Ostatnio chciałem wyciągnąć z logów komunikaty wysyłane do zewnętrznego systemu i przeprowadzić kilka operacji LINQ to XML na pozyskanych danych. Oto przykładowa linia logu (uproszczona, rzeczywisty log był o wiele bardziej skomplikowany ale nie ma to znaczenia w tym poscie):
Call:<getName seqNo="56789"><id>123</id></getName> Result:<getName seqNo="56789">John Smith</getName>
Interesowały mnie takie dane XML ("call"):
<getName seqNo="56789">
<id>123</id>
</getName>
Przy okazji: najprostszym sposobem na pozyskanie ładnie sformatowanego XMLa w .NET 3.5 lub nowszym jest wywołanie metody ToString na obiekcie XElement:
var xml = System.Xml.Linq.XElement.Parse(someUglyXmlString);
Console.WriteLine(xml.ToString());
Jeśli chodzi o log, kilka rzeczy było pewnych:
- XML z callem znajduje się po tekście „Call:” z początku linii
- nazwa głównego elementu (roota) komunikatu będzie skądać się wyłącznie ze znaków alfanumerycznych lub podkreślenia
- w komunikacie nie będzie znaków podziału linii
- głowy element calla może wystąpić w logu także w sekcji „Result”
Otrzymanie prawidłowych danych było dosyć proste dzięki klasie Regex:
Regex regex = new Regex(@"(?<=^Call:)<(\w+).*?</\1>");
string call = regex.Match(logLine).Value;
To krótkie wyrażenie regularne ma kilka interesujących części. Nie jest może doskonałe ale okazało się bardzo przydatne podczas analizy logów. Jeśli powyższy regex nie jest dla Ciebie zupełnie jasny – czytaj dalej, prędzej czy później będziesz musiał użyć czegoś podobnego.
Poniżej jest to samo wyrażenie ale z komentarzami (ustawienie opcji RegexOptions.IgnorePatternWhitespace jest konieczne do przetworzenia wyrażenia zapisanego w ten sposób):
string pattern = @"(?<=^Call:) # Positive lookbehind dla znacznika wywołania
<(\w+) # Capturing group dla nazwy tagu otwierającego
.*? # Lazy wildcard (wszystko w środku)
</\1> # Backreference do nazwy tagu otwierającego";
Regex regex = new Regex(pattern, RegexOptions.IgnorePatternWhitespace);
string call = regex.Match(logLine).Value;
Positive lookbehind
(?<=Call:) to tzw. lookaround a dokładniej positive lookbehind. Jest to asercja zerowej szerokości, która pozwala na sprawdzanie czy tekst poprzedzony jest przez dany ciąg znaków. Tutaj „Call:” to tekst poprzedzający, którego szukamy. Zapis (?<=something) określa positive lookbehind. Istnieje również negative lookbehind zapisywany za pomocą (?<!something). Dzięki niemu można zweryfikować czy jakiś tekst nie posiada określonych znaków przed sobą. Lookaround sprawdza fragment tekstu ale nie stanowi on części dopasowanej wartości. Tak więc rezultatem tego:
Regex.Match("X123", @"(?<=X)\d*").Value
będzie „123” a nie „X123”
Silnik wyrażeń regularnych w .NET obsługuje także mechanizm lookaheads. Odwiedź tą świetną stronę jeśli chcesz dowiedzieć się więcej o lookarounds.
Uwaga: w niektórych przypadkach (np. w naszym badaniu logu) zamiast positive lookaround można użyć grup nieprzechwytujących...
Capturing group
<(\w+) dopasowuje znak mniejszości, po którym następuje jeden lub więcej znaków z klasy \w (litery, cyfry lub znaki podkreślenia). Fragment \w+ jest otoczony nawiasami w celu utworzenia grupy zawierającej nazwę korzenia XML (getName dla przykładowej linii logu). Grupa ta jest później użyta do znalezienia tagu zamykającego przy użyciu odwołania wstecznego. (\w+) to grupa przechwytujaca (capturing group), co oznacza, że rezultat istnienia tej grupy jest dodawany do kolekcji Groups obiektu Match. Jeśli chcesz umieścić część wyrażenia w grupie ale nie chcesz wstawiać rezultatu do kolekcji Groups możesz skorzystać z grupy nieprzechwytującej. Grupę taką tworzy się poprzez dodanie pytajnika i dwukropka przed nawiasem otwierającym: (?:something)
Lazy wildcard
.*? dopasowuje wszystkie znaki z wyjątkiem nowe linii (ponieważ nie używamy opcji RegexOptions.Singleline) w trybie leniwym (lazy lub non-gready) dzięki pytajnikowi umieszczonemu za gwiazdką. Domyślnie kwantyfikator * działa w trybie zachłannym (greedy) co oznacza, że silnik wyrażeń regularnych próbuje dopasować tak dużo tekstu jak to możliwe. W naszym przypadku, domyślny tryb spowodowałby zwrócenie zbyt długiego tekstu:
<getName seqNo="56789"><id>123</id></getName> Result:<getName seqNo="56789">John Smith</getName>
Backreference
</\1> dopasowuje zamykający tag XML, którego nazwa dostarczona jest przez \1 backreference. Wspomniana wcześniej grupa (\w+) ma numer 1, przez użycie składni \1 odwołujemy się do tekstu dopasowanego przez tą grupę. Więc dla naszego przykładowego logu </\1> zwraca </getName>. Jeśli wyrażenie regularne jest skomplikowane, dobrym pomysłem jest porzucenie numerowanych referencji na rzecz nazwanych. Grupę można nazwać poprzez składnię <name> lub <’name’> a odwołać się do niej można dzięki k<name> lub k’name’. Wyrażenie może więc wyglądać tak:
@"(?<=^Call:)<(?<tag>\w+).*?</\k<tag>>"
lub tak:
@"(?<=^Call:)<(?'tag'\w+).*?</\k'tag'>"
W naszym przypadku wersja druga jest lepsza. Użycie znaków < > w przypadku dopasowywania XML jest mylące. Silnik regex poradzi sobie z zapisem używającym < > ale pamiętaj, że kod źródłowy piszę się dla ludzi...
Wyrażenia regularne wyglądają strasznie ale warto poświęcić kilka godzin na ich przećwiczenie, regexy to niesamowicie przydatne narzędzie (nie tylko w czasie analizy logów)!