跳到主要内容
稻草人
プロフィール

EP.02

💻Technology

Goで小さなCLIツールを作る入門

Goで日常作業を自動化する小さなCLIツールを作るための実践メモ。

BY 稻, 草人

学习Go时,如果一直只写语法练习,很容易知道struct和interface怎么写,却不知道如何组合成真正能用的程序。

CLI工具很适合作为练习:没有复杂UI,可以直接使用标准库,最后还能生成一个独立可执行文件。

这次做一个mdcheck,扫描指定目录下的Markdown文件,检查是否有一级标题。

目次

先定义使用方式

mdcheck ./articles

输出:

OK   articles/go-cli.md
WARN articles/draft.md: missing H1

checked=2 warnings=1

返回规则:

  • 全部正常:退出码0
  • 发现内容问题:退出码1
  • 参数或文件读取失败:退出码2

CLI除了打印文字,退出码也很重要,因为CI或脚本会根据退出码判断成功失败。

初始化项目

mkdir mdcheck
cd mdcheck
go mod init example.com/mdcheck

主程序只负责组合流程

package main

import (
    "fmt"
    "os"
)

func main() {
    os.Exit(run(os.Args[1:]))
}

func run(args []string) int {
    if len(args) != 1 {
        fmt.Fprintln(os.Stderr, "usage: mdcheck <directory>")
        return 2
    }

    result, err := CheckDirectory(args[0])
    if err != nil {
        fmt.Fprintln(os.Stderr, "error:", err)
        return 2
    }

    for _, item := range result.Items {
        if item.HasH1 {
            fmt.Println("OK  ", item.Path)
        } else {
            fmt.Println("WARN", item.Path+": missing H1")
        }
    }

    fmt.Printf("\nchecked=%d warnings=%d\n",
        len(result.Items), result.WarningCount)

    if result.WarningCount > 0 {
        return 1
    }
    return 0
}

把逻辑放进run()而不是全部写在main()里,测试时就不用真的结束测试进程。

遍历文件

func CheckDirectory(root string) (Result, error) {
    result := Result{}

    err := filepath.WalkDir(root, func(
        path string,
        entry fs.DirEntry,
        walkErr error,
    ) error {
        if walkErr != nil {
            return walkErr
        }
        if entry.IsDir() ||
            strings.ToLower(filepath.Ext(path)) != ".md" {
            return nil
        }

        hasH1, err := fileHasH1(path)
        if err != nil {
            return err
        }

        result.Items = append(result.Items, Item{
            Path: path,
            HasH1: hasH1,
        })
        if !hasH1 {
            result.WarningCount++
        }
        return nil
    })

    return result, err
}

filepath会处理不同操作系统的路径分隔符,比自己拼接字符串安全。

检查标题

func fileHasH1(path string) (bool, error) {
    file, err := os.Open(path)
    if err != nil {
        return false, err
    }
    defer file.Close()

    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        if strings.HasPrefix(scanner.Text(), "# ") {
            return true, nil
        }
    }

    if err := scanner.Err(); err != nil {
        return false, err
    }
    return false, nil
}

这里没有一次把整个文件读入内存,而是逐行扫描。对小文件差别不大,但这种写法更容易扩展到较大文件。

需要注意,bufio.Scanner对单个token有默认大小限制。如果Markdown里可能出现非常长的一行,要调整buffer或改用bufio.Reader。

测试核心逻辑

func TestFileHasH1(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "sample.md")

    err := os.WriteFile(path, []byte("# Title\nbody"), 0644)
    if err != nil {
        t.Fatal(err)
    }

    got, err := fileHasH1(path)
    if err != nil {
        t.Fatal(err)
    }
    if !got {
        t.Fatal("expected H1")
    }
}

t.TempDir()创建的目录会在测试后清理,很适合文件类工具。

构建和安装

开发中可以:

go run . ./articles

生成可执行文件:

go build

安装到Go的bin目录:

go install

官方文档中,go build负责构建包及依赖,go install还会把结果安装到指定目录。

可以继续增加的功能

  • -format json输出JSON
  • -strict将更多警告视为错误
  • 忽略指定目录
  • 检查front matter
  • 检查重复slug
  • 在GitHub Actions中运行

增加功能前先保持核心检查函数不依赖终端输出。这样同一逻辑以后能用于CLI、测试或Web服务。

总结

这个工具不复杂,但包含了CLI开发的核心:参数、文件系统、错误处理、退出码、测试和构建。

Go适合这类工具的一个原因是标准库已经覆盖很多常用功能,而且可以编译成单一可执行文件。对学习者来说,比继续写零散语法例子更容易建立完整程序的感觉。

参考资料