Одна команда Weather CLI, пять инструментальных экосистем: Rust, Go, Zig, Bun и Node.js
Как выглядит тот же небольшой CLI на основе HTTP-протокола и JSON в Rust, Go, Zig, Bun и Node.js, а также что означают размер бинарного файла, время сборки и сложность настройки при выборе инструмента.
Микротесты, такие как цикл Фибоначчи, мало что говорят о затратах на создание реального инструмента командной строки. Лучшим тестом является небольшая утилита, которая взаимодействует с сетью, декодирует JSON, выполняет простые математические операции и выводит структурированный результат; она должна быть реализована одинаково на нескольких языках. В данном обзоре именно такой эксперимент проводится с использованием Rust, Go, Zig, Bun и Node.js, чтобы вы могли оценить, какой набор инструментов лучше всего подходит для вашей следующей командной строки, исходя из размера бинарного файла, времени сборки, нагрузки во время выполнения и, что самое важное, от количества препятствий между пустой папкой и бинарным файлом, который могут запустить ваши пользователи.
Стоит сразу упомянуть основные результаты. Размер готовых бинарных файлов варьировался от 1,2 МБ до 45 МБ, а полное пропускание этапа компиляции требует от пользователей установки среды выполнения объемом примерно 100 МБ. Время сборки без лишних операций составляло от 0,8 секунд до 28 секунд. Самая прагматичная рекомендация в конце — это не выбирать язык с самой высокой скоростью выполнения.
Инструмент тестирования и причины его подходящести
Этот утилитарный инструмент называется wx. Вы указываете ему название города; он преобразует это название в координаты с помощью API геокодирования Open-Meteo, запрашивает текущие погодные условия через API прогнозов Open-Meteo и выводит результат вместе с рассчитанной температурой, ощущаемой человеком. Типичный вызов выглядит следующим образом:
$ wx reykjavik
Reykjavik, Iceland
Temperature: 4.2C (feels like -0.4C)
Wind: 24 km/h NNW
Humidity: 68%
Этот инструмент преднамеренно прост в реализации, но охватывает четыре области, в которых эргономика командной строки действительно отличается в разных экосистемах:
- HTTP-клиент с TLS. Требуются два запроса HTTPS: один для геокодирования, другой — для получения прогноза.
- Декодирование JSON. Ответы преобразуются в структурированные данные с определенным типом, а не обрабатываются как свободные объекты.
- Фактические вычисления. Значение кажущейся температуры определяется по формуле с ветвлениями, а не путем объединения строк.
- Вывод в терминал. Цвета ANSI и выровненные колонки — то, что фактически видят пользователи.
Язык, который не может комфортно обрабатывать все эти аспекты, не является хорошим выбором для командной строки, независимо от скорости выполнения числовых операций.
Единственный элемент общей логики
Каждая версия использует одно и то же правило с трьмя ветвями. При температуре ниже 10 °C применяется формула оценки холода от Environment Canada. При температуре выше 27 °C используется регрессионная формула индекса жары Rothfusz от NOAA, выраженная в градусах Цельсия с учетом влажности в процентах. В промежуточном диапазоне значение температуры возвращается без изменений. Здесь показана версия на TypeScript, используемая как в сборках Bun, так и Node.js; другие языки реализуют ту же арифметику.
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 — reqwest для работы с HTTP и 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 МБ. Включение оптимизации во время связывания и удаления символов из файла Cargo.toml сократило его размер до 3,8 МБ. Эти настройки хорошо известны тем, кто создаёт инструменты на 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) } встречается пять раз — по одному разу для запроса на геокодирование, его обработки, декодирования, запроса на прогноз погоды и обработки результата прогноза. Такое повторение не является серьезной проблемой, но это самая частая строка практически в любом CLI-программе на 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 действительно есть встроенный менеджер пакетов на основе build.zig.zon, доступный с версии 0.11, но экосистема по-прежнему ограничена; не было найдено ни одной поддерживаемой библиотеки цветов терминала, поэтому вместо этого была скопирована примерно 40-строчная помощница ANSI из gist.
Bun с одним файлом TypeScript
Версия 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}¤t=temperature_2m,wind_speed_10m,relative_humidity_2m,wind_direction_10m`
);
const data = await wxRes.json();
Весь файл состоит из 47 строк, включая оба запроса, и его выполнение занимало около восьми минут; большая часть времени уходила на вычисление формулы температуры. Однако обратите внимание, чего не делает этот фрагмент кода: он никогда не проверяет значение res.ok, а переменная geo.results[0] остается неопределенной, когда геокодер не находит совпадений, в результате чего ошибка неправильного написания названия города приводит к сбою из-за ошибки деструктуризации вместо дружелюбного сообщения. Конечно, код краткий, но в производственной CLI необходимы эти дополнительные строки.
Проблема с Bun заключается в способе распространения. Команда bun build --compile генерирует автономный исполняемый файл размером около 45 МБ для этого крошечного инструмента, поскольку в результате сборки объединяются движок JavaScriptCore и среда выполнения Bun. Это примерно в семь раз больше, чем у бинара на Go, и почти в сорок раз больше, чем у бинара на Zig. Если у ваших пользователей уже установлен Bun, прямое запускание файла .ts полностью избавляет от этой проблемы.
Node.js, который не требует отдельного описания
Реализация Node.js по сути является кодом Bun, поскольку с версии 18 Node.js включает встроенную функцию fetch. Значимые различия кроются в среде выполнения:
- Затраты на запуск. Большая часть разницы в общем времени выполнения связана с самим запуском процесса: 82 мс у Node.js против 24 мс у Bun, в то время как имитация сетевого запроса добавляет всего несколько миллисекунд. При одной интерактивной команде этого не заметно; однако в цикле оболочки, выполняющем инструмент сотни раз, это накапливается.
.ts может запускаться без использования tsx или ts-node; на момент написания статус этой функции был охарактеризован как стабильный в рамках версий LTS 24. Поддерживается только синтаксис, который можно удалить: аннотации исчезают, но конструкции, генерирующие JavaScript, такие как перечисления, пространства имен и свойства параметров, по-прежнему требуют транспилятора. Bun идет еще дальше без необходимости настройки, обрабатывая JSX, декораторы и псевдонимы путей. Чтобы узнать подробнее о том, что включает в себя встроенная функция удаления синтаксиса, ознакомьтесь с тем, что на самом деле делает и чего не делает встроенная поддержка TypeScript в Node.js.Если смотреть исключительно с точки зрения совершенно новой командной строки, Node.js предлагает тот же код, что и Bun, но с более медленным запуском и более сложной структурой распространения. Однако это довольно узкий взгляд на ситуацию. Если ваша команда уже использует Node.js в качестве стандарта, полагается на гарантии совместимости npm или поддерживает существующую командную строку на его основе, эти факторы могут легко перевесить разницу в 60 мс при запуске.
Настройка измерений и приведенные цифры
Временные показатели были зафиксированы на M3 MacBook Pro с 18 ГБ ОЗУ, работающем под управлением macOS 26, с использованием hyperfine по команде hyperfine --warmup 3 --min-runs 50 './wx reykjavik'. Для устранения сетевого шума обе API-запросы направлялись на локальный имитационный HTTP-сервер, возвращающий фиксированный JSON. Показатель полного времени выполнения — это время от запуска процесса до его завершения, включая время загрузки. Времена разработки являются приблизительными оценками, а не измеренными значениями, поэтому следует сравнивать соотношения, а не конкретные минуты.
Основные показатели выполнения:
- Rust: около 95 строк кода, 3,8 МБ после настройки (8,4 МБ по умолчанию), 28 секунд на чистую компиляцию, время выполнения — 4,8 мс.
- Go: 68 строк кода, 6,2 МБ, компиляция занимает менее секунды, время выполнения — 5,2 мс; работа завершается примерно за 10 минут.
Значения размера зависят от флагов компиляции. Для Rust требовались параметры lto = true и strip = true в настройках [profile.release]. Go был скомпилирован с использованием команды go build -ldflags='-s -w' для удаления информации для отладки. Размер бинарника Zig в 1,2 МБ соответствует режиму компиляции ReleaseSmall; он остается крайне маленьким даже с поддержкой TLS, поскольку клиент HTTP и криптографический код находятся в стандартной библиотеке и статически связаны с удалённым «мертвым» кодом, в отличие от использования таких библиотек, как OpenSSL. Режим компиляции ReleaseSafe, сохраняющий проверки безопасности во время выполнения, даёт размер бинарника около 2,1 МБ.
Что скрывают цифры тестов производительности
Zig побеждает по теоретическим показателям, но проигрывает на практике
Согласно метрикам, Zig генерирует самый маленький и один из самых быстрых бинарников. Однако результаты теста, выполненного за час на 108 строках кода, показывают другую картину:
- Проблема с пакетом сертификатов заняла более 40 минут из-за ошибки, состоящей всего из одной строки кода.
ReleaseSmall размером 1,2 МБ не содержит трассировок стека; версия ReleaseSafe сохраняет трассировки сбоев, но занимает 2,1 МБ.defer, приводивших к освобождению памяти, вызывали утечки, которые проявлялись лишь тогда, когда дебаггер-механизм выделения памяти сообщал об этом при завершении работы.Для инструмента долгосрочного использования, где важно контролировать каждое выделение памяти и каждый байт выводимого контента, такая явная структура оправдывается со временем. Для чего-то, что нужно сделать к обеду, он в настоящее время не подходит. Сам язык имеет элегантную архитектуру; однако окружающая его экосистема ещё слишком молода.
Go никогда не бывает лучшим в чем-то одном, но всё равно побеждает в целом
Go компилируется менее чем за секунду, генерирует приемлемый самодостаточный бинарник, требует только стандартной библиотеки и уже функционировал через десять минут. Он больше, чем оптимизированный Rust (6,2 МБ против 3,8 МБ), и немного медленнее (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, это просто непревзойдённо. Однако при распространении 45 МБ для поиска погоды — это серьёзный недостаток, и поскольку почти вся эта масса данных состоит из встроенного движка, практически невозможно уменьшить размер без более лёгкой версии сборки от команды Bun, которой на момент тестирования не существовало.
Аргументы Rust в пользу небольших инструментов ослабли
Несколько лет назад преимущества Rust для CLI-инструментов заключались в скорости, безопасности и небольшом размере бинарников. Для инструментов, ориентированных на ввод-вывод, эти преимущества теперь уже не так очевидны:
- Go обеспечивает практически такую же скорость, как Rust.
- Zig генерирует более маленькие бинарники без какой-либо настройки.
- Время сборки в 28 секунд для программы из 95 строк — это слишком большая нагрузка для быстрых утилит.
Rust по-прежнему выделяется тем, что его инструменты привлекают тысячи пользователей и поддерживаются годами, причем система типов постоянно помогает находить редкие случаи использования. ripgrep, fd, bat, delta и hyperfine — все это CLI-инструменты на Rust, которые долгое время серьезно поддерживаются, а не были написаны за выходные. Для краткосрочных проектов такие затраты редко оправдываются; однако для широко распространенных инструментов это может быть вариант, наименее склонный к появлению тонких багов.
Выбор инструментальной сборки для вашего следующего CLI
Решение зависит скорее не от чистой скорости, а от того, кто будет использовать инструмент и как он до него доберется:
- Выбирайте Go по умолчанию, если цель — минимальные общие затраты от пустого каталога до готового бинарника: рабочий код готов за несколько минут, сборка занимает доли секунды, файл весом 6,2 МБ без зависимостей и возможность легкой межсистемной компиляции.
Каждая реализация достаточно мала, чтобы её можно было пересобрать из приведённых выше фрагментов за один день вместе с тестовым сервером и простым скриптом. Конкретные цифры будут различаться в зависимости от аппаратного обеспечения, операционной системы и версий инструментов, но общая картина должна оставаться стабильной: Zig — самый маленький, Bun — самый крупный, Go — с наименьшими трудностями.
Основные выводы
- Измеряйте полный путь до готового бинарного файла, а не только время выполнения; на настройку, отладку и распространение уходит большая часть времени при работе с небольшими инструментами.
- Стандартные настройки сборки могут удвоить размер бинарного файла, поэтому изучите флаги выпуска той среды разработки, которую вы выбрали.
- Бинарные файлы, создаваемые с использованием Bun или Node.js SEA в режиме runtime, содержат всю движковую часть, что имеет гораздо большее значение, чем время их запуска.
- Проверяйте входные данные и HTTP-ответы даже в кратких скриптах; часто самая короткая реализация не включает обработку ошибок.
- Рассматривайте конкретные статусы версий и размеры как моментальную картину ситуации и перепроверяйте их по последним версиям перед принятием решения.
Связанные материалы
- Edge Isolates и Wasm против Node.js: компромиссы в работе среды выполнения и практики использования в производстве — рассматривается, как механизмы изоляции V8 и технология WebAssembly превосходят Node.js, основанный на контейнерах, в средах работы на краю сети, а также описываются практики управления, делающие Node.js готовым к использованию в производстве.
- Сравнение Node.js, Deno и Bun: тесты, компромиссы и стратегия миграции — объясняются реальные архитектурные различия между Node.js, Deno и Bun, что показывают тесты на 2025 год, и как определить, стоит ли и когда мигрировать.