
Recientemente, pasé a trabajar en una aplicación que debía controlar la velocidad de sus conexiones salientes. Por ejemplo, al conectarse a una URL, la aplicación debería limitarse a, digamos, 200KiB / seg. Y conectarse a otra URL: solo 30 KB / seg.
El punto más interesante aquí fue probar estas mismas limitaciones. Necesitaba un servidor HTTP que ofreciera tráfico a una velocidad determinada, por ejemplo, 512 KB / seg. Entonces pude ver si la aplicación realmente soporta la velocidad de 200 KB / seg o si se descompone a velocidades más altas.
Pero, ¿dónde conseguir un servidor HTTP?
Como tengo algo que ver con el servidor HTTP RESTinio incrustado en aplicaciones C ++, no se me ocurrió nada mejor que lanzar rápidamente un simple servidor de prueba HTTP en mi rodilla que puede enviar una larga secuencia de datos salientes al cliente.
Sobre lo simple que sería y me gustaría contarlo en el artículo. Al mismo tiempo, descubra en los comentarios si esto es realmente simple o si me estoy engañando a mí mismo. En principio, este artículo puede considerarse como una continuación del artículo anterior sobre RESTinio llamado "RESTinio es un servidor HTTP asíncrono. Asíncrono" . Por lo tanto, si alguien está interesado en leer sobre la aplicación real, aunque no muy seria, de RESTinio, entonces puede invitar a cat.
Idea general
La idea general del servidor de prueba mencionado anteriormente es muy simple: cuando el cliente se conecta al servidor y realiza una solicitud HTTP GET, se activa un temporizador que se ejecuta una vez por segundo. Cuando se activa el temporizador, el siguiente bloque de datos de un tamaño determinado se envía al cliente.
Pero todo es un poco más complicado.
Si el cliente lee los datos a un ritmo más lento que el que envía el servidor, no es una buena idea enviar N kilobytes por segundo. Dado que los datos comenzarán a acumularse en el zócalo y esto no conducirá a nada bueno.
Por lo tanto, al enviar datos, es aconsejable controlar la disponibilidad del socket para escribir en el lado del servidor HTTP. Mientras el socket esté listo (es decir, aún no se hayan acumulado demasiados datos), puede enviar una nueva porción. Pero si no está listo, debe esperar hasta que el socket entre en un estado de preparación para la grabación.
Parece razonable, pero las operaciones de E / S están ocultas en los menudillos de RESTinio ... ¿Cómo puedo saber si se puede escribir o no el siguiente dato?
Puede salir de esta situación si utiliza notificadores de escritura posterior , que están en RESTinio. Por ejemplo, podemos escribir esto:
void request_handler(restinio::request_handle_t req) { req->create_response()
Se llamará al lambda pasado al método done()
cuando RESTinio termine de escribir datos salientes. En consecuencia, si el socket no estuvo listo para grabar durante algún tiempo, entonces no se llamará a lambda de inmediato, sino después de que el socket llegue a su estado correcto y acepte todos los datos salientes.
Debido al uso de notificaciones posteriores a la escritura, la lógica del servidor de prueba será la siguiente:
- envíe el siguiente lote de datos, calcule el tiempo en que tendríamos que enviar el siguiente lote en el curso normal de los eventos;
- colgamos después de escribir el notificador en la siguiente porción de datos;
- cuando se llama a la notificación posterior a la escritura, verificamos si ha llegado el siguiente lote. Si es así, inicie inmediatamente el envío de la siguiente porción. Si no es así, entonces activa el temporizador.
Como resultado, resulta que tan pronto como la grabación comience a disminuir, el envío de nuevos datos se detendrá. Y reanude cuando el socket esté listo para aceptar nuevos datos salientes.
Y un poco más complicado: chunked_output
RESTinio admite tres formas de generar una respuesta a una solicitud HTTP . El método más simple, que se usa por defecto, no es adecuado en este caso, porque Necesito un flujo casi infinito de datos salientes. Y, por supuesto, dicha secuencia no puede set_body
a una sola llamada al método set_body
.
Por lo tanto, el servidor de prueba descrito utiliza el llamado chunked_output . Es decir Al crear una respuesta, le indico a RESTinio que la respuesta se formará en partes. Luego, periódicamente, llamo a los métodos append_chunk
para agregar la siguiente parte a la respuesta y flush
para escribir las partes acumuladas en el zócalo.
¡Y veamos el código!
Quizás sea suficiente que las palabras iniciales sean suficientes y es hora de pasar al código en sí, que se puede encontrar en este repositorio . Comencemos con la función request_processor
, que se llama para procesar cada solicitud HTTP válida. Al mismo tiempo, profundicemos en las funciones que se request_processor
desde request_processor
. Bueno, entonces veremos cómo se asigna exactamente request_processor
a una u otra solicitud HTTP entrante.
Función Request_processor y sus ayudantes
Se llama a la función request_processor
para procesar las solicitudes HTTP GET que necesito. Se pasa como argumentos:
- Asio-shny io_context en el que se realiza todo el trabajo (se requerirá, por ejemplo, para temporizadores de armado);
- El tamaño de una parte de la respuesta. Es decir si necesito dar un flujo saliente a una velocidad de 512 KB / seg, entonces el valor 512 KB se pasará como este parámetro;
- número de partes en respuesta. En caso de que la secuencia tenga una longitud limitada. Por ejemplo, si desea dar una transmisión a una velocidad de 512 KB / s durante 5 minutos, el valor 300 se pasará como este parámetro (60 bloques por minuto durante 5 minutos);
- Bueno, la solicitud entrante en sí para su procesamiento.
Dentro de request_processor
, se crea un objeto con información sobre la solicitud y sus parámetros de procesamiento, después de lo cual comienza este mismo procesamiento:
void request_processor( asio_ns::io_context & ctx, std::size_t chunk_size, std::size_t count, restinio::request_handle_t req) { auto data = std::make_shared<response_data>( ctx, chunk_size, req->create_response<output_t>(), count); data->response_ .append_header(restinio::http_field::server, "RESTinio") .append_header_date_field() .append_header( restinio::http_field::content_type, "text/plain; charset=utf-8") .flush(); send_next_portion(data); }
El tipo de datos de response_data
, que contiene todos los parámetros relacionados con la solicitud, se ve así:
struct response_data { asio_ns::io_context & io_ctx_; std::size_t chunk_size_; response_t response_; std::size_t counter_; response_data( asio_ns::io_context & io_ctx, std::size_t chunk_size, response_t response, std::size_t counter) : io_ctx_{io_ctx} , chunk_size_{chunk_size} , response_{std::move(response)} , counter_{counter} {} };
Cabe señalar aquí que una de las razones para la aparición de la estructura response_data
es que un objeto de tipo restinio::response_builder_t<restinio::chunked_output_t>
(es decir, este tipo está oculto detrás del alias corto response_t
) es un tipo movible, pero no un tipo copiable (por analogías con std::unique_ptr
). Por lo tanto, este objeto no solo puede capturarse en una función lambda, que luego se envuelve en std::function
. Pero si coloca el objeto de respuesta en una instancia de response_data
creada dinámicamente, el puntero inteligente a la instancia de reponse_data
ya puede capturarse en funciones lambda sin problemas, y luego guarde este lambda en std::function
.
Función Send_next_portion
La función send_next_portion
llama cada vez que es necesario enviar la siguiente parte de la respuesta al cliente. No sucede nada complicado, por lo que parece bastante simple y conciso:
void send_next_portion(response_data_shptr data) { data->response_.append_chunk(make_buffer(data->chunk_size_)); if(1u == data->counter_) { data->response_.flush(); data->response_.done(); } else { data->counter_ -= 1u; data->response_.flush(make_done_handler(data)); } }
Es decir enviar la siguiente parte Y, si esta parte fue la última, entonces completamos el procesamiento de la solicitud. Y si no es lo último, se envía un flush
al método flush
, que es creado, quizás, por la función más compleja de este ejemplo.
Función make_done_handler
La función make_done_handler
responsable de crear una lambda que se pasará a RESTinio como un notificador posterior a la escritura. Este notificador debe verificar si la grabación de la siguiente parte de la respuesta se ha completado con éxito. En caso afirmativo, debe averiguar si la siguiente parte debe enviarse de inmediato (es decir, hubo "frenos" en el zócalo y la tasa de envío no puede mantenerse), o después de una pausa. Si necesita una pausa, se proporciona a través de un temporizador de armado.
En general, acciones simples, pero el código produce lambda dentro de la lambda, lo que puede confundir a las personas que no están acostumbradas al C ++ "moderno". Que no es tan pocos años para ser llamado moderno;)
auto make_done_handler(response_data_shptr data) { const auto next_timepoint = steady_clock::now() + 1s; return [=](const auto & ec) { if(!ec) { const auto now = steady_clock::now(); if(now < next_timepoint) { auto timer = std::make_shared<asio_ns::steady_timer>(data->io_ctx_); timer->expires_after(next_timepoint - now); timer->async_wait([timer, data](const auto & ec) { if(!ec) send_next_portion(data); }); } else data->io_ctx_.post([data] { send_next_portion(data); }); } }; }
En mi opinión, la principal dificultad en este código proviene de las peculiaridades de la creación y el pelotón de temporizadores en Asio. En mi opinión, resulta de alguna manera demasiado detallado. Pero realmente hay, eso es. Pero no necesita atraer bibliotecas adicionales.
Conexión de un enrutador tipo express
send_next_portion
, send_next_portion
y make_done_handler
muestran arriba en send_next_portion
conformaron la primera versión de mi servidor de prueba, escrita literalmente en 15 o 20 minutos.
Pero después de un par de días de usar este servidor de prueba, resultó que había un serio inconveniente: siempre devolvía el flujo de respuesta a la misma velocidad. Compilado a una velocidad de 512 KB / s: proporciona todos los 512 KB / s. Recompilado a una velocidad de 20KiB / seg: le dará a todos 20KiB / seg y nada más. Lo que era inconveniente, porque se hizo necesario poder recibir respuestas de diferente "grosor".
Entonces surgió la idea: ¿qué pasa si la velocidad de retorno se solicita directamente en la URL? Por ejemplo, hicieron una solicitud a localhost:8080/
y recibieron una respuesta a una velocidad predeterminada. Y si realizó una solicitud a localhost:8080/128K
, entonces comenzaron a recibir una respuesta a una velocidad de 128KiB / seg.
Luego, la idea fue aún más lejos: en la URL también puede especificar el número de partes individuales en la respuesta. Es decir solicitud de localhost:8080/128K/3000
producirá un flujo de 3000 partes a una velocidad de 128KiB / seg.
No hay problema RESTinio tiene la capacidad de utilizar un enrutador de consultas creado bajo la influencia de ExpressJS . Como resultado, existía dicha función para describir los controladores para las solicitudes HTTP entrantes:
auto make_router(asio_ns::io_context & ctx) { auto router = std::make_unique<router_t>(); router->http_get("/", [&ctx](auto req, auto) { request_processor(ctx, 100u*1024u, 10000u, std::move(req)); return restinio::request_accepted(); }); router->http_get( R"(/:value(\d+):multiplier([MmKkBb]?))", [&ctx](auto req, auto params) { const auto chunk_size = extract_chunk_size(params); if(0u != chunk_size) { request_processor(ctx, chunk_size, 10000u, std::move(req)); return restinio::request_accepted(); } else return restinio::request_rejected(); }); router->http_get( R"(/:value(\d+):multiplier([MmKkBb]?)/:count(\d+))", [&ctx](auto req, auto params) { const auto chunk_size = extract_chunk_size(params); const auto count = restinio::cast_to<std::size_t>(params["count"]); if(0u != chunk_size && 0u != count) { request_processor(ctx, chunk_size, count, std::move(req)); return restinio::request_accepted(); } else return restinio::request_rejected(); }); return router; }
Aquí, los controladores de solicitudes HTTP GET se forman para tres tipos de URL:
- de la forma
http://localhost/
; - de la forma
http://localhost/<speed>[<U>]/
; - de la forma
http://localhost/<speed>[<U>]/<count>/
Donde la speed
es un número que define la velocidad, y U
es un multiplicador opcional que indica en qué unidades se establece la velocidad. Entonces 128
o 128b
significa una velocidad de 128 bytes por segundo. Y 128k
son 128 kilobytes por segundo.
Cada URL tiene su propia función lambda, que comprende los parámetros recibidos, si todo está bien, llama a la función request_processor
que se muestra arriba.
La función auxiliar extract_chunk_size
siguiente:
std::size_t extract_chunk_size(const restinio::router::route_params_t & params) { const auto multiplier = [](const auto sv) noexcept -> std::size_t { if(sv.empty() || "B" == sv || "b" == sv) return 1u; else if("K" == sv || "k" == sv) return 1024u; else return 1024u*1024u; }; return restinio::cast_to<std::size_t>(params["value"]) * multiplier(params["multiplier"]); }
Aquí, C ++ lambda se usa para emular funciones locales de otros lenguajes de programación.
Función principal
Queda por ver cómo funciona todo esto en la función principal:
using router_t = restinio::router::express_router_t<>; ... int main() { struct traits_t : public restinio::default_single_thread_traits_t { using logger_t = restinio::single_threaded_ostream_logger_t; using request_handler_t = router_t; }; asio_ns::io_context io_ctx; restinio::run( io_ctx, restinio::on_this_thread<traits_t>() .port(8080) .address("localhost") .write_http_response_timelimit(60s) .request_handler(make_router(io_ctx))); return 0; }
¿Qué está pasando aquí?
- Como no necesito un enrutador de solicitudes normal (que no puede hacer nada y pone todo el trabajo sobre los hombros del programador), defino nuevas propiedades para mi servidor HTTP. Para hacer esto, tomo las propiedades estándar de un servidor HTTP de subproceso único (escriba
restinio::default_single_thread_traits_t
) e indico que se utilizará una instancia de enrutador tipo express como controlador de solicitudes. Al mismo tiempo, para controlar lo que está sucediendo en el interior, indico que el servidor HTTP utiliza un registrador real (de forma predeterminada, null_logger_t
utiliza null_logger_t
que no registra nada). - Como necesito activar los temporizadores dentro de los notificadores de escritura posterior, necesito una instancia io_context con la que pueda trabajar. Por lo tanto, lo creo yo mismo. Esto me da la oportunidad de pasar un enlace a mi io_context en la función
make_router
. - Solo queda iniciar el servidor HTTP en una versión de subproceso único en el io_context que creé anteriormente. La función
restinio::run
devolverá el control solo cuando el servidor HTTP termine su trabajo.
Conclusión
El artículo no mostraba el código completo de mi servidor de prueba, solo sus puntos principales. El código completo, que es ligeramente más grande debido a los tipos de letra adicionales y las funciones auxiliares, es algo más auténtico. Puedes verlo aquí . Al momento de escribir, esto es 185 líneas, incluyendo líneas en blanco y comentarios. Bueno, estas 185 líneas están escritas en un par de enfoques con una duración total de apenas más de una hora.
Me gustó este resultado y la tarea fue interesante. En términos prácticos, la herramienta auxiliar que necesitaba se obtuvo rápidamente. Y en términos del desarrollo posterior de RESTinio, aparecieron algunos pensamientos.
En general, si alguien más no ha probado RESTinio, entonces lo invito a que lo intente. El proyecto en sí mismo vive en GitHub . Puede hacer una pregunta o expresar sus sugerencias en el grupo de Google o aquí mismo en los comentarios.