openjdk.ruOpenJDK на русском

JEP 459: String Templates (Second Preview)

String Templates, вторая версия Preview (предварительная версия)

ОтветственныйJim Laskey
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск22
Компонентspecification / language
Обсуждениеamber dash dev at openjdk dot org
ТрудоёмкостьM
ДлительностьM
Связан сJEP 430: String Templates (Preview)
JEP 465: String Templates (Third Preview)
8318909: JEP 459: JLS Changes for String Templates (Second Preview)
РецензентыAlex Buckley, Gavin Bierman
ОдобренBrian Goetz
Создан2023/08/14 12:17
Обновлён2025/02/25 16:31
Задача8314219

Аннотация

Добавить в язык программирования Java String Templates. String Templates дополняют существующие в Java строковые литералы и Text Blocks (текстовые блоки): они сочетают литеральный текст со встроенными выражениями и процессорами шаблонов и так дают специализированные результаты. Это Preview-возможность языка и API.

История

String Templates впервые были предложены как Preview-возможность в JEP 430 в JDK 21. Здесь мы предлагаем вторую версию Preview, чтобы получить дополнительный опыт и отзывы. Кроме технического изменения в типах шаблонных выражений, по сравнению с первой версией Preview ничего не изменилось.

Цели

  • Упростить написание программ на Java: сделать так, чтобы строки со значениями, вычисляемыми во время выполнения, было легко записывать.

  • Сделать удобнее для чтения выражения, в которых смешаны текст и выражения, независимо от того, помещается ли текст в одну строку исходного кода (как в строковых литералах) или занимает несколько строк (как в Text Blocks).

  • Повысить безопасность программ на Java, которые составляют строки из значений, полученных от пользователя, и передают их другим системам (например, формируют запросы к базам данных): для этого нужна поддержка проверки и преобразования как самого шаблона, так и значений его встроенных выражений.

  • Сохранить гибкость: позволить библиотекам Java определять синтаксис форматирования, используемый в строковых шаблонах.

  • Упростить работу с API, которые принимают строки на других языках (например, SQL, XML и JSON).

  • Дать возможность создавать нестроковые значения, вычисляемые из литерального текста и встроенных выражений, без промежуточного строкового представления.

Что не является целью

  • Целью не является добавление синтаксического сахара для оператора конкатенации строк (+), поскольку это позволило бы обойти проверку, а проверка — одна из целей.

  • Целью не является объявление устаревшими или удаление классов StringBuilder и StringBuffer, которые традиционно используются для сложного или программного составления строк.

Мотивация

Разработчики постоянно составляют строки из литерального текста и выражений. В языке Java и его API есть несколько механизмов составления строк, но, к сожалению, у каждого из них есть недостатки.

  • Конкатенация строк с помощью оператора + даёт трудночитаемый код:

    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::format и String::formatted отделяют строку формата от параметров, из-за чего легко ошибиться в количестве и типах аргументов:

    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);
  • java.text.MessageFormat требует слишком много церемоний и использует непривычный синтаксис в строке формата:

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

Интерполяция строк

Во многих языках программирования в качестве альтернативы конкатенации строк есть интерполяция строк. Обычно это строковый литерал, который содержит и литеральный текст, и встроенные выражения. Поскольку выражения встроены in situ (на своём месте), читателю легко понять, какой результат имеется в виду. Во время выполнения встроенные выражения заменяются своими значениями (преобразованными в строку) — говорят, что значения интерполируются в строку. Вот несколько примеров интерполяции в других языках:

C#$"{x} plus {y} equals {x + y}"
Visual Basic$"{x} plus {y} equals {x + y}"
Pythonf"{x} plus {y} equals {x + y}"
Scalas"$x plus $y equals ${x + y}"
Groovy"$x plus $y equals ${x + y}"
Kotlin"$x plus $y equals ${x + y}"
JavaScript`${x} plus ${y} equals ${x + y}`
Ruby"#{x} plus #{y} equals #{x + y}"
Swift"\(x) plus \(y) equals \(x + y)"

В одних языках интерполяция работает для всех строковых литералов, в других её нужно включать там, где она требуется, например поставив перед открывающим разделителем литерала $ или f. Синтаксис встроенных выражений тоже различается, но часто в нём используются символы вроде $ или { }, а значит, эти символы не могут встречаться в тексте буквально, если их не экранировать.

