Vamos a ver como PHP nos ayuda en nuestra tarea de implementar un chat asíncrono con tecnología COMET.
Lo primero sería la explicación teórica de la parte servidor (la parte cliente ya se trató en la entrega anterior). Ya se comentó el problema de compartir datos entre clientes. Joseba me comentó que era un ejemplo más del típico problema de los lectores y escritores. Los lectores serían los procesos que van leyendo los mensajes que producen otros usuarios y los reenvian al navegador. Los escritores serían los procesos que se ejecutan cuando un usuario quiere mandar un mensaje. Vale, entendido el problema, sigue presente la cuestión ¿cómo comunicar los procesos?.
The cool way
El caso era que hay que utilizar un area de datos común para que ambos (lectores y escritores) puedan comunicarse. Yo me decidí por un area de memoria compartida. ¿Uh?. Si, PHP implementa una serie de funciones para la gestión de memoria compartida y semáforos. Tal memoria es un area de datos identificada por una clave de tal forma que cualquier proceso que conozca la clave, puede acceder (leer y escribir) en ese area de memoria. Es ultrarápida y gracias a los semáforos puedes controlar múltiples accesos para no corromper los datos.
Ya está, descubierta la forma de implementarlo, vamos a reunir todos los pedacitos en un código PHP que realice la tarea.
1 <?php 2 require_once("eventmanager.php"); 3 $id_semaforo = 666; 4 $id_memoria = 123; 5 6 session_start(); 7 $_SESSION['nick'] = $_GET['who']; 8 9 header("Expires: Mon, 26 Jul 1997 05:00:00 GMT" ); 10 header("Last-Modified: ".gmdate("D, d M Y H:i:s")."GMT"); 11 header("Cache-Control: no-cache, must-revalidate" ); 12 header("Pragma: no-cache"); 13 14 if( $_GET['accion'] == "send") { 15 $sem = sem_get( $id_semaforo ); 16 if( $sem !== false) { 17 $mem = shm_attach( $id_memoria ); 18 $em = @shm_get_var( $mem, 1 ); 19 if( $em === false ) $em = new EventManager(); 20 $em->post( new Event(time(), $_GET['msg'] ) ); 21 sem_acquire( $sem ); 22 shm_put_var( $mem, 1, $em ); 23 sem_release( $sem ); 24 shm_detach( $mem ); 25 return true; 26 } else { 27 echo "fallo al crear el semáforo."; 28 return; 29 } 30 } 31 elseif( $_GET['accion'] == "listen" ) { 32 if( !isset($_SESSION['last_time']) ) $_SESSION['last_time'] = time(); 33 $time_start = time(); 34 $last_time = $_SESSION['last_time']; 35 $_SESSION['last_time'] = $last_time+30; 36 37 session_write_close(); 38 while( ($time_start+30) > time() ){ 39 $mem = shm_attach( $id_memoria ); 40 $em = @shm_get_var( $mem, 1 ); 41 42 if($em){ 43 $eventos = $em->last_from( $last_time ); 44 foreach( $eventos as $evento ){ 45 $last_time = $evento->time; 46 echo $evento->toJSON()."\n"; 47 flush(); 48 } 49 } 50 shm_detach( $mem ); 51 usleep(1000); 52 } 53 } 54 ?>
Vayamos pasito a pasito. Lo primero que hacemos es importar un archivo llamado 'eventmanager.php'. Lo veremos después.
Las variables de las lineas 3 y 4 son los identificadores que utilizaremos para referirnos al semáforo y al área de memoria compartida respectivamente. Como veis, he utilizado números enteros, pero he visto sitios por ahí que utilizan ficheros para sacar identificadores únicos. De todas formas, eso trasciende los objetivos de este texto.
Lineas 6 y 7: comenzamos la sesión y nos guardamos el nick del usuario. Cutre pero no quería liarme la manta a la cabeza para tan poca cosa.
Lineas de la 9 a la 12:Estas son una serie de cabeceras que encontré en internet para asegurarme que el navegador no intentaba cachear la página. Puede ser útil para otros usos.
El escritor
Linea 14: En este if tenemos el control principal de nuestra aplicación. Las dos únicas acciones posibles son 'listen' y 'send'.
Lineas 15 a 17: Adquirimos las referencias al semaforo y la memoria compartida.
Lineas 18 a 19: Extraemos de la memoria compartida la variable $em. Esta es una instancia de la clase EventManager que veremos después. Notar que en la linea 19 nos creamos una instancia si detectamos que no había ninguna creada.
Linea 20: Esta linea la explicaré posteriormente.
Lineas 21 a 24: Ahora viene lo bueno. En la 21 adquirimos el semáforo. Hay que notar que la función sem_acquire es bloqueante, es decir, detiene la ejecución hasta que consigue hacerse con el semáforo (porque otro proceso lo libera). En la 22 escribimos en la memoria e inmediatamente después, liberamos el semáforo.
Tened en cuenta que una vez que adquirimos el semáforo, estramos en la 'zona crítica'. Todo el tiempo que el proceso permanezca en esa zona, es tiempo que el resto de procesos está a la espera. Salimos de esa zona liberando el semáforo. SIEMPRE hay que liberar el semáforo.
El lector
Linea 33: Nos vamos a definir una variable de sesión para guardarnos la hora de la última lectura. Con esto evitaremos perder mensajes (espero). En la variable $time_start guardaremos la hora actual, mientras que en $last_time guardamos la última vez que se ejecutó el lector.
Linea 37: Cerramos la sesión. Es necesario puesto que las sesiones PHP son bloqueantes para el usuario, es decir, un mismo usuario no puede abrir más de una sesión al mismo tiempo. Si no cerraramos la sesión nos encontraríamos que el proceso lector se ejecutaría durante 30 segundos bloqueando los posibles escritores hasta el fin de su ejecución. Eso no estaria nada bien, ¿verdad?.
Linea 38: Entramos en un while durante 30 segundos. Lineas 39 y 40: Leemos la variable de la memoria compartida. Por lo que he leido, no es necesario utilizar semáforos en operaciones de lectura.
Lineas 43 a 48: Utilizamos el método last_from para obtener los últimos eventos a partir de $last_time. Recorremos los eventos y los vamos escribiendo en JSON. Hacemos un flush para asegurarnos que el PHP vacia el buffer de salida.
Linea 51: Dormimos el proceso durante un segundo para no quemar el procesador.
Como veis, no tiene mayor dificultad. Lo único que me falta por comentar es la clase EventManager, que os muestro a continuación.
1 <?php 2 require_once("eventmanager.php"); 3 $id_semaforo = 666; 4 $id_memoria = 123; 5 6 session_start(); 7 $_SESSION['nick'] = $_GET['who']; 8 9 header("Expires: Mon, 26 Jul 1997 05:00:00 GMT" ); 10 header("Last-Modified: ".gmdate("D, d M Y H:i:s")."GMT"); 11 header("Cache-Control: no-cache, must-revalidate" ); 12 header("Pragma: no-cache"); 13 14 if( $_GET['accion'] == "send") { 15 $sem = sem_get( $id_semaforo ); 16 if( $sem !== false) { 17 $mem = shm_attach( $id_memoria ); 18 $em = @shm_get_var( $mem, 1 ); 19 if( $em === false ) $em = new EventManager(); 20 $em->post( new Event(time(), $_GET['msg'] ) ); 21 sem_acquire( $sem ); 22 shm_put_var( $mem, 1, $em ); 23 sem_release( $sem ); 24 shm_detach( $mem ); 25 return true; 26 } else { 27 echo "fallo al crear el semáforo."; 28 return; 29 } 30 } 31 elseif( $_GET['accion'] == "listen" ) { 32 if( !isset($_SESSION['last_time']) ) $_SESSION['last_time'] = time(); 33 $time_start = time(); 34 $last_time = $_SESSION['last_time']; 35 $_SESSION['last_time'] = $last_time+30; 36 37 session_write_close(); 38 while( ($time_start+30) > time() ){ 39 $mem = shm_attach( $id_memoria ); 40 $em = @shm_get_var( $mem, 1 ); 41 42 if($em){ 43 $eventos = $em->last_from( $last_time ); 44 foreach( $eventos as $evento ){ 45 $last_time = $evento->time; 46 echo $evento->toJSON()."\n"; 47 flush(); 48 } 49 } 50 shm_detach( $mem ); 51 usleep(1000); 52 } 53 } 54 ?>
Las dos clases que nos definimos aquí son EventManager y Event. La primera tiene como propósito servir de almacén para los últimos 20 eventos producidos. La segunda es una representación de un evento con las propiedades básicas como time o id.
El evento puede autorepresentarse en notación JSON mediante el método toJSON. Esta representación será la que se envíe a los clientes y se evaluará en javascript dando lugar a un objeto.
Conclusiones
Las posibilidades de esta amalgama de tecnologías es desbordante. Su implementación, salvados algunos escollos iniciales, es sencilla y fácil de adaptar. Por contra, la parte servidor sufre una carga adicional en peticiones y sobre todo, en la duración de estas. No es un obstaculo insalvable, pero la cantidad de CPU y las conexiones máximas permitidas por el Apache deberían ser ajustadas para obtener el mejor rendimiento.
En resumen, que mola!.
Primera parte de este tutorial..
Artículo escrito por Francisco Javier Nieto Borrallo.