Patrón: Abstract Factory

Patrón: Abstract Factory

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

En esta serie de artículo dedicados a los patrones de diseño trataremos ver la implementación de cada uno de ellos siguiente tanto la programación orientada a objetos como la programación funcional.

Un ejemplo de este patrón de diseño es un logger donde tenemos por una parte una aplicación cliente, tenemos además una factoría para la creación de las instancias de este logger (en nuestro ejemplo el LoggerFactory) y lo realmente interesante aquí es que desde el punto de vista de la aplicación cliente dentro de esta factoría se estará decidiendo si se está ejecutando en el entorno de desarrollo o de producción y por lo tanto ante una petición de creación nos responderá con una instancia de ProductionLogger en el caso de que estemmos en producción o con una instancia de DevelopmentLogger en el caso de que estemos en desarrollo.

¿Qué estamos logrando con esto? Pues que el código de nuestra aplicación cliente no esté en ningún momento acoplado con el código del Logger en función del entorno en el que se está ejecutando puesto que tenemos que pensar que si no usásemos este patrón deberíamos tener la lógica en la que se determinar el entorno de ejecución en la propia aplicación cliente y dentro de esta lógica es donde deberíamos crear las instancias de ProductionLogger o de DevelopmentLogger en función del entorno en que se está ejecutando (dicho de otra manera, nuestra aplicación cliente estaría fuertmente acoplada con el código del logger).

Siguiendo el patrón Abstract Factory lo que vamos a conseguir es eliminar este acoplamiento y que sea en la factoría donde se lleve a cabo toda la lógica que se encargará de darnos la instancia del logger que vamos a necesitar en función del entorno de ejecución.

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

Programación Orientación a Objetos.

Vamos a comenzar creando una nueva clase dentro de nuestra aplicación que será en la que se corresponderá con nuestro LoggerFactory. Pero antes de nada vamos a tener que definir una interfaz en la que recogeremos todas aquellas operaciones que van a poder ser llevadas a cabo por los logger de nuestro sistema independientemente de cuál sea la implementación concreta.

interface ILogger {
  debug(message: string): void
  error(message: string): void
  info(message: string): void
  warn(message: string): void
}

Vemos por lo tanto que la definición de nuestra interfaz es muy sencilla puesto que lo único que estaremos recogiendo es la definición de los cuatro métodos que se encargarán de implementar los diferentes niveles de log en nuestras aplicaciones.

Vamos ahora a centrarnos en la implementación de cada uno de los loggers concretos, es decir, en la implementación de las clases ProductionLogger y DevelopmentLogger que lo que tienen que tener en común es que ambas han de implementar nuestra interfaz ILogger.

Comenzando con la implementación de ProductionLogger lo que vamos a hacer es que únicamente se muestren en el log los mensajes que son de tipo warning o error o, dicho de otra manera, en el caso que se llamen a los métodos encargados de escribir los mensajes de tipo debug o info no se haga nada. Así pues la implementación de muestra clase quedará como sigue:


class ProductionLogger implements ILogger {
  debug(message: string): void {}

  error(message: string): void {
    console.error(message)
  }

  info(message: string): void {}

  warn(message: string): void {
    console.warn(message)
  }
}

En el caso de la implementación para el entorno de desarrollo vamos a querer que se escriban todos los mensajes de error por lo que en este caso la implementación que tendremos será algo parecido a lo que podemos ver a continuación:

class DevelopmentLogger implements ILogger {
  debug(message: string): void {
    console.debug(message)
  }

  error(message: string): void {
    console.error(message)
  }

  info(message: string): void {
    console.log(message)
  }

  warn(message: string): void {
    console.warn(message)
  }
}

Ahora que tenemos la implmentaciones concretas de cada uno de los logger que están disponibles en nuestro sistema es el momento de pasar a la implementación de la clase LoggerFactory. Vamos a mostrar en primer lugar el código y luego lo explicaremos:

class LoggerFactory {
  public static createLogger(env: string): ILogger {
    if (env === 'production') {
      return new ProductionLogger()
    } else {
      return new DevelopmentLogger()
    }
  }
}

Lo primero que tenemos que observar es que en esta primera implementación el método createLogger está recibiendo como parámetro un string que le indicará el entorno de ejecución desde el que se está realizando la llamada y así determinar el Logger que tiene que devolver. Esta aproximación es correcta pero lo que estamos obligando es que cualquier cliente que llame a este método tenga que conocer el entorno de ejecución desde el que lo llama para poder obtener el logger correcto por lo que una mejor solución pasaría porque ver si tenemos una manera que, desde el propio código de nuestra factoría, poder obtener cuál es el entorno de ejecución y por lo tanto retornar el logger adecuado.

En este caso sí que vamos a poder lograrlo gracias a las variables de entorno de Node y más concretamente a la variable NODE_ENV que podemos consular de la siguiente manera en el cógido:

class LoggerFactory {
  public static createLogger(): ILogger {
    if (process.env.NODE_ENV === 'production') {
      return new ProductionLogger()
    } else {
      return new DevelopmentLogger()
    }
  }
}

Si hacemos esto y estamos usando un editor como Visual Studio Code lo más probable es que TypeScript nos informe de un error puesto que no sabe qué es process y por lo tanto no vamos a poder acceder a nignuno de sus atributos de forma segura como podemos ver en la siguiente imagen:

Si nos fijamos bien TypeScript no solamente nos estará informando de cuál es el error que se está produciendo sino que además nos estará diciendo una posible solución para el mismo por lo que el problema anterior lo vamos a solventar si añadimos como una dependencia de desarrollo los tipos de Node ejecutando el comando:

$ yarn add -D @types/node

Ahora vamos a juntar todo el código que hemos desarrollado en un único fichero al que vamos a denominar abstract-factory-class.ts y dentro del mismo vamos a exportar dos cosas: la interfaz que define un Logger (es decir, la interfaz ILogger) y la factoría que hemos creado lo que nos deja algo como:

// abstract-factory-class.ts
export interface ILogger {
  debug(message: string): void
  error(message: string): void
  info(message: string): void
  warn(message: string): void
}

class ProductionLogger implements ILogger {
  debug(message: string): void {}
  error(message: string): void {
    console.error(message)
  }
  info(message: string): void {}
  warn(message: string): void {
    console.warn(message)
  }
}

class DevelopmentLogger implements ILogger {
  debug(message: string): void {
    console.debug(message)
  }
  error(message: string): void {
    console.error(message)
  }
  info(message: string): void {
    console.log(message)
  }
  warn(message: string): void {
    console.warn(message)
  }
}

export class LoggerFactory {
  public static createLogger(): ILogger {
    if (process.env.NODE_ENV === 'production') {
      return new ProductionLogger()
    } else {
      return new DevelopmentLogger()
    }
  }
}

Tests de la factoría

Ahora que tenemos el código de nuestra factoría vamos a testear que efectivamente está funcionando tal y como queremos para lo cual creamos el fichero factory-abstract-test.ts donde en primer lugar importamos la factoría:

import { LoggerFactory } from './factory-class'

Y vamos a ahora a obtener un nuevo logger sin más que invocar al método createLogger sin especificar en ningún momento en el qué entorno se está ejecutando nuestra aplicación:

import { LoggerFactory } from './factory-class'

const logger = LoggerFactory.createLogger()

Ahora simplemente vamos a invocar a todos los métodos que están definidos dentro de la interfaz ILogger pasándole un mensaje en el que se indicará el nivel de log que queremos que se escriba:

import { LoggerFactory } from './factory-class'

const logger = LoggerFactory.createLogger()