Интерполяция не только удобнее конкатенации при написании кода, но и делает код понятнее при чтении. Особенно это заметно на длинных строках. Например, в JavaScript:

const title = "My Web Page";
const text  = "Hello, world";

var html = `<html>
              <head>
                <title>${title}</title>
              </head>
              <body>
                <p>${text}</p>
              </body>
            </html>`;

Интерполяция строк опасна

К сожалению, у удобства интерполяции есть обратная сторона: легко составить строки, которые будут интерпретироваться другими системами, но в этих системах окажутся опасно некорректными.

Строки, содержащие SQL-запросы, документы HTML/XML, фрагменты JSON, shell-скрипты и текст на естественном языке, нужно проверять и очищать по правилам соответствующей предметной области. Язык программирования Java никак не может обеспечить соблюдение всех таких правил, поэтому проверять и очищать данные должны сами разработчики, использующие интерполяцию. Обычно это значит, что нужно не забывать оборачивать встроенные выражения в вызовы методов escape или validate и полагаться на IDE или инструменты статического анализа при проверке литерального текста.

Интерполяция особенно опасна для SQL-запросов, потому что может привести к атакам внедрения. Рассмотрим, например, такой гипотетический код на Java со встроенным выражением ${name}:

String query = "SELECT * FROM Person p WHERE p.last_name = '${name}'";
ResultSet rs = connection.createStatement().executeQuery(query);

Если бы name имело проблемное значение

Smith' OR p.last_name <> 'Smith

то строка запроса была бы такой:

SELECT * FROM Person p WHERE p.last_name = 'Smith' OR p.last_name <> 'Smith'

и код выбрал бы все строки таблицы, что может раскрыть конфиденциальную информацию. Составлять строку запроса наивной интерполяцией так же небезопасно, как и традиционной конкатенацией:

String query = "SELECT * FROM Person p WHERE p.last_name = '" + name + "'";

Можно ли сделать лучше?

Для платформы Java мы хотели бы иметь механизм составления строк, который был бы так же нагляден, как интерполяция, но сразу давал бы более безопасный результат, возможно, ценой небольшой потери удобства ради значительного выигрыша в безопасности.

Например, при составлении SQL-запросов все кавычки в значениях встроенных выражений должны экранироваться, а кавычки в строке в целом должны быть сбалансированы. При показанном выше проблемном значении name должен получиться безопасный запрос:

SELECT * FROM Person p WHERE p.last_name = '\'Smith\' OR p.last_name <> \'Smith\''

Почти всякое использование интерполяции строк сводится к тому, чтобы построить строку по некоторому шаблону: SQL-запрос обычно следует шаблону SELECT ... FROM ... WHERE ..., HTML-документ — шаблону <html> ... </html>, и даже сообщение на естественном языке следует шаблону, в котором динамические значения (например, имя пользователя) перемежаются с литеральным текстом. У каждого вида шаблона свои правила проверки и преобразования, например «экранировать все кавычки» для SQL-запросов, «допускать только корректные символьные сущности» для HTML-документов и «локализовать на язык, настроенный в ОС» для сообщений на естественном языке.

В идеале шаблон строки можно было бы выразить прямо в коде, как бы аннотировав строку, а среда выполнения Java автоматически применяла бы к строке правила, соответствующие шаблону. В результате получались бы SQL-запросы с экранированными кавычками, HTML-документы без недопустимых сущностей и локализация сообщений без шаблонного кода. Составление строки по шаблону избавило бы разработчиков от необходимости кропотливо экранировать каждое встроенное выражение, вызывать validate() для всей строки или использовать java.util.ResourceBundle для поиска локализованной строки.

Другой пример: мы можем составить строку, представляющую JSON-документ, а затем передать её парсеру JSON, чтобы получить строго типизированный JSONObject:

String name    = "Joan Smith";
String phone   = "555-123-4567";
String address = "1 Maple Drive, Anytown";
String json = """
    {
        "name":    "%s",
        "phone":   "%s",
        "address": "%s"
    }
    """.formatted(name, phone, address);

JSONObject doc = JSON.parse(json);
... doc.entrySet().stream().map(...) ...

