Skip to content

CPP-KT/repl-task

Repository files navigation

REPL

В этом задании необходимо реализовать REPL (Read-Eval-Print Loop), служащий клиентом для отправки запросов в соответствии с заданной схемой.

Общая идея и мотивация

Когда разные программы (например, клиент и сервер) обмениваются данными, им нужно договориться о трёх вещах:

  1. Какие данные они собираются передавать.
  2. Как эти данные должны быть представлены при передаче.
  3. Через какой канал данные будут передаваться.

С первым помогает схема — формальное описание набора поддерживаемых запросов и их структуры. Она позволяет разным частям системы коммуницировать, полагаясь на общее описание интерфейса. Часто схема доступна статически, и может использоваться для кодогенерации.

Второе — вопрос сериализации структурированных данных, описанных схемой, в последовательность байтов (в случае отправки данных) и, наоборот, десериализации последовательности байтов в структурированный вид (в случае получения данных).

Существует множество форматов для задания схем и (де-)сериализации данных в соответствии с этими схемами (Protocol Buffers, Apache Thrift, FlatBuffers, TL и другие).

В качестве канала передачи данных могут служить как механизмы для локального IPC (e.g. разделяемая память, пайпы, Unix Domain Socket), так и сетевые протоколы и высокоуровневые обёртки над ними (e.g. gRPC на базе HTTP/2 + Protocol Buffers, вновь Apache Thrift, Java RMI и другие).

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

  • Считывать у пользователя строковое представление запроса;
  • Сериализовывать его в соответствии со схемой;
  • Отправлять его серверу при помощи RPC-клиента;
  • Десериализовывать полученный ответ в соответствии со схемой;
  • Красиво отображать строковое представление ответа пользователю.

Note

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

При этом вовсе необязательно весь описанный функционал реализовывать руками — в этом задании вам будет предложено воспользоваться существующими библиотеками для решения типичных задач. Также в репозитории уже приложена реализация вышеупомянутого RPC-клиента (подробнее про него ниже).

Формат схемы

Формат схемы состоит из двух компонент: функций и структур.

Функции

Функции служат для описания запросов. У функции есть имя, список аргументов и тип возвращаемого значения. Каждый аргумент имеет имя и тип. Имена аргументов в пределах функции уникальны.

Пример описания в схеме функции concat, принимающей две строки (left и right), и возвращающей строку:

fn concat -> string {
  string left;
  string right;
}

Структуры

Помимо встроенных типов (int32, int64, uint32, uint64, string), в схеме можно задавать пользовательские структуры, что удобно для группировки семантически связанных данных, и их дальнейшего переиспользования, например:

struct Person {
  uint32 id;
  string name;
}

fn getId -> uint32 {
  Person person;
}

fn getName -> string {
  Person person;
}

fn getPersonById -> Person {
  uint32 id;
}

Структуры могут быть вложенными:

struct Point {
  int32 x;
  int32 y;
  string label;
}

struct Segment {
  Point p1;
  Point p2;
  string label;
}

fn getLength -> int32 {
  Segment segment;
}

Формат запросов и ответов

На каждой итерации в REPL вводится строка запроса, задающая, какую функцию, описанную в схеме, и с какими значениями аргументов нужно вызвать. Результат исполнения функции выводится на экран.

Путь до файла со схемой указывается в виде обязательного аргумента командной строки: --schema <path/to/schema>.

Note

Выше упоминалось, что часто схема доступна статически (то есть во время сборки). Это полезно, когда хочется в коде работать с описанными в ней структурами, т.к. тогда можно на основе схемы сгенерировать код на C++ для манипуляции каждой из них. В этом задании не требуется ничего знать про бизнес-логику, стоящую за отдельными функциями и структурами из схемы, потому здесь это неактуально, и достаточно знать схему уже во время исполнения клиента.

  • Простейший поддерживаемый формат чем-то похож на синтаксис С++, но отличается необходимостью указывать имена аргументов:
>> concat(left="hello", right=" world")
"hello world"
  • Аргументы не обязаны следовать в том же порядке, в котором они были указаны в схеме:
>> concat(right=" world", left="hello")
"hello world"
  • Для задания значения, соответствующего структуре, используются фигурные скобки, внутри которых рекурсивно задаются значения для полей аналогично тому, как задаются аргументы функции:
>> getId(person={id=42, name="John"})
42

>> getName(person={name="John", id=42})
"John"
  • Перед фигурными скобками можно для ясности опционально указать имя структуры:
>> getId(person=Person{id=42, name="John"})
42
  • При выводе ответа на запрос имена структур пишутся всегда:
>> getPersonById(id=42)
Person{id=42, name="John"}

Сериализация и десериализация

Целые числа

  • int32 и int64 — числа со знаком, представляются в виде 4 и 8 байтов соответственно;
  • uint32 и uint64 — числа без знака, представляются в виде 4 и 8 байтов соответственно;
  • Отрицательные числа представляются в дополнении до двух;
  • Порядок байтов — Big Endian.
Raw (uint32): 42
Serialized: 00 00 00 2a
Raw (int32): -1
Serialized: ff ff ff ff

Строки

  • Сначала в виде uint32 кодируется длина строки n;
  • Следующие n байтов соответствуют ASCII-кодам символов строки.
Raw: "John"
Serialized: 00 00 00 04 4a 6f 68 6e

Структуры

  • По порядку (как в схеме) сериализуется каждое поле рекурсивно вплоть до встроенных типов.
Raw: Person{id=42, name="John"}
Serialized: 00 00 00 2a 00 00 00 04 4a 6f 68 6e

Запросы

  • Сначала имя запроса кодируется в виде uint32, полученным при помощи алгоритма xxhash;
  • Затем по порядку (как в схеме) сериализуется каждый аргумент.
Raw: getId(person={id=42, name="John"})  
Serialized: 69 5d ff b3 00 00 00 2a 00 00 00 04 4a 6f 68 6e

Интерактивность

REPL должен поддерживать работу в двух режимах: интерактивном (TTY) и неинтерактивном (no-TTY).

Интерактивный режим — тот, в котором будет работать пользователь, вводя команды руками. Неинтерактивный же предназначен для автоматизированного использования утилиты в скриптах (в частности, он активно используется в интеграционных тестах).

Note

Разделение этих режимов работы утилиты позволяет поддержать некоторые удобства и красоту CLI, актуальные только при взаимодействии с пользователем (при этом не убив возможность автоматизации). Среди них могут быть вывод промпта перед командой (как >> в примерах выше), автодополнение, цветовая разметка элементов CLI и частей запросов/ответов и т.д.

По умолчанию утилита должна работать в интерактивном режиме. Неинтерактивный режим активируется флагом --no-tty, переданным в качестве аргумента командной строки, в случае чего вся красота отключается и остаются лишь чтение запросов через stdin и запись ответов в stdout.

Автодополнение

По нажатии на Tab префикс введённого запроса должен автоматически дополняться максимальным количеством символов, которое может быть предсказано однозначно. Вы можете сами выбрать, как обработать дополнение имени структуры (опускать его или дополнять).

Пример (строка до нажатия Tab и после):

>> getI
>> getId(person={id=

История

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

Выход из REPL

В неинтерактивном режиме REPL завершает свою работу, когда заканчивается stdin.

В интерактивном режиме для выхода из REPL также должна поддерживаться специальная команда exit (которая вводится вместо запроса). Завершение работы REPL в интерактивном режиме тем или иным способом сопровождается выводом строки Goodbye!.

Портативность

Поскольку операция нажатия Tab никак не специфицируется стандартом C++, придётся воспользоваться системоспецифичным API. В частности, автодополнение достаточно поддержать на системах, совместимых с POSIX (или по крайней мере по большей части совместимых, как в случае со многими дистрибутивами Linux). Для этого можно воспользоваться unistd.h и termios.h или сторонней библиотекой поверх этого API.

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

RPC-клиент

Для взаимодействия с сервером в репозитории прилагается класс ct::rpc::Client, расположенный в lib/rpc/client.h.

В конструкторе он принимает три параметра: host, port, path, которые задаются опциональными аргументами командной строки --rpc-host <host> (127.0.0.1, если не указан), --rpc-port <port> (8080, если не указан), --rpc-path <path> (пустой, если не указан) соответственно.

Далее сериализованные запросы отправляются посредством вызова метода std::vector<std::byte> send(std::span<const std::byte> request). На выходе — сериализованные ответы.

Вызов send может бросить исключение типа ct::rpc::Exception, если что-то пошло не так при обработке запроса.

Tip

У ct::rpc::Client также есть конструктор от произвольного функционального объекта (e.g. указателя на функцию, лямбды, экземпляра класса с перегруженным operator()), принимающего запрос в виде последовательности байтов, и отдающего последовательность байтов в виде ответа. Это делает клиент полиморфным относительно того, куда и как запросы отправляются. Вы можете использовать этот конструктор для отладки или тестирования.

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

При использовании клиента могут возникать ошибки разного рода:

  1. Ошибки в аргументах командной строки (пример: файла, указанного в качестве схемы, не существует);
  2. Ошибки при разборе схемы (пример: используется несуществующее имя типа);
  3. Ошибки при вводе запроса (пример: передали отрицательное число, когда ожидался uint32);
  4. Ошибки при обработке запроса (send выбросил исключение).

Во всех случаях должно выводиться информативное человекочитаемое сообщение об ошибке в формате Error: <message>. При этом:

  • В случаях (1) и (2) сообщение выводится в stderr и программа завершается ненулевым кодом возврата.
  • В случаях (3) и (4) сообщение выводится в stdout (как и неошибочный ответ на запрос), и REPL продолжает работу, ожидая следующий запрос.

Тесты

Вам необходимо самостоятельно покрыть свой код unit-тестами. В первую очередь это касается логики сериализации/десериализации и всяких вспомогательных функций.

Warning

Ваши unit-тесты не должны отправлять запросы на удалённый сервер. Этим занимаются интеграционные тесты, которые находятся в директории integration-tests. При желании их дополнить, взаимодействие с клиентом вы можете замокать, используя его конструктор от функционального объекта.

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

Библиотеки

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

  • Парсинг и обработка аргументов командной строки;
  • Парсинг и обход схемы;
  • Интерактивность CLI;
  • Вычисление xxhash;
  • Написание Unit-тестов;
  • ...

Important

Вы обязаны воспользоваться хотя бы тремя различными сторонними библиотеками, чтобы задание считалось выполненным (httplib, использующийся в реализации RPC-клиента — не в счёт).

Tip

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

Управление зависимостями

В шаблоне репозитория вы найдёте пример использования фреймворка для unit-тестирования GoogleTest:

  • В third-party/gtest/CMakeLists.txt задаются параметры для FetchContent — модуля CMake, использующегося для загрузки зависимостей на этапе конфигурации CMake. В данном случае в качестве источника указан URL архива с кодом последнего релиза. Если вместо архива вы решите указать ссылку на Git-репозиторий, не забудьте об опции GIT_SHALLOW для оптимизации времени на подтягивание репозитория.
  • В unit-tests/CMakeLists.txt к таргету repl-unit-tests (с вашими Unit-тестами) линкуется таргет gtest_main из GTest (с реализацией интерфейса, объявленного в gtest/gtest.h, а также main, запускающего тесты).
  • Комбинация из FIND_PACKAGE_ARGS у FetchContent и find_package позволяет использовать системный GTest, если такой нашёлся, вместо предоставляемого FetchContent-ом.

Аналогичным образом можно подключить большинство библиотек, использующих CMake. Не возбраняется пользоваться и другими методами управления зависимостями, в том числе пакетными менеджерами или вендорингом.

Структура репозитория

Основное:

  • lib — Библиотечная часть вашего REPL-клиента. В ней не должно быть main, но должно быть всё, чтобы его можно было легко реализовать. То есть в основном ваш код будет располагаться здесь.
  • app — Само приложение, т.е. main. Зависит от lib.
  • unit-tests — Unit-тесты для lib. Зависят от lib и фреймворка для тестирования со своим main.
  • third-party — Сторонние библиотеки.

Не забудьте из своего решения удалить файлы с примером юнит-тестов (lib/example.h, unit-tests/example-test.cpp).

А ещё:

  • .github/workflows/cpp.yml — Основной CI-скрипт для запуска сборки, unit- и интеграционных тестов. Вы можете его дополнять при необходимости (см. документацию GitHub Actions workflows).
  • ci-extra — Вспомогательные скрипты, использующиеся в cpp.yml.
  • cmake — CMake-скрипты, задающие различные опции сборки.
  • integration-tests — Интеграционные тесты для app. Написаны на Python. Пример запуска можно найти в ci-extra/run-integration-tests.sh (требуется pytest).

Возможности для расширения

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

  • Расширение системы типов, поддерживаемой схемой: массивы динамической длины, параметризованные типы, тип-сумма;
  • Обратная совместимость с клиентами, работающими по старой схеме (например, путём введения опциональных полей, записи масок полей при сериализации, использования полиморфных типов и пр.);
  • Автоматическая проверка схемы на совместимость с предыдущей;
  • Более эффективное кодирование целых чисел;
  • Поддержка UTF-8 для строк;
  • ...

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •