Etiqueta: UX

  • Por qué tus páginas con DataTables tardan tanto en cargar

    Por qué tus páginas con DataTables tardan tanto en cargar

    Una página que tarda mucho en cargar es una página mala.

    Lo sabés vos y lo saben tus usuarios.

    Si tu sitio tarda más de 5 segundos en mostrarse tus visitantes se sentirán así:

    Las páginas que usan DataTables se ven bien y son muy funcionales pero pueden tardar una eternidad en desplegarse.

    Más de la mitad de los usuarios que no encuentran pronto lo que buscan se van a otro sitio.

    Y los que se quedan desconfían de la profesionalidad de quien las desarrolló.

    ¿Y quién quiere contratar a alguien que entrega trabajos de dudosa calidad?

    Más vale dedicar unos minutos a aprender cómo mejorarlas.

    Factores que afectan el tiempo de carga de tu página

    En su forma más simple, un DataTable puede cargarse usando un arreglo JavaScript.

    Basta un código como este:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="//cdn.datatables.net/1.10.24/css/jquery.dataTables.min.css">
        <title>DataTables example</title>
    </head>
    <body>
    <h1>Behold... the power of DataTables!</h1>
    <table id="theTable" class="display" style="width: 100%">
        <thead>
            <tr>
                <th>Id</th>
                <th>Name</th>
                <th>Price</th>
            </tr>
        </thead>
        <tfoot>
            <tr>
                <th>Id</th>
                <th>Name</th>
                <th>Price</th>
            </tr>
        </tfoot>
    </table>
    </body>
    </html>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
    <script src="https://cdn.datatables.net/1.10.24/js/jquery.dataTables.min.js"></script>
    <script type="application/javascript">
        $(document).ready( function () {
            $('#theTable').DataTable({
                'data': [
                    [1, 'Box', 10.0],
                    [2, 'Chair', 20.2]
                ],
            });
        } );
    </script>

    Por supuesto que si toda la información tuviese que estar hardcodeada no sería muy útil la aplicación, ¿cierto?

    Una versión algo más realista sería combinar este código con algo de PHP que levante información de una db, por ejemplo:

    <?php
    
    try {
        $conn = new PDO('sqlite:dt.sq3');
    } catch (PDOException $exception) {
        die($exception->getMessage());
    }
    
    $sql = "SELECT * FROM products";
    $st = $conn
    ->query($sql);
    
    if ($st) {
    $rs = $st->fetchAll(PDO::FETCH_ASSOC );
    ?>
    <script type="application/javascript">
        $(document).ready( function () {
            $('#theTable').DataTable({
                'data': [
                    <?php
                    foreach ($rs as $row) {
                        ?>
                    [ '<?php echo $row['id'];?>', '<?php echo $row['name'];?>', '<?php echo $row['price'];?>' ],
                        <?php
                    }
                    ?>
                ],
            });
        } );
    </script>
    <?php
    } else {
        var_dump($conn->errorInfo());
        die;
    }

    Hasta aquí, todo perfecto.

    El problema se presenta cuando la cantidad de registros es grande.

    La definición de grande es un poco esquiva… podés empezar a notar problemas con 500 registros pero si estás tratando con algo como 150 000 o más definitivamente tus páginas van a tardar una eternidad en mostrarse.

    Aún si tenés páginas pequeñas (Digamos, de 10 registros cada una), el tiempo total de carga no se modifica en absoluto…

    ¿Por qué sucede esto?

    Como en cualquier otro script, esto es, a grosso modo lo que ocurre:

    1. El servidor recibe la petición
    2. Se ejecuta el PHP
    3. Se realiza la consulta a la db
    4. El servidor recibe todos los registros de la tabla
    5. Se genera el HTML y el JavaScript
    6. Se envía todo al cliente
    7. El cliente interpreta el HTML y el JavaScript
    8. Se ejecuta el JavaScript y se dibuja la tabla y todo lo demás

    Como te podrás imaginar, para trabajar con los registros de la primera página no es realmente necesario tener en memoria las otras 500…

    Toda esa información que no se utilizará no hace más que sobrecargar tanto al servidor (especialmente al de bases de datos) como al cliente.

    Es más, si hay muchos usuarios accediendo a la vez el problema se va multiplicando, con lo cual la experiencia de uso es peor para todos 🙁

    Podrías intentar atacar el problema mejorar los recursos de hardware, implementando algún tipo de caché o similar y es posible que todo eso ayudara.

    Sin embargo, existe una solución mucho más al alcance de tus manos.

    La solución: Procesamiento del lado del Servidor

    Y aquí viene la genialidad de DataTables… ¡lo pensaron todo estos muchachos!

    Este plugin admite dos modos de procesamiento: Client-Side (Es decir, todo se hace en el navegador del cliente) o Server-Side (El procesamiento se hace del lado del servidor y sólo se realiza el rendering del lado del cliente).

    El modo por defecto es, por supuesto, Client-Side.

    He ahí la clave del problema.

    Trabajar con Server-Side es un poco más complicado, pero tampoco es terrible.

    Lo más importante es comprender que, en este modo, DataTables hará una llamada Ajax cada vez que se requiera re-dibujar la información de la tabla (Al paginar, ordenar, etc…).

    Para comenzar a usar este modo necesitás cambiar la configuración de tu DataTable por algo como:

    $('#theTable').DataTable( {
        serverSide: true,
        ajax: '/get_data.php'
    } );

    Claro que, a diferencia del ejemplo en que la tabla se carga por Ajax sólo al comienzo (o cuando se refresca), esta versión de get_data.php deberá ser bastante más inteligente para poder responder a las órdenes que le dará el FrontEnd.

    Qué envía el FrontEnd al BackEnd en DataTables Server-Side

    La comunicación entre FrontEnd y BackEnd se realizará a través del envío de parámetros a través de la URL de la llamada (salvo que lo cambies explícitamente).

    Esto implica que tu script php deberá recogerlos desde $_GET.

    A modo de resumen te comento los más importantes (La lista completa la podés encontrar acá):

    start: Número de registro por el que debe comenzar la paginación

    length: Cantidad de registros que se espera que devuelva el servidor. Es decir, a partir del registro start, retorná length registros (Similar al LIMIT X OFFSET Y de SQL)

    search: Arreglo que contiene los parámetros de la búsqueda realizada por el usuario. Esta búsqueda es global, es decir, aplica a todas aquellas columnas marcadas como searchable.

    • value: El texto ingresado por el usuario en el cuadro de búsqueda
    • regex: Booleano que indica si el value debe ser interpretado en forma literal o como expresión regular

    columns: Arreglo con una entrada por cada columna de la tabla.

    Cada entrada de este arreglo será, a su vez, un arreglo con las siguientes claves:

    • data: información de mapeo entre el arreglo de datos y la tabla HTML (En este ejemplo se trata del índice que vincula la columna en la visualización con el campo en la consulta: id => 0, name => 1, price => 2)
    • name: Nombre de la columna como está definido en la propiedad columns.name (En este ejemplo no lo estamos usando así que será un string vacío).
    • searchable: Booleano que indica si es posible realizar búsquedas sobre esta columna
    • orderable: Booleano que indica si es posible realizar ordenamientos basados en esta columna
    • search: Arreglo que contiene los parámetros de la búsqueda realizada por el usuario. Similar a la búsqueda global, sólo que aplicada a esta columna en particular (Un caso algo más avanzado y no muy frecuentemente utilizado).
      • value: El texto ingresado por el usuario en el cuadro de búsqueda
      • regex: Booleano que indica si el value debe ser interpretado en forma literal o como expresión regular

    order: Arreglo que especifica el criterio de ordenamiento que debe aplicarse, contendrá una entrada por cada criterio a utilizar (Para el caso de que se quieran combinar varios). Dentro de cada entrada contendrá un arreglo con dos claves:

    1. column: Número de columna por la que se quiere ordenar
    2. dir: String con la dirección del ordenamiento (asc para ascendente, desc para descendiente)

    Como te podrás imaginar, la URL de la llamada puede ser algo extensa 🙂

    ¿No me creés? ¿Qué te parece esta?

    http://localhost:8000/get_data.php?draw=1&columns%5B0%5D%5Bdata%5D=0&columns%5B0%5D%5Bname%5D=&columns%5B0%5D%5Bsearchable%5D=true&columns%5B0%5D%5Borderable%5D=true&columns%5B0%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B0%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B1%5D%5Bdata%5D=1&columns%5B1%5D%5Bname%5D=&columns%5B1%5D%5Bsearchable%5D=true&columns%5B1%5D%5Borderable%5D=true&columns%5B1%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B1%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B2%5D%5Bdata%5D=2&columns%5B2%5D%5Bname%5D=&columns%5B2%5D%5Bsearchable%5D=true&columns%5B2%5D%5Borderable%5D=true&columns%5B2%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B2%5D%5Bsearch%5D%5Bregex%5D=false&order%5B0%5D%5Bcolumn%5D=0&order%5B0%5D%5Bdir%5D=asc&start=0&length=10&search%5Bvalue%5D=&search%5Bregex%5D=false&_=1619613946473

    Si la pasás a través de urldecode queda un poquito más entendible:

    http://localhost:8000/get_data.php?draw=1&columns[0][data]=0&columns[0][name]=&columns[0][searchable]=true&columns[0][orderable]=true&columns[0][search][value]=&columns[0][search][regex]=false&columns[1][data]=1&columns[1][name]=&columns[1][searchable]=true&columns[1][orderable]=true&columns[1][search][value]=&columns[1][search][regex]=false&columns[2][data]=2&columns[2][name]=&columns[2][searchable]=true&columns[2][orderable]=true&columns[2][search][value]=&columns[2][search][regex]=false&order[0][column]=0&order[0][dir]=asc&start=0&length=10&search[value]=&search[regex]=false&_=1619613946473

    O, todavía mejor si lo ves desde la consola del navegador:

    De modo que si del lado de tu PHP hacés un var_dump($_GET) te vas a encontrar con:

    array (size=7)
      'draw' => string '1' (length=1)
      'columns' => 
        array (size=3)
          0 => 
            array (size=5)
              'data' => string '0' (length=1)
              'name' => string '' (length=0)
              'searchable' => string 'true' (length=4)
              'orderable' => string 'true' (length=4)
              'search' => 
                array (size=2)
                  ...
          1 => 
            array (size=5)
              'data' => string '1' (length=1)
              'name' => string '' (length=0)
              'searchable' => string 'true' (length=4)
              'orderable' => string 'true' (length=4)
              'search' => 
                array (size=2)
                  ...
          2 => 
            array (size=5)
              'data' => string '2' (length=1)
              'name' => string '' (length=0)
              'searchable' => string 'true' (length=4)
              'orderable' => string 'true' (length=4)
              'search' => 
                array (size=2)
                  ...
      'order' => 
        array (size=1)
          0 => 
            array (size=2)
              'column' => string '0' (length=1)
              'dir' => string 'asc' (length=3)
      'start' => string '0' (length=1)
      'length' => string '10' (length=2)
      'search' => 
        array (size=2)
          'value' => string '' (length=0)
          'regex' => string 'false' (length=5)
      '_' => string '1619615674442' (length=13)

    Y de ahí… bueno, habrá que hacer la consulta que corresponda, por ejemplo:

    <?php
    
    try {
        $conn = new PDO('sqlite:dt.sq3');
    } catch (PDOException $exception) {
        die($exception->getMessage());
    }
    
    $orderBy = " ORDER BY ";
    foreach ($_GET['order'] as $order) {
        $orderBy .= $order['column'] + 1 . " {$order['dir']}, ";
    }
    
    $orderBy = substr($orderBy, 0, -2);
    $where = '';
    
    $columns = $_GET['columns'];
    $fields = ['id', 'name', 'price'];
    $where = '';
    
    foreach ($columns as $k => $column) {
        if ($search = $column['search']['value']) {
            $where .= $fields[$k].' = '.$search.' AND ';
        }
    }
    
    $where = substr($where, 0, -5);
    $length = $_GET['length'];
    $start = $_GET['start'];
    
    $sql = "SELECT * FROM products ".($where ? "WHERE $where " : '')."$orderBy LIMIT $length OFFSET $start";

    (Me tomé una pequeña licencia… asumí que el search no será una expresión regular… te queda como ejercicio :))

    Cómo debe responder el BackEnd al FrontEnd en DataTables Server-Side

    Por otro lado, es importante que tu php sepa qué es lo que DataTables está esperando como respuesta, de otro modo la comunicación no podrá realizarse exitosamente.

    Lo principal: DataTables espera un resultado JSON que debe contener:

    data: El arreglo de los registros que se levantaron de la base

    recordsTotal: Número total de registros que existen en la base (Sin aplicar filtros)

    recordsFiltered: Número de registros que devuelve la consulta (Una vez aplicados los filtros).

    En definitiva, el código php se completa con:

    $countSql = "SELECT count(id) as Total FROM products";
    $countSt = $conn
        ->query($countSql);
    
    $total = $countSt->fetch()['Total'];
    
    $sql = "SELECT * FROM products ".($where ?? "WHERE $where ")."$orderBy LIMIT $length OFFSET $start";
    $st = $conn
        ->query($sql);
    
    if ($st) {
        $rs = $st->fetchAll(PDO::FETCH_FUNC, fn($id, $name, $price) => [$id, $name, $price] );
    
        echo json_encode([
            'data' => $rs,
            'recordsTotal' => $total,
            'recordsFiltered' => count($rs),
        ]);
    } else {
        var_dump($conn->errorInfo());
        die;
    }

    El código completo lo podés descargar de aquí.

    DataTables ServerSide FTW!

    En este artículo aprendiste las razones por las que tu página con DataTables tarda mucho en cargar y cómo podés acelerar los tiempos de carga.

    ¿Siempre te conviene usar ServerSide? No

    Si la cantidad de registros es pequeña (O más bien diría si no hay retrasos visibles) no te conviene hacer una implementación con esta complejidad, pero en cambio, si los DataTables se están volviendo un cuello de botella SeverSide puede ser una buena solución.

    Ahora que lo sabés, andá a acelerar esa página que los usuarios están esperando!

  • Un dashboard en tiempo real basado en PHP y Bootstrap

    Un dashboard en tiempo real basado en PHP y Bootstrap

    Algo que está muy de moda por estos días es la creación de tableros de comandos (Dashboards) que se mantengan actualizados en tiempo real.

    Si bien la definición de tiempo real es algo vaga (Formalmente se trata de sistemas en los cuales el tiempo de respuesta es crítico), hay una suerte de conocimiento en común respecto de lo que quiere decir: que los cambios se vean en forma inmediata (o casi).

    En lo que hace a aplicaciones web, de lo que se trata es de permitir al visitante recibir novedades sin tener que recargar la página.

    Para lograr este efecto se necesitan dos partes coordinadas:

    1. Una aplicación front-end con la que el usuario interactuará en forma directa
    2. Un servidor que mantenga la información actualizada en todo momento

    Un frontend para el Dashboard

    Del lado del frontend, lo que necesitaremos serán dos cosas:

    1. Una capa de presentación (HTML + CSS)
    2. Una capa de interacción con el backend (JavaScript)

    Presentación del dashboard

    Del lado de la presentación, dado que se trata de un dashboard, lo que querremos hacer es mostrar información resumida, en algún formato fácil de interpretar por una persona (Usualmente se tratará de gráficos pero podemos usar cualquier forma que nos parezca adecuada).

    Una librería que resulta muy útil para realizar este tipo de tareas es Bootstrap (Que, de paso, nos da una visualización estándar).

    En este ejemplo haré un dashboard muy sencillo, sólo a modo de ilustración, pero con los mismos principios puedes hacer otros mucho más vistosos.

    Veamos el HTML:

    <!doctype html>
    <html lang="en">
    <head>
        <!-- Required meta tags -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    
        <!-- Bootstrap CSS -->
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
              integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    
        <title>Hello, world!</title>
    </head>
    <body>
    <h1>Hola, este es mi dashboard</h1>
    <div class="container">
        <div class="row">
            <div class="col-sm">
                <div class="card">
                    <div class="card-body">
                        Usuarios conectados en este momento: <strong><span id="connected_users">0</span></strong>
                    </div>
                </div>
            </div>
            <div class="col-sm">
                <div class="card">
                    <div class="card-body">
                        Facturación del día: <strong>$ <span id="daily_revenue">0.00</span></strong>
                    </div>
                </div>
            </div>
        </div>
    </div>
    </body>
    </html>

    Aquí lo que estoy haciendo es crear el esqueleto de mi dashboard.

    Para el ejemplo tomé dos métricas que pueden resultar interesantes:

    • Cantidad de usuarios conectados
    • Facturación del día

    La pantalla inicial se verá así:


    Si te fijas, el tag link (en la sección head) incluye el CSS que necesito para usar Bootstrap.

    Interacción con el backend del dashboard

    El siguiente paso es agregar la interacción con el backend (¡Que aún no está hecho! Cierto… no nos adelantemos, al final del artículo tendrás todo lo que buscas :).

    Lo primero es incluir la librería jQuery:

    <script
            src="https://code.jquery.com/jquery-3.4.1.min.js"
            integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
            crossorigin="anonymous"></script>

    Podría escribir todo el JavaScript necesario sin utilizarla, es cierto, pero sería muchísimo más trabajo del necesario y, honestamente, prefiero evitármelo :p

    Algo que puedes notar es que estoy incluyendo la librería desde otro servidor (code.jquery.com, un CDN).

    Y ahora me queda incluir algo de código mío (el que efectivamente interactuará con mi servidor):

    <script type="text/javascript">
        window.setInterval(function () {
            updateStats();
        }, 2000);
    
        function updateStats() {
            updateConnectedUsers();
            updateDailyRevenue();
        }
    
        function updateConnectedUsers() {
            jQuery.get(
                'get_connected_users.php',
                function (data) {
                    $('#connected_users').text(data);
                }
            );
        }
    
        function updateDailyRevenue() {
            jQuery.get(
                'get_daily_revenue.php',
                function (data) {
                    $('#daily_revenue').text(data);
                }
            );
        }
    </script>

    Aquí lo que estoy haciendo es generar un mecanismo de polling de modo que el cliente esté constantemente pidiendo actualizaciones al servidor (y reflejándolas en el HTML para el visitante).

    Muy bien, sólo nos queda ver el backend 🙂

    Un backend para el dashboard

    Del lado del backend sólo debo crear los archivos que puedan responder a las preguntas del frontend (get_connected_users.php y get_daily_revenue.php).

    En mi ejemplo haré algo de trampa, no voy a consultar a una base de datos ni nada, simplemente generaré valores aleatorios, pero claramente, en una aplicación real, deberías usar algún tipo de almacenamiento.

    get_connected_users.php:

    <?php
    
    header('Content-Type: text/javascript');
    echo rand(0, 200);

    get_daily_revenue.php:

    <?php
    
    session_start();
    
    $acummulatedRevenue = $_SESSION['acummulatedRevenue'] ?? 0;
    
    header('Content-Type: text/javascript');
    
    $_SESSION['acummulatedRevenue'] = $acummulatedRevenue + rand( 0, 100000 ) / 100;
    
    echo number_format( $_SESSION['acummulatedRevenue'], 2, ',', '.' );

    Lo importante de ambos scripts es la línea header(‘Content-Type: text/javascript’);.

    Esto es lo que hace que la llamada Ajax entienda que lo que recibe del servidor es, efectivamente, texto jSON

    Dashboards más profesionales

    Con lo que te mostré hasta aquí tienes todo lo necesario para hacer un dashboard básico (y bastante poco estético :p).

    Ciertamente existen plantillas de dashboards mucho más atractivas que puedes usar (o ser creativo y crear las tuyas!), algunas que puedes probar:

    ¡Ya estás list@ para agregar bonitos dashboards a tus aplicaciones! ¡Adelante!

  • Cómo hacer un autocomplete con PHP

    Cómo hacer un autocomplete con PHP

    Es muy común hoy en día encontrarnos con formularios que deben completarse mediante alguna opción pre-existente en el sistema.

    Si las opciones son pocas, lo más usual es utilizar un dropdown (un objeto basado en el tag select de HTML), pero si la cantidad de opciones es grande, esto puede volverse un fastidio para el usuario.

    Una forma mejor es dejar que el usuario ingrese el texto que quiera y dejar que el sistema autocomplete el resto.

    Para lograr este efecto se requiere una combinación de factores:

    1. Un servicio que pueda tomar el texto introducido por el usuario y devuelva una lista de opciones disponibles que coincidan.
    2. Una página capaz de tomar la entrada del usuario, interactuar con el servicio y presentar las opciones al usuario.

    Un servicio que busque opciones que coincidan con el texto que ingresó el usuario

    Vamos a comenzar por diseñar un script php que resuelva esta parte del problema:

    <?php
    
    $options = [
            "ActionScript",
          "AppleScript",
          "Asp",
          "BASIC",
          "C",
          "C++",
          "Clojure",
          "COBOL",
          "ColdFusion",
          "Erlang",
          "Fortran",
          "Groovy",
          "Haskell",
          "Java",
          "JavaScript",
          "Lisp",
          "Perl",
          "PHP",
          "Python",
          "Ruby",
          "Scala",
          "Scheme"
    ];
    
    if ( $term = $_GET['term'] ?? '' ) {
            $matches = array_filter( $options, function( $option ) use ( $term ) {
                    return strpos( strtolower($option), $term ) !== false;
            } );
    
            header( 'Content-Type: text/javascript' );
            echo json_encode( array_values($matches) );
    }

    A este pequeño script le llamaremos get_matches.php.

    Un formulario que use el autocomplete

    La segunda parte de la solución es un formulario capaz de tomar la entrada del usuario, enviársela al servicio y actuar según la respuesta recibida.

    Se verá de este modo:

    <html>
    <body>
    <form>
    	<p><label for="language">Language</label><input type="text" name="language" id="language"/></p>
    	<p><select id="multiple" style="display: none;" size="10"/></p>
    </form>
    </body>
    <script src="https://code.jquery.com/jquery-1.12.4.js"></script>
      <script>
    	$( "#language" ).on( 'input', function() {
    			$('#multiple').hide();
    			$.ajax(
    				{
    					url: 'get_matches.php?term=' + $(this).val(),
    					success: function( data ) {
    						if ( data.length == 1 ) {
    							$('#language').val( data[0] );
    						} else {
    							if ( data.length > 1 ) {
    								$("#multiple").find('option').remove();
    								data.forEach( function(e) {
    									$("#multiple").append("<option>" + e + "</option>");
    								});
    								$("#multiple").prop( 'size', data.length );
    								$("#multiple").show();
    							}
    						} 
    					},
    					dataType: 'json',
    				}
    			);
    		}
    	);
    	
    	$( "#multiple" ).click( function() {
    		$('#language').val( $(this).val() );
    		$('#multiple').hide();
    	}
    	);
      </script>
    </html>

    Para verlo todo funcionando basta con iniciar el servidor incorporado a PHP:

    php -S localhost:8000

    Y abrir un navegador en localhost:8000 para ver:

    Y luego, a medida que vayamos escribiendo veremos aparecer opciones:

    Y cuando hayamos escrito una opción que acota la lista a un solo candidato automáticamente se llenará el campo.

    Algunas notas

    Habrás notado que, en realidad, la magia se hace del lado del frontend (¡Mucho javascipt!).

    Lo que se podría (y probablemente debería!) modificarse es el modo de conseguir las opciones desde PHP.

    En este ejemplo usamos un arreglo estático, pero en un caso más real lo haríamos a través de alguna consulta a una base de datos, algo como:

    SELECT name FROM languages WHERE name LIKE '%TERM%';

    Reemplazando TERM por lo que haya ingresado el usuario.

    Esto funcionará bien mientras la tabla sobre la que queramos buscar no sea extremadamente grande (Unos pocos miles de registros debería soportar sin mucho problema)… si la base es más grande o queremos hacer búsquedas menos exactas deberemos utilizar alguna otra herramienta como Solr, Sphinx o ElasticSearch.

  • Cómo prevenir la subida duplicada

    Cómo prevenir la subida duplicada

    Ultimamente vengo trabajando bastante con procesamiento de planillas Excel usando PHP.

    Por lo general, el workflow del usuario es algo así como:

    • Trabajar con algún otro sistema (HomeBanking, Plataforma de trading, etc…)
    • Descargar información en formato Excel
    • Importar planilla descargada al sistema que yo desarrollé
    • Trabajar la información dentro del sistema

    Uno de los errores comunes cuando una persona carga información a un sistema es el de la carga duplicada.

    Este problema se agrava cuando los duplicados no siempre son errores .

    Esta condición hace que no sea simple detectar y prevenir la importación duplicada.

    El escenario sería algo como tomar el workflow original y modificarlo ligeramente:

    • Trabajar con algún otro sistema (HomeBanking, Plataforma de trading, etc…)
    • Descargar información en formato Excel
    • Importar planilla descargada al sistema que yo desarrollé
    • Salir por un café
    • Olvidar qué fue lo último que se hizo
    • Volver a importar planilla descargada al sistema que yo desarrollé
    • Trabajar la información dentro del sistema

    Siendo que el sistema no puede automáticamente eliminar un registro sólo porque su contenido ya exista parece poco lo que puede hacerse…

    Mi idea de solución se basa en intentar detectar archivos que ya han sido subidos al sistema de un modo eficiente.

    Disclaimer: la solución que voy a descibir aquí es, hasta el momento, también teórica (más allá de alguna prueba de concepto).

    Cómo detectar archivos ya subidos

    Una primera idea es:

    1. Almacenar todos los archivos subidos
    2. Realizar una comparación binaria de los contenidos del nuevo archivo subido contra todos los anteriores

    No me suena muy eficiente que digamos :p

    Una idea algo más elaborada es la de usar un hash como base de comparación.

    La idea es por cada archivo subido calcularlo y comparar contra los ya existentes.

    Lo interesante de este método es que el cálculo es rápido y la comparación es casi trivial.

    Un punto a tener en cuenta sin embargo es que, aunque muy poco probable, existe el riesgo de «falsos positivos».

    Dos strings diferentes pueden tener el mismo hash, con lo cual el sistema podría dar por duplicado dos archivos que no tengan nada que ver.

    Para evitar este problema dejaré la solución 1 como fallback para los casos en que encuentre hashes duplicados.

    Implementación en PHP de detección de subidas duplicados

    No te voy a dejar sólo con palabras, ¿cuál sería la gracia, cierto?

    Vamos a ver algo de código:

    Voy a almacenar todo el contenido de los archivos en una base de datos.

    A efectos de esta prueba me basta con SQLite, pero podés usar la que te resulte más cómoda (o guardar los archivos en algún directorio si preferís).

    Entonces, lo primero es crear la base usando este SQL:

    CREATE TABLE "files" (
    	`id`	INTEGER PRIMARY KEY AUTOINCREMENT,
    	`contents`	BLOB,
    	`hash`	TEXT
    );
    CREATE TABLE sqlite_sequence(name,seq);

    Luego tendremos dos archivos, el que permite subir el archivo (HTML) y el que recibe el archivo (PHP):

    upload.html

    <form action="get_file.php" method="post" enctype="multipart/form-data">
        <p>Importar: <input type="file" name="fileToUpload"/></p>
        <p><input type="submit" value="Subir"/></p>
    </form>

    get_file.php

    <?php
    /**
    * Created by PhpStorm.
    * User: mauro
    * Date: 1/9/19
    * Time: 12:46 PM
    */

    $tmp_name = $_FILES["fileToUpload"]['tmp_name'];
    $hash = sha1_file($tmp_name);

    echo 'Hash for uploaded file: "' . $hash . '"<br/>';

    $new_file_contents = file_get_contents($tmp_name);

    $db = new PDO('sqlite:uploded.sq3');

    if ( $suspects = find_file_by_hash($hash) ) {
    foreach ( $suspects as $suspect ) {
    if ( $suspect == $new_file_contents) {
    die ('Duplicate file');
    }
    }
    }

    if ( save_file_record( $new_file_contents, $hash ) ) {
    echo 'File saved ok!';
    } else {
    echo 'Saving error :(';
    }

    /**
    * @param $hash
    * @return array
    */
    function find_file_by_hash( string $hash ) : array
    {
    GLOBAL $db;

    $sql = "SELECT * FROM files WHERE hash = '$hash'";

    if ( $st = $db->query( $sql ) ) {

    return array_map( function( $r ) {

    return $r['contents'];
    }, $st->fetchAll( PDO::FETCH_ASSOC ) );
    } else {

    return [];
    }
    }

    /**
    * @param string $contents
    * @param string $hash
    * @return int
    */
    function save_file_record( string $contents, string $hash ) : bool
    {
    GLOBAL $db;

    $sql = "INSERT INTO files ( contents, hash ) VALUES ( :contents, :hash )";

    $st = $db->prepare( $sql );
    $st->bindParam('contents', $contents, PDO::PARAM_LOB );
    $st->bindParam('hash', $hash );

    if ( !$st->execute() ) {
    print_r( $st->errorInfo() );

    return false;
    }

    return true;
    }

    Para hacer correr este código sólo hay que grabar los archivos en un mismo directorio e iniciar el servidor web que viene con PHP:

    php -S localhost:8080

    Y subir un archivo varias veces. La primera deberías ver el mensaje «File saved ok!» y la segunda «Duplicate file».

    Listo! No te recomiendo implementar el código así como está, pero espero que haya servido para explicar la idea :).

    Si preferís una versión en video, acá hay un link a YouTube.

  • Cómo mostrar resultados de un proceso largo en tiempo real en una aplicación web

    Cómo mostrar resultados de un proceso largo en tiempo real en una aplicación web

    Un caso interesante en el que me tocó trabajar fue la implementación de un sistema de gamification para una red social de viajeros en la que trabajaba.

    Los responsables del producto estaban muy interesados en fomentar la generación de contenido por parte de los usuarios del sitio y se les ocurrió que ofrecer «galardones» a quienes más contenido subían a la plataforma era una buena forma de lograrlo.

    Dejando de lado la discusión sobre la viabilidad de la estrategia, hay unas cuantas lecciones interesantes desde el punto de vista de la implementación técnica.

    Algo de información de contexto:

    1. Las reglas de obtención de los galardones no eran triviales (Tampoco eran super complejas, básicamente, según el tipo de contenido de que se trataba había un número mínimo de aportes que permitían alcanzar el siguiente nivel y ese número iba aumentando, haciendo que alcanzar los niveles más elevados fuera mucho más difícil que los primeros).
    2. Era muy importante dar satisfacción inmediata al usuario que hiciera aportes.

    Dimos muchas vueltas respecto de cómo lograr esto, que implicaba la actualización de contadores en una base de datos relativamente grande, la evaluación de las reglas y el rendering del mensaje adecuado para la situación que el usuario acababa de generar.

    La solución que encontramos fue hacer un pequeño truco: hicimos el cálculo en el FrontEnd y en el BackEnd:

    Lo primero que hacíamos era enviar la información que el cliente necesitaría para galardonar al usuario en caso de ser necesario (las reglas de cálculo de los galardones y el estado actual de la cuenta del usuario) y luego asentábamos ese cambio en la base de datos.

    De esta forma logramos que el usuario tuviera su cucarda al momento exacto en que cruzaba el umbral y, a la vez, que el resultado no se perdiera.

    Esta es una forma más de tratar con procesos largos que, en un entorno de usuarios impacientes (como es una red social), funciona muy bien :).

    Un tiempo más tarde leí que Apple había usado un truco similar para lograr que el IPhone mostrara la lista de aplicaciones disponibles al momento en que el teléfono se encendía: tenían pre-calculada una imagen estática que se mostraba mientras la aplicación terminaba de cargar.

    Siempre me gustaron estos ejemplos de combinación de ingeniería con cierta picardía (como el de los espejos en el hotel de ascensores lentos).

    ¿Tenés algún otro ejemplo de cómo tratar con este tipo de situación?

  • Cómo mostrar progreso de procesamiento en un entorno Web

    Cómo mostrar progreso de procesamiento en un entorno Web

    Cuando se requiere tratar con un proceso largo se presenta un problema. Existen varias alternativas (algunas las discutíamos acá).

    Idependientemente de cuál sea la estrategia elegida, el objetivo es siempre el mismo: evitar que el visitante se aburra (o piense que la aplicación se colgó o algo parecido).

     

    Algo que hasta hace un tiempo era impensable (o al menos muy poco práctico) era la posibilidad de ir mostrando progreso a medida que el procesamiento avanza.

    El truco se compone de tres partes:

    1. El front end
    2. El script del lado del servidor que ejecuta el proceso
    3. El script que reporta el progreso obtenido hasta el momento

    Del lado del frontend se usa Ajax (y probablemente jQuery o algún otro framework JavaScript) para disparar la acción, algo como:

    <input type="button" id="trigger" />
    <div id="progreso" style="visibility: hidden">
      <p id="mensaje"></p>
    </div>
    <script type="text/javascript">
    $('#trigger').click(function(){
     $.post(
      'iniciarProceso.php',
      {
        param: value
      },
      function( data ) {
       $('#mensaje').text('Proceso iniciado...');
       $('#progreso').show();
      }
     );
    })
    </script>

    Con esto hemos logrado que el proceso se inicie (y que al visitante se le muestre un mensaje indicador).

    Para completar nuestro cometido necesitamos contar con algún mecanismo que consulte periódicamente en qué estado está el proceso (lo que se conoce como polling) y un servicio (del lado del servidor) que pueda brindar la información requerida:

    Del lado del cliente será algo como:

    <script type="text/javascript">
    function pollServer()
    {
     $.get(
      'processStatus.php',
      {
        param: value
      },
      function( data ) {
       $('#mensaje').text( 'Porcentaje de avance: ' + data.percent );
      } 
     );
    }
    
    setTimeOut( pollServer, 2000 );
    </script>

    Con este código se está solicitando una actualización de progreso al servidor cada 2 segundos (y mostrando el resultado al usuario).

    Veamos ahora cómo debería ser el código de processStatus.php:

    <?php
    header('Content-Type: text/javascript');
    $percent = calculateProgress(); // Probablemente esto consultara a la BD o a algun otro registro de progreso
    
    echo json_encode($percent);
    ?>

    La clave de este sencillo proceso es la definición del header (De modo que el cliente entienda que recibe datos en un formato compatible con el request realizado vía Ajax).

    Obviamente, se pueden hacer implementaciones mucho más vistosas (Por ejemplo usando Bootstrap o mostrando imágenes en lugar de mensajes) pero la base de este mecanismo es esta.

    ¿En qué casos creés que te serviría usar esta técnica?

  • Cómo tratar con procesos largos en PHP

    Cómo tratar con procesos largos en PHP

    Antes de meternos en los detalles distingamos dos escenarios:

    1. PHP dentro del contexto de un WebServer
    2. PHP como lenguaje de scripting de algún proceso off-line.

    Cuando se trata del segundo caso, es posible que existan oportunidades de mejorar el tiempo que insume el proceso, pero, en la gran mayoría de los casos, no pasará de ser una pequeña molestia si no se consigue este objetivo.

    Cuando estamos en el contexto de WebServer, la molestia puede ser tal que haga que nuestros visitantes abandonen el sitio (Para no regresar jamás).

    Por otro lado, es de suponer que en un ambiente web la concurrencia es alta (Es decir, un script PHP que está ejecutando está compitiendo contra muchos otros similares a él por el uso de los recursos), con lo cual, el propio sistema suele tomarse algunos recaudos para evitar que muchos inocentes sufran por los desmanes de unos pocos alborotadores :).

    Uno de los límites que se nos impone (desde el archivo php.ini) es el tiempo máximo de ejecución (Que normalmente está en los 30 segundos).

    Pero eso es lo que el servidor soporta antes de determinar que nuestro script tarda demasiado… los usuarios reales tienen mucha menor tolerancia (Hay estudios que afirman que una demora de más de 4 segundos hace que el 75% de los visitantes pierdan la paciencia…).

    Entonces… ¿cómo atacar este problema?

    Como diría Zun Tzu, la mejor manera de salir vivo de una batalla es no entrar nunca (No sé si eso es exactamente lo que dijo, pero si no, seguro que pensaba algo parecido :)).

    Llevado al mundo de la programación web, la primera pregunta que debe hacerse es: ¿es realmente necesario ejecutar este proceso consumidor de tiempo?. Aunque parezca una trivialidad, en muchas ocasiones la respuesta es un rotundo NO, y simplemente se hace por razones que pueden haber sido válidas tiempo atrás pero ya no lo son más.

    La siguiente pregunta (Asumiendo que la respuesta a la primera haya sido SI) es: ¿es realmente necesario ejecutar este proceso ahora mismo?. Esta es una pregunta un poco más sutil, pero sumamente importante. Para responderla hay que pensar mucho sobre lo que significa «ahora mismo»: el momento en que se está ejecutando PHP en un WebServer es cuando se ha recibido una petición de un usuario y se está intentando generar una respuesta.

    Aquí nos metemos un poco en temas de psicología más que de programación, pero vale la pena intentarlo: muchas veces un usuario preferirá una respuesta rápida y casi correcta (o que parezca correcta) antes que una respuesta perfecta pero retrasada.

    Se puede pensar en esto como en un truco de magia (un acto de ilusionismo si se quiere), donde es más importante lo que se ve que lo que efectivamente sucede.

    Un típico ejemplo de estos problemas los constituye el recálculo de contadores. Tomo a Facebook como ejemplo, si te fijás en los números que indican la cantidad de actualizaciones no leídas podrás notar que rara vez es correcto… sin embargo, ¿cuál es el riesgo de tener un número ligeramente diferente del real? ¿estarías dispuesto a esperar más para tener la tranquilidad de que el número es correcto?

    En la mayoría de los casos, con que eventualmente los números cierren no habrá mayores problemas, lo cual da lugar a un esquema de solución muy utilizado: diferir la ejecución de procesos largos.

    ¿Cómo se logra esto? Una forma artesanal de hacerlo es guardar en algún medio de almacenamiento (por ejemplo una base de datos), toda la información necesaria para realizar el cálculo en cuestión, devolver al usuario algo que le de tranquilidad y, mediante algún mecanismo offline (un cronjob por ejemplo), terminar los trabajos que se dejaron a la mitad.

    Otro modo de resolverlo (un poco más profesional) es utilizar algún sistema de procesamiento de trabajos en paralelo, por ejemplo Gearman.

    ¿Qué otras alternativas se te ocurren para resolver este problema?