生产级 Spring Boot 网关简单实现方案-CSDN博客

文章中存在不足。

如果这是Demo项目,我会给 85分。

如果这是准备上线承载真实流量的生产网关,我会给 65分,因为核心骨架有了,但还有不少生产级缺失点。

我按照真实互联网网关(阿里、字节、美团常见实践)帮你挑一下问题。

一、最大的问题:JWT校验位置不对

你现在:

validateToken()

validateSign()

转发

实际上应该:

限流

签名校验

JWT校验

权限校验

转发

原因:

JWT解析是CPU操作。

如果攻击者疯狂发垃圾请求:

Authorization: xxxxxxx

你的网关会先解析JWT。

CPU直接被打爆。

签名校验成本更低。

应该先挡掉非法请求。

二、缺少权限控制(RBAC)

你目前只做了:

header("X-User-Roles", roles)

但没校验。

例如:

/admin/**

应该:

ADMIN

才能访问。

生产环境一般增加:

RoleAuthorizationFilter

例如:

/api/admin/**

需要:

ADMIN

三、缺少IP黑名单

现在只有:

RateLimiter

但没有:

BlackList

生产上通常:

Redis

ip:blacklist:1.1.1.1

过滤器最前面:

if (blacklist.contains(ip))

直接:

403

四、缺少动态限流

现在:

20
10

写死。

生产一定会动态配置。

例如:

/api/login/**
100/s
/api/order/**
20/s
/api/pay/**
5/s

一般放:

* Redis
* Nacos
* Apollo

动态下发。

五、缺少用户维度限流

你只有:

IP限流

问题:

公司出口IP

1000个人
同一个IP

直接被限死。

通常:

userId + ip

双限流。

例如:

rate:user:10001
rate:ip:1.1.1.1

同时校验。

六、防重放基本没实现

这里:

checkReplay()

是假的。

生产真正实现:

Header:

X-Nonce
X-Timestamp
X-Sign

流程:

1 校验时间窗口
当前时间 - timestamp < 300s
2 查询Redis
nonce存在
直接拒绝
3 不存在
SETNX
过期5分钟

Redis:

nonce:xxxxxx

七、签名校验不完整

你自己也注释了:

只校验Query参数

问题很严重。

POST:

{
  "amount":100
}

攻击者改成:

{
  "amount":1
}

签名还能通过。

生产:

query
+
body
+
timestamp
+
nonce

一起参与签名。

八、Body缓存缺失

WebFlux读取Body:

request.getBody()

只能读一次。

你的签名要读取Body:

validateSign()

下游还要读Body。

必须:

CachedBodyOutputMessage
ServerHttpRequestDecorator

缓存请求体。

这是Spring Gateway生产开发最容易踩坑的地方之一。

九、TraceId生成方式不推荐

现在:

UUID.randomUUID()

生产一般:

MDC

结合:

SkyWalking
Zipkin
OpenTelemetry

例如:

traceId
spanId

形成完整调用链。

推荐:

OpenTelemetry

十、日志不够

现在:

path
status
cost
ip

生产还需要:

userId
clientId
traceId
requestSize
responseSize
userAgent
referer

特别是:

requestSize
responseSize

排查问题非常重要。

十一、缺少熔断降级

真实生产一定有:

CircuitBreaker

例如:

下游:

user-service

挂了。

应该:

fallback

而不是一直超时。

Spring Gateway直接支持:

Resilience4j

例如:

filters:
  - name: CircuitBreaker
    args:
      name: user-service
      fallbackUri: forward:/fallback

十二、缺少超时控制

生产必须配置:

spring:
  cloud:
    gateway:
      httpclient:
        connect-timeout: 3000
        response-timeout: 5s

否则:

下游卡死。

网关线程全部堆积。

十三、JWT密钥管理有问题

现在:

private static final String SECRET

生产绝对不能这样。

应该:

KMS

或者:

环境变量

或者:

Nacos加密配置

否则:

代码泄露=全站失陷。

十四、缺少接口灰度能力

生产网关很重要的一项:

Canary

例如:

X-Version=v2

路由到:

user-service-v2

普通用户:

user-service-v1

Spring Gateway非常适合做这个。

十五、缺少网关监控指标

你引入了:

Actuator

但没真正统计:

QPS
TPS
RT
错误率
限流次数
认证失败次数

通常接:

Prometheus +
Grafana

改造:

优先级排序:

P0(必须补)

1. Body缓存
2. 真正防重放
3. 用户维度限流
4. 熔断降级
5. 超时控制
6. RBAC权限控制

P1(建议补)

1. OpenTelemetry链路追踪
2. IP黑名单
3. 动态限流
4. 灰度发布
5. Prometheus监控

P2(大型系统必备)

1. API网关权限中心
2. OAuth2
3. 多租户
4. WAF防火墙
5. 风控规则引擎

--------------------------------------------以下为改造代码-------------------------------------------------

当前架构问题

1. 自定义 Redis Lua 限流器不如 Spring Gateway 官方 RedisRateLimiter
2. JWT、签名、防重放全部耦合在一个 Filter
3. Body 缓存方案缺失
4. 权限系统(RBAC)缺失
5. 熔断降级缺失
6. OpenTelemetry 链路追踪缺失
7. Redis 故障兜底缺失
8. 配置中心动态规则缺失
9. 网关监控指标缺失
10. 灰度发布能力缺失

直接按下面的生产架构重构:

gateway

├── config
│   ├── GatewayConfig
│   ├── RedisConfig
│   ├── SecurityConfig
│   ├── MetricsConfig

├── filter
│   ├── TraceFilter
│   ├── IpBlacklistFilter
│   ├── ReplayAttackFilter
│   ├── JwtAuthenticationFilter
│   ├── SignatureVerifyFilter
│   ├── RbacAuthorizationFilter
│   ├── AccessLogFilter

├── component
│   ├── JwtManager
│   ├── SignManager
│   ├── ReplayManager
│   ├── UserContext

├── ratelimit
│   ├── UserRateLimiter
│   ├── IpRateLimiter

├── fallback
│   ├── GatewayFallbackController

├── exception
│   ├── GlobalExceptionHandler

├── metrics
│   ├── GatewayMetricsCollector

└── route
    ├── GrayRoutePredicateFactory

既然是生产环境,直接按 Spring Boot 3.x + Spring Cloud Gateway + Redis Reactive + Micrometer + Prometheus + JWT(JJWT 0.11.5) 的标准来写。

GatewayConfig.java

负责:

* 全局Gateway配置
* HttpClient连接池
* 超时配置
* Header过滤

package com.xxx.gateway.config;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.cloud.gateway.config.HttpClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
@Configuration
public class GatewayConfig {
    /**
     * Gateway底层Netty连接池配置
     */
    @Bean
    public HttpClientCustomizer httpClientCustomizer() {
        return httpClient -> httpClient
                // TCP连接超时
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
                // 响应超时
                .responseTimeout(Duration.ofSeconds(5))
                // KeepAlive
                .keepAlive(true)
                // Read Timeout
                .doOnConnected(conn ->
                        conn.addHandlerLast(
                                        new ReadTimeoutHandler(5, TimeUnit.SECONDS))
                                .addHandlerLast(
                                        new WriteTimeoutHandler(5, TimeUnit.SECONDS)
                                )
                );
    }
}

RedisConfig.java

生产推荐:

* Jackson序列化
* ReactiveRedisTemplate
* StringRedisTemplate

package com.xxx.gateway.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.*;
@Configuration
public class RedisConfig {
    @Bean
    public ReactiveRedisTemplate<String,Object> reactiveRedisTemplate(
            ReactiveRedisConnectionFactory factory) {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        Jackson2JsonRedisSerializer<Object> serializer =
                new Jackson2JsonRedisSerializer<>(mapper,Object.class);
        RedisSerializationContext<String,Object> context =
                RedisSerializationContext
                        .<String,Object>newSerializationContext(
                                RedisSerializer.string())
                        .value(serializer)
                        .hashValue(serializer)
                        .build();
        return new ReactiveRedisTemplate<>(factory,context);
    }
    @Bean
    public StringRedisTemplate stringRedisTemplate(
            RedisConnectionFactory factory) {
        return new StringRedisTemplate(factory);
    }
}

SecurityConfig.java

生产网关:

仅负责认证。

不负责登录。

使用JWT。

package com.xxx.gateway.config;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.crypto.SecretKey;
@Configuration
public class SecurityConfig {
    /**
     * 建议配置中心管理
     */
    @Value("${gateway.jwt.secret}")
    private String secret;
    @Bean
    public SecretKey jwtSecretKey() {
        return Keys.hmacShaKeyFor(
                Decoders.BASE64.decode(secret)
        );
    }
}

对应配置:

gateway:
  jwt:
    secret: >
      dGhpc19pc19hX3Byb2R1Y3Rpb25fan
      d0X3NlY3JldF9rZXlfZm9yX2dhdGV3
      YXlfMjAyNl8wNl8wOA==

不要写:

secret: abc123

生产禁止。

MetricsConfig.java

生产必须接Prometheus。

Maven:

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

配置类:

package com.xxx.gateway.config;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.Getter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MetricsConfig {
    @Bean
    public GatewayMetrics gatewayMetrics(
            MeterRegistry registry) {
        return new GatewayMetrics(registry);
    }
    @Getter
    public static class GatewayMetrics {
        private final Counter requestCounter;
        private final Counter authFailCounter;
        private final Counter rateLimitCounter;
        private final Counter replayAttackCounter;
        private final Timer requestTimer;
        public GatewayMetrics(
                MeterRegistry registry) {
            this.requestCounter =
                    registry.counter(
                            "gateway_request_total");
            this.authFailCounter =
                    registry.counter(
                            "gateway_auth_fail_total");
            this.rateLimitCounter =
                    registry.counter(
                            "gateway_rate_limit_total");
            this.replayAttackCounter =
                    registry.counter(
                            "gateway_replay_attack_total");
            this.requestTimer =
                    registry.timer(
                            "gateway_request_duration");
        }
    }
}

application.yml(配套生产配置)

server:
  port: 8080
spring:
  application:
    name: gateway
  cloud:
    gateway:
      httpclient:
        connect-timeout: 3000
        response-timeout: 5s
      discovery:
        locator:
          enabled: true
  data:
    redis:
      host: redis-prod
      port: 6379
      password: xxxxxx
      timeout: 2000ms
management:
  endpoints:
    web:
      exposure:
        include: health,prometheus,metrics
  endpoint:
    health:
      show-details: always
gateway:
  jwt:
    secret: dGhpc19pc19hX3Byb2R1Y3Rpb25fan...

TraceFilter

职责:

* 生成 TraceId
* 写入 Header
* 写入 Reactor Context
* 写入 MDC
* 记录耗时

Order:

Ordered.HIGHEST_PRECEDENCE

最先执行。

package com.xxx.gateway.filter;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.UUID;
@Slf4j
@Component
public class TraceFilter implements GlobalFilter, Ordered {
    public static final String TRACE_ID = "traceId";
    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                             GatewayFilterChain chain) {
        String traceId =
                UUID.randomUUID()
                        .toString()
                        .replace("-", "");
        long startTime = System.currentTimeMillis();
        ServerHttpRequest request =
                exchange.getRequest()
                        .mutate()
                        .header("X-Trace-Id", traceId)
                        .build();
        exchange.getAttributes()
                .put(TRACE_ID, traceId);
        exchange.getAttributes()
                .put("startTime", startTime);
        MDC.put(TRACE_ID, traceId);
        return chain.filter(
                        exchange.mutate()
                                .request(request)
                                .build()
                )
                .doFinally(signal -> {
                    long cost =
                            System.currentTimeMillis()
                                    - startTime;
                    log.info(
                            "gateway request finish traceId={} path={} cost={}ms",
                            traceId,
                            request.getURI().getPath(),
                            cost
                    );
                    MDC.remove(TRACE_ID);
                });
    }
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

IpBlacklistFilter

职责:

Redis实时黑名单。

支持运营后台动态封禁。

Redis结构:

gateway:blacklist:ip:1.1.1.1

value:

1
package com.xxx.gateway.filter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Slf4j
@Component
@RequiredArgsConstructor
public class IpBlacklistFilter implements GlobalFilter, Ordered {
    private final ReactiveStringRedisTemplate redisTemplate;
    private static final String PREFIX =
            "gateway:blacklist:ip:";
    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                             GatewayFilterChain chain) {
        String ip = getClientIp(exchange);
        String key = PREFIX + ip;
        return redisTemplate
                .hasKey(key)
                .flatMap(exists -> {
                    if (Boolean.TRUE.equals(exists)) {
                        log.warn(
                                "ip blocked {}",
                                ip
                        );
                        exchange.getResponse()
                                .setStatusCode(
                                        HttpStatus.FORBIDDEN
                                );
                        exchange.getResponse()
                                .getHeaders()
                                .setContentType(
                                        MediaType.APPLICATION_JSON
                                );
                        byte[] bytes =
                                """
                                {
                                  "code":403,
                                  "msg":"ip blocked"
                                }
                                """
                                .getBytes();
                        return exchange
                                .getResponse()
                                .writeWith(
                                        Mono.just(
                                                exchange
                                                        .getResponse()
                                                        .bufferFactory()
                                                        .wrap(bytes)
                                        )
                                );
                    }
                    return chain.filter(exchange);
                });
    }
    @Override
    public int getOrder() {
        return -900;
    }
    private String getClientIp(
            ServerWebExchange exchange) {
        String xff =
                exchange.getRequest()
                        .getHeaders()
                        .getFirst("X-Forwarded-For");
        if (xff != null) {
            return xff.split(",")[0]
                    .trim();
        }
        return exchange
                .getRequest()
                .getRemoteAddress()
                .getAddress()
                .getHostAddress();
    }
}

ReplayAttackFilter

职责:

防重放攻击。

Header要求:

X-Nonce
X-Timestamp
X-Sign

生产流程:

1 校验时间窗口
2 校验nonce
3 Redis SETNX
4 成功继续
5 失败拒绝

