首页 / 文章 / 一个天气命令行工具,五种技术栈:Rust、Go、Zig、Bun和Node.js

一个天气命令行工具,五种技术栈:Rust、Go、Zig、Bun和Node.js

同一款基于 HTTP 加 JSON 的小型 CLI 在 Rust、Go、Zig、Bun 和 Node.js 中的表现如何,以及二进制大小、构建时间和配置难度对您做选择有何影响。

3165 词

像斐波那契循环这样的微型基准测试几乎无法反映开发一个真正的命令行工具所需的实际成本。更好的测试方法是编写一个小工具,让它能够与网络通信、解码JSON数据、进行简单计算并输出格式整齐的结果,然后用多种语言以完全相同的方式实现这个工具。本指南将围绕Rust、Go、Zig、Bun和Node.js展开这样的实验,这样你就可以根据二进制文件大小、构建时间、运行时开销,以及最重要的——从空文件夹到用户可运行的二进制文件之间存在的障碍程度,来判断哪种工具链最适合你的下一个命令行工具。

最关键的测试结果值得首先提及。最终生成的二进制文件大小在1.2 MB到45 MB之间,而完全跳过编译步骤则要求用户安装大小约为100 MB的运行时环境。干净构建的时间则在0.8秒到28秒不等。最后给出的最实际的建议是:并非执行速度最快的编程语言才是最佳选择。

测试工具及其为何能代表公平的工作负载

该工具名为wx。用户只需输入城市名称,它就会通过Open-Meteo的地理编码API将该名称转换为坐标,再从Open-Meteo的天气预报API获取当前天气状况,并输出结果以及计算出的“体感温度”。典型的使用方式如下:

$ wx reykjavik
Reykjavik, Iceland
Temperature: 4.2C (feels like -0.4C)
Wind: 24 km/h NNW
Humidity: 68%

该工具刻意保持小巧,但它涉及了不同生态系统在命令行界面易用性方面存在差异的四个领域:

  • 带TLS功能的HTTP客户端。需要发送两个HTTPS请求,一个用于地理编码,另一个用于天气预报。
  • JSON解码。响应会被映射到结构化的类型中,而非作为松散的对象来处理。
  • 实际计算。表观温度是通过带分支的公式计算得出的,而非字符串拼接结果。
  • 终端输出。ANSI颜色与对齐的列格式,这是用户实际看到的内容。

无论其执行数值循环的速度有多快,若某种语言无法妥善处理这四个方面,就不适合作为命令行界面工具的选择。

唯一的共享逻辑部分

每个版本都采用相同的三分支规则。在10℃以下时,使用加拿大环境部的风寒指数公式;在27℃以上时,则采用美国国家海洋和大气管理局的Rothfusz热指数回归公式,结果以摄氏度表示并附有湿度百分比。介于这两者之间的温度则直接原样返回。此处展示的是Bun和Node.js版本所使用的TypeScript实现形式;其他语言也采用相同的计算逻辑。

function feelsLike(tempC: number, windKmh: number, humidity: number): number {
  if (tempC < 10) {
    // Wind chill (Environment Canada formula)
    const v = windKmh ** 0.16;
    return 13.12 + 0.6215 * tempC - 11.37 * v + 0.3965 * tempC * v;
  }
  if (tempC > 27) {
    // Heat index (NOAA Rothfusz regression, in Celsius)
    const t = tempC;
    const r = humidity;
    return -8.784 + 1.611 * t + 2.338 * r - 0.146 * t * r
      - 0.0123 * t * t - 0.0164 * r * r + 0.00221 * t * t * r
      + 0.000725 * t * r * r - 0.00000358 * t * t * r * r;
  }
  return tempC; // between 10C and 27C, raw temperature
}

有两点值得注意。首先,气象区域边界是实现不完善的版本容易出错的地方,因此在比较不同实现时,这些边界是很好的正确性检验标准。其次,这两个公式都有有效的适用范围,而这个简化版本并未考虑这些限制:风寒公式适用于风速大约5公里/小时及以上的情况,而Rothfusz回归模型则仅适用于炎热且湿度较高的环境。对于一个简单的天气工具来说这尚可接受,但正式版本应在超出这些范围时对数值进行限制或回退到实际室外温度。

