Processamento de Annotations em Java
Processe annotations Java em tempo de compilação com a API javax.annotation.processing para gerar código ou validar fontes.
O processamento de annotations é um ponto de extensão do javac. Você escreve uma classe — um processador de annotations — que o compilador chama durante a compilação, passa os elementos que viu até aquele momento e aguarda. O processador pode fazer duas coisas úteis: validar o código anotado (emitir erros ou avisos pelo canal de diagnóstico do javac) ou escrever novos arquivos-fonte que participam da mesma compilação.
Frameworks que você provavelmente já usou são alimentados por esse mecanismo:
- Lombok reescreve classes anotadas para adicionar getters, builders e
equals/hashCode. - Dagger / Hilt geram a fiação de injeção de dependência em resposta a
@Injecte@Module. - O metamodelo estático do Hibernate gera classes
Entity_para consultas Criteria com tipagem segura. - Auto-Service / Auto-Value geram entradas de serviço
META-INFe classes de valor em boilerplate. - Micronaut / Quarkus geram a fiação do framework em tempo de build em vez de na inicialização.
A API do processador está em javax.annotation.processing e o modelo de linguagem está em javax.lang.model. Juntos, eles permitem que o javac hospede ferramentas de terceiros em tempo de compilação.
A estrutura de um processador
Um processador implementa javax.annotation.processing.Processor. Na prática, você estende AbstractProcessor e sobrescreve process(...):
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import java.util.Set;
@SupportedAnnotationTypes("com.example.Marker") // which annotations to handle
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class MarkerProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
for (Element e : roundEnv.getElementsAnnotatedWith(Marker.class)) {
processingEnv.getMessager().printMessage(
javax.tools.Diagnostic.Kind.NOTE,
"found @Marker on " + e.getSimpleName(),
e);
}
return true; // claim the annotation
}
}As duas annotations na classe declaram quais tipos de annotation este processador deseja tratar e qual nível de linguagem ele visa. Ambas também podem ser retornadas dinamicamente de getSupportedAnnotationTypes() / getSupportedSourceVersion() se você precisar calculá-las.
process é chamado por rodada. Cada rodada é uma passagem pelas fontes; se o seu processador produzir novos arquivos, esses arquivos são processados em uma rodada subsequente. O loop termina quando nenhuma rodada produz novos arquivos.
O modelo de linguagem: não é reflection
A primeira surpresa: dentro de um processador você não tem Class<?>. As classes que você está processando ainda não foram compiladas. Em vez disso, você trabalha com os tipos de javax.lang.model.element:
Element— qualquer coisa no fonte: uma classe, método, campo, parâmetro, pacote.TypeElement— uma classe, interface ou enum (umElementdo qual você pode obtergetQualifiedName()).ExecutableElement— um método ou construtor.VariableElement— um campo, parâmetro ou variável local.TypeMirror— um tipo (como em "o tipoList<String>"), distinto do elemento que o declarou.
Eles espelham os tipos de reflection em tempo de execução, mas representam o fonte, não as classes carregadas. Você pode percorrê-los, consultar suas annotations e seu escopo de encapsulamento. Não é possível chamar métodos neles, avaliar expressões constantes arbitrariamente ou instanciá-los — ainda não há instância.
Para ler os valores dos elementos de uma annotation, você usa Element.getAnnotation(MyAnn.class) (retorna um proxy, similar à reflection) ou Element.getAnnotationMirrors() (retorna a forma estrutural, que é o que você precisa quando o valor do elemento contém uma referência Class para um tipo que também está sendo compilado na mesma rodada).
Registrando o processador
O compilador precisa encontrar seu processador. Existem duas maneiras:
- Arquivo service-loader. Coloque um arquivo chamado
META-INF/services/javax.annotation.processing.Processorno classpath do processador, cujo conteúdo é o nome totalmente qualificado da classe processadora, um por linha. É isso que ferramentas como oauto-servicedo Google geram automaticamente. - Flag
-processor. Passe-processor com.example.MarkerProcessorpara ojavac(ou configure isso na sua ferramenta de build — configuraçãoannotationProcessordo Gradle,<annotationProcessorPaths>do Maven).
No Maven e no Gradle, a convenção é manter o processador em seu próprio módulo e depender dele no módulo principal com annotationProcessor (Gradle) / <scope>provided</scope> (Maven). O processador só é executado durante a compilação e não é enviado para o runtime.
Gerando arquivos
Dois tipos de saída são possíveis:
- Arquivos-fonte — escritos via
processingEnv.getFiler().createSourceFile(name). O resultado é umJavaFileObjectcujoopenWriter()você preenche com código-fonte. O novo arquivo é compilado na próxima rodada. - Arquivos de recurso — escritos via
getFiler().createResource(...)para qualquer coisa que acabe no classpath em tempo de execução (por exemplo, registros de serviço).
O padrão é derivar o pacote e o nome da nova classe a partir do elemento anotado e, em seguida, modelar o fonte como uma String:
TypeElement cls = ...; // the annotated class
String pkg = elementUtils.getPackageOf(cls).getQualifiedName().toString();
String genName = cls.getSimpleName() + "Generated";
JavaFileObject src = filer.createSourceFile(pkg + "." + genName, cls);
try (Writer w = src.openWriter()) {
w.write("package " + pkg + ";\n");
w.write("public class " + genName + " {\n");
w.write(" public static String origin() { return \"" + cls.getSimpleName() + "\"; }\n");
w.write("}\n");
}Um processador real normalmente usa um gerador de código como JavaPoet (que expõe um construtor de AST tipado) em vez de concatenação de strings. A mecânica é idêntica; o JavaPoet apenas torna o fonte mais legível.
Erros, avisos, notas
Um processador reporta diagnósticos através do Messager:
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@Marker may only annotate top-level classes",
element);Kind.ERROR falha o build na posição de fonte daquele elemento. WARNING, MANDATORY_WARNING e NOTE são os níveis mais baixos. Sempre passe o argumento Element quando puder — ele dá ao usuário um local de fonte clicável em vez de um bloco de log de build.
Considerações sobre compilação incremental
Os processadores de annotations são uma causa conhecida de lentidão no build. Dois motivos:
- Podem ser não-incrementais: se o processador não souber quais fontes reprocessar, a ferramenta de build reprocessa tudo quando qualquer fonte muda.
- Podem bloquear o paralelismo: as rodadas são sequenciais.
O Gradle introduziu as categorias de processador isolating e aggregating para permitir que os processadores participem da compilação incremental. Um processador que produz um arquivo gerado por fonte anotada (o Dagger faz isso para @Component) pode se declarar "isolating" e o Gradle o executa novamente apenas para as fontes alteradas. Os processadores de agregação — aqueles que analisam todos os elementos anotados para produzir um único arquivo de registro — são executados novamente quando qualquer fonte anotada muda. Escolha a categoria do processador honestamente; o tradeoff é entre correção e velocidade.
Um exemplo prático: um substituto em runtime para processamento em tempo de compilação
O processamento real de annotations requer um build multi-módulo, o ponto de extensão do javac e um arquivo de serviço — nada disso cabe em um único programa. A demonstração mais próxima é um substituto em runtime que faz o mesmo tipo de trabalho: percorrer classes anotadas, validá-las e escrever arquivos-fonte em um diretório temporário como um processador em tempo de compilação faria.
O que extrair da execução:
- O processador percorreu três classes e agiu sobre duas — exatamente o formato de
RoundEnvironment.getElementsAnnotatedWith(Generate.class)em um processadorjavacreal. A terceira classe foi silenciosamente ignorada porque sua annotation não estava presente. Esse é o modelo: um processador consome um conjunto de elementos por rodada e trabalha apenas com os que lhe interessam. - Cada arquivo gerado carregou o pacote da classe fonte e um nome derivado. No
javax.lang.model, você computa o pacote deelementUtils.getPackageOf(typeElement).getQualifiedName()e o nome detypeElement.getSimpleName(); aqui usamosClass.getPackageName()eClass.getSimpleName()como análogos. A estrutura se transfere. - O elemento
suffixpermitiu personalização por uso:AccountproduziuAccountGenerated,InvoiceproduziuInvoiceHelper. Os elementos de annotation são o controle que você oferece ao usuário; os padrões tornam o caso comum conciso e os elementos nomeados oferecem controle preciso quando necessário. - A validação simulada imprimiu uma linha
ERROR:para classes abstratas. Em um processador real, isso seriamessager.printMessage(Diagnostic.Kind.ERROR, "...", element)e o build falharia na localização do fonte do usuário. Os diagnósticos são um recurso de primeira classe, não um fallback — use-os sempre que a annotation for usada incorretamente, nuncathrow. - O fonte gerado não contém nada complicado — um
List.of(...)de nomes de campos e um helperorigin(). Isso é típico. O valor da geração em tempo de compilação raramente está na sofisticação da saída; está no fato de que a saída existe, antes de o programa executar, onde o runtime de outra forma precisaria de reflection (e pagaria seu custo).
Quando usar um processador
Um processador vale a pena quando:
- Você estaria escrevendo o mesmo boilerplate manualmente para cada classe anotada.
- O trabalho pode ser feito a partir de assinaturas de fonte apenas (sem comportamento de instância real necessário).
- A alternativa em runtime usaria reflection a cada chamada, e esse custo se acumula.
Um processador é a ferramenta errada quando:
- Você quer modificar uma classe existente. Processadores padrão só podem adicionar novos arquivos-fonte; eles não reescrevem a classe anotada. (O Lombok reescreve ao se conectar à AST interna do
javac, o que não é oficial e é frágil.) - Os metadados que você precisa só existem em tempo de execução (escopo de requisição, identidade do usuário, configuração carregada do disco).
- Uma simples consulta reflexiva na inicialização faria o mesmo trabalho em 50 linhas.
A decisão é a mesma que para qualquer geração de código: mais trabalho em tempo de compilação, menos trabalho em runtime, e um build mais difícil de depurar. Escolha com cuidado.
Fim da parte 16
Isso conclui a parte de Annotations do livro. Cobrimos o que uma annotation é — pura metadados, distinta de código que executa — depois o pequeno conjunto que a biblioteca padrão fornece, as cinco meta-annotations que configuram as suas próprias, a receita para declarar uma annotation personalizada, e finalmente a API de processamento em tempo de compilação que os frameworks usam para agir sobre annotations durante o build.
O modelo mental para levar adiante: uma annotation nunca faz nada. Algo mais a lê e escolhe agir. Esse "algo mais" é o compilador (lints integrados), um processador de annotations (geração de código em tempo de compilação) ou seu próprio código via reflection (frameworks em runtime). Quando uma annotation não está se comportando como esperado, a primeira pergunta é sempre: quem deveria estar lendo-a?
A próxima parte do livro é Reflection — o lado em runtime da API que você já começou a usar para ler annotations.