在反复写同一套“读配置、跑批、收结果”的脚本之后,我大概是第三遍把同样的代码复制进新项目时,终于决定把它抽出来做成一个自己的小库。这篇文章要聊的就是XSF 自制库——一套围绕“配置驱动的批处理与任务编排”搭起来的轻量工具集。它不解决什么宏大问题,就是把那些散落在各种项目里的重复动作收口成一份可复用的代码:一个 JSON 或 TOML 配置就能描述清楚要跑什么、按什么顺序跑、失败怎么办、结果汇总到哪。它适合两类人:一类是长期靠零散脚本干活、已经被“改一处忘三处”折磨过的个人开发者;另一类是团队里想统一小工具写法、但又不想为这点需求引入一整套重型编排框架的人。下面从它为什么长成这个样子讲起,一路讲到下载、配置、跑通第一个示例,以及我在实际使用中踩出来的那些坑,全部按我自己的实践节奏来写。
1. XSF 自制库的定位与整体设计
1.1 先定边界:这个库明确不做什么
做自制库最容易犯的错,是一开始雄心勃勃,最后做成一个半成品的“平台”。我在动第一行代码之前,先给自己画了三条红线,这三条决定了 XSF 后面所有的结构。
第一,不做调度服务。它没有常驻进程,没有任务队列,没有 Web 界面。你在命令行敲一下,它把你配置里列的任务按依赖顺序跑完,打印汇总,进程退出。想定时执行?交给系统自带的定时任务工具。这个取舍让整个库的体积控制在一个目录里就能翻完的程度,出问题的时候我可以直接读源码定位,而不是去查一份几十页的文档。
第二,不做分布式。所有任务都在本机跑,通过子进程或线程并发。这个决定的代价是它撑不起几百个节点的大规模场景,但换来的是零网络依赖、零中间件部署。我大部分实际需求其实就是“本机把二十个步骤按顺序跑一遍,其中几个可以并行”,分布式在这一层是纯粹的负担。
第三,不做抽象层套娃。任务定义就是一个字典加几个约定键,不需要继承基类,不需要实现接口,不需要注册插件。这一点是我踩过坑之后才定下来的:早前写过一版带抽象基类的版本,结果每加一个新任务类型就要写一个类、注册一次、再改一处工厂函数,最后代码量比不用库还多。
| 方案 | 依赖体积 | 上手成本 | 排查难度 | 适合场景 |
|---|---|---|---|---|
| 重型编排框架 | 大,通常带一串传递依赖 | 需要理解概念模型 | 出错要深入框架内部 | 多人协作、跨机器、有审计要求 |
| 纯手写脚本 | 无 | 低 | 低但重复 | 一次性任务 |
| XSF | 单目录,无第三方强依赖 | 读一份配置格式即可 | 出错位置基本落在自己写的任务里 | 本机批处理、多步骤流水、个人与小组复用 |
为什么最终选“配置 + 注册表”而不是“面向对象的任务类”?理由很实际。我写脚本的时候脑子的模型是“先干这个,再干那个,这个失败了就重试两次”,这是一条线性的叙述,不是一个类图。配置文件的顺序天然就是这个叙述顺序,改起来是改一行字,不是改一个类的继承关系。人在赶时间的时候,改一行字的成本远低于改一个类。
1.2 四个核心模块怎么分工
XSF 内部切成四块,依赖方向严格单向:配置层 → 注册层 → 执行层 → 汇总层。上层可以调用下层,下层绝不回头引用上层。这条纪律看起来教条,但它是我后来能放心改任何一个模块而不炸掉其他的根本原因。
配置层负责把 JSON/TOML 读成一个内部对象,做字段校验、默认值填充、路径规范化。它的输出是一个任务列表,每个任务是一个标准化的字典。这一层最重要的设计是默认值要足够聪明:超时不填就按 300 秒算,重试次数不填就是 0,工作目录不填就用配置文件所在目录。默认值定得好,示例能短到十几行;定得差,用户第一件事就是去抄一份二十行的模板。
注册层是一个字符串到可调用对象的映射表。XSF 自带几个内置动作(执行命令、执行 Python 函数、调用 HTTP 接口、拷贝文件),你也可以用一行装饰器把自己的函数挂进去。为什么用字符串键而不是直接传函数对象?因为配置是纯数据,必须能被序列化、能被外部生成、能被 diff。如果配置里塞的是函数引用,这套东西就只能活在 Python 进程里,跨语言调用和配置生成就全废了。
执行层处理依赖拓扑排序、并发控制、超时、重试、失败传播。这是整个库里唯一有“算法味”的地方,也是最容易出隐蔽 bug 的地方,后面第 4 节会详细讲参数怎么算。
汇总层收集每个任务的开始时间、结束时间、退出状态、标准输出尾部若干行、重试次数,最后打一张表。这张表的用处比想象中大:批处理跑完之后,人往往只想知道“哪个失败了、失败在哪一行”,而不是去翻几千行滚动日志。
1.3 为什么不用“一个大函数”搞定
有人会问,这些功能用一个两百行的函数不也能凑出来吗?能,但会丢三样东西。
一是可测性。拆成四层之后,配置解析可以单独测,拓扑排序可以单独测,重试策略可以单独测。我之前那个大函数版本,改一个重试逻辑要靠“跑一遍真实场景看结果对不对”,一次验证五分钟。
二是错误定位。分层之后错误会带着层级信息抛出来:配置错就是配置错,带字段名和行号;任务错就是任务错,带任务名和退出码。混在一起的版本里,一个 KeyError 既可能是配置缺字段,也可能是你自己的任务代码写错了,排查全靠猜。
三是复用粒度。汇总层那张表,我在三个不同的项目里都单独 import 出来用过。 monolithic 的函数做不到这件事。
2. 下载与版本选择:拿到一份干净可用的包
2.1 发布包里到底装了什么
XSF 的发布包刻意做得很“扁”,解压之后不需要安装步骤就能直接跑。目录结构大致是这样:
xsf-0.9.3/ ├── xsf/ │ ├── __init__.py │ ├── config.py # 配置加载与校验 │ ├── registry.py # 动作注册表 │ ├── runner.py # 依赖排序、并发、重试 │ ├── report.py # 结果汇总与打印 │ └── actions/ # 内置动作 │ ├── shell.py │ ├── python_call.py │ ├── http.py │ └── fs.py ├── examples/ │ ├── minimal.json │ └── pipeline.json ├── tests/ ├── CHANGELOG.md ├── LICENSE └── xsf.py # 单文件入口,方便直接运行每个部分的作用和“能不能删”我列个表,方便你拿到包之后做减法:
| 路径 | 作用 | 是否必需 | 备注 |
|---|---|---|---|
xsf/ | 核心包 | 必需 | 所有逻辑都在这里 |
xsf/actions/ | 内置动作 | 建议保留 | 只删不用的动作文件也行,但省不了多少空间 |
examples/ | 示例配置 | 可删 | 建议先留着,第一条命令就是跑它 |
tests/ | 自测用例 | 可删 | 改源码时建议保留,改完跑一遍能挡掉大部分低级错误 |
xsf.py | 单文件入口 | 可选 | 不想装包、只想扔一个文件进项目的场景用它 |
CHANGELOG.md | 变更记录 | 建议保留 | 升级前扫一眼能省掉很多“怎么突然报错了”的时间 |
下载之后我建议第一件事是校验哈希。自制库的发布包通常不走大型包管理器的签名体系,中间被替换或下载不完整是有可能的。发布页一般会给出 SHA256,本地算一遍对一下:
# 计算下载文件的哈希 sha256sum xsf-0.9.3.zip # 输出示例(对照发布页给出的值) # 7f3c9a1e5b2d4c8f0a6e1b3d9c7f2a4e8b6d0c3f5a1e9b7d2c4f6a8e0b1d3c5f xsf-0.9.3.zip对不上就别解压,重新下一遍。这一步看起来很形式主义,但我确实遇到过一次网络中断导致压缩包少了尾部字节,解压时报的错是“CRC 校验失败”,如果当时没做哈希校验,我大概率会以为是解压工具的问题,去折腾一圈工具。
2.2 版本号怎么挑:0.x 阶段的兼容性现实
XSF 目前还在 0.x 阶段,这个阶段按语义化版本的约定,次版本号变化可能带来不兼容改动。我把过去几个版本的关键变化整理成一张表,你在选版本的时候可以直接对照:
| 版本 | 配置文件格式 | 关键变化 | 兼容性提示 |
|---|---|---|---|
| 0.6.x | JSON only | 基础任务链 | 无depends_on,顺序即执行顺序 |
| 0.7.x | JSON only | 引入depends_on与拓扑排序 | 老配置可直接用,但不享受并行 |
| 0.8.x | JSON / TOML | 引入retry_backoff字段 | retries语义从“总尝试次数”改为“额外重试次数” |
| 0.9.x | JSON / TOML | 引入fail_fast与汇总表 | 默认值改为fail_fast=true,与旧版行为相反 |
看到 0.8.x 那一行的坑了吗?retries的语义改过一次。老配置写retries: 2表示“最多跑两次”,新版本里同样的写法表示“失败后再试两次,最多跑三次”。这类语义漂移是 0.x 阶段最难防的问题,因为它不报错,只是行为悄悄变了。
我的建议是:配置里永远显式写全关键字段,不要依赖默认值。多写几个字,换来的升级时不用提心吊胆。另外在项目里固定版本号,不要用“取最新”的方式引用,否则某天早上起来流水线行为变了,你会花半天时间去怀疑自己的代码。
2.3 三种放置方式,按你的项目形态选
拿到包之后怎么放,取决于你用它的场景。
方式一:直接解压到项目子目录。适合“我就是这个项目里用一下”的情况。把xsf/整个目录拷到项目根下,代码里import xsf就能用。好处是零安装、跟着项目走、clone 下来就能跑。坏处是多个项目各有一份,改了一处其他地方不会同步。
方式二:装成本地包。适合“我有五六个项目都要用,想统一升级”的情况。在包目录里执行本地安装:
# 在解压后的目录内执行,安装到当前 Python 环境 python -m pip install . --no-deps # 验证安装 python -c "import xsf; print(xsf.__version__)"--no-deps是我习惯加的,因为 XSF 核心不依赖第三方库,加了反而可能触发一些不必要的解析。如果你用的版本引入了可选依赖(比如 TOML 解析在某些老 Python 上需要额外库),去掉这个参数让它自己装。
方式三:只放单文件入口。适合“临时跑一次,不想污染环境”。把xsf.py拷到任意目录,用python xsf.py run config.json直接跑。这个单文件是把核心逻辑打平后的版本,功能略少(不支持自定义动作注册),但足够应付简单场景。
注意:三种方式不要混用。同一个 Python 环境里既装了包、项目目录下又有一份同名目录,
import的优先级会让你拿到哪一份完全取决于当前工作目录,这类问题排查起来极其费时。
3. 配置详解:把字段一个一个说清楚
3.1 配置文件长什么样,每个字段管什么
先看一份完整的配置,然后逐个字段拆。这是examples/pipeline.json的稍加改动版:
{ "name": "daily-batch", "defaults": { "timeout": 300, "cwd": ".", "env": { "XSF_LOG_LEVEL": "info" } }, "tasks": [ { "name": "fetch", "action": "shell", "params": { "command": "python tools/fetch_data.py --date ${DATE}" }, "timeout": 600, "retries": 2, "retry_backoff": 5 }, { "name": "clean", "action": "python", "params": { "target": "tools.clean:run", "args": ["--strict"] }, "depends_on": ["fetch"] }, { "name": "report", "action": "shell", "params": { "command": "python tools/build_report.py" }, "depends_on": ["clean"], "fail_fast": false } ] }字段含义我做成表格,配置写错的时候可以直接对照查:
| 字段 | 层级 | 类型 | 默认值 | 作用与注意点 |
|---|---|---|---|---|
name | 顶层 | string | 无,必填 | 批次名字,只用于日志和汇总表标题 |
defaults | 顶层 | object | {} | 全局默认值,任务里同名字段会覆盖它 |
defaults.timeout | 默认 | number | 300 | 单个任务超时秒数,超时按失败处理 |
defaults.cwd | 默认 | string | 配置文件所在目录 | 任务工作目录,相对路径以配置文件位置为基准 |
defaults.env | 默认 | object | 继承当前环境 | 追加到子进程环境变量,不是替换 |
tasks[] | 顶层 | array | 无,必填 | 任务列表,顺序不影响执行,依赖决定顺序 |
tasks[].name | 任务 | string | 无,必填 | 全局唯一,重复会直接报配置错误 |
tasks[].action | 任务 | string | 无,必填 | 内置动作名或自己注册的动作名 |
tasks[].params | 任务 | object | {} | 传给动作的参数,内容由动作自己解释 |
tasks[].timeout | 任务 | number | 继承默认 | 秒。长任务一定要显式调大,别用默认值赌 |
tasks[].retries | 任务 | integer | 0 | 额外重试次数,不是总次数 |
tasks[].retry_backoff | 任务 | number | 0 | 首次退避秒数,实际等待按指数增长 |
tasks[].depends_on | 任务 | array | [] | 前置任务名列表,决定执行顺序与并行度 |
tasks[].fail_fast | 任务 | boolean | true | true 时失败立即终止全批;false 则继续跑无关任务 |
tasks[].skip_if | 任务 | string | 无 | 条件表达式,为真时跳过该任务并标记为 skipped |
关于fail_fast我想多讲两句。这个字段我起初觉得没什么用,直到有一次批处理里有“生成数据”和“清理临时文件”两个任务,前者失败之后,后者如果也被终止,临时目录会越积越大,第二天磁盘就报警了。把清理任务的fail_fast设成false,并且不依赖那个失败任务,它就能在别的任务炸掉之后依然跑完。
还有env这个字段容易被误解。它是追加而不是替换,也就是说子进程能看到父进程已有的环境变量再加上你写在这里的。这个设计是刻意的,因为替换式环境会让PATH之类的变量消失,导致命令找不到。但反过来说,如果你想清掉某个变量,得写成空字符串而不是删掉这一行。
3.2 变量替换与路径规则
配置里支持${VAR}形式的占位符,来源有两个:一是defaults.env里的键,二是启动时传入的--var参数。这个功能我一开始没加,后来发现每次换日期都要手改配置,实在受不了。
# 启动时注入变量 python -m xsf run config.json --var DATE=2024-06-01 --var REGION=cn-north替换规则有三条,记住就行:
${VAR}出现在params的字符串值里会被替换,出现在键名里不会。- 未定义的变量会直接报错而不是替换成空串。这是故意的,替换成空串会让命令变成
--date,看起来像跑成功了,实际上跑的是错的数据。 - 想要字面量的
${,写成$${。
路径规则是另一个高频出错点。cwd的基准是配置文件所在的目录,不是执行命令时的当前目录。这个设计让配置具有可移植性,config.json和它的相对路径依赖一起移动不会失效。但如果你习惯在项目的某个子目录里执行命令,第一次用会觉得“怎么找不着文件”。我的做法是:不管在哪执行,一律写cwd显式指定,把不确定性降到零。
4. 简易示例与参数调优
4.1 最小可运行示例:十几行跑通
先跑最小的一份,确认环境没问题。下面这个文件存成hello.json:
{ "name": "hello", "tasks": [ { "name": "say", "action": "shell", "params": { "command": "python -c \"print('xsf ok')\"" } } ] }执行:
python -m xsf run hello.json预期输出大致是这样(时间戳和耗时因人而异):
[xsf] batch: hello [xsf] plan : say [xsf] run : say ... ok (0.12s) [xsf] ---- summary ---- [xsf] total 1 | ok 1 | fail 0 | skipped 0 | elapsed 0.12s跑通这一步之后,把它改成真实的三步流水。这里用 Python 代码而不是命令行,是因为我想演示自定义动作注册这个更常用的形态:
# run_pipeline.py import xsf # 注册一个自定义动作,名字叫 "process" @xsf.action("process") def process(payload, ctx): # payload 就是配置里的 params # ctx 里有 vars、logger、task_name threshold = int(payload.get("threshold", 10)) rows = ctx.vars.get("ROWS", "100") ctx.logger.info("processing rows=%s threshold=%s", rows, threshold) if int(rows) < threshold: raise ValueError(f"rows {rows} below threshold {threshold}") return {"processed": int(rows)} if __name__ == "__main__": xsf.run("pipeline.json", vars={"ROWS": "500"})对应的pipeline.json:
{ "name": "demo", "defaults": { "timeout": 120 }, "tasks": [ { "name": "step_a", "action": "process", "params": { "threshold": 100 }, "retries": 1 }, { "name": "step_b", "action": "process", "params": { "threshold": 50 }, "depends_on": ["step_a"] } ] }step_b会在step_a成功之后才开始。如果step_a抛异常,它会按retries: 1再试一次,还是失败就整批中断(因为默认fail_fast是 true)。
4.2 并发数、超时、退避这三个参数怎么算
这三个参数如果拍脑袋填,要么跑得慢,要么把机器压垮,要么在网络抖动的时候误判失败。我把自己算它们的方法写下来,你可以直接套。
并发数。XSF 用的是线程池 + 子进程的方式,实际并发受两件事限制:CPU 核数和 IO 等待比例。经验公式是:
建议并发数 N = min(CPU 核数 × (1 + 平均IO等待时间 / 平均计算时间), 任务总数)举个具体例子。我这台机器 8 核,跑的是“下载 + 解析”类任务,单个任务平均耗时 4 秒,其中真正吃 CPU 的大概 0.5 秒,剩下 3.5 秒在等网络。那么1 + 3.5/0.5 = 8,8 × 8 = 64,看起来能开到 64。但实际受限于对端服务,我实测下来稳定在16 左右,再往上错误率明显上升,重试反而拖慢了整体。所以公式算出来的是理论上限,实际值要靠一轮压测收敛。
| 并发数 | 20 个任务总耗时 | 失败率 | 备注 |
|---|---|---|---|
| 4 | 21.3s | 0% | 稳但慢 |
| 8 | 11.6s | 0% | 日常首选 |
| 16 | 7.4s | 0.5% | 性价比拐点 |
| 32 | 7.1s | 6.2% | 重试吃掉收益 |
| 64 | 8.9s | 21% | 明显过载 |
这张表是我在自己环境里用 20 个性质相近的任务测出来的,你的绝对值肯定不一样,但拐点形状大概率类似:先线性下降,然后因为对端限流开始反弹。找拐点的方法是二分:从 4 开始翻倍,跑到耗时不再明显下降为止,取前一个值。
超时时间。别用“感觉差不多”的数字。正确做法是先跑一轮,统计每个任务耗时的 P99,然后设成P99 × 1.5。为什么要乘 1.5 而不是乘 1.1?因为超时的代价是不对称的:设太松,一个卡死的任务会拖住整条链;设太紧,正常的慢请求被判死,触发无谓重试,反而更慢。1.5 倍是留出正常波动空间又能在异常时较快兜底的折中值。
P99 = 118s → timeout = 118 × 1.5 ≈ 177s,取整 180s重试退避。XSF 的退避是指数增长:第 n 次重试等待retry_backoff × 2^(n-1)秒。retry_backoff: 5、retries: 3的等待序列是 5s、10s、20s,累计 35 秒。这个设计针对的是“临时性故障”——对端短暂不可用、文件被别的进程占着。如果是确定性错误(参数写错、文件不存在),重试纯粹是浪费时间,这时候retries应该设 0,让它快速失败。
我的经验配置是:网络类任务retries: 2, retry_backoff: 5;本地文件类任务retries: 1, retry_backoff: 1;纯计算类任务retries: 0,失败就修代码。
注意:
retries的次数是按“任务”算的,不是按“命令里的每一步”算。一个任务里如果串了三条命令,重试是把三条命令整个再跑一遍,前面的副作用会重复发生。涉及写文件的任务,务必把写入做成幂等的。
5. 常见问题与排查技巧实录
5.1 报错速查表
下面这些是我自己和小范围试用时反复遇到的,按现象分类:
| 现象 | 大概率原因 | 解决方式 |
|---|---|---|
ConfigError: unknown field 'retry' | 字段名拼错,正确是retries | 拼写检查;配置解析是严格模式,容错字段不会静默忽略 |
ConfigError: duplicate task name | 两个任务同名 | 重命名;同名不会覆盖,直接报错 |
CycleDetected: a -> b -> a | depends_on成环 | 画出依赖关系,删掉多余那条边 |
| 任务一直不结束,最后超时 | 子进程在等标准输入 | 命令里加非交互参数(如--yes),或重定向< /dev/null |
| 拿不到任务输出 | 输出量过大被截断 | 汇总表只保留尾部若干行,完整输出看日志文件 |
FileNotFoundError找不到脚本 | cwd基准不是当前目录 | 显式写cwd,或把路径改成相对配置文件 |
| Windows 下命令报“不是内部或外部命令” | 用了 shell 内建命令但没走 shell | 显式指定shell: true,或改成调用可执行文件 |
| 重试次数感觉不对 | 把retries当成总次数 | 它是额外重试次数,总执行次数 =retries + 1 |
| 环境变量没生效 | 写在了任务层但被默认层覆盖 | 检查defaults.env与任务层同名键,任务层优先 |
| 中文输出乱码 | 子进程编码与终端不一致 | 显式设PYTHONIOENCODING=utf-8,或统一用 UTF-8 |
5.2 几条踩坑心得
第一条:子进程不清理会积累僵尸。XSF 执行 shell 动作时会起子进程。如果超时被强杀,只杀父进程而不杀进程组,被它拉起来的孙子进程会挂在后台。我早期版本就吃过这个亏,跑了一晚上之后机器上挂着两百多个孤儿进程。修复方式是启动时把子进程放进独立进程组,超时的时候对整个组发终止信号。如果你自己写类似逻辑,这条务必记住。
第二条:日志要带任务名,而且要在开始时打一行。汇总表再好用,也只是事后的。任务卡住的时候,你最想知道的是“现在跑到哪一步了”。XSF 在任务开始时立刻打一行run: <name>,失败时补一行带退出码的。这个设计看起来简陋,但实际排查时比什么都管用——一眼就知道卡在哪个任务。
第三条:不要用配置文件写敏感信息。配置是要进版本库、要被 diff、要被分享的。密码、令牌这类东西通过--var或者环境变量注入,配置里只写${TOKEN}这样的占位符。这不是为了安全级别多高,而是为了让“配置能不能给人看”这个问题永远有肯定答案。
第四条:自定义动作里的异常要老实抛出去。XSF 的动作函数返回什么不影响流程,抛不抛异常才影响。我见过有人在动作里try/except把所有异常吞掉,然后返回一个{"ok": False},结果外层认为任务成功了,fail_fast永远不触发。要么抛异常,要么用一个明确的检查步骤去判断,别让失败伪装成成功。
第五条:先用串行跑通,再开并发。并发带来的问题(顺序依赖、共享文件竞争、对端限流)会掩盖掉任务本身的 bug。我的习惯是--jobs 1跑三遍确认稳定,再逐步往上加。这个顺序反过来做,你会花大量时间在“到底是我的任务写错了还是并发的问题”之间反复摇摆。
第六条:给配置加一个--dry-run习惯。正式跑之前先看一眼计划,确认任务顺序和依赖符合预期。牵涉到删除、覆盖这类不可逆操作的时候,这一步能救你。XSF 的 dry-run 只打印拓扑排序结果,不执行任何动作,成本几乎为零。
第七条:任务粒度别切太细。刚开始用的时候容易把每个命令都拆成一个任务,结果配置比脚本还长。我的经验是:有独立失败语义和独立重试需求的才拆成任务,纯粹的顺序命令合并到一个 shell 动作里就好。判断标准很简单——如果这个步骤失败了,我会不会想单独重试它?会,就拆;不会,就合。
第八条:版本升级前先读 CHANGELOG,特别是 0.x 阶段。前面提过retries语义变更那次,我是靠一个跑了三个月的老配置行为异常才发现的,中间浪费了一整个下午。现在我的习惯是升级前扫一眼变更记录,把涉及的字段在自己配置里 grep 一遍,改完再升。多花五分钟,省下半天。
最后再分享一个小技巧。XSF 的汇总表默认只打终端,但我在长时间批处理里会给它加一个--report-json out.json参数,把结果同时写成 JSON。原因很实在:批处理跑完之后我经常是第二天早上才看,终端早就滚没了,而out.json可以被另一个小脚本读去做飞书或邮件通知。这个「输出结构化结果」的习惯,我从这个库一直沿用到所有自建工具上,回报率比想象中高得多。