This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
name: Publish UPM package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }}
|
||||
steps:
|
||||
- name: Fetch tagged revision
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
ref="${{ gitea.sha }}"
|
||||
test -n "$ref"
|
||||
git init .
|
||||
git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkNetwork.git"
|
||||
git fetch --depth=1 origin "$ref"
|
||||
git checkout --detach FETCH_HEAD
|
||||
|
||||
- name: Validate immutable release version
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
tag="$(git describe --exact-match --tags HEAD)"
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
test "$tag" = "v$version"
|
||||
npm pack --dry-run
|
||||
|
||||
- name: Publish to ShrinkSDK registry
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
: "${NODE_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}"
|
||||
npmrc="$HOME/.npmrc"
|
||||
cleanup() { rm -f "$npmrc"; }
|
||||
trap cleanup EXIT
|
||||
printf '%s\n' \
|
||||
'registry=https://git.crash.work/api/packages/ShrinkSDK/npm/' \
|
||||
'//git.crash.work/api/packages/ShrinkSDK/npm/:_authToken=${NODE_AUTH_TOKEN}' > "$npmrc"
|
||||
npm publish --registry=https://git.crash.work/api/packages/ShrinkSDK/npm/
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Verify standalone Unity package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
editmode:
|
||||
runs-on: unity-2022.3.62f3
|
||||
container:
|
||||
image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3
|
||||
volumes:
|
||||
- /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity
|
||||
- /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses
|
||||
steps:
|
||||
- name: Fetch selected revision
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
ref="${{ gitea.sha }}"
|
||||
git init .
|
||||
git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkNetwork.git"
|
||||
git fetch --depth=1 origin "$ref"
|
||||
git checkout --detach FETCH_HEAD
|
||||
|
||||
- name: Run package EditMode tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)"
|
||||
test -n "$unity_bin"
|
||||
"$unity_bin" \
|
||||
-batchmode \
|
||||
-nographics \
|
||||
-quit \
|
||||
-projectPath "$PWD/Development~/UnityProject" \
|
||||
-runTests \
|
||||
-testPlatform EditMode \
|
||||
-testResults "$PWD/TestResults/editmode.xml" \
|
||||
-logFile "$PWD/TestResults/unity.log"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/Development~/UnityProject/[Ll]ibrary/
|
||||
/Development~/UnityProject/[Tt]emp/
|
||||
/Development~/UnityProject/[Oo]bj/
|
||||
/Development~/UnityProject/[Ll]ogs/
|
||||
/Development~/UnityProject/[Uu]ser[Ss]ettings/
|
||||
/Development~/UnityProject/TestResults/
|
||||
/Tools~/**/[Bb]in/
|
||||
/Tools~/**/[Oo]bj/
|
||||
*.user
|
||||
*.DotSettings.user
|
||||
@@ -0,0 +1,8 @@
|
||||
.git/
|
||||
.gitea/
|
||||
Development~/
|
||||
Tools~/
|
||||
*.csproj
|
||||
*.sln
|
||||
*.user
|
||||
*.DotSettings.user
|
||||
@@ -0,0 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
本文件记录 `ShrinkNetwork` 在当前工作区中的包内变更。
|
||||
|
||||
## [0.2.0] - 2026-05-18
|
||||
|
||||
### Changed
|
||||
|
||||
- 静态消息合同与静态 handler 的默认自动发现从运行时全域反射扫描切换为编译期注册表 `ShrinkNetworkGeneratedRegistry`。
|
||||
- `ShrinkNetworkRuntime.Default` 启动时优先消费编译期消息/handler 清单,降低默认服务的注册开销。
|
||||
- `RegisterHandlers(object target)` 的实例注册路径保持不变,继续服务于模组实例和运行时对象。
|
||||
|
||||
## [0.1.0] - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- 正式建立 RPC 优先的 Unity 网络层,包含服务、会话、路由、消息注册、权限与日志基础设施。
|
||||
- 建立属性式注册模型:`[ShrinkNetworkMessage]`、`[ShrinkNetworkSubscriber]`、`[ShrinkNetworkSubscribe]`。
|
||||
- 提供 `ShrinkJsonNetworkSerializer` 与 `ShrinkMessagePackNetworkSerializer`。
|
||||
- 提供 `Loopback`、TCP、KCP 传输实现,以及独立服务器生成器与生成宿主链路。
|
||||
- 新增会话令牌能力,`ShrinkNetworkPacket`、`ShrinkNetworkSession`、`ShrinkNetworkService` 支持令牌签发、回传与逐包校验。
|
||||
- TCP 传输与生成宿主补上可选 TLS 接入。
|
||||
- 宿主侧补上 `server.properties` 文本配置、协议版本窗口与基础指标摘要。
|
||||
|
||||
### Changed
|
||||
|
||||
- 运行时公共类型按真实语义收口可空性,覆盖消息元数据、权限、注册器、路由器、会话、服务、序列化器与传输层。
|
||||
- `GetDefaultInitializer(...)` 现会为任意 `T[]` 生成 `Array.Empty<T>()`,避免重新生成合同后数组默认值 warning 回弹。
|
||||
- 当前工作区中 `ShrinkSDK.sln` 与独立宿主工程已重新收口到 `0 warning / 0 error`。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ccd00ba8f4c15241be2c1d6200fb547
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,6 @@
|
||||
[Ll]ibrary/
|
||||
[Tt]emp/
|
||||
[Oo]bj/
|
||||
[Ll]ogs/
|
||||
[Uu]ser[Ss]ettings/
|
||||
TestResults/
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"scopedRegistries": [
|
||||
{
|
||||
"name": "ShrinkSDK",
|
||||
"url": "https://git.crash.work/api/packages/ShrinkSDK/npm/",
|
||||
"scopes": [
|
||||
"com.cneicy"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"com.unity.test-framework": "1.1.33",
|
||||
"com.cneicy.shrink-network": "file:../../.."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
m_EditorVersion: 2022.3.62f3
|
||||
m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7)
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1142dff5a900c294e9a66f527fa1710a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("ShrinkNetwork.Editor.Tests")]
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 360b0480ff82f4d4bbb7cecae33717a3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7fbdc5e00c5febc45b3353592b8b008a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5612ce6a5722f164ca2cc030308b2a37
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,184 @@
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkNetwork;
|
||||
using ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost;
|
||||
|
||||
public sealed class AuthServerModule : IShrinkServerModule
|
||||
{
|
||||
public const int LoginRequestOpcode = 1201;
|
||||
public const int LoginResponseOpcode = 1202;
|
||||
public const int RefreshRequestOpcode = 1203;
|
||||
public const int RefreshResponseOpcode = 1204;
|
||||
public const string LoginRoute = "server/auth/login";
|
||||
public const string RefreshRoute = "server/auth/refresh";
|
||||
private static readonly string[] DefaultGrantedPermissions = { "auth.ok", "room.access" };
|
||||
|
||||
public string Name => "Auth";
|
||||
|
||||
public void ConfigureService(ServerModuleContext context, ShrinkNetworkService service, string transportName)
|
||||
{
|
||||
service.RegisterMessage<ServerAuthLoginRequest>(LoginRequestOpcode, LoginRoute);
|
||||
service.RegisterMessage<ServerAuthLoginResponse>(LoginResponseOpcode, "server/auth/login_response");
|
||||
service.RegisterMessage<ServerAuthRefreshRequest>(RefreshRequestOpcode, RefreshRoute);
|
||||
service.RegisterMessage<ServerAuthRefreshResponse>(RefreshResponseOpcode, "server/auth/refresh_response");
|
||||
service.RegisterRequestHandler<ServerAuthLoginRequest, ServerAuthLoginResponse>((ctx, request) =>
|
||||
HandleLoginAsync(context, transportName, ctx, request));
|
||||
service.RegisterRequestHandler<ServerAuthRefreshRequest, ServerAuthRefreshResponse>((ctx, request) =>
|
||||
HandleRefreshAsync(context, ctx, request));
|
||||
}
|
||||
|
||||
public UniTask StartAsync(ServerModuleContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
|
||||
private UniTask<ServerAuthLoginResponse> HandleLoginAsync(
|
||||
ServerModuleContext moduleContext,
|
||||
string transportName,
|
||||
ShrinkNetworkContext context,
|
||||
ServerAuthLoginRequest request)
|
||||
{
|
||||
var authToken = moduleContext.Options.SharedAuthToken;
|
||||
var requiresAuth = !string.IsNullOrWhiteSpace(authToken);
|
||||
if (!requiresAuth && !moduleContext.Options.AllowAnonymousWhenAuthTokenMissing)
|
||||
{
|
||||
return UniTask.FromResult(new ServerAuthLoginResponse
|
||||
{
|
||||
ErrorCode = 503,
|
||||
ErrorMessage = "服务端未配置 SHRINK_SERVER_AUTH_TOKEN,且未显式允许匿名登录。",
|
||||
IsAuthenticated = false
|
||||
});
|
||||
}
|
||||
|
||||
if (requiresAuth && !string.Equals(authToken, request.Token, StringComparison.Ordinal))
|
||||
{
|
||||
return UniTask.FromResult(new ServerAuthLoginResponse
|
||||
{
|
||||
ErrorCode = ShrinkRpcErrorCode.AuthenticationFailed,
|
||||
ErrorMessage = "鉴权失败,口令不正确。",
|
||||
IsAuthenticated = false
|
||||
});
|
||||
}
|
||||
|
||||
var authName = string.IsNullOrWhiteSpace(request.ClientName)
|
||||
? $"{transportName}-{context.Session.SessionId}"
|
||||
: request.ClientName.Trim();
|
||||
var authState = moduleContext.AuthStore.MarkAuthenticated(context.Service, context.Session.SessionId, authName,
|
||||
BuildSessionTokenTtl(moduleContext.Options));
|
||||
|
||||
context.Session.Items["auth.name"] = authName;
|
||||
context.Session.Items["auth.at_utc"] = DateTimeOffset.UtcNow;
|
||||
context.Session.SetSessionToken(authState.SessionToken, authState.SessionTokenExpiresAtUtc);
|
||||
foreach (var permission in DefaultGrantedPermissions)
|
||||
context.Session.GrantPermission(permission);
|
||||
|
||||
return UniTask.FromResult(new ServerAuthLoginResponse
|
||||
{
|
||||
IsAuthenticated = true,
|
||||
AuthName = authName,
|
||||
SessionToken = authState.SessionToken,
|
||||
SessionTokenExpiresAtUnixTimeSeconds = authState.SessionTokenExpiresAtUtc.ToUnixTimeSeconds(),
|
||||
RefreshRecommendedAtUnixTimeSeconds = BuildRefreshRecommendedAtUnixTimeSeconds(
|
||||
authState.SessionTokenExpiresAtUtc,
|
||||
moduleContext.Options),
|
||||
GrantedPermissions = DefaultGrantedPermissions.ToArray(),
|
||||
ServerMessage = requiresAuth ? "鉴权成功。" : "服务端已显式允许匿名登录。"
|
||||
});
|
||||
}
|
||||
|
||||
private UniTask<ServerAuthRefreshResponse> HandleRefreshAsync(
|
||||
ServerModuleContext moduleContext,
|
||||
ShrinkNetworkContext context,
|
||||
ServerAuthRefreshRequest request)
|
||||
{
|
||||
var sessionToken = string.IsNullOrWhiteSpace(request.SessionToken)
|
||||
? context.Packet.SessionToken
|
||||
: request.SessionToken.Trim();
|
||||
if (!moduleContext.AuthStore.TryRefreshSessionToken(context.Service, context.Session.SessionId, sessionToken,
|
||||
BuildSessionTokenTtl(moduleContext.Options), BuildRefreshWindow(moduleContext.Options), out var authState,
|
||||
out var failureReason))
|
||||
{
|
||||
return UniTask.FromResult(new ServerAuthRefreshResponse
|
||||
{
|
||||
ErrorCode = string.Equals(failureReason, "会话令牌已过期。", StringComparison.Ordinal)
|
||||
? ShrinkRpcErrorCode.SessionTokenExpired
|
||||
: ShrinkRpcErrorCode.AuthenticationFailed,
|
||||
ErrorMessage = failureReason,
|
||||
IsAuthenticated = false
|
||||
});
|
||||
}
|
||||
|
||||
context.Session.SetSessionToken(authState.SessionToken, authState.SessionTokenExpiresAtUtc);
|
||||
context.Session.Items["auth.name"] = authState.AuthName;
|
||||
foreach (var permission in DefaultGrantedPermissions)
|
||||
context.Session.GrantPermission(permission);
|
||||
|
||||
return UniTask.FromResult(new ServerAuthRefreshResponse
|
||||
{
|
||||
IsAuthenticated = true,
|
||||
AuthName = authState.AuthName,
|
||||
SessionToken = authState.SessionToken,
|
||||
SessionTokenExpiresAtUnixTimeSeconds = authState.SessionTokenExpiresAtUtc.ToUnixTimeSeconds(),
|
||||
RefreshRecommendedAtUnixTimeSeconds = BuildRefreshRecommendedAtUnixTimeSeconds(
|
||||
authState.SessionTokenExpiresAtUtc,
|
||||
moduleContext.Options),
|
||||
ServerMessage = "会话令牌已刷新。"
|
||||
});
|
||||
}
|
||||
|
||||
private static TimeSpan BuildSessionTokenTtl(ServerHostOptions options)
|
||||
{
|
||||
return TimeSpan.FromSeconds(Math.Max(60, options.SessionTokenTtlSeconds));
|
||||
}
|
||||
|
||||
private static TimeSpan BuildRefreshWindow(ServerHostOptions options)
|
||||
{
|
||||
return TimeSpan.FromSeconds(Math.Max(0, options.SessionTokenRefreshWindowSeconds));
|
||||
}
|
||||
|
||||
private static long BuildRefreshRecommendedAtUnixTimeSeconds(DateTimeOffset expiresAtUtc, ServerHostOptions options)
|
||||
{
|
||||
var refreshWindow = BuildRefreshWindow(options);
|
||||
if (refreshWindow <= TimeSpan.Zero)
|
||||
return 0;
|
||||
|
||||
return expiresAtUtc.Subtract(refreshWindow).ToUnixTimeSeconds();
|
||||
}
|
||||
}
|
||||
|
||||
[ShrinkNetworkMessage(AuthServerModule.LoginRequestOpcode, AuthServerModule.LoginRoute)]
|
||||
public sealed class ServerAuthLoginRequest : IShrinkNetworkRequest
|
||||
{
|
||||
public string ClientName { get; set; } = string.Empty;
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[ShrinkNetworkMessage(AuthServerModule.LoginResponseOpcode, "server/auth/login_response")]
|
||||
public sealed class ServerAuthLoginResponse : ShrinkRpcResponseBase
|
||||
{
|
||||
public bool IsAuthenticated { get; set; }
|
||||
public string AuthName { get; set; } = string.Empty;
|
||||
public string SessionToken { get; set; } = string.Empty;
|
||||
public long SessionTokenExpiresAtUnixTimeSeconds { get; set; }
|
||||
public long RefreshRecommendedAtUnixTimeSeconds { get; set; }
|
||||
public string[] GrantedPermissions { get; set; } = Array.Empty<string>();
|
||||
public string ServerMessage { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[ShrinkNetworkMessage(AuthServerModule.RefreshRequestOpcode, AuthServerModule.RefreshRoute)]
|
||||
public sealed class ServerAuthRefreshRequest : IShrinkNetworkRequest
|
||||
{
|
||||
public string SessionToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[ShrinkNetworkMessage(AuthServerModule.RefreshResponseOpcode, "server/auth/refresh_response")]
|
||||
public sealed class ServerAuthRefreshResponse : ShrinkRpcResponseBase
|
||||
{
|
||||
public bool IsAuthenticated { get; set; }
|
||||
public string AuthName { get; set; } = string.Empty;
|
||||
public string SessionToken { get; set; } = string.Empty;
|
||||
public long SessionTokenExpiresAtUnixTimeSeconds { get; set; }
|
||||
public long RefreshRecommendedAtUnixTimeSeconds { get; set; }
|
||||
public string ServerMessage { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 983129d399a30af419b08bc0cb951054
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5e85da237e6287d4fbf41163901f9ff8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,13 @@
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkNetwork;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public interface IShrinkServerModule
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
void ConfigureService(ServerModuleContext context, ShrinkNetworkService service, string transportName);
|
||||
|
||||
UniTask StartAsync(ServerModuleContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c849f6eb91ed5824db0497318345601b
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using ShrinkNetwork;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class ServerAuthStore
|
||||
{
|
||||
public sealed class AuthState
|
||||
{
|
||||
public string AuthName { get; set; } = string.Empty;
|
||||
public bool IsAuthenticated { get; set; }
|
||||
public DateTimeOffset AuthenticatedAtUtc { get; set; }
|
||||
public DateTimeOffset LastSeenAtUtc { get; set; }
|
||||
public string SessionToken { get; set; } = string.Empty;
|
||||
public DateTimeOffset SessionTokenExpiresAtUtc { get; set; }
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<(ShrinkNetworkService service, long sessionId), AuthState> _states = new();
|
||||
|
||||
public AuthState MarkAuthenticated(ShrinkNetworkService service, long sessionId, string authName, TimeSpan tokenTtl)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var state = new AuthState
|
||||
{
|
||||
AuthName = authName,
|
||||
IsAuthenticated = true,
|
||||
AuthenticatedAtUtc = now,
|
||||
LastSeenAtUtc = now,
|
||||
SessionToken = CreateSessionToken(),
|
||||
SessionTokenExpiresAtUtc = now.Add(tokenTtl)
|
||||
};
|
||||
_states[(service, sessionId)] = state;
|
||||
return CloneState(state);
|
||||
}
|
||||
|
||||
public void Remove(ShrinkNetworkService service, long sessionId)
|
||||
{
|
||||
_states.TryRemove((service, sessionId), out _);
|
||||
}
|
||||
|
||||
public bool IsAuthenticated(ShrinkNetworkService service, long sessionId)
|
||||
{
|
||||
return _states.TryGetValue((service, sessionId), out var state) && state.IsAuthenticated;
|
||||
}
|
||||
|
||||
public bool TryGetState(ShrinkNetworkService service, long sessionId, out AuthState state)
|
||||
{
|
||||
if (_states.TryGetValue((service, sessionId), out var current))
|
||||
{
|
||||
state = CloneState(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
state = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryValidateSessionToken(ShrinkNetworkService service, long sessionId, string? sessionToken, out AuthState state,
|
||||
out string failureReason)
|
||||
{
|
||||
if (!_states.TryGetValue((service, sessionId), out var current) || !current.IsAuthenticated)
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "会话未认证。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sessionToken))
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "缺少会话令牌。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(current.SessionToken, sessionToken.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "会话令牌不匹配。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (current.SessionTokenExpiresAtUtc <= now)
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "会话令牌已过期。";
|
||||
return false;
|
||||
}
|
||||
|
||||
current.LastSeenAtUtc = now;
|
||||
state = CloneState(current);
|
||||
failureReason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryRefreshSessionToken(ShrinkNetworkService service, long sessionId, string? sessionToken, TimeSpan tokenTtl,
|
||||
TimeSpan refreshWindow, out AuthState state, out string failureReason)
|
||||
{
|
||||
if (!TryValidateSessionToken(service, sessionId, sessionToken, out _, out failureReason))
|
||||
{
|
||||
state = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = (service, sessionId);
|
||||
if (!_states.TryGetValue(key, out var current))
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "认证状态不存在。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (refreshWindow > TimeSpan.Zero && current.SessionTokenExpiresAtUtc - now > refreshWindow)
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "当前还不在会话令牌续期窗口内。";
|
||||
return false;
|
||||
}
|
||||
|
||||
current.SessionToken = CreateSessionToken();
|
||||
current.SessionTokenExpiresAtUtc = now.Add(tokenTtl);
|
||||
current.LastSeenAtUtc = now;
|
||||
state = CloneState(current);
|
||||
failureReason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static AuthState CloneState(AuthState state)
|
||||
{
|
||||
return new AuthState
|
||||
{
|
||||
AuthName = state.AuthName,
|
||||
IsAuthenticated = state.IsAuthenticated,
|
||||
AuthenticatedAtUtc = state.AuthenticatedAtUtc,
|
||||
LastSeenAtUtc = state.LastSeenAtUtc,
|
||||
SessionToken = state.SessionToken,
|
||||
SessionTokenExpiresAtUtc = state.SessionTokenExpiresAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateSessionToken()
|
||||
{
|
||||
var buffer = new byte[32];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
return Convert.ToBase64String(buffer)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 911b8227233322049a930fb0ed3c2701
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,145 @@
|
||||
using ShrinkNetwork;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class ServerHostOptions
|
||||
{
|
||||
public int Port { get; set; } = 17777;
|
||||
public string UnityAssetsPath { get; set; } = ResolveDefaultUnityAssetsPath();
|
||||
public string ScanOutputDirectory { get; set; } = ResolveDefaultScanOutputDirectory();
|
||||
public string? SharedAuthToken { get; set; }
|
||||
public bool AllowAnonymousWhenAuthTokenMissing { get; set; }
|
||||
public bool EnableSessionTokens { get; set; } = true;
|
||||
public int SessionTokenTtlSeconds { get; set; } = 1800;
|
||||
public int SessionTokenRefreshWindowSeconds { get; set; } = 300;
|
||||
public bool DisconnectOnInvalidSessionToken { get; set; } = true;
|
||||
public bool EnableUnityCodeScan { get; set; } = true;
|
||||
public int MinProtocolVersion { get; set; } = ShrinkNetworkProtocol.CurrentProtocolVersion;
|
||||
public int MaxProtocolVersion { get; set; } = ShrinkNetworkProtocol.CurrentProtocolVersion;
|
||||
public int MinSchemaVersion { get; set; } = ShrinkNetworkProtocol.CurrentSchemaVersion;
|
||||
public int MaxSchemaVersion { get; set; } = ShrinkNetworkProtocol.CurrentSchemaVersion;
|
||||
public bool DisconnectOnProtocolViolation { get; set; } = true;
|
||||
public int DiagnosticsLogIntervalSeconds { get; set; } = 60;
|
||||
public bool EnableTcpTls { get; set; }
|
||||
public string? TcpTlsTargetHost { get; set; }
|
||||
public string? TcpTlsCertificatePath { get; set; }
|
||||
public string? TcpTlsCertificatePassword { get; set; }
|
||||
public bool TcpTlsCheckCertificateRevocation { get; set; }
|
||||
|
||||
public void ApplyEnvironmentOverrides()
|
||||
{
|
||||
SharedAuthToken = ReadOptionalStringFromEnvironment("SHRINK_SERVER_AUTH_TOKEN", SharedAuthToken);
|
||||
AllowAnonymousWhenAuthTokenMissing = ReadBoolFromEnvironment(
|
||||
"SHRINK_SERVER_ALLOW_ANONYMOUS_WHEN_AUTH_TOKEN_MISSING",
|
||||
AllowAnonymousWhenAuthTokenMissing);
|
||||
EnableSessionTokens = ReadBoolFromEnvironment("SHRINK_SERVER_ENABLE_SESSION_TOKENS", EnableSessionTokens);
|
||||
SessionTokenTtlSeconds = ReadIntFromEnvironment("SHRINK_SERVER_SESSION_TOKEN_TTL_SECONDS", SessionTokenTtlSeconds, 60);
|
||||
SessionTokenRefreshWindowSeconds = ReadIntFromEnvironment(
|
||||
"SHRINK_SERVER_SESSION_TOKEN_REFRESH_WINDOW_SECONDS",
|
||||
SessionTokenRefreshWindowSeconds,
|
||||
0);
|
||||
DisconnectOnInvalidSessionToken = ReadBoolFromEnvironment(
|
||||
"SHRINK_SERVER_DISCONNECT_ON_INVALID_SESSION_TOKEN",
|
||||
DisconnectOnInvalidSessionToken);
|
||||
EnableUnityCodeScan = ReadBoolFromEnvironment("SHRINK_SERVER_ENABLE_UNITY_CODE_SCAN", EnableUnityCodeScan);
|
||||
MinProtocolVersion = ReadIntFromEnvironment("SHRINK_SERVER_MIN_PROTOCOL_VERSION", MinProtocolVersion, 1);
|
||||
MaxProtocolVersion = ReadIntFromEnvironment("SHRINK_SERVER_MAX_PROTOCOL_VERSION", MaxProtocolVersion, 1);
|
||||
MinSchemaVersion = ReadIntFromEnvironment("SHRINK_SERVER_MIN_SCHEMA_VERSION", MinSchemaVersion, 1);
|
||||
MaxSchemaVersion = ReadIntFromEnvironment("SHRINK_SERVER_MAX_SCHEMA_VERSION", MaxSchemaVersion, 1);
|
||||
DisconnectOnProtocolViolation = ReadBoolFromEnvironment(
|
||||
"SHRINK_SERVER_DISCONNECT_ON_PROTOCOL_VIOLATION",
|
||||
DisconnectOnProtocolViolation);
|
||||
DiagnosticsLogIntervalSeconds = ReadIntFromEnvironment(
|
||||
"SHRINK_SERVER_DIAGNOSTICS_LOG_INTERVAL_SECONDS",
|
||||
DiagnosticsLogIntervalSeconds,
|
||||
0);
|
||||
EnableTcpTls = ReadBoolFromEnvironment("SHRINK_SERVER_ENABLE_TCP_TLS", EnableTcpTls);
|
||||
TcpTlsTargetHost = ReadOptionalStringFromEnvironment("SHRINK_SERVER_TLS_TARGET_HOST", TcpTlsTargetHost);
|
||||
TcpTlsCertificatePath = ReadOptionalStringFromEnvironment("SHRINK_SERVER_TLS_CERT_PATH", TcpTlsCertificatePath);
|
||||
TcpTlsCertificatePassword = ReadOptionalStringFromEnvironment(
|
||||
"SHRINK_SERVER_TLS_CERT_PASSWORD",
|
||||
TcpTlsCertificatePassword);
|
||||
TcpTlsCheckCertificateRevocation = ReadBoolFromEnvironment(
|
||||
"SHRINK_SERVER_TLS_CHECK_CERTIFICATE_REVOCATION",
|
||||
TcpTlsCheckCertificateRevocation);
|
||||
}
|
||||
|
||||
public static string ResolveHostProjectDirectory()
|
||||
{
|
||||
var resolved = TryFindProjectDirectory(AppContext.BaseDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
return resolved;
|
||||
|
||||
resolved = TryFindProjectDirectory(Environment.CurrentDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
return resolved;
|
||||
|
||||
return Environment.CurrentDirectory;
|
||||
}
|
||||
|
||||
public static string ResolveDefaultConfigFilePath()
|
||||
{
|
||||
return Path.Combine(ResolveHostProjectDirectory(), ServerHostProperties.DefaultFileName);
|
||||
}
|
||||
|
||||
public static string ResolveDefaultUnityAssetsPath()
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(ResolveHostProjectDirectory(), "..", "..", "Assets"));
|
||||
}
|
||||
|
||||
public static string ResolveDefaultScanOutputDirectory()
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(ResolveHostProjectDirectory(), "Generated"));
|
||||
}
|
||||
|
||||
private static string? TryFindProjectDirectory(string startDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(startDirectory) || !Directory.Exists(startDirectory))
|
||||
return null;
|
||||
|
||||
var directory = new DirectoryInfo(Path.GetFullPath(startDirectory));
|
||||
for (var depth = 0; directory != null && depth < 8; depth++, directory = directory.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, "ShrinkNetwork.ServerHost.csproj");
|
||||
if (File.Exists(candidate))
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ReadIntFromEnvironment(string name, int fallback, int minValue)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(name);
|
||||
if (!int.TryParse(raw, out var value))
|
||||
return fallback;
|
||||
|
||||
return Math.Max(minValue, value);
|
||||
}
|
||||
|
||||
private static bool ReadBoolFromEnvironment(string name, bool fallback)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return fallback;
|
||||
|
||||
if (bool.TryParse(raw, out var parsed))
|
||||
return parsed;
|
||||
|
||||
return raw switch
|
||||
{
|
||||
"1" => true,
|
||||
"0" => false,
|
||||
_ => fallback
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ReadOptionalStringFromEnvironment(string name, string? fallback)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(name);
|
||||
if (raw == null)
|
||||
return fallback;
|
||||
|
||||
return string.IsNullOrWhiteSpace(raw) ? string.Empty : raw.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 515c40ff2d019da4985e05bd55a5443f
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class ServerHostPropertiesLoadResult
|
||||
{
|
||||
public required ServerHostOptions Options { get; init; }
|
||||
public required string FilePath { get; init; }
|
||||
public bool CreatedDefaultFile { get; init; }
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public static class ServerHostProperties
|
||||
{
|
||||
public const string DefaultFileName = "server.properties";
|
||||
|
||||
public static ServerHostPropertiesLoadResult LoadOrCreate()
|
||||
{
|
||||
var filePath = ResolveFilePath();
|
||||
var options = new ServerHostOptions();
|
||||
var warnings = new List<string>();
|
||||
var createdDefaultFile = false;
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
File.WriteAllText(filePath, BuildDefaultFile(filePath, options), Encoding.UTF8);
|
||||
createdDefaultFile = true;
|
||||
}
|
||||
|
||||
ApplyFile(filePath, options, warnings);
|
||||
options.ApplyEnvironmentOverrides();
|
||||
NormalizeOptions(options);
|
||||
|
||||
return new ServerHostPropertiesLoadResult
|
||||
{
|
||||
Options = options,
|
||||
FilePath = filePath,
|
||||
CreatedDefaultFile = createdDefaultFile,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveFilePath()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHRINK_SERVER_CONFIG_PATH");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
return Path.GetFullPath(configured.Trim());
|
||||
|
||||
var currentDirectoryCandidate = Path.Combine(Environment.CurrentDirectory, DefaultFileName);
|
||||
if (File.Exists(currentDirectoryCandidate))
|
||||
return currentDirectoryCandidate;
|
||||
|
||||
return ServerHostOptions.ResolveDefaultConfigFilePath();
|
||||
}
|
||||
|
||||
private static void ApplyFile(string filePath, ServerHostOptions options, List<string> warnings)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory;
|
||||
var lines = File.ReadAllLines(filePath);
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
var rawLine = lines[index].Trim();
|
||||
if (string.IsNullOrWhiteSpace(rawLine) || rawLine.StartsWith("#", StringComparison.Ordinal) ||
|
||||
rawLine.StartsWith(";", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var separatorIndex = rawLine.IndexOf('=');
|
||||
if (separatorIndex <= 0)
|
||||
{
|
||||
warnings.Add($"第 {index + 1} 行不是有效的 key=value,将忽略。");
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = rawLine[..separatorIndex].Trim();
|
||||
var value = rawLine[(separatorIndex + 1)..].Trim();
|
||||
ApplyValue(options, directory, key, value, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyValue(ServerHostOptions options, string directory, string key, string value, List<string> warnings)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "server-port":
|
||||
options.Port = ParseInt(value, options.Port, 1, warnings, key);
|
||||
break;
|
||||
case "unity-assets-path":
|
||||
options.UnityAssetsPath = ResolvePath(directory, value, options.UnityAssetsPath);
|
||||
break;
|
||||
case "scan-output-directory":
|
||||
options.ScanOutputDirectory = ResolvePath(directory, value, options.ScanOutputDirectory);
|
||||
break;
|
||||
case "shared-auth-token":
|
||||
options.SharedAuthToken = value;
|
||||
break;
|
||||
case "allow-anonymous-when-auth-token-missing":
|
||||
options.AllowAnonymousWhenAuthTokenMissing = ParseBool(value, options.AllowAnonymousWhenAuthTokenMissing, warnings, key);
|
||||
break;
|
||||
case "enable-session-tokens":
|
||||
options.EnableSessionTokens = ParseBool(value, options.EnableSessionTokens, warnings, key);
|
||||
break;
|
||||
case "session-token-ttl-seconds":
|
||||
options.SessionTokenTtlSeconds = ParseInt(value, options.SessionTokenTtlSeconds, 60, warnings, key);
|
||||
break;
|
||||
case "session-token-refresh-window-seconds":
|
||||
options.SessionTokenRefreshWindowSeconds = ParseInt(value, options.SessionTokenRefreshWindowSeconds, 0, warnings, key);
|
||||
break;
|
||||
case "disconnect-on-invalid-session-token":
|
||||
options.DisconnectOnInvalidSessionToken = ParseBool(value, options.DisconnectOnInvalidSessionToken, warnings, key);
|
||||
break;
|
||||
case "enable-unity-code-scan":
|
||||
options.EnableUnityCodeScan = ParseBool(value, options.EnableUnityCodeScan, warnings, key);
|
||||
break;
|
||||
case "min-protocol-version":
|
||||
options.MinProtocolVersion = ParseInt(value, options.MinProtocolVersion, 1, warnings, key);
|
||||
break;
|
||||
case "max-protocol-version":
|
||||
options.MaxProtocolVersion = ParseInt(value, options.MaxProtocolVersion, 1, warnings, key);
|
||||
break;
|
||||
case "min-schema-version":
|
||||
options.MinSchemaVersion = ParseInt(value, options.MinSchemaVersion, 1, warnings, key);
|
||||
break;
|
||||
case "max-schema-version":
|
||||
options.MaxSchemaVersion = ParseInt(value, options.MaxSchemaVersion, 1, warnings, key);
|
||||
break;
|
||||
case "disconnect-on-protocol-violation":
|
||||
options.DisconnectOnProtocolViolation = ParseBool(value, options.DisconnectOnProtocolViolation, warnings, key);
|
||||
break;
|
||||
case "diagnostics-log-interval-seconds":
|
||||
options.DiagnosticsLogIntervalSeconds = ParseInt(value, options.DiagnosticsLogIntervalSeconds, 0, warnings, key);
|
||||
break;
|
||||
case "enable-tcp-tls":
|
||||
options.EnableTcpTls = ParseBool(value, options.EnableTcpTls, warnings, key);
|
||||
break;
|
||||
case "tcp-tls-target-host":
|
||||
options.TcpTlsTargetHost = NullIfEmpty(value);
|
||||
break;
|
||||
case "tcp-tls-certificate-path":
|
||||
options.TcpTlsCertificatePath = ResolvePath(directory, value, options.TcpTlsCertificatePath);
|
||||
break;
|
||||
case "tcp-tls-certificate-password":
|
||||
options.TcpTlsCertificatePassword = value;
|
||||
break;
|
||||
case "tcp-tls-check-certificate-revocation":
|
||||
options.TcpTlsCheckCertificateRevocation = ParseBool(value, options.TcpTlsCheckCertificateRevocation, warnings, key);
|
||||
break;
|
||||
default:
|
||||
warnings.Add($"未识别的配置键:{key}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeOptions(ServerHostOptions options)
|
||||
{
|
||||
options.SharedAuthToken = NullIfEmpty(options.SharedAuthToken);
|
||||
options.TcpTlsTargetHost = NullIfEmpty(options.TcpTlsTargetHost);
|
||||
options.TcpTlsCertificatePath = NullIfEmpty(options.TcpTlsCertificatePath);
|
||||
options.TcpTlsCertificatePassword = NullIfEmpty(options.TcpTlsCertificatePassword);
|
||||
options.SessionTokenRefreshWindowSeconds = Math.Max(0, options.SessionTokenRefreshWindowSeconds);
|
||||
options.SessionTokenTtlSeconds = Math.Max(60, options.SessionTokenTtlSeconds);
|
||||
options.Port = Math.Clamp(options.Port, 1, 65535);
|
||||
options.DiagnosticsLogIntervalSeconds = Math.Max(0, options.DiagnosticsLogIntervalSeconds);
|
||||
if (options.MaxProtocolVersion < options.MinProtocolVersion)
|
||||
options.MaxProtocolVersion = options.MinProtocolVersion;
|
||||
if (options.MaxSchemaVersion < options.MinSchemaVersion)
|
||||
options.MaxSchemaVersion = options.MinSchemaVersion;
|
||||
if (options.EnableTcpTls && string.IsNullOrWhiteSpace(options.TcpTlsCertificatePath))
|
||||
options.EnableTcpTls = false;
|
||||
}
|
||||
|
||||
private static string BuildDefaultFile(string filePath, ServerHostOptions options)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory;
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# ShrinkNetwork server.properties");
|
||||
builder.AppendLine("# 类似 Minecraft 的 key=value 配置文件。");
|
||||
builder.AppendLine("# 优先级:代码默认值 < 本文件 < 环境变量。");
|
||||
builder.AppendLine("# 修改后需要重启服务器进程。");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# 基础网络");
|
||||
builder.AppendLine($"server-port={options.Port}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# 代码扫描与生成");
|
||||
builder.AppendLine($"unity-assets-path={ToRelativePath(directory, options.UnityAssetsPath)}");
|
||||
builder.AppendLine($"scan-output-directory={ToRelativePath(directory, options.ScanOutputDirectory)}");
|
||||
builder.AppendLine($"enable-unity-code-scan={options.EnableUnityCodeScan.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# 登录与会话");
|
||||
builder.AppendLine("shared-auth-token=");
|
||||
builder.AppendLine($"allow-anonymous-when-auth-token-missing={options.AllowAnonymousWhenAuthTokenMissing.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine($"enable-session-tokens={options.EnableSessionTokens.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine($"session-token-ttl-seconds={options.SessionTokenTtlSeconds}");
|
||||
builder.AppendLine($"session-token-refresh-window-seconds={options.SessionTokenRefreshWindowSeconds}");
|
||||
builder.AppendLine($"disconnect-on-invalid-session-token={options.DisconnectOnInvalidSessionToken.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# 协议兼容");
|
||||
builder.AppendLine($"min-protocol-version={options.MinProtocolVersion}");
|
||||
builder.AppendLine($"max-protocol-version={options.MaxProtocolVersion}");
|
||||
builder.AppendLine($"min-schema-version={options.MinSchemaVersion}");
|
||||
builder.AppendLine($"max-schema-version={options.MaxSchemaVersion}");
|
||||
builder.AppendLine($"disconnect-on-protocol-violation={options.DisconnectOnProtocolViolation.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# 观测");
|
||||
builder.AppendLine($"diagnostics-log-interval-seconds={options.DiagnosticsLogIntervalSeconds}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# TCP TLS");
|
||||
builder.AppendLine($"enable-tcp-tls={options.EnableTcpTls.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine("tcp-tls-target-host=");
|
||||
builder.AppendLine("tcp-tls-certificate-path=");
|
||||
builder.AppendLine("tcp-tls-certificate-password=");
|
||||
builder.AppendLine($"tcp-tls-check-certificate-revocation={options.TcpTlsCheckCertificateRevocation.ToString().ToLowerInvariant()}");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static int ParseInt(string raw, int fallback, int minValue, List<string> warnings, string key)
|
||||
{
|
||||
if (!int.TryParse(raw, out var value))
|
||||
{
|
||||
warnings.Add($"配置 {key} 不是有效整数,将回退为 {fallback}。");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.Max(minValue, value);
|
||||
}
|
||||
|
||||
private static bool ParseBool(string raw, bool fallback, List<string> warnings, string key)
|
||||
{
|
||||
if (TryParseBool(raw, out var parsed))
|
||||
return parsed;
|
||||
|
||||
warnings.Add($"配置 {key} 不是有效布尔值,将回退为 {fallback}。");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static bool TryParseBool(string raw, out bool value)
|
||||
{
|
||||
if (bool.TryParse(raw, out value))
|
||||
return true;
|
||||
|
||||
switch (raw.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "1":
|
||||
case "yes":
|
||||
case "on":
|
||||
value = true;
|
||||
return true;
|
||||
case "0":
|
||||
case "no":
|
||||
case "off":
|
||||
value = false;
|
||||
return true;
|
||||
default:
|
||||
value = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePath(string baseDirectory, string raw, string? fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return fallback ?? string.Empty;
|
||||
|
||||
return Path.GetFullPath(Path.IsPathRooted(raw) ? raw : Path.Combine(baseDirectory, raw));
|
||||
}
|
||||
|
||||
private static string ToRelativePath(string baseDirectory, string targetPath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(baseDirectory, targetPath);
|
||||
return relative.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4f074bc9686c304d8875fa11b191fdb
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,24 @@
|
||||
using ShrinkNetwork;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class ServerModuleContext
|
||||
{
|
||||
private readonly Dictionary<string, ShrinkNetworkService> _services = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ServerModuleContext(ServerHostOptions options, ServerAuthStore authStore)
|
||||
{
|
||||
Options = options;
|
||||
AuthStore = authStore;
|
||||
}
|
||||
|
||||
public ServerHostOptions Options { get; }
|
||||
public ServerAuthStore AuthStore { get; }
|
||||
public UnityNetworkScanManifest? ScanManifest { get; set; }
|
||||
public IReadOnlyDictionary<string, ShrinkNetworkService> Services => _services;
|
||||
|
||||
public void RegisterService(string transportName, ShrinkNetworkService service)
|
||||
{
|
||||
_services[transportName] = service;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e12dcb63434cc454bbee36f180eb6534
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,123 @@
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkNetwork;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class ShrinkDedicatedServerApp
|
||||
{
|
||||
private readonly List<IShrinkServerModule> _modules;
|
||||
private readonly ServerModuleContext _context;
|
||||
|
||||
public ShrinkDedicatedServerApp(ServerHostOptions options, params IShrinkServerModule[] modules)
|
||||
{
|
||||
_modules = modules.ToList();
|
||||
_context = new ServerModuleContext(options, new ServerAuthStore());
|
||||
}
|
||||
|
||||
public ServerModuleContext Context => _context;
|
||||
|
||||
public async UniTask StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_context.Options.EnableUnityCodeScan)
|
||||
{
|
||||
var manifest = UnityNetworkCodeScanner.Scan(_context.Options.UnityAssetsPath);
|
||||
UnityNetworkCodeScanner.WriteOutputs(manifest, _context.Options.ScanOutputDirectory);
|
||||
_context.ScanManifest = manifest;
|
||||
}
|
||||
|
||||
var tcpService = CreateService("TCP");
|
||||
var kcpService = CreateService("KCP");
|
||||
|
||||
_context.RegisterService("TCP", tcpService);
|
||||
_context.RegisterService("KCP", kcpService);
|
||||
|
||||
foreach (var module in _modules)
|
||||
{
|
||||
module.ConfigureService(_context, tcpService, "TCP");
|
||||
module.ConfigureService(_context, kcpService, "KCP");
|
||||
}
|
||||
|
||||
tcpService.BindTransport(new TcpServerTransport(System.Net.IPAddress.Any, _context.Options.Port,
|
||||
tlsOptions: BuildTcpTlsOptions()));
|
||||
kcpService.BindTransport(new KcpServerTransport(System.Net.IPAddress.Any, _context.Options.Port, new ShrinkKcpTransportOptions
|
||||
{
|
||||
Interval = 10,
|
||||
UpdateIntervalMs = 10,
|
||||
IdleTimeoutMs = 15000,
|
||||
HandshakeRetryMs = 250,
|
||||
ConnectTimeoutMs = 4000
|
||||
}));
|
||||
|
||||
foreach (var module in _modules)
|
||||
module.StartAsync(_context, cancellationToken).Forget();
|
||||
|
||||
await UniTask.CompletedTask;
|
||||
}
|
||||
|
||||
private ShrinkNetworkService CreateService(string transportName)
|
||||
{
|
||||
var service = new ShrinkNetworkService(new ShrinkJsonNetworkSerializer(),
|
||||
new ShrinkNetworkMessageRegistry(),
|
||||
new ShrinkNetworkRouter());
|
||||
service.MinProtocolVersion = _context.Options.MinProtocolVersion;
|
||||
service.MaxProtocolVersion = _context.Options.MaxProtocolVersion;
|
||||
service.MinSchemaVersion = _context.Options.MinSchemaVersion;
|
||||
service.MaxSchemaVersion = _context.Options.MaxSchemaVersion;
|
||||
service.DisconnectOnProtocolViolation = _context.Options.DisconnectOnProtocolViolation;
|
||||
service.IncomingPacketValidator = (session, packet) => ValidateIncomingPacket(service, session, packet);
|
||||
|
||||
service.OnSessionConnected += session =>
|
||||
{
|
||||
session.SetPeerKind(ShrinkNetworkPeerKind.Client);
|
||||
ShrinkNetworkLogger.Info($"[{transportName}] Client connected: {session.SessionId} {session.RemoteAddress}");
|
||||
};
|
||||
service.OnSessionDisconnected += session =>
|
||||
{
|
||||
_context.AuthStore.Remove(service, session.SessionId);
|
||||
ShrinkNetworkLogger.Info($"[{transportName}] Client disconnected: {session.SessionId}");
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
private ShrinkIncomingPacketValidationResult ValidateIncomingPacket(
|
||||
ShrinkNetworkService service,
|
||||
ShrinkNetworkSession session,
|
||||
ShrinkNetworkPacket packet)
|
||||
{
|
||||
if (!_context.Options.EnableSessionTokens)
|
||||
return ShrinkIncomingPacketValidationResult.Allow;
|
||||
if (packet.Kind == ShrinkNetworkPacketKind.Response)
|
||||
return ShrinkIncomingPacketValidationResult.Allow;
|
||||
if (string.Equals(packet.Route, AuthServerModule.LoginRoute, StringComparison.Ordinal) ||
|
||||
string.Equals(packet.Route, AuthServerModule.RefreshRoute, StringComparison.Ordinal))
|
||||
return ShrinkIncomingPacketValidationResult.Allow;
|
||||
if (!_context.AuthStore.TryGetState(service, session.SessionId, out _))
|
||||
return ShrinkIncomingPacketValidationResult.Allow;
|
||||
|
||||
if (_context.AuthStore.TryValidateSessionToken(service, session.SessionId, packet.SessionToken, out var authState,
|
||||
out var failureReason))
|
||||
{
|
||||
session.SetSessionToken(authState.SessionToken, authState.SessionTokenExpiresAtUtc);
|
||||
return ShrinkIncomingPacketValidationResult.Allow;
|
||||
}
|
||||
|
||||
return ShrinkIncomingPacketValidationResult.Reject(failureReason,
|
||||
_context.Options.DisconnectOnInvalidSessionToken);
|
||||
}
|
||||
|
||||
private ShrinkTcpTlsOptions? BuildTcpTlsOptions()
|
||||
{
|
||||
if (!_context.Options.EnableTcpTls)
|
||||
return null;
|
||||
|
||||
return new ShrinkTcpTlsOptions
|
||||
{
|
||||
Enabled = true,
|
||||
TargetHost = _context.Options.TcpTlsTargetHost,
|
||||
ServerCertificatePath = _context.Options.TcpTlsCertificatePath,
|
||||
ServerCertificatePassword = _context.Options.TcpTlsCertificatePassword,
|
||||
CheckCertificateRevocation = _context.Options.TcpTlsCheckCertificateRevocation
|
||||
};
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6706e29aa428c5e41a63e978feb7d5a0
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class UnityNetworkScanManifest
|
||||
{
|
||||
public string AssetsPath { get; set; } = string.Empty;
|
||||
public DateTimeOffset ScannedAtUtc { get; set; }
|
||||
public List<UnityNetworkMessageEntry> Messages { get; set; } = new();
|
||||
public List<UnityNetworkSubscriberEntry> Subscribers { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UnityNetworkMessageEntry
|
||||
{
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
public string TypeName { get; set; } = string.Empty;
|
||||
public int Opcode { get; set; }
|
||||
public string Route { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UnityNetworkSubscriberEntry
|
||||
{
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
public string Permission { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class UnityNetworkCodeScanner
|
||||
{
|
||||
private static readonly Regex MessageRegex = new(
|
||||
@"\[ShrinkNetworkMessage\((?<opcode>-?\d+)\s*,\s*""(?<route>[^""]+)""\)\][\s\S]*?(?:class|struct)\s+(?<name>\w+)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex SubscriberRegex = new(
|
||||
@"\[ShrinkNetworkSubscribe\((?<args>.*?)\)\][\s\S]*?(?:UniTask<.*?>|UniTask|void|Task<.*?>|Task)\s+(?<name>\w+)\s*\(",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex AuthorityRegex = new(
|
||||
@"Authority\s*=\s*ShrinkNetworkAuthority\.(?<value>\w+)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex PermissionRegex = new(
|
||||
@"Permission\s*=\s*""(?<value>[^""]+)""",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public static UnityNetworkScanManifest Scan(string assetsPath)
|
||||
{
|
||||
var manifest = new UnityNetworkScanManifest
|
||||
{
|
||||
AssetsPath = assetsPath,
|
||||
ScannedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (!Directory.Exists(assetsPath))
|
||||
return manifest;
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(assetsPath, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
if (file.IndexOf($"{Path.DirectorySeparatorChar}Editor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
continue;
|
||||
|
||||
var content = File.ReadAllText(file);
|
||||
var relativePath = Path.GetRelativePath(assetsPath, file).Replace('\\', '/');
|
||||
|
||||
foreach (Match match in MessageRegex.Matches(content))
|
||||
{
|
||||
manifest.Messages.Add(new UnityNetworkMessageEntry
|
||||
{
|
||||
FilePath = relativePath,
|
||||
TypeName = match.Groups["name"].Value,
|
||||
Opcode = int.Parse(match.Groups["opcode"].Value),
|
||||
Route = match.Groups["route"].Value
|
||||
});
|
||||
}
|
||||
|
||||
foreach (Match match in SubscriberRegex.Matches(content))
|
||||
{
|
||||
var args = match.Groups["args"].Value;
|
||||
manifest.Subscribers.Add(new UnityNetworkSubscriberEntry
|
||||
{
|
||||
FilePath = relativePath,
|
||||
MemberName = match.Groups["name"].Value,
|
||||
Authority = AuthorityRegex.Match(args).Groups["value"].Value,
|
||||
Permission = PermissionRegex.Match(args).Groups["value"].Value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
manifest.Messages = manifest.Messages
|
||||
.OrderBy(item => item.Opcode)
|
||||
.ThenBy(item => item.TypeName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
manifest.Subscribers = manifest.Subscribers
|
||||
.OrderBy(item => item.Permission, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.MemberName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public static void WriteOutputs(UnityNetworkScanManifest manifest, string outputDirectory)
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
var jsonPath = Path.Combine(outputDirectory, "unity-network-scan.json");
|
||||
var markdownPath = Path.Combine(outputDirectory, "UNITY_NETWORK_SCAN.md");
|
||||
|
||||
File.WriteAllText(jsonPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
|
||||
var markdown = new StringBuilder();
|
||||
markdown.AppendLine("# Unity 网络代码扫描清单");
|
||||
markdown.AppendLine();
|
||||
markdown.AppendLine($"扫描时间:{manifest.ScannedAtUtc:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
markdown.AppendLine($"Assets 路径:`{manifest.AssetsPath}`");
|
||||
markdown.AppendLine();
|
||||
markdown.AppendLine("## 网络消息");
|
||||
markdown.AppendLine();
|
||||
|
||||
foreach (var message in manifest.Messages)
|
||||
markdown.AppendLine($"- `{message.Opcode}` `{message.Route}` `{message.TypeName}` [{message.FilePath}]");
|
||||
|
||||
markdown.AppendLine();
|
||||
markdown.AppendLine("## 订阅与权限");
|
||||
markdown.AppendLine();
|
||||
|
||||
if (manifest.Subscribers.Count == 0)
|
||||
{
|
||||
markdown.AppendLine("- 本次扫描未发现 `[ShrinkNetworkSubscribe]`。");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var subscriber in manifest.Subscribers)
|
||||
markdown.AppendLine($"- `{subscriber.MemberName}` authority=`{subscriber.Authority}` permission=`{subscriber.Permission}` [{subscriber.FilePath}]");
|
||||
}
|
||||
|
||||
File.WriteAllText(markdownPath, markdown.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a958cc5087369284e8ec56efb9e33a45
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost;
|
||||
|
||||
using ShrinkNetwork;
|
||||
|
||||
public sealed class KcpServerTransport : IShrinkNetworkAsyncTransport, IShrinkNetworkSessionControlTransport
|
||||
{
|
||||
private sealed class KcpSessionState : IDisposable
|
||||
{
|
||||
public KcpSessionState(long sessionId, IPEndPoint remoteEndPoint, ShrinkKcpPeer peer)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
RemoteEndPoint = remoteEndPoint;
|
||||
Peer = peer;
|
||||
}
|
||||
|
||||
public long SessionId { get; }
|
||||
public IPEndPoint RemoteEndPoint { get; }
|
||||
public ShrinkKcpPeer Peer { get; }
|
||||
public string RemoteAddress => RemoteEndPoint.ToString();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Peer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly Dictionary<long, KcpSessionState> _sessionsById = new();
|
||||
private readonly Dictionary<uint, KcpSessionState> _sessionsByConversationId = new();
|
||||
private readonly UdpClient _udpClient;
|
||||
private readonly ShrinkKcpTransportOptions _options;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private long _sessionIdGenerator;
|
||||
|
||||
public KcpServerTransport(IPAddress ipAddress, int port, ShrinkKcpTransportOptions? options = null)
|
||||
{
|
||||
_udpClient = new UdpClient(new IPEndPoint(ipAddress, port));
|
||||
_options = (options ?? new ShrinkKcpTransportOptions()).Clone();
|
||||
_options.Validate();
|
||||
_udpClient.Client.ReceiveBufferSize = _options.ReceiveBufferSize;
|
||||
}
|
||||
|
||||
public bool IsStarted { get; private set; }
|
||||
|
||||
public event Action<ShrinkNetworkTransportEvent>? OnEvent;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (IsStarted)
|
||||
return;
|
||||
|
||||
IsStarted = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = ReceiveLoopAsync(_cts.Token);
|
||||
_ = UpdateLoopAsync(_cts.Token);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!IsStarted)
|
||||
return;
|
||||
|
||||
IsStarted = false;
|
||||
_cts?.Cancel();
|
||||
|
||||
List<KcpSessionState> sessions;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
sessions = _sessionsById.Values.ToList();
|
||||
_sessionsById.Clear();
|
||||
_sessionsByConversationId.Clear();
|
||||
}
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
TrySendDisconnect(session);
|
||||
session.Dispose();
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(session.SessionId, session.RemoteAddress));
|
||||
}
|
||||
|
||||
_udpClient.Close();
|
||||
}
|
||||
|
||||
public void Send(long sessionId, byte[] packetData)
|
||||
{
|
||||
SendAsync(sessionId, packetData).Forget();
|
||||
}
|
||||
|
||||
public bool DisconnectSession(long sessionId, string? reason = null)
|
||||
{
|
||||
KcpSessionState? session;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_sessionsById.TryGetValue(sessionId, out session);
|
||||
}
|
||||
|
||||
if (session == null)
|
||||
return false;
|
||||
|
||||
DisconnectSession(sessionId, sendRemoteNotice: true, "server-side kick");
|
||||
if (!string.IsNullOrWhiteSpace(reason))
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork][KCP-Server] Disconnect session {sessionId}: {reason}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public UniTask SendAsync(long sessionId, byte[] packetData)
|
||||
{
|
||||
KcpSessionState session;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (!_sessionsById.TryGetValue(sessionId, out session!))
|
||||
throw new InvalidOperationException($"Session {sessionId} is not connected.");
|
||||
}
|
||||
|
||||
session.Peer.Send(packetData ?? Array.Empty<byte>());
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
UdpReceiveResult result;
|
||||
try
|
||||
{
|
||||
result = await _udpClient.ReceiveAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!IsStarted)
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
HandleDatagram(result.Buffer, result.RemoteEndPoint);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShrinkNetworkLogger.Exception(ex);
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] KCP server datagram handling failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
List<KcpSessionState> snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
snapshot = _sessionsById.Values.ToList();
|
||||
}
|
||||
|
||||
foreach (var session in snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
session.Peer.Tick(packet => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(session.SessionId, packet)));
|
||||
|
||||
if (DateTime.UtcNow.Ticks - session.Peer.LastReceiveUtcTicks >
|
||||
TimeSpan.FromMilliseconds(_options.IdleTimeoutMs).Ticks)
|
||||
{
|
||||
DisconnectSession(session.SessionId, sendRemoteNotice: true, "idle timeout");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShrinkNetworkLogger.Exception(ex);
|
||||
DisconnectSession(session.SessionId, sendRemoteNotice: true, $"peer tick failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(_options.UpdateIntervalMs, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDatagram(byte[] datagram, IPEndPoint remoteEndPoint)
|
||||
{
|
||||
if (datagram == null || datagram.Length == 0)
|
||||
return;
|
||||
|
||||
if (ShrinkKcpTransportProtocol.TryReadConnectRequest(datagram, out var nonce, out var requestedConversationId))
|
||||
{
|
||||
HandleConnectRequest(remoteEndPoint, nonce, requestedConversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShrinkKcpTransportProtocol.TryReadDisconnect(datagram, out var disconnectedConversationId))
|
||||
{
|
||||
HandleDisconnect(remoteEndPoint, disconnectedConversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ShrinkKcpTransportProtocol.TryReadDataPacket(datagram, out var conversationId, out var payloadOffset,
|
||||
out var payloadLength))
|
||||
return;
|
||||
|
||||
KcpSessionState? session;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_sessionsByConversationId.TryGetValue(conversationId, out session);
|
||||
}
|
||||
|
||||
if (session == null)
|
||||
return;
|
||||
if (!Equals(session.RemoteEndPoint, remoteEndPoint))
|
||||
return;
|
||||
|
||||
session.Peer.Input(datagram, payloadOffset, payloadLength,
|
||||
packet => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(session.SessionId, packet)));
|
||||
}
|
||||
|
||||
private void HandleConnectRequest(IPEndPoint remoteEndPoint, long nonce, uint requestedConversationId)
|
||||
{
|
||||
KcpSessionState? existingSession;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
existingSession = _sessionsById.Values.FirstOrDefault(x => Equals(x.RemoteEndPoint, remoteEndPoint));
|
||||
}
|
||||
|
||||
if (existingSession != null)
|
||||
{
|
||||
ShrinkNetworkLogger.Info(
|
||||
$"[ShrinkNetwork][KCP-Server] Reusing session {existingSession.SessionId} for endpoint {existingSession.RemoteAddress}.");
|
||||
SendEnvelope(existingSession.RemoteEndPoint,
|
||||
ShrinkKcpTransportProtocol.CreateConnectAccept(nonce, existingSession.Peer.ConversationId));
|
||||
return;
|
||||
}
|
||||
|
||||
var conversationId = AllocateConversationId(requestedConversationId);
|
||||
var sessionId = Interlocked.Increment(ref _sessionIdGenerator);
|
||||
var peer = new ShrinkKcpPeer(conversationId, _options,
|
||||
payload => SendEnvelope(remoteEndPoint, ShrinkKcpTransportProtocol.CreateDataPacket(conversationId, payload)));
|
||||
var session = new KcpSessionState(sessionId, remoteEndPoint, peer);
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_sessionsById[sessionId] = session;
|
||||
_sessionsByConversationId[conversationId] = session;
|
||||
}
|
||||
|
||||
SendEnvelope(remoteEndPoint, ShrinkKcpTransportProtocol.CreateConnectAccept(nonce, conversationId));
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, remoteEndPoint.ToString()));
|
||||
}
|
||||
|
||||
private void HandleDisconnect(IPEndPoint remoteEndPoint, uint conversationId)
|
||||
{
|
||||
KcpSessionState? session;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_sessionsByConversationId.TryGetValue(conversationId, out session);
|
||||
}
|
||||
|
||||
if (session == null)
|
||||
return;
|
||||
if (!Equals(session.RemoteEndPoint, remoteEndPoint))
|
||||
return;
|
||||
|
||||
DisconnectSession(session.SessionId, sendRemoteNotice: false, "remote disconnect packet");
|
||||
}
|
||||
|
||||
private void DisconnectSession(long sessionId, bool sendRemoteNotice, string reason)
|
||||
{
|
||||
KcpSessionState? session;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (!_sessionsById.Remove(sessionId, out session))
|
||||
return;
|
||||
|
||||
_sessionsByConversationId.Remove(session.Peer.ConversationId);
|
||||
}
|
||||
|
||||
if (sendRemoteNotice)
|
||||
TrySendDisconnect(session);
|
||||
|
||||
ShrinkNetworkLogger.Warn(
|
||||
$"[ShrinkNetwork][KCP-Server] Session {sessionId} {session.RemoteAddress} disconnected: {reason}");
|
||||
session.Dispose();
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, session.RemoteAddress));
|
||||
}
|
||||
|
||||
private uint AllocateConversationId(uint requestedConversationId)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (requestedConversationId != 0 && !_sessionsByConversationId.ContainsKey(requestedConversationId))
|
||||
return requestedConversationId;
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
var conversationId = unchecked((uint)RandomNumberGenerator.GetInt32(1, int.MaxValue));
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (!_sessionsByConversationId.ContainsKey(conversationId))
|
||||
return conversationId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TrySendDisconnect(KcpSessionState session)
|
||||
{
|
||||
try
|
||||
{
|
||||
SendEnvelope(session.RemoteEndPoint, ShrinkKcpTransportProtocol.CreateDisconnect(session.Peer.ConversationId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void SendEnvelope(IPEndPoint remoteEndPoint, byte[] datagram)
|
||||
{
|
||||
_udpClient.Send(datagram, datagram.Length, remoteEndPoint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 22712a66ebe45004494fc762ea5f3de4
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Reflection;
|
||||
using ShrinkNetwork;
|
||||
using ShrinkNetwork.ServerHost;
|
||||
using ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
ShrinkNetworkLogger.InfoHandler = msg => Console.WriteLine(msg);
|
||||
ShrinkNetworkLogger.WarningHandler = msg => Console.WriteLine("[Warn] " + msg);
|
||||
ShrinkNetworkLogger.ErrorHandler = msg => Console.Error.WriteLine(msg);
|
||||
ShrinkNetworkLogger.ExceptionHandler = ex => Console.Error.WriteLine(ex);
|
||||
|
||||
var config = ServerHostProperties.LoadOrCreate();
|
||||
var options = config.Options;
|
||||
var modules = DiscoverModules();
|
||||
var app = new ShrinkDedicatedServerApp(options, modules);
|
||||
|
||||
await app.StartAsync();
|
||||
|
||||
Console.WriteLine("ShrinkNetwork 独立服务器模板已启动。");
|
||||
Console.WriteLine($"配置文件:{config.FilePath}");
|
||||
Console.WriteLine($"首次生成默认配置:{config.CreatedDefaultFile}");
|
||||
foreach (var warning in config.Warnings)
|
||||
Console.WriteLine($"[Warn] 配置文件:{warning}");
|
||||
Console.WriteLine($"TCP: 0.0.0.0:{options.Port}");
|
||||
Console.WriteLine($"KCP: 0.0.0.0:{options.Port}");
|
||||
Console.WriteLine($"Unity 扫描输出目录:{options.ScanOutputDirectory}");
|
||||
Console.WriteLine($"Unity 资产扫描目录:{options.UnityAssetsPath}");
|
||||
Console.WriteLine($"共享口令鉴权已启用:{!string.IsNullOrWhiteSpace(options.SharedAuthToken)}");
|
||||
Console.WriteLine($"缺少口令时允许匿名登录:{options.AllowAnonymousWhenAuthTokenMissing}");
|
||||
Console.WriteLine($"会话令牌已启用:{options.EnableSessionTokens}");
|
||||
Console.WriteLine($"会话令牌 TTL(秒):{options.SessionTokenTtlSeconds}");
|
||||
Console.WriteLine($"会话令牌续期窗口(秒):{options.SessionTokenRefreshWindowSeconds}");
|
||||
Console.WriteLine($"会话令牌校验失败时断开会话:{options.DisconnectOnInvalidSessionToken}");
|
||||
Console.WriteLine($"协议版本范围:{options.MinProtocolVersion}-{options.MaxProtocolVersion}");
|
||||
Console.WriteLine($"Schema 版本范围:{options.MinSchemaVersion}-{options.MaxSchemaVersion}");
|
||||
Console.WriteLine($"TCP TLS 已启用:{options.EnableTcpTls}");
|
||||
if (options.EnableTcpTls)
|
||||
{
|
||||
Console.WriteLine($"TCP TLS 证书路径:{options.TcpTlsCertificatePath}");
|
||||
Console.WriteLine($"TCP TLS SNI/目标主机:{options.TcpTlsTargetHost}");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(options.SharedAuthToken) && !options.AllowAnonymousWhenAuthTokenMissing)
|
||||
{
|
||||
Console.WriteLine("[Warn] 当前未配置 SHRINK_SERVER_AUTH_TOKEN,登录请求默认会被拒绝。");
|
||||
}
|
||||
Console.WriteLine("[Info] 配置优先级:代码默认值 < server.properties < 环境变量");
|
||||
Console.WriteLine("已加载模块:");
|
||||
foreach (var module in modules)
|
||||
Console.WriteLine($"- {module.Name}");
|
||||
Console.WriteLine("按 Ctrl+C 退出。");
|
||||
|
||||
using var metricsCts = new CancellationTokenSource();
|
||||
if (options.DiagnosticsLogIntervalSeconds > 0)
|
||||
_ = RunDiagnosticsLoopAsync(app, options.DiagnosticsLogIntervalSeconds, metricsCts.Token);
|
||||
|
||||
Console.CancelKeyPress += (_, args) =>
|
||||
{
|
||||
args.Cancel = true;
|
||||
metricsCts.Cancel();
|
||||
DumpDiagnostics(app);
|
||||
Environment.Exit(0);
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, metricsCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
static IShrinkServerModule[] DiscoverModules()
|
||||
{
|
||||
return Assembly.GetExecutingAssembly()
|
||||
.GetTypes()
|
||||
.Where(type => typeof(IShrinkServerModule).IsAssignableFrom(type))
|
||||
.Where(type => !type.IsAbstract && !type.IsInterface)
|
||||
.Where(type => type.GetConstructor(Type.EmptyTypes) != null)
|
||||
.Select(type => (IShrinkServerModule)Activator.CreateInstance(type)!)
|
||||
.OrderBy(module => module.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
static async Task RunDiagnosticsLoopAsync(ShrinkDedicatedServerApp app, int intervalSeconds, CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
DumpDiagnostics(app);
|
||||
}
|
||||
}
|
||||
|
||||
static void DumpDiagnostics(ShrinkDedicatedServerApp app)
|
||||
{
|
||||
Console.WriteLine("[Metrics] ShrinkNetwork service snapshots:");
|
||||
foreach (var pair in app.Context.Services.OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var metrics = pair.Value.GetDiagnosticsSnapshot();
|
||||
Console.WriteLine(
|
||||
$"[Metrics][{pair.Key}] sessions={metrics.CurrentSessions} connected={metrics.SessionsConnected} disconnected={metrics.SessionsDisconnected} sent={metrics.PacketsSent}/{metrics.BytesSent}B recv={metrics.PacketsReceived}/{metrics.BytesReceived}B rpc={metrics.RpcStarted}/{metrics.RpcCompleted} timeout={metrics.RpcTimedOut} canceled={metrics.RpcCanceled} failed={metrics.RpcFailed} proto={metrics.ProtocolViolations} authRejected={metrics.AuthRejectedCount} denied={metrics.PermissionDeniedCount} handlerEx={metrics.HandlerExceptionCount} unknownOpcode={metrics.UnknownOpcodeCount} dispatchMiss={metrics.DispatchMissCount} serialization={metrics.SerializationErrorCount} queueReject={metrics.DispatchQueueRejectedCount}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 088e5ef7fb8b5014d9a5afcbb1441d4b
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,143 @@
|
||||
# ShrinkNetwork.ServerHost
|
||||
|
||||
这是由 `ShrinkNetwork` 插件生成的独立服务器基础模板。
|
||||
|
||||
默认提供:
|
||||
- 可编译、可启动的宿主入口
|
||||
- TCP / KCP 双传输监听
|
||||
- 类似 Minecraft 的 `server.properties` 文本配置
|
||||
- Unity 项目扫描后的 `Generated/*` 自动装配
|
||||
- 基础鉴权、会话令牌、协议版本闸门与周期性指标摘要
|
||||
|
||||
## 模板结构
|
||||
|
||||
- `Program.cs`
|
||||
宿主入口,负责加载 `server.properties`、启动模块、打印配置摘要和输出指标。
|
||||
- `Framework/`
|
||||
宿主框架层,包括配置、鉴权状态存储与应用启动器。
|
||||
- `AuthServerModule.cs`
|
||||
内置鉴权模块,提供 `server/auth/login` 与 `server/auth/refresh`。
|
||||
- `TcpServerTransport.cs`
|
||||
TCP 传输层,可选 TLS。
|
||||
- `KcpServerTransport.cs`
|
||||
KCP 传输层。
|
||||
- `Generated/`
|
||||
Unity 扫描后产出的项目专属合约、副本、处理器与模块入口。
|
||||
|
||||
## 生成后你要关注的文件
|
||||
|
||||
- `Generated/UnityGeneratedNetworkContracts.g.cs`
|
||||
当前 Unity 项目扫描得到的消息合约副本。
|
||||
- `Generated/UnityGeneratedServerHandlers.g.cs`
|
||||
自动生成的处理器骨架。
|
||||
- `Generated/UnityGeneratedServerModule.g.cs`
|
||||
自动加载的项目专属服务器模块。
|
||||
|
||||
说明:
|
||||
- `UnityGeneratedServerModule.g.cs` 是项目级生成物,不是通用插件内置玩法模块。
|
||||
- 如果你有正式业务逻辑,建议把自动生成骨架整理成你自己的正式模块文件。
|
||||
|
||||
## 鉴权
|
||||
|
||||
## 配置文件
|
||||
|
||||
- 默认配置文件名:`server.properties`
|
||||
- 默认位置:`GeneratedServers/ShrinkNetwork.ServerHost/server.properties`
|
||||
- 首次启动如果文件不存在,宿主会自动生成一份默认配置。
|
||||
- 优先级:代码默认值 < `server.properties` < 环境变量
|
||||
- 可通过环境变量 `SHRINK_SERVER_CONFIG_PATH` 指向自定义配置文件路径。
|
||||
|
||||
常用键:
|
||||
- `server-port`
|
||||
- `shared-auth-token`
|
||||
- `enable-session-tokens`
|
||||
- `session-token-ttl-seconds`
|
||||
- `session-token-refresh-window-seconds`
|
||||
- `enable-unity-code-scan`
|
||||
- `unity-assets-path`
|
||||
- `scan-output-directory`
|
||||
- `enable-tcp-tls`
|
||||
- `tcp-tls-certificate-path`
|
||||
|
||||
环境变量:
|
||||
- `SHRINK_SERVER_AUTH_TOKEN`
|
||||
|
||||
行为:
|
||||
- 默认情况下,如果未设置 `SHRINK_SERVER_AUTH_TOKEN`,`server/auth/login` 会直接拒绝,避免把匿名放行作为线上默认值。
|
||||
- 如果你明确要跑内网演示,可在 `ServerHostOptions.AllowAnonymousWhenAuthTokenMissing = true` 后再允许匿名登录。
|
||||
- 设置口令后,客户端应先完成登录,再进入后续项目逻辑。
|
||||
|
||||
## 会话令牌
|
||||
|
||||
默认行为:
|
||||
- `server/auth/login` 成功后,宿主会签发一个内存态会话令牌,并把它放进响应包头与响应体。
|
||||
- 已登录会话的后续消息 / RPC 必须携带当前会话令牌,否则会被宿主拒绝;默认还会直接断开该会话。
|
||||
- 客户端可通过 `server/auth/refresh` 在续期窗口内轮换新令牌,避免长连接在固定 TTL 后硬过期。
|
||||
|
||||
关键配置:
|
||||
- `ServerHostOptions.EnableSessionTokens`
|
||||
- `ServerHostOptions.SessionTokenTtlSeconds`
|
||||
- `ServerHostOptions.SessionTokenRefreshWindowSeconds`
|
||||
- `ServerHostOptions.DisconnectOnInvalidSessionToken`
|
||||
|
||||
对应环境变量:
|
||||
- `SHRINK_SERVER_ENABLE_SESSION_TOKENS`
|
||||
- `SHRINK_SERVER_SESSION_TOKEN_TTL_SECONDS`
|
||||
- `SHRINK_SERVER_SESSION_TOKEN_REFRESH_WINDOW_SECONDS`
|
||||
- `SHRINK_SERVER_DISCONNECT_ON_INVALID_SESSION_TOKEN`
|
||||
|
||||
当前范围:
|
||||
- 已覆盖“登录签发 / 包头自动携带 / 服务端逐包校验 / 显式刷新”
|
||||
- 还没有接入外部身份源、分布式会话存储、多实例共享撤销表
|
||||
|
||||
## 协议兼容与指标
|
||||
|
||||
- 模板宿主默认只接受当前 `ShrinkNetworkProtocol.CurrentProtocolVersion / CurrentSchemaVersion`。
|
||||
- 可通过 `ServerHostOptions.MinProtocolVersion / MaxProtocolVersion / MinSchemaVersion / MaxSchemaVersion` 调整兼容窗口。
|
||||
- 检测到协议版本越界时,默认直接断开会话。
|
||||
- 宿主默认每 `60` 秒输出一次基础指标摘要,也会在 `Ctrl+C` 退出前打印最后一份快照。
|
||||
- 指标摘要现已包含 `authRejected`,可直接看到会话令牌校验失败次数。
|
||||
|
||||
## TCP TLS
|
||||
|
||||
- 如果设置了 `SHRINK_SERVER_TLS_CERT_PATH`,宿主会自动为 TCP 监听启用 TLS。
|
||||
- 可选环境变量:
|
||||
- `SHRINK_SERVER_TLS_CERT_PASSWORD`
|
||||
- `SHRINK_SERVER_TLS_TARGET_HOST`
|
||||
- 当前只覆盖“服务端证书 + 客户端校验”主链,还没有扩展到双向证书认证。
|
||||
|
||||
## 运行方式
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
dotnet run --project .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj
|
||||
```
|
||||
|
||||
如果只想改文本配置,不想改环境变量,直接编辑:
|
||||
|
||||
```powershell
|
||||
notepad .\GeneratedServers\ShrinkNetwork.ServerHost\server.properties
|
||||
```
|
||||
|
||||
如果要启用共享口令鉴权:
|
||||
|
||||
```powershell
|
||||
$env:SHRINK_SERVER_AUTH_TOKEN = "your-token"
|
||||
dotnet run --project .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj
|
||||
```
|
||||
|
||||
如果要直接跑运行时烟测:
|
||||
|
||||
```powershell
|
||||
$env:SHRINK_SERVER_AUTH_TOKEN = "smoke-token"
|
||||
$env:SHRINK_SERVER_SESSION_TOKEN_TTL_SECONDS = "120"
|
||||
$env:SHRINK_SERVER_SESSION_TOKEN_REFRESH_WINDOW_SECONDS = "120"
|
||||
dotnet run --project .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj
|
||||
```
|
||||
|
||||
另开一个终端执行:
|
||||
|
||||
```powershell
|
||||
dotnet run --project .\GeneratedServers\ShrinkNetwork.RuntimeSmoke\ShrinkNetwork.RuntimeSmoke.csproj -- 127.0.0.1 17777 smoke-token runtime-smoke
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 105169ed61f386646a3f1562026c28ec
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Kcp-CSharp" Version="1.0.8" />
|
||||
<PackageReference Include="UniTask" Version="2.5.10" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Serialization\IShrinkNetworkSerializer.cs" Link="Runtime\Serialization\IShrinkNetworkSerializer.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Abstractions\IShrinkNetworkAsyncTransport.cs" Link="Runtime\Transport\Abstractions\IShrinkNetworkAsyncTransport.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Abstractions\IShrinkNetworkTransport.cs" Link="Runtime\Transport\Abstractions\IShrinkNetworkTransport.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Tcp\ShrinkTcpTlsOptions.cs" Link="Runtime\Transport\Tcp\ShrinkTcpTlsOptions.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Serialization\ShrinkJsonNetworkSerializer.cs" Link="Runtime\Serialization\ShrinkJsonNetworkSerializer.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Kcp\ShrinkKcpPeer.cs" Link="Runtime\Transport\Kcp\ShrinkKcpPeer.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Kcp\ShrinkKcpTransportOptions.cs" Link="Runtime\Transport\Kcp\ShrinkKcpTransportOptions.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Kcp\ShrinkKcpTransportProtocol.cs" Link="Runtime\Transport\Kcp\ShrinkKcpTransportProtocol.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Serialization\ShrinkMessagePackNetworkSerializer.cs" Link="Runtime\Serialization\ShrinkMessagePackNetworkSerializer.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkAttributes.cs" Link="Runtime\Metadata\ShrinkNetworkAttributes.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Core\ShrinkNetworkContext.cs" Link="Runtime\Core\ShrinkNetworkContext.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Core\ShrinkNetworkLogger.cs" Link="Runtime\Core\ShrinkNetworkLogger.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkMessageContracts.cs" Link="Runtime\Metadata\ShrinkNetworkMessageContracts.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Routing\ShrinkNetworkMessageRegistry.cs" Link="Runtime\Routing\ShrinkNetworkMessageRegistry.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkPacket.cs" Link="Runtime\Metadata\ShrinkNetworkPacket.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkPermissions.cs" Link="Runtime\Metadata\ShrinkNetworkPermissions.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Routing\ShrinkNetworkRegHelper.cs" Link="Runtime\Routing\ShrinkNetworkRegHelper.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Routing\ShrinkNetworkGeneratedRegistry.cs" Link="Runtime\Routing\ShrinkNetworkGeneratedRegistry.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Routing\ShrinkNetworkRouter.cs" Link="Runtime\Routing\ShrinkNetworkRouter.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkRpc.cs" Link="Runtime\Metadata\ShrinkNetworkRpc.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Core\ShrinkNetworkService.cs" Link="Runtime\Core\ShrinkNetworkService.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Core\ShrinkNetworkSession.cs" Link="Runtime\Core\ShrinkNetworkSession.cs" />
|
||||
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkTransportEvent.cs" Link="Runtime\Metadata\ShrinkNetworkTransportEvent.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b2ae563558cfd04cb1511ec9a57ea16
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost;
|
||||
|
||||
using ShrinkNetwork;
|
||||
|
||||
public sealed class TcpServerTransport : IShrinkNetworkAsyncTransport, IShrinkNetworkSessionControlTransport
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, TcpClient> _clients = new();
|
||||
private readonly ConcurrentDictionary<long, SemaphoreSlim> _sendLocks = new();
|
||||
private readonly ConcurrentDictionary<long, Stream> _streams = new();
|
||||
private readonly TcpListener _listener;
|
||||
private readonly int _maxPacketSize;
|
||||
private readonly ShrinkTcpTlsOptions? _tlsOptions;
|
||||
private long _sessionIdGenerator;
|
||||
private CancellationTokenSource? _cts;
|
||||
private X509Certificate2? _serverCertificate;
|
||||
|
||||
public TcpServerTransport(IPAddress ipAddress, int port, int maxPacketSize = 64 * 1024,
|
||||
ShrinkTcpTlsOptions? tlsOptions = null)
|
||||
{
|
||||
if (maxPacketSize <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxPacketSize));
|
||||
|
||||
_listener = new TcpListener(ipAddress, port);
|
||||
_maxPacketSize = maxPacketSize;
|
||||
_tlsOptions = tlsOptions?.Clone();
|
||||
}
|
||||
|
||||
public bool IsStarted { get; private set; }
|
||||
|
||||
public event Action<ShrinkNetworkTransportEvent>? OnEvent;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (IsStarted)
|
||||
return;
|
||||
|
||||
IsStarted = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
EnsureTlsCertificateLoaded();
|
||||
_listener.Start();
|
||||
_ = AcceptLoopAsync(_cts.Token);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!IsStarted)
|
||||
return;
|
||||
|
||||
IsStarted = false;
|
||||
_cts?.Cancel();
|
||||
_listener.Stop();
|
||||
|
||||
foreach (var pair in _clients)
|
||||
{
|
||||
pair.Value.Close();
|
||||
}
|
||||
|
||||
_clients.Clear();
|
||||
|
||||
foreach (var pair in _streams)
|
||||
pair.Value.Dispose();
|
||||
|
||||
_streams.Clear();
|
||||
|
||||
foreach (var pair in _sendLocks)
|
||||
pair.Value.Dispose();
|
||||
|
||||
_sendLocks.Clear();
|
||||
}
|
||||
|
||||
public void Send(long sessionId, byte[] packetData)
|
||||
{
|
||||
SendAsync(sessionId, packetData).Forget();
|
||||
}
|
||||
|
||||
public bool DisconnectSession(long sessionId, string? reason = null)
|
||||
{
|
||||
if (!_clients.TryGetValue(sessionId, out var client))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
if (_streams.TryRemove(sessionId, out var stream))
|
||||
stream.Dispose();
|
||||
client.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reason))
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork][TCP-Server] Disconnect session {sessionId}: {reason}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public async UniTask SendAsync(long sessionId, byte[] packetData)
|
||||
{
|
||||
if (!_clients.TryGetValue(sessionId, out var client))
|
||||
throw new InvalidOperationException($"Session {sessionId} is not connected.");
|
||||
if (!_streams.TryGetValue(sessionId, out var stream))
|
||||
throw new InvalidOperationException($"Session {sessionId} stream is not initialized.");
|
||||
|
||||
if (!_sendLocks.TryGetValue(sessionId, out var sendLock))
|
||||
throw new InvalidOperationException($"Session {sessionId} send lock is not initialized.");
|
||||
if ((packetData?.Length ?? 0) > _maxPacketSize)
|
||||
throw new InvalidOperationException($"TCP packet is too large. Size={(packetData?.Length ?? 0)}, Limit={_maxPacketSize}.");
|
||||
|
||||
await SendInternalAsync(stream, sendLock, packetData ?? Array.Empty<byte>(), CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try
|
||||
{
|
||||
client = await _listener.AcceptTcpClientAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!IsStarted)
|
||||
break;
|
||||
|
||||
ShrinkNetworkLogger.Warn("[ShrinkNetwork][TCP-Server] Accept failed, retrying.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var sessionId = Interlocked.Increment(ref _sessionIdGenerator);
|
||||
client.NoDelay = true;
|
||||
var stream = await CreateServerStreamAsync(client, cancellationToken);
|
||||
_clients[sessionId] = client;
|
||||
_streams[sessionId] = stream;
|
||||
_sendLocks[sessionId] = new SemaphoreSlim(1, 1);
|
||||
var remoteAddress = client.Client.RemoteEndPoint?.ToString() ?? "unknown";
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, remoteAddress));
|
||||
_ = ReceiveLoopAsync(sessionId, client, stream, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Stream> CreateServerStreamAsync(TcpClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
Stream stream = client.GetStream();
|
||||
var tlsOptions = _tlsOptions;
|
||||
if (tlsOptions == null || !tlsOptions.Enabled)
|
||||
return stream;
|
||||
|
||||
if (_serverCertificate == null)
|
||||
throw new InvalidOperationException("TCP TLS is enabled, but the server certificate is not loaded.");
|
||||
|
||||
var sslStream = new SslStream(stream, false);
|
||||
await sslStream.AuthenticateAsServerAsync(_serverCertificate, false, tlsOptions.EnabledProtocols,
|
||||
tlsOptions.CheckCertificateRevocation);
|
||||
return sslStream;
|
||||
}
|
||||
|
||||
private void EnsureTlsCertificateLoaded()
|
||||
{
|
||||
var tlsOptions = _tlsOptions;
|
||||
if (tlsOptions == null || !tlsOptions.Enabled)
|
||||
{
|
||||
_serverCertificate = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tlsOptions.ServerCertificatePath))
|
||||
throw new InvalidOperationException("TCP TLS is enabled, but ServerCertificatePath is empty.");
|
||||
|
||||
_serverCertificate = new X509Certificate2(tlsOptions.ServerCertificatePath, tlsOptions.ServerCertificatePassword);
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(long sessionId, TcpClient client, Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
Exception? disconnectException = null;
|
||||
try
|
||||
{
|
||||
var lengthBuffer = new byte[4];
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await ReadExactlyAsync(stream, lengthBuffer, cancellationToken);
|
||||
var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer);
|
||||
if (length <= 0)
|
||||
throw new InvalidDataException($"Invalid packet length: {length}");
|
||||
if (length > _maxPacketSize)
|
||||
throw new InvalidDataException($"TCP packet length exceeded limit. Length={length}, Limit={_maxPacketSize}");
|
||||
|
||||
var payload = new byte[length];
|
||||
await ReadExactlyAsync(stream, payload, cancellationToken);
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(sessionId, payload));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
disconnectException = ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_clients.TryRemove(sessionId, out var removed))
|
||||
{
|
||||
if (_streams.TryRemove(sessionId, out var ownedStream))
|
||||
ownedStream.Dispose();
|
||||
if (_sendLocks.TryRemove(sessionId, out var sendLock))
|
||||
sendLock.Dispose();
|
||||
|
||||
var remoteAddress = removed.Client.RemoteEndPoint?.ToString() ?? "unknown";
|
||||
if (disconnectException != null)
|
||||
{
|
||||
ShrinkNetworkLogger.Warn(
|
||||
$"[ShrinkNetwork][TCP-Server] Session {sessionId} {remoteAddress} disconnected: {disconnectException.GetType().Name}: {disconnectException.Message}");
|
||||
}
|
||||
|
||||
removed.Close();
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, remoteAddress));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendInternalAsync(Stream stream, SemaphoreSlim sendLock, byte[] packetData,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await sendLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var header = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header, packetData.Length);
|
||||
await stream.WriteAsync(header, cancellationToken);
|
||||
if (packetData.Length > 0)
|
||||
await stream.WriteAsync(packetData, cancellationToken);
|
||||
await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken);
|
||||
if (read <= 0)
|
||||
throw new IOException("Remote closed.");
|
||||
offset += read;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6083285ae11674f40b2db7dba5818bfa
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,37 @@
|
||||
# ShrinkNetwork server.properties
|
||||
# 类似 Minecraft 的 key=value 配置文件。
|
||||
# 优先级:代码默认值 < 本文件 < 环境变量。
|
||||
# 修改后需要重启服务器进程。
|
||||
|
||||
# 基础网络
|
||||
server-port=17777
|
||||
|
||||
# 代码扫描与生成
|
||||
unity-assets-path=../../Assets
|
||||
scan-output-directory=Generated
|
||||
enable-unity-code-scan=true
|
||||
|
||||
# 登录与会话
|
||||
shared-auth-token=
|
||||
allow-anonymous-when-auth-token-missing=false
|
||||
enable-session-tokens=true
|
||||
session-token-ttl-seconds=1800
|
||||
session-token-refresh-window-seconds=300
|
||||
disconnect-on-invalid-session-token=true
|
||||
|
||||
# 协议兼容
|
||||
min-protocol-version=1
|
||||
max-protocol-version=1
|
||||
min-schema-version=1
|
||||
max-schema-version=1
|
||||
disconnect-on-protocol-violation=true
|
||||
|
||||
# 观测
|
||||
diagnostics-log-interval-seconds=60
|
||||
|
||||
# TCP TLS
|
||||
enable-tcp-tls=false
|
||||
tcp-tls-target-host=
|
||||
tcp-tls-certificate-path=
|
||||
tcp-tls-certificate-password=
|
||||
tcp-tls-check-certificate-revocation=false
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f19321a4ef92c44409dc7f9a14fe3e57
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f19393d389423f41b6e04815cedb1fc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,417 @@
|
||||
#if UNITY_EDITOR
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using UnityEditor.Compilation;
|
||||
|
||||
internal sealed class ShrinkNetworkSemanticScanResult
|
||||
{
|
||||
public List<ShrinkDedicatedServerScaffoldGenerator.MessageSpec> Messages { get; } = new();
|
||||
public List<ShrinkDedicatedServerScaffoldGenerator.SubscriberSpec> Subscribers { get; } = new();
|
||||
public List<ShrinkDedicatedServerScaffoldGenerator.EnumSpec> Enums { get; } = new();
|
||||
public List<ShrinkDedicatedServerScaffoldGenerator.DataTypeSpec> DataTypes { get; } = new();
|
||||
}
|
||||
|
||||
internal static class ShrinkNetworkSemanticScanner
|
||||
{
|
||||
private const string MessageAttributeName = "ShrinkNetwork.ShrinkNetworkMessageAttribute";
|
||||
private const string StateSyncAttributeName = "ShrinkNetwork.ShrinkNetworkStateSyncAttribute";
|
||||
private const string SubscribeAttributeName = "ShrinkNetwork.ShrinkNetworkSubscribeAttribute";
|
||||
private const string MessageInterfaceName = "ShrinkNetwork.IShrinkNetworkMessage";
|
||||
private const string RequestInterfaceName = "ShrinkNetwork.IShrinkNetworkRequest";
|
||||
private const string ResponseBaseName = "ShrinkNetwork.ShrinkRpcResponseBase";
|
||||
private const string ResultEventInterfaceName = "ShrinkEventBus.IShrinkResultEvent`1";
|
||||
private const string NetworkEventAttributeName = "ShrinkNetwork.Integration.EventBus.ShrinkNetworkEventAttribute";
|
||||
private const string DeltaEventInterfaceName = "ShrinkNetwork.Integration.EventBus.IShrinkNetworkDeltaEvent";
|
||||
|
||||
private static readonly Dictionary<Type, string> TypeAliases = new()
|
||||
{
|
||||
[typeof(void)] = "void",
|
||||
[typeof(bool)] = "bool",
|
||||
[typeof(byte)] = "byte",
|
||||
[typeof(sbyte)] = "sbyte",
|
||||
[typeof(short)] = "short",
|
||||
[typeof(ushort)] = "ushort",
|
||||
[typeof(int)] = "int",
|
||||
[typeof(uint)] = "uint",
|
||||
[typeof(long)] = "long",
|
||||
[typeof(ulong)] = "ulong",
|
||||
[typeof(float)] = "float",
|
||||
[typeof(double)] = "double",
|
||||
[typeof(decimal)] = "decimal",
|
||||
[typeof(char)] = "char",
|
||||
[typeof(string)] = "string",
|
||||
[typeof(object)] = "object"
|
||||
};
|
||||
|
||||
internal static ShrinkNetworkSemanticScanResult ScanCompiledPlayerAssemblies()
|
||||
{
|
||||
var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.Where(assembly => !assembly.IsDynamic)
|
||||
.GroupBy(assembly => assembly.GetName().Name ?? string.Empty, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal);
|
||||
var playerAssemblyNames = new HashSet<string>(
|
||||
CompilationPipeline.GetAssemblies(AssembliesType.Player).Select(assembly => assembly.name),
|
||||
StringComparer.Ordinal);
|
||||
var types = playerAssemblyNames
|
||||
.Where(loadedAssemblies.ContainsKey)
|
||||
.SelectMany(name => GetLoadableTypes(loadedAssemblies[name]))
|
||||
.Where(type => type != null)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
return ScanTypes(types!);
|
||||
}
|
||||
|
||||
internal static ShrinkNetworkSemanticScanResult ScanTypesForTests(params Type[] types)
|
||||
{
|
||||
return ScanTypes(types);
|
||||
}
|
||||
|
||||
internal static string FormatTypeForTests(Type type)
|
||||
{
|
||||
return FormatType(type);
|
||||
}
|
||||
|
||||
private static ShrinkNetworkSemanticScanResult ScanTypes(IEnumerable<Type> inputTypes)
|
||||
{
|
||||
var types = inputTypes
|
||||
.Where(type => type != null && !type.ContainsGenericParameters)
|
||||
.Distinct()
|
||||
.OrderBy(type => type.FullName, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var availableTypes = new HashSet<Type>(types);
|
||||
var result = new ShrinkNetworkSemanticScanResult();
|
||||
var messageTypes = new HashSet<Type>();
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
var messageAttribute = FindAttribute(type.CustomAttributes, MessageAttributeName);
|
||||
if (messageAttribute == null || !IsNetworkContract(type))
|
||||
continue;
|
||||
|
||||
result.Messages.Add(BuildMessageSpec(type, messageAttribute));
|
||||
messageTypes.Add(type);
|
||||
}
|
||||
|
||||
ThrowOnPortableNameCollision(
|
||||
result.Messages.Select(message => (message.TypeName, message.SourcePath)),
|
||||
"网络消息");
|
||||
|
||||
foreach (var type in types)
|
||||
AddSubscriberSpecs(type, result.Subscribers);
|
||||
|
||||
AddPortableDependencySpecs(result, messageTypes, availableTypes);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ShrinkDedicatedServerScaffoldGenerator.MessageSpec BuildMessageSpec(
|
||||
Type type,
|
||||
CustomAttributeData messageAttribute)
|
||||
{
|
||||
if (messageAttribute.ConstructorArguments.Count == 0)
|
||||
throw new InvalidOperationException($"{type.FullName} 的 ShrinkNetworkMessage 缺少 opcode。");
|
||||
|
||||
var opcode = Convert.ToInt32(messageAttribute.ConstructorArguments[0].Value, CultureInfo.InvariantCulture);
|
||||
var route = messageAttribute.ConstructorArguments.Count > 1
|
||||
? messageAttribute.ConstructorArguments[1].Value as string ?? string.Empty
|
||||
: string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(route))
|
||||
throw new InvalidOperationException($"{type.FullName} 的 ShrinkNetworkMessage 必须声明非空 route,服务器合同无法依赖运行时回退值。");
|
||||
|
||||
var stateSyncAttributes = type.CustomAttributes
|
||||
.Where(attribute => IsAttribute(attribute, StateSyncAttributeName))
|
||||
.ToArray();
|
||||
if (stateSyncAttributes.Length > 1)
|
||||
throw new InvalidOperationException($"{type.FullName} 声明了多个 ShrinkNetworkStateSync,服务器脚手架要求每个消息只有一个同步角色。");
|
||||
|
||||
var stateSync = stateSyncAttributes.FirstOrDefault();
|
||||
var spec = new ShrinkDedicatedServerScaffoldGenerator.MessageSpec
|
||||
{
|
||||
TypeName = type.Name,
|
||||
Kind = GetMessageKind(type),
|
||||
Opcode = opcode,
|
||||
Route = route.Trim(),
|
||||
SourcePath = GetSourceName(type),
|
||||
HasResult = ImplementsOpenGeneric(type, ResultEventInterfaceName),
|
||||
IsNetworkEvent = HasAttribute(type, NetworkEventAttributeName),
|
||||
IsDeltaEvent = Implements(type, DeltaEventInterfaceName),
|
||||
SyncGroup = GetConstructorString(stateSync, 0),
|
||||
SyncRole = GetConstructorEnumName(stateSync, 1)
|
||||
};
|
||||
spec.Properties.AddRange(GetSerializableProperties(type)
|
||||
.Select(property => (FormatType(property.PropertyType), property.Name)));
|
||||
return spec;
|
||||
}
|
||||
|
||||
private static void AddSubscriberSpecs(
|
||||
Type type,
|
||||
ICollection<ShrinkDedicatedServerScaffoldGenerator.SubscriberSpec> target)
|
||||
{
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic |
|
||||
BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
|
||||
foreach (var method in type.GetMethods(flags).OrderBy(method => method.MetadataToken))
|
||||
{
|
||||
foreach (var attribute in method.CustomAttributes.Where(item => IsAttribute(item, SubscribeAttributeName)))
|
||||
{
|
||||
target.Add(new ShrinkDedicatedServerScaffoldGenerator.SubscriberSpec
|
||||
{
|
||||
MemberName = type.Name + "." + method.Name,
|
||||
SourcePath = GetSourceName(type),
|
||||
Authority = GetNamedEnumName(attribute, "Authority"),
|
||||
Permission = GetNamedString(attribute, "Permission")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddPortableDependencySpecs(
|
||||
ShrinkNetworkSemanticScanResult result,
|
||||
ISet<Type> messageTypes,
|
||||
ISet<Type> availableTypes)
|
||||
{
|
||||
var queue = new Queue<Type>(messageTypes
|
||||
.SelectMany(GetSerializableProperties)
|
||||
.Select(property => property.PropertyType));
|
||||
var visited = new HashSet<Type>();
|
||||
var dependencyTypes = new HashSet<Type>();
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var dependency = UnwrapType(queue.Dequeue());
|
||||
if (dependency == null || !visited.Add(dependency))
|
||||
continue;
|
||||
|
||||
if (dependency.IsGenericType)
|
||||
{
|
||||
foreach (var argument in dependency.GetGenericArguments())
|
||||
queue.Enqueue(argument);
|
||||
}
|
||||
|
||||
if (!availableTypes.Contains(dependency) || messageTypes.Contains(dependency) || IsFrameworkType(dependency))
|
||||
continue;
|
||||
|
||||
dependencyTypes.Add(dependency);
|
||||
if (!dependency.IsEnum)
|
||||
{
|
||||
foreach (var property in GetSerializableProperties(dependency))
|
||||
queue.Enqueue(property.PropertyType);
|
||||
}
|
||||
}
|
||||
|
||||
ThrowOnPortableNameCollision(
|
||||
dependencyTypes.Select(type => (type.Name, GetSourceName(type))),
|
||||
"消息依赖类型");
|
||||
|
||||
foreach (var type in dependencyTypes.OrderBy(type => type.Name, StringComparer.Ordinal))
|
||||
{
|
||||
if (type.IsEnum)
|
||||
result.Enums.Add(BuildEnumSpec(type));
|
||||
else
|
||||
result.DataTypes.Add(BuildDataTypeSpec(type));
|
||||
}
|
||||
}
|
||||
|
||||
private static ShrinkDedicatedServerScaffoldGenerator.EnumSpec BuildEnumSpec(Type type)
|
||||
{
|
||||
var spec = new ShrinkDedicatedServerScaffoldGenerator.EnumSpec
|
||||
{
|
||||
Name = type.Name,
|
||||
SourcePath = GetSourceName(type)
|
||||
};
|
||||
var underlyingType = Enum.GetUnderlyingType(type);
|
||||
foreach (var name in Enum.GetNames(type))
|
||||
{
|
||||
var rawValue = Enum.Parse(type, name);
|
||||
var value = underlyingType == typeof(ulong) || underlyingType == typeof(uint) || underlyingType == typeof(ushort) || underlyingType == typeof(byte)
|
||||
? Convert.ToUInt64(rawValue, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture)
|
||||
: Convert.ToInt64(rawValue, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture);
|
||||
spec.Members.Add((name, value));
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
private static ShrinkDedicatedServerScaffoldGenerator.DataTypeSpec BuildDataTypeSpec(Type type)
|
||||
{
|
||||
var spec = new ShrinkDedicatedServerScaffoldGenerator.DataTypeSpec
|
||||
{
|
||||
Name = type.Name,
|
||||
Kind = type.IsValueType ? "struct" : "class",
|
||||
SourcePath = GetSourceName(type)
|
||||
};
|
||||
spec.Properties.AddRange(GetSerializableProperties(type)
|
||||
.Select(property => (FormatType(property.PropertyType), property.Name)));
|
||||
return spec;
|
||||
}
|
||||
|
||||
private static IEnumerable<PropertyInfo> GetSerializableProperties(Type type)
|
||||
{
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
|
||||
return type.GetProperties(flags)
|
||||
.Where(property => property.GetIndexParameters().Length == 0 &&
|
||||
property.GetMethod?.IsPublic == true &&
|
||||
property.SetMethod?.IsPublic == true)
|
||||
.OrderBy(property => property.MetadataToken);
|
||||
}
|
||||
|
||||
private static string FormatType(Type type)
|
||||
{
|
||||
if (TypeAliases.TryGetValue(type, out var alias))
|
||||
return alias;
|
||||
if (type.IsArray)
|
||||
return FormatType(type.GetElementType()!) + "[" + new string(',', type.GetArrayRank() - 1) + "]";
|
||||
if (type.IsGenericParameter)
|
||||
return type.Name;
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
|
||||
return FormatType(type.GetGenericArguments()[0]) + "?";
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var name = type.Name;
|
||||
var backtickIndex = name.IndexOf('`');
|
||||
if (backtickIndex >= 0)
|
||||
name = name[..backtickIndex];
|
||||
return name + "<" + string.Join(", ", type.GetGenericArguments().Select(FormatType)) + ">";
|
||||
}
|
||||
return type.Name;
|
||||
}
|
||||
|
||||
private static Type? UnwrapType(Type type)
|
||||
{
|
||||
while (type.IsArray || type.IsByRef || type.IsPointer)
|
||||
type = type.GetElementType()!;
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
|
||||
return type.GetGenericArguments()[0];
|
||||
return type;
|
||||
}
|
||||
|
||||
private static bool IsFrameworkType(Type type)
|
||||
{
|
||||
var namespaceName = type.Namespace ?? string.Empty;
|
||||
return type.Assembly == typeof(string).Assembly ||
|
||||
namespaceName.StartsWith("System", StringComparison.Ordinal) ||
|
||||
namespaceName.StartsWith("Unity", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsNetworkContract(Type type)
|
||||
{
|
||||
return Implements(type, MessageInterfaceName) || Implements(type, RequestInterfaceName) || Inherits(type, ResponseBaseName);
|
||||
}
|
||||
|
||||
private static string GetMessageKind(Type type)
|
||||
{
|
||||
if (Implements(type, RequestInterfaceName))
|
||||
return "request";
|
||||
if (Inherits(type, ResponseBaseName))
|
||||
return "response";
|
||||
return "message";
|
||||
}
|
||||
|
||||
private static bool Implements(Type type, string interfaceFullName)
|
||||
{
|
||||
return type.GetInterfaces().Any(item => string.Equals(item.FullName, interfaceFullName, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool ImplementsOpenGeneric(Type type, string interfaceFullName)
|
||||
{
|
||||
return type.GetInterfaces().Any(item =>
|
||||
item.IsGenericType &&
|
||||
string.Equals(item.GetGenericTypeDefinition().FullName, interfaceFullName,
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool Inherits(Type type, string baseTypeFullName)
|
||||
{
|
||||
for (var current = type.BaseType; current != null; current = current.BaseType)
|
||||
{
|
||||
if (string.Equals(current.FullName, baseTypeFullName, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasAttribute(MemberInfo member, string attributeFullName)
|
||||
{
|
||||
return FindAttribute(member.CustomAttributes, attributeFullName) != null;
|
||||
}
|
||||
|
||||
private static CustomAttributeData? FindAttribute(IEnumerable<CustomAttributeData> attributes, string attributeFullName)
|
||||
{
|
||||
return attributes.FirstOrDefault(attribute => IsAttribute(attribute, attributeFullName));
|
||||
}
|
||||
|
||||
private static bool IsAttribute(CustomAttributeData attribute, string attributeFullName)
|
||||
{
|
||||
return string.Equals(attribute.AttributeType.FullName, attributeFullName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string GetConstructorString(CustomAttributeData? attribute, int index)
|
||||
{
|
||||
return attribute != null && attribute.ConstructorArguments.Count > index
|
||||
? attribute.ConstructorArguments[index].Value as string ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static string GetConstructorEnumName(CustomAttributeData? attribute, int index)
|
||||
{
|
||||
if (attribute == null || attribute.ConstructorArguments.Count <= index)
|
||||
return string.Empty;
|
||||
return GetEnumName(attribute.ConstructorArguments[index]);
|
||||
}
|
||||
|
||||
private static string GetNamedEnumName(CustomAttributeData attribute, string memberName)
|
||||
{
|
||||
var argument = attribute.NamedArguments.FirstOrDefault(item => string.Equals(item.MemberName, memberName, StringComparison.Ordinal));
|
||||
return argument.MemberName == null ? string.Empty : GetEnumName(argument.TypedValue);
|
||||
}
|
||||
|
||||
private static string GetNamedString(CustomAttributeData attribute, string memberName)
|
||||
{
|
||||
var argument = attribute.NamedArguments.FirstOrDefault(item => string.Equals(item.MemberName, memberName, StringComparison.Ordinal));
|
||||
return argument.MemberName == null ? string.Empty : argument.TypedValue.Value as string ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string GetEnumName(CustomAttributeTypedArgument argument)
|
||||
{
|
||||
if (!argument.ArgumentType.IsEnum || argument.Value == null)
|
||||
return argument.Value?.ToString() ?? string.Empty;
|
||||
return Enum.GetName(argument.ArgumentType, argument.Value) ?? argument.Value.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static void ThrowOnPortableNameCollision(IEnumerable<(string Name, string Source)> items, string category)
|
||||
{
|
||||
var collisions = items
|
||||
.GroupBy(item => item.Name, StringComparer.Ordinal)
|
||||
.Where(group => group.Select(item => item.Source).Distinct(StringComparer.Ordinal).Skip(1).Any())
|
||||
.OrderBy(group => group.Key, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (collisions.Length == 0)
|
||||
return;
|
||||
|
||||
var details = collisions.Select(group =>
|
||||
group.Key + ": " + string.Join(", ", group.Select(item => item.Source).Distinct(StringComparer.Ordinal).OrderBy(value => value, StringComparer.Ordinal)));
|
||||
throw new InvalidOperationException(
|
||||
$"{category}存在命名空间不同但简单类型名相同的类型。独立服务器合同会去掉命名空间,无法安全生成:" +
|
||||
Environment.NewLine + string.Join(Environment.NewLine, details));
|
||||
}
|
||||
|
||||
private static string GetSourceName(Type type)
|
||||
{
|
||||
return (type.Assembly.GetName().Name ?? "unknown") + "::" + (type.FullName ?? type.Name);
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetLoadableTypes(System.Reflection.Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException exception)
|
||||
{
|
||||
return exception.Types.OfType<Type>();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 81f2d65d24dd8184ea8b396ba83ba159
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "ShrinkNetwork.Editor",
|
||||
"rootNamespace": "ShrinkNetwork.Editor",
|
||||
"references": [
|
||||
"ShrinkNetwork.Runtime"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12dae8366427fee4b805ff52065bd1c0
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9462a0dab15fd2d41b197b999c9d7355
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e2750876f7909154ea645e32a5e8e4f6
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 0
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
- first:
|
||||
Any:
|
||||
second:
|
||||
enabled: 1
|
||||
settings: {}
|
||||
- first:
|
||||
Editor: Editor
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
- first:
|
||||
Windows Store Apps: WindowsStoreApps
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
CPU: AnyCPU
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6d7ddf89a31144f6a0b498e1a7ff2be3
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 0
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
- first:
|
||||
Any:
|
||||
second:
|
||||
enabled: 1
|
||||
settings: {}
|
||||
- first:
|
||||
Editor: Editor
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
- first:
|
||||
Windows Store Apps: WindowsStoreApps
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
CPU: AnyCPU
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,367 @@
|
||||
# ShrinkNetwork
|
||||
|
||||
一个为 Unity C# 项目设计的轻量网络框架。重点不是“再包一层 Socket”,而是把消息声明、权限校验、RPC、序列化、传输层,以及独立服务器生成流程收拢到一套统一范式里。
|
||||
|
||||
## ✨ 特性概览
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 🔒 **强类型消息** | 基于 `[ShrinkNetworkMessage]` 声明消息、请求、响应,客户端与服务端共享同一套合同 |
|
||||
| ⚡ **RPC 内建** | `RpcAsync` / `CallAsync` 请求响应模型,超时、错误码、路由重载统一处理 |
|
||||
| 🧭 **权限与来源校验** | `ShrinkNetworkAuthority` + `Permission` 双重约束,处理器注册时直接绑定 |
|
||||
| 🔁 **多传输切换** | 内置 `Loopback / TCP / KCP`,同一套业务层代码可复用 |
|
||||
| 📦 **双序列化实现** | `JSON / MessagePack` 可切换,适配调试与正式环境 |
|
||||
| 🧩 **特性自动注册** | 编译期注册表 + 运行时实例注册并存,静态消息与静态处理器默认不再做全域反射扫描 |
|
||||
| 🏗️ **独立服务器生成** | Unity 菜单扫描项目代码,生成完整 .NET 独立服务器工程 |
|
||||
| 🌉 **桥接扩展友好** | 可选接入 `ShrinkNetwork.Integration.EventBus` 等桥接层,运行时自动发现并接入 |
|
||||
|
||||
## 📦 依赖
|
||||
|
||||
- Unity 2022.3+
|
||||
- [UniTask](https://github.com/Cysharp/UniTask) `2.x`
|
||||
- [Newtonsoft.Json](https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2/manual/index.html)
|
||||
- [MessagePack for C#](https://github.com/MessagePack-CSharp/MessagePack-CSharp) `3.1.4`(模板工程已同步)
|
||||
|
||||
## ⚙️ 目录结构
|
||||
|
||||
`Assets/Modules/ShrinkNetwork/` 当前按职责拆分为:
|
||||
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `Runtime/Core/` | `ShrinkNetworkService`、`ShrinkNetworkSession`、上下文与日志 |
|
||||
| `Runtime/Metadata/` | 消息合同、属性、权限、RPC、传输事件等基础类型 |
|
||||
| `Runtime/Routing/` | 注册表、反射辅助、路由派发 |
|
||||
| `Runtime/Serialization/` | 序列化接口与 `JSON / MessagePack` 实现 |
|
||||
| `Runtime/Transport/` | `Loopback / TCP / KCP` 传输层 |
|
||||
| `Editor/Scaffolding/` | 独立服务器模板与生成器 |
|
||||
| `Samples/PingClient/` | 最小客户端示例 |
|
||||
| `Plugins/` | 运行时第三方 DLL |
|
||||
|
||||
说明:
|
||||
|
||||
- `Assets/Scenes/` 下的演示控制器仍放在场景目录,不并入插件本体。
|
||||
- 项目专属独立服务器逻辑不常驻仓库,而是通过扫描结果生成到 `GeneratedServers/`。
|
||||
|
||||
## 🚀 快速上手
|
||||
|
||||
### 第一步:声明消息
|
||||
|
||||
```csharp
|
||||
using ShrinkNetwork;
|
||||
|
||||
[ShrinkNetworkMessage(1001, "auth/ping")]
|
||||
public sealed class PingRequest : IShrinkNetworkRequest
|
||||
{
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[ShrinkNetworkMessage(1002, "auth/ping_response")]
|
||||
public sealed class PingResponse : ShrinkRpcResponseBase
|
||||
{
|
||||
public string Reply { get; set; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
### 第二步:声明处理器
|
||||
|
||||
```csharp
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkNetwork;
|
||||
|
||||
[ShrinkNetworkSubscriber]
|
||||
public static class PingHandlers
|
||||
{
|
||||
[ShrinkNetworkSubscribe(
|
||||
Authority = ShrinkNetworkAuthority.ClientOnly,
|
||||
Permission = "rpc.ping")]
|
||||
private static UniTask<PingResponse> HandlePing(
|
||||
ShrinkNetworkContext context,
|
||||
PingRequest request)
|
||||
{
|
||||
return UniTask.FromResult(new PingResponse
|
||||
{
|
||||
Reply = "pong:" + request.Text
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 第三步:启动服务并自动注册
|
||||
|
||||
```csharp
|
||||
var service = new ShrinkNetworkService(
|
||||
new ShrinkMessagePackNetworkSerializer(),
|
||||
new ShrinkNetworkMessageRegistry(),
|
||||
new ShrinkNetworkRouter());
|
||||
|
||||
service.AutoRegisterAll();
|
||||
service.BindTransport(new ShrinkTcpClientTransport("127.0.0.1", 17777));
|
||||
```
|
||||
|
||||
### 跨线程派发与背压
|
||||
|
||||
`ShrinkNetworkService` 默认使用 inline 调度,适合独立服务器。传输层可能在后台线程触发事件;Unity 侧不要让网络线程直接进入会访问 Unity API 的业务 handler,应显式注入调度器:
|
||||
|
||||
```csharp
|
||||
var dispatchQueue = new ShrinkNetworkDispatchQueue(2048);
|
||||
service.DispatchScheduler = dispatchQueue;
|
||||
|
||||
// 在 Unity 主线程的 Update 中按预算泵出任务
|
||||
dispatchQueue.PumpAsync(128).Forget();
|
||||
```
|
||||
|
||||
队列满时默认拒绝新任务,并通过 `GetDiagnosticsSnapshot().DispatchQueueRejectedCount` 计数。实时状态可以选择丢弃策略;登录、控制命令和 RPC 不应与可丢弃状态共用同一个队列。
|
||||
|
||||
### 第四步:发送消息或发起 RPC
|
||||
|
||||
```csharp
|
||||
await session.SendAsync(new PingRequest
|
||||
{
|
||||
Text = "hello"
|
||||
});
|
||||
|
||||
var response = await session.RpcAsync<PingRequest, PingResponse>(
|
||||
new PingRequest { Text = "hello" });
|
||||
```
|
||||
|
||||
## 📖 核心概念
|
||||
|
||||
### 消息模型
|
||||
|
||||
ShrinkNetwork 把网络载荷分成三类:
|
||||
|
||||
| 类型 | 基接口 / 基类 | 用途 |
|
||||
|------|---------------|------|
|
||||
| 普通消息 | `IShrinkNetworkMessage` | 单向通知、状态广播 |
|
||||
| 请求 | `IShrinkNetworkRequest` | 发起 RPC |
|
||||
| 响应 | `ShrinkRpcResponseBase` | 返回错误码、错误消息与业务载荷 |
|
||||
|
||||
推荐规则:
|
||||
|
||||
- 所有网络消息都显式声明 `[ShrinkNetworkMessage(opcode, route)]`
|
||||
- `opcode` 与 `route` 一一对应,不要复用
|
||||
- 客户端与服务端共用同一消息类型定义
|
||||
|
||||
### 自动注册
|
||||
|
||||
运行时支持两类自动化:
|
||||
|
||||
```csharp
|
||||
service.AutoRegisterAttributedMessages(); // 消费编译期消息清单
|
||||
service.AutoRegisterStaticHandlers(); // 消费编译期静态处理器清单
|
||||
service.AutoRegisterAll(); // 两者都做
|
||||
```
|
||||
|
||||
这套机制适合插件化模块与独立服务器共用同一批消息合同。
|
||||
|
||||
当前版本中:
|
||||
|
||||
- 静态 `[ShrinkNetworkMessage]` / `[ShrinkNetworkSubscriber]` 由编译期注册表 `ShrinkNetworkGeneratedRegistry` 收口
|
||||
- `RegisterHandlers(object target)` 仍保留实例注册路径,继续支持模组实例和运行时对象
|
||||
|
||||
### 权限模型
|
||||
|
||||
处理器可通过 `[ShrinkNetworkSubscribe]` 直接声明来源和权限:
|
||||
|
||||
```csharp
|
||||
[ShrinkNetworkSubscribe(
|
||||
Authority = ShrinkNetworkAuthority.ClientOnly,
|
||||
Permission = "room.join")]
|
||||
```
|
||||
|
||||
常见用途:
|
||||
|
||||
- 限制某消息只能由客户端发起
|
||||
- 登录后再开放某些协议
|
||||
- 独立服务器按 `Permission` 做集中裁决
|
||||
|
||||
### 状态同步范式
|
||||
|
||||
如果你希望独立服务器生成器直接产出“可运行”的同步模块,推荐额外声明:
|
||||
|
||||
```csharp
|
||||
[ShrinkNetworkStateSync("lan", ShrinkNetworkStateSyncRole.JoinRequest)]
|
||||
```
|
||||
|
||||
当前内置识别的角色有:
|
||||
|
||||
- `JoinRequest`
|
||||
- `JoinResponse`
|
||||
- `Command`
|
||||
- `StateDelta`
|
||||
- `LeaveNotice`
|
||||
- `Heartbeat`
|
||||
|
||||
示例:
|
||||
|
||||
```csharp
|
||||
[ShrinkNetworkMessage(4111, "lan/player_state_delta")]
|
||||
[ShrinkNetworkStateSync("lan", ShrinkNetworkStateSyncRole.StateDelta)]
|
||||
public sealed class LanPlayerStateDelta : IShrinkNetworkMessage
|
||||
{
|
||||
public string PlayerId { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public float X { get; set; }
|
||||
public float Y { get; set; }
|
||||
public float VX { get; set; }
|
||||
public float VY { get; set; }
|
||||
public bool IsGrounded { get; set; }
|
||||
public long Version { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 优先推荐显式特性声明
|
||||
- 旧演示仍保留基于 route 的回退识别
|
||||
- `opcode` / `route` 冲突不会被框架吞掉,生成阶段会直接报错并指出来源
|
||||
|
||||
## 🏗️ 独立服务器生成
|
||||
|
||||
Unity 菜单:
|
||||
|
||||
- `ShrinkSDK/Network/生成完整独立服务器工程`
|
||||
- `ShrinkSDK/Network/刷新独立服务器 Generated 合同`
|
||||
|
||||
生成结果默认输出到:
|
||||
|
||||
`GeneratedServers/ShrinkNetwork.ServerHost/`
|
||||
|
||||
生成内容分两部分:
|
||||
|
||||
- 模板提供:宿主、TCP / KCP 服务端传输、基础鉴权与扩展点
|
||||
- 语义扫描生成:从 Unity 已编译的 Player 程序集读取类型、特性、继承与属性元数据,产出 `Generated/*.g.cs` 项目专属消息合同、处理器模板和自动模块
|
||||
|
||||
完整工程生成会用 SHA-256 清单保护模板托管文件。再次生成前先校验全部文件仍等于上次生成版本;检测到人工修改时不会写入或删除任何模板文件。`Generated/*.g.cs` 仍属于可重建区域。
|
||||
|
||||
当前生成器会优先识别三类高价值范式:
|
||||
|
||||
- `[ShrinkNetworkStateSync]` 标记的状态同步消息组
|
||||
- `[ShrinkNetworkEvent]` 标记的广播型 / 裁决型 / 增量型网络事件
|
||||
- `[ShrinkNetworkSubscribe]` 标记的权限声明
|
||||
|
||||
扫描以编译后类型为准,因此支持 `partial` 和复杂泛型。导出合同会去掉命名空间;不同命名空间存在同名消息或同名依赖类型时,生成器会列出冲突来源并中止,而不是静默保留第一项。仅手写 `RegisterMessage<T>()` 不属于服务器生成输入。
|
||||
|
||||
这意味着即便不手写额外适配层,只要项目遵守同一套特性范式,独立服务器也能先生成出一套可编译、可注册、可继续补业务的骨架。
|
||||
|
||||
## 🔧 API 参考
|
||||
|
||||
### ShrinkNetworkService
|
||||
|
||||
```csharp
|
||||
service.BindTransport(IShrinkNetworkTransport transport)
|
||||
service.RegisterMessage<TMessage>(int opcode, string route = null)
|
||||
service.RegisterHandler<TMessage>(Func<ShrinkNetworkContext, TMessage, UniTask> handler, requirement)
|
||||
service.RegisterRequestHandler<TRequest, TResponse>(Func<ShrinkNetworkContext, TRequest, UniTask<TResponse>> handler, requirement)
|
||||
service.AutoRegisterAttributedMessages()
|
||||
service.AutoRegisterStaticHandlers()
|
||||
service.AutoRegisterAll()
|
||||
```
|
||||
|
||||
### ShrinkNetworkSession
|
||||
|
||||
```csharp
|
||||
session.SendAsync<TMessage>(message, route = null)
|
||||
session.NotifyAsync<TMessage>(message, route = null)
|
||||
session.RpcAsync<TRequest, TResponse>(request, route = null)
|
||||
session.RpcAsync<TRequest, TResponse>(request, ShrinkRpcCallOptions options)
|
||||
```
|
||||
|
||||
### ShrinkRpcCallOptions
|
||||
|
||||
```csharp
|
||||
new ShrinkRpcCallOptions
|
||||
{
|
||||
TimeoutMs = 10000,
|
||||
RouteOverride = "custom/route",
|
||||
RequestTokenOverride = (ShrinkRequestToken)123,
|
||||
DebugLabel = "LoginRpc"
|
||||
}
|
||||
```
|
||||
|
||||
### 序列化
|
||||
|
||||
```csharp
|
||||
new ShrinkJsonNetworkSerializer()
|
||||
new ShrinkMessagePackNetworkSerializer()
|
||||
```
|
||||
|
||||
## ✅ 最佳实践
|
||||
|
||||
- 所有消息统一用 `[ShrinkNetworkMessage]` 声明,不要只靠手写 `RegisterMessage<T>()`
|
||||
- 客户端与服务端共用合同类型,不要维护两套“看起来一样”的 DTO
|
||||
- 对高频实时协议优先考虑 `MessagePack + KCP`
|
||||
- 对生成器可识别的同步模块,优先补齐 `[ShrinkNetworkStateSync]`
|
||||
- 如果项目接入了桥接包,优先让 `ShrinkNetworkService` 走自动接入,不要到处手工注册扩展
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
- `BindTransport(...)` 只负责绑定传输与启动监听,不会替你注册业务 handler
|
||||
- EventBus 桥接统一使用 `Post/PostAsync` 和编译期生成注册;未安装 `ShrinkNetwork.Integration.EventBus` 时不会建立桥接,也不会做运行时程序集扫描
|
||||
- `GeneratedServers/*/Generated/` 中的产物允许重建,不要把人工业务逻辑直接写进自动生成文件;模板托管区虽有哈希保护,也应通过独立业务文件扩展
|
||||
- 运行依赖中的 `Kcp-CSharp.dll` 与 `System.Runtime.CompilerServices.Unsafe.dll` 需要与 Unity 平台兼容
|
||||
|
||||
## ✅ 已验证
|
||||
|
||||
当前命令行验证已通过:
|
||||
|
||||
- `dotnet build .\Assembly-CSharp.csproj`
|
||||
- `dotnet build .\Assembly-CSharp-Editor.csproj`
|
||||
- `dotnet build .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj /nr:false`
|
||||
|
||||
当前未覆盖:
|
||||
|
||||
- 在 Unity 编辑器中实际点击菜单,重新生成完整独立服务器工程后的运行时回归
|
||||
|
||||
## 📄 License
|
||||
|
||||
## 会话令牌
|
||||
|
||||
- `ShrinkNetworkPacket` 现已携带 `SessionToken` 与 `SessionTokenExpiresAtUnixTimeSeconds`。
|
||||
- `ShrinkNetworkSession` 会在收到服务端响应包时自动学习并更新会话令牌,后续 `SendAsync / NotifyAsync / RpcAsync` 会自动把令牌带回去。
|
||||
- 宿主侧如启用了会话令牌校验,推荐流程是:
|
||||
1. 先调用 `server/auth/login`
|
||||
2. 拿到登录响应后继续正常发包
|
||||
3. 在到达续期窗口后调用 `server/auth/refresh`
|
||||
- 当前实现是“宿主内存态会话令牌”:
|
||||
- 适合单进程独立服
|
||||
- 不包含跨进程共享、外部身份提供商或持久化会话存储
|
||||
|
||||
## TCP TLS
|
||||
|
||||
- `ShrinkTcpClientTransport` 与 `ShrinkTcpServerTransport` 现已支持可选 TLS。
|
||||
- 客户端通过 `ShrinkTcpTlsOptions` 传入:
|
||||
- `Enabled`
|
||||
- `TargetHost`
|
||||
- `AllowInvalidServerCertificate`
|
||||
- `CheckCertificateRevocation`
|
||||
- `EnabledProtocols`
|
||||
- 服务端额外需要:
|
||||
- `ServerCertificatePath`
|
||||
- `ServerCertificatePassword`
|
||||
- 当前范围仅覆盖“服务端证书 + 客户端校验”主链,不包含双向证书认证。
|
||||
|
||||
```csharp
|
||||
var clientTransport = new ShrinkTcpClientTransport(
|
||||
"127.0.0.1",
|
||||
17777,
|
||||
tlsOptions: new ShrinkTcpTlsOptions
|
||||
{
|
||||
Enabled = true,
|
||||
TargetHost = "game.example.com"
|
||||
});
|
||||
|
||||
var serverTransport = new ShrinkTcpServerTransport(
|
||||
System.Net.IPAddress.Any,
|
||||
17777,
|
||||
tlsOptions: new ShrinkTcpTlsOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ServerCertificatePath = "certs/server.pfx",
|
||||
ServerCertificatePassword = "change-me"
|
||||
});
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 42cb0c91805d8764eaeb4946b931cb79
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa9ffde1ea564904089266d4cf32db3a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: afa48bd281d22914ea8fe82af25901b1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkNetworkContext
|
||||
{
|
||||
public ShrinkNetworkService Service { get; }
|
||||
public ShrinkNetworkSession Session { get; }
|
||||
public ShrinkNetworkPacket Packet { get; }
|
||||
|
||||
public ShrinkNetworkContext(ShrinkNetworkService service, ShrinkNetworkSession session, ShrinkNetworkPacket packet)
|
||||
{
|
||||
Service = service;
|
||||
Session = session;
|
||||
Packet = packet;
|
||||
}
|
||||
|
||||
public bool IsRequest => Packet.Kind == ShrinkNetworkPacketKind.Request;
|
||||
public bool IsResponse => Packet.Kind == ShrinkNetworkPacketKind.Response;
|
||||
public ShrinkRequestToken RequestToken => Packet.RequestToken;
|
||||
public string? Route => Packet.Route;
|
||||
|
||||
public UniTask ReplyAsync<TResponse>(TResponse response)
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
{
|
||||
return Service.SendResponseAsync(Session, response, Packet.RequestToken, Packet.Route);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 41088f41c7fd6b544a1ec928650e42be
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public static class ShrinkNetworkLogger
|
||||
{
|
||||
public static Action<string> InfoHandler { get; set; } = DefaultInfo;
|
||||
public static Action<string> WarningHandler { get; set; } = DefaultWarning;
|
||||
public static Action<string> ErrorHandler { get; set; } = DefaultError;
|
||||
public static Action<Exception> ExceptionHandler { get; set; } = DefaultException;
|
||||
|
||||
public static void Info(string message) => InfoHandler?.Invoke(message);
|
||||
public static void Warn(string message) => WarningHandler?.Invoke(message);
|
||||
public static void Error(string message) => ErrorHandler?.Invoke(message);
|
||||
public static void Exception(Exception ex) => ExceptionHandler?.Invoke(ex);
|
||||
|
||||
private static void DefaultInfo(string message)
|
||||
{
|
||||
#if UNITY_5_3_OR_NEWER
|
||||
UnityEngine.Debug.Log(message);
|
||||
#else
|
||||
Console.WriteLine(message);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static void DefaultWarning(string message)
|
||||
{
|
||||
#if UNITY_5_3_OR_NEWER
|
||||
UnityEngine.Debug.LogWarning(message);
|
||||
#else
|
||||
Console.WriteLine("[Warn] " + message);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static void DefaultError(string message)
|
||||
{
|
||||
#if UNITY_5_3_OR_NEWER
|
||||
UnityEngine.Debug.LogError(message);
|
||||
#else
|
||||
Console.Error.WriteLine(message);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static void DefaultException(Exception ex)
|
||||
{
|
||||
#if UNITY_5_3_OR_NEWER
|
||||
UnityEngine.Debug.LogException(ex);
|
||||
#else
|
||||
Console.Error.WriteLine(ex);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1250bb387385afa40abf0450af4616f8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,26 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public static class ShrinkNetworkRuntime
|
||||
{
|
||||
public static ShrinkNetworkService Default { get; private set; }
|
||||
|
||||
static ShrinkNetworkRuntime()
|
||||
{
|
||||
RebuildDefault();
|
||||
}
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetOnPlayModeEnter()
|
||||
{
|
||||
RebuildDefault();
|
||||
}
|
||||
|
||||
private static void RebuildDefault()
|
||||
{
|
||||
Default = new ShrinkNetworkService();
|
||||
ShrinkNetworkGeneratedRegistry.RegisterAll(Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f9c281d5168894c40843e80276fe1592
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,857 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkNetworkService
|
||||
{
|
||||
private sealed class PendingRequest
|
||||
{
|
||||
public long SessionId;
|
||||
public Type ResponseType = null!;
|
||||
public UniTaskCompletionSource<object?> CompletionSource = null!;
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<long, ShrinkNetworkSession> _sessions = new();
|
||||
private readonly ConcurrentDictionary<ShrinkRequestToken, PendingRequest> _pendingRequests = new();
|
||||
private int _requestTokenGenerator;
|
||||
private IShrinkNetworkTransport? _transport;
|
||||
private long _sessionsConnected;
|
||||
private long _sessionsDisconnected;
|
||||
private long _packetsSent;
|
||||
private long _packetsReceived;
|
||||
private long _bytesSent;
|
||||
private long _bytesReceived;
|
||||
private long _rpcStarted;
|
||||
private long _rpcCompleted;
|
||||
private long _rpcTimedOut;
|
||||
private long _rpcCanceled;
|
||||
private long _rpcFailed;
|
||||
private long _protocolViolations;
|
||||
private long _authRejectedCount;
|
||||
private long _permissionDeniedCount;
|
||||
private long _handlerExceptionCount;
|
||||
private long _unknownOpcodeCount;
|
||||
private long _dispatchMissCount;
|
||||
private long _serializationErrorCount;
|
||||
private long _dispatchQueueRejectedCount;
|
||||
private IShrinkNetworkDispatchScheduler _dispatchScheduler;
|
||||
|
||||
public ShrinkNetworkService()
|
||||
: this(new ShrinkJsonNetworkSerializer(), new ShrinkNetworkMessageRegistry(), new ShrinkNetworkRouter())
|
||||
{
|
||||
}
|
||||
|
||||
public ShrinkNetworkService(IShrinkNetworkSerializer serializer, ShrinkNetworkMessageRegistry messageRegistry,
|
||||
ShrinkNetworkRouter router, IShrinkNetworkDispatchScheduler? dispatchScheduler = null)
|
||||
{
|
||||
Serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
|
||||
MessageRegistry = messageRegistry ?? throw new ArgumentNullException(nameof(messageRegistry));
|
||||
Router = router ?? throw new ArgumentNullException(nameof(router));
|
||||
_dispatchScheduler = dispatchScheduler ?? ShrinkNetworkDispatchSchedulers.Inline;
|
||||
}
|
||||
|
||||
public IShrinkNetworkSerializer Serializer { get; }
|
||||
public ShrinkNetworkMessageRegistry MessageRegistry { get; }
|
||||
public ShrinkNetworkRouter Router { get; }
|
||||
public IReadOnlyDictionary<long, ShrinkNetworkSession> Sessions => _sessions;
|
||||
public int MinProtocolVersion { get; set; } = ShrinkNetworkProtocol.CurrentProtocolVersion;
|
||||
public int MaxProtocolVersion { get; set; } = ShrinkNetworkProtocol.CurrentProtocolVersion;
|
||||
public int MinSchemaVersion { get; set; } = ShrinkNetworkProtocol.CurrentSchemaVersion;
|
||||
public int MaxSchemaVersion { get; set; } = ShrinkNetworkProtocol.CurrentSchemaVersion;
|
||||
public bool DisconnectOnProtocolViolation { get; set; } = true;
|
||||
public Func<ShrinkNetworkSession, ShrinkNetworkPacket, ShrinkIncomingPacketValidationResult?>? IncomingPacketValidator { get; set; }
|
||||
|
||||
public IShrinkNetworkDispatchScheduler DispatchScheduler
|
||||
{
|
||||
get => _dispatchScheduler;
|
||||
set => _dispatchScheduler = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
public event Action<ShrinkNetworkSession>? OnSessionConnected;
|
||||
public event Action<ShrinkNetworkSession>? OnSessionDisconnected;
|
||||
|
||||
public void BindTransport(IShrinkNetworkTransport? transport)
|
||||
{
|
||||
if (_transport != null)
|
||||
_transport.OnEvent -= OnTransportEvent;
|
||||
|
||||
_transport = transport;
|
||||
|
||||
if (_transport != null)
|
||||
{
|
||||
_transport.OnEvent += OnTransportEvent;
|
||||
if (!_transport.IsStarted)
|
||||
_transport.Start();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public void RegisterMessage<TMessage>(int opcode, string? route = null) where TMessage : IShrinkNetworkMessage
|
||||
=> MessageRegistry.Register<TMessage>(opcode, route);
|
||||
|
||||
public void RegisterMessage(Type messageType, int opcode, string? route = null)
|
||||
=> MessageRegistry.Register(messageType, opcode, route);
|
||||
|
||||
public void RegisterHandler<TMessage>(Func<ShrinkNetworkContext, TMessage, UniTask> handler,
|
||||
ShrinkNetworkPermissionRequirement requirement = default)
|
||||
where TMessage : IShrinkNetworkMessage
|
||||
=> Router.RegisterHandler(handler, requirement);
|
||||
|
||||
public void RegisterHandler(Type messageType, Func<ShrinkNetworkContext, object, UniTask> handler,
|
||||
ShrinkNetworkPermissionRequirement requirement = default)
|
||||
=> Router.RegisterHandler(messageType, handler, requirement);
|
||||
|
||||
public void RegisterRequestHandler<TRequest, TResponse>(Func<ShrinkNetworkContext, TRequest, UniTask<TResponse>> handler,
|
||||
ShrinkNetworkPermissionRequirement requirement = default)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> Router.RegisterRequestHandler(handler, requirement);
|
||||
|
||||
public void RegisterRequestHandler(Type requestType, Type responseType,
|
||||
Func<ShrinkNetworkContext, object, UniTask<object?>> handler,
|
||||
ShrinkNetworkPermissionRequirement requirement = default)
|
||||
=> Router.RegisterRequestHandler(requestType, responseType, handler, requirement);
|
||||
|
||||
public void RegisterRpc<TRequest, TResponse>(Func<ShrinkNetworkContext, TRequest, UniTask<TResponse>> handler,
|
||||
ShrinkNetworkPermissionRequirement requirement = default)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> RegisterRequestHandler(handler, requirement);
|
||||
|
||||
public void AutoRegisterAttributedMessages()
|
||||
=> ShrinkNetworkRegHelper.RegisterAttributedMessages(this);
|
||||
|
||||
public void AutoRegisterStaticHandlers()
|
||||
=> ShrinkNetworkRegHelper.RegisterStaticHandlers(this);
|
||||
|
||||
public void RegisterHandlers(object target)
|
||||
=> ShrinkNetworkRegHelper.RegisterHandlers(this, target);
|
||||
|
||||
public void AutoRegisterAll()
|
||||
{
|
||||
AutoRegisterAttributedMessages();
|
||||
AutoRegisterStaticHandlers();
|
||||
}
|
||||
|
||||
public ShrinkNetworkServiceDiagnosticsSnapshot GetDiagnosticsSnapshot()
|
||||
{
|
||||
return new ShrinkNetworkServiceDiagnosticsSnapshot
|
||||
{
|
||||
CurrentSessions = _sessions.Count,
|
||||
SessionsConnected = Volatile.Read(ref _sessionsConnected),
|
||||
SessionsDisconnected = Volatile.Read(ref _sessionsDisconnected),
|
||||
PacketsSent = Volatile.Read(ref _packetsSent),
|
||||
PacketsReceived = Volatile.Read(ref _packetsReceived),
|
||||
BytesSent = Volatile.Read(ref _bytesSent),
|
||||
BytesReceived = Volatile.Read(ref _bytesReceived),
|
||||
RpcStarted = Volatile.Read(ref _rpcStarted),
|
||||
RpcCompleted = Volatile.Read(ref _rpcCompleted),
|
||||
RpcTimedOut = Volatile.Read(ref _rpcTimedOut),
|
||||
RpcCanceled = Volatile.Read(ref _rpcCanceled),
|
||||
RpcFailed = Volatile.Read(ref _rpcFailed),
|
||||
ProtocolViolations = Volatile.Read(ref _protocolViolations),
|
||||
AuthRejectedCount = Volatile.Read(ref _authRejectedCount),
|
||||
PermissionDeniedCount = Volatile.Read(ref _permissionDeniedCount),
|
||||
HandlerExceptionCount = Volatile.Read(ref _handlerExceptionCount),
|
||||
UnknownOpcodeCount = Volatile.Read(ref _unknownOpcodeCount),
|
||||
DispatchMissCount = Volatile.Read(ref _dispatchMissCount),
|
||||
SerializationErrorCount = Volatile.Read(ref _serializationErrorCount),
|
||||
DispatchQueueRejectedCount = Volatile.Read(ref _dispatchQueueRejectedCount)
|
||||
};
|
||||
}
|
||||
|
||||
public UniTask SendAsync<TMessage>(ShrinkNetworkSession session, TMessage message, string? route = null)
|
||||
where TMessage : IShrinkNetworkMessage
|
||||
=> SendInternalAsync(session, message, ShrinkNetworkPacketKind.Message, ShrinkRequestToken.Default, route);
|
||||
|
||||
public UniTask SendAsync(ShrinkNetworkSession session, IShrinkNetworkMessage message, string? route = null)
|
||||
{
|
||||
if (message == null)
|
||||
throw new ArgumentNullException(nameof(message));
|
||||
|
||||
return SendInternalAsync(session, message, message.GetType(), ShrinkNetworkPacketKind.Message, ShrinkRequestToken.Default, route);
|
||||
}
|
||||
|
||||
public UniTask NotifyAsync<TMessage>(ShrinkNetworkSession session, TMessage message, string? route = null)
|
||||
where TMessage : IShrinkNetworkMessage
|
||||
=> SendAsync(session, message, route);
|
||||
|
||||
public UniTask NotifyAsync(ShrinkNetworkSession session, IShrinkNetworkMessage message, string? route = null)
|
||||
=> SendAsync(session, message, route);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message whose payload has already been serialized. The payload is
|
||||
/// treated as immutable and can be reused for multiple sessions.
|
||||
/// </summary>
|
||||
public UniTask SendSerializedAsync(ShrinkNetworkSession session, Type messageType, byte[] payload,
|
||||
string? route = null)
|
||||
{
|
||||
return SendPacketAsync(session, messageType, ShrinkNetworkPacketKind.Message,
|
||||
ShrinkRequestToken.Default, route, payload);
|
||||
}
|
||||
|
||||
public UniTask<TResponse> CallAsync<TRequest, TResponse>(ShrinkNetworkSession session, TRequest request, string? route = null)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> CallAsync<TRequest, TResponse>(session, request, new ShrinkRpcCallOptions { RouteOverride = route });
|
||||
|
||||
public async UniTask<TResponse> CallAsync<TRequest, TResponse>(ShrinkNetworkSession session, TRequest request,
|
||||
ShrinkRpcCallOptions options)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
{
|
||||
if (session == null)
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
|
||||
Interlocked.Increment(ref _rpcStarted);
|
||||
var requestToken = options?.RequestTokenOverride ?? new ShrinkRequestToken(Interlocked.Increment(ref _requestTokenGenerator));
|
||||
var pending = new PendingRequest
|
||||
{
|
||||
SessionId = session.SessionId,
|
||||
ResponseType = typeof(TResponse),
|
||||
CompletionSource = new UniTaskCompletionSource<object?>()
|
||||
};
|
||||
if (!_pendingRequests.TryAdd(requestToken, pending))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Pending request token already exists. RequestToken={requestToken}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendInternalAsync(session, request, ShrinkNetworkPacketKind.Request, requestToken, options?.RouteOverride);
|
||||
var response = await WaitForPendingResponse<TResponse>(requestToken, pending, options);
|
||||
EnsureResponseSucceeded(response);
|
||||
Interlocked.Increment(ref _rpcCompleted);
|
||||
return response;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_pendingRequests.TryRemove(requestToken, out _);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public UniTask<TResponse> RpcAsync<TRequest, TResponse>(ShrinkNetworkSession session, TRequest request, string? route = null)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> CallAsync<TRequest, TResponse>(session, request, route);
|
||||
|
||||
public UniTask<TResponse> RpcAsync<TRequest, TResponse>(ShrinkNetworkSession session, TRequest request,
|
||||
ShrinkRpcCallOptions options)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> CallAsync<TRequest, TResponse>(session, request, options);
|
||||
|
||||
internal UniTask SendResponseAsync<TResponse>(ShrinkNetworkSession session, TResponse response, ShrinkRequestToken requestToken, string? route = null)
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> SendInternalAsync(session, response, typeof(TResponse), ShrinkNetworkPacketKind.Response, requestToken, route);
|
||||
|
||||
internal UniTask SendResponseAsync(ShrinkNetworkSession session, IShrinkNetworkResponse response, Type responseType,
|
||||
ShrinkRequestToken requestToken, string? route = null)
|
||||
=> SendInternalAsync(session, response, responseType, ShrinkNetworkPacketKind.Response, requestToken, route);
|
||||
|
||||
private UniTask SendInternalAsync<TMessage>(ShrinkNetworkSession session, TMessage message,
|
||||
ShrinkNetworkPacketKind kind, ShrinkRequestToken requestToken, string? route)
|
||||
where TMessage : IShrinkNetworkMessage
|
||||
=> SendInternalAsync(session, message, typeof(TMessage), kind, requestToken, route);
|
||||
|
||||
private UniTask SendInternalAsync(ShrinkNetworkSession session, IShrinkNetworkMessage message, Type messageType,
|
||||
ShrinkNetworkPacketKind kind, ShrinkRequestToken requestToken, string? route)
|
||||
{
|
||||
if (session == null)
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
if (message == null)
|
||||
throw new ArgumentNullException(nameof(message));
|
||||
if (messageType == null)
|
||||
throw new ArgumentNullException(nameof(messageType));
|
||||
|
||||
return SendPacketAsync(session, messageType, kind, requestToken, route, Serializer.Serialize(message));
|
||||
}
|
||||
|
||||
private async UniTask SendPacketAsync(ShrinkNetworkSession session, Type messageType,
|
||||
ShrinkNetworkPacketKind kind, ShrinkRequestToken requestToken, string? route, byte[] payload)
|
||||
{
|
||||
if (session == null)
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
if (messageType == null)
|
||||
throw new ArgumentNullException(nameof(messageType));
|
||||
if (payload == null)
|
||||
throw new ArgumentNullException(nameof(payload));
|
||||
|
||||
var transport = _transport ?? throw new InvalidOperationException("Transport is not bound.");
|
||||
var meta = MessageRegistry.GetMeta(messageType);
|
||||
var packet = new ShrinkNetworkPacket
|
||||
{
|
||||
Opcode = meta.Opcode,
|
||||
RequestToken = requestToken,
|
||||
SessionToken = session.SessionToken,
|
||||
SessionTokenExpiresAtUnixTimeSeconds = session.SessionTokenExpiresAtUtc?.ToUnixTimeSeconds() ?? 0,
|
||||
Route = string.IsNullOrWhiteSpace(route) ? meta.Route : route.Trim(),
|
||||
Kind = kind,
|
||||
Payload = payload
|
||||
};
|
||||
|
||||
var packetData = Serializer.Serialize(packet);
|
||||
Interlocked.Increment(ref _packetsSent);
|
||||
Interlocked.Add(ref _bytesSent, packetData.Length);
|
||||
if (transport is IShrinkNetworkAsyncTransport asyncTransport)
|
||||
{
|
||||
await asyncTransport.SendAsync(session.SessionId, packetData);
|
||||
return;
|
||||
}
|
||||
|
||||
transport.Send(session.SessionId, packetData);
|
||||
}
|
||||
|
||||
private void OnTransportEvent(ShrinkNetworkTransportEvent evt)
|
||||
{
|
||||
if (evt == null)
|
||||
return;
|
||||
|
||||
// 连接生命周期必须在传输回调返回前提交,否则快速退役/重激活时,
|
||||
// 旧的断开事件可能晚于新的连接事件完成,观察者会读到过期会话视图。
|
||||
if (evt.Type == ShrinkNetworkTransportEventType.Connected)
|
||||
{
|
||||
HandleConnected(evt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.Type == ShrinkNetworkTransportEventType.Disconnected)
|
||||
{
|
||||
HandleDisconnected(evt);
|
||||
return;
|
||||
}
|
||||
|
||||
ScheduleTransportEventAsync(evt).Forget();
|
||||
}
|
||||
|
||||
private async UniTaskVoid ScheduleTransportEventAsync(ShrinkNetworkTransportEvent evt)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scheduled = await DispatchScheduler.ScheduleAsync(() => HandleTransportEventAsync(evt));
|
||||
if (!scheduled)
|
||||
Interlocked.Increment(ref _dispatchQueueRejectedCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShrinkNetworkLogger.Exception(ex);
|
||||
ShrinkNetworkLogger.Error($"[ShrinkNetwork] Transport event dispatch failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private UniTask HandleTransportEventAsync(ShrinkNetworkTransportEvent evt)
|
||||
{
|
||||
switch (evt.Type)
|
||||
{
|
||||
case ShrinkNetworkTransportEventType.Connected:
|
||||
HandleConnected(evt);
|
||||
return UniTask.CompletedTask;
|
||||
case ShrinkNetworkTransportEventType.Disconnected:
|
||||
HandleDisconnected(evt);
|
||||
return UniTask.CompletedTask;
|
||||
case ShrinkNetworkTransportEventType.Packet:
|
||||
return HandlePacketAsync(evt);
|
||||
default:
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleConnected(ShrinkNetworkTransportEvent evt)
|
||||
{
|
||||
var session = _sessions.AddOrUpdate(evt.SessionId,
|
||||
_ => new ShrinkNetworkSession(evt.SessionId, evt.RemoteAddress, this),
|
||||
(_, existing) =>
|
||||
{
|
||||
existing.RemoteAddress = evt.RemoteAddress;
|
||||
return existing;
|
||||
});
|
||||
|
||||
Interlocked.Increment(ref _sessionsConnected);
|
||||
OnSessionConnected?.Invoke(session);
|
||||
}
|
||||
|
||||
private void HandleDisconnected(ShrinkNetworkTransportEvent evt)
|
||||
{
|
||||
if (_sessions.TryRemove(evt.SessionId, out var session))
|
||||
{
|
||||
FailPendingRequestsForSession(evt.SessionId);
|
||||
Interlocked.Increment(ref _sessionsDisconnected);
|
||||
OnSessionDisconnected?.Invoke(session);
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask HandlePacketAsync(ShrinkNetworkTransportEvent evt)
|
||||
{
|
||||
try
|
||||
{
|
||||
Interlocked.Increment(ref _packetsReceived);
|
||||
Interlocked.Add(ref _bytesReceived, evt.PacketData.Length);
|
||||
var packet = Serializer.Deserialize<ShrinkNetworkPacket>(evt.PacketData);
|
||||
|
||||
if (!_sessions.TryGetValue(evt.SessionId, out var session))
|
||||
{
|
||||
session = _sessions.GetOrAdd(evt.SessionId,
|
||||
id => new ShrinkNetworkSession(id, evt.RemoteAddress, this));
|
||||
}
|
||||
|
||||
if (!ValidatePacketCompatibility(packet, evt.SessionId))
|
||||
return;
|
||||
|
||||
ApplySessionTokenFromPacket(session, packet);
|
||||
|
||||
if (packet.Kind == ShrinkNetworkPacketKind.Response)
|
||||
{
|
||||
HandleResponse(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ValidateIncomingPacket(session, packet))
|
||||
return;
|
||||
|
||||
if (!MessageRegistry.TryGetMeta(packet.Opcode, out var meta))
|
||||
{
|
||||
Interlocked.Increment(ref _unknownOpcodeCount);
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Unregistered opcode: {packet.Opcode}");
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedMeta = meta!;
|
||||
var message = Serializer.Deserialize(packet.Payload, resolvedMeta.MessageType);
|
||||
if (message == null)
|
||||
{
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Failed to deserialize message for opcode {packet.Opcode}.");
|
||||
Interlocked.Increment(ref _serializationErrorCount);
|
||||
return;
|
||||
}
|
||||
|
||||
var context = new ShrinkNetworkContext(this, session, packet);
|
||||
|
||||
var handled = await Router.DispatchAsync(context, message, resolvedMeta.MessageType);
|
||||
if (!handled)
|
||||
{
|
||||
Interlocked.Increment(ref _dispatchMissCount);
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] No handler found for {resolvedMeta.MessageType.FullName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Interlocked.Increment(ref _serializationErrorCount);
|
||||
ShrinkNetworkLogger.Exception(ex);
|
||||
ShrinkNetworkLogger.Error($"[ShrinkNetwork] Packet handling failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleResponse(ShrinkNetworkPacket packet)
|
||||
{
|
||||
if (!_pendingRequests.TryRemove(packet.RequestToken, out var pending))
|
||||
{
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Pending request not found. RequestToken={packet.RequestToken}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = Serializer.Deserialize(packet.Payload, pending.ResponseType);
|
||||
pending.CompletionSource.TrySetResult(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
pending.CompletionSource.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<TResponse> WaitForPendingResponse<TResponse>(ShrinkRequestToken requestToken, PendingRequest pending,
|
||||
ShrinkRpcCallOptions? options)
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
{
|
||||
var timeoutMs = options?.TimeoutMs ?? 10000;
|
||||
var externalToken = options?.CancellationToken ?? default;
|
||||
var debugLabel = string.IsNullOrWhiteSpace(options?.DebugLabel) ? string.Empty : options.DebugLabel.Trim();
|
||||
|
||||
using var timeoutCts = timeoutMs > 0 ? new CancellationTokenSource(timeoutMs) : null;
|
||||
using var linkedCts = CreateLinkedTokenSource(externalToken, timeoutCts?.Token ?? default);
|
||||
|
||||
try
|
||||
{
|
||||
var boxed = await pending.CompletionSource.Task.AttachExternalCancellation(linkedCts?.Token ?? default);
|
||||
if (boxed is not TResponse response)
|
||||
throw new ShrinkRpcException(ShrinkRpcErrorCode.InvalidResponse, BuildRpcMessage("RPC returned an invalid response.", requestToken, debugLabel));
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (OperationCanceledException) when (externalToken.IsCancellationRequested)
|
||||
{
|
||||
_pendingRequests.TryRemove(requestToken, out _);
|
||||
Interlocked.Increment(ref _rpcCanceled);
|
||||
Interlocked.Increment(ref _rpcFailed);
|
||||
throw new ShrinkRpcException(ShrinkRpcErrorCode.Canceled, BuildRpcMessage("RPC call was canceled.", requestToken, debugLabel));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_pendingRequests.TryRemove(requestToken, out _);
|
||||
Interlocked.Increment(ref _rpcTimedOut);
|
||||
Interlocked.Increment(ref _rpcFailed);
|
||||
throw new ShrinkRpcException(ShrinkRpcErrorCode.Timeout, BuildRpcMessage("RPC call timed out.", requestToken, debugLabel));
|
||||
}
|
||||
}
|
||||
|
||||
private void FailPendingRequestsForSession(long sessionId)
|
||||
{
|
||||
var pendingEntries = _pendingRequests
|
||||
.Where(pair => pair.Value.SessionId == sessionId)
|
||||
.ToArray();
|
||||
if (pendingEntries.Length == 0)
|
||||
return;
|
||||
|
||||
foreach (var pendingEntry in pendingEntries)
|
||||
{
|
||||
if (_pendingRequests.TryRemove(pendingEntry.Key, out var pending))
|
||||
{
|
||||
pending.CompletionSource.TrySetException(
|
||||
new ShrinkRpcException(ShrinkRpcErrorCode.ConnectionClosed,
|
||||
$"RPC peer disconnected. RequestToken={pendingEntry.Key}, SessionId={sessionId}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildRpcMessage(string message, ShrinkRequestToken requestToken, string debugLabel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(debugLabel))
|
||||
return $"{message} RequestToken={requestToken}";
|
||||
|
||||
return $"{message} RequestToken={requestToken}, Label={debugLabel}";
|
||||
}
|
||||
|
||||
private static CancellationTokenSource? CreateLinkedTokenSource(CancellationToken left, CancellationToken right)
|
||||
{
|
||||
if (left.CanBeCanceled && right.CanBeCanceled)
|
||||
return CancellationTokenSource.CreateLinkedTokenSource(left, right);
|
||||
|
||||
if (left.CanBeCanceled)
|
||||
return CancellationTokenSource.CreateLinkedTokenSource(left);
|
||||
|
||||
if (right.CanBeCanceled)
|
||||
return CancellationTokenSource.CreateLinkedTokenSource(right);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void EnsureResponseSucceeded<TResponse>(TResponse response)
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
{
|
||||
if (response == null)
|
||||
throw new ShrinkRpcException(ShrinkRpcErrorCode.InvalidResponse, "RPC returned a null response.");
|
||||
|
||||
if (response.ErrorCode != 0)
|
||||
throw new ShrinkRpcException(response.ErrorCode,
|
||||
string.IsNullOrWhiteSpace(response.ErrorMessage) ? "RPC call failed." : response.ErrorMessage);
|
||||
}
|
||||
|
||||
internal void ReportPermissionDenied(Type messageType)
|
||||
{
|
||||
Interlocked.Increment(ref _permissionDeniedCount);
|
||||
}
|
||||
|
||||
internal void ReportHandlerException(Type messageType, Exception ex)
|
||||
{
|
||||
Interlocked.Increment(ref _handlerExceptionCount);
|
||||
Interlocked.Increment(ref _rpcFailed);
|
||||
}
|
||||
|
||||
private bool ValidatePacketCompatibility(ShrinkNetworkPacket packet, long sessionId)
|
||||
{
|
||||
var protocolAllowed = packet.ProtocolVersion >= MinProtocolVersion && packet.ProtocolVersion <= MaxProtocolVersion;
|
||||
var schemaAllowed = packet.SchemaVersion >= MinSchemaVersion && packet.SchemaVersion <= MaxSchemaVersion;
|
||||
if (protocolAllowed && schemaAllowed)
|
||||
return true;
|
||||
|
||||
Interlocked.Increment(ref _protocolViolations);
|
||||
var reason =
|
||||
$"protocol/schema mismatch. Protocol={packet.ProtocolVersion}, Schema={packet.SchemaVersion}, AllowedProtocol={MinProtocolVersion}-{MaxProtocolVersion}, AllowedSchema={MinSchemaVersion}-{MaxSchemaVersion}";
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Session {sessionId} rejected: {reason}");
|
||||
if (DisconnectOnProtocolViolation && _transport is IShrinkNetworkSessionControlTransport sessionControl)
|
||||
sessionControl.DisconnectSession(sessionId, reason);
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool ValidateIncomingPacket(ShrinkNetworkSession session, ShrinkNetworkPacket packet)
|
||||
{
|
||||
var validator = IncomingPacketValidator;
|
||||
if (validator == null)
|
||||
return true;
|
||||
|
||||
var result = validator(session, packet);
|
||||
if (result == null || result.IsAllowed)
|
||||
return true;
|
||||
|
||||
Interlocked.Increment(ref _authRejectedCount);
|
||||
var reason = string.IsNullOrWhiteSpace(result.Reason)
|
||||
? "incoming packet rejected by validator."
|
||||
: result.Reason.Trim();
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Session {session.SessionId} rejected: {reason}");
|
||||
if (result.DisconnectSession && _transport is IShrinkNetworkSessionControlTransport sessionControl)
|
||||
sessionControl.DisconnectSession(session.SessionId, reason);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ApplySessionTokenFromPacket(ShrinkNetworkSession session, ShrinkNetworkPacket packet)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packet.SessionToken))
|
||||
return;
|
||||
|
||||
var expiresAtUtc = packet.SessionTokenExpiresAtUnixTimeSeconds > 0
|
||||
? DateTimeOffset.FromUnixTimeSeconds(packet.SessionTokenExpiresAtUnixTimeSeconds)
|
||||
: (DateTimeOffset?)null;
|
||||
session.SetSessionToken(packet.SessionToken, expiresAtUtc);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public interface IShrinkNetworkDispatchScheduler
|
||||
{
|
||||
/// <summary>
|
||||
/// Schedules a transport event. The result is false when the scheduler
|
||||
/// rejects or drops the work because its bounded queue is full.
|
||||
/// </summary>
|
||||
UniTask<bool> ScheduleAsync(Func<UniTask> callback);
|
||||
}
|
||||
|
||||
public static class ShrinkNetworkDispatchSchedulers
|
||||
{
|
||||
public static IShrinkNetworkDispatchScheduler Inline { get; } =
|
||||
new ShrinkNetworkInlineDispatchScheduler();
|
||||
}
|
||||
|
||||
public sealed class ShrinkNetworkInlineDispatchScheduler : IShrinkNetworkDispatchScheduler
|
||||
{
|
||||
public async UniTask<bool> ScheduleAsync(Func<UniTask> callback)
|
||||
{
|
||||
if (callback == null)
|
||||
throw new ArgumentNullException(nameof(callback));
|
||||
|
||||
await callback();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ShrinkNetworkDispatchOverflowPolicy
|
||||
{
|
||||
Reject = 0,
|
||||
DropNewest = 1,
|
||||
DropOldest = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A caller-pumped, bounded dispatch queue. Unity can pump it from Update
|
||||
/// while a dedicated server can keep the default inline scheduler.
|
||||
/// </summary>
|
||||
public sealed class ShrinkNetworkDispatchQueue : IShrinkNetworkDispatchScheduler, IDisposable
|
||||
{
|
||||
private sealed class WorkItem
|
||||
{
|
||||
public Func<UniTask> Callback = null!;
|
||||
public UniTaskCompletionSource<bool> Completion = null!;
|
||||
}
|
||||
|
||||
private readonly ConcurrentQueue<WorkItem> _queue = new();
|
||||
private readonly object _lifecycleLock = new();
|
||||
private readonly int _capacity;
|
||||
private readonly ShrinkNetworkDispatchOverflowPolicy _overflowPolicy;
|
||||
private int _queuedCount;
|
||||
private int _pumping;
|
||||
private int _disposed;
|
||||
private long _rejectedCount;
|
||||
private long _droppedCount;
|
||||
|
||||
public ShrinkNetworkDispatchQueue(int capacity,
|
||||
ShrinkNetworkDispatchOverflowPolicy overflowPolicy = ShrinkNetworkDispatchOverflowPolicy.Reject)
|
||||
{
|
||||
if (capacity <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
|
||||
_capacity = capacity;
|
||||
_overflowPolicy = overflowPolicy;
|
||||
}
|
||||
|
||||
public int Capacity => _capacity;
|
||||
public int PendingCount => Volatile.Read(ref _queuedCount);
|
||||
public long RejectedCount => Volatile.Read(ref _rejectedCount);
|
||||
public long DroppedCount => Volatile.Read(ref _droppedCount);
|
||||
|
||||
public UniTask<bool> ScheduleAsync(Func<UniTask> callback)
|
||||
{
|
||||
if (callback == null)
|
||||
throw new ArgumentNullException(nameof(callback));
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
return UniTask.FromException<bool>(new ObjectDisposedException(nameof(ShrinkNetworkDispatchQueue)));
|
||||
|
||||
var item = new WorkItem
|
||||
{
|
||||
Callback = callback,
|
||||
Completion = new UniTaskCompletionSource<bool>()
|
||||
};
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (Volatile.Read(ref _queuedCount) >= _capacity)
|
||||
{
|
||||
switch (_overflowPolicy)
|
||||
{
|
||||
case ShrinkNetworkDispatchOverflowPolicy.Reject:
|
||||
Interlocked.Increment(ref _rejectedCount);
|
||||
return UniTask.FromResult(false);
|
||||
case ShrinkNetworkDispatchOverflowPolicy.DropNewest:
|
||||
Interlocked.Increment(ref _droppedCount);
|
||||
return UniTask.FromResult(false);
|
||||
case ShrinkNetworkDispatchOverflowPolicy.DropOldest:
|
||||
if (_queue.TryDequeue(out var dropped))
|
||||
{
|
||||
Interlocked.Decrement(ref _queuedCount);
|
||||
Interlocked.Increment(ref _droppedCount);
|
||||
dropped.Completion.TrySetResult(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
Thread.Yield();
|
||||
continue;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
|
||||
var currentCount = Volatile.Read(ref _queuedCount);
|
||||
if (currentCount >= _capacity ||
|
||||
Interlocked.CompareExchange(ref _queuedCount, currentCount + 1, currentCount) != currentCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
Interlocked.Decrement(ref _queuedCount);
|
||||
item.Completion.TrySetResult(false);
|
||||
return item.Completion.Task;
|
||||
}
|
||||
|
||||
_queue.Enqueue(item);
|
||||
return item.Completion.Task;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public UniTask<int> PumpAsync(int maxItems)
|
||||
{
|
||||
if (maxItems <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxItems));
|
||||
if (Interlocked.Exchange(ref _pumping, 1) == 1)
|
||||
return UniTask.FromResult(0);
|
||||
|
||||
return PumpCoreAsync(maxItems);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
return;
|
||||
|
||||
while (_queue.TryDequeue(out var item))
|
||||
{
|
||||
Interlocked.Decrement(ref _queuedCount);
|
||||
item.Completion.TrySetResult(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<int> PumpCoreAsync(int maxItems)
|
||||
{
|
||||
var processed = 0;
|
||||
try
|
||||
{
|
||||
while (processed < maxItems && _queue.TryDequeue(out var item))
|
||||
{
|
||||
Interlocked.Decrement(ref _queuedCount);
|
||||
await ExecuteItemAsync(item);
|
||||
processed++;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref _pumping, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static async UniTask ExecuteItemAsync(WorkItem item)
|
||||
{
|
||||
try
|
||||
{
|
||||
await item.Callback();
|
||||
item.Completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
item.Completion.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ShrinkNetworkServiceDiagnosticsSnapshot
|
||||
{
|
||||
public int CurrentSessions { get; set; }
|
||||
public long SessionsConnected { get; set; }
|
||||
public long SessionsDisconnected { get; set; }
|
||||
public long PacketsSent { get; set; }
|
||||
public long PacketsReceived { get; set; }
|
||||
public long BytesSent { get; set; }
|
||||
public long BytesReceived { get; set; }
|
||||
public long RpcStarted { get; set; }
|
||||
public long RpcCompleted { get; set; }
|
||||
public long RpcTimedOut { get; set; }
|
||||
public long RpcCanceled { get; set; }
|
||||
public long RpcFailed { get; set; }
|
||||
public long ProtocolViolations { get; set; }
|
||||
public long AuthRejectedCount { get; set; }
|
||||
public long PermissionDeniedCount { get; set; }
|
||||
public long HandlerExceptionCount { get; set; }
|
||||
public long UnknownOpcodeCount { get; set; }
|
||||
public long DispatchMissCount { get; set; }
|
||||
public long SerializationErrorCount { get; set; }
|
||||
public long DispatchQueueRejectedCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShrinkIncomingPacketValidationResult
|
||||
{
|
||||
public static readonly ShrinkIncomingPacketValidationResult Allow = new()
|
||||
{
|
||||
IsAllowed = true
|
||||
};
|
||||
|
||||
public bool IsAllowed { get; set; }
|
||||
public bool DisconnectSession { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public static ShrinkIncomingPacketValidationResult Reject(string reason, bool disconnectSession = true)
|
||||
{
|
||||
return new ShrinkIncomingPacketValidationResult
|
||||
{
|
||||
IsAllowed = false,
|
||||
DisconnectSession = disconnectSession,
|
||||
Reason = reason ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eb6513d534c7574478bd07ffd195cf26
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,113 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkNetworkSession
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, object> _items = new();
|
||||
private readonly ConcurrentDictionary<string, byte> _permissions = new();
|
||||
|
||||
internal ShrinkNetworkSession(long sessionId, string remoteAddress, ShrinkNetworkService service)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
RemoteAddress = remoteAddress;
|
||||
Service = service;
|
||||
}
|
||||
|
||||
public long SessionId { get; }
|
||||
public string RemoteAddress { get; internal set; }
|
||||
public ShrinkNetworkService Service { get; }
|
||||
public ShrinkNetworkPeerKind PeerKind { get; private set; } = ShrinkNetworkPeerKind.Unknown;
|
||||
public string SessionToken { get; private set; } = string.Empty;
|
||||
public DateTimeOffset? SessionTokenExpiresAtUtc { get; private set; }
|
||||
|
||||
public IDictionary<string, object> Items => _items;
|
||||
|
||||
public void SetPeerKind(ShrinkNetworkPeerKind peerKind)
|
||||
{
|
||||
PeerKind = peerKind;
|
||||
}
|
||||
|
||||
public void SetSessionToken(string sessionToken, DateTimeOffset? expiresAtUtc = null)
|
||||
{
|
||||
SessionToken = string.IsNullOrWhiteSpace(sessionToken) ? string.Empty : sessionToken.Trim();
|
||||
SessionTokenExpiresAtUtc = string.IsNullOrWhiteSpace(SessionToken) ? null : expiresAtUtc;
|
||||
}
|
||||
|
||||
public void ClearSessionToken()
|
||||
{
|
||||
SessionToken = string.Empty;
|
||||
SessionTokenExpiresAtUtc = null;
|
||||
}
|
||||
|
||||
public void GrantPermission(string permission)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(permission))
|
||||
_permissions[permission.Trim()] = 0;
|
||||
}
|
||||
|
||||
public void RevokePermission(string permission)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(permission))
|
||||
_permissions.TryRemove(permission.Trim(), out _);
|
||||
}
|
||||
|
||||
public bool HasPermission(string permission)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(permission) && _permissions.ContainsKey(permission.Trim());
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> GetPermissions() => _permissions.Keys.ToArray();
|
||||
|
||||
public UniTask SendAsync<TMessage>(TMessage message, string? route = null)
|
||||
where TMessage : IShrinkNetworkMessage
|
||||
=> Service.SendAsync(this, message, route);
|
||||
|
||||
public UniTask SendAsync(IShrinkNetworkMessage message, string? route = null)
|
||||
=> Service.SendAsync(this, message, route);
|
||||
|
||||
public UniTask NotifyAsync<TMessage>(TMessage message, string? route = null)
|
||||
where TMessage : IShrinkNetworkMessage
|
||||
=> Service.SendAsync(this, message, route);
|
||||
|
||||
public UniTask NotifyAsync(IShrinkNetworkMessage message, string? route = null)
|
||||
=> Service.SendAsync(this, message, route);
|
||||
|
||||
public UniTask<TResponse> CallAsync<TRequest, TResponse>(TRequest request, string? route = null)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> Service.CallAsync<TRequest, TResponse>(this, request, route);
|
||||
|
||||
public UniTask<TResponse> CallAsync<TRequest, TResponse>(TRequest request, ShrinkRpcCallOptions options)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> Service.CallAsync<TRequest, TResponse>(this, request, options);
|
||||
|
||||
public UniTask<TResponse> RpcAsync<TRequest, TResponse>(TRequest request, string? route = null)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> Service.CallAsync<TRequest, TResponse>(this, request, route);
|
||||
|
||||
public UniTask<TResponse> RpcAsync<TRequest, TResponse>(TRequest request, ShrinkRpcCallOptions options)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> Service.CallAsync<TRequest, TResponse>(this, request, options);
|
||||
|
||||
public UniTask<TResponse> RpcAsync<TRequest, TResponse>(TRequest request, int timeoutMs,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
=> Service.CallAsync<TRequest, TResponse>(this, request, new ShrinkRpcCallOptions
|
||||
{
|
||||
TimeoutMs = timeoutMs,
|
||||
CancellationToken = cancellationToken
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 26140534bef037a44a72d8c9eadb99b6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 56839208d6041c545bc4c52c7d1c7472
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,54 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
|
||||
public sealed class ShrinkNetworkMessageAttribute : Attribute
|
||||
{
|
||||
public int Opcode { get; }
|
||||
public string? Route { get; }
|
||||
|
||||
public ShrinkNetworkMessageAttribute(int opcode, string? route = null)
|
||||
{
|
||||
Opcode = opcode;
|
||||
Route = string.IsNullOrWhiteSpace(route) ? null : route.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
|
||||
public sealed class ShrinkNetworkSubscriberAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
public enum ShrinkNetworkStateSyncRole
|
||||
{
|
||||
JoinRequest = 0,
|
||||
JoinResponse = 1,
|
||||
Command = 2,
|
||||
StateDelta = 3,
|
||||
LeaveNotice = 4,
|
||||
Heartbeat = 5
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
|
||||
public sealed class ShrinkNetworkStateSyncAttribute : Attribute
|
||||
{
|
||||
public string Group { get; }
|
||||
public ShrinkNetworkStateSyncRole Role { get; }
|
||||
|
||||
public ShrinkNetworkStateSyncAttribute(string group, ShrinkNetworkStateSyncRole role)
|
||||
{
|
||||
Group = string.IsNullOrWhiteSpace(group) ? string.Empty : group.Trim();
|
||||
Role = role;
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
|
||||
public sealed class ShrinkNetworkSubscribeAttribute : Attribute
|
||||
{
|
||||
public ShrinkNetworkAuthority Authority { get; set; } = ShrinkNetworkAuthority.Any;
|
||||
public string? Permission { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c0a58e8dd0b553646bc53a29187ee423
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public interface IShrinkNetworkMessage
|
||||
{
|
||||
}
|
||||
|
||||
public interface IShrinkNetworkRequest : IShrinkNetworkMessage
|
||||
{
|
||||
}
|
||||
|
||||
public interface IShrinkNetworkResponse : IShrinkNetworkMessage
|
||||
{
|
||||
int ErrorCode { get; set; }
|
||||
string ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2183af3c779211e43933d975814eb435
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public static class ShrinkNetworkProtocol
|
||||
{
|
||||
public const int CurrentProtocolVersion = 1;
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
}
|
||||
|
||||
public enum ShrinkNetworkPacketKind
|
||||
{
|
||||
Message = 0,
|
||||
Request = 1,
|
||||
Response = 2
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class ShrinkNetworkPacket
|
||||
{
|
||||
public int ProtocolVersion = ShrinkNetworkProtocol.CurrentProtocolVersion;
|
||||
public int SchemaVersion = ShrinkNetworkProtocol.CurrentSchemaVersion;
|
||||
public int Opcode;
|
||||
public ShrinkRequestToken RequestToken;
|
||||
public string SessionToken = string.Empty;
|
||||
public long SessionTokenExpiresAtUnixTimeSeconds;
|
||||
public string? Route;
|
||||
public ShrinkNetworkPacketKind Kind;
|
||||
public byte[] Payload = Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d6e511da358c6c64d8c34fdb62928178
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,64 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public enum ShrinkNetworkPeerKind
|
||||
{
|
||||
Unknown = 0,
|
||||
Client = 1,
|
||||
Server = 2,
|
||||
TrustedServer = 3
|
||||
}
|
||||
|
||||
public enum ShrinkNetworkAuthority
|
||||
{
|
||||
Any = 0,
|
||||
ClientOnly = 1,
|
||||
ServerOnly = 2,
|
||||
TrustedServerOnly = 3
|
||||
}
|
||||
|
||||
public readonly struct ShrinkNetworkPermissionRequirement
|
||||
{
|
||||
public ShrinkNetworkAuthority Authority { get; }
|
||||
public string? Permission { get; }
|
||||
|
||||
public ShrinkNetworkPermissionRequirement(ShrinkNetworkAuthority authority, string? permission)
|
||||
{
|
||||
Authority = authority;
|
||||
Permission = string.IsNullOrWhiteSpace(permission) ? null : permission.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ShrinkNetworkPermissionValidator
|
||||
{
|
||||
public static bool IsAllowed(ShrinkNetworkSession session, ShrinkNetworkPermissionRequirement requirement)
|
||||
{
|
||||
if (session == null)
|
||||
return false;
|
||||
|
||||
if (!CheckAuthority(session.PeerKind, requirement.Authority))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(requirement.Permission) && !session.HasPermission(requirement.Permission))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool CheckAuthority(ShrinkNetworkPeerKind peerKind, ShrinkNetworkAuthority authority)
|
||||
{
|
||||
return authority switch
|
||||
{
|
||||
ShrinkNetworkAuthority.Any => true,
|
||||
ShrinkNetworkAuthority.ClientOnly => peerKind == ShrinkNetworkPeerKind.Client,
|
||||
ShrinkNetworkAuthority.ServerOnly => peerKind == ShrinkNetworkPeerKind.Server || peerKind == ShrinkNetworkPeerKind.TrustedServer,
|
||||
ShrinkNetworkAuthority.TrustedServerOnly => peerKind == ShrinkNetworkPeerKind.TrustedServer,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c6a9dc7f8d56afd41b1e47fac50c5180
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,71 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public readonly struct ShrinkRequestToken : IEquatable<ShrinkRequestToken>
|
||||
{
|
||||
public static readonly ShrinkRequestToken Default = new(0);
|
||||
|
||||
public ShrinkRequestToken(int value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public int Value { get; }
|
||||
public bool IsDefault => Value == 0;
|
||||
|
||||
public bool Equals(ShrinkRequestToken other) => Value == other.Value;
|
||||
public override bool Equals(object? obj) => obj is ShrinkRequestToken other && Equals(other);
|
||||
public override int GetHashCode() => Value;
|
||||
public override string ToString() => Value.ToString();
|
||||
|
||||
public static bool operator ==(ShrinkRequestToken left, ShrinkRequestToken right) => left.Equals(right);
|
||||
public static bool operator !=(ShrinkRequestToken left, ShrinkRequestToken right) => !left.Equals(right);
|
||||
public static explicit operator int(ShrinkRequestToken token) => token.Value;
|
||||
public static explicit operator ShrinkRequestToken(int value) => new(value);
|
||||
}
|
||||
|
||||
public sealed class ShrinkRpcCallOptions
|
||||
{
|
||||
public int TimeoutMs { get; set; } = 10000;
|
||||
public string? RouteOverride { get; set; }
|
||||
public ShrinkRequestToken? RequestTokenOverride { get; set; }
|
||||
public string? DebugLabel { get; set; }
|
||||
public CancellationToken CancellationToken { get; set; } = default;
|
||||
}
|
||||
|
||||
public sealed class ShrinkRpcException : Exception
|
||||
{
|
||||
public int ErrorCode { get; }
|
||||
|
||||
public ShrinkRpcException(int errorCode, string message)
|
||||
: base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ShrinkRpcErrorCode
|
||||
{
|
||||
public const int Unknown = 1;
|
||||
public const int Timeout = 2;
|
||||
public const int Canceled = 3;
|
||||
public const int HandlerException = 4;
|
||||
public const int InvalidResponse = 5;
|
||||
public const int PermissionDenied = 6;
|
||||
public const int ConnectionClosed = 7;
|
||||
public const int ProtocolMismatch = 8;
|
||||
public const int AuthenticationFailed = 9;
|
||||
public const int SessionTokenExpired = 10;
|
||||
}
|
||||
|
||||
public abstract class ShrinkRpcResponseBase : IShrinkNetworkResponse
|
||||
{
|
||||
public int ErrorCode { get; set; }
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
|
||||
public bool IsSuccess => ErrorCode == 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e430341a390625b4a90e3608f6bbc2f9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
#nullable enable
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public enum ShrinkNetworkTransportEventType
|
||||
{
|
||||
Connected = 0,
|
||||
Disconnected = 1,
|
||||
Packet = 2
|
||||
}
|
||||
|
||||
public sealed class ShrinkNetworkTransportEvent
|
||||
{
|
||||
public ShrinkNetworkTransportEventType Type;
|
||||
public long SessionId;
|
||||
public string RemoteAddress = string.Empty;
|
||||
public byte[] PacketData = System.Array.Empty<byte>();
|
||||
|
||||
public static ShrinkNetworkTransportEvent Connected(long sessionId, string remoteAddress)
|
||||
{
|
||||
return new ShrinkNetworkTransportEvent
|
||||
{
|
||||
Type = ShrinkNetworkTransportEventType.Connected,
|
||||
SessionId = sessionId,
|
||||
RemoteAddress = remoteAddress
|
||||
};
|
||||
}
|
||||
|
||||
public static ShrinkNetworkTransportEvent Disconnected(long sessionId, string remoteAddress)
|
||||
{
|
||||
return new ShrinkNetworkTransportEvent
|
||||
{
|
||||
Type = ShrinkNetworkTransportEventType.Disconnected,
|
||||
SessionId = sessionId,
|
||||
RemoteAddress = remoteAddress
|
||||
};
|
||||
}
|
||||
|
||||
public static ShrinkNetworkTransportEvent Packet(long sessionId, byte[] packetData)
|
||||
{
|
||||
return new ShrinkNetworkTransportEvent
|
||||
{
|
||||
Type = ShrinkNetworkTransportEventType.Packet,
|
||||
SessionId = sessionId,
|
||||
PacketData = packetData
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0fbbbfca75a4ebd4c924c2cf328d549d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 050397bcd638e4e42952d69d28425c9a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,81 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
|
||||
public sealed class ShrinkNetworkMessageRegistryAttribute : Attribute
|
||||
{
|
||||
public ShrinkNetworkMessageRegistryAttribute(params Type[] messageTypes)
|
||||
{
|
||||
MessageTypes = messageTypes ?? Array.Empty<Type>();
|
||||
}
|
||||
|
||||
public Type[] MessageTypes { get; }
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
|
||||
public sealed class ShrinkNetworkStaticSubscriberRegistryAttribute : Attribute
|
||||
{
|
||||
public ShrinkNetworkStaticSubscriberRegistryAttribute(params Type[] subscriberTypes)
|
||||
{
|
||||
SubscriberTypes = subscriberTypes ?? Array.Empty<Type>();
|
||||
}
|
||||
|
||||
public Type[] SubscriberTypes { get; }
|
||||
}
|
||||
|
||||
internal static class ShrinkNetworkGeneratedRegistry
|
||||
{
|
||||
public static IReadOnlyList<Type> GetAttributedMessageTypes()
|
||||
=> GetAssemblyRegisteredTypes<ShrinkNetworkMessageRegistryAttribute>(attribute => attribute.MessageTypes);
|
||||
|
||||
public static IReadOnlyList<Type> GetStaticSubscriberTypes()
|
||||
=> GetAssemblyRegisteredTypes<ShrinkNetworkStaticSubscriberRegistryAttribute>(attribute => attribute.SubscriberTypes);
|
||||
|
||||
public static void RegisterAll(ShrinkNetworkService service)
|
||||
{
|
||||
if (service == null)
|
||||
throw new ArgumentNullException(nameof(service));
|
||||
|
||||
ShrinkNetworkRegHelper.RegisterAttributedMessages(service, GetAttributedMessageTypes());
|
||||
ShrinkNetworkRegHelper.RegisterStaticHandlers(service, GetStaticSubscriberTypes());
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Type> GetAssemblyRegisteredTypes<TAttribute>(Func<TAttribute, Type[]> selector)
|
||||
where TAttribute : Attribute
|
||||
{
|
||||
var types = new List<Type>();
|
||||
var seen = new HashSet<Type>();
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
object[] attributes;
|
||||
try
|
||||
{
|
||||
attributes = assembly.GetCustomAttributes(typeof(TAttribute), false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attribute in attributes.OfType<TAttribute>())
|
||||
{
|
||||
foreach (var registeredType in selector(attribute) ?? Array.Empty<Type>())
|
||||
{
|
||||
if (registeredType == null || !seen.Add(registeredType))
|
||||
continue;
|
||||
|
||||
types.Add(registeredType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return types;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c8557e972f759fd4cac12069d5ac43e8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,60 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkNetworkMessageRegistry
|
||||
{
|
||||
private readonly Dictionary<int, ShrinkNetworkMessageMeta> _opcodeToMeta = new();
|
||||
private readonly Dictionary<Type, ShrinkNetworkMessageMeta> _typeToMeta = new();
|
||||
|
||||
public void Register<TMessage>(int opcode, string? route = null) where TMessage : IShrinkNetworkMessage
|
||||
=> Register(typeof(TMessage), opcode, route);
|
||||
|
||||
public void Register(Type messageType, int opcode, string? route = null)
|
||||
{
|
||||
if (messageType == null)
|
||||
throw new ArgumentNullException(nameof(messageType));
|
||||
if (!typeof(IShrinkNetworkMessage).IsAssignableFrom(messageType))
|
||||
throw new ArgumentException($"Type {messageType.FullName} is not a network message.", nameof(messageType));
|
||||
if (_opcodeToMeta.ContainsKey(opcode))
|
||||
throw new InvalidOperationException($"Opcode {opcode} is already registered.");
|
||||
if (_typeToMeta.ContainsKey(messageType))
|
||||
throw new InvalidOperationException($"Message type {messageType.FullName} is already registered.");
|
||||
|
||||
var meta = new ShrinkNetworkMessageMeta(opcode, messageType, route);
|
||||
_opcodeToMeta.Add(opcode, meta);
|
||||
_typeToMeta.Add(messageType, meta);
|
||||
}
|
||||
|
||||
public bool TryGetMeta(int opcode, out ShrinkNetworkMessageMeta? meta) => _opcodeToMeta.TryGetValue(opcode, out meta);
|
||||
|
||||
public bool TryGetMeta(Type type, out ShrinkNetworkMessageMeta? meta) => _typeToMeta.TryGetValue(type, out meta);
|
||||
|
||||
public ShrinkNetworkMessageMeta GetMeta<TMessage>() where TMessage : IShrinkNetworkMessage => GetMeta(typeof(TMessage));
|
||||
|
||||
public ShrinkNetworkMessageMeta GetMeta(Type type)
|
||||
{
|
||||
if (_typeToMeta.TryGetValue(type, out var meta))
|
||||
return meta;
|
||||
|
||||
throw new KeyNotFoundException($"Message type {type.FullName} is not registered.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ShrinkNetworkMessageMeta
|
||||
{
|
||||
public int Opcode { get; }
|
||||
public Type MessageType { get; }
|
||||
public string? Route { get; }
|
||||
|
||||
public ShrinkNetworkMessageMeta(int opcode, Type messageType, string? route)
|
||||
{
|
||||
Opcode = opcode;
|
||||
MessageType = messageType;
|
||||
Route = route;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ba2cec4b827a0a4b86f42a16fd10393
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,210 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public static class ShrinkNetworkRegHelper
|
||||
{
|
||||
private static readonly MethodInfo AwaitUniTaskResponseMethod =
|
||||
typeof(ShrinkNetworkRegHelper).GetMethod(nameof(AwaitUniTaskResponse), BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
|
||||
public static void RegisterAttributedMessages(ShrinkNetworkService service)
|
||||
{
|
||||
RegisterAttributedMessages(service, ShrinkNetworkGeneratedRegistry.GetAttributedMessageTypes());
|
||||
}
|
||||
|
||||
public static void RegisterAttributedMessages(ShrinkNetworkService service, IEnumerable<Type> messageTypes)
|
||||
{
|
||||
if (service == null)
|
||||
throw new ArgumentNullException(nameof(service));
|
||||
if (messageTypes == null)
|
||||
throw new ArgumentNullException(nameof(messageTypes));
|
||||
|
||||
foreach (var type in messageTypes)
|
||||
{
|
||||
if (type == null || !typeof(IShrinkNetworkMessage).IsAssignableFrom(type))
|
||||
continue;
|
||||
|
||||
var attr = type.GetCustomAttribute<ShrinkNetworkMessageAttribute>(false);
|
||||
if (attr == null || service.MessageRegistry.TryGetMeta(type, out _))
|
||||
continue;
|
||||
|
||||
service.RegisterMessage(type, attr.Opcode, attr.Route);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RegisterStaticHandlers(ShrinkNetworkService service)
|
||||
=> RegisterStaticHandlers(service, ShrinkNetworkGeneratedRegistry.GetStaticSubscriberTypes());
|
||||
|
||||
public static void RegisterStaticHandlers(ShrinkNetworkService service, IEnumerable<Type> subscriberTypes)
|
||||
{
|
||||
if (service == null)
|
||||
throw new ArgumentNullException(nameof(service));
|
||||
if (subscriberTypes == null)
|
||||
throw new ArgumentNullException(nameof(subscriberTypes));
|
||||
|
||||
foreach (var type in subscriberTypes)
|
||||
{
|
||||
if (type == null || type.GetCustomAttribute<ShrinkNetworkSubscriberAttribute>(false) == null)
|
||||
continue;
|
||||
|
||||
ScanMethodsAndRegister(service, null, type,
|
||||
type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic));
|
||||
}
|
||||
}
|
||||
|
||||
public static void RegisterHandlers(ShrinkNetworkService service, object target)
|
||||
=> RegisterHandlersInternal(service, target);
|
||||
|
||||
private static void RegisterHandlersInternal(ShrinkNetworkService service, object? target)
|
||||
{
|
||||
if (target == null)
|
||||
return;
|
||||
|
||||
var ownerType = target.GetType();
|
||||
if (ownerType.GetCustomAttribute<ShrinkNetworkSubscriberAttribute>(false) == null)
|
||||
return;
|
||||
|
||||
ScanMethodsAndRegister(service, target, ownerType,
|
||||
ownerType.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
|
||||
}
|
||||
|
||||
private static void ScanMethodsAndRegister(ShrinkNetworkService service, object? target, Type ownerType, MethodInfo[] methods)
|
||||
{
|
||||
foreach (var method in methods)
|
||||
{
|
||||
if (target != null && method.IsStatic)
|
||||
continue;
|
||||
var attributes = method.GetCustomAttributes(typeof(ShrinkNetworkSubscribeAttribute), false);
|
||||
if (attributes.Length == 0)
|
||||
continue;
|
||||
var subscribeAttr = (ShrinkNetworkSubscribeAttribute)attributes[0];
|
||||
var requirement = new ShrinkNetworkPermissionRequirement(subscribeAttr.Authority, subscribeAttr.Permission);
|
||||
|
||||
if (!TryParseHandlerSignature(method, out var hasContext, out var messageType, out var responseType))
|
||||
{
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Invalid handler signature: {ownerType.FullName}.{method.Name}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolvedMessageType = messageType!;
|
||||
EnsureMessageRegistered(service, resolvedMessageType);
|
||||
if (responseType != null)
|
||||
EnsureMessageRegistered(service, responseType);
|
||||
|
||||
if (responseType == null)
|
||||
{
|
||||
service.RegisterHandler(resolvedMessageType, (context, message) =>
|
||||
InvokeMessageHandler(target, method, hasContext, context, message), requirement);
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolvedResponseType = responseType!;
|
||||
service.RegisterRequestHandler(resolvedMessageType, resolvedResponseType, (context, request) =>
|
||||
InvokeRequestHandler(target, method, hasContext, resolvedResponseType, context, request), requirement);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseHandlerSignature(MethodInfo method, out bool hasContext, out Type? messageType, out Type? responseType)
|
||||
{
|
||||
hasContext = false;
|
||||
messageType = null;
|
||||
responseType = null;
|
||||
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length == 1)
|
||||
{
|
||||
messageType = parameters[0].ParameterType;
|
||||
}
|
||||
else if (parameters.Length == 2 && parameters[0].ParameterType == typeof(ShrinkNetworkContext))
|
||||
{
|
||||
hasContext = true;
|
||||
messageType = parameters[1].ParameterType;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!typeof(IShrinkNetworkMessage).IsAssignableFrom(messageType))
|
||||
return false;
|
||||
|
||||
var returnType = method.ReturnType;
|
||||
if (returnType == typeof(void) || returnType == typeof(UniTask))
|
||||
return true;
|
||||
|
||||
if (typeof(IShrinkNetworkResponse).IsAssignableFrom(returnType))
|
||||
{
|
||||
responseType = returnType;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(UniTask<>))
|
||||
{
|
||||
var resultType = returnType.GetGenericArguments()[0];
|
||||
if (typeof(IShrinkNetworkResponse).IsAssignableFrom(resultType))
|
||||
{
|
||||
responseType = resultType;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void EnsureMessageRegistered(ShrinkNetworkService service, Type messageType)
|
||||
{
|
||||
if (service.MessageRegistry.TryGetMeta(messageType, out _))
|
||||
return;
|
||||
|
||||
var attr = messageType.GetCustomAttribute<ShrinkNetworkMessageAttribute>(false);
|
||||
if (attr == null)
|
||||
throw new InvalidOperationException($"Message type {messageType.FullName} must declare [ShrinkNetworkMessage].");
|
||||
|
||||
service.RegisterMessage(messageType, attr.Opcode, attr.Route);
|
||||
}
|
||||
|
||||
private static async UniTask InvokeMessageHandler(object? target, MethodInfo method, bool hasContext,
|
||||
ShrinkNetworkContext context, object message)
|
||||
{
|
||||
var args = hasContext ? new object?[] { context, message } : new object?[] { message };
|
||||
var result = method.Invoke(target, args);
|
||||
if (method.ReturnType == typeof(UniTask))
|
||||
await (UniTask)(result ?? throw new InvalidOperationException($"Handler returned null UniTask: {method.DeclaringType?.FullName}.{method.Name}"));
|
||||
}
|
||||
|
||||
private static async UniTask<object?> InvokeRequestHandler(object? target, MethodInfo method, bool hasContext,
|
||||
Type responseType, ShrinkNetworkContext context, object request)
|
||||
{
|
||||
var args = hasContext ? new object?[] { context, request } : new object?[] { request };
|
||||
var result = method.Invoke(target, args);
|
||||
if (result == null)
|
||||
return null;
|
||||
|
||||
if (responseType.IsInstanceOfType(result))
|
||||
return result;
|
||||
|
||||
if (method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(UniTask<>))
|
||||
return await AwaitUniTaskResponseObject(result, responseType);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static UniTask<object?> AwaitUniTaskResponseObject(object taskObject, Type responseType)
|
||||
{
|
||||
return (UniTask<object?>)AwaitUniTaskResponseMethod
|
||||
.MakeGenericMethod(responseType)
|
||||
.Invoke(null, new[] { taskObject })!;
|
||||
}
|
||||
|
||||
private static async UniTask<object?> AwaitUniTaskResponse<TResponse>(UniTask<TResponse> task)
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
{
|
||||
return await task;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fc0b4f8cf9205df4caedc2cb1dc8b6bf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,152 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkNetworkRouter
|
||||
{
|
||||
private sealed class MessageHandlerRegistration
|
||||
{
|
||||
public ShrinkNetworkPermissionRequirement Requirement;
|
||||
public Func<ShrinkNetworkContext, object, UniTask> Handler = null!;
|
||||
}
|
||||
|
||||
private sealed class RequestHandlerRegistration
|
||||
{
|
||||
public Type ResponseType = null!;
|
||||
public ShrinkNetworkPermissionRequirement Requirement;
|
||||
public Func<ShrinkNetworkContext, object, UniTask<object?>> Handler = null!;
|
||||
}
|
||||
|
||||
private readonly Dictionary<Type, MessageHandlerRegistration> _messageHandlers = new();
|
||||
private readonly Dictionary<Type, RequestHandlerRegistration> _requestHandlers = new();
|
||||
|
||||
public void RegisterHandler<TMessage>(Func<ShrinkNetworkContext, TMessage, UniTask> handler,
|
||||
ShrinkNetworkPermissionRequirement requirement = default)
|
||||
where TMessage : IShrinkNetworkMessage
|
||||
{
|
||||
RegisterHandler(typeof(TMessage), (context, message) => handler(context, (TMessage)message), requirement);
|
||||
}
|
||||
|
||||
public void RegisterHandler(Type messageType, Func<ShrinkNetworkContext, object, UniTask> handler,
|
||||
ShrinkNetworkPermissionRequirement requirement = default)
|
||||
{
|
||||
if (messageType == null)
|
||||
throw new ArgumentNullException(nameof(messageType));
|
||||
if (handler == null)
|
||||
throw new ArgumentNullException(nameof(handler));
|
||||
if (_messageHandlers.ContainsKey(messageType) || _requestHandlers.ContainsKey(messageType))
|
||||
throw new InvalidOperationException($"Handler already exists for {messageType.FullName}.");
|
||||
|
||||
_messageHandlers.Add(messageType, new MessageHandlerRegistration
|
||||
{
|
||||
Requirement = requirement,
|
||||
Handler = handler
|
||||
});
|
||||
}
|
||||
|
||||
public void RegisterRequestHandler<TRequest, TResponse>(Func<ShrinkNetworkContext, TRequest, UniTask<TResponse>> handler,
|
||||
ShrinkNetworkPermissionRequirement requirement = default)
|
||||
where TRequest : IShrinkNetworkRequest
|
||||
where TResponse : class, IShrinkNetworkResponse
|
||||
{
|
||||
RegisterRequestHandler(typeof(TRequest), typeof(TResponse),
|
||||
async (context, message) => await handler(context, (TRequest)message), requirement);
|
||||
}
|
||||
|
||||
public void RegisterRequestHandler(Type requestType, Type responseType,
|
||||
Func<ShrinkNetworkContext, object, UniTask<object?>> handler,
|
||||
ShrinkNetworkPermissionRequirement requirement = default)
|
||||
{
|
||||
if (requestType == null)
|
||||
throw new ArgumentNullException(nameof(requestType));
|
||||
if (responseType == null)
|
||||
throw new ArgumentNullException(nameof(responseType));
|
||||
if (handler == null)
|
||||
throw new ArgumentNullException(nameof(handler));
|
||||
if (_messageHandlers.ContainsKey(requestType) || _requestHandlers.ContainsKey(requestType))
|
||||
throw new InvalidOperationException($"Handler already exists for {requestType.FullName}.");
|
||||
|
||||
_requestHandlers.Add(requestType, new RequestHandlerRegistration
|
||||
{
|
||||
ResponseType = responseType,
|
||||
Requirement = requirement,
|
||||
Handler = handler
|
||||
});
|
||||
}
|
||||
|
||||
public async UniTask<bool> DispatchAsync(ShrinkNetworkContext context, object message, Type messageType)
|
||||
{
|
||||
if (context.Packet.Kind == ShrinkNetworkPacketKind.Request &&
|
||||
_requestHandlers.TryGetValue(messageType, out var requestHandler))
|
||||
{
|
||||
if (!ShrinkNetworkPermissionValidator.IsAllowed(context.Session, requestHandler.Requirement))
|
||||
{
|
||||
context.Service.ReportPermissionDenied(messageType);
|
||||
var denied = CreatePermissionDeniedResponse(requestHandler.ResponseType, requestHandler.Requirement);
|
||||
await context.Service.SendResponseAsync(context.Session, denied, requestHandler.ResponseType,
|
||||
context.Packet.RequestToken, context.Packet.Route);
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await requestHandler.Handler(context, message);
|
||||
if (response is IShrinkNetworkResponse networkResponse)
|
||||
await context.Service.SendResponseAsync(context.Session, networkResponse, requestHandler.ResponseType,
|
||||
context.Packet.RequestToken, context.Packet.Route);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Service.ReportHandlerException(messageType, ex);
|
||||
var errorResponse = CreateErrorResponse(requestHandler.ResponseType, ex);
|
||||
await context.Service.SendResponseAsync(context.Session, errorResponse, requestHandler.ResponseType,
|
||||
context.Packet.RequestToken, context.Packet.Route);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_messageHandlers.TryGetValue(messageType, out var messageHandler))
|
||||
{
|
||||
if (!ShrinkNetworkPermissionValidator.IsAllowed(context.Session, messageHandler.Requirement))
|
||||
{
|
||||
context.Service.ReportPermissionDenied(messageType);
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Permission denied for message {messageType.FullName} on session {context.Session.SessionId}.");
|
||||
return true;
|
||||
}
|
||||
|
||||
await messageHandler.Handler(context, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IShrinkNetworkResponse CreateErrorResponse(Type responseType, Exception ex)
|
||||
{
|
||||
if (Activator.CreateInstance(responseType) is not IShrinkNetworkResponse response)
|
||||
throw new InvalidOperationException($"Response type {responseType.FullName} cannot be instantiated.", ex);
|
||||
|
||||
response.ErrorCode = ShrinkRpcErrorCode.HandlerException;
|
||||
response.ErrorMessage = ex.Message;
|
||||
return response;
|
||||
}
|
||||
|
||||
private static IShrinkNetworkResponse CreatePermissionDeniedResponse(Type responseType,
|
||||
ShrinkNetworkPermissionRequirement requirement)
|
||||
{
|
||||
if (Activator.CreateInstance(responseType) is not IShrinkNetworkResponse response)
|
||||
throw new InvalidOperationException($"Response type {responseType.FullName} cannot be instantiated.");
|
||||
|
||||
response.ErrorCode = ShrinkRpcErrorCode.PermissionDenied;
|
||||
response.ErrorMessage = string.IsNullOrEmpty(requirement.Permission)
|
||||
? $"Permission denied. Authority={requirement.Authority}"
|
||||
: $"Permission denied. Authority={requirement.Authority}, Permission={requirement.Permission}";
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 89e99852277437b439ff7891cd6aa8d5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5ee1de0ef0ca6a4ba49cc8d900808f5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public interface IShrinkNetworkSerializer
|
||||
{
|
||||
byte[] Serialize(object value);
|
||||
object Deserialize(byte[] payload, Type type);
|
||||
T Deserialize<T>(byte[] payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f1353960f8123d4f96a4815b3627051
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,29 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkJsonNetworkSerializer : IShrinkNetworkSerializer
|
||||
{
|
||||
private static readonly JsonSerializerSettings Settings = new()
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
DefaultValueHandling = DefaultValueHandling.Include,
|
||||
Formatting = Formatting.None
|
||||
};
|
||||
|
||||
public byte[] Serialize(object value)
|
||||
=> Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(value, Settings));
|
||||
|
||||
public object Deserialize(byte[] payload, Type type)
|
||||
=> JsonConvert.DeserializeObject(Encoding.UTF8.GetString(payload), type, Settings)
|
||||
?? throw new JsonSerializationException($"Failed to deserialize payload into {type.FullName}.");
|
||||
|
||||
public T Deserialize<T>(byte[] payload)
|
||||
=> JsonConvert.DeserializeObject<T>(Encoding.UTF8.GetString(payload), Settings)
|
||||
?? throw new JsonSerializationException($"Failed to deserialize payload into {typeof(T).FullName}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f2a0f828927e72b4b9d1542cc8a24b8c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,150 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkMessagePackNetworkSerializer : IShrinkNetworkSerializer
|
||||
{
|
||||
private readonly MethodInfo _serializeMethod;
|
||||
private readonly MethodInfo _deserializeMethod;
|
||||
private readonly object? _serializerOptions;
|
||||
|
||||
public ShrinkMessagePackNetworkSerializer()
|
||||
{
|
||||
var serializerType = Type.GetType("MessagePack.MessagePackSerializer, MessagePack");
|
||||
if (serializerType == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"MessagePack assembly was not found. Please install MessagePack-CSharp before using ShrinkMessagePackNetworkSerializer.");
|
||||
}
|
||||
|
||||
_serializerOptions = ResolveSerializerOptions(serializerType.Assembly);
|
||||
|
||||
var serializeMethod = serializerType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(m =>
|
||||
{
|
||||
if (m.Name != "Serialize")
|
||||
return false;
|
||||
var parameters = m.GetParameters();
|
||||
return parameters.Length >= 2 &&
|
||||
parameters[0].ParameterType == typeof(Type) &&
|
||||
parameters[1].ParameterType == typeof(object);
|
||||
});
|
||||
|
||||
var deserializeMethod = serializerType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(m =>
|
||||
{
|
||||
if (m.Name != "Deserialize")
|
||||
return false;
|
||||
var parameters = m.GetParameters();
|
||||
return parameters.Length >= 2 &&
|
||||
parameters[0].ParameterType == typeof(Type) &&
|
||||
(parameters[1].ParameterType == typeof(byte[]) ||
|
||||
parameters[1].ParameterType == typeof(ReadOnlyMemory<byte>));
|
||||
});
|
||||
|
||||
if (serializeMethod == null || deserializeMethod == null)
|
||||
throw new MissingMethodException("MessagePack serialize/deserialize API not found.");
|
||||
|
||||
_serializeMethod = serializeMethod;
|
||||
_deserializeMethod = deserializeMethod;
|
||||
}
|
||||
|
||||
public byte[] Serialize(object value)
|
||||
{
|
||||
if (value == null)
|
||||
return Array.Empty<byte>();
|
||||
|
||||
var parameters = BuildParameters(_serializeMethod, value.GetType(), value, _serializerOptions);
|
||||
return (byte[])_serializeMethod.Invoke(null, parameters)!;
|
||||
}
|
||||
|
||||
public object Deserialize(byte[] payload, Type type)
|
||||
{
|
||||
payload ??= Array.Empty<byte>();
|
||||
var parameters = BuildParameters(_deserializeMethod, type, payload, _serializerOptions);
|
||||
return _deserializeMethod.Invoke(null, parameters)
|
||||
?? throw new InvalidOperationException($"MessagePack returned null for type {type.FullName}.");
|
||||
}
|
||||
|
||||
public T Deserialize<T>(byte[] payload)
|
||||
{
|
||||
return (T)Deserialize(payload, typeof(T));
|
||||
}
|
||||
|
||||
private static object?[] BuildParameters(MethodInfo method, Type type, object valueOrBytes, object? serializerOptions)
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
var args = new object?[parameters.Length];
|
||||
|
||||
if (parameters.Length > 0)
|
||||
args[0] = type;
|
||||
if (parameters.Length > 1)
|
||||
args[1] = ConvertPrimaryArgument(parameters[1].ParameterType, valueOrBytes);
|
||||
|
||||
for (var i = 2; i < parameters.Length; i++)
|
||||
{
|
||||
args[i] = ResolveAdditionalArgument(parameters[i], serializerOptions);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private static object? ResolveAdditionalArgument(ParameterInfo parameter, object? serializerOptions)
|
||||
{
|
||||
if (serializerOptions != null && parameter.ParameterType.IsInstanceOfType(serializerOptions))
|
||||
return serializerOptions;
|
||||
|
||||
return parameter.HasDefaultValue
|
||||
? parameter.DefaultValue
|
||||
: GetDefault(parameter.ParameterType);
|
||||
}
|
||||
|
||||
private static object ConvertPrimaryArgument(Type parameterType, object value)
|
||||
{
|
||||
if (parameterType == typeof(ReadOnlyMemory<byte>) && value is byte[] bytes)
|
||||
return new ReadOnlyMemory<byte>(bytes);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static object? ResolveSerializerOptions(Assembly serializerAssembly)
|
||||
{
|
||||
var contractlessResolverType = serializerAssembly.GetType("MessagePack.Resolvers.ContractlessStandardResolver");
|
||||
if (contractlessResolverType != null)
|
||||
{
|
||||
var optionsField = contractlessResolverType.GetField("Options",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var options = optionsField?.GetValue(null);
|
||||
if (options != null)
|
||||
return options;
|
||||
|
||||
var instanceField = contractlessResolverType.GetField("Instance",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var instance = instanceField?.GetValue(null);
|
||||
if (instance != null)
|
||||
{
|
||||
var optionsType = serializerAssembly.GetType("MessagePack.MessagePackSerializerOptions");
|
||||
var standardProperty = optionsType?.GetProperty("Standard", BindingFlags.Public | BindingFlags.Static);
|
||||
var standardOptions = standardProperty?.GetValue(null);
|
||||
var withResolverMethod = optionsType?.GetMethod("WithResolver", BindingFlags.Public | BindingFlags.Instance);
|
||||
var resolvedOptions = withResolverMethod?.Invoke(standardOptions, new[] { instance });
|
||||
if (resolvedOptions != null)
|
||||
return resolvedOptions;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object? GetDefault(Type type)
|
||||
{
|
||||
return type.IsValueType ? Activator.CreateInstance(type) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 88c6239e9fc049f43b2999d7311eec1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "ShrinkNetwork.Runtime",
|
||||
"rootNamespace": "ShrinkNetwork",
|
||||
"references": [
|
||||
"UniTask",
|
||||
"Newtonsoft.Json"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": true,
|
||||
"overrideReferences": false,
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 74abbc4dbafd9ac449e816fe51c954b9
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user