夜雨聆风学习资料网

ARTICLE · 1214193

Feign 动态代理源码拆解:@FeignClient 是怎么变成 HTTP 调用的

Feign 动态代理源码拆解:@FeignClient 是怎么变成 HTTP 调用的

引言

新同事入职第一天写了个调用:定义一个接口 @FeignClient(name = "user-service"),方法上标个 @GetMapping,然后 @Autowired 注入就发 HTTP 请求了。他盯着代码看了十分钟,跑来问我:"这个接口没有任何实现类,Spring 是怎么把它实例化出来的?userMapper.query() 这种调用,方法体里明明什么都没有,参数怎么就变成 JSON、URL 怎么就拼出来了?"

这确实是 Spring Cloud 里体验最"魔法"的一块。更有意思的是上个月刚排的一个线上问题:我们调用户服务的 GET /user/query 查询接口,前端传参是 UserQuery 对象,Feign 一发出去,对端直接回了 405 Method Not Allowed——查了半天发现请求方法变成了 POST,body 里躺着那个 Query 对象。为什么会变?答案就藏在"Java 方法如何被翻译成 HTTP 报文"的翻译官——SpringMvcContract 的解析规则里。

这篇文章就沿着"魔法"的生成路径逐层拆开:**@EnableFeignClients 扫描 → FeignClientFactoryBean 创建代理对象 → JDK 动态代理(ReflectiveFeign.newInstance)→ 方法调用拦截(MethodHandler.invoke)→ 构造 RequestTemplate → Encoder 编码 → Client 发送 → Decoder 解码**,共八层。每层给出关键源码(基于 spring-cloud-openfeign 3.1.x + feign-core 11.x,4.x 中 FeignContext 更名 FeignClientFactory,流程不变)、源码阅读的坐标和"为什么这么设计"的思考,最后用一个完整的自定义拦截器实战(traceId 透传 + token 中继 + 签名)把源码知识落回工程。

先给结论:@FeignClient 的本质是"用 JDK 动态代理把'声明式接口'翻译成'HTTP 客户端'"——启动期把接口的每个方法解析成一次 HTTP 请求的模板(Contract 解析),运行期每次调用就是"填模板 → 编码 → 发送 → 解码"四步流水线。理解了这条流水线,本文开头那个 405 问题、以及你可能踩过的超时、拦截器顺序问题,都能在源码里找到唯一答案。


一、全景:一次 Feign 调用的完整生命周期

把八层组件放到一张图上,启动期(编译翻译)和运行期(执行流水线)分开看:

【启动期:把接口"翻译"成可执行的请求模板】

@EnableFeignClients
   │ FeignClientsRegistrar(ImportBeanDefinitionRegistrar)
   ▼
扫描 basePackages 下所有 @FeignClient 接口
   │  每个接口注册一个 BeanDefinition
   ▼
FeignClientFactoryBean(FactoryBean,beanName=接口全限定名)
   │ getObject() 触发
   ▼
Feign.Builder 从 FeignContext 取齐组件:
   Contract(方法→HTTP 的翻译规则)+ Encoder + Decoder
   + Client + Retryer + RequestInterceptors
   │
   ▼
Contract.parseAndValidateMetadata():逐方法解析
   @GetMapping/@PathVariable/@RequestParam/@RequestBody
   → MethodMetadata(请求方法/路径/参数表/返回类型)
   │
   ▼
ReflectiveFeign.newInstance():
   每个方法 → 一个 SynchronousMethodHandler
   Proxy.newProxyInstance() 生成接口实现 → 放入容器

【运行期:每次接口调用的四步流水线】

userClient.query(uid)                    ← 调用的只是代理对象的 invoke
   │
   ▼ SynchronousMethodHandler.invoke(argv)
① buildTemplateFromArgs.create(argv)
      用实参填充 MethodMetadata 模板 → RequestTemplate
      (@RequestBody 参数在此被 Encoder 编码为 body)
② targetRequest(template)
      遍历 RequestInterceptor 列表(改 header/参数/路径)
      target.apply() 拼出最终 URL
③ client.execute(request, options)
      FeignBlockingLoadBalancerClient:服务名 → ip:port
      delegate(HttpURLConnection/OkHttp/ApacheHC)真正发送
④ decoder.decode(response)
      2xx → 按方法返回类型反序列化
      非 2xx → ErrorDecoder → FeignException / 业务异常
      IOException/Retry-After → Retryer 决定重试

源码分布在两个 jar 里:feign-core(代理、模板、Client、编解码的骨架)和 spring-cloud-openfeign-core(扫描注册、与 Spring 生态的装配)。建议对照源码阅读时先立好这个坐标。


