Наша команда специализируется на C++ проектах. И нам время от времени приходилось создавать HTTP-точки входа в C++ компоненты. Для чего использовались разные инструменты. Тут были и старые-добрые CGI, и различные встраиваемые библиотеки, как сторонние, так и самописные. Все это работало, но всегда оставалось ощущение, что следовало бы делать такие вещи и проще, и быстрее, и производительнее.
В итоге мы решили, что пора прекращать смотреть по сторонам и нужно попробовать сделать что-то свое, спреферансом и куртизанками кроссплатформенностью, асинхронностью, производительностью и человеческим отношением к конечному пользователю. В результате у нас получилась небольшая C++14 библиотека RESTinio, которая позволяет запустить HTTP-сервер внутри C++ приложения всего несколькими строчками кода. Вот, например, простейший сервер, который на все запросы отвечает «Hello, World»:
В реализации RESTinio активно используются C++ные шаблоны и об этом хотелось бы сегодня немного поговорить.
RESTinio — это небольшой OpenSource проект, который распространяется под BSD-3-CLAUSE лицензией. RESTinio активно развивается с весны 2017-го года. За это время мы сделали несколько публичных релизов, постепенно наполняя RESTinio функциональностью. Самый свежий релиз состоялся сегодня. Это релиз версии 0.4, в которой мы, пожалуй, таки реализовали тот минимум функциональности, который мы хотели иметь.
RESTinio использует несколько сторонних компонентов. Для работы с сетью мы используем Asio (standalone версию Asio), для парсинга HTTP-протокола у нас используется http-parser из Node.js. Также внутри используется fmtlib, а для тестирования — библиотека Catch2.
Не смотря на то, что RESTinio пока еще не достиг версии 1.0, мы очень тщательно относимся к качеству и стабильности работы RESTinio. Например, наш коллега участвовал в Mail.ru-шном конкурсе HighloadCup с решением на базе RESTinio. Это решение вышло в финал с 45-го места и заняло в финале 44-е место. Могу ошибаться, но среди финалистов было всего два или три решения, которые строились на базе универсальных HTTP-фреймворков. Вот одним из них как раз и оказалось решение на базе RESTinio.
Вообще, если говорить о производительности, то скорость работы RESTinio не была приоритетом №1 при разработке. И хотя производительности мы уделяли внимание, тем не менее более важным для нас было получение решения, которым удобно пользоваться. При этом RESTinio не так уж плохо выглядит в синтетических бенчмарках.
Однако, в данной статье хотелось бы поговорить не столько о самой библиотеке RESTinio и ее возможностях (подробнее с этой информацией можно ознакомиться здесь). Сколько о том, как в ее реализации используется такая важная фича языка C++, как шаблоны.
Код RESTinio построен на шаблонах. Так, в показанном выше примере шаблонов не видно, хотя они там повсюду:
Почему же RESTinio так активно использует шаблоны? Наверное, самыми серьезными были две следующих причины:
Во-первых, мы хотели, чтобы RESTinio могла кастомизироваться в широких пределах. Но чтобы кастомизация имела минимальную стоимость в run-time. Как нам кажется, шаблоны здесь просто вне конкуренции.
Во-вторых, кое-кого из нас, видимо, сильно покусал Александреску. И это до сих пор сказывается, хотя времени с тех пор прошло уже немало.
Ну и еще нам понравилось следствие из того, что изрядная часть RESTinio представляет из себя шаблонный код: библиотека получилась header-only. Так уж складывается, что в нынешнем C++ подключить header-only библиотеку к своему (или к чужому) проекту гораздо проще, чем ту, которую нужно компилировать. Таки зоопарк систем сборки и систем управления зависимостями в C++ доставляет. И header-only библиотеки в этих зоопарках чувствуют себя гораздо лучше. Пусть даже за это приходится платить увеличением времени компиляции, но это уже тема для совершенно другого разговора…
Выше мы сказали, что шаблоны позволяют кастомизировать RESTinio. Давайте покажем, что под этим подразумевается на паре-тройке простых примеров.
Выше уже было сказано, что метод create_response() является шаблонным. Этот метод параметризуется способом формирования HTTP-ответа. По умолчанию используется restinio_controlled_output_t. Этот метод самостоятельно вычисляет значение HTTP-заголовка Content-Length и инициирует запись ответа в сокет после того, как программист полностью создаст весь ответ и вызовет метод done().
Но RESTinio поддерживает еще несколько методов: user_controlled_output_t и chunked_output_t. Например, использование режима chunked_output_t будет выглядеть как-то так:
Примечательно то, что create_response() возвращает объект response_builder_t<Output_Type>, публичный API которого зависит от Output_Type. Так, у response_builder_t<restinio_controlled_output_t> нет публичного метода flush(), а публичный метод set_content_length() есть только у response_builder_t<user_controlled_output_t>.
В самом начале статьи мы показали простейший однопоточный HTTP-сервер. Который работает как «черный ящик», без каких-либо отладочных печатей или диагностического логирования. Давайте сделаем так, чтобы запускаемый HTTP-сервер логировал все происходящие с ним действия на стандартный поток вывода. Для этого нам потребуется небольшой трюк с шаблонами:
Что мы здесь сделали?
Мы определили собственный класс свойств (traits) для HTTP-сервера, в котором задали нужный нам тип логгера. Потом заставили RESTinio использовать этот класс свойств при конструировании HTTP-сервера внутри restinio::run(). В итоге внутри restino::run() создается HTTP-сервер, который логирует все события посредством логгера, который реализуется типом single_threaded_ostream_logger_t.
Если мы запустим модифицированный пример и выдадим простейший запрос к нашему серверу (вроде wget localhost:8080), то мы увидим что-то такое:
Что мы сделали? По сути мы поправили один параметр в свойствах HTTP-сервера и получили дополнительную функциональность. Которой вообще не было в первом случае, когда мы использовали дефолтные свойства для HTTP-сервера. Причем под «вообще» мы понимаем именно «вообще». Поясним на примере.
В коде RESTinio разбросано логирование выполняемых сервером операций. Вот, скажем:
Идет обращение к логгеру с передачей лямбда-функции, отвечающей за формирование сообщения для лога. Но если в качестве логгера используется restinio::null_logger_t (а это и происходит по умолчанию), то в null_logger_t методы trace(), info() и им подобные просто ничего не делают:
Поэтому нормальный компилятор просто выбрасывает все обращения к логгеру и не генерирует никакого кода для логирования. «Не используешь — не платишь» в чистом виде.
Еще один пример кастомизации за счет шаблонов продемонстрируем с использованием express-роутера, который есть в RESTinio. Express-роутер сделан в RESTinio по мотивам JavaScript-фреймворка Express. Использование express-роутера существенно упрощает работу с URL для выбора подходящего обработчика. Особенно, когда внутри URL «зашиты» нужные обработчику параметры.
Вот небольшой пример, который показывает, как посредством express-роутера вешать обработчики на GET-запросы вида /measure/:id и /measures/:year/:month/:day:
Для того, чтобы разбирать URL-ы из запросов, express-роутеру нужна какая-то реализация регулярных выражений. По умолчанию используется std::regex, но std::regex, на данный момент, к сожалению, не может похвастаться отличной производительностью. Например, PCRE/PCRE2 гораздо быстрее std::regex.
Поэтому в RESTinio можно задать другую реализацию регулярных выражений для express_router_t. Задать как? Правильно: через параметр шаблона. Например, для того, чтобы использовать PCRE2 вместо std::regex:
Причем внимательный читатель может заметить, что pcre2_regex_engine_t так же является шаблоном. В этот раз pcre2_regex_engine_t довольствуется дефолтными параметрами. Но мы можем легко это исправить…
pcre2_regex_engine_t параметризуется собственным классом свойств, специфических для PCRE2. В настоящий момент в свойствах для pcre2_regex_engine_t можно задать такие параметры как опции для компиляции регулярного выражения, опции для pcre2_match, а также такой важный параметр, как max_capture_groups. Этот параметр определяет максимальное количество извлекаемых из строки фрагментов. По умолчанию max_capture_groups равен 20, что означает, что pcre2_regex_engine_t сразу выделит место под 20 фрагментов. В нашем случае это слишком много, т.к. максимальное количество элементов в строках с URL для нашего короткого примера — три. Давайте сделаем настройки, специфические для нашего конкретного случая:
Выше уже были показаны примеры использования классов свойств (т.е. traits) для управления поведения тех или иных сущностей. Но вообще именно Traits определяют все поведение HTTP-сервера в RESTinio. Ибо под капотом у показанных выше функций restinio::run() скрывается создание экземпляра шаблонного класса restinio::http_server_t. И шаблонный параметр Traits как раз определяет параметры работы HTTP-сервера.
Если смотреть по большому сверху, то в Traits должны быть определены следующие имена типов:
timer_manager_t. Определяет тип, который будет использоваться HTTP-сервером для отсчета таймаутов, связанных с подключениями к серверу. В RESTinio по умолчанию используется asio_timer_manager_t, использующий штатный механизм таймеров Asio. Так же есть so_timer_manager_t, который использует механизм таймеров SObjectizer-а. Есть еще null_timer_manager_t, который вообще ничего не делает и который оказывается полезным для проведения бенчмарков.
logger_t. Определяет механизм логирования внутренней активности HTTP-сервера. По умолчанию используется null_logger_t, т.е. по умолчанию HTTP-сервер ничего не логирует. Есть штатная реализация очень простого логгера ostream_logger_t, полезная для отладки.
request_handler_t. Определяет тип обработчика HTTP-запросов. По умолчанию используется default_request_handler_t, что есть всего лишь std::function<request_handling_status_t(request_handle_t)>. Но пользователь может задать и другой тип, если этот тип предоставляет operator() с нужной сигнатурой. Например, express-роутер, о котором речь шла выше, определяет свой тип обработчика запросов, который нужно задать в качестве request_handler_t в Traits HTTP-сервера.
strand_t. Определяет тип т.н. strand-а для защиты Asio-шных потрохов при работе в многопоточном режиме. По умолчанию это asio::strand<asio::executor>, что позволяет безопасно запускать HTTP-сервер сразу на нескольких рабочих нитях. Например:
Если же HTTP-сервер работает в однопоточном режиме, то можно избежать дополнительных накладных расходов определив Traits::strand_t как restinio::noop_strand_t (что и делается в restinio::default_single_thread_traits_t).
stream_socket_t. Определяет тип сокета, с которым предстоит работать RESTinio. По умолчанию это asio::ip::tcp::socket. Но для работы с HTTPS этот параметр должен быть задан как restinio::tls_socket_t.
В общем, даже в своем ядре — центральном классе http_server_t — в RESTinio применяется policy based design на С++ных шаблонах. Поэтому неудивительно, что отголоски этого подхода обнаруживаются и во многих других частях RESTinio.
В заголовке статьи упомянуты трехэтажные шаблоны, но до сих пор речь шла лишь о том, как широко шаблоны используются в RESTinio. Примеров же самой трехэтажности пока не было. Нужно устранить это упущение ;)
Есть в C++ такая хитрая штука, как CRTP (что расшифровывается как Curiously recurring template pattern). Вот с помощью этой штуки в RESTinio реализована работа с параметрами сервера.
Перед тем, как запустить HTTP-сервер, ему нужно задать несколько обязательных параметров (+ еще можно задать несколько необязательных). Например, в этом примере задается порт и адрес, которые должен слушать HTTP-сервер, обработчик для запросов, а так же тайм-ауты для различных операций:
На самом деле здесь нет ничего особо сложного: функция on_this_thread конструирует и возвращает объект server_settings, который далее уже модифицируется посредством вызова методов-setter-ов.
Однако, говоря «нет ничего особо сложного» мы немного лукавим, поскольку on_this_thread возвращает экземпляр вот такого типа:
Т.е. мы уже видим уши CRTP. Но еще интереснее заглянуть в определение basic_server_settings_t:
Тут можно увидеть еще один шаблон, который используется в качестве базового типа. Сам по себе он ничего интересного не представляет:
Но зато его можно специализировать для различных сочетаний Settings и Socket. Например, для поддержки TLS:
И вот если все это сложить вместе, например, вот в такой ситуации:
То тут уж точно шаблон сидит на шаблоне и шаблоном погоняет. Что особенно хорошо становится заметно в сообщениях об ошибках компилятора, если где-то случайно опечатаешься…
Вряд ли мы ошибемся, если скажем, что отношение к C++ным шаблонам среди практикующих C++программистов очень разное: кто-то использует шаблоны повсеместно, кто-то время от времени, кто-то категорически против. Еще более неоднозначное отношение к С++ым шаблонам у завсегдатаев профильных форумов/ресурсов, особенно среди тех, кто профессионально разработкой на C++ не занимается, но мнение имеет. Поэтому наверняка у многих прочитавших статью возникнет вопрос: «А оно того стоило?»
По нашему мнению — да. Хотя нас, например, не сильно смущает время компиляции C++ного кода. Кстати говоря, у компиляции RESTinio+Asio вполне себе нормальная скорость. Это когда к этому добавляется еще и Catch2, вот тогда да, время компиляции увеличивается в разы. Да и сообщений об ошибках от C++ компилятора мы не боимся, тем более, что от года к году эти самые сообщения становятся все более и более вменяемыми.
В любом случае, на C++ программируют очень по-разному. И каждый может использовать тот стиль, который ему наиболее подходит. Начиная от оберток над чисто сишными библиотеками (вроде mongoose или civetweb) или C++ных библиотек, написанных в Java-подобном «Си с классами» (как это происходит, скажем, в POCO). И заканчивая активно использующими C++ные шаблоны CROW, Boost.Beast и RESTinio.
Мы вообще придерживаемся того мнения, что в современном мире, при наличии таких конкурентов, как Rust, Go, D и, не говоря уже про C# и Java, у С++ не так уж много серьезных и объективных достоинств. И C++ные шаблоны, пожалуй, одно из немногих конкурентных преимуществ C++, способное оправдать применение C++ в конкретной прикладной задаче. А раз так, то какой смысл отказываться от C++ных шаблонов или ограничивать себя в их использовании? Мы такого смысла не видим, поэтому и задействуем шаблоны в реализации RESTinio настолько активно, насколько это нам позволяет здравый смысл (ну или его отсутствие, тут уж с какой стороны посмотреть).
В итоге мы решили, что пора прекращать смотреть по сторонам и нужно попробовать сделать что-то свое, с
#include <restinio/all.hpp>
int main()
{
restinio::run(
restinio::on_this_thread()
.port(8080)
.address("localhost")
.request_handler([](auto req) {
return req->create_response().set_body("Hello, World!").done();
}));
return 0;
}
В реализации RESTinio активно используются C++ные шаблоны и об этом хотелось бы сегодня немного поговорить.
Буквально пара общих слов о RESTinio
RESTinio — это небольшой OpenSource проект, который распространяется под BSD-3-CLAUSE лицензией. RESTinio активно развивается с весны 2017-го года. За это время мы сделали несколько публичных релизов, постепенно наполняя RESTinio функциональностью. Самый свежий релиз состоялся сегодня. Это релиз версии 0.4, в которой мы, пожалуй, таки реализовали тот минимум функциональности, который мы хотели иметь.
RESTinio использует несколько сторонних компонентов. Для работы с сетью мы используем Asio (standalone версию Asio), для парсинга HTTP-протокола у нас используется http-parser из Node.js. Также внутри используется fmtlib, а для тестирования — библиотека Catch2.
Не смотря на то, что RESTinio пока еще не достиг версии 1.0, мы очень тщательно относимся к качеству и стабильности работы RESTinio. Например, наш коллега участвовал в Mail.ru-шном конкурсе HighloadCup с решением на базе RESTinio. Это решение вышло в финал с 45-го места и заняло в финале 44-е место. Могу ошибаться, но среди финалистов было всего два или три решения, которые строились на базе универсальных HTTP-фреймворков. Вот одним из них как раз и оказалось решение на базе RESTinio.
Вообще, если говорить о производительности, то скорость работы RESTinio не была приоритетом №1 при разработке. И хотя производительности мы уделяли внимание, тем не менее более важным для нас было получение решения, которым удобно пользоваться. При этом RESTinio не так уж плохо выглядит в синтетических бенчмарках.
Однако, в данной статье хотелось бы поговорить не столько о самой библиотеке RESTinio и ее возможностях (подробнее с этой информацией можно ознакомиться здесь). Сколько о том, как в ее реализации используется такая важная фича языка C++, как шаблоны.
Почему шаблоны?
Код RESTinio построен на шаблонах. Так, в показанном выше примере шаблонов не видно, хотя они там повсюду:
- функция restinio::run() шаблонная;
- функция restinio::on_this_thread() шаблонная;
- метод request_handler() так же шаблонный;
- и даже метод create_response() шаблонный.
Почему же RESTinio так активно использует шаблоны? Наверное, самыми серьезными были две следующих причины:
Во-первых, мы хотели, чтобы RESTinio могла кастомизироваться в широких пределах. Но чтобы кастомизация имела минимальную стоимость в run-time. Как нам кажется, шаблоны здесь просто вне конкуренции.
Во-вторых, кое-кого из нас, видимо, сильно покусал Александреску. И это до сих пор сказывается, хотя времени с тех пор прошло уже немало.
Ну и еще нам понравилось следствие из того, что изрядная часть RESTinio представляет из себя шаблонный код: библиотека получилась header-only. Так уж складывается, что в нынешнем C++ подключить header-only библиотеку к своему (или к чужому) проекту гораздо проще, чем ту, которую нужно компилировать. Таки зоопарк систем сборки и систем управления зависимостями в C++ доставляет. И header-only библиотеки в этих зоопарках чувствуют себя гораздо лучше. Пусть даже за это приходится платить увеличением времени компиляции, но это уже тема для совершенно другого разговора…
Кастомизация на шаблонах в простых примерах
Выше мы сказали, что шаблоны позволяют кастомизировать RESTinio. Давайте покажем, что под этим подразумевается на паре-тройке простых примеров.
Отдаем ответ в режиме chunked encoding
Выше уже было сказано, что метод create_response() является шаблонным. Этот метод параметризуется способом формирования HTTP-ответа. По умолчанию используется restinio_controlled_output_t. Этот метод самостоятельно вычисляет значение HTTP-заголовка Content-Length и инициирует запись ответа в сокет после того, как программист полностью создаст весь ответ и вызовет метод done().
Но RESTinio поддерживает еще несколько методов: user_controlled_output_t и chunked_output_t. Например, использование режима chunked_output_t будет выглядеть как-то так:
auto handler = [&](auto req) {
auto resp = req->create_response<restinio::chunked_output_t>();
resp
.append_header(restinio::http_field::server, "MyApp Embedded Server")
.append_header_date_field()
.append_header(restinio::http_field::content_type, "text/plain; charset=utf-8");
resp.flush(); // Запись подготовленных заголовков.
for(const auto & part : fragments) {
resp.append_chunk(make_chunk_from(part));
resp.flush(); // Запись очередной части ответа.
}
return resp.done(); // Завершение обработки.
};
Примечательно то, что create_response() возвращает объект response_builder_t<Output_Type>, публичный API которого зависит от Output_Type. Так, у response_builder_t<restinio_controlled_output_t> нет публичного метода flush(), а публичный метод set_content_length() есть только у response_builder_t<user_controlled_output_t>.
Включаем логирование
В самом начале статьи мы показали простейший однопоточный HTTP-сервер. Который работает как «черный ящик», без каких-либо отладочных печатей или диагностического логирования. Давайте сделаем так, чтобы запускаемый HTTP-сервер логировал все происходящие с ним действия на стандартный поток вывода. Для этого нам потребуется небольшой трюк с шаблонами:
#include <restinio/all.hpp>
int main()
{
struct my_traits : public restinio::default_single_thread_traits_t {
using logger_t = restinio::single_threaded_ostream_logger_t;
};
restinio::run(
restinio::on_this_thread<my_traits>()
.port(8080)
.address("localhost")
.request_handler([](auto req) {
return req->create_response().set_body("Hello, World!").done();
}));
return 0;
}
Что мы здесь сделали?
Мы определили собственный класс свойств (traits) для HTTP-сервера, в котором задали нужный нам тип логгера. Потом заставили RESTinio использовать этот класс свойств при конструировании HTTP-сервера внутри restinio::run(). В итоге внутри restino::run() создается HTTP-сервер, который логирует все события посредством логгера, который реализуется типом single_threaded_ostream_logger_t.
Если мы запустим модифицированный пример и выдадим простейший запрос к нашему серверу (вроде wget localhost:8080), то мы увидим что-то такое:
[2017-12-24 12:04:29.612] TRACE: starting server on 127.0.0.1:8080
[2017-12-24 12:04:29.612] INFO: init accept #0
[2017-12-24 12:04:29.612] INFO: server started on 127.0.0.1:8080
[2017-12-24 12:05:00.423] TRACE: accept connection from 127.0.0.1:45930 on socket #0
[2017-12-24 12:05:00.423] TRACE: [connection:1] start connection with 127.0.0.1:45930
[2017-12-24 12:05:00.423] TRACE: [connection:1] start waiting for request
[2017-12-24 12:05:00.423] TRACE: [connection:1] continue reading request
[2017-12-24 12:05:00.423] TRACE: [connection:1] received 141 bytes
[2017-12-24 12:05:00.423] TRACE: [connection:1] request received (#0): GET /
[2017-12-24 12:05:00.423] TRACE: [connection:1] append response (#0), flags: { final_parts, connection_keepalive }, bufs count: 2
[2017-12-24 12:05:00.423] TRACE: [connection:1] sending resp data, buf count: 2
[2017-12-24 12:05:00.423] TRACE: [connection:1] start waiting for request
[2017-12-24 12:05:00.423] TRACE: [connection:1] continue reading request
[2017-12-24 12:05:00.423] TRACE: [connection:1] outgoing data was sent: 76 bytes
[2017-12-24 12:05:00.423] TRACE: [connection:1] should keep alive
[2017-12-24 12:05:00.423] TRACE: [connection:1] start waiting for request
[2017-12-24 12:05:00.423] TRACE: [connection:1] continue reading request
[2017-12-24 12:05:00.424] TRACE: [connection:1] EOF and no request, close connection
[2017-12-24 12:05:00.424] TRACE: [connection:1] close
[2017-12-24 12:05:00.424] TRACE: [connection:1] destructor called
[2017-12-24 12:05:16.402] TRACE: closing server on 127.0.0.1:8080
[2017-12-24 12:05:16.402] INFO: server closed on 127.0.0.1:8080
Что мы сделали? По сути мы поправили один параметр в свойствах HTTP-сервера и получили дополнительную функциональность. Которой вообще не было в первом случае, когда мы использовали дефолтные свойства для HTTP-сервера. Причем под «вообще» мы понимаем именно «вообще». Поясним на примере.
В коде RESTinio разбросано логирование выполняемых сервером операций. Вот, скажем:
void close_impl()
{
const auto ep = m_acceptor.local_endpoint();
m_logger.trace( [&]{
return fmt::format( "closing server on {}", ep );
} );
m_acceptor.close();
m_logger.info( [&]{
return fmt::format( "server closed on {}", ep );
} );
}
Идет обращение к логгеру с передачей лямбда-функции, отвечающей за формирование сообщения для лога. Но если в качестве логгера используется restinio::null_logger_t (а это и происходит по умолчанию), то в null_logger_t методы trace(), info() и им подобные просто ничего не делают:
class null_logger_t
{
public:
template< typename Message_Builder >
constexpr void trace( Message_Builder && ) const {}
template< typename Message_Builder >
constexpr void info( Message_Builder && ) const {}
template< typename Message_Builder >
constexpr void warn( Message_Builder && ) const {}
...
Поэтому нормальный компилятор просто выбрасывает все обращения к логгеру и не генерирует никакого кода для логирования. «Не используешь — не платишь» в чистом виде.
Выбираем regex-engine для express-роутера
Еще один пример кастомизации за счет шаблонов продемонстрируем с использованием express-роутера, который есть в RESTinio. Express-роутер сделан в RESTinio по мотивам JavaScript-фреймворка Express. Использование express-роутера существенно упрощает работу с URL для выбора подходящего обработчика. Особенно, когда внутри URL «зашиты» нужные обработчику параметры.
Вот небольшой пример, который показывает, как посредством express-роутера вешать обработчики на GET-запросы вида /measure/:id и /measures/:year/:month/:day:
#include <restinio/all.hpp>
using my_router_t = restinio::router::express_router_t<>;
auto make_request_handler()
{
auto router = std::make_unique<my_router_t>();
router->http_get(R"(/measure/:id(\d+))",
[](auto req, auto params) {
return req->create_response()
.set_body(
fmt::format("Measure with id={} requested",
restinio::cast_to<unsigned long>(params["id"])))
.done();
});
router->http_get(R"(/measures/:year(\d{4})/:month(\d{2})/:day(\d{2}))",
[](auto req, auto params) {
return req->create_response()
.set_body(
fmt::format("Request measures for a date: {}.{}.{}",
restinio::cast_to<int>(params["year"]),
restinio::cast_to<short>(params["month"]),
restinio::cast_to<short>(params["day"])))
.done();
});
router->non_matched_request_handler([](auto req) {
return req->create_response(404, "Unknown request")
.connection_close()
.done();
});
return router;
}
int main()
{
struct my_traits : public restinio::default_single_thread_traits_t {
using request_handler_t = my_router_t;
};
restinio::run(
restinio::on_this_thread<my_traits>()
.port(8080)
.address("localhost")
.request_handler(make_request_handler()));
return 0;
}
Для того, чтобы разбирать URL-ы из запросов, express-роутеру нужна какая-то реализация регулярных выражений. По умолчанию используется std::regex, но std::regex, на данный момент, к сожалению, не может похвастаться отличной производительностью. Например, PCRE/PCRE2 гораздо быстрее std::regex.
Поэтому в RESTinio можно задать другую реализацию регулярных выражений для express_router_t. Задать как? Правильно: через параметр шаблона. Например, для того, чтобы использовать PCRE2 вместо std::regex:
#include <restinio/all.hpp>
#include <restinio/router/pcre2_regex_engine.hpp>
using my_router_t = restinio::router::express_router_t<
restinio::router::pcre2_regex_engine_t<>>;
Причем внимательный читатель может заметить, что pcre2_regex_engine_t так же является шаблоном. В этот раз pcre2_regex_engine_t довольствуется дефолтными параметрами. Но мы можем легко это исправить…
pcre2_regex_engine_t параметризуется собственным классом свойств, специфических для PCRE2. В настоящий момент в свойствах для pcre2_regex_engine_t можно задать такие параметры как опции для компиляции регулярного выражения, опции для pcre2_match, а также такой важный параметр, как max_capture_groups. Этот параметр определяет максимальное количество извлекаемых из строки фрагментов. По умолчанию max_capture_groups равен 20, что означает, что pcre2_regex_engine_t сразу выделит место под 20 фрагментов. В нашем случае это слишком много, т.к. максимальное количество элементов в строках с URL для нашего короткого примера — три. Давайте сделаем настройки, специфические для нашего конкретного случая:
#include <restinio/all.hpp>
#include <restinio/router/pcre2_regex_engine.hpp>
struct my_pcre2_traits : public restinio::router::pcre2_traits_t<> {
static constexpr int max_capture_groups = 4; // +1 для всей строки с URL.
};
using my_router_t = restinio::router::express_router_t<
restinio::router::pcre2_regex_engine_t<my_pcre2_traits>>;
И еще про Traits
Выше уже были показаны примеры использования классов свойств (т.е. traits) для управления поведения тех или иных сущностей. Но вообще именно Traits определяют все поведение HTTP-сервера в RESTinio. Ибо под капотом у показанных выше функций restinio::run() скрывается создание экземпляра шаблонного класса restinio::http_server_t. И шаблонный параметр Traits как раз определяет параметры работы HTTP-сервера.
Если смотреть по большому сверху, то в Traits должны быть определены следующие имена типов:
timer_manager_t. Определяет тип, который будет использоваться HTTP-сервером для отсчета таймаутов, связанных с подключениями к серверу. В RESTinio по умолчанию используется asio_timer_manager_t, использующий штатный механизм таймеров Asio. Так же есть so_timer_manager_t, который использует механизм таймеров SObjectizer-а. Есть еще null_timer_manager_t, который вообще ничего не делает и который оказывается полезным для проведения бенчмарков.
logger_t. Определяет механизм логирования внутренней активности HTTP-сервера. По умолчанию используется null_logger_t, т.е. по умолчанию HTTP-сервер ничего не логирует. Есть штатная реализация очень простого логгера ostream_logger_t, полезная для отладки.
request_handler_t. Определяет тип обработчика HTTP-запросов. По умолчанию используется default_request_handler_t, что есть всего лишь std::function<request_handling_status_t(request_handle_t)>. Но пользователь может задать и другой тип, если этот тип предоставляет operator() с нужной сигнатурой. Например, express-роутер, о котором речь шла выше, определяет свой тип обработчика запросов, который нужно задать в качестве request_handler_t в Traits HTTP-сервера.
strand_t. Определяет тип т.н. strand-а для защиты Asio-шных потрохов при работе в многопоточном режиме. По умолчанию это asio::strand<asio::executor>, что позволяет безопасно запускать HTTP-сервер сразу на нескольких рабочих нитях. Например:
restinio::run(
restinio::on_thread_pool(std::thread::hardware_concurrency())
.port(8080)
.address("localhost")
.request_handler(make_request_handler()));
Если же HTTP-сервер работает в однопоточном режиме, то можно избежать дополнительных накладных расходов определив Traits::strand_t как restinio::noop_strand_t (что и делается в restinio::default_single_thread_traits_t).
stream_socket_t. Определяет тип сокета, с которым предстоит работать RESTinio. По умолчанию это asio::ip::tcp::socket. Но для работы с HTTPS этот параметр должен быть задан как restinio::tls_socket_t.
В общем, даже в своем ядре — центральном классе http_server_t — в RESTinio применяется policy based design на С++ных шаблонах. Поэтому неудивительно, что отголоски этого подхода обнаруживаются и во многих других частях RESTinio.
Ну и какая же трехэтажность без CRTP?
В заголовке статьи упомянуты трехэтажные шаблоны, но до сих пор речь шла лишь о том, как широко шаблоны используются в RESTinio. Примеров же самой трехэтажности пока не было. Нужно устранить это упущение ;)
Есть в C++ такая хитрая штука, как CRTP (что расшифровывается как Curiously recurring template pattern). Вот с помощью этой штуки в RESTinio реализована работа с параметрами сервера.
Перед тем, как запустить HTTP-сервер, ему нужно задать несколько обязательных параметров (+ еще можно задать несколько необязательных). Например, в этом примере задается порт и адрес, которые должен слушать HTTP-сервер, обработчик для запросов, а так же тайм-ауты для различных операций:
restinio::run(
restinio::on_this_thread()
.port(8080)
.address("localhost")
.request_handler(server_handler())
.read_next_http_message_timelimit(10s)
.write_http_response_timelimit(1s)
.handle_request_timeout(1s));
На самом деле здесь нет ничего особо сложного: функция on_this_thread конструирует и возвращает объект server_settings, который далее уже модифицируется посредством вызова методов-setter-ов.
Однако, говоря «нет ничего особо сложного» мы немного лукавим, поскольку on_this_thread возвращает экземпляр вот такого типа:
template<typename Traits>
class run_on_this_thread_settings_t final
: public basic_server_settings_t<run_on_this_thread_settings_t<Traits>, Traits>
{
using base_type_t = basic_server_settings_t<
run_on_this_thread_settings_t<Traits>, Traits>;
public:
using base_type_t::base_type_t;
};
Т.е. мы уже видим уши CRTP. Но еще интереснее заглянуть в определение basic_server_settings_t:
template<typename Derived, typename Traits>
class basic_server_settings_t
: public socket_type_dependent_settings_t<Derived, typename Traits::stream_socket_t>
{
...
};
Тут можно увидеть еще один шаблон, который используется в качестве базового типа. Сам по себе он ничего интересного не представляет:
template <typename Settings, typename Socket>
class socket_type_dependent_settings_t
{
protected :
~socket_type_dependent_settings_t() = default;
};
Но зато его можно специализировать для различных сочетаний Settings и Socket. Например, для поддержки TLS:
template<typename Settings>
class socket_type_dependent_settings_t<Settings, tls_socket_t>
{
protected:
~socket_type_dependent_settings_t() = default;
public:
socket_type_dependent_settings_t() = default;
socket_type_dependent_settings_t(socket_type_dependent_settings_t && ) = default;
Settings & tls_context(asio::ssl::context context ) & {...}
Settings && tls_context(asio::ssl::context context ) && {...}
asio::ssl::context tls_context() {...}
...
};
И вот если все это сложить вместе, например, вот в такой ситуации:
struct my_pcre2_traits : public restinio::router::pcre2_traits_t<> {
static constexpr int max_capture_groups = 4;
};
using my_router_t = restinio::router::express_router_t<
restinio::router::pcre2_regex_engine_t<my_pcre2_traits>>;
using my_traits_t = restinio::single_thread_tls_traits_t<
restinio::asio_timer_manager_t,
restinio::single_threaded_ostream_logger_t,
my_router_t>;
...
restinio::run(
restinio::on_this_thread<my_traits_t>()
.address("localhost")
.request_handler(server_handler())
.read_next_http_message_timelimit(10s)
.write_http_response_timelimit(1s)
.handle_request_timeout(1s)
.tls_context(std::move(tls_context)));
То тут уж точно шаблон сидит на шаблоне и шаблоном погоняет. Что особенно хорошо становится заметно в сообщениях об ошибках компилятора, если где-то случайно опечатаешься…
Заключение
Вряд ли мы ошибемся, если скажем, что отношение к C++ным шаблонам среди практикующих C++программистов очень разное: кто-то использует шаблоны повсеместно, кто-то время от времени, кто-то категорически против. Еще более неоднозначное отношение к С++ым шаблонам у завсегдатаев профильных форумов/ресурсов, особенно среди тех, кто профессионально разработкой на C++ не занимается, но мнение имеет. Поэтому наверняка у многих прочитавших статью возникнет вопрос: «А оно того стоило?»
По нашему мнению — да. Хотя нас, например, не сильно смущает время компиляции C++ного кода. Кстати говоря, у компиляции RESTinio+Asio вполне себе нормальная скорость. Это когда к этому добавляется еще и Catch2, вот тогда да, время компиляции увеличивается в разы. Да и сообщений об ошибках от C++ компилятора мы не боимся, тем более, что от года к году эти самые сообщения становятся все более и более вменяемыми.
В любом случае, на C++ программируют очень по-разному. И каждый может использовать тот стиль, который ему наиболее подходит. Начиная от оберток над чисто сишными библиотеками (вроде mongoose или civetweb) или C++ных библиотек, написанных в Java-подобном «Си с классами» (как это происходит, скажем, в POCO). И заканчивая активно использующими C++ные шаблоны CROW, Boost.Beast и RESTinio.
Мы вообще придерживаемся того мнения, что в современном мире, при наличии таких конкурентов, как Rust, Go, D и, не говоря уже про C# и Java, у С++ не так уж много серьезных и объективных достоинств. И C++ные шаблоны, пожалуй, одно из немногих конкурентных преимуществ C++, способное оправдать применение C++ в конкретной прикладной задаче. А раз так, то какой смысл отказываться от C++ных шаблонов или ограничивать себя в их использовании? Мы такого смысла не видим, поэтому и задействуем шаблоны в реализации RESTinio настолько активно, насколько это нам позволяет здравый смысл (ну или его отсутствие, тут уж с какой стороны посмотреть).
Комментарии (13)
pavelgeme2
29.12.2017 11:13append_header(restinio::http_field::content_type, "test/plain; charset=utf-8");
test/plain, а не text/plain это опечатка?
EvilsInterrupt
29.12.2017 12:09>>И нам время от времени приходилось создавать HTTP-точки входа в C++ компоненты.
А не дешевле было написать Node.JS плагин и потом обвязку на JavaScript?
Т.е. хочется знать, почему решили именно в плюсовом коде работать с сетью?eao197 Автор
29.12.2017 12:36А не дешевле было написать Node.JS плагин и потом обвязку на JavaScript?
AFAIK, это выгодно делать в случае, когда на C++ пишется какой-то «тяжелый» вычислительный код, необходимый Web-приложению. Но даже и в таких ситуациях не всем нравится сопрягать Node.JS и C++.
Т.е. хочется знать, почему решили именно в плюсовом коде работать с сетью?
Чаще всего это были задачи, когда уже есть работающий C++ компонент, к которому нужно присобачить HTTP-вход для того, чтобы этот компонент научился работать с внешним миром по какому-нибудь REST-у или XML-RPC. Тут (в наших условиях) было бы проще встроить http-вход прямо в C++ный компонент, нежели приделывать сбоку что-то вроде Node.JS.
ToSHiC
29.12.2017 13:55+1Во-вторых, кое-кого из нас, видимо, сильно покусал Александреску. И это до сих пор сказывается, хотя времени с тех пор прошло уже немало.
Если посмотреть на эту фотографию, то неудивительно, что он мог покусать:
EvilsInterrupt
29.12.2017 16:07В вашем репозитории не нашел cmake-finder-а для вашей библиотеки. Общепринятым подходом является поставлять либу с Finde.cmake скриптом. Рекомендую добавить его
eao197 Автор
29.12.2017 16:15У нас CMake не основная система сборки, так что косяки в поддержке CMake возможны.
Видимо, поддержку поиска RESTinio через CMake мы добавим когда будем опакечивать RESTinio для vcpkg и conan-а.
dukei
Шаблоны — это круто, и позволяют существенно меньше кода писать в некоторых случаях, экономя усилия по сравнению с другими языками. Но использование шаблонов только ради шаблонов работает наоборот — кода больше, размер генерируемого кода больше, читаемость хуже. Именно из-за этого столько холиваров, и всякие радикалы предлагают обходиться без шаблонов вообще.
В http клиенте все же лучше без шаблонов обходиться и не дискредитировать этот отличный инструмент языка. Вы это даже сами понимаете, судя по вашим осторожным оговоркам. Имхо.
NeoCode
А какие кстати есть хорошие реализации http серверов на С++? Или даже не http серверов, а CGI/FastCGI фреймворков, которые работают через штатные apache или nginx, установленные на сервере?
eao197 Автор
pavard
cppcms — одно время игрался — очень интересная штука — за вечер сайт с регистрацией и блогом ( с комментариями, модерацией, тегами и прочими мелочами ) сделал. также kore — не игрался, но по документации вполне мощный аппарат.
aensidhe
Я два года назад искал, всё могло поменяться. На тот момент был proxygen от facebook и сервер от микрософта, забыл название.
eao197 Автор
proxygen, AFAIK, это фреймворк с полноценной реализацией встраиваемого http-сервера.
Сервер от Микрософта — это, скорее всего, C++ REST SDK. Опять же, это полноценный встраиваемый http-сервер. Но с никакой (на данный момент) производительностью под Unix-ами (это вроде как даже сами разработчики в обсуждениях на reddit-е подтверждали).