跳到主要内容

第 12 章 服务端:业务逻辑的家

副题:分层、框架、中间件——请求到达服务器之后,业务规则住在哪里

第 11 章结束,请求终于到达了服务器:POST /v1/contents 的报文落在一台真实机器上。谁先接待它?怎么把报文变成业务逻辑的调用?业务规则(谁能发内容、内容多长、存到哪)又住在哪里?本章回答这三个问题——跟着一次请求,把整个后端走一遍。

本章要建立的认知

  1. 业务规则需要一个家:分层(表现/业务/数据访问)把"请求怎么处理"和"业务怎么决策"和"数据怎么存取"分开——每一层都有自己的职责;
  2. 框架是"请求 → 响应"的答案:路由(去哪)、中间件(路上办什么事)、控制器(接待)——这是第 9 章"前后端框架同一思路"的服务端兑现;
  3. 分层有代价也有边界:过度分层是另一种乱——分层粒度是决策,不是习惯。

12.1 一坨代码

请求到达了。第一版的后端,处理"发布内容"的代码长这样(示意,真实版本更长):

app.post('/v1/contents', (req, res) => {
// 1. 检查登录
const token = req.headers.authorization;
if (!token || !verifyToken(token)) { res.status(401).json({ code: 'UNAUTHORIZED' }); return; }
const userId = getUserId(token);
// 2. 校验内容
const { title, body } = req.body;
if (!title || title.length > 100) { res.status(400).json({ code: 'INVALID_TITLE' }); return; }
if (!body || body.length > 10000) { res.status(400).json({ code: 'INVALID_BODY' }); return; }
// 3. 敏感词检查
if (containsBadWord(title + body)) { res.status(400).json({ code: 'BAD_WORD' }); return; }
// 4. 写入数据库
const id = db.insert('content', { userId, title, body });
// 5. 发通知
notifyFollowers(userId, id);
// 6. 返回
res.status(201).json({ id, title });
});

这段代码能跑。但一个月后,三个问题一起爆发:

**问题一:改一个功能,崩了另一个。**产品说"发布内容要加个图片字段"——你往第 2 步加校验;然后发现登录逻辑被影响了(第 1 步和第 2 步共用了一个变量,改的时候没注意)。没有边界的代码,改动像地震——震中在这头,裂痕在那头

问题二:同一个逻辑,复制了三份。"检查登录"在发布内容里有、评论里有、下单里也有——每处复制一份。后来登录规则改了(token 过期时间变了),你改了发布内容的,忘了改评论的——评论还能用旧 token 登录。复制三份的逻辑,迟早会变成三份不同的逻辑。(这个问题的严重度要排最高:复制三份的登录逻辑不只是维护问题——某一份忘了改,可能就是一个安全漏洞,第 19 章会看到这类漏洞的真实案例。)

问题三:没法测试。"发布内容"这个业务——你怎么测?要模拟 HTTP 请求、要登录、要数据库……测试一个业务规则,得先把整个环境搭起来。业务逻辑和 HTTP 报文、数据库绑死在一起,谁也测不了谁

三个问题之外,还有一个隐藏的第四问题:错误处理散落。第 7 章设计的错误码(OUT_OF_STOCK、UNAUTHORIZED……),在一坨代码里变成了每个分支手写一行 res.status(400).json({ code: '...' })——第 7 章说"错误响应的结构必须全项目一致",但一坨代码里,每个分支的写法都可能不一样(有人写 code: 'BAD_WORD',有人写 code: 'badword'——前端对应表对不上)。错误码设计的落地,需要代码结构来保证——这一点后面会给出答案(12.4 的错误处理中间件)。

错误处理的"两种风格"也提前认识(团队里常吵):返回错误对象(业务层返回 { error: {...} },调用方自己判断——本章示例用的)和抛异常(业务层抛 BadWordError,由错误处理中间件统一接住转成响应)。两种都成立:返回对象适合"错误是业务的一部分"(库存不足是正常业务结果);抛异常适合"错误是意外"(数据库挂了)。选一种写进团队规范,比两种混着用好——混用的代码,错误处理逻辑比业务逻辑还难读。

这四个问题的根源是同一个:这段代码把四件不同的事混在了一起——请求怎么接待(HTTP)、用户是谁(登录)、业务规则是什么(能发什么内容)、数据存在哪(数据库)。四件事的变化频率完全不同:接口格式偶尔变、登录规则几个月变一次、业务规则跟着产品变、数据库结构按版本变——变化频率不同的东西混在一起,改一个就要动全部(第 8 章"三层分工"的同一句话,在服务端重新上演)。