二、@EnableFeignClients:把"没有实现的接口"注册成 BeanDefinition

2.1 入口注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(FeignClientsRegistrar.class)          // ★ 一切从这里开始
public @interfaceEnableFeignClients
{
    String[] value() default {};
    String[] basePackages() default {};
    Class<?>[] defaultConfiguration() default {};   // 全局默认配置
    Class<?>[] clients() default {};                // 手动指定接口,跳过扫描
}

它是个空壳,核心是 @Import(FeignClientsRegistrar.class)——这是 Spring 的 ImportBeanDefinitionRegistrar 扩展点,容器启动到 ConfigurationClass 解析阶段会回调它的 registerBeanDefinitions,让我们用编程方式注册 BeanDefinition。

2.2 FeignClientsRegistrar:扫描与注册

classFeignClientsRegistrarimplementsImportBeanDefinitionRegistrar{

@Override
publicvoidregisterBeanDefinitions(AnnotationMetadata metadata,
                                      BeanDefinitionRegistry registry)
{
    registerDefaultConfiguration(metadata, registry);  // ① 注册 defaultConfiguration
    registerFeignClients(metadata, registry);          // ② 扫描 @FeignClient
  }

publicvoidregisterFeignClients(AnnotationMetadata metadata,
                                   BeanDefinitionRegistry registry)
{
    LinkedHashSet<BeanDefinition> candidateComponents = new LinkedHashSet<>();
// clients() 手动指定时不扫描;否则 ClassPathScanningCandidateComponentProvider
// 只挑带 @FeignClient 注解的类
    ...
for (BeanDefinition candidateComponent : candidateComponents) {
if (candidateComponent instanceof AnnotatedBeanDefinition) {
        AnnotatedBeanDefinition beanDefinition = (AnnotatedBeanDefinition) candidateComponent;
        AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();
        Map<String, Object> attributes = annotationMetadata
                .getAnnotationAttributes(FeignClient.class.getCanonicalName());
        String name = getClientName(attributes);
        registerClientConfiguration(registry, name, attributes.get("configuration")); // ③ 客户端级配置
        registerFeignClient(registry, annotationMetadata, attributes);               // ④ 核心
      }
    }
  }

privatevoidregisterFeignClient(BeanDefinitionRegistry registry,
                                   AnnotationMetadata annotationMetadata,
                                   Map<String, Object> attributes)
{
    String className = annotationMetadata.getClassName();
    BeanDefinitionBuilder definition = BeanDefinitionBuilder
            .genericBeanDefinition(FeignClientFactoryBean.class);   // ★ 注册的不是接口,是工厂
    validate(attributes);
    definition.addPropertyValue("url", getUrl(attributes));
    definition.addPropertyValue("path", getPath(attributes));
    definition.addPropertyValue("name", getName(attributes));
    definition.addPropertyValue("type", className);
    definition.addPropertyValue("fallback", attributes.get("fallback"));
    ...
    definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);
    BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className, qualifiers);
    BeanDefinitionRegistryUtils.registerBeanDefinition(holder, registry);
  }
}

三个关键设计,决定了后面所有行为:

  1. 注册的 BeanDefinition 类型是 FeignClientFactoryBean,beanName 是接口全限定名。接口本身永远不会被实例化,容器里那个"接口 bean"实际是工厂的产物。
  2. 接口的元数据(url/name/path/fallback)作为属性存进工厂,运行期工厂据此决定"直连"还是"走负载均衡"。
  3. @FeignClient(configuration=X) 的配置按 name 注册到独立的作用域(name.FeignClientSpecification),实现"每个 client 独立组件集"——这是 FeignContext 的地基。
❝

源码坐标:org.springframework.cloud.openfeign.FeignClientsRegistrar


三、FeignClientFactoryBean:动态代理的生产车间

当业务代码 @Autowired UserClient userClient 触发容器 getBean("com.xxx.UserClient") 时,走到的是这个工厂的 getObject():

