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

