Обложка: Путешествие в golang regexp

Путешествие в golang regexp

Маргарита Гавриленко
Маргарита Гавриленко

Разработчик NGR Softlab

Приветствую всех, принадлежащих к клубу «Тыжпрограммист, почини утюг», а также просто интересующихся IT-миром!

В этой статье мы рассмотрим инструмент, с помощью которого можно прорываться через мусор в тексте.  А также фильтровать контент и названия файлов, отлавливать запрещённые/разрешенные команды, парсить SQL-запросы и выпендриваться перед коллегами. Это регулярные выражения 🙂

Все примеры описаны для языка Golang, однако общие принципы, синтаксис самих регулярных выражений применимы и к других языкам программирования.

В начале было слово…

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

_ = regexp.MustCompile(`+++`)

Запустив сей код легким мановением руки… получим то, что всей душой ненавидят пишущие на golang люди — панику. Дело в том, что MustCompile паникует вместо возврата ошибки, как это сделано, например, в методе Compile из того же пакета.

Поэтому MustCompile рекомендуется использовать только в тех случаях, когда:

  • вы на 100% уверены, что регулярное выражение валидно;
  • вы очень хотите упростить код с инициализацией каких-нибудь глобальных переменных.

В остальных случаях лучше подойдёт вариант с возвратом ошибки.

_, err := regexp.Compile(`+++`)
if err != nil { fmt.Println(err) }   //  error parsing regexp: missing 
argument to repetition operator: `+`

Простой пример проверки соответствия (который можно скопировать и поломать):

matched, _ := regexp.MatchString(`ayayay`, "o no") // пустой «приемник» ошибки, ведь мы уверены, что пример отработает нормально
fmt.Println(matched) // false

Общая информация

  • Немного общих сведений о регулярных выражениях в Golang (regexp пакет):
  • синтаксис RE2 (библиотека регулярок от Google);
  • кодировка UTF-8 и классы символов Unicode;
  • время выполнения линейно зависимо от размера ввода;
  • обратные ссылки не поддерживаются (не думайте об этом, просто положите на полочку в своих «чертогах разума», чуть позже будет объяснение);
  • для регулярных выражений лучше использовать (аккуратно, ведь там своя специфика) необработанные строки (raw strings, строки без интерпретации экранированных литералов).

А теперь приступим к более подробному разбору темы.

Простые совпадения

Простые совпадения не несут в себе никакого тайного смысла. Что написано – то и ищем.

В дальнейшем в простых примерах будем использовать функции:

  • MatchString (проверяет, есть ли в строке вхождения регулярного выражения);
  • FindAllString (ищет все последовательные непересекающиеся повторения в
    строке).

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

matched, _ := regexp.MatchString(`I am here`, "I am there")
fmt.Println(matched) // false

Совпадение не полное, в последнем слове лишняя «t».

matched, _ := regexp.MatchString(`Banana`, "Banana")
fmt.Println(matched) // true

«Banana» полностью совпадает со строкой.

matched, _ := regexp.MatchString(`cat`, "black cat meow")
fmt.Println(matched) // true

«сat» встречается в строке.

re, _ := regexp.Compile(`cat`)
res := re.FindAllString("black cat meowcat", -1)
fmt.Println(res) // [cat cat]

Находятся два вхождения.

Якори границ

Якори границ позволяют нам делить текст на отдельные слова, явно задавать привязку к началу или концу строки/текста.

matched, _ := regexp.MatchString(`^I am here`, " I am here")
fmt.Println(matched) // false

Ищем текст, начинающийся с «I am here», но есть пробел перед «I» – не подходит.

matched, _ := regexp.MatchString(`^cat$`, "Black cat meow")
fmt.Println(matched) // false

Ищем строку, состоящую только из кота — но он в середине.

matched, _ := regexp.MatchString(`\bcat\b`, "Black cat meow")
fmt.Println(matched) // true

Ищем кота отдельным словом — находим.

matched, _ := regexp.MatchString(`\Bcat\b`, "Blackcat meow")
fmt.Println(matched) // true

Ищем что-то, заканчивающееся котом — находим.

Классы символов (воин, маг, лучник)

Классы — это краткая запись перечисления символов, объединённых по какому-либо признаку. Также можно использовать posix классы ([:digit:], [:space:], etc.), если так удобней.

matched, _ := regexp.MatchString(`.....`, "any trash with 5 chars")
fmt.Println(matched) // true

Ищем вхождение пяти любых символов.

