Перейти к содержанию

Подключение PostgreSQL через пул

В базе уже существует схема DeskQueue, но сервер JavaScript по-прежнему не умеет читать её строки. Между процессом приложения и PostgreSQL нужен клиент: он устанавливает соединение, передаёт команду и возвращает результат. В этом уроке добавим pg, известный как node-postgres, и разберём одну операцию чтения. Материал является дополнением по первичной документации драйвера; используем PostgreSQL 18 и семейство pg 8.

Начальное состояние — stages/10 с применённой миграцией 001-core.sql. В состоянии stages/11 добавлены db.js и отдельная программа inspect.js. HTTP-сервер пока не зависит от базы. Такая промежуточная ступень полезна: ошибка адреса сервера, пароль роли и ошибка SQL являются разными причинами, и сначала мы учимся различать их без маршрутов, токенов и других слоёв.

Соединение и пул

Одно соединение PostgreSQL обслуживает последовательность команд. Если каждый HTTP-запрос создаёт собственное соединение и забывает его закрыть, число подключений растёт. Если все запросы принудительно используют одно соединение, они ждут друг друга. Пул держит ограниченный набор клиентов и выдаёт свободного для очередной операции. Это управление ресурсом внутри процесса, а не встроенная база данных и не кеш результатов.

Полный файл db.js создаёт один пул на всё приложение. Значение DATABASE_URL читатель задаёт в своём учебном окружении. Строку подключения нельзя включать в публичный ответ или журнал: она может содержать пароль. Локальный файл .env.example содержит только образец с нейтральными значениями, а настоящая конфигурация хранится отдельно от исходников.

import pg from 'pg';
const { Pool } = pg;
if (!process.env.DATABASE_URL) {
  throw new Error('DATABASE_URL is required');
}
export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,
  connectionTimeoutMillis: 3000,
  idleTimeoutMillis: 30000
});
pool.on('error', (error) => {
  console.error(JSON.stringify({ event: 'db_idle_error', code: error.code }));
});

max: 5 — учебное ограничение одного процесса. Десять таких процессов потенциально используют уже пятьдесят соединений. Поэтому нельзя выбирать число, глядя только на один экземпляр приложения. Требуется учитывать предел PostgreSQL, служебные подключения, число реплик и длительность запросов. Увеличение пула не устраняет медленный SQL и иногда усиливает нагрузку на базу. Назначение параметров описано в документации пула node-postgres.

Тайм-аут подключения ограничивает ожидание получения соединения. Он не равнозначен ограничению времени каждого SQL-запроса. Отдельно существует statement_timeout PostgreSQL; его политику задают осознанно на стороне роли, сессии или команды. Пока короткий запрос позволяет сосредоточиться на возврате результата. Событие error относится в том числе к клиентам, которые простаивают в пуле: ошибка может возникнуть вне конкретного обработчика HTTP.

Данные отдельно от команды

Полная программа inspect.js получает идентификатор заявки из аргумента и выполняет один запрос. Последний блок закрывает пул даже при ошибке, поэтому служебная программа не остаётся ждать открытые сокеты после завершения работы.

import { pool } from './db.js';
const id = Number(process.argv[2] ?? 1);
try {
  if (!Number.isSafeInteger(id) || id < 1) throw new Error('Invalid ticket id');
  const result = await pool.query(
    'SELECT id, title, status FROM tickets WHERE id = $1',
    [id]
  );
  console.log(JSON.stringify(result.rows));
} finally {
  await pool.end();
}

$1 является позицией значения, а массив [id] передаёт само значение. Строка SQL остаётся неизменной. Это принципиальное отличие от вставки пользовательского ввода через шаблонную строку. Даже если заголовок содержит апостроф или текст, похожий на SQL, драйвер передаст его как данные. Для синтаксиса таблиц, имён столбцов и направления сортировки такие параметры не работают; динамические имена требуют отдельного разрешённого набора. Подробности приведены в руководстве по запросам node-postgres.

Программа выводит массив, потому что result.rows описывает найденные строки. Для существующей заявки ожидается объект с тремя полями внутри массива. Для отсутствующей — пустой массив. Отсутствие записи не является ошибкой соединения и не обязано порождать исключение драйвера. В HTTP-слое позднее появится решение вернуть 404; хранилище сначала сообщает факт, не назначая ему код протокола.

Обратите внимание на преобразование аргумента. Значение из командной строки всегда приходит строкой. Number переводит его в число, а последующая проверка отвергает дробь, бесконечность и отрицательное значение. Нельзя считать, что любое успешно преобразованное число подходит столбцу integer: у PostgreSQL есть собственные пределы этого типа. В публичном маршруте мы дополнительно ограничим идентификатор положительным диапазоном до 2147483647.

Возврат ресурса и наблюдение ошибок

Для одиночного запроса используем pool.query. Драйвер сам берёт клиента и возвращает его в пул. Нам не требуется вручную вызывать release. Для нескольких связанных команд внутри транзакции этот удобный метод не подходит: разные вызовы могут попасть на разные соединения. Соответствующую границу разберём в уроке о транзакциях. Пока правило простое: самостоятельная операция — pool.query, связанная последовательность — явно полученный клиент.

Если адрес базы неверный, результатом будет ошибка подключения, а не пустой список заявок. Если роль подключилась, но не имеет прав чтения, PostgreSQL вернёт другую ошибку. Если таблица отсутствует, проблема находится в состоянии схемы. Эти случаи требуют разных исправлений. Полезно сохранять код ошибки и идентификатор запроса, но не публиковать полный текст SQL вместе с секретами или внутренней конфигурацией.

Также различайте закрытие пула и возвращение одного клиента. pool.end() используется при завершении всего процесса или отдельной служебной программы. Вызывать его после каждого HTTP-запроса нельзя: следующий запрос лишится действующего пула. Управление жизненным циклом должно соответствовать времени жизни владельца ресурса. Поэтому окончательное HTTP-приложение создаст пул при старте и закроет при завершении сервера.

Ожидаемый итог урока — самостоятельное чтение строки через параметризованный запрос. На подготовленном стенде вы сможете сопоставить три ситуации: найденная заявка, отсутствие строки и ошибка подключения. Здесь запросы не выполнялись. Теперь можно заменить хранилище в памяти SQL-реализацией и сохранить уже знакомое поведение контроллеров.

Объект результата содержит не только строки. Для некоторых команд важен rowCount, который сообщает число затронутых записей. Это не универсальная замена длине rows: команда без RETURNING может изменить строки и не вернуть их содержимое. Поэтому функция хранилища выбирает показатель согласно смыслу операции. Для чтения нам нужен массив, для удаления может понадобиться факт изменения, для создания — назначенный идентификатор.

Клиент драйвера также преобразует некоторые типы PostgreSQL в значения JavaScript. Не предполагайте, что любой числовой столбец автоматически станет безопасным целым числом языка. Например, большие целые требуют отдельной политики представления. Мы выбрали integer и явный диапазон идентификаторов, чтобы не вводить эту сложность незаметно. При расширении схемы тип ответа проверяется как часть API.

Если в программе используется несколько отдельных модулей, они импортируют один экспортированный пул. Создание нового пула в каждой функции разрушило бы учебное ограничение max и усложнило закрытие. Следите за владельцем ресурса: один процесс приложения имеет один выбранный пул, а отдельная служебная программа — собственное время жизни и закрытие.

Оглавление курса

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