文章目录

Spring Cloud 学习与实践(9):Gateway + JWT 统一鉴权

本章目标:实现登录认证、JWT 签发、Gateway 统一鉴权、用户身份透传、下游用户上下文、Feign 身份继续透传,以及 ThreadLocal 在线程切换时丢失的两类真实故障修复。

本章不是直接上完整 Spring Security OAuth2 体系,而是先通过手写 JWT 鉴权链路理解底层原理。掌握这条主线后,再学习 Spring Security、OAuth2 Resource Server 或 Spring Authorization Server 会更容易。


1、本章目标

第 8 章已经完成了 Gateway 统一入口:

客户端
    ↓
cloud-gateway:9000
    ↓
cloud-user / cloud-product / cloud-order

但是现在所有业务接口只要知道地址就能访问,例如:

GET http://localhost:9000/api/product/products/1
POST http://localhost:9000/api/order/orders

这显然不符合真实项目要求。

本章要完成的目标是:

1. cloud-auth 实现登录接口
2. 使用 BCrypt 校验密码哈希
3. 登录成功后签发 JWT
4. Gateway 通过 GlobalFilter 统一校验 JWT
5. 白名单放行登录接口和 OPTIONS 预检请求
6. Gateway 将 JWT 中的 userId 写入 X-User-Id 请求头
7. 下游服务通过 MVC Interceptor 读取 X-User-Id
8. 使用 ThreadLocal 保存当前请求用户身份
9. 订单服务不再信任客户端传入的 userId
10. Feign 调用继续透传 X-User-Id
11. 复现 CircuitBreaker 线程切换导致身份丢失
12. 使用上下文感知 ExecutorService 修复
13. 复现 @Async 异步线程身份丢失
14. 使用 TaskDecorator 修复

本章最终请求链路如下:

用户登录
    ↓
cloud-auth 校验账号密码
    ↓
签发 JWT
    ↓
客户端携带 Authorization: Bearer <token>
    ↓
Gateway 统一校验 Token
    ↓
Gateway 写入 X-User-Id
    ↓
cloud-order 读取当前用户身份
    ↓
cloud-order 创建订单
    ↓
Feign 调用 cloud-product
    ↓
cloud-product 继续拿到当前用户身份

2、为什么需要统一鉴权

2.1 当前项目缺少什么能力

在没有统一鉴权之前,业务接口存在几个问题。

第一,任何人都能访问业务接口。

不登录也能查询商品
不登录也能创建订单
不登录也能访问订单接口

第二,订单服务仍然信任客户端传入的 userId。

{
  "userId": 999999,
  "productId": 1,
  "quantity": 1
}

这非常危险。客户端传什么用户 ID,服务端就用什么用户 ID,相当于把身份决定权交给了客户端。

第三,每个服务如果都自己写鉴权逻辑,会导致重复代码。

cloud-user 校验一次 Token
cloud-product 校验一次 Token
cloud-order 校验一次 Token

这样会带来:

重复解析 Token
重复处理异常
规则不一致
遗漏接口风险
维护成本高

因此,更合理的做法是:

外部请求统一经过 Gateway
        ↓
Gateway 统一鉴权
        ↓
下游服务只读取可信用户身份

2.2 认证与鉴权有什么区别

这两个概念经常被混用,但含义不同。

认证 Authentication:
你是谁?

鉴权 Authorization:
你能做什么?

举例:

输入用户名和密码登录:
认证

普通用户不能删除商品,管理员可以删除商品:
鉴权

本章主要完成:

登录认证
Token 校验
用户身份透传

还没有深入做:

角色权限
菜单权限
按钮权限
接口级权限
方法级权限

这些可以后续在 Spring Security 或权限模型扩展章节中继续学习。


2.3 为什么放在 Gateway 统一处理

Gateway 是外部请求入口。

客户端
    ↓
Gateway
    ↓
业务服务

因此它天然适合做:

统一鉴权
统一跨域
统一日志
统一限流
统一请求头处理

在本项目中,职责划分如下:

模块职责
cloud-auth负责登录、校验账号密码、签发 JWT
cloud-gateway负责校验 JWT、白名单、身份透传
cloud-user只处理用户业务
cloud-product只处理商品业务
cloud-order只处理订单业务

一句话总结:

登录认证由 cloud-auth 做;
统一 Token 校验由 Gateway 做;
业务服务只接收 Gateway 透传后的可信身份。

3、JWT

3.1 JWT 是什么

JWT 全称:

JSON Web Token

它是一种紧凑的 Token 格式,常用于在客户端和服务端之间传递声明信息。

一个 JWT 通常长这样:

xxxxx.yyyyy.zzzzz

由三部分组成:

Header.Payload.Signature
部分作用
Header描述 Token 类型和签名算法
Payload保存声明信息,例如 userId、username、过期时间
Signature防止 Token 被篡改

本章使用的是签名 JWT,不是加密 JWT。

需要特别注意:

JWT 的 Payload 通常只是 Base64URL 编码,不是加密。
不要把密码、身份证号、银行卡号等敏感信息放进去。

本项目中,JWT 中只保存:

sub:用户 ID
username:用户名
iat:签发时间
exp:过期时间

3.2 JWT 和 Session 的区别

传统 Session 登录大致是:

用户登录
    ↓
服务端创建 Session
    ↓
浏览器保存 Cookie:JSESSIONID
    ↓
后续请求携带 Cookie
    ↓
服务端根据 SessionId 查询登录状态

JWT 登录大致是:

用户登录
    ↓
服务端生成 JWT
    ↓
客户端保存 Token
    ↓
后续请求携带 Authorization
    ↓
服务端校验 Token 签名和过期时间

简单对比:

对比项SessionJWT
登录状态保存位置服务端客户端保存 Token
服务端是否需要存储登录态通常需要通常不需要
微服务横向扩展需要共享 Session 或粘性会话更容易无状态扩展
主动失效相对容易需要黑名单、版本号或短有效期
Token 泄露风险Cookie 泄露有风险JWT 泄露同样有风险

JWT 的优势是适合无状态认证,但它不是万能方案。

生产项目仍然需要考虑:

Token 有效期
Refresh Token
Token 黑名单
密钥轮换
HTTPS
客户端安全存储


4、本章整体项目结构

本章涉及的模块:

cloud-demo
├── cloud-common
│   └── src/main/java/com/example/cloud/common
│       ├── context
│       │   └── UserContext.java
│       └── web
│           ├── UserContextInterceptor.java
│           └── UserContextWebMvcConfig.java
├── cloud-auth
│   └── src/main/java/com/example/cloud/auth
│       ├── CloudAuthApplication.java
│       ├── config/PasswordConfig.java
│       ├── controller/AuthController.java
│       ├── dto/LoginRequest.java
│       ├── dto/LoginResponse.java
│       ├── entity/AuthAccount.java
│       ├── mapper/AuthAccountMapper.java
│       ├── properties/JwtProperties.java
│       ├── service/AuthService.java
│       ├── service/impl/AuthServiceImpl.java
│       └── util/JwtUtil.java
├── cloud-gateway
│   └── src/main/java/com/example/cloud/gateway/auth
│       ├── filter/JwtAuthGlobalFilter.java
│       ├── properties/GatewayAuthProperties.java
│       ├── properties/JwtProperties.java
│       └── util/JwtUtil.java
├── cloud-order
│   └── src/main/java/com/example/cloud/order
│       ├── client/interceptor/UserContextFeignInterceptor.java
│       ├── config/OrderAsyncConfig.java
│       ├── config/Resilience4jExecutorConfig.java
│       ├── config/UserContextAwareExecutorService.java
│       ├── config/UserContextTaskDecorator.java
│       ├── controller/ContextController.java
│       ├── dto/CreateOrderRequest.java
│       ├── service/AsyncUserContextService.java
│       └── service/impl/OrderServiceImpl.java
└── cloud-product
    └── ProductServiceImpl 中增加用户上下文日志

5、第一阶段:cloud-auth 登录认证与 JWT 签发

5.1 为什么单独创建 t_auth_account

项目中已经有:

t_user

它表示用户业务信息,例如昵称、手机号、状态等。

本章新增:

t_auth_account

它专门保存认证相关信息:

登录用户名
密码哈希
关联 userId
账号状态

这样职责更清晰:

t_user:
用户业务资料

t_auth_account:
认证凭据

不要把明文密码写入数据库。


5.2 创建认证账号表

在 cloud_demo 数据库中执行:

DROP TABLE IF EXISTS t_auth_account;

