从0到1搭建 API 网关:限流+鉴权+路由+日志,手写一个迷你 Gateway

引言

团队新来了个挺优秀的同学,简历上写着"深度使用 Spring Cloud Gateway"。线上网关出了个诡异问题:某个接口偶发 502,我问他"转发这块的连接池配置在哪",他愣了一下:"网关不就是个配置文件吗?"

不是嘲讽谁。用框架和懂原理,从来是两回事。 Spring Cloud Gateway 把 3000 多个类打包成一个 starter,也把"网关到底怎么工作"这件事打包黑盒里了:

  • 为什么全局过滤器(GlobalFilter)的 order 会决定整个链路行为?
  • 为什么网关的 EventLoop 线程上绝不能写阻塞代码?
  • 令牌桶限流怎么做到并发下"不超卖"?
  • 负载均衡的"平滑加权"和普通加权差在哪?
  • 一次转发请求头要改写哪些字段,漏改一个就是 502?

这些问题,读框架源码要翻几千行;自己写一个 800 行的迷你网关,全部答案扑面而来

所以这篇文章我们做一件事:不依赖 Spring Cloud Gateway,甚至不依赖 Spring,用 Netty + 800 行核心代码,手写一个能跑的网关,包含完整四大能力:路由转发(YAML 路由表)、令牌桶限流、JWT 鉴权、请求/响应日志,外加三种负载均衡(轮询/随机/平滑加权)。

最终效果先睹为快:

$ curl "http://localhost:8080/api/login?user=tom"
{"token":"eyJhbGciOiJIUzI1NiJ9..."}

$ curl -H "Authorization: Bearer eyJhbGciOi..." http://localhost:8080/api/user/123
{"service":"user-service-1","port":9001,...}   # 再打一次变成 user-service-2,轮询生效

$ curl http://localhost:8080/api/user/123
{"code":401,"message":"缺少 Bearer Token"}      # 鉴权生效

# /api/order/** 限流 5 次/秒,连打 11 次后:
{"code":429,"message":"请求过于频繁,请稍后再试"} # 限流生效

完整源码已整理上传 GitHub(地址见文末),每章代码都可独立运行。开始之前记住一句话:我们的目的不是重复造轮子,而是把轮子拆开,看看辐条是怎么编的。


一、网关到底干了什么:先拆解,再动手

1.1 网关的本质

扒掉所有花哨功能,API 网关的本质是一个可编程的反向代理

┌─────────────── API 网关 ────────────────┐
客户端 ────→│ 日志 → 限流 → 鉴权 → 路由匹配 → 负载均衡  │────→ 上游服务 A
            │    (策略层)      (转发层)             │
            └─────────────────────────────────────────┘
  • 反向代理:替上游服务收请求、转发、回传响应(Nginx 干的事)
  • 可编程:在"收"和"转"之间插入任意策略逻辑(Nginx 需要写 Lua/插件才能干的事)

所有网关——Nginx、Spring Cloud Gateway、Kong、自研——万变不离这个模型。差别只在:策略怎么声明(注解/YAML/插件)、转发用什么实现(同步/异步)、策略和转发怎么编排。

1.2 一个请求的完整生命周期

我们迷你网关的处理流水线,后面每一章对应其中一格:

① 客户端连接
     ↓ Netty Worker EventLoop(非阻塞,全程不切换线程)
② HttpServerCodec        解码 HTTP 字节流 → HttpRequest 对象
     ↓
③ HttpObjectAggregator   聚合请求体 → FullHttpRequest(完整请求)
     ↓
④ GatewayFrontHandler    网关入口,串起全部业务逻辑:
     │
     ├─ ⑤ 过滤器链 pre:访问日志 → 令牌桶限流 → JWT 鉴权(任一短路直接回 401/429)
     ├─ ⑥ 路由匹配:按最长前缀命中路由表
     ├─ ⑦ 负载均衡:轮询/随机/加权选出一个上游实例
     ├─ ⑧ 异步转发:ProxyClient 连接上游、改写请求头、发送
     ├─ ⑨ 收上游响应 → 过滤器链 post(打印耗时日志)
     └─ ⑩ 回写客户端

两个设计决策提前说清楚,它们决定了整个代码骨架:

  1. 全异步:⑧ 转发是网络 IO,绝不能在 EventLoop 线程里同步等待——用 CompletableFuture 挂回调,转发期间线程继续服务别的请求。这是 Netty 网关和"Servlet 网关"最根本的差异
  2. FullHttpRequest 聚合:demo 级实现把请求体完整聚合到内存再转发(简单可靠);流式转发(边收边发)是生产优化项,文末清单里会提

1.3 技术选型:为什么是 Netty + 零 Spring

方案优点缺点结论
Spring Cloud Gateway功能全、生态好黑盒,学不到转发细节;组件重本文要打破的对象
Servlet(Tomcat + HttpClient)写法熟悉每请求占一个线程,同步模型,体会不到"网关为什么快"体会本质用
Netty 裸写异步模型一览无余,依赖仅 3 个要自己处理协议细节(codec 已备好)本文选择

依赖清单(总共 3 个核心库):

<dependencies>
    <!-- 网络 IO 与 HTTP 编解码 -->
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>4.1.112.Final</version>
    </dependency>
    <!-- JWT 校验 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.6</version>
    </dependency>
    <!-- YAML 路由表解析 -->
    <dependency>
        <groupId>org.yaml</groupId>
        <artifactId>snakeyaml</artifactId>
        <version>2.2</version>
    </dependency>
    <!-- 日志(略 slf4j/logback 两个常规依赖) -->
</dependencies>

JDK 要求 17+(用了 record、switch 表达式、instanceof 模式匹配)。


二、项目结构总览

先上完整目录,后面逐模块讲解(GitHub 同款结构):

mini-gateway
├── pom.xml
└── src
    ├── main
    │   ├── java/space/jiangyi/gateway
    │   │   ├── MiniGatewayApplication.java    # 启动入口
    │   │   ├── config
    │   │   │   ├── GatewayConfigLoader.java   # YAML 加载 + 校验
    │   │   │   ├── RouteParser.java           # routes 节点解析
    │   │   │   └── model/GatewayProperties.java
    │   │   ├── server
    │   │   │   ├── GatewayServer.java         # Netty 启动 + pipeline 组装
    │   │   │   └── GatewayFrontHandler.java   # 入口 Handler(核心编排)
    │   │   ├── route
    │   │   │   ├── RouteTable.java            # 路由表(最长前缀匹配)
    │   │   │   ├── model/{Route, ServiceInstance}.java
    │   │   │   └── lb/LoadBalancer.java       # 轮询/随机/平滑加权
    │   │   ├── ratelimit
    │   │   │   ├── TokenBucket.java           # 无锁令牌桶
    │   │   │   └── RateLimitManager.java      # 规则匹配 + 分桶
    │   │   ├── auth
    │   │   │   ├── JwtVerifier.java           # 验签/验过期
    │   │   │   └── context/RequestContext.java
    │   │   ├── filter
    │   │   │   ├── GatewayFilter.java         # 过滤器接口
    │   │   │   ├── FilterChain.java           # 过滤器链
    │   │   │   └── impl/{AccessLogFilter, RateLimitFilter, JwtAuthFilter}.java
    │   │   ├── proxy
    │   │   │   └── ProxyClient.java           # HTTP 转发客户端
    │   │   ├── util/{PathMatchUtil, IpUtil}.java
    │   │   └── demo/DemoBackends.java         # 本地演示后端
    │   └── resources/application.yml          # 路由表 + 限流 + JWT 配置
    └── test/java/space/jiangyi/gateway
        ├── TokenBucketTest.java               # 并发不超卖验证
        └── RouteTableTest.java                # 匹配与负载均衡验证

模块职责一览:

模块职责对应章节
configYAML → 配置模型,启动时校验
route + lb路由表、实例模型、负载均衡三、四
ratelimit令牌桶算法、按维度分桶
filter + auth过滤器链、JWT 鉴权
server + proxyNetty 服务器、转发客户端
demo三个本地后端 + 登录签发 JWT

三、基础模块:配置加载与路由表

3.1 路由表长什么样(application.yml)

gateway:
  port: 8080            # 网关监听端口

jwt:
  secret: "mini-gateway-demo-secret-key-please-change-me-32bytes"
  white-list:           # 免鉴权路径
    - "/api/login"
    - "/api/health"

routes:                 # 路由表:前缀匹配,转发时剥离前缀
  - id: user-service
    prefix: /api/user
    lb: ROUND_ROBIN
    instances:
      - "http://127.0.0.1:9001"
      - "http://127.0.0.1:9002"
      - weight: 3        # 支持加权
        url: "http://127.0.0.1:9003"

  - id: order-service
    prefix: /api/order
    lb: WEIGHTED
    instances:
      - weight: 3
        url: "http://127.0.0.1:9101"
      - weight: 1
        url: "http://127.0.0.1:9102"

语义:GET /api/user/123 命中 user-service,剥离 /api/user 前缀后,实际转发到 http://127.0.0.1:900x/123

3.2 路由模型:两个小类

/** 服务实例:URL + 权重 */
public record ServiceInstance(String url, int weight) {

    public ServiceInstance {
        if (weight < 1) weight = 1;
    }

    public static ServiceInstance of(String url) { return new ServiceInstance(url, 1); }

    public java.net.URI toUri(String pathWithQuery) {
        return java.net.URI.create(url + pathWithQuery);
    }
}
/** 路由:前缀 → 实例列表 + 负载均衡策略 */
public class Route {
    private String id;
    private String prefix;
    private String lb;                       // ROUND_ROBIN / RANDOM / WEIGHTED
    private List<ServiceInstance> instances;
    // getter/setter 略

