Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

서버가 클라이언트에게 Ping/Pong 요청 #776

Merged
merged 8 commits into from
May 9, 2024
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,27 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.PingMessage;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

@Slf4j
@Component
@RequiredArgsConstructor
public class ChatWebSocketHandleTextMessageProvider implements WebSocketHandleTextMessageProvider {

private static final String PING_STATUS_KEY = "pingStatus";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

필수

static 필드 밑에 한 줄 개행해주세요!

private final WebSocketChatSessions sessions;
private final ObjectMapper objectMapper;
private final MessageService messageService;
Expand Down Expand Up @@ -136,6 +143,39 @@ private void updateReadMessageLog(

@Override
public void remove(final WebSocketSession session) {
log.info("{} 연결 종료", session);
sessions.remove(session);
}

@Scheduled(fixedDelay = 60000)
public void sendPingSessions() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

칭찬

스케줄러를 잘 사용해주셨네요 👍👍🏻

final Set<WebSocketSession> webSocketSessions = getWebSocketSessions();

webSocketSessions.parallelStream()
.forEach(this::sendPingMessage);
}

private Set<WebSocketSession> getWebSocketSessions() {
return sessions.getChatRoomSessions()
.values()
.stream()
.flatMap(webSocketSessions -> webSocketSessions.getSessions().stream())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

칭찬

오! flatMap을 사용해주셨네요 👍🏻

.collect(Collectors.toSet());
}

private void sendPingMessage(final WebSocketSession session) {
final Map<String, Object> attributes = session.getAttributes();
final boolean pingStatus = (boolean) attributes.get(PING_STATUS_KEY);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

선택

pingStatus 대신 connected는 어떤가요?
!pingStatus라고 하니 의미르 한 번 더 고민하게 되어서요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

connected 좋습니다!
pingStatus가 애매하다고 생각했는데 적절한 네이밍을 찾지 못했습니다..
그런데 메리가 제안해 준 connected가 적절한 것 같네요!

if (!pingStatus) {
sessions.remove(session);
return;
}

attributes.put(PING_STATUS_KEY, false);
try {
session.sendMessage(new PingMessage());
} catch (IOException e) {
log.error("ping 보내기 실패 : {} ", session);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

질문

ping 전송에 실패하면 로그만 찍고 다시 ping을 전송하지 않아도 괜찮을까요?
저는 ping 전송에 실패한 경우 정해둔 시간 동안 다시 ping 전송 요청을 해보는 것을 생각했습니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 ping이 실패한 경우 다시 ping 요청을 수행하더라도 실패할 것이라 생각했습니다.
네트워크, 세션 종료, 메시지 등에 대한 문제로 일어날 것으로 예상됩니다.
그래서 다시 시도해도 동일한 상황 및 메시지로는 계속 실패할 것이라는 생각이 드는 데 어떻게 생각하실까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

실패한 로직을 재전송하는 부분이 필요할 것 같다는 생각이 들었어요!
여기 뿐만 아니라 알림 전송도 비슷한 기능이 있어야 한다고 생각합니다.
이건 나중에 논의해보고 이슈 새로 파서 진행하면 좋을 것 같아요~!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다음 회의에서 이에 대해 한번 논의해 보기로 해요!
그 전까지 sendMessage()가 실패하는 원인에 대해 좀 더 알아보도록 하겠습니다!
그래서 일단 이번 pr에서는 해당 부분은 적용하지 않고 넘어가도록 하겠습니다.

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public boolean beforeHandshake(
) throws Exception {
attributes.put("userId", findUserId(request));
attributes.put("baseUrl", ImageRelativeUrl.USER.calculateAbsoluteUrl());
attributes.put("pingStatus", true);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

선택

리마인드 차원에서 코멘트 남깁니다
만약 위의 pingStatus 네이밍을 변경하게 된다면 여기도 함께 변경해주세요!


return super.beforeHandshake(request, response, wsHandler, attributes);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.PongMessage;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.util.List;
import java.util.Map;

@Component
@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {

private static final String TYPE_KEY = "type";
private static final String PING_STATUS_KEY = "pingStatus";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

선택 & 질문

여기도 함께 변경해주세요!
그런데 동일한 PingStatusKey가 여러군데에서 반복적으로 사용되고 있네요!
WebSocketAttributeKey와 같은 enum을 생성하고 따로 관리해주는건 어떤가요??

Copy link
Member Author

@JJ503 JJ503 May 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 WebSocketAttributeKey가 어떤 클래스를 이야기하는 걸까요?
아니면 다른 attribute를 포함해 enum을 만들면 좋겠다는 이야기가 맞을까요?
맞다면 제가 생각한 방식으로 만들어보았는데 확인해 주시면 감사하겠습니다!
추가적으로 WebSocketAttributeKey 클래스의 위치가 어디가 적절한지 고민이네요...


private final WebSocketHandleTextMessageProviderComposite providerComposite;
private final ObjectMapper objectMapper;
Expand All @@ -43,4 +46,10 @@ public void afterConnectionClosed(final WebSocketSession session, final CloseSta
final WebSocketHandleTextMessageProvider provider = providerComposite.findProvider(textMessageType);
provider.remove(session);
}

@Override
public void handlePongMessage(WebSocketSession session, PongMessage message) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

필수

제 눈에는 왜 Final이 이렇게 잘 보이는 걸까요..?

final Map<String, Object> attributes = session.getAttributes();
attributes.put(PING_STATUS_KEY, true);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

선택

PingStatus를 Enum으로 관리하고, value로 true/false를 관리하는 것은 어떤가요?
지난번에 작업하다보니 원시값만 사용하다보면 어떤 의미로 사용했었는지 헷갈리는 경우가 많더라구요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PingStatus가 위 리뷰에 따라 conected로 변경되었습니다.
그리고 connected 즉 연결이 true/false 인 것은 크게 헷갈리지 않다고 생각하는 데 어떻게 생각하시나요?
어차피 enum으로 만들더라도 connected/disconnected 혹은 true/false일 것 같아 여쭤봅니다!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다! 반영해주신거 확인했습니다

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.ddang.ddang.chat.application.event.MessageNotificationEvent;
import com.ddang.ddang.chat.application.event.UpdateReadMessageLogEvent;
import com.ddang.ddang.chat.domain.WebSocketChatSessions;
import com.ddang.ddang.chat.domain.WebSocketSessions;
import com.ddang.ddang.chat.domain.repository.ReadMessageLogRepository;
import com.ddang.ddang.chat.handler.fixture.ChatWebSocketHandleTextMessageProviderTestFixture;
import com.ddang.ddang.configuration.IsolateDatabase;
Expand All @@ -22,16 +23,22 @@
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;
import org.springframework.web.socket.PingMessage;
import org.springframework.web.socket.WebSocketSession;

import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willDoNothing;
import static org.mockito.BDDMockito.willReturn;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

@IsolateDatabase
@RecordApplicationEvents
Expand All @@ -48,6 +55,9 @@ class ChatWebSocketHandleTextMessageProviderTest extends ChatWebSocketHandleText
@SpyBean
WebSocketChatSessions sessions;

@SpyBean
WebSocketSessions webSocketSessions;

@Mock
WebSocketSession writerSession;

Expand Down Expand Up @@ -186,4 +196,35 @@ class ChatWebSocketHandleTextMessageProviderTest extends ChatWebSocketHandleText
final boolean actual = sessions.containsByUserId(채팅방.getId(), 발신자.getId());
assertThat(actual).isFalse();
}

@Test
void 저장되어_있는_세션에_ping_메시지를_보낸다() throws IOException {
// given
given(writerSession.getAttributes()).willReturn(발신자_세션_attribute_정보);
given(webSocketSessions.getSessions()).willReturn(Set.of(writerSession));
given(sessions.getChatRoomSessions()).willReturn(Map.of(채팅방.getId(), webSocketSessions));

// when
provider.sendPingSessions();

// then
verify(sessions, never()).remove(writerSession);
verify(writerSession, times(1)).sendMessage(new PingMessage());
}

@Test
void 연결이_끊긴_세션은_삭제한다() throws IOException {
// given
given(writerSession.getAttributes()).willReturn(연결이_끊긴_세션_attribute_정보);
given(webSocketSessions.getSessions()).willReturn(Set.of(writerSession));
given(sessions.getChatRoomSessions()).willReturn(Map.of(채팅방.getId(), webSocketSessions));
willDoNothing().given(sessions).remove(writerSession);

// when
provider.sendPingSessions();

// then
verify(sessions, times(1)).remove(writerSession);
verify(writerSession, never()).sendMessage(new PingMessage());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import com.ddang.ddang.chat.application.event.CreateReadMessageLogEvent;
import com.ddang.ddang.chat.domain.ChatRoom;
import com.ddang.ddang.chat.domain.repository.ChatRoomRepository;
import com.ddang.ddang.chat.domain.repository.ReadMessageLogRepository;
import com.ddang.ddang.image.domain.ProfileImage;
import com.ddang.ddang.user.domain.Reliability;
import com.ddang.ddang.user.domain.User;
Expand Down Expand Up @@ -46,6 +45,7 @@ public class ChatWebSocketHandleTextMessageProviderTestFixture {

protected Map<String, Object> 발신자_세션_attribute_정보;
protected Map<String, Object> 수신자_세션_attribute_정보;
protected Map<String, Object> 연결이_끊긴_세션_attribute_정보;
protected Map<String, String> 메시지_전송_데이터;

protected CreateReadMessageLogEvent 메시지_로그_생성_이벤트;
Expand Down Expand Up @@ -86,8 +86,21 @@ void setUpFixture() {

chatRoomRepository.save(채팅방);

발신자_세션_attribute_정보 = new HashMap<>(Map.of("userId", 발신자.getId(), "baseUrl", "/images"));
수신자_세션_attribute_정보 = new HashMap<>(Map.of("userId", 수신자.getId(), "baseUrl", "/images"));
발신자_세션_attribute_정보 = new HashMap<>(Map.of(
"userId", 발신자.getId(),
"baseUrl", "/images",
"pingStatus", true
));
수신자_세션_attribute_정보 = new HashMap<>(Map.of(
"userId", 수신자.getId(),
"baseUrl", "/images",
"pingStatus", true
));
연결이_끊긴_세션_attribute_정보 = new HashMap<>(Map.of(
"userId", 수신자.getId(),
"baseUrl", "/images",
"pingStatus", false
));
메시지_전송_데이터 = Map.of(
"chatRoomId", String.valueOf(채팅방.getId()),
"receiverId", String.valueOf(수신자.getId()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
import org.mockito.Mock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.web.socket.PongMessage;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;

import java.util.List;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.BDDMockito.given;
Expand Down Expand Up @@ -69,4 +71,16 @@ class WebSocketHandlerTest extends WebSocketHandlerTestFixture {
// then
verify(provider, times(1)).remove(any(WebSocketSession.class));
}

@Test
void pong_메시지_수신시_ping_상태를_참으로_변환한다() {
// given
given(session.getAttributes()).willReturn(세션_attribute_정보);

// when
webSocketHandler.handlePongMessage(session, new PongMessage());

// then
assertThat((boolean) 세션_attribute_정보.get(ping_상태_키)).isTrue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
public class WebSocketHandlerTestFixture {

protected Long 사용자_아이디 = 1L;
protected String ping_상태_키 = "pingStatus";
protected Map<String, Object> 세션_attribute_정보 = new HashMap<>(
Map.of(
"type", TextMessageType.CHATTINGS.name(),
"data", Map.of("userId", 사용자_아이디, "baseUrl", "/images")
"data", Map.of("userId", 사용자_아이디, "baseUrl", "/images", ping_상태_키, false)
)
);
protected TextMessage 전송할_메시지 = new TextMessage("메시지");
Expand Down
Loading