软件工程应用与实践(10)——网关,服务熔断

Posted 叶卡捷琳堡

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了软件工程应用与实践(10)——网关,服务熔断相关的知识,希望对你有一定的参考价值。

2021SC@SDUSC

文章目录

一、概述

在上一篇文章中,我介绍了微服务治理中的服务注册,配置中心,服务间通信等内容,本博客重点介绍老年健康管理系统中关于网关和服务熔断的内容。在老年健康管理系统中,为了保证服务的可用,防止出现服务雪崩现象,使用了Spring Cloud Alibaba的Sentinel对微服务系统进行保护。在分析具体的源代码之前,我首先查阅了相关资料,这些资料能帮我更好地理解项目的源代码

网关

网关的英文叫做gateway,网关可以用于管理微服务的统一入口,方便实现对平台众多微服务进行管控。在之前单体应用的环境下,后端提供给前端的接口相对较少,可以使用API接口文档进行管理,但随着系统不断扩大,需要一个统一的网关用于管理所有的接口。除此之外网关的作用还非常强大,网关的主要作用如下

  • 统一所有微服务入口
  • 实现请求路由转发,并实现路由转发过程中的负载均衡
  • 对请求进行身份认证,脱敏,并发以及流量控制

经过阅读源码,发现本项目使用Spring Cloud的gateway组件对微服务入口进行统一管理

服务雪崩与服务熔断

服务雪崩是指,在微服务系统服务调用的过程中,由于一个服务故障,导致级联服务故障的情况,成为服务雪崩。服务雪崩的情况十分危险,因为会导致级联服务不可用。

服务熔断是用于解决服务雪崩问题的,当某个服务出现故障时,通过断路器监控,发现某个异常条件被触发,则会直接熔断整个服务,并返回一个备选的,符合预期的响应,直到服务恢复。

经过阅读源码,发现本项目使用Spring Cloud Alibaba的Sentinel对微服务进行相应的保护。

二、代码分析

2.1 gateway网关

在Spring Cloud中,网关一共有两种配置方式,一种是使用yml的方式配置,还有一种是使用java代码的方式进行配置,本项目中两种方法都有使用,首先使用了yml配置了网关的基本信息,又使用java代码配置了网关请求失败的异常信息,接下来我将对这两个部分分别进行分析

2.1.2 yml配置


首先我们查看对应的pom.xml,发现导入了相关的依赖

  • 第一个依赖引入webflux组件
  • 第二个依赖引入我们本次代码分析的核心组件:网关gateway
  • 第三个依赖引入了Spring Boot的测试组件
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

接下来我们阅读bootstrap.yml和application.yml两个配置文件,可以看到在application.yml中,有关于网关的配置

  • 首先使用default-filters属性,设置DedupeResponseHeader为Access-Control-Allow-Origin,表明网关的配置是允许跨域的,default-filters代表默认的过滤器
  • 接下来的globalcors和下面的配置,同样是对跨域的配置。项目中指定了请求的源,请求方法,请求头等信息
  • 接下来我们关注routes,这个组件是我们本次网关配置的核心。在routes的下一级,我们可以看到有以下几个子属性:id属性,uri属性,order属性,predicates断言属性等
  • id属性用于指明当前路由对象的唯一标识
  • uri属性用于指明当前路由所访问的微服务路径,即类别服务名称
  • predicates的意思是断言,用于配置具体的路由规则,比如在本项目的配置中,就指定了具体的路径,并且通过RequestBody指定了请求体为true,表明要求要有请求体
  • filters的意思是过滤器,用于对发往该请求的路由进行处理。在本项目中,使用StripPrefix=2,用于将发往该网关的请求截去前两个路径。比如一个路径是/name/blue/red,经过截取后转发的路径是/red
