Change Surfers

Separando la infraestructura de ejecución del software, de la lógica de la aplicación.

La principal característica de un diseño de software es que el código sea mantenible, que sea capaz de absorber cambios durante de la vida del programa.

Para ello deberemos separar la lógica de la aplicación, reglas de negocio que estamos modelando, de la infraestructura de ejecución con la que este software va a interactuar (accesos a disco, peticiones por red, via http, entrada de datos, etc …).

Vamos a ver de forma práctica, cómo a partir de una historia de usuario de nuestro product owner, somos capaces de entregar un software que permita la incorporación de cambios de forma sencilla, lo que nos llevará a un software barato (en tiempo y esfuerzo) de mantener.

Planteamiento del problema a resolver, en forma de historia de usuario: 

Como bibliotecario,  quiero un informe que me de el número de palabras del texto dado, para poder clasificar los textos de la biblioteca por el de número de palabras de estos. 🤷‍♂️

Bien, interrogamos a nuestros stakeholders y nos dicen que, por ahora, nos pasarán el texto a procesar en un archivo alojado en un filesystem del que nos indicarán su ruta.

Desarrollamos una primera aproximación del problema y obtenemos un script en javascript ejecutado en node tal cual

  1. const path = require('path')  
  2. const fs = require('fs')  
  3.   
  4. main(process.argv[2])  
  5.   
  6. async function main (filepath) {  
  7.   
  8.     try {  
  9.         const data = await fs.promises.readFile(path.resolve(filepath), 'utf8')  
  10.   
  11.         const wordCount = data.split(/\s/).length  
  12.         const wordCountJSON = {  
  13.             wordCount  
  14.         }  
  15.   
  16.         await fs.promises.writeFile(  
  17.             path.resolve('word-count.json'),  
  18.             JSON.stringify(wordCountJSON, null, 2)  
  19.         )  
  20.     } catch (e) {  
  21.         console.error(e)  
  22.         process.exit(1)  
  23.     }  
  24. }  

Pero, ¿qué problemas presenta?, está todo el código acoplado: 

  • – obtención de parámetros de entrada: línea 4.
  • – dependencias de librerías de infraestructura: líneas 1 y 2.
  • – obtención del texto desde un fichero del filesystem: línea 9.
  • – salida del informe a un fichero del filesystem: línea 16.
  • – control de flujo de la aplicación y lógica de la misma: líneas 9 a 19.
  • – gestión de errores globales: líneas 21 y 22.

 

Si las necesidades planteadas por negocio no cambiaran, ¿para que íbamos a cambiar nuestro script que satisface dichas necesidades?. Pero nada es constante y nos dicen que ya tienen los textos en formato online y ahora el texto lo tenemos que consultar de a un servicio web, pero debemos conservar el programa tal y como funciona en este momento. Por ello, ahora, todos estos elementos hay que separarlos, por lo que nos guiaremos por el principio de única responsabilidad de SOLID  (el código debe de tener una única razón para cambiar).

  1. main(process.argv[2])  
  2.   
  3. async function main(filepath) {  
  4.   
  5.     try {  
  6.         const data = await input(filepath)  
  7.   
  8.         const wordCount = data.split(/\s/).length  
  9.         const wordCountJSON = {  
  10.             wordCount  
  11.         }  
  12.   
  13.         await output(wordCountJSON)  
  14.     } catch (e) {  
  15.         console.error(e)  
  16.         process.exit(1)  
  17.     }  
  18. }  
  19.   
  20. function input(location) {  
  21.     const path = require('path')  
  22.     const fs = require('fs')  
  23.       
  24.     const filepathResolved = path.resolve(location)  
  25.     return fs.promises.readFile(filepathResolved, 'utf8')  
  26. }  
  27.   
  28. const saveTo = filepath => content => {  
  29.     const path = require('path')  
  30.     const fs = require('fs')  
  31.       
  32.     const filepathResolved = path.resolve(filepath)  
  33.     return fs.promises.writeFile(  
  34.         file path Resolved,  
  35.         JSON.stringify(content, null, 2)  
  36.     )  
  37. }  
  38.   
  39. const output = saveTo('word-count.json')

Mediante la técnica de refactoring – extract method  encapsulamos la entrada/salida de datos del programa en sendas funciones input/output que definen la interfaz de entrada y salida de datos, líneas 6 a 13.  De esta forma la lógica de nuestra aplicación sólo depende de dihcas interfaces, contratos de cómo debe comportarse el entorno para obtener el texto y donde dejar el informe. Hemos aplicado el principio de inversión de dependencias de SOLID (depender de abstracciones, no de concreciones)Ahora me puedo llevar las:

– líneas 6 – 13: al fichero word-counter.js, que aísla la lógica de negocio de cómo se cuentan las palabras, de forma independiente del origen y destino de los datos, mediante la inyección de las dependencias que llevan a cabo ese trabajo.

  1. module.exports = (input, output) => async location => {  
  2.   
  3.     const data = await input(location)  
  4.   
  5.     const wordCount = data.split(/\s/).length  
  6.     const wordCountJSON = {  
  7.         wordCount  
  8.     }  
  9.   
  10.     await output(wordCountJSON)  

– líneas 20 – 26: al fichero input-from-file.js, que aisla la entrada de datos desde el filesystem y define la interfaz de input: string => Promise<string>

  1. const path = require('path')  
  2. const fs = require('fs')  
  3.   
  4. // string => Promise<string>  
  5. module.exports = function (filepath) {  
  6.     const filepathResolved = path.resolve(filepath)  
  7.     return fs.promises.readFile(filepathResolved, 'utf8')  
  8. }

– líneas 28 – 39: al fichero output-to-file.js, que aísla la salida de datos a un fichero de texto y define la interfaz de output: string => Promise<void>

  1. const path = require('path')  
  2. const fs = require('fs')  
  3.   
  4. // string => string => Promise<void>  
  5. module.exports = filepath => content => {  
  6.     const filepathResolved = path.resolve(filepath)  
  7.     return fs.promises.writeFile(  
  8.         filepathResolved,  
  9.         JSON.stringify(content, null, 2)  
  10.     ) 
  11.  }

– quedando el fichero main.js de ejecución desde consola como el responsable de componer todas las piezas de la aplicación, inyectando las implementaciones concretas, para ese modo de ejecución, la consola, a la lógica de negocio del contador.

  1. const input = require('./inputFromFile')  
  2. const output = require('./outputToFile')  
  3. const wordCounterFrom = require('./word-counter')(  
  4.     input, output('word-count.json')  
  5. )  
  6.   
  7. main(process.argv[2])  
  8.   
  9. async function main (location) {  
  10.     try {  
  11.         await wordCounterFrom(location)  
  12.     } catch (e) {  
  13.         console.error(e)  
  14.         process.exit(1)  
  15.     }  
  16. }  

Pero nuestro objetivo era introducir el cambio del origen del fichero, en este caso un endpoint http, por lo que ahora sólo necesitamos introducir un nuevo fichero input.js, que cumpla con la misma interfaz de input-from-file.js, y que decida de qué fuente es la localización del fichero, para usar la estrategia implementada en input-from-file.js o la nueva implementación alojada en input-from-http.js, la cual cumple con la interfaz de input:

  1. const strategies = {  
  2.     file: require('./input-from-file'),
  3.     http: require('./input-from-http')  
  4. }  
  5.   
  6. // string => Promise<string>  
  7. module.exports = function (location) {  
  8.   
  9.     if (location.startsWith('http')) {  
  10.         return strategies['http'](location);  
  11.     }  
  12.   
  13.     return strategies['file'](location);  
  14. }  

Ahora en vez de importar input-from-file.js desde main.js, para inyectarla en word-counter.js, importamos input.js y la usamos para inyectarlo igualmente, ya que mantiene la misma interfaz. Este mecanismo cumple con el principio abierto/cerrado de SOLID (se ha llevado a cabo una extensión de la funcionalidad sin cambiar la implementaciones que teníamos)Quedando la implementación de input-from-http.js:

  1. const http = require('http')  
  2. // string => Promise<string>  
  3. module.exports = function (url) {  
  4.   
  5.     return new Promise((resolve, reject ) => {  
  6.   
  7.         http.get(url, response => {  
  8.             let data = '';  
  9.   
  10.             response.on('data', chunk => {  
  11.                 data += chunk  
  12.             });  
  13.   
  14.             response.on('end', () => {  
  15.                 resolve(data)  
  16.             })  
  17.         }).on("error", (err) => {  
  18.             err.message = `[HTTPContent] Error retrieve data from ${error} due to ${err.message}`  
  19.             reject(err)  
  20.         });  
  21.     })  
  22. }  

De esta forma, tanto la ejecución de: “node main.js <ruta a fichero> o node main.js <url del texto>” generarán el mismo resultado, un fichero word-count.json con el número de palabras del texto recibido por cada fuente de datos.

Y dado el éxito de nuestro sistema, nos llegan más cambios. Se quiere exponer el sistema como un microservicio que sea accesible vía web, al que mediante una petición GET se pase un parámetro querystring denominado location, con ubicación para recuperar el texto a procesar, que responderá con el informe donde se indica el número de palabras del fichero. En este caso todo son cambios de infraestructura, sustituimos la consola inicial por un servidor web, pero dado el diseño en que hemos aislado la lógica de aplicación de la infraestructura, nos será trivial llevar a cabo esta implementación, ya que podemos reusar dicha lógica.

  1. const http = require('http')  
  2. const url = require('url')
  3.   
  4. const input = require('./input')  
  5. const WordCounter = require('./word-counter')  
  6.   
  7. const port = 8000  
  8.   
  9. const server = http.createServer(handlerRequest)  
  10. server.listen(port)  
  11. server.on('error', e => console.error('*** Server error', e))  
  12. server.on('listening', () => console.log('*** Server running at http://localhost:%s/', port))  
  13.   
  14. function handlerRequest (req, res) {  
  15.     const reqURL = url.parse(req.url, true)  
  16.     const location = reqURL.query['location']  
  17.   
  18.     wordCountFrom(location, res)  
  19. }  
  20.   
  21. async function wordCountFrom (location, response) {  
  22.     try {  
  23.         const wordCounter = WordCounter(input, output)  
  24.         await wordCounter(location)  
  25.     } catch (err) {  
  26.         handleError(err)  
  27.     }  
  28.   
  29.     async function output (wordCountJSON) {  
  30.         response.writeHead(200, {  
  31.             'Content-Type': 'application/json'  
  32.         })  
  33.         response.end(JSON.stringify(wordCountJSON))  
  34.     }  
  35.   
  36.     function handleError (error) {  
  37.         response.status(500)  
  38.                 .json({cause: error.message})  
  39.     }  

Y llegan más cambios, hay que añadir al informe el número de proposiciones del texto. El informe quedaría así:

{

     wordCount: number;

     prepositionCount: number;

}
¿Dónde crees que impacta el cambio introducido?, ahora sólo hay que modificar el word-counter.js para añadir este cambio de formato de documento, pero TODO nuestro sistema permanece inalterado. A esto es a lo que pretendíamos llegar, un sistema mantenible con la capacidad de absorber cambios tanto de infraestructura de ejecución como de funcionalidad.