Skip to content

Commit

Permalink
Merge pull request #1 from prgrms-web-devcourse-final-project/QUZ-36-…
Browse files Browse the repository at this point in the history
…Websocket-configuration

[QUZ-36][FEATURE] WebSocket Configuration
  • Loading branch information
NaMinhyeok authored Nov 16, 2024
2 parents 27bf01f + 31948ce commit c269521
Show file tree
Hide file tree
Showing 12 changed files with 333 additions and 3 deletions.
2 changes: 1 addition & 1 deletion game-service/game-domain/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
dependencies {
api(project(":common"))

implementation("org.springframework:spring-context")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.grepp.quizy.domain.game

import java.security.Principal

interface GameMessageSender {
fun send(principal: Principal, message: String)
}
28 changes: 26 additions & 2 deletions game-service/game-infra/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
plugins {
kotlin("plugin.jpa") version "1.8.22"
}

allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}

dependencies {
implementation(project(":common"))
}
// Kafka Module
api(project(":infrastructure:kafka"))
// Game Domain Module
implementation(project(":game-service:game-domain"))
// H2 Database
runtimeOnly("com.h2database:h2")
// MySQL
runtimeOnly("com.mysql:mysql-connector-j")
// JPA
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// Redis
implementation("com.github.codemonstur:embedded-redis:1.4.3")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
// WebSocket
implementation("org.springframework.boot:spring-boot-starter-websocket")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.grepp.quizy.infra.game.websocket

import org.springframework.http.server.ServerHttpRequest
import org.springframework.web.socket.WebSocketHandler
import org.springframework.web.socket.server.support.DefaultHandshakeHandler
import java.security.Principal
import java.util.*

