Używanie Spring Boot do implementacji WebSocket z STOMP

Opublikowany: 2022-03-11

Protokół WebSocket jest jednym ze sposobów, aby Twoja aplikacja obsługiwała wiadomości w czasie rzeczywistym. Najczęstszymi alternatywami są długie sondowania i zdarzenia wysyłane przez serwer. Każde z tych rozwiązań ma swoje zalety i wady. W tym artykule pokażę, jak zaimplementować WebSockets za pomocą Spring Boot Framework. Omówię zarówno konfigurację po stronie serwera, jak i po stronie klienta, a do komunikacji ze sobą użyjemy STOMP przez protokół WebSocket.

Strona serwera będzie kodowana wyłącznie w Javie. Ale w przypadku klienta pokażę fragmenty napisane zarówno w Javie, jak iw JavaScript (SockJS), ponieważ zazwyczaj klienci WebSockets są osadzani w aplikacjach typu front-end. Przykłady kodu pokażą, jak rozgłaszać wiadomości do wielu użytkowników za pomocą modelu pub-sub, a także jak wysyłać wiadomości tylko do jednego użytkownika. W dalszej części artykułu pokrótce omówię zabezpieczanie WebSocketów i sposób, w jaki możemy zapewnić, że nasze rozwiązanie oparte na WebSocket będzie działać nawet wtedy, gdy środowisko nie obsługuje protokołu WebSocket.

Należy pamiętać, że temat zabezpieczania WebSocketów zostanie tutaj poruszony tylko pobieżnie, ponieważ jest to temat wystarczająco złożony, aby można go było poświęcić na osobny artykuł. W związku z tym i kilkoma innymi czynnikami, o których mówię w WebSocket in Production? na końcu, zalecam wprowadzenie modyfikacji przed użyciem tej konfiguracji w środowisku produkcyjnym , przeczytaj do końca, aby uzyskać konfigurację gotową do produkcji z zastosowanymi środkami bezpieczeństwa.

Protokoły WebSocket i STOMP

Protokół WebSocket pozwala na zaimplementowanie dwukierunkowej komunikacji między aplikacjami. Ważne jest, aby wiedzieć, że protokół HTTP jest używany tylko do początkowego uzgadniania. Po tym, połączenie HTTP jest uaktualniane do nowo otwartego połączenia TCP/IP, które jest używane przez WebSocket.

Protokół WebSocket jest protokołem raczej niskiego poziomu. Określa, w jaki sposób strumień bajtów jest przekształcany w ramki. Ramka może zawierać wiadomość tekstową lub binarną. Ponieważ sama wiadomość nie dostarcza żadnych dodatkowych informacji o tym, jak ją przekierować lub przetworzyć, trudno jest zaimplementować bardziej złożone aplikacje bez napisania dodatkowego kodu. Na szczęście specyfikacja WebSocket pozwala na korzystanie z podprotokołów, które działają na wyższym poziomie aplikacji. Jednym z nich, wspieranym przez Spring Framework, jest STOMP.

STOMP to prosty tekstowy protokół przesyłania wiadomości, który został pierwotnie stworzony dla języków skryptowych, takich jak Ruby, Python i Perl, w celu łączenia się z brokerami komunikatów korporacyjnych. Dzięki STOMP klienci i brokerzy opracowani w różnych językach mogą wysyłać i odbierać wiadomości między sobą i od siebie. Protokół WebSocket jest czasami nazywany TCP for Web. Analogicznie STOMP nazywa się HTTP for Web. Definiuje kilka typów ramek, które są mapowane na ramki WebSockets, np. CONNECT , SUBSCRIBE , UNSUBSCRIBE , ACK lub SEND . Z jednej strony te polecenia są bardzo przydatne do zarządzania komunikacją, az drugiej pozwalają nam wdrażać rozwiązania z bardziej wyrafinowanymi funkcjami, takimi jak potwierdzanie wiadomości.

Po stronie serwera: Spring Boot i WebSockets

Do budowy WebSocket po stronie serwera wykorzystamy framework Spring Boot, który znacząco przyspiesza tworzenie aplikacji samodzielnych i webowych w Javie. Spring Boot zawiera moduł spring-WebSocket , który jest zgodny ze standardem Java WebSocket API (JSR-356).

Wdrożenie WebSocket po stronie serwera za pomocą Spring Boot nie jest bardzo złożonym zadaniem i obejmuje tylko kilka kroków, przez które przejdziemy jeden po drugim.

Krok 1. Najpierw musimy dodać zależność biblioteki WebSocket.

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

Jeśli planujesz używać formatu JSON do przesyłanych wiadomości, możesz chcieć uwzględnić również zależność GSON lub Jackson. Całkiem prawdopodobne, że możesz dodatkowo potrzebować struktury bezpieczeństwa, na przykład Spring Security.

Krok 2. Następnie możemy skonfigurować Spring, aby włączyć komunikację WebSocket i STOMP.

 Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/mywebsockets") .setAllowedOrigins("mydomain.com").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry config){ config.enableSimpleBroker("/topic/", "/queue/"); config.setApplicationDestinationPrefixes("/app"); } }

Metoda configureMessageBroker robi dwie rzeczy:

  1. Tworzy brokera komunikatów w pamięci z co najmniej jednym miejscem docelowym do wysyłania i odbierania komunikatów. W powyższym przykładzie zdefiniowane są dwa prefiksy docelowe: topic i queue . Działają zgodnie z konwencją, zgodnie z którą miejsca docelowe wiadomości, które mają być przesyłane do wszystkich subskrybowanych klientów za pośrednictwem modelu pub-sub, powinny być poprzedzone prefiksem topic . Z drugiej strony miejsca docelowe dla prywatnych wiadomości są zazwyczaj poprzedzone przedrostkiem queue .
  2. Definiuje app prefiksu używaną do filtrowania miejsc docelowych obsługiwanych przez metody z adnotacją @MessageMapping , które zaimplementujesz w kontrolerze. Kontroler po przetworzeniu komunikatu prześle go do brokera.

Spring Boot WebSocket: Jak wiadomości są obsługiwane po stronie serwera

Jak wiadomości są obsługiwane po stronie serwera (źródło: dokumentacja Spring)


Wracając do powyższego fragmentu — prawdopodobnie zauważyłeś wywołanie metody withSockJS() — włącza ona opcje awaryjne SockJS. Krótko mówiąc, pozwoli to naszym WebSocketom działać nawet wtedy, gdy protokół WebSocket nie jest obsługiwany przez przeglądarkę internetową. Omówię ten temat bardziej szczegółowo nieco dalej.

Jest jeszcze jedna rzecz, która wymaga wyjaśnienia — dlaczego wywołujemy setAllowedOrigins() na punkcie końcowym. Jest to często wymagane, ponieważ domyślnym zachowaniem WebSocket i SockJS jest akceptowanie tylko żądań tego samego pochodzenia. Tak więc, jeśli twój klient i strona serwera używają różnych domen, ta metoda musi zostać wywołana, aby umożliwić komunikację między nimi.

Krok 3 . Zaimplementuj kontroler, który będzie obsługiwał żądania użytkowników. Otrzymaną wiadomość wyśle ​​do wszystkich użytkowników subskrybujących dany temat.

Oto przykładowa metoda, która wysyła wiadomości do miejsca docelowego /topic/news .

 @MessageMapping("/news") @SendTo("/topic/news") public void broadcastNews(@Payload String message) { return message; }

Zamiast adnotacji @SendTo , możesz również użyć SimpMessagingTemplate , który możesz automatycznie okablować wewnątrz kontrolera.

 @MessageMapping("/news") public void broadcastNews(@Payload String message) { this.simpMessagingTemplate.convertAndSend("/topic/news", message) }

W późniejszych krokach możesz chcieć dodać kilka dodatkowych klas, aby zabezpieczyć swoje punkty końcowe, takie jak ResourceServerConfigurerAdapter lub WebSecurityConfigurerAdapter z platformy Spring Security. Ponadto często korzystne jest zaimplementowanie modelu komunikatów, aby przesyłany JSON można było mapować na obiekty.

Budowanie klienta WebSocket

Wdrożenie klienta to jeszcze prostsze zadanie.

Krok 1. Klient Autowire Spring STOMP.

 @Autowired private WebSocketStompClient stompClient;

Krok 2. Otwórz połączenie.

 StompSessionHandler sessionHandler = new CustmStompSessionHandler(); StompSession stompSession = stompClient.connect(loggerServerQueueUrl, sessionHandler).get();

Po wykonaniu tej czynności możliwe jest wysłanie wiadomości do miejsca docelowego. Wiadomość zostanie wysłana do wszystkich użytkowników subskrybujących temat.

 stompSession.send("topic/greetings", "Hello new user");

Istnieje również możliwość subskrybowania wiadomości.

 session.subscribe("topic/greetings", this); @Override public void handleFrame(StompHeaders headers, Object payload) { Message msg = (Message) payload; logger.info("Received : " + msg.getText()+ " from : " + msg.getFrom()); }

Czasami potrzebne jest wysłanie wiadomości tylko do dedykowanego użytkownika (np. przy wdrażaniu czatu). Następnie klient i strona serwera muszą użyć oddzielnego miejsca docelowego przeznaczonego na tę prywatną konwersację. Nazwę miejsca docelowego można utworzyć, dodając unikalny identyfikator do ogólnej nazwy miejsca docelowego, np. /queue/chat-user123 . W tym celu można wykorzystać identyfikatory sesji HTTP lub STOMP.

Spring znacznie ułatwia wysyłanie prywatnych wiadomości. Musimy tylko dodać adnotację do metody kontrolera za pomocą @SendToUser . Następnie to miejsce docelowe będzie obsługiwane przez UserDestinationMessageHandler , który opiera się na identyfikatorze sesji. Po stronie klienta, gdy klient subskrybuje miejsce docelowe z prefiksem /user , to miejsce docelowe jest przekształcane w miejsce docelowe unikatowe dla tego użytkownika. Po stronie serwera miejsce docelowe użytkownika jest ustalane na podstawie wartości Principal użytkownika.

Przykładowy kod po stronie serwera z adnotacją @SendToUser :

 @MessageMapping("/greetings") @SendToUser("/queue/greetings") public String reply(@Payload String message, Principal user) { return "Hello " + message; }

Lub możesz użyć SimpMessagingTemplate :

 String username = ... this.simpMessagingTemplate.convertAndSendToUser(); username, "/queue/greetings", "Hello " + username);

Przyjrzyjmy się teraz, jak zaimplementować klienta JavaScript (SockJS) zdolnego do odbierania prywatnych wiadomości, które mogą być wysyłane przez kod Java w powyższym przykładzie. Warto wiedzieć, że WebSockety są częścią specyfikacji HTML5 i są obsługiwane przez większość nowoczesnych przeglądarek (Internet Explorer obsługuje je od wersji 10).

 function connect() { var socket = new SockJS('/greetings'); stompClient = Stomp.over(socket); stompClient.connect({}, function (frame) { stompClient.subscribe('/user/queue/greetings', function (greeting) { showGreeting(JSON.parse(greeting.body).name); }); }); } function sendName() { stompClient.send("/app/greetings", {}, $("#name").val()); }

Jak zapewne zauważyłeś, aby otrzymywać prywatne wiadomości, klient musi zapisać się do ogólnego przeznaczenia /queue/greetings z prefiksem /user . Nie musi zawracać sobie głowy żadnymi unikalnymi identyfikatorami. Jednak klient musi wcześniej zalogować się do aplikacji, więc inicjowany jest obiekt Principal po stronie serwera.

Zabezpieczanie gniazd sieciowych

Wiele aplikacji internetowych korzysta z uwierzytelniania opartego na plikach cookie. Na przykład możemy użyć Spring Security, aby ograniczyć dostęp do niektórych stron lub kontrolerów tylko do zalogowanych użytkowników. Kontekst bezpieczeństwa użytkownika jest następnie utrzymywany przez sesję HTTP opartą na plikach cookie, która jest później kojarzona z sesjami WebSocket lub SockJS utworzonymi dla tego użytkownika. Punkty końcowe WebSockets można zabezpieczyć tak, jak każde inne żądanie, np. w WebSecurityConfigurerAdapter firmy Spring.

Obecnie aplikacje internetowe często używają interfejsów API REST jako swoich tokenów zaplecza i tokenów OAuth/JWT do uwierzytelniania i autoryzacji użytkowników. Protokół WebSocket nie opisuje, jak serwery powinny uwierzytelniać klientów podczas uzgadniania HTTP. W praktyce do tego celu wykorzystywane są standardowe nagłówki HTTP (np. Authorization). Niestety nie jest obsługiwany przez wszystkich klientów STOMP. Klient Spring Java STOMP umożliwia ustawienie nagłówków dla uzgadniania:

 WebSocketHttpHeaders handshakeHeaders = new WebSocketHttpHeaders(); handshakeHeaders.add(principalRequestHeader, principalRequestValue);

Ale klient JavaScript SockJS nie obsługuje wysyłania nagłówka autoryzacji z żądaniem SockJS. Pozwala jednak na przesłanie parametrów zapytania, które można wykorzystać do przekazania tokena. Takie podejście wymaga napisania niestandardowego kodu po stronie serwera, który odczyta token z parametrów zapytania i zweryfikuje go. Ważne jest również, aby upewnić się, że tokeny nie są rejestrowane razem z żądaniami (lub dzienniki są dobrze chronione), ponieważ może to spowodować poważne naruszenie bezpieczeństwa.

Opcje awaryjne SockJS

Integracja z WebSocket nie zawsze przebiega bezproblemowo. Niektóre przeglądarki (np. IE 9) nie obsługują WebSockets. Co więcej, restrykcyjne proxy mogą uniemożliwić wykonanie aktualizacji HTTP lub odciąć połączenia, które są otwarte zbyt długo. W takich przypadkach z pomocą przychodzi SockJS.

Transporty SockJS dzielą się na trzy ogólne kategorie: WebSockets, HTTP Streaming i HTTP Long Polling. Komunikacja rozpoczyna się od wysłania przez SockJS GET /info w celu uzyskania podstawowych informacji z serwera. Na podstawie odpowiedzi SockJS decyduje, jaki transport zostanie użyty. Pierwszym wyborem są WebSockets. Jeśli nie są obsługiwane, wtedy, jeśli to możliwe, używana jest transmisja strumieniowa. Jeśli ta opcja również nie jest możliwa, jako metodę transportu wybiera się Polling.

WebSocket w produkcji?

Chociaż ta konfiguracja działa, nie jest „najlepsza”. Spring Boot pozwala na korzystanie z dowolnego pełnoprawnego systemu przesyłania wiadomości z protokołem STOMP (np. ActiveMQ, RabbitMQ), a zewnętrzny broker może obsłużyć więcej operacji STOMP (np. potwierdzenia, pokwitowania) niż prosty broker, którego używaliśmy. STOMP Over WebSocket dostarcza interesujących informacji o WebSockets i protokole STOMP. Zawiera listę systemów przesyłania wiadomości, które obsługują protokół STOMP i mogą być lepszym rozwiązaniem do wykorzystania w produkcji. Zwłaszcza jeśli ze względu na dużą liczbę żądań broker komunikatów musi być łączony w klastry. (Prosty broker komunikatów Springa nie nadaje się do klastrowania). Następnie zamiast włączania prostego brokera w WebSocketConfig , należy włączyć przekaźnik brokera Stomp, który przekazuje komunikaty do iz zewnętrznego brokera komunikatów. Podsumowując, zewnętrzny broker wiadomości może pomóc w zbudowaniu bardziej skalowalnego i solidnego rozwiązania.

Jeśli chcesz kontynuować swoją przygodę z programowaniem Java, odkrywając Spring Boot, spróbuj przeczytać artykuł Korzystanie ze Spring Boot dla ochrony OAuth2 i JWT REST .