Spring Boot Web Chatting : 스프링 부트로 실시간 채팅 만들기 (9) Kurento 를 이용한 그룹 화상 채팅 코드 분석
0. 시작하면서
1) MediaServer 의 구현
- SFU 의 모습을 기억해두자. 특히 이 모습과 코드를 비교하면서 보면 더욱 이해가 쉽다.
2) 동작은 어떻게??
대략 이런 느낌이구나하고 넘어가고, 아래의 코드를 보면서 더 자세한 내용을 확인한다.
1. roomMap 을 통해 전체 room 을 조회, 추가, 수정, 삭제 한다
2. KurentoRoom 을 통해 화상 채팅 방을 만든다.
3. KurentoRoomManager 를 통해 KurentoRoom 에 대한 자세한 관리 - 조회, 추가, 수정, 삭제 -를 한다.
4. KurentoRegistry 는 이런 room 에 접속한 유저 - kurentoUserSession - 를 관리한다.
5. KurentoUserSession webrtc 를 위해 각 유저에게 필요한 부분을 다룬다 - sdp, ice, 각 유저 endpoint 관리, incomingMedia, outgoingMedia 관리
대략 요런 느낌...난중에 그림으로 바꿀게요ㅋㅋ
1. KurentoConfig
- 쿠렌토 서버와 연결하기 위한 설정에 대한 정보를 담는 class
- Kurento 라이브러리를 임포트 후 KurentoHandler 와 KurentoClient() 를 Bean 으로 등록한다.
- 이후 addHandler 에 kurentoHander() 를 등록한다. 이 다음 “/signal” 부분에는 websocket 을 통해 연결할 요청 주소 를 적어준다.
- createWebSocketContainer() 부분은 메세지 버퍼 크기, 유휴 시간 초과 등 런타임 특성을 제어하는 설정값을 적어준다.
public class WebRtcConfig implements WebSocketConfigurer {
/* TODO WebRTC 관련 */
// signalHandler 대신 KurentoHandler 사용
// private final SignalHandler signalHandler;
// kurento 를 다루기 위한 핸들러
@Bean
public KurentoHandler kurentoHandler(){
return new KurentoHandler();
}
// Kurento Media Server 를 사용하기 위한 Bean 설정
// Bean 으로 등록 후 반드시!! VM 옵션에서 kurento 관련 설정을 해주어야한다.
// 아니면 에러남
@Bean
public KurentoClient kurentoClient() {
return KurentoClient.create();
}
// signal 로 요청이 왔을 때 아래의 WebSockerHandler 가 동작하도록 registry 에 설정
// 요청은 클라이언트 접속, close, 메시지 발송 등에 대해 특정 메서드를 호출한다
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(kurentoHandler(), "/signal")
.setAllowedOrigins("*");
}
// 웹 소켓에서 rtc 통신을 위한 최대 텍스트 버퍼와 바이너리 버퍼 사이즈를 설정한다?
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(32768);
container.setMaxBinaryMessageBufferSize(32768);
return container;
}
}
2. KurentoRoomDto
- 기존에는 화상채팅을 사용하기 위해서 따로 화상채팅에 대한 room class 를 만들지 않았고, type 으로 구분해서 하나의 클래스를 사용하였다.
- 이렇게 하나로 사용하는 첫번째 이유는 하나의 room class 로 만들어야지만 나중에 추후에 프론트에서 roomlist 를 보다 쉽게 뿌려줄 수 있기 때문이었다.
- 두번째 이유는 추후 DB 를 사용해서 room 정보를 저장하거나 두 개로 나누었던 채팅을 최종적으로는 하나의 서비스(화상+문자 동시에 되도록) 하는 것을 목표로했기 때문에였다. 하나로 해야지만 나중에 더 편할것 같아서…
- 그러나 이렇게 하나로 가려는 고집이 결국 문제를 만들었다. 왜냐하면 kurento 를 사용하기 위한 여러 클래스들이 많았고, 이 때문에 기존 코드를 거의 다 뜯어고쳐야 할 상황에 처했다.
- “차라리 내 코드 전체를 없애고, kurento 에 맞춰서 다시 설계를 해야할까?” 부터 시작해서 정말 많이 고민했다. 그러나 이렇게하면 “내 프로젝트에 kurento 를 적용한다” 가 아닌 “kurento 를 좀 더 좋게 개발한다” 가 되어버릴것이라고 생각했기에 조금 더 고민해서 kurento 를 뜯어보기로 했다.
- 그렇게 찾은 방법은 기존의 ChatRoomDto 를 상속받은 KurentoRoomDto 를 사용하는 것이었다.
- 이를 통해서 roomList map 에 chatRoomDto 와 함께 담을 수 있고, 동시에 userList 역시 사용 가능하게 되었다.
- participants 변수는 한 채팅방 안에 존재하는 유저들을 저장하기 위한 변수로, 기존의 kurento 에서 사용하던 변수로 chatRoomDto의 userList 가 완전히 동일한 역할을 한다.
/**
* @modifyBy SeJon Jang (wkdtpwhs@gmail.com)
* @desc 화상채팅을 위한 클래스 ChatRoomDto 를 상속받음
*/
@Getter
@Setter
@NoArgsConstructor
@RequiredArgsConstructor
public class KurentoRoomDto extends ChatRoomDto implements Closeable {
// 로깅 객체 생성
private final Logger log = LoggerFactory.getLogger(KurentoRoomDto.class);
private KurentoClient kurento;
// 미디어 파이프라인
private MediaPipeline pipeline;
@NotNull
private String roomId; // 채팅방 아이디
private String roomName; // 채팅방 이름
private int userCount; // 채팅방 인원수
private int maxUserCnt; // 채팅방 최대 인원 제한
private String roomPwd; // 채팅방 삭제시 필요한 pwd
private boolean secretChk; // 채팅방 잠금 여부
private ChatType chatType; // 채팅 타입 여부
/**
* @desc 참여자를 저장하기 위한 Map
* TODO ConcurrentHashMap 에 대해서도 공부해둘 것!
* */
private ConcurrentMap<String, KurentoUserSession> participants;
// // 채팅룸 이름?
// private final String roomId;
// 룸 정보 set
public void setRoomInfo(String roomId, String roomName, String roomPwd, boolean secure, int userCount, int maxUserCnt, ChatType chatType, KurentoClient kurento){
this.roomId = roomId;
this.roomName = roomName;
this.roomPwd = roomPwd;
this.secretChk = secure;
this.userCount = userCount;
this.maxUserCnt = maxUserCnt;
this.chatType = chatType;
this.kurento = kurento;
this.participants = (ConcurrentMap<String, KurentoUserSession>) this.userList;
}
3. ChatRoomMap
- 채팅방을 담기위한 클래스. 이전과 달라진 것은 하나도 없다.
- 화상채팅 방에 접속한 유저는 ChatRoomMap 안에 있는 KurentoRoom 클래스 안에 있는 participants 에 닉네임과 KurentoSession 과 함께 저장된다.
/**
* @desc Room 을 담기위한 클래스 => 싱글톤
* */
// 싱글톤으로 생성
// 모든 ChatService 에서 ChatRooms가 공통된 필요함으로
@Getter
@Setter
public class ChatRoomMap {
private static ChatRoomMap chatRoomMap = new ChatRoomMap();
private ConcurrentMap<String, ChatRoomDto> chatRooms = new ConcurrentHashMap<>();
private ChatRoomMap(){}
public static ChatRoomMap getInstance(){
return chatRoomMap;
}
}
4. KurentoHandler : 중요 클래스 1
- 이름부터 중요하다. 무려 kurento 에서 시그널링을 담당하는 클래스 되시겠다.
- 사실 코드를 보면...어디선가 많이 봤던 모습을 보인다. 이전의 나의 코드를 봤던 사람들이나 아니면 시그널링 서버를 구현햇던 사람들이라면 알겠지만 정말 딱!! 그 모양이다. 더도 덜도 없이 정말 그 모양 그대로이다.
- 중요 코드라고 했지만 오히려 가장 고치지 않은못한 클래스이다. 사실 고칠게 거의 없었다. 특히 중간에 handleTextMessage 부분은 전혀 건들지 않았고, close 부분과 joinRoom, leaveRoom 부분만 고쳐서 사용했다.
- 여기서 중요한 부분은 handleTextMessage 부분인데, 이곳에서 kurentoRegistry 를 통해 KurentoUserSession 를 사용한다는 것을 유의하자. 역시 아주 중요한 클래스이다.
- 이전에 시그널링 서버를 구축 해봤던게 이렇게 도움이 되다니 굉장히 행복했다ㅋㅋ
package webChat.rtc;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import lombok.RequiredArgsConstructor;
import org.kurento.client.IceCandidate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
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;
import webChat.dto.KurentoRoomDto;
import webChat.service.chatService.KurentoManager;
import webChat.service.chatService.KurentoUserRegistry;
import java.io.IOException;
@RequiredArgsConstructor
public class KurentoHandler extends TextWebSocketHandler {
// 로깅을 위한 객체 생성
private static final Logger log = LoggerFactory.getLogger(KurentoHandler.class);
// 데이터를 json 으로 넘겨 받고, 넘기기 때문에 관련 라이브러리로 GSON 을 사용함
// gson은 json구조를 띄는 직렬화된 데이터를 JAVA의 객체로 역직렬화, 직렬화 해주는 자바 라이브러리 입니다.
// 즉, JSON Object -> JAVA Object 또는 그 반대의 행위를 돕는 라이브러리 입니다.
private static final Gson gson = new GsonBuilder().create();
// 유저 등록? 을 위한 객체 생성
@Autowired
private KurentoUserRegistry registry;
// room 매니저
@Autowired
private KurentoManager roomManager;
// 이전에 사용하던 그 메서드
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
final JsonObject jsonMessage = gson.fromJson(message.getPayload(), JsonObject.class);
final KurentoUserSession user = registry.getBySession(session);
if (user != null) {
log.debug("Incoming message from user '{}': {}", user.getName(), jsonMessage);
} else {
log.debug("Incoming message from new user: {}", jsonMessage);
}
// 일전에 내가 만들었던 시그널링 서버와 동일하게 handleTextMessage 파라미터 message 로 값이 들어오면
// swtich 문으로 해당 message 를 잘라서 사용한다.
// 이때 message 는 json 형태로 들어온다
// key : id 에 대하여
switch (jsonMessage.get("id").getAsString()) {
case "joinRoom": // value : joinRoom 인 경우
joinRoom(jsonMessage, session); // joinRoom 메서드를 실행
break;
case "receiveVideoFrom": // receiveVideoFrom 인 경우
// sender 명 - 사용자명 - 과
final String senderName = jsonMessage.get("sender").getAsString();
// 유저명을 통해 session 값을 가져온다
final KurentoUserSession sender = registry.getByName(senderName);
// jsonMessage 에서 sdpOffer 값을 가져온다
final String sdpOffer = jsonMessage.get("sdpOffer").getAsString();
// 이후 receiveVideoFrom 실행 => 아마도 특정 유저로부터 받은 비디오를 다른 유저에게 넘겨주는게 아닌가...?
user.receiveVideoFrom(sender, sdpOffer);
break;
case "leaveRoom": // 유저가 나간 경우
leaveRoom(user);
break;
case "onIceCandidate": // 유저에 대해 IceCandidate 프로토콜을 실행할 때
JsonObject candidate = jsonMessage.get("candidate").getAsJsonObject();
if (user != null) {
IceCandidate cand = new IceCandidate(candidate.get("candidate").getAsString(),
candidate.get("sdpMid").getAsString(), candidate.get("sdpMLineIndex").getAsInt());
user.addCandidate(cand, jsonMessage.get("name").getAsString());
}
break;
default:
break;
}
}
// 유저의 연결이 끊어진 경우
// 참여자 목록에서 유저 제거
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
KurentoUserSession user = registry.removeBySession(session);
// roomManager.getRoom(user.getRoomName()).leave(user);
}
// 유저가 Room 에 입장했을 때
private void joinRoom(JsonObject params, WebSocketSession session) throws IOException {
// json 형태의 params 에서 room 과 name 을 분리해온다
final String roomName = params.get("room").getAsString();
final String name = params.get("name").getAsString();
log.info("PARTICIPANT {}: trying to join room {}", name, roomName);
// roomName 를 기준으로 room 으 ㄹ가져온다
KurentoRoomDto room = roomManager.getRoom(roomName);
// 유저명과 session 을 room 에 넘겨서 room 에 유저 저장
final KurentoUserSession user = room.join(name, session);
// 단순히 room 에 저장하는 것 외에도 user 를 저장하기 위한 메서드?
registry.register(user);
}
// 유저가 room 에서 떠났을 때
private void leaveRoom(KurentoUserSession user) throws IOException {
// 유저명을 기준으로 room 을 가져온다
final KurentoRoomDto room = roomManager.getRoom(user.getRoomName());
// room 에서 유저를 제거하고
room.leave(user);
// room 에서 userCount -1
room.setUserCount(room.getUserCount()-1);
}
}
5. KurentoUserSession : 중요 클래스 2
- kurentoUserSession 은 쿠렌토 미디어 서버를 통해서 관리되는 유저들의 세션을 다루기 위한 클래스이다.
- 딱 보면 알겠지만 얘와 앞에서 설명한 KurentoHandler 은 아주 중요하고 특히 Handler 쪽에서 이 클래스를 많이 사용하는 모습을 보인다.
- SFU 형태로 구현한 코드이다. 위의 그림과 함께 생각하면 알겠지만 보내는 것은 하나, 받는 것은 여러개이다
=> outgoing 은 하나 incoming 은 여러개(map)이다.
- 사실 설명할 내용은 굉장히 많다. 나름 열심히 주석을 달고 이해했지만, 사실 완전히 이해를 한 것이 아닌 일부만 이해를 했고, 코드 역시 기존 코드에 일부만 뜯어서 사용하는 상황이라...진짜 중요한 부분만 설명하고 넘어간다.
- 차라리 원본 코드와 주석을 봐주세요ㅠ.ㅠ
만약 이해가 힘들다면 이렇게 생각해보자.
결국 kurento 를 사용하더라도 ICE 와 SDP 를 통해 각 유저들이 서로를 알고 서로에게 미디어 정보를 보내게 된다. 이때 유저가 늘어나면 늘어날 수록 서버는 각 유저를 알아야하고 구분해야한다. 그래야 각 유저들에게 필요한 미디어 정보를 줄 수 있을 것이다.
각 유저(KurentoUserSession)는 하나의 미디어 정보가 도달하는 EndPoint 즉 끝 점이라고 생각하고, 쿠렌토에서는 어떤 끝 점(EndPoint) 에 도달하면 되는지를 관리하기 위해 해당 클래스를 사용한다고 생각하면 편하다.
이 클래스에서 가장 중요한 부분은 incomingMedia 부분과 onEvent 부분이다.
먼저 incomingMedia 부분은 String 을 key 로 하고 WebRtcEndpint 를 value 로 하는 map 형태로 되어있다. 이는 하나의 유저(kurentoSession) 이 어떤 endPoint 들에 대해서 미디어 정보를 받는지 저장하기 위한 변수이다. 이렇게 저장해두고 handler 에서 사람이 추가되거나 없어졌을 때 incomingMedia 에서 해당 유저를 추가하거나 삭제하면서 endpoint 정보를 조절한다.
다음으로 onEvent 부분이다. 이 부분은 솔직히 본인도 이해를 잘 못하고 넘어갔다ㅠㅠ 대략 어떤 이벤트 - 추가, 삭제 - 가 발생했을 때 이를 해당 룸(kurentoRoom) 에 있는 다른 유저와 ice 정보나 incomingMedia 정보를 동기화 시키는 부분인거 같은데...
아시는 분은 댓글로 설명 부탁드립니다!!
package webChat.rtc;
import com.google.gson.JsonObject;
import lombok.RequiredArgsConstructor;
import org.kurento.client.*;
import org.kurento.jsonrpc.JsonUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.Closeable;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
*
* @modifyBy SeJon Jang (wkdtpwhs@gmail.com)
* @desc 결국엔 여기서 중요한 것은 현재의 '나' 의 webRtcEndPoint 객체와 다른 사람들의 webRtcEndPoint 객체를 저장한 map 을 확인하고
* 새로운 유저가 들어왔을 때 이를 나의 map 에 저장하고, 다른 사람들과 이를 동기화 해서 일치 시키는 것?
*/
@RequiredArgsConstructor
public class KurentoUserSession implements Closeable {
private static final Logger log = LoggerFactory.getLogger(KurentoUserSession.class);
private final String name;
private final WebSocketSession session;
private final MediaPipeline pipeline;
private final String roomName;
/**
* @desc 현재 '나' 의 webRtcEndPoint 객체
* 나의 것이니까 밖으로 내보낸다는 의미의 outgoingMedia
* */
private final WebRtcEndpoint outgoingMedia;
/**
* @desc '나'와 연결된 다른 사람의 webRtcEndPoint 객체 => map 형태로 유저명 : webRtcEndPoint 로 저장됨
* 다른 사람꺼니까 받는다는 의미의 incomingMedia
* */
private final ConcurrentMap<String, WebRtcEndpoint> incomingMedia = new ConcurrentHashMap<>();
/**
* @Param String 유저명, String 방이름, WebSocketSession 세션객체, MediaPipline (kurento)mediaPipeline 객체
*/
public KurentoUserSession(String name, String roomName, WebSocketSession session,
MediaPipeline pipeline) {
this.pipeline = pipeline;
this.name = name;
this.session = session;
this.roomName = roomName;
// 외부로 송신하는 미디어?
this.outgoingMedia = new WebRtcEndpoint.Builder(pipeline).build();
// iceCandidateFounder 이벤트 리스너 등록
// 이벤트가 발생했을 때 다른 유저들에게 새로운 iceCnadidate 후보를 알림
this.outgoingMedia.addIceCandidateFoundListener(new EventListener<IceCandidateFoundEvent>() {
@Override
public void onEvent(IceCandidateFoundEvent event) {
// JsonObject 생성
JsonObject response = new JsonObject();
// id : iceCnadidate, id 는 ice후보자 선정
response.addProperty("id", "iceCandidate");
// name : 유저명
response.addProperty("name", name);
// add 랑 addProperty 랑 차이점?
// candidate 를 key 로 하고, IceCandidateFoundEvent 객체를 JsonUtils 를 이용해
// json 형태로 변환시킨다 => toJsonObject 는 넘겨받은 Object 객체를 JsonObject 로 변환
response.add("candidate", JsonUtils.toJsonObject(event.getCandidate()));
try {
/** synchronized 안에는 동기화 필요한 부분 지정*/
// 먼저 동기화는 프로세스(스레드)가 수행되는 시점을 조절하여 서로가 알고 있는 정보가 일치하는 것
// 여기서는 쉽게 말해 onEvent 를 통해서 넘어오는 모든 session 객체에게 앞에서 생성한 response json 을
// 넘겨주게되고 이를 통해서 iceCandidate 상태를 '일치' 시킨다? ==> 여긴 잘 모르겟어요...
synchronized (session) {
session.sendMessage(new TextMessage(response.toString()));
}
} catch (IOException e) {
log.debug(e.getMessage());
}
}
});
}
/**
* @desc 나의 webRtcEndpoint 객체를 return 함
* @return webRtcEndpoint 객체
* */
public WebRtcEndpoint getOutgoingWebRtcPeer() {
return outgoingMedia;
}
/**
* @desc IncomingMedia return
* @return ConcurrentMap<String, WebRtcEndpoint>
*/
public ConcurrentMap<String, WebRtcEndpoint> getIncomingMedia() {
return incomingMedia;
}
/**
* @desc 이름 return
* */
public String getName() {
return name;
}
/**
* @desc webSocketSession 객체 return
* */
public WebSocketSession getSession() {
return session;
}
/**
* The room to which the user is currently attending.
*
* @return The room
*/
public String getRoomName() {
return this.roomName;
}
/**
* @desc
* @Param userSession, String
* */
public void receiveVideoFrom(KurentoUserSession sender, String sdpOffer) throws IOException {
// 유저가 room 에 들어왓음을 알림
log.info("USER {}: connecting with {} in room {}", this.name, sender.getName(), this.roomName);
// 들어온 유저가 Sdp 제안
log.trace("USER {}: SdpOffer for {} is {}", this.name, sender.getName(), sdpOffer);
/**
*
* @Desc sdpOffer 에 대한 결과 String
*/
final String ipSdpAnswer = this.getEndpointForUser(sender).processOffer(sdpOffer);
final JsonObject scParams = new JsonObject();
scParams.addProperty("id", "receiveVideoAnswer");
scParams.addProperty("name", sender.getName());
scParams.addProperty("sdpAnswer", ipSdpAnswer);
log.trace("USER {}: SdpAnswer for {} is {}", this.name, sender.getName(), ipSdpAnswer);
this.sendMessage(scParams);
log.debug("gather candidates");
this.getEndpointForUser(sender).gatherCandidates();
}
/**
* @Desc userSession 을 통해서 해당 유저의 WebRtcEndPoint 객체를 가져옴
* @Param UserSession : 보내는 유저의 userSession 객체
* @return WebRtcEndPoint
* */
private WebRtcEndpoint getEndpointForUser(final KurentoUserSession sender) {
// 만약 sender 명이 현재 user명과 일치한다면, 즉 sdpOffer 제안을 보내는 쪽과 받는 쪽이 동일하다면?
// loopback 임을 찍고, 그대로 outgoinMedia 를 return
if (sender.getName().equals(name)) {
log.debug("PARTICIPANT {}: configuring loopback", this.name);
return outgoingMedia;
}
// 참여자 name 이 sender 로부터 비디오를 받음을 확인
log.debug("PARTICIPANT {}: receiving video from {}", this.name, sender.getName());
// sender 의 이름으로 나의 incomingMedia 에서 sender 의 webrtcEndpoint 객체를 가져옴
WebRtcEndpoint incoming = incomingMedia.get(sender.getName());
// 만약 가져온 incoming 이 null 이라면
// 즉 현재 내가 갖고 있는 incomingMedia 에 sender 의 webrtcEndPoint 객체가 없다면
if (incoming == null) {
// 새로운 endpoint 가 만들어졌음을 확인
log.debug("PARTICIPANT {}: creating new endpoint for {}", this.name, sender.getName());
// 새로 incoming , 즉 webRtcEndpoint 를 만들고
incoming = new WebRtcEndpoint.Builder(pipeline).build();
// incoming 객체의 addIceCandidateFoundListener 메서드 실행
incoming.addIceCandidateFoundListener(new EventListener<IceCandidateFoundEvent>() {
@Override
public void onEvent(IceCandidateFoundEvent event) {
// json 오브젝트 생성
JsonObject response = new JsonObject();
// { id : "iceCandidate"}
response.addProperty("id", "iceCandidate");
// { name : sender 의 유저명}
response.addProperty("name", sender.getName());
// {candidate : { event.getCandidate 를 json 으로 만든 형태 }
response.add("candidate", JsonUtils.toJsonObject(event.getCandidate()));
try {
// 새로 webRtcEndpoint 가 만들어 졌기 때문에 모든 UserSession 이 이것을 동일하게 공유해야 할 필요가 있다.
// 즉 모든 UserSession 의 정보를 일치시키기 위해 동기화 - synchronized - 실행
// 이를 통해서 모든 user 의 incomingMedia 가 동일하게 일치 - 동기화 - 됨
synchronized (session) {
session.sendMessage(new TextMessage(response.toString()));
}
} catch (IOException e) {
log.debug(e.getMessage());
}
}
});
// incomingMedia 에 유저명과 새로 생성된 incoming - webrtcEndPoint 객체 - 을 넣어준다
incomingMedia.put(sender.getName(), incoming);
}
log.debug("PARTICIPANT {}: obtained endpoint for {}", this.name, sender.getName());
/** 여기가 이해가 안갔었음 */
// sender 기존에 갖고 있던 webRtcEndPoint 와 새로 생성된 incoming 을 연결한다
sender.getOutgoingWebRtcPeer().connect(incoming);
return incoming;
}
public void cancelVideoFrom(final KurentoUserSession sender) {
this.cancelVideoFrom(sender.getName());
}
public void cancelVideoFrom(final String senderName) {
log.debug("PARTICIPANT {}: canceling video reception from {}", this.name, senderName);
final WebRtcEndpoint incoming = incomingMedia.remove(senderName);
log.debug("PARTICIPANT {}: removing endpoint for {}", this.name, senderName);
incoming.release(new Continuation<Void>() {
@Override
public void onSuccess(Void result) throws Exception {
log.trace("PARTICIPANT {}: Released successfully incoming EP for {}",
KurentoUserSession.this.name, senderName);
}
@Override
public void onError(Throwable cause) throws Exception {
log.warn("PARTICIPANT {}: Could not release incoming EP for {}", KurentoUserSession.this.name,
senderName);
}
});
}
@Override
public void close() throws IOException {
log.debug("PARTICIPANT {}: Releasing resources", this.name);
for (final String remoteParticipantName : incomingMedia.keySet()) {
log.trace("PARTICIPANT {}: Released incoming EP for {}", this.name, remoteParticipantName);
final WebRtcEndpoint ep = this.incomingMedia.get(remoteParticipantName);
ep.release(new Continuation<Void>() {
@Override
public void onSuccess(Void result) throws Exception {
log.trace("PARTICIPANT {}: Released successfully incoming EP for {}",
KurentoUserSession.this.name, remoteParticipantName);
}
@Override
public void onError(Throwable cause) throws Exception {
log.warn("PARTICIPANT {}: Could not release incoming EP for {}", KurentoUserSession.this.name,
remoteParticipantName);
}
});
}
outgoingMedia.release(new Continuation<Void>() {
@Override
public void onSuccess(Void result) throws Exception {
log.trace("PARTICIPANT {}: Released outgoing EP", KurentoUserSession.this.name);
}
@Override
public void onError(Throwable cause) throws Exception {
log.warn("USER {}: Could not release outgoing EP", KurentoUserSession.this.name);
}
});
}
public void sendMessage(JsonObject message) throws IOException {
log.debug("USER {}: Sending message {}", name, message);
synchronized (session) {
session.sendMessage(new TextMessage(message.toString()));
}
}
public void addCandidate(IceCandidate candidate, String name) {
if (this.name.compareTo(name) == 0) {
outgoingMedia.addIceCandidate(candidate);
} else {
WebRtcEndpoint webRtc = incomingMedia.get(name);
if (webRtc != null) {
webRtc.addIceCandidate(candidate);
}
}
}
/*
* (non-Javadoc)
*
* @see java.lang.Object#equals(java.lang.Object)
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || !(obj instanceof KurentoUserSession)) {
return false;
}
KurentoUserSession other = (KurentoUserSession) obj;
boolean eq = name.equals(other.name);
eq &= roomName.equals(other.roomName);
return eq;
}
/*
* (non-Javadoc)
*
* @see java.lang.Object#hashCode()
*/
@Override
public int hashCode() {
int result = 1;
result = 31 * result + name.hashCode();
result = 31 * result + roomName.hashCode();
return result;
}
}
6. KurentoManager
- KurentoRoom 을 관리하기 위한 클래스.
- 이전에 rtcService 에서 했던 기능들 - 조회, 추가, 수정, 삭제 - 들이 여기서 이뤄진다.
package webChat.service.chatService;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import webChat.dto.ChatRoomDto;
import webChat.dto.ChatRoomMap;
import webChat.dto.KurentoRoomDto;
import java.util.concurrent.ConcurrentMap;
/**
* @modifyBy SeJon Jang (wkdtpwhs@gmail.com)
* @Desc KuirentoRoom 을 관리하기 위한 클래스
*/
@Service
@RequiredArgsConstructor
public class KurentoManager {
// 로깅을 위한 객체 생성
private final Logger log = LoggerFactory.getLogger(KurentoManager.class);
// kurento 미디어 서버 연결을 위한 객체 생성?
// private final KurentoClient kurento;
/**
* @desc room 정보를 담은 map
* */
// private final ConcurrentMap<String, KurentoRoom> rooms = new ConcurrentHashMap<>();
private final ConcurrentMap<String, ChatRoomDto> rooms = ChatRoomMap.getInstance().getChatRooms();
/**
*
* @Desc room 정보 가져오기
* @param roomId room 이름
* @return 만약에 room 이 있다면 해당 room 객체 return 아니라면 새로운 room 생성 후 return
*/
public KurentoRoomDto getRoom(String roomId) {
log.debug("Searching for room {}", roomId);
// roomName 기준으로 room 가져오기
KurentoRoomDto room = (KurentoRoomDto) rooms.get(roomId);
// room return
return room;
}
/**
*
* @param room
* @Desc room 삭제
*/
public void removeRoom(KurentoRoomDto room) {
// rooms 에서 room 객체 삭제 => 이때 room 의 Name 을 가져와서 조회 후 삭제
this.rooms.remove(room.getRoomId());
// room 을 종료?
room.close();
log.info("Room {} removed and closed", room.getRoomId());
}
}
7. KurentoRegistry
- KurentoUserSession 을 다루기 위한 - 조회, 추가, 수정, 삭제 - 클래스. 일종의 userService 클래스라고 생각하면 편하다.
package webChat.service.chatService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.WebSocketSession;
import webChat.rtc.KurentoUserSession;
import java.util.concurrent.ConcurrentHashMap;
/**
* Map of users registered in the system. This class has a concurrent hash map to store users, using
* its name as key in the map.
*
* 유저를 관리하는 클래스로 concurrent hash map 을 쓰는데 유저명을 key 로 사용함
*
* @author Boni Garcia (bgarcia@gsyc.es)
* @author Micael Gallego (micael.gallego@gmail.com)
* @author Ivan Gracia (izanmail@gmail.com)
* @modifyBy SeJon Jang (wkdtpwhs@gmail.com)
*/
@Service
@RequiredArgsConstructor
public class KurentoUserRegistry {
/**
* @Desc 유저명 - userSession 객체 저장 map
* */
private final ConcurrentHashMap<String, KurentoUserSession> usersByName = new ConcurrentHashMap<>();
/**
* @Desc 세션아이디 - userSession 객체 저장 map
* */
private final ConcurrentHashMap<String, KurentoUserSession> usersBySessionId = new ConcurrentHashMap<>();
/**
* @Desc userSession 을 파라미터로 받은 후 해당 객체에서 userName 과 sessionId 를 key 로해서 userSession 저장
* @Param userSession
* */
public void register(KurentoUserSession user) {
usersByName.put(user.getName(), user);
usersBySessionId.put(user.getSession().getId(), user);
}
/**
* @Desc 유저명으로 userSession 을 가져옴
* @Param userName
* @Return userSession
* */
public KurentoUserSession getByName(String name) {
return usersByName.get(name);
}
/**
* @Desc 파라미터로 받은 webSocketSession 로 userSession 을 가져옴
* @Param WebSocketSession
* @Return userSession
* */
public KurentoUserSession getBySession(WebSocketSession session) {
return usersBySessionId.get(session.getId());
}
/**
* @Desc 파라미터로 받은 userName 이 usersByName map 에 있는지 검색
* @Param String userName
* @Return Boolean
* */
public boolean exists(String name) {
return usersByName.keySet().contains(name);
}
/**
* @Desc 파라미터로 WebSocketSession 을 받은 후 이를 기준으로 해당 유저의 userSession 객체를 가져옴,
* 이후 userByName 과 userBySessionId 에서 해당 유저를 삭제함
* @Param WebSocketSession session
* @return userSession 객체
* */
public KurentoUserSession removeBySession(WebSocketSession session) {
final KurentoUserSession user = getBySession(session);
usersByName.remove(user.getName());
usersBySessionId.remove(session.getId());
return user;
}
}
8. Html, JS
1) html
- 아주 단순한 html 코드. kurento-utils 와 adapter-latest, getscreenId js 가 꼭 필요하다.
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<script src="https://code.jquery.com/jquery-3.6.1.min.js"></script>
<script src="https://cdn.WebRTC-Experiment.com/getScreenId.js"></script>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<script src="/js/rtc/participant.js"></script>
<script src="/js/rtc/kurento-utils.js"></script>
<script src="/js/rtc/conferenceroom.js"></script>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css">
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.0/js/bootstrap.min.js"></script>
<link rel="styleSheet" href="/css/rtc/kurentostyle.css" type="text/css" media="screen">
</head>
<body>
<div id="container">
<div id="wrapper">
<div id="room">
<input type="hidden" id="roomId" th:value="${roomId}">
<input type="hidden" id="uuid" th:value="${uuid}">
<div class="col-lg-12 mb-3">
<div class="d-flex justify-content-around mb-3">
<div class="mr-2" data-toggle="buttons">
<input type="button" class="btn btn-outline-success" id="screenShareBtn" value="share Off"
data-flag="false" onclick="screenShare()">
</div>
</div>
</div>
<h2 id="room-header"></h2>
<div id="participants"></div>
<input type="button" id="button-leave" onmouseup="leaveRoom();"
value="Leave room">
</div>
</div>
</div>
</body>
</html>
2) kurento-service.js
- 딱 포인트만 기억하자. 물론 다 기억하면 좋겠지만, 직접 쿠렌토의 클라이언트 js 를 고칠게 아니라면 굳이 손댈필요가 없다고 생각한다.
- 두가지만 기억하면 되는데 하는 WebSocket 부분에서 어떻게 연결하는지이다. 이 부분은 이전과 동일하게 new WebSocket(url) 을 사용한다.
- 230225 기준 화면 공유쪽 코드가 많이 변경된 관계로 기존의 코드는 지우도록 하겠습니다. 자세한 내용은 git 에서 해당 코드를 확인해주세요
사실 화면 공유 코드를 보면 살짝 의문이 들 수 도 있다. 나는 내가 짜면서도 의문을 느꼈다.
바로 kurento 를 사용해서 미디어를 전송하는데 그냥 이전처럼 바로 video track 를 바꿔서 전송하는 방법을 써도 될까? 에 대한 의문이다.
처음에는 이전처럼 바로 video track 을 바꾸는 방법을 선택하더라도 크게 이상이 없을 것이라고 생각했다.
사실 kurento 던 뭐던 결국 webrtc 를 사용하는 것은 동일하기 때문에 크게 문제는 없다고 봤던 것이다. 그런데 하다보니까 궁금해졌던 부분이 "이렇게 video track 를 바꿨을 때 kurento 를 통해서 전달되는 것인지 아니면 바로 kurento 를 통해 연결된 다른 사람들에게 video track 만 바꿔서 다이렉트로 보내는건지" 고민이 되었다.
고민이 가속화 되었던 계기는 아래에 적어둔 stackoverflow 에서 찾은 내용 때문이다. 저기에서는 다른 방법을 사용하는 듯 한데...결국 그 방법으로는 못하고, 이전에 내가 사용했던 그대로 구현하게 되었고, 결과적으로 기능에 한해서는 성공했다.
이 부분에 대해서도 잘 아시는 분은 댓글 부탁드립니다.
// var script = document.createElement('script');
// script.src = "https://code.jquery.com/jquery-3.6.1.min.js";
// document.head.appendChild(script);
// websocket 연결 확인 후 register() 실행
var ws = new WebSocket('wss://' + location.host + '/signal');
ws.onopen = () => {
register();
}
// console.log("location.host : "+location.host)
var participants = {};
let name = null;
let roomId = null;
const constraints = {
// 'volume', 'channelCount', 'echoCancellation', 'autoGainControl', 'noiseSuppression', 'latency', 'sampleSize', 'sampleRate'
audio: {
autoGainControl: false,
channelCount: 2,
echoCancellation: false,
latency: 0,
noiseSuppression: false,
sampleRate: 48000,
sampleSize: 16,
volume: 1.0
},
video: {
width: 1200,
height: 1000,
maxFrameRate: 50,
minFrameRate: 40
}
};
---------------------------- 굉장히_많은_코드.js ----------------------------
/* 화면 공유를 위한 변수 선언 */
const screenHandler = new ScreenHandler();
let shareView = null;
<--- 수정된 코드 git 에서 확인해주세요 --->
Reference
- 문제의 kurento screen sharing
https://stackoverflow.com/questions/43843576/kurento-screen-sharing-in-room
- 공식 문서
https://doc-kurento.readthedocs.io/en/latest/index.html#
https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getUserMedia
- special thanks to
https://github.com/AndoneKwon/morse
https://andonekwon.tistory.com/71