CREATE TABLE t_auth_account (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '认证账号ID',
    user_id BIGINT NOT NULL COMMENT '关联用户ID',
    username VARCHAR(64) NOT NULL COMMENT '登录用户名',
    password_hash VARCHAR(100) NOT NULL COMMENT 'BCrypt 密码哈希',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_user_id (user_id),
    UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认证账号表';

在这里插入图片描述


5.3 BCrypt 是什么

BCrypt 是一种常用的密码哈希算法。

它的特点是:

不是简单加密
不能反向解密出原始密码
每次生成结果不同
内部包含随机 Salt
可以通过 matches() 验证原始密码是否匹配哈希

本项目使用:

BCryptPasswordEncoder

用于:

注册或初始化账号:
明文密码 -> BCrypt 哈希 -> 写入数据库

登录时:
用户输入密码 + 数据库哈希 -> matches() 校验

5.4 生成 BCrypt 哈希

在 cloud-auth 中临时创建:

src/test/java/com/example/cloud/auth/PasswordHashGenerator.java

代码:

package com.example.cloud.auth;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class PasswordHashGenerator {

    public static void main(String[] args) {
        BCryptPasswordEncoder encoder =
                new BCryptPasswordEncoder();

        System.out.println(
                encoder.encode("123456")
        );
    }
}

运行后得到类似:

$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

然后插入数据库:

INSERT INTO t_auth_account (
    user_id,
    username,
    password_hash,
    status
) VALUES (
    1,
    'zhangsan',
    '粘贴刚才生成的 BCrypt 哈希',
    1
);

在这里插入图片描述


5.5 父工程管理 JJWT 版本

打开:

cloud-demo/pom.xml

在 <properties> 中确认存在:

<jjwt.version>0.11.5</jjwt.version>

本章 cloud-auth 和 cloud-gateway 都会使用同一个版本。


5.6 cloud-auth/pom.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">

    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.example.cloud</groupId>
        <artifactId>cloud-demo</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>cloud-auth</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupId>com.example.cloud</groupId>
            <artifactId>cloud-common</artifactId>
            <version>${project.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-crypto</artifactId>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>

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

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

5.7 cloud-auth/bootstrap.yml

位置:

cloud-auth/src/main/resources/bootstrap.yml

内容:

spring:
  application:
    name: cloud-auth

  profiles:
    active: dev

  cloud:
    nacos:
      config:
        server-addr: 127.0.0.1:8848
        file-extension: yaml
        group: DEFAULT_GROUP
        namespace: 你的-dev-Namespace-ID

5.8 cloud-auth-dev.yaml

Nacos 中创建:

Data ID:cloud-auth-dev.yaml
Group:DEFAULT_GROUP
Namespace:dev

内容:

server:
  port: 9100

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/cloud_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
    username: root
    password: 你的数据库密码

  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

  global-config:
    db-config:
      id-type: auto

jwt:
  secret: VhxU/uOVwrgavYRbnWfzAYTUDjY8jC/rYOyDkoAFyig=
  expire-minutes: 120

5.9 cloud-auth 关键代码

5.9.1 启动类: CloudAuthApplication
package com.example.cloud.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@EnableDiscoveryClient
@SpringBootApplication(scanBasePackages = "com.example.cloud")
public class CloudAuthApplication {

    public static void main(String[] args) {
        SpringApplication.run(
                CloudAuthApplication.class,
                args
        );
    }
}
5.9.2 配置密码哈希器:PasswordConfig
package com.example.cloud.auth.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
public class PasswordConfig {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
5.9.3 AuthAccount.java
package com.example.cloud.auth.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@TableName("t_auth_account")
public class AuthAccount {

    @TableId(type = IdType.AUTO)
    private Long id;

    private Long userId;

    private String username;

    private String passwordHash;

    private Integer status;

    private LocalDateTime createTime;
}
5.9.4 AuthAccountMapper.java
package com.example.cloud.auth.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.cloud.auth.entity.AuthAccount;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface AuthAccountMapper
        extends BaseMapper<AuthAccount> {
}
5.9.5 LoginRequest.java
package com.example.cloud.auth.dto;

import lombok.Data;

import javax.validation.constraints.NotBlank;

@Data
public class LoginRequest {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "密码不能为空")
    private String password;
}
5.9.6 LoginResponse.java
package com.example.cloud.auth.dto;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class LoginResponse {

    private String token;

    private String tokenType;

    private Long expiresIn;

    private Long userId;

    private String username;
}
5.9.7 Auth JwtProperties.java
package com.example.cloud.auth.properties;

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

@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

    private String secret;

    private Long expireMinutes = 120L;
}
5.9.8 Auth JwtUtil.java
package com.example.cloud.auth.util;

import com.example.cloud.auth.properties.JwtProperties;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
@RequiredArgsConstructor
public class JwtUtil {

    private final JwtProperties jwtProperties;

    public String generateToken(
            Long userId,
            String username
    ) {
        Date issuedAt = new Date();

        Date expiration = new Date(
                issuedAt.getTime()
                        + jwtProperties.getExpireMinutes()
                        * 60
                        * 1000
        );

        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim("username", username)
                .setIssuedAt(issuedAt)
                .setExpiration(expiration)
                .signWith(
                        getSigningKey(),
                        SignatureAlgorithm.HS256
                )
                .compact();
    }

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(
                jwtProperties.getSecret()
        );

        return Keys.hmacShaKeyFor(keyBytes);
    }
}
5.9.9 AuthService.java
package com.example.cloud.auth.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.example.cloud.auth.dto.LoginRequest;
import com.example.cloud.auth.dto.LoginResponse;
import com.example.cloud.auth.entity.AuthAccount;

public interface AuthService
        extends IService<AuthAccount> {

    LoginResponse login(LoginRequest request);
}
5.9.10 AuthServiceImpl.java
package com.example.cloud.auth.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.auth.dto.LoginRequest;
import com.example.cloud.auth.dto.LoginResponse;
import com.example.cloud.auth.entity.AuthAccount;
import com.example.cloud.auth.mapper.AuthAccountMapper;
import com.example.cloud.auth.properties.JwtProperties;
import com.example.cloud.auth.service.AuthService;
import com.example.cloud.auth.util.JwtUtil;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AuthServiceImpl
        extends ServiceImpl<AuthAccountMapper, AuthAccount>
        implements AuthService {

    private final BCryptPasswordEncoder passwordEncoder;

    private final JwtUtil jwtUtil;

    private final JwtProperties jwtProperties;

    @Override
    public LoginResponse login(LoginRequest request) {
        AuthAccount account = lambdaQuery()
                .eq(
                        AuthAccount::getUsername,
                        request.getUsername()
                )
                .one();

        if (account == null
                || !passwordEncoder.matches(
                        request.getPassword(),
                        account.getPasswordHash()
                )) {
            throw new BizException(
                    ErrorCode.UNAUTHORIZED,
                    "用户名或密码错误"
            );
        }

        if (account.getStatus() == null
                || account.getStatus() != 1) {
            throw new BizException(
                    ErrorCode.UNAUTHORIZED,
                    "账号已禁用"
            );
        }

        String token = jwtUtil.generateToken(
                account.getUserId(),
                account.getUsername()
        );

        return LoginResponse.builder()
                .token(token)
                .tokenType("Bearer")
                .expiresIn(
                        jwtProperties.getExpireMinutes()
                                * 60
                )
                .userId(account.getUserId())
                .username(account.getUsername())
                .build();
    }
}
5.9.11 AuthController.java
package com.example.cloud.auth.controller;

import com.example.cloud.auth.dto.LoginRequest;
import com.example.cloud.auth.dto.LoginResponse;
import com.example.cloud.auth.service.AuthService;
import com.example.cloud.common.result.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@Validated
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/login")
    public Result<LoginResponse> login(
            @Valid @RequestBody LoginRequest request
    ) {
        return Result.success(
                authService.login(request)
        );
    }
}

在这里插入图片描述


5.10 Gateway 增加 auth 路由

修改 Nacos 中的:

cloud-gateway-dev.yaml

