Implementacja WebSocket w Springu

devcezz.pl 1 rok temu

W dzisiejszym wpisie zaimplementujemy aplikację, która będzie komunikowała się ze swoim klientem poprzez WebSocket. O tym sposobie przekazywania informacji, pomiędzy serwerem a klientem, napisałem więcej we wcześniejszym artykule, do którego serdecznie zapraszam. Teraz skupimy się tylko i wyłącznie na praktyce.

Zamysł na naszą aplikację będzie bardzo prosty. Klient będzie mógł wysłać na serwer wybrane przez siebie słowo, a ten po jego otrzymaniu zapisze je w pamięci. Dostawca natomiast co jakiś czas (załóżmy jedną sekundę) będzie dostarczał zainteresowanemu odbiorcy właśnie to słowo plus licznik ile razy pojawiło się ono w odpowiedzi. Dzięki tak prostej aplikacji będzie łatwiej nam zobaczyć działanie komunikacji wykorzystującej WebSocket. Zaczynajmy więc!

Początki przygotowanie projektu

Tworząc aplikację w Spring Boot najlepiej jest wejść na stronę Spring Initializr. Gdy już tam będziemy to musimy podać metadane naszego projektu. Jednak wstrzymajmy się przez chwilę z jego wygenerowaniem. Konieczne jest bowiem dodanie zależności o nazwie WebSocket. W przypadku wybrania Mavena do budowania projektu to, po wejściu do stworzonego projektu, w pliku pom.xml sekcja dependencies powinna wyglądać następująco.

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>

I to tyle, nic więcej nie jest potrzebne na tym etapie. Tak naprawdę tą testową zależność można by również wyrzucić, ponieważ testy nie leżą w zakresie tego artykułu.

Przejdźmy teraz do kodu, a dokładniej konfiguracji projektu. Żeby zacząć przygodę z WebSocket w Spring Boot należy dodać adnotację @EnableWebSocketMessageBroker w dowolnej klasie konfiguracyjnej. Powinno nasunąć się tutaj pytanie dlaczego wybraliśmy tą wersję z MessageBroker na końcu, a nie samo @EnableWebSocket. Już spieszę z wyjaśnieniem.

Krótkie wtrącenie o STOMP i SockJS

Oczywiście można by wykorzystać sam protokół WebSocket w naszej aplikacji, ale po co? Albo pytanie raczej powinno brzmieć “dlaczego mam wykorzystywać coś poza WebSocket?”. WebSocket to tak naprawdę niskopoziomowy protokół komunikacyjny, który przy bardziej rozbudowanych aplikacjach wymagałby implementacji wielu dodatkowych mechanizmów. Można powiedzieć, iż jest on trochę surowy.

Na szczęście specyfikacja WebSocket wspiera wiele podprotokołów wyższego poziomu. Jednym z nich jest właśnie STOMP (Simple Text-based Messaging Protocol). Dzięki niemu format wiadomości pomiędzy klientem a dostawcą jest już opisany ad hoc. Dodatkowo posiada on już zdefiniowane funkcjonalności, które sami musielibyśby zaprogramować w czystym WebSocket np. wysyłka wiadomości do sprecyzowanego użytkownika. Warto również wspomnieć, iż STOMP został zaprojektowany, aby wchodzić w interakcję z message brokerami (stąd wykorzystanie @EnableWebSocketMessageBroker).

Można pójść jeszcze o krok dalej z uproszczeniami i wykorzystać bibliotekę SockJS (tutaj spoiler – to nie do końca prawda, więcej o tym na końcu). Pozwala ona nam na lepszą obsługa awarii, które mogą wystąpić podczas połączenia wykorzystującego WebSocket. Dostarcza też API do obsługi komunikacji pomiędzy przeglądarką a serwerem i np. wykorzystuje mechanizm heartbeat do sprawdzenie czy połączenie dalej jest aktywne (przy braku czasowej wysyłki wiadomości).

Po tym krótki przerywniku wróćmy do dalszej implementacji.

Powrót do konfiguracji

Skoro już nasza klasa konfiguracyjna posiada adnotację @EnableWebSocketMessageBroker przejdźmy do zdefiniowania punktów wejścia i wyjścia naszej aplikacji. Aby tego dokonać musimy zarejestrować beana implementującego interfejs WebSocketMessageBrokerConfigurer (a nie WebSocketConfigurer). Tutaj warto zwrócić uwagę na dwie metody: configureMessageBroker i registerStompEndpoints. Pierwsza, jak sama nazwa wskazuje, pozwala nam skonfigurować message brokera. W naszej aplikacji wykorzystamy do tego prostego brokera (takiego w pamięci), który będzie wysyłał wiadomości na endpoint /topic. Przy okazji jeszcze zdefiniujemy ogólny endpoint, a dokładniej prefiks, do odbioru wiadomości – /count-app.

Idąc dalej do registerStompEndpoints. W nim ustawimy endpoint dla STOMP. Będzie to po prostu /websocket. To do niego będziemy się łączyć, aby uzyskać połączenie. Przy rejestracji tego endpointu trzeba jeszcze przekazać informację o tym, iż chcemy użyć SockJS plus możemy jeszcze ustawić heartbeat na np. 10 sekund.