publicclassFeignClientFactoryBeanimplementsFactoryBean<Object>, InitializingBean{

@Override
public Object getObject(){
return getTarget();
  }

  <T> T getTarget(){
// ① 从 FeignContext(子上下文)里取这个 client 专属的组件集
    FeignContext context = beanFactory != null
            ? beanFactory.getBean(FeignContext.class)
            : applicationContext.getBean(FeignContext.class)
;
    Feign.Builder builder = feign(context);          // ② 装配 Builder
if (!StringUtils.hasText(url)) {
// ③A 没有 url:服务名模式 → 包一层负载均衡
if (url == null && LOG.isWarnEnabled()) {
        LOG.warn("因为 url 为空,将使用负载均衡... url must be provided when using load balancer");
      }
return (T) loadBalance(builder, context,
new HardCodedTarget<>(type, name, "http://" + name));
    }
// ③B 有 url:直连模式,剥离负载均衡包装
if (getHostFromUrl(url)) { ... }
    String url = this.url + cleanPath();
    Client client = getOptional(clientFactory, Client.class);
if (client != null) {
if (client instanceof FeignBlockingLoadBalancerClient) {
        client = ((FeignBlockingLoadBalancerClient) client).getDelegate();  // ★ 摘掉LB
      }
      builder.client(client);
    }
    Targeter targeter = get(context, Targeter.class);
return (T) targeter.target(this, builder, context,
new HardCodedTarget<>(type, name, url));
  }

protected Feign.Builder feign(FeignContext context){
    FeignLoggerFactory loggerFactory = get(context, FeignLoggerFactory.class);
    Logger logger = loggerFactory.create(type);
// ② 从子上下文取齐四大件
    Feign.Builder builder = get(context, Feign.Builder.class)
            .logger(logger)
            .encoder(get(context, Encoder.class))      // SpringEncoder
            .decoder(get(context, Decoder.class))      // ResponseEntityDecoder→SpringDecoder
            .contract(get(context, Contract.class))
;   // SpringMvcContract
    configureFeign(context, builder);                  // 应用 yml 配置与级联配置
return builder;
  }

private <T> T loadBalance(Feign.Builder builder, FeignContext context,
                            HardCodedTarget<T> target)
{
    Client client = getOptional(context, Client.class);
if (client != null) {
      builder.client(client);                          // FeignBlockingLoadBalancerClient
      Targeter targeter = get(context, Targeter.class);
return targeter.target(this, builder, context, target);
    }
thrownew IllegalStateException("No Feign Client for loadBalancing defined...");
  }
}

两个容易误读的点:

  1. FeignContext 是"父子上下文"设计:每个 @FeignClient 的配置类注册在自己 name 对应的子作用域里,取组件时先查子作用域、回落到默认。所以 A 服务配了 OkHttp、B 服务用默认 HttpURLConnection,互不污染。
  2. url 有值/没值走两条路:没 url 时 Target 是 http://user-service(服务名当主机名),发送前由 FeignBlockingLoadBalancerClient 把服务名解析成真实实例;有 url 时直接把 LoadBalancerClient 剥掉用裸 Client 直连。理解这一点,排查"为什么配了 url 还走注册中心"(版本差异/属性没生效)就有了抓手。

Targeter.target(...) 最终调用 Feign.Builder.target(target) → ReflectiveFeign.newInstance(target),进入动态代理的核心。

❝

源码坐标:org.springframework.cloud.openfeign.FeignClientFactoryBean


四、ReflectiveFeign.newInstance:JDK 动态代理的落点

publicclassReflectiveFeignextendsFeign{

privatefinal ParseHandlersByName targetToHandlersByName;

public <T> T newInstance(Target<T> target){
// ① 逐方法解析:Contract 把 @GetMapping 等注解翻译成 MethodMetadata
//    并为每个方法创建一个 SynchronousMethodHandler
    Map<String, MethodHandler> nameToHandler = targetToHandlersByName.apply(target);
    Map<Method, MethodHandler> methodToHandler = new LinkedHashMap<Method, MethodHandler>();
    List<DefaultMethodHandler> defaultMethodHandlers = new LinkedList<DefaultMethodHandler>();

for (Method method : target.type().getMethods()) {
if (method.getDeclaringClass() == Object.class) {
continue;                                       // Object 方法跳过
      } elseif (Util.isDefault(method)) {                // ② 接口 default 方法 → 原生执行
        DefaultMethodHandler handler = new DefaultMethodHandler(method);
        defaultMethodHandlers.add(handler);
        methodToHandler.put(method, handler);
      } else {
        methodToHandler.put(method,
                nameToHandler.get(Feign.configKey(target.type(), method)));  // ③ 业务方法 → handler
      }
    }
// ④ 标准 JDK 动态代理:接口类加载器 + 接口数组 + InvocationHandler
    InvocationHandler handler = factory.create(target, methodToHandler);
    T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(),
new Class<?>[] { target.type() }, handler);
for (DefaultMethodHandler defaultMethodHandler : defaultMethodHandlers) {
      defaultMethodHandler.bindTo(proxy);
    }
return proxy;
  }