在 routes 中加入:

        - id: auth-route
          uri: lb://cloud-auth
          predicates:
            - Path=/api/auth/**
          filters:
            - StripPrefix=1

外部请求:

/api/auth/login

经过 StripPrefix=1 后,下游收到:

/auth/login

正好匹配:

@RequestMapping("/auth")
@PostMapping("/login")

在这里插入图片描述


5.11 创建 auth.http

为了验证 cloud-auth 登录接口和 Gateway 转发是否正常,创建接口测试文件:

cloud-auth
└── src/test/http
    └── auth.http

内容如下:

### 直接访问认证服务:登录成功
POST http://localhost:9100/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

### 通过 Gateway 登录成功
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

### 密码错误
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "wrong-password"
}

### 用户名不存在
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "not-exists",
  "password": "123456"
}

5.12 启动顺序

启动顺序如下:

1. Nacos Server
2. CloudAuthApplication
3. CloudUserApplication
4. CloudProductApplication
5. CloudOrderApplication
6. CloudGatewayApplication

启动完成后,进入 Nacos 服务列表,应能看到新增服务:

cloud-auth

端口为:

9100

这说明认证服务已经成功注册到 Nacos,Gateway 后续才能通过:

uri: lb://cloud-auth

按服务名找到认证服务实例。


5.13 本轮预期结果

5.13.1 正确登录

请求:

POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

预期返回类似:

{
  "code": 0,
  "message": "success",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiJ9...",
    "tokenType": "Bearer",
    "expiresIn": 7200,
    "userId": 1,
    "username": "zhangsan"
  }
}

在这里插入图片描述

在这里插入图片描述

其中:

token:
登录成功后签发的 JWT

tokenType:
后续请求头使用 Bearer 类型

expiresIn:
Token 有效秒数

userId:
当前登录用户 ID

username:
当前登录用户名

后续访问业务接口时,客户端需要携带:

Authorization: Bearer <token>

5.13.2 密码错误

请求:

POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "wrong-password"
}

预期返回:

{
  "code": 40100,
  "message": "用户名或密码错误",
  "data": null
}

具体 code 以当前项目中 ErrorCode.UNAUTHORIZED 的实际数值为准。


5.13.3 用户名不存在

请求:

POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "not-exists",
  "password": "123456"
}

预期同样返回:

用户名或密码错误

不要返回:

用户不存在

原因是:

如果明确提示“用户不存在”,
攻击者就可以通过登录接口枚举哪些账号真实存在。

因此本章统一返回:

用户名或密码错误

这样不会向外部暴露账号是否存在。

在这里插入图片描述


5.14 本轮需要理解的链路

当前阶段完整链路如下:

客户端提交用户名和密码
        ↓
Gateway 根据 auth-route 转发
        ↓
cloud-auth 查询 t_auth_account
        ↓
BCrypt 校验密码哈希
        ↓
JwtUtil 使用密钥签名
        ↓
生成 JWT
        ↓
返回给客户端

此时需要注意:

当前 Token 只是签发成功。

下一阶段才会完成:

客户端携带 Token
        ↓
Gateway GlobalFilter 校验 Token
        ↓
提取 userId
        ↓
写入 X-User-Id
        ↓
转发到业务服务

也就是说,第一阶段只解决:

登录后如何拿到 Token

还没有解决:

业务接口如何校验 Token

Gateway 支持全局 Filter。后续 JwtAuthGlobalFilter 会在请求进入下游服务之前执行,用于统一处理 Token 校验、白名单放行和用户身份透传。


6、第二阶段:Gateway GlobalFilter 统一校验 JWT

6.1 GlobalFilter 是什么

第 8 章使用过配置式 Filter:

filters:
  - StripPrefix=2

这种 Filter 属于某条路由。

本章使用:

GlobalFilter

它是全局过滤器,适合处理:

统一鉴权
统一日志
链路追踪
请求头加工
全局审计

本章的 JwtAuthGlobalFilter 负责:

放行 OPTIONS
放行登录白名单
检查 Authorization
校验 JWT
提取 userId
写入 X-User-Id
过滤器类型作用范围核心底层机制经典配置/代码示例黄金应用场景默认执行顺序权重
Route Filter
(路由过滤器)
某条特定路由
只对绑定的单个服务生效
继承 GatewayFilterFactory
在特定路由的 filters 属性下配置
StripPrefix=2
AddRequestHeader=X-User, Admin
针对单个微服务的路径重写、特定接口的请求头增强。居中
(同等 Order 下先于 GlobalFilter 执行)
Default Filter
(默认过滤器)
所有路由
无差别应用到配置文件里的全部服务
同样继承 GatewayFilterFactory
但在网关的 default-filters 节点下全局配置
DedupeResponseHeader
AddResponseHeader=X-Platform, Web
全局跨域响应头去重、全局添加统一响应标识、全局基础限流。最高
(同等 Order 下最先被加载执行)
GlobalFilter
(全局过滤器)
所有路由
通过 Java 代码硬编码,全量拦截
实现 GlobalFilter 和 Ordered 接口
作为 Bean 注入到 Spring 容器
JWT 权限校验
全局接口耗时统计
黑名单统一拦截
统一身份安全认证(Token 校验)、全局日志审计、系统级核心防刷。最末
(同等 Order 下最后执行,但通常手动指定高优先级)

6.2 Gateway 为什么不直接依赖 cloud-auth

cloud-auth 里已经有 JwtUtil。

但是不推荐让:

cloud-gateway

直接依赖:

cloud-auth

因为:

cloud-auth 是认证服务
cloud-gateway 是网关服务

如果 Gateway 依赖认证服务模块,会导致:

模块边界模糊
依赖传递过重
Gateway 被动引入 MVC、MyBatis-Plus、MySQL 等依赖

当前阶段在 Gateway 中创建轻量级 JWT 解析工具。

后续如果复用需求变多,可以抽取:

cloud-security-core

6.3 cloud-gateway 增加依赖

打开:

cloud-gateway/pom.xml

增加:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>${jjwt.version}</version>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>${jjwt.version}</version>
    <scope>runtime</scope>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>${jjwt.version}</version>
    <scope>runtime</scope>
</dependency>

<!-- Lombok:JwtProperties、GatewayAuthProperties 使用 @Data -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

注意:

只加 JJWT 不够。
只要新增代码使用 @Data、@Slf4j、@RequiredArgsConstructor,
就要检查当前模块是否已经引入 Lombok。

6.4 修改 cloud-gateway-dev.yaml

在 Nacos 中的:

cloud-gateway-dev.yaml

增加:

jwt:
  secret: VhxU/uOVwrgavYRbnWfzAYTUDjY8jC/rYOyDkoAFyig=

gateway:
  auth:
    white-paths:
      - /api/auth/login

6.5 登录接口为什么必须在白名单中

如果登录接口也要求 Token:

登录需要 Token
        ↓
Token 又必须登录后才有

这会造成死循环。

因此:

/api/auth/login

必须匿名放行。


6.6 OPTIONS 为什么不能校验 Token

浏览器跨域复杂请求会先发送:

OPTIONS

预检请求通常不会携带业务 Token。

如果 OPTIONS 被 Gateway 返回 401:

浏览器认为跨域失败
        ↓
正式请求根本不会发送

因此 JwtAuthGlobalFilter 必须放行:

OPTIONS

6.7 Gateway JwtProperties.java

package com.example.cloud.gateway.auth.properties;

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

@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

    private String secret;
}

6.8 GatewayAuthProperties.java

package com.example.cloud.gateway.auth.properties;

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

import java.util.ArrayList;
import java.util.List;

@Data
@Component
@ConfigurationProperties(prefix = "gateway.auth")
public class GatewayAuthProperties {

    private List<String> whitePaths = new ArrayList<>();
}

6.9 Gateway JwtUtil.java

package com.example.cloud.gateway.auth.util;

import com.example.cloud.gateway.auth.properties.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;

@Component
@RequiredArgsConstructor
public class JwtUtil {

    private final JwtProperties jwtProperties;

    public Claims parseToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(
                jwtProperties.getSecret()
        );

        return Keys.hmacShaKeyFor(keyBytes);
    }
}

6.10 JwtAuthGlobalFilter.java

package com.example.cloud.gateway.auth.filter;

import com.example.cloud.gateway.auth.properties.GatewayAuthProperties;
import com.example.cloud.gateway.auth.util.JwtUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Gateway JWT 全局鉴权 Filter。
 *
 * 职责:
 * 1. 放行 OPTIONS 预检请求
 * 2. 放行白名单接口
 * 3. 读取 Authorization: Bearer <token>
 * 4. 校验 JWT
 * 5. 从 JWT 中提取 userId
 * 6. 覆盖写入可信请求头 X-User-Id
 * 7. 将请求继续转发给下游服务
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthGlobalFilter
        implements GlobalFilter, Ordered {

    private static final String BEARER_PREFIX = "Bearer ";

    /**
     * Gateway 写给下游服务的可信用户身份请求头。
     */
    public static final String USER_ID_HEADER = "X-User-Id";

    /**
     * 与 cloud-common 中 Result 的返回结构保持一致。
     *
     * Gateway 基于 WebFlux,
     * 当前不直接依赖 cloud-common 的 MVC 相关实现。
     */
    private static final int UNAUTHORIZED_CODE = 40100;

    private final JwtUtil jwtUtil;

    private final GatewayAuthProperties authProperties;

    private final ObjectMapper objectMapper;

    private final AntPathMatcher pathMatcher = new AntPathMatcher();

    @Override
    public Mono<Void> filter(
            ServerWebExchange exchange,
            GatewayFilterChain chain
    ) {
        ServerHttpRequest request = exchange.getRequest();

        /*
         * 1. 放行 OPTIONS 预检请求。
         *
         * 浏览器跨域请求可能先发送 OPTIONS。
         * 如果 OPTIONS 也要求 JWT,
         * 浏览器将无法发出正式请求。
         */
        if (HttpMethod.OPTIONS.equals(request.getMethod())) {
            return chain.filter(exchange);
        }

        String path = request.getURI().getPath();

        /*
         * 2. 放行白名单。
         *
         * 登录接口必须匿名访问,
         * 否则客户端无法获得 Token。
         */
        if (isWhitePath(path)) {
            return chain.filter(exchange);
        }

        /*
         * 3. 读取 Authorization。
         */
        String authorization = request.getHeaders()
                .getFirst(HttpHeaders.AUTHORIZATION);

        if (!StringUtils.hasText(authorization)
                || !authorization.startsWith(BEARER_PREFIX)) {
            return unauthorized(
                    exchange,
                    "未登录或 Token 缺失"
            );
        }

        String token = authorization
                .substring(BEARER_PREFIX.length())
                .trim();

        if (!StringUtils.hasText(token)) {
            return unauthorized(
                    exchange,
                    "未登录或 Token 缺失"
            );
        }

        try {
            /*
             * 4. 校验 Token。
             */
            Claims claims = jwtUtil.parseToken(token);

            /*
             * 5. sub 中保存 userId。
             */
            String userId = claims.getSubject();

            if (!StringUtils.hasText(userId)) {
                return unauthorized(
                        exchange,
                        "Token 中缺少用户身份"
                );
            }

            /*
             * 6. 覆盖写入可信 X-User-Id。
             *
             * 必须覆盖,而不是直接信任客户端传入值。
             *
             * 否则攻击者可以自己构造:
             * X-User-Id: 999999
             */
            ServerHttpRequest trustedRequest = request
                    .mutate()
                    .headers(headers -> {
                        headers.remove(USER_ID_HEADER);
                        headers.set(USER_ID_HEADER, userId);
                    })
                    .build();

            /*
             * 7. 使用加工后的请求继续执行 Filter Chain。
             */
            return chain.filter(
                    exchange.mutate()
                            .request(trustedRequest)
                            .build()
            );
        } catch (ExpiredJwtException e) {
            log.warn("JWT 已过期:{}", e.getMessage());

            return unauthorized(
                    exchange,
                    "Token 已过期,请重新登录"
            );
        } catch (JwtException | IllegalArgumentException e) {
            log.warn("JWT 校验失败:{}", e.getMessage());

            return unauthorized(
                    exchange,
                    "Token 无效,请重新登录"
            );
        }
    }

    /**
     * 判断路径是否属于匿名白名单。
     */
    private boolean isWhitePath(String path) {
        return authProperties.getWhitePaths()
                .stream()
                .anyMatch(pattern -> pathMatcher.match(pattern, path));
    }

    /**
     * 直接在 Gateway 返回 401 JSON。
     *
     * Gateway 基于 WebFlux,
     * 返回类型是 Mono<Void>。
     */
    private Mono<Void> unauthorized(
            ServerWebExchange exchange,
            String message
    ) {
        ServerHttpResponse response = exchange.getResponse();

        response.setStatusCode(HttpStatus.UNAUTHORIZED);

        response.getHeaders()
                .setContentType(MediaType.APPLICATION_JSON);

        byte[] bytes = serializeError(message);

        return response.writeWith(
                Mono.just(
                        response.bufferFactory().wrap(bytes)
                )
        );
    }

    /**
     * 统一错误响应:
     *
     * {
     *   "code": 40100,
     *   "message": "...",
     *   "data": null
     * }
     */
    private byte[] serializeError(String message) {
        Map<String, Object> body = new LinkedHashMap<>();

        body.put("code", UNAUTHORIZED_CODE);
        body.put("message", message);
        body.put("data", null);

        try {
            return objectMapper.writeValueAsBytes(body);
        } catch (JsonProcessingException e) {
            log.error("序列化 Gateway 鉴权失败响应异常", e);

            return (
                    "{\"code\":40100,"
                            + "\"message\":\"Unauthorized\","
                            + "\"data\":null}"
            ).getBytes(StandardCharsets.UTF_8);
        }
    }

    /**
     * 数值越小,前置逻辑越早执行。
     */
    @Override
    public int getOrder() {
        return -100;
    }
}

在这里插入图片描述


6.11 为什么必须覆盖 X-User-Id

客户端可能主动伪造:

X-User-Id: 999999

如果下游服务直接信任这个请求头,就会产生身份伪造风险。

正确做法是:

Gateway 从 JWT 提取真实 userId
        ↓
删除客户端传来的 X-User-Id
        ↓
重新写入可信 X-User-Id

所以:

X-User-Id 只能由 Gateway 写入。

6.12 为什么 Gateway 中不直接返回 Result

在普通业务服务中,接口通常是 Spring MVC Controller,例如:

@GetMapping("/users/{id}")
public Result<UserDTO> getById(@PathVariable Long id) {
    return Result.success(userService.getById(id));
}

这种场景中,Controller 可以直接返回:

Result<T>

但 Gateway 不一样。

Gateway 使用的是:

Spring WebFlux
Mono<Void>
ServerWebExchange

它不是传统 Spring MVC Controller。

当前鉴权失败时,JwtAuthGlobalFilter 是直接向响应对象写入 JSON 字节流:

ServerHttpResponse
        ↓
DataBuffer
        ↓
Mono<Void>

也就是说,Gateway 中的返回方式不是:

return Result.fail(...)

而是:

response.writeWith(...)

不过响应结构仍然和业务服务保持一致:

{
  "code": 40100,
  "message": "...",
  "data": null
}

这样做的好处是:

实现方式符合 Gateway 的 WebFlux 模型;
响应格式又能和业务服务保持统一。

一句话总结:

Gateway 不直接返回 Result<T>,
但可以手动写出同样结构的 JSON。

6.13 getOrder() = -100 有什么作用?

JwtAuthGlobalFilter 实现了:

Ordered

并重写:

@Override
public int getOrder() {
    return -100;
}

这个值决定当前 Filter 在 Gateway Filter Chain 中的执行顺序。

Gateway 会将:

GlobalFilter
Route Filter

合并到同一条 Filter Chain 中。

然后根据:

Ordered#getOrder()

排序。

数值越小:

前置逻辑越早执行

鉴权属于非常靠前的逻辑。

因为如果请求没有 Token,或者 Token 无效,就没有必要继续执行后续路由、负载均衡、转发等逻辑。

所以这里设置为:

-100

表示让 JWT 鉴权尽量提前执行。

Gateway 官方文档说明,GlobalFilter 和 GatewayFilter 会合并为一条过滤器链,并通过 Ordered 接口排序;优先级最高的 Filter 会最先执行 pre 阶段,并在 post 阶段最后执行。

可以这样理解:

请求进入 Gateway
        ↓
优先执行 JWT 鉴权
        ↓
鉴权失败:直接返回 401
        ↓
鉴权成功:继续路由匹配和转发

一句话总结:

鉴权 Filter 越早执行,越能避免无效请求继续进入后续链路。

6.14 重启 Gateway

发布 Nacos 配置后,需要刷新 Maven,并重新启动:

CloudGatewayApplication

观察控制台是否正常启动。

如果启动失败,优先检查:

1. cloud-gateway 是否已经添加 JJWT 依赖
2. cloud-gateway 是否已经添加 Lombok 依赖
3. jwt.secret 是否存在
4. gateway.auth.white-paths 是否配置正确
5. JwtAuthGlobalFilter 是否有导包错误
6. Gateway 中是否误引入 spring-boot-starter-web

特别注意:

cloud-gateway 使用 Spring WebFlux;
不要引入 spring-boot-starter-web。

6.15 更新 gateway.http

打开:

cloud-gateway/src/test/http/gateway.http

追加:

### --------------------------------------------------
### JWT 鉴权测试
### --------------------------------------------------

### 登录:无需 Token
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

### 将上方登录返回的 token 粘贴到这里
@token = 粘贴你的JWT

### 业务接口:缺少 Token,预期返回 401
GET http://localhost:9000/api/product/products/1

### 业务接口:携带合法 Token,预期正常返回
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}

### 创建订单:携带合法 Token,预期成功
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "userId": 1,
  "productId": 1,
  "quantity": 1
}

### Token 被篡改,预期返回 401
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}abc

### Token 格式错误,预期返回 401
GET http://localhost:9000/api/product/products/1
Authorization: invalid-token

### OPTIONS 预检:无需 Token,预期仍然成功
OPTIONS http://localhost:9000/api/order/orders
Origin: http://localhost:5500
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

这里仍然保留:

{
  "userId": 1,
  "productId": 1,
  "quantity": 1
}

是因为当前还处于第二阶段。

此时只验证:

Gateway 能不能校验 Token

下一阶段才会改造订单接口,让订单服务不再信任请求体中的 userId。


6.16 本轮预期结果

6.16.1 登录接口无需 Token

请求:

POST /api/auth/login

预期:

正常返回 JWT。

原因:

/api/auth/login 在白名单中,
不需要携带 Authorization。

6.16.2 业务接口缺少 Token

请求:

GET /api/product/products/1

不携带:

Authorization

预期 HTTP 状态码:

401 Unauthorized

响应:

{
  "code": 40100,
  "message": "未登录或 Token 缺失",
  "data": null
}

在这里插入图片描述

说明 Gateway 已经开始保护业务接口。


6.16.3 携带合法 Token

请求头:

Authorization: Bearer <token>

预期:

商品接口正常返回。

说明:

Token 签名正确;
Token 没有过期;
Gateway 可以从 JWT 中提取 userId;
请求可以继续转发到下游服务。

在这里插入图片描述

在这里插入图片描述


6.16.4 篡改 Token

在合法 Token 末尾增加:

abc

请求:

GET /api/product/products/1
Authorization: Bearer {{token}}abc

预期返回:

{
  "code": 40100,
  "message": "Token 无效,请重新登录",
  "data": null
}

原因是:

JWT 内容被篡改后,
签名校验无法通过。

签名校验失败的 Token 不应该被信任。

在这里插入图片描述


6.16.5 Token 格式错误

请求:

GET /api/product/products/1
Authorization: invalid-token

预期返回:

{
  "code": 40100,
  "message": "未登录或 Token 缺失",
  "data": null
}

原因是当前 Filter 要求请求头必须符合:

Authorization: Bearer <token>

如果没有 Bearer 前缀,就认为格式不合法。


6.16.6 OPTIONS 预检无需 Token

请求:

OPTIONS http://localhost:9000/api/order/orders
Origin: http://localhost:5500
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

预期仍然返回允许跨域的响应头,例如:

Access-Control-Allow-Origin: http://localhost:5500
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS

原因是:

浏览器跨域预检请求通常不会携带业务 Token。

如果 OPTIONS 被 JWT Filter 拦截,浏览器会认为跨域失败,正式请求不会继续发送。


6.17 本轮边界:Gateway 校验不等于彻底安全

当前外部入口:

http://localhost:9000

已经受 Gateway JWT Filter 保护。

也就是说,通过 Gateway 访问业务接口时:

无 Token:
返回 401

Token 无效:
返回 401

Token 合法:
允许转发

但本地学习环境中,仍然可以直接访问业务服务端口:

http://localhost:9200
http://localhost:9300
http://localhost:9400

这会绕过 Gateway。

这是本地学习环境的正常现象。

因为我们在本地直接启动了多个 Spring Boot 服务,每个服务端口都暴露在本机。

真实部署中,还需要通过:

安全组
防火墙
Kubernetes Service
内网隔离
反向代理

限制业务服务端口不对公网开放。

一句话总结:

代码层由 Gateway 统一鉴权;
网络层还要限制下游服务绕过 Gateway 被直接访问。

否则攻击者如果能直接访问:

cloud-order:9400

就可能绕开:

cloud-gateway:9000

所以真实项目中,Gateway 鉴权和网络隔离必须配合使用。

7、第三阶段:下游用户上下文与 Feign 身份透传

7.1 为什么不能继续从请求体读取 userId

前面虽然 Gateway 已经验证了 Token,但订单接口仍然接收:

{
  "userId": 1,
  "productId": 1,
  "quantity": 1
}

这不合理。

因为客户端可以伪造:

{
  "userId": 999999,
  "productId": 1,
  "quantity": 1
}

既然 Gateway 已经从 JWT 中提取出可信 userId,订单服务就不应该再信任请求体里的 userId。

因此改成:

{
  "productId": 1,
  "quantity": 1
}

用户身份只来自:

JWT
    ↓
Gateway
    ↓
X-User-Id
    ↓
UserContext

7.2 ThreadLocal 是什么

ThreadLocal 可以理解为:

线程自己的变量盒子

同一个变量名,不同线程看到的是自己的值。

线程 A:userId = 1
线程 B:userId = 2
线程 C:userId = null

在一个普通 HTTP 请求中,Controller、Service、Mapper 通常运行在同一个 Tomcat 工作线程里。

因此可以在请求进入时:

读取 X-User-Id
        ↓
写入 ThreadLocal

业务代码中再通过:

UserContext.getUserId()

获取当前用户身份。


7.3 为什么必须 clear

Tomcat 使用线程池。

线程处理完一个请求后,不会销毁,而是继续处理下一个请求。

如果不清理 ThreadLocal,可能出现:

请求 A:userId = 1
        ↓
线程放回线程池
        ↓
请求 B:没有身份头
        ↓
复用同一个线程
        ↓
错误读到 userId = 1

所以必须形成完整生命周期:

请求进入:set
请求处理:get
请求结束:remove

7.4 保存当前请求用户身份:UserContext.java

前面 Gateway 已经完成了两件事:

  1. 校验 JWT 是否合法
  2. 从 JWT 中提取 userId,并写入 X-User-Id 请求头

但请求进入 cloud-order、cloud-product 等业务服务后,业务代码不能每一层都手动传递:

userId

否则 Controller、Service、Mapper、Feign 调用中都会出现大量重复参数。

因此需要一个统一的当前用户上下文:

UserContext

它的作用是:

在同一个请求线程中保存当前用户 ID,
让业务代码可以随时获取当前登录用户。

package com.example.cloud.common.context;

import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;

/**
 * 当前请求用户上下文。
 *
 * 作用:
 * 在同一个请求线程内保存当前用户 ID。
 *
 * 生命周期:
 * 请求进入时 set
 * 业务处理时 get
 * 请求结束后 clear
 */
public final class UserContext {

    /**
     * Gateway 写给下游服务的可信请求头。
     */
    public static final String USER_ID_HEADER = "X-User-Id";

    /**
     * 每个线程拥有独立的用户 ID 副本。
     */
    private static final ThreadLocal<Long> USER_ID_HOLDER =
            new ThreadLocal<>();

    private UserContext() {
    }

    /**
     * 保存当前线程用户 ID。
     */
    public static void setUserId(Long userId) {
        USER_ID_HOLDER.set(userId);
    }

    /**
     * 获取当前线程用户 ID。
     *
     * 允许返回 null:
     * 某些公开接口或内部任务可能没有用户身份。
     */
    public static Long getUserId() {
        return USER_ID_HOLDER.get();
    }

    /**
     * 获取必须存在的用户 ID。
     *
     * 适用于创建订单等必须登录的业务。
     */
    public static Long requireUserId() {
        Long userId = getUserId();

        if (userId == null) {
            throw new BizException(
                    ErrorCode.UNAUTHORIZED,
                    "用户身份缺失"
            );
        }

        return userId;
    }

    /**
     * 清理当前线程上下文。
     *
     * Tomcat 使用线程池。
     * 请求结束后必须 remove,
     * 防止线程复用导致用户身份串线。
     */
    public static void clear() {
        USER_ID_HOLDER.remove();
    }
}


7.5 读取 Gateway 透传的用户身份:UserContextInterceptor.java

UserContext 只是一个保存用户 ID 的工具类。

但它本身不会自动知道:

X-User-Id 是多少

因此还需要一个 MVC 拦截器,在请求进入业务服务时读取请求头。

这个拦截器的职责是:

  1. 请求进入 Controller 之前,读取 X-User-Id
  2. 如果请求头中存在用户 ID,就写入 UserContext
  3. 如果请求头不存在,就允许继续放行
  4. 请求结束后,清理 UserContext

为什么请求头不存在也放行?

因为不是所有接口都必须有用户身份。

例如后续可能会有:

公开接口
健康检查接口
内部任务接口
测试接口

所以拦截器只负责:

有身份就读取并写入上下文。

至于某个业务接口是否必须登录,由业务代码自己决定。

例如创建订单时调用:

UserContext.requireUserId()

如果没有用户身份,就抛出:

用户身份缺失

这里还要注意一个安全边界:

UserContextInterceptor 不是完整的安全认证逻辑。

真正的 JWT 校验已经在 Gateway 中完成。

当前拦截器只负责读取 Gateway 透传后的可信请求头,并写入当前线程上下文。

也就是说:

Gateway:
负责校验 Token

UserContextInterceptor:
负责读取 X-User-Id,并保存到 ThreadLocal

下面创建 UserContextInterceptor.java。

package com.example.cloud.common.web;

import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

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

/**
 * MVC 请求用户上下文拦截器。
 *
 * 注意:
 * 它不是完整安全边界。
 *
 * 真正的 JWT 校验在 Gateway 中完成。
 * 当前拦截器只负责:
 * 1. 读取 Gateway 写入的 X-User-Id
 * 2. 保存到 ThreadLocal
 * 3. 请求结束后清理 ThreadLocal
 */
@Component
@Slf4j
public class UserContextInterceptor
        implements HandlerInterceptor {

    /**
     * Controller 执行前调用。
     */
    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler
    ) {
        /*
         * 防御性清理:
         * 避免当前线程残留上一次请求的数据。
         */
        UserContext.clear();

        String userId = request.getHeader(
                UserContext.USER_ID_HEADER
        );

        log.info(
                "接收请求,uri={},线程={},X-User-Id={}",
                request.getRequestURI(),
                Thread.currentThread().getName(),
                userId
        );

        /*
         * 公开接口或内部接口可能没有用户身份。
         * 当前只在有值时写入。
         *
         * 具体业务是否必须登录,
         * 由业务方法调用 requireUserId() 决定。
         */
        if (!StringUtils.hasText(userId)) {
            return true;
        }

        try {
            UserContext.setUserId(
                    Long.valueOf(userId)
            );

            return true;
        } catch (NumberFormatException e) {
            throw new BizException(
                    ErrorCode.UNAUTHORIZED,
                    "用户身份格式错误"
            );
        }
    }

    /**
     * 请求完成后调用。
     *
     * 无论请求成功还是异常,
     * 都必须清理 ThreadLocal。
     */
    @Override
    public void afterCompletion(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler,
            Exception ex
    ) {
        UserContext.clear();
    }
}


7.6 注册用户上下文拦截器: UserContextWebMvcConfig.java

创建了 UserContextInterceptor 之后,还需要把它注册到 Spring MVC 的拦截器链中。

否则这个类虽然被 Spring 扫描到了,但不会自动拦截请求。

因此需要创建:

package com.example.cloud.common.web;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class UserContextWebMvcConfig
        implements WebMvcConfigurer {

    private final UserContextInterceptor
            userContextInterceptor;

    @Override
    public void addInterceptors(
            InterceptorRegistry registry
    ) {
        registry.addInterceptor(userContextInterceptor)
                .addPathPatterns("/**");
    }
}

在这里插入图片描述


7.7 ContextController.java

package com.example.cloud.order.controller;

import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.result.Result;
import com.example.cloud.order.service.AsyncUserContextService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/context")
@RequiredArgsConstructor
public class ContextController {

    private final AsyncUserContextService
            asyncUserContextService;

    @GetMapping("/user-id")
    public Result<Long> currentUserId() {
        return Result.success(
                UserContext.requireUserId()
        );
    }

    @GetMapping("/async-user-id")
    public Result<String> asyncUserId() {
        return Result.success(
                asyncUserContextService
                        .currentUserId()
                        .join()
        );
    }
}

通过 Gateway 访问:

GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer <token>

执行过程:

Gateway:
JWT sub = 1
        ↓
写入 X-User-Id: 1
        ↓
cloud-order:
UserContextInterceptor 读取请求头
        ↓
UserContext.setUserId(1)
        ↓
ContextController 读取 ThreadLocal
        ↓
返回 1

7.8 修改 CreateOrderRequest.java

package com.example.cloud.order.dto;

import lombok.Data;

@Data
public class CreateOrderRequest {

    private Long productId;

    private Integer quantity;
}

7.9 修改 OrderServiceImpl.java

核心变化:

不再使用 request.getUserId()
改为 UserContext.requireUserId()

关键代码:

Long userId = UserContext.requireUserId();

完整代码:

package com.example.cloud.order.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.api.product.dto.ProductDTO;
import com.example.cloud.api.user.dto.UserDTO;
import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.common.result.Result;
import com.example.cloud.order.client.ProductClient;
import com.example.cloud.order.client.UserClient;
import com.example.cloud.order.dto.CreateOrderRequest;
import com.example.cloud.order.entity.Order;
import com.example.cloud.order.mapper.OrderMapper;
import com.example.cloud.order.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

/**
 * 订单业务实现类。
 */
@Service
@RequiredArgsConstructor
public class OrderServiceImpl
        extends ServiceImpl<OrderMapper, Order>
        implements OrderService {

    private final UserClient userClient;

    private final ProductClient productClient;

    /**
     * 创建订单。
     *
     * 当前用户 ID 不再由客户端请求体传入。
     * 只从 UserContext 获取。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Order createOrder(CreateOrderRequest request) {
        if (request == null) {
            throw new BizException(
                    ErrorCode.PARAM_ERROR,
                    "订单参数不能为空"
            );
        }

        if (request.getProductId() == null) {
            throw new BizException(
                    ErrorCode.PARAM_ERROR,
                    "商品 ID 不能为空"
            );
        }

        if (request.getQuantity() == null
                || request.getQuantity() <= 0) {
            throw new BizException(
                    ErrorCode.PARAM_ERROR,
                    "购买数量必须大于 0"
            );
        }

        /*
         * 1. 从当前请求上下文获取可信用户 ID。
         *
         * 不再信任客户端请求体中的 userId。
         */
        Long userId = UserContext.requireUserId();

        /*
         * 2. 远程校验用户。
         */
        UserDTO user = requireData(
                userClient.getById(userId),
                "用户不存在或用户服务调用失败"
        );

        if (user.getStatus() == null
                || user.getStatus() != 1) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "用户已禁用"
            );
        }

        /*
         * 3. 远程查询商品。
         */
        ProductDTO product = requireData(
                productClient.getById(
                        request.getProductId()
                ),
                "商品不存在或商品服务调用失败"
        );

        if (product.getStatus() == null
                || product.getStatus() != 1) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "商品未上架"
            );
        }

        /*
         * 4. 远程扣减库存。
         */
        ensureSuccess(
                productClient.deductStock(
                        request.getProductId(),
                        request.getQuantity()
                ),
                "库存扣减失败"
        );

        /*
         * 5. 使用可信用户身份和真实商品信息落库。
         */
        Order order = new Order();

        order.setUserId(userId);
        order.setProductId(request.getProductId());
        order.setProductName(product.getName());
        order.setQuantity(request.getQuantity());

        BigDecimal amount = product.getPrice()
                .multiply(
                        BigDecimal.valueOf(
                                request.getQuantity()
                        )
                );

        order.setAmount(amount);
        order.setStatus(0);

        boolean success = save(order);

        if (!success) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "创建订单失败"
            );
        }

        return order;
    }

    private <T> T requireData(
            Result<T> result,
            String defaultMessage
    ) {
        if (result == null
                || result.getCode() != 0
                || result.getData() == null) {
            String message = result == null
                    ? defaultMessage
                    : result.getMessage();

            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    message
            );
        }

        return result.getData();
    }

    private void ensureSuccess(
            Result<?> result,
            String defaultMessage
    ) {
        if (result == null
                || result.getCode() != 0) {
            String message = result == null
                    ? defaultMessage
                    : result.getMessage();

            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    message
            );
        }
    }
}

完整创建订单流程变为:

校验请求参数
        ↓
从 UserContext 获取可信 userId
        ↓
远程查询用户
        ↓
远程查询商品
        ↓
远程扣减库存
        ↓
使用可信 userId 落库

7.10 先验证 Gateway 到订单服务的身份链路

完成 UserContext、UserContextInterceptor、UserContextWebMvcConfig、ContextController 和 OrderServiceImpl 改造后,开始验证。

这一轮只验证:

Gateway
    ↓
cloud-order
    ↓
MVC Interceptor
    ↓
ThreadLocal

也就是先确认订单服务本身能不能读取到 Gateway 透传的用户身份。

重新启动:

CloudOrderApplication
CloudProductApplication
CloudUserApplication

登录获取 Token:

POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

将登录返回的 Token 写入 HTTP Client 变量:

@token = 粘贴你的JWT

执行:

### Gateway → cloud-order → ThreadLocal
GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer {{token}}

预期返回:

{
  "code": 0,
  "message": "success",
  "data": 1
}

在这里插入图片描述

这证明下面这条链路已经跑通:

JWT
    ↓
Gateway
    ↓
X-User-Id
    ↓
MVC Interceptor
    ↓
ThreadLocal

也就是说:

Gateway 已经能从 JWT 中提取 userId;
Gateway 已经能把 userId 写入 X-User-Id;
cloud-order 已经能读取 X-User-Id;
UserContext 已经能在当前请求线程中保存 userId。

但这还不能证明服务间调用时身份也能继续传递。

因为:

cloud-order 调用 cloud-product

会发起一次新的 HTTP 请求。

这个问题需要下一步继续验证。


7.11 修改商品服务日志

为了观察用户身份是否能继续跨服务传播,需要在商品服务扣库存逻辑中打印当前用户 ID。

打开:

cloud-product
└── src/main/java
    └── com.example.cloud.product.service.impl
        └── ProductServiceImpl.java

如果类上还没有:

@Slf4j

则加入导入:

import lombok.extern.slf4j.Slf4j;

并在类上增加:

@Slf4j
@Service
public class ProductServiceImpl {
    // 原有代码
}

然后在扣库存方法开头加入日志:

log.info(
        "扣减库存,当前用户 ID:{}",
        UserContext.getUserId()
);

并加入导入:

import com.example.cloud.common.context.UserContext;

注意:

这里只增加日志,
不要修改原有库存扣减逻辑。

本轮的目的不是改变商品业务,而是观察:

cloud-product 能不能读取到当前用户身份。

7.12 第一次创建订单:观察身份丢失

通过 Gateway 创建订单:

### 创建订单:请求体不再传 userId
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "productId": 1,
  "quantity": 1
}

预期:

订单应当仍然可以创建成功。

原因是:

cloud-order 已经可以从 UserContext 中获取 userId;
创建订单不再依赖请求体中的 userId。

在这里插入图片描述

但是观察 cloud-product 日志,会看到类似:

扣减库存,当前用户 ID:null

在这里插入图片描述

这就是一个非常重要的现象。

它说明:

cloud-order 中有 UserContext
        ↓
但 ProductClient 发起的是新的 HTTP 请求
        ↓
ThreadLocal 只属于当前 JVM 当前线程
        ↓
不会自动跨网络传播
        ↓
cloud-product 中 userId = null

也就是说,当前只解决了:

Gateway → cloud-order

还没有解决:

cloud-order → cloud-product

的身份继续传递问题。

因此下一步需要引入:

Feign RequestInterceptor

让 cloud-order 发起 Feign 请求时,自动把当前用户身份继续写入请求头:

X-User-Id: 1

这样 cloud-product 才能继续通过 UserContextInterceptor 读取到当前用户身份。

7.13 Feign RequestInterceptor 是什么

现在 cloud-order 已经能拿到用户身份。

但 cloud-order 还会通过 Feign 调用:

cloud-user
cloud-product

此时会发起新的 HTTP 请求。

ThreadLocal 不会自动跨网络传递。

所以需要在 Feign 发请求前,把当前用户 ID 放到请求头中。

这就是:

RequestInterceptor

它适合统一添加:

X-User-Id
X-Trace-Id
租户 ID
语言
内部调用标识

7.14 让 Feign 继续透传用户身份:UserContextFeignInterceptor.java

package com.example.cloud.order.client.interceptor;

import com.example.cloud.common.context.UserContext;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class UserContextFeignInterceptor
        implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        Long userId = UserContext.getUserId();

        log.info(
                "Feign 准备发送请求,线程={},url={},当前用户 ID={}",
                Thread.currentThread().getName(),
                template.url(),
                userId
        );

        if (userId == null) {
            return;
        }

        template.header(
                UserContext.USER_ID_HEADER,
                String.valueOf(userId)
        );
    }
}

