Ushbu maqoladagi kod va nomlar tushunishni osonlashtirish uchun soddalashtirilgan va o‘zgartirilgan misollardir.
Kirish
Men ishlayotgan loyihaga sun’iy intellektli chatbot funksiyasini qo‘shish vazifasini oldim. Foydalanuvchi savol kiritganda, sun’iy intellekt javob yaratishi va natijani chatbot kabi ekranda ko‘rsatishi kerak edi. Server sun’iy intellekt tomonidan qayta ishlash tugagach, natijani qaytaradigan qilib ishlab chiqilgan edi.
Backendda allaqachon Flux<ServerSentEvent<...>> qaytaradigan endpoint bor edi. SSE bilan ilk bor duch kelayotganim, kodni ham birinchi marta ko‘rayotganim uchun, uni frontendga ulashdan oldin nima ekanini o‘rganishga kirishdim. Ammo sinab ko‘rganimda, yakunlanganlik haqidagi xabar hech qachon kelmadi.
Sababini kutganimizdan tezroq topdik. Men frontend uchun mas’ul edim, shuning uchun ekranni SSE’ga allaqachon ulab qo‘ygan edim. Backend dasturchisi bilan alomatlarni birgalikda ko‘rib chiqib, ehtimoliy sabablarni toraytirdik. Backend dasturchisi server tomonidagi o‘zgarishlarni amalga oshirdi, men esa nima o‘zgargani va nima sababdan o‘zgargani haqidagi tushuntirishlarni tinglab, qayd qilib bordim. Ushbu maqolada SSE aslida qanday ishlashi va kuzatishimiz mumkin bo‘lgan alomatlardan boshlab sababni qanday aniqlaganimiz frontend nuqtayi nazaridan bayon qilinadi.
1. SSE nima?
SSE — server bitta ulanishni ochiq saqlab, ma’lumotlar mavjud bo‘lishi bilan ularni mijozga uzatib turadigan usul. Brauzerlarda buni qabul qilish uchun o‘rnatilgan API mavjud: EventSource.
Oddiy so‘rov va javob quyidagicha yakunlanadi.
클라이언트: GET /api/data
서버: [응답 완성] → 200 OK + 본문 전체 → 연결 종료
SSE javobi yakunlanmaydi.
클라이언트: GET /api/tasks/{taskId}/stream
서버: 200 OK, Content-Type: text/event-stream
서버: data: {"status":"GENERATING"}\n\n
서버: data: {"status":"GENERATING"}\n\n
서버: data: {"status":"SUCCESS"}\n\n
서버: (연결 종료)
Spetsifikatsiyaga ko‘ra, data: dan keyin ikkita qator uzilishi kelganda xabar tugaydi. Brauzer ushbu formatni tahlil qiladi va har safar bir bo‘lak kelganda message hodisasini yuzaga keltiradi.
Shuning uchun frontend tinglovchini bir marta ro‘yxatdan o‘tkazishi kifoya.
eventSource.addEventListener("message", handleEventMessage);
Shu tarzda ro‘yxatdan o‘tkazilgach, server 100 ta bo‘lak yuborsa, handleEventMessageham 100 marta chaqiriladi.
Bu backenddagi Fluxga bevosita mos keladi. Flux— vaqt davomida ketma-ket bir nechta qiymatni uzatib turadigan tur; Spring WebFlux esa Fluxdan kelgan har bir qiymatni data: {...}ko‘rinishida kodlab, javob oqimiga yozadi.
2. Boshqa usuldan foydalanganimizda nima bo‘lardi?
Aslida, bizda faqat bitta imkoniyat bor edi. Backendda SSE endpointi allaqachon mavjud bo‘lib, uni ulashimning o‘zi kifoya edi. Shunga qaramay, ulayotganimda bu haqiqatan ham SSE bo‘lishi shartmikan, boshqa usuldan foydalanganimizda qanday bo‘lardi, deb o‘yladim va shu bois uni muqobil usullar bilan taqqosladim.
Polling qisqa vaqt oralig‘ida so‘rov API’siga qayta-qayta murojaat qiladi. Uni amalga oshirish eng oson va istalgan server muhitida ishlaydi, biroq keraksiz so‘rovlarni ko‘paytiradi, javob tezligi esa polling oralig‘i bilan cheklanadi.
Long polling so‘rov yuboradi va server holat o‘zgarguncha javobni kechiktiradi. U pollingga qaraganda tezroq javob beradi, ammo server ulanishlarni uzoq vaqt ochiq saqlashi kerak bo‘ladi. SSE’dan ustun jihatlari aniq emas, shu bilan birga amalga oshirishni murakkablashtiradi.
|
Polling |
Long polling |
SSE |
WebSocket |
|
|---|---|---|---|---|
|
Yo‘nalish |
Bir tomonlama |
Bir tomonlama |
Bir tomonlama |
Ikki tomonlama |
|
Amalga oshirish qiyinligi |
Juda oson |
O‘rtacha |
Oson |
Nisbatan murakkab |
|
Javob tezligi |
Past |
O‘rtacha |
Yuqori |
Juda yuqori |
|
Avtomatik qayta ulanish |
N/A |
Bevosita amalga oshirilgan |
Brauzerning ichki qo‘llab-quvvatlashi |
Bevosita amalga oshirilgan |
Bizning holatimiz bir yo‘nalishli edi: server yaratgan natijalarni mijozga yuborishimiz kerak edi, xolos. Foydalanuvchi savol yuborganidan keyin mijoz serverga boshqa hech narsa yubormasdi, server esa natijani qayta ishlash tugagach bir marta yuborishi kerak edi.
Faqat shu talabni hisobga olsak, so‘rovlarni davriy yuborishning o‘zi kifoya qilardi. Biroq so‘rovlarni davriy yuborish natija qachon tayyor bo‘lishini bilmasdan, muntazam oraliqlarda so‘rov yuborishni talab qiladi va natijani qachon olishingiz shu oraliqqa bog‘liq bo‘ladi. SSE yordamida mijoz bitta ulanishni kutib turishi va natija mavjud bo‘lishi bilanoq uni olishi mumkin. Bu kutish holatini ko‘rsatishi, so‘ng natija tayyor bo‘lishi bilan darhol unga o‘tishi kerak bo‘lgan chatbot interfeysiga mos edi. Orqaga qarab, SSE’dan foydalanish ham o‘rinli qaror bo‘lgan deb o‘ylayman, chunki u allaqachon mavjud edi.
3. JWT autentifikatsiyasi bilan bog‘liq muammolar va kutubxona tanlash
Chatbotimiz avval savol yuborish va vazifa ID’sini olish uchun API’ga murojaat qilar, so‘ng shu ID yordamida SSE ulanishini ochardi.
Frontendni amalga oshirish qiyin ko‘rinmadi. EventSource nusxasini yarating va tinglovchi biriktiring — bo‘ldi.
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};
Biroq sinab ko‘rganimda darhol autentifikatsiya xatosiga duch keldim.
Brauzerga o‘rnatilgan EventSource maxsus sarlavhalarni qo‘llab-quvvatlamaydi. Konstruktoriga faqat URL va withCredentials ikkalasini ham uzatish mumkin. Loyihamizda autentifikatsiya uchun so‘rov sarlavhalarida JWT tokenlaridan foydalanilardi, ammo bu sarlavhani qo‘shishning hech qanday imkoni yo‘q edi.
Bu muammoni hal qiladigan ikkita kutubxonani topdim.
event-source-polyfill maxsus sarlavhalarni qo‘llab-quvvatlagan holda EventSource API’siga taqlid qilish uchun ichkarida XHR’dan foydalanadi. Uning API’si brauzerga o‘rnatilgan EventSource bilan deyarli bir xil, shuning uchun addEventListener yoki close() kabi metodlardan foydalanadigan mavjud kod tuzilmalarini o‘zgartirmasdan ishlatish mumkin.
@microsoft/fetch-event-source — SSE’ni polifill sifatida emas, balki fetch() asosida qayta amalga oshiradigan kutubxona. U fetch taqdim etadigan barcha imkoniyatlardan foydalanish mumkin: POST so‘rovlarini yuborish, tekshirish uchun javob obyektiga bevosita murojaat qilish va qayta urinish strategiyasini to‘liq boshqarish. Shuningdek, sahifa fonga o‘tganda ulanishni yopadi va yana ko‘rinadigan bo‘lganda qayta ulanadi.
Faqat imkoniyatlar nuqtayi nazaridan qaraganda, keyingi kutubxona moslashuvchanroq. Biroq backendimiz GET’dan foydalanardi, loyihaning boshqa ekranida esa event-source-polyfill allaqachon ishlatilayotgan edi. Bir maqsad uchun ikkita kutubxonani qo‘shishga hech qanday sabab yo‘q edi. Mavjud kod tuzilmasini deyarli o‘zgartirmasdan saqlab qolish imkoniyati ham muhim omil bo‘ldi. Shu bois birinchi kutubxonani tanladik.
import { EventSourcePolyfill } from "event-source-polyfill";
const eventSource = new EventSourcePolyfill(url, {
headers: {
Authorization: `Bearer ${token}`,
},
heartbeatTimeout: 5 * 60 * 1000,
});
eventSource.addEventListener("message", handleEventMessage);
eventSource.addEventListener("error", handleEventError);
4. Alomat: birinchi xabardan keyin javob kelmadi
Sinab ko‘rganimda, birinchi xabar bemalol keldi.
eventMessage { status: "GENERATING", message: "Generating response...", ... }
Ammo shu bilan tugadi. Sun’iy intellekt javobini yaratib bo‘lishi kerak bo‘lgan vaqtdan ancha o‘tgan bo‘lsa ham, yakunlanganlik haqidagi xabar kelmadi. Besh daqiqa kutganimdan keyin quyidagi xatoga duch keldim.
EventSource failed.
Error: No activity within 300000 milliseconds. 277 chars received. Reconnecting.
heartbeatTimeout uchun belgilangan besh daqiqa davomida hech qanday ma’lumot kelmadi, shuning uchun kutubxona ulanishni uzib, qayta ulanishga urindi. 277 ta belgi — ya’ni faqat boshidagi bitta xabar kelgan.
5. Asosiy sababni tahlil qilish
5-1. Frontend va takroriy xabar ehtimolini istisno qilish
Avvalo, frontendning bunga aloqador bo‘lishi mumkinligini istisno qilishim kerak edi. Chatbotni brauzerda bir marta ishga tushirgach, vazifa ID’si va tokenni dasturchi vositalaridan nusxaladim va API’ni sinash vositasi yordamida o‘sha endpointga so‘rov yubordim.
Natija o‘sha-o‘sha edi. Faqat boshidagi bitta xabar keldi, keyin esa oqim to‘xtadi. React, kutubxona va tinglovchini ro‘yxatdan o‘tkazish qatnashmagan so‘rovda ham ayni alomat kuzatilgani uchun frontend gumonlanuvchilar ro‘yxatidan chiqarildi. Javob sarlavhasidagi Content-Type qiymati ham text/event-stream edi, demak, bu format bilan bog‘liq muammo ham emasdi.
Xuddi shu so‘rovni bir necha marta yuborganimda, har safar GENERATING Xabar darhol keldi va uning mazmuni har doim bir xil edi. Backend kodini ko‘zdan kechirib, sababini topdim.
public Flux<ServerSentEvent<TaskMessage>> subscribe(AiTask task) {
String taskId = task.getId();
Sinks.Many<TaskMessage> channel = this.channelMap.computeIfAbsent(taskId,
id -> Sinks.many().replay().all());
channel.tryEmitNext(TaskMessage.of(task, GENERATING));
return channel.asFlux().map(m -> ServerSentEvent.builder(m).build());
}
tryEmitNext metod tanasida, hatto Flux qaytarilishidan oldin ham, shartsiz bajariladi. Xabar server jarayon borligini aniqlab, yuborgani uchun yaratilmagan; u aynan shu yerda, shunchaki so‘rov kelgani uchun yaratilgan. Boshqacha aytganda, so‘rovim hodisani qabul qilmayotgan, balki uni yaratayotgan edi. Holat ham doim GENERATING qilib belgilangan edi, shu sababli uni necha marta chaqirmay, mazmuni bir xil bo‘lib qolaverardi.
5-2. Yakunlanish xabari nega tashlab yuborildi
Qolgan savol — yakunlanish xabari nega hech qachon kelmaganida edi. Yakunlanishni qayta ishlash kodi quyidagicha ko‘rinardi.
private void completeTask(TaskMessage message) {
String taskId = message.getTaskId();
Sinks.Many<TaskMessage> channel = this.channelMap.remove(taskId);
if (channel != null) {
channel.tryEmitNext(message);
channel.tryEmitComplete();
}
}
Kanal channelMap’ga faqat bir marta — avval ko‘rgan computeIfAbsent ichida — qo‘shilgan edi. Ammo bu kod faqat frontend SSE ulanishini o‘rnatishga uringanda ishga tushadi.
Muammo AI chaqiruvi darhol xato bilan tugab, odatdagidan ancha tez yakunlanganda yuz berdi. Frontendning SSE ulanishi o‘rnatilishidan oldin yakunlanishni qayta ishlash bajarildi. Umumiy jarayon quyidagicha edi.
Avval ro‘yxatdan o‘tkazilmagan kanalni olishga urinayotgani sababli, remove metodi null qaytardi. Shartli blokka kira olmagani uchun, yakunlanish xabarini qabul qiladigan tomon yo‘q edi va xabar jurnallarda birorta ham qator qoldirmasdan tashlab yuborildi. Bundan tashqari, yakunlanishni qayta ishlovchi kod kanalni olishi bilanoq uni xaritadan olib tashlaydigan qilib tuzilgan edi. Demak, kanal mavjud bo‘lgan taqdirda ham, kech ulangan mijoz uni topa olmasdi.
6. Yechim — kanallarni ro‘yxatdan o‘tkazish va ularni olish usulini o‘zgartirish
Bunga ikki sabab bor edi. Har bir vazifa uchun kanal vazifa ro‘yxatdan o‘tkazilayotganda xaritaga qo‘shilishi kerak edi, ammo bu bosqich bajarilmagan. Bundan tashqari, yakunlanishni qayta ishlash vaqtida kanal olinishi bilanoq xaritadan olib tashlanardi. Backend muhandisi bu muammoni hal qilib, uchta joyga o‘zgartirish kiritdi.
Birinchidan, vazifani ro‘yxatdan o‘tkazayotganda kanalni xaritaga qo‘shdik. AI chaqiruvi hodisasi bu metod tugagandan keyin alohida va asinxron tarzda yuboriladi.
public String registerTask(String prompt) {
AiTask task = taskRepository.save(AiTask.create(prompt));
this.channelMap.put(task.getId(), Sinks.many().replay().all());
return task.getId();
}
Keyin, yakunlanishni qayta ishlovchi kodda kanalni olish usulini remove dan get ga o‘zgartirdik.
private void completeTask(TaskMessage message) {
Sinks.Many<TaskMessage> channel = this.channelMap.get(message.getTaskId());
if (channel != null) {
channel.tryEmitNext(message);
channel.tryEmitComplete();
}
}
Nihoyat, kanalni olib tashlashni SSE ulanishi tomoniga ko‘chirdik. Obunachi yakunlanish signalini olgach, kanal xaritadan olib tashlanadi. Shuningdek, obunachi allaqachon ulangan bo‘lsa, navbatdagi xabar qayta yuborilishining oldini oladigan shart qo‘shdik.
public Flux<ServerSentEvent<TaskMessage>> subscribe(AiTask task) {
String taskId = task.getId();
Sinks.Many<TaskMessage> channel = this.channelMap.computeIfAbsent(taskId,
id -> Sinks.many().replay().all());
if (channel.currentSubscriberCount() == 0) {
channel.tryEmitNext(TaskMessage.of(task, GENERATING));
}
return channel.asFlux()
.doOnComplete(() -> this.channelMap.remove(taskId))
.map(m -> ServerSentEvent.builder(m).build());
}
Endi, hatto SSE ulanishi o‘rnatilishidan oldin AI ishini tugatsa ham, yakunlanish xabari xaritada saqlanib qoladigan kanalda saqlanadi. Frontend kech ulansa, o‘sha kanalga obuna bo‘ladi va replay().all() shu vaqt ichida to‘plangan barcha xabarlarni boshidan qayta yuboradi, natijada frontend yakunlanish xabarini ham olishi mumkin. Bu paytga kelib kanal allaqachon yopilgan bo‘lgani uchun, ulanish o‘rnatilganda yuborilishi kerak bo‘lgan navbatdagi xabar yetkazilmaydi va e’tiborsiz qoldiriladi. Natija olingach, kanal ham xaritadan olib tashlanadi.
Ulanish birinchi bo‘lib o‘rnatilganmi yoki AI birinchi bo‘lib ishini tugatganmi — endi frontend olgan natija bir xil edi.
Xulosa
Menda SSEning o‘zidan ko‘ra, muammo doirasini toraytirish jarayoni ko‘proq taassurot qoldirdi. Dastlab sarlavhalar yoki kutubxona konfiguratsiyasi kabi o‘z kodimdan shubhalandim. Ammo API sinov vositasi yordamida ayni so‘rovni yuborganimda, muammo frontendda emasligi darhol ayon bo‘ldi. Buni avval tasdiqlab olish backend muhandisi bilan gaplashganda qayerdan izlash kerakligini ham aniqlashtirdi.
Server kodini birgalikda kuzatib borish asinxron tuzilmada bajarilish tartibi qanchalik muhimligini ham o‘rgatdi. Yuborish va qabul qilish kodlarining har biri alohida qaralganda to‘g‘ri tuyulardi, ammo tartib qabul qiluvchi kanal qachon yaratilishi va qachon olib tashlanishini belgilardi. Frontenddagi vazifangiz faqat ulanishni boshqarish bo‘lsa ham, shunga o‘xshash muammolarni tezda aniqlash uchun server xabarlarni yuborishdan oldin ularni qachon va qayerda joylashtirishini tushunishingiz kerakligini angladim.
Pong