有没有过这种经历:明明只是“把文件拉下来”或者“把这个接口的数据存一份”,结果每次都要写一个全新的小脚本;wget 处理不了需要翻页的接口,curl 又不擅长管理多个下载任务,到了第二天发现文件下载了一半、服务器 403 了,只能重新跑一遍,内心极度崩溃。我开发 omniget 的初衷,不是要做又一个下载器,而是想给自己准备一个“一口吸掉所有资源获取需求”的命令行工具。它的名字来自 omni(全能)+ get(获取),本质上是一个配置驱动、支持多后端策略的资源获取编排工具,既能拉取单个文件,也能处理分页 API,还能按自定义请求批量拉取内容。这篇文章我会从最初的需求拆解、架构取舍,到并发与重试的实现细节,再到我在实际使用中踩过的坑,完整地把这个项目讲清楚。不管你是想直接借鉴思路做一个自己的同类工具,还是想了解命令行工具设计中的一些共性问题,这篇都应该能给你一些实在的参考。
1. 从“又要写脚本”到“写了 omniget”:这个工具到底在解决什么问题
1.1 日常开发中千奇百怪的“获取”需求
如果你和我一样,长期在做数据采集、离线分析、或者维护一些数据同步任务,多少会遇到下面这几类看起来类似、处理起来却完全不同的情况。
第一类是单纯的文件下载。看起来最没技术含量,实际上问题最多。一个 URL 指向一个 zip 包,需要下载后校验完整性,而且文件可能有 2GB,中间断一次网就得重新来。第二类是分页接口的数据拉取。比如要拉某个内部系统三天的监控数据,接口一次只返回 100 条,你需要跟着next_page字段翻到最后一页,中间还要处理限流和字段类型变化。第三类是带鉴权和自定义头的请求。很多内部接口需要签名、需要在 Header 里塞 token、需要在 POST body 里传 JSON,这类请求用 curl 也能做,但一旦要做几十个、上百个,脚本就变得又长又乱。
这三个场景本身都没有什么难度,难的是它们经常混在一起出现。你今天拉的是文件,明天可能就要拉一批接口数据,后天又要从这个接口里解析出若干下载链接再拉文件。每次都用临时脚本去处理,脚本散落在各处,没人维护,参数也记不清,这其实是非常浪费精力的事情。
1.2 已有工具和通用下载库给我的落差
我一开始也没想自己写,先是试了一圈通用下载工具。wget 适合简单下载,但它的分页、并发和配置管理基本是空白。curl 很强大,但它是命令级别的一次性工具,不负责“任务编排”。aria2 在多分片并发下载上确实猛,但它面向的场景是“大文件加速”,对 API 数据拉取这一块不关心。
我也看过 Python 生态里的 requests + 自定义脚本方案,这个方案胜在灵活,但维护成本高。每次写新的采集脚本,都要重写一遍“读配置、建会话、处理重试、写日志、落盘”的模板代码。到最后你会发现,真正业务逻辑没多少,绝大多数代码都是基建代码。这种感觉就像你每次做饭都要先从打铁开始做一口锅,效率太低了。
所以我真正需要的,是一个可以放在PATH里随时调用、通过命令行参数或配置文件描述“我要获取什么”、并内置了重试、并发控制、断点续传、校验等通用能力的命令行工具。它能覆盖简单文件下载,也能处理分页接口,还能应对自定义请求。这就是 omniget。
1.3 明确边界:omniget 不是什么
设计工具的第一步,其实是划清边界。我一直觉得,一个工具如果什么都做,那它大概率什么都做不好。omniget 的目标不是替代 Git、不是替代 rsync、也不是替代专业的 API 测试工具。
确认的范围是这样:
- 它适合处理“一次性要获取一批资源”的场景,无论是几十个文件还是几百页 API;
- 它服务的对象是开发者和运维人员,不是普通用户;
- 它面向的是 HTTP/HTTPS 协议下的资源获取,不处理 FTP、数据库、消息队列;
- 它不提供 GUI,所有交互都在命令行完成。
这个定位意味着我可以把大量精力放在“任务编排”上,而不是去兼容各种底层协议。很多工具之所以不好用,就是因为想覆盖的场景太宽,结果每个场景都做不透。
2. 设计取舍:为什么是“配置驱动 + 多后端分发”,而不是堆一堆函数
2.1 配置驱动的核心好处
omniget 从一开始就确定了“配置驱动”这个原则。因为获取资源的逻辑,本质上是一份声明式的描述:我从哪里拿、怎么拿、拿到后存到哪里、成功和失败怎么处理。这些内容写成 YAML 配置,比写成 if-else 分支要好维护得多。
一份典型的 omniget 配置长这样:
groups: - name: release-assets out_dir: ./downloads items: - type: file url: https://example.com/releases/myapp-1.0.0.zip filename: myapp-1.0.0.zip sha256: 4f8a...(这里填期望哈希) - type: file url: https://example.com/releases/myapp-1.0.0.tar.gz filename: myapp-1.0.0.tar.gz - name: api-dump out_dir: ./dumps items: - type: page url: https://api.example.com/v1/events params: limit: "100" page_mode: offset page_size: 100 max_pages: 50 headers: Authorization: "Bearer ${TOKEN}"命令行调用方式:
omniget run --config config.yml --group release-assets --concurrency 4这个设计的最大优势在于:配置文件和工具本身是解耦的。你可以把一份配置提交到仓库里做代码评审,也可以在 CI 里换不同的环境变量去复用同一份配置。工具代码一旦稳定,日常增删资源几乎不需要改代码,只需要改配置。
2.2 三种内置后端:file、page、request
我参照了插件化的思路,把获取方式抽象成后端(backend)。不同的资源类型对应不同的后端,这样新增一种获取策略时,不会动到原有代码。
三个内置后端的设计如下:
| 后端类型 | 适用场景 | 核心逻辑 |
|---|---|---|
| file | 单个文件下载 | 支持断点续传、自动命名、哈希校验 |
| page | 分页 API 数据拉取 | 支持 page/offset 两种分页模式,按页拉取并拼接 |
| request | 自定义 HTTP 请求 | 支持任意方法、请求头、请求体,拿到响应后按模板落盘 |
file 后端最直接,处理的是Content-Disposition、Content-Length、Accept-Ranges这些 HTTP 语义。page 后端则是根据用户指定的分页模式自动翻页,每次拿到的 JSON 数组按页写入单独的文件,同时生成一个_merged.json汇总所有页的数据。request 后端则是最灵活但最需要用户自己负责细节的后端,你可以用template字段把响应内容按字符串模板落盘,比如只要提取响应中的某个字段。
有人可能会问,为什么要分page和request,直接用request然后自己在模板里写翻页逻辑不行吗?确实可以,但那样的话,翻页、限流、去重这些公共逻辑每个用户都要自己实现一遍,这恰恰是我想要避免的。把最常用的翻页逻辑固化到后端里,普通数据同步任务就不需要重复造轮子。
2.3 为什么底层用 Go 实现
语言选型这个决定,会直接影响后续的开发和维护成本。我选择 Go,是因为它的几个特性非常贴合 omniget 这种命令行工具。
首先是部署形态。Go 编译出来是单个静态二进制,交叉编译也很简单。在 Linux 服务器上跑的任务,不需要预装 Python 环境或者 JVM,扔上去就能跑。其次是并发模型。下载任务天然是并发的,Go 的 goroutine 和 channel 处理这类任务非常顺手。第三是跨平台能力。我经常在 macOS 上开发、在 Linux 服务器上跑任务、偶尔还要在 Windows 机器上处理数据,Go 的GOOS/GOARCH交叉编译让我一次开发到处编译。
当然,用 Rust 也完全没问题,用 Python 也不是不行。但如果目标是“一个维护成本低、分发给别人也不费劲的 CLI 工具”,Go 在生态成熟度和上手曲线上确实有优势。如果看到这篇文章的你打算用 Python 做一个类似工具,核心架构思路依然适用,只是需要额外考虑打包分发的问题。
3. 三个最核心的实现细节:分发器、并发池、重试退避
3.1 分发器:用接口替代长长的 switch
很多类似工具最终变得难以维护,是因为分发逻辑写成了一个巨大的 switch 语句。今天加一个类型,明天加一个类型,主函数越来越长,测试越来越难写。omniget 在设计上做一个小的接口抽象:
type Backend interface { Name() string Fetch(ctx context.Context, item Item, out io.Writer) error }这个接口非常克制,只有两个方法:Name用于注册识别,Fetch用于执行获取任务。每个后端只需要关心“给我一个 context、一个 item、一个输出流,我把数据写进去”。至于并发调度、重试这些横切关注点,都不需要后端自己关心,而是由调度层统一处理。
分发器做的事情也很简单,维护一个注册表,根据item.Type找到对应的后端:
type Dispatcher struct { registry map[string]Backend } func (d *Dispatcher) Dispatch(t string) (Backend, error) { b, ok := d.registry[t] if !ok { return nil, fmt.Errorf("unknown type: %s", t) } return b, nil }为什么不直接在后端里面写 switch?因为接口的方式让每个后端都可以独立测试,也允许用户在编译期注入自定义后端。实际开发中,注册表模式还有一个隐藏的好处:你可以在代码里遍历注册表生成帮助文档,不用手改文档。
3.2 并发池:控制“同时开多少个连接”
并发下载看起来很美好,但如果把所有任务一次性全并发出去,小则把目标服务器打挂,大则触发对方防火墙把你 IP 封掉。所以在 omniget 里,并发控制是硬性要求,而且默认值很保守。
我用带缓冲的 channel 作为一种简单的信号量:
sem := make(chan struct{}, concurrency) var wg sync.WaitGroup for _, item := range group.Items { item := item // 防止 Go 1.22 之前循环变量复用问题 wg.Add(1) sem <- struct{}{} go func() { defer wg.Done() defer func() { <-sem }() runFetch(ctx, item) }() } wg.Wait()这个实现虽然简单,但有几个细节值得注意。并发数默认是 4,不要一上来就设置为 16 或者 32。在真实网络环境中,过高的并发对共享带宽和高延迟链路的收益很小,反而容易触发服务端限流。我一般建议在操作内网服务时用 4~8,在操作公网服务时用 2~4。
另外,信号量的位置也很讲究。我这里是先获取信号量再启动 goroutine,这样不会出现“goroutine 无限堆积但实际没在执行”的问题。如果先启动 goroutine 再在里面获取信号量,任务量大的时候 goroutine 数量还是会失控。
3.3 重试与退避:指数退避 + 随机抖动
重试是资源获取工具的必备能力,但重试策略如果设计得不好,会变成“故障放大器”。很多下载任务在同一个时间片内失败,如果所有任务都固定等 1 秒后重试,那服务端会看到一波一波的重试洪峰,永远没有机会喘口气。
omniget 的重试逻辑使用了指数退避,并加入了随机抖动:
func retry(ctx context.Context, fn func() error, maxRetries int) error { var err error for attempt := 1; attempt <= maxRetries; attempt++ { if err = fn(); err == nil { return nil } wait := time.Duration(1<<uint(attempt-1)) * time.Second // 1s, 2s, 4s wait += time.Duration(rand.Int63n(int64(wait/4))) // 增加 0~25% 抖动 select { case <-ctx.Done(): return ctx.Err() case <-time.After(wait): } } return err }这里值得说明的是:指数退避的作用是让重试间隔按 2 的幂次增长,而随机抖动的作用是让同一批失败请求不要在同一时刻重试。这两个缺一不可。如果在重试时不判断ctx.Done(),那么用户 Ctrl+C 之后程序还会傻等重试间隔,体验极差。
还有一个容易被忽略的点:并不是所有错误都适合重试。对 404、400 这类永久性错误做重试只是在浪费时间。omniget 的做法是只对超时、5xx、连接错误这类瞬时错误进行重试,同时对永久性错误直接标记为失败并记录原因。
4. 实际使用中逼出来的细节:断点续传、哈希校验、进度可视化
4.1 断点续传的几个边界
断点续传是使用过程中最早被提上日程的功能。原因很简单,一旦要下载的文件超过 1GB,自动续传的收益就非常明显。
这个功能的实现依赖 HTTP 的Range头。下载前先看本地是否已经存在同名文件的临时文件,如果有且大小大于 0,就带上Range: bytes=<已有大小>-发起请求。如果服务器返回206 Partial Content,说明服务器支持续传;如果服务器返回200 OK,说明服务器忽略了 Range 头,此时需要从头下载并覆盖临时文件。
这里有一个很容易踩的坑:不能只根据“返回了 206”就认为可以续传。部分服务器虽然返回 206,但响应的Content-Range的起始字节和你请求的不一致,这时候直接往临时文件末尾追加数据会引发文件损坏。所以正确做法是校验Content-Range: bytes <start>-<end>/<total>中的 start 是否等于你本地临时文件的大小。
4.2 内容哈希校验,省掉“手动解压跑一把”的悲剧
纯文件下载场景下,哈希校验是可选的,但在我做数据同步的场景下,它是必须的。很多时候我拉回来的是一个包,下一步依赖这个包做解析。如果包在传输过程中坏了一个字节,解析阶段才会报错,你很难第一时间判断是下载损坏还是代码逻辑问题。而下载完成时顺手算一下 SHA-256,和配置里的期望值比对,几秒钟就能把问题定位在传输层。
omniget 的配置项里有一个sha256字段,设置了之后,下载完成会计算文件的哈希并比对。不一致时,删除文件,标记为失败,并输出错误信息。这样做有点“粗暴”,但恰恰是这种“失败要立即失败”的设计思路,能让你在出问题时第一时间感知到,而不是带着一个损坏文件继续往下跑。
4.3 进度条:要不要显示,取决于是不是 TTY
进度条这个功能,看起来是纯 UI 的事情,但它背后的逻辑其实反映了一个命令行工具的基本素养:在交互式终端里,你需要给用户反馈;在管道或日志环境里,你绝对不能输出那些\r刷新的控制字符,否则日志系统会收到一堆垃圾。
判断方法很简单:
fi, _ := os.Stdout.Stat() isTTY := fi.Mode()&os.ModeCharDevice != 0只有isTTY为 true 的时候,才开启动态进度条,使用\r来刷新当前行的百分比、速度和已用时间。否则就只输出普通的一行式日志,比如:
[2025-01-15 10:00:03] downloading myapp-1.0.0.zip (45.2%)这个细节看起来小,但实际使用中非常重要。因为你一旦在 CI 里跑omniget并且日志输出被重定向到文件,动态进度条会把控制字符写进日志里,直接污染日志文件。
4.4 日志设计:普通模式安静,verbose 模式话痨
设计日志时我遵循一个原则:默认情况下,只有成功完成和一个任务失败时打印一行;想要详细排查时,通过--verbose打开详细日志。这个原则听起来简单,实际执行起来却容易跑偏,因为很多开发者在调试时依赖详细日志,随手就把默认 log 级别调成了 Debug,结果用户看到的输出全都是噪音。
omniget 的日志输出分四级:
- 正常模式:每个任务成功一行、失败一行;
--verbose:打印每个请求的 URL、状态码、耗时、重试次数;--quiet:只打印错误,不打印任何成功信息;- 失败时:除了错误信息,还会提示失败的 item 在配置文件里的行号,方便定位。
5. 真实踩坑记录:三次排查让我改掉了三个默认值
5.1 坑一:Windows 上的文件名“减肥手术”
第一次把 omniget 跑到 Windows 机器上时,部分文件名保存失败。排查的过程是这样的:先在 Linux 上跑完全正常,在 Windows 上同一个配置文件却报了open: 文件名太长或者包含非法字符。
怀疑过路径过长,后来发现路径长度其实没超,问题出在文件名。因为我把 URL 的最后一节作为默认文件名,但某些 URL 里带查询参数,比如https://example.com/api/data?from=2025&to=2025,最后一段就变成了data?from=2025&to=2025。Windows 下?是通配符,&虽然不非法,但:、*这些都是非法字符。
定位到根因后,修复方案分两层:第一层是优先使用 HTTP 响应头里的Content-Disposition的 filename 字段,服务器给了文件名就优先用服务器给的;第二层是对所有最终文件名做一个清洗函数,把/、\、:、*、?、"、<、>、|这些字符全部替换成_。这个修复当时看起来是小修小补,但后来在 Linux 上遇到一个文件名带:的下载任务也受益了,因为某些敏感目录在 Linux 上虽然允许冒号,但同步到别的系统又会出问题。
5.2 坑二:分页参数不统一引发的数据错位
这个坑是在使用 page 后端拉取一个内部系统日志时遇到的。配置里设定page_mode: page,按page=1&limit=100翻页,拉回来的数据总量总是比接口预期的少,而且页与页之间还能看到重复记录。
我打开详细日志,翻出每一页请求的 URL,逐页检查参数。发现服务端接口实际返回的数据确实是按页返回的,但它的分页语义是“第几页”,而我配置里给的page_size却让请求里带了limit=100。当limit参数影响的是每页条数,而内部语义的 page 又是基于固定行数,就会出现漏拉或重复。
在多次实践之后,我给 page 后端增加了明确的page_mode字段:page模式对应page=1+page_size=100,offset模式对应offset=0+limit=100。并且在每次翻页后都会对结果做一次去重,去重的主键可以配置,默认取 JSON 数组里每项的id字段。如果id没有,就取前 16 个字符的哈希作为主键。
这个调整之后,分页拉取的数据正确性显著提升。但更重要的是,它让我意识到:接口的分页语义五花八门,靠猜是不行的,必须在工具层面显式声明并校验。
5.3 坑三:重试风暴把测试环境打挂了
这是我印象最深的一次事故。当时我在跑一个定时任务,从某内部服务批量获取数据,网络短暂抖动导致 100 个请求几乎同时失败。由于重试逻辑只做了指数退避,没有做全局并发限制,这 100 个任务在 1 秒后同时重试,服务端刚缓过来,又被打了一波请求。就这么循环了 3 轮,最后测试环境的网关直接报了 5xx 错误,运维同事跑来问怎么回事。
这暴露了两个问题。一是没有全局熔断机制:单个任务失败重试很正常,但如果你在一分钟内连续失败了 N 次,继续重试的意义就不大了,此时应该暂停一个窗口,比如 30 秒,让服务端喘口气。二是重试的退避必须加随机抖动,不然所有任务的重试节奏完全一致,就等于制造了另一个突发流量。
修复后的逻辑是:连续失败计数达到 10 次时,进入熔断状态 30 秒;熔断期间所有新任务和重试任务都在等待,不发送真实请求。同时保留随机抖动逻辑。这个修复之后,类似场景下服务端再也没有被打挂过。
6. 把 omniget 接进真实工作流的三种姿势
6.1 数据采集脚本里的“前置拉取”
在数据采集项目里,我习惯把“拉数据”这一步从业务逻辑里剥离出去。采集脚本本身只关心“解析已存在的文件”,而“把文件拉到本地”是 omniget 的事。通过管道组合:
omniget run --config ./data-sources.yml --group raw-files --concurrency 6 python3 parse_local_files.py ./downloads/raw这样做的收益是:拉文件的逻辑稳定可靠,适合频繁重跑;解析脚本则专注于业务解析,两者互不干扰。一旦某个文件下载损坏,omniget 会在第一步就失败退出,解析脚本根本无法启动,避免了带着脏数据往下跑。
这个姿势特别适合“每天要处理一批相同来源数据”的任务。配置好一次,之后每天只需要换日期参数。
6.2 定时同步任务:cron 与 systemd timer
定时同步是 omniget 最典型的应用场景。我通常在服务器上用 systemd timer 而不是简单的 cron,因为 systemd timer 能记日志、能设置网络依赖,还能看上次执行时间。
一个简单的 unit 文件:
[Unit] Description=Run omniget sync for daily metrics [Service] Type=oneshot ExecStart=/usr/local/bin/omniget run --config /etc/omniget/metrics.yml --group daily --out /var/data/metrics Environment=API_TOKEN=xxx配合对应的 timer 定义,每天早上 3 点在业务低峰期自动运行。这个姿势的最大好处是“无人值守”,只要配置正确,它就一直在后台服务器上稳定跑着,省心不少。
6.3 在 CI 流程里做资源缓存刷新
还有一个容易被忽略的应用场景:CI 构建之前,拉取最新依赖数据。很多项目会把一部分业务数据打进最终的产物里,而这种数据又是会变化的。以往的做法是手动刷新,或者写一个脏脚本。用 omniget 做这个事,在 CI 配置文件里增加一个前置步骤:
- name: Refresh external resources run: omniget run --config ./deploy/assets.yml --group assets --out ./build/assets --quiet这一步会在构建前准确定位需要拉取的文件,失败时整个流程直接失败。这样既避免了缓存过期的问题,也把“外部资源不可用”这个风险提前暴露在 CI 阶段。
顺便提一句,在 CI 里一定要加--quiet或至少不要用交互式进度条,否则日志系统会被控制字符刷屏。这也是我在前面提到 TTY 检测的原因之一。
6.4 一些不适合硬套 omniget 的场景
虽然 omniget 在处理批量获取任务上很方便,但它也有不适合的场景。比如单个超大文件的多分片加速下载,这个应该是 aria2 的菜,omniget 只是单连接下载。再比如数据库导出、消息队列消费这类不是走 HTTP 语义的任务,也完全不在它的能力范围内。
想清楚什么场景适合、什么场景不适合,本身就是工具使用成熟度的表现。与其让 omniget 和 aria2 抢活干,不如让它们各司其职:日常的数据同步和接口拉取交给 omniget,超大文件分发交给 aria2,两层互补,谁也不干扰谁。
7. 后续可能想加的东西和一点个人总结
工具做到这个阶段,功能上已经可以覆盖我绝大多数日常需求。不过最近我还在考虑两个值得做的方向。一个是支持从配置里的 URL 模板生成“参数化任务”,目前的配置是静态列举任务,如果能在配置里写{date}这样的占位符,配合--var date=2025-01-15传入,就能更灵活地处理每日定时拉取。另一个是增加导出“执行报告”的能力,把每次运行的失败任务、成功任务、耗时统计输出成 JSON,方便接入监控告警。
最后说一点个人体会:做这一类命令行工具,最艰难的部分往往不是写好一个功能,而是克制住不断加功能的冲动。每次遇到一个新场景,我的第一反应是“给 omniget 加一个选项”,但后来慢慢改成了“这个场景是不是更适合外面套一层脚本”。克制住了,工具就保持简单;保持简单,它才真正可靠。希望 omniget 的设计思路——配置驱动、后端抽象、并发控制、重试退避、失败即失败——也能给正在做类似工具的你一些参考。如果你最后做了一个自己的版本,我相信你的版本一定比我的更贴合你自己的工作流,这才是这类工具最大的意义所在。