Rust чи Go для бекенд-сервісів: платіть лише за ті гарантії, які вам потрібні
Чому безпека та швидкість Rust рідко вирішують справжні проблеми команд, які розробляють API, як Go оптимізується для підтримки, та коли Rust є правильним стандартом для бекенду.
Rust є однією з найвражаючіших мов останнього десятиліття, і саме тому вона заслуговує уважного розгляду перед тим, як стати стандартом для команд, що працюють з бекендом. Більшість сервісів бекенду не обмежуються тими перевагами, які підкреслює Rust; їх обмежує швидкість, з якою змінна група людей може їх зрозуміти та безпечно модифікувати. У цій статті порівнюються Rust та Go з цієї точки зору, показано, де насправді проявляються недоліки кожної мови, та надано чіткі критерії для вирішення, коли сила Rust варта того, щоб за неї платити.
Сценарій, який варто врахувати
Але більшість команд, які працюють з серверною частиною, не займаються двигунами зберігання, ядрами операційних систем, браузерами, двигунами ігор, вбудованою прошивкою чи інфраструктурою, схильною до загроз безпеці, де кожне виділення пам’яті та межі її використання є критично важливими. Більшість з них створює API. Їхньою щоденною роботою є передача даних у форматі JSON між системами Postgres, Redis, Kafka, S3, платіжними шлюзами, сервісами сповіщень, внутрішніми системами та API сторонніх постачальників, які іноді непередбачувано виходять з ладу у найневідповідніші моменти.
Ця робота не є привабливою: бізнес-правила, повторні спроби виконання завдань, облік діяльності, панелі керування, розгортання систем, міграції та постійна робота інженерів над підтримкою стабільності продукту. У таких умовах Rust може стати чудовою відповіддю на запитання, про яке команда навіть не замислювалась.
Постановка правильного запитання
Зазвичай це питання формулюється у рамках певних упереджень. Одні вважають, що Go занадто спрощений; інші — що Rust занадто складний. Обидві думки є правдивими, але обидві не враховують суті проблеми.
„Чи є Rust кращим за Go?“ — це запитання занадто розпливчасте для відповіді. Бензопила краща за кухонний ніж у рубці дерев, але ніхто не бере її за обіднім столом. Корисне порівняння — це те, для чого оптимізовані кожна з мов:
- Rust пропонує потужність та точність, намагаючись усунути цілі категорії помилок ще до того, як програма почне працювати.
- Go забезпечує стриманість та швидке розуміння, будучи створеним з припущенням, що звичайні інженери під тиском термінів зможуть підтримувати код у належному стані.
Це друге припущення має більше значення, ніж здається на перший погляд.
Що змінюється, коли до обговорення додається підтримка коду
Багато розробників на мові Go не відчувають неприязні до Rust. Деякі захоплюються нею, деякі пишуть код на ній, деякі хочуть її вивчити, а багато хто без вагань погоджується, що це кращий вибір для серйозної роботи на низькому рівні. Тон змінюється, коли тема переходить від проектування мови до довгострокового технічного обслуговування серверної частини.
У цьому випадку ніхто не заперечує, що Rust є потужною мовою. Питання полягає у тому, чи повинна звичайна команда, яка працює з серверною частиною, брати на себе витрати, пов’язані з використанням Rust, щоб вирішувати проблеми, яких у її сервісах здебільшого немає.
Саме тут Go стає сильним конкурентом. Не тому, що він більш розвинений, хоча це не так. Не тому, що його система типів багатша, хоча це також не так. І точно не тому, що він змушує розробників почуватися розумними; навпаки, зазвичай це не так. Go перемагає тому, що відображає неприємну правду про програмні організації: більшості команд не потрібен більш виразний код. Їм потрібен код, який могли б змінювати більше людей, не боячись наслідків.
Вузьке місце рідко є процесором
Інженери бекенду люблять вірити, що їхня система за одну оптимізацію може досягти досконалості. Це приємна історія, але зазвичай вона хибна.
Більшість повільних бекендів не є повільними через мовний середовище виконання. Вони повільні через неефективні запити, використання застарілих даних з кешу, ненадійну мережу, накопичення завдань у черзі, нестабільні залежності чи те, що бізнес-процес поєднує п’ять операцій, які мали б бути незалежними. Швидша мова не вирішує жодної з цих проблем.
Складною частиною розробки бекенду зазвичай є не те, щоб змусити машину виконувати інструкції. Це допомога групі людей глибоко зрозуміти систему, щоб вони могли її модифікувати, не пошкодивши. Наведена нижче діаграма ілюструє це, перелічуючи місця, де зазвичай виникає справжній опір:
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.
Це пояснює, чому Rust може перемагати за технічними якостями, проте все одно залишатися поганим вибором для багатьох команд, які працюють з бекендом. Rust робить акцент на коректності коду, тоді як Go часто оптимізується для функціонування в умовах обмежень команди. Саме цей контраст — суть усіх дискусій у одному реченні.
Простота Go — це свідома обмеження
Типовий обробник HTTP у Go не вирізняється елегантністю. Він декодує тіло запиту, повертає код 400 у разі невдачі декодування, викликає сервіс, повертає код 500 у разі його невдачі, а в іншому випадку записує 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)
}
Ніхто не назве це майбутнім програмування, проте майже кожен розробник бекенду може його одразу прочитати. Молодий інженер може простежувати код рядок за рядком. Досвідчений рецензент може схвалити його за хвилину. Новий співробітник може зрозуміти логіку виконання без додаткових пояснень. Людина, яка вирішує проблеми під час дебаггінгу, може точно бачити, де надходить запит, де він може зазнати помилки та де залишається.
Ця читабельність — це не дрібна перевага; у багатьох організаціях саме вона є найважливішою. Цей уривок також демонструє, наскільки очевидними є проблеми мови Go: безпосередня передача err.Error() клієнту може вивести назовні внутрішні деталі, такі як повідомлення бази даних, а у справжньому сервісі слід було б задокументувати помилку та надіслати загальне повідомлення. Цю проблему легко виявити саме тому, що нічого не приховано.
Цінність обмеження кмітливих рішень
Досвід роботи з довгоживучими системами часто призводить до зменшення захоплення їхньою винахідливістю. Повагу викликає незрозумілий код із очевидними місцями помилок — код, який не вимагає пояснень від його первинного автора.
Go є недостатньо привабливим у позитивному сенсі: він обмежує кількість складних рішень, які може прийняти команда. Це звучить як критика, поки ви не будете підтримувати кодову базу, наповнену складними рішеннями. Кожна абстракція здавалась доцільною на момент її впровадження. Кожен універсальний помічник мав вагому причину для існування. Кожен вибір фреймворку мав переконливі аргументи. А потім люди йшли далі, вимоги змінювалися, і кодова база перетворилася на сукупність минулих рішень, які ніхто повністю не розуміє.
Навмисна простота Go протидіє цьому тенденції. Однак це не завжди вдається. Поганий код на Go є поширеним явищем: дублювання механізмів обробки помилок, слабкі моделі домену, глобальний стан, конкурентні ситуації з даними, використання інтерфейсів там, де вони не потрібні, а також безконтрольне використання context.Context у кожній нитці без належного розуміння механізмів скасування. Різниця полягає у тому, що безлад у Go зазвичай є очевидним, тоді як безлад у Rust може бути набагато складнішим. Це не є недоліком Rust – це наслідок того, що мова надає здібним інженерам більше можливостей для прояву їхніх навичок.
Коли проста робота набуває складної форми
Саме тут Rust може стати проблемою для команд, які працюють з бекендом. Саме завдання може бути тривіальним, але типи, які його оточують, – ні. Наведена нижче функція обробляє пакет елементів послідовно, очікуючи асинхронного обробника для кожного з них та зупиняючись при першій помилці:
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)
}
Логіка полягає у простому циклі. Однак сигнатура має чітко вказувати, що обробник може бути викликаний, клонований та безпечно надсилатися та ділитися між потоками; що об’єкт, який він повертає, є типу Send та має атрибут 'static; і що типи елементів та помилок відповідають однаковим обмеженням. Це не є поганим чи штучним підходом у Rust. Коли разом поєднуються асинхронний код, універсальні обробники, спільні обмеження, створені завдання, типи помилок та періоди життя, Rust вимагає від вас чітко вказати те, що інші мови бекенду залишають неявним.
Ця чіткість має справжню цінність, і іноді саме її й потребує система. Однак вона не є безкоштовною. Витрати проявляються під час інтеграції, перевірки коду та щоразу, коли функція, яка з точки зору продукту є простою, виявляється складною з точки зору типової системи. Вони також з’являються, коли інженер витрачає більше зусиль на переконання компілятора прийняти певне рішення, ніж на роздуми щодо того, чи взагалі це рішення має існувати.
Краще, але краще у чому?
Прихильники Rust стверджують, що саме ці труднощі призводять до кращих систем, і іноді вони мають рацію. Команда, яка працює з бекендом, повинна поставити більш точне запитання: краще у якому аспекті?
- Безпека пам’яті: можливо, хоча Go також є безпечним з точки зору пам’яті, за винятком проблем з конкурентним доступом та явного використання
unsafe. - Продуктивність: часто.
- Запобігання певним помилкам конкурентності: так, у багатьох ситуаціях.
Останній аспект — це той, за яким оцінюються більшість бекенд-команд, і саме тут перевага Rust є найменш очевидною.
Справжньою витратою є технічне обслуговування, а не створення коду
Чудовий інженер, який володіє Rust, може створювати чудові системи на цьому мовленні. У цьому немає сумнівів. Проблема полягає у тому, що організації не можуть заморозити свою команду на рівні, який був у момент працевлаштування цього інженера. Люди приходять та йдуть, терміни змінюються, продукти еволюціонують, а той, хто спроєктував початкову архітектуру, може отримати підвищення, вигоріти чи перейти до іншої групи. Код залишається.
У такій ситуації вибір мови стає менш пов’язаним із елегантністю та більш — із соціальною стабільністю. Кілька запитань допомагають це зрозуміти:
- Чи може наступний інженер зрозуміти цей код?
- Чи може команда поступово його переробити?
- Чи може втомлений інженер, який працює у режимі чергування, безпечно це змінити, не пам’ятаючи всю структуру типів у своїй голові?
- Як швидко новий співробітник стає продуктивним?
- Чи працює система в середньому кількості днів, а не лише в ідеальних умовах?
Go зазвичай дає більш позитивні відповіді на ці запитання. Не тому, що розробники Go більш кваліфіковані, і не тому, що код на Go за своєю природою є чистим, а тому, що мова зберігає мінімум складнощів та залишає менше можливостей для їхнього приховування. Саме тому деякі інженери вважають її розчаровуючою. Go не лестить своїм користувачам. Rust може створити враження, що ви будуєте щось значуще, тоді як Go може створити враження, що ви займаєтесь простими завданнями.
Інженерія бекенду — це переважно прості завдання. Вода має течти, труби мають бути легкодоступними, і наступна людина має мати змогу замінити клапан, не вивчаючи спочатку всю історію будівлі.
Де Rust безумовно є правильним інструментом
Це зовсім не означає, що Rust підходить у всіх ситуаціях. Коли продуктивність, безпека пам’яті та низькорівневий контроль є ключовими аспектами того, що ви створюєте, Rust заслуговує на серйозне розглядання. Якщо збої є неприйнятними, якщо помилки, пов’язані з безпекою пам’яті, становлять ризик для безпеки, або якщо затримка є критерієм якості продукту, а не просто показником, Rust може бути найрозумнішим вибором.
Проксі, бази даних, середовища виконання мов, інструменти безпеки, вбудовані системи, мережі високої продуктивності, інструменти для розробників та деякі інфраструктурні сервіси належать до цієї категорії. У таких випадках вибір Rust є зрілим інженерним рішенням.
Однак стандартний API серверної частини не стає більш досконалим лише через те, що він написаний на Rust. Іноді це навіть призводить до зростання витрат. Команди обирають Rust з обґрунтованих причин, але також тому, що Go здається занадто простим, Java — занадто корпоративним, Python — занадто вільним, а Rust здається тим вибором, який мають зробити серйозні інженери. Це не інженерне судження — це естетична невпевненість, прикидана під рішення з програмування систем.
Швидкий спосіб прийняття рішення
Rust є оптимальним варіантом, коли справедливі більшість з наступних умов:
- сервіс є частиною інфраструктури, де контроль продуктивності чи пам’яті є складовою продукту,
- у команди вже є кілька досвідчених інженерів, які працюють з Rust, і можливо найняти ще більше,
- збій чи помилка, пов’язана з безпекою пам’яті, може спричинити серйозні проблеми з безпекою чи фінансові втрати.
Go або інша мова, яка надає перевагу читабельності, є безпечнішим варіантом, коли справедливі більшість з наступних умов:
- сервіс переважно переміщує дані між базами даних, чергами та API,
- у команді є різноманітний досвід працівників та постійна зміна складу,
- основними ризиками є нечітке визначення відповідальностей, змінювані вимоги та слабка можливість моніторингу, а не просто низька продуктивність.
Підсумок
Блискучість Rust ніколи не ставилася під сумнів. Важливо те, чи саме цього бракує вашій команді, яка працює з бекендом.
Якщо команда складається з кваліфікованих інженерів, які використовують Rust для роботи з інфраструктурою, де гарантії Rust безпосередньо відповідають існуючим ризикам, обирайте Rust без вагань; вибір більш ефективного інструменту є правильним, коли це необхідно. Якщо ж команда створює звичайні сервіси, які передають дані між базами даних, чергами, API, панелями керування та внутрішніми інструментами, вибір Rust може свідчити скоріше про високі вимоги до якості, ніж про рівень зрілості.
Go не є кращим через те, що він потужніший. Для багатьох команд, які працюють з backendом, він кращий тому, що з ним легше працювати: його легше читати, наймати фахівців, перевіряти та розгортати, а також підтримувати у простому вигляді, коли початковий ентузіазм зникає. Простота — це не невдача; саме цього потребують продакшн-системи довгий час після того, як мине хайп.
Провали, які справді призводять до краху команд, що працюють з бекендом, рідко виникають через відсутність механізму перевірки позик. Вони виникають через нечіткі межі сервісів, повторні спроби, які не є ідемпотентними, бази даних, які тихо стають справжнім API, черги, які поглинають усі можливі швидкі рішення, журнали подій, які розповідають лише половину історії, та архітектури, розроблені для ідеалізованої команди, якої ніколи не існувало. Rust запобігає багатьом типам помилок, але він не може запобігти помилці обрання потужності замість ясності, коли команді потрібна була саме ясність. Саме тому Rust є поганим стандартом для більшості завдань у бекенді: не тому, що він слабкий, а тому, що його переваги мають високу ціну, коли основна проблема полягає переважно у людях.
Пов’язана література
- Edge Isolates and Wasm vs. Node.js: Runtime Tradeoffs and Prod Habits — Розглядається, як механізми ізоляції V8 та WebAssembly перевершують Node.js, заснований на контейнерах, у роботі на периферії, а також описуються практики керування, які роблять Node.js готовим до використання у продакшені.
- REST APIs for Beginners: Resources, Methods, Status Codes and Statelessness — Посібник простою мовою про те, що таке REST API, п’ять основних принципів його функціонування, де він використовується у реальних командах, та як створити та протестувати свій перший REST API.