Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

从零开始,手写一个配置中心框架。基于 Apollo 和 Nacos 的设计思想,从零开始设计并实现一个 Java简易版配置中心,包括 Server 和 Client 两部分。 与 Spring Boot 的集成,处理通过@value注解和@ConfigurationProperties注解绑定的属性。

Notifications You must be signed in to change notification settings

ipipman/config-man

Folders and files

NameName
Last commit message
Last commit date

Latest commit

History

53 Commits

Repository files navigation

简易配置中心框架设计概述

引言

以下只是一个非常简易的配置中心版本,通过下述能大致理解配置中心核心原理,仅是用来学习和探讨。

在分布式系统中,配置管理是一个关键问题。一个高效的配置中心可以帮助我们集中管理配置,动态更新配置,并且与应用程序无缝集成。

本文将基于 Apollo 和 Nacos 的设计思想,从零开始设计并实现一个 Java简易版配置中心,包括 Server 和 Client 两部分。

其中,Server 负责保存所有持久化的配置数据,Client 通过 Server 提供的 API 获取所需的配置集合,并在 Server 数据变化时获取新的配置数据。

此外,还将与 Spring Boot 的集成,处理通过@Value注解和@ConfigurationProperties注解绑定的属性。

image-20240810172758905

总体设计

架构概述

配置中心由 Server 和 Client 两部分组成:

  • Server:负责存储和管理所有的配置数据,提供 API 供 Client 获取配置,并在配置变化时通知 Client。
  • Client:通过调用 Server 提供的 API 获取配置数据,并在配置变化时更新 Spring本地配置。

工作流程

  1. 配置存储:Server 端持久化配置数据,提供接口供管理员版本控制、添加、修改和删除配置。
  2. 配置获取:Client 端在启动时扫描所有配置,从 Server 获取所需的配置数据后初始化 Spring本地配置。
  3. 配置更新:Client 端长轮询感知 Server 端的配置数据变化,变化时更新 Spring本地配置。

主要模块

  • Server 模块:配置存储、API 服务、配置变更通知。
  • Client 模块:配置获取、配置变更监听、与 Spring Boot 集成。配置变更分为启动赋值、动态赋值两个部分

技术选型

  • Spring Boot:用于构建 Server 和 Client 应用,通过SpringMVC DeferredResult 实现配置变更通知 Client端。
  • Spring Cloud Context:当 Client端感知到配置变更时,像 Spring程序发布 EnvironmentChangeEvent 事件,通过监听这个事件实现 Spring本地配置动态更新。
  • MySQL:用于持久化存储配置数据,为了方便演示(本文方便演示,用H2)。

代码实现概述

安装核心依赖

Client 端

  1. spring-context :Spring Framework 的一个核心模块,主要用于管理应用程序上下文,提供依赖注入、事件机制、资源管理等基础功能。

  2. spring-cloud-context:是 spring-context 在分布式场景的一个扩展,支持分布式配置管理、上下文刷新、环境属性和消息总线等高级功能。

  3. 需要注意的是,按照 Spring 的规范,在容器启动后,无法通过修改配置文件来动态刷新标记了@ConfigurationProperties注解的类的属性。不过随着spring-cloud的出现,可以通过spring-cloud-context提供的EnvironmentChangeEvent实现配置的动态刷新,从而使应用程序能够在运行时动态修改配置类。

  4. okhttp:用于 client端 通过 http 访问 server端的网络工具类。

<dependency>
 <groupId>org.springframework</groupId>
 <artifactId>spring-context</artifactId>
 <version>6.1.6</version>
</dependency>
<!-- 用于配置自动更新 -->
<dependency>
 <groupId>org.springframework.cloud</groupId>
 <artifactId>spring-cloud-context</artifactId>
 <version>4.1.0</version>
</dependency>
 <dependency>
 <groupId>com.squareup.okhttp3</groupId>
 <artifactId>okhttp</artifactId>
 <version>4.12.0</version>
</dependency>

Server 端

  1. spring-web:作为配置中心的Server端,本文会用MVC特性实现长轮询。
  2. h2 或 mysql: 任意一个即可,用于持久化配置信息,配置中心一般都会用 mysql 进行持久化数据(*H2*内存型,方便演示)。
  3. mybatis: ORM框架,方便与 h2mysql数据库 进行CRUD操作。
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
 <groupId>com.h2database</groupId>
 <artifactId>h2</artifactId>
 <scope>runtime</scope>
</dependency>
<dependency>
 <groupId>org.mybatis.spring.boot</groupId>
 <artifactId>mybatis-spring-boot-starter</artifactId>
 <version>3.0.3</version>
</dependency>
<dependency>
 <groupId>mysql</groupId>
 <artifactId>mysql-connector-java</artifactId>
 <version>8.0.32</version>
</dependency>

Server端实现

关于参考概念

目前基本上都是至少3个维度管理key-value配置,目标是为了满足管理不同应用、不同环境、不同集群、不同空间的配置,进行合理的分层设计,便于规范的权限、流程治理等特性

image-20240810174918193

初始化数据

  1. application.yaml 中添加以下 mysql 驱动相关配置(演示用H2内存数据库即可)
spring:
 application:
 name: config-server
 datasource:
 driver-class-name: org.h2.Driver
 url: jdbc:h2:mem:h2db
 username: root
 password: 自定义
 sql:
 init:
 schema-locations: classpath:db.sql
 mode: always
 h2:
 console:
 enabled: true
 path: /h2
 settings:
 web-allow-others: true
mybatis:
 configuration:
 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  1. 创建 Configs 配置类、ConfigsMapper 接口类,基于 MyBatis 提供针对应用 (app)、命名空间 (ns) 和环境 (env) 的 CRUD 方法。
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Configs {
 private String app; // 应用
 private String env; // 环境
 private String ns; // 命名空间
 private String pkey; // 配置键
 private String pval; // 配置值
}
@Repository
@Mapper
public interface ConfigsMapper {
 @Select("select * from configs where app=#{app} and env=#{env} and ns=#{ns}")
 List<Configs> list(String app, String env, String ns);
 @Select("select * from configs where app=#{app} and env=#{env} and ns=#{ns} and pkey=#{pkey}")
 Configs select(String app, String env, String ns, String pkey);
 @Insert("insert into configs(app, env, ns, pkey, pval) values(#{app}, #{env}, #{ns}, #{pkey}, #{pval})")
 int insert(Configs configs);
 @Update("update configs set pval=#{pval} where app=#{app} and env=#{env} and ns=#{ns} and pkey=#{pkey}")
 int update(Configs configs);
}
  1. classpath目录下(即resources文件夹),添加一个名为db.sql的文件,用于创建和初始化配置表configs的数据。

pkey:参数键 , pval:参数值

create table if not exists `configs` (
 `app` varchar(64) not null,
 `env` varchar(64) not null,
 `ns` varchar(64) not null,
 `pkey` varchar(64) not null,
 `pval` varchar(128) null
);
insert into configs(app, env, ns, pkey, pval) values('app1', 'dev', 'public', 'ipman.a', 'dev100');
insert into configs(app, env, ns, pkey, pval) values('app1', 'dev', 'public', 'ipman.b', 'http://localhost:9192');
insert into configs(app, env, ns, pkey, pval) values('app1', 'dev', 'public', 'ipman.c', 'cc100');

以上是数据等准备工作...

开发服务端

支持长轮询

image-20240810174940635

目标是为了客户端和服务端保持了一个长连接,从而能第一时间获得配置更新的推送。参考 Apollo 考虑到会有数万客户端向服务端发起长连,在服务端使用了async servlet (Spring DeferredResult) 来服务Http Long Polling请求。

实现 WebMvcConfigurer 配置,主要是配置异步请求支持,设置任务执行器和超时时间。关于DeferredResult 的代码实现后续会讲。

/**
 * WebMvc配置类,用于自定义Spring MVC的配置
 */
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
 /**
 * 定义并配置一个线程池任务执行器,用于处理异步请求。
 *
 * @return 配置好的ThreadPoolTaskExecutor实例。
 */
 @Bean
 public ThreadPoolTaskExecutor mvcTaskExecutor() {
 ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
 executor.setCorePoolSize(10); // 核心线程数
 executor.setQueueCapacity(100); // 队列容量
 executor.setMaxPoolSize(25); // 最大线程数
 return executor;
 }
 /**
 * 配置异步请求支持,设置任务执行器和超时时间。
 *
 * @param configurer 异步支持配置器
 */
 @Override
 public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
 configurer.setTaskExecutor(mvcTaskExecutor());
 configurer.setDefaultTimeout(60_000L); // 设置默认超时时间 10s
 }
 /**
 * 全局异常处理器,捕获并处理异步请求超时异常。
 */
 @ControllerAdvice
 static class GlobalExceptionHandler {
 /**
 * 处理异步请求超时异常,返回304状态码。
 *
 * @param e 异常实例
 * @param request HTTP请求
 */
 @ResponseStatus(HttpStatus.NOT_MODIFIED) //返回 304 状态码
 @ResponseBody
 @ExceptionHandler(AsyncRequestTimeoutException.class) //捕获特定异常
 public void handleAsyncRequestTimeoutException(AsyncRequestTimeoutException e, HttpServletRequest request) {
 System.out.println("handleAsyncRequestTimeoutException");
 }
 }
}

提供基础服务

Map<String, Long> VERSION:用于存储各应用的配置版本号,当配置发生变化时会更新该版本号。

MultiValueMap<String, DeferredResult<Long>> appKeyDeferredResult:当客户端请求服务器获取指定应用的版本号时,该请求会被DeferredResult挂起并保持长连接(这点类似于 Apollo 默认的60秒超时)。如果在这段时间内有客户端关注的配置发生变化,挂起的客户端请求会立即返回。

此外,还包括一些客户端访问服务器的核心接口,例如:

  • 查询数据库以获取配置列表
  • 更新或插入数据库中的配置
  • 获取配置版本号
/**
 * 配置服务控制器,提供配置的查询、更新和版本查询功能
 */
@RestController
@Slf4j
public class ConfigController {
 @Autowired
 ConfigsMapper mapper;
 // 用于存储配置的版本信息
 Map<String, Long> VERSION = new HashMap<>();
 // 用于存储appKey与DeferredResult之间的映射,以支持异步返回配置版本信息
 MultiValueMap<String, DeferredResult<Long>> appKeyDeferredResult = new LinkedMultiValueMap<>();
 // 生成应用键
 static String getAppKey(String app, String env, String ns) {
 return app + "-" + env + "-" + ns;
 }
 /**
 * 查询配置列表。
 *
 * @param app 应用名称
 * @param env 环境标识
 * @param ns 命名空间
 * @return 配置列表
 */
 @RequestMapping("/list")
 public List<Configs> list(@RequestParam("app") String app,
 @RequestParam("env") String env,
 @RequestParam("ns") String ns) {
 return mapper.list(app, env, ns);
 }
 /**
 * 更新配置。
 *
 * @param app 应用名称
 * @param env 环境标识
 * @param ns 命名空间
 * @param params 要更新的配置参数映射
 * @return 更新后的配置列表
 */
 @RequestMapping("/update")
 public List<Configs> update(@RequestParam("app") String app,
 @RequestParam("env") String env,
 @RequestParam("ns") String ns,
 @RequestBody Map<String, String> params) {
 String appKey = getAppKey(app, env, ns);
 log.info("config update. push {} {}", app, params);
 log.debug("config update. push in defer debug {} {}", app, params);
 // 查询或更新配置, 并更新版本号
 params.forEach((k, v) -> insertOrUpdate(new Configs(app, env, ns, k, v)));
 VERSION.put(appKey, System.currentTimeMillis());
 // 如果有配置更新, 返回获取版本 /version 的请求
 List<DeferredResult<Long>> deferredResults = appKeyDeferredResult.get(appKey);
 if (deferredResults != null) {
 deferredResults.forEach(deferredResult -> {
 Long version = VERSION.getOrDefault(appKey, -1L);
 deferredResult.setResult(version);
 log.debug("config version poll set defer for {} {}", ns, version);
 });
 }
 return mapper.list(app, env, ns);
 }
 /**
 * 插入或更新配置项
 * @param configs 查询或更新配置
 */
 private void insertOrUpdate(Configs configs) {
 Configs conf = mapper.select(configs.getApp(), configs.getEnv(), configs.getNs(), configs.getPkey());
 if (conf == null) {
 mapper.insert(configs);
 } else {
 mapper.update(configs);
 }
 }
 /**
 * 异步查询配置版本。
 *
 * @param app 应用名称
 * @param env 环境标识
 * @param ns 命名空间
 * @return DeferredResult,异步返回配置的版本号
 */
 @GetMapping("/version")
 public DeferredResult<Long> version(@RequestParam("app") String app,
 @RequestParam("env") String env,
 @RequestParam("ns") String ns) {
 String appKey = getAppKey(app, env, ns);
 log.info("config version poll {}", appKey);
 log.debug("config version poll in defer debug {}", appKey);
 // 创建并返回一个异步结果对象,用于后续通知
 DeferredResult<Long> deferredResult = new DeferredResult<>();
 deferredResult.onCompletion(() -> {
 System.out.println("onCompletion");
 appKeyDeferredResult.remove(appKey);
 });
 deferredResult.onTimeout(() -> {
 System.out.println("onTimeout");
 appKeyDeferredResult.remove(appKey);
 });
 deferredResult.onError((Throwable t) -> {
 System.out.println("onError");
 appKeyDeferredResult.remove(appKey);
 });
 appKeyDeferredResult.add(appKey, deferredResult);
 log.debug("return defer for {}", ns);
 return deferredResult;
 }
}

启动基础服务

