توسعه فرانت‌اند

WebSocket و ارتباطات بلادرنگ

تفاوت WebSocket و Socket.IO، وضعیت اتصال، بازیابی و امنیت پیام؛ نمونه کامل Echo محلی با Node، ws و HTML بدون وعده تحویل قطعی.

1404/11/23 5 دقیقه مطالعه 991 بازدید

WebSocket یک ارتباط دوطرفه باز میان Client و Server فراهم می‌کند تا پس از ایجاد اتصال، هر طرف بتواند پیام بفرستد. برای گفت‌وگو، همکاری هم‌زمان و بعضی وضعیت‌های زنده این ویژگی مفید است. باز بودن اتصال به معنی دائمی بودن آن یا تضمین تأخیر بسیار کم نیست. شبکه، دستگاه، Proxy و ظرفیت Server می‌توانند پیام را دیر برسانند یا اتصال را قطع کنند. طراحی باید این حالت‌ها را همراه مسیر موفق در نظر بگیرد.

قبل از انتخاب WebSocket، مشخص کنید داده از کدام طرف و با چه بسامدی می‌آید. اگر فقط اعلان از Server لازم است، Server-Sent Events می‌تواند گزینه‌ای قابل بررسی باشد. برای وضعیت با تغییر کم، Polling ساده ممکن است هزینه نگهداری پایین‌تری داشته باشد. انتخاب فناوری باید با نیاز به جهت ارتباط، زمان انتظار و بازیابی توضیح داده شود. برچسب Real-time به تنهایی مشخص نمی‌کند داده چقدر تازه است یا قطع کوتاه چه اثری روی کاربر می‌گذارد.

اتصال و پیام دو سطح جدا هستند

مرجع WebSocket مرورگر حالت اتصال و رویدادهای open، message، error و close را توضیح می‌دهد. ساختن object به معنی آماده بودن کانال نیست؛ ارسال باید با وضعیت سازگار باشد. از طرف دیگر، فراخوانی send به تنهایی تأیید دریافت یا ثبت داده در Server نیست. اگر کاربر پیامی مهم می‌فرستد، وضعیت ارسال و دریافت باید با قرارداد برنامه روشن شود، نه فقط با باز بودن اتصال.

پیام برنامه می‌تواند شناسه، نوع، نسخه قرارداد و داده داشته باشد. Server باید ساختار و حدود را بررسی کند. JSON معتبر همچنان می‌تواند درخواست نامجاز یا مقدار بی‌معنی حمل کند. برای مثال، ارسال شناسه اتاق گفت‌وگو نباید به معنی حق ورود به آن اتاق تلقی شود. ترتیب پیام روی یک اتصال با ترتیب رویدادهای چند اتصال و چند Server یکسان نیست. در سیستم بزرگ، شناسه رویداد و روش همگام‌سازی باید جداگانه تعریف شوند.

WebSocket با Socket.IO یک پروتکل نیست

مستندات Socket.IO روشن می‌کند که این ابزار امکانات سطح بالاتر و پروتکل خودش را دارد و یک Client بومی WebSocket مستقیماً جای Client آن را نمی‌گیرد. Reconnection، Room و Ack از نیازهایی هستند که ممکن است کتابخانه به مدیریتشان کمک کند. این به معنی حل خودکار همه تضمین‌های تحویل نیست. نسخه و قرارداد Client و Server باید هماهنگ باشند و هزینه وابستگی با نیاز واقعی محصول مقایسه شود.

API بومی مرورگر برای کار با WebSocket در Client موجود است، اما Server هم پیاده‌سازی سازگار می‌خواهد. انتخاب native به معنی حذف منطق برنامه، احراز هویت یا بازیابی نیست. ادعای «برای اکثر نیازها کافی است» بدون تعیین آن نیازها دقیق نیست. اگر به قابلیت‌های متعدد کتابخانه احتیاج دارید، بازنویسی ناقص آن‌ها ممکن است هزینه بیشتری بسازد. اگر فقط ارتباط ساده لازم است، افزودن لایه‌های نامربوط هم ضرورتی ندارد؛ قرارداد محصول معیار انتخاب است.

نمونه کامل محلی با Echo

نمونه زیر فقط متن را به همان اتصال برمی‌گرداند و Chat چندکاربره یا ثبت پایدار نیست. Node.js و npm پیش‌نیاز هستند. در پوشه آموزشی، npm init -y و npm install ws@8 را اجرا کنید. دو فایل را با نام‌های server.mjs و index.html کنار هم بگذارید، سپس node server.mjs را اجرا و http://127.0.0.1:8080 را باز کنید. package ws پیاده‌سازی Server را فراهم می‌کند و مرورگر از WebSocket بومی استفاده می‌کند؛ مخزن رسمی ws روش استفاده این کتابخانه را توضیح می‌دهد.

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { WebSocketServer } from 'ws';
const page=await readFile(new URL('./index.html',import.meta.url));
const origin='http://127.0.0.1:8080';
const server=createServer((req,res)=>{if(req.method!=='GET'||req.url!=='/'){res.writeHead(404);res.end('Not found');return}res.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'});res.end(page)});
const sockets=new WebSocketServer({noServer:true,maxPayload:2048,perMessageDeflate:false});
server.on('upgrade',(request,socket,head)=>{if(request.url!=='/ws'||request.headers.origin!==origin){socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');return}sockets.handleUpgrade(request,socket,head,ws=>sockets.emit('connection',ws,request))});
sockets.on('connection',socket=>{
  socket.on('error',()=>{});
  socket.on('message',(raw,isBinary)=>{
    if(isBinary){socket.close(1003,'Text only');return}
    let message;try{message=JSON.parse(raw.toString())}catch{socket.close(1007,'Invalid JSON');return}
    if(!message||message.type!=='echo'||!Number.isSafeInteger(message.id)||typeof message.text!=='string'||!message.text.trim()||message.text.length>200){socket.close(1008,'Invalid message');return}
    socket.send(JSON.stringify({type:'echo',id:message.id,text:message.text}));
  });
});
server.listen(8080,'127.0.0.1',()=>console.log('Open '+origin));

Server فقط روی Loopback گوش می‌دهد. درخواست صفحه از مسیر ریشه پاسخ می‌گیرد و Upgrade فقط در مسیر /ws با Origin همان صفحه پذیرفته می‌شود. طول Payload و طول متن محدود است و پیام باینری در این قرارداد پذیرفته نمی‌شود. هر پیام یک id عددی دارد و پاسخ echo همان شناسه را برمی‌گرداند. این شناسه برای تطبیق پاسخ نمونه است و به معنی شناسه یکتای جهانی یا ثبت پیام در پایگاه داده نیست.

<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Echo محلی WebSocket</title><style>body{font:1rem/1.8 Tahoma,sans-serif;max-width:40rem;margin:2rem auto;padding:1rem}input,button{font:inherit;padding:.5rem;margin:.3rem}button:focus-visible,input:focus-visible{outline:3px solid #111;outline-offset:3px}</style></head><body><h1>Echo محلی</h1><p>پیام ذخیره یا برای کاربر دیگری ارسال نمی‌شود.</p><button id="connect" type="button">اتصال</button><button id="disconnect" type="button" disabled>قطع</button><p id="status" role="status">قطع است</p><form id="form"><label for="text">متن تا ۲۰۰ کاراکتر</label><input id="text" required maxlength="200"><button id="send" type="submit" disabled>ارسال</button></form><ul id="messages" aria-label="پاسخ‌های Echo"></ul>
<script>
const connect=document.getElementById('connect'),disconnect=document.getElementById('disconnect'),send=document.getElementById('send'),status=document.getElementById('status'),input=document.getElementById('text');let socket=null,sequence=0;
connect.onclick=()=>{connect.disabled=true;disconnect.disabled=false;status.textContent='در حال اتصال';socket=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');socket.onopen=()=>{send.disabled=false;status.textContent='متصل؛ send به تنهایی تأیید دریافت نیست.'};socket.onmessage=({data})=>{try{const message=JSON.parse(data);if(message.type!=='echo'||typeof message.text!=='string')return;const item=document.createElement('li');item.textContent='پاسخ '+message.id+': '+message.text;document.getElementById('messages').append(item)}catch{status.textContent='پاسخ قابل تفسیر نبود.'}};socket.onerror=()=>{status.textContent='خطای اتصال؛ وضعیت نهایی در close مشخص می‌شود.'};socket.onclose=()=>{send.disabled=true;disconnect.disabled=true;connect.disabled=false;status.textContent='اتصال بسته شد؛ پیام بدون پاسخ، تأییدشده نیست.';socket=null}};
disconnect.onclick=()=>{if(socket)socket.close(1000,'User closed')};
document.getElementById('form').onsubmit=e=>{e.preventDefault();if(!socket||socket.readyState!==WebSocket.OPEN){status.textContent='برای ارسال اتصال لازم است.';return}if(!input.value.trim())return;if(socket.bufferedAmount>65536){status.textContent='صف خروجی پر است؛ کمی بعد تلاش کنید.';return}try{socket.send(JSON.stringify({type:'echo',id:++sequence,text:input.value}));input.value='';status.textContent='پیام ارسال شد؛ پاسخ Echo را در فهرست بررسی کنید.'}catch{status.textContent='ارسال ممکن نشد.'}};
window.addEventListener('pagehide',()=>{if(socket)socket.close()});
</script></body></html>

در صفحه، دکمه اتصال هنگام ایجاد کانال غیرفعال می‌شود و ارسال فقط پس از open ممکن است. متن پاسخ با textContent به فهرست افزوده می‌شود تا HTML واردشده اجرا نشود. close و error پیام وضعیت دارند. bufferedAmount پیش از ارسال بررسی می‌شود تا صف خروجی بدون توجه به حد نمونه رشد نکند. این کنترل ساده تمام مدیریت فشار ورودی و خروجی یک سامانه محصولی نیست. ارتباط از ws محلی استفاده می‌کند؛ استقرار عمومی باید با TLS و wss انجام شود و این Server آموزشی برای انتشار عمومی آماده نیست.

تحویل و بازیابی را تعریف کنید

برای پیام مهم، شناسه پایدار و تأیید برنامه‌ای لازم است. اگر اتصال پس از send قطع شد، Client ممکن است نداند Server پیام را دریافت کرده یا نه. تلاش دوباره بدون Idempotency می‌تواند ثبت تکراری بسازد. اگر پیام‌ها قابل تکرارند، Server باید روش تشخیص و پاسخ به تکرار را داشته باشد. در Echo نمونه، ذخیره‌ای وجود ندارد و نتیجه صرفاً دریافت پاسخ روی همان ارتباط است. از آن نباید تضمین تحویل دقیقاً یک‌بار استخراج کرد.

Reconnect باید با فاصله و محدودیت طراحی شود تا قطع گسترده باعث هجوم هم‌زمان درخواست‌ها نشود. پس از اتصال دوباره، داده ممکن است از زمان آخرین پیام عقب افتاده باشد. Snapshot تازه یا دریافت رویدادهای بعد از یک Cursor دو راه متفاوت‌اند و به قرارداد Server نیاز دارند. رابط باید روشن کند در حال اتصال، متصل یا در حال بازیابی است. نمایش سبز دائمی «آنلاین» در حالی که داده تازه نرسیده، وضعیت واقعی کار را توضیح نمی‌دهد.

فشار پیام و منابع را کنترل کنید

WebSocket معمولی مرورگر سازوکار Backpressure خودکار برای همه مسیر دریافت ندارد. ورود پیام سریع‌تر از پردازش می‌تواند صف و مصرف حافظه را زیاد کند. در داده‌ای مثل موقعیت لحظه‌ای، شاید آخرین مقدار کافی باشد و همه مقدارهای میانی لازم نباشند. در تراکنش یا پیام گفت‌وگو، حذف دلخواه داده قابل قبول نیست. سیاست تجمیع، حذف یا توقف باید با معنی پیام مرتبط باشد، نه فقط با هدف کم کردن مصرف منابع.

حد پیام، نرخ ارسال و تعداد اتصال‌ها را در Server تعیین کنید. خاموش بودن دکمه Client حفاظت Server نیست. ثبت رویداد باید اطلاعات لازم برای تشخیص مشکل داشته باشد و متن خصوصی همه پیام‌ها را بی‌دلیل وارد Log نکند. مدت اتصال، خطا و تعداد پیام می‌تواند برای تحلیل عملیات مفید باشد. مقاله Web Workers درباره جدا کردن پردازش مستقل کمک می‌کند، اما انتقال پردازش به Worker مشکل رشد نامحدود صف شبکه را خودکار حل نمی‌کند.

هویت، Origin و مجوز هر پیام را جدا ببینید

راهنمای WebSocket در OWASP کنترل Origin، احراز هویت و مجوز پیام را تفکیک می‌کند. Origin Allowlist برای درخواست مرورگر مفید است، اما هویت کاربر را ثابت نمی‌کند؛ Client غیرمرورگر می‌تواند Header دلخواه بسازد. کانال متصل باید با نشست معتبر مرتبط باشد و پایان نشست یا خروج کاربر روی آن اثر بگذارد. باز نگه داشتن اتصال پس از لغو مجوز، مسیر دسترسی قدیمی باقی می‌گذارد.

مجوز تنها در Handshake کافی نیست اگر در طول ارتباط تغییر کند یا هر پیام به منبع متفاوتی اشاره کند. بررسی کنید کاربر حق خواندن یا تغییر همان منبع را دارد. ورودی را اعتبارسنجی و خروجی را متناسب با محل نمایش Encode کنید. مقاله سیستم نظرات درباره متن کاربر و مرز نمایش مفید است. ارسال داده با WebSocket به جای HTTP آن را امن‌تر نمی‌کند؛ هر پیام همچنان داده غیرقابل اعتماد در مرز برنامه است.

زیرساخت و تجربه کاربر را هماهنگ کنید

Proxy باید Upgrade و Timeout مناسب را پشتیبانی کند. توزیع بار میان چند Server نیز نیازمند راهی برای رساندن پیام به اتصال درست است. وجود Room در یک Process، اشتراک همان Room میان همه Processها را تضمین نمی‌کند. هنگام استقرار نسخه جدید، اتصال‌های قدیمی ممکن است با قرارداد قبلی باقی بمانند؛ نسخه پیام و روش بستن یا سازگاری را مشخص کنید. Heartbeat می‌تواند به تشخیص اتصال خاموش کمک کند، اما باید با رفتار Client و زیرساخت سازگار باشد.

Server-Sent Events در MDN گزینه یک‌طرفه Server به Client را معرفی می‌کند. پیش از تحویل، نیاز دوطرفه، تازگی داده، رفتار Offline و تضمین مورد انتظار را در یک قرارداد کوتاه بنویسید. سپس مسیر اتصال، ارسال، پاسخ و قطع را در رابط روشن کنید. کاربر باید بداند داده ارسال شد، تأیید شد یا وضعیت آن هنوز معلوم نیست؛ یک کانال باز بدون این مدل وضعیت، ارتباط قابل پیگیری برای محصول نمی‌سازد.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!