Статьи

Построение моделей кода Java из исходного кода и файлов JAR

Недавно я провел некоторое время, работая над  эффективными java , которые находятся на пути к достижению 300 звезд на GitHub (не стесняйтесь помогать в достижении цели: D).

Effectivejava — это инструмент для выполнения запросов к вашему Java-коду. Он основан на другом проекте, в который я участвую,  javaparser . Javaparser принимает в качестве входного исходного кода Java и создает абстрактное синтаксическое дерево (AST). Мы можем выполнить простой анализ непосредственно на AST. Например, мы можем выяснить, какие методы принимают более 5 параметров (вы можете реорганизовать их…). Однако более сложный анализ требует  разрешения символов .

В этом посте я опишу, как я работаю над реализацией разрешения символов с учетом как исходного кода, так и файлов JAR. В этом первом посте мы создадим однородное представление как исходного кода, так и файлов JAR, в следующем посте мы решим эти символы, исследуя эти модели.

Код доступен на GitHub, на ветке  символьной ссылки  эффективной Java .

Разрешающие символы

По какой причине нам нужно разрешить символы?

Учитывая этот код:

foo.method(a,b,c);

нам нужно выяснить, что такое  foomethodabc  . Это ссылки на локальные переменные? К аргументам текущего метода? Чтобы поля объявлены в классе? Полям, унаследованным от класса суперкласса? Какой у них тип? Чтобы ответить на этот вопрос, мы должны уметь разрешать символы.

Для решения символов мы можем перемещаться по AST и применять правила определения объема. Например, мы можем посмотреть, соответствует ли определенный символ локальной переменной. Если нет, мы можем посмотреть среди параметров этого метода. Если мы все еще не можем найти соответствие, нам нужно искать среди полей, объявленных классом, и если нам все еще не повезло, нам, возможно, придется везти среди полей, унаследованных этим классом.

Теперь правила определения объема намного сложнее, чем набор маленьких шагов, которые я только что описал. Особенно сложно разрешить методы из-за перегрузки. Однако одним из ключевых моментов является то, что для решения символов нам нужно искать среди импортированных классов, расширенных классов и внешних классов в целом, которые могут быть частью проекта или импортироваться как зависимости.

Таким образом, чтобы решить символ, мы должны искать соответствующие объявления:

  1. на AST классов проекта мы изучаем
  2. среди классов, содержащихся в файлах JAR, используемых в качестве зависимостей

Javaparser предоставляет нам AST, которые нам нужны для первого пункта, для второго мы собираемся построить модель классов в файлах JAR с использованием  Javassist .

Создайте модель классов, содержащихся в JAR-файлах

Наш решатель символов должен просмотреть список записей (наши   записи пути к классам ) по порядку и посмотреть, можно ли там найти определенный класс. Для этого нам нужно открыть файлы JAR и просмотреть их содержимое. По соображениям производительности мы могли бы захотеть создать кеш элементов, содержащихся в данном JAR.

(ns app.jarloading
  (:use [app.javaparser])
  (:use [app.operations])
  (:use [app.utils])
  (:import [app.operations Operation]))

(import java.net.URLDecoder)
(import java.util.jar.JarEntry)
(import java.util.jar.JarFile)
(import javassist.ClassPool)
(import javassist.CtClass)

; An element on the classpath (a single class, interface, enum or resource file)
(defrecord ClasspathElement [resource path contentAsStreamThunk])

(defn- jarEntryToClasspathElement [jarFile jarEntry]
  (let [name (.getName jarEntry)
        content (fn [] (.getInputStream jarFile jarEntry))]
    (ClasspathElement. jarFile name content)))

(defn getElementsEntriesInJar
  "Return a set of ClasspathElements"
  [pathToJarFile]
  (let [url (URLDecoder/decode pathToJarFile "UTF-8")
        jarfile (new JarFile url)
        entries (enumeration-seq (.entries jarfile))
        entries' (filter (fn [e] (not (.isDirectory e))) entries )]
    (map (partial jarEntryToClasspathElement jarfile) entries')))

(defn getClassesEntriesInJar
  "Return a set of ClasspathElements"
  [pathToJarFile]
  (filter (fn [e] (.endsWith (.path e) ".class")) (getElementsEntriesInJar pathToJarFile)))

(defn pathToTypeName [path]
  (if (.endsWith path ".class")
    (let [path' (.substring path 0 (- (.length path) 6))
          path'' (clojure.string/replace path' #"/" ".")
          path''' (clojure.string/replace path'' "$" ".")]
      path''')
    (throw (IllegalArgumentException. "Path not ending with .class"))))

(defn findEntry
  "return the ClasspathElement corresponding to the given name, or nil"
  [typeName classEntries]
  (first (filter (fn [e] (= typeName (pathToTypeName (.path e)))) classEntries)))

(defn findType
  "return the CtClass corresponding to the given name, or nil"
  [typeName classEntries]
  (let [entry (findEntry typeName classEntries)
        classPool (ClassPool/getDefault)]
    (if entry
      (.makeClass classPool ((.contentAsStreamThunk entry)))
      nil)))

Как мы начнем? Прежде всего, мы читаем записи, перечисленные в банке ( getElementEntriesInJar ). Таким образом, мы получаем список  ClasspathElements . Затем мы сосредоточимся только на   файлах .class ( getClassesEntriesInJar ). Этот метод должен вызываться один раз для jar-файла, а результат должен кэшироваться. По заданному списку  ClasspathElement  мы можем затем найти элемент, соответствующий данному имени (например,  com.github.javaparser.ASTParser ). Для этого мы можем использовать метод findEntry . Или мы также можем загрузить этот класс с помощью  Javassist : это то, что делает метод  findType , возвращая экземпляр  CtClass .

Почему бы просто не использовать отражение?

Кто-то может подумать, что было бы проще просто добавить зависимости в путь к классуffectivejava, а затем использовать обычный загрузчик классов и рефлексию для получения необходимой информации. Хотя было бы проще, есть некоторые недостатки:

  1. когда класс загружен, статические инициализаторы выполняются, и это может быть не то, что мы хотим
  2. это может конфликтовать с реальными зависимостями эффективной Java.
  3. Наконец, не вся информация, доступная в байт-коде, легко доступна через API отражения.

Решить символы: объединение разнородных моделей

Хорошо, теперь, чтобы решить символы, нам нужно реализовать правила области видимости и перемещаться как по AST,  полученным из Javaparser, так и по  CtClasses,  полученным из Javassist. Мы увидим подробности в следующем сообщении в блоге, но сначала нам нужно рассмотреть еще один аспект. Рассмотрим этот код:

package me.tomassetti;

import com.github.someproject.ClassInJar;

public class MyClass extends ClassInJar {
    private int myDeclaredField;

    public int foo(){
        return myDeclaredField + myInheritedField;
    }
}

В этом случае мы предполагаем иметь JAR, содержащий класс com.github.someproject.ClassInJar,  который объявил поле  myInheritedField . Когда мы будем решать символы, у нас будут эти отображения:

  • myDeclaredField will be resolved to an instance of com.github.javaparser.ast.body.VariableDeclarator (in Javaparser we have nodes of type FieldDeclaration which maps to constructs such as private int a, b, c;.VariableDeclarators instead point to the single fields such as ab or c)
  • myInheritedField will be resolved to an instance of javassist.CtField

The problem is that we want to be able to treat them in an homogenous way: we should be able to treat each field using the same functions, irrespectively of their origin (a JAR file or a Java source file). To do so we are going to build common views using clojure protocols. I tend to view clojure’s protocols as the equivalent of Java’s interfaces.

(defprotocol FieldDecl
  (fieldName [this]))

(extend-protocol FieldDecl
  com.github.javaparser.ast.body.VariableDeclarator
  (fieldName [this]
    (.getName (.getId this))))

(extend-protocol FieldDecl
  javassist.CtField
  (fieldName [this]
    (.getName this)))

While in Java we would have to build adapters, implementing the new interface (FieldDecl) and wrapping the existing classes (VariableDeclaratorCtField) in Clojure we can just say that those classes extend the protocol and we are done.

Now we are able to treat each field as fieldDecl and we can invoke on each field fieldName. We still need to figure out how to solve the type of the field. For doing that we need to look into symbol resolution and in particular into type resolution, which is our next step.

Conclusions

Building model of Java code is something that has fascinated me for a while. As part of my master thesis I wrote a DSL which interacted with existing Java code (I had also editors, written as Eclipse plugins and code generators: it was kind of cool). In the DSL was possible to specify references to Java classes, using both source code and JAR files. I was using EMF and probably I adopted JaMoPP and Javassist for that project.

Later I built CodeModels a library to analyze ASTs of several languages (Java, JavaScript, Ruby, Html, etc.).

I think that building tools to manipulate code is a very interesting form of metaprogramming, and it should be in the toolbox of each developer. I plan to spend some more time playing with effectivejava. Fun times are coming.

Feel free to share comments and suggestions!