Разбор Java программы с помощью java программы +14


Разобрались с теорией в публикации «Модификация программы и что лучше менять: исполняемый код или AST программы?». Перейдем к практике, используя Eclipse java compiler API.



Java программа, которая переваривает java программу, начинается с работы над абстрактным синтаксическим деревом (AST)…

Перед трансформацией программы, хорошо бы научиться работать с ее промежуточным представлением в памяти компьютера. С этого и начнем.

Повторюсь выводами из своей прошлой публикации, что для анализа исходных текстов на java нет публичного и универсального API для работы с абстрактным синтаксическим деревом программы. Придется работать либо с com.sun.source.tree.* либо org.eclipse.jdt.core.dom.*

Выбор для примера в этой статье — Eclipse java compiler (ejc) и его AST модель org.eclipse.jdt.core.dom.*

Приведу несколько доводов в пользу ejc:

  • доступен в maven репозитарии и не надо надеяться на наличие tools.jar
  • реализует JavaCompiler API
  • поддерживает java 8
  • работает в Eclipse Java IDE и следовательно ejc достаточно популярный компилятор


Программа, которую я написал для примера работы с AST java программы, будет обходить все классы из jar файла и анализировать вызовы интересующих нас методов классов-логеров org.slf4j.Logger, org.apache.commons.logging.Log, org.springframework.boot.cli.util.Log

Задача с поиском исходного текста для класса легко решается, если проект публиковался в maven репозитарий вместе с артефактом типа source и в jar с классами есть файлы pom.properties или pom.xml. С извлечением этой информации, в момент выполнения программы, нам поможет класс MavenCoordHelper из артефакта io.fabric8.insight:insight-log4j и загрузчик классов из Maven репозитария MavenClassLoader из артефакта com.github.smreed:dropship.

MavenCoordHelper позволяет найти для заданного класса координаты groupId:artifactId:version из файла pom.properties в этом jar файле
    public static String getMavenSourcesId(String className) {
        String mavenCoordinates = io.fabric8.insight.log.log4j.MavenCoordHelper.getMavenCoordinates(className);
        if(mavenCoordinates==null) return null;
        DefaultArtifact artifact = new DefaultArtifact(mavenCoordinates);
        return String.format("%s:%s:%s:sources:%s", artifact.getGroupId(), artifact.getArtifactId(),
                                                    artifact.getExtension(), artifact.getVersion());
    }


MavenClassLoader позволяет загрузить исходный текст по этим координатам для анализа и составить classpath (включая транзитивные зависимости) для определения типов в программе. Загружаем из maven репозитария:
    public static LoadingCache<String, URLClassLoader> createMavenClassloaderCache() {
        return CacheBuilder.newBuilder()
                .maximumSize(MAX_CACHE_SIZE)
                .build(new CacheLoader<String, URLClassLoader>() {
                    @Override
                    public URLClassLoader load(String mavenId) throws Exception {
                        return com.github.smreed.dropship.MavenClassLoader.forMavenCoordinates(mavenId);
                    }
                });
    }


Сама инициализация компилятора EJC и работа с AST достаточно простая:
package com.github.igorsuhorukov.java.ast;

import com.google.common.cache.LoadingCache;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.CompilationUnit;
import java.net.URLClassLoader;
import java.util.Set;
import static com.github.igorsuhorukov.java.ast.ParserUtils.*;

public class Parser {
    public static final String[] SOURCE_PATH = new String[]{System.getProperty("java.io.tmpdir")};
    public static final String[] SOURCE_ENCODING = new String[]{"UTF-8"};

    public static void main(String[] args) throws Exception {

        if(args.length!=1) throw new IllegalArgumentException("Class name should be specified");
        String file = getJarFileByClass(Class.forName(args[0]));
        Set<String> classes = getClasses(file);
        LoadingCache<String, URLClassLoader> classLoaderCache = createMavenClassloaderCache();

        for (final String currentClassName : classes) {

            String mavenSourcesId = getMavenSourcesId(currentClassName);
            if (mavenSourcesId == null)
                throw new IllegalArgumentException("Maven group:artifact:version not found for class " + currentClassName);

            URLClassLoader urlClassLoader = classLoaderCache.get(mavenSourcesId);

            ASTParser parser = ASTParser.newParser(AST.JLS8);
            parser.setResolveBindings(true);
            parser.setKind(ASTParser.K_COMPILATION_UNIT);
            parser.setCompilerOptions(JavaCore.getOptions());

            parser.setEnvironment(prepareClasspath(urlClassLoader), SOURCE_PATH, SOURCE_ENCODING, true);

            parser.setUnitName(currentClassName + ".java");

            String sourceText = getClassSourceCode(currentClassName, urlClassLoader);
            if(sourceText == null) continue;

            parser.setSource(sourceText.toCharArray());
            
            CompilationUnit cu = (CompilationUnit) parser.createAST(null);

            cu.accept(new LoggingVisitor(cu, currentClassName));
        }
    }
}

Создав парсер, указываем что исходный текст будет соответствовать Java 8 language specification
ASTParser parser = ASTParser.newParser(AST.JLS8);

И что после разбора необходимо разрешать типы идентификаторов на основе classpath, что мы передали компилятору
parser.setResolveBindings(true);

Исходный текст класса передаем парсеру с помощью вызова
parser.setSource(sourceText.toCharArray());

Создаем AST дерево этого класса
CompilationUnit cu = (CompilationUnit) parser.createAST(null);

И получаем события при обходе AST с помощью нашего класса Visitor
cu.accept(new LoggingVisitor(cu, currentClassName));


Расширяя класс ASTVisitor и перегружая в нем метод public boolean visit(MethodInvocation node), передаем его компилятору ejc. В этом обработчике анализируем что этот именно те методы именно тех классов, что нас интересуют и после этого анализируем аргументы, вызываемого метода.

При обходе AST дерева программы, которое содержит также дополнительную информацию о типах, будет вызываться наш метод visit. В нем же мы получаем информацию о расположении лексемы в исходном файле, параметрах, выражениях и т.п.

Основной «фарш» с разбором интересующих нас мест вызова методов логгеров в анализируемой программе инкапсулирован в LoggingVisitor:
LoggingVisitor.java
package com.github.igorsuhorukov.java.ast;

import org.eclipse.jdt.core.dom.*;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

class LoggingVisitor extends ASTVisitor {

    final static Set<String> LOGGER_CLASS = new HashSet<String>() {{
        add("org.slf4j.Logger");
        add("org.apache.commons.logging.Log");
        add("org.springframework.boot.cli.util.Log");
    }};

    final static Set<String> LOGGER_METHOD = new HashSet<String>() {{
        add("fatal");
        add("error");
        add("warn");
        add("info");
        add("debug");
        add("trace");
    }};

    public static final String LITERAL = "Literal";
    public static final String FORMAT_METHOD = "format";

    private final CompilationUnit cu;
    private final String currentClassName;

    public LoggingVisitor(CompilationUnit cu, String currentClassName) {
        this.cu = cu;
        this.currentClassName = currentClassName;
    }

