Головна / Статті / Одна команда Weather CLI, п’ять інструментальних середовищ: Rust, Go, Zig, Bun та Node.js

Одна команда Weather CLI, п’ять інструментальних середовищ: Rust, Go, Zig, Bun та Node.js

Як виглядає та сама невелика CLI на основі HTTP-plus-JSON у Rust, Go, Zig, Bun та Node.js, і що означають розмір бінару, час збирання та складності налаштування для вибору інструменту.

3165 слів

Мікротестування, таке як цикл Фібоначчі, мало що говорить про те, скільки коштує створення справжнього інструменту командного рядка. Кращим тестом є невеликий утилітний програмний засіб, який взаємодіє з мережею, декодує 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 застосовується регресійний метод оцінки спеки від NOAA Rothfusz, виражений у градусах Цельсія з вологістю у відсотках. У проміжку температура повертається без змін. Тут показана версія на 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, але екосистема все ще залишається скромною; не знайшлося підтримуваної бібліотеки кольорів терміналу, тому замість неї було скопійовано допоміжний код ANSI довжиною близько 40 рядків з гісту.

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}&current=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, тоді як імітація мережевого запиту додає лише кілька мілісекунд. Для однієї інтерактивної команди це непомітно; проте у циклі шелу, який виконує інструмент сотні разів, це накопичується.
  • Розповсюдження. Користувачам потрібен середовище виконання Node.js обсягом близько 100 МБ, або ж ви створюєте окремий файл за допомогою Single Executable Applications. SEA є частиною основного продукту, проте його статус стабільності постійно змінюється, тому перевіряйте актуальну документацію для вашої версії Node.js. Оскільки він вбудовує все середовище виконання, розмір результату збігається з розміром скомпільованого бінарного файлу Bun.
  • TypeScript. Node.js тепер може самостійно видаляти синтаксис TypeScript, який можна видалити, тож звичайний файл .ts працює без tsx чи ts-node; на момент написання цього тексту ця функція вважалася стабільною у версіях лінійки 24 LTS. Підтримується лише синтаксис, який можна видалити: анотації зникають, але конструкції, які генерують JavaScript, такі як енумерації, простори імен та властивості параметрів, все ще потребують транспайлера. Bun йде ще далі без налаштувань, обробляючи JSX, декоратори та псевдоніми шляхів. Щоб детальніше дізнатися, що саме включає в себе вбудоване видалення, перегляньте що насправді робить та чого не робить вбудована підтримка TypeScript у Node.js.
  • Якщо дивитися виключно з точки зору абсолютно нового CLI, Node.js пропонує той самий код, що й Bun, але з повільнішим запуском та складнішою процедурою розповсюдження. Проте це досить обмежений підхід. Якщо ваша команда вже стандартизувалася на Node.js, покладається на гарантії сумісності npm чи підтримує на ньому існуючий CLI, ці фактори можуть легко переважити різницю у часі запуску у 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 хвилин.
  • Zig: 108 рядків, 1,2 МБ, час компіляції — 0,8 секунди, загальний час роботи — понад годину.
  • Bun: 47 рядків, розмір скомпільованого коду — близько 45 МБ, час виконання — 24 мс, загальний час роботи — близько 8 хвилин.
  • Node.js: той самий код, що й у Bun, час виконання — 82 мс, розмір під час роботи — приблизно 100 МБ або бінарний файл SEA у тому ж класі розміру, що й у Bun.
  • Розміри файлів залежать від параметрів компіляції. Для Rust були необхідні параметри lto = true та strip = true у налаштуваннях [profile.release]. Go було скомпільовано за допомогою команди go build -ldflags='-s -w' для видалення інформації для дебагування. Розмір 1,2 МБ у версії Zig — це результат компіляції в режимі 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 CLIs полягали у швидкості, безпеці та невеликих бінарних файлах. Для інструментів, заснованих на введенні-виведенні, ці переваги тепер менш очевидні:

    • Go досягає практично такої ж швидкості, як Rust.
    • Zig створює менші бінарні файли без будь-якої налаштування.
    • Час компіляції у 28 секунд для програми з 95 рядків є значним тягарем для швидких утиліт.

    Rust все ще залишається найкращим варіантом там, де інструмент приваблює тисячі користувачів та потребує багаторічного обслуговування, а система типів постійно виявляє рідкісні ситуації. ripgrep, fd, bat, delta та hyperfine — це всі CLI-інструменти на Rust, які серйозно підтримуються протягом тривалого часу, а не створюються за кілька вихідних. Для швидких проектів такі зусилля рідко виправдовуються; проте для інструменту з широким поширенням це може бути варіант, найменш схильний до появи тонких помилок.

    Вибір інструментального комплексу для вашого наступного CLI

    Рішення залежить менше від чистої швидкості, ніж від того, хто буде використовувати інструмент та яким чином він до нього дістанеться:

    • Використовуйте Go за замовчуванням, якщо метою є мінімальна загальна вартість від порожнього каталогу до готового бінарного файлу: код готовий за кілька хвилин, збірка відбувається за долі секунди, є один файл розміром 6,2 МБ без залежностей та можлива легка міжплатформова компіляція.
  • Оберіть Rust для інструментів з великою аудиторією, де важливі маржі прибутку, таких як інструменти для компіляції, лінтери та засоби виконання тестів, і погодьтеся на більш тривалий час компіляції.
  • Використовуйте Bun для особистих чи внутрішніх інструментів команди, де у всіх вже є необхідний середовище виконання, і файли ніколи не потребують компіляції.
  • Відкладіть використання Zig для простих CLI-інструментів, доки не стане більш розвиненою його екосистема та повідомлення про помилки; бінарник розміром 1,2 МБ справді вражаючий, але зусилля для досягнення такого рівня поки що не є пропорційними для простих утиліт.
  • Залиште Node.js там, де він вже є вашою платформою. Для абсолютно нового самостійного CLI він пропонує мало того, чого немає у Bun, хоча екосистемні та організаційні обмеження все ще можуть зробити його розумним вибором. Для більш повного порівняння середовищ виконання дивіться Node.js, Deno та Bun у порівнянні.
  • Кожна реалізація достатньо компактна, щоб її можна було перебудувати з вищезазначених фрагментів протягом одного дня разом із тестовим сервером та дуже простим скриптом. Ваші конкретні показники будуть залежати від апаратного забезпечення, операційної системи та версій інструментальних комплексів, але відносна картина має залишатися стабільною: Zig — найменший, Bun — найбільший, Go — з найменшими труднощами.

    Основні висновки

    • Вимірюйте всю довжину шляху до завантаженого бінарного файлу, а не лише час виконання; налаштування, дебагування та розповсюдження відіграють ключову роль у маленьких інструментах.
    • Стандартні налаштування компіляції можуть подвоїти розмір бінарного файлу, тому вивчіть прапорці випуску будь-якого інструментарію, який ви оберете.
    • Бінарні файли, створені за допомогою Bun чи Node.js SEA та які працюють у режимі реального часу, містять весь двигун, що має набагато більше значення, ніж час їхнього запуску.
    • Перевіряйте вхідні дані та відповіді HTTP навіть у коротких скриптах; найкоротша реалізація часто є тією, у якій відсутнє оброблення помилок.
    • Розглядайте конкретні статуси версій та їхні розміри як моментальний знімок та перевіряйте їх у порівнянні з поточними версіями перед прийняттям рішення.

    Пов’язана література

    • Edge Isolates and Wasm vs. Node.js: Runtime Tradeoffs and Prod Habits — Розглядається, як ізоляція V8 та WebAssembly перевершують Node.js, заснований на контейнерах, на периферії мережі, а також описуються практики керування, які роблять Node.js готовим до використання у продакшені.
    • Node.js, Deno, and Bun Compared: Benchmarks, Trade-offs, and Migration Strategy — Пояснюються справжні архітектурні відмінності між Node.js, Deno та Bun, що розкривають тестування 2025 року, та як вирішити, чи потрібно мігрувати та коли це зробити.
  • Node.js проти Go для JSON API: однакова продуктивність, у 2,6 разу менше пам’яті — порівняльні тести навантаження ідентичних HTTP-сервісів на Node.js та Go показують, де переписування коду приносить користь (споживання пам’яті) та де — ні (продуктивність та затримка).