[Перевод] Портируем утилиту командной строки с Go/Rust на D
Несколько дней назад, на реддите в «программировании», Paulo Henrique Cuchi поделился своим опытом разработки утилиты командной строки на Rust и на Go (перевод на Хабре). Утилита, о которой идет речь, — это клиент для его пет-проекта Hashtrack. Hashtrack предоставляет GraphQL API, с помощью которого клиенты могут отслеживать определенные хэштэги твиттера и получать список соответствующих твитов в реальном времени. Будучи спровоцированным комментарием, я решил написать порт на D, чтобы продемонстрировать, как D может быть использован для подобных целей. Я постараюсь сохранить ту же структуру, которую он использовал в своем блогпосте.
Исходники на Гитхабе
Видео по клику
Как я пришел к D
Основная причина заключается в том, что в оригинальном блогпосте сравнивались статически типизированные языки, такие как Go и Rust, а также делались уважительные отсылки к Nim и Crystal, но не упоминался D, который так же подпадает в эту категорию. Потому я думаю, что это сделает сравнение интересным.
Мне также нравится D как язык, и я упоминал об этом в различных других блогпостах.
Локальная среда
Руководство содержит обширную информацию о том, как загрузить и установить эталонный компилятор, DMD. Пользователи Windows могут получить инсталлятор, в то время как пользователи MacOS могут использовать homebrew. На Ubuntu, я просто добавил apt-репозиторий и выполнил обычную установку. С помощью этого вы получите не только DMD, но и dub, менеджер пакетов.
Я установил Rust, чтобы иметь представление о том, как легко будет начать работать. Я был удивлён, насколько это просто. Мне нужно было только запустить интерактивный инсталлятор, который позаботился об остальном. Мне нужно было добавить ~/.cargo/bin в path. Следовало просто перезапустить консоль, чтобы изменения вступили в силу.
Поддержка редакторами
Я написал Hashtrack в Vim без особых затруднений, но это, наверное, потому, что у меня есть некоторое представление о том, что происходит в стандартной библиотеке. У меня всегда была открыта документация, потому что временами я использовал символ, который не импортировал из нужного пакета, или же я вызывал функцию с неверными аргументами. Заметьте, что для стандартной библиотеки вы можете просто написать «import std;» и иметь все в своем распоряжении. Для сторонних библиотек, однако, вы сами по себе.
Мне было любопытно, в каком состоянии находится инструментарий, поэтому я изучил плагины для моей любимой IDE, Intellij IDEA. Я нашел этот и установил его. Я также установил DCD и DScanner, клонируя их соответствующие репозитории и собирая их, а затем настраивая плагин IDEA, чтобы указать правильные пути. Обратитесь к автору этой заметки в блоге за разъяснениями.
Сначала я столкнулся с несколькими проблемами, но они были исправлены после обновления IDE и плагина. Одна из проблем, с которой я столкнулся, заключалась в том, что она не могла распознать мои собственные пакеты и продолжала отмечать их как «возможно, неопределенные». Позже я обнаружил, что для того, чтобы они был распознаны, я должен был поместить «module имя_модуля_пакета;» вверху файла.
Я думаю, что все еще есть ошибка, что не распознается .length, по крайней мере, на моей машине. Я открыл проблему на Github, вы можете проследить за ней здесь, если вам любопытно.
Если вы в Windows, я слышал хорошее о VisualD.
Управление пакетами
Dub является дефакто менеджером пакетов в D. Он загружает и устанавливает зависимости с code.dlang.org. Для этого проекта мне нужен был HTTP клиент, потому что я не хотел использовать cURL. В итоге я получил две зависимости, requests и его зависимость, cachetools, который не имеет собственной зависимости. Однако, по каким-то причинам, он прихватил еще двенадцать зависимостей:
Я думаю, что Dub использует их для внутренних целей, но я не уверен насчет этого.
Rust загрузил много крейтов (Прим.пер: 228), но это, вероятно, потому, что версия на Rust имеет больше возможностей, чем моя. Например, он загрузил rpassword, инструмент, который скрывает символы пароля при вводе их в терминал, подобно функции getpass от Python. Это одна из многих вещей, которых у меня нет в коде. Я добавил поддержку getpass для Linux, благодаря этой рекомендации. Я также добавил форматирование текста в терминале, благодаря экранирующим последовательностям, которые я скопировал из оригинального исходного кода Go.
Библиотеки
Имея слабое представление о graphql, я понятия не имел, с чего начать. Поиск по «graphql» на code.dlang.org привел меня к соответствующей библиотеке, метко названной «graphqld». Однако после ее изучения мне показалось, что она больше похожа на плагин vibe.d, чем на реального клиента, если таковой есть.
После изучения сетевых запросов в Firefox, я понял, что для этого проекта я могу просто имитировать graphql-запросы и преобразования, которые я буду посылать с помощью HTTP-клиента. Ответы — это просто JSON-объекты, которые я могу разобрать с помощью инструментов, предоставляемых пакетом std.json. Помня об этом, я начал искать HTTP-клиенты и остановился на requests, это простой в использовании HTTP-клиент, но, что более важно, достигший определенного уровня зрелости.
Я скопировал исходящие запросы от сетевого анализатора и вставил их в отдельные .graphql файлы, которые затем импортировал и отправил с соответствующими переменными. Большая часть функциональности была помещена в структуру GraphQLRequest, потому что я хотел вставить различные конечные точки и конфигурации в него, необходимые для проекта:
struct GraphQLRequest
{
string operationName;
string query;
JSONValue variables;
Config configuration;
JSONValue toJson()
{
return JSONValue([
"operationName": JSONValue(operationName),
"variables": variables,
"query": JSONValue(query),
]);
}
string toString()
{
return toJson().toPrettyString();
}
Response send()
{
auto request = Request();
request.addHeaders(["Authorization": configuration.get("token", "")]);
return request.post(
configuration.get("endpoint"),
toString(),
"application/json"
);
}
}
struct Session
{
Config configuration;
void login(string username, string password)
{
auto request = createSession(username, password);
auto response = request.send();
response.throwOnFailure();
string token = response.jsonBody
["data"].object
["createSession"].object
["token"].str;
configuration.put("token", token);
}
GraphQLRequest createSession(string username, string password)
{
enum query = import("createSession.graphql").lineSplitter().join("\n");
auto variables = SessionPayload(username, password).toJson();
return GraphQLRequest("createSession", query, variables, configuration);
}
}
struct SessionPayload
{
string email;
string password;
//todo : make this a template mixin or something
JSONValue toJson()
{
return JSONValue([
"email": JSONValue(email),
"password": JSONValue(password)
]);
}
string toString()
{
return toJson().toPrettyString();
}
}
Спойлер — я никогда не делал подобного ранее.
Все происходит так: функция main () создает из аргументов командной строки структуру Config и инжектирует ее в структуру Session, которая реализует функциональность команд входа, выхода из системы и статуса. Метод createSession () конструирует graphQL-запрос, читая реальный запрос из соответствующего .graphql-файла и передавая вместе с ним переменные. Я не хотел загрязнять исходный код graphQL-мутациями и запросами, поэтому переместил их в .graphql файлы, которые затем импортирую во время компиляции с помощью enum и import. Последний требует наличия флага компилятора для указания его на stringImportPaths (который по умолчанию имеет значение view/).
Что касается метода login (), его единственной обязанностью является отправка HTTP-запроса и обработка ответа. В этом случае он обрабатывает потенциальные ошибки, хотя и не очень тщательно. Затем он сохраняет токен в конфигурационном файле, который на самом деле является не более чем славным JSON-объектом.
Метод throwOnFailure не является частью основной функциональности библиотеки запросов. На самом деле это вспомогательная функция, которая делает быструю и грязную обработку ошибок:
void throwOnFailure(Response response)
{
if(!response.isSuccessful || "errors" in response.jsonBody)
{
string[] errors = response.errors;
throw new RequestException(errors.join("\n"));
}
}
Так как D поддерживает UFCS, синтаксис throwOnFailure (response) может быть переписан как response.throwOnFailure (). Это делает его легко встраиваемым в другие вызовы методов, таких как send (). Возможно, я злоупотреблял этой функциональностью на протяжении всего проекта.
Обработка ошибок
D предпочитает исключения, когда дело доходит до обработки ошибок. Обоснование подробно объяснено здесь. Одна из вещей, которая мне нравится, заключается в том, что необработанные ошибки в конце концов всплывут, если их не явно не заткнуть. Вот почему мне удалось уйти от упрощенной обработки ошибок. Например, в этих строках:
string token = response.jsonBody
["data"].object
["createSession"].object
["token"].str;
configuration.put("token", token);
Если тело ответа не содержит токен или любой из объектов, приводящих к нему, будет выброшено исключение, которое всплывет в основной функции, а затем взорвется перед лицом пользователя. Если бы я использовал Go, мне пришлось бы быть очень осторожным с ошибками на каждом этапе. И, честно говоря, так как писать если err!= null каждый раз при вызове функции раздражает, я бы очень соблазнился ошибку просто проигнорировать. Однако мое понимание Go примитивно, и я не удивлюсь, если компилятор облает вас за то, что вы ничего не делаете с возвратом ошибки, так что не стесняйтесь поправлять меня, если я ошибаюсь.
Обработка ошибок в стиле Rust, как объяснено в оригинальном блогпосте, была интересна. Я не думаю, что в стандартной библиотеке D есть что-то подобное, но были дискуссии о реализации подобного как сторонней библиотеки.
Websockets
Я просто хочу кратко отметить, что я не использовал вебсокеты для реализации команды «watch». Я пытался использовать клиент websocket из Vibe.d, но он не смог работать с бэкэндом hashtrack, потому что продолжал закрывать соединение. В конце концов, я отказался от него в пользу циклического опроса, даже несмотря на то, что это осуждается. Клиент работает с тех пор, как я протестировал его с другим веб-сервером, так что я, возможно, вернусь к этому в будущем.
Непрерывная интеграция
Для CI я настроил два сборочных задания: обычную сборку для бранчей и мастер — релиз, чтобы обеспечить загрузки оптимизированных сборок артефактов.
Прим.пер. На картинках видно время на сборку. С учетом загрузки зависимостей. Пересборка без зависимостей ~4с
Потребление памяти
Я использовал команду /usr/bin/time -v ./hashtrack --list для измерения использования памяти, как объяснялось в оригинальной блогпосте. Я не знаю, зависит ли использование памяти от хэштэгов, за которыми следит пользователь, но вот результаты программы на D, собранной с помощью dub build -b release:
Maximum resident set size (kbytes): 10036
Maximum resident set size (kbytes): 10164
Maximum resident set size (kbytes): 9940
Maximum resident set size (kbytes): 10060
Maximum resident set size (kbytes): 10008
Неплохо. Я запустил версии Go и Rust с моим пользователем hashtrack’a и получил эти результаты:
Go, собранный с go build -ldflags »-s -w»:
Maximum resident set size (kbytes): 13684
Maximum resident set size (kbytes): 13820
Maximum resident set size (kbytes): 13904
Maximum resident set size (kbytes): 13796
Maximum resident set size (kbytes): 13600
Rust, собранный с cargo build --release:
Maximum resident set size (kbytes): 9224
Maximum resident set size (kbytes): 9192
Maximum resident set size (kbytes): 9384
Maximum resident set size (kbytes): 9132
Maximum resident set size (kbytes): 9168
Upd: пользователь реддита skocznymroczny рекомендовал также протестировать компиляторы LDC и GDC. Вот результаты:
LDC 1.22, собранный dub build -b release --compiler=ldc2 (уже после добавления цветного вывода и getpass)
Maximum resident set size (kbytes): 7816
Maximum resident set size (kbytes): 7912
Maximum resident set size (kbytes): 7804
Maximum resident set size (kbytes): 7832
Maximum resident set size (kbytes): 7804
В D есть сборка мусора, но также поддерживаются умные указатели и, совсем недавно, экспериментальная методология управления памятью, вдохновленная Rust. Я не совсем уверен, насколько хорошо эти функции интегрируются со стандартной библиотекой, поэтому я решил позволить GC обрабатывать память за меня. Я думаю, что результаты довольно неплохие, учитывая, что я не задумывался о потреблении памяти во время написания кода.
Размер бинарников
Rust, собранный cargo build --release: 7.0MD, собранный dub build -b release: 5.7M
D, собранный dub build -b release --compiler=ldc2: 2.4M
Go, собранный go build: 7.1M
Go, собранный go build -ldflags »-s -w»: 5.0M
Прим.пер. Здесь надо перепроверять — не очень понятно, где выполняется стрип отладочной информации, а где нет. Например у меня версия для Windows при сборке dub build -b release получается размером 2М для x64 (и 1.5M для x86-mscoff) и в них нет отладочных символов, а Rust версию на Ubuntu18 собрать не удалось из-за проблем с конфигурацией openssl, потому трудно сказать, как аукнулось огромное число зависимостей
Заключение
Я думаю, что D — надежный язык для написания подобных инструментов командной строки. Я не часто обращался к внешним зависимостям, потому что стандартная библиотека содержала большую часть того, что мне было нужно. Такие вещи, как разбор аргументов командной строки, обработка JSON, юнит-тестирование, отправка HTTP-запросов (с cURL) — все это доступно в стандартной библиотеке. Если стандартной библиотеке не хватает того, что вам нужно, то пакеты сторонних разработчиков существуют, но я думаю, что в этой области еще есть место для улучшений. С другой стороны, если у вас менталитет NIH «изобретено не здесь», или если вы хотите с легкостью оказать влияние как разработчик с открытым исходным кодом, то вам определённо понравится экосистема D.
Причины, по которым я бы использовал D