// 默认 InvocationHandler:方法分发表
staticclassFeignInvocationHandlerimplementsInvocationHandler{
privatefinal Target target;
privatefinal Map<Method, MethodHandler> dispatch;

@Override
public Object invoke(Object proxy, Method method, Object[] args)throws Throwable {
if ("equals".equals(method.getName())) {
try { Object otherHandler = args.length > 0 && args[0] != null
                ? Proxy.getInvocationHandler(args[0]) : null;
return equals(otherHandler);
        } catch (IllegalArgumentException e) { returnfalse; }
      } elseif ("hashCode".equals(method.getName())) { return hashCode(); }
elseif ("toString".equals(method.getName())) { return toString(); }
return dispatch.get(method).invoke(args);          // ★ 业务调用全走这行
    }
  }
}

四个值得咀嚼的细节:

  1. 为什么是 JDK 动态代理而不是 CGLIB:代理目标是接口,JDK 代理足矣且天然面向接口;CGLIB 需要能被继承的类,而这里根本没有类。代价是 final 的 Object 方法(equals/hashCode/toString)只能特殊处理——上面 invoke 里那三个 if 就是干这个的。
  2. default 方法有独立通道(DefaultMethodHandler,用 MethodHandles 原生绑定),所以接口里写 default 方法做参数加工是可行的,它们不走 HTTP 流水线。
  3. 每个方法一个 handler,启动期一次性解析:ParseHandlersByName.apply 里调用 contract.parseAndValidateMetadata(target.type(), method) 把注解解析成 MethodMetadata 并缓存——运行期零反射解析注解,这就是 Feign 声明式接口性能没有明显损耗的原因。
  4. Feign.configKey(type, method) 生成形如 UserClient#query(Long) 的 key,是 MethodMetadata、日志、ErrorDecoder 里到处流通的"方法身份证"。
❝

源码坐标:feign-core → feign.ReflectiveFeign


五、SynchronousMethodHandler.invoke:方法调用的拦截链

业务方法真正被调用的地方。它不继承接口,而是实现 feign 自己的 MethodHandler 接口(注意区别于 JDK 的 InvocationHandler):

finalclassSynchronousMethodHandlerimplementsMethodHandler{

staticclassFactory{
public MethodHandler create(Target<?> target,
                                QueryMapEncoder queryMapEncoder,
                                Map<Method, MethodMetadata> metadataMap,
                                Feign.ArgsToTemplateHandler argsToTemplateHandler,
                                Options options, Encoder encoder, Decoder decoder,
                                Client client, ... Retryer retryer,
                                List<RequestInterceptor> requestInterceptors,
                                Logger logger, ...)
{
// ① Contract 解析出 MethodMetadata,验证并缓存
      RequestTemplate.Factory buildTemplateFromArgs
              = new BuildTemplateByResolvingArgs(target, metadata, encoder,
                                                 queryMapEncoder, ...);
returnnew SynchronousMethodHandler(target, client, retryer, requestInterceptors,
              buildTemplateFromArgs, options, decoder, errorDecoder, ...);
    }
  }

@Override
public Object invoke(Object[] argv)throws Throwable {
// ② 实参填模板 → RequestTemplate
    RequestTemplate template = buildTemplateFromArgs.create(argv);
    Options options = findOptions(argv);          // 支持 @Options 参数级覆盖
    Retryer retryer = this.retryer.clone();       // ★ 每次调用 clone,保证重试状态独立
while (true) {
try {
return executeAndDecode(template, options);   // ③④⑤ 发送与解码
      } catch (RetryableException e) {                // ⑥ 重试判断
        Retryer.RetryResult retryResult = retryer.continueOrPropagate(e);
if (!retryResult.retryable()) throw e.getCause();
if (logLevel != Logger.Level.NONE) { ... }
continue;
      }
    }
  }

Object executeAndDecode(RequestTemplate template, Options options)throws Throwable {
// ④ 拦截器加工 + target.apply 拼最终 URL
    Request request = targetRequest(template);
if (logLevel != Logger.Level.NONE) {
      logger.logRequest(metadata.configKey(), logLevel, request);
    }
    Response response;
long start = System.nanoTime();
try {
      response = client.execute(request, options);      // ⑤ 真正发 HTTP
      response = response.toBuilder().request(request)
              .requestTemplate(template).build();       // 回填,便于日志/异常上下文
    } catch (IOException e) {
if (logLevel != Logger.Level.NONE) { ... }
// IOException 包装成 RetryableException 交给上层 while 循环
throw errorExecuting(request, e);
    }
    ...
try {
if (response.status() >= 200 && response.status() < 300) {
if (void.class== metadata.returnType()) returnnull;
elsereturn decode(response);                   // ⑥ Decoder 反序列化
      } else {
throw errorDecoder.decode(metadata.configKey(), response);  // ⑦ 非 2xx
      }
    } catch (...) { ... }
  }

Request targetRequest(RequestTemplate template){
for (RequestInterceptor interceptor : requestInterceptors) {
      interceptor.apply(template);                      // ★ 拦截器在编码后、发送前执行
    }
return target.apply(new RequestTemplate(template));
  }
}

invoke 这十几行是整条流水线的调度中心,把它背下来,所有运行期问题都有坐标:

实参 argv
  → BuildTemplateByResolvingArgs.create()     ①填模板(@RequestBody 在此经 Encoder 编码成 body)
  → targetRequest()                            ②拦截器 + URL 拼接
  → client.execute()                           ③发送(IOException → RetryableException)
  → decode / errorDecoder                      ④2xx 反序列化;非 2xx 抛异常
  → Retryer.continueOrPropagate()              ⑤决定重试还是上抛(默认 NEVER_RETRY)

特别注意两个顺序问题,它们是拦截器实战(第九章)和 FAQ 的伏笔:RequestInterceptor 执行时 body 已经编码完成(改 body 要小心二次编码问题);RetryableException 只由 IOException 和响应头 Retry-After 触发,普通 500 走 ErrorDecoder 不重试。

❝

源码坐标:feign-core → feign.SynchronousMethodHandler


六、Contract 与 Encoder:从 Java 方法到 HTTP 报文

6.1 SpringMvcContract:翻译官的词典

启动期 parseAndValidateMetadata 按如下规则翻译(这就是"Java 方法 → HTTP"的映射表):

// 业务接口示例:对照翻译规则
@FeignClient(name = "user-service", path = "/api")
publicinterfaceUserClient{

@GetMapping("/user/{id}")
UserVO query(@PathVariable("id") Long id);          // 路径变量 → URL 占位

@GetMapping("/user/list")
List<UserVO> list(@RequestParam("status") Integer status);  // → query string

@PostMapping("/user")
Long create(@RequestBody UserCreateDTO dto);        // → body(经 Encoder)

@GetMapping("/user/search")
List<UserVO> search(UserQuery query);               // ★ 无注解 POJO → 当 body!
//   GET+body → 大概率 405(见 FAQ)
@GetMapping("/user/page")
Page<UserVO> page(@SpringQueryMap UserQuery query); // ✓ POJO 做查询参数的正确姿势
}
  • 方法级:@RequestMapping/@GetMapping... → HTTP method + path + consumes/produces + headers。
  • 参数级:@PathVariable 填 URL 占位;@RequestParam 拼查询串;@RequestBody/未注解对象进 body;POJO 批量做查询参数用 @SpringQueryMap。
  • 开头那个 405 事故的答案就在最后一行:无注解的 POJO 参数默认按 body 处理,翻译规则没有任何"GET 就该放 query string"的猜测。

6.2 Encoder:body 的序列化时机

Spring Cloud 默认装的是 SpringEncoder——它不自己做序列化,而是委托给 Spring MVC 同款的 HttpMessageConverters(所以默认是 Jackson,Fastjson 换 converter 即可换):

publicclassSpringEncoderimplementsEncoder{
@Override
publicvoidencode(Object requestBody, Type bodyType, RequestTemplate request){
// contentType 决定选哪个 HttpMessageConverter
// 对 request.body() 写入序列化后的字节 —— 发生在"填模板"阶段
  }
}

时序再强调一遍:Encoder 在 buildTemplateFromArgs.create() 阶段执行(填模板时),RequestInterceptor 在其后的 targetRequest() 执行——拦截器拿到的 template.body() 已经是编码后的字节,想改结构得重新 bytes 或换思路(见 9.3 的签名示例)。

6.3 Client:谁真正打开 socket

Client 接口只有一个方法 execute(Request, Options),Spring Cloud 的默认装配链是:

FeignBlockingLoadBalancerClient            ← 最外层:服务名 → 实例
   │  ① 用 LoadBalancerClient.choose(name) 从注册中心(Nacos)拿到 ip:port
   │  ② 把 request.url() 里的 http://user-service 重写为 http://10.0.0.5:8080
   ▼
delegate: feign.Client.Default             ← 真正发送
   HttpURLConnection(默认)/ OkHttp / ApacheHttpClient(按依赖与配置切换)
   options.connectTimeout / readTimeout / followRedirects

服务发现在这里与《Nacos 注册中心源码拆解》的客户端拉取链路衔接:LoadBalancer 从 Nacos 拉到的服务列表做 choose。超时配置(feign.client.config.default.connectTimeout/readTimeout)最终落到这里的 Options——呼应《上线前自检清单》第 5 条:每一层默认值都要显式覆盖。


七、Decoder 与 ErrorDecoder:响应回来之后

// 2xx:ResponseEntityDecoder(包装 SpringDecoder) —— 同样复用 HttpMessageConverters
if (response.status() >= 200 && response.status() < 300) {
return decoder.decode(response, metadata.returnType());
}
// 非 2xx:ErrorDecoder 决定抛什么
else {
throw errorDecoder.decode(metadata.configKey(), response);
}

默认 ErrorDecoder.Default 把所有非 2xx 统一抛成 FeignException(附状态码与 body)。生产上几乎必然要自定义,否则下游的业务异常码(比如"余额不足")到了调用方全变成面目模糊的 500:

publicclassBizErrorDecoderimplementsErrorDecoder{
privatefinal ObjectMapper mapper = new ObjectMapper();

@Override
public Exception decode(String methodKey, Response response){
try {
            R<?> body = mapper.readValue(response.body().asInputStream(), R.class);
if (body.getCode() == 40001) {
returnnew BizException(ErrorCode.BALANCE_NOT_ENOUGH);  // 还原业务语义
            }
        } catch (IOException ignore) { /* 解析失败走默认 */ }
returnnew ErrorDecoder.Default().decode(methodKey, response);
    }
}

另一个重试细节呼应第五章:Retryer 默认是 NEVER_RETRY(Spring Cloud 默认装配),且只对 RetryableException(IOException / Retry-After)生效——想重试 500 需要自定义 Retryer 或在 ErrorDecoder 里抛 RetryableException,同时务必保证下游接口幂等(《消息重复消费排查》的幂等设计在这里同样适用)。


八、实战:自定义 RequestInterceptor 三件套

三个最常用的拦截器场景一次给全,每个都能直接抄。

8.1 traceId 链路透传

publicclassTraceIdInterceptorimplementsRequestInterceptor{
@Override
publicvoidapply(RequestTemplate template){
        String traceId = MDC.get("traceId");
if (traceId != null) {
            template.header("X-Trace-Id", traceId);
        }
    }
}

配合对端服务的 MDC 过滤器,全链路日志就能串起来(《可观测性》篇的 traceId 规范)。注意:如果 Feign 调用发生在异步线程,MDC 上下文要先装饰线程池传递,否则这里取到 null。

8.2 token 中继(把调用方的登录态传给下游)

publicclassAuthRelayInterceptorimplementsRequestInterceptor{
@Override
publicvoidapply(RequestTemplate template){
// 不能直接读 header 的场景(如消息驱动调用),改为注入服务账号
        RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes) {
            HttpServletRequest req = ((ServletRequestAttributes) attrs).getRequest();
            String token = req.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(token)) {
                template.header(HttpHeaders.AUTHORIZATION, token);
            }
        }
    }
}

坑点提前声明:RequestContextHolder 默认只在 Web 线程有效,异步线程/线程池里的 Feign 调用拿不到——这正是老版本 Spring Cloud 里 Hystrix 隔离策略导致 token 丢失的经典问题。解法是传递装饰器(RequestContextHolder.setRequestAttributes(attrs, true))或改走服务账号授权。

8.3 请求签名(防篡改,与《API 接口签名方案》同套规则)

publicclassSignInterceptorimplementsRequestInterceptor{

privatefinal String secret;

publicSignInterceptor(String secret){ this.secret = secret; }

@Override
publicvoidapply(RequestTemplate template){
        String timestamp = String.valueOf(System.currentTimeMillis());
        String nonce = UUID.randomUUID().toString().replace("-", "");

// 拼签名串:method + path + timestamp + nonce + body
// 注意:body 已被 Encoder 编码,直接取 bytes,不要重新序列化对象
        String body = template.body() == null ? "" : new String(template.body(),
                StandardCharsets.UTF_8);
        String toSign = template.method() + "\n"
                + template.path() + "\n"
                + timestamp + "\n" + nonce + "\n" + body;
        String sign = HmacUtils.hmacSha256Hex(secret, toSign);

        template.header("X-Timestamp", timestamp);
        template.header("X-Nonce", nonce);
        template.header("X-Sign", sign);
    }
}

注意拦截器的执行顺序就是注册顺序(requestInterceptors 是 List),依赖顺序的场景用 @Order 或控制注册顺序。

8.4 注册方式与作用域

// 方式一:仅作用于某一个 client(配置类【不加】@Configuration,避免被全局扫描)
@FeignClient(name = "user-service", configuration = { UserClientConfig.class })
publicinterfaceUserClient
{ ... }

