Criando um Plug-in do Compilador Java
1. Visão geral
Java 8 fornece uma API para criar plug-insJavac. Infelizmente, é difícil encontrar uma boa documentação para isso.
Neste artigo, vamos mostrar todo o processo de criação de uma extensão do compilador que adiciona código personalizado aos arquivos*.class.
2. Configuração
Primeiro, precisamos adicionartools.jar de JDK como uma dependência para nosso projeto:
com.sun
tools
1.8.0
system
${java.home}/../lib/tools.jar
Every compiler extension is a class which implements com.sun.source.util.Plugin interface. Vamos criá-lo em nosso exemplo:
Vamos criá-lo em nosso exemplo:
public class SampleJavacPlugin implements Plugin {
@Override
public String getName() {
return "MyPlugin";
}
@Override
public void init(JavacTask task, String... args) {
Context context = ((BasicJavacTask) task).getContext();
Log.instance(context)
.printRawLines(Log.WriterKind.NOTICE, "Hello from " + getName());
}
}
Por enquanto, estamos apenas imprimindo "Hello" para garantir que nosso código seja selecionado e incluído na compilação.
Nosso objetivo final será criar um plugin que adiciona verificações de tempo de execução para cada argumento numérico marcado com uma determinada anotação, e lançar uma exceção se o argumento não corresponder a uma condição.
Há mais uma etapa necessária para tornar a extensão detectável porJavac:it should be exposed through the ServiceLoader framework.
Para conseguir isso, precisamos criar um arquivo chamadocom.sun.source.util.Plugin com conteúdo que é o nome de classe totalmente qualificado do nosso plugin (com.example.javac.SampleJavacPlugin) e colocá-lo no diretórioMETA-INF/services.
Depois disso, podemos chamarJavac com a chave-Xplugin:MyPlugin:
example/tutorials$ javac -cp ./core-java/target/classes -Xplugin:MyPlugin ./core-java/src/main/java/com/example/javac/TestClass.java
Hello from MyPlugin
Observe quewe must always use a String returned from the plugin’s getName() method as a -Xplugin option value.
3. Ciclo de Vida do Plugin
Aplugin is called by the compiler only once, through the init() method.
Para ser notificado de eventos subseqüentes, precisamos registrar um retorno de chamada. Eles chegam antes e depois de cada estágio de processamento por arquivo de origem:
-
PARSE - constrói umAbstract Syntax Tree (AST)
-
ENTER - as importações do código-fonte foram resolvidas
-
ANALYZE - saída do analisador (um AST) é analisada quanto a erros
-
GENERATE - gerando binários para o arquivo de origem de destino
Existem mais dois tipos de eventos -ANNOTATION_PROCESSING eANNOTATION_PROCESSING_ROUND, mas não estamos interessados neles aqui.
Por exemplo, quando queremos melhorar a compilação adicionando algumas verificações com base nas informações do código-fonte, é razoável fazer isso no manipulador de eventosPARSE finished:
public void init(JavacTask task, String... args) {
task.addTaskListener(new TaskListener() {
public void started(TaskEvent e) {
}
public void finished(TaskEvent e) {
if (e.getKind() != TaskEvent.Kind.PARSE) {
return;
}
// Perform instrumentation
}
});
}
4. Extrair dados AST
We can get an AST generated by the Java compiler through the TaskEvent.getCompilationUnit(). Seus detalhes podem ser examinados por meio da interfaceTreeVisitor.
Observe que apenas um elementoTree, para o qual o métodoaccept() é chamado, despacha eventos para o visitante fornecido.
Por exemplo, quando executamosClassTree.accept(visitor), apenasvisitClass() é disparado; não podemos esperar que, digamos,visitMethod() também seja ativado para cada método na classe dada.
Podemos usarTreeScanner para superar o problema:
public void finished(TaskEvent e) {
if (e.getKind() != TaskEvent.Kind.PARSE) {
return;
}
e.getCompilationUnit().accept(new TreeScanner() {
@Override
public Void visitClass(ClassTree node, Void aVoid) {
return super.visitClass(node, aVoid);
@Override
public Void visitMethod(MethodTree node, Void aVoid) {
return super.visitMethod(node, aVoid);
}
}, null);
}
Neste exemplo, é necessário chamarsuper.visitXxx(node, value) para processar recursivamente os filhos do nó atual.
5. Modificar AST
Para mostrar como podemos modificar o AST, inseriremos verificações de tempo de execução para todos os argumentos numéricos marcados com uma anotação@Positive.
Esta é uma anotação simples que pode ser aplicada aos parâmetros do método:
@Documented
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.PARAMETER})
public @interface Positive { }
Aqui está um exemplo de uso da anotação:
public void service(@Positive int i) { }
No final, queremos que o bytecode pareça ter sido compilado de uma fonte como esta:
public void service(@Positive int i) {
if (i <= 0) {
throw new IllegalArgumentException("A non-positive argument ("
+ i + ") is given as a @Positive parameter 'i'");
}
}
O que isso significa é que queremos que umIllegalArgumentException seja lançado para cada argumento marcado com@Positive que seja igual ou menor que 0.
5.1. Onde instrumentar
Vamos descobrir como podemos localizar os locais de destino onde a instrumentação deve ser aplicada:
private static Set TARGET_TYPES = Stream.of(
byte.class, short.class, char.class,
int.class, long.class, float.class, double.class)
.map(Class::getName)
.collect(Collectors.toSet());
Para simplificar, adicionamos apenas tipos numéricos primitivos aqui.
A seguir, vamos definir um métodoshouldInstrument() que verifica se o parâmetro tem um tipo no conjunto TARGET_TYPES, bem como a anotação@Positive:
private boolean shouldInstrument(VariableTree parameter) {
return TARGET_TYPES.contains(parameter.getType().toString())
&& parameter.getModifiers().getAnnotations().stream()
.anyMatch(a -> Positive.class.getSimpleName()
.equals(a.getAnnotationType().toString()));
}
Em seguida, continuaremos o métodofinished() em nossa classeSampleJavacPlugin com a aplicação de uma verificação a todos os parâmetros que atendem às nossas condições:
public void finished(TaskEvent e) {
if (e.getKind() != TaskEvent.Kind.PARSE) {
return;
}
e.getCompilationUnit().accept(new TreeScanner() {
@Override
public Void visitMethod(MethodTree method, Void v) {
List parametersToInstrument
= method.getParameters().stream()
.filter(SampleJavacPlugin.this::shouldInstrument)
.collect(Collectors.toList());
if (!parametersToInstrument.isEmpty()) {
Collections.reverse(parametersToInstrument);
parametersToInstrument.forEach(p -> addCheck(method, p, context));
}
return super.visitMethod(method, v);
}
}, null);
Neste exemplo, invertemos a lista de parâmetros porque há um caso possível em que mais de um argumento é marcado por@Positive. Como cada verificação é adicionada como a primeira instrução do método, nós os processamos em RTL para garantir a ordem correta
Como cada verificação é adicionada como a primeira instrução de método, nós as processamos RTL para garantir a ordem correta.
5.2. Como instrumentar
O problema é que “ler AST” fica na área da APIpublic, enquanto operações de “modificar AST” como “adicionar verificações de nulos” são aprivateAPI.
Para resolver isso,we’ll create new AST elements through a TreeMaker instance.
Primeiro, precisamos obter uma instânciaContext:
@Override
public void init(JavacTask task, String... args) {
Context context = ((BasicJavacTask) task).getContext();
// ...
}
Então, podemos obter o objetoTreeMarker através do métodoTreeMarker.instance(Context).
Agora podemos construir novos elementos AST, por exemplo, uma expressãoif pode ser construída por uma chamada paraTreeMaker.If():
private static JCTree.JCIf createCheck(VariableTree parameter, Context context) {
TreeMaker factory = TreeMaker.instance(context);
Names symbolsTable = Names.instance(context);
return factory.at(((JCTree) parameter).pos)
.If(factory.Parens(createIfCondition(factory, symbolsTable, parameter)),
createIfBlock(factory, symbolsTable, parameter),
null);
}
Observe que queremos mostrar a linha de rastreamento de pilha correta quando uma exceção é lançada em nossa verificação. É por isso que ajustamos a posição de fábrica AST antes de criar novos elementos através dela comfactory.at(((JCTree) parameter).pos).
O métodocreateIfCondition() constrói a condição “parameterId <0 ″if:
private static JCTree.JCBinary createIfCondition(TreeMaker factory,
Names symbolsTable, VariableTree parameter) {
Name parameterId = symbolsTable.fromString(parameter.getName().toString());
return factory.Binary(JCTree.Tag.LE,
factory.Ident(parameterId),
factory.Literal(TypeTag.INT, 0));
}
Em seguida, o métodocreateIfBlock() constrói um bloco que retorna umIllegalArgumentException:
private static JCTree.JCBlock createIfBlock(TreeMaker factory,
Names symbolsTable, VariableTree parameter) {
String parameterName = parameter.getName().toString();
Name parameterId = symbolsTable.fromString(parameterName);
String errorMessagePrefix = String.format(
"Argument '%s' of type %s is marked by @%s but got '",
parameterName, parameter.getType(), Positive.class.getSimpleName());
String errorMessageSuffix = "' for it";
return factory.Block(0, com.sun.tools.javac.util.List.of(
factory.Throw(
factory.NewClass(null, nil(),
factory.Ident(symbolsTable.fromString(
IllegalArgumentException.class.getSimpleName())),
com.sun.tools.javac.util.List.of(factory.Binary(JCTree.Tag.PLUS,
factory.Binary(JCTree.Tag.PLUS,
factory.Literal(TypeTag.CLASS, errorMessagePrefix),
factory.Ident(parameterId)),
factory.Literal(TypeTag.CLASS, errorMessageSuffix))), null))));
}
Agora que podemos construir novos elementos AST, precisamos inseri-los no AST preparado pelo analisador. Podemos conseguir isso convertendo elementospublic API em tipos de APIprivate:
private void addCheck(MethodTree method, VariableTree parameter, Context context) {
JCTree.JCIf check = createCheck(parameter, context);
JCTree.JCBlock body = (JCTree.JCBlock) method.getBody();
body.stats = body.stats.prepend(check);
}
6. Testando o plug-in
Precisamos ser capazes de testar nosso plugin. Envolve o seguinte:
-
compilar a fonte de teste
-
execute os binários compilados e verifique se eles se comportam conforme o esperado
Para isso, precisamos introduzir algumas classes auxiliares.
SimpleSourceFile expõe o texto do arquivo de origem fornecido aoJavac:
public class SimpleSourceFile extends SimpleJavaFileObject {
private String content;
public SimpleSourceFile(String qualifiedClassName, String testSource) {
super(URI.create(String.format(
"file://%s%s", qualifiedClassName.replaceAll("\\.", "/"),
Kind.SOURCE.extension)), Kind.SOURCE);
content = testSource;
}
@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
return content;
}
}
SimpleClassFile contém o resultado da compilação como uma matriz de bytes:
public class SimpleClassFile extends SimpleJavaFileObject {
private ByteArrayOutputStream out;
public SimpleClassFile(URI uri) {
super(uri, Kind.CLASS);
}
@Override
public OutputStream openOutputStream() throws IOException {
return out = new ByteArrayOutputStream();
}
public byte[] getCompiledBinaries() {
return out.toByteArray();
}
// getters
}
SimpleFileManager garante que o compilador use nosso portador de bytecode:
public class SimpleFileManager
extends ForwardingJavaFileManager {
private List compiled = new ArrayList<>();
// standard constructors/getters
@Override
public JavaFileObject getJavaFileForOutput(Location location,
String className, JavaFileObject.Kind kind, FileObject sibling) {
SimpleClassFile result = new SimpleClassFile(
URI.create("string://" + className));
compiled.add(result);
return result;
}
public List getCompiled() {
return compiled;
}
}
Por fim, tudo isso está vinculado à compilação na memória:
public class TestCompiler {
public byte[] compile(String qualifiedClassName, String testSource) {
StringWriter output = new StringWriter();
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
SimpleFileManager fileManager = new SimpleFileManager(
compiler.getStandardFileManager(null, null, null));
List compilationUnits
= singletonList(new SimpleSourceFile(qualifiedClassName, testSource));
List arguments = new ArrayList<>();
arguments.addAll(asList("-classpath", System.getProperty("java.class.path"),
"-Xplugin:" + SampleJavacPlugin.NAME));
JavaCompiler.CompilationTask task
= compiler.getTask(output, fileManager, null, arguments, null,
compilationUnits);
task.call();
return fileManager.getCompiled().iterator().next().getCompiledBinaries();
}
}
Depois disso, precisamos apenas executar os binários:
public class TestRunner {
public Object run(byte[] byteCode, String qualifiedClassName, String methodName,
Class>[] argumentTypes, Object... args) throws Throwable {
ClassLoader classLoader = new ClassLoader() {
@Override
protected Class> findClass(String name) throws ClassNotFoundException {
return defineClass(name, byteCode, 0, byteCode.length);
}
};
Class> clazz;
try {
clazz = classLoader.loadClass(qualifiedClassName);
} catch (ClassNotFoundException e) {
throw new RuntimeException("Can't load compiled test class", e);
}
Method method;
try {
method = clazz.getMethod(methodName, argumentTypes);
} catch (NoSuchMethodException e) {
throw new RuntimeException(
"Can't find the 'main()' method in the compiled test class", e);
}
try {
return method.invoke(null, args);
} catch (InvocationTargetException e) {
throw e.getCause();
}
}
}
Um teste pode ser assim:
public class SampleJavacPluginTest {
private static final String CLASS_TEMPLATE
= "package com.example.javac;\n\n" +
"public class Test {\n" +
" public static %1$s service(@Positive %1$s i) {\n" +
" return i;\n" +
" }\n" +
"}\n" +
"";
private TestCompiler compiler = new TestCompiler();
private TestRunner runner = new TestRunner();
@Test(expected = IllegalArgumentException.class)
public void givenInt_whenNegative_thenThrowsException() throws Throwable {
compileAndRun(double.class,-1);
}
private Object compileAndRun(Class> argumentType, Object argument)
throws Throwable {
String qualifiedClassName = "com.example.javac.Test";
byte[] byteCode = compiler.compile(qualifiedClassName,
String.format(CLASS_TEMPLATE, argumentType.getName()));
return runner.run(byteCode, qualifiedClassName,
"service", new Class[] {argumentType}, argument);
}
}
Aqui, estamos compilando uma classeTest com um métodoservice() que tem um parâmetro anotado com@Positive. Então, estamos executando a classeTest definindo um valor duplo de -1 para o parâmetro do método.
Como resultado da execução do compilador com nosso plugin, o teste lançará umIllegalArgumentException para o parâmetro negativo.
7. Conclusão
Neste artigo, mostramos o processo completo de criação, teste e execução de um plugin do compilador Java.
O código-fonte completo dos exemplos pode ser encontradoover on GitHub.