如何用自己熟悉的语言写CLI
我自己不熟悉bash脚本,或者说不常用,经常是学了忘。如果没有深入的学习,要想实现一个复杂的逻辑,感觉是难于登天。所以我想向大家分享一下用自己熟悉的编程语言写命令行程序的经验。根据我们团队的情况,就以python3为例来实现一个调试业务逻辑的工具来进行实践讲述。
你可以下载本次的代码跟着实践。
项目需求
这个工具的需求,主要来源于有一些业务逻辑问题,经常需要去排查。或者为了临时测试需要在测试环境查看、修改一些数据。我们以premium这个模块为例,一般需要:
- 结束当前的premium sale
- 往前调premium sale的开始时间
- 查看premium sale的kv
- 给某个用户extend/remove premium
- 查看一些premium 接口的返回
以上这些需求,其实我都有一些小脚本。但是排查问题经常是需要进行多次操作的。重复输入一些user_id、app_code、platform、app_version等参数是 非常繁琐的。所以我就想把这些小命令整合起来,把这些输入的参数转化为一种context。同一个context下,执行不同的命令就不需要重复输入这些烦人的参数。
premium_doctor命令
说了那么多,还是赶快进入正题,先让项目跑起来吧!我们暂时给项目取名premium_doctor创建一个premium_doctor的文件夹,并且创建一个main.py的文件,打印出一句 “hello, world”
#!/usr/bin/env python3
print('hello, world')
然后将他变成一个可以全局调用的命令。
> chmod +x main.py # 赋予main.py可执行权限
> alias premium_doctor="`pwd`/main.py" # 暂时用alias让它可以全局调用
# 到家目录试着调用一下
> cd ~
> premium_doctor
hello, world
这样一个"hello, world"命令离我们要的premium_doctor命令还很远。我们再给前面所提的需求,预先命名好其对应的功能函数:
#!/usr/bin/env python3
# commit: 62dc2730336bc56a02a6a4f3e8df0e2a7199385d
def run():
print('hello, world')
def end_sale():
print('end_sale success!')
def move_sale_days(days=-1):
print(f'move_sale_days {days} days success!')
def view_sale_kv():
print('Current campaign: xxx')
print('Start At: 2023-03-13 17:38:00')
print('End At: 2023-03-15 17:38:00')
def extend_premium(seconds=1800):
print(f'extend_premium {seconds} seconds success!')
def remove_premium():
print('remove_premium success!')
if __name__ == '__main__':
run()
以上就是premium_doctor命令的草图。具体的功能实现离大家太远,并且也不是本次分享的重点,所以我全部用print代替。我们关心的重点是与命令行相关的部分。
参数输入与子命令
子命令和参数其实都是CLI启动参数输入的一部分。在python里,程序的启动参数可以通过sys.argv
获取。例如,如果premium_doctor打印出argv,则执行premium_doctor move_sale_days -2
的时候argv是:
> premium_doctor move_sale_days -2
['/Users/zhenguo/Code/python/premium_doctor/main.py', 'move_sale_days', '-2']
hello, world
整个argv都是程序的启动参数,但这个不是premium_doctor所"认为"的参数。我们会这样看待这段输入:
/Users/zhenguo/Code/python/premium_doctor/main.py
只是程序本身,没什么用,可以忽略。move_sale_days
是子命令-2
是子命令move_sale_days
的参数days
只要你所用的语言,有获取完整启动参数的接口,你想怎么设计你CLI的子命令、参数都是可以的。不过现今,几乎所有的流行语言都有几个CLI Framework,可以直接供我们使用。我们从这个列表里挑选一个python的typer库,用它来解析启动参数,以实现子命令注册、参数解析。
参照官网的文档,安装完typer后,对premium_doctor进行一些改装:
#!/usr/bin/env python3
# d6800919cb81c245c8bb7c59b1ee8a56734484aa
from typing import Optional
import typer
app = typer.Typer()
@app.command()
def end_sale():
print('end_sale success!')
@app.command()
def move_sale_days(days: Optional[int] = -1):
print(f'move_sale_days {days} days success!')
@app.command()
def view_sale_kv():
print('Current campaign: xxx')
print('Start At: 2023-03-13 17:38:00')
print('End At: 2023-03-15 17:38:00')
@app.command()
def extend_premium(seconds: Optional[int] = 1800):
print(f'extend_premium {seconds} seconds success!')
@app.command()
def remove_premium():
print('remove_premium success!')
if __name__ == '__main__':
app()
用了typer之后,整个premium_doctor一下子有点样子了。
> premium_doctor --help # 已经自动生成了help信息
Usage: main.py [OPTIONS] COMMAND [ARGS]...
Options:
--install-completion Install completion for the current shell.
--show-completion Show completion for the current shell, to copy it or
customize the installation.
--help Show this message and exit.
Commands:
end-sale
extend-premium
move-sale-days
remove-premium
view-sale-kv
> premium_doctor move-sale-days --help # 而且子命令也有help信息
Usage: main.py move-sale-days [OPTIONS]
Options:
--days INTEGER [default: -1]
--help Show this message and exit.
switch-context命令
premium_doctor的初衷是可以共用user_id等context信息。我们再给它加一个命令用来切换
# 6a7248f19b1fccc97932aac908f1dc06c48878f3
@app.command()
def switch_context(user_id: int):
# mock data
app_version = f'4.32.{random.randint(0, 10)}'
app_code = f'app{random.randint(0, 3)}'
platform = 'ios' if random.random() > 0.5 else 'android'
context = {
'user_id': user_id,
'app_version': app_version,
'app_code': app_code,
'platform': platform,
}
context_path = os.path.expanduser('~/.config/premium_doctor/context.json')
with open(context_path, 'w') as f:
json.dump(context, f, indent=4)
然后尝试进行切换context:
> mkdir ~/.config/premium_doctor # 出于简化代码的目的,我们暂时先手动创建context目录
> premium_doctor switch-context 1234
> cat ~/.config/premium_doctor/context.json
{
"user_id": 1234,
"app_version": "4.32.6",
"app_code": "app3",
"platform": "ios"
}
在实际的项目中,我是通过user_id去查找测试机器上的请求日志,进而自动解析出app_code等其它context信息的。这个步骤还可以进行一些优化,这里提供一些想法:
- 如果有多个context,可以让用户选择
- 如果找不到context,可以让用户输入
- user_id还是有点麻烦,是不是可以用关键字搜索想要的user,进而得到context
- 是不是可以支持切换到某个历史context?
- 是不是可以类似cd命令一样,支持
-
符号?即切换到上次的context
不过想法终究只是想法,没有痛点需求的驱动,可能也没动力去实现,即便实现了也很容易被遗忘。
支持PS1环境变量
现在我们已经可以切换context了,并且其它子命令都可以读取context的信息,这里就不细说了,不重要。回头看看其它子命令,大部分都是写操作。当你不知道当前的context是什么的时候,贸然执行写操作,多少都有点鲁莽,虽然只是在测试环境使用(其实创建测试账号也是蛮麻烦的一个过程)。大概有两种方式比较可取:
- 提供一个view-context的命令
- 支持PS1环境变量
前一种略过不说,主要讲讲PS1。所谓PS,应该是指prompt string,而PS1就是命令行的提示符。PS系列的环境变量里,也就PS1比较有用,其它的就不做介绍。我们希望能把context的信息显示在提示符上,类似这样:
zhenguo@sandbox:~ uid: 5872284 on app1-ios v5.0.0
>
PS1变量可以嵌入动态的命令,例如:
> export PS1="\u@\h \$(date +%s) $ " # 提示符将会带有timestamp
\u
跟\h
是PS1自己定义的变量,这里不多做讨论。date
是一个命令,date +%s
会显示当前的timestamp。而用\$()
把命令包裹起来,每次解析PS1的时候,就会得到一个动态的内容。
了解了这个知识点,就知道怎么支持PS1了:
- 实现一个新命令或者子命令用来显示context信息
- 把这个命令加入到PS1中
# da0dcc5b8601b322ab92cf71b079cd1efd69101f
@app.command()
def ps1():
ctx = get_context()
items = [
f'uid: {ctx["user_id"]}',
f'on {ctx["app_code"]}-{ctx["platform"]} v{ctx["app_version"]}',
]
ps1 = ' '.join(items)
print(ps1, end='')
# 修改完PS1之后,提示符会显示context了
zhenguo@Zhenguos-MacBook-Pro$ export PS1="\u@\h $(premium_doctor ps1)$ "
zhenguo@Zhenguos-MacBook-Pro uid: 324 on app2-ios v4.32.4$
命令补全
如果把premium_doctor跟ls之类的命令比较,你就会发现它非常的啰里八嗦。
- 为什么premium_doctor不能取名pd?
- 为什么不能用-e表示end_sale,-m表示move_sale_days?
这里不深入讨论,简单列举几个原因:
- 最重要的原因是它没有ls之类的基础命令重要。
- 功能复杂的命令群,可读性比字符精简更重要。
- option参数不适合用来表示子命令
要解决"啰里八嗦"的问题,完全可以用采用下列两种方案:
- 自己定义一些alias
- 让CLI支持命令补全
alias的方式没什么好多介绍的。我们这里只介绍一下如何让CLI支持命令行补全。
不同shell的补全接口不太一样,总体而言bash的接口算是最原始的,其它shell类似zsh/fish都能兼容。所以 这里就以bash的接口为例。
bash用complete命令来给命令注册补全信息,最简单的是用-W参数进行静态注册,如下:
> complete -W "switch-context end-sale extend-premium" premium_doctor
# 注册完之后premium_doctor就有了简单的补全
> premium_doctor [TAB][TAB]
end-sale extend-premium switch-context
> premium_doctor s[TAB]
> premium_doctor switch-context
# 但这种补全还不太准确。如下,switch-context子命令下还会继续给出补全选项。
> premium_doctor switch-context [TAB][TAB]
end-sale extend-premium switch-context
其实,如果要求不高,上面这种方式基本也就够用了。如果想要好的补全体验,类似git checkout
可以补全出准确的分支名。那么就需要把补全注册成函数式的。形式如下:
function _premium_doctor {
COMPREPLY=($(premium_doctor complete $COMP_POINT $COMP_LINE));
}
complete -F _premium_doctor premium_doctor
_premium_doctor
就是premium_doctor的补全函数。这个补全函数用到了三个补全变量:
- COMPREPLY 用来承接补全结果 (complete reply)
- $COMP_POINT 表示光标所处的下标
- $COMP_LINE 表示当前的所有输入
还有一些别的补全变量,但是这里暂时用不到。上面这个补全函数的大意是:
- 把$COMP_POINT跟$COMP_LINE作为参数传给
premium_doctor complete
命令 - 把命令的执行结果的stdout,作为补全结果
让我们来实现这个complete子命令:
# e65403d4ff407e4cbc410be14d52297802194d61
@app.command()
def complete(comp_point: int, comp_line: List[str]):
result = ['hello', 'world']
with open('/tmp/complete-debug-info.txt', 'w') as f:
f.write(f'comp_point: {comp_point}\n')
f.write(f'comp_line: {comp_line}\n')
f.write(f'result: {result}\n')
print(' '.join(result), end='')
可以看到,complete子命令只提供了两个补全选项,即hello跟world,最后用空格分割,组装成一个字符串,输出到stdout里。而在输出之前,还把一些debug信息输出到一个临时文件里。这是因为stdout已经被bash的补全函数给占用,如果我们把一些调试信息输出到stdout,就会导致补全错乱。让我们一起来看看效果如何:
> premium_doctor [TAB][TAB]
hello world
> premium_doctor h[TAB]
hello world
效果不是很理想,虽然第一步给了两个选项,但是当你多出入一个h再按TAB键的时候,并没有如预期的那样,自动把hello给补全了。这是因为当你输入一个h的时候,complete函数应该只给出一个hello选项,bash才会自动把剩余的部分补齐。
我们来解决这个问题:
@app.command()
def complete(comp_point: int, comp_line_parts: List[str]):
comp_line_parts = comp_line_parts[1:] # 第一个元素必定为'premium_doctor',于补全无用
comp_line_parts = comp_line_parts or ['']
first_word = comp_line_parts[0]
last_word = comp_line_parts[-1]
options = ['hello', 'world']
if first_word in options:
# comp_line == 'premium_doctor hello'
result = []
elif last_word == '':
# comp_line == 'premium_doctor '
result = options
else:
# comp_line == 'premium_doctor h'
result = [x for x in options if x.startswith(last_word)]
with open('/tmp/complete-debug-info.txt', 'w') as f:
f.write(f'comp_point: {comp_point}\n')
f.write(f'comp_line: {comp_line_parts}\n')
f.write(f'result: {result}\n')
print(' '.join(result), end='')
再来试试效果:
> premium_doctor h[TAB]
> premium_doctor hello # 搞定!
美化"UI"
到目前为止,整个CLI该有的功能算是都有了。这时候就可以考虑对它进行一些美化。比如,加上点颜色来凸显重点;操作比较久的话加个进度条动画;把输出的内容用纯文本的表格表示;甚至有类似下拉框的操作等等。
这些功能都有有很多第三库可以直接使用,这里不多说。这里主要讲讲它们实现原理,即shell(准确的讲是terminal)提供了什么样的接口供上层调用?答案是 ANSI/VT100编码
简单而言,VT100编码是终端的特殊控制符,当它们被"打印"出来的时候,就会起到特定的控制作用:
- 控制光标
- 控制颜色
- 控制屏幕打印
- 控制滚动
- 其它一些配置
我们用echo -e
实验一下:
> echo -e "\033[10B" # -e 参数可以让echo处理这些特殊控制符。光标会下跳10行
> echo -e "\033[7;31;43m" # 颜色变得非常的辣眼睛。。
类Unix系统基本都带有一个很古老的C语言库curses /ˈkɜːsɪz/ ,最早是为了写shell游戏用的。它对VT100等一些shell接口进行了封装,提供了更高级别的调用接口。熟知的htop、vim等命令都是基于这个库进行开发的。github还有一个curses topic ,里面都是基于curses再进行抽象的封装库。
如何拧好手中的螺丝?
git这类的命令,就像CLI界的火箭一样,大多时候离我们比较遥远。上面的例子意在向你演示如何用自己熟悉的语言调用shell的接口来实现一个比较现代化的CLI。很多时候,我们根本用不到补全、子命令、PS1、Pipe这类特性。
相比造火箭,我更推荐大家多拧一拧螺丝。我个人的经验是:
- 写出来的CLI功能尽量单一简洁;
- 不需要维护过多的help信息;
- 只有1-2个参数,甚至不要参数;
- 多写,即便是造轮子,慢慢形成自己的工具箱;
如果一个想法真的不错,经过自己实践跟使用改进后,就可以考虑写成一个比较现代化的CLI,拿出来分享。