2023/12/12

String Templateによる文字列補間 その2 - カスタムテンプレートプロセッサー

このエントリーをはてなブックマークに追加

本エントリーはJava Advent Calendarシリーズ2の12日目です。

 

本エントリーで紹介しているJEP 459は差し戻しになってしまったので、本エントリーの内容は使えなくなりました。また、新しい仕様が策定したら改めて紹介する予定です。

 

qiita.com


前回のエントリーで、String Templateの基本的な使い方を紹介しました。

www.javainthebox.com

基本的にはSTRを使って、\{}を使って式を埋め込めばOKです。ただ、STRの直後にピリオドで文字列という書き方は今までのJavaとは違うので、ちょっと違和感はあるかもしれません。

今日はその後半です。発表資料だとp15ぐらいからの内容です。


文字列補間はたいていのプログラミング言語で使うことができる当たり前の機能です。今までJavaになかったのが不思議なくらい。

しかし、後発だからこそ他にはあまりない機能も盛り込んでいます。それがテンプレートプロセッサーのカスタム化です。

つまり、STRと同じようなテンプレートプロセッサーを簡単に作ることができるのです。本エントリーでは、このテンプレートプロセッサーのカスタム化について説明していきます。


テンプレートプロセッサーの動作

カスタム化について説明する前に、テンプレートプロセッサーの動作を確認しておきましょう。

前回、テンプレートプロセッサーであるStringTemplate.Processorインタフェースのprocessメソッドで文字列補間を行うと紹介しました。

ところで、processメソッドの引数の型はStringTemplateインタフェースです。ということは、どこかでStringTemplateオブジェクトが生成されているはずです。

実をいうと、String Templateは、バイトコードだとinvokeDynamicで実行されます。最終的にはProcessor.processメソッドをコールしますが、その前の処理を含めてinvokeDynamicで処理されています。

実をいうとSTR、RAW、FMT、そしてカスタムテンプレートプロセッサーでInvokeDynamicの動作がすべて異なります。それらをすべて解説するのはちょっと深入りしすぎるので、共通的に行われている処理の手順だけを説明しておきます。

たとえば、次のString Templateの処理を考えてみます。

STR."Hello, \{name}!";

このテンプレート引数である "Hello, \{name}!" は、まず文字列の部分と値の部分にバラバラに分解されます。

"Hello, " name "!"

次に文字列だけを集めたリストfragmentsと、値の部分を集めたリストvaluesに集約されます。

List<String> fragments: "Hello, ", "!"

List<Object> values: name

そして、fragmentsとvaluesを使用してStringTemplateオブジェクトを生成します。

var template = StringTemplate.of(fragments, values);

最後に、生成したStringTemplateオブジェクトを引数にしてテンプレートプロセッサーのprocessメソッドをコールします。

STR.process(template);

重要なのはStringTemplateオブジェクトは文字列部分を集めたリストfragmentsと、値を集めたリストvaluesを持つということです。

テンプレートプロセッサーは、これら2つのリストを使用して文字列の補間を行います。

なお、テンプレート引数の先頭が\{}の場合、fragmentsの先頭は""になります。また、末端が\{}で終わった場合、fragmentsの末尾は""になります。つまり、fragmentsのサイズは必ずvaluesより1つ多くなります。


カスタムテンプレートプロセッサー

テンプレートプロセッサーを自作するには2つの方法があり、下記の2つのメソッドのいずれかを使用します。

  • static <T> Processor<T, RuntimeException> of(Function<? super StringTemplate, ? extends T> process)
  • R process(StringTemplate stringTemplate) throws E

ofメソッドはProcessorオブジェクトを生成するファクトリーメソッドです。引数には文字列補間を行うラムダ式を指定します。

2つめの方法は、Processorインタフェースを実装したクラスを作成して、processメソッドをオーバーライドします。ただし、Processorインタフェースは関数型インタフェースなので、こちらもラムダ式で記述できます。

これらのメソッドの使い分けは、例外を扱うかどうかです。ofメソッドの戻り値の型の2つめの型パラメータはRuntimeException例外に固定されています。

一方のprocessメソッドは型パラメータEが例外を示しており、任意の例外をスローすることができます。

つまり、例外を扱わないでおくにはofメソッド、例外を扱いたいのであればprocessメソッドを使います。

では、まずofメソッドを使う方法から試してみましょう。


ofメソッドを使用したカスタム化

ofメソッドの引数のラムダ式は引数はStringTemplateオブジェクトですが、戻り値は任意に決めることができます。

まずはSTRと同じ動作をする、つまり文字列を戻すテンプレートプロセッサーを作ってみましょう。

StringTemplateインタフェースはfragmentsを戻すfragmentsメソッド、valuesを戻すvaluesメソッドが定義されています。これらのメソッドを使用すれば、STRと同じことができるはずです。

コードはこんな感じです。

    StringTemplate.Processor<String, RuntimeException> proc
        = StringTemplate.Processor.of(st -> {

        // fragments()とvalues()を使用して文字列補間

        return // 補間結果の文字列を戻す
    });

このラムダ式で文字列補間処理を行います。

STRと同じ処理であるのであれば、fragmentsとvaluesを1つづつ取り出し、文字列連結をすればよいことになります。

    StringTemplate.Processor<String, RuntimeException> proc
        = StringTemplate.Processor.of(st -> {

        var fragments = st.fragments();
        var values = st.values();

        var builder = new StringBuilder(fragments.get(0));
        for (int i = 0; i < values.size(); i++) {
            builder.append(values.get(i));
            builder.append(fragments.get(i+1));
        }

        return builder.toString();
    });

個人的にはこういうミュータブルなリストを使ったコードはもう書きたくないのですが、Streamインタフェースにzipメソッドがないので、しかたありません。

ところで、ほとんどのテンプレートプロセッサーはfragmentsとvaluesを順々に連結する処理を行います。このため、StringTemplateインタフェースには、この処理を行うinterpolateメソッドが定義されています。

interpolateメソッドを使用して、上のコードを書き直してみましょう。

    StringTemplate.Processor<String, RuntimeException> proc
        = StringTemplate.Processor.of(StringTemplate::interpolate);

かなりスッキリしました。

これだとSTRとまったく同じなので、たとえばテンプレート引数の文字列の部分がアルファベットであれば、それをすべて大文字にしてみましょう。

これにはfragmentsを大文字に変換してから、interpolateメソッドをコールするようにします。

    StringTemplate.Processor<String, RuntimeException> proc
        = StringTemplate.Processor.of(st -> StringTemplate.interpolate(
                st.fragments().stream().map(String::toUpperCase).toList(),
                st.values()));

簡単にできますね。


processメソッドのオーバーライドによるカスタム化

processメソッドをオーバーライドしたProcessorインタフェースの実装クラスを作るというと大げさな感じですが、実際にはofメソッドの引数で指定したラムダ式とほぼ同じです。

STRと同じ動作をさせるのであれば、interpolateメソッドを使用して次のように記述できます。

    StringTemplate.Processor<String, RuntimeException> proc
        = st -> st.interpolate();

さらにメソッド参照を使用すれば、さらに簡潔に記述できます。

    StringTemplate.Processor<String, RuntimeException> proc
        = StringTemplate::interpolate;

これだけだとofメソッドの場合と同じなので、例外を扱えるようにしてみましょう。

STRは値がnullだった場合、nullと表記されます。

jshell> String name = null
name ==> null

jshell> STR."Hello, \{name}!"
$2 ==> "Hello, null!"

もちろん、これはこれでいいのですが、たとえば値がnullの場合、NullPointerException例外をスローするようにしてみましょう。

    StringTemplate.Processor<String, NullPointerException> proc
        = st -> {
            if (st.values().contains(null)) {
                throw new NullPointerException("Value is NULL");
            }
            return st.interpolate();
        };

NullPointerException例外をスローするので、Processorインタフェースの型パラメーターをRuntimeException例外からNullPointerException例外に変更してあります。

そして、ラムダ式の中でvaluesにnullが含まれるかどうかをチェックし、含まれていればNullPointerException例外をスロー、含まれていなければinterpolateメソッドをコールします。


また、カスタムテンプレートプロセッサーは文字列以外のオブジェクトを生成することも可能です。

たとえば、JSONを直接扱うこともできます。

JSON in Javaを使って、JSONObjectオブジェクトを生成するテンプレートプロセッサーは次のようになります。

    StringTemplate.Processor<JSONObject, JSONException> JSON
        = st -> new JSONObject(st.interpolate());

Processorインタフェースの第1型パラメータがJSONObject、第2型パラメータがJSONExceptionになっていることに注意してください。

後は、JSONObjectクラスのコンストラクタ引数に文字列補間の結果を渡すだけです。

使い方は今までのテンプレートプロセッサーと同じです。

    var name = "Sakuraba";
    var city = "Tokyo";
    
    JSONObject jsonObj = JSON."""
        {
            "name": "\{name}",
            "city": "\{city}"
        }
        """;

これまでJSONObjectオブジェクトを生成するために文字列を作るのがめんどうだったのが、簡単にできるようになりました。

カスタムテンプレートプロセッサーはいろいろと使い道がありそうですね。たとえば、以下のような使い方がありそうです。

  • SQL
  • JSON
  • HTML/CSS/XML
  • ログメッセージ
  • エラーメッセージ

定型的な文字列処理になるような部分は、テンプレートプロセッサーで担えそうです。


文字列補間の危険性

文字列補間は便利なのですが、その反面、危険性を伴うこともあります。

