[Перевод| Анализ исходного кода с помощью Java 6 API


Вы когда-нибудь задумывались как инструменты вроде Checkstyle или FindBugs выполняют статический анализ кода, или как IDE такие как NetBeans или Eclipse выполняют быстрое исправление кода или находят ссылки на поле, объявленное в вашем коде? Во многих случаях, IDE имеют свои собственные API для разбора исходного кода и генерируют стандартную древовидную структуру, называемую aбстрактное синтаксическое дерево (AST) и разбирают дерево, которое может быть использовано для глубокого анализа элементов исходного кода. Хорошая новость: теперь возможно выполнять описанные задачи и многое другое с помощью трёх новых API введённых в Java как часть шестой редакции стандарта Java Standard Edition 6. Этими API, которые могут быть интересны разработчикам Java приложений которым нужно выполнять анализ исходного кода, являются Java Compiler API (JSR 199), Pluggable Annotation Processing API (JSR 269), и Compiler Tree API.

В этой статье мы рассмотрим возможности каждого из этих API и разработаем простое демо приложение, которое проверяет некие правила кодирования на Java на наборе исходных файлах переданных в него. Эта утилита также выводит сообщения о нарушения кодирования и местонахождение нарушения. Рассмотрим простой класс, который переопределяет (англ. overrides) метод equals() класса Object. Правило кодирования, которое проверяется, заключается в том, что каждый класс, который реализует метод equals() должен также переопределять метод hashCode() с правильной сигнатурой. Вы можете увидеть, что класс TestClass описанный ниже не объявляет метод hashCode(), хотя он имеет метод equals().

public class TestClass implements Serializable {
 
  int num;

  @Override
  public boolean equals(Object obj) {
        if (this == obj)
                return true;
        if ((obj == null) || (obj.getClass() != this.getClass()))
                return false;
        TestClass test = (TestClass) obj;
        return num == test.num;
  }
}

Давайте проанализируем этот класс как часть процесса компиляции с помощью этих трёх API.

Вызов компилятора программно из кода: Java Compiler API

Мы все используем утилиту командной строки javac для компиляции исходных файлов Java в class файлы. Так зачем же нам нужно API для компиляции Java файлов? Хорошо, ответ очень прост: как ясно из названия, это новое стандартное API позволяет нам вызывать компилятор наших собственных Java приложений; т.е., вы можете программировано взаимодействовать с компилятором и тем самым сделать компиляцию частью сервисов уровня приложения. Несколько типичных использований этого API перечислены ниже.

  • API компилятора помогает серверам приложений минимизировать время занимаемое развёртыванием (deploying) приложений. Например, избегая накладных расходов от использования внешнего компилятора для компилирования исходников сервлета сгенерированных из JSP страниц.
  • Инструменты разработки вроде IDE и анализаторы кода могут вызывать компилятор во время редактирования или утилиты сборки, которые значительно занимают время компиляции.

Классы Java компилятора расположены в пакете javax.tools. Класс ToolProvider из этого пакеты предоставляет метод getSystemJavaCompiler() который возвращает экземпляр определенного класса, который реализует интерфейс JavaCompiler. Этот экземпляр компилятора может быть создан для постановки компиляционных задач, которые будут выполнять саму компиляцию. Файлы исходного кода, которые нужно скомпилировать, должны быть переданы в компиляционную задачу. Для этого, API компилятора предоставляет абстракцию файлового менеджера JavaFileManager, который позволяет Java файлам быть полученными из различных источников, такие как файловая система, базы данных, память, и другие. В этом примере мы используем StandardFileManager, файл менеджер, основанный на java.io.File. Стандартный файловый менеджер может быть получен вызовом метода getStandardFileManager() из экземпляра JavaCompiler . Фрагмент кода для выполнения указанных шагов рассмотрен ниже:

  // Берём новый экземпляр Java компилятора
  JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
  // Берём новый экземпляр реализации стандартного файлового менеджера
  StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
  // Получаем список файловых объектов java, в нашем случае мы имеем только один файл, TestClass.java
  Iterable<? extends JavaFileObject> compilationUnits1 = fileManager.getJavaFileObjectsFromFiles("TestClass.java");

Диагностический слушатель может быть опционально передан в метод getStandardFileManager() для выполнения диагностических отчётов о любых не фатальных проблемах. В этом фрагменте кода, мы передаём значения null, то есть мы не собираем диагностику из утилиты. Детали других параметров передаваемых в методы, вы можете узнать в Java 6 API. Метод getJavaFileObjectsfromFiles() класса StandardJavaFileManager возвращает все экземпляры StandardJavaFileManager, которые соответствуют предоставленным исходным Java файлам.

Следующим шагом будет постановка компиляционной задачи, которая может быть выполнена использованием метода getTask() класса JavaCompiler. В этом месте, компиляционная задача ещё не начинается. Задача может быть начата вызовом метода call() класса CompilationTask. Фрагмент кода для создания и запуска компиляционной задачи показан ниже.

  // Создаём задачу компиляции
  CompilationTask task = compiler.getTask(null, fileManager, null, null, null, compilationUnits1);
  // Выполняем задачу компиляции
  task.call();

Если, ошибок компиляции нет, то в папке назначения сгенерируется файл TestClass.class.

Обработка аннотаций: Подключаемое Annotation Processing API

Как мы все знаем, стандарт Java SE 5.0 внедрил поддержку для добавления и обработки метаданных или аннотаций к элементам вроде Java классов, полей, методов и прочего. Аннотации полностью обрабатываются сборочными утилитами или окружениями выполнения (runtime environments) для выполнения полезных задач наподобие управления поведением приложения, генерации кода, и.т.п.. Java 5 позволяет обрабатывать аннотированные данные, как во время компиляции, так и во время работы приложения. Обработчики аннотаций это утилиты, которые могут быть динамически подключены в компилятор для анализа исходных файлов и обработки аннотаций в них. Обработчики аннотаций могут полностью использовать метаданные для выполнения многих задач, включая и не ограничиваясь следующим:

  • Аннотации могут быть использованы для генерации дескрипторных файлов для развёртки приложений вроде persistence.xml или ejb-jar.xml, в случаях entity классов и enterprise beans соответственно.
  • Обработчики аннотаций могут использовать метаданные для генерации кода. Например, обработчик может генерировать интерфейсы Home и Remote для правильно аннотированного enterprise bean.
  • Аннотации могут быть использованы для проверки кода.

Стандарт Java 5.0 предоставил инструментарий обработки аннотаций (APT) и связанное зеркальное Reflection API (com.sun.mirror.*) для обработки аннотаций и моделирования обработанной информации. Инструмент APT запускает соответствующие обработчики аннотаций встречающихся в исходном файле. Зеркальное Reflection API предоставляет компиляционное, доступное только для чтения представление исходного файла. Главный недостаток APT в том, что он не стандартизирован; т.е. APT специфичен для Sun JDK.

Стандарт Java SE 6 ввёл новый инструментарий, называемый Pluggable Annotation Processing framework, который предоставляет стандартную поддержку для написания собственных обработчиков аннотаций. Он называется подключаемым («pluggable») потому что обработчик аннотации может быть подключен в javac динамически, и может оперировать набором аннотаций которые встречаются в исходном коде. Этот инструментарий (framework) разделён на две части: API для объявления и взаимодействия с обработчиками аннотаций — пакет javax.annotation.processing, и API для моделирования языка программирования Java — пакет javax.lang.model.

Написание собственного обработчика аннотаций

В следующей секции описано как создать собственный обработчик аннотаций и подключить его к компиляционной задаче. Обработчик аннотации наследуется от AbstractProcessor (который является реализацией интерфейса Processor) и переопределяет его метод process().

Класс обработчика аннотации будет задекорирован двумя аннотациями классового уровня, @SupportedAnnotationTypes и @SupportedSourceVersion. Аннотация SupportedSourceVersion определяет последнюю поддерживаемую версию обработчика аннотации. Аннотация @SupportedAnnotationTypes помечает в каких аннотациях этот обработчик заинтересован. Например, @SupportedAnnotationTypes ("javax.persistence.*") будет использован если обработчику нужно обрабатывать только аннотации Java Persistence API (JPA).

Стоит отметить, что обработчик аннотации вызывается даже когда не предоставлено аннотаций, если указана поддержка типов аннотаций как @SupportedAnnotationTypes("*"). Это позволяет нам использовать все возможности API моделирования вместе с Tree API для основных нужд обработки исходного кода. Используя эти API, можно получить много полезной информации касающейся модификаторов, полей, методов, и всего остального.

Фрагмент кода обработчика аннотации дан ниже:

@SupportedSourceVersion(SourceVersion.RELEASE_6)
@SupportedAnnotationTypes("*")
public class CodeAnalyzerProcessor extends AbstractProcessor {
  @Override
  public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnvironment) {
    for (Element e : roundEnvironment.getRootElements()) {
      System.out.println("Element is " + e.getSimpleName());
      // Напишите здесь код для анализа каждого корневого элемента
    }
    return true;
  }
}

