MyBatis/iBatisのsqlmapファイルを解析してタグ情報＆パラメータを取得する（ソースコード）

## 1． はじめに
前回の記事「[MyBatis/iBatisのsqlmapファイルを解析してタグ情報＆パラメータを取得する](https://qiita.com/5zm/items/0c08b8aa7303789a6ed4)」で説明したツールのソースコードの説明になります。このツールを作成することになった経緯やツールの概要、実行例については前回の記事を参照ください。
（2024/3/1 追記）
インターネットに繋がらない環境ではDTD・schema検証の処理にてコネクションタイムアウトエラーが発生します。この検証をOFFにするため、SAXParserFactoryに設定する内容を追加しました。

## ２． ツールの仕様・前提
* Javaのコンソールアプリ
* 必須の引数はMyBatisのmapperファイル、iBatisのsqlmapファイルが格納されたディレクトリパス
* ファイルが１つの場合はファイルパスでもOK
* `-mybatis(デフォルト)`オプションでMyBatisのmapperファイルとして解析する
* `-ibatis`オプションでiBatisのsqlmapファイルとして解析する
* `-f:ファイルサフィックス`で対象のファイルをフィルタする
* 解析結果はJSON形式で標準出力に出力する
* 外部ライブラリは利用せず、Java17の標準機能で実装する

![クラス図](https://camo.qiitausercontent.com/95d230bfd6098f9e3530e3f1893c95b3e14c2e29/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3139353734382f65376632616433362d343166372d613339662d353333372d3538666233633865323437302e6a706567)

## ３． ファイル構成
```
project_dir
|--app // java package
   |--DaoInfo.java
   |--IbatisInfo.java
   |--MyBatisInfo.java
   |--SqlMapAnalyze.java
   |--SqlMapHandler.java
```
```shell:コンパイル(project_dirで実行)
javac -encoding UTF-8 app/*.java
```

## ４． ソースコード
### ４．１． 解析結果を保持するクラス

```java:DaoInfo.java
package app;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * mapperファイル、sqlmapファイルの解析結果を保持する抽象クラス
 */
public abstract class DaoInfo {
    /**
     * 入れ子の階層
     */
    int nestLevel = 0;
    /**
     * タグ名
     */
    String tag = null;
    /**
     * タグ内のテキストノード
     */
    StringBuffer text = new StringBuffer();
    /**
     * タグの属性情報
     */
    Map<String, String> attributes = new HashMap<>();
    /**
     * タグ内に含まれるタグ
     */
    List<DaoInfo> tags = new ArrayList<>();
    /**
     * タグ内に含まれるパラメータ
     */
    String params = null;

    // constructor, setter, getter omitted

    // ⭐️　ポイント1
    @Override
    public String toString() {
        StringBuffer attr = new StringBuffer();
        for (String key : attributes.keySet()) {
            attr.append("\"" + key + "\":");
            attr.append("\"" + attributes.get(key) + "\", ");
        }
        int attrSize = attr.length();
        if (attrSize > 2) {
            attr.delete(attrSize -2, attrSize);
        }
        String json = """
        {
            "nestLevel": "%s",
            "tag": "%s",
            "text": "%s",
            "params": %s,
            "attributes": { %s },
            "tags": %s
        }
        """.formatted(nestLevel, tag, text, params, attr, tags);
        return json;
    }

    // ⭐️　ポイント２
    public void addAttribute(String qName, String value) {
        this.attributes.put(qName, value);
    }
    public void addTag(DaoInfo daoInfo) {
        this.tags.add(daoInfo);
    }
    public void addText(String text) {
        this.text.append(text);
    }

    // ⭐️　ポイント３
    public abstract Set<String> getTargetTags();

    // ⭐️　ポイント４
    public abstract void parseParams();
}

```
⭐️　ポイント1
解析結果をJSON形式で表示したいので`toString`メソッドをオーバーライドしています。これでこのインスタンスを`System.out.println`で標準出力するとJSON形式で表示されます。
Java17にJSONの標準APIがあればよかったのですが、JSON BindingはJava EEなので残念ですが使えません。

⭐️　ポイント２
XMLファイルをパースした際にタグ、属性、テキストノードの情報を追加するためのメソッドです。パースしたデータをこれらのメソッドを使って追加していきます。

⭐️　ポイント３
MyBatisのmapperファイルとiBatisのsqlmapファイルでは利用できるタグが異なります。対象とするタグをぞれぞれ指定できるように抽象メソッドを準備します。

⭐️　ポイント４
MyBatisとiBatisでは`#{xxx}`と`#xxx#`のようにパラメータの指摘が異なります。個別に実装できるように抽象メソッドを準備します。

```java:MyBatisInfo.java
package app;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;

/**
 * MyBatisの解析情報を保持するクラス
 */
public class MyBatisInfo extends DaoInfo {

    // ⭐️　ポイント1
    public static Set<String> TARGET_TAGS = new HashSet<String>() {
        {
            add("mapper");
            add("sql");
            add("select");
            add("insert");
            add("update");
            add("delete");
            add("where");
            add("if");
            add("choose");
            add("when");
            add("otherwise");
            add("trim");
            add("set");
            add("foreach");
        }
    };

    // ⭐️　ポイント2
    private Map<String, Function<Map<String, String>, String>> tagParamParsers = new HashMap<> () {
        {
            put("foreach", p -> p.get("collection"));
            put("if", p -> p.get("test"));
        }
    };

    // ⭐️　ポイント1
    @Override
    public Set<String> getTargetTags() {
        return TARGET_TAGS;
    }

    // ⭐️　ポイント３
    @Override
    public void parseParams() {
        List<String> params = new ArrayList<>();
        if (text != null) {
            int fromStartIndex = 0;
            int fromEndIndex = 0;
            for (int i=0; i<text.length(); i++) {
                fromStartIndex = text.indexOf("#{", i);
                if (fromStartIndex == -1) {
                    fromStartIndex = text.indexOf("${", i);
                    if (fromStartIndex == -1) {
                        break;
                    }
                }
                fromEndIndex = text.indexOf("}", fromStartIndex);
                if (fromEndIndex == -1) {
                    break;
                }
                String param = text.substring(fromStartIndex + 2, fromEndIndex);
                params.add(param);
                i = fromEndIndex + 1;
            }
        }
        if (tagParamParsers.containsKey(tag)) {
            params.add(tagParamParsers.get(tag).apply(attributes));
        }
        StringBuffer paramsText = new StringBuffer();
        for (int i=0; i<params.size(); i++) {
            if (i > 0) {
                paramsText.append(", ");
            }
            paramsText.append("\"" + params.get(i) + "\"");
        }
        this.params = "[" + paramsText.toString() + "]";
    }
}

```
⭐️　ポイント1
MyBatisのmapperファイルの解析対象とするタグを定義します。

⭐️　ポイント２
少し変わった記述になっていますがタグ名をkey、そのタグからパラメータに該当する属性を取得するFunctionをvalueにしたMapを定義します。タグ毎にパラメータとなっている属性が異なるため、このような仕組みにしています。この後で説明する`parseParams`メソッドから参照します。

⭐️　ポイント３
親クラスのtextプロパティつまりタグ内のテキストノードの情報を参照し、`#{xxx}`や`${xxx}`等のパラメータを抽出します。合わせて「⭐️　ポイント２」のMapを利用し、タグの属性からパラメータとして指定されている値も抽出します。最終的にJSON形式の配列の文字列として親クラスのparamsプロパティに結果を設定します。

```java:IbatisInfo.java
package app;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;

/**
 * iBatisの解析情報を保持するクラス
 */
public class IbatisInfo extends DaoInfo {

    public static Set<String> TARGET_TAGS = new HashSet<String>() {
        {
            add("sqlMap");
            add("statement");
            add("select");
            add("insert");
            add("update");
            add("delete");
            add("procedure");
            add("selectKey");
            add("parameterMap");
            add("parameter");
            add("resultMap");
            add("result");
            add("dynamic");
            add("isEqual");
            add("isNotEqual");
            add("isGreaterThan");
            add("isGreaterEqual");
            add("isLessThan");
            add("isLessEqual");
            add("isPropertyAvailable");
            add("isNotPropertyAvailable");
            add("isNull");
            add("isNotNull");
            add("isEmpty");
            add("isNotEmpty");
            add("isParameterPresent");
            add("isNotParameterPresent");
            add("iterate");
        }
    };

    private Map<String, Function<Map<String, String>, String>> tagParamParsers = new HashMap<> () {
        {
            put("iterate", p -> p.get("property"));
            put("isPropertyAvailable", p -> p.get("property"));
            put("isNotPropertyAvailable", p -> p.get("property"));
            put("isNull", p -> p.get("property"));
            put("isNotNull", p -> p.get("property"));
            put("isEmpty", p -> p.get("property"));
            put("isNotEmpty", p -> p.get("property"));
            put("isEqual", p -> p.get("property"));
            put("isNotEqual", p -> p.get("property"));
            put("isGreaterThan", p -> p.get("property"));
            put("isGreaterEqual", p -> p.get("property"));
            put("isLessThan", p -> p.get("property"));
            put("isLessEqual", p -> p.get("property"));
        }
    };

    @Override
    public Set<String> getTargetTags() {
        return TARGET_TAGS;
    }

    @Override
    public void parseParams() {
        List<String> params = new ArrayList<>();
        if (text != null) {
            int fromStartIndex = 0;
            int fromEndIndex = 0;
            for (int i=0; i<text.length(); i++) {
                fromStartIndex = text.indexOf("#", i);
                if (fromStartIndex == -1) {
                    fromStartIndex = text.indexOf("$", i);
                    if (fromStartIndex == -1) {
                        break;
                    }
                }
                fromEndIndex = text.indexOf("#", fromStartIndex + 1);
                if (fromEndIndex == -1) {
                    break;
                }
                String param = text.substring(fromStartIndex + 1, fromEndIndex);
                params.add(param);
                i = fromEndIndex + 1;
            }
        }
        if (tagParamParsers.containsKey(tag)) {
            params.add(tagParamParsers.get(tag).apply(attributes));
        }
        StringBuffer paramsText = new StringBuffer();
        for (int i=0; i<params.size(); i++) {
            if (i > 0) {
                paramsText.append(", ");
            }
            paramsText.append("\"" + params.get(i) + "\"");
        }
        this.params = "[" + paramsText.toString() + "]";
    }
}
```
作りはMyBatisInfo.javaと同じです。異なるのはmapperファイルとsqlmapファイルの違いであるタグ名やパラメータの記載方法の箇所です。


### ４．２． XMLファイルを解析するクラス
```java：SqlMapHandler.java
package app;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Set;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

/**
 * ⭐️　ポイント1
 * MyBatis、iBatisのXMLファイルを解析するSAXハンドラ
 */
public class SqlMapHandler<T extends DaoInfo> extends DefaultHandler {

    private static final String WHITE_CHAR = "\\s++";
    private static final Pattern WHITE_CHAR_PATTERN = Pattern.compile(WHITE_CHAR);

    private String filePath = null;
    private Set<String> targetTags = null;

    /**
    　　* ⭐️　ポイント２
     * 現在のタグの入れ子階層
     */
    private int nestLevel = 0;
    /**
    　　* ⭐️　ポイント２
     * タグの入れ子階層を記録するためのスタック
     */
    private Deque<T> stack = new ArrayDeque<>();

    // ⭐️　ポイント1
    Supplier<? extends T> supplier = null;

    /**
     * ⭐️　ポイント1
     * コンストラクタ
     * @param supplier DaoInfoのインスタンスを提供するサプライヤ
     * @param filePath 解析対象となるXMLのファイルパス
     */
    public SqlMapHandler(Supplier<? extends T> supplier, String filePath) {
        this.supplier = supplier;
        this.targetTags = supplier.get().getTargetTags();
        this.filePath = filePath;
    }

    // ⭐️　ポイント3
    @Override
    public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
        if (targetTags.contains(qName)) {
            nestLevel++;
            T data = supplier.get();
            data.setNestLevel(nestLevel);
            data.setTag(qName);
            for (int i=0; i<attributes.getLength(); i++) {
                String attrName = attributes.getQName(i);
                String val = attributes.getValue(i);
                data.addAttribute(attrName, val);
            }
            T parent = stack.peekFirst();
            if (parent != null) {
                parent.addTag(data);
            }
            stack.addFirst(data);
        }
    }

    // ⭐️　ポイント4
    @Override
    public void characters(char[] ch, int start, int length) throws SAXException {
        String text = String.copyValueOf(ch, start, length);
        Matcher matcher = WHITE_CHAR_PATTERN.matcher(text);
        if (!matcher.matches()) {
            T data = stack.getFirst();
            String trimCRLF = text.replaceAll("\\v", " "); 
            data.addText(trimCRLF.replaceAll("\\h{2,}", " "));
        }
    }

    // ⭐️　ポイント5
    @Override
    public void endElement(String uri, String localName, String qName) throws SAXException {
        if (targetTags.contains(qName)) {
            nestLevel--;
            if (nestLevel > 0) {
                T data = stack.removeFirst();
                data.parseParams();
            }
        }
    }

    // ⭐️　ポイント6
    public String getResultJson() {
        T data = stack.peekFirst();
        String json = """
        {
            "filePath": "%s",
            "sqlMap": %s
        }
        """.formatted(filePath, data);
        return json;
    }
}

```

⭐️　ポイント1
DaoInfoの子クラスに利用を限定するため、ジェネリクス（型パラメータ）を設定しています。
コンストラクタの引数にサブライヤを設定しているのは、子クラスのインスタンスを生成するのをちょっとだけ楽するための仕掛けです。

⭐️　ポイント２
開始タグの後にテキストノードが来てその次に終了タグが来るというような綺麗な単純なXMLファイルは少ないかと思います。入れ子の階層とテキストノードがどのタグの内容コンテンツ（スコープ）になるのか把握する必要があります。スタック（先入後出し）の構造を利用するとこの問題を簡単に解決できます。

```xml:タグの入れ子とテキストノードの関係
<A>
-- コメントA1
  <B>
    <C>
      -- コメントC
    </C>
  -- コメントB
  </B>
  <D>
    -- コメントD
  </D>
-- コメントA2
</A>
```
* タグA
    * 内容するタグ　：　タグB、タグD
    * テキストノード　：　コメントA1、コメントA2
* タグB
    * 内容するタグ　：　タグC
    * テキストノード　：　コメントB
* タグC
    * 内容するタグ　：　なし
    * テキストノード　：　コメントC
* タグD
    * 内容するタグ　：　なし
    * テキストノード　：　コメントD

⭐️　ポイント3
要素つまりタグが開始した際に呼ばれるメソッドです。引数の`qName`にはタグ名、`attributes`にそのタグの属性が設定されています。対象のタグだった場合、入れ子をカウントアップし、タグ名と属性を取得してDaoInfoのインスタンスに設定して情報を保持します。タグが開始したら、これ以降終了タグが呼ばれるまではそのタグのスコープになります。ただし、新しいタグの開始が始まったらその新しいタグのスコープにしなければいけません。そこでスタックを利用して現在のスコープの対象となるタグを取得できるように制御します。

⭐️　ポイント４
テキストノードを読み込んだ際に呼ばれるメソッドです。テキストの内容（改行等）によっては連続して呼ばれる場合もあります。スタックを利用しているので、スタックの先頭にあるタグがこのテキストノードの持ち主になります。今回はホワイトスペースだけの場合はSKIP、改行や連続するホワイトスペースも取り除いた文字列を取得するようにしています。

⭐️　ポイント5
タグが終了した際に呼ばれるメソッドです。入れ子の階層をカウントダウンし、スタックから先頭にある現在のタグを削除します。また、削除するタグはタグの情報の取得が完了したものなので、パラメータを抽出するため`parseParams`メソッドを呼び出します。

⭐️　ポイント６
XMLファイルの解析結果を取得するためのメソッドです。スタックに解析結果のDaoInfoが残っているのでそちらを取り出し、JSON形式の文字列を返却します。

### ４．３． mainメソッドのあるコンソールアプリとなるクラス
```java:SqlMapAnalyze.java
package app;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.ArrayList;

import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

/**
 * MyBatis、iBatisのXMLファイルを解析するコンソールアプリ
 */
public class SqlMapAnalyze {

    private static final String OPTION_SUFFIX = "-f:";
    private static final String DEFAULT_SUFFIX = ".xml";
    private static final String TYPE_MYBATIS = "-mybatis";
    private static final String TYPE_IBATIS = "-ibatis";

    // ⭐️　ポイント1
    class Arg {
        String dirPath = null;
        String fileSuffix = DEFAULT_SUFFIX;
        String type = TYPE_MYBATIS;
        String analyzeTime = null;

        // constructor, setter, getter omitted

        @Override
        public String toString() {
            String json = """
                {
                    "dirPath": "%s",
                    "fileSuffix": "%s",
                    "type": "%s",
                    "analyzeTime": "%s"
                }
                """.formatted(dirPath, fileSuffix, type, analyzeTime);
            return json;
        }
    }

    // ⭐️　ポイント2
    private static Arg parseArgs(String[] args) {
        Arg arg = (new SqlMapAnalyze()).new Arg();
        for (String a : args) {
            if (TYPE_IBATIS.equals(a)) {
                arg.setType(TYPE_IBATIS);
                continue;
            }
            if (a.startsWith(OPTION_SUFFIX)) {
                arg.setFileSuffix(a.substring(3));
                continue;
            }
            arg.setDirPath(a);
        }
        LocalDateTime nowDate = LocalDateTime.now();
        arg.setAnalyzeTime(nowDate.toString());
        return arg;
    }

    // ⭐️　ポイント3
    private static File[] findSqlMapFiles(Arg arg) throws IOException {
        ArrayList<File> sqlMapFiles = new ArrayList<>();
        Files.walk(Paths.get(arg.getDirPath()))
        .filter((p) -> {
            return Files.isRegularFile(p) && p.toString().endsWith(arg.getFileSuffix());
        })
        .forEach((p) -> {
            sqlMapFiles.add(p.toFile());
        });
        File[] result = new File[sqlMapFiles.size()];
        return sqlMapFiles.toArray(result);
    }

    // ⭐️　ポイント4
    public static void main(String[] args) {
        Arg arg = parseArgs(args);
        if (arg.getDirPath() == null) {
            System.out.println("reqired directory path.");
            System.exit(1);
        }
		try {
            File[] sqlMapFiles = findSqlMapFiles(arg);
            // ⭐️　ポイント5
            SAXParserFactory factory = SAXParserFactory.newInstance();
            // DTD・schema検証を無効にする
            factory.setNamespaceAware(false);
            factory.setValidating(false);
            factory.setFeature("http://xml.org/sax/features/namespaces", false);
            factory.setFeature("http://xml.org/sax/features/validation", false);
            factory.setFeature("http://apache.org/xml/features/nonvalidating/load-dtd-grammar", false);
            factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
            SAXParser parser = factory.newSAXParser();

            System.out.println("{ \"param\":  " + arg +", \"result\": [");
            if (TYPE_MYBATIS.equals(arg.getType())) {
                for (int i=0; i<sqlMapFiles.length; i++) {
                    if (i > 0) {
                        System.out.println(", ");
                    }
                    File f = sqlMapFiles[i];
                    // ⭐️　ポイント6
                    SqlMapHandler<MyBatisInfo> handler = new SqlMapHandler<>(MyBatisInfo::new, f.toString());
                    parser.parse(f, handler);
                    System.out.println(handler.getResultJson());
                }
            } else if (TYPE_IBATIS.equals(arg.getType())) {
                for (int i=0; i<sqlMapFiles.length; i++) {
                    if (i > 0) {
                        System.out.println(", ");
                    }
                    File f = sqlMapFiles[i];
                    // ⭐️　ポイント6
                    SqlMapHandler<IbatisInfo> handler = new SqlMapHandler<>(IbatisInfo::new, f.toString());
                    parser.parse(f, handler);
                    System.out.println(handler.getResultJson());                    
                }
            }
            System.out.println("]}");
        } catch (Exception e) {
            e.printStackTrace();
            System.exit(1);
        }
        System.exit(0);
    }
}
```

⭐️　ポイント1
mainメソッドの引数を保持するための内部クラスを用意しました。

⭐️　ポイント2
引数からMyBatisとiBatisのどちらとして解析するのかの判定、実行時の現在日時の取得等も一緒に実施しています。引数以外の情報もArgクラスに格納してしまっていますが、管理が楽なので一つのクラスに纏めています。

⭐️　ポイント3
指定されたディレクトリパスから対象とするXMLファイルを再起的に検索してその一覧を返します。その際、ファイルサフィックスで対象ファイルをフィルタします。ファイルサフィックスは`.xml`がデフォルトですが`-f:`オプションで指定することもできます。

⭐️　ポイント4
mainメソッドです。引数をチェックして、必須となっている解析対象のファイルが格納されているディレクトリパス（一つの場合はファイルパス）が指定されていない場合、異常終了とします。

⭐️　ポイント5
SAXでXMLファイルをパースする際のお作法です。Factoryのインスタンスを生成して、そこからSAXパーサのインスタンスを生成します。XMLをパースするにはSAXパーサの`parse`メソッドにSAXハンドラのインスタンスを指定して実行します。

⭐️　ポイント6
SqlMapHandlerのインスタンスを生成する際、MyBatisとiBatisで型パラメータを切り替えます。コンストラクタ引数のサプライヤにはそれぞれの`new`を指定します。SqlMapHandlerの実装を見れば分かりますが、インスタンス内部で情報を保持するステートフルになっています。そのためXMLファイルを解析する毎に新規インスタンスを利用します。

## ５． さいごに
最初はMyBatisのmapperファイルの解析のために実装しましたが、その後、変換前の資材であるiBatisのsqlmapファイルについても同様の解析を行いたくなり、上記の結果となりました。３ファイルだけで良かったものが、汎化とiBatis対応でちょっと処理が複雑、かつファイルが増えてしまったのが気になります。今後は解析結果のJSONを利用して、効率的に動的SQL部分の動作確認ができないか検討していきたいと思います。