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

[채팅] redis pub/sub을 활용한 채팅 #93

Merged
merged 7 commits into from
May 25, 2024
Merged
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions be/build.gradle
Original file line number Diff line number Diff line change
@@ -52,6 +52,14 @@ dependencies {

// fcm
implementation 'com.google.firebase:firebase-admin:9.2.0'

// websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:stomp-websocket:2.3.3-1'
implementation 'org.webjars:sockjs-client:1.1.2'

// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

tasks.named('bootBuildImage') {
Original file line number Diff line number Diff line change
@@ -5,27 +5,63 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.http.ResponseEntity;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.ResponseBody;
import yeonba.be.chatting.dto.request.ChatPublishRequest;
import yeonba.be.chatting.dto.response.ChatMessageResponse;
import yeonba.be.chatting.dto.response.ChatRoomResponse;
import yeonba.be.chatting.service.ChatService;
import yeonba.be.util.CustomResponse;

@Tag(name = "Chatting", description = "채팅 API")
@RestController
@Slf4j
@Controller
@RequiredArgsConstructor
public class ChattingController {
public class ChatController {

private final ChatService chatService;

@Operation(summary = "채팅 목록 조회", description = "자신이 참여 중인 채팅 목록을 조회할 수 있습니다.")
@MessageMapping("/chat")
public void chat(ChatPublishRequest request) {

log.info("chatting test log {}", request.getContent());

chatService.publish(request);
}

@Operation(summary = "채팅 메시지 목록 조회", description = "특정 채팅방의 메시지 목록을 조회할 수 있습니다.")
@ApiResponse(responseCode = "200", description = "채팅 메시지 목록 조회 성공")
@ResponseBody
@GetMapping("/chat-rooms/{roomId}/messages")
public ResponseEntity<CustomResponse<List<ChatMessageResponse>>> getChatMessages(
@RequestAttribute("userId") long userId,
@Parameter(description = "채팅방 ID", example = "1")
@PathVariable long roomId) {

List<ChatMessageResponse> response = chatService.getChatMessages(userId, roomId);

return ResponseEntity
.ok()
.body(new CustomResponse<>(response));
}

@Operation(summary = "채팅방 목록 조회", description = "자신이 참여 중인 채팅 목록을 조회할 수 있습니다.")
@ApiResponse(responseCode = "200", description = "참여 중인 채팅 목록 조회 성공")
@GetMapping("/chattings")
@ResponseBody
@GetMapping("/chat-rooms")
public ResponseEntity<CustomResponse<List<ChatRoomResponse>>> getChatRooms(
@RequestAttribute("userId") long userId) {

@@ -38,6 +74,7 @@ public ResponseEntity<CustomResponse<List<ChatRoomResponse>>> getChatRooms(

@Operation(summary = "채팅 요청", description = "다른 사용자에게 채팅을 요청할 수 있습니다.")
@ApiResponse(responseCode = "200", description = "채팅 요청 정상 처리")
@ResponseBody
@PostMapping("/users/{partnerId}/chat")
public ResponseEntity<CustomResponse<Void>> requestChat(
@RequestAttribute("userId") long userId,
@@ -53,6 +90,7 @@ public ResponseEntity<CustomResponse<Void>> requestChat(

@Operation(summary = "채팅 요청 수락", description = "요청받은 채팅을 수락할 수 있습니다.")
@ApiResponse(responseCode = "200", description = "채팅 요청 수락 정상 처리")
@ResponseBody
@PostMapping("/notifications/{notificationId}/chat")
public ResponseEntity<CustomResponse<Void>> acceptRequestedChat(
@RequestAttribute("userId") long userId,
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package yeonba.be.chatting.dto.request;

import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Getter;

@Getter
public class ChatPublishRequest implements Serializable {

private long roomId;
private long userId;
private String userName;
private String content;
private LocalDateTime sentAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package yeonba.be.chatting.dto.request;

import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Getter;

@Getter
public class ChatSubscribeResponse implements Serializable {

private long roomId;
private long userId;
private String userName;
private String content;
private LocalDateTime sentAt;
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package yeonba.be.chatting.dto.response;

import java.time.LocalDateTime;
import lombok.Getter;

@Getter
public class ChatMessageResponse {

private long userId;
private String userName;
private String content;
private LocalDateTime sentAt;

public ChatMessageResponse(long userId, String userName, String content, LocalDateTime sentAt) {

this.userId = userId;
this.userName = userName;
this.content = content;
this.sentAt = sentAt;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

@AllArgsConstructor로 간단히 표현할 수 있을 듯 한데, 직접 생성자를 작성하신 이유가 있을까요?

}
3 changes: 2 additions & 1 deletion be/src/main/java/yeonba/be/chatting/entity/ChatMessage.java
Original file line number Diff line number Diff line change
@@ -53,12 +53,13 @@ public class ChatMessage {

private LocalDateTime deletedAt;

public ChatMessage(ChatRoom chatRoom, User sender, User receiver, String content) {
public ChatMessage(ChatRoom chatRoom, User sender, User receiver, String content, LocalDateTime sentAt) {

this.chatRoom = chatRoom;
this.sender = sender;
this.receiver = receiver;
this.content = content;
this.sentAt = sentAt;
this.read = false;
}
}
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@ public class ChatMessageCommand {

private final ChatMessageRepository chatMessageRepository;

public ChatMessage createChatMessage(ChatMessage message) {
public ChatMessage save(ChatMessage message) {

return chatMessageRepository.save(message);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package yeonba.be.chatting.repository.chatmessage;

import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import yeonba.be.chatting.entity.ChatMessage;
import yeonba.be.chatting.entity.ChatRoom;

@Component
@RequiredArgsConstructor
@@ -19,4 +21,9 @@ public int countUnreadMessagesByChatRoomId(long chatRoomId) {

return chatMessageRepository.countByChatRoomIdAndReadIsFalse(chatRoomId);
}

public List<ChatMessage> findAllByChatRoom(ChatRoom chatRoom) {

return chatMessageRepository.findAllByChatRoomOrderBySentAtDesc(chatRoom);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package yeonba.be.chatting.repository.chatmessage;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import yeonba.be.chatting.entity.ChatMessage;
import yeonba.be.chatting.entity.ChatRoom;

@Repository
public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {

ChatMessage findFirstByChatRoomIdOrderBySentAtDesc(long chatRoomId);

int countByChatRoomIdAndReadIsFalse(long chatRoomId);

List<ChatMessage> findAllByChatRoomOrderBySentAtDesc(ChatRoom chatRoom);
}
Original file line number Diff line number Diff line change
@@ -15,6 +15,12 @@ public class ChatRoomQuery {

private final ChatRoomRepository chatRoomRepository;

public ChatRoom findById(long id) {

return chatRoomRepository.findById(id)
.orElseThrow(() -> new GeneralException(NOT_FOUND_CHAT_ROOM));
}

public List<ChatRoom> findAllBy(User user) {

return chatRoomRepository.findAllByUserAndActiveIsTrue(user);
59 changes: 56 additions & 3 deletions be/src/main/java/yeonba/be/chatting/service/ChatService.java
Original file line number Diff line number Diff line change
@@ -4,17 +4,24 @@
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import yeonba.be.chatting.dto.request.ChatPublishRequest;
import yeonba.be.chatting.dto.response.ChatMessageResponse;
import yeonba.be.chatting.dto.response.ChatRoomResponse;
import yeonba.be.chatting.entity.ChatMessage;
import yeonba.be.chatting.entity.ChatRoom;
import yeonba.be.chatting.repository.chatmessage.ChatMessageCommand;
import yeonba.be.chatting.repository.chatmessage.ChatMessageQuery;
import yeonba.be.chatting.repository.chatroom.ChatRoomCommand;
import yeonba.be.chatting.repository.chatroom.ChatRoomQuery;
import yeonba.be.chatting.repository.chatroom.ChatRoomRepository;
import yeonba.be.exception.BlockException;
import yeonba.be.exception.ChatException;
import yeonba.be.exception.GeneralException;
import yeonba.be.exception.NotificationException;
import yeonba.be.notification.entity.Notification;
@@ -26,6 +33,7 @@
import yeonba.be.user.repository.block.BlockQuery;
import yeonba.be.user.repository.user.UserQuery;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {
@@ -39,6 +47,44 @@ public class ChatService {
private final NotificationQuery notificationQuey;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
private final NotificationQuery notificationQuey;
private final NotificationQuery notificationQuery;

이전 PR에서 제대로 리뷰 되지 못한 것 같네요. 죄송합니다. 오타 수정 부탁드립니다.


private final ApplicationEventPublisher eventPublisher;
private final RedisChattingPublisher redisChattingPublisher;
private final RedisChattingSubscriber adapter;
private final RedisMessageListenerContainer container;
private final ChatRoomRepository chatRoomRepository;

@Transactional
public void publish(ChatPublishRequest request) {

ChatRoom chatRoom = chatRoomQuery.findById(request.getRoomId());
User sender = userQuery.findById(request.getUserId());
User receiver = chatRoom.getSender().equals(sender) ? chatRoom.getReceiver()
: chatRoom.getSender();

// TODO: 메시지 Pub/Sub과 메시지 저장 로직 비동기 처리(id, user 등 request, response 변경 가능)
redisChattingPublisher.publish(new ChannelTopic(String.valueOf(request.getRoomId())),
request);
chatMessageCommand.save(
new ChatMessage(chatRoom, sender, receiver, request.getContent(), request.getSentAt()));
}

public List<ChatMessageResponse> getChatMessages(long userId, long roomId) {
Copy link
Contributor

Choose a reason for hiding this comment

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

ChatServicegetChatMessages()acceptRequestedChat()의 경우 명시적인 트랜잭션 선언이 안 되어있는데 그 이유가 궁금합니다.


User user = userQuery.findById(userId);

ChatRoom chatRoom = chatRoomQuery.findById(roomId);

if (!user.equals(chatRoom.getSender()) && !user.equals(chatRoom.getReceiver())) {
throw new GeneralException(ChatException.NOT_YOUR_CHAT_ROOM);
}

List<ChatMessage> chatMessages = chatMessageQuery.findAllByChatRoom(chatRoom);

return chatMessages.stream()
.map(chatMessage -> new ChatMessageResponse(chatMessage.getSender().getId(),
chatMessage.getSender().getNickname(),
chatMessage.getContent(), chatMessage.getSentAt()))
.toList();
}

@Transactional(readOnly = true)
public List<ChatRoomResponse> getChatRooms(long userId) {
@@ -105,15 +151,22 @@ public void acceptRequestedChat(long userId, long notificationId) {
// 본인에게 온 채팅 요청인지 검증
if (receiver.equals(userQuery.findById(userId))) {

throw new GeneralException(NotificationException.NOT_YOUR_CHATTING_REQUEST_NOTIFICATION);
throw new GeneralException(
NotificationException.NOT_YOUR_CHATTING_REQUEST_NOTIFICATION);
}

// 채팅방 활성화
ChatRoom chatRoom = chatRoomQuery.findBy(sender, receiver);
chatRoom.activeRoom();

String activeRoom = "채팅방이 활성화되었습니다.";
chatMessageCommand.createChatMessage(new ChatMessage(chatRoom, sender, receiver, activeRoom));
String activeRoom = "채팅방이 생성되었습니다.";

chatMessageCommand.save(
new ChatMessage(chatRoom, sender, receiver, activeRoom, LocalDateTime.now()));

// 메시지 수신을 위한 Redis Pub/Sub 구독
container.addMessageListener(adapter, new ChannelTopic(String.valueOf(chatRoom.getId())));
log.info("channel topic 생성 {}", chatRoom.getId());

NotificationSendEvent notificationSendEvent = new NotificationSendEvent(
NotificationType.CHATTING_REQUEST_ACCEPTED, receiver, sender,
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package yeonba.be.chatting.service;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.stereotype.Service;
import yeonba.be.chatting.dto.request.ChatPublishRequest;

@Service
@RequiredArgsConstructor
public class RedisChattingPublisher {

private final RedisTemplate<String, Object> redisTemplate;

public void publish(ChannelTopic topic, ChatPublishRequest request) {

redisTemplate.convertAndSend(topic.getTopic(), request);
}
}
Loading
Loading