成为全栈·产品篇·领域建模:一个文章系统有哪些实体、什么关系
本文目标:带你做一件当纯前端时几乎从没做过的事——为数据「建模」,而不是「渲染」。我会用本系列这个真实文章系统作案例,把核心实体和它们的关系拆给你看,并讲清几个「不对这意味着什么」的建模决策。读完你会明白,为什么说「设计是常量」,而实体关系就是那个不变量。
前置知识:建议先读 技术选型不是投票(技术选型)。本文是「设计是常量」里那个不变量的具体展开。
你从没做过的事:建模
当纯前端时,你面对数据是这样的:
constarticle=awaitfetch('/api/articles/1').then(r=>r.json())return<h1>{article.title}</h1><div>{article.content}</div>你消费一个对象、把它渲染出来。对象长什么样,是别人定好的。
建模反过来:现在问你——「要做一个文章系统,数据该怎么组织?」没有人给你现成的对象。你要自己决定:有哪些「东西」值得单独记录、它们之间什么关系、每个字段是什么类型、状态怎么流转。
这就是领域建模。它练的不是「消费 + 渲染」,而是「抽象 + 约束」。我身边不少前端转全栈卡住,卡的就是这第一步——不是语法不会,是从没被要求「凭空把世界拆成表」。
一、从需求到实体:建模的第一步
建模的起点是问对问题:系统里有哪些「值得单独记录状态」的事物?
「值得单独记录」是关键。一个文章的标题不值得单独建表(它依附于文章),但「用户」值得——它有独立生命周期(注册、登录、改资料、禁用)。「评论」也值得——它有作者、内容、审核状态。
把需求在脑子里过一遍,凡是「能独立存在、有自己属性、会被增删改查」的,基本就是实体。剩下的,要么是实体的字段,要么是实体之间的关系。
关系只有三种,记住就行:
- 一对一:一个 A 对应一个 B(少见,多数可合并)。
- 一对多:一个 A 对应多个 B(最常见,用「B 上挂 A 的外键」表达)。
- 多对多:多个 A 对应多个 B(用一张中间表表达,比如「文章 ↔ 标签」)。
把这三种关系画出来更直观:
二、这个系统的核心实体
把「文章系统」这个需求拆开,本系列定下的核心实体如下(完整定义见 02-领域模型与API契约,这里只给总览):
┌─────────┐ ┌──────────┐ ┌────────┐ │ User │───1:N─▶│ Article │─N:N──▶│ Tag │ │ (作者/会员)│ │ (文章) │ └────────┘ └─────────┘ └────┬─────┘ │ 1:N │ 1:N ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Comment │ │ Category │─N:1─▶│ Category │ (自关联,支持层级) │ (评论) │ │ (分类) │ │ (父分类) │ └──────────┘ └──────────┘ └──────────┘ ┌──────────┐ ┌────────────┐ ┌────────────┐ │ Favorite │◀─N:1─│ User │─1:N─▶│ ReadingLog │ │ (收藏) │ │ │ │ (阅读历史) │ └──────────┘ └───────────┘ └────────────┘ ┌──────────┐ ┌────────────┐ │ Like │ │ Attachment │ (附件,一等实体) └──────────┘ └────────────┘| 实体 | 一句话职责 |
|---|---|
| User | 系统只有一类账户,靠role区分 admin / editor / member |
| Article | 核心实体,内容存 Markdown 源文,渲染交给前端 |
| Category | 分类,支持无限层级(自关联) |
| Tag | 标签,与文章多对多 |
| Comment | 评论,有自己的审核状态 |
| Favorite | 会员收藏 |
| Like | 会员点赞 |
| ReadingLog | 阅读历史(阅读量统计的底层) |
| Attachment | 上传的附件/图片,是一等实体,不是内部影子表 |
ASCII 图是文字兜底,下面这张可视化版本看得更清楚:
你看,这已经不是「一个文章对象」了——它是一张关系网。作为前端,你以前只摸过这张网里被裁出来的那一片 JSON;建模让你看见整张网。
三、几个「不对这意味着什么」的建模决策
实体列表好列,难的是决策背后的取舍。举四个本系列真实做过的决定,让你感受建模的思维重量:
1. 用户不拆表,靠role区分。
会员中心和后台管理员,本可以建两张表。但我们只用一张User表,靠role字段区分权限。理由只有一个:最大化「同一套 API 服务多端」的复用率。代价是权限判断要写在授权层(这正是契约里x-authz要机器化保证的事)。如果你当初拆成两张表,后面六个端都要为「两套用户」写两套逻辑——地基就歪了。
2. 文章内容存 Markdown 源文,不存渲染后的 HTML。Article.content存的是 Markdown 源文,渲染由前端负责。这是关注点分离:存储层只管「数据是什么」,表现层管「怎么画」。如果存了 HTML,哪天换个前端框架,旧 HTML 就成包袱。
3. 主键统一整数自增,不引入 uuid。
所有实体主键用整数自增。在 SQLite 下是INTEGER,PostgreSQL 下是BIGINT,但语义一致。理由:本期规模用不着 uuid 的分布式优势,简单够用就是最优。过早上 uuid,反而增加跨数据库适配的复杂度。
4. 状态要显式建模成状态机。
文章有三态draft / pending / published,评论有三态approved / rejected / reviewing。它们不是随便写的字符串,而是业务流程的可建模部分——哪些转移合法(比如pending → published可以,published → pending也可以,但得有规则),要在契约里机器化定义。状态机建模错了,业务就会出「文章卡在审核态出不去」这种事故。
顺手提醒建模时最容易栽的三个坑,你写自己项目时能避开:
- 过度拆分:什么都想建表,结果十张表 join 一次查询。先问「它能独立存在吗」,不能就做字段。
- 搞反关系方向:一对多时外键挂错边,查询要反向扫全表。记住「多的那一侧挂对方 id」。
- 把状态当普通字符串:用自由文本存状态,后面到处
if (status === 'xxx'),一改名全崩。状态该是受约束的枚举,最好机器化校验。
坑的本质,都是「没把关系和约束想清楚就动手」——建模慢一点,后面省十倍返工。
四、为什么建模是全栈最难的跃迁
前端练的是「给定结构,消费它、渲染它」——输入是确定的。建模练的是「没有结构,创造结构,并扛住它带来的约束」——输出要自己负责。
而且它贵。用一个真实系统串起全栈 说过,API 契约和领域模型是整个工程唯一的硬地基,一旦定歪,后面六个子项目全部返工。你前面学的 Hono、Drizzle、Next.js 都能边做边改,唯独实体关系和契约不能——因为它们是所有端共用的「常量」。
所以这一篇你不必背下每个字段,但要建立一种新直觉:看到需求,先想「有哪些实体、什么关系、状态怎么流转」,而不是直接想「页面怎么画」。这个直觉一旦长出来,你再回头看前端那些「列表页」「详情页」,会发现它们在你眼里已经变成了「某实体的集合视图」和「某实体的单个视图」——那一刻,你就已经是全栈视角了。
举个最小的例子体会这层追问:产品说「用户能收藏文章」。前端会想「加个爱心按钮」;建模者会想「要不要独立 favorite 表、用户删了收藏怎么办、文章删了收藏级联吗、收藏要不要进阅读历史」。这串追问,就是建模的起点——它不写在页面上,却决定了你后面写不写得出干净的后端。所以别嫌建模慢,它在替你挡住最贵的返工。
建模练的就是这种「先问清楚再动手」的肌肉,练熟了,后面的代码反而写得快。
小结
- 建模是「为数据建模,而非渲染」——从空白需求拆出实体和关系,这是纯前端极少练的能力。
- 实体 = 值得单独记录状态的事物;关系只有一对一、一对多、多对多三种。
- 本系统的核心实体是一张关系网(User / Article / Category / Tag / Comment / Favorite / Like / ReadingLog / Attachment),不是孤立的文章对象。
- 真正的建模功力在决策取舍:单用户表靠 role、Markdown 源文、整数主键、状态机——每个决定都有代价和理由。
- 建模是全栈最难的跃迁,也是唯一不能返工的地基;它长出的「先想实体再想页面」的直觉,就是全栈视角本身。
延伸阅读
- 《技术选型不是投票——本文说的「设计是常量」,就是实体关系这个不变量。
- {{LINK:M0-05}}《契约先行》——实体定好之后,下一步是把它们暴露成接口。
订阅这个专栏
- 本系列专栏:https://blog.csdn.net/fungleo/category_13204651.html(订阅看全部篇章)
- 完整项目仓库:https://github.com/fengcms/become-a-full-stack-developer