Yêu cầu truyền trực tuyến bằng API tìm nạp

Jake Archibald
Jake Archibald

Kể từ Chromium 105, bạn có thể bắt đầu một yêu cầu trước khi có toàn bộ nội dung bằng cách sử dụng Streams API.

Bạn có thể sử dụng thông tin này để:

  • Khởi động máy chủ. Nói cách khác, bạn có thể bắt đầu yêu cầu khi người dùng tập trung vào một trường nhập văn bản và loại bỏ tất cả các tiêu đề, sau đó đợi cho đến khi người dùng nhấn vào "gửi" trước khi gửi dữ liệu mà họ đã nhập.
  • Dần dần gửi dữ liệu được tạo trên máy khách, chẳng hạn như dữ liệu âm thanh, video hoặc dữ liệu đầu vào.
  • Tạo lại các ổ cắm web qua HTTP/2 hoặc HTTP/3.

Nhưng vì đây là một tính năng nền tảng web cấp thấp, nên đừng bị giới hạn bởi ý tưởng của tôi. Có thể bạn sẽ nghĩ ra một trường hợp sử dụng thú vị hơn nhiều cho tính năng truyền trực tuyến yêu cầu.

Trước đó, trong những cuộc phiêu lưu thú vị của các luồng tìm nạp

Luồng phản hồi đã có trong tất cả các trình duyệt hiện đại một thời gian. Chúng cho phép bạn truy cập vào các phần của phản hồi khi chúng đến từ máy chủ:

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');

Mỗi value là một Uint8Array gồm các byte. Số lượng mảng bạn nhận được và kích thước của các mảng này phụ thuộc vào tốc độ mạng. Nếu đang dùng một kết nối nhanh, bạn sẽ nhận được ít "khối" dữ liệu hơn nhưng kích thước lớn hơn. Nếu đang dùng kết nối chậm, bạn sẽ nhận được nhiều đoạn nhỏ hơn.

Nếu muốn chuyển đổi các byte thành văn bản, bạn có thể sử dụng TextDecoder hoặc luồng biến đổi mới hơn nếu các trình duyệt mục tiêu của bạn hỗ trợ luồng này:

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

TextDecoderStream là một luồng biến đổi lấy tất cả các khối Uint8Array đó và chuyển đổi chúng thành chuỗi.

Luồng rất hữu ích vì bạn có thể bắt đầu hành động dựa trên dữ liệu khi dữ liệu đến. Ví dụ: nếu đang nhận được danh sách gồm 100 "kết quả", bạn có thể hiển thị kết quả đầu tiên ngay khi nhận được, thay vì đợi tất cả 100 kết quả.

Dù sao thì đó cũng là luồng phản hồi. Điều mới mẻ và thú vị mà tôi muốn nói đến là luồng yêu cầu.

Nội dung yêu cầu phát trực tuyến

Yêu cầu có thể có nội dung:

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

Trước đây, bạn cần chuẩn bị sẵn toàn bộ nội dung trước khi có thể bắt đầu yêu cầu, nhưng giờ đây trong Chromium 105, bạn có thể cung cấp ReadableStream dữ liệu của riêng mình:

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',
});

Thao tác trên sẽ gửi "This is a slow request" (Đây là một yêu cầu chậm) đến máy chủ, mỗi lần một từ, với thời gian tạm dừng là một giây giữa mỗi từ.

Mỗi đoạn nội dung yêu cầu cần là một Uint8Array byte, vì vậy, tôi đang sử dụng pipeThrough(new TextEncoderStream()) để thực hiện việc chuyển đổi cho tôi.

Quy định hạn chế

Yêu cầu truyền phát trực tuyến là một tính năng mới cho web, vì vậy, tính năng này có một số hạn chế:

Bán song công?

Để cho phép sử dụng các luồng trong một yêu cầu, bạn cần đặt lựa chọn yêu cầu duplex thành 'half'.

Một tính năng ít người biết đến của HTTP (mặc dù hành vi này có phải là tiêu chuẩn hay không còn tuỳ thuộc vào người bạn hỏi) là bạn có thể bắt đầu nhận phản hồi trong khi vẫn đang gửi yêu cầu. Tuy nhiên, giao thức này ít được biết đến đến mức không được các máy chủ hỗ trợ đầy đủ và không được bất kỳ trình duyệt nào hỗ trợ.

Trong trình duyệt, phản hồi sẽ không bao giờ có sẵn cho đến khi nội dung yêu cầu được gửi đầy đủ, ngay cả khi máy chủ gửi phản hồi sớm hơn. Điều này áp dụng cho tất cả hoạt động tìm nạp của trình duyệt.

Mẫu mặc định này được gọi là "bán song công". Tuy nhiên, một số cách triển khai, chẳng hạn như fetch trong Deno, mặc định là "song công" để truyền trực tuyến các lượt tìm nạp, nghĩa là phản hồi có thể xuất hiện trước khi yêu cầu hoàn tất.

Vì vậy, để giải quyết vấn đề về khả năng tương thích này, trong trình duyệt, bạn cần chỉ định duplex: 'half' cho các yêu cầu có nội dung luồng.

Trong tương lai, duplex: 'full' có thể được hỗ trợ trong các trình duyệt cho yêu cầu phát trực tuyến và không phát trực tuyến.

Trong thời gian chờ đợi, cách tốt nhất tiếp theo để giao tiếp song công là thực hiện một lệnh tìm nạp bằng yêu cầu truyền phát trực tiếp, sau đó thực hiện một lệnh tìm nạp khác để nhận phản hồi truyền phát trực tiếp. Máy chủ sẽ cần một cách nào đó để liên kết hai yêu cầu này, chẳng hạn như một mã nhận dạng trong URL. Đó là cách hoạt động của bản minh hoạ.

Chuyển hướng bị hạn chế

Một số dạng chuyển hướng HTTP yêu cầu trình duyệt gửi lại nội dung yêu cầu đến một URL khác. Để hỗ trợ điều này, trình duyệt sẽ phải lưu vào bộ nhớ đệm nội dung của luồng. Điều này có vẻ như không cần thiết, nên trình duyệt sẽ không làm như vậy.

Thay vào đó, nếu yêu cầu có nội dung truyền trực tuyến và phản hồi là một lệnh chuyển hướng HTTP khác với 303, thì lệnh tìm nạp sẽ từ chối và lệnh chuyển hướng sẽ không được tuân theo.

Cho phép chuyển hướng 303 vì chúng thay đổi rõ ràng phương thức thành GET và loại bỏ nội dung yêu cầu.

Yêu cầu CORS và kích hoạt một yêu cầu kiểm tra trước

Yêu cầu truyền phát trực tiếp có nội dung nhưng không có tiêu đề Content-Length. Đó là một loại yêu cầu mới, vì vậy, CORS là bắt buộc và những yêu cầu này luôn kích hoạt một yêu cầu kiểm tra trước.

Không được phép gửi yêu cầu phát trực tuyến no-cors.

Không hoạt động trên HTTP/1.x

Lệnh tìm nạp sẽ bị từ chối nếu kết nối là HTTP/1.x.

Điều này là do theo các quy tắc HTTP/1.1, phần nội dung yêu cầu và phản hồi cần gửi tiêu đề Content-Length để phía bên kia biết lượng dữ liệu mà họ sẽ nhận được, hoặc thay đổi định dạng của thông báo để sử dụng mã hoá theo khối. Với phương thức mã hoá theo khối, phần nội dung sẽ được chia thành nhiều phần, mỗi phần có độ dài nội dung riêng.

Mã hoá theo khối khá phổ biến đối với phản hồi HTTP/1.1, nhưng rất hiếm đối với yêu cầu, vì vậy, đây là một rủi ro quá lớn về khả năng tương thích.

Các vấn đề tiềm ẩn

Đây là một tính năng mới và hiện chưa được sử dụng nhiều trên Internet. Sau đây là một số vấn đề cần lưu ý:

Không tương thích ở phía máy chủ