为什么 1-3 年的开发者特别容易写出一坨代码?和 9.1 的泥潭同一个原因:先跑通后组织——接口从两三个开始长,每个接口单独看都合理("就几十行")。一坨的演化通常分三个阶段:接口 3 个时——完全没问题(代码短,改起来一目了然);接口 6 个时——开始复制("检查登录"这段写第二遍了),但还能忍;接口 12 个时(第 7 章的清单全部上线)——复制了三份的逻辑开始各改各的,改 A 崩 B 成为日常。一坨不是"一开始就写坏了",是"每个功能加一行,慢慢长坏的"——第 2 章的演进循环,在服务端代码里转完了一圈:接口数量上升(复杂度)→ 一坨无法承受(改 A 崩 B)→ 分层出现(新技术)→ 解决 → 新代价(样板代码,12.6)。

第 9 章的泥潭和本章的一坨,是同一个病的两种症状:前端泥潭是"状态同步"失控(改 A 忘 B),后端一坨是"职责混合"失控(改 A 崩 B)——病根都是"变化频率不同的东西混在一起"。第 9 章的解法是声明式(状态是唯一真相),本章的解法是分层(职责各归其位)——两个解法都是"把混在一起的东西分开"

第 9 章说过:页面复杂度一上来,前端转向了声明式和组件化。服务端也一样——业务复杂度一上来,服务端转向了分层。下一节,把这一坨代码拆开。

12.2 分层:业务规则的家

分层的思路,是把"一坨代码"按职责拆成几层,每一层只回答一个问题:

表现层(接口层) ← 请求怎么接待:解析报文、校验格式、返回响应

业务层(服务层) ← 业务规则:谁能发、内容多长、要不要发通知

数据访问层 ← 数据怎么存取:读哪张表、写哪条记录

数据库
图12-1 图稿占位
服务端分层
表现/业务/数据访问分层

三层的职责边界要划清(这是本章的核心):

表现层(controller,控制器):只做两件事——解析请求(从报文里取出参数)和组装响应(把结果变成第 7 章的契约格式)。它不决策:内容合不合法、用户有没有权限——不是表现层的事。表现层是"翻译官":把 HTTP 语言翻译成业务语言,再把业务结果翻译回 HTTP。

表现层内部还能再细一层(很多团队这么分):路由是"地图",控制器是"接待员"——路由管"这个 URL 去哪"(12.3),控制器管"到了之后怎么接"(取参数、调业务、组响应)。地图和接待员分开的好处:接口清单(第 7 章)在路由文件里一览无余——改接口先看路由,改逻辑再看控制器

业务层(service,服务):业务规则的家。谁能发布内容(登录了就行?还是要等级?)、内容多长合法、敏感词检查、要不要发通知——这些决策全部住在业务层。业务层是"大脑":它不关心请求怎么来的(HTTP?测试调用?),也不关心数据存哪——它只做判断和编排。

数据访问层(repository/DAO):数据和代码的边界。读哪张表、写哪条记录、怎么把数据库行变成程序对象——全部在这里。数据访问层是"仓库管理员":业务层说"存这条内容",它负责"存进 content 表"(第 6 章建的那张)。

把 12.1 的一坨代码按三层拆开,变成三个文件,每个文件只干一件事:

(先回答一个必然的问题:拆完代码变多了吗?一坨版约 80 行,三层版约 90 行——行数没少,甚至略多。分层买的不是"代码变少",是"改动变小":一坨版改一个功能要通读 80 行,三层版改一个功能只动一个文件。代码量守恒,改动量降维——这是分层(以及本书所有结构手段)的共同经济账。另一笔账也顺带算:读哪个快?一坨版读的时候要自己分辨"这段是校验还是存库";三层版每个文件开头就知道自己在看什么——分层省的不是写的时间,是读的时间,而代码被读的次数远多于被写的次数。)

// 表现层:只做翻译
app.post('/v1/contents', (req, res) => {
const result = contentService.createContent(req.userId, req.body);
if (result.error) return res.status(result.status).json(result.error);
res.status(201).json(result.data);
});

// 业务层:只做决策
const contentService = {
createContent(userId, { title, body }) {
if (!title || title.length > 100) return { error: { code: 'INVALID_TITLE' } };
if (!body || body.length > 10000) return { error: { code: 'INVALID_BODY' } };
if (containsBadWord(title + body)) return { error: { code: 'BAD_WORD' } };
const id = contentRepo.insert(userId, title, body); // 数据的事交给数据层
notifyFollowers(userId, id); // 通知也是业务层的编排
return { data: { id, title } };
},
};

// 数据访问层:只做存取
const contentRepo = {
insert(userId, title, body) {
return db.insert('content', { userId, title, body });
},
};

拆分后的三个变化,正好回应 12.1 的三个问题:改图片字段只动表现层和业务层(登录逻辑不受影响——它住在中间件里,12.4 会讲);检查登录只写一次(在中间件,12.4);测试业务层不需要 HTTP、不需要数据库——直接调用 contentService.createContent(1, {title, body}),传参数、看返回(第 18 章测试体系会看到这是单元测试的地基)。

分层的依据,和第 6 章"实体边界"、第 9 章"组件化"是同一个思想:独立职责、独立变化、独立测试——只是这一次拆分的对象从"表"和"组件"换成了"服务端代码"。三处类比合起来看,就是本书一直在讲的同一件事:任何复杂的代码集合,都要按"变化频率"切分——表按业务对象切(第 6 章)、界面按组件切(第 9 章)、服务端按职责切(本章)。

