在 Garnet 中开发自定义对象(Custom Object):从继承 CustomObjectBase 到注册自定义命令的完整实战
在 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#Dictionarybyte[], byte[]的自定义字典类型为主线完整讲解如何实现对象类、工厂类与命令函数并通过RegisterAPI 将新命令注册进 Garnet 服务器。读完本文你将能够为 Garnet 添加自己的数据结构类型与配套的 RESP 命令并理解对象序列化、内存统计与 RMWRead-Modify-Write等底层机制。一、Garnet 的扩展机制全景自定义对象处于什么位置在动手之前先明确 Custom Object 在整个扩展体系中的定位。根据 website/docs/extensions/overview.mdGarnet 共提供五类扩展方式扩展方式操作对象特性对应文档Custom Raw String Command单个 key 的原始字符串值直接操作 unified store 中的 raw-string 记录raw-strings.mdCustom Object Command单个 key 的数据结构对象值让自定义数据类型拥有专属命令本篇文章objects.mdCustom Transaction多个命令事务性执行整个命令块保证原子性transactions.mdCustom Procedure多个命令非事务性执行等价于客户端逐条下发procedure.mdModule一组相关扩展的打包将命令、过程、事务打包成单一二进制模块加载module.mdCustom 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 Dictionarybyte[], 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 Listbyte[] 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 快照或主从复制中能够完整保存与恢复。基类DoSerializelibs/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 必选方法一NeedInitialUpdatepublic virtual bool NeedInitialUpdate(scoped ReadOnlySpanbyte key, ref ObjectInput input, ref RespMemoryWriter writer) throw new NotImplementedException();作用决定当记录不存在时是否需要创建新记录。返回true则创建false则不创建。这是 RMWRead-Modify-Write写入链路中“初始化”决策的入口Garnet 收到一个作用于不存在 key 的更新命令时先调用该方法判断是否值得为这个 key 建立新对象。例如MYDICTSET永远希望写入因此直接返回truemain/GarnetServer/Extensions/MyDictSet.cs而像LPOP这类“对象不存在时无操作”的语义则在这里返回false并提前输出 nil。4.2 必选方法二Readerpublic virtual bool Reader(ReadOnlySpanbyte 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(ReadOnlySpanbyte 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 必选方法三Updaterpublic virtual bool Updater(ReadOnlySpanbyte key, ref ObjectInput input, IGarnetObject value, ref RespMemoryWriter writer, ref RMWInfo rmwInfo) throw new NotImplementedException();作用执行 RMWread-modify-write或 upsert 场景下的就地更新给定key、用户输入input将计算结果写入value指向的既有对象并用writer回写命令输出rmwInfo携带该记录的行信息引用用于加锁等并发控制。原文档同样说明纯只读命令无需重写该方法默认抛NotImplementedException。MYDICTSET的实现main/GarnetServer/Extensions/MyDictSet.cs展示了完整的更新流程public class MyDictSet : CustomObjectFunctions { public override bool NeedInitialUpdate(scoped ReadOnlySpanbyte key, ref ObjectInput input, ref RespMemoryWriter writer) true; public override bool Updater(ReadOnlySpanbyte 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 可选方法InitialUpdaterpublic virtual bool InitialUpdater(ReadOnlySpanbyte 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.csNotFound当读取命令未命中记录时的兜底输出默认写入 nilRESP 3 为_\r\nRESP 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)向服务器的对象存储注册新对象类型绑定工厂这一步让引擎知道如何创建/反序列化MyDictNewCommand(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是总参数个数含命令名本身因此MYDICTSET3 个参数 命令名为 4MYDICTGET2 个参数 命令名为 3。像内置命令那样你还可以进一步补充FirstKey、LastKey、Step、Flags、AclCategories等字段参考同文件SETIFPM的注册示例 main/GarnetServer/Program.cs使命令出现在COMMAND/COMMAND INFO结果中获得客户端命令发现、ACL 分类等一等公民待遇。注册完成后启动GarnetServerdotnet 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与Deserializelibs/server/Custom/CustomObjectFactory.cs命令函数继承CustomObjectFunctions按需实现NeedInitialUpdate/Reader/Updater可选InitialUpdatermain/GarnetServer/Extensions/MyDictSet.cs、main/GarnetServer/Extensions/MyDictGet.cs、libs/server/Custom/CustomObjectFunctions.cs注册NewType注册对象类型 NewCommand注册读写命令可附RespCommandsInfomain/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),仅供参考