Motrix 停更、IDM 太贵,所以我用 Rust 写了个下载器

Motrix 停更、IDM 太贵,所以我用 Rust 写了个下载器

2026年2月9日
rust
开源
下载器

Motrix 停更、IDM 太贵,所以我用 Rust 写了个下载器

起因:一个下载器都用不踏实

说起来挺无奈的,2026 年了,找一个好用的免费下载器居然还是个问题。

我之前一直用 Motrix 当主力下载器。说真的,这软件各方面都挺好的——界面好看,开源免费,多连接下载也够用,该有的功能基本都有。当时用的时候还挺庆幸,终于找到一个能长期用的下载工具了。

但好景不长。有一天我下载一个文件的时候遇到了个 bug,想去 GitHub 看看有没有人提过,结果一打开 issue 列表傻眼了:几百个 issue 堆在那里没人管,PR 也没人 review,再一看最后一次 commit 的时间——好家伙,已经是几年前的事了。

心凉了半截。Motrix 基本算是停更了。

能理解,开源项目嘛,作者有自己的生活和工作,不可能永远维护一个免费项目。但作为用户,我确实需要一个还在维护的下载工具。万一哪天遇到兼容性问题或者安全漏洞,没人修就麻烦了。

那换别的?我把市面上能找到的下载工具都试了一遍。

IDM(Internet Download Manager) 是第一个想到的。毕竟名声在外,Windows 上的下载器天花板。试用了一下确实猛——多连接下载速度拉满,浏览器接管丝滑,下载大文件的时候那个速度看着就舒服。但是一看价格,永久授权 25 美元,折合人民币将近 200。对于经常用的生产力工具来说,这个价格其实不算贵。但问题是,它只支持 Windows,我平时还要在 Linux 和 macOS 上干活,买了也只能在一个平台上用,性价比就不太行了。而且说实话,就一个下载器,花钱总觉得有点亏。

aria2 是另一个选择。免费、开源、功能强大,命令行工具,支持 HTTP、FTP、BT、磁力,几乎什么都能下。社区也大,各种前端 GUI 一大堆。按理说应该完美对吧?

但实际用起来真的折腾。aria2 的配置文件写起来就像在写论文,参数几十个,有些名字还特别长,每次换台电脑就得重新配一遍。想用 RPC 模式远程控制?先启动 aria2c 守护进程,再搭个 AriaNG 或者 Aria2 WebUI 的前端,配好端口和密钥,中间但凡有一步不对就连不上。对于我这种只想「输个 URL 就开始下载」的人来说,门槛有点高。

还有一些其他的选择,比如 XDM(Xtreme Download Manager)、Free Download Manager 之类的。要么是 Java 写的启动慢占内存,要么是功能虽然够但界面实在太丑,要么是更新也不太勤快。试来试去,没一个让我满意的。

折腾了一圈之后我冷静下来想了想:我需要的到底是什么?

  • 多连接分片下载(这是基本功,单连接下载大文件太慢了)
  • 断点续传(网不好的时候下到一半断了,得能接着来)
  • 命令行工具(我大部分时间在终端里,GUI 反而碍事)
  • 跨平台(Windows、Linux、macOS 都能用)
  • 配置简单(别让我写一屏配置文件)
  • 最好还有 RPC 接口(方便以后做自动化或者套个前端)

想来想去,需求也不复杂,与其继续找,不如自己写一个。

为什么选 Rust

决定自己写之后,第一个问题是用什么语言。

Go 是个很自然的选择。goroutine 天然适合并发下载,编译也是单二进制,生态也够成熟。但我最近一直在写 Rust,手感正热,加上 Rust 有几个特别适合这个场景的优势,最终还是选了 Rust。

第一个原因是性能。下载器本质上是网络 IO 密集型应用,大量时间花在等网络响应和写磁盘上。Rust 的 async/await 配合 tokio 运行时,异步 IO 性能基本是天花板级别。reqwest 作为 HTTP 客户端,底层用的 hyper,支持连接池、流式传输、HTTP/2,该有的都有。这套组合在高并发场景下表现非常好。

第二个原因是单二进制。Rust 编译出来就是一个独立的可执行文件,不需要运行时、不需要虚拟机、不需要装任何依赖。把这个 exe 拷到任何一台机器上就能跑。这对于工具类软件来说太重要了——你不会想在服务器上为了用一个下载器先装一堆 runtime 的。

第三个原因是跨平台编译。Rust 的交叉编译支持非常好。我在 CI 里配了一套矩阵构建,一次推代码就能自动编译出 Linux(x86 和 ARM64)、Windows、macOS(Intel 和 Apple Silicon)五个平台的二进制文件。发 Release 的时候五个包一起出,用户下载对应平台的就行。

