Golang

В этой статье я описал свой увлекательный опыт создания небольшого CLI-приложения на двух малознакомых мне языках — Go и Rust.

Если вы предпочитаете сразу перейти к самому коду и самостоятельно сравнить эти два варианта, то можете найти их по следующим ссылкам: Go и Rust.

О проекте

У меня есть собственный проект под названием Hashtrack, который является фул-стек веб приложением, написанным для технического собеседования. Проект этот достаточно невелик и прост в использовании:

  1. Вы выполняете аутентификацию с учётом ранее созданного аккаунта.
  2. Вводите хэштеги, которые хотите отследить.
  3. Ожидаете появления на экране перехваченных твитов. 

Можете ознакомиться с ним здесь.

После собеседования я продолжил работу по улучшению этого проекта просто ради интереса и заметил, что с его помощью я могу проверить свои навыки, реализовав инструмент 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:

type creationMutation struct {
    CreateSession struct {
        Token graphql.String
    } `graphql:"createSession(email: $email, password: $password)"`
}

type CreationPayload struct {
    Email    string
    Password string
}

func Create(client *graphql.Client, payload CreationPayload) (string, error) {
    var mutation creationMutation
    variables := map[string]interface{}{
        "email":    graphql.String(payload.Email),
        "password": graphql.String(payload.Password),
    }
    err := client.Mutate(context.Background(), &mutation, variables)

    return string(mutation.CreateSession.Token), err
}

В Rust для совершения вызовов GraphQL мне пришлось использовать две библиотеки, так как graphql_client не зависит от протокола и фокусируется только на генерации кода для сериализации и десериализации данных. Поэтому мне понадобилась вторая библиотека (reqwest), которая бы обрабатывала HTTP-запросы. 

#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "graphql/schema.graphql",
    query_path = "graphql/createSession.graphql"
)]
struct CreateSession;

pub struct Session {
    pub token: String,
}

pub type Creation = create_session::Variables;

pub async fn create(context: &Context, creation: Creation) -> Result<Session, api::Error> {
    let res = api::build_base_request(context)
        .json(&CreateSession::build_query(creation))
        .send()
        .await?
        .json::<Response<create_session::ResponseData>>()
        .await?;
    match res.data {
        Some(data) => Ok(Session {
            token: data.create_session.token,
        }),
        _ => Err(api::Error(api::get_error_message(res).to_string())),
    }
}

Ни у одной из библиотек для Go и Rust не было реализации для GraphQL через протокол websocket.

Вообще, graphql_client для Rust поддерживает Subscriptions (подписки), но, поскольку она безразлична к протоколу, мне пришлось самостоятельно реализовывать всю коммуникацию между GraphQL и WebSocket. Можете ознакомиться с этим здесь.

Чтобы воспользоваться WebSocket в Go, нужно изменить библиотеку для поддержки этого протокола. Поскольку я уже разветвил библиотеку, то вместо этого я использовал “путь бедняка”, подразумевающий “наблюдение” за новыми твитами через запрашивание информации о них каждые 5 секунд. Вообще-то, я этим не горжусь.

В Go есть ключевое слово go, порождающее легковесный поток, также называемый горутиной. В противоположность этому Rust использует потоки операционной системы, вызывая Thread::spawn. Кроме того, обе реализации для передачи объектов между потоками используют каналы.

Обработка ошибок

В Go ошибки рассматриваются так же, как и любое другое значение. Стандартный способ их обработки подразумевает простую проверку их наличия. 

func (config *Config) Save() error {
	contents, err := json.MarshalIndent(config, "", "    ")
	if err != nil {
		return err
	}

	err = ioutil.WriteFile(config.path, contents, 0o644)
	if err != nil {
		return err
	}

	return nil
}

В Rust есть перечисление Result<T,E>, которое может инкапсулировать Ok(T) для успешного результата или Err(E) для ошибок. В нём также есть перечисление Option<T>, Some(T) или None. Если вы знакомы с Haskell, то можете узнать в них монады Either и Maybe

Существует также и синтаксический сахар для передачи ошибок (оператор ?), который разрешает значение из структуры Result или Option, автоматически возвращая Err(...) либо None, если что-то пойдёт не так. 

pub fn save(&mut self) -> io::Result<()> {
    let json = serde_json::to_string(&self.contents)?;
    let mut file = File::create(&self.path)?;
    file.write_all(json.as_bytes())
}

Код выше эквивалентен следующему:

pub fn save(&mut self) -> io::Result<()> {
    let json = match serde_json::to_string(&self.contents) {
        Ok(json) => json,
        Err(e) => return Err(e)
    };
    let mut file = match File::create(&self.path) {
        Ok(file) => file,
        Err(e) => return Err(e)
    };
    file.write_all(json.as_bytes())
}

В Rust есть:

  • Монадические конструкции (Option и Result).
  • Оператор передачи ошибок.
  • Способность From, применяемая для автоматического преобразования ошибок при передаче.

Комбинация трёх этих возможностей представляет наилучшее решение по обработке ошибок, какое я встречал в языках. Оно одновременно демонстрирует простоту, корректность и удобство в обслуживании.

Компиляция

При разработке Go в качестве критического требования закладывалась быстрая компиляция. Давайте посмотрим:

> time go get hashtrack # Установка зависимостей
go get hashtrack  1,39s user 0,41s system 43% cpu 4,122 total

> time go build -o hashtrack hashtrack # Первый раз
go build -o hashtrack hashtrack  0,80s user 0,12s system 152% cpu 0,603 total

> time go build -o hashtrack hashtrack # Второй раз
go build -o hashtrack hashtrack  0,19s user 0,07s system 400% cpu 0,065 total

> time go build -o hashtrack hashtrack # Внесено изменение
go build -o hashtrack hashtrack  0,94s user 0,13s system 169% cpu 0,629 total

Впечатляет. А теперь взглянем не результаты Rust:

> time cargo build
   Compiling libc v0.2.67
   Compiling cfg-if v0.1.10
   Compiling autocfg v1.0.0
   ...
   ...
   ...
   Compiling hashtrack v0.1.0 (/home/paulo/code/cuchi/hashtrack/cli-rust)
    Finished dev [unoptimized + debuginfo] target(s) in 1m 44s
cargo build  363,80s user 17,05s system 365% cpu 1:44,09 total

Он скомпилировал все зависимости, представленные 214 модулями. При повторном запуске всё уже скомпилировано, поэтому выполняется моментально:

> time cargo build # Второй раз
    Finished dev [unoptimized + debuginfo] target(s) in 0.08s
cargo build  0,07s user 0,03s system 104% cpu 0,094 total

> time cargo build # Внесены изменения
   Compiling hashtrack v0.1.0 (/home/paulo/code/cuchi/hashtrack/cli-rust)
    Finished dev [unoptimized + debuginfo] target(s) in 3.15s
cargo build  3,01s user 0,52s system 111% cpu 3,162 total

Здесь мы видим, что 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:)

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Paulo Cuchi: Go vs Rust: Writing a CLI tool