目标 :掌握微服务间的声明式 HTTP 调用
学习时长:1~2 周
目录
- 服务间通信方式
- [OpenFeign 快速入门](#OpenFeign 快速入门)
- [Feign 核心注解](#Feign 核心注解)
- [Feign 日志配置](#Feign 日志配置)
- [Feign 超时配置](#Feign 超时配置)
- [Feign 请求拦截器](#Feign 请求拦截器)
- [Feign 降级(Fallback)](#Feign 降级(Fallback))
- [Spring Cloud LoadBalancer](#Spring Cloud LoadBalancer)
- RestTemplate(对比)
- [Feign vs RestTemplate](#Feign vs RestTemplate)
- 面试高频题
1. 服务间通信方式
同步调用:
OpenFeign(推荐)→ HTTP REST
RestTemplate → HTTP REST(命令式,已较少用)
gRPC → HTTP/2 + Protobuf(高性能、跨语言)
异步调用:
RabbitMQ / Kafka → 消息队列(解耦、削峰、最终一致)
同步 vs 异步选择
| 场景 | 推荐方式 |
|---|---|
| 需要立即得到结果(下单查库存) | OpenFeign 同步调用 |
| 不需要立即得到结果(发通知邮件) | 消息队列异步 |
| 跨语言高性能 | gRPC |
2. OpenFeign 快速入门
引入依赖
xml
<!-- spring-cloud-starter-openfeign 不包含 LoadBalancer,需一并引入 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
开启 Feign(启动类)
java
@SpringBootApplication
@EnableFeignClients // 扫描 @FeignClient 注解,生成代理对象
public class OrderServiceApplication { ... }
定义 Feign 客户端
java
/**
* @FeignClient(name = "user-service")
* Spring 会:
* 1. 从 Eureka/Nacos 中查找 "user-service" 的实例列表
* 2. 使用 LoadBalancer 选择一个实例(默认轮询)
* 3. 将方法调用转换为 HTTP 请求发出去
* 4. 将响应反序列化为方法返回类型
*/
@FeignClient(name = "user-service")
public interface UserFeignClient {
// 与 user-service 的 Controller 路径完全一致
@GetMapping("/api/users/{id}")
Result<User> getUserById(@PathVariable("id") Long id);
@PostMapping("/api/users")
Result<User> createUser(@RequestBody CreateUserDTO dto);
@GetMapping("/api/users")
Result<List<User>> listAll(@RequestParam(required = false) String keyword);
}
注入使用
java
@Service
@RequiredArgsConstructor
public class OrderService {
private final UserFeignClient userFeignClient; // 像本地 Bean 一样注入
public void createOrder(Long userId) {
Result<User> result = userFeignClient.getUserById(userId); // 透明 HTTP 调用
User user = result.getData();
// ...
}
}
3. Feign 核心注解
路径与参数
java
@FeignClient(name = "user-service")
public interface UserFeignClient {
// @PathVariable:路径参数(必须写 value)
@GetMapping("/api/users/{id}")
Result<User> getById(@PathVariable("id") Long id);
// @RequestParam:查询参数
@GetMapping("/api/users")
Result<Page<User>> list(
@RequestParam("page") int page,
@RequestParam("size") int size,
@RequestParam(value = "keyword", required = false) String keyword);
// @RequestBody:JSON 请求体
@PostMapping("/api/users")
Result<User> create(@RequestBody CreateUserDTO dto);
// @RequestHeader:请求头
@GetMapping("/api/users/profile")
Result<User> getProfile(@RequestHeader("Authorization") String token);
}
文件上传
java
@PostMapping(value = "/api/files/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
Result<String> upload(@RequestPart("file") MultipartFile file);
4. Feign 日志配置
Feign 有四种日志级别:
| 级别 | 输出内容 |
|---|---|
NONE(默认) |
不输出 |
BASIC |
请求方法、URL、响应状态码、耗时 |
HEADERS |
BASIC + 请求/响应头 |
FULL |
HEADERS + 请求/响应体 |
java
// 全局配置
@Configuration
public class FeignConfig {
@Bean
public Logger.Level feignLoggerLevel() {
return Logger.Level.FULL; // 开发时用 FULL,生产用 BASIC
}
}
// 针对单个客户端配置
@FeignClient(name = "user-service", configuration = FeignConfig.class)
public interface UserFeignClient { ... }
yaml
# application.yml 中开启 Feign 接口的日志(SLF4J 需要 DEBUG 级别)
logging:
level:
com.example.cloud.order.feign: DEBUG # 指定 Feign 接口的包路径
spring:
cloud:
openfeign:
client:
config:
default:
loggerLevel: BASIC # NONE/BASIC/HEADERS/FULL
5. Feign 超时配置
yaml
spring:
cloud:
openfeign:
client:
config:
# 全局默认配置
default:
connect-timeout: 5000 # 连接超时(ms)
read-timeout: 10000 # 读取超时(ms)
# 针对具体服务的配置(覆盖全局)
user-service:
connect-timeout: 3000
read-timeout: 5000
order-service:
read-timeout: 30000 # 下单接口耗时长,设置更大超时
6. Feign 请求拦截器
常用于:统一传递 Token、TraceId、租户ID等
java
@Component
public class FeignAuthInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 1. 从当前请求上下文获取 Token
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
String token = attrs.getRequest().getHeader("Authorization");
if (token != null) {
// 2. 透传到下游服务(解决 Feign 调用丢失 Token 问题)
template.header("Authorization", token);
}
}
// 3. 添加链路追踪 ID
template.header("X-Trace-Id", UUID.randomUUID().toString());
// 4. 添加服务标识
template.header("X-Source-Service", "order-service");
}
}
⚠️ 重要:Feign 调用默认不会传递当前请求的 Header,必须通过拦截器手动传递(Token、TraceId等)。
7. Feign 降级(Fallback)
当目标服务不可用时,返回默认值而不是抛异常。
方式一:fallback 类
java
@FeignClient(name = "user-service", fallback = UserFallback.class)
public interface UserFeignClient {
@GetMapping("/api/users/{id}")
Result<User> getUserById(@PathVariable("id") Long id);
}
@Component // 必须注册为 Bean
public class UserFallback implements UserFeignClient {
@Override
public Result<User> getUserById(Long id) {
log.warn("user-service 不可用,触发降级");
return Result.fail(503, "用户服务暂不可用,请稍后重试");
}
}
方式二:fallbackFactory(可获取异常信息)
java
@FeignClient(name = "user-service", fallbackFactory = UserFallbackFactory.class)
public interface UserFeignClient { ... }
@Component
public class UserFallbackFactory implements FallbackFactory<UserFeignClient> {
@Override
public UserFeignClient create(Throwable cause) {
return id -> {
log.error("调用 user-service 失败: {}", cause.getMessage());
return Result.fail(503, "用户服务异常: " + cause.getMessage());
};
}
}
注意:使用 Feign Fallback 需要配合 Resilience4j 或 Sentinel 的断路器,需引入相关依赖并开启断路器。
8. Spring Cloud LoadBalancer
Spring Cloud LoadBalancer 是 Ribbon 的替代品,负责从服务实例列表中选择一个实例。
内置策略
java
// 1. 轮询(默认):依次请求每个实例
// 2. 随机:每次随机选一个实例
// 自定义为随机策略:
@Configuration
@LoadBalancerClient(name = "user-service",
configuration = UserServiceLoadBalancerConfig.class)
public class UserServiceLoadBalancerConfig {
@Bean
public ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
LoadBalancerClientFactory factory) {
return new RandomLoadBalancer(
factory.getLazyProvider("user-service",
ServiceInstanceListSupplier.class), "user-service");
}
}
区域感知路由(Zone Affinity)
yaml
spring:
cloud:
loadbalancer:
zone: shanghai # 优先选择同 zone 的实例(需在实例 metadata 中配置 zone)
9. RestTemplate(对比)
java
// 传统方式(不推荐,冗余代码多)
@Configuration
public class RestTemplateConfig {
@Bean
@LoadBalanced // 开启负载均衡(可使用服务名代替 IP)
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final RestTemplate restTemplate;
public User getUser(Long id) {
// 需手动处理序列化、错误码、类型转换
ResponseEntity<Result<User>> response = restTemplate.exchange(
"http://user-service/api/users/{id}", // 使用服务名
HttpMethod.GET,
null,
new ParameterizedTypeReference<Result<User>>() {},
id
);
return response.getBody().getData();
}
}
10. Feign vs RestTemplate
| 维度 | OpenFeign | RestTemplate |
|---|---|---|
| 代码量 | 少(接口声明) | 多(命令式) |
| 类型安全 | ✅(编译期检查) | ❌(运行时) |
| 日志/拦截 | ✅ 内置支持 | 手动实现 |
| 超时配置 | 简单(配置文件) | 需要定制 |
| 降级 | ✅(fallback) | 手动 try-catch |
| 可读性 | 高(接口即文档) | 低 |
| 推荐 | ✅ 新项目首选 | 不推荐 |
11. 面试高频题
Q1:OpenFeign 的底层原理是什么?
Feign 通过 JDK 动态代理为
@FeignClient接口生成代理对象。调用接口方法时,代理将方法签名转换为 HTTP 请求(使用 Encoder 序列化参数),通过 LoadBalancer 选择实例,然后使用 HTTP 客户端(默认 JDK HttpURLConnection,可换 OkHttp/Apache HttpClient)发起请求,最后用 Decoder 反序列化响应。
Q2:Feign 调用时如何传递 Header(如 Token)?
实现
RequestInterceptor接口,在apply()方法中通过RequestContextHolder获取当前请求的 Header,然后通过RequestTemplate.header()添加到 Feign 请求中。
Q3:Feign 的超时时间和 Ribbon/LoadBalancer 的超时有什么区别?
Feign 自身有连接超时和读超时配置;LoadBalancer 控制负载均衡行为(选择哪个实例),不控制超时。Feign 的超时配置需要在
spring.cloud.openfeign.client.config中设置,不要和 Ribbon 的配置混淆。
Q4:Feign 和 Dubbo 的区别?
Feign 基于 HTTP REST,轻量易用,与 Spring 生态无缝集成;Dubbo 基于自定义 RPC 协议,性能更高(序列化更高效),适合内部高频调用。现在 Dubbo 也支持 REST 模式,两者差距在缩小。
Q5:如何解决 Feign 调用丢失 ThreadLocal 数据的问题?
Feign 使用线程池异步执行请求,ThreadLocal 中的数据不会传递。解决方案:①使用
RequestInterceptor将数据转移到 Header;②使用TransmittableThreadLocal(阿里 TTL)实现线程池内的数据传递。
12. Feign 底层原理深度解析(专家必知)
知识点 1:Feign 代理对象创建链路
text
@EnableFeignClients
└── FeignClientsRegistrar(ImportBeanDefinitionRegistrar)
└── 扫描 @FeignClient 注解
└── 为每个接口注册 FeignClientFactoryBean 到 Spring 容器
└── 调用 getObject() 时:
├── 从 FeignContext 获取该服务专属的配置(编解码器、拦截器等)
├── 创建 Feign.Builder(配置 Logger、Encoder、Decoder、Contract)
└── 生成 JDK 动态代理(ReflectiveFeign.newInstance)
└── 返回代理对象注入 Spring 容器
知识点 2:一次 Feign 调用的完整链路
text
调用 userFeignClient.getUserById(1L)
│
▼
JDK InvocationHandler.invoke()
│
├── 根据方法签名找到 MethodHandler
│
├── RequestTemplate 构建(Contract 解析 @GetMapping 等注解)
│ ├── URL:/api/users/1
│ ├── Method:GET
│ └── Headers:从拦截器附加
│
├── RequestInterceptor 链执行(添加 Token、TraceId 等)
│
├── LoadBalancer 选择实例(从 Nacos/Eureka 缓存中选)
│ └── http://192.168.1.100:9001/api/users/1
│
├── HTTP Client 发起请求(默认 HttpURLConnection,推荐 OkHttp/Apache HC)
│
├── 响应 Decoder 反序列化(JSON → Result<User>)
│
└── 若异常 → ErrorDecoder 处理 → 触发 Fallback
知识点 3:替换 Feign 底层 HTTP 客户端(生产优化)
默认使用 JDK 内置 HttpURLConnection,不支持连接池,高并发性能差。
xml
<!-- 替换为 Apache HttpClient 5(推荐,支持连接池) -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-hc5</artifactId>
</dependency>
yaml
spring:
cloud:
openfeign:
httpclient:
hc5:
enabled: true # 启用 Apache HC5
max-connections: 200 # 最大连接数
max-connections-per-route: 50 # 每个目标的最大连接数
为什么要换:JDK HttpURLConnection 每次请求新建 TCP 连接(无连接池),高并发下开销极大;OkHttp/Apache HC5 有连接池,复用 TCP 连接,QPS 可提升 5-10 倍。
13. 自定义负载均衡策略(专家实战)
知识点:基于元数据的版本路由(灰度发布核心)
场景:同一服务有 v1(稳定版)和 v2(灰度版),需要根据请求 Header 中的版本标识路由到对应版本的实例。
java
/**
* 版本感知负载均衡器
* 读取请求 Header 中的 X-Version,优先选择同版本实例
*/
public class VersionAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private final String serviceId;
private final ObjectProvider<ServiceInstanceListSupplier> supplierProvider;
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
// 从请求 Header 获取版本要求
String requestVersion = getRequestVersion(request);
return supplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new)
.get(request).next()
.map(instances -> selectInstance(instances, requestVersion));
}
private Response<ServiceInstance> selectInstance(
List<ServiceInstance> instances, String requestVersion) {
if (instances.isEmpty()) return new EmptyResponse();
// 1. 优先选择版本匹配的实例
if (requestVersion != null) {
List<ServiceInstance> matched = instances.stream()
.filter(i -> requestVersion.equals(i.getMetadata().get("version")))
.collect(Collectors.toList());
if (!matched.isEmpty()) {
return new DefaultResponse(matched.get(
ThreadLocalRandom.current().nextInt(matched.size())));
}
}
// 2. 无版本匹配,降级到轮询
return new DefaultResponse(instances.get(
ThreadLocalRandom.current().nextInt(instances.size())));
}
private String getRequestVersion(Request request) {
if (request.getContext() instanceof RequestDataContext ctx) {
return ctx.getClientRequest().getHeaders()
.getFirst("X-Version");
}
return null;
}
}
// 注册自定义负载均衡器
@Configuration
@LoadBalancerClient(name = "user-service",
configuration = VersionLoadBalancerConfig.class)
public class VersionLoadBalancerConfig {
@Bean
public ReactorLoadBalancer<ServiceInstance> versionAwareLoadBalancer(
LoadBalancerClientFactory factory) {
return new VersionAwareLoadBalancer("user-service",
factory.getLazyProvider("user-service", ServiceInstanceListSupplier.class));
}
}
实例元数据配置:
yaml
# v1 版本实例(稳定版)
spring:
cloud:
nacos:
discovery:
metadata:
version: v1
# v2 版本实例(灰度版)
spring:
cloud:
nacos:
discovery:
metadata:
version: v2
14. Feign 错误解码器与重试机制
知识点 1:ErrorDecoder ------ 统一处理下游错误响应
text
问题:当下游服务返回 4xx/5xx 时,Feign 默认抛出 FeignException
FeignException 中只有原始响应体字符串,不便于业务处理
解决:自定义 ErrorDecoder,将下游错误响应解析为业务异常
java
@Component
public class FeignErrorDecoder implements ErrorDecoder {
private final ObjectMapper objectMapper;
private final ErrorDecoder defaultDecoder = new ErrorDecoder.Default();
public FeignErrorDecoder(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public Exception decode(String methodKey, Response response) {
int status = response.status();
try {
// 读取响应体
String body = "";
if (response.body() != null) {
body = new String(response.body().asInputStream().readAllBytes(),
StandardCharsets.UTF_8);
}
// 尝试解析为统一响应格式
if (body.startsWith("{")) {
Result<?> result = objectMapper.readValue(body, Result.class);
// 按状态码做不同处理
return switch (status) {
case 400 -> new BusinessException(400, result.getMessage());
case 401 -> new UnauthorizedException(result.getMessage());
case 403 -> new ForbiddenException(result.getMessage());
case 404 -> new BusinessException(404,
"下游资源不存在 [" + methodKey + "]: " + result.getMessage());
case 409 -> new BusinessException(409, result.getMessage());
default -> new RemoteServiceException(status,
"下游服务异常 [" + methodKey + "]: " + result.getMessage());
};
}
} catch (Exception e) {
// 解析失败,使用默认处理
}
return defaultDecoder.decode(methodKey, response);
}
}
// 注册 ErrorDecoder
@Configuration
public class FeignConfig {
@Bean
public FeignErrorDecoder feignErrorDecoder(ObjectMapper objectMapper) {
return new FeignErrorDecoder(objectMapper);
}
}
知识点 2:Feign 重试机制
text
重试策略选择:
GET 请求(查询):安全,可以重试(幂等)
POST 请求(创建):不安全,不应自动重试(可能重复创建)
PUT/DELETE:通常幂等,可以重试
配置重试时只对 GET 请求重试,其他方法不重试
java
@Configuration
public class FeignRetryConfig {
/**
* 自定义重试器:只重试连接超时,不重试读超时(读超时可能是业务慢)
*/
@Bean
public Retryer feignRetryer() {
// 参数:重试间隔起始(ms), 最大间隔(ms), 最大尝试次数
return new Retryer.Default(100, 1000, 3);
}
/**
* 更精细的重试控制(不同服务不同策略)
*/
@Bean
@LoadBalancerClient(name = "user-service")
public Retryer userServiceRetryer() {
return new Retryer() {
private int attempt = 1;
private static final int MAX_ATTEMPTS = 2;
@Override
public void continueOrPropagate(RetryableException e) {
if (attempt++ >= MAX_ATTEMPTS) throw e;
// 只重试连接超时(connect timeout)
if (e.getCause() instanceof ConnectTimeoutException) {
try { Thread.sleep(100L * attempt); } catch (InterruptedException ignore) {}
return;
}
throw e; // 其他异常不重试
}
@Override
public Retryer clone() { return this; }
};
}
}
15. Feign 文件上传与二进制传输
知识点:跨服务传输文件
java
// 文件服务的 Feign 客户端
@FeignClient(name = "file-service", configuration = FeignMultipartConfig.class)
public interface FileServiceClient {
// 文件上传(multipart/form-data)
@PostMapping(value = "/api/files/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
Result<String> uploadFile(@RequestPart("file") MultipartFile file,
@RequestPart("category") String category);
// 批量上传
@PostMapping(value = "/api/files/batch",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
Result<List<String>> uploadFiles(@RequestPart("files") List<MultipartFile> files);
// 下载(字节数组)
@GetMapping("/api/files/{fileId}")
Response download(@PathVariable String fileId);
}
// 支持 Multipart 的 Feign 配置
@Configuration
public class FeignMultipartConfig {
@Bean
public Encoder feignEncoder(ObjectFactory<HttpMessageConverters> converters) {
return new SpringFormEncoder(new SpringEncoder(converters));
}
}
// 使用示例
@Service
@RequiredArgsConstructor
public class ProductService {
private final FileServiceClient fileClient;
public String uploadProductImage(MultipartFile image) {
// 直接传递 MultipartFile,Feign 帮你转成 multipart 请求
Result<String> result = fileClient.uploadFile(image, "product");
return result.getData();
}
public byte[] downloadFile(String fileId) throws IOException {
// 下载文件(Feign Response)
Response response = fileClient.download(fileId);
return response.body().asInputStream().readAllBytes();
}
}
16. 服务间鉴权:JWT 透传
知识点:微服务间调用的身份传递
text
问题:Gateway 验证了用户 JWT,把 userId 放入 Header 转发给下游
但 order-service 调用 user-service 时,如何携带用户身份?
方案A:下游服务信任 Gateway 的 X-User-Id Header
order-service 从 Header 取 userId → 调用 user-service 时携带
Feign 拦截器:从当前请求 Header 取 userId,放入下游请求
方案B:下游服务内部使用内部 Token(M2M)
服务间调用使用独立的内部 Token(不是用户 JWT)
内部 Token 拥有服务权限,不代表任何用户
推荐:A 方案(简单)+ B 方案(安全),组合使用
java
// 用户上下文(线程级别)
public class UserContextHolder {
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
private static final ThreadLocal<String> USER_ROLES = new ThreadLocal<>();
public static void setUserId(String userId) { USER_ID.set(userId); }
public static String getUserId() { return USER_ID.get(); }
public static void clear() { USER_ID.remove(); USER_ROLES.remove(); }
}
// Gateway 解析 JWT,透传到 Header
// (在 Gateway 全局过滤器中已实现,见第5章)
// X-User-Id: 123
// X-User-Roles: ADMIN,USER
// 下游服务拦截器:从 Request Header 读取,存入 ThreadLocal
@Component
public class UserContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
String userId = request.getHeader("X-User-Id");
String roles = request.getHeader("X-User-Roles");
if (userId != null) {
UserContextHolder.setUserId(userId);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
Object handler, Exception ex) {
UserContextHolder.clear(); // 必须清理,防止线程池复用时污染
}
}
// Feign 拦截器:调用下游时透传 Header
@Component
public class UserContextFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 从 ThreadLocal 取当前用户 ID,放入下游请求
String userId = UserContextHolder.getUserId();
if (userId != null) {
template.header("X-User-Id", userId);
}
// 透传原始请求中的其他追踪 Header
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
String traceId = attrs.getRequest().getHeader("X-Trace-Id");
if (traceId != null) template.header("X-Trace-Id", traceId);
}
}
}
17. OpenFeign 接口契约测试
知识点:确保服务提供者和消费者接口一致
text
问题:user-service 修改了接口(参数、返回值),
order-service 的 FeignClient 没有更新 → 运行时报错
解决:契约测试(Contract Testing)
提供者发布接口契约(Spring Cloud Contract)
消费者基于契约生成测试用例
CI/CD 中自动验证契约
简单方案:共享 API 模块
java
// user-service-api 模块(单独的 jar,提供者和消费者都依赖)
// 这样接口变更时,编译期就能发现不兼容
public interface UserApi {
@GetMapping("/api/users/{id}")
Result<UserDTO> getUserById(@PathVariable Long id);
@PostMapping("/api/users/batch")
Result<List<UserDTO>> getUsersByIds(@RequestBody List<Long> ids);
}
// user-service 实现 UserApi
@RestController
public class UserController implements UserApi {
@Override
public Result<UserDTO> getUserById(Long id) { ... }
@Override
public Result<List<UserDTO>> getUsersByIds(List<Long> ids) { ... }
}
// order-service 的 FeignClient 继承 UserApi(接口共享,类型安全)
@FeignClient(name = "user-service")
public interface UserFeignClient extends UserApi {
// 直接继承,无需重复定义方法签名
// user-service 修改接口时,这里编译期就报错
}