class CustomHandshakeHandler : DefaultHandshakeHandler() {

// TODO: 실제로 게이트웨이에서 사용자 정보를 받는 방식이 정해지면 구현 방식 변경
override fun determineUser(
request: ServerHttpRequest,
wsHandler: WebSocketHandler,
attributes: MutableMap<String, Any>
): Principal = WebSocketPrincipal(UUID.randomUUID().toString())

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.grepp.quizy.infra.game.websocket

import com.grepp.quizy.domain.game.GameMessageSender
import com.grepp.quizy.infra.game.websocket.WebSocketDestination.*
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.stereotype.Component
import java.security.Principal

@Component
class GameWebSocketMessageSender(
private val messageTemplate: SimpMessagingTemplate
) : GameMessageSender {

override fun send(principal: Principal, message: String) {
messageTemplate.convertAndSendToUser(
principal.name,
"${SINGLE_PREFIX.destination}${QUIZ_GRADE.destination}",
message
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.grepp.quizy.infra.game.websocket

import com.grepp.quizy.infra.game.websocket.WebSocketDestination.*
import org.springframework.context.annotation.Configuration
import org.springframework.messaging.simp.config.MessageBrokerRegistry
import org.springframework.web.socket.config.annotation.StompEndpointRegistry
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer


@Configuration
class WebSocketBrokerConfig : WebSocketMessageBrokerConfigurer {

override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.setHandshakeHandler(CustomHandshakeHandler())
.withSockJS()
}

override fun configureMessageBroker(registry: MessageBrokerRegistry) {
registry.setApplicationDestinationPrefixes(APPLICATION_PREFIX.destination)
registry.enableSimpleBroker(MULTIPLE_PREFIX.destination, SINGLE_PREFIX.destination)
registry.setUserDestinationPrefix(USER_PREFIX.destination)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.grepp.quizy.infra.game.websocket

enum class WebSocketDestination(
val destination: String
) {

SINGLE_PREFIX("/queue"),
MULTIPLE_PREFIX("/topic"),
USER_PREFIX("/user"),
APPLICATION_PREFIX("/app"),

QUIZ_GRADE("/quiz-grade"),
;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.grepp.quizy.infra.game.websocket

import java.security.Principal

class WebSocketPrincipal(
private val name: String
) : Principal {
override fun getName(): String {
return name
}
}
105 changes: 105 additions & 0 deletions game-service/game-infra/src/main/resources/application-infra.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
spring:
config:
activate:
on-profile: local

datasource:
url: jdbc:h2:mem:~/webQuiz
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
show-sql: true
format_sql: true

redis:
host: localhost
port: 6379

kafka:
topic:
quiz: quiz
consumer-group:
quiz: quiz

kafka-config:
bootstrap-servers: localhost:19092, localhost:29092, localhost:39092
num-of-partitions: 3
replication-factor: 3

kafka-producer-config:
key-serializer-class: org.springframework.kafka.support.serializer.JsonSerializer
value-serializer-class: org.springframework.kafka.support.serializer.JsonSerializer
compression-type: none
acks: all
batch-size: 16384
batch-size-boost-factor: 100
linger-ms: 5
request-timeout-ms: 60000
retry-count: 5

kafka-consumer-config:
key-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
auto-offset-reset: earliest
batch-listener: true
auto-startup: true
concurrency-level: 3
session-timeout-ms: 10000
heartbeat-interval-ms: 3000
max-poll-interval-ms: 300000
max-poll-records: 500
max-partition-fetch-bytes-default: 1048576
max-partition-fetch-bytes-boost-factor: 1
poll-timeout-ms: 150

---
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:mysql://${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}?useSSL=false&allowPublicKeyRetrieval=true
username: ${DATABASE_USER}
password: ${DATABASE_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO}
properties:
hibernate:
show-sql: false
format_sql: false

redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}

---
spring:
config:
activate:
on-profile: test

datasource:
url: jdbc:h2:mem:~/webQuiz
driver-class-name: org.h2.Driver
username: sa
password:

redis:
host: localhost
port: 6379

jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.grepp.quizy.infra.game.websocket

import io.kotest.core.spec.style.DescribeSpec
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.verify
import org.springframework.messaging.simp.SimpMessagingTemplate
import java.security.Principal

class GameWebSocketMessageSenderTest : DescribeSpec({

val messageTemplate: SimpMessagingTemplate = mockk()
val messageSender = GameWebSocketMessageSender(messageTemplate)

describe("GameWebSocketMessageSender") {
context("메시지를 보내면") {
val principal = mockk<Principal>()
every { principal.name } returns "minhyeok"
justRun { messageTemplate.convertAndSendToUser(any(), any(), any()) }
val message = "테스트 메시지"
messageSender.send(principal, message)
it("메시지가 사용자에게 전달된다.") {
verify {
messageTemplate.convertAndSendToUser(
"minhyeok",
"/queue/quiz-grade",
message
)
}
}
}
}

})
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.grepp.quizy.infra.game.websocket

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.*
import org.springframework.messaging.simp.stomp.StompSession
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter
import org.springframework.web.socket.messaging.WebSocketStompClient
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit

class WebSocketBrokerConfigTest : FunSpec() {

private val stompClient = mockk<WebSocketStompClient>()
private val stompSession = mockk<StompSession>()

init {
beforeTest {
every {
stompClient.connectAsync(any(), any<StompSessionHandlerAdapter>())
} returns CompletableFuture.completedFuture(stompSession)

every { stompSession.isConnected } returns true
every { stompSession.disconnect() } just Runs
every { stompClient.stop() } just Runs
}

afterTest {
stompSession.disconnect()
stompClient.stop()
}

test("웹소켓 연결") {
// given
val url = "ws://localhost:8080/ws"

// when
val session = stompClient
.connectAsync(url, object : StompSessionHandlerAdapter() {})
.get(60, TimeUnit.SECONDS)

// then
session.isConnected shouldBe true
verify {
stompClient.connectAsync(url, any<StompSessionHandlerAdapter>())
session.isConnected
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.grepp.quizy.infra.game.websocket

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe

class WebSocketPrincipalTest : DescribeSpec({

describe("웹소켓 사용자에서") {
val principal = WebSocketPrincipal("minhyeok")
context("이름을 가져오면") {
val name = principal.name
it("이름을 반환한다.") {
name shouldBe "minhyeok"
}
}
}
})

0 comments on commit c269521

Please sign in to comment.