背景
最近我自己捣鼓了一个游戏自动化脚本,主要用于游戏里的倒货操作。这个脚本挺实用的,为了提高效率,我就在多台设备上都运行了它。然而,麻烦事儿也随之而来。有时候我需要修改一些配置,像倒货的价格区间、货物种类之类的,但要在每台设备上逐一同步这些修改,实在是太繁琐了。
我也考虑过使用企业级的配置中心组件,比如 Nacos、ZooKeeper 等。不过它们功能太强大、太复杂了,对于我这个小小的游戏脚本来说,就像是用牛刀杀鸡,部署成本太高,所以我决定自己动手搞一个简单的配置中心。
设计思路
技术选型
既然要自己做,那就选个熟悉又简单的技术栈。我决定使用 Spring Boot 来搭建配置中心的服务端。Spring Boot 提供了很多开箱即用的功能,能让我快速地开发出一个稳定的服务。
配置存储
在数据存储方面,我选择了 MySQL 数据库。只需要创建一个单表就可以满足我的需求。表结构很简单,主要存储配置的相关信息,比如 namespace_id
、group
、data_id
、和对应的 config_data。这样的设计既方便存储,又便于后续的查询和更新操作。
缓存机制
为了提高配置的读取效率,我打算在服务启动的时候,把 MySQL 中的配置数据缓存到 ConcurrentHashMap
中。ConcurrentHashMap
是线程安全的,在多线程环境下可以高效地进行读写操作。这样,大部分的配置读取操作都可以直接从缓存中获取,减少了数据库的访问压力。
定时刷新
虽然有了缓存,但配置数据是可能会发生变化的。为了保证缓存中的数据与数据库中的数据一致,我计划使用定时任务来扫描数据库表。每隔一段时间,将数据库中的数据与本地缓存进行对比,如果有更新就刷新缓存。这样可以在一定程度上保证配置数据的实时性。
接口设计
主动拉取接口(pull)
设计了一个 pull
接口,用于客户端主动拉取当前的配置参数。客户端可以通过传递 namespaceId
、group
、dataId
和 key
等参数来获取对应的配置值。接口的 URL 格式大概是这样的:/pull?namespaceId=xx&group=xx&dataId=xx&key=xx
。这样,客户端就可以根据自己的需求灵活地获取配置信息。
监听配置变更接口(subscribe)
为了让客户端能够实时感知到配置的变更,我采用长轮询的方式实现了一个 subscribe 接口。客户端能够借助这个接口,订阅特定配置的变更情况。一旦配置发生变化,服务端会迅速响应客户端。接口的 URL 格式和 pull 接口类似,为 /subscribe?namespaceId=xx&group=xx&dataId=xx&key=xxVersion=xx 。
这里提到的长轮询,具体运作方式是这样的:客户端向服务器发送请求后,服务器不会立刻给出响应,而是将这个请求 “hold 住”。在这段时间里,服务器持续监测数据状态。一旦监测到数据有变化,会第一时间把更新内容发送给客户端。要是直到设定的超时时间,都没检测到数据变化,服务器才会返回无更新消息给客户端。此时,客户端便会再次发起新的请求。如此循环往复,通过这种方式,既保障了实时性,又不会像短轮询那样频繁占用服务器资源,实现了高效的实时通信。
实现步骤
数据库表创建
首先,我在 MySQL 中创建了一个单表,用于存储配置信息。表结构如下:
-- 创建名为 app_config 的表,用于存储应用配置相关信息
CREATE TABLE `app_config` (
-- 自增主键,用于唯一标识每条记录
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '记录唯一标识,自增主键',
-- 应用的 ID,用于关联具体的应用,最大长度 128 字符
`app_id` varchar(128) DEFAULT NULL COMMENT '应用的唯一标识符',
-- 数据的 ID,用于标识特定的数据,最大长度 128 字符
`data_id` varchar(128) DEFAULT NULL COMMENT '数据的唯一标识符',
-- 配置的版本号,最大长度 32 字符
`version` varchar(32) DEFAULT NULL COMMENT '配置的版本号',
-- 存储配置的具体数据,文本类型,可存储较长的配置信息
`config_data` text COMMENT '具体的配置数据',
-- 配置文件的类型,如 JSON、XML 等,最大长度 32 字符
`file_type` varchar(32) DEFAULT NULL COMMENT '配置文件的类型',
-- 配置的状态,如启用、禁用等,最大长度 32 字符
`status` varchar(32) DEFAULT NULL COMMENT '配置的状态',
-- 备注信息,可用于记录额外的说明,最大长度 255 字符
`remark` varchar(255) DEFAULT NULL COMMENT '关于配置的备注信息',
-- 记录创建的时间,用于跟踪记录的创建时刻
`create_time` datetime DEFAULT NULL COMMENT '记录创建的时间',
-- 记录更新的时间,用于跟踪记录的最后更新时刻
`update_time` datetime DEFAULT NULL COMMENT '记录更新的时间',
-- 配置所属的组,默认为 default_group,最大长度 64 字符
`_group` varchar(64) DEFAULT 'default_group' COMMENT '配置所属的组',
-- 配置所属的命名空间,默认为 default_namespace,最大长度 64 字符
`namespace_id` varchar(64) DEFAULT 'default_namespace' COMMENT '配置所属的命名空间',
-- 将 id 字段设置为主键
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
这个表包含了配置的基本信息。
Spring Boot 项目搭建
使用 Spring Initializr 快速创建一个 Spring Boot 项目,添加必要的依赖,如 Spring Web、mybatis plus 和 MySQL 驱动。
配置service接口
AppConfigService
接口继,主要负责处理应用配置相关的业务逻辑,以下是对其各方法的简单说明:
getByDataIdLevel
:根据应用 ID、命名空间 ID、分组和数据 ID 获取应用配置信息。pull
:从缓存拉取指定配置数据。subscribe
:订阅指定配置数据,可指定版本号以获取更新。refreshCache
:刷新指定配置的缓存。tryRefreshCache
:尝试刷新所有配置缓存。
代码示例如下:
package com.example.xiongxin.monitor.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.xiongxin.common.api.ResponseResult;
import com.example.xiongxin.monitor.entity.AppConfig;
import com.example.xiongxin.monitor.model.ConfigCacheDataValue;
public interface AppConfigService extends IService<AppConfig> {
AppConfig getByDataIdLevel(String appId, String namespaceId, String group, String dataId);
ConfigCacheDataValue pull(String appId, String namespaceId, String group, String dataId, String key);
ConfigCacheDataValue subscribe(String appId, String namespaceId, String group, String dataId, String key, String version);
void refreshCache(String appId, String namespaceId, String group, String dataId);
void tryRefreshCache();
}
接口实现
配置缓存及监听数据结构
在配置管理系统里,我们首先定义一个类来作为最小缓存单位,该类用于存储配置的值和版本号。同时,要定义一个 ConcurrentHashMap
来缓存配置数据,以此高效管理和快速访问这些配置。此外,为了能够及时响应配置的变更,我们还需要一个针对配置键进行监听的队列,它会持续监听配置的变化情况。
具体如下:
@Data
public class ConfigCacheDataValue {
private Object value;
private String version;
public ConfigCacheDataValue(Object value, String version) {
this.value = value;
this.version = version;
}
}
// 存储配置数据的三级并发哈希表,外层键为 appId,中层键为 dataId 级别的配置键,内层键为具体配置键
private static final ConcurrentHashMap<String, ConcurrentHashMap<String, ConcurrentHashMap<String, ConfigCacheDataValue>>> configData = new ConcurrentHashMap<>();
// 存储等待的长轮询请求,每个配置对应一个阻塞队列,外层键为 appId,中层键为配置键,内层为阻塞队列
private static final ConcurrentHashMap<String, ConcurrentHashMap<String, BlockingQueue<CompletableFuture<ConfigCacheDataValue>>>> keyDataChangeRequestNotifyQueueMap = new ConcurrentHashMap<>();
// 长轮询超时时间,单位为毫秒,可通过配置文件配置,默认值为 30000 毫秒
@Value("${center.lang_loop_timeout:30000}")
private Long langLoopTimeout;
配置缓存刷新方法 refreshCache
在系统中,确保缓存的配置数据及时更新至关重要。refreshCache
方法的核心作用就是刷新指定应用、命名空间、分组和数据 ID 对应的配置缓存。下面我们逐步剖析这个方法的具体实现。
public synchronized void refreshCache(String appId, String namespaceId, String group, String dataId) {
// 生成 dataId 级别的配置键
String dataIdLevelConfigKey = generateConfigKey(namespaceId, group, dataId, "");
// 根据应用 ID、命名空间 ID、配置分组和配置数据 ID 获取 AppConfig 对象
AppConfig appConfig = getByDataIdLevel(appId, namespaceId, group, dataId);
if (appConfig == null) {
return;
}
// 获取配置版本
String version = appConfig.getVersion();
// 获取配置数据字符串
String configDataStr = appConfig.getConfigData();
Map<String, Object> resultCache = null;
// 根据配置文件类型处理配置数据 返回key - value格式
switch (appConfig.getFileType()) {
case "properties":
resultCache = handleProperties(configDataStr, dataIdLevelConfigKey);
break;
case "json":
try {
resultCache = handleJson(configDataStr, dataIdLevelConfigKey);
} catch (IOException e) {
e.printStackTrace();
}
break;
case "yaml":
resultCache = handleYaml(configDataStr, dataIdLevelConfigKey);
break;
}
// 创建 dataId 级别配置数据的并发哈希表
ConcurrentHashMap<String, ConfigCacheDataValue> dataIdLevelConfigData = new ConcurrentHashMap<>();
// 将 dataId 级别配置数据存入并发哈希表
dataIdLevelConfigData.put(dataIdLevelConfigKey, new ConfigCacheDataValue(configDataStr, version));
// 遍历处理后的配置数据,存入并发哈希表
assert resultCache != null;
resultCache.forEach((k, v) -> {
dataIdLevelConfigData.put(k, new ConfigCacheDataValue(v, version));
});
// 获取 app 级别缓存,如果不存在则创建一个新的
ConcurrentHashMap<String, ConcurrentHashMap<String, ConfigCacheDataValue>> orDefault = configData.getOrDefault(appId, new ConcurrentHashMap<>());
// 将 dataId 级别配置数据存入 app 级别缓存
orDefault.put(dataIdLevelConfigKey, dataIdLevelConfigData);
// 更新 app 级别缓存
configData.put(appId, orDefault);
// 发送更新通知,遍历等待的长轮询请求队列
keyDataChangeRequestNotifyQueueMap.forEach((_appId, queueConcurrentHashMap) -> {
dataIdLevelConfigData.forEach((k, v) -> {
// 获取配置键对应的阻塞队列
BlockingQueue<CompletableFuture<ConfigCacheDataValue>> completableFutures = queueConcurrentHashMap.get(k);
if (Objects.nonNull(completableFutures)) {
// 遍历阻塞队列中的 CompletableFuture,完成请求并清空队列
completableFutures.forEach(completableFuture -> {
completableFuture.complete(v);
});
completableFutures.clear();
}
});
});
}
配置订阅方法 subscribe
详解
在配置管理系统中,subscribe
方法用于订阅指定应用、命名空间、分组、数据 ID 和键对应的配置数据,并可以指定版本号来判断配置是否有更新。下面详细分析该方法的实现逻辑。
public ConfigCacheDataValue subscribe(String appId, String namespaceId, String group, String dataId, String key, String version) {
// 生成配置键
String configKey = generateConfigKey(namespaceId, group, dataId, key);
// 先尝试拉取当前配置
ConfigCacheDataValue currentConfigCacheDataValue = pull(appId, namespaceId, group, dataId, key);
// 如果当前配置存在且版本与传入版本不同,则直接返回当前配置
if (currentConfigCacheDataValue != null && !currentConfigCacheDataValue.getVersion().equals(version)) {
return currentConfigCacheDataValue;
}
// 获取 appId 对应的二级 Map,如果不存在则创建一个新的
ConcurrentHashMap<String, BlockingQueue<CompletableFuture<ConfigCacheDataValue>>> queueConcurrentHashMap = keyDataChangeRequestNotifyQueueMap.computeIfAbsent(appId, k -> new ConcurrentHashMap<>());
// 获取 configKey 对应的阻塞队列,如果不存在则创建一个新的
BlockingQueue<CompletableFuture<ConfigCacheDataValue>> queue = queueConcurrentHashMap.computeIfAbsent(configKey, k -> new LinkedBlockingQueue<>());
// 创建一个 CompletableFuture 用于异步处理
CompletableFuture<ConfigCacheDataValue> future = new CompletableFuture<>();
try {
// 将请求放入队列
queue.put(future);
// 处理超时异常,记录警告日志
future.exceptionally(ex -> {
log.warn("订阅超时,appId: {}, configKey: {}", appId, configKey);
return null;
});
// 设置超时时间,等待配置更新
return future.get(langLoopTimeout, TimeUnit.MILLISECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
// 记录订阅过程中的错误信息
log.error("订阅过程出错,appId: {}, configKey: {}, 错误信息: {}", appId, configKey, e.getMessage());
return null;
}finally {
queue.remove(future);
}
}
配置数据拉取方法 pull
详解
pull
方法的主要功能是从缓存中拉取指定应用、命名空间、分组、数据 ID 和键对应的配置数据。下面我们来详细分析这个方法的实现逻辑
@Override
public ConfigCacheDataValue pull(String appId, String namespaceId, String group, String dataId, String key) {
// 生成 dataId 级别的配置键
String dataIdLevelConfigKey = generateConfigKey(namespaceId, group, dataId, "");
// 生成具体配置键
String generateConfigKey = generateConfigKey(namespaceId, group, dataId, key);
// 获取 app 级别缓存
ConcurrentHashMap<String, ConcurrentHashMap<String, ConfigCacheDataValue>> appLevelConfigData = configData.get(appId);
if (Objects.nonNull(appLevelConfigData)) {
// 获取 dataId 级别缓存
ConcurrentHashMap<String, ConfigCacheDataValue> dataIdLevelConfigData = appLevelConfigData.get(dataIdLevelConfigKey);
// 如果 dataId 级别缓存存在,则获取具体配置数据
if (Objects.nonNull(dataIdLevelConfigData)) {
return dataIdLevelConfigData.get(generateConfigKey);
}
}
return null;
}
配置数据缓存初始化
public void onApplicationEvent(ApplicationReadyEvent event) {
log.info("应用启动完成------》开始刷新配置中心缓存");
// 查询所有的 AppInfo 记录
List<AppInfo> appInfoList = appInfoMapper.selectList(new QueryWrapper<>());
for (AppInfo appInfo : appInfoList) {
// 遍历每个 AppInfo 对应的 AppConfig 记录,刷新缓存
lambdaQuery().eq(AppConfig::getAppId, appInfo.getAppId()).list().forEach(appConfig -> {
refreshCache(appInfo.getAppId(), appConfig.getNamespaceId(), appConfig.getGroup(), appConfig.getDataId());
});
}
}
定时尝试刷新缓存
使用 Spring 的 @Scheduled
注解来实现定时任务,每隔10s扫描数据库表,对比数据并刷新缓存。代码示例如下:
@Component
@Slf4j
public class AppConfigCenterJob {
@Resource
private AppConfigService appConfigService;
@Scheduled(cron ="*/10 * * * * ?")
public void appConfigRefreshCache() {
appConfigService.tryRefreshCache();
}
}
public void tryRefreshCache() {
// 遍历 app 级别缓存
for (Map.Entry<String, ConcurrentHashMap<String, ConcurrentHashMap<String, ConfigCacheDataValue>>> appLevelConfigDataEntry : configData.entrySet()) {
String appId = appLevelConfigDataEntry.getKey();
// 遍历 dataId 级别缓存
for (Map.Entry<String, ConcurrentHashMap<String, ConfigCacheDataValue>> dataIdLevelConfigDataEntry : appLevelConfigDataEntry.getValue().entrySet()) {
String dataIdLevelKey = dataIdLevelConfigDataEntry.getKey();
// 解析 dataId 级别配置键,获取命名空间 ID、配置分组和配置数据 ID
String[] keys = dataIdLevelKey.split(":");
String namespaceId = keys[0];
String group = keys[1];
String dataId = keys[2];
// 根据应用 ID、命名空间 ID、配置分组和配置数据 ID 获取 AppConfig 对象
AppConfig byDataIdLevel = getByDataIdLevel(appId, namespaceId, group, dataId);
if (Objects.nonNull(byDataIdLevel)) {
// 如果数据库中的版本与缓存中的版本不一致,则刷新缓存
if (!byDataIdLevel.getVersion().equals(dataIdLevelConfigDataEntry.getValue().get(dataIdLevelKey).getVersion())) {
refreshCache(appId, namespaceId, group, dataId);
}
} else {
// 如果未找到 AppConfig 对象,则刷新缓存,就是新增加缓存内容
refreshCache(appId, namespaceId, group, dataId);
}
}
}
}