Eyeeshot BloG

Sping boot + websocket Hands On 본문

Tech

Sping boot + websocket Hands On

eyeeshot 2020. 12. 3. 11:57

Spring frameworkd는 WebSocket 메시지를 처리하는 Client, Server 측 application을 작성하는데
사용할 수 있는 WebSocket API를 제공하고 있습니다.

Gradle 로 작업

build.gladle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }

    implementation 'org.springframework.boot:spring-boot-starter-websocket:2.3.4.RELEASE'

    implementation 'org.webjars:webjars-locator-core'
    implementation 'org.webjars:sockjs-client:1.0.2'
    implementation 'org.webjars:stomp-websocket:2.3.3'
    implementation 'org.webjars:bootstrap:3.3.7'
    implementation 'org.webjars:jquery:3.1.1-1'
    
}

1. WebSocket end-point message broker 구성 ,config 설정

package com.eyeeshot.device.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
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
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 메시지 브로커는 특정 주제를 구독 한 연결된 모든 클라이언트에게 메시지를 broadcast 합니다.
        registry.enableSimpleBroker("/sub");
        // 시작되는 메시지가 message-handling methods으로 라우팅 되어야 한다는 것을 명시합니다.
        registry.setApplicationDestinationPrefixes("/pub");
    }

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

 

registerStompEndpoints - client 에서 웹소켓 연결할때 쓸 endpoint를 설정합니다. 

setApplicationDestinationPrefixes - 연결후 보낼 메시지 앞쪽 Prefixes 를 pub 으로 설정합니다.
enableSimpleBroker - sub 으로 들어오는것에 대해 broadcast 합니다.

 

2. 클라이언트와 서버간에 교환되는 메시지 페이로드 모델 생성

package com.eyeeshot.device.model;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Setter
@Getter
@ToString
public class ConsoleRequestModel {
  private String privateKeyPem;
  private String certificatePem;
  private String algorithm;
  private String thingName;
  private String topic;
  private String message;
  private String shadowState;
}

 

3. Controller 
client 에서 message를 수신한 다음 다른 client 에게 broadcast 하는 부분

package com.eyeeshot.device.controller;

import com.eyeeshot.device.model.ConsoleRequestModel;
import com.eyeeshot.device.service.MqttService;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;

@Log4j2
@Controller
public class ConsoleController {
  @Autowired
  private SimpMessagingTemplate simpMessagingTemplate;
  private final MqttService mqttService;

  public ConsoleController(MqttService mqttService) {
    this.mqttService = mqttService;
  }

  @GetMapping(value = "")
  public ModelAndView ready() {
    ModelAndView mav = new ModelAndView();
    mav.setViewName("console");

    return mav;
  }

  @MessageMapping("/connect")
  public void connect(@Payload ConsoleRequestModel messageModel) {
    log.info(messageModel.toString());
    try {
      mqttService.connect(messageModel);
    } catch (Exception e) {
      log.info(e);
    }
  }

  @MessageMapping("/publishJob")
  public void publishJob(@Payload ConsoleRequestModel messageModel) {
    log.info(messageModel.toString());
    try {
      mqttService.publishJob(messageModel);
    } catch (Exception e) {
      log.info(e);
    }
  }

  @MessageMapping("/publishTopic")
  public void publishTopic(@Payload ConsoleRequestModel messageModel) {
    try {
      mqttService.publishTopic(messageModel);
    } catch (Exception e) {
      log.info(e);
    }
  }

  @MessageMapping("/subscribeTopic")
  public void subscribeTopic(@Payload ConsoleRequestModel messageModel) {
    try {
      mqttService.subscribeTopic(messageModel);
    } catch (Exception e) {
      log.info(e);
    }
  }

  @MessageMapping("/{clientId}")
  public void sendMessage( @DestinationVariable String clientId,@Payload ConsoleRequestModel messageModel) {
    log.info(clientId);
    log.info(messageModel);
    simpMessagingTemplate.convertAndSend("/sub/" + clientId , messageModel);
  }
}

MessageMapping 이 WebSocket 용 Request Mapping 이라 생각하면 이해하기 쉽다.
simpMessagingTemplate.convertAndSend("/sub/" + clientId , messageModel); -> @SendTo("/sub/{clientId}") 라 보면된다 하지만 @SendTo("/sub/{clientId}") 안됨으로 저렇게 처리하였음.

 

4. test.html

<!DOCTYPE html>
<html>
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
  <title>Spring Boot WebSocket Application</title>
  <script src="/webjars/jquery/3.1.1-1/jquery.js"></script>
  <script src="/webjars/sockjs-client/1.0.2/sockjs.min.js"></script>
  <script src="/webjars/stomp-websocket/2.3.3/stomp.min.js"></script>
  <script src="/statics/test.js"></script>
