这一篇不是表扬稿。它是我自己项目的一次架构决策复盘:为什么全盘唯一一个 monorepo 是这样拆的、拆完真正带来了什么、哪一部分价值被我高估了、哪一项技术债到现在还没还清。
项目:C:\Users\Administrator\WorkBuddy\delimao-system,淋浴房产供销一体化系统的「路线B 生产级重构」。
全盘唯一一个 monorepo,npm workspaces(不是 pnpm、不是 turbo、不是 lerna),四个包、27 张表集中在 packages/db/src/schema.ts。
| 包 | 目录 | 负责什么 | 硬约束 |
|---|---|---|---|
@delimao/server | apps/server | Express + tRPC 接口 | 可被启动对外服务 |
@delimao/web | apps/web | React 页面 | 可被 vite 启动 |
@delimao/db | packages/db | schema 收敛 + migrate + seed | 只管存取,不写业务判断 |
@delimao/engine | packages/engine | 报价 / BOM / 玻璃图 / 主数据 | dependencies 为空,纯 TypeScript |
source='production_export' 这一批导出数据;全表 product_order 实测 33,178 单,多出的 2 单来自 source='structured'。相关基础篇:15 · 读懂 monorepo(速查版命令表)、15 · monorepo 深入(exports / 软链 / 循环依赖)、16 · 数据库怎么存东西、16 · 数据库深入(JSON blob 崩库机制)。
复盘的第一步不是夸 monorepo,是把拆之前的痛写准。写不准,后面的「值不值」就没法判断。
拆之前的代码形态是「一个目录里平铺:后端文件、前端文件、SQL 文件、规则文件都在一起」。它不是「单仓库 vs 多仓库」的问题,是同一个逻辑要被抄四遍的问题:
| 痛点 | 当时的具体表现 | 真实的后果 |
|---|---|---|
| 改一处要动四个文件 | 改一条报价规则 → engine 改算法、db 改价格表、server 改接口、web 改页面。四个地方靠人肉同步 | 漏改一处,线上不报错,只是算出来的数悄悄不对——这种 bug 最贵 |
| 接口靠文档对齐 | 后端改字段,前端看的是一份 Markdown 接口说明 | 文档一过期,前端就按旧字段取值,页面不崩,只是显示空白 |
| 前端拿不到后端类型 | 想让 TypeScript 知道接口长什么样,得为一个类型去装一个 npm 包 | 类型约束基本等于没有,靠review 和线上报错兜 |
| 状态值字符串满天飞 | 订单状态在后端、前端下拉框、导出表格里各写一遍 | 某处写成 'done'、某处写成 'DONE',查不出来 |
| 发版有排队 | 四个模块各有一份构建产物,更新要一个个来 | 中间态是「前端新的、后端旧的」,半套系统在线上跑 |
当时摆在面前的有五条路。选型的价值不在于选了最先进的,在于知道自己为什么没选另外四个。
| 方案 | 怎么做 | 在你这个体量下的真实结果 |
|---|---|---|
| ① 继续单体 (一个目录平铺) | 什么工具都不上,继续靠纪律 | 成本最低。但痛点 1/2/4 一条都解决不了,且随着 27 张表加进来会持续恶化 |
| ② 四个独立仓库 | server / web / db / engine 各一个 git | 把痛点 1 从一个文件变成四次提交 + 四次发版,中间任何一次没跟上就是版本错乱。对一个维护者是最差选项 |
| ③ monorepo + npm workspaces | 一个 git,packages/* + apps/*,本地软链互相引用 | 选中。一次提交四个包同时变,前端能直接 import 后端类型,枚举一处定义三处生效 |
| ④ monorepo + pnpm / turbo | 加一层任务编排工具 | 四包 + 一个人,编排工具的收益接近 0,纯增加学习成本。npm 自带的 workspaces 就够了 |
| ⑤ 先别建系统 | 拿 Excel + 脚本先把结论验证一遍 | 我实际上先做了这一步——先跑数据体检,确认「数据不够」再去建引擎(这条链路的完整复盘见 复盘 25 · 订购单流水线) |
monorepo 的难点从来不是「建起来」,是划边界。划错了,四个包会迅速长成四坨互相咬住的意大利面。
依赖方向(箭头 = 「依赖」):
apps/web ──(只 import type)──▶ apps/server
│ │
└──(import 枚举/类型)──▶ packages/db ◀── packages/engine
▲
└── apps/server 依赖它
packages/engine ★ 什么都不依赖(dependencies 为空)
packages/db ★ 只依赖 drizzle / libsql 客户端,不依赖任何业务包
| 包 | 负责什么 | 明确不许干什么 | 允许依赖谁 |
|---|---|---|---|
apps/server | HTTP 收口、鉴权、把请求翻译成 engine 调用 | 不许把算价公式写进来(否则 engine 就白纯了);不许直接写 SQL 拼业务结果 | db、engine |
apps/web | 页面、表单、表格 | 不许自己重新实现一套状态枚举;不许绕过接口直接读库 | server(仅类型)、engine(类型)、db(枚举) |
packages/db | 27 张表 schema、migrate、seed、枚举常量 | 不许含业务判断(「订单算不算完成」这种判断一律不上来) | 只有 drizzle / libsql 客户端 |
packages/engine | 型号解析 → BOM → 校验 → 玻璃图 → 报价 | 不许 import express、不许 import db——一旦 import 就成环且没法单测 | 什么都不许依赖 |
packages/engine 里出现任何 @delimao/server 或 @delimao/db 的 import,立刻停下来。它会同时击穿三件事:engine 跑不了单测、server 与 engine 成环、engine 彻底失去可复用性。packages/engine/package.json 里一个 dependencies 都没有(只有 devDependencies 的 tsx / typescript)。只要有人乱 import,npm install 会直接报缺包——问题在装依赖那一刻就暴露,而不是等运行时。npx madge --circular apps/server/src 逐个包查。成环的运行时表现不是报错,是某个 import 进来的是 undefined,报错位置离真因十万八千里。apps/web 的 devDependencies 里放了 @delimao/server。看起来违反直觉,其实它只 import type 一个 AppRouter——为拿接口清单而依赖,不是为调后端而依赖。放 devDependencies 是精确表达:编译期需要,运行期不需要。packages/db/src/schema.ts 里 27 张表不是随便堆的,是按业务生命周期分了 9 层。先说明白:9 层分区不是数据库约束,只是文件的组织顺序 + 人的索引。数据库不知道第 3 层比第 5 层重要。
| 分区 | 关键意义 | 谁写 | 谁读 |
|---|---|---|---|
| 1–3 主数据 / 商品 / 价格 | 决定「算不算得出价」。缺出厂价红线,4–9 层全是空转 | seed / 后台维护 | engine 优先,web 只展示 |
| 4 订单 | 流水线的入口,也是唯一有真实历史规模的一层(33,176 单) | server | 全线 |
| 5–7 拆单 / 生产 / 图纸 | 决定「能不能出图」,engine 的主战场 | engine | server → web |
| 8–9 库存 / 系统 | 校验与留痕,出问题时的证据链 | server | 审计与对账 |
很多人看 schema 只看表名和字段类型,把枚举当普通常量。在我这个项目里,枚举是全盘唯一一处「一处定义、三处生效」的东西,它是业务词汇的单一事实来源。
| 枚举 | 取值数 | 它其实在说什么 | 典型取值 |
|---|---|---|---|
ORDER_STATUS | 8 | 订单从录入到关闭的完整生命周期(具体取值见 schema.ts) | 录入态 / 校验中 / 已确认 / 已拆单 / 生产中 / 待安装 / 已完工 / 已关闭 |
PO_STATUS | 5 | 采购单(PO)的流转阶段 | 草稿 / 待审 / 已审 / 已下单 / 已到货 |
WO_STATUS | 4 | 工单(WO)的阶段,比订单少一半——因为工单不做校验 | 待排产 / 生产中 / 待检验 / 已完工 |
REQ_STATUS | 3 | 需求单:最简的一层 | 待处理 / 已处理 / 已关闭 |
MOVE_TYPES | 5 | 出入库流水类型(进 / 出 / 调 / 报损 / 退货) | 入库 / 出库 / 调拨 / 报损 / 退贷 |
USER_ROLES | 3 | 系统角色(管理员 / 门店操作员 / 只读) | 管理 / 操作 / 只读 |
zod 挂枚举,非法状态接口层直接拒。先清清楚楚说一句,免得后面全白讲:
better-sqlite3 在全项目命中 26 个文件。schema.ts 末尾确实有一段注释列着「将来迁到 PostgreSQL 时字段类型怎么映射」——integer 怎么变、real 怎么变、JSON 列怎么处理。那是预案,不是现状。讲这一段时我从不把它当成已有资产。| 环节 | 实际做法 | 为什么 |
|---|---|---|
| 运行时 | Express + better-sqlite3 | 进程内同步驱动,没有连接池开销 |
| 并发 | SQLite WAL 模式 | 读写不互斥,读不阻塞写 |
| 进程管理 | pm2 | 挂了自动拉起 |
| 发布 | rsync 推文件 + git tag + md5 校验 | 整个库是一个文件,备份 = 拷文件,迁移服务器 = 拷文件 |
| 迁移 | drizzle-kit generate → migrate | generate 只生成文件,migrate 才写库,两步不能混 |
| 信号 | SQLite 会怎样 | 要不要换 |
|---|---|---|
| 写并发超过一个进程(多台机器同时写同一份数据) | 文件锁打架,SQLITE_BUSY 频发 | 换(想清楚要不要先 accepts 多机) |
| 同一份数据要被多台机器读 | 要么共享磁盘(单点),要么每机一份(不一致) | 换 |
| 需要 JSONB 内部索引、窗口函数、复杂分析型聚合 | 能写但很难读、很难优化 | 考虑换 |
| 数据量大到单机磁盘装不下 / 备份窗口过长 | 拷文件这个「优点」直接变成缺点 | 换 |
| 只是「担心以后不够用」 | —— | 不换。这是过度工程。 |
这是全盘唯一一处我承认「知道错了但还没修完」的地方。机制在 深入版 16 讲过,这里只复盘为什么没修。
| 现象 | 机制(机制详见 16 深入版) |
|---|---|
| 写坏一次整库读不出来 | blob 字段一改就是重新序列化整个对象 + 重写整行,横跨好几页。中途任何一页写失败,这行就被截断在半路;截断的 JSON 是非法 JSON,JSON.parse 直接抛异常,于是这个查询接口整个报错,同一次查询里其他完全正常的数据也一起取不到 |
| 没法按字段查 | 索引用在「列」上,建不到 JSON 内部的键上。只能全表拉出来在应用层 parse 再 filter,连带分页失效、排序和分组统计做不了 |
| 并发只能串行 | 「读出来 → 改一个字段 → 整个写回」不是原子操作,两个请求会丢更新;而且整行互斥,写入串成一列 |
要动的是读写全路径 + 迁移期间的双写,一次性投入不小。但我当时只算了工作量,没算风险等级。
排序逻辑是「先交付客户要的新功能,债下个迭代还」。这个排序是错的:这个债的画像不是「高频低损」,是「低频高损」——一年可能一次都不爆,一旦爆就是整库读不出来,属于全站级故障。低频高损的债,优先级应该高于中等收益的新功能,因为它不可预测,且恢复时间长。
这是我在这套代码上做过的最值得记住的一个判断错误:不是技术错误,是风险排序错误。
那张表被多处读写,改结构要配合迁移脚本 + 应用双读双写 + 回滚预案三件事一起上,不能分两次做。
| 止损动作 | 做了没有 | 作用 |
|---|---|---|
迁移前 cp 主库 连 -wal 一起拷,并比对 md5 | ✅ 已在流程里 | 不校验 md5 等于没备份 |
只读接口外层加 try/catch,坏行只让这一行失败 | ✅ 已加 | 把「整功能挂」降级成「这一行显示不出来」,这是目前最有价值的一处改造 |
| 导出 CSV 留档(尤其删字段前) | ✅ 已进流程 | SQLite 没有 ALTER COLUMN,删列底层是「建新表→拷数据→删旧表→改名」 |
| 把 blob 拆成独立列 | ❌ 还没做 | 本债未偿 |
这一节是整篇复盘里最想让人记住的部分:把红利和代价分开算,不要混成一句「用了 monorepo 效率更高」。
| 项 | 是收益还是代价 | 真实程度(我自己怎么评价) |
|---|---|---|
| 类型联动(后端改接口,前端立刻编译报错) | ✅ 真收益 | 最值钱的一笔。而且只有 monorepo 能做到——拆两仓库前端根本拿不到后端类型。代价几乎为零 |
| 枚举一处定义三处生效 | ✅ 真收益 | 非常实用,见第 05 节。这个是「业务词汇收敛」的红利 |
| 一次 build 全量(engine → db → server 串行) | ✅ 真收益 | 部署时不用先想「该先编哪个包」。但收益比想象小:因为最后一个 packages 只有 4 个,人工排也算得清 |
| engine 能单测 | ✅ 超预期收益 | npm run engine:test 不起库、不起服务,几个真实型号几秒跑完。这直接决定了「敢不敢改引擎」 |
| packages/db 的 schema 收敛(27 张表一个文件) | ✅ 全盘最值钱的一处 | 见下 |
| CI 变慢 | ❌ 真代价 | 四个包各跑一次 typecheck,本地改一行代码要等全量 tsc。四包规模下,增量构建其实更划算 |
| 本地要装全栈工具链 | ❌ 真代价 | 一次 npm install 要把后端、前端、构建工具全装齐;Windows 上还要处理 symlink 权限(EPERM);新人上手第一天就卡 |
| 「一个人维护时 monorepo 的价值被高估了」 | ⚠️ 我的结论 | 见下 |
schema.ts 还在一处、枚举还在一处、engine 还是纯 TS,这套架构的核心价值就还在。packages/db 仍然分散成 9 个文件、枚举仍然各写一份、engine 仍然 import express——那 monorepo 只是一个换了位置的文件夹。不是「我早说了应该一开始就…」那种马后炮,是把当时做的决策逐个翻出来,标出我愿意改哪几条、哪几条不改。
| # | 当时怎么做 | 重来怎么改 | 理由 |
|---|---|---|---|
| 1 | 直接上四包全量 monorepo | 先只抽 packages/db(哪怕先只做 schema + 枚举一个包) | 收敛是最大价值,但它不需要 monorepo 才能成立。先做这一步,风险最小、见效最快 |
| 2 | engine 做成纯 TypeScript、依赖为空 | 不改,保持 | 这是唯一一条我觉得超出预期的决策:它换来的是「几秒跑完的单测」,是我后面敢改引擎的前提 |
| 3 | 一上来就配全量 CI(四条构建 + 四条 typecheck) | 先不上 CI,等包超过 6 个再上 | 四个包时全量构建纯属负担,增量构建更划算;多一层工具就多一份维护 |
| 4 | 把 JSON blob 的债排在功能后面 | 按「概率 × 恢复时长」排,高损债一律前置 | 第 07 节那个判断错的直接后果 |
| 5 | 9 层分区只存在我脑子里 + 文件顺序 | 落进表名前缀(mst_/ord_/wo_…) | 分区现在只能靠人记。靠人记的东西,第二个人来就会错位 |
| 6 | 部署用 rsync + git tag + md5 | 不改 | 单文件数据库 + 单机的组合下,这个方案简单到没有理由换 |
| 7 | 先跑数据体检再建引擎 | 不改,这条我要强调的是「坚持」 | 体检结论直接改掉了项目走向(数据不够就不硬算,详见 复盘 25)。这是这个项目最正确的顺序决策 |
dependencies 为空,乱 import 会 install 报错)schema.ts 末尾那段 PostgreSQL 映射是预案不是现状,全盘零 MySQLcollections 的 JSON blob 债还没还,并且知道根因是我把风险排序排错了,不是工作量问题我会讲「先做数据体检,再决定要不要建引擎」,架构决策放在第二条讲——因为架构决策的说服力来自「你为什么这么做、代价是什么」,而数据体检那条有真数字兜底。(那条在 复盘 25。)
架构这条我讲四包边界。我的项目只有一个 monorepo,npm workspaces,四个包:apps/server(Express + tRPC)、apps/web(React)、packages/db(27 张表 schema + migrate/seed)、packages/engine(报价 / BOM / 玻璃图)。划分标准是「有没有独立启动方式」,不是命名习惯。
最值钱的设计是 engine 一个依赖都没有。它纯 TypeScript,不是省事,是自检机制:package.json 里没声明 dependencies,谁乱 import 了 express 或 db,npm install 当场报错,不用等运行时发现。换来的是 npm run engine:test 不起数据库、不起服务,几秒钟跑完几个真实型号的断言——这就是我敢反复改引擎的原因。代价我也认:engine 绝不能 import server,否则成环。
第二值钱的是 packages/db 那一层 schema 收敛。27 张表在一个 schema.ts 里,按 9 层分区(主数据 / 商品 / 价格 / 订单 / 拆单 / 生产 / 图纸 / 库存 / 系统)。分区不是数据库约束,是给我自己排障用的索引:线上说「算不出价」,我先问订单进没进第 4 层、型号进没进第 2 层、价格进没进第 3 层,三层都有问题才去翻算法。
枚举也是这一层的红利:ORDER_STATUS 8 态、PO_STATUS 5 态、WO_STATUS 4 态、REQ_STATUS 3 态、MOVE_TYPES 5 种、USER_ROLES 3 种。这六组枚举一处定义、engine / server / web 三处消费——后端拒绝的集合、前端下拉框选项、engine 认得的合法值,物理上是同一份,不可能对不上。拆两仓库的话就是三份,然后就是经典的「前端多了个选项,后端直接 400」。
数据库这块我必须诚实说一句:全盘没有 MySQL / PostgreSQL,都是 SQLite 家族,better-sqlite3 命中 26 个文件,最佳实践是 schema.ts 末尾那一段 PostgreSQL 迁移映射注释——那是预案不是现状,我从不把它当已有资产讲。现在换 PG 我不认为是好事:部署是单机单进程 pm2 + rsync,库就是一个文件,备份 = 拷文件。真要换的触发条件应是「上了第二台机器」,而不是「怕以后不够」,后者是过度工程。
还有一个我没还完的债,我主动讲。门店订购单系统的 collections 表把整个对象存成单行 JSON blob。我清楚它的机制:改一个字段要重写整行,写坏一次整行截断、JSON.parse 抛异常,同一个查询里其他正常数据也一起取不到;索引用不到 JSON 内部键,只能全表拉出来应用层 filter;读写不是原子,两个请求会丢更新,而且整行互斥、并发串行。
但它还没修,根因不是工作量,是我把风险排序排错了——我按「修要几个人天」排,把它排在功能后面。它其实是低频高损:一年可能一次都不爆,一旦爆就是全站读不出来,恢复时间长。现在的最小止损是只读接口 try/catch(把整功能挂降级成这一行显示不出来)+ 迁移必拷 -wal 并比对 md5。下次排债我用一条新标准:按「发生概率 × 恢复时长」排,不按工作量排。
最后说说我对 monorepo 的真实判断,这个比技术细节更能看出水平:收益里类型联动、枚举收敛、engine 能单测是真收益;CI 变慢(四个包各跑一次全量 tsc)、本地要装全栈工具链(Windows 还要处理 symlink 权限)是真代价。而且一个人维护的时候,monorepo 的价值是被高估的——「免了四次发版」这条在一个人身上几乎等于零,我本来就不会忘记自己刚发过哪版。
所以我的结论是:monorepo 是「把已经收敛的东西放到一起」的容器,不是「让东西自动收敛」的机制。如果重来,我会先只抽 packages/db——因为 schema 收敛是最大价值,但它不需要 monorepo 也能成立;CI 等包超过 6 个再上;9 层分区要落进表名前缀而不是靠人记。 容器可以换,约束不能丢。