ドキュメントレスな社内システムをIntelliJ IDEAのプラグインを開発して調査を進めた話

はじめに

こんにちは。アソビューで主にバックエンド開発に携わっている東郷です。
今回は、社内システムの機能移行プロジェクトで直面したドキュメントレスの課題と、その解決策について共有します。

この記事が同じ問題に直面している開発者の助けとなれば幸いです。

既存システムの課題と新システムへの移行準備

既存の社内システムは、過去10年以上にわたって多くのビジネスプロセスを支えてきましたが、技術の進化と組織の成長に伴い、その限界が明らかになってきました。

効率性、拡張性、およびコスト効率の向上を実現するために、新しいシステムに機能を移し替えることを検討する段階にきました。

社内システムの概要

  • アソビューの商品データを生成、管理するシステム
  • システムはJava言語で開発されており、MVC(Model-View-Controller)アーキテクチャのWebフレームワークを使用
  • データベースとのインターフェースには、O/R Mapperの一つであるDBFluteを使用

この移行を進めるには、既存システムを詳細に理解することが不可欠なため、まずは調査を実施しました。

直面した課題

  • ドキュメントの不足: 開発から10年以上経過し、多くのドキュメントが失われ、現在はソースコードが主な情報源
  • 技術の陳腐化: 使用されているミドルウェアやライブラリが古いため、ローカル環境での実行にそれなりに準備が必要
  • 複雑性の増大: 機能とDBテーブルが多く、どの機能がどのテーブルに対してどのような操作を行っているかの追跡が困難

解決策とその実装

静的解析のアプローチを選択し、既存のSequenceDiagramプラグインを用いて個別の処理の流れを追いましたが、全体像を把握するためにはさらなるツールが必要でした。
そのため、新たにプラグインを開発することにしました。

プラグインの設計

処理の開始点となるMVCのControllerクラスから、データベース操作を行うDBFluteのビヘイビアクラスのメソッド呼び出しを追跡・可視化することを目的としました。

可視化のイメージ

プラグインの開発手順

プロジェクトのセットアップ

  1. IntelliJ IDEAを開き、「File」メニューから「New」を選択し、「Project」をクリックします。
  2. 左側のパネルから「IDE プラグイン」を選択し、「Next」をクリックします。
  3. プロジェクト名と保存場所を指定し、「Finish」をクリックしてプロジェクトを作成します。

モジュールの構造

  1. プロジェクトが作成されたら、srcフォルダが表示されます。
  2. srcフォルダを右クリックし、「New」>「Package」を選択して、新しいパッケージを作成します(例:com.example.ideplugindemo)。

プラグインのコア機能実装

  1. 新しいJavaクラスを作成し、メインロジックを実装します。
  2. プラグインのメタデータを設定し、エントリポイントを指定します。
  3. IntelliJ IDEAのビルド機能を使用してプラグインをビルドし、実行して動作をテストします。

以上の手順でプラグインモジュールの基本的な構造が作成され、開発を進めるための土台が整います。

コアロジックの作成

今回の例では以下のプロジェクトを解析対象としました。

github.com

package com.example.ideplugindemo;

import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.Messages;
import com.intellij.psi.*;
import com.intellij.psi.search.GlobalSearchScope;

import java.awt.*;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.StringSelection;
import java.util.*;
import java.util.List;

/**
 * このプラグインは、プロジェクト内のコードからDBFluteのビヘイビアへの呼び出しパターンを検出し、それをクリップボードにコピーすることで、開発者がアプリケーションのデータアクセス層の使用状況を簡単に確認できるようにすることを目的としています。
 */
public class IdePluginDemo extends AnAction {
    private static final List<String> targetDbflutePackages = Arrays.asList(
            "org.docksidestage.dbflute.exbhv",
            "org.docksidestage.dbflute.bsbhv"
    );

    @Override
    public void actionPerformed(AnActionEvent e) {
        Project project = e.getProject();
        if (project == null) {
            return;
        }

        String webPackagePrefix = "org.docksidestage.app.web";
        PsiPackage basePackage = findPackage(project, webPackagePrefix);
        if (basePackage != null) {
            Map<String, Map<String, Set<String>>> callMap = analyzePackage(basePackage, project);
            copyResultsToClipboard(callMap);
            Messages.showMessageDialog(project, "結果をクリップボードにコピーしました!", "情報", Messages.getInformationIcon());
        }
    }

    // 指定されたパッケージ名でパッケージを検索
    private PsiPackage findPackage(Project project, String packagePrefix) {
        return JavaPsiFacade.getInstance(project).findPackage(packagePrefix);
    }

