1. 为什么我在轻量服务场景里最终选了jsonRpc
1.1 先聊一下当时遇到的问题
前段时间我在做某个内部业务系统之间的数据同步模块,需求本身不复杂:一个服务要定时从另一个服务拉取一批订单状态,偶尔需要触发对方执行一些重操作。团队里有人提议直接上REST接口,也有人建议用gRPC,但我最后选了jsonRpc,说实话这个决定一开始也被质疑过。
先说我的核心诉求。这个场景里的两个服务都是内部服务,部署在同一套内网环境里,不需要经过网关,也不需要对公网开放。流量不大,峰值也就是每秒几十个请求,但接口数量不少,大概有二十多个操作。如果全用REST风格去写,每个接口都要设计URL、定义请求方法、处理状态码、设计各种语义,这对于纯内部服务间的函数式调用反而显得笨重。
jsonRpc的优势恰好在这里:它把整个服务看成一组可调用的方法,客户端只需要发一个包含方法名和参数的JSON对象,服务端执行完把结果打包成JSON返回。没有REST那种资源建模的负担,也不需要像gRPC那样维护一套IDL文件并生成代码。对于一个功能明确、调用关系简单的内部服务,这其实是最省事的方案。
1.2 三种候选方案的代价对比
我做过一轮对比,核心结论是"按场景选协议",而不是"按流行度选协议"。
| 维度 | REST | gRPC | jsonRpc |
|---|---|---|---|
| 接口定义成本 | 需要设计URL、HTTP方法、状态码语义,工作量最大 | 需要编写.proto文件并生成代码,初期成本最高 | 只需要注册方法名和参数结构,成本最低 |
| 数据格式 | JSON或XML,可读性好 | Protobuf二进制,性能好但排障成本高 | JSON,可读性和调试体验接近REST |
| 调试便捷性 | 有浏览器/Postman生态,最成熟 | 需要额外工具配合,比较麻烦 | 任意HTTP客户端可以直接调试,甚至curl即可 |
| 严格性 | 语义松散,容易产生风格差异 | 强约束,接口演进有规范约束 | 结构简单,约定清晰,但也意味着细节需要自己把控 |
| 跨语言能力 | 好 | 好,但依赖生成代码 | 好,任何能处理JSON的语言都可以 |
我个人的建议是:如果接口数量在几个到几十个这个量级、服务间调用不复杂、团队更看重快速交付和易调试,jsonRpc是一个性价比极高的选择。而当服务数量到了几十个以上、有严格的接口治理需求、或者对传输性能极其敏感时,再认真考虑gRPC不迟。
2. jsonRpc协议规范里容易被忽略的细节
2.1 请求、响应、错误三个对象的字段拆解
jsonRpc把整个协议浓缩成三个对象结构,理解清楚这三个结构,后面写代码就顺了。
请求对象的完整形态是:
{ "jsonrpc": "2.0", "method": "order.syncStatus", "params": { "orderId": "20250101001", "status": "shipped" }, "id": 1 }这里有几个细节值得注意。jsonrpc字段固定是字符串"2.0",协议规定必须是这个值。method是要调用的方法名,建议用点分命名空间风格,比如order.syncStatus、pay.refund,这样在方法变多以后便于归类。params不是必填字段,如果方法不需要参数可以直接不传。id必须是整数或字符串,用来关联请求和响应,不能是小数。
响应对象有两种形态,对应成功和失败:
{ "jsonrpc": "2.0", "result": { "success": true, "syncTime": "2025-01-01 12:00:00" }, "id": 1 }{ "jsonrpc": "2.0", "error": { "code": -32601, "message": "Method not found", "data": null }, "id": 1 }成功时返回result字段,失败时返回error字段,两者不会同时出现。error对象里的code是数字,message是简短描述,data可选,通常用来携带更详细的错误信息。
id字段在响应里有一个特殊约定:如果请求无法被解析,服务端应返回id为null的响应。我在实际项目里遇到过一个客户端库对id的类型校验特别严格,null会被当成异常,所以如果你的客户端是自己写的,一定要对id: null这种情况做兼容。
2.2 notification机制:不需要回执的请求
jsonRpc里有一种特殊的请求叫notification,特征是请求对象里没有id字段。服务端接收到notification后执行方法,但不返回任何响应。
这个机制我在实际项目中用在了两个场景。第一个是一些不需要关心结果的触发型操作,比如清理某条缓存、通知对端刷新配置。第二个是批量场景的降级,比如某个方法调用失败一次没关系,下一次定时任务还会再调,那就没必要等待响应。
但notification有个坑:由于服务端不返回响应,客户端无法知道这次调用到底成功还是失败。如果被调用的方法本身有副作用(比如写数据库、发消息),一旦失败就会静默丢失。所以我在项目里只把notification用于可容忍失败的操作,有状态变更的关键操作一律用带id的标准请求。
2.3 批量请求:一个HTTP连接做多件事
jsonRpc的批量调用就是把多个请求对象放进一个JSON数组,一次发给服务端,服务端按顺序处理并返回一个响应数组。
[ {"jsonrpc": "2.0", "method": "order.getStatus", "params": {"orderId": "1"}, "id": 1}, {"jsonrpc": "2.0", "method": "order.getStatus", "params": {"orderId": "2"}, "id": 2} ]批量请求最适合的场景是"多个不相关的数据查询同时需要"的情况。比如一个页面要同时展示用户的基本信息、账户余额、最近订单,三个查询可以合并成一个HTTP请求发出去,减少连接开销。
不过批量部分的失败处理要格外小心。协议规定,如果批量请求里某一个元素失败,服务端返回的数组中对应元素的error字段有值,其他元素正常返回result,整体仍然是200响应。我在项目里遇到过客户端只判断了HTTP状态码,没有逐个检查数组元素,导致部分方法失败但业务上看起来"成功"了。后来我统一封装了一个处理函数,遍历返回数组、校验每个元素的result和error,把失败的项单独记录日志,才算彻底解决这个问题。
3. 从零搭建一个jsonRpc服务端
3.1 服务端核心结构:路由分发与参数校验
我实现jsonRpc服务端时没有引入重量级框架,用的是标准库的json和http.server,整个服务端代码控制在两百行以内。核心思路是:接收请求体、解析JSON、按方法名分发、执行对应函数、构造响应。
服务端的核心逻辑大致是这样:
import json from http.server import BaseHTTPRequestHandler, HTTPServer class RPCHandler(BaseHTTPRequestHandler): methods = {} @classmethod def register(cls, name): def decorator(func): cls.methods[name] = func return func return decorator def do_POST(self): content_length = int(self.headers.get('Content-Length', 0)) body = self.rfile.read(content_length).decode('utf-8') try: request = json.loads(body) except json.JSONDecodeError: self._send_response({ "jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None }) return if isinstance(request, list): responses = [self._handle_single(req) for req in request] responses = [r for r in responses if r is not None] self._send_response(responses) else: response = self._handle_single(request) if response is not None: self._send_response(response) def _handle_single(self, request): if not isinstance(request, dict): return {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": None} request_id = request.get("id") method_name = request.get("method") if request.get("jsonrpc") != "2.0": return {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": request_id} if method_name not in self.methods: return {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": request_id} params = request.get("params", {}) if params is None: params = {} try: result = self.methods[method_name](params) return {"jsonrpc": "2.0", "result": result, "id": request_id} except Exception as exc: return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(exc)}, "id": request_id} def _send_response(self, payload): body = json.dumps(payload).encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'application/json-rpc') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body)这里的关键设计是用了类属性methods做方法注册表,装饰器register可以直接把函数绑定到指定方法名上。这种方法的好处是业务方法不需要关心HTTP细节,只需要接收一个params参数并返回一个可序列化的结果。
3.2 方法注册示例与异常处理
有了基础框架之后,注册业务方法变得非常干净:
@RPCHandler.register("order.syncStatus") def sync_order_status(params): order_id = params.get("orderId") status = params.get("status") if not order_id or not status: raise ValueError("orderId and status are required") # 这里写具体的业务逻辑,比如更新数据库状态 return {"success": True, "orderId": order_id, "status": status}我建议把参数校验放到每个方法内部,而不是在分发层做统一校验。原因是jsonRpc协议没有定义参数的结构,不同的方法需要的参数千差万别,每个方法内部做自己的校验,报错信息才能做到具体、可读。
关于异常处理,我在分发层捕获了所有抛出的异常,统一映射成-32603内部错误码。但这里要注意一个问题:生产环境里某些业务异常是可以预期的,比如订单不存在、状态不合法这类,不应该全部抛成内部错误。我的做法是自定义一个RPCError类,携带具体的错误码和消息,在分发层优先捕获这种类型的异常。
class RPCError(Exception): def __init__(self, code, message, data=None): self.code = code self.message = message self.data = data这样业务代码里想报"订单不存在"就可以直接raise RPCError(-32001, "Order not found"),前端拿到清晰的错误码去处理自己的逻辑,调试效率高很多。
3.3 参数校验的边界问题
jsonRpc的params结构虽然灵活,但也带来了一个实际麻烦:调用方传错参数类型时,服务端的报错信息往往不够直观。比如方法期待params["count"]是整数,调用方传了字符串"123",如果没有显式校验,计算时Python会直接抛类型错误,报错堆栈里只显示某行代码出错,定位问题全靠猜。
我在项目里给每个方法加了统一风格的参数校验逻辑,非法参数直接抛RPCError并返回-32602(invalid params)错误码,和协议语义对齐。这样前端和调用方看到错误码就能立刻知道是参数问题,而不是服务端内部bug。
4. 客户端连接管理与超时策略
4.1 客户端封装:连接复用和线程安全
写完服务端,客户端的封装同样重要。我用的Python客户端基于requests.Session实现,核心点是连接复用。每个Session对象在内部维护了一个连接池,同一主机下的多个请求可以复用TCP连接,避免每次请求都重新握手,对于高频率调用场景能明显降低延迟。
实现上有几个容易踩坑的地方。第一,Session对象不是线程安全的,多个线程同时使用同一个Session做请求会出现连接竞争。我的做法是用threading.local为每个线程维护独立连接,或者在上层用线程池,让每个线程持有自己的Session。
第二,连接超时和读取超时要分开设置。只设置连接超时的话,如果服务端处理某个方法特别慢、迟迟不返回,客户端会一直挂着。我的经验是连接超时给2到3秒,读超时按业务复杂度给5到10秒,宁可超时报错也不要让请求无限期等待。
import requests import json import threading class RPCClient: def __init__(self, base_url, timeout=5): self.base_url = base_url self.timeout = timeout self._local = threading.local() def _get_session(self): session = getattr(self._local, 'session', None) if session is None: session = requests.Session() session.headers.update({'Content-Type': 'application/json-rpc'}) self._local.session = session return session def call(self, method, params=None, _id=1): payload = { "jsonrpc": "2.0", "method": method, "id": _id } if params is not None: payload["params"] = params session = self._get_session() try: resp = session.post(self.base_url, data=json.dumps(payload), timeout=self.timeout) resp.raise_for_status() data = resp.json() if "error" in data: raise RPCError(data["error"]["code"], data["error"]["message"], data["error"].get("data")) return data["result"] except requests.exceptions.Timeout: # 记录日志并做超时后的降级处理 raise RPCError(-32000, "Request timeout")4.2 超时重试的幂等性设计
超时后的处理是我在这个项目里反复调整的地方。一次调用超时,直接抛异常让业务报错是最简单的做法,但在某些关键路径上,一次偶发超时会导致整个业务流程失败,代价有点大。所以我给部分方法加了重试机制。
重试的前提是方法必须幂等。比如"查询订单状态"这种纯读操作,重试一万次都没问题,随便重试。但像"创建订单"这种有副作用的写操作,如果第一次调用其实已经成功了,只是响应在传输过程中超时了,重试就会导致重复创建。
我的处理思路是:给写操作加上一个请求级的幂等键,放到params里。服务端收到请求后先检查这个幂等键是否已经处理过,如果处理过就直接返回上一次的结果。
具体实现上,服务端用一个字典缓存幂等键和对应的响应结果,设置一个合理的过期时间,这样客户端超时后重试同一个请求,服务端会命中缓存直接返回,不会产生副作用。
这个设计看起来简单,但确实帮我们规避过线上订单重复提交的问题,值得花时间做好。
5. 真实项目中踩过的几个坑
5.1 错误码设计过于随意,排查问题全靠翻日志
第一版服务端里,我除了保留协议预约的错误码,业务错误全部返回了-32001这个自定义码。当时觉得业务错误种类不多,统一用一个码足够,结果上线后很快就发现问题:前端报了一个-32001,但日志里根本看不出到底是参数错了、数据不存在还是权限不足,每次都要跑到服务器上翻详细日志才能定位。
后来我重新设计了业务错误码的分配规则:-32000到-32099留给通用错误(超时、服务不可用等),-32100到-32199留给业务校验错误,再往后按模块划段。同时每个错误都附带data字段,里面放错误发生的具体方法名和排查建议。这个改动之后,线上问题定位时间大大缩短。
5.2 批量请求里混入notification,响应数组长度对不上
有一次我为了减少请求数,把几个操作打包成批量请求发送,其中有一个是notification(不带id)。我原以为服务端会为每个元素都返回一个响应,但协议规定notification不返回响应。结果我拿到的响应数组长度比请求数组短,客户端解析时没做长度校验,直接把响应错位对齐,导致数据串了。
排查了好一阵才发现问题根源。后来我在客户端解析批量响应时做了两个改动:一是严格按id来匹配响应和请求,而不是按数组下标;二是当响应数量与请求数量不一致时,明确允许这种差异并跳过对notification的结果等待。
5.3 安全与鉴权:协议本身不提供任何防护
jsonRpc协议和高安全性本身不搭边,它默认调用方是可信的。这在纯内网环境问题不大,但只要服务被更多人访问到,就必须自己做一层防护。
我在项目中至少做了三件事。第一,请求统一经过一个SSO鉴权中间件,校验调用方身份和权限,拦截未登录请求。第二,所有写操作的接口都记录操作日志,包括调用方、方法名、入参、耗时和响应状态,方便追溯问题。第三,对敏感方法(比如退款、批量删除)额外限制调用来源IP,只允许指定的几个服务IP访问。
有一段时间我们架设了一个对外暴露的测试环境,忘了加鉴权,结果被外部扫描工具探测到并尝试调用了几个方法。虽然没有造成实际损失,但那次之后我把鉴权逻辑提到了最底层,所有入口统一过滤,不再依赖各业务方法自己防护。
5.4 方法参数变更要向后兼容
jsonRpc没有版本号的概念,客户端和服务端的接口约定完全靠method名和params结构来维护。一旦某个方法改了参数名称,老版本的客户端很可能继续用旧的字段名调用,服务端如果直接报错,调用方就立刻受影响。
我的做法是:参数变更时尽量做到向后兼容。新增字段时服务端用params.get()取默认值,不强制要求调用方传新的字段。删除字段时先保留旧字段的兼容解析逻辑,再逐步下线。如果实在要做不兼容的变更,就新增一个带版本后缀的方法名,比如order.syncStatus_v2,让调用方逐步迁移。
这个方法虽然土,但确实避免了线上服务因为参数不兼容而下线的事件,尤其是在客户端和服务端由不同团队维护的情况下,尤其实用。
6. 什么场景该选jsonRpc,什么场景别犹豫
6.1 适合jsonRpc的场景特征
从我自己的实践来看,以下场景选jsonRpc是很舒服的:
- 服务间调用关系简单,方法数量在几个到几十个的量级。
- 团队追求快速交付,不想花时间设计一堆REST资源路径。
- 内部服务,有统一鉴权入口,服务不直接暴露到公网。
- 性能要求中等,JSON的编解码开销可以接受。
- 跨语言场景,调用方可能用Python、Java、Go等不同语言,JSON的通用性最好。
在这些场景里,jsonRpc能带来显而易见的效率提升:定义方法、写逻辑、调试、上线,整个流程非常顺畅。
6.2 不建议用jsonRpc的场景
反过来说,以下情况我建议谨慎甚至直接避开:
- 接口数量超过几十个,且需要严格的接口文档和版本管理,这时候gRPC的.proto文件反而能成为强约束。
- 面向公网或第三方的开放API,REST风格仍然是主流,生态工具和消费方的认知成本都更低。
- 高并发低延迟的极致性能要求,二进制序列化协议优势明显。
- 团队里如果有大量偏业务前端的同事,他们对REST的熟悉程度远高于jsonRpc,学习成本需要计入。
一个场景做得多了自然会发现,选型没有绝对的对错,关键是匹配团队的技术栈和实际的运维能力。如果团队已有的监控、日志、调试工具对gRPC或REST支持更完善,即便jsonRpc协议本身很适合,也要优先考虑周边生态的成熟度。
6.3 我现在的选型原则
过了几轮项目之后,我沉淀下来一套简单的选型原则。两个服务之间做内部调用,方法数量有限,且没有强接口治理需求,我会默认考虑jsonRpc。如果涉及对外开放接口、或者有复杂的资源模型,我会选REST。如果团队有为接口定义自动生成文档和客户端代码的需求,或者对性能有硬性指标,我会直接上gRPC。
这套原则不一定适合所有人,但至少能帮你在面对"内部服务间通信该用什么协议"这个问题时,有一个可以立刻落地的判断依据。
7. 最后分享几个实际项目中验证过的小技巧
第一个是关于日志的。jsonRpc的请求和响应都是JSON,排障时把入参和结果完整记录下来往往最有价值。我习惯在最外层统一记录一条日志,包含请求方法名、id、参数摘要、耗时和返回状态。参数摘要可以截断长字段,避免日志体积过大,但取method名和时间毫秒数一定要保留全。
第二个是关于并发控制的。Python的http.server是单线程的,默认不适用于并发请求较高的场景。我在实际部署时用的是ThreadingHTTPServer,并且后面加了进程数做横向扩展。我这个项目里的量级不大,单机几个进程足够。如果你的流量预期更高,建议直接把服务端内嵌到一个异步框架里,或者在前面加一层负载均衡。
第三个是关于通知成功的确认的。notification模式用起来方便,但少了一条反馈链路。我的经验是,就算用了notification,被调用的方法仍然要打印执行结果的日志,方便事后追查。另外如果有定时任务在周期性调用某个方法,且该方法有副作用,强烈建议在方法内部做一个简单的时间戳去重或状态判断,防止重复执行。
jsonRpc不是万能的协议,但它在合适的场景里能给你非常顺畅的开发体验。我用了几个项目之后,已经把它放进内部服务通信的默认选项之一,至少在遇到更复杂的需求之前,这个选择一直都很稳。