Im Netz existieren zahlreiche Tutorials, wie man reguläre Ausdrücke schreibt. Leider konzentrieren sich wenige davon auf die Java-API. Dieser Artikel zeigt, wie man mit capturing groups aus praktischer Sicht umgeht und wie wir unseren bestehenden Code mit den Erweiterungen im JDK 20 erweitern können.
Wie wurden capturing groups vor JDK 20 verwendet?
Einzelne capturing group
Nehmen wir an, wir wollen IDs aus einem Text extrahieren. Der Einfachheit halber nehmen wir an, dass die IDs aus Ziffernfolgen bestehen und wir sie mit einer einfachen Regex abgleichen können: \d+
. Außerdem wird jeder ID in der Regel ihr Name in folgendem Format vorangestellt Id: {id}
, e.g. Id: 123
.
Der herkömmliche Ansatz für den Abgleich von Text in einem solchen Fall beinhaltet die Verwendung einer capturing group. Dazu muss das übereinstimmende pattern in Klammern eingeschlossen werden, wie (\d+)
: Die Regex könnte wie folgt aussehen:
(?i)\bId[: ]*?(\d+)
vorausgesetzt:
-
(?i)
- Groß- und Kleinschreibung wird nicht berücksichtigt -
\b
- Wortgrenze, d.h. es wird nach einem ganzen Wort gesucht, nicht nach einem Teil davon -
Id
- Übereinstimmung mit den Buchstaben “Id” -
[: ]*?
- Übereinstimmung mit Doppelpunkt oder Leerzeichen null oder mehr Mal, aber so wenig wie nötig (non-greedy) -
(\d+)
- Übereinstimmung und Erfassung einer oder mehrerer Ziffern
Werfen wir einen Blick auf ein vollständiges Beispiel:
@ParameterizedTest
@CsvSource(textBlock = """
Id: 123 | 123 | # happy path
Id: 1 | 1 | # shorter id
Id: . | | # missing id
Id: unknown | | # text instead of number
Id 123 | 123 | # space instead of ':'
ID: 123 | 123 | # upper case
Id:123 | 123 | #missing separating space
OtherId: 123 | |
""", delimiterString = "|")
void matchId(String input, String expectedId) {
var pattern = Pattern.compile("(?i)\\bId[: ]*?(\\d+)");
var matcher = pattern.matcher(input);
// using "java.util.regex.MatchResult.group(int)" to extract capture
var id = matcher.results().findFirst().map(m -> m.group(1)).orElse(null);
assertThat(id).isEqualTo(expectedId);
}
Dieser Ansatz scheint ein wenig wackelig zu sein. Was bedeutet die Gruppe 1
? Zählt (?i)
als eine Gruppe? (Es ist eigentlich das CASE_INSENSITIVE
-Flag). Warum beginnen die Gruppen nicht bei 0
? Was ist, wenn wir mehr Gruppen haben?
Eine Nebenbemerkung: Es gibt einen Trick, eine einzelne capturing group zu vermeiden, indem man ein Look-Behind verwendet:
var pattern = Pattern.compile("(?i)(?<=\\bId[: ]{0,10})\\d+");
var matcher = pattern.matcher(input);
var id = matcher.results().findFirst().map(MatchResult::group).orElse(null);
Ich habe {0,10}
anstelle von *
verwendet, weil das Look-Behind die Länge der potenziellen Übereinstimmung berechnet, um zurückzugehen und sie zu überprüfen. Diese Methode kann langsam sein und hat ihre Grenzen.
Mehrere capturing groups
Nehmen wir an, der Text enthält verschiedene Arten von IDs von verschiedenen Institutionen (Steuer-ID, Gerichts-ID, Statistik-ID). In diesem Fall müssen wir eine weitere Gruppe einführen, um die Art der ID zu erfassen. Best Practise ist es, zur besseren Lesbarkeit mehrere Gruppen zu benennen, etwa (?<groupName>.*)
. Schauen wir uns ein Beispiel an:
@Test
void matchDifferentTypesOfIds() {
String text = """
Some text. Tax Id: 123. Some text.
Some text, Court Id: 456, Stats Id: 789.
""";
var pattern = Pattern.compile("(?i)(?<type>\\w+ *Id)[: ]*?(?<id>\\d+)");
var matcher = pattern.matcher(text);
// using "java.util.regex.MatchResult.group(int)" to extract capture
List<IdEntry> idEntries = matcher.results()
.map(m -> new IdEntry(m.group(1), m.group(2)))
.toList();
assertThat(idEntries)
.extracting(IdEntry::type, IdEntry::id)
.containsExactly(tuple("Tax Id", "123"),
tuple("Court Id", "456"),
tuple("Stats Id", "789"));
}
Vor dem JDK 20 wurden named capturing groups leider nicht vollständig unterstützt. Wir konnten Gruppen nur über ihren Index referenzieren.
Neue Features des JDK 20
Seit dem JDK 11 hat es keine wesentlichen Änderungen an java.util.regex
gegeben. Auch JDK 20 weicht nicht wesentlich davon ab (obwohl es ein Feature-Release ist), wie man bei einem Vergleich der Versionen sehen kann. Neben der Erweiterung der named capturing groups gibt es nur noch eine kosmetische Änderung, die die Methoden java.util.regex.Matcher.hasMatch
und java.util.regex.MatchResult.hasMatch
einführt.
Erweiterte Unterstützung für named capturing groups
Issue: https://bugs.openjdk.org/browse/JDK-8292872.
Diese Funktion führt die folgenden neuen API-Methoden ein:
java.util.regex.MatchResult
+ String group(String name)
+ int start(String name)
+ int end(String name)
+ Map<String,Integer> namedGroups()
java.util.regex.Pattern
+ Map<String,Integer> namedGroups()
Gehen wir noch einmal auf das vorherige Beispiel ein. Im JDK 20 können wir es wie folgt schreiben:
// using "java.util.regex.MatchResult.group(String)" to extract capture
var idEntries = matcher.results()
.map(m -> new IdEntry(m.group("type"), m.group("id")))
.toList();
Mit den Methoden start
und end
von MatchResult
können wir sogar die Position im Text von passenden Gruppen ermitteln:
assertThat(matcher.results())
.extracting(m -> m.group("type"),
m -> m.group("id"), m -> m.start("type"), m -> m.end("id"))
.containsExactly(tuple("Tax Id", "123", 11, 22),
tuple("Court Id", "456", 46, 59),
tuple("Stats Id", "789", 61, 74));
Außerdem ist jetzt eine neue Methode enthalten, die eine Karte Map<String,Integer>
der Gruppennamen und der entsprechenden Indizes ausgibt:
assertThat(pattern.namedGroups())
.isEqualTo(matcher.namedGroups());
assertThat(pattern.namedGroups())
.asString()
.isEqualTo("{id=2, type=1}");
Fazit
JDK 20 bietet endlich volle Unterstützung für named capturing groups und erlaubt nicht nur deren Definition, sondern auch Operationen mit ihnen. Jetzt können Entwickler Gruppen über ihren Namen referenzieren, anstatt über ihren numerischen Index. Das verbessert die Lesbarkeit, da der Code intuitiver wird. Auch die Wartbarkeit wird verbessert, da der Code weniger fehleranfällig ist. Ein großes Dankeschön an die Java-Gemeinschaft und die Entwickler, die diese nützliche Verbesserung umgesetzt haben!