logger.debug('debug message')
logger.warn('warn message')
logger.info('info message')
logger.error('error message)

Sabemos que por defecto la implementación del método createLogger de LoggerFactory va a devolver una instancia de DevelopmentLogger en el caso de que el entorno de desarrollo no sea el de producción lo que viene a decirnos que en el momento en el que ejecutemos el código anterior lo que esperaremos ver por la consola serán los cuatro mensajes que hemos definido. Así pues nos vamos a una terminal del sistema y escribimos:

$ npx ts-node factory-class-test.ts

y podremos ver que por la consola estamos obteniendo el restulado que esperábamos:

Si ahora cambiamos el entorno de ejecución a producción gracias al siguiente comando lo que vamos a esperar es que en la terminal únicamente aparezcan dos de los mensajes:

$ NODE_ENV=production npx ts-node factory-class-test.ts

y si nos fijamos en la siguiente imagen podemos ver cómo esto es así:

Programación Funcional

En el caso de querer implementar este patrón siguiendo la programación funcional lo primero que tendremos que hacer será crear un nuevo fichero en el que guardaremos nuestro código (ene este caso lo vamos a llamar abstract-factory-function.ts) y dentro del mismo copiamos el código que define la interfaz Logger puesto que es el mismo tanto para programación funcional como para programación orientada a objetos.

// abstract-factory-function.ts
export interface ILogger {
  debug(message: string): void
  error(message: string): void
  info(message: string): void
  warn(message: string): void
}

¿Cómo implementaremos ahora el equivalente a las clases ProductionLogger y DevelopmentMode? Pues tenemos que pensar que el paradigma funcional nuestra pieza de código base serán las funciones por lo que de alguna manera tenemos que crear el equivalente a estas dos clases pero con funciones. Así, la clase ProductionLogger se corresponderá a una función que nos devolverá un ILogger, es decir, un objeto que tiene una implementación específica de esta interfaz, implementación que se corresponderá con la que hemos llevado a cabo en la clase ProductionLogger. Esto nos deja el siguiente código:

const productionLogger = (): ILogger => ({
  debug(message: string): void {}
  error(message: string): void {
    console.error(message)
  }
  info(message: string): void {}
  warn(message: string): void {
    console.warn(message)
  }
})

Siguiendo este mismo razonamiento podemos ver que es relativamente sencillo establecer la implementación de la función equivalente a la clase DevelopmentLogger dejando algo como lo siguiente:

const developmentLogger = (): ILogger => ({
  debug(message: string): void {
    console.debug(message)
  }
  error(message: string): void {
    console.error(message)
  }
  info(message: string): void {
    console.log(message)
  }
  warn(message: string): void {
    console.warn(message)
  }
})

Ya solamente nos quedará crear la función equivalente a la clase LoggerFactory siguiendo el mismo patrón de refactorización pero sabiendo que el método estático que teníamos hasta ahora (el método createLogger) es el que vamos a definir como la función que se ha de llamar para poder obtener el logger con el que se va a trabajar:

export const createLogger = (): ILogger => {
  if (process.env.NODE_ENV === 'production') {
    return productionLogger()
  } else {
    return developmentLogger()
  }
}

En el cógido anterior podemos observar como dentro de la función createLogger al final estaremos llamando a la función productionLogger si el código se está ejecutando en producción o developmentLogger en el caso de que el código se esté ejecutando en desarrollo, funciones que nos devolverán el ILogger adecuado para cada uno estos entornos.

El código completo del fichero abstract-factory-function.ts con toda la implemetación funcional del patrón Abstract Factory es el que podemos ver a continuación:

// abstract-factory-function.ts
export interface ILogger {
  debug(message: string): void
  error(message: string): void
  info(message: string): void
  warn(message: string): void
}

const productionLogger = (): ILogger => ({
  debug(message: string): void {}
  error(message: string): void {
    console.error(message)
  }
  info(message: string): void {}
  warn(message: string): void {
    console.warn(message)
  }
})

const developmentLogger = (): ILogger => ({
  debug(message: string): void {
    console.debug(message)
  }
  error(message: string): void {
    console.error(message)
  }
  info(message: string): void {
    console.log(message)
  }
  warn(message: string): void {
    console.warn(message)
  }
})

export const createLogger = (): ILogger => {
  if (process.env.NODE_ENV === 'production') {
    return productionLogger()
  } else {
    return developmentLogger()
  }
}

Test de la factoría

Vamos a probar que la implementación funcional que hemos hecho de este patrón es la correcta y para ello crearemos el fichero abstract-factory-function-test.ts donde lo que vamos a hacer es importar la función que nos permitirá crear nuestros logger:

import { createLogger } from 'abstract-factory-function'

y simplemente pasaremos a usarla de forma similar a lo que hicimos cuando estábamos creando las pruebas para la implementación orientada a objetos dejándonos un código como el siguiente:


import { createLogger } from 'abstract-factory-function'

const logger = createLogger()

logger.debug('debug message')
logger.warn('warn message')
logger.info('info message')
logger.error('error message)

Si ahora nos vamos a la terminal del sistema y ejecutamos el código anterior con el comando:

$ npx ts-node abstract-factory-function-test.ts

podremos ver como en la terminal se nos estarán mostrando los cuatro mensajes puesto que al no especificar como entorno de ejecución producción se toma el logger por defecto donde sí que se escriben los cuatro mensajes tal y como podemos ver en la siguiente imagen:

¿Y qué sucederá en el caso de que estemos indicando que el entorno de ejecución sea producción? Es decir, ¿qué sucederá si ejecutamos?

$ NODE_ENV=production npx ts-node abstract-factory-function-test.ts

Pues como podemos intuir que los dos únicos mensajes que se mostrarán por la consola serán los que se corresponde con los logs de warning y error tal y como se espera de un logger para producción: