接手过一个内部 jsonRpc 项目的长期维护,代码看了三天,最大的感慨是:业务方法写得清清楚楚,但把请求分发到对应方法的那一段,硬是炸出了几百行 if-else 和无数个重复的 JSON 解析片段。我们常说的 Dispatcher 模块,本质上就是 RPC 服务端的"分诊台"——请求进来,它决定找哪个科室、挂哪个号、怎么验单、怎么把结果递回去。这个模块写得好不好,直接决定你加一个接口要改多少代码,也决定线上出问题时排查要翻几个文件。
这篇博文围绕 jsonRpc 项目里的 Dispatcher 模块展开,梳理它的职责边界、路由表设计、参数绑定、同步异步并发控制、错误码规范,以及拦截器扩展点。内容偏实战,涉及 Java 生态和 JsonNode 处理方式,但思路部分也适合用其他语言实现的 RPC 框架参考。无论你是打算自己写一个 jsonRpc 服务端,还是正接手一个 Dispatcher 模块想重构,都能从中找到可直接落地的方案。
1. Dispatcher 在 jsonRpc 调用链里的位置与职责边界
1.1 一条请求从进入到返回,Dispatcher 到底管哪一段
先看一个标准的 JSON-RPC 2.0 请求长什么样:
{ "jsonrpc": "2.0", "method": "user.login", "params": { "username": "admin", "password": "123456" }, "id": 1 }服务端收到这个 JSON 文本后,大致要经历:反序列化、校验格式、查找方法、解析参数、执行方法、包装响应、回写客户端。Dispatcher 管的是中间四步——从拿到一个已经反序列化的请求对象开始,决定调用哪个业务方法,执行完再把结果封装成标准响应。它不管底层 TCP 怎么收发数据,也不管具体业务逻辑怎么实现,它的核心就是"路由"和"适配"。
有团队喜欢把 Dispatcher 写得又厚又重,把协议解析、参数校验、权限控制全塞进去,我见过最夸张的情况是 Dispatcher 类六千行,里面还嵌着 SQL 查询。这种写法维护成本非常高。我的建议是让 Dispatcher 保持薄,它只需要做四件事:
- 根据 method 字符串定位到已注册的处理器。
- 把 params 的 JsonNode 绑定成处理器所需的 Java 参数。
- 调用处理器,得到结果或异常。
- 把结果或异常转成 JSON-RPC 响应对象。
1.2 不用 Dispatcher,直接写 if-else 会怎样
前期为了赶进度,很多人会在接收消息的地方直接写:
if ("user.login".equals(method)) { UserLoginRequest req = objectMapper.treeToValue(params, UserLoginRequest.class); Object result = userService.login(req); ... } else if ("order.create".equals(method)) { ... } else if ("order.cancel".equals(method)) { ... }这种写法的痛点在三个地方:第一,每加一个接口都要改分发入口,类会越来越臃肿,合并代码时频繁冲突;第二,参数解析逻辑散落在每个分支里,风格不统一,有人用 treeToValue,有人手动 get("username"),排查问题时要挨个分支检查;第三,公共逻辑(埋点、超时控制、错误包装)没法统一收口,只能在每个分支后面重复粘贴。
我接手那个项目时统计过,分发入口有 47 个分支,每个分支的平均代码量在 40 行左右。后来重构为注册表 + Dispatcher 模式后,新增接口只需要在对应业务类上打个注解,分发入口的代码量从 1900 行缩到 260 行。这个对比足够说明问题。
2. 路由表与参数绑定:Dispatcher 模块的核心设计细节
2.1 路由表的结构设计与构建时机
Dispatcher 的核心数据结构就是一张路由表,最简单也最可靠的形式是:
Map<String, MethodHandler> routeTable;String 是方法完整名,比如"user.login";MethodHandler 封装了目标 bean 实例、目标方法、方法参数类型列表、以及必要的元数据。设计时需要考虑三个细节:
构建时机选在服务启动阶段。通过扫描所有标注了 @JsonRpcMethod 注解的 Bean,把注解里的方法名和反射得到的 Method 对象关联起来,构建不可变 Map。启动时扫描的好处是,如果方法名重复或参数绑定配置有问题,服务直接启动失败,而不是等到请求来了才暴露,这比运行时才发现要省太多事。
方法名冲突检测不能省。两个不同 Bean 里同时声明了 "user.login",应该抛异常终止启动。这点看起来简单,实际项目里我见过不少框架在这里只做 replace,静默覆盖,结果线上某些请求打到旧实现上,非常难排查。
参数类型列表必须缓存。反射调用本身不慢,慢的是每次调用都去 getMethod 或 getParameterTypes。路由表里把 Method、参数类型列表、还有每个参数的绑定方式都缓存好,后续每次调用都是纯内存查找,性能瓶颈就可以忽略。
2.2 参数绑定:从 JsonNode 到 Java 类型的转换规则
参数绑定是 Dispatcher 里最容易出幺蛾子的地方。JSON-RPC 的 params 可以是数组、对象或者缺省,绑定规则要分情况:
- params 为数组:按位置依次绑定到方法的参数列表,适合参数少且顺序固定的场景。
- params 为对象:按参数名匹配绑定,适合参数多、可读性要求高的场景。
- params 缺省:方法没有入参,直接调用。
我的实现里统一走 ObjectMapper 的 convertValue,但针对三种情况做了不同的入口封装。对象类型绑定基本逻辑如下:
public Object[] bindParameters(JsonNode params, MethodHandler handler, ObjectMapper mapper) { if (params == null || params.isNull()) { return new Object[0]; } Class<?>[] types = handler.getParameterTypes(); Object[] args = new Object[types.length]; if (params.isArray()) { if (params.size() != types.length) { throw new JsonRpcException(-32602, "Params size mismatch"); } for (int i = 0; i < types.length; i++) { args[i] = mapper.convertValue(params.get(i), types[i]); } } else if (params.isObject()) { for (int i = 0; i < types.length; i++) { String name = handler.getParameterNames()[i]; JsonNode node = params.get(name); if (node == null) { throw new JsonRpcException(-32602, "Missing param: " + name); } args[i] = mapper.convertValue(node, types[i]); } } return args; }这里有个容易被忽略的坑:JsonNode 为 null 和 JSON 里字段不存在是两码事。字段不存在时 params.get(name) 返回 null,对应"缺参数"错误;字段存在但值为 null 时返回 NullNode,可以允许它传给对象类型参数。很多实现把这两种情况混在一起判断,导致调用方一旦传了 null,服务端就报 Invalid params,实际上是误报。
基础类型绑定也要小心,特别是 int、long、boolean 这些。JSON 里传 "123"(字符串)和 123(数字)在某些宽松配置下都能转成功,但我在生产环境见过前端把数字 0 传成空字符串导致转换异常,这种问题很难从代码层面完全兜住,所以参数校验逻辑里建议给必填基础类型加上专门的类型判断,而不是完全交给 ObjectMapper 的隐式转换。
2.3 重载方法怎么处理
Java 允许重载,但 JSON-RPC 的方法名只有字符串,没有参数类型维度。我在设计路由表时明确不支持重载——方法名重复直接启动失败。但有一种合理的变通做法:方法名加后缀区分,比如 user.getById 和 user.getByIds,或者 user.list 和 user.page。这比在 Dispatcher 层面硬解析重载要干净得多,也让 API 文档更直观。
另外,服务端可以允许方法名带命名空间前缀。路由表里只存完整方法名,前缀仅用于组织代码结构。这个设计在项目膨胀之后受益很大,比如用户域的方法统一以 user. 开头,订单域统一以 order. 开头,团队新人看路由表也能快速定位代码位置。
3. 同步异步混杂场景下的并发控制与超时处理
3.1 线程模型选型:同步执行还是线程池派发
Dispatcher 一旦决定调用业务方法,就面临线程模型选择。三种常见做法:
- 请求线程直接执行:最简单,占用 IO 线程做业务处理,适合短平快、无阻塞操作的服务。问题是如果某个方法执行很慢,会占满所有 IO 线程,导致整个服务不可用。
- 独立线程池派发:Dispatcher 把任务扔进业务线程池,IO 线程立即返回。适合有耗时操作、IO 密集型的服务,但需要额外管理线程池生命周期和拒绝策略。
- 响应式异步:业务方法返回 CompletableFuture,真正执行可以在任何线程。适合高并发场景,但要求业务代码全部写成异步风格,对团队要求很高。
我个人的倾向是:先明确你的业务方法里有没有阻塞操作。如果全部是内存计算、Redis 读取这种微秒到毫秒级的操作,直接在 IO 线程执行就好,省掉一次线程切换,压测吞吐量反而更高。只有存在远程调用、文件读写、大批量数据库查询这类操作时,才有必要引入独立线程池。
| 线程模型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 线程直接执行 | 实现简单,性能高 | 慢方法拖垮 IO 线程 | 纯计算、毫秒级操作 |
| 独立线程池 | 隔离性好,线程可控 | 多一次线程切换 | 存在阻塞操作的服务 |
| 响应式异步 | 吞吐量极限 | 业务代码复杂 | 高并发、全链路异步团队 |
3.2 异步调用的 Future 超时与结果竞争
使用线程池时,最常踩的坑在超时处理。我见过大量这样写的代码:
Object result = future.get(3000, TimeUnit.MILLISECONDS);这个写法本身没问题,但很多人忽略了一个关键事实:future.get 超时抛 TimeoutException 后,工作任务还在线程池里继续跑。如果你的代码在超时后直接返回了"调用超时"的响应,redis 里的缓存、数据库里的状态、甚至一些外部接口仍然被执行了。
处理这种结果竞争,我总结了一套固定套路:
CompletableFuture<Object> future = CompletableFuture.supplyAsync(() -> handler.invoke(context), executor); try { Object result = future.get(timeoutMs, TimeUnit.MILLISECONDS); return Response.success(result); } catch (TimeoutException e) { future.whenComplete((r, ex) -> { if (r != null) { // 超时后任务还是完成了,记录日志,必要时做补偿 log.warn("Task finished after timeout, method={}", methodName); } }); return Response.timeout(); }注意:不要试图在超时后台线程里直接中断任务,除非你的业务代码充分处理了 InterruptedException。否则中断信号只能终止休眠和等待,但数据库查询和第三方 HTTP 调用未必响应,反而可能留下不可预期的中间状态。更稳妥的做法是超时后既返回超时响应,又记录一个"迟完成"日志,由异步补偿机制去处理已发生但未通知的结果。
3.3 上下文传递:从请求线程到业务线程的 TraceId 问题
独立线程池带来的另一个问题,是请求上下文丢失。比如你在 IO 线程设置了 ThreadLocal 存 TraceId,业务方法在另一个线程里执行时,TraceId 拿不到,日志串不起来,排查问题只能靠时间对比,非常痛苦。
早期我为了解决这个问题,在 Runnable 里手动传递 TraceId,但代码写得很丑。后来统一用 TransmittableThreadLocal 解决,核心思路是重写线程池的包装逻辑,提交任务时把主线程的上下文快照带过去,执行完成后恢复:
public class ContextAwareExecutor { private final ExecutorService delegate; public <T> CompletableFuture<T> submit(Callable<T> task) { Map<String, String> snapshot = ContextHolder.snapshot(); return CompletableFuture.supplyAsync(() -> { Map<String, String> previous = ContextHolder.inject(snapshot); try { return task.call(); } finally { ContextHolder.restore(previous); } }, delegate); } }这里的注意点是快照必须在提交时捕获,而不是任务执行时捕获,否则提交到真正执行的间隙上下文可能已经被后续请求覆盖。这个细节直接决定了多路复用场景下 TraceId 是否错乱,我在生产环境里用它排查过几轮,发现每次时序问题都出在快照时机偏移上。
3.4 优雅关闭:在途请求不能被粗暴丢弃
Dispatcher 配合线程池时,服务下线的处理不能忽略。如果进程直接退出,正在执行的任务会瞬间丢失,消费方收到的可能是连接被重置。我踩过这个坑之后,设计了优雅关闭三步骤:
- 把活跃标记置为 false,新的请求进来直接返回"服务停机中"错误。
- 调用线程池 shutdown,不再接收新任务。
- 用 awaitTermination 等待在途任务完成,超过最大等待时间再 shutdownNow 强制清理。
等待时间的设置可以参考自身服务的最大执行耗时,一般取 5 到 10 秒比较合理。如果某些方法本身可能执行几分钟,要么调大等待时间,要么就接受少量任务在停机时被打断,再做补偿重试。
4. 错误码规范与异常链路:让每个失败都能被快速定位
4.1 JSON-RPC 标准错误码和业务错误码怎么分配
JSON-RPC 2.0 规范定义了几个标准错误码,Dispatcher 这层只用这些标准码来标识协议层面的错误:
| 错误码 | 名称 | 使用时机 |
|---|---|---|
| -32700 | Parse error | 服务端无法解析 JSON 文本 |
| -32600 | Invalid Request | 请求对象结构不完整 |
| -32601 | Method not found | 路由表里找不到对应方法 |
| -32602 | Invalid params | 参数绑定失败 |
| -32603 | Internal error | 处理器内部抛出了未知异常 |
业务异常属于另一类。比如用户未登录、订单不存在、余额不足,这些不能归到 Internal error,否则客户端就无法区分"服务端出了 bug"和"业务上不允许这次操作"。
我的做法是给 Response 增加一个 code 字段,Dispatcher 定义的协议错误用 JSON-RPC 标准码,业务异常统一下推到 -32000 到 -32099 区间,由各个业务模块自行细分。客户端拿到 -32601 可以直接断定是方法名写错了,拿到 -32001 会去查对应的业务语义。
4.2 Dispatcher 内异常分类与统一包装
业务方法抛出的异常类型五花八门,Dispatcher 要做的是把它们按类别处理,而不是把原始异常直接透传给客户端。我把异常分成四类:
- 请求格式异常:JsonProcessingException、参数绑定异常,返回 -32602。
- 路由异常:路由表查不到,返回 -32601。
- 业务异常:业务代码主动抛出的错误码,比如 BizException(code, msg),按 code 原样返回。
注意:BizException 的 message 可以直接返回给调用方。但系统内部异常(NullPointerException、IllegalArgumentException、数据库连接超时)绝不能让堆栈直接暴露给客户端,这类统一返回 -32603,message 固定写 "Internal error" 或 "Service internal error",详细堆栈只打到服务端日志里。
有人可能会问,为什么不把内部异常的 class name 和 message 透传过去,方便客户端排查?我的经验是:一旦客户端依赖这些内部细节,后续你改了异常名字、重构了方法,客户端不升级就全部暴露问题,而且给攻击者提供了服务端内部结构信息,安全角度也不划算。
4.3 参数校验在哪里做:Dispatcher 还是业务层
参数绑定只是做了类型转换,不等于参数有效。比如用户 ID 不能为负数、分页大小不能超过 100、手机号要符合格式,这些校验放在哪一层?
我的建议是分两层:Dispatcher 只负责类型层面的校验,比如缺参、类型不匹配;业务语义校验放在业务方法内部。这样做的好处是,如果 Dispatcher 层做太多业务校验,校验规则就散落在多个地方且和各接口混合,后期维护时会发现同一个参数的校验逻辑重复写了十几次。而把校验放到业务层后,新接口在代码评审里也更容易被检查到。
遇到字段特别多的复杂对象,我推荐用 JSR-303 或类似注解先做 bean validation,业务方法入口统一校验注解标记。这个方式让参数校验的代码量大幅度下降,而且规则可视化程度高。
5. Dispatcher 的扩展点设计:拦截器链与动态路由的实战思路
5.1 拦截器链:让日志、鉴权、限流从业务代码里剥离
Dispatcher 模块最值得投入的扩展点,就是拦截器链。我最初实现时直接在 for 循环里调用所有拦截器,后来接入场景越来越多,改成了职责链模式,每个拦截器实现一个统一的接口:
public interface JsonRpcInterceptor { default boolean preHandle(InvocationContext context) { return true; } default void postHandle(InvocationContext context, Object result) {} default void afterCompletion(InvocationContext context, Throwable error) {} }preHandle 返回 false 时会短路后续拦截器和业务方法,常用于鉴权拦截器——权限不足时直接抛异常,业务方法根本不会执行。postHandle 在业务方法返回后调用,可以修改响应结果,比如统一脱敏。afterCompletion 不管业务成功与否都会执行,适合做日志埋点、指标统计。
拦截器链的顺序管理要注意。我在注解上增加了 order 字段,数值小的先执行。鉴权类拦截器的 order 通常设得很小,日志埋点次之,限流和黑白名单要放在最前面,否则不符合条件的请求会先产生业务日志,导致日志里出现大量被限流的烟雾弹。
5.2 动态路由:服务不重启,也能临时替换某个方法实现
静态路由表适合大多数场景,但总有一些紧急需求,比如某个线上接口的行为需要临时调整,又等不及发版。我实现过一种动态路由机制,理论上是在路由表里叠加一层"临时覆盖表",Map<String, String> 结构,把方法名映射到另一个 bean 的某个方法上:
public MethodHandler resolve(String methodName) { String overrideTarget = overrideTable.get(methodName); if (overrideTarget != null) { return dynamicTable.get(overrideTarget); } return routeTable.get(methodName); }动态路由在应急变更时很管用,比如临时将流量切换到新版本实现,不需要改代码重新发版。但风险点也要讲清楚:临时覆盖的信息需要可观测、可审计,否则日子久了,某条路由一直是旧逻辑,线上查问题会非常迷茫。我在每次覆盖生效时都会打 info 日志,并在路由管理接口里提供当前覆盖状态的展示。
5.3 泛化调用:让 Dispatcher 支持非标准协议的接入
有些场景下,调用方不方便发送标准 JSON-RPC 数据,比如内部监控系统要把某个方法暴露成 HTTP 接口,或者测试平台需要自由组合参数。我给 Dispatcher 加过一层泛化调用入口,允许调用方传方法名和 JSON 字符串形式的参数,由 Dispatcher 自己做反序列化:
public Object invoke(String methodName, String paramsJson) { JsonNode paramsNode = objectMapper.readTree(paramsJson); MethodHandler handler = resolve(methodName); Object[] args = bindParameters(paramsNode, handler, objectMapper); return handler.invoke(args); }这个扩展在实际运维中帮了很大忙,排查问题时我可以直接用一段脚本调任意接口,不需要再往请求里塞一堆无关字段。测试平台也接入了这个泛化入口,做接口联调的效率提升明显。不过入口要控制好权限,它理论上能调服务内所有被注册的方法,所以只允许内网访问,并且要记录完整的调用日志。
个人经验与最后的建议
接手并重构这个 jsonRpc 项目的 Dispatcher 模块之后,我最大的体会是:Dispatcher 再复杂,核心也不过是一张路由表加一个可靠的执行流程,难点在于把边界画清楚,让业务开发只关心业务方法内部逻辑,而无需理解协议细节。我踩过最大的坑不是反射调用性能,而是异步超时后任务仍然执行带来的状态错乱,这个问题的解法不是靠某个 API,而是靠对线程模型和任务生命周期的完整理解。
如果你想在自己的项目里落地这套设计,建议先从最小的闭环开始:注册表 + 方法调用 + 错误包装,跑通三个接口;再逐步加入参数绑定细节、拦截器和异步模型。每一层都单独验证,不要一开始就想实现完整框架,那样只会让 Dispatcher 从 6000 行变成 3000 行,换了个位置臃肿而已。