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

Jake Archibald
Jake Archibald

Trên 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 báo cáo 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 tiêu điểm vào một trường nhập văn bản và bỏ đi tất cả tiêu đề, rồi đợi cho đến khi người dùng nhấn vào "gửi" trước khi gửi dữ liệu họ đã nhập.
  • Gửi dần dữ liệu được tạo trên ứng dụng, chẳng hạn như âm thanh, video hoặc dữ liệu đầu vào.
  • Tạo lại ổ cắm web qua HTTP/2 hoặc HTTP/3.

Nhưng vì đây là tính năng nền tảng web cấp thấp, nên bạn đừng bị giới hạn bởi các ý 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 cho việc phát trực tuyến theo yêu cầu.

Bản minh hoạ

Hình ảnh này cho thấy cách bạn có thể truyền dữ liệu từ người dùng đến máy chủ và gửi lại dữ liệu có thể được xử lý theo thời gian thực.

Vâng, đây không phải là ví dụ sáng tạo nhất, tôi chỉ muốn đơn giản thôi được không?

Tính năng này hoạt động như thế nào?

Trước đây, những cuộc phiêu lưu thú vị của việc tìm nạp dòng tin

Luồng phản hồi hiện đã có trong tất cả các trình duyệt hiện đại. 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 byte. Số lượng mảng bạn nhận được và kích thước của các mảng phụ thuộc vào tốc độ mạng. Nếu bạn đang sử dụng kết nối nhanh, bạn sẽ nhận được ít hơn nhưng các "đoạn" lớn hơn dữ liệu. Nếu bạn đang sử dụng kết nối chậm, bạn sẽ nhận được nhiều phầ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 chuyển đổi mới hơn nếu các trình duyệt mục tiêu hỗ trợ điều này:

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

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

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

Dù sao thì đó vẫn là luồng phản hồi. Điều mới 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 thì mới có thể bắt đầu yêu cầu. Tuy nhiên, 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 thông báo "Đây là yêu cầu chậm" đến máy chủ, mỗi từ một từ, với thời gian tạm dừng một giây giữa mỗi từ.

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

Quy định hạn chế

Yêu cầu truyền trực tuyến là một sức mạnh mới cho web nên chúng đi kèm với một số hạn chế:

Bán song công?

Để cho phép dùng luồng trong một yêu cầu, bạn cần đặt tuỳ chọn yêu cầu duplex thành 'half'.

Một tính năng ít biết của HTTP (tuy nhiên, việc đây có phải là hành vi chuẩn hay không phụ thuộc vào người bạn yêu cầu) là bạn có thể bắt đầu nhận phản hồi trong khi vẫn gửi yêu cầu. Tuy nhiên, ngôn ngữ này ít được biết đến, không được các máy chủ hỗ trợ tốt 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 đúng với tất cả các quá trình tìm nạp 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ố phương pháp triển khai, chẳng hạn như fetch trong Deno, được đặt mặc định là "full duplex" đối với tìm nạp theo luồng, nghĩa là phản hồi có thể có 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, duplex: 'half' cần được chỉ định trên 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 các yêu cầu truyền 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ần tìm nạp bằng một yêu cầu truyền trực tuyến, sau đó thực hiện một lần tìm nạp khác để nhận phản hồi truyền trực tuyến. Máy chủ sẽ cần một số cách để liên kết hai yêu cầu này, chẳng hạn như mã nhận dạng trong URL. Đó là cách hoạt động của bản minh hoạ.

Lệnh chuyển hướng bị hạn chế

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

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à lệnh chuyển hướng HTTP không phải 303, thì quá trình tìm nạp sẽ từ chối và lệnh chuyển hướng sẽ không được tuân theo.

Lệnh chuyển hướng 303 được cho phép 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.

Cần có CORS và kích hoạt quá trình kiểm tra

Các yêu cầu truyền trực tuyến 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, cần phải có CORS và các yêu cầu này luôn kích hoạt kiểm tra.

Không cho phép truyền trực tuyến các yêu cầu no-cors.

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

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

Mã hoá phân đoạn khá phổ biến đối với phản hồi HTTP/1.1, nhưng rất hiếm khi xảy ra yêu cầu, vì vậy dẫn đến rủi ro về khả năng tương thích.

Các vấn đề có thể xảy ra

Đây là một tính năng mới và vẫn còn ít người dùng trên Internet hiện nay. Dưới đâ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ợ yêu cầu phát trực tuyến. Thay vào đó, hãy đợi hệ thống nhận được toàn bộ yêu cầu rồi mới cho phép bạn xem yêu cầu. Cách này không hiệu quả. Thay vào đó, hãy sử dụng một máy chủ ứng dụng có hỗ trợ truyền trực tuyến, như NodeJS hoặc Deno.

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

Tình trạng không tương thích nằm ngoài tầm kiểm soát của bạn

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

Nếu muốn ngăn chặn loại quảng cáo này, bạn có thể tạo một "thử nghiệm 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 cần đó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 phương thức tìm nạp khác. Khi điều này xảy ra, bạn sẽ biết ứng dụng hỗ trợ truyền trực tuyến các yêu cầu hai đầu.

Phát hiện tính nă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 muốn biết, dưới đây là cách hoạt động của tính năng phát hiện:

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 phần nội dung. Vì vậy, nếu trình duyệt không hỗ trợ luồng yêu cầu, thì nội dung yêu cầu sẽ trở thành chuỗi "[object ReadableStream]". Khi dùng làm phần nội dung của một chuỗi, chuỗi này 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 đặt tiêu đề đó, thì chúng ta sẽ biết 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 các đối tượng yêu cầu, nhưng không cho phép sử dụng các luồng này với fetch, vì vậy, tuỳ chọn duplex sẽ được thử nghiệm mà Safari hiện không hỗ trợ.

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

Đôi khi, việc xử lý sự kiện phát trực tiếp sẽ dễ dàng hơn nếu bạn có một WritableStream. Bạn có thể làm điều này bằng cách sử dụng "danh tính" luồng, là một cặp có thể đọc/ghi có thể lấy bất kỳ thứ gì được truyền đến cuối có thể ghi và gửi đến cuối có thể đọc được. Bạn có thể tạo một trong những cách này bằng cách tạo TransformStream mà không cần bất kỳ đối số nào:

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ẽ nằm trong yêu cầu. Điều này cho phép bạn soạn các luồng phát trực tiếp cùng nhau. Ví dụ: sau đây là một ví dụ ngớ ngẩn, trong đó dữ liệu được tìm nạp từ một URL, nén rồi 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 tùy ý bằng gzip.