Anotações em tempo de compilação usando @Implement como exemplo



Todos nós gostamos de detectar erros no estágio de compilação, em vez de exceções de tempo de execução. A maneira mais fácil de corrigi-los é que o próprio compilador mostra todos os locais que precisam ser corrigidos. Embora a maioria dos problemas só possa ser detectada quando o programa for iniciado, ainda estamos tentando fazer isso o mais rápido possível. Em blocos de inicialização de classes, em construtores de objetos, na primeira chamada de um método, etc. E, às vezes, temos sorte e, mesmo na fase de compilação, sabemos o suficiente para verificar se há erros no programa.

Neste artigo, quero compartilhar a experiência de escrever um desses testes. Mais precisamente, criando uma anotação que pode gerar erros, como o compilador. A julgar pelo fato de não haver tanta informação sobre esse tópico no RuNet, as situações felizes descritas acima não são frequentes.

Descreverei o algoritmo geral de verificação, bem como todas as etapas e nuances pelas quais passei tempo e células nervosas.

Declaração do problema


Nesta seção, darei um exemplo do uso desta anotação. Se você já sabe o que deseja fazer, pode ignorá-lo com segurança. Estou certo de que isso não afetará a integridade da apresentação.

Agora falaremos mais sobre como melhorar a legibilidade do código do que sobre como corrigir bugs. Um exemplo, pode-se dizer, da vida, ou melhor, do meu projeto de hobby.

Suponha que exista uma classe UnitManager, que, de fato, é uma coleção de unidades. Possui métodos para adicionar, excluir, obter uma unidade etc. Ao adicionar uma nova unidade, o gerente atribui um ID a ela. A geração de id é delegada à classe RotateCounter, que retorna um número no intervalo especificado. E há um pequeno problema, o RotateCounter não pode saber se o ID selecionado está livre. De acordo com o princípio da inversão de dependência, você pode criar uma interface, no meu caso, é RotateCounter.IClient, que possui um único método isValueFree (), que recebe id e retorna true se id estiver livre. E o UnitManager implementa essa interface, cria uma instância do RotateCounter e a transmite a si mesma como cliente.

Eu fiz exatamente isso. Mas, depois de abrir a fonte do UnitManager alguns dias depois de escrever, entrei em um estupor fácil depois de ver o método isValueFree (), que realmente não se encaixava na lógica do UnitManager. Seria muito mais simples se fosse possível especificar qual interface implementa esse método. Por exemplo, em C #, de onde vim para Java, uma implementação explícita da interface ajuda a lidar com esse problema. Nesse caso, primeiro, você pode chamar o método apenas com uma conversão explícita na interface. Em segundo lugar, e mais importante nesse caso, o nome da interface (e sem o modificador de acesso) é indicado explicitamente na assinatura do método, por exemplo:

IClient.isValueFree(int value) { } 

Uma solução é adicionar uma anotação com o nome da interface que implementa esse método. Algo como @Override , apenas com uma interface. Concordo, você pode usar uma classe interna anônima. Nesse caso, assim como em C #, o método não pode ser chamado apenas no objeto e você pode ver imediatamente qual interface ele implementa. Porém, isso aumentará a quantidade de código, portanto, degradará a legibilidade. Sim, e você precisa obtê-lo de alguma forma da classe - criar um getter ou um campo público (afinal, também não há sobrecarga de instruções de conversão no Java). Não é uma opção ruim, mas eu não gosto.

Inicialmente, pensei que em Java, como em C #, as anotações são classes completas e podem ser herdadas delas. Nesse caso, você só precisa criar uma anotação que herda de @Override . Mas não era assim, e tive que mergulhar no mundo surpreendente e assustador dos cheques na fase de compilação.

Código de exemplo do UnitManager
 public class Unit { private int id; } public class UnitManager implements RotateCounter.IClient { private final Unit[] units; private final RotateCounter idGenerator; public UnitManager(int size) { units = new Unit[size]; idGenerator = new RotateCounter(0, size, this); } public void addUnit(Unit unit) { int id = idGenerator.findFree(); units[id] = unit; } @Implement(RotateCounter.IClient.class) public boolean isValueFree(int value) { return units[value] == null; } public void removeUnit(int id) { units[id] = null; } } public class RotateCounter { private final IClient client; private int next; private int minValue; private int maxValue; public RotateCounter(int minValue, int maxValue, IClient client) { this.client = client; this.minValue = minValue; this.maxValue = maxValue; next = minValue; } public int incrementAndGet() { int current = next; if (next >= maxValue) { next = minValue; return current; } next++; return current; } public int range() { return maxValue - minValue + 1; } public int findFree() { int range = range(); int trysCounter = 0; int id; do { if (++trysCounter > range) { throw new IllegalStateException("No free values."); } id = incrementAndGet(); } while (!client.isValueFree(id)); return id; } public static interface IClient { boolean isValueFree(int value); } } 

