Architectural Patterns: Pipes and Filters

Posted on março 22, 2011 by elemarjr

1


Olá pessoal, tudo certo?

Hoje volto a abordar patterns arquiteturais. Meu primeiro post, sobre o Microkernel pattern, foi um pouco denso. Hoje, quero ver se consigo “aliviar o tom”.

O tema de hoje é um outro architeural pattern bacana: Pipes and Filters. Espero que gostem.

Sem mais delongas…

Antes de começar, mais uma palavrinha sobre patterns

Como bem disse Christopher Alexander:

Each pattern describes a problem wich occurs over and over again in our environment, and then describes  the core of the solution to that problem, in such way that you can use this solution a million times over, without ever doing it the same way twice.

Essa frase, escrita para um arquiteto (não de software), continua sendo atual e um alento para quem busca desenvolver software com mais qualidade. Entretanto, por favor, não é uma obrigação utilizar patterns em todas as soluções. É verdade que eles podem tornar problemas complexos mais simples, mas o oposto é igualmente verdadeiro.

Isso é verdade para patterns arquiteturais, de design e idioms.

Programas que transformam dados

Muitos programas (ou subsistemas) têm a atribuição de receber um conjunto de dados e gerar uma saída relacionada. Apenas para citar alguns exemplos óbvios:

  • Compiladores recebem arquivos com código-fonte (texto) e geram programas executáveis (binários);
  • Consultas de preços recebem listas de itens e devolvem essas listas de itens com preços;
  • Compactadores recebem conjuntos de arquivos e geram saídas compactadas;
  • Obfuscators recebem programas “compilados” e devolvem versões “protegidas”;
  • Conversor DOC para PDF – recebem arquivos DOC e devolvem arquivos PDF

Em todos os casos, temos como pontos comuns:

  • um conjunto de dados de entrada;
  • um “processador”;
  • um conjunto de dados de saída.

Além disso, é comum desenvolver uma interface com usuário para o processador. Em função disso, visando garantir isolamento e separação de responsabilidades, adiciona-se um ponto de entrada e outro de saída de dados.

image

Perceba que, em muitos casos, a unidade de processamento, entrada e saída de dados devem estar em “tiers” distintas. Considere, por exemplo, um serviço on-line para conversão de documentos.

Entre os tipos de transformações comuns, podemos destacar:

  • enriquecimento de dados por adição – pela adição de informações resultantes de algum processamento (como no exemplo da consuta de preços);
  • refinamento de dados por concentração – pela aplicação de um formato de dados que permita representação de conjuntos (unificação de lista de produtos em linhas de pedido, no exemplo da consulta de preços);
  • refinamento de dados por extração – como na conversão de um formato de imagem digital compactado em um mapa de bits;
  • modificação de formato (simples) – mudança direta do formato de representação de dados

Dividir para conquistar

Implementar a unidade de processamento em um único componente não é uma boa idéia. Geralmente, uma tarefa global de transformação pode ser decomposta naturalmente em algumas etapas distintas e há requisitos “instáveis” para cada uma dessas pequenas etapas.

Peguemos um exemplo concreto para dar uma idéia mais clara: um compilador para a plataforma .NET. Nesse exemplo temos:

  • análise léxica – interpretar um arquivo texto e gerar uma lista de símbolos [tokens];
  • análise sintática – interpretar a lista de tokens resultante da análise léxica gerando uma árvore sintática de expressões;
  • análise semântica – gerando uma árvore sintática de expressões aumentada;
  • geração de código intermediário – escrevendo código IL;
  • otimização – refinamento do IL gerado;
  • execução – convertendo o IL otimizado em código de máquina.

Outro exemplo concreto (mais tangível para a maioria dos desenvolvedores): um gerador de pedidos a partir de uma lista de itens:

  • agrupamento de itens – pegar uma lista de itens e gerar otimizações por quantidade (substituindo itens por conjuntos)
  • agregação de preços base – pega a lista de itens de compra e adiciona preços de tabela;
  • geração de preços de custo e venda – pegando a lista de itens de compra com preço e aplicando margens de venda;
  • cálculo de impostos;
  • renderização para html (ou outro formato).

Decompor a transformação de uma transformação em diversos passos intermediários, transforma a abstração apresentada inicialmente para a que indico na sequência:

image

A principal vantagem dessa abordagem é a “quebra” do problema em partes menores. Cada unidade de processamento executa uma tarefa distinda e isolada.

Repare que apenas uma unidade de processamento recebe os dados “limpos” da entrada. Essa unidade faz sua parte do processo e entrega o resultado em seu ponto de saída. Uma próxima unidade de processamento pega esses dados, faz sua parte, e libera para a saída, e assim sucessivamente. No fim, temos os dados resultantes.

Em sua forma mais simples, todo esse processamento ocorre dentro de um único programa. Considere:


              
public Stream Compile(Stream input)
{
    var tokens = PerformLexicalAnalysis(input);
    var sinTree = PerformSyntaticAnalysis(tokens);
    var semTree = PerformSemanticAnalysis(sinTree);
    var ilProg = GenerateILProgram(semTree);
    var optILProg = OptimizeILProgram(ilProg);

    return optILProg;
}

            

Em abordagens mais refinidas, podemos pensar cada etapa como um “programa” separado.

Outros exemplos dessa abordagem são consultas LINQ e instruções Powershell.

Deixe o usuário compor o fluxo de dados/operações

Sendo criativo, podemos permitir que nossos usários configure o fluxo de execução e de dados ajustando as etapas que devem ser pecorridas.

Isso é verdade para o Powershell (assim como para o Shell do Unix-Like). Isso é verdade para ambientes extremamente criativos como o . Aliás, se você é fera em criação de ambientes interativos, sinta-se desafiado a compor um ambiente como este:

image

O pipes da yahoo aborda com genialidade a possibilidade de compor um ambiente, com diversas unidades de processamento.

Chegando a Pipers and Filters

Reconhecida a maior facilidade de implementar aplicações que transformam dados como sequências de passos. Há uma oportunidade adicional de refinamento: implantar o pattern arquitetural “Pipes and Filters”.

Esse pattern recomenda a separação de uma tarefa em diversas etapas menores (tal qual recomendamos até aqui). Essas etapas são conectadas pelo fluxo de dados da aplicação sendo que a saída de uma etapa serve como entrada para a próxima.

Nesse pattern, descrevemos cada unidade de processamento (passo) como Filter. Um Filter  idealmente consome e entrega dados incrementalmente – em contraste com um modelo onde consome inteiramente dados antes de começar a entregar – atingindo menor latência e possibilitando o desenvolvimento de soluções baseadas em processamento paralelo real.

A entrada de dados para o sistema é provida na forma de um arquivo, objeto complexo, ou Stream. A saída (resultado da execução do último Filter)  é capturada por um data sink (coletor de dados) como um arquivo, um termina, uma cliente remoto…

A fonte de dados, os Filter e o data sink são conectados sequencialmente por Pipes. Cada pipe implementa o fluxo de dados entre duas etapas de processamento adjacentes. A sequência de Filters combinados por suas Pipes é chamada de processing pipeline.

Resumindo, podemos dizer que em uma arquitetura baseada em “Pipers and Filters”, temos os seguites componentes:

  • DataSource – fonte primária com os dados que serão transformados;
  • Filter – implementação de cada passo (unidade de processamento);
  • DataSource – Fonte primária de dados;
  • DataSink – Coletor final de dados (onde o dado transformado será utilizado);
  • Pipes – Conexão (ligação) entre um DataSource/Filter e um Filter/DataSink;
  • Processing Pipeline – Uma configuração de DataSource, Filters, Pipes, DataSink.

image

 

Se o formato dos dados transmitidos entre os Filters forem compatíveis, há ainda a possibilidade de compor uma pipeline inteiramente nova através da inclusão de novos “Filters” ou por uma mudança na sequência de processamento.

image

 

Como um Filter é ativado

A atividade de um Filter pode ser disparada de diversas formas diferentes:

  1. o próximo elemento da pipeline (Filter ou DataSink) pede dados para um filtro;
  2. o elemento anterior (DataSource ou Filtrer) “empurra” dados para um filtro;
  3. o filtro se mantem em operação continua, “pedindo” dados da pipeline e empurrando dados.

As primeiras duas formas estão associadas a filtros identificados como “passivos”. A última está associada a filtros “ativos”. Um filtro ativo inicia sua execução em sua própria thread de execução. Um filtro passivo é iniciado através de uma ação externa (pull ou push de outro elemento da pipeline).

Pipes

Uma Pipe é responsável pela conexão entre :

  • dois Filters adjacentes;
  • entre um DataSource e o primeiro Filter da processing pipeline;
  • entre o último Filter e a DataSink.

Se há dois componentes se conectando, há uma Pipe sincronizando-os. Essa sincronização ocorre com um buffer FIFO (First-In-First-Out).

Em cenários onde a atividade é controlada pelos Filtros, a pipe pode ser implementada por uma chamada direta por parte do componente ativo para o componente passivo. Entretanto, chamadas diretas costumam dificultar alteraçãoes na pipeline.

DataSource

DataSource representa a “entrada de dados” para o sistema, e provê uma sequência de dados, em uma mesma estrutra. Exemplos de DataSources são, arquivos compostos de linhas de texto, ou até sensores provendo uma sequência de números (se você está acompanhado a série sobre XNA, considere o Game Loop como um bom exemplo de DataSource)

O DataSource de uma pipeline pode prover dados através de enviar dados continuamente para o primeiro Filter ou passivamente prover dados quando este mesmo filtro requisitar.

DataSink

O DataSink coleta os resultados provenientes do último filtro da pipeline. Ha duas variantes possíveis de DataSink:

  1. ativo: requisitando dados continuamente do último Filter da pipeline
  2. passivo: esperando que o último Filter forneça dados.

Dinâmica para implantação do “Pipes and Filters” pattern

Implementar uma arquitetura baseada no padrão “Pipes and Filters”, é simples. Podemos usar um serviço de sistema como Message Queues, Pipes do PowerShell ou do Unix. Entretanto, mesmo que não se opte por uma dessas soluções, é plenamente viável escrever um orquestrador para a pipeline do zer.

Se você está considerando desenvolver um sistema utilizando esse pattern, recomendo o cumprimento das seguintes atividades:

  1. Divida o processamento que seu sistema precisa realizar em uma sequência de passos/estágios. Cada estágio precisa depender somente do “output” de seu predecessor direto. Todos os estágios precisam estar “conceitualmente conectados” pelo fluxo de dados. Se a meta é desenvolver uma família de sistemas pela simples troca/reordenação dos Filters, ou é desenvolver um conjunto reaproveitável de componentes, deve-se considerar alternativas ou novas combinações nesta atividade;
  2. Defina o formato de dados que irá “fluir” através de cada pipe. Definir um formato uniforme resulta em uma alta flexibilidade porque torna a recombinação de filtros mais fácil. A maioria dos “Filter” desenvolvidos para shell em programas Unix-like utilizam linhas de texto ASCII. Com os Filters do Powershell ocorre o mesmo.
  3. Decida como serão implementadas cada pipe. Importante ressaltar que essa decisão influencia fortemente a forma como os filtros serão implementados. Inclusive determinando se estes serão passivos ou ativos. A forma mais simples de pipe é uma chamada direta entre filtros adjacentes, entretanto é a que menos flexibiliza a pipeline.
  4. Projete e implemente os Filters. O design de um Filter é baseado tanto na tarefa que precisa executar quando nas pipes adjacentes.
  5. Projete o tratamento de erros/falhas. Eis uma parte importante de qualquer sistema e frequentemente negligenciada. Como os componentes da pipeline não compartilham estado, erros são difíceis de “identificar” e, por isso, são frequentemente negligenciados. Além de tratamentos locais, a infraestrutura da pipeline deve prover um bom mecanismo para tratamento de erros.
  6. Projete e configure uma processing pipeline. Se o sistema executa um único tipo de tarefa, podemos fazer com que o programa principal inicie a pipeline e, na sequencia, a execução. Muito mais bacana é permitir que o usuário crie uma configuração.

Por hoje, era isso!

Smiley piscando

Posted in: Arquitetura, Patterns