ezakto code

Los contenidos de este blog están desactualizados.

Pero estoy pensando en actualizarlos y escribir más.

Si te interesa, dejame tu email (no spam, no newsletters). Si hay suficiente interés me pondré manos a la obra y te lo haré saber!


Creando una utilidad de línea de comandos con node: Todo list

09 de enero 2015

Crear una utilidad para la línea de comandos en node es en general bastante intuitivo, como lo es en otros lenguajes como bash.

Los dos aspectos principales son la entrada de datos, vía argv, y la salida, vía stdout en principio. A modo ilustrativo, vamos a construir el "Hola mundo" de las apps, es decir un Todo list, o administrador de lista de tareas. Antes que nada, voy a establecer una ligera idea de los puntos claves de la aplicación:

Preparando el escenario

Creamos un directorio para nuestro paquete, y en él un archivo index.js. En la consola, nos dirigimos a este directorio y ejecutamos npm init para crear un archivo package.json. Seguimos los pasos tradicionales.

Abrimos el archivo index.js y especificamos algunas dependencias escenciales:

// Necesario para leer y escribir el archivo de tareas
var fs = require('fs');
// En este ejemplo, será para la compatibilidad entre plataformas
var path = require('path');

Nuestra aplicación creará una lista de tareas en el directorio en el que sea ejecutada. Es decir que si ejecutamos lo siguiente:

user@ejemplo:~/proyectos$ todo add ir de compras

La aplicación buscará (o creará si no lo encuentra) un archivo .todo en el directorio ~/proyectos (Es decir ~/proyectos/.todo) para guardar la información. Aclarado esto, debemos construir la ruta a este archivo. Para eso utilizaremos el objeto especial process de node, que contiene información acerca del script en ejecución. A continuación de las dependencias, agregamos la siguiente línea:

var file = path.join(process.cwd(), '.todo');

Por partes:

path.join() sirve para unir rutas. Por ejemplo, path.join('lorem', 'ipsum', 'dolor'); devolverá como resultado 'lorem/ipsum/dolor'. Y por qué usar path.join en vez de concatenar las cadenas manualmente? Porque path.join se encargará de usar el conector adecuado para el sistema que esté ejecutando el script. Es decir, '/' en caso de sistemas UNIX, y '\' en Windows.

process.cwd() devolverá la ruta completa hacia el directorio en el cual el script está siendo ejecutado. El CWD (Current Working Directory) no es necesariamente el directorio donde el archivo reside. Si por ejemplo nos encontramos en la carpeta ~/proyectos y ejecutamos $ node ./todo/index.js, el CWD será ~/proyectos, a pesar de que el archivo ejecutado se encuentre en otro lugar. Por eso es necesario que nuestra utilidad determine dónde guardar el todo list basándose en nuestra ubicación actual en la consola, y no donde el software en sí reside.

Creando la "base de datos"

Nuestra sencilla base de datos tendrá dos partes. Una archivo de texto (el ya mencionado .todo) y una variable en nuestro script que usaremos para leer y manipular las tareas para luego actualizar el archivo.

Entonces, las siguientes líneas serán:

// Nuestro store en memoria
var _todos = [];

// Comprobamos si el archivo ya existe
if (fs.existsSync(file)) {
    // Y en caso de ser así, leer su contenido
    // Explicación a continuación
    _todos = JSON.parse(fs.readFileSync(file, {encoding: 'utf8'}));
}

Como nuestra lista de tareas es, justamente, una lista, lo más adecuado para almacenarla es un array. En principio, creamos un array vacío y lo almacenamos en _todo. Luego viene lo interesante. Comprobamos si el archivo existe (usando la variable file que contiene la ruta completa) usando fs.existsSync().

En caso de que el archivo exista, leemos su contenido usando fs.readFileSync, al que le pasamos dos argumentos: el primero, la ruta al archivo a leer; el segundo es un objeto de opciones, que en este caso especificaremos una sola, encoding como 'utf8'. Esto hará que una vez leído el archivo, su contenido sea devuelto como una cadena de texto, en vez de un Buffer.

Leído el contenido, pasamos esta cadena de texto obtenida como argumento a JSON.parse que devolverá el contenido decodificado (es decir, un objeto de javascript en vez de una cadena de texto). Esto es necesario porque al momento de guardar nuestra lista, vamos a codificar el array como una cadena de texto.

Administrando la lista

En este punto, ya tenemos nuestra lista (ya sea vacía o no) en la variable _todos. Ahora necesitamos algunas operaciones básicas para administrarla: add (agregar), remove (remover), toggle (alternar, completar) y save (guardar).

Vamos a crear un objeto manager, que llamaremos Todo, y todos estas operaciones serán sus métodos. Código autoexplicado:

var Todo = {

    // Agrega una tarea, el texto se pasa como argumento
    add: function (text) {
        // El método push añade un elemento al final del array
        // Cada tarea será un objeto con dos propiedades:
        // text: claramente el texto
        // y completed: un booleano indicando si la tarea fue
        // completada o no. Las tareas nuevas no están completadas
        _todos.push({
            text: text,
            completed: false
        });
    },

    // Elimina una tarea, la posición se pasa como argumento
    remove: function(idx) {
        // Primero comprobamos que esa posición existe en el array
        if (_todos[idx]) {
            // De ser así, la suprimimos usando el método splice.
            // El primer argumento es la posición donde comenzar
            // y el segundo la cantidad de elementos a eliminar
            _todos.splice(idx, 1);
        }
    },

    // Alterna el estado de la tarea (completado o no)
    toggle: function(idx) {
        // También comprobamos que la posición existe
        if (_todos[idx]) {
            // invertimos el valor de la propiedad completed
            // (explicación más abajo)
            _todos[idx].completed = !_todos[idx].completed;
        }
    },

    // Guardamos la lista en el archivo
    save: function () {
        // Es el inverso al proceso utilizado para cargar
        // la lista inicialmente.
        // Primero codificamos con JSON.stringify el array
        // en una cadena de texto, y la pasamos como argumento
        // de fs.writeFileSync. El primer argumento es la ruta
        // destino. Si el archivo no existe, se creará; si
        // existe, se sobreescribirá
        fs.writeFileSync(file, JSON.stringify(_todos));
    }
};

Me gustaría explicar brevemente esta línea, del método toggle:

_todos[idx].completed = !_todos[idx].completed;

El operador binario ! niega o invierte un valor booleano. Es decir que !true es igual a false, y !false es igual a true. Si tenemos una variable, digamos completed y esta es igual a true, entonces !completed será igual a false, y viceversa. Por lo tanto, la asignación completed = !completed no hará más que invertir el valor de la variable. En el método toggle estamos haciendo esto mismo, pero sobre la propiedad de un elemento de un array.

Ejecutando las operaciones

Ya está casi todo listo. Tenemos la lógica de almacenamiento (_todo y .todo), y las operaciones definidas para manipular nuestra lista. Ahora tenemos que crear la interfaz de línea de comandos (CLI, command line interface) que nos permita ejecutar estas acciones.

Cuando ejecutamos un script de node desde la consola, el procedimiento es el siguiente:

$ node index.js

Y si queremos ejecutar el scripts con argumentos extra, haremos algo como:

$ node index.js add mi tarea

Cada una de las palabras que escribimos para ejecutar el script serán accesibles desde el antes mencionado objeto process. Más precisamente, desde process.argv. Argv quiere decir "Arguments vector" o "vector de argumentos". Es un array cuyos elementos son las palabras usadas para ejecutar el script. En el caso de esta última línea, process.argv contendrá los siguientes elementos:

['node', 'index.js', 'add', 'mi', 'tarea']

Como nuestra utilidad es sencilla, podemos asegurar que el 3 elemento del array siempre será una operación (add, remove, toggle) y el resto serán argumentos de dicha operación. Entonces añadimos las siguientes líneas:

// El método slice de un array (en este caso process.argv)
// nos devuelve una porción del mismo. Si se invoca con un
// sólo argumento (en este caso, 2), devolverá todos los
// elementos a partir del índice 2. Con esto nos deshacemos
// los dos primeros elementos 'node' e 'index.js',
// quedando como primer elemento la operación
var argv = process.argv.slice(2);
// El método shift quita el primer elemento del array y lo
// devuelve. Almacenamos este elemento en la variable command
var command = argv.shift();
// Ahora comprobamos que el método ingresado por el usuario
// realmente existe en nuestro manager, el objeto Todo.
// Se puede hacer de diferentes formas, nosotros usaremos el
// operador typeof, que devuelve el tipo de dato de una variable.
// Si typeof devuelve 'function' quiere decir que el método
// existe y es una función, en cualquier otro caso la condición
// no se cumplirá.
if (typeof Todo[command] == 'function') {
    // El método call de las funciones es un poco complejo,
    // pero básicamente ejecuta la función cambiando su contexto
    // y pasando los argumentos especificados
    // En nuestro caso, ejecutamos el método ingresado con
    // los argumentos restantes de argv unidos por join.
    // El método join devuelve una cadena de texto con todos
    // los elementos unidos por el texto especificado como
    // primer argumento (en este caso, un espacio)
    Todo[command].call(Todo, argv.join(' '));
    // Serializamos y guardamos la lista de tareas en el archivo
    Todo.save();
}

Si ejecutásemos $ node index.js add mi tarea, vamos paso a paso:

Listo! Sólo queda un paso más: mostrar la lista de tareas cada vez que ejecutamos el script. Últimas líneas:

// El método forEach recorre todo el array ejecutando
// una función por cada uno de sus elementos. A esta función
// se le pasarán como argumentos el elemento en sí y su posición
_todos.forEach(function(todo, idx){
    // Entonces, por cada elemento escribimos una línea en la
    // consola: primero la posición del elemento, luego un
    // espacio, luego el texto, luego su estado (explicación abajo)
    // y finalmente una salto de línea
    process.stdout.write(idx + '\t' + todo.text + (todo.completed ? ' \u2713' : '') + '\n');
});

Esto se ejecutará cada vez que el script sea llamado. Respecto a (todo.completed ? ' \u2713' : ''): Es una expresión ternaria. Es como un 'if rápido'. La primer parte de la expresión es una condición (tal como en un if), la segunda parte (luego del ?) es el valor que se devolverá en caso de ser la condición verdadera, y la tercera parte (después del :) se ejecutará en caso contrario. Ejemplo rápido:

// variable será igual a 'foo' porque 5 > 2 es verdadero.
var variable = 5 > 2 ? 'foo' : 'bar';

Entonces estamos usando como condición todo.completed, es decir, el estado (true o false) de la tarea. Si la tarea está completada (completed==true), entonces devolverá '\u2713', que es una secuencia unicode para representar un caracter de check (). Si no está completada, devolverá una cadena vacía.

Listo! Podemos ejecutar nuestra utilidad ahora y ver los resultados. En la consola:

$ node index.js add tarea
0   tarea

$ node index.js add tarea 2
0   tarea
1   tarea 2

$ node index.js toggle 0
0   tarea ✓
1   tarea 2

$ node index.js toggle 1
0   tarea ✓
1   tare a2 ✓

$ node index.js remove 0
0   tarea 2 ✓

Hacer la utilidad disponible globalmente

El problema ahora es que tenemos que escribir node ruta/hacia/mi/script/index.js add tarea cada vez que queremos usarlo! Esto lo solucionaremos con la instalación global de paquetes de npm.

Primero debemos agregar esta línea al inicio de nuestro script:

#!/usr/bin/env node

Esta línea indicará que si es archivo es ejecutado directamente, debe ser interpretado con node.

Luego, debemos editar nuestro archivo package.json (generado con npm init al principio) y agregar la propiedad "bin", como en el siguiente ejemplo:

{
  "name": "Todo manager",
  "version": "0.0.1",
  "description": "Administrador de tareas",
  "main": "index.js",
  "bin": {
    "todo": "index.js"
  }
}

El objeto especificado en la propiedad "bin" tiene dos partes: "todo" es el nombre que tendrá nuestro ejecutable (es decir, usaremos $ todo en la consola para ejecutarlo directamente). Y el valor es la ruta hacia nuestro script.

Esto será suficiente para indicarle a npm qué hacer. Ahora, ejecutamos:

npm install -g .

Esto instalará el paquete del directorio actual globalmente, y enlazará el ejecutable "todo" a nuestro script. Ahora podemos ejecutar, en cualquier directorio:

$ todo add tarea
0   tarea

$ todo add tarea 2
0   tarea
1   tarea 2

$ todo toggle 0
0   tarea ✓
1   tarea 2

$ todo toggle 1
0   tarea ✓
1   tare a2 ✓

$ todo remove 0
0   tarea 2 ✓

Por supuesto que este tutorial es básico y no contempla muchos casos. La idea es dar un puntapié inicial en el asunto. Existen librerías que facilitan mucho las cosas, como optimist o minimist para el manejo del argv, y otras tantas para el manejo de archivos, persistencia, etc. A investigar!