Обработчики аннотаций вызываются на те аннотации на которые они настроены. Обработка аннотаций может проходить в несколько раундов. Например, в первом раунде, будут обработаны первоначальные исходные Java файлы; во втором раунде, будут учтены файлы сгенерированные во время первого этапа обработки, и так далее. Обработчик должен переопределять метод process() класса AbstractProcessor. Этот метод принимает два аргумента:

  1. Набор типов/элементов/аннотаций найденных в исходном файле.
  2. RoundEnvironment который инкапсулирует информацию о текущем раунде обработки аннотации.

Если обработчик поддерживает тип аннотации, то метод process() возвращает true и другие обработчики не будут вызваны для этих аннотаций. Иначе, метод метод process() возвращает значение false и будет вызван следующий обработчик, если есть.

Подключение обработчика аннотаций

Теперь, когда наш обработчик аннотации готов к использованию, давайте посмотрим, как вызвать этот обработчик как часть процесса компиляции. Обработчик может быть вызван либо из утилиты командной строки javac либо программно через специальный Java класс. Утилита javac из Java SE 6 предоставляет опцию -processor которая принимает полное имя (fully qualified name) обработчика аннотаций который нужно подключить. Синтаксис для этого следующий:

javac -processor demo.codeanalyzer.CodeAnalyzerProcessor TestClass.java

где CodeAnalyzerProcessor это класс обработчика аннотации и TestClass это входящий для обработки Java файл. Эта утилита ищет CodeAnalyzerProcessor в classpath; т.е., нужно поместить этот класс в classpath.

Модифицированный фрагмент кода для подключения обработчика программно показан ниже. Метод setProcessors() класса CompilationTask позволяет подключить в задачу компиляции множество обработчиков аннотаций. Этот метод нужно вызвать перед методом call(). Также помните, что если обработчик аннотаций подключен к задаче компиляции, то сперва будет выполнена обработка аннотации, а только потом задача компиляции. Стоит отметить, что обработка аннотаций не произойдет если код содержит ошибки компиляции.

  CompilationTask task = compiler.getTask(null, fileManager, null, null, null, compilationUnits1);
  // Создаём список обработчиков аннотаций
  LinkedList<AbstractProcessor> processors = new LinkedList<AbstractProcessor>();
  // Добавляем обработчик аннотаций в список
  processors.add(new CodeAnalyzerProcessor());
  // Передаём список обработчиков аннотаций в компиляционную задачу
  task.setProcessors(processors);
  // Выполняем компиляционную задачу
  task.call();

Если мы выполним этот код, то обработчик аннотации во время компиляции TestClass.java будет печатать его название «TestClass».

Работа с AST: The Compiler Tree API

Aбстрактное синтаксическое дерево (AST) это доступное только для чтения представление исходного кода как дерево узлов, где каждый узел представляет языковую конструкцию Java. Например, Java класс представлен как ClassTree, объявления методов представлены как MethodTrees, объявления переменных как VariableTrees, аннотации как AnnotationTree и т.д..

Compiler Tree API предоставляет доступ к AST исходного кода Java и также предоставляет несколько утилит вроде TreeVisitors, TreeScanners, и т.д., для выполнения операций над AST. Более глубокий анализ исходного кода может быть выполнен используя TreeVisitor, который обходит все дочерние узлы дерева для извлечения необходимой информации о полях, методах, аннотациях, и других элементах класса. Посетители дерева реализованы в стиле шаблона проектирования Посетитель (Visitor). Когда Посетитель передаётся в метод accept дерева, то будет вызван метод visitXYZ который более подходит к этому дереву.

Java Compiler Tree API предоставляет три реализации TreeVisitor: SimpleTreeVisitor, TreeScanner, и TreePathScanner который и используется в нашем демо приложении. TreePathScanner это TreeVisitor который обходит все дочерние узлы дерева и предоставляет возможность для поддержки путей к родительским узлам. Для сканирования дерева нужно вызывать его метод scan(). Для посещения узлов необходимого типа, просто переопределите соответствующий visitXYZ метод. Внутри вашего посещающего метода вызовете super.visitXYZ для посещения дочерних узлов. Фрагмент кода полного класса Посетителя показан ниже:

  public class CodeAnalyzerTreeVisitor extends TreePathScanner<Object, Trees>  {

    @Override
    public Object visitClass(ClassTree classTree, Trees trees) {
      // ...какие нибудь действия
      return super.visitClass(classTree, trees);
    }

    @Override
    public Object visitMethod(MethodTree methodTree, Trees trees) {
      // ...какие нибудь действия
      return super.visitMethod(methodTree, trees);
    }

  } 

Как видите, методы-посетители принимают два аргумента: дерево отображающее узлы (ClassTree для узлов классов, MethodTree для узлов методов, и т.д.) и объект Trees. Класс Trees предоставляет утилитные методы для извлечения информации о пути элементов в дереве. Важно помнить, что объект Trees выступает мостом между JSR 269 и Compiler Tree API. В этом примере, есть только один корневой элемент, т.е. сам TestClass.

  CodeAnalyzerTreeVisitor visitor = new CodeAnalyzerTreeVisitor();

  @Override
  public void init(ProcessingEnvironment pe) {
    super.init(pe);
    trees = Trees.instance(pe);
  }
  for (Element e : roundEnvironment.getRootElements()) {
    TreePath tp = trees.getPath(e);
    // Вызов сканера
    visitor.scan(tp, trees);
  }

Следующая секция показывает получение информации исходного кода используя Tree API и вводит общую модель используемую для проверки кода далее. Метод visitClass() вызывается когда класс, интерфейс или перечисляемый тип (enum) посещается из AST с аргументом ClassTrees. Точно также метод visitMethod() вызывается для всех методов с аргументом MethodTree, visitVariable() для всех переменных с аргументом VariableTree, и т.д..

  @Override
  public Object visitClass(ClassTree classTree, Trees trees) {
    // Сохранение деталей посещяемого класса в модель
    JavaClassInfo clazzInfo = new JavaClassInfo();
    // Получаем текущий путь узла
    TreePath path = getCurrentPath();
    // Берём тип элемента соответствующего классу
    TypeElement e = (TypeElement) trees.getElement(path);
    // Устанавливаем полное определёное имя класса в модель
    clazzInfo.setName(e.getQualifiedName().toString());
    // Устанавливаем предка от которого унаследован класс 
    clazzInfo.setNameOfSuperClass(e.getSuperclass().toString());
    // Устанавливаем интерфейсы которые класс реализовывает
    for (TypeMirror mirror : e.getInterfaces()) {
      clazzInfo.addNameOfInterface(mirror.toString());
    }
    return super.visitClass(classTree, trees);
  }

