---
title: "8小时与2000行代码：一个博客分类工具的「失控」之旅"
description: "一个旨在修复博客分类拼写错误、预计耗时5分钟的脚本，最终演变成一个包含分层架构与生产级功能的2000行TypeScript项目。本文深度复盘了这次「过度工程」之旅，并探讨了如何将对技术的热情与过程本身，转化为更有价值的知识资产。"
pubDate: "2025-06-23"
categories: ["软件开发", "TypeScript", "项目管理", "过度工程化", "软件匠艺"]
---

上个周末，我本来只想花**五分钟**写个简单脚本，结果花了**八小时**写了 **2000 多行代码**。

最初的目标很简单：给博客写一个分类拼写检查脚本。最终却做出了一个功能完整的 TypeScript 应用，包含*依赖注入*、*分层架构*、*Git 集成*、*交互式 CLI* 和*文件锁机制*。

这篇文章记录了这次开发过程，探讨**技术热情**、**过度工程**，以及如何把这次经历转化为有价值的内容。

## 缘起：一个五分钟就能解决的问题

事情起源于我在检查代码时发现的一个小问题。我的博客用 Astro 构建，所有文章都是 Markdown 文件。为了规范文章分类，我维护了一个 `categories.json` 文件，定义了所有合法的分类及其多语言翻译。

```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 中手动引用这些分类，很容易出现拼写错误。

```yaml
---
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` 命令。

```typescript
// 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` 处理用户输入。

```typescript
// 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
}
```

```d2 title="sync Command Interactive Flow"
style: {
  fill: transparent
}
direction: down

start: “User runs ‘sync’ command” {
  shape: person
}

detect: “Detects a new category in a file”

check_similarity: “Is it similar to an existing category?” {
  shape: diamond
}

interactive_flow: “Interactive Creation Process” {
  prompt_translations: “1. Prompt for multi-language translations”
  generate_slug: “2. Generate URL-friendly slug from English input”
  prompt_aliases: “3. Prompt for optional aliases (e.g., ‘LLM’)”

  prompt_translations -> generate_slug -> prompt_aliases
}

prompt_alias_suggestion: “Suggests adding it as an alias to the similar entry” {
  shape: callout
}

update_as_alias: “User confirms -> Update categories.json with the new alias”

create_new_entry: “User declines -> Proceed to create a new entry”

write_new_entry: “Write the newly defined category to categories.json”

end: “End”

start -> detect -> check_similarity

check_similarity -> prompt_alias_suggestion: “Yes”
check_similarity -> interactive_flow: “No”

prompt_alias_suggestion -> update_as_alias: “Confirm”
prompt_alias_suggestion -> interactive_flow: “Decline”

interactive_flow -> write_new_entry
write_new_entry -> end
update_as_alias -> end
```

当 `sync` 检测到新分类时，它会自动：

1. 询问新词条的各种语言翻译。
2. 根据英文翻译生成 URL 友好的 slug。
3. 询问是否需要添加别名。
4. 检查新分类是否与现有分类相似（例如 `LLM` vs `Large Language Model`），并建议将其添加为别名。为此，我引入了 `fastest-levenshtein` 库，并编写了一个 `SimilarityService`。

不知不觉**午饭时间**过了，我完全专注于完善这个交互功能。

### 3:00 PM：重构与分层架构

这时，`sync` 命令的逻辑变得很复杂，混合了文件读写、Markdown 解析、用户交互、相似度计算和数据更新等多种职责。

我觉得：「这太乱了，违背了单一职责原则。需要重新分层！」

**这是一个关键的转折点**。我决定用构建*大型应用*的思路来重构这个*小工具*，把 `blog-categories` 目录重新组织成清晰的分层结构：

```txt
blog-categories
├── handlers/      # 数据访问层: 读写文件、Git操作
├── services/      # 业务逻辑层: 核心算法、纯粹的业务规则
├── processors/    # 应用/编排层: 协调services和handlers完成一个用例
├── utils/         # 无状态的纯函数工具
├── main.ts        # 入口与CLI定义
└── types.ts       # 类型定义
```

接着，我手动实现了一个依赖注入容器来管理各个模块。

```typescript
// 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 ... */); // ...
}
```

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

```d2 title="Blog Category Tool Architecture Diagram"
style: {
  fill: transparent
}

direction: down

presentation: "Presentation Layer" {
  main: "main.ts (Commander.js)" {
    shape: rectangle
  }
}

application: "Application/Orchestration Layer" {
  validate: ValidateProcessor
  sync: SyncProcessor
  fixup: FixupProcessor
}

business: "Business Logic Layer (Services)" {
  factory: CategoryFactoryService
  similarity: SimilarityService
  merge: MergeService
  etc: "..."
}

data: "Data Access Layer (Handlers)" {
  category: CategoryHandler
  scanner: ContentScanner
  git: GitService
}

infrastructure: "Infrastructure" {
  json: "categories.json" {
    shape: stored_data
  }
  md: "*.md Files" {
    shape: document
  }
  repo: "Git Repository" {
    shape: cloud
  }
}

presentation.main -> application.validate: invokes
presentation.main -> application.sync: invokes
presentation.main -> application.fixup: invokes

application.validate -> business.similarity: uses
application.validate -> data.scanner: uses
application.validate -> data.git: uses

application.sync -> business.factory: uses
application.sync -> data.category: uses
application.sync -> data.scanner: uses

business.factory -> business.similarity: uses
business.factory -> data.category: uses

data.category -> infrastructure.json: "reads/writes"
data.scanner -> infrastructure.md: reads
data.git -> infrastructure.repo: reads
```

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

### 5:00 PM：完善功能——`fixup` 与文件锁

系统虽已优雅，但功能还不够完整。我注意到，如果先用中文创建分类（如「随笔」），其 slug 会是临时的 `chinese-category-12345`。我希望在补充英文翻译 `Essay` 后，slug 能自动更新为 `essay`。

`fixup` 命令因此诞生，专门处理这类数据不一致的情况。

然而，在编写保存逻辑时，我想到：「如果在两个终端里同时运行 `sync`，会不会把 `categories.json` 写坏？」

*理性*告诉我这不可能发生。但追求完美的想法已经占据了上风：「一个**健全的系统**，必须考虑*并发*。」

于是，我花了最后一个多小时，编写了那个如今看来过于复杂，却在技术上让我很满意的 `withFileLock` 函数。

```typescript
// 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` 库。

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

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