El otro día me vi en la necesidad de modificar una aplicación de stress test de un protocolo de red y tuve una excelente experiencia de refactorización que paso a relatar.
Ten en cuenta que el objetivo de este relato es mostrar el espíritu del proceso de refactorización, no el programa en cuestión, que tratándose de una herramienta de stress test bien prodría utilizarse para denegación de servicio. Aunque el protocolo es marginal y carece de importancia, ¿quién sabe que puede ocurrir mañana?
Aplicación original
El código original consiste en un archivo con cinco funciones minúsculas y un main gigante.
Tiene una sección para procesar las opciones, una para crear el payload, una para armar el paquete ip y un loop donde modifica el paquete sin necesidad de rehacerlo y lo envía, tantas veces como se le pida.
La elección de que poner en funciones y que no, parece ser muy laxa. No es conceptual, ya que un par de operaciones inversas se han implementado con una función y con código suelto.
La característica más interesante es que para que los paquetes sean distintos y no sea trivial detectarlos como provenientes de la herramienta, hay unos punteritos al payload que permiten modificarlo y reenviar sin hacer la system call para construir el paquete, con una importante ventaja en la performance, como luego se apreciará.
Este es el pseudo código del programa original:
procesar opciones
crear payload
crear ip packet
tantas veces como se pida
actualizar payload sin modificar el tamaño del packet
enviar
limpieza
Pros y contras de la implementación original
+ Muy veloz
+ Simple si estás familiarizado con el protocolo (no era mi caso al comenzar)
- Código difícil de modificar
- Fácilmente detectable
Objetivos
Poder generar tráfico más creible.
Mantener la performance en la medida de lo posible.
Mejorar el código para modificaciones posteriores
Decisiones
Pude haber optado por intentar comprender bien el protocolo y rescribir en algún lenguaje más sencillo que C o C++. En ese momento no sabía cual era el cuello de botella de la aplicación, asi que decidí no incorporar un elemento extra contra la performance.
Decidí convertir el código a C++ para poder utilizar los contenedores con los que ya estoy familiarizado, que al final no usé. En última instancia la elección fue muy influida por no invertir trabajo previo en comprender y el deseo de usar C++.
Decidí utilizar shunit2 y testear funcionalmente en lugar de utilizar testeo unitario, ya que pese ha haber decidido usar c++, la verdad es que no sabía si iba a terminar haciéndolo en perl. De hecho, de continuar el proyecto, será embebiendo un intérprete de algún lenguaje a determinar.
Primer paso: el test
Lo primero que hice fue un test con shunit2:
#! /bin/bash
testFullDump() {
./prg param1 param2 param3 | \
sed -e "s/\(some headers=\)......../\1xxxxxxxx/" \
-e "s/\(other header=\)......../\1xxxxxxxx/" \
> /tmp/shunit.txt
cmp output/fullDump.txt /tmp/shunit.txt
result=$?
assertEquals 0 $result || return
rm /tmp/shunit.txt
}
. shunit2
O sea:
./prg param1 param2 param3
Ejecutá el programa con tales parámetros
sed -e "s/\(some headers=\)......../\1xxxxxxxx/" \
-e "s/\(other header=\)......../\1xxxxxxxx/" \
Que
sed reemplace lo modificado por la actualización del payload por
"xxxxxxxx" de modo tal que sea igual a mi archivo de referencia,
output/fullDump.txt
> /tmp/shunit.txt
Poné en
/tmp/shunit.txt la salida del programa
cmp output/fullDump.txt /tmp/shunit.txt
Compará utilizando
cmp mi archivo de referencia con la salida del programa
result=$?
Costumbre mía, no confiar en que $? tenga el exit code más alla de la ejecución inmediata.
assertEquals 0 $result
Comprobá que la salida de
cmp sea 0, o sea que sean iguales
|| return
Si no eran iguales, interrumpí el test
rm /tmp/shunit.txt
Limpiá
testFullDump() {
...
}
. shunit2
Este es el modo de hacer un test con shunit2, primero definir funciones de la forma testXXX y al final ejecutar en este mismo proceso shunit2.
Segundo paso: refactorización
Teniendo esta red de protección procedí a mover algunas secciones de código a funciones, cambiar de gcc a g++, creé clases y finalmente tuve la misma funcionalidad con la que había comenzado, pero en código modularizado.
También incorporé el uso de valgrind, para evitar perder memoria, ya que este programa bien podría ser ejecutado durante mucho tiempo.
Medí la performance antes y después y me dió parecido, así que por ese lado, no había problema.
Tercer paso: crear ip packet dentro del loop
Ahora, la llamada al sistema, que supongo debe ser cara, se hace dentro del loop:
procesar opciones
crear payload
tantas veces como se pida
actualizar payload
crear ip packet
enviar
limpieza
Esto no agrega nada, es sólo un paso intermedio, desde el punto de vista del resultado sigue siendo refactorización. Estoy respetando "baby steps".
La performance se vió seriamente afectada, pero dentro de un rango aceptable.
Cuarto paso: crear payload packet dentro del loop
Ahora el payload es modificado dentro del loop, esto implica un payload de tamaño variable, cosa que en la implementación original no podiamos hacer, ya que hay que recrear el packet:
procesar opciones
tantas veces como se pida
crear payload
crear ip packet
enviar
limpieza
Para crear payload usé unos pocos casos creados a mano, por ahora sólo me interesa que funcione y poder medir la performance. En teoría ya no puede empeorar.
Fin
Todo esto sólo fue el preparativo para poder cumplir el objetivo: crear payloads distintos pero coherentes.
Como me sigo resistiendo a terminar de entender el protocolo, en lugar de hacer generadores, me gusta la idea de generar las payloads a partir de la captura de tráfico legítimo, reduciendo la comprensión a que no se filtre información rastreable al tráfico original y que respeten el protocolo.
De un modo u otro, jamás lo haría en c++. Usaría una arquitectura de plugins con un intérprete de lua, python, perl, lo que sea. Si eso afectara la performance, prototiparía interpretando y al final volvería a c++.
Pero esas ya son otras historias...
Aprendizaje
Con un solo test, avanzando paso a paso y versionando con git, sin necesidad de una inversión inicial de investigación, pude tomar un programa funcionalmente correcto pero apestoso y convertirlo un programa limpio y manejable sin pérdida de performance.
Cuando empecé no sabía nada del protocolo, ni siquiera que existía; cuando terminé con esta etapa me vi obligado a leer algunas RFCs y no me costó nada pues ya estaba
bastante familiarizado por haberlo usado.
Algunas métricas
Para medir las lineas usé:
for version in 0 1 2; do
git checkout PerformanceV${version}
echo "Version ${version}"
for type in c h cpp hpp; do
echo " Type ${type}"
echo -n " Files "
ls -1 *.$type 2>/dev/null | wc -l
echo -n " Lines "
grep *.$type -ve "^ *$" -ve "^ */" 2>/dev/null | wc-l
done
done
siendo $EXT
hpp,
cpp,
h o
c según el caso. Con el grep quité las lineas en blanco y casi todos los comentarios.
Para medir la performance utilicé
un script para tirar ejecuciones simultáneas. Me da un poquito de vergüenza, no he sido muy estricto,
ya sé que esta no es la manera de hacer profiling, es sólo una medida
aproximada pero al menos tomé la precaución de dejar la velocidad del
micro fija.
PROCESSES=$1
REQUESTS=$2
while [ $PROCESSES != 0 ] ; do
./run $REQUESTS &
PROCESSES=$(( $PROCESSES - 1 ))
done
wait
El script se invoca:
time ./stresstest.sh $PROCESSES $REQUESTS
Cambiando el branch, recompiliando y con REQUESTS = 1000000
|
Original
c |
Refactorizado
c++ |
Rediseñado
libnet_build_* en loop |
Archivos headers |
1 |
5 |
7 |
Archivos código |
1 |
5 |
7 |
Lineas headers |
71 |
98 |
117 |
Lineas código |
349 |
473 |
473 |
1 proceso |
1.2 |
1.4 |
7.5 |
2 procesos |
2.3 |
2.5 |
8.5 |
3 procesos |
1.8 |
1.9 |
8.1 |
4 procesos |
3.9 |
3.7 |
8.8 |
5 procesos |
4.9 |
4.9 |
12.5 |
Muy llamativa la anomalía con tres procesos, ¿sistema operativo? ¿hardware? ¡quién sabe! Mirá que lo probé varias veces y el comportamiento fué siempre el mismo.
Glosario
Dado que he utilizado algunos términos de modo un tanto "libre", paso a definirlos:
packet: me refiero al datagrama, lo que va a ser enviado a la red, se le ajustan las direcciones y puertos de origen y destino, el protocolo de red y los datos y se hace una llamada al sistema para que lo entienda y pueda enviarlo.
payload: es la carga específica del protocolo en cuestión, lo único en que afecta al packet es en su tamaño.