0
点赞
收藏
分享

微信扫一扫

网络自动化jinja2最佳实践

斗米 2023-05-11 阅读 86

《从零开始NetDevOps》是本人8年多的NetDevOps实战总结的一本书(且称之为书,通过公众号连载的方式,集结成册,希望有天能以实体书的方式和大家相见)。

NetDevOps是指以网络工程师为主体,针对网络运维场景进行自动化开发的工作思路与模式,是2014年左右从国外刮起来的一股“网工学Python"的风潮,最近几年在国内逐渐兴起。本人在国内某大型金融机构的数据中心从事网络自动化开发8年之久,希望能通过自己的知识分享,给大家呈现出一个不同于其他人的实战为指导、普适性强、善于抠细节、知其然知其所以然风格、深入浅出的NetDevOps知识体系,给大家一个不同的视角,一个来自于实战中的视角。

由于时间比较仓促,文章中难免有所纰漏,敬请谅解,同时笔者也会在每个章节完成后进行修订再发布,欢迎大家持续关注



第五章 配置模板引擎jinja2

5.1 配置标准化及模板引擎

配置标准化其实是我们一直在探讨的事情,也是困扰很多网络运维管理人员的一大难题。

配置标准化,它包括两方面:

  1. 我们要刷的配置是符合制定的标准,不仅要没有语法错误,且与我们的意图是一致的。
  2. 我们在线设备的配置应该是符合制定的标准的。

这两个方面其实是一个数据与文本配置相互转换的两个过程:前者聚焦于数据到文本,我们的意图数据能准确无误地翻译成设备配置;后者笔者认为是文本到数据的过程,网络配置解析成结构化数据,对结构化数据进行一些逻辑处理,比如是否配置了syslog服务器,各个分区的网络设备是否指向了对应的syslog服务器等。

后者基于我们讲过的网络解析相关章节结合Python代码就可以实现,而我们今天就来讲讲前者,标准配置的生成。

配置的生成看似很简单的一个问题,当我们仔细去回想,在实际运作过程中,我们真的做好了吗?

答案是未必,我们来从个人与团队两个方面来看看这个问题。

个人:散兵游勇,机械重复

我们很多工程师也都有自己的“宝典”,比如放到一个文件夹里,或者整齐或者凌乱,每次需要生成配置就摘出来一段以前的,然后改一改参数,生成一段新的配置刷入到网络设备中。这是很多网络工程师的一个工作思路。遇到细心的,会在自己的宝典里备注一些规则,改的时候会认真仔细一些。遇到熟悉的命令配置,可能查都不查,脑子里就可以浮现出来,直接写下配置。这种写出来的配置,完全是没有品控可言,可能存在语法错误,这种在刷入配置的时候就会报错,最怕的是那种没有语法错误,但是与我们的意图南辕北辙,这是最最危险的情况。本来要添加一个vlan,结果变成了初始化vlan并添加一个vlan。

团队:流于表面,难以持久

当网络运维的组织意识到了需要管理配置,需要标准化,然后花大力气去写word文档,制作精良的用户手册,细细的描述配置生成的各种规则限制,然后再配置模板里把一些可变的参数用特殊的字符标记一下。非常详细的标准化手册,你按照上面的去写配置品控是有所进步的,也可以作为新手的学习手册,理论上不会出错。但是这个很难坚持下来,总会有不遵守配置标准文档自己写的情形出现,同时并不排除看文档的时候走神、检查不仔细等等各种情况的频发。因为人毕竟不是机器,总会有所懈怠。

运维事故的发生,很多时候都是由于变更引起的,如果变更配置没有一个比较好的运作方式,紧靠人海战术,这对生产来说就是一个很大的隐患。

我们需要的是能长期坚持下去的配置生成标准化流程,同时还要减少人的重复、枯燥、机械性的劳动,无疑我们需要借助自动化。一个比较好的方法是让运维人员“远离”配置,我们在出配置环节,离配置越近越容易引起这种安全隐患。网络工程师的日常之一就是配置,而在出配置环节又要远离配置,这个说法好似有点矛盾,但是我们转变一下思路,这个矛盾就可以化解,我们对网络的配置修改,基于我们的意图,抽象出要改的配置项的相关参数,然后通过程序自动生成最终刷入设备的配置文本。我们只要保证意图和对应的参数是正确的,程序就会生成准确无误的配置,不会出现语法错误,不会出现业务逻辑错误。

那什么是意图?意图就是我们想修改网络的那些配置,可以对应前文提到的一些各类配置模板和其标准。而意图的参数,就是我们要将这个配置模板的某些内容修改为哪个目标值,这种事情交给程序来做最合适不过了。

我们可能会联想到字符串的格式化,类似如下代码,貌似也可以实现差不多的效果。

intf_template = """interface {name}
description {desc}
"""

name = 'Eth1/1'
desc = 'connect to app01'

