Google Code Prettify

2018年7月20日 星期五

Spring Boot Angular Websocket

這篇要說明的是如何由 Angular 透過 websocket 與 spring boot 的 server 雙向溝通。我使用的版本為 Angular 5 及 spring boot 2.0.3。程式執行會如下圖,測試步驟如下:
  1. 按【Connect】連線到 server。
  2. 輸入名稱後按【Send】,將名稱送到 server。
  3. server 收到後,再將名稱回覆給 Angular。
  4. Angular 收到回覆後顯示「Greetings」表示成功。
  5. 按【Disconnect】終止與 server 的連線。

文章的來源是「Spring Boot Angular Websocket」,我直接執行裡面的程式會有問題,我修正後整理如下。
  • Angular 程式
    • install
npm install stompjs --save
npm install sockjs --save
使用這兩個函式庫可連與 server 端進行 websocket 連線。STOMP 的解釋如下 ...

What is STOMP

STOMP stands for Streaming Text Oriented Messaging Protocol. As per wiki, STOMP is a simple text-based protocol, designed for working with message-oriented middleware (MOM). It provides an interoperable wire format that allows STOMP clients to talk with any message broker supporting the protocol.
This means when we do not have STOMP as a client, the message sent lacks of information to make Spring route it to a specific message handler method. So, if we could create a mechanism that can make Spring to route to a specific message handler then probably we can make websocket connection without STOMP.
大意差不多是這樣的 ...
STOMP 即 Simple (or Streaming) Text Orientated Messaging Protocol,簡單(流)文本定向消息協議,它提供一個可互操作的連接格式,允許 STOMP 客戶端與任意 STOMP 訊息代理 (broker) 進行交互傳送訊息。STOMP 協議由於設計簡單,易於開發客戶端,因此在多種語言和多種平台上得到廣泛應用。
    • app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule, FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
下面的頁面會用到 Input,是屬於 FormsModule 裡的 component,這裡要先加入宣告。
    • app.component.html
<div id="main-content" class="container">
  <div class="row">
    <div class="col-md-6">
      <form class="form-inline">
        <div class="form-group">
          <label for="connect">WebSocket connection:</label>
          <button id="connect" class="btn btn-default" type="button" [disabled]="disabled" (click)="connect()">Connect</button>
          <button id="disconnect" class="btn btn-default" type="button" [disabled]="!disabled" (click)="disconnect()">Disconnect
          </button>
        </div>
      </form>
    </div>
    <div class="col-md-6">
      <form class="form-inline" name="test-form">
        <div class="form-group">
          <label for="name">What is your name?</label>
          <input type="text" id="name" name="name" class="form-control" placeholder="Your name here..." [(ngModel)]="name">
        </div>
        <button id="send" class="btn btn-default" type="button" (click)="sendName()">Send</button>
      </form>
    </div>
  </div>
  <div class="row">
    <div class="col-md-12" *ngIf="showConversation">
      <table id="conversation" class="table table-striped">
        <thead>
        <tr>
          <th>Greetings</th>
        </tr>
        </thead>
        <tbody *ngFor="let greeting of greetings" >
          <tr><td> </td></tr>
        </tbody>
      </table>
    </div>
  </div>
</div>
最前面那個圖就是這個網頁顯示出來的樣子,會搜尋到這篇網誌的人應該都懂 Angular 吧? 我把重點用紅色標出,它們會對應到下面 app.component.ts 裡的相關 method 或 property。
    • app.component.ts
import { Component } from '@angular/core';
import * as Stomp from 'stompjs';
import * as SockJS from 'sockjs-client';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';

  greetings: string[] = [];
  showConversation: boolean = false;
  ws: any;
  name: string;
  disabled: boolean;

  constructor(){}

  connect() {
    let socket = new WebSocket("ws://localhost:8080/greeting");
    this.ws = Stomp.over(socket);
    let that = this;
    this.ws.connect({}, function(frame) {
      that.ws.subscribe("/errors", function(message) {
        alert("Error " + message.body);
      });
      that.ws.subscribe("/topic/reply", function(message) {
        console.log(message)
        that.showGreeting(message.body);
      });
      that.disabled = true;
    }, function(error) {
      alert("STOMP error " + error);
    });
  }

  disconnect() {
    if (this.ws != null) {
      this.ws.ws.close();
    }
    this.setConnected(false);
    console.log("Disconnected");
  }

  sendName() {
    let data = JSON.stringify({
      'name' : this.name
    })
    this.ws.send("/app/message", {}, data);
  }

  showGreeting(message) {
    this.showConversation = true;
    this.greetings.push(message)
  }

  setConnected(connected) {
    this.disabled = connected;
    this.showConversation = connected;
    this.greetings = [];
  }
}
  1. server 提供的 broker endpoint 為 /greeting,所以一開始要先從這個介面與 server 端建立連線。
  2. 傳送資料是傳送到 /app/message,回覆資料是以 call back 的方式回覆,所以要訂閱。
  3. 上面可以看到我們訂閱了兩個訊息 "/topic/reply"、"/errors",分別是正確時回覆的訊息及當發生錯誤時回覆的錯誤訊息。
  • spring boot Server 端程式
    • build.gradle
buildscript {
  ext {
    springBootVersion = '2.0.3.RELEASE'
  }
  repositories {
    mavenCentral()
  }
  dependencies {
    classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
  }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'idv.steven.demowebsocket'
version = '1.0'
sourceCompatibility = 1.8

repositories {
  mavenCentral()
}

dependencies {
  compile('org.springframework.boot:spring-boot-starter-security')
  compile('org.springframework.boot:spring-boot-starter-web')
  compile('org.springframework.boot:spring-boot-starter-websocket')
  compileOnly('org.projectlombok:lombok')
  testCompile('org.springframework.boot:spring-boot-starter-test')
  testCompile('org.springframework.security:spring-security-test')
 
  compile group: 'com.google.code.gson', name: 'gson', version: '2.8.5'
}
特別注意紅色那三個函式庫,我們正在寫 websocket 程式,當然要引入第二個紅色的函式庫,第一個則是因為 spring security 需要開啟一些連線的設定,Angular 端才能連的進來,第三個紅色的函式庫則是因為 client、server 間是以 JSON 格式傳遞,所以需用到。
    • SecurityConfig.java
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
 
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .antMatchers("/**").permitAll();
  }
}
為了測試方便,簡單的設定為允許所有 request 不需認證。
    • WebSocketConfig.java
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

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

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/greeting").setAllowedOrigins("*");
  }
}
要使用 STOMP,需在有 @Configuration 的類別上加上 @EnableWebSocketMessageBroker (紅色),下面橘色是開發 broker 的 endpoint 為 "/greeting",並且允許所有人都可以連線進來。
    • WebSocketController.java

import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Controller;

import com.google.gson.Gson;

@Controller
public class WebSocketController {

  @Autowired
  private SimpMessageSendingOperations messagingTemplate;

  @MessageMapping("/message")
  @SendTo("/topic/reply")
  public String processMessageFromClient(@Payload String message) throws Exception {
    String name = new Gson().fromJson(message, Map.class).get("name").toString();
    return name;
  }
 
  @MessageExceptionHandler
  public String handleException(Throwable exception) {
    messagingTemplate.convertAndSend("/errors", exception.getMessage());
    return exception.getMessage();
  }
}
  1. @MessagMapping("/message") 是 client 傳過來時的路徑,要注意,在前面我們有設定 prefixes 為 "app",所以實際的路徑為"/app/message"。
  2. @SendTo("...") 是回覆給 client 的路徑,client 想收到回覆訊息,要先訂閱。
  3. @MessageExceptionHandler 是發生錯誤時會執行的 method,client 端想收到錯誤訊息,要訂閱 "/errors"。
    • DemowebsocketApplication.java
@SpringBootApplication
public class DemowebsocketApplication {

  public static void main(String[] args) {
    SpringApplication.run(DemowebsocketApplication.class, args);
  }
}

沒有留言:

張貼留言