
1. 拦截器解决的四个真实场景从统一请求头到防篡改签名先讲一个很典型的痛点。我之前在给某开放平台做联调的时候要对接十几个第三方接口每个接口都要求带clientId、timestamp、sign这些公共参数而且服务端验签特别严格。最开始图省事我直接在每一个 Feign 方法里手动塞请求头代码写成这样PostMapping(value /order/create, headers {X-Client-Idclient-001, X-Signabc}) Order createOrder(RequestBody OrderCreateRequest request);表面上看没什么问题但真跑起来就难受了。签名里往往要带上时间戳和请求体内容headers注解里写的是静态值根本无法根据每次请求动态计算。后来只能改成在方法内部用RequestContextHolder拿请求对象、再塞进 Feign 的配置里……绕了一大圈最后还是绕回到RequestInterceptor上。我当时的结论是只要你的 Feign 客户端需要做“每个请求都要做一遍”的事情就应该考虑拦截器而不是在业务代码里到处复制粘贴。具体来说有四类场景特别适合用RequestInterceptor统一处理场景典型需求不用拦截器的后果令牌传递下游接口要求Authorization: Bearer xxx每个方法单独处理漏一个就 401链路追踪透传traceId、spanId等链路标识排查问题时很难串起整条调用链多环境隔离联调环境、预发环境路由到不同实例或带不同标识改一处要重新编译部署接口签名请求头附带timestamp、nonce、sign防篡改防重放动态签名根本没法用静态注解实现其中签名这个场景最复杂后面我会专门用一整节来讲。先记住一个最核心的判断标准只要这个动作是“所有 Feign 请求都要做”的就落入RequestInterceptor的职责范围。2. RequestInterceptor 的执行时机为什么它在编码器之前又为什么日志里看不到很多人第一次写RequestInterceptor时都会有一个疑惑我明明加上了Component程序也能启动但请求日志里看不到我设置的 header到底执行了没有要回答这个问题得先搞清楚 Feign 处理一次请求的完整链路。2.1 Feign 请求执行的顺序概括来说一次 Feign 调用的流程是通过 JDK 动态代理拦截你的接口调用MethodHandler 将方法参数解析成RequestTemplate对象依次执行你配置的RequestInterceptor链对RequestTemplate进行改写通过Encoder把方法参数编码成请求体交给Client发起真正的 HTTP 请求通过Decoder解析响应。注意第 3 步和第 4 步的顺序。RequestInterceptor是在编码器之前执行的也就是说拦截器拿到的是还没有被编码的RequestTemplate。此时如果你设置了template.body()你会发现它拿的是参数对象的原始字符串形式通常是 JSON 序列化之前的样子而不是最终 HTTP 报文里的字节流。这个“先拦截、后编码”的顺序有个很实际的影响如果你想在拦截器里读取请求体做签名拿到的可能是LinkedHashMap序列化后的字符串而不是你最终发送的 body。我一开始在这里栽过跟头后面讲签名时再重点展开。2.2 拦截器生效的两种注册方式RequestInterceptor有两条注册路径很多人只知道第一种// 方式一使用默认的全局配置 Configuration public class FeignGlobalConfig { Bean public RequestInterceptor globalRequestInterceptor() { return new MyRequestInterceptor(); } }// 方式二细粒度指定给某个 FeignClient FeignClient(name order-service, configuration OrderFeignConfig.class) public interface OrderClient { // ... }方式一是全局生效所有通过 Feign 发出的请求都会走过这个拦截器方式二是局部生效只对指定的 client 生效。这里面有一个很多初学者容易踩的坑如果OrderFeignConfig类被放在了启动类可以扫描到的包路径下而且你又在类上加了Configuration那么这个局部配置其实会变成全局配置影响所有 Feign 客户端。这不是玄学是 Spring 的组件扫描机制导致的。2.3 RequestTemplate 的核心操作 API拦截器的入参是RequestTemplate核心操作无非这几种public class HeaderInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { // 添加请求头 template.header(X-Api-Key, your-api-key); // 覆盖请求头如果不覆盖重复调用会追加值 template.header(Authorization, Bearer token); // 获取某个请求头 CollectionString values template.headers().get(Content-Type); // 修改请求体注意body 在拦截器阶段还是 String没有经过 Encoder 编码 template.body({\name\:\test\}); // 修改请求路径 template.uri(/v2 template.path()); // 获取 query 参数 MapString, CollectionString queries template.queries(); } }这里有个细节值得注意template.header()是追加语义。如果同名的 header 已经存在再次调用会把新值追加到后面而不是替换。实际场景里下游服务如果只取第一个值可能会看不到你后来追加的。所以我习惯在设置关键 header 之前先template.removeHeader(X-Api-Key)或者确认原始请求不会带这个 header。还有一个细节RequestTemplate暴露的 header 名称在查询时区分大小写但 HTTP 协议本身不区分。如果你在排查时发现明明设置了 header 却拿不到先检查一下是不是大小写不一致。3. 手写统一请求头拦截器从第一版到可上线的完整代码这一节直接提供可抄作业的代码。我先把一个通用的实现写出来再逐步加上健壮性处理。3.1 第一版从配置文件读取请求头需求是给所有 Feign 请求加上X-Client-Id和X-Api-Key并且这两个值在不同环境本地、联调、预发不一样所以不能写死在代码里。Component public class ClientInfoInterceptor implements RequestInterceptor { private static final Logger log LoggerFactory.getLogger(ClientInfoInterceptor.class); private final ClientInfoProperties properties; public ClientInfoInterceptor(ClientInfoProperties properties) { this.properties properties; } Override public void apply(RequestTemplate template) { template.header(X-Client-Id, properties.getClientId()); template.header(X-Api-Key, properties.getApiKey()); // 可选加上环境标识方便下游区分布 template.header(X-Env, properties.getEnv()); log.debug(Feign request intercepted, uri: {}, template.url()); } }对应的配置类ConfigurationProperties(prefix openapi.client) public class ClientInfoProperties { private String clientId; private String apiKey; private String env dev; // getter / setter 省略 }然后在application.yml里配置openapi: client: client-id: client-001 api-key: sk-abc123 env: local这一版能解决 80% 的静态请求头需求。但如果你只是需要透传用户身份 Token还需要继续看下一节。3.2 第二版从当前线程上下文透传 Token常见的业务场景是用户 A 发起请求到你的 Spring MVC 接口这个接口内部又通过 Feign 调用了下游服务。下游服务同样需要校验用户身份所以要把用户的Authorizationheader 原样透传过去。这时候拦截器里要读取当前的ServletRequest获取 header再设置到RequestTemplate上Component public class TokenPropagationInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { // 1. 从 Spring 的线程上下文中拿当前请求 RequestAttributes requestAttributes RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { HttpServletRequest request ((ServletRequestAttributes) requestAttributes).getRequest(); String authz request.getHeader(Authorization); if (StringUtils.hasText(authz)) { template.header(Authorization, authz); return; } } // 2. 拿不到就使用兜底 Token一般来自服务间调用凭证 String fallback environment.getProperty(openapi.client.service-token, ); if (StringUtils.hasText(fallback)) { template.header(Authorization, Bearer fallback); } } }这个实现里有个容易掉进去的坑异步线程丢上下文。如果你在业务代码里用了Async或自定义线程池RequestContextHolder里是拿不到当前请求的因为 Spring 默认的ThreadLocal不会跨线程传递。遇到这种情况要么在进入异步之前手动把 Token 存入ThreadLocal并在拦截器里读取要么使用HystrixRequestVariableDefault这类方案。我现在项目的做法是封装了一个UserContextHolder在请求入口处解析用户信息存入自定义上下文异步任务里也能拿到。3.3 加上开关控制联调/生产一键切换另一个实践中的刚需是拦截器要能开关。比如你在联调时下游并不验证签名但一上生产就要验。如果把逻辑做成硬编码每次切换环境都要改代码。我的做法是给拦截器加一个配置开关openapi: client: sign-enabled: trueComponent public class SigningInterceptor implements RequestInterceptor { private final ClientInfoProperties properties; Override public void apply(RequestTemplate template) { if (!properties.isSignEnabled()) { return; } // 签名逻辑... } }同时在配置中心可以动态调整非常方便。4. 签名场景的完整链路时间戳、防重放与参数排序签名是最能体现RequestInterceptor价值的场景。我把它单独拆开讲因为这里面坑最深也最容易出错。4.1 签名流程总览一个相对规范的开放平台签名方案通常包含这几部分客户端生成timestamp毫秒级时间戳客户端生成nonce随机字符串防重放将请求路径、查询参数、请求体等按约定规则拼接成待签名字符串使用约定的密钥通常是appSecret或私钥对字符串做HMAC-SHA256或MD5签名将timestamp、nonce、sign放入请求头传给服务端服务端校验时间戳是否过期、nonce 是否第一次使用、签名是否一致。下面这个签名拦截器是综合了以上要点的完整实现Component public class SigningInterceptor implements RequestInterceptor { private static final Logger log LoggerFactory.getLogger(SigningInterceptor.class); private final ClientInfoProperties properties; public SigningInterceptor(ClientInfoProperties properties) { this.properties properties; } Override public void apply(RequestTemplate template) { if (!properties.isSignEnabled()) { return; } String timestamp String.valueOf(System.currentTimeMillis()); String nonce UUID.randomUUID().toString().replace(-, ); // 1. 拼接待签名内容 String content buildSignContent(template); // 2. 计算签名 String sign sign(timestamp, nonce, content); // 3. 放入请求头 template.header(X-Timestamp, timestamp); template.header(X-Nonce, nonce); template.header(X-Sign, sign); template.header(X-Api-Key, properties.getApiKey()); } private String buildSignContent(RequestTemplate template) { StringBuilder sb new StringBuilder(); // 请求路径 sb.append(template.method()).append(\n); sb.append(template.path()).append(\n); // 查询参数排序拼接核心难点1 MapString, CollectionString queries template.queries(); if (queries ! null !queries.isEmpty()) { ListString queryParts new ArrayList(); for (Map.EntryString, CollectionString entry : queries.entrySet()) { for (String value : entry.getValue()) { queryParts.add(entry.getKey() value); } } Collections.sort(queryParts); sb.append(String.join(, queryParts)).append(\n); } // 请求体核心难点2编码时机问题 String body template.body() null ? : new String(template.body(), StandardCharsets.UTF_8); sb.append(body); return sb.toString(); } private String sign(String timestamp, String nonce, String content) { String raw properties.getApiKey() \n timestamp \n nonce \n content \n properties.getApiSecret(); return DigestUtils.sha256Hex(raw); } }4.2 参数排序为什么是核心难点很多签名校验失败问题多半出在参数顺序不一致。服务端在验签时对查询参数的常见处理是先按 key 字典序排序再用keyvalue形式拼接多个参数用连接。而RequestTemplate.queries()返回的是MapString, CollectionStringkey 的顺序不保证是字典序。如果不排序同一个请求在客户端签名的顺序和服务端验签的顺序就可能不同导致签名对不上。我建议在拼接前强制排序一次ListString queryParts new ArrayList(); // ... 拼出 keyvalue 形式 Collections.sort(queryParts);对于参数值里有中文、特殊字符的情况还涉及 URL 编码。通常签名用的字符串是原始值未编码还是编码后的值取决于接口文档。这个必须提前和服务端对齐否则两边各做各的签名永远对不上。4.3 body 签名的编码时机问题前面提到拦截器的执行时机在编码器之前所以在拦截器里template.body()拿到的是未经 Encoder 处理的内容。如果Encoder用的是默认的SpringEncoder它接收的是RequestBody注解的对象通常会被MappingJackson2HttpMessageConverter序列化成 JSON。这里有个非常隐蔽的问题签名时你拿到的字符串和传输时真正序列化后的字符串可能不一致。例如对象中的LocalDateTime在序列化前和序列化后的格式不同字段为null时可能被JsonInclude注解过滤掉。如果你签名用的是序列化前的原始 JSON而服务端验签用的却是收到报文里的 JSON两边自然对不上。针对这个问题的常见解法有两种方案一在调用 Feign 之前先把请求体序列化成字符串再传给 Feign 方法这样拦截器里拿到 body 就是最终字符串。String json objectMapper.writeValueAsString(orderRequest); orderClient.createOrder(json);PostMapping(/order/create) Order createOrder(RequestBody String body);这种做法的缺点是签名语义和业务方法签名绑定得比较紧但能保证签名内容与传输内容完全一致。方案二注册自定义 Encoder在 Encoder 阶段统一序列化并在序列化过程中做签名。这种方式链路更完整但实现复杂度高适合在真正需要精细控制的场景下使用。我现在项目里用的是方案一操作简单、定位问题也快。4.4 防重放timestamp nonce 的双保险签名只解决了“内容有没有被篡改”的问题但没有解决“同一个请求被原样重放”的问题。如果黑客截获了一个有效请求过几分钟再原样发一次服务端验签依然通过。所以必须加上时间戳和 nonce 双保险timestamp服务端校验客户端时间与服务器时间差在允许范围内通常 5 分钟。超出范围的直接拒绝。nonce随机字符串服务端需要记录一段时间内使用过的 nonce。如果同一个 nonce 再次出现说明是重放请求直接拒绝。nonce 的存储服务端可以放在 Redis 里设置与时间戳窗口相同的过期时间。这里要注意并发场景如果同一毫秒内两个请求生成了相同 nonce概率极低但存在。因此生成 nonce 时建议加上随机数或线程安全手段String nonce Long.toHexString(System.nanoTime()) UUID.randomUUID().toString().replace(-, );4.5 服务端验签的简易实现思路虽然标题讲的是客户端拦截器但服务端验签逻辑决定了客户端怎么签名。我贴一个简单的 Controller 拦截器来模拟服务端验签流程方便你本地验证Component public class VerifySignInterceptor extends HandlerInterceptorAdapter { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String timestamp request.getHeader(X-Timestamp); String nonce request.getHeader(X-Nonce); String sign request.getHeader(X-Sign); if (!StringUtils.hasText(timestamp) || !StringUtils.hasText(nonce) || !StringUtils.hasText(sign)) { response.setStatus(401); return false; } // 1. 校验时间戳 long ts Long.parseLong(timestamp); if (Math.abs(System.currentTimeMillis() - ts) 5 * 60 * 1000) { response.setStatus(401); return false; } // 2. 校验 nonce 是否使用过此处应查 Redis if (nonceUsed(nonce)) { response.setStatus(401); return false; } // 3. 校验签名伪代码真实场景需要读取 body String content request.getRequestURI() \n readQuerySorted(request); String expectSign DigestUtils.sha256Hex(canonicalize(content)); if (!MessageDigest.isEqual(expectSign.getBytes(StandardCharsets.UTF_8), sign.getBytes(StandardCharsets.UTF_8))) { response.setStatus(401); return false; } return true; } }看到没服务端验签和客户端签名是一体两面的设计。客户端怎么拼字符串服务端就必须怎么拼字符串两边的canonicalize函数必须完全一致这是签名联调中最容易出问题的地方。5. 自带拦截器的“继承陷阱”子类库重写 Feign 配置时为何突然失联这一节是我自己项目的血泪经验。我们有一个基础 SDK 模块里面定义了一个公共的 Feign 配置类并注册了一个签名拦截器。业务模块引入这个 SDK 后只需要写接口和调用即可。// 基础 SDK 里的公共配置 Configuration public class BaseFeignConfig { Bean public RequestInterceptor signInterceptor() { return new SigningInterceptor(); } }某一天新业务方接入了 SDK但他们自己定义了 Feign 客户端时指定了一个CustomFeignConfigFeignClient(name order-service, configuration OrderFeignConfig.class) public interface OrderClient { }结果上线后联调一直报 401签名校验失败。排查了很久最后发现OrderFeignConfig里没有继承BaseFeignConfig而是自己定义了一套配置Configuration public class OrderFeignConfig { // 只配置了自定义的 decode/encoder忘了把父类的签名拦截器带进来 }在这个配置类生效的 Feign 客户端上BaseFeignConfig的全局拦截器没有生效因为 Feign 对每个 client 都会独立构建一套拦截器链局部配置可以覆盖或替换全局配置。5.1 Feign 拦截器集合的构建规则深入了解后我发现FeignClientsRegistrar在注册每个 FeignClient 时会读取你指定的configuration类并把这些类中的 Bean 注册到对应 client 的 Spring 子容器中。这个子容器和全局容器是隔离的RequestInterceptor的集合也基于这个子容器构建。所以如果局部配置类里自己也定义了一个RequestInterceptor这个拦截器会替换全局的拦截器而不是叠加。这一点很容易被忽略因为项目能正常启动看起来没有任何报错。5.2 排查过程从现象到根因当时我们的排查链路是这样的先看下游服务端日志发现收到的请求头里没有X-Sign确认拦截器确实没有执行在SigningInterceptor.apply()里加断点发现根本没走到检查是否注册了多个RequestInterceptor用ApplicationContext.getBeansOfType(RequestInterceptor.class)查看只有全局的拦截器一个但业务模块的 FeignClient 用的并不是这个最后检查OrderFeignConfig发现问题出在局部配置覆盖了全局配置且没有把签名拦截器重新注册进去。修正方式很简单让业务方的OrderFeignConfig继承 SDK 的BaseFeignConfigConfiguration public class OrderFeignConfig extends BaseFeignConfig { // 业务方自己的配置签名拦截器自动继承 }或者更稳妥的做法在业务模块中不要重复定义RequestInterceptor而是通过Import(BaseFeignConfig.class)把公开配置引入。5.3 设计层面的三点建议经历这次踩坑后我在设计 SDK 时总结了三条原则不要在基础配置类里写业务相关的拦截器必要时拆成多个配置类按场景分别引入如果确定拦截器必须全局生效把它注册成基础配置中的一个 Bean并且让所有局部配置类继承基础配置在 FeignClient 的自定义配置类上尽量不再定义RequestInterceptor类型的 Bean如果一定要就要清楚它是在替换全局拦截器而非追加。6. 调试与验证从日志全开到一个完整的测试用例写完了拦截器关键是怎么验证它真的按照预期工作。如果只是“感觉没问题”一旦联调签不上排查起来极其痛苦。6.1 打开 Feign 的日志Feign 自带的 Logger 可以输出请求头、请求体等关键调试信息。步骤是在配置类里指定日志级别Configuration public class FeignLogConfig { Bean Logger.Level feignLoggerLevel() { return Logger.Level.FULL; // NONE、BASIC、HEADERS、FULL } }在application.yml里开启对应包的日志输出logging: level: com.example.openapi.client: debug然后把Logger.Level设为FULL就可以在日志中看到[OrderClient#createOrder] --- POST http://order-service/order/create HTTP/1.1 [OrderClient#createOrder] X-Timestamp: 1735023600123 [OrderClient#createOrder] X-Nonce: 6f2a5c4d8e1b4f2a [OrderClient#createOrder] X-Sign: 3b4c5d6e7f8a9b0c... [OrderClient#createOrder] Content-Type: application/json [OrderClient#createOrder] --- END HTTP (56-byte body)日志级别对照表级别输出内容NONE不输出任何日志BASIC请求方法、URL、响应状态码、耗时HEADERSBASIC 请求和响应头FULLHEADERS 请求和响应体如果开了 DEBUG 日志但没看到任何输出先检查是不是logging.level写的包名不对。Feign 日志的 Logger name 是FeignClient 接口的完整类名必须对应到实际接口的包名。6.2 单测不启动服务端直接验证拦截器最方便的验证方式其实是写一个单元测试直接构造RequestTemplate然后调用拦截器的apply方法。这样不用启动 Spring 容器也不依赖下游服务。public class SigningInterceptorTest { Test public void testApply() { // 构造一个请求模板 RequestTemplate template new RequestTemplate(); template.method(HttpMethod.POST); template.uri(/order/create); template.query(orderId, 10086); template.body({\name\:\张三\}); // 初始化拦截器 ClientInfoProperties properties new ClientInfoProperties(); properties.setApiKey(client-001); properties.setApiSecret(secret); properties.setSignEnabled(true); SigningInterceptor interceptor new SigningInterceptor(properties); // 执行拦截逻辑 interceptor.apply(template); // 断言 assertNotNull(template.headers().get(X-Timestamp)); assertNotNull(template.headers().get(X-Nonce)); assertNotNull(template.headers().get(X-Sign)); // 打印签名结果以便和服务端算法对照 System.out.println(X-Timestamp: template.headers().get(X-Timestamp).iterator().next()); System.out.println(X-Sign: template.headers().get(X-Sign).iterator().next()); } }这种方法的好处是本地调试极其快速还能在 CI 中跑回归。凡是涉及签名规则调整我都会先跑一遍单测确认签名串和服务端一致再去做接口联调。6.3 跨库请求体的特殊处理Content-Type 为 form 表单最后提一个我在实际项目中遇到的坑。如果 Feign 方法接收的是RequestBody MapString, Object拦截器里template.body()拿到的字符串是 JSON 格式。但如果某个接口的 Content-Type 是application/x-www-form-urlencodedbody 就是keyvaluekey2value2格式。这时候签名字符串的拼接规则就完全不同了。针对不同 Content-Type我建议在拦截器里做分支String contentType template.headers().getOrDefault(Content-Type, List.of(application/json)).iterator().next(); if (contentType.contains(application/x-www-form-urlencoded)) { // 表单格式直接拼原始 urlencoded 字符串 } else { // JSON 格式直接拼 JSON 字符串 }表单参数在RequestTemplate中的body()拿到的可能是类字段的形式未必是最终的 urlencoded 结果这一点要特别小心。最稳妥的方式是在调用 Feign 之前就用UriComponentsBuilder等工具手动构造好表单字符串这样签名和传输完全一致。最后分享一个小技巧调试签名类拦截器我强烈建议在本地启动一个仅用于打印请求信息的最小 Mock 服务。方法很简单写一个 Spring Boot 接口把所有 header 和 body 打出来然后让 Feign 指向它。这样你能在几分钟内看出拦截器到底设置哪些 header、body 是什么、签名原文长什么样。另外如果你的项目同时依赖了多个 Feign 库以及重试组件要特别小心RequestInterceptor的执行次数。遇到重试时拦截器会随每次重试重新执行所以签名中的时间戳在重试时也会更新。服务端如果做了严格的时间窗口校验重试太频繁可能触发时间戳超过允许范围。我在项目中就是把这个时间窗口放宽到 1 分钟同时把 nonce 的 Redis 过期时间设为 5 分钟既防重放又不误伤正常重试。这套RequestInterceptor的方案我已经在多个项目中稳定运行核心思路就是统一收口、动态计算、可配置开关、测试兜底。照着这个思路做你的 Feign 客户端在接入第三方接口时会省掉很多重复劳动签名这类复杂逻辑也能在一个文件里维护清楚。