Skip to content
DAILY QUOTE

“ ”

上一章已经把黑马商城拆成了多个微服务:

text
用户服务user-service
商品服务item-service
购物车服务cart-service
交易服务trade-service
支付服务pay-service

拆分之后会出现几个新问题:

text
1. 前端要访问多个服务地址,入口混乱
2. 每个服务都做登录校验,代码重复
3. 网关校验完登录后,用户信息怎么传给微服务
4. 微服务之间调用时,用户信息怎么继续传递
5. 每个服务配置重复,修改配置还要重启
6. 网关路由写死在配置文件里,不方便动态修改

所以这一章主要解决三个问题:

问题解决方案
前端入口混乱Spring Cloud Gateway统一入口
登录校验重复网关GlobalFilter统一鉴权
配置重复、不能热更新Nacos配置管理
网关路由不能动态改Nacos + 动态路由监听

1.网关路由

1.1.认识网关

微服务拆分后,前端如果直接访问各个微服务,就需要维护多个服务地址。例如商品服务、购物车服务、用户服务、订单服务、支付服务可能都有不同的端口。这样不仅前端调用麻烦,也不利于统一做登录校验、安全控制和请求转发。

网关的作用就是作为整个微服务系统的统一入口。

请求流程可以理解为:

text
前端请求 → 网关 → 根据路由规则转发到具体微服务

网关主要作用:

  1. 统一请求入口:前端只需要访问网关地址。
  2. 请求路由:根据请求路径转发到对应微服务。
  3. 负载均衡:配合Nacos,从多个服务实例中选择一个访问。
  4. 统一鉴权:在请求进入微服务前完成登录校验。
  5. 安全控制:可以做权限校验、黑白名单、限流等处理。

在SpringCloud当中,提供了两种网关实现方案:

  • Netflix Zuul:早期实现,目前已经淘汰
  • SpringCloudGateway:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强

1.2.快速入门

网关本身也是一个独立的微服务,因此也需要创建一个模块开发功能。大致步骤如下:

  • 创建网关微服务
  • 引入SpringCloudGateway、NacosDiscovery依赖
  • 编写启动类
  • 配置网关路由

1.2.1.创建项目

在hmall创建新的module,作为网关微服务:

1.2.2.引入依赖

hm-gateway模块的pom.xml文件中引入依赖:

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>hmall</artifactId>
        <groupId>com.heima</groupId>
        <version>1.0.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>hm-gateway</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>
    <dependencies>
        <!--common-->
        <dependency>
            <groupId>com.heima</groupId>
            <artifactId>hm-common</artifactId>
            <version>1.0.0</version>
        </dependency>
        <!--网关-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <!--nacos discovery-->
        <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>
    </dependencies>
    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

1.2.3.启动类

hm-gateway模块的com.hmall.gateway包下新建一个启动类:

代码如下:

java
package com.hmall.gateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

1.2.4.配置路由

