JavaScript

2 jul, 2020

Design Patterns com JavaScript & TypeScript: Padrões Estruturais

100 visualizações
Publicidade

No artigo anterior conversamos um pouco sobre os padrões de projetos criacionais em JavaScript que são mais
famosos. Agora, vamos continuar explorando estes padrões através dos padrões estruturais.

Padrões de projeto estruturais são padrões que facilitam o desenho de aplicações através da identificação de
relacionamentos entre as entidades. Em resumo, um padrão estrutural explica como construir objetos compondo-os em estruturas maiores, mas mantendo ainda a eficiência e a flexibilidade. Vamos abordar alguns padrões mais comuns (e outros nem tão comuns assim).

Adapter

O padrão adapter é um dos padrões estruturais mais famosos. Ele também é conhecido como Wrapper. O adapter é um padrão estrutural que permite que objetos com interfaces diferentes colaborem para um mesmo fim. Por exemplo, quando viajamos e temos tomadas de diferentes tipos. Para que possamos ligar os nossos eletrônicos nestas saídas, precisamos de um adaptador, e é justamente isso que o adapter faz. Ele permite que entidades que tenham interfaces incompatíveis possam se comunicar de forma simples e rápida.

Os maiores exemplos de uso deste padrão é quando temos que buscar fontes de dados diferentes e formatar estes
dados de forma que atendam um sistema único, ou então quando estamos integrando bancos de dados, ou até mesmo para integrar sistemas legados com sistemas mais modernos através de um adaptador de sistema. Os adaptadores são as “colas” que permitem que possamos integrar sistemas muito diferentes de forma rápida.

Funcionamento

1. Temos um client que será uma classe que contém a lógica de negócio atual

2. Depois temos uma interface que será a interface deste cliente, um protocolo que todas as outras classes que
quiserem colaborar ou utilizar a classe Client precisam ter

3. Temos então um serviço externo, geralmente um serviço legado ou algo incompatível com o Client.

4. A parte mais importante é o Adapter. Esta classe recebe um parâmetro no formato da interface do cliente e
converte este dado para a interface do Service, desta forma o cliente pode usar o Service sem estar totalmente
acoplado com ele.

Código

/**
 * Interface de domínio utilizada pelo cliente
 */
class Usable {
    public request(): string {
        return 'Comportamento padrão';
    }
}
  
/**
 * Código legado ou incompatível com o cliente
 */
class Legacy {
    public specificRequest(): string {
        return 'Q29tcG9ydGFtZW50byBFc3BlY8OtZmljbw==';
    }

}
  
/**
 * O adapter estende o comportamento padrão
 * Portanto ele pode chamar todos os métodos
 * No entanto, ele recebe a entidade adaptável e faz a conversão
 */
class Adapter extends Usable {
    private legacy: Legacy;
  
    constructor(legacy: Legacy) {
        super();
        this.legacy = legacy;
    }
  
    public request(): string {
        const result = Buffer.from(this.legacy.specificRequest(), 'base64').toString('utf-8')
        return `Adapter: (TRADUZIDO) ${result}`;
    }
}
  
/**
 * O Client suporta tudo que segue a interface Usable
 */
function clientCode(usable: Usable) {
    console.log(usable.request());
}
  
console.log('Client: Consigo trabalhar normalmente com qualquer objeto Usable:');
const usable = new Usable();
clientCode(usable);
  
const legacy = new Legacy();
console.log('Client: O legado tem uma interface estranha que não entendo:');
console.log(`Legacy: ${legacy.specificRequest()}`);
  
console.log('Client: Mas consigo entender através do adapter:');
const adapter = new Adapter(legacy);
clientCode(adapter);

Veja mais aqui

Facade

O padrão Facade (lê-se Façade) é um padrão que visa abstrair uma interface mais complexa de um objeto através de uma fachada mais simples para o usuário. Desta

forma a complexidade estrutural geral não é comprometida e, ao mesmo tempo, outras pessoas podem utilizar as bibliotecas externas sem se preocupar com a complexidade interna.

Este padrão vem para resolver um problema que é inerente à criação de softwares: A complexidade com o tempo. Assim como uma máquina que se desgasta com o tempo, um software vai ficando cada vez mais complexo conforme o tempo passa, esta complexidade pode se acumular em grandes sistemas integrados que fazem com que a manutenção e integração entre eles seja quase impossível.

Por isto, padrões como o Facade podem ser muito úteis para quebrar esta complexidade e integrar serviços que antes seriam muito complexos para serem chamadas individualmente.

Funcionamento

1. Iniciamos com um cliente que tenta acessar um método dentro de uma estrutura complexa

2. Uma Facade é criada para capturar a ordem de chamada e o instanciamento de todas as entidades complexas
dentro do emaranhado do sistema.

3. O Client pode chamar a Facade para simplificar a funcionalidade do sistema

4. Podemos também criar uma Facade adicional que implementa a Facade original para evitar poluir uma única
entidade com métodos não relacionados

Código

class Facade {
  protected system1: System1
  protected system2: System2
  
