Video Summary and Transcription
Hola y bienvenidos al Congreso de Node 2024. NearForm se enfoca en ofrecer soluciones modernas y elegantes. Milo es un nuevo analizador HTTP escrito en Rust, diseñado para abordar la complejidad y las vulnerabilidades del analizador HTTP actual de Node. Milo permite a los desarrolladores optar por copiar los datos que se están analizando para mejorar la experiencia del desarrollador. Sigue estrictamente las últimas RFC para HTTP y proporciona una interfaz común en diferentes lenguajes. Se está explorando la integración de Milo con C++ y WebAssembly, y los próximos pasos incluyen mejoras de rendimiento y pruebas de regresión.
1. Introducción a Node Congress 2024
Hola y bienvenidos a Node Congress 2024. NearForm se enfoca en ofrecer soluciones modernas y elegantes. Paolo se presenta y habla sobre las versiones de HTTP. Node tiene implementaciones estables para HTTP 1 y 2, y están trabajando en HTTP 3.
Hola y bienvenidos a Node Congress 2024. Este es Milo, un nuevo analizador HTTP para Node.js.
En primer lugar, permítanme presentar NearForm. Somos una empresa de servicios profesionales que se enfoca en ofrecer las soluciones más modernas, eficientes y elegantes a nuestros socios digitales. Estamos activos en varios países del mundo y siempre estamos buscando nuevos talentos, así que por favor apliquen.
A veces, ser imprudente tiene sus recompensas. ¿Por qué es eso? Permítanme demostrárselo. En primer lugar, quiero presentarme. Hola de nuevo, soy Paolo. Soy miembro del Comité Técnico de Dirección de Node y Ingeniero de Desarrollo en NearForm. Pueden encontrarme en línea al final de la diapositiva que pueden ver. Y también a la derecha pueden ver de dónde vengo. Vengo de Campobasso en Italia, en la región más pequeña que es Molise, que el resto de Italia finge que no existe. Pero es su pérdida, no la mía. Continúen.
Todos amamos HTTP. ¿Por qué es eso? Porque es el protocolo más extendido y utilizado en todo el mundo. ¿Cuál versión eres tú? Bueno, resulta que a pesar de tener 30 años, solo existen tres versiones de HTTP en realidad. Dos solo fueron borradores, 09 y 10, así que no las considero como versiones existentes. Las que llegaron a ser la versión final son 11, 2 y 3. 11 es, con mucho, la más utilizada, es la histórica, es la que probablemente también conocen y aún está vigente y no irá a ninguna parte en el corto plazo. 20 fue creada para abordar algunos de los problemas del socket TCP utilizando el protocolo speedy. Sin embargo, los resultados no fueron realmente exitosos. Ahora también tenemos la 3, que en cambio utiliza QUIC, que utiliza UDP, lo que complica las cosas, especialmente para los administradores del sistema. Lo siento por ustedes, de verdad.
¿Y qué hay de Node? Node tiene una implementación estable para HTTP 1 y HTTP 2. En ese caso, están listos para comenzar. En cuanto a HTTP 3, aún no hemos llegado del todo. Todavía estamos trabajando en la implementación de QUIC, pero llegaremos allí. Es una promesa.
2. Análisis de HTTP e Introducción a Milo
Ahora centrémonos en el tema de esta charla, que es el análisis de HTTP. El analizador actual de HTTP en Node se llama LLHTTP y fue escrito por Fedor Indutny en 2019. Es el predeterminado desde Node 12 y funciona de manera brillante. LLHTTP es compatible con versiones anteriores de HTTP 09 y 10, lo que trae consigo una complejidad y vulnerabilidades innecesarias. Para abordar estos problemas, se desarrolló Milo como solución. Milo está escrito en Rust, un lenguaje flexible y de alto rendimiento. La elección de Rust fue deliberada para explorar su potencial para contribuir a Node con código en Rust.
Ahora centrémonos en el tema de esta charla, que es el análisis de HTTP. ¿Cuál es el analizador actual de HTTP en Node en la actualidad? Se llama LLHTTP. Fue escrito por Fedor Indutny en 2019 y es el predeterminado desde Node 12. Funciona de manera brillante. En el lado derecho pueden ver la máquina de estados que realmente utiliza, compuesta por 80 estados, por lo que es muy, muy compleja. La magia está en su generador de máquina de análisis, que es LLParse. LLParse recibe una definición de máquina de estados de entrada en TypeScript, que tiene un subconjunto muy específico del lenguaje oval, y genera una máquina de estados en C. En otras palabras, LLParse transpila de TypeScript a C. Malas señales hoy. Pueden ver fácilmente cómo un transpilador así puede ser difícil de debug y de lanzar. Además, LLHTTP siempre ha sido compatible con versiones anteriores de HTTP 09 y 10, y esto trae consigo una complejidad innecesaria para abordar casos excepcionales. También ha sido tolerante con implementaciones rotas de HTTP, como, no sé, dispositivos integrados generalmente. Esto es muy peligroso porque abre la puerta a vulnerabilidades y otras puertas traseras y demás. Estos son generalmente los problemas de LLHTTP, que me llevaron a la decisión de escribir Milo, como verán en un momento. Milo es la solución, por supuesto, de lo contrario no estarían aquí, así que por supuesto tenemos una solución. Empezamos desde cero. Perdón por el horrible juego de palabras, realmente lo siento. Este es Milo. No es el Milo que esperaban, pero este también era Milo. Lo que están viendo es una, para aquellos que no lo saben, es una ardilla Tamiya, básicamente es una ardilla japonesa, y esta en particular se llamaba Milo. Era una de las mascotas de mi esposa, que en ese momento era mi novia, y también fue la primera que elegí para nombrar mi nuevo software. Básicamente, ahora tengo la costumbre de nombrar mi software en honor a mis mascotas actuales o anteriores, y tengo muchas. Ya saben, gatos, perros, caballos, peces, lo que sea. De todos modos, este es Milo, o un Milo. Les mostraré el otro Milo en un momento. Hablando del último Milo, por el que realmente están aquí, soltemos la bomba. Milo está escrito en Rust, punto. ¿Por qué? El lenguaje ha demostrado ser flexible, poderoso y eficiente para lograr esta tarea específica. Es de bajo nivel en cuanto a rendimiento, pero no es de bajo nivel en cuanto a definición. Por ejemplo, no conocía Rust en absoluto antes de escribir Milo, y hice esta elección a propósito, hice un experimento conmigo mismo para ver qué tan difícil sería para un nuevo colaborador adoptar Rust para contribuir a Node si Node contiene código en Rust.
3. Arquitectura y Generación de Código de Milo
No estoy aquí para iniciar una nueva discusión sobre lenguajes o criticar a LLHTTP o Feather. La arquitectura de Milo está inspirada en LLHTTP pero con una máquina de estados más simple. Aprovechando las macros de Rust, Milo genera código Rust a partir de archivos YAML, lo que permite flexibilidad y una potente generación de código. El sistema de macros de Rust es increíblemente poderoso, permitiendo la generación de código sin limitaciones. Milo tiene una huella de memoria pequeña y proporciona código compilado que se asemeja a la máquina de estados original.
No es tan difícil. Se puede hacer. Además, no estoy aquí para iniciar una nueva discusión sobre lenguajes. No soy un troll, o al menos no tanto. Así que por favor, sean amables. No inicien una discusión sobre lenguajes.
Tampoco estoy criticando a LLHTTP o Feather en absoluto, porque Feather es una persona muy buena, y LLHTTP fue un analizador increíble y eficiente que me encantó. Su arquitectura es la inspiración y la base de Milo, obviamente. Aún tengo una máquina de estados, pero mucho más simple, tengo muchos menos estados. Pasamos de 80 a 32, y elegí usar una forma declarativa de escribir estados, sin restricciones de código en Rust.
Ahora, no es magia negra. Nada es magia negra en TI, ¿verdad? Simplemente aprovecho las macros. El sistema de macros de Rust es uno de los más poderosos, si no el más poderoso, que he visto. Básicamente, la idea es que antes de completar tu código, si usas una macro (no estoy hablando de macros procedurales), básicamente puedes ejecutar otra parte de código Rust que se genera y se compila eventualmente. Entonces, básicamente, la macro en Rust producirá código Rust, pero no tienes ninguna limitación de código, puedes hacer lo que quieras. Por ejemplo, cargo la lista de métodos, estados, etc., desde archivos YAML que no están presentes o incrustados en tiempo de ejecución, porque se pasan en tiempo de compilación, genero código Rust y creo un ejecutable Rust.
También hay una herramienta que está hecha específicamente para depurar la macro procedural en Rust, porque en Rust, generalmente se usan las bibliotecas scene y quote, y con cargo span, puedes ver qué hacen estas bibliotecas con tu código, por lo que puedes tener un paso adicional antes de la compilación. Los ejemplos suelen ser más elocuentes que mil palabras. Echemos un vistazo. Incluso si no eres programador de Rust, puedes ver fácilmente la similitud entre el lado izquierdo y el lado derecho. En el lado izquierdo, tienes un estado real en Milo, que es el estado después de recibir un fragmento y volver a la longitud del siguiente fragmento, eventualmente, si recibes un slash r slash n. De lo contrario, si recibes otros dos caracteres que no esperas, fallas en el análisis. O si no tienes al menos dos caracteres para hacer la comparación, generalmente solo tienes uno, suspendes la ejecución, detienes el análisis por ahora y regresas al llamador. En el lado derecho, después de transpilar las macros, que para que conste son las que terminan en signo de exclamación, tienes el código compilado. Básicamente, el estado se convierte en una función con una firma específica que no tienes que recordar porque está implícita en la macro. CR LF se convierte en slash r slash n, pero con una sintaxis expandida que Rust espera. Lo mismo ocurre con MOV2 que se convierte en analizador MOV2, un estado y un tamaño. Finalmente, también puedes devolver una constante. Pero no tienes que recordar todos estos detalles porque las macros lo harán por ti. Ahora, ¿qué hay de la memoria? Sabemos que Milo es de alto rendimiento, ¿verdad? Pero, ¿qué hay de la huella de memoria? Milo tiene una huella de memoria muy pequeña.
4. Copia de datos en Milo
Milo permite a los desarrolladores optar por copiar los datos que se están analizando, lo que facilita y mejora la experiencia del desarrollador. Por defecto, Milo prioriza el rendimiento y no copia ningún dato. La función de optar por copiar automáticamente copia la parte no consumida del búfer para la próxima iteración.
En primer lugar, por supuesto, debo recordar algunas banderas, algunos contadores, generalmente de 32 bits, y algunos contadores de 64 bits, por lo que probablemente menos de unos cientos de bytes. Eso es todo.
Luego, si el desarrollador opta por este comportamiento, que está desactivado de forma predeterminada, eventualmente se puede copiar parte de los datos que se están analizando en Milo. Por defecto, Milo no copia ningún dato. Los datos se analizan sobre la marcha y se devuelven al llamador sin copiar incluso el puntero de memoria.
Si optas por ello, en lugar de devolver el número de bytes no consumidos al llamador, Milo eventualmente optará por copiar la parte no consumida del búfer y la añadirá al comienzo de la próxima iteración de la ejecución. En lugar de recordarte que lo hagas, Milo lo hará automáticamente por ti. Esto es simplemente experiencia del desarrollador. Es totalmente opcional y puede facilitar la vida en algunos casos. De lo contrario, de forma predeterminada, Milo se centra en el rendimiento.
5. Milo en Acción
Milo sigue estrictamente las últimas RFC para HTTP, sin excepciones. La función principal en Rust crea un analizador utilizando el método milocreate y establece devoluciones de llamada para el manejo de datos. La carga útil consiste en un desplazamiento de datos y un tamaño, y se llama al método parse para procesar los datos. Se utiliza un enfoque de interfaz común en diferentes lenguajes para garantizar la consistencia. Al ejecutar el ejemplo con Cargo, se imprime la información de depuración.
Además, Milo elimina la compatibilidad hacia atrás y la latencia. Somos estrictos. Punto. Seguimos estrictamente las últimas RFC para HTTP, que son la 91.10 y la 91.12. Al pie de la letra, no tenemos ninguna excepción. Punto. No deberíamos, deberíamos, deberíamos, cruzar los dedos, tener ninguna vulnerabilidad siguiendo la especificación al pie de la letra.
Ahora, creo que estás bastante cansado de escucharme hablar, así que vamos a la acción. Permíteme mostrarte a Milo en acción. Esto es Rust. Ahora, incluso si no conoces Rust, deberías ser capaz de seguir mi explicación. Solo echemos un vistazo a la función principal. Creas un analizador utilizando el método milocreate. Declaras un mensaje y luego estableces un cierto número de devoluciones de llamada. Todas estas devoluciones de llamada tienen la misma firma. Devuelven un puntero y eventualmente una carga útil. La carga útil está compuesta por un desplazamiento de data al búfer de entrada y el tamaño. Básicamente, es un puntero y una longitud. Si no hay carga útil, tanto data como el tamaño serán cero, por lo que son fáciles de detectar en el código.
Una vez que tienes eso, eventualmente puedes manipular los data e imprimir en tiempo de ejecución. Listo. Por último, llamas al método parse. Ahora, debería haber importado parse en el useMilo, pero confía en mí en esto. Lo olvidé. De todos modos, si eres un programador de Rust, te preguntarás por qué no he implementado parse como una implementación de la estructura parse. Hay una razón, que proviene de WebAssembly y también de C++, y te lo mostraré en un momento. Pero hay una razón para eso. Intento tener una interfaz común en los tres posibles lenguajes en lugar de tres implementaciones diferentes. Sé que no es muy sólido para Rust, pero ya sabes. Si ejecutas el ejemplo anterior con Cargo, verás la impresión de la información de depuración.
6. Flujo de trabajo de C++ en Milo
El flujo de trabajo de C++ en Milo es sencillo. Cargo admite la generación de bibliotecas estáticas, que se pueden enlazar estáticamente en cualquier ejecutable de C o C++. Cbindgen, creado por Mozilla, genera archivos de encabezado completamente funcionales de C o C++ a partir de un pequeño archivo TOML.
Entonces, la posición, por ejemplo, del estado code, el nombre del encabezado, el valor del encabezado y el cuerpo, y la carga útil. Si eres un desarrollador o colaborador de Node, sabes que Node utiliza ya sea JavaScript o C++. Así que en este caso, nos preocupamos por C++, y te tengo cubierto, no te preocupes. El flujo de trabajo de C++ es bastante sencillo.
En primer lugar, Cargo admite de forma nativa la generación de bibliotecas estáticas. Estas bibliotecas estáticas, por ejemplo, en Mac OS y Linux son archivos.ai, se pueden enlazar estáticamente en cualquier ejecutable de C o C++. Básicamente, se importan estáticamente. Y tenemos una herramienta que genera los archivos de encabezado, se llama Cbindgen, creado por Mozilla, y genera un archivo de encabezado de C o C++ completamente funcional a partir de un archivo TOML muy pequeño, de aproximadamente 3-4 líneas de code. Listo. Muy fácil.
7. C++ Version and WebAssembly in Milo
Y esta es la versión de C++. Haces un analizador, declaras un mensaje, estableces un callback. Lo único es que en este caso, las conversiones de reinterpretación hacen que el código sea un poco más difícil de leer. Al final del día, haces el análisis de Milo con el analizador, el mensaje y especificas la longitud. C++ con Milo, con main y ejemplo. Eso es todo. Ahora, veamos la historia divertida. Node admite varias arquitecturas, pero SmartOS o Solaris, el soporte de Rust está en un nivel experimental. Así que elegimos WebAssembly como una solución diferente. WebAssembly siempre ha sido un ciudadano de primera clase en Rust. Ahora tenemos Wasm-BinGen, que facilita la generación de un paquete JS completamente funcional con un archivo de WebAssembly y código de enlace de JavaScript. Esto es Milo en Node.js con WebAssembly. Importamos Milo, preparamos un mensaje y jugamos con la memoria. WebAssembly exporta un espacio de memoria compartido entre WebAssembly y la capa de JavaScript.
Y esta es la versión de C++. Ahora, incluso si no eres programador de C++, puedes reconocer fácilmente la similitud con Rust. 1. Haces un analizador, declaras un mensaje y luego estableces un callback. Puedes reconocer cómo la firma es la misma. Lo único es que en este caso, las conversiones de reinterpretación hacen que el código sea un poco más difícil de leer, pero entiendes la idea.
Al final del día, hacemos lo mismo que hicimos en Rust y con la misma firma, haces el análisis de Milo con el analizador, el mensaje y en este caso, también tienes que especificar la longitud. Esto es algo bastante común en C++. Y este es el resultado. C++ con Milo, con main y ejemplo. Eso es todo, y aún obtienes el cuerpo cuando ejecutas el código.
Ahora, esta es una historia divertida. Cuando presenté originalmente Milo en la Cumbre NodeCollab en Bilbao en septiembre del año pasado, pensé que la gente estaría contenta de que les diera una forma de incrustar Milo a partir de una biblioteca estática y un archivo de encabezado sin tener que instalar la cadena de herramientas de Rust. Ese no fue el caso. Fue todo lo contrario. Querían instalar la cadena de herramientas de Rust en lugar de descargar un archivo compilado. Pero ese no fue realmente el problema. El verdadero problema que todos encontramos al hablar juntos es que Node admite varias arquitecturas.
Y desafortunadamente, una de estas arquitecturas es SmartOS, que es un dialecto de Solaris. Desafortunadamente, para SmartOS o Solaris, el soporte de Rust está en un nivel experimental. Así que hay soporte, pero no muy extenso. Y por lo tanto, elegimos no incluir este tipo de cosa en Node en un entorno de producción, crítico para la misión, y así sucesivamente. Necesitamos encontrar una solución diferente, que es WebAssembly. WebAssembly siempre ha sido un ciudadano de primera clase en Rust. Rust siempre ha podido compilar a la arquitectura Wasm. Ahora también tenemos una cadena de herramientas, que es Wasm-BinGen, que está probada en batalla, lo que facilita mucho la generación de un paquete JS completamente funcional, un paquete NPM, compuesto por un archivo de WebAssembly y código de enlace de JavaScript. Básicamente, este código de enlace carga internamente el archivo Wasm de forma transparente para el desarrollador, como verás en un momento.
Vamos a verlo en acción. Esto es Milo en Node.js con WebAssembly. Importamos Milo, preparamos un mensaje y ahora tenemos que jugar un poco con la memoria. El concepto es que WebAssembly exporta un espacio de memoria que se comparte entre WebAssembly y la capa de JavaScript.
8. Asignación de memoria y análisis en Milo
Si colocas datos dentro de ese espacio de memoria, es accesible por ambas capas. Asignamos un búfer de memoria, creamos un búfer usando esa memoria y creamos un analizador y un callback. En JavaScript, no necesitamos llevar el analizador con nosotros. Copiamos el mensaje en el búfer compartido, luego llamamos a milo.parse con el analizador, el puntero a la memoria compartida y la longitud. La razón de usar milo.parse es el rendimiento, ya que evita la deserialización. El analizador se pasa como un entero a WebAssembly, donde se reconstruye como un analizador dentro del espacio de WebAssembly de alto rendimiento. Si encuentras una mejor manera, avísame.
Si colocas data dentro de ese espacio de memoria, es accesible por ambas capas, sin la disponibilidad de serialización y deserialización. Por lo tanto, lo que hacemos es asignar una memoria en ese búfer y recibimos un puntero al inicio de esa memoria. Después de eso, podemos crear un búfer usando ese búfer de memoria, disculpa por la ambigüedad, pasando el puntero y la longitud. Finalmente, llegamos a lo importante. Creamos un analizador, y como siempre hemos estado haciendo en C++ y también en Rust, creamos el callback, que tiene la misma firma. En este caso, omitimos el analizador. En primer lugar, porque en JavaScript, podemos enlazar el callback, por lo que no necesitamos realmente llevar el analizador con nosotros, o lo tenemos en el ámbito local y así sucesivamente. Podemos jugar con el mensaje, podemos obtener el desplazamiento y así sucesivamente para extraer la carga útil, y mostramos en tiempo de ejecución lo que obtuvimos. Ahora, para activar el análisis, tenemos que hacer dos operaciones. En primer lugar, tenemos que copiar el mensaje en el búfer compartido, por lo que hacemos buffer.set desde la cadena como un búfer, y luego llamamos a milo.parse, analizando el analizador, el puntero a la memoria compartida y la longitud. Eso es todo. La razón por la que estoy usando milo.parse en lugar de parser.parse, que también es lo que estaba diciendo en Rust, es porque en WebAssembly, si creo el analizador como una clase, cada vez que llamo a un método dentro de WebAssembly desde JavaScript, tengo que deserializar la referencia al objeto, por lo que sería muy costoso. En cambio, de esta manera, el analizador no es más que un entero, que puedo pasar a WebAssembly, por lo que es un tipo primitivo que se puede cambiar de ida y vuelta de manera transparente. Y cuando llego a Rust, básicamente, ese entero es un puntero de memoria que eventualmente se reconstruye como un analizador, pero dentro del espacio de WebAssembly, y por lo tanto es eficiente. Disculpa por la explicación muy precisa. Puedes retroceder y escuchar a una velocidad más lenta. Pero esa es la idea. Esa es la idea general de por qué tengo esta estructura compleja, y por eso elegí no usar imp en Rust. Esa es la explicación. Si encuentras una mejor manera, avísame, porque estoy interesado.
9. Pasos Finales y Conclusión
Este es el resultado, que es el mismo que en C++, nada ha cambiado. ¿Qué falta? Rendimiento con milo en comparación con LLHTTP en Node utilizando la versión de C++. Falta la integración de Node.js para la parte de WebAssembly. Trabajando en WebAssembly con Undis para integrar milo. Solucionando el problema de rendimiento de milo en WebAssembly. Considerando implementar SIMD en WebAssembly. Migrando la suite de pruebas de bus de LLHTTP a milo para asegurar que no haya regresiones. Citando a Albert Einstein: una persona que nunca ha cometido un error nunca ha intentado algo nuevo.
Este es el resultado, que es el mismo que en C++, nada ha cambiado. Y eso es milo. Hemos terminado. Lo lograste.
Ahora, ¿qué falta? En primer lugar, los rendimientos, con milo en comparación con LLHTTP en Node utilizando la versión de C++. Esto es una ejecución preliminar.
¿Qué falta? En primer lugar, la integración de Node.js para la parte de WebAssembly. Ahora estoy trabajando en WebAssembly con Undis como memoria, un software más pequeño con el que puedo experimentar para integrar milo. También me gustaría solucionar el problema de rendimiento de milo en WebAssembly, porque en este momento LLHTTP es más rápido. También estoy pensando en implementar SIMD en WebAssembly, si puedo hacerlo.
Finalmente, el último paso, que es solo para verificar la corrección, es migrar la suite de pruebas de bus de LLHTTP, que también fue heredada por el analizador anterior, el analizador LLHTTP, a milo, para asegurar que milo no introduzca ninguna regresión. Después de eso, milo estará listo.
Ahora, algo que suelo hacer en mis charlas es terminar con una cita de una persona mucho más inteligente de lo que yo podría ser. En este caso, tengo una de Albert Einstein, a quien conoces, que dice: una persona que nunca ha cometido un error nunca ha intentado algo nuevo.
La clave aquí es que siempre debes intentar algo nuevo para atreverte incluso con lo imposible, incluso si no llegas a una solución, eso es lo que hice cuando escribí milo. No conocía Rust, nunca había escrito realmente un analizador HTTP, y probé ambos. Aprendí mucho sobre milo, sobre WebAssembly, sobre análisis HTTP, sobre Rust y sobre arquitectura. Ha sido un viaje increíble.
Espero que milo se fusione en Node lo antes posible. Eso es todo lo que tengo para hoy. Una vez más, gracias por asistir a esta charla. No dudes en contactarme en Twitter, LinkedIn, GitHub o por correo electrónico en cualquier momento que desees. Una vez más, gracias por asistir a Node Congress 2024 y gracias por asistir a mi charla. ¡Salud!
Comments