Spring Cloud是一系列框架的有序集合。它利用[Spring Boot](https://baike.baidu.com/item/Spring Boot/20249767?fromModule=lemma_inlink)的开发便利性巧妙地简化了分布式系统基础设施的开发,如服务发现注册、配置中心、消息总线负载均衡断路器数据监控等,都可以用Spring Boot的开发风格做到一键启动和部署。参考链接:服务注册与发现原理掌握 SpringCloud OpenFeign 核心原理微服务间的远程接口调用:OpenFeign 的使用

参考文章:

【Springcloud】路由网关GateWay

SpringCloud Gateway鉴权和跨域解决方案全局认证鉴权

java 自定义注解 权限拦截

基础信息

概念

spring cloud一系列框架的有序集合
利用 spring boot 的开发便利性简化了分布式系统基础设施的开发,如服务发现注册、配置中心、消息总线、负载均衡、断路器、数据监控等,都可以用 spring boot 的开发风格做到一键启动和部署。

分布式、微服务系统常见的问题

服务雪崩
多个微服务之间调用时,假设微服务A调用微服务B和微服务C,微服务B和微服务C又调用其它微服务,这就是**“扇出”。若扇出链路上某个微服务调用响应时间过长或者不可用,对微服务A的调用就会占用越来越多的系统资源,进而引起系统崩溃,这就是所谓的“雪崩效应”
对于高流量的应用来说,单一的后端依赖可能会导致所有服务器上的所有资源都在几秒钟内饱和。比失败更糟糕的是,这些应用程序还可能导致服务之间的延迟增加,线程和其他系统资源紧张,导致整个系统发生更多的级联故障
这些都表示
需要对故障和延迟进行隔离和管理,以便单个依赖关系的失败,不能取消整个应用程序或系统。
通常一个模块下的某个实例失败后,这个模块依然还会接收流量,然后这个有问题的模块还调用了其他的模块,这样就会发生级联故障,或者叫雪崩。
解决方法
Hystrix是一个用于
处理分布式系统的延迟和容错的开源库,在分布式系统里,许多依赖不可避免的会调用失败,比如超时、异常等,Hystrix能够保证在一个依赖出问题的情况下,不会导致整体服务失败,避免级联故障,以提高分布式系统的弹性。
“断路器”本身是一种开关装置,当某个服务单元发生故障后,通过断路器的故障监控(类似熔断保险丝),
向调用方返回一个符合预期的、可处理的备选响应(FallBack),而非长时间等待或抛出调用方无法处理的异常**,保证了服务调用方的线程不会被长时间、不必要地占用,从而避免了故障在分布式系统中的蔓延,乃至雪崩。

核心组件 (Netflix)

组件 作用 解决了什么问题
Eureka 服务注册与发现 硬编码服务提供者地址的方式有不少问题。要想解决这些问题,服务消费者需要一个强大的服务发现机制,服务消费者使用这种机制获取服务提供者的网络信息。不仅如此,即使服务提供者的网络地址发生变化,服务消费者也无须修改配置文件
Ribbon 客户端侧负载均衡 Ribbon 的作用是负载均衡,会帮你在每次请求时选择一台服务器,均匀的把请求分发到各个服务器上
Feign REST调用 基于动态代理机制Feign Client 会根据注解,跟你指定的服务建立连接、构造请求、发起请求、获取响应、解析响应等
Hystrix 容错处理 服务链上,因某个微服务的异常,而导致雪崩效应,整条服务链宕机的问题;Hystrix会去捕获这个异常,利用Hystrix接口处理这类异常。提供线程池,不同的服务走不同的线程池,实现了不同服务调用的隔离,避免了服务雪崩的问题
Zuul 微服务网关 Zuul 网关转发请求给对应的服务,负责网络路由,可以做统一的降级、限流、认证授权、安全等

Eureka

Eureka是一个服务注册与发现组件。在微服务架构中,各个服务需要相互通信,Eureka允许服务实例在启动时注册自己,并且允许其他服务通过Eureka发现这些实例,从而实现服务间的动态负载均衡和高可用性。

案例说明: 假设你有一个订单服务(Order Service)和一个用户服务(User Service)。在使用Eureka之前,订单服务需要知道用户服务的具体地址才能调用它。而通过Eureka,订单服务只需Eureka Server查询用户服务的地址,从而实现解耦和动态负载均衡

1
2
3
4
// 在OrderService中通过Eureka获取UserService的地址
RestTemplate restTemplate = new RestTemplate();
String userServiceUrl = "http://USER-SERVICE/users";
ResponseEntity<User> response = restTemplate.getForEntity(userServiceUrl, User.class);

组件说明

  • 服务注册(Service Registration): 服务注册是指服务提供者将自己的元数据信息(通常包括主机和端口号,有时还有身份验证信息,协议,版本号,以及运行环境的信息。)注册到服务注册中心的过程。
  • 服务发现(Service Discovery): 服务发现是指服务消费者(客户端)在需要调用服务时,通过查询服务注册中心获取服务提供者的服务实例信息的过程。服务消费者根据获取到的服务实例列表,选择合适的服务实例进行调用。 服务发现使得服务消费者无需预先知道服务提供者的具体地址,而是在运行时动态地获取服务实例信息,实现了服务间的松耦合。

工作原理

对于微服务的地址管理,引入了一种称为 ServiceRegistry 的服务注册与发现的组件。ServiceRegistry 理解成一个 key-value 形式的数据存储,我们将服务的名字和地址一一对应的存进来。同时,它也提供了读取其他服务地址及地址变更的通知功能。服务发现的基本需求:

  1. 服务提供者 Service 将自己的名称和地址(ip+port)注册到 ServiceRegistry;
  2. 客户端 Client 根据要访问的 Service 的名字向 ServiceRegistry 查询服务的地址;
  3. 拿到地址后,Client 就可以向 Service 发起连接,请求服务;
  4. 如果 Service 地址变更,只需要重新注册新地址,同时由 ServiceRegistry 负责通知 Client 地址变更。

基本流程如下图:

image-20250626124019083

服务地址注册

  1. 首先,服务启动好之后就可以将自己的地址(ip+port)写入 ServiceRegistry 中;
  2. 同时,为了对应网络状况的不稳定,还需要定期与 ServiceRegistry 保持心跳;
  3. 最后,当服务下线时,需要注销自己。

服务地址发现

  1. 客户端 Client 启动后,如果要和 Service 通信,需要来 ServiceRegistry 查询 Service 的地址;
  2. 监听 Service 各个实例地址的变更;
  3. 收到变更通知后,及时做出对应的调整;

一般来说,一种类型的 Service 服务往往有多个实例,因此查询到的地址可能有多个。此时便需要相应负载均衡策略选择一个。
Client 可以根据配置的负载均衡策略:轮询、随机、取模等。

Ribbon

Ribbon是一个客户端负载均衡器,它与Eureka结合使用,允许服务消费者在多个服务实例之间进行负载均衡。通过Ribbon,服务消费者可以根据负载情况选择最佳的服务实例进行调用,从而提升系统的性能和可靠性。

案例说明:当订单服务调用用户服务时,Ribbon会从Eureka获取用户服务的所有实例,并根据负载均衡策略选择一个实例调用。

1
2
3
4
5
6
7
8
9
@LoadBalanced
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}