package com.xxx.gateway.filter;
import com.xxx.gateway.component.ReplayManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Slf4j
@Component
@RequiredArgsConstructor
public class ReplayAttackFilter
        implements GlobalFilter, Ordered {
    private final ReplayManager replayManager;
    private static final long WINDOW =
            5 * 60 * 1000;
    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                             GatewayFilterChain chain) {
        String nonce =
                exchange.getRequest()
                        .getHeaders()
                        .getFirst("X-Nonce");
        String timestampStr =
                exchange.getRequest()
                        .getHeaders()
                        .getFirst("X-Timestamp");
        if (nonce == null ||
                timestampStr == null) {
            return reject(
                    exchange,
                    "missing nonce"
            );
        }
        long timestamp;
        try {
            timestamp =
                    Long.parseLong(timestampStr);
        } catch (Exception e) {
            return reject(
                    exchange,
                    "invalid timestamp"
            );
        }
        long now =
                System.currentTimeMillis();
        if (Math.abs(now - timestamp)
                > WINDOW) {
            return reject(
                    exchange,
                    "request expired"
            );
        }
        return replayManager
                .checkAndSaveNonce(
                        nonce,
                        WINDOW
                )
                .flatMap(pass -> {
                    if (!pass) {
                        return reject(
                                exchange,
                                "replay attack"
                        );
                    }
                    return chain.filter(exchange);
                });
    }
    @Override
    public int getOrder() {
        return -850;
    }
    private Mono<Void> reject(
            ServerWebExchange exchange,
            String msg) {
        exchange.getResponse()
                .setStatusCode(
                        HttpStatus.FORBIDDEN
                );
        exchange.getResponse()
                .getHeaders()
                .setContentType(
                        MediaType.APPLICATION_JSON
                );
        byte[] bytes =
                String.format(
                        """
                        {
                           "code":403,
                           "msg":"%s"
                        }
                        """,
                        msg
                ).getBytes();
        return exchange
                .getResponse()
                .writeWith(
                        Mono.just(
                                exchange
                                        .getResponse()
                                        .bufferFactory()
                                        .wrap(bytes)
                        )
                );
    }
}

当前过滤器执行顺序

生产建议:

TraceFilter
order = -1000

IpBlacklistFilter
order = -900

ReplayAttackFilter
order = -850

SignatureVerifyFilter
order = -800

JwtAuthenticationFilter
order = -700

RbacAuthorizationFilter
order = -600

RateLimitFilter
order = -500

AccessLogFilter
order = LOWEST_PRECEDENCE

前面那版代码里有个生产隐患:

MDC.put(...)

在 WebFlux 里并不可靠。

后面我给你的 AccessLogFilter 会优先从 exchange.getAttribute() 获取 TraceId,而不是直接依赖 MDC。

JwtAuthenticationFilter

职责:

* 校验JWT
* 解析用户信息
* 写入UserContext
* Header透传下游

Order:

-700
package com.xxx.gateway.filter;
import com.xxx.gateway.component.JwtManager;
import com.xxx.gateway.component.UserContext;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter
        implements GlobalFilter, Ordered {
    private final JwtManager jwtManager;
    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                             GatewayFilterChain chain) {
        String token =
                exchange.getRequest()
                        .getHeaders()
                        .getFirst(HttpHeaders.AUTHORIZATION);
        if (token == null || token.isBlank()) {
            return unauthorized(exchange, "token missing");
        }
        return jwtManager.parse(token)
                .flatMap(claims -> {
                    UserContext userContext =
                            buildUserContext(claims);
                    exchange.getAttributes()
                            .put(
                                    UserContext.KEY,
                                    userContext
                            );
                    ServerHttpRequest request =
                            exchange.getRequest()
                                    .mutate()
                                    .header(
                                            "X-User-Id",
                                            userContext.getUserId()
                                    )
                                    .header(
                                            "X-Username",
                                            userContext.getUsername()
                                    )
                                    .header(
                                            "X-Roles",
                                            String.join(
                                                    ",",
                                                    userContext.getRoles()
                                            )
                                    )
                                    .build();
                    return chain.filter(
                            exchange.mutate()
                                    .request(request)
                                    .build()
                    );
                })
                .onErrorResume(
                        e -> unauthorized(
                                exchange,
                                "token invalid"
                        )
                );
    }
    @Override
    public int getOrder() {
        return -700;
    }
    private UserContext buildUserContext(
            Claims claims) {
        return UserContext.builder()
                .userId(claims.getSubject())
                .username(
                        claims.get(
                                "username",
                                String.class
                        )
                )
                .roles(
                        claims.get(
                                "roles",
                                java.util.List.class
                        )
                )
                .build();
    }
    private Mono<Void> unauthorized(
            ServerWebExchange exchange,
            String msg) {
        exchange.getResponse()
                .setStatusCode(
                        HttpStatus.UNAUTHORIZED
                );
        return exchange.getResponse()
                .setComplete();
    }
}

SignatureVerifyFilter

职责:

校验:

X-Nonce
X-Timestamp
X-Sign

签名内容:

method
path
query
body
nonce
timestamp

Order:

-800

在 JWT 前执行。

package com.xxx.gateway.filter;
import com.xxx.gateway.component.SignManager;
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.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
@RequiredArgsConstructor
public class SignatureVerifyFilter
        implements GlobalFilter, Ordered {
    private final SignManager signManager;
    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                             GatewayFilterChain chain) {
        return signManager.verify(exchange)
                .flatMap(pass -> {
                    if (!pass) {
                        exchange.getResponse()
                                .setStatusCode(
                                        HttpStatus.FORBIDDEN
                                );
                        return exchange
                                .getResponse()
                                .setComplete();
                    }
                    return chain.filter(exchange);
                });
    }
    @Override
    public int getOrder() {
        return -800;
    }
}

RbacAuthorizationFilter

职责:

RBAC权限控制

例如:

gateway:
  auth:
    rules:
      - path: /admin/**
        roles:
          - ADMIN
      - path: /order/**
        roles:
          - USER
          - ADMIN
package com.xxx.gateway.filter;
import com.xxx.gateway.component.UserContext;
import lombok.extern.slf4j.Slf4j;
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.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.Map;
@Slf4j
@Component
public class RbacAuthorizationFilter
        implements GlobalFilter, Ordered {
    private final AntPathMatcher matcher =
            new AntPathMatcher();
    /**
     * 后面改成Nacos动态加载
     */
    private final Map<String, List<String>> ruleMap =
            Map.of(
                    "/admin/**",
                    List.of("ADMIN"),
                    "/order/**",
                    List.of("USER", "ADMIN")
            );
    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                             GatewayFilterChain chain) {
        String path =
                exchange.getRequest()
                        .getURI()
                        .getPath();
        UserContext userContext =
                exchange.getAttribute(
                        UserContext.KEY
                );
        if (userContext == null) {
            exchange.getResponse()
                    .setStatusCode(
                            HttpStatus.UNAUTHORIZED
                    );
            return exchange.getResponse()
                    .setComplete();
        }
        for (Map.Entry<String, List<String>> entry
                : ruleMap.entrySet()) {
            if (matcher.match(
                    entry.getKey(),
                    path
            )) {
                boolean pass =
                        userContext.getRoles()
                                .stream()
                                .anyMatch(
                                        entry.getValue()::contains
                                );
                if (!pass) {
                    exchange.getResponse()
                            .setStatusCode(
                                    HttpStatus.FORBIDDEN
                            );
                    return exchange.getResponse()
                            .setComplete();
                }
            }
        }
        return chain.filter(exchange);
    }
    @Override
    public int getOrder() {
        return -600;
    }
}

AccessLogFilter

职责:

统一访问日志

记录:

traceId
userId
path
method
status
cost
ip
ua
requestSize

Order:

LOWEST_PRECEDENCE

最后执行。

package com.xxx.gateway.filter;
import com.xxx.gateway.component.UserContext;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
public class AccessLogFilter
        implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                             GatewayFilterChain chain) {
        long start =
                System.currentTimeMillis();
        return chain.filter(exchange)
                .doFinally(signal -> {
                    long cost =
                            System.currentTimeMillis()
                                    - start;
                    UserContext user =
                            exchange.getAttribute(
                                    UserContext.KEY
                            );
                    Map<String, Object> logMap =
                            new HashMap<>();
                    logMap.put(
                            "traceId",
                            exchange.getAttribute(
                                    "traceId"
                            )
                    );
                    logMap.put(
                            "userId",
                            user == null
                                    ? null
                                    : user.getUserId()
                    );
                    logMap.put(
                            "path",
                            exchange.getRequest()
                                    .getURI()
                                    .getPath()
                    );
                    logMap.put(
                            "method",
                            exchange.getRequest()
                                    .getMethod()
                    );
                    logMap.put(
                            "status",
                            exchange.getResponse()
                                    .getStatusCode()
                    );
                    logMap.put(
                            "cost",
                            cost
                    );
                    logMap.put(
                            "ip",
                            exchange.getRequest()
                                    .getHeaders()
                                    .getFirst(
                                            "X-Forwarded-For"
                                    )
                    );
                    logMap.put(
                            "ua",
                            exchange.getRequest()
                                    .getHeaders()
                                    .getFirst(
                                            "User-Agent"
                                    )
                    );
                    log.info(
                            JSONUtil.toJsonStr(
                                    logMap
                            )
                    );
                });
    }
    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE;
    }
}

TraceFilter

IpBlacklistFilter

ReplayAttackFilter

SignatureVerifyFilter

JwtAuthenticationFilter

RbacAuthorizationFilter

RateLimitFilter(下一批)

AccessLogFilter

UserContext

package com.xxx.gateway.component;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserContext implements Serializable {
    public static final String KEY = "USER_CONTEXT";
    /**
     * 用户ID
     */
    private String userId;
    /**
     * 用户名
     */
    private String username;
    /**
     * 角色
     */
    private List<String> roles;
    /**
     * 租户ID
     */
    private String tenantId;
    /**
     * 客户端ID
     */
    private String clientId;
}

JwtManager

生产版 JWT 管理器。

package com.xxx.gateway.component;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import javax.crypto.SecretKey;
@Component
@RequiredArgsConstructor
public class JwtManager {
    private final SecretKey jwtSecretKey;
    public Mono<Claims> parse(String authorization) {
        return Mono.fromCallable(() -> {
            if (authorization == null) {
                throw new RuntimeException("token missing");
            }
            if (!authorization.startsWith("Bearer ")) {
                throw new RuntimeException("invalid token");
            }
            String token =
                    authorization.substring(7);
            return Jwts.parserBuilder()
                    .setSigningKey(jwtSecretKey)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        });
    }
    public String getUserId(Claims claims) {
        return claims.getSubject();
    }
    public String getUsername(Claims claims) {
        return claims.get(
                "username",
                String.class
        );
    }
}

ReplayManager

Redis防重放。

核心:

SETNX
EXPIRE

原子执行。

package com.xxx.gateway.component;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class ReplayManager {
    private final ReactiveStringRedisTemplate redisTemplate;
    private static final String PREFIX =
            "gateway:nonce:";
    /**
     * true:
     * 首次请求
     *
     * false:
     * 重放请求
     */
    public Mono<Boolean> checkAndSaveNonce(
            String nonce,
            long ttlMillis) {
        String key =
                PREFIX + nonce;
        return redisTemplate
                .opsForValue()
                .setIfAbsent(
                        key,
                        "1",
                        Duration.ofMillis(ttlMillis)
                );
    }
}

SignManager

生产建议:

Header:

X-App-Id
X-Nonce
X-Timestamp
X-Sign

签名算法:

method
+
path
+
query
+
timestamp
+
nonce
+
secret

HmacSHA256

package com.xxx.gateway.component;
import cn.hutool.crypto.digest.HMac;
import cn.hutool.crypto.digest.HmacAlgorithm;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.TreeMap;
@Component
@RequiredArgsConstructor
public class SignManager {
    @Value("${gateway.sign.secret}")
    private String signSecret;
    public Mono<Boolean> verify(
            ServerWebExchange exchange) {
        ServerHttpRequest request =
                exchange.getRequest();
        String nonce =
                request.getHeaders()
                        .getFirst("X-Nonce");
        String timestamp =
                request.getHeaders()
                        .getFirst("X-Timestamp");
        String sign =
                request.getHeaders()
                        .getFirst("X-Sign");
        if (nonce == null ||
                timestamp == null ||
                sign == null) {
            return Mono.just(false);
        }
        String content =
                buildContent(
                        request,
                        nonce,
                        timestamp
                );
        String localSign =
                generateSign(content);
        return Mono.just(
                localSign.equalsIgnoreCase(sign)
        );
    }
    private String buildContent(
            ServerHttpRequest request,
            String nonce,
            String timestamp) {
        TreeMap<String, String> params =
                new TreeMap<>(
                        request.getQueryParams()
                                .toSingleValueMap()
                );
        StringBuilder sb =
                new StringBuilder();
        sb.append(
                request.getMethod()
                        .name()
        );
        sb.append("|");
        sb.append(
                request.getURI()
                        .getPath()
        );
        sb.append("|");
        params.forEach((k, v) -> {
            sb.append(k)
                    .append("=")
                    .append(v)
                    .append("&");
        });
        sb.append("|");
        sb.append(timestamp);
        sb.append("|");
        sb.append(nonce);
        return sb.toString();
    }
    public String generateSign(
            String content) {
        HMac hmac =
                new HMac(
                        HmacAlgorithm.HmacSHA256,
                        signSecret.getBytes(
                                StandardCharsets.UTF_8
                        )
                );
        return hmac.digestHex(content);
    }
}

配套配置

gateway:
  sign:
    secret: your-sign-secret
  jwt:
    secret: xxxxxxxxxxxxxxxxxxxxxxxxx

不建议用你前面那套自己拼的 RedisTemplate.execute(script -> script.eval(...)) 方式。

生产上建议:

Redis
+
Lua
+
ReactiveRedisTemplate
+
令牌桶(Token Bucket)

支持:

* 集群部署
* 多实例共享限流状态
* 用户限流
* IP限流
* Redis异常降级

先定义公共返回对象

package com.xxx.gateway.ratelimit;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class RateLimitResult {
    /**
     * 是否允许
     */
    private boolean allowed;
    /**
     * 剩余令牌
     */
    private long remainTokens;
}

公共Lua脚本

建议放:

resources/lua/token_bucket.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local lastTime = tonumber(redis.call('HGET', key, 'lastTime') or now)
local tokens = tonumber(redis.call('HGET', key, 'tokens') or capacity)
local delta = math.max(0, now - lastTime)
local refill = delta * rate / 1000
tokens = math.min(capacity, tokens + refill)
local allowed = 0
if tokens >= 1 then
    tokens = tokens - 1
    allowed = 1
end
redis.call('HSET', key, 'tokens', tokens)
redis.call('HSET', key, 'lastTime', now)
redis.call('EXPIRE', key, 300)
return {
    allowed,
    math.floor(tokens)
}

抽象基类

避免代码重复。

package com.xxx.gateway.ratelimit;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RequiredArgsConstructor
public abstract class AbstractRateLimiter {
    protected final ReactiveStringRedisTemplate redisTemplate;
    private RedisScript<List> script;
    protected RedisScript<List> getScript() {
        if (script != null) {
            return script;
        }
        try {
            String lua =
                    new String(
                            new ClassPathResource(
                                    "lua/token_bucket.lua"
                            )
                                    .getInputStream()
                                    .readAllBytes(),
                            StandardCharsets.UTF_8
                    );
            script =
                    RedisScript.of(
                            lua,
                            List.class
                    );
            return script;
        } catch (Exception e) {
            throw new RuntimeException(
                    "load lua fail",
                    e
            );
        }
    }
    protected Mono<RateLimitResult> execute(
            String key,
            long capacity,
            long rate) {
        long now =
                System.currentTimeMillis();
        return redisTemplate.execute(
                        getScript(),
                        List.of(key),
                        String.valueOf(capacity),
                        String.valueOf(rate),
                        String.valueOf(now)
                )
                .next()
                .map(result -> {
                    Long allowed =
                            Long.valueOf(
                                    result.get(0)
                                            .toString()
                            );
                    Long remain =
                            Long.valueOf(
                                    result.get(1)
                                            .toString()
                            );
                    return new RateLimitResult(
                            allowed == 1,
                            remain
                    );
                })
                .onErrorResume(e -> {
                    /*
                     * Redis故障降级
                     *
                     * 放行
                     */
                    return Mono.just(
                            new RateLimitResult(
                                    true,
                                    -1
                            )
                    );
                });
    }
}

UserRateLimiter

按用户限流。

适合:

下单
支付
短信发送

package com.xxx.gateway.ratelimit;
import com.xxx.gateway.component.UserContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Slf4j
@Component
public class UserRateLimiter
        extends AbstractRateLimiter {
    private static final String PREFIX =
            "gateway:user:rate:";
    /**
     * 每秒20个请求
     */
    private static final long RATE = 20;
    /**
     * 最大容量100
     */
    private static final long CAPACITY = 100;
    public UserRateLimiter(
            ReactiveStringRedisTemplate redisTemplate) {
        super(redisTemplate);
    }
    public Mono<RateLimitResult> isAllowed(
            UserContext userContext) {
        if (userContext == null) {
            return Mono.just(
                    new RateLimitResult(
                            true,
                            -1
                    )
            );
        }
        String key =
                PREFIX
                        + userContext.getUserId();
        return execute(
                key,
                CAPACITY,
                RATE
        );
    }
}

IpRateLimiter

按IP限流。

适合:

登录
注册
验证码

package com.xxx.gateway.ratelimit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Slf4j
@Component
public class IpRateLimiter
        extends AbstractRateLimiter {
    private static final String PREFIX =
            "gateway:ip:rate:";
    /**
     * 每秒10个
     */
    private static final long RATE = 10;
    /**
     * 最大容量50
     */
    private static final long CAPACITY = 50;
    public IpRateLimiter(
            ReactiveStringRedisTemplate redisTemplate) {
        super(redisTemplate);
    }
    public Mono<RateLimitResult> isAllowed(
            String ip) {
        String key =
                PREFIX + ip;
        return execute(
                key,
                CAPACITY,
                RATE
        );
    }
}

推荐增加统一过滤器

后面你应该再加:

RateLimitFilter

顺序:

TraceFilter

IpBlacklistFilter

ReplayAttackFilter

SignatureVerifyFilter

JwtAuthenticationFilter

RbacAuthorizationFilter

RateLimitFilter

AccessLogFilter

逻辑:

IP限流
↓
用户限流
↓
通过

即:

ipLimiter.isAllowed(ip)
↓
userLimiter.isAllowed(user)
↓
chain.filter()

生产环境还建议再升级

目前这一版已经能上线。

但大流量场景(10万+QPS)建议把:

gateway:user:rate:10001
gateway:ip:rate:1.1.1.1

升级为:

gateway:rate:user:{10001}
gateway:rate:ip:{1.1.1.1}

利用 Redis Cluster 的 Hash Tag,保证同一个用户的限流数据始终落在同一个 Slot,避免跨 Slot 问题。

一个关键点:

GlobalExceptionHandler 对 Spring Cloud Gateway 的 GlobalFilter 里的异常并不能完全兜住。

很多异常发生在:

GlobalFilter