intf_config = intf_template.format(name=name, desc=desc)
print(intf_config)

这种Python代码可以实现类似的方式,保证我们的配置的标准及准确。但是有两个弊端:

  1. 复杂逻辑的配置,在代码中需要众多的判断循环,没有一个视觉上统一的模板。
  2. 每针对一个标准模板,都要写一段代码,耦合度比较高,且不够灵活。

针对以上特点有没有一种可以将代码和参数解耦的方式呢?

答案是肯定的,那就是模板引擎!

什么是模板引擎?

这其实是Web开发中比较常用的一个名词,它是为了将业务数据和用户界面分离而产生的一种工具。众所周知,web界面都是由HTML语言编写的,其本质是文本,每次渲染页面,业务数据可能变化。比如我们的一些个人信息页,其样式大体是固定的,改变的主要是我们的用户名、邮箱、头像等个人信息。所以开发者们发明了模板引擎,用户访问指定个人信息页时,Web程序先获取指定页的模板(包含了众多样式和界面的基本布局),同时获取用户信息,从后台读取用户信息后,将用户信息渲染到指定页面模板,这样就实现了千人千面的效果。

从这个描述中,我们可以窥探到,这和我们的诉求非常相似,我们也需要配置模板,不同的配置项对应不同的配置模板,想要进行变更时,先选配置模板,然后传入配置参数,渲染成我们的最终配置文本。这个过程,让用户远离了配置文本的编写,可以有效地防止语法错误和业务逻辑错误,让配置标准化、规范化、准确化。

在Web开发中,有着众多的模板引擎,那在NetDevOps领域有没有比较好的模板引擎呢?

答案就是jinja2!

5.2 jinja2简介

jinja2是一个纯Python开发的模板引擎。从发音上大家也可以猜出,它是一个日系的名称,它的名字是日语庙宇的意思,因庙宇的英文temple与模板的英文template发音相近,所以起了这个名字。在很早之前有一个jinja的Python包的,但是在版本2当中,jinja单独发布了一个jinja2的包,即使现在它的版本已经到了3,但是由于其广泛使用,包的名字也继续使用jinja2。

它最早是给Python的web开发使用的模板引擎,在一定程度上其语法规则参考了django(Python的一个web框架)内置的模板引擎,但是jinja2只是内聚了一个功能——模板引擎。NetDevOps兴起之时,在寻找模板引擎时,大家都关注到了jinja2,因为它是基于普通文本的一种模板引擎,语法简洁,可扩展性强,使用灵活,功能强大,可以非常方便地生成配置。

jinja2是一个第三方包,所以我们需要借助pip安装,执行命令行“pip install jinja2”即可。本书内容使用的jinja2版本是3.1.2,我们在安装时也可以指定版本,执行“pip install jinja2==3.1.2"即可。

接下来我们就可以使用jinja2实现通过模板生成指定文本了。

from jinja2 import Template

templ = Template("Let's study {{ course }} now!")
result = templ.render(course='NetDevOps')
print(result)
# 输出Let's study NetDevOps now!

我们通过模板文本(字符串)加载了一个Template的模板对象templ,然后调用这个对象的render方法,给模板中的变量course赋值为“NetDevOps”,最终返回一段渲染后的文本。

这一段代码乍看和Python用字符串实现的效果差不多,大家不要着急,接下来我们为大家讲解一下jinja2的语法规则,再结合实例给大家展现其魅力所在。

5.3 jinja2基础语法

在讲解jinja2语法的时候,为了便捷直观,我们先暂时将模板与代码耦合在一起,后面我们会慢慢讲解一些高阶用法,将模板与代码剥离,甚至将数据与代码剥离。

jinja2是一种模板引擎,类似于其他的Web模板系统,它也支持变量的定义、判断、循环这些最基础的语法,我们先从基础的语法讲起,结合使用场景遇到的问题,我们再会讲解一下空白符的控制和注释的编写。

5.3.1 变量

jinja2的模板中定义变量直接使用双花括号,然后在其内定义一个变量的名称,形如是{{ variable }},变量名称左右一般各留一个空格,为了提高易读性,变量无需也无法事先定义,随用随定义。变量在一段文本中对于“变”的部分直接“抠”出,替换为jinja2的变量定义即可,我们可以定义众多变量。在渲染的时候我们给对应的参数赋值即可。

from jinja2 import Template

templ_str="""interface {{ name }}
 description {{ desc }}
 undo shutdown
"""
templ = Template(templ_str)
result = templ.render(name='Eth1/1',desc='gen by jinja2')
print(result)

输出结果为:

interface Eth1/1
 description gen by jinja2
 undo shutdown

当然输出方式我们也可以通过文本操作写入文本文件。

我们先通过字符串定义了一个模板,有两个变量——“name”和“desc”。调用Template类进行初始化,可以通过字符串赋值给第一个参数source(代码中省略)加载出一个模板对象。Template实例化后的对象有一个render(渲染)的方法,它可以接受可变参数,简单理解就是我们在模板定义的变量,在这里都可以进行赋值。

我们赋值可以是任何Python数据类型,比如字符串、数字、字典、列表等,包括自定义的对象,但最终都会被jinja2以类似强制转为str类型渲染到指定位置。

from jinja2 import Template

templ_str = """我们可以传入数字,传入的数字为:{{ my_num }},
我们可以传入字典,传入的字典为:{{ my_dict}},
我们可以传入列表,传入的列表为:{{ my_list}},
"""
templ = Template(templ_str)
result = templ.render(my_num=100,
                      my_dict={'course': 'NetDevOps'},
                      my_list=['1', 2, {"course": "NetDevOps"}]
                      )
print(result)

其输出结果为:

我们可以传入数字,传入的数字为:100,
我们可以传入字典,传入的字典为:{'course': 'NetDevOps'},
我们可以传入列表,传入的列表为:['1', 2, {'course': 'NetDevOps'}],

所有的传入的变量都被强制转为了str字符串类型后渲染。

同时字典和列表还可以保留原有的访问方式,在模板中直接取其成员的值。

from jinja2 import Template

templ_str = """访问传入字典的成员值:{{ my_dict['course'] }},
访问传入的列表的成员值:{{ my_list[2] }},
"""
templ = Template(templ_str)
result = templ.render(my_num=100,
                      my_dict={'course': 'NetDevOps'},
                      my_list=['1', 2, {"course": "NetDevOps"}]
                      )
print(result)

其输出结果为:

访问传入字典的成员值:NetDevOps,
访问传入的列表的成员值:{'course': 'NetDevOps'},

在渲染的时候,我们可以传入多余的参数,这并不会报错。我们也可以不传入模板中期望的参数,默认情况下也不会报错,只会让对应位置渲染不上数据。

5.3.2 判断

在出配置的时候我们会根据提供的参数进入到不同的分支,比如刚才的端口配置示例,我们默认是配完端口后开启端口。如果用户想部分端口开启,部分端口关闭呢?这个时候我们可以添加一个变量shutdown,对其进行判断进入不同分支,展示不同的内容。

jinja2的判断语法与Python中的判断语法极其相似,可以和else elif组合。判断和循环是属于控制结构,控制结构在默认语法中以 {% .. %} 块的形式出现,在中间我们编写控制相关的逻辑,进行逻辑表达式的编写,与Python的逻辑表达式几乎一致。有一点需要注意的,jinja2中的控制结构都要有对应的结束控制结构,if对应的结束是endif。在控制结构后,我们可以输入满足条件会渲染的模板。比如一个简单的判断端口开启关闭配置的模板我们可以简单书写如下:

{% if shutdown=='yes' %}
shutdown
{% elif shutdown=='no' %}
undo shutdown
{% else %}
请人工确认端口状态配置
{% endif %}

从中我们可以窥探到jinja2中对于判断的使用,加上相关的Python代码如下:

from jinja2 import Template

templ_str = """interface {{ name }}
 description {{ desc }}
{% if shutdown=='yes' %}
shutdown
{% elif shutdown=='no' %}
undo shutdown
{% else %}
请人工确认端口状态配置
{% endif %}"""

templ = Template(templ_str)
result = templ.render(name='Eth1/1', desc='gen by jinja2', shutdown='no')
print(result)

其输出结果为:

interface Eth1/1
 description gen by jinja2

undo shutdown

根据我们输入的shutdown的值,模板会产生相应的变化。

5.3.3  空白控制

细心的同学会发现,为什么生成的文本中会多出来一个空白行。这其实是判断控制那行文本所导致的,我们来通过一个示例来演示一下。

from jinja2 import Template

templ_str = """您输入的两个数字为:a={{ a }}、b={{ b }},{% if a>b %}a>b{% endif %}"""

templ = Template(templ_str)
result = templ.render(a=2, b=1)
print(result)

其运行结果为:

您输入的两个数字为:a=2、b=1,a>b

这个示例,我们想表达的是:jinja2中的控制模块是无需缩进的,它是通过特定控制符号及其内标签(if、elif、else)来确定当前逻辑,通过控制符号及其内标签(endif)来确定控制逻辑结束的位置,所以我们在一行之内也可以实现一个判断甚至多个判断。当符合条件时就会输入对应逻辑分支里的内容比较数字的示例中是输出a与b的关系。

反观端口配置的模板,我们仔细观察,那个空白行来自于哪里呢?

空白行来自于条件成立时的“{% elif shutdown=='no' %}”,在模板中控制结构后面还有一个换行符,当此条件成立时后面的换行符则会保留。

如果我们想去除这个换行符,最简单的方式是使用空白控制,即在控制符的内侧添加减号,它会去除所属控制符左侧或者右侧的紧邻它的所有连续的空白符。如果放在左侧的控制符,则去除左侧的所有空白符,如果放在右侧的控制符则去除右侧的所有空白符。