  constructor (system1: System1 = null, system2: System2 = null) {
    this.system1 = system1 || new System1()
    this.system2 = system2 || new System2()
  }
  
  /**
   * Os métodos em uma Facade são só atalhos para vários métodos dentro de vários sistemas
  */
  public operation (): string{
    let result = 'Facade inicializa os sistemas:\n'
    result += this.system1.operation1()
    result += this.system2.operation2()
    result += 'Facade realiza as ações nos sistemas\n'
    result += this.system1.operationX()
    result += this.system2.operationY()
  }
}
  
class System1 {
  public operation1 (): string {
    return 'System1: Pronto!\n'
  }
  
  public operationX (): string {
    return 'System1: Executando ação X\n'
  }
}
  
class System2 {
  public operation2 (): string {
    return 'System2: Pronto!\n'
  }
  
  public operationY (): string {
    return 'System2: Executando ação X\n'
  }
}
  
function client (facade: Facade) => console.log(facade.operation())
  
const system1 = new System1()
const system2 = new System2()
const facade = new Facade(system1, system2)
client(facade)

Veja mais aqui

Proxy

O proxy é um padrão que permite que você substitua um objeto por outro. O proxy tem acesso ao objeto original, permitindo que você execute uma ação antes ou depois de a solicitação passar para o objeto original novamente.

Este padrão é muito utilizado quando temos que lidar com classes ou objetos externos que exigem algum tipo de modificador antes ou depois de ser utilizado. Um grande exemplo de proxy são os próprios proxies do JavaScript ou até mesmo os proxies das Azure Functions que permitem que você execute uma função antes ou depois de o objeto original receber a operação.

Funcionamento

1. A interface do Service é a responsável por declarar tudo que o Service pode fazer. O proxy precisa seguir essa
mesma interface para que ele possa se passar por aquele objeto.

2. O Service é o que queremos imitar

3. A classe do Proxy tem uma referência ao Service original, para que ele possa realizar as ações necessárias e depois
enviar para o objeto original. Em geral, os proxies gerenciam todo o ciclo de vida de seus objetos internos.

Código

// Interface do objeto que vamos copiar
interface Service {
  request(): void
}
  
// O serviço real que o proxy vai substituir
class RealService implements Service {
  public request(): void {
    console.log('RealService: Executando request')

  }
}
  
// O Proxy tem uma interface idêntica ao serviço
class Proxy implements Service {
  private realService: RealService
  
  constructor (realService: RealService) {
    this.realService = realService
  }
  
  public request (): void {
    this.logAccess()
    this.realService.request()
  }
  
  private logAccess (): void {
    console.log('Proxy: Logando a request')
  }
}
  
/**
 * O client deve ser capaz de trabalhar com ambos os objetos
*/
function clientCode (service: Service) => service.request()
  
// Executando com o objeto real
const realService = new RealService()
clientCode(realService) // Não tem o log
  
const proxy = new Proxy(realService)
clientCode(proxy) // Com o log

Perceba que estamos enviando o objeto real como um parâmetro para o proxy, os usos mais comuns de um proxy são lazy loading, controle de acesso, logging e etc. Dessa forma, podemos reescrever o proxy assim:

import { RealService } from './RealService'
  
class Proxy implements Service {
  private realService: RealService
  
  constructor () {
    // Lógica de inicialização direta no construtor
    this.realService = new RealService()
  }
  
  public request (): void {
    this.logAccess()
    this.realService.request()
  }

  
  private logAccess (): void {
    console.log('Proxy: Logando a request')
  }
}

Para que o proxy tenha total controle do objeto envelopado, ou então até mesmo executar o instanciamento somente na chamada da função:

import { RealService } from './RealService'
  
class Proxy implements Service {
  
  public request (): void {
    realService = new RealService()
    this.logAccess()
    realService.request()
  }
  
  private logAccess (): void {
    console.log('Proxy: Logando a request')
  }
}

O contra destas duas abordagens é que perdemos a capacidade de um teste direto, precisando realizar uma injeção de memória para que possamos mockar o objeto e testá-lo.

Veja mais aqui

Flyweight

O último padrão que vamos comentar é o FlyWeight, ou, literalmente, peso mosca. Como o próprio nome já diz, este padrão tem como foco a criação de objetos mais leves e que ocupam menos memória. Ele faz isso por permitir que você utilize mais de um objeto para armazenar um estado compartilhado ao invés de guardar o estado todo em um único objeto.

Um exemplo interessante é o compartilhamento de propriedades em jogos. Por exemplo, quando estamos jogando, partículas e projéteis do jogo geralmente possuem algumas propriedades idênticas, como as cores e o sprite. Estas propriedades podem ser compartilhadas ao invés de serem copiadas para todas as instâncias das partículas.

O padrão flyweight cria o conceito de estados externos e estados internos. Os estados externos são todos os dados que podem ser alterados entre uma instância e outra, enquanto o estado interno são os dados imutáveis pertencentes a todas as classes do grupo.

Na partícula do jogo, o estado externo serão as propriedades que se alteram de tempos em tempos, enquanto o estado interno (ou intrínseco) é tudo aquilo que elas compartilham entre si.

Importante: lembre-se de que este padrão não é nada mais do que uma otimização, ou seja, não saia aplicando ele sem antes ter certeza de que o problema que você está enfrentando é, de fato, um problema de falta de memória por repetição.

Funcionamento

1. A classe Flyweight tem o estado repetitivo, ou seja, o estado interno da classe que será otimizada

2. A classe Context contém o contexto, que é o estado externo, ou seja, as partes da classe que se alteram
constantemente. Quando esta classe é ligada a uma classe Flyweight ela se torna o objeto original com o estado
completo.

3. Veja que é possível utilizar o parâmetro flyweight da classe Context de duas formas.

  1. Podemos chamar um método dentro do contexto, mas manter toda a lógica e comportamento dentro da
    classe Flyweight original, o que é recomendado quando estes métodos forem repetitivos, assim não
    alocamos memória desnecessária. Mas teremos que enviar o estado externo para todas as operações que
    enviarmos.
  2. Podemos usar o flyweight simplesmente como um repositório de dados para buscar os dados do estado
    interno

4. O Client é o responsável por calcular ou armazenar o estado externo dos Context, ou seja, o objeto que engloba
tudo será o responsável por guardar o estado global.

Código

/**
 * Classe que guarda o estado interno e realiza operações de comportamento
 * recebendo o estado externo, ou o contexto.
*/
class Flyweight {

  private sharedState: any;
  
  constructor(sharedState: any) {
      this.sharedState = sharedState;
  }
  
  public operation(uniqueState): void {
      const s = JSON.stringify(this.sharedState);
      const u = JSON.stringify(uniqueState);
      console.log(`Flyweight: Mostrando estados compartilhado (${s}) e único (${u}).`);
  }
}
  
/**
 * Temos que ter uma factory para gerenciar os objetos a fim de que
 * eles não tenham instanciamentos múltiplos e também gerencie todo o fluxo
 * de vida de uma classe compartilhada
*/
class FlyweightFactory {
  private flyweights: {[key: string]: Flyweight} = <any>{};
  
  constructor(initialFlyweights: string[][]) {
      for (const state of initialFlyweights) {
          this.flyweights[this.getKey(state)] = new Flyweight(state);
      }
  }
  
  /**
   * Cria um hash de string para cada um dos flyweights
   */
  private getKey(state: string[]): string {
    return Buffer.from(state.join('_')).toString('base64')
  }
  
  /**
   * Retorna um flyweight existente para aquele estado ou cria outro.
   */
  public getFlyweight(sharedState: string[]): Flyweight {
    const key = this.getKey(sharedState);
  
    if (!(key in this.flyweights)) {
      console.log('FlyweightFactory: Criando um novo.');
      this.flyweights[key] = new Flyweight(sharedState);
    } else {
      console.log('FlyweightFactory: Reusando flyweight existente.');
    }
  
    return this.flyweights[key];
  }

  
  public listFlyweights(): void {
    const count = Object.keys(this.flyweights).length;
    console.log(`\nFlyweightFactory: Contém ${count} flyweights`);
    for (const key in this.flyweights) {
      console.log(key);
    }
  }
}
  
/**
 * O client geralmente cria uma série de flyweights no início da aplicação
 */
const factory = new FlyweightFactory([
    ['Chevrolet', 'Camaro2018', 'pink'],
    ['Mercedes Benz', 'C300', 'black'],
    ['Mercedes Benz', 'C500', 'red'],
    ['BMW', 'M5', 'red'],
    ['BMW', 'X6', 'white']
]);
factory.listFlyweights();
  
function addCarToPoliceDatabase(
    ff: FlyweightFactory,
    plates: string,
    owner: string,
    brand: string,
    model: string,
    color: string,
) {
  console.log('\nClient: Adicionando ao banco.');
  const flyweight = ff.getFlyweight([brand, model, color]);
  
  // O client calcula ou armazena o estado externo e passa para o método
  flyweight.operation([plates, owner]);
}
  
addCarToPoliceDatabase(factory, 'CL234IR', 'James Doe', 'BMW', 'M5', 'red');
addCarToPoliceDatabase(factory, 'CL234IR', 'James Doe', 'BMW', 'X1', 'red');
  
factory.listFlyweights();

Algo importante de notar neste padrão é que os estados internos não possuem nenhum tipo de setter, ou seja, eles são imutáveis.

Note também que estamos usando outro pattern que aprendemos no artigo anterior,

o Factory pattern. Isso mostra como os padrões de projeto podem ser extensíveis e como eles podem ser úteis até mesmo uns com os outros.

Veja mais aqui.

Conclusão

Finalizamos aqui o que temos para falar sobre padrões estruturais! Se você quer saber mais sobre padrões estruturais, veja este conteúdo que me deu uma excelente ideia e providenciou vários exemplos úteis para utilizarmos no aprendizado!

Além disso, este artigo deixa mais claro que podemos utilizar vários destes padrões em outros padrões compostos, como os padrões de design de microsserviços, desta forma podemos estender ainda mais nossas aplicações e desacoplar de nossas regras de negócio.