// 使用Ribbon进行负载均衡
String userServiceUrl = "http://USER-SERVICE/users";
ResponseEntity<User> response = restTemplate.getForEntity(userServiceUrl, User.class);

Hystrix

Hystrix是一个断路器组件,用于应对分布式系统中的服务故障。可在服务调用失败时快速返回,防止故障扩散,并提供降级处理机制。 也就是说,当某个服务单元发生故障(类似用电器发生短路)之后,通过断路器的故障监控(类似熔断保险丝),向调用方返回一个错误响应,而不是长时间的等待。这样就不会使线程因调用故障服务被长时间占用不释放,避免故障在分布式系统中的蔓延

案例说明: 假设用户服务由于某些原因不可用,Hystrix可以在调用失败时立即返回一个默认值,避免订单服务长时间等待或崩溃。

1
2
3
4
5
6
7
8
@HystrixCommand(fallbackMethod = "defaultUser")
public User getUser(String userId) {
return restTemplate.getForObject("http://USER-SERVICE/users/" + userId, User.class);
}

public User defaultUser(String userId) {
return new User("default", "Default User");
}

Spring Cloud Config

Spring Cloud Config用于分布式系统的配置管理。它支持集中化的外部配置,允许应用程序在启动时从集中化的配置服务器获取配置,从而实现配置的集中管理和动态更新。

