Привет, Хабр!
Сегодня рассмотрим regexp — стандартный пакет Go для работы с регулярными выражениями. Если вы уже пользовались регулярками в других языках (например, Python, JavaScript или Perl), то знаете, как они могут нагружать процессор и вызывать некоторые подвисания.
Основное отличие Go — он использует движок RE2, который не поддерживает бэктрекинг. Это значит, что он работает за линейное время и не устроит сюрпризов в виде зависшего сервера.
Компиляция regexp: как её делать правильно?
Перед тем как работать с регулярным выражением, его нужно скомпилировать. В Go есть два способа:
regexp.Compile
Этот вариант безопасный: если регулярка написана с ошибкой, он просто вернёт ошибку.
re, err := regexp.Compile(`[a-z]+`) if err != nil { log.Fatal("Ошибка компиляции регулярного выражения:", err) } fmt.Println("Регулярка скомпилирована:", re)
Используйте этот вариант, если регулярка динамическая — например, её передаёт пользователь или она загружается из базы данных.
regexp.MustCompile
Более жёсткий вариант: если регулярка сломана, программа сразу же падает в panic.
package main import ( "fmt" "regexp" ) func main() { pattern := "(a+)+" re := regexp.MustCompile(pattern) fmt.Println(re.MatchString("aaaaaaaaaaaaaaaaaaaaaaaaa")) // Не зависает, отрабатывает моментально }
Используем MustCompile когда регулярка статическая и ошибки быть не может, например:
var emailRegex = regexp.MustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`)
Если здесь будет ошибка, лучше узнать об этом сразу, чем через 2 часа работы сервиса.
Поиск соответствий: Match, MatchString, MatchReader
Одна из самых частых задач при работе с regexp — проверить, подходит ли строка под шаблон. Например, мы хотим узнать, является ли строка целым числом, email’ом, IP‑адресом или чем‑то ещё.
Go предлагает несколько способов сделать это, в зависимости от типа данных:
-
MatchString(s string) bool— проверяет строку. -
Match(b []byte) bool— проверяет массив байтов. -
MatchReader(io.RuneReader) bool— проверяет потоковый источник (io.Reader), полезно для больших файлов и сетевых данных.
MatchString
Простейший случай — проверка строки. Например, проверим, является ли строка числом (состоит только из цифр):
package main import ( "fmt" "regexp" ) func main() { pattern := `^\d+$` // Только цифры от начала до конца строки re := regexp.MustCompile(pattern) fmt.Println(re.MatchString("12345")) // true (всё цифры) fmt.Println(re.MatchString("12a45")) // false (есть буква) fmt.Println(re.MatchString("00123")) // true (ноль допустим) fmt.Println(re.MatchString("")) // false (пустая строка) }
^ — обозначает начало строки. \d+ — означает «одна или более цифр» (0–9). $ — конец строки, то есть строка должна состоять ТОЛЬКО из цифр.
Если убрать ^ и $, регулярка будет искать любое число в строке, а не проверять всю строку.
re := regexp.MustCompile(`\d+`) fmt.Println(re.MatchString("Цена: 123 руб.")) // true, потому что 123 есть в тексте
Но если ищем целое число, ^ и $ нужны.
Match: проверка []byte
Функция Match работает аналогично MatchString, но принимает []byte, а не string.
package main import ( "fmt" "regexp" ) func main() { re := regexp.MustCompile(`^\d+$`) fmt.Println(re.Match([]byte("12345"))) // true fmt.Println(re.Match([]byte("12a45"))) // false }
В высоконагруженных приложениях []byte быстрее, потому что избегает лишних аллокаций памяти.
Пример с HTTP‑запросами:
package main import ( "fmt" "net/http" "regexp" ) var emailRegex = regexp.MustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`) func handler(w http.ResponseWriter, r *http.Request) { email := []byte(r.URL.Query().Get("email")) if emailRegex.Match(email) { fmt.Fprintln(w, "Email корректный") } else { fmt.Fprintln(w, "Некорректный email") } } func main() { http.HandleFunc("/", handler) http.ListenAndServe(":8080", nil) }
Match([]byte) используется для проверки email, который передаётся в GET‑запросе.
MatchReader
Допустим, есть большой файл, и нужно проверить, есть ли в нём число, не загружая весь файл в память. В этом случае MatchReader будет получше, чем MatchString, потому что он читает данные постепенно.
Читаем строку из io.Reader и проверяем, есть ли в ней число:
package main import ( "fmt" "regexp" "strings" ) func main() { re := regexp.MustCompile(`\d+`) reader := strings.NewReader("Значение: 42") fmt.Println(re.MatchReader(reader)) // true (число найдено) }
strings.NewReader("Значение: 42") создаёт io.Reader, имитируя поток данных. MatchReader читается из потока до первого совпадения. Если число найдено — возвращает true, иначе false.
Пример с чтением файла построчно:
package main import ( "bufio" "fmt" "os" "regexp" ) func main() { re := regexp.MustCompile(`\d+`) file, err := os.Open("log.txt") if err != nil { fmt.Println("Ошибка:", err) return } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { if re.MatchString(scanner.Text()) { fmt.Println("Найдено число в строке:", scanner.Text()) } } if err := scanner.Err(); err != nil { fmt.Println("Ошибка чтения файла:", err) } }
os.Open("log.txt") открывает файл. bufio.NewScanner(file) позволяет читать файл построчно. re.MatchString(scanner.Text()) проверяет каждую строку на наличие числа. Если число найдено — строка выводится.
Поиск данных: Find, FindAll, FindIndex
Когда просто проверить соответствие строки шаблону недостаточно, в дело вступает поиск. regexp в Go предоставляет несколько способов не только узнать, есть ли совпадения, но и где они находятся, сколько их и какой именно текст был найден.
FindString — найти первое совпадение
Иногда нужно просто взять первую подходящую подстроку, соответствующую шаблону. Для этого используем FindString:
package main import ( "fmt" "regexp" ) func main() { re := regexp.MustCompile(`\d+`) str := "Цена: 123 руб, скидка: 456 руб." fmt.Println(re.FindString(str)) // "123" }
Компилируем регулярное выражение \d+, которое находит одно или несколько подряд идущих чисел. Передаём строку «Цена: 123 руб, скидка: 456 ₽». FindString возвращает первое найденное совпадение — «123», а на «456» уже не смотрит.
Обратите внимание, что если совпадений нет, метод просто вернёт пустую строку, а не nil.
fmt.Println(re.FindString("тут нет чисел")) // ""
FindAllString — найти все совпадения
Если в строке несколько чисел, а нужны все, используем FindAllString:
fmt.Println(re.FindAllString(str, -1)) // ["123", "456"]
Разберёмся, что означает второй аргумент:
-
-1 — найти всё.
-
2 — вернуть только два первых совпадения.
-
1 — вернуть только одно совпадение (аналог
FindString).
Примеры:
fmt.Println(re.FindAllString(str, 1)) // ["123"] fmt.Println(re.FindAllString(str, 2)) // ["123", "456"] fmt.Println(re.FindAllString(str, 5)) // ["123", "456"] (всё равно максимум два)
Если совпадений нет, вернётся пустой срез ([]string{}), а не nil.
fmt.Println(re.FindAllString("абвгд", -1)) // []
FindAllString не будет разбирать строку дальше после найденного совпадения, если регулярка заточена под «жадный» поиск. Например, если мы ищем «ab+» в строке «abbb abb ab», то результат будет:
re := regexp.MustCompile(`ab+`) fmt.Println(re.FindAllString("abbb abb ab", -1)) // ["abbb", "abb", "ab"]
Каждое совпадение отдельно, а не одно гигантское «abbb abb ab».
FindIndex — находит позиции совпадений
Если нужно узнать не только текст совпадения, но и его позицию в строке, используем FindIndex:
fmt.Println(re.FindStringIndex(str)) // [6 9]
Число «123» найдено в позициях [6:9] строки «Цена: 123 руб, скидка: 456 ₽». Границы совпадения включительные (индекс 6 — начало, 9 — конец, но не включается).
Проверим на других примерах:
fmt.Println(re.FindStringIndex("abc 987 xyz")) // [4 7]
Здесь «987» найдено в позиции [4:7], а xyz уже не входит.
Если совпадения нет, FindIndex вернёт nil:
fmt.Println(re.FindStringIndex("тут нет чисел")) // nil
FindAllStringIndex — все позиции всех совпадений
Для тех, кто хочет не просто текст совпадений, но и их позиции, есть FindAllStringIndex:
fmt.Println(re.FindAllStringIndex(str, -1)) // [[6 9] [18 21]]
Вывод [6 9] [18 21] означает:
-
«123» найдено в позиции [6:9].
-
«456» найдено в позиции [18:21].
Аналогично FindAllString, можно ограничить количество возвращаемых элементов:
fmt.Println(re.FindAllStringIndex(str, 1)) // [[6 9]] fmt.Println(re.FindAllStringIndex(str, 2)) // [[6 9] [18 21]]
Это полезно, если мы ищем первые N совпадений, не загружая память лишними данными.
FindAll — универсальный метод
Метод FindAll более универсален, потому что работает не только со строками, но и с []byte. Он аналогичен FindAllString, но возвращает [][]byte, а не []string:
b := []byte("Цена: 123 руб, скидка: 456 руб.") matches := re.FindAll(b, -1) for _, match := range matches { fmt.Println(string(match)) }
Выведет:
123 456
Используем FindAll, а не FindAllString, когда данные в []byte (например, лог‑файл, загруженный в память) и когда string не оптимален (например, высоконагруженные сервисы, где []byte дешевле).
FindReaderIndex — работа с потоками
Допустим, большой файл, и не хочется хотим загружать его целиком в память. В таком случае FindReaderIndex поможет искать на ходу, читая данные из io.Reader:
package main import ( "fmt" "regexp" "strings" ) func main() { re := regexp.MustCompile(`\d+`) reader := strings.NewReader("Цена: 123 руб, скидка: 456 руб.") fmt.Println(re.FindReaderIndex(reader)) // [6 9] }
В отличие от FindIndex, здесь данные могут поступать потоками. Это полезно, если:
-
Читаем файл построчно.
-
Принимаем данные из сети (например, парсим HTTP‑ответ).
-
Работает потоковый лог‑анализ.
Но FindReaderIndex имеет ограничения: он ищет только первое совпадение. Если нужно все, проще использовать FindAllStringIndex на загруженном куске данных.
FindSubmatch — если важны не только совпадения, но и подгруппы
Если нам нужны не только полные совпадения, но и данные из подгрупп, используем FindSubmatch:
re := regexp.MustCompile(`(\d{3})-(\d{3})-(\d{4})`) str := "Телефон: 123-456-7890" matches := re.FindStringSubmatch(str) fmt.Println(matches) // ["123-456-7890" "123" "456" "7890"]
Здесь:
-
matches[0] — полное совпадение «123–456–7890».
-
matches[1] — первая группа «123».
-
matches[2] — вторая группа «456».
-
matches[3] — третья группа «7890».
То же самое можно сделать для всех совпадений с FindAllStringSubmatch:
allMatches := re.FindAllStringSubmatch("123-456-7890, 987-654-3210", -1) fmt.Println(allMatches)
Лучшие практики программирования на Go для старта в карьере разработчика можно изучить на онлайн-курсе.
Группы в regexp
Группы захвата позволяют разбивать совпадения на части, извлекать подстроки и гибко работать с данными. Если обычный FindString находит просто «что‑то», то группы позволяют точечно вытаскивать нужные элементы.
Допустим, есть телефонный номер в формате 123–456–7890. Нужно не просто найти его, но и разделить на три части: код города, первую и вторую половину номера.
re := regexp.MustCompile(`(\d{3})-(\d{3})-(\d{4})`) str := "Телефон: 123-456-7890" matches := re.FindStringSubmatch(str) fmt.Println(matches) // ["123-456-7890" "123" "456" "7890"]
\d{3} — ищет три цифры подряд. Круглые скобки (…) создают группы захвата.
В итоге FindStringSubmatch возвращает срез строк, где:
-
matches[0] — полное совпадение «123–456–7890»,
-
matches[1] — код «123»,
-
matches[2] — первая часть «456»,
-
matches[3] — вторая часть «7890».
Допустим, есть текст, в котором много телефонных номеров, и нужно извлечь все коды городов:
text := "Контакты: 123-456-7890, 987-654-3210, 555-777-9999" matches := re.FindAllStringSubmatch(text, -1) for _, match := range matches { fmt.Println("Код города:", match[1]) }
Вывод:
Код города: 123 Код города: 987 Код города: 555
Индексы групп
Если нужно не только само совпадение, но и его местоположение, используем FindStringSubmatchIndex:
fmt.Println(re.FindStringSubmatchIndex(str)) // [9 21 9 12 13 16 17 21]
Расшифруем:
-
[9 21] — полное совпадение «123–456–7890», его границы [9:21].
-
[9 12] — первая группа («123») находится в [9:12].
-
[13 16] — вторая группа («456») в [13:16].
-
[17 21] — третья группа («7890») в [17:21].
Если нужно заменить только код города в телефонных номерах, индексы помогут определить, какую часть строки менять.
Замена текста: ReplaceAllString и ReplaceAllStringFunc
Регулярные выражения не только ищут текст, но и позволяют его модифицировать. В Go есть два метода для этого:
-
ReplaceAllString — заменяет совпадения на заданную строку.
-
ReplaceAllStringFunc — позволяет динамически менять найденный текст.
ReplaceAllString
Допустим, нужно замаскировать все числа, чтобы скрыть личные данные:
re := regexp.MustCompile(`\d+`) str := "Я купил 3 яблока и 5 груш." newStr := re.ReplaceAllString(str, "XXX") fmt.Println(newStr) // "Я купил XXX яблока и XXX груш."
Этот метод грубый, потому что заменяет все числа на один и тот же текст. Но что, если нужно преобразовывать каждое число отдельно?
ReplaceAllStringFunc
Допустим, хочется удваивать все числа в тексте. Вместо «3 яблока» и «5 груш» будет «6 яблок» и «10 груш»:
newStr := re.ReplaceAllStringFunc(str, func(s string) string { num, _ := strconv.Atoi(s) return strconv.Itoa(num * 2) }) fmt.Println(newStr) // "Я купил 6 яблок и 10 груш."
regexp ищет все числа. ReplaceAllStringFunc вызывает функцию для каждого совпадения. Функция конвертирует число, удваивает его и записывает обратно.
Разделение строки
Метод Split работает как strings.Split, но даёт больше возможностей. Например, можно разбивать строку не просто по , а учитывать пробелы после запятой.
re := regexp.MustCompile(`,\s*`) str := "яблоки, груши, бананы, апельсины" words := re.Split(str, -1) fmt.Println(words) // ["яблоки" "груши" "бананы" "апельсины"]
— ищем запятую. \s* — возможно, есть пробелы после запятой, учитываем их. Split(str, -1) — разбиваем строку по найденным совпадениям.
Теперь разобьём текст по всем пробелам, табуляциям и переводам строк:
re := regexp.MustCompile(`[\s\t\n]+`) str := "слово1 слово2\tслово3\nслово4" words := re.Split(str, -1) fmt.Println(words) // ["слово1" "слово2" "слово3" "слово4"]
Здесь [\s\t\n]+ означает:
-
\s— любой пробел. -
\t— табуляция. -
\n— новая строка. -
+— может быть несколько подряд.
Кейсы использования regexp в Go
Валидация и нормализация email-адресов
Допустим, есть сервис с регистрацией пользователей, и нужно:
-
Проверить, что email корректный.
-
Привести email к нижнему регистру.
-
Удалить лишние пробелы.
-
Убрать дубликаты точек перед @ (например, foo..bar@example.com → foo.bar@example.com).
Решаем это с помощью regexp:
package main import ( "fmt" "regexp" "strings" ) var emailRegex = regexp.MustCompile(`(?i)^\s*([a-z0-9._%+-]+)@([a-z0-9.-]+\.[a-z]{2,})\s*$`) func NormalizeEmail(email string) (string, error) { matches := emailRegex.FindStringSubmatch(email) if matches == nil { return "", fmt.Errorf("некорректный email") } localPart := strings.ToLower(matches[1]) domain := strings.ToLower(matches[2]) // Убираем дубликаты точек в локальной части (foo..bar@example.com → foo.bar@example.com) localPart = regexp.MustCompile(`\.{2,}`).ReplaceAllString(localPart, ".") return localPart + "@" + domain, nil } func main() { emails := []string{ " USER@EXAMPLE.COM ", "foo..bar@example.com", "invalid-email", } for _, email := range emails { normalized, err := NormalizeEmail(email) if err != nil { fmt.Println("Ошибка:", email, "→", err) } else { fmt.Println("ОК:", email, "→", normalized) } } }
Извлечение данных из логов и метрик
Допустим, есть лог‑система, и нужно:
-
Находить в логах ID запроса (request_id=abc123).
-
Фильтровать ошибки (ERROR: что‑то сломалось).
-
Анализировать медленные запросы (duration=1452ms → 1452).
Код:
package main import ( "fmt" "regexp" "strconv" ) var logRegex = regexp.MustCompile(`request_id=([\w-]+)|duration=(\d+)ms|ERROR: (.+)`) func ParseLogLine(line string) { matches := logRegex.FindAllStringSubmatch(line, -1) for _, match := range matches { if match[1] != "" { fmt.Println("ID запроса:", match[1]) } if match[2] != "" { duration, _ := strconv.Atoi(match[2]) if duration > 1000 { fmt.Println("Медленный запрос:", duration, "мс") } } if match[3] != "" { fmt.Println("Ошибка:", match[3]) } } } func main() { logs := []string{ "INFO: request_id=abc123 duration=400ms", "ERROR: database connection failed", "WARNING: request_id=xyz789 duration=1452ms", } for _, log := range logs { ParseLogLine(log) } }
Парсинг HTML без громоздких библиотек
Допустим, есть HTML‑страница, и нужно достать ссылки. Можно, конечно, подключить парсер типа goquery, но если нужны только ссылки, regexp — отличное решение.
Вот как можно быстро вытащить все ссылки из HTML:
package main import ( "fmt" "regexp" ) var linkRegex = regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+)["']`) func ExtractLinks(html string) []string { matches := linkRegex.FindAllStringSubmatch(html, -1) var links []string for _, match := range matches { links = append(links, match[1]) } return links } func main() { html := `<html> <body> <a href="https://example.com">Example</a> <a href='https://another.com'>Another</a> </body> </html>` links := ExtractLinks(html) fmt.Println("Найденные ссылки:", links) }
Не пытайтесь парсить HTML полностью с regexp. Для сложных задач лучше использовать парсеры DOM (golang.org/x/net/html или goquery).
Итоги
Когда стоит использовать regexp, а когда нет?
-
Если задача простая (разбить строку по «,»), лучше strings.Split`.
-
Если требуется поиск шаблонов, regexp будет полезен.
-
Если нужно что‑то заменить,
ReplaceAllStringFuncпозволяет писать умные замены.
Всегда проверяйте: не проще ли решить задачу без regexp?
Статья подготовлена для будущих студентов онлайн-курса «Go (Golang) Developer Basic». Хорошая новость: в рамках этого курса студенты получат поддержку карьерного центра Otus. Узнать подробнее
ссылка на оригинал статьи https://habr.com/ru/articles/889320/
Добавить комментарий