JavaScript

4 ago, 2020

Design Patterns com JavaScript & TypeScript: Padrões Comportamentais

Publicidade

No último artigo falamos sobre os padrões estruturais em JavaScript que são mais famosos. Agora, vamos continuar explorando os padrões comportamentais para fechar a nossa série.

Padrões de projeto comportamentais são os mais famosos pois se preocupam mais com a implementação do algoritmo e também com a separação de responsabilidades entre os objetos.

Muitos padrões de projeto comportamentais já são amplamente utilizados em frameworks como o Angular, que utilizar o padrão observer extensivamente.

Vamos começar falando dele!

Observer

O padrão observer é um padrão comportamental que permite a definição de um mecanismo de assinatura em eventos para notificar outros objetos sobre os eventos que ocorreram no objeto que estão observando.

Pense que o observer pode ser muito bem explicado através de algo muito comum: avisos de disponibilidade.

Imagine que você, como cliente de uma loja, quer comprar um produto que está indisponível. Você pode ir todos os dias até a loja para ver se o produto está disponível, porém a maioria dessas idas será inútil, pois o produto só vai estar lá uma vez.

Outra opção seria a loja mandar um e-mail para todos os clientes quando qualquer produto chegasse em estoque, o que seria péssimo do ponto de vista de usabilidade porque seria considerado um spam.

Então, para solucionar o problema, a loja mantém um cadastro de clientes interessados em produtos específicos, cada produto tem uma lista de clientes interessados em saber quando ele está disponível. Assim que o produto chegar na loja, a mesma envia um email a todos estes clientes.

Poupando o tempo de ir até a loja para todo mundo e também melhorando a qualidade de vida dos demais por não enviar spams.

Funcionamento

O objeto notificador, chamado de publisher possui uma lista de notificados (subscribers) e dois métodos, um para adicionar um novo subscriber e outro para remover algum já adicionado.

Porém acoplar o notificador a todos os inscritos seria criar um acoplamento de funcionalidade muito grande. Porque você precisaria implementar todas as formas de notificar diferentes classes de inscritos, por exemplo, alguns clientes querem receber um email, outros um SMS. Por isso é importante que todos os observadores (subscribers) sigam uma mesma interface.

Desta forma, quando o objeto notificador chamar a sua função notifySubscribers, ele fará um loop
por todos os inscritos, chamando o método update() de cada um deles, porque todos seguem e mesma interface:

Em uma sequência, isto é o que acontece com o padrão:

1. O notificador (publisher) emite eventos de interesse para outros objetos. Estes eventos podem ocorrer durante uma alteração de estado ou durante outra modificação
– 1. É importante que o publisher possua um método para adicionar e remover subscribers
– 2. É importante que o publisher tenha um método para notificar os subscribers dos eventos emitidos

2. Quando um novo evento acontecer, o publisher itera sobre a lista de subscribers chamando o método responsável por atualizar seu contexto

3. A interface do subscriber define um método update que será o método responsável por fazer a notificação para o subscriber

4. Todo o subscriber concreto implementa a interface subscriber dessa forma desacoplamos o publisher de todos os subscribers

5. O client cria o publisher e adiciona os subscribers a lista de objetos

Código

// Interface que todo o publisher deve seguir
interface Publisher {
    addSub(subscriber: Subscriber): void // Adiciona um subscriber
    removeSub(subscriber: Subscriber): void // Remove um subscriber
    notify(): void // Notifica todos os subscribers de um evento
}
  
// Um publisher que notifica os seus observadores sobre mudanças de estado
class ConcretePublisher implements Publisher {
    public state: number
    private subscribers: Subscriber[] = []
  
    public addSub (subscriber: Subscriber): void {
        const subExists = this.subscribers.include(subscriber)
        if (!subExists) this.subscribers.push(subscriber)
        console.log('Subscriber adicionado com sucesso')
    }
  
    public removeSub (subscriber: Subscriber): void {
        const index = this.subscribers.indexOf(subscriber)
        if (index === -1) return console.log('Subscriber não existe na lista')
        this.subscribers.splice(index, 1) // remove o subscriber
        console.log('Subscriber removido')
    }
  
    public notify (): void {
        console.log('Notificando observadores')
        for (const subscriber of this.subscribers) { subscriber.update(this) }
    }

