白物集 API 架构拆解:分层设计背后的工程取舍
一个内容网站的后端 API 长什么样?白物集线上跑了几个月,API 从最初的几个路由膨胀到了二十多个端点。这篇文章直接拆开 api/src/ 目录,看看一个中型 Express 项目的分层设计是怎么组织的。
三个目录,三层职责
白物集 API 的源代码结构很简单:
api/src/
├── index.js # 入口:中间件链 + 路由挂载
├── supabase.js # 数据库客户端
├── routes/ # 路由层(9 个路由模块)
│ ├── health.js
│ ├── articles.js
│ ├── ingest.js
│ ├── cards.js
│ ├── views.js
│ ├── likes.js
│ ├── stats.js
│ ├── tasks.js
│ ├── garage.js
│ └── agent-memory.js
└── lib/ # 服务层
├── validation.js # Zod 校验规则
├── errors.js # 统一错误响应
├── cache.js # NodeCache 封装
├── logger.js # Pino 日志
└── openapi.js # OpenAPI 文档生成
三层各司其职:
- 路由层(routes/):处理 HTTP 请求/响应,不做业务逻辑
- 服务层(lib/):校验、缓存、日志、错误处理
- 数据层(supabase.js):封装 Supabase 客户端,是整个 API 唯一的数据源
中间件链:请求从进来到出去
入口文件 index.js 的中间件挂载顺序决定了每个请求的完整生命周期:
// index.js 的中间件链
app.use((req, res, next) => {
// 1. 请求日志(方法、路径、状态码、耗时)
const start = Date.now();
res.on('finish', () => {
logger.info({ method: req.method, path: req.originalUrl,
status: res.statusCode, durationMs: Date.now() - start });
});
next();
});
// 2. CORS
app.use(cors());
// 3. JSON 解析(限制 10MB)
app.use(express.json({ limit: '10mb' }));
CORS 放在最前,确保跨域请求不因其他中间件拦截。JSON 解析限 10MB 是因为 content_md/content_html 字段可能很大——早报正文加 HTML 转换后经常上百 KB。
然后是限流。白物集对普通 API 和内容入库 API 用了不同的限流策略:
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟窗口
max: 100, // 普通 API 最多 100 次
handler: (req, res) => rateLimited(res, '...')
});
const ingestLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20, // 入库 API 更严格:20 次
});
一个经验:内容入库(ingest)限流要比普通 API 更严,因为 pipeline 自动化写稿可能出错导致频繁 POST。15 分钟 20 次足够 cron 写稿用,也不至于被误触打满。
路由挂载:版本化前缀
所有路由挂在 /api/v1 下:
const v1 = express.Router();
v1.use('/articles', apiLimiter, articlesRouter);
v1.use('/content/ingest', ingestLimiter, ingestRouter);
v1.use('/knowledge-cards', apiLimiter, cardsRouter);
v1.use('/health', healthRouter);
v1.use('/views', apiLimiter, viewsRouter);
v1.use('/likes', apiLimiter, likesRouter);
v1.use('/stats', apiLimiter, statsRouter);
v1.use('/tasks', apiLimiter, tasksRouter);
v1.use('/agent-memory', apiLimiter, agentMemoryRouter);
v1.use('/garage', apiLimiter, garageRouter);
app.use('/api/v1', v1);
几个设计上的取舍:
限流在路由挂载层而非路由模块内。 这样新增路由时不用重复写限流配置,也避免遗漏。只有 health 不需要限流——健康检查本身不应该被限流干扰。
ingest 路由用独立的限流器。 因为 POST /content/ingest 是 pipeline 写稿的唯一入口,频率低但 payload 大,和前端列表请求是两种负载特征。
一个反例: 早期版本把所有限流写死在每个路由模块里,结果新增 agent-memory 路由时忘了加限流,被 hot-article-pipeline 的代理请求打到 Supabase 连接池耗尽。后来移到公共挂载层,这个坑才填上。
路由模块内部:一个统一的模式
每个路由模块遵循相同的结构。以 articles.js 为例:
import { Router } from 'express';
const router = Router();
// 认证中间件(写操作专用)
function requireIngestAuth(req, res, next) {
const token = req.headers['x-api-key'];
if (!token || token !== (process.env.INGEST_API_KEY || '')) {
return unauthorized(res, 'Invalid or missing API key');
}
next();
}
// 公共模式:校验 → 缓存查询 → 数据库查询 → 响应
router.get('/', async (req, res) => {
const parsed = articleQuerySchema.safeParse(req.query);
if (!parsed.success) return zodError(res, parsed.error);
const data = await getOrSet('articles', cacheKey, async () => {
// ... Supabase 查询
}, TTL.ARTICLE_LIST);
res.json(data);
});
认证中间件不全局挂载,而是只在需要写操作的路由(PATCH、DELETE、POST)单独引用。这样列表页的 GET 请求不需要走认证检查,减少不必要的计算。
校验层:Zod 落地经验
白物集用 Zod v4 做入参校验。每个路由模块定义自己的 schema:
// validation.js — 集中的模式定义
export const articleQuerySchema = z.object({
category: z.enum(ALL_CATEGORIES).optional(),
status: z.enum(['published', 'draft', 'archived']).optional().default('published'),
search: z.string().max(200).optional(),
tag: z.string().max(100).optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(20),
offset: z.coerce.number().int().min(0).optional().default(0),
sort: z.enum(['published_at', 'created_at', 'title', 'id']).optional().default('published_at'),
});
几个关键设计:
-
用
z.coerce.number()处理 query string。 Express 的req.query所有字段都是字符串,直接z.number()会校验失败。z.coerce.number()自动转类型。 -
所有枚举值集中在
ALL_CATEGORIES。 前端 constants.ts 和后端 validation.js 维护同一份分类列表,两边不同步会导致「API 返回的数据在前端不显示」。 -
articleCreateSchema的 content_html 设为optional().default('')。 这样即使 pipeline 忘了传 content_html,API 也不会报 500——虽然页面会显示「内容加载中」,但至少数据写进去了,之后可以用 PATCH 补救。 -
给 status 设置默认值
'published',避免 pipeline 忘记传 status 时文章变成 draft。
缓存层:最简单的 NodeCache
白物集没有上 Redis。对于单机 Express 服务,NodeCache 够了:
const cache = new NodeCache({
stdTTL: 300, // 默认 5 分钟
checkperiod: 60, // 每 60 秒清理过期键
useClones: false, // 返回引用而非深拷贝
});
export async function getOrSet(keyPrefix, params, fetchFn, ttl) {
const key = cacheKey(keyPrefix, params);
const cached = cache.get(key);
if (cached !== undefined) return cached;
const data = await fetchFn();
cache.set(key, data, ttl);
return data;
}
不同数据用不同的 TTL:
| 数据类型 | TTL | 原因 |
|---|---|---|
| 文章列表 | 5 分钟 | 首页/频道页更新频率低 |
| 文章详情 | 2 分钟 | 新文章发布后需要较快可见 |
| 分类统计 | 10 分钟 | 几乎不变 |
| 知识卡片 | 5 分钟 | 采集频率低 |
写入操作时主动失效缓存:
export function invalidateArticles() {
invalidate('articles');
invalidate('categories');
}
注意分类缓存也要失效——新增一篇文章后,分类下的文章计数变了。
错误响应:统一的格式
所有错误响应走同一个格式:
{
"error": {
"code": "NOT_FOUND",
"message": "Article #42 not found"
}
}
错误码枚举在 errors.js 中集中管理:
const ERROR_CODES = {
VALIDATION_ERROR: 'VALIDATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
RATE_LIMITED: 'RATE_LIMITED',
INTERNAL_ERROR: 'INTERNAL_ERROR',
BAD_REQUEST: 'BAD_REQUEST',
};
全局错误处理器兜底所有未捕获异常:
app.use((err, req, res, next) => {
const log = logger.child({ method: req.method, path: req.originalUrl });
internalError(res, err, log);
});
这个兜底极度重要。没有它,任何路由中未 catch 的 throw error 都会导致 Express 挂起请求(客户端收到 304 或超时)。白物集踩过一次:Zod v4 升级后 zodError() 函数用 zodErr.errors.map() 取校验失败信息,但 Zod v4 只有 .issues 没有 .errors,导致校验失败时抛出 TypeError: Cannot read properties of undefined (reading 'map')——如果没有全局错误处理器,客户端只会收到一个空响应,排查几乎不可能。
代理透传:garage 模块的特殊设计
garage.js 是一个特殊的路由——它透传到另一个 Supabase 实例(车研库):
// 白名单表
const ALLOWED_TABLES = [
'articles', 'models', 'brands', 'diagnostic_codes',
'parts', 'specifications', 'maintenance_schedules',
'repair_guides', 'knowledge_base',
];
router.get('/rest/v1/*', async (req, res) => {
const tableName = req.params[0].split('?')[0].split('/')[0];
if (!ALLOWED_TABLES.includes(tableName)) {
return unauthorized(res, `Table '${tableName}' is not accessible`);
}
// ... fetch from remote Supabase
});
// 阻止所有非 GET 请求
router.all('/rest/v1/*', (req, res) => {
return unauthorized(res, 'Only GET requests are allowed');
});
这层代理有两个作用:限制可访问的表(白名单而不是黑名单),和限制操作为只读(非 GET 直接拒绝)。这样即使前端被 XSS 拿到 API key,也无法通过这个代理删改车研库的数据。
总结
白物集 API 的分层设计可以用一句话概括:路由管 HTTP,lib 管逻辑,supabase 管数据。 每个模块只做一件事,中间件的挂载顺序决定了请求的完整流程。
这种设计的价值不在理论完美,而在实际迭代中——当你要加一个新的路由时(比如 agent-memory),照着已有的路由模块复制粘贴,改改 schema 和 Supabase 查询,十分钟就能上线。这才是「架构」对独立开发者的真正意义。
下一篇预告:前端怎么调后端接口?白物集 Astro 页面如何通过 SSR API 客户端获取数据、实现列表分页和文章详情。我们直接从 src/lib/api.js 开始拆。