Netty

Gateway Routing

这时候应该配合:

ErrorWebExceptionHandler

而不是单纯:

@RestControllerAdvice

GatewayFallbackController

用于:

filters:
  - name: CircuitBreaker
    args:
      name: user-service
      fallbackUri: forward:/fallback/user
package com.xxx.gateway.fallback;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/fallback")
public class GatewayFallbackController {
    @GetMapping("/{service}")
    public Mono<Map<String, Object>> fallback(
            @PathVariable String service) {
        log.error("service fallback {}", service);
        Map<String, Object> result =
                new HashMap<>();
        result.put("code", 503);
        result.put("msg", service + " unavailable");
        result.put("time", LocalDateTime.now());
        return Mono.just(result);
    }
}

GlobalExceptionHandler

生产版。

不要用:

@ControllerAdvice

而是:

ErrorWebExceptionHandler
package com.xxx.gateway.exception;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
@Order(-1)
@RequiredArgsConstructor
public class GlobalExceptionHandler
        implements ErrorWebExceptionHandler {
    private final ObjectMapper objectMapper;
    @Override
    public Mono<Void> handle(
            ServerWebExchange exchange,
            Throwable ex) {
        log.error(
                "gateway exception",
                ex
        );
        HttpStatus status =
                HttpStatus.INTERNAL_SERVER_ERROR;
        Map<String, Object> body =
                new HashMap<>();
        body.put("code", status.value());
        body.put("msg", ex.getMessage());
        body.put("time", LocalDateTime.now());
        try {
            byte[] bytes =
                    objectMapper.writeValueAsBytes(
                            body
                    );
            exchange.getResponse()
                    .setStatusCode(status);
            exchange.getResponse()
                    .getHeaders()
                    .setContentType(
                            MediaType.APPLICATION_JSON
                    );
            DataBuffer buffer =
                    exchange.getResponse()
                            .bufferFactory()
                            .wrap(bytes);
            return exchange
                    .getResponse()
                    .writeWith(
                            Mono.just(buffer)
                    );
        } catch (Exception e) {
            return Mono.error(e);
        }
    }
}

GatewayMetricsCollector

这里直接接 Micrometer。

配合前面你的:

MetricsConfig
package com.xxx.gateway.metrics;
import io.micrometer.core.instrument.*;
import lombok.Getter;
import org.springframework.stereotype.Component;
@Component
@Getter
public class GatewayMetricsCollector {
    private final Counter requestCounter;
    private final Counter authFailCounter;
    private final Counter replayCounter;
    private final Counter rateLimitCounter;
    private final Counter blacklistCounter;
    private final Timer requestTimer;
    public GatewayMetricsCollector(
            MeterRegistry registry) {
        this.requestCounter =
                registry.counter(
                        "gateway_request_total"
                );
        this.authFailCounter =
                registry.counter(
                        "gateway_auth_fail_total"
                );
        this.replayCounter =
                registry.counter(
                        "gateway_replay_total"
                );
        this.rateLimitCounter =
                registry.counter(
                        "gateway_rate_limit_total"
                );
        this.blacklistCounter =
                registry.counter(
                        "gateway_blacklist_total"
                );
        this.requestTimer =
                registry.timer(
                        "gateway_request_duration"
                );
    }
}

使用示例

例如:

private final GatewayMetricsCollector metrics;

认证失败:

metrics.getAuthFailCounter()
       .increment();

限流:

metrics.getRateLimitCounter()
       .increment();

请求:

metrics.getRequestCounter()
       .increment();

GrayRoutePredicateFactory

生产灰度发布。

支持:

X-Version: gray

或者:

X-Version: v2

配置

spring:
  cloud:
    gateway:
      routes:
        - id: user-gray
          uri: lb://user-service-gray
          predicates:
            - Gray=v2

实现

package com.xxx.gateway.route;
import lombok.Data;
import org.springframework.cloud.gateway.handler.predicate.AbstractRoutePredicateFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.function.Predicate;
@Component
public class GrayRoutePredicateFactory
        extends AbstractRoutePredicateFactory<
        GrayRoutePredicateFactory.Config> {
    public GrayRoutePredicateFactory() {
        super(Config.class);
    }
    @Override
    public List<String> shortcutFieldOrder() {
        return List.of("version");
    }
    @Override
    public Predicate<ServerHttpRequest> apply(
            Config config) {
        return request -> {
            String version =
                    request.getHeaders()
                            .getFirst(
                                    "X-Version"
                            );
            return config.getVersion()
                    .equalsIgnoreCase(version);
        };
    }
    @Data
    public static class Config {
        private String version;
    }
}

生产增强版灰度

实际上大厂不会只做:

X-Version=v2

而是:

用户ID
手机号
租户
城市
百分比
白名单

例如:

userId % 100 < 10

实现:

10%
灰度用户

这种才是真正生产级。

Logo

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

更多推荐