סטרימינג של עדכונים עם אירועים שנשלחו על ידי השרת

אירועים שנשלחו על ידי שרת (SSE) שולחים עדכונים אוטומטיים ללקוח משרת, עם חיבור HTTP. אחרי שהחיבור ייווצר, השרתים יוכלו להתחיל להעביר נתונים.

כדאי להשתמש ב-SSE כדי לשלוח התראות דחיפה מאפליקציית האינטרנט. אירועי SSE שולחים מידע בכיוון אחד, ולכן לא תקבלו עדכונים מהלקוח.

יכול להיות שהמושג 'שירותי SSE' מוכר לכם. אפליקציית אינטרנט 'נרשמת' לעדכונים ששרת יוצר, וכשהאירוע החדש מתרחש, נשלחת התראה ללקוח. אבל כדי להבין באמת את האירועים שנשלחים מהשרת, צריך להבין את המגבלות של קודמי ה-AJAX. בין היתר, אסור:

  • סקרים: האפליקציה מבצעת סקרים חוזרים ונשנים בשרת כדי לקבל נתונים. רוב האפליקציות של AJAX משתמשות בשיטה הזו. באמצעות פרוטוקול HTTP, אחזור הנתונים מתבצע סביב בקשה ופורמט של תגובה. הלקוח שולח בקשה וממתין לתגובה מהשרת עם נתונים. אם אף אחת מהן לא זמינה, תוחזר תשובה ריקה. סקרים נוספים יוצרים תקורת HTTP גדולה יותר.

  • דגימה ארוכה (Hanging GET / COMET): אם לשרת אין נתונים זמינים, השרת ישמור את הבקשה פתוחה עד שנתונים חדשים יהפכו לזמינים. לכן, השיטה הזו נקראת לעיתים קרובות 'Hanging GET'. כשהמידע זמין, השרת מגיב, סוגר את החיבור והתהליך חוזר על עצמו. לכן, השרת מגיב כל הזמן עם נתונים חדשים. כדי להגדיר את זה, המפתחים בדרך כלל משתמשים בהאקים כמו הוספת תגי סקריפט ל-iframe 'ללא הגבלה'.

אירועים שנשלחים מהשרת תוכננו מראש כדי להיות יעילים. כשמתקשרים עם SSE, השרת יכול לדחוף נתונים לאפליקציה שלכם מתי שהוא רוצה, בלי צורך לשלוח בקשה ראשונית. כלומר, אפשר להעביר עדכונים משרת ללקוח בזמן שהם מתרחשים. שרתי SSE פותחים ערוץ יחיד בכיוון אחד בין השרת ללקוח.

ההבדל העיקרי בין אירועים שנשלחים מהשרת לבין פוליגרפיה ארוכה הוא שאירועי SSE מטופלים ישירות על ידי הדפדפן, והמשתמש צריך רק להאזין להודעות.

אירועים שנשלחים מהשרת לעומת WebSockets

למה כדאי לבחור באירועים שנשלחו משרת על פני WebSockets? שאלה טובה.

ל-WebSockets יש פרוטוקול עשיר עם תקשורת דו-כיוונית ברוחב פס מלא. ערוץ דו-כיווני מתאים יותר למשחקים, לאפליקציות הודעות ולכל תרחיש לדוגמה שבו אתם צריכים עדכונים כמעט בזמן אמת, בשני הכיוונים.

עם זאת, לפעמים דרושה רק תקשורת חד-כיוונית משרת. לדוגמה, כשחבר מעדכן את הסטטוס שלו, נתוני מניות, פידים של חדשות או מנגנונים אחרים להעברת נתונים באופן אוטומטי. במילים אחרות, עדכון למסד נתוני Web SQL בצד הלקוח או לאחסון אובייקטים של IndexedDB. אם אתם צריכים לשלוח נתונים לשרת, XMLHttpRequest תמיד יעזור לכם.