cloud:
 gateway:
   default-filters:
      - DedupeResponseHeader=Access-Control-Allow-Origin
   globalcors:
      add-to-simple-url-handler-mapping: true
      corsConfigurations:
        '[/**]':
          allowed-origins: "http://localhost:8989"
          allowed-methods: "*"
          allowed-headers: "*"
          allow-credentials: true
   discovery:
     locator:
       lowerCaseServiceId: true
       enabled: true
   routes:
     - id: nurse-auth
       uri: lb://nurse-admin
       order: 8000
       predicates:
       - RequestBody=true
       - Path=/api/auth/**
       filters:
       - StripPrefix=2
     - id: nurse-admin
       uri: lb://nurse-admin
       order: 8001
       predicates:
       - RequestBody=true
       - Path=/api/admin/**
       filters:
       - StripPrefix=2
     - id: nurse-generator
       uri: lb://nurse-generator
       order: 8001
       predicates:
         - Path=/api/code/**
       filters:
         - StripPrefix=1
     - id: nurse-sample
       uri: lb://nurse-sample
       order: 8001
       predicates:
         - RequestBody=true
         - Path=/api/sample/**
       filters:
         - StripPrefix=2

除了以上几点以外,我们还可以发现,所有的uri的前面都是lb://,这是因为在使用gateway继承时,需要考虑分发请求路径时的负载均衡,从而避免了路径写死的问题。用服务类别来代表服务地址

我们可以看到,在配置文件中,已经继承了Ribbon负载均衡组件,在负载均衡的Ribbon组件中,定义了请求的耗时,最长连接时间等信息

ribbon:
  eureka:
    enabled: true
  ReadTimeout: 60000
  ConnectTimeout: 60000
  MaxAutoRetries: 0
  MaxAutoRetriesNextServer: 1
  OkToRetryOnAllOperations: false

2.1.2 java代码

在本项目中,关于网关的java代码配置在config包,filter和handler包下,一共有三个配置类,这三个配置类各司其职

在config包下,有一个类是GatewayConfig,这个类里配置了网关的基本信息

  • restTemplate方法用于配置一个restTemplate对象,并使用LoadBalanced注解,保证这是一个可以发送负载均衡Http请求的restTemplate对象
  • feignDecoder方法同样用于配置一个Decoder对象,ResponseEntityDecoder是用户可以自定义的一个返回对象,用于在OpenFeign调用的时候返回
  • 下面的feignHttpMessageConverter方法和GateWayMappingJackson2HttpMessageConverter类,继承了MappingJackson2HttpMessageConverter类,而通过查阅官方文档可以知道,MappingJackson2HttpMessageConverter类是用于将java对象,数组,字符串等形式的数据转为JSON对象的一个转换器
  • 最后的loadBalancedWebClientBuilder方法,用于创建一个带有负载均衡的客户端,同样加上了Ribbon组件的@LoadBalanced注解
  • 最后统一说明,@Bean注解是Spring框架中的注解,用于表明将具体的对象注入容器中,该对象的新建,释放等全部交由容器管理,程序员不需要关心,仅需获取与调用对象的方法即可
@Configuration
public class GatewayConfig 
    @LoadBalanced
    @Bean
    public RestTemplate restTemplate() 
        return new RestTemplate();
    
    @Bean
    public Decoder feignDecoder() 
        return new ResponseEntityDecoder(new SpringDecoder(feignHttpMessageConverter()));
    
    public ObjectFactory<HttpMessageConverters> feignHttpMessageConverter() 
        final HttpMessageConverters httpMessageConverters = new HttpMessageConverters(new GateWayMappingJackson2HttpMessageConverter());
        return new ObjectFactory<HttpMessageConverters>() 
            @Override
            public HttpMessageConverters getObject() throws BeansException 
                return httpMessageConverters;
            
        ;
    
    public class GateWayMappingJackson2HttpMessageConverter extends MappingJackson2HttpMessageConverter 
        GateWayMappingJackson2HttpMessageConverter()
            List<MediaType> mediaTypes = new ArrayList<>();
            mediaTypes.add(MediaType.valueOf(MediaType.TEXT_html_VALUE + ";charset=UTF-8"));
            setSupportedMediaTypes(mediaTypes);
        
    
    @Bean
    @LoadBalanced
    public WebClient.Builder loadBalancedWebClientBuilder() 
        return WebClient.builder();
    

在AccessGatewayFilter类中,对网关的过滤器进行了更为详细的配置。这个类实现了GlobalFilter接口,而GlobalFilter类是Spring Cloud Gateway组件中自带的一个接口,里面只有一个方法,就是filter方法

  • 通过@Slf4j注解表明该项目需要使用日志输出,在加入@Slf4j注解后,程序员可以使用log.xxx()输出对应的日志,较为方便
  • 在属性注入方面,使用@Value注解,通过$的形式注入配置文件中的相关属性
  • filter方法重写了接口的filter方法,主要用于获取和检验用户权限
  • 在这个类中,返回了大量的Mono对象,这个对象是Spring中Reactor编程的对象,即响应式编程,关于响应式编程的内容太多,由于本文的重点放在网关上,因此这里不再赘述
  • 具体方法的说明,在代码中的注释里有
@Configuration
@Slf4j
public class AccessGatewayFilter implements GlobalFilter 
    @Autowired
    private LogService logService;
    @Value("$gate.ignore.startWith")
    private String startWith;
    private static final String GATE_WAY_PREFIX = "/api";
    @Autowired
    private UserAuthUtil userAuthUtil;
    @Autowired
    private UserAuthConfig userAuthConfig;
    @Autowired
    private WebClient.Builder webClientBuilder;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Override
    public Mono<Void> filter(ServerWebExchange serverWebExchange, GatewayFilterChain gatewayFilterChain) 
        log.info("check token and user permission....");
        LinkedHashSet requiredAttribute = serverWebExchange.getRequiredAttribute(ServerWebExchangeUtils.GATEWAY_ORIGINAL_REQUEST_URL_ATTR);
        ServerHttpRequest request = serverWebExchange.getRequest();
        // 获取当前网关访问的URI
        String requestUri = request.getPath().pathWithinApplication().value();
        if (requiredAttribute != null) 
            Iterator<URI> iterator = requiredAttribute.iterator();
            while (iterator.hasNext()) 
                URI next = iterator.next();
                if (next.getPath().startsWith(GATE_WAY_PREFIX)) 
                    requestUri = next.getPath().substring(GATE_WAY_PREFIX.length());
                
            
        
        final String method = request.getMethod().toString();
        BaseContextHandler.setToken(null);
        ServerHttpRequest.Builder mutate = request.mutate();
        // 网关不进行拦截的URI配置,常见如验证码、Login接口
        if (isStartWith(requestUri)) 
            ServerHttpRequest build = mutate.build();
            return gatewayFilterChain.filter(serverWebExchange.mutate().request(build).build());
        
        IJWTInfo user = null;
        try 
            // 判断用户token,获取用户信息
            user = getJWTUser(request, mutate);
         catch (Exception e) 
            log.error("用户Token过期异常", e);
            return getVoidMono(serverWebExchange, new TokenForbiddenResponse("User Token Error or Expired!"), HttpStatus.UNAUTHORIZED);
        
        Mono<CheckPermissionInfo> checkPermissionInfoMono = webClientBuilder.build().
                get().uri("http://nurse-admin/api/user/username/check_permission?requestMethod=" + method + "&requestUri=" + requestUri, user.getUniqueName()).header(userAuthConfig.getTokenHeader(), BaseContextHandler.getToken()).retrieve().bodyToMono(CheckPermissionInfo.class);
        IJWTInfo finalUser = user;
        return checkPermissionInfoMono.flatMap(checkPermissionInfo -> 
            // 当前用户具有访问权限
            if (checkPermissionInfo.getIsAuth()) 
                if (checkPermissionInfo.getPermissionInfo() != null) 
                    // 若资源存在则请求设置访问日志
                    setCurrentUserInfoAndLog(serverWebExchange, finalUser, checkPermissionInfo.getPermissionInfo());
                
                ServerHttpRequest build = mutate.build();
                return gatewayFilterChain.filter(serverWebExchange.mutate().request(build).build());
             else 
                // 当前用户不具有访问权限
                return getVoidMono(serverWebExchange, new TokenForbiddenResponse("Forbidden!Does not has Permission!"), HttpStatus.FORBIDDEN);
            
        );
    
    /**
     * 网关抛异常
     *
     * @param body
     */
    @NotNull
    private Mono<Void> getVoidMono(ServerWebExchange serverWebExchange, BaseResponse body, HttpStatus status) 
        serverWebExchange.getResponse().setStatusCode(status);
        byte[] bytes = JSONObject.toJSONString(body).getBytes(StandardCharsets.UTF_8);
        DataBuffer buffer = serverWebExchange.getResponse().bufferFactory().wrap(bytes);
        return serverWebExchange.getResponse().writeWith(Flux.just(buffer));
    
    private void setCurrentUserInfoAndLog(ServerWebExchange serverWebExchange, IJWTInfo user, PermissionInfo pm) 
        String host = serverWebExchange.getRequest().getRemoteAddress().toString();
        LogInfo logInfo = new LogInfo(pm.getMenu(), pm.getName(), pm.getUri(), new Date(), user.getId(), user.getName(), host, String.valueOf(serverWebExchange.getAttributes().get(RequestBodyRoutePredicateFactory.REQUEST_BODY_ATTR)));
        DBLog.getInstance().setLogService(logService).offerQueue(logInfo);
    
    /**
     * 返回session中的用户信息
     *
     * @param request
     * @param ctx
     * @return
     */
    private IJWTInfo getJWTUser(ServerHttpRequest request, ServerHttpRequest.Builder ctx) throws Exception 
        List<String> strings = request.getHeaders().get(userAuthConfig.getTokenHeader());
        String authToken = null;
        if (strings != null) 
            authToken = strings.get(0);
        
        if (StringUtils.isBlank(authToken)) 
            strings = request.getQueryParams().get("token");
            if (strings != null) 
                authToken = strings.get(0);
            
        
        IJWTInfo infoFromToken = userAuthUtil.getInfoFromToken(authToken);
        String s = stringRedisTemplate.opsForValue().get(RedisKeyConstant.REDIS_KEY_TOKEN + ":" + infoFromToken.getTokenId());
        if (StringUtils.isBlank(s)) 
            throw new UserTokenException("User token expired!");
        
        ctx.header(userAuthConfig.getTokenHeader(), authToken);
        BaseContextHandler.setToken(authToken);
        return infoFromToken;
    
    /**
     * URI是否以什么打头
     *
     * @param requestUri
     * @return
     */
    private boolean isStartWith(String requestUri) 
        boolean flag = false;
        for (String s : startWith.split(",")) 
            if (requestUri.startsWith(s)) 
                return true;
            
        
        return flag;
    

