CLI/Command line interface/命令行界面 是任何接触过 Unix 系统乃至大多数了解过计算机系统的人都有过认识或有用过的程序界面。
同时也是很多人都是望而生畏的对象

这篇文章不是在解释命令行的历史和 Shell 的使用,虽然有提到但主要都是在为 命令行参数的设计风格 这一主题服务

(默认系统环境是常规 Linux + bash 啦)

相对比与 GUI 而言

命令行从操作上的一大特点就是完全且直接的键盘操作,只需要“命令”的名称及其参数配上自动补全就可以迅速的执行某些简短的操作。
比如常用于文件操作的 ls, cp, mkdir 各自都有简短的主要参数以及能看上一小会的帮助信息(后面说啦)

但虽说提高了效率,但“光标”面前的也便不再是一个个复选框和输入框,一片漆黑的底色却将其取而代之。
这也是命令行程序的一个致命缺点:易用性

任何程序的使用者一开始都是不知道怎么使用程序的,但使用 GUI 时能干的所有事都会显示在窗口之上。
使用者只需看一眼就能明白这些按钮的大概作用,也会对自己能用这个软件做些什么有些把握

但命令行程序大多会有一个特殊的使用方式 command --help,这在几乎所有程序中都意味着打印该程序的帮助信息,这些信息很可能会由几十行的参数格式、标志(flag)列表和详情、和类似散文的注释组成。(这些内容的结构可能会在下文中写到)
在读到这些冗长的信息前使用者不能对程序做出任何的尝试,但同样的,在看完这些信息后用户也会对程序能干的事有着更加清晰的理解

但这也不是全部,命令行程序所在的项目(一个项目功能可能会有好多个主命令)大多都会有一或几份叫做 手册(manual) 的东西。这些页面都会随着程序的软件包一同下载到系统里,man 命令是查阅他们的入口,比如 ls 的手册可以用 man ls 查看

乍一看手册中的内容与帮助信息(–help)中显示的差别可能不大,但手册中一般会在散文类型的描述上放飞自我。这些手册中包含了几乎所有使用或者只是有关于该命令的全部信息

这对于理解程序固然为好事,那些详细信息正是一般 GUI 软件难以直观展现或者已不突兀的方式链接到文档页面的。
不过站在学习曲线的角度看,命令行如此繁琐的认知过程也让它变得相对于 GUI 而言及其不直观和难以学习

况且 Windows 的历史也会无时无刻的指示人们 GUI 对于普通人而言是最好的程序界面

谁在用呢

我对这方面其实也没什么太多的历史经验

但在家用计算机初期,从 BASIC 到 MS-DOS 的操作方式都和命令行离不开关系。那是的计算机只能做到在终端上显示字符,人们也由此开发出了一系列有趣且实用的软件

人们也似乎一直认为使用命令行界面的实用工具是操纵计算机底层的最直接的办法,虽说有些类似服务器系统的 web 管理面板也会提供及其完善的控制功能,但这几十年的历史积累下来的实用程序也不是说着玩的嘛 (º﹃º )

另外就是至少对某些种类的软件开发者而言,命令行快速高效的特性可以在很多需要重复操作的场景中提高操纵效率,加上脚本更是能让开发者顺畅的的组合很多基础操作并且简单的依次执行(iPhone 上的快捷指令也是这个意思来着的(虽说我没用过))

既然说到脚本就不得不提 unix 命令的“哲学”了

Unix 命令设计哲学

这不是完整的介绍,只是我的感受

很多程序的设计是以易于使用为目标的,实用程序首先会考虑用户的角度需要什么并应该尽量减少实现用户需求所要的学习成本

另外每一个操作都需要足够的小,开发者固然能想到很多常用场景,但将选择权交给用户能直接适配全部场景

另外在 unix 系统中,名为“管道”的系统尤为重要。
在 C/C++ 中有几个特殊的文件流叫做 stdin/stdout/stderr 它们都是从那是一直保留现代的管道“接口”。
其中 stdin 是程序接受管道输入的地方,stdout 也就是程序向其他地方输出数据的接口,至于 stderr 则是用于传递不想输出给其他程序而是显示到用户眼前的信息

Shell 中的基本逻辑

这些管道中流转的其实也只是文本数据,比如在 bash 中的管道符 | 可以将前后两个程序连接到一起,比如:

echo "Hello World" | cat

中的 echo 会将后面的字符串输出到 echostdout 并接入到 catstdin,在默认情况下 cat 会将这些信息打印到它的 stdout 在这里也就是默认的终端界面

以及像变量赋值和替换等操作也让脚本变得相当灵活(但也带来了超多的不确定性,等下再说):

# 通过变量替换让 echo 打印当前目录,而不是让 pwd 直接输出
varTest=$(pwd)
echo "$varTest"

如果将管道指向文件,便会得到一个极其简单方便的写入文件的方式:

echo "text here" > ./test.txt

但需要注意的是 bash 中的变量只有字符串这一种类型,由于所有变量会没有任何保护的替换到最终命令里,任何未经确认是否合规或者为空的变量(尤其是用户输入的)都是不可信且极度危险的,因为这样的程序真的可以执行:

# 简写为 rm -rf /* 它会删除根目录下的所有文件
# 如果这个 var 的内容由用户输入,脚本就会偏离编写者意愿的执行某些很坏的代码
var="--recursive --force /*"
rm $var

对于这种攻击最简单的不完全防御方式是将输入内容用双引号 "" 扩起来,这会让 shell 把这一段内容当作一个 arguments/参数 传递给命令(准确的讲是可执行文件),但这仍然会引起程序的报错。
如果这样做,在上面的例子中,rm 会把整个 "--recursive --force /*" 当一整个 flag/标志 来处理(而非两个标志一个参数),结果就是匹配不到长成这样的标志也就报错退出了。
另外,不要尝试这个例子

更具体的解释说不定下面关于标志和参数风格那里有写,不过最好还是认真的看看不同语言中对于命令参数的处理方式为好

另外还有用单独的 -- 命令来停止标志解析的小用法,这在下文有写,但在这里还不是那么重要


标志和参数的获取风格们的简介

这其实才是我真正想写的东西,也是我想要总结自己接触命令行以来经验的章节

每个命令行程序的参数样式并不是固定的,但在具体说之前有几个需要指明的关键概念:

术语 Argument/参数

没有任何前缀的字符串,语境中用于声明程序主要的操作对象,比如 rm 要删除的文件是什么,一个程序很可能会有可选的多个不同意义的参数可供输入。
而且这也是输入给程序的原始字符串的总成(简写 args),这点其实挺容易搞混的

C/C++ 语言中主函数接受的 (int argc, char *argv[]) 分别就是 argument countargument value 的缩写

术语 Flag/标志

用于修饰程序的行为,比如 mkdir 是否创建不存在的父文件夹,rm 是否递归删除文件夹

Unix 风格的命令行参数

一般而言,类 unix 系统的标志都已 --- 开头,
其中 -- 开头一般会接上 >= 两个字符的完整单词,就像这样:--color,这也会被称为长名称;
- 开头一般意味着某个长名称的缩写版,比如 --color -> -c

短名称标志的作用和缺点

短名称的出现只是在加快人类输入的速度,毕竟每次都打一个超长的词还是蛮费劲的。
脚本编写中,为了维护时更清晰的头脑(尤其是对于不常用的程序)一律使用长名称会是个好选择的

还有个很显而易见的问题是,短名称只使用单个字母,26 个选项加上大写固然够用,但仍然相当容易造成冲突。
比如 protocolport 二词都以 p 开头,如果它们同样重要那把 -p 让就会直接严重的影响用户的体验

有些程序会将相对不重要的参数调剂到和原单词可能根本不相关的短名称上,但大写版本可能用的更多。
不过我对这种行为一向持保留态度… 好吧,或者说,该把哪些常用的长名称添加简写是个值得斟酌的问题

短名称标志可以合起来缩写

Unix 风格的短名称还有个有用的“语法糖”,虽说 rm -r -f 已经很简短了,但还可以更短:rm -rf
很明显这把两个短名称塞一块了… 就是这样

当然,对于需要参数的标志其实也是可以这样缩写的… 嘛(眼神飘忽)。
比如命令 tar -cvf ./message.tar ./message.txt(标志含义:c:创建;v:详细输出;f:指定输出文件)
中的 -f 必须放到组的最后以接受下一个参数 ./message.tar

如果写成 -cfv 的话,其实会创建一个叫做 v 的文件…

常见的长短名称共识以及兼容性

我不会以为自己真的能整合这堆东西吧,其实设计标志的命名时要防止与其他软件相混淆,最好的办法就是多去看看它们是怎么设计的,并将一些重要命名(长/短)放到自己的程序中。
其他人写了 cmd-1 --help 考虑到它的使用频率就最好不要只写 cmd-2 --show-usage

参数(Arguments)也是同理:
cp/mv 都在用 cp 原始文件 目的地(Copy source file to the destination) 时就不要没有理由的写成 cp-2 目的地 原始文件(Copy the file here from the source)
如果语义上的关系实在是没法平衡,也可以将其中一个或多个参数(比如原始文件)当作一个带有参数的标志,比如:cp-3 目的地 --source 原始文件。至少这样就没人会搞错了

(当然在兼容的前提下整整新东西也很棒的啦)

如何输入长很容易被混淆的参数

参数有着标志的前缀

