iT邦幫忙

2026 iThome 鐵人賽

DAY 6
1

「在數百上千張筆記交織成的知識庫中,毫秒級的迴圈處理速度是保持流暢開發體驗(DX)的基石。Go 語言原生強大的檔案 I/O 與併發能力,正是執行這項任務的不二人選。」

⚡ 為什麼用 Go 來當 Vault 處理器?

Vault 掃描是一個會被高頻執行的動作——之後每天示範都要重新掃一次。Go 編譯成單一二進位檔,啟動時間趨近 0ms,跟需要直譯器啟動成本的 Python/Node.js 相比有明顯優勢;標準庫的 filepath.WalkDir 本身也是效率不錯的原生實作。但要誠實說明:這個優勢目前只來自「啟動快、原生工具鏈」,不是平行演算法——demo vault 現階段只有 2 篇真實筆記,規模遠遠稱不上「大容量」,這個階段引入 goroutine/worker pool 只會增加維護負擔,不會帶來可量測的效益,因此本次的掃描器刻意維持單執行緒循序掃描。

🧱 1. 設計 Note 與 Frontmatter 資料結構

延續 Day 05 已定案的 cmd/ + internal/ 佈局(internal/ 讓 Go 編譯器強制不可被外部模組 import,符合「不對外釋出成函式庫」的現況),資料結構放在 internal/vault/note.goFrontmatter 的欄位直接對齊 Day 04 封存的 note-metadata-schema spec:五個必填字串欄位、三個選填字串陣列欄位。

type Frontmatter struct {
	ID      string   `yaml:"id"`
	Title   string   `yaml:"title"`
	Date    string   `yaml:"date"`
	Type    string   `yaml:"type"`
	Status  string   `yaml:"status"`
	Tags    []string `yaml:"tags"`
	Related []string `yaml:"related"`
	Aliases []string `yaml:"aliases"`
}

type Note struct {
	FilePath    string
	Frontmatter Frontmatter
	Body        string
	RawContent  string
}

ParseRawNote 負責切開頭以 --- 包夾的 YAML 區塊與其後的 Markdown 正文,用 gopkg.in/yaml.v3(原本已在 go.mod 但標記 // indirect,實際 import 後執行 go build -mod=mod ./... 會自動轉成 direct 依賴)把 YAML 解析成 Frontmatter struct:

func ParseRawNote(filePath string, rawContent []byte) (*Note, error) {
	frontmatterYAML, body, err := splitFrontmatter(rawContent)
	if err != nil {
		return nil, err
	}

	var fm Frontmatter
	if err := yaml.Unmarshal(frontmatterYAML, &fm); err != nil {
		return nil, fmt.Errorf("解析 Frontmatter YAML 失敗: %w", err)
	}

	return &Note{FilePath: filePath, Frontmatter: fm, Body: body, RawContent: string(rawContent)}, nil
}

這裡刻意只做「結構化解析」:YAML 能不能轉成 struct,就是成功或失敗,不驗證 id 格式、type/status 是否落在受控詞彙、title 是否跟檔名一致。語意層級的合法性檢查留給 Day 11 的 health check,避免今天的範圍蔓延。


🔍 2. 實作高效率 Vault 掃描器 (Scanner)

internal/vault/scanner.go 用標準庫 filepath.WalkDir 循序遍歷 vault 目錄,跳過名稱以 . 開頭的目錄(.git.obsidian)與非 .md 檔案:

func Scan(root string) ([]*Note, []ScanError, error) {
	var notes []*Note
	var scanErrors []ScanError

	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			if path != root && strings.HasPrefix(d.Name(), ".") {
				return filepath.SkipDir
			}
			return nil
		}
		if filepath.Ext(d.Name()) != ".md" {
			return nil
		}

		rawContent, readErr := os.ReadFile(path)
		if readErr != nil {
			scanErrors = append(scanErrors, ScanError{FilePath: path, Err: readErr})
			return nil
		}
		note, parseErr := ParseRawNote(path, rawContent)
		if parseErr != nil {
			scanErrors = append(scanErrors, ScanError{FilePath: path, Err: parseErr})
			return nil
		}
		notes = append(notes, note)
		return nil
	})
	if err != nil {
		return nil, nil, err
	}
	return notes, scanErrors, nil
}

