SpringCould —— 网关详解

一、前言

在讲网关之前,我们需要先自行拆分黑马商城的其他模块,比如用户微服务、交易微服务、支付微服务。

然后就会发现一个问题,我们在前面的确是可以使用不同端口号对各个微服务进行单一访问的,并且可以用测试文档去测试,这个是没有问题的,但是我们在实际使用时发现,我们是不能通过nginx进行前后端联调的,这是因为nginx的配置只会将前端请求转发到8080,而8080端口并不是我们微服务的端口,所以当我们关闭单体架构项目(单体架构的端口号是8080)时,是肯定无法访问项目的,同时还有一个问题,就是我们无法统一对所有微服务进行管理,我们缺少了一个可以自动转发请求的模块,这个模块就是网关

二、网关路由

1.快速入门

首先要明确一点,**网关是怎么知道每个微服务的存在的呢?**联想到之前我们也有一个组件可以统一管理端口,就是Nacos,所以网关是从Nacos中拿取微服务的信息(包括微服务注册名)。

那什么是网关路由呢?

网关是所有微服务的路由的集中管理中心,它掌管着所有微服务的路由,当我们的请求从浏览器发送给网关,网关会首先识别这是哪个微服务的路由,然后再把这个请求转发过去,最终交由微服务进行处理,当然,处理的结果(响应)也是需要网关来传回给浏览器的。

一下就是网关的配置文件,首先需要配置网关的端口号,然后要配置Nacos,

最后配置网关路由:

id是可以自己随便取的。

uri是指的对应微服务注册名,注册名前面的lb是指的负载均衡。

predicates断言中配置微服务的路由,也就是拦截这些请求,然后转发给对应配置的微服务。

