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

JEP 465: String Templates (Third Preview)

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

АвторJim Laskey
ОтветственныйGavin Bierman
ТипFeature
ОбластьSE
СтатусClosed / Withdrawn
Компонентspecification / language
Обсуждениеamber dash dev at openjdk dot org
ТрудоёмкостьM
ДлительностьM
Связан сJEP 459: String Templates (Second Preview)
РецензентыMark Reinhold
Создан2024/01/09 16:54
Обновлён2024/06/21 08:56
Задача8323333

Аннотация

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

История

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

Цели

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

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

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

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

  • Упростить использование API, которые принимают строки на языках, отличных от Java (например, SQL, XML и JSON).

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

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

  • Добавление в язык Java интерполяции строк не является целью. String Templates — другая, более мощная возможность.

    Многие разработчики хотели бы иметь интерполяцию строк, и их удивит синтаксис строковых шаблонов, особенно выбор \{...}. Этот выбор обусловлен приверженностью платформы Java совместимости. Альтернативы, такие как ${...}, уже встречаются во многих строках Java. Если выбрать их для строковых шаблонов, смысл ${...} изменится при переводе таких строк на строковые шаблоны, а это неприемлемо.

  • Объявление устаревшими или удаление классов 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);

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

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

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, скрипты командной оболочки и текст на естественном языке, должны проверяться и очищаться по правилам предметной области. Поскольку язык программирования 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}";

Если между последовательностями символов \{ и } ничего нет, встроенным выражением неявно считается литерал null:

String tmp = STR."Is \{} a billion dollar mistake?";
System.out.println(tmp);

Этот код выводит в консоль строку Is null a billion dollar mistake?.

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

Шаблон шаблонного выражения может занимать несколько строк исходного кода; синтаксис при этом похож на синтаксис 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.

Шаблонные выражения намеренно спроектированы так, что напрямую перейти от строкового литерала или литерала Text Blocks со встроенными выражениями к 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. Часть ... этих элементов мы единообразно называем содержимым строкового литерала, строкового шаблона, литерала Text Blocks или шаблона Text Blocks.

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

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

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

Во время выполнения шаблонное выражение вычисляется так:

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

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

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

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

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

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

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 представляет строковый шаблон или шаблон Text Blocks, который выступает шаблоном в шаблонном выражении. Рассмотрим этот код:

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 возвращает список текстовых фрагментов, стоящих до и после встроенных выражений в строковом шаблоне или шаблоне Text Blocks:

    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.