feat(packages): 内置 SDK 包并完善 ContextLoader 集成

- 将 ShrinkEventBus、ShrinkDataSaver 及其 EventBus 集成从 gitlink 转为仓库直接维护的完整 UPM 包,补齐运行时、编辑器工具、测试与文档
- 新增 Command 和 Network 的 App 集成组件,支持 ContextLoader 服务发布、可逆注销及 Network Loopback 生命周期管理
- 更新 Starter 与演示组合逻辑,缺失模块时可注册、已有兼容安装器时可覆盖,并补充宿主启动断言
- 升级内部包依赖与 Shared CodeGen 包定义,放宽 Integration.App 包的 Git 忽略规则
- 将独立服务器生成器改为基于已编译程序集的语义扫描,支持 partial、复杂泛型、命名冲突检测及模板 SHA-256 覆写保护
- 新增 Network 语义扫描、模板保护和 App 组件生命周期测试
- 新增真实 UPM 消费工程验证脚本,校验内部版本一致性、程序集加载及 EditMode 测试
- 重构当前架构文档并归档已完成的 Cordis 迁移与旧代码地图
This commit is contained in:
2026-08-18 18:06:34 +08:00
parent 517c4cf46e
commit d74c2f08ca
240 changed files with 13647 additions and 545 deletions
@@ -4,8 +4,8 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;
@@ -19,7 +19,7 @@ public static class ShrinkDedicatedServerScaffoldGenerator
private const string TemplateStampFileName = ".shrink-server-template.json";
private const string TemplateManifestFileName = ".shrink-server-template-files.txt";
private sealed class MessageSpec
internal sealed class MessageSpec
{
public string TypeName = string.Empty;
public string Kind = "message";
@@ -41,7 +41,7 @@ public static class ShrinkDedicatedServerScaffoldGenerator
public string SuggestedAction = string.Empty;
}
private sealed class SubscriberSpec
internal sealed class SubscriberSpec
{
public string MemberName = string.Empty;
public string SourcePath = string.Empty;
@@ -49,14 +49,14 @@ public static class ShrinkDedicatedServerScaffoldGenerator
public string Permission = string.Empty;
}
private sealed class EnumSpec
internal sealed class EnumSpec
{
public string Name = string.Empty;
public string SourcePath = string.Empty;
public List<(string Name, string? Value)> Members = new();
}
private sealed class DataTypeSpec
internal sealed class DataTypeSpec
{
public string Name = string.Empty;
public string Kind = "class";
@@ -78,50 +78,6 @@ public static class ShrinkDedicatedServerScaffoldGenerator
public string SnapshotElementType = string.Empty;
}
private static readonly Regex MessageHeaderRegex = new(
@"\[ShrinkNetworkMessage\((?<opcode>-?\d+)\s*,\s*""(?<route>[^""]+)""\)\][\s\S]*?(?<decl>public\s+(?:sealed\s+|partial\s+)?class\s+(?<name>\w+)\s*:\s*(?<bases>[^{\r\n]+))",
RegexOptions.Compiled);
private static readonly Regex PropertyRegex = new(
@"public\s+(?<type>[\w<>\[\]\?,\.]+)\s+(?<name>\w+)\s*\{\s*get;\s*set;\s*\}",
RegexOptions.Compiled);
private static readonly Regex NetworkTypeRegex = new(
@"public\s+(?:sealed\s+|partial\s+)?class\s+(?<name>\w+)\s*:\s*(?<bases>[^{\r\n]+)",
RegexOptions.Compiled);
private static readonly Regex RegisteredMessageRegex = new(
@"RegisterMessage<(?<name>\w+)>\((?<opcode>-?\d+)\s*,\s*""(?<route>[^""]+)""\)",
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 StateSyncAttributeRegex = new(
@"\[ShrinkNetworkStateSync\(\s*""(?<group>[^""]+)""\s*,\s*ShrinkNetworkStateSyncRole\.(?<role>\w+)\s*\)\]",
RegexOptions.Compiled);
private static readonly Regex EnumHeaderRegex = new(
@"public\s+enum\s+(?<name>\w+)\s*",
RegexOptions.Compiled);
private static readonly Regex EnumMemberRegex = new(
@"(?m)^\s*(?<name>[A-Za-z_]\w*)(?:\s*=\s*(?<value>[^,\r\n/]+))?\s*,?\s*(?://.*)?$",
RegexOptions.Compiled);
private static readonly Regex DataTypeHeaderRegex = new(
@"public\s+(?:sealed\s+|partial\s+|readonly\s+)?(?<kind>class|struct)\s+(?<name>\w+)\s*(?::[^{\r\n]+)?",
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);
private static readonly HashSet<string> KnownTypeTokens = new(StringComparer.Ordinal)
{
"bool", "byte", "sbyte", "short", "ushort", "int", "uint", "long", "ulong",
@@ -209,7 +165,7 @@ public static class ShrinkDedicatedServerScaffoldGenerator
return (projectRoot, assetsPath, templateRoot, serverProjectRoot, generatedOutputRoot);
}
private static void CopyTemplateProject(string templateRoot, string outputRoot)
internal static void CopyTemplateProject(string templateRoot, string outputRoot)
{
if (!Directory.Exists(templateRoot))
throw new DirectoryNotFoundException($"未找到插件内服务器模板:{templateRoot}");
@@ -222,41 +178,95 @@ public static class ShrinkDedicatedServerScaffoldGenerator
$"目标目录已存在且不是 ShrinkNetwork 自动生成的模板工程,为避免覆盖人工修改,已中止:{outputRoot}");
}
var newManagedFiles = new List<string>();
var manifestPath = Path.Combine(outputRoot, TemplateManifestFileName);
var previousManagedFiles = ReadTemplateManifest(manifestPath);
var templates = new List<(string RelativePath, string OutputPath, byte[] Content)>();
foreach (var templateFile in Directory.EnumerateFiles(templateRoot, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(templateRoot, templateFile);
var outputRelativePath = relativePath.EndsWith(".txt", StringComparison.Ordinal)
? relativePath[..^".txt".Length]
: relativePath;
newManagedFiles.Add(outputRelativePath.Replace('\\', '/'));
var outputFile = Path.Combine(outputRoot, outputRelativePath);
var outputDir = Path.GetDirectoryName(outputFile);
if (!string.IsNullOrWhiteSpace(outputDir))
Directory.CreateDirectory(outputDir);
var content = File.ReadAllText(templateFile, Encoding.UTF8);
File.WriteAllText(outputFile, content, new UTF8Encoding(false));
var normalizedContent = new UTF8Encoding(false).GetBytes(content);
var normalizedRelativePath = outputRelativePath.Replace('\\', '/');
templates.Add((
normalizedRelativePath,
ResolveManagedOutputPath(outputRoot, normalizedRelativePath),
normalizedContent));
}
var manifestPath = Path.Combine(outputRoot, TemplateManifestFileName);
if (File.Exists(manifestPath))
var newManagedPaths = new HashSet<string>(templates.Select(item => item.RelativePath), StringComparer.Ordinal);
var conflicts = new List<string>();
foreach (var template in templates)
{
var previousManagedFiles = File.ReadAllLines(manifestPath, Encoding.UTF8)
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToArray();
foreach (var previousManagedFile in previousManagedFiles)
{
if (newManagedFiles.Contains(previousManagedFile, StringComparer.Ordinal))
continue;
if (!File.Exists(template.OutputPath))
continue;
var staleFile = Path.Combine(outputRoot, previousManagedFile.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(staleFile))
File.Delete(staleFile);
var currentHash = ComputeSha256(File.ReadAllBytes(template.OutputPath));
if (previousManagedFiles.TryGetValue(template.RelativePath, out var previousHash))
{
if (!string.IsNullOrWhiteSpace(previousHash))
{
if (!string.Equals(currentHash, previousHash, StringComparison.OrdinalIgnoreCase))
conflicts.Add(template.RelativePath);
}
else if (!string.Equals(currentHash, ComputeSha256(template.Content), StringComparison.OrdinalIgnoreCase))
{
conflicts.Add(template.RelativePath + "(旧清单无法证明文件未被修改)");
}
}
else if (!string.Equals(currentHash, ComputeSha256(template.Content), StringComparison.OrdinalIgnoreCase))
{
conflicts.Add(template.RelativePath + "(不在生成清单中)");
}
}
File.WriteAllLines(manifestPath, newManagedFiles.OrderBy(item => item, StringComparer.Ordinal), new UTF8Encoding(false));
foreach (var previousManagedFile in previousManagedFiles)
{
if (newManagedPaths.Contains(previousManagedFile.Key))
continue;
var stalePath = ResolveManagedOutputPath(outputRoot, previousManagedFile.Key);
if (!File.Exists(stalePath))
continue;
var currentHash = ComputeSha256(File.ReadAllBytes(stalePath));
if (string.IsNullOrWhiteSpace(previousManagedFile.Value) ||
!string.Equals(currentHash, previousManagedFile.Value, StringComparison.OrdinalIgnoreCase))
{
conflicts.Add(previousManagedFile.Key + "(待删除的旧模板文件已被修改)");
}
}
if (conflicts.Count > 0)
{
throw new InvalidOperationException(
"检测到人工修改的模板托管文件。生成器未写入任何文件,请先保留或迁移这些修改:" +
Environment.NewLine + string.Join(Environment.NewLine, conflicts.OrderBy(item => item, StringComparer.Ordinal)));
}
foreach (var template in templates)
{
var outputDirectory = Path.GetDirectoryName(template.OutputPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
Directory.CreateDirectory(outputDirectory);
File.WriteAllBytes(template.OutputPath, template.Content);
}
foreach (var previousManagedFile in previousManagedFiles.Keys)
{
if (newManagedPaths.Contains(previousManagedFile))
continue;
var stalePath = ResolveManagedOutputPath(outputRoot, previousManagedFile);
if (File.Exists(stalePath))
File.Delete(stalePath);
}
var manifestLines = templates
.OrderBy(item => item.RelativePath, StringComparer.Ordinal)
.Select(item => item.RelativePath + "\t" + ComputeSha256(item.Content));
File.WriteAllLines(manifestPath, manifestLines, new UTF8Encoding(false));
var stamp = new StringBuilder();
stamp.AppendLine("{");
@@ -267,39 +277,55 @@ public static class ShrinkDedicatedServerScaffoldGenerator
File.WriteAllText(stampPath, stamp.ToString(), new UTF8Encoding(false));
}
private static Dictionary<string, string> ReadTemplateManifest(string manifestPath)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
if (!File.Exists(manifestPath))
return result;
foreach (var rawLine in File.ReadAllLines(manifestPath, Encoding.UTF8))
{
var line = rawLine.Trim();
if (line.Length == 0)
continue;
var separatorIndex = line.IndexOf('\t');
var relativePath = separatorIndex >= 0 ? line[..separatorIndex] : line;
var hash = separatorIndex >= 0 ? line[(separatorIndex + 1)..].Trim() : string.Empty;
result[relativePath.Replace('\\', '/')] = hash;
}
return result;
}
private static string ResolveManagedOutputPath(string outputRoot, string relativePath)
{
var normalizedRoot = Path.GetFullPath(outputRoot)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var fullPath = Path.GetFullPath(Path.Combine(normalizedRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
if (!fullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"模板清单包含越界路径:{relativePath}");
return fullPath;
}
private static string ComputeSha256(byte[] content)
{
using var sha256 = SHA256.Create();
return BitConverter.ToString(sha256.ComputeHash(content)).Replace("-", string.Empty);
}
private static void GenerateScaffoldFiles(string projectRoot, string assetsPath, string outputDir)
{
Directory.CreateDirectory(outputDir);
var messages = new List<MessageSpec>();
var subscribers = new List<SubscriberSpec>();
var enums = new List<EnumSpec>();
var dataTypes = new List<DataTypeSpec>();
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, Encoding.UTF8);
var relativePath = Path.GetRelativePath(projectRoot, file).Replace('\\', '/');
ExtractMessages(content, relativePath, messages);
ExtractSubscribers(content, relativePath, subscribers);
ExtractEnums(content, relativePath, enums);
ExtractDataTypes(content, relativePath, dataTypes);
}
messages = messages.GroupBy(item => item.TypeName, StringComparer.Ordinal).Select(group => group.First())
var scanResult = ShrinkNetworkSemanticScanner.ScanCompiledPlayerAssemblies();
var messages = scanResult.Messages
.OrderBy(item => item.Opcode).ThenBy(item => item.TypeName, StringComparer.Ordinal).ToList();
subscribers = subscribers.OrderBy(item => item.Permission, StringComparer.Ordinal)
var subscribers = scanResult.Subscribers.OrderBy(item => item.Permission, StringComparer.Ordinal)
.ThenBy(item => item.MemberName, StringComparer.Ordinal).ToList();
enums = enums.GroupBy(item => item.Name, StringComparer.Ordinal).Select(group => group.First())
var enums = scanResult.Enums
.OrderBy(item => item.Name, StringComparer.Ordinal).ToList();
var messageTypeNames = new HashSet<string>(messages.Select(item => item.TypeName), StringComparer.Ordinal);
dataTypes = dataTypes.Where(item => !messageTypeNames.Contains(item.Name))
.GroupBy(item => item.Name, StringComparer.Ordinal).Select(group => group.First())
var dataTypes = scanResult.DataTypes
.OrderBy(item => item.Name, StringComparer.Ordinal).ToList();
ValidateTemplateConflicts(messages);
@@ -326,152 +352,6 @@ public static class ShrinkDedicatedServerScaffoldGenerator
}
}
private static void ExtractMessages(string content, string relativePath, List<MessageSpec> target)
{
var metadataByType = new Dictionary<string, (int Opcode, string Route)>(StringComparer.Ordinal);
foreach (Match match in MessageHeaderRegex.Matches(content))
{
metadataByType[match.Groups["name"].Value] = (
int.Parse(match.Groups["opcode"].Value),
match.Groups["route"].Value);
}
foreach (Match match in RegisteredMessageRegex.Matches(content))
{
var typeName = match.Groups["name"].Value;
if (!metadataByType.ContainsKey(typeName))
{
metadataByType[typeName] = (
int.Parse(match.Groups["opcode"].Value),
match.Groups["route"].Value);
}
}
foreach (Match match in NetworkTypeRegex.Matches(content))
{
var typeName = match.Groups["name"].Value;
if (!metadataByType.TryGetValue(typeName, out var metadata))
continue;
var bases = match.Groups["bases"].Value;
var kind = bases.Contains("IShrinkNetworkRequest", StringComparison.Ordinal)
? "request"
: bases.Contains("ShrinkRpcResponseBase", StringComparison.Ordinal)
? "response"
: bases.Contains("IShrinkNetworkMessage", StringComparison.Ordinal)
? "message"
: string.Empty;
if (string.IsNullOrWhiteSpace(kind))
continue;
var body = TryExtractBody(content, match.Index);
var headerStart = Math.Max(0, match.Index - 256);
var headerLength = Math.Min(content.Length - headerStart, match.Length + 256);
var headerSnippet = content.Substring(headerStart, headerLength);
var syncMatch = StateSyncAttributeRegex.Match(headerSnippet);
var spec = new MessageSpec
{
TypeName = typeName,
Opcode = metadata.Opcode,
Route = metadata.Route,
SourcePath = relativePath,
HasResult = headerSnippet.Contains("[HasResult]", StringComparison.Ordinal),
IsNetworkEvent = headerSnippet.Contains("[ShrinkNetworkEvent]", StringComparison.Ordinal),
IsDeltaEvent = bases.Contains("IShrinkNetworkDeltaEvent", StringComparison.Ordinal),
SyncGroup = syncMatch.Success ? syncMatch.Groups["group"].Value.Trim() : string.Empty,
SyncRole = syncMatch.Success ? syncMatch.Groups["role"].Value.Trim() : string.Empty,
Kind = kind
};
foreach (Match propertyMatch in PropertyRegex.Matches(body))
spec.Properties.Add((propertyMatch.Groups["type"].Value, propertyMatch.Groups["name"].Value));
target.Add(spec);
}
}
private static void ExtractSubscribers(string content, string relativePath, List<SubscriberSpec> target)
{
foreach (Match match in SubscriberRegex.Matches(content))
{
var args = match.Groups["args"].Value;
target.Add(new SubscriberSpec
{
SourcePath = relativePath,
MemberName = match.Groups["name"].Value,
Authority = AuthorityRegex.Match(args).Groups["value"].Value,
Permission = PermissionRegex.Match(args).Groups["value"].Value
});
}
}
private static void ExtractEnums(string content, string relativePath, List<EnumSpec> target)
{
foreach (Match match in EnumHeaderRegex.Matches(content))
{
var body = TryExtractBody(content, match.Index);
if (string.IsNullOrWhiteSpace(body))
continue;
var spec = new EnumSpec { Name = match.Groups["name"].Value, SourcePath = relativePath };
foreach (Match memberMatch in EnumMemberRegex.Matches(body))
{
var memberName = memberMatch.Groups["name"].Value;
if (string.IsNullOrWhiteSpace(memberName))
continue;
var memberValue = memberMatch.Groups["value"].Success ? memberMatch.Groups["value"].Value.Trim() : null;
spec.Members.Add((memberName, string.IsNullOrWhiteSpace(memberValue) ? null : memberValue));
}
if (spec.Members.Count > 0)
target.Add(spec);
}
}
private static void ExtractDataTypes(string content, string relativePath, List<DataTypeSpec> target)
{
foreach (Match match in DataTypeHeaderRegex.Matches(content))
{
var body = TryExtractBody(content, match.Index);
if (string.IsNullOrWhiteSpace(body))
continue;
var spec = new DataTypeSpec
{
Name = match.Groups["name"].Value,
Kind = match.Groups["kind"].Value,
SourcePath = relativePath
};
foreach (Match propertyMatch in PropertyRegex.Matches(body))
spec.Properties.Add((propertyMatch.Groups["type"].Value, propertyMatch.Groups["name"].Value));
if (spec.Properties.Count > 0)
target.Add(spec);
}
}
private static string TryExtractBody(string content, int startIndex)
{
var braceIndex = content.IndexOf('{', startIndex);
if (braceIndex < 0)
return string.Empty;
var depth = 0;
for (var i = braceIndex; i < content.Length; i++)
{
if (content[i] == '{') depth++;
else if (content[i] == '}')
{
depth--;
if (depth == 0)
return content.Substring(braceIndex + 1, i - braceIndex - 1);
}
}
return string.Empty;
}
private static void ResolvePortableDependencies(
IReadOnlyList<MessageSpec> messages,
IReadOnlyList<EnumSpec> enums,
@@ -531,11 +411,22 @@ public static class ShrinkDedicatedServerScaffoldGenerator
private static IEnumerable<string> ExtractTypeDependencies(string typeName)
{
foreach (Match match in Regex.Matches(typeName, @"[A-Za-z_]\w*"))
var token = new StringBuilder();
foreach (var character in typeName.Append(' '))
{
var token = match.Value;
if (!KnownTypeTokens.Contains(token))
yield return token;
if (char.IsLetterOrDigit(character) || character == '_')
{
token.Append(character);
continue;
}
if (token.Length == 0)
continue;
var value = token.ToString();
token.Clear();
if (!KnownTypeTokens.Contains(value) && !char.IsDigit(value[0]))
yield return value;
}
}
@@ -1113,11 +1004,15 @@ public static class ShrinkDedicatedServerScaffoldGenerator
return true;
}
var genericMatch = Regex.Match(collectionType, @"^[A-Za-z_][\w\.]*<(?<element>[A-Za-z_][\w\.\?]*)>$");
if (genericMatch.Success)
var genericStart = collectionType.IndexOf('<');
if (genericStart > 0 && collectionType.EndsWith(">", StringComparison.Ordinal))
{
elementType = genericMatch.Groups["element"].Value;
return true;
var candidate = collectionType.Substring(genericStart + 1, collectionType.Length - genericStart - 2).Trim();
if (candidate.Length > 0)
{
elementType = candidate;
return true;
}
}
elementType = string.Empty;
@@ -1137,7 +1032,13 @@ public static class ShrinkDedicatedServerScaffoldGenerator
private static string SanitizeIdentifier(string value)
{
var token = value.Replace("/", "_").Replace("-", "_");
token = Regex.Replace(token, @"[^A-Za-z0-9_]", "_");
token = new string(token.Select(character =>
(character >= 'A' && character <= 'Z') ||
(character >= 'a' && character <= 'z') ||
(character >= '0' && character <= '9') ||
character == '_'
? character
: '_').ToArray());
if (string.IsNullOrWhiteSpace(token))
return "Room";
if (!char.IsLetter(token[0]) && token[0] != '_')
@@ -0,0 +1,409 @@
#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 HasResultAttributeName = "ShrinkEventBus.HasResultAttribute";
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 = HasAttribute(type, HasResultAttributeName),
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 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: