Patrón Factory Method

Patrón Factory Method

¿En qué consiste el patrón Factory Method? ¿Cómo se implementa con TypeScript?

Para la explicación del Patrón Factory Method nos vamos a basar en el mismo ejemplo que hemos descrito cuando se ha explicado el patrón Builder lo que quiere decir que estaremos intentando modelar una aplicación en la que se va a leer un directorio para obtener la información de los ficheros que lo forman y mostrarla en un formato u otro en función de si estamos ante un fichero JSON o un fichero de texto.

Cuando estamos hablando del patrón de creación Factory Method tenemos que pensar en un diagrama como el que se puede ver en la siguiente imagen:

En concreto cuando se está aplicando este patrón lo que podemos ver es que es la aplicación App la que es la encargada de crear el objeto DirScrapper que a su vez tiene que ser un descendiente (tiene que heredar de) de AbstractDirScrapper.

En este caso es la clase AbstractDirScrapper la que tiene que tener la lógica que va a permitir ir leyendo el contenido de los ficheros que están dentro de un directorio mientras que que será la clase DirScrapper la que ha de tener los métodos concretos que permitirán distinguir si el fichero es un JSON o un fichero de texto.

Lo realmente interesante de este patrón es que en este caso App solamente precisa conocer la API que le estará ofreciendo DirScrapper lo que le va a permitir instanciarla directamente y obtener la funcionalidad que precise.

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 Factory Method tanto en programación orientada a objetos como en programación funcional.

Implemetación: Orientación a Objetos

Lo primero que vamos a hacer es crear el fichero dir-scraper-class.ts que es el que va a contener la implemetación mediante Orientación a Objectos del patrón Factory Method.

Vamos a comenzar con la definición de la clase DirectoryScraper que recordemos que es la encargada de definir toda la funcionalidad que va a permitir realizar la lectura de un directorio y además deberá proporcionar la definición de los métodos que van a tener que implementar las clases específicas que hereden de ella. Así comenzaremos definiendo que nuestra clase es abstracta:

abstract class DirectorySCraper {}

¿Qué es lo que vamos a necesitar para la correcta implementación de la clase? Pues el directorio que queremos que sea leído y como tal se lo vamos a pasar en su constructor:

abstract class DirectoryScraper {
  constructor(public dirPath: string) {}
}

Nota: recordemos que como estamos usando TypeScript el definir un parámetro como public en el constructor de la clase este será creado por el compilador sin que nosotros los tengamos que definir de forma explícita en nuestro código lo que nos facilitará mucho el desarrollo porque evitaremos código verboso.

Con este código lo que venimos a decir es que, cada vez que se instancie un objeto de una clase que implemente DirectoryScrapper va a tener que saber el path del directorio que se vaya a leer o, dicho de otra manera, siempre tendremos que instanciar un nuevo objeto para poder leer un nuevo directorio (a no ser que cambiemos directamente el valor del atributo dirPath pero esto no se considerará una buena práctica dentro de la programación orientada a objetos).

Ahora será responsabilidad de definir en esta clase todos aquellos métodos que deberán ser implementados por sus clases hijas. Teniendo en cuenta que nuestro objetivo es intentar que la explicación sea lo más sencilla posible vamos a suponer que en un directorio concreto en el que se ejecutará nuestra aplicación va a poder tener tan solo archivos JSON y de texto por lo que parece lógico que en esta clase se definan el método que determinarán si se trata de un archivo JSON o no y los métodos que permitirán leer tanto un fichero JSON como un fichero de texto:

abstract class DirectoryScraper {
  constructor(public dirPath: string) {}

  abstract isJSONFile(file: string): boolean
  abstract readText(file: string): string
  abstract readJSON(file: string): unknown
}

Nos quedará por definir el método que se encargará de realizar la lectura de todos los ficheros que están dentro del directorio, método que será común en todas las instancias de la clase pero que hará uso de los métodos abstractos que acabamos de definir. En este caso como vamos a realizar la lectura de un directorio vamos a utilizar el módulo fs que nos proporciona Node:

import fs from 'fs'

y ahora pasaremos a definir el método scanFiles qen el que iremos construyendo el objeto que alberga la información de todos los archivos que han sido procesados dejándonos algo como lo siguiente:

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

Nota: no vamos entrar en la explicación de cómo se crear el objeto acc que está recogido en el método reduce por lo que te remitimos a la explicación detallada que hemos realizado cuando hemos explicado el patrón. En este punto a nosotros lo que nos interesa saber es que este objeto tiene un atributo por cada uno de los ficheros cuyo nombre es el mismo que el nombre del fichero y el valor que tiene asociado es el contenido de ese fichero que será un string en el caso de que estemos ante un fichero de texto o un objeto JSON en el caso de que estemos ante un fichero JSON.

Vamos con la creación de objeto DirScraper que recordemos que en el diagrama que hemos visto al inicio del artículo es el que podrá invocar directamente App. Además, habíamos dicho que esta clase habrá de heredar (extender) de DirectoryScraper por lo que la definimos de la siguiente manera:

class DirScraper extends DirectoryScraper {}

Como consecuencia de ello DirScrapper va a tener que implementar todos los métodos que están declarados como abstractos (abstract) en la clase padre. En este caso la implementación quedaría como sigue:

class DirScraper extends DirectoryScraper {
  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())
  }
}

Nota: Una vez más no entramos en los detalles de implementación de estos métodos puesto que ya los hemos explicado en profundidad en este otro artículo y no son relevantes para poder entender el patrón Factory Method.

Con esta estructura de clases en mente ahora ya podemos crear un instancia de DirScraper, llamar a su método scanFiles para que lea la información del directorio que le hemos pasado a la hora de crear la instancia (en este caso el directorio data) y mostrar el resultado por la consola:

const dirScraper = new DirScraper('./data')
const output = dirScraper.scanFiles()
console.log(output)

Si guardamos nuestro trabajo y nos dirigimos a la terminal del sistema podremos ejecutar el código que acabamos de construir gracias a la invocación del siguiente comando:

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

El resultado que obtenemos es el que podemos ver en la siguiente imagen donde podemos ver que está retornando un objeto donde cada uno de los atributos se corresponde con el nombre del fichero que ha sido leído y como valor el contenido del fichero, tal y como esperábamos:

Importante: con esta implementación hemos logrado que el grado de acoplamiento sea mínimo (loose copupling) entre la lógica que está definida en la clase DirectoryScraper y la implementación que se haya decidido hacer dentro del clase DirScraper de tal manera que DirectoryScraper no tiene por qué saber nada de cómo se determinará si estamos ante un fichero JSON o ante un fichero de texto sino que simplemente utilizará las implementaciones que se hayan establecido en cada una de las clase hijas.

Esto al final lo que nos va a permitir es tener múltiples versiones de DirectoryScraper que permitirán realizar la lectura de un directorio de forma totalmente diferente..

A continuación mostramos el código completo de la implementación del patrón Factory Method siguiendo el paradigma de la programación orientada a objetos.

import fs from 'fs'

abstract class DirectoryScraper {
  constructor(public dirPath: string) {}

