Serviços

Com o desenvolvimento dos produtos da MAPS, surgiu a necessidade de uma forma ágil de testar os cenários de negócio e permitir que os analistas utilizem uma ferramenta a qual eles estejam familiarizados para descrever casos de uso. No caso de Mercado Financeiro, eles estão acostumados a trabalhar com planilhas eletrônicas. Assim, criou-se também necessidade de uma DSL (Domain Specific Language) capaz de definir cenários de negócios detalhados.

DSL (Domain Specific Language)

Uma DSL tem o objetivo de resolver problemas em um domínio particular e não se destina a ser capaz de resolver os problemas externos ao seu âmbito. O principal domínio para uma DSL é a área de negócio. As principais vantagens de uma DSL são:

  • permitem soluções expressas na linguagem e no nível de abstração do domínio do problema. A idéia é de que especialistas em domínio possam entender, criar e validar regras de negócio;
  • Código auto-documentado.

Ideia

A ideia principal era criar uma DSL utilizando planilhas eletrônicas que definissem “comandos” compostos de ações e parâmetros que permitissem testar cenários de negócio. Cada “comando” é denominado “Serviço”, e devem existir serviços para a ativação de todas as funcionalidades do sistema, permitindo a comunicação entre usuários e o sistema. Os serviços permitem a utilização de planilhas para construir cenários de teste de negócio, facilitando o trabalho do analista. A estrutura básica de um “comando” é mostrada a seguir:

../../_images/Exemplo-planilha.png

As ações nada mais são do que convenções definidas por enums, sendo as mais comuns:

  • INCLUIR
  • EXECUTAR
  • EXCLUIR
  • BUSCAR
  • PROCESSAR
  • VALIDAR

O Nome pode ser qualquer nome do serviço definido pelo usuário, sendo usualmente usado o nome de uma entidade ou ação, de forma que a ação seja o mais idiomática possível. Exemplos:

  • INCLUIR Usuário
  • EXCLUIR Usuário
  • EXECUTAR EnvioNewsletter

Os parâmetros são os parâmetros do serviço necessários para a execução da ação. Pode-se referenciar outras entidades nos parâmetros. Exemplos:

  • Data
  • Quantidade
  • Item

Anotações

Uma classe que deseja ser um servico deve conter (em pelo menos alguma superclasse) a anotacao @ServiceImplementor:

//apenas os valores obrigatórios estão exibidos
public @interface ServiceImplementor {
        ActionsEnum action(); // a ação do serviço
        String serviceName() default "";
}

O atributo serviceName() é opcional e serve para sobrescrever o nome do serviço. Caso não seja especificado, a API usará o nome da classe, removido o ‘Service’ Adicionalmente, a classe deve conter o método de execução, anotado por @Execution.

@Execution
public void executaAlgo() throws MinhaExcecao { .... }

Além das planilhas eletrônicas, existe outra interface via XML:

<import>
        <services name="Some services">
                <service name="SomeServiceName" action="EXECUTE">
                        <param name="Parameter 1">Some value</param>
                        <param name="Parameter 2">Some other value</param>
                </service>
        </services>
</import>

Injeção de dependências

As dependências de um serviço podem ser estáticas (que não dependem de dados da planilha) ou dinâmicos (dados recebidos da planilha).

Dependências estáticas

Dependências estáticas são declaradas como parâmetros de construtores:

@ServiceImplementor(action = ActionsEnum.INCLUIR)
public class MeuService {
        public MeuService(@Injected BaseDAO<Produto> produtoDao, @Inject ProdutoController controller) { .... }
}

ou parâmetros do método de execução de serviços:

@Execution
public void executa(@Injected BaseDAOFactory factory) { .... }

ou, mais comumente, em métodos ‘setter’:

@Injected
public void setController(ProdutoController controller) { this.controller = controller; }

Se alguma dependência não estiver disponível ou ainda o tipo genérico não estiver completamente fixo, a API lança IllegalArgumentException com a causa.

Dependências dinâmicas

As dependências dinâmicas são expressas pelas anotações a seguir. Apenas para lembrar, menos no caso do @AllProperties, as anotações podem ser colocadas em métodos setters com o tipo desejado - incluindo Persistable’s com @NaturalKey, caso em que o valor do campo correspondente será utilizado para procurar a entidade com a natural key.

  • @Input

