1. はじめに
前回の記事「MyBatis/iBatisのsqlmapファイルを解析してタグ情報&パラメータを取得する」で説明したツールのソースコードの説明になります。このツールを作成することになった経緯やツールの概要、実行例については前回の記事を参照ください。
(2024/3/1 追記)
インターネットに繋がらない環境ではDTD・schema検証の処理にてコネクションタイムアウトエラーが発生します。この検証をOFFにするため、SAXParserFactoryに設定する内容を追加しました。
2. ツールの仕様・前提
- Javaのコンソールアプリ
- 必須の引数はMyBatisのmapperファイル、iBatisのsqlmapファイルが格納されたディレクトリパス
- ファイルが1つの場合はファイルパスでもOK
-
-mybatis(デフォルト)
オプションでMyBatisのmapperファイルとして解析する -
-ibatis
オプションでiBatisのsqlmapファイルとして解析する -
-f:ファイルサフィックス
で対象のファイルをフィルタする - 解析結果はJSON形式で標準出力に出力する
- 外部ライブラリは利用せず、Java17の標準機能で実装する
3. ファイル構成
project_dir
|--app // java package
|--DaoInfo.java
|--IbatisInfo.java
|--MyBatisInfo.java
|--SqlMapAnalyze.java
|--SqlMapHandler.java
javac -encoding UTF-8 app/*.java
4. ソースコード
4.1. 解析結果を保持するクラス
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;
}
// ⭐️ ポイント2
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);
}
// ⭐️ ポイント3
public abstract Set<String> getTargetTags();
// ⭐️ ポイント4
public abstract void parseParams();
}
⭐️ ポイント1
解析結果をJSON形式で表示したいのでtoString
メソッドをオーバーライドしています。これでこのインスタンスをSystem.out.println
で標準出力するとJSON形式で表示されます。
Java17にJSONの標準APIがあればよかったのですが、JSON BindingはJava EEなので残念ですが使えません。
⭐️ ポイント2
XMLファイルをパースした際にタグ、属性、テキストノードの情報を追加するためのメソッドです。パースしたデータをこれらのメソッドを使って追加していきます。
⭐️ ポイント3
MyBatisのmapperファイルとiBatisのsqlmapファイルでは利用できるタグが異なります。対象とするタグをぞれぞれ指定できるように抽象メソッドを準備します。
⭐️ ポイント4
MyBatisとiBatisでは#{xxx}
と#xxx#
のようにパラメータの指摘が異なります。個別に実装できるように抽象メソッドを準備します。
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;
}
// ⭐️ ポイント3
@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ファイルの解析対象とするタグを定義します。
⭐️ ポイント2
少し変わった記述になっていますがタグ名をkey、そのタグからパラメータに該当する属性を取得するFunctionをvalueにしたMapを定義します。タグ毎にパラメータとなっている属性が異なるため、このような仕組みにしています。この後で説明するparseParams
メソッドから参照します。
⭐️ ポイント3
親クラスのtextプロパティつまりタグ内のテキストノードの情報を参照し、#{xxx}
や${xxx}
等のパラメータを抽出します。合わせて「⭐️ ポイント2」のMapを利用し、タグの属性からパラメータとして指定されている値も抽出します。最終的にJSON形式の配列の文字列として親クラスのparamsプロパティに結果を設定します。
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ファイルの違いであるタグ名やパラメータの記載方法の箇所です。
4.2. XMLファイルを解析するクラス
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;
/**
* ⭐️ ポイント2
* 現在のタグの入れ子階層
*/
private int nestLevel = 0;
/**
* ⭐️ ポイント2
* タグの入れ子階層を記録するためのスタック
*/
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の子クラスに利用を限定するため、ジェネリクス(型パラメータ)を設定しています。
コンストラクタの引数にサブライヤを設定しているのは、子クラスのインスタンスを生成するのをちょっとだけ楽するための仕掛けです。
⭐️ ポイント2
開始タグの後にテキストノードが来てその次に終了タグが来るというような綺麗な単純な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のインスタンスに設定して情報を保持します。タグが開始したら、これ以降終了タグが呼ばれるまではそのタグのスコープになります。ただし、新しいタグの開始が始まったらその新しいタグのスコープにしなければいけません。そこでスタックを利用して現在のスコープの対象となるタグを取得できるように制御します。
⭐️ ポイント4
テキストノードを読み込んだ際に呼ばれるメソッドです。テキストの内容(改行等)によっては連続して呼ばれる場合もあります。スタックを利用しているので、スタックの先頭にあるタグがこのテキストノードの持ち主になります。今回はホワイトスペースだけの場合はSKIP、改行や連続するホワイトスペースも取り除いた文字列を取得するようにしています。
⭐️ ポイント5
タグが終了した際に呼ばれるメソッドです。入れ子の階層をカウントダウンし、スタックから先頭にある現在のタグを削除します。また、削除するタグはタグの情報の取得が完了したものなので、パラメータを抽出するためparseParams
メソッドを呼び出します。
⭐️ ポイント6
XMLファイルの解析結果を取得するためのメソッドです。スタックに解析結果のDaoInfoが残っているのでそちらを取り出し、JSON形式の文字列を返却します。
4.3. mainメソッドのあるコンソールアプリとなるクラス
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ファイルを解析する毎に新規インスタンスを利用します。
5. さいごに
最初はMyBatisのmapperファイルの解析のために実装しましたが、その後、変換前の資材であるiBatisのsqlmapファイルについても同様の解析を行いたくなり、上記の結果となりました。3ファイルだけで良かったものが、汎化とiBatis対応でちょっと処理が複雑、かつファイルが増えてしまったのが気になります。今後は解析結果のJSONを利用して、効率的に動的SQL部分の動作確認ができないか検討していきたいと思います。