ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot手写JSON-RPC Server:不引依赖实现轻量RPC服务

Spring Boot手写JSON-RPC Server:不引依赖实现轻量RPC服务

简介:面向 Java 开发者的 Spring Boot 集成 JSON-RPC 2.0 服务端工程示例,演示了如何通过标准 JSON-RPC 协议对外暴露方法。工程内置 multiplier 乘法方法,客户端发送包含 id、jsonrpc、method、params 字段的 JSON 请求,例如指定方法名为 multiplier、传入 5 和 8 两个参数时,服务端会返回对应结果 40,交互模式简洁清晰,适合前后端分离、跨语言服务调用及轻量级 RPC 接口项目,也适合对比理解 RPC 与普通 Web 接口的差异。资源包共 26 个文件,压缩后仅 55KB,主要包含 5 个 Java 源码、5 个 class 编译文件、4 个 properties 配置、2 个 XML 工程文件,以及 Maven Wrapper、jar 包、classpath 与 project 辅助文件;目录保留 src/main、src/test、target 标准 Maven 结构,便于直接导入 IDE 查看和运行,target 下保留编译产物,适合对照源码与测试用例理解构建过程。已有 299 人浏览学习,可将工程作为脚手架,快速扩展自己的 JSON-RPC 业务方法,同时能够通过现成示例掌握请求解析、方法分发、参数绑定及结果返回的完整流程。

1. 用 Spring Boot 手写 JSON-RPC Server:不引依赖也能做完整 RPC

某业务系统有段时间被内部服务间的接口规范折腾得够呛:REST 接口每个团队各写各的,路径、状态码、错误体全对不上。换来换去之后我们把一部分核心调用切成 jsonrpc 2.0 协议,服务端就是在 Spring Boot 之上手写了一个轻量 JSON-RPC server,不引额外的 RPC 框架,就靠一个注解、一个分发器、一层 Jackson 绑定,把方法调用变成了标准化的 JSON 请求。这个项目适合两类人:想在 springboot 服务里快速提供 jsonrpc 接口、又不想被重框架绑架的人;以及想搞懂 RPC 服务端“方法注册—请求分发—参数绑定”这条链路,拿现成源码边跑边改的 java 开发者。文章后面所有代码都出自这个可运行的 server 工程,跟着顺序能直接落地。

2. 协议先行:JSON-RPC 2.0 的请求/响应结构与方法注册表设计

2.1 请求与响应:一个方法调用就是一段 JSON 文案

JSON-RPC 2.0 的特点是协议描述极其精简,请求就是一个带jsonrpc、method、params、id的 JSON 对象。相比 REST 动辄设计一堆路径和资源模型,JSON-RPC 只需要约定“方法名 + 参数表”。项目里请求体长这样:

{ "jsonrpc": "2.0", "method": "UserService.getUserById", "params": { "userId": 1024 }, "id": 1 }

服务端通过method字段定位到具体业务方法,params交给 Jackson 反序列化成目标类型。id用于关联请求和响应,调用方发异步批量请求时靠它把结果配对。协议要求只要请求里有id,响应必须携带相同id;没有id的请求表示通知类消息,服务端处理完不返回任何内容。

响应对象分三种形态:成功返回result字段,业务失败返回error对象,协议层解析失败返回带错误码的 error。项目里统一的成功响应如下:

{ "jsonrpc": "2.0", "result": { "code": 200, "message": "success", "data": { "userId": 1024, "userName": "张三" } }, "id": 1 }

result内部再包一层业务状态码,是为了兼容调用方已有的结果判断逻辑。这套结构的好处是:不管底层方法返回值是什么,从协议层看响应永远只有“成功/失败”两种大形态,统一处理超时、重试、日志埋点都变得简单。

2.2 方法注册表:注解声明优于配置文件

项目没有用 XML 或 properties 声明方法映射,而是用两个注解完成注册。@JsonRpcService标注在 service 类上,指定命名空间;@JsonRpcMethod标注在方法上,指定方法名。服务启动后通过 Spring 的ApplicationContext扫描所有带注解的 Bean,把“serviceName + methodName”拼成完整方法键存进注册表。

