8小时与2000行代码:一个博客分类工具的「失控」之旅
上个周末,我本来只想花五分钟写个简单脚本,结果花了八小时写了 2000 多行代码。
最初的目标很简单:给博客写一个分类拼写检查脚本。最终却做出了一个功能完整的 TypeScript 应用,包含依赖注入、分层架构、Git 集成、交互式 CLI 和文件锁机制。
这篇文章记录了这次开发过程,探讨技术热情、过度工程,以及如何把这次经历转化为有价值的内容。
缘起:一个五分钟就能解决的问题
事情起源于我在检查代码时发现的一个小问题。我的博客用 Astro 构建,所有文章都是 Markdown 文件。为了规范文章分类,我维护了一个 categories.json 文件,定义了所有合法的分类及其多语言翻译。
{
"web-development": {
"aliases": ["Web开发"],
"translations": {
"en": "Web Development",
"zh-cn": "Web 开发",
"zh-tw": "Web 開發"
}
},
"cognitive-science": {
"aliases": ["认知科学"],
"translations": {
"en": "Cognitive Science",
"zh-cn": "认知科学",
"zh-tw": "認知科學"
}
}
}问题在于,写新文章时我需要在 frontmatter 中手动引用这些分类,很容易出现拼写错误。
---
title: "我的新文章"
pubDate: 2025-06-21
categories:
- "Web Development"
- "Cognitive Science" # 我在这里会不会手滑打成 "Cognitive-Science"?
---这种不一致不会让网站崩溃,但会在分类页面上产生两个几乎相同的分类链接,这让我很不舒服。
我想:「很简单,写个几十行的脚本,放在 pre-commit 钩子里。每次提交前扫描暂存区的 Markdown 文件,提取 categories 字段,跟 categories.json 对比,发现不匹配就报错。五分钟搞定。」
那是周日上午 10 点。我以为这只是一个小任务。
第一幕:失控的雪球
10:30 AM:从一次性脚本到 CLI 工具
最初的脚本很快写好了,但看起来太简陋,让我觉得不够专业。
我想:「直接用 bun run check.ts 运行不太优雅,不如用 commander.js 包装成一个正式的 CLI 工具。这样可以有 -v 和 --help 这些标准参数,以后扩展也方便。」
于是创建了 core/cli.ts 和 blog-categories/main.ts。项目结构开始成型,原本的检查逻辑被封装成了一个 validate 命令。
// scripts/blog-categories/main.ts
program
.command("validate")
.description("Validate blog post categories")
.option("--staged", "Only validate staged files") // ... Other options
.action(async (options) => {
// ... Validation logic
});这个心态转变是关键的一步。我开始觉得自己不是在写脚本,而是在开发一个产品。
12:00 PM:功能扩展——添加 sync 命令
validate 命令能发现问题,但修复问题还需要手动操作,感觉功能不完整。我想:「既然能发现不存在的分类,为什么不能直接帮我添加进去?」
这个想法很实用,于是开始开发 sync 命令。但这不只是简单的「添加」。一个新分类,比如「大型语言模型」,它的英文翻译是什么?URL slug 应该是什么?
为了实现智能化的添加流程,我引入了 inquirer.js 来构建交互式问答,并编写了一个 InteractiveService 处理用户输入。
// scripts/blog-categories/services/interactive-service.ts
class InteractiveService {
async promptForTranslation(
categoryName: string,
language: string
): Promise<string> {
return await input({
message: `${language} translation for "${categoryName}" (optional):`, // ...
});
} // ... Other interactive methods
}当 sync 检测到新分类时,它会自动:
- 询问新词条的各种语言翻译。
- 根据英文翻译生成 URL 友好的 slug。
- 询问是否需要添加别名。
- 检查新分类是否与现有分类相似(例如
LLMvsLarge Language Model),并建议将其添加为别名。为此,我引入了fastest-levenshtein库,并编写了一个SimilarityService。
不知不觉午饭时间过了,我完全专注于完善这个交互功能。
3:00 PM:重构与分层架构
这时,sync 命令的逻辑变得很复杂,混合了文件读写、Markdown 解析、用户交互、相似度计算和数据更新等多种职责。
我觉得:「这太乱了,违背了单一职责原则。需要重新分层!」
这是一个关键的转折点。我决定用构建大型应用的思路来重构这个小工具,把 blog-categories 目录重新组织成清晰的分层结构:
blog-categories
├── handlers/ # 数据访问层: 读写文件、Git操作
├── services/ # 业务逻辑层: 核心算法、纯粹的业务规则
├── processors/ # 应用/编排层: 协调services和handlers完成一个用例
├── utils/ # 无状态的纯函数工具
├── main.ts # 入口与CLI定义
└── types.ts # 类型定义接着,我手动实现了一个依赖注入容器来管理各个模块。
// scripts/blog-categories/main.ts
// --- Composition Root ---
let configManager: ConfigManager;
let categoryHandler: CategoryHandler;
// ... A long list of dependencies
async function initializeDependencies(verbose = false) {
// Configuration
configManager = new ConfigManager(verbose);
await configManager.load(); // Handlers (Data Layer)
categoryHandler = new CategoryHandler(configManager, verbose); // ... // Services (Business Logic Layer)
interactiveService = new InteractiveService(categoryHandler); // ... // Processors (Orchestration Layer)
syncProcessor = new SyncProcessor(/* ... injecting dependencies ... */); // ...
}为了更好地展示架构,我还绘制了一张架构图:
代码变得清晰、解耦、可测试。这时我已经完全沉浸在构建这个系统中了。
5:00 PM:完善功能——fixup 与文件锁
系统虽已优雅,但功能还不够完整。我注意到,如果先用中文创建分类(如「随笔」),其 slug 会是临时的 chinese-category-12345。我希望在补充英文翻译 Essay 后,slug 能自动更新为 essay。
fixup 命令因此诞生,专门处理这类数据不一致的情况。
然而,在编写保存逻辑时,我想到:「如果在两个终端里同时运行 sync,会不会把 categories.json 写坏?」
理性告诉我这不可能发生。但追求完美的想法已经占据了上风:「一个健全的系统,必须考虑并发。」
于是,我花了最后一个多小时,编写了那个如今看来过于复杂,却在技术上让我很满意的 withFileLock 函数。
// scripts/blog-categories/handlers/category-handler.ts
private async withFileLock<T>(
lockFile: string,
action: () => Promise<T>,
): Promise<T> {
const maxWaitMs = 5000;
const checkIntervalMs = 100;
const staleLockTimeoutMs = 30000;
// 1. Clean up stale lock
await this.cleanupStaleLock(lockFile, staleLockTimeoutMs);
// 2. Try to acquire lock
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
try {
// 3. Use 'wx' flag for atomic file creation
const lockData = { pid: process.pid, timestamp: Date.now() };
const fs = await import("node:fs/promises");
const fd = await fs.open(lockFile, "wx");
await fd.writeFile(JSON.stringify(lockData));
await fd.close();
// 4. Execute protected operation
try {
return await action();
} finally {
// 5. Release lock
const { unlink } = await import("node:fs/promises");
await unlink(lockFile).catch(() => {});
}
} catch (error: any) {
if (error.code === "EEXIST") {
await new Promise((resolve) => setTimeout(resolve, checkIntervalMs));
} else {
throw error;
}
}
}
throw new Error(`Failed to acquire lock on ${lockFile} within ${maxWaitMs}ms.`);
}它包含了原子操作、超时、重试、僵尸锁检测和清理——一个为单用户、单机脚本设计的、生产级的并发控制方案。
当我写完最后一行代码,时钟指向了下午 6 点。8 个小时过去了,我得到了一个功能完整、架构清晰的博客分类管理系统。
第二幕:反思与价值重评
周日傍晚,我看着这 2000 多行代码,开始思考这个问题。我解决的,真的只是那个「分类名拼写错误」的问题吗?
从效率角度:投入产出不成比例
用工程逻辑来审视这 8 个小时:
- 投资 (Investment):8 小时高级开发者时间。机会成本是本可以写 2-3 篇高质量博客,或学习一个新框架。
- 回报 (Return):每年为我节省约 5 分钟手动修复拼写错误的时间。
投资回报率(ROI)基本为零。从项目管理的角度看,这确实不够经济。就像用制造航空发动机的工艺,去给自行车拧螺丝。
从体验角度:过程本身的价值
但我们开发者,并非只是追求效率的机器。
当我换个角度思考,我回想起那 8 小时的专注体验。这并非痛苦的工作,而是一次愉快的创造。
- 这是智力练习:我享受将混乱逻辑拆分成清晰分层时的秩序感。
- 这是编程练习:我以这个小需求为目标,完整地练习了如何构建一个现代、可靠的 CLI 应用。
- 这是软件匠艺(Software Craftsmanship):编写文件锁的感觉,如同木匠打造一个精密的榫卯,即便它最终会被隐藏起来。
从这个角度看,这 8 小时的主要价值并非那个工具,而是构建它过程中的技能提升和编程体验。
从内容角度:意外的收获
如果故事到此为止,它只是一个关于程序员完美主义的个人记录。但我很快发现了价值转换的关键一环。
我意识到,这个过度工程的、结构清晰的、注释详尽的项目,它本身就是一个很好的内容素材。
这个项目的真正价值,不在于它为我节省了多少时间,而在于它能为我的读者带来多少价值。我花费 8 小时构建的,不仅是一个 CLI 工具,更是一个可以被分享、被解剖、被学习的真实案例。
这个项目的最终产出,就是这篇文章本身。
这个想法让我重新审视这次经历。原本看似无效率的 8 小时,突然变成了一次有意义的投资。我不仅享受了过程,提升了技能,还为我的博客创造了独特的、有深度的内容。
结语:重新审视「过度工程」
下一次,当你发现自己为一个简单的需求写了许多代码时,不必立刻否定这种做法。
不妨问自己三个问题:
- 我是否享受这个过程? (体验角度)
- 我是否在其中学到了新东西? (学习角度)
- 我能否将这个过程或结果,转化为可以与他人分享的知识? (分享角度)
如果三个答案都是肯定的,那么,你可能不是在「浪费时间」,而是在进行一次有价值的个人投资。
技术行业的进步,除了来自解决实际问题的需求,也来自对技术的热爱、对优雅代码的追求,以及愿意分享所学所思的开放精神。
后记:理想与现实的平衡
写完这篇文章后不久,我在实际使用这个工具时发现了一些问题。
那个花费一个多小时手写的文件锁机制,虽然在技术上实现了原子操作和并发控制,但在处理一些边缘情况时并不如成熟的第三方库稳定。于是我最终还是换回了 proper-lockfile 库。
同样,我手写的字符串相似度算法在处理分类名时效果不够理想,最终也换回了功能更强大的 fuse.js 库。
这个结果有点讽刺,但也很真实:有时候,「重新发明轮子」确实不如使用经过大量实际验证的成熟方案。但这个过程让我深入理解了文件锁的原理和模糊搜索的算法,这些知识在未来的项目中必然会派上用场。
或许这才是「过度工程」的真正价值——不是最终的产品,而是在探索过程中获得的深度理解和技术洞察。