Strona główna / Artykuły / Rust czy Go dla usług backendowych: płacisz tylko za te gwarancje, których potrzebujesz

Rust czy Go dla usług backendowych: płacisz tylko za te gwarancje, których potrzebujesz

Dlaczego bezpieczeństwo i szybkość Rusta rzadko rozwiązują prawdziwe problemy zespołów zajmujących się API, jak Go optymalizuje się pod kątem łatwości utrzymania oraz kiedy Rust jest odpowiednim standardem dla backendu.

2501 słów

Rust jest jednym z najbardziej imponujących języków z ostatniej dekady, i właśnie dlatego zasługuje na uważną analizę, zanim stanie się standardem w zespołach zajmujących się backendem. Większość usług backendowych nie jest ograniczona przez zalety, które maksymalizuje Rust; są one ograniczone szybkością, z jaką zmieniająca się grupa osób potrafi je zrozumieć i bezpiecznie modyfikować. Ten artykuł porównuje Rust i Go z tego punktu widzenia, pokazuje, gdzie faktycznie pojawiają się koszty każdego z języków, oraz dostarcza jasnych wskazówek, kiedy warto zapłacić za moc Rusta.

Sytuacja, którą warto rozpoznać

Załóżmy zespół, który w języku Rust implementuje zwykłą funkcjonalność backendową. Powstały kod jest bezpieczniejszy, bardziej uporządkowany i oparty na silniejszych gwarancjach podczas kompilacji niż odpowiednik w Go. Jego tworzenie zajmuje również znacznie więcej czasu, wywołuje długie dyskusje na temat typów i czasu trwania, a zwykłą zmianę przekształca w dyskusję nad projektem. Ta sama funkcjonalność w Go zostałaby szybko wdrożona i była łatwa do zrozumienia dla każdego członka zespołu.

Ani jedno z tych rozwiązań nie oznacza, że jeden język jest gorszy. Oznacza to, że koszt narzędzia należy porównać z problemem, który ma rozwiązać.

Błyskotliwość to nie to samo co odpowiedniość

Rust zmienił sposób, w jaki branża postrzega bezpieczeństwo, wydajność i poprawność, nie polegając przy tym na kolektorze śmieci. To rzeczywisty osiągnięcie, które zasługuje na uznanie, jakim cieszy się.

Ale większość zespołów backendowych nie pracuje nad silnikami przechowywania, jądrami systemowymi, przeglądaczami, silnikami gier, oprogramowaniem wbudowanym ani infrastrukturą wymagającą wysokich standardów bezpieczeństwa, gdzie każde przydzielanie pamięci i granica jej dostępu stanowią krytyczne zagadnienie. Większość z nich tworzy interfejsy API. Ich codzienną pracą jest przesyłanie danych w formacie JSON pomiędzy systemami Postgres, Redis, Kafka, S3, bramkami płatności, usługami powiadamiania, systemami wewnętrznymi oraz API dostawców trzecich, które w najgorszych momentach zawodzą w nieoczekiwany sposób.

To praca mało efektowna: reguły biznesowe, próby ponownych wysyłek, rejestracja działań, panele kontrolne, wdrażanie aktualizacji, migracje oraz starania inżynierów o utrzymanie stabilności systemu w produkcji. W takim środowisku Rust może być doskonałą odpowiedzią na pytanie, którego zespół nawet nie zadawał.

Zadawanie właściwego pytania

Zwykłe podejście jest uprzedzone. Jedna grupa twierdzi, że Go jest zbyt uproszczone; druga, że Rust jest zbyt skomplikowany. Obie te opinie są trafne, ale obie omijają sedno sprawy.

"Czy Rust jest lepszy od Go?" to zbyt niejasne pytanie, by na nie odpowiedzieć. Piła łańcuchowa radzi sobie lepiej niż nóż kuchenny przy ścinaniu drzew, ale nikt nie zabiera jej na stół. Korzystne porównanie dotyczy tego, co każdy język optymalizuje pod kątem:

  • Rust oferuje moc i precyzję, starając się wyeliminować całe kategorie błędów jeszcze przed uruchomieniem programu.
  • Go zapewnia umiarkowanie i szybkie zrozumienie, przy czym jest projektowany z założeniem, że zwykli inżynierowie pod presją terminów będą utrzymywać kod w dobrym stanie.

To drugie założenie ma większe znaczenie, niż się na pierwszy rzut oka wydaje.

Co zmienia się, gdy do dyskusji wchodzi konserwacja?

Wielu programistów używających Go nie ma nic przeciwko Rustowi. Niektórzy go podziwiają, inni nim programują, jeszcze inni chcą się go nauczyć, a wielu zgodnie uważa, że jest to lepszy wybór do poważnej pracy na niskim poziomie. Ton zmienia się, gdy temat przechodzi od projektowania języka do długoterminowej konserwacji backendu.

W tym momencie nikt nie kwestionuje potęgi Rusta. Pytanie brzmi, czy zwykły zespół backendowy powinien brać na siebie koszty związane z Rustem, aby rozwiązywać problemy, których jego usługi w większości nie mają.

Tutaj Go staje się silnym konkurentem. Nie dlatego, że jest bardziej zaawansowane, co nie jest prawdą. Nie dlatego, że jego system typów jest bogatszy, co również nie jest prawdą. I z pewnością nie dlatego, że sprawia, iż programiści czują się mądrzy; raczej robi coś przeciwnego. Go wygrywa, ponieważ odzwierciedla nieprzyjemną prawdę o organizacjach zajmujących się oprogramowaniem: większość zespołów nie potrzebuje bardziej wyraźnego kodu. Potrzebują kodu, który większa liczba osób może edytować bez obaw.

Wąskim gardłem rzadko jest procesor

Inżynierowie backendu lubią wierzyć, że ich system jest o jedną optymalizację od doskonałości. To pocieszająca historia, ale zazwyczaj błędna.

Większość wolnych backendów nie jest wolna z powodu środowiska wykonawczego języka. Są wolne, ponieważ zapytanie jest nieefektywne, cache dostarcza przestarzałe dane, sieć jest niewiarygodna, kolej ma zaległości, zależność jest niestabilna lub proces biznesowy łączy pięć operacji, które powinny być niezależne. Szybszy język nic z tego nie naprawia.

Trudna część inżynierii backendu to zazwyczaj nie sprawianie, by maszyna wykonywała instrukcje. Chodzi o pomoc grupie ludzi w zrozumieniu systemu na tyle dogłębnie, aby mogli go modyfikować bez jego uszkodzenia. Poniższy diagram ilustruje to, wymieniając miejsca, gdzie zazwyczaj występuje prawdziwy opór:

A Normal Backend Team's Real Bottleneck

          CPU
           |
           |   usually not here
           v

    -----------------
    |  application  |
    -----------------
       |     |     |
       v     v     v

  Postgres  Redis  Kafka
       |       |      |
       v       v      v

  unclear ownership
  changing product rules
  missing observability
  slow code reviews
  fear of refactoring
  tired on-call engineers

The machine was rarely the hard part.
The humans were.

To wyjaśnia, dlaczego Rust może przewyższać inne języki pod względem jakości technicznej, a mimo to pozostaje kiepskim wyborem na początek pracy dla wielu zespołów zajmujących się backendem. Rust skupia się na poprawności w miejscach, gdzie kod spotyka się z kodem. Go natomiast często optymalizuje działanie w celu przetrwania na granicach zadań realizowanych przez zespół. Ten kontrast stanowi esencję całej dyskusji w jednym zdaniu.

Prostota Go to celowe ograniczenie

Typowy obsługiwacz HTTP w Go nie charakteryzuje się żadną elegancją. Dekoduje ciało żądania, zwraca błąd 400 w przypadku nieudanej dekodacji, wywołuje usługę, zwraca błąd 500 w przypadku jej nieudanego działania, a w pozostałych sytuacjach zapisuje wynik w formacie JSON:

func CreateOrder(w http.ResponseWriter, r *http.Request) {
    var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, "invalid request", http.StatusBadRequest)
        return
    }
    order, err := service.CreateOrder(r.Context(), req)
    if err != nil {
        writeError(w, err.Error(), http.StatusInternalServerError)
        return
    }
    writeJSON(w, order)
}

Nikt nie nazwałby tego przyszłością programowania, a mimo to prawie każdy deweloper backendu potrafi go od razu odczytać. Junior może prześledzić każdą linię kodu. Doświadczony recenzent może je zatwierdzić w ciągu minuty. Nowy pracownik może zrozumieć strukturę przepływu sterowania bez żadnych wyjaśnień. Osoba naprawiająca błąd może dokładnie zobaczyć, skąd przychodzi żądanie, gdzie może dojść do awarii i dokąd odchodzi.

Ta czytelność to nie błahe udogodnienie; w wielu organizacjach jest to najważniejsza kwestia. Ten fragment pokazuje również, jak wyraźnie widoczne są problemy języka Go: bezpośrednie zwracanie err.Error() do klienta może ujawnić wewnętrzne szczegóły, takie jak komunikaty z bazy danych, a w prawdziwym serwisie należałoby zalogować błąd i wysłać zamiast tego ogólny komunikat. Ten błąd jest łatwy do wykrycia właśnie dlatego, że nic nie jest ukrywane.

Znaczenie ograniczania pomysłowych decyzji

Doswiadczenie w pracy z systemami długowiecznymi ma tendencję do umniejszania podziwu dla pomysłowości. Szacunek buduje nudny kod z oczywistymi punktami awarii, kod, który nie wymaga od swojego pierwotnego twórcy wyjaśnień.

Go jest mało atrakcyjne w pozytywnym sensie: ogranicza liczbę złożonych decyzji, jakie może podjąć zespół. Brzmi to jak krytyka, dopóki nie zarządzasz bazą kodu pełną takich złożonych decyzji. Każda abstrakcja wydawała się sensowna w momencie wprowadzenia, każdy pomocnik generyczny miał uzasadniony powód istnienia, a każdy wybór frameworka opierał się na przekonujących argumentach. Potem ludzie przechodzili dalej, wymagania się zmieniały, a baza kodu stawała się zbiorem dawnej pewności siebie, której nikt w pełni nie rozumie.

Celowa prostota Go stoi w sprzeczności z tą tendencją. Nie zawsze się to udaje. Kiepski kod napisany w Go jest powszechny: duplikowane mechanizmy obsługi błędów, skąpe modele domenowe, stan globalny, konkurencja danych, użycie interfejsów tam, gdzie nie są one potrzebne, oraz context.Context stosowany we wszystkich wątkach bez rzeczywistego zrozumienia mechanizmów anulowania. Różnica polega na tym, że bałagan w Go jest zazwyczaj oczywisty, podczas gdy bałagan w Rust może być znacznie bardziej złożony. To nie jest wada języka Rust – to efekt tego, że język daje zdolnym inżynierom większą swobodę w wyrażaniu ich umiejętności.

Gdy prosta praca przybiera złożoną formę

Tutaj Rust może stanowić problem dla zespołów zajmujących się backendem. Sam zadanie może być błahe, ale typy używane do jego realizacji mogą nie być takie proste. Poniższa funkcja przetwarza partię elementów sekwencyjnie, czekając na asynchronicznego obsługującego każdy z nich i zatrzymując się przy pierwszym błędzie:

use std::future::Future;
trait Processable {
    type Output: Send + 'static;
}
async fn process_batch<F, Fut, T, E>(
    items: Vec<T>,
    handler: F,
) -> Result<Vec<T::Output>, E>
where
    F: Fn(T) -> Fut + Send + Sync + Clone + 'static,
    Fut: Future<Output = Result<T::Output, E>> + Send + 'static,
    T: Processable + Send + 'static,
    E: Send + 'static,
{
    let mut output = Vec::new();
    for item in items {
        output.push(handler(item).await?);
    }
    Ok(output)
}

Logika polega na prostym pętli. Sygnatura musi jednak jasno określić, że obsługa jest wywoływalna, klonowalna oraz bezpieczna do wysyłania i udostępniania między wątkami; że zwracany przez nią obiekt typu Send ma atrybut 'static; oraz że typy elementów i błędów spełniają te same ograniczenia. To nie jest zły ani wymyślony styl programowania w Rust. Gdy połączą się kod asynchroniczny, ogólne obsługi, wspólne granice, zadania uruchamiane w tle, typy błędów oraz okresy trwania, Rust wymaga, abyś wyraźnie określił to, co w innych językach backendowych pozostaje niejasne.

Ta jasność ma rzeczywistą wartość i czasami jest dokładnie tym, czego potrzebuje system. Nie jest jednak bezpłatna. Koszt ten objawia się podczas wdrażania, przy przeglądaniu kodu oraz za każdym razem, gdy funkcjonalność prosta z punktu widzenia produktu okazuje się skomplikowana z perspektywy systemu typów. Pojawia się również wtedy, gdy inżynier poświęca więcej wysiłku na przekonanie kompilatora do przyjęcia rozwiązania niż na zastanowienie się, czy to rozwiązanie w ogóle powinno istnieć.

Lepsze, ale lepsze w czym?

Zwolennicy Rust twierdzą, że ten opór prowadzi do lepszych systemów, i czasami mają rację. Zespół zajmujący się backendem powinien zadać bardziej precyzyjne pytanie: lepsze w jakiej wymiarze?

  • Bезpieczeństwo pamięci: być może, chociaż Go również zapewnia bezpieczeństwo pamięci, z wyjątkiem konfliktów danych i wyraźnego użycia unsafe.
  • Wydajność: często.
  • Zapobieganie określonym błędom współbieżności: tak, w wielu sytuacjach.
  • Dzienne zarządzanie funkcjonalnościami biznesowymi w zespole o mieszanej specjalizacji przez trzy lata: niekoniecznie.
  • Ostatni wymiar to ten, według którego oceniane są zespoły zajmujące się warstwą backend, i to właśnie w tym obszarze przewaga Rust nie jest tak oczywista.

    Prawdziwym kosztem jest konserwacja, a nie autorstwo

    Wybitny inżynier używający Rusta może tworzyć doskonałe systemy napisane w tym języku. Nie ma co do tego wątpliwości. Problem polega na tym, że organizacje nie mogą zamrozić swojego zespołu w momencie, gdy posiada on takiego inżyniera. Ludzie przychodzą i odchodzą, terminy się zmieniają, produkty ewoluują, a osoba, która zaprojektowała początkową architekturę, może zostać awansowana, wyczerpać się lub przenieść do innego zespołu. Kod natomiast pozostaje.

    W takiej sytuacji wybór języka programowania ma mniej wspólnego z elegancją, a więcej z trwałością społeczną. Kilka pytań to dobrze ilustruje:

    • Czy następny inżynier będzie w stanie zrozumieć ten kod?
    • Czy zespół może go stopniowo refaktoryzować?
    • Czy zmęczony inżynier na dyżurze może to bezpiecznie zmienić, nie przechowując całego grafu typów w pamięci?
    • Jak szybko nowy pracownik staje się produktywny?
    • Czy system funkcjonuje przeciętnie przez kilka dni, a nie tylko w idealnych warunkach?

    Go zazwyczaj daje bardziej pozytywne odpowiedzi na te pytania. Nie dlatego, że programiści używający Go są bardziej utalentowani, ani dlatego, że kod napisany w tym języku jest z natury czysty, ale dlatego, że język ten ma niewielką powierzchnię do obsługi i mniej miejsc, gdzie może się ukrywać złożoność. To również powód, dla którego niektórzy inżynierowie uważają go za frustrujący. Go nie schlebia swoim użytkownikom. Rust może sprawić wrażenie, że budujesz coś ważnego, podczas gdy Go może sprawić wrażenie, że zajmujesz się drobnymi naprawami.

    Inżynieria backendu to w większości drobne naprawy. Woda musi płynąć, rury muszą być łatwe do znalezienia, a następna osoba powinna móc wymienić zawór bez konieczności najpierw poznawania całej historii budynku.

    Gdzie Rust jest wyraźnie odpowiednim narzędziem

    To wszystko nie oznacza, że Rust jest konieczny we wszystkich sytuacjach. Gdy wydajność, bezpieczeństwo pamięci i kontrola na niskim poziomie są kluczowe dla tego, co budujesz, Rust zasługuje na poważne rozważenie. Jeśli awarie są nie do przyjęcia, jeśli błędy związane z bezpieczeństwem pamięci stanowią zagrożenie dla bezpieczeństwa, lub jeśli opóźnienia są istotą produktu, a nie tylko metryką, Rust może być najrozsądniejszym wyborem.

    Proksie, bazy danych, środowiska wykonawcze języków, narzędzia bezpieczeństwa, systemy wbudowane, sieci o wysokiej wydajności, narzędzia dla programistów oraz niektóre usługi infrastrukturalne należą do tej kategorii. W takich przypadkach wybór Rust to dojrzała decyzja inżynieryjna.

    Standardowa API backend nie staje się jednak bardziej dojrzała dzięki użyciu Rust. Czasami staje się nawet droższa w utrzymaniu. Zespoły przyjmują Rust z uzasadnionych powodów, ale robią to także dlatego, że Go wydaje się zbyt proste, Java zbyt korporacyjne, Python zbyt swobodny, a Rust wydaje się odpowiednim wyborem dla poważnych inżynierów. To nie jest osąd inżynieryjny – to estetyczna niepewność przybrana na pozór decyzji z zakresu programowania systemowego.

    Szybki sposób na podjęcie decyzji

    Rust jest dobrym wyborem domyślnym, gdy zachodzi większość z poniższych warunków:

    • usługa stanowi infrastrukturę, w której kontrola wydajności lub pamięci jest częścią produktu,
    • zespół posiada już kilku doświadczonych inżynierów pracujących z Rust i może zatrudnić więcej,
    • błąd powodujący awarię lub problem z bezpieczeństwem pamięci wiąże się z poważnymi konsekwencjami bezpieczeństwa lub biznesowymi.

    Go lub inny język sprzyjający czytelności jest bezpieczniejszym wyborem domyślnym, gdy zachodzi większość z poniższych warunków:

    • Usługa przede wszystkim przenosi dane pomiędzy bazami danych, kolejkami i API,
    • Zespół ma zróżnicowane doświadczenie i częste zmiany w składzie,
    • Głównymi ryzykami są niejasna odpowiedzialność, zmieniające się wymagania oraz słaba możliwość obserwacji, a nie sama przepustowość.

    Podsumowanie

    Sława języka Rust nigdy nie była kwestionowana. Ważne jest to, czy właśnie tej sławy brakuje zespołowi odpowiedzialnemu za backend.

    Jeśli zespół składa się z doświadczonych inżynierów Rust pracujących nad infrastrukturą, gdzie gwarancje języka bezpośrednio odpowiadają istniejącym ryzykom, wybierz Rust bez wahania; wybór lepszego narzędzia jest właściwy, gdy zadanie tego wymaga. Jeśli zespół tworzy konwencjonalne usługi, które przekazują dane pomiędzy różnymi systemami, kolejkami, API, panelami kontrolnymi i narzędziami wewnętrznymi, wybór Rust może świadczyć bardziej o ekskluzywnych upodobaniach niż o dojrzałości rozwiązania.

    Go nie jest lepsze tylko dlatego, że jest potężniejsze. Dla wielu zespołów zajmujących się backendem jest preferowane, ponieważ łatwiej z nim pracować: łatwiejsze jest je czytać, zatrudniać specjalistów do jego obsługi, przeglądać kod oraz wdrażać, a także utrzymywać je na poziomie przeciętnym, gdy początkowy entuzjazm ustępuje. Przeciętność to nie porażka – to właśnie potrzebują systemy produkcyjne długo po zniknięciu hossy.

    Błędy, które faktycznie niszczą zespoły backendowe, rzadko wynikają z braku narzędzia do sprawdzania pożyczek. Pochodzą one z niejasnych granic usług, prób ponownych, które nie są idempotentne, baz danych, które po cichu stały się prawdziwym API, kolejek, które przyjmują każdą możliwą oszczędność w projektowaniu, logów, które opisują tylko połowę sytuacji, oraz architektur zaprojektowanych dla idealizowanego zespołu, który nigdy nie istniał. Rust zapobiega wielu rodzajom błędów, ale nie może zapobiec błędnemu wyborowi potęgi zamiast jasności, gdy zespół tego potrzebuje. Dlatego Rust jest słabym wyborem domyślnym dla większości zadań backendowych: nie dlatego, że jest słaby, ale dlatego, że jego zalety są kosztowne, gdy podstawowy problem dotyczy przede wszystkim ludzi.

    Literatura pokrewna