Sockets: Implementación, consejos y pasos recomendados - GandalFran/Sockets-de-berkeley GitHub Wiki

Introducción

En este apartado trataremos algunos puntos, los cuales son recomendaciones (basados en mi propia experiencia) de cómo implementar tu primer socket

Construcción y lectura de tramas a nivel de aplicación

Para poder comunicarnos, tenemos que saber construir y leer las tramas de aplicación, las cuales vienen descritas en el RFC del protocolo que queremos implementar.

Como tipo de dato sobre el que construir las tramas, como estamos en C, lo más fácil es usar un array de char, ya que este tipo de dato ocupa un byte, y los campos de las tramas de aplicación suelen venir definidos en función de N bytes por campo. Y para copiar los datos necesarios sobre la trama, primero, necesitamos las herramientas para hacerlo, las cuales son las siguientes funciones:

  • void *memset(void *s, int c, size_t n) : Dado un puntero s, copia el valor c, en cada byte del puntero, durante n bytes. Esta función, la usaremos para limpiar los buffers, y las estructuras, y asegurarnos que tienen los valores por defecto que deseamos.
  • void *memcpy(void *dest, const void *src, size_t n) : Copia n bytes de memoria de src a dest.
  • char *strcpy(char *dest, const char *src) : nos sirve para realizar la misma tarea que memcpy, pero sin un tamaño especificado, ya que, esta función copia desde src hacia dest, todos los caracteres que encuentre hasta llegar a '\0'.
  • char *strncpy(char *dest, const char *src, size_t n) : realiza la misma funcion que memcpy, con una pequena diferencia explicada aquí, la cual es basicamente, que strncpy copia caracteres hasta llegar a el tamano que se le pasa por parametro (n), o hasta que encuentre un NULL (el caracter '\0' (recordar que en C, tanto NULL como '\0' equivalen a el valor 0), que es el terminador de una cadena de caracteres).

Una vez dicho esto, cabe recordar que estamos comunicando dos máquinas distintas, entre las que puede variar la forma de almacenar los datos (por ejemplo little-endian o big-endian), por eso, tenemos que convertir los datos a enviar a un formato estándar antes de enviar, y al leer los datos de las tramas, volverlo a convertir de formato estándar, a nuestro propio formato. Por eso, es recomendado usar las siguientes funciones a la hora de ensamblar o leer tramas de aplicación:

Procesar peticiones de forma concurrente en UDP

Como ya dije en la sección Estructura de un socket, en el apartado de estructura de un servidor UDP; tratar peticiones de forma concurrente en UDP es un poco mas complicado que en TCP; y que en TCP, tenemos la funcion accept, que nos acepta una conexion y nos crea un socket para procesarla. No obstante en UDP, no disponemos de ninguna función parecida, pero esto se puede solventar con el siguiente mecanismo:

  1. Crear un socket, el cual servirá de socket de escucha.
  2. Entrar en el bucle de procesamiento de peticiones
  3. Quedarnos bloqueados en el primer recvfrom, esperando el primer mensaje.
  4. Cuando recibimos el mensaje, creamos otro socket, que escuche en un puerto efímero
  5. Procesamos la petición a través del nuevo socket.

Para el cliente, esto será un proceso transparente. Ya que el servidor, al enviar el siguiente mensaje por el socket nuevo, en el cliente en el recvfrom, se le rellenaran los datos del servidor (struct sockaddr_in serverData), con los datos del nuevo puerto de escucha. Gracias a este mecanismo podremos separar la comunicación con ese cliente concreto, de la recepción de peticiones.

pid_t pid;
int sListen, s;
int port = 80;
int sockaddrSize;
char buffer[512];
struct sockaddr_in serverData, clientData;

//Limpiar las estructuras que vamos a usar durante el programa
// NOTA: esto lo hacemos para evitar problemas por los campos que no rellenamos y dejamos por defecto
memset (&clientData, 0, sizeof(clientData));
memset (&serverData, 0, sizeof(serverData));