    @Override
    public boolean visit(MethodInvocation node) {
        if (LOGGER_METHOD.contains(node.getName().getIdentifier())) {
            ITypeBinding objType = node.getExpression() != null ? node.getExpression().resolveTypeBinding() : null;
            if (objType != null && LOGGER_CLASS.contains(objType.getBinaryName())) {

                int lineNumber = cu.getLineNumber(node.getStartPosition());

                boolean isFormat = false;
                boolean isConcat = false;
                boolean isLiteral1 = false;
                boolean isLiteral2 = false;
                boolean isMethod = false;
                boolean withException = false;

                for (int i = 0; i < node.arguments().size(); i++) {
                    ASTNode innerNode = (ASTNode) node.arguments().get(i);
                    if (i == node.arguments().size() - 1) {
                        if (innerNode instanceof SimpleName && ((SimpleName) innerNode).resolveTypeBinding() != null) {
                            ITypeBinding typeBinding = ((SimpleName) innerNode).resolveTypeBinding();
                            while (typeBinding != null && Object.class.getName().equals(typeBinding.getBinaryName())) {
                                if (Throwable.class.getName().equals(typeBinding.getBinaryName())) {
                                    withException = true;
                                    break;
                                }
                                typeBinding = typeBinding.getSuperclass();
                            }
                            if (withException) continue;
                        }
                    }
                    if (innerNode instanceof MethodInvocation) {
                        MethodInvocation methodInvocation = (MethodInvocation) innerNode;
                        if (FORMAT_METHOD.equals(methodInvocation.getName().getIdentifier()) && methodInvocation.getExpression() != null
                                && methodInvocation.getExpression().resolveTypeBinding() != null
                                && String.class.getName().equals(methodInvocation.getExpression().resolveTypeBinding().getBinaryName())) {
                            isFormat = true;
                        } else {
                            isMethod = true;
                        }
                    } else if (innerNode instanceof InfixExpression) {
                        InfixExpression infixExpression = (InfixExpression) innerNode;
                        if (InfixExpression.Operator.PLUS.equals(infixExpression.getOperator())) {
                            List expressions = new ArrayList();
                            expressions.add(infixExpression.getLeftOperand());
                            expressions.add(infixExpression.getRightOperand());
                            expressions.addAll(infixExpression.extendedOperands());
                            long stringLiteralCount = expressions.stream().filter(item -> item instanceof StringLiteral).count();
                            long notLiteralCount = expressions.stream().filter(item -> item.getClass().getName().contains(LITERAL)).count();
                            if (notLiteralCount > 0 && stringLiteralCount > 0) {
                                isConcat = true;
                            }
                        }
                    } else if (innerNode instanceof Expression && innerNode.getClass().getName().contains(LITERAL)) {
                        isLiteral1 = true;
                    } else if (innerNode instanceof SimpleName || innerNode instanceof QualifiedName
                            || innerNode instanceof ConditionalExpression || innerNode instanceof ThisExpression
                            || innerNode instanceof ParenthesizedExpression
                            || innerNode instanceof PrefixExpression || innerNode instanceof PostfixExpression
                            || innerNode instanceof ArrayCreation || innerNode instanceof ArrayAccess
                            || innerNode instanceof FieldAccess || innerNode instanceof ClassInstanceCreation) {
                        isLiteral2 = true;
                    }
                }
                String type = loggerInvocationType(node, isFormat, isConcat, isLiteral1 || isLiteral2, isMethod);
                System.out.println(currentClassName + ":" + lineNumber + "\t\t\t" + node+"\t\ttype "+type); //node.getStartPosition()

            }
        }
        return true;
    }

    private String loggerInvocationType(MethodInvocation node, boolean isFormat, boolean isConcat, boolean isLiteral, boolean isMethod) {
        if (!isConcat && !isFormat && isLiteral) {
            return "literal";
        } else {
            if (isFormat && isConcat) {
                return "format concat";
            } else if (isFormat && !isLiteral) {
                return "format";
            } else if (isConcat && !isLiteral) {
                return "concat";
            } else {
                if (isConcat || isFormat || isLiteral) {
                    if (node.arguments().size() == 1) {
                        return "single argument";
                    } else {
                        return  "mixed logging";
                    }
                }
            }
            if(isMethod){
                return "method";
            }
        }
        return "unknown";
    }
}


Зависимости программы-анализатора, необходимые для компиляции и работы описаны в
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <parent>
        <groupId>org.sonatype.oss</groupId>
        <artifactId>oss-parent</artifactId>
        <version>7</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.github.igor-suhorukov</groupId>
    <artifactId>java-ast</artifactId>
    <packaging>jar</packaging>
    <version>1.0-SNAPSHOT</version>
    <properties>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <insight.version>1.2.0.redhat-133</insight.version>
    </properties>
    <dependencies>
        <!-- EJC -->
        <dependency>
            <groupId>org.eclipse.tycho</groupId>
            <artifactId>org.eclipse.jdt.core</artifactId>
            <version>3.11.0.v20150602-1242</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.core</groupId>
            <artifactId>runtime</artifactId>
            <version>3.9.100-v20131218-1515</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.birt.runtime</groupId>
            <artifactId>org.eclipse.core.resources</artifactId>
            <version>3.8.101.v20130717-0806</version>
        </dependency>

        <!-- MAVEN -->
        <dependency>
            <groupId>io.fabric8.insight</groupId>
            <artifactId>insight-log4j</artifactId>
            <version>${insight.version}</version>
            <exclusions>
                <exclusion>
                    <groupId>*</groupId>
                    <artifactId>*</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>io.fabric8.insight</groupId>
            <artifactId>insight-log-core</artifactId>
            <version>${insight.version}</version>
        </dependency>
        <dependency>
            <groupId>io.fabric8</groupId>
            <artifactId>common-util</artifactId>
            <version>${insight.version}</version>
        </dependency>
        <dependency>
            <groupId>com.github.igor-suhorukov</groupId>
            <artifactId>aspectj-scripting</artifactId>
            <version>1.0</version>
            <classifier>agent</classifier>
        </dependency>


        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>19.0-rc2</version>
        </dependency>

        <!-- Dependency to analyze -->
        <dependency>
            <groupId>com.googlecode.log4jdbc</groupId>
            <artifactId>log4jdbc</artifactId>
            <version>1.2</version>
        </dependency>

    </dependencies>
