Skip to content

Commit 6164a8c

Browse files
authored
[QUZ-83][FEATURE] 게임 랜덤 매칭 구현
[QUZ-83][FEATURE] 게임 랜덤 매칭 구현
2 parents 1d8ac07 + c783af0 commit 6164a8c

40 files changed

+902
-0
lines changed

matching-service/matching-application/app-api/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ dependencies {
1010

1111
//swagger
1212
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")
13+
14+
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
1315
}
1416

1517
tasks.named<BootJar>("bootJar") {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.grepp.quizy.matching.api.game
2+
3+
import com.grepp.quizy.common.api.ApiResponse
4+
import com.grepp.quizy.matching.api.sse.SseConnector
5+
import com.grepp.quizy.matching.domain.match.MatchingUseCase
6+
import com.grepp.quizy.matching.domain.user.UserId
7+
import com.grepp.quizy.web.annotation.AuthUser
8+
import com.grepp.quizy.web.dto.UserPrincipal
9+
import org.springframework.http.MediaType
10+
import org.springframework.http.ResponseEntity
11+
import org.springframework.web.bind.annotation.*
12+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
13+
14+
@RestController
15+
@RequestMapping("/api/matching")
16+
class GameMatchingApi(
17+
private val matchingUseCase: MatchingUseCase,
18+
private val sseConnector: SseConnector
19+
) {
20+
21+
@GetMapping(
22+
value = ["/subscribe"],
23+
produces = [MediaType.TEXT_EVENT_STREAM_VALUE]
24+
)
25+
fun subscribe(@AuthUser principal: UserPrincipal): ResponseEntity<SseEmitter> {
26+
val emitter = sseConnector.connect(UserId(principal.value))
27+
matchingUseCase.registerWaiting(UserId(principal.value))
28+
return ResponseEntity.ok(emitter)
29+
}
30+
31+
@DeleteMapping("/unsubscribe")
32+
fun unsubscribe(@AuthUser principal: UserPrincipal): ApiResponse<Unit> {
33+
sseConnector.disconnect(UserId(principal.value))
34+
return ApiResponse.success()
35+
}
36+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.grepp.quizy.matching.api.sse
2+
3+
import com.grepp.quizy.matching.domain.match.MatchingPoolManager
4+
import com.grepp.quizy.matching.domain.user.UserId
5+
import org.springframework.stereotype.Component
6+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
7+
import java.io.IOException
8+
9+
@Component
10+
class SseConnector(
11+
private val matchingPoolManager: MatchingPoolManager,
12+
private val emitterRepository: SseEmitterRepository
13+
) {
14+
15+
fun connect(userId: UserId): SseEmitter {
16+
val emitter = SseEmitter(TIME_OUT)
17+
emitterRepository.save(userId.value, emitter)
18+
emitter.onTimeout { disconnect(userId) }
19+
emitter.onCompletion { disconnect(userId) }
20+
21+
try {
22+
emitter.send(
23+
SseEmitter.event()
24+
.id("")
25+
.name(CONNECTION_NAME)
26+
.data("emitter connected")
27+
)
28+
} catch (e: IOException) {
29+
emitterRepository.remove(userId.value)
30+
emitter.completeWithError(e)
31+
}
32+
33+
return emitter
34+
}
35+
36+
fun disconnect(userId: UserId) {
37+
emitterRepository.remove(userId.value)
38+
matchingPoolManager.remove(userId)
39+
}
40+
41+
companion object {
42+
const val CONNECTION_NAME = "CONNECT"
43+
const val TIME_OUT = 5 * 60 * 1000L
44+
}
45+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.grepp.quizy.matching.api.sse
2+
3+
import org.springframework.stereotype.Repository
4+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
5+
import java.util.concurrent.ConcurrentHashMap
6+
7+
@Repository
8+
class SseEmitterRepository {
9+
private val emitters = ConcurrentHashMap<Long, SseEmitter>()
10+
11+
fun save(userId: Long, emitter: SseEmitter) {
12+
emitters[userId] = emitter
13+
}
14+
15+
fun findById(userId: Long) = emitters[userId]
16+
17+
fun remove(userId: Long) {
18+
emitters.remove(userId)
19+
}
20+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.grepp.quizy.matching.api.sse
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import com.grepp.quizy.matching.domain.match.MatchingEventSender
5+
import com.grepp.quizy.matching.domain.match.MatchingPoolManager
6+
import com.grepp.quizy.matching.domain.match.PersonalMatchingSucceedEvent
7+
import com.grepp.quizy.matching.domain.user.UserId
8+
import org.springframework.stereotype.Component
9+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
10+
import java.io.IOException
11+
12+
@Component
13+
class SseSender(
14+
private val matchingPoolManager: MatchingPoolManager,
15+
private val emitterRepository: SseEmitterRepository,
16+
private val objectMapper: ObjectMapper
17+
) : MatchingEventSender {
18+
19+
override fun send(event: PersonalMatchingSucceedEvent) {
20+
val emitter = emitterRepository.findById(event.userId) ?: return
21+
22+
try {
23+
emitter.send(
24+
SseEmitter.event()
25+
.id("")
26+
.name("MATCHING")
27+
.data(objectMapper.writeValueAsString(MatchingSucceed(event.gameRoomId)))
28+
)
29+
} catch (e: IOException) {
30+
closeEmitter(event.userId)
31+
emitter.completeWithError(e)
32+
}
33+
}
34+
35+
private fun closeEmitter(userId: Long) {
36+
emitterRepository.remove(userId)
37+
matchingPoolManager.remove(UserId(userId))
38+
}
39+
}
40+
41+
private data class MatchingSucceed(val roomId: Long)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.grepp.quizy.matching.domain.game
2+
3+
import com.grepp.quizy.matching.domain.user.InterestCategory
4+
import com.grepp.quizy.matching.domain.user.UserId
5+
6+
interface GameFetcher {
7+
fun requestGameRoomId(userIds: List<UserId>, subject: InterestCategory): GameRoomId
8+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.grepp.quizy.matching.domain.game
2+
3+
@JvmInline value class GameRoomId(val value: Long)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.grepp.quizy.matching.domain.match
2+
3+
const val MATCHING_K = 5
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.grepp.quizy.matching.domain.match
2+
3+
import org.springframework.context.event.EventListener
4+
import org.springframework.scheduling.annotation.Async
5+
import org.springframework.stereotype.Component
6+
7+
@Component
8+
class MatchingEventHandler(
9+
private val matchingPoolManager: MatchingPoolManager,
10+
private val matchingManager: MatchingManager,
11+
) {
12+
13+
@Async
14+
@EventListener(UserWaitingRegisteredEvent::class)
15+
fun handleUserWaitingRegistered(event: UserWaitingRegisteredEvent) {
16+
val pivot = matchingPoolManager.findPivot() ?: return
17+
matchingManager.match(pivot)
18+
}
19+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.grepp.quizy.matching.domain.match
2+
3+
import org.springframework.context.ApplicationEventPublisher
4+
import org.springframework.stereotype.Component
5+
6+
@Component
7+
class MatchingEventInternalPublisher(
8+
private val eventPublisher: ApplicationEventPublisher
9+
) {
10+
fun publish(event: UserWaitingRegisteredEvent) {
11+
eventPublisher.publishEvent(event)
12+
}
13+
}
14+
15+
interface MatchingEventPublisher {
16+
fun publish(event: PersonalMatchingSucceedEvent)
17+
}
18+
19+
interface MatchingEventSender {
20+
fun send(event: PersonalMatchingSucceedEvent)
21+
}

0 commit comments

Comments
 (0)