在这里插入图片描述


7.12 第二次创建订单:验证 Feign 身份透传

再来尝试创建订单。

在这里插入图片描述

日志显示:

cloud-order:
Feign 准备发送请求,线程=pool-3-thread-3,
url=/products/1/deduct-stock?quantity=1,
当前用户 ID=null

cloud-product:
接收请求,uri=/products/1/deduct-stock,
线程=http-nio-9300-exec-4,
X-User-Id=null

ProductServiceImpl:
扣减库存,当前用户 ID:null

这说明:

UserContextFeignInterceptor 生效了
但它执行时 UserContext 已经是 null

8、第四阶段:CircuitBreaker 线程切换导致身份丢失

8.1 为什么 RequestInterceptor 读取到 null

第 7 章中为了 Feign 降级,开启了:

feign:
  circuitbreaker:
    enabled: true

开启后,Feign 调用会被 CircuitBreaker 包装。

实际日志显示:

线程=pool-3-thread-3

而不是:

线程=http-nio-9400-exec-1

这说明 Feign 调用已经切换到了 CircuitBreaker 使用的线程池中。

普通 ThreadLocal 不能跨线程传递。

所以:

Tomcat 请求线程:
UserContext = 1

CircuitBreaker 工作线程:
UserContext = null

8.2 对照实验:关闭 CircuitBreaker

临时修改 Nacos:

feign:
  circuitbreaker:
    enabled: false

重启 cloud-order 后再次创建订单。

在这里插入图片描述
在这里插入图片描述

日志显示:

cloud-order:
Feign 准备发送请求,线程=http-nio-9400-exec-1,
url=/products/1/deduct-stock?quantity=1,
当前用户 ID=1

cloud-product:
接收请求,uri=/products/1/deduct-stock,
线程=http-nio-9300-exec-1,
X-User-Id=1

ProductServiceImpl:
扣减库存,当前用户 ID:1

这证明:

template.header(...) 没问题
UserContextFeignInterceptor 没问题
问题来自 CircuitBreaker 线程切换

但不能为了身份透传成功,就放弃 CircuitBreaker。


8.3 在 CircuitBreaker 线程池中传播用户上下文:UserContextAwareExecutorService.java

前面已经做过对照实验。

当关闭 CircuitBreaker 时:

feign:
  circuitbreaker:
    enabled: false

Feign 调用日志显示:

线程=http-nio-9400-exec-1
当前用户 ID=1

说明 Feign 调用仍然在原来的 Tomcat 请求线程中执行,所以 UserContext 没有丢失。

但当开启 CircuitBreaker 时:

feign:
  circuitbreaker:
    enabled: true

Feign 调用日志显示:

线程=pool-3-thread-3
当前用户 ID=null

这说明 Feign 调用被 CircuitBreaker 包装后,实际执行线程已经发生了变化。

原来的请求线程中:

UserContext = 1

但 CircuitBreaker 的工作线程中:

UserContext = null

根本原因是:

ThreadLocal 只属于当前线程,
不会自动复制到线程池中的另一个线程。

所以,仅仅有 UserContextFeignInterceptor 还不够。

因为它执行时已经处于 CircuitBreaker 的工作线程中,此时 UserContext.getUserId() 已经变成了 null。

修复思路是:

任务提交时:
捕获父线程中的 UserContext

任务执行前:
把捕获到的 userId 恢复到工作线程

任务执行后:
清理或恢复工作线程原有上下文

这正是 UserContextAwareExecutorService 要做的事情。

它不是一个新的业务线程池,而是对已有 ExecutorService 的包装:

原始 ExecutorService
        ↓
UserContextAwareExecutorService 包装
        ↓
提交任务时捕获 userId
        ↓
执行任务前恢复 userId
        ↓
任务结束后清理 userId

为什么任务结束后必须清理?

因为线程池会复用线程。

如果不清理,可能出现:

用户 A 的任务:
userId = 1
        ↓
线程回到线程池
        ↓
用户 B 的任务复用同一线程
        ↓
错误读到 userId = 1

这就是用户身份串线,属于非常严重的问题。

所以本类的核心原则是:

捕获
恢复
执行
清理

下面创建 UserContextAwareExecutorService.java

package com.example.cloud.order.config;

import com.example.cloud.common.context.UserContext;

import java.util.List;
import java.util.Objects;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * 用户上下文感知线程池包装器。
 *
 * 作用:
 * 将提交任务线程中的 UserContext,
 * 安全传递到真正执行任务的线程。
 *
 * 当前主要用于:
 * Resilience4j CircuitBreaker / TimeLimiter。
 */
public class UserContextAwareExecutorService
        extends AbstractExecutorService {

    private final ExecutorService delegate;

    public UserContextAwareExecutorService(
            ExecutorService delegate
    ) {
        this.delegate = delegate;
    }

    @Override
    public void execute(Runnable command) {
        Objects.requireNonNull(command, "command");

        /*
         * 提交任务时仍然位于父线程。
         *
         * 此时捕获父线程中的用户身份。
         */
        Long capturedUserId = UserContext.getUserId();

        delegate.execute(() -> {
            /*
             * 保存工作线程原有值。
             *
             * 线程池会复用线程,
             * 因此任务结束后必须恢复或清理。
             */
            Long previousUserId = UserContext.getUserId();

            try {
                if (capturedUserId == null) {
                    UserContext.clear();
                } else {
                    UserContext.setUserId(capturedUserId);
                }

                command.run();
            } finally {
                if (previousUserId == null) {
                    UserContext.clear();
                } else {
                    UserContext.setUserId(previousUserId);
                }
            }
        });
    }

    @Override
    public void shutdown() {
        delegate.shutdown();
    }

    @Override
    public List<Runnable> shutdownNow() {
        return delegate.shutdownNow();
    }

    @Override
    public boolean isShutdown() {
        return delegate.isShutdown();
    }

    @Override
    public boolean isTerminated() {
        return delegate.isTerminated();
    }

    @Override
    public boolean awaitTermination(
            long timeout,
            TimeUnit unit
    ) throws InterruptedException {
        return delegate.awaitTermination(timeout, unit);
    }
}


8.4 将上下文感知线程池交给 CircuitBreaker:Resilience4jExecutorConfig.java

UserContextAwareExecutorService 只是定义了一个“能传播 UserContext 的线程池包装器”。

但如果不把它交给 Resilience4j CircuitBreaker 使用,Feign 调用仍然会继续使用默认线程池。

因此还需要创建配置类:

Resilience4jExecutorConfig
package com.example.cloud.order.config;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cloud.client.circuitbreaker.Customizer;
import org.springframework.cloud.circuitbreaker.resilience4j
        .Resilience4JCircuitBreakerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Resilience4j CircuitBreaker 线程池配置。
 *
 * 为什么需要自定义?
 *
 * Feign 开启 CircuitBreaker 后,
 * 远程调用可能切换到线程池中执行。
 *
 * 普通 ThreadLocal 不会自动跨线程传递,
 * 因此需要包装 ExecutorService。
 */
@Configuration
public class Resilience4jExecutorConfig {

    /**
     * 创建用户上下文感知线程池。
     *
     * destroyMethod = "shutdown":
     * Spring 容器关闭时,自动关闭线程池。
     */
    @Bean(
            name = "userContextAwareCircuitBreakerExecutor",
            destroyMethod = "shutdown"
    )
    public ExecutorService userContextAwareCircuitBreakerExecutor() {
        AtomicInteger index = new AtomicInteger();

        ThreadFactory threadFactory = task -> {
            Thread thread = new Thread(task);

            thread.setName(
                    "resilience4j-context-"
                            + index.incrementAndGet()
            );

            /*
             * 学习项目中设置为守护线程。
             *
             * 正式项目还需要结合:
             * 核心线程数
             * 最大线程数
             * 队列容量
             * 拒绝策略
             * 监控指标
             */
            thread.setDaemon(true);

            return thread;
        };

        ExecutorService delegate =
                Executors.newFixedThreadPool(
                        10,
                        threadFactory
                );

        return new UserContextAwareExecutorService(
                delegate
        );
    }

    /**
     * 将自定义线程池交给 Resilience4j CircuitBreaker。
     */
    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory>
            resilience4jExecutorCustomizer(
                    @Qualifier(
                            "userContextAwareCircuitBreakerExecutor"
                    )
                    ExecutorService executorService
            ) {
        return factory ->
                factory.configureExecutorService(
                        executorService
                );
    }
}


8.5 恢复 CircuitBreaker 后验证

恢复 Nacos:

feign:
  circuitbreaker:
    enabled: true

重启 cloud-order 后再次创建订单。

在这里插入图片描述

在这里插入图片描述

实际验证结果:

cloud-order:
Feign 拦截器日志中 userId=1

cloud-product:
X-User-Id=1

ProductServiceImpl:
扣减库存,当前用户 ID:1

说明:

CircuitBreaker 开启
        ↓
线程发生切换
        ↓
上下文感知 ExecutorService 捕获并恢复 UserContext
        ↓
Feign RequestInterceptor 成功写入 X-User-Id
        ↓
