Patrones de nodos:de devoluciones de llamada a observador

ACTUALIZACIÓN:ahora también disponible como video (tomado en NodePDX 2016) en YouTube.

Este ensayo comenzó como una presentación en la conferencia ConFoo Canada. ¿Disfrutas de los toboganes? en https://github.com/azat-co/node-patterns:

git clone https://github.com/azat-co/node-patterns

Patrones de nodos:de devoluciones de llamada a observador

Antes de que podamos comenzar con los patrones de Node, veamos algunas de las principales ventajas y características de usar Node. Nos ayudarán más adelante a comprender por qué debemos enfrentar ciertos problemas.

Ventajas y características del nodo

Estas son algunas de las razones principales por las que las personas usan Node:

  • JavaScript:Node se ejecuta en JavaScript para que pueda reutilizar el código, las bibliotecas y los archivos de su navegador.
  • Asíncrono + Impulsado por eventos:el nodo ejecuta tareas simultáneamente con el uso de código y patrones asíncronos, gracias al bucle de eventos.
  • E/S sin bloqueo:Node es extremadamente rápido debido a su arquitectura de entrada/salida sin bloqueo y al motor Google Chrome V8.

Todo eso está bien, pero el código asíncrono es difícil. Los cerebros humanos simplemente no evolucionaron para procesar cosas de una manera asíncrona donde el bucle de eventos programa diferentes piezas de lógica en el futuro. Su orden a menudo no es el mismo orden en el que se implementaron.

Para empeorar el problema, la mayoría de los lenguajes tradicionales, los programas de informática y los bootcamps de desarrollo se centran en la programación síncrona. Esto hace que la enseñanza asincrónica sea más difícil, porque realmente necesita comprender y comenzar a pensar de forma asincrónica.

JavaScript es la ventaja y la desventaja al mismo tiempo. Durante mucho tiempo, JavaScript se consideró un lenguaje de juguete. :unamused:Impidió que algunos ingenieros de software se tomaran el tiempo de aprenderlo. En cambio, asumirían que pueden simplemente copiar algún código de Stackoverflow, cruzar los dedos y ver cómo funciona. JavaScript es el único lenguaje de programación que los desarrolladores creen que no necesitan aprender. ¡Error!

JavaScript tiene sus partes malas, por eso es aún más importante conocer los patrones. Y, por favor, tómese el tiempo para aprender los fundamentos.

Luego, como saben, la complejidad del código crece exponencialmente. Cada módulo A usado por el módulo B también es usado por el módulo C que usa el módulo B y hasta ahora. Si tiene un problema con A, entonces afecta a muchos otros módulos.

Así que la buena organización del código es importante. Es por eso que nosotros, los ingenieros de Node, debemos preocuparnos por sus patrones.

Todo lo que puedas comer Devoluciones de llamadas

¿Cómo programar algo en el futuro? En otras palabras, cómo asegurar que después de un evento determinado, nuestro código se ejecutará, es decir, garantizar la secuencia correcta. ¡Devoluciones de llamadas hasta el final!

Las devoluciones de llamada son solo funciones y las funciones son ciudadanos de primera clase, lo que significa que puede tratarlas como variables (cadenas, números). Puede lanzarlos a otras funciones. Cuando pasamos una función t como argumento y llamarlo más tarde, se llama devolución de llamada:

var t = function(){...}
setTimeout(t, 1000)

t es una devolución de llamada. Y hay una cierta convención de devolución de llamada. Eche un vistazo a este fragmento que lee los datos de un archivo:

var fs = require('fs')
var callback = function(error, data){...}
fs.readFile('data.csv', 'utf-8', callback)

Las siguientes son convenciones de devolución de llamada de Node:

[Nota al margen]

Leer publicaciones de blog es bueno, pero ver cursos en video es aún mejor porque son más atractivos.

Muchos desarrolladores se quejaron de la falta de material de video de calidad asequible en Node. Es una distracción ver videos de YouTube y una locura pagar $ 500 por un curso de video de Node.

Visite Node University, que tiene cursos de video GRATUITOS en Node:node.university.

[Fin de la nota al margen]

  • error 1er argumento, nulo si todo está bien
  • data es el segundo argumento
  • callback es el último argumento

Nota:No importa el nombre, pero sí el orden. Node.js no aplicará los argumentos. La convención no es una garantía, es solo un estilo. Lea la documentación o el código fuente.

Funciones con nombre

Ahora surge un nuevo problema:¿Cómo asegurar la secuencia correcta? Flujo de control ?
Por ejemplo, hay tres solicitudes HTTP para realizar las siguientes tareas:

  1. Obtener un token de autenticación
  2. Obtener datos usando el token de autenticación
  3. PONER una actualización utilizando los datos obtenidos en el paso 2

Deben ejecutarse en un orden determinado como se muestra en el siguiente pseudocódigo:

... // callback is defined, callOne, callTwo, and callThree are defined
callOne({...}, function(error, data1) {
    if (error) return callback(error, null)
    // work to parse data1 to get auth token
    // fetch the data from the API
    callTwo(data1, function(error, data2) {
        if (error) return callback(error, null)
        // data2 is the response, transform it and make PUT call
        callThree(data2, function(error, data3) {
            //
            if (error) return callback(error, null)
            // parse the response
            callback(null, data3)
        })
    })
})

Por lo tanto, bienvenido al infierno de devolución de llamada. Este fragmento se tomó de callbackhell.com (sí, existe, un lugar donde el código incorrecto muere):

fs.readdir(source, function (err, files) {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)
            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
}

Callback hell también se conoce como enfoque anidado y pirámide de la perdición. Solo es bueno garantizar una alta seguridad laboral para un desarrollador porque nadie más entenderá su código (broma, no lo hagas). Las características distintivas del infierno de devolución de llamada son:

  • Difícil de leer
  • Difícil de modificar/mantener/mejorar
  • Fácil para los desarrolladores crear errores
  • Paréntesis de cierre – ?

Algunas de las soluciones incluyen:

  • Resumen en funciones nombradas (elevadas o variables)
  • Usar obturadores
  • Usar bibliotecas y técnicas avanzadas

Comenzamos con el enfoque de funciones nombradas. El código de tres solicitudes anidadas se puede refactorizar en tres funciones:

callOne({...}, processResponse1)

function processResponse1(error, data1) {
  callTwo(data1, processResponse2)
}

function processResponse2(error, data2) {
  callThere(data2, processResponse3)
}

function processResponse3(error, data1) {
  ...
}

Modularización en Nodo

Además, puede modularizar las funciones en archivos separados para mantener sus archivos limpios y ordenados. Además, la modularización te permitirá reutilizar el código en otros proyectos. El punto de entrada principal contendrá solo dos declaraciones:

var processResponse1 = require('./response1.js')
callOne({...}, processResponse1)

Este es el response.js módulo con la primera devolución de llamada:

// response1.js
var processResponse2 = require('./response2.js')
module.exports = function processResponse1(error, data1) {
  callTwo(data1, processResponse2)
}

Del mismo modo en response2.js , importamos el response3.js y exportar con la segunda devolución de llamada:

// response2.js
var processResponse3 = require('./response3.js')
module.exports = function processResponse2(error, data2) {
  callThere(data2, processResponse3)
}

La devolución de llamada final:

// response3.js
module.exports = function processResponse3(error, data3) {
  ...
}

Patrón de software intermedio de Node.js

Llevemos las devoluciones de llamada al extremo. Podemos implementar un patrón de paso de continuidad conocido simplemente como el patrón de middleware.

El patrón de middleware es una serie de unidades de procesamiento conectadas entre sí, donde la salida de una unidad es la entrada de la siguiente. En Node.js, esto a menudo significa una serie de funciones en la forma:

function(args, next) {
  // ... Run some code
  next(output) // Error or real output
}

El middleware se usa a menudo en Express donde la solicitud proviene de un cliente y la respuesta se envía de vuelta al cliente. Solicite viajes a través de una serie de middleware:

request->middleware1->middleware2->...middlewareN->route->response

El next() El argumento es simplemente una devolución de llamada que le dice a Node y Express.js que continúen con el siguiente paso:

app.use(function(request, response, next) {
  // ...
  next()
}, function(request, response, next) {
  next()
}, function(request, response, next) {
  next()
})

Patrones de módulos de nodo

Como comenzamos a hablar sobre la modularización, hay muchas formas de despellejar un bagre. El nuevo problema es ¿cómo modularizar correctamente el código?

Los principales patrones de módulos son:

  • module.exports = {...}
  • module.exports.obj = {...}
  • exports.obj = {...}

Nota:exports = {...} es anti-patrón porque no exportará nada. Solo está creando una variable, no asignando module.exports .

El segundo y tercer enfoque son idénticos, excepto que necesita escribir menos caracteres cuando usa exports.obj = {...} .

La diferencia entre primero y segundo/tercero es tu intención. Al exportar un único objeto/clase monolítico con componentes que interactúan entre sí (por ejemplo, métodos, propiedades), utilice module.exports = {...} .

Por otro lado, cuando se trata de cosas que no interactúan entre sí pero que quizás sean categóricamente iguales, puede ponerlas en el mismo archivo pero usar exports.obj = {...} o module.exports = {...} .

Exportar objetos y cosas estáticas ahora está claro. Pero, ¿cómo modularizar código dinámico o dónde inicializar?

La solución es exportar una función que actuará como un inicializador/constructor:

  • module.exports = function(options) {...}
  • module.exports.func = function(options) {...}
  • exports.func = function(options) {...}

La misma nota al margen sobre module.exports.name y exports.name siendo idénticos se aplican a las funciones también. El enfoque funcional es más flexible porque puede devolver un objeto pero también puede ejecutar algún código antes de devolverlo.

Este enfoque a veces se denomina enfoque de subpila, porque es el favorito de la prolífica subpila de contribuyentes de Node.

Si recuerda que las funciones son objetos en JavaScript (tal vez por leer sobre los fundamentos de JavaScript), entonces sabe que podemos crear propiedades en las funciones. Por lo tanto, es posible combinar dos patrones:

module.exports = function(options){...}
module.exports.func = function(options){...}
module.exports.name = {...}

Sin embargo, esto rara vez se usa, ya que se considera un Nodo Kung Fu. El mejor enfoque es tener una exportación por archivo. Esto mantendrá archivos ligeros y pequeños.

Código en módulos de nodo

¿Qué pasa con el código fuera de las exportaciones? También puede tener eso, pero funciona de manera diferente al código dentro de las exportaciones. Tiene algo que ver con la forma en que Node importa módulos y los almacena en caché. Por ejemplo, tenemos el código A fuera de las exportaciones y el código B dentro:

//import-module.js
console.log('Code A')
module.exports = function(options){
  console.log('Code B')
}

Cuando require , el código A se ejecuta y el código B no. El código A se ejecuta solo una vez, sin importar cuántas veces require , porque los módulos se almacenan en caché por su nombre de archivo resuelto (¡puede engañar a Node cambiando mayúsculas y minúsculas y rutas!).

Finalmente, debe invocar el objeto para ejecutar el código B, porque exportamos una definición de función. Necesita ser invocado. Sabiendo esto, el siguiente script imprimirá solo el "Código A". Lo hará solo una vez.

var f = require('./import-module.js')

require('./import-module.js')

El almacenamiento en caché de los módulos funciona en diferentes archivos, por lo que requerir el mismo módulo muchas veces en diferentes archivos activará el "Código A" solo una vez.

Patrón Singleton en Nodo

Los ingenieros de software familiarizados con el patrón singleton saben que su propósito es proporcionar una única instancia generalmente global. Deja de lado las conversaciones de que los singletons son malos, ¿cómo los implementas en Node?

Podemos aprovechar la función de almacenamiento en caché de los módulos, es decir, require almacena en caché los módulos. Por ejemplo, tenemos una variable b que exportamos con valor 2:

// module.js
var a = 1 // Private
module.exports = {
  b: 2 // Public
}

Luego, en el archivo de script (que importa el módulo), incremente el valor de b e importe el módulo main :

// program.js
var m = require('./module')
console.log(m.a) // undefined
console.log(m.b) // 2
m.b ++
require('./main')

El módulo main importa module de nuevo, ¡pero esta vez el valor de b no es 2 sino 3!

// main.js
var m = require('./module')
console.log(m.b) // 3

Un nuevo problema a mano:los módulos se almacenan en caché en función de su nombre de archivo resuelto. Por esta razón, el nombre de archivo romperá el almacenamiento en caché:

var m = require('./MODULE')
var m = require('./module')

O caminos diferentes romperán el almacenamiento en caché. La solución es usar global

global.name = ...
GLOBAL.name = ...

Considere este ejemplo que cambia nuestro amado console.log del blanco predeterminado al rojo alarmante:

_log = global.console.log
global.console.log = function(){
  var args = arguments
  args[0] = '\033[31m' +args[0] + '\x1b[0m'
  return _log.apply(null, args)
}

Debe solicitar este módulo una vez y todos sus registros se volverán rojos. Ni siquiera necesita invocar nada porque no exportamos nada.

Usar global es poderoso... pero anti-patrón, porque es muy fácil estropear y sobrescribir algo que usan otros módulos. Por lo tanto, debe saberlo porque puede usar una biblioteca que se base en este patrón (por ejemplo, debe ser un desarrollo basado en el comportamiento), pero utilícelo con moderación, solo cuando sea necesario.

Es muy similar al navegador window.jQuery = jQuery patrón. Sin embargo, en los navegadores no tenemos módulos, es mejor usar exportaciones explícitas en Node, que usar globales.

Importación de carpetas

Continuando con la importación, hay una función interesante en Node que le permite importar no solo archivos JavaScript/Node o archivos JSON, sino también carpetas completas.

La importación de una carpeta es un patrón de abstracción que a menudo se usa para organizar el código en paquetes o complementos (o módulos, también aquí). Para importar una carpeta, cree index.js en esa carpeta con un module.exports tarea:

// routes/index.js
module.exports = {
  users: require('./users.js'),
  accounts: require('./accounts.js')
  ...
}

Luego, en el archivo principal, puede importar la carpeta con el nombre:

// main.js
var routes = require('./routes')

Todas las propiedades en index.js como usuarios, cuentas, etc. serán propiedades de routes en main.js . Casi todos los módulos npm utilizan el patrón de importación de carpetas. Hay bibliotecas para exportar automáticamente TODOS los archivos en una carpeta determinada:

  • require-dir
  • require-directory
  • require-all

Patrón de fábrica de funciones

No hay clases en Node. Entonces, ¿cómo organizar su código modular en clases? Los objetos heredan de otros objetos y las funciones también son objetos.

Nota:Sí, hay clases en ES6, pero no admiten propiedades. El tiempo dirá si son un buen reemplazo de la herencia pseudoclásica. Los desarrolladores de nodos prefieren el patrón de fábrica de funciones por su simplicidad a uno pseudoclásico tosco.

La solución es crear una fábrica de funciones, también conocida como patrón de herencia funcional. En él, la función es una expresión que toma opciones, inicializa y devuelve el objeto. Cada invocación de la expresión creará una nueva instancia. Las instancias tendrán las mismas propiedades.

module.exports = function(options) {
  // initialize
  return {
    getUsers: function() {...},
    findUserById: function(){...},
    limit: options.limit || 10,
    // ...
  }
}

A diferencia de los pseudoclásicos, los métodos no serán del prototipo. Cada objeto nuevo tendrá su propia copia de los métodos, por lo que no debe preocuparse de que un cambio en el prototipo afecte a todas sus instancias.

A veces, solo tiene que usar pseudo-clásico (por ejemplo, para emisores de eventos), luego está inherits . Úselo así:

require('util').inherits(child, parent)

Inyección de dependencia de nodo

De vez en cuando, tiene algunos objetos dinámicos que necesita en los módulos. En otras palabras, hay dependencias en los módulos de algo que está en el archivo principal.

Por ejemplo, al usar un número de puerto para iniciar un servidor, considere un archivo de entrada Express.js server.js . Tiene un módulo boot.js que necesita las configuraciones del app objeto. Es sencillo implementar boot.js como función exportar y pasar app :

// server.js
var app = express()
app.set(port, 3000)
...
app.use(logger('dev'))
...
var boot = require('./boot')(app)
boot({...}, function(){...})

Función que devuelve una función

El boot.js file en realidad usa otro patrón (probablemente mi favorito) al que simplemente llamo función que devuelve una función. Este patrón simple le permite crear diferentes modos/versiones de la función interna, por así decirlo.

// boot.js
module.exports = function(app){
  return function(options, callback) {
    app.listen(app.get('port'), options, callback)
  }
}

Una vez leí una publicación de blog donde este patrón se llamaba mónada, pero luego un fan enojado de la programación funcional me dijo que esto no es una mónada (y también estaba enojado por eso). Bueno.

Patrón de observador en nodo

Aún así, las devoluciones de llamada son difíciles de administrar incluso con módulos. Por ejemplo, tienes esto:

  1. Module Job está realizando una tarea.
  2. En el archivo principal, importamos Job.

¿Cómo especificamos una devolución de llamada (alguna lógica futura) en la finalización de la tarea del trabajo? Tal vez pasemos una devolución de llamada al módulo:

var job = require('./job.js')(callback)

¿Qué pasa con múltiples devoluciones de llamada? ¿No es muy escalable en desarrollo?

La solución es bastante elegante y en realidad se usa mucho, especialmente en los módulos centrales de Node. ¡Conoce el patrón de observador con emisores de eventos!

Este es nuestro módulo que emite el evento done cuando todo termine:

// module.js
var util = require('util')
var Job = function Job() {
  // ...
  this.process = function() {
    // ...
    job.emit('done', { completedOn: new Date() })
  }
}

util.inherits(Job, require('events').EventEmitter)
module.exports = Job

En el guión principal, podemos personalizar qué hacer cuando el trabajo esté terminado.

// main.js
var Job = require('./module.js')
var job = new Job()

job.on('done', function(details){
  console.log('Job was completed at', details.completedOn)
  job.removeAllListeners()
})

job.process()

Es como una devolución de llamada, solo que mejor, porque puede tener varios eventos y puede eliminarlos o ejecutarlos una vez.

emitter.listeners(eventName)
emitter.on(eventName, listener)
emitter.once(eventName, listener)
emitter.removeListener(eventName, listener)

Resumen de 30 segundos

  1. Devoluciones de llamada
  2. Observador
  3. Único
  4. Complementos
  5. Middleware
  6. ¿Un montón de otras cosas?

Estudio adicional

Obviamente, hay más patrones como corrientes. Administrar código asincrónico es un conjunto completamente nuevo de problemas, soluciones y patrones. Sin embargo, este ensayo ya es bastante largo. ¡Gracias por leer!

Comience con estos patrones de nodos de piedra angular, utilícelos donde sea necesario. Para dominar Node, mira tus módulos favoritos; ¿Cómo implementan ciertas cosas?

Estas son cosas que vale la pena mirar para estudiar más a fondo:

  • async y neo-async :Excelentes bibliotecas para administrar código asíncrono
  • Promesas:Ven con ES6
  • Generadores:prometedores
  • Async await:buen envoltorio para las promesas que llegarán pronto
  • hooks :Módulo de patrón de ganchos
  • El libro de patrones de diseño de nodos no es mío, solo lo estoy leyendo en este momento.