草庐IT

撸了一个简易的配置中心,顺带整合到了SpringCloud

zzyang 2023-04-16 原文

大家好,我是三友~~

最近突然心血来潮(就是闲的)就想着撸一个简单的配置中心,顺便也照葫芦画瓢给整合到SpringCloud。

本文大纲

配置中心的概述

随着历史的车轮不断的前进,技术不断的进步,单体架构的系统都逐渐转向微服务架构。虽然微服务架构有诸多优点,但是随着越来越多的服务实例的数量,配置的不断增多,传统的配置文件方式不能再继续适用业务的发展,所以急需一种可以统一管理配置文件应用,在此之下配置中心就诞生了。

所以配置中心就是用来统一管理各种服务配置的一个组件,本质上就是一个web应用。

配置中心的核心功能

一个配置中心的核心功能其实主要包括两个:

  • 配置的存取
  • 配置变更的通知

配置的存取是配置中心不可缺失的功能,配置中心需要能够将配置进行保存,存在磁盘文件也好,又或是数据库也罢,总之需要持久化,同时配置中心也得提供配置查询的功能。

配置变化的通知也是一个很重要的功能,一旦配置中心的配置有变动的话,那么使用到这个配置的客户端就需要知道这个配置有变动,从而可以做到相应的变动的操作。

手撸一个简易的配置中心

上文分析了一个配置中心的核心功能,接下来就实现这两个核心的功能。

一、文件工程整体分析

文件工程整体分为客户端与服务端

  • 服务端:单独部署的一个web应用,端口是8888,提供了对于配置增删改查的http接口
  • 客户端(SDK):业务系统需要引用对应的依赖,封装了跟服务端交互的代码

二、服务端实现详解

1、配置文件的数据存储模型ConfigFile

在配置中心存储配置的时候,需要指明以下信息

public class ConfigFile {

    private String fileId;

    private String name;

    private String extension;

    private String content;

    private Long lastUpdateTimestamp;

}
  • fileId: 文件的唯一id,由配置中心服务端在新增配置文件存储的时候自动生成,全局唯一
  • name: 就是文件的名字,没有什么要求,见名知意就行
  • extension: 文件后缀名,指的是该配置是什么类型的文件,比如是properties、yml等
  • content: 就是配置文件的内容,不同的后缀名有不同的格式要求
  • lastUpdateTimestamp: 上一次文件更新的时间戳。当文件存储或者更新的时候,需要更新时间戳,这个字段是用来判断文件是否有改动
2、文件存储层ConfigFileStorage

对于文件存储层,我提供了一个ConfigFileStorage接口,

public interface ConfigFileStorage {

    void save(ConfigFile configFile);

    void update(ConfigFile configFile);

    void delete(String fileId);

    ConfigFile selectByFileId(String fileId);

    List<ConfigFile> selectAll();

}

这个接口提供了对于配置存储的crud操作,目前我已经实现了基于内存和磁盘文件的存储的代码

可以在项目启动的时候,在配置文件指定是基于磁盘文件存储还是基于内存存储,默认是基于磁盘文件存储。

当然,如果想把配置信息存储到数据库,只要新增一个存储到数据的实现就行。

3、ConfigController

ConfigController提供了对于配置文件的crud的http接口

ConfigController是通过调用ConfigManager来完成配置文件的crud

4、ConfigManager

其实就是一个service层,就是简单的参数封装,最终是调用ConfigFileStorage存储层的实现来完成配置的存储功能。

这样配置中心的配的存取的功能就实现了。

所以,服务端还是比较简单的。其实就是跟平时写的业务系统的crud没什么区别,就是将数据库存储替换成了磁盘文件的存储。

至于前面说的配置文件变更通知的功能,我是基于客户端来实现的。

三、客户端的实现

客户端工程代码如下

1、ConfigFileChangedListener
ConfigFileChangedListener

配置变动的监听器,当客户端对某个配置监听的时候,如果这个配置的内容有变化的话,客户端就会回调这个监听器,传入最新的配置

2、ConfigService

封装了客户端的核心功能,可以添加对某个文件的监听器和获取某个文件的配置内容。

使用示例:

// 创建一个ConfigService,传入配置中心服务端的地址
ConfigService configService = new ConfigService("localhost:8888");

// 从服务端获取配置文件的内容,文件的id是新增配置文件时候自动生成
ConfigFile config = configService.getConfig("69af6110-31e4-4cb4-8c03-8687cf012b77");

// 对某个配置文件进行监听
configService.addListener("69af6110-31e4-4cb4-8c03-8687cf012b77"new ConfigFileChangedListener() {
    @Override
    public void onFileChanged(ConfigFile configFile) {
        System.out.printf("fileId=%s配置文件有变动,最新内容为:%s%n", configFile.getFileId(), configFile.getContent());
    }
});

这里说一下配置变更通知的实现原理。

首先对于客户端来说,要想知道哪个配置文件进行了改动,有两种方式

第一种是通过push的方式来实现。当配置文件发生变动的时候,服务端主动将变动的配置文件push给客户端。这种方式实现起来比较麻烦,一方面是服务端还得存储客户端的服务的信息,因为服务端得知道push到哪台服务器上;另一方面,客户端需要提供一个接口来接收服务端push的请求,所以这种方式整体实现起来比较麻烦。但是这种push方式时实性比较好,一旦配置文件有变动,第一时间客户端就能够知道配置有变动。

第二种方式就是基于pull模式来实现。客户端定时主动去服务端拉取配置文件,判断文件内容是否有变动,一旦有变动就进行监听器的回调。这种实现相比push来说简单不少,因为服务端不需要关心客户端的信息,所有的操作都由客户端来完成。但是这个定时的时间间隔不好控制,太长可能会导致时实性差,太短会导致可能无效请求过多,因为配置压根可能没有变化。

但是这里我选择了第二种方式,因为实现起来简单。。

变动通知代码实现

到这,一个简单的配置中心的服务端的和客户端就完成了,这里画张图来总结一下配置中心的核心原理。

接下来就把这个简易的配置中心整合到SpringCloud中。

SpringCloud配置中心的原理

1、项目启动是如何从配置中心加载数据的?

在SpringCloud环境下,当项目启动的时候,在SpringBoot应用容器创建之前,会先创建一个容器,这个容器非常重要,这个容器是用来跟配置中心交互,拉取配置的。

这个容器在启动的时候会干两件事:

  • 加载bootstrap配置文件,这就是为什么配置中心的配置信息需要写在bootstrap配置文件的重要原因

  • 加载所有spring.factories文件中的键为org.springframework.cloud.bootstrap.BootstrapConfiguration对应的配置类,将这些配置类注入到这个容器中,注意这里是不会加载@EnbaleAutoConfiguration自动装配的类

当这两件事都做好之后,会从这个容器中获取到所有的PropertySourceLocator这个接口的实现类对象,依次调用locate方法。

PropertySourceLocator

这个类很重要,先来看看注释

Strategy for locating (possibly remote) property sources for the Environment. Implementations should not fail unless they intend to prevent the application from starting.

扔到有道翻译如下:

为环境定位(可能是远程)属性源的策略。实现不应该失败,除非它们打算阻止应用程序启动。

说的简单点就是用来定位到(也就是获取的意思)项目启动所需要的属性信息。同时要注意到括号内的 可能是远程 告诉我们一个很重要的信息,那就是获取的配置信息不仅仅可以存在本地,而且还可以存在远程。

远程?作者这里就差直接告诉你可以从配置中心获取了。。

所以从这个注释就可以发现,原来PropertySourceLocator就是起到在SpringCloud环境下从配置中心获取配置的作用。

PropertySourceLocator是一个接口,所以只要不同的配置中心实现这个接口,那么不同的配置中心就可以整合到了SpringCloud,从而实现从配置中心加载配置属性到Spring环境中了。

2、如何实现注入到Bean中的属性动态刷新?

上面讲了在项目启动的时候SpringCloud是如何从配置中心加载数据的,主要是通过新建一个容器,加载bootstrap配置文件和一些配置类,最后会调用PropertySourceLocator来从配置中心获取到配置信息。

那么在SpringCloud环境下,是如何实现注入到Bean中的属性动态刷新的呢?

举个例子

UserService

当在类上加一个@RefreshScope注解之后,那么当配置中心sanyou.username的属性有变化的时候,那么此时注入的username也会跟着变化。

这种变化是如何实现的呢?

SpringCloud中规定,当配置中心客户端一旦感知到服务端的某个配置有变化的时候,需要发布一个RefreshEvent事件来告诉SpringCloud配置有变动。

在SpringCloud中RefreshEventListener类会去监听这个事件,一旦监听到这个事件,就会进行两步操作来刷新注入到对象的属性。

RefreshEventListener
  • 从配置中心再次拉取属性值,而这个拉取的代码逻辑跟项目启动时拉取的属性值核心逻辑几乎是一样的,也是创建一个新的spring容器,加载配置文件和配置类,最后通过PropertySourceLocator获取属性,这一部分核心的代码逻辑是复用的。

  • 有了最新的属性之后,就开始刷新对象的属性。

刷新的逻辑实现的非常的巧妙,可不是你以为的简单地将新的属性重新注入对象中,而是通过动态代理的方式来实现的。

对于在类上加了@RefreshScope注解的Bean,Spring在生成这个Bean的时候,会进行动态代理。

这里我们就上面举个UserService例子来分析,在生成UserService有两步操作

  • 生成一个UserService对象,将从配置中心拉到的配置sanyou.username注入给UserService对象

  • 由于加了@RefreshScope,会给上一步骤生成的UserService对象进行代理,生成一个代理对象

最后真正暴露出去供我们使用的其实是就是这个代理对象,如图所示

由于暴露出去的是一个代理对象,所以当调用getUsername方法的时候,其实是调用UserService的代理对象的getUsername方法,从而就会找到UserService,调用UserService的getUsername获取到username的属性值。

当配置中心的配置有变动刷新属性的时候,Spring会把UserService这个对象(非代理对象)给销毁,重新创建一个UserService对象,注入最新的属性值。

当再次通过UserService代理对象获取username属性的时候,就会找最新创建的那个UserService对象,此时就能获取到最新的属性值。

配置每刷新一次,UserService对象就会先销毁再重新创建,但是暴露出去的UserService代理对象一直不会变。

这样,对于使用者来说,好像是UserService对象的属性自动刷新了,其实本质上是UserService代理对象最终找的UserService对象发生了变化。

到这应该就知道为什么加了@RefreshScope的对象能够实现配置的自动刷新了,其实依靠的是动态代理完成的。

3、源码执行流程图

由于上面并没有涉及整体执行流程的源码分析,所以我特地结合源码画了两张源码的执行流程图,有兴趣的小伙伴可以对照着图翻一翻具体的源码。

3.1启动时加载配置流程

最终从配置中心获取到的属性会放在项目启动时创建的 Environment 对象里面。

3.2配置刷新源码流程

这个图新增了对于加了@ConfigurationProperties数据绑定的对象原理的分析。

整合SpringCloud和测试

一、整合SpringCloud

1、ConfigCenterProperties

配置中心的配置信息,这里需要配置配置中心服务端的地址和使用的配置文件的id。当然这部分信息需要写在bootstrap配置文件中,前面也说过具体的原因。

2、ConfigCenterPropertySourceLocator

上面分析知道,项目启动和刷新的时候,SpringCloud是通过PropertySourceLocator的实现从配置中心加载配置信息,所以这里就得实现一下

核心的逻辑就是根据所配置的文件的id,从配置中心拉取配置信息,然后解析配置。

