JDK23 で正式公開 文字列テンプレート(JEP 465: String Templates)

blog1.mammb.com


はじめに

JDK 21 でプレビュー公開(JEP 430)、JDK 22で第二プレビュー公開(JEP 459) された文字列テンプレートが、JDK23にて正式公開(JEP 465)となります。

Java で導入される文字列テンプレートは、他言語における ${x} plus ${y} equals ${x + y} のような文字列補完としても利用できますが、それを超えた拡張性があります。

例えば、文字列補完でSQLを構築した場合、簡単に(SQLインジェクションなどの)脆弱性が埋め込まれてしまいますが、文字列テンプレートでは、脆弱な文字列連結を安全に扱うことができる他、構造化テキストをあらゆる種類のオブジェクトに変換することができます。

それゆえ、文字列補完(string interpolation)ではなく、文字列テンプレート(String Templates) となっています。


従来の文字列構築

文字列の結合には + オペレータを使うことができますが、これは非常に読みにくいものになります。

String s = x + " plus " + y + " equals " + (x + y);

StringBuilder を使った場合も同じです。

String s = new StringBuilder()
                 .append(x)
                 .append(" plus ")
                 .append(y)
                 .append(" equals ")
                 .append(x + y)
                 .toString();

バインド変数として埋め込む場合は以下のようになりますが、型の不一致が発生する可能性があります。

String s = String.format("%2$d plus %1$d equals %3$d", x, y, x + y);
String t = "%2$d plus %1$d equals %3$d".formatted(x, y, x + y);

MessageFormat を使うこともできますが、いろいろとお作法が必要になります。

MessageFormat mf = new MessageFormat("{0} plus {1} equals {2}");
String s = mf.format(x, y, x + y);


文字列テンプレート

文字列テンプレートは以下のように利用します。

String name = "Joan";
String info = STR."My name is \{name}"; // My name is Joan

他の言語の文字列補完に比べてSTR. が野暮ったいですが、これにより文字列補完を超えた機能性が提供されます。

  • STR."My name is \{name}" はテンプレート式と呼ぶ
  • STR はテンプレートプロセッサを示す
  • "My name is \{name}" はテンプレートと呼ぶ
  • \{name} は埋め込み式と呼ぶ

テンプレートの内容を、テンプレートプロセッサが処理し、文字列に限らず、任意のオブジェクトを生成できます。 ここでの STR テンプレートプロセッサは文字列を生成するプロセッサで、全ての Java ソース上に自動的にインポートされる public static final なフィールドです。


埋め込み式(Embedded expressions)

埋め込み式には、当然ですが、任意の Java 式を記載でき、以下のように算術演算を実行できます。

int x = 10, y = 20;
String s = STR."\{x} + \{y} = \{x + y}"; // "10 + 20 = 30"

メソッドの実行やフィールドへのアクセスも可能です。

String s = STR."You have a \{getOfferType()} waiting for you!";
String t = STR."Access at \{req.date} \{req.time} from \{req.ipAddress}";

埋め込み式の中では、" をエスケープ無しで使用できます。

String msg = STR."The file \{filePath} \{file.exists() ? "does" : "does not"} exist";

さらに埋め込み式の中で改行することもできますし、コメントを記述することもできます。

String time = STR."The time is \{
    // The java.time.format package is very useful
    DateTimeFormatter
      .ofPattern("HH:mm:ss")
      .format(LocalTime.now())
} right now";

文字列テンプレートをネストすることもできます。

String s = STR."\{fruit[0]}, \{
    STR."\{fruit[1]}, \{fruit[2]}"
}";

文字列リテラルだけでも有効です。

String s = STR."Hello";

\{}null リテラルとして解釈されます。

String tmp = STR."Is \{}"; // "Is null"


マルチライン テンプレート式

テキストブロックの構文を利用してテンプレート式を構築することができます。

String title = "My Web Page";
String text  = "Hello, world";
String html = STR."""
        <html>
          <head>
            <title>\{title}</title>
          </head>
          <body>
            <p>\{text}</p>
          </body>
        </html>
        """;

テキストブロックと同様に """ を利用します。


テンプレートプロセッサ

テンプレートプロセッサとして以下が提供されています。

  • STR :テンプレートに埋め込まれた各式を、文字列化された式の結果で置き換える。一般的な文字列補完の用途で使うことができる
  • FMTSTR の文字列補完に加え、埋め込み式に書式指定子適用することができる。表示幅を定義したり、小数点表記を指定したりすることができる
  • RAW :StringTemplate オブジェクトを生成するだけの標準的なテンプレートプロセッサであり、カスタマイズされたテンプレートプロセッサを自作する場合などで利用する

FMT テンプレートプロセッサ

FMT は、補間を行う点では STR と同じですが、埋め込み式の左側に現れる書式指定子を解釈することができます。 フォーマット指定子は、java.util.Formatterで定義されているものと同じです。

record Rectangle(String name, double width, double height) {
    double area() {
        return width * height;
    }
}
Rectangle[] zone = new Rectangle[] {
    new Rectangle("Alfa", 17.8, 31.4),
    new Rectangle("Bravo", 9.6, 12.4),
    new Rectangle("Charlie", 7.1, 11.23),
};
String table = FMT."""
    Description     Width    Height     Area
    %-12s\{zone[0].name}  %7.2f\{zone[0].width}  %7.2f\{zone[0].height}     %7.2f\{zone[0].area()}
    %-12s\{zone[1].name}  %7.2f\{zone[1].width}  %7.2f\{zone[1].height}     %7.2f\{zone[1].area()}
    %-12s\{zone[2].name}  %7.2f\{zone[2].width}  %7.2f\{zone[2].height}     %7.2f\{zone[2].area()}
    \{" ".repeat(28)} Total %7.2f\{zone[0].area() + zone[1].area() + zone[2].area()}
    """;

以下のような整形された文字列を得ることができます。

Description     Width    Height     Area
Alfa            17.80    31.40      558.92
Bravo            9.60    12.40      119.04
Charlie          7.10    11.23       79.73
                             Total  757.69

RAW テンプレートプロセッサ

以下のテンプレート式は、

String name = "Joan";
String info = STR."My name is \{name}";

以下と同等になります。

String name = "Joan";
StringTemplate st = RAW."My name is \{name}";
String info = STR.process(st);

RAW は未処理のオブジェクトをそのまま返すテンプレートプロセッサとなり、以下のように定義されています。

package java.lang;

public interface StringTemplate {
    Processor<String, RuntimeException> STR = StringTemplate::interpolate;
    Processor<StringTemplate, RuntimeException> RAW = st -> st;
    // ...
}


StringTemplate と Processor

テンプレートプロセッサ(Processor)は、StringTemplate の内部インターフェースとして以下のように定義されています。

package java.lang;

public interface StringTemplate {
    // ...
    public interface Processor<R, E extends Throwable> {
        R process(StringTemplate stringTemplate) throws E;
    }
    // ...
}

StringTemplate を入力に、結果型 R を返す関数になります。


StringTemplate は以下のような定義となっています。

package java.lang;

public interface StringTemplate {

    List<String> fragments();
    List<Object> values();
    
    default String interpolate() {
        return StringTemplate.interpolate(fragments(), values());
    }
    
    default <R, E extends Throwable> R
    process(Processor<? extends R, ? extends E> processor) throws E {
        Objects.requireNonNull(processor, "processor should not be null");
        return processor.process(this);
    }
    // ...
}

例えば以下のような文字列テンプレート定義があった場合、

int x = 10, y = 20;
StringTemplate st = RAW."\{x} plus \{y} equals \{x + y}";

fragmentsvalues は以下のようになります。

StringTemplate{ 
  fragments = [ "", " plus ", " equals ", "" ],
  values = [10, 20, 30]
}

fragments は StringTemplate のインスタンス生成後は不変ですが、values は各評価毎で新たに計算されたものとなります。


ユーザ定義のテンプレートプロセッサ

テンプレートプロセッサは、Processor のファクトリメソッド Processor.of にて簡単に作成できます。

例えば以下のようなテンプレートプロセッサにより、

var INTER = StringTemplate.Processor.of((StringTemplate st) -> {
    StringBuilder sb = new StringBuilder();
    Iterator<String> fragIter = st.fragments().iterator();
    for (Object value : st.values()) {
        sb.append(fragIter.next());
        sb.append(value);
    }
    sb.append(fragIter.next());
    return sb.toString();
});

STR テンプレートプロセッサと同様の結果を得ることができます。

int x = 10, y = 20;
String s = INTER."\{x} plus \{y} equals \{x + y}"; // "10 and 20 equals 30"


テンプレートプロセッサは任意のオブジェクトを返すことができるため、以下のように JSONObject を生成することもできます。

String name    = "Joan Smith";
String phone   = "555-123-4567";
String address = "1 Maple Drive, Anytown";
try {
    JSONObject doc = JSON_VALIDATE."""
        {
            "name":    \{name},
            "phone":   \{phone},
            "address": \{address}
        };
        """;
} catch (JSONException ex) {
    ...
}

このようなテンプレートプロセッサは以下のように定義することができます。

StringTemplate.Processor<JSONObject, JSONException> JSON_VALIDATE =
    (StringTemplate st) -> {
        String quote = "\"";
        List<Object> filtered = new ArrayList<>();
        for (Object value : st.values()) {
            if (value instanceof String str) {
                if (str.contains(quote)) {
                    throw new JSONException("Injection vulnerability");
                }
                filtered.add(quote + str + quote);
            } else if (value instanceof Number ||
                       value instanceof Boolean) {
                filtered.add(value);
            } else {
                throw new JSONException("Invalid value type");
            }
        }
        String jsonSource =
            StringTemplate.interpolate(st.fragments(), filtered);
        return new JSONObject(jsonSource);
    };

ここで作成したテンプレートプロセッサは、バリデーション処理を含んでいることに注目してください。


データベースクエリの安全な作成と実行

PreparedStatement によるクエリ生成を可能にするテンプレートプロセッサを以下のように定義することができます。

record QueryBuilder(Connection conn)
  implements StringTemplate.Processor<PreparedStatement, SQLException> {

    public PreparedStatement process(StringTemplate st) throws SQLException {
        // 1. Replace StringTemplate placeholders with PreparedStatement placeholders
        String query = String.join("?", st.fragments());

        // 2. Create the PreparedStatement on the connection
        PreparedStatement ps = conn.prepareStatement(query);

        // 3. Set parameters of the PreparedStatement
        int index = 1;
        for (Object value : st.values()) {
            switch (value) {
                case Integer i -> ps.setInt(index++, i);
                case Float f   -> ps.setFloat(index++, f);
                case Double d  -> ps.setDouble(index++, d);
                case Boolean b -> ps.setBoolean(index++, b);
                default        -> ps.setString(index++, String.valueOf(value));
            }
        }

        return ps;
    }
}

QueryBuilder により、SQLインジェクションを防いで、読みやすいクエリを書くことができます。

Connection conn = ...
var DB = new QueryBuilder(conn);

PreparedStatement ps = DB."SELECT * FROM Person p WHERE p.last_name = \{name}";
ResultSet rs = ps.executeQuery();


リソースバンドルの簡素化

ResourceBundle からメッセージを構築するテンプレートプロセッサも考えられます。

record LocalizationProcessor(Locale locale)
  implements StringTemplate.Processor<String, RuntimeException> {

    public String process(StringTemplate st) {
        ResourceBundle resource = ResourceBundle.getBundle("resources", locale);
        String stencil = String.join("_", st.fragments());
        String msgFormat = resource.getString(stencil.replace(' ', '.'));
        return MessageFormat.format(msgFormat, st.values().toArray());
    }
}

resources_jp.properties に以下のメッセージを定義しておき、

no.suitable._.found.for._(_)={1}に適切な{0}が見つかりません({2})

日本語ロケールで使用できます。

var LOCALIZE = new LocalizationProcessor(new Locale("jp"));
var symbolKind = "field", name = "tax", type = "double";

LOCALIZE."no suitable \{symbolKind} found for \{name}(\{type})");
taxに適切なfieldが見つかりません(double)