golang

Коротко про regexp в Go

  • воскресенье, 16 марта 2025 г. в 00:00:10
https://habr.com/ru/companies/otus/articles/889320/

Привет, Хабр!

Сегодня рассмотрим 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 предлагает несколько способов сделать это, в зависимости от типа данных:

  1. MatchString(s string) bool — проверяет строку.

  2. Match(b []byte) bool — проверяет массив байтов.

  3. 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 возвращает срез строк, где:

  1. matches[0] — полное совпадение «123–456–7890»,

  2. matches[1] — код «123»,

  3. matches[2] — первая часть «456»,

  4. 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 есть два метода для этого:

  1. ReplaceAllString — заменяет совпадения на заданную строку.

  2. 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.comfoo.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. Узнать подробнее