一个可对外交付的 Java SDK,核心不在于能调通接口,而在于六个设计决策是否到位:配置驱动、统一数据模型、鉴权收敛、HTTP 客户端选型、异常分层、高可用治理。我在企业级 AI 网关的 SDK 开发中把这六块逐一定型,调用方拿到的是”配置即用”的体验——填一个网关地址和密钥,就能以方法调用的方式发起对话并接收 SSE 流,鉴权、重试、超时全部由 SDK 内部消化。
一、SDK 的定位与设计原则
SDK 是网关能力的语言侧封装,不是业务逻辑的搬运工。它只做四件事:组装请求、签名鉴权、传输、把响应翻译成类型化结果。原则有三条:配置驱动,密钥与端点不写死在代码里;协议集中,鉴权、错误码映射只实现一次;调用方零负担,不暴露内部线程与连接细节。
这条边界决定了很多取舍。把日志、限流、重试这类横切逻辑都塞进 SDK 是常见失控点——SDK 只负责一次调用的稳定性,全局治理交给网关层。同时 SDK 要区分”库”与”服务”:它只是一个被引入的 jar,不能假设自己运行在容器里,所以不持有线程池状态、不依赖 Spring 上下文之外的运行时环境,纯配置驱动,任何 Spring Boot 应用都能直接装配。
二、配置驱动
所有可变项收敛到一个配置类,用 Spring Boot 的绑定能力自动填充:
@ConfigurationProperties(prefix = "ai.gateway")
public class AiGatewayProperties {
private String baseUrl;
private String apiKey;
private Duration connectTimeout = Duration.ofSeconds(5);
private Duration readTimeout = Duration.ofSeconds(60);
private int maxRetries = 2;
// getter / setter 省略
}
配置项集中在 application.yml 里,调用方不用翻源码就能调整超时与重试。用配置类而不是到处写 @Value,还有个好处:SDK 提供默认值,调用方不配也能跑通默认链路,配了则精确覆盖,边界行为明确。
ai:
gateway:
base-url: https://ai-gateway.example.com
api-key: ${AI_GATEWAY_KEY}
read-timeout: 90s
max-retries: 2
三、统一数据模型
请求与响应用 Java 17 的 record 定义,字段即契约。统一响应包裹结果、错误码与耗时,错误码透传网关侧定义,方便调用方对齐语义。
public record ChatRequest(String model, String prompt, boolean stream) {}
public record ChatResponse(String content, String requestId) {}
public record ApiResult<T>(int code, String message, T data) {}
模型名这类枚举值做成常量类暴露给调用方,避免字符串散落各处写错拼写。新增能力时先加模型类与端点常量,再补业务方法,契约演进受控。
四、鉴权收敛进拦截器
签名逻辑不能散落在每个业务方法里。网关侧采用 API Key + 时间戳签名,SDK 用 RestClient 拦截器统一注入请求头,业务方法只关心参数。
public class AuthInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String ts = String.valueOf(System.currentTimeMillis());
String sign = HmacSHA256.sign(ts + request.getURI().getPath(), apiKey);
request.getHeaders().set("X-Api-Key", apiKey);
request.getHeaders().set("X-Timestamp", ts);
request.getHeaders().set("X-Sign", sign);
return execution.execute(request, body);
}
}
密钥只出现在配置与拦截器两处,审计和轮换都方便。
五、HTTP 客户端选型与流式调用
5.1 三种客户端怎么选
| 客户端 | 声明方式 | 适合场景 | 流式支持 |
|---|---|---|---|
| RestTemplate | 过程式 | 遗留代码 | 弱 |
| OpenFeign | 接口注解 | 契约稳定的服务间调用 | 一般 |
| RestClient | 流式 Builder | 新代码、策略多变的外部调用 | 原生支持 |
SDK 面向外部网关、端点数量有限、鉴权与错误处理逐调用略有差异,选 RestClient 最合适,它是 Spring 官方推荐的新客户端,支持拦截器与按状态码处理错误。
5.2 构建客户端并支持 SSE
RestClient client = RestClient.builder()
.baseUrl(props.getBaseUrl())
.requestInterceptor(new AuthInterceptor(props))
.requestFactory(JdkClientHttpRequestFactory.builder()
.readTimeout(props.getReadTimeout())
.build())
.build();
流式对话返回 Flux,SDK 对调用方隐藏协议细节:
public Flux<String> streamChat(ChatRequest request) {
return client.post()
.uri("/v1/chat/completions")
.body(request)
.retrieve()
.body(Flux<String>.class)
.doOnError(e -> log.warn("stream error: {}", e.getMessage()));
}
SSE 流需要单独的路由与读取策略:连接建立后响应体不断返回,RestClient 基于响应式流按块读取,readTimeout 按首字节到达时间计算,而不是整体响应时间,否则长文本生成必然误报超时。
六、异常分层与高可用治理
异常体系要能区分错误来源,调用方才能对症处理。SDK 至少拆四类:网络与超时异常、鉴权异常、限流异常(对应 429)、网关业务异常(透传错误码)。全部继承同一个根异常,用 since 语义捕获一类即可。
治理在 SDK 内用 Resilience4j 落地,核心四条:重试只针对网络抖动类错误,配上退避,且请求带幂等键防止重复消费;熔断器在网关连续失败时快速失败,不再把请求打进去;并发信号量隔离单个模型的慢请求;超时用 readTimeout 兜底,避免调用方线程被耗尽。这四件套装完,SDK 在厂商侧抖动时依然稳得住。
异常翻译放在统一出口:客户端拿到的是结构化错误码而不是裸堆栈,429 翻译成限流异常并带出重试时间,调用方按类型捕获,不用解析响应体猜原因。日志只记录入参与结果摘要,密钥与完整响应体一律不进日志。
七、交付前的完整检查清单
- 本地起一个 Mock 网关,覆盖成功、鉴权失败、429、超时四类响应,验证错误翻译正确;
- 写一个最小可运行示例,从添加依赖到完成一次流式对话,全程不超 20 行调用代码;
- 检查配置项是否全部有默认值,密钥是否只读环境变量;
- 用 RestClientTest 配 MockRestServiceServer 固化请求契约,防止后续改动破坏格式;
- 发布后跑一遍与网关实测的联调用例,确认真实签名与响应结构一致。
版本管理上,SDK 对外承诺兼容性:接口签名变动走新版本号,小版本只做内部实现替换。release 包附一份变更记录,写明每个版本改了什么,调用方升级时能快速评估影响面。这步看似琐碎,却是 SDK 长期被信任的前提。
常见问题(FAQ)
Q1:SDK 为什么不用 RestTemplate?
RestTemplate 流式弱、语法旧;RestClient 支持拦截器与状态码处理,官方推荐。
Q2:鉴权信息应该怎么保存?
密钥走环境变量或配置中心,SDK 内部只读,不落盘、不进日志。
Q3:重试会不会造成重复扣费?
只对网络类错误重试,业务类错误不重试;请求带幂等键,网关侧去重。