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

    Разобрались с теорией в публикации «Модификация программы и что лучше менять: исполняемый код или 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 репозитария исходные коды для интересующих нас классов. Пример из статьи доступен на github

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


    До скорых встреч!
    • +14
    • 17,6k
    • 2
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 2
    • 0
      На какие только ухищрения люди не идут, лишь бы Clojure не учить :)
      • 0
        Clojure вряд ли решит проблему с тоннами существующих исходников на java. А свою программу конечно там круче модифицировать

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.