很多(只是很多,自己试一下啦)程序都会有一个特殊的标志:一个单独的 --,程序只要解析到这里就会停止解析标志,并将后面的参数全部当成单纯的字符串处理。
举个例子,如果要创建一个叫做 --hell.txt文件夹,直接输入 mkdir --hell.txt 的话程序会看到参数前的 -- 前缀,进而把文件名当成一个标志处理

但很明显没有标志叫这个,报错也就来了

mkdir: unrecognized option '--hell.txt'
Try 'mkdir --help' for more information.

但如果在说明文件名前加上 -- 来停止解析标志(不将其余参数当作标志解析):mkdir -- --hell.txt
该文件夹就会被成功创建,另外记得删除的时候也要用 rm --dir -- --hell.txt

标志的前缀定义了一片特殊的“命名空间”,也让全局的参数丧失了一部分的可能性,而停止解析就是对这一现象的妥协

参数中有空格或者能被 Shell 转译的字符

不只是 bash,大多数 Shell 都用空格来分割要输入给程序的参数字符串的。
所以如果要输入的参数里包含空格,就需要借助到一些 Shell 提供的大括号将其包裹起来:mkdir "Bad Apple!!"
Shell 并不会将这两个大括号传递给程序,它们在 Shell 自己解析输入时就已经被删掉了