比如端口示例中是由于右侧有换行符,我们可以选择去除右侧的换行符,在所有判断分支的控制部分使用“-%}”,所以将模板修改为:

from jinja2 import Template

templ_str = """interface {{ name }}
 description {{ desc }}
{% if shutdown=='yes' -%}

  shutdown
{% elif shutdown=='no' -%}
undo shutdown
{% else -%}
请人工确认端口状态配置
{% endif -%}"""

templ = Template(templ_str)
result = templ.render(name='Eth1/1', desc='gen by jinja2', shutdown='yes')
print(result)

由于我们在每个控制结构的右侧添加了减号(必须与百分号紧邻,不允许有空格),所以换行被取消了。同时我们刻意在模板中的第一个判断后又添加了一个空白行,shutdown后添加了两个空格,但是结合我们刚才所讲的空白控制,它会消除紧邻它右侧所有的连续的多个空白符,所以两个换行和两个空格都会被取消,结果为:

interface Eth1/1
 description gen by jinja2
shutdown

我们会发现没有了空白行,shutdown左侧的空格也消失了。当然我们也可以在左侧的百分号添加减号,实现左侧空白符的去除,但是一般而言,在网络配置模板中,适当在单侧(笔者推荐右侧)添加空白控制,这样出来的配置基本是没问题的。大家可以在实践中结合具体的模板去适当调整。实际使用中绝大多数场景,笔者推荐大家将判断控制的单独占用一行,这样可以提高可读性。

5.3.4 循环

控制结构除了判断,必须要讲的就是循环。当我们传入了一个可以迭代对象的时候,jinja2就可以对此对象进行循环迭代,读取其中成员,进行相关模板的渲染。端口模板示例中我们是针对单独一个端口进行了配置渲染。实际生产中,我们可能会对众多端口进行配置生成。这个时候,传入的参数不会是单个端口成员,而是一个列表对象。在模板中,我们需要对这种列表对象(实际可以是任意可迭代的对象)进行循环,读取其中成员,在模板中完成渲染。

jinja2的循环也是一种逻辑控制,循环标签是for,同时需要加上控制符。用法上也类似Python的for循环,比如我们模板中定义一列表数据,循环渲染。

from jinja2 import Template

templ_str = """{% for i in data_in_list %}
列表数据中包含:{{ i }}
{% endfor %}"""

templ = Template(templ_str)
result = templ.render(data_in_list=[1,2,3])
print(result)

每次循环的成员我们可以自己给它定义一个变量名称,如同Python中的for循环一样。每次循环调用都需要有一个结束控制endfor。在循环体当中,我们可以对循环取出的对象进行操作,如果其他对其他变量的操作一样。上述代码输出结果为:

列表数据中包含:1

列表数据中包含:2

列表数据中包含:3

我们发现还会有一些空白行,其原因是for循环所在的那行产生的,我们可以使用空白控制在右侧的控制符号前加一个减号,去除右侧的空白符。

列表数据中包含:1
列表数据中包含:2
列表数据中包含:3

这样可以让文本比较符合我们的预期。当然,信心的同学会发现最后一仍有一个空白行,那个是endfor和上一行的换行产生的空白行,实际上,处理上比较麻烦,因为如果在endfor左侧添加空白控制,会将换行一并去除。由于对出配置而言影响不大(只会在块的最后多处一个空白行),建议不处理。

对迭代产生的局部变量,我们可以取其属性或者使用其一些方法。回归到我们之前提的端口的配置生成,我们希望处理多个端口,所以传入一个字典的列表,每个字典是对一个端口的相关配置数据。使用jinja2的循环,我们将它展开渲染。

from jinja2 import Template

templ_str = """{% for intf in data -%}
interface {{ intf['name'] }}
 description {{ intf.desc }}
{% if intf.shutdown=='yes' -%}
shutdown
{% elif intf.shutdown=='no' -%}
undo shutdown
{% else -%}
请人工确认端口状态配置
{% endif -%}
{% endfor -%}"""

templ = Template(templ_str)
data = [{'name': 'Eth1/1', 'desc': 'gen by jinja2', 'shutdown': 'yes'},
        {'name': 'Eth1/2', 'desc': 'gen by jinja2', 'shutdown': 'no'}]
result = templ.render(data=data)
print(result)

使用jinja2的for加控制符进行for循环,一定要以endfo加控制符结束。其中data是我们需要从Python程序中传入的变量,intf是承载当前循环成员信息的变量,在整个for循环块中可以使用jinja2对变量的语法操作去使用它。对于字典取值,我们可以直接用Python中字典的取值方式取值,也可以使用类似对象的属性访问方法(用点加字段名称),这种可读性好,编写简单。示例中为了做展示,两种方式都有采用。同时为了对格式调整,添加了一些空白控制,也都在右侧。这样能保证没有多余的空白行,也不会串行(空白控制符出现在左侧特别容易串行)。上述代码运行结果如下:

interface Eth1/1
 description gen by jinja2
shutdown
interface Eth1/2
 description gen by jinja2
undo shutdown

jinja2的循环实际上还有很多其他特殊的局部变量,但是对于我们出网络配置而言,意义不大,故不做过多展开。

5.4 jinja2进阶用法

通过对jinja2的基础语法进行讲解,大家已经对jinja2有了初步的了解,也可以在代码中去实现。接下来,我们为大家介绍一些偏进阶的用法(部分是语法,部分是jinja2的API),这样可以更进一步提高我们模板和代码的灵活性。

5.4.1 通过文件系统加载模板

上述示例中,我们的模板仍然和代码耦合在一起,我们实际可以把模板写到文本文件中(多以j2作为后缀),然后通过Python的文本操作加载成字符串进而加载成模板对象。实际上jinja2已经为我们提供了类似的功能,甚至更加强大。

这就涉及到了两个非常重要的类Environment和FileSystemLoader。

  1. Environment是jinja2最核心的概念之一,当我们简单使用jinja2的时候我们对此无感知,但是当我们想比较成体系的组织我们的jinja2模板时就需要使用Environment了。Environment里包含非常重要的一些共享的变量,诸如配置、过滤器、全局变量等,当我们将这些赋值实例化的时候,会加载出一个特定的Environment对象,用于处理我们的模板渲染。
  2. Environment实例化的时候需要加载我们的所有模板,涉及到一个参数加载器loader。jinja2内置了几种模板的加载器,比如FileSystemLoader可以通过文件目录加载模板,PackageLoader可以从Python包加载模板。笔者推荐大家使用FileSystemLoader,我们仅需指定目录就可以构造一个加载器。

我们基于FileSystemLoader来演示一下,用普通的目录及模板文件来构建一个jinja2的环境(Environment)对象。

首先我们创建一个目录,示例中我们命名为jinja2_templates,然后在目录中编写一个我们之前代码中使用过的模板,命名为jinja2_demo.j2,其内容为:

{% for intf in data -%}
interface {{ intf['name'] }}
 description {{ intf.desc }}
{% if intf.shutdown=='yes' -%}
shutdown
{% elif intf.shutdown=='no' -%}
undo shutdown
{% else -%}
请人工确认端口状态配置
{% endif -%}
{% endfor -%}

做了好模板等的准备之后,我们就着手进行代码的编写,先构建一个FileSystemLoader的对象,只需要赋值它的第一个参数searchpath为指定目录即可,之后通过赋值加载器给loader实现Environment对象的实例化,获取到一个基于指定目录的jinja2环境对象。

from jinja2 import Environment, FileSystemLoader

# 通过目录创建加载器
loader = FileSystemLoader("jinja2_templates")
# 通过文件系统加载器创建环境
env = Environment(loader=loader)
# 获取指定jinja2模板文件
template = env.get_template("jinja2_demo.j2")

data = [{'name': 'Eth1/1', 'desc': 'gen by jinja2', 'shutdown': 'yes'},
        {'name': 'Eth1/2', 'desc': 'gen by jinja2', 'shutdown': 'no'}]
result = template.render(data=data)
print(result)

这个env对象就是我们创建的jinja2的“环境”,通过调用这个环境对象的get_template方法,我们可以快速的获取模板对象,并可以调用render方法进行渲染数据了。其结果为:

interface Eth1/1
 description gen by jinja2
shutdown
interface Eth1/2
 description gen by jinja2
undo shutdown

这种通过文件系统加载模板的方式,可以实现模板和代码的解耦,同时可以有效的组织起我们的模板,将日常的配置标准化,按照厂商、配置项、场景等多种维度有效地对模板进行组织。

5.4.2 通过文件承载渲染所需的数据

通过之前的文件系统加载模板的方式我们已经实现了模板与代码的分离,实际上我们可以进一步将配置数据与脚本分离,将数据放到表格或者yaml等。从文件中读取数据,加载jinja2模板,二者结合渲染生成配置文件。同时jinja2模板支持判断循环,可以避免在Python脚本中编写硬逻辑,jinja2模板还支持组合等等功能,这些都极大提高了配置准备的便捷和高效性。

由于表格中只能承载列表类数据,所以笔者推荐,即使是单条非表配置,我们在数据存储上也统一成列表格式,模板中统一定义为data,将数据编写到表格文件中承载。如果是单独的配置项,我们只需写一条,然后在模板中使用data[0]访问到数据。如果是列表数据,我们正常使用for循环迭代渲染即可。

按照这个思路,我们可以改造上述的端口配置脚本,我们先准备一个表格文件来存储配置数据,考虑到后续的延展性,笔者推荐使用Excel类的文件,而不是csv这种文件格式,因为前者可以有多个sheet,存放多种数据,可以实现更复杂的配置场景,而csv相对而言比较单一,大家根据实际情况进行选择。我们在第一个sheet创建相关数据,文件名命名为data.xlsx,其数据内容如下