הודעות SSE נשלחות באמצעות HTTP. אין צורך בהטמעה מיוחדת של שרת או פרוטוקול כדי להתחיל לעבוד. כדי לטפל בפרוטוקול, WebSockets דורש חיבור דו-מפלסי מלא ושרתי WebSocket חדשים.

בנוסף, לאירועים שנשלחים על ידי שרת יש מגוון תכונות שלא נקבעו ל-WebSockets, כולל חיבור מחדש אוטומטי, מזהי אירועים ויכולת לשלוח אירועים שרירותיים.

יצירת EventSource באמצעות JavaScript

כדי להירשם למינוי לזרם אירועים, יוצרים אובייקט EventSource ומעבירים לו את כתובת ה-URL של הזרם:

const source = new EventSource('stream.php');

בשלב הבא מגדירים טיפול באירוע message. אפשר גם להאזין ל-open ול-error:

source.addEventListener('message', (e) => {
  console.log(e.data);
});

source.addEventListener('open', (e) => {
  // Connection was opened.
});

source.addEventListener('error', (e) => {
  if (e.readyState == EventSource.CLOSED) {
    // Connection was closed.
  }
});

כשמתבצעת דחיפה של עדכונים מהשרת, הטיפול של onmessage מופעל והנתונים החדשים יהיו זמינים בנכס e.data שלו. החלק הקסום הוא שבכל פעם שהחיבור נסגר, הדפדפן מתחבר מחדש למקור באופן אוטומטי אחרי כ-3 שניות. להטמעה שלכם בשרת יכולה להיות אפילו שליטה על הזמן הקצוב לחיבור מחדש.

זה הכול. עכשיו הלקוח יכול לעבד אירועים מ-stream.php.

פורמט של מקור נתונים של אירועים

כדי לשלוח מקור של אירועים, צריך ליצור תשובה בטקסט ללא עיצוב, עם Content-Type‏ text/event-stream, שתואמת לפורמט SSE. בצורתה הבסיסית, התשובה צריכה להכיל שורה data:, ואחריה את ההודעה שלכם, ואחריה שני תווים מסוג \n כדי לסיים את הסטרימינג:

data: My message\n\n

נתונים בכמה שורות

אם ההודעה ארוכה יותר, אפשר לפצל אותה באמצעות כמה שורות data:. שתי שורות רצופות או יותר שמתחילות ב-data: נחשבות לחלקי נתונים יחיד. כלומר, רק אירוע message אחד מופעל.

כל שורה צריכה להסתיים בתו 'n' יחיד (למעט השורה האחרונה, שצריכה להסתיים בשני תווים כאלה). התוצאה שמועברת למטפל message היא מחרוזת אחת שמקושרת באמצעות תווים של שורת חדשה. לדוגמה:

data: first line\n
data: second line\n\n</pre>

הפונקציה הזו יוצרת את הטקסט 'שורה ראשונה\nשורה שנייה' ב-e.data. לאחר מכן אפשר להשתמש ב-e.data.split('\n').join('') כדי לשחזר את ההודעה ללא התווים ‎\n‎.

שליחת נתוני JSON

שימוש בכמה שורות עוזר לשלוח JSON בלי לשבור את התחביר:

data: {\n
data: "msg": "hello world",\n
data: "id": 12345\n
data: }\n\n

וקוד אפשרי בצד הלקוח לטיפול במקור הנתונים הזה:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.id, data.msg);
});

שיוך מזהה לאירוע

כדי לשלוח מזהה ייחודי עם אירוע סטרימינג, אפשר להוסיף שורה שמתחילה ב-id::

id: 12345\n
data: GOOG\n
data: 556\n\n

הגדרת מזהה מאפשרת לדפדפן לעקוב אחרי האירוע האחרון שהופעל, כך שאם החיבור לשרת ינותק, בבקשה החדשה תוגדר כותרת HTTP מיוחדת (Last-Event-ID). כך הדפדפן יכול לקבוע איזה אירוע מתאים להפעלה. האירוע message מכיל מאפיין e.lastEventId.

קביעת הזמן הקצוב לתפוגה של חיבור מחדש

הדפדפן מנסה להתחבר מחדש למקור כ-3 שניות אחרי שכל חיבור נסגר. אפשר לשנות את הזמן הקצוב לתפוגה על ידי הוספת שורה שמתחילה ב-retry:, ואחריה מספר אלפיות השנייה שצריך להמתין לפני שמנסים להתחבר מחדש.

בדוגמה הבאה מתבצע ניסיון התחברות מחדש אחרי 10 שניות:

retry: 10000\n
data: hello world\n\n

מציינים שם לאירוע.

מקור אירועים יחיד יכול ליצור סוגים שונים של אירועים על ידי הכללת שם אירוע. אם יש שורה שמתחילה ב-event: ואחריה שם ייחודי לאירוע, האירוע משויך לשם הזה. אפשר להגדיר בלקוח פונקציית event listener כדי להאזין לאירוע הספציפי הזה.

לדוגמה, פלט השרת הבא שולח שלושה סוגים של אירועים: אירוע 'message' כללי, אירוע 'userlogon' ואירוע 'update':

data: {"msg": "First message"}\n\n
event: userlogon\n
data: {"username": "John123"}\n\n
event: update\n
data: {"username": "John123", "emotion": "happy"}\n\n

אחרי שמגדירים פונקציות event listener בחשבון הלקוח:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.msg);
});

source.addEventListener('userlogon', (e) => {
  const data = JSON.parse(e.data);
  console.log(`User login: ${data.username}`);
});

source.addEventListener('update', (e) => {
  const data = JSON.parse(e.data);
  console.log(`${data.username} is now ${data.emotion}`);
};

דוגמאות לשרתים

הנה הטמעה בסיסית של שרת ב-PHP:

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache'); // recommended to prevent caching of event data.

/**
* Constructs the SSE data format and flushes that data to the client.
*
* @param string $id Timestamp/id of this connection.
* @param string $msg Line of text that should be transmitted.
**/

function sendMsg($id, $msg) {
  echo "id: $id" . PHP_EOL;
  echo "data: $msg" . PHP_EOL;
  echo PHP_EOL;
  ob_flush();
  flush();
}

$serverTime = time();

sendMsg($serverTime, 'server time: ' . date("h:i:s", time()));
?>

הנה הטמעה דומה ב-Node JS באמצעות טיפול של Express:

app.get('/events', (req, res) => {
    // Send the SSE header.
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    // Sends an event to the client where the data is the current date,
    // then schedules the event to happen again after 5 seconds.
    const sendEvent = () => {
        const data = (new Date()).toLocaleTimeString();
        res.write("data: " + data + '\n\n');
        setTimeout(sendEvent, 5000);
    };

    // Send the initial event immediately.
    sendEvent();
});

sse-node.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <script>
    const source = new EventSource('/events');
    source.onmessage = (e) => {
        const content = document.createElement('div');
        content.textContent = e.data;
        document.body.append(content);
    };
    </script>
  </body>
</html>

ביטול של שידור אירוע

בדרך כלל, הדפדפן מתחבר מחדש באופן אוטומטי למקור האירוע כשהחיבור נסגר, אבל אפשר לבטל את ההתנהגות הזו מהלקוח או מהשרת.

כדי לבטל שידור מהלקוח, צריך להתקשר:

source.close();

כדי לבטל סטרימינג מהשרת, מגיבים עם ערך שאינו text/event-stream Content-Type או עם סטטוס HTTP שאינו 200 OK (למשל 404 Not Found).

שתי השיטות מונעות מהדפדפן ליצור מחדש את החיבור.

כמה מילים על אבטחה

בקשות שנוצרות על ידי EventSource כפופות למדיניות המקור הזהה כמו API אחרים של רשתות, כמו fetch. אם אתם צריכים שנקודת הקצה של SSE בשרת תהיה נגישה ממקורות שונים, קראו את המאמר בנושא שיתוף משאבים בין מקורות (CORS).