diff --git a/build.gradle b/build.gradle index 23c3606..3a1689a 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,15 @@ dependencies { //chat implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + // 외부 브로커를 사용하기 위해 + + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-reactor-netty', version: '2.4.6' + //jackson2json에서 LocalDateTime을 handling 하기 위해 + implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: '2.12.4' + + implementation 'org.springframework.boot:spring-boot-starter-amqp' + testImplementation 'org.springframework.amqp:spring-rabbit-test' // jwt implementation 'io.jsonwebtoken:jjwt-api:0.11.5' diff --git a/src/main/java/com/cluting/clutingbackend/chat/config/RabbitConfig.java b/src/main/java/com/cluting/clutingbackend/chat/config/RabbitConfig.java new file mode 100644 index 0000000..217ce24 --- /dev/null +++ b/src/main/java/com/cluting/clutingbackend/chat/config/RabbitConfig.java @@ -0,0 +1,108 @@ +package com.cluting.clutingbackend.chat.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 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.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer; +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; + +@Configuration +@EnableRabbit +public class RabbitConfig { + + @Value("${spring.rabbitmq.host}") + private String rabbitHost; + + @Value("${spring.rabbitmq.username}") + private String rabbitUsername; + + @Value("${spring.rabbitmq.password}") + private String rabbitPassword; + + @Value("${spring.rabbitmq.port}") + private Integer rabbitPort; + + 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.*"; + + // Queue 등록 + @Bean + public Queue queue(){ + return new Queue(CHAT_QUEUE_NAME,true); + } + + @Bean + public TopicExchange exchange(){ + return new TopicExchange(CHAT_EXCHANGE_NAME); + } + + @Bean + public Binding binding(Queue queue, TopicExchange exchange){ + return BindingBuilder.bind(queue).to(exchange).with(ROUTING_KEY); + } + + @Bean + public RabbitTemplate rabbitTemplate(){ + RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory()); + rabbitTemplate.setMessageConverter(jsonMessageConverter()); + rabbitTemplate.setRoutingKey(CHAT_QUEUE_NAME); + return rabbitTemplate; + } + + @Bean + public SimpleMessageListenerContainer container(){ + SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(); + container.setConnectionFactory(connectionFactory()); + container.setQueueNames(CHAT_QUEUE_NAME); +// container.setMessageListener(null); + container.setMessageListener(message -> { + String body = new String(message.getBody()); + System.out.println("Received message: " + body); + // 메시지 처리 로직 추가 + }); + return container; + } + + //Spring에서 자동생성해주는 ConnectionFactory는 SimpleConnectionFactory인가? 그건데 + //여기서 사용하는 건 CachingConnectionFacotry라 새로 등록해줌 + @Bean + public ConnectionFactory connectionFactory() { + CachingConnectionFactory factory = new CachingConnectionFactory(); + factory.setHost(rabbitHost); + factory.setPort(rabbitPort); + factory.setUsername(rabbitUsername); + factory.setPassword(rabbitPassword); + return factory; + } + + @Bean + public Jackson2JsonMessageConverter jsonMessageConverter(){ + //LocalDateTime serializable을 위해 + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true); + objectMapper.registerModule(dateTimeModule()); + + Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper); + + return converter; + } + + @Bean + public Module dateTimeModule(){ + return new JavaTimeModule(); + } +} diff --git a/src/main/java/com/cluting/clutingbackend/chat/config/RabbitMQConnectionChecker.java b/src/main/java/com/cluting/clutingbackend/chat/config/RabbitMQConnectionChecker.java new file mode 100644 index 0000000..193af32 --- /dev/null +++ b/src/main/java/com/cluting/clutingbackend/chat/config/RabbitMQConnectionChecker.java @@ -0,0 +1,26 @@ +package com.cluting.clutingbackend.chat.config; + +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.context.annotation.Configuration; +import org.springframework.boot.ApplicationRunner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Configuration +public class RabbitMQConnectionChecker { + + private static final Logger logger = LoggerFactory.getLogger(RabbitMQConnectionChecker.class); + + public ApplicationRunner checkConnection(ConnectionFactory connectionFactory) { + return args -> { + try { + connectionFactory.createConnection().close(); + logger.info("RabbitMQ connection successful."); + } catch (Exception e) { + logger.error("RabbitMQ connection failed: {}", e.getMessage()); + throw new IllegalStateException("Failed to connect to RabbitMQ", e); + } + }; + } +} + diff --git a/src/main/java/com/cluting/clutingbackend/chat/config/StompConfig.java b/src/main/java/com/cluting/clutingbackend/chat/config/StompConfig.java new file mode 100644 index 0000000..b0001b9 --- /dev/null +++ b/src/main/java/com/cluting/clutingbackend/chat/config/StompConfig.java @@ -0,0 +1,52 @@ +package com.cluting.clutingbackend.chat.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.util.AntPathMatcher; +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 StompConfig implements WebSocketMessageBrokerConfigurer { + @Value("${spring.rabbitmq.host}") + private String rabbitHost; + + @Value("${spring.rabbitmq.username}") + private String rabbitUsername; + + @Value("${spring.rabbitmq.password}") + private String rabbitPassword; + + @Value("${spring.rabbitmq.port}") + private Integer rabbitPort; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry){ + // stomp 접속 주소 url -> ws://AWS EC2 ip주소/ws + registry.addEndpoint("/stomp/chat") + .setAllowedOrigins("http://localhost:8081") + .withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry){ + // 메시지를 수신하는 요청 엔드포인트 +// registry.enableSimpleBroker("/sub"); + // 메시지를 송신하는 엔드포인트 +// registry.setApplicationDestinationPrefixes("/pub"); + + registry.setPathMatcher(new AntPathMatcher(".")); // url을 chat/room/3 -> chat.room.3으로 참조하기 위한 설정 + registry.setApplicationDestinationPrefixes("/pub"); + + registry.enableStompBrokerRelay("/queue","/topic","/exchange","amq/queue") + .setRelayHost(rabbitHost) // RabbitMQ의 IP 주소 + .setRelayPort(61613) // STOMP 포트 + .setClientLogin(rabbitUsername) // RabbitMQ 사용자 이름 + .setClientPasscode(rabbitPassword) // RabbitMQ 비밀번호 + .setSystemLogin(rabbitUsername) + .setSystemPasscode(rabbitPassword); + } +} diff --git a/src/main/java/com/cluting/clutingbackend/chat/controller/ChatController.java b/src/main/java/com/cluting/clutingbackend/chat/controller/ChatController.java new file mode 100644 index 0000000..530c79a --- /dev/null +++ b/src/main/java/com/cluting/clutingbackend/chat/controller/ChatController.java @@ -0,0 +1,28 @@ +//package com.cluting.clutingbackend.chat.controller; +// +//import com.cluting.clutingbackend.chat.dto.ChatMessageDto; +//import lombok.RequiredArgsConstructor; +//import org.springframework.messaging.handler.annotation.MessageMapping; +//import org.springframework.messaging.simp.SimpMessagingTemplate; +//import org.springframework.stereotype.Controller; +// +//@Controller +//@RequiredArgsConstructor +//public class ChatController { +// +// private final SimpMessagingTemplate template; //특정 Broker로 메세지를 전달 +// +// //Client가 SEND할 수 있는 경로 +// //stompConfig에서 설정한 applicationDestinationPrefixes와 @MessageMapping 경로가 병합됨 +// //"/pub/chat/enter" +// @MessageMapping(value = "/chat/enter") +// public void enter(ChatMessageDto message){ +// message.setMessage(message.getWriter() + "님이 채팅방에 참여하였습니다."); +// template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message); +// } +// +// @MessageMapping(value = "/chat/message") +// public void message(ChatMessageDto message){ +// template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message); +// } +//} diff --git a/src/main/java/com/cluting/clutingbackend/chat/controller/ChatRoomController.java b/src/main/java/com/cluting/clutingbackend/chat/controller/ChatRoomController.java new file mode 100644 index 0000000..37a8ee6 --- /dev/null +++ b/src/main/java/com/cluting/clutingbackend/chat/controller/ChatRoomController.java @@ -0,0 +1,26 @@ +package com.cluting.clutingbackend.chat.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping(value = "/chat") +public class ChatRoomController { + + @GetMapping("/rooms") + public String getRooms(){ + return "chat/rooms"; + } + + @GetMapping(value = "/room") + public String getRoom(Long chatRoomId, String nickname, Model model){ + + model.addAttribute("chatRoomId", chatRoomId); + model.addAttribute("nickname", nickname); + + return "chat/room"; + } +} + diff --git a/src/main/java/com/cluting/clutingbackend/chat/controller/StompRabbitController.java b/src/main/java/com/cluting/clutingbackend/chat/controller/StompRabbitController.java new file mode 100644 index 0000000..7636ba6 --- /dev/null +++ b/src/main/java/com/cluting/clutingbackend/chat/controller/StompRabbitController.java @@ -0,0 +1,59 @@ +package com.cluting.clutingbackend.chat.controller; + +import com.cluting.clutingbackend.chat.dto.ChatDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +import java.time.LocalDateTime; + +@Controller +@RequiredArgsConstructor +@Log4j2 +public class StompRabbitController { + + private final RabbitTemplate template; + + private final static String CHAT_EXCHANGE_NAME = "chat.exchange"; + private final static String CHAT_QUEUE_NAME = "chat.queue"; + + @MessageMapping("chat.enter.{chatRoomId}") + public void enter(ChatDto chat, @DestinationVariable String chatRoomId){ + + chat.setMessage("입장하셨습니다."); + chat.setRegDate(LocalDateTime.now()); + + template.convertAndSend(CHAT_EXCHANGE_NAME, "room." + chatRoomId, chat); // exchange + //template.convertAndSend("room." + chatRoomId, chat); //queue + //template.convertAndSend("amq.topic", "room." + chatRoomId, chat); //topic + } + + @GetMapping("/send") + public String sendMessage() { + ChatDto dto = new ChatDto(1L,1L,1L,"HI TEST","SEOUL",LocalDateTime.now()); + template.convertAndSend(CHAT_EXCHANGE_NAME, "room." + 1, dto); + return "Message sent!"; + } + + @MessageMapping("chat.message.{chatRoomId}") + public void send(ChatDto chat, @DestinationVariable String chatRoomId){ + + chat.setRegDate(LocalDateTime.now()); + + template.convertAndSend(CHAT_EXCHANGE_NAME, "room." + chatRoomId, chat); + //template.convertAndSend( "room." + chatRoomId, chat); + //template.convertAndSend("amq.topic", "room." + chatRoomId, chat); + } + + //receive()는 단순히 큐에 들어온 메세지를 소비만 한다. (현재는 디버그용도) + @RabbitListener(queues = CHAT_QUEUE_NAME) + public void receive(ChatDto chat){ + + System.out.println("received : " + chat.getMessage()); + } +} diff --git a/src/main/java/com/cluting/clutingbackend/chat/dto/ChatDto.java b/src/main/java/com/cluting/clutingbackend/chat/dto/ChatDto.java new file mode 100644 index 0000000..5103e38 --- /dev/null +++ b/src/main/java/com/cluting/clutingbackend/chat/dto/ChatDto.java @@ -0,0 +1,24 @@ +package com.cluting.clutingbackend.chat.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import lombok.*; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +@ToString +public class ChatDto { + private Long id; + private Long chatRoomId; + private Long memberId; + + private String message; + private String region; + + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + private LocalDateTime regDate; +} diff --git a/src/main/java/com/cluting/clutingbackend/chat/dto/ChatMessageDto.java b/src/main/java/com/cluting/clutingbackend/chat/dto/ChatMessageDto.java new file mode 100644 index 0000000..2d56a80 --- /dev/null +++ b/src/main/java/com/cluting/clutingbackend/chat/dto/ChatMessageDto.java @@ -0,0 +1,11 @@ +package com.cluting.clutingbackend.chat.dto; + +import lombok.Data; + +@Data +public class ChatMessageDto { + + private String roomId; + private String writer; + private String message; +} diff --git a/src/main/java/com/cluting/clutingbackend/chat/dto/ChatRoomDto.java b/src/main/java/com/cluting/clutingbackend/chat/dto/ChatRoomDto.java new file mode 100644 index 0000000..35bc1da --- /dev/null +++ b/src/main/java/com/cluting/clutingbackend/chat/dto/ChatRoomDto.java @@ -0,0 +1,25 @@ +package com.cluting.clutingbackend.chat.dto; + +import lombok.Data; +import org.springframework.web.socket.WebSocketSession; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Data +public class ChatRoomDto { + + private String roomId; + private String name; + private Set sessions = new HashSet<>(); + //WebSocketSession은 Spring에서 Websocket Connection이 맺어진 세션 + + public static ChatRoomDto create(String name){ + ChatRoomDto room = new ChatRoomDto(); + + room.roomId = UUID.randomUUID().toString(); + room.name = name; + return room; + } +} diff --git a/src/main/java/com/cluting/clutingbackend/chat/repository/ChatRoomRepository.java b/src/main/java/com/cluting/clutingbackend/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..6ff31fc --- /dev/null +++ b/src/main/java/com/cluting/clutingbackend/chat/repository/ChatRoomRepository.java @@ -0,0 +1,40 @@ +package com.cluting.clutingbackend.chat.repository; + +import com.cluting.clutingbackend.chat.dto.ChatRoomDto; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.*; + +@Repository +public class ChatRoomRepository { + + private Map chatRoomDTOMap; + + @PostConstruct + private void init(){ + chatRoomDTOMap = new LinkedHashMap<>(); + } + + public List findAllRooms(){ + //채팅방 생성 순서 최근 순으로 반환 + List result = new ArrayList<>(chatRoomDTOMap.values()); + Collections.reverse(result); + + return result; + } + + public ChatRoomDto findRoomById(String id){ + return chatRoomDTOMap.get(id); + } + + public ChatRoomDto createChatRoomDTO(String name){ + ChatRoomDto room = ChatRoomDto.create(name); + chatRoomDTOMap.put(room.getRoomId(), room); + + return room; + } +} \ No newline at end of file diff --git a/src/main/java/com/cluting/clutingbackend/global/config/ChatConfig.java b/src/main/java/com/cluting/clutingbackend/global/config/ChatConfig.java deleted file mode 100644 index fd7a670..0000000 --- a/src/main/java/com/cluting/clutingbackend/global/config/ChatConfig.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.cluting.clutingbackend.global.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 ChatConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void registerStompEndpoints(StompEndpointRegistry registry){ - // stomp 접속 주소 url -> ws://AWS EC2 ip주소/ws - registry.addEndpoint("/ws") - .setAllowedOrigins("*"); - } - - @Override - public void configureMessageBroker(MessageBrokerRegistry registry){ - // 메시지를 수신하는 요청 엔드포인트 - registry.enableSimpleBroker("/sub"); - - // 메시지를 송신하는 엔드포인트 - registry.setApplicationDestinationPrefixes("/pub"); - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b60848e..794ec79 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,6 @@ spring: profiles: - include: dev + include: local server: - port: 8080 \ No newline at end of file + port: 8081 \ No newline at end of file diff --git a/src/main/resources/templates/chat/room.html b/src/main/resources/templates/chat/room.html new file mode 100644 index 0000000..ddbaaff --- /dev/null +++ b/src/main/resources/templates/chat/room.html @@ -0,0 +1,132 @@ + + + + + Title + + + + + +

CHAT ROOM

+

+

+ +
+ + +
+ +
+
+ +
+
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/chat/rooms.html b/src/main/resources/templates/chat/rooms.html new file mode 100644 index 0000000..d84bdd2 --- /dev/null +++ b/src/main/resources/templates/chat/rooms.html @@ -0,0 +1,56 @@ + + + + + + Chat Rooms + + + +
+ +
+

Available Chat Rooms

+ +
+ + +
+

Create a New Chat Room

+
+
+ +
+ +
+
+
+ + + + + \ No newline at end of file