name

desc

shutdown

Eth1/1

gen by jinja2

yes

Eth1/2

gen by jinja2

no

然后将上述代码改造,使用我们之前讲解的处理表格文件的方式,使用pandas读取表格,并将数据加载成字典的列表格式。

from jinja2 import Environment, FileSystemLoader
import pandas as pd


def get_jinja2_templ(templ, dir='jinja2_templates'):
    # 通过目录创建加载器
    loader = FileSystemLoader(dir)
    # 通过文件系统加载器创建环境
    env = Environment(loader=loader)
    # 获取指定jinja2模板文件
    template = env.get_template(templ)
    return template


def get_data_from_excel(file='data.xlsx'):
    df = pd.read_excel(file)
    data = df.to_dict(orient='records')
    return data


if __name__ == '__main__':
    template = get_jinja2_templ(templ='jinja2_demo.j2')
    data = get_data_from_excel()
    result = template.render(data=data)
    print(result)

在这段代码中,我们将一些代码进行了封装,写了两个函数:

  1. get_jinja2_templ,指定模板目录(默认值)以及要调用的jinja2的模板文件,返回一个jinja2的模板对象。
  2. get_data_from_excel从指定表格文件加载出数据,以字典的列表形式返回给调用方。

二者都使用了一些默认的参数,我们也可以结合实际情况进行调整。

以上代码的配置输出部分采用了打印的方式,大家也可以根据实际情况输出到文本,也建议使用函数的方法进行封装。

如果是类似配置设备名称这种单配置项的模板,我们也可以使用上述代码,只需要将模板和表格数据能对应上即可,其中关键点是data是一个列表数据,所以模板中要取出第一个元素(下标为0)。

我们的表格数据如下:

hostname

netdevops

对应的模板调整如下:

hostname {{ data[0]['hostname'] }}

当然这个配置模板中还可以编写更多的类似的单配置项内容。模板的名称也可以改变,我们只需要通过改变脚本中的两个文件名即可,至此,数据和模板都与代码实现了结构解耦。当然我们也有更高级的方法,将我们的代码封装成可以用CLI传参调用的方式,这样甚至无需改变代码中的文件名,但相对而言笔者认为初学者掌握这些足够,故不详细展开这种方式。

5.4.3 模板的组合

当我们的设备中既有hostname等这种单配置项内容,又有很多端口的批量配置项内容时该如何优化我们的模板呢?

这个时候我们可以考虑将模板拆解成比较小的原子模块,然后使用jinja2的include语法对模板进行组合。

假设我们有两个模板A.j2和B.j2,我们可以基于A和B去组合构建第三个模板C.j2,在C.j2中直接使用include语法,声明要包含的模板文件路径即可。示例如下:

我们来演示两个模板文件组合生成一个新的模板文件
这是第一个模板文件A.j2渲染的文本
{% include 'A.j2' %}
这是第二个模板文件B.j2渲染的文本
{% include 'B.j2' %}

其语法相当简单,使用include加控制符,即可包含我们想复用的模板,我们的模板名称是字符串类型,需要用引号包裹住。include语法也无需使用类似end的符号声明结束。示例中我们A和B模板的内容会自动在此展开渲染,A模板和B模板中定义的变量,我们像正常使用一样传入即可。难点在于各个模板中的变量名称的控制,为了避免冲突,笔者建议传入的变量为字典,每个字典的key对应模板名称,模板中读取传入字典的数据,先读取对应字典key的值,然后渲染使用。

按照这个思路,我们以一个实际案例来展开讲解,假设我们要配置一台交换机的众多配置项,比如设备名称、NTP服务、Syslog服务器、端口等等,我们先把渲染所需的数据简单设计一下,它是从表格加载而来的,一个sheet页签对应一个配置项,受制于表格的承载方式,每个配置项内容都只能以字典的列表表示。所以我们渲染模板所使用的数据如下:

data = {
        'hostname': [{'hostname': 'netdevops'}],
        'ntp': [{'ip': '192.168.137.1'}],
        'syslog': [{'level': 'debugging',
                    'source_intf': 'vlanif15',
                    'loghost': '192.168.137.10',
                    }],
        'interface': [{'name': 'Eth1/1',
                       'desc': 'gen by jinja2',
                       'shutdown': 'yes'},
                      {'name': 'Eth1/2',
                       'desc': 'gen by jinja2',
                       'shutdown': 'no'}]
    }

这个数据是通过pandas从一个表格中读取的,相关代码我们可以通过如下函数实现:

def get_complex_data_from_excel(file='data.xlsx'):
    data = {}
    df_dict = pd.read_excel(file,sheet_name=None)
    for i in df_dict:
        data[i] = df_dict[i].to_dict(orient='records')
    return data

