微服务网关实现 -- 浅聊Spring Cloud Gateway

222100209李炎东 2024-06-03 01:00:00
这个作业属于哪个课程2302软件工程社区
这个作业要求在哪里软件工程实践总结&个人技术博客
这个作业的目标个人技术总结
其他参考文献《构建之法》

目录

目录

  • 目录
  • 技术概述
  • 技术详述
  • Quick Start
  • 引入依赖
  • 配置yml文件
  • 服务注册与发现
  • 网关路由配置
  • 请求过滤器
  • 配置过滤器规则
  • 全局过滤器配置
  • Sentinel熔断限流
  • 问题和解决过程
  • 问题一
  • 问题二
  • 总结
  • 参考文献/博客

技术概述

​ Spring Cloud Gateway是建立在Spring生态系统之上的API网关,用于构建基于Spring框架的微服务网关,用于路由请求、执行过滤器链等。通常用于微服务架构中统一处理请求、提供安全性、监控等功能。学习该技术可提高微服务架构的可伸缩性和灵活性。难点在于配置路由规则、理解过滤器链的执行顺序等。

技术详述

Quick Start

引入依赖

​ SpringCloud是基于Spring生态的网关服务,首先项目中需要有Spring依赖(Boot/Cloud),然后我们在网关模块引入Spring Cloud Gateway的相关依赖。

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>

配置yml文件

server:
  port: 10001 # 网关端口
