SSEGWSW: Server-Terkirim Acara Gateway oleh Pekerja Layanan

Hai

Nama saya Sasha dan saya bekerja sebagai arsitek di Tinkoff Business.

Pada artikel ini saya ingin berbicara tentang cara mengatasi batas browser pada jumlah koneksi HTTP yang berumur panjang dalam domain yang sama menggunakan pekerja layanan.

Jika Anda mau, silakan lewati latar belakang, uraian masalah, cari solusi, dan segera lanjutkan ke hasilnya.

SSEGWSW

Latar belakang


Sekali waktu di Tinkoff Business ada obrolan yang berhasil di Websocket .

Setelah beberapa waktu, ia tidak lagi cocok dengan desain akun pribadinya, dan secara umum ia sudah lama meminta penulisan ulang dari sudut 1,6 ke sudut 2+. Saya memutuskan bahwa sudah waktunya untuk mulai memperbaruinya. Seorang kolega-pendukung menemukan bahwa frontend obrolan akan berubah, dan menyarankan pada saat yang sama mengulang API, khususnya - mengubah transportasi dari websocket ke SSE (peristiwa yang dikirim server) . Dia menyarankan ini karena ketika memperbarui konfigurasi NGINX semua koneksi terputus, yang kemudian menyakitkan untuk dipulihkan.

Kami membahas arsitektur solusi baru dan sampai pada kesimpulan bahwa kami akan menerima dan mengirim data menggunakan permintaan HTTP biasa. Misalnya, kirim pesan POST: / api / kirim-pesan , dapatkan daftar dialog GET: / api / daftar percakapan, dan sebagainya. Dan peristiwa tak sinkron seperti "pesan baru dari teman bicara" akan dikirim melalui SSE. Jadi kami akan meningkatkan toleransi kesalahan aplikasi: jika koneksi SSE terputus, obrolan akan tetap berfungsi, hanya saja ia tidak akan menerima notifikasi waktu nyata.

Selain obrolan di websocket, kami mengejar acara untuk komponen "pemberitahuan tipis". Komponen ini memungkinkan Anda untuk mengirim berbagai pemberitahuan ke akun pribadi pengguna, misalnya, bahwa impor akun, yang mungkin memakan waktu beberapa menit, telah berhasil diselesaikan. Untuk sepenuhnya meninggalkan websocket, kami memindahkan komponen ini ke koneksi SSE yang terpisah.

Masalah


Ketika Anda membuka satu tab browser, dua koneksi SSE dibuat: satu untuk obrolan dan satu untuk pemberitahuan halus. Baiklah, biarkan mereka diciptakan. Maaf atau apa? Kami tidak merasa menyesal, tetapi browser merasa menyesal! Mereka memiliki batasan jumlah koneksi persisten bersamaan untuk suatu domain . Coba tebak berapa banyak di Chrome? Benar enam! Saya membuka tiga tab - Saya mencetak seluruh kumpulan koneksi dan Anda tidak bisa lagi membuat permintaan HTTP. Ini berlaku untuk protokol HTTP / 1.x. Dalam HTTP / 2 tidak ada masalah seperti itu karena multiplexing.

Ada beberapa cara untuk mengatasi masalah ini di tingkat infrastruktur:

  1. Pecahan domain.
  2. HTTP / 2.

Kedua metode ini tampaknya mahal, karena banyak infrastruktur yang harus terkena dampak.

Karenanya, sebagai permulaan, kami mencoba menyelesaikan masalah di sisi browser. Gagasan pertama adalah membuat semacam transportasi antar tab, misalnya, melalui LocalStorage atau Broadcast Channel API .

Artinya adalah ini: kita membuka koneksi SSE hanya dalam satu tab dan mengirim data ke yang lain. Solusi ini juga tidak terlihat optimal, karena akan membutuhkan pelepasan seluruh 50 SPA, yang merupakan akun pribadi Tinkoff Business. Melepaskan 50 aplikasi juga mahal, jadi saya terus mencari cara lain.

Solusi


Saya baru-baru ini bekerja dengan pekerja layanan dan berpikir: apakah mungkin menerapkannya dalam situasi ini?

Untuk menjawab pertanyaan ini, Anda harus terlebih dahulu memahami apa yang biasanya dilakukan oleh pekerja layanan? Mereka dapat mem-proxy permintaan, tampilannya seperti ini:

self.addEventListener('fetch', event => { const response = self.caches.open('example') .then(caches => caches.match(event.request)) .then(response => response || fetch(event.request)); event.respondWith(response); }); 

Kami mendengarkan acara untuk permintaan HTTP dan merespons sesuka kami. Dalam hal ini, kami mencoba merespons dari cache, dan jika tidak berhasil, maka kami membuat permintaan ke server.

Oke, mari kita coba untuk mencegat koneksi SSE dan menjawabnya:

 self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } event.respondWith(new Response('Hello!')); }); 

Dalam permintaan jaringan, kita melihat gambar berikut:

gambar

Dan di konsol, ini:

gambar

Sudah tidak buruk. Permintaan dicegat, tetapi SSE tidak menginginkan respons dalam bentuk teks / polos , tetapi menginginkan teks / aliran acara . Bagaimana cara membuat streaming sekarang? Tetapi bisakah saya bahkan merespons dengan aliran dari pekerja layanan? Baiklah mari kita lihat:

gambar

Hebat! Kelas Respons diambil sebagai body ReadableStream . Setelah membaca dokumentasi , Anda dapat mengetahui bahwa ReadableStream memiliki pengontrol yang memiliki metode enqueue () - dengan bantuannya Anda dapat mengalirkan data. Cocok, bawa!

 self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } const responseText = 'Hello!'; const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); const stream = new ReadableStream({start: controller => controller.enqueue(responseData)}); const response = new Response(stream); event.respondWith(response); }); 

gambar

Tidak ada kesalahan, koneksi hang dalam status pending dan tidak ada data tiba di sisi klien. Membandingkan permintaan saya dengan permintaan server nyata, saya menyadari bahwa jawabannya ada di header. Untuk permintaan SSE, tajuk berikut harus ditentukan:

 const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', }; 

Ketika Anda menambahkan header ini, koneksi akan terbuka dengan sukses, tetapi data tidak akan diterima di sisi klien. Ini jelas, karena Anda tidak bisa hanya mengirim teks acak - pasti ada beberapa format.

Di javascript.info, format data di mana Anda ingin mengirim data dari server dijelaskan dengan baik . Dapat dengan mudah dijelaskan dengan satu fungsi:

 const sseChunkData = (data: string, event?: string, retry?: number, id?: number): string => Object.entries({event, id, data, retry}) .filter(([, value]) => ![undefined, null].includes(value)) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n'; 

Untuk mematuhi format SSE, server harus mengirim pesan yang dipisahkan oleh jeda baris ganda \ n \ n .

Pesan terdiri dari bidang-bidang berikut:

  • data - badan pesan, beberapa data dalam satu baris ditafsirkan sebagai satu pesan, dipisahkan oleh jeda baris \ n;
  • id - memperbarui properti lastEventId yang dikirim dalam header Last-Event-ID saat menghubungkan kembali;
  • coba lagi - penundaan yang disarankan sebelum menghubungkan kembali dalam milidetik, tidak dapat diatur menggunakan JavaScript;
  • acara - nama acara pengguna, ditunjukkan sebelum data.

Tambahkan tajuk yang diperlukan, ubah jawaban ke format yang diinginkan dan lihat apa yang terjadi:

 self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } const sseChunkData = (data, event, retry, id) => Object.entries({event, id, data, retry}) .filter(([, value]) => ![undefined, null].includes(value)) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n'; const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', }; const responseText = sseChunkData('Hello!'); const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); const stream = new ReadableStream({start: controller => controller.enqueue(responseData)}); const response = new Response(stream, {headers: sseHeaders}); event.respondWith(response); }); 

gambar

Astaga! Ya, saya membuat koneksi SSE tanpa server!

Hasil


Sekarang kita berhasil mencegat permintaan SSE dan meresponsnya tanpa melampaui browser.

Awalnya, idenya adalah untuk membuat koneksi dengan server, tetapi hanya satu hal - dan darinya mengirim data ke tab. Ayo lakukan!

 self.addEventListener('fetch', event => { const {headers, url} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; //   SSE- if (!isSSERequest) { return; } //    SSE const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', }; // ,    SSE const sseChunkData = (data, event, retry, id) => Object.entries({event, id, data, retry}) .filter(([, value]) => ![undefined, null].includes(value)) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n'; //    ,   โ€” url,  โ€” EventSource const serverConnections = {}; //   url             const getServerConnection = url => { if (!serverConnections[url]) serverConnections[url] = new EventSource(url); return serverConnections[url]; }; //          const onServerMessage = (controller, {data, type, retry, lastEventId}) => { const responseText = sseChunkData(data, type, retry, lastEventId); const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); controller.enqueue(responseData); }; const stream = new ReadableStream({ start: controller => getServerConnection(url).onmessage = onServerMessage.bind(null, controller) }); const response = new Response(stream, {headers: sseHeaders}); event.respondWith(response); }); 

Kode yang sama di github.
Saya mendapat solusi yang cukup sederhana untuk tugas yang tidak terlalu sepele. Tapi, tentu saja, masih banyak nuansa. Misalnya, Anda harus menutup koneksi ke server saat menutup semua tab, mendukung protokol SSE, dan sebagainya.

Kami telah berhasil memutuskan semua ini - saya yakin tidak akan sulit bagi Anda!

Source: https://habr.com/ru/post/id471718/


All Articles