当然还有一个不太正式的原因:我想练手。之前用 Rust 写的都是些小工具,几百行就搞定的那种。这次想挑战一个稍微有点规模的项目,涉及网络编程、并发控制、文件 IO、RPC 服务器、CLI 框架、状态持久化……这些东西全凑在一起,对我来说是个很好的学习机会。

架构设计:怎么把下载器拆清楚

开工之前我先想了想整体架构。一个下载器看起来简单,但要做好细节还是挺多的。

我把整个项目分成了几个模块:

src/
├── main.rs              # 入口,CLI 解析和调度
├── cli.rs               # 命令行参数定义
├── config.rs            # 配置文件管理
├── download_core/       # 下载核心
│   ├── engine.rs        # 下载引擎(总调度)
│   ├── task.rs          # 任务数据结构
│   ├── segment.rs       # 分片逻辑
│   ├── worker.rs        # 分片下载协程
│   └── merge.rs         # 文件合并
├── net/
│   └── http.rs          # HTTP 客户端封装
├── storage/
│   └── state.rs         # 断点续传状态持久化
├── rpc/
│   └── server.rs        # JSON-RPC 服务器
└── util/
    ├── speed.rs         # 限速和速度格式化
    ├── progress.rs      # 终端进度条
    └── checksum.rs      # 校验和

核心思路是:engine 负责调度,task 代表一个下载任务,segment 代表一个分片,worker 负责下载单个分片。

一个典型的下载流程是这样的:

  1. 用户输入 URL
  2. engine 发一个 HEAD 请求,拿到文件大小、是否支持 Range、ETag 等信息
  3. 根据文件大小和分片数,把文件切成 N 个 segment
  4. 为每个 segment 启动一个 worker 协程,每个 worker 发一个带 Range 头的 GET 请求
  5. 每个 worker 把下载的数据写到临时文件(taskid.part0taskid.part1...)
  6. 所有 worker 完成后,按顺序把临时文件合并成最终文件
  7. 清理临时文件,完成

这个流程看起来简单,但魔鬼在细节里。

分片下载:看起来简单,坑还不少

分片的核心逻辑其实就是算数学。比如一个 100MB 的文件分 8 片,每片 12.5MB。但字节数不能有小数,所以实际上前几片会多分一个字节来处理余数。

let segment_size = total_size / n;
let remainder = total_size % n;
// 前 remainder 个分片各多 1 字节

真正复杂的是边界情况:

服务器不支持 Range 怎么办? 有些服务器(特别是一些小网站和对象存储)不支持 Range 请求。这种情况下只能退化成单连接下载。所以在发 HEAD 请求的时候,要检查响应头里有没有 Accept-Ranges: bytes

文件大小未知怎么办? 有些服务器的响应头里没有 Content-Length,比如动态生成的内容。这时候也只能单连接下载,而且进度条没法显示百分比,只能显示已下载的字节数。

重定向怎么办? 很多下载链接会经过一层或多层重定向(比如 CDN 调度)。HEAD 请求拿到的最终 URL 可能和用户输入的不一样。分片请求必须用重定向后的最终 URL,不然有些 CDN 会返回错误。

ETag 变了怎么办? 断点续传的时候,如果服务器上的文件已经更新了(ETag 变了),那之前下载的部分就作废了,必须重新开始。所以恢复下载前要先验证 ETag。

这些边界情况一个一个加上去,代码量其实不少。但处理好了以后,下载的可靠性会高很多。

断点续传:比想象中麻烦

断点续传是下载器的核心功能之一,也是我觉得实现起来最有意思的部分。

思路很直接:每隔一段时间(我设的是 3 秒)把当前的下载状态保存到磁盘上。状态包括:

  • 任务 ID、URL、文件路径
  • 每个分片下载了多少字节
  • ETag(用于验证文件有没有变)

保存成 JSON 文件,放在下载目录里,文件名是 taskid.edl.state

恢复的时候,先根据 URL 找到对应的 state 文件,然后:

  1. 重新发 HEAD 请求拿 ETag
  2. 和 state 里记录的 ETag 比较
  3. 相同就继续,不同就全部重来
  4. 对于每个未完成的分片,检查临时文件的实际大小(以磁盘上的为准,不信 state 里记录的数字)
  5. 从上次的位置继续下载

第 4 步很重要。因为如果程序是被强制杀掉的(比如 kill -9),最后一次写入可能只写了一半,state 里记录的字节数和磁盘上实际的字节数可能不一致。所以恢复的时候必须以磁盘上的实际文件大小为准。

// 恢复时信任磁盘上的实际文件大小
if temp_path.exists() {
    let file_len = tokio::fs::metadata(&temp_path).await?.len();
    if file_len != segment.downloaded {
        segment.downloaded = file_len;
    }
}

这个细节不处理的话,轻则下载出来的文件里有一段重复数据,重则直接报错。

限速:令牌桶算法

限速用的是经典的令牌桶(Token Bucket)算法。原理很简单:

  • 有一个「桶」,容量等于每秒允许的最大字节数
  • 每秒钟桶会被重新填满
  • 每次写入数据前,先从桶里「取」对应的字节数
  • 如果桶里的余量不够,就计算需要等多久,然后 sleep

所有的 worker 共享同一个桶,这样限速是全局的,不管你开了多少连接,总下载速度都不会超过设定值。

pub fn consume(&self, bytes: u64) -> Option<Duration> {
    let limit = self.limit.load(Ordering::Relaxed);
    if limit == 0 { return None; } // 0 = 不限速

    // 如果超过限制,计算需要 sleep 的时间
    let total = prev + bytes;
    if total > limit {
        let overshoot = total - limit;
        let delay_ms = (overshoot as f64 / limit as f64 * 1000.0) as u64;
        Some(Duration::from_millis(delay_ms.max(1)))
    } else {
        None
    }
}

实现上用了 AtomicU64 来做无锁计数,只有重置窗口的时候才需要加锁。在高并发场景下(比如 16 个 worker 同时跑),这种设计的竞争很小,基本不会成为瓶颈。

限速值支持人类可读的格式:1M 就是 1MB/s,500K 就是 500KB/s,0 就是不限速。命令行和配置文件里都可以用这种格式,比写纯数字直观多了。

JSON-RPC:兼容 aria2 的远程控制

做 RPC 接口是因为我有时候想在服务器上跑下载任务,然后在本地控制它。aria2 用的是 JSON-RPC 2.0 协议,接口设计得挺合理的,所以我直接借鉴了它的 API 设计。

用 axum 搭了个 HTTP 服务器,只暴露一个端点 POST /jsonrpc,所有操作都走这个端点,通过 method 字段区分:

  • addUri — 添加下载任务
  • pause / unpause — 暂停和恢复
  • remove — 删除任务
  • tellStatus — 查询单个任务状态
  • tellActive / tellWaiting / tellStopped — 查询任务列表
  • getGlobalStat — 全局统计
  • changeGlobalOption — 运行时修改配置(比如改限速)

认证方面用的也是 aria2 的方案:在 params 数组的第一个位置传 "token:你的密钥"。这样现有的 aria2 前端(比如 AriaNG)理论上稍微改改就能对接。

# 添加一个下载任务(带认证)
curl -X POST http://127.0.0.1:6800/jsonrpc \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":"1","method":"addUri",
       "params":["token:mysecret","https://example.com/file.zip"]}'

RPC 模式下添加的任务是非阻塞的,addUri 会立即返回任务 ID,下载在后台进行。通过 tellStatus 可以随时查进度。

并发控制用的是 tokio 的 Semaphore。配置里设了最大同时下载数(默认 5),每个任务开始前要先获取一个 permit,获取不到就排队等。这样不管通过 RPC 添加多少任务,实际同时在跑的不会超过限制。

自定义 Header 和 Cookie:下载需要登录的资源

这个功能是后来加的,因为我发现很多实际场景下,下载链接需要认证才能访问。

比如某些网盘的直链需要带 Cookie,某些 API 的下载接口需要带 Authorization 头,还有些网站会检查 Referer 防盗链。

用法很直观:

# 带 Authorization 头
edl download https://api.example.com/files/123 \
  --header "Authorization: Bearer eyJhbGciOi..."

# 带 Cookie
edl download https://pan.example.com/download/abc \
  --cookie "SESSID=xxx; token=yyy"

# 防盗链
edl download https://cdn.example.com/video.mp4 \
  --header "Referer: https://www.example.com"

--header 可以多次使用,所有头都会被合并。--cookie 是个快捷方式,等价于 --header "Cookie: ..." 但写起来更方便。

这些参数也可以写在配置文件里,这样就不用每次都敲一遍了:

headers = ["Authorization: Bearer xxx", "Referer: https://example.com"]
cookie = "sid=abc; token=xyz"

实现上是在构建 reqwest Client 的时候通过 default_headers 注入的,这样所有请求(包括 HEAD 和每个分片的 GET)都会自动带上这些头,不需要每个地方单独处理。

配置文件:不想每次都敲一堆参数

aria2 的配置文件是我觉得它最让人头疼的地方之一。参数太多,格式又是自定义的,每次换环境都得查文档。

所以我用的是 TOML 格式。TOML 可读性好,和 INI 文件差不多直观,但比 INI 规范得多(支持数组、嵌套等)。Rust 生态里 TOML 的支持也是一等公民级别的。

配置文件在首次运行的时候自动生成,所有选项都注释掉了,带中文说明:

# ExquisiteDownload (edl) 配置文件

# 最大同时下载任务数
# max_concurrent_tasks = 5

# 每个任务的最大连接数
# max_connections_per_task = 8

