CHAPTER 08 · 全栈

后端、数据与端到端类型

这是你最熟悉的领域:HTTP 接口、数据库、认证、后台任务。本章不重复后端基础,而是讲清楚在 TS 全栈中哪些做法不一样:前后端共享类型、serverless 下的数据库连接、以及“一个函数就是一个接口”带来的新安全模型。

⏱ 约 60 分钟原理(端到端类型、连接模型、授权)会用(Hono、Drizzle、Better Auth)知道(队列与工作流)
读完本章,你应该能够:
  • 说出 TS 后端的三种组织形态,并为一个项目做选择。
  • 用 Drizzle 定义表结构,并解释类型如何从数据库一路流到前端组件。
  • 解释 serverless 环境下数据库连接的问题和常见解法。
  • 把“认证”和“授权”分开,并在数据访问层落实授权检查。
  • 为耗时任务(如 AI 摘要)设计不阻塞请求的处理方式。

开场:改了一个字段名

开场问题

在一个 Java 后端 + 独立前端的项目里,后端把 summary 字段改名为 abstract。前端没有同步修改,上线后详情页显示空白,直到用户反馈才发现。通常的防御手段是接口文档、契约测试和联调。在 TS 全栈项目里,同样的修改能否在按下保存键的那一刻就暴露出来?

答案方向:可以。因为前后端是同一种语言、同一个仓库,类型可以通过 import 从数据库结构一路“流”到界面组件:表结构定义推导出行的类型,服务端函数的返回类型由查询推导,前端调用的结果类型又由服务端函数推导。改了源头,所有下游使用处会立即出现编译错误。这就是“端到端类型安全(end-to-end type safety)”。它不能替代运行时校验(第 04 章),但能消灭一大类“前后端不同步”的 bug。

TS 后端的三种形态

形态是什么例子适合
元框架内置后端代码写在全栈框架里:服务器组件、服务器函数、路由处理器Next.js Server Actions / Route Handlers;TanStack Start createServerFn只有一个 Web 前端的产品;最省事
独立 API 服务一个单独的 HTTP 服务,前端通过 fetch 调用Hono、Fastify、Express、NestJS多客户端(Web + 移动 + 第三方);需要独立扩展和部署
类型化 RPC在独立服务之上,让客户端“像调函数一样”调用接口并获得类型tRPC、oRPC、Hono RPC 客户端前后端都是 TS,希望独立部署又保留类型

对大多数个人项目和早期产品,从第一种开始。当出现第二个客户端(例如移动 App 或开放 API)时,再把业务逻辑抽到独立服务。为了让这次迁移容易,关键是:不要把业务逻辑写在框架特定的文件里,而是写在普通的 TS 模块中,框架层只做“适配”。

分层:和你熟悉的一样,只是更薄

组件 浏览器 / 服务器组件 适配层 server fn / 路由 服务层 业务规则 · 授权 数据访问 Drizzle 查询 数据库 类型的来源与流向(逆着请求方向) ① schema.ts:表结构定义 typeof links.$inferSelect ② 服务函数返回类型 由查询结果自动推导 ③ 组件拿到的数据类型 由 server fn / loader 推导 Zod 输入 schema 既做运行时校验,又推导出参数类型 改 ① 中一个字段名 → ② ③ 的所有使用处立刻报错
上排是请求的方向;下排是类型的方向。类型只需要在两个源头定义:数据库结构和输入 schema,其余全部推导。

和 Spring 的 Controller → Service → Repository 分层对应,但通常更薄:没有依赖注入容器,模块本身就是单例;没有 DTO 类,用类型推导和 Pick/Omit 表达“对外暴露哪些字段”。

Hono:一个到处都能跑的 API 框架 会用

如果需要独立的 API 服务,Hono 是 2026 年最通用的选择:基于 Web 标准的 Request/Response,同一份代码能部署到 Cloudflare Workers、Node、Bun、Deno、Vercel;体积小、速度快、类型推导好。风格上接近 Express 或 Go 的 Gin。

数据库与 ORM 会用

选数据库

选 ORM:Drizzle 还是 Prisma

DrizzlePrisma
定义方式用 TS 代码定义表专用的 schema.prisma 文件,生成客户端
查询风格接近 SQL(select/where/join),也有关系查询 API对象式 API,抽象程度更高
边缘 / serverless轻量,天然适配 Workers 和 D1新版本已改进,但要看具体适配方式
什么时候选懂 SQL、想要透明和轻量团队习惯、需要成熟的工具链(Studio、迁移流程)

本教程使用 Drizzle。作为后端开发者,你会发现它几乎就是“带类型的 SQL”:

迁移(migration):修改 schema.ts 后,运行 drizzle-kit generate 生成 SQL 迁移文件(要审查并提交),再用 drizzle-kit migrate 应用。让 Agent 改表结构时,要求它展示生成的 SQL——删列、改类型这类破坏性变更必须由你确认。

serverless 下的连接问题 原理

传统服务是一个常驻进程,启动时建立一个连接池,所有请求复用。Serverless 函数则可能同时存在成百上千个实例,每个实例各自建立连接——很快就会耗尽 Postgres 的最大连接数(通常只有几百个)。常见解法:

同理,“在模块顶层建一个全局客户端”的写法,在常驻进程里是正确的单例,在某些 serverless 平台上却可能跨请求共享了不该共享的状态。这是第 09 章“运行时的形状”要讨论的话题。

认证与授权 原理

两个词经常被混用,但必须分开思考:

方案类型特点
Better Auth开源库,自托管TS 原生;用户和会话存在你自己的数据库;插件支持 OAuth、2FA、组织等;可跑在各种平台
Clerk托管服务开箱即用的登录界面和用户管理;按用户量收费;数据在第三方
Auth.js开源库历史悠久,与 Next.js 集成深
Supabase Auth托管(随数据库)与 Supabase 数据库的行级安全(RLS)结合
授权最常见的漏洞:IDOR

“不安全的直接对象引用(Insecure Direct Object Reference,IDOR)”:接口是 DELETE /links/:id,代码只检查了“用户已登录”,就按 id 删除。结果任何登录用户都能删除别人的收藏。防御方法就是上面 remove 函数的写法:把“属于当前用户”作为查询条件的一部分,而不是查出来之后再比较(后者容易被遗漏)。给 Agent 的每个涉及数据修改的任务,都应该明确写出授权规则。

校验与错误处理

和 Java 相比,TS 社区在错误处理上没有统一约定。实用的做法:

  1. 入口校验:所有外部输入在适配层用 Zod 解析,失败返回 400/422 和字段级错误信息。
  2. 预期内的业务失败用返回值表达:例如 { ok: false, reason: "duplicate" } 这样的判别联合,调用方被类型系统强制处理每种情况。
  3. 意外错误抛出异常,由框架的统一错误处理器记录日志并返回 500,不把堆栈暴露给客户端。

耗时任务:不要让用户等在请求上 知道

LinkNote 的“抓取网页 + LLM 生成摘要”可能需要 10~60 秒。如果放在保存请求里同步完成:用户盯着转圈;serverless 函数可能超时;中途失败还得整体重来。标准做法是异步化:

① 保存请求写入 status=pending ② 投递任务立即返回 202 ③ 队列 / 工作流重试 · 分步 · 持久化 ④ 执行抓取 → LLM → 写库 ⑤ 通知SSE / 轮询 Cloudflare:Queues / Workflows Vercel:Queues / Workflow 第三方:Inngest、Trigger.dev 自托管:BullMQ + Redis
用户在第 ② 步就拿到响应,界面显示“生成中”;结果就绪后通过推送或轮询更新。

“工作流(workflow / durable execution)”是近年流行的升级版:把任务写成多个步骤,平台持久化每一步的结果,失败只重试失败的那一步,还能“等待几小时后继续”。对 AI 应用(多步调用、需要人工审批)特别合适。

其他常见的后端组件:对象存储(S3、Cloudflare R2、Vercel Blob)存文件,通常用预签名 URL 让浏览器直传;键值缓存(Redis、Upstash、Cloudflare KV)做缓存和限流;定时任务(cron)由平台提供。知道

对 Agent 怎么说
模糊
精确
“写一个删除收藏的接口。”
“新增删除收藏:服务层函数 remove(userId, id),删除条件同时包含 id 和 userId(防止 IDOR),返回是否删除成功。适配层:未登录 401,id 不是 UUID 返回 400,删除不到返回 404。为‘删除他人的收藏’写一个测试,预期 404。”
“把摘要生成做成后台任务。”
“保存链接时只写入 status=pending 并投递到队列,接口立即返回 202。消费者抓取网页(10 秒超时)、调用 LLM 生成摘要,成功写 done,失败写 failed 和错误原因;最多重试 3 次,指数退避。任务必须幂等:同一个 link id 重复执行不会产生重复数据。”

本章词汇:端到端类型安全、单一事实来源、适配层 / 服务层、迁移、连接池、认证 vs 授权、IDOR、幂等(idempotent)、指数退避(exponential backoff)、异步任务 / 队列 / 持久化工作流。

面试角
  • tRPC / 服务器函数和 REST 怎么选?只服务自己的 TS 前端时,类型化 RPC 开发效率最高;需要开放给第三方或多语言客户端时,REST(配 OpenAPI)更合适。
  • serverless 连数据库有什么问题?实例多、生命周期短,容易耗尽连接;用连接池代理、HTTP 驱动或 Hyperdrive。
  • 如何保证后台任务可以安全重试?设计成幂等:用业务 ID 去重、先检查状态再执行、写入使用 upsert。

自检

1. 你把 links.title 改成了 links.headline,重新运行类型检查。哪些地方会报错?哪些地方不会报错但仍然会出问题?

会报错:所有通过推导类型访问 .title 的服务层、服务器函数和组件代码。不会报错:数据库本身(需要迁移才会改列名);用 as 断言或 any 绕过类型的地方;手写的 SQL 字符串;外部系统(如缓存里的旧数据、第三方调用你 API 的客户端)。端到端类型只覆盖“同一次编译”内的代码。

2. Agent 写的服务层是:先 findById(id),然后 if (link.userId !== user.id) throw Forbidden,再删除。这样写安全吗?和把条件写进 where 相比有什么差别?

逻辑上是安全的,但有两个弱点:一是依赖每个开发者(和 Agent)记得写这个 if,容易在新接口里遗漏;二是“检查”和“删除”是两次操作,中间有时间窗口(对删除影响不大,对更新可能有竞态)。写进 where 让授权成为查询本身的一部分,更难被遗漏。返回 404 而不是 403 还能避免泄露“这个 id 存在”的信息。

3. 某个 serverless 应用在流量高峰时报 “too many connections”,低峰时一切正常。最可能的原因是什么?

高峰时平台启动了大量函数实例,每个实例都建立了自己的数据库连接(或连接池),总数超过了数据库上限。解决:换用连接池地址 / serverless 驱动 / Hyperdrive,并确认每个实例的连接池大小很小(如 1)。