publicclassUserClientConfig{
@Bean
public RequestInterceptor traceIdInterceptor(){ returnnew TraceIdInterceptor(); }
@Bean
public ErrorDecoder bizErrorDecoder(){ returnnew BizErrorDecoder(); }
}

// 方式二:全局拦截器(所有 client 生效)
@EnableFeignClients(defaultConfiguration = GlobalFeignConfig.class)

作用域纪律:client 级配置类不加 @Configuration 注解(加了会被主上下文扫描成全局默认,污染所有 client);全局配置走 @EnableFeignClients(defaultConfiguration=...) 而不是放在被扫描的 @Configuration 里——这是官方文档明确的规则,也是最常踩的装配事故。


九、常见问题

9.1 为什么 Feign 用 JDK 动态代理,MyBatis Mapper 也是 JDK 代理,二者什么关系?

机制同源(Proxy.newProxyInstance + InvocationHandler 分发),但模板不同:MyBatis 把方法调用翻译成 SQL 执行,Feign 翻译成 HTTP 请求;MyBatis 的 MapperProxy 也是"启动期解析注解 → 运行期填参数执行"的同构设计。理解其中一个,另一个的源码可以平移着读。Feign 不用 CGLIB 的原因见第四章:代理目标是纯接口,JDK 代理天然匹配,且无需依赖字节码增强库。

9.2 GET 接口传 POJO 对象,为什么发出的是 POST/405?

SpringMvcContract 的翻译规则里,没有 @RequestParam/@PathVariable/@SpringQueryMap 注解的参数一律按 body 处理(6.1 节那张表)。GET + body 在多数 Servlet 容器/网关下被拒或被降级,于是 405/400。修复:POJO 想做查询参数用 @SpringQueryMap,或逐字段标 @RequestParam;这也是《上线前自检清单》建议"新接口先在联调环境真调一次"的原因——翻译规则与人的直觉并不总是一致。

9.3 超时到底怎么配?feign 超时和 LoadBalancer/连接池超时是什么关系?

Feign 的 feign.client.config.<name>.connectTimeout/readTimeout 最终落到 SynchronousMethodHandler 的 Options,作用于 Client.execute 这一步;而 DNS/连接池层面(如 Apache HttpClient 的连接池 maxPerRoute)是另一层。排查超时问题的顺序:先确认生效的是哪一层(开 logging.level.com.xxx.UserClient=DEBUG 看日志里的 options),再按"下游 P99×2"定值(《自检清单》第 5 条的预算制),最后检查 yml 缩进与 client 名拼写——配置类装配链长,配置没生效比没配置更常见。

9.4 RequestInterceptor 里为什么拿不到/改不动 body?

因为时序:Encoder 在"填模板"(buildTemplateFromArgs.create)阶段已经把 @RequestBody 序列化成字节,拦截器在 targetRequest 阶段才执行,template.body() 是编码后的 byte[]。想改 body:用 template.body(byte[] newBody, Charset) 直接替换字节(注意与 Content-Length、签名的一致性);想加查询参数/头部则没有限制。另外拦截器对每次请求都执行(重试的每次循环都会走 targetRequest),幂等性要自己保证,别在里面做累加类操作。

9.5 怎么给 Feign 换掉默认的 HttpURLConnection?

FeignBlockingLoadBalancerClient 持有一个 delegate,换客户端就是替换 delegate:引入 feign-okhttp(或 feign-httpclient)依赖并配置 feign.okhttp.enabled=true,Spring Cloud 自动装配会把 OkHttpClient 装进 Client 角色。在 FeignClientFactoryBean 的 3.1 源码里能看到,直连模式甚至会主动 getDelegate() 摘掉 LB 包装——理解了第三章的装配图,这些开关的源码位置一眼可辨。性能敏感场景选 OkHttp/ApacheHC 的核心收益是连接池复用,默认 HttpURLConnection 每次建连在高 QPS 下是隐形瓶颈。

9.6 调用抛 FeignException 后怎么优雅处理?会不会触发重试放大故障?

处理链路:非 2xx → ErrorDecoder(可还原业务异常,第七章示例)→ 上游统一兜底(《自检清单》第 3 条的全局异常兜底)。重试边界务必认清:默认 NEVER_RETRY,IOException 触发的 RetryableException 也只交给 Retryer 判断;自定义重试必须满足两个前提——下游幂等 + 重试次数有上限(否则呼应《消息重复消费排查》:重试×无幂等=重复扣款)。熔断层(Sentinel/Resilience4j 对 Feign 的适配)负责把下游故障隔离成降级返回,三者分工:超时管单次调用、重试管偶发抖动、熔断管持续故障。


十、总结

源码链路速查卡