安易に文字列補間を行った結果、SQLインジェクションやクロスサイトスクリプティングなどの原因になってしまうことがあります。

たとえば、SQLインジェクションについて見てみましょう。

    String query = STR."SELECT * FROM Person p WHERE p.name = '\{name}'";

このテンプレートに対し、変数nameが次の値だったらどうでしょう。

    name = "Sakuraba' OR p.name <> 'Sakuraba'";

名前がSakurabaでもSakuraba以外でもOKになってしまい、結果登録されている全件がクエリー結果になってしまいます。

このような問題に対し、カスタムテンプレートプロセッサーで対応することもできます。

たとえば、次のような手法が考えられます。

  • 使用できない文字を置き換える
  • 使用できない文字が含まれていれば例外をスロー
  • 型で制限する

まず、使用できない文字を置き換えるテンプレートプロセッサーを作ってみましょう。

valuesの値に使用できない文字があれば、置き換える処理を加えればできそうです。

    StringTemplate.Processor<String, RuntimeException> PROC = st -> {
        List<String> replacedValues
            = st.values().stream()
                         .map(v -> v.toString().replace("'", "\\'"))
                         .toList();
        return StringTemplate.interpolate(st.fragments(), replacedValues);
    };

これで、先ほどのクエリーは次のように変換されました。

jshell> StringTemplate.Processor<String, RuntimeException> PROC = st -> {
   ...>             List<String> replacedValues
   ...>                 = st.values().stream()
   ...>                              .map(v -> v.toString().replace("'", "\\'"))
   ...>                              .toList();
   ...>             return StringTemplate.interpolate(st.fragments(), replacedValues);
   ...>     };
PROC ==> $Lambda/0x000002380c00a000@60c6f5b

jshell> var name = "Sakuraba' OR p.name <> 'Sakuraba'"
name ==> "Sakuraba' OR p.name <> 'Sakuraba'"

jshell> var query = PROC."SELECT * FROM Person p WHERE p.name = '\{name}'"
query ==> "SELECT * FROM Person p WHERE p.name = 'Sakuraba\ ... p.name <> \\'Sakuraba\\''"

jshell> System.out.println(query)
SELECT * FROM Person p WHERE p.name = 'Sakuraba\' OR p.name <> \'Sakuraba\''

ここではクオーテーション'が\'に置き換えられていることが分かります。

2番目の方法はそもそも置き換えられるような文字が入っていることがまちがっているということです。この場合は、例外をスローするようにします。

    StringTemplate.Processor<String, IllegalArgumentException> PROC = st -> {
        st.values().stream()
                   .map(v -> v.toString())
                   .filter(v -> v.contains("'"))
                   .findFirst()
                   .ifPresent(s -> {
                       throw new IllegalArgumentException(STR. "Illegal Text: \{s}" );
                   });

        return st.interpolate();
    };

ストリームでvaluesの中に使用できない文字が含まれていないか調べ、含まれていたらIllegalArgumentException例外をスローするようにしてあります。

使用できない文字が含まれていなければ、interpolateメソッドで文字列補間を行います。

ここまでの2種類の方法は、文字列補間の時にバリデーションをして対応する手法です。しかし、そもそもバリデーションはもっと早い段階、たとえばWebからの入力を受け取った時に行うこともできるはずです。

バリデーションを行って、適切なデータ型に変換し、カスタムテンプレートプロセッサーではそのデータ型に制限して処理を行うのが最後の方法です。

たとえば、name属性を持つPersonレコード( record Person(String name){} )があったとしましょう。カスタムテンプレートプロセッサーは、このPersonレコードだけを使用できるようにします。

    StringTemplate.Processor<String, IllegalArgumentException> PROC = st -> {
        var values = st.values().stream()
           .map(v -> {
               if (v instanceof Person(var name)) {
                   return name;
               } else {
                   throw new IllegalArgumentException(STR."Not Person class \{v.getClass()}: \{v}");
               }
           })
           .toList();

           return StringTemplate.interpolate(st.fragments(), values);
    };

型の比較には、Java 21で導入されたパターンマッチングを使用しています。


システムによってどのようにバリデーションを行うかの方針は異なると思いますが、その方針に応じてカスタムテンプレートプロセッサーを作成して、文字列補間の危険性を下げていくことができるはずです。


このように、いろいろと便利なString Templateなのですが、使いにくい点が1つだけあります。それはテンプレート引数が文字列リテラルもしくはテキストブロックしか使えないところです。

通常のテンプレートエンジンであれば、テンプレートをファイルから読み込むなど、任意の文字列をテンプレートにすることができます。

しかし、String Templateではそれができません。でも、できないと言われると、やりたくなりますよね。

そこで、次回は動的にテンプレート引数を作成する方法を紹介します。


0 件のコメント: