Patrón: Builder

Patrón: Builder

Patrón Builder en los paradigmas de Programación Orientación a Objectos y Programación Funcional.

Para poder describir el patrón Builder vamos a apoyarnos una vez más en un ejemplo. En concreto vamos a imaginar una situación en la que vamos a tener que hacer el escaneo de un directorio que está lleno de archivos de tal manera que por cada uno de estos archivos comprobaremos si se trata de un fichero JSON y por lo tanto lo tendríamos que leer con un parser de JSON o bien en si se trata de una fichero de texto en cuyo caso lo deberíamos leer con un parser de fichero de texto.

De forma gráfica el patrón Builder se puede representar como se muestra en la siguiente imagen:

¿Qué pasos se seguirán cuando estemos implementando este patrón? Pues atendiendo al diagrama anterior tenemos que saber que:

  1. App lo primero que tiene que hacer es instanciar un objeto que garantice que tiene una serie de métodos dentro del mismo, que en nuestro ejemplo, esto quiere decir que creará un objeto del tipo ScraperMethods que será el que deberá determinar si se tiene que leer el archivo con un parser de JSON o con un parse de texto en función del tipo que sea.

  2. Una vez App tiene este objeto creado lo que hará será dárselo a otro objeto que actuará de forma genérica con el. Esto se entiende mejor en nuestro caso puesto que lo que App hace es coger el ScraperMethods que ha construido previamente y pasárselo a DirScraper para que los utilice.

  3. DirScraper lo que hace es comenzar a leer el contenido un directorio y en función del tipo del archivo que sea se lo pasará a ScraperMethod que ha recibido de App para que lo trate lo que hace que nuestro objecto DirScraper sea mucho más genérico y además lo objetos ScraperMethods son a su vez más genéricos y por lo que nuestro código será mucho más mantenible además de que vamos a poderlos reutilizar en otros contexto cuando lo consideremos necesario.

Setup

Antes de comenzar con la implementación del patrón vamos a hacer el setup de nuestro proyecto simplemente instalando typescript y ts-node como dependencias de desarrollo:

$ yarn add -D typescript ts-node

Y después lo que tenemos que hacer es inicializar TypeScript con la ejecución del comando:

$ npx tsc --init

Ahora que tenemos nuestro proyecto configurado lo siguiente que vamos a hacer es crear el directorio data y dentro del mismo y dentro del mismo vamos a crear dos archivos para ver como funcionará el patrón donde el contenido de cada uno de ellos no es relevante para lo que estamos explicando. Así creamos el fichero json-file.json que contendrá el siguiente código:

{
  "someValue": true
}

Y el fichero text-file.txt con el siguiente contenido:

Avengers is cool

Ahora que ya tenemos los datos con los que va a trabajar nuestra aplicación el siguiente paso que vamos a dar consistirá en ver cómo implementamos el patrón Builder tanto en programación orientada a objetos como en programación funcional.

Implementación: Orientación a Objectos

Vamos a comenzar creando el fichero dir-scraper-class.ts el cual albergará la implementación orientada a objetos (clases) de todas las clases que forman parte de este patrón. Así comenzaremos definiendo la interfaz IFileReader como sigue:

interface IFileReader {
  isJSONFile(file: string): boolean
  readText(file: string): string
  readJSON(file: string): unknown
}

Como podemos observar esta interfaz es muy sencilla puesto que lo que estamos haciendo es definir el método isJSONFile el cual recibirá como parámetro el nombre del fichero que queremso leer y nos retornará true en el caso de que se trate de un fichero JSON o false en caso contrario.

Nota: se podría definir también el método isTextFile de forma análoga a lo que hemos hecho con isJSONFile pero con el objetivo de simplicar el código con el que estamos trabajando vamos a suponer que en nuestro proyecto solamente puede haber fichero JSON y ficheros de texto de tal manera que si no se trata de un fichero JSON será un fichero de texto y viceversa por lo que únicamente con el método isJSONFile vamos a poder determinar el tipo de archivo del que se trata.

Ademas se define el método readText que es el que deberemos invocar en el caso de que se tenga que leer un fichero de texto retornando un string que contendrá el contenido de este fichero y el método readJSON que también recibe como parámetro el nombre del fichero a leer pero en este caso retornará unknown puesto que no sabemos cuál es la estructura del JSON que está contenido dentro de dicho fichero.

La interfaz IFileReader es la que va a utilizar nuestro DirScrapper invocando en primer lugar al método isJSONFile y en función del valor que retorne lo que acabará haciendo es invocar al método readText o readJSON en función de si se trata de un fichero JSON o de un fichero de texto.

Vamos por lo tanto con la implementación de DirScrapper:

class DirScrapper {}

Lo que tenemos que pensar ahora es en qué cosas precisa nuestra clase para poder realizar su trabajo y proporcionársela en su constructor. En concreto lo que vamos a necesitar va a ser el path del directorio que vamos a leer y el objeto IFileReader que nos servirá para poder llevar a cabo el parser de los ficheros contenidos en el directorio:

class DirScrapper {
  constructor(
    public dirPath: string,
    public fileReader: IFileReader
  ) {}
}

Nota: teniendo en cuenta que estamos usando TypeScript al definir los parámetros de nuestro constructor como lo hemos hecho lo que sucederá es que TypeScript va a construir los atributo dirPath y fileReader para la clase DirScrapper y asignarles como valores los parámetros que recibe el constructor sin que nosotros lo tengamos que hacer de forma explícita.

Nos queda por definir el método que permita realizar el scraper de todos los ficheros que están en el directorio de tal manera que pueda ser llamado desde el exterior. Así definimos el método scanFiles como sigue:

scanFiles() {}

En este punto es cuando vamos a hacer uso del módulo fs que nos proporciona Node (fs viene de File System) puesto que nos permitirá acceder a una serie de métodos que nos van a facilitar mucho nuestro trabajo por lo que vamos a importarlo:

import fs from 'fs'

En concreto lo que haremos será llamar al método readdirSync el cual recibe como parámetro el path de un directorio que queremos leer de forma síncrona:

scanFiles() {
  fs.readdirSync(this.dirPath)
}

lo que tenemos que saber es que la invocación de readdirSync nos va a retornar una serie de objetos que representarán a todos los ficheros que están dentro de nuestro directorio.

Ahora bien, ¿qué queremos hacer con estos fichero? Pues vamos a suponer que como resultado de la invocación del método scanFile lo que queremos es retornar un objeto donde cada uno de los atributos que los forman representa a uno de los ficheros que están dentro del directorio y como valor que tiene asociado será el contenido del fichero en cuestión (es decir, que tendrá asociado a un texto o un objeto JSON). Si lllevamos esta idea a nuestro código tendremos algo como:

scanFiles() {
  fs.readdirSync(this.dirPath)
    .reduce<Record<string, unknown>>((acc, file) => {}, {})
}

Vamos por lo tanto a aprovecharnos de método reduce que tenemos a nuestra disposición porque estamos trabajando con el array que nos retorna readdirSync donde atendiendo a la explicación que acabamos de hacer el resultado de su ejecución será un Record<string, unknown> de TypeScript (es decir, un objeto que está formado por una serie de claves que son string y como valor no podemos saberlo a priori porque dependerá de si se trata de un fichero de texto o un fichero JSON) e inicialmente definimos que el objeto que retornará está vacío.

Dentro de la función asociada reduce es donde vamos a preguntar si estamos ante un fichero de texto o un fichero JSON para lo cual utilizaremos el método isJSONFile que está definido dentro de nuestro fileReader:

scanFiles() {
  fs.readdirSync(this.dirPath)
    .reduce<Record<string, unknown>>((acc, file) => {
      if (this.fileReader.isJSONFile(file)) {}
    }, {})
}

Como podemos imaginar en el caso de que estemos ante un fichero JSON lo que vamos a tener que hacer es crear un nuevo atributo dentro del objeto acc en el que su clave será el nombre del fichero y como valor será el resultado de la invocación del método readJSON de nuestro fileReader. El caso contrario se creará también el atributo del objeto pero como valor tendremos el resultado de la lectura de readText:

scanFiles() {
  fs.readdirSync(this.dirPath)
    .reduce<Record<string, unknown>>((acc, file) => {
      if (this.fileReader.isJSONFile(file)) {
        acc[file] = this.fileReader.readJSON(file)
      } else {
        acc[file] = this.fileReader.readText(file)
      }
    }, {})
}

Nos queda un pequeño detalle dentro de la función reduce y es que tenemos que retornar el objeto acc para que pueda seguir siendo utilizado en la función de reducción:

scanFiles() {
  fs.readdirSync(this.dirPath)
    .reduce<Record<string, unknown>>((acc, file) => {
      if (this.fileReader.isJSONFile(file)) {
        acc[file] = this.fileReader.readJSON(file)
      } else {
        acc[file] = this.fileReader.readText(file)
      }

      return acc
    }, {})
}

y no solamente eso sino que el propio resultado de su ejecución será lo que retornará la función reduce:

scanFiles() {
  return fs.readdirSync(this.dirPath)
    .reduce<Record<string, unknown>>((acc, file) => {
      if (this.fileReader.isJSONFile(file)) {
        acc[file] = this.fileReader.readJSON(file)
      } else {
        acc[file] = this.fileReader.readText(file)
      }

      return acc
    }, {})
}

Como estamos trabajando con TypeScript el compilador se quejará de que no sabe cual es el tipado que está asociado al objeto acc que estamos usando dentro del reducer (lo que hemos tipado es el valor que retorna el reducer) pero es este caso la corrección es sencilla puesto que acc es el valor que retorna el reducer y por lo tanto ha de ser del mismo tipo:


scanFiles() {
  return fs.readdirSync(this.dirPath)
    .reduce<Record<string, unknown>>((acc: <Record<string, unknown>>, file) => {
      if (this.fileReader.isJSONFile(file)) {
        acc[file] = this.fileReader.readJSON(file)
      } else {
        acc[file] = this.fileReader.readText(file)
      }

      return acc
    }, {})
}

También deberemos tipar el parámetro file que en este caso ha de ser un string lo que nos deja algo como lo siguiente:

scanFiles() {
  return fs.readdirSync(this.dirPath)
    .reduce<Record<string, unknown>>(
      (acc: <Record<string, unknown>>, file: string) => {
        if (this.fileReader.isJSONFile(file)) {
          acc[file] = this.fileReader.readJSON(file)
        } else {
          acc[file] = this.fileReader.readText(file)
        }

        return acc
      },
      {}
    )
}

Nos queda un pequeño detalle que puede no ser evidente y que tiene que ver con la invocación de los método readJSON y readText y es que ahora mismo como parámetro le estamos pasando el nombre del fichero pero no el path completo a dicho fichero por lo que es probable que estos métodos no funcionen. ¿La solución? Pues pasar el path completo al fichero como sigue:

scanFiles() {
  return fs.readdirSync(this.dirPath)
    .reduce<Record<string, unknown>>(
      (acc: <Record<string, unknown>>, file: string) => {
        if (this.fileReader.isJSONFile(file)) {
          acc[file] = this.fileReader.readJSON(`${this.dirPath}/${file}`)
        } else {
          acc[file] = this.fileReader.readText(`${this.dirPath}/${file}`)
        }

        return acc
      },
      {}
    )
}

Para poder continuar con la implementación del patrón Buildir tendremos que crear la clase que implemente la interfaz IFileReader, clase a la que vamos a llamar FileReader:

class FileReader implements IFileReader {}

Ahora tenemos que implementar cada uno de los métodos que están recogidos dentro de esta interfaz. Comenzando por isJSONFile lo que haremos será ver la extensión del fichero que se recibe como parámetro y en función de la misma determinaremos si es un fichero JSON o no:

class FileReader implements IFileReader {
  isJSONFile(file: string): boolean {
    return file.endsWith('.json')
  }
}

¿Qué hacermos en el caso de estar leyendo un fichero de texto? Pues simplemente haremos uso del método readFileSync que también nos proporciona fs el cual espera recibir como primer parámetro el path del fichero que se quiere leer y como segundo parámetro el formato de caracteres que se estará utilanzo dejando algo como lo siguiente:

class FileReader implements IFileReader {
  isJSONFile(file: string): boolean {
    return file.endsWith('.json')
  }

  readText(file: string): string {
    return fs.readFileSync(file, 'UTF-8').toString()
  }
}

En el caso de la implementación de readJSON lo que vamos a hacer es lo mismo que para readText pero una vez tenemos leído el contenido del fichero que se recibe como parámetro como un string se lo pasaremos al método parse que tenemos definido en el objeto JSON de JavaScript:

class FileReader implements IFileReader {
  isJSONFile(file: string): boolean {
    return file.endsWith('.json')
  }

  readText(file: string): string {
    return fs.readFileSync(file, 'UTF-8').toString()
  }

  readJSON(file: string): unknown {
    return JSON.parse(fs.readFileSync(file, 'UTF-8').toString())
  }
}

Encajando todas las piezas del patrón Builder

Una vez tenemos definidas todas las piezas por separado del patrón Builder el siguiente paso que tenemos que dar será encajarlas para lograr nuestro objetivo. Así pues, comenzaremos creando un objeto de la clase FileReader:

const fileReader = new FileReader()

Y a continuación lo que haremos será crear una instancia de DirScrapper a la que le pasaremos como primero parámetro el path al directorio que contiene nuestros archivos (que recordemos es el directorio data) y como segundo parámetro el IFileReader que acabamos de crear:

const fileReader = new FileReader()
const dirScraper = new DirScraper('./data', fileReader)

Hecho esto vamos a obtener el resultado del escaneado del directorio en cuestión y lo escribiremos por la consola:

const fileReader = new FileReader()
const dirScraper = new DirScraper('./data', fileReader)
const output = dirScraper.scanFiles()

console.log(output)

Pasamos a ejecutar el código que hemos construido abriendo una terminal del sistema y escribiendo en la misma:

$ npx ts-node dir-scraper-class.ts

Y vemos que en la salida obtenemos el resultado que estábamos esperando:

¿Qué hemos logrado con todo esto?

En definitiva gracias a la aplicación del patrón Builder lo que hemos logrado es que DirScraper no esté acoplado con el IFileReader que utilizará de tal manera que podremos reutilizarlos cuando lo consideremos necesario.

Por ejemplo, DirScraper podría tener diferentes tipos de scraper que le ayuden a realizar su trabajo (no solamenente IFileReader) por lo que podría añadir de forma sencilla nuevos métodos que se encargarán de llevar estas tareas a cabo.

Además podemos usar el FileReader que hemos implementado en otros contexto más allá de DirScraper e incluso irle añadiendo de forma sencilla nuevos tipos de ficheros con los que trabajar.

Por normal general que dos piezas de software estén desacopladas es una buena práctica.

Código completo (Orientación a Objetos)

Con el fin de tenerlo todo recogido en un único sitio vamos a mostrar a continuación el código completo de la implementación del patrón Builder mediante el paradigma de programación orientada a objetos:

import fs from 'fs'

interface IFileReader {
  isJSONFile(file: string): boolean
  readText(file: string): string
  readJSON(file: string): unknown
}

class DirScrapper {
  constructor(
    public dirPath: string,
    public fileReader: IFileReader
  ) {}

  scanFiles() {
    return fs.readdirSync(this.dirPath)
      .reduce<Record<string, unknown>>(
        (acc: <Record<string, unknown>>, file: string) => {
          if (this.fileReader.isJSONFile(file)) {
            acc[file] = this.fileReader.readJSON(`${this.dirPath}/${file}`)
          } else {
            acc[file] = this.fileReader.readText(`${this.dirPath}/${file}`)
          }

          return acc
        },
        {}
      )
  }
}

class FileReader implements IFileReader {
  isJSONFile(file: string): boolean {
    return file.endsWith('.json')
  }

  readText(file: string): string {
    return fs.readFileSync(file, 'UTF-8').toString()
  }

  readJSON(file: string): unknown {
    return JSON.parse(fs.readFileSync(file, 'UTF-8').toString())
  }
}

const fileReader = new FileReader()
const dirScraper = new DirScraper('./data', fileReader)
const output = dirScraper.scanFiles()

console.log(output)

Implementación: Funcional

Vamos a centrarnos ahora en la implementación funcional del patrón Builder para lo cual crearemos el fichero dir-scraper-functions.ts donde dejaremos la definición de la interfaz IFileReader puesto que es la misma:

import fs from 'fs'

interface IFileReader {
  isJSONFile(file: string): boolean
  readText(file: string): string
  readJSON(file: string): unknown
}

En el paradigma funcional lo que antes era la clase DirScraper ahora tiene que pasar a ser una función que recibirá como parámetros los dos argumentos que estaban definidos en el constructor de dicha clase:

const dirScraper = (dirPath: string, reader: IFileReader) => {}

¿Cuál será el continido de esta función? Pues el mismo que teníamos en el código del método scanFiles de la implementación orientada a objetos haciendo las modificaciones oportunas para que se pase a trabajar con los parámetros que define la función lo que nos dejará algo como lo siguiente:

const dirScraper = (dirPath: string, reader: IFileReader) => {
  return fs.readdirSync(dirPath)
    .reduce<Record<string, unknown>>(
      (acc: <Record<string, unknown>>, file: string) => {
        if (fileReader.isJSONFile(file)) {
          acc[file] = fileReader.readJSON(`${dirPath}/${file}`)
        } else {
          acc[file] = fileReader.readText(`${dirPath}/${file}`)
        }

        return acc
      },
      {}
    )
}

Nos toca ahora implementar la clase FileReader que teníamos en la implementación orientada a objetos con programación funcional y el patrón de actuación es el mismo puesto que lo que vamos a hacer es definir una constante fileReader que será del tipo IFileReader (es decir, que será un objeto) con los métodos que tiene IFileReader dejando exactamente la misma implementación que en el caso de la implementación orientada a objetos:

const fileReader: IFileReader {
  isJSONFile(file: string): boolean {
    return file.endsWith('.json')
  }

  readText(file: string): string {
    return fs.readFileSync(file, 'UTF-8').toString()
  }

  readJSON(file: string): unknown {
    return JSON.parse(fs.readFileSync(file, 'UTF-8').toString())
  }
}

Nos queda por ver cómo llamamos ahora a nuestro código con el fin de que nos liste el contenido de los ficheros recogidos dentro del directorio data lo que nos deja algo tan simple como puesto que llamaremos directamente a la función directoryScraper pasándole como primer parámetro el directorio que vamos a leer y como segundo el objeto fileReader que acabamos de definir puesto que este implementa la interfaz IReader:

const output = directoryScraper('./date', fileReader)

console.log(output)

Además en el código anterior hemos añadido la instrucción para que nos muestre por la consola el resultado de su ejecución para poder evaluar que está funcionando correctamente por lo que si nos vamos a la terminal del sistema y escribimos:

$ npx ts-node dir-scraper-functions.ts

El resultado que obtenemos es algo similar a lo que podemos ver en la siguiente imagen:

que es exactamente lo que estábamos esperando.

Código completo (Funcional)

El código completo de la implementación funcional del patrón Builder es el que podemos ver a continuación:

import fs from 'fs'

interface IFileReader {
  isJSONFile(file: string): boolean
  readText(file: string): string
  readJSON(file: string): unknown
}

const dirScraper = (dirPath: string, reader: IFileReader) => {
  return fs.readdirSync(dirPath)
    .reduce<Record<string, unknown>>(
      (acc: <Record<string, unknown>>, file: string) => {
        if (fileReader.isJSONFile(file)) {
          acc[file] = fileReader.readJSON(`${dirPath}/${file}`)
        } else {
          acc[file] = fileReader.readText(`${dirPath}/${file}`)
        }

        return acc
      },
      {}
    )
}

const fileReader: IFileReader {
  isJSONFile(file: string): boolean {
    return file.endsWith('.json')
  }

  readText(file: string): string {
    return fs.readFileSync(file, 'UTF-8').toString()
  }

  readJSON(file: string): unknown {
    return JSON.parse(fs.readFileSync(file, 'UTF-8').toString())
  }
}

const output = directoryScraper('./date', fileReader)

console.log(output)