BT

Disseminando conhecimento e inovação em desenvolvimento de software corporativo.

Contribuir

Tópicos

Escolha a região

Início Artigos Os frameworks Java na era Cloud Native Java/Jakarta: Dicas, desafios e tendências

Os frameworks Java na era Cloud Native Java/Jakarta: Dicas, desafios e tendências


O Java vem evoluindo bastante aos longos dos seus 25 anos, principalmente na performance, na integração com os containers e também devido ao fato de sempre estar atualizando-se a cada seis meses. Mas, e como andam os frameworks Java nessa nova era, onde novas demandas de arquitetura de software aparecem, como a atual cloud-native? Quais são os desafios que eles enfrentarão para manter o Java vivo por mais 25 anos? O objetivo desse artigo é falar um pouco do histórico e os desafios para as novas gerações e as tendências dos frameworks que, como desenvolvedores Java, utilizaremos em nosso dia a dia.Um pouco da história

Para entender os movimentos atuais, é importante entender um pouco tanto da história da computação quanto da arquitetura de software. Vamos falar sobre a relação do homem com a máquina, ponto importantíssimo, onde podemos dividir em três etapas:

  • Uma máquina para muitas pessoas: A era do mainframe. Momento da história onde um único computador era gerido por várias pessoas.
  • Uma máquina para uma pessoa: A geração PC ou computador pessoal. Nesse período, nasceu os computadores de mesa e os sistemas operacionais.
  • Muitas máquinas para muitas pessoas: Atualmente a quantidade de dispositivos é muito maior, por exemplo, é muito comum uma única pessoa ter um smartwatch, celular, notebook, e utilizá-los simultaneamente.

A abundância de máquinas também impactou na arquitetura de software, principalmente, no número de camadas físicas:

  • Uma única camada: Relacionada com o mainframe, onde o computador é responsável tanto pelo processamento como pelo banco de dados, sendo a interação, no próprio computador.
  • Três camada físicas: O modelo mais utilizado atualmente. É bem semelhante a arquitetura clássica de MVC, mas para camadas físicas. Neste modelo, temos um servidor para o banco de dados, um para a aplicação e o cliente acessa tudo em outro dispositivo, seja um computador, celular, tablet ou smartwatch.
  • A era de microservices: O preço baixo dos computadores ajudou a aumentar a quantidade de camadas físicas. É muito comum uma única requisição lidar com vários servidores, seja uma orquestração com microservices , ou com servidores usando balanceadores de carga.

Um ponto importante é que o ambiente cloud, em conjunto com a automação, acelerou diversos processos, desde o número de camadas físicas até o número de deploys. Por exemplo, houve um momento na história onde o normal era um deploy por ano. Com a metodologia Agile, a cultura de integrar o setor de desenvolvimento com o de operações e muitas outras técnicas, fizeram essa frequência aumentar para uma vez por mês, por dia a até mesmo vários deploys diários, inclusive, sexta-feira às 18h.

Qual o problema do Java?

Com as evoluções das arquiteturas para um ambiente focado na cloud, deploys constantes e automatizações como o CI/CD, diversos requisitos mudaram e consequentemente vieram as diversas reclamações do Java, incluindo:

  • Inicialização: Certamente, a maior reclamação. Comparando com outras linguagens o tempo de inicialização é altíssimo;
  • Cold start: O JIT é um recurso maravilhoso que permite várias melhorias no código em tempo de execução, entretanto, o que acontece com a aplicação que realiza constantes deploys? O JIT não tão crítico quanto uma inicialização melhor.
  • Memória: Certamente, a JVM ainda é uma das poucas linguagens que podem desfrutar de uma thread native, porém, isso tem um grande problema quando comparado ao Green Thread, que está relacionado ao consumo de memória. Este é um dos motivos pelo qual utilizamos os Pools de Threads. Porém, os recursos da thread nativa fazem sentido em um ambiente de programação reativa? Um outro ponto que requer bastante recurso de memória são os JIT (Just in time compiler) e GC(Garbage Collector) que atuam também em diversas threads, mas, precisamos desse recurso quando trabalhamos com em serverless? Afinal, ele tende a executar uma única vez e posteriormente o recurso será retornado para o sistema operacional. Por questão de curiosidade, o Java está trabalhando no conceito de Thread Virtual.

Apesar disso, o Java ainda continua sendo uma linguagem popular, principalmente, quando o assunto é performance e desempenho. É comum vermos empresas de tecnologia que saem de outras linguagem e migram para o Java como a Spotify que deixou de lado o Python e migrou para o Java, justamente para ter mais performance em suas aplicações. Um outro ponto importante está nas aplicações de Big Data, onde boa parte delas são desenvolvidas em Java como Hadoop, o banco de dados NoSQL Cassandra e, o índice de textos Lucene.