各语言实现的开发体验

五种语言的完整代码总行数约为360行,因此此处仅展示具有代表性的片段。关键在于每种编程环境在哪些方面提供了帮助,又在哪些方面造成了阻碍。

Rust语言搭配reqwest、serde以及基于derive的参数解析器

Rust版本使用了一个流行的基于derive的库来处理参数解析,HTTP请求则用reqwest,反序列化则用serde。下面的代码片段展示了Rust在处理这类任务时的优势:derive宏能够从普通的结构体定义中自动生成命令行解析器和JSON解码器,同时还能在编译时检查字段类型。

#[derive(Parser)]
#[command(name = "wx", about = "Weather lookup")]
struct Cli {
    city: String,
}

#[derive(Deserialize)]
struct WeatherResponse {
    current: CurrentWeather,
}

#[derive(Deserialize)]
struct CurrentWeather {
    temperature_2m: f64,
    wind_speed_10m: f64,
    relative_humidity_2m: u8,
    wind_direction_10m: f64,
}

整个程序大约有95行代码,涵盖了API调用和温度计算逻辑。异步运行时tokio需要用户明确启用,而Node.js则隐藏了其事件循环机制。这种显式性有助于更好地控制程序流程,但也会增加二进制文件的体积。

令人惊讶的是默认输出结果:仅使用 cargo build --release 即可生成8.4 MB的二进制文件。在 Cargo.toml 中启用链接时优化和符号删除功能后,文件大小降至3.8 MB。这些设置在那些发布Rust工具的人中早已广为人知,但新手很可能会直接分发较大的文件,而不知道只需修改两行代码就能得到更小的版本。

仅使用标准库

Go编译完全不需要任何第三方包。net/http、encoding/json和os.Args就足以完成所有工作。示例代码展示了响应结构体,其中的标签将JSON键映射为符合Go语言习惯的字段名,同时还展示了包含最基本使用检查的 main 函数开头部分。

type WeatherResponse struct {
    Current struct {
        Temperature float64 `json:"temperature_2m"`
        WindSpeed   float64 `json:"wind_speed_10m"`
        Humidity    int     `json:"relative_humidity_2m"`
        WindDir     float64 `json:"wind_direction_10m"`
    } `json:"current"`
}

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: wx <city>")
        os.Exit(1)
    }
    city := os.Args[1]
    // geocode, fetch weather, compute, print
}

最终生成的程序共有68行代码。其中有一种模式十分常见:if err != nil { log.Fatal(err) }这一检查语句出现了五次,分别对应地理编码请求、其处理过程、解码操作、天气预报请求以及天气预报结果的处理。虽然这种重复本身并非问题,但它几乎是所有Go命令行工具中最常见的代码结构。

大约十分钟内就生成了可运行的二进制文件。这样的速度并非得益于Go语言的简洁性(这种特性并不受所有人欢迎),而是因为该语言无需做任何决策:无需比较不同库、无需下载依赖项、也无需进行配置。

Zig 0.16搭配std.http.Client与std.json

Zig在0.16版本下进行测试,它是最具教育意义的构建版本,但完成速度也最慢。该代码片段展示了响应结构以及main函数的起始部分,在此处会创建一个调试用分配器并随后将其传递给其他函数。这正是Zig的显著特点:任何可能分配堆内存的函数都会接收一个分配器参数,因此内存所有权始终体现在函数调用签名中。

const WeatherResponse = struct {
    current: struct {
        temperature_2m: f64,
        wind_speed_10m: f64,
        relative_humidity_2m: u8,
        wind_direction_10m: f64,
    },
};

pub fn main() !void {
    var debug_allocator = std.heap.DebugAllocator(.{}){};
    defer _ = debug_allocator.deinit();
    const allocator = debug_allocator.allocator();

    // Every function that might allocate takes `allocator` as a parameter.
    // This is Zig's deal: you control memory, always.
}

