בקשות סטרימינג עם ממשק ה-API לאחזור

Jake Archibald
Jake Archibald

החל מ-Chromium 105, אפשר לשלוח בקשה לפני שכל התוכן שלה זמין באמצעות Streams API.

אפשר להשתמש בזה כדי:

  • צריך לחמם את השרת. במילים אחרות, אפשר להתחיל את הבקשה ברגע שהמשתמש מדגיש שדה להזנת קלט טקסט, ולהסיר את כל הכותרות, ואז להמתין עד שהמשתמש ילחץ על 'שליחה'. לפני שליחת הנתונים שהם הזינו.
  • שליחה בהדרגה של נתונים שנוצרו על ידי הלקוח, כמו נתוני אודיו, וידאו או קלט.
  • יצירה מחדש של שקעי אינטרנט באמצעות HTTP/2 או HTTP/3.

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

הדגמה (דמו)

כאן אפשר לראות איך אפשר לשדר נתונים מהמשתמש לשרת ולשלוח חזרה נתונים שניתן לעבד בזמן אמת.

כן, זו לא הדוגמה הכי דמיונית, רק רציתי לשמור את זה פשוט, בסדר?

בכל אופן, איך זה עובד?

בעבר, בהרפתקאות המלהיבות של שידורי אחזור

שידורי תגובות זמינים בכל הדפדפנים המתקדמים כבר זמן מה. הם מאפשרים לגשת לחלקים מהתשובה כשהם מגיעים מהשרת:

const response = await fetch(url);
const reader = response.body.getReader();

while (true) {
  const {value, done} = await reader.read();
  if (done) break;
  console.log('Received', value);
}

console.log('Response fully received');

כל value הוא Uint8Array של בייטים. מספר המערכים שיתקבלו וגודל המערכים תלויים במהירות הרשת. אם משתמשים בחיבור מהיר, יוצגו 'מקטעים' קטנים יותר של נתונים. אם החיבור איטי, יוצגו יותר מקטעי נתונים קטנים יותר.

אם רוצים להמיר את הבייטים לטקסט, אפשר להשתמש ב-TextDecoder או במקור הטרנספורמציה החדש יותר אם דפדפני היעד תומכים בו:

const response = await fetch(url);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();

TextDecoderStream הוא זרם טרנספורמציה שמאחזר את כל המקטעים של Uint8Array וממיר אותם למחרוזות.

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

בכל מקרה, אלה זרמי תגובות, הדבר החדש והמלהיב שרציתי לדבר עליו הוא שידורים של בקשות.

גופי בקשות לסטרימינג

בקשות יכולות להיות בגוף:

await fetch(url, {
  method: 'POST',
  body: requestBody,
});

בעבר, הייתם צריכים שכל הגוף מוכן לפעולה כדי להתחיל בתהליך הבקשה, אבל עכשיו ב-Chromium 105 אתם יכולים לספק נתונים מסוג ReadableStream משלכם:

function wait(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

const stream = new ReadableStream({
  async start(controller) {
    await wait(1000);
    controller.enqueue('This ');
    await wait(1000);
    controller.enqueue('is ');
    await wait(1000);
    controller.enqueue('a ');
    await wait(1000);
    controller.enqueue('slow ');
    await wait(1000);
    controller.enqueue('request.');
    controller.close();
  },
}).pipeThrough(new TextEncoderStream());

fetch(url, {
  method: 'POST',
  headers: {'Content-Type': 'text/plain'},
  body: stream,
  duplex: 'half',
});

המידע שלמעלה ישלח את ההודעה "זוהי בקשה איטית" לשרת, מילה אחר מילה, עם השהיה של שנייה אחת בין כל מילה.

כל מקטע בגוף הבקשה צריך להיות Uint8Array בייטים, אז אני משתמש ב-pipeThrough(new TextEncoderStream()) כדי לבצע את ההמרה בשבילי.

הגבלות

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

חצי דופלקס?

כדי לאפשר שימוש בשידורים בבקשה, צריך להגדיר את אפשרות הבקשה duplex לערך 'half'.

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

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

דפוס ברירת המחדל הזה נקרא 'חצי דופלקס'. עם זאת, באפליקציות מסוימות, כמו fetch ב-Deno, ברירת המחדל היא 'דופלקס מלא' לצורך אחזורים בסטרימינג, כלומר התגובה יכולה להיות זמינה לפני שהבקשה תושלם.

לכן, כדי לפתור את בעיית התאימות הזו, duplex: 'half' צריך לצפות בדפדפנים תצוין בבקשות עם גוף זרם.

בעתיד, ייתכן ש-duplex: 'full' ייתמך בדפדפנים לבקשות סטרימינג ובקשות שאינן סטרימינג.

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

הפניות אוטומטיות מוגבלות

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

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

מותרות הפניות 303, כי הן משנות את השיטה באופן מפורש ל-GET ומוחקות את גוף הבקשה.

נדרשת CORS ומפעיל קדם-הפעלה

לבקשות בסטרימינג יש גוף, אבל אין להן כותרת Content-Length. זה סוג חדש של בקשה, לכן יש צורך ב-CORS, והבקשות האלה תמיד מפעילות קדם-הפעלה.

לא ניתן לשדר בקשות של no-cors.

לא פועלת על HTTP/1.x

האחזור יידחה אם החיבור הוא HTTP/1.x.

הסיבה לכך היא שבהתאם לכללי HTTP/1.1, גופי בקשות וגוף צריכים לשלוח כותרת Content-Length, כדי שהצד השני ידע כמה נתונים הוא יקבל, או שהוא משנה את פורמט ההודעה לשימוש בקידוד מקטעי נתונים. באמצעות קידוד של מקטעי נתונים, הגוף מחולק לחלקים, שלכל אחד מהם אורך תוכן משלו.

קידוד במקטעים הוא די נפוץ בתגובות HTTP/1.1, אבל הוא נדיר מאוד כשמדובר בבקשות, ולכן קיים סיכון גבוה מדי לתאימות.

בעיות אפשריות

זוהי תכונה חדשה שכיום משתמשים בה הרבה באינטרנט. ריכזנו כאן כמה בעיות שכדאי לבדוק:

חוסר תאימות בצד השרת

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

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

חוסר תאימות שלא בשליטתכם

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

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

זיהוי תכונות

const supportsRequestStreams = (() => {
  let duplexAccessed = false;

  const hasContentType = new Request('', {
    body: new ReadableStream(),
    method: 'POST',
    get duplex() {
      duplexAccessed = true;
      return 'half';
    },
  }).headers.has('Content-Type');

  return duplexAccessed && !hasContentType;
})();

if (supportsRequestStreams) {
  // …
} else {
  // …
}

אם אתם סקרנים, כך פועל זיהוי התכונות:

אם הדפדפן לא תומך בסוג מסוים של body, הוא קורא לאובייקט toString() ומשתמש בתוצאה בתור הגוף. לכן, אם הדפדפן לא תומך בשידורי בקשות, גוף הבקשה הופך למחרוזת "[object ReadableStream]". כשמחרוזת משמשת כגוף, נוח להגדיר את הכותרת Content-Type כ-text/plain;charset=UTF-8. לכן, אם הכותרת הזו מוגדרת, אנחנו יודעים שהדפדפן לא תומך בשידורים באובייקטים של בקשות, ונוכל לצאת מוקדם.

Safari כן תומך בשידורים באובייקטים של בקשות, אבל לא מאפשר להשתמש בהם עם fetch, לכן האפשרות duplex נבדקת, ו-Safari לא תומך בה כרגע.

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

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

const {readable, writable} = new TransformStream();

const responsePromise = fetch(url, {
  method: 'POST',
  body: readable,
});

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

// Get from url1:
const response = await fetch(url1);
const {readable, writable} = new TransformStream();

// Compress the data from url1:
response.body.pipeThrough(new CompressionStream('gzip')).pipeTo(writable);

// Post to url2:
await fetch(url2, {
  method: 'POST',
  body: readable,
});

הדוגמה שלמעלה משתמשת בזרמי דחיסה כדי לדחוס נתונים שרירותיים באמצעות gzip.