这个代码是基于我们之前讲解的pandas读取表格之法改造的,将表格加载到pandas的时候,我们一定要将sheet_name赋值为None,这样pandas会加载所有的sheet中的数据,整合成一个字典,key为sheet的名称,value是pandas的Dataframe对象。我们对字典进行循环迭代,把每个成员的Dataframe转成字典的列表数据即可。

我们只需要组织好表格中的数据即可,表格中的sheet名称一定要与我们设计的数据中的字典key名称一致,一些情况之下笔者建议与模板名称一致。数据设计完成之后,我们再准备几个独立的配置模板如下:

设备名称配置模板hostname.j2

hostname {{ data.hostname[0].hostname }}

设备ntp配置模板ntp.j2

ntp unicast-server {{ data.ntp[0].ip }} {% if data.ntp[0].vpn_instance %} vpn-instance {% endif %}

设备syslog配置模板syslog.j2

info-center source default channel 2 log level {{ data.syslog[0].level }}
info-center loghost source {{ data.syslog[0].source_intf }}
info-center loghost {{ data.syslog[0].loghost }}

设备端口配置模板interface.j2

{% for intf in data.interface -%}
interface {{ intf['name'] }}
 description {{ intf.desc }}
{% if intf.shutdown=='yes' -%}
shutdown
{% elif intf.shutdown=='no' -%}
undo shutdown
{% else -%}
请人工确认端口状态配置
{% endif -%}
{% endfor -%}

各类原子模板准备好之后,我们可以按照厂商甚至系列型号进行存放,以上配置,我们将其放入模板库的huawei文件夹内。然后在模板库中编写一个新的配置模板,我们命名为“my_include_demo.j2”,其内容如下:

{% include 'huawei/hostname.j2' %}

{% include 'huawei/ntp.j2' %}

{% include 'huawei/syslog.j2' %}

{% include 'huawei/interface.j2' %}

我们使用include标签将我们想引入的配置模板引入进来,中间可以适当添加空白行,让各个配置项上能有所间隔,提高配置可读性。我们也可以在这个配置模板中添加一些其他的jinja2的语法,比如添加进入配置模式和保存的命令:

system-view

{% include 'huawei/hostname.j2' %}

{% include 'huawei/ntp.j2' %}

{% include 'huawei/syslog.j2' %}

{% include 'huawei/interface.j2' %}

return
save
y

之后对代码进行调整,读取指定的使用了模板组合方式的新模板,读取表格中的数据,渲染模板,只需对我们之前的单配置项的脚本进行改造,将读取数据部分使用我们新写的函数get_complex_data_from_excel:

from jinja2 import Environment, FileSystemLoader
import pandas as pd


def get_jinja2_templ(templ, dir='jinja2_templates'):
    # 通过目录创建加载器
    loader = FileSystemLoader(dir)
    # 通过文件系统加载器创建环境
    env = Environment(loader=loader)
    # 获取指定jinja2模板文件
    template = env.get_template(templ)
    return template


def get_complex_data_from_excel(file='data.xlsx'):
    data = {}
    df_dict = pd.read_excel(file, sheet_name=None)
    for i in df_dict:
        data[i] = df_dict[i].to_dict(orient='records')
    return data


if __name__ == '__main__':
    data = get_complex_data_from_excel()
    template = get_jinja2_templ(templ='huawei/my_include_demo.j2')
    result = template.render(data=data)
    print(result)

上述脚本执行后的结果为:

system-view

sysname netdevops

ntp unicast-server 192.168.137.1 

info-center source default channel 2 log level debugging
info-center loghost source vlanif15
info-center loghost 192.168.137.10

interface Eth1/1
 description gen by jinja2
shutdown
interface Eth1/2
 description gen by jinja2
no shutdown


return
save
y

在不同的场景和实践中,我们要进行众多的配置项的配置,就可以使用这种方法,将配置项原子化,在使用中再按需进行组合,准备好对应实施场景的参数,自动生成配置,我们可以选择手工配置,也可以结合自动化脚本进行配置,当然自动化推送的前提是风险可控。

5.4.4 过滤器及其定制化

我们在处理配置中会遇到各种各样的情况,有时需要进行比较复杂的逻辑计算,或者对数据进行一些校验、格式化等,这个时候我们就可以用到过滤器。

过滤器filter是一个比较抽象的概念,我们在编程中经常遇到这种说法,究竟什么是过滤器?其实转念一想这个东西也比较好理解,过滤器,代表的是其功能,就好次饮水机中的过滤器,有5合一的过滤沙子颗粒的过来不齐,需要先有水入,在过滤器内部,水发生变化,变为了没有沙子的水,然后它还可以流经另外一个过滤器,用于过滤细菌和微生物的,过滤之后变为比较干净的无细菌和微生物的水。类比到编程中,就是将数据传入过滤器函数,然后过滤器函数内部可以进行一些逻辑处理,返回一个新的结果,当然也可以保留原有的结果。