В идеале JSON-структуру строки можно было бы выразить прямо в коде, а среда выполнения Java автоматически преобразовывала бы строку в JSONObject. Обходной путь через парсер вручную был бы не нужен.

Итак, будь у нас полноценный механизм составления строк на основе шаблонов, мы могли бы сделать почти любую программу на Java более читаемой и надёжной. Такая возможность давала бы преимущества интерполяции, известные по другим языкам программирования, но с меньшим риском внести уязвимости. Она также сократила бы церемонии при работе с библиотеками, которые принимают сложные входные данные в виде строк.

Описание

Шаблонные выражения — новый вид выражений в языке программирования Java. Шаблонные выражения могут выполнять интерполяцию строк, но при этом программируемы, и это помогает разработчикам составлять строки безопасно и эффективно. Кроме того, шаблонные выражения не ограничиваются составлением строк — они могут превращать структурированный текст в объект любого вида по правилам предметной области.

Синтаксически шаблонное выражение похоже на строковый литерал с префиксом. Во второй строке этого кода находится шаблонное выражение:

String name = "Joan";
String info = STR."My name is \{name}";
assert info.equals("My name is Joan");   // true

Шаблонное выражение STR."My name is \{name}" состоит из:

  1. процессора шаблонов (STR);
  2. символа точки (U+002E), как и в выражениях других видов;
  3. шаблона ("My name is \{name}"), который содержит встроенное выражение (\{name}).

Когда шаблонное выражение вычисляется во время выполнения, его процессор шаблонов объединяет литеральный текст шаблона со значениями встроенных выражений и получает результат. Результат процессора шаблонов, а значит, и результат вычисления шаблонного выражения, часто имеет тип String, но не всегда.

Процессор шаблонов STR

STR — процессор шаблонов, определённый в платформе Java. Он выполняет интерполяцию строк: заменяет каждое встроенное выражение в шаблоне значением этого выражения, преобразованным в строку. Результат вычисления шаблонного выражения с STR имеет тип String, например "My name is Joan".

В повседневном общении разработчики, скорее всего, будут называть «шаблоном» либо всё шаблонное выражение целиком, включая процессор шаблонов, либо только шаблонную часть шаблонного выражения, то есть аргумент процессора шаблонов. Такое неформальное словоупотребление вполне допустимо, если не смешивать эти понятия.

STR — это поле public static final, которое автоматически импортируется в каждый исходный файл Java.

Вот ещё примеры шаблонных выражений с процессором шаблонов STR. Символ | на левом поле означает, что в строке показано значение предыдущей инструкции, как в jshell.

// Embedded expressions can be strings
String firstName = "Bill";
String lastName  = "Duck";
String fullName  = STR."\{firstName} \{lastName}";
| "Bill Duck"
String sortName  = STR."\{lastName}, \{firstName}";
| "Duck, Bill"

// Embedded expressions can perform arithmetic
int x = 10, y = 20;
String s = STR."\{x} + \{y} = \{x + y}";
| "10 + 20 = 30"

// Embedded expressions can invoke methods and access fields
String s = STR."You have a \{getOfferType()} waiting for you!";
| "You have a gift waiting for you!"
String t = STR."Access at \{req.date} \{req.time} from \{req.ipAddress}";
| "Access at 2022-03-25 15:34 from 8.8.8.8"

Для удобства рефакторинга символы двойных кавычек можно использовать внутри встроенных выражений, не экранируя их как \". Это значит, что встроенное выражение может выглядеть в шаблонном выражении точно так же, как вне его, и переходить от конкатенации (+) к шаблонным выражениям проще. Например:

String filePath = "tmp.dat";
File   file     = new File(filePath);
String old = "The file " + filePath + " " + (file.exists() ? "does" : "does not") + " exist";
String msg = STR."The file \{filePath} \{file.exists() ? "does" : "does not"} exist";
| "The file tmp.dat does exist" or "The file tmp.dat 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";
| "The time is 12:34:56 right now"

Число встроенных выражений в шаблонном выражении не ограничено. Встроенные выражения вычисляются слева направо, так же как аргументы в выражении вызова метода. Например:

// Embedded expressions can be postfix increment expressions
int index = 0;
String data = STR."\{index++}, \{index++}, \{index++}, \{index++}";
| "0, 1, 2, 3"

В качестве встроенного выражения можно использовать любое выражение Java — даже шаблонное выражение. Например:

// Embedded expression is a (nested) template expression
String[] fruit = { "apples", "oranges", "peaches" };
String s = STR."\{fruit[0]}, \{STR."\{fruit[1]}, \{fruit[2]}"}";
| "apples, oranges, peaches"

Здесь шаблонное выражение STR."\{fruit[1]}, \{fruit[2]}" встроено в шаблон другого шаблонного выражения. Этот код трудно читать из-за обилия символов ", \ и { }, поэтому лучше отформатировать его так:

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

Поскольку у встроенного выражения нет побочных эффектов, его можно также вынести в отдельное шаблонное выражение:

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

Многострочные шаблонные выражения

Шаблон шаблонного выражения может занимать несколько строк исходного кода; синтаксис при этом похож на синтаксис Text Blocks. (Выше мы видели встроенное выражение на нескольких строках, но шаблон, содержавший это встроенное выражение, логически был однострочным.)

Вот примеры шаблонных выражений, представляющих HTML-текст, JSON-текст и таблицу часовых поясов, — все они занимают несколько строк:

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

String name    = "Joan Smith";
String phone   = "555-123-4567";
String address = "1 Maple Drive, Anytown";
String json = STR."""
    {
        "name":    "\{name}",
        "phone":   "\{phone}",
        "address": "\{address}"
    }
    """;
| """
| {
|     "name":    "Joan Smith",
|     "phone":   "555-123-4567",
|     "address": "1 Maple Drive, Anytown"
| }
| """

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 = STR."""
    Description  Width  Height  Area
    \{zone[0].name}  \{zone[0].width}  \{zone[0].height}     \{zone[0].area()}
    \{zone[1].name}  \{zone[1].width}  \{zone[1].height}     \{zone[1].area()}
    \{zone[2].name}  \{zone[2].width}  \{zone[2].height}     \{zone[2].area()}
    Total \{zone[0].area() + zone[1].area() + zone[2].area()}
    """;
| """
| Description  Width  Height  Area
| Alfa  17.8  31.4     558.92
| Bravo  9.6  12.4     119.03999999999999
| Charlie  7.1  11.23     79.733
| Total 757.693
| """

Процессор шаблонов FMT

