WebSocket jako sygnał: zlecenia kierowcy bez pollingu
Aplikacja kierowcy pytała o zlecenia co 30 sekund. Teraz WebSocket mówi tylko, że coś się zmieniło, dane przychodzą z API, a polling został jako zapas.
Opublikowano: 4 min czytania
Autor: Łukasz Głowacz, Full-Stack Engineer · Read in English

Spis treści
Aplikacja kierowcy w BusiKM do niedawna pytała serwer o listę zleceń co 30 sekund. Dyspozytor zmieniał status albo przypisywał nowe zlecenie, a kierowca widział to z opóźnieniem do pół minuty, mimo że backend już wcześniej wysyłał takie zmiany przez WebSocket do panelu webowego. 9 października podłączyłem ten sam kanał w aplikacji mobilnej. Najważniejsza decyzja nie dotyczyła samego WebSocketu, tylko tego, co zrobić z wiadomością, która przez niego przychodzi.
Backend już wysyłał zdarzenia
Każda zmiana zlecenia w backendzie, czyli status, przypisanie, odpięcie, utworzenie i usunięcie, przechodzi przez jeden moduł broadcast.py. Wysyła on zdarzenie przez Django Channels i Redisa do kilku grup: całej firmy, mapy floty, a jeśli zlecenie ma przypisanego kierowcę, także do jego osobnej grupy orders_driver_{id}. Kierowca dostaje więc tylko zdarzenia swoich zleceń, a nie całej firmy.
Jedna rzecz z tego modułu ma znaczenie dla klienta. Wysyłka jest efektem ubocznym zapisu, a nie jego częścią:
for group in groups:
try:
async_to_sync(layer.group_send)(group, {"type": channel_type, "payload": payload})
except Exception as exc:
# A broadcast is a side effect. Redis being down must not fail POST /orders/.
logger.warning("Order broadcast %s failed for group=%s: %s", channel_type, group, exc)Jeśli Redis nie działa, zapis zlecenia się udaje, a zdarzenie po prostu nie wychodzi. To dobra decyzja dla backendu, ale oznacza, że klient nie może zakładać, że dostanie każde zdarzenie. Ten fakt ustawił resztę projektu po stronie aplikacji.
Zdarzenie jako sygnał, a nie dane
Zdarzenie zmiany statusu niesie numer zlecenia, stary i nowy status, czas zmiany i kilka innych pól. Kusi, żeby wstawić te wartości prosto do cache aplikacji i nie pytać serwera wcale. Nie zrobiłem tego. Zdarzenie nie niesie pełnego zlecenia, a ekran szczegółów pokazuje dużo więcej, na przykład etapy trasy i dokumenty. Łatanie cache częściowymi danymi daje stan, którego nie ma nigdzie indziej: nowy status obok starych etapów albo zlecenie na liście, które powinno z niej zniknąć po zmianie statusu.
Dlatego aplikacja czyta ze zdarzenia tylko dwie rzeczy: typ i identyfikator zlecenia. Resztę traktuje jak sygnał „coś się zmieniło” i pobiera aktualne dane z API, tak jak przy zwykłym odświeżeniu.

