很多人第一次写 Go 程序,是从一个 HTTP 服务开始。但在真实团队里,命令行小工具同样常见:清理过期文件、导入一批 CSV、生成报表、检查接口是否可用。小工具看起来简单,最容易写成“先能跑再说”的样子:路径写死在代码里,超时时间散落在函数里,出了错只打印一行“failed”。等这个工具被同事拿去每天跑,问题就来了。
Go 标准库里的 flag 包很朴素,没有花哨的子命令和自动补全,但它足够稳定。入门阶段先把 flag 用好,比一上来引入复杂 CLI 框架更能帮助你理解命令行配置的基本规则。
最小可用版本
假设我们要写一个检查 URL 的小工具,输入目标地址和超时时间,输出 HTTP 状态码。第一版可以这样:
package main
import (
"flag"
"fmt"
"net/http"
"os"
"time"
)
func main() {
target := flag.String("url", "", "target URL to check")
timeout := flag.Duration("timeout", 3*time.Second, "request timeout")
flag.Parse()
if *target == "" {
fmt.Fprintln(os.Stderr, "-url is required")
os.Exit(2)
}
client := &http.Client{Timeout: *timeout}
resp, err := client.Get(*target)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer resp.Body.Close()
fmt.Println(resp.Status)
}
这里有几个细节值得记住。flag.String 返回的是指针,所以后面用 *target 取值。flag.Duration 可以直接解析 3s、500ms、1m30s 这样的写法,比自己解析整数更安全。参数缺失时退出码用 2,表示用法错误;请求失败用 1,表示运行失败。退出码虽然小,却能让脚本和 CI 判断结果。
默认值要像真实使用
默认值不是随便填的。比如超时时间默认 3 秒,就是在“不要太慢”和“网络偶尔抖动”之间做折中。如果默认值离真实场景太远,使用者会每次都传参数,等于没有默认值。
go run . -url https://example.com -timeout 5s
如果工具运行在内网,默认超时可以短一点;如果它要访问跨境服务,默认超时可能要长一点。默认值应该来自业务经验,而不是来自代码作者当天的心情。写注释时也要说人话,不要把 request timeout 写成 timeout flag,后者没有提供任何额外信息。
自定义 Usage
默认的帮助信息能用,但有时你希望补充例子。可以覆盖 flag.Usage:
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage:\n")
fmt.Fprintf(flag.CommandLine.Output(), " healthcheck -url URL [options]\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Examples:\n")
fmt.Fprintf(flag.CommandLine.Output(), " healthcheck -url https://example.com -timeout 5s\n\n")
flag.PrintDefaults()
}
这个函数要在 flag.Parse() 之前设置。一个好帮助信息不需要很长,但要告诉用户最常见的用法。很多内部工具被交接时,下一位维护者不是先读源码,而是先敲 -h。帮助信息写清楚,少开很多口头解释会。
校验不要拖到深处
参数校验应该尽早做。比如 URL 为空、超时时间小于等于 0、输出路径是目录,这些都可以在 main 开始阶段处理。不要等到业务函数里才发现参数坏了。
func validate(target string, timeout time.Duration) error {
if target == "" {
return fmt.Errorf("-url is required")
}
if timeout <= 0 {
return fmt.Errorf("-timeout must be positive")
}
return nil
}
校验函数看起来普通,但它能让主流程更清楚:
if err := validate(*target, *timeout); err != nil {
fmt.Fprintln(os.Stderr, err)
flag.Usage()
os.Exit(2)
}
入门时常见错误是“先写业务,再补校验”。结果就是每个函数都防一点,错误信息还不一致。参数入口只有一个,能在入口拒绝,就不要让坏数据继续往里走。
环境变量可以做兜底
命令行工具在 CI 或定时任务里运行时,敏感值不适合写在参数里,因为参数可能出现在进程列表或日志里。可以用环境变量做兜底。比如 token:
token := flag.String("token", "", "API token, defaults to HEALTH_TOKEN")
flag.Parse()
actualToken := *token
if actualToken == "" {
actualToken = os.Getenv("HEALTH_TOKEN")
}
if actualToken == "" {
fmt.Fprintln(os.Stderr, "token is required, use -token or HEALTH_TOKEN")
os.Exit(2)
}
这里的规则要简单:命令行参数优先,环境变量兜底。不要同时支持配置文件、环境变量、参数、远程配置,还没有明确优先级。小工具最怕配置来源太多,最后没人知道实际生效的是哪一个。
把配置收进结构体
当参数超过三四个时,把它们收进结构体会更清楚:
type Config struct {
URL string
Token string
Timeout time.Duration
Verbose bool
}
func loadConfig() Config {
url := flag.String("url", "", "target URL")
token := flag.String("token", "", "API token")
timeout := flag.Duration("timeout", 3*time.Second, "request timeout")
verbose := flag.Bool("v", false, "print verbose logs")
flag.Parse()
cfg := Config{
URL: *url,
Token: *token,
Timeout: *timeout,
Verbose: *verbose,
}
if cfg.Token == "" {
cfg.Token = os.Getenv("HEALTH_TOKEN")
}
return cfg
}
这样业务函数只接收 Config,不用知道值来自命令行还是环境变量。以后要把工具改成服务,也更容易迁移。
不要把 flag 藏在库包里
flag.Parse() 最好只在 main 包里调用。库包如果偷偷注册 flag,会让调用者很难理解有哪些参数,也可能和别的包冲突。库包应该暴露普通函数或结构体,让 main 决定如何读取配置。
type Checker struct {
Client *http.Client
Token string
}
func (c Checker) Check(url string) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("server error: %s", resp.Status)
}
return nil
}
这段代码完全不关心 flag。它可以被命令行使用,也可以被测试、HTTP handler 或定时任务复用。边界清楚,是 Go 程序好维护的重要原因。
测试配置解析
标准库的全局 flag.CommandLine 不太适合直接在测试里反复解析。可以使用 flag.NewFlagSet:
func parseArgs(args []string, getenv func(string) string) (Config, error) {
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
url := fs.String("url", "", "target URL")
timeout := fs.Duration("timeout", 3*time.Second, "request timeout")
token := fs.String("token", "", "API token")
if err := fs.Parse(args); err != nil {
return Config{}, err
}
cfg := Config{URL: *url, Timeout: *timeout, Token: *token}
if cfg.Token == "" {
cfg.Token = getenv("HEALTH_TOKEN")
}
if cfg.URL == "" {
return Config{}, fmt.Errorf("-url is required")
}
return cfg, nil
}
测试时传入假的环境变量函数:
func TestParseArgsTokenFromEnv(t *testing.T) {
cfg, err := parseArgs([]string{"-url", "https://example.com"}, func(key string) string {
if key == "HEALTH_TOKEN" {
return "secret"
}
return ""
})
if err != nil {
t.Fatal(err)
}
if cfg.Token != "secret" {
t.Fatalf("token = %q", cfg.Token)
}
}
把环境变量访问抽成函数,看起来多一步,却让测试不用污染真实环境。小工具也值得测试,因为它们经常承担数据迁移、批量修复和发布检查这类高风险工作。
小结
flag 包不复杂,但它能训练你把入口、校验、默认值和业务逻辑分开。一个可靠的 Go 命令行工具,应该有清楚的帮助信息、合理的默认值、明确的配置优先级、可测试的参数解析,以及能被脚本理解的退出码。
入门时不要急着追求漂亮的命令行界面。先把参数读对、错误说清、边界摆正。等工具真的变复杂,再考虑子命令和第三方框架也不迟。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。