JavaScript

7 mai, 2020

Design Patterns com JavaScript & TypeScript: Padrões Criacionais

100 visualizações
Publicidade

No Artigo anterior conversamos um pouco sobre os padrões de projetos em JavaScript que são mais famosos. Nele também falamos que os padrões de projeto eram divididos em categorias. Vamos falar um pouco mais dessas categorias agora! Começando os padrões Criacionais.

Padrões criacionais

Os padrões de projeto criacionais são aqueles que definem como os objetos e classes são instanciados e criados. Provendo funcionalidades para abstrair muitas vezes a lógica completa de criação como também facilitar o reuso de muitos desses objetos.

Dentro dos padrões de projeto criacionais temos:

  • Abstract Factory
  • Builder
  • Factory Method
  • Singleton
  • Prototype

Já comentamos sobre o Factory Method e também sobre o Singleton. Vamos falar um pouco mais dos outros três!

Abstract Factory

O Abstract Factory é uma variação do padrão Factory Method que fortifica o princípio de Programe para interfaces e não para implementações como pode ser visto neste excelente artigo do Gustavo Bigardi.

Apesar de padrões criacionais estarem sendo substituídos aos poucos por injeção de dependências, ainda sim em JavaScript ou TypeScript eles ainda se fazer bastante presentes já que podemos criar outros tipos de objetos diferentes de classes, por exemplo, o trecho de código abaixo é um Factory Method utilizado em um dos meus

códigos de exemplo:
// https://github.com/khaosdoctor/event-sourcing-demo-app/blob/master/backend/src/presentation/routes/port/create.ts
import rescue from 'express-rescue'
import { validate } from '@expresso/expresso'
import { PortService } from '../../../services/PortService'
  
export function factory (service: PortService) {
  return [
    /**
     * Route validation
     * ==================
     */
    validate({
      type: 'object',
      properties: {
        name: { type: 'string' },
        dockedShips: {
          type: 'array',
          items: { type: 'string' },
          default: []
        }
      },
      required: ['name'],
      additionalProperties: false
    }),
    /**
     * Route handler
     * =============
     */
    rescue(async (req, res) => {
      const port = await service.create(req.body, req.onBehalfOf)
  
      res.status(201)
        .json(port.state)
    })
  ]
}

O problema que este padrão visa atacar é o instanciamento de um grupo de classes que possuem um ponto de implementação comum, porém implementações diferentes. No livro do GoF, este exemplo é dado através de uma série de produtos A e B:

Veja que temos uma interface AbstractFactory que é implementada por duas classes ConcreteFactory, cada uma das factories concretas instanciam produtos diferentes que implementam interfaces diferentes. Desta forma o cliente só interage com uma classe abstrata de AbstractFactory que define, para aquele cliente, qual é a classe concreta que será usada. Vamos ao exemplo:

Criamos as nossas factories:

interface AbstractFactory {
  createProductA(): AbstractProductA;
  
  createProductB(): AbstractProductB;
}
  
class ConcreteFactory1 implements AbstractFactory {
  public createProductA(): AbstractProductA {
    return new ConcreteProductA1();
  }
  
  public createProductB(): AbstractProductB {
    return new ConcreteProductB1();
  }
}
  
class ConcreteFactory2 implements AbstractFactory {

  public createProductA(): AbstractProductA {
    return new ConcreteProductA2();
  }
  
  public createProductB(): AbstractProductB {
    return new ConcreteProductB2();
  }
}

E nossas interfaces de produto:

interface AbstractProductA {
  usefulFunctionA(): string;
}
  
class ConcreteProductA1 implements AbstractProductA {
  public usefulFunctionA(): string {
    return "The result of the product A1.";
  }
}
  
class ConcreteProductA2 implements AbstractProductA {
  public usefulFunctionA(): string {
    return "The result of the product A2.";
  }
}
  
interface AbstractProductB {
  usefulFunctionB(): string;
  anotherUsefulFunctionB(collaborator: AbstractProductA): string;
}
  
class ConcreteProductB1 implements AbstractProductB {
  public usefulFunctionB(): string {
    return "The result of the product B1.";
  }
  
  public anotherUsefulFunctionB(collaborator: AbstractProductA): string {
    const result = collaborator.usefulFunctionA();
    return `The result of the B1 collaborating with the (${result})`;
  }
}
  
class ConcreteProductB2 implements AbstractProductB {
  public usefulFunctionB(): string {
    return "The result of the product B2.";
  }
  
  public anotherUsefulFunctionB(collaborator: AbstractProductA): string {

    const result = collaborator.usefulFunctionA();
    return `The result of the B2 collaborating with the (${result})`;
  }
}

E agora o nosso código do cliente:

function clientCode(factory: AbstractFactory) {
  const productA = factory.createProductA();
  const productB = factory.createProductB();
  
  console.log(productB.usefulFunctionB());
  console.log(productB.anotherUsefulFunctionB(productA));
}

Veja que o cliente recebe a factory como parâmetro, então podemos passar ou uma ConcreteFactory1 ou ConcreteFactory2 e cada uma irá instanciar um produto diferente:

console.log("Client: Testing client code with the first factory type...");
clientCode(new ConcreteFactory1());
  
console.log("");
  
console.log(
  "Client: Testing the same client code with the second factory type..."
);
clientCode(new ConcreteFactory2());

Builder

O padrão builder é um dos padrões mais interessantes de serem explicados porque ele pode ser transposto facilmente para um modelo físico que todos conhecem. O objetivo desse padrão é construir as diversas partes de uma classe ou objeto de forma independente utilizando uma interface única.

Por exemplo, imagine que o produto que queremos construir é um hamburger, ele é montado aos poucos, mas sabemos que algumas coisas são obrigatórias – como os pães – e outras não. O que podemos fazer é ir montando ele de acordo com o que queremos e, ao final da montagem, só precisamos receber o produto pronto. Uma característica dos Builders é que eles sempre vão possuir um método build() ou getProduct() que finaliza a construção do produto e o retorna, zerando o estado da classe:

interface IBurgerBuilder {

  addLetuce (): IBurgerBuilder
  addTomato (): IBurgerBuilder
  addCheese (): IBurgerBuilder
  addOnion (): IBurgerBuilder
  addPickles (): IBurgerBuilder
  addKetchup (): IBurgerBuilder
  addMustard (): IBurgerBuilder
  build (): Burger
}
  
class BurgerBuilder () {
  private burger: Burger
  
  constructor () {
    this.reset()
  }
  
  reset (): void {
    this.burger = new Burger()
  }
  
  addCheese () {
    this.burger.ingredients.push('Cheese')
    return this
  }
  
  addLetuce () {
    this.burger.ingredients.push('Letuce')
    return this
  }
  
  addTomato () {
    this.burger.ingredients.push('Tomato')
    return this
  }
  
  addOnion () {
    this.burger.ingredients.push('Onion')
    return this
  }
  
  addPickles () {
    this.burger.ingredients.push('Pickles')
    return this
  }
  
  addKetchup () {
    this.burger.ingredients.push('Ketchup')
    return this
  }
  

  addMustard () {
    this.burger.ingredients.push('Mustard')
    return this
  }
  
  build () {
    this.burger.ingredients.push('Top Bread')
    const burger = this.burger
    this.reset()
    return burger
  }
}
  
class Burger {
  public ingredients = ['Bottom bread', 'Meat']
  
  listIngredients (): void {
    console.log(`Your burger has: ${this.ingredients.join(', ')}`)
  }
}

Veja que temos uma classe BurgerBuilder que constrói nosso hamburger colocando seus ingredientes, mas o hamburger em si – na classe Burger – já começa com o pão de baixo e a carne, quando executamos o método build o construtor vai adicionar o pão de cima.

Eu utilizei return this para criar uma interface mais fluente ao construtor e poder encadear os métodos, mas é possível não retornar nada também e chamar cada método separadamente

Agora precisamos de alguém para manipular esse construtor, no nosso caso seria o atendente:

class Clerk {
  private builder: IBurgerBuilder
  
  setBuilder (builder: IBurgerBuilder): void {
    this.builder = builder
  }
  
  buildCheeseburger (): Burger {
    return this.builder
            .addCheese()
            .addKetchup()
            .addMustard()
            .build()
  }
  
  buildCompleteBurger (): Burger {
    return this.builder

            .addCheese()
            .addLetuce()
            .addPickles()
            .addTomato()
            .addOnion()
            .addKetchup()
            .addMustard()
            .build()
  }
}

Agora só precisamos do nosso código do cliente:

function clientCode (clerk: Clerk) {
  const builder = new BurgerBuilder()
  clerk.setBuilder(builder)
  
  console.log('Building Cheeseburger')
  clerk.buildCheeseburger().listIngredients()
  
  console.log('Building Complete burger')
  clerk.buildCompleteBurger().listIngredients()
  
  console.log('Building custom burger')
  builder.addOnion().addKetchup().addPickles().build().listIngredients()
}
  
const clerk = new Clerk()
clientCode(clerk)

Veja que podemos construir utilizando o Clerk mas também podemos ter a nossa própria construção customizada do hamburger utilizando o Builder diretamente. Este padrão de projetos é muito utilizado em Query Builders de bancos de dados, onde temos que adicionar pequenas partes a uma string completa através de uma interface fluente.

Prototype

O padrão prototype é a base de como as cadeias de herança no JavaScript funciona. Este padrão ataca principalmente o uso de recursos, permitindo que escondamos a complexidade de criar uma nova instancia a partir do cliente através da clonagem de um objeto já existente e modificação de seus parâmetros. O objeto clonado age como um protótipo para o novo objeto que será instanciado.

Este padrão geralmente utilizado quando uma instancia de um objeto demora muito tempo para ser criado ou então consome muitos recursos, algumas bibliotecas de bancos de dados, por exemplo, usam este padrão para criar diversas instancias de conexões aos bancos, uma vez que o processo de conexão pode ser lento e consumir muitos recursos.

Tudo começa com uma classe protótipo:

class Prototype {
  public primitive: any;
  public component: object;
  public circularReference: ComponentWithBackReference;
  
  public clone(): this {
    const clone = Object.create(this);
  
    clone.component = Object.create(this.component);
  
    clone.circularReference = {
      ...this.circularReference,
      prototype: { ...this }
    };
  
    return clone;
  }
}
  
class ComponentWithBackReference {
  public prototype;
  
  constructor(prototype: Prototype) {
    this.prototype = prototype;
  }
}

Veja que o protótipo possui um primitivo e um componente, que estão aqui somente para exemplificar propriedades simples e complexas da classe, além disso ele também possui uma referência circular, que é um componente que tem uma referência a outro objeto, para exemplificar a passagem de parâmetros.

Veja que, no protótipo, o objeto passado é clonado, recebendo this como seu protótipo, o mesmo é feito com o component para que ele receba a instancia do protótipo da classe e, por fim, o componente com uma referência circular é clonado para dentro de outro objeto e sua chave prototype, que define o protótipo da classe, depois retornamos o clone, veja como fica o nosso código do cliente:

function clientCode() {
  const p1 = new Prototype();
  p1.primitive = 245;
  p1.component = new Date();
  p1.circularReference = new ComponentWithBackReference(p1);
  
  const p2 = p1.clone();
  if (p1.primitive === p2.primitive) {
    console.log(
      "Primitive field values have been carried over to a clone. Yay!"
    );

  } else {
    console.log("Primitive field values have not been copied. Booo!");
  }
  if (p1.component === p2.component) {
    console.log("Simple component has not been cloned. Booo!");
  } else {
    console.log("Simple component has been cloned. Yay!");
  }
  
  if (p1.circularReference === p2.circularReference) {
    console.log("Component with back reference has not been cloned. Booo!");
  } else {
    console.log("Component with back reference has been cloned. Yay!");
  }
  
  if (p1.circularReference.prototype === p2.circularReference.prototype) {
    console.log(
      "Component with back reference is linked to original object. Booo!"
    );
  } else {
    console.log("Component with back reference is linked to the clone. Yay!");
  }
}
  
clientCode();

Conclusão

Os padrões criacionais são simples porém poderosos, fique ligado nos próximos artigos onde vamos falar um pouco mais de alguns outros padrões para diversas outras funcionalidades!