Os reflections são uma das ferramentas mais utilizadas para a criação de frameworks dentro do mundo Java. Porém, possui alguns problemas que precisam ser superados, um dos problemas está na inicialização da aplicação e no alto consumo de memória inicial, tornando um grande desafio para algumas aplicações, como o serverless. Conheça mais sobre a nova tendência dos frameworks reflectionless e saiba como criar o seu.
Ao longo dos 25 anos, muitas coisas mudaram além das versões do Java, como as decisões arquiteturais e os respectivos requisitos. Atualmente, existe o fator da cloud computing que, no geral, necessita que a aplicação tenha uma melhor inicialização além de um baixo heap da memória inicial.
Com isso, é necessário redesenhar a forma como os frameworks são feitos, se livrando do gargalo com o reflection. O objetivo deste artigo é apresentar algumas soluções que auxiliam o reflectionless, os trade-offs dessa escolha além de apresentar o Java Annotation Processor.
Entendendo o reflection
Dentro do mundo dos frameworks, certamente, o reflection realiza um grande papel em diversas ferramentas, tanto para os clássicos ORM, quanto para outros pontos, como uma API REST como o JAX-RS. Esse tipo de mecanismo facilita a vida de profissionais Java reduzindo massivamente o boilerplate de diversas operações. Existem pessoas que relatam que o maior diferencial do mundo Java é justamente o grande número de ferramentas e ecossistemas ao redor da linguagem.
Para o usuário final, e aqui me refiro ao usuário que utiliza esses frameworks, todo esse processo acontece de maneira mágica. Basta colocar algumas notações dentro da classe e todas as operações são feitas a partir dessas configurações. Elas ou os metadados da classe serão lidos e utilizados para facilitar algum processo.
Atualmente o modo mais popular para realizar esse tipo de leitura é a partir do reflection que realiza uma introspecção que levianamente, gera uma ideia de linguagem dinâmica dentro do Java.
Problemas com o reflection
O uso da API do reflection para esse tipo de trabalho dentro do framework foi facilitada devido a um grande material criado, com exemplos e documentação para esse tipo de trabalho. Porém, existem alguns problemas como aquele dito no momento da inicialização e o consumo de memória, por alguns motivos que iremos discutir.
Primeiro motivo, todo o processamento e a estrutura de dados será realizado no momento da execução. Imagine um motor de injeção de dependência que precisa varrer classe por classe, verificar o escopo, as dependências e muito mais. Assim, quanto mais classes precisam ser analisadas mais processamento será necessário e isso tende a aumentar o tempo de resposta.
Já o segundo motivo está no consumo de memória. Esse é um dos motivos relacionado ao fato de cada classe necessitar ser percorrida para buscar os metadados dentro do Class, que existe um cache ReflectionData que carrega todas as informações da classe. Em outras palavras, para buscar uma simples informação, como o getSimpleName(), toda a informação dos metadados é carregada e referenciada através do SoftReference que demora para sair da memória.
Em resumo, a abordagem via reflection trás um problema tanto no consumo inicial de memória, quanto na demora para iniciar a aplicação. Isso se dá porque os dados, análises e processamento de parser são realizados tão logo uma aplicação começa.
O consumo de memória e tempo de execução tende a aumentar à medida que o número de classes aumenta. Esse é um dos motivos que fez Graeme Rocher dar uma palestra explicando o problema do reflection (em inglês) e como isso inspirou a criação do Micronaut.
Solução para os problemas do reflection
Uma solução para os problemas é fazer com que os frameworks realizem essas operações no momento de compilação, ao invés de ser em tempo de execução, trazendo os seguintes benefícios:
- Os metadados e as estruturas estarão prontos quando a aplicação iniciar, podemos imaginar aqui uma espécie de cache;
- Não existe a necessidade de chamar as classes do reflection, dentre elas a ReflectionData, diminuindo assim o consumo de memória na inicialização;
- Um outro ponto é que não precisamos nos preocupar com o efeito do Type Erasure.
Um outro ponto ao evitar o reflection está no fato de podermos usar com muito mais facilidade o Ahead of Time (AoT). Outra vantagem seria poder criar um código nativo através do GraalVM, uma possibilidade bastante interessante, sobretudo, para o conceito de serverless, uma vez que o programa tende a executar uma única vez e depois retornar todo o recurso para o sistema operacional.
Certamente, existem diversos mitos ao redor do Ahead of Time, afinal, como toda escolha, existem trade-offs. É por isso que Steve Millidge escreve um brilhante artigo sobre o assunto (em inglês), de um modo geral, ele percorre diversos mitos sobre reflection na nuvem e as desmente.
Vamos ao que interessa! O código!
Após a explicação dos conceitos, motivações e trade-offs dos tipos de leituras, o próximo passo será a criação de uma simples ferramenta que realiza a conversão de uma classe Java para um Map a partir de algumas notações que definirão que a entidade será mapeada, os atributos que serão convertidos e o campo que será um identificador único.
Vamos fazer tudo isso como mostra o código abaixo:
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Entity {
String value() default "";
}
@Documented
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Column {
String value() default "";
}
@Documented
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Id {
String value() default "";
}
Para simplificar a comparação com o reflection, ou eventualmente outras opções, criaremos uma interface que será responsável por realizar a conversão de/para Map.
import java.util.Map;
public interface Mapper {
<T> T toEntity(Map<String, Object> map, Class<T> type);
<T> Map<String, Object> toMap(T entity);
}
Com o intuito de comparar as duas soluções, a primeira implementação será via reflection. Um ponto é que existem diversas estratégias para se trabalhar com o reflection, por exemplo, utilizando o pacote “java.beans” com o Introspector. Porém, neste exemplo, iremos realizar da maneira mais simples para mostrar o básico do funcionamento.
public class ReflectionMapper implements Mapper {
@Override
public <T> T toEntity(Map<String, Object> map, Class<T> type) {
Objects.requireNonNull(map, "Map is required");
Objects.requireNonNull(type, "type is required");
final Constructor<?>[] constructors = type.getConstructors();
try {
final T instance = (T) constructors[0].newInstance();
for (Field field : type.getDeclaredFields()) {
write(map, instance, field);
}
return instance;
} catch (InstantiationException | IllegalAccessException | InvocationTargetException exception) {
throw new RuntimeException("An error to field the entity process", exception);
}
}
@Override
public <T> Map<String, Object> toMap(T entity) {
Objects.requireNonNull(entity, "entity is required");
Map<String, Object> map = new HashMap<>();
final Class<?> type = entity.getClass();
final Entity annotation = Optional.ofNullable(
type.getAnnotation(Entity.class))
.orElseThrow(() -> new RuntimeException("The class must have Entity annotation"));
String name = annotation.value().isBlank() ? type.getSimpleName() : annotation.value();
map.put("entity", name);
for (Field field : type.getDeclaredFields()) {
try {
read(entity, map, field);
} catch (IllegalAccessException exception) {
throw new RuntimeException("An error to field the map process", exception);
}
}
return map;
}
private <T> void read(T entity, Map<String, Object> map, Field field) throws IllegalAccessException {
final Id id = field.getAnnotation(Id.class);
final Column column = field.getAnnotation(Column.class);
final String fieldName = field.getName();
if (id != null) {
String idName = id.value().isBlank() ? fieldName : id.value();
field.setAccessible(true);
final Object value = field.get(entity);
map.put(idName, value);
} else if (column != null) {
String columnName = column.value().isBlank() ? fieldName : column.value();
field.setAccessible(true);
final Object value = field.get(entity);
map.put(columnName, value);
}
}
private <T> void write(Map<String, Object> map, T instance, Field field) throws IllegalAccessException {
final Id id = field.getAnnotation(Id.class);
final Column column = field.getAnnotation(Column.class);
final String fieldName = field.getName();
if (id != null) {
String idName = id.value().isBlank() ? fieldName : id.value();
field.setAccessible(true);
final Object value = map.get(idName);
if (value != null) {
field.set(instance, value);
}
} else if (column != null) {
String columnName = column.value().isBlank() ? fieldName : column.value();
field.setAccessible(true);
final Object value = map.get(columnName);
if (value != null) {
field.set(instance, value);
}
}
}
}
Com o mapper construído, o próximo passo é realizar um pequeno exemplo. Vamos criar então uma entidade Animal.
@Entity("animal")
public class Animal {
@Id
private String id;
@Column("native_name")
private String name;
public Animal() {
}
public Animal(String id, String name) {
this.id = id;
this.name = name;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class ReflectionMapperTest {
private Mapper mapper;
@BeforeEach
public void setUp() {
this.mapper = new ReflectionMapper();
}
@Test
public void shouldCreateMap() {
Animal animal = new Animal("id", "lion");
final Map<String, Object> map = mapper.toMap(animal);
Assertions.assertEquals("animal", map.get("entity"));
Assertions.assertEquals("id", map.get("id"));
Assertions.assertEquals("lion", map.get("native_name"));
}
@Test
public void shouldCreateEntity() {
Map<String, Object> map = new HashMap<>();
map.put("id", "id");
map.put("native_name", "lion");
final Animal animal = mapper.toEntity(map, Animal.class);
Assertions.assertEquals("id", animal.getId());
Assertions.assertEquals("lion", animal.getName());
}
}
Com isso, foi demonstrado o funcionamento da implementação do reflection. Caso queira usar esse tipo de ferramenta em outros projetos, é possível criar um pequeno projeto e adicioná-lo como qualquer outra dependência e toda essa operação e leitura serão realizadas em tempo de execução.
É importante salientar que no mundo do reflection existem algumas opções e estratégias para trabalhar com ele. Por exemplo, criar um cache interno desses metadados para evitar o uso das informações do ReflectionData ou, a partir dessas informações, compilar classes em momento de execução como Geoffrey De Smet demonstra em seu artigo (em inglês), utilizando o JavaCompiler.
Porém, o grande ponto é que todo esse processo acontecerá no momento de execução. Para fazer com que o processamento seja movido para a compilação, iremos utilizar o Java Annotation Processor API.
Como fazer com que o processamento seja movido para a compilação?
De uma maneira geral, a classe para ser entity no processo precisa:
- Herdar a classe AbstractProcessor;
- Utilizar a anotação SupportedAnnotationTypes para definir quais classes serão lidas em tempo de compilação;
- O método process onde ficará o coração do código.
Esse método é onde toda a análise será realizada. O último passo é registrar a classe como o SPI e o código estará pronto para executar no momento da compilação.
@SupportedAnnotationTypes("org.soujava.medatadata.api.Entity")
public class EntityProcessor extends AbstractProcessor {
//…
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
final List<String> entities = new ArrayList<>();
for (TypeElement annotation : annotations) {
roundEnv.getElementsAnnotatedWith(annotation)
.stream().map(e -> new ClassAnalyzer(e, processingEnv))
.map(ClassAnalyzer::get)
.filter(IS_NOT_BLANK).forEach(entities::add);
}
try {
if (!entities.isEmpty()) {
createClassMapping(entities);
createProcessorMap();
}
} catch (IOException exception) {
error(exception);
}
return false;
}
//…
}
Um ponto importante é que a configuração para o Java Annotation Processing requer mais passos de configuração que o reflection. Porém, com os passos iniciais, os próximos tendem a ser semelhantes a API do reflection. A dependência desse tipo de leitura pode ser feita a partir da tag annotationProcessorPaths.
Uma grande vantagem é que essas dependências serão visíveis apenas no escopo de compilação. Em outras palavras, é possível adicionar dependências para gerar classes, por exemplo, utilizando o Mustache, sem se preocupar com essas dependências em tempo de execução.
Tão logo se adiciona a dependência em um projeto e o mesmo é executado, será gerado classes dentro da pasta <pre>target/generated-sources</pre>. No exemplo, todas as classes foram geradas graças ao projeto Mustache.
@Generated(value= "Soujava ClassMappings Generator", date = "2021-01-21T13:08:48.618494")
public final class ProcessorClassMappings implements ClassMappings {
private final List<EntityMetadata> entities;
public ProcessorClassMappings() {
this.entities = new ArrayList<>();
this.entities.add(new org.soujava.metadata.example.PersonEntityMetaData());
this.entities.add(new org.soujava.metadata.example.AnimalEntityMetaData());
this.entities.add(new org.soujava.metadata.example.CarEntityMetaData());
}
...
O funcionamento para o usuário final dessa biblioteca, no geral, não muda muito uma vez que o usuário continuará fazendo as anotações nas entidades. Porém, toda a lógica de processamento foi trazida para o tempo de compilação.
@Entity("animal")
public class Animal {
@Id
private String id;
@Column("native_name")
private String name;
public Animal() {
}
public Animal(String id, String name) {
this.id = id;
this.name = name;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class ProcessorMapperTest {
private Mapper mapper;
@BeforeEach
public void setUp() {
this.mapper = new ProcessorMapper();
}
@Test
public void shouldCreateMap() {
Animal animal = new Animal("id", "lion");
final Map<String, Object> map = mapper.toMap(animal);
Assertions.assertEquals("animal", map.get("entity"));
Assertions.assertEquals("id", map.get("id"));
Assertions.assertEquals("lion", map.get("native_name"));
}
@Test
public void shouldCreateEntity() {
Map<String, Object> map = new HashMap<>();
map.put("id", "id");
map.put("native_name", "lion");
final Animal animal = mapper.toEntity(map, Animal.class);
Assertions.assertEquals("id", animal.getId());
Assertions.assertEquals("lion", animal.getName());
}
}
Reflectionless: como tudo, há trade-offs
Nesse artigo falamos um pouco sobre os efeitos, as vantagens e desvantagens dentro do mundo de reflection. Introduzimos um exemplo com Java Annotation Processor e mostramos as vantagens do AoT no Java e até mesmo convertemos o código para ser nativo, facilitando em diversas situações, como o serverless.
Como sempre, toda escolha resulta em uma desvantagem. Ao remover a aplicação, se perde toda a otimização do compilador Just-In-Time (JIT), e já existem alguns relatos de que, com o tempo, a Java Virtual Machine (JVM) acabará sendo muito mais eficiente que o próprio código nativo.
A definição de performance é algo muito complexo e não leva em consideração apenas o tempo de inicialização da aplicação. Uma boa analogia seria atravessar o oceano com uma moto ao invés de um avião, uma vez que a análise apenas levou em consideração a inicialização dos dois meios de transporte.
Quer ver mais sobre o assunto? Então acesse o Github da Sou Java.