Skip to content

Commit

Permalink
[BE] fix: 외부 메시지 큐(RabbitMQ) 도입, 분산 환경 채팅 안 되던 버그 수정 (#630)
Browse files Browse the repository at this point in the history
  • Loading branch information
takoyakimchi authored Oct 23, 2024
1 parent bd551e9 commit 14bbd65
Show file tree
Hide file tree
Showing 13 changed files with 299 additions and 63 deletions.
16 changes: 9 additions & 7 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-amqp' // rabbitmq
implementation 'org.springframework.boot:spring-boot-starter-reactor-netty' // rabbitmq
implementation 'io.jsonwebtoken:jjwt:0.12.6'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation "org.springframework.boot:spring-boot-starter-actuator"
Expand Down Expand Up @@ -91,12 +93,12 @@ tasks.withType(GenerateSwaggerUI) {
doFirst {
def swaggerUIFile = file("${openapi3.outputDirectory}/openapi3.yaml")

def securitySchemesContent = " securitySchemes:\n" + \
" APIKey:\n" + \
" type: apiKey\n" + \
" name: Authorization\n" + \
" in: header\n" + \
"security:\n" +
def securitySchemesContent = " securitySchemes:\n" + \
" APIKey:\n" + \
" type: apiKey\n" + \
" name: Authorization\n" + \
" in: header\n" + \
"security:\n" +
" - APIKey: [] # Apply the security scheme here"

swaggerUIFile.append securitySchemesContent
Expand Down Expand Up @@ -130,5 +132,5 @@ resolveMainClassName {

test {
systemProperty 'user.timezone', 'Asia/Seoul'

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.happy.friendogly.chat.config;

public interface ChatTemplate {

void convertAndSend(Long chatRoomId, Object payload);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.happy.friendogly.chat.config;

import org.springframework.context.annotation.Profile;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;

@Component
@Profile("local")
public class InMemoryChatTemplate implements ChatTemplate {

private static final String TOPIC_CHAT_PREFIX = "/exchange/chat.exchange/room.";

private final SimpMessagingTemplate template;

public InMemoryChatTemplate(SimpMessagingTemplate template) {
this.template = template;
}

@Override
public void convertAndSend(Long chatRoomId, Object payload) {
template.convertAndSend(TOPIC_CHAT_PREFIX + chatRoomId, payload);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.happy.friendogly.chat.config;

import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

@Component
@Profile("!local")
public class RabbitChatTemplate implements ChatTemplate {

private final RabbitTemplate rabbitTemplate;

public RabbitChatTemplate(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}

@Override
public void convertAndSend(Long chatRoomId, Object payload) {
rabbitTemplate.convertAndSend("chat.exchange", "room." + chatRoomId, payload);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,48 +16,35 @@
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ChatSocketController {

private final ChatCommandService chatCommandService;
private final ChatRoomQueryService chatRoomQueryService;
private final SimpMessagingTemplate template;

public ChatSocketController(
ChatCommandService chatCommandService,
ChatRoomQueryService chatRoomQueryService,
SimpMessagingTemplate template
ChatRoomQueryService chatRoomQueryService
) {
this.chatCommandService = chatCommandService;
this.chatRoomQueryService = chatRoomQueryService;
this.template = template;
}

@MessageMapping("/invite")
@MessageMapping("/invite") // TODO: 1대1 채팅방 구현 시 수정 필요
public void invite(
@WebSocketAuth Long senderMemberId,
@Payload InviteToChatRoomRequest request
) {
chatRoomQueryService.validateInvitation(senderMemberId, request);
template.convertAndSend(
"/topic/invite/" + request.receiverMemberId(),
new InviteToChatRoomResponse(request.chatRoomId())
);
}

@Deprecated
@MessageMapping("/enter/{chatRoomId}")
public void enter(
@WebSocketAuth Long memberId,
@DestinationVariable(value = "chatRoomId") Long chatRoomId
) {
chatCommandService.sendEnter(memberId, chatRoomId);
// template.convertAndSend(
// "/topic/invite/" + request.receiverMemberId(),
// new InviteToChatRoomResponse(request.chatRoomId())
// );
}

@MessageMapping("/chat/{chatRoomId}")
@MessageMapping("chat.{chatRoomId}")
public void sendMessage(
@WebSocketAuth Long memberId,
@DestinationVariable(value = "chatRoomId") Long chatRoomId,
Expand All @@ -66,15 +53,6 @@ public void sendMessage(
chatCommandService.sendChat(memberId, chatRoomId, request);
}

@Deprecated
@MessageMapping("/leave/{chatRoomId}")
public void leave(
@WebSocketAuth Long memberId,
@DestinationVariable(value = "chatRoomId") Long chatRoomId
) {
chatCommandService.sendLeave(memberId, chatRoomId);
}

@MessageExceptionHandler
public ResponseEntity<ApiResponse<ErrorResponse>> handleException(FriendoglyException exception) {
ErrorResponse errorResponse = new ErrorResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static com.happy.friendogly.chat.domain.MessageType.ENTER;
import static com.happy.friendogly.chat.domain.MessageType.LEAVE;

import com.happy.friendogly.chat.config.ChatTemplate;
import com.happy.friendogly.chat.domain.ChatMessage;
import com.happy.friendogly.chat.domain.ChatRoom;
import com.happy.friendogly.chat.domain.MessageType;
Expand All @@ -16,35 +17,33 @@
import com.happy.friendogly.member.repository.MemberRepository;
import com.happy.friendogly.notification.service.NotificationService;
import java.time.LocalDateTime;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class ChatCommandService {

private static final String TOPIC_CHAT_PREFIX = "/topic/chat/";
private static final String EMPTY_CONTENT = "";

private final MemberRepository memberRepository;
private final ChatRoomRepository chatRoomRepository;
private final ChatMessageRepository chatMessageRepository;
private final NotificationService notificationService;
private final SimpMessagingTemplate template;
private final ChatTemplate chatTemplate;

public ChatCommandService(
MemberRepository memberRepository,
ChatRoomRepository chatRoomRepository,
ChatMessageRepository chatMessageRepository,
NotificationService notificationService,
SimpMessagingTemplate template
ChatTemplate chatTemplate
) {
this.memberRepository = memberRepository;
this.chatRoomRepository = chatRoomRepository;
this.chatMessageRepository = chatMessageRepository;
this.notificationService = notificationService;
this.template = template;
this.chatTemplate = chatTemplate;
}

public void sendEnter(Long senderMemberId, Long chatRoomId) {
Expand All @@ -71,9 +70,10 @@ public void sendLeave(Long senderMemberId, Long chatRoomId) {
}

private void sendAndSave(MessageType messageType, String content, ChatRoom chatRoom, Member senderMember) {
ChatMessageSocketResponse chat = new ChatMessageSocketResponse(messageType, content, senderMember, LocalDateTime.now());
ChatMessageSocketResponse chat = new ChatMessageSocketResponse(messageType, content, senderMember,
LocalDateTime.now());
notificationService.sendChatNotification(chatRoom.getId(), chat);
template.convertAndSend(TOPIC_CHAT_PREFIX + chatRoom.getId(), chat);
chatTemplate.convertAndSend(chatRoom.getId(), chat);
chatMessageRepository.save(new ChatMessage(chatRoom, messageType, senderMember, content));
}
}
108 changes: 108 additions & 0 deletions backend/src/main/java/com/happy/friendogly/config/RabbitMqConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.happy.friendogly.config;

import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.rabbitmq.client.ConnectionFactory;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.springframework.amqp.core.AmqpAdmin;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.annotation.EnableRabbit;
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@EnableRabbit
@Profile("!local")
public class RabbitMqConfig {

private static final String CHAT_QUEUE_NAME = "chat.queue";
private static final String CHAT_EXCHANGE_NAME = "chat.exchange";
private static final String ROUTING_KEY = "*.room.*";

private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");

@Value("${spring.rabbitmq.host}")
private String host;

@Value("${spring.rabbitmq.username}")
private String username;

@Value("${spring.rabbitmq.password}")
private String password;

@Value("${spring.rabbitmq.stomp-port}")
private int port;

@Bean
public Queue queue() {
return new Queue(CHAT_QUEUE_NAME, true);
}

@Bean
public TopicExchange topicExchange() {
return new TopicExchange(CHAT_EXCHANGE_NAME, true, false);
}

@Bean
public Binding binding(Queue queue, TopicExchange topicExchange) {
return BindingBuilder.bind(queue)
.to(topicExchange)
.with(ROUTING_KEY);
}

@Bean
public ConnectionFactory connectionFactory() {
CachingConnectionFactory connectionFactory = new CachingConnectionFactory();
connectionFactory.setHost(host);
connectionFactory.setPort(port);
connectionFactory.setUsername(username);
connectionFactory.setPassword(password);
connectionFactory.setVirtualHost("/");
return connectionFactory.getRabbitConnectionFactory();
}

@Bean
public AmqpAdmin amqpAdmin(RabbitTemplate rabbitTemplate) {
RabbitAdmin rabbitAdmin = new RabbitAdmin(rabbitTemplate.getConnectionFactory());
rabbitAdmin.declareExchange(topicExchange());
return rabbitAdmin;
}

@Bean
public RabbitTemplate rabbitTemplate(
org.springframework.amqp.rabbit.connection.ConnectionFactory connectionFactory) {

RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
rabbitTemplate.setMessageConverter(jackson2JsonMessageConverter());
return rabbitTemplate;
}

@Bean
public Jackson2JsonMessageConverter jackson2JsonMessageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.registerModule(javaTimeModule());
return new Jackson2JsonMessageConverter(objectMapper);
}

@Bean
public Module javaTimeModule() {
return new JavaTimeModule()
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(TIME_FORMAT))
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(TIME_FORMAT));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
@Component
public class WebSocketInterceptor implements ChannelInterceptor {

private static final String TOPIC_CHAT_ENDPOINT = "/topic/chat/";
private static final String TOPIC_INVITE_ENDPOINT = "/topic/invite/";
private static final String TOPIC_CHAT_ENDPOINT = "/exchange/chat.exchange/room.";
// private static final String TOPIC_INVITE_ENDPOINT = "/topic/invite/";

private final ClubRepository clubRepository;
private final ChatRoomRepository chatRoomRepository;
Expand Down Expand Up @@ -50,9 +50,10 @@ public Message<?> preSend(Message<?> message, MessageChannel channel) {
String accessToken = accessor.getFirstNativeHeader(AUTHORIZATION);
long memberId = validateAndExtractMemberIdFrom(accessToken);

if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) {
validateInviteSubscription(memberId, destination);
}
// TODO: 1대1 채팅방 구현할 때 고려하기
// if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) {
// validateInviteSubscription(memberId, destination);
// }

if (destination.startsWith(TOPIC_CHAT_ENDPOINT)) {
validateChatSubscription(memberId, destination);
Expand All @@ -68,9 +69,9 @@ private void validateDestination(String destination) {
if (destination.startsWith(TOPIC_CHAT_ENDPOINT)) {
return;
}
if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) {
return;
}
// if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) {
// return;
// }
throw new FriendoglyWebSocketException(String.format("%s는 올바른 sub 경로가 아닙니다.", destination));
}

Expand All @@ -88,14 +89,14 @@ private long validateAndExtractMemberIdFrom(String accessToken) {
}
}

private void validateInviteSubscription(long memberId, String destination) {
String rawMemberIdToSubscribe = destination.substring(TOPIC_INVITE_ENDPOINT.length());
long memberIdToSubscribe = convertToLong(rawMemberIdToSubscribe);

if (memberId != memberIdToSubscribe) {
throw new FriendoglyWebSocketException("자신의 초대 endpoint만 구독할 수 있습니다.");
}
}
// private void validateInviteSubscription(long memberId, String destination) {
// String rawMemberIdToSubscribe = destination.substring(TOPIC_INVITE_ENDPOINT.length());
// long memberIdToSubscribe = convertToLong(rawMemberIdToSubscribe);
//
// if (memberId != memberIdToSubscribe) {
// throw new FriendoglyWebSocketException("자신의 초대 endpoint만 구독할 수 있습니다.");
// }
// }

private void validateChatSubscription(long memberId, String destination) {
String rawChatRoomId = destination.substring(TOPIC_CHAT_ENDPOINT.length());
Expand Down
Loading

0 comments on commit 14bbd65

Please sign in to comment.