FMT — ещё один процессор шаблонов, определённый в платформе Java. 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
| """

Обеспечение безопасности

Шаблонное выражение STR."..." — это сокращённая запись вызова метода process процессора шаблонов STR. То есть уже знакомый пример:

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 — стандартный процессор шаблонов, который создаёт необработанный объект StringTemplate.

Шаблонные выражения намеренно устроены так, что нельзя напрямую перейти от строкового литерала или текстового блока со встроенными выражениями к String с интерполированными значениями выражений. Так опасно некорректные строки не распространяются по программе. Строковый литерал обрабатывается процессором шаблонов, который явно отвечает за безопасную интерполяцию и проверку результата, будь то String или что-то иное. Поэтому если мы забудем использовать процессор шаблонов, например STR, RAW или FMT, будет выдана ошибка компиляции:

String name = "Joan";
String info = "My name is \{name}";
| error: processor missing from template expression

Синтаксис и семантика

Четыре вида шаблонов в шаблонном выражении видны из его грамматики, которая начинается с TemplateExpression:

TemplateExpression:
  TemplateProcessor . TemplateArgument

TemplateProcessor:
  Expression

TemplateArgument:
  Template
  StringLiteral
  TextBlock

Template:
  StringTemplate
  TextBlockTemplate

StringTemplate:
  Resembles a StringLiteral but has one or more embedded expressions

TextBlockTemplate:
  Resembles a TextBlock but has one or more embedded expressions

Компилятор Java просматривает терм "..." и по наличию встроенных выражений определяет, разбирать ли его как StringLiteral или как StringTemplate. Аналогично компилятор просматривает терм """...""" и определяет, разбирать ли его как TextBlock или как TextBlockTemplate. Часть ... этих термов мы единообразно называем содержимым строкового литерала, строкового шаблона, текстового блока или шаблона текстового блока.

Мы настоятельно рекомендуем IDE визуально отличать строковый шаблон от строкового литерала, а шаблон текстового блока — от текстового блока. В содержимом строкового шаблона или шаблона текстового блока IDE следует визуально отличать встроенное выражение от литерального текста.

Язык программирования Java различает строковые литералы и строковые шаблоны, а также текстовые блоки и шаблоны текстовых блоков прежде всего потому, что тип строкового шаблона или шаблона текстового блока — не привычный java.lang.String. Тип строкового шаблона или шаблона текстового блока — java.lang.StringTemplate. Это интерфейс, и String не реализует StringTemplate. Поэтому, когда шаблон шаблонного выражения является строковым литералом или текстовым блоком, компилятор Java автоматически преобразует String, обозначенный шаблоном, в StringTemplate без встроенных выражений.

В TemplateExpression тип TemplateProcessor должен быть подтипом StringTemplate.Processor. Тип шаблонного выражения — это тип возвращаемого значения метода process(StringTemplate) в типе TemplateProcessor. Если этот метод выбрасывает проверяемые исключения, то шаблонное выражение должно быть заключено в блок try-catch либо объемлющий метод должен объявлять, что выбрасывает эти исключения; подробнее см. ниже.

Во время выполнения шаблонное выражение вычисляется следующим образом:

  1. Вычисляется выражение TemplateProcessor, и получается экземпляр интерфейса StringTemplate.Processor, то есть процессор шаблонов.

  2. Вычисляется выражение TemplateArgument, и получается экземпляр StringTemplate.

  3. Экземпляр StringTemplate передаётся методу process экземпляра StringTemplate.Processor, который формирует результат.

Процессоры шаблонов выполняются во время выполнения, а не во время компиляции, поэтому они не могут обрабатывать шаблоны на этапе компиляции. Также они не могут получить точные символы, которые стоят в шаблоне в исходном коде: доступны только значения встроенных выражений, а не сами встроенные выражения.

Строковые литералы внутри шаблонных выражений

Возможность использовать строковый литерал или текстовый блок в качестве аргумента-шаблона делает шаблонные выражения гибче. Разработчики могут писать шаблонные выражения, в которых сначала стоит текст-заглушка в строковом литерале, например

String s = STR."Welcome to your account";
| "Welcome to your account"

и постепенно встраивать выражения в текст, превращая его в строковый шаблон, не меняя никаких разделителей и не добавляя никаких специальных префиксов:

String s = STR."Welcome, \{user.firstName()}, to your account \{user.accountNumber()}";
| "Welcome, Lisa, to your account 12345"

Пользовательские процессоры шаблонов

Ранее мы видели процессоры шаблонов STR и FMT, из-за которых кажется, будто процессор шаблонов — это объект, доступный через поле. Это удобное сокращение, но, опять же, точнее сказать, что процессор шаблонов — это объект, являющийся экземпляром функционального интерфейса StringTemplate.Processor. В частности, класс объекта реализует единственный абстрактный метод этого интерфейса, process, который принимает StringTemplate и возвращает объект. Статическое поле, такое как STR, лишь хранит экземпляр такого класса. (Фактический класс, экземпляр которого хранится в STR, имеет метод process, выполняющий интерполяцию без состояния, для которой подходит единственный экземпляр-синглтон, — отсюда имя поля в верхнем регистре.)

Разработчики могут легко создавать процессоры шаблонов для использования в шаблонных выражениях. Однако прежде чем обсуждать, как создать процессор шаблонов, мы должны рассмотреть класс StringTemplate.

Экземпляр StringTemplate представляет строковый шаблон или шаблон текстового блока, который выступает шаблоном в шаблонном выражении. Рассмотрим такой код:

int x = 10, y = 20;
StringTemplate st = RAW."\{x} plus \{y} equals \{x + y}";
String s = st.toString();
| StringTemplate{ fragments = [ "", " plus ", " equals ", "" ], values = [10, 20, 30] }

Результат, возможно, окажется неожиданным. Где же интерполяция 10, 20 и 30 в текст " plus " и " equals "? Напомним, что одна из целей шаблонных выражений — обеспечить безопасное составление строк. Если бы StringTemplate::toString просто конкатенировал "10", " plus ", "20", " equals " и "30" в String, эта цель была бы обойдена. Вместо этого метод toString выводит две полезные части StringTemplate:

  • Текстовые фрагменты, "", " plus ", " equals ", "", и
  • Значения, 10, 20, 30.

Класс StringTemplate предоставляет эти части напрямую:

  • StringTemplate::fragments возвращает список текстовых фрагментов, идущих до и после встроенных выражений в строковом шаблоне или шаблоне текстового блока:

    int x = 10, y = 20;
    StringTemplate st = RAW."\{x} plus \{y} equals \{x + y}";
    List<String> fragments = st.fragments();
    String result = String.join("\\{}", fragments);
    | "\{} plus \{} equals \{}"
  • StringTemplate::values возвращает список значений, полученных при вычислении встроенных выражений, в том порядке, в котором они встречаются в исходном коде. В текущем примере это эквивалентно List.of(x, y, x + y).

    int x = 10, y = 20;
    StringTemplate st = RAW."\{x} plus \{y} equals \{x + y}";
    List<Object> values = st.values();
    | [10, 20, 30]

fragments() экземпляра StringTemplate неизменны во всех вычислениях шаблонного выражения, а values() вычисляется заново при каждом вычислении. Например:

int y = 20;
for (int x = 0; x < 3; x++) {
    StringTemplate st = RAW."\{x} plus \{y} equals \{x + y}";
    System.out.println(st);
}
| ["Adding ", " and ", " yields ", ""](0, 20, 20)
| ["Adding ", " and ", " yields ", ""](1, 20, 21)
| ["Adding ", " and ", " yields ", ""](2, 20, 22)

С помощью fragments() и values() мы можем легко создать интерполирующий процессор шаблонов, передав лямбда-выражение статическому фабричному методу StringTemplate.Processor::of:

var INTER = StringTemplate.Processor.of((StringTemplate st) -> {
    String placeHolder = "•";
    String stencil = String.join(placeHolder, st.fragments());
    for (Object value : st.values()) {
        String v = String.valueOf(value);
        stencil = stencil.replaceFirst(placeHolder, v);
    }
    return stencil;
});

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

Мы можем сделать этот интерполирующий процессор шаблонов эффективнее, собирая его результат из фрагментов и значений и пользуясь тем, что каждый шаблон представляет собой чередующуюся последовательность фрагментов и значений:

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();
});

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

Служебный метод StringTemplate::interpolate делает то же самое, последовательно конкатенируя фрагменты и значения:

var INTER = StringTemplate.Processor.of(StringTemplate::interpolate);

Поскольку значения встроенных выражений обычно непредсказуемы, процессору шаблонов, как правило, нет смысла интернировать создаваемый им String. Например, STR не интернирует свой результат. Однако при необходимости несложно создать интернирующий и интерполирующий процессор шаблонов:

var INTERN = StringTemplate.Processor.of(st -> st.interpolate().intern());

API процессоров шаблонов

Во всех примерах до сих пор процессоры шаблонов создавались с помощью фабричного метода StringTemplate.Processor::of. Эти процессоры из примеров возвращают экземпляры String и не выбрасывают исключений, поэтому шаблонные выражения, которые их используют, всегда будут вычисляться успешно.

Напротив, процессор шаблонов, который напрямую реализует интерфейс StringTemplate.Processor, может быть полностью универсальным. Он может возвращать объекты любого типа, а не только String. Он также может выбрасывать проверяемые исключения, если обработка завершается неудачей — либо потому, что шаблон недопустим, либо по другой причине, например из-за ошибки ввода-вывода. Если процессор шаблонов выбрасывает проверяемые исключения, то разработчики, использующие его в шаблонных выражениях, должны обрабатывать сбои обработки с помощью операторов try-catch или же передавать исключения вызывающему коду.

Объявление интерфейса StringTemplate.Processor:

package java.lang;
public interface StringTemplate {
    ...
    @FunctionalInterface
    public interface Processor<R, E extends Throwable> {
        R process(StringTemplate st) throws E;
    }
    ...
}

Показанный ранее код, интерполирующий строки:

var INTER = StringTemplate.Processor.of(StringTemplate::interpolate);
...
String s = INTER."\{x} plus \{y} equals \{x + y}";

эквивалентен следующему:

StringTemplate.Processor<String, RuntimeException> INTER =
    StringTemplate.Processor.of(StringTemplate::interpolate);
...
String s = INTER."\{x} plus \{y} equals \{x + y}";

Тип шаблонного выражения INTER."..." задаётся первым аргументом типа у типа INTER, а именно String. Проверяемые исключения, выбрасываемые процессором шаблонов INTER, задаются вторым аргументом типа у типа INTER. INTER не выбрасывает проверяемых исключений, но, поскольку второй аргумент типа обязателен, мы должны выразить этот факт, указав непроверяемое исключение (RuntimeException).

Вот процессор шаблонов, который возвращает не строки, а экземпляры JSONObject:

var JSON = StringTemplate.Processor.of(
        (StringTemplate st) -> new JSONObject(st.interpolate())
    );

String name    = "Joan Smith";
String phone   = "555-123-4567";
String address = "1 Maple Drive, Anytown";
JSONObject doc = JSON."""
    {
        "name":    "\{name}",
        "phone":   "\{phone}",
        "address": "\{address}"
    };
    """;

Приведённое выше объявление JSON эквивалентно следующему:

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

Сравните первый аргумент типа, JSONObject, с первым аргументом типа INTER выше, String.

Пользователи этого гипотетического процессора JSON никогда не видят String, создаваемый st.interpolate(). Однако такое использование st.interpolate() грозит перенести уязвимости к внедрению в JSON-результат. Мы можем проявить осторожность и изменить код так, чтобы сначала проверять значения шаблона и выбрасывать проверяемое исключение JSONException, если значение подозрительно:

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);
    };

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::of. Вместо этого мы напрямую используем лямбда-выражение в правой части. Это, в свою очередь, означает, что в левой части нельзя использовать var, так как язык требует явного целевого типа для лямбда-выражения.

Чтобы сделать этот процессор шаблонов эффективнее, мы могли бы применить к нему мемоизацию: компилировать фрагменты шаблона в JSONObject со значениями-заглушками и кэшировать результат. Если следующий вызов процессора использует те же фрагменты, он может внедрить значения встроенных выражений в новую глубокую копию кэшированного объекта; промежуточного String не было бы нигде.

Безопасное составление и выполнение запросов к базе данных

Приведённый ниже класс процессора шаблонов QueryBuilder сначала создаёт строку SQL-запроса из строкового шаблона. Затем он создаёт JDBC 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 для конкретного Connection

var DB = new QueryBuilder(conn);

то вместо небезопасного кода, уязвимого для атак внедрения,

String query = "SELECT * FROM Person p WHERE p.last_name = '" + name + "'";
ResultSet rs = conn.createStatement().executeQuery(query);

мы можем написать более безопасный и более читаемый код

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

Может показаться удобным, чтобы процессор шаблонов сам выполнял запрос и возвращал ResultSet; тогда можно было бы написать просто

ResultSet rs = DB."SELECT ...";

Однако процессору шаблонов неразумно запускать потенциально долгие действия ради формирования результата. Также неразумно, чтобы процессор выполнял действия, которые могут иметь побочные эффекты, например обновлял базу данных. Авторам процессоров шаблонов настоятельно рекомендуется сосредоточиться на проверке входных данных и на формировании результата, который даёт вызывающему коду максимальную гибкость.

Упрощение локализации

Процессор шаблонов FMT, показанный ранее, — это экземпляр класса процессора шаблонов java.util.FormatProcessor. Хотя FMT использует локаль по умолчанию, процессор шаблонов для другой локали несложно создать, создав экземпляр класса по-другому. Например, этот код создаёт процессор шаблонов для тайской локали:

Locale thaiLocale = Locale.forLanguageTag("th-TH-u-nu-thai");
FormatProcessor THAI = new FormatProcessor(thaiLocale);
for (int i = 1; i <= 10000; i *= 10) {
    String s = THAI."This answer is %5d\{i}";
    System.out.println(s);
}
| This answer is     ๑
| This answer is    ๑๐
| This answer is   ๑๐๐
| This answer is  ๑๐๐๐
| This answer is ๑๐๐๐๐

Упрощение использования пакетов ресурсов

Приведённый ниже класс процессора шаблонов LocalizationProcessor упрощает работу с пакетами ресурсов. Для заданной локали он сопоставляет строке соответствующее свойство в пакете ресурсов.

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_en_CA.properties file
no.suitable._.found.for._(_)=\
    no suitable {0} found for {1}({2})

# resources_zh_CN.properties file
no.suitable._.found.for._(_)=\
    \u5BF9\u4E8E{1}({2}), \u627E\u4E0D\u5230\u5408\u9002\u7684{0}

# resources_jp.properties file
no.suitable._.found.for._(_)=\
    {1}\u306B\u9069\u5207\u306A{0}\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093({2})

то программа может составить локализованную строку на основе свойства:

var userLocale = Locale.of("en", "CA");
var LOCALIZE = new LocalizationProcessor(userLocale);
...
var symbolKind = "field", name = "tax", type = "double";
System.out.println(LOCALIZE."no suitable \{symbolKind} found for \{name}(\{type})");

и процессор шаблонов сопоставит строке соответствующее свойство в пакете ресурсов для нужной локали:

no suitable field found for tax(double)

Если бы программа вместо этого выполнила

var userLocale = Locale.of("zh", "CN");

то вывод был бы таким:

对于tax(double), 找不到合适的field

Наконец, если бы программа вместо этого выполнила

var userLocale = Locale.of("ja");

то вывод был бы таким:

taxに適切なfieldが見つかりません(double)

Альтернативы

  • Альтернативный вариант конструкции позволял бы использовать строковые шаблоны без процессоров шаблонов и выполнял бы базовую интерполяцию строк. Например:

    String s = "\{x} + \{y} = \{x + y}";

    Однако такой вариант нарушил бы цель безопасности. Возникал бы соблазн, например, строить SQL-запросы с помощью интерполяции, и в совокупности это снизило бы безопасность программ на Java. Обязательный процессор шаблонов гарантирует, что разработчик по крайней мере осознаёт, что в строковом шаблоне могут действовать предметно-ориентированные правила.

  • Визуальная заметность процессора шаблонов, когда STR стоит перед строковым шаблоном, не является строго необходимой. Вместо этого мы могли бы передавать процессор как аргумент методу StringTemplate::process. Например:

    String s = "The answer is %5d\{i}".process(FMT);

    Однако предпочтительнее, чтобы процессор шаблонов стоял первым, поскольку результат вычисления шаблонного выражения полностью зависит от работы процессора шаблонов.

  • Для встроенных выражений в строковых шаблонах мы рассматривали вариант заимствовать синтаксис ${...} из Expression Language (EL) в Java EE вместо \{...}. Однако тогда разработчикам пришлось бы экранировать каждый знак $ в литеральном тексте строкового шаблона, продолжая при этом писать $ без экранирования в строковых литералах и в Text Blocks. Такая непоследовательность слишком легко могла бы приводить к ошибкам.

    Кроме того, код, который использует EL в строковых литералах и Text Blocks, было бы особенно трудно перевести на строковые шаблоны: смысл ${...} изменился бы, поскольку теперь его определял бы язык Java, а не фреймворк. Например, строковый шаблон "Hello ${user.name}" ссылался бы не на объект user в контексте интерпретатора EL, а на переменную user, находящуюся в области видимости в исходном коде. Другой пример: строковый шаблон, содержащий встроенное выражение ${header['user-agent']} или встроенное выражение ${empty param.Customer}, не компилировался бы, поскольку header['user-agent'] и empty param.Customer не являются допустимыми выражениями Java. Необходимость экранировать знак $ в большинстве выражений EL, чтобы язык Java не считал их своими встроенными выражениями, была бы обременительной.

    При выбранном нами синтаксисе фреймворки могут упростить миграцию, предоставив процессор шаблонов, который понимает синтаксис EL. Разработчики могут продолжать использовать ${...} и при этом иметь доступ к окружению Java через \{...}:

    EL."""
      Hello ${user.name}
      \{server.isBusy() ? "Please try later" : ""}
    """
  • Мы также рассматривали другие ограничители, например \[...] или \(...), но [ ] и ( ) вполне могут встречаться во встроенных выражениях. { } встречается реже, поэтому начало и конец встроенного выражения проще определить на глаз.

  • Можно было бы встроить спецификаторы формата прямо в строковые шаблоны, как это сделано в C#:

    var date = DateTime.Now;
    Console.WriteLine($"The time is {date:HH:mm}");

    Однако тогда при появлении каждого нового спецификатора формата требовалось бы вносить изменения в Java Language Specification (спецификацию языка Java).