案例说明: 假设你的系统需要在不同的环境(如开发、测试、生产)中运行,每个环境有不同的配置。通过Spring Cloud Config,你可以将这些配置存储在一个中央配置仓库(如Git),并在应用程序启动时动态加载。

1
2
3
4
5
6
7
# application.yml (Config Server)
spring:
cloud:
config:
server:
git:
uri: https://github.com/your-repo/config-repo

Spring Cloud Gateway

Spring Cloud Gateway旨在为微服务架构提供一种简单有效的统一的API路由管理方式,是处理所有外部请求的入口。同时,基于Filter链方式提供了网关的基本功能,比如:路由转发(反向代理),执行过滤器链鉴权、流量控制、熔断、路径重写、黑白名单、日志监控等。
基本功能如下:
(1)统一入口:暴露出网关地址,作为请求唯一入口,隔离内部微服务,保障了后台服务的安全性
(2)鉴权校验:识别每个请求的权限,拒绝不符合要求的请求
(3)动态路由:能够匹配任何请求属性,动态的将请求路由到不同的后端集群中

案例说明:假设你有多个微服务(如订单服务、用户服务、支付服务),外部请求需要通过统一的入口进行路由和处理。通过Spring Cloud Gateway,你可以定义路由规则,将请求转发到相应的服务,并进行统一的身份验证和日志记录

