从TypeScript到C#:手把手把OpenAI Codex SDK完整移植成.NET原生SDK
我是在一条Windows构建流水线上被逼着走上这条路的。当时团队要在.NET后端里集成OpenAI Codex的编码智能体能力,按照官方文档,标准做法是npm install一个TypeScript SDK包。可构建机上一个非常经典的报错直接把人整懵了:
missing optional dependency @openai/codex-win32-x64. reinstall codex: npm install
查了一晚上才搞明白,这个SDK不少核心能力打包在平台相关的原生二进制里,npm的optional dependency在Windows CI上没有正确拉取,而团队其实压根不想为了一个嵌入式能力去养一套Node.js工具链。我当时的决定很干脆:直接把TypeScript SDK完整移植成.NET原生SDK,让C#程序员用dotnet add package装完就能跑,不需要任何Node运行时。
这篇文章会把整个移植过程摊开来讲,从SDK的架构解剖、类型映射、HTTP层重写,到流式事件的.NET化改造、测试策略和NuGet打包,一条线走完。适合两类人看:一类是想把Codex能力集成到C#应用里的开发者,另一类是和我一样需要把一个TypeScript SDK搬到其他语言生态的工程团队。代码量不小,但每一步都有明确的理由,照着做就能少踩我踩过的那些坑。
1. 移植前必须做对的三件事:理解SDK层次、圈定边界、决定策略
1.1 Codex SDK的结构不是一个包,而是三层东西
在动手写任何代码之前,我先把npm包拆开看了一遍。Codex SDK表面上是一个TypeScript库,实际上由三层构成:第一层是命令行工具的交互层,负责终端UI、参数解析、用户输入;第二层是客户端SDK层,负责和OpenAI后端通信,管理会话、消息、事件流;第三层是核心逻辑的可执行部分,打包为各平台的原生二进制(比如codex-win32-x64),负责真正的代码推理和工具执行。三者的关系用一句话概括:TS客户端负责发请求和收事件,底层二进制负责干活,CLI层负责把这一切包装成人能用的界面。
我们真正要移植的是第二层,也就是客户端SDK。第一层CLI交互我们根本不需要,第三层原生二进制无法用C#重写,但C#完全可以直接调用——通过Process或者更好的方式把原生活动封装起来。想明白了这个,移植范围一下子就清晰了:我做的不是把整个Codex“重写”一遍,而是用C#重新实现TS SDK提供的编程接口和协议逻辑,让上层用户从“调用npm包拿到TS对象”变成“调用NuGet包拿到C#对象”,行为完全一致。
1.2 移植的收益和成本,说实话算清楚
移植不是激情活,得先算账。收益有三条:第一,去掉Node.js运行时依赖,在纯.NET环境(尤其是容器、离线内网、Windows Server)里部署成本骤降;第二,C#强类型可以和现有业务对象无缝对接,不再需要JSON转来转去;第三,我们能完全掌控SDK内部的重试、超时、日志策略,出现问题可以直接改源码而不是提交issue等上游。
成本同样不可忽视。TypeScript SDK里有些类型设计非常“动态”,尤其是联合类型、可选字段和回调事件,搬到C#之后会明显“啰嗦”。另外SDK涉及的事件流协议、取消机制、错误语义都得逐一复刻,稍有不慎就会在边界行为上出现千奇百怪的差异。我的建议是:如果你的团队已经有Node环境且没有强约束,那直接用官方TS包更省事;如果你想清楚了要.NET原生体验,这篇文章的流程能帮你把成本降到最低。
1.3 三条路线里我为什么选了“契约移植”
实际下手前有三条路摆在面前:第一条是“桥接”,在C#里包一层进程调用,本质还是调用npm包,省事但没解决依赖问题;第二条是“全重写”,连核心推理逻辑都自己实现,听起来牛但既不现实也极其危险;第三条是“契约移植”,只重写TS SDK层的协议逻辑和公共API,底层核心仍然通过封装的本地进程或RPC调用原生组件。
我选了第三条。理由很实在:我们要解决的是集成体验问题,不是重新发明Codex。契约移植的核心是“接口、类型、协议一致”,实现载体可以完全不同。C#客户端负责文档化为API协议的那一部分,而重活仍然交给原生二进制去干——这就好比你不用重写一个数据库引擎,但可以写一个完美的ADO.NET驱动。后面所有章节的展开,都是这条策略的自然延伸。
2. 解剖TypeScript SDK:先列契约清单,再谈代码移植
2.1 把src目录翻了个底朝天
移植的起点永远不是写代码,而是读源码。我当时把SDK的src目录拆成几大类:类型定义文件放一块,API客户端实现放一块,资源模块(resource modules)放一块,工具函数放一块。每个模块我都做了一个“契约卡片”,记录它导出了什么类型、什么函数、什么常量。
一张典型的契约卡片大概是这个形状:
| 模块 | 公开成员 | 依赖 | 需要移植的等级 |
|---|---|---|---|
| types/chat.ts | ChatMessage, Role, ToolCall | 无 | 高 |
| core/client.ts | CodexClient类, AuthConfig | fetch, EventSource | 高 |
| resources/sessions.ts | createSession, getSessionEvents | client | 高 |
| events | EventStream, EventDispatcher | EventEmitter | 高 |
| utils/errors | APIError, AuthenticationError | 无 | 高 |
这一步绝对不能省。契约清单就是译者的“原文”,后面C#代码写成什么样,全靠这张表对照。我在移植过程中把所有公开API名字都列进了清单并标记状态,确保没有遗漏任何一个入口。
2.2 真正要复刻的是“行为契约”而不仅是方法签名
方法签名只占工作量的一部分,真正的重头戏是那些看不见的行为。举个例子,TS SDK里很多方法接受一个包含stream字段的参数,stream: true时返回一个异步迭代器,stream: false时返回完整对象,这两种模式下错误处理、超时行为、参数校验完全不同。移植到C#时,方法签名可以都叫CreateAsync,但返回值、可选逻辑必须严格对应。
还有事件语义:SDK暴露的不是简单的“请求响应”,而是一串持续发生的事件——消息开始、增量token、工具调用、错误信息、会话结束。事件的顺序、每个事件字段的约束、事件和事件之间有没有严格的状态机关系,这些都属于行为契约。我在移植前专门画了一份事件流转表,把所有事件类型按触发顺序排好,C#实现时直接用枚举和状态机约束住。
2.3 识别TS对“现代平台特性”的依赖
阅读源码时还要注意一件事:TS SDK是否依赖特定运行时的能力。Codex SDK大量使用了fetch、ReadableStream、EventSource、AbortController这些Web标准API,它们在不同Node版本上行为有一点点差异,尤其体现在流式数据的边界处理上。C#后端没有这些基础设施,但HttpClient本身提供了更强大的能力,甚至在某些方面比TS的fetch更顺手。关键是翻译时别把TS的实现细节当成“标准”,比如TS里某个for await循环手动拼接chunk,到了C#里可能用StreamReader.ReadLineAsync更自然——行为一致就好,何必逐行翻译。
3. 类型映射:TS的动态类型,如何在C#里既优雅又不失灵活
3.1 用record + JsonPolymorphic替代interface
TS的interface是结构性类型,C#的class是名义类型,直接一一对应是做不到了。我的主力方案是C# 9的record,配合System.Text.Json的JsonPolymorphic特性处理多态。比如消息对象可能是用户消息、助手消息、工具消息三种,TS里用type: 'user' | 'assistant' | 'tool'来区分,C#里就可以定义一个抽象的CodexMessage基类,然后用[JsonPolymorphic]和[JsonDerivedType]让它自动反序列化成对应子类。
TS接口里那些纯数据对象,基本都能用record优雅搞定:
public sealed record ChatCompletionRequest { public string Model { get; init; } = "codex-mini-latest"; public required List<CodexMessage> Messages { get; init; } public bool? Stream { get; init; } public double? Temperature { get; init; } public int? MaxOutputTokens { get; init; } }用required和init把数据对象的不可变性表达出来,这和TS里大量使用readonly字段是一模一样的意图,而且序列化行为更可控。
3.2 联合类型是最大麻烦,没有之一
TS里的联合类型到处是,比如某个字段是string | string[],某个事件是FunctionCallEvent | MessageEvent | ErrorEvent。C#没有原生联合类型,我试过三种方案:用抽象基类加多态、用object字段然后在读取时switch、用自定义JsonConverter把不同类型映射到不同DTO。最终我的经验是分层处理——事件这类有明确类型标识的,用多态基类,干净且能利用编译期类型检查;纯数据字段如content既是字符串又是字符串数组的,我用一个自定义的OneOrMany<T>包装类型。
public sealed class OneOrMany<T> : IReadOnlyList<T> { private readonly IReadOnlyList<T> _items; public static OneOrMany<T> FromOne(T item) => new(new[] { item }); public static OneOrMany<T> FromMany(IEnumerable<T> items) => new(items.ToArray()); // 添加一个内置的JsonConverter<T>处理单值/数组两种JSON形态 }这比直接暴露object敬职敬业得多,使用者会看到一组可靠的API,同时序列化时又能完美兼容TS侧的JSON格式。
3.3 字符串枚举:宁可要常量,别滥用C# enum
TS的很多“枚举”其实不是真正的枚举类型,而是字符串字面量联合,比如Role = 'system' | 'user' | 'assistant'。最直觉的做法是映射成C#的enum,但我踩过更深的坑:System.Text.Json默认把枚举序列化成数字,一旦TS侧严格校验字符串类型,传输就破防了。虽然可以配JsonStringEnumConverter,但多一个配置就多一个出错点。
我的最终方案是定义一组静态只读字符串常量类,类型安全上不如enum,但序列化天然正确,也方便以后对接新值。要知道AI SDK的枚举值更新极快,今天你用enum把'draft'锁死了,明天SDK加了'archived',你的客户端就废了。常数类永远可以扩充,这才适合做SDK的原料。
3.4 undefined与null:语义别搞混
TS里undefined表示“未提供”,null表示“显式置空”,这在请求序列化时是两个完全不同的JSON表现:undefined字段干脆不出现,null字段则写"field": null。我在C#里用JsonIgnoreCondition.WhenWritingDefault配合Optional<T>包装类来精确控制这个语义。凡是TS标记为可选的字段,C#侧要么用可空类型(int?、string?),要么用自定义的Optional<T>,并且在序列化配置上明确区分“没设置”和“设置了但为null”。
这类细节是最容易导致线上事故的。后端拿到一个缺失字段和一个null字段,解析逻辑经常完全不同。映射完所有类型后,我建议做一遍“往返序列化测试”:生成TS侧的样例JSON,反序列化成C#对象,再正向序列化一次,比对两次JSON的结构差异,这能迅速暴露所有语义错位。
4. 重建HTTP层:认证、请求路由和错误语义一个都不能漏
4.1 为什么官方OpenAI .NET包不能拿来直接用
开始写HTTP层前,团队里有人提议直接用OpenAI官方提供的.NET包,再把Codex端点塞进去。这个想法很快被我否了:第一,Codex SDK有自己专属的端点和请求结构,官方通用包虽然在某些大模型服务上通用,但Codex会话管理、事件流、审批交互这些能力根本没有对应模型对象;第二,两者的错误语义完全不同,错误码、重试时机、限流头解析都是SDK自己的活儿。结论是:自己实现一套HTTP层,只借用HttpClient,其它全部按TS SDK的协议来。
4.2 HttpClient基础配置:连接复用与DNS刷新
C#的HttpClient不是“随便new一个拿来用”就行,我见过很多团队死在Socket exhaustion上。移植SDK时,正确的姿势是配置一个单例的SocketsHttpHandler,打开连接池复用,设置合理的PooledConnectionLifetime,避免DNS解析长期不刷新。我的配置大概长这样:
var handler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), MaxConnectionsPerServer = 64, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }; var httpClient = new HttpClient(handler);为什么这么较真?因为Codex SDK的流式接口可能长时间占用连接,如果每个请求都new一个HttpClient,你的服务在高并发下很快就把连接池扛爆。PooledConnectionLifetime设成5分钟循环刷新,既保证连接新鲜度,又避免频繁握手。
4.3 认证与Key管理:不要硬编码,往依赖注入里走
TS SDK通常直接从环境变量读OPENAI_API_KEY,C#端做一个原生SDK也完全可以支持这个,但真正的生产环境需要用依赖注入把认证信息从配置中心拉进来。我设计的客户端接口很简单:
public sealed class CodexClientOptions { public string? ApiKey { get; set; } public string? AccessToken { get; set; } public Uri? BaseUrl { get; set; } public TimeSpan? Timeout { get; set; } } var client = new CodexClient(options);真正的认证逻辑集中在HttpRequestMessage构造时注入Authorization头。这里有个安全细节:不要在日志里打印Authorization头,也不要把ApiKey塞进异常消息。TS SDK没见过这个问题是因为它的生态里日志相对粗放,但.NET服务普遍接入了结构化日志和集中监控,敏感信息泄漏一次就很麻烦。
4.4 错误层级:把TS的APIError家族翻译成C#异常
TS SDK里有一套成体系的错误类型:AuthenticationError、RateLimitError、BadRequestError、APIError等。移植时我做了同样层次的两个异常基类:
public abstract class CodexApiException : Exception { public int StatusCode { get; } public string? ErrorCode { get; } public string? RequestId { get; } } public sealed class RateLimitException : CodexApiException { } public sealed class AuthenticationException : CodexApiException { } public sealed class InvalidRequestException : CodexApiException { }错误码映射不是只有Status Code,TS SDK里很多错误是通过响应体里的code字段区分的,我也会解析它。RequestId字段则完全是实战体验后的追加——线上排查问题时,没有服务端RequestId你根本无从查起。
4.5 重试策略:不是所有请求都值得重试
移植时最容易犯的错是照抄TS SDK里某个简单重试循环。我的重试策略按错误类型分了三类:网络层失败(HTTP连接断开、超时)可以重试2次,指数退避加抖动;429限流必须读取服务端返回的Retry-After头,没读对就重试等于火上浇油;4xx这类客户端参数错误是绝对不该重试的,重试一万次也是白费。实现这个逻辑我用了Polly,但它本身也是一层依赖,如果你不想引入额外包,老老实实写一个RetryPolicy也完全够用。
核心原则就一句话:重试的价值在于抵御瞬时故障,不在于掩盖参数错误。我把这个判断标准写进了代码注释里,希望后来维护的同事不会随便改动它。
5. 流式交互:把EventEmitter翻译成IAsyncEnumerable才是精髓
5.1 TS侧是怎么处理流的
Codex SDK的流式输出采用的是Server-Sent Events(SSE)协议,TS客户端内部基于EventSource或fetch的ReadableStream,把一整串事件异步迭代出来。上层用户看到的是这样的代码:
for await (const chunk of codexSession.streamEvents()) { if (chunk.type === 'message') console.log(chunk.delta); }这个模式本身就非常函数式、非常“异步流”,和C#的IAsyncEnumerable<T>简直天生一对。我完全没有必要在C#里生造一个事件订阅模型。
5.2 事件订阅 vs IAsyncEnumerable的选择
说实话,我也认真考虑过用事件模型:client.MessageReceived += handler;。但经过一比,立即否掉了。事件模型有两个致命弱点:第一,流的生命周期是无法用事件表达的,什么时候流结束、什么时候取消,事件模型里这些全是隐式状态,维护是一团糟;第二,请求和响应没法一一对应,你并发跑两个会话时,事件回调里根本区分不出消息属于哪个会话。
IAsyncEnumerable<T>天然解决这两个问题,它把异步序列当一等公民,可以让调用方用await foreach消费,也可以传给LINQ做过滤、合并、缓冲,还能在循环外通过CancellationToken随时终止。这正是把TS“for await”逐字翻译的最佳姿势。
await foreach (var evt in client.Sessions.StreamEventsAsync(sessionId, cancellationToken)) { switch (evt) { case MessageEvent msg: Console.Write(msg.Delta); break; case ToolCallEvent tool: _logger.LogInformation("工具调用: {Name}", tool.Name); break; } }5.3 SSE解析的细节,一半的bug藏在这里
SSE的格式看着简单:一行为一个字段,空行表示事件分隔,data:可以多行拼接,event:指定事件类型。但实际解析时全是坑:有些代理服务器会在流中间插心跳注释(:开头),纯粹的注释行不能当事件发;有些平台的SSE用了\r\n而不是\n;data:字段的值可能包含Unicode换行符被转义之后的多行JSON,处理不好就会把一个事件错拆成两个。
我的实现方案是写一个专用的SseParser,一个字节一个字节地扫描,维护一个StringBuilder拼接data块,在遇到空行时抛出完整事件。这块逻辑不能偷懒用ReadLine,因为超大JSON会跨行。测试时我也专门构造了CRLF、无尾换行、心跳注释、data分块这四种脏数据来砸它,确保解析器稳如老狗。
5.4 取消与超时:把CancelletionToken从顶传到最底层
TS SDK的AbortController在C#里对应的就是CancellationToken。我的习惯是从公开方法的第一个参数开始就传它,一路穿透到HttpClient调用,中间的所有循环体都要在每次迭代时检查token.IsCancellationRequested。这件小事看起来不起眼,但没有它,用户按一个“停止生成”按钮,底层的流可能还要继续烧几分钟经费。
超时控制我做了两层:整个请求的超时时间(from options),以及流式场景下“两个事件之间的最大间隔时间”。后者特别重要,因为一个巨大的模型推理任务中间可能很久没有新事件,如果按整体超时一刀切,长任务全被误杀。我实现了一个带超时的ReadNextAsync,只在读下一个事件时计时,这个设计在实际接入后非常受欢迎。
6. 测试策略:怎么证明移植版和TS原版“行为一致”
6.1 三层测试金字塔,层层都不可少
移植SDK最怕的不是大功能不会写,而是没人说得清“原来的行为到底是什么”。我的测试体系分三层:第一层是契约快照测试,把TS SDK的请求/响应JSON样本固定下来,直接验证C#序列化和反序列化是否对齐;第二层是Mock服务器测试,用内存Kestrel模拟真实后端,覆盖各种状态码、限流头、SSE事件序列;第三层是真实API联调,只在前两层全部通过后做,并且所有调用都用小模型、小请求限制成本。
这三层各有各的用处,缺一层都会在某个深夜给你惊喜。
6.2 契约快照:把TS侧的真实输出当作“度量衡”
我用TS SDK写了一批脚本,每个脚本固定输入,然后把请求体、响应头、事件序列完整录制为JSON快照文件。C#测试直接加载这些快照,用JsonNode做深度比较。重点检查的不只是字段名称,更多是字段顺序(虽然JSON没有顺序语义,但一些弱后端会依赖)、数字精度(TS的number是浮点数,会用科学计数法序列化大数)、字段缺失(我方序列化出来多一个字段都不行)。
举个我踩过的例子:TS快照里content字段是个字符串数组,我的OneOrMany<T>从数组转换成功后重新序列化时,居然稳定地输出了单个字符串对象——因为我的Converter看到只有一个元素就自动折叠成单值了。这个语义在TS侧可以用string | string[]表示,但“数组长度为1”和“单值字符串”在JSON上是两种东西,必须严格区分。这种坑只有快照测试能抓住。
6.3 Mock服务器比真实API好用一百倍
真实API不稳定、慢、还花钱。我用Kestrel在内存里起了一个假的“OpenAI后端”,专门返回我设定好的事件序列和错误代码。Mock的不是核心逻辑,而是协议边界:比如限流后返回Retry-After头、SSE流中间断线、返回一个未知事件类型、响应体被截断一半。这些场景在真实后端根本不敢去试,但在Mock服务器里就是一分钟的功夫。
Mock服务器的另一个好处是并发测试。我可以写一个测试,同时打开30个会话流,验证客户端会不会出现事件串流的诡异情况——这种问题在真实API环境下极难定位,但在Mock环境里非常容易复现和修复。
6.4 最终验收:对比跑同一个场景,不是比截图而是比对事件序列
我做了一个“双跑验证”小工具:同样的输入,分别用TS SDK和C# SDK发起请求,把两边产生的事件序列按类型和关键字段逐条对比。因为两次调用不可能完全相同(模型有随机性),我只比较稳定字段:消息关联的会话ID、事件类型顺序、工具调用的function name等结构性信息。这个验收并不能完全自动化,但它是我最有信心的“证明行为一致”的手段。最后给管理层汇报时,拿着这份对比日志比任何PPT都好使。
7. 打包发布:让一个.NET开发者开箱即用,才是最后的胜利
7.1 目标框架怎么选:netstandard2.0还是net8.0
作为要在真实世界里发出去给别人用的SDK,目标框架的选择是一个极大的坑。我一开始图省事只写net8.0,结果我自己的演示Demo没问题,但同事的老项目跑不起来。最终我让包同时打出netstandard2.0和net8.0两个目标:前者保证老框架(.NET Framework 4.8、.NET Core 3.1)也能装,后者给现代化项目提供最好的性能体验。代码里凡是涉及新API的地方,都用多目标条件编译或者把新API隔离在独立文件中,尽量让核心逻辑完全可移植。
SDK这种基础设施一定要把兼容性当回事,用户装了你的包回来跟你说“我这是个老项目跑不了”,口碑直接崩掉。
7.2 依赖注入和日志:设计成可插拔而不是写死
原生SDK不能和宿主框架绑定死。我做了两件事:第一,所有的日志输出都走Microsoft.Extensions.Logging.ILogger,但通过一个内部包装类实现,SDK内部不强制require DI容器,使用者想传进去一个logger就可以传,不传就用空实现;第二,提供一个AddCodexClient扩展方法,让ASP.NET Core项目可以用标准DI方式接入。这两个设计让SDK既能用在控制台工具里,也能用在Web API里,一点都不挑食。
配置方面我也做得比较灵活:CodexClientOptions支持从IConfiguration里自动绑定,字段命名和下划线、驼峰都能兼容,尽量让TS环境里的环境变量习惯(比如OPENAI_API_KEY)无缝迁移。
7.3 提供一眼就会用的Demo
NuGet包装完后,用户第一件事肯定是找Demo。我在包的README里放了一个最小可运行示例,目标就是让人在5分钟内跑起来:
var options = new CodexClientOptions { ApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") }; await using var client = new CodexClient(options); var session = await client.Sessions.CreateAsync(); await foreach (var evt in client.Sessions.RunAsync(session.Id, "给下面这段代码加个单元测试:...", cts.Token)) { if (evt is MessageEvent { Delta: { Length: > 0 } } msg) Console.Write(msg.Delta); }这个Demo刻意没有做过多的参数定制,就是要让用户看到“就这么简单”。更多高级用法放在单独的wiki文档里,不污染主文档的清爽度。
7.4 与OpenAI官方.NET包的共存策略
做包之前一定要想清楚和官方OpenAI包的关系。我们做了极强的兼容方案:不抢占OpenAI命名空间,也不依赖官方包的任何类型,命名空间全部挂在Codex.Client下面。这样一来,你可以在同一个项目里同时使用官方通用包和我们的Codex原生包,各司其职。我甚至测试了同时注册两个HttpClient完全隔离的场景,确保不会因为静态配置互相干扰。
8. 移植完成后的复盘:那些只有实际动手才会知道的事
8.1 最容易翻车的五个地方
第一次搞这种跨语言SDK移植,有几个坑我反复踩,在这里一次性说透。
第一条,JSON大小写。TS SDK的默认命名是camelCase,而C#项目里清一色PascalCase。我一开始不想写一堆[JsonPropertyName],偷懒用的JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase,结果发现用户自定义的字段没法处理。最终老老实实做了两套策略:SDK内部传输对象全用camelCase序列化,公开API的对象再用PascalCase暴露,两边靠映射对象转换。多写了一些代码,但用户住得很舒服。
第二条,数字精度。TS的number是双精度浮点,C#的long是64位整数。涉及token计数、时间戳、速率限制这些大整数时,TS给出的值可能超过int的范围。我完整走查了一遍所有字段,凡是大数一律用long或decimal,绝不偷懒用int。
第三条,HTTP头大小写。某些代理网关对自定义Header比较敏感,而HttpClient对不同Header的大小写处理在Windows和Linux上有细微差别。我专门压测了一轮大小写场景,最后统一强制规范Header名称。
第四条,流式断线重连。Codex这种长时间SSE流,断线是常态,不是异常。我一开始把断线当错误抛出,后来改成可配置的自动重连策略,中间增加心跳检测。这个默认值改过之后就再没回来过,用户体验好了不少。
第五条,时间的时区。模型返回的时间字段有可能是ISO8601带时区偏移,C#侧如果用DateTimeOffset就是正确姿势,用DateTime处理起来全是心病。我全包统一用DateTimeOffset,只有格式化输出时才转成本地时间。
8.2 花了两周时间的移植,最后教会我什么
如果只让我留一句复盘,我会写:TypeScript到C#的移植,本质上不是翻译语法,而是翻译“契约的含义”。接口可以重新设计,类型可以重新映射,但协议的行为边界、错误语义、流式时序这些“看不见的约定”,比任何类型定义都重要。我的整个移植过程就是把这个思想贯穿到底:先列契约清单,再逐条对照、逐条实现、逐条测试。
另一个很实用的心得是:做这种SDK移植,文档要写到“使用者会在做哪三件事时开始骂人”的细致程度。我开发到一半让一个没参与过的同事试用Demo,他的第一反应是“为什么不是OpenAI命名空间?”——于是我改了命名空间,加了更详细的code comments,把包的这件事彻底理顺。面向使用者的SDK,设计永远是为了少挨骂。
最后,我要给所有准备干类似事情的人一个诚恳建议:非必要不移植,既然要移植,就把契约文档和测试体系放在代码之前。我这次操作之所以整体顺利,靠的就是开工前那几张契约卡片,还有后面覆盖了各种边界场景的测试矩阵。一个没有契约和测试的SDK移植,就是对生产环境提前埋雷。