摘要

经过前面几个章节的学习,大家应该已经对巡检模块的整体设计以及各个部分的实现都有了清晰的了解,但之前的代码其实只是小试牛刀,主要是为了让大家能够更方便的理解各个部分的功能,并且让刚接触较为复杂的程序设计的朋友更容易上手。

今天的章节中,我们会把巡检的代码和新手村中的CMDB结合起来,将巡检集成到Flask后端应用中,并且对其中命令筛选和设备筛选进行重构。

设备/命令Handler实现

之前的章节中DeviceHandler和ActionHandler都各自实现了一个具备增删改查功能的子类,今天我们就用ORM将其改造一下,使其能结合到Flask应用中。

ActionORMHandler

首先创建Action的数据表

CREATE TABLE `action` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(64) NOT NULL COMMENT '动作名称',
  `description` varchar(256) DEFAULT NULL COMMENT '动作描述',
  `vendor` varchar(64) DEFAULT NULL COMMENT '厂商',
  `model` varchar(64) DEFAULT NULL COMMENT '型号',
  `cmd` varchar(256) NOT NULL COMMENT '命令行',
  `type` varchar(8) DEFAULT NULL COMMENT '命令类型[show|config]',
  `parse_type` varchar(8) DEFAULT NULL COMMENT '解析类型[regexp|textfsm]',
  `parse_content` varchar(1024) DEFAULT NULL COMMENT '解析内容',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

然后定义两个与数据模型相关的工具方法,负责从数据模型转成字典,或者从字典转成数据模型

# utils.py
from typing import Dict, Any


def to_model(cls: Any, **kwargs: Dict) -> Any:
    """
    根据关键字参数生成对象
    :param cls: ClassVar 目标对象
    :param kwargs: Dict 关键字字典
    :return: ClassVar
    """
    device = cls()  # 实例化一个对象
    columns = [c.name for c in cls.__table__.columns]  # 获取模型定义的所有列属性的名字
    for k, v in kwargs.items():  # 遍历传入kwargs的键值
        if k in columns:  # 如果键包含在列名中,则为该对象赋加对应的属性值
            setattr(device, k, v)
    return device


def to_dict(self: Any) -> Dict:
    """
    将实例对象的属性生成字典
    :param self: ClassVar 对象实例
    :return: Dict
    """
    return {c.name: getattr(self, c.name) for c in self.__table__.columns}

下面在原先的app.py文件中定义Action的数据模型

# app.py
from utils import to_model, to_dict


class Action(db.Model):
    __tablename__ = "action"
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    name = db.Column(db.String(64), nullable=False, comment="动作名称")
    description = db.Column(db.String(256), comment="动作描述")
    vendor = db.Column(db.String(64), comment="厂商")
    model = db.Column(db.String(64), comment="型号")
    cmd = db.Column(db.String(256), nullable=False, comment="命令行")
    type = db.Column(db.String(8), comment="命令类型[show|config]")
    parse_type = db.Column(db.String(8), comment="解析类型[regexp|textfsm]")
    parse_content = db.Column(db.String(1024), comment="解析内容")

    @classmethod
    def to_model(cls, **kwargs) -> Dict:
        return to_model(cls, **kwargs)

    def to_dict(self) -> db.Model:
        return to_dict(self)

有了数据模型之后,就可以定义ActionORMHandler类,该类初始化的时候接收一个handler参数,该参数就是SQLAlchemy的db实例,可以通过这个handler来操作数据库,实现增删改查,代码如下:

# action.py
from app import Action


class ActionORMHandler(ActionHandler):
    def __init__(self, handler):
        self.handler = handler

    def add(self, args: List[Dict]):
        if self.handler is None:
            raise Exception("has no active db handler")
        actions = []
        for item in args:
            actions.append(Action.to_model(**item))
        self.handler.add_all(actions)
        self.handler.commit()

    def delete(self, args: List[int]):
        if self.handler is None:
            raise Exception("has no active db handler")
        Action.query.filter(Action.id.in_(args)).delete()
        self.handler.commit()

    def update(self, args: List[Dict]):
        if self.handler is None:
            raise Exception("has no active db handler")
        for item in args:
            if "id" not in item:
                continue
            Action.query.filter_by(id=item.pop("id")).update(item)
        self.handler.commit()

    def get(self, filters: Optional[Dict] = None):
        return Action.query.filter_by(**(filters or {})).all()

利用orm进行增删改动作之后,均需要调用commit完成提交,否则操作会被回滚。

DeviceORMHandler

由于之前我们已经在app.py中定义过Device和DeviceDetail的数据模型,但之前的章节中给device_detail表增加了两列,所以现在需要修改DeviceDetail模型的属性,代码如下:

# app.py
from sqlalchemy import text, DateTime, Numeric
from ..utils import to_model as tm, to_dict as td


class Devices(db.Model):
    __tablename__ = "devices"
    sn = db.Column(db.String(128), primary_key=True, comment="资产号")
    ip = db.Column(db.String(16), nullable=False, comment="IP地址")
    hostname = db.Column(db.String(128), nullable=False, comment="主机名")
    idc = db.Column(db.String(32), comment="机房")
    vendor = db.Column(db.String(16), comment="厂商")
    model = db.Column(db.String(16), comment="型号")
    role = db.Column(db.String(8), comment="角色")
    created_at = db.Column(db.DateTime(), nullable=False, server_default=text('NOW()'), comment="创建时间")
    updated_at = db.Column(db.DateTime(), nullable=False, server_default=text('NOW()'), server_onupdate=text('NOW()'), comment="修改时间")

    detail = db.relationship("DeviceDetail", uselist=False, backref="device")
    ports = db.relationship("Ports", uselist=True, backref="device")

    @classmethod
    def to_model(cls, **kwargs):
        return tm(**kwargs)

    def to_dict(self):
        res = {}
        for col in self.__table__.columns:
            val = getattr(self, col.name)
            if isinstance(col.type, DateTime):  # 判断类型是否为DateTime
                if not val:  # 判断实例中该字段是否有值
                    value = ""
                else:  # 进行格式转换
                    value = val.strftime("%Y-%m-%d %H:%M:%S")
            elif isinstance(col.type, Numeric):  # 判断类型是否为Numeric
                value = float(val)  # 进行格式转换
            else:  # 剩余的直接取值
                value = val
            res[col.name] = value
        return res


class DeviceDetail(db.Model):
    __tablename = "device_detail"
    sn = db.Column(db.String(128), db.ForeignKey(Devices.sn, ondelete="cascade"), primary_key=True, comment="资产号")
    ipv6 = db.Column(db.String(16), nullable=False, comment="IPv6地址")
    console_ip = db.Column(db.String(16), nullable=False, comment="console地址")
    row = db.Column(db.String(8), comment="机柜行")
    column = db.Column(db.String(8), comment="机柜列")
    last_start = db.Column(db.DateTime(), comment="最近启动时间")
    runtime = db.Column(db.Integer, comment="运行时长")
    image_version = db.Column(db.String(128), comment="镜像版本")
    over_warrant = db.Column(db.BOOLEAN, comment="是否过保")
    warrant_time = db.Column(db.DateTime(), comment="过保时间")
    device_type = db.Column(db.String(32), comment="远程连接设备类型")
    channel = db.Column(db.String(8), comment="远程连接方式[ssh|telnet]")
    created_at = db.Column(db.DateTime(), nullable=False, server_default=text('NOW()'), comment="创建时间")
    updated_at = db.Column(db.DateTime(), nullable=False, server_default=text('NOW()'), server_onupdate=text('NOW()'), comment="修改时间")

    @classmethod
    def to_model(cls, **kwargs):
        return tm(**kwargs)

    def to_dict(self):
        return td(self)

修改好数据模型后就可以利用其实现DeviceORMHandler,代码如下:

# device.py
from app import Devices, DeviceDetail


class DeviceORMHandler(DeviceHandler):
    def __init__(self, handler: scoped_session):
        self.handler = handler

    def add(self, args: List[Dict]):
        if self.handler is None:
            raise Exception("has no active db handler")
        devices = []
        for item in args:
            device = Devices.to_model(**item)
            device.device = DeviceDetail.to_model(**item)
            devices.append(device)
        self.handler.add_all(devices)
        self.handler.commit()

    def delete(self, args: List[int]):
        if self.handler is None:
            raise Exception("has no active db handler")
        Devices.query.filter(Devices.sn.in_(args)).delete()
        self.handler.commit()

    def update(self, args: List[Dict]):
        if self.db_handler is None:
            raise Exception("has no active db handler")
        for item in args:
            if "sn" not in item:
                continue
            sn = item.pop("sn")
            if "detail" in item:
                DeviceDetail.query.filter_by(sn=sn).update(item.pop("detail"))
            Devices.query.filter_by(sn=sn).update(item)
        self.db_handler.commit()

    def get(self, filters: Optional[Dict] = None) -> List[Devices]:
        return Devices.query.filter_by(**(filters or {})).all()

文件拆分

在定义了很多个数据模型之后大家应该会发现,app.py文件已经变得非常冗长,Model和Route混在一起,让代码开始变得难以阅读和维护,这时候就需要进行适当的文件拆分了。

大部分朋友第一反应应该就是,将Model拆出去写在另一个文件不就好了?就像如下:

# model.py
from app import db

class Action(db.Model):
    pass


class Devices(db.Model):
    pass
    
    
class DeviceDetail(db.Model):
    pass

拆完后model.py中需要引用ORM的db对象,这个对象是在app.py中创建的。

device.py和action.py文件中的ORMHandler,需要引用到Action和Devices的Model类,app.py中的路由函数如果需要提供命令和设备的增删改查接口的话,就需要从device.py或action.py中引入ORMHandler,这时候就出现了让人非常头疼的circular import,也叫循环引用;如下图所示:

zabbix自动化巡检报告 运维自动化巡检_自动化

当一个项目或者应用的体积初步变大的时候,出现循环引用的情况是必然现象,这时候就需要对项目的整体布局进行合理的规划。

Flask大型应用

下面我会详细给大家讲解如何进行Flask大型应用的布局规划,并把巡检模块集成到后端中。

工厂函数创建应用

根据上面的图示可知,循环引用的最源头其实就是app对象,所以现在首先就是要把app拆分出来,使用“应用程序工厂”来创建app,代码如下:

# __init__.py
from flask import Flask

def create_app() -> Flask:
    app = Flask(__name__)
    # 挂载各种中间件
    # ...
    return app
# run.py
from . import create_app

app = create_app()


if __name__ == "__main__":
    app.run()

“应用程序工厂”的意思就是在一个函数里把app给“生产”出来,在工厂里把数据库初始化完成,或者在app上挂载其他的对象,这些下面会提到。

ORM集成

我们需要使用到SQLAlchemy作为ORM的第三方库,并将其注册到app上,除此之外将model进行拆分,代码如下:

# /models/__init__.py
from flask_sqlalchemy import SQLAlchemy


db = SQLAlchemy()
# /models/action.py
from . import db


class Action(db.Model):
    pass
# /models/device.py
from . import db


class Devices(db.Model):
    pass
    

class DeviceDetail(db.Model):
    pass
# /__init__.py
from config import config
from model import db


def create_app(env: str = "dev") -> Flask:
    app = Flask(__name__)
    # register db
    db.init_app(app)
    return app

这里大家可能发现,在models/init.py中的db没有传入app参数,是不是没有注册在Flask的app上呢?

其实并不是,我们在create_app的工厂里面引入了models中的db对象,并使用的db.init_app(app)进行了注册,在程序启动一开始创建app的过程里已经把db这个对象注册过了,因为models/init.py是一个全局对象,所以后续用到的db都会是已经注册过的db。

路由蓝图

之前的章节中我们实现了对设备数据的增删改查,分别对应四个路由函数,那么现在又增加了命令数据的增删改查,后续又会有解析规则的增删改查,那么就有必要对路由文件进行拆分。

原先直接使用app.route()进行路由的注册,这种方式也会导致我们的路由文件依赖app,导致了循环引用的风险。

所以结合上述两点原因,把路由文件进行拆分,将路由函数进行分类,同一类的称作一个Blueprint(蓝图),代码如下:

# cmdb_view.py
from flask import Blueprint

cmdb_blueprint = Blueprint("cmdb", __name__, url_prefix="/cmdb")


@cmdb_blueprint.route("/get")
def get():
    return "cmdb"
# action_view.py
from flask import Blueprint

action_blueprint = Blueprint("action", __name__, url_prefix="/action")


@action_blueprint.route("/get")
def get():
    return "action"

如上述代码所示,从Flask中引入Blueprint对象,创建一个蓝图实例;

参数

name

Blueprint的第一个参数是name,这个参数起到了相当重要的作用,应用程序想要区分不用的蓝图模块就是靠这个参数,现在cmdb和action蓝图模块里都存在get函数,而且都把这个函数注册成了路由函数,如果实例化cmdb_blueprint和action_blueprint的时候没有传name参数或者传了相同的值(最新版Flask该参数要求必传),那程序启动就会报错,因为Flask不允许两个路由函数的endpoints重名(函数上添加了路由装饰器,就会将该函数名称作为路由的endpoints,例如"/get"路由对应的endpoints就是"get");

