Skip to content

Commit

Permalink
[IDLE-476] 웹소켓, Redis pub/sub을 이용한 채팅 전송 기능 (#217)
Browse files Browse the repository at this point in the history
* [IDLE-476] 웹소켓 dependency 추가

* [IDLE-476] 웹소켓, Redis pub/sub을 이용한 채팅 전송 기능

* [IDLE-476] 불필요 클래스 제거

* [IDLE-476] 채팅 메세지 생성 책임을 하위 도메인에서 생성하도록 수정

* [IDLE-476] hash 역직렬화 시 필요한 처리를 Serializer 설정 추가

* [IDLE-476] json 역직렬화 시, 특수문자 허용

* [IDLE-476] websocket stomp 엔드포인트 노출 설정 변경

* [IDLE-476] 채팅 메세지 길이 정책 적용
  • Loading branch information
wonjunYou authored Nov 27, 2024
1 parent d613d34 commit 274eb8f
Show file tree
Hide file tree
Showing 14 changed files with 275 additions and 6 deletions.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core",
kotest-extensions-spring = { group = "io.kotest.extensions", name = "kotest-extensions-spring", version.ref = "kotest-extensions-spring" }
spring-boot-starter-actuator = { group = "org.springframework.boot", name = "spring-boot-starter-actuator", version.ref = "spring-boot" }
spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test", version.ref = "spring-boot" }
spring-boot-starter-websocket = { group = "org.springframework.boot", name = "spring-boot-starter-websocket", version.ref = "spring-boot" }
kotest-extensions-testcontainers = { group = "io.kotest.extensions", name = "kotest-extensions-testcontainers", version.ref = "kotest-extensions-testcontainers" }
testcontainers-junit-jupiter = { group = "org.testcontainers", name = "junit-jupiter", version.ref = "testcontainers" }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.swm.idle.application.chat.domain

import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import com.swm.idle.domain.chat.enums.SenderType
import org.springframework.stereotype.Service
import java.util.*

@Service
class ChatMessageService {

fun createByUser(roomId: UUID, userId: UUID, contents: List<ChatMessage.Content>): ChatMessage {
return ChatMessage(
roomId = roomId,
senderId = userId,
senderType = SenderType.USER,
contents = contents
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.swm.idle.application.chat.facade

import com.swm.idle.application.chat.domain.ChatMessageService
import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import com.swm.idle.domain.chat.event.ChatMessageRedisPublisher
import org.springframework.stereotype.Service
import java.util.*

@Service
class ChatMessageFacadeService(
private val chatMessageRedisPublisher: ChatMessageRedisPublisher,
private val chatMessageService: ChatMessageService,
) {

fun sendTextMessage(
roomId: UUID,
senderId: UUID,
contents: List<ChatMessage.Content>,
) {
chatMessageService.createByUser(
roomId = roomId,
userId = senderId,
contents = contents,
).also {
chatMessageRedisPublisher.publish(it)
}
}

fun saveMessage(chatMessage: ChatMessage) {
// TODO : 메세지 저장 로직 구현
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.swm.idle.application.chat.facade

import org.springframework.stereotype.Service

@Service
class ChatRoomFacadeService {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.swm.idle.domain.chat.config

import com.swm.idle.domain.chat.event.ChatMessageRedisConsumer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.listener.ChannelTopic
import org.springframework.data.redis.listener.RedisMessageListenerContainer
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter

@Configuration
class ChatMessageRedisConfig {

@Bean
fun redisListenerContainer(
connectionFactory: RedisConnectionFactory,
messageListenerAdapter: MessageListenerAdapter,
): RedisMessageListenerContainer {
val container = RedisMessageListenerContainer()
container.setConnectionFactory(connectionFactory)
container.addMessageListener(messageListenerAdapter, chatChannelTopic())
return container
}

@Bean
fun messageListenerAdapter(chatMessageRedisConsumer: ChatMessageRedisConsumer): MessageListenerAdapter {
return MessageListenerAdapter(chatMessageRedisConsumer)
}

@Bean
fun chatChannelTopic(): ChannelTopic {
return ChannelTopic(CHAT_MESSAGE)
}

companion object {

const val CHAT_MESSAGE = "chat_message"

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.swm.idle.domain.chat.entity.jpa

import com.swm.idle.domain.chat.enums.ContentType
import com.swm.idle.domain.chat.enums.SenderType
//import com.swm.idle.domain.chat.vo.Content
import com.swm.idle.domain.common.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
Expand Down Expand Up @@ -43,6 +42,12 @@ class ChatMessage(
data class Content(
val type: ContentType,
val value: String,
)
) {

init {
require(value.isNotBlank()) { "채팅 메세지는 최소 1자 이상 입력해 주셔야 합니다." }
require(value.length <= 500) { "채팅 메세지는 500자를 초과할 수 없습니다." }
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.swm.idle.domain.chat.event

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.redis.connection.Message
import org.springframework.data.redis.connection.MessageListener
import org.springframework.stereotype.Component

@Component
class ChatMessageRedisConsumer(
private val applicationEventPublisher: ApplicationEventPublisher,
private val objectMapper: ObjectMapper,
) : MessageListener {

val logger = KotlinLogging.logger {}

override fun onMessage(
message: Message,
pattern: ByteArray?,
) {
logger.debug { "Received message: $message" }

objectMapper.readValue<ChatMessage>(message.body)
.also { applicationEventPublisher.publishEvent(it) }
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.swm.idle.domain.chat.event

import com.swm.idle.domain.chat.config.ChatMessageRedisConfig
import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Component

@Component
class ChatMessageRedisPublisher(
private val redisTemplate: RedisTemplate<String, Any>,
) {

private val logger = KotlinLogging.logger {}

fun publish(chatMessage: ChatMessage) {
logger.info { "RedisPublisher 도달 " }

redisTemplate.convertAndSend(ChatMessageRedisConfig.CHAT_MESSAGE, chatMessage)
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.swm.idle.domain.common.config

import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.swm.idle.domain.common.properties.RedisProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
Expand All @@ -10,6 +14,8 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
import org.springframework.data.redis.serializer.StringRedisSerializer

@Configuration
@EnableConfigurationProperties(RedisProperties::class)
Expand All @@ -30,10 +36,22 @@ class RedisConfig(
}

@Bean
fun redisTemplate(redisConnectionFactory: RedisConnectionFactory?): RedisTemplate<*, *> {
val template = RedisTemplate<ByteArray, ByteArray>()
template.connectionFactory = redisConnectionFactory
return template
fun redisTemplate(redisConnectionFactory: RedisConnectionFactory?): RedisTemplate<String, Any> {
val serializer = GenericJackson2JsonRedisSerializer(objectMapper)

return RedisTemplate<String, Any>().apply {
connectionFactory = redisConnectionFactory!!
keySerializer = StringRedisSerializer()
valueSerializer = serializer
hashKeySerializer = StringRedisSerializer()
hashValueSerializer = serializer
}
}

private val objectMapper = ObjectMapper().apply {
registerModule(KotlinModule.Builder().build())
registerModule(JavaTimeModule())
enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
}

}
1 change: 1 addition & 0 deletions idle-presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies {
implementation(project(":idle-infrastructure:monitoring"))

implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.websocket)
implementation(libs.spring.boot.starter.data.jpa)
implementation(libs.springdoc.openapi.starter.webmvc.ui)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.swm.idle.presentation.chat.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
class WebSocketConfig : WebSocketMessageBrokerConfigurer {

override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry
.addEndpoint("/websocket")
.setAllowedOriginPatterns("*")
}

override fun configureMessageBroker(registry: MessageBrokerRegistry) {
registry.enableSimpleBroker("/topic")
registry.setApplicationDestinationPrefixes("/app")
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.swm.idle.presentation.chat.controller

import com.swm.idle.application.chat.facade.ChatMessageFacadeService
import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.event.EventListener
import org.springframework.messaging.simp.SimpMessageSendingOperations
import org.springframework.stereotype.Controller

@Controller
class ChatMessageHandler(
private val simpMessageSendingOperations: SimpMessageSendingOperations,
private val chatMessageFacadeService: ChatMessageFacadeService,
) {

private val logger = KotlinLogging.logger { }

@EventListener
fun handleChatMessage(chatMessage: ChatMessage) {
logger.info { "Handler까지 도달 " }

simpMessageSendingOperations.convertAndSend(
"/topic/chat-rooms/${chatMessage.roomId}",
chatMessage
)

chatMessageFacadeService.saveMessage(chatMessage)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.swm.idle.presentation.chat.controller

import com.swm.idle.application.chat.facade.ChatMessageFacadeService
import com.swm.idle.support.common.uuid.UuidCreator
import com.swm.idle.support.transfer.chat.SendChatMessageRequest
import org.springframework.messaging.handler.annotation.DestinationVariable
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.SendTo
import org.springframework.stereotype.Controller
import java.util.*

@Controller
class ChatWebSocketController(
private val chatMessageService: ChatMessageFacadeService,
) {

@MessageMapping("/chat-rooms/{roomId}")
@SendTo("/topic/chat-rooms/{roomId}")
fun sendTextMessage(
@DestinationVariable roomId: UUID,
request: SendChatMessageRequest,
) {
chatMessageService.sendTextMessage(
roomId = roomId,
senderId = UuidCreator.create(), // TODO: 토큰 인증 구현
contents = request.contents,
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.swm.idle.support.transfer.chat

import com.swm.idle.domain.chat.entity.jpa.ChatMessage

data class SendChatMessageRequest(
val contents: List<ChatMessage.Content>,
)

0 comments on commit 274eb8f

Please sign in to comment.