spring:
  application:
    name: gateway # 服务名称
  cloud:
    nacos:
      server-addr: nacos-server:8848 # nacos地址
    gateway:
      routes: # 网关路由配置
        - id: user # 路由id, 自定义,唯一即可
          # uri: 127.0.0.1:/user # - 路由目的地,支持lb和http两种
          uri: lb://userService # 路由的目的地,lb是负载均衡,后面跟服务名称
          predicates: # 路由断言,也就是判断是否符合路由规则的条件
            - Path=/user-service/** # path 按照路径进行匹配,只要以/user-service/开头就符合规则
          filters:
            - StripPrefix=1 # 过滤器,去除请求的前缀路径,StripPrefix=1 表示去除请求路径的第一个路径片段

​ 当然,手动指定路由有些麻烦,每次新编写一个服务都需要来网关模块手动配置路由,我们可以根据SpringCloud的服务注册与发现功能,简简单单的从注册中心获取服务,并自动生成路由,只要使用修改使用以下配置即可,我们这里以nacos为例:

网关路由可以配置的内容有:

  • 路由id: 路由唯一标示
  • uri: 路由目的地,支持lb和http两种
  • predicates: 路由断言,判断请求是否符合要求,符合则转发到路由目的地
  • filters: 路由过滤器,处理请求或响应

做到这里,一个简易的路由转发网关服务就搭建成功了,直接运行GatewayApplication服务,得到结果如图:

img

接下来只要请求/user-service/xx,网关就会将路由转发到userService服务的对应接口上

服务注册与发现

​ 要想要让微服务们,相互通信,我们需要实现一个服务注册与发现的机制。一个分布式系统中,服务的部署和扩展是动态变化的。我们通过服务注册与发现可以将服务注册到服务注册中心,其他服务可以通过监听注册中心来动态发现和获取所需服务实例。在正常的生产环境中,我们不可能对每个接口进行单独的路由配置,所以我们可以通过服务注册与发现来动态生成路由。

​ 我们只需要简单配置有关配置中心连接的配置和Spring Cloud Gateway的路由自动生成配置即可:

spring:
  cloud:
    gateway:
      discovery:
        locator:
          # 根据注册中心的服务自动生成路由
          enabled: true
          # 路由名转小写
          lower-case-service-id: true
  nacos:
    discovery:
      watch:
        enabled: true
      server-addr: nacos-server:18848        # nacos访问ip
      namespace: test
      group: NOOP_GROUP        # 分组名
      username: nacos        # nacos用户名
      password: nacos        # nacos密码

​ 这里我实现了spring-cloud-starter-alibaba-nacos-discovery这个依赖中的Subscriber<InstancesChangeEvent>来实现对注册中心的动态监听,产生事件时,提醒服务做出一些处理(后续可以用于预处理swagger在线接口文档,并根据服务种类数量动态更新在线文档)

img

网关路由配置

​ 上面我们已经提到了两种路由配置方式,接下来我们详细聊聊路由配置的路由断言工厂 - Predicate Factory

​ 我们在配置文件中写的断言规则只是字符串,这些字符串会被Predicate Factory读取并处理,转变为路由判断的条件。

​ 例如Path=/user/**是按照路径匹配,这个规则是由org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory类来处理的。

​ 像这样的断言工厂在SpringCloudGateway还有十几个:

名称说明示例
After是某个时间后的请求- After=2024-06-02T11:55:58.789-07:00[America/Denver]
Before是某个时间点之前的请求- Before=2024-06-02T14:35:230.433+08:00[Asia/Shanghai]
Between是某两个时间点之间的请求- Between=2024-06-02T11:55:58.789-07:00[America/Denver], 2024-06-03T11:55:58.789-07:00[America/Denver]
Cookie请求必须包含某些Cookie- Cookie=chocolate,ch.p
Header请求必须包含某些Header- Header=X-Request-Id, \d+
Host请求必须是访问某个host(域名)- Host=**.somehost.org,**.anotherhost.org
Method请求方式必须是指定方法- Method=GET,POST
Path请求路径必须符合指定规则- Path=/red/{segment},/blue/**
Query请求路径必须包含指定参数- Query=name,Jack
RemoteAddr请求者的ip必须是指定范围- RemoteQAddr=192.168.1.1/24
Weight权重处理

在上表中,有很多类型的Predicate。
比如说时间类型的Predicated(AfterRoutePredicateFactory、 BeforeRoutePredicateFactory、BetweenRoutePredicateFactory),当只有满足特定时间要求的请求才会交由router处理;

​ 除了这种方法,我们还可以将重写的RouteLocator注入Spring Bean,达到使用代码手动控制路由的实现。下面是一个简单的例子:

@SpringBootApplication
public class DemogatewayApplication {
    @Bean
    public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
        return builder.routes()
            .route("path_route", r -> r.path("/get")
                .uri("http://httpbin.org"))
            .route("host_route", r -> r.host("*.myhost.org")
                .uri("http://httpbin.org"))
            .route("rewrite_route", r -> r.host("*.rewrite.org")
                .filters(f -> f.rewritePath("/foo/(?<segment>.*)", "/${segment}"))
                .uri("http://httpbin.org"))
            .route("hystrix_route", r -> r.host("*.hystrix.org")
                .filters(f -> f.hystrix(c -> c.setName("slowcmd")))
                .uri("http://httpbin.org"))
            .route("hystrix_fallback_route", r -> r.host("*.hystrixfallback.org")
                .filters(f -> f.hystrix(c -> c.setName("slowcmd").setFallbackUri("forward:/hystrixfallback")))
                .uri("http://httpbin.org"))
            .route("limit_route", r -> r
                .host("*.limited.org").and().path("/anything/**")
                .filters(f -> f.requestRateLimiter(c -> c.setRateLimiter(redisRateLimiter())))
                .uri("http://httpbin.org"))
            .build();
    }
}

请求过滤器

​ 我们同样有两种方式可以编写过滤器,一种是在配置中编写对应路由的过滤器规则,另一种是在代码中自定义全局过滤器(GlobalFilter)。

配置过滤器规则

​ 首先介绍过滤器规则:

过滤规则实例说明
PrefixPath- PrefixPath=/app在请求路径前加上app
RewritePath- RewritePath=/test, /app/test访问localhost:9022/test,请求会转发到localhost:8001/app/test
SetPathSetPath=/app/通过模板设置路径,转发的规则时会在路径前增加app,{path}表示原请求路径
RedirectTo重定向
RemoveRequestHeader去掉某个请求头信息

注:当配置多个filter时,优先定义的会被调用,剩余的filter将不会生效。

下面是一个简单的例子,是改写路径过滤规则的实,/where/... 改成 test/...:

spring:
  cloud:
    gateway:
      routes:
      - id: rewrite_filter
        uri: http://localhost:8081
        predicates:
        - Path=/test/**
        filters:
        - RewritePath=/where(?<segment>/?.*), /test(?<segment>/?.*)

全局过滤器配置

我们以cors跨域配置为例,来讲讲这个全局过滤器配置。正常我们在Spring Cloud Gateway中配置Cors配置,需要在yml配置文件中配置以下内容:

spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOrigins: "*"
            allowedHeaders: "*"
            allowedMethods: "*"
        add-to-simple-url-handler-mapping: true

这样Spring就会自动给接受到的请求加上配置中跨域相关的请求头,但是我们在实际使用过程中,我们发现一些请求头会重复,如图,Vary 和 Access-Control-Allow-Origin 两个头重复了两次,其中浏览器对后者有唯一性限制!

img

所以,我们编写了一个全局过滤器GlobalFilter来进行对Cors请求头的去重。我们以此为例讲解全局过滤器。

要编写自己的全局过滤器,我们需要继承GlobalFilterOrdered,来实现过滤器的方法和对过滤器的优先级进行定义。

下面是一个简单的例子,看代码应该挺好理解的:

@Component
public class CorsResponseHeaderFilter implements GlobalFilter, Ordered {

    private static final String ANY = "*";
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            exchange.getResponse().getHeaders().entrySet().stream()
                    .filter(kv -> (kv.getValue() != null && kv.getValue().size() > 1))
                    .filter(kv -> (kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)
                            || kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)
                            || kv.getKey().equals(HttpHeaders.VARY)))
                    .forEach(kv ->
                    {
                        // Vary只需要去重即可
                        if(kv.getKey().equals(HttpHeaders.VARY)) {
                            kv.setValue(kv.getValue().stream().distinct().collect(Collectors.toList()));
                        } else{
                            List<String> value = new ArrayList<>();
                            if(kv.getValue().contains(ANY)){  //如果包含*,则取*
                                value.add(ANY);
                                kv.setValue(value);
                            }else{
                                value.add(kv.getValue().get(0)); // 否则默认取第一个
                                kv.setValue(value);
                            }
                        }
                    });
        }));
    }

    @Override
    public int getOrder() {
        // 指定此过滤器位于NettyWriteResponseFilter之后
        // 即待处理完响应体后接着处理响应头
        return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER + 1;
    }
}

Sentinel熔断限流

项目中我们也同样将Sentinel集成在了网关模块,对请求进行限流。这是一个简单的配置

@Component
public class GatewayConfig {

    /**
     * spring 提供的视图解析器
     */
    private final List<ViewResolver> viewResolvers;

    private final ServerCodecConfigurer serverCodecConfigurer;

    public GatewayConfig(ObjectProvider<List<ViewResolver>> viewResolversProvider,
                         ServerCodecConfigurer serverCodecConfigurer) {
        this.viewResolvers = viewResolversProvider.getIfAvailable(Collections::emptyList);
        this.serverCodecConfigurer = serverCodecConfigurer;
    }

    /**
     * 注册服务熔断过滤器
     * @return GlobalFilter 全局过滤器
     */
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public GlobalFilter sentinelGatewayFilter() {
        return new SentinelGatewayFilter();
    }

    /**
     * 初始化网关规则
     */
    @PostConstruct
    public void initGatewayRules() {
        Set<GatewayFlowRule> rules = new HashSet<>();
//        rules.add(
//                // resource与微服务应用中的${spring.application.name}保持一致
//                new GatewayFlowRule("praise-module").setCount(5).setIntervalSec(1));
        GatewayRuleManager.loadRules(rules);
    }

    /**
     * 注册限流异常处理器
     * @return SentinelGatewayBlockExceptionHandler 限流异常处理器
     */
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SentinelGatewayBlockExceptionHandler sentinelGatewayBlockExceptionHandler() {
        // Register the block exception handler for Spring Cloud Gateway.
        return new SentinelGatewayBlockExceptionHandler(viewResolvers,
                serverCodecConfigurer);
    }

    /**
     * 自定义限流异常处理器
     */
    @PostConstruct
    public void initBlockHandlers() {
        BlockRequestHandler blockRequestHandler = new BlockRequestHandler() {
            @Override
            public Mono<ServerResponse> handleRequest(ServerWebExchange serverWebExchange,
                                                      Throwable throwable) {
                return ServerResponse.status(HttpStatus.OK)
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .body(BodyInserters.fromObject("此接口被限流了"));
            }
        };
        GatewayCallbackManager.setBlockHandler(blockRequestHandler);
    }
    
}

