
有没有过这种经历明明只是“把文件拉下来”或者“把这个接口的数据存一份”结果每次都要写一个全新的小脚本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(1uint(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()那么用户 CtrlC 之后程序还会傻等重试间隔体验极差。还有一个容易被忽略的点并不是所有错误都适合重试。对 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?from2025to2025最后一段就变成了data?from2025to2025。Windows 下?是通配符虽然不非法但:、*这些都是非法字符。定位到根因后修复方案分两层第一层是优先使用 HTTP 响应头里的Content-Disposition的 filename 字段服务器给了文件名就优先用服务器给的第二层是对所有最终文件名做一个清洗函数把/、\、:、*、?、、、、|这些字符全部替换成_。这个修复当时看起来是小修小补但后来在 Linux 上遇到一个文件名带:的下载任务也受益了因为某些敏感目录在 Linux 上虽然允许冒号但同步到别的系统又会出问题。5.2 坑二分页参数不统一引发的数据错位这个坑是在使用 page 后端拉取一个内部系统日志时遇到的。配置里设定page_mode: page按page1limit100翻页拉回来的数据总量总是比接口预期的少而且页与页之间还能看到重复记录。我打开详细日志翻出每一页请求的 URL逐页检查参数。发现服务端接口实际返回的数据确实是按页返回的但它的分页语义是“第几页”而我配置里给的page_size却让请求里带了limit100。当limit参数影响的是每页条数而内部语义的 page 又是基于固定行数就会出现漏拉或重复。在多次实践之后我给 page 后端增加了明确的page_mode字段page模式对应page1page_size100offset模式对应offset0limit100。并且在每次翻页后都会对结果做一次去重去重的主键可以配置默认取 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] DescriptionRun omniget sync for daily metrics [Service] Typeoneshot ExecStart/usr/local/bin/omniget run --config /etc/omniget/metrics.yml --group daily --out /var/data/metrics EnvironmentAPI_TOKENxxx配合对应的 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 date2025-01-15传入就能更灵活地处理每日定时拉取。另一个是增加导出“执行报告”的能力把每次运行的失败任务、成功任务、耗时统计输出成 JSON方便接入监控告警。最后说一点个人体会做这一类命令行工具最艰难的部分往往不是写好一个功能而是克制住不断加功能的冲动。每次遇到一个新场景我的第一反应是“给 omniget 加一个选项”但后来慢慢改成了“这个场景是不是更适合外面套一层脚本”。克制住了工具就保持简单保持简单它才真正可靠。希望 omniget 的设计思路——配置驱动、后端抽象、并发控制、重试退避、失败即失败——也能给正在做类似工具的你一些参考。如果你最后做了一个自己的版本我相信你的版本一定比我的更贴合你自己的工作流这才是这类工具最大的意义所在。