      /**
    * Geralmente os publishers possuem um estado e uma regra de negócio
    * esta regra de negócio que emite a notificação quando seu estado é alterado
    */
    public logicaDeNegocio (): void {
        console.log('Publisher: Estou realizando uma regra de negócio')
        this.state = Math.floor(Math.random() * (10+1))
        console.log(`Publisher: Meu estado mudou para ${this.state}`)
        this.notify()
    }
}
  
interface Subscriber {
  update (publisher: Publisher): void
}
  
class SubscriberA implements Subscriber {
  public update (publisher: Publisher): void {
    if (publisher instanceof ConcretePublisher && publisher.state < 3) console.log('SubscriberA reagiu ao evento emitido')
  }
}
  
class SubscriberB implements Subscriber {
  public update (publisher: Publisher): void {
    if (publisher instanceof ConcretePublisher && (publisher.state === 0 || publisher.state >= 2)) console.log('SubscriberB reagiu ao evento emitido')
  }
}
  
// Código do client
const publisher = new ConcretePublisher()
  
const observer1 = new SubscriberA()
publisher.addSub(observer1)
  
const observer2 = new SubscriberB()
publisher.addSub(observer2)
  
publisher.logicaDeNegocio()
publisher.logicaDeNegocio()
  
publisher.removeSub(observer2)
  
publisher.logicaDeNegocio()

Veja mais aqui

Strategy

O strategy é um padrão interessante, pois ele define uma familia de algoritmos e coloca cada um em uma classe separada, de forma que você possa utilizá-los de forma intercambiável para ações diferentes.

Um caso simples de uso seria criar um publicador para redes sociais. Imagine que você quer postar todos os artigos do seu blog em todas as suas redes sociais assim que eles são publicados.

O problema é que cada API de cada rede social funciona de maneira diferente e você precisa de chamadas diferentes para cada tipo de post que você vai publicar… Se você adicionar as chamadas para todas as redes sociais em uma única função, seu código ficará muito cheio e acoplado, então o ideal seria criar uma classe para cada rede social, onde cada uma terá a implementação nativa daquela rede.

Depois, em uma classe central publicadora, você poderá receber como o parâmetro de um método uma lista de redes sociais e publicar individualmente nelas.

Um outro exemplo bacana deste padrão aplicado é o da biblioteca passport. Que permite que você realize autenticações diretas em diversos serviços, apenas alterando a estratégia de autenticação em cada um.

Funcionamento

1. O contexto mantém a referência para uma das estratégias, é importante ressaltar que ela só se comunica com essa estratégia através da sua interface.

2. A interface da estratégia é comum a todas as estratégias concretas. Geralmente possui apenas um único método exposto que a executa.

3. A estratégia concreta implementa a interface

4. O client em um contexto chama o método de execução definindo uma estratégia. O contexto não sabe qual será a estratégia utilizada, somente que ela possui um método de execução

5. O client cria os objetos específicos e passa para o contexto de execução, que expõe um setter, permitindo que a estratégia seja trocada

Código

class Context {
  constructor(private strategy: Strategy) { }
  
  /**
   * setStrategy
   */

  public setStrategy(strategy: Strategy) {
    this.strategy = strategy
  }
  
  public async logicaDeNegocio (): Promise<void> {
    console.log(`Contexto: Fazendo publicação utilizando uma estratégia`)
    const resultado = await this.strategy.run({text: 'Minha publicação', url: 'umaurl.com'})
    console.log(resultado)
  }
}
  
type PostData = {text: string, url: string}
interface Strategy {
  run (data: PostData): Promise<string>
}
  
class EstrategiaFacebook implements Strategy {
  public async run (data: PostData): Promise<string> {
    return Promise.resolve(`Publicando post "${data.text}" com URL "${data.url}" no Facebook`)
  }
}
  
class EstrategiaTwitter implements Strategy {
  public async run (data: PostData): Promise<string> {
    return Promise.resolve(`Publicando post "${data.text}" com URL "${data.url}" no Twitter`)
  }
}
  
// Código do client
(async () => {
  const contexto = new Context(new EstrategiaFacebook)
  console.log('Publicando no facebook')
  await contexto.logicaDeNegocio()
  
  console.log('Trocando a estratégia')
  contexto.setStrategy(new EstrategiaTwitter)
  console.log('Publicando no Twitter')
  await contexto.logicaDeNegocio()
})()

Veja mais aqui

Chain of Responsibility

O chain of responsibility é provavelmente o padrão de projeto mais conhecido de todos.
Ele é um padrão comportamental que permite que você passe uma série de requisições ao longo de uma cadeia de handlers.

Cada handler, depois de receber a requisição, decide se vai ou não processá-la. Se não for processar, apenas passa a requisição para o próximo handler na cadeia.

Parece familiar? Isto porque quem trabalha com Node.js já deve ter visto este padrão com o nome de middlewares na biblioteca Express.

Este padrão se tornou muito famoso no mundo web, pois é muito versátil ao se passar uma requisição HTTP por vários tratamentos antes de dar uma resposta.

Um exemplo real, que é muito utilizado por quem desenvolve aplicações com Express é a própria autenticação. Quando um usuário acessa uma rota autenticada, a requisição é primeiro passada a um middleware de autenticação que verifica se o usuário possui um token e se o mesmo é válido.

Se sim, a request segue a diante, se não, o middleware pára a request e devolve um Código HTTP 403 ou 401.

Funcionamento

1. O handler segue uma interface que possui um método para definir o próximo handler da cadeia e também um método para executar a lógica dentro do mesmo

2. O BaseHandler é uma classe opcional onde você pode criar um boilerplate contendo códigos que são comuns a todos os handlers. Geralmente essa classe tem a definição para o próximo handler ou então define uma forma padrão de gerenciar as requests

3. Os handlers concretos implementam a interface ou estendem a classe base, implementando o código real que vai lidar com as requests. Quando uma request é recebida, cada um dos handlers deve decidir se vai ou não processá-la. Na maioria das vezes, um handler é auto-contido e imutável, recebendo os dados somente uma vez no construtor

4. O client compõe a cadeia de responsabilidade passando todos os handlers necessários uma única vez no início, ou então compondo-a dinâmicamente através da lógica da aplicação. Note que uma request pode ser passara para qualquer handler, não necessariamente precisa começar do primeiro.

Código

interface Handler {
  setNext (handler: Handler): Handler
  handle (request: string): string
}
  
// Comportamento padrão implementado em uma classe base abstrata
abstract class HandlerAbstrato implements Handler {
  private next: Handler
  
  public setNext (handler: Handler): Handler {
    this.next = handler
    // Retornar um handler aqui nos permite criar uma cadeia interessante
    // Chamando de forma fluente como handler.setNext(h).setNext(h1)...
    return handler
  }
  
  // Permite que executemos a lógica de passar para o próximo
  public handle (request: string): string {
    if (this.next) return this.next.handle(request)
    return null
  }
}
  
class HandlerAzul extends HandlerAbstrato {
  public Handle (request: string): string {
    if (request === 'Azul') return `HandlerAzul: Estou processando a cor Azul`
    return super.handle(request)
  }
}
  
class HandlerVerde extends HandlerAbstrato {
  public Handle (request: string): string {
    if (request === 'Verde') return `HandlerVerde: Estou processando a cor Verde`
    return super.handle(request)
  }
}
  
class HandlerVermelho extends HandlerAbstrato {
  public Handle (request: string): string {
    if (request === 'Vermelho') return `HandlerVermelho: Estou processando a cor Vermelha`
    return super.handle(request)
  }
}

  
/**
 * O código do cliente geralmente pensa que só existe um único handler
 * Ele nem tem conhecimento que é uma cadeia
 */
function client (handler: Handler) {
  const cores = ['Verde', 'Azul', 'Amarelo', 'Vermelho']
  
  for (const cor of cores) {
    console.log(`Quem quer pegar a cor ${cor}`)
  
    const resultado = handler.handle(cor)
    if (resultado) return console.log(resultado)
    return console.log(`A cor ${cor} não foi processada`)
  }
}
  
const azul = new HandlerAzul
const vermelho = new HandlerVermelho
const verde = new HandlerVerde
  
azul.setNext(verde).setNext(vermelho).setNext(verde)
  
console.log(`Cadeia: Azul > Vermelho > Verde`)
client(azul)
  
console.log(`Começando do segundo: Vermelho > Verde`)
client(vermelho)

Veja mais aqui

Conclusão

Finalizamos aqui o que temos para falar sobre padrões de projeto no geral! 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.

Não deixe de acompanhar mais do meu conteúdo no meu blog e se inscreva na newsletter para receber notícias semanais!

Até mais!