问题和解决过程

问题一

跨域配置问题:

  • 描述
    • 使用传统注入SpringBean的方式无法成功进行跨域配置
  • 原因
    • Spring Cloud Gateway与传统配置不同,需要在网关过滤器的配置中配置好Cors配置
  • 解决方法
    • 如上问说的,在application.yml中添加有关Cors的请求头配置,但是出现了返回头重复的问题,通过编写全局过滤器删除多余请求头解决了这个问题

问题二

网关鉴权问题

  • 描述

    • 如何设计一个可用于分布式的,处于网关层面的rbac鉴权方案
  • 解决方法

    • 我们编写了一个新服务,按照对rbac开发规范设计了五个表的增删改查业务,网关服务调用认证服务的jwt工具,对进入网关的请求进行鉴权。
    • 这里使用了另一种GlobalFilter的配置方式,直接将GlobalFiter作为Spring Bean注册进系统。
        @Bean
        @Order(-101)
        public GlobalFilter jwtAuthGlobalFilter() {
            return (exchange, chain) -> {
                ServerHttpRequest serverHttpRequest = exchange.getRequest();
                ServerHttpResponse serverHttpResponse = exchange.getResponse();
                ServerHttpRequest.Builder mutate = serverHttpRequest.mutate();
                if (!authEnabled) {
                    return chain.filter(exchange.mutate().request(mutate.build()).build());
                }
                // 请求路径
                String requestUrl = serverHttpRequest.getURI().getPath();
                // 请求方法
                String method = serverHttpRequest.getMethod().name();
                // RestFul风格的请求路径
                String restFulPath = method + ":" + requestUrl;
                // url匹配工具
                AntPathMatcher antPathMatcher = new AntPathMatcher();
    
                //对白名单中的地址放行
                List<String> whiteList = whiteListProperties.getWhites();
                for(String str : whiteList){
                    if(requestUrl.contains(str)){
                        log.info("白名单,放行:{}", restFulPath);
                        return chain.filter(exchange);
                    }
                }
    
                // 从 HTTP 请求头中获取 JWT 令牌
                String token = getToken(serverHttpRequest);
                if (!StringUtils.hasLength(token)) {
                    return this.unauthorizedResponse(exchange, serverHttpResponse, MsgCodeUtil.MSG_CODE_JWT_TOKEN_ISNULL);
                }
    
                // 对Token解签名,并验证Token是否过期
                boolean isJwtNotValid = jwtTokenUtil.isTokenExpired(token);
                if(isJwtNotValid){
                    return this.unauthorizedResponse(exchange, serverHttpResponse, MsgCodeUtil.MSG_CODE_JWT_TOKEN_EXPIRED);
                }
                // 验证 token 里面的 userId 是否为空
                String userId = jwtTokenUtil.getUserIdFromToken(token);
                String username = jwtTokenUtil.getUserNameFromToken(token);
                String role = jwtTokenUtil.getUserRoleFromToken(token);
                if (!StringUtils.hasLength(userId)) {
                    return this.unauthorizedResponse(exchange, serverHttpResponse, MsgCodeUtil.MSG_CODE_JWT_ILLEGAL_ARGUMENT);
                }
    
                // 权限校验
                if (rbacEnabled) {
                    // 超级管理员直接放行
                    if (AuthConstants.ROOT_ROLE.equals(role)) {
                        // 设置用户信息到请求
                        addHeader(mutate, USER_ID, userId);
                        addHeader(mutate, USER_NAME, username);
                        // 内部请求来源参数清除
                        removeHeader(mutate, FROM_SOURCE);
                        return chain.filter(exchange.mutate().request(mutate.build()).build());
                    }
    
                    // 从redis中读入所有uri->角色对应关系
                    Map<Object, Object> entries = redisUtil.hmget(AuthConstants.AUTH_CACHE_KEY);
                    // 角色集合
                    List<String> authorities = new ArrayList<>();
                    entries.forEach((path, roles) -> {
                        // 路径匹配则添加到角色集合中
                        if (antPathMatcher.match((String) path, restFulPath)) {
                            authorities.addAll((List<String>) roles);
                        }
                    });
                    if (CollectionUtils.isEmpty(authorities) || !authorities.contains(role)) {
                        return this.unauthorizedResponse(exchange, serverHttpResponse, MsgCodeUtil.MSG_CODE_JWT_PERMISSION_LIMITED);
                    }
                }
    
                // 设置用户信息到请求
                addHeader(mutate, USER_ID, userId);
                addHeader(mutate, USER_NAME, username);
                // 内部请求来源参数清除
                removeHeader(mutate, FROM_SOURCE);
                return chain.filter(exchange.mutate().request(mutate.build()).build());
            };
        }
    

总结

​ 这算是第一次独立搭建这样一个比较大型的系统吧,之前只是止于学习,没有实际上手操作。通过这次实践,踩了不少坑,也对分布式、SpringCloud、网关等有了更深入的了解,也学习了一些底层源码的优秀之处。一个好的架构,对于业务的编写也是能起到很关键的作用的。

​ 下面这是我们后端项目的完整项目架构,整体都是依托于我们最初的微服务网关模块进行的,网关模块的抗压能力,以及健壮性都是在开发初期需要十分注意的。

img

参考文献/博客

Spring Cloud Gateway - 官方文档 (spring.io)

Spring Cloud Gateway CORS 方案看这篇就够了-腾讯云开发者社区 (tencent.com)

...全文
59 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

122

社区成员

发帖
与我相关
我的任务
社区描述
FZU-SE
软件工程 高校
社区管理员
  • LinQF39
  • 助教-吴可仪
  • 一杯时间
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