اتصال سالم؛ Ping/Pong، کلاینت کند و توقف مرتب
در این درس ۶ بخش
باز بودن یک تب مرورگر به معنی سالم بودن اتصال نیست. اینترنت موبایل قطع میشود، لپتاپ به خواب میرود یا گیرندهای کندتر از سرعت ارسال پیامها داده میخواند. اگر برنامه فقط وضعیت ظاهری socket را نگاه کند، ممکن است اتصال مرده را مدت زیادی عضو اتاق بشمارد یا حافظهٔ فراوانی برای گیرندهٔ کند مصرف کند. در این درس، سه مسئله را جدا بررسی میکنیم: تشخیص زنده بودن مسیر، محدود کردن صف خروجی و بستن مرتب منابع هنگام توقف. این قواعد برای یک اتاق آموزشی کوچک نوشته شدهاند؛ اندازههای آنها باید همراه هدف برنامه فهمیده شوند.
Ping و Pong پیام گفتوگو نیستند
WebSocket علاوه بر پیام متنی و باینری، فریمهای کنترلی دارد. سرور میتواند Ping بفرستد و طرف مقابل با Pong پاسخ دهد. مرورگر این پاسخ پروتکل را مدیریت میکند؛ رابط WebSocket جاوااسکریپت مرورگر روش عمومی ping یا رویداد pong در اختیار برنامه نمیگذارد. پس دکمهٔ ارسال متن یا پیام ساختگی «من زندهام» جای همین سازوکار نیست. پاسخ Pong فقط نشان میدهد مسیر و طرف مقابل در آن لحظه پاسخ دادهاند؛ نشانهٔ خواندن پیام توسط کاربر، فعال بودن صفحه یا معتبر بودن هویت انسان پشت نام نمایشی نیست.
در پروژه، زمانسنج هر ۳۰ ثانیه اعضای WebSocket را بررسی میکند. بار اول isAlive را false میگذارد و Ping میفرستد. رویداد pong آن را دوباره true میکند. در نوبت بعد، اگر هنوز false باشد، اتصال با terminate پایان مییابد. بنابراین نباید بنویسیم «هر اتصال قطعشده دقیقاً در ۳۰ ثانیه حذف میشود»؛ زمان قطع نسبت به نوبت بررسی، تأخیر شبکه و مشغول بودن حلقهٔ رویداد بر نتیجه اثر دارد. #timers یادآوری میکند زمانسنج، قرار ملاقات دقیق زمان واقعی نیست و باید امکان تأخیر را در رفتار رابط پذیرفت.
فایل سیاست اتصال و حدود آن
کد کامل connection.mjs را در ریشهٔ پروژه نگه دارید. تابع heartbeat فقط یک نوبت بررسی را انجام میدهد؛ ساخت و پاککردن زمانسنج در app.mjs درس چهارم است. این جداسازی اجازه میدهد سیاست را بدون منتظر ماندن سی ثانیه بررسی کنیم. تابع sendBounded نیز پیش از ارسال، آماده بودن socket و حجم صف را میسنجد. این دو تابع دادهٔ کاربر را معتبر نمیکنند؛ بستهٔ ارسالی باید پیشتر طبق قرارداد برنامه ساخته شده باشد. داشتن یک تابع کمکی به معنی حل همهٔ مسئولیتهای اتصال در همان تابع نیست.
import WebSocket from 'ws';
import { LIMITS } from './protocol.mjs';
export function sendBounded(socket, packet) {
if (socket.readyState !== WebSocket.OPEN) return false;
const text = JSON.stringify(packet);
if (socket.bufferedAmount + Buffer.byteLength(text) > LIMITS.maxBufferedBytes) {
socket.terminate();
return false;
}
socket.send(text, error => { if (error) socket.terminate(); });
return true;
}
export function heartbeat(wss) {
for (const socket of wss.clients) {
if (socket.readyState !== WebSocket.OPEN) continue;
if (socket.isAlive === false) { socket.terminate(); continue; }
socket.isAlive = false;
socket.ping();
}
}عدد ۲۶۲۱۴۴ در قرارداد، سقف ۲۵۶ کیبیبایت صف است. شرط، مجموع bufferedAmount فعلی و بایتهای UTF-8 بستهٔ JSON بعدی را مقایسه میکند. اگر از حد عبور کند، گیرندهٔ کند قطع میشود تا مصرف حافظهٔ این اتصال نامحدود رشد نکند. تعداد نویسههای فارسی را با تعداد بایت یکی نگیرید. در ضمن مقدار صف شامل کل حافظهٔ سیستمعامل، کتابخانه و مرورگر نیست؛ این یک کنترل مشخص در مرز برنامه است. نسخهٔ آموزشی فشردهسازی پیام را خاموش کرده تا هزینه و رفتار سادهتری داشته باشد؛ این انتخاب بهتنهایی ارزیابی کامل ظرفیت سرور نیست.
sendBounded اگر ارسال را به کتابخانه واگذار کند true برمیگرداند؛ معنایش دریافت قطعی توسط مرورگر نیست. callback خطای send میتواند بعداً اجرا شود و اتصال را پایان دهد. وقتی صف از حد گذشته و terminate اجرا میشود، حتی ارسال یک پیام توضیح خطا هم تضمینشده نیست؛ همان صف مشکل دارد. رابط باید قطع ناگهانی را قابل فهم نمایش دهد و پیام تأییدنشده را نامعلوم بداند. مفهوم #async-basics در اینجا کاربرد عملی دارد: پذیرش کار در یک تابع و پایان موفق انتقال شبکه دو لحظهٔ متفاوتاند.
آزمایش سیاست، بدون شبیهسازی دروغین شبکه
فایل connection-policy-lab.mjs را کنار connection.mjs بسازید. این آزمایش با شیء ساختگی، تصمیم تابع را میسنجد و ادعا نمیکند یک شبکهٔ کند واقعی ساختهایم. یک اتصال پاسخگو و یک اتصال بیپاسخ در نظر میگیریم. پس از نخستین نوبت، هر دو Ping میگیرند. فقط برای اولی دریافت Pong را با true کردن isAlive مدل میکنیم؛ نوبت دوم باید فقط دومی را قطع کند. تفکیک آزمون سیاست از آزمون واقعی WebSocket کمک میکند هنگام شکست، بفهمیم تصمیم برنامه غلط است یا رفتار انتقال باید بررسی شود.
import assert from 'node:assert/strict';
import WebSocket from 'ws';
import { heartbeat, sendBounded } from './connection.mjs';
import { LIMITS } from './protocol.mjs';
function fakeSocket() {
return { readyState: WebSocket.OPEN, isAlive: true, bufferedAmount: 0,
pings: 0, ended: false, sent: [],
ping() { this.pings++; },
terminate() { this.ended = true; },
send(text, done) { this.sent.push(text); done(); } };
}
const responsive = fakeSocket();
const silent = fakeSocket();
const wss = { clients: new Set([responsive, silent]) };
heartbeat(wss);
assert.equal(responsive.pings, 1);
assert.equal(silent.pings, 1);
responsive.isAlive = true; // models a received Pong
heartbeat(wss);
assert.equal(responsive.ended, false);
assert.equal(silent.ended, true);
const slow = fakeSocket();
slow.bufferedAmount = LIMITS.maxBufferedBytes;
assert.equal(sendBounded(slow, { type: 'presence', online: 1 }), false);
assert.equal(slow.ended, true);
assert.equal(slow.sent.length, 0);
console.log('connection policy checks passed');با node connection-policy-lab.mjs باید پیام موفقیت را ببینید؛ شکست یک assert اجرای فایل را ناموفق میکند. برای آزمایش مرز دقیق، بسته را یکبار JSON.stringify کنید، طول بایتش را حساب کنید و bufferedAmount ساختگی را برابر سقف منهای همان طول بگذارید: برابر سقف مجاز است و یک بایت بیشتر رد میشود. این آزمون ارزش بیشتری از انتخاب تصادفی یک عدد بسیار بزرگ دارد. اگر شکل بسته تغییر کند، طول تازه را دوباره محاسبه کنید؛ حدس زدن بر اساس تعداد حروف، آزمون مرزی قابل نگهداری تولید نمیکند.
محدودیت نرخ و مهلت عضویت، مسئلهای جدا هستند
قانون نرخ این پروژه حداکثر ده بستهٔ کاربردی در یک پنجرهٔ دهثانیهای برای هر اتصال است؛ join و بستههای نامعتبر هم شمرده میشوند. بستهٔ یازدهم خطای RATE_LIMIT و سپس بستن با کد 1008 را ایجاد میکند. Ping و Pong کنترلی در این شمارش نیستند. این محدودیت با سقف بایت پیام فرق دارد؛ یک پیام بزرگ را نمیتوان فقط با کندتر فرستادن معتبر کرد. همچنین بیش از بیست اتصال همزمان پذیرفته نمیشود. این اعداد نمونهٔ آموزشیاند و جای تحلیل سوءاستفاده، چند نمونهٔ سرور یا سامانهٔ کنترل ترافیک عمومی را نمیگیرند.
پس از اتصال، کاربر ده ثانیه برای join معتبر فرصت دارد. اتصال پذیرفتهشده ولی عضو نشده، منابع دارد و در سقف اتصال شمرده میشود؛ اما در online اعضا نیست. بعد از مهلت، با 1008 بسته میشود. نام پس از join برای همان اتصال ثابت است و درخواست join دوم خطای ALREADY_JOINED دارد. اگر یک نفر دو تب باز کند، دو عضو داریم. این تعریف را کنار عدد افراد بنویسید تا کاربر آن را با شمار حسابهای یکتا اشتباه نگیرد. هیچیک از این کنترلها، نام نمایشی را به هویت تأییدشده تبدیل نمیکند.
توقف مرتب از کجا شروع میشود؟
وقتی SIGTERM برای استقرار نسخهٔ تازه میرسد، سرور باید ورود جدید را متوقف کند، زمانسنج heartbeat و مهلتهای join را پاک کند و برای اتصالهای موجود بستن 1001 بفرستد. در این پروژه، پس از سه ثانیه مهلت، اتصال باقیمانده terminate میشود. server.close برای HTTP بهتنهایی مسئول همهٔ socketهای Upgradeشده نیست؛ به همین دلیل فهرست WebSocketها جدا بسته میشود. وعدهٔ close نیز یکبار ساخته میشود تا دو درخواست توقف، دو فرایند مستقل پاکسازی راه نیندازند. برای دیدن جزئیات، تابع close در نسخهٔ کامل app.mjs درس چهارم را دنبال کنید.
توقف مرتب یعنی منابع و وضعیت پایان قابل پیشبینی دارند؛ معنایش تحویل تضمینی همهٔ پیامهای در راه نیست. اگر کاربر درست هنگام توقف ارسال کند، ACK ممکن است نرسد و رابط باید نامعلوم نشان دهد. handlerهای SIGINT و SIGTERM در server.mjs همین close را فراخوانی میکنند. از بستن اجباری همهٔ پردازههای Node سیستم برای آزمایش استفاده نکنید؛ تنها نمونهٔ آموزشی خود را در همان ترمینال با Ctrl+C متوقف کنید. پس از پایان باید بتوانید همان پورت را دوباره برای نمونهٔ تازه استفاده کنید، بدون باقی ماندن فرایند قبلی یا زمانسنج ناخواسته.
اگر خطا رخ داد، نخست نقطهٔ مالکیت منبع را پیدا کنید: کدام بخش interval را ساخته و کدام بخش باید آن را پاک کند؟ کدام اتصال در members ثبت شده و در close حذف میشود؟ #try-catch برای گرفتن خطا بهتنهایی کافی نیست؛ بلعیدن خطا بدون پایان منبع، حافظه و اتصال را آزاد نمیکند. هنگام گزارش مشکل، کد بستن، زمان تقریبی و وضعیت رابط را ثبت کنید. نام و متن ساختگی برای بازتولید کافیاند؛ افزودن محتوای واقعی کاربران به لاگ عمومی، فهم این مسئله را بهتر نمیکند.
تمرین و پاسخ راهنما
آزمایش سیاست را طوری گسترش دهید که socket بستهشده نه Ping بگیرد و نه پیام تازه ارسال کند. پاسخ راهنما، قرار دادن readyState برابر WebSocket.CLOSED و بررسی صفر ماندن شمار ارسال است. سپس مسیر توقف را روی دو تب بررسی کنید: ابتدا وضعیت عضو، بعد Ctrl+C، بعد نمایش قطع اتصال و در نهایت شروع دوباره. انتظار نداریم همان serverId یا تاریخچه باقی بماند. در گزارش تحویل، تفاوت آزمایش شیء ساختگی، اتصال واقعی محلی و شبکهٔ عمومی را روشن بنویسید؛ هرکدام فقط بخشی از رفتار را اثبات میکنند.
برای رابطهٔ زمانسنج با حلقهٔ رویداد، به درس قبلی Node برگردید: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=852c12c3-e66d-4c16-91b3-166f3d5d6ae2
برای توقف سرویس و نقش مدیر پردازه در اوبونتو، این درس را مرور کنید: https://www.darsavand.com/courses/nodejs-backend-ubuntu-deployment?lesson=78a3a92a-f850-4744-9a6f-0b4fade2d5a6
معیار پایان، توضیح تفاوت Ping، پیام کاربر، ACK و بستن اتصال است. اگر این چهار مفهوم مخلوط شوند، رابط ممکن است موفقیت ارسال را از زنده بودن اتصال حدس بزند و دادهٔ نامعلوم را بهاشتباه قطعی نمایش دهد.
منابع رسمی انگلیسی:
ws — Broken connections and heartbeat: https://github.com/websockets/ws/blob/master/README.md
ws — bufferedAmount, send and terminate: https://github.com/websockets/ws/blob/master/doc/ws.md
RFC 6455 — Control frames: https://www.rfc-editor.org/rfc/rfc6455
Node.js — Timers: https://nodejs.org/api/timers.html
Node.js — Signal events: https://nodejs.org/api/process.html#signal-events
تألیف و آزمایشهای سیاست اتصال: تحریریه درسآوند. آزمایش ساختگی جای سنجش شبکهٔ واقعی را نمیگیرد.
تألیف و مثالهای اختصاصی: تحریریه درسآوند. فایلهای کامل پروژه و راهنمای اجرا و دیباگ:
https://www.darsavand.com/resources/simple-websocket-chat-project
متن درسهای عمومی را بدون ورود بخوانید.