Класс JavaClassInfo используемый в этом коде это наша модель для хранения информации о исходном коде. После выполнения этого кода, информация касающаяся класса вроде полного имени класса (fully qualified name), имени родительского класса, интерфейсов реализуемых TestClass, и т.д., будет извлечена и сохранена в нашей собственной модели для будущих проверочных нужд.

Установление местонахождения токенов в исходном коде

Итак, мы пока занимались получением информации о различных узлах в AST и созданию объектной модели для информации о классах, методах и полях.
С этой информацией, мы можем проверить следует ли исходный код хорошим программистским практикам, соответствует ли он спецификациям, и другое. Эта информация может быть очень полезной для проверяющих инструментов вроде Checkstyle или FindBugs, но они также требуют детали о местонахождении токенов (token) которые нарушили правила, потому что они предоставляют детали о местонахождении пользователям.

Объект SourcePositions, который является частью Compiler Tree API, предоставляет позиции всех AST узлов. Этот объект содержит полезную информацию о позициях начала и конца всех ClassTree, MethodTree, FieldTree, и т.д., в файле. Позиция определена как простая позиция символа (offset, сдвиг) с начала компилированного модуля (CompilationUnit) где первый символ занимает офсет 0. Фрагмент кода ниже показывает как можно получить офсетную позицию символа переданного объекта Tree с начала компилируемого модуля.

  public static LocationInfo getLocationInfo(Trees trees, TreePath path, Tree tree) {
    LocationInfo locationInfo = new LocationInfo();
    SourcePositions sourcePosition = trees.getSourcePositions();
    long startPosition = sourcePosition.getStartPosition(path.getCompilationUnit(), tree);
    locationInfo.setStartOffset((int)startPosition);
    return locationInfo;
  }

Однако, если нам нужно получить позицию token который даст имя самого класса или метода, тогда этого будет не достаточно. Чтобы найти нужную позицию из компилируемого модуля, одним из решений будет найти токены из символьного содержимого исходного файла. Мы можем получить символьное содержимое из JavaFileObject соответствующего компилируемого модуля, как продемонстрировано ниже.

  // Получаем делево компилируемых модулей 
  CompilationUnitTree compileTree = treePath.getCompilationUnit();
  // Берём исходный файл java который будет обработан
  JavaFileObject file = compileTree.getSourceFile();
  // Извлекаем символьное содержимое файла в строку
  String javaFile = file.getCharContent(true).toString();
  // Конвертируем содержимое java файла в символьный буфер
  CharBuffer charBuffer = CharBuffer.wrap (javaFile.toCharArray()); 

Следующий фрагмент кода находит позицию имени класса из исходного кода. Используются классы java.util.regex.Pattern и java.util.regex.Matcher для получения первого вхождения токена совпадающего с именем класса в символьном буфере, начиная с позиции начала дерева класса из дерева компилируемого модуля.

  LocationInfo clazzNameLoc = (LocationInfo) clazzInfo.getLocationInfo();
  int startIndex = clazzNameLoc.getStartOffset();
  int endIndex = -1;
  if (startIndex >= 0) {
    String strToSearch = buffer.subSequence(startIndex, 
    buffer.length()).toString();
    Pattern p = Pattern.compile(clazzName);
    Matcher matcher = p.matcher(strToSearch);
    matcher.find();
    startIndex = matcher.start() + startIndex;
    endIndex = startIndex + clazzName.length();
  } 
  clazzNameLoc.setStartOffset(startIndex);
  clazzNameLoc.setEndOffset(endIndex);
  clazzNameLoc.setLineNumber(compileTree.getLineMap().getLineNumber(startIndex));