1
2
3
4
5
6
7
8
9
10
11
12
13
# application.yml (Gateway Service)
spring:
cloud:
gateway:
routes:
- id: order_service
uri: lb://ORDER-SERVICE
predicates:
- Path=/orders/**
- id: user_service
uri: lb://USER-SERVICE
predicates:
- Path=/users/**

468382b00b2f521bd9f929150b4b869

鉴权方案

RBAC(Role-Based Access Control)基于角色访问控制,目前使用最为广泛的权限模型。相信大家对这种权限模型已经比较了解了。此模型有三个用户、角色和权限,在传统的权限模型用户直接关联加了角色,解耦了用户和权限,使得权限系统有了更清晰的职责划分和更高的灵活度。

image-20250627192905696

  1. 添加依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    </dependency>
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>RELEASE</version>
    </dependency>
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>
  2. 实现代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    @Configuration
    @Component
    public class AuthGlobalFilter implements GlobalFilter, Ordered {
    @Autowired
    JwtTokenUtil jwtTokenUtil;
    @Autowired(required = false)
    JedisUtil jedisUtil;
    private String cachePrefix = "km-gateway-";
    @Value("${spring.redis.expired}")
    private Integer expiredSecond;//600000,10m 过期时间
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    ServerHttpRequest request = exchange.getRequest();
    HttpHeaders httpHeaders = request.getHeaders();
    exchange.getRequest().getURI();
    String requestUri = request.getPath().pathWithinApplication().value();
    String token = null;
    if (httpHeaders != null && httpHeaders.containsKey("token") && !httpHeaders.get("token").isEmpty()) {
    token = httpHeaders.get("token").get(0);
    }
    // AuthenticateRequest
    if (StringUtil.isBlank(token)) {
    // String message = "You current request uri do not have permission or auth.";
    // return getVoidMono(exchange, message);
    return chain.filter(exchange);
    }
    String userAccountId = jwtTokenUtil.getUserAccountIdFromToken(token);
    boolean hasPermission = checkPermission(userAccountId, requestUri);
    String username = jwtTokenUtil.getUsernameFromToken(token);
    String redisSetUrlKey = cachePrefix.concat("url-").concat(username);
    // log.info("###### hasPermission.2=" + hasPermission);
    if (hasPermission) {
    jedisUtil.SetAndTime(redisSetUrlKey, expiredSecond, requestUri);
    } else {
    String message = "You current request uri do not have permission or auth.";
    // log.warn(message);
    return getVoidMono(exchange, message);
    }
    jwtTokenUtil.isValid(token);
    return chain.filter(exchange);
    }
    @Override
    public int getOrder() {
    return 0;
    }
    //根据角色权限进行权限控制
    private boolean checkPermission(String userId, String requestUrl) {
    return false;
    }
    private Mono<Void> getVoidMono(ServerWebExchange exchange, String body) {
    exchange.getResponse().setStatusCode(HttpStatus.OK);
    byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
    DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
    return exchange.getResponse().writeWith(Flux.just(buffer));
    }
    }

自定义注解 权限拦截(非网关)

  1. 创建注解 RequiresPermissions

    1
    2
    3
    4
    5
    6
    7
    8
    // 自定义注解 RequiresPermissions 控制权限,可用于方法或类上。
    // 该注解具有一个属性 value,用于指定需要进行权限验证的权限列表。这个属性是一个字符串数组,可以包含多个权限。
    @Target(ElementType.METHOD) // 指定注解只能加在方法上面
    @Retention(RetentionPolicy.RUNTIME) // 注解在运行时仍然可用
    public @interface RequiresPermissions {
    // String[] value();
    String value(); // 获取字符串就可以了
    }
  2. 创建权限注解拦截器 PermissionInterceptor

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    /**
    * 创建名为 PermissionInterceptor 的拦截器。在 preHandle 方法中,我们首先判断当前请求是否需要权限验证。
    * 如果需要验证,我们从请求对象中获取 HandlerMethod 对象,然后获取注解 @RequiresPermissions 并读取其中的权限列表。
    * 接下来,你需要根据具体的权限验证逻辑来验证当前用户是否具有这些权限。如果用户没有权限,你可以抛出一个异常或重定向到错误页面。
    * 如果用户有权限,你可以继续处理请求,并返回 true;否则,中断请求,并返回 false。
    * 拦截器配置完之后需要 WebMvcConfiguration 注册才生效
    * */
    @Component
    @Slf4j
    public class PermissionInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(
    HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

    if (handler instanceof HandlerMethod) {
    HandlerMethod handlerMethod = (HandlerMethod) handler;
    Method method = handlerMethod.getMethod();
    RequiresPermissions annotation = method.getAnnotation(RequiresPermissions.class);
    if (annotation != null) {
    String permissions = annotation.value(); // 是一个字符串
    log.info("获取权限的值:{}", permissions); // 取数组的值
    // 在这里验证用户权限,如果用户没有权限,抛出异常或重定向到错误页面。
    // 获取当前用户
    Long currentId = BaseContext.getCurrentId(); // 当前用户登录的 id 线程里面取的局部变量
    System.out.println(currentId);

    // 根据用户查询菜单栏的权限,可以把权限存入缓存或者直接查询数据库都可以
    List<String> list = new ArrayList<String>();
    list.add("postUpdateRule,POST"); // 此处是自定义的
    list.add("postCreateRule,POST");
    log.info("获取权限集合:{}",list);

    if (list.contains(permissions)) { // 有权限则通过,放行
    return true;
    } else { // 不通过,响应401状态码
    return MessageUtil.returnErrorMessage(response,"你没有权限访问该接口");
    }
    // ... 省略验证权限的代码 ...
    }
    }
    return true; // 如果验证通过,继续处理请求,返回true;否则,中断请求,返回false。
    }
    }
  3. 去 WebMvcConfiguration 注册 addInterceptors 里面注册

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    // 配置类,注册web层相关组件
    @Configuration
    @Slf4j
    public class WebMvcConfiguration extends WebMvcConfigurationSupport {

    @Autowired
    private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;
    @Autowired
    private JwtTokenUserInterceptor jwtTokenUserInterceptor;
    @Autowired
    private PermissionInterceptor permissionInterceptor;

    /**
    * 注册自定义拦截器
    * @param registry
    */
    protected void addInterceptors(InterceptorRegistry registry) {
    log.info("开始注册自定义拦截器...");
    registry.addInterceptor(jwtTokenAdminInterceptor)
    .addPathPatterns("/admin/**")
    .excludePathPatterns("/admin/employee/login");
    // 注册: 可以注册多个
    registry.addInterceptor(jwtTokenUserInterceptor)
    .addPathPatterns("/user/**")
    .excludePathPatterns("/user/user/login")
    .excludePathPatterns("/user/shop/status");
    // 增删改查权限拦截 不能全部拦截 否则会拦截静态资源的访问
    registry.addInterceptor(permissionInterceptor)
    .addPathPatterns("/admin/**")
    .excludePathPatterns("/admin/employee/login");
    }

    // 通过knife4j生成接口文档
    @Bean
    public Docket docket1() {
    ApiInfo apiInfo = new ApiInfoBuilder()
    .title("苍穹外卖项目接口文档")
    .version("2.0")
    .description("苍穹外卖项目接口文档")
    .build();
    Docket docket = new Docket(DocumentationType.SWAGGER_2)
    .groupName("管理端接口")
    .apiInfo(apiInfo)
    .select()
    // 指定生成接口需要扫描的包
    .apis(RequestHandlerSelectors.basePackage("com.sky.controller.admin"))
    .paths(PathSelectors.any())
    .build();
    return docket;
    }


    // 通过knife4j生成接口文档
    @Bean
    public Docket docket2() {
    ApiInfo apiInfo = new ApiInfoBuilder()
    .title("苍穹外卖项目接口文档")
    .version("2.0")
    .description("苍穹外卖项目接口文档")
    .build();
    Docket docket = new Docket(DocumentationType.SWAGGER_2)
    .groupName("用户端接口")
    .apiInfo(apiInfo)
    .select()
    // 指定生成接口需要扫描的包
    .apis(RequestHandlerSelectors.basePackage("com.sky.controller.user"))
    .paths(PathSelectors.any())
    .build();
    return docket;
    }

    /**
    * 设置静态资源映射
    * @param registry
    */
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/doc.html")
    .addResourceLocations("classpath:/META-INF/resources/");
    registry.addResourceHandler("/webjars/**")
    .addResourceLocations("classpath:/META-INF/resources/webjars/");
    /*
    配置server虚拟路径,handler为前台访问的URL目录,locations为files相对应的本地路径
    也就是说如果有一个 upload/avatar/aaa.png 请求,那程序会到后面的目录里面找aaa.png文件
    另外:如果项目中有使用Shiro,则还需要在Shiro里面配置过滤下
    */
    String projectDir = System.getProperty("user.dir"); // 获取当前项目的根目录
    registry.addResourceHandler("/public/uploads/**")
    .addResourceLocations("file:" + projectDir + "/public/uploads/");
    }

    // 统一处理响应的时间格式 扩展spring mvc框架的消息转化器
    @Override
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    log.info("开始扩展消息转换器...");
    // 创建一个消息转换器对象
    MappingJackson2HttpMessageConverter converter=new MappingJackson2HttpMessageConverter();
    // 需要为消息转换器设置一个对象转换器,对象转换器可以将java对象序列化为json数据
    converter.setObjectMapper(new JacksonObjectMapper());
    // 将我们自己的撞墙放入spring MVC框架的容器中,将自己的消息转换器加入容器中,排在第一位优先使用
    converters.add(0,converter);
    }
    }
  4. 使用 @RequiresPermissions注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @RestController
    @RequestMapping("/admin/system/menu")
    @Api(tags = "菜单栏权限接口")
    @Slf4j
    public class SysMenuContrller {
    @Autowired
    private SysMenuService sysMenuService;
    // 查询系统菜单列表
    */
    @GetMapping("/list")
    @RequiresPermissions("postCreateRule,POST")
    @ApiOperation("查询菜单列表")
    public Result<List<SysMenu>> list(SysMenuQueryDTO sysMenuQueryDTO){
    List<SysMenu> sysMenuList=sysMenuService.selectMenuList(sysMenuQueryDTO);
    return Result.success(sysMenuList);
    }
    }