Olhando mais de perto para os códigos das aplicações corporativas, em média 90% do código dos projetos pertencem a terceiros, ou seja, quase todo o código de uma aplicação Java é feita por frameworks.

Os metadados no mundo Java

Mas por que utilizamos tantos frameworks? Uma resposta simples seria, a grande maioria dessas ferramentas facilitam o processo de alguma forma, sobretudo, em uma conversão ou um mapper. Por exemplo, quando se converte as entidades Java para os arquivos XML ou banco de dados.

O objetivo é tentar diminuir a impedância entre os paradigmas. Por exemplo, diminuir a distância dos bancos de dados relacionais e a orientação a objetos, sem falar nas boas práticas onde o Java trabalha com camelCase e os bancos de dados relacionais trabalham com snake_case. Uma das estratégias para fazer este serviço é usando a criação dos metadados. São esses metadados que garantem que façamos a relação entre o banco de dados com uma classe Java. Por exemplo, é muito comum nas primeiras especificações do mundo Java trabalharmos com esse tipo de abordagem como o orm.xml realizado pelo JPA.

Por exemplo, na criação de uma entidade que representa os deuses da mitologia grega, a classe normalmente ficaria desta maneira:

public class God {

  private String id;

  private String name;

  private Integer age;

  //getter and setter

}

O próximo passo seria criar um arquivo XML para fazer a relação entre a classe Java e as instruções para o mapeamento no banco de dados. Esse arquivo será lido em tempo de execução para gerar o metadados também no mesmo momento.

<entity class="entity.God" name="God">
     <table name="God"/>
     <attributes>
         <id name="id"/>
         <basic name="name">
             <column name="NAME" length="100"/>
         </basic>
         <basic name="age"/>
     </attributes>
</entity>

Esses metadados são lidos e gerados em tempo de execução e são esses dados que farão a parte mais dinâmica dentro da linguagem. Porém, essa geração de dados seria inútil caso não existe o recurso que permita que se exista modificações em tempo de execução. Tudo isso é possível graças a um recurso da ciência da computação que dá a habilidade de processar, examinar e realizar a introspecção dos tipos das estrutura de dados. Estamos falando do Reflection. O recurso de Reflection, diferente do que muitos desenvolvedores Java pensam, não é um recurso exclusivo dentro desta linguagem, existindo também nas linguagens GO, C#, PHP e Python. No mundo Java o pacote para realizar as APIs de Reflection existe desde a versão 1.1. Esse recurso permite a criação de ferramentas ou frameworks genéricos, no qual aumentam a produtividade. Porém, muitas pessoas tiveram problemas com essa abordagem.

Com evolução da linguagem e de todo ecossistema Java, os desenvolvedores notaram que fazer com que esses metadados gerados muito distante do código (como até então era feito com os arquivos XML), muitas vezes, não era intuitivo, sem falar no aumento da dificuldade na hora de realizar manutenção, pois era necessário a alteração em dois locais, por exemplo, quando se precisava atualizar um campo, era necessário alterar a classe Java e o banco de dados.

Com o intuito tornar isso algo ainda mais fácil, uma das melhorias que aconteceram dentro do Java 5 em meados de 2004 foi a Metadata facility for Java JSR 175, conhecida carinhosamente pelos íntimos como notações do Java. Por exemplo, voltando a falar sobre a entidade anterior, não seria mais necessário um segundo arquivo para a configuração. Todas as informações ficariam juntas em um único arquivo.

@Entity
public class God {

  @Id
  private String id;

  @Column
  private String name;

  @Column
  private Integer age;

}

Certamente a combinação do reflection com as notações trouxeram vários benefícios para o mundo das ferramentas Java ao longo dos anos. Existem várias vantagens das quais podemos destacar a conectividade, ou seja, uma vez que toda a validação acontece em momento de execução é possível adicionar bibliotecas que funcionarão no estilo "plug and play". Afinal, quem nunca ficou maravilhado com o ServiceLoader e todas as "mágicas" que podem ser feitas com ele?

O reflection também garante um lado mais dinâmico na linguagem. Graças a esse recurso, é possível fazer um encapsulamento muito forte, como criar atributos sem getter e setter que em execução tudo isso será resolvido com o método setAccessible.

Class<Good> type = ...;
Object value =...;
Entity annotation = type.getAnnotation(Entity.class);
Constructor<?>[] constructors = type.getConstructors();
T instance = (T) constructors[0].newInstance();
for (Field field : type.getDeclaredFields()) {
    field.setAccessible(true);
    field.set(instance, value);
}

Até mesmo o Reflection tem problemas

