☰
JSON-RPC 2.0 实战:从协议到 Flask 服务端与客户端调用
2026/10/19 5:36:20 网站建设 项目流程

简介:一份面向 Java Web 开发者的 JSON-RPC 入门案例,精选 jsonrpc4j 及配套依赖,解决从接口调用到工程部署的实践缺口,适合初中级后端工程师快速掌握远程过程调用。资源包共 35 个文件、18.86MB,以 25 个 jar 库为主体,涵盖 Spring、Jackson、jsonrpc4j 等核心组件;同时包含 5 个编译后 class、2 个 xml 配置、1 个 jsp 页面和 properties 元信息,并附有 META-INF 下的 MANIFEST.MF 元数据,可借 WEB-INF 与 META-INF 目录快速理清 Web 应用结构。案例通过 index.jsp 构建客户端请求,配合 web.xml 路由到服务端处理逻辑,完整演示 JSON-RPC 2.0 标准中 jsonrpc、method、params、id 字段的组装与解析,并展示成功返回 result 与异常返回 error 的响应结构,兼顾错误处理机制,帮助理解 HTTP 传输层下的远程调用语义。已吸引 223 人学习下载,读者可依托该工程在 Tomcat 等常见容器中直接部署验证,从请求构造、服务端方法映射到结果回传形成完整闭环,是理论与实践衔接的高效入门材料。

1. jsonRPC 到底能解决什么问题:先看清协议边界再动手

干过前后端联调的人都有这种体会:REST 接口看着规范,实际对接时字段对齐、错误码约定、超时重试全靠嘴上对,最后查问题查到最后发现是两边对参数的理解不一样。jsonRPC 就是来治这个病的,它把请求和响应的格式固定死,服务端只认一个入口,客户端按模板填字段,出错时错误码是协议级的,不再各自发明一套。适合的场景很明确:内部服务间调用、前端到网关的轻量透传、多端统一接口契约。不适合大文件传输和复杂查询条件组合。这套资源包就是围绕 JSON-RPC 2.0 做的一套可运行样例,解压后能看协议文档、能跑服务端、能调客户端,主打一个「照着改就能用」。

2. 协议规范与资源包结构:JSON-RPC 2.0 的四个字段先吃透

2.1 请求对象的四个字段,一个都不能含糊

JSON-RPC 2.0 的请求就是一个 JSON 对象,真正决定协议语义的只有四个字段:jsonrpc、method、params、id。jsonrpc固定写成"2.0",这是版本号,协议规定必须存在且必须精确等于这个字符串,大小写都不能错。method是方法名,字符串,规范保留rpc.开头的方法名给内部协议用,客户端别用,服务端见到这类方法应该直接返回Method not found。

params最容易被误解,它可以是数组,也可以是对象。数组对应按位置传参,对象对应按关键字传参,两者语义完全不同。服务端拿到params后要先判断类型再决定怎么调用,我在后面的服务端代码里会给出两种分支。id是请求的唯一标识,规范允许 string、number 或 null,但不能没有。客户端用 id 来匹配响应:哪个 id 回来了,就知道是哪个请求的结果。

下面是一份标准请求样例:

{"jsonrpc": "2.0", "method": "add", "params": [2, 3], "id": 1}

逻辑上这个请求告诉服务端三件事:协议版本是 2.0、要调用add方法、参数是2和3。id为 1 表示这是我的第 1 个调用。服务端必须用相同的 id 返回结果。注意两个细节:params可以省略,但省略后服务端要按空对象处理;id如果不存在,这就不再是普通请求,而是通知,下一节讲。

2.2 响应结构、错误码与通知语义

响应对象同样是一个 JSON 对象,包含jsonrpc、result或error、id。result和error不能同时出现,也不能同时缺席。error是错误对象,固定有三个字段的位置:code数字、message字符串、data可选。错误码是协议级约定的,客户端可以放心用这些码做分支判断。

错误码含义说明
-32700解析错误请求不是合法 JSON
-32600无效请求JSON 合法但对象结构不对
-32601方法未找到method 不存在或保留前缀
-32602无效参数params 类型不对或参数缺失
-32603内部错误服务端执行方法时抛出异常
-32000 至 -32099服务端保留自定义错误用这个区间

