-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathatom.xml
More file actions
365 lines (365 loc) · 310 KB
/
Copy pathatom.xml
File metadata and controls
365 lines (365 loc) · 310 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<author>
<name>Robbs Luo</name>
</author>
<generator uri="https://hexo.io/">Hexo</generator>
<id>https://www.robbs.win/</id>
<link href="https://www.robbs.win/" rel="alternate"/>
<link href="https://www.robbs.win/atom.xml" rel="self"/>
<rights>All rights reserved 2026, Robbs Luo</rights>
<subtitle>己所不欲,勿施于人</subtitle>
<title>Robbs</title>
<updated>2026-07-08T01:06:19.829Z</updated>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="全栈" scheme="https://www.robbs.win/tags/%E5%85%A8%E6%A0%88/"/>
<category term="复盘" scheme="https://www.robbs.win/tags/%E5%A4%8D%E7%9B%98/"/>
<content>
<![CDATA[<p>2014 年 7 月,我入职长沙蜜獾信息科技有限公司。</p><p>2026 年 5 月,我离开。</p><p>11 年,一家公司。</p><p>在这之前,我还自己创过业:从 2011 年到 2014 年,搭过一个会议信息平台。那 3 年是另一段故事,这篇不展开,但它是我技术生涯的真正起点。算下来,我的研发经历是这 11 年加上前面创业的 3 年。</p><p>这篇不是简历,简历上有的东西我不重复。这篇讲的是这 11 年里我的技术体系怎么一步步长出来的,以及每个阶段我学到了什么。</p><h2 id="起点:后端的笨功夫"><a href="#起点:后端的笨功夫" class="headerlink" title="起点:后端的笨功夫"></a>起点:后端的笨功夫</h2><p>2014 年入职后,我先在百商号做了一年:百货商超的活动营销平台,基于 MEAN.JS 全栈框架,前端后端一把抓。那是我的第一个正式项目。</p><p>2015 年转到 HomePartners,做房产投资的数据计算与投资管理系统,后端用 Ruby on Rails,一待三年。</p><p>那个阶段没什么花巧,就是一个接口一个接口地写,一个 bug 一个 bug 地修。但有两件事我后来才意识到它的价值:</p><p>一是对数据的敬畏。投资收益、资产分布、风险指标,这些口径不能错,错一个小数点,后面的报表全崩。我养成了一个习惯:任何计算逻辑都要能复算、能追溯。这个习惯一直带到了后来做理财工具。</p><p>二是对整个项目的感知。当时我不只写后端,部署、监控、发布流程我都碰。不是分工让我碰的,是我自己想搞明白。这个习惯后来成了我做架构判断的底气。</p><p>全栈不是什么都会,是对整个项目有感知。不需要每个环节都精通,但要知道每个环节在干什么。</p><h2 id="CoreTeam:第一次做”全局视角”"><a href="#CoreTeam:第一次做”全局视角”" class="headerlink" title="CoreTeam:第一次做”全局视角”"></a>CoreTeam:第一次做”全局视角”</h2><p>2018 年,我转到 CoreTeam,做用户画像系统和运维管理平台。</p><p>这是第一次,我从”设计一个系统”开始,而不是从”写一个接口”开始。</p><p>用户画像系统要采集编码数据、计算能力指标、做多维度分析。运维平台要管资源、管发布、管监控。这些系统的共同点是:没有现成的路,得自己定义流程。</p><p>这个阶段我学到的核心能力是抽象:把散乱的业务需求抽象成可复用的计算节点、可组合的功能模块。后来做理财工具的时候,这套抽象能力直接用上了。</p><p>也是在 CoreTeam,我开始带人、做规划。技术能力解决的是”能不能做”,管理能力解决的是”能不能持续地做”。</p><h2 id="Supernova:理财线的端到端"><a href="#Supernova:理财线的端到端" class="headerlink" title="Supernova:理财线的端到端"></a>Supernova:理财线的端到端</h2><p>2021 年转到 Supernova,负责理财工具产品线。这是 11 年里最重的一段。</p><p>理财线有 13 个工具:某理财计算器、某贷款计算器、某再融资工具、某购房能力评估……每一个都是独立的计算器,但底层共享同一套金融逻辑。</p><p>我在这条线上做的事跨度很大:</p><ul><li>产品规划:哪些工具先做、哪些后做,工具之间的关系怎么设计。</li><li>系统架构:前端 Vue 2.7 后来迁到 React 19,后端 API 设计,状态管理方案。</li><li>核心算法:金融计算的口径确认和实现(具体算法不展开,但每个工具的数字都要对得上)。</li><li>研发交付:3 人小组,13 个工具,用 AI 编码驱动。</li><li>质量保障:单测、e2e、lint、发布流程,全套建起来。</li></ul><p>同时我还管 Collateral Management(抵押品管理)和 DAL 小组(数据库审核和 SDK 维护)。</p><p>Collateral 教会我性能优化:大批量数据下的计算耗时怎么压,索引怎么建,SQL 怎么调。</p><p>DAL 教我跨语言工程:Java 和 Python 两个版本的数据库 SDK,行为要对齐,连接管理、读写分离、慢查询监控要一致。维护两套代码不是简单地复制粘贴,得用工程规范保证跨语言的一致性。</p><h2 id="AI-编码:这-11-年最大的变量"><a href="#AI-编码:这-11-年最大的变量" class="headerlink" title="AI 编码:这 11 年最大的变量"></a>AI 编码:这 11 年最大的变量</h2><p>如果说前 10 年我的成长路径还算”常规”:一直是全栈,只是技术栈从 MEAN.JS、Rails 换到 Vue、React;执行到架构、个人到团队。那 2025 年开始的 AI 编码是最大的变量。</p><p>Claude Code 出来之后,我做了一个判断:这件事不是”用不用 AI”的选择题,是”早用还是晚用”的时间问题。</p><p>然后理财线的迁移就成了 AI 编码的试验场。一个半月,13 个工具,从 Vue 到 React,用迁移 Agent 并行跑完。</p><p>后面的故事前面几篇都讲了:Skill 沉淀、CLAUDE.md 进团队、Subagent 编排。到 2026 年初,AI 编码已经是我们团队的标准工作方式。</p><p>AI 编码改变的不是”代码怎么写”,而是”工程师的精力应该花在哪里”。以前花在写重复代码上的时间,现在花在设计规则、编排流程、做架构判断上。</p><h2 id="技术体系长什么样"><a href="#技术体系长什么样" class="headerlink" title="技术体系长什么样"></a>技术体系长什么样</h2><p>11 年下来,如果让我画一张”技术体系”的图,大概是这个结构:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"> ┌─────────────────┐</span><br><span class="line"> │ 工程化思维 │ 抽象、复用、自动化</span><br><span class="line"> └────────┬────────┘</span><br><span class="line"> │</span><br><span class="line"> ┌──────────────┼──────────────┐</span><br><span class="line"> │ │ │</span><br><span class="line">┌────────▼───────┐ ┌───▼────────┐ ┌───▼────────┐</span><br><span class="line">│ 前端体系 │ │ 后端体系 │ │ 数据体系 │</span><br><span class="line">│ React/Vue/Vite │ │ API/架构 │ │ SQL/优化 │</span><br><span class="line">│ 状态管理/CSS │ │ 性能/部署 │ │ 分库分表 │</span><br><span class="line">└────────────────┘ └────────────┘ └────────────┘</span><br><span class="line"> │ │ │</span><br><span class="line"> └──────────────┼──────────────┘</span><br><span class="line"> │</span><br><span class="line"> ┌────────▼────────┐</span><br><span class="line"> │ AI 编码体系 │ Skill/CLAUDE.md/Agent</span><br><span class="line"> └─────────────────┘</span><br></pre></td></tr></table></figure><p>底层是前端、后端、数据三套技术栈;中间是工程化思维把它们串起来;上面是 AI 编码体系,让这套体系的产出效率翻倍。</p><p>这个体系不是一天建成的,是 11 年一层一层叠的。每一层都有踩过的坑和交过的学费。</p><h2 id="如果给新人一句话"><a href="#如果给新人一句话" class="headerlink" title="如果给新人一句话"></a>如果给新人一句话</h2><p>如果让我给刚入行的人一句话,大概是:</p><p>不要急着追新技术,先把一件事做深;做深了一件事,第二件事就会快很多。</p><p>我在 HomePartners 写 Rails、做投资计算的那几年,没想过以后会做 React。但那几年养成的数据敬畏、项目感知、抽象能力,做 React 的时候全用上了。</p><p>技术栈会变,底层的工程思维和解决问题的能力不会变。这些能力才是 11 年里最值钱的东西。</p><h2 id="收尾"><a href="#收尾" class="headerlink" title="收尾"></a>收尾</h2><p>11 年,一家公司。</p><p>有人问我为什么待这么久。原因不复杂:每两三年就有新的挑战,技术栈在变,业务在变,角色在变。变化足够多,就不需要靠跳槽来找新鲜感。</p><p>但现在是个节点了。AI 编码这条路才刚开头,理财线的体系已经成型,剩下的增量需要新的场景去验证。</p><p>11 年不是终点,是一个阶段的交付。</p><p>写完这篇,就算给这段旅程画个句号。接下来去哪、做什么,到时候再说。</p><p>但不管做什么,这 11 年攒下的东西——对数据的敬畏、对项目的感知、对工程的理解、对 AI 的判断——都会跟着我。</p><p>这就够了。</p>]]>
</content>
<id>https://www.robbs.win/2026-05-18/Eleven-Year-FullStack-Retrospective.html</id>
<link href="https://www.robbs.win/2026-05-18/Eleven-Year-FullStack-Retrospective.html"/>
<published>2026-05-18T06:00:00.000Z</published>
<summary>2014 到 2026,在一家公司待了 11 年。一直是全栈,只是技术栈一路在变——从 MEAN.JS、Rails 到 Vue、React;从 CoreTeam 到 Collateral 到 DAL 到理财线,从手写代码到 AI 编码。这是我的技术体系复盘,也是一段旅程的收尾。</summary>
<title>11 年,一家公司:我的全栈技术体系复盘</title>
<updated>2026-07-08T01:06:19.829Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="Claude Code" scheme="https://www.robbs.win/tags/Claude-Code/"/>
<category term="AI 工程化" scheme="https://www.robbs.win/tags/AI-%E5%B7%A5%E7%A8%8B%E5%8C%96/"/>
<content>
<![CDATA[<p>2025 年 2 月,Claude Code 出研究预览版的时候,我开始用它。当时理财线刚准备做 Vue 2.7 到 React 19 的大迁移。</p><p>到 2026 年 3 月写这篇的时候,理财线 3 个人用 AI 编码交付了 13 个工具的完整迁移,外加持续的迭代和新功能。两年下来,AI 编码在我们这里已经不算一个工具了,而是一套工程体系。</p><p>这篇做个总复盘,讲这套体系是怎么一步步长出来的。</p><h2 id="时间线"><a href="#时间线" class="headerlink" title="时间线"></a>时间线</h2><p>先拉一下关键节点:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">2025-02 开始用 Claude Code(研究预览版)</span><br><span class="line">2025-03 写第一个 Prompt,手动迁试点工具</span><br><span class="line">2025-04 抽出第一批 Skill,固化迁移规则</span><br><span class="line">2025-05 UI 迁移 Agent 成型,13 个工具并行迁移</span><br><span class="line">2025-06 Skill 库到 10+,开始复用</span><br><span class="line">2025-07 Webpack 切 Vite,构建工具迁移完成</span><br><span class="line">2025-08 迁移收尾,开始推团队</span><br><span class="line">2025-09 CLAUDE.md + Subagent 模式进团队</span><br><span class="line">2025-12 团队 AI 编码规范稳定</span><br><span class="line">2026-03 写这篇复盘</span><br></pre></td></tr></table></figure><p>你看这个节奏,一开始并没有设计好一套体系,是被需求推着一步步长出来的。</p><h2 id="演进的四层"><a href="#演进的四层" class="headerlink" title="演进的四层"></a>演进的四层</h2><p>回看这两年,AI 编码工程化的演进大致分四层:</p><h3 id="第一层:Prompt(2025-初)"><a href="#第一层:Prompt(2025-初)" class="headerlink" title="第一层:Prompt(2025 初)"></a>第一层:Prompt(2025 初)</h3><p>最早就是写 Prompt。手动把 Vue 代码贴给 Claude,让它转成 React。</p><p>问题很明显:每次都要重复讲一遍规范,讲一遍约束,讲一遍验收标准。Prompt 越写越长,但换个文件又得重来。</p><p>Prompt 解决的是”AI 能不能干这个活”的问题,没解决”每次干得一样不一样”的问题。</p><h3 id="第二层:Skill(2025-春)"><a href="#第二层:Skill(2025-春)" class="headerlink" title="第二层:Skill(2025 春)"></a>第二层:Skill(2025 春)</h3><p>Prompt 写多了,我发现重复的部分太多。把规范、映射规则、检查标准抽出来,写成 Skill,Claude Code 自动加载。</p><p>这一层解决的是一致性:13 个工具迁出来风格统一,靠的是 Skill 而不是 Prompt。</p><figure class="highlight markdown"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="section"># 一个 Skill 的骨架(脱敏)</span></span><br><span class="line"><span class="section">## 适用场景</span></span><br><span class="line">xxx 时使用本 Skill</span><br><span class="line"></span><br><span class="line"><span class="section">## 规则</span></span><br><span class="line"><span class="bullet">-</span> 规则 1</span><br><span class="line"><span class="bullet">-</span> 规则 2</span><br><span class="line"></span><br><span class="line"><span class="section">## 禁止项</span></span><br><span class="line"><span class="bullet">-</span> 禁止 xxx</span><br><span class="line"></span><br><span class="line"><span class="section">## 验收标准</span></span><br><span class="line"><span class="bullet">-</span> tsc 零报错</span><br><span class="line"><span class="bullet">-</span> ESLint 零 error</span><br></pre></td></tr></table></figure><p>Skill 让我从”每次教 AI 怎么做”变成了”教一次,以后自动复用”。</p><h3 id="第三层:CLAUDE-md(2025-秋)"><a href="#第三层:CLAUDE-md(2025-秋)" class="headerlink" title="第三层:CLAUDE.md(2025 秋)"></a>第三层:CLAUDE.md(2025 秋)</h3><p>Skill 解决了”单次任务的一致性”,但团队里每个人用出来的风格还是不一样。因为全局的工程规范(技术栈、PR 流程、测试要求)不在 Skill 里,也不在每个人的脑子里一致。</p><p>CLAUDE.md 把团队级的规范写进了项目根目录,Claude Code 每次启动都读。这一层解决的是团队一致性:不是一个人的代码一致,是所有人的代码一致。</p><h3 id="第四层:Subagent-编排(2025-末)"><a href="#第四层:Subagent-编排(2025-末)" class="headerlink" title="第四层:Subagent 编排(2025 末)"></a>第四层:Subagent 编排(2025 末)</h3><p>前面三层都是”一个 AI 怎么干好一件事”。到第四层,开始同时跑多个 Agent,处理并行的任务。</p><p>Subagent 编排解决的是并行效率:3 个人同时推 3 个功能,靠主 Agent 做编排和兜底,而不是各自单干。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">编排层(主 Agent)</span><br><span class="line"> ├── 任务 A(Subagent)</span><br><span class="line"> ├── 任务 B(Subagent)</span><br><span class="line"> └── 任务 C(Subagent)</span><br><span class="line"> │</span><br><span class="line"> └─ 各自带着 CLAUDE.md + Skill 跑</span><br></pre></td></tr></table></figure><h2 id="什么有效,什么没用"><a href="#什么有效,什么没用" class="headerlink" title="什么有效,什么没用"></a>什么有效,什么没用</h2><p>两年下来,有些判断我比较确定了:</p><p><strong>有效的:</strong></p><ul><li>规则先行。不管做什么,先把规则写成 Skill 或 CLAUDE.md,再让 AI 干。AI 干活快,但没规则的话,干得越快错得越多。</li><li>分层设计。Skill 管单任务,CLAUDE.md 管全局,Subagent 管编排。各管各的,不混。</li><li>验证自动化。AI 产出的代码必须过 lint + 类型检查 + 测试,没有人工 review 兜底的 AI 编码就是赌博。</li></ul><p><strong>没太大用的:</strong></p><ul><li>教团队写 Prompt。投入产出比很低,每个人用法不同,教了也管不住。不如把精力放在 Skill 和 CLAUDE.md 上。</li><li>追求 Prompt 的”完美”。Prompt 写到 80 分就够了,剩下 20 分靠 Skill 和验证流程补。追求 Prompt 完美是过度优化。</li><li>什么都让 AI 干。有些事情(比如构建工具迁移的坑、线上故障排查)AI 给不了你答案,这些得靠人的经验。</li></ul><p>AI 编码这件事,是用 AI 做好重复的部分,让人专注判断的部分,不是用 AI 替代人。</p><h2 id="质量怎么保证"><a href="#质量怎么保证" class="headerlink" title="质量怎么保证"></a>质量怎么保证</h2><p>被问最多的一个问题:你们用 AI 写代码,质量怎么保证?</p><p>我的回答是:AI 产出的代码质量,取决于你的工程基建有多扎实,而不是 AI 有多聪明。</p><p>我们的质量兜底:</p><ol><li>CLAUDE.md 定规范,AI 按规范产出</li><li>Skill 定规则,AI 按映射和约束干活</li><li>ESLint + tsc 自动检查,格式和类型问题自动拦截</li><li>e2e 覆盖核心路径,业务逻辑回归自动发现</li><li>人工 review,架构决策和边界 case 人来看</li></ol><p>这五层里,前三层是自动的,第四层半自动,只有第五层是人。人的精力集中在真正需要判断的地方,不在格式和语法上。</p><h2 id="如果重来一遍"><a href="#如果重来一遍" class="headerlink" title="如果重来一遍"></a>如果重来一遍</h2><p>如果重新来一次,我会调整两个节奏:</p><ul><li>更早推 CLAUDE.md。我是在迁移跑完之后才推的,其实应该在写第一个 Skill 的时候就同步建 CLAUDE.md,这样团队其他人介入更早。</li><li>更早建 Subagent 编排。迁移时已经用了 Subagent,但日常开发用得晚。其实多工具并行的场景,越早用编排模式效率越高。</li></ul><p>不过整体来看,这两年的演进路径是合理的:从 Prompt 到 Skill 到 CLAUDE.md 到 Subagent,每一层解决一个前一层没解决的问题。这套路径不是一开始设计出来的,是被需求推出来的。</p><p>AI 编码工程化跟所有工程化一样,没有银弹,只有一层一层地解决问题。你能解决多少层,AI 的产出就有多可靠。</p><p>理财线的 AI 编码体系到 2026 年算是基本成型了。但我知道这只是个开始:AI 编码的工具和范式变化太快,今年的体系明年可能就要重构。保持学习和调整,可能才是这套体系唯一不变的部分。</p>]]>
</content>
<id>https://www.robbs.win/2026-03-15/AI-Coding-Engineering-Retrospective.html</id>
<link href="https://www.robbs.win/2026-03-15/AI-Coding-Engineering-Retrospective.html"/>
<published>2026-03-15T02:00:00.000Z</published>
<summary>从 2025 年初第一次用 Claude Code,到 2026 年初把 AI 编码工程化推到团队级别。这篇是理财线两年的总复盘:从 Prompt 到 Skill 到 CLAUDE.md 到 Subagent,这套体系怎么长出来的。</summary>
<title>理财线两年:AI 编码工程化这套怎么沉淀下来的</title>
<updated>2026-07-08T01:06:19.795Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="Claude Code" scheme="https://www.robbs.win/tags/Claude-Code/"/>
<category term="CLAUDE.md" scheme="https://www.robbs.win/tags/CLAUDE-md/"/>
<content>
<![CDATA[<p>理财线迁移跑通之后,我做了一件事:把 Claude Code 从我自己用的工具,推成了整个团队的工作方式。</p><p>迁移那一个半月,主要是我和 Claude Code 两个人(应该说一个人加一堆 Agent)在跑。但跑完之后,其他人也得用起来,不然这些 Skill 和规范就是我一个人的东西,换个人就废了。</p><p>这篇讲两件事:CLAUDE.md 怎么写团队规范,Subagent 怎么编排多人并行。</p><h2 id="先说为什么不能只靠口头规范"><a href="#先说为什么不能只靠口头规范" class="headerlink" title="先说为什么不能只靠口头规范"></a>先说为什么不能只靠口头规范</h2><p>迁移完了之后,团队里有人开始自己用 Claude Code。但很快出现一个问题:每个人用出来的风格不一样。</p><p>A 同学让 Claude 写组件,文件组织是一个目录一个组件;B 同学让 Claude 写,全堆在一个文件里。C 同学的测试覆盖很全,D 同学压根没让 Claude 写测试。</p><p>你去说”我们要统一规范”,说了等于没说。口头规范在 AI 编码时代几乎等于零约束力,因为每个人跟 AI 的对话都不一样,AI 只按当前对话的上下文来。</p><p>人类团队的规范靠 wiki 和口头传达还能凑合,AI 团队的规范得写进机器读得到的地方。</p><p>这就是 CLAUDE.md 存在的意义。</p><h2 id="CLAUDE-md-是什么"><a href="#CLAUDE-md-是什么" class="headerlink" title="CLAUDE.md 是什么"></a>CLAUDE.md 是什么</h2><p>CLAUDE.md 是 Claude Code 在项目根目录自动读取的一个配置文件。你把团队规范写进去,Claude Code 每次启动都会读它,然后按规矩办事。</p><p>你可以把它理解成给 AI 看的 CONTRIBUTING.md。人类的新人读 wiki,AI 的新人(也就是每次 Claude Code 启动)读 CLAUDE.md。</p><h2 id="我们的-CLAUDE-md-长什么样"><a href="#我们的-CLAUDE-md-长什么样" class="headerlink" title="我们的 CLAUDE.md 长什么样"></a>我们的 CLAUDE.md 长什么样</h2><p>脱敏后的核心片段:</p><figure class="highlight markdown"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="section"># 项目工程规范(CLAUDE.md)</span></span><br><span class="line"></span><br><span class="line"><span class="section">## 技术栈</span></span><br><span class="line"><span class="bullet">-</span> React 19 + TypeScript 5.x</span><br><span class="line"><span class="bullet">-</span> Vite 6 构建</span><br><span class="line"><span class="bullet">-</span> Zustand 状态管理</span><br><span class="line"><span class="bullet">-</span> CSS Modules 样式</span><br><span class="line"><span class="bullet">-</span> pnpm monorepo</span><br><span class="line"></span><br><span class="line"><span class="section">## 代码规范</span></span><br><span class="line"><span class="bullet">-</span> 组件用函数组件,禁止 Class 组件</span><br><span class="line"><span class="bullet">-</span> 文件命名:组件 PascalCase,工具函数 camelCase</span><br><span class="line"><span class="bullet">-</span> 一个组件一个目录:<span class="code">`Button/index.tsx`</span> + <span class="code">`Button.module.css`</span> + <span class="code">`types.ts`</span></span><br><span class="line"><span class="bullet">-</span> 跨组件状态走 Zustand store,组件内状态走 useState</span><br><span class="line"><span class="bullet">-</span> 禁止 <span class="code">`any`</span>,必须显式标类型</span><br><span class="line"></span><br><span class="line"><span class="section">## 测试要求</span></span><br><span class="line"><span class="bullet">-</span> 每个新组件至少有主路径的单元测试</span><br><span class="line"><span class="bullet">-</span> 业务工具必须有 e2e 覆盖核心计算流程</span><br><span class="line"><span class="bullet">-</span> 测试文件放 <span class="code">`__tests__`</span> 目录,命名 <span class="code">`xxx.test.ts`</span></span><br><span class="line"></span><br><span class="line"><span class="section">## PR 规范</span></span><br><span class="line"><span class="bullet">-</span> commit message 用 conventional commits 格式</span><br><span class="line"><span class="bullet">-</span> PR 描述必须包含:改了什么、为什么改、怎么测的</span><br><span class="line"><span class="bullet">-</span> 禁止直接 push master,必须走 PR</span><br><span class="line"></span><br><span class="line"><span class="section">## 禁止项</span></span><br><span class="line"><span class="bullet">-</span> 禁止引入新依赖而不在 PR 里说明理由</span><br><span class="line"><span class="bullet">-</span> 禁止用 <span class="code">`// @ts-ignore`</span> 跳过类型检查</span><br><span class="line"><span class="bullet">-</span> 禁止提交 console.log</span><br></pre></td></tr></table></figure><p>你看,它不是什么高深的东西,就是把团队约定的事实写成了 AI 能读的格式。但效果差别巨大:写进去之后,每个人用 Claude Code 产出的代码风格开始趋同了。</p><h2 id="怎么组织-CLAUDE-md"><a href="#怎么组织-CLAUDE-md" class="headerlink" title="怎么组织 CLAUDE.md"></a>怎么组织 CLAUDE.md</h2><p>我们的 CLAUDE.md 不是一个大文件,而是分层的:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">项目根/</span><br><span class="line">├── CLAUDE.md ← 全局规范(技术栈、代码风格、PR 流程)</span><br><span class="line">├── packages/</span><br><span class="line">│ ├── shared/</span><br><span class="line">│ │ └── CLAUDE.md ← 共享库的特定规范</span><br><span class="line">│ └── tools/</span><br><span class="line">│ ├── mortgage-calculator/</span><br><span class="line">│ │ └── CLAUDE.md ← 这个工具的特定规范(业务逻辑约束)</span><br><span class="line">│ └── ...</span><br><span class="line">└── .claude/</span><br><span class="line"> └── skills/ ← 可复用 Skill</span><br><span class="line"> ├── vue-to-react-mapping.md</span><br><span class="line"> ├── code-review-checklist.md</span><br><span class="line"> └── ...</span><br></pre></td></tr></table></figure><p>Claude Code 会按层级合并这些文件:根目录的全局规范 + 当前工作目录的特定规范。这样你在一个工具里干活时,Claude 既知道全局规矩,也知道这个工具的特殊约束。</p><p>CLAUDE.md 的分层设计很重要。全局规范管一致性,局部规范管特殊性,不要全堆一个文件里。</p><h2 id="Subagent-怎么编排"><a href="#Subagent-怎么编排" class="headerlink" title="Subagent 怎么编排"></a>Subagent 怎么编排</h2><p>CLAUDE.md 解决的是”规范统一”的问题,Subagent 解决的是”并行效率”的问题。</p><p>迁移的时候我用了 Subagent 来并行跑 13 个工具。迁移完了之后,日常开发也开始用 Subagent 模式。</p><p>举一个真实的场景:同时有三个工具要加新功能。传统做法是三个人各干各的,互相不知道对方在干嘛。用 Subagent 的做法是:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">主 Agent(我)</span><br><span class="line">├── Subagent A:给某贷款计算器加提前还款功能</span><br><span class="line">├── Subagent B:给某再融资工具加新利率场景</span><br><span class="line">└── Subagent C:给某购房能力评估加税费计算</span><br></pre></td></tr></table></figure><p>每个 Subagent 带着同一份 CLAUDE.md 和相关 Skill,独立跑。跑完汇总到主 Agent 做集成检查。</p><p>关键是主 Agent 的职责不是写代码,而是编排和兜底:</p><ul><li>分配任务(谁干什么)</li><li>检查冲突(改了同一个共享文件?)</li><li>集成验证(三个功能加完,整体能跑吗?)</li></ul><p>人类写代码的时代,管理者分配任务;AI 写代码的时代,人类设计编排方式。角色的重心从”执行”转向了”编排”。</p><h2 id="实际效果"><a href="#实际效果" class="headerlink" title="实际效果"></a>实际效果</h2><p>推了大概两个月,几个明显的变化:</p><ul><li>PR 风格统一了:不再是每个人一个风格,code review 的”格式问题”少了 80% 以上。</li><li>新人上手快了:新同学进来,CLAUDE.md 一读,Skill 一看,用 Claude Code 产出的代码天然符合规范。</li><li>测试覆盖上去了:CLAUDE.md 里写了测试要求,Claude Code 每次都会主动补测试,不用人提醒。</li></ul><p>但也有代价:CLAUDE.md 的维护成本不低。技术栈变了、规范调整了,都得同步改。我把 CLAUDE.md 的更新列进了团队的常规迭代项,不然它很快就会过时。</p><h2 id="我觉得最重要的一点"><a href="#我觉得最重要的一点" class="headerlink" title="我觉得最重要的一点"></a>我觉得最重要的一点</h2><p>推了一轮下来,我觉得最重要的一点是:</p><p>AI 编码进团队,核心不是教大家怎么写 Prompt,而是把规范”机器可读化”。</p><p>Prompt 是个人技巧,你教了也管不住每个人怎么用。CLAUDE.md 和 Skill 是工程基建,写好了所有人自动受益。</p><p>这跟以前做 DevOps 是一个道理:你不会去教每个新人怎么配 CI/CD,你把 CI/CD 配好,让流程自动跑。AI 编码的规范化也是一样,建基建比教技巧更有用。</p><p>下一篇是理财线 AI 编码的总复盘,讲这两年从”个人用 AI”到”团队工程化”的完整脉络。</p>]]>
</content>
<id>https://www.robbs.win/2025-09-15/CLAUDE-md-Agent-Orchestration.html</id>
<link href="https://www.robbs.win/2025-09-15/CLAUDE-md-Agent-Orchestration.html"/>
<published>2025-09-15T07:00:00.000Z</published>
<summary>迁移跑通后,我把 Claude Code 的使用从"个人工具"推到了"团队规范"。这篇讲 CLAUDE.md 怎么写团队级的 AI 编码规范,Subagent 怎么编排多人并行。</summary>
<title>Claude Code 进团队:CLAUDE.md 和 Agent 怎么编排</title>
<updated>2026-07-08T01:06:19.762Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="Vite" scheme="https://www.robbs.win/tags/Vite/"/>
<category term="Webpack" scheme="https://www.robbs.win/tags/Webpack/"/>
<content>
<![CDATA[<p>前面两篇讲了组件迁移和 Skill 沉淀,其实整个迁移还有一条线没怎么提——构建工具从 Webpack 切到了 Vite。</p><p>组件迁移是 AI Agent 跑的,但构建工具的切换没法让 AI 全包,因为每个坑都跟具体依赖、具体环境相关,AI 猜不出来。这部分基本是我一个坑一个坑踩出来的。</p><p>这篇把我踩过的坑记一下,别人要迁的时候能少走点弯路就行。</p><h2 id="为什么要换掉-Webpack"><a href="#为什么要换掉-Webpack" class="headerlink" title="为什么要换掉 Webpack"></a>为什么要换掉 Webpack</h2><p>原因其实不复杂:</p><ul><li>dev 启动太慢:13 个工具的 monorepo,Webpack cold start 要 40 秒以上,HMR 响应也越来越慢。</li><li>配置太重:一堆 loader 和 plugin 互相依赖,改一个地方怕牵一发动全身。</li><li>React 19 生态对新构建工具更友好:很多新库的文档默认按 Vite 写,Webpack 的适配反而成了额外工作。</li></ul><p>换构建工具的收益不是”快了一点”,是整个开发体验的质变。Vite 的 dev server 秒开,这个体感差距是回不去的。</p><p>但代价也不小。下面是真实踩到的坑。</p><h2 id="坑一:CommonJS-依赖的预构建"><a href="#坑一:CommonJS-依赖的预构建" class="headerlink" title="坑一:CommonJS 依赖的预构建"></a>坑一:CommonJS 依赖的预构建</h2><p>Vite 的 dev server 基于 ESM,但 node_modules 里很多包还是 CommonJS。Vite 会自动用 esbuild 预构建这些依赖,但有些包预构建后会出问题。</p><p>典型的:某个依赖在 CommonJS 下导出 <code>{ default: xxx, ...namedExports }</code>,esbuild 预构建后 <code>default</code> 取不到,运行时报 <code>xxx is not a function</code>。</p><p>解决办法是把这类包显式加到 <code>optimizeDeps.include</code>,让 Vite 强制处理:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// vite.config.ts</span></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title function_">defineConfig</span>({</span><br><span class="line"> <span class="attr">optimizeDeps</span>: {</span><br><span class="line"> <span class="attr">include</span>: [</span><br><span class="line"> <span class="comment">// 这些包是 CJS,不显式声明会出运行时错误</span></span><br><span class="line"> <span class="string">'some-cjs-lib'</span>,</span><br><span class="line"> <span class="string">'@some/legacy-package'</span>,</span><br><span class="line"> ],</span><br><span class="line"> },</span><br><span class="line">})</span><br></pre></td></tr></table></figure><p>更坑的是有些包只有 production build 才报错(dev 没问题,build 时 Rollup 处理 CJS 的方式和 esbuild 不一样)。这种只能上线前跑一遍 <code>vite build</code> 才能发现。</p><h2 id="坑二:路径别名"><a href="#坑二:路径别名" class="headerlink" title="坑二:路径别名"></a>坑二:路径别名</h2><p>Webpack 的 <code>resolve.alias</code> 和 Vite 的 <code>resolve.alias</code> 配置方式不一样,名字一样但行为不同,这个最坑。</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Webpack 写法</span></span><br><span class="line"><span class="attr">resolve</span>: {</span><br><span class="line"> <span class="attr">alias</span>: {</span><br><span class="line"> <span class="string">'@'</span>: path.<span class="title function_">resolve</span>(__dirname, <span class="string">'src'</span>),</span><br><span class="line"> },</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="comment">// Vite 写法(注意格式不同)</span></span><br><span class="line"><span class="attr">resolve</span>: {</span><br><span class="line"> <span class="attr">alias</span>: {</span><br><span class="line"> <span class="string">'@'</span>: path.<span class="title function_">resolve</span>(__dirname, <span class="string">'src'</span>),</span><br><span class="line"> <span class="comment">// 尾斜杠很关键,不写会匹配错</span></span><br><span class="line"> <span class="string">'@/'</span>: path.<span class="title function_">resolve</span>(__dirname, <span class="string">'src'</span>) + <span class="string">'/'</span>,</span><br><span class="line"> },</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>实际踩到的:有个工具里 <code>@/components/Button</code> 和 <code>@components/Button</code> 两种写法混用,Webpack 都能解析,Vite 只认第一种。统一别名写法这件事得在迁移前先做,不然迁完到处报 <code>module not found</code>。</p><p>另外 tsconfig.json 的 <code>paths</code> 也得同步改,不然 IDE 的类型提示全飘了。</p><h2 id="坑三:CSS-Modules-的产物差异"><a href="#坑三:CSS-Modules-的产物差异" class="headerlink" title="坑三:CSS Modules 的产物差异"></a>坑三:CSS Modules 的产物差异</h2><p>我们统一用 CSS Modules,这个在迁移 Skill 里定了。但 Webpack 和 Vite 对 CSS Modules 的处理有差异:</p><figure class="highlight css"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/* Button.module.css */</span></span><br><span class="line"><span class="selector-class">.title</span> { <span class="attribute">color</span>: red; }</span><br></pre></td></tr></table></figure><ul><li>Webpack:生成的类名是 <code>Button_title__xxxxx</code>(带文件名前缀)</li><li>Vite:默认是 <code>_title_xxxxx</code>(不带文件名前缀)</li></ul><p>看起来无所谓?但你如果有 e2e 测试用类名做选择器就炸了。我们的 Playwright 测试里有一些 <code>data-testid</code> 不够的地方用了类名兜底,迁完全挂。</p><p>解决办法是显式配置 Vite 的 CSS Modules 生成规则:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">css</span>: {</span><br><span class="line"> <span class="attr">modules</span>: {</span><br><span class="line"> <span class="attr">generateScopedName</span>: <span class="string">'[name]_[local]__[hash:base64:5]'</span>,</span><br><span class="line"> },</span><br><span class="line">},</span><br></pre></td></tr></table></figure><p>让产物格式和 Webpack 对齐。不过我后来想通了,测试不应该依赖实现细节(类名是实现细节),所以把 e2e 的类名选择器全换成了 <code>data-testid</code>,这才是正道。</p><h2 id="坑四:HMR-行为不一样"><a href="#坑四:HMR-行为不一样" class="headerlink" title="坑四:HMR 行为不一样"></a>坑四:HMR 行为不一样</h2><p>Vite 的 HMR 是基于原生 ESM 的,比 Webpack 快很多,但行为有差异:</p><ul><li>Webpack 的 HMR 会保留组件的 state(配合 react-refresh)。</li><li>Vite + react-refresh 大部分场景一样,但修改 store 文件时,Vite 会整页 reload,而 Webpack 可能只刷组件。</li></ul><p>这在调试的时候很影响体感。你改一个 Zustand store,页面整个刷新,之前填的表单数据没了。最后我们的做法是把 store 拆得更细——改一个 store 不应该影响无关页面的 state,这本身也是更好的架构实践。</p><h2 id="坑五:环境变量"><a href="#坑五:环境变量" class="headerlink" title="坑五:环境变量"></a>坑五:环境变量</h2><p>Webpack 用 <code>DefinePlugin</code> 注环境变量,Vite 用 <code>import.meta.env</code>。这个迁移 Agent 能帮忙转一部分,但运行时取值方式变了,需要全局搜一遍:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Webpack</span></span><br><span class="line"><span class="keyword">const</span> apiBase = process.<span class="property">env</span>.<span class="property">API_BASE</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// Vite</span></span><br><span class="line"><span class="keyword">const</span> apiBase = <span class="keyword">import</span>.<span class="property">meta</span>.<span class="property">env</span>.<span class="property">VITE_API_BASE</span></span><br></pre></td></tr></table></figure><p>注意 Vite 的环境变量必须以 <code>VITE_</code> 开头才会暴露给前端代码。我们有好几个环境变量没改前缀,上线后取到 <code>undefined</code>,排查了半天。</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># .env 文件</span></span><br><span class="line">VITE_API_BASE=https://api.example.com <span class="comment"># 对</span></span><br><span class="line">API_BASE=https://api.example.com <span class="comment"># 错,前端拿不到</span></span><br></pre></td></tr></table></figure><h2 id="坑六:production-build-产物结构"><a href="#坑六:production-build-产物结构" class="headerlink" title="坑六:production build 产物结构"></a>坑六:production build 产物结构</h2><p>Vite 默认把所有东西打到 <code>dist/assets/</code>,文件名带 hash。WebPack 也是类似,但 chunk 拆分策略不同。</p><p>Webpack 的 <code>splitChunks</code> 配置在 Vite 里对应 <code>build.rollupOptions.output.manualChunks</code>。如果你之前手动配过 chunk 拆分,迁过来要重写。我们的做法是先不手动拆,用 Vite 默认策略跑一轮,看实际产物再调。大多数场景默认策略够用,别过度优化。</p><h2 id="复盘:怎么平滑迁移"><a href="#复盘:怎么平滑迁移" class="headerlink" title="复盘:怎么平滑迁移"></a>复盘:怎么平滑迁移</h2><p>总结一下,如果重来一遍,我会按这个顺序做:</p><ol><li>先统一别名和路径规范——不依赖构建工具的事先做了。</li><li>把 e2e 里的实现细节依赖去掉(类名选择器换成 data-testid)。</li><li>Vite 配置先跑 dev,再跑 build,最后对比产物。</li><li>环境变量统一改 <code>VITE_</code> 前缀,全局搜一遍。</li><li>灰度上线,先切一个工具到 Vite,稳定后再铺开。</li></ol><p>构建工具迁移的难点不在配置本身,在于”你以为迁好了但某个边角在生产环境才暴露”。dev 能跑不等于没问题,一定要 build + e2e 全过再上线。</p><p>这一篇坑比较多,但每个都是实打实遇到的。构建工具的迁移不像组件迁移能让 AI 大包大揽,这里更多是人对工具链的理解和判断。AI 能帮你写配置,但踩坑的经验得自己攒。</p>]]>
</content>
<id>https://www.robbs.win/2025-07-15/Webpack-to-Vite-Lessons.html</id>
<link href="https://www.robbs.win/2025-07-15/Webpack-to-Vite-Lessons.html"/>
<published>2025-07-15T03:00:00.000Z</published>
<summary>13 个工具从 Vue 2.7 迁到 React 19 的同时,构建工具也从 Webpack 切到了 Vite。这篇复盘踩过的坑:依赖兼容、HMR、别名、构建产物、CSS Modules 等。</summary>
<title>迁移复盘:从 Webpack 切到 Vite 踩过的坑</title>
<updated>2026-07-08T01:06:19.728Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="Claude Code" scheme="https://www.robbs.win/tags/Claude-Code/"/>
<category term="Skill" scheme="https://www.robbs.win/tags/Skill/"/>
<content>
<![CDATA[<p>上篇说到,我用一个 UI 迁移 Agent 把 13 个工具从 Vue 2.7 迁到了 React 19,花了一个半月。</p><p>很多人关心的其实是那句话后面的一句,”后面沉淀出了 10+ 个能复用的 Skill”。</p><p>这篇就展开讲:Skill 到底是什么,怎么写,以及为什么我觉得它是目前 AI 编码里最被低估的东西。</p><h2 id="先说-Skill-是什么"><a href="#先说-Skill-是什么" class="headerlink" title="先说 Skill 是什么"></a>先说 Skill 是什么</h2><p>Claude Code 里的 Skill,简单说就是一段可复用的指令模板。你把一件事情的做法、约束、检查标准写成结构化的 markdown,Claude Code 在合适的场景自动加载它,按你的规矩来干活。</p><p>你可以理解为:Prompt 是一次性的对话,Skill 是沉淀下来的工程规范。</p><p>打个比方:你第一次教一个新人”我们这边 PR 怎么提”,你会在 Slack 上打一大段话;第二次又来一个新人,你又打一遍;打到第三次你就烦了,写进 wiki 对吧?Skill 就是那个 wiki,只不过 AI 会自动在需要的时候去读它,不需要你提醒。</p><h2 id="一个-Skill-长什么样"><a href="#一个-Skill-长什么样" class="headerlink" title="一个 Skill 长什么样"></a>一个 Skill 长什么样</h2><p>拿迁移里最常用的 <code>vue-to-react-mapping</code> 来说(脱敏后):</p><figure class="highlight markdown"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="section"># vue-to-react-mapping</span></span><br><span class="line"></span><br><span class="line"><span class="quote">> 把 Vue 2.7 SFC 的常见写法映射到 React 19,保证 13 个工具迁移风格一致。</span></span><br><span class="line"></span><br><span class="line"><span class="section">## 适用场景</span></span><br><span class="line">当需要把 Vue 单文件组件迁移为 React 函数组件时使用。</span><br><span class="line"></span><br><span class="line"><span class="section">## 模板映射规则</span></span><br><span class="line">| Vue 写法 | React 产出 | 备注 |</span><br><span class="line">|---|---|---|</span><br><span class="line">| <span class="code">`v-if`</span> | <span class="code">`{cond && <Comp />}`</span> | 不用三元,短路更清晰 |</span><br><span class="line">| <span class="code">`v-for`</span> | <span class="code">`arr.map(item => ...)`</span> | key 优先用业务 id |</span><br><span class="line">| <span class="code">`v-model`</span> | <span class="code">`useState + onChange`</span> | 受控组件 |</span><br><span class="line">| <span class="code">`:class`</span> | <span class="code">`clsx(...)`</span> | 不拼字符串 |</span><br><span class="line">| <span class="code">`slot`</span> | <span class="code">`children`</span> / render prop | 具名 slot 用对象传 |</span><br><span class="line"></span><br><span class="line"><span class="section">## Script 映射规则</span></span><br><span class="line"><span class="bullet">-</span> <span class="code">`ref(x)`</span> / <span class="code">`reactive({})`</span> → <span class="code">`useState`</span></span><br><span class="line"><span class="bullet">-</span> <span class="code">`computed(() => ...)`</span> → <span class="code">`useMemo(() => ..., [deps])`</span></span><br><span class="line"><span class="bullet">-</span> <span class="code">`watch(src, cb)`</span> → <span class="code">`useEffect(() => cb(), [deps])`</span></span><br><span class="line"><span class="bullet">-</span> <span class="code">`onMounted`</span> → <span class="code">`useEffect(..., [])`</span></span><br><span class="line"><span class="bullet">-</span> <span class="code">`defineProps`</span> → <span class="code">`interface Props`</span> + 解构 + 默认值</span><br><span class="line"></span><br><span class="line"><span class="section">## 禁止项</span></span><br><span class="line"><span class="bullet">-</span> 禁止产出 Class 组件,一律函数组件</span><br><span class="line"><span class="bullet">-</span> 禁止用 <span class="code">`dangerouslySetInnerHTML`</span> 替代 <span class="code">`v-html`</span>,先标记 TODO</span><br><span class="line"><span class="bullet">-</span> 禁止自创状态管理方案,跨组件状态走 Zustand store</span><br><span class="line"></span><br><span class="line"><span class="section">## 验收标准</span></span><br><span class="line"><span class="bullet">-</span> <span class="code">`tsc --noEmit`</span> 零报错</span><br><span class="line"><span class="bullet">-</span> ESLint 零 error</span><br><span class="line"><span class="bullet">-</span> 主路径 e2e 通过</span><br></pre></td></tr></table></figure><p>你看,它不是一段模糊的 Prompt,而是有适用场景、有规则表、有禁止项、有验收标准的工程文档。AI 拿到这个东西,迁出来的代码才会一致。</p><h2 id="我沉淀了哪些-Skill"><a href="#我沉淀了哪些-Skill" class="headerlink" title="我沉淀了哪些 Skill"></a>我沉淀了哪些 Skill</h2><p>迁移跑完,我数了一下,实际产出的大概是这些(分几类):</p><p>映射类(迁移核心)</p><ul><li><code>vue-to-react-mapping</code> — Vue 语法到 React 的映射规则</li><li><code>element-to-component-lib</code> — Element-Plus 组件到目标 UI 库的对应关系</li><li><code>pinia-to-zustand</code> — 状态管理迁移规则</li></ul><p>规范类(保证一致性)</p><ul><li><code>react-naming-convention</code> — 命名规范</li><li><code>file-structure</code> — 一个组件一个目录的组织方式</li><li><code>css-modules-style</code> — 样式方案规范</li></ul><p>质量类(兜底)</p><ul><li><code>migration-verify</code> — 迁移后的自检清单(类型、lint、测试)</li><li><code>test-coverage-check</code> — 测试覆盖检查</li><li><code>a11y-checklist</code> — 可访问性检查(理财工具对 a11y 有要求)</li></ul><p>流程类(团队协作)</p><ul><li><code>migration-handoff</code> — 迁移完一个工具后的交接检查</li><li><code>code-review-checklist</code> — 迁移相关 PR 的 review 要点</li></ul><p>加起来 10+ 个,每个都不长,但每个都在解决一个”如果不写下来就会重复出问题”的点。</p><h2 id="为什么是-Zustand-不是-Redux"><a href="#为什么是-Zustand-不是-Redux" class="headerlink" title="为什么是 Zustand 不是 Redux"></a>为什么是 Zustand 不是 Redux</h2><p>顺便回答上篇留的坑。状态管理我最后选了 Zustand,原因很实在:</p><ul><li>迁移成本最低:Pinia 的 <code>defineStore</code> 到 Zustand 的 <code>create</code>,心智模型几乎一对一。Agent 迁起来很顺,几乎不需要人介入。</li><li>没有 boilerplate:Redux Toolkit 已经够精简了,但 Zustand 更轻。对于一个计算器工具来说,我不想为了状态管理写一堆 slice、reducer。</li><li>TypeScript 友好:类型推导自然,不用额外写类型模板。</li></ul><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Pinia(迁移前)</span></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> useCalcStore = <span class="title function_">defineStore</span>(<span class="string">'calc'</span>, <span class="function">() =></span> {</span><br><span class="line"> <span class="keyword">const</span> amount = <span class="title function_">ref</span>(<span class="number">0</span>)</span><br><span class="line"> <span class="keyword">const</span> <span class="title function_">setAmount</span> = (<span class="params"><span class="attr">v</span>: <span class="built_in">number</span></span>) => { amount.<span class="property">value</span> = v }</span><br><span class="line"> <span class="keyword">return</span> { amount, setAmount }</span><br><span class="line">})</span><br><span class="line"></span><br><span class="line"><span class="comment">// Zustand(迁移后)</span></span><br><span class="line"><span class="keyword">interface</span> <span class="title class_">CalcState</span> {</span><br><span class="line"> <span class="attr">amount</span>: <span class="built_in">number</span></span><br><span class="line"> <span class="attr">setAmount</span>: <span class="function">(<span class="params"><span class="attr">v</span>: <span class="built_in">number</span></span>) =></span> <span class="built_in">void</span></span><br><span class="line">}</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> useCalcStore = create<<span class="title class_">CalcState</span>>(<span class="function">(<span class="params">set</span>) =></span> ({</span><br><span class="line"> <span class="attr">amount</span>: <span class="number">0</span>,</span><br><span class="line"> <span class="attr">setAmount</span>: <span class="function">(<span class="params">v</span>) =></span> <span class="title function_">set</span>({ <span class="attr">amount</span>: v }),</span><br><span class="line">}))</span><br></pre></td></tr></table></figure><p>你看这个映射多直接。Agent 第一轮就能转对,不用反复调。</p><p>选技术栈的判断标准不是”哪个更好”,而是哪个在当前场景下 AI 迁起来最不容易出错。</p><h2 id="Skill-为什么被低估"><a href="#Skill-为什么被低估" class="headerlink" title="Skill 为什么被低估"></a>Skill 为什么被低估</h2><p>我觉得大家用 AI 编码,目前卡在一个误区里:太关注 Prompt,不关注 Skill。</p><p>Prompt 是你和 AI 的一次对话,聊完就没了。Skill 是你把这次对话里值得沉淀的部分固化下来,下次自动复用。</p><p>打个比方:Prompt 是你给新人讲了一遍怎么做,Skill 是你把这个做法写进了团队规范。前者每次都要重来,后者只需要讲一次。</p><p>迁移这件事让我确认了一个判断:AI 编码的上限由 Prompt 决定,但 AI 编码的下限和一致性由 Skill 决定。13 个工具能并行跑出一致的结果,靠的不是 Prompt 写得多好,是 Skill 把规范钉死了。</p><p>如果你团队在用 Claude Code,我强烈建议把那些重复出现的规范、约束、检查标准都抽成 Skill。前期花点时间写,后面每次都用上,这笔账非常划算。</p><p>下一篇讲 Webpack 切到 Vite 的那些坑,构建工具的迁移比组件迁移坑多了。</p>]]>
</content>
<id>https://www.robbs.win/2025-06-17/Reusable-Migration-Skills.html</id>
<link href="https://www.robbs.win/2025-06-17/Reusable-Migration-Skills.html"/>
<published>2025-06-17T06:00:00.000Z</published>
<summary>13 个工具迁完后,我把迁移过程中反复用到的规则抽成了 10+ 个 Claude Code Skill。这篇讲 Skill 到底是什么、怎么写、为什么我觉得它是最被低估的 AI 编码工具。</summary>
<title>迁移 Skill:沉淀出 10+ 个能复用的东西</title>
<updated>2026-07-08T01:06:19.694Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="Claude Code" scheme="https://www.robbs.win/tags/Claude-Code/"/>
<category term="Agent" scheme="https://www.robbs.win/tags/Agent/"/>
<content>
<![CDATA[<p>理财线有 13 个工具,全是 Vue 2.7 的,要整体迁到 React 19。</p><p>按传统手迁的路子,一个工具按 3 天算,串下来快两个月;3 个人手迁,算上踩坑和返工,三个月起步。</p><p>我最后花了大概一个半月,而且不是一个人闷头干,是 3 个人同时铺开 13 个工具。</p><p>怎么做到的?核心就一件事:我没有去迁代码,我做了一个迁代码的 Agent。</p><h2 id="先说结论"><a href="#先说结论" class="headerlink" title="先说结论"></a>先说结论</h2><p>不要让 AI 帮你迁代码,让 AI 去当一个”迁移工程师”。</p><p>这两件事听起来像,其实差很远。前者是你坐在那里,一个文件一个文件喂给 Claude,让它给你翻成 React;后者是你设计一套工作流,让 Agent 自己扫描、自己决策、自己产出、自己验证,你只负责处理它搞不定的边角。</p><h2 id="为什么是-13-个一起迁"><a href="#为什么是-13-个一起迁" class="headerlink" title="为什么是 13 个一起迁"></a>为什么是 13 个一起迁</h2><p>理财线的工具长这样:某理财计算器、某贷款计算器、某再融资工具、某购房能力评估、某债务合并工具……十几个,业务上互相独立,但技术栈完全一样:Vue 2.7 + Pinia + Element-Plus + Webpack。</p><p>这其实是个非常适合并行的场景。如果迁移规则能沉淀成一份”规范”,那 13 个工具就是 13 次同一套规则的应用,不是 13 个全新的问题。</p><p>所以我把它们当成 1 个迁移规则 + 13 次执行,而不是 13 个独立的迁移任务。这个视角的转换很重要。</p><h2 id="Agent-怎么设计的"><a href="#Agent-怎么设计的" class="headerlink" title="Agent 怎么设计的"></a>Agent 怎么设计的</h2><p>我用 Claude Code 做了一个 UI 迁移 Agent,工作流分四步:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐</span><br><span class="line">│ 1. SCAN │──▶│ 2. MAP │──▶│ 3. GENERATE │──▶│ 4. VERIFY │</span><br><span class="line">│ 扫 Vue SFC │ │ 规则映射 │ │ 产出 React │ │ lint+类型 │</span><br><span class="line">└─────────────┘ └─────────────┘ └──────────────┘ └─────────────┘</span><br><span class="line"> │</span><br><span class="line"> 不通过 ▼</span><br><span class="line"> 回 GENERATE 重跑</span><br></pre></td></tr></table></figure><p><strong>第一步 SCAN</strong>:让 Agent 读 Vue 单文件组件,把 <code><template></code>、<code><script setup></code>、<code><style></code> 三段拆出来,同时把 props、emits、computed、refs 这些元信息结构化,输出一个 JSON 描述。这步是为了后面映射有”上下文”,不是裸字符串替换。</p><p><strong>第二步 MAP</strong>:整个 Agent 的核心。我写了一份映射规则(后面抽成了 Skill,下篇细讲),规则长这样:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># vue-to-react-mapping(节选,脱敏)</span></span><br><span class="line"><span class="attr">template:</span></span><br><span class="line"> <span class="attr">v-if:</span> <span class="string">"→ 条件渲染 {cond && <Comp />}"</span></span><br><span class="line"> <span class="attr">v-for:</span> <span class="string">"→ Array.map,key 优先用业务 id"</span></span><br><span class="line"> <span class="attr">v-model:</span> <span class="string">"→ useState + onChange 双向绑定"</span></span><br><span class="line"> <span class="attr">@click:</span> <span class="string">"→ onClick"</span></span><br><span class="line"> <span class="string">:class:</span> <span class="string">"→ clsx() 拼接"</span></span><br><span class="line"> <span class="attr">slot:</span> <span class="string">"→ children / render prop"</span></span><br><span class="line"></span><br><span class="line"><span class="attr">script:</span></span><br><span class="line"> <span class="attr">ref / reactive:</span> <span class="string">"→ useState"</span></span><br><span class="line"> <span class="attr">computed:</span> <span class="string">"→ useMemo"</span></span><br><span class="line"> <span class="attr">watch:</span> <span class="string">"→ useEffect(带依赖)"</span></span><br><span class="line"> <span class="attr">onMounted:</span> <span class="string">"→ useEffect(() => {}, [])"</span></span><br><span class="line"> <span class="attr">defineProps:</span> <span class="string">"→ interface Props + 解构默认值"</span></span><br><span class="line"> <span class="attr">defineEmits:</span> <span class="string">"→ 回调 props(onXxx)"</span></span><br><span class="line"></span><br><span class="line"><span class="attr">style:</span></span><br><span class="line"> <span class="attr">scoped css:</span> <span class="string">"→ CSS Modules(*.module.css)"</span></span><br></pre></td></tr></table></figure><p><strong>第三步 GENERATE</strong>:Agent 按映射规则产出 React 19 的 <code>.tsx</code> + <code>.module.css</code>,状态管理统一走 Zustand(为什么是 Zustand 下篇讲,简单说就是迁移成本最低、心智模型最接近 Composition API)。</p><p><strong>第四步 VERIFY</strong>:自动跑 ESLint + <code>tsc</code> 类型检查 + 关键路径的 e2e 冒烟。挂了就回退到 GENERATE 让 Agent 自己修,尽量不让人介入。这一步把”人盯着 AI”变成了”AI 盯着 AI”。</p><h2 id="关键设计:规则和执行分离"><a href="#关键设计:规则和执行分离" class="headerlink" title="关键设计:规则和执行分离"></a>关键设计:规则和执行分离</h2><p>这里有个我认为很关键的判断:</p><p>迁移规则是人定的,迁移执行是 AI 干的,千万别混在一起。</p><p>如果你让 Agent “看着办”,把一个 Vue 文件丢给他说”转成 React”,他会给你一个能跑但风格全不一样的结果。13 个工具 13 种写法,后面维护就是灾难。</p><p>我的做法是先把规则钉死,再让 Agent 在规则的框架里干活。规则覆盖了:</p><ul><li>命名规范(组件 PascalCase、hooks 用 <code>use</code> 前缀、工具函数 camelCase)</li><li>文件组织(一个组件一个目录,<code>index.tsx</code> + <code>*.module.css</code> + <code>types.ts</code>)</li><li>状态边界(跨组件走 Zustand store,组件内状态走 useState,不混用)</li><li>样式方案(统一 CSS Modules,不用 styled-components)</li><li>测试要求(每个工具至少覆盖主路径的 e2e,迁移后跑通才算完)</li></ul><p>Agent 只能在这个框架里发挥,不能自己发明规范。</p><h2 id="怎么并行-13-个工具"><a href="#怎么并行-13-个工具" class="headerlink" title="怎么并行 13 个工具"></a>怎么并行 13 个工具</h2><p>Claude Code 支持 Subagent(子代理)模式,可以把一个大任务拆成多个独立的子任务并行跑。我的编排大概是:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 简化示意,实际走 Claude Code 的 Task 编排</span></span><br><span class="line">tools=(wealth-calc-1 wealth-calc-2 wealth-calc-3 wealth-calc-4 \</span><br><span class="line"> wealth-calc-5 wealth-calc-6 wealth-calc-7 wealth-calc-8 ...)</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> tool <span class="keyword">in</span> <span class="string">"<span class="variable">${tools[@]}</span>"</span>; <span class="keyword">do</span></span><br><span class="line"> <span class="comment"># 每个工具一个独立 Agent,带同一份迁移规则</span></span><br><span class="line"> claude run migration-agent --target <span class="string">"<span class="variable">$tool</span>"</span> --rules ./skills/ &</span><br><span class="line"><span class="keyword">done</span></span><br><span class="line"><span class="built_in">wait</span></span><br><span class="line"><span class="built_in">echo</span> <span class="string">"全部跑完,开始人工兜底"</span></span><br></pre></td></tr></table></figure><p>每个工具起一个独立的 Agent 实例,跑完整的 SCAN → MAP → GENERATE → VERIFY 流程。13 个工具同时转,机器在跑,人去看它搞不定的报错。</p><p>实际跑下来,大部分工具第一轮就能跑到 VERIFY 通过。少数卡在复杂计算属性或者深度依赖 Element-Plus 组件的地方(比如某些带复杂 slot 的表格组件),需要人工介入。但介入量比纯手迁少太多了。</p><h2 id="一个半月到底花在哪了"><a href="#一个半月到底花在哪了" class="headerlink" title="一个半月到底花在哪了"></a>一个半月到底花在哪了</h2><p>坦白讲,一个半月里 Agent 真正跑的时间可能就两周。剩下的时间花在:</p><ul><li>写规则(最耗时):前两周几乎全在写和调映射规则,跑了三四个试点工具反复打磨,直到规则稳定。</li><li>人工兜底:有些 Vue 里的奇技淫巧(<code>$attrs</code> 透传、动态组件、render 函数、<code>v-html</code>)Agent 转不好,要手改。</li><li>e2e 补测:迁完不是结束,要保证业务逻辑没回归,理财工具的数据口径不能错,验证花了不少时间。</li><li>联调和灰度:13 个工具一起上线,联调成本不低,灰度也要一步步来。</li></ul><h2 id="最值的一个判断"><a href="#最值的一个判断" class="headerlink" title="最值的一个判断"></a>最值的一个判断</h2><p>回头看,最值的判断不是用了 AI,而是”先建规则再开工”。</p><p>如果一上来就开干,一个文件一个文件地迁,AI 会越用越乱:每个文件的迁移结果都不一样,后面接手的人完全看不懂。先花两周把规则和 Skill 沉淀好,后面 13 个工具几乎是”复制粘贴”的体验。</p><p>AI 迁移的瓶颈从来不是 AI 的能力,而是你有没有把规则讲清楚。</p><p>规则讲清楚了,13 个工具并行就是水到渠成的事。规则没讲清楚,迁 1 个工具都是赌。</p><p>下一篇我会展开讲那 10 多个迁移 Skill 是怎么写的,以及为什么我觉得 Skill 是目前 AI 编码里最被低估的东西。</p>]]>
</content>
<id>https://www.robbs.win/2025-05-14/AI-Migration-Agent.html</id>
<link href="https://www.robbs.win/2025-05-14/AI-Migration-Agent.html"/>
<published>2025-05-14T02:00:00.000Z</published>
<summary>把理财线 13 个 Vue 2.7 工具整体迁到 React 19,我没有手迁,而是用 Claude Code 做了一个 UI 迁移 Agent,让 13 个工具并行跑完,一个半月交付。</summary>
<title>UI 迁移 Agent:用 Claude Code 把 13 个工具一起迁了</title>
<updated>2026-07-08T01:06:19.663Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="Vue" scheme="https://www.robbs.win/tags/Vue/"/>
<category term="React 19" scheme="https://www.robbs.win/tags/React-19/"/>
<category term="技术选型" scheme="https://www.robbs.win/tags/%E6%8A%80%E6%9C%AF%E9%80%89%E5%9E%8B/"/>
<content>
<![CDATA[<p>这一篇可能会得罪一些 Vue 党。但我得说清楚:这不是”React 比 Vue 好”的文章。这是一个具体的业务场景,在 2025 年 4 月这个时间点,做出的一次有立场的选型。</p><p>如果你看完不同意,欢迎来吵。</p><h2 id="先说背景"><a href="#先说背景" class="headerlink" title="先说背景"></a>先说背景</h2><p>到 2025 年初,我们的状态是这样的:</p><ul><li>前端 13 个工具,全栈 Vue 2.7 + Webpack</li><li>组件库 15+ 组件,基于 Vue 2.7</li><li>pnpm monorepo + Turbo 并行构建,跑得很顺</li><li>测试覆盖率核心模块 93%</li></ul><p>一切看起来挺好。但有两个阴影一直在变大。</p><h2 id="Vue-2-7-的倒计时"><a href="#Vue-2-7-的倒计时" class="headerlink" title="Vue 2.7 的倒计时"></a>Vue 2.7 的倒计时</h2><p>Vue 2.7 是 Vue 2.x 的最后一个 minor 版本,2022 年 7 月发布。它的意义是给 Vue 2 项目一个”升级到 Composition API”的过渡通道。但它本身就有 EOL(End of Life)——2023 年 12 月 31 日,Vue 2 全线停止维护。</p><p>到 2025 年,我们跑的是一个已经停止维护一年多的框架。</p><p>这意味着什么?</p><ul><li>安全漏洞没人修。CVE 来了,你得自己 fork 一个分支打补丁。</li><li>生态在退场。<code>vue-router@3</code>、<code>vuex@3</code> 这些配套库也不再维护了。新版本的 Vue Router、Pinia 都是面向 Vue 3 的。</li><li>招人越来越难。2025 年的市场上,简历上写”精通 Vue 2”的候选人越来越少。年轻前端工程师学的是 React 或 Vue 3,没人主动去学一个 EOL 框架。</li></ul><p>有人会说:那升 Vue 3 不就行了?</p><p>是的,Vue 3 是一个选项。但我们认真评估之后,觉得 React 19 更适合我们的场景。下面讲为什么。</p><h2 id="升-Vue-3-vs-迁-React-19"><a href="#升-Vue-3-vs-迁-React-19" class="headerlink" title="升 Vue 3 vs 迁 React 19"></a>升 Vue 3 vs 迁 React 19</h2><p>这是当时摆在桌面上的两个方案。我逐条对比。</p><h3 id="生态:React-在金融工具场景的积累更厚"><a href="#生态:React-在金融工具场景的积累更厚" class="headerlink" title="生态:React 在金融工具场景的积累更厚"></a>生态:React 在金融工具场景的积累更厚</h3><p>我们的工具不是内容站,是重交互的数据工具——表格、表单、图表、拖拽、实时计算。这类场景下 React 生态的优势是实打实的:</p><ul><li>ag-Grid / TanStack Table:金融报表的事实标准,React 版本的功能领先 Vue 版本一个身位。</li><li>TanStack Query:服务端状态管理,和我们的 Spring Boot API 配合极好。</li><li>Recharts / Nivo:数据可视化生态比 Vue 那边丰富太多。</li><li>react-hook-form:复杂表单的方案,Vue 那边 <code>vee-validate</code> 能用但生态差距明显。</li></ul><p>选框架不是选语言,是选生态。生态决定了你能不能站在巨人的肩膀上。</p><p>Vue 3 也有生态,但坦率说,在”金融数据工具”这个细分领域,React 的生态厚度是 Vue 比不了的。这不是主观偏好,是客观差距。</p><h3 id="类型系统:TS-React-的体验更好"><a href="#类型系统:TS-React-的体验更好" class="headerlink" title="类型系统:TS + React 的体验更好"></a>类型系统:TS + React 的体验更好</h3><p>Vue 2.7 的 TypeScript 支持是”能跑”但不”舒服”。<code>defineComponent</code> 的类型推导经常在复杂场景下断链,template 里的类型检查基本靠 VS Code 插件硬撑。</p><p>Vue 3 在类型上进步很大,但 React + TypeScript 是从底层设计的类型优先:</p><figure class="highlight tsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// React 里一个 props 类型的定义,干净利落</span></span><br><span class="line"><span class="keyword">interface</span> <span class="title class_">AmountInputProps</span> {</span><br><span class="line"> <span class="attr">value</span>: <span class="built_in">number</span>;</span><br><span class="line"> <span class="attr">onChange</span>: <span class="function">(<span class="params"><span class="attr">value</span>: <span class="built_in">number</span></span>) =></span> <span class="built_in">void</span>;</span><br><span class="line"> <span class="attr">max</span>?: <span class="built_in">number</span>;</span><br><span class="line"> <span class="attr">currency</span>?: <span class="string">'CNY'</span> | <span class="string">'USD'</span>;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">AmountInput</span>(<span class="params">{ value, onChange, max, currency = <span class="string">'CNY'</span> }: <span class="title class_">AmountInputProps</span></span>) {</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>Vue 那边要达到同样的类型体验,需要 <code>defineProps</code> + 泛型 + macros 的组合,心智负担更重。</p><h3 id="React-19-的吸引力"><a href="#React-19-的吸引力" class="headerlink" title="React 19 的吸引力"></a>React 19 的吸引力</h3><p>React 19 在 2024 年 12 月正式发布。到 2025 年 4 月我们做决策时,它已经稳定了 4 个月,社区反馈很充分。几个点让我特别心动:</p><p>Server Components:虽然我们暂时用不上 RSC,但 React 19 的架构方向让”按需加载”变成了框架级别的能力。</p><p>Actions 和 <code>useFormStatus</code>:表单提交的状态管理终于不用自己造轮子了。金融工具里有大量表单,这个特性直接干掉了一大堆样板代码。</p><p><code>use()</code> Hook:异步数据的消费变得更直观。配合 Suspense,加载状态的处理干净了一大截。</p><p>编译器(React Compiler):虽然还在实验阶段,但它的方向是”让你不用再操心 <code>useMemo</code> 和 <code>useCallback</code>“。这对大型应用的性能优化是革命性的。</p><p>React 19 不是小修小补的版本,是 React 团队对过去五年社区痛点的集中回应。</p><h3 id="团队因素"><a href="#团队因素" class="headerlink" title="团队因素"></a>团队因素</h3><p>最后一条,也是最现实的一条:小团队更要借力招人市场。</p><p>这不是纯技术决策,也是人的决策。我们是个 3 人小组(含前端和测试),后续还要继续招人。迁到 React 19 之后,onboarding 新人的成本更低——市场上 React 工程师的数量是 Vue 的好几倍。</p><p>Vue 3 那条路意味着整个团队要重新学习 Composition API 的最佳实践、Pinia 的模式、新的 reactivity 心智模型。对一个需要持续扩招的小团队来说,React 这条路的招人面更宽。</p><h2 id="我的判断"><a href="#我的判断" class="headerlink" title="我的判断"></a>我的判断</h2><p>把上面的对比浓缩成一句话:在 2025 年 4 月这个时间点,对一个重交互的金融工具产品线,迁 React 19 是比升 Vue 3 更合理的选择。</p><p>我承认 Vue 3 是一个好框架。如果这是一个内容型网站、营销页面、或者中小型后台,Vue 3 完全够用甚至更舒服。但我们的场景——13 个重交互的数据工具、金融级别的类型严谨性要求、小团队需要更宽的招人市场——每一项都指向 React。</p><p>选型不是选”最好的框架”,是选”最适合当前场景的框架”。如果有人拿这篇文章去证明”React 比 Vue 好”,那是误读。我讲的是一个具体的业务决策,不是一个普适结论。</p><h2 id="迁移策略"><a href="#迁移策略" class="headerlink" title="迁移策略"></a>迁移策略</h2><p>决策做了,怎么迁是下一个问题。13 个工具不可能一夜之间全换。我们的策略是:</p><ol><li>新工具直接用 React 19 写——不再用 Vue 开新工具。</li><li>老工具按流量优先级逐步迁移——高频使用的先迁。</li><li>组件库双轨——Vue 版继续维护,React 版同步开发。两个版本共享设计 token 和样式。</li><li>微前端作为过渡——React 工具和 Vue 工具在同一个壳子里通过 module federation 共存。</li></ol><p>这是决策时排的计划,照这个节奏走大概要 6-9 个月。但实际开干的时候,我用 Claude Code 做了个 UI 迁移 Agent,把 13 个工具并行着一起迁了,最后只花了一个半月——这是下一篇要讲的事。</p><h2 id="给同样在纠结的人"><a href="#给同样在纠结的人" class="headerlink" title="给同样在纠结的人"></a>给同样在纠结的人</h2><p>如果你也在 Vue 和 React 之间纠结,我的建议是:</p><ul><li>先看场景:内容型用 Vue,交互型用 React。</li><li>再看团队:团队熟什么就用什么,别逆着来。</li><li>最后看生态:你的场景需要哪些库,去查这些库在两个框架下的成熟度。</li></ul><p>框架之争没有标准答案,但你的业务场景有。</p><p>这一篇是工具线前半段的结束,也是下半段的开始。下一篇讲我怎么用 Claude Code 做了个 UI 迁移 Agent,把计划的 6-9 个月压到一个半月。</p>]]>
</content>
<id>https://www.robbs.win/2025-04-15/Why-Migrate-React-19.html</id>
<link href="https://www.robbs.win/2025-04-15/Why-Migrate-React-19.html"/>
<published>2025-04-15T06:00:00.000Z</published>
<summary>Vue 2.7 到 EOL 了,React 19 刚发布。这篇文章不讲"哪个框架更好",讲的是一个具体的金融工具产品线,在 2025 年 4 月这个时间点,为什么决定迁移。</summary>
<title>为什么决定把 Vue 迁到 React 19</title>
<updated>2026-07-08T01:06:19.630Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="Vitest" scheme="https://www.robbs.win/tags/Vitest/"/>
<category term="Playwright" scheme="https://www.robbs.win/tags/Playwright/"/>
<category term="ESLint" scheme="https://www.robbs.win/tags/ESLint/"/>
<category term="测试覆盖率" scheme="https://www.robbs.win/tags/%E6%B5%8B%E8%AF%95%E8%A6%86%E7%9B%96%E7%8E%87/"/>
<content>
<![CDATA[<p>工具线跑到第 8 个月的时候,有个数字让我坐不住了:核心模块的单元测试覆盖率只有 47%。</p><p>13 个金融计算工具,覆盖率不到一半。换句话说,超过一半的代码改了之后根本不知道有没有把别的东西改坏。这在金融场景里是定时炸弹——你不可能靠人工 review 保证金融计算的正确性。</p><p>于是我们花了三个月建了一套质量基线:Vitest 跑单测、Playwright 跑 e2e、ESLint 守代码风格。到年底,核心模块覆盖率拉到 93%。这篇讲怎么做的。</p><h2 id="为什么是-Vitest,不是-Jest"><a href="#为什么是-Vitest,不是-Jest" class="headerlink" title="为什么是 Vitest,不是 Jest"></a>为什么是 Vitest,不是 Jest</h2><p>Vue 2.7 + Webpack + TypeScript 这套技术栈,选 Jest 还是 Vitest,我纠结过一阵。最后选 Vitest 的原因:</p><ul><li>和 Vite 生态零配置:我们的组件库和新工具都在用 Vite,Vitest 直接复用 Vite 的配置,不用单独维护一份 jest.config。</li><li>速度:Vitest 默认用 esbuild 做转译,比 Jest 的 babel 快一大截。我们的测试套件从 Jest 迁过来后,跑完全量从 90 秒降到 35 秒。</li><li>API 几乎兼容 Jest:<code>describe</code>、<code>it</code>、<code>expect</code>、<code>vi.mock</code> 这些写法迁移成本极低。</li></ul><p>如果你已经在用 Vite,选 Vitest 几乎是唯一合理的答案。</p><h2 id="Vitest-配置"><a href="#Vitest-配置" class="headerlink" title="Vitest 配置"></a>Vitest 配置</h2><p>根目录的 <code>vitest.config.ts</code>,workspace 级别统一管理:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { defineConfig } <span class="keyword">from</span> <span class="string">'vitest/config'</span>;</span><br><span class="line"><span class="keyword">import</span> vue <span class="keyword">from</span> <span class="string">'@vitejs/plugin-vue'</span>;</span><br><span class="line"><span class="keyword">import</span> { resolve } <span class="keyword">from</span> <span class="string">'path'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title function_">defineConfig</span>({</span><br><span class="line"> <span class="attr">plugins</span>: [<span class="title function_">vue</span>()],</span><br><span class="line"> <span class="attr">resolve</span>: {</span><br><span class="line"> <span class="attr">alias</span>: {</span><br><span class="line"> <span class="string">'@'</span>: <span class="title function_">resolve</span>(__dirname, <span class="string">'src'</span>),</span><br><span class="line"> },</span><br><span class="line"> },</span><br><span class="line"> <span class="attr">test</span>: {</span><br><span class="line"> <span class="attr">globals</span>: <span class="literal">true</span>,</span><br><span class="line"> <span class="attr">environment</span>: <span class="string">'jsdom'</span>,</span><br><span class="line"> <span class="attr">coverage</span>: {</span><br><span class="line"> <span class="attr">provider</span>: <span class="string">'v8'</span>, <span class="comment">// 用 v8 比 istanbul 快很多</span></span><br><span class="line"> <span class="attr">reporter</span>: [<span class="string">'text'</span>, <span class="string">'lcov'</span>, <span class="string">'html'</span>],</span><br><span class="line"> <span class="attr">reportsDirectory</span>: <span class="string">'./coverage'</span>,</span><br><span class="line"> <span class="comment">// 这就是覆盖率门槛——核心模块 90%</span></span><br><span class="line"> <span class="attr">thresholds</span>: {</span><br><span class="line"> <span class="attr">statements</span>: <span class="number">90</span>,</span><br><span class="line"> <span class="attr">branches</span>: <span class="number">85</span>,</span><br><span class="line"> <span class="attr">functions</span>: <span class="number">90</span>,</span><br><span class="line"> <span class="attr">lines</span>: <span class="number">90</span>,</span><br><span class="line"> },</span><br><span class="line"> <span class="comment">// 只统计 src/ 下的代码,不算测试文件和配置</span></span><br><span class="line"> <span class="attr">include</span>: [<span class="string">'src/**/*.{ts,vue,tsx}'</span>],</span><br><span class="line"> <span class="attr">exclude</span>: [<span class="string">'src/**/*.d.ts'</span>, <span class="string">'src/**/__mocks__/**'</span>],</span><br><span class="line"> },</span><br><span class="line"> <span class="attr">setupFiles</span>: [<span class="string">'./test/setup.ts'</span>],</span><br><span class="line"> },</span><br><span class="line">});</span><br></pre></td></tr></table></figure><p><code>thresholds</code> 这块是关键——它不是建议,是硬性门槛。覆盖率低于这个数字,<code>vitest --coverage</code> 直接退出码非零,CI 会挂掉。</p><p>覆盖率门槛不是”尽量达到”,是”达不到就别想合并”。</p><h2 id="单元测试示例:金融组件"><a href="#单元测试示例:金融组件" class="headerlink" title="单元测试示例:金融组件"></a>单元测试示例:金融组件</h2><p>拿金额输入框 <code>AmountInput</code> 举例。这个组件看似简单,但金融场景下有一堆边界情况要覆盖:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// src/components/AmountInput/AmountInput.test.ts</span></span><br><span class="line"><span class="keyword">import</span> { mount } <span class="keyword">from</span> <span class="string">'@vue/test-utils'</span>;</span><br><span class="line"><span class="keyword">import</span> { describe, it, expect } <span class="keyword">from</span> <span class="string">'vitest'</span>;</span><br><span class="line"><span class="keyword">import</span> <span class="title class_">AmountInput</span> <span class="keyword">from</span> <span class="string">'./AmountInput.vue'</span>;</span><br><span class="line"></span><br><span class="line"><span class="title function_">describe</span>(<span class="string">'AmountInput'</span>, <span class="function">() =></span> {</span><br><span class="line"> <span class="title function_">it</span>(<span class="string">'格式化输入为千分位'</span>, <span class="title function_">async</span> () => {</span><br><span class="line"> <span class="keyword">const</span> wrapper = <span class="title function_">mount</span>(<span class="title class_">AmountInput</span>);</span><br><span class="line"> <span class="keyword">await</span> wrapper.<span class="title function_">find</span>(<span class="string">'input'</span>).<span class="title function_">setValue</span>(<span class="string">'1234567.89'</span>);</span><br><span class="line"> <span class="title function_">expect</span>(wrapper.<span class="title function_">find</span>(<span class="string">'input'</span>).<span class="property">element</span>.<span class="property">value</span>).<span class="title function_">toBe</span>(<span class="string">'1,234,567.89'</span>);</span><br><span class="line"> });</span><br><span class="line"></span><br><span class="line"> <span class="title function_">it</span>(<span class="string">'拒绝非数字输入'</span>, <span class="title function_">async</span> () => {</span><br><span class="line"> <span class="keyword">const</span> wrapper = <span class="title function_">mount</span>(<span class="title class_">AmountInput</span>);</span><br><span class="line"> <span class="keyword">await</span> wrapper.<span class="title function_">find</span>(<span class="string">'input'</span>).<span class="title function_">setValue</span>(<span class="string">'abc'</span>);</span><br><span class="line"> <span class="title function_">expect</span>(wrapper.<span class="title function_">emitted</span>(<span class="string">'update:modelValue'</span>)?.[<span class="number">0</span>]).<span class="title function_">toEqual</span>([<span class="string">''</span>]);</span><br><span class="line"> });</span><br><span class="line"></span><br><span class="line"> <span class="title function_">it</span>(<span class="string">'负数金额标红'</span>, <span class="title function_">async</span> () => {</span><br><span class="line"> <span class="keyword">const</span> wrapper = <span class="title function_">mount</span>(<span class="title class_">AmountInput</span>);</span><br><span class="line"> <span class="keyword">await</span> wrapper.<span class="title function_">find</span>(<span class="string">'input'</span>).<span class="title function_">setValue</span>(<span class="string">'-500'</span>);</span><br><span class="line"> <span class="title function_">expect</span>(wrapper.<span class="title function_">classes</span>()).<span class="title function_">toContain</span>(<span class="string">'amount-input--negative'</span>);</span><br><span class="line"> });</span><br><span class="line"></span><br><span class="line"> <span class="title function_">it</span>(<span class="string">'超过最大值时触发 error 事件'</span>, <span class="title function_">async</span> () => {</span><br><span class="line"> <span class="keyword">const</span> wrapper = <span class="title function_">mount</span>(<span class="title class_">AmountInput</span>, {</span><br><span class="line"> <span class="attr">props</span>: { <span class="attr">max</span>: <span class="number">1000000</span> },</span><br><span class="line"> });</span><br><span class="line"> <span class="keyword">await</span> wrapper.<span class="title function_">find</span>(<span class="string">'input'</span>).<span class="title function_">setValue</span>(<span class="string">'2000000'</span>);</span><br><span class="line"> <span class="title function_">expect</span>(wrapper.<span class="title function_">emitted</span>(<span class="string">'error'</span>)).<span class="title function_">toBeTruthy</span>();</span><br><span class="line"> });</span><br><span class="line">});</span><br></pre></td></tr></table></figure><p>注意这些测试用例不是拍脑袋想的——每一个都对应一个真实的金融业务约束。千分位格式化、负数标红、上限校验,这些逻辑如果在某个工具里坏了,客户看到的数字就是错的。</p><h2 id="Playwright:端到端测试"><a href="#Playwright:端到端测试" class="headerlink" title="Playwright:端到端测试"></a>Playwright:端到端测试</h2><p>单测覆盖了组件级别的逻辑,但 13 个工具端到端的流程(用户登录 → 选客户 → 填表 → 点计算 → 看报表)需要 e2e 测试来守。</p><p>Playwright 的配置放在仓库根目录:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// playwright.config.ts</span></span><br><span class="line"><span class="keyword">import</span> { defineConfig, devices } <span class="keyword">from</span> <span class="string">'@playwright/test'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title function_">defineConfig</span>({</span><br><span class="line"> <span class="attr">testDir</span>: <span class="string">'./e2e'</span>,</span><br><span class="line"> <span class="attr">fullyParallel</span>: <span class="literal">true</span>,</span><br><span class="line"> <span class="attr">retries</span>: process.<span class="property">env</span>.<span class="property">CI</span> ? <span class="number">2</span> : <span class="number">0</span>,</span><br><span class="line"> <span class="attr">reporter</span>: process.<span class="property">env</span>.<span class="property">CI</span> ? [[<span class="string">'github'</span>], [<span class="string">'html'</span>]] : <span class="string">'list'</span>,</span><br><span class="line"> <span class="attr">use</span>: {</span><br><span class="line"> <span class="attr">baseURL</span>: <span class="string">'http://localhost:5173'</span>,</span><br><span class="line"> <span class="attr">trace</span>: <span class="string">'on-first-retry'</span>,</span><br><span class="line"> <span class="attr">screenshot</span>: <span class="string">'only-on-failure'</span>,</span><br><span class="line"> },</span><br><span class="line"> <span class="attr">projects</span>: [</span><br><span class="line"> { <span class="attr">name</span>: <span class="string">'chromium'</span>, <span class="attr">use</span>: { ...devices[<span class="string">'Desktop Chrome'</span>] } },</span><br><span class="line"> { <span class="attr">name</span>: <span class="string">'webkit'</span>, <span class="attr">use</span>: { ...devices[<span class="string">'Desktop Safari'</span>] } },</span><br><span class="line"> ],</span><br><span class="line"> <span class="attr">webServer</span>: {</span><br><span class="line"> <span class="attr">command</span>: <span class="string">'pnpm dev'</span>,</span><br><span class="line"> <span class="attr">url</span>: <span class="string">'http://localhost:5173'</span>,</span><br><span class="line"> <span class="attr">reuseExistingServer</span>: !process.<span class="property">env</span>.<span class="property">CI</span>,</span><br><span class="line"> },</span><br><span class="line">});</span><br></pre></td></tr></table></figure><p>一个典型的 e2e 测试——理财计算工具的完整流程:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// e2e/wealth-calc.spec.ts</span></span><br><span class="line"><span class="keyword">import</span> { test, expect } <span class="keyword">from</span> <span class="string">'@playwright/test'</span>;</span><br><span class="line"></span><br><span class="line"><span class="title function_">test</span>(<span class="string">'理财计算完整流程'</span>, <span class="title function_">async</span> ({ page }) => {</span><br><span class="line"> <span class="keyword">await</span> page.<span class="title function_">goto</span>(<span class="string">'/wealth-calc'</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 选客户</span></span><br><span class="line"> <span class="keyword">await</span> page.<span class="title function_">click</span>(<span class="string">'[data-testid="client-select"]'</span>);</span><br><span class="line"> <span class="keyword">await</span> page.<span class="title function_">click</span>(<span class="string">'[data-testid="client-option-0"]'</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 填入计算参数(脱敏:不写真实业务字段)</span></span><br><span class="line"> <span class="keyword">await</span> page.<span class="title function_">fill</span>(<span class="string">'[data-testid="input-amount"]'</span>, <span class="string">'500000'</span>);</span><br><span class="line"> <span class="keyword">await</span> page.<span class="title function_">fill</span>(<span class="string">'[data-testid="input-years"]'</span>, <span class="string">'20'</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 点击计算</span></span><br><span class="line"> <span class="keyword">await</span> page.<span class="title function_">click</span>(<span class="string">'[data-testid="calc-button"]'</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 等待结果出现</span></span><br><span class="line"> <span class="keyword">await</span> <span class="title function_">expect</span>(page.<span class="title function_">locator</span>(<span class="string">'[data-testid="result-table"]'</span>)).<span class="title function_">toBeVisible</span>();</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 验证结果表格有数据行</span></span><br><span class="line"> <span class="keyword">const</span> rows = page.<span class="title function_">locator</span>(<span class="string">'[data-testid="result-table"] tbody tr'</span>);</span><br><span class="line"> <span class="keyword">await</span> <span class="title function_">expect</span>(rows).<span class="title function_">toHaveCount</span>(<span class="number">20</span>); <span class="comment">// 20 年 = 20 行</span></span><br><span class="line"></span><br><span class="line"> <span class="comment">// 验证导出按钮可点击</span></span><br><span class="line"> <span class="keyword">await</span> <span class="title function_">expect</span>(page.<span class="title function_">locator</span>(<span class="string">'[data-testid="export-button"]'</span>)).<span class="title function_">toBeEnabled</span>();</span><br><span class="line">});</span><br></pre></td></tr></table></figure><p>e2e 测试的灵魂在于 <code>data-testid</code>。我们定了一个规范:所有需要测试交互的元素必须有 <code>data-testid</code> 属性,CSS 类名可以变,但 testid 不能变。这样重构时测试不会大面积失效。</p><h2 id="覆盖率门槛怎么执行"><a href="#覆盖率门槛怎么执行" class="headerlink" title="覆盖率门槛怎么执行"></a>覆盖率门槛怎么执行</h2><p>光有配置不够,得有人守。我们的做法是分两层:</p><p>第一层是 pre-commit hook。跑 lint + 受影响包的单测,不通过不让提交:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#!/bin/bash</span></span><br><span class="line"><span class="comment"># .husky/pre-commit</span></span><br><span class="line">pnpm lint-staged</span><br></pre></td></tr></table></figure><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// package.json</span></span><br><span class="line"><span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"lint-staged"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"*.{ts,vue,tsx}"</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">"eslint --fix"</span><span class="punctuation">,</span> <span class="string">"vitest related --run"</span><span class="punctuation">]</span></span><br><span class="line"> <span class="punctuation">}</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></table></figure><p>第二层是 CI 流水线。跑全量单测 + 覆盖率检查 + e2e,任何一项挂了 PR 不能合并:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># .github/workflows/quality.yml</span></span><br><span class="line"><span class="attr">name:</span> <span class="string">Quality</span> <span class="string">Gate</span></span><br><span class="line"><span class="attr">on:</span> [<span class="string">pull_request</span>]</span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line"> <span class="attr">unit-test:</span></span><br><span class="line"> <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line"> <span class="attr">steps:</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/checkout@v4</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">pnpm/action-setup@v4</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">run:</span> <span class="string">pnpm</span> <span class="string">install</span> <span class="string">--frozen-lockfile</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">run:</span> <span class="string">pnpm</span> <span class="string">test</span> <span class="string">--coverage</span></span><br><span class="line"> <span class="comment"># threshold 不过会自动失败</span></span><br><span class="line"></span><br><span class="line"> <span class="attr">e2e-test:</span></span><br><span class="line"> <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line"> <span class="attr">steps:</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/checkout@v4</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">pnpm/action-setup@v4</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">run:</span> <span class="string">pnpm</span> <span class="string">install</span> <span class="string">--frozen-lockfile</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">run:</span> <span class="string">pnpm</span> <span class="string">exec</span> <span class="string">playwright</span> <span class="string">install</span> <span class="string">--with-deps</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">run:</span> <span class="string">pnpm</span> <span class="string">e2e</span></span><br></pre></td></tr></table></figure><p>覆盖率是个数字,但数字背后是纪律。没有 CI 卡门,配置写得再漂亮也没用。</p><h2 id="ESLint:把规范写进机器里"><a href="#ESLint:把规范写进机器里" class="headerlink" title="ESLint:把规范写进机器里"></a>ESLint:把规范写进机器里</h2><p>人的 code review 会遗漏,机器不会。我们的 ESLint 配置在共享包里:</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// packages/eslint-config/index.js</span></span><br><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = {</span><br><span class="line"> <span class="attr">extends</span>: [</span><br><span class="line"> <span class="string">'eslint:recommended'</span>,</span><br><span class="line"> <span class="string">'@vue/eslint-config-typescript'</span>,</span><br><span class="line"> <span class="string">'@vue/eslint-config-typescript/strict'</span>,</span><br><span class="line"> ],</span><br><span class="line"> <span class="attr">rules</span>: {</span><br><span class="line"> <span class="comment">// 金融场景禁止 console.log 残留</span></span><br><span class="line"> <span class="string">'no-console'</span>: [<span class="string">'error'</span>, { <span class="attr">allow</span>: [<span class="string">'warn'</span>, <span class="string">'error'</span>] }],</span><br><span class="line"> <span class="comment">// 禁止 any,金融类型必须明确</span></span><br><span class="line"> <span class="string">'@typescript-eslint/no-explicit-any'</span>: <span class="string">'error'</span>,</span><br><span class="line"> <span class="comment">// 禁止 parseFloat/parseInt,金额必须用 Decimal</span></span><br><span class="line"> <span class="string">'no-restricted-globals'</span>: [<span class="string">'error'</span>, { <span class="attr">name</span>: <span class="string">'parseFloat'</span>, <span class="attr">message</span>: <span class="string">'金额计算请使用 BigDecimal 或 decimal.js'</span> }],</span><br><span class="line"> },</span><br><span class="line">};</span><br></pre></td></tr></table></figure><p>最后那条 <code>no-restricted-globals</code> 是专门为金融场景加的——强制阻止 <code>parseFloat</code> 出现在代码里,因为浮点精度问题在金融场景下是致命的。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>质量基线这件事,没有银弹,就是三件事做到位:</p><ul><li>Vitest 跑单测,覆盖率门槛卡死(核心模块 90%+)</li><li>Playwright 跑 e2e,关键流程全覆盖</li><li>ESLint 把规范写进 CI(人能漏的机器不能漏)</li></ul><p>测试不是”写完功能再补”,是和功能一起写,甚至先写测试再写实现。</p><p>到这一篇为止,工具线的前半段(架构搭建)基本讲完了。下一篇是一个转折——为什么我决定把这套 Vue 2.7 的体系迁到 React 19。</p>]]>
</content>
<id>https://www.robbs.win/2025-02-17/Quality-Baseline-Vitest-Playwright.html</id>
<link href="https://www.robbs.win/2025-02-17/Quality-Baseline-Vitest-Playwright.html"/>
<published>2025-02-17T02:30:00.000Z</published>
<summary>13 个金融工具没有测试基线是不行的。这篇讲我们怎么用 Vitest 跑单测、Playwright 跑 e2e、ESLint 守风格,核心模块覆盖率拉到 90%+。</summary>
<title>质量基线:Vitest + Playwright + ESLint 把覆盖率拉到 90%</title>
<updated>2026-07-08T01:06:19.595Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="组件库" scheme="https://www.robbs.win/tags/%E7%BB%84%E4%BB%B6%E5%BA%93/"/>
<category term="UMD" scheme="https://www.robbs.win/tags/UMD/"/>
<category term="跨工具复用" scheme="https://www.robbs.win/tags/%E8%B7%A8%E5%B7%A5%E5%85%B7%E5%A4%8D%E7%94%A8/"/>
<content>
<![CDATA[<p>工具线做到中期,一个绕不开的问题冒出来了:13 个工具的 UI 组件得统一。</p><p>理财计算工具里的金额输入框、退休规划工具里的年份选择器、税务工具里的税率展示卡片……这些东西如果每个工具各写一套,视觉不一致是小事,行为不一致才是大事。金融场景里,一个金额输入框的格式化逻辑必须 13 个工具完全一致。</p><p>所以我们做了一个内部组件库,10+ 个组件,一套源码,同时输出两种产物:UMD 包和 npm 包。这篇讲怎么做到的。</p><h2 id="为什么是两种产物"><a href="#为什么是两种产物" class="headerlink" title="为什么是两种产物"></a>为什么是两种产物</h2><p>先说清楚为什么不能只选一种。</p><p>只有 npm 包不行——因为有几个工具是早期遗留项目,构建配置改不动,没法直接 import。它们需要的是往 HTML 里丢一个 <code><script></code> 标签就能用的 UMD 产物。</p><p>只有 UMD 不行——因为新工具走的是 monorepo 里的 ES 模块体系,UMD 的 tree-shaking 效果差,包体积会膨胀。</p><p>一套源码,两种产物,看起来是”都要”,其实是被现实逼的。</p><h2 id="组件库的目录结构"><a href="#组件库的目录结构" class="headerlink" title="组件库的目录结构"></a>组件库的目录结构</h2><p>组件库本身也是 monorepo 里的一个 package:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">packages/ui-components/</span><br><span class="line">├── src/</span><br><span class="line">│ ├── components/</span><br><span class="line">│ │ ├── AmountInput/ # 金额输入(金融专用)</span><br><span class="line">│ │ ├── YearPicker/ # 年份选择</span><br><span class="line">│ │ ├── RateCard/ # 比率展示卡片</span><br><span class="line">│ │ ├── ResultTable/ # 结果表格</span><br><span class="line">│ │ └── ...</span><br><span class="line">│ ├── styles/</span><br><span class="line">│ │ ├── tokens.scss # 设计 token(颜色、间距)</span><br><span class="line">│ │ └── theme.scss # 主题变量</span><br><span class="line">│ ├── utils/</span><br><span class="line">│ │ └── format.ts # 格式化工具(金额、日期等)</span><br><span class="line">│ └── index.ts # 统一出口</span><br><span class="line">├── vite.config.ts # 构建配置(关键!)</span><br><span class="line">├── package.json</span><br><span class="line">└── tsconfig.json</span><br></pre></td></tr></table></figure><p>每个组件一个目录,内含 <code>index.vue</code>(组件实现)和 <code>index.test.ts</code>(单元测试)。这种结构方便单独导出——后面讲。</p><h2 id="Vite-Library-Mode:一次构建两种产物"><a href="#Vite-Library-Mode:一次构建两种产物" class="headerlink" title="Vite Library Mode:一次构建两种产物"></a>Vite Library Mode:一次构建两种产物</h2><p>配置重心在 <code>vite.config.ts</code>。Vite 的 library mode 支持同时输出多种格式:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { defineConfig } <span class="keyword">from</span> <span class="string">'vite'</span>;</span><br><span class="line"><span class="keyword">import</span> vue <span class="keyword">from</span> <span class="string">'@vitejs/plugin-vue'</span>;</span><br><span class="line"><span class="keyword">import</span> dts <span class="keyword">from</span> <span class="string">'vite-plugin-dts'</span>;</span><br><span class="line"><span class="keyword">import</span> { resolve } <span class="keyword">from</span> <span class="string">'path'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title function_">defineConfig</span>({</span><br><span class="line"> <span class="attr">plugins</span>: [</span><br><span class="line"> <span class="title function_">vue</span>(),</span><br><span class="line"> <span class="title function_">dts</span>({ <span class="attr">insertTypesEntry</span>: <span class="literal">true</span> }), <span class="comment">// 自动生成 .d.ts</span></span><br><span class="line"> ],</span><br><span class="line"> <span class="attr">build</span>: {</span><br><span class="line"> <span class="attr">lib</span>: {</span><br><span class="line"> <span class="attr">entry</span>: <span class="title function_">resolve</span>(__dirname, <span class="string">'src/index.ts'</span>),</span><br><span class="line"> <span class="attr">name</span>: <span class="string">'SuiteUI'</span>, <span class="comment">// UMD 全局变量名</span></span><br><span class="line"> <span class="attr">formats</span>: [<span class="string">'es'</span>, <span class="string">'umd'</span>], <span class="comment">// 同时输出 ES 和 UMD</span></span><br><span class="line"> <span class="attr">fileName</span>: <span class="function">(<span class="params">format</span>) =></span> <span class="string">`suite-ui.<span class="subst">${format}</span>.js`</span>,</span><br><span class="line"> },</span><br><span class="line"> <span class="attr">rollupOptions</span>: {</span><br><span class="line"> <span class="comment">// 外部化 Vue,不打包进去</span></span><br><span class="line"> <span class="attr">external</span>: [<span class="string">'vue'</span>],</span><br><span class="line"> <span class="attr">output</span>: {</span><br><span class="line"> <span class="attr">globals</span>: { <span class="attr">vue</span>: <span class="string">'Vue'</span> },</span><br><span class="line"> <span class="comment">// UMD 需要导出 CSS</span></span><br><span class="line"> <span class="attr">assetFileNames</span>: <span class="string">'suite-ui.[ext]'</span>,</span><br><span class="line"> },</span><br><span class="line"> },</span><br><span class="line"> },</span><br><span class="line">});</span><br></pre></td></tr></table></figure><p>关键是 <code>formats: ['es', 'umd']</code> 这一行——一次构建,同时生成:</p><ul><li><code>suite-ui.es.js</code>:给 monorepo 内部工具 import 的 ES 模块</li><li><code>suite-ui.umd.js</code>:给遗留项目 <code><script></code> 标签引入的 UMD 包</li><li><code>suite-ui.css</code>:样式文件(UMD 模式必须单独导出 CSS)</li></ul><h2 id="package-json-的-exports-字段"><a href="#package-json-的-exports-字段" class="headerlink" title="package.json 的 exports 字段"></a>package.json 的 exports 字段</h2><p><code>package.json</code> 里用 <code>exports</code> 字段让两种消费方式自动选对产物:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"name"</span><span class="punctuation">:</span> <span class="string">"@suite/ui-components"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"version"</span><span class="punctuation">:</span> <span class="string">"1.4.0"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"main"</span><span class="punctuation">:</span> <span class="string">"./dist/suite-ui.umd.js"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"module"</span><span class="punctuation">:</span> <span class="string">"./dist/suite-ui.es.js"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"types"</span><span class="punctuation">:</span> <span class="string">"./dist/index.d.ts"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"exports"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"."</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"import"</span><span class="punctuation">:</span> <span class="string">"./dist/suite-ui.es.js"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"require"</span><span class="punctuation">:</span> <span class="string">"./dist/suite-ui.umd.js"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"types"</span><span class="punctuation">:</span> <span class="string">"./dist/index.d.ts"</span></span><br><span class="line"> <span class="punctuation">}</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"./styles.css"</span><span class="punctuation">:</span> <span class="string">"./dist/suite-ui.css"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"./package.json"</span><span class="punctuation">:</span> <span class="string">"./package.json"</span></span><br><span class="line"> <span class="punctuation">}</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"sideEffects"</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">"*.css"</span><span class="punctuation">,</span> <span class="string">"*.scss"</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"files"</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">"dist/"</span><span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></table></figure><p><code>sideEffects</code> 这个字段必须配,否则消费者的 tree-shaking 会把 CSS 当副作用代码全部删掉。</p><p>monorepo 里的工具这样引用:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"dependencies"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"@suite/ui-components"</span><span class="punctuation">:</span> <span class="string">"workspace:*"</span></span><br><span class="line"> <span class="punctuation">}</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></table></figure><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// ES 模块方式(新工具)</span></span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">AmountInput</span>, <span class="title class_">RateCard</span> } <span class="keyword">from</span> <span class="string">'@suite/ui-components'</span>;</span><br><span class="line"><span class="keyword">import</span> <span class="string">'@suite/ui-components/styles.css'</span>;</span><br></pre></td></tr></table></figure><p>遗留项目这样用:</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"><!-- UMD 方式(老项目) --></span></span><br><span class="line"><span class="tag"><<span class="name">script</span> <span class="attr">src</span>=<span class="string">"/vendor/vue.runtime.js"</span>></span><span class="tag"></<span class="name">script</span>></span></span><br><span class="line"><span class="tag"><<span class="name">script</span> <span class="attr">src</span>=<span class="string">"/vendor/suite-ui.umd.js"</span>></span><span class="tag"></<span class="name">script</span>></span></span><br><span class="line"><span class="tag"><<span class="name">link</span> <span class="attr">rel</span>=<span class="string">"stylesheet"</span> <span class="attr">href</span>=<span class="string">"/vendor/suite-ui.css"</span> /></span></span><br><span class="line"></span><br><span class="line"><span class="tag"><<span class="name">script</span>></span><span class="language-javascript"></span></span><br><span class="line"><span class="language-javascript"> <span class="keyword">const</span> { <span class="title class_">AmountInput</span> } = <span class="variable language_">window</span>.<span class="property">SuiteUI</span>;</span></span><br><span class="line"><span class="language-javascript"> <span class="comment">// 注册到 Vue...</span></span></span><br><span class="line"><span class="language-javascript"></span><span class="tag"></<span class="name">script</span>></span></span><br></pre></td></tr></table></figure><h2 id="按需引入:子路径导出"><a href="#按需引入:子路径导出" class="headerlink" title="按需引入:子路径导出"></a>按需引入:子路径导出</h2><p>到后面组件多了(15+ 个),全量引入太重。我们做了子路径导出,让消费者只引需要的组件:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"exports"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"."</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"import"</span><span class="punctuation">:</span> <span class="string">"./dist/suite-ui.es.js"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"require"</span><span class="punctuation">:</span> <span class="string">"./dist/suite-ui.umd.js"</span></span><br><span class="line"> <span class="punctuation">}</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"./AmountInput"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"import"</span><span class="punctuation">:</span> <span class="string">"./dist/components/AmountInput/index.es.js"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"require"</span><span class="punctuation">:</span> <span class="string">"./dist/components/AmountInput/index.umd.js"</span></span><br><span class="line"> <span class="punctuation">}</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"./RateCard"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"import"</span><span class="punctuation">:</span> <span class="string">"./dist/components/RateCard/index.es.js"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"require"</span><span class="punctuation">:</span> <span class="string">"./dist/components/RateCard/index.umd.js"</span></span><br><span class="line"> <span class="punctuation">}</span></span><br><span class="line"> <span class="punctuation">}</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></table></figure><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 只引金额输入框,不打包整个库</span></span><br><span class="line"><span class="keyword">import</span> <span class="title class_">AmountInput</span> <span class="keyword">from</span> <span class="string">'@suite/ui-components/AmountInput'</span>;</span><br></pre></td></tr></table></figure><p>这意味着构建配置要做多入口——Vite 配置改一下:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">build</span>: {</span><br><span class="line"> <span class="attr">lib</span>: {</span><br><span class="line"> <span class="attr">entry</span>: {</span><br><span class="line"> <span class="attr">index</span>: <span class="title function_">resolve</span>(__dirname, <span class="string">'src/index.ts'</span>),</span><br><span class="line"> <span class="string">'components/AmountInput/index'</span>: <span class="title function_">resolve</span>(__dirname, <span class="string">'src/components/AmountInput/index.ts'</span>),</span><br><span class="line"> <span class="string">'components/RateCard/index'</span>: <span class="title function_">resolve</span>(__dirname, <span class="string">'src/components/RateCard/index.ts'</span>),</span><br><span class="line"> <span class="comment">// ... 其他组件</span></span><br><span class="line"> },</span><br><span class="line"> <span class="attr">formats</span>: [<span class="string">'es'</span>, <span class="string">'umd'</span>],</span><br><span class="line"> <span class="attr">fileName</span>: <span class="function">(<span class="params">format, entryName</span>) =></span> <span class="string">`<span class="subst">${entryName}</span>.<span class="subst">${format}</span>.js`</span>,</span><br><span class="line"> },</span><br><span class="line">}</span><br></pre></td></tr></table></figure><h2 id="设计-Token:样式一致性的底子"><a href="#设计-Token:样式一致性的底子" class="headerlink" title="设计 Token:样式一致性的底子"></a>设计 Token:样式一致性的底子</h2><p>13 个工具的视觉不能飘。我们在 <code>tokens.scss</code> 里定义了一套设计 token:</p><figure class="highlight scss"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 语义色</span></span><br><span class="line"><span class="variable">$color-primary</span>: <span class="number">#1677ff</span>;</span><br><span class="line"><span class="variable">$color-success</span>: <span class="number">#52c41a</span>;</span><br><span class="line"><span class="variable">$color-warning</span>: <span class="number">#faad14</span>;</span><br><span class="line"><span class="variable">$color-danger</span>: <span class="number">#ff4d4f</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 金额专用色(金融场景区分正负)</span></span><br><span class="line"><span class="variable">$color-amount-positive</span>: <span class="number">#52c41a</span>;</span><br><span class="line"><span class="variable">$color-amount-negative</span>: <span class="number">#ff4d4f</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 间距</span></span><br><span class="line"><span class="variable">$spacing-unit</span>: <span class="number">4px</span>;</span><br><span class="line"><span class="variable">$spacing-sm</span>: <span class="variable">$spacing-unit</span> * <span class="number">2</span>;</span><br><span class="line"><span class="variable">$spacing-md</span>: <span class="variable">$spacing-unit</span> * <span class="number">4</span>;</span><br><span class="line"><span class="variable">$spacing-lg</span>: <span class="variable">$spacing-unit</span> * <span class="number">6</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 圆角</span></span><br><span class="line"><span class="variable">$radius-sm</span>: <span class="number">2px</span>;</span><br><span class="line"><span class="variable">$radius-md</span>: <span class="number">4px</span>;</span><br><span class="line"><span class="variable">$radius-lg</span>: <span class="number">8px</span>;</span><br></pre></td></tr></table></figure><p>这些 token 编译进 CSS 变量,消费者可以在运行时覆盖主题:</p><figure class="highlight scss"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="selector-pseudo">:root</span> {</span><br><span class="line"> <span class="attr">--suite-color-primary</span>: #{<span class="variable">$color-primary</span>};</span><br><span class="line"> <span class="attr">--suite-color-amount-positive</span>: #{<span class="variable">$color-amount-positive</span>};</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></table></figure><figure class="highlight css"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/* 消费者覆盖主题 */</span></span><br><span class="line"><span class="selector-pseudo">:root</span> {</span><br><span class="line"> <span class="attr">--suite-color-primary</span>: <span class="number">#722ed1</span>; <span class="comment">/* 换成紫色 */</span></span><br><span class="line">}</span><br></pre></td></tr></table></figure><h2 id="踩过的坑"><a href="#踩过的坑" class="headerlink" title="踩过的坑"></a>踩过的坑</h2><p>坑一:UMD 模式下 CSS 没自动引入。ES 模块那边 <code>import '@suite/ui-components/styles.css'</code> 就行了,但 UMD 那边用户经常忘了引 CSS,页面全白。后来我们在文档里加了一句铁律:UMD 引入必须同时引 CSS,否则布局必崩。</p><p>坑二:Vue 版本不一致导致运行时报错。组件库 external 掉了 Vue,但如果消费者用的 Vue 版本和库开发时的不一致,运行时可能炸。我们在 peerDependencies 里锁死了范围:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"peerDependencies"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"vue"</span><span class="punctuation">:</span> <span class="string">"^2.7.0"</span></span><br><span class="line"> <span class="punctuation">}</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></table></figure><p>坑三:按需引入的产物体积没降反升。早期多入口构建时,每个组件都重复打包了 utils 和 styles 的公共部分。后来用 Vite 的 <code>manualChunks</code> 把公共依赖抽出来,体积才真正降下去。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>一套源码同时出 UMD 和 npm 包,核心就三件事:</p><ul><li>Vite library mode 多格式输出(<code>formats: ['es', 'umd']</code>)</li><li>package.json exports 精准路由(让消费者自动选对产物)</li><li>设计 token 统一视觉(CSS 变量支持主题覆盖)</li></ul><p>组件库不是”写组件”的问题,是”工程化交付”的问题。下一篇讲质量基线,怎么把测试覆盖率拉到 90%。</p>]]>
</content>
<id>https://www.robbs.win/2024-12-16/UMD-Component-Library.html</id>
<link href="https://www.robbs.win/2024-12-16/UMD-Component-Library.html"/>
<published>2024-12-16T07:00:00.000Z</published>
<summary>13 个工具要复用一套 UI 组件,但集成方式不同——有的要 UMD 直接嵌,有的要走 npm 包 import。10+ 个组件,两种产物,一套源码。</summary>
<title>组件库:10+ 组件怎么同时支持 UMD 和组件包</title>
<updated>2026-07-08T01:06:19.562Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="计算引擎" scheme="https://www.robbs.win/tags/%E8%AE%A1%E7%AE%97%E5%BC%95%E6%93%8E/"/>
<category term="可复算" scheme="https://www.robbs.win/tags/%E5%8F%AF%E5%A4%8D%E7%AE%97/"/>
<category term="工程架构" scheme="https://www.robbs.win/tags/%E5%B7%A5%E7%A8%8B%E6%9E%B6%E6%9E%84/"/>
<content>
<![CDATA[<p>这一篇是整个工具线里我觉得最”金融”的一块:计算引擎。</p><p>先说清楚:这篇不涉及任何计算公式、系数、利率口径。那些是业务机密,也不是我该讲的。我讲的是工程:怎么让一张金融报表,三个月后回头看,还能算出一模一样的结果。</p><h2 id="什么是”可复算”"><a href="#什么是”可复算”" class="headerlink" title="什么是”可复算”"></a>什么是”可复算”</h2><p>金融工具和普通工具最大的区别就在这四个字:可复算(Reproducible)。同一份输入配上同一个引擎版本,输出永远一致。</p><p>为什么这么讲究?因为金融业务要面对监管审计。审计人员会问:”半年前那张理财规划报表,是怎么算出来的?”你不能耸耸肩说”算法升级了,现在算出来不一样了”,那就出大事了。</p><p>所以我们的引擎设计,从头到尾都围绕这一个目标。</p><h2 id="关键设计"><a href="#关键设计" class="headerlink" title="关键设计"></a>关键设计</h2><h3 id="1-输入快照"><a href="#1-输入快照" class="headerlink" title="1. 输入快照"></a>1. 输入快照</h3><p>用户每次发起计算请求,我们不是直接把参数丢进计算函数就完事。而是先做一份完整的输入快照:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">CalculationRequest</span> {</span><br><span class="line"> <span class="keyword">private</span> String requestId; <span class="comment">// 唯一标识</span></span><br><span class="line"> <span class="keyword">private</span> String engineVersion; <span class="comment">// 引擎版本号</span></span><br><span class="line"> <span class="keyword">private</span> String toolId; <span class="comment">// 哪个工具发起的</span></span><br><span class="line"> <span class="keyword">private</span> String inputSnapshot; <span class="comment">// 完整入参的 JSON 快照</span></span><br><span class="line"> <span class="keyword">private</span> Instant requestedAt;</span><br><span class="line"> <span class="keyword">private</span> String userId;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p><code>inputSnapshot</code> 是一份完整的 JSON,包含了那次计算需要的所有入参。这份快照和 requestId 绑定,永久存储。</p><p>为什么要存完整快照而不是引用?因为客户数据会变。三个月后客户档案更新了,如果你只存一个 <code>clientId</code>,回头复算的结果就和原来不一致了。快照意味着”冻结那一刻的世界”。</p><h3 id="2-引擎版本化"><a href="#2-引擎版本化" class="headerlink" title="2. 引擎版本化"></a>2. 引擎版本化</h3><p>每次计算结果都必须绑定一个引擎版本号。我们的版本规则是语义化的:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">EngineVersion</span> {</span><br><span class="line"> <span class="keyword">private</span> <span class="type">int</span> major; <span class="comment">// 公式逻辑变更(需要重新审计)</span></span><br><span class="line"> <span class="keyword">private</span> <span class="type">int</span> minor; <span class="comment">// 新增计算维度(向前兼容)</span></span><br><span class="line"> <span class="keyword">private</span> <span class="type">int</span> patch; <span class="comment">// Bug 修复</span></span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>major 版本变更意味着报表口径变了,这是需要合规团队签字才能上线的事。</p><p>引擎本身是个无状态的纯函数式模块:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">interface</span> <span class="title class_">CalculationEngine</span> {</span><br><span class="line"> CalculationResult <span class="title function_">compute</span><span class="params">(CalculationInput input)</span>;</span><br><span class="line"> EngineVersion <span class="title function_">version</span><span class="params">()</span>;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>关键在于我们不覆盖旧版本,而是让多个版本共存。注册中心维护一个版本表:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">EngineRegistry</span> {</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">final</span> Map<String, CalculationEngine> engines = <span class="keyword">new</span> <span class="title class_">ConcurrentHashMap</span><>();</span><br><span class="line"></span><br><span class="line"> <span class="meta">@PostConstruct</span></span><br><span class="line"> <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">init</span><span class="params">()</span> {</span><br><span class="line"> register(<span class="string">"1.0.0"</span>, <span class="keyword">new</span> <span class="title class_">WealthEngineV1</span>());</span><br><span class="line"> register(<span class="string">"1.1.0"</span>, <span class="keyword">new</span> <span class="title class_">WealthEngineV1_1</span>());</span><br><span class="line"> register(<span class="string">"2.0.0"</span>, <span class="keyword">new</span> <span class="title class_">WealthEngineV2</span>());</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">public</span> CalculationEngine <span class="title function_">get</span><span class="params">(String version)</span> {</span><br><span class="line"> <span class="type">CalculationEngine</span> <span class="variable">engine</span> <span class="operator">=</span> engines.get(version);</span><br><span class="line"> <span class="keyword">if</span> (engine == <span class="literal">null</span>) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">BizException</span>(ErrorCode.ENGINE_VERSION_NOT_FOUND);</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> engine;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>报表里存了 <code>engineVersion</code>,复算时用对应版本的引擎跑一遍,不是用最新版。</p><h3 id="3-计算日志:中间步骤可追溯"><a href="#3-计算日志:中间步骤可追溯" class="headerlink" title="3. 计算日志:中间步骤可追溯"></a>3. 计算日志:中间步骤可追溯</h3><p>光有结果不够。审计要看的是”这个数字怎么来的”。所以引擎每次跑完会顺手吐出一份 step-by-step 的计算日志:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">CalculationResult</span> {</span><br><span class="line"> <span class="keyword">private</span> String requestId;</span><br><span class="line"> <span class="keyword">private</span> BigDecimal finalValue; <span class="comment">// 最终结果</span></span><br><span class="line"> <span class="keyword">private</span> List<CalcStep> steps; <span class="comment">// 计算步骤</span></span><br><span class="line"> <span class="keyword">private</span> EngineVersion engineVersion;</span><br><span class="line"> <span class="keyword">private</span> Instant computedAt;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">CalcStep</span> {</span><br><span class="line"> <span class="keyword">private</span> String stepName; <span class="comment">// 步骤名(脱敏的泛化名称)</span></span><br><span class="line"> <span class="keyword">private</span> String inputSummary; <span class="comment">// 这一步的入参摘要</span></span><br><span class="line"> <span class="keyword">private</span> BigDecimal stepOutput; <span class="comment">// 这一步的输出</span></span><br><span class="line"> <span class="keyword">private</span> String note; <span class="comment">// 备注</span></span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>注意 <code>stepName</code> 用的是脱敏后的泛化名称,比如 “阶段一汇总””权益折算”这种工程标签,绝不暴露具体算法口径。审计能看到计算流程的骨架,但看不到公式细节。</p><p>这套日志存在哪?直接落 PostgreSQL 的 JSONB 字段,查询方便。量大的时候可以考虑单独的时序存储,但我们目前的规模 PG 够用。</p><h2 id="复算的完整流程"><a href="#复算的完整流程" class="headerlink" title="复算的完整流程"></a>复算的完整流程</h2><p>把上面三块串起来,复算流程是这样的:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">[原始报表]</span><br><span class="line"> │</span><br><span class="line"> ├── requestId: "REQ-2024-001234"</span><br><span class="line"> ├── engineVersion: "1.1.0"</span><br><span class="line"> └── inputSnapshot: {...}</span><br><span class="line"> │</span><br><span class="line"> ▼</span><br><span class="line"> [EngineRegistry.get("1.1.0")]</span><br><span class="line"> │</span><br><span class="line"> ▼</span><br><span class="line"> [engine.compute(snapshot)]</span><br><span class="line"> │</span><br><span class="line"> ▼</span><br><span class="line"> [新的 CalculationResult]</span><br><span class="line"> │</span><br><span class="line"> ▼</span><br><span class="line"> 与原始报表对比 → 完全一致 ✓</span><br></pre></td></tr></table></figure><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">RecalculationService</span> {</span><br><span class="line"></span><br><span class="line"> <span class="keyword">public</span> RecalcResult <span class="title function_">verify</span><span class="params">(String originalRequestId)</span> {</span><br><span class="line"> <span class="type">CalculationRequest</span> <span class="variable">original</span> <span class="operator">=</span> requestRepo.findById(originalRequestId);</span><br><span class="line"> <span class="type">CalculationEngine</span> <span class="variable">engine</span> <span class="operator">=</span> engineRegistry.get(original.getEngineVersion());</span><br><span class="line"></span><br><span class="line"> <span class="type">CalculationInput</span> <span class="variable">input</span> <span class="operator">=</span> CalculationInput.fromJson(original.getInputSnapshot());</span><br><span class="line"> <span class="type">CalculationResult</span> <span class="variable">fresh</span> <span class="operator">=</span> engine.compute(input);</span><br><span class="line"></span><br><span class="line"> <span class="type">boolean</span> <span class="variable">matches</span> <span class="operator">=</span> fresh.getFinalValue().compareTo(</span><br><span class="line"> original.getResult().getFinalValue()</span><br><span class="line"> ) == <span class="number">0</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">RecalcResult</span>(original, fresh, matches);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>这段代码看着简单,但背后的含义很重:任何一张报表,任何时候都能被独立验证。</p><h2 id="踩过的坑"><a href="#踩过的坑" class="headerlink" title="踩过的坑"></a>踩过的坑</h2><p>坑一:BigDecimal 的精度陷阱。Java 里做金融计算必须用 <code>BigDecimal</code>,但 <code>equals</code> 和 <code>compareTo</code> 行为不一致:<code>new BigDecimal("1.0")</code> 和 <code>new BigDecimal("1.00")</code> 用 equals 是 false,用 compareTo 才是 true。早期复算对比时踩过这个坑,排查了一下午。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 错误:会被 scale 误导</span></span><br><span class="line"><span class="keyword">if</span> (a.equals(b)) { ... }</span><br><span class="line"></span><br><span class="line"><span class="comment">// 正确:只比较数值大小</span></span><br><span class="line"><span class="keyword">if</span> (a.compareTo(b) == <span class="number">0</span>) { ... }</span><br></pre></td></tr></table></figure><p>坑二:浮点数绝对不能出现在计算链路里。有同事图省事用 <code>double</code> 做了中间转换,结果在某个边界值上偏差了 0.01。听起来不多,但金融场景下 0.01 的偏差放大到百万级客户就是大事故。后来加了静态检查,禁止计算模块使用 <code>double</code>/<code>float</code>。</p><p>坑三:时区导致”同一天”不一致。快照里的日期如果没固定时区,服务器换了部署区域后复算结果就飘了。最后我们统一用 UTC 存储,展示时再转。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>计算引擎这事的核心不是算法多精妙,而是工程纪律:</p><ul><li>输入冻结(快照)</li><li>版本锁定(不覆盖旧版本)</li><li>过程留痕(计算日志)</li></ul><p>能复算的报表,才是金融系统真正的资产。下一篇讲组件库,10+ 个组件怎么同时支持 UMD 和 npm 包两种集成方式。</p>]]>
</content>
<id>https://www.robbs.win/2024-10-15/Reproducible-Calculation-Engine.html</id>
<link href="https://www.robbs.win/2024-10-15/Reproducible-Calculation-Engine.html"/>
<published>2024-10-15T03:00:00.000Z</published>
<summary>金融场景下报表必须可复算——给定输入和引擎版本,结果完全一致。这篇只讲工程架构怎么落地,不碰计算公式。</summary>
<title>金融计算引擎:让每张报表都能复算</title>
<updated>2026-07-08T01:06:19.530Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="Spring Boot" scheme="https://www.robbs.win/tags/Spring-Boot/"/>
<category term="Java" scheme="https://www.robbs.win/tags/Java/"/>
<category term="后端架构" scheme="https://www.robbs.win/tags/%E5%90%8E%E7%AB%AF%E6%9E%B6%E6%9E%84/"/>
<content>
<![CDATA[<p>接上篇 monorepo。前端 13 个工具塞一个仓库,那后端呢?总不能 13 套独立服务各自为政吧。前端收敛了,后端不收敛,等于把混乱从仓库搬到了网络层。</p><p>我们最后的选择是:一套 Spring Boot 服务,multi-module 结构,把 13 个工具的 API 全部收进来。这篇讲清楚怎么做的。</p><h2 id="为什么是一个服务,不是-13-个微服务"><a href="#为什么是一个服务,不是-13-个微服务" class="headerlink" title="为什么是一个服务,不是 13 个微服务"></a>为什么是一个服务,不是 13 个微服务</h2><p>我知道”微服务”听着高级。但在金融工具这个场景,13 个微服务是过度设计:</p><ul><li>工具之间数据强耦合:理财计算工具和退休规划工具共用同一份客户档案,拆成两个服务就要做数据同步,反而更脆弱。</li><li>团队规模不够:维护 13 个服务的运维成本(监控、发布、链路追踪)我们这种团队扛不动。</li><li>金融监管要求统一审计:一个服务一套审计逻辑,监管来看你直接懵。</li></ul><p>我的判断是:先做模块化的单体(Modular Monolith),等真正出现瓶颈再拆。这不是偷懒,是工程上的克制。</p><h2 id="multi-module-怎么分"><a href="#multi-module-怎么分" class="headerlink" title="multi-module 怎么分"></a>multi-module 怎么分</h2><p>Maven multi-module 是这次的主角。我们把整个服务分成几个模块,每个工具是一个独立的 module:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">fintech-backend/</span><br><span class="line">├── pom.xml # 根 POM,统一依赖版本</span><br><span class="line">├── common/ # 公共模块</span><br><span class="line">│ ├── common-core/ # 统一响应、错误码、工具类</span><br><span class="line">│ ├── common-web/ # 拦截器、过滤器、全局异常处理</span><br><span class="line">│ ├── common-security/ # 鉴权、JWT、权限</span><br><span class="line">│ └── common-audit/ # 审计日志</span><br><span class="line">├── domain/ # 领域模块</span><br><span class="line">│ ├── domain-wealth/ # 理财领域</span><br><span class="line">│ ├── domain-retirement/ # 退休领域</span><br><span class="line">│ ├── domain-tax/ # 税务领域</span><br><span class="line">│ └── ...</span><br><span class="line">├── api-wealth-calc/ # 某理财计算工具的 API</span><br><span class="line">├── api-retirement-plan/ # 某退休规划工具的 API</span><br><span class="line">├── api-tax-optimize/ # 某税务优化工具的 API</span><br><span class="line">├── app/ # 启动模块,聚合所有 API</span><br><span class="line">└── docker/</span><br></pre></td></tr></table></figure><p>这个结构有个关键设计:common 和 domain 是独立 module,api 模块依赖它们但不互相依赖。也就是说 <code>api-wealth-calc</code> 不能直接调用 <code>api-retirement-plan</code> 的代码,要协作就走 domain 层或者事件。</p><p>根 POM 用 <code>dependencyManagement</code> 把版本统死:</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag"><<span class="name">dependencyManagement</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">dependencies</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">dependency</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">groupId</span>></span>org.springframework.boot<span class="tag"></<span class="name">groupId</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">artifactId</span>></span>spring-boot-dependencies<span class="tag"></<span class="name">artifactId</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">version</span>></span>3.2.5<span class="tag"></<span class="name">version</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">type</span>></span>pom<span class="tag"></<span class="name">type</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">scope</span>></span>import<span class="tag"></<span class="name">scope</span>></span></span><br><span class="line"> <span class="tag"></<span class="name">dependency</span>></span></span><br><span class="line"> <span class="comment"><!-- 内部模块版本也在这里统一管理 --></span></span><br><span class="line"> <span class="tag"><<span class="name">dependency</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">groupId</span>></span>com.suite<span class="tag"></<span class="name">groupId</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">artifactId</span>></span>common-core<span class="tag"></<span class="name">artifactId</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">version</span>></span>${project.version}<span class="tag"></<span class="name">version</span>></span></span><br><span class="line"> <span class="tag"></<span class="name">dependency</span>></span></span><br><span class="line"> <span class="tag"></<span class="name">dependencies</span>></span></span><br><span class="line"><span class="tag"></<span class="name">dependencyManagement</span>></span></span><br></pre></td></tr></table></figure><h2 id="统一响应格式"><a href="#统一响应格式" class="headerlink" title="统一响应格式"></a>统一响应格式</h2><p>13 个工具如果返回的 JSON 结构各不相同,前端 <code>api-client</code> 就得写 13 套适配代码。我们在 <code>common-core</code> 里定了一个统一信封:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">ApiResponse</span><T> {</span><br><span class="line"> <span class="keyword">private</span> <span class="type">int</span> code; <span class="comment">// 业务码,0 表示成功</span></span><br><span class="line"> <span class="keyword">private</span> String message;</span><br><span class="line"> <span class="keyword">private</span> T data;</span><br><span class="line"> <span class="keyword">private</span> String traceId; <span class="comment">// 链路追踪 ID</span></span><br><span class="line"></span><br><span class="line"> <span class="keyword">public</span> <span class="keyword">static</span> <T> ApiResponse<T> <span class="title function_">ok</span><span class="params">(T data)</span> {</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">ApiResponse</span><>(<span class="number">0</span>, <span class="string">"OK"</span>, data, TraceContext.currentId());</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">public</span> <span class="keyword">static</span> <T> ApiResponse<T> <span class="title function_">fail</span><span class="params">(ErrorCode errorCode)</span> {</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">ApiResponse</span><>(errorCode.getCode(), errorCode.getMessage(), <span class="literal">null</span>, TraceContext.currentId());</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>统一信封看着是小决策,但它让前端的错误处理从 13 套变成了 1 套。</p><p>前端那边的 <code>api-client</code> 就可以统一拦截:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">apiClient.<span class="property">interceptors</span>.<span class="property">response</span>.<span class="title function_">use</span>(<span class="function">(<span class="params">response</span>) =></span> {</span><br><span class="line"> <span class="keyword">const</span> body = response.<span class="property">data</span>;</span><br><span class="line"> <span class="keyword">if</span> (body.<span class="property">code</span> !== <span class="number">0</span>) {</span><br><span class="line"> <span class="comment">// 统一走错误处理</span></span><br><span class="line"> <span class="keyword">return</span> <span class="title class_">Promise</span>.<span class="title function_">reject</span>(<span class="keyword">new</span> <span class="title class_">BizError</span>(body.<span class="property">code</span>, body.<span class="property">message</span>));</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> body.<span class="property">data</span>;</span><br><span class="line">});</span><br></pre></td></tr></table></figure><h2 id="全局异常处理"><a href="#全局异常处理" class="headerlink" title="全局异常处理"></a>全局异常处理</h2><p>Spring 的 <code>@RestControllerAdvice</code> 在这里很关键。我们把所有业务异常收口到一个地方,避免每个 controller 重复写 try-catch:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@RestControllerAdvice</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">GlobalExceptionHandler</span> {</span><br><span class="line"></span><br><span class="line"> <span class="meta">@ExceptionHandler(BizException.class)</span></span><br><span class="line"> <span class="keyword">public</span> ApiResponse<?> handleBiz(BizException e) {</span><br><span class="line"> <span class="keyword">return</span> ApiResponse.fail(e.getErrorCode());</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@ExceptionHandler(MethodArgumentNotValidException.class)</span></span><br><span class="line"> <span class="keyword">public</span> ApiResponse<?> handleValidation(MethodArgumentNotValidException e) {</span><br><span class="line"> <span class="type">String</span> <span class="variable">msg</span> <span class="operator">=</span> e.getBindingResult().getFieldErrors().stream()</span><br><span class="line"> .map(f -> f.getField() + <span class="string">": "</span> + f.getDefaultMessage())</span><br><span class="line"> .collect(Collectors.joining(<span class="string">"; "</span>));</span><br><span class="line"> <span class="keyword">return</span> ApiResponse.fail(ErrorCode.VALIDATION_FAILED.withMessage(msg));</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@ExceptionHandler(Exception.class)</span></span><br><span class="line"> <span class="keyword">public</span> ApiResponse<?> handleUnknown(Exception e) {</span><br><span class="line"> log.error(<span class="string">"unhandled exception, traceId={}"</span>, TraceContext.currentId(), e);</span><br><span class="line"> <span class="keyword">return</span> ApiResponse.fail(ErrorCode.INTERNAL_ERROR);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>注意最后那个 catch-all,金融系统绝不能把堆栈泄露给前端,只返回一个模糊的”系统错误”,详细信息走 traceId 在日志里查。</p><h2 id="鉴权:每个工具有独立的-scope"><a href="#鉴权:每个工具有独立的-scope" class="headerlink" title="鉴权:每个工具有独立的 scope"></a>鉴权:每个工具有独立的 scope</h2><p>13 个工具,不是每个用户都有权用所有工具。我们在 JWT 里塞了 <code>tools</code> 字段,标记当前用户可以用哪些工具:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">ToolAccessInterceptor</span> <span class="keyword">implements</span> <span class="title class_">HandlerInterceptor</span> {</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">public</span> <span class="type">boolean</span> <span class="title function_">preHandle</span><span class="params">(HttpServletRequest req, HttpServletResponse resp, Object handler)</span> {</span><br><span class="line"> <span class="type">String</span> <span class="variable">tool</span> <span class="operator">=</span> req.getHeader(<span class="string">"X-Tool-Id"</span>);</span><br><span class="line"> <span class="type">UserContext</span> <span class="variable">user</span> <span class="operator">=</span> UserContext.fromRequest(req);</span><br><span class="line"> <span class="keyword">if</span> (!user.hasToolAccess(tool)) {</span><br><span class="line"> resp.setStatus(<span class="number">403</span>);</span><br><span class="line"> <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>注册的时候给每个 api module 的路径打上标记:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Configuration</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">WebConfig</span> <span class="keyword">implements</span> <span class="title class_">WebMvcConfigurer</span> {</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">addInterceptors</span><span class="params">(InterceptorRegistry registry)</span> {</span><br><span class="line"> registry.addInterceptor(toolAccessInterceptor())</span><br><span class="line"> .addPathPatterns(<span class="string">"/api/wealth/**"</span>, <span class="string">"/api/retirement/**"</span>, <span class="string">"/api/tax/**"</span>);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><h2 id="审计日志:金融场景的硬要求"><a href="#审计日志:金融场景的硬要求" class="headerlink" title="审计日志:金融场景的硬要求"></a>审计日志:金融场景的硬要求</h2><p>这块我觉得是金融项目和普通项目最大的差别:每一次计算请求都必须留痕。监管来了要能说清楚”某年某月某日,某顾问用某工具做了什么计算,输入是什么”。</p><p>我们的做法是在 <code>common-audit</code> 里定义一个切面:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Aspect</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuditAspect</span> {</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Autowired</span></span><br><span class="line"> <span class="keyword">private</span> AuditLogRepository auditRepo;</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Around("@annotation(audited)")</span></span><br><span class="line"> <span class="keyword">public</span> Object <span class="title function_">audit</span><span class="params">(ProceedingJoinPoint pjp, Audited audited)</span> <span class="keyword">throws</span> Throwable {</span><br><span class="line"> <span class="type">Object</span> <span class="variable">result</span> <span class="operator">=</span> pjp.proceed();</span><br><span class="line"> <span class="type">AuditLog</span> <span class="variable">log</span> <span class="operator">=</span> AuditLog.builder()</span><br><span class="line"> .userId(UserContext.currentId())</span><br><span class="line"> .toolId(audited.tool())</span><br><span class="line"> .action(audited.action())</span><br><span class="line"> .requestHash(DigestUtils.sha256Hex(toJson(pjp.getArgs())))</span><br><span class="line"> .timestamp(Instant.now())</span><br><span class="line"> .build();</span><br><span class="line"> auditRepo.save(log);</span><br><span class="line"> <span class="keyword">return</span> result;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>注意 <code>requestHash</code> 这里,我们不存原始入参(含客户敏感数据),只存 hash,既能追溯”是否同一份输入”,又不泄露客户信息。这个设计是和合规团队磨了好几轮才定的。</p><h2 id="前后端契约:OpenAPI-生成-TS-类型"><a href="#前后端契约:OpenAPI-生成-TS-类型" class="headerlink" title="前后端契约:OpenAPI 生成 TS 类型"></a>前后端契约:OpenAPI 生成 TS 类型</h2><p>最后一个关键点:前后端的类型对齐。手写 TS 类型必然和后端 drift。我们用 springdoc 生成 OpenAPI spec,然后前端自动生成类型:</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"><!-- 后端 pom.xml --></span></span><br><span class="line"><span class="tag"><<span class="name">dependency</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">groupId</span>></span>org.springdoc<span class="tag"></<span class="name">groupId</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">artifactId</span>></span>springdoc-openapi-starter-webmvc-ui<span class="tag"></<span class="name">artifactId</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">version</span>></span>2.3.0<span class="tag"></<span class="name">version</span>></span></span><br><span class="line"><span class="tag"></<span class="name">dependency</span>></span></span><br></pre></td></tr></table></figure><p>前端 monorepo 的 <code>api-client</code> 包里:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 从后端拉 OpenAPI spec,生成 TS 类型</span></span><br><span class="line">openapi-typescript http://localhost:8080/v3/api-docs -o src/types/api.d.ts</span><br></pre></td></tr></table></figure><p>类型不靠人维护,靠工具同步,这是 13 个工具规模下唯一可持续的做法。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>13 个工具收敛到一套 Spring Boot 后端,关键就几件事:multi-module 把模块化分清楚、common-core 统一 API 信封、common-audit 让审计可追溯。不是花哨的架构,但稳。</p><p>下一篇讲计算引擎,金融报表怎么做到”每一张都能复算”。</p>]]>
</content>
<id>https://www.robbs.win/2024-08-19/Spring-Boot-Fintech-Backend.html</id>
<link href="https://www.robbs.win/2024-08-19/Spring-Boot-Fintech-Backend.html"/>
<published>2024-08-19T06:00:00.000Z</published>
<summary>13 个金融工具前端各自拉数据是地狱模式。这篇讲我们怎么用 Spring Boot multi-module 把 API 收敛到一套,统一响应格式、鉴权和审计。</summary>
<title>工具线后端:用 Spring Boot 把 API 收敛起来</title>
<updated>2026-07-08T01:06:19.499Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<category term="Monorepo" scheme="https://www.robbs.win/tags/Monorepo/"/>
<category term="pnpm" scheme="https://www.robbs.win/tags/pnpm/"/>
<category term="前端工程化" scheme="https://www.robbs.win/tags/%E5%89%8D%E7%AB%AF%E5%B7%A5%E7%A8%8B%E5%8C%96/"/>
<content>
<![CDATA[<p>最近我接手了一个蛮有体量的活儿:给理财工具产品线从零搭一套前端工程化底座。不是一两个工具,是 13 个。理财计算、退休规划、税务优化……形态各不相同,但底层数据、UI 风格、计算引擎都得复用。</p><p>13 个工具开 13 个仓库,那不叫工程化,那叫灾难。</p><p>第一天我们就拍板:上 monorepo。这篇讲清楚我们怎么用 pnpm workspace 把这事做扎实,顺带把踩过的坑也摊出来。</p><h2 id="为什么是-pnpm,不是-yarn-lerna"><a href="#为什么是-pnpm,不是-yarn-lerna" class="headerlink" title="为什么是 pnpm,不是 yarn / lerna"></a>为什么是 pnpm,不是 yarn / lerna</h2><p>选型的时候我没纠结太久。理由很直接:</p><ul><li>硬链接省磁盘:13 个工具都装 Vue + Webpack 一整套,yarn/npm 装出来几十 GB;pnpm 的全局 store 让同一个包只占一份磁盘空间。</li><li>严格的依赖隔离:yarn workspace 的 hoisting 会让子包”意外”用上根目录的依赖。pnpm 默认 strict,没在 <code>package.json</code> 里声明的依赖就是 import 不到,避免了一大堆”在我机器上能跑”的玄学问题。</li><li>速度:CI 上 cold install 比 yarn 快将近一倍。</li></ul><p>monorepo 的第一原则:依赖能复用,但绝不能”串味儿”。</p><p>Lerna 2023 年起已经交给 nx 团队维护了,新项目我就不赌它的未来。pnpm 自带的 <code>workspace</code> 协议加上 <code>-r</code> 递归命令,能覆盖我们 90% 的场景。</p><h2 id="workspace-怎么分层"><a href="#workspace-怎么分层" class="headerlink" title="workspace 怎么分层"></a>workspace 怎么分层</h2><p>我们先定了一个分层原则,照着搭目录:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">fintech-suite/</span><br><span class="line">├── apps/ # 13 个可独立部署的工具</span><br><span class="line">│ ├── wealth-calc/ # 某理财计算工具</span><br><span class="line">│ ├── retirement-plan/ # 某退休规划工具</span><br><span class="line">│ ├── tax-optimize/ # 某税务优化工具</span><br><span class="line">│ └── ... # 其余 10 个</span><br><span class="line">├── packages/ # 多工具共享的内部包</span><br><span class="line">│ ├── ui-components/ # 组件库(后面单独写一篇)</span><br><span class="line">│ ├── calc-engine/ # 计算引擎的 JS 绑定层</span><br><span class="line">│ ├── api-client/ # 统一 HTTP 客户端 + 拦截器</span><br><span class="line">│ ├── shared-types/ # TS 类型定义</span><br><span class="line">│ └── eslint-config/ # 共享 ESLint 配置</span><br><span class="line">├── tools/ # 构建/脚本工具</span><br><span class="line">│ └── release-cli/</span><br><span class="line">├── pnpm-workspace.yaml</span><br><span class="line">├── package.json</span><br><span class="line">└── .npmrc</span><br></pre></td></tr></table></figure><p>核心是分了三层:<code>apps</code>(最终产物)、<code>packages</code>(内部依赖)、<code>tools</code>(开发期辅助)。这层分好之后,依赖方向是单向的——<code>apps</code> 依赖 <code>packages</code>,<code>packages</code> 之间可以互相依赖,但绝不能反向。</p><p><code>pnpm-workspace.yaml</code> 就这么几行:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">packages:</span></span><br><span class="line"> <span class="bullet">-</span> <span class="string">'apps/*'</span></span><br><span class="line"> <span class="bullet">-</span> <span class="string">'packages/*'</span></span><br><span class="line"> <span class="bullet">-</span> <span class="string">'tools/*'</span></span><br></pre></td></tr></table></figure><p>这一步看着平淡,但它定义了整个仓库的”拓扑骨架”——后面所有递归命令、依赖解析、构建顺序都从这里推出来。</p><h2 id="依赖管理:catalog-把版本统死"><a href="#依赖管理:catalog-把版本统死" class="headerlink" title="依赖管理:catalog 把版本统死"></a>依赖管理:catalog 把版本统死</h2><p>13 个工具的 Vue 版本必须一致,不然构建出来的行为飘忽不定。早期我们靠人工 review 各子包的 <code>package.json</code>,特别容易漏掉一两个。pnpm 9+ 引入的 <code>catalog:</code> 协议正好治这个病——在根 <code>pnpm-workspace.yaml</code> 里集中声明版本:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">packages:</span></span><br><span class="line"> <span class="bullet">-</span> <span class="string">'apps/*'</span></span><br><span class="line"> <span class="bullet">-</span> <span class="string">'packages/*'</span></span><br><span class="line"> <span class="bullet">-</span> <span class="string">'tools/*'</span></span><br><span class="line"></span><br><span class="line"><span class="attr">catalog:</span></span><br><span class="line"> <span class="attr">vue:</span> <span class="number">2.7</span><span class="number">.16</span></span><br><span class="line"> <span class="attr">vue-router:</span> <span class="number">3.6</span><span class="number">.5</span></span><br><span class="line"> <span class="attr">webpack:</span> <span class="number">5.91</span><span class="number">.0</span></span><br><span class="line"> <span class="attr">sass:</span> <span class="number">1.77</span><span class="number">.0</span></span><br><span class="line"> <span class="attr">'@vitest/runner':</span> <span class="string">^1.6.0</span></span><br></pre></td></tr></table></figure><p>子包里这样引用:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"dependencies"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"vue"</span><span class="punctuation">:</span> <span class="string">"catalog:"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"vue-router"</span><span class="punctuation">:</span> <span class="string">"catalog:"</span></span><br><span class="line"> <span class="punctuation">}</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></table></figure><p><code>catalog:</code> 是 monorepo 里管依赖版本的正确姿势——一次声明,处处生效。</p><p>升级 Vue 小版本时只改根目录一处,所有子包自动跟着走。这在金融业务里尤其重要:金融工具的依赖版本必须可控可追溯,下游审计要的是”全局 Vue 版本是 X.Y.Z”,不是 13 个包各自飘各自的。</p><p><code>.npmrc</code> 也要配几个关键项:</p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 严格隔离,禁止子包通过 hoisting 偷用未声明的依赖</span></span><br><span class="line"><span class="attr">strict-peer-dependencies</span>=<span class="literal">true</span></span><br><span class="line"><span class="comment"># 锁文件统一在根目录</span></span><br><span class="line"><span class="attr">shared-workspace-lockfile</span>=<span class="literal">true</span></span><br><span class="line"><span class="comment"># CI 环境节省时间</span></span><br><span class="line"><span class="attr">prefer-frozen-lockfile</span>=<span class="literal">true</span></span><br></pre></td></tr></table></figure><h2 id="多工具并行构建:turbo-把流水线压短"><a href="#多工具并行构建:turbo-把流水线压短" class="headerlink" title="多工具并行构建:turbo 把流水线压短"></a>多工具并行构建:turbo 把流水线压短</h2><p>光搭好 workspace 还不算完。13 个工具串行 build,CI 上要跑十几分钟,谁也忍不了。我们最后选了 Turbo(Vercel 那个),效果立竿见影:增量构建 + 并行执行,CI 平均从 12 分钟压到 3 分多。</p><p>对 13 个工具的仓库来说,这是”十分钟合入主干”和”卡半天”的区别。</p><p>根目录 <code>turbo.json</code>:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"$schema"</span><span class="punctuation">:</span> <span class="string">"https://turbo.build/schema.json"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"pipeline"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"build"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"dependsOn"</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">"^build"</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"outputs"</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">"dist/**"</span><span class="punctuation">]</span></span><br><span class="line"> <span class="punctuation">}</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"test"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"dependsOn"</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">"build"</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"outputs"</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="punctuation">]</span></span><br><span class="line"> <span class="punctuation">}</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"lint"</span><span class="punctuation">:</span> <span class="punctuation">{</span><span class="punctuation">}</span></span><br><span class="line"> <span class="punctuation">}</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></table></figure><p><code>^build</code> 里那个 <code>^</code> 是关键——它表示”先构建完我的上游依赖包,再构建我”。比如 <code>wealth-calc</code> 依赖 <code>@suite/ui-components</code>,turbo 会自动先 build <code>ui-components</code>,再 build <code>wealth-calc</code>,而且彼此没有依赖关系的工具之间是并行跑的。</p><p>跑全量构建:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">pnpm turbo run build</span><br></pre></td></tr></table></figure><p>更实用的是按需构建——只跑 git diff 涉及的工具及其下游:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 只构建 HEAD^ 之后变更影响到的包</span></span><br><span class="line">pnpm turbo run build --filter=...[HEAD^]</span><br></pre></td></tr></table></figure><p>这个 <code>--filter</code> 在 PR review 时是神器:一个 PR 只动了退休规划工具,CI 就只构建它和它的依赖,其他 12 个工具完全不碰。</p><h2 id="踩过的坑"><a href="#踩过的坑" class="headerlink" title="踩过的坑"></a>踩过的坑</h2><p>坑一:依赖方向反过来。早期我们把一段共享逻辑写在了某个 app 里,结果另外两个 app 开始依赖它,构建顺序就乱了。turbo 报循环依赖,找了半天才定位。教训:共享的东西永远往上提到 <code>packages</code>,<code>apps</code> 之间不互相依赖。</p><p>坑二:catalog 升级要全量回归。有次升级 vue-router 大版本,只跑了改动的那一个工具的测试,结果另外两个工具的 e2e 挂了。后来定了个铁律——catalog 涉及运行时依赖的升级,强制走全量 e2e,不能省。</p><p>坑三:跨 workspace 引用要显式声明协议。pnpm 默认不允许你写个裸版本号去引用 workspace 内的包,必须显式写 <code>workspace:*</code> 或 <code>workspace:^1.0.0</code>:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"dependencies"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"@suite/ui-components"</span><span class="punctuation">:</span> <span class="string">"workspace:*"</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">"@suite/calc-engine"</span><span class="punctuation">:</span> <span class="string">"workspace:^1.0.0"</span></span><br><span class="line"> <span class="punctuation">}</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></table></figure><p>一开始觉得啰嗦,后来发现这是好事——它逼着你每次都明确”这是内部包还是外部包”,少了很多浑水摸鱼的依赖。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>monorepo 这事,结构定了,后面所有事都顺。pnpm 的 workspace + catalog + 严格依赖隔离,加上 Turbo 的并行构建,是 13 个工具规模下我觉得最舒服的组合。</p><p>下一篇我会写工具线后端——13 个工具共用一套 Spring Boot API 是怎么收敛的,敬请期待。</p>]]>
</content>
<id>https://www.robbs.win/2024-06-17/pnpm-Monorepo-Fintech-Suite.html</id>
<link href="https://www.robbs.win/2024-06-17/pnpm-Monorepo-Fintech-Suite.html"/>
<published>2024-06-17T02:00:00.000Z</published>
<summary>13 个金融计算工具塞进一个仓库,pnpm workspace 是怎么把依赖收敛、构建提速的——讲清楚 workspace 结构、依赖管理和多工具并行构建。</summary>
<title>13 个工具一个仓库:pnpm Monorepo 怎么搭</title>
<updated>2026-07-08T01:06:19.465Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<content>
<![CDATA[<p>2024 年春天,我在整理 DAL 小组的工作总结。从 2021 年 11 月到这个时候,两年半的时间,这个小组从零搭起了一套数据库治理体系。这篇算是个复盘,把”怎么搭起来的”这个脉络理清楚,给后来人留个参考,也给自己留个记号。</p><h2 id="起点:一团乱麻"><a href="#起点:一团乱麻" class="headerlink" title="起点:一团乱麻"></a>起点:一团乱麻</h2><p>2021 年底我刚进 DAL 小组的时候,公司的数据库访问状况可以用四个字形容:各自为战。</p><ul><li>十几个 Java 服务,连接池配置五花八门,有的用 HikariCP,有的用老掉牙的 c3p0,有的干脆裸连。</li><li>Python 服务更乱,有的用 SQLAlchemy,有的用 pymysql 直接连。</li><li>没有统一的慢查询监控。出了问题靠用户投诉和 DBA 看 processlist。</li><li>DDL 变更没有审核流程,谁想改就改,好几次差点出大事。</li><li>多租户隔离靠业务代码自觉,数据串号的隐患一直悬着。</li></ul><p>那时候谈不上治理,全是救火。再不抓就要出大事。</p><h2 id="搭体系的顺序"><a href="#搭体系的顺序" class="headerlink" title="搭体系的顺序"></a>搭体系的顺序</h2><p>回顾这两年半,我们做的事其实有一个清晰的顺序。这个顺序是 dependencies 决定的。</p><h3 id="第一步:变更审核(地基)"><a href="#第一步:变更审核(地基)" class="headerlink" title="第一步:变更审核(地基)"></a>第一步:变更审核(地基)</h3><p>时间:2021 年底到 2022 年初。</p><p>理由很简单:变更不停,事故不止。不先把变更审核流程建起来,SDK 做得再好也白搭,因为一个 <code>ALTER TABLE</code> 就能把线上搞挂。</p><p>具体怎么做,前面那篇《DAL 小组:数据库变更审核怎么做》讲过了。核心就是工单 + 评审会 + 执行复核。</p><p>先止血,再治病。这是体系搭建的铁律。</p><h3 id="第二步:Java-SDK(主力收口)"><a href="#第二步:Java-SDK(主力收口)" class="headerlink" title="第二步:Java SDK(主力收口)"></a>第二步:Java SDK(主力收口)</h3><p>时间:2022 年上半年。</p><p>变更审核把”乱改”的问题摁住了,接下来要解决”乱连”的问题。</p><p>先做 Java 版,因为 Java 是主力语言。HikariCP 做连接池,自研读写分离,慢查询自动上报。半年时间,十几个 Java 服务全部接入了 dal-java SDK。</p><p>这一步的价值是让基础设施的升级能一次推到所有服务。以前改个连接池参数得求着十几个团队,现在改 SDK 配置中心一处,全部生效。</p><h3 id="第三步:Python-SDK(覆盖长尾)"><a href="#第三步:Python-SDK(覆盖长尾)" class="headerlink" title="第三步:Python SDK(覆盖长尾)"></a>第三步:Python SDK(覆盖长尾)</h3><p>时间:2022 年下半年。</p><p>Java 收口之后,Python 那边的”裸连”就显得特别扎眼。Python 服务虽然少,但数据访问的诉求一样:连接池、读写分离、慢查询监控。</p><p>用 SQLAlchemy 独立搭建,行为和 Java 版对齐。难点不在写代码,在保证两端的行为契约一致:同样的读写分离规则、同样的慢查询阈值、同样的配置来源。</p><h3 id="第四步:慢查询治理(持续运动)"><a href="#第四步:慢查询治理(持续运动)" class="headerlink" title="第四步:慢查询治理(持续运动)"></a>第四步:慢查询治理(持续运动)</h3><p>时间:2023 年全年,一直延续。</p><p>SDK 把慢查询采集和上报建好之后,剩下的就是”持续优化”的活。每周从 Top 慢查询里挑几条出来,EXPLAIN 分析,加索引、改 SQL。</p><p>这件事没什么技巧,就是持之以恒。做了半年之后,线上 P99 慢查询数量降了一个数量级。</p><h3 id="第五步:多租户隔离固化(查漏补缺)"><a href="#第五步:多租户隔离固化(查漏补缺)" class="headerlink" title="第五步:多租户隔离固化(查漏补缺)"></a>第五步:多租户隔离固化(查漏补缺)</h3><p>时间:2023 年中。</p><p>多租户隔离方案从系统早期就存在,但原来靠业务自觉。DAL 小组把它下沉到 SDK 层:自动注入 <code>tenant_id</code>,不设上下文就报错。把”约定”升级成”强制”。</p><h3 id="第六步:分库分表调研(论证)"><a href="#第六步:分库分表调研(论证)" class="headerlink" title="第六步:分库分表调研(论证)"></a>第六步:分库分表调研(论证)</h3><p>时间:2023 年下半年。</p><p>业务量涨上来之后,”要不要分库分表”的问题被反复问。我们花了半年做调研和试点,最后结论是不上中间件,靠优化 + 读写分离 + 缓存扛住。这个决策的逻辑前面那篇讲过了。</p><h2 id="体系的脉络"><a href="#体系的脉络" class="headerlink" title="体系的脉络"></a>体系的脉络</h2><p>把上面六步串起来,就是这套数据库治理体系的脉络:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">变更审核(止血)</span><br><span class="line"> ↓</span><br><span class="line">Java SDK(主力收口)</span><br><span class="line"> ↓</span><br><span class="line">Python SDK(覆盖长尾)</span><br><span class="line"> ↓</span><br><span class="line">读写分离 + 慢查询监控(SDK 内建能力)</span><br><span class="line"> ↓</span><br><span class="line">慢查询治理(持续运动)</span><br><span class="line"> ↓</span><br><span class="line">多租户隔离固化(查漏补缺)</span><br><span class="line"> ↓</span><br><span class="line">分库分表调研(论证边界)</span><br></pre></td></tr></table></figure><p>体系不是一天建成的,是一层一层叠上去的。每一步都为下一步铺路。</p><h2 id="哪些做对了"><a href="#哪些做对了" class="headerlink" title="哪些做对了"></a>哪些做对了</h2><p>回头看,有几个决策我觉得是对的:</p><ol><li><p>先做变更审核,再做 SDK。如果反过来,SDK 还没做完,线上已经被乱改的 DDL 搞挂了。</p></li><li><p>Java 先做,Python 后做。先把主力语言收口,趟一遍雷,再做 Python。顺序清楚,风险可控。</p></li><li><p>读写分离自研,不上中间件。省了运维成本,避免了单点风险,Python 也能用同一套逻辑。</p></li><li><p>没上分库分表。这是最容易被质疑的决策,但事后看是对的。复杂度债务能不借就不借。</p></li></ol><h2 id="哪些可以做得更好"><a href="#哪些可以做得更好" class="headerlink" title="哪些可以做得更好"></a>哪些可以做得更好</h2><p>也有遗憾:</p><ol><li><p>Python 版动手太晚。Java 版做完后拖了大半年才动 Python。中间那段时间 Python 服务还在裸连,等于多冒了半年的险。理想情况应该 Java 版稳定后就立刻铺 Python。</p></li><li><p>慢查询治理断断续续。中间有段时间大家忙别的,Top 慢查询的优化停了两周。慢查询积了二十多条,花了一个月才清完。持续运动这件事,停下来再启动成本很高。</p></li><li><p>监控告警阈值不精细。一开始慢查询阈值统一设 200ms,后来发现不同业务该有不同的阈值。精细化调整做晚了。</p></li></ol><h2 id="体系的本质"><a href="#体系的本质" class="headerlink" title="体系的本质"></a>体系的本质</h2><p>做了这两年半,我对”体系”这个词的理解变了。</p><p>以前觉得体系就是一堆工具和流程的集合。现在我觉得,体系的本质是”让正确的事情变容易,让错误的事情变困难”。</p><ul><li>变更审核:让”乱改 DDL”变困难。</li><li>SDK 收口:让”乱连数据库”变困难。</li><li>多租户隔离下沉:让”漏带 tenant_id”变困难。</li><li>慢查询监控:让”忽略性能问题”变困难。</li></ul><p>好的体系靠机制拦住错误,不靠人盯。</p><p>人盯会累、会忘、会换岗。机制不会。一个团队的人会流动,但体系留下来,新来的人照着走就行。</p><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>DAL 小组的这套体系不算先进,没有什么原创架构,都是业内成熟方案的组合。但它的价值在于完整和持续:从变更到 SDK 到监控到治理,一条线走下来,没有断层。</p><p>技术体系的价值不在单点的牛逼,在整体的协同。</p><p>两年半,从一团乱麻到一套能跑的体系。这个过程让我学到的东西,比任何一本书都多。</p>]]>
</content>
<id>https://www.robbs.win/2024-04-15/DAL-Two-And-A-Half-Years-Retrospective.html</id>
<link href="https://www.robbs.win/2024-04-15/DAL-Two-And-A-Half-Years-Retrospective.html"/>
<published>2024-04-15T02:00:00.000Z</published>
<summary>DAL 小组两年半,从变更审核到跨语言 SDK 到慢查询治理,数据库治理这套体系怎么一步步搭起来的,复盘一下。</summary>
<title>DAL 两年半:数据库治理这套体系怎么搭起来的</title>
<updated>2026-07-08T01:06:19.432Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<content>
<![CDATA[<p>2023 年那会儿,公司业务量涨得挺猛,有几张核心表的数据量往亿级走。业务方开始焦虑:要不要分库分表?要不要上 ShardingSphere?</p><p>DAL 小组花了大概半年做调研和小范围试点,最后给出的结论是:现阶段不上分库分表中间件,自研读写分离 + Redis 缓存够用。</p><p>这篇就把调研过程和决策逻辑摊开讲。不打算给”该不该分库分表”下通用结论,只是讲我们当时怎么权衡的。</p><h2 id="先搞清楚问题"><a href="#先搞清楚问题" class="headerlink" title="先搞清楚问题"></a>先搞清楚问题</h2><p>调研的第一步不是看方案,是搞清楚到底有没有问题。</p><p>业务方说”数据量大了”,但”大了”不等于”有问题”。MySQL 单表能扛多少数据?这个问题的答案取决于很多因素:</p><ul><li>表结构设计合不合理</li><li>索引建得好不好</li><li>查询模式是点查还是范围查、是 OLTP 还是 OLAP</li><li>硬件配置</li></ul><p>我们拉了一下当时的数据:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 看几张核心表的数据量</span></span><br><span class="line"><span class="keyword">SELECT</span> table_name, table_rows, ROUND(data_length<span class="operator">/</span><span class="number">1024</span><span class="operator">/</span><span class="number">1024</span>, <span class="number">2</span>) <span class="keyword">AS</span> data_mb,</span><br><span class="line"> ROUND(index_length<span class="operator">/</span><span class="number">1024</span><span class="operator">/</span><span class="number">1024</span>, <span class="number">2</span>) <span class="keyword">AS</span> index_mb</span><br><span class="line"><span class="keyword">FROM</span> information_schema.tables</span><br><span class="line"><span class="keyword">WHERE</span> table_schema <span class="operator">=</span> <span class="string">'t_xxx'</span>;</span><br></pre></td></tr></table></figure><p>结果:最大的表 1.2 亿行,数据 45GB,索引 18GB。B+树高度 3-4 层,单次点查走索引的话延迟在 10ms 以内。</p><p>说实话,这个量级 MySQL 单表完全扛得住。1 亿行在 InnoDB 里不算什么,只要索引和查询模式没大问题。</p><p>很多时候”数据量大了”其实是”慢查询多了”,根儿在索引和 SQL 写得烂,不在数据量本身。</p><h2 id="分库分表的代价"><a href="#分库分表的代价" class="headerlink" title="分库分表的代价"></a>分库分表的代价</h2><p>但既然要调研,就得把”如果分了会怎样”想清楚。分库分表的代价比大多数人想象的大得多。</p><h3 id="1-事务没了"><a href="#1-事务没了" class="headerlink" title="1. 事务没了"></a>1. 事务没了</h3><p>跨库的事务 MySQL 原生不支持。要么用 XA 分布式事务(性能差、复杂),要么走 TCC / Saga 的应用层补偿(开发量大)。</p><p>我们核心交易链路强依赖事务。如果分库了,一笔订单涉及订单库、库存库、账户库,原来一个 <code>BEGIN ... COMMIT</code> 搞定的事,现在要写一堆补偿逻辑。开发和维护成本翻几倍。</p><h3 id="2-JOIN-没了"><a href="#2-JOIN-没了" class="headerlink" title="2. JOIN 没了"></a>2. JOIN 没了</h3><p>跨库 JOIN 基本做不了。原来一条 SQL 能查出来的数据,分库后要么走多次查询再在应用层组装,要么做数据冗余。</p><p>数据冗余意味着数据一致性维护成本,A 库的数据更新了,B 库的冗余副本怎么同步?又是 binlog 订阅、异步同步、最终一致性那一套。</p><h3 id="3-运维复杂度飙升"><a href="#3-运维复杂度飙升" class="headerlink" title="3. 运维复杂度飙升"></a>3. 运维复杂度飙升</h3><ul><li>备份恢复:从单库备份变成多库协调备份。</li><li>DDL 变更:一个 <code>ALTER TABLE</code> 要在所有分片上执行。</li><li>数据迁移:扩容、缩容、rebalance,每一步都是大工程。</li><li>监控:要监控每个分片的健康状态。</li></ul><p>这些事情不是 DBA 加几个人就能解决的,整个运维体系要重构。</p><h3 id="4-ShardingSphere-的坑"><a href="#4-ShardingSphere-的坑" class="headerlink" title="4. ShardingSphere 的坑"></a>4. ShardingSphere 的坑</h3><p>我们做了 ShardingSphere-JDBC 的小范围试点。功能确实强,但踩了几个坑:</p><ul><li>复杂 SQL 解析偶有问题。子查询嵌套三层以上,路由结果偶尔不对。</li><li>运维工具链不成熟。ShardingSphere-Scaling 做数据迁移,当时还不太稳定。</li><li>学习成本高。团队里能玩转的人不多,出了问题排查困难。</li></ul><h2 id="不分库,靠什么扛"><a href="#不分库,靠什么扛" class="headerlink" title="不分库,靠什么扛"></a>不分库,靠什么扛</h2><p>既然决定不分,那怎么扛住业务增长?我们的方案是三板斧:优化现有查询、读写分离、Redis 缓存。</p><h3 id="第一斧:SQL-和索引优化"><a href="#第一斧:SQL-和索引优化" class="headerlink" title="第一斧:SQL 和索引优化"></a>第一斧:SQL 和索引优化</h3><p>这个前面慢查询治理那篇讲过了。EXPLAIN + 加索引,把大量全表扫描的慢查询优化掉。光这一步,数据库压力就降了一半。</p><p>很多业务方以为”数据库慢了就该分库”,其实 80% 的慢是 SQL 写得烂。把 SQL 治好了,单表扛到 2-3 亿行没压力。</p><h3 id="第二斧:读写分离"><a href="#第二斧:读写分离" class="headerlink" title="第二斧:读写分离"></a>第二斧:读写分离</h3><p>主写从读,前面也讲过了。读流量大头在报表和列表查询,这些走从库,主库压力直接减半。</p><h3 id="第三斧:Redis-缓存"><a href="#第三斧:Redis-缓存" class="headerlink" title="第三斧:Redis 缓存"></a>第三斧:Redis 缓存</h3><p>热数据放 Redis,MySQL 只在缓存 miss 的时候被命中。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 典型的 cache-aside 模式</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">get_order</span>(<span class="params">order_id</span>):</span><br><span class="line"> <span class="comment"># 先查 Redis</span></span><br><span class="line"> cached = redis.get(<span class="string">f"order:<span class="subst">{order_id}</span>"</span>)</span><br><span class="line"> <span class="keyword">if</span> cached:</span><br><span class="line"> <span class="keyword">return</span> json.loads(cached)</span><br><span class="line"> <span class="comment"># 缓存 miss,查 MySQL</span></span><br><span class="line"> order = db.query(Order).filter_by(<span class="built_in">id</span>=order_id).first()</span><br><span class="line"> <span class="keyword">if</span> order:</span><br><span class="line"> redis.setex(<span class="string">f"order:<span class="subst">{order_id}</span>"</span>, <span class="number">300</span>, json.dumps(order.to_dict()))</span><br><span class="line"> <span class="keyword">return</span> order</span><br></pre></td></tr></table></figure><p>缓存策略上我们的经验:</p><ul><li>过期时间加随机抖动,防止缓存雪崩。</li><li>热点 key 单独保护,用本地缓存兜一层。</li><li>缓存与数据库一致性靠 binlog 订阅 + 主动删除,不做复杂更新。</li></ul><p>缓存的本质是用内存换 CPU 和 IO。内存比硬盘贵,但比分库分表便宜得多。</p><h2 id="什么时候才该分"><a href="#什么时候才该分" class="headerlink" title="什么时候才该分"></a>什么时候才该分</h2><p>我们没有说”永远不分库分表”。当时给了一个明确的触发条件:</p><ul><li>单表数据超过 5 亿行,且通过 SQL 优化、读写分离、缓存三板斧仍无法满足性能要求。</li><li>出现明确的垂直拆分边界,某些业务模块的数据访问模式和其他模块差异巨大,可以独立成一个库。</li></ul><p>这两个条件目前都没触发。等到触发的那一天,我们会重新评估。</p><h2 id="决策的逻辑"><a href="#决策的逻辑" class="headerlink" title="决策的逻辑"></a>决策的逻辑</h2><p>把决策逻辑写清楚:</p><table><thead><tr><th>方案</th><th>成本</th><th>收益</th><th>风险</th></tr></thead><tbody><tr><td>分库分表 + 中间件</td><td>高(开发+运维翻倍)</td><td>理论上的线性扩展</td><td>事务/JOIN 丢失、运维复杂</td></tr><tr><td>SQL优化 + 读写分离 + 缓存</td><td>低(现有体系内)</td><td>够用,扛到 2-3 亿行</td><td>缓存一致性、从库延迟</td></tr></tbody></table><p>够用就别加复杂度,不是说先进方案就该用。</p><p>软件工程里有一条不成文的规律:复杂度是债务,能不借就不借。分库分表借的是一大笔复杂度债,不到万不得已不要借。</p><h2 id="结论"><a href="#结论" class="headerlink" title="结论"></a>结论</h2><p>调研报告交上去之后,CTO 拍板:现阶段不分。DAL 小组继续把读写分离和缓存做扎实,把慢查询治理持续推下去。</p><p>回头看,这个决策是对的。又过了一年多,业务还在涨,但靠优化和缓存扛住了,没有分库分表带来的运维噩梦。</p><p>很多技术决策的关键在于”当前阶段的成本和收益哪个最匹配”,而不是”哪个方案更先进”。这是我做 DAL 调研最大的体会。</p>]]>
</content>
<id>https://www.robbs.win/2023-11-13/Why-No-Sharding-Middleware.html</id>
<link href="https://www.robbs.win/2023-11-13/Why-No-Sharding-Middleware.html"/>
<published>2023-11-13T07:00:00.000Z</published>
<summary>分库分表做了大半年调研和试点,最后结论是不上中间件。成本、收益、风险怎么权衡的,这篇文章交底。</summary>
<title>分库分表调研:为什么我们最后没上中间件</title>
<updated>2026-07-08T01:06:19.401Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<content>
<![CDATA[<p>这篇文章我想讲一件容易被误解的事:SaaS 多租户的数据库隔离不是一个”项目”,不是某个时间点立项、设计、上线的东西,而是一套从系统诞生之初就存在的方案,贯穿至今。</p><p>DAL 小组 2021 年底成立的时候,这套隔离方案已经在跑了。我们做的事是把它在 SDK 层固化下来,让它更规范、更不容易出错,而不是从零设计。</p><h2 id="多租户隔离的几种路子"><a href="#多租户隔离的几种路子" class="headerlink" title="多租户隔离的几种路子"></a>多租户隔离的几种路子</h2><p>先说背景。SaaS 多租户的数据隔离,业界通常三种模式:</p><ol><li>独立数据库:每个租户一个库。隔离最好,成本最高。</li><li>共享数据库、独立 Schema:一个库里多个 Schema,每个租户一个 Schema。隔离中等,管理复杂。</li><li>共享数据库、共享表:所有租户的数据在同一张表里,用 <code>tenant_id</code> 区分。成本最低,隔离靠应用层保证。</li></ol><p>我们公司走的是第三种,共享表 + <code>tenant_id</code>。原因很实际:客户量大,独立数据库扛不住成本。一个库一个库地维护、备份、监控,光是 DBA 的工时就吃不消。</p><p>隔离方案不是”哪个最好”的问题,而是”哪个你养得起”的问题。</p><h2 id="方案一直存在"><a href="#方案一直存在" class="headerlink" title="方案一直存在"></a>方案一直存在</h2><p>我想强调的是:这套共享表 + tenant_id 的方案,在 DAL 小组成立之前就一直在用了。不是我们发明的,是整个系统从早期就奠定的架构选择。</p><p>具体怎么做:</p><ul><li>每张业务表都有一个 <code>tenant_id</code> 字段。</li><li>所有查询必须带 <code>tenant_id</code> 条件。不带 <code>tenant_id</code> 的查询就是 bug。</li><li>应用层在每次请求的上下文里带上当前租户 ID,SDK 在生成 SQL 时自动追加 <code>tenant_id</code> 条件。</li></ul><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 应用层写的 SQL(业务代码)</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> t_xxx_orders <span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">12345</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- SDK 实际执行的 SQL(自动追加了 tenant_id)</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> t_xxx_orders</span><br><span class="line"><span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">12345</span> <span class="keyword">AND</span> tenant_id <span class="operator">=</span> <span class="string">'current_tenant'</span>;</span><br></pre></td></tr></table></figure><p>这种”SDK 层强制注入 tenant_id”的做法,是 DAL 小组的贡献。在这之前,tenant_id 是靠业务代码自觉加的,而”自觉”这种东西,迟早会出漏子。</p><h2 id="Job-按客户隔离"><a href="#Job-按客户隔离" class="headerlink" title="Job 按客户隔离"></a>Job 按客户隔离</h2><p>这里有个关键细节:离线任务(Job)按客户访问隔离数据库。</p><p>什么意思?SaaS 系统除了在线请求,还有大量后台任务,报表生成、数据清洗、定时推送等等。这些任务如果混在一起跑,一个大客户的报表任务占满了数据库连接,小客户的在线请求就会被拖慢。</p><p>我们的做法是:Job 执行时,按客户维度隔离数据库访问。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Job 执行框架的租户隔离配置(脱敏)</span></span><br><span class="line"><span class="attr">job:</span></span><br><span class="line"> <span class="attr">isolation:</span></span><br><span class="line"> <span class="attr">mode:</span> <span class="string">per-tenant</span></span><br><span class="line"> <span class="attr">pool:</span></span><br><span class="line"> <span class="attr">per-tenant-max-connections:</span> <span class="number">5</span> <span class="comment"># 每个租户最多 5 个数据库连接</span></span><br><span class="line"> <span class="attr">schedule:</span></span><br><span class="line"> <span class="attr">stagger:</span> <span class="literal">true</span> <span class="comment"># 不同租户的 Job 错峰执行</span></span><br></pre></td></tr></table></figure><p>效果:一个租户的 Job 不管怎么跑,最多占 5 个连接,不会把别的租户的在线请求挤死。</p><p>资源隔离是多租户系统活下去的根本。不隔离,一个大客户就能把所有人都拖下水。</p><p>这种按租户限流的思路,跟在线请求的限流是一脉相承的。在线请求按租户限流,离线 Job 也按租户限流,逻辑一致。</p><h2 id="SDK-层怎么固化"><a href="#SDK-层怎么固化" class="headerlink" title="SDK 层怎么固化"></a>SDK 层怎么固化</h2><p>DAL 小组做的核心改进,是把这套隔离方案从”靠自觉”变成”靠框架”。</p><h3 id="Java-SDK-层"><a href="#Java-SDK-层" class="headerlink" title="Java SDK 层"></a>Java SDK 层</h3><p>在 SQL 执行前拦截,自动注入 <code>tenant_id</code>:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">TenantInterceptor</span> <span class="keyword">implements</span> <span class="title class_">Interceptor</span> {</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">public</span> Object <span class="title function_">intercept</span><span class="params">(Invocation invocation)</span> <span class="keyword">throws</span> Throwable {</span><br><span class="line"> <span class="type">String</span> <span class="variable">tenantId</span> <span class="operator">=</span> TenantContext.get();</span><br><span class="line"> <span class="keyword">if</span> (tenantId == <span class="literal">null</span>) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">DalException</span>(<span class="string">"Tenant context not set"</span>);</span><br><span class="line"> }</span><br><span class="line"> <span class="comment">// 拿到原始 SQL,追加 tenant_id 条件</span></span><br><span class="line"> <span class="type">String</span> <span class="variable">sql</span> <span class="operator">=</span> invocation.getSql();</span><br><span class="line"> <span class="type">String</span> <span class="variable">rewritten</span> <span class="operator">=</span> TenantSqlRewriter.rewrite(sql, tenantId);</span><br><span class="line"> invocation.setSql(rewritten);</span><br><span class="line"> <span class="keyword">return</span> invocation.proceed();</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>如果 TenantContext 没有设值,直接抛异常。这是强制的,宁可报错,也不允许”忘了带 tenant_id”的查询混过去。</p><h3 id="Python-SDK-层"><a href="#Python-SDK-层" class="headerlink" title="Python SDK 层"></a>Python SDK 层</h3><p>Python 版用 SQLAlchemy 的 Session 事件做同样的事:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@event.listens_for(<span class="params">Session, <span class="string">"do_orm_execute"</span></span>)</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">_add_tenant_filter</span>(<span class="params">execute_state</span>):</span><br><span class="line"> tenant_id = TenantContext.get()</span><br><span class="line"> <span class="keyword">if</span> tenant_id <span class="keyword">is</span> <span class="literal">None</span>:</span><br><span class="line"> <span class="keyword">raise</span> DalException(<span class="string">"Tenant context not set"</span>)</span><br><span class="line"> execute_state.statement = execute_state.statement.where(</span><br><span class="line"> execute_state.statement.column_descriptions[<span class="number">0</span>][<span class="string">"entity"</span>].tenant_id == tenant_id</span><br><span class="line"> )</span><br></pre></td></tr></table></figure><p>两端的行为一致:没设 tenant 上下文就报错,设了就自动追加过滤。</p><h2 id="踩过的坑"><a href="#踩过的坑" class="headerlink" title="踩过的坑"></a>踩过的坑</h2><h3 id="坑一:跨租户查询"><a href="#坑一:跨租户查询" class="headerlink" title="坑一:跨租户查询"></a>坑一:跨租户查询</h3><p>有些后台管理功能需要查所有租户的数据(比如运营看全局数据)。这时候 <code>tenant_id</code> 过滤反而成了障碍。</p><p>我们的解决方法:给运营后台开一个”超级管理员”模式,可以绕过 tenant 过滤。但这个模式有严格的权限控制和审计日志,不是谁都能用。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 只有特定角色能跳过 tenant 过滤</span></span><br><span class="line"><span class="keyword">if</span> (CurrentUser.isSuperAdmin()) {</span><br><span class="line"> <span class="comment">// 不追加 tenant_id</span></span><br><span class="line">} <span class="keyword">else</span> {</span><br><span class="line"> <span class="comment">// 强制追加</span></span><br><span class="line">}</span><br></pre></td></tr></table></figure><h3 id="坑二:JOIN-忘了带-tenant-id"><a href="#坑二:JOIN-忘了带-tenant-id" class="headerlink" title="坑二:JOIN 忘了带 tenant_id"></a>坑二:JOIN 忘了带 tenant_id</h3><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 单表查询 SDK 能自动加 tenant_id</span></span><br><span class="line"><span class="comment">-- 但 JOIN 的时候,如果只给主表加了,关联表忘了加,就会泄露其他租户的数据</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> t_xxx_orders o</span><br><span class="line"><span class="keyword">JOIN</span> t_xxx_items i <span class="keyword">ON</span> o.id <span class="operator">=</span> i.order_id</span><br><span class="line"><span class="keyword">WHERE</span> o.user_id <span class="operator">=</span> <span class="number">12345</span>;</span><br></pre></td></tr></table></figure><p>SDK 的 SQL 重写器必须处理 JOIN 场景,给所有涉及的业务表都追加 <code>tenant_id</code> 条件。这一块的实现比单表复杂得多,我们踩了几次”数据泄露”的虚惊之后才把逻辑补全。</p><p>多租户系统的数据泄露,往往不是因为黑客攻击,而是因为 JOIN 忘了带条件。</p><h2 id="这套方案的价值"><a href="#这套方案的价值" class="headerlink" title="这套方案的价值"></a>这套方案的价值</h2><p>这套方案的价值不在”技术多先进”,而在”一直被执行”。</p><p>很多公司的多租户隔离是写在文档里的,”请大家务必带 tenant_id”,然后某天某个新人忘了,数据就串了。我们把它下沉到 SDK 层之后,这种事基本绝迹了。你不带 tenant_id,代码直接跑不通,根本到不了生产。</p><p>好的架构不是靠人记住的,而是让犯错变得不可能。</p><p>这套隔离方案从系统早期一直用到现在,DAL 小组只是把它从”约定”升级成了”强制”。这个升级看似不性感,但它消除了多租户系统里最危险的一类风险,也就是人为失误导致的数据串号。</p>]]>
</content>
<id>https://www.robbs.win/2023-07-18/Multi-Tenant-DB-Isolation.html</id>
<link href="https://www.robbs.win/2023-07-18/Multi-Tenant-DB-Isolation.html"/>
<published>2023-07-18T03:00:00.000Z</published>
<summary>多租户数据库隔离不是某个时间点新建的方案,而是从 DAL 小组成立之前就一直存在的做法,贯穿整个系统。</summary>
<title>SaaS 多租户:数据库隔离这套方案我们一直用</title>
<updated>2026-07-08T01:06:19.366Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<content>
<![CDATA[<p>前面讲了慢查询监控怎么搭。监控搭好之后,每周从 Top 慢查询里挑几条出来优化——这就是”慢查询治理”。</p><p>做了半年之后我发现一件事:大多数慢查询的根因就那几类。索引没建、索引建了但没用上、SQL 写法导致优化器选错执行计划。你不需要是什么 MySQL 源码级专家,只要能读懂 <code>EXPLAIN</code> 的输出,就能解决 80% 的慢查询。</p><p>这篇讲怎么读 EXPLAIN,以及我们治过的几种典型慢查询。</p><h2 id="EXPLAIN-怎么读"><a href="#EXPLAIN-怎么读" class="headerlink" title="EXPLAIN 怎么读"></a>EXPLAIN 怎么读</h2><p>随便拿一条 SQL:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> t_xxx_orders</span><br><span class="line"><span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">12345</span> <span class="keyword">AND</span> status <span class="operator">=</span> <span class="number">1</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> created_at <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">20</span>;</span><br></pre></td></tr></table></figure><p><code>EXPLAIN</code> 一下:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">EXPLAIN <span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> t_xxx_orders</span><br><span class="line"><span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">12345</span> <span class="keyword">AND</span> status <span class="operator">=</span> <span class="number">1</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> created_at <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">20</span>;</span><br></pre></td></tr></table></figure><p>输出大概长这样:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">+----+-------------+-------------+------+---------------+------+---------+------+----------+-----------------------------+</span><br><span class="line">| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |</span><br><span class="line">+----+-------------+-------------+------+---------------+------+---------+------+----------+-----------------------------+</span><br><span class="line">| 1 | SIMPLE | t_xxx_orders| ALL | NULL | NULL | NULL | NULL | 8500000 | Using where; Using filesort |</span><br><span class="line">+----+-------------+-------------+------+---------------+------+---------+------+----------+-----------------------------+</span><br></pre></td></tr></table></figure><p>这条 SQL 是典型的慢查询。逐列讲:</p><h3 id="type-列:访问类型"><a href="#type-列:访问类型" class="headerlink" title="type 列:访问类型"></a>type 列:访问类型</h3><p>这是最重要的列。从好到差:</p><ul><li><code>system</code> / <code>const</code>:通过主键或唯一索引等值查询,最快。</li><li><code>eq_ref</code>:JOIN 时用主键或唯一索引匹配,一行对一行。</li><li><code>ref</code>:通过普通索引等值查询。</li><li><code>range</code>:索引范围扫描(<code>></code>, <code><</code>, <code>BETWEEN</code>, <code>IN</code>)。</li><li><code>index</code>:扫描整个索引树。</li><li><code>ALL</code>:全表扫描,最差。</li></ul><p>上面那个例子 <code>type = ALL</code>,说明没走任何索引,扫了 850 万行。这就是问题所在。</p><p>看到 <code>type = ALL</code> 基本就可以判定索引有问题了。</p><h3 id="key-列:实际用的索引"><a href="#key-列:实际用的索引" class="headerlink" title="key 列:实际用的索引"></a>key 列:实际用的索引</h3><p><code>possible_keys</code> 列出”可能用到的索引”,<code>key</code> 是”实际用了哪个”。如果 <code>possible_keys</code> 有值但 <code>key</code> 是 NULL,说明优化器选了全表扫描——这时候得想想为什么。</p><h3 id="rows-列:预估扫描行数"><a href="#rows-列:预估扫描行数" class="headerlink" title="rows 列:预估扫描行数"></a>rows 列:预估扫描行数</h3><p>这个数字是优化器的估算值,越小越好。一条查询如果 rows 是百万级,不管别的列好不好看,基本都是慢查询。</p><h3 id="Extra-列:附加信息"><a href="#Extra-列:附加信息" class="headerlink" title="Extra 列:附加信息"></a>Extra 列:附加信息</h3><p>这里面藏的信息很关键。常见的:</p><ul><li><code>Using where</code>:用了 WHERE 条件过滤(正常)。</li><li><code>Using index</code>:覆盖索引,不用回表,好东西。</li><li><code>Using filesort</code>:需要额外的排序操作,通常意味着 ORDER BY 没走索引。</li><li><code>Using temporary</code>:用了临时表,常见于 GROUP BY、DISTINCT,要警惕。</li></ul><p>上面那个例子 <code>Extra = Using where; Using filesort</code>,说明既有全表扫描又有额外排序,双重打击。</p><h2 id="治过的几种典型慢查询"><a href="#治过的几种典型慢查询" class="headerlink" title="治过的几种典型慢查询"></a>治过的几种典型慢查询</h2><h3 id="类型一:没建索引"><a href="#类型一:没建索引" class="headerlink" title="类型一:没建索引"></a>类型一:没建索引</h3><p>就是上面那条 SQL。解决方法很简单:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE</span> INDEX idx_uid_status_created <span class="keyword">ON</span> t_xxx_orders(user_id, status, created_at);</span><br></pre></td></tr></table></figure><p>联合索引把 <code>WHERE</code> 条件的 <code>user_id</code> 和 <code>status</code> 放前面,<code>ORDER BY</code> 的 <code>created_at</code> 放最后。加完之后再看 EXPLAIN:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">+----+-------------+-------------+-------+----------------------+----------------------+---------+------+------+--------------------------+</span><br><span class="line">| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |</span><br><span class="line">+----+-------------+-------------+-------+----------------------+----------------------+---------+------+------+--------------------------+</span><br><span class="line">| 1 | SIMPLE | t_xxx_orders| ref | idx_uid_status_created| idx_uid_status_created| 12 | const| 120 | Using where; Using index |</span><br><span class="line">+----+-------------+-------------+-------+----------------------+----------------------+---------+------+------+--------------------------+</span><br></pre></td></tr></table></figure><p><code>type</code> 从 ALL 变成 <code>ref</code>,<code>rows</code> 从 850 万降到 120,<code>Extra</code> 出现了 <code>Using index</code>(覆盖索引)。扫描行数降了 7 万倍,查询从 2 秒变成 2 毫秒。</p><p>加一个索引解决 99% 的性能问题,这句话不夸张。</p><h3 id="类型二:索引建了但没用上"><a href="#类型二:索引建了但没用上" class="headerlink" title="类型二:索引建了但没用上"></a>类型二:索引建了但没用上</h3><p>这条 SQL:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> t_xxx_orders <span class="keyword">WHERE</span> <span class="type">DATE</span>(created_at) <span class="operator">=</span> <span class="string">'2023-03-01'</span>;</span><br></pre></td></tr></table></figure><p>表上有 <code>created_at</code> 的索引,但 EXPLAIN 显示走了全表扫描。为什么?</p><p>因为对索引列用了函数。<code>DATE(created_at)</code> 让优化器无法直接走索引。改成:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> t_xxx_orders</span><br><span class="line"><span class="keyword">WHERE</span> created_at <span class="operator">>=</span> <span class="string">'2023-03-01'</span> <span class="keyword">AND</span> created_at <span class="operator"><</span> <span class="string">'2023-03-02'</span>;</span><br></pre></td></tr></table></figure><p>立刻走索引了。</p><p>类似地,这些写法都会让索引失效:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 用了函数</span></span><br><span class="line"><span class="keyword">WHERE</span> <span class="keyword">LEFT</span>(name, <span class="number">3</span>) <span class="operator">=</span> <span class="string">'abc'</span></span><br><span class="line"><span class="comment">-- 用了运算</span></span><br><span class="line"><span class="keyword">WHERE</span> id <span class="operator">+</span> <span class="number">1</span> <span class="operator">=</span> <span class="number">100</span></span><br><span class="line"><span class="comment">-- 用了隐式类型转换(user_id 是 VARCHAR,传入 INT)</span></span><br><span class="line"><span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">12345</span></span><br><span class="line"><span class="comment">-- 用了 LIKE 前缀通配</span></span><br><span class="line"><span class="keyword">WHERE</span> name <span class="keyword">LIKE</span> <span class="string">'%abc'</span></span><br></pre></td></tr></table></figure><h3 id="类型三:深分页"><a href="#类型三:深分页" class="headerlink" title="类型三:深分页"></a>类型三:深分页</h3><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> t_xxx_orders <span class="keyword">ORDER</span> <span class="keyword">BY</span> id LIMIT <span class="number">1000000</span>, <span class="number">20</span>;</span><br></pre></td></tr></table></figure><p>MySQL 要先扫描前 100 万行再丢弃,然后返回 20 行。越往后翻越慢。</p><p>解决方法是<strong>游标分页</strong>(也叫 keyset pagination):</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 上一页最后一条的 id 是 1000099</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> t_xxx_orders</span><br><span class="line"><span class="keyword">WHERE</span> id <span class="operator">></span> <span class="number">1000099</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id</span><br><span class="line">LIMIT <span class="number">20</span>;</span><br></pre></td></tr></table></figure><p>走主键索引,直接定位,不管翻到第几页都一样快。</p><h3 id="类型四:GROUP-BY-慢"><a href="#类型四:GROUP-BY-慢" class="headerlink" title="类型四:GROUP BY 慢"></a>类型四:GROUP BY 慢</h3><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> user_id, <span class="built_in">COUNT</span>(<span class="operator">*</span>)</span><br><span class="line"><span class="keyword">FROM</span> t_xxx_orders</span><br><span class="line"><span class="keyword">WHERE</span> created_at <span class="operator">>=</span> <span class="string">'2023-03-01'</span></span><br><span class="line"><span class="keyword">GROUP</span> <span class="keyword">BY</span> user_id;</span><br></pre></td></tr></table></figure><p>EXPLAIN 里出现 <code>Using temporary; Using filesort</code>。GROUP BY 默认会排序,如果不需要排序,加上 <code>ORDER BY NULL</code>(MySQL 8.0 之前有效)或者干脆给 GROUP BY 的列加索引。</p><p>不过老实说,大数据量的 GROUP BY 不应该放在 MySQL 里做。这种统计类查询更适合扔到数仓或者用预聚合表。我们后来把这类需求推到了 OLAP 平台,MySQL 只管行存业务数据。</p><h2 id="治理节奏"><a href="#治理节奏" class="headerlink" title="治理节奏"></a>治理节奏</h2><p>慢查询治理不是一次性运动,而是持续的日常。</p><p>我们的节奏:</p><ul><li>每周一从监控里拉出 Top 20 慢查询。</li><li>DAL 小组认领,逐条 EXPLAIN 分析。</li><li>能加索引的加索引,能改 SQL 的推动业务方改。</li><li>改完之后观察一周,确认生效。</li></ul><p>慢查询治理最怕的不是”难”,而是”放着不管”。</p><p>每条慢查询其实都不难治,难的是建立机制持续去做。一旦停下来不管,慢查询会越积越多,最后积重难返。</p>]]>
</content>
<id>https://www.robbs.win/2023-03-14/Slow-Query-Tuning-EXPLAIN.html</id>
<link href="https://www.robbs.win/2023-03-14/Slow-Query-Tuning-EXPLAIN.html"/>
<published>2023-03-14T06:00:00.000Z</published>
<summary>慢查询优化不是玄学,是让 EXPLAIN 执行计划说话。type、key、rows、Extra 怎么看,真实案例拆解。</summary>
<title>慢查询治理:让 EXPLAIN 说话</title>
<updated>2026-07-08T01:06:19.329Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<content>
<![CDATA[<p>前面两篇讲了 Java 版和 Python 版 SDK 的搭建。这一篇单独把读写分离和慢查询监控拎出来讲,因为这两个东西是整个 SDK 最核心的能力,而且都是我们自己在 SDK 层实现的,没碰任何中间件。</p><h2 id="为什么不用中间件"><a href="#为什么不用中间件" class="headerlink" title="为什么不用中间件"></a>为什么不用中间件</h2><p>先说为什么不选 ShardingSphere、MyCat、ProxySQL 这些。</p><p>2022 年那会儿,读写分离的方案大概分两类:</p><ul><li>中间件层:ShardingSphere-JDBC / ShardingSphere-Proxy、MyCat、ProxySQL、MaxScale。SQL 先过中间件,中间件决定路由到主还是从。</li><li>SDK 层:自己在应用里做路由。</li></ul><p>中间件的优点是”对应用透明”,业务代码不用改,接上中间件就有读写分离。听起来很美,但我们最后没用。原因:</p><p>第一,运维成本太高。ShardingSphere-Proxy 和 MyCat 是独立进程,得部署、得监控、得高可用。我们当时 DBA 资源紧张,再引入一套中间件集群等于给自己加活。</p><p>第二,故障域变大。中间件一旦挂了,所有走它的服务全挂。这种单点风险我们不愿意背。SDK 层做路由,挂的只是一个服务,影响面小得多。</p><p>第三,SQL 兼容性。ShardingSphere 和 MyCat 对复杂 SQL 的支持有限,子查询、存储过程、某些 JOIN 场景会出问题。我们的业务 SQL 五花八门,不想踩这个坑。</p><p>我们是要完全可控,不是”啥都不想改”,所以选了 SDK 层。</p><p>第四,Python 那边没法用。</p><p>ShardingSphere-JDBC 是 Java 库,Python 服务用不了。如果读写分离做在中间件层,Python 服务还得另搞一套。SDK 层做的话,Java 和 Python 各自实现一份,行为对齐就行。</p><h2 id="读写分离怎么做"><a href="#读写分离怎么做" class="headerlink" title="读写分离怎么做"></a>读写分离怎么做</h2><h3 id="Java-版"><a href="#Java-版" class="headerlink" title="Java 版"></a>Java 版</h3><p>核心是 Spring 的 <code>AbstractRoutingDataSource</code>。它本质上是个”数据源路由器”,每次拿连接的时候问你:你要主还是从?</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">DalRoutingDataSource</span> <span class="keyword">extends</span> <span class="title class_">AbstractRoutingDataSource</span> {</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">protected</span> Object <span class="title function_">determineCurrentLookupKey</span><span class="params">()</span> {</span><br><span class="line"> <span class="comment">// 写操作或事务内,走主库</span></span><br><span class="line"> <span class="keyword">if</span> (TransactionSynchronizationManager.isActualTransactionActive()) {</span><br><span class="line"> <span class="keyword">return</span> <span class="string">"master"</span>;</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">if</span> (DalContext.isWriteOperation()) {</span><br><span class="line"> <span class="keyword">return</span> <span class="string">"master"</span>;</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> <span class="string">"slave"</span>;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>判断”是不是写操作”的方法:拦截 SQL,看开头是不是 <code>INSERT/UPDATE/DELETE/REPLACE</code>。我们用了一个简单的正则:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="type">Pattern</span> <span class="variable">WRITE_PATTERN</span> <span class="operator">=</span></span><br><span class="line"> Pattern.compile(<span class="string">"^\\s*(insert|update|delete|replace|create|alter|drop|truncate)"</span>, Pattern.CASE_INSENSITIVE);</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">static</span> <span class="type">boolean</span> <span class="title function_">isWriteOperation</span><span class="params">(String sql)</span> {</span><br><span class="line"> <span class="keyword">return</span> WRITE_PATTERN.matcher(sql).find();</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>这里有个细节很多人会漏:事务里的查询必须走主库。为什么?因为主从有延迟。你刚写了一条数据,马上在从库查,可能查不到。事务内的查询通常依赖于事务内的写入,走从库就会出 bug。</p><h3 id="Python-版"><a href="#Python-版" class="headerlink" title="Python 版"></a>Python 版</h3><p>Python 那边的做法在上一篇讲过:维护主从两个 Engine,在 Session 级别路由。规则和 Java 完全一致:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">DalManager</span>:</span><br><span class="line"> <span class="keyword">def</span> <span class="title function_">session</span>(<span class="params">self, write=<span class="literal">False</span></span>):</span><br><span class="line"> engine = <span class="variable language_">self</span>.master <span class="keyword">if</span> write <span class="keyword">else</span> <span class="variable language_">self</span>.slave</span><br><span class="line"> <span class="keyword">return</span> sessionmaker(bind=engine)()</span><br><span class="line"></span><br><span class="line"><span class="meta"> @contextmanager</span></span><br><span class="line"> <span class="keyword">def</span> <span class="title function_">transaction</span>(<span class="params">self</span>):</span><br><span class="line"> <span class="comment"># 事务内强制走主库</span></span><br><span class="line"> session = <span class="variable language_">self</span>.session(write=<span class="literal">True</span>)</span><br><span class="line"> ...</span><br></pre></td></tr></table></figure><p>两端规则一致比代码长得一样重要。</p><h3 id="主从延迟怎么办"><a href="#主从延迟怎么办" class="headerlink" title="主从延迟怎么办"></a>主从延迟怎么办</h3><p>读写分离绕不开这个问题:从库延迟。</p><p>我们没有搞什么”延迟检测自动切主”的花活。做法很朴素:</p><ol><li>强一致场景走主库。事务内的查询、刚写入马上要读的,统统走主库。SDK 层用事务上下文判断。</li><li>业务能容忍延迟的走从库。报表、列表查询、历史数据这些,延迟几百毫秒无所谓。</li><li>监控从库延迟。Percona 的 <code>pt-heartbeat</code> 跑着,延迟超过 1 秒报警,超过 5 秒自动把读流量切回主库。</li></ol><p>这套策略够用了。大部分业务的读请求其实不在乎那点延迟。</p><h2 id="慢查询监控"><a href="#慢查询监控" class="headerlink" title="慢查询监控"></a>慢查询监控</h2><p>慢查询监控分两部分:采集和上报。</p><h3 id="采集"><a href="#采集" class="headerlink" title="采集"></a>采集</h3><p>在 SDK 层拦截每一条 SQL 的执行时间。</p><p>Java 版通过 JDBC 的 <code>PreparedStatement</code> 包装类埋点:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">DalPreparedStatement</span> <span class="keyword">extends</span> <span class="title class_">PreparedStatementWrapper</span> {</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">public</span> ResultSet <span class="title function_">executeQuery</span><span class="params">()</span> <span class="keyword">throws</span> SQLException {</span><br><span class="line"> <span class="type">long</span> <span class="variable">start</span> <span class="operator">=</span> System.nanoTime();</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">return</span> <span class="built_in">super</span>.executeQuery();</span><br><span class="line"> } <span class="keyword">finally</span> {</span><br><span class="line"> reportIfSlow(<span class="string">"query"</span>, System.nanoTime() - start);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">void</span> <span class="title function_">reportIfSlow</span><span class="params">(String op, <span class="type">long</span> elapsedNs)</span> {</span><br><span class="line"> <span class="type">long</span> <span class="variable">elapsedMs</span> <span class="operator">=</span> elapsedNs / <span class="number">1_000_000</span>;</span><br><span class="line"> <span class="keyword">if</span> (elapsedMs > SLOW_THRESHOLD_MS) {</span><br><span class="line"> SlowQueryReporter.report(sql, elapsedMs, op);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>Python 版用 SQLAlchemy 的事件钩子,上一篇文章里有代码,这里不重复了。</p><h3 id="上报"><a href="#上报" class="headerlink" title="上报"></a>上报</h3><p>慢查询数据报到两个地方:</p><ol><li>监控平台(Prometheus / 内部 metrics 系统):聚合看趋势,P99、P999 慢查询数量。</li><li>日志系统(ELK):存原始 SQL 和调用栈,排查用。</li></ol><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 慢查询上报配置</span></span><br><span class="line"><span class="attr">dal:</span></span><br><span class="line"> <span class="attr">slow-query:</span></span><br><span class="line"> <span class="attr">threshold-ms:</span> <span class="number">200</span> <span class="comment"># 超过 200ms 算慢</span></span><br><span class="line"> <span class="attr">sample-rate:</span> <span class="number">1.0</span> <span class="comment"># 全量采样</span></span><br><span class="line"> <span class="attr">report:</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">type:</span> <span class="string">metrics</span></span><br><span class="line"> <span class="attr">endpoint:</span> <span class="string">prometheus-pushgateway:9091</span></span><br><span class="line"> <span class="bullet">-</span> <span class="attr">type:</span> <span class="string">log</span></span><br><span class="line"> <span class="attr">endpoint:</span> <span class="string">elasticsearch:9200</span></span><br></pre></td></tr></table></figure><p>阈值 200ms 是拍脑袋定的,后来根据数据调整过。实际上不同业务的容忍度不一样,报表类服务可以放到 1 秒,交易类服务收紧到 100ms。</p><h2 id="效果"><a href="#效果" class="headerlink" title="效果"></a>效果</h2><p>读写分离上了之后,主库的读压力直接降了 60% 以上。原来高峰期 CPU 经常飙到 80%,分离后稳在 30% 左右。</p><p>慢查询监控的价值更大。以前慢查询是用户投诉了才知道,上了监控之后,还没变慢就能先发现。每周从监控里捞 Top 10 慢查询,逐一优化,这就是后面”慢查询治理”那篇文章要讲的事。</p><p>监控的目的不是为了看数据好看,而是驱动行动。如果监控只是看个图,不做优化,那等于白装。慢查询监控一定要配上”每周优化 Top N”的机制,才能真正产生价值。</p>]]>
</content>
<id>https://www.robbs.win/2022-11-15/SDK-Layer-ReadWrite-Split.html</id>
<link href="https://www.robbs.win/2022-11-15/SDK-Layer-ReadWrite-Split.html"/>
<published>2022-11-15T02:30:00.000Z</published>
<summary>读写分离和慢查询监控没用任何中间件,全在 SDK 层自己实现。怎么做、为什么这么做。</summary>
<title>读写分离与慢查询监控:在 SDK 层自己实现</title>
<updated>2026-07-08T01:06:19.296Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<content>
<![CDATA[<p>上一篇讲了 Java 版 SDK 怎么用 HikariCP 把底子打好。这一篇讲 Python 版。</p><p>先说结论:Python 版是独立搭建的,不是 Java 版的”翻译”。Python 有自己的生态和习惯,硬搬 Java 那套会水土不服。但两端的行为必须一致,同样的 SQL,在 Java 和 Python 里走的是同样的读写分离逻辑、同样的慢查询阈值、同样的连接池策略。</p><h2 id="为什么-Python-版后做"><a href="#为什么-Python-版后做" class="headerlink" title="为什么 Python 版后做"></a>为什么 Python 版后做</h2><p>不是优先级低,是确实更难。</p><p>Java 版做完后,我们手里有了一份明确的能力清单:连接池、读写分离、慢查询上报、配置中心。照着抄一份 Python 版不就行了?</p><p>不行。原因是:</p><ul><li>Java 那套用 Spring 的 <code>AbstractRoutingDataSource</code> 做读写分离,Python 没有对应的机制。</li><li>Java 用 ThreadLocal 传递上下文(当前是读还是写),Python 的线程模型完全不一样。</li><li>Java 服务跑在 JVM 里,进程模型稳定;Python 那边有的是 Web 服务,有的是定时任务,还有跑在 Airflow 里的数据脚本,进程模型五花八门。</li></ul><p>所以 Python 版必须从头设计,不能照搬。</p><h2 id="选-SQLAlchemy"><a href="#选-SQLAlchemy" class="headerlink" title="选 SQLAlchemy"></a>选 SQLAlchemy</h2><p>Python 生态里,数据库访问主要有几个选择:</p><ul><li>原生 DB-API(pymysql / mysqlclient):底层、灵活,但连接池、ORM 啥都得自己造。维护成本高。</li><li>SQLAlchemy:Python ORM 的事实标准,自带连接池、Session 管理,生态成熟。</li><li>Django ORM:和 Django 绑定太死,我们有的服务不是 Django。</li></ul><p>最后选了 SQLAlchemy。原因和 Java 选 HikariCP 类似:它把该做的事做了(连接池、Session),但不限制你怎么往上叠东西。</p><p>选框架的逻辑两端是一致的,底层扎实,上层灵活。Java 用 HikariCP,Python 用 SQLAlchemy。</p><h2 id="架构设计"><a href="#架构设计" class="headerlink" title="架构设计"></a>架构设计</h2><p>Python SDK 分三层,跟 Java 版结构对齐:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">应用层</span><br><span class="line"> ↓</span><br><span class="line">dal-python(Session 管理 + 读写分离 + 慢查询上报)</span><br><span class="line"> ↓</span><br><span class="line">SQLAlchemy(连接池)</span><br><span class="line"> ↓</span><br><span class="line">MySQL(主从)</span><br></pre></td></tr></table></figure><h3 id="连接池:QueuePool"><a href="#连接池:QueuePool" class="headerlink" title="连接池:QueuePool"></a>连接池:QueuePool</h3><p>SQLAlchemy 自带的 <code>QueuePool</code> 够用,不用再引入第三方。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> sqlalchemy <span class="keyword">import</span> create_engine</span><br><span class="line"><span class="keyword">from</span> sqlalchemy.pool <span class="keyword">import</span> QueuePool</span><br><span class="line"></span><br><span class="line">engine = create_engine(</span><br><span class="line"> <span class="string">"mysql+pymysql://user:pass@slave-host:3306/t_xxx"</span>,</span><br><span class="line"> poolclass=QueuePool,</span><br><span class="line"> pool_size=<span class="number">20</span>,</span><br><span class="line"> max_overflow=<span class="number">10</span>,</span><br><span class="line"> pool_timeout=<span class="number">3</span>, <span class="comment"># 拿不到连接等 3 秒</span></span><br><span class="line"> pool_recycle=<span class="number">1800</span>, <span class="comment"># 30 分钟回收</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>参数和 Java 版一一对应:</p><table><thead><tr><th>参数</th><th>Java (HikariCP)</th><th>Python (SQLAlchemy)</th><th>含义</th></tr></thead><tbody><tr><td>最大连接数</td><td><code>maximum-pool-size=20</code></td><td><code>pool_size + max_overflow</code></td><td>同</td></tr><tr><td>连接超时</td><td><code>connection-timeout=3000</code></td><td><code>pool_timeout=3</code></td><td>同(3 秒)</td></tr><tr><td>连接回收</td><td><code>max-lifetime=1800000</code></td><td><code>pool_recycle=1800</code></td><td>同(30 分钟)</td></tr></tbody></table><p>这就是”行为一致”的第一层:参数语义对齐。</p><h3 id="读写分离:Session-级别路由"><a href="#读写分离:Session-级别路由" class="headerlink" title="读写分离:Session 级别路由"></a>读写分离:Session 级别路由</h3><p>Java 用 <code>AbstractRoutingDataSource</code> 在数据源层做路由。Python 这边我换了个思路:在 Session 级别做。</p><p>原因:Python 的并发模型跟 Java 不一样。Java 服务是线程模型,一个请求一个线程,ThreadLocal 传上下文很自然。Python 大量用协程(gevent、asyncio),线程本地变量不靠谱。</p><p>我的做法是维护主从两个 Engine,根据操作类型选 Engine 创建 Session:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">DalManager</span>:</span><br><span class="line"> <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, master_url, slave_url</span>):</span><br><span class="line"> <span class="variable language_">self</span>.master = create_engine(master_url, poolclass=QueuePool, ...)</span><br><span class="line"> <span class="variable language_">self</span>.slave = create_engine(slave_url, poolclass=QueuePool, ...)</span><br><span class="line"></span><br><span class="line"> <span class="keyword">def</span> <span class="title function_">session</span>(<span class="params">self, write=<span class="literal">False</span></span>):</span><br><span class="line"> <span class="string">"""根据操作类型选 Engine"""</span></span><br><span class="line"> engine = <span class="variable language_">self</span>.master <span class="keyword">if</span> write <span class="keyword">else</span> <span class="variable language_">self</span>.slave</span><br><span class="line"> <span class="keyword">return</span> sessionmaker(bind=engine)()</span><br><span class="line"></span><br><span class="line"><span class="meta"> @contextmanager</span></span><br><span class="line"> <span class="keyword">def</span> <span class="title function_">transaction</span>(<span class="params">self</span>):</span><br><span class="line"> <span class="string">"""事务强制走主库"""</span></span><br><span class="line"> session = <span class="variable language_">self</span>.session(write=<span class="literal">True</span>)</span><br><span class="line"> <span class="keyword">try</span>:</span><br><span class="line"> <span class="keyword">yield</span> session</span><br><span class="line"> session.commit()</span><br><span class="line"> <span class="keyword">except</span> Exception:</span><br><span class="line"> session.rollback()</span><br><span class="line"> <span class="keyword">raise</span></span><br><span class="line"> <span class="keyword">finally</span>:</span><br><span class="line"> session.close()</span><br></pre></td></tr></table></figure><p>业务代码用起来很清爽:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 读操作,走从库</span></span><br><span class="line"><span class="keyword">with</span> dal.session() <span class="keyword">as</span> s:</span><br><span class="line"> orders = s.query(Order).filter_by(user_id=uid).<span class="built_in">all</span>()</span><br><span class="line"></span><br><span class="line"><span class="comment"># 写操作或事务,走主库</span></span><br><span class="line"><span class="keyword">with</span> dal.transaction() <span class="keyword">as</span> s:</span><br><span class="line"> order = Order(user_id=uid, amount=<span class="number">100</span>)</span><br><span class="line"> s.add(order)</span><br></pre></td></tr></table></figure><p>这就是”行为一致”的第二层:读写分离的规则两端一致。Java 那边是”写走主、读走从、事务走主”,Python 这边一模一样。</p><h3 id="慢查询上报"><a href="#慢查询上报" class="headerlink" title="慢查询上报"></a>慢查询上报</h3><p>SQLAlchemy 有个 <code>before_cursor_execute</code> 和 <code>after_cursor_execute</code> 的事件钩子,正好用来埋点。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> sqlalchemy <span class="keyword">import</span> event</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="meta">@event.listens_for(<span class="params">engine, <span class="string">"before_cursor_execute"</span></span>)</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">_before</span>(<span class="params">conn, cursor, statement, parameters, context, executemany</span>):</span><br><span class="line"> context._query_start = time.monotonic()</span><br><span class="line"></span><br><span class="line"><span class="meta">@event.listens_for(<span class="params">engine, <span class="string">"after_cursor_execute"</span></span>)</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">_after</span>(<span class="params">conn, cursor, statement, parameters, context, executemany</span>):</span><br><span class="line"> cost_ms = (time.monotonic() - context._query_start) * <span class="number">1000</span></span><br><span class="line"> <span class="keyword">if</span> cost_ms > <span class="number">200</span>: <span class="comment"># 和 Java 版同样的阈值</span></span><br><span class="line"> metrics.report(<span class="string">"dal.slow_query"</span>, cost_ms, statement)</span><br></pre></td></tr></table></figure><h2 id="两端怎么保证行为一致"><a href="#两端怎么保证行为一致" class="headerlink" title="两端怎么保证行为一致"></a>两端怎么保证行为一致</h2><p>这是跨语言 SDK 最难的部分。代码能跑只是起步,两端对同一个 SQL 的处理逻辑必须一样。</p><p>我做了几件事:</p><ol><li>参数语义对齐:连接池的关键参数,Java 和 Python 两端取同样的值,含义一致(见上面那张表)。</li><li>读写分离规则一致:都是”写走主、读走从、事务走主”,不允许某一端有特殊行为。</li><li>慢查询阈值一致:两端都是 200ms,上报到同一个监控指标的同一个 tag 下。</li><li>配置来源统一:两端的连接池配置都从同一个配置中心拉,避免各改各的。</li></ol><p>跨语言 SDK 的真正成本不是写两份代码,而是维护两份行为契约。</p><p>举个例子:有一次 Java 版调了连接池的 <code>max-lifetime</code> 从 30 分钟改成 15 分钟,Python 那边忘了同步。结果 Java 服务连接回收更快,Python 服务还留着老连接,碰上一次 MySQL 侧的连接重置,Python 那边报了一波连接错误,Java 没事。</p><p>从那以后,配置改动必须两端一起走,写进了变更流程。</p><h2 id="上线"><a href="#上线" class="headerlink" title="上线"></a>上线</h2><p>Python 版的推广比 Java 版顺一些。一是因为 Java 版已经趟过坑了,大家知道这套东西有用;二是 Python 服务相对少,迁移面小。</p><p>但也有坑。最大的一个是异步框架的兼容。有的服务用 asyncio,SQLAlchemy 的同步 Engine 在协程里会阻塞事件循环。后来我们对异步服务单独提供了 <code>AsyncEngine</code> + <code>AsyncSession</code> 的方案,读写分离逻辑还是同一套,只是 Engine 换了个异步实现。</p><p>下一篇讲读写分离和慢查询监控的具体实现细节,都在 SDK 层自己做,没依赖任何中间件。</p>]]>
</content>
<id>https://www.robbs.win/2022-08-16/Python-DAL-SDK-SQLAlchemy.html</id>
<link href="https://www.robbs.win/2022-08-16/Python-DAL-SDK-SQLAlchemy.html"/>
<published>2022-08-16T07:00:00.000Z</published>
<summary>Java 版 SDK 跑通后,Python 版怎么独立搭起来?用 SQLAlchemy,既要行为一致又要尊重 Python 的玩法。</summary>
<title>跨语言 SDK(二):Python 版用 SQLAlchemy 独立搭起来</title>
<updated>2026-07-08T01:06:19.265Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<content>
<![CDATA[<p>Supernova 的服务端以 Java 为主力,所以 DAL 跨语言 SDK 这件事,Java 版是第一个做的。这事后来看是对的,先把主力语言的底子打牢,再去啃 Python 那边,节奏清楚得多。</p><p>这一篇讲 Java SDK 怎么搭,下一篇讲 Python 版怎么独立搭起来,以及两端怎么做到行为一致。</p><h2 id="为什么要自建-SDK"><a href="#为什么要自建-SDK" class="headerlink" title="为什么要自建 SDK"></a>为什么要自建 SDK</h2><p>公司原来的数据访问层五花八门。有的服务直接拿 JDBC 连,连接池也不管;有的用 MyBatis;有的老项目还在用 c3p0。连接池配置各搞各的,慢查询没人监控,读写分离更是没有。</p><p>DAL 小组成立后,我们决定统一收口:做一套 SDK,所有 Java 服务都走它来访问数据库。目标很明确:</p><ul><li>统一连接池管理(用 HikariCP)</li><li>内置读写分离</li><li>慢查询自动上报</li><li>配置统一管理</li></ul><p>收口的目的是让基础设施的升级能一次推到所有服务。如果不收口,你改个连接池参数都得求着十几个团队各自改一遍,推不动。</p><h2 id="为什么选-HikariCP"><a href="#为什么选-HikariCP" class="headerlink" title="为什么选 HikariCP"></a>为什么选 HikariCP</h2><p>2022 年那会儿,Java 连接池其实就两个正经选择:HikariCP 和 Druid。</p><ul><li>Druid 是阿里开源的,功能多,自带 SQL 解析和监控页面,国内用的人多。</li><li>HikariCP 是 Spring Boot 默认带的,性能好,代码精简,社区活跃。</li></ul><p>我们最后选了 HikariCP。原因很简单:我们要自己做监控和读写分离,不需要连接池自带这些。HikariCP 就是把连接池这件事做到极致,其他不掺和,反而更适合我们往上叠东西。</p><p>而且 HikariCP 的代码量很小(据说核心就几千行),出了问题翻源码也翻得动。Druid 功能虽多,但代码量大了好几倍,排查问题的时候会被各种旁枝逻辑带跑。</p><h2 id="SDK-长什么样"><a href="#SDK-长什么样" class="headerlink" title="SDK 长什么样"></a>SDK 长什么样</h2><p>整套 SDK 分三层:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">应用层</span><br><span class="line"> ↓</span><br><span class="line">DAL SDK(连接管理 + 读写分离 + 慢查询上报)</span><br><span class="line"> ↓</span><br><span class="line">HikariCP(连接池)</span><br><span class="line"> ↓</span><br><span class="line">MySQL(主从)</span><br></pre></td></tr></table></figure><h3 id="连接池配置"><a href="#连接池配置" class="headerlink" title="连接池配置"></a>连接池配置</h3><p>我们给每个 DataSource 套了一层 HikariCP,配置统一从配置中心拉。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># dal-sdk 默认配置(脱敏)</span></span><br><span class="line"><span class="attr">dal:</span></span><br><span class="line"> <span class="attr">datasource:</span></span><br><span class="line"> <span class="attr">master:</span></span><br><span class="line"> <span class="attr">jdbc-url:</span> <span class="string">jdbc:mysql://master-host:3306/t_xxx</span></span><br><span class="line"> <span class="attr">username:</span> <span class="string">xxx</span></span><br><span class="line"> <span class="attr">password:</span> <span class="string">xxx</span></span><br><span class="line"> <span class="attr">pool:</span></span><br><span class="line"> <span class="attr">maximum-pool-size:</span> <span class="number">20</span></span><br><span class="line"> <span class="attr">minimum-idle:</span> <span class="number">5</span></span><br><span class="line"> <span class="attr">connection-timeout:</span> <span class="number">3000</span></span><br><span class="line"> <span class="attr">idle-timeout:</span> <span class="number">600000</span></span><br><span class="line"> <span class="attr">max-lifetime:</span> <span class="number">1800000</span></span><br><span class="line"> <span class="attr">slave:</span></span><br><span class="line"> <span class="attr">jdbc-url:</span> <span class="string">jdbc:mysql://slave-host:3306/t_xxx</span></span><br><span class="line"> <span class="attr">pool:</span></span><br><span class="line"> <span class="attr">maximum-pool-size:</span> <span class="number">20</span></span><br></pre></td></tr></table></figure><p>几个参数我重点说一下:</p><ul><li><code>maximum-pool-size</code>:默认 20。别一上来就开 100,连接池开太大反而会让数据库被打满。一个服务 20 个连接,100 个服务就是 2000 个连接,MySQL 默认 <code>max_connections</code> 才 151。</li><li><code>connection-timeout</code>:拿不到连接时最多等 3 秒。超过 3 秒直接抛异常,比卡死强。</li><li><code>max-lifetime</code>:连接最多活 30 分钟。MySQL 的 <code>wait_timeout</code> 默认 8 小时,但中间如果有防火墙或 NAT 断连,连接就废了。30 分钟回收重建更保险。</li></ul><h3 id="读写分离"><a href="#读写分离" class="headerlink" title="读写分离"></a>读写分离</h3><p>这一块是在 SDK 层自己实现的,没用 ShardingSphere 之类的中间件。原因后面那篇会专门讲,这里先说实现。</p><p>做法其实不复杂:维护一主一从两个 HikariDataSource,根据 SQL 类型分流。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">DalRoutingDataSource</span> <span class="keyword">extends</span> <span class="title class_">AbstractRoutingDataSource</span> {</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">protected</span> Object <span class="title function_">determineCurrentLookupKey</span><span class="params">()</span> {</span><br><span class="line"> <span class="comment">// 写操作走主库</span></span><br><span class="line"> <span class="keyword">if</span> (DalContext.isWriteOperation()) {</span><br><span class="line"> <span class="keyword">return</span> <span class="string">"master"</span>;</span><br><span class="line"> }</span><br><span class="line"> <span class="comment">// 读操作走从库</span></span><br><span class="line"> <span class="keyword">return</span> <span class="string">"slave"</span>;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>判断读写的方法很简单:<code>INSERT/UPDATE/DELETE</code> 走主库,<code>SELECT</code> 走从库。事务内的查询强制走主库(避免主从延迟读到旧数据)。</p><h3 id="慢查询上报"><a href="#慢查询上报" class="headerlink" title="慢查询上报"></a>慢查询上报</h3><p>每个 SQL 执行完后,SDK 记录耗时。超过阈值(默认 200ms)的,打到监控平台。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Around("execution(* javax.sql.DataSource.getConnection(..))")</span></span><br><span class="line"><span class="keyword">public</span> Object <span class="title function_">trackSql</span><span class="params">(ProceedingJoinPoint pjp)</span> <span class="keyword">throws</span> Throwable {</span><br><span class="line"> <span class="type">long</span> <span class="variable">start</span> <span class="operator">=</span> System.nanoTime();</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">return</span> pjp.proceed();</span><br><span class="line"> } <span class="keyword">finally</span> {</span><br><span class="line"> <span class="type">long</span> <span class="variable">costMs</span> <span class="operator">=</span> (System.nanoTime() - start) / <span class="number">1_000_000</span>;</span><br><span class="line"> <span class="keyword">if</span> (costMs > SLOW_THRESHOLD_MS) {</span><br><span class="line"> Metrics.report(<span class="string">"dal.slow_query"</span>, costMs, currentSql());</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>这段是示意。实际上我们是在 JDBC 的 <code>PreparedStatement</code> 执行层做拦截,能拿到完整 SQL 文本和参数。</p><h2 id="为-Python-版铺路"><a href="#为-Python-版铺路" class="headerlink" title="为 Python 版铺路"></a>为 Python 版铺路</h2><p>Java 版 SDK 做完之后,我们心里大概有了谱:一个 DAL SDK 该有哪些能力。这个”能力清单”后来直接成了 Python 版的需求文档:</p><ul><li>统一连接池</li><li>读写分离(主写从读,事务走主)</li><li>滞查询上报</li><li>配置从中心拉取</li></ul><p>Java 版先走通,本质上是给 Python 版趟了一遍雷。哪些雷呢?比如一开始我们把读写分离的判断逻辑放在了业务线程的 ThreadLocal 里,后来发现 Python 那边没有 ThreadLocal 这东西,得用别的方案。这些后面 Python 那篇再细讲。</p><h2 id="上线节奏"><a href="#上线节奏" class="headerlink" title="上线节奏"></a>上线节奏</h2><p>Java SDK 不是一次性推的。我们先在一个新服务上试点,跑了两周没问题,再逐个迁移老服务。迁移过程中遇到不少奇葩配置,比如有的服务原来连接池开到 200,迁移后只给 20,业务方说”性能降了”。一查发现是原来 200 个连接里有 150 个是空跑的,真正并发的也就十几个。降下来之后数据库的压力反而小了。</p><p>这种沟通成本很高,但值得。收口这件事,一开始就是费劲,推过去之后爽得不行。</p><p>下一篇讲 Python 版怎么用 SQLAlchemy 独立搭起来,以及两端怎么保证行为一致。</p>]]>
</content>
<id>https://www.robbs.win/2022-05-17/Java-DAL-SDK-HikariCP.html</id>
<link href="https://www.robbs.win/2022-05-17/Java-DAL-SDK-HikariCP.html"/>
<published>2022-05-17T03:00:00.000Z</published>
<summary>DAL 跨语言 SDK 先从 Java 版做起,用 HikariCP 把连接池底子打牢,为后续 Python 版的行为一致铺路。</summary>
<title>跨语言 SDK(一):Java 版用 HikariCP 把底子打好</title>
<updated>2026-07-08T01:06:19.234Z</updated>
</entry>
<entry>
<author>
<name>Robbs Luo</name>
</author>
<category term="Career" scheme="https://www.robbs.win/categories/Career/"/>
<category term="Career" scheme="https://www.robbs.win/tags/Career/"/>
<content>
<![CDATA[<p>做 DAL 小组评审做久了,我发现大多数 SQL 问题其实就那么几类。索引没用对、锁没估准、迁移没想清楚后果。新来的人一开始评审总抓不住重点,东看一眼西看一眼,漏掉关键点。</p><p>后来我把这些东西整理成了一张清单,评审的时候照着过。这篇就把这张清单摊开来讲讲。</p><h2 id="DDL-评审清单"><a href="#DDL-评审清单" class="headerlink" title="DDL 评审清单"></a>DDL 评审清单</h2><h3 id="1-索引设计"><a href="#1-索引设计" class="headerlink" title="1. 索引设计"></a>1. 索引设计</h3><p>这是重头戏。80% 的慢查询都是索引没设计好。</p><p><strong>要看的东西:</strong></p><ul><li>新加的字段,查询条件里会不会用到?会用到就该考虑加索引。</li><li>加的索引基数(cardinality)够不够?一个 status 字段只有 0/1 两个值,单独建索引基本没用。</li><li>有没有联合索引可以替代多个单列索引?联合索引的列顺序很关键,等值条件在前,范围条件在后。</li></ul><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 反例:单独给低基数字段建索引,基本没用</span></span><br><span class="line"><span class="keyword">CREATE</span> INDEX idx_status <span class="keyword">ON</span> t_xxx_orders(status);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 正例:联合索引,高基数在前</span></span><br><span class="line"><span class="keyword">CREATE</span> INDEX idx_uid_status <span class="keyword">ON</span> t_xxx_orders(user_id, status);</span><br></pre></td></tr></table></figure><ul><li>有没有冗余索引?比如已经有 <code>(user_id, status)</code> 了,再单独建 <code>(user_id)</code> 就是浪费。</li></ul><h3 id="2-字段类型"><a href="#2-字段类型" class="headerlink" title="2. 字段类型"></a>2. 字段类型</h3><p>选错类型后患无穷。我见过太多用 <code>VARCHAR(255)</code> 装状态值的,明明 <code>TINYINT</code> 就够了。</p><ul><li>状态值、枚举:<code>TINYINT</code> 或 <code>SMALLINT</code>,别用字符串。</li><li>金额:<code>DECIMAL</code>,别用 <code>FLOAT</code>,浮点精度问题够你喝一壶。</li><li>时间:<code>DATETIME</code> 还是 <code>TIMESTAMP</code> 要想清楚,<code>TIMESTAMP</code> 有 2038 问题,但占用空间小一半。</li></ul><h3 id="3-锁的影响"><a href="#3-锁的影响" class="headerlink" title="3. 锁的影响"></a>3. 锁的影响</h3><p>MySQL 的 DDL 不是无锁的。</p><ul><li>MySQL 5.6 之前,<code>ALTER TABLE</code> 会锁全表,读写都阻塞。</li><li>5.6 之后,加了 Online DDL,大部分操作可以”在线”做,但不是所有操作都支持。比如给一个已有的大表加 <code>NOT NULL</code> 列,照样锁表。</li><li>大表(百万行以上)一律走 pt-online-schema-change 或 gh-ost,别裸跑 DDL。</li></ul><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># gh-ost 比 pt-osc 更现代,触发器换成 binlog 订阅</span></span><br><span class="line">gh-ost \</span><br><span class="line"> --alter <span class="string">"ADD COLUMN remark VARCHAR(200)"</span> \</span><br><span class="line"> --database=t_xxx --table=orders \</span><br><span class="line"> --execute</span><br></pre></td></tr></table></figure><h3 id="4-迁移风险"><a href="#4-迁移风险" class="headerlink" title="4. 迁移风险"></a>4. 迁移风险</h3><p>这一块新人最容易忽略。加了字段之后,老代码会不会挂?</p><p>举个例子:原来一个接口返回订单的 JSON,现在多了一个字段,下游服务反序列化的 POJO 里没有这个字段。大多数情况 Jackson 会忽略未知字段,但如果配置了 <code>FAIL_ON_UNKNOWN_PROPERTIES</code>,直接炸。</p><p>DDL 上线前必须确认:</p><ul><li>新字段有没有默认值?</li><li>老代码读到新字段会不会报错?</li><li>下游服务有没有兼容性处理?</li><li>回滚方案是什么?删字段还是留着不管?</li></ul><h2 id="DML-评审清单"><a href="#DML-评审清单" class="headerlink" title="DML 评审清单"></a>DML 评审清单</h2><h3 id="1-UPDATE-DELETE-的-WHERE-条件"><a href="#1-UPDATE-DELETE-的-WHERE-条件" class="headerlink" title="1. UPDATE / DELETE 的 WHERE 条件"></a>1. UPDATE / DELETE 的 WHERE 条件</h3><p>这是事故高发区。我见过有人写 <code>UPDATE t_xxx_orders SET status = 1;</code> 忘了加 <code>WHERE</code>,一秒钟全表更新,直接 gg。</p><p>评审时必看:</p><ul><li>有没有 <code>WHERE</code> 条件?(听起来像废话,但真有人忘)</li><li><code>WHERE</code> 条件走的是索引还是全表扫描?<code>EXPLAIN</code> 一下就知道了。</li><li>影响行数预估多少?如果 <code>EXPLAIN</code> 的 <code>rows</code> 列显示百万级,得停下来想想。</li><li>大批量更新要分批做,别一把梭。</li></ul><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 大批量更新分批做,每批 1000 行</span></span><br><span class="line"><span class="keyword">UPDATE</span> t_xxx_orders</span><br><span class="line"><span class="keyword">SET</span> status <span class="operator">=</span> <span class="number">2</span></span><br><span class="line"><span class="keyword">WHERE</span> status <span class="operator">=</span> <span class="number">1</span> <span class="keyword">AND</span> id <span class="operator"><=</span> <span class="number">1000000</span></span><br><span class="line">LIMIT <span class="number">1000</span>;</span><br></pre></td></tr></table></figure><h3 id="2-事务大小"><a href="#2-事务大小" class="headerlink" title="2. 事务大小"></a>2. 事务大小</h3><p>一个事务里更新的行数太多,会导致:</p><ul><li>undo log 膨胀,占空间。</li><li>长事务阻塞其他查询,锁等待飙升。</li><li>主从延迟拉大。</li></ul><p>原则:一个事务别超过 1 万行更新。超了就拆。</p><h3 id="3-INSERT-批量-vs-单条"><a href="#3-INSERT-批量-vs-单条" class="headerlink" title="3. INSERT 批量 vs 单条"></a>3. INSERT 批量 vs 单条</h3><p>批量 <code>INSERT</code> 性能比循环单条 <code>INSERT</code> 高几十倍,这个不用多解释。</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 反例:循环单条插入</span></span><br><span class="line"><span class="keyword">INSERT INTO</span> t_xxx_log(user_id, action) <span class="keyword">VALUES</span> (<span class="number">1</span>, <span class="string">'login'</span>);</span><br><span class="line"><span class="keyword">INSERT INTO</span> t_xxx_log(user_id, action) <span class="keyword">VALUES</span> (<span class="number">2</span>, <span class="string">'login'</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 正例:批量插入</span></span><br><span class="line"><span class="keyword">INSERT INTO</span> t_xxx_log(user_id, action) <span class="keyword">VALUES</span> (<span class="number">1</span>, <span class="string">'login'</span>), (<span class="number">2</span>, <span class="string">'login'</span>);</span><br></pre></td></tr></table></figure><h2 id="一张表总结"><a href="#一张表总结" class="headerlink" title="一张表总结"></a>一张表总结</h2><table><thead><tr><th>检查项</th><th>DDL</th><th>DML</th><th>严重程度</th></tr></thead><tbody><tr><td>WHERE 条件走索引?</td><td>-</td><td>必查</td><td>致命</td></tr><tr><td>锁表风险?</td><td>必查</td><td>看情况</td><td>高</td></tr><tr><td>字段类型合理?</td><td>必查</td><td>-</td><td>中</td></tr><tr><td>影响行数可控?</td><td>必查</td><td>必查</td><td>高</td></tr><tr><td>回滚方案?</td><td>必查</td><td>必查</td><td>致命</td></tr><tr><td>下游兼容性?</td><td>必查</td><td>看情况</td><td>高</td></tr></tbody></table><h2 id="最后说一句"><a href="#最后说一句" class="headerlink" title="最后说一句"></a>最后说一句</h2><p>清单这东西,关键是每次都用,不能挑着用。</p><p>评审的目的不是挑毛病,是帮你把那些”应该没事”变成”确定没事”。</p><p>我见过太多人觉得”这条 SQL 简单,应该没问题”,然后就出事了。简单的东西恰恰最容易翻车,因为你放松了警惕。养成习惯,每次评审都照着清单过一遍,不管 SQL 长什么样。</p>]]>
</content>
<id>https://www.robbs.win/2022-02-15/DDL-DML-Review-Checklist.html</id>
<link href="https://www.robbs.win/2022-02-15/DDL-DML-Review-Checklist.html"/>
<published>2022-02-15T06:00:00.000Z</published>
<summary>DAL 小组评审 DDL/DML 时到底在看什么?一张清单把索引、锁、迁移风险全过一遍。</summary>
<title>DDL/DML 评审:索引、锁、迁移风险一张表说清</title>
<updated>2026-07-08T01:06:19.204Z</updated>
</entry>
</feed>