简介:jsonRPC.rar 是一套面向 Java Web 开发者的 JSON-RPC 入门案例资源,通过完整可部署的小型工程,演示了远程调用中客户端如何封装请求、服务端如何解析并返回结果,也展示了这类协议在分布式服务和前后端交互中的典型用法。压缩包共 35 个文件,以 25 个 jar 依赖库为主体,涉及 jsonrpc4j、Spring、Jackson 等常用组件,另有 5 个编译后的 class 文件、2 个 xml 配置、作为项目入口的 jsp 页面、属性配置文件及元数据信息,整体大小约 18.86MB;目录按照 META-INF、WEB-INF 和 maven 结构组织,便于直接导入容器部署学习。内容覆盖 JSON-RPC 2.0 的请求与响应格式、参数传递、错误处理等核心机制,并结合 jsonrpc4j 库说明服务端和客户端的实现方法,使读者理解 jsonrpc、method、params、id 等关键字段的含义,掌握 result 与 error 的正确处理方式。目前已有 223 人浏览学习,适合初涉远程调用、希望快速上手 JSON-RPC 的 Java 后端开发者。
1. jsonRPC 到底解决了什么:从 REST 的别扭说起
如果只在内部系统之间做接口调用,REST 用起来其实挺别扭的:一个「查订单」要拆成 URL、查询参数、状态码好几处去约定,前端传参顺序和后端接收顺序稍微错一点,问题就藏在 200 OK 里。jsonRPC 的思路是把「远程调用」重新拉回「函数调用」——你不需要设计资源路径,只需要定义一个方法名,把参数打包成 JSON 发过去,对方执行完把结果再打包回来。这份 jsonRPC.rar 资源里整理的就是这样一套可以直接抄走的处理思路和示例代码,适合正在做微服务内部接口、插件协议或者前后端调试工具链的工程师。它的价值不在「更轻」,而在把「调用一个函数」这件事彻底标准化,让对接双方不用再为 URL 语义和状态码争论。
2. JSON-RPC 协议规范:请求、响应、通知与错误码
2.1 一个合格的请求长什么样:版本、方法、参数与 id
JSON-RPC 2.0 的请求对象有四个关键字段,jsonrpc固定是"2.0",method是你要调用的方法名,params是参数,id用来关联请求和响应。这里的id是最容易被低估的字段:服务端返回响应时必须原样带上请求里的id,客户端才能知道这条响应对应哪一次调用。id可以是数字,也可以是字符串,但整条连接里不能重复。如果id缺失,这个消息就成了「通知」,服务端不会返回任何响应。
{ "jsonrpc": "2.0", "method": "order.query", "params": { "order_id": "A10086", "with_items": true }, "id": 1 }这段请求的意思很直白:调用order.query,传两个具名参数。注意到params用的是对象而不是数组,这是推荐写法。用对象传参时,服务端可以直接按字段名取参数,接口层再加参数校验;用数组传参时协议会要求「顺序一致」,但字段一多就很容易错位。实践中我一般强制团队在跨服务接口里用对象传参,数组形式只保留给那种参数极少且语义极其稳定的内部方法。还有个细节:method里带点号不是必须的,但很多实现会把方法按模块命名,比如order.query、pay.create,这样日志里搜方法名时能直接看出业务域,排查问题时比看一坨路径参数舒服得多。
2.2 响应与错误对象:宁可多写一个 error,也别让调用方猜
正常响应的结构是{"jsonrpc": "2.0", "result": ..., "id": 1},result可以是任意 JSON 类型。但出错时的响应结构才是协议的关键:不能把错误塞进result,必须用error字段,里面包含code、message和可选的data。规范预定义了几个保留错误码:-32700是解析错误,-32600是无效请求,-32601是方法不存在,-32602是参数无效,-32603是内部错误。-32000到-32099留给服务端自定义。
{ "jsonrpc": "2.0", "error": { "code": -32602, "message": "Invalid params: order_id is required", "data": { "field": "order_id", "reason": "missing" } }, "id": 1 }这里有个我反复踩过的点:很多初学者不管遇到什么问题都返回-32603(内部错误),这个行为会让联调变成猜谜游戏。因为message字段在客户端日志里经常会被脱敏或截断,真正能救命的诊断信息应该放进data。比如参数校验失败时,data里写明是哪个字段、缺了什么、期望什么类型;业务规则不满足时,data里带上业务错误码和上下文。一个经验法则是:code用于程序分支判断,message给人看,data给排查用。这样客户端拿到错误后不用反查日志就能定位到具体字段,联调效率完全不一样。
2.3 通知与批量:两种常被忽略但很实用的形态
通知(Notification)是没有id的请求,服务端处理后不返回任何东西。它适合日志上报、缓存预热、异步任务触发这类「发了就行」的场景,能省掉一大半无意义的响应流量。但代价是你永远不知道服务端到底成功没有,所以通知只适合丢得起、可重试的场景,不适合支付、状态变更。批量请求则是把多个请求对象放进一个 JSON 数组一次发出去,服务端返回一个响应数组,顺序可以与请求顺序不一致,因为每个响应都带自己的id。批量里如果混入通知,服务端对通知不会返回对应响应,所以响应数组的元素个数可能比请求数组少,客户端处理时必须按id去关联,不能按数组下标硬算。这两点先记住,后面避坑章节会展开讲。
3. 服务端实现:用 Python 搭建一个可用的 JSON-RPC 服务端
3.1 选型思路:标准库还是框架,资源里通常怎么组织
JSON-RPC 不像 REST 有那么多现成的框架约束,实现自由度很高,这也是为什么这份资源里通常会有多个版本的示例——有人习惯直接基于标准库写,有人喜欢套进现有 Web 框架里。我个人的建议是:如果只是内部工具、脚本或测试桩,直接用 Python 标准库http.server加json就能搞定,零依赖,拿到哪台机器都能跑;如果是要嵌入到生产服务里,再考虑把它挂到 Web 框架的路由上。资源里一般会把「协议解析」和「业务处理」分开写:前面一段负责把原始 JSON 文本解析成请求对象、做版本校验、补错误码,后面一段才是真正的方法分发。这样分层的结构能让你把协议层彻底冻结,业务方法加了减了都不影响协议兼容性。
还需要想清楚一个边界:JSON-RPC 服务端只负责「接收消息、路由方法、返回结果」,连接管理、超时、并发模型都不是协议本身的事,而是承载层的事。如果用 HTTP 承载,并发由 HTTP 服务器处理;如果用 TCP 长连接承载,就得自己管连接池和心跳。初学者最容易犯的错是把承载层和协议层混在一起,导致服务端代码里全是 socket 处理逻辑,协议解析反倒被挤成了配角。你这个资源如果下载下来看到的是那种结构,建议动手重构,把协议解析独立出来,后续维护会轻松很多。
3.2 HTTP 承载还是 TCP 承载:两种方式的适用边界
HTTP 承载是大多数内部服务的首选,原因很实际:HTTP 的请求响应模型天生就和 JSON-RPC 的请求响应模型对得上,而且可以复用现有网关的鉴权、日志、限流能力。内网服务之间用 HTTP 承载 JSON-RPC,客户端代码量很小,出问题时也能用 curl 或浏览器插件直接验证,调试成本低。TCP 承载则适合长连接、推送、双向通信的场景——比如设备接入网关、实时协作系统。但 TCP 承载的成本远高于 HTTP:要自己处理粘包拆包、心跳保活、半包状态,协议还得在 JSON-RPC 外层再包一层长度头。除非有明确的低延迟或推送需求,否则不建议一上来就上 TCP。
资源里如果有 TCP 承载的示例,要注意看它长度头的定义方式。常见的做法是 4 字节大端整数表示 JSON 字节长度,后面紧跟 JSON 数据体。这里有一个很典型的坑:有的实现用len(json.dumps(msg).encode('utf-8'))算长度,有的用len(str)算,中文字段一多,两种算出来的字节数不一样,接收方就会一直读不完整。我一般会在 TCP 版本的示例里强制用字节长度,并在文档里把这一步写死,免得后面翻车。
3.3 一个可跑的服务端示例:解析、路由与错误返回
下面这个例子基于 Python 标准库实现,适合作为资源里服务端代码的骨架参考。它把协议解析、方法分发和错误处理拆成了三个函数,方便你替换成自己的业务方法。
import json from http.server import BaseHTTPRequestHandler, HTTPServer def parse_request(body: str) -> dict: """把原始文本解析成请求对象,任何不合法的输入都抛异常。""" try: msg = json.loads(body) except json.JSONDecodeError: raise ValueError(-32700, "Parse error") if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0": raise ValueError(-32600, "Invalid Request") if "method" not in msg or not isinstance(msg["method"], str): raise ValueError(-32600, "Invalid Request: method is required") return msg def make_error(code: int, message: str, data=None) -> dict: err = {"code": code, "message": message} if data is not None: err["data"] = data return {"jsonrpc": "2.0", "error": err} def call_method(msg: dict) -> dict: """按方法名分发到具体业务处理函数。""" method = msg.get("method") params = msg.get("params", {}) handler = METHODS.get(method) if handler is None: return make_error(-32601, "Method not found", {"method": method}) try: result = handler(**params) if isinstance(params, dict) else handler(*params) except TypeError as e: return make_error(-32602, "Invalid params", {"detail": str(e)}) except Exception as e: return make_error(-32603, "Internal error", {"detail": str(e)}) return {"jsonrpc": "2.0", "result": result, "id": msg.get("id")}这段代码里parse_request把 JSON 解析、版本校验、方法名校验合在一起,任何一步失败都直接抛带错误码的异常。call_method里用了METHODS这个字典做路由表,方法名就是键,值是普通 Python 函数。参数传递这里分了两条路:params是字典时用**params按关键字展开,是列表时用*params按位置展开,这样既能兼容具名参数也能兼容位置参数。TypeError单独捕获,因为参数数量不匹配或字段名不对都会抛这个异常,统一映射成-32602参数无效。
def example_add(a: int, b: int) -> int: return a + b METHODS = {"example.add": example_add} class RPCHandler(BaseHTTPRequestHandler): def do_POST(self): length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length).decode("utf-8") try: msg = parse_request(body) response = call_method(msg) except ValueError as e: response = make_error(e.args[0], e.args[1]) payload = json.dumps(response, ensure_ascii=False).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) if __name__ == "__main__": server = HTTPServer(("0.0.0.0", 9000), RPCHandler) server.serve_forever()RPCHandler只实现了POST,因为 JSON-RPC 的请求都是带消息体的,GET 没有意义。注意这里无论业务成功还是失败,HTTP 状态码一律返回 200,错误信息放在 JSON 的error字段里。这是 JSON-RPC 和 REST 的一个关键区别:REST 喜欢用 HTTP 状态码表达错误语义,但 JSON-RPC 的错误语义只在协议内部表达,HTTP 状态码只负责「消息送达」这层含义。如果你让服务端在业务错误时返回 500,那客户端就要同时处理两套错误逻辑,反而更乱。ensure_ascii=False是让中文消息原样输出,方便在日志里直接看,代价是响应体积稍大,内网环境完全可接受。
4. 客户端调用与批量请求:从单发到并发
4.1 客户端的最小实现:发请求、配对 id、拿结果
客户端比服务端要简单得多,核心就是三件事:构造请求对象、发送、按id配对响应。但配对这一步很多人偷懒,直接按顺序取响应数组的第一个元素,这在单请求时没问题,一旦并发或批量就翻车。我习惯在客户端里维护一个等待id的集合,每次发请求时用一种原子递增的方式生成id,收到响应后从集合里摘掉对应的id再唤醒等待方。这样即使服务端乱序返回,客户端也能正确配对。
import json, urllib.request, itertools _counter = itertools.count(1) def call(url: str, method: str, params=None, timeout=5) -> dict: msg = { "jsonrpc": "2.0", "method": method, "id": next(_counter), } if params is not None: msg["params"] = params req = urllib.request.Request( url, data=json.dumps(msg).encode("utf-8"), headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode("utf-8")) if "error" in data: raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}") return data.get("result")itertools.count生成的id从 1 开始递增,单进程内天然不重复,不用加锁。urlopen的timeout参数是整体读超时,单位是秒,内网调用我一般设 3 到 5 秒。这个函数里如果收到error就直接抛异常,但注意它只适用于「客户端能接受失败」的场景;如果调用方想自己判断错误码做重试或降级,那应该把原始响应返回而不是抛掉。实际使用时我通常再加一层,让这个函数支持一个full_response=False参数,需要完整响应时就把data原样返回,等于同时给两种调用方留了口子。
4.2 批量请求的边界:顺序、超时与部分失败
批量请求的价值在于减少往返次数——一次网络请求里带上多个方法调用,尤其适合初始化场景,比如页面加载时同时取用户信息、配置、权限点。但批量请求有个非常隐蔽的坑:服务端处理批量时可能多个方法并行执行,也可能串行执行,协议本身不管。如果两个方法之间有先后依赖,就不能放进同一个批量里,否则结果不可预期。另一个坑是超时语义:批量请求的超时是整个数组的总超时,如果里面某个方法很慢,会拖垮整批。我一般会把慢方法和快方法拆成两个批量,或者给批量方法单独设置更长的超时。
批量响应还有一个容易出错的地方:响应的顺序。服务端完全可以按任意顺序返回数组里的元素,甚至可以漏掉通知对应的响应。所以客户端处理批量响应时必须先建立一个「id到回调函数」的映射,一个个取出来执行,而不是按位置对号入座。看下面的代码会更清楚:
def batch_call(url: str, calls: list, timeout=10) -> list: payload = [ {"jsonrpc": "2.0", "method": m, "params": p, "id": i} for i, (m, p) in enumerate(calls) ] req = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=timeout) as resp: responses = json.loads(resp.read().decode("utf-8")) lookup = {r["id"]: r for r in responses} result = [] for i in range(len(calls)): r = lookup.get(i) if r is None: result.append({"error": {"code": -32000, "message": "no response"}}) else: result.append(r) return result这里用enumerate给每个请求生成从 0 开始的id,再把响应对应回原来的调用位置。lookup.get(i)取不到时说明服务端漏了某个响应,这种情况要格外警惕:如果传入的候选参数里有通知(没有id),服务端本来就不会返回,这时漏掉是正常的;如果不是通知却漏了,那就要去看服务端是不是在处理过程中丢了消息。no response这种占位错误码是我习惯用的-32000自定义区间,方便上层统一遍历结果而不必做 None 判断。
4.3 并发与连接复用:别让请求建立连接成为瓶颈
用urllib.request每次调用都新建连接,短连接在高频调用的场景下会把大量时间浪费在 TCP 握手上。资源里如果只给了最简客户端,我建议你至少要改成连接复用。Python 里可以用urllib.request配合HTTPConnection的底层接口,更省事的做法是建一个连接池,把同一个目标地址的HTTPConnection实例缓存起来,加一个信号量控制最大并发数。不过内网调用还有个更关键的问题:并发数上去之后,服务端线程模型能不能撑住。比如前面那个HTTPServer例子是单线程的,同时只能处理一个请求,如果你在客户端开了 20 个并发,实际表现就是排队,而不是并行。
我在实践里对并发做两层控制:第一层是客户端限流,用一个简单的信号量把并发数限制在服务端可承受范围内;第二层是给每个方法绑定独立的超时,避免某个慢方法占满连接池。这里有个血泪经验:之前在某公司的内部网关场景里,上游一个统计接口偶尔要跑 8 秒,客户端超时设了 3 秒,但因为连接池被这些慢请求占满了,其他正常接口也跟着超时,整个调用链就像雪崩一样。从那以后我强制所有批量或并发调用都区分「慢方法池」和「快方法池」,不让两类请求挤在一起。
5. 避坑:JSON-RPC 实战中常见的坑与排查方法
5.1 id 类型不稳定,响应对不上号
现象:客户端收到响应后,lookup里找不到对应的id,要么报 KeyError,要么响应被丢弃;服务端日志显示明明处理完了,但客户端一直超时等待。
原因:服务端代码里把id做了类型转换。比如客户端发的是数字1,服务端用某种 ORM 或字典存储时把id变成了字符串"1",返回时id类型就变了。JSON-RPC 规范其实允许id是字符串或数字,但没说服务端必须保持类型一致,很多实现就图省事直接str(id)存起来,结果客户端按数字去匹配就崩了。
解决:客户端和服务端约定id一律用字符串,并且服务端在组装响应时直接取原始请求里的id原样放回,不要做任何类型转换。在代码里可以加一个防御性校验:如果响应里的id在等待集合里找不到,就把响应报文原样打到日志里,而不是直接扔掉。这样至少能区分是类型问题还是消息错乱。
5.2 错误码全返回 -32603,排查像黑匣子
现象:服务端一报错就是-32603 Internal error,客户端日志里看不到任何具体信息,每次都要去服务端翻日志,而且日志里也只有一行「Internal error」,完全不知道哪个方法、哪个参数、什么异常。
原因:业务代码里的异常处理写得过于粗糙,一个裸的except Exception: return internal_error()把所有的异常细节都吞掉了。我之前看过某项目的代码,整个服务端的异常处理就是给前端返回-32603,异常堆栈只打到了后台日志的 DEBUG 级别,线上默认不开,等于什么排查信息都没有。
解决:只在最外层捕获未知异常,并且把repr(e)、方法名、params摘要写进error.data。注意不要原样输出异常字符串里的敏感参数,params里可能有密码或 token,所以我会在代码里先做脱敏再放入data。另外把-32603当成「最后一层保险」,业务代码里每一层都尽量捕获并抛出更具体的错误码,避免错误冒泡到顶层才被打包。
5.3 批量请求里混入通知,响应数组长度对不上
现象:客户端发了一个包含 3 个请求的批量,其中有 1 个是通知,结果服务端只返回了 2 个响应。客户端按「3 个请求对应 3 个响应」去遍历,最后一个元素取不到,逻辑崩掉。
原因:通知的定义就是「服务端不返回响应」,批量里混入通知后,响应数组天然会比请求数组短。这不是服务端的 bug,是协议本身的规则。问题出在客户端写死了长度对应关系。
解决:客户端在处理批量响应时一律按id关联,而不是按数组位置对应;同时在构造批量前明确标记哪些是通知,把这些通知从等待集合里排除掉。如果批量是程序动态拼出来的,比如根据配置决定要不要加通知,那就在代码里维护一个expect_response的集合,响应来了先查这个集合,不在集合里的直接忽略。
5.4 params 传参方式混乱:一会儿数组一会儿对象
现象:同一个方法,有的调用方传{"a": 1, "b": 2},有的传[1, 2],服务端里有的处理函数支持关键字参数,有的只支持位置参数,结果参数顺序一错就返回-32602。
原因:接口设计时没规定死params的类型。JSON-RPC 规范允许数组和对象两种形式,但如果服务端方法本身用def handler(a, b)定义,收到数组[2, 1]时就会变成a=2, b=1,业务逻辑直接错位。很多项目上线前不会覆盖这种「参数形态混用」的测试用例,直到联调炸了才反应过来。
解决:在协议层统一强制用对象传参。服务端在call_method里如果发现params是数组,可以拒绝执行并返回-32602,提示调用方改用对象传参;如果是兼容老客户端不方便拒绝,就定义一个统一的ABTest式适配层,把数组转换成对象。不要在每个业务函数里单独判断参数类型,那样等于把协议规则散落到业务代码里,后期根本没有维护性。
5.5 大字段响应超时,问题却不在协议
现象:某个方法返回的数据比较大,比如一次查出几千条订单,客户端经常超时,但服务端日志显示处理时间只有几十毫秒,方法本身很快。
原因:响应体太大,序列化和传输占用了大量时间;或者是服务端日志打得太完整,每条订单都打一行 JSON,把处理线程拖住了。还有一种是客户端超时设置太短,批量里混着大字段请求,把整批拖垮。
解决:先分清楚瓶颈在哪。在服务端打印出「方法执行耗时」和「序列化后字节数」两个指标,如果方法执行很快但序列化后字节数很大,大概率是传输问题。这个时候有两个方向:一是把大字段拆成子方法,列表接口不返回全量详情,等调用方需要时再逐个查;二是压缩响应,内网环境可以用gzip,代价是两端都要处理解压,代码量会多一点点。常规做法是在网关层统一压缩,而不是每个业务方法自己处理。如果确定是传输问题,优先考虑拆字段,压缩是最后的兜底手段。
6. 进阶:给 JSON-RPC 加上鉴权、超时与统一错误码
这一层做完,这套 jsonRPC 资源才算真正能上生产。鉴权不复杂:在 HTTP 头里加Authorization,服务端在路由分发前先做校验。资源里的示例如果没带鉴权,你可以在RPCHandler.do_POST的最前面加一段检查,不通过直接返回-32001自定义错误码。关键是错误码体系要用好-32000到-32099这个自定义区间。
| 错误码 | 场景 | 客户端处理方式 |
|---|---|---|
| -32001 | 未认证或 token 失效 | 重新登录后重试 |
| -32002 | 请求频率超限 | 退避重试或降级 |
| -32003 | 业务规则拦截 | 提示用户,不重试 |
| -32004 | 上游依赖服务超时 | 熔断或降级 |
超时这一层我建议做两级:客户端连接超时设短一点,比如 3 秒;整体读超时按方法单独配置。批量请求则把整批超时设置成「方法数 × 单方法超时」的乘积再乘一个修正系数,不要在代码里写死一个通用值。最后再做一件事:每次上线前,用一组预先构造的非法输入和服务端做一轮协议自测,覆盖未知方法、参数类型不匹配、批量缺 id、超长消息体这几个场景,把这些用例固化成一个脚本,和代码一起进仓库。
这套做法并不是什么高深技巧,而是我从几次联调事故里攒出来的习惯。最早我图省事,觉得服务端能跑通就行,结果上线第一天就遇到参数类型不匹配的问题,客户端传的是{"order_id": 10086},服务端方法签名是def query(order_id: str),校验直接失败,错误信息还是-32603,双方排查了大半天。从那以后我每次接 JSON-RPC 项目都会强制走一遍「错误码分层 + 鉴权前置 + 超时按方法隔离」这三板斧,没有再在类似问题上翻过车。希望帮到你。
本文还有配套的精品资源,点击获取