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

ג'ייק ארצ'יבלד
ג'ייק ארצ'יבלד

מגרסה 105 של Chromium, אפשר להתחיל בקשה לפני שכל התוכן יהיה זמין באמצעות 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 'תוצאות', אתם יכולים להציג את התוצאה הראשונה ברגע שקיבלתם אותה, במקום להמתין עד שהן כולן.

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

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

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

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

בעבר הייתם צריכים להכין את כל הגוף כדי להתחיל את הבקשה, אבל עכשיו בגרסה 105 של Chromium אפשר לספק 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 מחייבות את הדפדפן לשלוח מחדש את גוף הבקשה לכתובת אתר אחרת. כדי לתמוך בכך, הדפדפן צריך לבצע אחסון זמני של תוכן הסטרימינג, מה שבאופן כללי ביסס את המטרה, ולכן הוא לא עושה זאת.

במקום זאת, אם הבקשה מכילה גוף סטרימינג והתשובה היא הפניה אוטומטית מסוג 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,
});

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

// 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.