接下来,在hm-gateway模块的resources目录新建一个application.yaml文件,内容如下:

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/**,/search/** # 这里是以请求路径作为判断规则
        - id: cart
          uri: lb://cart-service
          predicates:
            - Path=/carts/**
        - id: user
          uri: lb://user-service
          predicates:
            - Path=/users/**,/addresses/**
        - id: trade
          uri: lb://trade-service
          predicates:
            - Path=/orders/**
        - id: pay
          uri: lb://pay-service
          predicates:
            - Path=/pay-orders/**

1.2.5.测试

启动GatewayApplication,以http://localhost:8080拼接微服务接口路径来测试。例如:http://localhost:8080/items/page?pageNo=1&pageSize=1

启动UserApplication、CartApplilcation,然后打开前端页面,相关功能都可以正常访问。

1.3.路由过滤

路由规则语法定义如下:

yaml
spring:
  cloud:
    gateway:
      routes:
        - id: item
          uri: lb://item-service
          predicates:
            - Path=/items/**,/search/**

其中routes对应类型如下:

是一个集合,也就是说可以定义很多路由规则。集合中的RouteDefinition就是具体的路由规则定义,其中常见的属性如下:

四个属性含义如下:

  • id:路由的唯一标示
  • predicates:路由断言,其实就是匹配条件
  • filters:路由过滤条件,后面讲
  • uri:路由目标地址,lb://代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问。

这里我们重点关注predicates,也就是路由断言。SpringCloudGateway中支持的断言类型有很多:

名称说明示例
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权重处理

2.网关登陆校验

2.1.鉴权思路分析

单体项目中,登录校验只需要写一套,因为所有业务都在同一个服务里。

微服务拆分后,如果每个微服务都自己校验登录状态,会有几个问题:

  1. 每个服务都要保存JWT秘钥,不安全。
  2. 每个服务都要重复写登录校验代码,维护成本高。
  3. 权限逻辑分散,不方便统一管理。
  4. 修改鉴权逻辑时,需要改多个服务。

更合理的做法是:

text
把登录校验放到网关统一完成。

流程:

text
前端请求 → 网关校验JWT → 校验通过 → 转发到微服务
前端请求 → 网关校验JWT → 校验失败 → 返回401

此时,登录校验的流程如图:

这样做的好处:

  • 业务微服务不用重复写登录校验。
  • JWT秘钥只需要放在用户服务和网关。
  • 鉴权逻辑集中,维护方便。
  • 请求进入业务服务前就已经完成身份校验。

但这样会带来三个问题:

  1. 网关怎么在请求转发前做登录校验?
  2. 网关解析出userId后,怎么传给下游微服务?
  3. 微服务之间通过Feign调用时,怎么继续传递用户信息?

后面分别通过Gateway过滤器、请求头、SpringMVC拦截器、Feign拦截器解决。

2.2.网关过滤器

登录校验必须在请求转发到微服务之前做,否则就失去了意义。而网关的请求转发是Gateway内部代码实现的,要想在请求转发之前做登录校验,就必须了解Gateway内部工作的基本原理。

核心流程为:

  1. 客户端请求进入网关后由HandlerMapping对请求做判断,找到与请求匹配的路由,交给WebHandler处理。
  2. WebHandler加载路由下的过滤器链,然后按顺序逐一执行过滤器。
  3. 当所有Filter的pre逻辑依次顺序执行通过后,NettyRoutingFilter转发请求到微服务。
  4. 微服务返回响应后,再倒序执行Filter中的post逻辑。
  5. 最终把响应结果返回。

过滤器分为两部分逻辑:

  • pre:请求转发到微服务之前执行。
  • post:微服务响应返回之后执行。

登录校验必须在请求转发之前完成,所以要在pre阶段做。

Gateway中常见过滤器有两类:

类型作用范围说明
GatewayFilter指定路由配置在哪个路由下,就对哪个路由生效
GlobalFilter全局所有路由对所有请求生效,适合统一鉴权

登录校验适合用:

text
GlobalFilter

因为所有请求都需要经过统一鉴权逻辑。

过滤器核心方法:

java
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);

参数说明:

  • exchange:当前请求上下文,里面有request、response等信息。
  • chain:过滤器链,调用chain.filter(exchange)表示放行。
  • 如果不调用chain.filter(exchange),而是直接设置响应状态码并结束,就表示拦截。

注意:过滤器链之外还有一种过滤器,HttpHeadersFilter,用来处理传递到下游微服务的请求头。例如org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter可以传递代理请求原本的host头到下游微服务。

其实GatewayFilterGlobalFilter这两种过滤器的方法签名完全一致:

java
/**
 * 处理请求并将其传递给下一个过滤器
 * @param exchange 当前请求的上下文,其中包含request、response等各种数据
 * @param chain 过滤器链,基于它向下传递请求
 * @return 根据返回值标记当前请求是否被完成或拦截,chain.filter(exchange)就放行了。
 */
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);

FilteringWebHandler在处理请求时,会将GlobalFilter装饰为GatewayFilter,然后放到同一个过滤器链中,排序以后依次执行。

Gateway中内置了很多的GatewayFilter,详情可以参考官方文档: Spring Cloud Gateway

Gateway内置的GatewayFilter过滤器使用起来非常简单,无需编码,只要在yaml文件中简单配置即可。而且其作用范围也很灵活,配置在哪个Route下,就作用于哪个Route.

例如,有一个过滤器叫做AddRequestHeaderGatewayFilterFacotry,顾明思议,就是添加请求头的过滤器,可以给请求添加一个请求头并传递到下游微服务。

使用只需要在application.yaml中这样配置:

yaml
spring:
  cloud:
    gateway:
      routes:
      - id: test_route
        uri: lb://test-service
        predicates:
          -Path=/test/**
        filters:
          - AddRequestHeader=key, value # 逗号之前是请求头的key,逗号之后是value

如果想要让过滤器作用于所有的路由,则可以这样配置:

yaml
spring:
  cloud:
    gateway:
      default-filters: # default-filters下的过滤器可以作用于所有路由
        - AddRequestHeader=key, value
      routes:
      - id: test_route
        uri: lb://test-service
        predicates:
          -Path=/test/**

2.3.自定义过滤器

无论是GatewayFilter还是GlobalFilter都支持自定义,只不过编码方式、使用方式略有差别。

2.3.1.自定义GatewayFilter

自定义GatewayFilter一般不是直接实现GatewayFilter,而是继承:

text
AbstractGatewayFilterFactory

示例:

java
@Component
public class PrintAnyGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
    @Override
    public GatewayFilter apply(Object config) {
        return new GatewayFilter() {
            @Override
            public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
                System.out.println("过滤器执行了");
                return chain.filter(exchange);
            }
        };
    }
}
  • apply():生产过滤器
  • filter():检查当前请求
  • chain.filter():把请求交给下一环节

注意:类名必须以GatewayFilterFactory结尾。

在配置中使用时,只写前缀:

yaml
spring:
  cloud:
    gateway:
      default-filters:
        - PrintAny

如果需要传参,可以定义内部配置类Config,再重写shortcutFieldOrder()

2.3.2.自定义GloableFilter

自定义GlobalFilter则简单很多,直接实现GlobalFilter即可,而且也无法设置动态参数:

java
@Component
public class PrintAnyGlobalFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 编写过滤器逻辑
        System.out.println("未登录,无法访问");
        // 放行
        // return chain.filter(exchange);

        // 拦截
        ServerHttpResponse response = exchange.getResponse();
        response.setRawStatusCode(401);
        return response.setComplete();
    }

    @Override
    public int getOrder() {
        // 过滤器执行顺序,值越小,优先级越高
        return 0;
    }
}

2.4.登录校验

接下来利用自定义GloableFilter完成登录校验

2.4.1.JWT工具

登录校验需要用到JWT,而且JWT的加密需要秘钥和加密工具。这些在hm-service中已经有了,我们直接拷贝过来:

具体作用如下:

  • AuthProperties:配置登录校验需要拦截的路径,因为不是所有的路径都需要登录才能访问
  • JwtProperties:定义与JWT工具有关的属性,比如秘钥文件位置
  • SecurityConfig:工具的自动装配
  • JwtTool:JWT工具,其中包含了校验和解析token的功能
  • hmall.jks:秘钥文件

其中AuthPropertiesJwtProperties所需的属性要在application.yaml中配置:

yaml
hm:
  jwt:
    location: classpath:hmall.jks # 秘钥地址
    alias: hmall # 秘钥别名
    password: hmall123 # 秘钥文件密码
    tokenTTL: 30m # 登录有效期
  auth:
    excludePaths: # 无需登录校验的路径
      - /search/**
      - /users/login
      - /items/**

2.4.2.登录校验过滤器

接下来,定义一个登录校验的过滤器:

登录校验过滤器使用GlobalFilter实现。

核心流程:

text
1. 获取请求路径
2. 判断是否在白名单
3. 如果在白名单,直接放行
4. 如果不在白名单,读取authorization请求头
5. 使用JwtTool解析token
6. 解析失败,返回401
7. 解析成功,得到userId
8. 把userId传给下游微服务
9. 放行请求

代码如下:

java
package com.hmall.gateway.filter;

import com.hmall.common.exception.UnauthorizedException;
import com.hmall.common.utils.CollUtils;
import com.hmall.gateway.config.AuthProperties;
import com.hmall.gateway.util.JwtTool;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;

@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();
        }

        // TODO 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;
    }
}

访问其他路径则,未登录状态下请求会被拦截,并且返回401状态码:

2.5.微服务获取用户

网关校验token后可以得到userId,但下游微服务还需要知道当前用户是谁。

由于网关转发到微服务本质上还是HTTP请求,所以可以通过请求头传递用户信息。

流程图如下:

接下来我们要做的事情有:

  • 改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务
  • 编写微服务拦截器,拦截请求获取用户信息,保存到ThreadLocal后放行

2.5.1.保存用户到请求头

在网关过滤器中,解析出userId后,把它写入请求头:

注意:Gateway中的request是不可变对象,不能直接修改,需要通过mutate()创建新请求。

2.5.2.拦截器获取用户

在hm-common中已经有了一个用户保存登录用户的ThreadLocal工具:

其中已经提供了保存和获取用户的方法:

接下来只需要编写拦截器,获取用户信息并保存到UserContext,然后放行即可。

由于每个微服务都有获取登录用户的需求,因此拦截器我们直接写在hm-common中,并写好自动装配。这样微服务只需要引入hm-common就可以直接具备拦截器功能,无需重复编写。

我们在hm-common模块下定义一个拦截器:

java
package com.hmall.common.interceptor;

import cn.hutool.core.util.StrUtil;
import com.hmall.common.utils.UserContext;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class UserInfoInterceptor implements HandlerInterceptor {
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //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) throws Exception {
        // 移除用户
        UserContext.removeUser();
    }
}

preHandle()

text
请求进入Controller前执行,用来读取user-info并保存到ThreadLocal。

afterCompletion()

text
请求处理完成后执行,用来清理ThreadLocal。

为什么要清理ThreadLocal? 因为Tomcat线程池中的线程会被复用。如果不清理,可能导致上一个请求的用户信息残留到下一个请求中,造成用户信息混乱。

接着在hm-common模块下编写SpringMVC的配置类,配置登录拦截器:

代码如下:

java
package com.hmall.common.config;

import com.hmall.common.interceptor.UserInfoInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

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

需要注意的是,这个配置类默认是不生效的,因为它所在包是com.hmall.common.config,与其他微服务的扫描包不一致,无法被扫描到,因此无法生效。

基于SpringBoot的自动装配原理,将其添加到resources目录下的META-INF/spring.factories文件中:

内容如下:

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

这样其他微服务只要引入hm-common,就能自动拥有用户信息拦截器。

2.5.3.恢复购物车代码

之前我们无法获取登录用户,所以把购物车服务登录用户写死了,现在需要恢复到原来的样子,找到cart-service模块下的com.hmall.cart.service.impl.CartServiceImpl修改其中的queryMyCarts方法:

2.6.OpenFeign传递用户

前端请求经过网关时,网关可以把用户信息传给第一个微服务。

但微服务之间互相调用时,不会再经过网关。

例如下单业务:

交易服务调用购物车服务时,如果不继续传递用户信息,购物车服务就不知道当前用户是谁。

因为微服务获取用户信息是从请求头user-info中读取的,所以Feign发起请求时也需要带上这个请求头。

OpenFeign提供了请求拦截器:

text
RequestInterceptor

我们只需要实现这个接口,然后实现apply方法,利用RequestTemplate类来添加请求头,将用户信息保存到请求头中。这样以来,每次OpenFeign发起请求的时候都会调用该方法,传递用户信息。

由于FeignClient全部都是在hm-api模块,因此我们在hm-api模块的com.hmall.api.config.DefaultFeignConfig中编写这个拦截器,在其中添加一个Bean:

java
@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());
        }
    };
}

执行逻辑:

text
Feign发起请求前

RequestInterceptor执行

从UserContext获取当前userId

把userId写入请求头user-info

下游微服务拦截器读取user-info

3.配置管理

到目前为止,已经解决了:

  • 微服务远程调用
  • 服务注册与发现
  • 网关统一入口
  • 网关登录校验
  • 用户信息传递

但还有配置相关问题:

  1. 每个微服务都有重复配置。
  2. 配置写在本地文件中,修改后要重新打包或重启服务。
  3. 网关路由写死在配置文件中,变更不方便。
  4. 业务参数写死在代码中,不方便调整。

Nacos除了可以做注册中心,还可以做配置管理。

Nacos配置管理可以实现:

  • 共享配置
  • 配置集中管理
  • 配置热更新
  • 动态路由配置

微服务共享的配置可以统一交给Nacos保存和管理,在Nacos控制台修改配置后,Nacos会将配置变更推送给相关的微服务,并且无需重启即可生效,实现配置热更新。

网关的路由同样是配置,因此同样可以基于这个功能实现动态路由功能,无需重启网关即可修改路由配置。

3.1.配置共享

把微服务共享的配置抽取到Nacos中统一管理,这样就不需要每个微服务都重复配置了,分为两步:

  • 在Nacos中添加共享配置
  • 微服务拉取配置

3.1.1.添加共享配置

以cart-service为例,我们看看有哪些配置是重复的,可以抽取的:

首先是jdbc相关配置:

然后是日志配置:

然后是swagger以及OpenFeign的配置:

在noacs控制台添加这些配置。

首先是jdbc相关配置,在配置管理->配置列表中点+号新建一个配置:

在弹出的表单中填写信息:

详细配置如下:

yaml
datasource:
  url: jdbc:mysql://${hm.db.host:127.0.0.1}:${hm.db.port:13307}/${hm.db.database}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
  driver-class-name: com.mysql.cj.jdbc.Driver
  username: ${hm.db.un:root}
  password: ${hm.db.pw:123}
  cloud:
    nacos:
      server-addr: 127.0.0.1:8848
mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
  global-config:
    db-config:
      update-strategy: not_null
      id-type: auto

重点看这种写法:

text
${hm.db.host:127.0.0.1}

含义:

text
优先读取hm.db.host,如果没有配置,就使用默认值127.0.0.1。

每个微服务只需要在本地配置自己的数据库名:

yaml
hm:
  db:
    database: hm-cart

然后是统一的日志配置,命名为shared-log.yaml,配置内容如下:

yaml
logging:
  level:
    com.hmall: debug
  pattern:
    dateformat: HH:mm:ss:SSS
  file:
    path: "logs/${spring.application.name}"

日志文件路径会根据服务名变化:

text
logs/cart-service
logs/item-service
logs/user-service

然后是统一的swagger配置,命名为shared-swagger.yaml,配置内容如下:

yaml
knife4j:
  enable: true
  openapi:
    title: ${hm.swagger.title:黑马商城接口文档}
    description: ${hm.swagger.description:黑马商城接口文档}
    email: ${hm.swagger.email:zhanghuyi@itcast.cn}
    concat: ${hm.swagger.concat:虎哥}
    url: https://www.itcast.cn
    version: v1.0.0
    group:
      default:
        group-name: default
        api-rule: package
        api-rule-resources:
          - ${hm.swagger.package}

每个服务本地只需要配置自己的文档标题和扫描包:

yaml
hm:
  swagger:
    title: 购物车服务接口文档
    package: com.hmall.cart.controller

3.1.2.拉取共享配置

接下来要在为服务拉取共享配置,将拉取的共享配置与本地的application.yaml合并,完成项目上下文的初始化。

Nacos配置是在Spring Cloud引导阶段加载的,而application.yaml是Spring Boot阶段才读取的。

如果Nacos地址写在application.yaml中,项目在引导阶段还不知道Nacos地址,也就没法去Nacos拉配置。

所以需要把Nacos配置中心地址写在更早加载的:

text
bootstrap.yaml

微服务要从Nacos拉取配置,需要引入两个依赖,在cart-service模块引入依赖:

xml
  <!--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>

在cart-service中的resources目录新建一个bootstrap.yaml文件:

内容如下:

yaml
spring:
  application:
    name: cart-service # 服务名称
  profiles:
    active: dev
  cloud:
    nacos:
      server-addr: 127.0.0.1 # nacos地址
      config:
        file-extension: yaml # 文件后缀名
        shared-configs: # 共享配置
          - dataId: shared-jdbc.yaml # 共享mybatis配置
          - dataId: shared-log.yaml # 共享日志配置
          - dataId: shared-swagger.yaml # 共享日志配置

由于一些配置挪到了bootstrap.yaml,因此application.yaml需要修改为:

yaml
server:
  port: 8082
feign:
  okhttp:
    enabled: true # 开启OKHttp连接池支持
hm:
  swagger:
    title: 购物车服务接口文档
    package: com.hmall.cart.controller
  db:
    database: hm-cart

3.2.配置热更新

业务中有些配置可能经常调整,比如购物车最大商品数量。

如果写死在代码中:

java
if (count >= 10) {
    throw new BizIllegalException("用户购物车不能超过10");
}

后期要改成20,就必须改代码、重新打包、重启服务。

更好的方式是把它放到Nacos配置中,实现热更新。

配置热更新指的是:

text
修改Nacos中的配置后,服务不用重启,新配置自动生效。

分为两步:

  • 在Nacos中添加配置
  • 在微服务读取配置

3.2.1.添加配置到Nacos

首先,我们在nacos中添加一个配置文件,将购物车的上限数量添加到配置中:

含义:

text
购物车商品数量上限为1。

注意文件的dataId格式:

text
[服务名]-[spring.active.profile].[后缀名]

文件名称由三部分组成:

  • 服务名:我们是购物车服务,所以是cart-service
  • spring.active.profile:就是spring boot中的spring.active.profile,可以省略,则所有profile共享该配置
  • 后缀名:例如yaml

这里我们直接使用cart-service.yaml这个名称,则不管是dev还是local环境都可以共享该配置。

配置内容如下:

yaml
hm:
  cart:
    maxAmount: 1 # 购物车商品数量上限

提交配置,在控制台能看到新添加的配置:

3.2.2.配置热更新

cart-service中定义配置读取类:

代码如下:

java
package com.hmall.cart.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "hm.cart")
public class CartProperties {
    private Integer maxAmount;
}

接着,在业务中使用该属性加载类:

重启之后,向购物车添加多个商品进行测试:

在nacos控制台,将购物车上限配置为5:

无需重启,再次测试购物车功能:

可以看到,无需重启即可修改生效。

3.3.动态路由

网关路由如果写在application.yaml中,会有一个问题:

text
路由变更后必须重启网关。

但网关是所有请求的入口,重启网关影响比较大。

所以希望把路由配置放到Nacos中,实现:

text
修改Nacos路由配置后,网关不用重启,路由自动更新。

不过Gateway路由比较特殊。

Gateway启动时会把路由加载到内存路由表中,并缓存起来。普通Nacos配置热更新不能直接刷新Gateway路由。

所以要自己做两件事:

  1. 监听Nacos中的路由配置变化。
  2. 使用Gateway提供的接口手动更新路由表。

3.3.1.监听Nacos配置变更

在Nacos官网中给出了手动监听Nacos配置变更的SDK:

https://nacos.io/zh-cn/docs/sdk.html

如果希望Nacos推送配置变更,可以使用Nacos动态监听配置接口来实现。

java
public void addListener(String dataId, String group, Listener listener)

请求参数说明:

参数名参数类型描述
dataIdstring配置ID,保证全局唯一性,只允许英文字符和4种特殊字符("."、":"、"-"、"_")。不超过256字节。
groupstring配置分组,一般是默认的DEFAULT_GROUP。
listenerListener监听器,配置变更进入监听器的回调函数。
如果直接使用Nacos原生API,我们需要手动准备Nacos地址,并通过NacosFactory.createConfigService()创建ConfigService对象。

拿到ConfigService后,才能读取指定配置并注册配置监听器。

代码:

java
String serverAddr = "{serverAddr}";
String dataId = "{dataId}";
String group = "{group}";
// 1.创建ConfigService,连接Nacos
Properties properties = new Properties();
properties.put("serverAddr", serverAddr);
ConfigService configService = NacosFactory.createConfigService(properties);
// 2.读取配置
String content = configService.getConfig(dataId, group, 5000);
// 3.添加配置监听器
configService.addListener(dataId, group, new Listener() {
        @Override
        public void receiveConfigInfo(String configInfo) {
        // 配置变更的通知处理
                System.out.println("recieve1:" + configInfo);
        }
        @Override
        public Executor getExecutor() {
                return null;
        }
});

但当前项目已经引入spring-cloud-starter-alibaba-nacos-config。Spring Boot启动时,NacosConfigAutoConfiguration会根据配置文件中的Nacos地址自动创建ConfigService并交给Spring容器管理。

因此,在当前项目中不需要手动创建ConfigService,只需通过依赖注入获取它。然后调用getConfig()读取配置,调用addListener()监听配置变化即可。

NacosConfigManager中是负责管理Nacos的ConfigService的,具体代码如下:

第二步,编写监听器。虽然官方提供的SDK是ConfigService中的addListener,不过项目第一次启动时不仅仅需要添加监听器,也需要读取配置,因此建议使用的API是这个:

java
String getConfigAndSignListener(
    String dataId, // 配置文件id
    String group, // 配置组,走默认
    long timeoutMs, // 读取配置的超时时间
    Listener listener // 监听器
) throws NacosException;

3.3.2.更新路由

更新路由要用到org.springframework.cloud.gateway.route.RouteDefinitionWriter这个接口:

java
package org.springframework.cloud.gateway.route;

import reactor.core.publisher.Mono;

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

}

路由对象是:

text
RouteDefinition

里面包含:

  • id:路由id
  • uri:转发目标
  • predicates:断言
  • filters:过滤器

Nacos中保存的JSON需要能转换成RouteDefinition

将来我们保存到Nacos的配置也要符合这个对象结构,将来我们以JSON来保存,格式如下:

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

等价于

yaml
spring:
  cloud:
    gateway:
      routes:
        - id: item
          uri: lb://item-service
          predicates:
            - Path=/items/**,/search/**

3.3.3.实现动态路由

首先在网关gateway引入依赖

xml
<!--统一配置管理-->
<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>

然后在网关gatewayresources目录创建bootstrap.yaml文件,内容如下:

yaml
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: 127.0.0.1
      config:
        file-extension: yaml
        shared-configs:
          - dataId: shared-log.yaml # 共享日志配置

接着,修改gatewayresources目录下的application.yml,把之前的路由移除,最终内容如下:

yaml
server:
  port: 8080
hm:
  jwt:
    location: classpath:hmall.jks # 秘钥地址
    alias: hmall # 秘钥别名
    password: hmall123 # 秘钥文件密码
    tokenTTL: 30m # 登录有效期
  auth:
    excludePaths: # 无需登录校验的路径
      - /search/**
      - /users/login
      - /items/**

然后,在gateway中定义配置监听器:

代码如下:

java
package com.hmall.gateway.route;

import cn.hutool.json.JSONUtil;
import com.alibaba.cloud.nacos.NacosConfigManager;
import com.alibaba.nacos.api.config.listener.Listener;
import com.alibaba.nacos.api.exception.NacosException;
import com.hmall.common.utils.CollUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.gateway.route.RouteDefinitionWriter;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import javax.annotation.PostConstruct;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;

@Slf4j
@Component
@RequiredArgsConstructor
public class DynamicRouteLoader {

    private final RouteDefinitionWriter writer;
    private final NacosConfigManager nacosConfigManager;

    // 路由配置文件的id和分组
    private final String dataId = "gateway-routes.json";
    private final String group = "DEFAULT_GROUP";
    // 保存更新过的路由id
    private final Set<String> routeIds = new HashSet<>();

    @PostConstruct
    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);
    }

    private void updateConfigInfo(String configInfo) {
        log.debug("监听到路由配置变更,{}", configInfo);
        // 1.反序列化
        List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class);
        // 2.更新前先清空旧路由
        // 2.1.清除旧路由
        for (String routeId : routeIds) {
            writer.delete(Mono.just(routeId)).subscribe();
        }
        routeIds.clear();
        // 2.2.判断是否有新的路由要更新
        if (CollUtils.isEmpty(routeDefinitions)) {
            // 无新路由配置,直接结束
            return;
        }
        // 3.更新路由
        routeDefinitions.forEach(routeDefinition -> {
            // 3.1.更新路由
            writer.save(Mono.just(routeDefinition)).subscribe();
            // 3.2.记录路由id,方便将来删除
            routeIds.add(routeDefinition.getId());
        });
    }
}

接下来在Nacos控制台添加路由,路由文件名为gateway-routes.json,类型为json

内容如下:

json
[
    {
        "id": "item",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0":"/items/**", "_genkey_1":"/search/**"}
        }],
        "filters": [],
        "uri": "lb://item-service"
    },
    {
        "id": "cart",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0":"/carts/**"}
        }],
        "filters": [],
        "uri": "lb://cart-service"
    },
    {
        "id": "user",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0":"/users/**", "_genkey_1":"/addresses/**"}
        }],
        "filters": [],
        "uri": "lb://user-service"
    },
    {
        "id": "trade",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0":"/orders/**"}
        }],
        "filters": [],
        "uri": "lb://trade-service"
    },
    {
        "id": "pay",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0":"/pay-orders/**"}
        }],
        "filters": [],
        "uri": "lb://pay-service"
    }
]

重启网关,访问http://localhost:8080/items?ids=100000006163 将nacos中的路由删除接着访问发现无法访问:

路由成功。