Arquiteto de Soluções
Microsoft Brasil
Clique aqui para baixar o código-fonte da demo contida neste artigo.
Introdução
Grande parte dos softwares de ERP ou CRM atuais têm de lidar com a questão da personalização, por vezes mais conhecida pelo anglicismo “customização”. Customizar um software não só significa introduzir modificações que o tornem aderentes às necessidades particulares de uma empresa ou linha de negócio, mas também um grande esforço de implementação.
Quando realizada pela empresa fabricante do software, a customização pode ser, por vezes, uma fonte de renda, por outras, de prejuízo. Ela costuma ser uma fonte de renda quando a customização pode ser vendida e re-incorporada em novas versões do software, ou quando for possível agregar uma rede de serviços capaz de implementá-las sem modificar o produto. Por outro lado, a customização pode ser um ônus pesado quando ela implica em uma versão diferente do produto para cada cliente, causando problemas de suporte e manutenção, ou quando a exigência de customização pelo cliente não pode ser obedecida pelo fornecedor devido a limitações de recursos, aumentando o risco da perda do cliente para a concorrência.
Podemos caracterizar a evolução da maturidade de uma aplicação, quanto ao aspecto customização, segundo as seguintes fases:
| • | 1 código fonte, 0/1 customizações: ocorre em cenários onde a aplicação é muito simples, sem suporte à parametrização, ou na primeira venda e implantação; | ||||||||
| • | N códigos fontes, N customizações: ocorre quando clientes importantes exigem customizações e não são implementadas práticas que garantam um único código (produto) para todos os clientes; | ||||||||
| • | 1 código fonte, N customizações: ocorre quando o produtor do software se organiza e produz um único código fonte através de uma ou mais das seguintes técnicas:
|
Este texto apresenta algumas das técnicas mais utilizadas pelas aplicações encontradas hoje no mercado e aprofunda, em especial, as técnicas de Personalização de Código e de Uso de linguagens de Programação. A razão desta escolha é simples: estas são técnicas mais complexas, mais difíceis de serem encontradas no mercado, e que podem beneficiar muitas empresas que as desconhecem ou não as usam por falta de conhecimento de como implementá-las. A intenção não é ser um texto ostensivo no “como” implementar parametrizações, mas apenas uma introdução com exemplos a algumas práticas.
Existe uma única certeza em software: todo software sofrerá mudanças no tempo – é mera questão do quando e do quanto custará.
É função do arquiteto planejar com antecedência uma arquitetura que minimize o impacto das futuras customizações, o que poderá significar uma grande economia tanto para usuários quanto para o produtor. Mas como podemos nos planejar para o desconhecido?
De fato, já existe um bom conjunto de técnicas que apóiam as mudanças em um software. Dentre as mais conhecidas que serão abordadas abaixo temos:
| • | Metamodelos, metadados e pontos de extensão; |
| • | Mecanismos (como Herança e Interface) e Patterns de extensão (como Factory, Composite, Inversão de Dependência, etc.); |
| • | Avaliação de regras (como fórmulas cadastradas pelo usuário e avaliadas em tempo de execução); |
| • | Linguagens de customização (como macros do Office, a linguagem ABAP do SAP, ou ADVPL da Microsiga, onde o cliente ou uma área de serviços pode estender ou customizar as funcionalidades de uma aplicação sem a necessidade de uma nova versão do produto). |
Avaliações de regras e linguagens de customização têm em comum a ciência de compiladores e interpretadores. As diferenças mais importantes se devem à complexidade e função da linguagem. Regras simples, como o cálculo de taxas de produtos e percentuais, não exigem declarações de variáveis, tratamento de exceções e outros mecanismos necessários em linguagens genéricas. Por outro lado, se pretendemos criar novas janelas, algoritmos de negócio e acesso direto aos dados da base, uma linguagem genérica pode ser o melhor caminho.
Técnicas mais experimentais como Mixins [3] ou Aspect Oriented Programming [4] não são abordados neste artigo devido à atual falta de suporte nas linguagens e ambientes de desenvolvimento amplamente usados no mercado.
As sessões que se seguem apresentam o contexto de uso de cada técnica, suas características e, eventualmente, pequenos exemplos de implementação.
A necessidade do uso de metadados costuma surgir já no início da fase de análise e requisitos de um software, quando nos deparamos com o fato de que existem diferentes cenários e usos para uma mesma aplicação.
Por definição, metadados são dados que descrevem a estrutura e/ou comportamento das entidades de um modelo, e metamodelo é o modelo destes metadados.
Um exemplo: imagine uma aplicação que necessite de uma entidade Produto onde é necessário especificar a forma de caracterizá-lo para, por exemplo, realizar uma compra, venda ou armazenamento. Supondo que este aplicativo será usado por empresas distintas, e que cada empresa lida com diferentes famílias de produtos, temos que lidar com o requisito de “tornar possível a definição das unidades características de um produto”.
Continuando o exemplo, um sapato pode ser especificado pela cor, tamanho e outros (se é chinelo, se tem salto alto, etc.). Isto significa que o usuário deve ser capaz de cadastrar tipos como COR e TAMANHO_DE_SAPATO, ESTILO, etc.. Neste caso, temos domínios diferentes como as enumerações VERMELHO, VERDE, etc. para COR ou 32, 33, ..., 44 para TAMANHO_DE_SAPATO. Outros, por sua vez, podem exigir domínios mais complexos como intervalos de números como (0,50], conjuntos de intervalos [0,50] e [80,100), ou apenas um valor Inteiro ou Real.
A figura abaixo apresenta uma modelagem simplificada (um metamodelo) para estes metadados:

É desta necessidade de parametrização que nascem os metadados, de uma forma natural dentro do processo de modelagem. Dentre os cenários de uso que costumam ser mais freqüentes costumamos encontrar:
| • | Meta-descrições de entidades. Exemplos: papel (role) ou função de um funcionário, descrição de um produto, cor de fundo e imagem (gif animado) para as telas dos usuários de uma empresa, etc.; |
| • | Meta-descrição da estrutura e tipos de uma entidade. Exemplo: descritores de atributos de uma entidade, como “a coluna de nome C1, para empresa de número 127, tem significado de COR”; |
| • | Especificações de tipos. Exemplos: enumerações, valores inteiros ou reais, intervalos, arrays (estruturas de tipos uniformes), estruturas (estruturas de tipos não uniformes), etc.. |
O uso de metadados implica em um código capaz de ler dados de configuração e se comportar de acordo com estes valores. No caso em que a cor de uma janela pode ser parametrizável, o código de apresentação deve ser capaz de ler esses dados e atribuir a cor correta à janela. Ele também costuma necessitar de telas auxiliares para cadastro dos metadados . Dando seqüência ao exemplo da entidade Produto, devemos, depois de desenhar o metamodelo, criar telas de cadastro de domínios (como COR ou TAMANHO) e tipos de produtos (SAPATO, CALÇA, etc.). Na tela de cadastro de produtos, em particular, é necessário apresentar opções diferentes de visualizações segundo cada tipo e domínio. Por exemplo, no caso de um produto que tem como atributo a COR o código deve identificar que tal atributo tem um tipo enumeração e pode apresentar ao usuário final um simples combobox com todas as cores cadastradas. No caso de um intervalo, o código deve ser capaz de mostrar dois comboboxes para os valores mínimos e máximos, e assim por diante.
O uso de metadados tem dupla complexidade uma vez que tanto o modelo de dados quanto o modelo de programação são mais rebuscados, podendo criar problemas de manutenção e/ou performance.
Com referência ao problema de desempenho, pode se dizer que técnicas de cache [1] costumam ser apropriadas, já que metadados costumam ter um ciclo de vida que requer uma alta incidência de inserção e mudanças na fase inicial de configuração do software, mas muito baixa no período de produção, onde a operação mais freqüente é a leitura simples.
Quanto ao problema de manutenabilidade, este decorre quase que exclusivamente da complexidade e espalhamento do código nas várias regras de negócio. Neste caso, o uso de técnicas que favoreçam o aumento da abstração e coesão costuma ser o mais recomendado, permitindo um desacoplamento entre o código usuário do metadados e o código que faz acesso e manutenção destes. Somados a estas técnicas, encontram-se também alguns patterns mais complexos que organizam o uso destas práticas para possibilitar a extensão do metamodelo no que tange aos algoritmos e regras sobre os metadados. Linguagens como C# e VB.Net possuem mecanismos de para a construção de Interfaces, classes abstratas e regras de encapsulamento que favorecem o uso destes técnicas que serão apresentadas na sessão Técnicas e Patterns para Extensão abaixo.
Outra questão relevante se refere aos pontos de extensão, que devem ser pré-planejados. Por exemplo, se o software permitir a inclusão de novas telas e itens de menu, faz-se necessário especificar não só as formas de inserção das novas telas (metadescrição da tela ou referência a um código que a implementa), mas também determinar como e onde serão referenciadas (cadastro de itens de menu, inclusão de botões ou links de referência para as novas telas, etc.).
Pontos de extensão têm a finalidade de restringir a parametrização a lugares específicos da aplicação e código fonte, diminuindo tanto a complexidade do código original quanto a migração deste para novas versões. Mesmo no caso em que o usuário tem à disposição uma linguagem para implementar a customização, como o código fonte do produto original não costuma ser disponibilizado (tanto por questões de propriedade intelectual quanto pela questão da complexidade da migração para novas versões), o lugar da customização deve ficar restrito aos pontos de extensão pré-planejados.
Um exemplo mais atual do uso de metadados pode ser encontrado na arquitetura SaaS (Software as a Service) onde uma única instância de código de software deve servir à vários clientes (inquilinos) ao mesmo tempo, cada um com sua configuração exclusiva de interface gráfica, direitos e campos customizados. Uma boa referência para este tópico é http://msdn2.microsoft.com/en-us/library/aa479086.aspx .
Variabilidade de Regras e Algoritmos
Especificar tipos ou descrever entidades costuma ser mais complexo do que meramente preencher dados em tabelas (ou metatabelas) de um banco de dados relacional ou tags de um arquivo de configuração. Junto com estes dados costumam existir códigos referentes à validação, apresentação e lógica de negócio que vão exigir técnicas específicas de extensão.
Um exemplo: no caso da definição de uma taxa de imposto, poderemos ter que associar a taxa com vários fatores, como tipo do produto, estado em que a venda foi feita, data da venda, etc. Uma mera mudança de legislação local pode tornar esta regra ainda mais complexa e criar dependências não previstas no metamodelo, modelo e código original. Neste caso, tanto metamodelo quanto modelo/código poderão requerer mudanças. Imaginando que temos leis diferentes para cada estado e pais, podemos prever que o mais comum será a exigência de mudança do código do cálculo da taxa, e que, melhor que tentar imaginar um metamodelo/modelo que seja capaz de lidar com a parametrização para um único algoritmo capaz de prever todas as peculiaridades futuras, é mais interessante construir mecanismos que permitam a variabilidade destes algoritmos ou regras. Neste contexto, devemos entender variabilidade como: a capacidade de ativar um código de acordo com um contexto definido por um dado ou metadado.
A forma como uma regra pode estar descrita em um sistema pode ser:
| • | Como script (texto ou metadado a ser interpretado ou compilado para, em seguida, ser executado); |
| • | Como código executável (dll, stored procedure ou outro tipo de executável). |
Em todos os casos, deveremos identificar:
| • | Os mecanismos de inclusão e extensão de regras; |
| • | A sintaxe e semântica da linguagem e o mecanismo de execução; |
| • | O local de armazenamento das regras e o mecanismo de carga. |
Os itens que se seguem abordam as principais técnicas usadas para alcançar a variabilidade de regras.
Uma customização pré-planejada e de menor custo costuma se basear nos mecanismos das linguagens de programação, dos ambientes de execução e/ou dos patterns específicos que lidam com a variabilidade na execução de código.
No caso das linguagens de programação orientadas a objetos, o mecanismo de polimorfismo é o usual para atingir a variabilidade. No caso do .Net Framework e suas linguagens (C#, VB.Net e outras) os mecanismos principais são:
| • | Interfaces: que permitem tanto especificar o nome quanto os parâmetros de um método que deverão ser implementados por uma classe; |
| • | Herança: que permite a sobre-escrita a um método de uma superclasse, fazendo com que uma variável membro de uma classe possa receber tanto objetos de sua classe quanto da subclasses desta; |
| • | Delegates: que definem interfaces de métodos como sendo tipos, permitindo a definição de variáveis que são, de fato, referências às funções, tornando possível atribuir e executar funções |
| • | Reflection: mecanismo de leitura do metadado de um código (seus tipos e estruturas) e sua execução; |
| • | Carga de código: mecanismo de carga de um código em formato dll para dentro do ambiente de execução (Application Domain) e sua ativação |
De uma forma geral, o padrão para a execução de código variável é:
1. | Definir uma interface (por delegates, interface ou classe/subclasse); |
2. | Declarar uma variável segundo esta interface; |
3. | Ler o metadado e carregar o código especificado; |
4. | Usar a variável para executar o(s) método(s) definido(s) na interface; |
5. | Destruir o objeto (ou retorná-lo ao cache). |
O exemplo abaixo apresenta um código definindo uma interface e um Executor. Cabe à classe Factory, não explicitada no exemplo, carregar uma dll e criar um objeto segundo uma definição armazenada em algum repositório (arquivo xml de configuração, banco de dados ou equivalente).
public interface IExecutorDeDll {
double Execute ();
}
public class Executor {
public double Execute (string typeName) {
try {
using (IExecutorDeDll exe = Factory.CreateInstance(typeName)
as IExecutorDeDll )
if (exe == null) {
throw new Exception("Não foi possível carregar o executor: " + typeName);
}
double d = exe.Execute();
}
}
catch (Exception err ) {
}
}
}
Por outro lado, o padrão para programação do código que deve ser chamado é:
1. | Programar o código segundo a interface; |
2. | Armazenar o código com eventual metadado (exemplo: o código fica no diretório de extensões e o arquivo de configuração faz referência ao código). |
Um exemplo de código que obedece a interface seria:
public class Pi : IExecutorDeDll {
public double Execute () {
return 3.1416;
}
}
No exemplo acima, podemos destacar alguns aspectos
1. | O método Execute é um exemplo de Ponto de Extensão, onde um código definido externamente ao código executado pode ser chamado; |
2. | Caso haja um teste da existência ou não deste código externo, podemos dizer que é um Ponto de Extensão Opcional; |
3. | Pode ser imprenscindível passar parâmetros ao método que fabrica o objeto para que ele possa resgatá-lo de acordo com o contexto (exemplo: segundo o ID da empresa, o ID do usuário, etc.); |
4. | Pode ser necessário armazenar o código variável em memória cachê para evitar problemas de performance devido à carga e descarga do código. A fábrica de objetos costuma ser o lugar mais simples para implementar a administração do cachê ; |
5. | Outros padrões como Plugg Inn, Service Locator, Inversion of Control e Dependence Injection (ver http://www.martinfowler.com/articles/injection.html e http://msdn2.microsoft.com/en-us/architecture/aa973811.aspx ) podem ser utilizados; |
6. | A Enterprise Library 3.0 implementa uma infra-estrutura para a injeção de objetos em tempo de execução denominado ObjectBuilder (http://msdn2.microsoft.com/en-us/library/aa480453.aspx) assim como o PoliceInjection como pattern para a interceptação e injeção de código antes de uma chamada a um código remoto; |
7. | Outro mecanismo factível, oferecidos em linguagens como C# e VB.Net, é o Reflection. Exemplos de como chamar um código podem ser encontrados em http://msdn2.microsoft.com/en-us/library/k3a58006(VS.80).aspx ou http://msdn2.microsoft.com/en-us/library/b8ytshk6(VS.80).aspx ; |
8. | O desempenho dos mecanismos de linguagem não é um tópico irrelevante. O artigo é uma boa referência neste assunto e conclui que a chamada de uma interface (como no exemplo acima) é a chamada de menor custo. Os três tipos de invocação através do mecanismo de Reflection são os de pior desempenho (ver figura abaixo). Porém, considere sempre o custo da carga da carga do executável, pois costuma ser maior do que o tempo de invocação – o que pode exigir o uso de cache; |

Execução em Sandboxes
Como exemplificado na sessão anterior, a execução de códigos diferentes, carregados por algum tipo de fábrica, é a base para a variabilidade de comportamento de uma aplicação. Porém, um segundo ponto importante refere-se à garantia de que este código não pode desestabilizar o ambiente de execução do código chamador, isto é, a sua aplicação. Esta separação de ambientes de execução pode ser interessante tanto pelo aspecto estabilidade quanto pelo aspecto segurança, pois, por exemplo, podemos com isto evitar que o código chamado tenha acesso às variáveis do ambiente de execução ou que, em caso da ocorrência de exceções, este erro não chegue a afetar o ambiente chamador. A denominação que damos a esta execução em separado em contexto diferenciado de segurança é Sandbox.
O uso de Application Domains diferentes como mecanismo de implementação de Sandbox costuma ser, dentro do Framework .Net, uma boa opção. Embora um assembly .Net não possa ser descarregado (unload) em tempo de execução de dentro do seu Application Domain, podemos sempre destruir um Application Domain, quando necessário, descarregando com ele os assemblies nele contidos. Além disto, Applications Domains podem estar sujeitos a políticas de segurança diferentes da do ambiente que o criou, completando as exigências básicas para a implementação de um Sandbox.
A sessão Execução em uma Sandbox abaixo irá exemplificar uma implementação de Sandbox utilizando Applications Domains do .Net Framework.
O uso de linguagens é outra técnica comumente utilizada para prover extensões a um código pré-existente. Seja compilada ou interpretada, uma linguagem especifica a sintaxe e semântica necessárias para que usuários produzam regras ou algoritmos que serão carregados e utilizados em tempo de execução.
Neste caso, existem algumas opções a serem feitas:
| • | Utilizar uma linguagem bem conhecida como forma de extensão, como VB.NET ou C#, provendo bibliotecas de classes que facilitam o acesso aos objetos e dados do programa; |
| • | Definir e implementar um linguagem de domínio específica, capaz de expressar as ações semânticas mais comuns dentro da lógica do negócio (como fórmulas para cálculo de taxas de impostos, etc.). |
A primeira opção tem o benefício de diminuir o trabalho de implementação, mas pode criar problemas por não restringir ações que poderiam ser lesivas ao aplicativo, como a atualização inconsistente de bases de dados, loops infinitos, etc. Outra ponto importante é que o usuário final da linguagem pode não ter conhecimento extenso de linguagens de computação, criando problemas de impedância no momento da compra do produto ou aumentando o custo total devido ao custo de treinamento do usuário final.
A definição de linguagens costuma ser bastante útil em casos de expressões simples, como cálculo de taxas, expressões de workflows, etc.
As sessões que se seguem vão apresentar algumas das técnicas comuns utilizadas na implementação de linguagens. A idéia não é apresentar um texto completo no assunto. Muito pelo contrário, serão mostradas apenas algumas técnicas simples, muito bem conhecidas, para indicar que, embora o trabalho seja complexo, ele é ainda simples o suficiente para ser implementado em aplicações LOB.
Como exemplo desta facilidade, é mostrada a implementação de uma linguagem simples e extensível para a resolução de expressões com números reais, operadores de adição, subtração, multiplicação e divisão. Além destes, são implementadas algumas funções como Maior(a,b), Menor(a,b) e If(a,b,c) e o como implementar outras funções, seja pelo projetista da linguagem, seja pelo usuário que deseja estender a linguagem com novas funções escritas em .Net.
Ao final da leitura e compreensão das próximas (e indigestas) sessões, você terá como recompensa a possibilidade de incorporar no seu software funcionalidades análogas às fórmulas de um Excel, em que o próprio usuário define um cálculo e o seu software pode avaliar a fórmula apresentando o resultado.
Definição de Linguagens
Linguagens são definidas por gramáticas. Quando escrevemos a fórmula “6+7/5”, sabemos que
| • | 6, 7 e 5 são números; |
| • | ‘+’ e ‘/’ são operadores que exigem duas expressões, uma à esquerda e outra à direita; |
| • | a divisão de 5 por 7 deve ser feita antes da soma. |
A maneira clássica de lidarmos com este tipo de análise é diferenciá-la entre descobrir os elementos mais básicos (isto é, as palavras do nosso vocabulário) e descobrir a estrutura da fase. A primeira análise é denominada de Análise Léxica. A segunda, relativa à estrutura da frase, é denominada Análise Gramatical.
A Análise Léxica é normalmente especificada através de expressões regulares que determinam o formato de um vocábulo. Por exemplo, a expressão regular para um identificador poderia ser:
[a..zA..Z] [a..zA..Z0-9]*
Nesta expressão, fica explícito que um identificador inicia com uma letra minúscula (a..z => de ‘a’ a ‘z’) ou maiúscula (A..Z => de ‘A’ a ‘Z’) e é seguido de um conjunto de números ou letras maiúsculas ou minúscula. O asterisco ao final da fórmula indica o conjunto final de letras pode ter zero ou mais elementos.
O segundo tipo de análise é a Análise Sintática que trata da estrutura de uma expressão, ao invés da estrutura de um vocábulo. Com no caso do nosso português em que descobrimos na frase quem é o sujeito da ação, qual é a ação e qual é o predicado, temos que compreender a estrutura de uma expressão para poder interpretá-la ou traduzi-la em ações do computador.
A técnica mais popular para especificar uma gramática é o uso de BNF’s (Backus-Naur Form). BNF’s são formadas por regras de produção, que determinam a estrutura de uma frase. Nelas existem dois tipos de elementos (símbolos): terminais e não terminais. Os terminais representam um item léxico. Os não-terminais representam elementos de uma frase que são definidos através de outros elementos terminais ou não-terminais, podendo haver definições recursivas. Uma regra é, portanto, esta definição da estrutura de um símbolo não-terminal, e a gramática é uma lista de regras de produção. Um exemplo incompleto de gramática seria:
ChamadaDeFunção := id ( ) | id ( ListaDeArgumentos );
ListaDeArgumentos := Expressão | Expressão , ListaDeArgumentos;
Neste exemplo, cada linha é uma regra de produção, enquanto os elementos em negrito são os não-terminais e os em itálicos são os terminais. O caracter | indica que existem formas opcionais para a descrição de um termo. Por exemplo, uma ChamadaDeFunção pode ser uma chamada com nenhum argumento (id ( )) ou com pelo menos um argumento ( id ( ListaDeParametros ) ). Uma lista de parâmetros, por sua vez, pode ter uma única Expressão, ou um conjunto de Expressões separadas por vírgula – portanto, uma definição recursiva.
Esta gramática está incompleta porque ela não define o não-terminal Expressão. Outro problema: ele não indica as expressões regulares que definem um terminal – o que poderia ser feito explicitando-se junto a gramática as expressões regulares para cada terminal.
Um problema recorrente na especificação da gramática refere-se à questão do uso de recursão. Por exemplo, na gramática
Expressão := Expressão Operador Expressão;
Operador := + | - | * | /;
o termo Expressão é recursivo duas vezes, o que dificulta a implementação do analisador sintático (ou, no inglês, parser). Outro problema é mais estrutural: nesta gramática não é possível identificar a prioridade dos operadores. Isto é, ao receber uma string “1+2*7”, não conseguimos determinar se o resultado é 21 (isto é, interpretamos a expressão como (1+2)* 7 ) ou 15 (quando interpretamos como 1 + (2*7) ).
Para resolver este duplo problema é comum “massagearmos” a gramática evitando recursões e indicando precedências. Na gramática
Expressão := Expressão OpMenorPrecedência Termo | Termo;
Termo := Termo OpMaiorPrecedência Fator | Fator;
Fator := número | ( Expressão );
OpMenorPrecedência := + | -;
OpMaiorPrecedência := * | /;
ambos os problemas são tratados. Ao introduzir os não-terminais Termo e Fator, a gramática irá privilegiar o reconhecimento dos operadores de maior precedência, pois na escalada de reconhecimento, que vai da regra posicionada mais abaixo na lista de regras até a mais acima, este operador é encontrado antes do que os operadores de menor precedência.
A escalada de reconhecimento pode ser melhor compreendida com o exemplo do parsing (análise gramatical) da string “1+2*3”. Neste caso a seqüência é:
1. | 1 é reconhecido pelo léxico e o parser o identifica como um número; | ||||||
2. | o número é reconhecido como um Fator; | ||||||
3. | o + é reconhecido pelo analisador sintático e o Fator(1) torna-se um Termo(1), pois só um Termo pode ser seguido por um operador de menor precedência; | ||||||
4. | o Termo(1) torna-se uma Expressão(1) também por ser seguido por um operador de menor precedência; | ||||||
5. | o 2 é devolvido ao parser e este transforma-se em um Fator este em Termo(2); | ||||||
6. | o * é recebido pelo parser e com isto, fica-se à espera de um Termo; | ||||||
7. | o 3 é reconhecido e o parser pode agora fazer algumas transformações:
|
Visualmente, podemos descrever este processo por uma árvore da forma:

Maiores detalhes destes processos de análise léxica e gramatical podem ser encontrados nas referências [2].
Gramático Exemplo
Este texto vem acompanhado de uma implementação real de uma gramática para expressões matemáticas simples. Seu download pode ser feito a partir do link (ver)
A definição da linguagem é dada por:
Léxico:
id := [a..zA..Z] [a..bA..Z0-9]* ;
numero := [0-9]+[/.[0-9]*] [[e|E][0-9]+]]
ignora := /b | /n | /t;
Gramática:
Exp := Exp OpSoma ExpMult | ExpMult ;
ExpMult := ExpMult OpMult Fator | Fator;
Fator := OpSoma Fator | numero | ( Exp ) | Funcao
Funcao := id ( ListaParam ) | id ()
ListaParam := Exp | ListaParam , Exp
OpSoma := + | -
OpMult := * | /
Com esta gramática é possível escrever expressões como: “1.023e-3+ -1*(Maior(1,2)/3+1)”
Observação: a definição léxica de “ignora” indica que o léxico não retornará nenhum token ou erro ao encontrar os caracteres brancos, line feed ou tab. Qualquer seqüência de caracteres que não sejam os definidos fará com que o analisador léxico levante uma exceção.
Arquitetura de um Interpretador ou Compilador
Interpretadores e compiladores costumam ter uma arquitetura comum no que se refere aos seguintes elementos:
| • | um analisador léxico, que lê o texto de entrada (a expressão) e retorna um a um os itens léxicos encontrados; |
| • | um parser (ou analisador sintático) que pede ao analisador léxico os itens e constrói uma árvore que representa a expressão; |
| • | uma tabela de símbolos que pode ser usada pelo parser para a procura ou inserção de nomes de elementos da linguagem, como nomes de variáveis e de funções. Ela também pode ser usada pelo interpretador e/ou gerador de código para consultas; |
| • | uma AST (Abstract Syntax Tree) que é a representação simplificada de uma expressão que servirá de entrada para o módulo de interpretação e/ou geração de código. |
Um exemplo da AST para a expressão “1+2*7 seria:

A figura abaixo apresenta a estrutura de blocos de um interpretador. No caso de um compilador, no lugar do interpretador, teremos um gerador de código que irá ler a AST para, com o apoio da tabela de símbolos, gerar o código executável.

As sessões que se seguem tratam com um pouco mais de profundidade cada um dos componentes de um compilador/interpretador, tendo como exemplo a gramática-exemplo.
Analisadores Léxicos
Um analisador léxico é um programa ou biblioteca capaz de retornar os itens léxicos de uma gramática.
Um analisador costuma ler de um stream (advindo de um arquivo, string, ou qualquer outro tipo de entrada de texto) e analisa, caractere a caractere, a qual expressão regular o string satisfaz.
Como existe o caso em que um item léxico pode ser prefixo de um segundo item, o analisador tem como regra identificar sempre a expressão regular com maior número de caracteres. Exemplo: imagine duas expressões para identificar números inteiros e reais. Neste caso, um número inteiro é um prefixo para um real (ex.: 1 é prefixo de 1.23e20). Ao encontrar o caractere ‘.’, o analisador deve continuar seu reconhecimento em busca de um número real, desistindo do reconhecimento de um número inteiro.
Analisadores léxicos podem ser escritos à mão ou implementados com geradores de analisadores léxicos, como por exemplo, o Lex [5] ou o MPLex [6]. Geradores como estes recebem um arquivo com a definição das expressões regulares e geram arquivos fontes que implementam um analisador léxico para estas expressões. Eles costumam utilizar tabelas que representam internamente os diagramas de transições e podem ser, na maior parte das vezes, mais velozes do que um analisador codificado por um programador. São também mais simples para dar manutenção, uma vez que é simples re-gerar um analisador a partir da modificação de uma expressão regular, se necessário.
Um analisador feito à mão também não é um software muito complexo de fazer ou de dar manutenção.
Para exemplificar, veja o código da classe Lexico, contida no projeto que implementa nossa gramática exemplo. Nela, existe um método pegaToken() que percorre o string de entrada caractere a caractere procurando por um token Por exemplo, o código
if (posicao >= tamanho) {
tokenAtual = Token.FIM_TEXTO;
return tokenAtual;
}
// pula eventuais espaços em branco
if ( char.IsSeparator(texto[posicao] ) ) {
for ( posicao++;
(posicao < tamanho) && char.IsSeparator(texto[posicao]); posicao++) {
val += texto[posicao];
}
// chegou ao fim da expressão
if (posicao >= tamanho) {
tokenAtual = Token.FIM_TEXTO;
return tokenAtual;
}
}
procura pelo fim de texto ou outro caractere, ignorando todos os separadores. O código seguinte
switch (texto[posicao] ) {
case '(':
posicao++;
tokenAtual = Token.ABRE_PARENTESIS;
return tokenAtual;
case ')':
posicao++;
tokenAtual = Token.FECHA_PARENTESIS;
return tokenAtual;
case '+':
posicao++;
tokenAtual = Token.ADICAO;
return tokenAtual;
case '-':
posicao++;
tokenAtual = Token.SUBTRACAO;
return tokenAtual;
case '*':
posicao++;
tokenAtual = Token.MULTIPLICACAO;
return tokenAtual;
case '/':
posicao++;
tokenAtual = Token.DIVISAO;
return tokenAtual;
case ',':
posicao++;
tokenAtual = Token.VIRGULA;
return tokenAtual;
default:
if (char.IsDigit(texto[posicao])) {
tokenAtual = getTokenDecimal();
return tokenAtual;
} else if (char.IsLetter(texto[posicao])) {
tokenAtual = getTokenNome();
return tokenAtual;
} else {
string msgFinal = "Erro na coluna {0}: " + "Item Léxico desconhecido";
msgFinal = String.Format(msgFinal, (posicao+1).ToString());
throw new Exception(msgFinal);
}
}
trata de todos os itens léxicos diferentes dos caracteres de espaço (a serem ignorados). No caso de itens de pontuação simples, o código apenas testa o caractere e retorna o token (uma enumeração) ao código chamador (o parser). Caso o caractere seja um número ou letra, os métodos getTokenDecimal() ou getTokenNome() irão continuar a análise. Neste código, posição é uma variável que guarda a posição do analisador na string que está sendo analisada.
Um identificador (ou nome) é uma seqüência de caracteres que inicia com um caractere alfabético e pode conter caracteres numéricos ou alfabéticos. Uma vez que o switch acima já testou o caractere atual e sabe que é um efetivamente um dígito ou letra, basta um loop para testar se há uma seqüência de zero ou mais desses elementos:
private Token getTokenNome () {
val = texto[posicao].ToString();
for ( posicao++;
(posicao < tamanho) && char.IsLetterOrDigit(texto[posicao]);
posicao++ ) {
val += texto[posicao];
}
return Token.NOME;
}
A análise de um número é mais complexa, mas, como as outras análises, ela se resume a uma procura pelos caracteres segundo a expressão regular que a define.
Um segundo método útil de um léxico é o que implementa um lookahead, ou seja, um método que retorna o próximo token contido na string, sem adiantar de fato a posição atual do analisador léxico. Este é um método importante no caso de uso de certos tipos de parsers, como veremos adiante.
Parsers ou Analisadores Sintáticos
Parsers são executáveis ou bibliotecas que verificam a boa formação gramatical dos itens léxicos, confirmando se um texto está de acordo com as regras da gramática para gerar código ou, o que é mais comum, uma dupla estrutura: uma árvore que representa a estrutura da frase ou expressão e uma tabela de símbolos para armazenar metadados relativos à declarações (tipos, variáveis, funções, etc.).
Como os analisadores léxicos, os parsers também podem ser gerados automaticamente através de programas como Yacc [5] ou MPPG [6] que lêem a especificação de uma gramática e são capazes de gerar o código que implementa o respectivo parser. Porém, neste texto, trataremos de maneira superficial dos parsers implementados à mão – uma tarefa complexa, mas não impossível.
Na implementação de parsers, é comum associarmos o reconhecimento de terminais e não terminais com ações para montar uma AST. Por exemplo, ao reconhecer um número, um parser pode criar um objeto da classe Numero contendo o valor do item léxico reconhecido. Ao reconhecer que o Numero deve ser transformado para Fator, o parser pode, por exemplo, criar um objeto da classe Fator.
Antes de entrarmos mais a fundo no tema, é importante saber que existem duas classes de parsers: os top-down e os bottom-up.
Parsers top-down analisam a gramática de cima para baixo. Para exemplificar, imagine a gramática de um Programa, constituído de Blocos de Declaração de Variáveis e Blocos de Declaração de Funções e, dentro destes, sintaxes específicas para cada Bloco. Neste caso, a estrutura recursiva da estrutura do programa pode ser copiada no analisador sintático: uma função Programa() para análise do programa que chama duas outras: BlocoDeDeclaração DeVariáveis() e BlocoDeFunções(). Esta última função, por sua vez, é composta por chamadas a Declarações() e ListaDeComandos(), e assim por diante até chegarmos aos itens léxicos. Portanto, parses top-down podem ser implementados de forma recursiva com uma função representando um não terminal e chamando ordenadamente os elementos da regra à sua direita na BNF até chegarmos ao momento da leitura dos símbolos terminais.
No entanto, por serem mais restritivos quanto ao espectro de gramáticas que são capazes de analisar, parsers top-down costumam ser utilizados em linguagens mais simples. Linguagens mais complexas exigem o parser bottom-up que analisam a gramática de cima para baixo, isto é, reconhecendo primeiro os símbolos terminais para daí subir na gramática procurando o reconhecimento dos símbolos não-terminais a que a estrutura da frase corresponde.
Para exemplificar a análise de um parser bottom-up e suas ações, não há nada melhor que um exemplo: a análise da expressão “1 * 2” segundo nossa gramática exemplo.
Segundo nossa gramática, o 1 será reconhecido como um número e colocado na pilha como um objeto da classe Numero com valor 1. Em seguida, ao reconhecer o caractere “*” o parser faz várias ações:
| • | Ele descobre que o objeto Numero no topo da pilha deve ser transformado em um Fator e, como Fator, ele deve ser transformado em ExpMult; |
| • | Ele cria um objeto da classe Multiplicacao e o insere no topo da pilha |
Continuando o reconhecimento, o parser encontra o “2” e cria um objeto da classe Numero, com valor 2, colocando-o na pilha. Em seguida ele reduz este Numero para um Fator.
Neste momento, o parser está pronto para fazer a grande redução de “bold ExpMult OpMult Fator” (conteúdo na pilha) para o não terminal ExpMult. Para isto, ele conta com os elementos ordenados na pilha e realiza as seguintes operações:
| • | Remove o topo de pilha (Numero com valor 2), o seguinte (a Multiplicacao) e o próximo (o Numero com valor 1); |
| • | Usa os métodos da classe Multiplicacao para fazer com que o Número 2 seja referenciado como o operando à esquerda da multiplicação e o Número 1 como o à direita; |
| • | Insere o operador Multiplicacao de novo no topo da pilha; |
Ao final, teremos no topo da pilha uma árvore que representa a expressão de entrada.

Para implementar um parser, portanto, precisamos de uma estrutura/algoritmo de reconhecimento das regras da gramática, mais um algoritmo de ações semânticas associadas ao reconhecimento.
A estrutura/algoritmo de parte de reconhecimento é dada por uma máquina de estados capaz de reconhecer os elementos de uma gramática. Uma máquina exemplo para identificar o não terminal de uma Funcao da nossa gramática seria dado por:

Neste autômato, identificamos estados (círculos) e transições (setas) que indicam a seqüência de reconhecimento. A caixa Exp indica que, neste ponto, estamos esperando uma expressão e podemos chamar outro autômato para isto. Por fim, o círculo inicial é diferenciado por ter dois círculos concêntricos enquanto o final é preenchido com a cor preta.
Ações semânticas, como criação de objetos e sua inserção na pilha, são implementadas a cada transição de estado, pois a transição indica o reconhecimento de um item à direita de uma regra gramatical (neste caso, id ( ListaParam ) | id () ). Por exemplo, ao reconhecer um Id, podemos criar um objeto da classe Funcao e associarmos a ele o valor do id (o nome da função). Em seguida, ao reconhecer cada parâmetro, podemos recuperar o topo da pilha que contem o resultado do reconhecimento de uma expressão (Exp) e o inserimos na lista de parâmetros da função (a classe Funcao declara esta lista para armazenar a todos os parâmetros a serem usados na sua execução).
A implementação de um autômato pode ser feita com a implementação de um loop com switches no seu interior. Algo como:
lêToken = true; estado = Inicial; Enquanto não achou o estado final Caso estado = Inicial: verifica se o token é um Id. Caso não, levantar erro cria um objeto da classe Função(id) e coloca no topo da pilha estado := AguardaAbreParenteses; Caso estado = AguardaAbreParenteses: verifica se o token é Abre Parênteses. Caso não, levantar erro token = léxico.LookAhead(); // retorna o próximo token sem mover a posição atual Se token = FechaParenteses Então estado = Final Senão ParseParaUmaExpressão(); estado = AguardaVirgula; Caso estado = AguardaVirgula: verifica se o token é Vírgula. Caso não, levantar erro retira o objeto no topo da pilha, que deve ser do tipo Expressão pega o novo topo da pilha, que de ser do tipo Função, e insere a Expressão na sua lista de argumentos estado = AguardaAbreParenteses; lêToken = false; Caso estado = Fim: Retorna; Fim Caso Se lêToken Então token = léxico.PegaToken(); Senão lêToken = true; Fim Enquanto
Este código exemplo é quase um template para uma máquina de estados utilizada em parsers bottom-up. Ele é chamado com um token já lido, que neste caso deve ser o identificador da função. O código implementa um loop onde, ao final, o léxico pode ou não ser chamado para ler o próximo token. Dentro do loop, existe um teste (Case) para determinar a ação a ser feita segundo o estado da máquina. Quanto às ações, elas buscam:
| • | Verificar se o token lido está correto (ou enviar erro, caso incorreto); |
| • | Criar objetos que irão representar a árvore (AST); |
| • | Realizar ações na pilha para inserir, retirar ou manipular estes objetos. |
O intuito, ao final, é ter no topo da pilha uma representação em memória da AST.
O código que acompanha este artigo implementa o parser para a linguagem exemplo de acordo com esta técnica.
Tabela de Símbolos
Tabelas de símbolos são estruturas de dados que armazenam informações sobre símbolos usados ou definidos pelo usuário, como funções e seus parâmetros, variáveis e seus tipos, classes, etc.
A estrutura mais convencional a ser usada numa tabela de símbolos é uma Hash Table, que funciona como um dicionário associando o nome do elemento a ser procurado e a estrutura que descreve este elemento.
Os dois elementos da Tabela de símbolos merecem consideração:
| • | Por vezes, o nome armazenado pode ser composto, como classe.método, garantindo uma contextualização suficiente para evitar a colisão de nomes entre métodos homônimos de classes diferentes; |
| • | A estrutura de descrição pode ser bastante complexa, de acordo com o uso a ser feito. Por exemplo, numa estrutura que descreve uma classe deveremos acessar não só o seu nome, visibilidade (privada ou pública), etc. como temos que ter acesso a todos os membros da classe (listas dos métodos, dos subtipos, etc.). |
A próxima sessão apresenta o uso da tabela de símbolos usada na implementação da gramática exemplo.
Estendendo Linguagens com novas Funções
Na implementação da gramática exemplo utilizamos uma tabela de símbolos para duas funções distintas:
| • | Apoiar o teste, no momento da análise gramatical, que garante que a função existe e que o número de parâmetros está correto; |
| • | Correlacionar o nome das funções com os códigos que, de fato, executarão as funções em tempo de execução. |
Como vimos na sessão de Mecanismos e Patterns para Extensão acima, existem várias maneiras de chamar um código. No nosso exemplo, em particular, utilizamos delegates e interfaces. Interfaces são utilizadas para garantir que as funções externas, em dll’s hospedadas pela aplicação, obedecem a um padrão de chamada. Como exemplos de extensões, nós podemos considerar as dll’s feitas por usuários externos (clientes ou consultorias) e que executam em um sandbox diferente visando proteger o ambiente em que o parser está rodando. Delegates, por sua vez, são utilizados para chamar funções pré-definidas junto ao parser ou definidas pelo usuário.
O código abaixo apresenta a classe Funcoes, que armazena numa tabela de símbolos o conjunto de funções reconhecidas pelo parser (não confundir com a classe Funcao que representa o elemento função numa AST):
public class Funcoes {
static public Dictionary<string, Delegates> ListaFuncoes;
static public void Inicializa() {
if (ListaFuncoes != null)
return;
ListaFuncoes = new Dictionary<string, Delegates>();
// Adcionar funções sempre com Maiúsculas
ListaFuncoes.Add("INV", new ListaExecutorLocal(Inv, 1));
ListaFuncoes.Add("MAIOR", new ListaExecutorLocal(Maior, 2));
ListaFuncoes.Add("MENOR", new ListaExecutorLocal(Menor, 2));
ListaFuncoes.Add("IF", new ListaExecutorLocal(If, 3));
try {
ListaFuncoes.Add("PI", new ListaExecutorEmSandBox("MathFunctions",
"MathFunctions.Pi", 0));
ListaFuncoes.Add("MOD", new ListaExecutorEmSandBox("MathFunctions",
"MathFunctions.Modulo", 2));
}
catch (Exception err) {
throw err;
}
}
}
Nele podemos observar que a Tabela de Símbolos é implementada através de uma classe Dictionary que correlaciona o nome da função com o Delegates que aponta para o código a ser executado.
Delegates é a classe abstrata pai da árvore de classes de Delegates de acordo com o número de parâmetros. Sua função única é garantir um tipo único para o dicionário. As suas subclasses são diferenciadas por chamarem código interno ou externo à aplicação.

As sessões que se seguem apresentam o modelo de execução de uma expressão e irão mostrar como as classes de Delegates são utilizadas neste exemplo.
AST
Uma Abstract Syntax Tree (Árvore Sintática Abstrata) é uma representação simplificada de uma expressão que servirá de entrada para o módulo de interpretação e/ou geração de código. Por simplificação entenda-se: ela não contém elementos intermediários de transformação, como alguns não-terminais (por exemplo, Fator ou ExpMult). Ela contém apenas elementos que contribuem de fato com a execução da expressão.
No caso da nossa gramática exemplo, os nodos de uma árvore são objetos que representam um Número, uma Função, ou operadores Binários e Unários. A classe abstrata Expressão é a superclasse de todas estas classes e tem como função obrigar que todas implementem o método Execute(), para serem chamada por um interpretador, ou GeraCodigo(), por um compilador.

A avaliação (execução) de uma AST pode ser feita de forma recursiva, bastando chamar o método Execute() da raiz da árvore (o elemento mais alto da árvore). Este, por sua vez, irá chamar os métodos Execute() de seus descendentes até chegar recursivamente às folhas.
A string de entrada “1+2”, por exemplo, geraria a árvore
Onde o topo da pilha é o operador binário Adição e seus descendentes são: o Número contendo ‘1’ à esquerda e o Número ‘2’ à direita. Ao chamar o método Execute() da classe Adição, este chama primeiro o método Execute() do Número à esquerda – que retorna o valor 1. Em seguida, chama o método Execute() do Número à direita - que retorna 2. Ao final, o objeto da classe Adição soma os dois resultados e devolve para o seu chamador o valor 3 como resultado da expressão.
O código exemplo para a classe Adição é:
public class Adicao : Binario {
public Adicao (int posicao, Expressao pExpEsq, Expressao pExpDir) {
expEsquerda = pExpEsq;
expDireita = pExpDir;
this.posicao = posicao;
}
override public double Execute () {
double esq = expEsquerda.Execute();
double dir = expDireita.Execute();
return esq + dir;
}
}
Um defeito do uso de chamadas recursivas é o descontrole da pilha, que pode passar do seu limite.
Uma forma mais controlada e segura de realizar esta execução é a do uso do Pattern Visitor [7] associado a uma pilha para armazenar os resultados intermediários. Neste caso, o Visitor pode indicar um erro antes do limite da pilha ser alcançado.
Execução de Funções
A execução de Funções é um pouco mais complexa do que a execução dos operadores Binário mostrada na sessão anterior.
Uma Função tem um nome e uma lista de expressões que devem ser avaliadas. Com o nome, o método Execute da classe Função deve buscar na tabela de símbolos o Delegates associado e, com este em mãos, pode executar o método. O código abaixo apresenta o código da classe Função:
public class Funcao : Expressao {
public List<Expressao> lista;
public string nome;
public Funcao (int posicao, string pnome) {
nome = pnome.ToUpper();
lista = new List<Expressao>();
this.posicao = posicao;
}
public void AddExpressao (Expressao exp) {
lista.Add(exp);
}
override public double Execute () {
Funcoes.Inicializa();
Delegates del;
if ( ! Funcoes.ListaFuncoes.TryGetValue(nome, out del)) {
throw new Exception("Função Inexistente");
}
Type t = del.GetType();
switch (t.Name) {
case "ExecutorLocal":
ExecutorLocal lf = (ExecutorLocal)del;
return lf.funcao(lista);
case "ExecutorEmSandBox":
ExecutorEmSandBox ls = (ExecutorEmSandBox) del;
return ls.funcao(lista);
}
throw new Exception("Função Inexistente");
}
}
O código do método Execute()procura no dicionário o Delegates correspondente ao nome da função para depois executá-lo. No nosso exemplo criamos duas categorias de Delegates: ExecutorLocal para chamar funções definidas localmente à aplicação; e um ExecutorEmSandbox que recebe uma lista de Expressões para cada parâmetro.
Desta forma, a implementação de uma função nova fica relativamente simples. Para isto precisamos:
| • | Criar um método que implementa uma função (ver a função Maior abaixo);
static public double Maior(List<Expressao> lista)
{
if (lista.Count != 2)
{
throw new Exception("A função Maior necessita de dois parâmetro");
}
double v1 = lista[0].Execute();
double v2 = lista[1].Execute();
if (v2 > v1) return v2;
return v1;
}
|
| • | Inserir no dicionário de Funções o nome da função e seu Delegates:
ListaFuncoes.Add("MAIOR", new ListaExecutorLocal(Maior, 2));
|
Por fim, vale a pena comentar que a implementação do conceitos de ExecutorLocal ou ExecutorEmSandbox são bons exemplos do conceito de Ponto de Extensão mencionado na sessão Metadados e Pontos de Extensão.
Execução em uma Sandbox
Sandboxes podem ser implementadas no .Net Framework utilizando um Application Domain diferente do software hospedeiro (a aplicação que chamará o interpretador/executor do código). Applications Domains implementam um ambiente auto-contido e isolado para a execução de threads .Net e códigos em diferentes Aplications Domain podem se comunicar com chamadas a métodos e passagens de parâmetro através do Remoting. Com isto, podemos criar um ambiente seguro para chamar dll’s implementadas por terceiros que implementam uma interface de execução.
A figura abaixo apresenta o esquema usado no nosso exemplo para criar um sandbox. Nele, a aplicação hospedeira tem uma entidade (AssemblyLoader) responsável pela criação do sandbox e sua classe de plug-in (ExecutorDeExpressao). Com esta plug-in, o AssemblyLoader pode pedir para que dll’s de execução (que obedecem a interface IExecutorDeDll) sejam carregadas e, para seu controle de qual já foi carregada, conta com uma tabela (dicionário) com as funções já carregadas.

A interface a ser chamada pela classe Funcao no momento da interpretação é:
public interface IExecutorDeDll {
double Execute (List<Expressao> lista, AppDomain current);
}
Exemplos de códigos que poderiam ser chamados seriam:
public class Pi : IExecutorDeDll {
public double Execute (List<Expressao> lista, AppDomain current) {
if (lista.Count != 0) {
throw new Exception("A função Pi não aceita parâmetros");
}
return 3.1416;
}
}
public class Modulo : IExecutorDeDll {
public double Execute (List<Expressao> lista, AppDomain current) {
if (lista.Count != 2) {
throw new Exception("A função Modulo requer 2 parâmetros");
}
return lista[0].Execute() % lista[1].Execute();
}
Tecnicamente, Application Domains podem ser criados com ajuda do método CreateDomain da classe AppDomain. Uma vez com uma instância de um AppDomain em mãos, é possível criar uma instância de uma classe através do método appDomain.CreateInstanceAndUnwrap(), que recebe como argumentos o nome do assembly e o nome da classe a ser instanciada (que deve herdar de MarshalByRefObject). Se este método obtiver sucesso, teremos em mãos uma instância da classe que foi passada como parâmetro e estaremos prontos para chamar um método remoto que executa em outro Application Domain.
Esta é a forma simples de criar um Application Domain, instanciar um objeto de uma classe e chamar um método deste objeto. No entanto, o ideal é garantir um cache de códigos já carregados para que não se perca tempo construindo AppDomains e classes remotas à cada chamada.
Como dito anteriormente, no código exemplo utilizamos um cachê no hospedeiro, responsável por criar o sandbox e invocar a execução. Para isto, no código exemplo, foram construídas algumas classes extras.
A primeira é a classe FunctionWrapper que tem função de armazenar o nome do arquivo que contem uma DLL, o tipo/classe a ser criado que obedece a interface IExecutorDeDll, e uma instância de uma classe que seja herança da classe ExecutorDeAssembly. As instâncias desta classe são armazenadas em cachê e são a base para acesso e controle dos códigos já carregados.
ExecutorDeAssembly é uma classe abstrata que define os métodos Load(), para carregar um tipo/classe de uma DLL, e Execute(), para executar o método Execute() como os da classe Pi ou Modulo acima. ExecutorDeAssembly não é uma interface porque ela deve herdar de MarshalByRef, o que garante que tanto Load() quanto Execute() possam ser chamados a partir do código hospedeiro. O código abaixo apresenta a implementação de FunctionWrapper e ExecutorDeAssembly:
abstract public class ExecutorDeAssembly : MarshalByRefObject {
abstract public void Load (string fileName, string typeName, AppDomain domainOriginal);
abstract public double Execute (List<Expressao> lista);
}
public class FunctionWrapper {
private string fileName;
private string typeName;
private ExecutorDeAssembly executor;
public FunctionWrapper (string p_fileName, string p_typeName, ExecutorDeAssembly p_processor) {
fileName = p_fileName;
typeName = p_typeName;
executor = p_processor;
if (executor == null)
throw new Exception("Erro na carga da dll " + fileName + " tipo " + typeName);
}
public double Execute( List<Expressao> lista ) {
if (executor != null) {
return executor.Execute(lista);
} else
throw new Exception("Erro na execução da dll " + fileName);
}
}
A classe AssemblyLoader é a classe responsável pela construção do AppDomain (caso já não tenha sido criado), da carga da DLL a ser executada remotamente (caso já não esteja em cachê) e da manutenção das instâncias da classe FunctionWrapper no cachê. Seu código encontra-se abaixo:
public class AssemblyLoader : IDisposable {
static AppDomain appDomain;
Dictionary<string, ExecutorDeAssembly> dllcache =
new Dictionary<string, ExecutorDeAssembly>();
public AssemblyLoader () {
}
void Dispose (bool disposing) {
if (appDomain != null) {
try {
AppDomain.Unload(appDomain);
}
catch { }; // ignora
appDomain = null;
}
}
~AssemblyLoader () {
Dispose(false);
}
public void Dispose () {
Dispose(true);
}
public FunctionWrapper LoadAssembly (string fileName, string typeName) {
FileInfo fileInfo = new FileInfo(fileName);
AppDomainSetup setup = new AppDomainSetup();
setup.ApplicationBase = fileInfo.DirectoryName;
setup.PrivateBinPath =
AppDomain.CurrentDomain.BaseDirectory;
setup.ApplicationName = "ExecutorDeAssembly";
setup.ShadowCopyFiles = "true";
try {
if (appDomain == null) {
appDomain = AppDomain.CreateDomain(
"ExecutorDeAssemblyDomain", null, setup);
}
ExecutorDeAssembly processor;
if (dllcache.TryGetValue(fileName + " " + typeName, out processor))
return null;
try {
if (processor == null) {
processor = (ExecutorDeAssembly)
appDomain.CreateInstanceAndUnwrap(
"AppDomainLoader",
"AppDomainLoader.ExecutorDeExpressao");
}
dllcache.Add(fileName + " " + typeName, processor);
if (! dllcache.ContainsKey(fileName)) {
processor.Load(fileName, typeName, AppDomain.CurrentDomain);
}
return new FunctionWrapper(fileName, typeName, processor);
}
catch (Exception err) {
throw err;
}
}
catch (Exception err) {
throw err;
}
}
}
Como você pode notar, o método LoadAssembly() cria uma instância da classe ExecutorDeExpressao que roda no Application Domain utilizado como sandbox. Esta classe é responsável por carregar a DLL e executá-la.
Com isto, podemos criar uma ExecutorEmSandBox responsável por executar o código no sandbox
public class ExecutorEmSandBox : Delegates {
static AssemblyLoader loader = new AssemblyLoader();
public FuncaoComListaDeParam funcao;
public ExecutorEmSandBox (string fileName, string typeName, int p_numParametros) {
numParametros = p_numParametros;
FunctionWrapper fw = loader.LoadAssembly(fileName, typeName);
funcao = fw.Execute;
}
}
e incluir as funções no dicionário a partir de algum metadado, como por exemplo uma arquivo de config que indica que dlls e funções carregar:
ListaFuncoes.Add("PI", new ListaExecutorEmSandBox("MathFunctions","MathFunctions.Pi", 0));
ListaFuncoes.Add("MOD", new ListaExecutorEmSandBox("MathFunctions","MathFunctions.Modulo", 2));
Geração de Código
A geração de código não é implementada no nosso exemplo por ser complexa demais para a nossa finalidade de introdução ao tema. Porém, basta pensar que ao invés de um método Execute() em cada nodo da nossa AST, poderíamos chamar um método GeraCódigo() capaz de:
| • | Criar o preâmbulo do operador (com declarações e inicializações); |
| • | Chamar cada um dos GeraCódigo() das sub-árvores abaixo; |
| • | Costurar os códigos retornados junto ao preâmbulo; |
| • | inserir o postâmbulo; |
Existem várias técnicas que podem ser utilizadas na geração de código. Entre elas temos:
| • | Emissão Código de Alto Nível: uma opção é gerar código em linguagem de alto nível, como C# ou VB.Net que pode ser, em seguida, compilado e executado usando o ambiente .Net (compilador e máquina virtual). A vantagem é a simplicidade do processo. A desvantagem é o tempo de compilação que tem mais um ou dois passos; |
| • | Emissão de Código IL: o Framework .Net tem classes para emissão de código IL, como a classe Emiter (ver http://msdn2.microsoft.com/en-us/library/8ffc3x75(VS.80).aspx). Neste caso, obtemos maior controle da geração, com possíveis otimizações e estrutura de código não existentes no C# ou VB.Net. Em contrapartida, teremos de implementar um código mais complexo e difícil de depurar; |
| • | Geração de código otimizado: referências como [X] e [Y] apresentam estruturas de dados e algoritmos que visam otimizar e gerar código. Neste caso, temos total controle do código gerado e máquina de destino (virtual ou hardware). No entanto, a complexidade é muito grande, exigindo especialistas raros de se achar no mercado. |
O armazenamento do código compilado é outro tema subjacente. O mais comum é guardar o executável junto com o código fonte em diretórios de arquivos ou em campos blobs no banco de dados. Junto a ele é comum encontrar dados extras como datas e números de versão que podem garantir que o código reflete de fato a última mudança no código fonte.
O código interpretado também está sujeito ao armazenamento da AST e tabela de símbolos por motivo de otimização (eliminar a faze de parsing da string original). Os mesmos cuidados observados com referência ao armazenamento de código também devem ser observados aqui.
Linguagens são um meio excelente de abstração, mas podem ainda não ser suficientemente abstratas para esconder o nível físico ou lógico dos dados.
Imagine que você escolheu VB.Net como sua linguagem, ou uma linguagem similar. Neste caso, o acesso a dados fica com uma biblioteca ou componente, como o ADO.Net. Nesta linguagem, cabe ao programador executar os comandos de banco de dados para acessar o banco de dados e, para isto, ele necessita informar nomes de tabelas e colunas. E aí está o problema: caso haja necessidade de manutenção com modificação da estrutura física da tabela, programas legados poderão não mais funcionar.
Resolver este problema significa implementar um nível conceitual, a ser usado pelo programador da customização, que deve ser mapeado como nível físico, abstraindo-o e, ao mesmo tempo, protegendo-o das mudanças futuras.
A técnica para implementar este nível intermediário de mapeamento entre conceitual e físico se baseia no uso de metadados, com dicionários, validadores, etc. Por ter de tratar também de problemas de otimização, o design desta camada costuma ser extremamente complexa.
Uma boa notícia é que a Microsoft promete prover uma camada que implementa esta abstração. O artigo “Próxima Geração de Acesso a Dados: Torne Real o Nível Conceitual” (ver ...) apresenta o plano para esta futura funcionalidade.
Este artigo apresentou um conjunto de práticas comuns usados na customização de softwares. Desde o uso de metadados para descrever configurações do aplicativo até o uso de linguagens, vários são os tipos de mecanismos que um arquiteto/projetista pode utilizar.
O código exemplo trata da implementação de uma linguagem de expressões matemáticas muito simples, mas pode ser modificado para considerar novos tipos ou sintaxe. Ele apresenta também conceitos como o de Sandbox, Ponto de Extensão e uso de Reflection e Delegates, importantes no design e implementação de aplicações mais afeitas à customização.
[1] Caching Architecture Guide for .NET Framework Applications em http://msdn2.microsoft.com/en-us/library/ms978498.aspx;
[2] Compilers: Principles, Techniques, and Tools, Alfred V. Aho, Monica S. Lam, Ravi Sethi, Jeffrey D. Ullman, Addison Wesley, 2007;
[3] Mixin layers: an object-oriented implementation technique for refinements and collaboration-based designs, Yannis Smaragdakis, Don Batory, ACM Transactions on Software Engineering and Methodology (TOSEM), Volume 11, Issue 2 ;
[4] Aspect-Oriented Analysis and Design: The Theme Approach, Siobhàn Clarke, Elisa Baniassad, Addison-Wesley Professional, 2005;
[5] lex & yacc, Doug Brown, John Levine, Tony Mason, O'Reilly Media, Inc., 1995;
[6] Managed Babel System Essentials http://msdn2.microsoft.com/en-us/library/bb165963(VS.80).aspx ;
[7] Design Patterns: Elements of Reusable Object-Oriented Software, Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Addison-Wesley Professional, 1995;