返回文章列表

Rust 从零实现 API 网关:6 个核心组件的设计与踩坑

1249·9 分钟阅读
RustAPI 网关微服务限流反向代理

"我们有 20 多个微服务,前端要对接 20 个地址,跨域配置写了 20 遍,鉴权逻辑每个服务抄了一份,限流?有的服务写了有的没写。"

—— 一个真实团队的痛点

先说结论

API 网关的本质就是一堆中间件的有序组合。听起来简单,但每个组件都有设计决策和生产陷阱。本文从零实现 6 个核心组件:反向代理、路由、限流、熔断、负载均衡、认证,重点讲为什么这么设计,而不是"能跑就行"。

本文不是教你搭一个能转发请求的玩具代理,而是带你理解每个组件背后的设计权衡。

如果你还没读过 从 hyper 手写一个 HTTP 框架,建议先读那篇,本文大量使用 tower::Service/Layer 抽象。


架构全景

一个请求经过网关时的完整链路:

┌─────────────────────────────────────────────────────────────┐
│                        API Gateway                          │
│                                                             │
│  ┌────────┐  ┌────────┐  ┌────────┐  ┌────────┐  ┌──────┐ │
│  │ 路由   │→│ 认证   │→│ 限流   │→│ 熔断   │→│ 负载 │ │
│  │ Router │  │  Auth  │  │ Limit  │  │ Breaker│  │均衡  │ │
│  └────────┘  └────────┘  └────────┘  └────────┘  └──────┘ │
│       ↑                                              │      │
│       │           中间件洋葱模型                       ↓      │
│  ┌────┴───┐                                  ┌──────────┐   │
│  │ Client │                                  │ Backend  │   │
│  └────────┘                                  │ Services │   │
│                                              └──────────┘   │
└─────────────────────────────────────────────────────────────┘

每一层都是一个 tower::Service,请求从外到内穿过,响应回来反向穿出。这个抽象是整个网关的基石。

// 一切皆 Service —— tower 的核心 trait
// 你在 axum 里写的 handler、middleware,底层都是这个
pub trait Service<Request> {
    type Response;
    type Error;
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
    fn call(&mut self, req: Request) -> Self::Future;
}

第一步:反向代理

最核心的功能——把请求转发到后端服务。

正向代理 vs 反向代理

先搞清楚一个容易混淆的概念:

正向代理(Forward Proxy):                反向代理(Reverse Proxy):
客户端知道目标服务器是谁                    客户端不知道后端有几台服务器

  Client → Proxy → Server                  Client → Proxy → Server A
                                                 ↘→ Server B
  "帮我访问 google.com"                      ↘→ Server C
  代理代表客户端
                                               "帮我访问 api.example.com"
                                               代理代表服务器

API 网关是反向代理。客户端只看到网关的地址,不知道后端有多少个服务、地址是什么。这个"隐藏"是网关所有功能的基础——因为所有请求都必经网关,我们才能在网关层做路由、限流、熔断、认证。

反向代理的连接模型

转发请求时,网关和后端之间的连接怎么管理?有三种模型:

模型一:短连接(最简单,性能最差)
  每个请求都新建 TCP 连接,用完就关
  Client ──TCP──→ Gateway ──TCP──→ Backend (每次新建)
                          ──TCP──→ Backend (每次新建)
  问题:三次握手 + TLS 握手的开销 × 请求数

模型二:连接池复用(推荐)
  Gateway 维护一个到每个后端的连接池
  Client ──TCP──→ Gateway ═══池═══→ Backend (复用连接)
                          ═══池═══→ Backend (复用连接)
  好处:省去建连开销,配合 [连接池设计](./0624.md) 使用

模型三:HTTP/2 多路复用(最优,但复杂)
  一个 TCP 连接上并发多个请求
  Client ──TCP──→ Gateway ═══单连接═══→ Backend
                          Stream 1 ──→
                          Stream 2 ──→  (并发)
                          Stream 3 ──→
  好处:无队头阻塞,连接数最少