Класс LineMap из Complier Tree API предоставляет карту символьных позиций и номеров строк из CompilationUnitTree. Мы можем получить номер строки нужного токена передав позицию начала в метод getLineMap() дерева CompilationUnitTree.

Проверка исходного кода на соответствие правилам

Теперь, когда мы полностью получили необходимую информацию из AST, следующая задача проверить что исходный код удовлетворяет определённым стандартам кодирования. Правила кодирования настроены в XML файле и управляются через наш класс называемые RuleEngine. Этот класс берёт правила из XML файла и применяет их одно за другим. Если класс не удовлетворяет правилу, RuleEngine возвращает список объектов ErrorDescription. Класс ErrorDescription инкапсулирует сообщения об ошибках и местонахождение ошибок в исходном коде.

  ClassFile clazzInfo = ClassModelMap.getInstance().getClassInfo(className);
  for (JavaCodeRule rule : getRules()) {
    // Применяем правила один за одним 
    Collection<ErrorDescription> problems = rule.execute(clazzInfo);
    if (problems != null) {
      problemsFound.addAll(problems);
    }
  }

Каждое правило реализовано как Java класс; модельная информация класса для проверки передаётся в этот класс правила. Класс правила инкапсулирует логику проверки используя модельную информацию. Реализация простого правила (OverrideEqualsHashCode) приведена ниже. Это правило проверят что класс, который переопределяет метод equal(), должен также переопределять метод hashCode(). Здесь мы итерируем по методам класса и проверяем, следуют ли они контракту по equals() и hashCode(). В TestClass, метод hashCode() отсутствует хотя есть метод equals(), в результате чего правило возвращает объект ErrorDescription содержащий соответствующее сообщение об ошибке и местонахождение ошибки.

  public class OverrideEqualsHashCode extends JavaClassRule {

    @Override
    protected Collection<ErrorDescription> apply(ClassFile clazzInfo) {
      boolean hasEquals = false;
      boolean hasHashCode = false;
      Location errorLoc = null;
      for (Method method : clazzInfo.getMethods()) {
        String methodName = method.getName();
        ArrayList paramList = (ArrayList) method.getParameters();
        if ("equals".equals(methodName) && paramList.size() == 1) {
          if ("java.lang.Object".equals(paramList.get(0))) {
            hasEquals = true;
            errorLoc = method.getLocationInfo();
          }
        } else if ("hashCode".equals(methodName) && method.getParameters().size() == 0) {
          hasHashCode = true;
        }
      }
      if (hasEquals) {
        if (hasHashCode) {
          return null;
        } else {
          StringBuffer errrMsg = new StringBuffer();
          errrMsg.append(CodeAnalyzerUtil.getSimpleNameFromQualifiedName(clazzInfo.getName()));
          errrMsg.append(" : The class that overrides equals() should ");
          errrMsg.append("override hashcode()");
          Collection<ErrorDescription> errorList = new ArrayList<ErrorDescription>();
          errorList.add(setErrorDetails(errrMsg.toString(), errorLoc));
          return errorList;
        }
      }
    return null;
  }

}

Запуск демонстрационного приложения

Вы можете скачать бинарный файл этого демо приложения из секции Ресурсы. Сохраните этот файл в любую локальную папку. Используйте следующую команду для запуска приложения из командной строки:

Заключение

Эта статья рассказывает как новое Java 6 API может быть использовано для вызова компилятора из Java кода и как разбирать и анализировать исходный код используя подключаемые обработчики аннотаций и Посетители (visitors) деревьев. Используя стандартное API вместо специфичной для IDE логики разбора/анализа делает возможным переиспользовать код в разных инструментах и окружениях. Мы только поверхностно очертили здесь эти три компиляционные API; вы можете найти много более полезных возможностей покопавшись глубже в них.

Исходный код демонстрационного приложения также расположен на GitHub

© Seema Richard.

Реклама

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s