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

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

Spring Boot手写JSON-RPC Server:不引依赖实现轻量RPC服务 ★ FEATURED ARTICLE
简介面向 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 ApplicationListenerContextRefreshedEvent { private final MethodRegistry registry; public JsonRpcMethodScanner(MethodRegistry registry) { this.registry registry; } Override public void onApplicationEvent(ContextRefreshedEvent event) { ApplicationContext context event.getApplicationContext(); MapString, 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 字符串解析成统一的请求对象。项目里没有用MapString, 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.javaannotation包放两个注解core包放分发器、注册表、请求响应模型controller只留 HTTP 入口service里是示例业务方法config负责注册 Jackson 相关 Bean。分层的原则是core 不依赖 spring-web如果哪天想换成 Netty 或纯 Servletcore 包能原样搬走。4.2 启动依赖与 Maven 配置项目基于 Spring Boot 3.xJava 环境要求 17 以上。Maven 依赖极其精简核心只有spring-boot-starter-webJackson 随 web starter 自动引入不需要额外配置序列化器。dependency groupIdorg.springframework.boot/groupId artifactIdspring-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_id1): payload { jsonrpc: 2.0, method: method, params: params, id: rpc_id } data json.dumps(payload).encode(utf-8) req urllib.request.Request( url, datadata, headers{Content-Type: application/json}, methodPOST ) 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_asciiFalse, indent2))脚本验证了两个关键点请求体必须 UTF-8 编码响应里的中文要保证正常显示。实际项目里用这个脚本做联调回归非常顺手改一次服务端代码跑一遍脚本基本能覆盖大部分参数绑定和返回序列化问题。5. 常见的坑与排查参数丢失、布尔颠倒、中文乱码5.1 多参数绑定时参数名全部变成 arg0、arg1现象请求里按参数名传参服务端方法收到的参数全是 null。原因Java 编译时默认不保留方法参数名反射拿到的参数名退化成arg0、arg1。项目里的parameterNameDiscoverer转成 arg0 后自然匹配不上请求里的userId。解决Maven 编译插件显式加parameterstrue/parameters或者在pom.xml里配maven-compiler-plugin的 compilerArgs。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration parameterstrue/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-responsetrue。项目里这两种配置都做了加上前端解析时按 UTF-8 解码中文再也没出过问题。5.4 方法不存在但日志里没有异常现象请求一个明显存在的方法返回-32601但服务端日志干干净净。原因注册表扫描时间早于 service 类初始化或者 service 类没有被 Spring 管理。这种情况常见于 service 类漏加了Service注解——类实现了JsonRpcService但本身没有注册成 BeangetBeansWithAnnotation自然扫不到。解决启动日志里如果缺方法注册信息第一反应检查这个类是不是 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是硬编码进返回对象的。更好的做法是引入一个RpcResultT泛型包装服务方法返回它分发器直接透传public class RpcResultT { private int code; private String message; private T data; public static T RpcResultT ok(T data) { RpcResultT result new RpcResult(); result.code 200; result.message success; result.data data; return result; } }改造时 service 方法返回类型统一改成RpcResultUserInfo单独抽一个RpcResultSerializer注册进 Jackson 的 ObjectMapper。因为RpcResult里有泛型字段序列化时最好用TypeReference保留类型信息避免data反序列化后变成LinkedHashMap。这个坑在给调用方做 SDK 封装时特别明显要么服务端返回JsonNode保留原始结构要么调用方强制转类型。第三个方向是让 JSON-RPC 和 REST 共存。新接口走 RPC老接口先不动控制器层只需要在RequestMapping上限定好路径两者互不干扰。项目里实际就是这么做的对外网关还是 REST内部服务间调用切到 JSON-RPC 协议藕合度明显降低。从那以后我每次新起服务都会强制走一遍启动日志看方法注册表、curl 跑单请求、python 脚本跑批量请求、再校验一次参数名编译配置。这套流程能挡住大部分低级错误。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站