Несколько дней назад, на реддите в «программировании», 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.0M

D, собранный 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

  • Да