# 全局限速(如 "1M", "500K", "0" 为不限速)
# max_speed = "0"

# HTTP/HTTPS/SOCKS5 代理
# proxy = "http://127.0.0.1:7890"

# 自定义 HTTP 请求头
# headers = ["Authorization: Bearer xxx"]

# Cookie 字符串
# cookie = "sid=abc"

想改什么就取消注释改一下,不想改的就保持默认。CLI 参数的优先级高于配置文件,所以临时想覆盖某个配置直接加参数就行,不用改文件。

配置文件路径:

  • Windows 下放在 exe 同目录(方便做「绿色版」)
  • Linux/macOS 下放在 ~/.config/edl/config.toml(遵循 XDG 规范)

TLS 的选择:为什么用 rustls 而不是 OpenSSL

这个决定是在做 CI 的时候被迫做的。

一开始用的是 reqwest 默认的 TLS 后端,也就是系统的 OpenSSL。在本机编译没问题,但到了 CI 的交叉编译环节就炸了——编译 Linux ARM64 的时候找不到 aarch64 版本的 OpenSSL 库。

当然可以在 CI 里装交叉编译版本的 OpenSSL,但那又多了一堆依赖要管,不同平台的包名还不一样,维护起来很烦。

最后换成了 rustls——一个纯 Rust 实现的 TLS 库。它完全不依赖系统的 OpenSSL,编译的时候直接把 TLS 实现打包进二进制里。这样交叉编译就变成了纯 Rust 编译,不需要任何 C 语言的交叉编译工具链(除了链接器)。

# Cargo.toml
reqwest = { version = "0.12", default-features = false,
            features = ["stream", "json", "rustls-tls"] }

就这一行改动,五个平台的 CI 全部通过。rustls 的性能和兼容性在绝大多数场景下和 OpenSSL 没有明显区别,对于一个下载器来说完全够用。

CI/CD:推一下代码就全自动了

项目配了两条 GitHub Actions 流水线:

CI 流水线(每次 push 或 PR 到 main/dev 分支触发):

  1. 跑测试(cargo test
  2. 检查格式(cargo fmt --check
  3. 静态检查(cargo clippy -- -D warnings
  4. 五个平台并行编译

Release 流水线(打 v* tag 触发):

  1. 跑测试
  2. 五个平台并行编译
  3. 打包(Linux/macOS 用 tar.gz,Windows 用 zip,包含 config.toml 和 README.md)
  4. 自动生成 changelog(提交记录)
  5. 创建 GitHub Release,上传所有包

发版只需要:

git tag v0.1.0
git push origin v0.1.0

等几分钟 CI 跑完,GitHub Releases 页面就有五个平台的下载包了。整个过程零人工干预。

和同类工具的对比

写了这么多,最后客观对比一下:

IDMMotrixaria2edl
价格$25免费免费免费
维护状态活跃停更低频更新活跃
多连接下载优秀
断点续传优秀
GUI有(Electron)无(需第三方)
RPC 接口有(兼容)
单文件部署否(Electron)
跨平台仅 Windows全平台全平台全平台
BT / 磁力暂无
自定义 Header
配置复杂度低(GUI)低(GUI)
内存占用高(Electron)
浏览器集成需插件暂无

说实话,edl 目前还比不上 aria2 的功能全面度,BT、Metalink、FTP 这些都还没做。IDM 在 Windows 上的体验也确实是标杆级别的。

但如果你的需求和我一样——主要下载 HTTP/HTTPS 资源,想要一个免费、跨平台、配置简单、还在维护的命令行下载器——edl 应该能满足你。

后续计划

目前在规划的功能:

  • 批量下载:支持 -i input.txt 从文件读取 URL 列表批量下载,这是 aria2 的高频功能
  • TUI 终端界面:用 ratatui 做一个交互式界面,实时查看所有任务的状态和进度
  • BT 下载:这是个大工程,但如果要做一个「完整」的下载器就绕不过去
  • WebSocket RPC:HTTP 轮询拿状态效率太低,WebSocket 可以实时推送
  • 浏览器插件:接管浏览器的下载请求,转发给 edl 处理

有些功能可能要花不少时间,慢慢来。

最后

项目完全开源,MIT 协议,代码在 GitHub 上,欢迎 star、提 issue、交 PR。

42 个单元测试覆盖了核心逻辑,CI 保证了每次提交都能编译通过。代码风格上也做到了 cargo clippy -- -D warnings 零警告,虽然不能保证没 bug,但质量应该是有保障的。

如果你和我一样,受够了下载器要么停更、要么收费、要么配置复杂的现状,不妨试试 edl

一个二进制文件,开箱即用:

edl download https://example.com/your-file.zip

就这么简单。有什么问题欢迎来 GitHub 提 issue,我会认真看每一个。