webRtc 화상채팅을 도전하게 되었다.
socket config 파일에서 handler를 지정해주었고 setAllowedOrigns("*")로 요청 url을 전부 허용해 주었다.
MainController 부분은 크게 볼게 없어서 그냥 넘어가겠다.
SignalHandler.java
package com.example.webrtckw.handler;
import java.util.*;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
@Component
// TextWebSocketHandler인터페이스를 구현한다.
public class SignalHandler extends TextWebSocketHandler {
List<HashMap<String, Object>> sessions = new ArrayList<>(); // 세션 저장 + 세션에 메세지 전달 하기 위한 용도
static int roomIndex = -1;
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
//메시지 발송 // 구독 개념이 아닌 세션들을 찾아서 찾은 세션들한테만 보내주는 개념인듯함
System.out.println("message : " + message);
String msg = message.getPayload();
System.out.println("msg : " + msg);
JSONObject obj = jsonToObjectParser(msg);
String roomNumber = (String) obj.get("roomNumber");
String msgType = (String) obj.get("type");
HashMap<String, Object> sessionMap = new HashMap<String, Object>();
if (sessions.size() > 0) {
for (int i=0; i<sessions.size(); i++) {
String tempRoomNumber = (String) sessions.get(i).get("roomNumber");
if (roomNumber.equals(tempRoomNumber)) {
sessionMap = sessions.get(i);
roomIndex = i;
break;
}
}
if(!msgType.equals("file")) {
// 해당 방에 있는 세션들에만 메세지 전송
for (String sessionMapKey: sessionMap.keySet()) {
if(sessionMapKey.equals("roomNumber")) { // 다만 방번호일 경우에는 건너뛴다.
continue;
}
WebSocketSession webSocketSession = (WebSocketSession) sessionMap.get(sessionMapKey); // webSocketSession == StandardWebSocketSession[id=9b6f63f7-c1b1-9a09-5110-1fdff5973f86, uri=ws://localhost:8080/chating/abc]
webSocketSession.sendMessage(new TextMessage(obj.toJSONString()));
}
}
}
}
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
//소켓 연결
boolean sessionExist = false;
String sessionUrl = session.getUri().toString();
String roomNumber = sessionUrl.split("/signal/")[1];
int roomIndex = -1;
if (sessions.size() > 0) {
for (int i=0; i<sessions.size(); i++) {
String tempRoomNumber = (String) sessions.get(i).get("roomNumber");
if (roomNumber.equals(tempRoomNumber)) {
sessionExist = true;
roomIndex = i;
break;
}
}
}
if (sessionExist) {
HashMap<String, Object> sessionMap = sessions.get(roomIndex);
sessionMap.put(session.getId(), session);
} else {
HashMap<String, Object> sessionMap = new HashMap<String, Object>();
sessionMap.put("roomNumber", roomNumber);
sessionMap.put(session.getId(), session);
sessions.add(sessionMap);
}
super.afterConnectionEstablished(session);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
//소켓 종료
if(sessions.size() > 0) { //소켓이 종료되면 해당 세션값들을 찾아서 지운다.
for (HashMap<String, Object> stringObjectHashMap : sessions) {
stringObjectHashMap.remove(session.getId());
}
}
super.afterConnectionClosed(session, status);
}
private static JSONObject jsonToObjectParser(String jsonStr) {
JSONParser parser = new JSONParser();
JSONObject obj = null;
try {
obj = (JSONObject) parser.parse(jsonStr);
} catch (ParseException e) {
e.printStackTrace();
}
return obj;
}
}
여기는 코드가 너무 길어서 사진으로 남길 수 없어서 어쩔 수 없이 코드로 적었다. 개인적으로 사진이 보기 깔끔한데 ㅠㅠ
세션에 처음 연결이 되면 afterConnectionEstablished이 코드가 실행이 된다.
String roomNumber = sessionUrl.split("/signal/")[1]; 이 코드를 통해 roomNumber을 꺼내서 sessions에서 roomNumber를 통해 각각 어느 채팅방에 접속해 있는지 구분 할 수 있게 해주었다.
chatrooms.html
<!doctype html>
<html lang="en">
<head>
<title>Websocket Chat</title>
<meta charset="utf-8">
</head>
<body>
<div>
<div>
<div>
<h3>채팅방 리스트</h3>
</div>
</div>
<div>
<div>
<label>방제목</label>
</div>
<input type="text" id="roomTitle">
</div>
<ul id="roomList">
<li class="list-group-item list-group-item-action" onclick="enterRoom(1)">
1, abc
</li>
<li class="list-group-item list-group-item-action" onclick="enterRoom(2)">
2, abc
</li>
<li class="list-group-item list-group-item-action" onclick="enterRoom(3)">
3, abc
</li>
</ul>
</div>
<!-- JavaScript -->
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></script>
<script>
function makeHtml(roomId, roomTitle) {
return `<li class="list-group-item list-group-item-action"token interpolation">${roomId}')">
${roomId}, ${roomTitle}
</li>`
}
function enterRoom(roomId) {
localStorage.setItem('roomId', roomId);
location.href = "/chat/room/" + roomId;
}
function createRoom() {
let roomTitle = $('#roomTitle').val();
$.ajax({
type: "POST",
url: "/chatroom",
data: {
roomTitle : roomTitle
},
success: function (response) {
console.log(response);
let roomId = response.roomId;
let roomTitle = response.roomTitle;
let roomOwner = response.roomOwner;
let roomUUID = response.roomUUID;
let tempHtml = makeHtml(roomId, roomTitle, roomOwner, roomUUID);
$('#roomList').append(tempHtml);
//window.location.reload();
}
});
}
</script>
</body>
</html>
chat.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Chat Room</title>
<!-- Latest minified Bootstrap & JQuery-->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.0/js/bootstrap.min.js"></script>
<!-- Custom styles for this template -->
</head>
<body class="text-center">
<div>
<div>
<button id="video_off">Video On</button>
<button id="video_on">Video Off</button>
<button id="audio_off">Audio On</button>
<button id="audio_on">Audio Off</button>
<div>
<div>
<video id="local_video" autoplay playsinline></video>
</div>
<div>
<video id="remote_video" autoplay playsinline></video>
</div>
</div>
</div>
</div>
</body>
<script>
let roomId = localStorage.getItem('roomId');
const socket = new WebSocket("ws://" + window.location.host + "/signal/" + roomId); // handler맵핑 url
const localVideo = document.getElementById('local_video'); // id=local_video 변수
const remoteVideo = document.getElementById('remote_video'); // id=remote_video 변수
// UI elements
const videoButtonOff = document.querySelector('#video_off');
const videoButtonOn = document.querySelector('#video_on');
const audioButtonOff = document.querySelector('#audio_off');
const audioButtonOn = document.querySelector('#audio_on');
const exitButton = document.querySelector('#exit');
// WebRTC STUN servers
const peerConnectionConfig = {
'iceServers': [
{'urls': 'stun:stun.stunprotocol.org:3478'},
{'urls': 'stun:stun.l.google.com:19302'}, // P2P 연결의 중계서버는 구글에서 무료로 지원하는 Google STUN 서버
]
};
// WebRTC media
const mediaConstraints = {
audio: true,
video: true
};
// WebRTC variables
let localStream;
let localVideoTracks;
let myPeerConnection;
$(function(){
start(); // window load시 바로 시작
});
function start() {
socket.onmessage = function(msg) {
let message = JSON.parse(msg.data);
switch (message.type) {
case "text":
break;
case "join":
handlePeerConnection(message);
break;
default:
console.log("start error");
}
};
socket.onopen = function() {
sendToServer({
roomNumber: roomId, // uuid를 의미
type: 'join',
});
};
// a listener for the socket being closed event
socket.onclose = function(message) {
};
// an event listener to handle socket errors
socket.onerror = function(message) {
};
}
function sendToServer(msg) {
let msgJSON = JSON.stringify(msg);
socket.send(msgJSON);
}
function handlePeerConnection(message) {
createPeerConnection(); // peer 생성?
getMedia(mediaConstraints); // mediaConstraints == autio true video true 변수
}
function createPeerConnection() {
myPeerConnection = new RTCPeerConnection(peerConnectionConfig); // peerConnectionConfig == 구글 peer 서버
}
function getMedia(constraints) { // constraints == autio true video true 변수
if (localStream) {
alert("이거는 실행됨???@22222222222222222222222");
localStream.getTracks().forEach(track => {
track.stop();
});
} // navigator.mediaDevices.getUserMedia로 미디어 정보 불러오기??
navigator.mediaDevices.getUserMedia(constraints) // constratints video audio true 변수 값
.then(getLocalMediaStream).catch(handleGetUserMediaError);
}
/**
* 카메라/마이크 등 데이터 스트림 접근
* @param mediaStream
*/
// add MediaStream to local video element and to the Peer
function getLocalMediaStream(mediaStream) {
localStream = mediaStream;
localVideo.srcObject = mediaStream;
localStream.getTracks().forEach(track => myPeerConnection.addTrack(track, localStream));
}
function handleGetUserMediaError(error) {
alert("여기가 꼭 실행이 되야하나???")
switch(error.name) {
case "NotFoundError":
alert("Unable to open your call because no camera and/or microphone were found.");
break;
case "SecurityError":
case "PermissionDeniedError":
// Do nothing; this is the same as the user canceling the call.
break;
default:
alert("Error opening your camera and/or microphone: " + error.message);
break;
}
}
// mute video buttons handler
videoButtonOff.onclick = () => {
localVideoTracks = localStream.getVideoTracks();
localVideoTracks.forEach(track => localStream.removeTrack(track)); // 비디오 트랙 localStream 제거
$(videoButtonOff).css('display', 'none');
$(videoButtonOn).css('display', 'inline');
$(localVideo).css('display', 'none'); // id=local_video display none;
};
videoButtonOn.onclick = () => {
localVideoTracks.forEach(track => localStream.addTrack(track)); // 비디오 트랙 localStream 추가
$(videoButtonOff).css('display', 'inline');
$(videoButtonOn).css('display', 'none');
$(localVideo).css('display', 'inline'); // id=local_video display none;
};
// mute audio buttons handler
audioButtonOff.onclick = () => {
$(audioButtonOff).css('display', 'none');
$(audioButtonOn).css('display', 'inline');
localVideo.muted = true; // id=local_video mute 활성화;
};
audioButtonOn.onclick = () => {
$(audioButtonOff).css('display', 'inline');
$(audioButtonOn).css('display', 'none');
localVideo.muted = false; // id=local_video mute 제거;
};
// function video_off_function() {
// alert("video_off_function 여기 오긴함???111111111111");
// localVideoTracks = localStream.getVideoTracks();
// localVideoTracks.forEach(track => localStream.removeTrack(track)); // 비디오 트랙 localStream 제거
//
// $(videoButtonOff).css('display', 'none');
// $(videoButtonOn).css('display', 'inline');
//
// $(localVideo).css('display', 'none'); // id=local_video display none;
// };
// function video_on_function() {
// alert("video on 여기 오긴함22222222");
// localVideoTracks.forEach(track => localStream.addTrack(track)); // 비디오 트랙 localStream 추가
//
// $(videoButtonOff).css('display', 'inline');
// $(videoButtonOn).css('display', 'none');
//
// $(localVideo).css('display', 'inline'); // id=local_video display none;
// };
// function audio_off_function() {
// $(audioButtonOff).css('display', 'none');
// $(audioButtonOn).css('display', 'inline');
//
// localVideo.muted = true; // id=local_video mute 활성화;
// };
// function audio_on_function() {
// $(audioButtonOff).css('display', 'inline');
// $(audioButtonOn).css('display', 'none');
//
// localVideo.muted = false; // id=local_video mute 제거;
// };
// // room exit button handler
// exitButton.onclick = () => {
// stop();
// };
</script>
</html>
JS 변수.onclick = () ==> {} 을 html button을 통해 구현하였다. 근데 그냥 label로 해도 되긴한다. 적절한 것을 골라서 쓰자
프론트엔드에 관한 설명은 주석으로 대체하겠다 나는 프론트 엔드가 아니기 때문에 자세하게 들어가지는 못할꺼 같다.
application.properties
#H2DB
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.type.descriptor.sql=trace
#Thymeleaf develop
spring.thymeleaf.cache=false
logging.level.org.springframework= info
logging.level.com.example.webrtc=debug
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation group: 'com.googlecode.json-simple', name: 'json-simple', version: '1.1.1'
성공 기념 인증샷
아직 갈 길이 멀지만 local에서 내 화면만 띄우는 것은 크게 어려운 점이 없었다.
얼른 WebRtc를 더 공부하고 싶다.