WebSocket چیست؟ تفاوت اتصال زنده با درخواست HTTP
در این درس ۸ بخش
در پروژهٔ HTTP قبلی، کاربر نامش را میفرستاد و یک پاسخ میگرفت. حالا میخواهیم وقتی «سارا» در یک تب پیام نوشت، «امیر» در تب دیگر بدون تازهکردن صفحه آن را ببیند. این نیاز به ارتباطی زنده و دوطرفه مربوط است. WebSocket یکی از راههای پیادهسازی آن است؛ در این درس ابتدا دلیل انتخابش را میفهمیم و سپس مرزهایش را مشخص میکنیم. داشتن اتصال باز، برابر داشتن حساب کاربری، ذخیرهٔ دائمی یا تضمین تحویل تمام پیامها نیست.
پروژهٔ «گفتوگوی کوچک» یک لابی آموزشی مشترک دارد؛ همهٔ افراد واردشده، تاریخچهٔ موقت همان لابی را میبینند. از نام و متن ساختگی استفاده کنید. هنوز احراز هویت، اتاق خصوصی، پایگاه داده یا فایل پیوست نداریم. سادگی پروژه کمک میکند تفاوت اتصال، عضویت و پذیرفتهشدن پیام را ببینیم. هدف ما ساخت پیامرسان تولیدی در چند خط نیست؛ میخواهیم هر وضعیت رابط، پشتوانهٔ مشخصی در پاسخ سرور داشته باشد و شکست ارتباط نیز قابل توضیح بماند.
سه الگوی ارتباط را با نیاز مقایسه کنید
در HTTP معمولی، کلاینت درخواست میدهد و سرور پاسخ میدهد. برای وضعیت سرویسی که هر چند دقیقه بررسی میشود، همین الگو ساده و مناسب است. پرسوجوی دورهای یا polling یعنی همان درخواست را با فاصله تکرار کنیم؛ راه بدی برای هر مسئله نیست، ولی در چت سریع میتواند درخواستهای بیتغییر زیادی بسازد. فاصلهٔ کمتر، تأخیر مشاهده را کم میکند اما تعداد درخواستها را بیشتر میکند. تصمیم باید بر اساس نیاز به تازگی داده، تعداد کاربران و هزینهٔ نگهداری باشد.
Server-Sent Events یا SSE مسیر دریافت پیوستهٔ رخدادها از سرور را فراهم میکند؛ برای فرستادن داده از مرورگر میتوان از درخواست HTTP جدا استفاده کرد. اگر نیاز عمده، نمایش اعلان یا پیشرفت یک کار باشد، این ترکیب قابل بررسی است. WebSocket پس از برقراری اتصال، امکان فرستادن پیام از هر دو طرف را دارد و برای گفتوگوی رفتوبرگشتی انتخاب آموزشی مناسبی است. انتخاب آن به معنی حذف HTTP نیست: صفحه، فایل جاوااسکریپت و مسیر وضعیت پروژه هنوز با HTTP ارائه میشوند.
دستدادن ابتدایی و تغییر پروتکل
Handshake یا دستدادن، گفتوگوی آغازین برای پذیرفتن اتصال است. در مسیر HTTP/1.1 این پروژه، مرورگر درخواست Upgrade میفرستد و در حالت پذیرفتهشدن، پاسخ 101 Switching Protocols دیده میشود. سپس دادهها طبق قالب پیام WebSocket جابهجا میشوند. فریم، واحد پروتکل روی ارتباط است؛ پیام کاربردی میتواند جزئیات دیگری داشته باشد. ما فریمها را دستی تجزیه نمیکنیم و محاسبهٔ دستدادن را از صفر نمینویسیم؛ کتابخانهٔ شناختهشده این بخش پروتکل را انجام میدهد.
در مرورگر از کلاس بومی WebSocket استفاده میکنیم. در سمت سرور Node.js، بستهٔ ws مسئول پذیرفتن ارتباط است. وجود WebSocket در محیط Node.js جدید به این معنی نیست که سرور WebSocket داخلی با همان نام آماده شده؛ نقش کلاینت را با سرور اشتباه نگیرید. همین تفاوت محیط، دلیل نصب بسته برای سرور و نصبنکردن آن در مرورگر است. فایل public/app.js با API مرورگر اجرا میشود و کد app.mjs در فرایند Node.js اجرا خواهد شد.
نشانی اتصال را از نشانی صفحه بسازید
ws برای اتصال بدون TLS و wss برای اتصال دارای TLS به کار میرود. در اجرای محلیِ این پروژه، صفحه روی http://127.0.0.1:3001 است و اتصال روی ws://127.0.0.1:3001/chat برقرار میشود. در استقرار HTTPS، نشانی WebSocket باید wss باشد تا مسیر صفحه و اتصال هماهنگ بماند. نوشتن یک نشانی ثابت localhost داخل فایل مرورگرِ سرور عمومی خطاست؛ localhost برای هر بازدیدکننده به دستگاه خودش اشاره میکند. نشانی باید از محیط همان صفحه ساخته شود.
برای تمرین بدون شبکه، کد زیر را در websocket-url.mjs ذخیره و با node websocket-url.mjs اجرا کنید. این تابع فقط نشانی میسازد و اتصال واقعی باز نمیکند. استفاده از URL بهجای چسباندن رشته، مسیر /chat را روشن نگه میدارد و پرسوجوی صفحه را بیدلیل به اتصال منتقل نمیکند. در واژهنامهٔ #url-query، تفاوت مسیر و پارامتر را مرور کنید. نشانی chat.example.com در مثال سندی است و سرور واقعی یا دامنهٔ آمادهٔ شما محسوب نمیشود.
import assert from 'node:assert/strict';
function chatSocketURL(pageURL) {
const page = new URL(pageURL);
if (!['http:', 'https:'].includes(page.protocol)) {
throw new TypeError('Open the page through HTTP or HTTPS');
}
const socket = new URL('/chat', page);
socket.protocol = page.protocol === 'https:' ? 'wss:' : 'ws:';
return socket.href;
}
assert.equal(chatSocketURL('http://127.0.0.1:3001/'), 'ws://127.0.0.1:3001/chat');
assert.equal(chatSocketURL('https://chat.example.com/?view=main'), 'wss://chat.example.com/chat');
assert.throws(() => chatSocketURL('file:///tmp/index.html'), TypeError);
console.log(chatSocketURL('http://127.0.0.1:3001/'));
console.log(chatSocketURL('https://chat.example.com/'));
console.log(WebSocket.CONNECTING, WebSocket.OPEN, WebSocket.CLOSING, WebSocket.CLOSED);خروجی دو نشانی ws و wss و سپس 0 1 2 3 است. چهار عدد به ترتیب CONNECTING، OPEN، CLOSING و CLOSED هستند. در کلاینت واقعی، send را فقط در وضعیت OPEN بررسی میکنیم؛ با این حال OPEN فقط وضعیت ارتباط است. تا پاسخ history به join نرسیده، عضویت کاربردی ما کامل نیست و فرم ارسال فعال نمیشود. این تفکیک جلوی حالتی را میگیرد که کاربر پیام مینویسد ولی سرور هنوز نام نمایشی او را نپذیرفته است.
Origin مرز مرورگر است و هویت کاربر نیست
Origin ترکیب طرح، میزبان و درگاه صفحه است. http://localhost:3001 و http://127.0.0.1:3001 برای بررسی دقیق، دو مقدار متفاوتاند. پروژه فقط یک Origin مشخص را میپذیرد؛ اگر صفحه را از نشانی دیگری باز کنید، دستدادن میتواند با 403 رد شود. راه اصلاح، بازکردن نشانی درست یا تنظیم آگاهانهٔ PUBLIC_ORIGIN است. گذاشتن ستاره یا قبولکردن هر مقدار برای خاموشکردن خطا، قرارداد ما را ضعیف میکند و پاسخ آموزشی مناسبی به مسئله نیست.
مرورگر Origin را هنگام اتصال میفرستد، ولی یک برنامهٔ غیرمرورگری میتواند آن را جعل کند. بنابراین این کنترل، ورود امن با نام کاربری و رمز نمیسازد. نام نمایشی «مدیر» نیز به کسی اختیار اضافه نمیدهد. در #validation-contracts یادآوری میکنیم دادهٔ پذیرفتهشده باید مستقل بررسی شود؛ داشتن Origin درست، مجوز اعتماد به متن، شناسه یا تعداد درخواستها نیست. پروژهٔ آموزشی عمداً با دادهٔ ساختگی و دسترسی محلی شروع میشود تا این محدودیتها پنهان نماند.
اتصال باز، ارسالشده و پذیرفتهشده سه چیزند
وقتی مرورگر send را صدا میزند، داده ابتدا وارد مسیر ارسال میشود. bufferedAmount اندازهٔ دادهٔ صفشدهٔ ارسال را گزارش میکند، نه تعداد پیامهای پذیرفتهشده در مدل سرور. صفرشدن آن هم رسید کاربر دوم نیست. پروژه برای هر درخواست، clientMessageId میسازد و سرور پس از پذیرش، ack دارای شناسهٔ پیام میفرستد. اگر ارتباط پیش از این پاسخ قطع شود، نتیجه میتواند نامعلوم باشد؛ ممکن است سرور پیام را پذیرفته باشد ولی تأیید به مرورگر نرسیده باشد.
برای همین، اتصال دوباره به معنی ارسال خودکار تمام پیامهای قبلی نیست. این کار میتواند یک متن را دوبار ثبت کند. تاریخچهٔ تازه از سرور گرفته میشود، وضعیت نامعلوم به کاربر نشان داده میشود و ارسال دوباره تصمیمی آگاهانه است. شناسه و ترتیب پیام را سرور تعیین میکند؛ ساعت محلی تب دوم معیار قطعی ترتیب نیست. در درس کلاینت، #event-listener و رویدادهای open، message و close به تغییر وضعیتهای روشن وصل میشوند و #json قالب انتقال قرارداد خواهد بود.
پس از اجرای پروژه، در مرورگر چه چیزی ببینیم؟
پس از آمادهکردن فایلهای کامل در درس بعد، npm start را در پوشهٔ پروژه اجرا کنید و نشانی چاپشده را در دو تب باز کنید. ابزار توسعهٔ مرورگر را باز کنید و در Network فیلتر WS را انتخاب کنید؛ سپس در همان تب اتصال را برقرار کنید. درخواست /chat باید در حالت موفق، دستدادن 101 داشته باشد. در بخش پیامها، ابتدا join و سپس history را پیدا کنید. نام این زبانه بین مرورگرها ممکن است Messages یا Frames باشد؛ محتوای بسته برای ما مهمتر از نام ابزار است.
یک پیام فارسی از تب اول بفرستید و حضور آن را در تب دوم ببینید. سپس درخواست GET /api/status را در زبانهٔ جدا باز کنید؛ این پاسخ HTTP هنوز مستقل از اتصال چت است. اگر اتصال رد شد، ابتدا نشانی صفحه، Origin و مسیر /chat را مقایسه کنید، بعد خطای ترمینال را بخوانید. فایل HTML را با file:// باز نکنید و برای دورزدن مشکل، بررسی Origin را حذف نکنید. در این مرحله درخواست دستی به یک دامنهٔ ناشناس لازم نیست؛ آزمایش روی دستگاه خودتان کافی است.
تمرین با پاسخ راهنما
سه نیاز را دستهبندی کنید: مشاهدهٔ روزانهٔ نسخهٔ برنامه، دریافت پیشرفت یک پردازش و گفتوگوی دوطرفهٔ سریع. پاسخ پیشنهادی به ترتیب HTTP عادی، بررسی SSE همراه درخواست لازم، و بررسی WebSocket است؛ انتخاب نهایی به محدودیتهای محصول بستگی دارد. سپس توضیح دهید چرا OPEN بودن ارتباط برای فعالشدن فرم پروژه کافی نیست. پاسخ، لایهٔ عضویت است: سرور باید join را بپذیرد و snapshot تاریخچه را بفرستد تا کلاینت از وضعیت آغازین مطمئن شود.
در تمرین عملی، با تابع نشانیساز، صفحهای دارای مسیر و query را امتحان کنید و بررسی کنید نتیجه همچنان /chat است. ورودی file را نیز باید رد کنید. معیار تحویل، توضیح تفاوت دستدادن 101، خطای HTTP پیش از اتصال و error کاربردی پس از اتصال است. این سه را با یک پیام مبهم «اینترنت قطع است» جایگزین نکنید؛ هر کدام محل عیبیابی و عمل بعدی متفاوتی دارند.
برای مرور تجربهٔ قبلی، درس ساخت API و درس گواهی خودکار را ببینید. در اینجا صدور TLS را تکرار نمیکنیم؛ در استقرار نهایی از همان مبنا برای WSS استفاده میشود.
API و پاسخ HTTP: https://www.darsavand.com/courses/nodejs-http-server-page-api-tls?lesson=7a83fd1a-3584-409c-8eb4-dd51e21144ce
HTTPS و گواهی خودکار: https://www.darsavand.com/courses/nodejs-http-server-page-api-tls?lesson=ebfdc0f5-e56c-45d7-9b23-f8623f0b57af
منابع رسمی و مطالعهٔ تکمیلی
RFC 6455، پروتکل و Origin: https://www.rfc-editor.org/rfc/rfc6455
MDN، WebSocket API: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
MDN، readyState: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
MDN، bufferedAmount: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/bufferedAmount
MDN، SSE: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
Node.js، کلاینت WebSocket: https://nodejs.org/api/globals.html#class-websocket
مخزن رسمی ws: https://github.com/websockets/ws
تألیف و مثالهای اختصاصی: تحریریه درسآوند.
تألیف و مثالهای اختصاصی: تحریریه درسآوند. فایلهای کامل پروژه و راهنمای اجرا و دیباگ:
https://www.darsavand.com/resources/simple-websocket-chat-project
متن درسهای عمومی را بدون ورود بخوانید.