單一檔案解析失敗(缺少 Frontmatter、YAML 語法錯誤)不會中斷整體掃描,而是收進 []ScanError,跟成功解析的 []*Note 清單一起回傳——一座真實的 vault,遲早會有某篇筆記忘記加 Frontmatter,掃描器不能因為一篇壞檔案就整個掛掉。

值得一提的邊界情況是 vault/README.md:它副檔名是 .md,會被掃描器讀到,但因為沒有 YAML Frontmatter,會被 ParseRawNote 判定為「缺少 Frontmatter」而落入錯誤清單,而不是被特別 hardcode 排除——讓它跟未來任何一篇忘記加 Frontmatter 的筆記走同一套判斷邏輯,不需要額外維護白名單。


🚀 3. 接軌 cmd/brain/main.go

cmd/brain/main.go 掛上 scan 子指令,串接 vault.Scan,逐筆輸出筆記資訊與統計:

func newScanCmd() *cobra.Command {
	return &cobra.Command{
		Use:   "scan",
		Short: "掃描 vault 目錄下所有筆記並輸出清單與統計",
		RunE: func(cmd *cobra.Command, args []string) error {
			notes, scanErrors, err := vault.Scan(vaultRoot)
			if err != nil {
				return err
			}
			for _, note := range notes {
				fm := note.Frontmatter
				fmt.Fprintf(cmd.OutOrStdout(), "- [%s] %s (%s/%s)\n", fm.ID, fm.Title, fm.Type, fm.Status)
			}
			fmt.Fprintf(cmd.OutOrStdout(), "共 %d 篇筆記,%d 筆解析失敗\n", len(notes), len(scanErrors))
			for _, scanErr := range scanErrors {
				fmt.Fprintf(cmd.OutOrStdout(), "  失敗:%s\n", scanErr.Error())
			}
			return nil
		},
	}
}

輸出刻意只用最小可行的純文字格式,不做 JSON、不做彩色終端輸出,除非未來有明確的消費端(例如 Day 21 的 Graphify 匯出)需要結構化格式。同步在 Makefile 新增 make scan 目標,取代每次手打 make run ARGS="scan"


🧪 4. 實機測試

對現有 vault(00_Inbox/PARA 筆記法.md20_Areas/Cobra CLI 框架.md、以及沒有 Frontmatter 的 vault/README.md)執行:

$ make scan
go run ./cmd/brain scan
- [20260815-090000] PARA 筆記法 (inbox-draft/seed)
- [20260815-093000] Cobra CLI 框架 (atomic-note/growing)
共 2 篇筆記,1 筆解析失敗
  失敗:vault/README.md: 缺少 Frontmatter:檔案開頭必須是 "---"

vault/00_Inbox/ 新增一篇符合規範的測試筆記後重跑 make scan,新筆記正確出現在清單中;放入一篇刻意缺少 Frontmatter 的檔案,scan 依然正常執行完畢(結束碼 0),只是多列進失敗清單。internal/vault 底下的 table-driven 測試(testify)涵蓋 ParseRawNote 的成功解析、缺少 Frontmatter、非法 YAML、選填欄位缺席等情境,以及 Scan 的跳過隱藏目錄、跳過非 .md 檔案、單一檔案失敗不中斷等情境,go test ./...gofmt -l .go vet ./... 全數通過。

💬 結語

今天我們成功用 Go 打造了 brain-cli 的第一重基石!我們實現了:

  • 高效全庫遍歷(跳過 .git / .obsidian)。
  • 切分 YAML Frontmatter 與 Markdown Body。
  • 對齊自訂標準 Metadata 規格。

這意味著我們的 CLI 工具已經具備了讀取 Obsidian 檔案結構的能力。


上一篇
建立 obsidian-agent-brain Demo Repo 與開發環境搭建
下一篇
零阻力收集(Capture):實作 brain capture 終端指令
系列文
打造 AI Agent 驅動的第二大腦:用 Go + Claude Code + Obsidian + Graphify 打造工程師知識作業系統7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言