Cobra 这个库大家都熟,Go 里但凡是个正经命令行工具基本都靠它。我自己用也用了好几年,但一直有个说不清道不明的感觉------它到底是怎么把一个 git commit --amend 这样的东西,正确地派发到对应函数的?
上周末闲着也是闲着,就决定自己撸一个阉割到只剩骨架的版本。不依赖任何第三方库,纯标准库,把"命令树 + 递归派发"这个最核心的思路跑通就行。代码写完大概两百来行,这篇就聊聊这东西是怎么搭起来的。
一个 Command 就是一棵树的一个节点
整个工具的核心就一个 struct,没别的:
go
type Command struct {
Name string
Description string
Run func([]string) error
Hidden bool
AllowArg bool
subCommands []*Command
}
每个命令知道自己叫啥、说句人话描述是啥、真正干活的 Run 函数在哪,以及它下面挂着哪些子命令。
Hidden 和 AllowArg 是两个顺手加的小开关。Hidden 字面意思,不在帮助里列出来,但命令本身还能用------有些内部调试命令我不想让用户看见,就这么处理。AllowArg 解决的是"裸参数"问题:像 ping google.com 这种,google.com 既不是 flag 也不是子命令,默认我是不让它过的,但 ping 这种命令确实需要吃一个地址,所以给个开关放行。
加子命令就一个方法,顺便做个重名校验:
go
func (c *Command) AddCommand(cmd *Command) error {
if c.Name == cmd.Name {
return fmt.Errorf("子命令不能与父命令同名: %s", cmd.Name)
}
c.subCommands = append(c.subCommands, cmd)
return nil
}
就这,一棵命令树就建起来了。
派发逻辑是这东西的命门
真正有意思的是 execute 这个函数,它是整个工具的递归派发核心。说白了就干一件事:拿到一串参数,判断下一步该往下钻到哪个子命令,还是说到底了该执行了。
go
func execute(cmd *Command, args []string, root bool) error {
if len(args) == 1 {
return run(cmd, args[1:])
}
if root && cmd.Name != args[1] && !contains(cmd.subCommands, args[1]) {
return run(cmd, args[1:])
}
if len(args) >= 2 && cmd.Name == args[1] {
if len(args) < 3 || !contains(cmd.subCommands, args[2]) {
return run(cmd, args[2:])
}
}
if len(cmd.subCommands) == 0 {
return run(cmd, args[1:])
}
for _, sub := range cmd.subCommands {
if len(args) >= 2 && sub.Name == args[1] {
return execute(sub, args, false)
}
if len(args) >= 3 && sub.Name == args[2] {
return execute(sub, args[1:], false)
}
}
return fmt.Errorf("未找到命令: %v", args[1:])
}
这块逻辑我得承认写得有点绕,第一次看的人大概率会愣一下。我拆开说:
len(args) == 1:os.Args里只有程序名,啥参数没有,那就直接跑当前命令。root的特殊处理:顶层命令时,如果第一个参数既不是命令名、也不是任何已知子命令(比如用户瞎敲了一个词),那干脆把整串参数丢给顶层命令的 Run 去处理。这是给根命令留的一个"兜底"入口。cmd.Name == args[1]:说明参数里显式写了命令名(最常见就是根命令myapp自己),那就看后面跟的是不是子命令,不是的话就从args[2:]开始跑。- 最后那个循环:挨个比对子命令名。注意它同时查了
args[1]和args[2]------后者是为了支持嵌套子命令(myapp serve child这种),递归时参数切片没截断,所以子命令名可能落在第二或第三位。
说实话,这里用 args[2] 兜底匹配是我当时图省事写的,属于能用但不够优雅。正经实现应该用个位置游标一路往后走,而不是每次都从固定下标猜。不过对于一个周末玩具来说,跑通就行。
真正执行前,还得收拾点杂活
匹配到最终命令后,就进 run:
go
func run(cmd *Command, args []string) error {
if len(args) > 0 && !isFlag(args[0]) && !cmd.AllowArg {
_ = PrintHelp(os.Stdout, cmd)
return fmt.Errorf("未找到命令: %s", args[0])
}
if isHelp(args) {
return PrintHelp(os.Stdout, cmd)
}
if cmd.Run == nil {
_ = PrintHelp(os.Stdout, cmd)
return fmt.Errorf("命令 %s 不可执行(缺少 Run 函数)", cmd.Name)
}
return cmd.Run(args)
}
三道关卡:--help/-h 优先拦截;非法裸参数(不是 flag 又没开 AllowArg)直接打帮助并报错;Run 为空也不许跑。都过了才真正调用业务函数。
这里有个细节值得说:帮助系统是"失败兜底"而不是"主动服务"。无论是因为参数不对、命令没实现、还是用户主动要帮助,统一都是调 PrintHelp。实现很朴素:
go
func PrintHelp(w io.Writer, cmd *Command) error {
_, _ = fmt.Fprintf(w, "\n%s\n\n", cmd.Description)
var visible []*Command
for _, sub := range cmd.subCommands {
if !sub.Hidden {
visible = append(visible, sub)
}
}
if len(visible) > 0 {
_, _ = fmt.Fprintln(w, "可用命令:")
for _, sub := range visible {
desc := strings.SplitN(sub.Description, "\n", 2)[0]
_, _ = fmt.Fprintf(w, " %-15s %s\n", sub.Name, desc)
}
_, _ = fmt.Fprintln(w)
}
_, _ = fmt.Fprintf(w, "用法: %s [选项] [命令]\n", filepath.Base(os.Args[0]))
_, _ = fmt.Fprintln(w, " --help, -h 显示帮助信息")
return nil
}
就缩进打印一下描述和子命令列表,Hidden 的在这里被过滤掉。没有颜色、没有分类、没有自动生成的 flag 说明------够用。
几个配套的小函数也顺手贴一下,它们撑起了上面那些判断:
go
func contains(cmds []*Command, name string) bool {
for _, c := range cmds {
if c.Name == name {
return true
}
}
return false
}
func isFlag(arg string) bool {
return len(arg) > 0 && arg[0] == '-'
}
func isHelp(args []string) bool {
for _, a := range args {
if a == "-h" || a == "--help" {
return true
}
}
return false
}
isFlag 的判断方式很粗暴------就看第一个字符是不是 -。这也就意味着它根本不区分短横线和长横线,更别提 = 连写之类的了。后面会说到,这正是这版最大的软肋。
入口 Execute 就更直白了,把 os.Args 整个喂进去,标记这是 root:
go
func Execute(cmd *Command) error {
return execute(cmd, os.Args, true)
}
跑起来长这样
用法照着 Cobra 的套路来:
go
func main() {
root := &cli.Command{
Name: "myapp",
Description: "myapp - 一个演示命令行的 Demo 程序",
Run: func(args []string) error {
fmt.Println("欢迎使用 myapp!使用 --help 查看帮助。")
return nil
},
}
serve := &cli.Command{
Name: "serve",
Description: "启动 HTTP 服务器",
Run: func(args []string) error {
port := "8080"
for i, a := range args {
if a == "--port" && i+1 < len(args) {
port = args[i+1]
}
}
fmt.Printf("服务器启动中,监听端口: %s ...\n", port)
return nil
},
}
root.AddCommand(serve)
ping := &cli.Command{
Name: "ping",
Description: "检测目标是否可达",
Run: func(args []string) error {
target := "localhost"
if len(args) > 0 && args[0][0] != '-' {
target = args[0]
}
fmt.Printf("正在 ping %s ...\n", target)
return nil
},
AllowArg: true,
}
root.AddCommand(ping)
secret := &cli.Command{
Name: "secret",
Description: "这是一个隐藏命令",
Run: func(args []string) error {
fmt.Println("你发现了一个隐藏命令!")
return nil
},
Hidden: true,
}
root.AddCommand(secret)
if err := cli.Execute(root); err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
os.Exit(1)
}
}
myapp 打欢迎语,myapp serve --port 9090 起服务,myapp ping google.com 因为开了 AllowArg 能吃裸参数,myapp secret 能用但不显示在帮助里。四五百行的 Cobra 示例能干的事,这几十行也能干个七七八八。
说点实在的:这玩意儿离能用还差得远
写完了回头看,这版本最大的硬伤是根本没正经解析 flag。你看 serve 里那个循环,是我手动 for i, a := range args 去翻 --port 的,纯手工活。真正的 Cobra 背后挂着 pflag,能把 --port 9090、-p 9090、--port=9090 各种写法归一,还能做类型校验、默认值、必填检查。我这连等号写法都不支持。
另外几个明显缺胳膊少腿的地方:
- 没有 persistent flag:父命令定义的 flag 没法自动下传给子命令。
- 派发逻辑脆弱:前面说的
args[2]兜底匹配,遇到多层嵌套或者参数顺序稍微一变就容易翻车。 - 错误处理太糙:找不到命令就一句
未找到命令完事,没有"你是不是想输入 xxx"这种模糊建议。 - 没有自动补全、没有版本命令、没有配置文件集成------这些 Cobra 开箱就有的东西,我这通通没有。
但话说回来,写这个的初衷就不是替代 Cobra。它更像一次"拆轮子"练习:当你亲手把命令树、递归派发、帮助兜底这几块撸一遍之后,再回头看 Cobra 的 Command 结构和 Execute() 入口,那种"哦------原来就这么回事"的通透感,是光读文档得不到的。
如果你也想试试,建议从这个骨架出发,先给 execute 加个位置游标替换掉 args[1]/args[2] 那套,然后把 flag 解析接上 pflag。这两步走完,一个能打的日常小工具框架就成型了。
代码仓库地址: cli