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

اتصال یک برنامه 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 تقریباً این مراحل اتفاق میافتند:
- یک Object از نوع
Clientساخته میشود. - یک Connection واقعی به PostgreSQL ایجاد میشود.
- در اتصال شبکهای، TCP handshake انجام میشود.
- اگر TLS فعال باشد، TLS handshake نیز انجام میشود.
- PostgreSQL کاربر را احراز هویت میکند.
- یک PostgreSQL Session شکل میگیرد.
- Query اجرا میشود.
- Connection بسته میشود.
- 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 و سرور PostgreSQL | Connection |
الان نیازی نیست توی جزئیات 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 در پشت صحنه:
- یک Client آزاد پیدا میکند.
- اگر Client آزاد نباشد و هنوز ظرفیت داشته باشد، Client جدید میسازد.
- Query را روی Client انتخابشده اجرا میکند.
- پس از پایان 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 };
چند نکته درباره این فایل:
- Pool فقط یک بار در Module مرکزی ساخته میشود.
- Repositoryها لازم نیست مستقیماً تنظیمات اتصال را بدانند.
- تابع
query()نقطه مرکزی اجرای Queryهای مستقل است. - Listener مربوط به
errorخطاهای غیرمنتظره Clientهای Pool را ثبت میکند. SELECT 1برای بررسی اولیه در دسترس بودن PostgreSQL استفاده میشود.pool.end()برای Shutdown برنامه در دسترس است.- آمار Pool برای Monitoring قابل دریافت است.
- پارامترهای 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 سیگنال پایان دریافت میکند، بهتر است ناگهانی خارج نشود. باید:
- دریافت Request جدید متوقف شود.
- Requestهای در حال اجرا فرصت اتمام پیدا کنند.
- Pool بسته شود.
- Connectionهای PostgreSQL پایان پیدا کنند.
- 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
- شماره تلفن
- اطلاعات شخصی
- 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 داخل Transaction | pool.connect() و یک Client ثابت |
| عملیات وابسته به Session | Client ثابت |
| Script کوتاه | Client مستقیم یا Pool کوتاهعمر |
| Migration | Client یا Pool کوتاهعمر |
| Seed | Client یا Pool کوتاهعمر |
| CLI | Client یا Pool کوتاهعمر |
LISTEN / NOTIFY | Client اختصاصی و طولانیعمر |
| 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 مستقل خودش را دارد.