通知请求和普通请求长得一样,唯一区别是没有id。既然没有标识,服务端就不需要返回任何内容,客户端也不该等响应。在批量请求里,通知的处理尤其要小心:服务端处理完通知后不能产生响应项,否则客户端按 id 配对时会直接错位。

2.3 资源包里有什么

jsonRPC.rar 解压后是文档加源码加调试脚本的组合。protocol.md是协议要点说明,server.py是可直接启动的 Flask 服务端,client.py是 Python 客户端封装,client.js是浏览器端 fetch 调用示例,curl_examples.sh是常用的 curl 调试命令集,README.md写明了安装依赖、启动顺序和端口配置。这套结构覆盖了「先读懂协议、再跑通服务端、再调客户端」的完整路径,比单纯看协议原文省事得多。

3. 服务端实现:Python + Flask 版本与并发参数怎么设

3.1 为什么选 Flask 而不是自己写 TCP 服务

JSON-RPC 本质上只是应用层协议,底层用 HTTP 传输最常见,也最省事。HTTP 帮你解决了连接管理、超时、代理、负载均衡这些基础设施问题,自己写 TCP 服务端还得处理粘包、半包、心跳,得不偿失。在 Python 环境里我一般直接选 Flask,因为路由就是那个入口,@app.post一行就能把所有 JSON-RPC 请求收进来,请求体解析、响应序列化都现成。

如果你在资源包里看到threaded=True这个参数,它的含义是每个请求开一个线程处理。JSON-RPC 方法通常是短耗时操作,线程模式够用;如果你的方法里有大量 IO 等待,可以考虑换成异步框架,但协议部分不用动,协议和传输是解耦的。这里先给资源包里的服务端核心代码:

import json from flask import Flask, request, Response app = Flask(__name__) METHODS = {} def rpc(name=None): def decorator(fn): METHODS[name or fn.__name__] = fn return fn return decorator @rpc("add") def add(a, b): return a + b @rpc("get_user") def get_user(user_id): # 实际项目里这里查数据库或缓存 return {"id": user_id, "name": "user_" + str(user_id)} def build_error(code, message, rid): return {"jsonrpc": "2.0", "error": {"code": code, "message": message}, "id": rid} def handle_single(req): # 非对象请求直接返回无效请求 if not isinstance(req, dict): return build_error(-32600, "Invalid Request", None) rid = req.get("id") # 没有 id 说明是通知,不返回响应 if "id" not in req: method = METHODS.get(req.get("method")) if method is None: return None params = req.get("params") or {} try: if isinstance(params, list): method(*params) else: method(**params) except Exception: pass # 通知不反馈错误,由服务端日志兜底 return None if req.get("jsonrpc") != "2.0": return build_error(-32600, "Invalid Request", rid) method = METHODS.get(req.get("method")) if method is None: return build_error(-32601, "Method not found", rid) params = req.get("params", {}) try: if isinstance(params, list): result = method(*params) else: result = method(**params) return {"jsonrpc": "2.0", "result": result, "id": rid} except TypeError: # 参数数量或类型不匹配 return build_error(-32602, "Invalid params", rid) except Exception: # 业务逻辑异常统一转协议错误 return build_error(-32603, "Internal error", rid) @app.post("/rpc") def rpc_endpoint(): raw = request.get_data(as_text=True) try: req = json.loads(raw) except ValueError: return Response(json.dumps(build_error(-32700, "Parse error", None)), content_type="application/json; charset=utf-8") # 批量请求:数组里每一项都走单个处理 if isinstance(req, list): result = [] for item in req: resp = handle_single(item) if resp is not None: result.append(resp) return Response(json.dumps(result), content_type="application/json; charset=utf-8") resp = handle_single(req) if resp is None: return Response("", status=204, content_type="application/json; charset=utf-8") return Response(json.dumps(resp), content_type="application/json; charset=utf-8") if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, threaded=True)

这段代码有四个地方值得停下来看。第一,@rpc("add")装饰器把方法名和函数绑定到METHODS字典,后面分发时直接用METHODS.get查表,新增一个 RPC 方法只需要加一个@rpc函数,不用改分发逻辑。第二,handle_single先判断"id" in req,这一步把通知和普通请求分开,通知分支里异常直接吞掉,只在日志里留痕迹,因为协议规定通知没有响应可回。第三,params的两种形态在调用时分别走*params和**params,这个分支写错会让参数莫名其妙传串。第四,批量请求的响应数组只收集非 None 的结果,通知项被自然过滤,客户端返回数组的长度很可能不等于请求数组长度,这是协议设计如此。

3.2 超时、连接数与消息大小边界

服务端参数里最容易忽略的是消息大小限制。Flask 默认不限制 POST 体大小,但你开在公网上就等于敞开大门。一般做法是在请求入口前加一层判断,request.content_length超过阈值直接返回-32600,我习惯把上限设在 1MB,够传业务数据,又能挡住异常报文。threaded=True也有边界,线程爆炸时文件描述符先扛不住,所以生产环境建议在前面加一层 Nginx 限制并发,Flask 只负责业务。

超时分为两个方向:服务端处理超时,客户端连接超时。客户端如果设置太短的超时,批量请求里一旦有一个慢方法,整个调用就被掐断。我一般把客户端超时设为服务端快方法的 3 倍,比如快方法平均 200ms,客户端超时给 10s,给足余量又不至于挂死。

3.3 启动顺序与依赖安装

资源包的 README 里写的安装方式很简单,先装 Flask 和 requests 两个依赖,再启动服务端,最后开另一个终端跑客户端。服务端启动后控制台会打印监听地址,默认是0.0.0.0:5000,局域网内其他机器也能连。这里有个坑:host="0.0.0.0"意味着所有网卡都监听,如果你在云主机上跑,记得在安全组里限制 5000 端口的来源 IP,不然任何人都能调你的add方法。

4. 客户端调用与调试:curl 先行,代码跟上

4.1 用 curl 验证服务端是不是好的

写客户端代码之前,我永远先用 curl 打一发。这一步排除掉了所有客户端代码层面的干扰,能确认服务端是否真的在按 JSON-RPC 协议响应。下面这条命令请求add方法,传数组参数[2, 3]:

curl -s -X POST http://127.0.0.1:5000/rpc \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"add","params":[2,3],"id":1}'

正常响应是{"jsonrpc": "2.0", "result": 5, "id": 1}。注意id回显成 1,这是协议要求,客户端靠它匹配请求。如果返回的是 HTTP 200 但没有result字段,多半是服务端代码里返回结构写错了,去查handle_single的返回语句。如果 curl 命令本身报Failed to connect,先确认 Flask 进程还活着,再确认端口。

curl 调试时建议把所有错误码都打一遍:传一个畸形 JSON、传一个不存在的方法名、传一个参数缺省的调用,把响应收集起来作为客户端联调时的对照表。这套对照表放进资源包正好,以后新同事接手时不用踩坑,直接照着 curl 命令改。

4.2 Python 客户端封装:id 自增与超时控制

客户端封装的核心是两件事:自动生成 id,把错误转成异常抛给业务层。我写的JsonRpcClient类里,id 用实例变量自增,每次调用加一,这样同一个客户端实例内不会重复。业务代码调用时只管传方法和参数,收到的是已经解包好的result,错误码统一转成RuntimeError抛出去,业务层用 try 接住判断。

import requests class JsonRpcClient: def __init__(self, url, timeout=10): self.url = url self.timeout = timeout self._id = 0 def call(self, method, params=None): self._id += 1 payload = { "jsonrpc": "2.0", "method": method, "params": params if params is not None else {}, "id": self._id } resp = requests.post(self.url, json=payload, timeout=self.timeout) resp.raise_for_status() # 通知请求返回 204,没有响应体 if resp.status_code == 204: return None data = resp.json() if "error" in data: err = data["error"] raise RuntimeError(f"{err['code']}: {err['message']}") return data["result"] def notify(self, method, params=None): payload = {"jsonrpc": "2.0", "method": method, "params": params or {}} requests.post(self.url, json=payload, timeout=self.timeout) client = JsonRpcClient("http://127.0.0.1:5000/rpc") print(client.call("add", [2, 3])) print(client.call("get_user", {"user_id": 9}))

