
Если вам не терпится увидеть код и самостоятельно сравнить один вариант моей программы с другим — то вот репозиторий Go-варианта проекта, а вот — репозиторий его варианта, написанного на Rust.
Обзор проекта
У меня есть домашний проект, который я назвал Hashtrack. Это — небольшой сайт, фуллстек-приложение, которое я написал для технического собеседования. Работать с ним очень просто:
- Пользователь аутентифицируется (учитывая то, что он уже создал себе учётную запись).
- Он вводит хештеги, за появлением которых в Твиттере он хочет наблюдать.
- Он ждёт появления на экране найденных твитов с заданным хештегом.
Испытать 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-проекта
Потребление памяти
Для анализа потребления памяти разными версиями моего инструмента командной строки я воспользовался следующей командой:
/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, взгляните на эту статью. В ней, кроме прочего, поднят вопрос, касающийся серьёзных проблем с многоплатформенной совместимостью программ.
Какой язык вы использовали бы для разработки инструмента командной строки?

darkit
Для разработки cli под растом я пользуюсь clap — очень помогает для обработки аргументов команды.
Так же в Го мне очень не нравится подход с дефолтными значениями и ошибка это просто такое значение. Тем самым Го позволяет вам забить/забыть на обработку ошибок и программа что то будет дальше выполнять.
Как пример вашей функции у которой мы первую ошибку забыли обработать и у нас в любом случае есть значение в
contents
и мы что то будем передавать вWriteFile
.Но почему то в Го сообществе это считают нормальным. Я как то сказал, что жду генерики, чтобы можно было сделать нормальные
Optional
иEither
и меня все заминусовали :)))TonyLorencio
По-хорошему, такую ситуацию должен отлавливать линтер.
go vet
это должен отследить в рамках проверки наuseless assignments
.Когда дженерики сделают (а их сделают, работа над proposal активно ведется), возможное наличие
Optional
не будет противоречить гошной концепции ошибок как значений, это скорее про избежание nil pointer dereference. Просто функция будет иметь сигнатуру вида:func MyFunc[type T](s string) (Optional[T], error)
Either
может не вписаться в текущую концепцию, потому что есть случаи, когда функция хоть и возвращает ошибку, но при этом возвращает какое-то значениеasm0dey
Когда сделают дженерики — можно будет использовать монады Either или Result если хочется.
asm0dey
Потому что это привычка. Вон всё ядро на таком написано (в смысле на С, в котором нет исключений)
darkit
Да, ладно бы старики так писали, но Го это же хипстерское молодое и там все горой за такую обработку. И про генерики крутят носами что мол зачем оно нам. У нас есть кодогенерация и мы на ней все легко и просто делаем.
asm0dey
Может быть просто история циклична? Новые реализации появляются, новые концепции — редко.
darkit
Тогда мы пропустили очередной этап перфокарт :(((
asm0dey
у нет, как раз носители — это преходящая штука. Нынешние перфокарты — это гошный ассемблер, который основан на plan9 и inferno :)
TonyLorencio
Go это про простые (simple, not easy) подходы к разработке. В том числе и к разработке компилятора языка. Часто эта простота идет в приоритете над удобством использования, и можно очень по-разному к этому относиться :)
darkit
Я думаю писать в лоб как на Го можно на любом языке. Но если Джаву все козлят, что она многословна, то в Го кол-во строк для одного и того же решения получается больше. И всем это вдруг нравится.
Или почему генерики есть для map/list которые из коробки, но для остальных продвигается мантра — генерики это зло.
Еще вариант, у нас все имеет дефолтные значения, кроме ссылки, она может быть нуль, но ссылка когда
interface{}
там опять уже всегда не нул а надо копать в глубину чтобы понять какое значение.И поэтому я не понимаю о какой простоте можно говорить в Го.
AnthonyMikh
А он и не простой по факту.
darkit
Отличная линка.
TonyLorencio
О простоте реализации. С логичностью мало общего имеет, что подтверждает комментарий AnthonyMikh с ссылкой на пост в соседней подветке.
Лично я не считаю дженерики злом. Более того, мне бы хотелось увидеть их в Go с привычным по другим языкам синтаксисом в виде угловых скобок. Но их мы не увидим, потому что "это сильно усложнит парсер". Скорее всего скобки будут квадратными
darkit
Да, это просто жесть. Не смочь сделать
<>
потому что как мы будем понимать что это выражениеx < z, y > w
а не генерик :)))) Это кстати тоже показатель, что развивать язык тяжело будет дальше.PsyHaSTe
Это норма. Именно поэтому в расте турборыба так называемая существует, чтобы парсер отличал генерики от сравнения. Так-то квадратные скобки это лучший выбор, та же скала пошла по тому же пути. Идеальная запись же остаётся за ML синтаксисом, но го к таким поворотам судьбы явно не готов.
PsyHaSTe
Как я писал год назад, у меня на хаскелле программу проще было написать чем на го. Если на хаскелле посидел с гуглом и заработало, то в го я скопипастил то что гугл насоветовал, а потом в дебаггере пытался дедлок поймать чтобы починить.
AnthonyMikh
Для этого одних лишь дженериков недостаточно, нужны ещё сумм-типы. А их в Go, скорее всего, не добавят никогда, потому что сложна.
darkit
Можно будет сделать интерфейс
Optional
, две структурыSome
иNone
которы реализуютOptional
и можно уже работать.PsyHaSTe
darkit
Да такое может быть. Но в рамках своего проекта можно следить за этим. Те да вы правы это будет криво косо, но такое в Го повсеместно :))))
PsyHaSTe
Я просто прекрасно представляю себе как это работает потому что в сишарпе так АДТ эмулировал)) Выглядит конечно убого, во многом именно из-за отстуствия проверки исчерпывания вариантов.