3、ConfigContextRefresher

这个是用来注册文件变动的监听器,来刷新文件的信息的。

因为上面提到,当配置发生变化的时候,需要发布一个RefreshEvent事件来触发刷新配置的功能。

核心的逻辑就是当项目启动的时候,对所使用的配置文件进行注册一个监听器,监听器的实现就是当发生配置改动的时候,就发布一个RefreshEvent事件。

4、两个配置类
4.1 ConfigCenterBootstrapConfiguration

配置了ConfigCenterPropertySourceLocator、ConfigCenterProperties、ConfigService

4.2 ConfigCenterAutoConfiguration

配置了ConfigContextRefresher、ConfigCenterProperties、ConfigService

最后需要将两个配置类在spring.factories配置一下。

这里有个需要注意,前面说过,SpringCloud会创建新的容器来加载配置,而这个容器只会加载spring.factories文件中键为@BootstrapConfiguration注解的配置类,所以需要将ConfigCenterBootstrapConfiguration跟BootstrapConfiguration配对,因为ConfigCenterBootstrapConfiguration配置了ConfigCenterPropertySourceLocator。

好了,到这里真的就完成了对SpringCloud整合了。

二、测试

1、新增一个配置文件

启动配置中心的server端,然后打开ApiPost,新增一个配置文件

新增文件类型为properties一个配置,内容为sanyou.username=sanyou键值对,当然可以写很多键值对,我这里就写了一个,新增成功之后,返回了文件的id:79765c73-c1ef-4ea2-ba77-5d27a64c4685

2、测试客户端

这里我为了方便,就把测试代码跟客户端写在同一个服务了,正常情况肯定是把跟SpringCloud代码打成一个依赖引到项目中。

在bootstrap.yml文件中配置配置中心的相关信息

  • 配置中心服务端的地址是:localhost:8888
  • 使用的配置文件的id是刚才创建的:79765c73-c1ef-4ea2-ba77-5d27a64c4685

测试Controller

提供一个接口,注入上面提到的UserService

启动项目,调用接口

从断这里可以看出,实际注入的是一个UserService代理对象,并且最终找的是com.sanyou.configcenter.test.UserService@3a1e4fd3这个UserService对象

此时这次调用的返回值就是:sanyou

接下来测试一下自动刷新属性的功能

现在修改一下配置中心的sanyou.username为sanyou666

静静等待5秒钟。。

此时控制台打印出 Refresh keys changed: [sanyou.username] ,也就是sanyou.username属性变了

此时再次获取username

可以看出,UserService代理对象没变,但是UserService对象已经变成了com.sanyou.configcenter.test.UserService@4237b3cd

此时获取到的username就已经变成了sanyou666

所以,到这里就成功将我们自己写的那个简易版的配置中心整合到了SpringCloud中了。

不足和改进

虽然我们这里的配置中心有了配置中心基本的功能,但是其实还有很多的不足和可以改进的地方。

1、配置变更推送问题

问题前面也说过,在判断配置是否变更的时候,这里是每隔5s从服务端获取一次,这里就会可能5s之后才能感知到配置有变化,达不到真正时实的效果,并且由于这里是由客户端根据来判断,会导致无效的请求过多,因为可能配置压根没有变化,但是还是每隔5s获取一次配置信息,白白浪费资源

解决这个问题可以换成上面提到的push方式来做,或者将轮询方式改成长轮询的方式实现也是可以的,如果不清楚push、轮询、长轮询的,可以翻一下 RocketMQ的push消费方式实现的太聪明了这篇文章。

2、高可用问题

这里服务端的实例只有一个,不支持集群的方式,就会有单点故障的问题,不支持高可用。在实际项目中,肯定要支持集群的方式,保证即使有服务实例挂了,整个集群仍然可以继续对外提供服务,比如nacos就支持集群的方式,并且可以自由选择是使用AP模式还是CP模式。

3、通信协议和序列化协议

对于通信协议,这里为了方便,我选择了客户端和服务端的通信方式是基于http协议的,当然也可以自定义协议,或者使用其它的协议,比如gRPC协议。其实在nacos2.x的版本中,nacos开始全面拥抱gRPC协议了。

至于序列化协议,这里选择了json协议,因为很简单、常见、使用范围广、跨语言,当然也可以选择其它的,比如hessian序列化协议等等。

4、多租户隔离

一个合格的配置中心需要能支持不同应用的隔离,还有同一个应用不同环境的隔离,这里就图省事,直接就是有一个文件id来表示,虽然也可以做到隔离(不同系统用不同的文件id),但是这种方式比较low。像nacos会自动根据配置的名称和后缀名之类的,生成文件id(dataId),同时还有分组的概念,其实就是为了做到隔离的效果。

5、鉴权

鉴权是一个系统比较常见的东西,这里就不做过多赘述

6、控制页面

上面所有对于配置的crud都是基于ApiPost来的,但是实际怎么也得通过一个页面来操作吧,至于这里我为啥不自己写个页面,给你个眼神自己体会~~

最后,本文代码地址:

https://github.com/sanyou3/sanyou-config-center

往期热门文章推荐

RocketMQ保姆级教程

三万字盘点Spring/Boot的那些常用扩展点

RocketMQ的push消费方式实现的太聪明了

一网打尽异步神器CompletableFuture

@Async注解的坑,小心

扫码或者搜索关注公众号 三友的java日记 ,及时干货不错过,公众号致力于通过画图加上通俗易懂的语言讲解技术,让技术更加容易学习,回复 面试 即可获得一套面试真题。

有关撸了一个简易的配置中心,顺带整合到了SpringCloud的更多相关文章

  1. ruby - 使用 Vim Rails,您可以创建一个新的迁移文件并一次性打开它吗? - 2

    使用带有Rails插件的vim,您可以创建一个迁移文件,然后一次性打开该文件吗?textmate也可以这样吗? 最佳答案 你可以使用rails.vim然后做类似的事情::Rgeneratemigratonadd_foo_to_bar插件将打开迁移生成的文件,这正是您想要的。我不能代表textmate。 关于ruby-使用VimRails,您可以创建一个新的迁移文件并一次性打开它吗?,我们在StackOverflow上找到一个类似的问题: https://sta

  2. ruby-on-rails - Rails - 一个 View 中的多个模型 - 2

    我需要从一个View访问多个模型。以前,我的links_controller仅用于提供以不同方式排序的链接资源。现在我想包括一个部分(我假设)显示按分数排序的顶级用户(@users=User.all.sort_by(&:score))我知道我可以将此代码插入每个链接操作并从View访问它,但这似乎不是“ruby方式”,我将需要在不久的将来访问更多模型。这可能会变得很脏,是否有针对这种情况的任何技术?注意事项:我认为我的应用程序正朝着单一格式和动态页面内容的方向发展,本质上是一个典型的网络应用程序。我知道before_filter但考虑到我希望应用程序进入的方向,这似乎很麻烦。最终从任何

  3. ruby-on-rails - 渲染另一个 Controller 的 View - 2

    我想要做的是有2个不同的Controller,client和test_client。客户端Controller已经构建,我想创建一个test_clientController,我可以使用它来玩弄客户端的UI并根据需要进行调整。我主要是想绕过我在客户端中内置的验证及其对加载数据的管理Controller的依赖。所以我希望test_clientController加载示例数据集,然后呈现客户端Controller的索引View,以便我可以调整客户端UI。就是这样。我在test_clients索引方法中试过这个:classTestClientdefindexrender:template=>

  4. ruby-on-rails - 独立 ruby​​ 脚本的配置文件 - 2

    我有一个在Linux服务器上运行的ruby​​脚本。它不使用rails或任何东西。它基本上是一个命令行ruby​​脚本,可以像这样传递参数:./ruby_script.rbarg1arg2如何将参数抽象到配置文件(例如yaml文件或其他文件)中?您能否举例说明如何做到这一点?提前谢谢你。 最佳答案 首先,您可以运行一个写入YAML配置文件的独立脚本:require"yaml"File.write("path_to_yaml_file",[arg1,arg2].to_yaml)然后,在您的应用中阅读它:require"yaml"arg

  5. ruby-on-rails - 如果 Object::try 被发送到一个 nil 对象,为什么它会起作用? - 2

    如果您尝试在Ruby中的nil对象上调用方法,则会出现NoMethodError异常并显示消息:"undefinedmethod‘...’fornil:NilClass"然而,有一个tryRails中的方法,如果它被发送到一个nil对象,它只返回nil:require'rubygems'require'active_support/all'nil.try(:nonexisting_method)#noNoMethodErrorexceptionanymore那么try如何在内部工作以防止该异常? 最佳答案 像Ruby中的所有其他对象

  6. ruby - 为什么 SecureRandom.uuid 创建一个唯一的字符串? - 2

    关闭。这个问题需要detailsorclarity.它目前不接受答案。想改进这个问题吗?通过editingthispost添加细节并澄清问题.关闭8年前。Improvethisquestion为什么SecureRandom.uuid创建一个唯一的字符串?SecureRandom.uuid#=>"35cb4e30-54e1-49f9-b5ce-4134799eb2c0"SecureRandom.uuid方法创建的字符串从不重复?

  7. Ruby Sinatra 配置用于生产和开发 - 2

    我已经在Sinatra上创建了应用程序,它代表了一个简单的API。我想在生产和开发上进行部署。我想在部署时选择,是开发还是生产,一些方法的逻辑应该改变,这取决于部署类型。是否有任何想法,如何完成以及解决此问题的一些示例。例子:我有代码get'/api/test'doreturn"Itisdev"end但是在部署到生产环境之后我想在运行/api/test之后看到ItisPROD如何实现? 最佳答案 根据SinatraDocumentation:EnvironmentscanbesetthroughtheRACK_ENVenvironm

  8. ruby-on-rails - Rails - 从另一个模型中创建一个模型的实例 - 2

    我有一个正在构建的应用程序,我需要一个模型来创建另一个模型的实例。我希望每辆车都有4个轮胎。汽车模型classCar轮胎模型classTire但是,在make_tires内部有一个错误,如果我为Tire尝试它,则没有用于创建或新建的activerecord方法。当我检查轮胎时,它没有这些方法。我该如何补救?错误是这样的:未定义的方法'create'forActiveRecord::AttributeMethods::Serialization::Tire::Module我测试了两个环境:测试和开发,它们都因相同的错误而失败。 最佳答案

  9. ruby - 用 Ruby 编写一个简单的网络服务器 - 2

    我想在Ruby中创建一个用于开发目的的极其简单的Web服务器(不,不想使用现成的解决方案)。代码如下:#!/usr/bin/rubyrequire'socket'server=TCPServer.new('127.0.0.1',8080)whileconnection=server.acceptheaders=[]length=0whileline=connection.getsheaders想法是从命令行运行这个脚本,提供另一个脚本,它将在其标准输入上获取请求,并在其标准输出上返回完整的响应。到目前为止一切顺利,但事实证明这真的很脆弱,因为它在第二个请求上中断并出现错误:/usr/b

  10. ruby - 一个 YAML 对象可以引用另一个吗? - 2

    我想让一个yaml对象引用另一个,如下所示:intro:"Hello,dearuser."registration:$introThanksforregistering!new_message:$introYouhaveanewmessage!上面的语法只是它如何工作的一个例子(这也是它在thiscpanmodule中的工作方式。)我正在使用标准的ruby​​yaml解析器。这可能吗? 最佳答案 一些yaml对象确实引用了其他对象:irb>require'yaml'#=>trueirb>str="hello"#=>"hello"ir

随机推荐