设计一个可靠的配置表可重载系统
前言
在制作一款游戏中,有大量的配置表。比如游戏中有装备的配置表,它定义了装备的icon,名称,属性等等。有关卡配置表,配置了关卡信息,怪物信息,怪物数据等等。对于一款实时在线游戏,我们需要这些配置表可以被热更新,可以快速fix一些配置错误,而无需停机维护游戏。那如何做到可靠有效的热更新呢?正是这篇文章要介绍给大家的。
一、理解配置表
不同的游戏配置表的实际形式也是多种多样的,有的配置表采用Excel
,有的配置表采用csv文件
,有的直接使用数据库表
。不管是那种形式,游戏的配置表不是那种单纯配置参数的key,value配置文件。它是结构化的!不管它的载体是什么,我把它当做一个数据表
去维护,表与表之间可以有关联关系。
对于一张实体配置表,我新建一个对应的Domain实体类
与之对应。这是不是像是管理数据表类似的,是的!我就是把配置表当做一个数据库表,在代码上有一个对应的实体类。但不同的是,它是一张内存只读表
下面我就用装备配置表为例来一场载入到安全可重载之旅!
装备Id | 装备名称 | 装备的品质 | 装备的图像 |
---|---|---|---|
1001 | 暗影战斧 | 精良 | anyingzhanfu |
1002 | 铁剑 | 普通 | tiejian |
1003 | 破晓 | 传说 | pojie |
备注:这里只是举个例子,实际游戏中的装备配置表要比这个复杂许多
对应的Domain类
public class Equip
{
public int Id { get; set; }
public string Name { get; set; }
public string Quality { get; set; }
public string Pic { get; set; }
}
二、载入配置表
对于上面定义的配置表,我们如果载入到内存并且管理起来呢?我想到的是用一个Dictionary
,Key是表的主键,Value是对应的实体类。我定义了一个配置表的Cache管理器,它的接口定义如下:
public interface ConfigCache<K, V>
{
/// <summary>
/// 从缓存中获取Key
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
V Get(K key);
/// <summary>
/// 将对象放入缓存
/// </summary>
/// <param name="key"></param>
/// <param name="value"></param>
void Put(K key, V value);
/// <summary>
/// 载入所有Models
/// </summary>
/// <returns></returns>
List<V> GetModels();
}
- 你可以通过主键获取指定配置
- 你可以放入指定配置到缓存表,实际的操作一般是启动时载入所有配置到内存
- 获取所有的配置信息
我们有了配置表管理类,那如何载入呢?如何将配置信息载入进管理类呢?上面我提到配置表有很多不同的载体,比如Excel,数据库表,CSV文件等。同样我也设计了一个载入API,不同的载体去实现不同的实现类即可,定义如下:
public interface IConfigLoader
{
/// <summary>
/// 获取所有配置信息
/// </summary>
/// <typeparam name="V"></typeparam>
/// <returns></returns>
List<V> GetModels<V>();
}
public interface ConfigLoadable
{
/// <summary>
/// 载入缓存
/// </summary>
void Load();
/// <summary>
/// 设置配置载入器
/// </summary>
/// <param name="loader"></param>
void SetConfigLoader(IConfigLoader loader);
/// <summary>
/// 获取配置载入器
/// </summary>
/// <returns></returns>
IConfigLoader GetConfigLoader();
}
有了载入器,你就可以在系统启动时候载入对应配置,放入缓存管理类了。那我们看看实际的EquipCache吧。
// 配置表的基类,子类只需要实现尽量少的代码
public abstract class BaseConfigCache<K, V> : ConfigCache<K, V>, IConfigLoader
{
/// <summary>
/// 缓存Map
/// </summary>
protected readonly Dictionary<K, V> CacheMap = new ();
/// <summary>
/// 缓存载入器
/// </summary>
protected IConfigLoader ConfigLoader;
public V Get(K key)
{
return CacheMap.TryGetValue(key, out var value) ? value : default;
}
public void Put(K key, V value)
{
CacheMap[key] = value;
}
public List<V> GetModels()
{
return CacheMap.Values.ToList();
}
public void Load()
{
CacheMap.Clear();
Init();
}
public void SetConfigLoader(IConfigLoader loader)
{
this.ConfigLoader = loader;
}
public IConfigLoader GetConfigLoader()
{
return this.ConfigLoader;
}
public abstract void Init();
}
// 实际的Equip配置表Cache
public class EquipCache : BaseConfigCache<int, Equip>
{
public override void Init()
{
var modelList = GetConfigLoader().GetModels<Equip>();
foreach(var model in modelList)
{
Put(model.Id, model);
}
}
}
看看目前我们做到了什么?
- 定义了配置表的管理方式
- 定义了配置表的载入方法,采用接口的设计,你可以根据自己的需要扩展自己的载入器即可
- 每个配置表实际管理类只需要实现少量代码
- 由于每个配置表都有一个管理器,你实际上拥有了自由定义配置表管理的能力。比如我想装备配置表可以按照品质检索。
public class EquipCache : BaseConfigCache<int, Equip>
{
public override void Init()
{
var modelList = GetConfigLoader().GetModels<Equip>();
foreach(var model in modelList)
{
Put(model.Id, model);
}
}
/// <summary>
/// 通过品质获取装备配置信息
/// </summary>
public List<Equip> GetEquipByQuality(string quality)
{
return GetModels().Where(v => v.Quality == quality).ToList();
}
}
- 如果非常高频,你还可以通过在Init里面提前构建索引表用来加速查询
目前我们已经做到如果载入配置表,以及怎么管理和使用它,在游戏的其他模块使用它已经很称手了,接下去是时候给它添加热重载
支持了!
三、热重载配置表
热重载本质是重新载入,可以是通过指定让系统重新载入,或者是自动检测到配置表变化实现重新载入。对于上述的设计,热重载不就是重新调用一下Load
方法即可吗?其实是的,但当如果只是这样做它是不够安全的。它有以下问题:
- 配置表的管理类的
Dictionary
不是线程安全的,如果在其他线程查询的时候,又触发了重新载入,这是不可预期的 - 还有就是配置表之间一般是有
关联
关系的,重新载入的过程可能破坏这种关联关系,导致依赖关联关系逻辑出现不可预期的错误。比如关卡配置表依赖怪物配置表,重载发生时候,关卡表重新载入了,依赖了新的怪物。但怪物表还未载入完成,如果玩家此时读取到了新的关卡配置表就会出现错误 - 重载时必须清理和重建自己自定义的索引缓存
那我是怎么设计的,我是通过以下设计避免上述问题呢?
- 双buff机制
- 配置管理类每次重载时重新生成的
- 通过实体Type查询具体的配置管理类
那我们直接看代码吧
public class ConfigManager
{
/// <summary>
/// 内部实例
/// </summary>
private static readonly ConfigManager ConfigInstance = new ConfigManager();
/// <summary>
/// 内部CacheMap
/// </summary>
private readonly Dictionary<Type, object>[] _cacheMaps;
/// <summary>
/// 游标cursor
/// </summary>
private volatile int _cursor;
/// <summary>
/// 游标local标志
/// </summary>
private readonly ThreadLocal<int> _cursorLocal;
/// <summary>
/// 被cache的类
/// </summary>
private readonly List<Type> _cacheList;
private ConfigManager()
{
_cacheMaps = new Dictionary<Type, object>[2];
_cacheMaps[0] = new();
_cacheMaps[1] = new();
_cursorLocal = new(() => -1);
_cacheList = new();
}
public static ConfigManager Instance()
{
return ConfigInstance;
}
public void Register(Type type)
{
_cacheList.Add(type);
}
public ConfigCache<K, V> GetCache<K, V>(Type type)
{
var index = _cursorLocal.Value;
if (index == -1)
{
_cursorLocal.Value = _cursor;
index = _cursorLocal.Value;
}
else
{
index = _cursorLocal.Value;
}
return _cacheMaps[index][type] as ConfigCache<K, V>;
}
public void Reload(IConfigLoader loader)
{
var index = 1 - _cursor;
try
{
Init(index, loader);
_cursor = index;
_cursorLocal = new(() => -1);
}
catch (Exception e)
{
Log.Error(e, "reload Config error");
}
}
private void Init(int index, IConfigLoader loader)
{
foreach (var type in _cacheList)
{
var obj = Activator.CreateInstance(type);
if (obj is ConfigLoadable configLoader)
{
configLoader.SetConfigLoader(loader);
configLoader.Load();
_cacheMaps[index][type] = loader;
}
}
}
}
- 设计了一个
ConfigManager
统一管理所有ConfigCache
- 设计了双buff
_cacheMaps
用于安全的重载ConfigCache
- 采用了
ThreadLocal
保证,单个线程访问的一致性 - 一次重新载入过程是重建
ConfigCache
避免了自建索引忘记重建的bug - 提供了一个通过
Type
获取ConfigCache
的统一入库
那剩下的重载配置表就是这行代码了
ConfigManager.Instance.Reload(loader);
结语
通过以上设计,就完成了一个安全可靠的配置表重载系统,对此你怎么看?欢迎评论交流!