在 Garnet 中开发自定义对象(Custom Object):从继承 CustomObjectBase 到注册自定义命令的完整实战
【免费下载链接】garnetGarnet is a remote cache-store from Microsoft Research that offers strong performance (throughput and latency), scalability, storage, recovery, cluster sharding, key migration, and replication features. Garnet can work with existing Redis clients.项目地址: https://gitcode.com/GitHub_Trending/garnet4/garnet
导读:Garnet 是微软研究院推出的高性能远程缓存存储系统,除内置的 String、Set、List、Sorted Set 等数据结构外,还提供了多种可扩展机制。本文聚焦其中的Server-Side Object Extensions(服务端自定义对象),以官方示例
MyDict(基于 C#Dictionary<byte[], byte[]>的自定义字典类型)为主线,完整讲解如何实现对象类、工厂类与命令函数,并通过RegisterAPI 将新命令注册进 Garnet 服务器。读完本文,你将能够为 Garnet 添加自己的数据结构类型与配套的 RESP 命令,并理解对象序列化、内存统计与 RMW(Read-Modify-Write)等底层机制。
一、Garnet 的扩展机制全景:自定义对象处于什么位置
在动手之前,先明确 Custom Object 在整个扩展体系中的定位。根据 website/docs/extensions/overview.md,Garnet 共提供五类扩展方式:
| 扩展方式 | 操作对象 | 特性 | 对应文档 |
|---|---|---|---|
| Custom Raw String Command | 单个 key 的原始字符串值 | 直接操作 unified store 中的 raw-string 记录 | raw-strings.md |
| Custom Object Command | 单个 key 的数据结构(对象)值 | 让自定义数据类型拥有专属命令 | 本篇文章(objects.md) |
| Custom Transaction | 多个命令 | 事务性执行整个命令块,保证原子性 | transactions.md |
| Custom Procedure | 多个命令 | 非事务性执行,等价于客户端逐条下发 | procedure.md |
| Module | 一组相关扩展的打包 | 将命令、过程、事务打包成单一二进制模块加载 | module.md |
Custom Object Command 的核心价值在于:内置的 Set、List、Sorted Set 只覆盖通用场景,而当业务需要一种全新的数据结构(如带特殊约束的映射、自定义索引、复合聚合结构)时,你可以用 C# 实现自己的对象类型,并为它定义自定义命令,最终仍然通过标准的 RESP 协议对外提供服务,兼容现有 Redis 客户端。
原文档以 C# 的 Dictionary 类型为蓝本,实现一个名为MyDict的新对象类型,并配套MYDICTSET/MYDICTGET两个自定义命令。下面按“对象类 → 工厂类 → 命令函数 → 注册命令”四步展开。
二、第一步:实现自定义对象类(继承 CustomObjectBase)
原文档指出:添加新对象类型的第一步,是实现一个继承自CustomObjectBase的类,该类封装了对象在 Garnet 中的基本生命周期管理。基类定义位于 libs/server/Custom/CustomObjectBase.cs,抽象成员如下:
public abstract class CustomObjectBase : GarnetObjectBase { public override byte Type => type; // 对象类型标识 public abstract void SerializeObject(BinaryWriter writer); // 序列化 public abstract CustomObjectBase CloneObject(); // 克隆对象外壳 public abstract override void Dispose(); // 释放资源 }官方示例 main/GarnetServer/Extensions/MyDictObject.cs 中的MyDict类就是按此契约实现的:
class MyDict : CustomObjectBase { readonly Dictionary<byte[], byte[]> dict; // 空对象构造:传入对象类型与预估算的堆内存开销 public MyDict(byte type) : base(type, MemoryUtils.DictionaryOverhead) { dict = new(ByteArrayComparer.Instance); } // 反序列化构造:从持久化流中恢复对象内容 public MyDict(byte type, BinaryReader reader) : base(type, reader, MemoryUtils.DictionaryOverhead) { dict = new(ByteArrayComparer.Instance); var count = reader.ReadInt32(); for (var i = 0; i < count; i++) { var key = reader.ReadBytes(reader.ReadInt32()); var value = reader.ReadBytes(reader.ReadInt32()); dict.Add(key, value); UpdateSize(key, value); } } // 复制构造:用于对象的浅拷贝外壳 public MyDict(MyDict obj) : base(obj) { dict = obj.dict; } public override CustomObjectBase CloneObject() => new MyDict(this); public override void SerializeObject(BinaryWriter writer) { writer.Write(dict.Count); foreach (var kvp in dict) { writer.Write(kvp.Key.Length); writer.Write(kvp.Key); writer.Write(kvp.Value.Length); writer.Write(kvp.Value); } } public override void Dispose() { } // 供 COSCAN 等扫描类命令使用的游标遍历实现(详见下文) public override unsafe void Scan(long start, out List<byte[]> items, out long cursor, int count = 10, byte* pattern = null, int patternLength = 0, bool isNoValue = false) { // ...遍历 dict,支持游标、pattern 匹配与 count 分页... } // 业务方法:写入键值对 public bool Set(byte[] key, byte[] value) { if (dict.TryGetValue(key, out var oldValue)) UpdateSize(key, oldValue, false); // 先扣减旧值占用的内存 dict[key] = value; UpdateSize(key, value); return true; } // 业务方法:读取键值对 public bool TryGetValue(byte[] key, [MaybeNullWhen(false)] out byte[] value) => dict.TryGetValue(key, out value); }这段实现揭示了自定义对象需要关注的四个核心维度:
对象类型标识:构造函数中的
byte type决定该对象属于哪一类,Garnet 在命令分发时用它做类型校验。这一点在基类Operate方法中体现得很直接(libs/server/Custom/CustomObjectBase.cs):若input.header.type != this.type,则输出ObjectOutputFlags.WrongType,即返回“WRONGTYPE”语义的错误,防止对同一 key 误用不同类型的对象命令。序列化 / 反序列化对称性:
SerializeObject与反序列化构造函数必须严格成对。MyDict的格式为:先是条目总数(int),随后每个条目依次写“键长度 + 键字节 + 值长度 + 值字节”。这保证了对象在 AOF 日志、checkpoint 快照或主从复制中能够完整保存与恢复。基类DoSerialize(libs/server/Custom/CustomObjectBase.cs)会先调用基类逻辑再委托给SerializeObject,因此你只需要实现对象自身的负载。克隆与浅拷贝语义:
CloneObject返回“新的对象外壳 + 共享内部数据”的浅拷贝(示例中直接共享dict引用)。这与 Tsavorite 存储引擎的写时复制(copy-on-write)与版本管理机制配合,是对象能够参与并发读写的基础。堆内存追踪:
UpdateSize通过Utility.RoundUp对齐键值长度,并累加MemoryUtils.ByteArrayOverhead、MemoryUtils.DictionaryEntryOverhead等常量来估算HeapMemorySize。Garnet 用这一数值做内存统计与驱逐决策,所以自定义对象必须如实维护它——替换旧值时要先扣减旧内存,再累加新内存,示例代码中的Debug.Assert(HeapMemorySize >= MemoryUtils.DictionaryOverhead)即为防止内存计数为负的守护。
原文档提示的官方示例路径为
GarnetServer\Extensions\MyDictObject.cs,在仓库中的实际位置是 main/GarnetServer/Extensions/MyDictObject.cs,其同目录下还有MyDictSet.cs、MyDictGet.cs等配套命令实现。
附带能力:实现 Scan 以支持对象遍历
MyDict.Scan实现了游标式遍历:从start游标开始,按count分页返回条目,支持可选的pattern(通过GlobUtils.Match做 glob 模式匹配)。注意它的特殊约定——由于字典中每个条目是“键值对”,返回的items列表里每个条目占两个元素(Key 与 Value),因此循环截止条件是items.Count == (count * 2);遍历结束时游标归零,表示一轮扫描完成。这一实现支撑了 RESP 的COSCAN语义(基类Operate中当input.header.type == GarnetObjectType.All时进入扫描路径),使自定义对象也能被安全的全量枚举。
三、第二步:实现工厂类(继承 CustomObjectFactory)
原文档强调:有了对象类之后,还必须提供一个管理对象创建的工厂类,它必须派生自CustomObjectFactory。该抽象基类定义于 libs/server/Custom/CustomObjectFactory.cs,只包含两个抽象方法:
public abstract class CustomObjectFactory { // 创建新的(空的)自定义对象实例 public abstract CustomObjectBase Create(byte type); // 从给定的 reader 反序列化出对象实例 public abstract CustomObjectBase Deserialize(byte type, BinaryReader reader); }示例中的MyDictFactory正是MyDict的工厂,两个方法分别对应“新建空对象”与“从持久化数据恢复对象”两条创建路径:
class MyDictFactory : CustomObjectFactory { public override CustomObjectBase Create(byte type) => new MyDict(type); public override CustomObjectBase Deserialize(byte type, BinaryReader reader) => new MyDict(type, reader); }工厂对象在注册阶段通过server.Register.NewType(factory)绑定到 Garnet 的对象存储(见第五节),后续存储引擎需要创建或恢复该类型对象时,都会经由这个工厂完成。
四、第三步:开发自定义对象命令(扩展 CustomObjectFunctions)
原文档明确指出:CustomObjectFunctions是所有自定义对象命令的基类,新命令必须扩展它并实现三个核心方法。基类位于 libs/server/Custom/CustomObjectFunctions.cs,三个必选方法与一个可选方法如下:
4.1 必选方法一:NeedInitialUpdate
public virtual bool NeedInitialUpdate(scoped ReadOnlySpan<byte> key, ref ObjectInput input, ref RespMemoryWriter writer) => throw new NotImplementedException();作用:决定当记录不存在时是否需要创建新记录。返回true则创建,false则不创建。这是 RMW(Read-Modify-Write)写入链路中“初始化”决策的入口:Garnet 收到一个作用于不存在 key 的更新命令时,先调用该方法判断是否值得为这个 key 建立新对象。例如MYDICTSET永远希望写入,因此直接返回true(main/GarnetServer/Extensions/MyDictSet.cs);而像LPOP这类“对象不存在时无操作”的语义,则在这里返回false并提前输出 nil。
4.2 必选方法二:Reader
public virtual bool Reader(ReadOnlySpan<byte> key, ref ObjectInput input, IGarnetObject value, ref RespMemoryWriter writer, ref ReadInfo readInfo) => throw new NotImplementedException();作用:执行一次记录读取,根据key与用户输入input,从value中计算输出并写入writer。readInfo中的ReadAction选项用于控制读取时是否顺带执行过期(expire)语义。原文档特别说明:纯写命令无需重写该方法(默认抛NotImplementedException);反之,纯读命令(如MYDICTGET)只需实现Reader。
MYDICTGET的实现(main/GarnetServer/Extensions/MyDictGet.cs)展示了完整的读取流程:
public class MyDictGet : CustomObjectFunctions { public override bool Reader(ReadOnlySpan<byte> key, ref ObjectInput input, IGarnetObject value, ref RespMemoryWriter writer, ref ReadInfo readInfo) { Debug.Assert(value is MyDict); var entryKey = GetFirstArg(ref input); // 取出命令的第一个参数:字典的键 var dictObject = (MyDict)value; if (dictObject.TryGetValue(entryKey.ToArray(), out var result)) writer.WriteBulkString(result); // 命中则返回 bulk string else writer.WriteNull(); // 未命中则返回 nil return true; } }这里用到的GetFirstArg(ref input)是基类提供的参数解析助手:它从ObjectInput中按 RESP 格式逐个取出命令参数。同类助手还有GetNextArg(ref input, ref offset)(按偏移量顺序取参数)与GetNextString(...)(取参并转为 UTF-8 字符串),全部封装在 libs/server/Custom/CustomObjectFunctions.cs 中。输出侧则直接使用RespMemoryWriter的WriteBulkString/WriteNull写出标准 RESP 回复,保证与现有客户端协议完全兼容。
4.3 必选方法三:Updater
public virtual bool Updater(ReadOnlySpan<byte> key, ref ObjectInput input, IGarnetObject value, ref RespMemoryWriter writer, ref RMWInfo rmwInfo) => throw new NotImplementedException();作用:执行 RMW(read-modify-write)或 upsert 场景下的就地更新:给定key、用户输入input,将计算结果写入value指向的既有对象,并用writer回写命令输出;rmwInfo携带该记录的行信息引用(用于加锁等并发控制)。原文档同样说明:纯只读命令无需重写该方法(默认抛NotImplementedException)。
MYDICTSET的实现(main/GarnetServer/Extensions/MyDictSet.cs)展示了完整的更新流程:
public class MyDictSet : CustomObjectFunctions { public override bool NeedInitialUpdate(scoped ReadOnlySpan<byte> key, ref ObjectInput input, ref RespMemoryWriter writer) => true; public override bool Updater(ReadOnlySpan<byte> key, ref ObjectInput input, IGarnetObject value, ref RespMemoryWriter writer, ref RMWInfo rmwInfo) { Debug.Assert(value is MyDict); var offset = 0; var keyArg = GetNextArg(ref input, ref offset).ToArray(); // 字典键 var valueArg = GetNextArg(ref input, ref offset).ToArray(); // 字典值 _ = ((MyDict)value).Set(keyArg, valueArg); return true; } }注意此处offset从 0 开始、连续两次GetNextArg即可顺序取出两个参数,参数解析与 RESP 客户端发来的MYDICTSET <objectKey> <dictKey> <dictValue>完全对应。两个方法共同构成 RMW 语义:对象不存在时走NeedInitialUpdate → InitialUpdater → 新建对象,对象存在时走Updater → 就地更新。
4.4 可选方法:InitialUpdater
public virtual bool InitialUpdater(ReadOnlySpan<byte> key, ref ObjectInput input, IGarnetObject value, ref RespMemoryWriter writer, ref RMWInfo rmwInfo) => Updater(key, ref input, value, ref writer, ref rmwInfo);原文档说明:InitialUpdater用于对象首次创建时的特化处理。默认实现直接转发给Updater——对大多数命令而言,首次更新与后续更新的逻辑一致,因此无需重写;仅当“新建对象的初始化逻辑”与“既有对象更新逻辑”确有差异(例如初始化时要预置容量、填充默认结构)时才需要覆盖它。
4.5 基类自带的其他实用助手
除上述方法外,基类还提供一组开箱即用的工具方法(libs/server/Custom/CustomObjectFunctions.cs):
NotFound:当读取命令未命中记录时的兜底输出,默认写入 nil(RESP 3 为_\r\n,RESP 2 为$-1\r\n),可按需重写;AbortWithWrongNumberOfArguments(ref writer, cmdName):输出“参数个数错误”错误信息并中止当前命令;AbortWithErrorMessage(ref writer, message)/AbortWithSyntaxError(ref writer):分别输出自定义错误与语法错误,用于命令参数校验失败时的快速失败路径。
这些助手让自定义命令能够与内置命令保持一致、规范的错误回复格式。
五、第四步:注册对象类型与自定义命令
原文档没有展开注册细节,但这是让自定义对象真正可用的最后一步。官方示例在 main/GarnetServer/Program.cs 中完成注册:
// Register custom commands on objects var factory = new MyDictFactory(); server.Register.NewType(factory); server.Register.NewCommand("MYDICTSET", CommandType.ReadModifyWrite, factory, new MyDictSet(), new RespCommandsInfo { Arity = 4 }); server.Register.NewCommand("MYDICTGET", CommandType.Read, factory, new MyDictGet(), new RespCommandsInfo { Arity = 3 });拆解三个关键动作:
server.Register.NewType(factory):向服务器的对象存储注册新对象类型(绑定工厂),这一步让引擎知道如何创建/反序列化MyDict;NewCommand("MYDICTSET", CommandType.ReadModifyWrite, factory, new MyDictSet(), ...):注册写命令,CommandType.ReadModifyWrite会走NeedInitialUpdate → InitialUpdater/Updater的 RMW 链路,并绑定同一工厂(用于不存在时创建对象);NewCommand("MYDICTGET", CommandType.Read, factory, new MyDictGet(), ...):注册读命令,CommandType.Read走Reader链路。
RespCommandsInfo用于声明命令的 RESP 元数据,其中Arity是总参数个数(含命令名本身),因此MYDICTSET(3 个参数 + 命令名)为 4,MYDICTGET(2 个参数 + 命令名)为 3。像内置命令那样,你还可以进一步补充FirstKey、LastKey、Step、Flags、AclCategories等字段(参考同文件SETIFPM的注册示例 main/GarnetServer/Program.cs),使命令出现在COMMAND/COMMAND INFO结果中,获得客户端命令发现、ACL 分类等一等公民待遇。
注册完成后,启动GarnetServer(dotnet run --project main/GarnetServer,或参照 main/GarnetServer/README.md),即可用任意 RESP 客户端调用:
# 设置对象 key "mykey" 中字典条目 "foo" -> "bar" MYDICTSET mykey foo bar # 读取该字典条目 MYDICTGET mykey foo # 返回 "bar" MYDICTGET mykey nope # 返回 nil六、从源码结构看对象命令的执行链路
结合基类实现,可以梳理出一条完整的自定义对象命令执行链路,帮助你定位各方法的调用时机:
- 客户端发送
MYDICTSET mykey foo bar,服务端解析命令后,根据注册信息定位到对象存储与该命令的CustomObjectFunctions实现; - 对象存储查询
mykey:不存在→ 调用NeedInitialUpdate(返回true)→ 通过工厂Create新建MyDict→ 调用InitialUpdater(默认转发Updater)完成首次写入;已存在→ 直接调用Updater对既有对象做就地更新; - 对象内部使用
UpdateSize维护HeapMemorySize,供内存管理与驱逐决策使用; - 对象变化被记录进日志,checkpoint / AOF / 复制时调用
SerializeObject落盘或同步;重启恢复时由工厂的Deserialize路径重建对象; - 读取类命令(
MYDICTGET)则走Reader,命中时WriteBulkString,未命中时WriteNull(或经NotFound兜底)。
这一机制与内置的 Hash、Sorted Set 等对象共用同一套 Tsavorite 对象存储基础设施,因此自定义对象天然获得持久化、复制、集群迁移等 Garnet 核心能力,这正是“用 C# 自定义数据结构 + 自定义命令”这一扩展方式的价值所在。
七、小结
围绕原文档的指引,我们完成了自定义对象从零到可用的完整闭环:
| 步骤 | 需要做的事 | 参考文件 |
|---|---|---|
| 对象类 | 继承CustomObjectBase,实现序列化、克隆、释放与内存统计 | main/GarnetServer/Extensions/MyDictObject.cs、libs/server/Custom/CustomObjectBase.cs |
| 工厂类 | 继承CustomObjectFactory,实现Create与Deserialize | libs/server/Custom/CustomObjectFactory.cs |
| 命令函数 | 继承CustomObjectFunctions,按需实现NeedInitialUpdate/Reader/Updater(可选InitialUpdater) | main/GarnetServer/Extensions/MyDictSet.cs、main/GarnetServer/Extensions/MyDictGet.cs、libs/server/Custom/CustomObjectFunctions.cs |
| 注册 | NewType注册对象类型 +NewCommand注册读写命令(可附RespCommandsInfo) | main/GarnetServer/Program.cs |
至此,你可以基于同一套路实现任意自定义数据结构:只需明确对象的序列化格式、克隆语义与内存估算,再按读写语义实现三个核心方法,即可让 Garnet 承载你的专属数据类型。若要进一步了解如何将这类扩展打包成可分发模块,可继续阅读 website/docs/extensions/module.md;官方仓库中还提供了MyDict之外更多示例(如DeleteIfMatch、ReadWriteTxn等),均位于 main/GarnetServer/Extensions,是极佳的学习参照。
【免费下载链接】garnetGarnet is a remote cache-store from Microsoft Research that offers strong performance (throughput and latency), scalability, storage, recovery, cluster sharding, key migration, and replication features. Garnet can work with existing Redis clients.项目地址: https://gitcode.com/GitHub_Trending/garnet4/garnet
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考