手写配置中心-搞定多开游戏脚本配置

背景

最近我自己捣鼓了一个游戏自动化脚本,主要用于游戏里的倒货操作。这个脚本挺实用的,为了提高效率,我就在多台设备上都运行了它。然而,麻烦事儿也随之而来。有时候我需要修改一些配置,像倒货的价格区间、货物种类之类的,但要在每台设备上逐一同步这些修改,实在是太繁琐了。


 

我也考虑过使用企业级的配置中心组件,比如 Nacos、ZooKeeper 等。不过它们功能太强大、太复杂了,对于我这个小小的游戏脚本来说,就像是用牛刀杀鸡,部署成本太高,所以我决定自己动手搞一个简单的配置中心。

设计思路

技术选型
 

既然要自己做,那就选个熟悉又简单的技术栈。我决定使用 Spring Boot 来搭建配置中心的服务端。Spring Boot 提供了很多开箱即用的功能,能让我快速地开发出一个稳定的服务。

配置存储

在数据存储方面,我选择了 MySQL 数据库。只需要创建一个单表就可以满足我的需求。表结构很简单,主要存储配置的相关信息,比如 namespace_idgroupdata_id、和对应的 config_data。这样的设计既方便存储,又便于后续的查询和更新操作。

缓存机制

为了提高配置的读取效率,我打算在服务启动的时候,把 MySQL 中的配置数据缓存到 ConcurrentHashMap 中。ConcurrentHashMap 是线程安全的,在多线程环境下可以高效地进行读写操作。这样,大部分的配置读取操作都可以直接从缓存中获取,减少了数据库的访问压力。

定时刷新

虽然有了缓存,但配置数据是可能会发生变化的。为了保证缓存中的数据与数据库中的数据一致,我计划使用定时任务来扫描数据库表。每隔一段时间,将数据库中的数据与本地缓存进行对比,如果有更新就刷新缓存。这样可以在一定程度上保证配置数据的实时性。

接口设计

主动拉取接口(pull)

设计了一个 pull 接口,用于客户端主动拉取当前的配置参数。客户端可以通过传递 namespaceIdgroupdataIdkey 等参数来获取对应的配置值。接口的 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 接口继,主要负责处理应用配置相关的业务逻辑,以下是对其各方法的简单说明:

  1. getByDataIdLevel:根据应用 ID、命名空间 ID、分组和数据 ID 获取应用配置信息。
  2. pull:从缓存拉取指定配置数据。
  3. subscribe:订阅指定配置数据,可指定版本号以获取更新。
  4. refreshCache:刷新指定配置的缓存。
  5. 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);
                }
            }
        }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

静水深渊

随便

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值