Содержание урока · 10 разделов
- CollectRows: курсор превращается в срез
- RowToStructByName: колонки по именам, регистр не важен
- db-тег: переименование колонок без ALTER TABLE
- Семейство коллекторов RowTo
- RowToStructByPos и Lax: когда имена не спасают
- Правила выбора коллектора одним списком
- CollectOneRow и CollectExactlyOneRow: одна строка с гарантиями
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Канонический цикл из пятого урока честен, но многословен: на каждый SELECT — объявление переменных под колонки, Scan внутри Next, проверка Err после цикла. В сервисе с двумя десятками запросов на списки это десятки одинаковых блоков, и половина код-ревью уходит на обсуждение опечаток в порядке приёмников. pgx v5 решил именно это: пятая мажорная версия вышла после появления обобщений в Go 1.18, и их главное применение здесь — семейство функций RowTo, каждая из которых превращает одну строку результата в значение нужного типа, и CollectRows, которая прогоняет курсор через такую функцию и собирает срез. Вместо восьми строк цикла остаётся одна: notes, err := pgx.CollectRows(rows, pgx.RowToStructByName[Note]). Курсор закрывается автоматически, ошибка по дороге доезжает в err, пустая выборка превращается в пустой срез. При этом ручной вариант не устаревает: он остаётся способом понять, что происходит под капотом, и запасным входом для нестандартных случаев — трансформации строк на лету, составных приёмников, нестандартной логики заполнения. В этом уроке пройдём по всему семейству и решим, когда оно уместно.
CollectRows: курсор превращается в срез#
Сигнатура говорит сама: CollectRows[T any](rows Rows, fn RowToFunc[T]) ([]T, error). Generic-параметр T — тип элементов среза, а второй аргумент — функция-коллектор, которая для каждой строки возвращает T или ошибку. Сама CollectRows устроена прозаично: цикл Next, вызов коллектора, append в срез, в конце проверка Err — ровно то, что вы писали руками, спрятано в библиотеку и не может быть забыто. Два контракта стоит проговорить. Первый: CollectRows закрывает курсор автоматически при выходе — defer rows.Close() не нужен, и ранние выходы больше не держат соединение пула. Второй: пустой результат — это пустой срез нулевой длины, а не ошибка и не nil: len(notes) == 0, обработка списков не ветвится. Рядом живут три родни. CollectOneRow — аналог QueryRow: берёт первую строку, при пустоте возвращает ошибку, для которой errors.Is(err, pgx.ErrNoRows) истинно. CollectExactlyOneRow — строже: более одной строки — ErrTooManyRows, и это готовый инструмент для запросов, где вторая строка означает сломанную инварианту; у обеих ошибок есть собственные тексты, no rows in result set и too many rows in result set, — их удобно оборачивать в доменные типы рядом, чтобы слой HTTP отдавал 404 и 409 из одной функции. ForEachRow — вариант без сборки среза: колбэк на каждую строку, удобно для стриминга больших выборок. CollectRows, CollectOneRow и CollectExactlyOneRow принимают один и тот же RowToFunc, поэтому коллекторы взаимозаменяемы; ForEachRow — отдельная сигнатура: срез приёмников и колбэк без аргументов, зато возвращает pgconn.CommandTag, как и Exec. Родня CollectRows — AppendRows: та же прогулка по курсору, но с дописыванием в переданный срез — удобно, когда результат нужно долить в уже собранный список.
Восемь строк цикла против одного вызова
GOtype Note struct {
ID int64
Title string
CreatedAt time.Time
}
rows, err := pool.Query(ctx,
"SELECT id, title, created_at FROM notes ORDER BY created_at DESC")
if err != nil {
log.Fatal(err)
}
notes, err := pgx.CollectRows(rows, pgx.RowToStructByName[Note])
if err != nil {
log.Fatal(err)
}
fmt.Println(len(notes), notes[0].Title)Структура Note объявлена один раз, дальше — Query и один вызов: CollectRows закрывает курсор, проходит строки и раскладывает колонки по полям Note по именам. Совпадение имён здесь не случайность: колонка created_at нашла поле CreatedAt, потому что регистр не важен, а подчёркивания разворачиваются без тегов. Порядок колонок и полей может не совпадать — маппинг идёт по именам.
RowToStructByName: колонки по именам, регистр не важен#
RowToStructByName[T] — самый употребимый коллектор: он сопоставляет колонки результата с публичными полями структуры по именам, причём сравнение игнорирует регистр и подчёркивания. created_at и CreatedAt — пара, id и ID — пара, алиас вроде SELECT count(*) AS total найдёт поле Total: нормализация убирает подчёркивания с обеих сторон и сравнивает остаток без учёта регистра. Нестандартные имена — с кавычками, символами или кириллицей — проще всего заводить через db-тег, который сравнивается с колонкой буквально. Непубличные поля пропускаются: структуру с приватным кэшем или мьютексом это не сломает. Коллектор требует, чтобы каждая колонка результата нашла своё поле: лишняя колонка в SELECT приносит ошибку вида struct doesn't have corresponding row field — драйвер называет имя, которому не нашлось места. Зеркальный случай срабатывает так же строго: колонку удалили или переименовали в схеме, а поле в структуре осталось — ошибка cannot find field <имя поля> in returned row называет уже имя поля. Оба текста читаются за секунду и прямо указывают, какая из двух сторон разошлась. Сам тег при этом пишется прямо в структуру: db:"created_at" сопоставится именно с этой колонкой. Сопоставление не бесплатно — отражение и построение карты полей, — поэтому pgx кэширует карту для пары «тип структуры, набор колонок»: после первого запроса с этим же набором колонок маппинг берётся из кэша, и повторные вызовы почти не добавляют накладных расходов. Если имена всё же расходятся принципиально, нижняя граница скорости — RowToStructByPos, о нём ниже.
db-тег: переименование колонок без ALTER TABLE#
Тег db — единственная аннотация, которую семейство RowTo понимает из коробки. Три формы полезны на практике. db:"created_at" переименовывает: поле CreatedAt в Go, колонка created_at в базе — уже и так совпало бы, но вот колонка created_at_ms или legacy_name совпадёт только так. db:"-" исключает поле из маппинга: поле не считается соответствием ни для какой колонки — удобно для вычисляемых полей, которые заполняются после выборки. Тег на встроенных структурах работает рекурсивно: если Note встроила Auditable с полями created_at и updated_at, колонки найдут свои поля внутри. Правила именования при отсутствии тега простые: нормализация убирает подчёркивания и не смотрит на регистр, поэтому NoteID найдёт note_id, а TableName спокойно совпадёт с table_name. Тонкость, которую стоит помнить: db-тег отключает нормализацию — db:"CreatedAt" совпадёт только с колонкой CreatedAt буква в букву, поэтому в тегах пишут точное имя колонки из базы. Стилевая вилка, которую наблюдают почти все команды: либо имена колонок заранее подогнаны под snake_case Go-полей и теги почти не нужны, либо база легаси с именами вроде nm_usr — тогда теги живут в одном файле с моделью и читаются как таблица соответствий. Второй вариант неприятно пахнет, зато локализует легаси в одном месте, а не в каждом запросе.
Семейство коллекторов RowTo#
| Функция | Сопоставление | Особенность | Типичный сценарий |
|---|---|---|---|
| RowTo[T] | весь ряд в один приёмник | для одной колонки или вручную | SELECT id из справочника |
| RowToStructByName[T] | по именам, регистр не важен | db-тег, лишняя колонка — ошибка | основной путь для структур |
| RowToStructByPos[T] | по позиции колонок | число публичных полей = числу колонок | горячие пути без отражения имён |
| RowToAddrOfStructByName[T] | по именам, возвращает *T | указатели на структуры в срезе | мутация после выборки, nil-слоты |
| RowToStructByNameLax[T] | по именам, мягкий | лишние колонки разрешены | SELECT * и переиспользование SELECT |
ForEachRow: тот же коллектор, без сборки среза
GOvar title string
count := 0
rows, err := pool.Query(ctx, "SELECT title FROM notes")
if err != nil {
log.Fatal(err)
}
_, err = pgx.ForEachRow(rows, []any{&title}, func() error {
count++
return nil
})
if err != nil {
log.Fatal(err)
}ForEachRow прогоняет курсор через колбэк: Scan в переменные выполняется до вызова функции, Close и Err — после, руками ничего закрывать не нужно. Такой стиль экономит сборку среза, когда элемент нужен только на время итерации — счётчики, запись в файл, агрегаты без удержания всей выборки в памяти.
Что напечатает фрагмент?
Прочитайте код и выберите, что он выведет:
rows, _ := pool.Query(ctx,
"SELECT id, title, created_at FROM notes WHERE author_id = 999")
notes, err := pgx.CollectRows(rows, pgx.RowToStructByName[Note])
fmt.Println(len(notes), err)Типичная ошибка — держать в репозитории SELECT и структуру, где поля отстают от схемы: колонку добавили в миграции, структуру забыли, и все списки упали с struct doesn't have corresponding row field — в тексте ошибки названа колонка, которой не нашлось места в структуре. Строгий маппинг по именам превращает расхождение в быстрый фейл — это плюс, но фейл всё же в рантайме, а не при компиляции. Моё спорное мнение, которое я озвучиваю на ревью: SELECT с RowToStructByName — запах в принципе, потому что состав структуры прячется в схеме базы; я пишу колонки явно и расхождение ловлю в тестах. Условие, при котором я прав: схема и структура принадлежат одному сервису. Для аналитических выборок поверх чужой схемы мягкий RowToStructByNameLax с лишними колонками честнее, чем сорок алиасов в каждом запросе.
RowToStructByPos и Lax: когда имена не спасают#
У сопоставления по именам есть цена: отражение, карта колонок в поля, кэш. RowToStructByPos[T] выбрасывает эту работу — он раскладывает колонки по публичным полям строго по порядку, как обычный Scan, и требует, чтобы число колонок не превышало число публичных полей структуры: больше значений, чем полей — ошибка got N values, but dst struct has only M fields. Взамен — минимум накладных расходов и никаких сюрпризов с именами: порядок приёмников в Scan и порядок полей структуры становятся контрактом, который ломается молча при перестановке колонок в SELECT. Я беру ByPos на горячих путях с фиксированным SELECT, где каждая строка проверена тестом, и ByName — везде, где читаемость важнее наносекунд. Переезд с одного на другой — механическая замена одного имени, поэтому решение легко пересмотреть, если профиль нагрузки изменится. Lax-варианты — RowToStructByNameLax и RowToAddrOfStructByNameLax — снимают требование полноты: колонки, которым не нашлось поля, просто пропускаются. Это делает их совместимыми с SELECT * и с запросами, которые формируются динамически. Аналогично RowToAddrOfStructByPos возвращает указатели. Пара gotcha на память: встроенные структуры разворачиваются в маппинге — поля внутри них участвуют как свои; db-тег со значением минус исключает поле и из позиционного маппинга, и из именного.
Проверка: контракт CollectRows
Чем CollectRows отличается от ручного цикла rows.Next()?
Правила выбора коллектора одним списком#
- Список структур с читаемым SQL: CollectRows + RowToStructByName
- Горячий путь с фиксированным SELECT и тестами: RowToStructByPos
- Нужны указатели на элементы: RowToAddrOfStructByName или Lax-вариант
- Одна строка с гарантией отсутствия дублей: CollectExactlyOneRow
- Одна строка без строгости к дублям: CollectOneRow
- Большие выборки без удержания среза: ForEachRow с колбэком
- Плоские выборки одной колонки: RowTo[int64] и компания
- Чужая схема и SELECT *: RowToStructByNameLax, чтобы лишние колонки не роняли запрос
Проверка: лишняя колонка
SELECT возвращает три колонки, а у структуры Note — два публичных поля. Что произойдёт с RowToStructByName?
Соберите выборку структур
Восстановите вызовы: сборка среза, коллектор по именам и проверка пустоты.
Соберите репозиторий-метод
Расставьте строки метода со сборкой структур в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
CollectOneRow и CollectExactlyOneRow: одна строка с гарантиями#
QueryRow из пятого урока закрывает тему «ровно одной строки» наполовину: он возвращает ErrNoRows, но молча пропускает вторую строку — SELECT без UNIQUE-ограничения мог принести неожиданный дубль, и никто бы не узнал. Обобщённая пара решает оба случая явно. CollectOneRow(rows, fn) берёт первую строку, применяет коллектор и возвращает ErrNoRows на пустоте — прямой аналог QueryRow, но с любым коллектором, включая структуры. CollectExactlyOneRow(rows, fn) дополнительно требует, чтобы строк была ровно одна: вторая строка приносит ErrTooManyRows, для которого errors.Is работает так же, как для ErrNoRows. Сценарий, где это не паранойя, а контракт: поиск пользователя по логину перед созданием — вторая строка означает битое ограничение уникальности, и лучше упасть с внятной ошибкой, чем неявно работать с первой попавшейся. Сюда же просится семнадцатый урок про 23505 — там конфликты уникальности разбираются как штатная ветка кода. Помните, что оба вызова закрывают курсор сами, в том числе при раннем выходе по ошибке, — defer rows.Close() не нужен и здесь.
Карточки: коллекторы pgx
Честная оговорка по запуску: в песочнице сайта исполняется тематическая программа на чистом Go — обобщения и срезы можно потрогать без сети, но CollectRows с настоящим курсором живёт только рядом с базой. Локальный набор: Go, контейнер Postgres из первого урока и таблица notes из пятого — с ними этот урок проходится за двадцать минут, включая все интерактивы. Практический итог для слоя репозитория: методы списков держите на CollectRows с ByName, методы «одной записи» — на CollectExactlyOneRow, а ручной цикл оставьте для мест, где строка превращается во что-то нестандартное — например, агрегирует поля по условию. Дальше по курсу: в восьмом уроке — аргументы и плейсхолдеры, которые защищают запросы от инъекций, а в пятом — фундамент Exec, Query и QueryRow, если что-то из сигнатур сегодня показалось незнакомым. Сгенерированные sqlc-методы выглядят почти как этот код — курс по sqlc покажет, почему.
Через адаптер stdlib из девятого урока всё это недоступно: интерфейс database/sql не знает про обобщения и коллекторы — там остаются ручные циклы или сторонние обёртки. Это одна из реальных причин выбрать нативный API, а не «совместимость на всякий случай»: CollectRows — не сахар, а слой безопасности против забытых Close и Err.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- CollectRows собирает срез структур через функцию-коллектор и закрывает курсор сама.
- RowToStructByName мапит колонки по именам регистронезависимо, db-тег переименовывает, db:"-" исключает.
- Лишняя колонка в строгом ByName — ошибка; RowToStructByNameLax пропускает лишнее.
- RowToStructByPos мапит по позиции — быстрее, но порядок колонок становится контрактом.
- Пустая выборка даёт пустой срез нулевой длины, а не ErrNoRows.
- CollectOneRow — аналог QueryRow; CollectExactlyOneRow добавляет ErrTooManyRows для второй строки.
- ForEachRow — тот же коллектор с колбэком вместо сборки среза.
Домашнее задание
Перепишите три запроса из пятого урока на CollectRows с RowToStructByName: список всех заметок, список по автору и выборку с JOIN на таблицу users — для JOIN-а понадобится либо db-тег, либо алиасы колонок. Затем сломайте один запрос нарочно: добавьте в SELECT колонку, которой нет в структуре, и прочитайте текст ошибки. В конце замените один QueryRow на CollectExactlyOneRow с дублем в данных и убедитесь, что ErrTooManyRows ловится errors.Is, а пустая выборка CollectRows возвращает len 0 без ошибки — проверьте оба края в одном тесте.
Личные заметкиПусто
Вопросы и ответы по уроку
CollectRows или ручной цикл — что быстрее?
Разница исчезающе мала: внутри CollectRows тот же цикл Next и Scan, а маппинг полей кэшируется — у именного и позиционного коллектора свои карты, каждая по типу структуры. Кэш живёт один на процесс: первая строка с новым набором колонок строит карту, остальные её переиспользуют. Выбирать стоит по читаемости, а по скорости — только ByPos на подтверждённо горячем пути. Ручной цикл оставьте для нестандартных преобразований строки: смешанных приёмников, условной логики заполнения, сторис-обработки.
Как сканировать структуру, если SELECT возвращает JOIN из двух таблиц?
Три пути: алиасы колонок, чтобы имена разошлись и легли в одну структуру с db-тегами; плоская структура под конкретный JOIN; или вложенные структуры — коллектор разворачивает встроенные структуры как свои поля. Отдельные объекты для каждой таблицы CollectRows не собирает — это делается вручную поверх плоских строк, и в типовом коде такой необходимости почти не возникает.
Почему RowToStructByName требует поля под каждую колонку?
Контракт строгого маппинга: структура описывает весь результат запроса, и любая колонка без поля — признак рассинхрона кода и схемы. Такой фейл ловится первыми же тестами и предотвращает молчаливую потерю данных, поэтому по умолчанию стоит не Lax, а строгий вариант — мягкость включается там, где она осознанна.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup