Источник: Nuances of Programming
В этой статье я описал свой увлекательный опыт создания небольшого CLI-приложения на двух малознакомых мне языках — Go и Rust.
Если вы предпочитаете сразу перейти к самому коду и самостоятельно сравнить эти два варианта, то можете найти их по следующим ссылкам: Go и Rust.
О проекте
У меня есть собственный проект под названием Hashtrack, который является фул-стек веб приложением, написанным для технического собеседования. Проект этот достаточно невелик и прост в использовании:
- Вы выполняете аутентификацию с учётом ранее созданного аккаунта.
- Вводите хэштеги, которые хотите отследить.
- Ожидаете появления на экране перехваченных твитов.
Можете ознакомиться с ним здесь.
После собеседования я продолжил работу по улучшению этого проекта просто ради интереса и заметил, что с его помощью я могу проверить свои навыки, реализовав инструмент CLI. У меня уже был сервер, поэтому нужно было лишь выбрать язык для воплощения небольшого набора функций под API моего проекта.
Функционал
- hashtrack login — создаёт токен сессии и хранит его в локальной файловой системе в файле конфигурации.
- hashtrack logout — удаляет хранящийся локально токен.
- hashtrack track <hashtag> [...] — отслеживает один или более хэштегов.
- hashtrack untrack <hashtag> [...] — прекращает отслеживание одного или нскольких хэштегов.
- hashtrack tracks — отображает отслеживаемые хэштеги.
- hashtrack list — отображает 50 последних перехваченных твитов.
- hashtrack watch — транслирует и отображает перехваченные твиты в реальном времени.
- hashtrack status — отображает ваш статус, если вы авторизованы.
- Должен иметь параметр --endpoint, указывающий CLI на другой сервер.
- Должен иметь параметр --config для загрузки пользовательского файла конфигурации.
- Файл конфигурации может также использовать свойство property.
Что нужно знать, прежде чем начать:
- CLI должен использовать API проекта — GraphQL под HTTP+Websocket.
- CLI должен использовать файловую систему для хранения файла конфигурации.
- CLI должен считывать позиционные аргументы и флаги.
Почему именно Go и Rust?
Есть много разных языков, которые можно использовать для написания CLI-инструмента. В данном же случае меня интересовал язык, с которым я был малознаком или вовсе не знаком. Кроме того, мне хотелось, чтобы он легко компилировался в нативный исполняемый файл, что очень удобно для CLI-инструмента.
По некоторым причинам первым очевидным выбором стал Go. Но я также был мало знаком с Rust и решил, что и он хорошо подойдёт для данного проекта. Вот тут у меня и возникла мысль: “А почему бы не испробовать оба?” Поскольку моя главная задача состояла в обучении, реализация проекта дважды стала бы отличной возможностью сравнить плюсы и минусы этих языков.
Локальная среда
Первое, на что я обращаю внимание, когда берусь за новый набор инструментов, — это то, можно ли с лёгкостью сделать его доступным для пользователей, не прибегая к диспетчеру распространения пакетов для общесистемной установки. Здесь я имею в виду менеджеров версий, которые облегчают нашу жизнь посредством установки инструментов в пользовательском масштабе, а не на всю систему. С этой задачей отлично справляется NVM для Node.js.
В Go за обработку локальной установки и управление версиями отвечает легко настраиваемый проект GVM:
gvm install go1.14 -B
gvm use go1.14
Кроме этого, существуют две переменные окружения, о которых нам нужно знать — GOROOT и GOPATH. Подробнее о них вы можете почитать здесь.
Первая проблема при написании на Go возникла, когда я выяснял, как разрешение модулей работает совместно с GOPATH. Было сложно настроить структуру проекта с помощью функциональной локальной среды разработки, и в итоге я просто использовал GOPATH=$(pwd) в директории проекта. Главным достоинством Go была возможность настройки зависимостей для каждого проекта, вроде node_modules. Работала она отлично.
Уже по завершении проекта я узнал о существовании virtualgo, который мог бы решить мою проблему с GOPATH.
В Rust есть официальный проект под названием rustup, отвечающий за управление его установкой и иначе известный как toolchain. Этот проект можно легко настроить с помощью однострочного скрипта. Помимо этого, присутствует набор выборочных компонентов, использующих rustup, например rls и rustfmt. Для многих проектов требуется ночная версия toolchain, но с rustup проблем с переключением версий не возникает.
Редактор
Инструменты редактора были безупречны у обоих языков. Будучи пользователем VSCode, я смог найти на рынке расширения, как для Rust, так и для Go, но проводя отладку в Rust, мне пришлось установить расширение CodeLLDB.
Управление пакетами
В Go нет пакетного менеджера и даже официального реестра. Пакеты просто импортируются из внешних URL.
Для управления зависимостями Rust использует Cargo, который загружает и компилирует их из crates.io — официального реестра пакетов Rust. Пакеты внутри экосистемы Crates могут также имеют собственную документацию, доступную на docs.rs.
Библиотеки
Моей первой задачей было разобраться, насколько легко можно выполнить простой запрос или мутацию GraphQL через HTTP.
Для Go я нашёл ряд библиотек, вроде machinebox/graphql и shurcooL/graphql. Вторая использует структуры для (де)маршалинга данных, в связи с чем я предпочёл именно её.
Фактически же я применил ветвление shurcool/graphql, поскольку мне нужно было настроить заголовок Authorization в клиенте. Внесённые изменения можете найти в этом пул-реквесте.
Вот пример вызова мутации GraphQL в Go:
В Rust для совершения вызовов GraphQL мне пришлось использовать две библиотеки, так как graphql_client не зависит от протокола и фокусируется только на генерации кода для сериализации и десериализации данных. Поэтому мне понадобилась вторая библиотека (reqwest), которая бы обрабатывала HTTP-запросы.
Ни у одной из библиотек для Go и Rust не было реализации для GraphQL через протокол websocket.
Вообще, graphql_client для Rust поддерживает Subscriptions (подписки), но, поскольку она безразлична к протоколу, мне пришлось самостоятельно реализовывать всю коммуникацию между GraphQL и WebSocket. Можете ознакомиться с этим здесь.
Чтобы воспользоваться WebSocket в Go, нужно изменить библиотеку для поддержки этого протокола. Поскольку я уже разветвил библиотеку, то вместо этого я использовал “путь бедняка”, подразумевающий “наблюдение” за новыми твитами через запрашивание информации о них каждые 5 секунд. Вообще-то, я этим не горжусь.
В Go есть ключевое слово go, порождающее легковесный поток, также называемый горутиной. В противоположность этому Rust использует потоки операционной системы, вызывая Thread::spawn. Кроме того, обе реализации для передачи объектов между потоками используют каналы.
Обработка ошибок
В Go ошибки рассматриваются так же, как и любое другое значение. Стандартный способ их обработки подразумевает простую проверку их наличия.
В Rust есть перечисление Result<T,E>, которое может инкапсулировать Ok(T) для успешного результата или Err(E) для ошибок. В нём также есть перечисление Option<T>, Some(T) или None. Если вы знакомы с Haskell, то можете узнать в них монады Either и Maybe.
Существует также и синтаксический сахар для передачи ошибок (оператор ?), который разрешает значение из структуры Result или Option, автоматически возвращая Err(...) либо None, если что-то пойдёт не так.
Код выше эквивалентен следующему:
В Rust есть:
- Монадические конструкции (Option и Result).
- Оператор передачи ошибок.
- Способность From, применяемая для автоматического преобразования ошибок при передаче.
Комбинация трёх этих возможностей представляет наилучшее решение по обработке ошибок, какое я встречал в языках. Оно одновременно демонстрирует простоту, корректность и удобство в обслуживании.
Компиляция
При разработке Go в качестве критического требования закладывалась быстрая компиляция. Давайте посмотрим:
Впечатляет. А теперь взглянем не результаты Rust:
Он скомпилировал все зависимости, представленные 214 модулями. При повторном запуске всё уже скомпилировано, поэтому выполняется моментально:
Здесь мы видим, что Rust использует инкрементную модель компиляции, которая частично перекомпилирует дерево зависимостей модулей, начиная с изменённых модулей и заканчивая их распространением на свои зависимости.
При создании релизной сборки времени требуется больше, что объективно оправдывается задачами по оптимизации, выполняемыми компилятором внутренне.
Непрерывная интеграция
Как можно догадаться, отличие по времени проявляется в рабочем цикле CI:
Использование памяти
Чтобы измерить использование памяти я вызвал /usr/bin/time -v ./hashtrack list для каждой из версий. time -v отображает много интересной информации, но нам нужно узнать максимальный резидентный размер набора процесса, являющийся пиковым количеством выделенной физической памяти в процессе выполнения.
for n in {1..5}; do
/usr/bin/time -v ./hashtrack list > /dev/null 2>> time.log
done
grep 'Maximum resident set size' time.log
Go
Maximum resident set size (kbytes): 9840
Maximum resident set size (kbytes): 10068
Maximum resident set size (kbytes): 9972
Maximum resident set size (kbytes): 10032
Maximum resident set size (kbytes): 10072
Rust
Maximum resident set size (kbytes): 9840
Maximum resident set size (kbytes): 10068
Maximum resident set size (kbytes): 9972
Maximum resident set size (kbytes): 10032
Maximum resident set size (kbytes): 10072
Это использование памяти соответствует следующим задачам:
- интерпретация системных аргументов;
- загрузка и считывание файла конфигурации из файловой системы;
- вызов GrapQL через HTTP через TLS;
- парсинг JSON-ответа;
- запись отформатированных данных в stdout.
У обоих языков есть разные способы управления памятью и её распределением.
В Go есть сборщик мусора, являющийся стандартным способом отслеживания неиспользованной кучи памяти и её восстановлением, избавляющим от ручного выполнения этой задачи. Поскольку сборщики мусора представляют собой смесь эвристик, в их использовании всегда присутствуют компромиссы. Как правило, выбор встаёт между производительностью и использованием памяти.
Модель памяти в Rust имеет такие концепции, как ownership (владение), borrowing (одалживание) и lifetimes (жизненные циклы), что не только помогает в плане безопасности памяти, но также гарантирует полный контроль над кучей памяти программы без ручного управления или сборки мусора.
Для сравнения возьмём некоторые другие исполняемые файлы, которые выполняют аналогичные задачи:
Заключение
Оба этих языка отлично справились с поставленной задачей. Но, конечно же, у них разные приоритеты. С одной стороны, у нас есть вариант, который старается сохранять простоту разработки, делать её легко обслуживаемой и доступной. С другой же стороны, мы имеем язык, сфокусированный на точности, безопасности и производительности.
Я бы выбрал Go, если:
- Нужен простой язык, который без проблем освоят мои коллеги.
- Нужна небольшая гибкость для написания простого кода.
- Создаю исключительно или преимущественно для Linux.
- Время компиляции является важным критерием.
- Нужна зрелая асинхронная семантика.
Я бы выбрал Rust, если:
- Нужна современная обработка ошибок для кода.
- Нужен мульти-парадигмальный язык, позволяющий писать более экспрессивный код.
- Для проекта критически важна безопасность.
- Для проекта критически важна производительность.
- Проект нацелен на множество операционных систем, и мне нужна мультиплатформенная база кода.
В обоих языках есть некоторые детали, которые меня всё ещё беспокоят:
- Go настолько фокусируется на простоте, что иногда возникает противоположный эффект (например, GOROOT и GOPATH).
- Я до сих пор недостаточно понимаю, как в Rust работают жизненные циклы, и вообще работа с ними может вызывать сильное негодование.
Лично я считаю, что оба языка очень интересны для изучения и могут стать отличным дополнением в мире C или C++. Они предоставляют более обширный диапазон приложений, вроде веб-сервисов и даже фронтенд фреймворков. Всё благодаря WebAssembly:)
Читайте также:
Перевод статьи Paulo Cuchi: Go vs Rust: Writing a CLI tool