在对应的配置文件中,我们可以看到注入的属性值

gate:
  ignore:
    startWith: /auth/jwt,/auth/captcha

在handler包下的RequestBodyRoutePredicateFactory类,继承了AbstractRoutePredicateFactory类,AbstractRoutePredicateFactory类是一个对路由断言的配置类

  • 使用@Order注解,表明组件的加载顺序。由于在Spring中,组件都注入容器,而这些组件的加载可以由程序员自己指定顺序,在Order注解中,值越小,优先级越高
  • 在本类中,同样定义了一个Log对象,用于输出日志
  • 定义了一个messageReaders对象,用于获取请求的消息
  • 本类中定义了两个构造函数,其中第一个构造函数是空构造函数,首先调用父类的构造函数执行,并将messageReaders设置为默认值
  • 第二个构造函数可以让用户传入messageReaders,并在构造函数内赋值
  • 在applyAsync方法中,对网关的断言进行了相关的配置。使用lambda表达式构造返回值
  • 在apply方法中,用于抛出网关调用时的异常,抛出的异常是UnsupportedOperationException,通过阅读源码可以得知,这个异常类继承了RunTimeException
  • 在本类中,还自定义了一个静态内部类Config,在这个类中,有一个属性sources,他的类型是ArrayList,在这个类中使用了两个set方法和一个get方法对该变量进行赋值。
  • set方式的赋值使用了直接赋List或赋数组的形式,再调用Arrays.asList方法,将数组转为List集合
@Slf4j
@Component
@Order(1)
public class RequestBodyRoutePredicateFactory
        extends AbstractRoutePredicateFactory<RequestBodyRoutePredicateFactory.Config> 
    protected static final Log LOGGER = LogFactory.getLog(RequestBodyRoutePredicateFactory.class);
    private final List<HttpMessageReader<?>> messageReaders;

    public RequestBodyRoutePredicateFactory() 
        super(以上是关于软件工程应用与实践(10)——网关,服务熔断的主要内容,如果未能解决你的问题,请参考以下文章

微服务高可用之熔断器实现原理与 Golang 实践

大佬分享:API网关在微服务架构中的应用

阿里大神分享API网关在微服务架构中的应用!

阿里大佬分享API网关在微服务架构中的应用

API网关在网龙教育业务中的实践

API网关在微服务架构中的应用