Como toda decisão e escolha de arquitetura existem os impactos negativos. O ponto importante neste caso é que até mesmo o uso de Reflection traz alguns problemas para o as aplicações que utilizarão esses frameworks.

O primeiro ponto é que seu maior benefício vem em conjunto com os malefícios do processamento em tempo de execução. Dessa forma, o primeiro passo que uma aplicação precisa fazer é justamente executar as informações dos metadados, de modo que a aplicação só ficará funcional quando todo o processo terminar. Imagine um container de injeção de dependência, que como CDI, precisará escanear as classes para verificar os respectivos contextos e dependências. Esse tempo tende a aumentar bastante com a popularização do ecossistema do CDI graças ao CDI Extension.

Além do tempo de iniciar uma aplicação, graças ao processamento em tempo de execução, existe também o problema de consumo de memória. É muito natural que os frameworks, com o intuito de evitar constantes acesso as APIs reflections, criem sua própria estrutura de memória. Porém, além desse consumo de memória ao utilizar qualquer recurso que precisa do reflection dentro de uma instância class ele carregará todas as informações de uma única vez dentro da classe ReflectionData como software reference. Caso não esteja familiarizado com as referências no Java, é importante dizer que o SoftwareReference só será elegível para o GC quando não tiver memória suficiente dentro do Heap.

Ou seja, um simples getSimpleName precisará carregar todas as informações da classe.

//java.lang.Class
    public String getSimpleName() {
        ReflectionData<T> rd = reflectionData();
        String simpleName = rd.simpleName;
        if (simpleName == null) {
            rd.simpleName = simpleName = getSimpleName0();
        }
        return simpleName;
    }

Com base nessas informações, ao utilizar a combinação de Reflection com as notações em tempos de execução, teremos tanto problema no processamento como também ao iniciar uma aplicação quando analisamos o consumo de memória. Cada framework tem o seu propósito e, como trabalhar com essas instâncias em Java o CDI faz buscas dos beans para definir os escopos e o JPA para realizar o mapeamento entre Java e o banco de dados relacional, é natural que cada um realize o seu próprio processamento e tenha os metadados de acordo com o seu propósito.

Esse tipo de design não é um problema até o presente momento, porém, como já mencionamos, a maneira como estamos fazendo software vem mudando de maneira bastante drástica da programação ao deploy. Era muito comum realizar o deploy numa janela de processo onde se criava um período de ociosidade de sistema, normalmente durante a madrugada, a ponto do processo de "aquecimento dos motores" nunca ser um problema. Porém, com diversos deploys ao longo do dia, a escalabilidade horizontal de maneira automática, e demais mudanças no processo de desenvolvimento de software, fizeram com que o tempo de inicialização para algumas aplicações entrasse como requisito mínimo.

Um outro ponto que vale a pena ser citado, certamente, está relacionado a era cloud. Atualmente, o número de serviços que a cloud fornece cresceu de forma drástica. Dentre elas está o serverless, já que a abordagem atual não faz sentido em um processo que será executado uma única vez para então ser destruído. O Graeme Rocher fala desses desafios no mundo do Java framework em uma das suas mais famosos palestras para introduzir o Micronaut.

Além dos problemas de demora para carregar tudo para a memória e a latência na inicialização com relação ao Reflection, existe também um outro problema, a velocidade e a performance da execução comparado um código nativo, além do uso do Reflection deixar a solução mais lenta que um código gerado. Por mais que existam diversas otimizações dentro do mundo do JIT, trabalhar ele faz com que esteja cada vez melhor, porém essas otimizações ainda são muito tímidas quando comparadas com um código nativo.

Uma opção para melhorar as otimizações e garantir as validações dinâmicas dentro dos frameworks e da geração de classes que fazem esse tipo de manipulação no momento de execução, seria ler as informações das classes via Reflection, mas ao invés de manipular as informações através dele, o objetivo seria criar classes em momento de execução para que elas façam essa tarefa. Em outras palavras, durante o tempo de execução ler as informações das notações e criar classes que realizam tais manipulações.

Esse tipo de manipulação é possível graças a JSR 199, fazendo que essas classes de manipulação deem uma otimização mais forte para o JIT.

JavaSource<Entity> source = new JavaSource() {
    @Override
    public String getName() {
        return fullClassName;
    }

    @Override
    public String getJavaSource() {
        return source;
    }

};
JavaFileObject fileObject = new JavaFileObject(...);
compiler.getStandardFileManager(diagnosticCollector, ...);

Porém, o grande ponto é que dentro do processo, será incluído, além da leitura das informações classe, a interpolação de texto para gerar uma classe além da compilação da classe. Ou seja, o consumo de memória tende a aumentar drasticamente junto com o tempo e poder computacional para iniciar uma aplicação.

