Rust или Go для бэкенд-сервисов: платите только за те гарантии, которые вам нужны
Почему безопасность и скорость Rust редко помогают преодолеть настоящие проблемы команд, работающих с API, как Go оптимизируется для удобства обслуживания, и когда Rust является подходящим стандартным выбором для бэкенда.
Rust — один из самых впечатляющих языков последнего десятилетия, и именно поэтому он заслуживает тщательного рассмотрения прежде чем станет стандартом для команд, работающих с бэкендом. Большинство сервисов бэкенда ограничены не теми качествами, которые максимизирует Rust; их ограничивает скорость, с которой меняющаяся группа людей может понять их и безопасно внести изменения. В этой статье Rust сравнивается с Go с учетом этого аспекта, показываются места, где фактически проявляются недостатки каждого языка, и приводится четкое критерий для определения того, когда преимущества Rust стоят тех затрат, которые они требуют.
Сценарий, который стоит учесть
Представьте команду, разрабатывающую обычную функцию бэкенда на Rust. Получившийся код безопаснее, более строго организован и обеспечивается более надежными гарантиями на этапе компиляции, чем аналогичный код на Go. Однако его разработка занимает значительно больше времени, вызывает длинные обсуждения по поводу типов и сроков жизни данных, превращая рутинные изменения в дискуссии по вопросам проектирования. Аналогичная функция на Go была бы готова быстро, и ее код был бы легок для понимания любым членом команды.
Ни один из этих результатов не означает, что тот или иной язык хуже. Это означает, что стоимость использования инструмента необходимо сопоставлять с проблемой, для решения которой он применяется.
Гениальность — это не то же самое, что подходящая адаптация
Rust изменил подход индустрии к вопросам безопасности, производительности и корректности, не опираясь на коллектор мусора. Это реальный достижение, которое заслуживает уважения.
Но большинство команд, работающих с бэкендом, занимаются не движками хранения, ядрами операционных систем, браузерами, игровыми движками, встраиваемым прошивочным программным обеспечением или инфраструктурой, чувствительной к вопросам безопасности, где каждое выделение памяти и границы памяти являются критически важными аспектами. Большинство из них создают API. Их ежедневная работа заключается в передаче данных в формате JSON между системами Postgres, Redis, Kafka, S3, платежными шлюзами, сервисами уведомлений, внутренними системами и API сторонних поставщиков, которые портятся самым неожиданным образом в самый неподходящий момент.
Эта работа не выглядит привлекательно: бизнес-правила, повторные попытки выполнения операций, логирование, панели управления, развертывание приложений, миграции данных и постоянные усилия инженеров по поддержанию стабильности производства. В таких условиях Rust может стать отличным решением для проблемы, о которой команда даже не задумывалась.
Постановка правильного вопроса
Обычно споры строятся по категориям. Одни считают, что Go слишком упрощен; другие — что 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 не является предпочтительным из-за своей высокой мощности. Для многих команд, работающих с бэкендом, он предпочтительнее, потому что его проще использовать: его легче читать, нанимать специалистов на его разработку, проверять код и развертывать приложения, а также поддерживать без особых проблем после того, как первоначальный энтузиазм утихает. Быть без особых проблем — это не провал; именно этого нуждаются производственные системы долгое время после того, как утихнет ажиотаж вокруг них.
Сбои, которые действительно ставят под угрозу работу команд, занимающихся бэкендом, редко возникают из-за отсутствия проверки заимствований. Они происходят из-за нечетко определенных границ сервисов, повторных попыток, которые не являются идемпотентными, баз данных, которые незаметно становятся настоящим API, очередей, поглощающих все возможные упрощения в проектировании, логов, отражающих лишь часть информации, а также архитектур, разработанных для идеализированной команды, которой на самом деле не существует. Rust предотвращает множество типов ошибок, но он не может помочь избежать ошибки выбора мощных инструментов вместо ясности, когда команде нужна именно последняя. Именно поэтому Rust является плохим стандартным выбором для большинства задач бэкенд-разработки: не потому, что он слаб, а потому, что его преимущества имеют высокую цену, когда основная проблема связана с людьми.
Связанные материалы
- Edge Isolates и Wasm против Node.js: компромиссы в работе среды выполнения и практики использования в производстве — Рассматривается, как механизмы изоляции V8 и технология WebAssembly превосходят Node.js, основанный на контейнерах, в средах работы на краю сети, а затем описываются практики управления, делающие Node.js готовым к использованию в производстве.
- REST API для начинающих: ресурсы, методы, коды состояния и отсутствие состояния — Понятный гид, объясняющий, что такое REST API, какие пять принципов обеспечивают его работу, где он используется в реальных командах, и как создать и протестировать свой первый REST API.