首页 / 资讯中心 / 文章详情

把HTTP调用写成本地方法:基于注解与动态代理的声明式HTTP客户端

把HTTP调用写成本地方法:基于注解与动态代理的声明式HTTP客户端 ★ FEATURED ARTICLE
简介http-api-invoker 是一套基于动态代理与反射实现的轻量级 HTTP 接口调用框架主要面向 Java 开发者用于降低远程接口调用的编码成本。与传统 RestTemplate、HttpClient 手动拼接请求、解析响应不同项目借鉴 MyBatis 的“只写接口”模式开发者只需用注解声明接口与 HTTP 请求的绑定关系框架便会生成代理实现使调用方法像调用本地方法一样自然同时支持泛型返回、文件上传下载、Mock 测试也可选集成 Spring通过 Autowired 自动注入接口实现。压缩包共 73 个文件大小仅 93KB其中 65 个 Java 源码文件构成核心代码另有 Maven 配置 pom.xml、若干 properties 配置文件、Markdown 文档、LICENSE 与 .gitignore结构清晰便于查阅。目前已有 313 人学习或下载适合希望了解动态代理、注解处理、自动包扫描等 Java 底层机制的开发者作为源码研读或项目集成参考。1. 把 HTTP 调用写成本地方法http-api-invoker 到底解决了什么很多老项目里HTTP 调用点散落得到处都是每次改路径都要满仓库搜字符串改完还要担心有没有漏掉某个定时任务。把远程调用收敛成接口定义之后业务代码里调用和本地方法几乎一样orderApi.getById(1024L)。这就是 http-api-invoker 这类声明式 HTTP 客户端在做的事用注解描述请求契约用动态代理把一次方法调用翻译成一次 HTTP 请求。它带来的不只是少写几行样板代码而是让接口文档、调用代码、测试桩三处共享同一份真实来源改动只发生在接口定义里。下面按原理、最小实现、参数细节和坑四条线展开适合正在做接口收敛、想摆脱重复封装的开发者参考。2. 先理解为什么现在的调用代码不自然从封装到动态代理2.1 你现在的调用代码差在哪一个典型对比打开任何一个调用远程接口的方法大概率是这种结构// 传统写法拼接 URL、打开连接、拆 JSON、塞进 DTO String url https://orders.example.com/api/v1/orders/ orderId; URLConnection conn new URL(url).openConnection(); InputStream in conn.getInputStream(); // 读流、按编码转字符串、再用 JSON 工具解析成 Map MapString, Object data json.readValue( new String(in.readAllBytes(), UTF-8), Map.class); Order order new Order(); order.setOrderNo((String) data.get(orderNo)); order.setAmount(new BigDecimal(String.valueOf(data.get(amount))));这还只是一次 GET。如果参数要拼 query、要带 header、要处理 4xx/5xx、要写超时和重试代码量至少再翻一倍。声明式接口把这些全收敛到一行Order order orderApi.getById(1024L);表面上是少了样板代码实质是改变了信息流动的方式。传统写法里URL、参数、返回类型各自散落在代码里一次改动要在三个地方同步声明式写法里URL 是注解参数参数和返回类型直接长在方法签名上编译器帮你检查类型重构时 IDE 也参与进来不会再出现“路径改了但某个调用点漏改”的翻车原因。传统写法还有两个隐含成本。一是异常在连接层、协议层、业务层混在一起4xx 和 5xx 都按同一个 IOException 或 Map 返回处理调用方很难区分“参数错了”和“服务挂了”二是验证成本高每次调试要么连测试环境要么本地把服务起全。声明式接口可以对着本地 Mock 验证请求是否正确发出这点在后面的避坑章节会反复用到。2.2 动态代理InvocationHandler 是整条链路的翻译中枢动态代理是这个方案所有魔术发生的地方。理解它只需要盯住一件事当你调用api.getById(1024L)时调用并没有真正进入某个实现类而是被转发到一个统一的处理器里方法名、参数值、参数注解全部作为参数传进来。处理器的职责就一句话把这份“方法调用的现场信息”翻译成一个 HTTP 请求。JDK 自带的机制示意如下OrderApi api (OrderApi) Proxy.newProxyInstance( OrderApi.class.getClassLoader(), new Class?[] { OrderApi.class }, handler ); Order order api.getById(1024L); // 对调用方来说是一次本地方法调用 // 底层实际发生的是 // GET https://orders.example.com/api/v1/orders/1024这个handler是InvocationHandler的实现核心方法是invoke(Object proxy, Method method, Object[] args)。method告诉框架请求路径模板和 HTTP 方法注解args告诉框架参数值method上的参数注解告诉框架每个参数应该放到 URL 的哪个位置。三者组合一次调用就完整成型。这里还牵出一个选型问题为什么坚持接口而不是抽象类JDK 动态代理的Proxy.newProxyInstance只接受接口数组抽象类需要借助字节码生成库。就算引入了字节码生成抽象类还有一个麻烦它可以带默认实现一旦有人往里面写业务逻辑接口的“纯契约”属性就被污染了。把定位锁死在接口上等于从机制上保证使用者只声明、不实现契约和实现彻底分离。有个细节必须点出来处理器要放行Object的方法。proxy.toString()、proxy.equals()、proxy.hashCode()不应该被翻译成 HTTP 请求否则 debug 时打印一个代理对象会打出一次网络请求。常见的做法是判断method.getDeclaringClass() Object.class时直接走本地方法这也是后面代码骨架里的第一道分支。2.3 注解即契约为什么接口签名可以当接口文档用接口签名本身非常接近一份天然契约。方法名描述操作方法参数描述入参返回类型描述出参但缺三样东西请求路径、HTTP 方法、参数在请求里的位置。注解恰好补的是这三样。路径是GET(/v1/orders/{id})参数位置是PathVar(id)、Query(status)、Body文档缺失的信息用注解补齐后整个接口就变成机器可读的声明。注解方案相比 XML 配置和代码内拼 URL 的优势在于来源单一。XML 里写的路径、Java 方法名、调用点的字符串常量是三份各自维护的信息漂移是必然的注解长在方法上路径和方法绑定改动只发生在一处编译期就能发现注解写错、参数对不上这类低级问题。把接口理解成“HTTP 请求的声明”之后业务代码里读接口比读一堆工具类直观得多。设计这一层时注解的最小集是四个接口上的Api提供 basePath方法上的GET/POST提供路径和 HTTP 方法参数上三个标记PathVar/Query/Body分配参数位置。这套最小集足够覆盖绝大多数业务接口后面所有扩展——超时、重试、日志——都可以挂在这套契约上做。还有一个附加好处注解是带类型的元数据IDE 能跳转、能自动补全写错路径虽然编译期不报错但启动自检环节能兜住这一点在第 6 章展开。3. 用注解和动态代理把最小框架跑起来3.1 先定义一套人人都看得懂的接口注解先定义注解。RUNTIME 保留策略是死条件少了它反射拿不到注解代理层直接废掉。Target也要按位置收窄接口注解、方法注解、参数注解三种目标分开避免注解出现在不该出现的地方。Retention(RetentionPolicy.RUNTIME) Target(ElementType.TYPE) public interface Api { String basePath(); } Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface GET { String value(); } Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface POST { String value(); } Retention(RetentionPolicy.RUNTIME) Target(ElementType.PARAMETER) public interface PathVar { String value(); } Retention(RetentionPolicy.RUNTIME) Target(ElementType.PARAMETER) public interface Query { String value(); } Retention(RetentionPolicy.RUNTIME) Target(ElementType.PARAMETER) public interface Body { }基于这套注解接口写出来是这个样子Api(basePath https://orders.example.com/api) public interface OrderApi { GET(/v1/orders/{id}) Order getById(PathVar(id) Long id); GET(/v1/orders) ListOrder search(Query(status) String status, Query(page) Integer page); POST(/v1/orders) Order create(Body Order request); }三个方法分别覆盖了常用的三种形态路径参数、query 参数、JSON 请求体。接口读起来的观感很接近本地 DAO 或 Service新人看两眼就能上手参数类型是强类型的调用方传错类型编译期就报错这是拼 URL 方案永远做不到的。注解集可以按业务再扩比如加Header、Form但最小集里不建议放太多。一开始就堆功能接口会被注解淹没反而丢了“自然”这个初衷。我一般建议从这三个参数注解起步用一段时间确定缺什么再加而不是第一版就设计出一个大而全的注解体系。3.2 InvocationHandler 骨架四步完成一次远程调用核心处理器是invoke方法。它要做四件事取接口上的Api拿 basePath取方法上的GET/POST拿 HTTP 方法和路径模板把参数绑定到 URL / query / body然后发送请求并反序列化响应。class RemoteInvoker implements InvocationHandler { private final HttpClient client; private final ObjectMapper mapper; RemoteInvoker(HttpClient client, ObjectMapper mapper) { this.client client; this.mapper mapper; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (method.getDeclaringClass() Object.class) { return method.invoke(this, args); // toString/equals/hashCode 不翻译成请求 } Api api method.getDeclaringClass().getAnnotation(Api.class); GET get method.getAnnotation(GET.class); POST post method.getAnnotation(POST.class); boolean isGet get ! null; String pathTemplate isGet ? get.value() : post.value(); BindResult bind bindParams(api.basePath() pathTemplate, method, args, isGet); HttpRequest.Builder builder HttpRequest.newBuilder(URI.create(bind.url())); if (isGet) { builder.GET(); } else { String json bind.body() null ? : mapper.writeValueAsString(bind.body()); builder.header(Content-Type, application/json) .POST(HttpRequest.BodyPublishers.ofString(json)); } HttpResponseString resp client.send( builder.build(), HttpResponse.BodyHandlers.ofString()); if (resp.statusCode() 200 || resp.statusCode() 300) { throw new RemoteApiException(method.getName(), resp.statusCode(), resp.body()); } if (method.getReturnType() Void.TYPE) { return null; } Type returnType method.getGenericReturnType(); JavaType javaType mapper.getTypeFactory().constructType(returnType); return mapper.readValue(resp.body(), javaType); } }逻辑主线是清晰的先区分 GET/POST再走参数绑定发送、判状态码、反序列化。几个值得注意的细节状态码判断用的是“2xx 之外都算失败”而不是只判断 200避免把 204、206 误判成错误返回类型先判断Void.TYPE避免readValue对空 body 做无意义解析POST 请求体为 null 时发空串而不是字符串 null这一点在调试时能省很多困惑。RemoteApiException里建议至少带三个字段接口方法名、HTTP 状态码、响应体前几百字符。线上排查时没有方法名只能知道“某个请求挂了”有了方法名可以直接定位到接口定义响应体截断到几百字符是防止把完整错误堆栈或敏感信息打进日志。这个异常类型要面向调用方暴露业务代码 catch 它做降级而不是只打日志继续跑。bindParams是参数绑定入口返回值里保存拼接好的 URL 和请求体对象。这个方法本身是参数细节的核心我在第 4 章完整展开。处理器里刻意没有处理超时和重试是为了先让主链路跑通再在配置层补全避免第一版就把概念混在一起。3.3 注册入口与启动扫描接口从声明到可用接口要变成可调用的对象需要一个统一的创建入口public final class HttpApis { private HttpApis() {} public static T T create(ClassT apiType, HttpClient client) { return (T) Proxy.newProxyInstance( apiType.getClassLoader(), new Class?[] { apiType }, new RemoteInvoker(client, new ObjectMapper())); } }使用方只需要一行OrderApi orderApi HttpApis.create(OrderApi.class, httpClient);如果接口数量少手动一行行create完全可维护接口多了之后更常见的做法是启动时扫包遍历某个包下所有带Api的接口逐个创建代理对象按接口类型注册到容器里。扫描逻辑不复杂核心是“反射拿接口列表 逐个 create”用类路径扫描工具或者手写目录遍历都可以。需要注意扫描结果必须去重否则同一个接口被注册两次行为上看起来正常排查时最隐蔽。重复注册的场景并非只在扫描器写错时出现。一个接口被两个不同的扫描规则扫到或者同名接口在多个依赖里存在注册表里就会出现两份代理。调用方每次拿到的实例不是同一个连接池、超时配置都可能被悄悄分流。防护做法是注册前检查类型是否已存在冲突时直接报错不让它静默过去。入口我建议统一管理不要在每个业务类里各自new。统一入口能让 HttpClient 只有一个实例连接池、超时、日志等全局配置只写一处也方便在测试里替换成 Mock 实现。4. 参数绑定、泛型响应与超时配置落地的三个细节4.1 参数绑定规则GET 落 query、POST 落 body 的默认行为参数绑定是整个实现里最容易被问“为什么这么设计”的部分。设计目标只有一个让调用方少写字。先把参数注解解析出来标注了PathVar的替换路径模板标注了Query的进 query 串标注了Body的作为请求体没有标注的参数GET 方法默认进 queryPOST 方法默认进 body。// record 需要 JDK 16低版本换成普通静态类即可 private record BindResult(String url, Object body) {} private record NameValue(String name, String value) {} private BindResult bindParams(String path, Method method, Object[] args, boolean isGet) throws Exception { Annotation[][] annos method.getParameterAnnotations(); MapString, String pathVars new LinkedHashMap(); ListNameValue query new ArrayList(); Object body null; for (int i 0; i args.length; i) { boolean tagged false; for (Annotation a : annos[i]) { if (a instanceof PathVar) { pathVars.put(((PathVar) a).value(), args[i] null ? : String.valueOf(args[i])); tagged true; } else if (a instanceof Query) { if (args[i] ! null) { query.add(new NameValue(((Query) a).value(), String.valueOf(args[i]))); } tagged true; } else if (a instanceof Body) { body args[i]; tagged true; } } if (!tagged) { if (isGet) { query.add(new NameValue(arg i, String.valueOf(args[i]))); } else { body args[i]; } } } String url path; for (Map.EntryString, String e : pathVars.entrySet()) { String encoded URLEncoder.encode(e.getValue(), UTF-8).replace(, %20); url url.replace({ e.getKey() }, encoded); } if (!query.isEmpty()) { String qs query.stream() .map(nv - URLEncoder.encode(nv.name(), UTF-8) URLEncoder.encode(nv.value(), UTF-8)) .collect(Collectors.joining()); url ? qs; } return new BindResult(url, body); }三个边界行为是特意定的。第一Query参数传 null 时直接跳过不拼进 URL这样调用方不用写一堆if (xxx ! null)的空值判断第二PathVar传 null 按空串处理但路径模板里如果还有没被替换的占位符到URI.create时会抛异常属于早失败第三未标注参数用arg0这类名字兜底主要是防止反射解析漏掉注解时静默丢参数。这套绑定规则有个设计前提约定优于配置。框架不要求每个参数都标注解没标的默认行为可预测调用方少写一半注解代价是隐式行为需要文档说清楚。我也见过有的团队把每个参数都强制标注代码显得很安全但接口定义读起来特别啰嗦违背了优雅这个初衷。显式标注和隐式默认之间我倾向于只在“一眼能看出意图”的地方保留隐式规则。关于编码的细节经常被忽视query 参数和路径变量都要做URLEncoder.encode但路径变量编码后空格会变成这在路径里是错的必须再替换成%20。这个坑在第 5 章会单独讲这里先把正确的做法固定下来。4.2 泛型响应不丢类型从方法签名还原 List这个细节非常容易翻车。如果响应类型是Order直接用mapper.readValue(body, Order.class)没问题一旦接口返回ListOrder用method.getReturnType()拿到的只是List.class反序列化结果是一个ArrayListLinkedHashMap业务代码里遍历集合取属性时直接ClassCastException。正确的做法是拿方法的泛型签名而不是返回的 Class 对象。// 错误丢泛型 Class? returnClass method.getReturnType(); // 只有 List.class ListOrder list mapper.readValue(body, returnClass); // 拿到 ListLinkedHashMap // 正确从泛型签名还原完整类型 Type returnType method.getGenericReturnType(); // ListOrder JavaType javaType mapper.getTypeFactory().constructType(returnType); ListOrder list mapper.readValue(body, javaType);这里有个容易忽略的前提这个特性只在方法上成立因为泛型签名是编译期写进字节码的Method.getGenericReturnType()能读到如果某个环节把Method换成了Class泛型信息就丢了。所以框架内部必须始终基于Method对象处理返回类型中间不要经过任何“转成 Class 再回来”的操作。写完这个逻辑后我习惯把带泛型的接口放进测试里返回ListOrder、返回MapString, Order各写一条断言。泛型这块出问题往往不是当场暴露而是被某个上游 try-catch 吞掉等数据落到页面上变成一堆{orderNo...}结构才发现。越是这种隐蔽问题越值得在框架层一次修对。4.3 超时、重试、日志一张配置表和一个 Builder主链路跑通后配置层是决定这个方案能不能上生产的门槛。配置项不多但每个都对应一类线上症状配置项作用建议值connectTimeoutTCP 连接建立的等待时间3 秒以内requestTimeout单个请求的总体等待时间5~10 秒按调用链调整连接池复用HttpClient 实例是否全局单例全局唯一禁止每次 new重试次数连接失败、5xx 时的重发次数1~2 次幂等接口才开日志级别请求行、状态码、耗时生产只打请求行和状态码HttpClient 的配置入口很直观HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .followRedirects(HttpClient.Redirect.NORMAL) .version(HttpClient.Version.HTTP_1_1) .build(); HttpRequest request HttpRequest.newBuilder(uri) .timeout(Duration.ofSeconds(5)) // 请求级超时比连接超时大 .GET() .build();connectTimeout和requestTimeout的层级关系值得多写两句连接超时只管建连请求超时覆盖“建连 发送 等待响应”整个过程两者是包含关系。只配连接超时不配请求超时接口慢到 30 秒时调用方会一直等只配请求超时不配连接超时对端 IP 不可达时连接要等操作系统默认超时两分钟是常有的事。所以两个都要配且 requestTimeout 一定要大于 connectTimeout。重试的逻辑不要盲目加。先确认接口是否幂等GET 重试基本安全POST 要对业务是否幂等做评估创建类接口重试可能产生重复单据。重试范围要圈在连接失败、超时、5xx 这类可恢复错误上4xx 是业务拒绝重试只会放大问题。重试间隔我一般用固定 100~200 毫秒指数退避看起来更专业但对大多数内部接口来说差别不大反而让超时累计变长。选择退避策略之前先确认调用链上对延迟的容忍度。日志方面生产环境默认不打印 body打印了会把敏感字段写进日志排查问题时再临时把 body 打开用完关掉。日志格式建议固定为“接口方法名 HTTP 方法 完整URL 状态码 耗时”这五样信息组合起来线上大多数“偶发超时”“连接被重置”都能定位到具体阶段。5. 排查与避坑动态代理 HTTP 调用的 5 条踩坑记录5.1 每次请求都 new HttpClient连接被耗尽现象功能本地正常压测跑到一半开始报连接失败错误里有Too many open files或大量连接重置。看在线连接数曲线一路向上不回落。原因处理器里有人在invoke内部new HttpClient()每个请求都新开一个连接用完全不归还连接池文件描述符被耗尽。日志里通常伴随大量SocketException: Too many open files。解决HttpClient 全局唯一通过构造器注入或容器管理确保整个进程只有一个实例复用连接。排查时先把创建次数打进日志跑一次压测看实例数是不是 1。如果框架里只有一个构造位置但连接依然耗尽那要检查 HttpClient 是不是被放进了 ThreadLocal 或请求级对象这两种都是伪单例。这是这套方案里最常见的翻车点没有之一。5.2 返回 List 拿到的是 List edhashmap现象接口返回正常但业务代码遍历列表后取属性时抛ClassCastException。断点看集合元素类型是LinkedHashMap而不是Order。原因反序列化目标用了method.getReturnType()泛型信息丢失ListOrder被当成裸ListJSON 解析器只能按 Map 处理每个元素。解决改用method.getGenericReturnType()配合类型工厂构建完整类型第 4.2 节有完整代码。如果项目里已经踩了这个坑把返回类型从ListOrder临时改成Order[]也能验证根因但正确修复方向还是泛型。如果业务代码里自定义了TypeReference也要确认构造发生在代理层内部业务层的 TypeReference 救不了内层已经丢失的泛型。5.3 接口重载之后请求路径被顶掉或注解读成 null现象给接口加了重载方法比如一个search(String status)和一个search(String status, Integer page)调用其中一个发现请求走错了路径或者启动时注解读取到 null。原因如果框架内部用method.getName()作为契约的 key同名方法会互相覆盖后注册的方法把前面的映射挤掉更隐蔽的是动态代理场景下重载让Method对象不同但名字相同某些按名字匹配的注册逻辑直接失效。解决契约注册用Method对象本身做 key注解从method上反射获取不走名字。同时团队约定接口内禁止重载方法名保持全局唯一别赌框架能智能区分。这个约定写进接口规范里比在框架层做兼容更省事也更符合“接口即文档”的定位。5.4 路径参数里的中文和空格请求直接 400现象/files/{name}传a b.jpg或中文文件名返回 400 或拿到错误资源本地测试偶尔换一个名字又失败看起来像玄学。原因拼接 URL 时没有做百分号编码空格把路径截断成两段中文走了非 UTF-8 编码服务端按错乱字节解析。解决路径变量和 query 参数都先URLEncoder.encode(v, UTF-8)路径变量额外把编码后的替换成%20因为只在 query 里有意义路径里会当成字面字符。编码时要分开处理路径和 query 各编码一次不要整个 URL 一起编码否则://和?都会被破坏。这类问题用第 6 章的本地 Mock 验证最方便传中文和空格跑一次看控制台打印的请求行是不是编码后的样子。5.5 重试配了但一次没重试重试循环包错了范围现象加了重试参数用断网或 kill 连接压测发现请求直接失败重试根本没发生。翻代码发现重试只包住了“解析响应”没包住“发送请求”。原因重试循环放错了位置网络异常在发送阶段就抛出去程序根本走不到重试分支。也可能是网络异常被包成了业务异常外层 catch 只处理了业务异常导致可重试的异常被静默吞掉。解决把“发请求 读状态码 读响应体”整体放进重试循环内部catch 范围覆盖IOException、InterruptedException以及 5xx 状态码。重试之间加短暂 sleep避免雪崩式重试把对端打挂。验证方式是在本地 Mock 服务里加一个“前两次必失败”的测试端点确认重试确实前前后后发了三次请求。这个验证动作要进测试用例不能只靠人肉观察。6. 进阶启动自检加本地 Mock让这套方案沉淀成公共能力6.1 启动时做契约自检把注解错误拦在发布前声明式接口的最大风险是“注解里写的内容对不对要等运行时才知道”。解决思路是启动自检扫描所有带Api的接口逐个检查方法注解、路径模板和参数注解是否完整。路径模板里的占位符必须在方法参数里都能找到对应的PathVar缺注解的占位符直接抛异常阻止启动。// 启动时执行一次契约不完整就拒绝启动 for (Method m : apiType.getMethods()) { if (m.getAnnotation(GET.class) null m.getAnnotation(POST.class) null) { throw new IllegalStateException(apiType.getName() . m.getName() 缺少 HTTP 方法注解); } Matcher matcher PLACEHOLDER.matcher(pathOf(m)); while (matcher.find()) { boolean hasBind Arrays.stream(m.getParameterAnnotations()) .flatMap(Arrays::stream) .anyMatch(a - a instanceof PathVar ((PathVar) a).value().equals(matcher.group(1))); if (!hasBind) { throw new IllegalStateException(路径占位符未绑定: matcher.group(1)); } } }这个自检让“写错注解”从线上故障变成一个启动报错成本低收益高。我习惯把它挂在应用启动流程里和配置文件校验放一起每次发布前自动跑一遍。做了这个之后契约错误基本不会再漏到线上。血泪经验是动态代理框架一旦“能用”大家会疯狂加接口自检是唯一能兜住所有接口质量的机制。6.2 本地 Mock 验证请求行比看代码推演快得多框架写完验证最方便的方式是在本地起一个 Mock 服务把收到的请求原样打出来然后跑一次接口调用对比请求方法和路径是否和预期一致。JDK 自带 HTTP Server 就能做不用引入额外依赖。HttpServer server HttpServer.create(new InetSocketAddress(18080), 0); server.createContext(/api/orders/, exchange - { System.out.println(exchange.getRequestMethod() exchange.getRequestURI()); byte[] body {\orderNo\:\SN01\,\amount\:199.0} .getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); exchange.close(); }); server.start();接口的 basePath 指到http://localhost:18080/api跑一次调用控制台输出的就是框架真实发出的请求。改参数、改注解、测编码问题都比看代码推演快得多。如果项目开了模块化记得在 module-info 里requires jdk.httpserver否则编译直接报模块找不到。这套方案收尾我最想留下的习惯是每个新接口先写接口定义再本地 Mock 验证请求行最后才接真实环境接口定义是唯一事实来源调用方、测试桩、Mock 服务都从它生成。把自检和 Mock 沉淀成公共能力之后团队接新系统、改旧接口的效率提升是实打实的。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站