@toc
禁止 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 中:
java
@Component
public class FeignHeaderInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
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 中全局设置:
yaml
feign:
client:
config:
default:
connectTimeout: 5000
readTimeout: 10000
问题是:不同调用的超时需求差异很大。
- 查询用户基本信息:50ms 就够了
- 生成报表导出:可能需要 30 秒
- 批量同步数据:可能要 1 分钟
用 Feign 要么全局改(影响其他调用),要么每个 Client 单独配配置类(代码膨胀)。
更严重的是,在微服务调用链中,如果 A → B → C 每层都用 10 秒超时,最终用户可能要等 30 秒才拿到响应。这就是超时累积效应------调用链越长,最终响应越慢。
2.3 降级处理复杂
Feign 的降级(fallback)依赖 Hystrix 或 Resilience4j:
java
@FeignClient(name = "user-service", fallback = UserClientFallback.class)
public interface UserClient {
@GetMapping("/api/user/info")
Resp<UserDto> getUserInfo(@RequestParam String userId);
}
@Component
public class UserClientFallback implements UserClient {
@Override
public Resp<UserDto> getUserInfo(String userId) {
return Resp.error("用户服务暂时不可用");
}
}
每个接口都要写一个 Fallback 实现类。服务越多,Fallback 类越多,代码量直线上升。
而且,Fallback 是接口级别的,不是调用级别的。同一个接口,有的调用方需要降级,有的不需要,但 Feign 只能配一个全局策略。
2.4 接口定义和实现割裂
Feign 要求调用方定义接口:
java
@FeignClient(name = "user-service")
public interface UserClient {
@PostMapping("/api/admin/sysUser/getInfo")
Resp<UserDto> getUserInfo(@RequestBody InternalReq req);
}
但接口的实际实现是被调用方的 Controller。当被调用方改了接口路径、参数类型、返回格式时,调用方的 Feign 接口不会有任何编译期提示------只有在运行时调用失败才会发现问题。
对于服务数量多、迭代频繁的项目,这种割裂感会非常痛苦。
三、InternalServiceClient 的设计
3.1 核心设计理念
MetaLite 的 InternalServiceClient 设计哲学很明确:一个统一的调用入口,自动处理所有横切关注点。
java
public class InternalServiceClient {
public void callOneInstance(RpcRequest rpcRequest) { }
public <T> Resp<T> callOneInstanceRtnData(RpcRequest rpcRequest, Class<T> respDataType) { }
public <T> Resp<List<T>> callOneInstanceRtnListData(RpcRequest rpcRequest, Class<T> respDataType) { }
public <E> Resp<PageResultDto<E>> callOneInstanceRtnPageData(RpcRequest rpcRequest, Class<E> respDataType) { }
}
只有 4 个核心方法,覆盖所有调用场景:
| 方法 | 用途 |
|---|---|
callOneInstance |
调用单个实例,无返回值 |
callOneInstanceRtnData |
调用单个实例,返回单个对象 |
callOneInstanceRtnListData |
调用单个实例,返回集合 |
callOneInstanceRtnPageData |
调用单个实例,返回分页数据 |
3.2 上下文自动传递
这是 InternalServiceClient 最核心的能力之一。在 checkAndFillRpcRequest 方法中,自动注入所有上下文:
java
private RpcRequest checkAndFillRpcRequest(RpcRequest rpcRequest) {
// 构建内部请求头,自动传递全链路上下文
Map<String, String> headers = new HashMap<>();
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);
}
return rpcRequest;
}
开发者不需要关心 Header 怎么传、TraceId 怎么带过去、Seata XID 怎么跨服务传播------只要用 InternalServiceClient,这些全部自动处理。
3.3 超时逐层递减
这是 InternalServiceClient 区别于 Feign 的关键设计。在 checkAndFillRpcRequest 中:
java
// 超时时间逐层递减
int timeoutMillis = rpcRequest.getTimeoutMillis();
timeoutMillis = timeoutMillis <= 0 ? API_REQUEST_TIMEOUT_MILLIS : timeoutMillis - API_REQUEST_TIMEOUT_MILLIS_MINUS;
rpcRequest.setTimeoutMillis(timeoutMillis);
什么意思?
ini
A → B → C → D
A 发起调用时超时 = 5000ms
B 收到调用时超时 = 5000 - 500 = 4500ms
C 收到调用时超时 = 4500 - 500 = 4000ms
D 收到调用时超时 = 4000 - 500 = 3500ms
每经过一层,超时时间自动递减。这保证了:
- 调用链越长,下游超时越短,避免下游服务长时间等待
- 最终用户等待时间有上限,不会出现调用链累积导致的超长等待
- 下游服务更容易快速失败,减少雪崩风险
3.4 基于 Nacos 服务发现
InternalServiceClient 底层通过 Nacos 做服务发现,调用时指定服务名即可:
java
RpcRequest rpcRequest = 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 要求在调用时显式指定响应数据类型:
java
// 单个对象
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);
内部会严格校验类型:
java
paramCheck(!Collection.class.isAssignableFrom(respDataType), "respDataType", "不能是集合类型");
如果类型不匹配,在调用方就会立即报错,而不是在下游服务里静默失败。
3.6 切面链增强
InternalServiceClient 的所有方法都被 ApiCallAspect 拦截:
java
@Around("execution(public * com.metalite.rpc.InternalServiceClient.call*(..))")
private Object aroundBaseInternalServiceClient(ProceedingJoinPoint pjp) throws Throwable {
// 统一的日志、监控、异常处理
}
这意味着每次 RPC 调用都会自动经过:
- 日志记录:调用方、被调方、耗时、响应码
- 异常处理 :统一封装为
Resp格式 - 监控埋点:调用成功率、延迟分布
开发者不需要手动加 try-catch、打日志、做监控------全部自动完成。
四、代码对比
4.1 Feign 方式
java
// 1. 定义 Feign 接口
@FeignClient(name = "user-service", fallback = UserClientFallback.class)
public interface UserClient {
@PostMapping("/api/admin/sysUser/getInfo")
Resp<UserDto> getUserInfo(@RequestBody InternalReq req);
}
// 2. 写 Fallback 实现
@Component
public class UserClientFallback implements UserClient {
@Override
public Resp<UserDto> getUserInfo(InternalReq req) {
return Resp.error("用户服务暂时不可用");
}
}
// 3. 写 RequestInterceptor 传递上下文
@Component
public class FeignHeaderInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
template.header("traceId", ThreadContext.getTraceId());
template.header("userId", ThreadContext.getLoginUserId());
// ... 每个 Header 都要手动加
}
}
// 4. 调用方使用
@Resource
private UserClient userClient;
public Resp<UserDto> getUser(String userId) {
InternalReq req = new InternalReq();
req.putParam("userId", userId);
return userClient.getUserInfo(req); // 没有超时递减
}
4.2 InternalServiceClient 方式
java
@Resource
private InternalServiceClient internalServiceClient;
public Resp<UserDto> getUser(String userId) {
InternalReq req = new InternalReq();
req.putParam("userId", userId);
RpcRequest rpcRequest = RpcRequest.builder()
.provider("user-service")
.endpoint("/api/admin/sysUser/getInfo")
.param(req)
.rpcMode(RpcModeEnum.HTTP)
.build();
// 一行调用:上下文自动传、超时自动减、响应自动转换
return internalServiceClient.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 (gitee.com/MetaLite)