ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

jsonRpc Dispatcher模块核心设计:从路由表到并发控制的实践

jsonRpc Dispatcher模块核心设计:从路由表到并发控制的实践 接手过一个内部 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 的核心数据结构就是一张路由表最简单也最可靠的形式是MapString, MethodHandler routeTable;String 是方法完整名比如user.loginMethodHandler 封装了目标 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 里的缓存、数据库里的状态、甚至一些外部接口仍然被执行了。处理这种结果竞争我总结了一套固定套路CompletableFutureObject 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 CompletableFutureT submit(CallableT task) { MapString, String snapshot ContextHolder.snapshot(); return CompletableFuture.supplyAsync(() - { MapString, 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 这层只用这些标准码来标识协议层面的错误错误码名称使用时机-32700Parse error服务端无法解析 JSON 文本-32600Invalid Request请求对象结构不完整-32601Method not found路由表里找不到对应方法-32602Invalid params参数绑定失败-32603Internal 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、数据库连接超时绝不能让堆栈直接暴露给客户端这类统一返回 -32603message 固定写 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 动态路由服务不重启也能临时替换某个方法实现静态路由表适合大多数场景但总有一些紧急需求比如某个线上接口的行为需要临时调整又等不及发版。我实现过一种动态路由机制理论上是在路由表里叠加一层临时覆盖表MapString, 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 行换了个位置臃肿而已。
返回列表