ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写 CLI-Anything:把脚本、API 和数据库查询统一收进命令行

手写 CLI-Anything:把脚本、API 和数据库查询统一收进命令行 1. 为什么我非要把所有东西都塞进命令行如果你跟我一样在终端里待的时间比在浏览器里还长那你大概率也冒出过这个念头能不能把那些用鼠标点好几层菜单才能完成的操作统统用一条命令解决掉。CLI-Anything 不是某个大厂出的框架也没有一搜一大把的官方文档——它是我自己攒了大半年的一个项目名准确说是一套“把任意东西封装成命令行工具”的方法论和脚手架。这篇文章不打算讲空概念直接把我是怎么把散落在各个角落的 Python 脚本、curl 命令、数据库查询统一收编成一套 CLI以及这个过程中踩过的坑、做过的取舍写出来。适合看这篇东西的人我大概划了这么三类第一类是脚本管理已经快失控的开发者~/bin和~/scripts下躺着几十个随手写的 .py 文件半年后自己都分不清哪个是哪个第二类是偏爱键盘流、想把常用操作都按进终端的人第三类是想自己搭一套通用命令行工作流、但不知道从哪儿下手的同学。我先说说自己的场景。之前我的日常操作是这样的访问内部接口用 curl参数一长串每次都要翻历史命令找格式查数据库用另一个脚本它输出的是裸数据看起来像一堵墙发个通知又得去翻一个专门的服务页面手工点按钮。这些东西相互独立、风格各异几个月不用再回来光搞清楚“上次我是怎么调这个脚本的”就要花掉小十分钟。真正的转折点是有一天我要在同事的机器上跑一条查询人家问我这条命令到底是干嘛的我憋了半天说不出个囫囵话。那一刻我才意识到工具如果只有自己能读懂那它就是负担不是资产。CLI-Anything 的目标很朴素——让每个功能都有清晰的命令名、统一的参数风格、标准的输出格式随便什么人都能看--help上手。项目从零搭起的时候我给自己定了三条规矩一个主入口所有子命令都挂在它下面比如ca db query、ca api get记忆成本降到最低参数风格全局统一短参数、长参数、必填项、默认值的写法保持一致输出要么是易读的表格要么是标准 JSON绝不能再出现“一堆 print 砸脸”的情况。这篇文章就是你照着它能复现出同样一套东西的完整记录。我用的都是 Python 生态里最常见的库没整任何花活。2. 先定骨架命令注册、路由表和子命令分发代码还没写几行我先把整体骨架想清楚了。CLI 工具最忌讳的就是没有结构所有功能塞在一个文件里main 函数里堆几百行 if/else。那样写一时爽维护火葬场。CLI-Anything 的核心是一个“注册表 装饰器 子命令分发”三层结构。注册表就是一个全局字典装饰器负责把函数登记进去分发逻辑则根据用户输入的命令名从这个字典里找到对应函数去执行。2.1 用装饰器实现“一行注册”我的第一版代码极其简单早期就是这样# cli_anything/registry.py COMMANDS {} def command(nameNone, help_text, argsNone): def wrapper(func): key name if name is not None else func.__name__.replace(_, -) COMMANDS[key] { func: func, help: help_text, args: args or [], } return func return wrapper这个设计有几个好处。第一命令名和函数名可以不一样逻辑该怎么命名还怎么命名暴露给用户的命令格式可以单独定第二因为装饰器保留原函数我可以在不破坏调用方的前提前把参数定义和函数实现分开维护第三新加一个子命令只需要在任意文件里写一个函数加一行装饰器注册动作自动完成完全不用去动路由那段代码。我习惯的命令名规范是小写字母加中划线比如db-query写成db query这种两级子命令会在路由里单独拆分基础版本先保持api-get这样的单级命令名。2.2 主入口别手写 if/else借力 argparse 子命令实现路由之前我也纠结过是直接用if command xxx逐个分支还是用 argparse 的add_subparsers。踩过一次手写路由的坑之后我坚定地用 argparse。手写 if/else 的问题在于参数解析、帮助信息、报错格式全要自己造轮子而且很难做到统一。用 argparse 子命令就省心得多# cli_anything/main.py import argparse from cli_anything.registry import COMMANDS def build_parser(): parser argparse.ArgumentParser( progca, descriptionCLI-Anything: 统一命令行入口, ) # 全局参数所有子命令都可用 parser.add_argument(-c, --config, help指定配置文件的路径) parser.add_argument(-d, --debug, actionstore_true, help开启调试输出) subparsers parser.add_subparsers(destcommand, requiredTrue) for name, meta in COMMANDS.items(): sub subparsers.add_parser(name, helpmeta[help]) for arg_meta in meta[args]: kwargs dict(arg_meta) flag kwargs.pop(name) sub.add_argument(flag, **kwargs) return parser def main(argvNone): parser build_parser() args parser.parse_args(argv) if args.debug: setup_logging(levelDEBUG) else: setup_logging(levelINFO) command_meta COMMANDS.get(args.command) if command_meta is None: parser.error(f未知命令: {args.command}) # 让每个命令函数自己决定从什么形式拿参数 func command_meta[func] try: return func(args) except KeyboardInterrupt: return 130 except Exception as exc: if args.debug: raise print(f错误: {exc}, filesys.stderr) return 1 if __name__ __main__: sys.exit(main())这里有两个细节值得展开。第一个是requiredTrue子命令参数在新版 Python 里是必填的如果没有这个参数用户空运行时工具会静默退出特别容易造成困惑第二个是func(args)我故意不传展开后的关键字参数命令函数统一收args对象等到命令内部再自己取字段。这样做的代价是命令函数和 argparse 的Namespace有耦合换来的好处是整个路由框架不需要关心每个命令有多少参数添加新命令时框架代码一行都不用改。2.3 参数定义收敛成统一风格为了让所有子命令长得像一家人我把参数声明也做成了一套约定。每个参数在注册的时候就是一个字典长这样command( nameapi-get, help_text发送 GET 请求, args[ {name: url, help: 目标地址}, {name: --timeout, type: int, default: 10, help: 超时秒数}, {name: --headers, action: append, help: 可以多次传递的请求头}, ], ) def api_get(args): ...全局约定是必填位置参数放前面可选参数用--xxx长选项能加默认值的一律加默认值所有打印信息走统一的输出函数。这个约定我没写进任何设计文档全靠代码 review 时一条条盯出来的。等命令数量到了二十多个风格统一的优势就很明显——你不需要回忆“上一个命令是写成--verbose还是-v”看一遍ca api-get --help就能猜个八九不离十。3. 核心落地把脚本、接口和数据库查询封装成子命令骨架搭好了接下来是往里面填实打实的功能。这一步最关键的不是写代码而是想清楚“到底怎么把已有能力变成 CLI 子命令”。我挑了三个最典型的场景来讲。3.1 封装一个本地脚本函数假设你手上有一个parse_report.py内部逻辑是读某个报表、做数据清洗、输出处理后的 CSV。如果只想把它收编进 CLI-Anything最简单的办法是直接当函数调# cli_anything/scripts/report.py from cli_anything.registry import command command( namereport-parse, help_text解析报表文件并输出清洗结果, args[ {name: input_path, help: 原始报表路径}, {name: --output, -o, default: output.csv, help: 输出路径}, {name: --sheet, default: Sheet1, help: Excel 工作表名}, ], ) def report_parse(args): from .your_report_module import parse_and_clean df parse_and_clean(args.input_path, sheet_nameargs.sheet) df.to_csv(args.output, indexFalse) print(f已写入 {args.output})这里有个性能技巧把重依赖的 import 写在函数体内而不是文件顶部。CLI 工具对启动速度很敏感如果一敲ca report-parse --help都要等 pandas 加载完整整一秒体验就崩了。把 import 挪进去之后只有真正执行到命令时才会加载--help和其它命令完全不受拖累。3.2 封装外部 API请求、超时与重试日常里最多的其实是“调接口”。以前我每次都要复制一长串 curl现在我把它包装成了一个api子命令command( nameapi, help_text通用 HTTP 请求, args[ {name: method, choices: [get, post, put, delete]}, {name: url, help: 请求地址}, {name: --data, help: POST 请求体JSON 字符串}, {name: --timeout, type: int, default: 10}, {name: --retry, type: int, default: 0, help: 失败后重试次数}, ], ) def api(args): import requests kwargs {timeout: args.timeout} if args.data: import json kwargs[json] json.loads(args.data) for attempt in range(args.retry 1): try: response requests.request(args.method, args.url, **kwargs) response.raise_for_status() break except requests.RequestException: if attempt args.retry: raise print_response(response)这个封装有我自己很在意的几个点超时必须有默认值防止某个接口卡死把整个终端拖住重试只在幂等请求上开启所以我把--retry交给用户自己判断另外 JSON 字符串用json.loads解析而不是 eval安全第一。print_response是我自己写的一个输出函数会根据响应的 Content-Type 自动判断打印成格式化 JSON 还是纯文本同时给状态码 ≥400 的内容打上醒目标记。这一步特别重要CLI 工具输出的美感直接决定你愿不愿意用它。3.3 封装数据库查询输出格式化才是灵魂数据库查询是我最开始最不想封装的东西因为结果格式太难统一了。后来想通了与其让命令函数自己 print 每一行不如让所有查询结果都过同一个渲染管线。command( namedb-query, help_text在默认数据库连接上执行 SQL, args[ {name: sql, help: 要执行的 SQL 语句}, {name: --db, help: 覆盖配置文件里的数据库名}, {name: --format, choices: [table, json, csv], default: table}, ], ) def db_query(args): from cli_anything.db import get_connection from cli_anything.output import render_rows conn get_connection(args.db) cursor conn.execute(args.sql) columns [desc[0] for desc in cursor.description] rows cursor.fetchall() render_rows(columns, rows, output_formatargs.format)render_rows的实现依赖一个叫tabulate的库它能自动适配宽度和表格对齐。如果选json我会把每一行转成 dict用json.dumps(..., ensure_asciiFalse, indent2)输出。这样同一个查询想要人看还是想被别的程序消费都能满足。有人可能觉得--format json多此一举但实际用起来你会发现很多场景你其实就是想把查询结果喂给下一个命令管道里传好看的表格完全没用JSON 才是万能中间格式。3.4 配置文件的读取层次CLI 工具做大了之后最烦的就是配置散落各处。我的方案是规定一个明确的优先级命令行参数 环境变量 配置文件 默认值。命令行参数由 argparse 天然支持环境变量我统一加CLI_ANYTHING_前缀配置文件默认放在~/.config/cli-anything/config.yaml也支持用--config覆盖。加载配置的代码长这样# cli_anything/config.py import os from pathlib import Path import yaml DEFAULT_CONFIG_PATH Path(~/.config/cli-anything/config.yaml).expanduser() def load_config(explicit_pathNone): path Path(explicit_path) if explicit_path else DEFAULT_CONFIG_PATH config {} if path.exists(): with open(path, encodingutf-8) as f: config yaml.safe_load(f) or {} return config配置里放的都是连接信息、默认好习惯之类的长尾参数比如数据库默认连接串、通知服务地址、报表目录这些。我把这些默认值从命令参数里抽出来之后命令行本身变得很干净需要覆盖的时候再临时传参数就行。4. 工程化细节日志、错误码、调试模式和 Tab 补全一个工具能不能长期用下去不取决于它功能多不多而是细节糙不糙。CLI-Anything 到了这一步我开始处理那些“看起来不起眼但没有就浑身难受”的工程问题。4.1 让输出“像样”的逻辑CLI 工具的输出风格我总结成三句话人能看的要有结构机器能读的要符合标准出错时要一眼看见。实现上我用一个output.py集中所有打印逻辑。正常信息打白色成功打绿色警告打黄色错误打红色。在支持 ANSI 的终端里这是标准操作关键是要留一个--no-color开关否则它在管道重定向、持续集成环境里会输出一堆转义字符污染日志。我上个月就遇到一次同事把ca db-query ... | grep xxx的结果贴到聊天工具里结果满屏\x1b[32m这类乱码。后来我规定了一个默认行为当检测到输出不是 TTY也就是被管道或重定向了时自动关闭颜色。用 Python 判断很简单import sys def should_use_color(): if os.getenv(NO_COLOR): return False return sys.stdout.isatty()这个规范和业界通用的NO_COLOR约定保持一致让工具在管道场景下行为更安全。4.2 退出码比想象中更重要命令行工具的退出码是给 shell 看的也是最容易被忽略的部分。我按 Linux 惯例定了一套0 表示成功1 表示普通执行错误2 表示参数错误130 表示用户按了 Ctrl-C。这套约定如果从第一天就定好后面所有 shell 脚本都能放心用、||来判断跳转。argparse 在参数错误时默认返回 2这一点我很早就手动验证过所以业务逻辑层发生了运行时异常我就只写return 1不跟 2 抢语义。在全局异常捕获里我还把 traceback 抑制住了只输出一行错误信息用户想深挖细节就必须加--debug。这个设计是有意的默认路径干净利落排查路径信息齐全。调试模式这个功能一开始我觉得能打印堆栈就够了后来发现不够。一个工具最让人崩溃的时刻是“命令明明加了--debug却没输出任何东西”不是程序出错而是日志级别没有压低。所以我在全局setup_logging里做了配合开启--debug时标准库 logging 的级别切到 DEBUG同时把第三方库的日志级别调到 WARNING防止 requests 这种库刷屏。def setup_logging(levellogging.INFO): logging.basicConfig( levellevel, format%(asctime)s %(levelname)s %(message)s, ) if level logging.DEBUG: logging.getLogger(urllib3).setLevel(logging.WARNING)4.3 Tab 补全终端效率的最后拼图命令行工具如果只有单级命令记住命令名不算难一旦子命令超过十个你就必须依赖 Tab 补全。argparse 本身不提供补全能力但生成补全脚本的生态很成熟。我用的是shtab一条命令就能给 bash 和 zsh 生成补全脚本。shtab --shellbash --progca cli_anything.main.build_parser ~/.bash_completion.d/ca.bash然后在~/.bashrc里 source 一下。这样敲ca apTab会自动补全成ca api参数也能跟着提示。补全有个前置条件解析器必须能被独立构建不能依赖main()里的运行时状态。所以我把build_parser()单独放到了模块顶层而不是塞进 main 的局部变量里。这算是一个“为了补全反推架构设计”的典型例子。5. 平台兼容与分发同一个命令两边都能跑项目做到一半我发现如果工具只在 Linux 上跑得顺那它的价值就少了一半。团队里用 Windows 的人不在少数而 Windows 上的终端和 Linux 有一堆隐性差异很多都是不踩不知道的。5.1 路径、编码和 PowerShell 的“惊喜”第一个坑是路径分隔符。我早期直接拿/拼接配置文件路径在 Windows 上就出问题。后来统一用pathlib.Path所有拼接、判断、遍历都依赖这个标准库两边行为就完全一致了。第二个坑是编码。Windows 默认的 GBK 编码在打印中文时一旦遇到某些字符就可能抛UnicodeEncodeError。最省心的解决方案是给 Python 加 UTF-8 运行时模式Windows 上我直接设置环境变量$env:PYTHONUTF8 1或者在入口脚本最前面加import sys if hasattr(sys.stdout, reconfigure): sys.stdout.reconfigure(encodingutf-8) sys.stderr.reconfigure(encodingutf-8)第三种是 PowerShell 本身的$OutputEncoding问题管道传给 Python 程序时中文会乱码。我试过修改注册表里的CodePage太激进只在个人的机器上用过。团队协作时我建议直接用上面的 reconfigure 方案简单且覆盖面大。5.2 打包成真正的“命令”我一开始是直接python cli_anything/main.py这样跑后来意识到这根本不算“工具”。要把东西变成像ls一样随手可用的命令我选择把它做成一个真正的 Python 包并用入口点安装# pyproject.toml [project] name cli-anything version 0.1.0 [project.scripts] ca cli_anything.main:main这样pip install -e .之后系统里就多了一条ca命令而且python -m cli_anything也能等价调用。Windows 上它会自动生成ca.exe放在 Scripts 目录不需要手动配 PATH前提是装的时候 Python 的 Scripts 目录已经在 PATH 里。5.3 依赖隔离别污染系统环境CLI 工具最忌讳的就是把依赖装到系统 Python 里一旦和系统自带的其它包冲突整个环境都会变得很古怪。我现在一律用虚拟环境开发部署到长期使用的机器时也会给用户写清楚推荐做法python -m venv ~/.venvs/cli-anything source ~/.venvs/cli-anything/bin/activate pip install -e .在 Windows 上对应的激活命令是~\.venvs\cli-anything\Scripts\activate。如果你觉得同一个 Python 环境里项目太多也可以试试uv做工具管理和依赖锁定它创建虚拟环境的速度比传统方式快不少。还有一个比较实用的技巧我把 CLI-Anything 的配置目录也当作“可移动数据目录”比如数据库连接信息、通知 token 这些敏感内容都放在配置文件里且明确给配置文件加上chmod 600权限。命令行里不建议直接传密钥因为 shell 历史会记下所有参数一不留神就泄露了。6. 把“任何”变成现实插件目录与约定优先于配置做到这一步CLI-Anything 在我手里已经稳定跑了三个月。但标题里那个“Any”逼着我继续往前走——光是我自己维护一套命令并不是真的“anything”。要让它能装进别人的东西必须有插件机制。6.1 插件协议一个 register 函数约等于一切我设计插件协议时只定了一条硬规矩插件文件必须暴露一个register(cli)函数接收一个“命令注册器”在里面调用它提供的add_command注册自己的子命令。插件存放目录定为~/.cli-anything/plugins/每个文件是一个.py模块。主程序启动时扫描目录import 每个模块然后调用register# cli_anything/plugin_loader.py import importlib import importlib.util from pathlib import Path PLUGIN_DIR Path(~/.cli-anything/plugins).expanduser() def load_plugins(registry): if not PLUGIN_DIR.exists(): return [] loaded [] for pyfile in sorted(PLUGIN_DIR.glob(*.py)): if pyfile.name.startswith(_): continue spec importlib.util.spec_from_file_location( fcli_anything_plugin_{pyfile.stem}, pyfile ) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) if hasattr(module, register): module.register(registry) loaded.append(pyfile.stem) return loaded这个设计最大的优势就是零配置。用户想加一个新能力只需要把文件丢进插件目录下次运行ca时就自动出现了。从“配置驱动”退到“约定驱动”对个人工具来说是一条正确的路——少一个配置文件就少一个出错点。registry在这里其实就是我第一节写的COMMANDS字典加几个辅助方法我没有额外定义接口类因为 Python 的鸭子类型已经足够插件里只需要调用register.add_command(name..., func..., args...)这种简单方法即可协议越薄越好上手。6.2 一次实测把一个内部管理脚本变成插件上个月我把一个“检查线上配置文件漂移”的内部脚本迁进了插件体系。原来那个脚本是自己维护的独立文件里面定义了三个函数和一堆参数。我改造成插件时基本没删业务代码只做两件事把主函数签名的参数挪进args列表再加上一层register壳# ~/.cli-anything/plugins/config_diff.py from cli_anything.registry import command command( nameconfig-diff, help_text对比本地配置与线上配置的差异, args[ {name: env, choices: [dev, staging, prod]}, {name: --local-dir, default: ./configs}, ], ) def config_diff(args): from nacelle_checker import run_diff changes run_diff(envargs.env, local_dirargs.local_dir) for item in changes: print(f- {item.path}: {item.change_type}) def register(cli): cli.add_command(config_diff)整个过程不到半小时。因为骨架把参数解析、帮助信息这些外围工作全包圆了业务代码几乎是零成本嵌入系统。这给了我一个启示CLI-Anything 的核心价值不是帮我写了某个脚本而是给所有脚本提供了同一套入口、同一套约定和同一套输出规范。6.3 踩过的坑与我的使用心得插件机制也带来了几个新问题。第一个是 importlib 的模块缓存开发插件时反复改代码重跑ca却还是旧行为因为同一个模块已经缓存了。我的处理方式是用importlib.reload或干脆在 loader 里用哈希判断文件变化不过这只是开发期的痛点用户正常使用不受影响。第二个坑是插件内部的异常如果裸着向上抛主程序的全局捕获会打印一行“错误: xxx”然后退出用户很难知道是哪个插件出的问题。所以我在 loader 里给每个插件单独做异常隔离try: module.register(registry) except Exception as exc: print(f加载插件 {pyfile.stem} 失败: {exc}, filesys.stderr)这样单个插件坏了不影响其它命令。用了大半年下来我最大的体会是CLI-Anything 最大的价值不是节省了多少秒的执行时间而是把我从“记脚本名、记参数格式、记输出格式”的琐碎里解放了出来。每新增一个能力我不再考虑新建什么文件、怎么调用、怎么传参只需要遵循那一小套约定剩下的全交给同一个入口。最后分享一个小技巧插件目录里我特意放了一个_template.py里面是一个待填写的命令函数模板每次开新插件我直接复制这个文件再改比看文档快得多。把工具的“起步成本”压到最低你才真的愿意用它这就是 CLI-Anything 留给我最有价值的一条工程经验。
返回列表