禁止 Feign!我们为什么自研 InternalServiceClient

@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)

相关推荐
用户125758524361 小时前
进销存后台别急着上线,先重放一次退货请求
人工智能·后端·go
qq_22589174661 小时前
基于Python的城市内涝积涝监测数据可视化分析系统
后端·python·信息可视化·数据分析·django
苏三说技术1 小时前
为什么越来越多人用Apache Tika?
后端
Zane19941 小时前
Lock 接口与 AQS 核心原理:手写理解一把可重入锁是怎么运作的
java·后端
Full Stack Developme1 小时前
SpringBoot 整合 Druid 并列出参数清单
java·spring boot·后端
明月_清风2 小时前
🕵️ MEV 与链上交易:理解区块链的"暗面"
后端·web3
明月_清风2 小时前
🤖 Web3 + AI 融合:去中心化智能的未来
后端·web3
swipe2 小时前
13|(前端转全栈)支付成功不等于结束:回调、幂等、超时关单和状态竞争
前端·后端·面试
swipe2 小时前
12|(前端转全栈)点击提交订单后,后端如何用事务守住价格、库存和订单?
前端·后端·面试