Input denota parâmetos simples que devem ser pegos da planilha. A tipagem do método anotado é respeitada para os tipos que o AbstractService conseguia converter - a saber, Date, String, enums, Boolean, Integer, Long, BigDecimal, Double e Persistable, quando o baseDAOFactory está setado no ServiceRuntime. Esta anotação possui dois campos:

  • String fieldName(): o nome do campo cujo valor será utilizado
  • boolean required() default true: se esta propriedade é obrigatória para a execução do serviço.
@Input(fieldName = "nome da coluna ")
public void setNome(String nome) {...}

//note que o nome do método é irrelevante. Note também o required = false, denotando propriedades opcionais
@Input(fieldName = "dataCobranca", required = false)
public void dataCobranca(Date data) { ... }

// fazendo 'wrap' em um mapa para pegar o nome da coluna
@Input(fieldName = "Data")
public void mapa(Map<String, Date> mapa) { ... }
  • @Parameter

Parecido com @Input, mas para ser utilizado como parâmetro em métodos ou construtores. Parâmetros anotados com @Parameter são sempre considerados obrigatórios.

@Execuction
public void executa(@Parameter("Data Vencimento") Date dataDeAlgo) { ... }
  • @WithPreffix e @WithRegex

Para coletar valores de várias colunas cujos nomes obedeçam a um prefixo dado ou a uma expressão regular. Serve tanto para métodos quanto para parâmetros do método de execução e construtores.

@WithPreffix(preffix = "Data")
public void setAlgumasDatas(List<Date> datas) { ... }
@WithRegex(regex = PARAMETERS, groupIndex = 1)
public void setParameters(Map<String, String> parameters) { ... }
  • @AllParameters

Para coletar todos os parâmetros. Apenas neste método, os tipos não são respeitados (este método deve ser usado para Map<String,Object>).

Tipos genéricos

Como pôde ser visto anteriormente, o sistema é capaz de lidar bem com tipos genéricos, desde que eles estejam instanciados nas classes que a utilizam - mesmo que seja com o odiado ‘wildcard’ ?.

@WithPreffix(preffix = "numero")
public void setListaNum(List<? extends Number> numeros) { ... }

Tipos genéricos declarados em superclasses mas utilizados em subclasses também podem ser utilizados:

public class SuperClass<E> {

        @Input
        public void setInstance(E e) {...}

        @Input
        public void setLista(List<E> listaE) {...}

}

//na classe filha, os valores são convertidos corretamente para Integer
@ServiceImplementor(action = ActionsEnum.ALTERAR)
public class ClasseFixa extends SuperClass<Integer> {

}

Saída de parâmetros

Para parâmetros disponibilizarem objetos para outros serviços. A primeira forma de disponibilizar objetos para outros serviços era feita através do método em AbstractService:

protected void saveReference(String key, Object reference) {... }

porém, os serviços precisavam fazer algo do tipo:

//este método busca o valor do campo ID e faz o 'bind' do benchmark para este valor.
this.saveReference("ID", benchmark);

A anotação @Output deve ser colocada em métodos que não recebem nenhum parâmetro e devolve algo diferente de ‘null’:

//da mesma maneira, esta anotação vai exportar o benchmark usando como nome o valor do campo ID
@Output(propertyName = "ID")
public Benchmark getBenchmark(){ return this.benchmark; }

Este método será invocado após a invocação do serviço propriamente dito (e apenas se este for bem sucedido) e seu retorno (mesmo que ‘null’) ficará disponível para serviços subsequentes. Mais útil, porém, é o seu uso em conjunto com @Execution

@Output(propertyName = "ID")
@Execution
public Benchmark executa() { .... }

O próprio método de execução pode devolver algo que ficará disponível para serviços subsequentes. Ainda pode ser desejável (em casos não muito comuns) implementar Service diretamente. O comum e recomendado é utilizar POJOs e anotações, mas também é possível implementar Service em casos em que seja necessário um controle muito fino ou incomum dos parâmetros.

Validação

Os serviços podem definir métodos para validação dos estados do serviço antes deles serem executados. Para isso, os métodos de validação devem ser anotados com @Validation:

@Validation
public void validaEstado() throws EstadoInvalidoException {
}

a invocação do método

public List<String> validate()

do serviço invocará os métodos marcados com a anotação e as exceções VERIFICADAS do método serão consideradas erros de validação e sua ‘message’ será adicionada à lista de validações. Se o serviço quiser que exceções de runtime também sejam consideradas erros de validação (normalmente as exceções são simplesmente propagadas), modifique a anotação:

@Validation(validateRuntimeException = true)

public void validaEstado() throws EstadoInvalidoException {
}

porém isto é altamente não recomendável, pois erros de programação poderão ser vistos como erros de validação (por exemplo, NullPointerException). É, porém, perfeitamente aceitável o seu uso em serviços de teste. Por último, perceba que não é necessário validar atributos marcados como obrigatórios - estes são automaticamente validados.

Execução do serviço

Por último e talvez o mais importante, temos a anotação @Execution. Ela serve para marcar o método que de fato executa o serviço. Esta anotação pode estar presente em superclasses, mas a anotação mais ‘rasa’ - ou seja, a mais baixa na hierarquia da classe do serviço - irá sobrescrever a anotação em superclasses. Seu uso tradicional compreende:

@Execution
public void executa() {....}

porém, combinado com as anotações anteriores, temos um serviço que pode ser escrito com apenas um método:

@ServiceImplementor(action = ActionsEnum.ALTERAR)
public class MeuServico {
             @Execution
             @Output(propertyName="ID")
             public Benchmark alteraNome(@Parameter("benchmark") Benchmark bench, @Parameter("nome") String nomeBench) {
                     bench.setNome(nomeBench);
                     bench.getPersister().save();
                     return bench;
             }
}

Exemplos

Serviço Soma

Neste exemplo, deseja-se criar um serviço que soma dois números. O serviço mínimo pode ser construído a partir da criação da classe e anotações:

@ServiceImplementor(action = ActionsEnum.EXECUTAR)
public class SomaService() {

        private int num1;

        private int num2;

        @Execution
        @Output(propertyName = "Resultado")
        public int executaSoma() {
                return this.num1 + this.num2;
        }

        @Input(fieldName="Número 1", required = true)
        public void setNum1(int num1) {
                this.num1 = num1;
        }

        @Input(fieldName="Número 2", required = true)
        public void setNum2(int num2) {
                this.num2 = num2;
        }
}

Neste caso, os números poderiam ser parâmetros do construtor, devendo ser anotados com @Parameter:

public SomaService(@Parameter("Número 1") int num1, @Parameter("Número 2") int num2) { ... }

Serviço Soma Múltiplos Parâmetros

Neste exemplo, deseja-se que o serviço faça a soma de n números. Considerando que os parâmetros na planilha comecem com “Número”, pode-se usar a anotação @WithPreffix para obter uma lista dos números, iterá-la somando seus elementos:

@ServiceImplementor(action = ActionsEnum.EXECUTAR)
public class SomaService() {

        private List<Integer> listaNumeros;

        @Execution
        @Output(propertyName = "Resultado")
        public Integer executaSoma() {
                Integer soma = 0;
                for (Integer elem : this.listaNumeros) {
                        soma += soma + elem;
                }
                return soma;
        }

        @WithPreffix(preffix = "Número ")
        public void setListaNumeros(List<Integer> listaNumeros) {
                this.listaNumeros = listaNumeros;
        }
}

Considerando que os parâmetros a serem somados são os parâmetros que comecem com “Número” seguidos de um número e existam outros parâmetros que comecem com “Número” seguidos de números e caracteres que não devem ser somados, a anotação @WithPreffix não servirá. Neste caso, a melhor forma é utilizar a anotação @WithRegex passando a regex dos parâmetros a serem somados:

@WithRegex(regex = "Número \\d+")
public void setListaNumeros(List<int> listaNumeros) {
        this.listaNumeros = listaNumeros;
}

Serviço Calculadora

Deseja-se criar um serviço que faça o cálculo de dois números utilizando uma Calculadora (soma, subtração, multiplicação ou divisão) e validar o divisor no caso de uma operação de divisão. Como a Calculadora é uma dependência estática, ela é injetada usando a anotação @Injected. Para a validação do divisor, é utilizada a anotação @Validation no método de validação, indicando que deve ser executado antes da execução do serviço.

@ServiceImplementor(action = ActionsEnum.EXECUTAR)
public class CalculadoraService() {

             private TipoOperacao tipoOp; // Enum que define o tipo de operação

        private Calculadora calc;

             private double num1;

             private double num2;

             @Execution
             @Output(propertyName = "Resultado")
             public double executaCalculo() {
                     return calc.calcular(this.tipoOp, this.num1, this.num2);
             }

        @Injected
        public void setCalc(Calculadora calc) {
                this.calc = calc;
        }

        @Validation
        public void validaDivisor() { // Em uma divisão, valida que o divisor é maior que zero
                if (this.tipoOp.isDivisao() && this.num2 <= 0.0) {
                        throw new DivisorInvalidoException();
                }
        }

             @Input(fieldName="Número 1", required = true)
             public void setNum1(int num1) {
                     this.num1 = num1;
             }

             @Input(fieldName="Número 2", required = true)
             public void setNum2(int num2) {
                     this.num2 = num2;
             }

        @Input(fieldName="Tipo de Operação", required = true)
             public void setTipoOp(TipoOperacao tipoOp) {
                     this.tipoOp = tipoOp;
             }
}

Implementação

A implementação inicial de Serviços exigia que um serviço implementasse uma interface Service. A interface exigia a implementação de métodos conversores que deviam converter os parâmetros no formato String para o tipo correto. Isso acabava gerando código duplicado e classes abstratas sem sentido. Também não era possível descobrir quais parâmetros eram obrigatórios sem olhar o código-fonte. Havia uma documentação que descrevia os parâmetros obrigatórios, porém a documentação ficava descasada com o código conforme a evolução e mudanças de regras de negócio. Dentro da MAPS, foi criado um plugin do maven de mapeamento dos serviços e suas dependências obrigatórias, mantendo a documentação sempre atualizada com o código-fonte. Com a nova implementação, usando anotações, não é mais necessário implementar Service. Bastar usar a anotação @ServiceImplementor passando a ação desejada:

@ServiceImplementor(action = ActionsEnum.INCLUIR)

Há uma implementação de Service que delega para um POJO (Plain Old Java Object). Ela possui um mecanismo de conversão de tipo embutido para converter String para o tipo do campo declarado, além de possuir diversos conversores padrões, permitindo também a implementação de novos conversores.

Leitura de planilha

Um passo intermediário na execução de uma planilha é a leitura da planilha. A representação de uma planilha é composta de três elementos: o arquivo XLS em si, suas abas e os serviços a serem executados de cada aba.

ServicePageHeader

Representa um arquivo XLS, guardando uma coleção de suas abas e o número total de serviços. Cada aba, chamada página, é representada por uma ServicesPageBean.

ServicesPageBean

Representa uma aba da planilha, contendo o nome da aba e sua lista de serviços, representados por ServiceBeans. Uma ServicesPageBean é o que será, de fato, executado por um executor, denominado ServicesPageExecutor.

ServiceBean

O ServiceBean é responsável por guardar informações da planilha como:

  • nome do serviço: nome do serviço sendo executado
  • ação: ação do serviço (incluir, executar, etc)
  • mapa de parâmetros: mapa de String para Object: alguns parâmetros não precisam de conversão, como double e data, evitando perda de precisão
  • linha: localização de onde se encontra o serviço na planilha. Usado para fins de log
  • comentário: as células podem ter comentários (começam com o caracter especial #)

ServicesPageExecutor

Responsável por executar uma lista de ServicesPageBean, cuida da parte transacional, log e rollback. Para executar a lista de ServicesPageBean, ele precisa de alguns parâmetros que ditarão políticas de como os serviços serão executados. Existem três políticas de execução: Política Transacional, Política de Validação e Continuar Após Falha:

ServiceFactory

O objetivo do ServiceFactory é instanciar e retornar um serviço executável de acordo com a ação, nome e ServiceProperties do serviço desejado. É responsável também por registrar todos os serviços implementados, sabendo obter o serviço correto.

Política Transacional

Define quando e como será necessário o uso de transações na execução de serviços:

  • Por página: o executor cria uma transação por página, mais transações exclusivas para serviços transacionais
  • Estritamente por página: o executor cria uma, e apenas uma, transação por página, independente dos serviços contidos
  • Por serviço: o executor executa cada serviço em uma transação exclusiva
  • Quando necessário: o executor cria o mínimo de transações, sendo criadas transações extras apenas quando ocorrem serviços transacionais
  • Nunca: o executor nunca cria transações

O comportamento default é criar transações quando necessário. O ServicesPageExecutor cria um grande bloco de execução isolando os serviços que precisam de transação. Caso ocorra uma falha em uma transação, ela deve sofrer rollback.

Política de Validação

Define estratégias para validação de serviços:

  • Antes de qualquer execução: a validação deve ser feita antes de qualquer execução. Caso seja encontrado erro, nada deve ser executado
  • Valida a execução por bloco: a validação deve ser feita antes da execução de cada bloco. Caso seja encontrado erro, o bloco não deve ser executado

Continuar Após Falha

Define se a execução deve continuar após uma falha. Há situações em que não faz sentido a execução continuar se uma das execuções falhar e existem situações que a execução de outros serviços podem ocorrer independemente do status da execução de outros serviços.

Diagrama

../../_images/Diagrama-services.png

ServiceProperties

Contexto de execução que permite a passagem de parâmetros por referências entre os serviços. O serviço pode deixar algo como output para o próximo serviço. A referência é guardada no contexto pai para que todos possam fazer referência a ele.

ServiceImplementorProxy

Dado algumas informações, o ServiceFactory precisa devolver a implementação correta de Service desejada. Para isso, existe o ServiceImplementorProxy que faz a ponte entre um serviço implementado com anotações e o sistema de serviços habitual. Seu atributo chave é o ServiceImplementorAccessor que obtém o serviço, injeta suas dependências, executa os métodos de validação e executa o serviço, resolvendo parâmetros para a execução do serviço, além de colocar os parâmetros de saída no escopo passado.

ServiceImplementorAccessor

São helpers que geram código de máquina que implementam acesso aos construtores, aos métodos de injeção de dependência, aos executores, etc para não precisarem de Reflection. Ele gera bytecode que chama o método sem precisar buscar construtores e fazer chamadas via Reflection. Uma das responsabilidades do ServiceImplementorAccessor é tentar criar uma instância e injetar suas dependências se possível. Ele utiliza um InstanceCreator, responsável por hidratar as dependências e construir o serviço e um DependencyInjector para injetar as dependências. Caso a execução do serviço tenha algum retorno, ele possui um extrator que obtém o nome do parâmetro de saída (@Output do método de execução) e extrai o output do serviço. No caso de serviços que têm estado, ele pode armazenar atributos do serviço no output e obtê-los através de getters de output. Um serviço pode fazer output de vários objetos diferentes e o extrator é responsável por obter as várias saídas. As saídas são armazenadas em um escopo que serão enviadas ao contexto pai para que fiquem disponíveis para outros serviços.

Evolução dos Serviços

Inicialmente, os serviços eram adicionados usando SQL (Structured Query Language). Naturalmente, a descrição de um serviço evoluiu para postscript em formato XML (Extensible Markup Language), mas nada impede que seja criado um driver para converter um JSON (JavaScript Object Notation) ou outro formato em ServiceBean. O objetivo principal de Serviços era conseguir executar uma planilha de teste, mas percebeu-se que a estrutura de ServiceBean permitia que ela fosse instanciada e executada em um trecho de código, tendo a vantagem de ser rastreável. No caso de importação de um arquivo, os serviços são usados para fazer a importação. Cada serviço é guardado como um item do log de execução, permitindo a consulta em tela do resultado de um serviço já traduzidos em uma DSL. Para o Hydra, é uma boa prática traduzir em serviços um script Hydra de um sistema legado, permitindo a rastreabilidade e diminuindo o acoplamento, já que a lógica concentra-se nos serviços.

Exportação

O objetivo inicial da exportação nasceu da necessidade de se construir um serviço para uma entidade existente no banco para que ela pudesse ser incluída novamente em outro sistema. Mais tarde, percebeu-se que a exportação também poderia ser usada para qualquer objeto além de entidades. Representada pela interface EntityExporter, ela gera uma lista de ServiceBeans que descrevem serviços para a inclusão de um objeto. Ela pode também retornar a lista de objetos que devem ser exportados antes da exportação do objeto dado, no caso de dependências, por exemplo. Pode-se também retornar a lista de objetos que devem ser exportados depois da exportação do objeto. Em geral são associações à entidades fortes e entidades fracas. A ideia inicial dos exportadores era conseguir extrair um cenário de teste do ambiente de homologação ou produção. Para isso, era necessário criar exportadores para quase tudo do sistema que pudesse ser exportado. Porém, manter os exportadores é difícil e seu uso nesse sentido têm diminuído. Entretanto, percebeu-se que o uso de exportadores agregaria muito valor na troca de versão do sistema, surgindo a exportação de postscript.

Exportação de postscript

Havia um grande trabalho na criação de alters triviais por conta das milhares entradas de postscripts. A troca de versão do sistema exigia a criação de alters gigantes. A manutenção disso tudo era complicado, então criou-se o pacote de exportação. Um pacote de exportação é basicamente um arquivo zip contendo, principalmente, arquivos de scripts e XMLs de serviços que serão importados pela aplicação. A criação de pacotes de exportação permitiu que os postcripts fiquem fora do alter, diminuindo consideravelmente o tamanho do alter.

Empacotamento

A ideia do empacotamento é ter vários populadores de pacote. Cada populador obtém entidades do banco e exporta usando a própria infra de exportadores, gera serviços e guarda em um arquivo zip. Assim, é possível extrair de uma base de dados todo postscript da aplicação. Subindo a base com H2, o plugin refdb do Jmine já faz a exportação do zip com todos os metadados da aplicação, tendo a vantagem de não depender de um banco de dados. Um pacote de dados exportação é definido pela interface Pack. É possível adicionar elementos, definidos por um PackElement, e obter os elementos de um pacote. Cada PackElement contém o tipo do arquivo, seu caminho e o arquivo em si. Um populador de pacotes é definido pela interface PackPopulator. Sua função é popular um pacote de exportação a partir de um PopulatorDataSource. Um PopulatorDataSource pode ser populadores de várias entidades ou objetos do sistema, como grupos de propriedades, sistemas Hydra e Scripts. Há um artigo detalhando o uso de pacote de dados.

Testando Serviços

Existem três formas básicas para testar serviços:

  • Usando AbstractServiceTest: Válido para testar comportamentos de serviços. Verifica comportamento esperado quando um serviço é executado. Possui hooks de pré-execução e pós-execução. Na pré-execução, prepara-se os pré-requisitos para a execução do serviço. Na execução, basta informar os ServiceBeans que serão executados. Na pós-execução, verifica-se se os efeitos colaterais foram executados. Por exemplo: em um serviço de inclusão, verifica-se se a entidade foi incluída.
  • Usando o ServiceRunner: Providencia a execução de uma pequena lista de serviços, com finalidade de facilitar a elaboração de testes integrados de serviços, permitindo definir os parãmetros de execução, desabilitar transações, etc. A execução de um serviço retorna as propriedades do parent, facilitando sua validação após a execução do serviço no caso do serviço retornar alguma saída.
  • Testes unitários: Por ser apenas um POJO, o teste unitário de um ServiceBean é facilitado. Para se criar um teste unitário de ServiceBean, basta instanciá-lo e executá-lo. Testes podem ser escritos sem criar ServiceProperties, ServiceController, etc. Como as dependências estão todas declaradas como métodos públicos (incluindo o método de execução), basta que os testes instanciem o serviço, injetem as dependências (estas etapas podem ser feitas, por exemplo, por uma config do spring) e executá-los, verificando os estados após a execução.

Ainda não existem testes de sanidade das anotações de um serviço. Por exemplo, seria útil validar que:

  • o serviço tenha a anotação @ServiceImplementor
  • pelo menos um método possua a anotação @Execution
  • que todos parâmetros do método anotado com @Execution sejam anotados com @Injected ou @Input
  • os setters de dependências tenham anotação @Injected ou @Input