该程序的代码行数增至108行,成为五个程序中最长的一个。编译本身仅耗时0.8秒,是所有测试中最快的,但整个构建过程却花了超过一小时的时间。问题的根源在于TLS。虽然std.http.Client内置了自身的TLS实现,但仍需要可信的根证书,而在测试机器上无法找到系统的CA证书包。唯一的错误表现为error.TlsInitializationFailed,且没有其他相关提示信息。通过std.crypto.Certificate.Bundle显式加载证书并将该证书包传递给客户端,这一解决方案是在GitHub的议题讨论中才被提出的。不同平台上的测试结果可能会有所差异,但其中的教训是:再快的编译器也无法弥补因不可知的运行时错误而损失的时间。

在那些对内存行为有严格要求的软件中,显式传递分配器非常有用。但对于只需分配几个字符串和一个JSON缓冲区的工具而言,这样做反而增加了不必要的繁琐步骤。Zig从0.11版本起就提供了基于build.zig.zon的集成包管理器,但相关生态系统仍然较为薄弱;目前还没有经过维护的终端颜色库,因此只好从某个gist中复制了一个约40行的ANSI辅助函数。

仅使用一个TypeScript文件的Bun

Bun版本最为简短且易于理解。它从Bun.argv中读取城市名称,若该参数缺失则会输出使用说明后退出;随后会两次调用内置的fetch函数,并用.json()方法解码每次请求的响应。由于使用了顶层await,因此无需额外的包装函数。

const city = Bun.argv[2];
if (!city) {
  console.error("usage: wx <city>");
  process.exit(1);
}

// Geocode city name to coordinates
const geoRes = await fetch(
  `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
);
const geo = await geoRes.json();
const { latitude, longitude } = geo.results[0];

// Fetch weather
const wxRes = await fetch(
  `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,wind_speed_10m,relative_humidity_2m,wind_direction_10m`
);
const data = await wxRes.json();

整个文件包含47行代码,涵盖了所有的请求处理逻辑,运行时间约为8分钟,其中大部分时间都用于计算温度公式。不过需要注意的是:该代码片段并未检查res.ok的状态,而且当地理编码器找不到匹配结果时geo.results[0]是未定义的,因此输入拼写错误的城市名称会导致解构错误而非友好的提示信息。虽然代码确实很简短,但生产环境下的命令行工具还是需要那些额外的几行代码。

Bun的缺点在于分发问题。bun build --compile为这个小型工具生成了一个约45MB的独立可执行文件,因为输出结果中包含了JavaScriptCore引擎和Bun运行时。这一大小是Go语言二进制文件的约7倍,也是Zig语言二进制文件的近40倍。如果用户已经安装了Bun,直接运行.ts文件就能完全避免这个问题。

无需单独列出的 Node.js

Node.js 的实现本质上就是 Bun 代码,因为从版本 18 开始 Node.js 已经内置了 fetch 函数。两者之间的实际差异体现在运行时方面:

  • 启动成本。 总运行时间上的差异主要源于进程的启动过程:Node.js 需要 82 毫秒,而 Bun 只需 24 毫秒,模拟的网络往返请求仅会增加几毫秒的时间。对于单个交互式命令来说几乎察觉不到差异;但在需要运行该工具数百次的 shell 循环中,这种差异就会累积起来。
  • 分发方式。用户需要安装约100 MB大小的Node.js运行时,或者通过单可执行应用程序功能生成独立文件。SEA属于原生方案,但其稳定性状况一直在变化,因此请查阅适用于您所使用Node.js版本的最新文档。由于它内置了整个运行时,其文件大小与编译后的Bun二进制文件相当。
  • TypeScript。 Node.js 现在可以自行移除可被删除的 TypeScript 语法,因此普通的 .ts 文件无需借助 tsx 或 ts-node 即可运行;在撰写本文时,该功能在 24 LTS 版本中已被视为稳定。目前仅支持可被删除的语法:注解会消失,但那些能生成 JavaScript 的结构,如枚举、命名空间和参数属性,仍需要转译器。Bun 更为更进一步,无需任何配置即可处理 JSX、装饰器和路径别名。如需详细了解原生移除功能的具体范围,请参阅Node.js 原生 TypeScript 支持的实际功能与限制。
  • 仅从全新命令行接口的角度来看,Node.js 虽然能提供与 Bun 相同的代码功能,但启动速度更慢,分发体积也更大。不过这种评价未免过于片面。如果您的团队已经采用 Node.js 作为标准,依赖其 npm 兼容性保障,或是已经在其基础上维护了现有命令行接口,那么这些因素足以抵消 60 毫秒的启动时间差异。

    测量设置及报告的数据

    测试在配备18 GB内存、运行macOS 26系统的M3 MacBook Pro上完成,使用了hyperfine工具,通过命令hyperfine --warmup 3 --min-runs 50 './wx reykjavik'进行测试。为消除网络干扰,所有API调用均指向一个返回固定JSON数据的本地模拟HTTP服务器。完整运行时间是指从进程启动到结束的实时时长,包括启动过程。开发时间仅为大致估算值而非实际测量结果,因此应比较各语言的运行速度比例,而非具体分钟数。

    测试得到的关键数据如下:

    • Rust:约95行代码,优化后可降至3.8 MB(默认为8.4 MB),干净构建耗时28秒,执行时间为4.8毫秒。
    • Go:68行代码,大小为6.2 MB,编译时间不到1秒,执行时间为5.2毫秒,整体运行耗时约10分钟。
  • Zig:108行代码,大小为1.2 MB,编译耗时0.8秒,总开发时间超过一小时。
  • Bun:47行代码,编译后大小约为45 MB,完整运行耗时24毫秒,大约8分钟即可完成运行。
  • Node.js:代码与Bun相同,完整运行耗时82毫秒,运行时内存约为100 MB,生成的二进制文件大小与Bun的同类版本相当。
  • 大小数值取决于构建选项。Rust在[profile.release]配置下需要设置lto = true和strip = true。Go则是通过go build -ldflags='-s -w'命令构建以去除调试信息。Zig的1.2 MB版本属于ReleaseSmall构建类型;即便包含TLS功能,其体积依然很小,因为HTTP客户端和加密代码都位于标准库中,并且经过静态链接去除了无用代码,无需引入类似OpenSSL这样的外部库。ReleaseSafe构建类型保留了运行时安全检查,大小约为2.1 MB。

    基准测试数据未揭示的信息

    纸面上Zig领先,实际性能却落后

    从各项指标来看,Zig生成的二进制文件体积最小且速度最快之一。但实际测试108行代码所花费的时间却展现了另一面:

    • 处理证书包问题就耗费了40多分钟,仅仅因为一行代码的错误。
  • 生成1.2 MB大小的ReleaseSmall版本不包含堆栈跟踪信息;而ReleaseSafe版本虽保留了崩溃日志,但大小为2.1 MB。
  • 所有字符串操作都必须通过线程化的分配器来完成,此外还有两个被忽略的defer释放操作导致了内存泄漏,这些问题直到调试分配器在程序退出时报告后才被发现。
  • 由于现有生态系统中缺乏相关功能,不得不自行实现一个终端颜色处理工具。
  • 对于那种需要精确控制每一处内存分配及每字节输出结果的长期使用的工具来说,这种明确的实现方式会随着时间带来好处。但对于那些希望能在短时间内就能使用的工具而言,目前这种设计并不合适。该语言的设计十分优雅,只是其周边生态系统还不够成熟。

    Go虽并非在某一方面做到极致,但总体仍能胜出

    Go的编译时间不到一秒,生成的二进制文件体积适中且自包含,仅需标准库即可运行,10分钟内就能完成开发。虽然它的体积比优化后的Rust更大(6.2 MB对比3.8 MB),运行速度也稍慢一些(5.2毫秒对比4.8毫秒),但对于一个在几毫秒内就能完成运行的工具来说,这种差异是难以察觉的。

    跨平台编译只需设置一个环境变量,例如GOOS=linux go build。Rust通过cargo build --target x86_64-unknown-linux-gnu或cargo-zigbuild工具也能实现类似功能,而Zig由于自带链接器和libc,其跨平台编译能力堪称最强。不同之处在于Go不需要任何额外的工具或复杂的设置流程。这正是Go的典型特点:它在单个指标上很少能做到最优,但却拥有最低的整体使用门槛。

    Bun在本地开发体验极佳,但部署起来较为麻烦

    Bun在最短的时间内生成了最为干净的代码。对于存储在~/bin目录中的.ts格式个人脚本而言,几乎无可匹敌。但就分发而言,天气查询功能需要45MB的体积实在是个缺点;而且由于其中大部分都是内置引擎,除非Bun团队能推出更轻量的运行时版本——而在测试时并未有此类版本——否则实际上无法进一步缩小文件大小。

    Rust在小型工具领域的优势已减弱

    几年前,Rust CLI的优势在于速度、安全性以及较小的二进制文件大小。但对于依赖I/O操作的工具来说,这一优势已不再那么明显:

    • Go在实际速度上可与Rust相媲美。
    • Zig无需任何调整即可生成更小的二进制文件。
    • 一个仅95行代码的程序就需要28秒的时间才能完成干净构建,这对需要快速使用的工具来说负担过重。

    Rust在那些拥有数千用户且经过多年维护的工具中依然表现出色,其类型系统还能不断发现各种边缘情况。ripgrep、fd、bat、delta和hyperfine都是用Rust编写的命令行工具,它们都经过了长期且认真的维护,而非在周末仓促编写而成。对于快速开发的项目来说,这种额外的开销往往得不偿失;但对于需要广泛部署的工具而言,它可能是最不容易积累隐蔽缺陷的选择。

    为下一个命令行工具选择技术栈

    决策的关键不在于纯粹的速度,而在于谁来使用该工具以及如何让使用者获取它:

    • 如果目标是实现从空目录到可分发二进制文件的最低总体成本:几分钟内就能得到可用代码,构建速度在亚秒级,仅有一个6.2 MB的无依赖文件,且能轻松进行跨平台编译,那么首选Go语言。
  • 选择 Rust,适用于那些有大量用户且对性能要求较高的工具,比如构建工具、代码检查器和测试运行器,同时需接受其较长的编译时间。
  • 使用 Bun,适用于个人或团队内部工具,因为所有使用者都已拥有其运行时环境,且这些文件无需编译。
  • 暂缓选择 Zig用于普通命令行工具,需待其生态系统和错误提示功能更加成熟;虽然 1.2 MB 的二进制文件体积令人印象深刻,但对于简单工具而言,目前为其发展所做的努力还显得不成比例。
  • 若已使用 Node.js 作为平台,则无需更换。对于全新的独立 CLI 工具,它几乎不具备 Bun 所没有的功能,不过受生态系统和组织架构的限制,它仍可能是合理的选择。如需更全面的运行时对比,请参阅Node.js、Deno 与 Bun 的对比。
  • 每种实现体的体积都足够小,只需利用上述片段再加上一个模拟服务器和一段超简脚本,便可在一个下午内重新构建。实际性能数值会因硬件、操作系统及工具链版本的不同而有所差异,但相对排序应保持稳定:Zig 最小,Bun 最大,Go 的开发阻力最小。

    核心要点

    • 应衡量到已打包二进制文件的完整路径长度,而不仅仅是执行时间;对于小型工具而言,准备、调试和分发环节所占时间往往更长。
    • 默认的构建设置可能会使二进制文件大小翻倍,因此需了解所选工具链的相关发布标志。
    • Bun或Node.js SEA生成的运行时二进制文件会包含整个引擎,这一点远比它们的启动时间更为重要。
    • 即便在简短的脚本中也要对输入数据和HTTP响应进行验证;最简短的实现方案往往缺乏错误处理机制。
    • 此处列出的特定版本状态和大小仅作为参考,做决定前应再次核对当前发布的实际数据。

    相关阅读

  • Node.js与Go在JSON API中的对比:吞吐量相同,内存占用仅为其1/2.6 —— 对相同的Node.js和Go HTTP服务进行的并行负载测试表明,重写代码在哪些方面能带来优势(内存占用),而在哪些方面则没有效果(吞吐量和延迟)。