Разбор 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:
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";
}
}
Зависимости программы-анализатора, необходимые для компиляции и работы описаны в
<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, реализована за счет сторонних библиотек и рассматривалась выше.
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, модификация и компиляция в рантайм — задача
До скорых встреч!