经验法则:先用连接池(模型二),等 QPS 上去了再考虑 HTTP/2 多路复用。过早优化连接模型是浪费时间。

async fn reverse_proxy(req: Request<Body>) -> Result<Response<Body>, Infallible> {
    let client = HttpClient::builder(TokioExecutor::new()).build_http();
    // 把网关地址替换成后端地址,透传 header 和 body
    let backend_uri = format!("http://127.0.0.1:8081{}", req.uri().path());
    let mut proxy_req = Request::builder()
        .method(req.method()).uri(backend_uri).body(req.into_body()).unwrap();
    *proxy_req.headers_mut() = req.headers().clone();
 
    match client.request(proxy_req).await {
        Ok(resp) => Ok(resp),
        Err(_) => Ok(Response::builder().status(502).body(Body::from("Bad Gateway")).unwrap()),
    }
}

看起来能跑对吧?踩坑时间。

坑一:Body 只能读一次

hyper 的 Body 是流式的,读完就没了。如果你的中间件需要先读 Body(比如认证要解析 JWT payload),转发时 Body 就是空的。

// ❌ body 被消费后,转发出去的是空 body
let body_bytes = req.body_mut().collect().await.unwrap().to_bytes();
 
// ✅ 读完后重新构造
let body_bytes = req.body_mut().collect().await.unwrap().to_bytes();
let (mut parts, _) = req.into_parts();
let new_req = Request::from_parts(parts, Body::from(body_bytes));

经验法则:如果中间件需要读 Body,读完后用 Bytes 重新构造。axum 用 RequestParts + extensions 缓存解决这个问题,我们也可以借鉴。

坑二:没有超时的代理是定时炸弹

后端服务挂了怎么办?请求会一直等。生产环境必须设超时。

// 设超时,网关层应该比后端服务超时短(后端 60s → 网关 30s)
let resp = timeout(Duration::from_secs(30), client.request(proxy_req)).await;

经验法则:网关层超时应该比后端服务超时短。后端设 60s,网关设 30s。否则用户看到的是网关超时,但后端还在处理,造成资源浪费。

坑三:Header 透传的陷阱

反向代理不是"把所有 Header 原封不动转发"就完事了。有几个 Header 必须处理:

必须删除的 Header:
  - Hop-by-hop headers(Connection, Keep-Alive, TE...)
    这些 Header 只在相邻两跳之间有意义,不应该透传到后端

必须添加的 Header:
  - X-Forwarded-For: 客户端真实 IP(后端日志、限流都需要)
  - X-Forwarded-Proto: 原始协议(http/https)
  - X-Forwarded-Host: 原始 Host(后端生成链接时需要)
  - X-Request-ID: 请求追踪 ID(贯穿整个调用链)

不应该透传的 Header:
  - Authorization: 已经解析过了,把用户信息塞 extensions 更安全
    透传 token 意味着后端也能拿到 secret,扩大了攻击面
fn prepare_proxy_headers(original: &HeaderMap, client_ip: &str) -> HeaderMap {
    let mut headers = HeaderMap::new();
    // 过滤 hop-by-hop headers(只在相邻两跳有意义,不应透传)
    let hop_by_hop = ["connection", "keep-alive", "te", "transfer-encoding"];
    for (key, value) in original.iter() {
        if !hop_by_hop.contains(&key.as_str()) {
            headers.insert(key.clone(), value.clone());
        }
    }
    // 添加代理 Header
    headers.insert("x-forwarded-for", client_ip.parse().unwrap());
    headers.insert("x-request-id", Uuid::new_v4().to_string().parse().unwrap());
    headers
}

经验法则:Header 透传要"选择性",不是"全量"。删除 hop-by-hop,添加代理 Header,隐藏敏感 Header。这是反向代理的基本功。


第二步:路由系统

路由是网关的入口,决定请求去哪个后端。听起来就是字符串匹配,但"怎么匹配"直接影响性能和正确性。

路由匹配的三种实现