    // パッケージ内のファイルを解析してメソッド呼び出しを記録
    private Map<String, Map<String, Set<String>>> analyzePackage(PsiPackage basePackage, Project project) {
        Map<String, Map<String, Set<String>>> callMap = new HashMap<>();
        GlobalSearchScope scope = GlobalSearchScope.projectScope(project);
        Arrays.stream(basePackage.getDirectories(scope))
                .filter(dir -> dir.getVirtualFile().getPath().contains("/src/main/"))
                .forEach(directory -> processDirectory(directory, callMap));
        return callMap;
    }

    // ディレクトリを再帰的に処理し、Javaファイルを解析
    private void processDirectory(PsiDirectory directory, Map<String, Map<String, Set<String>>> callMap) {
        for (PsiFile file : directory.getFiles()) {
            if (file instanceof PsiJavaFile) {
                processJavaFile((PsiJavaFile) file, callMap);
            }
        }
        for (PsiDirectory subDirectory : directory.getSubdirectories()) {
            processDirectory(subDirectory, callMap);
        }
    }

    // Javaファイル内のクラスを解析
    private void processJavaFile(PsiJavaFile javaFile, Map<String, Map<String, Set<String>>> callMap) {
        for (PsiClass psiClass : javaFile.getClasses()) {
            processClass(psiClass, callMap);
        }
    }

    // クラス内のメソッドを解析
    private void processClass(PsiClass psiClass, Map<String, Map<String, Set<String>>> callMap) {
        psiClass.accept(new JavaRecursiveElementVisitor() {
            @Override
            public void visitMethod(PsiMethod method) {
                super.visitMethod(method);
                method.accept(new JavaRecursiveElementVisitor() {
                    @Override
                    public void visitMethodCallExpression(PsiMethodCallExpression expression) {
                        super.visitMethodCallExpression(expression);
                        handleMethodCall(expression, method, psiClass, callMap);
                    }
                });
            }
        });
    }

    // メソッド呼び出しを処理して、DBFluteビヘイビアへの呼び出しを記録
    private void handleMethodCall(PsiMethodCallExpression expression, PsiMethod callerMethod, PsiClass callerClass, Map<String, Map<String, Set<String>>> callMap) {
        PsiMethod method = expression.resolveMethod();
        if (method != null) {
            PsiClass containingClass = method.getContainingClass();
            if (containingClass != null && targetDbflutePackages.stream().anyMatch(prefix -> containingClass.getQualifiedName().startsWith(prefix))) {
                String callerMethodSignature = callerClass.getQualifiedName() + "." + callerMethod.getName();
                String bhvClassName = containingClass.getName();
                callMap.computeIfAbsent(callerMethodSignature, k -> new HashMap<>())
                        .computeIfAbsent(bhvClassName, k -> new TreeSet<>())
                        .add(method.getName());
            }
        }
    }

    // 解析結果をクリップボードにコピー
    private void copyResultsToClipboard(Map<String, Map<String, Set<String>>> callMap) {
        StringBuilder resultBuilder = new StringBuilder();
        Set<String> allBhvs = new TreeSet<>();
        callMap.values().forEach(bhvMap -> allBhvs.addAll(bhvMap.keySet()));

        resultBuilder.append("Class.Method");
        allBhvs.forEach(bhv -> resultBuilder.append("\t").append(bhv));
        resultBuilder.append("\n");

        // キーを昇順でソートして出力
        callMap.entrySet().stream()
                .sorted(Map.Entry.comparingByKey())
                .forEach(entry -> {
                    String callerMethod = entry.getKey();
                    Map<String, Set<String>> bhvMap = entry.getValue();
                    resultBuilder.append(callerMethod);
                    allBhvs.forEach(bhv -> {
                        resultBuilder.append("\t");
                        if (bhvMap.containsKey(bhv)) {
                            resultBuilder.append(String.join(", ", bhvMap.get(bhv)));
                        }
                    });
                    resultBuilder.append("\n");
                });

        Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
        clipboard.setContents(new StringSelection(resultBuilder.toString()), null);
    }
}

特定のパッケージ(org.docksidestage.app.web)内のJavaファイルを解析して、特定のDBFluteビヘイビアクラス(org.docksidestage.dbflute.exbhv、org.docksidestage.dbflute.bsbhv)へのメソッド呼び出しを検出し、その結果をクリップボードにコピーしています。

plugin.xmlの修正

<!-- Plugin Configuration File. Read more: https://plugins.jetbrains.com/docs/intellij/plugin-configuration-file.html -->
<idea-plugin>
    <!-- Unique identifier of the plugin. It should be FQN. It cannot be changed between the plugin versions. -->
    <id>com.example.ide-plugin-demo</id>

    <!-- Public plugin name should be written in Title Case.
         Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-name -->
    <name>Ide-plugin-demo</name>

    <!-- A displayed Vendor name or Organization ID displayed on the Plugins Page. -->
    <vendor email="support@yourcompany.com" url="https://www.yourcompany.com">YourCompany</vendor>

    <!-- Description of the plugin displayed on the Plugin Page and IDE Plugin Manager.
         Simple HTML elements (text formatting, paragraphs, and lists) can be added inside of <![CDATA[ ]]> tag.
         Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-description -->
    <description><![CDATA[
    Enter short description for your plugin here.<br>
    <em>most HTML tags may be used</em>
  ]]></description>

    <!-- Product and plugin compatibility requirements.
         Read more: https://plugins.jetbrains.com/docs/intellij/plugin-compatibility.html -->
    <depends>com.intellij.modules.platform</depends>
    <!-- デフォルトから変更。Javaモジュールへの依存関係を追加 -->
    <depends>com.intellij.modules.java</depends>

    <actions>
        <!-- Declare the action here -->
        <action id="IdePluginDemoAction"
                class="com.example.ideplugindemo.IdePluginDemo"
                text="Demo Action"
                description="Description of the demo action.">
            <!-- Define in which menu or toolbar the action should appear -->
            <add-to-group group-id="ToolsMenu" anchor="last"/>
        </action>
    </actions>

    <!-- Extension points defined by the plugin.
         Read more: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html -->
    <extensions defaultExtensionNs="com.intellij">

    </extensions>
</idea-plugin>

基本は作成された時の値を使っているので変更した部分だけ説明します。

  • <depends>:プラグインが依存する他のモジュールやプラグインを指定します。今回はIntelliJの基本プラットフォーム(com.intellij.modules.platform)とJavaモジュール(com.intellij.modules.java)への依存があるので宣言しています。
  • <actions>:プラグインによって定義されたアクション(ユーザーの操作に応答して行うタスク)を宣言します。今回はToolsメニューにDemo Actionの項目を追加してプラグインを実行できるようにしています。

build.gradle.ktsの修正

plugins {
    id("java")
    id("org.jetbrains.intellij") version "1.8.0"
}

group = "com.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

// Configure Gradle IntelliJ Plugin
// Read more: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html
intellij {
    version.set("2021.3.3")
    type.set("IC") // Target IDE Platform

    // ここはデフォルトから変更
    plugins.set(listOf("java"))
}

tasks {
    // Set the JVM compatibility versions
    withType<JavaCompile> {
        sourceCompatibility = "11"
        targetCompatibility = "11"
    }

    patchPluginXml {
        sinceBuild.set("213")
        untilBuild.set("223.*")
    }

    signPlugin {
        certificateChain.set(System.getenv("CERTIFICATE_CHAIN"))
        privateKey.set(System.getenv("PRIVATE_KEY"))
        password.set(System.getenv("PRIVATE_KEY_PASSWORD"))
    }

    publishPlugin {
        token.set(System.getenv("PUBLISH_TOKEN"))
    }
}

基本は作成された時の値を使っているので主要な部分だけ説明します。

IntelliJプラグインセクション

  • version.set("2021.3.3"): ターゲットとするIntelliJ IDEAのバージョンを指定します。
  • type.set("IC"): プラグインが対象とするIDEのタイプを指定します。ICはIntelliJ IDEA Community Editionを意味します。
  • plugins.set(listOf("java")): プラグインが依存する他のIntelliJプラグインのリストです。ここではjavaプラグインのみが必要です。

実行

Run Pluginを実行

IntelliJが開くので解析対象のプロジェクトを選択

ToolsメニューのDemo Actionを選択

結果がクリップボードにコピーされる

表計算ソフトに貼り付け

まとめ

このプラグイン開発を通じて、ドキュメントレスなシステムの理解を深める新たな方法を見出しました。

実際にはこれで全て明らかになるわけではなくシステムのログやユーザーへの聞き取り調査なども併せて行ってシステムの理解を深めていきましたが、ソースコードから効率的に情報が取得できるようにって調査は格段にやりやすくなりました。

開発者の皆さんも類似の課題に直面した際は、カスタムプラグインの開発を検討してみてはいかがでしょうか?

PR

アソビューでは一緒に働くメンバーを大募集しています!カジュアル面談もありますので、少しでも興味があればお気軽にご応募いただければと思います!

www.asoview.com

speakerdeck.com