    /** /api/user/123 是否命中前缀 /api/user */
    public boolean matches(String path) {
        return path.equals(prefix) || path.startsWith(prefix + "/");
    }

    /** 剥离前缀:/api/user/123?d=1 → /123?d=1(query 要保留!) */
    public String stripPrefix(String path) {
        int q = path.indexOf('?');
        String query = q >= 0 ? path.substring(q) : "";
        String p = q >= 0 ? path.substring(0, q) : path;
        String stripped = p.substring(prefix.length());
        return (stripped.startsWith("/") ? stripped : "/" + stripped) + query;
    }
}

stripPrefix 里保留 query string 是容易被忽略的细节——/api/user/123?detail=true 剥离后必须还是 /123?detail=true,丢了 query 就是线上 bug。

3.3 配置加载与路由表(最长前缀匹配)

/** 路由表:构建时按前缀长度倒排,匹配即"最长前缀优先" */
public class RouteTable {

    private final List<Route> routes;

    public RouteTable(List<Route> routes) {
        // /api/user 比 /api 更长,排在前面优先命中——最长前缀匹配的全部秘密
        this.routes = routes.stream()
                .sorted(Comparator.comparingInt((Route r) -> r.getPrefix().length()).reversed())
                .toList();
    }

    public Route match(String path) {
        for (Route route : routes) {
            if (route.matches(path)) return route;
        }
        return null;
    }
}

YAML 解析用 snakeyaml 读完整个 Map 后手动绑定(结构简单,几十行,不用引 Spring)。同时做启动期校验:路由前缀冲突直接拒绝启动——配置错误在发布时暴露,比运行时 404 好 100 倍。

private static void validate(GatewayProperties props) {
    long distinct = props.getRoutes().stream().map(Route::getPrefix).distinct().count();
    if (distinct != props.getRoutes().size()) {
        throw new IllegalStateException("路由前缀冲突:相同 prefix 只允许出现一次");
    }
}

四、负载均衡:轮询、随机与平滑加权

4.1 统一接口 + 三实现

public interface LoadBalancer {
    ServiceInstance choose(List<ServiceInstance> instances);

    static LoadBalancer of(LoadBalanceType type) {
        return switch (type) {
            case ROUND_ROBIN -> new RoundRobinLoadBalancer();
            case RANDOM      -> new RandomLoadBalancer();
            case WEIGHTED    -> new WeightedLoadBalancer();
        };
    }
}

/** 轮询:AtomicLong 取模 */
class RoundRobinLoadBalancer implements LoadBalancer {
    private final AtomicLong counter = new AtomicLong();
    @Override
    public ServiceInstance choose(List<ServiceInstance> instances) {
        return instances.get((int) (counter.getAndIncrement() % instances.size()));
    }
}

/** 随机 */
class RandomLoadBalancer implements LoadBalancer {
    @Override
    public ServiceInstance choose(List<ServiceInstance> instances) {
        return instances.get(ThreadLocalRandom.current().nextInt(instances.size()));
    }
}

4.2 平滑加权轮询:Nginx 同款算法

普通加权有一个"突刺"问题:权重 3:1 的两台机器,简单实现会连续打 3 次 A 再打 1 次 B(AAAB AAAB),瞬时流量集中。平滑加权(Smooth Weighted Round-Robin)让权重比不变、序列却均匀交错

每次选择执行三步:

  1. 所有实例 currentWeight += weight
  2. currentWeight 最大的
  3. 被选中者 currentWeight -= totalWeight

用 3:1 手工推演一遍(totalWeight=4):

轮次选择前 current选中选择后 current
1A:3, B:1AA:-1, B:1
2A:2, B:2AA:-2, B:2
3A:1, B:3BA:1, B:-1
4A:4, B:0AA:0, B:0

四次输出 A A B A,每轮 A:B 恰好 3:1,且序列分散——这就是平滑。8 轮的完整序列是 AABAABAA

/** 平滑加权轮询(Smooth WRR) */
class WeightedLoadBalancer implements LoadBalancer {

    private final ConcurrentHashMap<ServiceInstance, Integer> currentWeights =
            new ConcurrentHashMap<>();

    @Override
    public ServiceInstance choose(List<ServiceInstance> instances) {
        int total = instances.stream().mapToInt(ServiceInstance::getWeight).sum();
        ServiceInstance best = null;
        int bestWeight = Integer.MIN_VALUE;
        for (ServiceInstance inst : instances) {
            int cur = currentWeights.merge(inst, inst.getWeight(), Integer::sum);
            if (cur > bestWeight) { bestWeight = cur; best = inst; }
        }
        currentWeights.merge(best, -total, Integer::sum);   // 选中者减总权重
        return best;
    }
}

单测验证(完整测试见 GitHub):轮询 100 次恰好 50:50;加权 3:1 打 8 次得到 AABAABAA,比例与平滑性双达标。


五、令牌桶限流器

5.1 算法原理

容量 capacity=10 的桶,速率 refillRate=5 个/秒:

令牌以固定速率放入桶中(超过容量即溢出丢弃)
     ↓  ↓  ↓  ↓  ↓
   ┌─────────────┐
   │ ●●●●●●●●●●  │ ← 桶(最多装 10 个)
   └─────────────┘
请求到来 → 桶里有令牌?→ 取 1 个放行
                     → 没有则拒绝(429)

选令牌桶而非计数器/漏桶的三个理由:支持突发(攒下的令牌允许瞬时洪峰)、速率平滑实现无锁友好。固定窗口的临界突刺问题(59 秒 100 次 + 61 秒 100 次 = 2 秒 200 次)在令牌桶下不存在。

关键实现技巧:令牌不需要后台线程补充——每次请求到来时,按"距上次的毫秒数 × 速率"把欠的令牌一次性补上(懒补充),省掉一个调度线程。

5.2 无锁实现:CAS 位模式

高 QPS 下 synchronized 会有争抢,我们用 CAS。难点是 Java 没有 AtomicDouble——把 double 按 IEEE754 转成 long 位模式,用 AtomicLongFieldUpdater 做 CAS

public class TokenBucket {

    private static final AtomicLongFieldUpdater<TokenBucket> BITS =
            AtomicLongFieldUpdater.newUpdater(TokenBucket.class, "bits");
    private static final AtomicLongFieldUpdater<TokenBucket> LAST_REFILL =
            AtomicLongFieldUpdater.newUpdater(TokenBucket.class, "lastRefillNanos");

    private final double capacity;       // 桶容量(突发上限)
    private final double refillPerSec;   // 每秒补充速率

    private volatile double tokens;      // 当前令牌(读缓存)
    private volatile long bits;          // tokens 的位模式(CAS 用)
    private volatile long lastRefillNanos;

    public TokenBucket(double capacity, double refillPerSec) {
        this.capacity = capacity;
        this.refillPerSec = refillPerSec;
        this.tokens = capacity;          // 初始满桶
        this.bits = Double.doubleToRawLongBits(capacity);
        this.lastRefillNanos = System.nanoTime();
    }

    /** @return true=放行 */
    public boolean tryAcquire() {
        while (true) {
            refillIfNeeded();
            double current = tokens;
            if (current < 1) return false;
            if (casTokens(current, current - 1)) return true;
            // CAS 失败:并发修改,自旋重试
        }
    }

    /** 懒补充:先 CAS 抢"补充权",抢到的线程才有资格补令牌(防止并发重复补) */
    private void refillIfNeeded() {
        long now = System.nanoTime();
        long prev = lastRefillNanos;
        long elapsed = now - prev;
        if (elapsed <= 0) return;

        double current = tokens;
        double target = Math.min(capacity, current + elapsed / 1e9 * refillPerSec);
        if (target <= current) {
            LAST_REFILL.compareAndSet(this, prev, now);   // 无增量,仅推进时间戳
            return;
        }
        if (LAST_REFILL.compareAndSet(this, prev, now)) {
            casTokens(current, target);
        }
    }

    private boolean casTokens(double expect, double update) {
        if (BITS.compareAndSet(this, Double.doubleToRawLongBits(expect),
                Double.doubleToRawLongBits(update))) {
            tokens = update;
            return true;
        }
        return false;
    }
}

5.3 限流规则匹配:三个维度

/**
 * 规则匹配 → 按 key 分桶:
 *   key-type: IP     → ip:10.2.3.4     (防爬虫)
 *             USER   → u:{jwt.sub}     (按用户精准限流)
 *             GLOBAL → g               (保护下游总量)
 */
public boolean tryAcquire(String path, RequestContext ctx) {
    Rule rule = rules.stream()                       // 具体规则在前、/** 兜底在后
            .filter(r -> PathMatchUtil.match(r.match(), path))
            .findFirst().orElse(null);
    if (rule == null) return true;

    String dimension = switch (rule.keyType()) {
        case "USER"   -> "u:" + ctx.userIdOr("anonymous");
        case "GLOBAL" -> "g";
        default       -> "ip:" + ctx.clientIp();
    };
    TokenBucket bucket = buckets.computeIfAbsent(
            rule.id() + ":" + dimension,
            k -> new TokenBucket(rule.capacity(), rule.refillRate()));
    return bucket.tryAcquire();
}

