Коротко про regexp в Go
- воскресенье, 16 марта 2025 г. в 00:00:10
Привет, Хабр!
Сегодня рассмотрим regexp — стандартный пакет Go для работы с регулярными выражениями. Если вы уже пользовались регулярками в других языках (например, Python, JavaScript или Perl), то знаете, как они могут нагружать процессор и вызывать некоторые подвисания.
Основное отличие Go — он использует движок RE2, который не поддерживает бэктрекинг. Это значит, что он работает за линейное время и не устроит сюрпризов в виде зависшего сервера.
Перед тем как работать с регулярным выражением, его нужно скомпилировать. В Go есть два способа:
Этот вариант безопасный: если регулярка написана с ошибкой, он просто вернёт ошибку.
re, err := regexp.Compile(`[a-z]+`)
if err != nil {
log.Fatal("Ошибка компиляции регулярного выражения:", err)
}
fmt.Println("Регулярка скомпилирована:", re)
Используйте этот вариант, если регулярка динамическая — например, её передаёт пользователь или она загружается из базы данных.
Более жёсткий вариант: если регулярка сломана, программа сразу же падает в 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 часа работы сервиса.
Одна из самых частых задач при работе с regexp — проверить, подходит ли строка под шаблон. Например, мы хотим узнать, является ли строка целым числом, email'ом, IP‑адресом или чем‑то ещё.
Go предлагает несколько способов сделать это, в зависимости от типа данных:
MatchString(s string) bool
— проверяет строку.
Match(b []byte) bool
— проверяет массив байтов.
MatchReader(io.RuneReader) bool
— проверяет потоковый источник (io.Reader), полезно для больших файлов и сетевых данных.
Простейший случай — проверка строки. Например, проверим, является ли строка числом (состоит только из цифр):
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 работает аналогично 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
будет получше, чем 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())
проверяет каждую строку на наличие числа. Если число найдено — строка выводится.
Когда просто проверить соответствие строки шаблону недостаточно, в дело вступает поиск. regexp в Go предоставляет несколько способов не только узнать, есть ли совпадения, но и где они находятся, сколько их и какой именно текст был найден.
Иногда нужно просто взять первую подходящую подстроку, соответствующую шаблону. Для этого используем 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:
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:
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:
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
более универсален, потому что работает не только со строками, но и с []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
поможет искать на ходу, читая данные из 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:
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 для старта в карьере разработчика можно изучить на онлайн-курсе.
Группы захвата позволяют разбивать совпадения на части, извлекать подстроки и гибко работать с данными. Если обычный 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].
Если нужно заменить только код города в телефонных номерах, индексы помогут определить, какую часть строки менять.
Регулярные выражения не только ищут текст, но и позволяют его модифицировать. В Go есть два метода для этого:
ReplaceAllString — заменяет совпадения на заданную строку.
ReplaceAllStringFunc — позволяет динамически менять найденный текст.
Допустим, нужно замаскировать все числа, чтобы скрыть личные данные:
re := regexp.MustCompile(`\d+`)
str := "Я купил 3 яблока и 5 груш."
newStr := re.ReplaceAllString(str, "XXX")
fmt.Println(newStr) // "Я купил XXX яблока и XXX груш."
Этот метод грубый, потому что заменяет все числа на один и тот же текст. Но что, если нужно преобразовывать каждое число отдельно?
Допустим, хочется удваивать все числа в тексте. Вместо «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
— новая строка.
+
— может быть несколько подряд.
Допустим, есть сервис с регистрацией пользователей, и нужно:
Проверить, что 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‑страница, и нужно достать ссылки. Можно, конечно, подключить парсер типа 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. Узнать подробнее