cmdb系统设计

概念

CMDB: Configuration Management DB,配置管理数据库。它管理的是资产,核心是DB,即存放资产信息的系统,而在实际中,运维团队会围绕这个DB衍生出运维管理系统,该系统依赖CMDB中的基础信息。

传统设计模式

数据库表设计

资产类型表
    id
    name
如: 1 服务器, 2 交换机, 3 路由器

资产表
    id
    name
    资产类型id
    设备编号
如: 1 dell服务器 1 d1002933, 2 H3C路由器 3 r234000033

路由器表
    id
    配置信息
    机架信息
    责任人

...

由于资产的多样性,这样设计表,如果增加一个资产,那就需要创建表,如果一个已有的资产需要增加管理信息,那就需要增加字段,反之就需要删除字段或删除表,当然生产环境中当然不建议数据的真删除。

这种传统的设计,资产的多样性只能以增加表的方式来表达。

现代设计模式

需要充分考虑运维日常管理信息的复杂性,将管理信息所需要的表、字段、值抽离出来形成不同的表。

做模型设计时工具有许多,这里使用的是MySQLWorkbench,在使用MySQLWorkbench进行模型设计时一定要把目标mysql的版本设置成自己使用数据库的版本,因高版本有些语句不兼容低版本。设置方式:在Preference --> Modeing --> MySQL 在model一栏的 Default Target MySQL Version 中设置成对应的版本,如设置成“5.7”。

逻辑表设计实现

表设计

schema表: 描述有哪些表,一行数据表示一个具体的资产表的名称,相应的sql如下:

CREATE TABLE IF NOT EXISTS `cmdb`.`schema` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(48) NOT NULL,
  `desc` VARCHAR(128) NULL,
  PRIMARY KEY (`id`),
  UNIQUE INDEX `name_UNIQUE` (`name` ASC))
ENGINE = InnoDB

field表: 描述一个表的字段,一行数据表示归属一个特定资产表中的字段;该表的多条数据可以对应schema表中的一条数据,即对应一个特定的资产表,这里field表与schema表就形成一个外键约束,field表以多对一的关系对应schema表,相应的sql如下:

CREATE TABLE IF NOT EXISTS `cmdb`.`field` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(48) NOT NULL,
  `meta` TEXT NULL,
  `schema_id` INT NOT NULL,
  PRIMARY KEY (`id`),
  INDEX `fk_field_schema1_idx` (`schema_id` ASC),
  CONSTRAINT `fk_field_schema1`
    FOREIGN KEY (`schema_id`)
    REFERENCES `cmdb`.`schema` (`id`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB

schemafield两个表结合起来就可描述资产管理系统的对应各种资产的表,如:

schema表数据

id name Desc
1 host 主机表
2 route 路由器表

field表数据

id name meta schema_id
1 name 1
2 ip 1
3 rack_number 1
4 ip 2
5 rack_number 2

field表中的meta字段先不用管它,它是后边描述字段的元数据信息的,这个后面会用到。如上两个表中的数据就创建两个资产表,一个名为host,另一个为route,组合起来就可以描述表和相应的字段。上边表示host表有三个字段,为nameipr和rack_number,route表有2个字段,为iprackk_number。这种host和route表我们叫做逻辑表

实体表设计实现

这样设计后,如果要增加资产,在schema表中增加一条记录,并在field表中增加相应的字段即可,向表中增加一条记录,对mysql来说相当方便。那逻辑表中的一行行记录怎样存放呢,在filed表中存放是不合适的,在schema表更不合适,所以需要再增加表才行。

再增加一张表,表名为entity,该表记录有一条资产记录,此表需要描述该记录是对应哪个逻辑表,即entity表与schema表也是多对一的关系。

再增加一张表,表名为value,此表才真正记录资产信息的数据,一行数据需要描述该数据属于哪个逻辑表的哪个字段,所以value表与entity表也是多对一的关系,因为一个entity表中的id对对应schema表中的唯一一行数据,所以value表中的多条数据都能对应entity表中的一个id;value表的多行数据也需要对应field表的id,也是多对一的关系。最后entityvalue表的sql语句如下:

entity表:描述一行资产记录需要插入

CREATE TABLE IF NOT EXISTS `cmdb`.`entity` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `key` VARCHAR(48) NOT NULL,
  `schema_id` INT NOT NULL,
  PRIMARY KEY (`id`),
  INDEX `fk_entity_schema2_idx` (`schema_id` ASC),
  CONSTRAINT `fk_entity_schema2`
    FOREIGN KEY (`schema_id`)
    REFERENCES `cmdb`.`schema` (`id`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB

entity表中的key字段是记录一条记录的唯一性,一个uuid的值。

value表:描述具体的资产信息属于哪张逻辑表的哪个字段

CREATE TABLE IF NOT EXISTS `cmdb`.`value` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `value` TEXT NOT NULL,
  `field_id` INT NOT NULL,
  `entity_id` INT NOT NULL,
  PRIMARY KEY (`id`),
  INDEX `fk_value_field2_idx` (`field_id` ASC),
  INDEX `fk_value_entity2_idx` (`entity_id` ASC),
  CONSTRAINT `fk_value_field2`
    FOREIGN KEY (`field_id`)
    REFERENCES `cmdb`.`field` (`id`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_value_entity2`
    FOREIGN KEY (`entity_id`)
    REFERENCES `cmdb`.`entity` (`id`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB

现在模拟向host这个逻辑表中插入一条数据,那entity表就有一条类似这样的记录

id key schema_id
1 xxxxxxxxxxxuuid 1

value表中的记录

id value filed_id entity_id
1 webserver01 1 1
2 192.168.1.20 2 1
3 001 3 1

这样一个资产记录主被记录好了。在MySQLWorkbench呈现如下:

cmdb系统设计

为什么value表中的value字段的类型为TEXT?首先要明白value字段是需要存放所有资产类型的数据,可能是一个ip,一个数字,一个字符串,布尔值,一个时间等,所以为TEXT类型是最为合适的。

表关系描述

数据库模型创建好后,就可以应用到MySQL中生成相应的表。现在来创建资产表,并向资产表中插入一些数据。

创建一张逻辑表,名为host,并为该表创建两个字段,hostnameip

mysql> INSERT INTO `cmdb`.`schema` (`name`,`desc`) VALUES ('host','host table');
mysql> INSERT INTO field (name, schema_id) values ('hostname', 1);
mysql> INSERT INTO field (name, schema_id) values ('ip', 1);

查询逻辑表

mysql> SELECT * FROM `schema`, field WHERE field.schema_id = `schema`.id and `schema`.id = 1;
+----+------+------------+----+----------+------+-----------+
| id | name | desc       | id | name     | meta | schema_id |
+----+------+------------+----+----------+------+-----------+
|  1 | host | host table |  1 | hostname | NULL |         1 |
|  1 | host | host table |  2 | ip       | NULL |         1 |
+----+------+------------+----+----------+------+-----------+
2 rows in set (0.01 sec)

逻辑表有了,现在准备插入一条资产记录

mysql> INSERT INTO entity (`key`, schema_id) values ('uuid00000001', 1);
mysql> SELECT * from entity;
+----+--------------+-----------+
| id | key          | schema_id |
+----+--------------+-----------+
|  1 | uuid00000001 |         1 |
+----+--------------+-----------+

表示向将要向schema_id1的逻辑表插入一条记录,即schema.id1对应的逻辑表,就是host逻辑表。现在来真正插入记录,如下:

mysql> INSERT INTO `value`(entity_id, field_id, `value`) values (1, 1, 'webserver01');
Query OK, 1 row affected (0.01 sec)

mysql> INSERT INTO `value`(entity_id, field_id, `value`) values (1, 2, '192.168.0.1');
Query OK, 1 row affected (0.00 sec)

插入了一个名为webserver01的主机,其IP地址为192.168.0.1,再插入一条记录,如下:

mysql>  INSERT INTO entity (`key`, schema_id) values ('uuid00000002', 1);
Query OK, 1 row affected (0.01 sec)

mysql> SELECT * from entity;
+----+--------------+-----------+
| id | key          | schema_id |
+----+--------------+-----------+
|  1 | uuid00000001 |         1 |
|  2 | uuid00000002 |         1 |
+----+--------------+-----------+
2 rows in set (0.00 sec)

mysql> INSERT INTO `value`(entity_id, field_id, `value`) values (2, 1, 'webserver02');
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO `value`(entity_id, field_id, `value`) values (2, 2, '192.168.0.2');
Query OK, 1 row affected (0.00 sec)

mysql> select * from value;
+----+-------------+----------+-----------+
| id | value       | field_id | entity_id |
+----+-------------+----------+-----------+
|  1 | webserver01 |        1 |         1 |
|  2 | 192.168.0.1 |        2 |         1 |
|  3 | webserver02 |        1 |         2 |
|  4 | 192.168.0.2 |        2 |         2 |
+----+-------------+----------+-----------+

那如何查询逻辑表中的数据呢?

mysql> SELECT
    entity.id AS entity_id, entity.`key`, entity.schema_id, `schema`.`name`, field.id AS fied_id, field.`name` AS fname, `value`.`value`
FROM
    entity
    INNER JOIN `value` ON entity.id = `value`.entity_id
    INNER JOIN field ON field.id = `value`.field_id
    INNER JOIN `schema` ON field.schema_id = `schema`.id
ORDER BY entity_id;

+-----------+--------------+-----------+------+---------+----------+-------------+
| entity_id | key          | schema_id | name | fied_id | fname    | value       |
+-----------+--------------+-----------+------+---------+----------+-------------+
|         1 | uuid00000001 |         1 | host |       1 | hostname | webserver01 |
|         1 | uuid00000001 |         1 | host |       2 | ip       | 192.168.0.1 |
|         2 | uuid00000002 |         1 | host |       1 | hostname | webserver02 |
|         2 | uuid00000002 |         1 | host |       2 | ip       | 192.168.0.2 |
+-----------+--------------+-----------+------+---------+----------+-------------+

这样就能查询到逻辑表对应的字段及字段对应的值。

这样设计表关系的好处:

不管有多少资产需要管理,只需在schema表中增加,不管资产有多少属性字段,只需要在field表中增加。

坏处:

表结构复杂了,关系复杂了,原来在一张表里能呈现的信息现在需要多张表来关联呈现。复杂的同时,带了灵活性。

如果要想描述逻辑表中字段的一些约束,应该怎样来描述?比如host逻辑中的ip字段是唯一键约束,在value表中的value字段加上一个唯一键约束,这样合适吗?这样做不合适,因为value这个字段是记录所有逻辑表的字段值的,如果加上唯一键约束,那会影响所有的逻辑表

开发环境

先交代下我的开发环境,python3.6django2.2,下边的cmdb为项目名称,dbapi是创建的app。

(cmdbdev) MyMac:cmdb_django Admin$ tree .
.
├── cmdb
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── dbapi
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   │   ├── 0001_initial.py
│   │   ├── 0002_auto_20200901_1502.py
│   │   ├── 0003_auto_20200902_0135.py
│   │   ├── 0004_auto_20200902_0351.py
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── manage.py
└── test.py

4 directories, 18 files

约束设计

逻辑表字段业务类型约束

在向value表中插入数据时,相同的数据可以重复插入,例如“INSERT INTO value(entity_id, field_id, value) values (2, 1, 'webserver02');”这条语句,可以重复执行多次,如果数据被重复插入,那在检索时就会出现重复的数据。这个问题可以在value表上创建以field_identity_id两个字段联合的唯一键约束即可解决这个问题。

逻辑表中字段如果有约束应该怎样定义呢,这里有得使用field表的meta字段,这个字段的类型为TEXT。此字段存放json格式的字符串,描述了该字段的一些属性信息,比如,此字段存放的是一个整形的数字,存放的是一个IP地址等。例如一个meta信息如下,注意:value这个字段并不会在meta中出现,这是value表中value字段的值,放在这里只是为了更好的说明问题。

{
  "type": "dbapi.types.Int",
  "value": 100
}

或者

{
  "type": "dbapi.types.IP",
  "value": "192.168.1.23"
}

其中的type的值是能对应到相应的类对象,以一个字符串来动态加载一个类,这是利用了反射的特性,有了类对象就可实例化后调用相应的函数对value进行相应的数据较验证。

存放数据的多样性,对各个字段的约束也会不尽相同,后期可能会编写许多dbapi.types这个模块中的相应方法。现在先创建一个包dbapi/types,在dbapi/types/__init__.py文件中实现Int类、IP类等,如下:

import importlib
import ipaddress

def get_instance(meta_type: str):
    # 获取较验类的实例对象
    m, c = meta_type.rsplit('.', maxsplit=1)
    mod = importlib.import_module(m)  # 动态加载模块
    cls = getattr(mod, c)  # 反射,获取c对象
    obj = cls()
    if isinstance(obj, BaseType):
        return obj
    raise TypeError('{} is not BaseType'.format(obj))

class BaseType:
    def stringify(self, value):
        # 从用户端拿到数据,转换成字符串,基类未实现
        raise NotImplementedError()

    def destringify(self, value):
        # 从数据库拿数据,返回。还原数据的过程,基类不实现
        raise NotImplementedError()

class Int(BaseType):
    # 整形类型较验
    def stringify(self, value):
        return str(int(value))  # 最后要存放在value表的value字段里,所以为str

    def destringify(self, value):
        return value

class IP(BaseType):
    # 检验IP地址
    def stringify(self, value):
        return str(ipaddress.ip_address(value))  # 如果value是一个合法的ip地址字符器,那就能被ipaddress操作

    def destringify(self, value):
        return value

在项目根目录下的test.py文件中可以进行简单的测试

import json
from dbapi.types import get_instance

meta_str = """
{
    "type":"dbapi.types.Int",
    "value": "100"
}
"""

obj = json.loads(meta_str)

a = get_instance(obj['type']).stringify(obj['value'])
print(a, type(a))

对IP地址的较验也类似,尝试修改meta_str中的value的值修改为一个不合法的,一运行就会抛出相应的异常。

使用json格式的字符串来描述字段的约束关系时,用户需要与后端协商好type是如何定义的,后端代码使用到的动态加载模块以及反射的使用需要好好体会。

逻辑表字段值范围约束

如果value表的value字段为整形,且需要对这个字段做大小的约束,那meta的json字符串可以定义如下:

{
    "type":"dbapi.types.Int",
    "option": {
        "min": 10,
      "max": 100
    },
    "value": "100"
}

后端代码只要拿到option就可以做相应的判断,修改types/__init__.py中的get_instance方法,其能再接收一个option参数。

def get_instance(meta_type: str, option: dict):
    # 获取较验类的实例对象
    m, c = meta_type.rsplit('.', maxsplit=1)
    mod = importlib.import_module(m)  # 动态加载模块
    cls = getattr(mod, c)  # 反射,获取c对象
    obj = cls(option)  # 向较验类传递option参数
    if isinstance(obj, BaseType):
        return obj
    raise TypeError('{} is not BaseType'.format(obj))

既然obj = cls(option),那动态加载的类对象也得接收一个参数,而类对象继承BaseType基类,所以在基类上增加可接收一个option参数,并实现__getattr__方法,方便调用。

class BaseType:
    def __init__(self, option):
        self.option = option

    def __getattr__(self, item):
        return self.option.get(item)

    def stringify(self, value):
        # 从用户端拿到数据,转换成字符串,基类未实现
        raise NotImplementedError()

    def destringify(self, value):
        # 从数据库拿数据,返回。还原数据的过程,基类不实现
        raise NotImplementedError()

Int类也把最小值最大值的判断加入,如下:

class Int(BaseType):
    """
    整形类型及满园较验
    """
    def stringify(self, value):
        try:
            val = int(value)
        except Exception as e:
            raise TypeError('{} is not like digit.'.format(value))
        if self.option is not None and self.option:
            _max = self.max
            _min = self.min
            if _max and val > _max:
                raise ValueError('Too big.')
            if _min and val < _min:
                raise ValueError('Too small.')
        return str(val)  # 最后要存放在value表的value字段里,所以要str

    def destringify(self, value):
        return value

如果对整形数据没有范围的限制,那meta字段中的option是一个空对象,最好不省略。

逻辑表字段值前缀约束

如果要对IP做前缀匹配,那meta字段类似如下:

{
    "type":"dbapi.types.IP",
    "option": {
        "prefix": "192.168"
    },
    "value": "192.168.1.32"
}

同样需要修改IP较验的类

class IP(BaseType):
    # 检验IP地址
    def stringify(self, value):
        try:
            val = ipaddress.ip_address(value)
        except Exception as e:
            raise ValueError('{} does not look like ip address.'.format(value))
        if self.option is not None and self.option:
            print(self.option)
            if not str(val).startswith(str(self.prefix)):
                raise ValueError('Must startswith {}'.format(self.prefix))
            return str(val)
        return str(val)  # 无前缀约束时返回

    def destringify(self, value):
        return value

如果有其它的字段需要加上类似的约束,那按照上边的逻辑处理即可。

函数抽象

现在对get_instance函数可以进行进一步的抽象,把动态加载模块功能与实例化功能分开。

def get_instance(meta_type: str, option: dict):
    # 获取较验类的实例对象
    m, c = meta_type.rsplit('.', maxsplit=1)
    mod = importlib.import_module(m)  # 动态加载模块
    cls = getattr(mod, c)  # 反射,获取c对象
    obj = cls(option)  # 向较验类传递option参数
    if isinstance(obj, BaseType):
        return obj
    raise TypeError('{} is not BaseType'.format(obj))

以上函数可以分解为以下的两个函数:

def get_cls(meta_type: str):
    m, c = meta_type.rsplit('.', maxsplit=1)
    mod = importlib.import_module(m)  # 动态加载模块
    cls = getattr(mod, c)  # 反射,获取c对象
    if not issubclass(cls, BaseType):
        raise TypeError('{} is not BaseType'.format(obj))
    return cls

def get_instance(meta_type: str, option: dict):
    return get_cls(meta_type)(option)

缓存

类对象缓存

先明确一个概念,同名的模块多次进行加载操作,只加载一次。

比如有两个逻辑表中的一个字段同时都定义了对值进行int类型的约束,那他们各自都会执行到get_cls函数进行types模块的加载,但实质是只要加载过一次,那后边的都会延用前边已加载的模块,python会先去sys.modules中查找相应的模块是否已经加载,如果未加载才进行加载模块的操作。而对于我们的业务系统,这个过程可以更加优化,自己来记录哪个模块已加载,如下代码

classes_cache = {}

def get_cls(meta_type: str):
    cls = classes_cache.get(meta_type)
    if cls:
        return cls
    m, c = meta_type.rsplit('.', maxsplit=1)
    mod = importlib.import_module(m)  # 动态加载模块
    cls = getattr(mod, c)  # 反射,获取c对象
    classes_cache[meta_type] = cls  # 写缓存
    if not issubclass(cls, BaseType):
        raise TypeError('{} is not BaseType'.format(obj))
    return cls

实例对象缓存

获取实例对象函数如下:

def get_instance(meta_type: str, option: dict):
    return get_cls(meta_type)(option)

同样每次用户提交meta元数据中有约束定义时都会实例化一个对象,即使两个约束规则一样。所以这里也需要增加缓存来加速,那key应该如何选择?还是单纯使用meta中的type合适吗?要弄清楚这个问题先看一个meta,如下:

{
    "type":"dbapi.types.Int",
    "option": {
        "min": 10,
        "max": 100
    },
    "value": 80
}

如果option中的约束更改了,如果以type为key,那实例在进行约束判断时就不对了,而且"option":{"min": 10, "max": 100}"option":{"max": 100, "min": 10}应该是视为相同的。所以需要对这个key做好设计。

instances_cache = {}

def get_instance(meta_type: str, option: dict):
    key = ",".join("{}={}".format(k, v) for k, v in sorted(option.items()))
    key = "{}|{}".format(meta_type, key)

    obj = instances_cache.get(key)
    if obj:
        return obj
    obj = get_cls(meta_type)(option)
    instances_cache[key] = obj
    return obj

key = ",".join("{}={}".format(k, v) for k, v in sorted(option.items()))巧妙的将option进行排序后,把kv键值对使用逗号连接起来,最后再与type结合,这就形成了一个独一的key。

小结:

缓存的引入并不会能有立竿见影的效果,特别是对类对象的缓存,即使不做缓存,实质也不会加载多次模块,只是要培养这种建立缓存的思想,在开发中一见到有某个对象可能会被多次创建时就应该条件反射想到是否需要缓存。对实例对象进行缓存可以使用之前创建过的实例,如果没有这个缓存,每一次调用都是新创建实例对象,实例对象被使用后就不会再被使用,此时管理垃圾回收的引用计数器就会在该对象上减去1,当引用计数器为0时,该对象的生命就结束了,GC在进行垃圾回收时就会把相应的对象进行回收,如果有大量的实例对象被创建,又需要被回收,那GC运行时消耗资源越多,时间也占用越长,所以引入缓存是比较合适的。

type名称简化

meta中的dbapi.types.Int这个type值太长了,不好记忆,能否写成Int这样的短名称,这里需要与用户约定好,dbapi/types/__init__.py中的类名可以直接拿来当做type的值。既然这样,那短名称也要对应到相应的类对象上,那该如何处理?

globals函数

在python中,作用域是无法突破当前模块的,当一个python包被加载时会执行__init__.py中的语句,有什么方法能知道当前模块中所有的变量及对象呢?globals方法就可以,先看一个小测试:

class A:
    X = 10

def fn():
    a = 100
    pass

m = 100

print(globals())

运行后输出

{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x10fe6f780>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': '/Users/Admin/Documents/python/cmdb_django/test.py', '__cached__': None, 'A': <class '__main__.A'>, 'fn': <function fn at 0x10fde6e18>, 'm': 100}

'A': &lt;class '__main__.A'&gt;, 'fn': &lt;function fn at 0x10fde6e18&gt;, 'm': 100都被打印出来,那就可以使用global方法把types模块加载时运行__init__.py的特性把该模块中的验证约束的类对象全部收集后放入到classes_cahce这个缓存字典中,这样只要该模块被加载,那该模块中的类就在缓存中就有了。

加载注入类对象

type/__init__.py最后增加一个方法

def inject_classes_cache():
    """
    类缓存函数
    :return: None
    """
    for k, v in globals().items():
        if type(v) == type and issubclass(v, BaseType) and not k.startswith('BaseType'):
            classes_cache[k] = v  # 短名称缓存
            classes_cache['{}.{}'.format(__name__, k)] = v  # 长名称缓存

# 此模块被导入时注入较验数据类型的class
inject_classes_cache()

inject_classes_cache()需要放在该文件的最后,只要该模块被加载就执行该函数,也就把较验证约束相关的类对象注入到了classe_caches字典中。那get_cls函数中的动态加载模块的代码就不必了,该函数修改为

def get_cls(meta_type: str):
    """
    :param meta_type: 数据库中field表中meta字段中json字符串中的type值
    :return: class
    """
    cls = classes_cache.get(meta_type)
    if cls:
        return cls
    # 如果缓存里未注入的类都视为非法的
    raise TypeError('Wrong Type {}. Not subclass of BaseType.'.format(meta_type))

get_instance函数也作了小的调整,如下:

def get_instance(meta_type: str, option: dict = None):
    """
    :param meta_type:
    :param option: meta中的option
    :return: 通过对type字串的解析处理后返回一个类型转换的实例
    """
    if option is not None and option:  # option字典不为None且不为空字典
        key = ",".join("{}={}".format(k, v) for k, v in sorted(option.items()))
        key = "{}|{}".format(meta_type, key)

        obj = instances_cache.get(key)
        if obj:
            return obj
        obj = get_cls(meta_type)(option)
        instances_cache[key] = obj
        return obj
    else:
        key = meta_type
        obj = get_cls(meta_type)(option)
        instances_cache[key] = obj
        return obj

单值约束

到目前,value表里的value字段里都是存放的一个单值。对这样的值一般可以设置是否可为空(nullable),是否是唯一(unique)的约束。

值是否为空(nullable)

meta字段中增加nullable选项,如果有此选项就需要应用到值的验证上。
如果设置为false,表示值不可以为空,如果为空就抛出异常;如果为true,表示可为空,那就什么也不做。

值是否唯一(unique)

unique选项,如果设置此选项就应用到值的验证上。
如果设为为false,就不需要检查值的唯一性,直接插入值即可;如果设置为true,就需要检查值的唯一性。

如何检查值的唯一性?
虚拟表schema + field组成,entity表使用同一个schema_id就表示这是同一张表的数据。例如有以下数据:

mysql> select * from `schema`;
+----+-------+------------+
| id | name  | desc       |
+----+-------+------------+
|  1 | host  | host table |
|  3 | phone | 手机       |
+----+-------+------------+
2 rows in set (0.00 sec)

mysql> select * from field;
+----+----------+------+-----------+
| id | name     | meta | schema_id |
+----+----------+------+-----------+
|  1 | hostname | NULL |         1 |
|  2 | ip       | NULL |         1 |
|  4 | imei     |      |         3 |
|  5 | price    |      |         3 |
+----+----------+------+-----------+
4 rows in set (0.01 sec)

mysql> select * from entity;
+----+--------------+-----------+
| id | key          | schema_id |
+----+--------------+-----------+
|  1 | uuid00000001 |         1 |
|  2 | uuid00000002 |         1 |
+----+--------------+-----------+
2 rows in set (0.01 sec)

mysql> select * from value;
+----+-------------+----------+-----------+
| id | value       | field_id | entity_id |
+----+-------------+----------+-----------+
|  1 | webserver01 |        1 |         1 |
|  2 | 192.168.0.1 |        2 |         1 |
|  3 | webserver02 |        1 |         2 |
|  4 | 192.168.0.2 |        2 |         2 |
+----+-------------+----------+-----------+
4 rows in set (0.00 sec)

上边的信息表示在schema_id为1的这个逻辑表中有两条记录,如果field表中id为2的ip字段要求唯一性约束,如果要再插入一个value='192.168.0.2'的数据,这时就需要对要插入的'192.168.0.2'做唯一性检查。

  1. 先确定schema_idfield_id,即要确定哪个逻辑表的哪个字段需要做唯一性检查
  2. 查找entity表中schema_id为1的数据,表示虚拟表host的记录,查询sql为“select id from entity where schema_id = 1”
  3. 查找value表中的field_id=2的所有entity_id,查询sql为“select entity_id from value where field_id = 2;”
  4. 如果第3步的entity_id在第2步查询的id中,说明这些数据是host逻辑表的数据,查询sql为“SELECT * from value WHERE field_id = 2 and entity_id in ( SELECT id from entity where schema_id = 1);”
  5. 再加上一个where条件判断要插入的ip是否在第4步查询出的数据中,查询sql为“SELECT * from value WHERE field_id = 2 and entity_id in ( SELECT id from entity where schema_id = 1) and value='192.168.0.2';”

最后统计一下查询出数据的条数,sql最后为“SELECT count(value) from value WHERE field_id = 2 and entity_id in ( SELECT id from entity where schema_id = 1) and value='192.168.0.2';”,如果结果为0,说明数据库中还没有这个ip地址,如果结果为1,说明数据库中已有此ip地址,如果大于1,说明数据库已经有重复的数据了。

另一种思路分析:

field表中的每个字段是唯一属于一个逻辑表的,那只要确定了逻辑表的字段的id,那在value表中的以这个id来过虑数据,得到的就是一个逻辑表指定的字段的数据,同样对这个value字段进行统计同样能知道是否有重复数据。sql如为“select count(value) from value where field_id = 2 and value = '192.168.0.2';”。

要实现nullableunique,需要在meta字段中增加相应的选项,如下:

{
    "type":"dbapi.types.IP",
    "nullable": false,
    "unique": true,
    "option": {
        "prefix": "192.168"
    },
    "value": "192.168.0.2"
}

多值约束

如果一个主机绑定了多个IP地址,应该如何在逻辑表中记录。

  1. 直接在value表中的value字段中存放类似192.168.0.1,192.168.0.10来表示多值,这种实现简单,但修改,做唯一性约束困难。
  2. 直接使用多条记录存储,在value表中使用相同的entity_idfield_id来存储,即表示在一条插入的记录中在相应的字段中有两行记录,这样修改,做唯一性约束都比较容易实现。因为之前entity_idfield_id做了unique索引,与现在的设计相违背,所以需要删除这个unique索引。

多值约束设计

multi=false 默认为单值

用户提交一个值,如果数据库已存在就更新,不存在就新增加。
如:一个主机只能有一个设备编号,用户提交上来的数据时,不存在时就新增加,已存在就更新。

multi=true 多值

多值时逻辑较为复杂,比如主机的ip字段可以为多值。

  1. 假设该主机ip字段目前为空,无记录,现在提交了多个Ip上来

  2. 如果该主机已记录有ip,又提交上来了ip,那需要分情况考虑

    2.1. 提交的ip都是新的ip

    2.2. 提交的ip部分是新的ip

    2.3. 提交的ip全部都是已存在的ip

这里的设计逻辑如下:

如果之前为空,现在提交多个ip上来,直接 增加。
如果之前数据库中有ip记录,先查一下Ip的条目,为C1;提交上来的数目为C2。那:

c1 == c2时,循环迭代,用c2的ip替换c1的
c1 < c2 时,循环迭代,替换c1个数据,再新增c2-c1个
c1 > c2 时,循环迭代,替换c2个数据,剩下c1-c2个删除

允许多值时meta字段内容类似如下:

{
    "type":"dbapi.types.IP",
    "nullable": false,
    "unique": true,
    "option": {
        "prefix": "192.168"
    },
    "multi": true,
    "value": "192.168.0.1,192.168.0.2"
}

多表关系设计

meta字段中提供reference来表示与另一个逻辑表的关系。类似如下:

{
    "type":"dbapi.types.IP",
    "nullable": false,
    "unique": true,
    "option": {
        "prefix": "192.168"
    },
    "multi": true,
    "reference": {
        "schema":1,
        "field":2
    },
    "value": "192.168.0.1,192.168.0.2"
}

上边的意思就是该字段引用schema中id为1的逻辑表的对应字段为field中id为2的字段。

比如有一个名为host的逻辑表和一个名ipaddress的逻辑表,host表中的数据需要在ipaddress中存在,也就是外键引用的关系。host表我们叫做source表,ipaddress表叫做target表。

类型一致性校验

如果两个逻辑表的字段增加了引用后,需要检查这两个字段的类型是否一致,即检查source表中的meta中的type定义与target表中定义的是否一致?如果一致,才能继续。

约束设计

source表

  1. 新增加数据,首先做类型校验,如是不是int类型,是否在取值范围内,可否为空,是否唯一,这些检验做完,再做外键约束。去相target表,看被引用的字段中是否存在当前source表的字段值,如果有,数据可插入,不存在就抛异常。
  2. 修改数据,同上,相当于加新值。
  3. 删除数据,直接删除。
  4. 查询数据,直接查询。

target表

  1. 查询数据,直接查。

  2. 增加数据,新数据直接插入。

  3. 删除数据,可能已被引用,需要有删除策略。

    3.1. 级联删除cascade,先查询host表中使用这个字段值的记录并删除它,然后再删除ipaddress表中的记录。

    3.2. 置空set_null,例如:host.ip和switch.ip都引用了ipaddress.ip,ipaddress.ip要删除192.168.0.2,那么就要去value表先置空host表、switch表的引用。

    3.3. 禁用disable,被引用了就不允许删除,host.ip和switch.ip都引用了ipaddress.ip,ipaddress.ip要删除192.168.0.2,如此此ip被引用了就不允许删除。

  4. 修改数据

    4.1. 级联更新cascade,与删除类似,不过是把source表更新成新值,target表要先检验这个新值,source表也要通过自己字段的检验,否则抛异常。

    4.2. 禁用disable,如果被引用,就更新失败。这里不用置空,因为target表的字段值与sorce表的字段应该保持一致。

source表的meta字段设计如下:

{
    "type":"dbapi.types.IP",
    "nullable": false,
    "unique": true,
    "option": {
        "prefix": "192.168"
    },
    "multi": true,
    "reference": {
        "schema":1,
        "field":2,
        "on_delete": "cascade|set_null|disable",  # 三值取一
        "on_update": "cascade|disable"   # 二值取一
    },
    "value": "192.168.0.1,192.168.0.2"
}

多表关联的外键约束设计,非常复杂,代码实现成本非常高,非常难控制。

如果使用了这种外键约束,target表的主键变动了,首先不知道谁引用了,需要代码去遍历field表的所有记录,从meta字段中解析谁引用了这个字段,非常没有效率。为了提高效率可以在field表增加一个字段reference,如果是source表中的字段,它引用了target表的字段,则这个reference字段一定有值,其值为target表的相应字段的id。

DDL设计

增加表

没影响,直接增加。

增加字段

新增加一个字段到逻辑表里,这个字段是新的,还没有任何数据使用这个字段。那就要考虑这个字段能否为空?

nullable可否为空:

  • true,可为空,那就直接增加此字段,其他就没什么可做了;
  • false,不可以为空,那就需要提供缺省值(default)。
    • 如果设置unique=true,default的值一定要满足唯一性。这种需求应该很少,可衡量下是否需要实现
    • 如果还有reference字段,那此字段是个外键,还得考虑此缺省值是否在被引用的主键中。这种需求应该很少,可衡量下是否需要实现

修改字段

field表中就只有两个字段可修改,一个是name,另一个是meta

  • 修改name,没问题,只是一个描述性字符串,value表引用的是id。

  • 修改meta

    • 改type,要遍历相应逻辑表该字段的所有数据,拿出来转换成相应的类型,转换成功后再写回去。操作成本太高,而且很少有这种需求,因此,不提供此功能。

    • 改nullable

    • 从false到true,什么都不做
    • 从trure到false,即表示从可为空变为不可为空,就需要遍历该逻辑表的该字段,使用该字段在field表中的id在value表中查找value字段是否为null,或者缺失(因为在新增字段时,可以为空,就什么都不做)。如果为null或缺失,较验失败。查询sql为:select count(id) from value where field_id=2 and value is null or value = ''; 如果count大于0,较验失败。

    • 改unique

    • 从true到false,什么都不做

    • 从fase到true,就需要判断当前该逻辑表的该字段里是否有重复的数据,如何判断?

      select count(id) from value where field_id = 2;

      select distinct count(value) from value where field_id = 2;

      如果两次的count结果一样,就无重复的数据,如果不一致,那就有重复数据。注意第二条语句执行不会太快。

    • 改multi

    • 从false到true,什么都不做
    • 从true到false,需要计算每一个entity_id、field_id的count,sql为:select count(id) from value where field_id = 2 and entity_id = 1; 如果count值大于1说明是多值,检验失败

    • 改reference

    • 清除引用信息,就是去掉约束,不用较验。而且value字段上的值原来就是合法的,所以继续保留
    • 增加引用,就是检查该字段(外键)的所有值是否出现在被引用逻辑表的主键上,只要有一个不在,检验就失败。如果检验成功,在reference字段上填上外键的信息。这些主键,外键值都存储在value表中,所以使用自关联。参考下边的参考1,如何使用自关联左联来做检验。

    • 改option,只是数据处理的限制条件,我们这里做好约定,修改了只将影响以后的数据,已入库的不检验不更新。

准备实验数据

truncate table四张表,再插入以下的实验数据

-- 增加host逻辑表
INSERT INTO `schema` (name) VALUES ('host');
-- 增加ippool逻辑表
INSERT INTO `schema` (name) VALUES ('ippool');
-- SELECT * FROM `schema`

-- ippool逻辑表增加名称为ip的字段
INSERT INTO field (name, schema_id) VALUES ('ip', 2);

-- ippool逻辑表增加三条记录
INSERT INTO entity (`key`, schema_id) VALUES ('uuid01', 2);
INSERT INTO entity (`key`, schema_id) VALUES ('uuid02', 2);
INSERT INTO entity (`key`, schema_id) VALUES ('uuid03', 2);
INSERT INTO `value` (`value`, entity_id, field_id) VALUES ('192.168.1.10', 1, 1);
INSERT INTO `value` (`value`, entity_id, field_id) VALUES ('192.168.1.20', 2, 1);
INSERT INTO `value` (`value`, entity_id, field_id) VALUES ('192.168.1.30', 3, 1);

-- 逻辑表host增加两个字段
INSERT INTO field (name, schema_id) VALUES ('hostname', 1);
INSERT INTO field (name, schema_id) VALUES ('ip', 1);

-- 逻辑表host增加两条记录
INSERT INTO entity (`key`, schema_id) VALUES ('uuid04', 1);
INSERT INTO `value` (`value`, entity_id, field_id) VALUES ('dns server', 4, 2);
INSERT INTO `value` (`value`, entity_id, field_id) VALUES ('172.16.100.1', 4, 3);

INSERT INTO entity (`key`, schema_id) VALUES ('uuid05', 1);
INSERT INTO `value` (`value`, entity_id, field_id) VALUES ('dbserver01', 5, 2);
INSERT INTO `value` (`value`, entity_id, field_id) VALUES ('192.168.1.20', 5, 3);

参考1

SELECT source.*, target.* 
FROM `value` AS source LEFT JOIN `value` AS target 
ON source.`value` = target.`value` AND target.field_id = 1
WHERE source.field_id = 3 AND target.`value` is NULL

# 查询结果为
+----+--------------+----------+-----------+------+-------+----------+-----------+
| id | value        | field_id | entity_id | id   | value | field_id | entity_id |
+----+--------------+----------+-----------+------+-------+----------+-----------+
|  5 | 172.16.100.1 |        3 |         4 | NULL | NULL  |     NULL |      NULL |
+----+--------------+----------+-----------+------+-------+----------+-----------+

如何理解这条sql?
把source当作host逻辑表,target当作ippool逻辑表,在host逻辑表上左关联ippool逻辑表,条件为value字段值相等,并且ippool逻辑表的字段为ip,即field_id为1,这样查出来的结果为:

mysql> SELECT source.*, target.*  FROM `value` AS source LEFT JOIN `value` AS target  ON source.`value` = target.`value` AND target.field_id = 1;
+----+--------------+----------+-----------+------+--------------+----------+-----------+
| id | value        | field_id | entity_id | id   | value        | field_id | entity_id |
+----+--------------+----------+-----------+------+--------------+----------+-----------+
|  1 | 192.168.1.10 |        1 |         1 |    1 | 192.168.1.10 |        1 |         1 |
|  2 | 192.168.1.20 |        1 |         2 |    2 | 192.168.1.20 |        1 |         2 |
|  7 | 192.168.1.20 |        3 |         5 |    2 | 192.168.1.20 |        1 |         2 |
|  3 | 192.168.1.30 |        1 |         3 |    3 | 192.168.1.30 |        1 |         3 |
|  4 | dns server   |        2 |         4 | NULL | NULL         |     NULL |      NULL |
|  5 | 172.16.100.1 |        3 |         4 | NULL | NULL         |     NULL |      NULL |
|  6 | dbserver01   |        2 |         5 | NULL | NULL         |     NULL |      NULL |
+----+--------------+----------+-----------+------+--------------+----------+-----------+

这样就能找出host逻辑表中所有的ip字段与ippool表中ip字段值相等的记录,这种记录是左右对齐的;host表的其他字段会出现在左边,右边为NULL,如果host表的ip字段的值与ippool表中的ip字段的值不相等时也会出现在左边,右边NULL,如上边的172.16.100.1,这就能说明host逻辑表中的ip字段有值不在ippool逻辑表中,如果要在host逻辑表的ip字段中增加外键引用ippool逻辑表的ip字段,那检验就不能成功。再进一步完善上边的sql,如下:

mysql> SELECT source.*, target.*  FROM `value` AS source LEFT JOIN `value` AS target  ON source.`value` = target.`value` AND target.field_id = 1 WHERE source.field_id = 3 AND target.`value` is NULL;
+----+--------------+----------+-----------+------+-------+----------+-----------+
| id | value        | field_id | entity_id | id   | value | field_id | entity_id |
+----+--------------+----------+-----------+------+-------+----------+-----------+
|  5 | 172.16.100.1 |        3 |         4 | NULL | NULL  |     NULL |      NULL |
+----+--------------+----------+-----------+------+-------+----------+-----------+

正好找出一条数据不在ippool逻辑表中,对结果做一下统计

mysql> SELECT count(source.id)  FROM `value` AS source LEFT JOIN `value` AS target  ON source.`value` = target.`value` AND target.field_id = 1 WHERE source.field_id = 3 AND target.`value` is NULL;
+------------------+
| count(source.id) |
+------------------+
|                1 |
+------------------+

只要这个count的值不等于0,那就检验失败。

设计微调

在生产环境中删除数据需要谨慎,一般会采取逻辑删除数据,所以在schema,field,entity,value四个表中增加deleted字段,默认值为0,表示未删除,1表示已删除。

数据做逻辑删除后带来了一个问题:

schema表中设置了name为unique,但是如果删除一个逻辑表后,加入一个同名的逻辑表名,就会报错。
field表中使用了schema_id和name构成unique,也一样有这种问题。
问题的关键在于名称冲突,而名称又是展示给用户看的,所以加上deleted字段构成unique。这样看似解决了删除一个名称,再次输入同名的问题,但是解决不了这个名称再次删除后的unique冲突。
所以依靠物理表的unique,不好解决这个问题,还需要自己编码最终解决这个问题。
一种解决思路:deleted字段中存放删除时的timestamp字符串,把deleted字段组合一起构成unique约束就可以了。

另一种思路:schema表删除name字段的唯一键约束,新增加deleted和deleted_date字段。当删除一个逻辑表时,置deleted字段为1,并且deleted_date记录删除时间,只要查询schema表中deleted=0,且deleted_date为空的条目数为0就代表想新增的name可以正常增加。

meta字段处理

  1. 把option放到type中,因为它和type类型有关
  2. 引用仿照mysql,指定表名和字段名。因为字段有可能删除后重新添加同名的字段,id就变了。
{
    "type": {
        "name": "dbapi.types.IP",
        "option": {
                "prefix": "192.168"
            }
    },
    "nullable": false,
    "unique": true,
    "default": "",
    "multi": true,
    "reference": {
        "schema":"ippool",
        "field":"ip",
        "on_delete": "cascade|set_null|disable",  # 三值取一
        "on_update": "cascade|disable"   # 二值取一
    }
}

type的简化写法

{
  "type": "dbapi.types.IP",
  "unique": true
}

也就是type可以是一个字符串,也可以是一个对象。

猜你喜欢

转载自blog.51cto.com/zhaochj/2529372