1. 先搞清楚:SDK库和业务代码里的“工具类集合”不是一回事
我最近在翻公司内部一个名叫“java_sdk_library”的示例项目时,想到一个很常见的现象:很多人把“封装一个SDK”理解成“写几个public类,把HTTP请求包一包,扔给调用方”。最后做出来的东西,说得好听叫SDK,说得直白点,就是一个散装的工具类大杂烩——调用方拿到手以后,不仅要翻源码猜用法,还得自己处理各种边界问题,用起来比直接写HTTP调用还累。
那什么才算是一个真正合格的SDK库?我的判断标准很简单:调用方不需要知道你内部怎么实现,只需要通过一个稳定的入口、一套明确的参数、一份清晰的文档,就能完成他要做的事,并且在这个过程中几乎踩不到你埋的坑。这个要求听起来不高,但实际做到的人不多。
举一个很典型的例子。有些SDK会把业务逻辑也塞进来,比如“调用一个方法,顺便帮我把用户信息存库里”,这其实已经超出SDK的职责范围了。SDK库的核心价值应该是“能力的封装与复用”,而不是“业务逻辑的下沉”。当你把一个SDK交给另一个团队时,你要保证的是它能稳定地完成某个领域能力,比如HTTP调用、加解密、消息发送、文件上传,而不是替对方决定业务的流程和规则。
为了把“java_sdk_library”这个话题讲得足够具体,我下面用一个实际场景来拆解:做一个面向内部服务调用的HTTP客户端SDK,供多个业务方通过Maven依赖接入。这个场景很典型,几乎所有Java后端团队都会遇到,而且它能把SDK设计里绝大多数的关键问题都串起来。
先把我心目中“SDK”和“工具类集合”的差别列一张表,后面所有内容都围绕这张表展开:
| 对比维度 | 工具类集合 | 合格的SDK库 |
|---|---|---|
| 入口 | 多个静态方法散落各处 | 一个核心客户端类 + 明确构建方式 |
| 参数配置 | 调用方自己拼参数、自己管理配置 | 统一配置入口,支持默认值 |
| 错误处理 | 异常随意抛出,类型混乱 | 自定义异常体系,语义清晰 |
| 重试/超时 | 调用方自己写循环 | SDK内置,且可配置 |
| 可观测性 | 调用方自己打日志 | SDK内置日志、耗时统计、链路透传 |
| 版本管理 | 基本不区分 | 语义化版本,兼容性保障 |
| 文档 | 可能只有一个README | 使用示例 + 核心概念说明 + 变更日志 |
你对照一下自己写过的或者用过的“SDK”,如果大部分都落在左边,那说明它还不是一个合格的SDK库。下面我按这个标准,从API设计到发布维护,把整个过程中的关键决策和踩坑经验一条条展开。
2. 设计SDK第一步不是写代码,是先把“调用方想怎么写代码”定下来
我封装SDK的习惯和别人可能不太一样:先不碰IDE,拿张纸或者开个空白文档,写一段调用方视角的“理想代码”。这段代码描述的是“如果这个SDK好用,我希望调用方写出来是什么样子”。它决定了后面所有设计的方向。
2.1 从一段理想代码倒推所有API
还是以上面说的HTTP客户端SDK为例。我先写调用方最舒服的用法:
public class OrderService { // 全局一个客户端实例,线程安全,多次复用 private final DemoHttpClient client = DemoHttpClient.builder() .baseUrl("https://api.demo.com") .connectTimeout(Duration.ofSeconds(3)) .readTimeout(Duration.ofSeconds(10)) .maxRetries(2) .build(); public OrderInfo getOrder(String orderId) { // 构造请求参数 GetOrderRequest request = GetOrderRequest.builder() .orderId(orderId) .build(); // 执行调用,拿到统一的响应包装 ApiResponse<OrderInfo> response = client.execute(request); if (response.isSuccess()) { return response.getData(); } throw new BizException(response.getCode(), response.getMessage()); } }这段理想代码一出来,几个关键设计决策就自动浮现了:
- 客户端需要一个可配置的构建方式,所以要用Builder模式。
- 客户端实例需要线程安全,因为调用方大概率会把它做成单例或者Spring Bean。
- 请求对象应当是参数对象的形态,而不是一个“Map + 方法重载”的堆砌品。
- 返回结果要有统一的包装类型,不直接抛业务异常,把“接口调用成功但业务失败”和“接口调用本身失败”区分开来。
这就是“面向调用方设计”的核心——先定清楚调用方应该怎么写代码,再去定SDK内部怎么实现。很多SDK不好用,就是因为设计顺序反了:先写了内部实现,然后为了暴露能力随手加了几个public方法,结果各个方法的签名风格不统一,参数类型混乱,调用方用起来非常痛苦。
2.2 Parameter Object模式:请求参数不要散成一堆方法入参
我见过不少SDK把方法的入参设计得非常多,比如下面这种:
// 反例:参数一多,调用方根本记不住顺序 public GetOrderResponse getOrder(String appId, String appSecret, String orderId, Integer timeout, Boolean needRetry, String traceId) { }这种设计在参数超过三四个的时候就已经不好用了,调用方要么按顺序硬记,要么每个调用都去翻文档。更严重的是,一旦SDK后续要加参数,就只能再写一个重载方法,久而久之方法爆炸。
更好的做法是引入Parameter Object模式:把一组相关的参数封装成一个不可变的请求对象。结合Builder模式,让调用方可以链式构造,而且字段可以只填需要的部分,其他走默认值。
public class GetOrderRequest { private final String orderId; private final boolean forceRefresh; private GetOrderRequest(Builder builder) { this.orderId = builder.orderId; this.forceRefresh = builder.forceRefresh; } public static Builder builder() { return new Builder(); } public String getOrderId() { return orderId; } public boolean isForceRefresh() { return forceRefresh; } public static class Builder { private String orderId; private boolean forceRefresh = false; public Builder orderId(String orderId) { this.orderId = orderId; return this; } public Builder forceRefresh(boolean forceRefresh) { this.forceRefresh = forceRefresh; return this; } public GetOrderRequest build() { // 参数校验应当在build阶段完成,而不是等到发送HTTP请求时才报错 if (orderId == null || orderId.trim().isEmpty()) { throw new IllegalArgumentException("orderId must not be blank"); } return new GetOrderRequest(this); } } }注意Builder的build()方法里做了基础校验——这是刻意为之的。校验越早发生,调用方定位问题越容易。如果等HTTP请求发出去了才发现参数是空的,那就白白浪费一次网络往返,而且报错信息会非常晦涩。
2.3 统一响应包装:是SDK最容易设计错的地方
很多SDK在返回结果时喜欢直接返回内部的对象,比如某个DTO,成功的时候有数据,失败的时候抛一个RuntimeException。这种做法有两个问题:第一,调用方无法判断“业务失败”到底算不算异常;第二,异常类型不合理的时候,调用方的兜底逻辑很难写。
我的经验是引入一个统一的响应包装类型,比如ApiResponse<T>:
public class ApiResponse<T> { private final boolean success; private final String code; private final String message; private final T data; private ApiResponse(boolean success, String code, String message, T data) { this.success = success; this.code = code; this.message = message; this.data = data; } public static <T> ApiResponse<T> success(T data) { return new ApiResponse<>(true, "00", "success", data); } public static <T> ApiResponse<T> failure(String code, String message) { return new ApiResponse<>(false, code, message, null); } public boolean isSuccess() { return success; } public String getCode() { return code; } public String getMessage() { return message; } public T getData() { return data; } }这里要专门说一个我在实际项目里反复调试出来的经验:业务层的失败(比如订单不存在、余额不足)和基础设施层的失败(比如连接超时、DNS解析失败)应该是两套东西。前者适合用ApiResponse表达,让调用方根据具体的code做分支;后者适合通过异常体系表达,因为网络都断了的时候,调用方大概率要做的是重试或者降级,而不是关心业务code。
所以我的SDK结构里会有两类“失败”:
- 业务失败:HTTP 200,但响应体里带了错误码。这种情况封装成
ApiResponse的failure状态,不抛异常。 - 基础设施失败:连接超时、读取超时、IO异常、线程池拒绝等。这种情况封装成SDK自己的异常,比如
SdkClientException。
这样划分逻辑清晰,调用方不需要写“catch Exception”这种大锅饭代码。你可以在catch里单独处理SdkClientException,而业务失败走正常分支判断。
3. 定义一个真实示例:从零封装一个可复用的HTTP客户端SDK
理论讲了半天,下面进入正题。我用一个完整的示例来演示java_sdk_library到底应该如何设计和编码。这个示例我给它起名叫demo-sdk,功能非常聚焦:封装HTTP客户端能力,对外提供统一的调用入口。
3.1 项目骨架与核心模块划分
首先看目录结构。一个SDK库的工程结构应该保持“小而清晰”,不要上来就拆好几个Maven模块。对于大多数场景,单模块就够了:
demo-sdk/ ├── pom.xml └── src/ ├── main/ │ └── java/ │ └── com/example/demo/ │ ├── DemoHttpClient.java // 核心客户端类 │ ├── DemoHttpClientBuilder.java // Builder(也可以直接写内部类) │ ├── DemoHttpClientConfig.java // 配置属性类 │ ├── request/ // 请求参数对象 │ │ ├── GetOrderRequest.java │ │ └── CreateOrderRequest.java │ ├── response/ // 统一响应对象 │ │ ├── ApiResponse.java │ │ └── OrderInfo.java │ ├── exception/ // SDK异常体系 │ │ ├── SdkClientException.java │ │ └── SdkConfigException.java │ └── internal/ // 内部实现,不对外暴露 │ ├── HttpInvoker.java │ └── RetryHandler.java └── test/ └── java/ └── com/example/demo/ ├── DemoHttpClientTest.java └── ApiResponseTest.java这里有一个容易犯的错误是把internal包下的类设成public。我见过很多SDK,本来不想暴露内部实现,结果为了“方便测试”或者“以后可能复用”,把内部代码全设成public了。这样做的后果是:调用方看API文档时,引入了一大堆他根本不需要关心的类,而且你以后想改内部实现,还得考虑“破坏兼容性”的风险。正确做法是internal包下的类保持包级私有或者用final类,只把必要的方法暴露出去。
3.2 核心客户端类实现
下面是核心客户端类的关键实现。我以JDK 11自带的java.net.http.HttpClient为例,这样可以避免引入额外的HTTP依赖,让示例SDK保持依赖最简。
public class DemoHttpClient { private final HttpClient httpClient; private final DemoHttpClientConfig config; // 构造方法包级私有,外部只能通过 builder 创建 DemoHttpClient(HttpClient httpClient, DemoHttpClientConfig config) { this.httpClient = httpClient; this.config = config; } public static DemoHttpClientBuilder builder() { return new DemoHttpClientBuilder(); } /** * 执行一个请求参数对象,返回统一的响应包装 */ public <T> ApiResponse<T> execute(BaseRequest request, TypeReference<T> responseType) { // 1. 构造 HttpRequest HttpRequest httpRequest = RequestConverter.convert(request, config); // 2. 发送请求(带重试) HttpResponse<String> httpResponse = RetryHandler.executeWithRetry( () -> sendRequest(httpRequest), config.getMaxRetries()); // 3. 解析响应 return ResponseParser.parse(httpResponse.body(), responseType); } private HttpResponse<String> sendRequest(HttpRequest httpRequest) throws Exception { return httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); } }这里有几个设计点值得细说。
第一个是泛型+TypeReference。直接返回ApiResponse<T>看似简单,但Java泛型有个经典坑:运行时类型擦除。如果你直接写ApiResponse<OrderInfo>,在运行时其实只有ApiResponse,反序列化的时候根本没有OrderInfo的类型信息,JSON库不知道怎么把body里的JSON转换成OrderInfo对象。所以需要借助TypeReference<T>来捕获带泛型的类型。这个不是SDK独有的问题,任何封装JSON反序列化的库都会遇到,但SDK设计者必须先想清楚,否则写完之后一调用就发现反序列化出来的永远是LinkedHashMap。
第二个是重试的使用边界。不是所有请求都适合无脑重试。GET请求重试是安全的,但POST/PUT这类写操作,重试可能带来重复提交的问题。所以重试应该是可配置的,并且最好能在请求参数对象上单独指定是否允许重试。我在上面的GetOrderRequest里加了forceRefresh字段,那个字段本身和重试无关,但我在实际项目中通常还会加一个boolean retryable字段来标记一个特定请求是否允许重试。这种做法虽然增加了一点复杂度,但能避免调用方因为SDK自动重试而踩到幂等问题的坑。
3.3 Builder模式的正确打开方式
Builder模式的实现方式五花八门,但用在SDK的客户端入口上,有几个最佳实践值得遵守:
第一,Builder的build()方法里要执行完整且友好的校验。比如baseUrl必须是合法的HTTP/HTTPS地址,connectTimeout不能为负数等。校验不通过时,抛出专门的SdkConfigException,而不是原封不动抛IllegalArgumentException——因为SDK的调用方可能是另一个团队,他看到一个奇怪的IllegalArgumentException时,很难快速定位到是SDK使用姿势问题。
public class DemoHttpClientBuilder { private String baseUrl; private Duration connectTimeout = Duration.ofSeconds(3); private Duration readTimeout = Duration.ofSeconds(10); private int maxRetries = 0; public DemoHttpClientBuilder baseUrl(String baseUrl) { this.baseUrl = baseUrl; return this; } public DemoHttpClientBuilder connectTimeout(Duration connectTimeout) { this.connectTimeout = connectTimeout; return this; } public DemoHttpClientBuilder readTimeout(Duration readTimeout) { this.readTimeout = readTimeout; return this; } public DemoHttpClientBuilder maxRetries(int maxRetries) { this.maxRetries = maxRetries; return this; } public DemoHttpClient build() { if (baseUrl == null || baseUrl.trim().isEmpty()) { throw new SdkConfigException("baseUrl must not be blank"); } if (connectTimeout.isNegative() || readTimeout.isNegative()) { throw new SdkConfigException("timeout must not be negative"); } if (maxRetries < 0 || maxRetries > 5) { throw new SdkConfigException("maxRetries must be in [0, 5]"); } // 这里可以做一些更深入的验证,比如URL格式 try { URI.create(baseUrl); } catch (Exception e) { throw new SdkConfigException("baseUrl is invalid: " + baseUrl, e); } // 真正的HttpClient可以在这里创建,并且复用 HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(connectTimeout) .followRedirects(HttpClient.Redirect.NORMAL) .build(); return new DemoHttpClient(httpClient, new DemoHttpClientConfig(...)); } }第二,默认值要“够用但不激进”。比如超时时间,默认3秒连接超时、10秒读取超时,对于大多数内部服务调用来说是合理的;maxRetries默认0(不做自动重试)是更安全的选择,因为写操作可能重复执行。调用方如果明确知道某个场景是安全的,再去设置重试次数。这种“默认保守、按需放开”的策略,能减少SDK在无意间引入的副作用。
第三,Builder本身要避免线程安全问题。构造阶段通常发生在应用启动的时候,一般不会并发,但为了防御性,也可以在Builder里加一些同步或者标记已构建状态。不过我的建议是不要过度设计——Builder的生命周期很短,调用方每次build完就丢掉,不太需要处理并发构建同一个Builder的情况。
4. 内部实现的隐藏门道:从“能调通”到“抗造”
SDK不是写一两个类,把HTTP请求发出去就完事了。真正考验SDK质量的,是内部实现在边界情况、异常场景、并发场景下的表现。下面我挑几个实际项目中踩过坑、后来才补上的点来讲。
4.1 异常体系设计:不要只抛一个Exception
先看一个我在评估SDK时一定会看的点:它的exception包下面有几个类。如果只有一个DemoException,通常意味着设计者没有认真思考异常场景。一个成熟的SDK,异常至少要分成两类:
| 异常类型 | 触发场景 | 调用方处理方式 |
|---|---|---|
SdkConfigException | 配置非法、构建参数错误 | 启动时就能发现,直接改配置 |
SdkClientException | 连接失败、超时、IO错误、线程中断 | 重试、降级、告警 |
| 业务异常(非SDK抛出) | 接口返回业务错误码 | 由调用方拿到ApiResponse后自行判断 |
有一点要强调:SDK内部不要把第三方库的异常直接抛给调用方。比如你用的是java.net.http.HttpClient,它抛出的ConnectException、HttpTimeoutException,调用方不一定会认识。SDK需要把这些底层异常捕获并转换为自身异常体系,同时保留原始异常作为cause,这样调用方既能针对SDK异常做统一处理,又能通过getCause()拿到完整的异常链进行排查。
4.2 可观测性:日志、耗时、链路透传缺一不可
SDK一旦被多个团队使用,你就几乎没有机会在调用方现场联调了。这个时候可观测性是救命稻草。我在示例SDK里加了三条基础的可观测能力:
第一,结构化日志。每一个请求发出前和响应返回后,都应该打一条日志,包含:请求方法、路径、耗时、响应码、traceId。而不是只在失败的时候打error日志——成功的慢请求同样是性能问题的重要线索。
long start = System.currentTimeMillis(); try { HttpResponse<String> resp = sendRequest(httpRequest); long cost = System.currentTimeMillis() - start; log.info("demo-sdk request completed, method={}, url={}, status={}, costMs={}, traceId={}", httpRequest.method(), httpRequest.uri(), resp.statusCode(), cost, traceId); return resp; } catch (Exception e) { long cost = System.currentTimeMillis() - start; log.warn("demo-sdk request failed, method={}, url={}, costMs={}, traceId={}, err={}", httpRequest.method(), httpRequest.uri(), cost, traceId, e.toString()); throw new SdkClientException("Request failed", e); }第二,耗时指标暴露。如果你的团队已经有Prometheus这类监控体系,SDK最好能暴露histogram类型的指标,比如demo_sdk_request_cost_seconds。很多团队会忽略这一步,但真正把SDK推广出去之后,你会发现“能不能看到上游服务调用耗时”直接决定了问题排查的效率。
第三,链路透传。现代微服务架构里,traceId一般是通过HTTP Header传递的。SDK在构造请求时,如果检测到当前线程有traceId(比如从ThreadLocal或Context里拿到),应该自动把它加到请求头里。这样调用方的全链路追踪才能贯穿上下游服务,而不是在SDK这一环断掉。
4.3 线程安全:客户端实例到底能不能做成单例
这是SDK设计里被问得最多的问题之一。答案是:如果你的SDK是无状态的,或者状态都是通过不可变配置注入的,那么客户端实例应该设计成线程安全的,支持单例复用。这是因为java.net.http.HttpClient本身是线程安全的,连接池也是内部的,单例复用能避免每次调用都新建连接池,极大地减少资源浪费。
但这里有一个隐藏的坑:如果你在SDK内部用了ThreadLocal来传递traceId或者某些上下文,就要小心了——ThreadLocal在异步场景下会“穿透”失败。比如调用方在A线程发起调用,但SDK内部用了异步执行,那在B线程里ThreadLocal是读不到值的。这也是很多SDK在日志里traceId丢失的原因。
我的建议是:能用方法参数传递的上下文,就不要用ThreadLocal。比如traceId可以通过请求参数对象显式传入。如果确实觉得调用方传参太麻烦,可以提供一个RequestContext,但必须明确说明使用边界,并且在异步处理时手动传递。
5. public API不是写出来就完事:可测试性与文档都得跟上
我见过不少SDK,代码写得不错,但交付出去之后,消费端的同事第一句话往往是:“请问这个方法怎么用?”然后SDK的作者只好在群里不停答疑。这不是代码的锅,而是交付物不完整。一个完整的SDK,除了代码本身,至少还需要三样东西:可运行的测试、清晰的示例、维护中的文档。
5.1 用MockWebServer做真实的HTTP测试
测试SDK有一个好工具:MockWebServer(来自OkHttp库,虽然SDK本身不依赖OkHttp,但测试阶段用它建本地Mock服务很方便)。它可以在本地启动一个假的HTTP服务,让你验证SDK的各种行为:正常响应、超时、错误码、重试次数等。
@Test void testExecute_success() { MockWebServer server = new MockWebServer(); server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") .setBody("{\"success\":true, \"code\":\"00\", \"data\":{\"orderId\":\"123\"}}")); DemoHttpClient client = DemoHttpClient.builder() .baseUrl(server.url("/").toString()) .build(); ApiResponse<OrderInfo> response = client.execute(GetOrderRequest.builder() .orderId("123") .build(), new TypeReference<OrderInfo>() {}); assertTrue(response.isSuccess()); assertEquals("123", response.getData().getOrderId()); server.shutdown(); }这种测试的意义不只是“证明代码能跑”,更是给调用方提供的“活的文档”。很多团队接手SDK后,第一件事就是看测试用例,因为测试用例里展示了各种场景下的预期行为,比看API文档更快。
一个真实的踩坑经验:MockWebServer会把所有request都记录下来,你可以用它来断言SDK是否发了正确的请求头、参数,甚至重试次数。我有一次排查一个“调用方环境偶发超时”的问题,就是在测试里模拟了SocketPolicy.NO_RESPONSE策略(即服务器不返回任何数据),再用request count断言重试确实发生了,最终定位到是重试行为在某些异常类型下没有生效。没有MockWebServer,这种问题只能靠线上日志慢慢找。
5.2 好的示例代码要分场景给
SDK的示例代码不能只写一个“最理想”的调用方式。我在实际交付中,会把示例分成几个层级:
- 快速开始:3行代码能跑起来的最简示例,让调用方先有一个整体感知。
- 最佳实践:如何初始化客户端、如何复用实例、如何配置超时和重试。
- 进阶用法:如何处理分页、如何异步调用、如何自定义请求头。
- 异常处理:各种异常场景下如何区分业务失败和基础设施失败。
这里要特别强调“快速开始”的重要性。如果一个SDK的README第一屏不是“3行代码跑起来”,而是大段大段的架构说明和配置表格,调用方的好感度会直线下降。人都是懒的,你得先让他在2分钟内看到一个结果,他才愿意接着看文档。
5.3 文档也要“版本化”
文档里一个经常被忽视的细节是:示例代码里写的方法,是不是当前版本可用的?我见过不少SDK的README还是上一版本的方法签名,调用方复制过去根本不能编译。所以文档要跟着版本走,特别是每次Release的时候,把示例代码和当前发布的jar一起打包构建一次,确保示例是能编译、能跑的。
另外我建议SDK的每个版本都配一份变更日志(CHANGELOG),分成三类:Added(新增功能)、Changed(行为变化)、Fixed(修复问题)。变更日志的价值不只是给调用方看,也是给你自己看——半年之后回头看,你能清楚知道哪些API是被哪个版本改掉的,省去翻Git历史的时间。
6. 依赖管理才是最容易被忽略的坑:别把SDK做成“依赖炸弹”
很多SDK作者在写代码时,只关注功能实现,却忽略了依赖管理——这会在SDK被其他项目引入时引发大量兼容性问题。下面这几个点,是我在维护SDK过程中踩过最深、最痛的坑。
6.1 optional和provided:依赖可见性不是小事
假设你的SDK内部用了Apache HttpClient来发HTTP请求,于是你在pom.xml里直接加了依赖:
<dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> <version>5.2.1</version> </dependency>看起来没什么问题。但SDK一旦被发布到Maven仓库,这个依赖就会被“传递”给调用方。如果调用方自己的项目里也依赖了不同版本的httpclient5,Maven会按“最近优先”的原则选择版本,很可能把SDK依赖的版本覆盖掉,导致SDK在运行时出现ClassNotFound或奇怪的兼容性问题。
所以SDK的依赖管理要遵循一个核心原则:尽量少依赖第三方库;一定要依赖时,尽量把依赖的传递性关掉,或者使用provided/optional作用域,让调用方自行决定是否引入。
比如用java.net.http.HttpClient的好处之一,就是零第三方依赖,天然避开这个问题。
如果确实需要用到第三方库,我建议:
- 能用JDK原生能力解决的,绝不用第三方库。
- 不得已引入时,优先选择更通用的库,比如Jackson、SLF4J这类几乎每家都在用的,不容易出现版本冲突的,但依然建议用
optional标记。 - 尽量把第三方依赖隔离在internal包中,不要暴露在public API签名里。否则调用方想用你的SDK,还得被迫了解Jackson的基础知识。
6.2 版本管理:用语义化版本,别随便升级小版本
SDK的版本号规则建议严格遵循语义化版本(SemVer):
| 版本段 | 含义 | 示例 |
|---|---|---|
| 主版本号 | API破坏性变更 | 2.0.0(旧API移除) |
| 次版本号 | 向后兼容的新功能 | 1.3.0(新增一个请求参数) |
| 修订号 | 向后兼容的bug修复 | 1.3.1(修一个超时问题) |
这里有一个很实际的建议:任何可能影响调用方行为的变更,哪怕是修bug,都值得评估要不要升次版本甚至主版本。举个我遇到过的例子:SDK原本在readTimeout超时的时候不重试,后来改成超时也重试一次,这个改动对调用方来说行为发生了变化,如果是某些非幂等接口,可能会引发重复提交。所以这种修复不能静默地藏在1.0.1里,最好至少升到1.1.0并在CHANGELOG里明确提示。
我还会在SDK里用@Deprecated标注老方法,并保留几个版本再真正删除。这样做两个好处:一是给调用方充分的迁移时间,二是让你在升级主版本时能少受到一些“为什么删除我的方法”的投诉。
6.3 环境差异:不是只有JDK版本这一个坑
SDK可能被运行在各种环境里:Java 8的遗留系统、Java 11的微服务、Java 17的新项目。如果你的SDK用了JDK 11才有的API,那Java 8的调用方直接编译失败。所以在SDK发布之前,一定要明确指定最低JDK版本,并且在pom.xml里用maven.compiler.source和maven.compiler.target做约束。
这里我建议一个比较务实的策略:如果你的目标用户是外部团队,最低JDK版本尽量向下兼容。比如很多To B业务系统还在用Java 8,那SDK的主代码就别用var、List.of这类Java 9+的特性;如果实在想用,可以通过多版本release或者模块化来兼顾。如果是内部工具SDK,可以大胆一些,但也得先统计一下整个公司还有多少服务在跑JDK 8。
另一个环境差异是操作系统和网络环境。比如Windows环境下路径分隔符是反斜杠,Linux下是正斜杠;再比如某些内网环境需要代理设置。这些细节也可能让SDK在上线之后才暴露出问题,所以测试的时候尽量覆盖Linux和Windows两种环境。
7. 发布与推广:从“我封装好了”到“别人愿意用”
最后这部分我要讲的是SDK开发里最容易被忽视、但对项目成功至关重要的环节:发布、推广和持续维护。
7.1 构建工具链的完整配置
SDK发布到Maven私服(比如Nexus或者Artifactory)之前,pom.xml里至少要做以下几件事:
- 配置
maven-source-plugin:让发布包包含源码jar。调用方在IDE里点开SDK类时能看到源码和注释,这会极大降低使用门槛。没有源码jar的SDK,调用方看反编译代码的体验非常痛苦。 - 配置
maven-javadoc-plugin:生成JavaDoc jar。如果你懒得写独立文档,至少保证类和方法上面的注释是全面、清晰的。 - 配置
maven-gpg-plugin(如果发布到中央仓库):签名是Maven Central的上传要求。内网私服一般不需要,但别混了。
还有一个很实际的点:发布前务必跑一遍完整mvn clean install,确认测试全过。不要在IDE里能编译就往上发。很多SDK的第一次“发布事故”,都是因为作者本地能跑,但构建服务器上没有某个插件或环境变量,导致发布失败。
7.2 让调用方“无痛接入”
SDK推广中最大的阻力通常是“老代码不想改”。所以接入指南要提供两种路径:
- 全量迁移:适合还没有使用旧工具类的项目,直接按最佳实践接入新SDK。
- 渐进式迁移:适合已经在用旧工具类的项目,提供“旧方法内部转调SDK”的过渡方案,让调用方能先在业务中做一个小的灰度验证,再逐步替换。
这是我在实际交付中跌过跟头才总结出来的。当时我封装了一个新的认证SDK,直接在群里发了一个“请全部切换到新SDK”的通知,结果一个月后检查,只有两个新项目在用它,存量项目全部一动不动。后来改成提供一个旧的AuthUtil类,内部转调新SDK,并把方法名、参数都保持兼容,存量项目才陆续迁过去。
7.3 一份好的README怎么写
最后提一下README。SDK的README不要写成“架构设计文档”,而是写成“接入手册”。我常用的一种结构是:
- 项目简介:两三句话说明这个SDK解决什么问题。
- 快速开始:Maven坐标 + 第一段可运行的代码。
- 核心概念:用图或者表格表述客户端、请求参数、响应包装、异常体系的关系。
- 配置项说明:所有可配置项、默认值、以及调整建议。
- 异常处理:哪些场景抛异常,哪些场景返回失败结果。
- 常见问题:从实际答疑群和Issue里收集的真实问题。
- 版本历史:链接到CHANGELOG。
这份README如果写得好,SDK的群聊答疑量能下降一半以上。
8. 结语之外的一些真心话
写了这么多,其实最想表达的一点是:SDK库的本质是一种契约——你和调用方之间的技术契约。业务代码你可以随时改,但SDK一旦发布出去,每一个API签名、每一个异常行为、每一个默认值,都会在调用方的系统里留下痕迹。你改一个字段名,可能就要推动很多个服务跟着改代码;你修一个看似正确的bug,也可能因为行为变化而引发线上故障。
所以在设计SDK的时候,我最常用的评判标准是:如果我是第一天接触这个SDK的调用方,我能在多少时间内写出第一个能跑的调用?如果超过5分钟,那说明设计还有优化空间。
我自己的经验是,第一次写SDK时,不要追求一次性把所有能力都做好,先聚焦一个核心场景,把API设计、测试、文档和发布流程都沉淀下来。等这个SDK被几个团队用起来,你自然会收到很多真实的反馈——哪些API设计是合理的,哪些参数配置是多余的,哪些异常处理是反直觉的。然后再根据这些反馈去迭代版本,而不是闭门造车地追求“完美设计”。
这也是为什么我觉得像“java_sdk_library”这样的示例项目值得仔细研究的原因——它把SDK开发的完整链路浓缩在一个可以反复学习、反复修改的例子里,里面藏着的是封装、复用、契约设计、依赖治理、可观测性等一系列工程问题的缩影。认真拆解一遍,比你在业务代码里写一万行工具类要有价值得多。