Escribiendo un port de Erlang

Resulta que este cuatrimestre ha habido una asignatura cuya práctica se ha desarrollado en Erlang, un lenguaje funcional que favorece la programación concurrente y la comunicación de los procesos usando paso de mensajes. El resultado de la práctica es un crawler donde cada dominio tiene asignado un “hilo” que tendrá que hacer las peticiones al servidor web, más otro que se encargará de descargar imágenes e indexarlas utilizando pHash, el programa se compone de más partes pero ahora nos centraremos en esto.

(Por cierto, el proyecto se ha desarrollado en abierto, tenéis el código en su repositorio de GitHub, EPC).

Al principio cada hilo simplemente hacía una llamada a httpc:request, que es la forma que ofrece la libería estándar del lenguaje de hacer estas peticiones, pero parece que la concurrencia que ofrece deja que desear, esto producía inanicción en el proceso de indexación.

Más abajo la especificación muestra una posible solución:

Detalles de Option (option()):
sync
    La petición debe ser síncrona o asíncrona.
    Por defecto true.

En su momento no se comprobó si esta era una solución, sinó que se implementaron otras dos, una fué el uso de un proceso que se encargara de realizar las descargas dándole prioridad al indexer y que los crawler no le produjeran inanicción ya que toma un tiempo para obtener las características de la imágen (no mucho pero algo), esto está implementado en Erlang puro y fué el que se tomó en la rama master.

Otra opción fué implementar la descarga como un port, un programa externo escrito en este caso en C y que sería llamado desde un proceso de Erlang, esta posibilidad quedó registrada en la rama GET-by-port.

Comunicación C - Erlang

El port tiene dos componentes, la parte en C y la parte en Erlang, la comunicación se puede hacer de varios tipos, y se define con PortSettings en open_port, las posibilidades son

  • {packet, N}

    Los mensajes son precedidos por su longitud, enviada en N bytes, con el más significativo primero, los valores válidos de N son 1, 2 y 4.

  • stream

    Los mensajes son enviados sin longitud de paquete. Debe establecerse un protocolo definido por el usuario entre el proceso Erlang y el objeto externo.

  • {line, L}

    Los mensajes son enviados por líneas. Cada línea (delimitada por una secuencia de fin de línea dependiente del SO) se envía en un solo mensaje is. El formato del mensaje es {Flag, Línea}. Donde Flag es o eol o noeol y Línea son los datos realmente enviados (sin la sequencia de salto de línea).

    L especifica la lóngitud máxima de la línea en bytes. Las línea mayores que estas serán enviadas en más de un mensaje Lines, con el Flag noeol para todos los mensajes menos el último. Si el fin de archivo se encuentra en cualquier otro lugar a parte de inmediatamente siguiendo un salto de línea, la última línea también será enviada con el Flag noeol. En el resto de casos, las líneas son enviadas con el Flag eol.

    Las opciones {packet, N} y {line, L} son mútuamente exclusivas.

En este caso elegiremos {packet, 4}, suficiente para enviar páginas enteras de vuelta.

Comunicación - El lado de C

Centrémonos ahora en lo que ocurre en el programa escrito en C cuando recibe los datos, la función que maneja esto es char* read_url()

El proceso es sencillo, lee 4 bytes de stdin y los guarda como un uint32_t

1
2
3
4
uint32_t length;
if (fread(&length, 4, 1, stdin) != 1){
    return NULL;
}

Después convierte los datos de big-endian a la codificación del host, esto se hace con la función ntohl()

1
length = ntohl(length);

El resto es simplemente leer la cadena de stdin, sin ninguna transformación y conociendo su longitud

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
char *url = malloc(sizeof(char) * (length + 1));
if (url == NULL){
    return NULL;
}

unsigned int already_read = 0;
while (already_read < length){
    already_read += fread(&url[already_read], sizeof(uint8_t),
                          length - already_read, stdin);
}
url[length] = '\0';

El devolver los datos a Erlang no supone mucho más esfuerzo, como se puede ver en el procedimiento void show_result(headers, body). Los datos se mandan en dos grupos, primero las cabeceras y después el cuerpo del resultado, enviar las cabeceras supone convertir su tamaño a big-endian usando htonl y escribirlo a stdout, para después escribir toda la cadena directamente

1
2
3
4
5
6
7
8
9
/* Pueden pasar cosas muy extrañas si olvidamos esto */
uint32_t headers_size = htonl(headers.size);
fwrite(&headers_size, 4, 1, stdout);

unsigned int written_head = 0;
while (written_head < headers.size){
    written_head += fwrite(&(headers.memory[written_head]), sizeof(uint8_t),
                           headers.size - written_head, stdout);
}

... repetiríamos lo mismo para el cuerpo de la respuesta

1
2
3
4
5
6
7
8
uint32_t body_size = htonl(body.size);
fwrite(&body_size, 4, 1, stdout);

unsigned int written_body = 0;
while (written_body < body.size){
    written_body += fwrite(&(body.memory[written_body]), sizeof(uint8_t),
                           body.size - written_body, stdout);
}

Y eso es todo lo que hay que hacer para completar la interfaz con Erlang, el resto es lógica común de C, en este caso sería hacer peticiones HTTP, algo que usando cURL no supone un gran problema y cada vez que quisieramos tomar una url solo habría que llamar a read_url(), y por supuesto la compilación se hace de la forma habitual.

Comunicación - El lado de Erlang

La lógica que debe manejar el proceso Erlang no es tampoco complicada, simplemente habría que utilizar open_port, que devolvería el PID con el que comunicarse, suponiendo que hayamos definido HTTP_GET_BINARY_PATH con la ruta al binario compilado que complete el port

1
Port = open_port({spawn, ?HTTP_GET_BINARY_PATH}, [{packet, 4}])

Cuando hubiera que enviar datos al binario se enviarían a través de ese PID, enviando una tupla

1
{Pid_del_proceso_actual, {command, Mensaje}}

Por ejemplo

1
Port ! {self(), {command, Msg}},

Esto se convierte en los datos que recibe el binario, de la misma forma cuando este envíe datos se recibirán en un mensaje con la forma

1
{Pid_del_port, {data, Mensaje_recibido}}

Como se reciben dos, las cabeceras y el cuerpo del mensaje...

1
2
3
4
5
6
7
8
9
receive
    {Port, {data, Headers}} ->
        receive
            {Port, {data, Body}} ->
                From ! {self(), {http_get, {ok, {200,
                                                 process_headers(Headers),
                                                 Body}}}}
        end
end,

Se puede ver que el resultado se muestra en un mensaje, esto es por que está pensado para correr dentro de un bucle y mantener el port activo en un proceso aislado hasta que el proceso que lo haya creado se cierre, pero no es necesario que se maneje de esta forma, no hay ningún motivo por el que no debiera devolver el resultado directamente como una función “normal”, aunque ¿quizá? dé problemas al coordinar el acceso al input/output si hay varios procesos dándole uso.

... y eso es todo, ya tenemos nuestro trozo de código en C ejecutandose desde Erlang, por supuesto al ser stdin/stdout la interfaz cualquier lenguaje puede ser usado, es una estrategia que da bastante juego :)

Trabajando en un repo de Debian » « Usando andEngine desde emacs