Một số máy chủ ứng dụng không hỗ trợ các yêu cầu truyền phát trực tiếp mà thay vào đó, chờ nhận được toàn bộ yêu cầu trước khi cho phép bạn xem bất kỳ phần nào của yêu cầu đó. Điều này có phần phản tác dụng. Thay vào đó, hãy dùng một máy chủ ứng dụng hỗ trợ truyền trực tuyến, chẳng hạn như NodeJS hoặc Deno.

Nhưng bạn vẫn chưa thoát khỏi tình trạng này! Máy chủ ứng dụng, chẳng hạn như NodeJS, thường nằm sau một máy chủ khác, thường được gọi là "máy chủ giao diện người dùng", máy chủ này có thể nằm sau một CDN. Nếu bất kỳ máy chủ nào trong số đó quyết định lưu vào bộ nhớ đệm yêu cầu trước khi chuyển yêu cầu đó đến máy chủ tiếp theo trong chuỗi, thì bạn sẽ mất lợi ích của tính năng truyền trực tuyến yêu cầu.

Vấn đề không tương thích nằm ngoài tầm kiểm soát của bạn

Vì tính năng này chỉ hoạt động qua HTTPS, nên bạn không cần lo lắng về các proxy giữa bạn và người dùng, nhưng người dùng có thể đang chạy một proxy trên máy của họ. Một số phần mềm bảo vệ Internet làm như vậy để cho phép phần mềm này giám sát mọi thứ diễn ra giữa trình duyệt và mạng. Ngoài ra, có thể có trường hợp phần mềm này lưu vào bộ nhớ đệm nội dung yêu cầu.

Nếu muốn ngăn chặn điều này, bạn có thể tạo một "kiểm thử tính năng" tương tự như bản minh hoạ ở trên, trong đó bạn cố gắng truyền trực tuyến một số dữ liệu mà không đóng luồng. Nếu nhận được dữ liệu, máy chủ có thể phản hồi thông qua một lần tìm nạp khác. Khi điều này xảy ra, bạn biết rằng ứng dụng hỗ trợ các yêu cầu truyền phát trực tiếp từ đầu đến cuối.

Phát hiện đối tượng

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 {
  // …
}

Nếu bạn tò mò, hãy xem cách hoạt động của tính năng phát hiện đối tượng:

Nếu trình duyệt không hỗ trợ một loại body cụ thể, thì trình duyệt sẽ gọi toString() trên đối tượng và sử dụng kết quả làm nội dung. Do đó, nếu trình duyệt không hỗ trợ các luồng yêu cầu, thì phần nội dung yêu cầu sẽ trở thành chuỗi "[object ReadableStream]". Khi một chuỗi được dùng làm nội dung, chuỗi đó sẽ đặt tiêu đề Content-Type thành text/plain;charset=UTF-8 một cách thuận tiện. Vì vậy, nếu tiêu đề đó được đặt, thì chúng ta biết rằng trình duyệt không hỗ trợ các luồng trong đối tượng yêu cầu và chúng ta có thể thoát sớm.

Safari hỗ trợ các luồng trong đối tượng yêu cầu, nhưng không cho phép sử dụng các luồng đó với fetch, vì vậy, lựa chọn duplex sẽ được kiểm thử. Hiện tại, Safari không hỗ trợ lựa chọn này.

Sử dụng với các luồng có thể ghi

Đôi khi, bạn sẽ dễ dàng làm việc với các luồng hơn khi có WritableStream. Bạn có thể thực hiện việc này bằng cách sử dụng luồng "danh tính". Đây là một cặp có thể đọc/ghi, nhận mọi thứ được truyền đến đầu có thể ghi của nó và gửi đến đầu có thể đọc. Bạn có thể tạo một trong những đối tượng này bằng cách tạo TransformStream mà không có đối số:

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

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

Giờ đây, mọi nội dung bạn gửi đến luồng có thể ghi sẽ là một phần của yêu cầu. Điều này cho phép bạn kết hợp các luồng với nhau. Ví dụ: sau đây là một ví dụ đơn giản về trường hợp dữ liệu được tìm nạp từ một URL, nén và gửi đến một URL khác:

// 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,
});

Ví dụ trên sử dụng luồng nén để nén dữ liệu tuỳ ý bằng gzip.