【启动期】
@EnableFeignClients(@Import FeignClientsRegistrar)
  → ClassPathScanningCandidateComponentProvider 扫 @FeignClient
  → 每个接口注册 FeignClientFactoryBean(beanName=接口全限定名)
  → configuration 按 name 存入 FeignClientSpecification(子作用域)

FeignClientFactoryBean.getObject()
  → FeignContext 取四大件:Contract/Encoder/Decoder/Client
  → 无url: HardCodedTarget("http://服务名") + FeignBlockingLoadBalancerClient
  → 有url: 剥 delegate 直连
  → Targeter.target → ReflectiveFeign.newInstance()

ReflectiveFeign.newInstance()
  → Contract.parseAndValidateMetadata 逐方法 → MethodMetadata
  → 每方法一个 SynchronousMethodHandler(default 方法走 DefaultMethodHandler)
  → Proxy.newProxyInstance(类加载器, [接口], FeignInvocationHandler)

【运行期 SynchronousMethodHandler.invoke】
  argv → BuildTemplateByResolvingArgs.create(@RequestBody 经 SpringEncoder 编码)
       → targetRequest:RequestInterceptor 顺序执行(body 已编码)+ target.apply 拼URL
       → client.execute:LB 先 choose 实例重写 URL → delegate 真发
       → 2xx: ResponseEntityDecoder.decode 按返回类型反序列化
         非2xx: ErrorDecoder → FeignException/业务异常
         IOException/Retry-After: Retryer(默认 NEVER_RETRY)

【翻译规则速记】
  @PathVariable→URL 占位  @RequestParam→query  @RequestBody/无注解POJO→body
  POJO做查询参数→@SpringQueryMap  接口级path→前缀

【拦截器实战】
  traceId透传(MDC) / token中继(RequestContextHolder, 异步线程要传上下文)
  签名(HMAC: method+path+timestamp+nonce+已编码body)
  client级配置类不加@Configuration;全局走 defaultConfiguration

给团队的建议

项
建议
规范
POJO 查询参数强制 @SpringQueryMap;写接口显式 @RequestBody;Code Review 对照 6.1 翻译表
超时
每个 client 显式配 connectTimeout/readTimeout(P99×2),纳入发布自检第 5 条
异常
统一 BizErrorDecoder 还原下游业务码;禁止裸 FeignException 透给用户
重试
默认 NEVER_RETRY 保持不动;确需重试必须下游幂等 + 上限
拦截器
traceId/鉴权/签名三件套进公共 starter;异步调用先做上下文传递
装配
client 级配置类不加 @Configuration;全局配置走 defaultConfiguration
客户端
高 QPS 服务换 OkHttp/ApacheHC(连接池复用),压测对比建连开销
观测
logging.level.=DEBUG 排障用,上生产前关闭(呼应自检第 1 条)

一句话

❝

@FeignClient 的全部魔法可以压缩成一句话:启动期用 Contract 把"接口方法 + 注解"翻译成 MethodMetadata 请求模板,用 JDK 动态代理给接口造一个"方法体是流水线调度器"的实现,运行期每次调用走"填模板 → 拦截器 → 发送 → 解码"四步——所以接口没有实现类不是玄学,而是 FeignClientsRegistrar 扫描后注册了 FeignClientFactoryBean,工厂在 getObject 时从 FeignContext 取齐组件、按 url 有无决定直连或负载均衡,最终 ReflectiveFeign.newInstance 里 Proxy.newProxyInstance 生成的代理把每次方法调用分发给对应的 SynchronousMethodHandler;而那个 GET 传对象变 405 的坑,翻译表里白纸黑字写着"无注解参数按 body 处理",Contract 从不猜测你的意图,它只按词典办事。理解这条流水线的真正收益是排障时有了精确坐标:超时不生效查 Options 的装配链、body 改不动查 Encoder 与拦截器的时序、异常面目模糊查 ErrorDecoder、想重试先问下游幂不幂等——声明式调用的优雅,恰恰建立在把"翻译规则"内化为团队共识之上,这也是它和 RestTemplate 手写调用最本质的分野:前者把协议约定固化在接口签名里,让 Code Review 能看见 HTTP 契约本身。

❝

互动话题:你用 Feign 踩过最玄学的坑是什么(405、token 丢失、超时不生效、拦截器顺序)?你们有没有沉淀自己的 Feign starter(统一异常/签名/trace)?欢迎评论区交流,下篇源码拆解选题也欢迎点单——目前系列已拆 Nacos 注册中心、Spring Cloud Gateway,Feign 之后你希望拆 Ribbon/LoadBalancer 还是 Sentinel?


相关学习资料