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

в 13:16, , рубрики: Go, Rust, Блог компании RUVDS.com, разработка, Разработка веб-сайтов

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

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

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

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

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

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

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

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

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

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

  • hashtrack login — вход в систему, то есть — создание сессионного токена и его сохранение в локальной файловой системе, в конфигурационном файле.
  • hashtrack logout — выход из системы, то есть — удаление сессионного токена, сохранённого локально.
  • hashtrack track <hashtag> [...] — начало наблюдения за хештегом или за несколькими хештегами.
  • hashtrack untrack <hashtag> [...] — окончание наблюдения за хештегом или за несколькими хештегами.
  • 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<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 поддерживает подписки, но, так как она независима от протоколов, мне пришлось самостоятельно реализовать механизмы 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<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.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, которые мы выявили выше, проявляются, что вполне ожидаемо, в системе непрерывной интеграции.

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

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

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

Обработка 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 есть такие понятия, как владение, заимствование, время жизни. Это не только способствует безопасной работе с памятью, но и гарантирует полный контроль над памятью, выделяемой в куче, не требуя ручного управления памятью или использования системы сборки мусора.

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

Команда Показатель Maximum resident set size (kbytes)
heroku apps 56436
gh pr list 26456
git ls-remote (с доступом по SSH) 6448
git ls-remote (с доступом по HTTP) 23488

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

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

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

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

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

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

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

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

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

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

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

Итоги

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

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

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

Автор: ru_vds

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js