پخش پیام؛ شناسه، ترتیب و حافظهٔ محدود
در این درس ۷ بخش
در درس قبل، درخواست اتصال را پذیرفتیم و قرارداد ورودی را مشخص کردیم. اکنون دو پنجرهٔ مرورگر باز میکنیم و میپرسیم: وقتی یک نفر پیام میفرستد، چه چیزی باید برای همه ارسال شود و چه پاسخی فقط به فرستنده تعلق دارد؟ این تفاوت، پایهٔ قابل اعتماد بودن رابط چت است. در «گفتوگوی کوچک» یک اتاق مشترک آموزشی داریم؛ نامها ساختگیاند، حساب کاربری و گفتوگوی خصوصی نداریم. هدف این درس ساخت پیامهایی است که بتوانیم ترتیب، هویت و حدود نگهداری آنها را توضیح دهیم؛ ظاهر شدن چند سطر در صفحه بهتنهایی معیار تمام شدن کار نیست.
پخش عمومی و پاسخ اختصاصی را جدا کنید
Broadcast یا پخش عمومی یعنی سرور یک رخداد پذیرفتهشده را به اتصالهای عضو اتاق میفرستد. در این پروژه، فرستنده هم عضو اتاق است و همان message را دریافت میکند؛ پس کلاینت نباید یک بار هنگام کلیک و بار دیگر هنگام دریافت، دو نسخهٔ قطعی به فهرست اضافه کند. در مقابل، ack پاسخ همان درخواست ارسال است و فقط برای فرستنده فرستاده میشود. ACK میگوید سرور پیام را پذیرفته است؛ ثابت نمیکند تمام مرورگرها آن را دیده یا انسان دیگری آن را خوانده است. حضور هم تعداد اتصالهای عضو را نشان میدهد و تعداد اشخاص واقعی نیست.
این جداسازی در کد سرور درس چهارم دیده میشود: پس از ساخت پیام، broadcast اجرا میشود و سپس ACK برای همان socket فرستاده میشود. اگر هنگام ارسال به یکی از گیرندهها خطا رخ دهد، پذیرش پیام برای دیگران خودبهخود پس گرفته نمیشود. چنین برنامهای تراکنش همزمان میان همهٔ مرورگرها نیست. برای بررسی، یک پنجره را «آوند» و دیگری را «نمونه» بنامید، یک متن کوتاه بفرستید و در بخش پیامهای شبکه، تفاوت بستهٔ عمومی و پاسخ اختصاصی را ببینید. اطلاعات شخصی یا رمز واقعی وارد این اتاق آموزشی نکنید.
چهار شناسه، چهار مسئولیت متفاوت
serverId هویت اجرای فعلی سرور است و با راهاندازی دوباره عوض میشود. clientId هویت همین اتصال پذیرفتهشده است و حساب کاربری محسوب نمیشود. clientMessageId را مرورگر برای یک تلاش ارسال میسازد تا ACK را به همان تلاش مرتبط کند. message.id را سرور برای پیام پذیرفتهشده میسازد و رابط با آن از افزودن دوبارهٔ همان پیام جلوگیری میکند. یکسان بودن نام نمایشی هیچکدام از این شناسهها را یکسان نمیکند. در بازبینی #json فقط شکل بسته را نخوانید؛ مشخص کنید هر فیلد را کدام طرف میسازد و در چه بازهای معتبر است.
شمارهٔ seq ترتیب پذیرش پیامها در همین اجرای سرور است: یک، دو و به همین ترتیب. UUID شناسهٔ مناسب برای برابری است، ولی آن را برای مرتبسازی زمانی مقایسه نمیکنیم. مقدار at زمان ISO ثبتشده توسط سرور است و در رابط میتوان آن را به فارسی نمایش داد؛ ساعت سیستم ممکن است تنظیم شود و ابزار اثبات ترتیب نیست. بنابراین در این پروژه seq برای ترتیب همان اجرا و at برای نمایش زمان به کار میروند. مدخل #date-intl را با این تفکیک بخوانید؛ زیبا کردن ساعت، یک منبع ترتیب مستقل ایجاد نمیکند.
قرارداد ورودی را در یک فایل مستقل ببینید
فایل protocol.mjs زیر را در ریشهٔ پروژه، کنار app.mjs قرار دهید؛ این نسخهٔ کامل فایل همراه است. نام فارسی و متن ابتدا از نظر رشتهٔ سالم بررسی، سپس با NFC عادیسازی و trim میشوند. حد نام، ۲۴ code point و ۹۶ بایت است؛ متن ۵۰۰ code point و ۲۰۰۰ بایت محدودیت دارد. کل پیام WebSocket، حتی اگر در چند فریم منتقل شود، حداکثر ۴۰۹۶ بایت است. این محدودیت آخر را کتابخانهٔ ws در مرز دریافت اجرا میکند. مفهوم #validation-contracts کمک میکند واحد این سه سقف با یکدیگر اشتباه نشود.
export const LIMITS = Object.freeze({
maxPayloadBytes: 4096, maxClients: 20, historyMessages: 50,
packetsPerWindow: 10, windowMs: 10000, joinDeadlineMs: 10000,
heartbeatMs: 30000, maxBufferedBytes: 262144
});
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export const isRequestId = value => typeof value === 'string' && UUID.test(value);
export class ProtocolError extends Error {
constructor(code) { super(code); this.code = code; }
}
function exactKeys(value, keys) {
return value && typeof value === 'object' && !Array.isArray(value) &&
Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
}
function clean(value, points, bytes, code) {
if (typeof value !== 'string' || !value.isWellFormed()) throw new ProtocolError(code);
const text = value.normalize('NFC').trim();
if (!text || [...text].length > points || Buffer.byteLength(text, 'utf8') > bytes) {
throw new ProtocolError(code);
}
return text;
}
export function parsePacket(raw) {
let value;
try {
const bytes = typeof raw === 'string' ? Buffer.from(raw) : raw;
if (bytes.length > LIMITS.maxPayloadBytes) throw new Error('Payload too large');
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
} catch { throw new ProtocolError('INVALID_JSON'); }
if (value?.type === 'join' && exactKeys(value, ['type', 'name'])) {
return { type: 'join', name: clean(value.name, 24, 96, 'INVALID_NAME') };
}
if (value?.type === 'chat' && exactKeys(value, ['type', 'clientMessageId', 'text'])) {
if (!isRequestId(value.clientMessageId)) throw new ProtocolError('INVALID_PACKET');
return {
type: 'chat', clientMessageId: value.clientMessageId,
text: clean(value.text, 500, 2000, 'INVALID_TEXT')
};
}
throw new ProtocolError('INVALID_PACKET');
}
const explanations = {
INVALID_JSON: 'متن پیام باید JSON معتبر با UTF-8 درست باشد.',
INVALID_PACKET: 'ساختار یا نوع پیام با قرارداد هماهنگ نیست.',
JOIN_REQUIRED: 'پیش از پیام، با یک نام آزمایشی وارد شوید.',
ALREADY_JOINED: 'نام این اتصال قبلاً انتخاب شده است.',
INVALID_NAME: 'نام باید از ۱ تا ۲۴ نویسهٔ یونیکد و حداکثر ۹۶ بایت باشد.',
INVALID_TEXT: 'پیام باید از ۱ تا ۵۰۰ نویسهٔ یونیکد و حداکثر ۲۰۰۰ بایت باشد.',
RATE_LIMIT: 'درخواستها خیلی سریعاند؛ کمی صبر کنید و دستی دوباره وصل شوید.',
TEXT_ONLY: 'این پروژه فقط پیام متنی میپذیرد.',
DUPLICATE_ID: 'همان شناسه قبلاً برای متن دیگری پذیرفته شده است.'
};
export function errorPacket(code, clientMessageId) {
const packet = { type: 'error', code, message: explanations[code] ?? 'پیام پذیرفته نشد.' };
if (isRequestId(clientMessageId)) packet.clientMessageId = clientMessageId;
return packet;
}کد، کلیدهای اضافی بسته را نیز رد میکند. اگر کلاینت کنار text فیلد دلخواه admin بفرستد، این برنامه آن را نادیده نمیگیرد و قرارداد ناقص را آشکار میکند. خطاهای قابل انتظار پیام کنترلشده دارند؛ متن استثنای داخلی یا پشتهٔ سرور برای کاربر پخش نمیشود. دریافت شناسهٔ معتبر در یک بسته هم مجوز پذیرش متن نامعتبر نیست. در دفترچهٔ تمرین، سه مورد بنویسید: بستهٔ درست، فیلد اضافه و رشتهٔ خالی پس از trim. انتظار هر مورد را پیش از اجرای آزمون مشخص کنید و بعد با نتیجه مقایسه کنید.
حافظهٔ محدود، تصمیم محصول است
History یا تاریخچهٔ این برنامه فقط آخرین ۵۰ پیام همین اجراست و در حافظه نگهداری میشود. هنگام join موفق، سرور snapshot یا تصویری از وضعیت کنونی میفرستد که serverId، latestSeq و فهرست پیامها را دارد. رابط فهرست قبلی را با این تصویر جایگزین میکند و سپس پیام زنده را میپذیرد. latestSeq مرز آخرین پیام موجود در همان تصویر است؛ قول بازیابی همهٔ پیامهای غیبت طولانی نیست. با راهاندازی دوباره، حافظه خالی و شناسهٔ اجرا تازه میشود. اگر آرشیو دائمی لازم باشد، طراحی پایگاه داده و سیاست نگهداری جداگانه لازم داریم.
مدل room.mjs فقط پذیرش دادهٔ از قبل معتبر را انجام میدهد؛ این تابع را مرز امن دریافت اینترنت فرض نکنید. پس از افزودن پیام، اگر تعداد از ۵۰ بیشتر شد، قدیمیترین مورد کنار گذاشته میشود. snapshot نسخهٔ ردیفها را برمیگرداند تا تغییر نمایشی مصرفکننده، متن اصلی مدل را عوض نکند. برای این دادههای ساده، کپی سطحی هر پیام کافی است؛ اگر بعداً شیء تو در تو اضافه کردید، این فرض را دوباره بررسی کنید. #array-add-remove توضیح پایهٔ افزودن و حذف را دارد؛ تصمیم جدید ما تعیین سقف و رفتار پس از رسیدن به سقف است.
import { randomUUID } from 'node:crypto';
import { LIMITS } from './protocol.mjs';
export function createRoom() {
const serverId = randomUUID();
const messages = [];
let latestSeq = 0;
return {
accept(name, text) {
if (!Number.isSafeInteger(latestSeq + 1)) throw new RangeError('Sequence exhausted');
const message = {
id: randomUUID(), seq: ++latestSeq,
at: new Date().toISOString(), name, text
};
messages.push(message);
if (messages.length > LIMITS.historyMessages) messages.shift();
return { ...message };
},
snapshot() {
return { serverId, latestSeq, messages: messages.map(message => ({ ...message })) };
},
get size() { return messages.length; }
};
}یک آزمایش کوچک، جدا از شبکه
فایل history-lab.mjs را در ریشهٔ پروژه بسازید و با node history-lab.mjs اجرا کنید. این آزمایش مستقیم مدل است و محدودیت تعداد درخواست شبکه را دور نمیزند، چون اصلاً اتصال شبکهای نمیسازد. پنجاهویک پیام ساختگی میسازیم تا مرز حذف قدیمیترین مورد را ببینیم. در برنامهٔ واقعی، فرستادن همین تعداد بسته پشت سر هم از یک اتصال با محدودیت نرخ برخورد میکند. جدا کردن این دو آزمایش کمک میکند خطای نگهداری داده را با خطای سیاست اتصال اشتباه نگیرید و برای یافتن مشکل، بیجهت محدودیت سرور را حذف نکنید.
import { createRoom } from './room.mjs';
const room = createRoom();
for (let i = 1; i <= 51; i++) room.accept('آوند', `پیام ${i}`);
const snapshot = room.snapshot();
console.log(snapshot.messages.length, snapshot.messages[0].seq, snapshot.latestSeq);
// 50 2 51
snapshot.messages[0].text = 'تغییر آزمایشی';
console.log(room.snapshot().messages[0].text); // پیام 2خروجی دوم نشان میدهد تغییر ردیف snapshot، مدل اصلی را تغییر نداده است. اکنون آزمایش را با ورودی خالی آغاز کنید و ببینید latestSeq برابر صفر و فهرست خالی است. سپس دو مدل جدا بسازید و تفاوت serverId آنها را بررسی کنید. انتظار ما تفاوت شناسهٔ اجراست، نه اندازه یا شکل خاص یک عدد تصادفی. برای اثبات یکتایی عملی از مقایسهٔ شناسهها استفاده کنید؛ به خاطر سپردن یک نمونهٔ UUID یا ساعت تولید، آزمون معناداری برای رفتار مدل نیست.
ارسال تکراری چگونه تشخیص داده میشود؟
سرور برای هر اتصال، آخرین ۵۰ clientMessageId پذیرفتهشده را در Map نگه میدارد. اگر همان شناسه با همان متن عادیشده دوباره برسد، ACK قبلی تکرار میشود و پیام عمومی تازهای ساخته نمیشود. اگر شناسه همان باشد و متن تغییر کند، خطای DUPLICATE_ID برمیگردد. #keyed-collections برای خواندن این ساختار مفید است. این حافظه به همان اتصال تعلق دارد و پس از اتصال دوباره حفظ نمیشود. همچنین با خروج مورد قدیمی از سقف ۵۰، سابقهٔ آن شناسه دیگر تضمین نشده است؛ بنابراین اصطلاح «تحویل دقیقاً یکبار» برای کل سامانه درست نیست.
این محدودیت توضیح میدهد چرا رابط، پیام بیپاسخ را خودکار دوباره نمیفرستد. ممکن است سرور آن را پذیرفته باشد و فقط ACK در مسیر از دست رفته باشد. حالت درست برای کاربر «نتیجه نامعلوم» است؛ او میتواند تاریخچهٔ تازه را بررسی کند و آگاهانه تصمیم بگیرد. تشابه متن دو پیام اثبات تکراری بودن نیست، چون دو نفر ممکن است عمداً جملهٔ یکسانی بنویسند. معیار حذف تکرار در رابط، شناسهٔ پیام سرور است؛ حدس زدن بر اساس ساعت و متن، دادهٔ درست را نیز ممکن است حذف کند.
تمرین و پاسخ راهنما
آزمایش مدل را برای ۶۱ پیام اجرا کنید و بدون نگاه کردن به خروجی، تعداد، نخستین seq و آخرین seq را بنویسید. پاسخ، ۵۰ پیام، شمارهٔ ۱۲ و شمارهٔ ۶۱ است. سپس توضیح دهید چرا با شروع اجرای جدید، مقایسهٔ seq بهتنهایی کافی نیست؛ باید serverId نیز عوض شده باشد. برای قرارداد شبکه، دو ارسال با یک clientMessageId و متن یکسان باید یک پیام عمومی و دو ACK متناظر ایجاد کنند؛ تغییر متن در ارسال دوم باید خطای مشخص بدهد. این آزمایش شبکهای در درس هفتم انجام میشود تا مدل و انتقال با هم بررسی شوند.
برای مرور تفاوت اعتبارسنجی و تبدیل داده، درس ورودی بکاند را بخوانید: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=9b1d29fd-37f1-43da-a662-b777e25afbc9
برای مقایسهٔ ساختار داده با گزارش دفتر هزینه، به این درس متوسط برگردید: https://www.darsavand.com/courses/javascript-project-lab?lesson=6b314ebc-2e12-443e-b0bf-cdb27d3abb96
معیار تحویل این درس، اجرای آزمایش مرزی و توضیح محدودیت تاریخچه و ارسال تکراری است. صرف دیدن پنجاه پیام در صفحه کافی نیست؛ باید بدانید پیام پنجاهویکم چه چیزی را حذف کرده و چرا قطع اتصال، تضمین قبلی شناسهٔ درخواست را پایان میدهد.
منابع رسمی انگلیسی:
ws — Server broadcast and API: https://github.com/websockets/ws/blob/master/README.md
ws — WebSocket API: https://github.com/websockets/ws/blob/master/doc/ws.md
RFC 6455 — WebSocket Protocol: https://www.rfc-editor.org/rfc/rfc6455
Node.js — crypto.randomUUID: https://nodejs.org/api/crypto.html#cryptorandomuuidoptions
تألیف، مدل آموزشی و مثالهای مستقل: تحریریه درسآوند. قرارداد پروژه در فایل همراه نیز آمده است.
تألیف و مثالهای اختصاصی: تحریریه درسآوند. فایلهای کامل پروژه و راهنمای اجرا و دیباگ:
https://www.darsavand.com/resources/simple-websocket-chat-project
متن درسهای عمومی را بدون ورود بخوانید.