方案一:遍历正则(最直观,最慢)
  对每条路由规则编译一个正则,请求来了逐个匹配
  复杂度:O(n),n = 路由数量
  问题:100 条路由,最坏情况匹配 100 次

方案二:前缀树 / Trie(中等复杂度,较快)
  把路由路径拆成字符/段,构建树形结构
  /api/users → [api] → [users] → handler
  /api/orders → [api] → [orders] → handler
  共享前缀 "/api" 只存一次
  复杂度:O(k),k = 路径长度,与路由数量无关

方案三:压缩前缀树 / Radix Tree(matchit 用的)
  在 Trie 基础上压缩单子节点路径
  /api/users 和 /api/orders 共享 "/api/" 前缀
  只在分叉处拆分
  复杂度:O(k),但常数更小,内存更省
普通 Trie:              压缩 Radix Tree (Radix Tree):
    /                        /
    a                        api/
    p                        ├─ users/
    i                        └─ orders/
   / \
  u   o
  s   r
  e   d
  r   e
  s   r
  s   s

matchit 库用的就是 Radix Tree,加上对通配符 {param} 和通配路径 *rest 的支持。这也是为什么它比正则匹配快一个数量级。

use matchit::Router as MatchitRouter;
 
// 注册路由:path → backend_url
let mut router = MatchitRouter::new();
router.insert("/api/users/*", "http://user-service:8081".to_string()).unwrap();
router.insert("/api/orders/*", "http://order-service:8082".to_string()).unwrap();
 
// 匹配:Radix Tree O(k),k = 路径长度,与路由数量无关
let matched = router.at("/api/users/123").unwrap();
println!("{}", matched.value); // "http://user-service:8081"

坑三:路由优先级冲突

/api/v1/users/api/v1/* 同时存在时,谁先匹配?

大多数路由库(包括 matchit)的规则是:精确匹配优先于通配符。但如果你自己实现路由,很容易搞反。

路由表:
  /api/v1/users     → user-service
  /api/v1/*         → default-service

请求 /api/v1/users → 命中 user-service ✅(精确优先)
请求 /api/v1/orders → 命中 default-service ✅(通配符兜底)

经验法则:路由注册顺序很重要。先注册精确路由,再注册通配符路由。用成熟的路由库(matchit、actix-router)而不是自己手写字符串匹配。

动态路由

生产环境路由表不能写死,要支持热更新:

// 生产环境路由表不能写死,用 RwLock 支持热更新
struct DynamicRouter {
    routes: Arc<RwLock<MatchitRouter<String>>>,
}
// 写入时获取写锁,原子替换整个路由表;读取时无锁并发

第三步:限流

没有限流的网关,就是给后端服务挖坑。

为什么需要限流?——排队论视角

还记得连接池文章里提到的 Little's Law 吗?L = λ × W(系统中平均请求数 = 到达速率 × 平均处理时间)。

限流的本质是控制 λ(到达速率)。当 λ 超过后端承载能力时:

λ(到达速率) > μ(服务能力)  →  队列无限增长  →  内存溢出  →  全部崩溃

限流后:
λ'(限流后的速率) ≤ μ  →  多余请求快速拒绝(429)  →  已接受的请求正常处理

残酷的事实:限流不是"拒绝用户",而是"保护已接入的用户"。不限流 = 所有人一起死。

固定窗口的边界突刺问题

最简单的限流:每秒重置计数器。但这个方案有个致命缺陷——窗口边界突刺

时间线:  |---- 第1秒 ----|---- 第2秒 ----|
请求分布:  ●●●●●●●●●●      ●●●●●●●●●●
            ↑ 第1秒末尾      ↑ 第2秒开头
            10个请求          10个请求
            全部放行           全部放行

问题:在第1秒末尾 + 第2秒开头的200ms内,涌入了20个请求!
      实际瞬时速率达到 100 QPS,但限流配置的是 10 QPS

这就是为什么固定窗口被淘汰。滑动窗口和令牌桶都是为了解决这个问题。

三种算法深度对比

算法 实现原理 时间复杂度 核心差异
固定窗口 计数器+时间窗 O(1) 窗口边界有突刺,简单粗暴
滑动窗口 环形数组存历史 O(w) w=窗口数,精确但内存开销大
令牌桶 按速率填充令牌 O(1) 天然平滑,允许突发(桶内存量)
漏桶 固定速率流出 O(1) 严格匀速,不允许突发

令牌桶 vs 漏桶——很多人搞混:

令牌桶(Token Bucket):              漏桶(Leaky Bucket):
  令牌以速率 r 填充                     请求以速率 r 流出
  请求来了有令牌就放行                  请求来了先进队列,按固定速率处理
  允许突发(桶里攒了令牌就能瞬间放行)   不允许突发(永远匀速输出)

  适合:API 限流(允许短时突发)        适合:流量整形(严格匀速)

经验法则:API 网关用令牌桶——用户偶尔发个突发请求是正常的,不应该被拒绝。流量整形(比如视频流带宽控制)用漏桶

令牌桶工作原理:

    ┌─────────────────┐
    │   Token Bucket   │
    │                  │
    │   ○ ○ ○ ○ ○     │  ← 令牌以固定速率填充
    │                  │
    │   capacity: 10   │  ← 桶容量(允许的突发量)
    │   rate: 100/s    │  ← 填充速率(稳态 QPS)
    └────────┬─────────┘
             │
             ↓ 每个请求消耗一个令牌
    ┌────────────────┐
    │    Request      │
    └────────────────┘
    令牌够 → 放行
    令牌不够 → 拒绝 (429 Too Many Requests)

核心逻辑很简单——按时间差补充令牌,有令牌就放行:

// DashMap 保证并发安全,每个 key 独立计数
struct RateLimiter {
    buckets: DashMap<String, (f64, Instant)>, // key → (剩余令牌, 上次刷新)
    capacity: f64,  // 桶容量(允许的突发量)
    rate: f64,      // 每秒填充令牌数
}
 
impl RateLimiter {
    fn allow(&self, key: &str) -> bool {
        let mut entry = self.buckets.entry(key.to_string()).or_insert((self.capacity, Instant::now()));
        let (tokens, last_refill) = entry.value_mut();
        // 按时间差补充令牌,不超过桶容量
        *tokens = self.capacity.min(*tokens + last_refill.elapsed().as_secs_f64() * self.rate);
        *last_refill = Instant::now();
        if *tokens >= 1.0 { *tokens -= 1.0; true } else { false }
    }
}

坑四:限流粒度选错

限流粒度不同,效果天差地别:

粒度 优点 缺点 适用场景
按 IP 简单,防刷 误伤 NAT 用户(公司出口 IP 相同) 开放 API
按用户 ID 精准 需要先鉴权 登录用户
按 API 路径 保护特定接口 无法防单用户滥用 后端保护

经验法则:组合使用。对外 API 用"按 IP + 按路径"双层限流。对内网关用"按用户 ID"即可。千万别只用一种

坑五:分布式限流的幻觉

上面的实现是单机限流。多实例部署时,每个实例独立计数,限流形同虚设。

分布式方案:Redis + Lua 脚本保证原子性——读取令牌数、判断、扣减在一个脚本里完成,避免竞态。

经验法则:单机限流用 dashmap,分布式用 Redis Lua。不要用 Redis 的 INCR + EXPIRE 做令牌桶,窗口边界会有突刺问题。


第四步:熔断器

限流是"控制进来的量",熔断是"检测后端的健康"。后端挂了还继续转发请求,只会让雪崩扩散。

为什么需要熔断?——级联故障

假设你有三个服务:A → B → C。C 挂了:

时间线:
  T0: C 服务挂了,所有请求超时
  T1: B 服务的连接池被 C 的超时请求占满(每个请求等 30s 才超时)
  T2: A 服务的连接池被 B 的超时请求占满
  T3: 用户看到的是 A 服务也挂了

  C 挂 → B 慢 → A 挂 → 整个系统雪崩

这就是级联故障(Cascading Failure)。熔断器的作用是:快速失败,不让故障扩散

熔断器 vs TCP 拥塞控制

熔断器的三态模型和 TCP 拥塞控制惊人地相似:

TCP 拥塞控制:                        熔断器:
  慢启动 → 拥塞避免 → 快速重传        Closed → Open → Half-Open
  检测到丢包 → 减半发送速率           检测到高错误率 → 停止转发
  逐步恢复发送速率                    逐步恢复流量

  核心思想一样:遇险则退,确认安全再进

设计哲学:熔断器不是"后端挂了就永远不访问",而是"给后端喘息的时间,然后小心翼翼地试探"。这个"试探"的策略,决定了熔断器的质量。

三态模型详解

                    错误率超过阈值
    ┌──────────┐ ──────────────→ ┌──────────┐
    │  Closed   │                 │   Open    │
    │ (正常放行) │ ←────────────── │ (全部拒绝) │
    └──────────┘    探测成功      └────┬─────┘
         ↑                            │
         │          超时后试探         │
         └────────────────────────────┘
                          ↓
                    ┌───────────┐
                    │ Half-Open  │
                    │(部分放行)  │
                    └───────────┘

核心是三态状态机 + 滑动窗口记录请求结果:

struct CircuitBreaker {
    state: State,                          // Closed / Open / HalfOpen
    failure_threshold: f64,                // 错误率阈值,如 0.5
    cooldown: Duration,                    // Open → Half-Open 的冷却时间
    window: VecDeque<(Instant, bool)>,     // 滑动窗口:最近 N 次请求结果
}
 
impl CircuitBreaker {
    fn should_allow(&mut self) -> bool {
        match &self.state {
            State::Closed => true,
            State::Open { opened_at } => {
                if opened_at.elapsed() >= self.cooldown {
                    self.state = State::HalfOpen { remaining: 3 };
                    true // 冷却结束,试探
                } else { false }
            }
            State::HalfOpen { remaining } => *remaining > 0,
        }
    }
}

坑六:Half-Open 恢复太激进

熔断器打开后,冷却期一过就恢复所有流量?如果后端只是"稍微好了一点",瞬间涌入大量请求又会把它打挂。

❌ 错误策略:Open → Half-Open → 立刻放行所有流量
✅ 正确策略:Open → Half-Open → 逐步放行(先 10%,再 30%,再 100%)

渐进恢复的思路——指数退避 + 放行比例递增:第 1 次试探放行 10%,成功后第 2 次放 20%,第 3 次 40%,逐步恢复到 100%。任何一次失败立刻回到 Open 状态,冷却时间加倍(10s → 20s → 40s,设上限)。

冷却时间也应该指数退避——第一次熔断等 10s,第二次等 20s,第三次等 40s。避免后端持续不稳定时,熔断器反复开关(这叫flapping)。

冷却时间序列:10s → 20s → 40s → 80s(上限)→ 持续 80s
                                    ↑
                            设置上限,别等到天荒地老

经验法则:Half-Open 状态应该渐进式恢复。先放少量请求试探,成功率达标后再逐步放开。像 TCP 拥塞控制一样——慢启动。冷却时间用指数退避,但要设上限。

滑动窗口的时间粒度

上面的实现用"最近 N 次请求"做滑动窗口。但还有一个选择:基于时间的滑动窗口

基于计数的窗口:最近 100 个请求中错误率 ≥ 50%
  优点:实现简单
  缺点:请求少的时候不灵敏(100 个请求可能跨越 10 分钟)

基于时间的窗口:最近 30 秒内错误率 ≥ 50%
  优点:时间维度可控,对低流量场景友好
  缺点:需要定时清理过期数据,实现稍复杂

经验法则:高 QPS 服务用基于计数的窗口(简单高效),低 QPS 服务用基于时间的窗口(否则窗口跨度过长)。生产环境一般用分段滑动窗口(把 30s 分成 6 个 5s 的小窗口,滚动淘汰),兼顾精度和性能。


第五步:负载均衡

路由决定了请求去哪个服务,负载均衡决定了去哪个实例

负载均衡算法

算法 原理与适用场景
轮询 依次分配,最简单。适合所有实例配置相同
加权轮询 按权重分配,权重高的分更多请求。适合异构集群
最少连接 分给当前连接数最少的实例。适合长连接场景
一致性哈希 相同 key 总是路由到同一实例。适合有状态服务
随机 随机选一个。适合大规模集群(大数定律抹平差异)

一致性哈希——为什么它重要

普通哈希 % 实例数 有一个致命问题:实例数变化时,几乎所有请求的映射都会变。

普通哈希:hash(user_id) % 3 → [A, B, C]
  新增实例 D:hash(user_id) % 4 → [A, B, C, D]
  几乎所有 user_id 的映射都变了 → 缓存全部失效

一致性哈希:将实例和请求都映射到同一个哈希环上
  请求顺时针找到的第一个实例就是目标
  新增/删除实例只影响相邻区间的请求
            A
           ╱ ╲
         ╱     ╲
       ╱         ╲
      C ──────── B
       ╲         ╱
         ╲     ╱
           ╲ ╱
            D

  请求 R1 → 顺时针 → 命中 A
  请求 R2 → 顺时针 → 命中 B
  新增 E 在 A 和 B 之间 → 只有原来映射到 B 的部分请求受影响

健康检查

负载均衡器必须知道哪些实例是活的:

主动健康检查(推荐):
  Gateway 每隔 N 秒向所有实例发 /health 请求
  连续 M 次失败 → 标记为不可用,从负载均衡池移除
  恢复后自动加回

被动健康检查:
  根据实际请求的失败率判断
  与熔断器配合:熔断器标记的不可用实例,负载均衡器自动跳过

经验法则:主动健康检查间隔设 5-10 秒,被动健康检查(熔断器)做兜底。两者互补——主动检查发现慢,但稳定;被动检查发现快,但可能误判。

坑八:优雅下线——连接排空

发布新版本时,旧实例要下线。但旧实例上还有正在处理的请求!如果直接杀掉:

❌ 错误做法:
  1. 发送 SIGKILL 杀掉旧实例
  2. 正在处理的请求全部中断
  3. 用户看到 502

✅ 正确做法:连接排空(Connection Draining)
  1. 负载均衡器停止向旧实例发新请求
  2. 旧实例继续处理完已有请求
  3. 等待排空超时(如 30s)
  4. 超时后强制关闭剩余连接
  5. 下线旧实例

经验法则:负载均衡器必须支持连接排空。排空时间设为后端最大请求处理时间的 1.5 倍。这是零停机发布的前提。


第六步:认证中间件

网关统一处理认证,后端服务只管业务逻辑。

JWT 的结构——为什么它适合网关

JWT(JSON Web Token)由三部分组成,用 . 分隔:

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjN9.SflKxwRJSMeKKF2QT4fwpM

Header(算法声明)    Payload(业务数据)    Signature(签名)
  {"alg":"HS256"}      {"user_id":123}       HMAC-SHA256(header + "." + payload, secret)

为什么 JWT 特别适合网关层认证?

传统 Session 认证:
  客户端 → 带 Cookie → 网关 → 查 Redis 验证 Session → 放行
  问题:每次请求都要查 Redis,网关和 Session 存储强耦合

JWT 认证:
  客户端 → 带 Bearer Token → 网关 → 本地验签(不查任何存储)→ 放行
  优势:无状态,网关不需要访问数据库/Redis,只需要 secret key

关键洞察:JWT 的签名验证是纯 CPU 计算,不需要网络调用。这意味着网关层认证的延迟是微秒级的,不会成为瓶颈。

签名验证的数学原理

JWT 的安全性依赖于单向函数——知道 secret 可以算出签名,但知道签名无法反推出 secret:

签名 = HMAC-SHA256(header + "." + payload, secret)

验证过程:
  1. 拆分 token 为 header.payload.signature
  2. 用同样的 secret 计算 HMAC-SHA256(header + "." + payload)
  3. 比对计算结果和 token 中的 signature
  4. 一致 → 未被篡改;不一致 → 拒绝

为什么安全?
  攻击者改了 payload(比如 user_id: 123 → 999)
  但没有 secret,无法算出正确的签名
  网关验签失败 → 拒绝请求

Access Token + Refresh Token 双令牌策略

JWT 一旦签发就无法撤销(除非维护黑名单)。所以生产环境一般用双令牌:

┌─────────────────────────────────────────────────────────┐
│  Access Token(短命,15min)                              │
│  - 每次请求携带,网关本地验签                              │
│  - 过期后用 Refresh Token 换新的                          │
├─────────────────────────────────────────────────────────┤
│  Refresh Token(长命,7d)                                │
│  - 只在 /auth/refresh 接口使用                            │
│  - 存在服务端(Redis),可以主动撤销                       │
└─────────────────────────────────────────────────────────┘

登录流程:
  Client → POST /auth/login → Server
  ← { access_token, refresh_token }

正常请求:
  Client → GET /api/users (Authorization: Bearer access_token) → Gateway
  → 本地验签 → 放行

Access Token 过期:
  Client → GET /api/users → Gateway → 401
  Client → POST /auth/refresh (refresh_token) → Server → 新 access_token
  Client → 重试原请求

经验法则:Access Token 设 15 分钟,Refresh Token 设 7 天。Access Token 过期时间越短,泄露后的影响窗口越小。千万不要把 Refresh Token 放在前端 localStorage——用 HttpOnly Cookie。

用 tower 的 Layer + Service 模式实现,核心逻辑就三步:提取 token → 验签 → 注入用户信息:

fn call(&mut self, mut req: Request<Body>) -> Self::Future {
    let secret = self.secret.clone();
    let mut inner = self.inner.clone();
    Box::pin(async move {
        let token = req.headers()
            .get("Authorization")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "));
 
        match token {
            Some(token) => {
                let key = DecodingKey::from_secret(secret.as_bytes());
                match decode::<Claims>(token, &key, &Validation::default()) {
                    Ok(data) => {
                        req.extensions_mut().insert(data.claims); // 用户信息塞 extensions
                        inner.call(req).await
                    }
                    Err(_) => Ok(Response::builder().status(401).body(Body::from("Invalid Token")).unwrap()),
                }
            }
            None => Ok(Response::builder().status(401).body(Body::from("Missing Token")).unwrap()),
        }
    })
}

坑七:认证失败的响应格式不统一

网关返回 {"error": "Invalid Token"},后端返回 {"code": 401, "msg": "未授权"}。前端要写两套错误处理。

经验法则:网关层的错误响应格式必须和后端统一。建议定义一个标准错误结构体,网关和所有后端服务共用。


组合成完整网关

中间件的洋葱模型

中间件不是"流水线",而是"洋葱"。每一层都可以在请求到达内层之前和响应回到外层之后做处理:

请求进入 ───────────────────────────────────────────→
         │                                        │
         │  Auth: 检查 token                      │
         │    │                                │  │
         │    │  RateLimit: 检查配额            │  │
         │    │    │                        │  │  │
         │    │    │  Breaker: 检查后端状态  │  │  │
         │    │    │    │                │  │  │  │
         │    │    │    │  Proxy: 转发   │  │  │  │
         │    │    │    │    │        │  │  │  │  │
         │    │    │    │    ↓        │  │  │  │  │
         │    │    │    │  后端响应    │  │  │  │  │
         │    │    │    │    │        │  │  │  │  │
         │    │    │    │  添加响应头  │  │  │  │  │
         │    │    │    └────┘        │  │  │  │  │
         │    │    │  记录熔断状态     │  │  │  │  │
         │    │    └────────┘        │  │  │  │  │
         │    │  记录限流计数         │  │  │  │  │
         │    └────────────┘        │  │  │  │  │
         │  注入用户信息到 extensions │  │  │  │  │
         └────────────────────────┘  │  │  │  │  │
←────────────────────────────────────────────────── 响应返回

这就是为什么 tower 用 Layer 而不是简单的"管道"——每一层可以包装内层,拥有完全的控制权。

Layer 的组合数学

tower::Layer 的强大之处在于组合性。每个 Layer 只做一件事,但可以自由组合:

如果顺序反了:RateLimit → Auth
匿名请求也会消耗限流配额 → 恶意用户用无效 token 就能打满限额

经验法则:层的顺序遵循"越便宜的检查越靠前"原则。Auth(CPU 微秒级)→ RateLimit(内存操作)→ CircuitBreaker(内存操作)→ Proxy(网络调用)。快速拒绝的层放前面,避免不必要的网络开销。

用 tower 的 ServiceBuilder 把组件串起来,顺序就是请求的执行顺序:

let gateway = ServiceBuilder::new()
    .layer(AuthLayer { secret: "my-secret".into() })           // ① 先认证
    .layer(RateLimitLayer::new(1000, 100))                      // ② 再限流
    .layer(CircuitBreakerLayer::new(0.5, 100, Duration::from_secs(30))) // ③ 熔断
    .service(ReverseProxyService::new(router));                 // ④ 最后转发

层的顺序很重要:

请求进入 → Auth → RateLimit → CircuitBreaker → LoadBalancer → Proxy → 后端
                    ↑                                        ↑
                    │                                        │
            认证通过才限流                            选一个健康的实例

经验法则:Auth 放最外层——未认证的请求不应该消耗限流配额。CircuitBreaker 放最内层——它保护的是后端,应该在最后一步才判断。


与现成方案的对比

什么时候自研,什么时候用现成方案?

方案 适用场景 优点 缺点
自研网关 业务逻辑定制多 完全可控,轻量 需要维护,功能少
Kong 标准 API 管理 生态丰富,插件多 依赖 Nginx+Lua
APISIX 高性能场景 性能强,插件热更 学习曲线
Envoy Service Mesh 协议支持全面 配置复杂,C++
axum+tower Rust 技术栈 类型安全,零开销 需要自己组合

我的建议

  • 团队小、需求简单 → 直接用 Kong/APISIX,别造轮子
  • 需要深度定制、嵌入业务逻辑 → 自研,用 axum+tower 起步
  • 已经是 Rust 技术栈 → 自研收益最大,代码复用率高

总结:你的网关需要哪些组件?

六个问题帮你决策:

  1. 需要反向代理吗? → 只要有多服务就需要,这是网关的根基
  2. 需要路由吗? → 单体应用不需要,微服务必须有
  3. 需要限流吗? → 对外 API 必须有,内网可以后做
  4. 需要熔断吗? → 后端有强依赖(调用链超过 3 层)才需要
  5. 需要负载均衡吗? → 后端多实例就必须有
  6. 需要认证吗? → 有用户体系就必须有

从最简单的反向代理开始,按需添加组件。不要一开始就造一个"万能网关"。

本文涉及的原理清单

原理 出处 核心思想
排队论 Little's Law 限流 L = λ × W,控制到达速率
令牌桶 vs 漏桶 限流 突发容忍 vs 严格匀速
固定窗口边界突刺 限流 窗口边界瞬时速率翻倍
Radix Tree 路由 压缩前缀树,O(k) 匹配
级联故障 熔断 一个服务挂,全链路雪崩
三态模型 熔断 Closed → Open → Half-Open
指数退避 熔断 避免 flapping(反复开关)
滑动窗口 熔断/限流 基于计数 vs 基于时间
JWT 签名验证 认证 单向函数,本地验签无网络调用
双令牌策略 认证 Access Token 短命 + Refresh Token 长命
洋葱模型 中间件 请求层层进入,响应反向穿出
一致性哈希 负载均衡 实例增减只影响相邻区间
Hop-by-hop Header 反向代理 只在相邻两跳有意义的 Header

最后的教训:网关本身不产生业务价值,它的价值是让后端服务更稳定、更安全。如果你的团队只有 3 个服务、日活 1000,一个 nginx.conf 就够了。等规模上来了再考虑自研。


相关文章: