807 lines
27 KiB
Text
807 lines
27 KiB
Text
<!-- task=kotlin_impl plan=0 tag=API -->
|
|
|
|
# Kotlin 구현체 신규 추가
|
|
|
|
## 이 파일을 읽는 구현 에이전트에게
|
|
|
|
각 항목의 체크리스트를 하나씩 완료 처리하고, 중간 검증 명령을 실제로 실행한 뒤 출력을 `CODE_REVIEW.md`의 `검증 결과` 섹션에 붙여 넣으세요.
|
|
계획과 다르게 구현한 부분이 있으면 `계획 대비 변경 사항`에 이유와 함께 기록하세요.
|
|
`CODE_REVIEW.md`의 모든 섹션을 실제 구현 내용으로 채운 뒤 코드 리뷰를 요청하세요.
|
|
|
|
---
|
|
|
|
## 배경
|
|
|
|
`PROTOCOL.md`에 기술된 Toki Socket 프로토콜의 Kotlin 구현체가 없다. Go/Dart 레퍼런스 구현체를 기준으로 동일한 wire format(TCP 4-byte big-endian framing, WebSocket binary frame), typeName 라우팅, request-response nonce 상관관계, heartbeat 자동 처리를 Kotlin coroutine 관용 패턴으로 구현한다. 구현 완료 기준은 같은 언어 단위 테스트 통과와 Go ↔ Kotlin 양방향 크로스테스트 통과이다.
|
|
|
|
---
|
|
|
|
## 의존 관계 및 구현 순서
|
|
|
|
API-1 → API-2 → API-3 → API-4 → API-5 → API-6 → API-7 → API-8
|
|
|
|
---
|
|
|
|
### [API-1] Kotlin 프로젝트 구조 및 protobuf 바인딩 설정
|
|
|
|
#### 문제
|
|
|
|
`kotlin/` 디렉터리가 없고 프로젝트 파일, proto 바인딩이 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
Gradle 멀티플랫폼이 아닌 순수 JVM 라이브러리 프로젝트로 설정한다. 빌드 도구는 Gradle Kotlin DSL(`build.gradle.kts`)을 사용한다.
|
|
|
|
**디렉터리 구조:**
|
|
|
|
```
|
|
kotlin/
|
|
build.gradle.kts
|
|
settings.gradle.kts
|
|
gradlew (gradle wrapper)
|
|
gradlew.bat
|
|
gradle/wrapper/
|
|
src/
|
|
main/
|
|
kotlin/com/tokilabs/toki_socket/
|
|
proto/ ← canonical proto 복사본
|
|
test/
|
|
kotlin/com/tokilabs/toki_socket/
|
|
crosstest/
|
|
go_kotlin_client/ ← Kotlin subprocess (Go 서버 ↔ Kotlin 클라이언트)
|
|
kotlin_go.kt ← Kotlin 서버 오케스트레이터 (Kotlin 서버 ↔ Go 클라이언트)
|
|
```
|
|
|
|
**`build.gradle.kts` 핵심 의존성:**
|
|
|
|
```kotlin
|
|
plugins {
|
|
kotlin("jvm") version "2.0.0"
|
|
id("com.google.protobuf") version "0.9.4"
|
|
}
|
|
|
|
dependencies {
|
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
|
implementation("com.google.protobuf:protobuf-kotlin:4.27.0")
|
|
implementation("com.squareup.okhttp3:okhttp:4.12.0") // WS client
|
|
implementation("org.java-websocket:Java-WebSocket:1.5.6") // WS server
|
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
|
|
testImplementation(kotlin("test"))
|
|
}
|
|
|
|
protobuf {
|
|
protoc { artifact = "com.google.protobuf:protoc:4.27.0" }
|
|
generateProtoTasks {
|
|
all().forEach { task ->
|
|
task.builtins {
|
|
id("kotlin")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**proto 동기화:**
|
|
`dart/lib/src/packets/message_common.proto`에서 `kotlin/src/main/proto/message_common.proto`로 복사. Go 처럼 언어별 option(`option java_package`, `option java_outer_classname`)을 추가할 수 있으나 **message schema는 canonical proto와 동일하게 유지**. `tools/check_proto_sync.sh`가 message 필드 diff를 검출한다.
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `kotlin/settings.gradle.kts` 생성
|
|
- [ ] `kotlin/build.gradle.kts` 생성 (위 의존성 포함)
|
|
- [ ] Gradle wrapper 생성 (`gradle wrapper` 실행 또는 수동 배치)
|
|
- [ ] `kotlin/src/main/proto/message_common.proto` 생성 (canonical proto 복사 + java options 추가)
|
|
- [ ] `./gradlew generateProto` 실행하여 바인딩 생성 확인
|
|
|
|
#### 테스트 작성
|
|
|
|
SKIP — 이 항목은 빌드 인프라 설정이며 동작 검증은 이후 항목의 테스트로 커버된다.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
cd kotlin
|
|
./gradlew compileKotlin
|
|
# 예상: BUILD SUCCESSFUL (proto 바인딩 포함 컴파일)
|
|
```
|
|
|
|
---
|
|
|
|
### [API-2] Communicator 구현
|
|
|
|
#### 문제
|
|
|
|
Kotlin에 `Communicator`, `Transport`, `ParserMap` 타입이 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
Go `communicator.go` 구조를 Kotlin coroutine 관용 패턴으로 이식한다.
|
|
|
|
**파일:** `kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt`
|
|
|
|
**핵심 매핑:**
|
|
|
|
| Go | Kotlin |
|
|
|----|--------|
|
|
| `Transport` interface | `interface Transport` |
|
|
| `ParserMap` | `typealias ParserMap = Map<String, (ByteArray) -> MessageLite>` |
|
|
| `atomic.Int32` (nonce) | `AtomicInteger` |
|
|
| `atomic.Bool` (isAlive) | `AtomicBoolean` |
|
|
| `sync.RWMutex` | `ReentrantReadWriteLock` |
|
|
| `channel queuedPacket (64)` | `Channel<QueuedPacket>(capacity = 64)` |
|
|
| `closed chan struct{}` | `Job` (coroutine cancellation) |
|
|
| `sync.Once` (closeOnce) | 없음 — `shutdown()`은 `closeOnce` 없이 AtomicBoolean + coroutine cancel |
|
|
| goroutine writeLoop | `scope.launch { writeLoop() }` |
|
|
|
|
**typeName 추출:**
|
|
|
|
```kotlin
|
|
fun typeNameOf(m: MessageLite): String =
|
|
(m as com.google.protobuf.Message).descriptorForType.fullName
|
|
```
|
|
|
|
proto에 `package` 선언이 없으므로 `fullName` = `"TestData"`, `"HeartBeat"` 등 단순 이름. PROTOCOL.md Kotlin 행과 일치.
|
|
|
|
**`addListener` / `addRequestListener` 상호 배타:**
|
|
|
|
```kotlin
|
|
fun addRequestListener(typeName: String, fn: (MessageLite, Int) -> Unit) {
|
|
val lock = rwLock.writeLock()
|
|
lock.lock()
|
|
try {
|
|
check(handlers[typeName].isNullOrEmpty()) {
|
|
"type $typeName is already registered with addListener"
|
|
}
|
|
check(!reqHandlers.containsKey(typeName)) {
|
|
"type $typeName is already registered with addRequestListener"
|
|
}
|
|
reqHandlers[typeName] = fn
|
|
} finally { lock.unlock() }
|
|
}
|
|
```
|
|
|
|
위반 시 `IllegalStateException`(Go의 `panic` 대응).
|
|
|
|
**`writeLoop`:**
|
|
|
|
```kotlin
|
|
private suspend fun writeLoop() {
|
|
for (item in writeQueue) {
|
|
val err = runCatching { transport.writePacket(item.base) }
|
|
item.done.complete(err.exceptionOrNull())
|
|
if (err.isFailure) {
|
|
writeErrorHandler?.invoke(err.exceptionOrNull()!!)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**`sendRequest`:**
|
|
|
|
```kotlin
|
|
suspend fun sendRequest(
|
|
req: MessageLite,
|
|
resTypeName: String,
|
|
timeoutMs: Long = 30_000L
|
|
): MessageLite {
|
|
...
|
|
withTimeout(timeoutMs) {
|
|
select {
|
|
pending.ch.onReceive { it }
|
|
pending.errCh.onReceive { throw it }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `Communicator.kt` 생성
|
|
- [ ] `interface Transport { suspend fun writePacket(base: PacketBase); fun close() }`
|
|
- [ ] `typealias ParserMap = Map<String, (ByteArray) -> MessageLite>`
|
|
- [ ] `class Communicator(transport, parserMap, scope)` 생성자
|
|
- [ ] `initialize()`: HeartBeat 파서 자동 등록, writeLoop launch
|
|
- [ ] `isAlive(): Boolean`
|
|
- [ ] `nextNonce()`: AtomicInteger.incrementAndGet()
|
|
- [ ] `shutdown()`: isAlive=false, writeQueue.close(), scope 내부 채널 정리
|
|
- [ ] `close()`: shutdown() + transport.close()
|
|
- [ ] `queuePacket(base)`: writeQueue에 enqueue, done 대기
|
|
- [ ] `send(m)`: marshal → queuePacket
|
|
- [ ] `sendRequest(req, resTypeName, timeout)`: nonce 등록 → queuePacket → withTimeout select
|
|
- [ ] `addListener(typeName, fn)`: 상호 배타 체크
|
|
- [ ] `removeListeners(typeName)`
|
|
- [ ] `addRequestListener(typeName, fn)`: 상호 배타 체크
|
|
- [ ] `onReceivedData(typeName, data, nonce, responseNonce)`
|
|
- [ ] `handleResponse(typeName, data, responseNonce)`
|
|
- [ ] `parse(typeName, data)`
|
|
- [ ] `removePending(nonce)`
|
|
- [ ] 타입 헬퍼 함수 (Go의 제네릭 헬퍼 대응):
|
|
- [ ] `inline fun <reified T : Message> addListenerTyped(communicator, fn)`
|
|
- [ ] `inline fun <reified Req : Message, reified Res : Message> addRequestListenerTyped(communicator, fn)`
|
|
- [ ] `inline fun <reified Req : Message, reified Res : Message> sendRequestTyped(communicator, req, timeoutMs)`
|
|
|
|
#### 테스트 작성
|
|
|
|
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/CommunicatorTest.kt`
|
|
|
|
| 테스트명 | 검증 목표 |
|
|
|---------|---------|
|
|
| `testSendRequestTimeout` | timeout 경과 시 exception 발생 |
|
|
| `testSendRequestTypeMismatch` | 응답 typeName 불일치 시 exception 발생 |
|
|
| `testListenerAndRequestListenerConflict` | 동일 typeName 이중 등록 시 IllegalStateException |
|
|
| `testSendFireAndForget` | send 후 fakeTransport에 패킷 1개 기록됨 |
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
cd kotlin
|
|
./gradlew test --tests "*.CommunicatorTest"
|
|
# 예상: 4개 테스트 PASS
|
|
```
|
|
|
|
---
|
|
|
|
### [API-3] HeartbeatTimer 구현
|
|
|
|
#### 문제
|
|
|
|
주기적 heartbeat 발송을 위한 취소 가능한 타이머가 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
Go의 `HeartbeatTimer`를 coroutine `delay` 기반으로 이식한다.
|
|
|
|
**파일:** `kotlin/src/main/kotlin/com/tokilabs/toki_socket/HeartbeatTimer.kt`
|
|
|
|
```kotlin
|
|
class HeartbeatTimer(
|
|
private val scope: CoroutineScope,
|
|
private val delayMs: Long,
|
|
private val callback: suspend () -> Unit
|
|
) {
|
|
private var job: Job? = null
|
|
|
|
fun reset(delayMs: Long = this.delayMs) {
|
|
job?.cancel()
|
|
job = scope.launch {
|
|
delay(delayMs)
|
|
callback()
|
|
}
|
|
}
|
|
|
|
fun stop() {
|
|
job?.cancel()
|
|
job = null
|
|
}
|
|
}
|
|
```
|
|
|
|
`scope`는 `BaseClient`에서 생성된 `CoroutineScope(SupervisorJob() + Dispatchers.IO)`를 전달한다.
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `HeartbeatTimer.kt` 생성
|
|
- [ ] `reset(delayMs)`: 기존 job cancel 후 새 delay job launch
|
|
- [ ] `stop()`: job cancel
|
|
|
|
#### 테스트 작성
|
|
|
|
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTimerTest.kt`
|
|
|
|
| 테스트명 | 검증 목표 |
|
|
|---------|---------|
|
|
| `testCallbackFires` | delay 후 callback 호출됨 |
|
|
| `testStopPreventsCallback` | stop() 후 callback 미호출 |
|
|
| `testResetRestartsTimer` | reset() 후 이전 callback 미호출, 새 delay 후 호출 |
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
cd kotlin
|
|
./gradlew test --tests "*.HeartbeatTimerTest"
|
|
# 예상: 3개 테스트 PASS
|
|
```
|
|
|
|
---
|
|
|
|
### [API-4] BaseClient 구현
|
|
|
|
#### 문제
|
|
|
|
heartbeat 로직, disconnect 리스너, connCloseOnce(close-once) 공통 구조가 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
Go의 `baseClient[Self]`를 `abstract class BaseClient<Self : BaseClient<Self>>`로 이식한다.
|
|
|
|
**파일:** `kotlin/src/main/kotlin/com/tokilabs/toki_socket/BaseClient.kt`
|
|
|
|
**핵심 매핑:**
|
|
|
|
| Go | Kotlin |
|
|
|----|--------|
|
|
| `self Self` | 생성자 파라미터로 `self: Self` 수신 |
|
|
| `connCloseOnce sync.Once` | `AtomicBoolean` + `compareAndSet(false, true)` |
|
|
| `hbMu sync.Mutex` | `Mutex` (kotlinx.coroutines) |
|
|
| `disconnectListeners` | `CopyOnWriteArrayList<(Self) -> Unit>` |
|
|
| `doClose func() error` | 생성자 람다 `doClose: suspend () -> Unit` |
|
|
|
|
```kotlin
|
|
abstract class BaseClient<Self : BaseClient<Self>>(
|
|
private val self: Self,
|
|
intervalSec: Int,
|
|
waitSec: Int,
|
|
private val doClose: suspend () -> Unit
|
|
) {
|
|
protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
abstract val communicator: Communicator
|
|
|
|
private val heartbeatIntervalMs = intervalSec * 1000L
|
|
private val heartbeatWaitMs = waitSec * 1000L
|
|
private val closedOnce = AtomicBoolean(false)
|
|
private val hbMutex = Mutex()
|
|
private var hbTimer: HeartbeatTimer? = null
|
|
private var waitingHBResponse = false
|
|
private val disconnectListeners = CopyOnWriteArrayList<(Self) -> Unit>()
|
|
|
|
fun addDisconnectListener(handler: (Self) -> Unit) { disconnectListeners.add(handler) }
|
|
fun removeDisconnectListeners() { disconnectListeners.clear() }
|
|
|
|
suspend fun close() {
|
|
if (!closedOnce.compareAndSet(false, true)) return
|
|
communicator.shutdown()
|
|
stopHeartbeat()
|
|
doClose()
|
|
notifyDisconnected()
|
|
scope.cancel()
|
|
}
|
|
|
|
// sendHeartBeat, onHeartBeat, stopHeartbeat, onDisconnected, notifyDisconnected
|
|
// Go 로직과 동일하게 구현
|
|
}
|
|
```
|
|
|
|
`close()`는 `connCloseOnce.compareAndSet(false, true)` 패턴으로 멱등성 보장. Go의 `sync.Once`와 동일 의미.
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `BaseClient.kt` 생성
|
|
- [ ] `scope`: `CoroutineScope(SupervisorJob() + Dispatchers.IO)`
|
|
- [ ] `close()`: compareAndSet + communicator.shutdown() + stopHeartbeat() + doClose() + notifyDisconnected() + scope.cancel()
|
|
- [ ] `sendHeartBeat()`: hbMutex 잠금, 이전 타이머 정지, 새 interval 타이머 → HeartBeat 전송 → waitingHBResponse=true → wait 타이머 → onDisconnected
|
|
- [ ] `onHeartBeat()`: waitingHBResponse 분기 처리 (Go 로직 그대로)
|
|
- [ ] `stopHeartbeat()`: hbTimer?.stop()
|
|
- [ ] `onDisconnected()`: close() 호출
|
|
- [ ] `notifyDisconnected()`: listener 복사 후 순회 호출
|
|
|
|
#### 테스트 작성
|
|
|
|
SKIP — heartbeat 통합 동작은 API-7의 HeartbeatTest에서 TcpClient를 통해 검증한다. BaseClient 자체는 추상 클래스여서 단독 단위 테스트가 어렵다.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
cd kotlin
|
|
./gradlew compileKotlin
|
|
# 예상: BUILD SUCCESSFUL (API-5 구현 전이므로 컴파일만 확인)
|
|
```
|
|
|
|
---
|
|
|
|
### [API-5] TcpClient / TcpServer 구현
|
|
|
|
#### 문제
|
|
|
|
Kotlin에 TCP transport가 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
Go의 `TcpClient`, `TcpServer`를 `java.net.Socket` / `ServerSocket` + coroutine으로 이식한다.
|
|
|
|
**파일:**
|
|
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpClient.kt`
|
|
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpServer.kt`
|
|
|
|
**TcpClient:**
|
|
|
|
```kotlin
|
|
class TcpClient private constructor(
|
|
private val socket: Socket,
|
|
intervalSec: Int,
|
|
waitSec: Int,
|
|
) : BaseClient<TcpClient>(
|
|
self = /* 순환 참조 해결: lateinit + apply */ ...,
|
|
intervalSec = intervalSec,
|
|
waitSec = waitSec,
|
|
doClose = { socket.close() }
|
|
) {
|
|
override val communicator: Communicator = Communicator(this, parserMap, scope)
|
|
private val writeMutex = Mutex()
|
|
|
|
// TCP Transport 구현
|
|
suspend fun writePacket(base: PacketBase) {
|
|
val bytes = base.toByteArray()
|
|
val header = ByteBuffer.allocate(4).putInt(bytes.size).array()
|
|
writeMutex.withLock {
|
|
withContext(Dispatchers.IO) {
|
|
socket.getOutputStream().write(header)
|
|
socket.getOutputStream().write(bytes)
|
|
}
|
|
}
|
|
}
|
|
fun closeTransport() { socket.close() }
|
|
|
|
private fun readLoop() {
|
|
scope.launch(Dispatchers.IO) {
|
|
val input = socket.getInputStream()
|
|
val header = ByteArray(4)
|
|
while (communicator.isAlive()) {
|
|
try {
|
|
input.readFully(header)
|
|
val length = ByteBuffer.wrap(header).int
|
|
if (length == 0) continue
|
|
if (length > MAX_PACKET_SIZE) { onDisconnected(); return@launch }
|
|
val bytes = ByteArray(length)
|
|
input.readFully(bytes)
|
|
val base = PacketBase.parseFrom(bytes)
|
|
communicator.onReceivedData(base.typeName, base.data.toByteArray(), base.nonce, base.responseNonce)
|
|
sendHeartBeat()
|
|
} catch (e: Exception) {
|
|
onDisconnected(); return@launch
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`InputStream.readFully`는 `java.io.DataInputStream` wrapping으로 구현 (`readFully` extension 함수 정의).
|
|
|
|
**TcpServer:**
|
|
|
|
Go의 `TcpServer`와 동일 구조. `ServerSocket.accept()`를 `Dispatchers.IO`에서 loop.
|
|
|
|
```kotlin
|
|
class TcpServer(
|
|
private val host: String,
|
|
private val port: Int,
|
|
private val newClient: (Socket) -> TcpClient,
|
|
) {
|
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
private val clients = CopyOnWriteArrayList<TcpClient>()
|
|
private var serverSocket: ServerSocket? = null
|
|
var onClientConnected: (TcpClient) -> Unit = {}
|
|
|
|
fun start() { ... }
|
|
fun stop() { ... }
|
|
fun broadcast(m: MessageLite) { ... }
|
|
fun clients(): List<TcpClient> { ... }
|
|
}
|
|
```
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `TcpClient.kt` 생성
|
|
- [ ] `companion object { const val MAX_PACKET_SIZE = 64 * 1024 * 1024 }`
|
|
- [ ] `Transport` 구현: `writePacket` (4-byte BE header + proto bytes), `close`
|
|
- [ ] `readLoop()`: header 4바이트 읽기 → length=0 skip → length>MAX reject → bytes 읽기 → PacketBase.parseFrom → onReceivedData → sendHeartBeat
|
|
- [ ] `DialTcp(host, port, intervalSec, waitSec, parserMap)` 팩토리 함수
|
|
- [ ] HeartBeat 리스너 등록 (`communicator.addListener(HeartBeat typeName, ::onHeartBeat)`)
|
|
- [ ] WriteErrorHandler 등록 (`communicator.setWriteErrorHandler { onDisconnected() }`)
|
|
- [ ] `TcpServer.kt` 생성
|
|
- [ ] `start()`: ServerSocket bind → accept loop (Dispatchers.IO)
|
|
- [ ] `stop()`: serverSocket.close() → clients 복사 후 전부 close
|
|
- [ ] `broadcast(m)`: clients 순회하여 send
|
|
- [ ] 클라이언트 disconnect 시 clients 리스트에서 제거 (addDisconnectListener 활용)
|
|
|
|
#### 테스트 작성
|
|
|
|
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt`
|
|
|
|
| 테스트명 | 검증 목표 |
|
|
|---------|---------|
|
|
| `testTcpSendReceive` | client.send → server addListener 수신 |
|
|
| `testTcpRequestResponse` | sendRequestTyped → index*2, "echo: msg" |
|
|
| `testTcpBroadcast` | server.broadcast → 연결된 클라이언트 수신 |
|
|
| `testTcpServerStopDisconnectsClients` | server.stop() → client disconnect 콜백 |
|
|
| `testTcpClientCloseIdempotent` | close() 3회 호출 시 오류 없음 |
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
cd kotlin
|
|
./gradlew test --tests "*.TcpTest"
|
|
# 예상: 5개 테스트 PASS
|
|
```
|
|
|
|
---
|
|
|
|
### [API-6] WsClient / WsServer 구현
|
|
|
|
#### 문제
|
|
|
|
Kotlin에 WebSocket transport가 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
WebSocket 클라이언트는 OkHttp(`okhttp3.WebSocket`), 서버는 `org.java-websocket:Java-WebSocket`(`WebSocketServer`)을 사용한다. Android 호환을 위해 서버 쪽 `java-websocket`은 JVM 테스트/서버 전용으로 명시한다.
|
|
|
|
**파일:**
|
|
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsClient.kt`
|
|
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt`
|
|
|
|
**WsClient (OkHttp 기반):**
|
|
|
|
```kotlin
|
|
class WsClient private constructor(
|
|
private val ws: okhttp3.WebSocket,
|
|
private val closeWs: () -> Unit,
|
|
intervalSec: Int,
|
|
waitSec: Int,
|
|
parserMap: ParserMap,
|
|
) : BaseClient<WsClient>(...) {
|
|
override val communicator = Communicator(this /* as Transport */, parserMap, scope)
|
|
|
|
// Transport 구현
|
|
fun writePacket(base: PacketBase) {
|
|
val bytes = base.toByteArray()
|
|
ws.send(ByteString.of(*bytes)) // OkHttp binary frame
|
|
}
|
|
|
|
// OkHttp WebSocketListener (onMessage에서 onReceivedData 호출)
|
|
}
|
|
```
|
|
|
|
OkHttp `WebSocket.send(ByteString)`은 thread-safe하므로 별도 writeMutex 불필요.
|
|
|
|
**WsServer (Java-WebSocket 기반):**
|
|
|
|
```kotlin
|
|
class WsServer(host: String, port: Int) : org.java_websocket.server.WebSocketServer(...) {
|
|
val clients = CopyOnWriteArrayList<WsClient>()
|
|
var onClientConnected: (WsClient) -> Unit = {}
|
|
|
|
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) { ... }
|
|
override fun onMessage(conn: WebSocket, bytes: ByteBuffer) { ... }
|
|
override fun onClose(...) { ... }
|
|
override fun onError(...) { ... }
|
|
}
|
|
```
|
|
|
|
`java-websocket`의 `onMessage(conn, ByteBuffer)`로 binary frame을 수신하여 `PacketBase.parseFrom(bytes)` 후 해당 WsClient의 `communicator.onReceivedData(...)` 호출.
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `WsClient.kt` 생성
|
|
- [ ] OkHttpClient + `Request.Builder().url(ws://...)` + `newWebSocket(request, listener)`
|
|
- [ ] `WebSocketListener.onMessage(ws, bytes: ByteString)`: PacketBase.parseFrom → onReceivedData → sendHeartBeat
|
|
- [ ] `WebSocketListener.onFailure(ws, t, response)`: onDisconnected
|
|
- [ ] `Transport.writePacket`: `ws.send(ByteString.of(*bytes))`
|
|
- [ ] `Transport.close`: `ws.close(1000, null)` + OkHttpClient.dispatcher.executorService.shutdown()
|
|
- [ ] HeartBeat 리스너 등록, WriteErrorHandler 등록
|
|
- [ ] `DialWs(host, port, path, intervalSec, waitSec, parserMap)` 팩토리
|
|
- [ ] `DialWss(host, port, path, sslContext, intervalSec, waitSec, parserMap)` 팩토리
|
|
- [ ] `WsServer.kt` 생성
|
|
- [ ] `WebSocketServer` 상속, binary 프레임 처리
|
|
- [ ] WsClient 인스턴스 생성 및 clients 리스트 관리
|
|
- [ ] `start()` / `stop()` / `broadcast(m)`
|
|
- [ ] 클라이언트 disconnect 시 clients 리스트에서 제거
|
|
|
|
#### 테스트 작성
|
|
|
|
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt`
|
|
|
|
| 테스트명 | 검증 목표 |
|
|
|---------|---------|
|
|
| `testWsSendReceive` | client.send → server addListener 수신 |
|
|
| `testWsRequestResponse` | sendRequestTyped → index*2, "echo: msg" |
|
|
| `testWsBroadcast` | server.broadcast → 연결된 클라이언트 수신 |
|
|
| `testWsServerStopDisconnectsClients` | server.stop() → client disconnect 콜백 |
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
cd kotlin
|
|
./gradlew test --tests "*.WsTest"
|
|
# 예상: 4개 테스트 PASS
|
|
```
|
|
|
|
---
|
|
|
|
### [API-7] Heartbeat 통합 테스트
|
|
|
|
#### 문제
|
|
|
|
heartbeat 타이머가 비활성 구간 후 올바르게 발송되고, 응답 없을 때 disconnect를 트리거하는지 검증되지 않았다.
|
|
|
|
#### 해결 방법
|
|
|
|
Go의 `heartbeat_test.go` 패턴을 Kotlin coroutine 테스트로 이식한다. `runTest` + `TestCoroutineScheduler`로 실제 시간 대기 없이 가상 시간 진행.
|
|
|
|
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt`
|
|
|
|
| 테스트명 | 검증 목표 |
|
|
|---------|---------|
|
|
| `testHeartbeatSentAfterInactivity` | intervalSec 경과 → HeartBeat 패킷 전송 확인 |
|
|
| `testHeartbeatDisconnectOnNoResponse` | waitSec 경과 → onDisconnected 호출 확인 |
|
|
| `testHeartbeatResetOnReceive` | 메시지 수신 → heartbeat 타이머 리셋 확인 |
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `HeartbeatTest.kt` 생성 (위 3개 테스트)
|
|
|
|
#### 테스트 작성
|
|
|
|
본 항목 자체가 테스트 작성이다.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
cd kotlin
|
|
./gradlew test --tests "*.HeartbeatTest"
|
|
# 예상: 3개 테스트 PASS
|
|
```
|
|
|
|
---
|
|
|
|
### [API-8] Go ↔ Kotlin 크로스테스트
|
|
|
|
#### 문제
|
|
|
|
다른 언어 구현체와의 wire format 호환성이 검증되지 않았다.
|
|
|
|
#### 해결 방법
|
|
|
|
`skills/add-toki-socket-crosstest-language/SKILL.md`의 runner 배치 규칙을 따른다.
|
|
|
|
**추가 파일:**
|
|
|
|
| 파일 | 역할 |
|
|
|------|------|
|
|
| `go/crosstest/go_kotlin.go` | Go 서버 오케스트레이터 (Kotlin subprocess 실행) |
|
|
| `kotlin/crosstest/go_kotlin_client/Main.kt` | Kotlin subprocess (Go 서버에 접속) |
|
|
| `kotlin/crosstest/kotlin_go.kt` | Kotlin 서버 오케스트레이터 (Go subprocess 실행) |
|
|
| `go/crosstest/kotlin_go_client/main.go` | Go subprocess (Kotlin 서버에 접속) |
|
|
|
|
**포트 배정 (기존과 충돌 없음):**
|
|
|
|
```
|
|
Go server / Kotlin client TCP: 29290
|
|
Go server / Kotlin client WS: 29292
|
|
Kotlin server / Go client TCP: 29390
|
|
Kotlin server / Go client WS: 29392
|
|
```
|
|
|
|
기존 포트 (충돌 없음 확인):
|
|
- 29090 (Dart server / Go client TCP)
|
|
- 29092 (Dart server / Go client WS)
|
|
- 29190 (Go server / Dart client TCP)
|
|
- 29192 (Go server / Dart client WS)
|
|
|
|
**시나리오 (TCP + WebSocket 각각):**
|
|
|
|
| 시나리오 | 내용 |
|
|
|---------|------|
|
|
| 1 | 클라이언트 fire-and-forget `TestData(index=101, message="fire from kotlin client")` → 서버 검증 |
|
|
| 2 | 서버 push `TestData(index=200, message="push from go server")` → 클라이언트 검증 |
|
|
| 3 | 클라이언트 `sendRequest` → 응답 `index=req.index*2`, `message="echo: req.message"` |
|
|
| 4 | 동시 5개 `sendRequest` → responseNonce 라우팅 검증 |
|
|
|
|
send-push/requests 페이즈 분리 패턴은 Go ↔ Dart 크로스테스트와 동일하게 적용한다.
|
|
|
|
**`kotlin/crosstest/go_kotlin_client/Main.kt` 실행 방법:**
|
|
|
|
```bash
|
|
cd kotlin
|
|
./gradlew run --args="--mode=tcp --port=29290 --phase=send-push"
|
|
```
|
|
|
|
`build.gradle.kts`에 `application { mainClass.set("com.tokilabs.toki_socket.crosstest.MainKt") }` 추가 필요.
|
|
|
|
**`go/crosstest/go_kotlin.go` 실행 방법:**
|
|
|
|
```bash
|
|
cd go
|
|
go run ./crosstest/go_kotlin.go
|
|
```
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `go/crosstest/go_kotlin.go` 생성
|
|
- [ ] TCP send-push (port 29290), TCP requests, WS send-push (port 29292), WS requests
|
|
- [ ] Kotlin subprocess 실행: `./gradlew run --args="..."` (kotlin dir 기준)
|
|
- [ ] `validateResultLines` 활용 (go_dart.go와 동일 헬퍼)
|
|
- [ ] `kotlin/crosstest/go_kotlin_client/Main.kt` 생성
|
|
- [ ] `INFO typeName kotlin=TestData` 출력
|
|
- [ ] `--mode`, `--port`, `--phase` 인수 파싱
|
|
- [ ] send-push: TestData 전송 (시나리오 1) + 서버 push 수신 (시나리오 2)
|
|
- [ ] requests: 단일 request (시나리오 3) + 5개 concurrent request (시나리오 4)
|
|
- [ ] `PASS scenario=N detail=...` / `FAIL scenario=N error=...` 출력
|
|
- [ ] `kotlin/crosstest/kotlin_go.kt` 생성 (Kotlin 서버 오케스트레이터)
|
|
- [ ] TCP 서버 (29390) + WS 서버 (29392) 시작
|
|
- [ ] Go subprocess 실행: `go run ./crosstest/kotlin_go_client`
|
|
- [ ] PASS/FAIL 파싱 및 검증
|
|
- [ ] `go/crosstest/kotlin_go_client/main.go` 생성
|
|
- [ ] Kotlin 서버에 접속하는 Go 클라이언트 (dart_go_client/main.go와 동일 구조)
|
|
- [ ] `INFO typeName go=TestData` 출력
|
|
- [ ] 동일 4개 시나리오
|
|
|
|
#### 테스트 작성
|
|
|
|
본 항목 자체가 크로스테스트이다.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
# Go 서버 ↔ Kotlin 클라이언트
|
|
cd go && go run ./crosstest/go_kotlin.go
|
|
# 예상: PASS all go-server/kotlin-client crosstests passed
|
|
|
|
# Kotlin 서버 ↔ Go 클라이언트
|
|
cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt
|
|
# 또는
|
|
cd kotlin && kotlinc -script crosstest/kotlin_go.kts
|
|
# 예상: PASS all kotlin-server/go-client crosstests passed
|
|
```
|
|
|
|
---
|
|
|
|
## 수정 파일 요약
|
|
|
|
| 파일 | 항목 |
|
|
|------|------|
|
|
| `kotlin/settings.gradle.kts` | API-1 |
|
|
| `kotlin/build.gradle.kts` | API-1 |
|
|
| `kotlin/src/main/proto/message_common.proto` | API-1 |
|
|
| `kotlin/src/main/kotlin/.../Communicator.kt` | API-2 |
|
|
| `kotlin/src/test/kotlin/.../CommunicatorTest.kt` | API-2 |
|
|
| `kotlin/src/main/kotlin/.../HeartbeatTimer.kt` | API-3 |
|
|
| `kotlin/src/test/kotlin/.../HeartbeatTimerTest.kt` | API-3 |
|
|
| `kotlin/src/main/kotlin/.../BaseClient.kt` | API-4 |
|
|
| `kotlin/src/main/kotlin/.../TcpClient.kt` | API-5 |
|
|
| `kotlin/src/main/kotlin/.../TcpServer.kt` | API-5 |
|
|
| `kotlin/src/test/kotlin/.../TcpTest.kt` | API-5 |
|
|
| `kotlin/src/main/kotlin/.../WsClient.kt` | API-6 |
|
|
| `kotlin/src/main/kotlin/.../WsServer.kt` | API-6 |
|
|
| `kotlin/src/test/kotlin/.../WsTest.kt` | API-6 |
|
|
| `kotlin/src/test/kotlin/.../HeartbeatTest.kt` | API-7 |
|
|
| `go/crosstest/go_kotlin.go` | API-8 |
|
|
| `kotlin/crosstest/go_kotlin_client/Main.kt` | API-8 |
|
|
| `kotlin/crosstest/kotlin_go.kt` | API-8 |
|
|
| `go/crosstest/kotlin_go_client/main.go` | API-8 |
|
|
| `PROTOCOL.md` | Kotlin 행 status 업데이트 |
|
|
| `README.md` | Kotlin 행 status 업데이트 |
|
|
|
|
---
|
|
|
|
## 최종 검증
|
|
|
|
```bash
|
|
# 1. Kotlin 단위 테스트 전체
|
|
cd kotlin
|
|
./gradlew test
|
|
# 예상: 모든 테스트 PASS, BUILD SUCCESSFUL
|
|
|
|
# 2. Go 단위 테스트 (크로스테스트 헬퍼 추가로 인한 회귀 없음)
|
|
cd go
|
|
go test ./...
|
|
# 예상: ok toki-labs.com/toki_socket/go [no test files changed]
|
|
|
|
# 3. Kotlin linter
|
|
cd kotlin
|
|
./gradlew ktlintCheck # ktlint plugin 추가 시
|
|
# 예상: BUILD SUCCESSFUL
|
|
|
|
# 4. Go 서버 ↔ Kotlin 클라이언트 크로스테스트
|
|
cd go
|
|
go run ./crosstest/go_kotlin.go
|
|
# 예상: PASS all go-server/kotlin-client crosstests passed
|
|
|
|
# 5. Kotlin 서버 ↔ Go 클라이언트 크로스테스트
|
|
cd kotlin
|
|
./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt
|
|
# 예상: PASS all kotlin-server/go-client crosstests passed
|
|
|
|
# 6. proto 동기화 검증
|
|
tools/check_proto_sync.sh
|
|
# 예상: no diff
|
|
```
|