XML 复制代码
server:
  port: 8080
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: 192.168.242.130
    gateway:
      routes:
        - id: item-service
          uri: lb://item-service
          predicates:
            - Path=/items/**,/search/**

        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/users/**,/addresses/**

        - id: trade-service
          uri: lb://trade-service
          predicates:
            - Path=/orders/**

        - id: pay-service
          uri: lb://pay-service
          predicates:
            - Path=/pay-orders/**

        - id: cart-service
          uri: lb://cart-service
          predicates:
            - Path=/carts/**

2.拦截器

网关作为一个管理中心,是可以拿到所有请求信息的,包括请求头请求体等等,并且每次请求都必须通过网关来转发。所以如果我们想统一处理这些请求,在网关中处理是最好的选择。这个可以用于登录校验等具有统一性的处理。

由于业务的不同,我们的拦截器当然需要自己写了,所以这里我们将自己写一个拦截器来尝试模拟登录校验,这是一个全局性的拦截,所以我们实现的接口是GlobalFilter,这是SpringCould提供的接口。

除此之外,我们的网关全局拦截器还需要实现Ordered接口,这个接口是规定拦截器优先级的,网关拦截器的优先级是按照Int的大小进行排序的,优先级最低的是转发请求,所以我们登录校验必须要在转发请求之前校验,自然的,我们的优先级需要比它高,这里我们设置为0即可。

java 复制代码
@Component
public class MyGlobalFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        //TODO 模拟登录校验逻辑
        ServerHttpRequest request = exchange.getRequest();
        HttpHeaders headers = request.getHeaders();
        System.out.println(headers);
        //放行
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

在filter方法中有两个参数,exchange和chain,exchange中包含了很多方法,主要都是用来处理请求信息的,而chain参数可以用来放行,相当于将这一次的请求从该拦截器中放行,请求将移动到拦截器链中的下一个拦截器里去。

这里我们模拟登录校验,就只是打印了一下请求头(因为token是在请求头中的,所以只要能打印出来,就代表拿到了token)。最后放行。

三、项目登录校验

1.登录校验

首先配置文件中就要加入登录校验部分的配置了,比如jwt的配置,以及配置排除的路由(部分路由是可以不登陆就能访问的,比如查看所有商品的信息)。

XML 复制代码
hm:
  jwt:
    location: classpath:hmall.jks
    alias: hmall
    password: hmall123
    tokenTTL: 30d
  auth:
    excludePaths:
      - /search/**
      - /users/login
      - /items/**
      - /hi

接下来就是写拦截器,在刚刚的上一节中我们提到了,登录校验是一个全局性的处理,所以我们在网关中统一拦截:

java 复制代码
@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    private final AuthProperties authProperties;
    private final JwtTool jwtTool;
    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 (headers != null && !headers.isEmpty()) {
            token = headers.get(0);
        }
        //4.校验并解析token
        Long userId = null;
        try {
            userId = jwtTool.parseToken(token);
        } catch (UnauthorizedException e) {
            //拦截,设置响应状态码
            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return response.setComplete();
        }

        //放行
        return chain.filter(swe);
    }

    private boolean isExclude(String path) {
        for (String excludePath : authProperties.getExcludePaths()) {
            if(antPathMatcher.match(excludePath,path)){
                return true;
            }
        }
        return false;
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

上述代码中,我们需要两个配置类来读取配置文件中的配置信息:

java 复制代码
@Data
@Component
@ConfigurationProperties(prefix = "hm.auth")
public class AuthProperties {
    private List<String> includePaths;
    private List<String> excludePaths;
}
java 复制代码
@Data
@ConfigurationProperties(prefix = "hm.jwt")
public class JwtProperties {
    private Resource location;
    private String password;
    private String alias;
    private Duration tokenTTL = Duration.ofMinutes(10);
}

这样就能在网关中统一登录校验了

2.网关传递用户信息

(1)网关中存入

这又是另一个问题了,以往在单体架构中,我们可以直接使用TreadLocal拿到用户信息,我们在校验时就将用户信息存入线程上下文中了,当我们进行某个需要确认用户信息的功能时,我们直接拿出来就行了,但是微服务这里就不能这样做了,因为线程模型不同了,网关使用的不再是SpringMVC中阻塞式的传统线程模型,传统式的线程模型非常简单,一个线程就是对应一个响应,而响应式就完全不同了,响应式编程的特点是:

1. 一个请求可能由多个线程处理

2. 一个线程可能处理多个请求的片段

所以我们不再能通过线程来存储和拿取信息了**(注意:这里仅指不能在网关,不代表在下游的微服务中不行,网关是响应式的,但下游的微服务不是,所以下游微服务线程唯一,是可以用TreadLocal获取储存信息的)**。

当然,这也是有办法解决的,既然无法通过线程来存储信息,那我就直接修改请求头,在请求头中存储用户信息。

这里就要用到exchange参数的另一个方法了------mutate():

mutate的意思是突变,这里就是修改请求的意思,这里我们添加一个名为"user-info"的请求头,我们往里面存入用户id,这样下游的微服务就能够通过请求头拿出用户信息(id)了。

java 复制代码
        //5.传递用户信息
        String userInfo = userId.toString();
        ServerWebExchange swe = exchange.mutate()
                .request(builder -> builder.header("user-info", userInfo))
                .build();
        //放行
        return chain.filter(swe);

(2)微服务中解析

存入了用户信息,那我们就要从微服务中拿取,由于很多微服务都需要这个用户信息,所以我们干脆直接做一个拦截器UserInfoInterceptor ,在拦截器中解析用户信息然后存入该微服务的TreadLocal。上文提到了,微服务里面是线程唯一的(非响应式的),所以在单个微服务中,都是可以通过TreadLocal来获取上下文的,这里我们使用工具类UserContext存储用户信息:

java 复制代码
public class UserInfoInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.获取登录用户信息
        String userInfo = request.getHeader("user-info");
        //2.判断是否获取了用户,如果有,存入ThreadLocal
        if(StrUtil.isNotBlank(userInfo)){
            UserContext.setUser(Long.valueOf(userInfo));
        }

        //3.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //清理用户
        UserContext.removeUser();
    }
}
java 复制代码
public class UserContext {
    private static final ThreadLocal<Long> tl = new ThreadLocal<>();

    /**
     * 保存当前登录用户信息到ThreadLocal
     * @param userId 用户id
     */
    public static void setUser(Long userId) {
        tl.set(userId);
    }

    /**
     * 获取当前登录用户信息
     * @return 用户id
     */
    public static Long getUser() {
        return tl.get();
    }

    /**
     * 移除当前登录用户信息
     */
    public static void removeUser(){
        tl.remove();
    }
}

至于拦截器,我们不可能在每个微服务中都重写一次,那样会降低开发效率,所以我们选择直接在所有微服务都要导入的包中写这个拦截器------hm-common包。

注意,这里写完了其实还是不生效的,这个拦截器还需要在MVC中配置:

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

这里为啥要加@ConditionalOnClass?

首先我们要明确,UserInfoInterceptor 这个拦截器是在微服务中拦截的,不是在网关中拦截,网关只负责将用户信息存入请求头。这个拦截器的目的是解析请求头,所以是在微服务中生效的。但是有一个问题,就是网关也导入了common包,这就会出现另一个问题:

网关是响应式的,不是传统的MVC,所以网关中是找不到SpringMVC的,如果这个拦截器在网关中生效,系统是找不到依赖包的,当然就会报错了。

但是我们又希望这个拦截器在微服务中生效,那很简单啊,只需要排除掉网关不就行了,换句话说就是在有MVC包的模块中生效就行了,所以这里用到了Springboot的自动装配的原理。

于是所有微服务现在都有这个拦截器了,也就能获得用户Id了,比如订单部分我们就可以这样写了:

java 复制代码
     // 1.5.其它属性
        order.setPaymentType(orderFormDTO.getPaymentType());
        order.setUserId(UserContext.getUser());
        order.setStatus(1);

3.OpenFeign传递用户信息

为什么会又扯到远程调用的问题上了呢?这是因为我们刚刚的拦截器是在网关中存入信息的,如果微服务要获取用户信息,请求必须是标准流程的,即:

浏览器发出---经过网关---网关拦截修改请求头---微服务拦截请求从请求头获取信息

但是别忘了我们还有一种请求方式------远程调用,这个是不经过网关的!!!

这就意味着我们刚刚存入请求头的信息就没用了,因为这是一个新的请求,是由微服务发出、由微服务响应的。

所以我们又要重新修改一次请求头,这次不是在网关里面了,而是在远程调用中修改。

那么又要使用拦截器了,这个拦截器我们写在hm-api中,因为这是管理远程调用的模块,不用远程调用的微服务肯定就不需要被拦截了。

java 复制代码
public class DefaultFeignConfig {

    /**
     * 配置日志级别
     *
     * @return
     */
    @Bean
    public Logger.Level feignLoggerLevel() {
        return Logger.Level.FULL;
    }

    @Bean
    public RequestInterceptor userInfoRequestInterceptor() {
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate requestTemplate) {
                Long userId = UserContext.getUser();
                if (userId != null) {
                    requestTemplate.header("user-info", userId.toString());
                }
            }
        };
    }
}

我们当然可以重新写个配置类,这里我和日志级别写在一起了。

所以我们需要在所有要使用远程调用的微服务的启动类上导入这个配置类。

java 复制代码
@EnableFeignClients(basePackages = "com.hmall.api.client",defaultConfiguration = DefaultFeignConfig.class)
@MapperScan("com.hmall.cart.mapper")
@SpringBootApplication
public class CartApplication {
    public static void main(String[] args) {
        SpringApplication.run(CartApplication.class, args);
    }

    @Bean
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }
}
相关推荐
华仔啊2 小时前
如何避免MySQL死锁?资深DBA的9条黄金法则
后端·mysql
老华带你飞2 小时前
列车售票|基于springboot 列车售票系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·学习·spring
青韵3 小时前
Claude 高级工具使用解析:从上下文优化到程序化调用的工程实践
后端
汝生淮南吾在北3 小时前
SpringBoot+Vue在线考试系统
vue.js·spring boot·后端·毕业设计·毕设
树獭叔叔3 小时前
Langgraph: Human-in-the-Loop 实现机制
后端·langchain·aigc
李拾叁的摸鱼日常3 小时前
Spring Boot中OncePerRequestFilter原理与Filter单次调用控制全解析
java·后端
script.boy3 小时前
基于spring boot校园二手交易平台的设计与实现
java·spring boot·后端
用户47949283569154 小时前
XSS、CSRF、CSP、HttpOnly 全扫盲:前端安全不只是后端的事
前端·后端·面试
我家领养了个白胖胖4 小时前
SSE在Spring ai alibaba中同时使用Qwen和DeepSeek模型
java·后端·ai编程