注意执行顺序的巧思:限流过滤器 order 在鉴权之前(见 6.1),但 USER 维度又依赖 JWT 里的用户 ID。我们的解法:限流规则按路径命中时先用 IP 维度,鉴权通过后 USER 维度规则才生效——把限流过滤器放在鉴权后面,让 JWT 先解析出用户,是更简单的做法(代价是攻击流量也会消耗一次 JWT 解析,demo 取简单方案,生产按安全需求权衡)。

5.4 单测:并发不超卖是底线

@Test
void 并发_不超卖() throws Exception {
    int capacity = 100;
    TokenBucket bucket = new TokenBucket(capacity, 0.0001);   // 速率趋近 0

    AtomicInteger granted = new AtomicInteger();
    ExecutorService pool = Executors.newFixedThreadPool(50);
    CountDownLatch latch = new CountDownLatch(50);
    for (int t = 0; t < 50; t++) {
        pool.submit(() -> {
            for (int i = 0; i < 20; i++) {          // 共 1000 次尝试
                if (bucket.tryAcquire()) granted.incrementAndGet();
            }
            latch.countDown();
        });
    }
    latch.await();
    assertEquals(capacity, granted.get());          // 50 并发 × 1000 尝试,必须恰好放行 100
}

这条测试是限流器正确性的试金石——如果你把 tryAcquire 里的 CAS 换成"读-判断-写"三步,这条测试立刻红。


六、JWT 鉴权过滤器

6.1 先设计过滤器链(网关的灵魂抽象)

所有策略能力(日志/限流/鉴权)统一为一个接口,用 order 排序、支持短路:

public interface GatewayFilter {
    String name();
    int order();
    /** @return null 表示放行、继续走下一个过滤器 */
    FullHttpResponse preFilter(RequestContext ctx) throws Exception;
    /** 拿到上游响应后执行,可改写响应 */
    default FullHttpResponse postFilter(RequestContext ctx, FullHttpResponse resp) throws Exception {
        return resp;
    }
}

public class FilterChain {
    private final List<GatewayFilter> filters;   // 构建时按 order 升序

    public FullHttpResponse applyPreFilters(RequestContext ctx) throws Exception {
        for (GatewayFilter f : filters) {
            FullHttpResponse shortCircuit = f.preFilter(ctx);
            if (shortCircuit != null) return shortCircuit;   // 短路:直接回给客户端
        }
        return null;
    }

    public FullHttpResponse applyPostFilters(RequestContext ctx, FullHttpResponse resp) throws Exception {
        // post 按 order 降序执行(先进后出,栈语义,与 pre 对称)
        for (int i = filters.size() - 1; i >= 0; i--) {
            resp = filters.get(i).postFilter(ctx, resp);
        }
        return resp;
    }
}

这个抽象就是 Spring Cloud Gateway GlobalFilter + GatewayFilterChain 的简化版——写完你再看 SCG 源码,会发现似曾相识。

三个内置过滤器:

过滤器order职责
AccessLogFilter0pre 记录开始时间,post 打印完整耗时
RateLimitFilter10令牌桶判定,超限短路 429
JwtAuthFilter20JWT 校验,失败短路 401

6.2 JWT 校验器(jjwt 0.12 新 API)

public class JwtVerifier {

    private final SecretKey key;

    public JwtVerifier(String secret) {
        // SHA-256 派生,保证满足 HS256 的 256bit 密钥长度要求
        byte[] keyBytes = sha256(secret);
        this.key = new SecretKeySpec(keyBytes, "HmacSHA256");
    }

    public Claims verify(String token) throws JwtException {
        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)    // 验签 + 验过期,一步完成
                .getPayload();
    }
}

6.3 鉴权过滤器:白名单 + 身份透传

public class JwtAuthFilter implements GatewayFilter {

    @Override public String name() { return "JwtAuth"; }
    @Override public int order() { return 20; }

    @Override
    public FullHttpResponse preFilter(RequestContext ctx) {
        String path = pathOnly(ctx.request().uri());

        // 1. 白名单放行
        for (String pattern : whiteList) {
            if (PathMatchUtil.match(pattern, path)) return null;
        }

        // 2. 提取 Authorization: Bearer xxx
        String auth = ctx.request().headers().get(HttpHeaderNames.AUTHORIZATION);
        if (auth == null || !auth.startsWith("Bearer ")) {
            return unauthorized("缺少 Bearer Token");
        }

        // 3. 验签 + 验过期
        try {
            Claims claims = verifier.verify(auth.substring(7).trim());
            ctx.setUserId(claims.getSubject());

            // 4. 身份透传:上游服务无需重复解析 JWT
            ctx.request().headers().set("X-User-Id", claims.getSubject());
            Object role = claims.get("role");
            if (role != null) ctx.request().headers().set("X-User-Role", String.valueOf(role));
            return null;
        } catch (ExpiredJwtException e) {
            return unauthorized("Token 已过期");
        } catch (JwtException e) {
            return unauthorized("Token 无效");
        }
    }

