harness-sdk 这个项目,最早不是技术驱动,而是被逼出来的。当时我们微服务数量超过 60 个,Go、Java、Python 三套技术栈混着跑,每个服务接配置中心和注册中心的方式都是各自为政——有人用官方客户端,有人自己封了一层 HTTP 轮询,还有人干脆写死 IP。线上排查链路要切三个平台,配置变更靠群里吼一嗓子"我改了啊",然后等十分钟再问"生效了没"。我实在忍不了,花了两周时间把 harness-sdk 的第一版写出来,目标只有一个:所有服务的基础治理能力统一走一个 SDK,谁也别想自己再造轮子。
这篇文章算是一次项目复盘。如果你也在维护公司内部 SDK、做微服务治理,或者正在纠结要不要自己封装一套注册中心/配置中心的客户端,那这篇文章值得你花十分钟看完。我会把设计取舍、核心模块实现、踩过的坑以及发布策略都过一遍,过程和代码都是可以直接参考的。
1. 为什么我们团队最后决定自研一套治理 SDK
很多人看到"自研 SDK"第一反应是重复造轮子,但 harness-sdk 还真不是。它解决的问题不是"没有轮子",而是"每个团队都在造自己的轮子,而且造得都不一样"。下面详细说说当时的处境。
1.1 各自为政的接入,线上排查基本靠猜
当时三个团队接入中间件的方式完全不同:
| 团队 | 技术栈 | 配置中心接入方式 | 注册中心接入方式 | 链路追踪 |
|---|---|---|---|---|
| A 团队 | Go | 官方 client,手动拉取 | 官方 client + 自写心跳 | 自研 traceID,UUID 格式 |
| B 团队 | Java | 自写 HTTP 轮询,每 30s 一次 | 直接调用注册中心 HTTP API | 没用,全靠日志 grep |
| C 团队 | Python | Docker 环境变量写死 | 完全没接入,直连 IP 调别人 | 没有 traceID 概念 |
这个表列出来之后,问题已经很刺眼了。最难受的是出故障的时候:一个请求从 A 服务走到 B 服务走到 C 服务,A 的 traceID 是 32 位 UUID,B 根本不透传,C 又不会接。本来一条链路追踪十分钟能定位的问题,实际排查要花一晚上。
这次复盘给我的第一个教训是:在微服务规模上来之前,统一治理接入方式这件事越早做越好,越晚越痛。等技术栈混杂、团队习惯固化之后,再推 SDK 阻力会大很多,因为每个团队都觉得自己那套"虽然不规范但能用"的方案挺好的。
1.2 开源全家桶很好,但解决不了"统一规范"
当时内部讨论过直接引 Spring Cloud 全家桶,或者用 go-micro、Dubbo 这类框架。讨论到最后结论很现实:
- Spring Cloud 对 Java 很友好,但我们有 Go 和 Python 服务,不能为了框架强行让所有服务都换语言。
- go-micro 等 Go 框架功能全,但它在改造业务代码的同时,又约束了项目的代码组织方式,推广阻力大。
- 开源组件注重"通用性",但我们更需要"统一规范"。比如"实例启动后必须打点才注册""优雅下线必须先反注册再退出"这类约束,开源组件不会强制你这么做,只能靠团队自觉。
还有一个选项是"用网关统一治理"。网关对南北向流量(客户端到服务端)很有效,但东西向流量(A 服务调 B 服务、B 服务调 C 服务)根本不经过网关,治理能力必须下沉到每个服务内部。靠网关解决不了服务间调用的治理问题。
所以当时的决策是:做一个轻量 SDK,不绑定框架、不强制技术栈,只提供一个统一的接入规范和一组治理能力。业务代码还是原来的业务代码,只是把"接配置中心""做健康检查""上报链路"这些动作全部收敛到一个 SDK 里。
1.3 harness-sdk 的定位:把治理规则变成代码约束
我想强调一个反直觉的观点:SDK 的本质不是"封装便利",而是"把治理规则变成不可绕过的代码约束"。
如果治理规范只是一份文档,那它只是"建议",团队忙起来就会绕过它。但如果治理规范是 SDK 里的强制逻辑——比如"实例必须在 Ready 之后才能注册,注册必须在启动流程里显式调用,否则服务不可被发现"——那它就变成了"代码约束",谁都没法绕过。
harness-sdk 第一版的定位就锚定在这四个能力上:
- 配置接入统一,支持热加载和变更监听。
- 服务注册与发现统一,支持优雅上线/下线。
- 链路追踪统一,自动透传 traceID。
- 指标上报统一,SDK 自身和业务都能上报监控数据。
后面所有模块都是围绕这四件事展开的。
2. harness-sdk 的核心目录设计与模块边界
很多人写 SDK 容易犯一个毛病:把内部工具类铺得满天飞,模块之间互相依赖,最后变成一坨循环引用的意大利面。harness-sdk 在目录结构上花了很大力气,这里分享一下最终定下来的方案。
2.1 五大核心模块,外加一个 internal
harness-sdk/ ├── config/ # 配置中心客户端:热加载、监听、快照 ├── registry/ # 服务注册与发现:状态机、心跳、本地缓存 ├── tracing/ # 链路追踪:traceID 生成、透传、上下文注入 ├── metric/ # 指标采集与上报:计数器、直方图、上报队列 ├── breaker/ # 熔断与降级:滑动窗口、半开状态 ├── internal/ # 内部工具:日志封装、重试器、并发安全工具 ├── examples/ # 可运行的示例工程 └── test/ # 集成测试与兼容性测试模块的定位很清晰:
config和registry是底座,不依赖任何上层模块。tracing和metric可以依赖底层模块,但彼此之间不直接依赖。breaker依赖metric做熔断指标记录,但不反向依赖。internal是内部包,外部代码无法 import,这是 Go 语言的一个硬性约束,也逼着我们收敛 API 面。
这样分层之后,每次加新功能时都先问一句:这个功能应该放哪个模块?如果答案不明确,说明功能定位没想清楚,先想清楚再写代码。
2.2 四条铁律:不占端口、不阻塞、不泄漏 panic、可降级
这是 harness-sdk 设计上最重要的四条内部约定,每条都踩过坑之后才总结出来的:
第一条:SDK 不启动独立端口。SDK 不是服务,不能占端口。如果需要暴露健康检查接口,由宿主服务自己来决定,SDK 只提供查询方法。一开始我们想内置一个 metrics HTTP 端口,后来发现多个服务实例部署在同一台宿主机时会端口冲突,果断砍掉。
第二条:SDK 不阻塞主流程。所有网络操作必须有超时,默认连接超时 1 秒、读超时 2 秒。SDK 初始化失败不允许 panic,不允许让服务直接挂掉。宁可让服务在"降级模式"下启动,也不能因为治理组件不可用导致线上服务起不来。
第三条:不泄漏 panic。SDK 内部的 goroutine、回调执行、消息处理,必须全链路 recover。业务监听器里抛 panic,吞掉并记录错误日志,而不是让整个推送循环炸掉。
第四条:可降级。治理后端(配置中心、注册中心)挂了,SDK 要继续使用本地缓存、继续把服务跑起来,同时通过日志和指标暴露"降级中"的状态。这条我们在故障演练中验证过很多次,SDK 降级后的行为直接决定了服务能不能扛住后端故障。
2.3 用事件总线解耦模块依赖
模块之间有些联动需要解耦,比如配置变更之后,本地缓存要刷新、指标要记录、可能的熔断状态要重置。如果直接让 config 模块 import metric 模块,耦合就变重了。
我们在internal里做了一个轻量事件总线:每个模块只发事件和订阅事件,不关心对方是谁。配置模块发ConfigChangedEvent,缓存模块订阅之后刷新缓存,指标模块订阅之后记录一条变更计数。谁想监听谁就订阅,新增联动不需要改发布方的代码。
// internal/eventbus/bus.go type Bus struct { mu sync.RWMutex topics map[string][]Handler } type Handler func(payload any) func (b *Bus) Publish(topic string, payload any) { b.mu.RLock() handlers := b.topics[topic] b.mu.RUnlock() for _, h := range handlers { // 每个 handler 单独 recover,防止一个监听着炸掉整个发布循环 func() { defer func() { if r := recover(); r != nil { log.Printf("eventbus: handler panic on %s: %v", topic, r) } }() h(payload) }() } }这套事件总线看起来简单,但非常实用。后面加了告警联动、日志采样联动,都是通过订阅完成的,核心模块一行没改过。
3. 配置热加载:从 30 秒轮询到推送式更新的演进
配置模块是 harness-sdk 最早做、也是改动最大的一块。起初我以为配置中心客户端无非就是拉数据、存本地、给接口,结果真正做起来才发现,热加载的设计很多细节都藏在"变更感知"这件事上。
3.1 第一版轮询为什么被吐槽
第一版实现很简单:每 30 秒全量拉一次配置,存到本地 map。上线当天就被运维吐槽了:
- 修改一个配置项,最长 30 秒才生效,变更完还要盯着 dashboard 看生效没有。
- 全量拉取太浪费,配置中心压力大。有一次一个团队放了几个大 key 进去,全量响应体超过 1MB,每个服务每 30 秒拉一次,直接把配置中心带宽打满。
- 没有"变更事件"的概念。业务想感知配置变化,只能自己比较上次和这次的快照,非常别扭。
第一版让我意识到:配置中心客户端的核心难点不是"读配置",而是"感知变更"。
3.2 长轮询加版本号:让变更"又快又省"
第二版改成"长轮询 + 版本号"机制,思路其实很朴素:
- 服务端维护一个全局版本号,配置变更时
version + 1。 - SDK 发起长轮询请求,带上本地版本号:
GET /config/notify?version=1024,服务端最长挂起 30 秒;期间若版本号有变,立即返回最新版本号。 - SDK 收到新版本号之后,再拉取一次全量配置
GET /config/values?version=1024。 - 没有变更时,只发一个轻量的 HTTP 请求挂着,几乎没有压力。
核心循环长这样(简化版):
// config/client.go func (c *Client) watchLoop(ctx context.Context) { for { select { case <-ctx.Done(): return default: } latest, err := c.fetchVersion(ctx, c.snapshot.Version) if err != nil { // 网络异常:退避重试,不阻塞主流程 backoff.Sleep(ctx, c.retryPolicy) continue } if latest <= c.snapshot.Version { continue } snap, err := c.fetchSnapshot(ctx, latest) if err != nil { backoff.Sleep(ctx, c.retryPolicy) continue } c.applySnapshot(snap) } }applySnapshot会做三件事:替换本地快照、发布ConfigChangedEvent、记录指标。替换快照用原子指针操作,保证业务读到的是一个完整的一致视图,不会出现半新半旧的状态。
这一版上线后,变更生效时间从 30 秒降到大约 1 秒(长轮询挂起返回 + 拉取全量),配置中心压力基本消失。到了这一步,基础的热加载已经能用了,但后面监听器和推送机制又折腾了几轮。
3.3 监听器回调的正确姿势与 panic 隔离
业务侧订阅配置,不是每次主动去查,而是注册一个监听器:
// config/listener.go type Listener func(event *ConfigEvent) type ConfigEvent struct { Keys []string // 本次变更的 key 列表 Snapshot *ConfigSnapshot // 最新全量快照 PrevVersion int64 } func (c *Client) Subscribe(keys []string, fn Listener) func() { c.mu.Lock() defer c.mu.Unlock() id := c.nextID() c.listeners[id] = subscriber{keys: keys, fn: fn} return func() { c.mu.Lock() defer c.mu.Unlock() delete(c.listeners, id) } }这里有几个细节我得专门说一下,全是实际踩坑踩出来的:
细节一:回调串行还是并行?同一个 key 的监听器必须串行,否则回调里读到旧值新值交错,很容易出诡异问题。不同 key 之间的监听器可以并行,但是要注意并发度不能高,我们默认同一批次事件最多 4 个并发 goroutine 处理,避免回调风暴。
细节二:panic 必须隔离。业务监听器写 panic 了,不能把推送循环炸掉。我们在applySnapshot里逐监听器分发,每个回调都用 defer recover 包住。监听器 panic 之后打日志、记指标、继续下一个,这是 SDK 最基本的自我保护。
细节三:快照必须不可变。业务拿到Snapshot之后可能会往 map 里塞东西,如果这个 map 是 SDK 内部持有的引用,业务就把 SDK 的状态改了。所以applySnapshot时做一次深度拷贝,业务拿到的是副本,随便改不影响 SDK。
3.4 一次大 key 引发的"推送风暴"
这个坑印象太深了。我们当时允许多个业务团队共用一套配置空间,后来发现有一个团队把几百 KB 的 JSON 模板直接塞进配置中心当配置用。问题在推送机制升级后爆发了:
- 配置服务端每变更一个 key,就会向所有 SDK 推送一次变更通知。
- SDK 收到通知后拉取全量快照,虽然我们做了差量更新,但几百 KB 的 key 每次变更都要全量传输。
- 高峰期一个大 key 每小时变更几十次,所有服务同时拉取,配置中心墙上的监控曲线直接拉满,GC 也被抬得很高。
事后我们做了四件事:
- 限制单个 key 最大 64 KB,超过直接拒绝写入,配置界面给出提示。
- 推送改成差量推送:变更通知里带上变更的 key 列表,SDK 只拉取变更的 key,不再拉全量。
- 推送端做限流:同一个 key 每 10 秒最多推送一次,变更太频繁就合并。
- 物理隔离:大 key 和核心配置拆到不同配置空间,避免互相干扰。
差量推送之后,配置中心压力又降了一个数量级。我的体会是:配置客户端绝不能假设配置都是小 key,必须从协议层面就限制异常体量的数据进入。
4. 服务注册与发现:状态机和本地缓存才是灵魂
注册中心模块的核心不只是一套 API,而是一套实例状态管理机制。状态没管好,就会出现"还没 ready 就被打流量""已经下线了还在被调用"这类事故。
4.1 Ready 之后才注册,别在 init 里抢跑
第一版注册逻辑放在 SDK 初始化时,服务启动后立刻注册。结果踩了个经典坑:服务进程起来了,但依赖的数据库连接池还没就绪,注册中心上已经有实例了,负载均衡开始往里打流量,请求直接报连接错误。
后面引入了实例状态机,注册必须等实例 Ready:
INIT → READY → REGISTERED → DOWN → UNREGISTEREDSDK 对外暴露MarkReady()方法,业务在依赖全部初始化完成之后再调用。SDK 内部只有状态从 READY 变成 REGISTERED 之后,才开始上报心跳、接受流量。注册前健康检查失败也会停留在 READY,不会进入注册流程。
// registry/instance.go type State int32 const ( StateInit State = iota StateReady StateRegistered StateDown StateUnregistered ) func (m *Manager) MarkReady() error { if err := m.checkHealth(); err != nil { return fmt.Errorf("health check failed: %w", err) } m.setState(StateReady) return m.doRegister() }这个状态机看起来简单,但它是后面所有容错逻辑的地基。有了状态机,才能回答"现在这个实例到底处于什么阶段"。
4.2 优雅下线:SIGTERM 之后的那十秒
服务下线时,最典型的事故是:容器收到 SIGTERM 直接退出,负载均衡器还没来得及摘除实例,流量还是涌进来,结果一批请求直接连接拒绝。这不是注册中心的问题,是实例下线流程没做对。
harness-sdk 的优雅下线流程是:
- 业务收到 SIGTERM,SDK 先给优雅下线钩子发信号。
- SDK 立即向注册中心发起反注册(unregister),并把实例状态置为
Down。 - 反注册成功之后,SDK 等待一段时间(默认 10 秒,可配置),让负载均衡感知到实例已摘除。
- 等待期间继续处理存量请求,不监听新请求。
- 等待时间结束后,告知业务进程可以退出;如果业务在配置时间内没退,再走强杀。
这里最容易被忽略的一点是:反注册动作必须在进程退出之前完成,而且必须显式等待反注册结果。不能"发出 unregister 请求就立刻退出",因为 TCP 包可能还没到注册中心进程就没了。
4.3 本地缓存与快照隔离,注册中心挂了也不慌
服务发现的实现上,harness-sdk 采用"本地缓存 + 服务端推送"的方式。SDK 启动时拉一次全量服务列表,之后靠服务端推送增量变化,本地维护一份只读快照。
// registry/snapshot.go type instanceSnapshot struct { services map[string][]*Instance version int64 updated time.Time } type Manager struct { cache atomic.Value // 存 *instanceSnapshot }业务在服务发现时的调用路径是:
instances := registry.GetService("order-service") // 返回快照副本这里有个重要细节:GetService返回的必须是一个副本,不能是内部 slice 的直接引用。否则调用方可能修改 slice,污染整个缓存视图。我们直接用slices.Clone拷贝一份再返回,虽然牺牲一点性能,但换来了安全性。
注册中心故障时,SDK 直接读本地缓存,并且记录一条降级日志。在后续故障演练里,注册中心挂掉三分钟,服务之间调用完全没受影响,靠的就是这份缓存。服务发现客户端一定要把"本地缓存可用性"放在比"数据实时性"更高的优先级上。缓存数据旧一点问题不大,缓存不可用才是大问题。
4.4 注册中心故障时 SDK 的降级表现
有一次做混沌测试,直接把注册中心所有节点都停了。我们观察各组服务的表现:
- 所有 SDK 已发现的服务实例继续可用,服务间调用零影响。
- 新扩容的实例注册失败,SDK 进入重试退避,退避间隔从 1 秒逐步增加到 30 秒。
- 已有的负载均衡策略继续工作,新流量按本地缓存分发。
最危险的情况是"缓存过期时间设得太短,故障期间缓存被清空"。我们后来把缓存过期策略改成分级清理:本地缓存默认 30 分钟过期,但故障模式下直接禁用过期清理,只有注册中心恢复后才重新同步。这个"故障模式禁用清理"的逻辑看起来简单,真碰到问题能救命。
5. 可观测性一次性打通:日志、指标、链路追踪
可观测性这三件事,如果每个团队自己各搞一套,跟没搞没区别。harness-sdk 做的是把三件事全部统一到 SDK 内部,业务侧接入成本降到最低。
5.1 traceID 的生成、透传与自动注入
链路追踪最关键的一环是 traceID 的透传。HTTP 入口处 SDK 自动生成 traceID,放进 context,然后所有日志自动带这个字段。服务间调用时,HTTP header 自动带上x-trace-id,对端服务从 header 恢复 traceID,继续往下传。
// tracing/http.go func Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { traceID := r.Header.Get("x-trace-id") if traceID == "" { traceID = newTraceID() } ctx := context.WithValue(r.Context(), traceIDKey{}, traceID) w.Header().Set("x-trace-id", traceID) next.ServeHTTP(w, r.WithContext(ctx)) }) }这个逻辑看起来简单,但迁移成本很低,业务甚至不需要知道 traceID 是怎么传的。只要 HTTP 客户端用的是 SDK 提供的封装,header 就自动带上。MQ 场景也类似:消息发送时自动把 traceID 塞进消息属性,消费端自动恢复。
我强烈建议 SDK 里把"透传协议头"固定成一个常量放在文档最显眼的位置:跨语言排查时,Java 服务的 header 和 Go 服务的 header 必须完全一致。我们当时跟 Java 团队对齐的就是x-trace-id这个字段名,用了整整两年没人提出过异议。
5.2 SDK 自身的指标:注册延迟、推送延迟、熔断次数
很多人做可观测性只做业务指标,SDK 自身的运行健康却一片黑盒。其实 SDK 自身的指标往往更能提前暴露问题。harness-sdk 内置了一组自监测指标,统一上报到 metric 模块:
| 指标名 | 含义 | 告警阈值建议 |
|---|---|---|
| sdk_registry_register_latency_ms | 注册耗时 | P99 > 500ms |
| sdk_config_push_latency_ms | 配置推送处理耗时 | P99 > 300ms |
| sdk_breaker_trigger_total | 熔断触发次数 | 突增告警 |
| sdk_heartbeat_lost_total | 心跳丢失次数 | 连续 3 次以上 |
| sdk_degraded_total | 降级模式次数 | 任何一次都告警 |
这些指标是 harness-sdk 排查线上问题时最依赖的数据。比如配置推送延迟突然升高,大概率是某个大 key 或者监听器回调里加了耗时操作;熔断触发次数突增,基本可以断定下游某个服务正在出问题。SDK 自身必须被监控,否则它只是另一个不可见的故障源。
5.3 统一日志格式,给排查省一晚上时间
日志格式不统一是跨语言排查的最大阻碍。Java 用[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level],Go 用log.Printf输出,Python 又是另一种格式,想用 logstash 统一解析都费劲。
harness-sdk 做了统一封装,输出格式固定为:
2025-01-15 10:30:45.123 INFO [trace_id=abc123] [service=order] [instance=10.0.1.5] message关键字段只有 5 个:时间戳、级别、traceID、服务名、实例 IP。日志采集端按这个格式解析,直接入库。业务团队需要记结构化字段时,可以通过log.WithField("key", "value")追加,中间用空格分隔,保证日志采集端总能解析出核心字段。统一格式这件事,前期看起来不过是"定个格式嘛",后期排查跨服务问题时省下的时间远超投入。
6. 版本兼容与灰度发布:SDK 升级是全局高风险操作
业务代码发版炸了影响的是一个服务,SDK 发布有 bug 影响的是所有接入服务。我们对 SDK 发布的谨慎程度,比线上核心服务还要高一个等级。
6.1 向后兼容的红线与动态开关机制
harness-sdk 的版本号严格遵循语义化版本规范,并且立了几条红线:
- 新增 API 不允许破坏既有签名。
- 默认行为不允许改变。如果某个新功能会改变既有行为,必须做成开关,默认关闭。
- 内部实现可以重构,但外部约定(协议字段名、header 名、回调参数)冻结不动。
比如 v0.5 想把默认回调模式从串行改成并行,这在语义化版本里属于违反默认行为红线的事,我们最终是通过新增SubscribeParallel方法解决,而不是改原方法的行为。
灰度发布靠的是"SDK 内置动态开关 + 服务端下发配置"这套机制。SDK 在启动时从配置中心拉一份自身开关配置,比如:
{ "version": "20250115001", "features": { "parallel_listener": { "enabled": true, "enabled_instances": ["order-service:10.0.1.5"], "enabled_percent": 5 } } }新功能先在指定实例上开启,观察指标正常后再逐步放量。这套机制几乎零成本,因为 SDK 本身就带配置中心客户端,等于复用了一套下发链路。
6.2 一次 SDK 升级引发的内存上涨事故
这是 harness-sdk 上线以来最严重的一次事故。v0.4 升级到 v0.5 时,我们新增了一个缓冲池,用于复用发送心跳时的 buffer。结果发布后第二天,部分服务的内存曲线开始抬头,第三天有服务 OOM 重启。
通过 pprof 排查,问题定位到缓冲池:我们按照固定大小初始化了一个池子,本意是减少 GC,但所有服务实例都会创建这个池子,并且池内对象长期存活,导致老年代内存持续上涨。修复方式很简单:改为按需创建、用后即弃,不再做全局池化。这个事故让我深刻认识到:SDK 内的任何"全局共享、长期存活"的数据结构,都要慎之又慎,它不像业务代码只影响一个实例,SDK 的资源开销会被所有接入服务放大成一个很大的系数。
改造后的发布流程变成了这样:
- 先在测试环境跑完整集成测试。
- 挑选 1-2 个非核心服务灰度,观察 48 小时内存、CPU、错误率。
- 放量到 5% 实例,再观察 24 小时。
- 全量发布,并在发布后 1 小时内保持最高关注。
SDK 发布不出事则已,一出事就是全局事故,所以这个流程我建议所有维护内部 SDK 的团队都严格执行。
7. 测试与文档:SDK 项目最容易欠下的两笔技术债
写 SDK 最爽的阶段是设计 API 和写核心逻辑,最痛苦的是写测试和文档。但这恰恰是 SDK 项目能不能长期维护的关键。可以说,测试和文档的完善程度,直接决定了这个 SDK 是"内部工具"还是"合格的基础设施"。
7.1 单元测试要把外部依赖全部 mock 掉
SDK 的单元测试必须快、必须稳定,不能依赖外部中间件真实实例。harness-sdk 的做法是:每个模块定义接口,测试时注入 mock 实现。
比如注册中心客户端,定义一个接口:
type RegistryAPI interface { Register(ctx context.Context, req *RegisterRequest) error Deregister(ctx context.Context, req *DeregisterRequest) error Heartbeat(ctx context.Context, req *HeartbeatRequest) error Watch(ctx context.Context, cb func(*WatchEvent)) error }测试时用一个 fakeRegistry,可以模拟各种异常场景:网络超时、服务端 500、推送乱序。这样核心逻辑(状态机转换、缓存更新、降级处理)在几分钟内就能跑完一遍,不依赖任何外部组件。SDK 的单元测试如果依赖真实配置中心,跑一次要等网络请求,开发效率会低到让人不想跑。
7.2 集成测试用 Testcontainers 拉起真实中间件
单元测试保证了逻辑正确,但"协议兼容"这种问题单元测试测不出来。比如真实的 etcd 可能对 key 大小有限制、对长轮询有超时限制,mock 根本模拟不了这些边缘情况。
我们的集成测试用 Testcontainers 在 CI 里拉起真实的 etcd 和 Consul,跑一遍完整的注册、发现、推送链路。第一次跑通的时候,就发现了一个 mock 测不出来的问题:etcd 对同一个 key 的 watch 有并发连接数限制,多个服务实例共享同一个 key 时,连接数会超过限制导致 watch 被断开。如果没有真实组件的集成测试,这个问题会直接带到生产环境。
集成测试跑得慢,所以分了两层:核心链路测试每次 CI 都跑,全量兼容性测试只在发布前跑。兼容性测试会特别跑三种组合:旧版 SDK 代码 + 新版中间件、新版 SDK + 旧版中间件、多语言客户端互通,确保升级不会把老用户坑了。
7.3 文档先写例子,再讲原理
SDK 文档跟业务文档不一样。业务文档讲"怎么操作",SDK 文档重点讲"怎么接入、怎么配置、什么情况下需要用哪个方法"。
harness-sdk 的文档策略是:先写 examples,再写原理说明,最后写 API 参考。每个核心功能必须在examples/目录下有可以直接运行的示例代码,比如:
examples/01-quickstart/main.go:最小接入示例。examples/02-hot-config/main.go:配置监听示例。examples/03-graceful-shutdown/main.go:优雅下线示例。examples/04-multi-language/:Java、Python 客户端示例。
很多 SDK 项目觉得文档就是 README 加 godoc,但真正让用户少走弯路的是"能跑通的例子"。我们内部有一条不成文的规定:每次新增功能,PR 里必须附带一个可运行的示例代码,否则不合并。这条规定强制保障了文档的可操作性。
最后的经验总结
harness-sdk 做下来,我最深的体会是:SDK 不是代码库,是一个组织共识的代码化表达。它的难度不在写代码,而在让所有人都愿意用它、并且不敢绕过它。想让团队接受你定的治理规范,靠的不是开会宣贯,而是把规范变成 SDK 里不可绕过的代码约束。
如果让我重新做一遍,我会把契约测试和兼容性测试的优先级提到功能开发前面。SDK 每多一个外部依赖,就多一层兼容性风险;每一行对外 API,都意味着长期的维护承诺。
另外分享一个很实用的小技巧:每次给 SDK 加新功能前,先在examples里写一段使用示例。如果示例代码自己都写得不顺,说明 API 设计有问题,趁早调整;等用户开始用上了再改,代价就大了。这个习惯帮我避掉了很多次糟糕的 API 设计,也让我对"SDK 易用性"的理解更深了一层。