server端版本号定义9129,用于client端访问

server:
 port: 9129

image-20240810181103760

Client端实现

关于核心概念

客户端的实现相对复杂得多,需要与服务器保持心跳长连接,以便在配置变更时及时更新本地配置。此外,客户端还需兼容 Spring PropertySource 中任意配置源的变更(如:xx.yaml, xxx.properties)。配置变更的赋值过程主要分为两个部分:容器启动时的赋值启动后的动态赋值

需要确保配置中心的配置优先级高于本地默认配置,并且同时支持@Value注解和@ConfigurationProperties注解下的配置变更操作。

image-20240810175002901

开发客户端

整体设计包含几个概念。

image-20240810175022683

集成自定义Spring配置源

  1. IMPropertySource:将自定义的IMConfigService配置实现类包装成Spring Framework的键值对配置属性源。这样就支持了在@Value注解和@ConfigurationProperties注解下获取配置的场景了。
  2. IMConfigService:自定义配置实现类,用于客户端应用获取配置信息,包括获取所有配置、按键获取指定配置、处理配置变化等。

与Server端建立通信

  1. IMRepository:用于从Server端获取配置,通过长轮询检测应用配置版本变化,并获取最新配置信息。当检测到配置变化时,通知IMConfigService处理配置变化。
  2. IMRepositoryChangeListener:定义配置变化时的回调方法,由IMRepository的配置变更检测触发,IMConfigService负责实现和处理配置变化。
  3. ConfigMeta:用于配置Client端访问Server端的接口地址、应用、环境和命名空间等信息。

自定义Spring配置数据源

目标:无缝衔接Spring设置和获取 配置的方式,利用 spring-cloud-context 发布spring 配置变更事件,实现@ConfigurationProperties 注解下的配置动态更新(@Value 注解下,动态修改配置的方式后面会讲)

IMConfigService:配置服务接口,用于管理和提供配置信息。实现IMRepositoryChangeListener接口,以监听和处理配置变更。

/**
 * 配置服务接口,用于管理和提供配置信息。
 * 实现了IMRepositoryChangeListener接口,用于监听配置的变更。
 */
public interface IMConfigService extends IMRepositoryChangeListener {
 /**
 * 获取默认配置服务实例。
 *
 * @param applicationContext 应用上下文,用于获取应用相关资源。
 * @param meta 配置元数据,描述配置的来源和其它必要信息。
 * @return 返回配置服务实例。
 */
 static IMConfigService getDefault(ApplicationContext applicationContext, ConfigMeta meta) {
 // 获取默认配置仓库实例, 从仓库中(远程server服务)上加载配置
 IMRepository repository = IMRepository.getDefault(meta);
 // 从配置中心server,获取配置
 Map<String, String> config = repository.getConfig();
 // 创建配置服务实例
 IMConfigService configService = new IMConfigServiceImpl(applicationContext, config);
 // 注册配置变更监听器
 repository.addListener(configService);
 return configService;
 }
 /**
 * 获取所有配置属性的名称。
 *
 * @return 返回配置属性名称数组。
 */
 String[] getPropertyNames();
 /**
 * 根据属性名称获取属性值
 *
 * @param name 属性名称。
 * @return 返回属性值,如果不存在,则返回null。
 */
 String getProperty(String name);
}

IMPropertySource:继承 EnumerablePropertySource,将 IMConfigServiceImpl定义到Spring 配置的数据源中。

/**
 * 该类是EnumerablePropertySource的子类,用于提供配置属性。
 * 它将IMConfigService作为属性源,
 * - 可以通过getPropertyNames()获取所有属性名,
 * - 通过getProperty(String name)获取指定属性的值。
 */
public class IMPropertySource extends EnumerablePropertySource<IMConfigService> {
 /**
 * 构造函数,初始化属性源。
 * 通过SpringPropertySource添加配置中心数据源, 这样Spring就能拿到我们写入的配置了
 *
 * @param name 属性源的名称。
 * @param source 提供配置属性的服务实例。
 */
 public IMPropertySource(String name, IMConfigService source) {
 super(name, source);
 }
 @Override
 @SuppressWarnings("NullableProblems")
 public String[] getPropertyNames() {
 return source.getPropertyNames();
 }
 @Override
 public Object getProperty(@Nullable String name) {
 return source.getProperty(name);
 }
}

IMConfigServiceImpl:客户端本地配置管理

IMConfigServiceImpl使用Map<String, String> config存储客户端本地配置,提供以下核心功能:

  • 获取所有配置属性名称
  • 根据属性名称获取对应的配置值
  • 处理配置变化

启动阶段:

  • 配置初始化:config配置通过IMRepository#getConfig方法从服务器端获取。
  • 配置注入:IMConfigServiceImpl被添加到 Spring 的PropertySource中,使得 Spring 应用可以使用@Value注解和@ConfigurationProperties注解来获取配置。获取时会调用IMConfigServiceImpl#getPropertyNames方法。
  • 配置变更处理:onChange方法监听IMRepository#heartbeat方法。当收到配置变更事件时,通过applicationContext.publishEvent(new EnvironmentChangeEvent(keys))发布 Spring 应用配置变更事件。Spring Cloud Context 接收到该事件后,会扫描并重新初始化@ConfigurationProperties的 bean 以更新配置信息。

注意事项:

  • 标记@Value注解的属性无法通过上述方式修改值,只能通过反射的方式进行修改,具体方法将在后续部分详细说明。
/**
 * 配置服务实现类,用于管理和提供配置信息
 */
@Slf4j
public class IMConfigServiceImpl implements IMConfigService {
 // 配置信息
 Map<String, String> config;
 // 应用上下文
 ApplicationContext applicationContext;
 /**
 * 构造函数,初始化配置服务。
 *
 * @param applicationContext 应用上下文,用于发布事件。
 * @param config 初始配置信息。
 */
 public IMConfigServiceImpl(ApplicationContext applicationContext, Map<String, String> config) {
 this.applicationContext = applicationContext;
 this.config = config;
 }
 /**
 * 获取所有配置属性的名称。
 *
 * @return 配置属性名称数组。
 */
 @Override
 public String[] getPropertyNames() {
 if (this.config == null) {
 return new String[]{};
 }
 return this.config.keySet().toArray(new String[0]);
 }
 /**
 * 根据属性名称获取对应的配置值。
 *
 * @param name 属性名称。
 * @return 对应的配置值,如果不存在则返回null。
 */
 @Override
 public String getProperty(String name) {
 return this.config.getOrDefault(name, null);
 }
 /**
 * 配置发生变化时的处理逻辑。
 * 更新配置信息,并发布环境变更事件。
 *
 * @param changeEvent 配置变更事件,包含新的配置信息。
 */
 @Override
 public void onChange(ChangeEvent changeEvent) {
 // 对比新旧值的变化
 Set<String> keys = calcChangeKeys(config, changeEvent.config());
 if (keys.isEmpty()) {
 log.info("[IM_CONFIG] calcChangeKeys return empty, ignore update.");
 }
 this.config = changeEvent.config();
 if (!config.isEmpty()) {
 /// 通过 spring-cloud-context 刷新配置
 log.info("[IM_CONFIG] fire an EnvironmentChangeEvent with keys:" + config.keySet());
 applicationContext.publishEvent(new EnvironmentChangeEvent(keys));
 }
 }
 /**
 * 计算配置变化的键集合。
 *
 * @param oldConfigs 旧配置信息。
 * @param newConfigs 新配置信息。
 * @return 发生变化的配置键集合。
 */
 private Set<String> calcChangeKeys(Map<String, String> oldConfigs, Map<String, String> newConfigs) {
 if (oldConfigs.isEmpty()) return newConfigs.keySet();
 if (newConfigs.isEmpty()) return oldConfigs.keySet();
 // 比较新旧配置,找出变化的键
 Set<String> news = newConfigs.keySet().stream()
 .filter(key -> !newConfigs.get(key).equals(oldConfigs.get(key)))
 .collect(Collectors.toSet());
 oldConfigs.keySet().stream()
 .filter(key -> !newConfigs.containsKey(key))
 .forEach(news::add);
 return news;
 }
}

