169 lines
11 KiB
Markdown
169 lines
11 KiB
Markdown
# 언어별 구현 포팅 가이드
|
|
|
|
## 공통 원칙
|
|
|
|
- **레퍼런스 구현체: `go/`, `dart/`**
|
|
- 동시성·close-once 구조는 Go 구현체를 우선 참고하고, Dart 구현체는 public API와 parser map 사용 예시를 함께 참고한다
|
|
- 프로토콜 정의는 `PROTOCOL.md`가 단일 기준이다
|
|
- 각 언어의 관용적 패턴을 따르되, 아래 핵심 구조는 반드시 유지한다
|
|
|
|
### 반드시 유지해야 하는 구조
|
|
|
|
| 요소 | Go 기준 | 설명 |
|
|
|------|---------|------|
|
|
| Transport 분리 | `Transport` interface | WritePacket과 Close만 있으면 됨. 소켓 종류(TCP/WS)에 무관하게 Communicator가 동작 |
|
|
| typeName 라우팅 | `TypeNameOf()` | protobuf 메시지 이름 기반. 언어마다 추출 방식이 다르므로 PROTOCOL.md의 언어별 표 확인 |
|
|
| nonce 단조 증가 | `atomic.Int32` | 연결당 1에서 시작, 송신마다 +1. thread-safe하게 구현 |
|
|
| connCloseOnce | `sync.Once` | Close가 여러 번 불려도 한 번만 수행되어야 함 |
|
|
| addListener / addRequestListener 상호 배타 | panic or throw | 같은 typeName에 두 가지 동시 등록 금지. 프로토콜 계약 |
|
|
| heartbeat 자동 처리 | `baseClient` 내부 | 앱 코드에서 HeartBeat를 직접 등록하거나 처리하지 않음 |
|
|
|
|
---
|
|
|
|
## 새 언어 추가 절차
|
|
|
|
1. 새 언어 디렉터리를 만들기 전에 `agent-ops/skills/project/add-toki-socket-crosstest-language/templates/`의 문서를 복사해 패키지 README와 체크리스트 초안으로 사용한다.
|
|
2. `PROTOCOL.md`와 `VERSIONING.md`의 현재 protocol version을 기준으로 지원 범위를 정한다.
|
|
3. canonical proto인 `dart/lib/src/packets/message_common.proto`에서 언어별 protobuf binding을 생성한다. Go처럼 generator 전용 option이 필요하면 언어 패키지 안에 copy를 둘 수 있지만, message schema는 `tools/check_proto_sync.sh`로 검증 가능해야 한다.
|
|
4. `Transport`와 `Communicator`를 먼저 구현하고, 그 위에 TCP/TLS+TCP/WS/WSS client/server를 올린다.
|
|
5. 같은 언어 단위 테스트를 작성한 뒤 `agent-ops/skills/project/add-toki-socket-crosstest-language/SKILL.md`의 runner 배치 규칙에 맞춰 양방향 crosstest를 추가한다.
|
|
6. README의 Implementations 표를 `Available`로 바꾸기 전에 formatter/linter, 단위 테스트, cross-language tests를 모두 통과시킨다.
|
|
|
|
템플릿은 시작점과 완료 조건만 고정한다. 실제 소스 구조, 패키지 매니저, async runtime은 각 언어의 관용적 선택을 따른다.
|
|
|
|
---
|
|
|
|
## C# (Unity / .NET)
|
|
|
|
### 핵심 매핑
|
|
|
|
| Go | C# |
|
|
|----|----|
|
|
| `Transport` interface | `ITransport` interface |
|
|
| `baseClient[Self]` | `BaseClient<TSelf>` generic class |
|
|
| `ParserMap` | `Dictionary<string, Func<byte[], IMessage>>` |
|
|
| goroutine | `Task` + `CancellationToken` |
|
|
| `context.Context` | `CancellationToken` |
|
|
| `sync.Once` | `Interlocked` 또는 `SemaphoreSlim(1,1)` |
|
|
| `atomic.Bool` | `volatile bool` 또는 `Interlocked.Exchange` |
|
|
| channel (writeQueue) | `Channel<T>` (`System.Threading.Channels`) |
|
|
|
|
### 주의사항
|
|
|
|
- **Unity 환경**: `System.Threading.Channels`가 Unity 버전에 따라 미지원일 수 있다. 대안으로 `ConcurrentQueue<T>` + ManualResetEvent 조합을 사용한다
|
|
- **메인 스레드 제약**: Unity는 UnityEngine API를 메인 스레드에서만 호출할 수 있다. 수신 콜백을 메인 스레드로 dispatch하는 래퍼(`SynchronizationContext`)가 필요하다. Communicator 코어는 스레드 무관하게 유지하고, 래퍼 레이어에서 처리한다
|
|
- **protobuf**: `Google.Protobuf` NuGet 패키지 사용. `typeof(T).FullName`이 proto qualified name과 일치하는지 반드시 검증한다. PROTOCOL.md의 typeName 표 참고
|
|
- **제네릭 헬퍼**: `AddListenerTyped<T>`, `SendRequestTyped<TReq, TRes>` 패턴은 C# 제네릭으로 그대로 구현 가능하다
|
|
- **`doClose` 패턴**: C#의 람다 캡처 동작이 Go와 동일하므로 그대로 이식 가능하다
|
|
|
|
---
|
|
|
|
## Kotlin (Android)
|
|
|
|
### 핵심 매핑
|
|
|
|
| Go | Kotlin |
|
|
|----|--------|
|
|
| `Transport` interface | `interface Transport` |
|
|
| `baseClient[Self]` | `abstract class BaseClient<Self>` 또는 generic open class |
|
|
| goroutine | `CoroutineScope` + `launch` |
|
|
| `context.Context` | `CoroutineContext` + `Job` |
|
|
| channel (writeQueue) | `Channel<T>` (kotlinx.coroutines) |
|
|
| `sync.Once` | `AtomicBoolean` + `compareAndSet` |
|
|
| `atomic.Bool` | `AtomicBoolean` |
|
|
| `sync.RWMutex` | `ReentrantReadWriteLock` 또는 `Mutex` (coroutines) |
|
|
|
|
### 주의사항
|
|
|
|
- **Coroutine Scope 관리**: 클라이언트 생성 시 `CoroutineScope(SupervisorJob() + Dispatchers.IO)`를 만들고, `Close()` 시 `scope.cancel()`로 정리한다. SupervisorJob을 사용해야 하위 Job 하나 실패가 전체를 취소하지 않는다
|
|
- **protobuf**: `com.google.protobuf:protobuf-kotlin` 사용. typeName은 `descriptor.fullName`으로 추출. PROTOCOL.md 표에서 Kotlin 행 확인
|
|
- **Android 메인 스레드**: Unity와 동일하게 UI 콜백은 `withContext(Dispatchers.Main)`으로 전환한다. Communicator 코어는 IO Dispatcher에서 동작
|
|
- **직렬화된 쓰기**: Go의 writeQueue(buffered channel 64) 패턴을 `Channel(capacity = 64)` + 별도 write coroutine으로 구현한다
|
|
- **`Self` 타입 전달**: Kotlin 제네릭의 타입 소거(type erasure) 때문에 런타임에 `Self` 인스턴스를 직접 넘겨야 한다. Go의 `self Self` 필드 패턴을 그대로 사용한다
|
|
|
|
---
|
|
|
|
## Swift (iOS / macOS)
|
|
|
|
### 핵심 매핑
|
|
|
|
| Go | Swift |
|
|
|----|-------|
|
|
| `Transport` interface | `protocol Transport` |
|
|
| `baseClient[Self]` | `class BaseClient<Self>` 또는 `protocol` + associated type |
|
|
| goroutine | `Task` (Swift Concurrency) |
|
|
| `context.Context` | `Task` 취소 (cooperative cancellation) |
|
|
| channel (writeQueue) | `AsyncStream` 또는 `actor` 기반 queue |
|
|
| `sync.Once` | `DispatchOnce` 또는 `actor` 격리 |
|
|
| `atomic.Bool` | `OSAllocatedUnfairLock` 또는 `actor` |
|
|
|
|
### 주의사항
|
|
|
|
- **Actor 활용**: Swift 5.5+ actor를 사용하면 mutex/lock 없이 상태 보호가 가능하다. `Communicator`를 actor로 구현하면 `sync.RWMutex` 없이 동일한 안전성을 확보할 수 있다
|
|
- **protobuf**: SwiftProtobuf 패키지 사용. typeName은 `Message.protoMessageName`으로 추출. PROTOCOL.md 표에서 Swift 행 확인
|
|
- **`connCloseOnce` 패턴**: Swift에는 `DispatchOnce`가 deprecated됐다. actor의 상태 변수(`var isClosed = false`) + actor 격리로 대체한다
|
|
- **WebSocket**: `URLSessionWebSocketTask` (iOS 13+) 또는 `Network.framework`의 `NWConnection` 사용
|
|
- **메모리 관리**: 클로저에서 `[weak self]` 캡처를 빠뜨리면 retain cycle이 발생한다. `doClose`, heartbeat 타이머 클로저 모두 `[weak self]`를 사용한다
|
|
- **`Self` 제약**: Swift에서 제네릭 `Self`는 프로토콜 associated type으로 처리하거나, 구체 타입을 생성자에서 전달하는 Go의 `self Self` 필드 패턴을 그대로 사용한다
|
|
|
|
---
|
|
|
|
## Python
|
|
|
|
### 핵심 매핑
|
|
|
|
| Go | Python |
|
|
|----|--------|
|
|
| `Transport` interface | `Protocol` (typing) 또는 ABC |
|
|
| `baseClient[Self]` | `Generic[Self]` + `TypeVar` |
|
|
| goroutine | `asyncio.Task` |
|
|
| `context.Context` | `asyncio.Event` 또는 `CancelScope` (anyio) |
|
|
| channel (writeQueue) | `asyncio.Queue(maxsize=64)` |
|
|
| `sync.Once` | `asyncio.Lock` + bool flag |
|
|
| `atomic.Bool` | `asyncio` 단일 스레드 내에서는 plain bool 가능 |
|
|
|
|
### 주의사항
|
|
|
|
- **asyncio 기반**: `async/await` + `asyncio.Queue`로 Go의 goroutine + channel 구조를 근사한다. 동기 API는 제공하지 않는다
|
|
- **타입 안전성 한계**: Python에는 런타임 제네릭이 없다. `AddListenerTyped`처럼 완전한 타입 안전 헬퍼 구현이 어렵다. `TypeVar`와 `@overload`로 가능한 범위까지만 표현하고, 나머지는 docstring으로 보완한다
|
|
- **protobuf**: `protobuf` 패키지 사용. typeName은 `message.DESCRIPTOR.name`으로 추출. PROTOCOL.md 표에서 Python 행 확인
|
|
- **스레드 안전**: asyncio는 단일 스레드이므로 대부분의 lock이 불필요하다. 단, 멀티스레드 환경에서 쓸 경우 `asyncio.Lock`을 사용한다
|
|
- **`connCloseOnce` 패턴**: bool flag + asyncio.Lock으로 구현한다. `asyncio.Event`의 `set()`은 멱등하므로 활용 가능하다
|
|
- **에러 처리**: Go의 명시적 error 반환 대신 exception을 사용하되, public API에서는 구체 exception 타입을 정의해서 문서화한다
|
|
|
|
---
|
|
|
|
## TypeScript
|
|
|
|
### 핵심 매핑
|
|
|
|
| Go | TypeScript |
|
|
|----|------------|
|
|
| `Transport` interface | `interface Transport` |
|
|
| `baseClient[Self]` | `abstract class BaseClient<T>` |
|
|
| goroutine | `Promise` + `async/await` |
|
|
| `context.Context` | `AbortSignal` (`AbortController`) |
|
|
| channel (writeQueue) | Async Queue (배열 기반 큐 또는 async iterator) |
|
|
| `sync.Once` | `boolean` 플래그 (단일 스레드이므로 충분) |
|
|
| `atomic.Bool` | `boolean` |
|
|
|
|
### 주의사항
|
|
|
|
- **크로스 환경 (Browser & Node.js)**: 코어 로직(`Communicator`, `BaseClient`)은 런타임 환경에 독립적으로 작성한다. 브라우저에서는 내장 `WebSocket`을, Node.js에서는 `net.Socket`(TCP)이나 `ws` 패키지를 사용하는 Transport 구현체를 주입받도록 설계한다
|
|
- **protobuf**: `@bufbuild/protobuf` (protobuf-es) 패키지 사용을 권장한다. `typeName`은 `MessageType.typeName`에서 추출한다. `PROTOCOL.md` 표에서 TypeScript 행을 확인한다
|
|
- **단일 스레드 (Event Loop)**: JS/TS는 단일 스레드 기반이므로 메모리 동시 접근에 대한 Mutex/Lock(`sync.RWMutex`)은 불필요하다. 단, 비동기 컨텍스트(await) 간의 논리적 상태 오염은 주의한다
|
|
- **바이너리 처리**: Node.js의 `Buffer` 대신 표준 웹 API인 `Uint8Array`를 기준으로 작성하여 브라우저 환경 호환성을 확보한다
|
|
- **`connCloseOnce` 패턴**: 단순 `isClosed: boolean` 플래그로 멱등성을 보장한다. 만약 `Close()` 내에 `await`가 포함될 경우 중복 실행(re-entrancy) 방지에 유의한다
|
|
- **제네릭 헬퍼**: TypeScript의 강력한 타입 시스템을 활용해 `addListener<T>`, `sendRequest<TReq, TRes>` 등을 완벽히 타입 안전한 API로 구현한다
|
|
|
|
---
|
|
|
|
## 공통 금지사항
|
|
|
|
다음은 Dart 구현체의 레거시 패턴이다. 새 구현체에 복제하지 않는다.
|
|
|
|
- 생성자·에러 핸들러에서 `print()` / `console.log()` 직접 호출 — 로거 인터페이스를 두거나 생략한다
|
|
- `isAlive`, `nonce`를 public mutable 필드로 노출 — 내부 상태는 캡슐화한다
|
|
- `onDisconnected`와 `dispose`를 별도 메서드로 분리 — Go처럼 `Close()` 하나로 통일한다
|
|
- heartbeat 타이머를 재귀 호출로 구현 — 루프 또는 단순 타이머 재설정으로 구현한다
|
|
- TCP와 WebSocket 클라이언트에 heartbeat·disconnect 로직 중복 — `baseClient` 패턴으로 공유한다
|