cloud-product 成功读取用户身份

8.6 直接访问订单服务会发生什么

在cloud-gateway/src/test/http/gateway.http追加:

### 绕过 Gateway 直接访问 cloud-order
### 预期:用户身份缺失
POST http://localhost:9400/orders
Content-Type: application/json

{
  "productId": 1,
  "quantity": 1
}

预期:

{
  "code": 40100,
  "message": "用户身份缺失",
  "data": null
}

执行直接访问↓

在这里插入图片描述
原因:

请求绕过 Gateway
        ↓
没有 X-User-Id
        ↓
UserContext.requireUserId() 失败

这说明订单服务已经不再信任请求体中的 userId。

但需要注意,攻击者如果能够直接访问内部端口,仍然可能手工伪造:

X-User-Id: 999999

因此真实部署还必须限制:

业务服务端口不对公网开放
只允许 Gateway 或可信内网访问

8.7 为什么不用 InheritableThreadLocal

InheritableThreadLocal 只适合:

父线程临时创建新子线程

但线程池中的线程通常早已存在,并且会反复复用。

在这种场景中,它容易失效或造成身份串线。

因此更推荐:

任务提交时捕获上下文
任务执行前恢复上下文
任务结束后清理上下文

9、第五阶段:@Async 异步线程身份丢失与 TaskDecorator 修复

9.1 @Async 是什么

@Async 表示异步执行。

普通方法调用:

调用方线程
    ↓
执行方法
    ↓
等待方法执行完成

@Async 方法调用:

调用方线程
    ↓
提交任务到线程池
    ↓
异步线程执行方法

常见用途:

发送通知
记录审计日志
导出文件
异步计算
无需阻塞主流程的附加任务

但它会带来一个问题:

线程切换后 ThreadLocal 丢失

9.2 OrderAsyncConfig.java 初始版本

先创建不带 TaskDecorator 的版本,用于复现故障:

package com.example.cloud.order.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class OrderAsyncConfig {

    @Bean("orderAsyncExecutor")
    public Executor orderAsyncExecutor() {
        ThreadPoolTaskExecutor executor =
                new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("order-async-");

        executor.initialize();

        return executor;
    }
}

9.3 AsyncUserContextService.java

package com.example.cloud.order.service;

import com.example.cloud.common.context.UserContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;

@Slf4j
@Service
public class AsyncUserContextService {

    @Async("orderAsyncExecutor")
    public CompletableFuture<String> currentUserId() {
        Long userId = UserContext.getUserId();

        log.info(
                "异步任务执行,线程={},当前用户 ID={}",
                Thread.currentThread().getName(),
                userId
        );

        String result = userId == null
                ? "null"
                : String.valueOf(userId);

        return CompletableFuture.completedFuture(result);
    }
}

在这里插入图片描述


9.4 为什么 @Async 方法要放在单独 Bean 中

不要在同一个类里自己调用自己的 @Async 方法。

错误示例:

public Result<String> asyncUserId() {
    return Result.success(
            currentUserId().join()
    );
}

@Async
public CompletableFuture<String> currentUserId() {
    ...
}

这是同一个对象内部调用,不会经过 Spring 代理。

结果是:

@Async 不生效
方法仍然在原线程执行

所以这里单独创建:

AsyncUserContextService

由 Controller 注入后调用。


9.5 第一次测试:异步线程身份丢失

请求同步接口:

GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer {{token}}

返回:

{
  "code": 0,
  "message": "success",
  "data": 1
}

在这里插入图片描述

请求异步接口:

GET http://localhost:9000/api/order/context/async-user-id
Authorization: Bearer {{token}}

返回:

{
  "code": 0,
  "message": "success",
  "data": "null"
}

在这里插入图片描述

日志:

异步任务执行,
线程=order-async-1,
当前用户 ID=null

在这里插入图片描述

说明:

请求线程 UserContext=1
        ↓
@Async 切换到 order-async-1
        ↓
普通 ThreadLocal 无法跨线程
        ↓
异步线程 UserContext=null

9.6 TaskDecorator 是什么

TaskDecorator 可以理解为:

异步任务包装器

任务提交到线程池之前,它可以把原始 Runnable 包装一层。

原始 Runnable
        ↓
TaskDecorator 包装
        ↓
包装后的 Runnable
        ↓
线程池执行

适合处理:

用户身份
TraceId
MDC 日志上下文
租户 ID
语言环境
监控埋点

9.7 UserContextTaskDecorator.java

package com.example.cloud.order.config;

import com.example.cloud.common.context.UserContext;
import org.springframework.core.task.TaskDecorator;

public class UserContextTaskDecorator
        implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        Long capturedUserId = UserContext.getUserId();

        return () -> {
            Long previousUserId = UserContext.getUserId();

            try {
                if (capturedUserId == null) {
                    UserContext.clear();
                } else {
                    UserContext.setUserId(
                            capturedUserId
                    );
                }

                runnable.run();
            } finally {
                if (previousUserId == null) {
                    UserContext.clear();
                } else {
                    UserContext.setUserId(
                            previousUserId
                    );
                }
            }
        };
    }
}

9.8 OrderAsyncConfig.java 修复版本

package com.example.cloud.order.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class OrderAsyncConfig {

    @Bean("orderAsyncExecutor")
    public Executor orderAsyncExecutor() {
        ThreadPoolTaskExecutor executor =
                new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("order-async-");

        executor.setTaskDecorator(
                new UserContextTaskDecorator()
        );

        executor.initialize();

        return executor;
    }
}

在这里插入图片描述


9.9 第二次测试:异步线程身份恢复

再次请求:

GET http://localhost:9000/api/order/context/async-user-id
Authorization: Bearer {{token}}

在这里插入图片描述

返回:

{
  "code": 0,
  "message": "success",
  "data": "1"
}

在这里插入图片描述

日志:

异步任务执行,
线程=order-async-1,
当前用户 ID=1

说明:

Gateway 写入 X-User-Id
        ↓
cloud-order 请求线程 UserContext = 1
        ↓
调用 @Async 方法
        ↓
TaskDecorator 捕获 userId = 1
        ↓
任务进入 order-async-1
        ↓
TaskDecorator 恢复 userId = 1
        ↓
异步任务读取 UserContext = 1
        ↓
finally 清理或恢复线程上下文

10、本章关键概念对比

10.1 JWT 与 Session

对比项JWTSession
状态保存客户端保存 Token服务端保存 Session
服务端扩展更适合无状态扩展需要共享 Session
主动失效相对麻烦相对容易
微服务网关场景常用也能用,但要额外设计

10.2 Gateway 鉴权与下游拦截器

位置职责
Gateway GlobalFilter校验 JWT,决定请求能否进入系统
MVC Interceptor读取可信请求头,写入 UserContext
UserContext保存当前线程用户身份
Feign RequestInterceptor发起远程调用时继续透传身份

10.3 Feign RequestInterceptor 与 TaskDecorator

技术解决问题
Feign RequestInterceptorHTTP 远程调用时添加请求头
UserContextAwareExecutorServiceCircuitBreaker 线程池中传播上下文
TaskDecorator@Async 线程池中传播上下文

11、本章结论

本章完成了完整的统一鉴权主线:

cloud-auth 登录
BCrypt 密码校验
JWT 签发
Gateway GlobalFilter
白名单
Authorization: Bearer
JWT 校验
X-User-Id 透传
UserContext
ThreadLocal
MVC Interceptor
Feign RequestInterceptor
CircuitBreaker 线程切换故障
UserContextAwareExecutorService
@Async 身份丢失故障
TaskDecorator

到目前为止架构如图↓

在这里插入图片描述


12、下一章预告:Sentinel 限流、熔断与降级

到目前为止,项目的主链路已经打通:

客户端
    ↓
Gateway 统一入口
    ↓
JWT 统一鉴权
    ↓
cloud-order 创建订单
    ↓
OpenFeign 调用 cloud-user 校验用户
    ↓
OpenFeign 调用 cloud-product 查询商品、扣减库存
    ↓
订单创建完成

但是主链路打通之后,还会面临新的问题。

例如:

如果短时间内大量请求同时访问商品接口,会不会把商品服务打垮?

如果订单服务频繁调用商品服务,而商品服务响应很慢,订单服务会不会被拖垮?

如果下游服务异常,Feign fallback 应该怎么处理?

如果库存不足这种业务异常也进入 fallback,会不会掩盖真实业务错误?

这些问题已经不属于“服务能不能调用成功”,而属于:

服务稳定性
流量治理
异常隔离
熔断降级

所以第 10 章将进入:

Sentinel 限流、熔断与降级

Sentinel 是 Alibaba 开源的流量治理组件,它以“流量”为切入点,提供流量控制、并发限制、熔断降级、系统自适应保护等能力,用于提升微服务系统可靠性。

下一章主要学习:

1. Sentinel 是什么
2. Sentinel 和 Hystrix 有什么区别
3. 如何接入 Sentinel Dashboard
4. 如何给商品接口配置 QPS 限流
5. 如何观察接口被限流后的返回结果
6. 如何让 Feign 调用配合 Sentinel fallback
7. 如何复现“降级掩盖真实业务异常”的问题
8. 如何区分业务异常和系统异常
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