Zastosowanie biblioteki stomp w projekcie dla dunskich kolei

Chcielibyśmy opowiedzieć wam, z czym ostatnio borykaliśmy się w Netcompany. Tym razem stanęliśmy przed wyzwaniem stworzenia systemu wspomagającego sieć kolejową.

Banedanmark - firma odpowiedzialna za utrzymanie i kontrolę ruchu większości krajowej sieci kolejowej zwróciła się do nas z prośbą o przygotowanie systemu: doradcy dla maszynistów.           

Przed rozpoczęciem projektu wybrano najważniejsze zagadnienia, z jakimi zmagają się koleje na całym świecie.

Do najważniejszych można zaliczyć:

  • Zapewnienie punktualności pociągów
  • Zminimalizowanie zużycia energii spowodowanego częstymi zmianami prędkości
  • Kontrolę nad monitorowaniem problemów na trasie
  • Zmniejszenie przestojów na trasie (np. w skutek przepuszczenia innego składu)

System miał za zadanie odpowiedzieć na powyższe wyzwania przy zachowaniu wysokiej efektywności i pewności działania, bowiem nawet najmniejszy błąd mógł przynieść niezykle negatywne konsekwencje.

Rozwiązanie

Cały system składa się z 4 pomniejszych aplikacji:
  • Głównego systemu pełniącego rolę analizatora rozkładu, pozycji pociągu, obliczającego opóźnienia oraz wysyłającego klientowi rekomendację
  • Aplikacji klienckiej wyświetlającej rekomendacje maszyniście
  • Aplikacji monitoringu pokazującej bieżące pociągi w ruchu wraz z przebytą trasą, rekomendowaną szybkością i innymi danymi
  • Symulatora pociągu napisanego w Javascript
  • Emulatora systemu kolei przekazującego dane między pociągiem a naszym głównym systemem

W stosie technologicznym rozwiązania znajdziemy między innymi: Thymeleaf, Spring boot, Redis, Docker, React, czy bibliotekę STOMP.

Wszystkie komponenty opakowane są w obrazy Dockera. Zarządza nimi CI na bazie Jenkinsa z pluginami oraz skrypty Bashowe. Dzięki temu zatwierdzone i przetestowane automatycznie nowe commity są kompilowane i instalowane jako nowe wersje na serwerze testowym.

Komunikacja serwer – klient

Do komunikacji serwera z klientem wybraliśmy technologię websocket oraz wykorzystujący ją protokół STOMP. Na decyzję wpłynęło dobre wsparcie dla Springa, elastyczna konfiguracja oraz podział na topiki.

Aby skorzystać z dobrodziejstw STOMPa należy dodać do pliku build.gradle następujące zależności:

dependencies {  

  compile("org.springframework.boot:spring-boot-starter-websocket") 

  compile("org.webjars:sockjs-client:1.0.2") 

  compile("org.webjars:stomp-websocket:2.3.3")

  compile("org.webjars:bootstrap:3.3.7")

  compile("org.webjars:jquery:3.1.0")

…  

}  

W odróżnieniu od standardowego użycia websocketów tutaj wiadomości podzielone są na tzw. topiki, które są niczym innym jak kolejkami, na które nasłuchuje klient. Dodatkowo klient może wysyłać wiadomości, które spowodują otrzymanie na subskrybowany kanał informacji zwrotnej.

Używając konfiguracji programistycznej Springa wystarczy, że opatrzymy naszą klasę konfiguracyjną annotacją:

@EnableWebSocket  

 

Aby włączyć obsługę websocketów.

Podstawowa konfiguracja WebSocketów wygląda tak:

@Configuration
@EnableWebSocketMessageBroker public class WebSocketConfig extends 

AbstractWebSocketMessageBrokerConfigurer {

    @Override 
    public void configureMessageBroker(MessageBrokerRegistry config) {  
        config.enableSimpleBroker("/topic");  
        config.setApplicationDestinationPrefixes("/app");  
    }

    @Override 
    public void registerStompEndpoints(StompEndpointRegistry registry) {  
        registry.addEndpoint("/ws").withSockJS();  
    }  
}  

 

Konfigurując MessageBrokera wskazujemy ścieżkę pod którą dostępne będą topiki oraz prefix dla wiadomości wysyłanych przez klienta.

Metoda registerStompEndpoints () rejestruje punkt końcowy „/ws”, umożliwiając użycie alternatywnych transportów przez SockJS. Klient SockJS spróbuje połączyć się z „/ws” i korzystać z najlepszego dostępnego transportu (websocket, xhr-streaming, xhr-polls itp.).

Przykładowa metoda nasłuchująca na wiadomości i wysyłająca odpowiedź przez topik ma następującą postać:

@MessageMapping("/updateStatus")
@SendTo("/topic/passage")
 public Passage reschedule(PassageStatus status) throws Exception {

    …   

    return buildPassageFromStatus(status);

}  

 

W przypadku, gdy chcemy inicjować wysyłanie wiadomości przez backend musimy skorzystać z SimpMessagingTemplate. Wystarczy wstrzyknąć go do naszej usługi i użyć w podobny sposób:

public void sendSchedule() {
  
    clientHolder.getSubscribedClients().forEach( trainNumber -> {
  
        logger.debug("sending schedule to {}", trainNumber);

        template.convertAndSend("/topic/schedule/" + trainNumber, 
prepareSchedule(trainNumber));
 
    });
  
}  

 

użycie metody convertAndSend na obiekcie template spowoduje przekonwertowanie naszego obiektu do jsona (w tym przypadku obiekt zwracany przez metodę prepareSchedule) oraz wysłanie go na topik "/topic/schedule/"+trainNumber.

Jeśli chcemy zawęzić wiadomość do konkretnego użytkownika powinniśmy użyć metody template.convertAndSendToUser jako pierwszy parametr podając użytkownika.

Czas na konfigurację po stronie klienta. Do nagłówka htmla musimy dopisać następujące linijki:

<script src="/webjars/jquery/jquery.min.js"></script>

<script src="/webjars/sockjs-client/sockjs.min.js"></script>

<script src="/webjars/stomp-websocket/stomp.min.js"></script>  

 

Spowoduje to dodanie odpowiednich bibliotek. Inicjalizacja STOMPa i zapisanie się do topika wygląda jak poniżej:

function connect() {  
    var socket = new SockJS(‘/ws’);  
        stompClient = Stomp.over(socket); stompClient.connect({}, function(frame) {  
                setConnected(true);  
                stompClient.subscribe('/topic/schedule, function (schedule) {  
                    console.log(JSON.parse(schedule.body));  
                });  
        });  
}  

 

Po pomyślnym połączeniu z serwerem zapisujemy się na „/topic/schedule” i każda wiadomość jaka przyjdzie (schedule) przez STOMPa wypisana będzie do logów konsoli.

Aby wysłać wiadomość do serwera posłużymy się następującym kodem:

function sendStatus() {  
    stompClient.send("/app/updateStatus", {}, JSON.stringify({  
        'status': 'ready'
    }));  
}  

 

Zmienne sesyjne, przekazywanie zmiennych pomiędzy sesją http a STOMPem

W trakcje developmentu może zajść potrzeba powiązania sesji użytkownika między http a STOMPem. W tym celu zamiast rozszerzania klasy AbstractWebSocketMessageBrokerConfigurer należy rozszerzyć AbstractSessionWebSocketMessageBrokerConfigurer<ExpiringSession>.  Oprócz standardowej konfiguracji MessageBrokera musimy dodać następującą konfigurację:

 

@Override
public void configureStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/ws")
    .setHandshakeHandler(new DefaultHandshakeHandler(new TomcatRequestUpgradeStrategy()))
    .setAllowedOrigins("*")
    .withSockJS().setInterceptors(httpSessionIdHandshakeInterceptor());
}

@Bean
public HttpSessionIdHandshakeInterceptor httpSessionIdHandshakeInterceptor() {
    return new HttpSessionIdHandshakeInterceptor();
}  

 

Gdzie kluczowym jest powołanie naszego session interceptora oraz ustawienie go dla SockJS.

Przykładowy interceptor może mieć postać:

public class HttpSessionIdHandshakeInterceptor implements HandshakeInterceptor {

@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, 
WebSocketHandler wsHandler, Map < String, Object > attributes) throws Exception {
        if (request instanceof ServletServerHttpRequest) {
            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
            HttpSession session = servletRequest.getServletRequest().getSession(false);
 
            if (session != null) {
                attributes.put("HTTPSESSIONID", session.getId());
            }
        }
  
        return true;
    }

  

    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, 
    WebSocketHandler wsHandler, Exception ex) {
    }
}  

 

gdzie do atrybutów dostępnych dla STOMP-a dodamy ID sesji http. Aby dostać się do ID sesji po otrzymaniu wiadomości STOMP należy dodać jako parametr metody zmienną typu MessageHeaders, a następnie wyciągnąć ID (bądź inną zmienną):

(String)(( ConcurrentHashMap) messageHeaders.get("simpSessionAttributes")).get("HTTPSESSIONID")  

Podsumowanie

Biblioteka STOMP w znacznym stopniu ułatwiła komunikację na linii serwer – klient. Dzięki interceptorowi możliwe było powiązanie sesji http z sesją STOMP, a mechanizm topików zapewnił ład w komunikacji.

Projekt obecnie jest już w fazie końcowej developmentu, zaś STOMP sprawdził się dobrze jako warstwa komunikacji i pokonał różne trudności, takie jak np. zaniki internetu, zarządzanie sesjami użytkowników czy powtarzanie wysyłki komunikatu w przypadku braku odpowiedzi.

Zainteresowało Cię to rozwiązanie?

W Netcompany masz szansę na równie ciekawe projekty. Jako część naszego zespołu przyczynisz się do tworzenia cyfrowej platformy przyszłości. Już pierwszego dnia pracy przejmiesz odpowiedzialność za realizację najciekawszych, kompleksowych projektów oraz innowacyjnych rozwiązań, które są wspólnym dziełem najlepszych specjalistów w branży.

Autor: Łukasz Połoncarz – Senior Consultant, Netcompany.