  abstract isJSONFile(file: string): boolean
  abstract readText(file: string): string
  abstract readJSON(file: string): unknown

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

class DirScraper extends DirectoryScraper {
  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 dirScraper = new DirScraper('./data')
const output = dirScraper.scanFiles()
console.log(output)

Implementación: Funcional.

Vamos ahora con la implementación funcional de este patrón y para ello vamos a crear el fichero dir-scraper-functions.ts donde vamos a tener que empezar definiendo la interfaz en la que se recogerán todos los métodos que han de proporcionarnos las clases que implemetarán AbstractDirScraper en el diagrama del inicio de este artículo.

Pero ¿esto por qué es así? Pues porque no vamos a hacer uso de la clases puesto que estamos bajo el paradigma funcional pero al usar TypeScript es posible que podamos establecer cuál es el tipado de un objeto y en nuestro caso querremos aseguirar que vamos a tener estos métodos.

Por lo tanto comenzamos con la definición de nuestro código como sigue:

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

Ahora vamos con la definición de la función que nos permitirá construir el objeto del tipo IFileReader puesto que esta será la función que invocaremos desde nuestra aplicación. Así pues definimos:

const createDirectoryScraper = (fileReader: IFileReader) => {}

Como podemos ver esta función toma como argumento un IFileReader y lo que tenemos que lograr es retornar una función que a su vez será el DirScrapper con el que trabajaremos desde nuestra aplicación.

const createDirectoryScraper = (fileReader: IFileReader) => {
  return () => {}
}

¿Qué necesitará esta función para poder trabajar correctamente? Pues el path del directorio sobre el que tiene que trabajar (cosa que le pasaremos como parámetro) y dentro de la misma se recorrerán todos los archivos que están definidos ahí dentro retornando el objeto que la información del nombre del archivo y el contenido del mismo (similar al que hemos visto anteriormente):

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

Como podemos observar la principal diferencia que existe con respecto a la implementación mediante la orientación a objetos es que en este caso no estaremos haciendo uso de la referencia this puesto que no estamos trabajando dentro de una clase sino que lo que hacemos es invocar a los métodos que están definidos dentro de la interfaz IFileReader.

El siguiente paso consistirá en definir una implementación de la interfaz IFileReader sabiendo que como estamos dentro de la programación funcional lo que vamos a tener que hacer es definir un objeto que nos garantice que retorna un objeto que cumple con esta interfaz. Así podemos definir algo como lo siguiente:

const fileReader: IFileReader = {
  isJSONFile: (filePath: string): boolean => filePath.endsWith('.json'),
  readTextFile: (filePath: string): string =>
    fs.readFileSync(filePath, 'utf8').toString(),
  readJSONFile: (filePath: string): unknown =>
    JSON.parse(fs.readFileSync(filePath, 'utf8').toString())
}

Nota: No entraremos en detalles del código propio de cada uno de estos métodos porque básicamente es lo mismo que hemos hecho en el caso de la programación orientada a objetos.

Ahora podemos usar este objeto para poder invocar a la función createDirectoryScraper que como sabemos nos retornará a su vez una función:

const scanFiles = createDirectoryScraper(fileReader)

y para obtener el resultado de la ejecución de nuestro código lo que vamos a tener que hacer es invocar a la función que acabamos de guardar en scanFiles pasándole el directorio que queremos escanear (que en nuestro caso será el directorio data) escribiendo además el resultado por la consola:

const scanFiles = createDirectoryScraper(fileReader)
console.log(scanFiles('./data'))

Con todos estos cambios realizados guardamos nuestro código y nos vamos a la terminal del sistema con el fin de ejecutarlo gracias a la ejecución del comando:

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

Lo que nos acabará sacando por la terminal algo como lo que se muestra en la siguiente imagen donde se muestra efectivamente que se ha logrado obtener la información de los ficheros que forman parte del directorio tal y como estábamos esperando:

En resumen hemos logrado abstraer la estrategia que se seguirá a la hora de hacer el scraping del directorio con el que estamos trabajando (es decir, cómo extraer la información de los archivos que están dentro de un directorio) de la forma en la que se deberá parsear (interpretar) la información de todos los archivos que nos hemos ido encontrando dentro de este directorio lo que en última instancia se traduce en que hemos conseguido reducir el acoplamiento entre estas dos cosas (lectura de un directorio y parseo de los ficheros).

A continuación mostramos el código completo en el caso de la implementación funciónal del patrón Factory Method por si necesitamos consultarlo de forma rápida:

import fs from 'fs'

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

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

const fileReader: IFileReader = {
  isJSONFile: (filePath: string): boolean => filePath.endsWith('.json'),
  readTextFile: (filePath: string): string =>
    fs.readFileSync(filePath, 'utf8').toString(),
  readJSONFile: (filePath: string): unknown =>
    JSON.parse(fs.readFileSync(filePath, 'utf8').toString())
}

const scanFiles = createDirectoryScraper(fileReader)