Una aplicación web a prueba de falta de conectividad

A raíz de un artículo que escribí para mi newsletter me llegó esta pregunta:

Y como no puedo negarme a un pedido semejante, aquí estoy 🙂

Este va a ser un post algo atípico ya que el protagonista no será, como acostumbro, PHP si no JavaScript, por una razón sencilla: la acción más importante sucederá del lado del cliente y no del servidor.

Voy a hacer una aplicación del estilo prueba de concepto, es decir, van a quedar unos cuantos «cabos sueltos» pero la idea es que comprendas el principio detrás de esto.

El escenario que planteo es el siguiente:

Existe una base de datos en el servidor y muchos clientes interactuando con ella a la vez.

Un ejemplo real de esto es una aplicación tipo Google Docs o tal vez un juego online multi-jugador (Acá sólo me queda imaginar porque no tengo mucha experiencia en este área :p).

Pues bien, un modo de encarar este problema pensando en conexiones poco confiables es utilizar un esquema tipo event sourcing donde, en lugar de almacenar el estado actual de los objetos guardamos la secuencia de eventos que conducen a que las cosas sean como las vemos.

De este modo, cada cliente enviará al servidor sus novedades, el servidor las recibirá y las re-distribuirá a los demás y dejará en manos de cada cliente refrescar la vista.

Un problema que deberemos resolver es el de la sincronización: el orden en que sucedieron los eventos es importante y los relojes de cada uno de los clientes pueden estar des-sincronizados con lo cual no serán suficientemente confiables.

La solución que yo elegí es tomar la hora del servidor como base y que cada cliente marque los tiempos como la cantidad de segundos que han transcurrido desde la primera marca enviada por el servidor.

Para no complicar más el ejemplo dejaré de lado cuestiones como la resolución de conflictos, aunque es claro que en un escenario real habría que tomarlos en cuenta también.

Otra pequeña licencia que me tomaré será asumir que la primera interacción con el servidor es siempre exitosa.

Para simular la falta de conectividad usaré un botón que permita detener/comenzar la sincronización automática.

En el caso real utilizaría simplemente una verificación de Navigator.online en forma periódica y, al detectar que hay conectividad aprovechar para enviar los eventos que corresponden.

Ajax

La base de este script es la utilización de peticiones asincrónicas (más conocidas como Ajax).

Mediante esta tecnología es posible realizar aplicaciones de tipo single-page (de una sola página).

Se carga una vez un HTML que hace de marco y, a través de JavaScript se manejan las interacciones con el servidor.

LocalStorage

El otro concepto importante sobre el que se basa esta pequeña aplicación es el de almacenamiento local.

Se trata de una característica de los navegadores web modernos que permite almacenar información en formato clave-valor.

Esto me permitirá persistir información del lado del cliente aún en el caso de que la sesión se cierre abruptamente (Por ejemplo por un corte de luz).

Y ahora sí, vamos a ver algo de código 🙂

El código

El backend

Del lado del servidor tendremos un simple archivo php:

<?php

const FILE_NAME = 'events.json';
$lastSync = $_GET['lastSync'];
$clientId = $_GET['clientId'];

$events = is_readable(FILE_NAME) ? json_decode(file_get_contents(FILE_NAME), true) : [];

$newEvents = array_filter($events, function (array $event) use ($clientId, $lastSync) {

    return $event['clientId'] != $clientId && $event['timestamp'] > $lastSync;
});

$receivedText = file_get_contents('php://input');

error_log('Got ' . $receivedText);
$receivedEvents = json_decode($receivedText,true);

foreach ($receivedEvents as $receivedEvent) {
    $receivedEvent['clientId'] = $clientId;
    $events[] = $receivedEvent;
}

usort($events, function ($e1, $e2) {
    if ($e1['timestamp'] == $e2['timestamp']) {

        return 0;
    } elseif ($e1['timestamp'] > $e2['timestamp']) {

        return 1;
    } else {

        return -1;
    }
});

file_put_contents(FILE_NAME, json_encode($events));

error_log('Sending new events: '.json_encode($newEvents));

echo json_encode([
    'newEvents' => $newEvents,
    'clientId' => $clientId ?: uniqid(),
    'timestamp' => time(),
]);

Este pequeño servidor se encargará de enviar a cada cliente los eventos que sucedieron luego de una cierta marca de tiempo (Que se supone corresponde con el último evento que el cliente recibió del servidor) en algún otro cliente.

A su vez, tomará los nuevos eventos enviados por el cliente y los incorporará a la base de datos centralizada.

Por último, para el caso especial en que el cliente no haya informado su id se asumirá que se trata de la primera interacción, con lo cual, le será asignado uno.

El frontend

Por el lado del frontend tendremos dos archivos, un HTML:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Records</title>
</head>
<body>
<h1 style="display: none;" id="clientInfo">Soy el cliente <span id="clientId"></span></h1>
<table id="record_table" border="1">
    <thead>
    <tr>
        <th>Id</th>
        <th>Tiempo</th>
        <th>Contenido</th>
        <th></th>
    </tr>
    </thead>
    <tbody>
    </tbody>
</table>
<p><label for="newContent">Agregar:</label> <input id="newContent"/></p>
<p>Sincronización: <span id="syncEnabled"></span>
    <button id="toogleSync"/>
</p>
</body>
</html>
<script type="text/javascript" src="sync.js"></script>

Y un JavaScript:

let clientId = localStorage.getItem('clientId') || '';
let events = JSON.parse(localStorage.getItem('events')) || [];
let nonSynced = JSON.parse(localStorage.getItem('nonSynced')) || [];
let syncEnabled = false;
let initialTime = events.length ? events[events.length - 1].timestamp : 0;
let lastSync = 0;
let elapsed = 0;
let nextLocalId = 1;

document.getElementById('clientId').innerText = clientId;
document.getElementById('syncEnabled').innerText = 'deshabilitada';
document.getElementById('toogleSync').innerText = 'Habilitar';

refreshTable();

window.setInterval(function () {
    if (navigator.onLine && clientId && syncEnabled) {
        sync();
    }
}, 3000);

window.setInterval(function () {
    elapsed += 1;
}, 1000);

if ('' == clientId) {
    sync();
}

document.getElementById('toogleSync').addEventListener('click', toggleSync);

document.getElementById('newContent').addEventListener('keyup', function (event) {
    if ("Enter" == event.code) {
        addRecord(this.value, initialTime + elapsed);
        this.value = '';
    }
});

function toggleSync() {
    syncEnabled = !syncEnabled;

    if (syncEnabled) {
        document.getElementById('syncEnabled').innerText = 'habilitada';
        document.getElementById('toogleSync').innerText = 'Deshabilitar';
    } else {
        document.getElementById('syncEnabled').innerText = 'deshabilitada';
        document.getElementById('toogleSync').innerText = 'Habilitar';
    }
}

function removeRecord(id) {
    let event = events.find(event => event.id == id);

    if (nonSynced.find(event => event.id == id)) {
        removeAddEvent(event.id);
    } else {
        addDeleteEvent(event.id);
    }

    events = events.filter(event => event.id != id);

    refreshTable();
}

function addDeleteEvent(id) {
    let newEvent = {
        action: 'D',
        id: id,
        timestamp: initialTime + elapsed
    };

    events.push(newEvent);
    nonSynced.push(newEvent);
}

function removeAddEvent(id) {
    events = events.filter(event => event.id != id);
    nonSynced = nonSynced.filter(event => event.id != id);

    localStorage.setItem('events',JSON.stringify(events));
    localStorage.setItem('nonSynced',JSON.stringify(nonSynced));
}

function addRecord(value, timestamp, id = null) {
    let newEvent = {
        'timestamp': timestamp,
        'action': 'A',
        'contents': value,
        'id': id ? id : clientId + '-' + nextLocalId++,
    };

    if (!id) {
        nonSynced.push(newEvent);
        localStorage.setItem('nonSynced', JSON.stringify(nonSynced));
    }

    events.push(newEvent);
    localStorage.setItem('events',JSON.stringify(events));

    events.sort(function (event1, event2) {
        if (event1.timestamp == event2.timestamp) {

            return 0;
        } else if (event1.timestamp > event2.timestamp) {

            return 1;
        } else {

            return -1;
        }
    });

    refreshTable();
}

function refreshTable() {
    let table = document.getElementById('record_table');
    let oldBody = table.tBodies[0];
    let newBody = document.createElement('tbody');

    let addEvents = events.filter(event => 'A' === event.action);
    for (let i in addEvents) {
        if (events.find(event => 'D' === event.action && addEvents[i].id == event.id)) {
            continue;
        }

        let row = newBody.insertRow();

        let cell = document.createElement('td');
        cell.innerText = addEvents[i].id;
        row.append(cell);

        cell = document.createElement('td');
        cell.innerText = addEvents[i].timestamp;
        row.append(cell);

        cell = document.createElement('td');
        cell.innerText = addEvents[i].contents;
        row.append(cell);

        cell = document.createElement('td');
        cell.innerHTML = '<button onclick="removeRecord(\'' + addEvents[i].id + '\')">Eliminar</button>';
        row.append(cell);
    }

    table.replaceChild(newBody, oldBody);
}

function addRemoteEvents(newEvents) {
    for (let i in newEvents) {
        events.push(newEvents[i]);
        lastSync = newEvents[i].timestamp;
    }

    events.sort(function(e1, e2) {
        if (e1.timestamp == e2.timestamp) {

            return 0;
        } else if (e1.timestamp > e2.timestamp) {

            return 1;
        } else {

            return -1;
        }
    });
    localStorage.setItem('events',JSON.stringify(events));
}

function sync() {
    var xhttp = new XMLHttpRequest();

    document.getElementById('newContent').disabled = true;
    xhttp.onreadystatechange = function () {
        if (this.readyState == 4 && this.status == 200) {
            document.getElementById('newContent').disabled = false;
            let response = JSON.parse(this.responseText);
            if (!clientId) {
                clientId = response.clientId;
                localStorage.setItem('clientId', clientId);
                document.getElementById('clientId').innerText = clientId;
                initialTime = response.timestamp;
                document.getElementById('clientInfo').style = 'display: block;';
            }
            addRemoteEvents(response.newEvents);
            refreshTable();
            nonSynced = [];
            localStorage.setItem('nonSynced', JSON.stringify(nonSynced));
        }
    };
    xhttp.open("POST", "sync.php?lastSync=" + lastSync + "&clientId=" + clientId, true);
    xhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
    xhttp.send(JSON.stringify(nonSynced));
}

Este último (La parte más interesante) funciona de la siguiente manera:

Lo primero que se intenta es re-establecer el estado recurriendo a lo almacenado en LocalStorage.

Para atacar el problema de la sincronización tomamos un tiempo inicial provisto por el servidor y, cada vez que se produce un evento le sumamos la cantidad de segundos transcurridos en el cliente, de modo de que todos los eventos tengan una misma referencia.

Este algoritmo está lejos de ser perfecto, pero lo uso para ilustrar precisamente el desafío que supone tratar con eventos en forma distribuida.

Constantemente mantengo en memoria una lista de eventos y otra de eventos aún no sincronizados.

De esta forma, los eventos se van encolando mientras la sincronización no está activa y, apenas se detecta conectividad, se envían todas las novedades y se procesan las que los demás clientes han producido.

Esas mismas listas se almacenan en el LocalStorage para prevenir una desincronización por, por ejemplo, una súbita pérdida de energía.

Conclusión

Como puedes ver, darle este tipo de robustez a una aplicación web no es precisamente tarea sencilla

Honestamente, dudo de que haya muchos escenarios reales que lo justifiquen.

Seguramente existen frameworks JavaScript que resuelven estos problemas de una mejor forma, sólo quise mostrarlo en la versión cruda para que queden claras las dificultades por subsanar.

¿Te quedó alguna duda? Publica aca tu pregunta

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.

A %d blogueros les gusta esto: