قرارداد پیام و وضعیت کلاینت؛ آماده، قطع و نامعلوم
در این درس ۵ بخش
وصلشدن یک اتصال، پایان آمادهسازی برنامه نیست. تصور کنید وارد اتاقی شدهاید ولی هنوز نام شما ثبت نشده و پیامهای قبلی نرسیدهاند؛ روشنبودن چراغ شبکه بهتنهایی اجازهٔ ارسال پیام نمیدهد. در این درس برای «گفتوگوی کوچک» قراردادی مینویسیم که وضعیت اتصال، آمادهبودن برنامه و نتیجهٔ ارسال را جدا نگه میدارد. قرارداد یعنی دو طرف بدانند چه دادهای، در چه مرحلهای و با چه معنایی پذیرفته میشود. هدف حفظکردن شش نام پیام نیست؛ باید بتوانید هنگام قطع شبکه، عبارتی دقیق و صادقانه به کاربر نشان دهید.
حملونقل آماده است؛ آیا برنامه هم آماده است؟
WebSocket مرورگر چهار وضعیت CONNECTING، OPEN، CLOSING و CLOSED دارد. OPEN میگوید ارتباط برای مبادلهٔ داده باز است؛ دربارهٔ ثبت نام نمایشی، اجازهٔ محصول یا دریافت تاریخچه چیزی نمیگوید. در پروژه، پس از open یک پیام join با نام آزمایشی میفرستیم. سرور پس از پذیرش، بستهٔ history را میدهد. تنها بعد از بررسی این بسته، ready در رابط روشن میشود. این دو شرط را جدا دنبال کنید: اتصال OPEN باشد و برنامه تاریخچهٔ آغاز نشست را پذیرفته باشد. اگر هنوز history نرسیده، فرم ارسال باید غیرفعال بماند.
در API مبتنی بر HTTP معمولاً هر درخواست پاسخ و کد وضعیت خودش را دارد. اینجا پس از برقرارشدن ارتباط، بستههای برنامه روی همان کانال میآیند؛ هر پیام چت یک پاسخ HTTP با عدد ۲۰۰ نمیگیرد. نامهای history، message و ack قرارداد اختصاصی پروژهاند، نه قابلیت ازپیشتعریفشدهٔ WebSocket. مرورگر فقط رویداد message و دادهٔ آن را تحویل میدهد. #json برای تبدیل رشته به مقدار به کار میرود، ولی JSON.parse موفق اثبات نمیکند بسته فیلدهای درست دارد. شیء خالی هم JSON معتبر است و پیام پذیرفتنی این برنامه نیست.
بستههای کوچک، معنای روشن
کلاینت نخست فقط join با type و name میفرستد. نام پس از trim و عادیسازی NFC باید بین یک تا ۲۴ نقطهٔ کد و حداکثر ۹۶ بایت UTF-8 باشد. پس از آمادهشدن، chat شامل type، یک clientMessageId یکتای UUID و text است. متن پس از عادیسازی، یک تا ۵۰۰ نقطهٔ کد و حداکثر ۲۰۰۰ بایت است. سرور فیلدهای اضافی و شکل نامعتبر را رد میکند؛ کلاینت نباید admin یا زمان دلخواه را به بسته بیفزاید. سقف ۴۰۹۶ بایت مربوط به کل محتوای یک پیام WebSocket، حتی در چند قطعهٔ انتقال، است؛ اندازهٔ یک فریم سیمی نیست.
const joinPacket = { type: 'join', name: 'سارا' };
const chatPacket = {
type: 'chat',
clientMessageId: '655d706f-1c66-4943-bdbf-d963d2e74e7d',
text: 'این یک پیام آزمایشی است.'
};
console.log(JSON.stringify(joinPacket));
console.log(new TextEncoder().encode(JSON.stringify(chatPacket)).byteLength <= 4096);
// خط دوم true است؛ این نمونه فقط بسته میسازد و به شبکه وصل نمیشود.سرور برای هر پیام پذیرفتهشده id، seq و at میسازد. id شناسهٔ پیام نهایی است؛ clientMessageId شناسهٔ تلاش ارسال کلاینت است و این دو را جای یکدیگر نگذارید. seq ترتیب همان اجرای سرور را مشخص میکند؛ at زمان ISO در UTC برای نمایش است. ساعت دستگاه کاربران ممکن است ناهماهنگ باشد و زمان قالببندیشدهٔ فارسی مبنای مرتبسازی نیست. بستهٔ message شامل یک message با این فیلدها و name و text است. سپس ack با clientMessageId، messageId و seq به فرستنده میرسد تا نتیجه به تلاش درست وصل شود.
تأیید سرور چه چیزی را ثابت میکند؟
فراخوانی send داده را برای ارسال در صف میگذارد و Promise تأیید تحویل برنمیگرداند. bufferedAmount اندازهٔ دادهٔ صفشده است، نه تعداد پیام خواندهشده. در پروژه، ack فقط میگوید سرور این پیام را پذیرفته و در حافظهٔ موقت اتاق ثبت کرده است. این تأیید، خواندن پیام توسط همه، ذخیرهٔ دائمی یا تحویل تضمینی پس از توقف سرور نیست. اگر محصول واقعاً به آن ضمانتها نیاز داشته باشد، طراحی داده و قرارداد دیگری لازم است. نوشتن «همه خواندند» پس از send حتی در آزمایش محلی هم نتیجهگیری درستی نیست.
کلاینت برای هر بار ارسال، یک انتظار حداکثر هشتثانیهای دارد. اگر ack نرسد یا اتصال در همین فاصله بسته شود، نتیجه «نامعلوم» است. شاید سرور پیام را نپذیرفته؛ شاید پذیرفته ولی تأیید به کلاینت نرسیده باشد. متن حفظ میشود و برنامه خودکار آن را دوباره نمیفرستد. ارسال دستی با شناسهٔ تازه ممکن است پیام تکراری بسازد. حتی دیدن متن مشابه در تاریخچه، روش قطعی تطبیق یک تلاش نیست؛ دو نفر میتوانند همان جمله را بنویسند. یک تأیید دیررسِ قابل تطبیق میتواند نتیجهٔ تلاش قبلی را روشن کند.
// مدل آموزشی نتیجهٔ یک تلاش؛ جایگزین handlerهای کلاینت نیست.
function deliveryState({ sent, acknowledged, rejected, ended }) {
if (acknowledged) return 'accepted';
if (rejected) return 'rejected';
if (!sent) return 'not-sent';
return ended ? 'unknown' : 'waiting';
}
console.log(deliveryState({ sent: true, acknowledged: false,
rejected: false, ended: true })); // unknown
console.log(deliveryState({ sent: true, acknowledged: true,
rejected: false, ended: true })); // acceptedاین مثال را مستقل در Console یا فایل mjs اجرا کنید. ورودی آن مجموعهای از واقعیتهای شناختهشده دربارهٔ یک تلاش است؛ مدتزمان را شبیهسازی نمیکند. در پیادهسازی واقعی، ابتدا تطبیق شناسهٔ تأیید و معتبربودن بسته لازم است. وقتی پیام خطای وابسته به یک شناسه میرسد، نتیجهٔ همان تلاش بررسی میشود؛ خطای نامعتبرِ یک بسته را بیدلیل به همهٔ پیامها نسبت ندهید. #validation-contracts در اینجا هم برای داده و هم برای ترتیب مجاز رخدادها مهم است. تابع کوتاه بدون تعریف معنای ورودی، خودبهخود قرارداد درستی نمیسازد.
تکرارینبودن محدود و تاریخچهٔ تازه
سرور برای همان اتصال، ۵۰ شناسهٔ آخر پیامهای پذیرفتهشدهٔ کلاینت را به خاطر میسپارد. اگر همان شناسه و همان متن عادیشده دوباره برسد، همان ack قبلی را میدهد و دوباره پخش نمیکند. همان شناسه با متن متفاوت خطای DUPLICATE_ID میگیرد. پس از اتصال مجدد یا بیرونرفتن شناسه از این حافظهٔ محدود، این تضمین وجود ندارد. این رفتار، «دقیقاً یک بار در همهٔ شرایط» نیست. کلاینت آموزشی با همین محدودیتها تصمیم گرفته ارسال خودکار مجدد نداشته باشد؛ رفتار رابط باید با ضمانت واقعی سرور متناسب باشد.
history تصویری از حداکثر ۵۰ پیام اخیر، همراه serverId، clientId و latestSeq است. هنگام هر اتصال، تصویر تازه جای فهرست قبلی را میگیرد؛ آن را کورکورانه به فهرست قبلی اضافه نمیکنیم. داخل این تصویر، شناسهها باید یکتا و seqها صعودی تا مرز latestSeq باشند. پس از آن، message تازه بر اساس id و شمارهٔ ترتیب افزوده میشود. تغییر serverId یعنی اجرای سرور عوض شده و seq ممکن است از ابتدا شروع شود. حفظ آخرین seq اجرای قبلی میتواند همهٔ پیامهای اجرای تازه را اشتباهاً حذف کند.
presence تعداد اتصالهای واردشده را گزارش میکند، نه تعداد انسانهای یکتا. دو تب یک نفر میتوانند عدد دو بسازند. نام نمایشی نیز هویت تأییدشده نیست و دو نام یکسان ممنوع نشدهاند. تمام افراد حاضر در این اتاق مشترک، پیامها و تاریخچهٔ محدود را میبینند. بنابراین از دادهٔ ساختگی استفاده کنید و نبود حساب کاربری را با حریم خصوصی اشتباه نگیرید. این محدودیتها بخشی از تعریف پروژهاند؛ کوچکبودن پروژه به معنی مبهمبودن معنای اطلاعات رابط نیست.
خطا، اتصال دوباره و تمرین تصمیمگیری
خطای سرور بستهٔ error با code و message دارد. عبور از نرخ مجاز، دادهٔ باینری و پیام بیشازحد بزرگ میتوانند اتصال را با کدهای 1008، 1003 و 1009 پایان دهند؛ کلاینت اینها را بیوقفه retry نمیکند. برای قطعهای گذرا، تلاش خودکار حداکثر پنج بار و با فاصلهٔ افزایشی است. پس از دریافت history موفق، شمار تلاشها بازنشانی میشود. #timers برای همین سیاست و انتظار ack استفاده میشود؛ پایان یک تایمر خبر قطعی از وضعیت سرور نیست. بازنشانی شمار تلاش نیز به معنی ارسال دوبارهٔ پیام قبلی نیست.
تمرین: برای چهار اتفاق، متن رابط را بنویسید: open بدون history؛ send موفق بدون ack و سپس قطع؛ ack معتبر و سپس قطع؛ history با serverId تازه. پاسخ راهنما: «در حال آمادهسازی»، «نتیجهٔ ارسال نامعلوم»، «سرور پیام را پذیرفت؛ اکنون ارتباط قطع است» و «اجرای سرور تازه است و تاریخچهٔ قبلی در حافظه نیست». معیار تحویل این است که هیچ پیامِ صرفاً صفشده را پذیرفته یا خواندهشده اعلام نکنید. سپس توضیح دهید چرا تکرار همان متن با شناسهٔ تازه میتواند دوباره پذیرفته شود؛ متن پیام، شناسهٔ درخواست نیست.
برای مدل وضعیت رابط، درس قبلی را با این قرارداد مقایسه کنید: https://www.darsavand.com/courses/javascript-project-lab?lesson=a978ac67-cf0d-427d-a3ae-2cfacec07cdc
برای قرارداد API و مرز مسئولیت سرور نیز این درس پیشنیاز است: https://www.darsavand.com/courses/javascript-project-lab?lesson=5671e034-580e-4490-91c2-d700b2ba7a54
در دورهٔ مکمل «Node.js تکمیلی؛ رویداد، جریان داده و سرور چت»، ساخت سمت سرور همین قرارداد را میبینید: https://www.darsavand.com/courses/nodejs-events-streams-websocket
منابع رسمی انگلیسی:
MDN WebSocket readyState: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
MDN WebSocket send: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
MDN bufferedAmount: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/bufferedAmount
MDN WebSocket message event: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event
تألیف، مثالها و تمرینها: تحریریه درسآوند. منابع رسمی انگلیسی برای راستیآزمایی رفتار ابزارها و مطالعهٔ بیشتر معرفی شدهاند.
تألیف و مثالهای اختصاصی: تحریریه درسآوند. فایلهای کامل پروژه و راهنمای اجرا و دیباگ:
https://www.darsavand.com/resources/simple-websocket-chat-project
متن درسهای عمومی را بدون ورود بخوانید.