همه نوشته‌ها

اتصال استاندارد Node.js به PostgreSQL

اتصال استاندارد Node.js به PostgreSQL؛ تفاوت Client، Connection، Session و Pool

nodejs-pg-standard-connection

اتصال یک برنامه Node.js به PostgreSQL در نگاه اول ساده به نظر می‌رسد: یک Client می‌سازیم، متد connect() را اجرا می‌کنیم، Query می‌زنیم و در پایان اتصال را می‌بندیم.

این روش برای یک اسکریپت کوتاه کاملاً قابل‌قبول است؛ اما وقتی برنامه ما یک API واقعی باشد و هم‌زمان چندین Request دریافت کند، موضوع پیچیده‌تر می‌شود. در چنین برنامه‌ای باید دقیقاً بدانیم Client، Connection، Session و Pool چه هستند، چه ارتباطی با یکدیگر دارند و هرکدام در کجای معماری قرار می‌گیرند.

مدل پیشنهادی برای بیشتر APIهای Node.js این است: در هر Process برنامه یک Pool مشترک بساز، برای Queryهای مستقل از pool.query() استفاده کن و فقط زمانی Client را مستقیماً از Pool بگیر که چند عملیات باید روی یک Session مشخص اجرا شوند.


مشکل ساخت Connection برای هر Request

فرض کنیم در یک برنامه Express برای هر Request یک Client جدید ایجاد کنیم:

import express from "express";
import { Client } from "pg";

const app = express();

app.get("/users", async (_request, response, next) => {
  const client = new Client({
    connectionString: process.env.DATABASE_URL,
  });

  try {
    await client.connect();

    const result = await client.query(
      "SELECT id, username, email FROM users",
    );

    response.json(result.rows);
  } catch (error) {
    next(error);
  } finally {
    await client.end();
  }
});

این کد از نظر عملکرد منطقی است؛ اتصال را ایجاد می‌کند، Query را اجرا می‌کند و در پایان آن را می‌بندد. مشکل زمانی شروع می‌شود که این کار برای تک‌تک Requestها تکرار شود.

برای هر Request تقریباً این مراحل اتفاق می‌افتند:

  1. یک Object از نوع Client ساخته می‌شود.
  2. یک Connection واقعی به PostgreSQL ایجاد می‌شود.
  3. در اتصال شبکه‌ای، TCP handshake انجام می‌شود.
  4. اگر TLS فعال باشد، TLS handshake نیز انجام می‌شود.
  5. PostgreSQL کاربر را احراز هویت می‌کند.
  6. یک PostgreSQL Session شکل می‌گیرد.
  7. Query اجرا می‌شود.
  8. Connection بسته می‌شود.
  9. Session سمت PostgreSQL پایان پیدا می‌کند.
نمای ساده این فرایند:
Request
ساخت Client
ایجاد Connection
Authentication و ساخت Session
اجرای Query
بستن Connection

ساخت Connection جدید هزینه دارد. این هزینه فقط زمان اجرای SELECT نیست؛ برقراری ارتباط، احراز هویت و ایجاد وضعیت سمت PostgreSQL نیز بخشی از آن است.

ساخت Connection جدید هزینه دارد. این هزینه فقط زمان اجرای SELECT نیست؛ برقراری کانکشن، احراز هویت و ایجاد وضعیت سمت PostgreSQL نیز بخشی از آن است.

اگر ۱۰۰ Request تقریباً هم‌زمان برسند، برنامه تلاش می‌کند بارها این فرایند را تکرار کند:

Request 1   → Connect → Query → Disconnect
Request 2   → Connect → Query → Disconnect
Request 3   → Connect → Query → Disconnect
...
Request 100 → Connect → Query → Disconnect

این مدل سه مشکل اصلی دارد:

  • زمان پاسخ افزایش پیدا می‌کند.
  • PostgreSQL دائماً Connection و Session جدید می‌سازد و حذف می‌کند.
  • کنترل تعداد Connectionهای هم‌زمان دشوار می‌شود.

برای یک Migration، Seed، ابزار CLI یا اسکریپتی که یک بار اجرا می‌شود، چنین مدلی می‌تواند مناسب باشد؛ اما برای یک Web API که دائماً Request دریافت می‌کند، بهتر است Connectionها دوباره استفاده شوند. این دقیقاً همان مسئله‌ای است که Connection Pool حل می‌کند.


چهار مفهوم اصلی اتصال به PostgreSQL

قبل از بررسی Pool باید این چهار مفهوم را از هم جدا کنیم:

Client
Connection
Session
Pool

این مفاهیم مرتبط‌اند، اما مترادف نیستند.

مدل ساده ارتباط آن‌ها:
کد Node.js
Client
Connection
PostgreSQL Session

و وقتی Pool وارد معماری می‌شود:

Pool
├── Client 1
│   └── Connection 1
│       └── Session 1
├── Client 2
│   └── Connection 2
│       └── Session 2
└── Client 3
    └── Connection 3
        └── Session 3

حالا هرکدام را دقیق‌تر بررسی کنیم.


Client چیست؟

در پکیج pg یا همان node-postgres، در واقع Client یک Object جاوااسکریپتی داخل برنامه Node.js است.

import { Client } from "pg";

const client = new Client({
  connectionString: process.env.DATABASE_URL,
});

در این مرحله فقط Object ساخته شده است. هنوز الزاماً Connection فعالی با PostgreSQL وجود ندارد.

با اجرای connect()، Client تلاش می‌کند ارتباط را برقرار کند:

await client.connect();

بعد می‌توانیم Query اجرا کنیم:

const result = await client.query(
  "SELECT NOW() AS current_time",
);

و در پایان اتصال را کاملاً ببندیم:

await client.end();

پس Client:

  • یک Object داخل برنامه Node.js است.
  • بخشی از کتابخانه pg است.
  • خودش PostgreSQL نیست.
  • خودش کانال شبکه‌ای نیست.
  • رابط برنامه برای ایجاد و کنترل یک Connection است.
  • Query را به پروتکل قابل‌فهم برای PostgreSQL تبدیل و ارسال می‌کند.
  • پاسخ PostgreSQL را دریافت و به ساختارهای جاوااسکریپتی تبدیل می‌کند.

مدل ذهنی:

Application Code
      ↓
pg.Client Object
      ↓
Connection
      ↓
PostgreSQL

می‌توان Client را شبیه یک کنترل‌کننده در نظر گرفت. برنامه با این Object کار می‌کند و Client جزئیات ارتباط با PostgreSQL را مدیریت می‌کند.


Connection چیست؟

Connection کانال ارتباطی واقعی بین برنامه Node.js و PostgreSQL است.

اگر برنامه و دیتابیس از طریق network با هم ارتباط داشته باشند، این Connection معمولاً یک TCP/IP Connection است:

Node.js
   ↕ TCP/IP Connection
PostgreSQL

اگر هر دو Process روی یک سیستم Unix-like باشند، امکان استفاده از Unix Domain Socket نیز وجود دارد:

Node.js
   ↕ Unix Domain Socket
PostgreSQL

بنابراین Connection ممکن است یکی از این‌ها باشد:

TCP/IP Connection
Unix Domain Socket

Connection مسئول انتقال داده است. وقتی این کد را اجرا می‌کنیم:

await client.query(
  "SELECT id, username FROM users WHERE id = $1",
  [userId],
);

Client فرمان SQL و پارامترها را طبق پروتکل PostgreSQL آماده می‌کند و از طریق Connection می‌فرستد. PostgreSQL نیز نتیجه را از همان Connection برمی‌گرداند.

تفاوت مهم:

توضیحمفهوم
کلاس موجود در پکیج pg که از آن یک شیء مثل client ساخته می‌شودClient
ارتباط واقعی شبکه‌ای میان client و سرور PostgreSQLConnection

الان نیازی نیست توی جزئیات TCP/IP، Unix Socket، TLS و Handshake عمیق بشیم. فعلاً کافی است بدانیم Client از طریق یک Connection با PostgreSQL ارتباط برقرار می‌کند.


PostgreSQL Session چیست؟

هنگامی که Connection با موفقیت برقرار می‌شود و مراحل ابتدایی اتصال و احراز هویت تکمیل می‌شوند، PostgreSQL برای آن ارتباط یک Session در نظر می‌گیرد.

Session وضعیت سمت PostgreSQL است که در طول عمر Connection نگه داشته می‌شود.

Client
   ↓
Connection
   ↓
PostgreSQL Session

Session می‌تواند اطلاعات و وضعیت‌هایی مانند موارد زیر را نگه دارد:

  • کاربری که با آن متصل شده‌ایم
  • دیتابیسی که انتخاب شده است
  • Transaction فعلی
  • تنظیمات اعمال‌شده با SET
  • Temporary Tableها
  • Cursorهای تعریف‌شده
  • Channelهایی که با LISTEN دنبال می‌شوند
  • Session-level Advisory Lockها
  • Prepared Statementها
  • بعضی تنظیمات مربوط به فرمت تاریخ، Timezone یا Encoding

برای مثال، اگر روی یک Session این دستور اجرا شود:

SET TIME ZONE 'UTC';

این تنظیم به همان Session مربوط است. Session دیگری الزاماً این تنظیم را ندارد.

یا اگر Temporary Table بسازیم:

CREATE TEMP TABLE imported_users (
  id UUID,
  email TEXT
);

این Table موقت فقط در همان Session وجود دارد.

همچنین Transaction نیز متعلق به Session است:

BEGIN;

PostgreSQL باید بداند Queryهای بعدی متعلق به کدام Transaction هستند؛ این وضعیت در همان Session نگه داشته می‌شود.

این نکته در ادامه بسیار مهم خواهد بود:

هر Connection فعال معمولاً به یک Session مشخص در PostgreSQL مربوط است و وضعیت‌های Session میان Connectionهای مختلف مشترک نیستند.

اگر Connection بسته شود، Session نیز پایان پیدا می‌کند و وضعیت‌های وابسته به آن از بین می‌روند.


Pool چیست؟

Pool یک مدیر برای مجموعه‌ای از Clientها و Connectionهاست.

Pool
├── Client 1 → Connection 1 → Session 1
├── Client 2 → Connection 2 → Session 2
└── Client 3 → Connection 3 → Session 3

خود Pool:

  • Connection نیست.
  • Session نیست.
  • یک Query خاص نیست.
  • یک Client تکی نیست.

Pool یک Object مدیریتی داخل برنامه است که وظایف زیر را انجام می‌دهد:

  • Client جدید ایجاد می‌کند.
  • Connectionهای ایجادشده را نگه می‌دارد.
  • Client آزاد را برای اجرای Query انتخاب می‌کند.
  • در صورت نیاز و تا سقف تعیین‌شده Client جدید می‌سازد.
  • Clientهای آزادشده را برای استفاده مجدد نگه می‌دارد.
  • درخواست‌های منتظر Client را صف‌بندی می‌کند.
  • Connectionهای Idle را براساس تنظیمات می‌بندد.
  • وضعیت کلی Clientهای فعال و آزاد را مدیریت می‌کند.

