Jedna interfejs konsolowa Weather, pięć ekosystemów narzędzi: Rust, Go, Zig, Bun i Node.js
Jak wygląda ta sama mała narzędzie CLI HTTP-plus-JSON w językach Rust, Go, Zig, Bun i Node.js, oraz co rozmiar pliku binarnego, czas kompilacji i trudności przy konfiguracji oznaczają dla Twojego wyboru.
Mikropomiary wydajności, takie jak pętla Fibonacciego, niewiele mówią o kosztach stworzenia prawdziwego narzędzia wiersza poleceń. Lepszym testem jest mała aplikacja pomocnicza, która komunikuje się z siecią, dekoduje JSON, wykonywa proste obliczenia i wypisuje uporządkowany wynik, zbudowana identycznie w kilku językach. Niniejszy przewodnik opisuje dokładnie takie doświadczenie w językach Rust, Go, Zig, Bun i Node.js, abyś mógł ocenić, który zestaw narzędzi pasuje do twojego kolejnego narzędzia wiersza poleceń, biorąc pod uwagę rozmiar pliku binarnego, czas kompilacji, obciążenie podczas działania oraz, co najważniejsze, stopień trudności w stworzeniu pliku, który użytkownicy będą mogli uruchomić.
Najważniejsze wyniki warto podać od razu. Ostateczne rozmiary plików binarnych wahały się od 1,2 MB do 45 MB, a pominięcie kompilacji oznacza konieczność zainstalowania przez użytkowników biblioteki runtime o rozmiarze około 100 MB. Czas budowania czystego kodu wahał się od 0,8 sekundy do 28 sekund. Najpraktyczniejszą rekomendacją na koniec jest to, by nie wybierać języka o najszybszym czasie wykonania.
Narzędzie testowe i dlaczego stanowi sprawiedliwy obciążenie
To narzędzie nazywa się wx. Podaje się mu nazwę miasta; narzędzie to przekształca tę nazwę na współrzędne za pomocą API geokodowania Open-Meteo, żąda aktualnych warunków pogodowych z API prognoz Open-Meteo i wyświetla wynik wraz z obliczoną temperaturą odczuwalną. Typowe użycie wygląda następująco:
$ wx reykjavik
Reykjavik, Iceland
Temperature: 4.2C (feels like -0.4C)
Wind: 24 km/h NNW
Humidity: 68%
Narzędzie jest celowo małe, ale obejmuje cztery obszary, w których ergonomia interfejsu linii poleceń rzeczywiście różni się pomiędzy poszczególnymi ekosystemami:
- Klient HTTP z TLS. Potrzebne są dwa żądania HTTPS – jedno do geokodowania, a drugie do uzyskania prognozy.
- Dekodowanie JSON. Odpowiedzi są mapowane na struktury typizowane, zamiast być traktowane jako luźne obiekty.
- Rzeczywiste obliczenia. Temperatura pozorna jest wynikiem zastosowania wzoru z rozgałęzieniami, a nie łączenia ciągów znaków.
- Wyjście do terminala. Kolory ANSI i wyrównane kolumny – to właśnie widzą użytkownicy.
Język, który nie potrafi wygodnie obsłużyć wszystkich tych czterech aspektów, nie jest dobrym wyborem dla interfejsu linii poleceń, bez względu na to, jak szybko potrafi wykonać złożony pętli numeryczne.
Jedyna wspólna logika
Każda wersja stosuje tę samą zasadę trzech gałęzi. Poniżej 10 °C stosuje się wzór na chłód wiatrowy opracowany przez Environment Canada. Powyżej 27 °C stosuje się regresję wskaźnika upału Rothfusza z NOAA, wyrażoną w stopniach Celsjusza przy wilgotności podanej w procentach. W pozostałych zakresach temperatura jest zwracana bez zmian. Tutaj przedstawiono wersję w TypeScript, używaną zarówno w budowlach Bun, jak i Node.js; inne języki implementują tę samą logikę arytmetyczną.
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
}
Dwa aspekty wymagają uwagi. Po pierwsze, granice reżimów są tym elementem, który często błędnie implementuje się przy nieudolnym przenoszeniu kodu, więc stanowią dobry punkt sprawdzania poprawności przy porównywaniu różnych wersji implementacji. Po drugie, obie formuły mają zakresy ważności, które ta prosta wersja ignoruje: równanie na chłód spowodowany wiatrem jest przeznaczone dla prędkości wiatru wynoszącej około 5 km/h i więcej, natomiast regresja Rothfusza nadaje się tylko do warunków upalnych i dość wilgotnych. Dla prostego narzędzia pogodowego jest to akceptowalne, ale wersja produkcyjna powinna ograniczyć działanie do wartości temperatury poza tymi zakresami lub użyć bezpośredniej wartości temperatury.
Wrażenia z budowania poszczególnych implementacji
Pełny kod w wszystkich pięciu językach liczy około 360 linii, więc pokazane są tylko najważniejsze fragmenty. Kluczowe jest to, w jakich miejscach poszczególne środowiska programistyczne pomogły, a w których stanowiły przeszkodę.
Rust z bibliotekami reqwest, serde oraz parserem argumentów opartym na derive
Budowa w Rust wykorzystuje popularny pakiet oparty na mechanizmie derive do analizy argumentów, reqwest do operacji HTTP oraz serde do deserializacji. Poniższy fragment pokazuje wzorzec, który sprawia, że Rust jest przyjazny do tego typu zadań: makra derive generują zarówno parser interfejsu wiersza poleceń, jak i dekodery JSON na podstawie zwykłych definicji struktur, a typy pól są sprawdzane w czasie kompilacji.
#[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,
}
Cały program składa się z około 95 linii, obejmujących zarówno wywołania API, jak i logikę obliczania temperatury. Asynchroniczny silnik wykonawczy tokio musi być wyraźnie włączony, podczas gdy Node.js ukrywa przed użytkownikiem swój pętlę zdarzeń. Ta wyraźność jest przydatna do kontroli, ale wpływa na rozmiar pliku binarnego.
Najbardziej zaskakujące było standardowe wynik: prosty wywołanie cargo build --release wygenerowało plik binarny o rozmiarze 8,4 MB. Włączenie optymalizacji w czasie łączenia i usuwania symboli w pliku Cargo.toml zmniejszyło ten rozmiar do 3,8 MB. Ustawienia te są dobrze znane osobom tworzącym narzędzia w języku Rust, ale osoba początkująca prawdopodobnie rozpowszechniłaby większy plik, nie zdając sobie sprawy, że mniejsza wersja znajduje się zaledwie dwa wiersze dalej.
Korzystaj tylko z biblioteki standardowej
Budowa w języku Go w ogóle nie wymaga pakietów zewnętrznych. net/http, encoding/json oraz os.Args wystarczają do wykonania całej pracy. Pokazany fragment przedstawia strukturę odpowiedzi, w której tagi mapują klucze JSON na typowe nazwy pól w Go, oraz początek funkcji main z minimalną kontrolą użycia.
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
}
Gotowy program składa się z 68 linii. Dominuje w nim jeden wzorzec: sprawdzenie if err != nil { log.Fatal(err) } pojawia się pięć razy – po raz jeden dla żądania geokodowania, jego treści, procesu dekodowania, żądania prognozy oraz jej treści. Ta powtórność nie stanowi prawdziwego problemu, ale jest to najczęściej występująca linia w praktycznie każdym interfejsie CLI w języku Go.
Binarny plik działający powstał w ciągu około dziesięciu minut. Szybkość nie wynikała z faktu, że Go jest językiem minimalistycznym (ma silne upodobania, które nie każdemu się podobają), lecz z braku konieczności podejmowania decyzji: żadnego porównywania bibliotek, żadnego pobierania zależności, żadnej konfiguracji.
Zig 0.16 z std.http.Client i std.json
Zig, przetestowany w wersji 0.16, był najbardziej pouczającą wersją pod względem edukacyjnym, ale jednocześnie naj wolniejszą w realizacji. Fragment ten pokazuje strukturę odpowiedzi oraz początek funkcji main, gdzie tworzony jest alokator do celów debugowania, który następnie jest przekazywany dalej. To właśnie stanowi kluczową cechę Zig: każda funkcja, która może alokować pamięć na stosie, otrzymuje argument w postaci alokatora, dzięki czemu prawa własności pamięci są zawsze widoczne w sygnaturze wywołania.
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.
}
Program składał się z 108 linii, co czyniło go najdłuższym spośród pięciu. Samo skompilowanie trwało zaledwie 0,8 sekundy – to najszybszy wynik w całym porównaniu – jednak proces budowania oprogramowania zajmował ponad godzinę. Przyczyną była technologia TLS. Klasa std.http.Client zawiera własną implementację TLS, ale nadal wymaga zaufanych certyfikatów korzeniowych; na maszynie testowej nie udało się znaleźć zestawu certyfikatów systemowych. Jedynym objawem była błąd error.TlsInitializationFailed bez żadnych dodatkowych informacji. Rozwiązanie, polegające na wyraźnym załadunku certyfikatów za pomocą std.crypto.Certificate.Bundle i przekazaniu tego zestawu do klienta, pojawiło się dopiero w dyskusji na GitHubie. Twoje wyniki mogą się różnić w zależności od platformy, ale lekcja pozostaje ta sama: szybki kompilator nie może nadrobić czasu straconego na niewidoczne błędy w czasie wykonywania.
Jawnie przekazywany alokator jest doskonały w oprogramowaniu, gdzie ważne jest zachowanie pamięci. W przypadku narzędzia, które alokuje kilka łańcuchów tekstowych i jeden bufor JSON, dodaje to głównie zbędnych formalności. Zig posiada zintegrowany menedżer pakietów oparty na build.zig.zon, dostępny od wersji 0.11, ale ekosystem pozostaje skąpy; nie znaleziono utrzymywanej biblioteki kolorów terminala, więc zamiast tego skopiowano pomocnik ANSI składający się z około 40 linii z jednego gist.
Bun z pojedynczym plikiem TypeScript
wersja Bun jest najkrótsza i najłatwiejsza do odczytania. Odczytuje nazwę miasta z Bun.argv, wychodzi z komunikatem o użyciu, jeśli jest ona brakująca, a następnie dwukrotnie używa wbudowanej funkcji fetch i dekoduje każdą odpowiedź za pomocą .json(). Użycie await na najwyższym poziomie oznacza, że nie ma funkcji otaczającej.
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();
Cały plik składa się z 47 wierszy, wliczając obie żądania, i jego wykonywanie trwało około ośmiu minut, z czego większość czasu pochłonęła obliczanie wzoru temperatury. Należy jednak zauważyć, czego ten fragment nie robi: nigdy nie sprawdza wartości res.ok, a geo.results[0] jest niezdefiniowany, gdy geokodery nie znajdują odpowiednich danych, więc błąd spowodowany błędnym zapisem nazwy miasta powoduje awarię z błędem dekonstrukcji zamiast przyjaznej wiadomości. Chociaż tekst jest krótki, interfejs wiersza poleceń przeznaczony do użycia w produkcji wymaga tych kilku dodatkowych wierszy.
Problemem z Bun jest dystrybucja. Polecenie bun build --compile tworzy samodzielny plik wykonywalny o wielkości około 45 MB dla tego małego narzędzia, ponieważ plik wyjściowy zawiera silnik JavaScriptCore oraz środowisko wykonawcze Bun. To około siedem razy więcej niż binarny plik w języku Go i prawie czterdzieści razy więcej niż w przypadku Zig. Jeśli Twoi użytkownicy już mają zainstalowany Bun, bezpośrednie uruchomienie pliku .ts całkowicie unika tego problemu.
Node.js, który nie wymaga osobnego opisu
Implementacja Node.js to w istocie kod Bun, ponieważ od wersji 18 Node.js posiada wbudowaną funkcję fetch. Istotne różnice dotyczą środowiska wykonawczego:
- Koszt uruchomienia. Większość różnicy w całkowitym czasie działania wynika z samego uruchamiania procesu: 82 ms w przypadku Node.js w porównaniu z 24 ms u Bun, podczas gdy symulowane połączenie sieciowe dodaje zaledwie kilka milisekund. Przy jednym interaktywnym poleceniu nikt tego nie zauważy; w pętli w shellu, która uruchamia narzędzie setki razy, różnica się kumuluje.
.ts może działać bez użycia tsx czy ts-node; w momencie pisania ten funkcjonalność była określana jako stabilna w wersji 24 LTS. Obsługiwana jest tylko składnia nadająca się do usunięcia: adnotacje znikają, ale konstrukcje wytwarzające kod JavaScript, takie jak enumy, przestrzenie nazw i właściwości parametrów, nadal wymagają transpilatora. Bun idzie jeszcze dalej bez konfiguracji, obsługując JSX, dekoratory oraz aliasy ścieżek. Aby dowiedzieć się więcej na temat tego, co obejmuje natywna funkcja usuwania składni, zapoznaj się z tym, co faktycznie robi, a czego nie robi natywna obsługa TypeScript w Node.js.Patrząc wyłącznie z perspektywy zupełnie nowego interfejsu wiersza poleceń, Node.js oferuje ten sam kod co Bun, ale z wolniejszym uruchamianiem i bardziej skomplikowaną historią dystrybucji. Jest to jednak ocena uproszczona. Jeśli wasz zespół już standardowo używa Node.js, polega na jego gwarancjach kompatybilności z npm lub utrzymuje na nim istniejący interfejs wiersza poleceń, te czynniki mogą z łatwością przeważyć nad różnicą w czasie uruchamiania wynoszącą 60 ms.
Ustawienie pomiarów i podane liczby
Czas wykonywania zadań został zmierzony na komputerze M3 MacBook Pro z 18 GB pamięci RAM i systemem macOS 26, przy użyciu narzędzia hyperfine z poleceniem hyperfine --warmup 3 --min-runs 50 './wx reykjavik'. Aby wyeliminować zakłócenia sieciowe, obie żądania API skierowane były do lokalnego serwera HTTP symulującego, który zwracał ustalony plik JSON. Całkowity czas wykonywania to czas od uruchomienia procesu do jego zakończenia, wliczając czas startowy. Czasy rozwoju to jedynie przybliżone szacunki, a nie dokładne wartości pomiarowe, dlatego należy porównywać stosunki, a nie same minuty.
Główne dane z testu:
- Rust: około 95 linii kodu, 3,8 MB po optymalizacji (8,4 MB domyślnie), 28 sekund na czystą kompilację, czas wykonywania 4,8 ms.
- Go: 68 linii kodu, 6,2 MB, kompilacja trwa mniej niż sekundę, czas wykonywania 5,2 ms, praca zajmuje około 10 minut.
Wartości rozmiaru zależą od flag konfiguracyjnych. W przypadku Rust wymagano ustawień lto = true oraz strip = true w sekcji [profile.release]. Go zostało zbudowane przy użyciu polecenia go build -ldflags='-s -w', aby usunąć informacje diagnostyczne. Rozmiar 1,2 MB w przypadku Zig to wersja ReleaseSmall; pozostaje niewielki nawet przy użyciu TLS, ponieważ klient HTTP i kod kryptograficzny znajdują się w bibliotece standardowej i są łączone statycznie przy usunięciu kodu niepotrzebnego, zamiast używać czegoś takiego jak OpenSSL. Wersja ReleaseSafe, która zachowuje kontrole bezpieczeństwa w czasie wykonywania, ma rozmiar około 2,1 MB.
Czego nie pokazują liczby z testów wydajności
Zig wygrywa teoretycznie, ale przegrywa w praktyce
Zgodnie z metrykami Zig wygenerował najmniejszy i jeden z najszybszych plików binarnych. Jednak godzina spędzona na obsłudze 108 linijek kodu opowiada inną historię:
- Problem z plikiem certyfikatów pochłonął ponad 40 minut z powodu błędu na jednej linijce kodu.
ReleaseSmall, która generuje 1,2 MB, nie zawiera śladów stosu; ReleaseSafe zachowuje ślady paniki, ale jest wersją o rozmiarze 2,1 MB.defer powodowały wycieki pamięci, które ujawniały się dopiero wtedy, gdy alokator debugowy zgłaszał je przy zamykaniu programu.Dla narzędzia długoterminowego, w którym chce się kontrolować każdą alokację i każdy bajt wyjściowy, taka jasność rozwiązań przynosi korzyści z czasem. Dla czegoś, co ma działać już do lunchu, obecnie nie jest to odpowiednie rozwiązanie. Projekt języka jest elegancki; otaczający go ekosystem jest po prostu młodszy.
Go nigdy nie jest najlepsze w jednej dziedzinie, a mimo to wygrywa ogólnie
Go kompiluje się w mniej niż sekundę, tworzy przyzwoity, samodzielny plik binarny, wymaga jedynie standardowej biblioteki i był gotowy do użycia w dziesięć minut. Jest większy od optymalizowanego Rusta (6,2 MB w porównaniu z 3,8 MB) i nieco wolniejszy (5,2 ms w porównaniu z 4,8 ms), ale człowiek nie jest w stanie dostrzec tej różnicy w narzędziu, które kończy działanie w kilku milisekundach.
Kompilacja międzyplatformowa wymaga tylko jednej zmiennej środowiskowej – na przykład GOOS=linux go build. Rust zbliża się do tego rozwiązania za pomocą cargo build --target x86_64-unknown-linux-gnu lub narzędzia cargo-zigbuild, a Zig ma najkorzystniejsze warunki do kompilacji międzyplatformowej, ponieważ zawiera własnego łącznika oraz bibliotekę libc. Różnica polega na tym, że Go nie wymaga żadnych dodatkowych narzędzi ani konfiguracji. To jest powtarzający się wzorzec: Go rzadko przewyższa jakikolwiek pojedynczy wskaźnik, ale ma najmniejsze ogólne opory przy pracy.
Bun jest doskonały lokalnie, ale trudny do dystrybucji
Bun dostarczył najczystszy kod w najkrótszym czasie. Jeśli chodzi o osobisty skrypt przechowywany w ~/bin jako plik .ts, trudno znaleźć lepsze rozwiązanie. Pod względem dystrybucji 45 MB na zapytanie o pogodę to poważna wada, a ponieważ prawie cała ta ilość stanowi wbudowany silnik, nie ma praktycznego sposobu na jego zmniejszenie bez lżejszej wersji runtime od zespołu Bun, której w momencie testów nie istniało.
Perswazja Rust wobec małych narzędzi osłabła
Kilka lat temu zalety CLI w Rust opierały się na szybkości, bezpieczeństwie oraz małych plikach binarnych. W przypadku narzędzi intensywnie wykorzystujących operacje wejścia/wyjścia sytuacja ta jest teraz mniej jednoznaczna:
- Go dorównuje praktycznej szybkości Rust.
- Zig tworzy mniejsze pliki binarne bez żadnych dostosowań.
- 28 sekund na przygotowanie czystej wersji programu składającego się z 95 linii to duży koszt dla szybkich narzędzi pomocniczych.
Rust wciąż się wyróżnia dzięki narzędziom, które przyciągają tysiące użytkowników i są regularnie utrzymywane przez lata, a system typów nieustannie radzi sobie z rzadkimi przypadkami. ripgrep, fd, bat, delta i hyperfine to wszystko narzędzia CLI napisane w Rust, a każde z nich jest poważnie utrzymywane przez długi czas, a nie stworzone w jeden weekend. W przypadku szybkich projektów taki nakład pracy rzadko się opłaca; natomiast dla szeroko rozpowszechnionego narzędzia może to być opcja najmniej narażona na pojawienie się subtelnych błędów.
Wybór zestawu narzędzi do Twojego następnego CLI
Decyzja zależy mniej od czystej szybkości, a bardziej od tego, kto będzie używał narzędzia i w jaki sposób ono do niego dotrze:
- Zastosuj Go jako standard, gdy celem jest jak najniższy całkowity koszt od pustego katalogu do rozproszonego pliku binarnego: kod gotowy w ciągu kilku minut, kompilacja w mniej niż sekundę, jeden plik o rozmiarze 6,2 MB bez zależności oraz łatwa kompilacja międzyplatformowa.
Każda z tych implementacji jest na tyle mała, że można ją odbudować na podstawie powyższych fragmentów w ciągu popołudnia, wraz z serwerem mock i skryptem hyperfine. Twoje dokładne wyniki będą się różnić w zależności od sprzętu, systemu operacyjnego oraz wersji narzędzi, ale ogólny obraz powinien pozostać stabilny: Zig jest najmniejszy, Bun największy, a Go daje najmniej problemów.
Główne wnioski
- Mierz całkowitą długość ścieżki do wysłanego pliku binarnego, a nie tylko czas wykonywania; przy małych narzędziach dominują czynności konfiguracji, debugowania i dystrybucji.
- Domyślne ustawienia kompilacji mogą podwoić rozmiar pliku binarnego, dlatego należy poznać flagi wydania dla wybranego zestawu narzędzi.
- Pliki binarne oparte na czasie wykonywania z Bun lub Node.js SEA zawierają cały silnik, co ma znacznie większe znaczenie niż czas ich uruchamiania.
- Waliduj dane wejściowe oraz odpowiedzi HTTP nawet w krótkich skryptach; najkrótsza implementacja to często ta, która nie zawiera obsługi błędów.
- Rozpatruj konkretne statusy wersji i ich rozmiary jako chwilowy obraz sytuacji i sprawdzaj je ponownie w odniesieniu do aktualnych wydań przed podjęciem decyzji.
Literatura pokrewna
- Isolaty Edge i Wasm vs. Node.js: Wybory między runtajmami a praktyki produkcyjne — Omawia, w jaki sposób izolaty V8 oraz WebAssembly przewyższają Node.js oparty na kontenerach w środowisku Edge, a następnie przedstawia praktyki operacyjne sprawiające, że Node.js nadaje się do użycia w produkcji.
- Node.js, Deno i Bun w porównaniu: testy wydajności, wybory między nimi i strategia migracji — Wyjaśnia rzeczywiste różnice architektoniczne między Node.js, Deno i Bun, co ujawniają testy z 2025 roku oraz jak zdecydować, czy i kiedy przeprowadzić migrację.