WebSocket

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

موارد استفاده رایج: اعلان‌های بلادرنگ (real-time)، چت زنده، داشبوردهایی که به‌طور خودکار به‌روزرسانی می‌شوند، بازی‌های چندنفره.

پشتیبانی WebSocket در فینچ حول سه کلاس ساخته شده است:

  • SocketManager — تمام اتصالات فعال WebSocket را مدیریت می‌کند و پیام‌ها را به handler مسیر درست ارسال می‌کند.
  • SocketEvent — callbackهای onConnect، onMessage، onDisconnect و onError را برای یک path مشخص تعریف می‌کند.
  • SocketClient — نماینده یک کلاینت متصل است؛ به هر callback پاس داده می‌شود و همان چیزی است که روی آن .send() را فراخوانی می‌کنید.

یک Controller معمولی فینچ همان چیزی است که درخواست HTTP ورودی را به یک اتصال WebSocket ارتقا (upgrade) می‌دهد — کلاس پایه جداگانه‌ای به نام SocketController برای extend کردن وجود ندارد.

راه‌اندازی

۱. تعریف یک SocketManager

SocketManager را در app.dart بسازید. این کلاس نمونه app، یک SocketEvent ریشه (root) (برای رویدادهای طول عمر اتصال)، و یک map از handlerهای مسیر نام‌گذاری‌شده را می‌پذیرد:

final socketManager = SocketManager(
  app,
  event: SocketEvent(
    onConnect: (socket) {
      // زمانی که یک کلاینت متصل می‌شود فراخوانی می‌شود
      // به همه کلاینت‌های دیگر درباره اتصال جدید اطلاع بده
      app.socketManager?.sendToAll(
        'A user connected. Total: ${app.socketManager?.countClients}',
        path: 'output',
      );

      // یک تاییدیه به کلاینت تازه‌متصل‌شده ارسال کن
      socket.send(
        {'message': 'Successfully connected to socket!'},
        path: 'connected',
      );
    },
    onMessage: (socket, data) {
      // برای پیام‌هایی که با هیچ مسیر نام‌گذاری‌شده‌ای مطابقت ندارند فراخوانی می‌شود
    },
    onDisconnect: (socket) {
      // زمانی که یک کلاینت قطع می‌شود فراخوانی می‌شود
      var count = app.socketManager?.countClients ?? 0;
      app.socketManager?.sendToAll(
        'A user disconnected. Total: ${count - 1}',
        path: 'output',
      );
    },
    onError: (socket, data) {
      // زمانی فراخوانی می‌شود که پیام ورودی نتواند به‌صورت JSON decode شود،
      // یا خطای دیگری هنگام پردازش آن رخ دهد
    },
  ),
  routes: _getSocketRoutes(),
);

فقط event ریشه فراخوانی‌های onConnect/onDisconnect/onError را دریافت می‌کند — به یادداشت زیر بخش «تعریف مسیرهای نام‌گذاری‌شده» در پایین مراجعه کنید.

۲. تعریف مسیرهای نام‌گذاری‌شده

مسیرهای socket یک Map<String, SocketEvent> هستند. هر کلید یک "path" (نام یک کانال منطقی) است. وقتی کلاینتی پیامی به آن path ارسال کند، callback مربوط به onMessage فراخوانی می‌شود:

Map<String, SocketEvent> _getSocketRoutes() {
  return {
    // کلاینت به path 'test' ارسال می‌کند — سرور با هدرهای درخواست پاسخ می‌دهد
    'test': SocketEvent(
      onMessage: (socket, data) {
        socket.send([socket.rq.headers], path: 'test');
      },
    ),

    // کلاینت به path 'time' ارسال می‌کند — سرور با زمان جاری پاسخ می‌دهد
    'time': SocketEvent(
      onMessage: (socket, data) {
        socket.send(DateTime.now().toString(), path: 'output');
      },
    ),
  };
}

فقط onMessage از SocketEvent یک مسیر نام‌گذاری‌شده استفاده می‌شود — onConnect/onDisconnect/onError که روی یک ورودی مسیر تنظیم شده باشند هرگز فراخوانی نمی‌شوند؛ آن‌ها فقط از event ریشه پاس‌داده‌شده به SocketManager فراخوانی می‌شوند. پیامی که path آن با هیچ مسیر نام‌گذاری‌شده‌ای مطابقت نداشته باشد، به‌جای آن به event.onMessage ریشه می‌رسد.

۳. ارتقای درخواست در یک کنترلر

وظیفه کنترلر این است که درخواست HTTP را به socketManager تحویل دهد تا ارتقا (upgrade) اتفاق بیفتد — این یک Controller معمولی است، چیز خاصی نیست:

class WebSocketController extends Controller {
  Future<String> socket() async {
    // درخواست را برای ارتقای WebSocket به SocketManager منتقل کن
    await socketManager.requestHandle(rq);
    return rq.renderSocket(); // 'Socket is requested!' را برمی‌گرداند
  }
}

۴. ثبت مسیر WebSocket

مسیر WebSocket باید Methods.ALL را بپذیرد، زیرا handshake ارتقا از یک درخواست GET با هدرهای مخصوص استفاده می‌کند:

FinchRoute(
  key: 'root.ws',
  path: '/ws',
  methods: Methods.ALL,
  index: webSocketController.socket,
),

ارسال پیام از سرور

هر callback یک SocketClient دریافت می‌کند (که در مثال‌های بالا socket نامیده شده است). این شیء — و همچنین خودِ SocketManager — متدهایی برای ارسال پیام ارائه می‌دهند:

// ارسال به همان کلاینت مشخصی که رویداد را trigger کرده است
socket.send(data, path: 'channelName');

// ارسال به همه کلاینت‌های متصل
app.socketManager?.sendToAll(data, path: 'channelName');

// ارسال به یک کلاینت مشخص بر اساس connection ID
// (توجه: نام واقعی این متد دارای typo است: "Clinet"، نه "Client")
app.socketManager?.sendToClinet(clientId, data, path: 'channelName');

// ارسال به هر کلاینتی که با یک شناسه کاربر مشخص مرتبط باشد
// (نیاز دارد که userId هنگام اتصال آن‌ها به requestHandle(rq, userId: ...) پاس داده شده باشد)
app.socketManager?.sendToUser(userId, data, path: 'channelName');

هیچ متد داخلی برای "ارسال به همه به‌جز این یکی" وجود ندارد — اگر به آن نیاز دارید، خودتان app.socketManager?.getAllClientsKeys() را فیلتر کنید و sendToClinet را برای هر شناسه باقی‌مانده فراخوانی کنید.

path در یک فراخوانی send مشخص می‌کند کدام handler در سمت کلاینت آن را دریافت می‌کند. در سمت جاوااسکریپت، برای پیام‌ها روی همان نام path گوش دهید.

نمونه جاوااسکریپت سمت کلاینت

const ws = new WebSocket('ws://localhost:8080/ws');

ws.onopen = () => console.log('Connected');

// برای پیام‌ها روی path 'connected' گوش بده
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.path === 'connected') {
    console.log('Server says:', msg.data.message);
  }
  if (msg.path === 'output') {
    console.log('Output:', msg.data);
  }
};

// یک پیام به مسیر 'time' در سرور ارسال کن
ws.send(JSON.stringify({ path: 'time', data: {} }));

چند کلاینت متصل هستند؟

int count = app.socketManager?.countClients ?? 0;
int users = app.socketManager?.countUsers ?? 0; // کاربران یکتا، در صورتی که هنگام اتصال userId پاس داده باشید