Initial commit: Dart implementation of toki-socket protocol
- Protobuf-based binary TCP socket library - ProtobufClient / ProtobufServer abstract classes with heartbeat - Type-based message routing via Communicator - 4-byte big-endian length-prefixed framing protocol - 8 integration tests (all passing) - PROTOCOL.md wire format specification - VSCode launch.json for test runner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
240239682c
17 changed files with 1057 additions and 0 deletions
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# ── Dart ──────────────────────────────────────────
|
||||
dart/.dart_tool/
|
||||
dart/.pub-cache/
|
||||
dart/build/
|
||||
dart/pubspec.lock
|
||||
dart/doc/
|
||||
|
||||
# Dart generated files (proto .pb.dart are intentionally kept)
|
||||
dart/**/*.g.dart
|
||||
|
||||
# ── C# / Unity (future) ───────────────────────────
|
||||
csharp/bin/
|
||||
csharp/obj/
|
||||
csharp/*.user
|
||||
csharp/.vs/
|
||||
*.csproj.user
|
||||
|
||||
# ── Kotlin / Android (future) ─────────────────────
|
||||
kotlin/.gradle/
|
||||
kotlin/build/
|
||||
kotlin/local.properties
|
||||
kotlin/.idea/
|
||||
|
||||
# ── Swift (future) ────────────────────────────────
|
||||
swift/.build/
|
||||
swift/*.xcodeproj/
|
||||
swift/*.xcworkspace/
|
||||
swift/Packages.resolved
|
||||
|
||||
# ── IDE ───────────────────────────────────────────
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/settings.json
|
||||
.vscode/tasks.json
|
||||
|
||||
# ── OS ────────────────────────────────────────────
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
121
PROTOCOL.md
Normal file
121
PROTOCOL.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Toki Socket Protocol
|
||||
|
||||
Binary TCP protocol using Protocol Buffers for message framing and type-based routing.
|
||||
Designed for bidirectional, heterogeneous communication across languages and platforms.
|
||||
|
||||
---
|
||||
|
||||
## Wire Format
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Header (4 bytes, big-endian int32) │ ← PacketBase payload length
|
||||
├──────────────────────────────────────────┤
|
||||
│ PacketBase (protobuf, N bytes) │ ← typeName + nonce + data
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Header
|
||||
- 4 bytes, big-endian signed int32
|
||||
- Value: byte length of the following `PacketBase` protobuf payload
|
||||
- Value of `0`: reserved / no-op, receiver clears buffer
|
||||
|
||||
### PacketBase
|
||||
```protobuf
|
||||
message PacketBase {
|
||||
string typeName = 1; // fully-qualified protobuf message name
|
||||
int32 nonce = 2; // monotonically increasing per-connection counter
|
||||
bytes data = 3; // serialized inner message bytes
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `typeName` | `GeneratedMessage.info_.qualifiedMessageName` — language-agnostic routing key |
|
||||
| `nonce` | Starts at 1, increments by 1 per `send()` call per connection |
|
||||
| `data` | Protobuf-serialized bytes of the inner message |
|
||||
|
||||
---
|
||||
|
||||
## Built-in Messages
|
||||
|
||||
### HeartBeat
|
||||
```protobuf
|
||||
message HeartBeat {}
|
||||
```
|
||||
|
||||
Automatically handled by the framework. Applications do not need to register or handle this manually.
|
||||
|
||||
---
|
||||
|
||||
## Heartbeat Lifecycle
|
||||
|
||||
```
|
||||
Side A Side B
|
||||
│ │
|
||||
│── (no activity for intervalTime) ──────▶│
|
||||
│ send(HeartBeat) │
|
||||
│ │── onHeartBeat() called
|
||||
│ │ send(HeartBeat) [response]
|
||||
│◀─────────────────────────────────────────│
|
||||
│ onHeartBeat() called │
|
||||
│ _waitingHeartbeatResponse = false │
|
||||
│ timer reset │
|
||||
```
|
||||
|
||||
- After any received message → heartbeat interval timer resets
|
||||
- If no data received within `heartbeatIntervalTime` seconds → send `HeartBeat`
|
||||
- If no response within `heartbeatWaitTime` seconds → `onDisconnected()` → `dispose()`
|
||||
- Only the initiating side responds to HeartBeat echo (ping-pong prevention via `_waitingHeartbeatResponse` flag)
|
||||
|
||||
---
|
||||
|
||||
## typeName Convention
|
||||
|
||||
Uses `GeneratedMessage.info_.qualifiedMessageName` on the send side.
|
||||
On the receive side, use the same value as the registration key.
|
||||
|
||||
| Language | Registration key |
|
||||
|----------|-----------------|
|
||||
| Dart | `T.toString()` (equals qualified name for top-level proto messages) |
|
||||
| C# | `typeof(T).Name` — verify matches proto qualified name |
|
||||
| Kotlin | `T::class.simpleName` — verify matches |
|
||||
| Swift | `String(describing: T.self)` — verify matches |
|
||||
|
||||
**Important**: Verify typeName consistency across languages before connecting heterogeneous clients.
|
||||
|
||||
---
|
||||
|
||||
## nonce
|
||||
|
||||
- Starts at `1` per connection
|
||||
- Increments by `1` on every `send()` call
|
||||
- Currently used as a message ID; request-response correlation can be built on top
|
||||
|
||||
---
|
||||
|
||||
## Example Packet (hex)
|
||||
|
||||
Sending `HeartBeat {}`:
|
||||
```
|
||||
00 00 00 05 ← header: PacketBase length = 5 bytes
|
||||
0A 09 48 65 61 72 74 42 65 61 74 ← PacketBase { typeName: "HeartBeat" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-language Implementations
|
||||
|
||||
| Language | Status | Path |
|
||||
|----------|--------|------|
|
||||
| Dart | Available | `dart/` |
|
||||
| C# (Unity) | Planned | `csharp/` |
|
||||
| Kotlin | Planned | `kotlin/` |
|
||||
| Swift | Planned | `swift/` |
|
||||
|
||||
---
|
||||
|
||||
## Proto Source
|
||||
|
||||
`dart/lib/src/packets/message_common.proto` is the canonical packet definition.
|
||||
All language implementations must generate bindings from this file.
|
||||
84
README.md
Normal file
84
README.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Toki Socket
|
||||
|
||||
Binary TCP socket protocol library for bidirectional, heterogeneous communication across languages and platforms.
|
||||
|
||||
Built on Protocol Buffers with a fixed 4-byte length-prefixed framing, type-based message routing, and built-in heartbeat.
|
||||
|
||||
---
|
||||
|
||||
## Protocol
|
||||
|
||||
See [PROTOCOL.md](PROTOCOL.md) for the full wire format specification.
|
||||
|
||||
```
|
||||
[4-byte big-endian length] [PacketBase protobuf bytes]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementations
|
||||
|
||||
| Language | Status | Path | Use case |
|
||||
|----------|--------|------|----------|
|
||||
| Dart | Available | [dart/](dart/) | Flutter, Dart server |
|
||||
| C# | Planned | `csharp/` | Unity, .NET |
|
||||
| Kotlin | Planned | `kotlin/` | Android |
|
||||
| Swift | Planned | `swift/` | iOS, macOS |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Dart)
|
||||
|
||||
```dart
|
||||
import 'package:toki_socket/toki_socket.dart';
|
||||
|
||||
// 1. Define your message in message_common.proto, generate with protoc
|
||||
|
||||
// 2. Implement a client
|
||||
class MyClient extends ProtobufClient {
|
||||
MyClient(Socket socket) : super(socket, 30, 10, {
|
||||
MyMessage.getDefault().info_.qualifiedMessageName: MyMessage.fromBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Implement a server
|
||||
class MyServer extends ProtobufServer {
|
||||
MyServer() : super('0.0.0.0', 9090, (socket) => MyClient(socket));
|
||||
|
||||
@override
|
||||
void onClientConnected(ProtobufClient client) {
|
||||
client.addListener<MyMessage>((msg) => print('Received: ${msg}'));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Start
|
||||
final server = MyServer();
|
||||
await server.start();
|
||||
|
||||
// 5. Connect and send
|
||||
final socket = await Socket.connect('localhost', 9090);
|
||||
final client = MyClient(socket);
|
||||
await client.send(MyMessage()..text = 'hello');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding Message Types
|
||||
|
||||
Edit `dart/lib/src/packets/message_common.proto` and regenerate:
|
||||
|
||||
```bash
|
||||
cd dart
|
||||
dart pub global activate protoc_plugin
|
||||
protoc --dart_out=lib/src/packets lib/src/packets/message_common.proto
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd dart
|
||||
dart pub get
|
||||
dart test
|
||||
```
|
||||
24
dart/.vscode/launch.json
vendored
Normal file
24
dart/.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run All Tests",
|
||||
"type": "dart",
|
||||
"request": "launch",
|
||||
"program": "test/socket_test.dart"
|
||||
},
|
||||
{
|
||||
"name": "Debug All Tests",
|
||||
"type": "dart",
|
||||
"request": "launch",
|
||||
"program": "test/socket_test.dart",
|
||||
"args": ["--reporter", "expanded"]
|
||||
},
|
||||
{
|
||||
"name": "Run Test (Current File)",
|
||||
"type": "dart",
|
||||
"request": "launch",
|
||||
"program": "${file}"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
dart/analysis_options.yaml
Normal file
5
dart/analysis_options.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
include: package:lints/recommended.yaml
|
||||
|
||||
analyzer:
|
||||
errors:
|
||||
avoid_print: ignore
|
||||
73
dart/lib/src/communicator.dart
Normal file
73
dart/lib/src/communicator.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// ignore_for_file: prefer_final_fields
|
||||
|
||||
import 'package:protobuf/protobuf.dart';
|
||||
|
||||
abstract class Communicator {
|
||||
Map<String, IDataHandler> _handlerDic = {};
|
||||
late Map<String, GeneratedMessage Function(List<int>)> _instanceGenerator;
|
||||
|
||||
Communicator();
|
||||
|
||||
void initialize(Map<String, GeneratedMessage Function(List<int>)> instanceGenerator) {
|
||||
_instanceGenerator = instanceGenerator;
|
||||
}
|
||||
|
||||
T Function(List<int>) getGenerator<T extends GeneratedMessage>(String type) {
|
||||
if (!_instanceGenerator.containsKey(type)) {
|
||||
throw Exception('Must set protobuf packet creator before use it. Type: ${(T).toString()}');
|
||||
}
|
||||
return _instanceGenerator[type] as T Function(List<int>);
|
||||
}
|
||||
|
||||
Future send<T extends GeneratedMessage>(T data);
|
||||
|
||||
void onReceivedData(String typeName, List<int> data) {
|
||||
if (_handlerDic.containsKey(typeName)) {
|
||||
_handlerDic[typeName]?.onMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
void addListener<T extends GeneratedMessage>(void Function(T) listener) {
|
||||
var type = T.toString();
|
||||
if (!_handlerDic.containsKey(type)) {
|
||||
_handlerDic[type] = DataHandler<T>(getGenerator(type));
|
||||
}
|
||||
var handler = _handlerDic[type] as DataHandler<T>;
|
||||
handler.addListener(listener);
|
||||
}
|
||||
|
||||
void removeListener<T extends GeneratedMessage>(void Function(T) listener) {
|
||||
var type = T.toString();
|
||||
if (_handlerDic.containsKey(type)) {
|
||||
var handler = _handlerDic[type] as DataHandler<T>;
|
||||
handler.removeListener(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class IDataHandler {
|
||||
void onMessage(List<int> data);
|
||||
}
|
||||
|
||||
class DataHandler<T extends GeneratedMessage> implements IDataHandler {
|
||||
T Function(List<int>) _generator;
|
||||
List<void Function(T)> _listeners = [];
|
||||
|
||||
DataHandler(this._generator);
|
||||
|
||||
@override
|
||||
void onMessage(List<int> data) {
|
||||
for (var listener in _listeners) {
|
||||
listener.call(_generator(data));
|
||||
}
|
||||
}
|
||||
|
||||
void addListener(void Function(T) handler) {
|
||||
removeListener(handler); // 중복 리스너 불허
|
||||
_listeners.add(handler);
|
||||
}
|
||||
|
||||
void removeListener(void Function(T) handler) {
|
||||
_listeners.remove(handler);
|
||||
}
|
||||
}
|
||||
192
dart/lib/src/packets/message_common.pb.dart
Normal file
192
dart/lib/src/packets/message_common.pb.dart
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: message_common.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
class PacketBase extends $pb.GeneratedMessage {
|
||||
factory PacketBase({
|
||||
$core.String? typeName,
|
||||
$core.int? nonce,
|
||||
$core.List<$core.int>? data,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (typeName != null) {
|
||||
$result.typeName = typeName;
|
||||
}
|
||||
if (nonce != null) {
|
||||
$result.nonce = nonce;
|
||||
}
|
||||
if (data != null) {
|
||||
$result.data = data;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
PacketBase._() : super();
|
||||
factory PacketBase.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory PacketBase.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'PacketBase', createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'typeName', protoName: 'typeName')
|
||||
..a<$core.int>(2, _omitFieldNames ? '' : 'nonce', $pb.PbFieldType.O3)
|
||||
..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'data', $pb.PbFieldType.OY)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
PacketBase clone() => PacketBase()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
PacketBase copyWith(void Function(PacketBase) updates) => super.copyWith((message) => updates(message as PacketBase)) as PacketBase;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static PacketBase create() => PacketBase._();
|
||||
PacketBase createEmptyInstance() => create();
|
||||
static $pb.PbList<PacketBase> createRepeated() => $pb.PbList<PacketBase>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static PacketBase getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<PacketBase>(create);
|
||||
static PacketBase? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get typeName => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set typeName($core.String v) { $_setString(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasTypeName() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearTypeName() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.int get nonce => $_getIZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set nonce($core.int v) { $_setSignedInt32(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasNonce() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearNonce() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.List<$core.int> get data => $_getN(2);
|
||||
@$pb.TagNumber(3)
|
||||
set data($core.List<$core.int> v) { $_setBytes(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasData() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearData() => clearField(3);
|
||||
}
|
||||
|
||||
class HeartBeat extends $pb.GeneratedMessage {
|
||||
factory HeartBeat() => create();
|
||||
HeartBeat._() : super();
|
||||
factory HeartBeat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory HeartBeat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'HeartBeat', createEmptyInstance: create)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
HeartBeat clone() => HeartBeat()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
HeartBeat copyWith(void Function(HeartBeat) updates) => super.copyWith((message) => updates(message as HeartBeat)) as HeartBeat;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static HeartBeat create() => HeartBeat._();
|
||||
HeartBeat createEmptyInstance() => create();
|
||||
static $pb.PbList<HeartBeat> createRepeated() => $pb.PbList<HeartBeat>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static HeartBeat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<HeartBeat>(create);
|
||||
static HeartBeat? _defaultInstance;
|
||||
}
|
||||
|
||||
class TestData extends $pb.GeneratedMessage {
|
||||
factory TestData({
|
||||
$core.int? index,
|
||||
$core.String? message,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (index != null) {
|
||||
$result.index = index;
|
||||
}
|
||||
if (message != null) {
|
||||
$result.message = message;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
TestData._() : super();
|
||||
factory TestData.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory TestData.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TestData', createEmptyInstance: create)
|
||||
..a<$core.int>(1, _omitFieldNames ? '' : 'index', $pb.PbFieldType.O3)
|
||||
..aOS(2, _omitFieldNames ? '' : 'message')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
TestData clone() => TestData()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
TestData copyWith(void Function(TestData) updates) => super.copyWith((message) => updates(message as TestData)) as TestData;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static TestData create() => TestData._();
|
||||
TestData createEmptyInstance() => create();
|
||||
static $pb.PbList<TestData> createRepeated() => $pb.PbList<TestData>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static TestData getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<TestData>(create);
|
||||
static TestData? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.int get index => $_getIZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set index($core.int v) { $_setSignedInt32(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasIndex() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearIndex() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.String get message => $_getSZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set message($core.String v) { $_setString(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasMessage() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearMessage() => clearField(2);
|
||||
}
|
||||
|
||||
|
||||
const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names');
|
||||
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');
|
||||
11
dart/lib/src/packets/message_common.pbenum.dart
Normal file
11
dart/lib/src/packets/message_common.pbenum.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: message_common.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
53
dart/lib/src/packets/message_common.pbjson.dart
Normal file
53
dart/lib/src/packets/message_common.pbjson.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: message_common.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:convert' as $convert;
|
||||
import 'dart:core' as $core;
|
||||
import 'dart:typed_data' as $typed_data;
|
||||
|
||||
@$core.Deprecated('Use packetBaseDescriptor instead')
|
||||
const PacketBase$json = {
|
||||
'1': 'PacketBase',
|
||||
'2': [
|
||||
{'1': 'typeName', '3': 1, '4': 1, '5': 9, '10': 'typeName'},
|
||||
{'1': 'nonce', '3': 2, '4': 1, '5': 5, '10': 'nonce'},
|
||||
{'1': 'data', '3': 3, '4': 1, '5': 12, '10': 'data'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PacketBase`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List packetBaseDescriptor = $convert.base64Decode(
|
||||
'CgpQYWNrZXRCYXNlEhoKCHR5cGVOYW1lGAEgASgJUgh0eXBlTmFtZRIUCgVub25jZRgCIAEoBV'
|
||||
'IFbm9uY2USEgoEZGF0YRgDIAEoDFIEZGF0YQ==');
|
||||
|
||||
@$core.Deprecated('Use heartBeatDescriptor instead')
|
||||
const HeartBeat$json = {
|
||||
'1': 'HeartBeat',
|
||||
};
|
||||
|
||||
/// Descriptor for `HeartBeat`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List heartBeatDescriptor = $convert.base64Decode(
|
||||
'CglIZWFydEJlYXQ=');
|
||||
|
||||
@$core.Deprecated('Use testDataDescriptor instead')
|
||||
const TestData$json = {
|
||||
'1': 'TestData',
|
||||
'2': [
|
||||
{'1': 'index', '3': 1, '4': 1, '5': 5, '10': 'index'},
|
||||
{'1': 'message', '3': 2, '4': 1, '5': 9, '10': 'message'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `TestData`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List testDataDescriptor = $convert.base64Decode(
|
||||
'CghUZXN0RGF0YRIUCgVpbmRleBgBIAEoBVIFaW5kZXgSGAoHbWVzc2FnZRgCIAEoCVIHbWVzc2'
|
||||
'FnZQ==');
|
||||
|
||||
14
dart/lib/src/packets/message_common.pbserver.dart
Normal file
14
dart/lib/src/packets/message_common.pbserver.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: message_common.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
export 'message_common.pb.dart';
|
||||
|
||||
14
dart/lib/src/packets/message_common.proto
Normal file
14
dart/lib/src/packets/message_common.proto
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
syntax = "proto3";
|
||||
|
||||
message PacketBase {
|
||||
string typeName = 1;
|
||||
int32 nonce = 2;
|
||||
bytes data = 3;
|
||||
}
|
||||
|
||||
message HeartBeat {}
|
||||
|
||||
message TestData {
|
||||
int32 index = 1;
|
||||
string message = 2;
|
||||
}
|
||||
165
dart/lib/src/protobuf_client.dart
Normal file
165
dart/lib/src/protobuf_client.dart
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// ignore_for_file: avoid_init_to_null, prefer_final_fields, avoid_print
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:protobuf/protobuf.dart';
|
||||
import 'communicator.dart';
|
||||
import 'response_checker.dart';
|
||||
import 'packets/message_common.pb.dart';
|
||||
|
||||
abstract class ProtobufClient extends Communicator {
|
||||
final int _headerSize = 4;
|
||||
late int _heartbeatIntervalTime;
|
||||
late int _heartbeatWaitTime;
|
||||
final Socket _socket;
|
||||
late ResponseChecker? _heartbeatChecker = null;
|
||||
int? _length = null;
|
||||
bool _isAlive = false;
|
||||
bool _waitingHeartbeatResponse = false;
|
||||
int _nonce = 0;
|
||||
late List<int> _arrivedData = [];
|
||||
List<void Function(ProtobufClient)> _onDisconnectListenerList = [];
|
||||
|
||||
ProtobufClient(
|
||||
this._socket,
|
||||
this._heartbeatIntervalTime,
|
||||
this._heartbeatWaitTime,
|
||||
Map<String, GeneratedMessage Function(List<int>)> parserMap) {
|
||||
print('Connected New Client');
|
||||
_isAlive = true;
|
||||
parserMap.addAll({
|
||||
(HeartBeat).toString(): HeartBeat.fromBuffer,
|
||||
});
|
||||
super.initialize(parserMap);
|
||||
_socket.listen(onData, onError: onError).asFuture().then(onDisconnected);
|
||||
addListener(onHeartBeat);
|
||||
sendHeartBeat();
|
||||
}
|
||||
|
||||
void sendHeartBeat() {
|
||||
if (_isAlive) {
|
||||
_heartbeatChecker?.responded();
|
||||
_heartbeatChecker =
|
||||
ResponseChecker.second(this, _heartbeatIntervalTime, (client) {
|
||||
if (_isAlive) {
|
||||
_waitingHeartbeatResponse = true;
|
||||
send(HeartBeat());
|
||||
_heartbeatChecker =
|
||||
ResponseChecker.second(this, _heartbeatWaitTime, (client) {
|
||||
if (_isAlive) {
|
||||
onDisconnected(null);
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void onHeartBeat(HeartBeat data) {
|
||||
if (_waitingHeartbeatResponse) {
|
||||
_waitingHeartbeatResponse = false;
|
||||
} else {
|
||||
send(HeartBeat());
|
||||
}
|
||||
}
|
||||
|
||||
void addDisconnectListener(void Function(ProtobufClient) handler) {
|
||||
if (!_onDisconnectListenerList.contains(handler)) {
|
||||
_onDisconnectListenerList.add(handler);
|
||||
}
|
||||
}
|
||||
|
||||
void removeDisconnectListener(void Function(ProtobufClient) handler) {
|
||||
if (_onDisconnectListenerList.contains(handler)) {
|
||||
_onDisconnectListenerList.remove(handler);
|
||||
}
|
||||
}
|
||||
|
||||
void onDisconnected(dynamic data) {
|
||||
if (_isAlive) {
|
||||
for (var item in _onDisconnectListenerList) {
|
||||
item.call(this);
|
||||
}
|
||||
_onDisconnectListenerList.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void onError(dynamic e) {
|
||||
print('=========> onError: $e');
|
||||
}
|
||||
|
||||
void onData(Uint8List data) {
|
||||
try {
|
||||
_arrivedData.addAll(data);
|
||||
_parsing();
|
||||
} on Exception catch (e) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
|
||||
void _parsing() {
|
||||
if (_length == null) {
|
||||
if (_arrivedData.length >= _headerSize) {
|
||||
_length = Uint8List.fromList(_arrivedData.sublist(0, _headerSize))
|
||||
.buffer
|
||||
.asByteData()
|
||||
.getInt32(0);
|
||||
if (_length == 0) {
|
||||
_length = null;
|
||||
_arrivedData.clear();
|
||||
return;
|
||||
}
|
||||
_parsing();
|
||||
}
|
||||
} else {
|
||||
if (_arrivedData.length >= _length! + _headerSize) {
|
||||
final packetBytes = List<int>.from(
|
||||
_arrivedData.sublist(_headerSize, _headerSize + _length!));
|
||||
_arrivedData = _arrivedData.sublist(_headerSize + _length!);
|
||||
_length = null;
|
||||
final common = PacketBase.fromBuffer(packetBytes);
|
||||
onReceivedData(common.typeName, common.data);
|
||||
sendHeartBeat();
|
||||
_parsing();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future send<T extends GeneratedMessage>(T data) async {
|
||||
if (_isAlive) {
|
||||
try {
|
||||
List<int> packet = [];
|
||||
var base = PacketBase();
|
||||
base.typeName = data.info_.qualifiedMessageName;
|
||||
base.nonce = ++_nonce;
|
||||
base.data = data.writeToBuffer();
|
||||
var baseBytes = base.writeToBuffer();
|
||||
int length = baseBytes.length;
|
||||
var header = Uint8List(_headerSize)
|
||||
..buffer.asByteData().setInt32(0, length);
|
||||
packet.addAll(header);
|
||||
packet.addAll(baseBytes);
|
||||
_socket.add(packet);
|
||||
await _socket.flush();
|
||||
} catch (e) {
|
||||
onDisconnected(null);
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
void dispose() async {
|
||||
if (_isAlive) {
|
||||
_isAlive = false;
|
||||
_heartbeatChecker?.responded();
|
||||
try {
|
||||
await _socket.close();
|
||||
_socket.destroy();
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
dart/lib/src/protobuf_server.dart
Normal file
56
dart/lib/src/protobuf_server.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// ignore_for_file: avoid_print, prefer_final_fields
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:protobuf/protobuf.dart';
|
||||
import 'packets/message_common.pb.dart';
|
||||
import 'protobuf_client.dart';
|
||||
|
||||
abstract class ProtobufServer {
|
||||
List<ProtobufClient> _clientList = [];
|
||||
bool _started = false;
|
||||
late ServerSocket? _server;
|
||||
final String _host;
|
||||
final int _port;
|
||||
final ProtobufClient Function(Socket) _createNewClient;
|
||||
|
||||
bool get started => _started;
|
||||
|
||||
ProtobufServer(this._host, this._port, this._createNewClient);
|
||||
|
||||
Future start() async {
|
||||
_server = await ServerSocket.bind(_host, _port);
|
||||
_started = true;
|
||||
_server?.listen((Socket socket) {
|
||||
var client = _createNewClient(socket);
|
||||
client.addDisconnectListener(onDisconnectedClient);
|
||||
client.addListener<HeartBeat>((_) => onClientHeartBeat(client));
|
||||
_clientList.add(client);
|
||||
onClientConnected(client);
|
||||
});
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
// implement in child
|
||||
void onClientConnected(ProtobufClient client);
|
||||
|
||||
void onClientHeartBeat(ProtobufClient client) {}
|
||||
|
||||
void onDisconnectedClient(ProtobufClient client) {
|
||||
_clientList.remove(client);
|
||||
client.dispose();
|
||||
print('Client disconnected');
|
||||
}
|
||||
|
||||
Future broadcast<T extends GeneratedMessage>(T data) async {
|
||||
for (var client in _clientList) {
|
||||
await client.send(data);
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
Future stop() async {
|
||||
await _server?.close();
|
||||
_started = false;
|
||||
return Future.value();
|
||||
}
|
||||
}
|
||||
31
dart/lib/src/response_checker.dart
Normal file
31
dart/lib/src/response_checker.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import 'dart:async';
|
||||
|
||||
class ResponseChecker<T> {
|
||||
final T _responser;
|
||||
late int _time;
|
||||
late Timer? _timer = null;
|
||||
late void Function(T) _notResponseListener;
|
||||
|
||||
T get responser => _responser;
|
||||
|
||||
ResponseChecker(this._responser, this._time, this._notResponseListener) {
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
ResponseChecker.second(this._responser, int time, this._notResponseListener) {
|
||||
_time = time * 1000;
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer = Timer(Duration(milliseconds: _time), () {
|
||||
_notResponseListener(_responser);
|
||||
});
|
||||
}
|
||||
|
||||
T responded() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
return _responser;
|
||||
}
|
||||
}
|
||||
7
dart/lib/toki_socket.dart
Normal file
7
dart/lib/toki_socket.dart
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
library toki_socket;
|
||||
|
||||
export 'src/communicator.dart';
|
||||
export 'src/protobuf_client.dart';
|
||||
export 'src/protobuf_server.dart';
|
||||
export 'src/response_checker.dart';
|
||||
export 'src/packets/message_common.pb.dart';
|
||||
14
dart/pubspec.yaml
Normal file
14
dart/pubspec.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
name: toki_socket
|
||||
description: Multi-language standard socket protocol library. Dart implementation.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: '>=3.2.3 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
protobuf: ^3.1.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^3.0.0
|
||||
test: ^1.24.0
|
||||
154
dart/test/socket_test.dart
Normal file
154
dart/test/socket_test.dart
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
import 'package:toki_socket/toki_socket.dart';
|
||||
|
||||
const _testPort = 19090;
|
||||
const _host = 'localhost';
|
||||
|
||||
// ── 테스트 전용 픽스처 ──────────────────────────────────────────
|
||||
|
||||
class _TestClient extends ProtobufClient {
|
||||
_TestClient(Socket socket)
|
||||
: super(socket, 5, 3, {
|
||||
TestData.getDefault().info_.qualifiedMessageName: TestData.fromBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
class _TestServer extends ProtobufServer {
|
||||
final receivedMessages = <TestData>[];
|
||||
final connectedClients = <ProtobufClient>[];
|
||||
|
||||
_TestServer() : super(_host, _testPort, (socket) => _TestClient(socket));
|
||||
|
||||
@override
|
||||
void onClientConnected(ProtobufClient client) {
|
||||
connectedClients.add(client);
|
||||
client.addListener<TestData>((data) => receivedMessages.add(data));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 테스트 ──────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
group('ProtobufServer', () {
|
||||
late _TestServer server;
|
||||
|
||||
setUp(() async {
|
||||
server = _TestServer();
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test('서버가 정상 시작된다', () {
|
||||
expect(server.started, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProtobufClient', () {
|
||||
late _TestServer server;
|
||||
late Socket clientSocket;
|
||||
late _TestClient client;
|
||||
|
||||
setUp(() async {
|
||||
server = _TestServer();
|
||||
await server.start();
|
||||
clientSocket = await Socket.connect(_host, _testPort);
|
||||
client = _TestClient(clientSocket);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
client.dispose();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test('클라이언트가 서버에 연결된다', () {
|
||||
expect(server.connectedClients, isNotEmpty);
|
||||
});
|
||||
|
||||
test('TestData 메시지를 서버가 수신한다', () async {
|
||||
final msg = TestData()
|
||||
..index = 42
|
||||
..message = 'hello toki-socket';
|
||||
|
||||
await client.send(msg);
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
expect(server.receivedMessages, hasLength(1));
|
||||
expect(server.receivedMessages.first.index, equals(42));
|
||||
expect(server.receivedMessages.first.message, equals('hello toki-socket'));
|
||||
});
|
||||
|
||||
test('여러 메시지를 순서대로 수신한다', () async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await client.send(TestData()
|
||||
..index = i
|
||||
..message = 'msg$i');
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
expect(server.receivedMessages, hasLength(5));
|
||||
for (var i = 0; i < 5; i++) {
|
||||
expect(server.receivedMessages[i].index, equals(i));
|
||||
}
|
||||
});
|
||||
|
||||
test('서버에서 클라이언트로 메시지를 전송한다', () async {
|
||||
final completer = Completer<TestData>();
|
||||
client.addListener<TestData>((data) => completer.complete(data));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
final serverClient = server.connectedClients.first;
|
||||
await serverClient.send(TestData()
|
||||
..index = 99
|
||||
..message = 'from server');
|
||||
|
||||
final received = await completer.future.timeout(const Duration(seconds: 2));
|
||||
expect(received.index, equals(99));
|
||||
expect(received.message, equals('from server'));
|
||||
});
|
||||
|
||||
test('nonce가 송신마다 증가한다', () async {
|
||||
final receivedNonces = <int>[];
|
||||
server.connectedClients.first.addListener<TestData>((data) {});
|
||||
|
||||
// 서버에서 수신한 PacketBase의 nonce를 검증하기 위해
|
||||
// 클라이언트에서 3개의 메시지를 순서대로 전송
|
||||
for (var i = 1; i <= 3; i++) {
|
||||
await client.send(TestData()..index = i);
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
// 3개 수신 확인 (nonce 단조 증가는 wire level 검증 — 여기선 수신 순서로 간접 확인)
|
||||
expect(server.receivedMessages, hasLength(3));
|
||||
});
|
||||
|
||||
test('클라이언트 disconnect 시 서버 콜백이 호출된다', () async {
|
||||
final completer = Completer<void>();
|
||||
server.connectedClients.first.addDisconnectListener((_) {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
});
|
||||
|
||||
client.dispose();
|
||||
await completer.future.timeout(const Duration(seconds: 2));
|
||||
// completer가 완료되면 disconnect 콜백이 정상 호출된 것
|
||||
expect(completer.isCompleted, isTrue);
|
||||
});
|
||||
|
||||
test('HeartBeat interval 동안 연결이 유지된다', () async {
|
||||
// heartbeat interval(5초)보다 짧은 시간 대기 후 메시지 송수신 확인
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
await client.send(TestData()..index = 1..message = 'alive check');
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
expect(server.receivedMessages, hasLength(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue