从入口到回包:一篇看懂 gateway 架构思路

注: 本文是对已经部署在生产环境应用下的基于 gateway 的三层架构复盘解析

太长不看版

原架构图图链接

目录

项目定位:gateway 到底在做什么

先说结论,gateway 放在整套系统里看,就是三层后端网关架构里的第一层入口。

它站在浏览器请求和真正内容服务之间,先按域名识别站点,再按 URL 判断这次请求该走哪条链路,然后把流量转发到后续的 nginx、静态资源、SSR 服务或者 FaaS。除此之外,这一层还顺手把跨域、鉴权、缓存更新、日志和错误兜底这些通用能力兜住了。

如果只盯着代码里的 proxy()faas()serveStatic(),会觉得这层什么都沾一点。但把部署关系放回来看,它做的其实是一件更收敛的事:先把请求判断清楚,再把流量送到后面真正产出内容的层里。

整体架构图

先看一张手绘版的主干图:

Browser / App / Bot
|
v
Layer 1: Koa gateway
|
+--> compress
+--> caught
+--> cors
+--> site
+--> auth
+--> updateCache
+--> route
|
v
choose site router by hostname
|
+--> decide where to forward
| |
| +--> proxy to nginx / cache proxy
| +--> static asset route
| +--> Kasha SSR route
| +--> FaaS route
|
+--> awtmt
+--> oia
+--> polyfill

Layer 2: nginx / cache proxy
|
+--> static files
+--> k8s faas service
+--> other upstream services

Layer 3: content/runtime layer
|
+--> object storage
+--> SSR bundle
+--> Node VM in FaaS

Supporting services:
- COS / public storage: 静态资源、FaaS 配置、SSR 资源
- @wscn/faas: 拉取并执行函数
- Kasha service: SSR 渲染
- Redis stream: 缓存刷新通知
- cache proxy / nginx: 上游代理与缓存层

图里最容易看漏的一点是:gateway 本身不是内容层,它就是第一层有业务判断能力的转发入口。

真正的静态资源读取、SSR 渲染和函数执行,更多发生在后面的 nginx、对象存储和 FaaS 运行时里。

结合 nginx/default.conf 看第二层代理

这部分如果不把 nginx/default.conf 放进来,整条链路其实是缺一截的。单看 gateway 代码,会误以为第一层已经把很多事情做完了;把 nginx 配置接上以后,第二层真正承接的职责才会完整露出来。

这份配置里,nginx 干的主要是两类事情。一类是做通用的缓存代理层,代替 gateway 去访问真正的上游;另一类是给一部分固定域名直接做代理,或者直接回源静态资源,不再经过复杂的业务分发。

第一类:通用的 /http 代理层

先看配置开头这段:

map $request_uri $upstream {
~^/(.+)$ $1;
}

server {
listen 80;

location /http {
proxy_pass $upstream;
proxy_set_header X-Cache-Prefix "";
proxy_cache_key $http_x_cache_prefix$upstream;
}
}

这段做的事很直接,就是把请求路径本身当成目标地址,再由 nginx 往外发。

例如 gateway 想访问某个上游 URL 时,并不一定自己直连,而是会把目标 URL 拼到缓存代理地址后面。对应到 src/lib/request.ts,它默认会把请求改写成:

cacheProxyUrl + 原始目标 URL

这样一来,请求会先到 nginx 的 /http,再由 nginx 继续 proxy_pass 到真正的上游。

这样拆有两个很实际的好处。一个是 gateway 自己不用承担缓存代理细节,统一交给 nginx 处理;另一个是 nginx 可以基于 x-cache-prefix + upstream 生成缓存 key,把不同设备、不同函数入口、不同降级策略对应的内容隔离缓存。

从配置上看,这层 nginx 还打开了一整套代理缓存能力,包括:

  • proxy_cache
  • proxy_cache_background_update
  • proxy_cache_use_stale
  • proxy_cache_lock
  • proxy_cache_revalidate

所以这里的 nginx 不是纯转发,它确实在扮演第二层缓存代理。

第二类:专门域名的直连代理或静态回源

default.conf 里还有不少 server_name 配置,它们不是给通用代理 /http 用的,而是给具体域名单独定行为。

差不多能分成几类。

1. 直接回源到 COS 静态资源

这类站点非常多,比如:

  • juicy.wallstcn.com
  • juicy-sit.wallstcn.com
  • carrot.wallstcn.com
  • carrot-sit.wallstcn.com
  • pear-wscn.xuangubao.cn
  • pear-wscn-sit.xuangubao.cn
  • oia.wallstreetcn.com

这些域名的 location / 基本都是直接 proxy_pass 到 COS 上的某个 index.html,例如:

location / {
proxy_pass http://static-1258626455.cos.ap-shanghai.myqcloud.com/juicy/index.html?;
}

说白了,这类配置就是 nginx 直接把对象存储里的静态站点挂出来。

2. 根据设备类型选择不同静态站点

org.wallstreetcn.comorg-sit.wallstreetcn.comorg-stage.wallstreetcn.com 这几组域名,会根据 User-Agent 先判断桌面端还是移动端,然后再决定回源哪个静态目录:

  • 桌面端走 org-pc
  • 移动端走 org-mobile
  • 不同环境再通过 @sit@stage 区分

也就是说,第二层 nginx 自己也会做一部分轻量路由,不是所有设备判断都压在 gateway 这一层。

3. 直接代理到 k8s 内部服务

配置里也有一部分域名不是去 COS,而是直接进集群服务,例如:

  • kasha-server.wallstcn.com 代理到 kasha-server.default.svc.cluster.local
  • yunshanapp.com 根据桌面端/移动端分别代理到 yunshan.default.svc.cluster.localyunshan-mobile.default.svc.cluster.local

从这类配置也能看出来,nginx 不只是静态资源入口,它还包了一层 k8s 内部服务的外部代理。

4. 做跳转和兼容入口

配置里还有一些明显属于入口治理的规则,例如:

  • www.wallstreetcn.com 跳转到 https://wallstreetcn.com
  • www.awtmt.com 跳转到 https://awtmt.com
  • 一些旧域名跳到新域名
  • cors.wallstcn.com 这类域名用于跨域下载第三方资源

这些规则放在一起看,nginx 这一层除了缓存和代理,还顺手把一部分历史兼容和域名治理接住了。

gateway 和 nginx 放在一起看

把两边代码连起来看,链路就比较顺了。gateway 先识别 host、path、环境和项目配置;一旦决定走代理链路,通常会通过 request()proxy() 把请求送到 nginx 一侧。nginx 再决定是去上游服务、COS 静态资源,还是 k8s 中的服务。要是继续进入 FaaS,那么 FaaS 运行时会再从对象存储拿函数代码或者 SSR 资源,在 Node VM 里执行渲染,最后结果再沿着 nginx -> gateway -> 客户端这条方向返回。

把两层放在一起看会更顺一些:gateway 先做业务判断,nginx 再把这条路真正铺下去,包括缓存、代理和一部分静态站点直出。这样分层以后,第一层不需要背太重的网络细节,第二层也不用理解太多业务语义。

启动时,服务做了什么

项目入口在 src/server/index.ts,事情不多,但都是主干上的东西:创建一个 Koa 应用,把统一中间件链串起来,然后监听端口开始接请求。

最核心的启动代码可以浓缩成这样:

app
.use(compress())
.use(caught)
.use(cors)
.use(site)
.use(auth)
.use(updateCache)
.use(route)
.listen(argv.port)

这几层中间件的顺序很重要,因为它直接决定了“请求先被谁处理,后被谁处理”。

除了 Koa 启动本身,项目里还有两个“导入即初始化”的动作。一个在 src/lib/faas.ts,启动时会从对象存储里拉取 faas*.json 配置,并且每 60 秒刷新一次;另一个在 src/server/updateCache.ts,启动时会连接 Redis,持续消费 recent-updates 这个 stream,用来维护本地的缓存失效信息。

这也是为什么这层虽然叫网关,但又不是一个特别“薄”的网关。它表面上主要做转发,实际上还是维护了一部分运行时状态,不然很多动态分发和缓存治理根本立不住。

一次请求是怎么走完整条链路的

第一步:进入 Koa 中间件链

一个请求进来以后,会先过公共层。compress 负责压缩响应内容,caught 负责记录请求日志并把抛出的异常转换成统一错误响应,cors 则处理跨域请求,必要时直接返回 204

这三层都比较标准,真正开始体现业务判断的是后面几层。

第二步:根据域名选择站点

site 中间件会根据 ctx.hostname 找到当前请求属于哪个站点。

这个设计很朴素,但很实用:同一个网关进程可以同时服务多个站点,每个站点各自有 hosts 配置和路由表。

当前项目里主要有四类站点:wallstreetcnawtmtoiapolyfill

一旦匹配成功,ctx.state.site 就被写入上下文,后续所有逻辑都基于这个站点继续分发。

同时,site 中间件还会生成一个 ctx.state.env() 辅助函数,用来把 sitstagepreview 这种环境信息拼接到项目名、资源路径和配置 key 里。

第三步:做访问控制

auth 中间件只对配置里声明了 auth: true 的 host 生效。

放行条件其实就那几种:要么当前 host 根本不需要鉴权,要么请求来源在白名单域名里,要么 Cookie 里的 gateway_key 命中了配置项 authKeys,再不然就是请求本身来自华尔街见闻 App。

只要四个条件里命中一个,请求就继续往下走;否则直接返回一个 403 页面。

所以 gateway 不只是对外流量入口,也顺带把测试环境、预发环境的入口保护做了。

第四步:处理缓存失效

updateCache 这个模块很容易一眼带过,但它其实挺关键。

它做的事情其实不多。如果收到 DELETE 请求,就把这个 URL 的“失效事件”写进 Redis stream;如果收到普通请求,就看这个 URL 最近有没有失效记录,有的话就自动给查询参数追加 _cacheKey

这里的做法不是直接跑去清理上游缓存,而是改写请求参数,让后面的缓存系统自然命中一个新的 key。

效果上看,这并不是强行删除旧缓存,而是让后续请求切换到新的缓存版本。

第五步:进入具体站点路由

到了 route 中间件,请求就正式交给具体站点的路由器了:

const middleware: Middleware = (ctx, next) =>
ctx.state.site.router.middleware(ctx, next)

这一步很轻,但它意味着前面的所有公共处理,最终都会收束到某个 site router 上。

后面的逻辑,核心就变成一句话:

这个 URL,到底应该由谁来返回内容?

如果放到整套部署架构里,这句话也可以换成:

这个 URL,应该先被转发到哪一层,再由哪一个后端模块继续处理。

主站 wallstreetcn 的分发逻辑

src/sites/wallstreetcn/index.ts 基本就是主站分发逻辑最集中的地方。这个文件看完,差不多就能知道这套网关为什么会越长越像一个“流量调度层”,而不是单纯的反向代理。

按职责拆开看,大概能分成三层。

第一层:显式路由优先

像首页、文章页、快讯页、作者页、下载页、法律页、OAuth 回调页这些,都先用显式路由单独拦出来。

这么拆开以后,好处也很直接。业务关键路径会更稳定,这些页面可以有各自独立的处理策略,也不会一股脑掉进最后的 catch-all 把逻辑搅在一起。

例如:

  • /oauth/callback 走 OAuth 回调处理。
  • /download 走下载逻辑。
  • /robots.txt/sitemap 直接交给 Kasha。
  • 一些旧页面路径交给 serveLegacy()

第二层:主站页面按设备和场景分流

主站里有个很重要的判断函数叫 newSiteSwitch()

它背后的想法其实不复杂。某些页面在移动端更适合走 FaaS,机器人流量也更适合走 FaaS;桌面端的普通流量,大多数时候则交给 Kasha SSR。

说得直接一点,这就是一个“按设备和访问者类型挑后端”的开关。

这背后体现的是一个现实问题:同样是页面请求,给移动端、给搜索引擎爬虫、给桌面浏览器,最合适的返回链路可能并不一样。

第三层:最后的 catch-all 做通用分发

真正的主干逻辑在最后那个 (.*) 路由里。

它大致是下面这个判断顺序:

1. 先从 URL 第一段里取 projectName
2. 去 faas 配置里查这个 projectName 有没有配置
3. 如果有配置:
- 访问的是 .js -> 当作静态资源
- 有 server -> 走 FaaS
- 只有 client -> 走静态文件
4. 如果没命中项目配置:
- 如果 URL 看起来像静态文件 -> 走公共静态资源
- 否则 -> 走 Kasha SSR

这段逻辑能看出,这个网关并不是把页面路由全部写死了,而是留了一套相对动态的项目接入方式。

某个项目只要在配置中心里挂上:

{
"某个key": {
"client": "xxx/index.html",
"server": "xxx/server.js"
}
}

配置一挂上去,gateway 就能识别这个项目,然后决定它该走静态资源、FaaS,还是两者混合。

这种写法的好处很直接,接新项目的时候,不需要总去碰主路由;代价也很明确,项目命名、配置结构和对象存储里的布局要长期保持一致。

几个核心模块,分别负责什么

前面的链路按请求顺序走了一遍,这里再换个角度,拆开看几个关键模块各自把哪块复杂度接住了。

src/server/index.ts:总装配入口

它负责把所有中间件串起来,就是请求生命周期真正开始的地方。

src/server/site.ts:按域名选站点

它解决的问题是:同一个网关进程,如何服务多个域名、多套站点逻辑。

这个模块本身不复杂,但它是整个多站点设计成立的前提。

src/server/auth/index.ts:环境保护

它不是常见的用户登录鉴权,更像站点入口的准入控制。

它主要保护的是 sitstagepreview 之类的环境域名,防止这些环境被无关用户直接访问。

src/server/updateCache.ts:缓存失效广播

这个模块很有平台侧的写法,因为它解决的已经不是单个请求怎么处理,而是多实例下缓存怎么一起失效。

它用 Redis stream 把“哪些 URL 最近被更新过”同步给网关实例,然后通过附加 _cacheKey 的方式,让后续请求自动避开旧缓存。

这个思路比“每层缓存都主动去删”更轻,也更适合分布式场景。

src/lib/serveStatic.ts:统一静态资源读取

它负责从 publicStorageUrl 指向的对象存储里拉取资源,并且在进程内做 TTL 缓存。

这里有个细节挺有意思:

  • version 参数的资源,缓存时间按正常 TTL 走。
  • 不带 version 参数的资源,只缓存 1 分钟。

这里的思路很明确:带版本号的资源适合长缓存,不带版本号的资源变化风险更高。

这是一个很常见也很实用的前端静态资源策略。

src/lib/faas.ts:函数配置加载 + 函数执行入口

这个模块主要干两件事:一是从 COS 里读取 faas*.json 项目配置,并定时刷新;二是提供统一的 FaaS 调用入口。

里面还有一个很关键的分支。如果允许缓存,就不直接执行函数,而是代理到 FaaS 服务地址,并带上 x-function-urlx-cache-prefix;如果不允许缓存,就直接调用 @wscn/faas 执行函数。

所以它不只是“把函数跑起来”这么简单,中间还夹着一层缓存策略选择。

放回整套部署关系里,这一层就是在决定怎么把请求送进 FaaS 链路。gateway 先根据配置判断该打到哪个函数入口,再把请求交给后面的 FaaS 服务。FaaS 自己跑在 k8s 里,本身就是一个基于 Node VM 的运行时。它会按函数地址去对象存储拿 SSR 资源或者 server bundle,跑完渲染以后,再把结果沿着上游链路送回来。

src/middleware/kasha.ts:SSR 服务代理层

Kasha 在这里就是另一个内容生产方,做的是 SSR。

gateway 调 Kasha 时,会额外带上几类信息:kasha-profile 用来告诉对方当前要按 mobile 还是 desktop 渲染,kasha-fallback 用来表达是否允许降级,x-cache-prefix 则帮助上游缓存区分不同变体。

所以它也不是普通转发,而是一个带业务语义的代理层。

src/lib/request.ts / src/lib/proxy.ts:统一代理出口

这两个模块分工很清楚。request.ts 负责真的发 HTTP 请求,proxy.ts 负责把 Koa 上下文和外部请求、响应桥接起来。

而且 request.ts 默认会走 cacheProxyUrl,说明这个项目天然把“上游缓存代理”当作整个架构的一部分,而不是可有可无的附属能力。

从部署层次看,proxy() 这一层就是 gateway 把流量送到后面 nginx 或代理层的主要出口。后面到底是 nginx 回源静态文件、继续代理到别的服务,还是再转到 k8s 里的 FaaS,就已经是第二层和第三层的事了。

把响应路径也补上会更完整:gateway 先把请求送到 nginx 一侧,nginx 再去静态资源或 FaaS 运行时拿结果;结果出来以后,再沿着反方向一层层返回,最后送回客户端。

另外几个站点怎么处理

虽然主站最复杂,但另外三个站点也能帮助理解这个网关的设计边界。

awtmt

awtmt 这边的逻辑明显更轻。静态文件走对象存储,移动端直接返回静态 index.html,其他流量则代理到 tmt.default.svc.cluster.local

这一点挺能说明问题:同样一个入口,不同站点后面接的后端模型可以完全不一样。

oia

oia 更像一个下载落地页或者导流站点。静态资源直接返回;在微信里会跳应用宝,iOS 会跳 App Store,其他情况就返回站点首页。

这种站点几乎没有复杂 SSR,但仍然受益于统一的域名接入和资源托管方式。

polyfill

polyfill 基本就是一层定制化代理。

它会根据 User-Agent 归一化出浏览器标识,再把这个标识放进请求头和缓存前缀里,目的是让 polyfill 结果更容易被缓存、复用。

这里也能看出来,网关不只是分页面,它还会顺手把协议和缓存语义补完整。

配置与环境命名是怎么串起来的

这个项目有个很明显的平台化特征:很多东西都没硬写在路由里,而是靠命名约定和配置文件串起来。

例如环境辅助函数 env() 的行为大概是:

  • 生产环境默认不加后缀。
  • sitstagepreview 会被拼进项目 key 或路径里。

所以你会在代码里看到很多这样的形式:

  • wscn/wallstreetcn.com
  • wscn_sit/download
  • ivanka-mobile@sit/index.html
  • faas_sit.json

这些命名虽然看起来零散,但其实是在表达同一件事:

同一套网关逻辑,可以通过环境后缀切到不同配置、不同静态资源、不同函数入口。

这也是为什么 gateway 能比较自然地同时承接生产、测试、预发等多环境流量。

cosupfaasfaas-config 和这个项目是什么关系

这几个库和 gateway 的关系,按职责拆开就比较清楚了。

@wscn/faas

这是 gateway 在运行时真正直接依赖的能力。

它负责把某个函数入口拉下来并执行,返回的是一个接近 HTTP 响应的数据结构,比如:

  • statusCode
  • statusMessage
  • headers
  • body

gateway 只是决定“什么时候调用它、传哪个函数地址、是否允许走缓存代理”。

faas-config

它更偏配置发布工具,不是运行时逻辑本身。

它维护的核心数据结构,就是“项目 key -> client/server 配置”的映射。

gateway 运行时会去 COS 上读取这份配置,因此 faas-config 更接近配置生产和发布环节。

cosup

按职责看,它就是个上传工具,主要服务部署流程。

它负责把静态资源、构建产物,甚至可能包括某些函数文件上传到对象存储。

把整条链路串起来,看起来是这样的:

开发 / 构建产物
|
+--> cosup 上传静态资源 / 文件
+--> faas-config 发布项目映射配置
|
v
对象存储 / 配置文件
|
v
gateway 读取配置并决定转发路径
|
+--> 转给 nginx / cache proxy
|
+--> 静态资源链路
+--> k8s 中的 faas
|
+--> Node VM 拉取 SSR 资源并渲染

这也解释了为什么在 gateway 仓库里你看不到太多 cosupfaas-config 的直接调用。

因为它们更多属于“部署链路”和“配置生产链路”,不是“请求处理链路”。

这套架构为什么会长成这样

代码和部署方式放在一起看,这套架构之所以会长成现在这个样子,背后其实对应了几类很现实的问题。

第一,分发逻辑是配置驱动的

项目没有把所有页面和项目都写死在路由里,而是通过 faas*.json 这类配置,把 clientserver 入口映射到具体项目上。

这样做最直接的好处是,新项目接入时不一定要改主干代码。只要命名约定和配置结构保持一致,就能接到现有体系里。代价是这套约定会慢慢变成隐性契约,后面的人如果不熟,很容易在 key、路径和环境后缀上踩坑。

第二,缓存治理是轻量化的

缓存失效没有通过“层层删除缓存”来实现,而是通过 Redis stream 广播更新事件,再由网关在请求侧追加 _cacheKey

这里选的不是“最强控制力”的方案,而是一个更轻、更适合多实例部署的方案。它不追求所有缓存层都被精确操控,而是优先保证这套东西能跑得稳、扩得开。

第三,同一个入口兼容了多种内容生产方式

在这一个网关入口里,同时容纳了静态资源链路、SSR 服务代理、FaaS 链路、旧站兼容和普通后端代理。

这种统一入口对接入方是友好的,外面看到的是一个域名和一套路由规则,背后到底是静态站点、SSR 服务、FaaS 还是别的代理链路,可以在内部慢慢演进。

第四,多环境切换依赖命名约定而不是分叉代码

sitstagepreview 等环境并没有复制出多套实现,而是通过 host、路径、配置 key 和资源命名中的环境后缀完成切换。

这类做法的好处是环境扩展比较省事,不用把代码分成好几套;但反过来讲,命名体系一旦失控,排查问题时也会变得很绕,因为 host、路径、对象存储目录和配置 key 都得一起对。

回头看这套架构

回头看这套架构,gateway 的位置其实挺清楚,它不是最终内容层,也不是最底层的网络代理,而是卡在中间那个“先判断、再分发”的位置上。

这一层真正接住的,其实就是几类判断:这个请求属于哪个站点,这个站点当前环境是什么,这个路径应该走静态资源、SSR、FaaS 还是代理,这次请求要不要鉴权保护,以及要不要绕开旧缓存。

这些判断做完以后,请求才会被送到后面的 nginx、静态资源、SSR 服务或者 FaaS 运行时。

从复盘角度看,这套设计的核心价值不在于某个模块写得多巧,而在于它把几件原本容易散掉的事情收到了一个入口里:多站点接入、环境切换、缓存治理、静态资源分发、SSR 和 FaaS 链路。

代价也不是没有。配置驱动、多层代理、命名约定、对象存储布局,这些东西叠在一起以后,理解成本会比一个单体应用高不少。只是对这种承接多站点、多环境、混合渲染链路的系统来说,这种复杂度基本躲不掉,它只是被比较集中地放到了网关和配置层里。