首页 / 资讯中心 / 文章详情

Egg.js集成SOFA RPC实践:服务拆分中的通信改造全记录

Egg.js集成SOFA RPC实践:服务拆分中的通信改造全记录 ★ FEATURED ARTICLE
先说我最近做的一个真实改造。某个内部项目原本是单体应用所有模块都在同一个进程里后来要拆出独立的商品中心和库存中心最直接的问题就来了两个Node.js服务之间到底用什么通信一开始我图省事直接用HTTPJSON互相调。后来压测发现调用耗时高居不下而且没有服务发现、没有超时熔断每次扩容改IP都要人肉改配置。于是我开始认真调研RPC方案最终落地了Egg.js和SOFA RPC的组合用egg-sofa-rpc这个插件在同一个应用里同时搭起了RPC服务端和RPC客户端。这篇文章就是这次实践的完整复盘从方案选型、代码落地到排错记录把我踩过的坑和验证过能用的做法都写出来给正在做类似改造的朋友一个参考。1. 为什么Egg.js项目需要RPC框架1.1 HTTP不是万能药RPC是不同量级的方案先聊一个基本问题HTTPJSON已经很简单了为什么还要用RPC我习惯用一个比喻来解释两者的区别。HTTP调用就像写信每次都要写好信封URL、填好邮编端口、贴好邮票认证送到邮局网关再分发。RPC调用更像打内线电话只要知道分机号接口名方法名拨过去就能直接对线运营商框架负责接通、维持线路、忙时转接。这个区别在简单场景下不明显但一旦服务数量多起来差异就会被放大。HTTP最大的优势是简单通用任何语言都能调调试也方便curl一发就知道结果。但如果你的服务有几十个Provider、上百个Consumer需要统一做服务发现、负载均衡、熔断限流时HTTP那一套的治理成本会逐渐失控。RPC框架则把这些能力内置了SOFA RPC在这块尤其成熟支持注册中心、路由规则、权重配置、故障隔离等功能相当于把通信层所有复杂的治理逻辑都沉淀在了框架里。当然RPC并不是银弹。如果你们的服务数量很少、调用量不大、团队对RPC不熟悉那就别折腾HTTPJSON足够用。RPC的价值在规模化之后才真正体现出来这是做技术选型时最重要的一条判断标准。1.2 选型坐标系搞清楚你的真实需求选RPC框架前先对着下面几个问题自我拷问一遍把答案写下来服务规模当前有多少个需要互相调用的服务未来会涨到多少性能要求单次调用允许的P99延迟是多少预估QPS有多少治理需求是否需要服务发现、动态路由、熔断、链路追踪团队背景团队对某个协议、某个生态是否已经有经验技术栈服务端是统一技术栈还是有多语言并存这些问题决定了你最终是选SOFA RPC、gRPC还是干脆就用HTTPJSON。我遇到过不少团队一上来就照着大厂的架构选型结果服务只有几个却把服务发现、熔断、链路追踪全上了一套运维成本高得离谱。选型不是选最牛的而是选最匹配的。1.3 egg-sofa-rpc模块的位置与价值Egg.js本身就是一套高度插件化的框架几乎所有扩展能力都挂在插件机制上。egg-sofa-rpc这个插件做的事情就是让Egg.js应用可以直接和SOFA RPC体系对接免去你自己去实现协议解析、连接管理、服务注册这些底层活。用了这个插件之后你的应用可以在一个进程里同时拥有两种身份服务提供者Provider把本地Service能力发布成RPC接口供其他系统调用服务消费者Consumer通过RPC客户端调用别的系统发布的接口。这对做中台化、服务化改造特别有用一个业务应用一边暴露自己的能力一边消费公共服务完全不需要维护两套连接逻辑。我最初决定使用它还有一个现实原因SOFA RPC支持直连模式。可以在不依赖注册中心的情况下直接指定目标地址进行调用这对本地调试和联调阶段的体验提升非常明显不需要先起一套注册中心改个配置就能跑通整条链路。这一点在很多RPC框架里并不容易做到。2. 方案预研与选型考量2.1 主流RPC方案横向对比我在调研阶段列过一个对比表把主流的几种方案放在一起看维度SOFA RPCgRPCHTTPJSON通信协议BOLT(TCP长连接)HTTP/2HTTP/1.1序列化方式Hessian2、Protobuf等ProtobufJSON服务发现内置支持注册中心需额外集成需自建或引入网关负载均衡内置多种策略需配合一般靠网关异步/回调支持支持需自研跨语言支持极佳任何语言与Egg.js亲和度高同生态中高学习成本中中高低从表里能看出来每种方案都不是完美无缺的。gRPC跨语言能力最强但需要定义proto文件对团队要求相对高HTTPJSON最平庸但最省事SOFA RPC在Node.js与Java体系内融会贯通得比较好对混合技术栈场景确实顺滑。2.2 为什么最终选择了SOFA RPC这里的选型依据我可以比较直白地分享。第一团队以Node.js和Java两套技术栈混合SOFA RPC在这两个生态里都有成熟的接入方式避免了服务之间因为协议不一致而各搞一套的麻烦。第二项目需要服务发现和负载均衡SOFA RPC内置的能力可以直接用省掉了额外搭一套注册中心客户端和路由层的成本。第三Egg.js生态对SOFA体系天然友好插件挂载即用不需要学习太多框架以外的概念。另一个很现实的考虑是直连模式。我在前面已经提过怀疑论者可以专门测一下不依赖注册中心直接配置目标服务地址就能发起调用。这在本地开发和联调阶段能省掉大量时间成本尤其是前端需要对接某个分支上的后端服务时直连模式可以精准地把流量导向指定的代码版本。2.3 引入前的成本与风险评估任何一种技术引入都不是免费的。使用egg-sofa-rpc之前至少要评估这么几项成本连接管理RPC客户端要维护长连接池进程多了之后TCP连接数量需要关注注册中心依赖如果走注册中心模式ZooKeeper这类组件的高可用就变成了新的运维负担接口维护RPC接口的变更需要同步更新Consumer版本管理比HTTP接口更严格排查手段HTTP可以拿curl直接调试RPC的排错需要借助框架自带的日志和监控工具。如果运维团队对注册中心这类基础设施的运维经验不足我的建议是前期先上直连模式跑通业务等基础设施和团队能力都补齐了再逐步引入注册中心。不要一上来就把所有依赖全压上步子太大容易扯到自己。3. 服务端搭建实战把本地能力发布成RPC接口3.1 环境准备与最小依赖我的演示环境是Node.js 14及以上我本地用的16.xEgg.js 2.x或者3.x都可以建议直接用脚手架创建新项目egg-sofa-rpc通过npm安装。先初始化一个Egg.js项目然后在项目根目录执行安装npm init egg --typesimple npm install egg-sofa-rpc --save安装完成后打开config/plugin.js把插件启用use strict; exports.sofaRpc { enable: true, package: egg-sofa-rpc, };这一步非常关键。很多同学装完包却忘了在plugin.js里enable导致应用启动时完全没有加载这个插件后续所有配置都不生效。如果你发现启动日志里看不到sofa相关的信息第一件事就去检查这里。3.2 服务端核心配置解析接下来在config/config.default.js中加一段配置use strict; exports.sofaRpc { // RPC服务端 server: { begin: true, // 是否启动RPC服务端 port: 12200, // RPC服务监听的端口和HTTP端口分开 protocol: bolt, // 通信协议 }, // RPC客户端 client: { begin: true, // 是否启动RPC客户端 }, // 注册中心 registry: { type: zookeeper, address: 127.0.0.1:2181, }, // 直连模式本地联调用 directConnect: { productService: 127.0.0.1:12200, }, };注意几个细节RPC端口一定要和Egg.js的HTTP端口默认7001区分开避免端口冲突begin: true不是默认值务必显式开启registry和directConnect可以同时配置有了directConnect之后本地测试时不依赖注册中心也能调用协议字段如果配置了bolt那么客户端和服务端必须都用bolt不允许一边bolt一边http这一点在排查问题时经常遇到。3.3 定义并实现RPC服务不同版本的egg-sofa-rpc暴露的API命名可能略有差异下面这套写法是我在实际项目中使用的你如果用了不同版本对照文档做一下替换即可。按约定在app/rpc目录下放RPC服务的实现文件。以商品服务为例新建app/rpc/productService.jsuse strict; const { RpcService } require(egg-sofa-rpc); module.exports app { class ProductRpcService extends RpcService { // 接口名注册中心按这个标识路由 static get interfaceName() { return productService; } // 版本号接口升级的时候使用 static get version() { return 1.0.0; } // 具体的RPC方法 async getProductById(request) { const { productId } request; app.logger.info([sofa-rpc-server] receive getProductById request:, request); const product await app.model.Product.findByPk(productId); if (!product) { return { success: false, message: product not found }; } return { success: true, data: { id: product.id, name: product.name, price: product.price, }, }; } } return ProductRpcService; };这里有几点经验值得展开。RpcService基类封装了请求接收、参数校验、异常捕获的逻辑我们只需要实现业务方法。方法的入参和返回值尽量用简单可序列化的对象。SOFA RPC默认支持Hessian2序列化复杂的类实例、带循环引用的对象容易在序列化环节出问题。还有方法的异常处理一定要做好如果RPC方法内部直接抛异常框架会把它包装成远程异常返回给调用方很多调用方看到错误根本不知道是服务端还是客户端出的问题。我习惯在方法内部用try/catch包一层统一返回{ success, message, data }这种结构。3.4 服务启动与注册验证代码写完之后启动应用npm run dev观察启动日志重点看有没有出现类似这样的信息[egg-sofa-rpc] rpc server is listening on 0.0.0.0:12200 [egg-sofa-rpc] publish success: productService:1.0.0如果配置了注册中心日志里还会出现注册成功的提示。这时可以再使用注册中心的管理工具查看服务节点是否已经出现在预期路径下。我强烈建议在本地做一次最小闭环验证跳过业务代码直接用一个临时脚本连接RPC服务端调用getProductById方法// scripts/verify-rpc.js const path require(path); const { RpcClient } require(egg-sofa-rpc); const client new RpcClient({ directConnect: { productService: 127.0.0.1:12200, }, }); client.invoke({ interfaceName: productService, version: 1.0.0, method: getProductById, args: [{ productId: 1001 }], }).then(res { console.log(res); client.close(); }).catch(err { console.error(err); client.close(); });不要跳过这一步直接去写业务调用代码因为链路越长出问题时越难定位。先把这个最小闭环跑通再往上搭业务后面基本就是顺水推舟的事。4. 客户端调用实战在Egg.js中消费RPC服务4.1 客户端配置与连接原理回到前面的配置客户端的关键开关是client.begin: true。启用后应用在启动阶段会初始化一个RPC客户端根据注册中心或直连配置拿到服务地址列表。这里有一个很重要的机制服务列表缓存。RPC客户端不是每次调用都去注册中心拉服务列表而是在启动时拉取一次并缓存到本地同时监听注册中心的变更事件动态更新列表。这意味着如果Provider挂了注册中心会通知Consumer剔除节点如果Provider新增了实例Consumer也能感知到。但这个感知过程不是瞬时的会有几秒的延迟。在故障演练时要有这个预期不要看到几秒内的失败调用就以为方案有问题。4.2 编写客户端封装层直接伸手够框架API虽然能跑但不利于维护。我建议在Egg.js的Service层包一层把RPC调用的细节全部收拢起来// app/service/rpcProductClient.js use strict; const Service require(egg).Service; class RpcProductClientService extends Service { async getProductById(productId) { const { app } this; // 发起远程调用 const result await app.sofaRpcClient.invoke({ interfaceName: productService, version: 1.0.0, method: getProductById, args: [{ productId }], timeout: 3000, // 毫秒 }); return result; } } module.exports RpcProductClientService;然后在Controller里正常使用// app/controller/product.js use strict; const Controller require(egg).Controller; class ProductController extends Controller { async getInfo() { const { ctx, app } this; const { id } ctx.query; const rpcResult await ctx.service.rpcProductClient.getProductById(id); if (!rpcResult || !rpcResult.success) { ctx.status 404; ctx.body { message: product not found }; return; } ctx.body rpcResult.data; } } module.exports ProductController;封装的核心要点是超时时间、重试策略、目标接口标识统一放在客户端Service里管理。业务Controller不需要知道RPC的任何细节它只知道自己调用了一个Service层的方法。这样后续如果要换框架、调整注册中心地址改动都被限制在Service层一个文件里不会牵扯到整个业务代码。4.3 超时、重试与容错策略RPC调用比本地方法调用多了一个不可控因素网络。所以超时和容错必须认真设计。超时时间怎么定我的经验是去看服务端接口的TP99然后在此基础上留出30%到50%的余量。比如商品接口的TP99是500ms那么客户端超时设到1秒左右比较合理。设太长会让故障请求堆积在客户端线程上设太短又容易引起误报这个平衡只能靠实际压测数据来确定。重试要格外谨慎。我见过线上事故就是因为默认重试2次结果一次慢查询的流量被放大成三倍直接把服务端打挂。除非你确认目标接口是幂等的否则不要在生产环境开启重试。如果一定要开限制在1次并且让重试请求落在不同节点上避免同一个节点连续被打两次。容错策略有几个层次兜底返回RPC失败时返回本地缓存数据或默认值快速失败超过一定比例的失败任务后直接短路不再发起RPC降级RPC不可用时走本地降级逻辑。在我们的实践里非关键路径上的调用都加了降级处理宁可返回稍旧的数据也不能让一个RPC故障把整个业务页面拖垮。4.4 直连模式与注册中心模式切换直连模式在前面的配置里已经出现过它的配置方式很简单exports.sofaRpc { directConnect: { productService: 192.168.1.10:12200, }, };直连模式适合本地开发、联调、单机验证跳过了注册中心的依赖启动更快排查更容易。但直连模式也有明显问题无法做服务发现Provider扩容之后Consumer不会自动感知没有健康检查Provider挂了你还要手动改配置多环境部署时配置文件容易混乱。所以生产环境必须要切换到注册中心模式。切换时注意两点。第一把directConnect配置去掉避免两种模式同时生效产生混乱第二注册中心地址建议用环境变量统一管理不要每个项目手填IP否则环境迁移就是个灾难。另外一个实用经验在测试环境可以用直连模式对接某个特定分支的服务端实例这样前端调测试环境时后端其实被定向到了你的本地服务对某些复杂联调场景的效率提升非常明显。5. 常见问题与排错实录5.1 Provider启动失败或注册失败典型现象应用启动了但日志里没有出现publish成功的记录。首先检查plugin.js是否enable了egg-sofa-rpc这个原因虽然低级但真的常见。确认配置文件无误后再检查RPC端口是否被占用lsof -i :12200如果有进程占用了端口修改配置换一个端口或者把占用进程处理掉。接下来检查服务实现文件中的interfaceName和version是否正确。注册中心里的服务标识就是这两个字段一旦拼错Consumer就找不到Provider。这个排查点很容易被忽略因为服务端本身启动是正常的只是下游调不到而已。最后确认网络策略。有些云环境的防火墙默认拦截非标准端口RPC端口只被本机和开发机访问没问题但跨机器就不通。排查时可以先测一下端口连通性telnet 192.168.1.10 122005.2 调用超时但Provider日志显示请求已处理这个现象很有迷惑性。我最初排查时也是盯着服务端日志看了半天明明请求都打印出来了但客户端还是超时。后来发现原因在序列化环节返回对象里有一个巨大的字段序列化耗时很长再加上网络传输总耗时超过了客户端超时设置。解决方式有两个方向一是客户端调大超时这是治标二是优化返回结构去掉不必要的大字段这是治本。最好是两个一起做先调大超时止血再优化返回体。另外还要注意一种情况服务端处理很快但客户端连接池满了请求在客户端排队等待被误算成了超时。这时候要检查客户端的连接池配置不要默认值一用到底。用一个简单的压测脚本观察并发上来后是不是就出现排队超时基本就能验证出来。5.3 序列化异常与类型不匹配RPC调用返回的数据结构如果Consumer端和Provider端的字段定义不一致很容易出现反序列化异常。比如Provider返回了{ name, price }Consumer端却按{ title, amount }接收那必然会出问题。这个问题的根治方法是维护一份接口契约文档并把版本号管理严格起来。接口变更时优先新增方法或新增版本而不是原地修改返回结构。我在踩过几次坑之后形成这样一个习惯所有RPC方法的返回结构都定义成{ success: boolean, message?: string, data?: any }并且字段只增不减。哪怕是内部接口也按对外API的标准来约束自己。几年下来因为接口变更引发的线上问题几乎绝迹。5.4 进程泄漏与连接数告警RPC客户端是长连接如果应用频繁创建新的Client实例而不复用很容易把TCP连接数打上去。排查方法是查看进程的fd数lsof -p pid | wc -l正常情况下一个进程的fd数量应该稳定在某个量级。如果持续增长就要怀疑是否有Client泄漏。解决的思路是确保整个应用只维护一个RPC Client实例通过依赖注入或者全局单例来使用不要在请求级别去new client。我遇到过一次比较隐蔽的情况某个中间件里每次调用都去创建一个临时客户端调用完又没关闭连接数一路飙升到几万个最后触发了告警。排查了很久才发现是这种低级的资源管理问题。所以连接池的创建和销毁尽量集中管理不要散落在各处。5.5 问题速查表把前面提到的排查经验整理成一张速查表方便实际遇到问题时快速对照现象可能原因排查建议启动无sofa日志插件未enable检查config/plugin.jspublish成功但调用失败接口名/版本不一致核对双方的interface/version跨机器连接失败防火墙拦截telnet 安全组策略检查参数返回未知类型报错序列化不兼容使用简单类型或契约对象调用偶发超时连接池耗尽或GC抖动观察客户端fd、服务端GC日志接口变更后调用出错契约不一致新增版本保持旧字段6. 落地之后的个人体会项目跑了大半年我对这套方案的体会可以总结成三句话。第一RPC解决的是规模化问题别在小项目里硬上。如果你们只有两三个服务、每天几千次调用HTTPJSON完全够用。像我们这次架构里服务数量已经到两位数RPC的治理能力才开始真正值回票价。第二egg-sofa-rpc的体验很大程度上取决于你对底层SOFA RPC机制的理解。插件把复杂的东西藏起来了但你在排错时还是要懂一点原理才能不走弯路。如果时间允许建议把SOFA RPC的架构文档翻一遍尤其是服务发现、连接管理、协议序列化这三块。第三接入手工写了几千行RPC代码之后再看简化后的核心链路其实非常短Provider把接口发布到注册中心Consumer从注册中心拉列表双方基于长连接通信。只要把这条链路的每个环节摸清用起来就不会慌。最后再分享一个习惯每个RPC方法上线前我都会用脚本单独跑一遍核心链路的压测记下TP50和TP99的基线。这样后面无论做性能优化还是故障排查都用得上。这个习惯帮我提前发现了不止一次潜在的连接池瓶颈和慢接口问题虽然多花了一点时间但带来的确定性值得这个成本。
阅读完成 · 觉得有帮助?
咨询建站