[Перевод] Разработка инструмента командной строки: сравнение Go и Rust

Эта статья посвящена разбору моего эксперимента по написанию небольшого инструмента командной строки с использованием двух языков, в программировании на которых у меня не особенно много опыта. Речь идёт о Go и Rust.

ztuypxbxyajis1ujopiunkbdrss.png

Если вам не терпится увидеть код и самостоятельно сравнить один вариант моей программы с другим — то вот репозиторий Go-варианта проекта, а вот — репозиторий его варианта, написанного на Rust.

Обзор проекта


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

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


Испытать Hashtrack можно здесь.

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

Возможности инструмента командной строки


Вот описание основных возможностей, в частности — команд, которые мне хотелось реализовать в моём инструменте командной строки.

  • hashtrack login — вход в систему, то есть — создание сессионного токена и его сохранение в локальной файловой системе, в конфигурационном файле.
  • hashtrack logout — выход из системы, то есть — удаление сессионного токена, сохранённого локально.
  • hashtrack track [...] — начало наблюдения за хештегом или за несколькими хештегами.
  • hashtrack untrack [...] — окончание наблюдения за хештегом или за несколькими хештегами.
  • hashtrack tracks — вывод списка хештегов, за которыми ведётся наблюдение.
  • hashtrack list — вывод 50 последних найденных твитов.
  • hashtrack watch — вывод найденных твитов в реальном времени.
  • hashtrack status — вывод сведений о пользователе в том случае, если был осуществлён вход в систему.
  • Инструмент должен поддерживать опцию командной строки --endpoint, которая позволяет настраивать его на работу с различными серверами.
  • Должна поддерживаться опция командной строки --config, позволяющая загружать конфигурационные файлы.
  • В конфигурационных файлах должно присутствовать свойство endpoint.


Вот некоторые важные сведения о моём инструменте, которые необходимо было учесть до начала работы над ним:

  • Он должен использовать API проекта, в котором применяется GraphQL, HTTP и WebSocket.
  • Он должен использовать файловую систему для хранения конфигурационного файла.
  • Он должен уметь разбирать позиционные аргументы и флаги командной строки.


Почему я решил использовать именно Go и Rust?


Есть много языков, на которых можно писать инструменты командной строки.

В данном случае мне хотелось выбрать язык, опыта работы с которым у меня не было, или язык, в работе с которым у меня был совсем небольшой опыт. Кроме того, мне хотелось подобрать что-то такое, что легко компилируется в машинный код, так как это — дополнительный плюс для инструмента командной строки.

Первым языком, что для меня очевидно, мне на ум пришёл Go. Вероятно, дело в том, что многие инструменты командной строки, которыми я пользуюсь, написаны на Go. Но у меня был ещё и небольшой опыт в Rust-программировании, и мне показалось, что этот язык тоже хорошо подойдёт для моего проекта.

Размышляя о Go и Rust, я подумал о том, что можно ведь выбрать и оба языка. Так как моей главной целью было самообучение, такой ход дал бы мне отличную возможность дважды реализовать проект и самостоятельно выяснить преимущества и недостатки каждого из языков.

Тут мне бы хотелось упомянуть языки Crystal и Nim. Они выглядят многообещающе. Я с нетерпением жду возможности испытать их в очередном своём проекте.

Локальное окружение


Перед использованием нового набора инструментов я всегда интересуюсь удобством работы с ним. А именно, тем, придётся ли мне использовать некий менеджер пакетов для глобальной установки программ в системе. Или, что кажется мне гораздо более удобным решением, можно ли будет устанавливать всё, ориентируясь на учётную запись пользователя. Мы говорим о менеджерах версий, они упрощают нам жизнь, ориентируясь при установке программ на пользователей, а не на систему в целом. В среде Node.js с этой задачей отлично справляется NVM.

При работе с Go для тех же целей можно пользоваться GVM. Этот проект отвечает за локальную установку программ и за управление версиями. Установить его очень просто:

gvm install go1.14 -B
gvm use go1.14


Готовя среду разработки на Go, нужно знать о существовании двух переменных окружения — GOROOT и GOPATH. Подробности о них можно почитать здесь.

Первая проблема, с которой я столкнулся, используя Go, заключалась в следующем. Когда я пытался понять то, как работает система разрешения модулей и как применяется GOPATH, мне было довольно сложно настроить структуру проекта с функциональным локальным окружением разработки.

В итоге я просто использовал в директории проекта GOPATH=$(pwd). Главный плюс этого заключался в том, что в моём распоряжении оказалась система работы с зависимостями, ограниченная рамками отдельного проекта, нечто вроде node_modules. Эта система показала себя хорошо.

После того, как я окончил работу над моим инструментом, я обнаружил, что существует проект virtualgo, который помог бы мне решить проблемы с GOPATH.

У Rust есть официальный установщик rustup, который выполняет установку набора инструментальных средств, необходимого для использования Rust. Rust можно установить буквально одной командой. Кроме того, при использовании rustup у нас есть доступ к дополнительным компонентам, к таким, как сервер rls и система форматирования кода rustfmt. Многие проекты требуют ночных сборок набора инструментов Rust. Благодаря применению rustup у меня не возникло проблем с переключением между версиями.

Поддержка редактора


Я пользуюсь VS Code и смог найти расширения, предназначенные для Go и для Rust. Оба языка отлично поддерживаются в редакторе.

Для отладки Rust-кода мне, следуя этому руководству, понадобилось установить расширение CodeLLDB.

Управление пакетами


В экосистеме Go нет менеджера пакетов или даже официального реестра. Здесь система разрешения модулей основана на импорте модулей с внешних URL.

Rust использует для управления зависимостями менеджер пакетов Cargo, который загружает пакеты с crates.io, из официального реестра для Rust-пакетов. У пакетов из экосистемы Crates может быть документация, размещённая на docs.rs.

Библиотеки


Моей первой целью в исследовании новых языков было выяснение того, насколько сложно будет реализовать простое взаимодействие с GraphQL-сервером по HTTP с использованием запросов и мутаций.

Если говорить о Go, то мне удалось найти несколько библиотек, вроде machinebox/graphql и shurcooL/graphql. Вторая из них использует структуры для маршалинга и анмаршалинга данных. Поэтому я выбрал именно её.

Я использовал форк shurcooL/graphql, так как мне нужно было настраивать на клиенте заголовок Authorization. Изменения представлены этим PR.

Вот пример вызова мутации 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 {
    let res = api::build_base_request(context)
        .json(&CreateSession::build_query(creation))
        .send()
        .await?
        .json::>()
        .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 поддерживает подписки, но, так как она независима от протоколов, мне пришлось самостоятельно реализовать механизмы WebSocket-взаимодействия с GraphQL.

Для использования WebSocket в Go-версии приложения библиотеку нужно было модифицировать. Так как я уже использовал форк библиотеки, мне этого делать не захотелось. Вместо этого я использовал упрощённый способ «наблюдения» за новыми твитами. А именно — я, для получения твитов, каждые 5 секунд отправлял запросы к API. Я не горжусь тем, что поступил именно так.

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

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


В Go ошибки рассматриваются так же, как любые другие значения. Обычный способ обработки ошибок в 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, которое включает в себя значения, выражающие успешное завершение операции и завершение операции с ошибкой. Это, соответственно, Ok(T) и Err(E). Здесь есть ещё одно перечисление, Option, включающее в себя значения 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.into())
    };
    let mut file = match File::create(&self.path) {
        Ok(file) => file,
        Err(e) => return Err(e.into())
    };
    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 использует инкрементную модель компиляции. Выполняется частичная повторная компиляция дерева зависимостей, начиная с изменённого модуля и заканчивая модулями, которые от него зависят.

На выполнение release-сборки проекта уходит больше времени, что вполне ожидаемо, так как компилятор при этом выполняет оптимизацию кода:

> time cargo build --release
   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 release [optimized] target(s) in 2m 42s
cargo build --release  1067,72s user 16,95s system 667% cpu 2:42,45 total


Непрерывная интеграция


Те особенности компиляции проектов, написанных на Go и на Rust, которые мы выявили выше, проявляются, что вполне ожидаемо, в системе непрерывной интеграции.

8047d6a5577f4673da6b51d35507c217.png


Обработка Go-проекта

5725edfa011e6525c3d8762d18baa56c.png


Обработка Rust-проекта

Потребление памяти


Для анализа потребления памяти разными версиями моего инструмента командной строки я воспользовался следующей командой:

/usr/bin/time -v ./hashtrack list


Команда time -v выводит много интересных сведений, но меня интересовал показатель процесса Maximum resident set size, который представляет собой пиковый объём физической памяти, выделенной программе в процессе её выполнения.

Вот код, который я применил для сбора данных о потреблении памяти разными версиями программы:

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): 13632
Maximum resident set size (kbytes): 14016
Maximum resident set size (kbytes): 14244
Maximum resident set size (kbytes): 13648
Maximum resident set size (kbytes): 14500


Вот — сведения о потреблении памяти 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


Эта память выделяется в ходе решения следующих задач:

  • Интерпретация системных аргументов.
  • Загрузка и разбор конфигурационного файла из файловой системы.
  • Обращение к GraphQL через HTTP с использованием TLS.
  • Разбор JSON-ответа.
  • Запись отформатированных данных в stdout.


В Go и Rust применяются разные способы управления памятью.

В Go есть сборщик мусора, который используется для обнаружения неиспользуемой памяти и её освобождения. Программист, в итоге, на эти задачи не отвлекается. Так как в основе сборщика мусора лежат эвристические алгоритмы, его использование всегда означает необходимость идти на компромиссы. Обычно — между производительностью и объёмом памяти, используемой приложением.

В модели управления памятью Rust есть такие понятия, как владение, заимствование, время жизни. Это не только способствует безопасной работе с памятью, но и гарантирует полный контроль над памятью, выделяемой в куче, не требуя ручного управления памятью или использования системы сборки мусора.

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


Причины, по которым я выбрал бы Go


Я выбрал бы для некоего проекта Go по следующим причинам:

  • Если бы мне нужен был язык, который легко будет изучить членам моей команды.
  • Если бы мне хотелось писать простой код за счёт меньшей гибкости языка.
  • Если бы я разрабатывал программы только для Linux, или если бы Linux была бы операционной системой, представляющей для меня наибольший интерес.
  • Если бы важным было время компиляции проектов.
  • Если бы мне нужны были зрелые механизмы асинхронного выполнения кода.


Причины, по которым я выбрал бы Rust


Вот причины, которые могут привести к тому, что я выберу для некоего проекта Rust:

  • Если бы мне нужна была продвинутая система обработки ошибок.
  • Если бы мне хотелось писать на мультипарадигмальном языке, позволяющем создавать более выразительный код, чем мне удалось бы создать, пользуясь другими языками.
  • Если бы мой проект имел бы очень высокие требования, касающиеся безопасности.
  • Если бы проекту жизненно важна была бы высокая производительность.
  • Если бы проект был бы нацелен на множество операционных систем и мне хотелось бы обладать по-настоящему многоплатформенной кодовой базой.


Общие замечания


У Go и Rust есть некоторые особенности, которые до сих пор не дают мне покоя. Речь идёт о следующем:

  • Go так сильно нацелен на простоту, что иногда это стремление даёт противоположный эффект (например, как в случаях с GOROOT и GOPATH).
  • Я всё ещё толком не пойму концепцию «времени жизни» в Rust. Меня выводят из равновесия даже попытки поработать с соответствующими механизмами языка.


Да, хочу отметить, что в новых версиях Go работа с GOPATH больше проблем не вызывает, поэтому мне стоит перевести мой проект на более новую версию Go.

Могу сказать, что и Go и Rust — это языки, которые было очень интересно изучать. Я считаю их отличными дополнениями к возможностям мира C/C++-программирования. Они позволяют создавать приложения самой разной направленности. Например — веб-сервисы и даже, благодаря WebAssembly, клиентские веб-приложения.

Итоги


Go и Rust — отличные инструменты, хорошо подходящие для разработки средств командной строки. Но, конечно, их создатели руководствовались разными приоритетами. Один язык нацелен на то, чтобы сделать разработку программ простой и доступной, на то, чтобы код, написанный на этом языке, было бы удобно поддерживать. Приоритеты другого языка — рациональность, безопасность и производительность.

Если вы хотите почитать ещё что-нибудь, посвящённое сравнению Go и Rust, взгляните на эту статью. В ней, кроме прочего, поднят вопрос, касающийся серьёзных проблем с многоплатформенной совместимостью программ.

Какой язык вы использовали бы для разработки инструмента командной строки?

oug5kh6sjydt9llengsiebnp40w.png

© Habrahabr.ru