JEP 430: String Templates (Preview)
Строковые шаблоны, версия Preview (предварительная версия)
| Ответственный | Jim Laskey |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 21 |
| Компонент | specification / language |
| Обсуждение | amber dash dev at openjdk dot org |
| Трудоёмкость | M |
| Длительность | M |
| Связан с | JEP 459: String Templates (Second Preview) |
| Рецензенты | Alex Buckley, Brian Goetz, Maurizio Cimadamore |
| Одобрен | Brian Goetz |
| Создан | 2021/09/17 13:41 |
| Обновлён | 2023/10/13 13:52 |
| Задача | 8273943 |
Аннотация
Расширить язык программирования Java строковыми шаблонами. Строковые шаблоны дополняют существующие в Java строковые литералы и Text Blocks (текстовые блоки). Они соединяют литеральный текст со встроенными выражениями и процессорами шаблонов, чтобы получать специализированные результаты. Это Preview-возможность языка и API.
Цели
-
Упростить написание программ на Java: сделать так, чтобы строки, которые содержат значения, вычисляемые во время выполнения, было легко выражать.
-
Сделать более читаемыми выражения, в которых смешаны текст и выражения, независимо от того, умещается ли текст в одну строку исходного кода (как в случае строковых литералов) или занимает несколько строк исходного кода (как в случае Text Blocks).
-
Повысить безопасность программ на Java, которые составляют строки из переданных пользователем значений и передают их другим системам (например, строят запросы к базам данных). Для этого нужно поддержать проверку и преобразование как самого шаблона, так и значений его встроенных выражений.
-
Сохранить гибкость: позволить библиотекам Java определять синтаксис форматирования, который используется в строковых шаблонах.
-
Упростить использование API, которые принимают строки на языках, отличных от Java (например, SQL, XML и JSON).
-
Дать возможность создавать нестроковые значения, вычисляемые из литерального текста и встроенных выражений, без промежуточного строкового представления.
Что не является целью
-
Целью не является введение синтаксического сахара для оператора конкатенации строк в Java (
+), поскольку это шло бы вразрез с целью обеспечить проверку. -
Целью не является объявление устаревшими или удаление классов
StringBuilderиStringBuffer, которые традиционно используются для сложного или программного составления строк.
Мотивация
Разработчики постоянно составляют строки из литерального текста и выражений. В Java есть несколько механизмов составления строк, но, к сожалению, у всех есть недостатки.
-
Конкатенация строк с помощью оператора
+даёт трудночитаемый код: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}"
Python f"{x} plus {y} equals {x + y}"
Scala s"$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}" состоит из следующих частей:
- процессор шаблонов (
STR); - символ точки (U+002E), как и в других видах выражений;
- шаблон (
"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,
and can be spread over multiple lines of source code
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 без встроенных выражений.
Во время выполнения шаблонное выражение вычисляется так:
-
Вычисляется выражение слева от точки, в результате получается экземпляр вложенного интерфейса
StringTemplate.Processor, то есть процессор шаблонов. -
Вычисляется выражение справа от точки, в результате получается экземпляр
StringTemplate. -
Экземпляр
StringTemplateпередаётся методуprocessэкземпляраStringTemplate.Processor, который составляет результат.
Тип шаблонного выражения — это тип возвращаемого значения метода 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 задаётся первым аргументом типа, String. Исключения, выбрасываемые процессором, задаются вторым аргументом типа, в данном случае это 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, потому что Java требует явного целевого типа для лямбда-выражения.
Чтобы сделать этот процессор эффективнее, можно было бы мемоизировать его, компилируя фрагменты шаблона в 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 упрощает работу с пакетами ресурсов (resource bundles). Для заданной локали он сопоставляет строке соответствующее свойство в пакете ресурсов.
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)
Альтернативы
-
Когда строковый шаблон записан без процессора шаблонов, можно было бы просто выполнять базовую подстановку. Однако такой выбор нарушил бы цель безопасности. Например, слишком велик был бы соблазн составлять SQL-запросы с помощью подстановки, и в совокупности это снизило бы безопасность программ на Java. Обязательный процессор шаблонов гарантирует, что разработчик хотя бы осознаёт возможность предметно-специфичных правил в строковом шаблоне.
-
Синтаксис шаблонных выражений, в котором процессор шаблонов стоит первым, не является строго необходимым. Процессор шаблонов можно было бы указывать как аргумент
StringTemplate::process. Например:String s = "The answer is %5d\{i}".process(FMT);Предпочтительнее, чтобы процессор шаблонов стоял первым, поскольку результат вычисления шаблонного выражения полностью зависит от работы процессора шаблонов.
-
Для синтаксиса встроенных выражений мы рассматривали вариант
${...}, но тогда строковым шаблонам понадобилась бы метка (префикс либо разделитель, отличный от"), чтобы избежать конфликтов с существующим кодом. Мы также рассматривали\[...]и\(...), но[ ]и( ), скорее всего, будут встречаться во встроенных выражениях;{ }встречается реже, поэтому визуально определять начало и конец встроенных выражений будет проще. -
Можно было бы встроить спецификаторы формата прямо в строковые шаблоны, как это сделано в C#:
var date = DateTime.Now; Console.WriteLine($"The time is {date:HH:mm}");но тогда при каждом появлении нового спецификатора формата пришлось бы вносить изменения в Java Language Specification.
Риски и допущения
Реализация java.util.FormatProcessor сильно зависит от java.util.Formatter, который, возможно, потребуется существенно переписать.