</project>

Часть «уличной магии», что помогает при парсинге, скрыта в классе ParserUtils, реализована за счет сторонних библиотек и рассматривалась выше.

ParserUtils.java
package com.github.igorsuhorukov.java.ast;

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.io.CharStreams;
import org.sonatype.aether.util.artifact.DefaultArtifact;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLClassLoader;
import java.security.CodeSource;
import java.util.Arrays;
import java.util.Collections;
import java.util.Set;
import java.util.function.Function;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Collectors;

public class ParserUtils {

    public static final int MAX_CACHE_SIZE = 1000;

    public static Set<String> getClasses(String file) throws IOException {
        return Collections.list(new JarFile(file).entries()).stream()
                .filter(jar -> jar.getName().endsWith("class") && !jar.getName().contains("$"))
                .map(new Function<JarEntry, String>() {
                    @Override
                    public String apply(JarEntry jarEntry) {
                        return jarEntry.getName().replace(".class", "").replace('/', '.');
                    }
                }).collect(Collectors.toSet());
    }

    public static String getMavenSourcesId(String className) {
        String mavenCoordinates = io.fabric8.insight.log.log4j.MavenCoordHelper.getMavenCoordinates(className);
        if(mavenCoordinates==null) return null;
        DefaultArtifact artifact = new DefaultArtifact(mavenCoordinates);
        return String.format("%s:%s:%s:sources:%s", artifact.getGroupId(), artifact.getArtifactId(),
                                                    artifact.getExtension(), artifact.getVersion());
    }

    public static LoadingCache<String, URLClassLoader> createMavenClassloaderCache() {
        return CacheBuilder.newBuilder()
                .maximumSize(MAX_CACHE_SIZE)
                .build(new CacheLoader<String, URLClassLoader>() {
                    @Override
                    public URLClassLoader load(String mavenId) throws Exception {
                        return com.github.smreed.dropship.MavenClassLoader.forMavenCoordinates(mavenId);
                    }
                });
    }

    public static String[] prepareClasspath(URLClassLoader urlClassLoader) {
        return Arrays.stream(urlClassLoader.getURLs()).map(new Function<URL, String>() {
            @Override
            public String apply(URL url) {
                return url.getFile();
            }
        }).toArray(String[]::new);
    }

    public static String getJarFileByClass(Class<?> clazz) {
        CodeSource source = clazz.getProtectionDomain().getCodeSource();
        String file = null;
        if (source != null) {
            URL locationURL = source.getLocation();
            if ("file".equals(locationURL.getProtocol())) {
                file = locationURL.getPath();
            } else {
                file = locationURL.toString();
            }
        }
        return file;
    }

    static String getClassSourceCode(String className, URLClassLoader urlClassLoader) throws IOException {
        String sourceText = null;
        try (InputStream javaSource = urlClassLoader.getResourceAsStream(className.replace(".", "/") + ".java")) {
            if (javaSource != null){
                try (InputStreamReader sourceReader = new InputStreamReader(javaSource)){
                    sourceText = CharStreams.toString(sourceReader);
                }
            }
        }
        return sourceText;
    }
}

Запустив com.github.igorsuhorukov.java.ast.Parser на исполнение и передав, как параметр для анализа, имя класса net.sf.log4jdbc.ConnectionSpy

Получим вывод в консоли, из которого можно понять, какие параметры передаются в методы:
Консоль приложения
[Dropship WARN] No dropship.properties found! Using .dropship-prefixed system properties (-D)
[Dropship INFO] Collecting maven metadata.
[Dropship INFO] Resolving dependencies.
[Dropship INFO] Building classpath for com.googlecode.log4jdbc:log4jdbc:jar:sources:1.2 from 2 URLs.
net.sf.log4jdbc.Slf4jSpyLogDelegator:104 jdbcLogger.error(header,e) type literal
net.sf.log4jdbc.Slf4jSpyLogDelegator:105 sqlOnlyLogger.error(header,e) type literal
net.sf.log4jdbc.Slf4jSpyLogDelegator:106 sqlTimingLogger.error(header,e) type literal
net.sf.log4jdbc.Slf4jSpyLogDelegator:111 jdbcLogger.error(header + " " + sql,e) type mixed logging
net.sf.log4jdbc.Slf4jSpyLogDelegator:116 sqlOnlyLogger.error(getDebugInfo() + nl + spyNo+ ". "+ sql,e) type mixed logging
net.sf.log4jdbc.Slf4jSpyLogDelegator:120 sqlOnlyLogger.error(header + " " + sql,e) type mixed logging
net.sf.log4jdbc.Slf4jSpyLogDelegator:126 sqlTimingLogger.error(getDebugInfo() + nl + spyNo+ ". "+ sql+ " {FAILED after "+ execTime+ " msec}",e) type mixed logging
net.sf.log4jdbc.Slf4jSpyLogDelegator:130 sqlTimingLogger.error(header + " FAILED! " + sql+ " {FAILED after "+ execTime+ " msec}",e) type mixed logging
net.sf.log4jdbc.Slf4jSpyLogDelegator:158 logger.debug(header + " " + getDebugInfo()) type concat
net.sf.log4jdbc.Slf4jSpyLogDelegator:162 logger.info(header) type literal
net.sf.log4jdbc.Slf4jSpyLogDelegator:221 sqlOnlyLogger.debug(getDebugInfo() + nl + spy.getConnectionNumber()+ ". "+ processSql(sql)) type concat
net.sf.log4jdbc.Slf4jSpyLogDelegator:226 sqlOnlyLogger.info(processSql(sql)) type method
net.sf.log4jdbc.Slf4jSpyLogDelegator:352 sqlTimingLogger.error(buildSqlTimingDump(spy,execTime,methodCall,sql,sqlTimingLogger.isDebugEnabled())) type method
net.sf.log4jdbc.Slf4jSpyLogDelegator:360 sqlTimingLogger.warn(buildSqlTimingDump(spy,execTime,methodCall,sql,sqlTimingLogger.isDebugEnabled())) type method
net.sf.log4jdbc.Slf4jSpyLogDelegator:365 sqlTimingLogger.debug(buildSqlTimingDump(spy,execTime,methodCall,sql,true)) type method
net.sf.log4jdbc.Slf4jSpyLogDelegator:370 sqlTimingLogger.info(buildSqlTimingDump(spy,execTime,methodCall,sql,false)) type method
net.sf.log4jdbc.Slf4jSpyLogDelegator:519 debugLogger.debug(msg) type literal
net.sf.log4jdbc.Slf4jSpyLogDelegator:531 connectionLogger.info(spy.getConnectionNumber() + ". Connection opened " + getDebugInfo()) type concat
net.sf.log4jdbc.Slf4jSpyLogDelegator:533 connectionLogger.debug(ConnectionSpy.getOpenConnectionsDump()) type method
net.sf.log4jdbc.Slf4jSpyLogDelegator:537 connectionLogger.info(spy.getConnectionNumber() + ". Connection opened") type concat
net.sf.log4jdbc.Slf4jSpyLogDelegator:550 connectionLogger.info(spy.getConnectionNumber() + ". Connection closed " + getDebugInfo()) type concat
net.sf.log4jdbc.Slf4jSpyLogDelegator:552 connectionLogger.debug(ConnectionSpy.getOpenConnectionsDump()) type method
net.sf.log4jdbc.Slf4jSpyLogDelegator:556 connectionLogger.info(spy.getConnectionNumber() + ". Connection closed") type concat



Например, если при вызове метода info, происходит конкатенация в строку результатов вызова метода spy.getConnectionNumber(), строки ". Connection opened " и вызова метода getDebugInfo(), мы получим сообщение что это concat
net.sf.log4jdbc.Slf4jSpyLogDelegator:531 connectionLogger.info(spy.getConnectionNumber() + ". Connection opened " + getDebugInfo()) type concat

И после этого мы могли бы трансформировать исходный текст таким образом, чтобы заменить операцию конкатенации в параметрах этого метода, вызовом метода с шаблоном "{}. Connection opened {}" и параметрами spy.getConnectionNumber(), getDebugInfo(). А дальше этот более машиночитаемый вызов и информацию из него можно отправить сразу в Elasticsearch, о чем я уже рассказывал в статье «Публикация логов в Elasticsearch — жизнь без регулярных выражений и без logstash».

Как видим, разбор и анализ java программы легко реализовать в java коде с помощью компилятора ejc и также легко программно получить из Maven репозитария исходные коды для интересующих нас классов.

Впереди нас ждет Java agent, модификация и компиляция в рантайм — задача
масштабнее и сложнее чем просто переваривание AST...


До скорых встреч!




К сожалению, не доступен сервер mySQL