Pouco de teoria


Farei uma reserva imediatamente, todos os métodos acima são instâncias; portanto, por uma questão de brevidade, indicarei os nomes dos métodos com o nome do tipo e sem parâmetros: <_>.<_>() .

O processamento de elementos no estágio de compilação envolve classes especiais de processador. Estas são classes que herdam da javax.annotation.processing.AbstractProcessor (você pode simplesmente implementar a interface javax.annotation.processing.Processor ). Você pode ler mais sobre processadores aqui e aqui . O método mais importante é o processo. Na qual podemos obter uma lista de todos os elementos anotados e realizar as verificações necessárias.

 @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) { return false; } 

No começo, sinceramente ingênuo, pensei que trabalhar com tipos na fase de compilação é realizado em termos de reflexão, mas ... não. Tudo é baseado em elementos lá.

Elemento ( javax.lang.model.element.Element ) - a interface principal para trabalhar com a maioria dos elementos estruturais da linguagem. Um elemento possui descendentes que determinam com mais precisão as propriedades de um elemento específico (para detalhes, veja aqui ):

 package ds.magic.example.implement; // PackageElement public class Unit // TypeElement { private int id; // VariableElement public void setId(int id) { // ExecutableElement this.id = id; } } 

TypeMirror ( javax.lang.model.type.TypeMirror ) é algo como Classe <?> Retornada pelo método getClass (). Por exemplo, eles podem ser comparados para descobrir se os tipos de elementos correspondem. Você pode obtê-lo usando o Element.asType() . Esse tipo também retorna algumas operações de tipo, como TypeElement.getSuperclass() ou TypeElement.getInterfaces() .

Tipos ( javax.lang.model.util.Types ) - aconselho que você dê uma olhada mais de perto nesta classe. Você pode encontrar muitas coisas interessantes lá. Em essência, este é um conjunto de utilitários para trabalhar com tipos. Por exemplo, permite recuperar um TypeElement de um TypeMirror.

 private TypeElement asTypeElement(TypeMirror typeMirror) { return (TypeElement)processingEnv.getTypeUtils().asElement(typeMirror); } 

TypeKind ( javax.lang.model.type.TypeKind ) - uma enumeração que permite esclarecer informações de tipo, verificar se o tipo é uma matriz (ARRAY), um tipo personalizado (DECLARED), uma variável de tipo (TYPEVAR) etc. Você pode obtê-lo através do TypeMirror.getKind()

ElementKind ( javax.lang.model.element.ElementKind ) - enumeração, permite esclarecer informações sobre o elemento, verificar se o elemento é um pacote (PACKAGE), classe (CLASS), método (METHOD), interface (INTERFACE) etc.

Nome ( javax.lang.model.element.Name ) - a interface para trabalhar com o nome do elemento pode ser obtida através de Element.getSimpleName() .

Basicamente, esses tipos foram suficientes para eu escrever um algoritmo de verificação.

Eu quero observar outro recurso interessante. As implementações das interfaces Element no Eclipse estão nos pacotes org.eclipse ..., por exemplo, os elementos que representam os métodos são do tipo org.eclipse.jdt.internal.compiler.apt.model.ExecutableElementImpl . Isso me deu a ideia de que essas interfaces são implementadas por cada IDE de forma independente.

Algoritmo de validação


Primeiro, você precisa criar a anotação em si. Muito já foi escrito sobre o assunto (por exemplo, aqui ), por isso não vou me deter sobre isso em detalhes. Só posso dizer que, para o nosso exemplo, precisamos adicionar duas anotações @Target e @Retention . O primeiro indica que nossa anotação só pode ser aplicada ao método e o segundo que a anotação existirá apenas no código-fonte.

As anotações devem ser especificadas em qual interface implementa o método anotado (o método ao qual a anotação é aplicada). Isso pode ser feito de duas maneiras: especifique o nome completo da interface com uma string, por exemplo, @Implement("com.ds.IInterface") ou passe a classe da interface diretamente: @Implement(IInterface.class) . A segunda maneira é claramente melhor. Nesse caso, o compilador monitorará o nome correto da interface. A propósito, se você chamar esse membro value (), ao adicionar anotações ao método, não será necessário especificar explicitamente o nome desse parâmetro.

 @Target({ElementType.METHOD}) @Retention(RetentionPolicy.SOURCE) public @interface Implement { Class<?> value(); } 

Então a diversão começa - a criação do processador. No método de processo, obtemos uma lista de todos os elementos anotados. Em seguida, obtemos a anotação em si e seu significado - a interface especificada. Em geral, a estrutura da classe do processador é assim:

 @SupportedAnnotationTypes({"ds.magic.annotations.compileTime.Implement"}) @SupportedSourceVersion(SourceVersion.RELEASE_8) public class ImplementProcessor extends AbstractProcessor { private Types typeUtils; @Override public void init(ProcessingEnvironment procEnv) { super.init(procEnv); typeUtils = this.processingEnv.getTypeUtils(); } @Override public boolean process(Set<? extends TypeElement> annos, RoundEnvironment env) { Set<? extends Element> annotatedElements = env.getElementsAnnotatedWith(Implement.class); for(Element annotated : annotatedElements) { Implement annotation = annotatedElement.getAnnotation(Implement.class); TypeMirror interfaceMirror = getValueMirror(annotation); TypeElement interfaceType = asTypeElement(interfaceMirror); //... } return false; } private TypeElement asTypeElement(TypeMirror typeMirror) { return (TypeElement)typeUtils.asElement(typeMirror); } } 

Quero observar que você não pode apenas obter e obter anotações de valor assim. Quando você tenta chamar annotation.value() , uma MirroredTypeException será lançada, mas a partir dela você pode obter um TypeMirror. Este método de trapaça, bem como o recebimento correto do valor, encontrei aqui :

 private TypeMirror getValueMirror(Implement annotation) { try { annotation.value(); } catch(MirroredTypeException e) { return e.getTypeMirror(); } return null; } 

A verificação em si consiste em três partes, se pelo menos uma delas falhar, é necessário exibir uma mensagem de erro e prosseguir para a próxima anotação. A propósito, você pode exibir uma mensagem de erro usando o seguinte método:

 private void printError(String message, Element annotatedElement) { Messager messager = processingEnv.getMessager(); messager.printMessage(Kind.ERROR, message, annotatedElement); } 

A primeira etapa é verificar se as anotações de valor são uma interface. Tudo é simples aqui:

 if (interfaceType.getKind() != ElementKind.INTERFACE) { String name = Implement.class.getSimpleName(); printError("Value of @" + name + " must be an interface", annotated); continue; } 

Em seguida, você precisa verificar se a classe na qual o método anotado está localizado realmente implementa a interface especificada. No começo, eu tolamente implementei esse teste com as mãos. Mas, usando um bom conselho, examinei Types e encontrei o método Types.isSubtype() lá, que verificará toda a árvore de herança e retornará true se a interface especificada estiver lá. Importante, ele pode trabalhar com tipos genéricos, ao contrário da primeira opção.

 TypeElement enclosingType = (TypeElement)annotatedElement.getEnclosingElement(); if (!typeUtils.isSubtype(enclosingType.asType(), interfaceMirror)) { Name className = enclosingType.getSimpleName(); Name interfaceName = interfaceType.getSimpleName(); printError(className + " must implemet " + interfaceName, annotated); continue; } 

Por fim, você precisa garantir que a interface tenha um método com a mesma assinatura que a anotada. Gostaria de usar o método Types.isSubsignature() , mas, infelizmente, não funcionará corretamente se o método tiver parâmetros de tipo. Então arregaçamos as mangas e escrevemos todos os cheques com as mãos. E nós temos três deles novamente. Bem, mais precisamente, a assinatura do método consiste em três partes: o nome do método, o tipo do valor de retorno e a lista de parâmetros. Você precisa seguir todos os métodos da interface e encontrar o que passou nas três verificações. Seria bom não esquecer que o método pode ser herdado de outra interface e executar recursivamente as mesmas verificações para as interfaces subjacentes.

A chamada deve ser feita no final do loop no método de processo, assim:

 if (!haveMethod(interfaceType, (ExecutableElement)annotatedElement)) { Name name = interfaceType.getSimpleName(); printError(name + " don't have \"" + annotated + "\" method", annotated); continue; } 

E o próprio método haveMethod () se parece com isso:

 private boolean haveMethod(TypeElement interfaceType, ExecutableElement method) { Name methodName = method.getSimpleName(); for (Element interfaceElement : interfaceType.getEnclosedElements()) { if (interfaceElement instanceof ExecutableElement) { ExecutableElement interfaceMethod = (ExecutableElement)interfaceElement; // Is names match? if (!interfaceMethod.getSimpleName().equals(methodName)) { continue; } // Is return types match (ignore type variable)? TypeMirror returnType = method.getReturnType(); TypeMirror interfaceReturnType = method.getReturnType(); if (!isTypeVariable(interfaceReturnType) && !returnType.equals(interfaceReturnType)) { continue; } // Is parameters match? if (!isParametersEquals(method.getParameters(), interfaceMethod.getParameters())) { continue; } return true; } } // Recursive search for (TypeMirror baseMirror : interfaceType.getInterfaces()) { TypeElement base = asTypeElement(baseMirror); if (haveMethod(base, method)) { return true; } } return false; } private boolean isParametersEquals(List<? extends VariableElement> methodParameters, List<? extends VariableElement> interfaceParameters) { if (methodParameters.size() != interfaceParameters.size()) { return false; } for (int i = 0; i < methodParameters.size(); i++) { TypeMirror interfaceParameterMirror = interfaceParameters.get(i).asType(); if (isTypeVariable(interfaceParameterMirror)) { continue; } if (!methodParameters.get(i).asType().equals(interfaceParameterMirror)) { return false; } } return true; } private boolean isTypeVariable(TypeMirror type) { return type.getKind() == TypeKind.TYPEVAR; } 

Vê o problema? Não? E ela está lá. O fato é que não consegui encontrar uma maneira de obter os parâmetros de tipo reais para interfaces genéricas. Por exemplo, eu tenho uma classe que implementa a interface Predicate :
 MyPredicate implements Predicate&ltString&gt { @Implement(Predicate.class) boolean test(String t) { return false; } } 

Ao analisar o método na classe, o tipo do parâmetro é String e, na interface, é T , e todas as tentativas de obter String vez dele não levaram a nada. No final, não tive nada melhor do que simplesmente ignorar os parâmetros de tipo. A verificação será aprovada com quaisquer parâmetros de tipo reais, mesmo que não correspondam. Felizmente, o compilador lançará um erro se o método não tiver implementação padrão e não for implementado na classe base. Mas ainda assim, se alguém souber como contornar isso, ficarei extremamente grato pela dica.

Conecte-se ao Eclipse


Pessoalmente, amo Eclipce e, na minha prática, usei apenas isso. Portanto, descreverei como conectar o processador a este IDE. Para que o Eclipse veja o processador, é necessário compactá-lo em um .JAR separado, no qual a anotação também será. Nesse caso, você precisa criar a pasta META-INF / services no projeto e criar o arquivo javax.annotation.processing.Processor lá e indicar o nome completo da classe do processador: ds.magic.annotations.compileTime.ImplementProcessor , no meu caso. Por via das dúvidas, darei uma captura de tela, mas quando nada funcionou para mim, quase comecei a pecar na estrutura do projeto.

imagem

Em seguida, colete .JAR e conecte-o ao seu projeto, primeiro como uma biblioteca comum, para que a anotação fique visível no código. Em seguida, conectamos o processador ( aqui está mais detalhado). Para fazer isso, abra as propriedades do projeto e selecione:

  1. Compilador Java -> Processamento de anotação e marque a caixa "Ativar processamento de anotação".
  2. Compilador Java -> Processamento de anotações -> Caminho da fábrica marque a caixa de seleção "Ativar configurações específicas do projeto". Em seguida, clique em Adicionar JARs ... e selecione o arquivo JAR criado anteriormente.
  3. Concorda em reconstruir o projeto.

Sumário


Todos juntos e no projeto Eclipse podem ser vistos no GitHub . No momento da escrita, existem apenas duas classes, se a anotação puder ser chamada assim: Implement.java e ImplementProcessor.java. Eu acho que você já adivinhou o propósito deles.

Talvez essa anotação possa parecer inútil para alguns. Talvez seja. Mas, pessoalmente, eu mesmo o uso em vez do @Override , quando os nomes dos métodos não se encaixam bem no objetivo da classe. E até agora, não tenho vontade de me livrar dela. Em geral, fiz uma anotação para mim, e o objetivo do artigo era mostrar qual rake estava atacando. Espero ter conseguido. Obrigado pela atenção.

PS. Agradecemos aos usuários do ohotNik_alex e Comdiv por sua ajuda na correção de bugs.

Source: https://habr.com/ru/post/pt414715/


All Articles