本文代码来自一个真实的外包交付项目:一个面向「客户提需求、我改配置」的定时数据管道框架。写这篇文章时它刚通过 84 个单元测试、demo 实测跑通。不是玩具 Demo,是能直接交付买家的那种。
一、先说我为什么这么设计
给客户做定时任务,最常见的是这两种形态:
- 手搓 crontab + 一堆脚本。客户要「每天早上 9 点拉一次数据、归档、发个汇总」。脚本越写越多:拉数一个、清洗一个、汇总一个,每个都有自己的参数和日志,时间一长自己都记不清哪个是哪个。
- 上一套重型调度系统。Airflow、DolphinScheduler 这类,功能是强,但买家大多只是「定时跑几条数据流」,为这个搭集群、学 DAG,交付成本直接翻几倍。
我这次的客户需求更直白:「我要能自己改的定时任务」------今天加一个数据源、明天改个频率,不想每次找你改代码。
所以这版的架构目标就三条:
- 配置驱动:什么时间、跑什么任务、失败怎么办,全部 JSON 声明,买家改配置不改代码
- 编排能力:一个任务 = 有序 step 链,前一步产物自动喂给后一步
- 交付友好 :换新环境 pip install 就能跑、运行留痕可查、失败自动告警,买家 5 分钟能上手
二、整体架构
ruby
t3_pipeline/
├── main.py # CLI 入口(--demo / --config / --serve / --history)
├── pipeline/ # 框架核心(7 个模块,各司其职)
│ ├── config.py # 配置加载与语义校验(JSON + .env 覆盖)
│ ├── registry.py # 任务注册表(内置 4 类 + @register 扩展点)
│ ├── builtin.py # 内置任务:http_fetch / file_archive / sql_export / shell_run
│ ├── runner.py # 执行引擎:step 链编排 + ctx 传参 + 重试 + 产物追踪
│ ├── scheduler.py # APScheduler 封装:cron / interval / date 三种触发
│ ├── ledger.py # 运行留痕:SQLite,--history 查询
│ ├── alerts.py # 告警:log / SMTP / webhook 渠道抽象
│ └── logconf.py # 日志按天轮转,默认保留 14 天
├── demo_pipe/ # 演示管道(demo 任务 + demo 配置)
├── config/ # 买家自定义管道配置放这里
├── tests/ # 84 个单测(全部离线)
├── requirements.txt # 依赖就两个:apscheduler + requests,其余全标准库
└── .env.example # 凭据模板(URL/Token/SMTP 全走 .env)
模块边界一句话:config 管"声明什么",registry 管"有哪些任务能做",runner 管"怎么按顺序跑",scheduler 管"什么时候触发",ledger 管"跑了没、跑成没",alerts 管"失败了找谁"。
依赖是刻意控制的------apscheduler(调度)+ requests(HTTP 拉取)就两个,配置加载、SQLite、CSV、SMTP、日志全是标准库。买家环境越干净,交付越少踩坑。
三、核心设计逐块拆
1. 配置层:一个 JSON 声明整个管道
买家每天打交道的就是这个文件(截自真实 demo 配置):
jsonc
{
"timezone": "Asia/Shanghai",
"output_dir": "output",
"jobs": [
{
"name": "demo_daily_snapshot", // job 名(全仓不重复)
"description": "生成订单快照 → 统计行数 → 归档",
"schedule": { "type": "interval", "hours": 24 }, // 每 24 小时
"steps": [
{ "task": "demo_gen_orders", "as": "orders_csv", "params": { "rows": 120 } },
{ "task": "demo_tally_rows", "as": "meta_file", "params": { "source": "$orders_csv" } },
{ "task": "file_archive", "params": { "source": "$orders_csv", "target_dir": "archive", "layout": "date-dirs" } }
],
"alert_on_failure": ["log"],
"alert_on_success": ["log"],
"retry_times": 0
},
{
"name": "demo_sql_export", // 每天凌晨 3 点跑 SQL 汇总
"description": "SQLite 查询导出 CSV(演示 sql_export 内置任务)",
"schedule": { "type": "cron", "expr": "0 3 * * *" },
"steps": [
{ "task": "demo_gen_orders", "as": "orders_csv", "params": { "rows": 60 } },
{ "task": "demo_sql_from_csv", "as": "db_path", "params": { "source": "$orders_csv" } },
{ "task": "sql_export", "params": {
"db": "$db_path",
"query": "SELECT city, COUNT(*) AS cnt, ROUND(SUM(amount),2) AS total FROM orders GROUP BY city ORDER BY total DESC",
"out": "sql/city_summary.csv" } }
],
"alert_on_failure": ["log"],
"alert_on_success": ["log"]
}
]
}
三个关键设计:
- step 链 +
as命名 :前一步用"as": "orders_csv"把产物(这里是文件路径)挂进共享上下文,后一步"source": "$orders_csv"直接引用。管道逻辑 = 配置里的 step 顺序,不用写一行胶水代码。 $引用两级查找 :先找前序 step 产物,找不到再找.env环境变量(URL/Token 这种敏感值走这里)。整串匹配才替换,普通字符串里的$不受影响,没有歧义。- 三种调度 :
interval(每 N 小时/分钟)、cron表达式(0 3 * * *)、date(指定时刻跑一次),APScheduler 驱动,够覆盖 99% 买家需求。
配置加载时做语义校验 (不是 JSON 能解析就放行):job 名重复、task 未注册、schedule 类型不合法、必填参数缺失,全部在启动时报明确错误,绝不带病运行。启动器顺手把 .env 加载进环境(轻量自实现,连 python-dotenv 依赖都省了)。
2. 任务层:内置 4 类 + 一行代码自定义
买家 80% 的需求落在四个内置任务上:
| task | 干什么 | 要点 |
|---|---|---|
http_fetch |
HTTP GET 拉取存文件 | headers/auth 走 .env;超时、状态码校验可配 |
file_archive |
文件按日期归档 | date-dirs(2026/09/)或日期前缀两种布局 |
sql_export |
SQLite 查询 → CSV | 只读护栏:仅放行 SELECT / WITH |
shell_run |
执行 shell 命令 | cwd / 超时可配 |
剩下的需求,写个函数 + 一个装饰器就接入(这是交付时给买家演示最多的一步):
python
from pipeline.registry import register
@register("my_task") # 任务名,config 里 "task": "my_task" 引用
def my_task(params: dict, ctx: dict):
# params: 本 step 配置($ENV 已解析、$前步产物已替换)
# ctx: 共享上下文,__output_dir__ = 输出根目录
out = ctx["__output_dir__"] + "/result.txt"
open(out, "w").write("done")
return out # 返回文件路径 → 自动记入运行记录产物
注册表模式的好处:框架本身永远不用改 。新需求 = 新函数 + 配置里加一个 job,框架代码零改动,测试不回归。demo 里就是靠这个机制注册了 demo_gen_orders(生成确定性订单 CSV)、demo_tally_rows(统计行数)、demo_sql_from_csv(CSV 灌 SQLite)三个演示任务。
3. 执行引擎:ctx 传值 + 双层重试
runner 按 steps 顺序执行:每个 step 从注册表取任务函数,(params, ctx) 调一次,返回值写入 ctx[step.as],下一个 step 就能 $ 引用。
失败处理做了两层,粒度分开:
- step 级重试:单步失败重试 N 次(如 HTTP 拉取抖动,隔 5 秒重试 2 次)
- job 级重试:整条管道失败后整体重跑(适用于"某一步把中间产物写坏了,重跑整条更稳")
重试耗尽仍失败 → job 记 failed → 触发告警。执行结果不靠猜:每个 job 的成败、耗时、重试次数、产物列表全部落 SQLite。
4. 调度层:--serve 进调度模式
配置校验通过后一条命令进调度:
bash
python3 main.py --config config/my_pipe.json --serve # 按配置 schedule 触发,Ctrl+C 退出
CLI 全家桶(交付文档直接抄):
bash
python3 main.py --demo # 内置演示管道立即跑一遍
python3 main.py --config x.json # 立即跑一遍(--run-once 语义)
python3 main.py --config x.json --only job_a # 只跑指定 job
python3 main.py --config x.json --serve # 调度模式
python3 main.py --history # 查看最近运行记录
python3 main.py --history --status failed --limit 10 # 只看失败最近 10 条
python3 main.py --list-tasks # 列出已注册任务
5. 留痕:每次运行都进账本
每次执行写一条 SQLite 记录,--history 直接查(真实输出):
ini
[ 12] OK 2026-09-08 15:03:11 job=demo_daily_snapshot dur= 312ms retries=0 | artifacts=2
[ 11] OK 2026-09-08 15:03:10 job=demo_sql_export dur= 128ms retries=0 | artifacts=1
统计: total=12 success=12 failed=0 avg_dur=211ms
产物的文件路径也记在账本里(JSON 列)。买家问"昨天那个文件生成到哪了",一条命令查出来,不用翻目录。日志按天轮转、保留 14 天,不会跑半年把磁盘撑爆------这些细节都是交付后少接一个"报障工单"的关键。
6. 告警:log / SMTP / Webhook
告警渠道做成抽象,job 配置里声明要哪些:"alert_on_failure": ["log", "smtp", "webhook"]。SMTP 凭据、Webhook URL 全走 .env,配置里只写渠道名。默认只开 log,外发渠道买家要用了才开------避免一装上就满世界发信。
四、测试:84 个单测,全部离线可跑
定时任务项目最容易栽的坑:测试里等真实调度------cron 触发动不动等几十分钟,CI 根本没法跑。
这版测试策略:把"调度触发"mock 掉,直接测执行逻辑 ;APScheduler 相关的用例用 interval=0.2s + 轮询断言,禁用真实 cron 等待。84 个用例分 4 个文件,全离线、毫秒级跑完:
bash
python3 -m unittest discover -s tests -v
# Ran 84 tests --- OK ✅
覆盖点:配置加载与语义校验(含 .env 合并、$引用解析、非法配置报错)、内置 4 任务(含 SQL 只读护栏:DELETE/DROP 直接被拒)、runner step 链与 ctx 传值、重试耗尽转失败、产物追踪、调度注册、ledger 记录与查询、告警渠道(SMTP/Webhook 用 mock 断言触发,禁网)。
五、合规与安全(交付级必守)
sql_export服务端护栏:只放行只读 SELECT / WITH ,买家把DELETE FROM orders写进配置也执行不了- 密钥/Token/SMTP 凭据严禁写进 JSON 配置 ,一律
.env(.env已 gitignore,交付源码干净) - 不含绕过反爬 / 破解验证码 / 未授权采集逻辑(与同系列 T1/T2 同一铁律)
shell_run参数白名单化,危险命令需买家自行确认
六、已知边界(交付前说清楚,比藏着强)
- 单机任务编排,不支持分布式调度------买家数据量到多机/海量场景,明说需要升级方案
- 管道是线性 step 链:不分叉、不合并、无条件分支,真有 DAG 需求走定制
sql_export内置只支持 SQLite(demo 零外部依赖);接 MySQL/PostgreSQL 需买家环境装驱动- SMTP 告警仅 SSL(465) 模式,特殊端口需求改 alerts.py 一行
边界写进 README,是为了买家预期对齐------最怕的是交付时说"都能做",上线后才发现场景不符,退款纠纷往往就这么来的。
七、复用价值
对做外包/接私活的朋友,这套设计的价值不在代码量,在决策可复制:
- 配置驱动 → 买家能自己改需求,报价敢说"后续小改动免费/极低价",复购率上来了
- 依赖最小化 → 买家服务器上 pip install 两条就完事,少一个依赖少一个事故
- 测试先行 + mock 调度 → 定时类代码改起来有安全网,不怕改崩线上
- 留痕内置 → 出问题先查 ledger,不用 SSH 上去翻半天日志
代码量不大,模块职责单一,可按需裁剪。评论区聊聊你的定时任务场景,人多了我下一篇写「如何把任意"每日要跑的数据流"十分钟配置成管道」的完整实战。
⚠️ 本文代码为外包项目交付物节选,仅作技术分享;实际使用请人工审核代码并在本地完整测试。