-
Notifications
You must be signed in to change notification settings - Fork 1
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
refactor: ETA 목록 조회 API web socket으로 수정 #531
base: develop
Are you sure you want to change the base?
Changes from 10 commits
99c5da6
2614c14
ba206f4
cc78568
927bcf1
df70539
dc8c54a
0bbd51a
3ab40b7
bf35c8b
6d3e3e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
package com.ody.eta.annotation; | ||
|
||
import java.lang.annotation.ElementType; | ||
import java.lang.annotation.Retention; | ||
import java.lang.annotation.RetentionPolicy; | ||
import java.lang.annotation.Target; | ||
|
||
@Target(ElementType.PARAMETER) | ||
@Retention(RetentionPolicy.RUNTIME) | ||
public @interface WebSocketAuthMember { | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
package com.ody.eta.argumentresolver; | ||
|
||
import com.ody.auth.service.AuthService; | ||
import com.ody.common.exception.OdyException; | ||
import com.ody.common.exception.OdyUnauthorizedException; | ||
import com.ody.eta.annotation.WebSocketAuthMember; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.core.MethodParameter; | ||
import org.springframework.http.HttpHeaders; | ||
import org.springframework.messaging.Message; | ||
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; | ||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor; | ||
|
||
@Slf4j | ||
@RequiredArgsConstructor | ||
public class WebSocketArgumentResolver implements HandlerMethodArgumentResolver { | ||
|
||
private final AuthService authService; | ||
|
||
public boolean supportsParameter(MethodParameter parameter) { | ||
return parameter.hasParameterAnnotation(WebSocketAuthMember.class); | ||
} | ||
|
||
@Override | ||
public Object resolveArgument( | ||
MethodParameter parameter, | ||
Message<?> message | ||
) { | ||
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); | ||
String accessToken = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); | ||
|
||
try { | ||
return authService.parseAccessToken(accessToken); | ||
} catch (OdyException exception) { | ||
log.warn(exception.getMessage()); | ||
throw new OdyUnauthorizedException("액세스 토큰이 유효하지 않습니다."); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
package com.ody.eta.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 registerStompEndpoints(StompEndpointRegistry registry) { | ||
registry.addEndpoint("/connect") | ||
.setAllowedOrigins("*"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [질문] There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 우선 모든 요청지로부터의 요청을 허용한다는 설정으로만 알고 있는데 CORS와 관련된 부분인지 조금 더 공부해볼게요! socket통신이 처음이다보니 저도 익숙치 않은 설정이 많네요 😢 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 안드와 이야기해보고 와일드카드 사용하지 않는 방향으로 수정할게요! |
||
} | ||
|
||
@Override | ||
public void configureMessageBroker(MessageBrokerRegistry registry) { | ||
registry.enableSimpleBroker("/topic"); | ||
registry.setApplicationDestinationPrefixes("/publish"); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
package com.ody.eta.controller; | ||
|
||
import com.ody.eta.annotation.WebSocketAuthMember; | ||
import com.ody.eta.dto.request.MateEtaRequest; | ||
import com.ody.eta.service.EtaSocketService; | ||
import com.ody.meeting.dto.response.MateEtaResponsesV2; | ||
import com.ody.member.domain.Member; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
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.handler.annotation.SendTo; | ||
import org.springframework.web.bind.annotation.RestController; | ||
|
||
@Slf4j | ||
@RestController | ||
@RequiredArgsConstructor | ||
public class EtaSocketController { | ||
|
||
private final EtaSocketService etaSocketService; | ||
|
||
@MessageMapping("/open/{meetingId}") | ||
public void open(@DestinationVariable Long meetingId) { | ||
log.info("--- websocket open ! - {}", meetingId); | ||
etaSocketService.open(meetingId); | ||
} | ||
|
||
@MessageMapping("/etas/{meetingId}") | ||
@SendTo("/topic/etas/{meetingId}") | ||
public MateEtaResponsesV2 etaUpdate( | ||
@DestinationVariable Long meetingId, | ||
@WebSocketAuthMember Member member, | ||
@Payload MateEtaRequest etaRequest | ||
) { | ||
log.info("--- etaUpdate 호출 ! - {}, {}, {}", meetingId, member, etaRequest); | ||
return etaSocketService.etaUpdate(meetingId, member, etaRequest); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
package com.ody.eta.service; | ||
|
||
import com.ody.eta.dto.request.MateEtaRequest; | ||
import com.ody.mate.service.MateService; | ||
import com.ody.meeting.domain.Meeting; | ||
import com.ody.meeting.dto.response.MateEtaResponsesV2; | ||
import com.ody.meeting.service.MeetingService; | ||
import com.ody.member.domain.Member; | ||
import com.ody.util.TimeUtil; | ||
import java.time.Duration; | ||
import java.time.Instant; | ||
import java.time.LocalDateTime; | ||
import java.time.ZoneOffset; | ||
import java.util.Map; | ||
import java.util.concurrent.ConcurrentHashMap; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.messaging.simp.SimpMessagingTemplate; | ||
import org.springframework.scheduling.TaskScheduler; | ||
import org.springframework.stereotype.Service; | ||
|
||
@Slf4j | ||
@Service | ||
@RequiredArgsConstructor | ||
public class EtaSocketService { | ||
|
||
private static final ZoneOffset KST_OFFSET = ZoneOffset.ofHours(9); | ||
private static final Map<Long, LocalDateTime> LATEST_TRIGGER_TIME_CACHE = new ConcurrentHashMap<>(); | ||
private static final Map<Long, LocalDateTime> MEETING_TIME_CACHE = new ConcurrentHashMap<>(); | ||
|
||
private final MeetingService meetingService; | ||
private final MateService mateService; | ||
private final TaskScheduler taskScheduler; | ||
private final SimpMessagingTemplate template; | ||
|
||
public void open(Long meetingId) { | ||
if (!MEETING_TIME_CACHE.containsKey(meetingId)) { | ||
Meeting meeting = meetingService.findById(meetingId); | ||
MEETING_TIME_CACHE.put(meetingId, meeting.getMeetingTime()); | ||
} | ||
scheduleTrigger(meetingId, LocalDateTime.now().plusSeconds(1)); | ||
} | ||
|
||
public MateEtaResponsesV2 etaUpdate(Long meetingId, Member member, MateEtaRequest etaRequest) { | ||
if (isOverMeetingTime(meetingId)) { | ||
log.info("--- websocket disconnect ! - {}", meetingId); | ||
template.convertAndSend("/topic/disconnect/" + meetingId, ""); | ||
} else if (isTimeToSchedule(meetingId)) { | ||
scheduleTrigger(meetingId, LocalDateTime.now().plusSeconds(10)); | ||
} | ||
return mateService.findAllMateEtas(etaRequest, meetingId, member); | ||
} | ||
|
||
private boolean isOverMeetingTime(Long meetingId) { | ||
LocalDateTime meetingTime = MEETING_TIME_CACHE.get(meetingId); | ||
LocalDateTime lastTriggerTime = meetingTime.plusMinutes(1L); | ||
return TimeUtil.nowWithTrim().isAfter(lastTriggerTime); | ||
} | ||
Comment on lines
+54
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [질문] 약속 시간이 지나면 더 이상 웹소켓 연결을 유지하지 않기 위해 검증하는 것 같은데 meetingTime.plusMinutes(1L)을 왜 lastTriggerTime이라는 변수명으로 받나요? 의미가 이해가 잘 안 가요 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 변수명에 대해서는 다시 생각해보는 게 맞을 것 같에요. lastCallTime, endCallTime 등의 변수명이 더 적절하다고 느껴지네요. 약속 시간 +1 분을 하는 이유는 약속 시간 이후에도 1분간 상태 업데이트가 되어야 지각 위기/ 도착 예정 -> 지각 / 도착 여부가 판별되기 때문입니다. 따라서 약속 시간 1분후 까지 eta 상태를 계속해서 업데이트하고 요청 시각이 1분후를 지났다면 disconnect 요청을 보내는 방식입니다. |
||
|
||
private boolean isTimeToSchedule(Long meetingId) { | ||
LocalDateTime lastTriggerTime = LATEST_TRIGGER_TIME_CACHE.get(meetingId); | ||
Duration duration = Duration.between(lastTriggerTime, LocalDateTime.now()); | ||
return duration.toSeconds() >= 10; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [제안] 상수로 둬도 좋을 것 같네요 |
||
} | ||
|
||
private void scheduleTrigger(Long meetingId, LocalDateTime triggerTime) { | ||
Instant startTime = triggerTime.toInstant(KST_OFFSET); | ||
taskScheduler.schedule( | ||
() -> template.convertAndSend("topic/coordinates/" + meetingId, ""), startTime | ||
); | ||
LATEST_TRIGGER_TIME_CACHE.put(meetingId, LocalDateTime.now()); | ||
Comment on lines
+68
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [질문] 스케줄링 성공과 무관하게 latest trigger time을 업데이트해줘도 되나요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
log.info("--- schedule 예약 완료 ! - {}, {}", meetingId, triggerTime); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
package com.ody.common.websocket; | ||
|
||
import com.ody.common.DatabaseCleaner; | ||
import com.ody.common.TestAuthConfig; | ||
import com.ody.common.TestRouteConfig; | ||
import com.ody.common.config.JpaAuditingConfig; | ||
import com.ody.eta.config.WebSocketConfig; | ||
import com.ody.notification.config.FcmConfig; | ||
import com.ody.notification.service.FcmPushSender; | ||
import com.ody.notification.service.FcmSubscriber; | ||
import java.util.concurrent.ExecutionException; | ||
import java.util.concurrent.TimeUnit; | ||
import java.util.concurrent.TimeoutException; | ||
import org.junit.jupiter.api.AfterEach; | ||
import org.junit.jupiter.api.BeforeEach; | ||
import org.junit.jupiter.api.extension.ExtendWith; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.boot.test.context.SpringBootTest; | ||
import org.springframework.boot.test.mock.mockito.MockBean; | ||
import org.springframework.boot.test.web.server.LocalServerPort; | ||
import org.springframework.context.annotation.Import; | ||
import org.springframework.messaging.converter.MappingJackson2MessageConverter; | ||
import org.springframework.messaging.simp.stomp.StompSession; | ||
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; | ||
import org.springframework.test.annotation.DirtiesContext; | ||
import org.springframework.test.annotation.DirtiesContext.ClassMode; | ||
import org.springframework.test.context.ActiveProfiles; | ||
import org.springframework.test.context.junit.jupiter.SpringExtension; | ||
import org.springframework.web.socket.client.standard.StandardWebSocketClient; | ||
import org.springframework.web.socket.messaging.WebSocketStompClient; | ||
|
||
@Import({JpaAuditingConfig.class, TestRouteConfig.class, TestAuthConfig.class, WebSocketConfig.class}) | ||
@ExtendWith(SpringExtension.class) | ||
@ActiveProfiles("test") | ||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) | ||
@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) | ||
public class BaseStompTest { | ||
|
||
private static final String ENDPOINT = "/connect"; | ||
|
||
@MockBean | ||
private FcmConfig fcmConfig; | ||
|
||
@MockBean | ||
protected FcmSubscriber fcmSubscriber; | ||
|
||
@MockBean | ||
protected FcmPushSender fcmPushSender; | ||
|
||
@Autowired | ||
private DatabaseCleaner databaseCleaner; | ||
|
||
protected StompSession stompSession; | ||
|
||
@LocalServerPort | ||
private int port; | ||
|
||
private final String url; | ||
|
||
private final WebSocketStompClient websocketClient; | ||
|
||
public BaseStompTest() { | ||
this.websocketClient = new WebSocketStompClient(new StandardWebSocketClient()); | ||
this.websocketClient.setMessageConverter(new MappingJackson2MessageConverter()); | ||
this.url = "ws://localhost:"; | ||
} | ||
|
||
@BeforeEach | ||
public void connect() throws ExecutionException, InterruptedException, TimeoutException { | ||
this.stompSession = this.websocketClient | ||
.connect(url + port + ENDPOINT, new StompSessionHandlerAdapter() { | ||
}) | ||
.get(3, TimeUnit.SECONDS); | ||
} | ||
|
||
@BeforeEach | ||
void databaseCleanUp() { | ||
databaseCleaner.cleanUp(); | ||
} | ||
|
||
@AfterEach | ||
public void disconnect() { | ||
if (this.stompSession.isConnected()) { | ||
this.stompSession.disconnect(); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package com.ody.common.websocket; | ||
|
||
import java.lang.reflect.Type; | ||
import java.util.concurrent.CompletableFuture; | ||
import org.springframework.messaging.simp.stomp.StompFrameHandler; | ||
import org.springframework.messaging.simp.stomp.StompHeaders; | ||
|
||
public class MessageFrameHandler<T> implements StompFrameHandler { | ||
|
||
private final CompletableFuture<T> completableFuture = new CompletableFuture<>(); | ||
|
||
private final Class<T> tClass; | ||
|
||
public MessageFrameHandler(Class<T> tClass) { | ||
this.tClass = tClass; | ||
} | ||
|
||
@Override | ||
public Type getPayloadType(StompHeaders headers) { | ||
return this.tClass; | ||
} | ||
|
||
@Override | ||
public void handleFrame(StompHeaders headers, Object payload) { | ||
if(completableFuture.complete((T)payload)){ | ||
System.out.println("끝남"); | ||
} | ||
} | ||
|
||
public CompletableFuture<T> getCompletableFuture() { | ||
return completableFuture; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[필수]
개행하지 않아도 될 것 같아요 😃
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
반영했습니다!