返回文章列表
高并发项目
SpringCloudGatewayNacos配置管理

03网关与nacos配置管理

微服务拆分完成后,五个独立服务(用户、商品、购物车、交易、支付)暴露了新的问题:每个微服务都有不同的地址或端口,前端调用时要访问不同地址;前端无法直接连接 Nacos,无法实时更新服务列表;单体架构时只需一次用户登录、身份校验,微服务后每个服务都要重复编写登录校验逻辑;微服务间相互调用时还难以传递用户信息。引入网关与 Nacos 配置管理正好可以一并解决这些问题,主要包含三件事:网关路由解决前端请求入口、网关过滤器解决统一登录校验与用户信息传递、Nacos 统一配置管理解决配置重复和热更新。

一、网关概述

网关就是网络的关口,负责请求的路由、转发、身份校验。前端请求不再直接打向某个微服务,而是统一发到网关,由网关判断该把请求路由到哪个具体服务。

网关在微服务中的位置

网关需要完成三件事:身份校验、路由转发、负载均衡。请求到达网关后先做身份校验,校验通过再根据规则路由到对应微服务,并通过注册中心拉取服务列表完成负载均衡。

网关的职责

在 SpringCloud 中网关的实现主要有两种:

  • Spring Cloud Gateway:Spring 官方出品,基于 WebFlux 响应式编程,无需调优即可获得优异性能。
  • Netflix Zuul:Netflix 出品,基于 Servlet 的阻塞式编程,需要调优才能获得与 SpringCloudGateway 类似的性能。

本项目选用 Spring Cloud Gateway。

二、网关路由

2.1 快速入门

网关服务搭建非常简单,共四步:创建新模块、引入网关依赖、编写启动类、配置路由规则。整个流程对外只暴露一个网关地址,背后连接 Nacos 注册中心完成服务注册与发现。

2.1.1 引入依赖

创建新的 hm-gateway 模块,引入网关依赖:

<!--网关-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--nacos 服务注册发现-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--负载均衡-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

2.1.2 配置路由规则

在 application.yaml 中配置路由:

server:
  port: 8080
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: 192.168.150.101:8848
    gateway:
      routes:
        - id: item                                      # 路由规则id,自定义,唯一
          uri: lb://item-service                        # 路由目标微服务,lb代表负载均衡
          predicates:                                   # 路由断言,判断请求是否符合规则
            - Path=/items/**                            # 以请求路径做判断,以/items开头则符合
        - id: xx
          uri: lb://xx-service
          predicates:
            - Path=/xx/**

完整的请求流程是:前端请求 http://127.0.0.1:8080/items/list → 网关根据 /items/** 路由规则匹配 → 通过 Nacos 拉取 item-service 实例列表 → 负载均衡选择一个实例 → 转发请求到目标微服务。

2.2 路由属性

网关路由对应的 Java 类型是 RouteDefinition,其中常见的属性有:

  • id:路由唯一标示
  • uri:路由目标地址
  • predicates:路由断言,判断请求是否符合当前路由
  • filters:路由过滤器,对请求或响应做特殊处理

2.2.1 路由断言

Spring 提供了 12 种基本的 RoutePredicateFactory 实现:

名称 说明 示例
After 是某个时间点后的请求 - After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 是某个时间点之前的请求 - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 是某两个时间点之前的请求 - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.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 或者 - Query=name
RemoteAddr 请求者的 ip 必须是指定范围 - RemoteAddr=192.168.1.1/24
Weight 权重处理 - Weight=group1, 2
XForwardedRemoteAddr 基于请求的来源 IP 做判断 - XForwardedRemoteAddr=192.168.1.1/24

2.2.2 路由过滤器

网关中提供了 33 种路由过滤器,每种过滤器都有独特的作用。常用的包括:

名称 说明 示例
AddRequestHeader 给当前请求添加一个请求头 AddRequestHeader=headerName,headerValue
RemoveRequestHeader 移除请求中的一个请求头 RemoveRequestHeader=headerName
AddResponseHeader 给响应结果中添加一个响应头 AddResponseHeader=headerName,headerValue
RemoveResponseHeader 从响应结果中移除一个响应头 RemoveResponseHeader=headerName
RewritePath 请求路径重写 RewritePath=/red/?(?.*), /${segment}
StripPrefix 去除请求路径中的 N 段前缀 StripPrefix=1,则路径 /a/b 转发时只保留 /b

三、网关登录校验

3.1 网关请求处理流程

要在网关转发之前完成登录校验,需要先理解 Spring Cloud Gateway 的请求处理流程。整个流程包含几个核心组件:

  • HandlerMapping(路由映射器):默认实现是 RoutePredicateHandlerMapping,根据请求找到匹配的路由并存入上下文,然后把请求交给 WebHandler 处理。
  • WebHandler(请求处理器):默认实现是 FilteringWebHandler,它会加载网关中配置的多个过滤器,放入集合并排序,形成过滤器链,然后依次执行。
  • Filter(过滤器):内部可以包含 pre 和 post 两部分逻辑,分别在请求路由到微服务之前和之后执行。所有 Filter 的 pre 逻辑依次顺序执行通过后,请求才会被路由到微服务,否则会被拦截,后续过滤器不再执行。微服务返回结果后,再倒序执行 Filter 的 post 逻辑。
  • NettyRoutingFilter(Netty 路由过滤器):负责将请求转发到微服务,当微服务返回结果后存入上下文。

3.2 自定义过滤器

要在网关做登录校验,需要自定义过滤器。网关过滤器有两种:

  • GatewayFilter:路由过滤器,作用于任意指定的路由;默认不生效,要配置到路由后生效。
  • GlobalFilter:全局过滤器,作用范围是所有路由;声明后自动生效。

两种过滤器的过滤方法签名完全一致:

public interface GlobalFilter {
    Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}
 
public interface GatewayFilter extends ShortcutConfigurable {
    Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}

其中 ServerWebExchange 是请求上下文,包含整个过滤器链内共享数据(如 request、response);GatewayFilterChain 是过滤器链,当前过滤器执行完后调用它的 filter 方法即可放行到下一个过滤器。

GatewayFilter 与 GlobalFilter

3.2.1 自定义 GlobalFilter

自定义 GlobalFilter 比较简单,直接实现 GlobalFilter 接口即可:

@Component
public class MyGlobalFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1.获取请求
        ServerHttpRequest request = exchange.getRequest();
        // 2.过滤器业务处理
        System.out.println("GlobalFilter pre阶段 执行了。");
        // 3.放行
        return chain.filter(exchange)
                .then(Mono.fromRunnable(() -> {
                    System.out.println("post阶段 执行了。");
                }));
    }
 
    @Override
    public int getOrder() {
        // 过滤器执行顺序,值越小,优先级越高
        return 0;
    }
}

3.2.2 自定义 GatewayFilter

自定义 GatewayFilter 不是直接实现 GatewayFilter,而是实现 AbstractGatewayFilterFactory,类名必须以 GatewayFilterFactory 为后缀,方便配置使用:

@Component
public class PrintAnyGatewayFilterFactory extends AbstractGatewayFilterFactory<PrintAnyGatewayFilterFactory.Config> {
 
    // 自定义配置属性,成员变量名称很重要,下面会用到
    @Data
    public static class Config {
        private String a;
        private String b;
        private String c;
    }
 
    // 将 Config 字节码传递给父类,父类负责帮我们读取 yaml 配置
    public PrintAnyGatewayFilterFactory() {
        super(Config.class);
    }
 
    // 将变量名称依次返回,顺序很重要,将来读取参数时需要按顺序获取
    @Override
    public List<String> shortcutFieldOrder() {
        return List.of("a", "b", "c");
    }
 
    @Override
    public GatewayFilter apply(Config config) {
        return new GatewayFilter() {
            @Override
            public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
                // 编写过滤器逻辑
                System.out.println("PrintAny filter 执行了");
                // 放行
                return chain.filter(exchange);
            }
        };
    }
}

在 yaml 中通过类名前缀声明使用:

spring:
  cloud:
    gateway:
      default-filters:
        - PrintAny=1,2,3

3.3 实现登录校验

黑马商城基于 JWT 实现登录校验,相关 JWT 工具原本在 hm-service 模块,可以将其拷贝到 gateway 模块,然后基于 GlobalFilter 实现统一登录校验。

校验逻辑:判断请求路径是否在白名单 → 获取请求头中的 token → 校验并解析 token → 校验通过则放行,否则返回 401。

@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(AuthProperties.class)
public class AuthGlobalFilter implements GlobalFilter, Ordered {
 
    private final JwtTool jwtTool;
    private final AuthProperties authProperties;
    private final AntPathMatcher antPathMatcher = new AntPathMatcher();
 
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1.获取Request
        ServerHttpRequest request = exchange.getRequest();
        // 2.判断是否不需要拦截
        if (isExclude(request.getPath().toString())) {
            return chain.filter(exchange);
        }
        // 3.获取请求头中的token
        String token = null;
        List<String> headers = request.getHeaders().get("authorization");
        if (!CollUtils.isEmpty(headers)) {
            token = headers.get(0);
        }
        // 4.校验并解析token
        Long userId = null;
        try {
            userId = jwtTool.parseToken(token);
        } catch (UnauthorizedException e) {
            ServerHttpResponse response = exchange.getResponse();
            response.setRawStatusCode(401);
            return response.setComplete();
        }
        // 5.如果有效,传递用户信息(详见下一节)
        System.out.println("userId = " + userId);
        // 6.放行
        return chain.filter(exchange);
    }
 
    private boolean isExclude(String antPath) {
        for (String pathPattern : authProperties.getExcludePaths()) {
            if (antPathMatcher.match(pathPattern, antPath)) {
                return true;
            }
        }
        return false;
    }
 
    @Override
    public int getOrder() {
        return 0;
    }
}

3.4 网关传递用户

网关解析出用户身份后,需要把它传给下游微服务,使微服务也能拿到当前登录用户。整体链路是:网关过滤器校验 JWT → 保存用户到请求头 → 微服务 MVC 拦截器读取请求头 → 保存到 ThreadLocal 供业务使用。

网关传递用户的完整链路

3.4.1 网关保存用户到请求头

在登录校验拦截器中,校验成功后修改下游请求:

exchange.mutate() // mutate就是对下游请求做更改
        .request(builder -> builder.header("user-info", userInfo))
        .build();

3.4.2 微服务拦截器获取用户

由于每个微服务都需要获取登录用户,将拦截器定义在 hm-common 模块中,微服务引入依赖即可生效,无需重复编写。在 hm-common 模块下定义 HandlerInterceptor:

在 hm-common 中定义拦截器

public class UserInfoInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 1.获取请求头中的用户信息
        String userInfo = request.getHeader("user-info");
        // 2.判断是否为空
        if (StrUtil.isNotBlank(userInfo)) {
            // 不为空,保存到ThreadLocal
            UserContext.setUser(Long.valueOf(userInfo));
        }
        // 3.放行
        return true;
    }
 
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 移除用户
        UserContext.removeUser();
    }
}

再在 hm-common 中编写 SpringMVC 配置类注册拦截器,并通过 SpringBoot 自动装配让所有微服务都能加载到它:

@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserInfoInterceptor());
    }
}

在 resources/META-INF/spring.factories 中声明:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.hmall.common.config.MyBatisConfig,\
  com.hmall.common.config.MvcConfig

3.5 OpenFeign 传递用户

微服务之间通过 OpenFeign 调用时也需要传递用户信息。OpenFeign 提供了 RequestInterceptor 拦截器接口,所有由 OpenFeign 发起的请求都会先调用拦截器处理请求:

public interface RequestInterceptor {
    void apply(RequestTemplate template);
}

利用 RequestTemplate 提供的方法可以修改请求头。这样每次 OpenFeign 发起请求时都会调用该方法,自动把当前线程中的用户写入请求头,传递给下游微服务:

OpenFeign 传递用户

@Bean
public RequestInterceptor userInfoRequestInterceptor() {
    return new RequestInterceptor() {
        @Override
        public void apply(RequestTemplate template) {
            // 获取登录用户
            Long userId = UserContext.getUser();
            if (userId == null) {
                return;
            }
            // 如果不为空则放入请求头中,传递给下游微服务
            template.header("user-info", userId.toString());
        }
    };
}

至此,整个微服务登录解决方案就形成了完整闭环:浏览器携带 JWT → 网关 GlobalFilter 校验并保存用户到请求头 → 微服务 HandlerInterceptor 读取并写入 ThreadLocal → OpenFeign RequestInterceptor 把 ThreadLocal 中的用户写入下游请求头。

四、配置管理

4.1 为什么需要配置管理

微服务项目普遍存在三个配置痛点:

  • 微服务重复配置过多,例如数据库、日志、Swagger 等配置在 5 个服务中几乎一样,维护成本高。
  • 业务配置经常变动,每次修改都要重启服务。
  • 网关路由配置写死,路由变更需要重启网关,影响整个后端系统。

Nacos 提供的配置管理服务正好覆盖这三个场景:配置共享、配置热更新、动态路由。

4.2 配置共享

4.2.1 添加共享配置到 Nacos

把一些通用配置抽取到 Nacos 中,包括 Jdbc、MybatisPlus、日志、Swagger、OpenFeign 等配置。例如 shared-jdbc.yaml、shared-log.yaml、shared-swagger.yaml。

添加共享配置到 Nacos

4.2.2 拉取共享配置

SpringCloud 在初始化上下文时会先读取 bootstrap.yaml(或 bootstrap.properties),把 Nacos 地址配置到 bootstrap.yaml 中,就可以在项目引导阶段拉取 Nacos 中的配置。

拉取共享配置流程

以 cart-service 为例:

  1. 引入依赖:
<!--nacos配置管理-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
  1. 新建 bootstrap.yaml:
spring:
  application:
    name: cart-service # 服务名称
  profiles:
    active: dev
  cloud:
    nacos:
      server-addr: 192.168.150.101:8848 # nacos地址
      config:
        file-extension: yaml # 文件后缀名
        shared-configs: # 共享配置
          - dataId: shared-jdbc.yaml   # 共享jdbc配置
          - dataId: shared-log.yaml    # 共享日志配置
          - dataId: shared-swagger.yaml # 共享swagger配置
  1. 简化 application.yaml,最终只保留本服务独有的内容(端口、Feign 配置、Swagger 信息、数据库名等)。

4.3 配置热更新

配置热更新是指修改 Nacos 中的配置文件时,微服务无需重启即可让配置生效。实现前提条件有两个:

  • Nacos 中要有一个与微服务名有关的配置文件,dataId 格式为 ${spring.application.name}-${spring.profiles.active}.${file-extension}。
  • 微服务中要以特定方式读取需要热更新的配置属性。

例如购物车数量上限的配置,先在 Nacos 中添加 cart-service-dev.yaml,写入:

hm:
  cart:
    maxItems: 10 # 购物车商品数量上限

在微服务中读取该属性有两种方式。方式一:使用 @ConfigurationProperties 注解类,自动绑定配置前缀:

@Data
@ConfigurationProperties(prefix = "hm.cart")
public class CartProperties {
    private int maxItems;
}

方式二:使用 @Value 注解字段,并配合 @RefreshScope 注解类:

@Data
@RefreshScope
public class CartProperties {
    @Value("${hm.cart.maxItems}")
    private int maxItems;
}

业务中直接注入 CartProperties 即可使用,Nacos 修改后无需重启服务就能生效。

4.4 动态路由

动态路由的目标是把网关的路由配置保存到 Nacos,当 Nacos 中的路由配置变更时推送最新配置到网关,实时更新网关中的路由信息。要完成两件事:

  1. 监听 Nacos 配置变更的消息。
  2. 当配置变更时,将最新的路由信息更新到网关路由表。

4.4.1 监听 Nacos 配置变更

通过 NacosConfigManager 拿到 ConfigService,调用 getConfigAndSignListener 在首次拉取配置的同时注册监听器:

private final NacosConfigManager nacosConfigManager;
 
public void initRouteConfigListener() throws NacosException {
    // 1.注册监听器并首次拉取配置
    String configInfo = nacosConfigManager.getConfigService()
            .getConfigAndSignListener(dataId, group, 5000, new Listener() {
                @Override
                public Executor getExecutor() {
                    return null;
                }
 
                @Override
                public void receiveConfigInfo(String configInfo) {
                    // 监听到配置变更,更新一次配置
                    updateConfigInfo(configInfo);
                }
            });
    // 2.首次启动时,更新一次配置
    updateConfigInfo(configInfo);
}

4.4.2 更新路由表

监听到路由信息后,利用 RouteDefinitionWriter 来更新路由表。该接口提供两个方法:save 用于添加或覆盖路由(如果路由 id 重复则覆盖旧路由),delete 用于根据路由 id 删除某个路由。

public interface RouteDefinitionWriter {
    /** 更新路由到路由表,如果路由id重复,则会覆盖旧的路由 */
    Mono<Void> save(Mono<RouteDefinition> route);
 
    /** 根据路由id删除某个路由 */
    Mono<Void> delete(Mono<String> routeId);
}

更新时一般先清空旧路由再写入新路由,避免残留。

4.4.3 路由配置语法

为了方便解析从 Nacos 读取到的路由配置,推荐使用 JSON 格式的路由配置,例如:

[
    {
        "id": "item",
        "uri": "lb://item-service",
        "predicates": [{
            "name": "Path",
            "args": {
                "_genkey_0": "/items/**",
                "_genkey_1": "/search/**"
            }
        }],
        "filters": []
    },
    {
        "id": "cart",
        "uri": "lb://cart-service",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0": "/carts/**"}
        }],
        "filters": []
    }
]

这样将来新增或调整微服务路由时,只需在 Nacos 控制台修改 JSON 配置即可,无需重启网关。

总结

本文围绕网关与配置管理完成了三件事:

  • 网关路由:使用 Spring Cloud Gateway 作为统一入口,通过 routes 配置实现基于路径的路由转发,背后结合 Nacos 完成服务发现与负载均衡。
  • 网关登录校验:通过自定义 GlobalFilter 完成 JWT 校验,把用户信息写入请求头传递给下游微服务;微服务通过 HandlerInterceptor 读取请求头并保存到 ThreadLocal;OpenFeign 再通过 RequestInterceptor 把用户信息传递给下游调用。
  • 配置管理:利用 Nacos 配置管理服务完成配置共享、配置热更新和动态路由,既减少了微服务的重复配置,又解决了频繁重启服务的问题,还支持网关路由的实时变更。