微服务架构 --- Gateway网关的项目实战教学

目录

一.什么是API网关?

二.GateWay的使用:

1.创建模块:

2.引入依赖:

3.配置路由:

三.网关登录校验 --- 基于 jwt 令牌:

1.网关过滤器:

两种过滤器:

2.自定义 GlobalFilter 全局过滤器的使用:

3.将用户信息保存在线程中:

四.限流(Rate Limiting)

1.什么是限流:

2.限流的实现:

3.限流配置:

4.自定义 Key Resolver:

五.熔断(Circuit Breaking)

1.什么是熔断:

2.熔断的配置:

3.自定义降级处理

4. 限流与熔断的原理图


一.什么是API网关?

API Gateway(API 网关)是微服务架构的核心组件,它是所有客户端请求的入口,用于将请求路由到正确的微服务。除了路由功能外,API 网关还支持:

  • 请求聚合:将多个微服务的响应汇总为一个响应。
  • 负载均衡:在多个实例间分发请求。
  • 身份验证和授权:拦截请求以执行鉴权。
  • 流量控制和限流:保护微服务避免高并发冲击。
  • 日志与监控:收集请求的日志数据用于监控。

数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由和转发以及数据安全的校验

前端请求不能直接访问微服务,而是要请求网关:

  • 网关可以做安全控制,也就是登录身份校验,校验通过才放行

  • 通过认证后,网关再根据请求判断应该访问哪个微服务,将请求转发过去

Spring Cloud Gateway 是 Spring 官方提供的 API 网关解决方案,它基于 Spring 生态系统构建,支持动态路由过滤器负载均衡全局过滤等功能。

二.GateWay的使用:

1.创建模块:

由于网关本身也是一个独立的微服务,因此也需要创建一个hm-gateway模块开发功能,以此作为网关微服务:

2.引入依赖:

<?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>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

3.配置路由:

路由包含四个属性:

  • id:路由的唯一标示

  • predicates:路由断言,其实就是匹配条件(集合)--- 了解 SpringCloudGateway 中支持的断言类型

  • filters:路由过滤条件,后面讲(集合)

  • uri:路由目标地址,lb://代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问。

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/**

三.网关登录校验 --- 基于 jwt 令牌:

以前单体架构的登录校验是基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,这就存在着两大问题:

  • 每个微服务都需要知道JWT的秘钥,不安全

  • 每个微服务重复编写登录校验代码、权限校验代码,麻烦

既然网关是所有微服务的入口,一切请求都需要先经过网关。我们完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:

  • 只需要在网关和用户服务保存秘钥

  • 只需要在网关开发登录校验功能

 但是出现三个问题需要思考:

  • 网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发之前做登录校验?

  • 网关校验JWT之后,如何将用户信息传递给微服务?

  • 微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?

1.网关过滤器:

Gateway的工作原理:

  1. 客户端请求进入网关后由HandlerMapping对请求做判断,找到与当前请求匹配的路由规则(Route),然后将请求交给WebHandler去处理。

  2. WebHandler则会加载当前路由下需要执行的过滤器链(Filter chain),然后按照顺序逐一执行过滤器(后面称为Filter)。

  3. 图中Filter被虚线分为左右两部分,是因为Filter内部的逻辑分为prepost两部分,分别会在请求路由到微服务之前之后被执行。

  4. 只有所有Filterpre逻辑都依次顺序执行通过后,请求才会被路由到微服务。

  5. 微服务返回结果后,再倒序执行Filterpost逻辑。

  6. 最终把响应结果返回。

如图中所示,最终请求转发是有一个名为 NettyRoutingFilter的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到 NettyRoutingFilter之前,这就符合我们的需求了!

两种过滤器:

  • GatewayFilter:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route.

  • GlobalFilter:全局过滤器,作用范围是所有路由,不可配置。

特性 GatewayFilter(路由过滤器) GlobalFilter(全局过滤器)
作用范围 作用于特定路由 作用于所有路由
可配置性 灵活,配置在单独的路由上 无法配置,所有请求都必须经过
实现方式 配置在 YAML 中 实现 GlobalFilter 接口
典型用途 认证、路径修改、添加响应头 全局日志、安全检查、限流
控制执行顺序 在特定路由下按顺序执行 使用 getOrder() 方法控制顺序
/**
 * 处理请求并将其传递给下一个过滤器
 * @param exchange 当前请求的上下文,其中包含request、response等各种数据
 * @param chain 过滤器链,基于它向下传递请求
 * @return 根据返回值标记当前请求是否被完成或拦截,chain.filter(exchange)就放行了。
 */
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);

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

2.自定义 GlobalFilter 全局过滤器的使用:

 下面是全局过滤器的基本使用:

@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;
    }
}
  • AuthProperties:配置登录校验需要拦截的路径,因为不是所有的路径都需要登录才能访问

  • JwtProperties:定义与JWT工具有关的属性,比如秘钥文件位置

  • SecurityConfig:工具的自动装配

  • JwtTool:JWT工具,其中包含了校验和解析token的功能

  • hmall.jks:秘钥文件

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

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

 下面是AuthPropperties的配置类:

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

import java.util.List;

@Data
@Component
@ConfigurationProperties(prefix = "hm.auth")
public class AuthProperties {
    private List<String> includePaths;
    private List<String> excludePaths;
}

  下面是JwtProperties的配置类:

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.Resource;

import java.time.Duration;

@Data
@ConfigurationProperties(prefix = "hm.jwt")
public class JwtProperties {
    private Resource location;
    private String password;
    private String alias;
    private Duration tokenTTL = Duration.ofMinutes(10);
}

  下面是SecurityConfig的配置类:

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.rsa.crypto.KeyStoreKeyFactory;

import java.security.KeyPair;

@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public KeyPair keyPair(JwtProperties properties){
        // 获取秘钥工厂
        KeyStoreKeyFactory keyStoreKeyFactory =
                new KeyStoreKeyFactory(
                        properties.getLocation(),
                        properties.getPassword().toCharArray());
        //读取钥匙对
        return keyStoreKeyFactory.getKeyPair(
                properties.getAlias(),
                properties.getPassword().toCharArray());
    }
}

 下面是JWT令牌的工具类:

import cn.hutool.core.exceptions.ValidateException;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTValidator;
import cn.hutool.jwt.signers.JWTSigner;
import cn.hutool.jwt.signers.JWTSignerUtil;
import com.hmall.common.exception.UnauthorizedException;
import org.springframework.stereotype.Component;

import java.security.KeyPair;
import java.time.Duration;
import java.util.Date;

@Component
public class JwtTool {
    private final JWTSigner jwtSigner;

    public JwtTool(KeyPair keyPair) {
        this.jwtSigner = JWTSignerUtil.createSigner("rs256", keyPair);
    }

    /**
     * 创建 access-token
     *
     * @param userId 用户信息
     * @return access-token
     */
    public String createToken(Long userId, Duration ttl) {
        // 1.生成jwt
        return JWT.create()
                .setPayload("user", userId)
                .setExpiresAt(new Date(System.currentTimeMillis() + ttl.toMillis()))
                .setSigner(jwtSigner)
                .sign();
    }

    /**
     * 解析token
     *
     * @param token token
     * @return 解析刷新token得到的用户信息
     */
    public Long parseToken(String token) {
        // 1.校验token是否为空
        if (token == null) {
            throw new UnauthorizedException("未登录");
        }
        // 2.校验并解析jwt
        JWT jwt;
        try {
            jwt = JWT.of(token).setSigner(jwtSigner);
        } catch (Exception e) {
            throw new UnauthorizedException("无效的token", e);
        }
        // 2.校验jwt是否有效
        if (!jwt.verify()) {
            // 验证失败
            throw new UnauthorizedException("无效的token");
        }
        // 3.校验是否过期
        try {
            JWTValidator.of(jwt).validateDate();
        } catch (ValidateException e) {
            throw new UnauthorizedException("token已经过期");
        }
        // 4.数据格式校验
        Object userPayload = jwt.getPayload("user");
        if (userPayload == null) {
            // 数据为空
            throw new UnauthorizedException("无效的token");
        }

        // 5.数据解析
        try {
           return Long.valueOf(userPayload.toString());
        } catch (RuntimeException e) {
            // 数据格式有误
            throw new UnauthorizedException("无效的token");
        }
    }
}

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

现在,网关已经可以完成登录校验并获取登录用户身份信息。但是当网关将请求转发到微服务时,微服务又该如何获取用户身份呢?

由于网关发送请求到微服务依然采用的是Http请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。

import cn.hutool.core.text.AntPathMatcher;
import com.hmall.common.exception.UnauthorizedException;
import com.hmall.hmgateway.config.AuthProperties;
import com.hmall.hmgateway.utils.JwtTool;
import lombok.RequiredArgsConstructor;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;


@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    private final AuthProperties authProperties;
    private final JwtTool jwtTool;
    //正则路径匹配,Spring提供的方法
    private final AntPathMatcher antPathMatcher = new AntPathMatcher();
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //获取用户信息
        ServerHttpRequest request = exchange.getRequest();
        //判断是否需要做登录拦截
        if(isExclude(request.getPath().toString())){
            return chain.filter(exchange);
        }
        //获取token
        String token = null;
        List<String> headers = request.getHeaders().get("authorization");
        if(headers != null && !headers.isEmpty()){
            token = headers.get(0);
        }
        //校验并解析token
        Long userId = null;
        try{
            userId = jwtTool.parseToken(token);
        } catch (UnauthorizedException e){
            //401 拦截
            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return response.setComplete();//终止
        }
        //传递用户信息
        System.out.println("userId = " + userId);
        String userInfo = userId.toString();
        ServerWebExchange swe = exchange.mutate()
                .request(builder -> builder.header("user-info", userInfo))
                .build();
        //放行
        return chain.filter(swe);
    }

    //判断是否为放行路径
    private boolean isExclude(String path) {
        //拿到所有的放行路径
        for (String pathPatten : authProperties.getExcludePaths()) {
            if(antPathMatcher.match(pathPatten,path)){
                return true;
            }
        }
        return false;
    }

    //优先级
    @Override
    public int getOrder() {
        return 0;
    }
}

然后再每个微服务组件定义拦截器:

package com.hmall.common.interceptors;

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

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

public class UserInfoInterceptor implements HandlerInterceptor {

    //在controller之前执行代码
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //获取登录用户信息
        String userInfo = request.getHeader("user-info");
        //判断
        if(StrUtil.isNotEmpty(userInfo)){
            UserContext.setUser(Long.valueOf(userInfo));
        }
        return true;
    }

    //controller执行结束后执行代码
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //清理用户
        UserContext.removeUser();
    }
}

3.将用户信息保存在线程中:

先导入UserContext:

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

之后加入MVC配置,让拦截器生效:

import com.hmall.common.interceptors.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
//DispatcherServlet条件是SpringMVC特有的API,这样配置才能让网关(不是MVC实现)不会使用这个拦截器配置类
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserInfoInterceptor());
    }
}

随后在/resources/META_INF/spring.factories文件内配置,让Spring能够扫描Config:

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

四.使用OpenFeign传递用户信息:

前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。

但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。比如下单业务,流程如下:

下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁!

由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头

微服务之间调用是基于OpenFeign来实现的,并不是我们自己发送的请求。我们如何才能让每一个由OpenFeign发起的请求自动携带登录用户信息呢?

这里要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor

public interface RequestInterceptor {

  /**
   * Called for every request. 
   * Add data using methods on the supplied {@link RequestTemplate}.
   */
  void apply(RequestTemplate template);
}

com.hmall.api.config.DefaultFeignConfig中添加一个Bean:

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

四.限流(Rate Limiting)

1.什么是限流:

限流是控制客户端访问请求的数量或频率,避免过多请求导致服务器过载。
常见的限流策略包括:

  • 固定时间窗口:每秒/每分钟允许的请求数。
  • 滑动窗口:请求频率在动态时间窗口内计算。
  • 令牌桶算法:为每个客户端分配一个“令牌”限额。
  • 漏桶算法:保证恒定的请求处理速率。

2.限流的实现:

Spring Cloud Gateway 使用Redis RateLimiter 作为默认的限流实现,每个客户端在固定时间内只能获得一定数量的令牌。

3.限流配置:

spring:
  cloud:
    gateway:
      routes:
        - id: user-service-route
          uri: lb://user-service  # 将请求路由到 user-service
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10   # 每秒生成 10 个令牌
                redis-rate-limiter.burstCapacity: 20   # 允许的最大突发请求数
                key-resolver: "#{@ipKeyResolver}"      # 限流基于 IP 地址

  • replenishRate:每秒向令牌桶中添加的令牌数,即每秒允许的平均请求数。
  • burstCapacity:桶中的最大令牌数,用于支持突发流量。
  • key-resolver:用于根据请求提取唯一标识,如客户端 IP 地址。

4.自定义 Key Resolver:

Spring Cloud Gateway 中,Key Resolver 是用于限流过滤器(RequestRateLimiter)中的一个核心组件。它决定了限流的维度,即根据什么来进行限流。例如,可以按 IP 地址用户 ID请求路径 来定义不同的限流策略。

Spring Cloud Gateway 的限流功能依赖于 Redis 作为分布式计数器。默认情况下,限流策略可以使用 IP 地址进行区分。但是,在一些复杂的场景下,你可能需要自定义限流的关键维度

  • 按用户 ID 限流:控制单个用户的访问频率。
  • 按 API 路径限流:限制某些敏感 API 的访问。
  • 按设备类型限流:区分 PC 端和移动端的流量。

自定义 Key Resolver 就是为了解决这些复杂场景下的限流需求。

import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component
public class IpKeyResolver implements KeyResolver {
    @Override
    public Mono<String> resolve(ServerWebExchange exchange) {
        // 提取客户端 IP 地址作为限流的唯一标识
        return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
    }
}

这个 KeyResolver 从请求中获取客户端的 IP 地址,用于作为限流的标识符。

五.熔断(Circuit Breaking)

1.什么是熔断:

熔断机制的灵感来自电路断路器。
当某个服务出现延迟或错误率过高时,熔断机制会暂时切断请求,避免请求继续积压导致系统雪崩。熔断器经过一段时间后会尝试半开状态,判断服务是否恢复。

熔断的状态通常包括:

  • Closed(关闭):正常工作,转发请求。
  • Open(开启):触发熔断,直接拒绝请求。
  • Half-Open(半开):部分请求通过,如果成功,熔断器关闭。

2.熔断的配置:

spring:
  cloud:
    gateway:
      routes:
        - id: order-service-route
          uri: lb://order-service
          filters:
            - name: CircuitBreaker
              args:
                name: orderServiceCircuitBreaker  # 熔断器的名字
                fallbackUri: forward:/fallback   # 熔断后的降级处理地址
  • CircuitBreaker:启用熔断器过滤器。
  • fallbackUri:当熔断发生时,将请求转发到降级处理服务 /fallback

3.自定义降级处理

在熔断器触发时,我们可以将请求转发到一个降级服务,向用户返回友好的提示信息。

@RestController
public class FallbackController {

    @RequestMapping("/fallback")
    public ResponseEntity<String> fallback() {
        return new ResponseEntity<>("服务暂时不可用,请稍后重试", HttpStatus.SERVICE_UNAVAILABLE);
    }
}

4. 限流与熔断的原理图

  1. 限流过程:

    • 每次请求会从 Redis 中取令牌,令牌不足则拒绝请求。
    • 支持突发请求,但总请求数不能超过限额。
  2. 熔断过程:

    • 请求频繁失败时,熔断器会开启,拒绝后续请求。
    • 一段时间后进入半开状态,少量请求通过判断服务是否恢复。
    • 如果服务恢复,熔断器关闭;否则重新开启熔断。

猜你喜欢

转载自blog.csdn.net/2302_79840586/article/details/142956978