Reflexão em padrões de projeto para Node.js

Bem pessoal, estou aqui traduzindo mais um texto. Agora sobre Node.js, o JavaScript no servidor. O texto a seguir é uma reflexão sobre os padrões de projeto nessa plataforma. Espero que ele seja de muito proveito a você, desenvolvedor JS. Confira o texto original, Refleting on node.js design patterns, de Kalin Chernev, aqui mesmo no Medium.com.


Era uma vez, havia um designer …

Créditos: commitstrip.com

Não importa se a história é verdade ou não, trabalhar com JavaScript no servidor com Node.js no momento é uma experiência muito diferente em comparação ao trabalho com Python, Ruby ou PHP. Eu pessoalmente digo que a vantagem de se trabalhar com uma única linguagem em todos os lugares vem com o preço de ter que aprender diferentes padrões de design, a fim de usar a linguagem de forma eficaz.

Um destes conceitos fundamentais a se aprender é a programação assíncrona. Há muitos artigos na comunidade Node.js / JavaScript sobre programação assíncrona em termos do que são callbacks e como resolver problemas de fluxo de controle com uma bíblioteca, uma promise, async / wait etc. Há alguns que são úteis quando a questão geralmente é:

Como usar módulos no node de uma boa forma?

E embora seja importante aprender e seguir boas práticas de implementação, faltam artigos que respondam a um outra questão:

Como organizar meu código em módulos no node de uma boa forma?

E neste texto, estarei refletindo sobre alguns padrões que respondem a esta última.

Padrão de estilo Continuation-passing

Este é um padrão simples de entender, mas fundamental para se trabalhar com código assíncrono.

Resumo: em sua função Continuation-passing (CPS):

  • Use cb(null, data) ao invés de return para passar um resultado;
  • Use return cb(err) para passar um erro e fechar a execução de uma função;
  • Comunicar um único resultado da função.
// Synchronous
function add(a, b) {
return a + b;
}

console.log(add(2, 2));
// Asynchronous
function addAsync(a, b, cb) {
cb(a + b);
}

addAsync(2, 2, function add(result) {
console.log(result);
});

É isso aí. Honestamente, a primeira vez que vi este tipo de código, eu senti um abrir de olhos. Se você já sabia disso, parabéns a você e bom trabalho!

Só por uma questão de estética, é possível refatorar esta última função para:

addSync(2, 2, result => console.log(result));

Ou ainda:

addSync(2, 2, console.log);

O importante de se entender nesta abordagem é que funções podem ser passadas como parâmetros para outras funções. São funções que agem como declaração de retorno.

Em Node.js, por convenção, se diz que você, ao escrever a sua função CPS, você deve enviar o erro da função como primeiro argumento do callback.

Vamos fazer algo um pouco mais prático — definir uma função que pegue uma lista de arquivos. Se há os tais arquivos, ela os retorna. Se não, ela devolve um erro.

function readFiles (files, cb) {
if (files.length) {
cb(null, files)
} else {
cb('no files supplied')
}
}

Esta função será chamada da seguinte forma:

readFiles(process.argv.slice(2), (err, data) => {
if (err) return console.error(err)
console.log(data)
})

Eu recomendo altamente que você teste isso se não sabe como funciona. Se você é preguiçoso para abrir uma sessão do terminal, basta usar um Runkit e e copiar-colar isto no navegador.

Note que o return é usado no caso de um erro parar a execução da função. Este padrão é muito popular e vem a calhar para quase todos os casos em que o consumidor do seu módulo precisa fazer uma única coisa e obter um único resultado.

Padrão Observer com o EventEmitter

Desenvolvedores Node.js conhecem a interface do EventEmitter desde o primeiro dia, como uma ferramenta “sob o capô” de quase todos os módulos do Node. Especialmente quando temos funções que tem um tempo para acabar.

Resumo: usando o EventEmitter:

  • Você cria objetos observáveis com múltiplos listeners, em que cada um tem uma função de callback, i.e, cada listener tem um possível resultado.
  • Use emitter.emit(eventName[, …args]) ao invés de cb(null, data) para passar um resultado para uma função listener.
  • Use emit(‘error’, err) para passar um erro e fechar a execução.
  • Transmita múltiplas saídas de sua função.

O EventEmitter prove o popular on() método que insere funções em um objeto. Então, ele as invoca sincronamente uma a uma a medida que os eventos acontecem. Esta abordagem provê mais granularidade e controle do que a CPS, o que dá um resultado melhor.

Vamos expandir nossa função anterior readFiles() para filterFiles() a fim de fornecer uma maneira da mensagem de todos os consumidores assinantes encontrarem um arquivo durante uma pesquisa.

// Give a list of files all of them which match an extensionfunction findFiles (files, extension) {
const emitter = new EventEmitter()
  if (files.length === 0) {
// yield an error
emitter.emit('error', 'no files supplied')
}
   // Check for matches
function checkFiles () {
files.forEach(file => {
if (path.extname(file) === extension) {
// yield a result
emitter.emit('match', file)
}
})
}
  // Ask the event loop to loop through our loop ...
process.nextTick(checkFiles)
// For chainability on on()
return emitter
}

Então, para usarmos esta função, temos de implementá-la assim:

findFiles(process.argv.slice(2), '.js')
.on('match', file => console.log(file + ' is a match'))
.on('error', err => console.log('Error emitted: ' + err.message))

Podemos também melhorar a forma, mas com a mesma funcionalidade:

'use strict' 
// Dependencies
const EventEmitter = require('events').EventEmitter
const path = require('path')
// Definition
class FindFiles extends EventEmitter {
constructor (extension) {
super()
this.extension = extension
this.files = []
}
addFile (file) {
this.files.push(file)
return this
}
  // Check for matches
findFiles () {
process.nextTick(() => {
this.files.forEach(file => {
if (path.extname(file) === this.extension) {
this.emit('match', file)
}
})
})
return this
}}
// Instantiation of observable object
const FindFilesSearcher = new FindFiles('.js')

Se você já deu uma olhada no capítulo sobre o padrão observer neste famoso livro de padrões de projeto, você já viu a grande diferença entre a forma como implementamos este padrão. Eu acho a “forma do node.js” melhor — é mais simples e você pode expressar as mesmas ideias com menos código. E honestamente, eu ensinei este padrão para desenvolvedores web, e te garanto que eu tive melhor chance de sucesso relacionando a familiaridade com o popular método do jQuery, o on(), sem falar muito sobre abstrações e interfaces.

Combinando CPS e padrão Observer

Nossa nova função findFiles() é definitivamente mais flexível que a readFiles().

findFiles() provê uma forma do usuário ouvir o evento durante o processamento de cada arquivo, o que pode ser muito útil para controle do processamento.

Mas e se quisermos torná-lo ainda mais flexível, como deixar o usuário escolher se ele quer esse controle ou não? E se o usuário não está realmente interessado em cada arquivo, mas quer obter apenas o resultado final?

Bem, vamos juntar o EventEmitter com a função CPS:

'use strict' 
// Dependenciesconst
EventEmitter = require('events').EventEmitter
const path = require('path')
// Definitionfunction findFiles (files, extension, cb = null) {
const emitter = new EventEmitter()
const errorMessage = 'no files supplied';
if (files.length === 0) {
if (cb) {
cb(errorMessage)
}
emitter.emit('error', errorMessage)
}
if (cb) {
// cps
let result = []
for (let i = 0; i < files.length; ++i) {
if (path.extname(files[i]) === extension) {
result.push(files[i])
}
}
cb(null, result)
} else {
// event emitter style
process.nextTick(() => {
files.forEach(file => {
if (path.extname(file) === extension) {
emitter.emit('match', file)
}
})
})
return emitter
}}
// Implementation with a callback
findFiles(process.argv.slice(2), '.js', (err, result) => {
if (err) return console.error(err)
console.log(`All in one: ${result}`)
})
// Implementation with an eventmitter
findFiles(process.argv.slice(2), '.js')
.on('match', file => console.log(file + ' is a match'))
.on('error', err => console.log('Error emitted: ' + err.message))

Este é somente um exemplo de demonstração em que não se deve copiar para dentro do seu código sem ao menos checkar as possíveis entradas do usuário, etc. Contudo, o exemplo traz uma ideia bem básica em como podemos fazer argumentos de callback serem opcionais e usá-los dependendo do cenário.


Conclusão

Espero que no final deste artigo você tenha algumas idéias de alto nível sobre algumas opções que você tem ao projetar seus módulos do Node. É bom começar com o fim em mente, imaginando como você deseja que seus módulos sejam usados. Isso o ajudará a equilibrar melhor suas decisões entre o nível de flexibilidade e facilidade de uso que você deseja fornecer aos usuários.

E lembre-se — na maioria dos casos a escolha de uma linguagem de programação para uso é menos importante do que a forma como você usa a linguagem de programação.


É isso ai pessoal, espero que tenham gostado do texto. Se gostou de uma recomendação no texto, ou seja, clique no ícone de coração ;). Até a próxima, valeu!

Show your support

Clapping shows how much you appreciated Emanuel G de Souza’s story.