//Creación del socket de escucha
//  Con esta operación no estamos conectando, sino diciéndole al sistema operativo que nos asigne un socket
//   en este caso de UDP (SOCK_DGRAM), y para trabajar con IPv4 (AF_INET), el cual usaremos para realizar 
//   conexiones más tarde
if(-1 == (sListen= socket (AF_INET, SOCK_DGRAM, 0))){
	exit(EXIT_FAILURE);
}

//Rellenar la estructura del servidor 
//  NOTA: en familia establecemos que trabajaremos con IPv4 (AF_INET), y en la dirección IP del servidor,
//     establecemos INADDR_ANY, la cual es una dirección especial, que se corresponde con cualquiera,
//     permitiéndonos no tener que hallar el valor de la dirección IP de nuestro servidor para configurar
//     esta estructura.
//  NOTA2: en puerto hemos puesto un valor determinado, y no 0, porque no queremos que el sistema operativo 
//      nos asigne un puerto disponible, sino que queremos escuchar en un puerto establecido por nosotros,
//      para que los clientes sepan con que puerto conectar.
serverData.sin_family = AF_INET;
serverData.sin_addr.s_addr = INADDR_ANY;
serverData.sin_port = htons(port);

//Bindear el socket a nuestra dirección
// NOTA: esto es para unir el socket a los datos de nuestra IP, y el puerto que hemos asignado
if (-1 == bind(sListen, (const struct sockaddr *) &serverData, sizeof(serverData))) {
	exit(EXIT_FAILURE);
}

//En esta estructura, NO creamos una cola de escucha con listen, pues UDP no es orientado a 
//  a conexión, y por tanto, no se mantiene una sesion y no se necesita una cola de clientes.

//A partir de aquí, ya hemos terminado de crear el socket de escucha para el servidor, y entramos en el
//  bucle, en el cual iremos recibiendo y tratando las peticiones
while(1){
	//Aquí nos bloqueamos hasta recibir un mensaje, ya que como en UDP no se mantiene  
	// sesión, no se espera a recibir una peticion de conexion, sino que directamente se recibe
	// un mensaje.
	// NOTA: aquí recibiremos en mensaje en un array de char llamado buffer, que tiene 512 elementos,
	// o lo que es lo mismo, 512 bytes. No obstante el tamaño del buffer, debe ser el tamaño máximo
	// que necesite un mensaje de vuestro protocolo, para que os llegue el que os llegue, se pueda
	// recibir por completo.
	if(-1 == recvfrom(sListen, buffer, 512, 0, (struct sockaddr*)&clientData, &sockaddrSize)){
		exit(EXIT_FAILURE);
	}

	//Una vez recibido el primer mensaje, procedemos a crear un hijo, tras lo cual, este creará un nuevo socket
	//NOTA: es importante que primero se cree el hijo y luego el socket, ya que un socket al fin y al cabo, es un 
	//   descriptor de fichero (ya que en unix la interfaz de fichero se ha utilizado para tratar con gran cantidad de 
	//   dispositivos); y un programa puede abrir un número limitado de descriptores.
	//   El problema se plantea, porque si creamos el descriptor antes de crear el hijo, llegará un momento en el que 
	//   dejamos a el proceso padre, sin posibilidad de abrir más descriptores, y no podrá procesar más peticiones.
	pid = fork();
	if(-1 == pid){
	//en caso de error se termina el proceso servidor
		exit(EXIT_FAILURE);
	}else if(0 != pid){
	//en caso de que sea el padre, hacemos un continue par que vuelva a escuchar peticiones
		continue;
	}
	//Aqui unicamente llega el hijo
	
	//Ahora procedemos a crear el nuevo socket
	//  NOTA: como el proceso es el mismo que el descrito arriba, no describiré lo que
	//    se está haciendo en cada parte

	//Creación del socket de para atender las peticiones
	if(-1 == (s= socket (AF_INET, SOCK_DGRAM, 0))){
		exit(EXIT_FAILURE);
	}

	//Rellenar la estructura del servidor 
	//  NOTA: solo cambia respecto arriba, que en puerto ponemos 0, porque queremos que el
	//     sistema operativo nos asigne uno libre
	serverData.sin_family = AF_INET;
	serverData.sin_addr.s_addr = INADDR_ANY;
	serverData.sin_port = 0;

	//Bindear el socket a nuestra dirección
	if (-1 == bind(s, (const struct sockaddr *) &serverData, sizeof(serverData))) {
		exit(EXIT_FAILURE);
	}

	//a partir de aquí vendría la implementación del protocolo
	//  NOTA: las comunicaciones se haria a traves de s, en vez de a través de sListen.
}

Ejecutar en un mismo proceso un servidor de TCP y uno de UDP

Para ejecutar de forma concurrente un servidor TCP y uno UDP, necesitaremos haber creado los sockets de escucha de TCP y UDP. Como este proceso ya se ha hecho muchas veces en este documento, aqui se omitra, no obstante se puede consultar en el apartado anterior ( Estructura de un socket).

Antes de el codigo, solo una pequeña anotación más, que es, que los sockets de escucha TCP y UDP, podrán escuchar en el mismo puerto, ya que son dos protocolos de transporte distintos, y por tanto, los puertos se gestionan de forma separada para dichos protocolos.

int main (int argc, char * argv[]){
	int sListenTcp, sListenUdp, foo, biggest;

	//....
	//a partir de este punto se consideran creados e inicializados los sockets de escucha de TCP y UDP	

	//Variable que albergará el conjunto de sockets de escucha
	fd_set set;

	while (1) {
		//Limpiar el conjunto de sockets, y meter nuestros sockets
		FD_ZERO(&set);
		FD_SET(sListenTcp, &set);
		FD_SET(sListenUdp, &set);

		//Escogemos el descriptor con un valor más grande, de nuestros sockets de escucha
		biggest = (sListenTcp > sListenUdp) ? sListenTcp: sListenUdp;
		biggest = biggest + 1;

		//
		foo = select(biggest , &set, NULL, NULL, NULL);
		if(EINTR ==errno){
			exit(EXIT_FAILURE);
		}

		//Comprobamos si nos ha llegado una petición a el socket TCP
		if (FD_ISSET(sListenTcp, &set)) {
			//Hacer accept, para obtener el socket para gestionar la nueva conexión
		}

		//Comprobamos si nos ha llegado una petición a el socket UDP
		if (FD_ISSET(sListenUdp, &set)) {
			//Hacer el primer recvfrom, y así, obtener los datos del cliente
		}
	}
	//...
}

Reintentos y timeouts en sockets UDP

UDP es un protocolo de transporte sin conexión; además, este se basa en IP, el cual es otro protocolo sin conexión. Debido a esto, es necesario poner timeouts, para saber cuando se ha perdido una trama, y reintentar el envío de esta en dicho caso. Hay 2 formas muy conocidas y sencillas, de hacer los reintentos. La primera, es con SIGALRM, y la segunda con funciones de la biblioteca de sockets. No obstante, aquí solo expondré la forma de hacerlo con SIGALRM:

#define TIMEOUT 2
#define RETRIES 3
#define TAM_BUFFER 128

int timeOutPassed;
int nRetries;

void SIGALRMHandler(int ss){
	//Si el número de intentos es menor que el permitido, poner timeOutPassed a 1
	if(nRetries<=RETRIES){
		nRetries++;
		timeOutPassed = 1;
	//Si el número de intentos es mayor que el permitido, salir, porque se habrá perdido la conexión
	}else{
		exit(EXIT_SUCCESS);
	}
}

int main(int argc, char * agrv[]){
...
	//Redefinir SIGALRM 
 	struct sigaction ss;
 	memset(&ss,0,sizeof(ss));

 	ss.sa_handler=SIGALRMHandler;
 	ss.sa_flags=0;
 	if(-1 == sigfillset(&ss.sa_mask))
		exit(EXIT_FAILURE);
 	if(-1 == sigaction(SIGALRM,&ss,NULL))
		exit(EXIT_FAILURE);
...
	//Poner a 0 el número de intentos actual
	nRetries = 0;
	//Nos mantenemos en este bucle mientras el timeOut pase
	do{
		//Enviar un mensaje (este dependerá del protocolo)
		framesize = buildmsg(...);
		if(-1 == sendto(s, buffer, framesize, 0,(struct sockaddr *)&serverOrClientData,sizeof(serverOrClientData)) ){
			exit(EXIT_FAILURE);	
		}
		//Esperar por respuesta
		memset(buffer,0,TAM_BUFFER);
		//Poner el flag de timeout a 0, y tras ello activar el alarm, para que en TIMEOUT 
		//   segundos nos llegue un SIGALRM
		timeOutPassed = 0;
		alarm(TIMEOUT);
		//Recibir un mensaje
		//NOTA:  Cuando nos llegue el SIGALRM, en el caso de que estemos dentro de recvfrom, se 
		//    saldrá de este, y se continuará en este bucle, llegando a la línea donde se encuentra en 
		//    "while(timeOutPassed)".
		msgsize= recvfrom(s, buffer, TAM_BUFFER, 0,(struct sockaddr*)&serverOrClientData,&size);
	}while(timeOutPassed);
	//Desactivar el alarm anterior para hacer que no nos llegue SIGALRM
	alarm(0);
...
}

Consejos

Una situación bastante común la primera vez que nos enfrentamos a la programación de un socket, es que no sabemos por dónde empezar. No obstante, cuando yo me encontré en dicha situación, seguí los siguientes pasos, lo que me facilitó mucho el proceso:

  • Implementar las funciones necesarias para fabricar y desensamblar las tramas de aplicación.
    Estaría bien que toda la funcionalidad relacionada con la gestión de las tramas esté en un mismo fichero, pues en el caso de querer localizar errores, se aprecian mejor.
    En el caso de que las tramas de aplicación del protocolo a implementar, tengan varios campos, separados por el carácter '\0', al principio, para facilitar la depuración, se puede sustituir por otro caracter conocido como por ejemplo '|', ya que podremos imprimirlas por pantalla como cadenas de caracteres y ver su contenido.
  • Implementar la parte de TCP (en el caso de haberla), ya que al ser un servicio orientado a conexión, los timeouts, reintentos, ... son gestionados internamente por dicho protocolo, por tanto simplifica bastante el trabajo del programador.
  • Implementar la parte de UDP (en el caso de haberla), ya que una vez que TCP está programado y funcionando, conocemos mejor el funcionamiento del protocolo a implementar, y podemos realizar tareas más complicadas. En este caso, lo mas facil seria implementar el protocolo en UDP fijándonos en el implementado en TCP, tras lo cual podríamos añadir la gestión de timeouts y reintentos, lo cual no es muy complejo, pero puede suponer un quebradero de cabeza hacerlo todo junto.

Por último, quería acabar con un tres consejos que a mi me fueron muy útiles a cuando me enfrente a programar mi primer socket:

  • El primero es que al principio, ejecutemos el cliente y el servidor en el mismo ordenador, y hagamos las pruebas con la direccion de loopback (127.0.0.1), ya que como estamos comunicándonos dentro de nuestro propio ordenador, no puede haber fallos de transmisión, y por tanto todos los fallos que ocurran serán causados por nosotros.
  • El segundo es que hagamos un script para ejecutar, que realice los siguientes pasos: En el caso de existir procesos vivos de otras ejecuciones, matarlos. Tras ello compilar y ejecutar nuestro cliente y nuestro servidor.
  • El tercero y último, es que si has programado tu el cliente y el servidor, busca en internet una implementación de dicho protocolo, que cumpla la RFC que tu estas implementando, y prueba tu cliente con su servidor y viceversa, porque puedes haber cometido fallos en la construcción de las tramas, el protocolo de envío de tramas, ... y este tipo de errores solo se pueden comprobar con el código de otros.