至于参数内部就有一个 " 的情况,bash 和很多别的语言一样都需要使用转义字符。就像:mkdir "0\"o"(最终文件夹名为:0"o
输入 \ 号也就需要 \\ 转译。
其实 Shell 里需要转译来保护的场景还是挺多的… 这也是我不喜欢 Shell 脚本的一点,经常需要想这个字符串会不会被转译呀什么的。
要想防护这种情况可以看看 printf %q ... 还是不细说了。都是头疼的东西

带有参数的标志

像上文中 cp-3 的例子一样,一个标志可以拥有一个或多个参数,它们独立于全局的参数顺序而仅仅为前跟的标志服务。
比如:cp-4 源文件 --flag "flag_var" 目的地 中间的 "flag_var" 并不会影响其余两个全局参数的解析,而是将值传递给了 --flag

除了用新的参数表示标志的参数外,还有两种相对常见的场景

用等号划分参数

首先是使用 = 号,这种方式其实会显得更清晰一点的说。
cp-5 源文件 目的地 --flag=flag_var
此处的参数中如果有空格的话 "--flag=x x"--flag="x x" 的形式都是可以的,Shell 好玩吧

但这样做的话,程序层面得到的标志和标志参数都处于一个字符串里

C/C++ 编译器留下来的奇怪标志格式

还有一种我个人认为非常丑陋且异端的格式…
也就是 C/C++ 编译器们在用的…

gcc -static -Wall -L./ -Dc_macro_name=2333

每个选项之间不添加任何的类似 =/: 的分隔符,而是依赖着大部分标志命名都只有一个字符,就直接… 吧参数怼在标志名后面

不过者也都是为了兼容性的考虑,C 的历史可是要追溯到个人电脑发展的初期呢。长久以来这些标志的风格被写进了大大小小的构建系统和人们的记忆中,想改也只能尽量在新的功能上尽量写的更友善了。
我一直认为这是个非常典型的严重历史遗留。不过也是命令行程序中有趣的一环呢 (@_@)

比较独特的小例子:dd 命令

嗯~ 刚才说的其实也都只是写规范和约定俗成,但程序实际怎么设计还是要看开发者怎么想

有一个例子是一个相当常用的命令 dd(dd - convert and copy a file) 它经常用于文件块的转录或者覆写还有擦除,万物皆文件的 unix 里 file 的含义还是挺多的。
但命令本身不是重点。它的参数设计方式很有趣

# oflag= 是 output flag 的意思。和前面的 of(output file) 所对应
# 而且它也确实有 iflag= 参数
dd if=/dev/sda of=./image.img status=progress oflag=sync,noatime

所有参数都没有使用常见的标志前缀,而是要求直接写上对应的名称。
这也是由于该命令设计时就没有打算使用全局参数,并为了更明了的格式所作出的变动

Windows 风格的命令行参数

但我没用过的说…

但有个基本思路就是短名称的标志都以 / 开头,很多 Windows 系统命令也对 --/- 有所兼容(?)

command /h

总之这不是要写的重点啦,接着来说说帮助信息吧

Unix 风格的帮助和手册页面

帮助信息是认识一个命令行程序的第一途径,通常要查看它需要一个已经算是约定俗成了的标志(长/短名称):--help/-h

一般来说,帮助信息会直接被打印到程序输出。但也可能打开类似手册(manual)的页面,比如 git 的很多子命令就是这样。
这些信息可能会告诉用户:参数格式(usage)、标志简介、注意事项、常见示例、以及版权信息

它们中的每一个部分都不会很详细,但旨在基本展示“我能干什么”和“怎样才能知道更多”。
而与其相对的手册(manual)就是在事无巨细的把程序或者整个项目的所有细节解释出来。
具体的差异看一看 git --helpman git 的区别就知道了,长了相当多

其中有个比较约定俗成的栏目是参数格式,也就是那个以 Usage: 开头的那段。
看起来可能是这样:command [--help | -h] [--banana[=kind]] [<path>]

  • [ ] 表示可选
  • | 表示“或”(就像很多编程语言那样)
  • < > 表示需要被替换成实际的值而不是当前显示的东西,比如 <path> 处就需要填写一个路径(./test.txt),而且也不是 <./test.txt> 的意思
  • 所以 [<path>] 的意思就是“如果需要指定路径(path)那么在这里写下,但也可以不指定”

可以看看这个更完整(且有点权威)的总结:12.1 Utility Argument Syntax - Open Group

子命令

子命令或者叫它 subcommand 在我看来是个非常大且有意义的设计改动,Git 和 Docker 是对其的极佳实践

当一个程序的功能可以被模块化分类时,更古早些的程序会选择使用多个二进制文件分类所有的模块(功能),比如 Linux 下的各种 LVM 控制命令: lv.../pv...
它们虽然数量可能有小几十个,但每个程序的参数风格都完美的与上文中的 unix 风格完美契合

子命令则是将这些模块整合到一个可执行文件中的设计风格。比如 git 中的 git initgit commit
这些命令的主命令(git)会搜索它获得的第一个非标志参数(比如字符串 init)并将剩下的参数交由相对应的 init 模块进行处理,一般而言最后一个子命令可以直接被视为传统 unix 风格命令的中第一个出现的主命令

这样做可以在很大程度上为命令未来的可扩展性打下基础,比如如果将来 git 有了第二套 init 模块且命令行标志与一代不兼容,那么加上一个 init2 作为子命令就可以完美的兼容二者。
同时像 Git LFS 和 Docker Compose 这些作为插件性质提供的模块,也选择了通过子命令的方式并入到程序中:对应的命令分别为 git lfs ...docker compose ...

Docker Compose 最早时使用的命令其实是独立的 docker-compose,但为了统一也被改到 docker ... 命令中了

多重嵌套的子命令

刚才之所以提到 Docker 是因为我对它的命令设计印象还是满深的,它不像 Git 那样只使用了一个子命令,而是多层的:docker image ls
这是对各个模块间的进一层抽象,也在语义上相当合理

子命令的每一层都可以接受标志

子命令的每一层看起来可能像个菜单,但这是命令行,所以得先让程序告诉用户菜单里都有什么(帮助信息)。
程序有可能会提供一个名为 help 的子命令专门用来打印帮助信息,甚至像 Git 一样作为打开其他子命令的帮助文档的一种途径:git help add(等价于 git add --help)。
但处于人的习惯所考量,git --help 应该是成立的,人们也确实是这么做的

但既然 --help 都成立了,那再包含些其他标志如何?
所以… 虽说 git --git-dir=/path/to/.git/ add ./test.c 看起来可能丢失了些子命令的美感,但它是有效的而且也尽了最大可能的保证了可读性和功能性

这里需要注意的是,上述例子的设计中不能将独属于 add 子命令的标志抽象到上一层级的 git ... 中,比如:git --interactive add
好吧,这个例子可能不是很好,但对于不是特别全局化的标志来说,将其放到上一层级会对可扩展性造成什么后果… 我不知道,但值得去好好斟酌一下

另外

  • 命令行界面/CLI 的程序可能被称为 实用工具/Utility
  • 终端中还个与 CLI 类似的 TUI/Terminal User Interface/终端用户界面 的东西,他和 GUI 就有点像了,比如类 top 的程序都是 TUI

我和命令行的关系

这篇帖子能写这么长也是在讲我接触命令行一两年以来的感受吧。
从 Windows 到在各种 Linux 发行版之间来回乱转的日子里,自己对命令行界面的理解也在一步步的加深。
见识到了很多程序,很多种设计模式。再到后来正经的学了 C 这种“编程语言(而非脚本)”,自己也在不断考虑参数的设计方式

另外,其实整个2024年八月我都没有在 blog 上发布任何东西,最多也只是改了改主题,所以正好来把命令行相关的事情都写一些啦。
我其实早就应该写这篇帖子了的

写了两天多,还是没赶在八月底前弄完www

我自己的解析器实现

另外写这么多除了是在心里想了很久以外,也是在为自己的解析器捋清思路。
我也做过几个命令行小程序了,但每次处理参数时的丑陋和不统一,甚至被迫将风格变丑,都无时无刻的提醒我要用些别的框架实现它们了

但,getopt 不是很想用,其它的(用于 C 语言的)库… 可能不是太和我胃口吧。所以就准备自己搓个小轮子

项目大概率会命名为 ArgParseX.c,等到差不多能用了再写篇新的帖子吧