但是如果两个函数上加的是不同的蓝图装饰器,并且两个蓝图实例传入了不同name,那就会在路由注册的时候进行区分,大家可以理解成将两个函数分别注册成了“action_get”和“cmdb_get”,这样就可以避免endpoints重复。

import_name

这个参数通常传__name__,通过该参数定位还蓝图的根路径,不需要深究

url_prefix

这个参数也很重要,该参数的值会附加在路由的URL前面,比如action_blueprint注册的“/get”路由,当传入了url_prefix="/action"之后,这个函数的路由就变成了“/action/get”。

注册

有一点大家不知道发现没有,只有一个app.py的时候,创建了app,直接使用app.route进行路由注册,那现在拆分之后,Blueprint是从Flask直接引入的,貌似跟我们“工厂”中的app没有任何关系。

实际上到目前为止确实还没有关系,因为创建完蓝图之后还需要挂载到app,代码如下:

# __init__.py
from action_view import action_blueprint
from cmdb_view import cmdb_blueprint


def create_app() -> Flask:
    app = Flask(__name__)
    ...
    # register blueprint
    blueprints = [cmdb_blueprint, action_blueprint]
    for blueprint in blueprints:
        app.register_blueprint(blueprint)
    return app

挂载的形式其实在我看来是大型应用解决循环引用的一个非常好的方式。

配置

在任何的项目中配置管理都是非常重要的一部分,通常一个应用会存在多种环境,至少会有“开发环境”和“生产环境”两个,不同环境的配置是不一样的。

Flask管理配置也有不同的方式,有通过文件管理的,也有直接通过变量来管理的,我们采用一个通过类的管理方式,代码如下:

# config.py
class Config:
    DEBUG = False
    LOG_LEVEL = "info"
    SQLALCHEMY_ECHO = False

首先定义一个配置基类,其中包含三项基本配置,下面再定义两个不同环境的配置类,继承自基类,代码如下:

# config.py
class Config:
    DEBUG = False
    LOG_LEVEL = "INFO"
    SQLALCHEMY_ECHO = False


class DevelopmentConfig(Config):
    DEBUG = True
    LOG_LEVEL = "DEBUG"
    # 查询时会显示原始SQL语句
    SQLALCHEMY_ECHO = True
    # 数据库连接格式
    SQLALCHEMY_DATABASE_URI = "mysql+pymysql://root:YfyH98333498.@localhost:3306/python_ops?charset=utf8"
    # 动态追踪修改设置,如未设置只会提示警告
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    # 数据库连接池的大小
    SQLALCHEMY_POOL_SIZE = 10
    # 指定数据库连接池的超时时间
    SQLALCHEMY_POOL_TIMEOUT = 10
    # 控制在连接池达到最大值后可以创建的连接数。当这些额外的 连接回收到连接池后将会被断开和抛弃。
    SQLALCHEMY_MAX_OVERFLOW = 2


class ProductionConfig(Config):
    pass


config_mapper = {
    "dev": DevelopmentConfig,
    "prod": ProductionConfig,
}

在“工厂应用”中给app做配置,通过环境变量区分应该使用哪一个环境的配置类,代码如下:

# __init__.py
from config import config_mapper


def create_app(env: str = "dev") -> Flask:
    app = Flask(__name__)
    # register configuration
    app.config.from_object(config_mapper[env])
    # register blueprint
    # ...
    return app
# run.py
import os
from . import create_app

env = os.getenv("env", "dev")
app = create_app(env)


if __name__ == '__main__':
    app.run()

到目前为止已经完成了Flask应用到初步改造,下面就需要对项目的目录稍作调整即可。

项目布局

完整的项目布局如下:

/application --------- 应用目录
||models ------------ 数据表模型目录
||  |__init__.py
||  |action.py ------- 执行命令模型
||  |cmdb.py --------- 设备模型
||  | 
||views ------------- 路由目录
||  |__init__.py
||  |action.py ------- 执行命令的路由
||  |cmdb.py --------- 设备信息的路有
||  |
||services ---------- 业务逻辑目录
||  |action.py ------- 获取命令的逻辑代码
||  |cmdb.py --------- 获取设备的逻辑代码
||  |executor.py ----- 执行器的逻辑代码
||  |
||__init__.py ------- 应用工厂目录
|config.py ----------- 配置文件
|utils.py ------------ 工具方法文件
|run.py ------------- 启动文件