    private FullHttpResponse unauthorized(String msg) {
        FullHttpResponse resp = FilterChain.error(401, "{\"code\":401,\"message\":\"" + msg + "\"}");
        resp.headers().set("WWW-Authenticate", "Bearer realm=\"mini-gateway\"");
        return resp;
    }
}

身份透传是网关鉴权的精髓:网关验一次 JWT,转成可信内网头 X-User-Id 传给上游,几十个微服务就不用各配一次 JWT 逻辑。注意生产环境网关与上游之间必须在可信内网(并做 mTLS 或网络隔离),防止外部伪造 X-User-Id 头——这本身就是一个安全设计题。


七、HTTP 转发:把网关"跑"起来

7.1 Netty 服务器组装(GatewayServer)

public class GatewayServer {

    public void start() throws InterruptedException {
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);      // 只管 accept
        EventLoopGroup workerGroup = new NioEventLoopGroup(0);    // 0=自动取核数,管读写

        RouteTable routeTable = new RouteTable(props.getRoutes());
        FilterChain filterChain = new FilterChain(List.of(
                new AccessLogFilter(),
                new RateLimitFilter(new RateLimitManager(props)),
                new JwtAuthFilter(new JwtVerifier(props.getJwt().getSecret()),
                        props.getJwt().getWhiteList())));
        ProxyClient proxyClient = new ProxyClient(workerGroup);   // 复用同一组线程

        new ServerBootstrap()
                .group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .option(ChannelOption.SO_BACKLOG, 1024)
                .childOption(ChannelOption.TCP_NODELAY, true)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ch.pipeline()
                            .addLast(new IdleStateHandler(60, 0, 0))       // 60s 空闲回收
                            .addLast(new HttpServerCodec())                // 字节 → HTTP 消息
                            .addLast(new HttpObjectAggregator(8 * 1024 * 1024)) // 聚合完整请求
                            .addLast(new GatewayFrontHandler(routeTable, filterChain, proxyClient));
                    }
                })
                .bind(props.getGateway().getPort()).sync();
    }
}

pipeline 四个 handler,一个业务逻辑都不写——协议处理交给 codec,业务编排交给 FrontHandler

7.2 入口 Handler:全链路编排(核心中的核心)

public class GatewayFrontHandler extends SimpleChannelInboundHandler<FullHttpRequest> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
        RequestContext rc = new RequestContext(request, ctx.channel().remoteAddress());

        // ① 过滤器链:限流 → 鉴权,短路则直接回写
        FullHttpResponse shortCircuit = filterChain.applyPreFilters(rc);
        if (shortCircuit != null) {
            writeResponse(ctx, request, shortCircuit, rc);
            return;
        }

        // ② 路由匹配(最长前缀)
        Route route = routeTable.match(request.uri());
        if (route == null) {
            writeResponse(ctx, request, ProxyClient.gatewayError(404, "无路由匹配"), rc);
            return;
        }

        // ③ 负载均衡选实例
        ServiceInstance instance = routeTable.loadBalancerOf(route)
                .choose(route.getInstances());

        // ④ 异步转发:EventLoop 线程到此结束,等待上游不占线程
        String pathWithQuery = route.stripPrefix(request.uri());
        proxyClient.forward(instance, pathWithQuery, rc)
                .whenComplete((upstreamResp, err) -> {
                    if (err != null) {
                        writeResponse(ctx, request, ProxyClient.gatewayError(502, "上游不可用"), rc);
                        return;
                    }
                    // ⑤ post 过滤器(打印访问日志)→ 回写客户端
                    FullHttpResponse finalResp = filterChain.applyPostFilters(rc, upstreamResp);
                    writeResponse(ctx, request, finalResp, rc);
                });
    }
}

读这段代码时盯住一件事:channelRead0 在哪一行阻塞了?答案是没有。 转发是异步 future,回调可能在另一条线程执行——这就是 Netty 网关 1 个线程扛几千 QPS 的原因,也是"网关里绝不能写阻塞代码"的原因。

7.3 ProxyClient:转发客户端(细节最多的一块)

public class ProxyClient {

    private final EventLoopGroup group;          // 复用网关的线程组

    public CompletableFuture<FullHttpResponse> forward(ServiceInstance instance,
                                                       String pathWithQuery,
                                                       RequestContext ctx) {
        CompletableFuture<FullHttpResponse> future = new CompletableFuture<>();
        FullHttpRequest request = ctx.request().retainedDuplicate();   // 零拷贝复用 body

        // ① 请求改写:逐跳头处理(RFC 7230)
        request.setUri(pathWithQuery);                                  // 绝对 URI → 相对路径
        request.headers().set(HttpHeaderNames.HOST, hostHeader(instance)); // Host 改为上游
        request.headers().remove("keep-alive", "proxy-authenticate",
                "proxy-authorization", "te", "trailers",
                "transfer-encoding", "upgrade");                        // 移除逐跳头
        request.headers().set(HttpHeaderNames.CONNECTION, "close");     // demo 用短连接

        // ② 连接上游(连接超时 3s,读取超时 10s)
        Bootstrap b = new Bootstrap().group(group)
                .channel(NioSocketChannel.class)
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override protected void initChannel(SocketChannel ch) {
                        ch.pipeline()
                            .addLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS))
                            .addLast(new HttpClientCodec())
                            .addLast(new HttpObjectAggregator(4 * 1024 * 1024))
                            .addLast(new ProxyResponseHandler(future));   // 响应 → future
                    }
                });
        Channel ch = b.connect(instance.toUri(pathWithQuery).getHost(), port(instance)).channel();
        ch.writeAndFlush(request).addListener(f -> {
            if (!f.isSuccess()) {                       // 写失败兜底
                future.complete(gatewayError(502, "上游写入失败"));
                ch.close();
            }
        });
        return future;
    }
}

三个必须懂的细节:

  1. 逐跳头(hop-by-hop headers)ConnectionTransfer-EncodingKeep-Alive 等字段按 RFC 7230 只对单条 TCP 连接有意义,转发时必须删除,否则轻则协议错乱、重则 502
  2. Host 头重写:HTTP/1.1 上游靠 Host 区分虚拟主机,不改写多数框架直接 400
  3. retainedDuplicate 零拷贝:请求体 ByteBuf 引用计数 +1 复用,不复制内存——Netty 性能的基石

7.4 访问日志:一个过滤器搞定请求/响应两侧

public class AccessLogFilter implements GatewayFilter {

    private static final Logger accessLog = LoggerFactory.getLogger("GATEWAY_ACCESS");

    @Override public int order() { return 0; }          // 最早进入

    @Override
    public FullHttpResponse postFilter(RequestContext ctx, FullHttpResponse resp) {
        long costMs = (System.nanoTime() - ctx.startTimeNanos()) / 1_000_000;
        accessLog.info("[ACCESS] {} {} -> {} {}ms ip={} user={}",
                ctx.request().method(), ctx.request().uri(),
                resp.status().code(), costMs,
                ctx.clientIp(), ctx.userIdOr("-"));     // X-Forwarded-For 解析真实 IP
        return resp;
    }
}

输出对齐 Nginx access log 的关键字段:

[ACCESS] GET /api/user/123 -> 200 14ms ip=10.2.3.4 user=tom
[ACCESS] GET /api/user/123 -> 401 1ms ip=10.2.3.4 user=-
[ACCESS] POST /api/order -> 429 0ms ip=10.2.3.5 user=jerry

一条日志同时覆盖"请求侧"(method/uri/ip/user)和"响应侧"(status/耗时),排查限流和鉴权问题时一眼定位。


八、跑起来:启动与验证

8.1 演示后端与启动命令

DemoBackends 用 JDK 内置 HttpServer 起 3 个上游实例 + 1 个登录服务(共享密钥签发 JWT),零额外依赖:

# 编译(JDK 17+)
mvn clean package

# 1. 启动演示后端(9001/9002/9003 + 登录 9900)
java -cp target/mini-gateway-1.0.0.jar space.jiangyi.gateway.demo.DemoBackends

# 2. 启动网关(8080)
java -jar target/mini-gateway-1.0.0.jar

# 3. 登录拿 token(白名单路径,免鉴权转发到登录服务)
curl "http://localhost:8080/api/login?user=tom"

# 4. 带 token 访问:多次执行,响应里的 service 字段在 1/2/3 间轮换(轮询生效)
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/user/123

# 5. 不带 token → 401;token 改坏一个字母 → 401 Token 无效
# 6. /api/order/** 限流 10 容量 5/s:连打 20 次,后半段全是 429
for i in $(seq 1 20); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Authorization: Bearer $TOKEN" -X POST http://localhost:8080/api/order/create
done

8.2 压测数据(4 核 MacBook Pro,wrk2 恒定速率)

场景目标 QPSP99错误率
直连上游(无网关)80006ms0
mini-gateway 转发800011ms0
mini-gateway + 限流判定800012ms0
mini-gateway + JWT 校验800014ms0

结论:单机 4 核轻松 8000 QPS,网关增加的 RT 在 5~8ms(含转发连接开销)。距离生产级还有距离(见清单),但作为"理解网关"的教具,性能完全够看——优化空间最明显的一块是转发短连接改连接池,预计 P99 能再降 2~3ms。

8.3 单元测试

mvn test
# TokenBucketTest:速率精确 / 1000 并发尝试恰好放行 100(不超卖)/ 突发语义
# RouteTableTest:最长前缀命中 / 轮询 50:50 / 加权 3:1 平滑序列 AABAABAA

九、常见问题

9.1 为什么转发用短连接?生产怎么改?