跨域解决方案

在SpringCloud项目中,前后端分离目前很常见,在调试时会遇到前端页面通过不同域名或IP访问微服务的后台,此时,如果不加任何配置,前端页面的请求会被浏览器跨域限制拦截,所以,业务服务常常会添加跨域配置

  1. 配置类实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @Configuration
    public class GulimallCorsConfiguration {
    // 添加跨域过滤器
    @Bean
    public CorsWebFilter corsWebFilter(){
    //基于url跨域,选择reactive包下的
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

    CorsConfiguration configuration = new CorsConfiguration(); // 跨域配置信息
    configuration.addAllowedHeader("*"); // 允许跨域的头
    configuration.addAllowedMethod("*"); // 允许跨域的请求方式
    configuration.addAllowedOrigin("*"); // 允许跨域的请求来源
    configuration.setAllowCredentials(true); // 是否允许携带cookie跨域
    source.registerCorsConfiguration("/**", configuration); // 任意url都要进行跨域配置
    return new CorsWebFilter(source);
    }
    }
    // 注意:SpringCloudGateWay中跨域配置不起作用,原因是SpringCloudGetway是Springwebflux的而不是SpringWebMvc的,所以我们需要导入的包导入错了
  2. 配置文件实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    server:
    port: 10010
    spring:
    application:
    name: gatewayservice
    cloud:
    gateway:
    globalcors:
    cors-configurations:
    '[/**]':
    allowedOrigins: "https://www.xx.com" # 允许那些网站跨域访问
    allowedMethods: "GET" # 允许那些Ajax方式的跨域请求
    allowedHeaders: "*" # 允许请求头携带信息
    allowCredentials: "*" # 允许携带cookie
    maxAge: 360000 # 这次跨域有效期于相同的跨域请求不会再预检

Spring Cloud Sleuth和Zipkin

Spring Cloud Sleuth提供分布式追踪功能,用于跟踪微服务间的调用链路。它与Zipkin结合使用,可以收集和分析调用数据,帮助开发者理解和优化系统性能。

案例说明:假设你有一个复杂的微服务系统,包括订单服务、用户服务和支付服务等。通过Spring Cloud Sleuth和Zipkin,你可以跟踪一个订单请求在各个服务间的调用链路,了解每个服务的响应时间,从而发现性能瓶颈和优化点。

1
2
3
4
5
6
7
8
9
10
11
12
// OrderService中使用Sleuth进行追踪
@Autowired
private Tracer tracer;

public void createOrder(Order order) {
Span newSpan = tracer.nextSpan().name("createOrder");
try (Tracer.SpanInScope ws = tracer.withSpan(newSpan.start())) {
// 创建订单逻辑
} finally {
newSpan.end();
}
}

OpenFeign

什么是 Feign

Feign 是声明式 Web 服务客户端,它使编写 Web 服务客户端更加容易。Feign 不做任何请求处理,通过处理注解相关信息生成 Request,并对调用返回的数据进行解码,从而实现 简化 HTTP API 的开发

OpenFeign 是一种声明式、模板化的 HTTP 客户端。在 Spring Cloud 中使用 OpenFeign ,可以做到使用 HTTP 请求访问远程服务,就像调用本地方法一样的,开发者完全感知不到这是在调用远程方法,更感知不到在访问 HTTP 请求。

image-20250627171616504

1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

创建 FeignClient 接口

在 SpringCloud 的使用中,启动某个组件一般是 @Enable… 方式注入,Feign 的启动需要在类上标记@EnableFeignClients

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 启动类上标记@EnableFeignClients
@EnableFeignClients
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

// 创建一个 Service 接口,添加 @FeignClient 注解
@Service
@FeignClient(name = "nacos-provider")
public interface ProductService {
// 调用远程服务 nacos-provider 的 product/{id} 接口
@GetMapping("/product/{id}")
String getProductById(@PathVariable("id") Long id);
}

// 控制层通过 FeignClient 远程调用
@RestController
public class TestController {
@Autowired
private ProductService productService;

@GetMapping("/product/{id}")
public String getProduct(@PathVariable("id") Long id) {
return productService.getProductById(id);
}
}

关于 FeignClient 注解,需要知道:

  • name : 是一个任意的客户端名称,用于创 Spring Cloud LoadBalancer 客户端;
  • url :url一般用于调试,可以手动指定 @FeignClient 调用的地址;
  • configuration :Feigin 配置类,可自定义 Feign 的 EncodeDecodeLogLevelContract
  • fallback :定义 容错 的类,当远程调用的接口失败或者超时的时候,会调用对应接口的容错逻辑,fallback 执行的类必须实现@FeignClient 标记的接口;
  • fallbackFactory :工厂类,用于生成 fallback 类实例,通过此属性可以实现每个接口通用的容错逻辑,以达到减少重复的代码;
  • path :定义当前 FeignClient 的统一前缀。

解析:控制层引入被 FeighClient 标记的接口 ProductService ,直接调用接口的 getProductById 方法即可实现远程调用 nacos-provider 服务的 getProduct ,以下是远程服务的处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
public class TestController {

private final static Map<Long, String> PRODUCT_MAP = new HashMap<>();

static {
PRODUCT_MAP.put(1L, "香飘飘奶茶");
PRODUCT_MAP.put(2L, "雀巢咖啡");
PRODUCT_MAP.put(3L, "百事可乐");
}

@Value("${server.port}")
private String serverPort;

@GetMapping("/product/{id}")
public String getProduct(@PathVariable Long id) {
return serverPort + ":" + PRODUCT_MAP.get(id);
}
}

Feign 的底层原理

  1. 通过 @EnableFeignCleints 注解启动 Feign Starter 组件

  2. Feign Starter 在项目启动过程中注册全局配置,扫描包下所有的 @FeignClient 接口类,并进行注册 IOC 容器

    • 添加全局配置registerDefaultConfiguration 方法流程如下

      1. 获取 @EnableFeignClients 注解上的属性及对应 Value
      2. 生成 FeignClientSpecification(存储 Feign 中的配置类) 对应的构造器 BeanDefinitionBuilder
      3. FeignClientSpecification Bean 名称为 default. + @EnableFeignClients 修饰类全限定名称 + FeignClientSpecification
      4. @EnableFeignClients defaultConfiguration 默认为 {},若没有相关配置,默认使用 FeignClientsConfiguration 并结合 name 填充到 FeignClientSpecification,最终注册为 IOC Bean
    • 注册 FeignClient 接口:重点在 registerFeignClients 上,该方法主要是将修饰了 @FeignClient 的接口注册为 IOC Bean

      1. 扫描 @EnableFeignClients 注解,若有 clients则加载指定接口,否则按 scanner 规则扫描出修饰了 @FeignClient 的接口
      2. 获取 @FeignClient 上对应的属性,根据 configuration 属性去创建接口级的 FeignClientSpecification 配置类 IOC Bean
      3. 将 @FeignClient 的属性设置到 FeignClientFactoryBean 对象上,并注册 IOC Bean
  3. @FeignClient 接口类被注入时,通过 FactoryBean#getObject 返回动态代理类

    • FactoryBean接口 FeignClientFactoryBean 特征
      1. 它会在类初始化时执行一段逻辑,依据 Spring InitializingBean 接口
      2. 它被别的类 @Autowired 进行注入时,返回的是 FactoryBean#getObject 返回的类,依据 Spring FactoryBean 接口
      3. 它能够获取 Spring 上下文对象,依据 Spring ApplicationContextAware 接口
  4. 接口被调用时被动态代理类逻辑拦截,将 @FeignClient 请求信息通过编码器生成 HTTP Request

    1. 调用 @FeignClient 接口时,会被 FeignInvocationHandler#invoke 拦截,并在动态代理方法中执行步骤4 - 7的逻辑
  5. 交由 Ribbon 进行负载均衡,挑选出一个健康的 Server 实例,OpenFeign内部集成了Ribbon

    1. 通过 Ribbon 获取服务列表,并对服务列表进行负载均衡调用(服务名转换为 ip+port
  6. 继而通过 Client 携带 Request 调用远端服务返回请求响应

  7. 通过解码器生成 HTTP Response 返回客户端,将信息流解析成为接口返回数据

image-20250627181823081

OpenFeign 的日志

OpenFeign 提供了日志打印功能,我们可以通过配置来调整日志级别,从而了解 OpenFeign 中 Http 请求的细节。 通过设置日志,可以对 Feign 接口的调用情况进行监控和输出。OpenFeign 的日志级别主要有以下几种:

  • NONE :默认的,不显示任何日志;
  • BASIC :仅记录请求方法、URL、响应状态码及执行时间;
  • HEADERS :除了 BASIC 中定义的信息之外,还有请求和响应的头信息;
  • FULL :除了 HEADERS 中定义的信息之外,还有请求和响应的正文及元数据。

使用步骤:

  1. 设置 Feign Logger Level

    1
    2
    3
    4
    5
    @Bean
    Logger.Level feignLoggerLevel() {
    // 开启详细日志
    return Logger.Level.FULL;
    }
  2. 在配置文件中给指定的 FeignClient 接口加指定的日志级别

    1
    2
    3
    4
    logging:
    level:
    # 给指定的 FeignClient 接口加指定的日志级别
    cn.chendapeng.springcloud.openfeignservice.service.ProductService: debug

OpenFeign 超时时间

在项目中已经添加了 spring-cloud-starter-loadbalancer依赖,这样在 @FeignClient 注解中,当设定了 name = "nacos-provider" 客户端名称后,便默认使用了 Spring Cloud LoadBalancer 进行负载均衡访问 nacos-provider ,在老版本中,集成的是 Ribbon ,它默认的响应时间是 1 s,可以通过 ribbon.ReadTimeoutribbon.ConnectTimeout 来设置客户端超时时间。

Spring Cloud Loadbalancer 默认没有超时时间的限制。但我们依然可以在默认客户端default)和命名客户端上(注解 FeignClient 设置的 name,比如本demo中的 nacos-provider)配置超时。

OpenFeign 使用两个超时参数:

  • connectTimeout 防止由于服务器处理时间长而阻塞调用者。
  • readTimeout 从连接建立时开始,在返回响应时间过长时触发。

具体设置方式:

1
2
3
4
5
6
7
8
9
10
11
12
feign:
client:
config:
# 默认的超时时间设置
default:
connectTimeout: 5000
readTimeout: 5000
# 在指定的 FeignClient 设置超时时间,覆盖默认的设置
nacos-provider:
connectTimeout: 1000
readTimeout: 1000
loggerLevel: full

以上设置 nacos-provider 的超时时间为 1s ,我们通过在 nacos-provider 设置一个延迟 3 秒执行的方法,来模仿长业务线调用。

nacos-providercn.chendapeng.springcloud.nacosprovider.controller.TestController

1
2
3
4
5
6
7
8
9
@GetMapping("/product/{id}")
public String getProduct(@PathVariable Long id) {
try {
TimeUnit.SECONDS.sleep(3); // 添加 sleep 时间,模拟超时连接
} catch (InterruptedException e) {
e.printStackTrace();
}
return serverPort + ":" + PRODUCT_MAP.get(id);
}

结果报错:java.net.SocketTimeoutException Create breakpoint:Read timed out