Hace algún tiempo estuve investigando sobre la tranmisión de paquetes cliente <-> servidor en una plataforma de juegos flash.

La forma tradicional de mandar paquetes en las conexiones de flash es mediante sockets XML. Los paquetes XML son muy grandes para un intercambio continuado entre cliente y servidor.

Algunos servidores de sockets flexibles pensados para flash utilizan también JSON. Los JSON son mucho más compactos, pero siguen enviándose muchísimas redundancias. Especialmente sobre la estructura de los paquetes.

Y tanto los XML como los JSON se mandan sin encriptación alguna. Existen programas que facilitan la intercepción de paquetes, evitando mandarlos, modificar su contenido o inyectar paquetes nuevos. Esto en plataformas de juegos P2P donde el servidor no dirige las partidas es un problema mayor. Especialmente por la facilidad de hacer esto sin conocimientos avanzados.

Me puse a investigar sobre los dos problemas: tamaño de paquete y encriptación.

BSON

Lo que primero miré fue BSON (Binary JSON). Los conocía a raíz de http://www.mongodb.org/. El BSON parecía que podía reducir el espacio usado, pero en la práctica acababa ocupando hasta más. Aunque los BSON tienen como ventaja que su decodificación es muy rápida a diferencia del JSON normal.

El problema del BSON es el mismo que el del JSON: se manda todo el rato la estructura del paquete.

PROTOCOL BUFFERS

Otra cosa que se me ocurrió usar es los Protocol Buffers de Google. Permiten definir una estructura para un tipo y reutilizarlo en diferentes lenguajes, además de poderlo serializar de forma óptima sin mandar todo el rato el nombre de los campos. El problema era que los paquetes tenían que ser totalmente flexibles y los tipos no debíamos definirlos nosotros.

COMPRESIÓN

Al final decidí probar a usar los JSON o los BSON pero en vez de mandarlos “tal cual”, mandarlos comprimidos usando zlib. Al comprimir cada paquete, se reducía el tamaño un poco. Pero realmente solo era notable para paquetes grandes. Los paquetes pequeños sólo podían hacer uso de Huffman porque no habían datos todavía en el ringbuffer.

COMPRESIÓN EN STREAM

Ya que los paquetes no estaban aislado,s sino en una conexión TCP síncrona, se podían recordar paquetes anteriores para mejorar sustancialmente la compresión haciendo uso del ringbuffer.

Probé a montar una compresión LZ que comprimiese los paquetes progresivamente y mandase bloques de paquete completos. La compresión LZ es muy rápida y requiere muy poca memoria tanto para comprimir como para descomprimir.

Con la compresión LZ progresiva se conseguía dejar el tamaño del paquete en un 60% menos y era bastante rápido.

Sin embargo vi que con la ZLIB haciendo también uso de Huffman, la cosa se podía quedar en un 90% menos ya que se mandaban paquetes muy similares (pero no iguales) continuamente. También permitía usar una librería ya hecha y conocida que pudiese tener todas las optimizaciones posibles, posibilidad de uso de hardware y un mantenimiento de la comunidad.

Así que estuve investigando con la ZLIB.

COMPRESIÓN DE PAQUETES CON ZLIB

La ZLIB permite hacer un FLUSH que no destruye el ringbuffer, pero que permite garantizar que se puede descomprimir todo lo que hay hasta el momento: Z_SYNC_FLUSH. La pega de este flush es que añade 4 bytes al final “\x00\x00\xff\xff”. Pero como siempre es igual, a la hora de mandarlo se puede omitir y añadir en el otro extremo del canal.

MANDADO DE PAQUETES BINARIOS

A la hora de mandar y procesar paquetes en la red hay varias formas de separar los paquetes:

  • Mandar un caracter de separación entre paquetes (en el caso de los XML Sockets y JSON Sockets es el caracter ‘\0’).
  • Mandar un ID con el tipo del paquete y determinar la longitud del paquete con una tabla local
  • Mandar la longitud del paquete antes del paquete

El primer método usado con XML y JSON tiene varias pegas, incluyendo la imposibilidad de mandar datos binarios (que contengan el caracter ‘\0’). Los datos resultantes de la compresión podían contener cualquier byte, así que no se pueden mandar los datos tal cual.

Mandar el ID también era descartable, porque eran paquetes flexibles y de longitud variable.

La otra opción era mandar la longitud. Se puede mandar como un unsigned short de dos bytes y abarcar 64KB, o mandar como un entero de longitud variable, que permitiría usar un solo byte para paquetes pequeños y solo perdería un bit por byte.

Sin embargo no me interesaba cambiar la forma en la que se mandaban las cosas, porque interesaba que fuese retrocompatible y permitiese mandar XML y JSON sin comprimir (al menos durante un tiempo).

Así que la única forma de que fuese compatible era evitar que los paquetes contuviesen el byte ‘\0’. ¿Cómo? Utilizando longitud variable por byte. Algo parecido a lo que hace UTF-8 o lo que se hace con Sqlite para guardar blobs sin ser binary safe.

Por ejemplo se puede utilizar el byte ‘\1’. Cuando te encuentres un ‘\0’ lo reemplazas por ‘\1\1’ y cuando te encuntres un ‘\1’ lo reemplazas por ‘\1\2’. Así de sencillo. Esos caracteres ocupan el doble, pero como con datos comprimidos la supuesta posibilidad de encontrarse con un byte o con otro es la misma, estadísticamente el tamaño se quedaría en un x * (1.0 + (2 / 256)). Aumentando en un 0.8%. No demasiado significativo.

ENCRIPTACIÓN

La compresión ya evitaba la posibilidad de interceptar fácilmente paquetes de forma externa. Pero no imposibilitaba una forma fácil de inyectar paquetes nuevos. ZLIB permite bloques sin comprimir que no hacen uso ni de la tabla huffman ni del ringbuffer. Con lo que crear un paquete (que no modificar uno existente o leerlo) es bastante fácil.

Así que consideré añadir encriptación al resultado de la compresión.

Interesaba una encriptación rápida y relativamente sencilla, que evitase la fácil inyección sin ralentizar mucho el asunto.

Para evitar que la encriptación fuese predecible para cada paquete, opté por una encriptación basada en XOR y en rotaciones de bits con varias tablas de longitudes de números primos (para evitar usar mucha memoria y a la vez que tuviese un periodo óptimo). El cursor de la encriptación no era relativo al inicio del paquete sino al inicio del stream.

Por supuesto esta encriptación no evita que alguien decompile el cliente y pueda seguir o modificar el stream. Pero desde luego lo dificulta MUCHO en relación a la dificultad original.

IMPLEMENTACIÓN

Tuve que implementar todo esto en Java, ActionScript 3 y PHP. Mientras lo hacía descubrí una tecnología de adobe llamada Alchemy. Que permitía compilar código C/C++ que no dependiese de librerías del sistema en bytecode de la máquina virtual de Flash. Esto me permitiría hacer un único código con todo esto y limitarme a hacer wrappers para las diferentes máquinas virtuales.

ALCHEMY

Mi experiencia con alchemy fue bastante positiva. Funcionaba como tocaba y excepto problemas menores iba bien. El problema era que metía un footprint tochísimo en el SWF final.

Miré de implementarlo en ActionScript 3 directamente. Flash 10 solo soportaba comprimir y descomprimir ByteArrays enteros, ni contexto de stream ni SYNC_FLUSH.

Como la compresión/decompresión en cliente no tenía por qué ser tremendamente rápida nusqué un port de la zlib en AS3. Había uno para Java: Jzlib. Y un port del Jzlib a AS3: as3zlib. Así que partí de ahí. Implementé todo eso en AS3.

JAVA Y JNI

Java tiene soporte nativo de la zlib en el api más básico incluso soporte de stream. Desafortunadamente el API hasta Java 1.7 no soporta SYNC_FLUSH. Como la 1.7 todavía no había salido, me tocó tirar del JNI (Java Native Interface).

El JNI fue bastante agradable de usar. Creas funciones proxy en C con los parámetros tal cual. Los parámetros no se pasan en una pila o en un objeto único de la VM como en otros lenguajes como Squirrel, PHP…

PHP

Como ya había hecho otras extensiones para PHP no tuve mucho problema. Reutilicé la implementación que había hecho en C/C++ y hice bindings en PHP sin mucho más misterio.

IMPLEMENTACIÓN EN C/C++

La implementación que tenía que ser todo lo rápida posible era la de JAVA. Ya que el servidor estaba hecho en Java y era el que iba a tener que gestionar todas las conexiones: tanto en una dirección como en la otra.

Estuve ajustando varios parámetros de la ZLIB. Por cada conexión necesitaba dos contextos: uno de compresión y otro de decompresión. Interesaba que usase la mínima memoria posible para poder gestionar el máximo número de conexiones.

Para evitar tocar en exceso la HEAP creé buffers con el tamaño máximo que se permitiría de paquete para comprimir y descomprimir; crearlos al establecer la conexión y mantenerlos hasta terminarla. El problema es que eso requería mucha memoria.

Pensando di con la solución: TLS. THREAD LOCAL STORAGE.

TLS (THREAD LOCAL STORAGE)

Los buffers solo se usan para comprimir y descomprimir y solo durante el proceso de compresión de cada bloque. Me di cuenta de que realmente solo hacía falta un par de buffers por thread y no por conexión. Threas había muchos menos. 2 buffers por 10 posibles threads son muchos menos buffers que 2 buffers 10,000 posibles conexiones.

Así que creé dos buffers usando TLS.

CONCLUSIONES

Y eso es todo. Conseguí mi objetivo.  Dejar el protocolo en un 10% del tamaño original (reducción del 90%), encriptar el protocolo y sin que afectase sustancialmente al rendimiento.