/** Order event from a WS frame; `connected`, `pong` and malformed frames return null. */
export const parseOrderWsEvent = (raw: unknown): OrderWsEvent | null => {
// ...JSON.parse with try/catch
if (!isObject(data) || !isOrderEventType(data.type)) return null;
const orderId = Number(data.id ?? data.order_id);
if (!Number.isInteger(orderId) || orderId <= 0) return null;
return { type: data.type, orderId };
};
/** Lists always; the detail only if the driver has opened it (it is in the cache). */
export const applyOrderWsEvent = (event: OrderWsEvent, deps: OrderWsDeps): void => {
deps.invalidateLists();
if (event.type !== "order_deleted" && deps.isDetailCached(event.orderId)) {
void deps.refreshDetail(event.orderId).catch(() => undefined);
}
};Szczegóły zlecenia pobieram tylko wtedy, gdy są już w cache, czyli kierowca je otwierał. Pobieranie szczegółów zlecenia, którego nie oglądał, byłoby zbędnym zapytaniem. Przy usunięciu nie ma czego pobierać, więc odświeżają się tylko listy. Ta logika jest w osobnej funkcji bez Reacta i bez WebSocketu, więc testy sprawdzają ją na zwykłych obiektach.
Kosztem jest jedno dodatkowe zapytanie po każdym zdarzeniu. Idzie ono jednak tylko wtedy, gdy coś się faktycznie zmieniło, a nie co 30 sekund niezależnie od tego, czy coś się działo.
Licznik zamiast nowego mechanizmu odświeżania
Lista zleceń miała już swój hook z pollingiem. Nie chciałem dopisywać drugiej ścieżki pobierania danych obok niego, więc zdarzenie robi jedną rzecz: podbija licznik w store.
// orders-store: grows on every WS order event
listRevision: 0,
invalidateLists: () => set((state) => ({ listRevision: state.listRevision + 1 })),
// useOrders: the revision is a dependency of the effect that fetches and polls
useEffect(() => {
void refresh();
const id = setInterval(() => void refresh(), interval ?? DEFAULT_POLL_INTERVAL_MS);
return () => clearInterval(id);
}, [refresh, options.pollIntervalMs, revision]);Zmiana licznika uruchamia efekt od nowa: lista pobiera się od razu, a odliczanie pollingu startuje od zera. Nie ma osobnej funkcji „odśwież po zdarzeniu”, która mogłaby kiedyś zacząć robić coś innego niż zwykłe odświeżenie.
Polling został, ale co 120 sekund
Skoro backend może nie wysłać zdarzenia, a telefon w trasie co chwilę traci zasięg, nie wyłączyłem pollingu. Zmieniłem tylko jego rolę. Wcześniej był głównym źródłem zmian i chodził co 30 sekund. Teraz jest zabezpieczeniem na wypadek zerwanego połączenia i chodzi co 120 sekund.
Połączenie WebSocket po zerwaniu próbuje się odnowić trzy razy, po 1, 2 i 4 sekundach, tak jak czat i moduł czasu pracy. Zamknięcie z kodem 4001 (brak uwierzytelnienia) albo 4003 (brak firmy) kończy próby od razu, bo ponawianie nic nie zmieni. Gdy próby się skończą, aplikacja dalej działa na samym pollingu, tylko wolniej widzi zmiany.
Co jeszcze jest do poprawy
Po odnowieniu połączenia aplikacja nie odświeża list od razu. Zdarzenia, które przyszły w czasie przerwy, nie są powtarzane, więc w najgorszym razie zmiana pojawi się dopiero przy najbliższym pollingu, do 120 sekund później. Rozwiązanie jest proste: podbić ten sam licznik w momencie ponownego połączenia, tak żeby aplikacja od razu pobrała aktualny stan.
Drugie ograniczenie dotyczy tokenu. Aplikacja przekazuje go w parametrze adresu połączenia, tak samo jak panel webowy, bo przeglądarkowe API WebSocket nie pozwala ustawić własnego nagłówka. To częste rozwiązanie, ale adres z tokenem nie może trafiać do logów serwera ani proxy, więc trzeba to pilnować po stronie infrastruktury.
Z tej zmiany najbardziej przydała mi się jedna zasada: zdarzenie mówi, że coś się zmieniło, a stan bierze się z jednego źródła, czyli z API. W poście o trybie tylko do odczytu opisywałem, jak ta sama aplikacja reaguje na blokadę subskrypcji, a tutaj widać, że nawet proste „na żywo” w aplikacji mobilnej sprowadza się do pytania, co zrobić, gdy wiadomość nie dojdzie. Jeśli budujesz podobny kanał w swojej aplikacji, napisz do mnie.