ساخت یک Pool:

import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

Pool معمولاً تمام Connectionهای ممکن را بلافاصله در Startup ایجاد نمی‌کند. Connectionها هنگام نیاز ساخته می‌شوند و بعد از پایان Query می‌توانند برای درخواست‌های بعدی دوباره استفاده شوند.

فرض کنیم Pool سه Connection دارد:

Pool
├── Client 1 → آزاد
├── Client 2 → مشغول
└── Client 3 → آزاد

وقتی Query جدیدی می‌رسد، Pool یکی از Clientهای آزاد را انتخاب می‌کند:

مسیر اجرای Query جدید:
Query جدید
Pool
Client 1
PostgreSQL

پس از پایان Query، Client به Pool برمی‌گردد:

پایان اجرای Query:
Query تمام شد
Client 1 آزاد شد
آمادهٔ استفادهٔ مجدد

به این ترتیب دیگر لازم نیست برای هر Query یک Connection کاملاً جدید ایجاد شود.


رابطه کامل Pool، Client، Connection و Session

مدل دقیق‌تر:

Node.js Process
│
└── Pool
    │
    ├── Client 1
    │   └── TCP/IP یا Unix Socket Connection 1
    │       └── PostgreSQL Session 1
    │
    ├── Client 2
    │   └── TCP/IP یا Unix Socket Connection 2
    │       └── PostgreSQL Session 2
    │
    └── Client 3
        └── TCP/IP یا Unix Socket Connection 3
            └── PostgreSQL Session 3

هر لایه مسئولیت متفاوتی دارد:

مفهوممحل قرارگیریمسئولیت
Poolداخل Node.jsمدیریت چند Client و Connection
Clientداخل Node.js و کتابخانه pgرابط برنامه برای کنترل یک Connection و اجرای Query
Connectionمیان Node.js و PostgreSQLانتقال داده از طریق TCP/IP یا Unix Socket
Sessionسمت PostgreSQLنگهداری وضعیت اتصال مانند Transaction، تنظیمات و Temporary Table

یک تشبیه ساده:

مفهومتوضیح
Poolمدیر مجموعه‌ای از اتصال‌ها
Clientابزاری برای مدیریت و استفاده از یک اتصال
Connectionارتباط واقعی برقرارشده میان برنامه و PostgreSQL
Sessionوضعیت و تعامل مربوط به همان اتصال در سمت PostgreSQL

تشبیه کامل نیست، اما برای جدا کردن مفاهیم مفید است.


یک Pool به‌ازای هر Request یا هر Process؟

Pool نباید به‌ازای هر Request ساخته شود.

مدل غلط:

app.get("/users", async (_request, response) => {
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  const result = await pool.query(
    "SELECT id, username FROM users",
  );

  await pool.end();

  response.json(result.rows);
});

در این مدل برای هر Request یک Pool جدید ساخته و سپس بسته می‌شود. با این کار عملاً مزیت Connection Pooling را از بین می‌بریم.

مدل درست:

Node.js Process
│
├── Request A ─┐
├── Request B ─┼──> یک Pool مشترک
├── Request C ─┤
└── Request D ─┘

یعنی:

در هر Node.js Process معمولاً فقط یک Pool ساخته می‌شود و تمام Requestهایی که توسط همان Process پردازش می‌شوند از آن استفاده می‌کنند.

مثلاً:

// database.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

سپس در بخش‌های مختلف برنامه همان Pool را Import می‌کنیم:

import { pool } from "./database.js";

منظور از Process چیست؟

وقتی برنامه را این‌طور اجرا می‌کنیم:

node dist/server.js

یک Node.js Process داریم:

Process 1
└── Pool 1

اما اگر با PM2 چهار نسخه از برنامه را اجرا کنیم:

pm2 start dist/server.js -i 4

چهار Process مستقل خواهیم داشت:

Process 1 → Pool 1
Process 2 → Pool 2
Process 3 → Pool 3
Process 4 → Pool 4

این چهار Process حافظه مشترک ندارند. بنابراین Pool میان آن‌ها مشترک نیست و هرکدام Pool خودش را می‌سازد.

در نتیجه عبارت «یک Pool در هر Process» به این معنی نیست که کل زیرساخت همیشه فقط یک Pool دارد. اگر چند Process، Pod، Container یا Instance داشته باشیم، هرکدام Pool مستقل خود را خواهند داشت.


رابطه Singleton و Pool

گاهی گفته می‌شود برای اتصال به PostgreSQL از Singleton استفاده کنیم. این جمله به‌تنهایی ممکن است گمراه‌کننده باشد، چون Singleton و Pool یک مسئله را حل نمی‌کنند.

Singleton چه چیزی را کنترل می‌کند؟

Singleton درباره تعداد Objectهای ساخته‌شده در حافظه برنامه است:

در هر Process فقط یک Pool Object ساخته شود

Pool چه چیزی را کنترل می‌کند؟

Pool درباره مدیریت چند Client و Connection است:

ساختار Pool:
یک شیء Pool
چند Client و Connection

پس این دو رقیب یکدیگر نیستند. مدل رایج در یک API واقعی چنین است:

ساختار پیشنهادی:
یک Pool مشترک در هر پردازش Node.js
چند Connection مدیریت‌شده داخل Pool

بنابراین جمله دقیق‌تر این است:

Pool را به‌صورت یک نمونه مشترک در هر Process نگه می‌داریم؛ خود Pool نیز چند Connection را مدیریت می‌کند.

چیزی که معمولاً مناسب نیست، استفاده از یک Client دائمی و مشترک برای تمام Requestها است:

ساختار ارتباط:
تمام Requestها
یک Client
یک Connection

در این مدل فقط یک Connection داریم، Queryها روی همان Session اجرا می‌شوند و عملیات وابسته به Session می‌توانند با Requestهای دیگر تداخل پیدا کنند. در مقاله Transaction این موضوع را با جزئیات بیشتری بررسی خواهیم کرد.


چرا معمولاً Singleton Class لازم نیست؟

ممکن است چنین کلاسی بنویسیم:

import { Pool } from "pg";

export class PostgresSingleton {
  private static instance: PostgresSingleton | undefined;

  public readonly pool: Pool;

  private constructor() {
    this.pool = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
  }

  public static getInstance(): PostgresSingleton {
    if (!PostgresSingleton.instance) {
      PostgresSingleton.instance = new PostgresSingleton();
    }

    return PostgresSingleton.instance;
  }
}

این کد می‌تواند کار کند؛ اما در بیشتر پروژه‌های Node.js ضروری نیست.

در ساختار معمول Moduleها، فایل دیتابیس یک بار در Module Graph همان Process ارزیابی می‌شود و Export آن دوباره استفاده خواهد شد:

// postgres.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

در فایل دیگر:

import { pool } from "./postgres.js";

تا زمانی که همه Importها به همان Module resolve شوند و عمداً Moduleهای تکراری یا Loaderهای جداگانه نسازیم، همان Pool داخل Process استفاده می‌شود.

ساخت Singleton Class می‌تواند:

  • کد را پیچیده‌تر کند.
  • تست‌نویسی را سخت‌تر کند.
  • جایگزین کردن Dependency را دشوارتر کند.
  • Dependency Injection را پیچیده‌تر کند.
  • بدون ایجاد مزیت واقعی، لایه اضافی بسازد.

برای بیشتر پروژه‌ها یک Module مرکزی کافی است:

// database/postgres.ts
export const pool = new Pool(config);

البته در معماری‌های Dependency Injection ممکن است Pool در Composition Root ساخته و به Adapterها تزریق شود. اصل موضوع تغییر نمی‌کند: در هر Process تعداد Poolها باید آگاهانه و کنترل‌شده باشد.


تفاوت pool.query() و pool.connect()

دو روش اصلی برای اجرای Query با Pool وجود دارد.

اجرای Query مستقل با pool.query()

برای بیشتر Queryهای مستقل بهتر است مستقیماً از pool.query() استفاده کنیم:

const result = await pool.query(
  `
    SELECT
      id,
      username,
      email
    FROM users
    WHERE id = $1
  `,
  [userId],
);

Pool در پشت صحنه:

  1. یک Client آزاد پیدا می‌کند.
  2. اگر Client آزاد نباشد و هنوز ظرفیت داشته باشد، Client جدید می‌سازد.
  3. Query را روی Client انتخاب‌شده اجرا می‌کند.
  4. پس از پایان Query، Client را خودکار به Pool برمی‌گرداند.

مدل:

روند اجرای pool.query():
pool.query()
گرفتن خودکار Client
اجرای Query
بازگرداندن خودکار Client به Pool

در این حالت نباید خودمان release() را صدا بزنیم، چون Pool این کار را انجام می‌دهد.

این روش برای موارد زیر مناسب است:

  • یک SELECT مستقل
  • یک INSERT مستقل
  • یک UPDATE مستقل
  • یک DELETE مستقل
  • هر عملیاتی که لازم نیست چند Query روی یک Session مشخص اجرا شوند

گرفتن Client با pool.connect()

گاهی چند عملیات باید روی یک Client و Session مشخص اجرا شوند. در این حالت Client را مستقیماً از Pool می‌گیریم:

const client = await pool.connect();

try {
  const userResult = await client.query(
    "SELECT id, username FROM users WHERE id = $1",
    [userId],
  );

  const taskResult = await client.query(
    "SELECT id, title FROM tasks WHERE user_id = $1",
    [userId],
  );

  return {
    user: userResult.rows[0] ?? null,
    tasks: taskResult.rows,
  };
} finally {
  client.release();
}

وقتی از pool.connect() استفاده می‌کنیم، مسئولیت بازگرداندن Client با خودمان است. به همین دلیل release() باید در finally قرار بگیرد تا حتی در صورت بروز خطا نیز اجرا شود.

مهم‌ترین کاربرد این روش Transaction است:

const client = await pool.connect();

try {
  await client.query("BEGIN");

  // تمام Queryهای Transaction با همین client

  await client.query("COMMIT");
} catch (error) {
  await client.query("ROLLBACK");
  throw error;
} finally {
  client.release();
}

چون Transaction متعلق به Session است، تمام Queryهای آن باید روی یک Client مشخص اجرا شوند. جزئیات کامل Transaction، BEGIN، COMMIT و ROLLBACK در مقاله مستقلی بررسی خواهند شد.

قانون ساده

یک Query مستقل:
pool.query()
چند عملیات وابسته به یک Session:
pool.connect()
استفاده از همان Client
client.release()

تفاوت release() و end()

این دو متد نباید با هم اشتباه گرفته شوند.

client.release()

وقتی Client را از Pool گرفته‌ایم:

const client = await pool.connect();

در پایان آن را Release می‌کنیم:

client.release();

release() معمولاً به این معنی است:

روند آزادسازی Client:
Client از حالت مشغول خارج می‌شود
به Pool برمی‌گردد
Connection برای استفادهٔ بعدی باز می‌ماند

یعنی Connection و Session الزاماً پایان پیدا نمی‌کنند. آن‌ها به Pool برمی‌گردند تا Query بعدی بتواند دوباره از همان Connection استفاده کند.

client.end()

وقتی یک Client مستقل ساخته‌ایم:

const client = new Client(config);
await client.connect();

با end() ارتباط را واقعاً می‌بندیم:

await client.end();

نتیجه:

نتیجهٔ بسته‌شدن Connection:
Connection بسته می‌شود
Session در سمت PostgreSQL پایان پیدا می‌کند

pool.end()

برای بستن کل Pool استفاده می‌شود:

await pool.end();

این متد برای پایان برنامه و Graceful Shutdown مناسب است، نه پایان هر Request.

خلاصه:

client.release()
Client را به Pool برمی‌گرداند
Connection برای استفادهٔ مجدد باز می‌ماند
client.end()
Connection مربوط به یک Client مستقل را می‌بندد
Session پایان پیدا می‌کند
pool.end()
Pool را تخلیه (Drain) می‌کند
Connectionهای داخل Pool بسته می‌شوند

ساختار پیشنهادی پروژه

برای یک پروژه Express و TypeScript می‌توان ساختار زیر را استفاده کرد:

src/
├── config/
│   └── env.ts
│
├── infrastructure/
│   └── database/
│       └── postgres.ts
│
├── modules/
│   └── users/
│       ├── user.repository.ts
│       ├── user.service.ts
│       ├── user.controller.ts
│       └── user.routes.ts
│
├── app.ts
└── server.ts

جریان Dependency:

HTTP Request
    ↓
Controller
    ↓
Service / Use Case
    ↓
Repository
    ↓
Database Adapter
    ↓
Pool
    ↓
PostgreSQL

Controller

مسئول HTTP است:

  • دریافت ورودی
  • خواندن Parameterها
  • ارسال Status Code
  • ساخت Response
  • فرستادن Error به Error Handler

Service یا Use Case

مسئول منطق Application و Business است:

  • تصمیم‌گیری درباره جریان عملیات
  • هماهنگی Repositoryها
  • اعمال Ruleهای Business
  • تعیین مرز Transaction در عملیات پیچیده

Repository

مسئول دسترسی به داده‌های یک بخش مشخص است:

  • Queryهای users
  • Queryهای tasks
  • تبدیل Rowهای دیتابیس به مدل موردنیاز برنامه

Database Adapter

یک لایه مرکزی برای ارتباط با Driver دیتابیس است:

  • اجرای Query
  • مدیریت Pool
  • بررسی سلامت اتصال
  • Graceful Shutdown
  • Logging و Metrics
  • Transaction helper در نسخه‌های پیشرفته‌تر

Pool

مدیریت Clientها و Connectionهای واقعی PostgreSQL را انجام می‌دهد.


پیاده‌سازی Pool مرکزی

ابتدا پکیج‌ها را نصب می‌کنیم:

npm install pg
npm install --save-dev @types/pg

در نسخه‌هایی که Typeها مستقیماً همراه پکیج ارائه می‌شوند ممکن است نیاز به @types/pg تغییر کند؛ تنظیم پروژه و نسخه مورد استفاده را بررسی کنید.

متغیر محیطی:

DATABASE_URL=postgresql://app_user:strong_password@127.0.0.1:5432/app_db
PG_POOL_MAX=10
PG_CONNECTION_TIMEOUT_MS=5000
PG_IDLE_TIMEOUT_MS=30000
APP_NAME=my-api

فایل مرکزی PostgreSQL:

// src/infrastructure/database/postgres.ts

import {
  Pool,
  type QueryResult,
  type QueryResultRow,
} from "pg";

function parsePositiveInteger(
  value: string | undefined,
  fallback: number,
): number {
  if (!value) {
    return fallback;
  }

  const parsedValue = Number.parseInt(value, 10);

  if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
    return fallback;
  }

  return parsedValue;
}

const databaseUrl = process.env.DATABASE_URL;

if (!databaseUrl) {
  throw new Error("DATABASE_URL is not defined");
}

const pool = new Pool({
  connectionString: databaseUrl,

  // حداکثر تعداد Clientهای این Pool
  max: parsePositiveInteger(process.env.PG_POOL_MAX, 10),

  // حداکثر زمان انتظار برای برقرار شدن Connection
  connectionTimeoutMillis: parsePositiveInteger(
    process.env.PG_CONNECTION_TIMEOUT_MS,
    5_000,
  ),

  // زمان نگهداری Client آزاد داخل Pool
  idleTimeoutMillis: parsePositiveInteger(
    process.env.PG_IDLE_TIMEOUT_MS,
    30_000,
  ),

  application_name: process.env.APP_NAME ?? "node-api",
});

pool.on("error", (error) => {
  console.error("Unexpected PostgreSQL pool error", {
    name: error.name,
    message: error.message,
    stack: error.stack,
  });
});

export async function query<
  T extends QueryResultRow = QueryResultRow,
>(
  text: string,
  values: unknown[] = [],
): Promise<QueryResult<T>> {
  const startedAt = performance.now();

  try {
    return await pool.query<T>(text, values);
  } finally {
    const durationMs = performance.now() - startedAt;

    if (durationMs >= 500) {
      console.warn("Slow PostgreSQL query detected", {
        durationMs: Math.round(durationMs),
      });
    }
  }
}

export async function checkDatabaseConnection(): Promise<void> {
  await pool.query("SELECT 1");
}

export async function closeDatabaseConnection(): Promise<void> {
  await pool.end();
}

export function getPoolStatistics() {
  return {
    total: pool.totalCount,
    idle: pool.idleCount,
    waiting: pool.waitingCount,
  };
}

export { pool };

چند نکته درباره این فایل:

  1. Pool فقط یک بار در Module مرکزی ساخته می‌شود.
  2. Repositoryها لازم نیست مستقیماً تنظیمات اتصال را بدانند.
  3. تابع query() نقطه مرکزی اجرای Queryهای مستقل است.
  4. Listener مربوط به error خطاهای غیرمنتظره Clientهای Pool را ثبت می‌کند.
  5. SELECT 1 برای بررسی اولیه در دسترس بودن PostgreSQL استفاده می‌شود.
  6. pool.end() برای Shutdown برنامه در دسترس است.
  7. آمار Pool برای Monitoring قابل دریافت است.
  8. پارامترهای Query عمداً داخل Log نوشته نشده‌اند تا اطلاعات حساس نشت نکند.

تنظیم دقیق max، idleTimeoutMillis، connectionTimeoutMillis و سایر گزینه‌های Pool به بار برنامه و معماری Deployment بستگی دارد که الان نیازی نیست بیش از این در این مورد دیپ بشیم


استفاده از Database Adapter در Repository

یک Repository ساده برای User:

// src/modules/users/user.repository.ts

import {
  query,
} from "../../infrastructure/database/postgres.js";

interface UserRow {
  id: string;
  username: string;
  email: string;
  first_name: string;
  last_name: string;
}

interface CreateUserInput {
  id: string;
  username: string;
  email: string;
  firstName: string;
  lastName: string;
  passwordHash: string;
}

export class UserRepository {
  async findById(id: string): Promise<UserRow | null> {
    const result = await query<UserRow>(
      `
        SELECT
          id,
          username,
          email,
          first_name,
          last_name
        FROM users
        WHERE id = $1
      `,
      [id],
    );

    return result.rows[0] ?? null;
  }

  async findByUsername(
    username: string,
  ): Promise<UserRow | null> {
    const result = await query<UserRow>(
      `
        SELECT
          id,
          username,
          email,
          first_name,
          last_name
        FROM users
        WHERE username = $1
      `,
      [username],
    );

    return result.rows[0] ?? null;
  }

  async create(input: CreateUserInput): Promise<UserRow> {
    const result = await query<UserRow>(
      `
        INSERT INTO users (
          id,
          username,
          email,
          first_name,
          last_name,
          password
        )
        VALUES ($1, $2, $3, $4, $5, $6)
        RETURNING
          id,
          username,
          email,
          first_name,
          last_name
      `,
      [
        input.id,
        input.username,
        input.email,
        input.firstName,
        input.lastName,
        input.passwordHash,
      ],
    );

    const createdUser = result.rows[0];

    if (!createdUser) {
      throw new Error("PostgreSQL did not return the created user");
    }

    return createdUser;
  }
}

در این ساختار Repository نمی‌داند Pool چگونه ساخته شده، Connection String چیست یا هنگام Shutdown چه اتفاقی می‌افتد. فقط API داخلی Database Adapter را استفاده می‌کند.

مزایای این جداسازی:

  • تنظیمات اتصال متمرکز می‌شوند.
  • تغییر Logging فقط در یک فایل انجام می‌شود.
  • Health Check از یک نقطه کنترل می‌شود.
  • Repositoryها ساده‌تر باقی می‌مانند.
  • Mock کردن Database Adapter در تست‌ها راحت‌تر است.
  • تغییرات Driver کمتر در کل پروژه پخش می‌شوند.

در معماری‌های بزرگ‌تر می‌توان به‌جای Import مستقیم تابع query، یک Interface تعریف کرد و Database Adapter را با Dependency Injection به Repository داد. اما برای شروع، همین ساختار مرکزی از Import کردن و ساختن Pool در هر Repository بسیار بهتر است.


Query پارامتری و جلوگیری از SQL Injection

هرگز ورودی کاربر را با String interpolation یا Concatenation مستقیماً وارد SQL نکنید.

روش ناامن

const result = await pool.query(
  `
    SELECT id, username
    FROM users
    WHERE username = '${username}'
  `,
);

فرض کنیم مقدار username توسط کاربر کنترل شود. کاربر می‌تواند ورودی‌ای بسازد که ساختار SQL را تغییر دهد.

روش درست

const result = await pool.query(
  `
    SELECT id, username
    FROM users
    WHERE username = $1
  `,
  [username],
);

در Query پارامتری، متن SQL و Valueها جداگانه ارسال می‌شوند:

SQL:
SELECT ... WHERE username = $1

Values:
[username]

PostgreSQL مقدار را به‌عنوان Data پردازش می‌کند، نه بخشی از ساختار SQL.

برای چند Parameter:

const result = await pool.query(
  `
    SELECT
      id,
      username,
      email
    FROM users
    WHERE email = $1
      AND status = $2
  `,
  [email, "ACTIVE"],
);

شماره Placeholderها از یک شروع می‌شود:

$1 → email
$2 → ACTIVE

نکته درباره Identifierها

پارامترهای $1، $2 و ... برای Valueها هستند؛ نه نام Table یا Column.

این مدل کار نمی‌کند:

await pool.query(
  "SELECT * FROM $1",
  [tableName],
);

اگر نام Table یا Column باید پویا باشد، بهتر است:

  • از Allowlist مشخص استفاده شود.
  • Identifierها براساس ورودی آزاد کاربر ساخته نشوند.
  • از ابزار مناسب برای Escape کردن Identifier استفاده شود.
  • طراحی Query بازنگری شود تا Dynamic SQL غیرضروری حذف شود.