</head>
<body>

<div id="thing-page">
  <div class="thing-page-container">
    <h1 class="title"> 입력하세요</h1>
      <div class="form-group">
        <input type="text" id="certificatePem" placeholder="certificatePem" autocomplete="off" class="form-control" />
        <input type="text" id="privateKeyPem" placeholder="privateKeyPem" autocomplete="off" class="form-control" />
        <input type="text" id="thingName" placeholder="thingName" autocomplete="off" class="form-control" />
      </div>
      <div class="form-group">
        <button class="connect-button">시작하기</button>
      </div>
  </div>
</div>

<div id="message-page" style="display: none">
  <div class="chat-container">
    <div class="chat-header">
      <h2>Spring WebSocket Chat Demo</h2>
    </div>
    <div class="form-group">
      <div class="input-group clearfix">
        <button class="jobPublish">Job 확인 보내기</button>
      </div>
      <div class="input-group clearfix">
        <input type="text" id="subscribeTopic" placeholder="topic" autocomplete="off" class="form-control"/>
        <button class="subscribeTopic">Topic 구독하기</button>
      </div>
      <div class="input-group clearfix">
        <input type="text" id="publishTopic" placeholder="topic" autocomplete="off" class="form-control"/>
        <input type="text" id="payload" placeholder="payload" autocomplete="off" class="form-control"/>
        <button class="publishTopic">Topic 보내기</button>
      </div>
    </div>
    <div class="connecting">
      연결중...
    </div>
    <ul id="messageArea">

    </ul>
  </div>
</div>
</body>
</html>

 

pem , privtekey, thingName 으로 접속하면 웹소켓이 연결되어 각 토픽을 호출하는부분을 만들어놨음.

5. test.js

let stompClient = null;
let thingName = null;
let privateKeyPem = null;
let certificatePem = null;


let payloadData = {
    privateKeyPem : "",
    certificatePem : "",
    algorithm : "",
    thingName : "",
    topic : "",
    message : "",
    shadowState : "",
}

function connect(event) {
    payloadData.thingName = thingName = $('#thingName').val();
    payloadData.privateKeyPem = privateKeyPem = $('#privateKeyPem').val();
    payloadData.certificatePem = certificatePem = $('#certificatePem').val();

    $('#thing-page').hide();
    $('#message-page').show();

    let socket = new SockJS('/console/device');
    stompClient = Stomp.over(socket);

    stompClient.connect({}, onConnected, onError);
}


function onConnected() {
  $('.connecting').append('<br>접속 성공!');

  stompClient.subscribe('/sub/'+thingName, onMessageReceived);
    stompClient.send("/pub/connect", {}, JSON.stringify(payloadData));

}

function onMessageReceived(payload) {
    let message = JSON.parse(payload.body);
    $('.connecting').append('<br>'+ message.topic +' : <code>'+ message.payload + '</code>');
}


function onError(error) {
    $('#thing-page').show();
    $('#message-page').hide();
    $('.connecting').append('<br>Could not connect to WebSocket server. Please refresh this page to try again!');
    stompClient = "";
}


function sendMessage(topic,message) {
    if(thingName && stompClient) {
        payloadData.topic = topic;
        payloadData.message = message;
        stompClient.send("/pub/"+thingName, {}, JSON.stringify(payloadData));
    } else {
        alert('연결이 끊어졌습니다.');
    }
}


$(document).on("click", ".connect-button", function(e) {
    e.preventDefault();
    connect();
});

$(document).on("click", ".jobPublish", function(e) {
    e.preventDefault()
    stompClient.send("/pub/publishJob", {}, JSON.stringify(payloadData));
});

$(document).on("click", ".publishTopic", function(e) {
    e.preventDefault()
    if(thingName && stompClient) {
        payloadData.topic = $('#publishTopic').val();
        payloadData.message = $('#payload').val();
        stompClient.send("/pub/publishJob", {}, JSON.stringify(payloadData));
    } else {
        alert('연결이 끊어졌습니다.');
    }
});

$(document).on("click", ".subscribeTopic", function(e) {
    e.preventDefault()
    if(thingName && stompClient) {
        payloadData.topic = $('#subscribeTopic').val();
        stompClient.send("/pub/subscribeTopic", {}, JSON.stringify(payloadData));
    } else {
        alert('연결이 끊어졌습니다.');
    }
});

버튼들에 대한 action 시 소켓으로 pub sub 하는 부분들 처리함.