在 Garnet 中开发自定义对象(Custom Object):从继承 CustomObjectBase 到注册自定义命令的完整实战
2026/9/15 16:48:41 网站建设 项目流程

在 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); }

这段实现揭示了自定义对象需要关注的四个核心维度:

  1. 对象类型标识:构造函数中的byte type决定该对象属于哪一类,Garnet 在命令分发时用它做类型校验。这一点在基类Operate方法中体现得很直接(libs/server/Custom/CustomObjectBase.cs):若input.header.type != this.type,则输出ObjectOutputFlags.WrongType,即返回“WRONGTYPE”语义的错误,防止对同一 key 误用不同类型的对象命令。

  2. 序列化 / 反序列化对称性SerializeObject与反序列化构造函数必须严格成对。MyDict的格式为:先是条目总数(int),随后每个条目依次写“键长度 + 键字节 + 值长度 + 值字节”。这保证了对象在 AOF 日志、checkpoint 快照或主从复制中能够完整保存与恢复。基类DoSerialize(libs/server/Custom/CustomObjectBase.cs)会先调用基类逻辑再委托给SerializeObject,因此你只需要实现对象自身的负载。

  3. 克隆与浅拷贝语义CloneObject返回“新的对象外壳 + 共享内部数据”的浅拷贝(示例中直接共享dict引用)。这与 Tsavorite 存储引擎的写时复制(copy-on-write)与版本管理机制配合,是对象能够参与并发读写的基础。

  4. 堆内存追踪UpdateSize通过Utility.RoundUp对齐键值长度,并累加MemoryUtils.ByteArrayOverheadMemoryUtils.DictionaryEntryOverhead等常量来估算HeapMemorySize。Garnet 用这一数值做内存统计与驱逐决策,所以自定义对象必须如实维护它——替换旧值时要先扣减旧内存,再累加新内存,示例代码中的Debug.Assert(HeapMemorySize >= MemoryUtils.DictionaryOverhead)即为防止内存计数为负的守护。

原文档提示的官方示例路径为GarnetServer\Extensions\MyDictObject.cs,在仓库中的实际位置是 main/GarnetServer/Extensions/MyDictObject.cs,其同目录下还有MyDictSet.csMyDictGet.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中计算输出并写入writerreadInfo中的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 中。输出侧则直接使用RespMemoryWriterWriteBulkString/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 });

拆解三个关键动作:

  1. server.Register.NewType(factory):向服务器的对象存储注册新对象类型(绑定工厂),这一步让引擎知道如何创建/反序列化MyDict
  2. NewCommand("MYDICTSET", CommandType.ReadModifyWrite, factory, new MyDictSet(), ...):注册写命令,CommandType.ReadModifyWrite会走NeedInitialUpdate → InitialUpdater/Updater的 RMW 链路,并绑定同一工厂(用于不存在时创建对象);
  3. NewCommand("MYDICTGET", CommandType.Read, factory, new MyDictGet(), ...):注册读命令,CommandType.ReadReader链路。

RespCommandsInfo用于声明命令的 RESP 元数据,其中Arity总参数个数(含命令名本身),因此MYDICTSET(3 个参数 + 命令名)为 4,MYDICTGET(2 个参数 + 命令名)为 3。像内置命令那样,你还可以进一步补充FirstKeyLastKeyStepFlagsAclCategories等字段(参考同文件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

六、从源码结构看对象命令的执行链路

结合基类实现,可以梳理出一条完整的自定义对象命令执行链路,帮助你定位各方法的调用时机:

  1. 客户端发送MYDICTSET mykey foo bar,服务端解析命令后,根据注册信息定位到对象存储与该命令的CustomObjectFunctions实现;
  2. 对象存储查询mykey不存在→ 调用NeedInitialUpdate(返回true)→ 通过工厂Create新建MyDict→ 调用InitialUpdater(默认转发Updater)完成首次写入;已存在→ 直接调用Updater对既有对象做就地更新;
  3. 对象内部使用UpdateSize维护HeapMemorySize,供内存管理与驱逐决策使用;
  4. 对象变化被记录进日志,checkpoint / AOF / 复制时调用SerializeObject落盘或同步;重启恢复时由工厂的Deserialize路径重建对象;
  5. 读取类命令(MYDICTGET)则走Reader,命中时WriteBulkString,未命中时WriteNull(或经NotFound兜底)。

这一机制与内置的 Hash、Sorted Set 等对象共用同一套 Tsavorite 对象存储基础设施,因此自定义对象天然获得持久化、复制、集群迁移等 Garnet 核心能力,这正是“用 C# 自定义数据结构 + 自定义命令”这一扩展方式的价值所在。

七、小结

围绕原文档的指引,我们完成了自定义对象从零到可用的完整闭环:

步骤需要做的事参考文件
对象类继承CustomObjectBase,实现序列化、克隆、释放与内存统计main/GarnetServer/Extensions/MyDictObject.cs、libs/server/Custom/CustomObjectBase.cs
工厂类继承CustomObjectFactory,实现CreateDeserializelibs/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之外更多示例(如DeleteIfMatchReadWriteTxn等),均位于 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询