这段代码里两个参数值得单独说。timeout=10同时覆盖连接和读取两个阶段,requests 库如果不传 timeout,网络抖动时请求会挂很久不报错,这在批量联调时非常难受。params默认传{}而不是None,是为了让handle_single里的params = req.get("params", {})走 keyword 分支;如果你把params设成None,服务端拿到的是null,isinstance(params, list)和isinstance(params, dict)都会返回 False,结果就是方法被调用了但参数没传进去,这个错最难排查。

4.3 JavaScript 客户端:浏览器里怎么调

前端调用 JSON-RPC 最大的区别是没有 requests 这种封装,fetch 是标准 API。封装时要注意两点:Content-Type必须带application/json;对 204 响应不能走response.json(),会抛解析异常。下面这一版足够覆盖常规业务:

async function rpc(method, params = {}, id = Date.now()) { const response = await fetch('/rpc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method, params, id }) }); if (response.status === 204) return null; const data = await response.json(); if (data.error) { throw new Error(`${data.error.code}: ${data.error.message}`); } return data.result; } // 调用示例 const total = await rpc('add', [2, 3]); const user = await rpc('get_user', { user_id: 9 });

参数说明:id = Date.now()在单页应用里基本够用,但如果同一毫秒内并发多次调用会重复。前端并发请求多的话,建议改成闭包内自增计数器。params默认{}和服务端的 keyword 分支是一致的,这个默认值建议前后端在项目启动前用文档确认一次,因为一旦一端改了参数结构,错误码只会是 -32602,排查要花不少时间。

4.4 调试脚本的推荐路径

资源包里的curl_examples.sh不是摆设,我建议每次改完服务端代码后,先把脚本里五个用例跑一遍:正常调用、解析错误、方法不存在、参数错误、通知请求。这五条覆盖了 JSON-RPC 协议最关键的分支,跑通了再动客户端。千万不要先写客户端再回头查服务端,那样会把协议问题和代码问题混在一起,浪费时间。

5. 避坑实录:id 类型、编码与通知请求的三次翻车

5.1 HTTP 500 竟然不是 JSON-RPC 错误

现象:客户端调用一个故意抛异常的方法,期望收到-32603错误对象,实际却收到 HTTP 500,响应体是一长段 HTML。原因:Flask 默认未捕获异常走的是 HTML 错误页,不是 JSON-RPC 错误通道。只有把异常捕获到handle_single内部并转成error对象,客户端才能按协议解析。解决:给@app.errorhandler(Exception)加统一兜底,返回-32603的 JSON 响应;同时检查每个 RPC 方法内部是否还有裸奔的异常没被外层接住。

5.2 params 类型不一致导致诡异 TypeError

现象:客户端用数组传参没问题,改成对象传参后同一方法报Invalid params。原因:我在服务端代码里对params做了*params和**params两个分支,客户端如果混用,比如方法定义是def add(a, b),客户端传{"a": 1, "b": 2}能通,传[1, 2]也能通;但如果方法定义是def add(a, b=0),对象传参缺省时也正常,数组传参缺省时反而报错。解决:项目启动时约定 params 一律用数组,或者一律用对象,不要两种混着传。这个约定要写进双方接口文档里,代码不会主动提示你。

5.3 id 类型不匹配,响应和请求永远对不上

现象:客户端发请求时 id 用数字 1,服务端响应也是 1,但客户端判断data.id === payload.id结果竟然是 false。原因:JSON 解析过程中 id 的类型被改变,比如服务端把 id 转成了字符串"1",或者客户端用了id = '1'而服务端返回的是数字。JavaScript 里1 === "1"是严格不等的。解决:全项目统一 id 类型,我习惯强制用 number 且从 0 开始自增。资源包的client.py里就是自增数字,前端封装里如果用了Date.now(),注意它返回的是数字,不要手滑加引号。

5.4 批量请求带通知,响应数组对不齐

现象:客户端批量发三个请求,其中一个不带 id,服务端返回了三条响应,客户端按序号取下一条结果时拿到的是通知的错误响应。原因:服务端处理批量请求时没有过滤通知项,把无 id 请求也当成普通请求走了handle_single。解决:资源包里的服务端代码已经处理了这一点,handle_single对通知返回None,批量循环里跳过None项。客户端收到响应数组后,不要按下标取结果,一定要遍历响应项,用id去匹配初始请求。

5.5 中文错误信息变成乱码或被转义

现象:错误信息里写了中文,客户端打印出来要么是\uXXXX转义序列,要么直接乱码。原因:Flask 默认把 JSON 响应的非 ASCII 字符转成\uXXXX,某些终端、日志系统不负责还原转义,直接把原始序列打出来。另一个并发问题是响应头charset没设置,客户端按错误编码解析响应体也会乱码。解决:响应头显式写application/json; charset=utf-8;Flask 新版用app.json.ensure_ascii = False关掉转义,旧版本用app.config["JSON_AS_ASCII"] = False。两个版本我都写在资源包 README 的注释里了。

6. 进阶玩法:批量请求与通知在真实业务里的落地技巧

单个方法调用只是 JSON-RPC 的基础,批量请求才是提高效率的关键。比如一个仪表盘页面要同时取用户信息、订单数、未读消息,三个接口串行调用要等三倍时间,用批量请求一次性发出去,服务端并行或顺序处理,客户端统一回收,整体耗时基本等于最慢的那个方法。资源包里的client.py已经预留了batch_call的扩展点,核心逻辑是把多个调用装进一个数组:

def batch_call(self, calls): payload = [] for i, item in enumerate(calls): payload.append({ "jsonrpc": "2.0", "method": item["method"], "params": item.get("params", {}), "id": i }) resp = requests.post(self.url, json=payload, timeout=self.timeout) data = resp.json() # 返回一个字典,按 id 映射结果,调用方直接查 return {item["id"]: item for item in data if "id" in item}

参数说明:calls是{"method": "...", "params": ...}的列表,我从 0 开始给每个调用分配自增 id,这样调用方拿到的响应字典键是 0、1、2,和calls的下标天然对应。这里刻意绕开了时间戳做 id,因为批量调用里多个请求的生成时间有可能落在同一毫秒,尤其是前端循环构建批量时,id 重复会让整批响应的匹配乱掉。

批量请求有一个容易忽视的特性:响应数组的顺序不保证与请求数组一致。协议层面允许服务端乱序返回,客户端如果按下标取值就翻车了。所以我把响应转成以 id 为 key 的字典,调用方用results.get(0)取值,不依赖返回顺序。

通知的落地场景则完全是另一路子。日志上报、异步任务触发这类操作,客户端只负责发出,不关心结果,用通知可以让调用方的代码更干净。但通知有个代价:服务端处理失败时客户端毫无感知,所以通知里能带的事件详情要尽量完整,服务端日志里必须有方法名、参数摘要、时间戳、来源 IP。资源包的server.py里通知分支吞掉了异常,实际项目里我会在except块里加一条logging.exception,把失败的调用留痕。

使用批量请求和通知时,我的习惯是先在压测环境试一遍混合场景:一个批量数组里同时混普通请求和通知,确认服务端返回项和请求项的对应关系符合预期。资源包里的方案不是唯一,但它把 id 匹配、通知过滤、错误码三分这几个坑都铺平了,拿来改业务代码时不用重踩。

之前我在一个内部系统里把 id 换成时间戳,结果并发一上来响应匹配就错乱,排查了一天才发现是同一毫秒内生成了两个相同 id。从那以后我每次接 jsonRPC 都强制走一遍标准流程:先跑 curl 五个用例、再确认 id 类型统一、最后才写业务逻辑。这套流程写进资源包,省得新来的同事再交一遍学费。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询