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:
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 tipoScraperMethods
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.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 queApp
hace es coger elScraperMethods
que ha construido previamente y pasárselo aDirScraper
para que los utilice.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á aScraperMethod
que ha recibido deApp
para que lo trate lo que hace que nuestro objectoDirScraper
sea mucho más genérico y además lo objetosScraperMethods
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 conisJSONFile
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étodoisJSONFile
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
yfileReader
para la claseDirScrapper
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)