状态字段长出了三个头(以及我们是如何修复的)

发布: (2026年1月15日 GMT+8 10:16)
6 min read
原文: Dev.to

I’m happy to help translate the article, but I don’t have the text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source line exactly as you provided and preserve all formatting, markdown, and technical terms in the translation.

原罪

当我第一次构建导入/导出配置时,状态一目了然:

type ImportConfig struct {
    Status string `json:"status"` // "draft" | "active" | "paused"
}

简洁。简单。完成。

随后用户开始运行导入。我需要知道上一次运行是成功还是失败:

type ImportConfig struct {
    Status        string `json:"status"`        // "draft" | "active" | "paused"
    LastRunStatus string `json:"lastRunStatus"` // "success" | "failed" | "running"
}

仍然可控。两个字段,两种关注点。

接着我构建了向导。用户需要在流程中途保存进度,而不产生损坏的配置。轻松修复:

type ImportConfig struct {
    Status        string `json:"status"`
    LastRunStatus string `json:"lastRunStatus"`
    IsDraft       bool   `json:"isDraft"` // wizard in progress
}

三个头。一个怪物。

组合噩梦

组合噩梦

下面是三个字段的问题:它们会形成一个状态矩阵。

Status:        draft | active | paused | disabled | completed
LastRunStatus: success | failed | running | (empty)
IsDraft:       true | false

这就是 5 × 4 × 2 = 40 种可能的状态。其中大多数是毫无意义的。比如 status=active, lastRunStatus=running, isDraft=true 到底表示什么?

前端有这么段代码:

function getDisplayStatus(config: ImportConfig): string {
  if (config.isDraft) return 'draft';
  if (config.lastRunStatus === 'running') return 'running';
  if (config.status === 'paused') return 'paused';
  if (config.lastRunStatus === 'failed') return 'failed';
  if (config.status === 'active') return 'ready';
  return 'draft'; // ¯\_(ツ)_/¯
}

这个函数是错误的,只是我不知道到底哪些情况是错的。

认知时刻

我在添加新功能时意识到自己无法回答一个基本问题:

“这个导入可以直接运行吗?”

要得到答案,需要检查三个字段,理解它们的优先级,并且希望自己的逻辑是正确的。

就在那一刻,我明白了:我并没有对状态进行建模。我已经积累了症状。

修复:一字段统治一切

重构的概念很简单:

之前: 3 个字段追踪相互重叠的关注点
之后: 1 个字段拥有 5 种互斥状态

const (
    StatusDraft   = "draft"   // 向导未完成,缺少必填字段
    StatusReady   = "ready"   // 配置完整,可运行
    StatusRunning = "running" // 正在执行
    StatusPaused  = "paused"  // 用户已暂停执行
    StatusFailed  = "failed"  // 最近一次运行失败,需要处理
)

type ImportConfig struct {
    Status string `json:"status"` // 以上任意一个,仅此而已。
}

没有矩阵。没有优先级。没有猜测。

“这个导入准备好运行了吗?”config.Status == StatusReady

但是等等,向导怎么办?

isDraft 字段之所以存在,是因为向导需要保存部分进度。删除它意味着要解决另一个问题:向导状态存放在哪里?

答案:在 DraftData 字段中,仅当 Status == StatusDraft 时才存在:

type ImportConfig struct {
    Status    string     `json:"status"`
    DraftData *DraftData `json:"draftData,omitempty"` // nil unless Status=draft
}

type DraftData struct {
    CurrentStep     string          `json:"currentStep"`
    PendingSchema   *PendingSchema  `json:"pendingSchema,omitempty"`
    PendingDataType *PendingDataType `json:"pendingDataType,omitempty"`
}

关键洞察:待处理资源存放在草稿数据中,而不在真实表中。当你在向导中途创建模式时,该模式尚未真正存在。它是存储在 DraftData 中的待处理模式。只有在你完成确认后,它才会变为真实的。这样就不会出现因放弃向导而产生的孤立模式。

损害报告

修复此更改涉及:

  • 193 个文件
  • 11,786 行新增
  • 4,179 行删除
  • 后端模型、服务、处理程序
  • 前端类型、钩子、向导
  • 两端的测试

值得吗?getDisplayStatus 函数现在是:

function getDisplayStatus(config: ImportConfig): string {
  return config.status;
}

是的,值得。

教训

  1. 状态字段会成倍增长。 一个变成两个变成三个。每一次添加看似微小。复杂度是组合性的。
  2. “这是什么状态?”应该是显而易见的。 如果你需要流程图,那说明你有建模问题。
  3. 待定资源不是真正的资源。 向导状态是草稿数据,而不是部分创建的实体。
  4. 大型重构其实是许多小改动的集合。 193 个文件听起来很吓人。实际上只是“更新 Status 常量” × 193。

构建 Flywheel —— 为初创公司提供的数据管道。如果你曾经看到一个“简单”的字段长出三个头,我很想听听你的战斗故事。

Back to Blog

相关文章

阅读更多 »

Go 中优雅的领域驱动设计对象

❓ 你如何在 Go 中定义你的领域对象?Go 并不是典型的面向对象语言。当你尝试实现 Domain‑Driven Design(DDD)概念,如 Entity …

爱恨信 do Json

将 JSON 用作配置格式是一个错误。是的,一个错误。不是偏好,也不是风格选择。是一次架构错误。该说法倾向于同时……

Go的秘密生活:包和结构

第十一章:建筑师的蓝图 星期五下午的阳光斜斜地透过档案室的窗户,照亮了空气中舞动的尘埃。伊桑发现…