matched, _ := regexp.MatchString(`^\w\wow\d\b.\D\Dow\d$`, "meow3_meow4")
fmt.Println(matched) // false

Ищем вхождения сочетаний: «ow<цифра><конец слова>» через два символа слова с начала строки, любой символ, затем две НЕ цифры, затем снова «ow», цифра, конец.

matched, _ := regexp.MatchString(`^...@_\w\wD$`, "rus@_UPD")
fmt.Println(matched) // true

Ищем начало текста, три любых символа, «@_», два символа слова, «D», конец.

matched, _ := regexp.MatchString(`^GO\s\d.\d\d$`, "GO 1.16")
fmt.Println(matched) // true

Ищем начало текста, «GO», один пробельный символ, одну цифру, любой символ, две цифры, конец.

Специальные символы и escape

Что нужно знать про специальные символы:

  • список спецсимволов: ^ $ * + ? { } [ ] \ | ( )
  • их нужно экранировать с помощью `\`, т.е.  `\+` = просто +
matched, _ := regexp.MatchString(`^I\nam\nhere$`, "I\nam\nhere")
fmt.Println(matched) // true

Ищем «I» в начале текста, перенос, «am», перенос, «here», конец текста.

matched, _ := regexp.MatchString(`^\x49\nam\nhere$`, "I\nam\nhere")
fmt.Println(matched) // true

Ищем «I» (в виде 16-ричного кода символа), в начале текста, перенос, «am», перенос, «here», конец текста.

matched, _ := regexp.MatchString(`a+b=c`, "a+b=c")
fmt.Println(matched) // false

Ищем… не «a+b=c», а одно и более повторение «a», «b=c», ибо «+» не экранирован.

matched, _ := regexp.MatchString(`a\|b=c`, "a|b=c")
fmt.Println(matched) // true

Ищем «a|b=c», символ «|» экранирован, всё в порядке.

Повторение (жабное, не жабное)

Повторения являются, пожалуй, одной из важнейших фич при работе с регулярными выражениями. Как минимум, из-за того, что дают возможность исключать некоторые подвыражения из обязательных (при использовании?). ЖаБным оно стало в связи со случайной опечаткой и осознанием, что так запоминается лучше.

re, _ := regexp.Compile(`\d+`)
res := re.FindAllString("A123AA455AAA2A89", -1)
fmt.Println(res) // [123  455  2  89]

Ищем все вхождения чисел из одной и более цифр.

matched, _ := regexp.MatchString(`^A{1}G{1,3}A{1,}!{,2}$`, "AGGAA!!")
fmt.Println(matched) // false (вероятно, бага, будьте осторожны, only python, с {0,2} поведение корректно)

Ищем начало текста, одно повторение «А», от одного до 3 повторений «G», одно и более повторение «А», от 0 до 2 повторений «!».

re, _ := regexp.Compile(`<.*>`)
res := re.FindAllString("<p><b>Golang</b> <i>VS</i> <b>Python</b></p>", -1)
fmt.Println(res) // [<p><b>Golang</b> <i>VS</i> <b>Python</b></p>] (len=1) :(

Пытаемся найти все вхождения тегов — из-за жадного повторения получаем весь текст как первое вхождение, ибо весь текст также соответствует выражению <.*> — начинается скобкой, дальше имеет 0 и более любых символов, заканчивается скобкой.

re, _ := regexp.Compile(`<.*?>`)
res := re.FindAllString("<p><b>Golang</b> <i>VS</i> <b>Python</b></p>", -1)
fmt.Println(res) // [<p>  <b>  </b>  <i>  </i>  <b>  </b>  </p>] :)

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

Квадратные скобки, ИЛИ и НЕ

Квадратные скобки эквивалентны перечислению (перечислению с отрицанием при использовании ^). Прямая черта | равнозначна набору альтернативных вариантов из слов. Крышечкой ^ обозначается отрицание при использовании внутри квадратных скобок.

matched, _ := regexp.MatchString(`good|bad|[^ice\s]$`, "work is hmm")
fmt.Println(matched) // true

Ищем либо «good», либо «bad», либо один символ в конце текста, не являющийся пробельным, «i», «c» или «e».

matched, _ := regexp.MatchString(`^[aAuUfF]*?go|python [1-3]\.\d$`, "Uf go 1.6")
fmt.Println(matched) // false

Ищем начало текста, ноль или более (лучше меньше, не жадное повторение) символов из перечня [a, A , u , U , f ,F], «go» или «python», пробел, от одной до трех цифр, точку, одну цифру и конец текста).

matched, _ := regexp.MatchString(`^[haHA]+$`, "HahaHaaaahaaaaaaa")
fmt.Println(matched) // true

Ищем начало текста, один и более символов из перечня [h, a, H, A], конец текста.

matched, _ := regexp.MatchString(`^[haHA]+|[goGO]*$`, "")
fmt.Println(matched) // true

Ищем начало текста, один и более символов из перечня [h, a, H, A] либо ноль и более символов из перечня [g, o, G, O], конец текста – пустая строка соответствует второму варианту после прямой черты.

Группы

Важное о группах:

  • позволяют поместить часть совпадения в отдельный массив;
  • квантификатор после скобок группы применяется ко всей группе (под квантификаторами подразумеваются такие товарищи, как: +, *, {min, max}, etc.);
  • группа 0 всегда относится ко всему выражению;
  • группа 1 — к подвыражению, начинающемуся с “(“ и заканчивающемуся “)”  (и так далее);
  • при повторении группы в качестве «группы 1» берется последнее совпадение.

В примерах некоторые элементы подчеркнуты. Это не баг, это фича, помогающая увидеть, какой элемент в какой список групп попал. Также используется новая функция — FindAllStringSubmatch — возвращающая срез последовательных непересекающихся подсовпадений (совпадений скобочных групп). Вторым параметром в данной функции является ограничение количества найденных подсовпадений (найдется всё, если использовать -1).

Тема групп совсем не проста, поэтому пробуйте разное, ломайте, дебажьте.

re, _ := regexp.Compile(`.(\d+)`)
res := re.FindAllStringSubmatch("Funny1 2020 ye12ar", -1)
fmt.Println(res) // [[y1  1] [ 2020  2020] [e12  12]]

Ищем все подсовпадения с выражением «любой символ, одна и более цифра», находим три вхождения, в каждом из которых есть группа 0 – всё вхождение целиком – и группа 1 – часть с «одна и более цифра».

re, _ := regexp.Compile(`(\d{4})-(\d{2})-(\d{2})`)
res := re.FindAllStringSubmatch("Now is 2021-01-14", -1)
fmt.Println(res) // [[2021-01-14  2021  01  14]]

Выделяем год, месяц и день в отдельные группы, ищем (4 цифры), тире, (2 цифры), тире, (2 цифры).  Получаем одно подсовпадение, где группа 0 — вся дата, группа 1 — год, группа 2 — месяц, группа 3 — день.

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

re, _ := regexp.Compile(`.*?(([a-zA-Z\-0-9]+)\\.[a-zA-Z]{2,})`)
res := re.FindAllStringSubmatch("version: v2-v3\\\\Go", -1)
fmt.Println(res) // [[version: v2-v3\\Go  v2-v3\\Go  v2-v3]]

Как ни странно, в группе может быть внутренняя подгруппа (а в ней ещё одна…и ещё…). Здесь мы ищем ноль и более (лучше меньше) любых символов, одно и более повторение символа из перечня [a-zA-Z\-0-9], слэш, любой символ, 2 и более повторения символов из перечня [a-zA-Z]. В итоге находим одно вхождение, где группа 0 – все выражение, группа 1, как внешняя, целиком соответствует части «одно и более повторение символа из перечня [a-zA-Z\-0-9], слэш, любой символ, 2 и более повторения символов из перечня [a-zA-Z]», а группа 2 – части «одно и более повторение символа из перечня [a-zA-Z\-0-9]».

Вспоминается мем (в нём, кстати, есть мааленькая опечатка :), кто отыщет?):

С группами, как и с математическим анализом, нужно сесть, поплакать, хорошо разобраться один раз и работать на автомате в дальнейшем…

Именованные и необязательные группы

Еще один факт о регулярных выражениях в Golang:

  • обратных ссылок тут нет (!) (запоминание встретившейся группы для повторного использования в том же выражении).

Иногда этот факт вызывает головную боль.

В примерах используется новая вспомогательная функция — SubexpNames — позволяющая получить доступ к списку разделённых по названию групп подсовпадений.

re, _ := regexp.Compile(`(?P<Year>\d{4})-(?P<Month>\d{2})-(?P<Day>\d{2})`)
res := re.FindAllStringSubmatch("trash trash \n \t 2021-01-14 ! 
trash again", -1)
for _, v := range res {
	for kk, vv := range re.SubexpNames() {
		if vv=="Year" {fmt.Printf("year: %s, ", v[kk])}
		if vv=="Month" {fmt.Printf("month: %s, ", v[kk])}
		if vv=="Day" {fmt.Printf("day: %s\n", v[kk])}
	}
}
// year: 2021, month: 01, day: 14

Пытаемся выловить из мусорного текста дату, разделив её на год, месяц, день. В группу Year попадают первые 4 цифры до тире, группу Month — 2 цифры до следующего тире, Day — последние 2 цифры. Доступ к разделенным по названиям групп подсовпадениям получаем при помощи прохождения по re.SubexpNames()

re, _ := regexp.Compile(`(?:[Gg]o)([pP]y)`)
res := re.FindAllStringSubmatch("Gopy goPy", -1)
fmt.Println(res) // [[Gopy py] [goPy Py]]

Ищем go либо Go (группа, которая не попадает в список подсовпадений благодаря ?: после открывающей скобки группы), py либо Py — находим два подсовпадения, где группа 0 — вхождение целиком, группа 1 – вторая группа
(которая «py либо Py»).

Другие функции для работы с регулярными выражениями

Формула функций работы с регулярными выражениями:

Find(All)?(String)?(Submatch)?(Index)?

Также рассмотрим несколько иных функций на примерах.

Split (сплитим текст на части по регулярному выражению-разделителю):
re := regexp.MustCompile(`[A-Z\d_]+`)
res := re.Split("then_theyKgo325236somewhere", -1)
fmt.Println(res) // [then they go somewhere]
Replace (чистим тот же самый текст, заменяя соответствия регулярке пробелами):
re := regexp.MustCompile(`[A-Z\d_]+`)
res := re.ReplaceAllString("then_theyKgo325236somewhere", " ")
fmt.Println(res) // then they go somewhere
Replace 2 (меняем местами соответствующие группам слова):
re := regexp.MustCompile(`(?P<first>[a-zA-Z]+) (?P<last>[a-zA-Z]+)`)
reversed := fmt.Sprintf("${%s} ${%s}", re.SubexpNames()[2], 
re.SubexpNames()[1])
fmt.Println(re.ReplaceAllString("doctor Strange", reversed)) // 
Strange doctor

Дальше будет сложно. Слабонервным рекомендуется закрыть статью, отойти от экранов и уехать жить в Лондон (почему бы и нет).

Большие примеры с кейсами применения регулярных выражений

Валидация логина:

loginPattern := `^[a-zA-Z]{1}[\w@\.]{5,}$`
matched, _ = regexp.MatchString(loginPattern, "1strange_Gopher.2020")
fmt.Println(matched) // false, логин не должен начинаться с цифры

Фильтрация трафика syslog (привет работающим с logstash и его фильтрами):

sysPattern := `(?:id=(?P<id>\d+?) )(?:id2=(?P<id2>\w+) )?(?:nD=(?P<nD>\d{1,5}))`
re, _ := regexp.Compile(sysPattern)
res := re.FindAllStringSubmatch("id=56 nD=9", -1)
fmt.Println(res) // [[id=56 nD=9 56  9]] (группа id2 – не обязательна, поэтому текст соответствует регулярному выражению, а группа id2 просто не заполняется)

Парсинг имен таблиц и баз данных, к которым идет обращение, из SELECT SQL-запроса:

selectPattern := 
`(select|SELECT)[\s]+[^\r]*?[\s]+(from|FROM)[\s]+(?P<TAB>[^\s()]+)[\s]*`
query := `SELECT * FROM testdb.test WHERE id=(SELECT id FROM testdb.test2 
WHERE score > 1) AND num=(SELECT max(fact) FROM testdb.test3)`
res := make([]string, 0)
reger, _ := regexp.Compile(selectPattern)
allSel := reger.FindAllStringSubmatch(query, -1)
for _, match := range allSel {
	for ind, subName := range reger.SubexpNames() {
		if subName == "TAB" {
			res = append(res, match[ind])
		}
	}
}
fmt.Println(strings.Join(res, ", ")) // testdb.test, testdb.test2,
testdb.test3

Маленькое заключение

Регулярные выражения — достаточно полезная штука при анализе текста, парсинге потокаданных, когда необходимо вытащить оттуда нечто действительно важное…ну и вообще для всякого рода магии 🙂

Разбирайтесь, не бойтесь экспериментировать и развлекайтесь!