@JsonRpcService("UserService") public class UserService { @JsonRpcMethod("getUserById") public UserInfo getUserById(Long userId) { // 业务查询逻辑 return userMapper.selectById(userId); } }

完整方法名是UserService.getUserById,请求里的 method 字段必须精确匹配。注册表在项目里被设计成 ConcurrentHashMap,避免并发注册时的线程安全问题。扫描逻辑对应上面的设计:

@Component public class JsonRpcMethodScanner implements ApplicationListener<ContextRefreshedEvent> { private final MethodRegistry registry; public JsonRpcMethodScanner(MethodRegistry registry) { this.registry = registry; } @Override public void onApplicationEvent(ContextRefreshedEvent event) { ApplicationContext context = event.getApplicationContext(); Map<String, Object> beans = context.getBeansWithAnnotation(JsonRpcService.class); beans.values().forEach(bean -> { JsonRpcService serviceAnnotation = bean.getClass().getAnnotation(JsonRpcService.class); String serviceName = serviceAnnotation.value(); for (Method method : bean.getClass().getDeclaredMethods()) { if (method.isAnnotationPresent(JsonRpcMethod.class)) { JsonRpcMethod methodAnnotation = method.getAnnotation(JsonRpcMethod.class); String methodName = methodAnnotation.value(); registry.register(serviceName + "." + methodName, bean, method); } } }); } }

这里留意两个细节。第一,ContextRefreshedEvent保证所有单例 Bean 初始化完成后再扫描,漏扫的坑基本不会踩到。第二,getBeansWithAnnotation返回的是带注解的 Bean,但 Spring 可能生成代理类,所以扫描方法时需要遍历接口方法或处理代理,项目里用AopUtils.getTargetClass获取真实类再做反射,避免扫描结果为空。

2.3 方法命名空间与批量请求

方法名带命名空间之后,服务间调用关系会清晰很多。某跨平台系统里同时有订单服务和用户服务,请求OrderService.createOrder与UserService.getUserById不会撞车;调用方 SDK 里也能直接按服务名分组管理方法列表。项目还支持 JSON-RPC 2.0 的批量请求,也就是请求体是数组:

[ {"jsonrpc": "2.0", "method": "UserService.getUserById", "params": {"userId": 1}, "id": 1}, {"jsonrpc": "2.0", "method": "OrderService.getOrderById", "params": {"orderId": 888}, "id": 2} ]

服务端批量处理的实现并不复杂,把数组逐条走同一套分发逻辑,最后把所有响应组装成一个数组返回。这个能力在网关聚合场景里非常有用,一次网络往返拿到多个服务的数据。

3. 核心模块拆解:一条 JSON 请求从进入到返回的完整链路

3.1 请求解析:Jackson 反序列化与类型容错

HTTP 层收到请求后先交给JsonRpcDispatcher,第一步是把 JSON 字符串解析成统一的请求对象。项目里没有用Map<String, Object>裸奔,而是定义了一个JsonRpcRequest类,字段固定之后后续代码可读性会好很多。

public class JsonRpcRequest { private String jsonrpc; private String method; private JsonNode params; private Long id; public boolean isNotification() { return id == null; } }

params用JsonNode而不是强类型对象,是因为参数可能是对象、数组、字符串甚至 null,先保持原始节点,等到真正调用方法时再按目标参数类型转换。id用Long是因为协议标准里 id 可以是字符串或数字,但在 java 侧大部分调用方传的都是数字,统一成 Long 够用且简单。

解析时最常踩的坑是 JSON 语法错误,项目对解析失败返回-32700 解析错误;如果 JSON 合法但缺少method字段,返回-32600 无效请求。这两个错误码是协议标准内置的,异常分支下响应对象里id为 null,调用方拿到后直接按协议错误处理。

3.2 方法分发:反射调用与参数绑定

请求解析完进入分发核心逻辑。分发器做的事情可以概括成三步:查注册表、绑定参数、反射调用。

public JsonRpcResponse dispatch(JsonRpcRequest request) { JsonRpcResponse response = new JsonRpcResponse(); response.setJsonrpc("2.0"); response.setId(request.getId()); MethodBinding binding = registry.lookup(request.getMethod()); if (binding == null) { response.setError(new JsonRpcError(-32601, "Method not found: " + request.getMethod(), null)); return response; } try { Object[] args = bindParameters(request.getParams(), binding.getMethod()); Object result = binding.getMethod().invoke(binding.getTarget(), args); response.setResult(result); } catch (JsonRpcParamException e) { response.setError(new JsonRpcError(-32602, "Invalid params: " + e.getMessage(), null)); } catch (InvocationTargetException e) { Throwable cause = e.getTargetException(); response.setError(new JsonRpcError(-32603, cause.getMessage(), null)); } catch (Exception e) { response.setError(new JsonRpcError(-32603, "Internal server error", null)); } return response; }

bindParameters是整个分发器里最值得细看的部分,它要处理三种 param 形态。如果方法只有一个参数且params是 JSON 对象,直接把整个对象反序列化成该参数类型;如果params是数组,按方法参数顺序逐个转换;如果params是对象且方法有多个参数,按参数名匹配。多参数按名匹配是默认策略,因为调用方可读性最好。

private Object[] bindParameters(JsonNode paramsNode, Method method) throws JsonRpcParamException { Class<?>[] paramTypes = method.getParameterTypes(); Object[] args = new Object[paramTypes.length]; if (paramsNode == null || paramsNode.isNull()) { return args; } try { if (paramsNode.isArray()) { for (int i = 0; i < paramTypes.length; i++) { args[i] = objectMapper.treeToValue(paramsNode.get(i), paramTypes[i]); } } else if (paramsNode.isObject() && paramTypes.length == 1) { args[0] = objectMapper.treeToValue(paramsNode, paramTypes[0]); } else if (paramsNode.isObject()) { for (int i = 0; i < paramTypes.length; i++) { String paramName = parameterNameDiscoverer.getParameterNames(method)[i]; JsonNode valueNode = paramsNode.get(paramName); if (valueNode != null) { args[i] = objectMapper.treeToValue(valueNode, paramTypes[i]); } } } } catch (Exception e) { throw new JsonRpcParamException(e.getMessage(), e); } return args; }

逻辑要点:数组形态按位置绑定,只要调用方顺序一致就不会错;对象形态单参数直接整体转换,多参数需要从字节码里拿参数名。parameterNameDiscoverer底层走的是 Spring 的LocalVariableTableParameterNameDiscoverer,编译时必须加-parameters参数,否则参数名拿不到会退化成 arg0、arg1,后面避坑章节细说。

3.3 错误处理:错误码规范与日志落点

错误处理如果不统一,调用方排查问题会非常痛苦。项目把错误码收敛成五个场景:-32700解析错误、-32600无效请求、-32601方法不存在、-32602参数无效、-32603内部错误。所有业务异常统一包成内部错误,返回给调用方时只带异常 message,不堆栈。

public class JsonRpcError { private int code; private String message; private Object data; public JsonRpcError(int code, String message, Object data) { this.code = code; this.message = message; this.data = data; } public int getCode() { return code; } public String getMessage() { return message; } public Object getData() { return data; } }

项目里所有异常都往JsonRpcError收敛,data字段留作扩展,比如携带请求 id 或业务追踪号。内部错误出现时,服务端日志要打完整堆栈,响应里只给 message。这样调用方拿到-32603后知道去哪份日志里翻真正的原因。

3.4 HTTP 层适配:为什么所有请求都走 POST

协议层是纯 JSON 解析,但最终还是要落到 HTTP 上。项目用@RestController暴露一个固定端点接收所有 JSON-RPC 请求,不走 REST 的多路径设计。这样网关、负载均衡、监控报警只需要关心这一个 URL 的流量。

@RestController public class JsonRpcController { private final JsonRpcDispatcher dispatcher; public JsonRpcController(JsonRpcDispatcher dispatcher) { this.dispatcher = dispatcher; } @PostMapping(value = "/api/rpc", produces = MediaType.APPLICATION_JSON_VALUE) public JsonNode handle(@RequestBody String requestBody) { JsonNode requestNode; try { requestNode = objectMapper.readTree(requestBody); } catch (JsonProcessingException e) { return buildErrorResponse(null, -32700, "Parse error"); } if (requestNode.isArray()) { // 批量请求逐条分发 ArrayNode responses = objectMapper.createArrayNode(); requestNode.forEach(item -> responses.add(dispatcher.dispatch(item))); return responses; } return dispatcher.dispatch(requestNode); } }

Controller 里没有业务逻辑,整个 HTTP 层薄得像一张纸。readTree先把请求解析成JsonNode,数组就拆开逐条处理,单对象直接丢给分发器。这样做的好处是批量请求和单请求共用同一套协议错误处理,HTTP 状态码统一返回 200,业务错误全部放进响应体。

4. 跑通工程:启动方式、目录结构与完整调用示例

4.1 工程目录结构

项目的骨架很干净,源码包下载解压后看一眼目录就能分清楚职责:

src/main/java/com/example/jsonrpc/ ├── JsonRpcServerApplication.java ├── annotation/ │ ├── JsonRpcService.java │ └── JsonRpcMethod.java ├── core/ │ ├── JsonRpcDispatcher.java │ ├── JsonRpcRequest.java │ ├── JsonRpcResponse.java │ ├── JsonRpcError.java │ ├── MethodRegistry.java │ ├── MethodBinding.java │ └── JsonRpcMethodScanner.java ├── controller/ │ └── JsonRpcController.java ├── service/ │ ├── UserService.java │ └── OrderService.java └── config/ └── JsonRpcConfig.java

annotation包放两个注解;core包放分发器、注册表、请求响应模型;controller只留 HTTP 入口;service里是示例业务方法;config负责注册 Jackson 相关 Bean。分层的原则是:core 不依赖 spring-web,如果哪天想换成 Netty 或纯 Servlet,core 包能原样搬走。

4.2 启动依赖与 Maven 配置

项目基于 Spring Boot 3.x,Java 环境要求 17 以上。Maven 依赖极其精简,核心只有spring-boot-starter-web,Jackson 随 web starter 自动引入,不需要额外配置序列化器。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>

启动方式和普通 Spring Boot 应用完全一样:

mvn spring-boot:run

启动日志里能看到方法注册表打印的已注册方法列表。项目在JsonRpcConfig里放了一个ApplicationRunner,启动完成后遍历注册表,把每个方法名打印出来。这一步非常重要——如果某个 service 方法没出现在日志里,后面调用必然返回-32601 方法不存在。

4.3 用 curl 验证单请求与批量请求

服务启动后,先拿最简单的请求验证链路通不通:

curl -X POST http://localhost:8080/api/rpc \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"UserService.getUserById","params":{"userId":1024},"id":1}'

正常返回的 JSON 如下:

{ "jsonrpc": "2.0", "result": { "code": 200, "message": "success", "data": { "userId": 1024, "userName": "张三" } }, "id": 1 }

再测一个批量请求,把两个方法放进数组:

curl -X POST http://localhost:8080/api/rpc \ -H "Content-Type: application/json" \ -d '[{"jsonrpc":"2.0","method":"UserService.getUserById","params":{"userId":1},"id":1},{"jsonrpc":"2.0","method":"OrderService.getOrderById","params":{"orderId":888},"id":2}]'

响应顺序与请求顺序一致,每个响应里的id字段可以配对。如果调用方需要并发处理,可以一次性把多个方法打包发送,服务端串行处理但整体性能依旧优于多次网络往返。

4.4 自写一个 Python 测试客户端

curl 能验证连通性,但真实调用方逻辑更复杂。项目仓库里附带了一个client.py测试脚本,用标准库urllib实现,不依赖 requests,任何机器都能跑:

import json import urllib.request def rpc_call(url, method, params, rpc_id=1): payload = { "jsonrpc": "2.0", "method": method, "params": params, "id": rpc_id } data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( url, data=data, headers={"Content-Type": "application/json"}, method="POST" ) with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode("utf-8")) if __name__ == "__main__": result = rpc_call("http://localhost:8080/api/rpc", "UserService.getUserById", {"userId": 1024}) print(json.dumps(result, ensure_ascii=False, indent=2))

脚本验证了两个关键点:请求体必须 UTF-8 编码,响应里的中文要保证正常显示。实际项目里用这个脚本做联调回归非常顺手,改一次服务端代码跑一遍脚本,基本能覆盖大部分参数绑定和返回序列化问题。

5. 常见的坑与排查:参数丢失、布尔颠倒、中文乱码

5.1 多参数绑定时参数名全部变成 arg0、arg1

现象:请求里按参数名传参,服务端方法收到的参数全是 null。原因:Java 编译时默认不保留方法参数名,反射拿到的参数名退化成arg0、arg1。项目里的parameterNameDiscoverer转成 arg0 后自然匹配不上请求里的userId。解决:Maven 编译插件显式加<parameters>true</parameters>,或者在pom.xml里配maven-compiler-plugin的 compilerArgs。

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <parameters>true</parameters> </configuration> </plugin>

改完配置重新启动,方法名就能正确解析。这个坑排查起来最费时间,因为服务端不报错,只是参数静默为 null,我当时是打了日志才发现反射拿到的参数名不对。

5.2 Java 布尔与 JSON 布尔的反序列化翻车

现象:调用方传"params": {"available": true},服务端接到的对象里available一直是false。原因:JsonNode.get("available")返回的是BooleanNode,直接asBoolean()没问题,但如果参数是包装类型Boolean且 JSON 字段缺失,treeToValue会回退成默认值false,没法区分“没传”和“传了 false”。解决:实体类里用Boolean包装类型,不要用基本类型boolean;如果必须区分,在方法里对JsonNode先做isMissingNode判断再转。

这类问题在对象参数场景下最容易出现。我的习惯是:所有 RPC 入参对象的字段全部用包装类型,前端传 false 就是 false,不传就是 null,业务层再自己做空值策略。

5.3 中文乱码:响应里的汉字变成问号

现象:curl 调用 getUserById 返回的中文字段全部是问号。原因:Spring 默认响应编码可能不是 UTF-8,或者 controller 返回JsonNode时没有指定 produces。解决:Controller 的@PostMapping里加produces = MediaType.APPLICATION_JSON_VALUE,并在application.yml里显式配server.servlet.encoding.force-response=true。项目里这两种配置都做了,加上前端解析时按 UTF-8 解码,中文再也没出过问题。

5.4 方法不存在但日志里没有异常

现象:请求一个明显存在的方法,返回-32601,但服务端日志干干净净。原因:注册表扫描时间早于 service 类初始化,或者 service 类没有被 Spring 管理。这种情况常见于 service 类漏加了@Service注解——类实现了@JsonRpcService,但本身没有注册成 Bean,getBeansWithAnnotation自然扫不到。解决:启动日志里如果缺方法注册信息,第一反应检查这个类是不是 Spring Bean;第二反应检查类上有没有被 AOP 代理,代理类注解获取要用AnnotationUtils.findAnnotation,我的项目里已经做了兼容。

5.5 批量请求中一个方法报错,整个响应消失

现象:批量数组里某条请求参数不合法,服务端返回的整个响应体是 null。原因:早期实现对批量数组直接dispatcher.dispatch,其中一条抛未捕获异常直接污染整个响应组装。解决:批量处理时每条请求单独 try-catch,任何单条异常都转化成JsonRpcResponse的错误对象,最后保证数组响应永远有值。现在的实现里,数组请求无论内部出多少错,HTTP 层一定会返回一个数组,每条元素要么是 result 要么是 error。

6. 扩展改造:加全局鉴权、统一响应包装与 REST 混合共存

二次开发最常遇到的需求是鉴权。JSON-RPC 服务暴露在公网时必须加一层验证,项目里可以通过 Spring 的HandlerInterceptor统一拦/api/rpc路径,拦截器里校验 header 的 token,校验不过直接返回协议级错误码:

@Component public class JsonRpcAuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { String token = request.getHeader("X-RPC-Token"); if (!"expected-token".equals(token)) { response.setStatus(200); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); response.getWriter().write( "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"auth failed\"},\"id\":null}" ); return false; } return true; } }

拦截器里把 HTTP 状态码固定成 200,错误塞进协议响应体,调用方拿到后按业务错误处理,不会因为 HTTP 非 200 被网关拦截。token 校验逻辑可以换成接入内部认证中心,这段代码基本不用变。

第二个改造点是把业务返回值统一包装。现在UserService.getUserById直接返回UserInfo对象,响应里result层的code、message是硬编码进返回对象的。更好的做法是引入一个RpcResult<T>泛型包装,服务方法返回它,分发器直接透传:

public class RpcResult<T> { private int code; private String message; private T data; public static <T> RpcResult<T> ok(T data) { RpcResult<T> result = new RpcResult<>(); result.code = 200; result.message = "success"; result.data = data; return result; } }

改造时 service 方法返回类型统一改成RpcResult<UserInfo>,单独抽一个RpcResultSerializer注册进 Jackson 的 ObjectMapper。因为RpcResult里有泛型字段,序列化时最好用TypeReference保留类型信息,避免data反序列化后变成LinkedHashMap。这个坑在给调用方做 SDK 封装时特别明显,要么服务端返回JsonNode保留原始结构,要么调用方强制转类型。

第三个方向是让 JSON-RPC 和 REST 共存。新接口走 RPC,老接口先不动,控制器层只需要在@RequestMapping上限定好路径,两者互不干扰。项目里实际就是这么做的:对外网关还是 REST,内部服务间调用切到 JSON-RPC 协议,藕合度明显降低。

从那以后我每次新起服务,都会强制走一遍:启动日志看方法注册表、curl 跑单请求、python 脚本跑批量请求、再校验一次参数名编译配置。这套流程能挡住大部分低级错误。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进