在jinja2中我们将过滤器即一个符合标准的函数写入一个文件当中,然后将其配置到Environment环境对象中,这样就可以在模板中在指定变量后使用管道符对指定变量进行“过滤”处理,渲染出一个经过逻辑处理的结果。

其中jinja2中内置了很多和web开发相关的过滤器,比如处理数字(保留小数点后2位)、处理单词(首字母大写或者全大写)等等。笔者以其中一个提供默认值的jinja2内置过滤器为例,简单介绍其使用。

我们在写配置的时候,有时有些配置项会有一些默认值,我们就可以使用default过滤器,过滤器本质是一个函数,使用管道符写在对应的变量后面,函数得到第一个参数就是将变量传入,后面的参数按需传入,我们想渲染一个变量,如用户未传入或者传入的值为空,则会根据default中传入的默认值返回。

from jinja2 import Template

templ = Template("Let's study {{ course| default('NetDevOps') }} now!")
result = templ.render()
print(result)
# 输出Let's study NetDevOps now!

像示例中,我们并未向模板中传入course变量的值,但是由于我们调用了default过滤器,并传入了参数其默认值,所以模板会将默认值赋值给course变量。

jinja2的相关过滤器,我们可以参考其官方文档https://jinja.palletsprojects.com/en/latest/templates/#builtin-filters

在实际使用中,我们有时候需要对数据进行二次加工进而渲染,这个逻辑我们可以放到jinja2体系之外,但也可以放在jinja2模板引擎体系之内,因为jinja2支持我们以Python函数的方式定义过滤器并加载到Environment环境对象中。

自定义过滤器本质是一个函数,第一个参数约定俗成为value,对应的是模板中定义的变量,在变量后调用过滤器,会将从外部传入的值在模板中进行相关处理(比如取其成员)后传给对应的过滤器,过滤器可以按需添加其他参数,过滤器内部编写逻辑进行一系列操作,然后返回一个值(可以是字符串、也可以是字典、列表等等)。我们写一个对端口进行格式化的过滤器,并加载到环境对象中。

import re
INTF_MAPPING = {
    'eth': 'Ethernet',
    'Eth': 'Ethernet',
}

def interface_name_filter(name):
    intf_re = re.compile('(.+?)(\d+[\d/]*)')
    match = intf_re.match(name)
    if match:
        intf_type = match.group(1)
        intf_no = match.group(2)
        intf_type = INTF_MAPPING.get(intf_type, intf_type)
        return f'{intf_type}{intf_no}'
    return name

然后我们将其加载到环境对象中,加载方式为调用其filters的属性,其值是一个字典,key为过滤器名称,value为这个过滤器函数。我们通过字典赋值的方式,将其添加进去。

env = Environment(loader=FileSystemLoader('jinja2_templates'))
env.filters['interface_name_format'] = interface_name_filter

过滤器的名称我们可以进行根据自己的使用习惯定义, 不一定与函数名一致,这点大家要注意一下。

然后在模板中我们就可以使用这个过滤器了:

{{ interface_name|interface_name_format() }}

自定义的过滤器我们可以将其放置于一个Python的package中,比如我们放入my_filters中的format.py中。

上述代码进行整合:

from jinja2 import Environment, FileSystemLoader

from my_filters.format import interface_name_filter


def get_jinja2_templ(templ, dir='jinja2_templates'):
    # 通过目录创建加载器
    loader = FileSystemLoader(dir)
    # 通过文件系统加载器创建环境
    env = Environment(loader=loader)
    # 添加自定义过滤器
    env.filters['interface_name_format'] = interface_name_filter
    # 获取指定jinja2模板文件
    template = env.get_template(templ)
    return template


if __name__ == '__main__':
    template = get_jinja2_templ(templ='interface_with_filters.j2')
    result = template.render(interface_name='eth1/1')
    print(result)

这个模板相对比较简单,其输出结果为:Ethernet1/1,jinja2使用我们自定义的过滤器对齐进行了相关处理。当然本代码只是一个示例,大家根据自己的实际需求去创建自己的过滤器,比如对一些日期的格式化(临时访问控制可能会用到)、默认Vlan等等。

总结

至此jinja2对NetDevOps有助力部分的知识与大家分享完毕,其中配置模板鉴于技能树的“偏科”可能会有所纰漏,但是对于大家了解和使用jinja2毫无影响。

通过对jinja2的系统介绍,同时结合笔者提出的代码和模板以及数据分离的方式,可以让我们更加方便地实现配置标准化,提高配置的生成效率以及配置的准确性。当然大家也不必局限于笔者推荐的方法,毕竟黑猫白猫,抓到老鼠就是好猫。只要使用了jinja2,将模板配置标准化也已经是一大进步。


举报

相关推荐

0 条评论