简介:这是一份基于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重在极致的简单和跨语言友好。三者放在一起做个对比更直观:
| 维度 | REST | JSON-RPC | gRPC |
|---|---|---|---|
| 消息格式 | JSON/XML等 | JSON | Protobuf |
| 接口语义 | 资源操作(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 Map<String, Object> handle(@RequestBody Map<String, 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 List<OrderVO> 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 Map<String, MethodInvoker> methodMap = new ConcurrentHashMap<>(); public JsonRpcRegistry(ApplicationContext context) { Map<String, 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 Map<String, Object> dispatch(Map<String, 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")); Map<String, 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()]; Map<String, Object> paramMap = params instanceof Map ? (Map<String, 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等注解声明约束,分发器在调用方法前对参数对象执行Validation:
Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); Set<ConstraintViolation<Object>> 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和message,data可选。错误响应整体如下:
{ "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 Map<String, Object> handleJsonRpcException(JsonRpcException e, HttpServletRequest request) { return responseWithError(request, e.getCode(), e.getMessage(), e.getData()); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) public Map<String, Object> handleTypeMismatch(MethodArgumentTypeMismatchException e) { return responseWithError(null, -32602, "Invalid params: " + e.getName(), null); } @ExceptionHandler(Exception.class) public Map<String, 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省事得多。
本文还有配套的精品资源,点击获取