بررسی اتصال هنگام Startup

اگر API بدون PostgreSQL قادر به انجام وظیفه اصلی خود نیست، بهتر است قبل از شروع Listen کردن HTTP Server، اتصال دیتابیس بررسی شود.

// src/server.ts

import app from "./app.js";
import {
  checkDatabaseConnection,
} from "./infrastructure/database/postgres.js";

const port = Number(process.env.PORT ?? 3000);

async function bootstrap(): Promise<void> {
  await checkDatabaseConnection();

  app.listen(port, () => {
    console.log(`HTTP server is running on port ${port}`);
  });
}

bootstrap().catch((error) => {
  console.error("Application startup failed", error);
  process.exit(1);
});

جریان:

شروع Process
    ↓
بررسی متغیرهای محیطی
    ↓
SELECT 1
    ↓
اتصال موفق؟
  ├── بله → HTTP Server بالا بیاید
  └── خیر → Process با خطا خارج شود

مزیت این روش این است که برنامه ظاهراً Up نمی‌شود درحالی‌که هیچ Queryای نمی‌تواند اجرا کند.

البته این تصمیم معماری است. بعضی سرویس‌ها ممکن است بدون دیتابیس نیز بخشی از قابلیت‌های خود را ارائه دهند یا بخواهند بعداً اتصال را Retry کنند. اما برای بیشتر APIهای وابسته به PostgreSQL، Fail Fast هنگام Startup رفتار قابل‌فهم و قابل‌مانیتورتری است.


Graceful Shutdown

وقتی Process سیگنال پایان دریافت می‌کند، بهتر است ناگهانی خارج نشود. باید:

  1. دریافت Request جدید متوقف شود.
  2. Requestهای در حال اجرا فرصت اتمام پیدا کنند.
  3. Pool بسته شود.
  4. Connectionهای PostgreSQL پایان پیدا کنند.
  5. Process خارج شود.

سیگنال‌های رایج:

سیگنالمعمولاً از طرف
SIGINTفشردن Ctrl + C در ترمینال
SIGTERMابزارهایی مثل PM2، Docker، Kubernetes یا سیستم‌عامل

پیاده‌سازی:

// src/server.ts

import app from "./app.js";
import {
  checkDatabaseConnection,
  closeDatabaseConnection,
} from "./infrastructure/database/postgres.js";

const port = Number(process.env.PORT ?? 3000);

async function bootstrap(): Promise<void> {
  await checkDatabaseConnection();

  const server = app.listen(port, () => {
    console.log(`HTTP server is running on port ${port}`);
  });

  let isShuttingDown = false;

  async function shutdown(signal: string): Promise<void> {
    if (isShuttingDown) {
      return;
    }

    isShuttingDown = true;

    console.log(`${signal} received. Starting graceful shutdown.`);

    server.close(async (httpError) => {
      try {
        await closeDatabaseConnection();
      } catch (databaseError) {
        console.error(
          "Failed to close PostgreSQL pool",
          databaseError,
        );
      }

      if (httpError) {
        console.error(
          "HTTP server shutdown failed",
          httpError,
        );

        process.exit(1);
      }

      console.log("Application stopped successfully");
      process.exit(0);
    });
  }

  process.once("SIGINT", () => {
    void shutdown("SIGINT");
  });

  process.once("SIGTERM", () => {
    void shutdown("SIGTERM");
  });
}

bootstrap().catch((error) => {
  console.error("Application startup failed", error);
  process.exit(1);
});

در Production معمولاً یک Timeout اضطراری نیز برای Shutdown در نظر گرفته می‌شود تا اگر یک Request یا Resource برای همیشه گیر کرد، Process بعد از مدت مشخصی Force Exit شود. مقدار آن باید با Grace Period ابزار Deployment هماهنگ باشد.

چرا pool.end() را بعد از هر Query اجرا نمی‌کنیم؟

چون pool.end() برای پایان عمر Pool است. اگر بعد از هر Request آن را اجرا کنیم، Query بعدی دیگر نمی‌تواند از Pool استفاده کند و دوباره مجبور می‌شویم Pool بسازیم.

پایان Query:
Client به Pool برمی‌گردد
پایان Process:
pool.end()

اشتباه‌های رایج

۱. ساخت Pool داخل Route یا Controller

app.get("/users", async (_request, response) => {
  const pool = new Pool(config);
  const result = await pool.query("SELECT * FROM users");

  response.json(result.rows);
});

مشکل: برای هر Request Pool جدید ساخته می‌شود.


۲. ساخت Pool در هر Repository

export class UserRepository {
  private readonly pool = new Pool(config);
}

اگر ده Repository داشته باشیم، ممکن است ده Pool مستقل در یک Process ایجاد کنیم. هر Pool نیز چند Connection می‌سازد و مجموع Connectionها به‌سرعت افزایش پیدا می‌کند.

مدل درست:

ساختار دسترسی به دیتابیس:
تمام Repositoryها
Database Adapter مشترک
یک Pool در همان Process

۳. استفاده از یک Client دائمی برای همه Requestها

const client = new Client(config);
await client.connect();

export { client };

این کار برای بعضی Sessionهای اختصاصی مانند LISTEN می‌تواند عمدی باشد، اما برای تمام Queryهای یک API عمومی مناسب نیست.

مشکلات:

  • فقط یک Connection وجود دارد.
  • Queryها روی یک Session مشترک اجرا می‌شوند.
  • عملیات Session-bound می‌توانند روی Requestهای دیگر اثر بگذارند.
  • Transactionها ممکن است تداخل خطرناک ایجاد کنند.
  • خرابی همان Connection روی کل دسترسی دیتابیس اثر می‌گذارد.

۴. فراموش کردن release()

const client = await pool.connect();

await client.query("SELECT * FROM users");

// client.release() فراموش شده است

این Client به Pool برنمی‌گردد و به‌عنوان Client مشغول باقی می‌ماند. تکرار این اشتباه باعث Client Leak می‌شود و Requestهای جدید در انتظار Connection می‌مانند.

روش درست:

const client = await pool.connect();

try {
  await client.query("SELECT * FROM users");
} finally {
  client.release();
}

۵. استفاده بی‌دلیل از pool.connect()

برای یک Query مستقل نیازی نیست Client را دستی بگیریم:

const client = await pool.connect();

try {
  return await client.query(
    "SELECT * FROM users WHERE id = $1",
    [userId],
  );
} finally {
  client.release();
}

نسخه ساده‌تر:

return pool.query(
  "SELECT * FROM users WHERE id = $1",
  [userId],
);

pool.connect() زمانی ارزش دارد که واقعاً به یک Client و Session ثابت برای چند عملیات نیاز داریم.


۶. اجرای Transaction با چند pool.query()

این مدل خطرناک است:

await pool.query("BEGIN");
await pool.query("UPDATE accounts SET ...");
await pool.query("COMMIT");

Pool تضمین نمی‌کند تمام این دستورات روی یک Client اجرا شوند. Transaction باید با Client گرفته‌شده از pool.connect() اجرا شود.


۷. بستن Pool بعد از هر Request

await pool.query("SELECT ...");
await pool.end();

pool.end() برای Shutdown کل Application است، نه پایان Query.


۸. ساخت SQL با ورودی کاربر

const sql = `
  SELECT *
  FROM users
  WHERE email = '${email}'
`;

این روش برنامه را در معرض SQL Injection قرار می‌دهد. از Query پارامتری استفاده کنید.


۹. Log کردن Valueهای حساس Query

console.log({
  text,
  values,
});

آرایه values ممکن است شامل این موارد باشد:

  • Password hash
  • Refresh Token
  • Email
  • شماره تلفن
  • اطلاعات شخصی
  • Secretها

برای Logging بهتر است Query Name، زمان اجرا، تعداد Row و Error Code ثبت شوند و Valueها فقط با سیاست Redaction مشخص وارد Log شوند.


۱۰. تصور اینکه Singleton میان Processها مشترک است

اگر چهار Process PM2 داشته باشیم، هرکدام حافظه مستقل دارند:

Process 1 → Singleton خودش → Pool خودش
Process 2 → Singleton خودش → Pool خودش
Process 3 → Singleton خودش → Pool خودش
Process 4 → Singleton خودش → Pool خودش

Singleton فقط داخل همان Process معنا دارد.


راهنمای انتخاب Client یا Pool

سناریوانتخاب مناسب
Web API معمولییک Pool مشترک در هر Process
یک Query مستقلpool.query()
چند Query داخل Transactionpool.connect() و یک Client ثابت
عملیات وابسته به SessionClient ثابت
Script کوتاهClient مستقیم یا Pool کوتاه‌عمر
MigrationClient یا Pool کوتاه‌عمر
SeedClient یا Pool کوتاه‌عمر
CLIClient یا Pool کوتاه‌عمر
LISTEN / NOTIFYClient اختصاصی و طولانی‌عمر
Cursor یا Streaming طولانیClient اختصاصی از Pool یا Client مستقل
پایان برنامهpool.end()

قانون اصلی:

Query مستقل؟
→ pool.query()

Session ثابت لازم است؟
→ pool.connect()

برنامه کوتاه و یک‌بارمصرف است؟
→ Client مستقیم یا Pool کوتاه‌عمر

برنامه در حال Shutdown است؟
→ pool.end()

جمع‌بندی

مدل استاندارد اتصال یک API Node.js به PostgreSQL معمولاً چنین است:

Node.js Process
│
└── مشترک Pool یک
    ├── Client 1
    │   └── Connection 1
    │       └── PostgreSQL Session 1
    ├── Client 2
    │   └── Connection 2
    │       └── PostgreSQL Session 2
    └── Client N
        └── Connection N
            └── PostgreSQL Session N

نکات کلیدی:

  • برای هر Request Pool جدید نسازید.
  • در هر Process معمولاً یک Pool مشترک داشته باشید.
  • Pool و Singleton رقیب هم نیستند.
  • Pool چند Client و Connection را مدیریت می‌کند.
  • Client یک Object در برنامه Node.js است.
  • Connection کانال ارتباطی واقعی است.
  • Session وضعیت سمت PostgreSQL است.
  • برای Query مستقل از pool.query() استفاده کنید.
  • برای عملیات نیازمند Session ثابت از pool.connect() استفاده کنید.
  • Client گرفته‌شده از Pool را همیشه در finally آزاد کنید.
  • release() اتصال را نمی‌بندد؛ Client را به Pool برمی‌گرداند.
  • client.end() اتصال Client مستقل را می‌بندد.
  • pool.end() برای Shutdown کل Pool است.
  • Queryها را پارامتری بنویسید.
  • Pool را در یک Database Adapter مرکزی مدیریت کنید.
  • هنگام Startup اتصال را بررسی کنید.
  • هنگام Shutdown، HTTP Server و Pool را تمیز ببندید.
  • فراموش نکنید که هر Process، Pod یا Container Pool مستقل خودش را دارد.

داکیومنت های مفید