Перейти к основному содержимому

Как использовать регулярные выражения для замены токенов в строках в Java

· 9 мин. чтения

1. Обзор

Когда нам нужно найти или заменить значения в строке в Java, мы обычно используем регулярные выражения . Они позволяют нам определить, соответствует ли часть или вся строка шаблону. Мы можем легко применить одну и ту же замену к нескольким токенам в строке с помощью метода replaceAll как в Matcher , так и в String .

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

Мы также рассмотрим несколько приемов настройки наших регулярных выражений для правильной идентификации токенов.

2. Индивидуальная обработка совпадений

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

2.1. Пример регистра заголовка

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

Наш вклад может быть:

"First 3 Capital Words! then 10 TLAs, I Found"

Из определения заглавного слова оно содержит совпадения:

  • Первый
  • Столица
  • Слова
  • я
  • Найденный

И регулярным выражением для распознавания этого шаблона будет:

"(?<=^|[^A-Za-z])([A-Z][a-z]*)(?=[^A-Za-z]|$)"

Чтобы понять это, давайте разобьем его на составные части. Начнем с середины:

[A-Z]

распознает одну заглавную букву.

Мы разрешаем односимвольные слова или слова, за которыми следуют строчные буквы, поэтому:

[a-z]*

распознает ноль или более строчных букв.

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

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

Выражение [^A-Za-z] означает «без букв». Мы поместили один из них в начало выражения в группе без захвата:

(?<=^|[^A-Za-z])

Незахватывающая группа, начинающаяся с (?<=, выполняет проверку назад, чтобы убедиться, что совпадение отображается на правильной границе. Ее аналог в конце выполняет ту же работу для следующих за ней символов.

Однако, если слова касаются самого начала или конца строки, нам нужно учитывать это, и именно здесь мы добавили ^| в первую группу, чтобы это означало «начало строки или любые небуквенные символы», и мы добавили |$ в конец последней незахватывающей группы, чтобы конец строки мог быть границей .

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

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

2.2. Тестирование нашего примера

С текстом нашего примера в константе с именем EXAMPLE_INPUT и нашим регулярным выражением в шаблоне с именем TITLE_CASE_PATTERN давайте используем find в классе Matcher для извлечения всех наших совпадений в модульном тесте: ``

Matcher matcher = TITLE_CASE_PATTERN.matcher(EXAMPLE_INPUT);
List<String> matches = new ArrayList<>();
while (matcher.find()) {
matches.add(matcher.group(1));
}

assertThat(matches)
.containsExactly("First", "Capital", "Words", "I", "Found");

Здесь мы используем функцию matcher в Pattern для создания Matcher . Затем мы используем метод find в цикле, пока он не перестанет возвращать true , чтобы перебрать все совпадения.

Каждый раз , когда find возвращает значение true , состояние объекта Matcher задается для представления текущего совпадения. Мы можем проверить все совпадения с помощью group(0) или проверить отдельные группы захвата с их индексом, основанным на 1 . В этом случае есть группа захвата вокруг нужного фрагмента, поэтому мы используем group(1) , чтобы добавить совпадение в наш список.

2.3. Немного больше о проверке Matcher

Нам пока удалось найти слова, которые мы хотим обработать.

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

while (matcher.find()) {
System.out.println("Match: " + matcher.group(0));
System.out.println("Start: " + matcher.start());
System.out.println("End: " + matcher.end());
}

Этот код покажет нам, где находится каждое совпадение. Он также показывает нам совпадение group(0) , в котором захвачено все:

Match: First
Start: 0
End: 5
Match: Capital
Start: 8
End: 15
Match: Words
Start: 16
End: 21
Match: I
Start: 37
End: 38
... more

Здесь мы видим, что каждое совпадение содержит только ожидаемые слова. Свойство start показывает отсчитываемый от нуля индекс совпадения в строке. Конец показывает индекс символа сразу после. Это означает, что мы могли бы использовать substring(start, end-start) для извлечения каждого совпадения из исходной строки. По сути, именно так групповой метод делает это для нас.

Теперь, когда мы можем использовать find для перебора совпадений, давайте обработаем наши токены.

3. Замена спичек по одной

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

"first 3 capital words! then 10 TLAs, i found"

Класс Pattern и Matcher не может сделать это за нас, поэтому нам нужно построить алгоритм.

3.1. Алгоритм замены

Вот псевдокод алгоритма:

  • Начните с пустой выходной строки

  • Для каждого матча:

  • Добавьте в вывод все, что было до совпадения и после любого предыдущего совпадения

  • Обработайте это совпадение и добавьте его к выводу

  • Продолжайте, пока не будут обработаны все совпадения

  • Добавить все, что осталось после последнего совпадения, к выводу

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

3.2. Заменитель токенов в Java

Мы хотим преобразовать каждое слово в нижний регистр, поэтому мы можем написать простой метод преобразования:

private static String convert(String token) {
return token.toLowerCase();
}

Теперь мы можем написать алгоритм для перебора совпадений. Это может использовать StringBuilder для вывода:

int lastIndex = 0;
StringBuilder output = new StringBuilder();
Matcher matcher = TITLE_CASE_PATTERN.matcher(original);
while (matcher.find()) {
output.append(original, lastIndex, matcher.start())
.append(convert(matcher.group(1)));

lastIndex = matcher.end();
}
if (lastIndex < original.length()) {
output.append(original, lastIndex, original.length());
}
return output.toString();

Следует отметить, что StringBuilder предоставляет удобную версию append , которая может извлекать подстроки . Это хорошо работает со свойством end Matcher , позволяющим нам подобрать все несопоставленные символы с момента последнего совпадения.

4. Обобщение алгоритма

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

4.1. Используйте ввод функции и шаблона

Мы можем использовать объект Java Function<Matcher, String>, чтобы позволить вызывающей стороне предоставить логику для обработки каждого совпадения. И мы можем взять ввод с именем tokenPattern , чтобы найти все токены:

// same as before
while (matcher.find()) {
output.append(original, lastIndex, matcher.start())
.append(converter.apply(matcher));

// same as before

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

4.2. Тестирование общей версии

Давайте посмотрим, работает ли общий метод так же хорошо, как оригинал:

assertThat(replaceTokens("First 3 Capital Words! then 10 TLAs, I Found",
TITLE_CASE_PATTERN,
match -> match.group(1).toLowerCase()))
.isEqualTo("first 3 capital words! then 10 TLAs, i found");

Здесь мы видим, что вызов кода прост. Функцию преобразования легко представить в виде лямбда. И тест проходит.

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

5. Некоторые варианты использования

5.1. Экранирование специальных символов

Давайте представим, что мы хотим использовать escape-символ регулярного выражения \ для ручного кавычек каждого символа регулярного выражения, а не использовать метод цитаты . Возможно, мы заключаем строку в кавычки как часть создания регулярного выражения для передачи в другую библиотеку или службу, поэтому блочного цитирования выражения будет недостаточно.

Если мы можем выразить шаблон, который означает «символ регулярного выражения», наш алгоритм легко экранирует их все:

Pattern regexCharacters = Pattern.compile("[<(\\[{\\\\^\\-=$!|\\]})?*+.>]");

assertThat(replaceTokens("A regex character like [",
regexCharacters,
match -> "\\" + match.group()))
.isEqualTo("A regex character like \\[");

Для каждого совпадения мы ставим префикс \ . Поскольку \ является специальным символом в строках Java, он экранируется другим символом \ .

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

5.2. Замена заполнителей

Распространенным способом выражения заполнителя является использование синтаксиса наподобие ${name} . Давайте рассмотрим вариант использования, когда шаблон «Привет, ${name} в ${company}» необходимо заполнить из карты с именем placeholderValues :

Map<String, String> placeholderValues = new HashMap<>();
placeholderValues.put("name", "Bill");
placeholderValues.put("company", "ForEach");

Все, что нам нужно, — это хорошее регулярное выражение для поиска токенов ${…} :

"\\$\\{(?<placeholder>[A-Za-z0-9-_]+)}"

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

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

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

assertThat(replaceTokens("Hi ${name} at ${company}",
"\\$\\{(?<placeholder>[A-Za-z0-9-_]+)}",
match -> placeholderValues.get(match.group("placeholder"))))
.isEqualTo("Hi Bill at ForEach");

Здесь мы видим, что получение значения именованной группы из Matcher просто включает использование группы с именем в качестве входных данных, а не числом.

6. Заключение

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

Затем мы создали и обобщили алгоритм, позволяющий выполнять замену токена за токеном.

Наконец, мы рассмотрели пару распространенных вариантов использования экранирования символов и заполнения шаблонов.

Как всегда, примеры кода можно найти на GitHub .