访问Server获取和监听配置

目标:向配置中心 server端获取数据,感知配置变化并发布事件通知 IMConfigServiceImpl 再发布Spring配置变更事件

IMRepositoryChangeListener : 提供配置发生变化时的回调 onChange方法。

@FunctionalInterface
public interface IMRepositoryChangeListener {
 /**
 * 配置发生变化时的回调方法。
 *
 * @param changeEvent 包含配置元数据和新配置信息的事件对象。
 * - meta: 配置的元数据,描述了配置的相关信息。
 * - config: 新的配置信息,以键值对的形式存储。
 */
 void onChange(ChangeEvent changeEvent);
 /**
 * ChangeEvent 类是一个记录类(JDK 16及以上版本特性),用于封装配置变化事件的信息。
 * 包含配置的元数据和新配置的数据。
 */
 record ChangeEvent(ConfigMeta meta, Map<String, String> config) {}
 // 如果jdk版本低于16, 不兼容record, 以下是低版本Java的实现
 // @Data
 // @AllArgsConstructor
 // class ChangeEvent {
 // private ConfigMeta meta;
 // private Map<String, String> config;
 // }
}

IMRepository:定义获取当前所有配置、添加配置变更监听器等核心方法。

public interface IMRepository {
 /**
 * 获取默认配置仓库实例。
 * 通过给定的配置元数据初始化配置仓库。
 *
 * @param meta 配置元数据,描述配置源的相关信息。
 * @return 返回默认配置仓库实例。
 */
 static IMRepository getDefault(ConfigMeta meta) {
 return new IMRepositoryImpl(meta);
 }
 /**
 * 获取当前所有配置。
 * 该方法用于一次性获取配置源中的所有配置项。
 *
 * @return 返回包含所有配置项的Map,配置项的键为配置名,值为配置值。
 */
 Map<String, String> getConfig();
 /**
 * 添加配置变更监听器。
 * 通过添加监听器,可以监听配置项的变更事件。
 *
 * @param listener 配置变更监听器实例。
 */
 void addListener(IMRepositoryChangeListener listener);
}

IMRepositoryImpl:实现了IMRepository接口的配置仓库类,用于管理和更新配置数据。最核心的方法是 heartbeat 用于通过Server端获取配置的版本号,用于检测配置版本是否需要更新。

  • 注意:以下关于 HttpUtils 的方法代码省略
/**
 * 实现了IMRepository接口的配置仓库类,用于管理和更新配置数据。
 */
public class IMRepositoryImpl implements IMRepository {
 // 当前配置实例的元数据信息, 列: 应用,环境,命名空间,配置服务信息
 ConfigMeta meta;
 // 存储配置的版本信息
 Map<String, Long> versionMap = new HashMap<>();
 // 存储配置数据
 Map<String, Map<String, String>> configMap = new HashMap<>();
 // 定时任务执行器
 // 配置变更监听器列表
 List<IMRepositoryChangeListener> listeners = new ArrayList<>();
 /**
 * 构造函数,初始化配置仓库
 *
 * @param meta 配置元数据,用于指定配置服务的地址和密钥等信息。
 */
 public IMRepositoryImpl(ConfigMeta meta) {
 this.meta = meta;
 // 异步长轮训心跳检测任务
 new Thread(this::heartbeat).start();
 }
 /**
 * 添加配置变更监听器。
 *
 * @param listener 配置变更监听器实例。
 */
 public void addListener(IMRepositoryChangeListener listener) {
 listeners.add(listener);
 }
 /**
 * 获取所有配置, 第一次初始化时, 通过Config-Server获取
 *
 * @return 返回当前配置的数据映射表。
 */
 @Override
 public Map<String, String> getConfig() {
 String key = meta.genKey();
 if (configMap.containsKey(key)) {
 return configMap.get(key);
 }
 return findAll();
 }
 /**
 * 获取所有配置, 通过Config-Server获取
 *
 * @return 返回从配置服务器获取到的配置数据映射表。
 */
 private @NotNull Map<String, String> findAll() {
 String listPath = meta.listPath();
 System.out.println("[IM_CONFIG] list all configs from ipman config server.");
 List<Configs> configs = HttpUtils.httpGet(listPath, new TypeReference<List<Configs>>() {
 });
 Map<String, String> resultMap = new HashMap<>();
 configs.forEach(c -> resultMap.put(c.getPkey(), c.getPval()));
 return resultMap;
 }
 /**
 * 心跳检测任务, 通过Config-Server获取配置的版本号,用于检测配置版本是否有更新。
 */
 private void heartbeat() {
 while (true) {
 try {
 // 通过请求Config-Server获取配置版本号
 String versionPath = meta.versionPath();
 HttpUtils.OkHttpInvoker okHttpInvoker = new HttpUtils.OkHttpInvoker();
 okHttpInvoker.init(20_000, 128, 300);
 Long version = JSON.parseObject(okHttpInvoker.get(versionPath), new TypeReference<Long>() {
 });
 // 检查是否有配置更新
 String key = meta.genKey();
 Long oldVersion = versionMap.getOrDefault(key, -1L);
 if (version > oldVersion) {
 System.out.println("[IM_CONFIG] current=" + version + ", old=" + oldVersion);
 System.out.println("[IM_CONFIG] need update new configs.");
 versionMap.put(key, version);
 Map<String, String> newConfigs = findAll();
 configMap.put(key, newConfigs);
 // 通知所有监听器配置发生了变更
 System.out.println("[IM_CONFIG] fire an EnvironmentChangeEvent with keys:" + newConfigs.keySet());
 listeners.forEach(listener ->
 listener.onChange(new IMRepositoryChangeListener.ChangeEvent(meta, newConfigs)));
 }
 } catch (Exception e) {
 System.out.println("[IM_CONFIG] loop request new configs.");
 }
 }
 }
}

