禁止 Feign!我们为什么自研 InternalServiceClient
文章目录
- 禁止 Feign!我们为什么自研 InternalServiceClient
- 一、微服务跨服务调用,为什么不用 Feign?
- 二、Feign 的痛点
- 2.1 上下文传递困难
- 2.2 超时控制不灵活
- 2.3 降级处理复杂
- 2.4 接口定义和实现割裂
- 三、InternalServiceClient 的设计
- 3.1 核心设计理念
- 3.2 上下文自动传递
- 3.3 超时逐层递减
- 3.4 基于 Nacos 服务发现
- 3.5 响应类型安全
- 3.6 切面链增强
- 四、代码对比
- 4.1 Feign 方式
- 4.2 InternalServiceClient 方式
- 五、总结设计哲学
禁止 Feign!我们为什么自研 InternalServiceClient
微服务跨服务调用,90% 的人用 Feign——但我们选择不。
一、微服务跨服务调用,为什么不用 Feign?
国内做 Spring Cloud 微服务,跨服务调用几乎只有一个答案:Feign。
OpenFeign 确实是经典方案:声明式接口、自动序列化、集成 Ribbon 负载均衡。但用久了就会发现,它解决了一些问题的同时,引入了更多新问题。
在 MetaLite 的设计过程中,我们做了一个决定:禁止使用 Feign,自研 InternalServiceClient。
这个决定在团队内部也争论了很久。毕竟 Feign 是 Spring Cloud 的标配,不用它意味着要自己写一套调用框架。但经过几个项目的踩坑,我们确信这是一个正确的选择。
这篇文章,我把 Feign 的痛点、InternalServiceClient 的设计思路、以及两者的代码对比说清楚。看完之后,你可以自己判断。
二、Feign 的痛点
2.1 上下文传递困难
微服务调用链中,有很多上下文信息需要在服务间传递:
- TraceId:全链路追踪,排查问题刚需
- 用户 ID:下游服务需要知道是谁发起的请求
- AppId:调用方标识,用于权限控制和审计
- Seata XID:分布式事务的 transaction ID
用 Feign 时,这些信息需要通过RequestInterceptor手动注入到 HTTP Header 中:
@ComponentpublicclassFeignHeaderInterceptorimplementsRequestInterceptor{@Overridepublicvoidapply(RequestTemplatetemplate){template.header("traceId",ThreadContext.getTraceId());template.header("userId",ThreadContext.getLoginUserId());template.header("appId",ThreadContext.getAppId());template.header("X-Seata-XID",ThreadContext.getSeataXid());}}看似没问题,但有两个隐患:
- 隐式依赖:Feign 的 Header 传递是全局拦截器做的,某个服务忘了配拦截器,上下文就断了,排查困难
- 传递逻辑分散:TraceId、用户 ID、XID 各自在不同的拦截器或拦截逻辑中处理,缺少统一入口
2.2 超时控制不灵活
Feign 的超时配置粒度很粗。通常是在application.yml中全局设置:
feign:client:config:default:connectTimeout:5000readTimeout:10000问题是:不同调用的超时需求差异很大。
- 查询用户基本信息:50ms 就够了
- 生成报表导出:可能需要 30 秒
- 批量同步数据:可能要 1 分钟
用 Feign 要么全局改(影响其他调用),要么每个 Client 单独配配置类(代码膨胀)。
更严重的是,在微服务调用链中,如果 A → B → C 每层都用 10 秒超时,最终用户可能要等 30 秒才拿到响应。这就是超时累积效应——调用链越长,最终响应越慢。
2.3 降级处理复杂
Feign 的降级(fallback)依赖 Hystrix 或 Resilience4j:
@FeignClient(name="user-service",fallback=UserClientFallback.class)publicinterfaceUserClient{@GetMapping("/api/user/info")Resp<UserDto>getUserInfo(@RequestParamStringuserId);}@ComponentpublicclassUserClientFallbackimplementsUserClient{@OverridepublicResp<UserDto>getUserInfo(StringuserId){returnResp.error("用户服务暂时不可用");}}每个接口都要写一个 Fallback 实现类。服务越多,Fallback 类越多,代码量直线上升。
而且,Fallback 是接口级别的,不是调用级别的。同一个接口,有的调用方需要降级,有的不需要,但 Feign 只能配一个全局策略。
2.4 接口定义和实现割裂
Feign 要求调用方定义接口:
@FeignClient(name="user-service")publicinterfaceUserClient{@PostMapping("/api/admin/sysUser/getInfo")Resp<UserDto>getUserInfo(@RequestBodyInternalReqreq);}但接口的实际实现是被调用方的 Controller。当被调用方改了接口路径、参数类型、返回格式时,调用方的 Feign 接口不会有任何编译期提示——只有在运行时调用失败才会发现问题。
对于服务数量多、迭代频繁的项目,这种割裂感会非常痛苦。
三、InternalServiceClient 的设计
3.1 核心设计理念
MetaLite 的InternalServiceClient设计哲学很明确:一个统一的调用入口,自动处理所有横切关注点。
publicclassInternalServiceClient{publicvoidcallOneInstance(RpcRequestrpcRequest){}public<T>Resp<T>callOneInstanceRtnData(RpcRequestrpcRequest,Class<T>respDataType){}public<T>Resp<List<T>>callOneInstanceRtnListData(RpcRequestrpcRequest,Class<T>respDataType){}public<E>Resp<PageResultDto<E>>callOneInstanceRtnPageData(RpcRequestrpcRequest,Class<E>respDataType){}}只有 4 个核心方法,覆盖所有调用场景:
| 方法 | 用途 |
|---|---|
callOneInstance | 调用单个实例,无返回值 |
callOneInstanceRtnData | 调用单个实例,返回单个对象 |
callOneInstanceRtnListData | 调用单个实例,返回集合 |
callOneInstanceRtnPageData | 调用单个实例,返回分页数据 |
3.2 上下文自动传递
这是 InternalServiceClient 最核心的能力之一。在checkAndFillRpcRequest方法中,自动注入所有上下文:
privateRpcRequestcheckAndFillRpcRequest(RpcRequestrpcRequest){// 构建内部请求头,自动传递全链路上下文Map<String,String>headers=newHashMap<>();headers.put(TRACE_ID,ThreadContext.getTraceId());headers.put(LOGIN_USER_ID,ThreadContext.getLoginUserId());headers.put(APP_ID,ThreadContext.getAppId());headers.put(SEATA_XID,ThreadContext.getSeataXid());if(rpcRequest.getHeaders()==null){rpcRequest.setHeaders(headers);}else{rpcRequest.getHeaders().putAll(headers);}returnrpcRequest;}开发者不需要关心 Header 怎么传、TraceId 怎么带过去、Seata XID 怎么跨服务传播——只要用 InternalServiceClient,这些全部自动处理。
3.3 超时逐层递减
这是 InternalServiceClient 区别于 Feign 的关键设计。在checkAndFillRpcRequest中:
// 超时时间逐层递减inttimeoutMillis=rpcRequest.getTimeoutMillis();timeoutMillis=timeoutMillis<=0?API_REQUEST_TIMEOUT_MILLIS:timeoutMillis-API_REQUEST_TIMEOUT_MILLIS_MINUS;rpcRequest.setTimeoutMillis(timeoutMillis);什么意思?
A → B → C → D A 发起调用时超时 = 5000ms B 收到调用时超时 = 5000 - 500 = 4500ms C 收到调用时超时 = 4500 - 500 = 4000ms D 收到调用时超时 = 4000 - 500 = 3500ms每经过一层,超时时间自动递减。这保证了:
- 调用链越长,下游超时越短,避免下游服务长时间等待
- 最终用户等待时间有上限,不会出现调用链累积导致的超长等待
- 下游服务更容易快速失败,减少雪崩风险
3.4 基于 Nacos 服务发现
InternalServiceClient 底层通过 Nacos 做服务发现,调用时指定服务名即可:
RpcRequestrpcRequest=RpcRequest.builder().provider("user-service")// 服务名,Nacos 自动发现.endpoint("/api/admin/sysUser/getInfo").param(internalReq).rpcMode(RpcModeEnum.HTTP).build();Resp<UserDto>resp=internalServiceClient.callOneInstanceRtnData(rpcRequest,UserDto.class);不需要 Feign 那样的@FeignClient(name = "user-service")注解绑定服务名。服务名在每次调用时显式指定,清晰且可控。
3.5 响应类型安全
Feign 返回的是接口定义的类型,但类型转换在运行时才校验。InternalServiceClient 要求在调用时显式指定响应数据类型:
// 单个对象Resp<UserDto>resp=internalServiceClient.callOneInstanceRtnData(rpcRequest,UserDto.class);// 集合Resp<List<OrderDto>>resp=internalServiceClient.callOneInstanceRtnListData(rpcRequest,OrderDto.class);// 分页Resp<PageResultDto<UserDto>>resp=internalServiceClient.callOneInstanceRtnPageData(rpcRequest,UserDto.class);内部会严格校验类型:
paramCheck(!Collection.class.isAssignableFrom(respDataType),"respDataType","不能是集合类型");如果类型不匹配,在调用方就会立即报错,而不是在下游服务里静默失败。
3.6 切面链增强
InternalServiceClient 的所有方法都被ApiCallAspect拦截:
@Around("execution(public * com.metalite.rpc.InternalServiceClient.call*(..))")privateObjectaroundBaseInternalServiceClient(ProceedingJoinPointpjp)throwsThrowable{// 统一的日志、监控、异常处理}这意味着每次 RPC 调用都会自动经过:
- 日志记录:调用方、被调方、耗时、响应码
- 异常处理:统一封装为
Resp格式 - 监控埋点:调用成功率、延迟分布
开发者不需要手动加 try-catch、打日志、做监控——全部自动完成。
四、代码对比
4.1 Feign 方式
// 1. 定义 Feign 接口@FeignClient(name="user-service",fallback=UserClientFallback.class)publicinterfaceUserClient{@PostMapping("/api/admin/sysUser/getInfo")Resp<UserDto>getUserInfo(@RequestBodyInternalReqreq);}// 2. 写 Fallback 实现@ComponentpublicclassUserClientFallbackimplementsUserClient{@OverridepublicResp<UserDto>getUserInfo(InternalReqreq){returnResp.error("用户服务暂时不可用");}}// 3. 写 RequestInterceptor 传递上下文@ComponentpublicclassFeignHeaderInterceptorimplementsRequestInterceptor{@Overridepublicvoidapply(RequestTemplatetemplate){template.header("traceId",ThreadContext.getTraceId());template.header("userId",ThreadContext.getLoginUserId());// ... 每个 Header 都要手动加}}// 4. 调用方使用@ResourceprivateUserClientuserClient;publicResp<UserDto>getUser(StringuserId){InternalReqreq=newInternalReq();req.putParam("userId",userId);returnuserClient.getUserInfo(req);// 没有超时递减}4.2 InternalServiceClient 方式
@ResourceprivateInternalServiceClientinternalServiceClient;publicResp<UserDto>getUser(StringuserId){InternalReqreq=newInternalReq();req.putParam("userId",userId);RpcRequestrpcRequest=RpcRequest.builder().provider("user-service").endpoint("/api/admin/sysUser/getInfo").param(req).rpcMode(RpcModeEnum.HTTP).build();// 一行调用:上下文自动传、超时自动减、响应自动转换returninternalServiceClient.callOneInstanceRtnData(rpcRequest,UserDto.class);}对比一下:
| 维度 | Feign | InternalServiceClient |
|---|---|---|
| 接口定义 | 需要定义 Feign 接口 + Fallback 类 | 不需要,直接构造 RpcRequest |
| 上下文传递 | 需要手动写 RequestInterceptor | 自动注入 |
| 超时控制 | 全局配置或每个 Client 单独配 | 逐层自动递减 |
| 降级处理 | 每个接口写 Fallback 实现 | 响应体统一处理 |
| 类型安全 | 运行时校验 | 调用时显式指定 |
| 监控日志 | 需要额外配置 | 切面自动处理 |
五、总结设计哲学
禁止 Feign 不是因为我们觉得 Feign 不好,而是因为Feign 解决的是"能不能调"的问题,但微服务更需要的是"调得好"的问题。
InternalServiceClient 的设计哲学:
- 约定优于配置:上下文传递、超时递减、切面监控全部自动,不需要开发者操心
- 显式优于隐式:服务名、端点、响应类型每次调用都显式指定,不留隐患
- 统一入口优于分散定义:一个 Client 覆盖所有调用场景,不需要为每个服务写单独的接口
这不是对 Feign 的否定,而是对微服务调用本质的另一种理解。
InternalServiceClient 的这套设计哲学,贯穿了整个 MetaLite 框架——约定优于配置、显式优于隐式、统一入口优于分散定义。
框架简介:元界 MetaLite — 下一代企业级 Java 微服务技术底座
作者简介:基于 Spring 体系 15 年企业级开发经验,专注于通过企业级生产环境落地的工程思维和架构思想打造下一代Java 微服务技术底座
完整文档与源码:Gitee 搜索 MetaLite (https://gitee.com/MetaLite)