第 11 章还有一个呼应:CDN 是静态资源的"分层"——静态内容从"源服务器全包"变成"源服务器 + 边缘节点"(第 11 章),服务端代码从"一坨全包"变成"三层分工"(本章)——同一个"职责分离"思想,在静态资源和代码上各用了一次。加上第 9 章组件化,"复杂的东西按职责切开"已经有了四个实例——机制是相通的,换的是对象(表/组件/静态资源/服务端代码)。

分层的调用规则也要立好:上层调下层,不跨层——表现层只能调业务层,不能直接查数据库;业务层只能调数据访问层,不能直接拼 HTTP 响应。跨层的后果和 12.1 的"复制三份"一样:一旦跨层,边界就是摆设,分层退化成装饰。这条规则是代码评审(第 20 章)里最先检查的。

三层是纵向的分工,还有一层横向的结构要预告(12.4 展开):中间件。日志、鉴权这些"横切关注点"不属于任何一层(它们横着切过所有接口),所以单独放在请求的路上。三层 + 中间件,服务端代码是"十字结构"——纵向是职责(表现/业务/数据),横向是关卡(日志/解析/鉴权),两条线交叉的地方,就是每个具体接口。这个直觉记好了,看任何后端代码都能快速定位"这段代码属于哪条线"。

"业务层"还有一个容易低估的角色:编排createContent 不只是做判断(校验、敏感词),它还编排了一串动作——存内容、发通知,将来可能还要发消息(第 17 章的异步)。业务层是这些动作的"指挥":判断该不该做、按什么顺序做、失败了怎么办。这个"编排"角色在第 26 章服务拆分后会放大(一个业务跨多个服务时,编排就是"服务调用")——现在先认识:业务层不只是规则的家,还是动作的指挥

分层还是第 18 章测试金字塔的服务端地基:单元测试测业务层(直接调 service,传参数看返回——不需要 HTTP 不需要数据库)、集成测试测链路(中间件+控制器+业务层+数据库一起跑)、E2E 测接口(完整请求)。第 17 章会展开金字塔,这里先记住分层和测试的关系:没有分层,就没有"单独测一层"的可能——12.1 的一坨代码只能整体测,分层的代码才能分层测。

按领域分 service,不按接口分——这是业务层的组织原则(第 26 章的伏笔):contentService(内容域:发布/列表/评论)、orderService(订单域:下单/查询),而不是 publishContentServicelistContentService(按接口分,一个接口一个 service,等于没有分层)。按领域分的意义:同一领域的规则住在一起(内容的所有规则在 contentService)——第 26 章拆服务时,"内容域"和"订单域"的边界就是 service 的边界。

12.3 框架:请求 → 响应的答案

分层拆好了,还差一个基础设施问题:谁来接待请求?你不可能自己解析 TCP 报文、自己处理 HTTP 协议——那是第 10 章的知识,但不是你要重复实现的活。你要用的是框架

第 9 章说过一句话:"前端框架和后端框架是同一个思路的两端——都是'把一类系统的常见问题答案内建'。"现在兑现:后端框架内建的是'请求 → 响应'的答案——路由(这个 URL 去哪)、中间件(路上办什么事)、控制器(接待并调用业务层)。案例用的是 Node.js + Express(第 5 章选了 Node.js;要不要用框架、用哪个,12.3 末尾的九步会走完——前后端同语言,现在你看到"同语言"的第二个好处:前后端都是 JS,一个人维护两个端,心智不切换)。

框架的核心是路由(routing):URL 和方法的组合,决定请求去哪。第 7 章的接口清单(12 个接口),在框架里就是 12 条路由:

app.post('/v1/contents', ...); // 发布内容
app.get('/v1/contents', ...); // 内容列表
app.post('/v1/contents/:id/comments', ...); // 评论
app.post('/v1/orders', ...); // 下单
// ...第 7 章的 12 个接口,一一对应

路由的意义,是把"第 7 章的契约"变成"代码里的入口":契约说'这个 URL 长这样',路由说'这个 URL 到这儿'——两章在这里对接。注意 :id 这种路径参数(第 7 章说过的):路由里的 :id 就是契约里的 {id}——框架负责把 URL 里的值取出来挂到 req.params.id(第 7 章的"路径参数定位资源",在框架里就是一行代码的事)。

框架还有两个内建的答案(12.4 展开):中间件(请求路上的关卡:日志、鉴权、解析 body)和控制器(接待员:调用业务层)。第 10 章的报文(请求行、首部、主体),框架负责解析成程序对象——你不需要自己解析 HTTP,框架帮你做了req.method 是方法、req.headers 是首部、req.body 是主体。第 10 章的知识在这里变成了"理解框架在做什么"的底气——报文是协议的真相,框架是报文的搬运工

把第 9 章和第 12 章的两个框架放在一起看,它们其实是两个"翻译器":前端框架翻译"状态 → 界面"(你描述界面,框架管怎么变),后端框架翻译"请求 → 响应"(你写业务,框架管怎么接)——两端的开发者都只回答"业务是什么","怎么变成界面/怎么接住请求"都交给了框架。这就是第 9 章那句话的完整形态:框架是同一思路的两端,中间夹着的都是'你的业务'

"不用框架会怎样"——这个反向问题最能说清"为什么用框架":不用框架,你要自己解析 TCP 连接(第 10 章的三次握手之后)、自己解析 HTTP 报文(请求行/首部/主体)、自己写路由匹配(URL 和方法到处理函数的映射)、自己处理错误……这些是第 10 章的全部知识,但每个项目都重写一遍,是纯粹的重复劳动——框架就是把"请求→响应"的常见问题答案内建(第 9 章那句话),你只需要回答"这个请求的业务逻辑是什么"。第 10 章的知识不是白学:当框架行为异常(比如报文解析出错、路由匹配不达预期)时,懂协议的人能定位,不懂的人只能瞎试——协议知识是框架的调试手册

框架的"内建答案"比路由还多几样:错误处理(异常统一转成第 7 章的错误响应)、静态文件服务(图片/CSS 直接返回,第 11 章的 CDN 之前先靠它)、CORS(跨域配置——前后端分域名后,第 7 章的 api 域要允许前端域访问)、请求日志(谁调了什么,第 25 章可观测性的起点)。这些"全家桶"每个都是一个小领域,但都是"常见问题的答案"——框架的价值,就是让你不重复发明这些轮子。

第 7 章的版本化也在路由层落地:/v1/contents 里的 v1,就是路由的前缀——app.use('/v1', v1Router) 把一整套 v1 路由挂在一个前缀下(第 7 章的"v1 不破坏、新东西进 v2",在代码里就是再加一个 v2 路由文件)。接口版本从契约到路由,都是同一个 v1——第 7 章的承诺,在代码里找到了它的家。

Express 的生态还有一个特点:它几乎是"纯中间件模型"——框架本身很薄,功能全靠中间件包(日志的 morgan、解析的 body-parser、跨域的 cors……),需要什么装什么。好处是灵活,代价是依赖管理(第 20 章会看到):中间件包的数量 = 依赖清单的长度 = 升级时要盯的列表——"框架生态"是第 20 章依赖管理的第一次亮相。

框架不是天上掉下来的,它是演进循环的产物(第 2 章的又一次实例):CGI 时代(1990s)——每个请求启动一个进程跑脚本,慢且重;Servlet/ASP 时代——进程常驻,但请求处理还是手写;MVC 框架时代(2000s)——路由、控制器、模板内建,"请求→响应"的答案成型;全栈框架时代(2010s)——前后端一起管(Next.js/Nuxt 一类)。每一代框架,都是上一代"手写太多"逼出来的——框架是"重复劳动的固化",不是"炫技"。理解了这条线,就理解了"选框架"为什么是九步决策:框架的代价(约束、学习成本)换来的是重复劳动的消失,划不划算,取决于你的重复劳动有多少

那"用不用框架、用哪个"本身,就是一个决策——走一遍九步(轻量版,第 5 章模型的又一处应用):业务目标:请求处理可维护、不重复造轮子;业务约束:阶段 1——单人开发、12 个接口、Node.js 已定(第 5 章决策记录);技术问题:要不要用后端框架?用多重的?候选:A 手写(自己解析报文、写路由——把第 10 章的知识每项目重写一遍);B 轻框架(Express:路由+中间件,薄而灵活);C 重型框架(Nest 一类:全套结构、约定多);评价维度:生态(缺什么能不能装)、学习成本(单人团队学得动吗)、灵活度(能不能只装需要的);方案选择B(Express)——12 个接口的量级,不需要 C 的全套结构,A 的重复劳动没有价值;收益:请求处理交给框架,业务只写规则;代价:框架的约定要学(中间件顺序、错误处理风格——12.1 的两种错误风格就在这定);演进条件:接口数量与团队规模再上一个台阶时,评估更重的框架或自己封装。这个决策和第 9 章"要不要前端框架"是同一个模型的两端——选型不是一次性的:第 5 章选了 Node.js,本章补上框架那一层,都在决策记录里

12.4 请求处理链路:一个请求在服务端的完整走法

分层和框架都有了,现在把一次请求在服务端的完整旅程走一遍——POST /v1/contents 从网线到数据库:

报文到达服务器

├─ 框架接住(Express:解析 HTTP,得到 req/res)
├─ ① 中间件1:请求日志 (记录:谁、什么时间、调了什么)
├─ ② 中间件2:解析 body (把 JSON 主体变成 req.body)
├─ ③ 中间件3:鉴权 (验证 token,把 userId 挂到 req 上——第 13 章主角)

├─ ④ 路由匹配:POST /v1/contents → 控制器
├─ ⑤ 控制器:翻译 (从 req 取参数,调业务层)
├─ ⑥ 业务层:决策 (校验/敏感词/存库编排)
├─ ⑦ 数据访问层:存取 (写 content 表)

└─ 响应原路返回:控制器组装 → 中间件(响应日志)→ 框架 → 报文
图12-2 图稿占位
中间件管道
请求穿过管道,每层横切关注点

这条链路里,中间件(middleware)是 12.2 分层之外的第二块拼图——12.2 说过它管"横切关注点",现在看它在链路上怎么布置:在请求到达控制器之前(和响应返回之后),插入一段代码,做日志、解析、鉴权、限流(第 24 章)这些事。如果每层都自己写日志,又是 12.1 的"复制三份"问题——中间件把横切关注点从各层里抽出来,放在请求的路上,一次写好,所有接口共享:

app.use(logMiddleware); // 所有请求先过日志
app.use(bodyParser()); // 所有请求先解析 body
app.use(authMiddleware); // 所有请求先过鉴权(第 13 章主角)
// 然后才是具体的路由
app.post('/v1/contents', controller);

中间件的顺序有讲究:日志要在最前(任何请求都要记录,包括失败的);鉴权要在路由前(没登录的请求不该到控制器);业务路由在最后(它们是"终点站")。顺序错了,行为就错——比如鉴权放在路由之后,没登录的请求也能进控制器(只是返回 401 晚了一步,但中间件顺序错误有时会产生真正的漏洞,第 19 章安全会看到)——所以中间件的排列顺序,也是每次代码评审要看的第一行。

中间件还有一个"末端关卡":错误处理中间件(管道末尾统一接住异常:任何一层抛错,都流到这里,统一转成第 7 章的错误响应格式)和 404 中间件(所有路由都没匹配上时,统一返回 404)。它们的存在让"错误响应的结构必须全项目一致"(第 7 章)在代码层有了保证——12.1 那个'错误处理散落'的第四问题,答案是中间件。它必须放在所有路由之后:只有前面的关卡都过了、业务都跑完了,剩下的错误才轮到它——放错了位置,错误会从它身边溜过去直接漏到响应里(第 19 章安全会看到这类"错误直接漏给前端"的真实案例)。

控制器的"薄"与"厚"要立个规矩:控制器应该薄——只翻译(取参数、调业务、组响应),不决策(不写校验、不写业务判断)。"胖控制器"(把业务逻辑写在控制器里)是后端最常见的反模式之一:表面上分层了,实际是把 12.1 的一坨从函数搬进了控制器——分层不是把代码分文件,是把职责分清楚

回看 12.1 的三个问题,现在都有了结构性的答案:检查登录从每个接口的复制三份,变成中间件里的一份(所有接口共享);改图片字段只动表现层的参数处理和业务层的校验,登录逻辑在中间件里,互不干扰;测试——中间件可以单独测、业务层可以单独测、控制器可以单独测(第 17 章)。

把链路放回第 10 章的 TTFB:TTFB 慢,在服务端内部是这条链路的时间——中间件(日志/解析/鉴权)+ 控制器 + 业务层 + 数据访问层(数据库查询)。第 10 章说过,TTFB 慢是服务端侧的事(第 12/14 章的战场),现在有了内部地图:先看数据访问层(查库慢?第 14 章),再看业务层(决策慢?),最后看中间件(鉴权慢?第 13 章)——TTFB 的定位,从"服务端"细化到了"服务端的哪一层"。

层内还有一笔性能预算账(第 4 章验收标准的层内落点):第 7 章的 P95 预算(列表接口 < 200ms)摊到各层大概是——中间件 10ms、控制器 5ms、业务层 20ms、数据层 150ms(查询是大头,第 14 章主角)。预算不平均,但每层有数——性能优化时"哪层超预算"一眼可见(第 16 章展开)。

链路的异常路径也补一句:任何一层抛错,错误沿管道向后流动——中间件抛错(鉴权失败)→ 错误处理中间件接住转 401;业务层抛错(校验失败)→ 接住转 400/409;数据库抛错 → 接住转 500。错误处理中间件是链路的"安全网":正常路径上它是最后一道关卡,异常路径上它是唯一的接盘侠——12.1 的"错误处理散落",到这里彻底收拢成一道统一的后门。

把整条链路的代码拼起来看一眼(示意,每一段都是前面讲过的):

// 中间件:日志 → 解析 → 鉴权(一次写好,所有接口共享)
app.use(logMiddleware);
app.use(bodyParser());
app.use(authMiddleware); // 把 userId 挂到 req 上

// 路由 + 控制器(薄:只翻译)
app.post('/v1/contents', (req, res) => {
const result = contentService.createContent(
{ userId: req.userId, title: req.body.title, body: req.body.body } // DTO
);
if (result.error) return res.status(result.status).json(result.error);
res.status(201).json(result.data);
});

// 错误处理中间件:统一兜底(安全网)
app.use((err, req, res, next) => {
res.status(500).json({ code: 'INTERNAL_ERROR', message: '服务器开小差了' });
});

从中间件到控制器到错误兜底——第 12 章的所有概念,在这一小段代码里全部到齐。这就是"一个请求在服务端的完整走法"的代码形态:短,但每一行都有它的位置。

链路还是第 25 章可观测性的起点:每一层都打一条日志(中间件记请求进来、业务层记决策结果、数据层记查询耗时),配合第 7 章的 request-id 贯穿——"这个请求在每层花了多久"一目了然(第 25 章的链路追踪,就是这条日志链的自动化版)。分层不只是代码结构,还是排障地图和监控地图——同一个结构,三种用途。

链路的"异步"底色(Node 的运行时事实):Node 是事件驱动的——请求处理不占线程,数据库查询(第 14 章)、外部调用(第 10 章)都是异步的(await 就是"等它回来再继续")。这让 Node 能用单线程扛大量并发(查询在等 IO 时不阻塞其他请求),但也让"错误处理"更依赖中间件兜底(异步异常容易逃出 try-catch)。第 16 章性能优化时,"事件驱动为什么扛并发"是 Node 系优化的底层逻辑——这里先记住:链路的每一段都可能是异步的,await 是 Node 世界的"等一等"

把第 12 章的请求链路和第 8 章的渲染管线放在一起看:两条管线是同一个形状——输入 → 逐段处理 → 输出(渲染:解析→树→布局→绘制;请求:中间件→控制器→业务→数据)。第 8 章说"管道思维"是本书反复出现的模式(找'卡在哪一段'),第 12 章是它在服务端的又一次实例——你在浏览器站学到的排查方法(分段定位),在服务端站原样可用

12.5 DTO 落地:接口和表之间的缓冲

第 7 章说过一句话:"为什么接口返回前要过一层 DTO?表结构是存储的细节,表会变——DTO 是接口和表之间的缓冲。"当时说"第 12 章服务端分层时,DTO 会在代码层正式登场"——现在登场。

DTO(Data Transfer Object,数据传输对象)在分层里的位置:它是跨层传输的"箱子"——表现层从请求里取出数据装进 DTO,业务层用 DTO 做决策,数据访问层把 DTO 翻译成数据库行。有了 DTO,三层之间的传输就有了固定的形状:

// 表现层:把请求参数装进 DTO
const dto = { userId: req.userId, title: req.body.title, body: req.body.body };
const result = contentService.createContent(dto);

DTO 解决的第 7 章问题,在分层语境里看得更清楚:content 表加了字段(比如 status)——数据访问层知道,业务层和表现层不关心(它们用的 DTO 没有 status);接口要加字段——表现层改 DTO,业务层和数据层不动。每一层只认识自己的 DTO 形状,层与层之间通过 DTO 解耦——这就是第 7 章"接口是承诺"的服务端实现:承诺的是 DTO 的形状,变的只是层内部的实现。

DTO 的代价也诚实:转换样板代码——请求参数转 DTO、DTO 转数据库行、数据库行转响应对象,每层转换都有几行样板。这个代价是"解耦"的租金:不想付租金,就得忍受'表一变接口就变'的搬迁费——第 6 章说过的"改表贵",DTO 就是那个"让改表不贵"的缓冲垫。

DTO 和第 7 章契约的对应:契约的代码形态就是 DTO——请求 DTO(CreateContentDto:对应契约的参数定义)、响应 DTO(ContentResponse:对应契约的返回结构)、错误 DTO(第 7 章的 code/message/request_id 结构)。第 7 章说"契约是唯一真相",在服务端就是"契约写的每一个字段,都会变成 DTO 的一个属性"。命名即文档:DTO 的名字和字段,就是第 7 章契约的代码投影——DTO 命名乱(data1obj),契约就乱;DTO 命名清,接口文档都不用查

DTO 和数据访问层之间还有一个第 6 章的兑现:ORM(第 6 章说过,ORM 可以自动建表,但表结构的设计始终是人的活)——ORM 做的正是"数据库行 ↔ 程序对象"的翻译(第 6 章那张六张表,在代码里就是六个模型类)。ORM 省的是翻译样板,不省的是第 6 章讲的约束和迁移——DTO 管接口侧的翻译,ORM 管数据侧的翻译,业务层夹在中间只做决策

DTO 的边界也划一下:**什么时候不需要 DTO?**两个信号——接口和表结构几乎一样(DTO 只是照抄,翻译样板多于解耦价值);内部一次性脚本(没有接口承诺要守)。判断标准还是第 4 章的复杂度预算:DTO 是"接口会变"的保险,接口不会变的地方,保险就是浪费。案例的 12 个接口(第 7 章)全都配得上 DTO——因为它们是"对外承诺"(第 7 章),承诺该买保险。

DTO 的完整生命周期(它是跨层的"快递"):请求到达 → 表现层把参数装进请求 DTO → 业务层收下 DTO 做决策 → 数据访问层把 DTO 翻译成数据库行 → 查完把行翻译回 DTO → 表现层把 DTO 组装成响应。一次请求,DTO 在每一层都过一遍手——它不做什么事,但它是层与层之间唯一的"语言"。第 16 章性能优化时,"减少 DTO 拷贝"也是一类优化手段(大对象在层间反复拷贝的开销)——现在先认识 DTO 的旅程。

12.6 分层的代价与边界

分层解决了大问题,但也要说清它的代价和边界——分层不是越多越好

**代价一:样板代码。**每一层多一个文件、多一层转换,小功能("改个状态")也要走完五层。**代价二:过度分层。**见过最极端的案例:一个"改昵称"的功能,拆了表现层/业务层/领域层/数据层/仓储层五层七个文件——改一行代码要翻五个文件。分层到这种程度,不是组织,是迷宫。**代价三:新人要学约定。**一坨代码没有结构可学,分层的代码有一套"哪层放什么"的约定要学——团队里新人问"这个校验写哪层"是常态。这个代价没法省,只能靠两样东西缓解:命名清楚(文件名带层:contentController.jscontentService.js)和评审把关(第 20 章)——分层的约定是团队的共同语言,也是新人培训的第一课。

**边界:什么时候不需要严格分层?**三个信号——业务规则极少(接口基本是"查表返回",没有决策)、团队一人且项目临时(分层是给"会变"的代码准备的,一次性脚本不需要)、性能极度敏感(每层多一次调用,极致性能下是开销——但 99% 的项目不到这个程度,真到了那一步再说)。判断标准和第 4 章砍需求、第 9 章要不要框架是同一个逻辑:复杂度没到,就别分层。这也呼应第 4 章的复杂度预算:分层是复杂度预算的一种花法——先一坨跑通(预算省着花),业务规则真多了再分(预算花在刀刃上),和第 4 章"先社区后交易"、第 11 章"阶段 1 不上 CDN"是同一个决策习惯。

到这里,第 5 章的九步模型已经在本书里应用了七次:语言选型(5)→ 数据库选型(5)→ 要不要 React(9)→ 要不要 CDN(11)→ DNS 托管(11)→ 框架选型(12)→ 分层粒度(12)——每一次都是同一个模型,换的是候选方案和评价维度。这就是"模型复用"的意义:方法论的价值不在第一次用,在第 N 次用还不用重新学(第 5 章的九步速查,到这里已经不需要翻回去看了)。

还有一个第 1 章的回扣:服务端是最容易"名词地图化"的领域——controller、service、repository、DAO、DTO、middleware……一堆名词,每个都能背定义,但连不成地图。本章的"机制先行"(跟着一次请求走)就是防名词地图:先立住"请求在服务端的旅程"这条主线,所有名词(路由/中间件/控制器/服务/数据访问)都是这条路上的站点——你记住的不是名词,是名词之间的路。

分层还有一层第 20 章的伏笔:分层是团队协作的地图。团队里说"改这个接口",大家知道去表现层;说"改这个规则",知道去业务层;说"查这条数据",知道去数据访问层——分层让"改哪里"有了共同语言(第 20 章 Code Review 的讨论,因为分层而有了坐标)。第 26 章还有一层更远的关联:分层是服务拆分的准备——第 26 章拆服务时,"哪些代码属于订单域、哪些属于用户域"的判断,靠的就是分层的边界;边界清晰的分层,拆起来是"搬文件",边界模糊的一坨,拆起来是"考古"(第 6 章说过同样的话——边界感是架构演进的准备金,这里再验证一次)。

分层还有一层第 19 章的伏笔:安全审查按层进行——表现层看输入校验(参数有没有被构造过)、中间件看鉴权(谁能进来)、业务层看授权规则(谁能发、谁能删)、数据访问层看查询注入(第 19 章主角)。分层的边界,就是安全审计的清单——第 19 章安全时,"每一层分别查什么"是最实用的检查框架。限流也是中间件(第 24 章的伏笔):限流中间件放在鉴权之后、路由之前——"这个 IP 每秒最多 10 个请求"(第 24 章展开)。第 11 章说过 CDN 是"第一道墙",限流中间件是"服务端的第一道闸"——横切关注点清单(日志/鉴权/限流)会随着业务成长越来越长,中间件管道是它们的家

**分层粒度是个决策,不是个习惯。**走一遍九步(轻量版):业务目标:业务规则可维护、可测试;业务约束:阶段 1 团队 1-2 人、接口 12 个、业务规则正在变多(内容/评论/交易);技术问题:服务端代码怎么组织?候选:A 一坨(12.1);B 三层(表现/业务/数据访问);C 五层(加领域层/仓储层);评价维度:可维护性、可测试性、样板成本;方案选择B——三层够用,五层是为更大的团队和更复杂的领域准备的(第 26 章服务拆分时再评估);收益:业务规则有家、可测、可替换;代价:样板代码、多一层理解;演进条件:业务规则复杂度再上一个台阶(或团队变大)时,评估更细的分层——第 26 章再评估,这就是第 9 步该有的样子。

把这一章放回第 2 章的演进链:一坨(阶段 1 起点)→ 分层(本章,被 12 个接口逼出)→ 服务拆分(第 26 章,被规模逼出)——每一次演进,都是"原方案无法承受"逼出来的(第 2 章的循环)。本章是这条链的中间一环:分层的边界,就是将来拆服务的边界(第 26 章会看到,"拆服务"的第一步是"按分层边界切代码")。

这个决策也写进第 5 章的决策记录:服务端分层——三层(表现/业务/数据访问),演进条件=业务复杂度上升或团队变大。决策记录里的"三层就够"和"要不要 CDN 的'不上'"是同一个习惯:知道当前该用什么,也知道什么时候该换——第 5 章九步模型的价值,在这里又一次兑现。

12.7 本章对应表

业务诉求技术选择为什么代价/取舍
业务规则要有个家分层(表现/业务/数据访问)独立职责、独立变化、独立测试样板代码、过度分层是另一种乱
请求怎么被接住框架(路由/中间件/控制器)内建"请求→响应"答案(第 9 章同思路)学习成本、框架的约束
横切逻辑(日志/鉴权)中间件管道一次写好、所有接口共享中间件顺序错了行为就错
接口和表解耦DTO(跨层传输)表变接口不变(第 7 章承诺落地)转换样板代码
分层粒度九步决策(三层就够)复杂度没到别过度分太粗耦合、太细迷宫

每一行都在本章正文里有完整的论证:分层在 12.2,框架在 12.3,链路在 12.4,DTO 在 12.5,粒度决策在 12.6。和前面章节的对应表一样,先看"业务诉求"列——这一章的每一行,都是从"一坨代码"的三个问题里长出来的。

把本章放回旅程图(图 8-1):服务端站开始——第三部分地图进度:浏览器 ✅ → 网络(上)✅ → 网络(下)✅ → 服务端 🔄 → 数据层 → 第 15 章全链路。本章是服务端站的第一章:请求怎么被接住、业务规则住在哪。服务端站还有两章:第 13 章的门禁(认证),第 14 章的数据层(存储)。对两种读者,本章的收益也呼应前几章:前端读者看到了"接口背后的房子"(原来接口不是一坨,是分层的);后端读者把自己天天写的 controller/service 从"习惯"升级成"为什么"(每层存在的理由)。全栈的"全",在服务端这里补上了最大的一块拼图。

本章和第 17 章的边界一句话:第 12 章回答'服务端怎么组织',第 18 章回答'组织好了怎么证明它对'——分层是测试的地基,测试是分层的验收。

本章小结

服务端速查(第三部分回指本章时翻回这里):

  1. 分层三层:表现层(翻译)/ 业务层(决策)/ 数据访问层(存取)——业务规则的家在业务层;
  2. 框架内建"请求→响应":路由(去哪)+ 中间件(横切关注点)+ 控制器(接待);
  3. 中间件管道:日志→解析→鉴权→路由——一次写好、所有接口共享,顺序有讲究;
  4. DTO 是跨层箱子:接口与表之间的缓冲(第 7 章承诺落地),代价是转换样板;
  5. 分层粒度是九步决策:三层就够,五层等业务复杂度真到了再说(决策记录已写入演进条件:业务复杂度上升或团队变大)。

(五条速查对应本章的认知结构:1-2 是"房子怎么盖"(分层与框架),3 是"请求怎么走"(链路),4 是"层间怎么传"(DTO),5 是"盖多细"(粒度决策)——五条串起来,就是服务端代码的完整决策地图,也是第 18 章测试、第 19 章安全、第 26 章拆服务共同的地基。)

  • 业务规则需要一个家:分层把"请求怎么处理"和"业务怎么决策"和"数据怎么存取"分开——每层都有自己的职责;
  • 框架是"请求 → 响应"的答案:路由(去哪)、中间件(横切关注点)、控制器(接待)——第 9 章"前后端框架同思路"的兑现,两个框架是夹着"你的业务"的两个翻译器;
  • 中间件把横切关注点(日志/鉴权)从各层抽出来放在请求路上——一次写好、所有接口共享;
  • 分层有代价也有边界:过度分层是另一种乱——分层粒度是决策,不是习惯;
  • 服务端站的第一章完成——下一站,业务的第一道门禁:认证与授权。

下一章

服务端组织好了:分层清晰、链路通畅、请求能顺利到达业务层。但业务层开门之前,还差一道门禁:这个请求是谁发的?他有资格吗?

登录、Session、JWT、权限——第 10 章说过"无状态记不住用户",第 12 章的中间件里留了一个鉴权的位置。下一章,认证与授权:业务的"门禁"——第 13 章会用决策模型完整走一遍 Session vs JWT 的选择,那是第 5 章九步模型的第八次应用(也会是全书最完整的一次)。本章完成,这个钩子全部兑现:决策模型第八次应用(完整实战版)、鉴权中间件、无状态补丁。