Регулярные выражения на практике: якоря, квантификаторы и группы захвата
Резюме (TL;DR)
2 июля 2019 года одно-единственное регулярное выражение — вроде .*(?:.*=.*) в новом правиле WAF Cloudflare, то самое «почти совпадает и падает» — уронило глобальный трафик почти вдвое на 27 минут. Произошёл бэктрекинговый взрыв: одно ядро уперлось в 100 %, и остальные запросы встали. Главное, что этот инцидент честно говорит о регулярках: регулярные выражения отлично прячут собственную цену. Обычно они маленькие, быстрые и милые, но «одна неверная строка на входе» может остановить систему. Поэтому тон этой статьи скептический. В реальной работе регулярки строятся из нескольких кирпичей: якорей, фиксирующих начало/конец/границу слова; классов символов, описывающих «один из этих символов»; квантификаторов, задающих количество повторений; и групп, в которых фиксируются захваты, ссылки и варианты. Хорошо написанные, они решают частые задачи коротким и читаемым шаблоном: приблизительная проверка email, извлечение полей из логов, нормализация телефонных номеров. Плохо написанные — матчат слишком много, слишком мало или, как выше, останавливают движок. Не парсьте регулярками HTML, JSON, XML. Регулярный язык не умеет описывать сбалансированную вложенность. Движки тоже разные: ECMAScript, PCRE (Perl/PHP), Python re, Go RE2 по-разному обрабатывают lookaround, обратные ссылки и юникод, и инцидент Cloudflare случился именно из-за модели бэктрекинга PCRE (в Go RE2 его бы не произошло).
Предыстория и концепции
Регулярное выражение — небольшой язык для поиска строк. Разложу привычные строительные блоки.
Якоря не потребляют символов, а утверждают позицию. ^ — начало входа (в multiline — начало строки), $ — конец, \b — граница слова. Без якорей abc совпадёт где угодно внутри длинной строки; ^abc$ — только когда весь вход равен abc, а \babc\b — когда abc стоит как отдельное слово.
Классы символов описывают, какому множеству принадлежит один символ. [abc] — один из a, b, c; [a-z] — строчная буква; [^0-9] — «всё, кроме цифр». Частые множества имеют короткие формы: \d (цифра), \w (символ слова — буквы, цифры, подчёркивание), \s (пробел). Прописные \D, \W, \S — дополнения. Точка . по умолчанию совпадает с любым символом кроме перевода строки; флаг s (dotall) меняет это поведение.
Квантификаторы применяются к предыдущему токену и задают повторения. * — 0 и больше, + — 1 и больше, ? — 0 или 1, {n,m} — от n до m раз (границы можно опускать). По умолчанию квантификатор жадный (greedy): он берёт максимум и уступает по одному символу только тогда, когда правее ничего не совпадает. Добавление ? делает его ленивым (lazy) — *?, +?, ?? — он берёт минимум и расширяется только под давлением. Некоторые движки поддерживают захватнические (possessive) квантификаторы — *+, ++ — жадные и без уступок. Они помогают против бэктрекингового взрыва на патологических входах; ровно этого и не хватило в инциденте Cloudflare.
Группы оборачивают подшаблон. (pattern) — группа захвата, номера идут с 1 по порядку открывающих скобок; внутри шаблона ссылка через \1, из host-языка — через индекс. (?<name>pattern) — именованный захват. (?:pattern) — не-захватывающая группа для выбора ((?:cat|dog)) или объединения под квантификатор без сохранения. Внутри группы | — альтернатива.
Наконец, флаги настраивают движок. i — без учёта регистра, m — multiline (^ и $ совпадают на границах строк), s — dotall, u — юникод. В JavaScript флаг u также включает escape-последовательности свойств юникода вроде \p{L} («любая буква»).
Сравнение и данные
| Квантификатор | Жадный (по умолчанию) | Ленивый (суффикс ?) | Захватнический (где поддерживается) |
|---|---|---|---|
* / *? / *+ | Максимум, уступает при провале | Минимум, расширяется под давлением | Максимум, без уступок |
+ / +? / ++ | 1+, жадный | 1+, ленивый | 1+, без уступок |
? / ?? / ?+ | 0–1, предпочитает 1 | 0–1, предпочитает 0 | 0–1, без уступок |
{n,m} / {n,m}? / {n,m}+ | Диапазон, жадный | Диапазон, ленивый | Диапазон, без уступок |
Жадность — дефолт, потому что чаще всего это то, что нужно. Ленивость оправдана, когда «паттерн справа» слишком общий. Чтобы извлечь пару <b>...</b> из HTML, нужен ленивый квантификатор <b>(.*?)</b>, иначе движок проглотит несколько тегов (и всё равно не используйте регулярки для настоящего HTML). Захватнические квантификаторы и атомарные группы (?>...) блокируют экспоненциальный откат на «почти совпадает и падает» паттернах. Их поддерживают PCRE, Java и Oniguruma в Ruby; в ECMAScript и Python re их нет (с Python 3.11 в re появились атомарные группы).
Практические сценарии
Сценарий 1 — прагматичная проверка email. Полная грамматика email по RFC 5322 допускает комментарии, quoted local-part и IP-литералы, поэтому регулярка, покрывающая её, знаменита размером (самый известный вариант — 6 425 символов) и всё равно не заменяет парсер. В боевом коде я почти всегда ограничивался чем-то вроде ^[^\s@]+@[^\s@]+\.[^\s@]+$ — «непустое, без пробелов, @ на месте, в домене есть хотя бы одна точка». Этого достаточно, чтобы отсечь явные опечатки, а существует ли почтовый ящик, узнать можно только письмом. Форма — регуляркой, существование — письмом.
Сценарий 2 — телефоны в разных форматах. +82 2-1234-5678, (02) 1234-5678, 82-2-1234-5678 — это один и тот же сеульский номер в разной записи. Паттерн вроде ^\+?\d{1,3}[-\s().]*\d{1,4}[-\s().]*\d{3,4}[-\s().]*\d{3,4}$ принимает обычную пунктуацию, а дальше отдельный шаг нормализации выкидывает её и оставляет канонический числовой вид. Для всего, где это действительно важно (маршрутизация, хранение, набор номера), берите Google libphonenumber. Самое быстрое совещание, которое я видел в одной команде, заканчивалось фразой «регулярками не делаем» — и за день отсекло недели технического долга. Роль регулярки здесь — лишь поверхностная проверка «похоже на телефонный номер».
Сценарий 3 — извлечение полей из строки лога. Строку вроде 2026-04-13T02:11:05Z 192.0.2.42 "GET /search?q=foo HTTP/1.1" 200 1534 можно разобрать одним паттерном: ^(?<ts>\S+)\s+(?<ip>\S+)\s+"(?<method>\w+)\s+(?<path>\S+)\s+\S+"\s+(?<status>\d+)\s+(?<bytes>\d+)$. Именованные группы здесь особенно ценны: матч становится «словарём», к полям обращаются по имени. А когда формат лога меняется, сам паттерн служит документацией этой версии формата.
Распространённые заблуждения
«Регуляркой можно идеально проверить email». Только форму. RFC 5322 разумно не уложить в регулярку, а если и уложить, «правильная форма» не гарантирует «ящик существует». Индустриальный стандарт — простая регулярка плюс подтверждение письмом.
«Жадный всегда медленнее ленивого». Неправда. Если подшаблон под квантификатором узкий, жадный матч за один длинный заход оказывается быстрее. Ленивый выигрывает, когда правая часть узкая и сама «заякоривает» конец, как в <b>(.*?)</b>. Бенчмаркайте на реальных данных, прежде чем рефлекторно добавлять ?.
«Все движки одинаковые». Нет. В ECMAScript нет захватнических квантификаторов и атомарных групп (флаг v немного улучшил ситуацию), у Python re свой набор юникод-свойств, PCRE поддерживает обратные ссылки и рекурсию. В Ruby и у крейта onig в Rust — Oniguruma, ещё одна вариация. Go RE2 отказывается от обратных ссылок и lookaround ради гарантии линейного времени от длины входа — именно на него Cloudflare после инцидента смотрели как на замену движка. Паттерн, скопированный из Perl-учебника, нередко не работает в JavaScript.
«Регуляркой можно парсить HTML (или JSON, или XML)». Регулярный язык не описывает сбалансированную вложенность. Извлечь конкретный хорошо определённый фрагмент — значение атрибута, например — регуляркой можно, а вот разобрать всё дерево корректно — нельзя. Для вложенных форматов берите настоящий парсер (DOMParser, JSON.parse, XML-библиотеку, CSV-ридер). Легендарный ответ на Stack Overflow про «regex vs HTML» (2009) — не дискуссия, а предупреждение.
Чек-лист
- Что на входе и какие контрпримеры? И то, и другое запишите до написания паттерна.
- Данные вложенные или рекурсивные? Используйте парсер. Регулярка здесь — не тот инструмент.
- Под какой движок пишете? JS, Python, Go, PCRE различаются в lookaround, обратных ссылках и юникоде.
- Нужен ли сам захват или достаточно бинарного «да/нет»? Группы, нужные только для квантификатора или альтернативы, делайте не-захватывающими
(?:...). - Паттерн применяется к недоверенному входу? Защитите себя атомарными группами, таймаутом или движком линейного времени (RE2), чтобы не повторить историю Cloudflare.
- Есть ли постобработка текста? Не пихайте всё в один паттерн: простой «поверхностный» матч плюс аккуратная пост-нормализация читается легче.
- Документирована ли регулярка? Комментарий в режиме
x(extended) или одна строка пояснения сверху экономит следующему человеку кучу времени.
Связанный инструмент
Тестер регулярных выражений Patrache Studio прямо на странице применяет паттерн к примерам и подсвечивает захваты, так что не приходится скакать между вкладками. Если матчите структурированные строки — например, JSON внутри лога, — в паре с JSON: форматирование, валидация и JSON Schema в реальной работе проверяйте извлечённые куски не второй регуляркой, а настоящим парсером. Частая мишень регулярок — UUID в URL: Сравнение UUID v1, v4 и v7 и дизайн первичного ключа в БД показывает, почему одна и та же 36-символьная строка может нести разный смысл в зависимости от битов версии.
Источники
- MDN, гид «Regular Expressions» — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions
- IETF RFC 5322, «Internet Message Format» (грамматика email) — https://datatracker.ietf.org/doc/html/rfc5322
- regex101, интерактивный тестер с выбором движка — https://regex101.com/
- Google, RE2 — регулярный движок линейного времени — https://github.com/google/re2/wiki/Syntax