配置Spring自定义数据源

目标:将自定义PropertySource添加到 Spring容器中运行

PropertySourcesProcessor 是一个配置类

  1. 获取已有的配置列表: 获取当前ConfigurableEnvironment中的配置列表。
  2. 初始化配置元数据: 设置ConfigMeta,包括服务器请求地址、应用名称、环境、命名空间等信息。
  3. 初始化配置服务: 先初始化IMRepositoryImpl从服务器获取配置,然后初始化IMConfigServiceImpl实现配置获取和配置变更等基础功能。
  4. 包装配置服务: 将IMConfigServiceImpl包装成IMPropertySource,使其成为 Spring 的配置数据源。
  5. 组合属性源: 将IMPropertySource添加到CompositePropertySource中,形成一个复合的属性源。
  6. 设置优先级: 将自定义的属性源添加到ConfigurableEnvironment的配置列表中,并设置为最高优先级。
/**
 * 该类是一个配置类,用于在Spring应用启动时,通过http请求从ipman-config-server获取配置,并将配置添加到Spring环境变量中。
 */
@Data
public class PropertySourcesProcessor implements BeanFactoryPostProcessor, ApplicationContextAware, EnvironmentAware, PriorityOrdered {
 private final static String IPMAN_PROPERTY_SOURCES = "IMPropertySources";
 private final static String IPMAN_PROPERTY_SOURCE = "IMPropertySource";
 Environment environment;
 ApplicationContext applicationContext;
 /**
 * 处理 BeanFactory,在 Spring 应用启动过程中注入自定义属性源。
 *
 * @param beanFactory ConfigurableListableBeanFactory,
 * Spring BeanFactory 的一个接口,提供访问和操作 Spring 容器中所有 Bean 的能力。
 * @throws BeansException 如果处理过程中发生错误。
 */
 @Override
 public void postProcessBeanFactory(@NonNull ConfigurableListableBeanFactory beanFactory) throws BeansException {
 // 检查是否已存在 ipman 的属性源,若存在则不重复添加
 ConfigurableEnvironment ENV = (ConfigurableEnvironment) environment;
 if (ENV.getPropertySources().contains(IPMAN_PROPERTY_SOURCES)) {
 return;
 }
 // 设置config-server远程服务的调用信息
 String app = ENV.getProperty("ipman.app", "app1");
 String env = ENV.getProperty("ipman.env", "dev");
 String ns = ENV.getProperty("ipman.ns", "public");
 String configServer = ENV.getProperty("ipman.configServer", "http://localhost:9129");
 // 使用获取到的配置创建配置服务和属性源
 ConfigMeta configMeta = new ConfigMeta(app, env, ns, configServer);
 // 创建配置中心实现类, 省去技术细节, 理解了下:
 // 1.启动时候 ConfigService 从 Repository拿配置, 同时 Repository 关联了 ConfigService 这个对象,.
 // 2.当 Repository 巡检发现配置变了, 在去改 ConfigService 里的 config.
 // 3.改完后, 最终再用EnvironmentChangeEvent 去刷新
 IMConfigService configService = IMConfigService.getDefault(applicationContext, configMeta);
 // 创建SpringPropertySource, 此时Spring就能识别我们自定义的配置了
 IMPropertySource propertySource = new IMPropertySource(IPMAN_PROPERTY_SOURCE, configService);
 // 创建组合属性源并将 ipman 的属性源添加到其中
 CompositePropertySource composite = new CompositePropertySource(IPMAN_PROPERTY_SOURCES);
 composite.addPropertySource(propertySource);
 // 将组合属性源添加到环境变量中,并确保其被最先访问
 ENV.getPropertySources().addFirst(composite);
 }
 /**
 * 获取Bean处理器的优先级,实现 PriorityOrdered 接口。
 *
 * @return int 返回处理器的优先级,值越小优先级越高。
 */
 @Override
 public int getOrder() {
 return Ordered.HIGHEST_PRECEDENCE;
 }
 /**
 * 设置 Spring 环境配置
 *
 * @param environment Environment,Spring 环境接口,提供环境变量的访问。
 */
 @Override
 public void setEnvironment(@NonNull Environment environment) {
 this.environment = environment;
 }
 /**
 * 设置应用上下文
 *
 * @param applicationContext Spring应用的上下文环境。
 */
 @Override
 public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
 this.applicationContext = applicationContext;
 }
}

关于Spring中@Value配置

在 Spring 中,@Value注解支持多种形式的占位符配置。以下是一些常见的形式及其解析方式:

  1. 简单占位符:
@Value("${some.key}")
private String someKey;

解析后得到的键:"some.key"

  1. 嵌套占位符:
@Value("${${some.key}}")
private String nestedKey;

解析后得到的键:"some.key"

  1. 带默认值的占位符:
@Value("${some.key:${some.other.key:100}}")
private String someKeyWithDefault;

解析后得到的键:"some.key", "some.other.key"

  1. 嵌套占位符带默认值:
@Value("${${some.key:other.key}}")
private String nestedKeyWithDefault;

解析后得到的键:"some.key"

  1. 多重嵌套占位符:
@Value("${${some.key}:${another.key}}")
private String multiNestedKey;

解析后得到的键:"some.key", "another.key"

  1. 结合 SpEL 表达式:
@Value("#{new java.text.SimpleDateFormat('${some.key}').parse('${another.key}')}")
private Date parsedDate;

解析后得到的键:"some.key", "another.key"

总结起来,@Value注解支持以下几种占位符配置形式:

  • 简单占位符${some.key}
  • 带默认值的占位符${some.key:${some.other.key:100}}
  • 嵌套占位符${${some.key}}
  • 嵌套占位符带默认值${${some.key:other.key}}
  • 多重嵌套占位符${${some.key}:${another.key}}
  • 结合 SpEL 表达式#{new java.text.SimpleDateFormat('${some.key}').parse('${another.key}')}

下面提供了某配置中心,开源版本中@Value配置的解析工具, 通过 #extractPlaceholderKeys 可以解析 @Value 注解 ${} 中的配置key

public class PlaceholderHelper {
 private static final String PLACEHOLDER_PREFIX = "${";
 private static final String PLACEHOLDER_SUFFIX = "}";
 private static final String VALUE_SEPARATOR = ":";
 private static final String SIMPLE_PLACEHOLDER_PREFIX = "{";
 private static final String EXPRESSION_PREFIX = "#{";
 private static final String EXPRESSION_SUFFIX = "}";
 private PlaceholderHelper() {
 }
 private static final PlaceholderHelper INSTANCE = new PlaceholderHelper();
 public static PlaceholderHelper getInstance() {
 return INSTANCE;
 }
 /**
 * Resolve placeholder property values, e.g.
 * <br />
 * <br />
 * "${somePropertyValue}" -> "the actual property value"
 */
 public Object resolvePropertyValue(ConfigurableBeanFactory beanFactory, String beanName, String placeholder) {
 // resolve string value
 String strVal = beanFactory.resolveEmbeddedValue(placeholder);
 BeanDefinition bd = (beanFactory.containsBean(beanName) ? beanFactory
 .getMergedBeanDefinition(beanName) : null);
 // resolve expressions like "#{systemProperties.myProp}"
 return evaluateBeanDefinitionString(beanFactory, strVal, bd);
 }
 private Object evaluateBeanDefinitionString(ConfigurableBeanFactory beanFactory, String value,
 BeanDefinition beanDefinition) {
 if (beanFactory.getBeanExpressionResolver() == null) {
 return value;
 }
 Scope scope = (beanDefinition != null ? beanFactory
 .getRegisteredScope(Objects.requireNonNull(beanDefinition.getScope())) : null);
 return beanFactory.getBeanExpressionResolver()
 .evaluate(value, new BeanExpressionContext(beanFactory, scope));
 }
 /**
 * Extract keys from placeholder, e.g.
 * <ul>
 * <li>${some.key} => "some.key"</li>
 * <li>${some.key:${some.other.key:100}} => "some.key", "some.other.key"</li>
 * <li>${${some.key}} => "some.key"</li>
 * <li>${${some.key:other.key}} => "some.key"</li>
 * <li>${${some.key}:${another.key}} => "some.key", "another.key"</li>
 * <li>#{new java.text.SimpleDateFormat('${some.key}').parse('${another.key}')} => "some.key", "another.key"</li>
 * </ul>
 */
 public Set<String> extractPlaceholderKeys(String propertyString) {
 Set<String> placeholderKeys = new LinkedHashSet<>();
 if (!isNormalizedPlaceholder(propertyString) && !isExpressionWithPlaceholder(propertyString)) {
 return placeholderKeys;
 }
 Stack<String> stack = new Stack<>();
 stack.push(propertyString);
 while (!stack.isEmpty()) {
 String strVal = stack.pop();
 int startIndex = strVal.indexOf(PLACEHOLDER_PREFIX);
 if (startIndex == -1) {
 placeholderKeys.add(strVal);
 continue;
 }
 int endIndex = findPlaceholderEndIndex(strVal, startIndex);
 if (endIndex == -1) {
 // invalid placeholder?
 continue;
 }
 String placeholderCandidate = strVal.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex);
 // ${some.key:other.key}
 if (placeholderCandidate.startsWith(PLACEHOLDER_PREFIX)) {
 stack.push(placeholderCandidate);
 } else {
 // some.key:${some.other.key:100}
 int separatorIndex = placeholderCandidate.indexOf(VALUE_SEPARATOR);
 if (separatorIndex == -1) {
 stack.push(placeholderCandidate);
 } else {
 stack.push(placeholderCandidate.substring(0, separatorIndex));
 String defaultValuePart =
 normalizeToPlaceholder(placeholderCandidate.substring(separatorIndex + VALUE_SEPARATOR.length()));
 if (StringUtils.hasText(defaultValuePart)) {
 stack.push(defaultValuePart);
 }
 }
 }
 // has remaining part, e.g. ${a}.${b}
 if (endIndex + PLACEHOLDER_SUFFIX.length() < strVal.length() - 1) {
 String remainingPart = normalizeToPlaceholder(strVal.substring(endIndex + PLACEHOLDER_SUFFIX.length()));
 if (!StringUtils.hasText(remainingPart)) {
 stack.push(remainingPart);
 }
 }
 }
 return placeholderKeys;
 }
 private boolean isNormalizedPlaceholder(String propertyString) {
 return propertyString.startsWith(PLACEHOLDER_PREFIX) && propertyString.endsWith(PLACEHOLDER_SUFFIX);
 }
 private boolean isExpressionWithPlaceholder(String propertyString) {
 return propertyString.startsWith(EXPRESSION_PREFIX) && propertyString.endsWith(EXPRESSION_SUFFIX)
 && propertyString.contains(PLACEHOLDER_PREFIX);
 }
 private String normalizeToPlaceholder(String strVal) {
 int startIndex = strVal.indexOf(PLACEHOLDER_PREFIX);
 if (startIndex == -1) {
 return null;
 }
 int endIndex = strVal.lastIndexOf(PLACEHOLDER_SUFFIX);
 if (endIndex == -1) {
 return null;
 }
 return strVal.substring(startIndex, endIndex + PLACEHOLDER_SUFFIX.length());
 }
 private int findPlaceholderEndIndex(CharSequence buf, int startIndex) {
 int index = startIndex + PLACEHOLDER_PREFIX.length();
 int withinNestedPlaceholder = 0;
 while (index < buf.length()) {
 if (StringUtils.substringMatch(buf, index, PLACEHOLDER_SUFFIX)) {
 if (withinNestedPlaceholder > 0) {
 withinNestedPlaceholder--;
 index = index + PLACEHOLDER_SUFFIX.length();
 } else {
 return index;
 }
 } else if (StringUtils.substringMatch(buf, index, SIMPLE_PLACEHOLDER_PREFIX)) {
 withinNestedPlaceholder++;
 index = index + SIMPLE_PLACEHOLDER_PREFIX.length();
 } else {
 index++;
 }
 }
 return -1;
 }
 public static void main(String[] args) {
 String strVal = "${some.key:other.key}";
 System.out.println(new PlaceholderHelper().extractPlaceholderKeys(strVal));
 strVal = "${some.key:${some.other.key:100}}";
 System.out.println(new PlaceholderHelper().extractPlaceholderKeys(strVal));
 strVal = "${${some.key}}";
 System.out.println(new PlaceholderHelper().extractPlaceholderKeys(strVal));
 strVal = "${${some.key:other.key}}";
 System.out.println(new PlaceholderHelper().extractPlaceholderKeys(strVal));
 strVal = "${${some.key}:${another.key}}";
 System.out.println(new PlaceholderHelper().extractPlaceholderKeys(strVal));
 }
}

动态处理被 @Value 注解的配置目标

目标:由于EnvironmentChangeEvent应用事件只能动态修改@ConfigurationProperties相关的类属性,因此标记了@Value注解的类成员变量无法通过这种方式进行动态修改。为了解决这个问题,需要采用以下方式进行处理:

SpringValue:用于声明@Value注解的配置信息。

@Data
@AllArgsConstructor
public class SpringValue {
 private Object bean; // 配置关联的关联的 Bean 对象
 private String beanName; // 配置关联的关联的 Bean 对象名称
 private String key; // @Value配置的key
 private String placeholder; // @Value配置的占位符
 private Field field; // @Value配置的 Bean 成员
}

FieldUtils: 用于扫描Bean中是否有特定Value注解Filed成员

/**
 * 提供用于检索类中具有特定注解或满足某些条件的字段的工具方法
 */
public interface FieldUtils {
 /**
 * 查找类中所有被指定注解标注的字段
 *
 * @param aClass 要搜索的类。
 * @param annotationClass 指定的注解类型。
 * @return 所有被指定注解标注的字段列表。
 */
 static List<Field> findAnnotatedField(Class<?> aClass, Class<? extends Annotation> annotationClass) {
 return findField(aClass, f -> f.isAnnotationPresent(annotationClass));
 }
 /**
 * 根据给定的函数条件查找类中所有满足条件的字段
 *
 * @param aClass 要搜索的类。
 * @param function 用于判断字段是否满足条件的函数。
 * @return 所有满足条件的字段列表。
 */
 static List<Field> findField(Class<?> aClass, Function<Field, Boolean> function) {
 List<Field> result = new ArrayList<>();
 while (aClass != null) {
 Field[] fields = aClass.getDeclaredFields();
 for (Field f : fields) {
 if (function.apply(f)) {
 result.add(f);
 }
 }
 // spring中有些类会被CGLIB代理,所以需要通过父类获取Field
 aClass = aClass.getSuperclass();
 }
 return result;
 }
}

SpringValueProcessor:动态更新 @Value 注解的成员变量

IMConfigServiceImpl触发配置变更后,会发布EnvironmentChangeEvent应用事件。此时,需要监听这个事件,并对标记了@Value注解的成员变量进行动态赋值。

  1. 实现BeanPostProcessor后置处理器:
  • 扫描类中是否存在@Value注解的成员变量。
  • 如果存在,继续处理。
  1. 记录注解信息:
  • 获取成员变量实例,提取${}占位符信息(例如,@Value("${some.key}")中的some.key)。
  • 获取Field实例、Bean 实例和 key 名称。
  • 将这些信息记录到VALUE_HOLDER集合中,以便后续使用。
  1. 监听EnvironmentChangeEvent配置变更事件:
  • 当监听到EnvironmentChangeEvent事件时,从VALUE_HOLDER中获取与 key 相关的所有Field实例。
  • 通过反射解析并设置新的值。
/**
 * process spring value
 * 1. 扫描所有 spring value,保存起来
 * 2. 在配置变更时, 更新所有 spring value
 *
 * @Author IpMan
 * @Date 2024年5月12日 12:04
 */
@Slf4j
public class SpringValueProcessor implements BeanPostProcessor, BeanFactoryAware, ApplicationListener<EnvironmentChangeEvent> {
 // 占位符操作工具,如: ${key:default}, 拿到 key
 static final PlaceholderHelper placeholderHelper = PlaceholderHelper.getInstance();
 // 保存所有使用@SpringValue注解的字段及其相关信息
 static final MultiValueMap<String, SpringValue> VALUE_HOLDER = new LinkedMultiValueMap<>();
 private BeanFactory beanFactory;
 /**
 * 设置BeanFactory,使处理器能够访问Spring BeanFactory。
 *
 * @param beanFactory Spring的BeanFactory。
 * @throws BeansException 如果设置过程中发生错误。
 */
 @Override
 public void setBeanFactory(@NotNull BeanFactory beanFactory) throws BeansException {
 this.beanFactory = beanFactory;
 }
 /**
 * 在Bean初始化之前处理Bean,扫描并保存所有使用@SpringValue注解的字段。
 *
 * @param bean 当前处理的Bean实例。
 * @param beanName 当前处理的Bean名称。
 * @return 处理后的Bean实例。
 * @throws BeansException 如果处理过程中发生错误。
 */
 @Override
 public Object postProcessBeforeInitialization(@NotNull Object bean, @NotNull String beanName) throws BeansException {
 List<Field> fields = FieldUtils.findAnnotatedField(bean.getClass(), Value.class);
 fields.forEach(field -> {
 log.info("[IM_CONFIG] >> find spring value:{}", field);
 Value value = field.getAnnotation(Value.class);
 placeholderHelper.extractPlaceholderKeys(value.value()).forEach(key -> {
 log.info("[IM_CONFIG] >> find spring value:{} for field:{}", key, field);
 SpringValue springValue = new SpringValue(bean, beanName, key, value.value(), field);
 VALUE_HOLDER.add(key, springValue);
 }
 );
 }
 );
 return bean;
 }
 /**
 * 当@Value配置, 发生改变时,更新所有相关字段的值。
 *
 * @param event 包含环境变量变更信息的事件。
 */
 @Override
 public void onApplicationEvent(@NotNull EnvironmentChangeEvent event) {
 // 更新所有与变更的键相关的@SpringValue字段的值
 log.info("[IM_CONFIG] >> update spring value for keys: {}", event.getKeys());
 event.getKeys().forEach(key -> {
 log.info("[IM_CONFIG] >> update spring value: {}", key);
 List<SpringValue> springValues = VALUE_HOLDER.get(key);
 if (springValues == null || springValues.isEmpty()) {
 return;
 }
 // 更新每个相关@Value字段的值
 springValues.forEach(springValue -> {
 log.info("[IM_CONFIG] >> update spring value:{} for key:{}", springValue, key);
 try {
 // 解析并设置新值
 Object value = placeholderHelper.resolvePropertyValue((ConfigurableBeanFactory) beanFactory,
 springValue.getBeanName(), springValue.getPlaceholder());
 log.info("[IM_CONFIG] >> update spring value:{} for holder:{}", value, springValue.getPlaceholder());
 springValue.getField().setAccessible(true);
 springValue.getField().set(springValue.getBean(), value);
 } catch (IllegalAccessException ex) {
 log.error("[IM_CONFIG] >> update spring value error", ex);
 }
 });
 });
 }
}

最终提供客户端集成方式

目标:上述代码讲解了如何实现 Spring 配置数据源的集成、客户端和服务器端的长轮询机制、配置获取、变更通知,以及@Value 注解的处理方式。接下来,从使用的角度出发,我们需要思考如何有效利用这个注册中心的功能。

IMConfigRegistry:将客户端功能注入到 Spring 容器

IMConfigRegistry是一个实现ImportBeanDefinitionRegistrar接口的类,用于在 Spring 容器中注册BeanDefinition。其核心功能如下:

注册 BeanDefinition:registerBeanDefinitions方法会在导入注解元数据时被调用。

判断 PropertySourcesProcessor 是否已注册:

  • 如果已注册,输出 "PropertySourcesProcessor already registered" 并返回。
  • 如果未注册,输出 "register PropertySourcesProcessor",并创建PropertySourcesProcessorBeanDefinition,然后将其注册到 Spring 容器中。

通过这种方式,IMConfigRegistry确保了客户端的所有功能都能正确注入到 Spring 容器中,从而使得应用可以有效利用注册中心的功能。

public class IMConfigRegistry implements ImportBeanDefinitionRegistrar {
 @Override
 public void registerBeanDefinitions(@NonNull AnnotationMetadata importingClassMetadata,
 @NonNull BeanDefinitionRegistry registry) {
 // 注册 @ConfigurationProperties() 配置方式的注册中心处理器
 registerClass(registry, PropertySourcesProcessor.class);
 // 注册 @Value() 配置方式的注册中心处理器
 registerClass(registry, SpringValueProcessor.class);
 }
 /**
 * 向给定的 BeanDefinitionRegistry 注册一个类。
 * 如果该类已经注册,则不进行重复注册。
 *
 * @param registry BeanDefinitionRegistry 实例,用于注册 Bean。
 * @param aClass 需要注册的类。
 */
 private static void registerClass(BeanDefinitionRegistry registry, Class<?> aClass) {
 System.out.println("registry " + aClass.getName());
 // 判断PropertySourcesProcessor 是否已经注册Bean
 Optional<String> first = Arrays.stream(registry.getBeanDefinitionNames())
 .filter(x -> aClass.getName().equals(x))
 .findFirst();
 if (first.isPresent()) {
 System.out.println(aClass.getName() + " already registered");
 return;
 }
 // 注册PropertySourcesProcessor
 AbstractBeanDefinition beanDefinition =
 BeanDefinitionBuilder.genericBeanDefinition(aClass).getBeanDefinition();
 registry.registerBeanDefinition(aClass.getName(), beanDefinition);
 System.out.println("registered " + aClass.getName());
 }
}

EnableIpManConfig: 注解用于提供开启配置中心的客户端功能。通过使用@EnableIpManConfig注解,可以自动激活配置中心的客户端功能。该注解通过@Import导入IMConfigRegistry.class,实现客户端 Bean 的自动注册。

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Inherited
@Import({IMConfigRegistry.class})
public @interface EnableIpManConfig {
}

讲到这,Like版本就基本完工了...

测试与体验

准备工作

准备测试用的配置(yaml和properties都行)

ipman:
 a: "a-00"
 b: "b-00"
 c: "c-00"

准备测试用的一个标记 @ConfigurationProperties 注解的配置类

@Data
@ConfigurationProperties(prefix = "ipman")
public class DemoConfig {
 private String a;
 private String b;
 private String c;
}

测试配置的初始化

启动时开启 @EnableIpManConfig 配置中心Client端

@SpringBootApplication
@EnableConfigurationProperties({DemoConfig.class})
@EnableIpManConfig // 激活配置中心
@RestController
public class ConfigDemoApplication {
 @Value("${ipman.a:213213}")
 private String a;
 @Value("${ipman.b}")
 private String b;
 @Value("${ipman.c}")
 private String c;
 @Autowired
 private DemoConfig demoConfig;
 public static void main(String[] args) {
 SpringApplication.run(ConfigDemoApplication.class, args);
 }
 @Autowired
 Environment environment;
 @GetMapping("/")
 public String demo() {
 return "ipman.a = " + a + ", \n" +
 "ipman.b = " + b + ", \n" +
 "ipman.c = " + c + ", \n" +
 "ipman.demo.a = " + demoConfig.getA() + ", \n" +
 "ipman.demo.b = " + demoConfig.getB() + ", \n" +
 "ipman.demo.c = " + demoConfig.getC() + ", \n";
 }
 @Bean
 ApplicationRunner applicationRunner() {
 System.out.println("===> " + Arrays.toString(environment.getActiveProfiles()));
 return args -> {
 System.out.println(a);
 System.out.println(demoConfig.getA());
 };
 }
}

这个结果与Server端初始化的H2数据库数据是一致的

image-20240810175103920

测试配置动态变更

为了验证配置的动态变更,可以模拟调用服务器端发布最新配置。随后,经过短暂的停顿后,再对比客户端是否成功更新了该配置。

@SpringBootTest(classes = {ConfigDemoApplication.class})
@Slf4j
class ConfigDemoApplicationTests {
 static ApplicationContext context1;
 @Autowired
 private DemoConfig demoConfig;
 static MockMvc mockMvc;
 @BeforeAll
 static void init() {
 System.out.println(" ================================ ");
 System.out.println(" ============ 9129 ============= ");
 System.out.println(" ================================ ");
 System.out.println(" ================================ ");
 context1 = SpringApplication.run(ConfigServerApplication.class,
 "--logging.level.root=info",
 "--logging.level.org.springframework.jdbc=debug",
 "--logging.level.cn.ipman.config=debug",
 "--mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl",
 "--server.port=9129",
 "--spring.application.name=config-server",
 "--spring.datasource.driver-class-name=org.h2.Driver",
 "--spring.datasource.url=jdbc:h2:mem:h2db",
 "--spring.datasource.username=root",
 "--spring.datasource.password=123456",
 "--spring.sql.init.schema-locations=classpath:db.sql",
 "--spring.sql.init.mode=always",
 "--spring.h2.console.enabled=true",
 "--spring.h2.console.path=/h2",
 "--spring.h2.console.settings.web-allow-others=true"
 );
 mockMvc = MockMvcBuilders.webAppContextSetup((WebApplicationContext) context1).build();
 }
 @Test
 void contextLoads() throws Exception {
 System.out.println("config demo running ... ");
 Map<String, String> configs = new HashMap<>();
 configs.put("ipman.a", "demo1");
 configs.put("ipman.b", "demo2");
 configs.put("ipman.c", "demo3");
 // 模拟调用 config-server 修改配置
 MvcResult mvcResult = mockMvc.perform(
 MockMvcRequestBuilders.post("/update?app=app1&env=dev&ns=public")
 .content(JSON.toJSONString(configs))
 .contentType("application/json")).andDo(print())
 .andExpect(MockMvcResultMatchers.status().isOk())
 .andReturn();
 List<Configs> newConfigs = JSON.parseObject(
 mvcResult.getResponse().getContentAsString(),
 new TypeReference<List<Configs>>() {
 }
 );
 System.out.println("config update to " + newConfigs);
 // 验证 config-client 是否将配置也成功更新
 Thread.sleep(5_000 * 2);
 Assertions.assertEquals(configs.get("ipman.a"), demoConfig.getA());
 Assertions.assertEquals(configs.get("ipman.b"), demoConfig.getB());
 Assertions.assertEquals(configs.get("ipman.c"), demoConfig.getC());
 }
 @AfterAll
 static void destroy() {
 System.out.println(" =========== close spring context ======= ");
 SpringApplication.exit(context1, () -> 1);
 }
}

测试结果,成功实现了动态发布与配置变更

image-20240810175126940

总结

以上只是一个非常简易的配置中心版本,通过以上讲述能大致理解配置中心核心原理,仅是用来学习和探讨。

About

从零开始,手写一个配置中心框架。基于 Apollo 和 Nacos 的设计思想,从零开始设计并实现一个 Java简易版配置中心,包括 Server 和 Client 两部分。 与 Spring Boot 的集成,处理通过@value注解和@ConfigurationProperties注解绑定的属性。

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

AltStyle によって変換されたページ (->オリジナル) /