- 后端
- 缓存
- 数据库客户端
- 消息队列
【免费下载链接】StackExchange.Redis
The Redis client for .NET
导读
本文是一份面向 StackExchange.Redis 贡献者的完整实操指南,讲解如何在src/StackExchange.Redis客户端中端到端地新增一个 Redis/RESP 命令(或重载):从RedisCommand枚举注册、IDatabase/IDatabaseAsync接口声明、RedisDatabase实现、ResultProcessor结果解析,到公共 API 追踪文件、ResultProcessor + RoundTrip 两层免服务器单元测试,以及当新命令可以替代MULTI/WATCH事务时如何扩展TransactionAnalyzer规则。读完本文,你将掌握一套可复制的九步流水线,并能理解每一步背后仓库源码的强制约束(如IsPrimaryOnly的穷举 switch、可加性重载避免歧义的技巧、PublicAPI.Shipped.txt的二进制兼容红线)。
前置:先读懂 AGENTS.md 的两条铁律
仓库根目录的 AGENTS.md 是任何命令实现工作的必读起点,其中两条约束直接决定了实现方式:
- Public API tracking → Backwards compatibility is paramount:该库在 .NET 生态中被广泛引用,对已发布公共 API 的硬性破坏被极度反对——尤其是以
MissingMethodException/MissingFieldException形式暴露的二进制破坏。注意“源码兼容的改动仍可能是二进制破坏”:给已存在方法追加可选参数会改变其 IL 签名,从而破坏已编译的调用方。因此优先采用可加性模式:新增重载而不是修改既有方法签名;确需淘汰时用[Obsolete(...)]标记而非删除。 - Architecture:
ConnectionMultiplexer→IDatabase/RedisDatabase→ServerEndPoint/PhysicalBridge→PhysicalConnection→ RESPite 的请求链路中,Message.cs与ResultProcessor.cs是理解命令实现的两个枢纽——新增或修改一个命令,本质就是“创建 Message(请求字节)+ 挑选/扩展一个 ResultProcessor(应答解析)”。
同时,仓库采用严格构建:TreatWarningsAsErrors=true、Features=strict,StyleCop 与分析器(含 PublicApiAnalyzers)随构建运行,任何警告都会让构建失败。构建与 API 分析器会在漏接线的第一时间大声报错,但真正证明命令“能用”的是测试。
第一步:先拿到命令的权威规范(Spec First)
动手写任何代码之前,必须先确定命令的精确参数顺序与应答形状——这两者分别决定Message(请求字节)与ResultProcessor(应答解析)怎么写,而往返(RoundTrip)测试会对两者做逐字节断言。规范来源按优先级排列:
- 服务器源码 JSON 规范(最精确):例如
https://github.com/redis/redis/blob/unstable/src/commands/xdelex.json,包含参数 token/顺序/可选性、arity、键规范,以及**write/readonly命令标志**——这直接告诉你IsPrimaryOnly该如何分类,多数情况下还附有reply_schema。 - HTML 文档:例如
https://redis.io/docs/latest/commands/xdelex/,可读性更好,带应答示例。 - 非 Redis 目标(Valkey/Garnet 等)使用各自源码与文档,但线缆命令通常一致。
- 模块命令(RediSearch
FT.*、RedisJSONJSON.*、RedisTimeSeriesTS.*、RedisBloom 等)在各模块自己的仓库中,通常是一份聚合的commands.json(如 RediSearch 的commands.json)。但模块命令通常由独立配套库(如 NRedisStack)处理,一般不进入 StackExchange.Redis 核心;临时使用走通用Execute/ExecuteAsync(string command, …)→RedisResultAPI。若确实要一线接入,注意线缆 token 带点号(FT.SEARCH),而 C# 枚举成员名不能含.——成员名非法标识符时,通过[AsciiHash("FT.SEARCH")]覆盖提供真实 token,详见 eng/StackExchange.Redis.Build/AsciiHash.md。一线接入前先确认确实需要类型化绑定。 - 未发布的新命令:两处都查不到时,向用户索取规范——精确参数顺序与一份具体的示例请求/应答(最好有 RESP 原始字节),不要猜;RoundTrip 与 ResultProcessor 测试的正确性完全取决于这份样本。
- RESP2 vs RESP3:应答(偶尔包括参数处理)在两个协议下可能微妙不同——例如 map/
%与扁平*数组之别、double/,与 bulk-string 数字之别、或附加属性。JSONreply_schema有时能区分二者。两种形态都要捕获,在ResultProcessor中分别处理,并双双覆盖进单元测试。
九步实现流水线
第 1 步:注册RedisCommand枚举并完成IsPrimaryOnly分类
命令名加入 src/StackExchange.Redis/Enums/RedisCommand.cs 的internal enum RedisCommand。枚举成员名就是线缆 token(CommandMap通过command.ToString()序列化它),所以必须与 Redis 期望的拼写完全一致(如GETEX、XAUTOCLAIM),并保持既有字母序分组。从源码结构可以确认两点:
- 枚举第一个成员是带
[AsciiHash("")]的NONE(“not a command; must be first for 'zero reasons'”); - 当枚举名不是合法线缆标识符时,用
[AsciiHash(...)]显式覆盖,例如[AsciiHash("BITFIELD")] BITFIELD、[AsciiHash("EVAL_RO")] EVAL_RO、[AsciiHash("BITFIELD_RO")] BITFIELD_RO等——这是仓库中已存在的真实模式。
随后必须在同一文件的IsPrimaryOnly扩展方法中完成分类。这个switch是穷举的:其default分支在运行时抛出ArgumentOutOfRangeException(错误信息形如"Every RedisCommand must be defined in Message.IsPrimaryOnly, unknown command '{command}' encountered.",位于 RedisCommand.cs 附近),所以这一步不可省略。分类规则见源码注释:写操作/变更类命令放入主库专用(primary-only)列表;纯读命令落入可副本(replica-eligible)分支。分错会错误路由命令——例如把写命令发往副本。源码注释还给出了一个反直觉的判定准则:“只要命令可能在 100% 场景下于只读副本上失败,就进列表”;而“可能可写”的命令(如 EVAL 脚本)不应标为 primary-only,否则会阻断调用方通过.DemandReplica在副本上跑只读脚本的合法场景。
第 2 步:在接口上声明方法(同步 + 异步,注意可加性重载)
方法声明写入 src/StackExchange.Redis/Interfaces/IDatabase.cs与IDatabaseAsync.cs(相关时也写在.Arrays.cs/.VectorSets.cs等分部文件里)。同步与异步必须成对提供。
- Back-compat 红线:永远不要给已发布方法追加可选参数(二进制破坏 → 运行时
MissingMethodException)。应新增一个重载。 - 可加性重载技巧(避免歧义):如果已发布方法尾部参数全可选(例如
Foo(key, int? count = null, CommandFlags flags = CommandFlags.None)),再追加一个仅多带几个可选参数的新重载(Foo(key, int? count = null, int? extra = null, CommandFlags flags = ...))会让既有调用Foo(key, 5)产生歧义——两个候选都替换默认值,没有“更优”者,编译器报CS0121,分析器还会标记RS0026。修复办法:把既有重载的参数改为非可选(去掉= ...默认值),让新重载承载全部可选参数。去掉默认值在二进制上是安全的(IL 签名不变),源码上也是安全的(旧调用自然重新绑定到功能等价的新全可选重载)。这个改动必须在接口与每一个实现者(RedisDatabase、KeyPrefixed/KeyPrefixedDatabase)间保持一致,并同步更新 PublicAPI.Shipped.txt(optional→required 属于就地编辑);新全可选重载进入 PublicAPI.Unshipped.txt。新重载需要用#pragma warning disable RS0026包裹——因为即使重载不再歧义,分析器仍会标记竞争的可选参数重载。注意“最富”的既有重载可能藏在兄弟分部文件里(例如IDatabaseAsync.VectorSets.cs),不要漏看。 - 实现所有
IDatabase/IDatabaseAsync实现者,否则构建失败。重点是 KeyspaceIsolation/KeyPrefixedDatabase.cs——它必须通过ToInner(key)给键加前缀;一个“转发但不加前缀”的桩可以编译通过,却会静默破坏新命令的键空间隔离。若命令还应支持批处理/事务,把它一并加到IBatch/ITransaction及其实现(RedisBatch/RedisTransaction/KeyPrefixedBatch)。
第 3 步:在RedisDatabase.cs实现(Message 构建)
实现写入 src/StackExchange.Redis/RedisDatabase.cs,紧挨着你选的模板方法。标准形态:
public RedisValue StringGet(RedisKey key, CommandFlags flags = CommandFlags.None) { var msg = Message.Create(Database, flags, RedisCommand.GET, key); return ExecuteSync(msg, ResultProcessor.RedisValue); } public Task<RedisValue> StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None) { var msg = Message.Create(Database, flags, RedisCommand.GET, key); return ExecuteAsync(msg, ResultProcessor.RedisValue); }这是贯穿整个文件的真实模式——源码中大量命令正是Message.Create(Database, flags, RedisCommand.X, key, args)的一行式构造(如GEOSEARCHSTORE、HDEL、HINCRBY、HSET/HSETNX、LPUSH、SPOP、ZPOPMIN/ZPOPMAX等),随后交给ExecuteSync/ExecuteAsync配一个ResultProcessor。
当Message.Create覆盖不了参数形状(可选 token、变长参数、多次往返)时,需要编写私有Message子类重写WriteImpl。在RedisDatabase.cs中搜索: Message与GetStringGetExMessage可找到现成范例:
private sealed class KeyMigrateCommandMessage : Message.CommandKeyBase——因为MIGRATE参数形态特殊(源码注释明说 “MIGRATE is atypical”);GetStringGetExMessage帮助方法——StringGet/StringGetEx家族用它把Expiration编译进GETEX的参数序列(见 RedisDatabase.cs 一带的调用)。
必要时也可以使用IMultiMessage组合多条消息。
第 4 步:挑选或编写ResultProcessor<T>
结果解析位于 src/StackExchange.Redis/ResultProcessor.cs。应答形状匹配时优先复用既有处理器:RedisValue、RedisValueArray、Int64、Boolean、Lease等(文件中有一批public static readonly ResultProcessor<...>字段,如Int32、ExpireResultArray等,均为internal sealed class XProcessor : ResultProcessor<T>的公开只读实例)。否则新增一个嵌套类:
internal sealed class XProcessor : ResultProcessor<T> { public override bool SetResult(PhysicalConnection connection, Message message, ref RespReader reader) { // 用 RespReader 解析应答 } }并以public static readonly字段对外暴露。RESP2 与 RESP3 的差异、以及旧版本服务器的应答变体,都要在这个类里处理——例如StreamAutoClaim处理器就要同时接受 Redis 7.0 的 3 元素应答(含已删除 ID 数组)与 Redis 6.2 的 2 元素旧格式。
第 5 步:新结果类型放APITypes/
新的结果类型(不是简单标量时)放入 src/StackExchange.Redis/APITypes/,以StreamAutoClaimResult等为模板——该目录已汇集GeoEntry、HashEntry、SortedSetEntry、StreamEntry、LCSMatchResult、RedisValueWithExpiry等公共结果类型。
第 6 步:更新公共 API 追踪文件
把每个新公共成员加入 PublicAPI.Unshipped.txt(以及仅在新 TFM 上存在的 API 对应的net6.0/子目录)。构建错误会精确告诉你该加哪一行——漏加则PublicApiAnalyzers直接让构建失败。
第 7 步:编写两层单元测试(免服务器、快速可靠)
详见下文“两个关键测试层”一节。这两层不需要任何外部服务器,是正确性最快速可靠的证明——即使你打算再补实时集成测试,也请先写它们。
第 8 步:按需为预发布服务端特性加实验门控
面向未发布/预发布服务端的特性,用[Experimental(Experiments.Server_8_x)]等诊断 ID 门控。诊断 ID 定义在 src/RESPite/Shared/Experiments.cs(SER001–SER006,例如Respite = "SER004"、按版本门控的服务端特性Server_8_4/8_6/8_8)。这些 ID 位于根NoWarn列表,因此库内部使用不会报错;配套文档在docs/exp/目录。
第 9 步:自问“这是不是原子组合?”
一个令人惊讶的常见情形是:新命令用一次往返做到了调用方目前需要MULTI/WATCH事务(或多条排队命令)才能完成的事——GETDEL、GETEX、HGETDEL、SMOVE、SET ... NX/GET/IFEQ、SMISMEMBER、所有M*/变长形式都是典型。如果是,就必须教会TransactionAnalyzer认识它,否则最可能受益的人永远不会发现它的存在。见下一节。
当新命令替代一个事务时:扩展 TransactionAnalyzer
eng/StackExchange.Redis.Build/TransactionAnalyzer.cs随包发布,会在消费者写了一条“现在可以是一条命令”的事务时给出提示。一条新增的原子命令若没进这张表,对消费者就是隐形的——分析器恰好对你写这条命令想要替代的代码保持沉默。现在补成本最低,没人会以后回来补。
先判断新命令替代的是哪种形态,然后在 TransactionAnalyzer.cs 中对应表格加一行:
| 它替代的事务 | 表 | 规则 |
|---|---|---|
一个AddCondition+ 一次写,命令现在把该条件作为参数 | Map,family A | SER300 |
一个AddCondition+ 一次写,由一条更新的命令同时吞掉两者 | Map,family B | SER301 |
一个AddCondition+ 一次写,写的返回值本身已回答该条件 | Map,family C | SER302 |
| 两条不同的排队命令 | MapPair | SER303 |
| 同一条命令排队 N 次,现可用变长重载 | MapVariadic | SER304 |
从源码看,这些规则在 TransactionAnalyzer.cs 中对应Rule枚举:ConditionalArgument(SER300)、NewerAtomicOperation(SER301)、RedundantCondition(SER302)、CompoundCommand(SER303)、VariadicOverload(SER304);映射逻辑集中在MapPair、MapVariadic等私有方法中。Rule与Rewrite.MinVersion有意保持分离:ID 描述“修复种类”(消费者据此配置严重级别、阅读文档页),版本是单个映射的数据,随服务器版本演进而移动。
除建议文本外,一行映射还要尽可能声明以下内容(取决于所在表的列):
- 建议所需的服务端版本——不是被标记代码所需的版本。使用与实时集成测试同一
RedisFeatures常量;形态早于任何实际服役版本时用ServerVersion.Any(提示“需要 2.6+”毫无意义)。这能让声明了<RedisMinServerVersion>的项目只看到自己可执行的建议。 - 覆盖集(coverage set)——建议仍携带的参数名。调用方写出的任何不在此集合内的东西都会让规则保持沉默,因为静默丢掉一个参数的改写比不提示更糟:N 次
StringSet(key, value, expiry)不等于MSET,“好心”地折叠会把键变成永不过期。只声明保留的名字、绝不声明丢弃的名字,这样以后给重载加参数也能安全失败(fail safe)。CommandFlags全局豁免。Family C 传null意为“全部”——它保留原命令、只删除条件。 - 是否要求同一成员/字段匹配(
Map的SameMember、MapVariadic的RequiresMember):关于成员"a"的条件对成员"b"的写毫无意义,折叠两者会丢掉真实的防护。 - 顺序(不可交换的命令,
MapPair):SET ... GET返回写入前的值;SET会清除任何 TTL,所以StringSet+KeyExpire是SET ... EX,而反向则不是。映射一个方向,用负向测试钉死另一个。 - 键的走向(
MapVariadic的ManyKeys):SADD是一个键多个值,N 次调用必须落在同一个键上;MSET/DEL是多个键,必须落在不同键上。方向搞反说明建议的是完全不同的命令。
把“决定不映射什么”及其理由写下来。近失配才是最危险的部分,表格里的注释是承重的:ListRightPop+ListLeftPush不是LMOVE(事务内 pop 的结果是未解析的Task,push 进的是另一个值);N 次ListLeftPop不是LMPOP(LMPOP从第一个非空键弹出,而不是从每个键弹出)。如果你说服自己不要某个映射,把推理留在下一个接手的人会撞上的地方。
随后还需三件事:
- 测试:
tests/StackExchange.Redis.Build.Tests/下——正向测试放SER30x.cs(如 SER300.cs),重要的负向测试放 DetectionShape.cs。负向测试才是重点:它们是“正确代码”,却会被一个过于激进的分析器建议改坏,而这条诊断会发给每一位消费者。如果你的映射要求同键、同成员、特定顺序或某参数缺失,就必须为每一条写一个测试,否则该约束就不是真的。 - 文档:docs/rules/SER30x.md 加一行——每条消息都链接到该页获取自身无法承载的注意事项。
- 新规则 ID(而不是在既有表里加行)还需在 eng/StackExchange.Redis.Build/Diagnostics.cs 加描述符,并在 eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md 登记——ID 一旦发布就是公共契约,因为消费者会把它们写进
NoWarn。
两个关键测试层
第一层:ResultProcessor 单元测试(纯解析)
证明你的ResultProcessor能把原始 RESP 字节变成正确的类型化值。在tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/下新增一个派生自ResultProcessorUnitTest的类;用手工构造的 RESP 线缆字节喂给Execute(resp, ResultProcessor.X)并对结果断言;用ExecuteUnexpected(resp, ...)覆盖必须失败的应答。以 ResultProcessorUnitTests/StreamAutoClaim.cs 为模板——该文件真实展示了如何用手拼 RESP 字符串(如"*3\r\n$3\r\n0-0\r\n*1\r\n...")验证XAUTOCLAIM的解析,并分别覆盖 3 元素新版应答、2 元素旧版应答、空数组、null 数组等形态:
public class MyCommand(ITestOutputHelper log) : ResultProcessorUnitTest(log) { [Fact] public void Basic_Success() { var resp = "*2\r\n$3\r\n0-0\r\n*0\r\n"; // hand-built RESP reply var result = Execute(resp, ResultProcessor.MyCommand); Assert.Equal("0-0", result.NextStartId.ToString()); } [Fact] public void WrongShape_Failure() => ExecuteUnexpected("$5\r\nhello\r\n", ResultProcessor.MyCommand); }务必覆盖真正咬人的情形:RESP2与RESP3 两种形态、空数组、null($-1/*-1)、旧版本服务器应答形状(如跨版本的 2 元素 vs 3 元素应答)、以及至少一个畸形应答(ExecuteUnexpected)。
第二层:RoundTrip 单元测试(完整写入 + 读取,依然无服务器)
证明命令序列化为 Redis 期望的确切字节并能正确解析回来,锻炼Message.WriteTo+ 命令映射。加入tests/StackExchange.Redis.Tests/RoundTripUnitTests/,用TestConnection.ExecuteAsync(message, processor, requestResp, responseResp, ...)——它会断言出站 RESP 恰好等于requestResp,再把responseResp喂回处理器。参见 RoundTripUnitTests/AdhocMessageRoundTrip.cs:
[Theory(Timeout = 1000)] [InlineData("hello", "*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n")] public async Task MyCommand_RoundTrips(string payload, string requestResp) { var msg = /* build the Message exactly as RedisDatabase does */; var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.MyCommand, requestResp, ":5\r\n", log: log); Assert.Equal(5, result.AsInt32()); }验证精确的出站字节(长度前缀也要对),并理想地验证命令映射的重命名与禁用行为——AdhocMessageRoundTrip.cs中的MapMode枚举(Null/Default/Disabled/Renamed)就是为此设计的真实模式:Disabled模式断言抛RedisCommandException(“This operation has been disabled in the command-map and cannot be used: echo”),Renamed模式断言出站 token 变为新名字(如ECHO2)。
可选:实时集成测试(需要真实服务器)
只有需要向真实服务器证明行为时才写——它们依赖 docker Redis 拓扑(见 AGENTS.md 的 Testing topology,或 tests/RedisConfigs/docker-compose.yml)。服务器缺席时测试基建会自动跳过,无需为此写代码。
新命令真正需要处理的是服务端版本:多数新命令是新服务端特性,测试必须在过老服务器上以“无结论”跳过。用require:参数创建连接——它会连接并在实时服务器低于阈值时自动跳过:
await using var conn = Create(require: RedisFeatures.v7_4_0_rc1); var db = conn.GetDatabase(); // ... exercise the command ...挑选与引入该命令的版本匹配的RedisFeatures.vX_Y_Z常量(模式见HashFieldTests.cs/CopyTests.cs)。若所需版本阈值尚不存在,把常量加入 src/StackExchange.Redis/RedisFeatures.cs。这样套件在 CI 与贡献者运行的各种服务器版本上都保持绿色。
另外,进程内托管服务器(toys/StackExchange.Redis.Server/RedisServer.cs)在集成测试针对它运行时,可能也需要加一个处理器。
完成前的验证清单
提交前必须跑通以下命令(均以仓库根目录为工作目录):
# 1. 全量构建:分析器 + TreatWarningsAsErrors 必须通过(会抓漏掉的 PublicAPI.Unshipped.txt 条目) dotnet build Build.csproj -c Release /p:CI=true # 2. 只跑新命令的单元测试(无需任何服务器) dotnet test tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -f net10.0 --filter "FullyQualifiedName~MyCommand" # 3. 若改过 TransactionAnalyzer(同样免服务器,秒级完成) dotnet test tests/StackExchange.Redis.Build.Tests/StackExchange.Redis.Build.Tests.csproj最后双重检查:没有任何已发布签名被改动(back-compat)。签名一旦发布,任何变更都会变成消费者运行时的一声MissingMethodException。
快速对照:常见踩坑一览
| 环节 | 常见错误 | 后果 | 正确做法 |
|---|---|---|---|
IsPrimaryOnly | 漏加新命令 | 运行时ArgumentOutOfRangeException(穷举 switch 的 default 抛异常) | 写操作进 primary-only 列表,读操作落到副本分支 |
| 接口重载 | 给已发布方法加可选参数 | 二进制破坏 →MissingMethodException | 去默认值 + 新全可选重载,#pragma warning disable RS0026 |
| 键空间隔离 | KeyPrefixedDatabase桩转发不加ToInner(key) | 编译通过但静默破坏前缀隔离 | 每个实现者都ToInner(key) |
| RESP 变体 | 只处理 RESP3 | 旧服务器/协议下解析错乱 | 两种形态 + 旧版本形状全覆盖进单测 |
TransactionAnalyzer | 不登记原子组合命令 | 消费者永远看不到建议 | 对照五类表加行 + 负向测试 +SER30x.md文档 |
| 公共 API | 漏更新PublicAPI.Unshipped.txt | 构建失败(PublicApiAnalyzers) | 按构建错误提示精确补行 |
掌握这条九步流水线后,从枚举注册到两层免服务器测试、再到事务替换分析器联动,任何新 RESP 命令都能以“构建通过 + 测试证明 + 不破坏兼容”的方式合入 StackExchange.Redis。
- 后端
- 缓存
- 数据库客户端
- 消息队列
【免费下载链接】StackExchange.Redis
The Redis client for .NET
相关推荐
node-redis 命令实现完全指南:从 `parseCommand` 到 `transformReply` 的端到端工作流
node redis 命令实现完全指南:从 parseCommand 到 transformReply 的端到端工作流 导读 node redis( @redi
后端数据库客户端缓存go-redis 新增 Redis 命令完整实战指南:从命令规范、Cmder 类型到 RESP 解析与测试
go redis 新增 Redis 命令完整实战指南:从命令规范、Cmder 类型到 RESP 解析与测试 本篇技术指南以 go redis 仓库(Redis
后端数据库客户端缓存RIOT OS USB HID 回显测试全指南:从设备枚举到 hidraw 通信的端到端验证
RIOT OS USB HID 回显测试全指南:从设备枚举到 hidraw 通信的端到端验证 导读 本文围绕 RIOT OS 测试套件中的 usbus_hid
物联网嵌入式操作系统实时系统
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考