Dodatkowo wymaganie mówiło o tym, aby cyklicznie wysyłać wiadomości do klienta. Niezbędne będzie, więc dodanie jeszcze jednej adnotacji (niezwiązanej z WebSocket) – @EnableScheduling. Całość konfiguracji prezentuje się następująco.

package pl.cezarysanecki.websocket; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; @Configuration @EnableScheduling @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic"); registry.setApplicationDestinationPrefixes("/count-app"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/websocket") .withSockJS() .setHeartbeatTime(100); } }

Obsługa przypadków biznesowych

Przejdźmy teraz do implementacji naszych przypadków biznesowych. W tym celu musimy utworzyć kontroler przy wykorzystaniu adnotacji @Contoller. W nim umieszczamy dwie metody:

  • saveWordToRepeat – zapisuje słowo, które będziemy powtarzać
  • repeat – wysyła powtórzone słowo ze zwiększonym licznikiem
@MessageMapping("/save-word") ResponseEntity<Void> saveWordToRepeat(RepeatWord repeatWord) { assignNewWord(repeatWord); resetCounter(); return ResponseEntity.ok().build(); } @Scheduled(fixedRate = 2_000) void repeat() { RepeatResponse response = new RepeatResponse( SAVED_WORD_TO_REPEAT.value(), COUNTER.getAndIncrement()); simpMessagingTemplate.convertAndSend("/topic/repeat", response); }

Warto tutaj zwrócić uwagę na dwie rzeczy. Pierwszą z nich jest adnotacja @MessageMapping. Jej intencja jest taka sama jak dla @PostMapping itp. Wskazuje na jaki endpoint możemy uderzać, przez nasz WebSocket, aby uruchomić daną funkcję biznesową. To jest po prostu inna nazwa na znany koncept z Spring Web.

Drugą natomiast jest wywołanie metody convertAndSend na obiekcie klasy SimpMessagingTemplate. Dzięki niemu jesteśmy w stanie wysłać wiadomość do naszych klientów. Jest to przykład użycia jednego z dostępnych sposobów (inny to np. adnotacja @SendTo). Wskazujemy mu endpoint, na którym ma się pojawić odpowiedź i obiekt, który chcemy wysłać. To wszystko działa przy wykorzystaniu schedulera @Scheduled.

Problemy podczas implementacji klienta

Nad implementacją klienta w Angularze zeszło mi się najwięcej czasu czasu… Miałem problem z wybraniem niezbędnych bibliotek. Nie wiedziałem, z którego źródła czerpać informacje. Wiele blogów do celów prezentacyjnych korzystało z jQuery. Ja chciałem pójść inną drogą.

Dopiero po jakimś czasie napotkałem ciekawą stronę dimitri.codes, gdzie zostało podane krok po kroku jak zaimplementować WebSocket w Angularze. Po pierwszej iteracji przyszedł czas na zmiany. I wtedy trafiłem na dokumentację biblioteki do StompJS, gdzie padło następujące stwierdzenie.

It is advised to use WebSockets by default and then fall back to SockJS if the browser does not support.

Jednak skoro już zdecydowałem się na SockJS to przy nim zostałem. I to był błąd, który kosztował mnie sporo czasu. Nie doczytałem powyższej dokumentacji do końca, gdzie widniała następująca informacja.

When you are using SockJS in an Angular6 project you might get “global is not defined”.

Oznacza to po prostu, iż trzeba było dodać następujący kawałek kodu do naszego index.html.

<script> var global = window; </script>

Wtedy całe rozwiązane powinno zadziałać jak ręką odjął. Oczywiście tak się nie stało. Problemem okazał się jeszcze CORS na backendzie. Aby go rozwiązać, na potrzeby prezentacji, trzeba było dodać następującą linijkę .setAllowedOriginPatterns(„*”) przy konfiguracji endpointu. Wtedy dopiero zobaczyłem światełko w tunelu.

Implementacja klienta

Nie będę tutaj omawiał każdego szczegółu implementacyjnego. Nie jestem wielkim ekspertem od Angulara. Pewnie można by znaleźć sporo złych praktyk w moim kodzie, o których nie jestem świadomy. Niemniej jednak zamieściłem kod na GitHub, gdyby ktoś chciał go sobie przejrzeć i spróbować odpalić prostą aplikację do wypróbowania WebSocket.

W tym miejscu zdecydowałem się jednak na zamieszczenie krótkich gifów prezentujących moje rozwiązanie. Warto zwrócić szczególną uwagę na to jak wygląda komunikacja po WebSocket oraz jak wysyłane są heartbeaty i dane w obydwie strony.

Podłączenie się do WebSocket i weryfikacja połączenia
Weryfikacja działania formularza w aplikacji

Podsumowanie

Mam nadzieję, iż tym artykułem dałem Ci pogląd na to jak można zacząć swoją przygodę z WebSocket w Spring. Nie jest to trudne i wygląda podobnie do tworzenia zwykłej aplikacji webowej. Dodatkowo liczę na to, iż w małym stopniu przedstawiłem też ideę tworzenia klienta w Angularze. jeżeli ten artykuł okazał się dla Ciebie wartościowy to daj komentarz pod nim!

Link do GitHub: https://github.com/cezarysanecki/websocket-demo

Źródła:

Idź do oryginalnego materiału