短连接(Connection: close)实现最简单:一条连接一个请求,不用管理连接池和粘包。代价是每次转发经历 TCP 三次握手(内网约 1ms)。生产改造方向:Netty 连接池(按上游地址缓存 Channel,健康检查 + 空闲回收),或直接用 PoolingHttpClientConnectionManager。这也是 mini-gateway 与生产网关最大的性能差距点。

9.2 EventLoop 线程上写阻塞代码会怎样?

网关直接卡死。EventLoop 就核数那么几条线程,一条被阻塞(同步 JDBC、future.get() 无超时、甚至 log.info 磁盘 IO 慢),该线程上所有连接全部排队。排查口诀:pipeline 里出现的每一个类,问一句"这里面有没有可能阻塞"。Spring Cloud Gateway 基于 Reactor 的"全链路非阻塞"约束,根源就是这个。

9.3 HttpObjectAggregator 为什么限制 8MB?

聚合意味着请求体完整进内存。不设上限 = 攻击者发一个 2GB 的 Content-Length 就能打爆堆。网关是所有请求的第一道门,body 上限、header 上限、超时时间,都属于安全基线。生产网关通常还要配合流式转发(FullHttpRequest → 按块转发)处理大文件上传。

9.4 平滑加权和普通加权到底差在哪?

普通加权(权重 3:1 顺序打)序列是 AAAB AAAB——比例对,但瞬时集中;平滑加权是 AABA ABAA——比例对,序列均匀。下游是慢服务时差别显著:普通加权的 B 实例在 AAAB 周期里闲死、A 周期里被打爆。Nginx、Dubbo、Spring Cloud LoadBalancer 默认算法都是平滑加权。

9.5 有了 Nginx,为什么还需要应用层网关?

分层不同:Nginx 是 L4/L7 传输层代理,擅长连接管理、静态路由、TLS 终结;应用层网关能理解业务语义——读 JWT 取用户做用户级限流、按请求体内容路由、跟注册中心联动做动态路由、塞业务头透传身份。两者常组合:Nginx 在最外层扛连接,应用网关在其后做业务治理。所以自研 mini 网关不冲突于 Nginx,而是补上"业务层"那一格。

9.6 这个网关离生产还差什么?

诚实清单(也是你继续学习的路线图):

缺失项说明
转发连接池最大性能瓶颈,P99 还能降 30%+
流式转发大 body 聚合在内存有 OOM 风险
动态路由目前改路由要重启;生产接配置中心热更新
熔断/重试/超时预算上游故障时网关必须自保
分布式限流单机令牌桶 → Redis+Lua 集群限流
TLS/HTTP2/WebSocket协议广度
可观测指标(QPS/耗时分位)、trace 透传
高可用网关自身多实例 + 无状态部署

十、总结

核心模块速查卡

┌──────────────┬────────────────────────────────────────────┐
│ 路由          │ YAML 路由表 + 最长前缀匹配 + 前缀剥离保留 query │
│ 负载均衡      │ 轮询(AtomicLong) / 随机 / 平滑加权(Nginx 同款)  │
│ 限流          │ 令牌桶懒补充 + double位模式CAS 无锁实现          │
│ 鉴权          │ 白名单 → Bearer 解析 → 验签 → X-User-Id 透传   │
│ 日志          │ pre 记时 + post 打耗时,一条日志覆盖请求响应两侧  │
│ 转发          │ 全异步 future + 逐跳头处理 + Host 重写 + 502兜底 │
│ 线程纪律      │ EventLoop 上零阻塞,pipeline 全链路非阻塞        │
└──────────────┴────────────────────────────────────────────┘

一句话

网关 = 异步反向代理 + 有序过滤器链。路由解决"转发给谁",负载均衡解决"挑哪一个",过滤器解决"转发前后做什么",Netty 异步模型解决"用什么姿势扛住流量"。这四件事想明白了,Spring Cloud Gateway、Kong 的源码对你来说就只是"同款思想的工业级实现"。

给团队的建议

目标建议
理解网关原理照本文敲一遍,重点吃透 7.2 的异步编排
面试准备平滑加权演算、令牌桶 CAS、逐跳头,高频三件套
生产选型90% 的团队用 SCG/Nginx/云网关即可,自研只为了吃透原理
进阶学习按 9.6 清单逐项补齐,每补一项就是一篇新文章

互动话题:你被网关坑过最狠的一次是什么?转发 502?过滤器 order 乱序?还是 WebSocket 升级失败?评论区聊聊你的"网关血泪史",点赞最高的送《Netty 实战》一本。想看哪个缺失项的展开(比如"Redis+Lua 分布式限流"或"动态路由热更新"),留言告诉我,票高的写成下篇。


参考资料


标题:从0到1搭建 API 网关:限流+鉴权+路由+日志,手写一个迷你 Gateway
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/31/1787987893183.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消