JavaParser で静的解析ツールを作ってみよう ダンドリ編
この記事はグロースエクスパートナーズAdvent Calendar 2024の14日目です。Githubが公開しているJavaparserでJavaプロジェクトのお手軽静的解析をやってみます。GxPの髙田です。
動機
ある歴史を重ねた趣あるWebシステムを保守していました。依存関係が古いままになってしまっているので、満を持してバージョンアップをすることになりました。
バージョンアップできるか検証を進めていると、どうやらトランザクション宣言外でデータベースへアクセスするお行儀の悪いコードがあり、実行時エラーになることが判明しました。このままではバージョンアップできないので、コードをなんとかしないといけない状況でした。
データアクセスにはhibernateを使用していたので設定による制御でバージョンアップ前と同じくトランザクション外でのデータアクセスを許可することもできるようでしたが、デフォルトの動作が変わるということはそれがあるべき姿ということでコードを直すことにしました。
対象プロジェクトの使用技術はこういったものですが、解析するにあたってはJavaのプロジェクトであればなんでもよいと思います。
- Java
- Spring MVC
- hibernate
やりたかったこと
- ControllerからRepository(Dataアクセス層)までのすべてのメソッドで、Transactionalアノテーションによるトランザクション制御がないにも関わらずデータベースへアクセスしている箇所を見つけたい
- プロジェクト全体のコードが対象なので、一括で処理できるものがよい
まずは静的解析ツールやIDEの支援などで達成できないか調査しましたが、メソッド呼び出しを階層で表示してくれたりするものはあるのですが、特定の条件で検索したり、プロジェクトコード全体を走査してくれるようなCLIツールをみつけることができませんでした。
あとはJavaparserがおもしろそうなので、自分で作ってみようということになりました。
実装
今回はダンドリ編なのでセットアップの部分を中心に解説します。全体はgithubのリポジトリにあります。
public static SymbolSolverCollectionStrategy symbolSolverCollectionStrategy(String target) {
SymbolSolverCollectionStrategy strategy = new SymbolSolverCollectionStrategy();
SymbolResolver resolver = javaSymbolSolverSetup(target);
strategy.getParserConfiguration()
.setCharacterEncoding(StandardCharsets.UTF_8)
.setLexicalPreservationEnabled(true)
.setSymbolResolver(resolver);
return strategy;
}
private static JavaSymbolSolver javaSymbolSolverSetup(String targetPath) {
// 依存関係先の型を解決するSolverをclasspathリストから作成する
CombinedTypeSolver typeSolver = DependencyJarTypeSolverSupport.dependencySolver(targetPath);
// lombokで生成するコードを解決できるようにdelombokしたソースファイルをSolverに登録する
Optional<JavaParserTypeSolver> delombokSolverMaybe = DelombokSolverSupport.delombokSolver(targetPath);
delombokSolverMaybe.ifPresent(typeSolver::add);
typeSolver.add(new ReflectionTypeSolver(false));
return new JavaSymbolSolver(typeSolver);
}
ダンドリ
依存関係の解決
- jarをローカルのmavenリポジトリから読み込む
依存関係から提供されているクラスを参照している場合、解析でUnresolved symbolエラーが発生します。
JavaparserにはJarファイルをリゾルバーとして追加できる仕組みが用意されているので、mavenやgradleで管理されているであろう依存関係のJarを登録します。
コードはここらへんです。
private static final String DEFAULT_DEPENDENCY_LIST_FILE = ".dependencies";
/**
*
* @param targetPath 解析対象のプロジェクトルート
* @return 依存関係のJarTypeSolver
*/
static CombinedTypeSolver dependencySolver(String targetPath) {
Path dependencyListPath = Paths.get(targetPath, DEFAULT_DEPENDENCY_LIST_FILE);
JarTypeSolver[] solvers = readDependencies(dependencyListPath).stream()
.map(Paths::get)
.map(DependencyJarTypeSolverSupport::mapToSolver)
.filter(Objects::nonNull)
.toArray(JarTypeSolver[]::new);
return new CombinedTypeSolver(solvers);
}
private static List<String> readDependencies(Path dependenciesListClassPath) {
try (BufferedReader br = Files.newBufferedReader(dependenciesListClassPath)) {
return br.lines()
.flatMap(line -> Stream.of(line.split(File.pathSeparator)))
.toList();
} catch (IOException e) {
e.printStackTrace();
return List.of();
}
}
private static JarTypeSolver mapToSolver(Path path) {
try {
return new JarTypeSolver(path);
} catch (IOException e) {
return null;
}
}
TIPS
依存関係のJarへのパスはmavenまたはgradleのコマンドで出力することができます。
ビルドツールがmavenであればmaven-dependency-pluginの提供するbuild-classpathタスクで、gradleであればbuild.gradleに3行くらいのタスクを実装することで実現できます。
maven
プラグインをpom.xmlに追加する
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
</plugin>
</plugins>
</pluginManagement>
webappモジュール直下の.deoendenciesというファイルに出力するコマンド例
./mvnw -pl webapp dependency:build-classpath "-Dmdep.outputFile=.dependencies"
参考
https://maven.apache.org/plugins/maven-dependency-plugin/build-classpath-mojo.html
gradle
// プロジェクト直下の.dependenciesというファイルに依存関係の一覧を出力する。
//標準出力にだすだけであれば以下でOK
// sourceSets.main.compileClasspath.each { print it }
task printClassPath {
BufferedWriter writer = Files.newBufferedWriter(Paths.get(projectDir.getAbsolutePath(), ".dependencies"))
writer.write(sourceSets.main.compileClasspath.join(File.pathSeparator))
writer.flush()
}
lombok自動生成コードの解決
GetterやSetterをlombokで自動生成している場合も、シンボルの解決に失敗します。ファイルを読み込んで解析しているので、それはそう、ですね。
lombokに備わるdelombokというコマンドを実行すると、lombokが生成するコードを含んだ状態のファイルを作成できます。delombokで生成したファイルを参考情報として読み込ませることで、エラーは解消します。
delombokコマンド例
$classpath = Get-Content .\webapp\.dependencies
java -jar ${HOME}/.m2/repository/org/projectlombok/lombok/1.18.34/lombok-1.18.34.jar delombok webapp/src/main/java --classpath $classpath -d .\webapp\delombok
delombokしたコードを参考情報として読み込むだけでよいので、プロダクションのコードを変更する必要はありません。なので、delombokした結果は別の場所に保存すればよいです。ここでも依存関係Jarを参照する必要があるので、前項で作成したクラスパスが利用できます。
delombokしたコードを渡している実装はここらへんです。
/** 対象プロジェクト直下にあるdelombokディレクトリを対象とする */
private static final String DEFAULT_DELOMBOK_DIR = "/delombok";
/**
* delombokしたソースファイルをSolverに登録する
* https://projectlombok.org/features/delombok
*
* @param targetPath 解析対象プロジェクトのパス
* @return delombokディレクトリが存在すれば、 JavaParserTypeSolverとして返す. ディレクトリがなければemptyを返す.
*/
static Optional<JavaParserTypeSolver> delombokSolver(String targetPath) {
Path delombok = Paths.get(targetPath, DEFAULT_DELOMBOK_DIR);
return Optional.ofNullable(Files.isDirectory(delombok) ? new JavaParserTypeSolver(delombok) : null);
}
まとめ
これでコードの解析をしていく準備が整いました。
静的解析にはパーサーや言語仕様の理解が必要と思われるため、敷居が高い印象ですが、優秀なツールを利用するとお手軽に試すことができした。Javaparserでは今回やりたかったコードの検証の他に、ソースコードを書き換えることもできるます。どのようなことができるか、は次回にお伝えしたいとます。
参考
2017年の記事ですがAPIの解説が豊富で実装進めるうえでとても参考になりました。
解説本。無料でもダウンロードできます