十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot内部接口为何优先选用JSON-RPC?从零实现全解析

Spring Boot内部接口为何优先选用JSON-RPC?从零实现全解析 简介这是一份基于Spring Boot的JSON-RPC服务端示例面向有Java基础、希望快速实现RPC接口的开发者也适用于需要了解JSON-RPC 2.0协议与Spring Boot整合方式的学习场景。资源包共26个文件压缩后仅55KB内容以Java源码、class编译文件和properties配置为主并附带Maven wrapper、jar包、XML及工程配置文件从源码、构建脚本到运行配置一应俱全可直接导入IDE查看项目结构或提取关键代码迁移到实际业务中。示例中的服务端实现了multiplier方法客户端以application/json发送包含id、jsonrpc、method和params字段的POST请求服务端返回result为40的结果完整演示了请求格式、方法分发与响应封装流程可帮助理解Spring Boot中RPC服务的注册与暴露机制。已有297人学习下载适合需要搭建跨语言或前后端分离场景下轻量级RPC接口的Java后端开发者参考。1. 为什么Spring Boot里做内部接口我会优先考虑JSON-RPC先说一个可能反直觉的结论很多人在Spring Boot项目里做接口第一反应永远是REST实际上有一类场景用JSON-RPC会更顺手尤其当你对接的是异构系统、内部平台或者脚本工具时。JSON-RPC是一种极简的远程调用协议整个协议规范就几个字能说清楚客户端往服务端发一个JSON对象里面带上方法名和参数服务端处理完再回一个JSON对象里面带上结果或者错误信息。相比REST要设计资源路径、HTTP方法、状态码语义、幂等策略JSON-RPC几乎不用设计只需要定义方法名和参数结构。适合用JSON-RPC的典型场景有这么几类后端服务之间的内部接口调用调用方是Java、Python、Go、Node.js甚至Shell脚本混用的环境。接口数量多但逻辑简单不需要暴露给外部生态不需要OpenAPI/Swagger那种面向全世界的文档体系。客户端与服务端天然是“调用远端函数”的语义而不是“操作资源”的语义。我在实际项目里遇到过类似情况一个平台需要给数据分析团队提供一批查询接口对方用Python脚本直接调HTTP接口数据团队不关心RESTful资源设计只想拿到数据。当时如果按REST风格写我光设计URL路径、请求方法、状态码就要反复沟通好几轮而且团队里每个人对“查询订单应该用GET还是POST”都有自己的看法争论成本远大于写代码成本。后来换成了JSON-RPC定义几个方法名参数用JSON传给对方一次联调通过这个方案在公司内部沿用至今。当然并不是说JSON-RPC能替代REST或gRPC而是它占据了一个被很多人忽视的中间位置REST重在资源化、语义化gRPC重在强类型、高性能、长连接而JSON-RPC重在极致的简单和跨语言友好。三者放在一起做个对比更直观维度RESTJSON-RPCgRPC消息格式JSON/XML等JSONProtobuf接口语义资源操作GET/POST/PUT/DELETE远程方法调用远程方法调用带接口定义学习成本中涉及URI/状态码/幂等低一个POST加JSON即可高需要掌握Protobuf和代码生成跨语言支持好极好好但需要生成SDK接口文档Swagger/OpenAPI简单文档或接口名即文档Proto文件即文档适合场景对外API、浏览器直接访问内部服务、脚本调用、轻量对接微服务内部高性能通信如果你正在开发一个Spring Boot项目面对的调用方是浏览器里的前端页面、外部合作伙伴、以及需要公开给第三方使用的场景REST仍然是稳妥的选择。但如果你是在做内部系统、中台服务、或者给数据分析师提供查询接口JSON-RPC的服务端在Spring Boot里搭起来比想象中要省事得多。2. 从零搭一个服务端不依赖第三方库的Spring Boot实现实现Spring Boot里的JSON-RPC服务端业界有一个现成的库叫jsonrpc4j用起来也不算复杂。但我更推荐的方式是用Spring Boot自身的注解和路由能力自己实现一版理由有两条JSON-RPC协议太简单了核心逻辑几十行代码就能覆盖自己实现反而没有黑盒出问题好排查。自实现可以完全融入Spring的Bean管理和参数校验体系不需要额外适配。2.1 先定路由入口JSON-RPC 2.0规范里请求和响应都是JSON对象。一次典型请求长这样{ jsonrpc: 2.0, method: order.getById, params: { id: 12345 }, id: 1 }服务端的职责就是接收这样一个JSON对象解析出method字段根据方法名找到对应的处理器把params里的参数绑定到Java方法入参上执行完把结果塞进响应JSON里返回。在Spring Boot里入口只需要一个普通的Controller接收POST请求。我通常把路径统一设置为/api/rpc当然这个路径完全可以自己定JSON-RPC协议本身对URL没有任何要求。RestController RequestMapping(/api/rpc) public class JsonRpcController { private final JsonRpcDispatcher dispatcher; public JsonRpcController(JsonRpcDispatcher dispatcher) { this.dispatcher dispatcher; } PostMapping public MapString, Object handle(RequestBody MapString, Object request) { return dispatcher.dispatch(request); } }Controller不做任何业务判断只负责把请求转发给核心分发器。这样做的原因是把HTTP层和协议层拆开后续即便换WebFlux或者增加拦截器都不影响协议解析逻辑。2.2 把method映射到Java方法分发器是整个服务端的核心。我的做法是先用注解定义服务和方法再通过Spring的ApplicationContext在启动时把所有可调用的方法注册到一个Map里Map的key就是method字符串value是一个封装了Bean实例和Method对象的调用器。定义两个注解Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) Component public interface JsonRpcService { }Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface JsonRpcMethod { String value(); }然后在业务类上标注JsonRpcService public class OrderRpcService { JsonRpcMethod(order.getById) public OrderVO getOrderById(RequestParam(id) Long id) { Order order orderMapper.selectById(id); return OrderVO.from(order); } JsonRpcMethod(order.listByStatus) public ListOrderVO listByStatus(RequestParam(status) Integer status) { return orderMapper.selectByStatus(status).stream() .map(OrderVO::from) .collect(Collectors.toList()); } }注册逻辑放在ApplicationRunner里启动时扫描Spring容器中带有JsonRpcService注解的Bean再把其中标注了JsonRpcMethod的方法按名字注册Component public class JsonRpcRegistry { private final MapString, MethodInvoker methodMap new ConcurrentHashMap(); public JsonRpcRegistry(ApplicationContext context) { MapString, Object beans context.getBeansWithAnnotation(JsonRpcService.class); for (Object bean : beans.values()) { for (Method method : bean.getClass().getDeclaredMethods()) { JsonRpcMethod annotation method.getAnnotation(JsonRpcMethod.class); if (annotation ! null) { methodMap.put(annotation.value(), new MethodInvoker(bean, method)); } } } } public MethodInvoker resolve(String methodName) { return methodMap.get(methodName); } }这里有一个关键点要注意getDeclaredMethods()拿到的Method对象是目标类自己的方法如果类里有被Spring代理过的方法反射调用时要注意可见性。我习惯在处理时调用method.setAccessible(true)否则在Java 17及更高版本的强封装机制下可能会报InaccessibleObjectException。2.3 分发器的手感分发器做的事情只有四步校验JSON-RPC协议版本、解析method、解析参数并调用、构造响应。整个过程我建议保持同步和直接不要在这里引入异步编排因为JSON-RPC本身就是典型的请求-响应模型引入异步只会增加排查难度。public MapString, Object dispatch(MapString, Object request) { if (!2.0.equals(request.get(jsonrpc))) { return errorResponse(null, -32600, Invalid Request); } Object id request.get(id); String methodName (String) request.get(method); if (methodName null || methodName.isEmpty()) { return errorResponse(id, -32600, Invalid Request); } try { MethodInvoker invoker registry.resolve(methodName); if (invoker null) { return errorResponse(id, -32601, Method not found: methodName); } Object result invoker.invoke(request.get(params)); MapString, Object response new LinkedHashMap(); response.put(jsonrpc, 2.0); response.put(result, result); response.put(id, id); return response; } catch (Throwable t) { return errorResponse(id, -32603, t.getMessage()); } }写到这里要单独提一个细节errorResponse里error对象的结构必须是code、message、data三个字段data是可选的用来携带堆栈或异常详情。关键是id字段必须原样返回客户端是靠它来匹配请求和响应的。如果请求里没有id就属于Notification请求服务端可以忽略或者返回null。3. 参数绑定与方法设计避免反射泥潭JSON-RPC参数绑定的设计直接决定了这个服务端好不好用。往深了说它也是协议层最容易出bug的地方值得单独开一节讲。3.1 params的两种形态JSON-RPC规范里params有两种形态数组和对象。数组形态是位置参数例如params: [1, 100]表示第一个参数是1第二个参数是100。对象形态是命名参数例如params: {userId: 1, limit: 100}。我的建议是对外统一使用对象形态。原因很简单位置参数一旦接口演化中间加一个参数所有调用方全得跟着改而且参数多了以后阅读代码的人根本记不住顺序。命名参数的可读性和可维护性远优于位置参数。实现的时候为了让Spring的Jackson反序列化能力直接生效我让MethodInvoker把params对象转成带RequestParam注解的Java参数值。具体做法是遍历方法入参的注解从params Map中按注解名取值再用Jackson的ObjectMapper转成目标类型。这里贴一段我当时写的核心逻辑public Object invoke(Object params) throws Exception { Object[] args new Object[method.getParameterCount()]; MapString, Object paramMap params instanceof Map ? (MapString, Object) params : Collections.emptyMap(); Parameter[] parameters method.getParameters(); for (int i 0; i parameters.length; i) { Annotation[] annotations parameters[i].getAnnotations(); String paramName null; for (Annotation annotation : annotations) { if (annotation instanceof RequestParam) { paramName ((RequestParam) annotation).value(); } } if (paramName null) { paramName parameters[i].getName(); } Object value paramMap.get(paramName); args[i] objectMapper.convertValue(value, parameters[i].getType()); } return method.invoke(bean, args); }用RequestParam来标注参数名是刻意选择的。因为Spring的-parameters编译参数不一定在每个项目里都开了直接依赖参数名反射不可靠而RequestParam是显式的、稳定的。这个设计灵感其实来自REST Controller的写法团队成员看一眼就懂不需要额外学习成本。3.2 参数校验落到哪里JSON-RPC服务端的参数校验很容易被人忽略早期我写的代码就是直接从params里取出来交给Service去执行结果一个空指针异常查了半天原因只是调用方漏传了一个参数。合理的做法是在参数绑定之后、业务方法执行之前统一做一次校验。Spring Boot自带jakarta.validation配合Validated注解可以复用一套校验逻辑JsonRpcMethod(order.create) public OrderVO createOrder(RequestParam(req) Valid CreateOrderReq req) { return orderService.create(req); }CreateOrderReq里面用NotNull、Size、Min等注解声明约束分发器在调用方法前对参数对象执行ValidationValidator validator Validation.buildDefaultValidatorFactory().getValidator(); SetConstraintViolationObject violations validator.validate(arg); if (!violations.isEmpty()) { throw new JsonRpcException(-32602, Invalid params: violations); }一旦校验不通过就抛一个业务异常由异常处理器统一映射成JSON-RPC -32602错误。这样做的好处是业务代码里完全不用写if判断参数是否为空的代码逻辑清爽很多。3.3 方法命名与版本化JSON-RPC没有REST那种天然的资源路径也没有网关层的路由前缀所以方法名就是接口的“URL”命名要格外用心。我的经验是采用“领域.动作”的格式比如order.getById、order.create、user.login。这样在日志里排查问题时看到方法名就能定位到业务领域。版本化也是必需要考虑的问题。REST常用/api/v1/order这种路径版本化JSON-RPC里我建议在方法名后加版本后缀order.getById_v2。虽然丑但是足够直观也不需要引入额外的解析规则。真要优雅一点可以在服务端注册时做一层方法名别名映射把order.getById映射到最新的实现老版本用带_v1后缀的方法名做到平滑升级。4. JSON-RPC标准错误码与业务异常映射错误处理是JSON-RPC服务端最容易做烂的部分。很多人因为贪图方便把所有异常都吞掉返回一个笼统的“Internal error”结果客户端拿到错误后完全不知道是参数问题还是服务端问题。4.1 错误对象的结构JSON-RPC 2.0规范规定错误对象必须包含code和messagedata可选。错误响应整体如下{ jsonrpc: 2.0, error: { code: -32602, message: Invalid params, data: { field: status, detail: must not be null } }, id: 1 }标准错误码有严格定义必须遵守否则客户端解析库可能直接报错code含义场景-32700解析错误请求JSON格式非法-32600无效请求请求对象结构不符合规范-32601方法不存在method字段找不到对应方法-32602无效参数参数缺失或类型错误-32603内部错误服务端执行异常规范的保留错误码范围是-32768到-32000自定义业务错误码只能在这个范围之外。我通常把业务异常定义为正数或小于-32768的数字比如10001代表订单不存在10002代表状态不允许变更避免和协议错误码混淆。4.2 业务异常处理链为了让业务代码可以自由抛异常而不关心协议细节我在分发器外再包了一层异常解析逻辑RestControllerAdvice public class JsonRpcExceptionHandler { ExceptionHandler(JsonRpcException.class) public MapString, Object handleJsonRpcException(JsonRpcException e, HttpServletRequest request) { return responseWithError(request, e.getCode(), e.getMessage(), e.getData()); } ExceptionHandler(MethodArgumentTypeMismatchException.class) public MapString, Object handleTypeMismatch(MethodArgumentTypeMismatchException e) { return responseWithError(null, -32602, Invalid params: e.getName(), null); } ExceptionHandler(Exception.class) public MapString, Object handleException(Exception e) { return responseWithError(null, -32603, e.getMessage(), null); } }这里有个容易踩的坑responseWithError里怎么拿到本次请求的id因为Request body已经流过输入流异常处理器无法直接读取。我的办法是在分发器入口就把request对象存入一个ThreadLocal变量异常处理器从中取id。当然更好的方式是直接在分发器里catch Throwable不走RestControllerAdvice这样id天然就在上下文里。两种方式我都试过后者代码量更少推荐直接用后者。4.3 错误码规划与客户端契约有了标准错误码和自定义业务错误码还需要把契约落到文档或代码里。我的做法是给调用方一个错误码清单页面并把自定义错误码做成一个Java枚举类打包发布到一个公共依赖模块这样Java调用方直接引用枚举不会写错数字。public enum BizError { ORDER_NOT_FOUND(10001, 订单不存在), ORDER_STATUS_INVALID(10002, 订单状态不允许该操作), USER_NOT_LOGIN(10003, 用户未登录); private final int code; private final String message; BizError(int code, String message) { this.code code; this.message message; } public JsonRpcException exception() { return new JsonRpcException(code, message); } public JsonRpcException exception(String detail) { return new JsonRpcException(code, message, detail); } }业务代码里的调用就变成一行if (order null) { throw BizError.ORDER_NOT_FOUND.exception(orderId orderId); }这样整体错误码的管理是收敛的不会出现每个开发各写各的数字最后同一个code在不同接口里表达不同含义的混乱局面。5. 安全、日志与性能上了生产环境才需要关心的细节一个能跑通的JSON-RPC服务端只是开始真正要上了生产环境还要面对鉴权、审计、性能这些实际问题。这里把我踩过的坑和解决方案一起说。5.1 鉴权放哪里JSON-RPC因为接口的URL统一不像REST那样方便按路径做粗粒度鉴权。我见过不少项目把鉴权写在业务方法里每个方法第一行都是“校验token”代码重复严重还容易漏掉新加的方法。正确的做法是在Dispatcher之前加一个拦截器统一处理鉴权。用Spring的HandlerInterceptor即可public class JsonRpcAuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(X-Auth-Token); if (token null || !tokenService.validate(token)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } return true; } }如果还要做到方法级别的权限区分可以给JsonRpcMethod注解加一个permission()属性分发器在调用前先判断当前token是否有该权限。这样权限体系和业务逻辑彻底解耦后续接权限系统也方便。5.2 日志与链路追踪JSON-RPC的日志和REST有个很大的不同一个HTTP请求进来URL永远是/api/rpc正常访问日志都是同一个路径根本区分不了请求做了什么。所以你必须把method和id打进入口和出口日志才能做问题排查。我习惯用MDC记录链路信息MDC.put(rpcMethod, methodName); MDC.put(rpcId, String.valueOf(id));这样logback的pattern里一旦配置了%X{rpcMethod}模板里调用的所有日志都会自动带上方法名。一次联调里因为某个方法慢导致超时grep一下日志里的rpcMethod就能快速定位到具体是哪个JSON-RPC方法耗时高。另外一个细节是响应时间统计也建议在分发器这一层做。因为JSON-RPC一个URL承接所有方法靠per-URL的监控完全无效你要按method维度的耗时统计就必须在分发器里包一层System.currentTimeMillis()计算完用log.info打印。5.3 性能实测与常见坑性能方面JSON-RPC比REST本质上没有太多额外开销主要成本都花在Jackson的JSON序列化和反序列化上。我做过一次简单的压力测试Spring Boot默认配置下一个什么都不做的JSON-RPC方法QPS大概在2万到3万之间和生产上REST接口的量级一致。如果把ObjectMapper手动配置成FAIL_ON_UNKNOWN_PROPERTIES关闭把Jackson的序列化缓存打开还能再快一点。但我的建议是别在性能上折腾真正要关注的是下面这些运行时坑id字段不能丢。有些客户端库在发送请求时如果没有给id它内部就会认为这是一个notification默认不接收响应。服务端的逻辑是id为null时返回null响应但很多客户端在这里会直接超时。方法名大小写敏感。order.getById和Order.getById在注册到Map时就是两个key如果不小心代码里写错了大小写返回的是Method not found排查起来还挺隐蔽。建议方法名统一注册时转小写匹配时也转小写。Jackson遇到未知字段。如果调用方多传了一个字段而Java入参没有这个字段默认Jackson会抛UnrecognizedPropertyException导致一个原本应该成功的调用变成-32602错误。我建议在服务端的公共ObjectMapper中关闭这个特性FAIL_ON_UNKNOWN_PROPERTIES false毕竟JSON-RPC调用方经常会多传一些上下文信息服务端没必要那么严格。exception message里的换行符。传给客户端之前建议把消息里的换行符替换为空格否则客户端在记录日志时可能出现日志注入这种安全细节做过安全评审的人都懂。大参数列表问题。JSON-RPC没有限制params的大小但实际生产上我见过有人把一个10MB的base64字符串塞进params里服务端直接内存溢出。建议在Controller层加一个请求体大小的限制比如spring.servlet.multipart.max-request-size不生效的情况下用Tomcat的max-swallow-size或者直接限制Content-Length。如果你还在纠结要不要在Spring Boot里引入JSON-RPC我个人的建议是内部接口、脚本调用、跨语言对接这三种场景放心上如果是对外开放的API继续用REST。我自己做内部服务端时凡是调用方明确表示“我只想调一个函数拿结果”的基本都用这一套JSON-RPC方案服务端代码量不大但接入方的满意度远高于之前让他们理解REST资源设计的时候。最后再分享一下调试JSON-RPC服务端最好用的工具不是Postman而是curl加jq请求体和响应体都是纯JSON一个命令行就能完成验证写自动化测试也要比REST省事得多。本文还有配套的精品资源点击获取
返回列表