Em uma análise comparando o acesso direto, o reflection e o combinado com a API de compilação, Geoffrey De Smet menciona no artigo que ao utilizar a abordagem de compilar em tempo de execução o código fica cerca de 5% mais lento quando comparado com o código nativo, ou seja, fica 104% mais lento que somente com o reflection. Porém, existe um altíssimo custo ao iniciar a aplicação.

A solução para o cold start no Java

Como mencionamos na parte de história das aplicações dentro do mundo Java, o Reflection vem sendo utilizado extensivamente, principalmente, pela sua conectividade, porém, isso trouxe como o grande desafio o tempo de inicialização de uma aplicação além alto consumo de memória. Uma possível solução é que de gerar estes metadados em tempo de execução, eles fossem feitos dentro do escopo de compilação. Essa abordagem traria algumas vantagens:

  • Cold-start: Todo o processo aconteceria em tempo de compilação, ou seja, ao iniciar a aplicação os metadados já estariam gerados e prontos para uso.
  • Memória: Os dados processados não são necessários, por exemplo para acessar as informações de ReflectionData das classes, economizando memória.
  • Velocidade: Como já foi mencionado, o acesso via Reflection tende a não ser tão rápido quanto o código compilado, além de ser passível das otimizações do JIT.

Felizmente, essa solução já existe no mundo Java graças a JSR 269 Pluggable Annotation Processing API. Além das vantagens que mencionamos acima, também existe um recurso que surgiu no Java 9, o JPE 295 Ahead of Time Compilation que dentre as suas vantagens se encontra na possibilidade de compilar uma classe para o código nativo, bem interessante para aplicações que querem uma inicialização rápida. Porém, ela peca em alguns pontos: O efeito plug and play, uma vez que todas as validações são feitas durante a compilação, uma nova biblioteca terá que ser explicitamente utilizada. Um outro ponto está no encapsulamento. Pensando num mapper para banco de dados, é importante deixar ou o atributo público ou os getter e setter visíveis de alguma forma, mesmo que não faça sentido para o negócio, como quando se trabalha com um id auto gerado, já que o framework fornecerá esse ID, um setter desse atributo tende a ser uma falha de encapsulamento.

@SupportedAnnotationTypes("org.soujava.medatadata.api.Entity")
public class EntityProcessor extends AbstractProcessor {

  @Override
    public boolean process(Set<? extends TypeElement> annotations,
                           RoundEnvironment roundEnv) {
                           //....

 }
}

O uso do código Nativo

Uma das grandes características do serverless é que ele funciona como um backend as service, ou seja, pagamos pela execução do processo. Seguindo essa linha de raciocínio, o uso de uma JVM em muitos momentos não é necessário uma vez que ela tem recursos que não fazem sentido para uma única execução. Dentre elas podemos citar o GC e também o JIT. Umas das estratégias seria usar o GraalVM para criar imagens nativas. Essa abordagem fazem com que a JVM não seja necessária no momento da execução. Usando como base o livro Structured Computer Organization do Andrew S. Tanenbaum teríamos as seguintes camadas com e sem a JVM.

Muito útil para uma única execução, porém, é importante salientar que quando temos muitas execuções, existem casos que a performance pode ser superior com o JVM ao invés de utilizar somente o código nativo.

Conclusão

Os números de frameworks no mundo Java tende a aumentar, principalmente quando levamos em consideração os diversos requisitos e estilos que as aplicações tendem a ter seja focando num could-start, seja focando na conectividade. Ainda é incerto o rumo que as arquiteturas irão tomar, sejam em microservices, macroservices, ou simplesmente, o retorno para o monólito. Porém, é muito provável que existirão diversas opções ainda mais numa era que a adoção do cloud está cada vez mais forte, sem mencionar o crescimento e estilos de serviços existentes nesse caminho. E caberá a instituições como a Eclipse Foundation com Jakarta EE entenderem todos esses caminhos e trabalharem nas especificações que darão suporte a esses estilos.

Sobre o Autor

Otávio Santana é engenheiro de software, com grande experiência em desenvolvimento opensource, com diversas contribuições ao JBoss Weld, Hibernate, Apache Commons e outros projetos. Focado no desenvolvimento poliglota e aplicações de alto desempenho, trabalhou em grandes projetos nas áreas de finanças, governamental, mídias sociais e e-commerce. Membro do comitê executivo do JCP e de vários Expert Groups de JSRs, é também um Java Champion, recebendo os prêmios de JCP Outstanding Award e Duke's Choice Award.

Avalie esse artigo

Relevância
Estilo/Redação

Conteúdo educacional

BT