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.tsblog-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 Command Interactive Flow

sync 检测到新分类时,它会自动:

  1. 询问新词条的各种语言翻译。
  2. 根据英文翻译生成 URL 友好的 slug。
  3. 询问是否需要添加别名。
  4. 检查新分类是否与现有分类相似(例如 LLM vs Large 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 ... */); // ...
}

为了更好地展示架构,我还绘制了一张架构图:

Blog Category Tool Architecture Diagram

代码变得清晰解耦可测试。这时我已经完全沉浸在构建这个系统中了。

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 小时,突然变成了一次有意义的投资。我不仅享受了过程,提升了技能,还为我的博客创造了独特的、有深度的内容。

结语:重新审视「过度工程」

下一次,当你发现自己为一个简单的需求写了许多代码时,不必立刻否定这种做法。

不妨问自己三个问题:

  1. 我是否享受这个过程? (体验角度)
  2. 我是否在其中学到了新东西? (学习角度)
  3. 我能否将这个过程或结果,转化为可以与他人分享的知识? (分享角度)

如果三个答案都是肯定的,那么,你可能不是在「浪费时间」,而是在进行一次有价值的个人投资

技术行业的进步,除了来自解决实际问题的需求,也来自对技术的热爱、对优雅代码的追求,以及愿意分享所学所思的开放精神。

后记:理想与现实的平衡

写完这篇文章后不久,我在实际使用这个工具时发现了一些问题。

那个花费一个多小时手写的文件锁机制,虽然在技术上实现了原子操作和并发控制,但在处理一些边缘情况时并不如成熟的第三方库稳定。于是我最终还是换回了 proper-lockfile 库。

同样,我手写的字符串相似度算法在处理分类名时效果不够理想,最终也换回了功能更强大的 fuse.js 库。

这个结果有点讽刺,但也很真实:有时候,「重新发明轮子」确实不如使用经过大量实际验证的成熟方案。但这个过程让我深入理解了文件锁的原理模糊搜索的算法,这些知识在未来的项目中必然会派上用场。

或许这才是「过度工程」的真正价值——不是最终的产品,而是在探索过程中获得的深度理解技术洞察