拓冰建站拓冰建站
首页 / 资讯中心 / 正文

YAML配置驱动:把所有脚本统一成一条CLI命令

我电脑里的scripts/目录一度是个监管盲区。里面躺着deploy.sh、check_server.py、weekly_report、sync_data.rb还有一堆叫v2_final、fix_again的单文件工具。每个脚本都有自己的参数风格有的用短横线有的用下划线输出有的是纯文本有的是 JSON还有的直接吐一屏幕红色日志。每次要用其中某个工具我都得先翻一遍它的源码或者 README才能想起来该传哪个参数。CLI-Anything 这个项目就是为这个毛病做的——它把散落在各处的脚本、二进制和 HTTP 接口统一收敛成一个命令行入口。你不用重写底层逻辑只需要写一份 YAML 配置就能生成一个参数风格一致、帮助文档完整、自带补全和错误处理的 CLI。这篇文章就是我搭建和落地这个工具的全过程适合手里工具多、想统一命令入口的开发、运维和数据同学参考。1. 背景散装脚本为什么需要一个统一入口1.1 真实环境里最常见的工具乱象每个稍微有点年头的团队都会有自己的一套“非正式工具”。这些东西通常不是因为流程混乱才产生的恰恰相反它们大多是为了解决某个紧急问题而快速写出来的。比如线上服务出问题时有人写了curl 10.1.2.3:8080/health | jq .status为了快速看日志有人写了grep ERROR app.log | tail -50为了发发布通知又有人顺手写了个 Python 脚本调群机器人接口。问题不在这些工具本身而在于它们各自为政参数风格不同有的用--env有的用-e还有的直接用位置参数。输出格式不同有的会退避三舍地加 ANSI 颜色有的输出 JSON有的只是print(yes)。错误处理不同有的会设置退出码有的抛异常后直接堆栈崩溃。没有统一帮助入口团队新人只能靠口口相传才知道某个脚本存在。如果只有两三个工具这些差异还能忍。一旦脚本超过十个每次找正确参数的时间就开始超过工具本身节省的时间。最痛苦的是上线前一天你需要在不同目录下串行执行五六个脚本每个脚本的参数格式都不一样手一抖就是生产事故。1.2 CLI-Anything 的定位命令入口的统一层CLI-Anything 做的事情非常克制它不试图把所有工具改写成同一套语言也不要求你重构现有逻辑。它只是给每个已有工具包一层皮让它们看起来像同一个产品下的子命令。打个比方你的底层逻辑可以是肉夹馍、可以是盖浇饭、也可以是汉堡CLI-Anything 做的就是统一门店装修和点餐台。顾客进来之后看到的是同一块菜单牌、同一套取餐方式但后厨各做各的菜互不影响。这种做法的价值在团队场景里尤其明显。新同事接手项目时不需要挨个问“这个脚本怎么用”只需要敲一句anything --help就能看到所有可用命令、每个命令的参数、默认值和用途。这比维护一堆 Markdown 文档靠谱得多因为菜单直接从配置生成永远和实际行为同步。1.3 哪些场景和人群最需要它我做了半年多之后回头看最受益的是这三类场景运维排障巡检、重启、看日志、查指标这些操作分布在不同的机器和脚本里统一入口后可以在一个终端会话里完成整个排查链路。后端开发本地开发时经常要启动依赖服务、跑迁移脚本、造数据、调内部接口每个命令一套参数脑负担很重。数据分析跑定时报表、清洗不同来源的数据文件、聚合统计这类任务的输入输出通常很固定非常适合配置化封装。简单说只要你的日常工作需要记住大量非标准的内部命令CLI-Anything 就有用武之地。2. 核心设计配置驱动 统一边界规则2.1 为什么不做“编码驱动”而选“配置驱动”最开始我有过两个方案A 是写一个抽象基类每个工具继承后实现run()方法B 是纯配置驱动用 YAML 描述命令框架负责生成解析和分派。对比下来方案 A 如果要做得好等于重新造一个微型的 Click/Typer。每个工具类要管参数定义、帮助文本、类型转换、错误处理代码量一点没少只是把脚本的乱换成了类的乱。方案 B 则有一个显著优势定义命令的成本极低低到你可以为临时任务也建一条命令不用新建一个类文件。更重要的是配置驱动让“命令清单”变成了一件实物——它就是仓库里那个anything.yaml。你可以对它做代码评审、版本管理、自动生成文档。而编码驱动下命令长什么样散落在不同类文件里外人根本看不全。下面是两种方式在新增一个命令时的成本对比维度编码驱动配置驱动新增命令新建类、实现方法、注册、写测试配置里加一个 block参数变更改代码、重新部署改配置、重新加载命令可见性要翻源码才知道help 自动生成复用程度低逻辑和夹层耦合高逻辑可独立维护维护成本随命令数线性增长随命令数缓慢增长我最终选了配置驱动但有一条红线配置里不写复杂逻辑。配置只做参数声明和调用关系的描述真正的业务逻辑还是留在原有的脚本、函数或接口里。这样既拿到了配置驱动的低成本又不会把配置变成一个没人看得懂的迷你编程语言。2.2 配置文件长什么样CLI-Anything 的配置格式受 GitHub Actions 和 Ansible 的启发核心就三个顶层字段name、version、commands。每个命令用description声明用途用options声明参数用handler声明调用的目标。name: anything version: 1.0.0 commands: deploy: description: 部署指定环境 options: - name: env type: str required: true help: 目标环境如 staging / production - name: tag type: str default: latest help: 镜像或版本的标签 - name: auto_approve type: bool flag: --yes default: false help: 跳过确认步骤 handler: type: shell cmd: ./scripts/deploy.sh --env {env} --tag {tag} logs: description: 拉取服务日志 options: - name: service type: str required: true help: 服务名称 - name: lines type: int default: 200 help: 显示行数 handler: type: shell cmd: kubectl logs svc/{service} --tail{lines}cmd里的{env}、{tag}就是选项名的占位符框架在运行时替换成实际值。这样设计的好处是原有脚本哪怕只接受环境变量或标准输入也能通过一层薄薄的 shell 胶水接进来。配置里另外可选的字段包括aliases命令别名、timeout超时时间、env注入环境变量、output输出格式化方式。这些字段不要求每个命令都写按需使用就行。2.3 三条边界规则不越界、不绕路、不留手做配置驱动的框架最容易翻车的是把配置越写越复杂。为此我给自己定了几条严格边界也建议所有使用者遵守第一配置里不实现业务逻辑。如果配置里有超过三行的条件判断或循环就该停下来。业务逻辑应该留在被调用的脚本、函数或 API 里配置只做参数传递。否则配置变成代码失去“低门槛维护”的意义。第二handler 只返回标准结果结构。无论调 shell 命令、Python 函数还是 HTTP 接口框架层统一包装成{ok: bool, data: obj, exit_code: int}的结构。这样上层渲染和错误处理逻辑只需要写一次不会为不同 handler 类型做次数爆炸的 if-else。第三所有副作用通过退出码和标准输出来表达。调用成功就退出码 0失败就非 0框架捕获后统一处理。不要让某个命令成功或失败时静默无声尤其是 CI 场景下退出码是自动化判断成败的唯一依据。这三条规则看着简单但它们保证了框架可以长期演进而不失控。我见过不少内部工具一开始很精简用着用着把数据库连接、模板渲染、权限判断全塞进配置里最后改一个字段都要翻半天文档。边界清晰才能活得更久。2.4 一次请求在框架内部的流转链路理解内部机制比背 API 更有用。当用户执行anything deploy --env production --tag v1.2.3时框架实际做了这些事加载anything.yaml做 schema 校验和版本检查。在命令注册表中找到deploy子命令。解析 CLI 参数按options里的类型声明做类型转换。把转换后的参数填入 handler 的模板或 kwargs 中。调用 handler可能是subprocess.run可能是动态 import 一个函数也可能是发一个 HTTP 请求。把返回结果包装成统一结构按output配置渲染成表格、JSON 或普通文本。根据结果设置进程退出码。这个链路里最容易出错的是第 3 步。用户输入的永远是字符串而type: int、type: bool需要框架在参数绑定阶段强转。如果转换失败要在参数解析阶段就报错退出而不是把字符串传给底层脚本导致莫名其妙的运行错误。3. 从零实现一个最小可用框架3.1 技术选型Typer、Click 和 Rich 的取舍实现 CLI 框架本身至少要解决三个问题参数解析、命令注册、输出美化。手写 argparse 当然可以但要支持动态注册子命令代码会非常繁琐。我的选型是用 Click 做参数解析和命令注册用 Rich 做终端输出用 PyYAML 加载配置。为什么不直接用 TyperTyper 基于 Click但它走的是 type-hint 驱动风格适合命令是静态定义的场景。CLI-Anything 需要根据 YAML 配置动态生成命令Click 的Partially-Parameterized Command模式更顺手。你完全可以用 Typer 模拟这个过程但要给 Typer 动态添加子命令内部得绕不少弯。Click 的Command对象可以接受一个params列表天然适合配置驱动。Rich 则解决了“输出好看”的问题。真实终端环境里用户需要区分成功、警告、错误信息Rich 的Console.print支持丰富的样式控制而且不会影响 stdout 上正常的数据输出。我特别强调这一点人看的日志和机器读的数据要分开。Rich 输出到 stderr 或专门的 console而 stdout 只留给数据本身方便anything foo | jq这类管道操作。3.2 加载配置并生成命令树下面是框架最核心的加载逻辑用 Click 实现了“配置即命令”。代码不长但把配置驱动模式的关键点都体现出来了。# build_cli.py import yaml import click import subprocess from pathlib import Path def load_config(path: Path) - dict: 加载 YAML 配置并对基本结构做校验。 if not path.exists(): raise click.UsageError(f配置文件不存在: {path}) with open(path, encodingutf-8) as f: config yaml.safe_load(f) assert commands in config, 配置文件缺少 commands 字段 return config def build_option(opt: dict) - click.Option: 把一个 YAML option 声明转成 Click 的 Option 对象。 return click.Option( [f--{opt[name]}], requiredopt.get(required, False), defaultopt.get(default), typegetattr(click, opt.get(type, str).capitalize(), str), helpopt.get(help, ), ) def make_command(name: str, spec: dict) - click.Command: 根据一条命令的 spec 动态生成 Click Command。 options [build_option(o) for o in spec.get(options, [])] click.command(namename, paramsoptions, helpspec.get(description, )) def cmd(**kwargs): return dispatch_handler(spec, kwargs) return cmd def build_cli(config_path: Path) - click.Group: 读取配置注册所有子命令返回完整的 CLI 对象。 config load_config(config_path) cli click.Group() for name, spec in config[commands].items(): cli.add_command(make_command(name, spec)) return cli这段代码的核心是make_command函数。YAML 里一个 option 映射成 Click 的一个Option每个命令 spec 映射成 Click 的一个Command。新增命令时配置加一段CLI 里就多一个子命令完全不用动框架代码。3.3 参数到 Handler 的转发层有了命令对象下一步是dispatch_handler——它决定不同类型的 handler 各自怎么被执行。# dispatch.py import importlib import subprocess def dispatch_handler(spec: dict, kwargs: dict): 根据 handler 类型分发到不同执行器。 handler spec[handler] htype handler.get(type, shell) if htype shell: cmd handler[cmd].format(**kwargs) return run_shell(cmd, timeouthandler.get(timeout, 60)) if htype python: fn load_python_handler(handler) return { ok: True, data: fn(**kwargs), exit_code: 0 } if htype http: return call_http(handler, kwargs) raise RuntimeError(f未知 handler 类型: {htype}) def run_shell(cmd: str, timeout: int): 执行 shell 命令统一返回标准结果结构。 try: result subprocess.run( cmd, shellTrue, textTrue, capture_outputTrue, timeouttimeout, ) return { ok: result.returncode 0, data: result.stdout, exit_code: result.returncode, } except subprocess.TimeoutExpired: return { ok: False, data: 命令执行超时, exit_code: 124 } def load_python_handler(handler: dict): 从 module 和 function 字段导入可调用对象。 module importlib.import_module(handler[module]) return getattr(module, handler[function])这个转发层的设计目标是框架永远不和具体业务耦合。spawn_process不管你在 shell 里跑的是巡检脚本还是发送通知load_python_handler不管 import 的是日志分析函数还是数据库备份函数。这样业务逻辑继续演进框架保持稳定。需要注意shellTrue的安全隐患。如果cmd里有用户直接输入的参数又没有做转义就可能变成命令注入。这在内部工具早期无所谓一旦要开放给团队用建议把shellTrue换成参数列表形式或者增加命令白名单校验。这个问题在第 5 章还会细说。3.4 统一错误与退出码的兜底逻辑一个命令行工具好不好用很大程度上看错误够不够友好。Click 默认会对参数解析错误给出红字提示并退出码 2这已经比手写 argparse 好很多。但运行时错误还需要我们自己做兜底。def entrypoint(cli: click.Group): 项目 main 函数的标准姿势。 try: cli(standalone_modeTrue) except Exception as exc: # noqa: BLE001 console.print(f[bold red]运行失败:[/] {exc}) raise SystemExit(1) from exc如果某个 handler 内部抛了KeyError或网络异常我们不希望用户看到一整屏堆栈。统一转成一句清晰的错误描述和退出码 1对大多数场景更友好。但要注意框架要留一个--debug或--traceback开关排查问题还是需要完整堆栈的。我实测下来的经验是退出码语义要和系统的/usr/include/sysexits.h尽量保持一致不要自创太多含义。否则用户记不住CI 里判断起来也容易出错。具体怎么约定第 5 章展开说。4. 三个真实任务的封装实践4.1 场景一服务器巡检命令运维同学最熟悉的一个场景是巡检一台服务器看 CPU、内存、磁盘、最近登录记录、关键服务状态。原本要敲好几条命令现在统一成anything inspect --host 192.168.1.10 --check disk,mem。配置文件可以这样写commands: inspect: description: 巡检服务器基础状态 options: - name: host type: str required: true help: 目标服务器 IP 或主机名 - name: check type: str default: all help: 巡检范围逗号分隔cpu,mem,disk,svc,login handler: type: shell cmd: ./ops/inspect.sh --host {host} --check {check}底层inspect.sh不需要知道 CLI-Anything 的存在它只接收传统的--host/--check参数。CLI-Anything 帮我们补齐了命令注册、帮助文档、参数校验。这种封装方式对已有脚本侵入为零半天就能把十来个巡检脚本全部接进来。4.2 场景二批量日志分析命令数据分析场景中日志分析通常是“管道套管道”。CLI-Anything 的 handler 也支持把标准输出和后续数据处理串起来。比如统计一个接口在过去一小时内的请求量和错误率commands: logstat: description: 统计 Nginx 访问日志的请求状态 options: - name: file type: str required: true help: 日志文件路径 - name: since type: str default: 1h help: 统计窗口比如 1h、30m handler: type: shell cmd: - ./scripts/parse_log.sh --file {file} --since {since} | ./scripts/aggregate.py --mode status这里aggregate.py从 stdin 读结构化日志输出统计结果。由于 CLI-Anything 统一把 handler 的标准输出返回给用户终端你可以继续对它做grep、sort或者重定向到文件。命令内聚了原始复杂度但管道力量没有被削弱。4.3 场景三把内部 HTTP 接口变成 CLI很多内部平台只有 HTTP API没有命令行工具。每次调用要么拼 curl要么写 Python 脚本。CLI-Anything 的httphandler 可以把 GET/POST 接口也变成一条规范命令。commands: deploy_status: description: 查询某次上线任务的状态 options: - name: task_id type: str required: true help: 上线任务 ID handler: type: http method: GET url: https://internal.example.com/api/tasks/{task_id} timeout: 10 extract: status配合第 3 章的call_http实现框架会发起请求、解析 JSON、提取目标字段最后在终端上输出表格或单值。对于不想广泛开放的平台这种封装也能让普通研发自助查询数据减少对平台的直接访问。4.4 端到端使用效果把上面三条命令配好后实际体验是这样的。输入anything --help会列出deploy、logs、inspect、logstat、deploy_status等全部命令以及各自的一句话说明。输入anything inspect --help会显示--host必填、--check默认 all。这种体验对老手来说可能觉得平淡但对新人来说价值巨大。团队里工具再多入口只有这一个学习成本被压缩得非常低。5. 命令行体验的细节打磨5.1 退出码约定我推进 CLI-Anything 时把退出码规划做成了一张硬性表退出码含义典型场景0成功所有正常执行1通用运行时错误handler 抛了未预计异常2参数错误缺少必填参数、类型不合法3相关文件不存在找不到配置文件、脚本缺失4网络或依赖失败HTTP 超时、连接出错5业务逻辑诊断失败巡检发现磁盘满、服务宕机这张表不是说每个脚本都必须实现而是框架在兜底时有统一映射。用户只要养成看一眼退出码的习惯就能快速判断是自己的参数写错还是系统环境有问题。5.2 日志与调试模式的取舍CLI-Anything 默认不打印调试日志只输出业务结果、错误描述。但每个 handler 调用前框架会记录一条包含被调用的命令和参数摘要的 trace 到~/.cli-anything/logs/下。这样排查问题时可以打开日志看用户到底传了什么参数、调了什么命令比让用户复述“我刚才敲了什么”靠谱得多。我建议增加一个--debug参数开启后打印完整堆栈和底层命令的原始输出。调试信息走 stderr不妨碍 stdout 上的数据管道。这个开关在框架内部就是click.option(--debug, is_flagTrue, envvarANYTHING_DEBUG)一行声明收益很大。5.3 Auto-completion 的接入成本其实很低一键补全是最能提升幸福度的功能。Click 原生支持生成 Bash/Zsh/Fish 补全脚本。接入方式非常简单export ANYTHING_CONFIG/path/to/anything.yaml anythings-update-completion zsh ~/.zfunc/_anything框架只需要提供一个completion子命令内部调用 Click 的shell_completion机制。因为子命令是从配置动态生成的所以补全的候选列表也随配置变化。团队里配置更新后同学只要重新执行一次补全生成脚本新命令立刻出现在 Tab 列表里。我见过不少工具把补全做成后期优化其实它应该在一开始就内置。补全不只是省打字它还充当了“命令发现”的入口用户按两下 Tab 就能看到有哪些可用的子命令和参数等于把帮助文档搬到了指尖。5.4 防注入与权限控制是内部工具最容易忽略的一环内部工具最常听到的一句话是“反正就我们几个人用没事”。但内部工具恰恰是安全事故的高发区。CLI-Anything 的shellhandler 在把用户参数直接拼进命令串之后等于给了用户可以执行任意命令的通道。几个实用的缓解手段参数白名单枚举当选项值是固定几个状态时用choice类型限制比如环境只能是staging/production。正则校验为host、task_id等自定义校验规则不匹配直接拒绝。避免字符串拼接优先使用subprocess.run(args_list)而不是shellTrue。权限分离CLI-Anything 进程内不做敏感操作需要 root 权限的步骤委托给低层脚本通过 sudo 策略机控制。另外不要把密钥写死在 YAML 配置里。配置会进仓库、会被复制、会被 CI 日志打印。密钥统一走环境变量或者凭据文件CLI-Anything 只负责把env字段里的变量名透传给子进程不负责存储敏感值。6. 团队落地时的三个教训6.1 命名一致性机器看到的和你敲的一致刚开始给命令起名时我随意用过svc_status、service-status、st三种风格结果团队成员经常记混。后来我定了一条铁律命令和参数看起来完全一致风格统一用小写和连字符。所有子命令和选项都用deploy-status而不是deploy_status避免在命令行里还得切换大小写和下划线。另外不要在名字里偷懒。dd某个服务听起来很酷但一个月后没人记得它是什么意思。宁可名字长一点也要让--help列表本身就足够可读。6.2 配置文件也要做版本管理配置随着命令增加会越来越长改配置的频率也会变高。我把anything.yaml放进了 Git 仓库并且遵循了和代码一样的流程拉分支、改配置、评审、合并、发布。在此基础上配置顶部加version字段。框架在做加载时检查主版本号如果不兼容就明确报错而不是让用户在诡异的行为里猜原因。这个习惯帮我省了很多“为什么我本地跑的和线上不一样”的排查时间。6.3 逐步演进不要一次到位我有一个很容易犯的毛病做什么都想要一步到位。CLI-Anything 初期就想支持插件机制、远程执行、Web 管理界面最后花了大量时间在架构抽象上核心体验反而没打磨好。正确的路径应该是先用配置驱动管住前二十个命令把帮助、补全、退出码这些底层体验做到顺手等命令数超过五十个再去考虑插件目录和远程执行。绝大多数团队到第二阶段就已经解决了主要痛点过度设计反而增加维护负担。6.4 后续可以往哪些方向扩展如果你真的需要走向更复杂的方向比较稳妥的扩展顺序是插件目录把commands.yaml拆分成conf.d/目录每个命令一个文件方便按团队或模块隔离维护。远程执行handler 支持host: userserver形式通过 SSH 在远端执行命令这让无 agent 的跳板机管理变得简单。Web 面板在 CLI 之上加一层 Web 入口让不熟悉终端的同事也能查看命令列表和运行记录。这些方向每个都不简单但因为有第 2 章的边界规则在框架不会因为扩展而变成一团乱麻。最后再分享一个我个人的习惯每把一个新工具接进 CLI-Anything我都要求自己先手敲一遍原始命令把参数顺序、输出格式、失败表现都弄清楚再动手写 YAML 配置。这个动作能在半小时内暴露出大部分封装问题比如某个参数是短横线还是下划线、输出里有没有 ANSI 控制字符。配置写完之后再跑anything cmd -h和anything cmd --help确认帮助文档和实际行为完全一致才收工。这个小习惯让我的配置从第一天起就是可用的而不是写着精良但运行起来处处踩坑的半成品。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门