在了解携程Applo配置中心之前,我们需要在心里思考以下这些问题:
- 什么是配置中心,如果我们自己去实现一个配置中心,它应该具备哪些功能;
- 我们用过哪些配置中心,它们有什么区别;
- 如何实现一个配置中心;
下面我将会就这些内容展开详细的介绍。
一、配置中心概述
1.1 配置中心的由来
由于微服务框架的兴起,以前一个大型项目,有许多个模块,比如用户模块、订单模块、商品模块、库存模块等,整个项目可能单单java文件就能有几百上千个。这种大项目打一次包可能就要几十分钟甚至几个小时,而且这种大项目在一个服务器上跑,浪费的资源是十分大的,而且访问量也低,这就是早期的单体项目。后来人们将这个大项目按模块拆开,各个模块自己作为一个web项目,彼此之间通过网络进行服务器间的通讯(RPC、HTTP等),各个模块自己随意部署,就渐渐演变成微服务项目。
以Spring Cloud微服务项目为例,我们将大项目拆成小项目后,我们的application.yml(或者applicaion.proprties)配置文件也同样变成了好几份,即便你两个小项目用的是同样的配置,你也要复制两次。一旦我们的小项目数量多了起来,那么管理这些配置将会变得困难。
而且一旦某个项目部署了十几二十个机器上后,你开发修改了某一个配置项,那就意味这必须重启这十几二十个机器上的项目。为了避免这种事情的发生,衍生出了一个项目专门管理配置项,也就是配置中心。
1.2 配置中心功能
配置中心的核心功能主要包含:
- 抽象标准化:配置中心应该屏蔽掉其实现的细节,方便用户进行自主式配置管理,一般用户只需要关注两个抽象和标准化的接口即可:
- 配置管理界面UI,方便应用开发人员管理和发布配置;
-
封装好的客户端API,方便应用集成和获取配置;
- 高可用:如果大量服务的运行依赖于配置中心,当配置中心宕机时,会影响大面积服务的运行。
- 多环境多集群:微服务应用大多采用多环境部署,一般标准的环境有开发/测试/生产等,有的应用还需要多集群部署,如支持跨机房或多版本部署。配置中心需要支持对多环境和多集群应用配置的集中式管理;
- 实时性:配置更新需要尽快通知到客户端,有些配置的实时性要求很高,像是主备切换配置,这些需要秒级切换配置的能力。
-
治理:配置需要治理,具体包括:
- 配置格式校验:应用的配置数据存储在配置中心一般都会以一种配置格式存储,比如properties、json、yml等,如果配置格式错误,会导致客户端解析配置失败引起生产故障,配置中心对配置的格式校验能够有效防止人为错误操作的发生,是配置中心核心功能中的刚需;
-
配置审计:需要记录修改人,修改内容和修改事件,方便出现问题时能后追溯;
-
配置版本控制:每次变更需要版本化,出现问题可以及时回滚到上一版本的配置;
-
配置权限控制:配置变更发布需要认证授权;
-
灰度发布:配置发布时可以先让少数实例生效,确定没有问题就可以扩大应用范围。
- 监听查询:当排查问题或者进行统计的时候,需要知道一个配置被哪些应用实例使用到,以及一个实例使用到了哪些配置。
1.3 常用的配置中心
目前市面上流行的配置中心种类繁多,常用的主要有以下几种(下表来源自其它博客):
| 功能点 | Spring Cloud Config | Apollo | Nacos |
| 开源时间 | 2014.9 | 2016.5 | 2018.6 |
| 配置实时推送 | 支持(Spring Cloud Bus) | 支持(HTTP长轮询1S内) | 支持(HTTP长轮询1S内) |
| 版本管理 | 支持(Git) | 支持 | 支持 |
| 配置回滚 | 支持(Git) | 支持 | 支持 |
| 灰度发布 | 支持 | 支持 | 支持(Nacos 1.1.0之前不支持) |
| 权限管理 | 支持 | 支持 | 不支持 |
| 多集群 | 支持 | 支持 | 支持 |
| 多环境 | 支持 | 支持 | 支持 |
| 监听查询 | 支持 | 支持 | 支持 |
| 多语言 | 支支持java | go、c++、python、php、.net等 | node.js、c++、python、java等 |
| 单机部署 | Config-Server + Git + Spring Cloud Bus | Apollo-quickstart + mysql | nacos单节点 |
| 分布式部署 | Config-Server + Git + MQ | Config + Admin + Portal + mysql | nacos + mysql |
| 配置格式支持 | 不支持 | 支持 | 支持 |
| 通信协议 | HTTP和AMQP | HTTP | HTTP |
| 数据一致性 | Git保证数据一致性、Config-server从Git读数据 | 数据库模拟消息队列,Apollo定时读消息 | HTTP异步通知 |
二、Apollo配置中心设计
Apollo(阿波罗)是携程框架部门研发的分布式配置中心,能够集中化管理应用不同环境、不同集群的配置,配置修改后能够实时推送到应用端,并且具备规范的权限、流程治理等特性,适用于微服务配置管理场景。
2.1 Apollo基本模型
上图Apollo的基础模型:
- 用户在配置中心对配置进行修改并发布;
- 配置中心通知Apollo客户端有配置更新;
- Apollo客户端从配置中心拉取最新的配置、更新本地配置并通知到应用;
2.2 架构模块
从这张图中我们可以看到Apollo将后端服务拆分成了Config Service、Admin Service、Portal Service(包含前后端模块,提供的接口只用于前端,前端位于/resources/static下):
- Config Service提供配置的读取、推送等功能,服务对象是Apollo客户端,该服务功能模块较少,并且不易变更,单独拆出来还是比较合适的;
- Admin Service提供配置的修改、发布、以及命名空间、集群、项目创建等功能,服务对象是Apollo Portal;
- Portal Service提供Web界面供用户管理配置,其提供的大部分接口都是远程调用自Admin Service、然后又加入一些用户管理、系统参数、系统权限管理等模块的接口;
- Config Service和Admin Service都是多实例、无状态部署,所以需要将自己注册到Eureka中并保持心跳;
- 在Eureka之上我们架了一层Meta Server用于封装Eureka的服务发现接口,Meta Server提供了三个 API ,services/meta、services/config、services/admin 获得 Meta Service、Config Service、Admin Service 集群地址。实际上,services/meta 暂时是不可用的,获取不到实例,因为 Meta Service 目前内嵌在 Config Service 中;
- Client通过域名访问Meta Server获取Config Service服务列表(IP+Port),而后直接通过IP+Port访问服务,同时在Client侧会做load balance、错误重试;
- Portal通过域名访问Meta Server获取Admin Service服务列表(IP+Port),而后直接通过IP+Port访问服务,同时在Portal侧会做load balance、错误重试;
- 默认情况下,Config Service、Meta Service、Eureka Server 统一部署在 Config Service 中;
个人感觉,Apollo这里将后端服务拆分的其实是过于分散了,而且将数据库也进行了拆分。在我看来,其实可以将Portal Service和Admin Service合并成一个服务,这两部分主要都是业务代码,用来为Web界面提供接口,然后再将前端单独拆分出来更为合适。
2.3 服务端设计
由于Apollo配置中心将后端服务拆分的较多,当通过页面发布配置时,其过程也是比较繁琐的:
- 用户在 Portal 操作配置发布;
- Portal 调用 Admin Service 的接口操作发布;
- Admin Service 发布配置后,发送 ReleaseMessage 给各个Config Service;
- Config Service 收到 ReleaseMessage 后,通知对应的客户端;
这里主要介绍一下第三步,Admin Service 发布配置后,发送 ReleaseMessage 给各个Config Service 。
Admin Service 在配置发布后,需要通知所有的 Config Service 有配置发布,从而 Config Service 可以通知对应的客户端来拉取最新的配置。
从概念上来看,这是一个典型的消息使用场景,Admin Service 作为 producer 发出消息,各个Config Service 作为 consumer 消费消息。
通过一个消息组件(Message Queue)就能很好的实现 Admin Service 和 Config Service 的解耦。 在实现上,考虑到 Apollo 的实际使用场景,以及为了尽可能减少外部依赖,我们没有采用外部的消息中间件,而是通过数据库实现了一个简单的消息队列。
实现方式如下:
- Admin Service 在配置发布后会往 ReleaseMessage 表插入一条消息记录,消息内容就是配置发布的 AppId+Cluster+Namespace ,其实现类为DatabaseMessageSender,同时DatabaseMessageSender 会创建一个线程任务清理ReleaseMessage表中相同消息内容的老消息(查找比新消息id小的相同消息内容的消息);
- Config Service 有一个线程会每隔固定事件扫描一次 ReleaseMessage 表,看看是否有新的消息记录,其实现类是ReleaseMessageScanner。那ReleaseMessageScanner 如何知道哪个ReleaseMessage表中哪条记录是最新发布的呢,其实很简单,这个类继承了InitializingBean,在Bean实例化之后会执行afterPropertiesSet方法,在这个方法中它会获取ReleaseMessage中最大的id并保存到maxIdScanned,然后执行定时任务,扫描数据表是不是有比记录的maxIdScanned还大的id,如果有的话,它就会通知消息监听器(实现了ReleaseMessageListener接口的类),告诉它有新的配置发布了;
- NotificationControllerV2实现了ReleaseMessageListener,NotificationControllerV2得到配置发布的 AppId+Cluster+Namespace 后,会通知对应的客户端。
这里我简要说一下AppId、Cluster、Namespace的关系,在Apollo配置中心的设计中:
- 每一个应用都有一个唯一标识符,也就是AppId;
- 如果我们在开发环境同一个应用部署了多个应用实例,并且希望每个实例使用不同的配置,那我们可以对应用进行分组,Cluster就是起到一个分组的作用;
- 一个应用的配置可能有几种类型的配置文件组成,或者一个配置文件配置项太多,我们就可以将配置文件进行拆分,每一个拆分后的配置文件就对应一个Namespace;
这样一个应用实例的配置项就可以通过应用AppId + Cluster + 若干个Namespace进行确定。
这里重点介绍一下配置发布时,Config Service是如何通知客户端的,其底层是利用DeferredResult实现,也就是http长轮询:
- 客户端会发起一个Http请求到Config Service的notifications/v2接口,也就是NotificationControllerV2;
- NotificationControllerV2不会立即返回结果,而是通过Spring DeferredResult把请求挂起;
- 如果在60秒内没有该客户端关心的配置发布,那么会返回Http状态码304给客户端;
- 如果有该客户端关心的配置发布,NotificationControllerV2会调用DeferredResult的setResult方法,传入有配置变化的namespace信息,同时该请求会立即返回。客户端从返回的结果中获取到配置变化的namespace后,会立即请求Config Service获取该namespace的最新配置。
这里我们说一下最后一步,NotificationControllerV2啥时候会调用DeferredResult的setResult方法呢?之前我们介绍过NotificationControllerV2实现了ReleaseMessageListener接口,当配置发布的时候,就会执行handleMessage(ReleaseMessage, channel)方法,在该方法中就会调用DeferredResult的setResult方法。
这里有个demo程序,有兴趣的可以看一下:通过spring提供的DeferredResult实现长轮询服务端推送消息。
2.4 客户端设计
- 客户端和服务端保持了一个http长轮询,从而能第一时间获得配置更新的推送;(通过Http Long Polling实现)
- 客户端还会定时从Apollo配置中心服务端拉取应用的最新配置;
- 这是一个fallback机制,为了防止推送机制失效导致配置不更新;
- 客户端定时拉取会上报本地版本,所以一般情况下,对于定时拉取的操作,服务端都会返回304 - Not Modified;、
- 定时频率默认为每5分钟拉取一次,客户端也可以通过在运行时指定System Property: apollo.refreshInterval来覆盖,单位为分钟。
- 客户端从Apollo配置中心服务端获取到应用的最新配置后,会保存在内存中;
- 客户端会把从服务端获取到的配置在本地文件系统缓存一份在遇到服务不可用,或网络不通的时候,依然能从本地恢复配置;
- 应用程序可以从Apollo客户端获取最新的配置、订阅配置更新通知;
其整体流程大致如下:
- 一个 Namespace 对应一个 RemoteConfigRepository ;
- 多个 RemoteConfigRepository ,注册到全局唯一的 RemoteConfigLongPollService 中,也就是说通过RemoteConfigLongPollService可以拉取到一个应用实例的所有配置信息;
如果Config Service有多个的话,客户端通过 ConfigServiceLocator ,可获得 Config Service 集群的地址,客户端定时查询配置的时候是随机选择一个Config Service进行查询,如果查询失败,才会查询其它的。
以上内容部分摘自携程Apollo配置中心官网,关于Apollo配置中心的设计官网已经写的很详细了,这里不过多设计。官网介绍:https://www.apolloconfig.com/#/zh/design/apollo-design。
三、源码分析
最近看了一部分Apollo的源码,由于代码写的比较早,数据库当时使用的还是hibernate,服务间调用采用的RetryableRestTemplate(作者自己对RestTemplate进行了封装,加了重试机制),并且代码中使用了大量的ExecutorService 、AtomicInteger、AtomicBoolean 、CopyOnWriteArrayList、队列、缓存等,此外还用到了许多设计模式,比如发布订阅模式,策略模式,值得我们学习。理清了业务逻辑后,代码还是很容易读懂的,这里就不做具体介绍了。源码分析请移步芋道源码。
我们试想一下,如果我们自己实现一个简单配置管理,我们如何实现之前我们介绍的配置管理核心功能?
- 我们配置中心至少要包含三部分、配置中心客户端Client、配置中心服务端Config Service、配置中心前端Config Web。如果我们不将配置中心服务端进一步拆分, 那么Config Service其将承担两部分功能,一部分是和客户端通信,另一部分是提供Web页面所需要的接口;
- 一一实现我们之前介绍配置中心的核心功能;
四、多环境、多集群的实现思路
4.1 问题思考
Q1、先来说一下多环境的实现,我们的环境一般分为开发环境、测试环境、生产环境,那如何保证指定环境启动时服务能正确读取到配置中心上相应环境的配置文件呢?
Q2、以开发环境为例,一个大型分布式微服务系统会有很多微服务子项目,那么在开发环境怎么对这些微服务配置进行管理呢?
Q3、再来说一下多集群的实现,配置中心如何支持同一个服务跨机房和多版本部署时使用不同的配置?
4.2 Apollo实现思路
我们先来介绍一下Apollo的实现,Apollo采用的是在每个环境下部署一个Config Service、Admin Service、以及我们的应用,服务发现是通过Eureka实现的,这样各个环境的程序注册到对应环境的Eureka上。这样就可以解决我们第一个问题。
Apollo会维护一个应用列表,应用列表中的每个应用由我们创建,主要包含应用名称、应用id等字段,我们通过该应用id来标识每个应用,就可以解决第二个问题。
针对第三个问题,Apollo加入了集群管理,通过为应用添加集群,将一个应用划分为若干个集群,可以使同一服务在不同的集群(如不同的数据中心,针对多版本情况,我们可以把相同版本的同一个服务划分为一个集群)使用不同的配置。
Apollo配置中心也有命名空间的概念,一个集群由若干个命名空间,每个命名空间中就包含我们的配置信息。以Spring Boot应用的application.properties文件为例,我们可以将application.properties拆分为若干份,比如数据库配置的、日志配置的、权限配置的,每一份对应一个命名空间。
Apollo中心的本质就是维护一个应用列表。下图是Apollo配置中心其中一个应用的全部信息,一个应用主要由项目信息、环境列表、集群、命名空间组成。
需要注意的是:Apollo将命名空间划分为两种,一种是公有的、还有一种是私有的,我们可以将公有的Namespace关联到多个应用上,从而实现多个应用共享同一份配置的需求,而私有Namespace的配置只能被所属的应用获取到。这块不太好理解,举个例子吧:假设A项目创建了一个公有的命名空间ns1,ns1如果关联到了A项目默认default集群(其它集群不行),并做了一些配置, 那么如果应用B下任一集群关联了这个ns1,那么它也享有这个配置。
Apollo配置信息是以key-value方式存放的。其实体ER图大致如下(下图只绘制了部分字段,你去看官方给的脚本,你会发现大量数据表的Id只是一个摆设,实际起到类似外键作用的都是Name字段,作者这么设计可能是为了避免连表查询吧,此外同一字段数据长度在不同表设置还不一样,这个字段大小写命名,MMP...):
Apollo中Portal服务是多个环境公用,通过在Portal服务中配置每个环境下的Meta Service集群服务所在的域名(或者多个ip地址,通过,分割),通过域名访问Meta Server获取Admin Service服务列表实现直接管理各个环境的配置。
域名的好处:你可以把服务器群里面的多个提供Meta Service服务的服务器IP设置一个域名可以轮询。但是同一时刻,一个域名只能解析出一个IP供你使用。这些IP可以轮流着被解析。
4.3 自己实现
由于Apollo在不同的环境下都要部署一套Config Service、Admin Service,部署起来麻烦。
这里我们如何区分部署环境呢?我们可以采用 App Id + Namespace(默认DEFAULT) + Group(DEFAULT_GROUP)的方式。
我们可以这么想,一个应用无论是部署在开发环境、还是测试环境、亦或者生产环境,假设每个环境上只部署一个应用实例 ?那我们是不是将应用实例关联到一个Namespace上呢。比如开发环境的Namespace就叫dev、测试环境的Namespace就叫test。
那针对多集群的问题怎么解决呢?一个应用可以包含多个Cluster(集群),比如我们常说的zookeeper、kafka、hadoop等集群,假设我们现有生产环境,杭州机房的若干台机器上部署了zookeeper服务,组成Zookeeper集群A; 然后我们又在广州机房的若干台机器上部署zookeeper服务,组成Zookeeper集群B,集群A和集群B想使用不同的配置那怎么办呢?实际上我们可以把集群A、和集群B看作为对应用实例进行一个分组操作,因此可以可以通过Group来实现。
命名空间表cmc_namspace:
| namespace_id | namespace_name | remark | create_time | create_by | update_time | update_by |
| 命名空间唯一标识符 | 命名空间名称 | 描述信息 | 创建时间/发布时间 | 创建者 | 更新时间 | 更新者 |
应用表cmc_app:
| app_id | app_name | format | remark | create_time | create_by | update_time | update_by |
| 应用唯一标识符 | 应用名称 | 配置文件格式 | 描述信息 | 创建时间/发布时间 | 创建者 | 更新时间 | 更新者 |
五、服务治理的实现思路
5.1 配置格式校验(Json Schema)
配置文件的格式一般有properties、yml、json、xml、text这几种。无论是哪种配置文件,我们的配置项一般都可以转换为key、value形式。因此我们可以采用JSON格式保存数据。我们可以使用Json模式(Json Schema)对配置文件做约束。Json Schema的语法我们在后面单独介绍。
我们为每个应用的做一个配置模板如cmc_config_template表所示,需要注意的是一个应用只有一个配置模板;
| config_template_id | app_id | config_template_name | remark | create_time | create_by | update_time | update_by |
| 模板id | 应用id | 模板名称 | 描述信息 | 创建时间 | 创建者 | 更新时间 | 更新者 |
一个配置模板对应若干个配置模板项如cmc_config_template_item表所示:
| config_template_item_id | config_template_id | key | type | title | description | default | minLength | maxLength | required | remark | create_time | create_by | update_time | update_by |
| 模板模板项id | 模板id | 模板项key | Json Schema语法 | Json Schema语法 | Json Schema语法 | Json Schema语法 | Json Schema语法 | Json Schema语法 | Json Schema语法 | 描述信息 | 创建时间 | 创建者 | 更新时间 | 更新者 |
应用有了配置模板和配置模板项后,我们就可以更具配置模版和配置模板项生成具体的配置实例,配置表cmc_config如下(其中app_id、namespace_id、group构成唯一约束):
| config_id | config_name | app_id | namespace_id | group_name | remark | create_time | create_by | update_time | update_by |
| 配置 | 配置名称 | 应用id | 命名空间id | 组名称 | 描述信息 | 创建时间 | 创建者 | 更新时间 | 更新者 |
需要注意的是这里比配置模板多了一个namespace_id、group_name字段,这是因为我们一个应用可能需要部署在多个环境、比如开发环境、测试环境、生产环境,无论是哪个环境我们的配置项都遵循应用配置模板的规范,仅仅是一些配置项的值不一样,比如数据库信息、redis信息等等。这时我们各个环境的应用实例就可以通过配置不同的namespace_id、group_name来找到对应的配置。以Spring Boot应用为例,我们在应用在application.yml文件中配置了App Id + Namespace + Group,那么配置中心客户端就可以通过这些信息唯一确定这个应用实例所使用的配置项。
有了配置表,对应的也有配置项表cmc_config_item:
| config_item_id | key | value | ... 同模板表字段(没有直接关联模板配置项id,是因为模板配置项可能被删除) | remark | create_time | create_by | update_time | update_by |
| 配置项id | 配置项 | 配置内容 | Json Schema语法 | 描述信息 | 创建时间 | 创建者 | 更新时间 | 更新者 |
如果使用了Json Schema语法,那么前端页面如何根据Json Schema语法生成对应的表单呢,以React框架为例,有相应的类库可以使用react-jsonschema-form;这个库只需要提供2份配置即可生成出界面,一份是Json schema,一份是ui schema。
具体用法可以参考react-jsonschema-form 文档:https://react-jsonschema-form.readthedocs.io/en/latest/。
5.2 配置更变历史
我们需要在每次对配置进行新增、删除、修改的时候,记录下配置的变更信息。以Apollo Commit为例:
我们可以发现Commit表的ChangeSet保存了我们每次对配置的操作记录。ChangeSet是一个json字符串,主要包含三块内容createItems、updateItems、deleteItems,比如我们将某个namespace的key为key4的配置项的值从value4123修改为value41234,将会产生一条这样的纪录:
{ "createItems": [ ], "updateItems": [ { "oldItem": { "namespaceId": 32, "key": "key4", "value": "value4123", "comment": "123", "lineNum": 4, "id": 15, "isDeleted": false, "dataChangeCreatedBy": "apollo", "dataChangeCreatedTime": "2018-04-27 16:49:59", "dataChangeLastModifiedBy": "apollo", "dataChangeLastModifiedTime": "2018-04-27 22:37:52" }, "newItem": { "namespaceId": 32, "key": "key4", "value": "value41234", "comment": "123", "lineNum": 4, "id": 15, "isDeleted": false, "dataChangeCreatedBy": "apollo", "dataChangeCreatedTime": "2018-04-27 16:49:59", "dataChangeLastModifiedBy": "apollo", "dataChangeLastModifiedTime": "2018-04-27 22:38:58" } } ], "deleteItems": [ ] }
同理,我们也可以采用类似的方式,创建一张cmc_commit表,用来记录每次对配置的增加、删除、修改信息。
| commit_id | app_id | namespace_id | group_name | oper_type | oper_before | oper_after | create_time | create_by |
| 主键 | ngsp_auth | DEFAULT | DEFAULT_GROUP | 新增/删除/修改 | 操作前内容 | 操作后内容 | 创建时间 | 创建者 |
有了配置变更记录之后,我们就可以很容易回归到上一个版本,下面举个例子,我们第一次为ngsp_anth应用创建一个配置信息,配置信息由若干个配置项组成,假设只有一个配置项,cmc_config_item表增加一条记录,如下:
| config_item_id | key | value | remark | create_time | create_by | update_time | update_by |
| 1 | test1 | true | -- | 2021:06:30 8:37 | -- | -- | -- |
此时同样会在cmc_commit表生成一条记录:
| commit_id | app_id | namespace_id | group_name | oper_type | oper_before | oper_after | create_time | create_by |
| 1 | ngsp_auth | DEFAULT | DEFAULT_GROUP | insert | NULL |
{ "id":1, "app_id":"ngsp_auth", "group_name":"DEFAULT_GROUP", ... } |
2021:06:30 8:37 | -- |
假设我们想修改这条配置,修改成如下内容:
| config_item_id | data_id | namespace_id | group_name | key | value | remark | create_time | create_by | update_time | update_by |
| 1 | ngsp_auth | DEFAULT | DEFAULT_GROUP |
test1 |
false |
-- | 2021:06:30 8:37 | -- | 2021:06:30 8:40 | -- |
此时cmc_commit又会生成一条记录:
| commit_id | app_id | namespace_id | group_name | oper_type | oper_before | oper_after | create_time | create_by |
| 1 | ngsp_auth | DEFAULT | DEFAULT_GROUP | insert | NULL |
{ "id":1, "app_id":"ngsp_auth", "group_name":"DEFAULT_GROUP", "oper_type":"insert" "content":{ "test1":true } ... } |
2021:06:30 8:37 | -- |
| 2 | ngsp_auth | DEFAULT | DEFAULT_GROUP | update |
{ "id":1, "app_id":"ngsp_auth", "group_name":"DEFAULT_GROUP", "oper_type":"insert" "content":{ "test1":true } ... } |
{ "id":1, "app_id":"ngsp_auth", "group_name":"DEFAULT_GROUP", "oper_type":"update" "content":{ "test2":false } ... } |
2021:06:30 8:40 |
同理,每次删除也会在cmc_commit表中生成一条记录,那么如果我们要显示ngsp_auth应用配置的变更历史,实际上就是从cmc_commit表拉取列变更记录的数据,按照时间顺序排序即可。
实际上我们的应用配置是通过配置模板生成的,因此我们应用的配置项只存在更新的情况,因此你可以忽略上面的新增删除情况。
5.3 配置版本监控
配置信息标记完成后,配置信息处于草稿状态,因此我们还需要进行发布,发布信息保存在cmc_release表中:
| release_id | release_name | release_content | app_id | namespace_id | group_name | name | configuration | create_time | create_by |
| 发版主键 | 发布名称 | 发布说明 | 应用Id | 命名空间id | 组名称 | insert | 发布配置项 | 2021:06:30 8:37 | -- |
由于发版信息configuration保存当前发版的配置信息,有了发布信息,那么就可以按照时间顺序进行版本回滚。
5.4 配置审计
配置审计其实就可以看作日志记录,只不过这个日志记录只是记录用户对配置信息的操作, 主要记录某人在某时做了什么配置操作。
5.5 灰度发版
假如我们现在某个应用在生产环境部署了1000个应用实例,这时候我们想给该应用进行配置升级,但是我又不能保证新的配置没有问题,那么就可以通过灰度发版解决这个问题?比如我指定新的配置只对某个ip地址上的应用实例生效,这样如果在某台机器上验证没问题了,我就可以全量发布了。
我先来说说Apollo的实现,如果你对demo应用default集群下命名空间为application的配置项进行灰度发版,我们点击Apollo配置中心右上角创建灰度,然后页面会变成这个样子:
Apollo在你灰度发版的时候会为集群default创建一个子集群,Apollo实现没有没有再去创建一个子命名空间,而是采用和父Namespace,然后会将application命名空间也关联到这个子集群上。其关系图如下:
这样我们就可以在上图灰度版中创建新的配置,然后添加灰度规则,比如指定ip地址为多少。客户端去拉取配置消息时也是先去拉取灰度发版配置信息,如果没有才去拉取普通发版的配置信息。
这里就不过多叙述了,内容比较多。有兴趣的自行研究。
六、实时性的实现
在配置中心中,一个重要的功能就是配置发布后实时推送到客户端。下面我们简要看一下这块是怎么设计实现的。这一块的实现我们可以参考Apollo的实现:
- 客户端和服务端保持了一个http长轮询,当服务端有配置发生变更时,发送配置更新的推送;
- 客户端还会定时从配置中心服务端拉取应用的最新配置(这里如何判断客户端的配置是不是最新发布的配置呢,我们每次发布都有一个release_id,可以在客户端每次请求的时候携带这个,然后我们去比对);
为了避免配置中心服务端每次都去数据库拉去最新发布的配置内容,我们也可以将最新发布的配置进行缓存。
下面我们说说Apollo加载配置的ConfigService服务(客户端每次请求配置时会执行),先看看 ConfigService 这个接口。最上层的是监听器接口,用于监听消息变化。然后是 ConfigService 接口,定义 loadConfig 方法并返回 release 对象。
假设使用的是基于缓存的ConfigService,那么就是查询缓存获取release对象。由于我们的ConfigServiceWithCache时间了ReleaseMessageListener,因此在每次有配置发布的时候,我们也会收到通知,这样就可以更新缓存了。
七、监听查询的实现
监听查询实际上就是监控使用某个已经发版的配置的应用实例。应用实例主要信息是应用实例所在服务器ip信息,我们使用cmc_instance保存应用信息:
| instance_id | app_id | namespace_id | group_name | release_id | ip | online_time | offline_time |
| 实例 | 应用Id | 命名空间id | 组名称 | 发版配置id | 客户端ip | 上线时间 | 离线时间 |
我们需要在缓存中保存每一个与配置中心服务端连接的应用实例。当客户端去请求配置信息的时候,就表明这个客户端上线了,同时将该客户端信息保存到缓存和数据库。
由于我们客户端和服务端采用了长轮询+定时查询的方式,因此服务端在固定时间没有收到客户端的查询信息,也就表明客户端已经离线了。此时就可以从缓存剔除当前实例,并更新数据库信息。
八、Java客户端实现
8.1 Spring Boot应用客户端实现
这一块内容比较复杂,后面会单独一节介绍Spring Boot应用如何从配置中心拉取配置信息。
九、JSON Schema语法
9.1 简介
什么是Json Schema? 以一个例子来说明,假设有一个http restful api,返回某个用户在某个城市关系最近的若干个好友。请求的数据如下:
{ "city" : "chicago", "number": 20, "user" : { "name":"Alex", "age":20 } }
在上面数据中,city是字符串,number是数值,user是一个对象,该对象又包含了name和age两个成员。
对于api来说,需要定义什么样的请求合法,即什么样的Json对于api来说是合法的输入。这个规范可以通过Json Schema来描述,对应的Json Schema如下。
{ "type": "object", "properties": { "city": { "type": "string" }, "number": { "type": "number" }, "user": { "type": "object", "properties": { "name" : {"type": "string"}, "age" : {"type": "number"} } } } }
例子可以通过Json Schema Validator来验证。
什么是Json Schema?
Json Schema定义了一套词汇和规则,这套词汇和规则用来定义Json元数据,且元数据也是通过Json数据形式表达的。Json元数据定义了Json数据需要满足的规范,规范包括成员、结构、类型、约束等。
本节后面的部分是简要介绍Json Schema定义的这些规则,以及如何用这些规则描述规范。
Json Schema定义了一系列关键字,元数据通过这些关键字来描述Json数据的规范。其中有些关键字是通用的;有些关键字是针对特定类型的;还有些关键字是描述型的,不影响合法性校验。本文的主要内容就是介绍这些关键字的应用。
9.2 类型关键字
首先需要了解的是"type"关键字,这个关键字定义了Json数据需要满足的类型要求。"type"关键字的用法如下面几个例子:
(1) {"type":"string"} : 规定了Json数据必须是一个字符串,符合要求的数据可以是
"Today is a good day." "I love you"
(2) {"type" : "object"}:规定了Json数据必须是一个对象,符合要求的数据可以是:
{"name" : "Alexander", "age" : 98}
{}
(3) {"type" : "number"}:规定了Json数据必须是一个数值,符合要求的数据可以是。Java Script不区分整数、浮点数,但是Json Schema可以区分。
2 0.5
(4) {"type": "integer"}:要求数据必须是整数。
(5) {"type" : "array"}。规定了Json数据必须是一个数组,符合要求的数据可以是:
["abc", "cdf"] [1, 2, 3] ["abc", 25, {"name": "Alexander"} ] []
(6) {"type" : "boolean"}:这个Json Schema规定了Json数据必须是一个布尔,只有两个合法值:
true false
(7) {"type" : "null"}:null类型只有一个合法值:
null
9.3 简单类型 - 字符串
Json合法的字符串:
"Today is a good day."
对应的Json Schema:
{"type": "string"}
可以进一步对字符串做规范要求。字符串长度、匹配正则表达式、字符串格式。
(1) 字符串长度
可以对字符串的最小长度、最大长度做规范。
{ "type" : "string", "minLength" : 2, "maxLength" : 3, }
(2) 正则表达式
可以对字符串应满足的Pattern做规范,Pattern通过正则表达式描述。
{ "type" : "string", "pattern" : "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$", }
(3) 字符串Format
可以通过Json Schema内建的一些类型,对字符串的格式做规范,例如电子邮件、日期、域名等。
{ "type" : "string", "format" : "date", }
Json Schema支持的format包括"date", "time", "date-time", "email", "hostname"等。具体可以参考文档。
9.4 简单类型 - 数值
Json Schema数值类型包括"number"和"integer"。number合法的数值可以是:
2 0.1
对应的Json Schema为:
{"type": "number"}
如果是integer则只能是整数。"number"和"integer"的类型特定参数相同,可以限制倍数、范围。
(1) 数值满足倍数
可以要求数值必须某个特定值的整数倍。例如要求数值必须是10的整数倍:
{ "type" : "number", "multipleOf" : 10, }
(2) 数值范围
可以限制数值的方位,包括最大值、最小值、开区间最大值、开区间最小值。
要求数值在[0, 100)范围内:
{ "type" : "number", "minimum": 0, "exclusiveMaximum": 100 }
9.5 简单类型 - 布尔/null
布尔/null类型没有额外的类型特定参数。
9.6 符合类型 - 数组
Json数组合法数据的例子:
[1, 2, 3] [1, "abc", {"name" : "alex"}] []
Json Schema为:
{"type": "array"}
数组的类型特定参数,可以用来限制成员类型、是否允许额外成员、最小元素个数、最大元素个数、是否允许元素重复。
(1) 数组成员类型
可以要求数组内每个成员都是某种类型,通过关键字items实现。下面的Schema要求数组内所有元素都是数值,这时关键字"items"对应一个嵌套的Json Schema,这个Json Schema定义了每个元素应该满足的规范:
{ "type": "array", "items": { "type": "number" } }
关键字items还可以对应一个数组,这时Json数组内的元素必须与Json Schema内items数组内的每个Schema按位置一一匹配。
{ "type": "array", "items": [ { "type": "number" }, { "type": "string" }] }
(2) 数组是否允许额外成员
当使用了items关键字,并且items关键字对应的是Schema数组,这个限制才起作用。关键字additionalItems规定Json数组内的元素,除了一一匹配items数组内的Schema外,是否还允许多余的元组。当additionalItems为true时,允许额外元素。
{ "type": "array", "items": [ { "type": "number" }, { "type": "string" }], "additionalItems" : true }
匹配项:
[1, "abc", "x"]
(3) 数组元素个数
可以限制数组内元素的个数:
{ "type": "array", "items": { "type": "number" }, "minItems" : 5, "maxItems" : 10 }
(4) 数组内元素是否必须唯一
uniqueItems规定数组内的元素是否必须唯一:
{ "type": "array", "items": { "type": "number" }, "uniqueItems" : true }
9.7 符合类型 - 对象
Json对象是最常见的Json数据类型,合法的数据可以是:
{ "name": "Froid", "age" : 26, "address" : { "city" : "New York", "country" : "USA" } }
就对象类型而言,最基本的类型限制Schema是:
{"type" : "object"}
然而,除了类型外,我们通常需要对其成员做进一步约定。对象的类型特定关键字,大多是为此目的服务的。
(1) 成员的Schema
properties规定对象各成员所应遵循的Schema:
{ "type": "object", "properties": { "name": {"type" : "string"}, "age" : {"type" : "integer"}, "address" : { "type" : "object", "properties" : { "city" : {"type" : "string"}, "country" : {"type" : "string"} } } } }
对于上例中的Schema,合法的data是:
{ "name": "Froid", "age" : 26, "address" : { "city" : "New York", "country" : "USA" } }
properties关键字的内容是一个key/value结构的字典,其key对应Json数据中的key,其value是一个嵌套的Json Schema。表示Json数据中key对应的值所应遵守的Json Schema。在上面的例子中,"name"对应的Schema是{"type" : "string"},表示"name"的值必须是一个字符串。在Json数据中,对象可以嵌套,同样在Json Schema中也可以嵌套。如"address"字段,在Json Schema中它的内容是一个嵌套的object类型的Json Schema。
(2) 批量定义成员Schema
patternProperties与properties一样,但是key通过正则表达式匹配属性名:
{ "type": "object", "patternProperties": { "^S_": { "type": "string" }, "^I_": { "type": "integer" } } }
匹配示例:
{"S_1" : "abc"}
{"S_1" : "abc", "I_3" : 1}
(3) 必须出现的成员
required规定哪些对象成员是必须的。
{ "type": "object", "properties": { "name": {"type" : "string"}, "age" : {"type" : "integer"}, }, "required" : ["name"] }
上例中"name"成员是必须的,因此合法的数据可以是:
{"name" : "mary", "age" : 26}
{"name" : "mary"}
但缺少"name"则是非法的:
{"age" : 26}
(4) 成员依赖体系
dependencies规定某些成员的依赖成员,不能在依赖成员缺席的情况下单独出现,属于数据完整性方面的约束。
{ "type": "object", "dependencies": { "credit_card": ["billing_address"] } }
dependencies也是一个字典结构,key是Json数据的属性名,value是一个数组,数组内也是Json数据的属性名,表示key必须依赖的其他属性。
上面Json Schema合法的数据可以是:
{}
{"billing_address" : "abc"}
但如果有"credit_card"属性,则"billing_address" 属性不能缺席。下面的数据是非法的:
{"credit_card": "7389301761239089"}
(5) 是否允许额外属性
additionaProperties规定object类型是否允许出现不在properties中规定的属性,只能取true/false。
{
"type": "object",
"properties": {
"name": {"type" : "string"},
"age" : {"type" : "integer"},
},
"required" : ["name"],
"additionalProperties" : false
}
上例中规定对象不能有"name"和"age"之外的成员。合法的数据:
{"name" : "mary"}
{"name" : "mary", "age" : 26}
非法的数据:
{"name" : "mary", "phone" : ""84893948}
(6) 属性个数的限制
规定最少、最多有几个属性成员。
{
"type": "object",
"minProperties": 2,
"maxProperties": 3
}
9.8 逻辑组合
关键字:allOf, anyOf, oneOf, not,从关键字名字可以看出其含义,满足所有、满足任意、满足一个。前三个关键字的使用形式是一致的,以allOf为例说明其形式。
(1) allOf
满足allOf数组中的所有Json Schema。
{
"allOf" : [
Schema1,
Schema2,
...
]
}
需要注意,不论在内嵌的Schema里还是外部的Schema里,都不应该使"additionalProperties"为false。否则可能会生成任何数据都无法满足的矛盾Schema。
可以用来实现类似“继承”的关系,例如我们定义了一个Schema_base,如果想要对其进行进一步修饰,可以这样来实现。
{
"allOf" : [
Schema_base
]
"properties" : {
"other_pro1" : {"type" : "string"},
"other_pro2" : {"type" : "string"}
},
"required" : ["other_pro1", "other_pro2"]
}
Json数据既需要满足Schema_base,又要具备属性"other_pro1"、"other_pro2"
(2) anyOf
满足allOf数组中的所有Json Schema。
{
"allOf" : [
Schema1,
Schema2,
...
]
}
Json数据需要满足Schema1、Schema2中的一个或多个。
(3) oneOf
满足且进满足oneOf数组中的一个Schema,这也是与anyOf的区别。
{
"oneOf" : [
Schema1,
Schema2,
...
]
}
(4) not
这个关键字不严格规定Json数据应满足什么要求,它告诉Json不能满足not所对应的Schema。
{
"not" : {"type" : "string"}
}
只要不是string类型的都Json数据都可以
9.9 复杂结构
对复杂结构的支持包括定义和引用。可以将相同的结构定义成一个“类型”,需要使用该“类型”时,可以通过其路径或id来引用。
(1) 定义
定义一个类型,并不需要特殊的关键字。通常的习惯是在root节点的definations下面,定义需要多次引用的schema。definations是一个json对象,key是想要定义的“类型”的名称,value是一个json schema。
{
"definitions": {
"address": {
"type": "object",
"properties": {
"street_address": { "type": "string" },
"city": { "type": "string" },
"state": { "type": "string" }
},
"required": ["street_address", "city", "state"]
}
},
"type": "object",
"properties": {
"billing_address": { "$ref": "#/definitions/address" },
"shipping_address": { "$ref": "#/definitions/address" }
}
}
上例中定义了一个address的schema,并且在两个地方引用了它,#/definitions/address表示从根节点开始的路径。
(2) id>∗∗关键字:id**
可以在上面的定义中加入id属性,这样可以通过id属性的值对该schema进行引用,而不需要完整的路径。
...
"address": {
"type": "object",
"$id" : "address",
"properties": {
"street_address": { "type": "string" },
"city": { "type": "string" },
"state": { "type": "string" }
},
"required": ["street_address", "city", "state"]
}
...
(3) 引用
关键字$ref可以用在任何需要使用json schema的地方。如上例中,billing_address的value应该是一个json schema,通过一个$ref替代了。
$ref的value,是该schema的定义在json中的路径,以#开头代表根节点。
{
"properties": {
"billing_address": { "$ref": "#/definitions/address" },
"shipping_address": { "$ref": "#/definitions/address" }
}
}
如果schema定义了$id属性,也可以通过该属性的值进行引用。
{
"properties": {
"billing_address": { "$ref": "#address" },
"shipping_address": { "$ref": "#address" }
}
}
9.10 通用关键字
通用关键字可以在任何json schema中出现,有些影响合法性校验,有些只是描述作用,不影响合法性校验。
(1) enum
enum可以在任何json schema中出现,其value是一个list,表示json数据的取值只能是list中的某个。
{
"type": "string",
"enum": ["red", "amber", "green"]
}
上例的schema规定数据只能是一个string,且只能是"red"、"amber"、"green"之一。
(2) metadata
{
"title" : "Match anything",
"description" : "This is a schema that matches anything.",
"default" : "Default value",
"examples" : [
"Anything",
4035
]
}
title,description,default,example只作为描述作用,不影响对数据的校验。
参考文章:
[3] Apollo官网指导手册
[4] Json Schema简介(转)
update_time