ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity与.NET WebSocket文件传输:基于NativeWebSocket与Fleck的实时方案

Unity与.NET WebSocket文件传输:基于NativeWebSocket与Fleck的实时方案

1. 项目概述与核心价值

最近在做一个Unity项目,需要实现一个实时性要求比较高的文件传输功能。传统的HTTP协议在频繁的小文件传输或者需要服务端主动推送进度时,显得有些笨重,每次请求都要建立连接、传输、断开,开销不小。而WebSocket协议,作为一种全双工通信协议,正好能解决这个问题——连接一旦建立,客户端和服务器就可以在任意时刻互发数据,非常适合用来做文件上传下载这种需要实时交互的场景。

这个项目,就是基于Unity,结合NativeWebSocket客户端和.NET服务端的Fleck库,搭建一个完整的WebSocket文件传输系统。我会把整个实现过程,从环境搭建、协议设计、核心代码实现到各种“坑”的规避,都详细拆解一遍。无论你是想在自己的游戏里加入实时更新资源包的功能,还是需要做一个内网工具进行快速文件分发,这套方案都能给你提供一个扎实的起点。代码我会尽量保持清晰、模块化,方便你直接拿去集成或修改。

2. 技术选型与架构设计思路

为什么选NativeWebSocketFleck?这背后有几个很实际的考量。

首先看客户端。Unity里处理WebSocket的插件不少,比如WebSocketSharpBest HTTP/2的WebSocket模块等。我选择NativeWebSocket,主要是因为它足够轻量、纯粹,并且对移动平台(iOS/Android)的支持很好。它是一个纯C#的实现,不依赖任何原生插件(Native Plugin),这意味着在WebGL平台也能用,虽然WebGL平台对二进制数据的处理有些特殊,但至少是可行的。它的API设计也很简洁,事件驱动,用起来非常顺手。

服务端选择Fleck,原因类似。它是一个轻量级的.NET WebSocket服务器库,基于async/await,性能不错,而且使用起来非常简单,几行代码就能跑起来一个WebSocket服务器。对于中小型项目或者内部工具来说,它比SignalR更轻,比直接用System.Net.WebSockets封装要省事得多。我们的文件传输服务端通常不需要特别复杂的路由和鉴权逻辑,Fleck的简洁性正合适。

整个架构的设计思路是“命令-数据”分离。WebSocket连接本身只负责传输数据流,我们需要自己定义一套简单的应用层协议来告诉对方“接下来要干什么”。比如,客户端要上传文件,不能直接把文件二进制流发过去,服务器会懵。所以,我们会先发一个小的JSON数据包(命令帧),告诉服务器:“嘿,我要开始上传了,文件名是level1_assets.zip,大小是1024000字节”。服务器收到这个命令后,做好准备(比如在指定目录创建空文件),然后回复一个“可以开始发送数据”的指令。之后,客户端再将文件切分成一个个数据块(数据帧)发送过去。下载流程则相反。

这种设计的好处是逻辑清晰,易于处理中断、续传和错误。如果传输中途连接断了,重连后我们可以根据已传输的大小,发送一个“续传”命令,从断点继续,而不是从头开始。

3. 环境准备与核心库引入

3.1 Unity客户端环境搭建

在Unity中,我们首先需要导入NativeWebSocket。最方便的方式是通过Unity的Package Manager使用Git URL添加。

  1. 打开Unity项目,进入Window -> Package Manager
  2. 点击左上角的+号,选择Add package from git URL...
  3. 在弹出的输入框中,填入NativeWebSocket的Git仓库地址:https://github.com/endel/NativeWebSocket.git#upm
  4. 点击Add,等待Unity下载并导入包。

导入成功后,你可以在Packages目录下看到NativeWebSocket。现在,在任何脚本中,你都可以通过using NativeWebSocket;来使用它了。

注意:如果你遇到Git相关错误,可能是因为没安装Git或者Unity找不到Git路径。确保系统已安装Git,并在Unity的Edit -> Preferences -> External Tools中正确配置了Git的安装路径。

3.2 .NET 服务端环境搭建

服务端我们创建一个简单的.NET Console App。使用Visual Studio或者命令行工具(dotnet new console)创建一个新项目。

然后,通过NuGet包管理器添加Fleck库。在项目根目录的.csproj文件中添加包引用,或者使用NuGet管理器界面搜索Fleck安装。

<!-- 在你的 .csproj 文件中添加 --> <ItemGroup> <PackageReference Include="Fleck" Version="1.2.0" /> </ItemGroup>

Fleck依赖于System.Net.WebSockets,但后者是.NET Framework/.NET Core/.NET 5+内置的,所以通常不需要额外安装。

4. 核心协议设计与消息格式定义

在WebSocket的二进制流之上,我们需要定义自己的消息格式。一个完整的消息由“消息头”和“消息体”组成。

消息头 (Message Header):我们用一个简短的JSON字符串作为消息头,它至少包含两个字段:

  • type: 消息类型,用于区分是命令还是数据。例如:command,data
  • action: 具体动作。例如:upload_start,upload_data,download_request,progress等。

消息体 (Message Body):

  • 对于command类型的消息,消息体可以是JSON字符串,包含更多参数,如fileName,fileSize,offset(用于断点续传)等。
  • 对于data类型的消息,消息体就是纯粹的二进制数据块。

如何组合发送?我们采用“头体分离,顺序发送”的方式。在同一WebSocket连接中,先发送一个完整的、包含消息头的文本帧(WebSocketOpcode.Text),紧接着发送一个或多个包含消息体的二进制帧(WebSocketOpcode.Binary)。接收方按顺序解析即可。

例如,上传文件的流程:

  1. 客户端 -> 服务器: 发送文本帧{"type":"command", "action":"upload_start", "fileName":"test.zip", "fileSize": 2048000}
  2. 服务器 -> 客户端: 发送文本帧{"type":"command", "action":"upload_ack"}(确认可以开始)
  3. 客户端 -> 服务器: 发送二进制帧[字节数据块1]
  4. 客户端 -> 服务器: 发送二进制帧[字节数据块2]
  5. ... 直到发送完毕
  6. 客户端 -> 服务器: 发送文本帧{"type":"command", "action":"upload_end"}
  7. 服务器 -> 客户端: 发送文本帧{"type":"command", "action":"upload_complete"}

这种设计避免了将元数据和二进制数据混合编码(如Base64)导致的开销增大,也保持了协议的灵活性和可读性。

5. Unity客户端核心实现详解

5.1 WebSocket连接管理与事件处理

首先,我们创建一个FileWebSocketClient类来封装所有WebSocket操作。

using NativeWebSocket; using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading.Tasks; using UnityEngine; public class FileWebSocketClient : MonoBehaviour { private WebSocket websocket; private string serverAddress = "ws://127.0.0.1:8181"; // 服务器地址 // 定义一些事件,方便UI或其他脚本监听 public event Action OnConnected; public event Action<string> OnDisconnected; public event Action<string> OnError; public event Action<long, long> OnUploadProgress; // 已传,总量 public event Action<long, long> OnDownloadProgress; // 已收,总量 private Dictionary<string, FileTransferTask> activeTasks = new Dictionary<string, FileTransferTask>(); async void Start() { // 启动时自动连接,实际项目可根据需要调整 await Connect(); } public async Task Connect() { if (websocket != null && websocket.State == WebSocketState.Open) { Debug.LogWarning("WebSocket is already connected."); return; } websocket = new WebSocket(serverAddress); websocket.OnOpen += () => { Debug.Log("Connection opened!"); OnConnected?.Invoke(); }; websocket.OnError += (errorMsg) => { Debug.LogError("WebSocket Error: " + errorMsg); OnError?.Invoke(errorMsg); }; websocket.OnClose += (closeCode) => { Debug.Log("Connection closed with code: " + closeCode); OnDisconnected?.Invoke(closeCode.ToString()); }; websocket.OnMessage += HandleIncomingMessage; try { await websocket.Connect(); } catch (Exception ex) { Debug.LogError("Connect failed: " + ex.Message); OnError?.Invoke(ex.Message); } } void Update() { // NativeWebSocket 需要在主线程分发事件 #if !UNITY_WEBGL || UNITY_EDITOR if (websocket != null) { websocket.DispatchMessageQueue(); } #endif } private async void OnDestroy() { if (websocket != null && websocket.State == WebSocketState.Open) { await websocket.Close(); } } // ... 后续处理消息和文件传输的方法 }

实操心得:NativeWebSocket在WebGL平台下,消息回调是自动在主线程执行的。但在其他平台(如PC、移动端),OnMessage等事件可能在后台线程触发。因此,我们需要在Update()中调用DispatchMessageQueue(),将消息队列里的回调函数拉到主线程执行,这样才能安全地操作Unity的API(如更新UI、打印Log)。这是一个非常关键的细节,忘记调用会导致回调不执行或者报错。

5.2 文件上传功能实现

上传的核心是将文件分块读取,并按照我们的协议发送。我们定义一个FileTransferTask类来管理单个传输任务的状态。

public class FileTransferTask { public string FilePath { get; set; } public string FileName { get; set; } public long FileSize { get; set; } public long TransferredSize { get; set; } public bool IsUpload { get; set; } public string TaskId { get; set; } // 用于标识任务,可以用GUID } // 在 FileWebSocketClient 类中添加方法 public async Task UploadFile(string localFilePath, string remoteFileName = null) { if (websocket == null || websocket.State != WebSocketState.Open) { Debug.LogError("WebSocket is not connected."); return; } if (!File.Exists(localFilePath)) { Debug.LogError($"File not found: {localFilePath}"); return; } FileInfo fileInfo = new FileInfo(localFilePath); string fileName = string.IsNullOrEmpty(remoteFileName) ? fileInfo.Name : remoteFileName; long fileSize = fileInfo.Length; // 创建传输任务 string taskId = Guid.NewGuid().ToString(); var task = new FileTransferTask { TaskId = taskId, FilePath = localFilePath, FileName = fileName, FileSize = fileSize, TransferredSize = 0, IsUpload = true }; activeTasks[taskId] = task; // 1. 发送上传开始命令 var startCommand = new { type = "command", action = "upload_start", taskId = taskId, fileName = fileName, fileSize = fileSize }; string startMsg = JsonUtility.ToJson(startCommand); await websocket.SendText(startMsg); Debug.Log($"Sent upload_start command for {fileName}"); }

发送开始命令后,我们需要在HandleIncomingMessage中等待服务器的确认(upload_ack)。收到确认后,才开始发送数据块。

private async void HandleIncomingMessage(byte[] bytes) { // 首先尝试解码为文本消息(命令) try { string message = Encoding.UTF8.GetString(bytes); var command = JsonUtility.FromJson<WebSocketCommand>(message); if (command != null && command.type == "command") { await ProcessCommand(command); return; // 命令处理完毕,不再尝试作为数据处理 } } catch { // 如果不是有效的JSON文本,则可能是二进制数据帧 } // 处理二进制数据帧(如下载的文件块) // 这部分逻辑在下载功能中实现 } private async Task ProcessCommand(WebSocketCommand cmd) { switch (cmd.action) { case "upload_ack": // 服务器允许上传,开始发送数据 if (activeTasks.TryGetValue(cmd.taskId, out var uploadTask)) { _ = SendFileDataChunks(uploadTask); // 使用 discard 操作符,不等待 } break; case "upload_complete": Debug.Log($"Upload complete for task: {cmd.taskId}"); if (activeTasks.ContainsKey(cmd.taskId)) { activeTasks.Remove(cmd.taskId); } break; case "progress": // 处理进度更新,可能是上传或下载进度 if (activeTasks.TryGetValue(cmd.taskId, out var task)) { long transferred = cmd.transferredSize; if (task.IsUpload) { OnUploadProgress?.Invoke(transferred, task.FileSize); } else { OnDownloadProgress?.Invoke(transferred, task.FileSize); } } break; // ... 处理其他命令,如 download_response, error 等 } } // 一个简单的命令类 [System.Serializable] public class WebSocketCommand { public string type; public string action; public string taskId; public string fileName; public long fileSize; public long transferredSize; public string error; }

现在实现发送数据块的核心方法SendFileDataChunks

private async Task SendFileDataChunks(FileTransferTask task) { const int chunkSize = 64 * 1024; // 64KB 为一个数据块,可根据网络调整 byte[] buffer = new byte[chunkSize]; using (FileStream fileStream = new FileStream(task.FilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) { fileStream.Seek(task.TransferredSize, SeekOrigin.Begin); // 支持断点续传 int bytesRead; while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length)) > 0) { if (websocket.State != WebSocketState.Open) { Debug.LogError("WebSocket disconnected during transfer."); break; } // 发送数据块 await websocket.Send(buffer.Take(bytesRead).ToArray()); // 注意:只发送实际读取的字节 task.TransferredSize += bytesRead; // 可以定期发送进度更新,或者由服务器计算并推送 // 这里我们选择由客户端每隔几个块或固定时间发送一次进度命令 if (task.TransferredSize % (chunkSize * 10) == 0) // 每10个块(约640KB)报告一次进度 { var progressCmd = new { type = "command", action = "progress", taskId = task.TaskId, transferredSize = task.TransferredSize }; await websocket.SendText(JsonUtility.ToJson(progressCmd)); } // 短暂延迟,避免发送过快淹没接收方(非必须,取决于网络和服务器处理能力) // await Task.Delay(1); } // 所有数据发送完毕,发送结束命令 if (websocket.State == WebSocketState.Open) { var endCommand = new { type = "command", action = "upload_end", taskId = task.TaskId }; await websocket.SendText(JsonUtility.ToJson(endCommand)); Debug.Log($"All data sent for {task.FileName}. Waiting for server confirmation..."); } } }

注意事项:分块大小chunkSize的选择是个权衡。太小(如1KB)会导致协议头开销比例变大,频繁的IO和网络发送调用;太大(如1MB)则可能导致单次发送延迟高,内存占用大,且不利于实时进度更新。通常选择16KB到256KB之间。对于移动网络,可以适当调小;对于稳定高速的内网,可以调大。我这里选择64KB是一个比较折中的值。

5.3 文件下载功能实现

下载流程是上传的逆过程。客户端发送下载请求命令,服务器回复一个开始命令后,持续发送二进制数据块。客户端需要将接收到的二进制数据块按顺序写入文件。

首先,在FileWebSocketClient中添加下载请求方法:

public async Task DownloadFile(string remoteFileName, string localSavePath) { if (websocket == null || websocket.State != WebSocketState.Open) { Debug.LogError("WebSocket is not connected."); return; } string taskId = Guid.NewGuid().ToString(); var task = new FileTransferTask { TaskId = taskId, FilePath = localSavePath, // 这次是保存路径 FileName = remoteFileName, FileSize = 0, // 初始未知,由服务器返回 TransferredSize = 0, IsUpload = false }; activeTasks[taskId] = task; // 发送下载请求命令 var requestCommand = new { type = "command", action = "download_request", taskId = taskId, fileName = remoteFileName }; await websocket.SendText(JsonUtility.ToJson(requestCommand)); Debug.Log($"Sent download_request for {remoteFileName}"); }

然后,我们需要扩展HandleIncomingMessageProcessCommand来处理下载相关的命令和数据。

ProcessCommand中添加对download_response的处理:

case "download_response": // 服务器响应下载请求,告知文件大小(或错误) if (activeTasks.TryGetValue(cmd.taskId, out var downloadTask)) { if (!string.IsNullOrEmpty(cmd.error)) { Debug.LogError($"Download error: {cmd.error}"); OnError?.Invoke(cmd.error); activeTasks.Remove(cmd.taskId); } else { downloadTask.FileSize = cmd.fileSize; Debug.Log($"Ready to download {downloadTask.FileName}, size: {cmd.fileSize}"); // 服务器接下来会开始发送二进制数据流 // 我们需要准备文件流来写入数据 PrepareFileStreamForDownload(downloadTask); } } break;

PrepareFileStreamForDownload方法负责创建或打开本地文件,并关联一个写入流。同时,我们需要修改HandleIncomingMessage,将非命令的二进制数据帧识别为下载的数据块,并写入文件。

这里有一个关键点:我们如何知道当前接收到的二进制帧属于哪个下载任务?由于WebSocket是全双工的,上传和下载的数据帧可能交错到达。我们需要一个状态机或者上下文来关联。

一个简单有效的方法是:在服务器发送二进制数据流之前,先发送一个download_start命令,其中包含taskId。客户端收到后,将当前taskId设置为“正在接收数据”的状态。之后接收到的二进制帧,都写入这个任务对应的文件,直到收到download_end命令。

我们在FileTransferTask中增加一个IsReceivingData的布尔状态,并在ProcessCommand中处理download_startdownload_end

public class FileTransferTask { // ... 原有属性 public bool IsReceivingData { get; set; } = false; public FileStream FileStream { get; set; } // 持有文件流引用 } // 在 FileWebSocketClient 中 private FileTransferTask currentDownloadTask = null; private async Task ProcessCommand(WebSocketCommand cmd) { switch (cmd.action) { // ... 其他case case "download_start": if (activeTasks.TryGetValue(cmd.taskId, out var startTask) && !startTask.IsUpload) { currentDownloadTask = startTask; startTask.IsReceivingData = true; // 创建文件流,准备写入 startTask.FileStream = new FileStream(startTask.FilePath, FileMode.Create, FileAccess.Write, FileShare.None); Debug.Log($"Start receiving data for task: {cmd.taskId}"); } break; case "download_end": if (currentDownloadTask != null && currentDownloadTask.TaskId == cmd.taskId) { currentDownloadTask.FileStream?.Close(); currentDownloadTask.FileStream?.Dispose(); currentDownloadTask.FileStream = null; currentDownloadTask.IsReceivingData = false; Debug.Log($"Download finished for task: {cmd.taskId}"); OnDownloadProgress?.Invoke(currentDownloadTask.FileSize, currentDownloadTask.FileSize); // 触发100%进度 activeTasks.Remove(cmd.taskId); currentDownloadTask = null; } break; } } private async void HandleIncomingMessage(byte[] bytes) { // 1. 尝试解析为命令 try { string message = Encoding.UTF8.GetString(bytes); var command = JsonUtility.FromJson<WebSocketCommand>(message); if (command != null && command.type == "command") { await ProcessCommand(command); return; } } catch { // 不是文本命令,继续 } // 2. 处理二进制数据帧(下载的数据块) if (currentDownloadTask != null && currentDownloadTask.IsReceivingData) { // 将数据写入文件流 await currentDownloadTask.FileStream.WriteAsync(bytes, 0, bytes.Length); currentDownloadTask.TransferredSize += bytes.Length; // 更新进度(可以节流,避免每帧都触发事件) OnDownloadProgress?.Invoke(currentDownloadTask.TransferredSize, currentDownloadTask.FileSize); } else { Debug.LogWarning($"Received binary data but no active download task is receiving."); } }

最后,别忘了在OnDestroy或断开连接时,清理所有打开的文件流。

6. .NET Fleck 服务端核心实现

服务端的职责是:监听连接、解析命令、协调文件的上传存储和下载发送。

6.1 WebSocket服务器启动与连接管理

using Fleck; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Threading.Tasks; namespace FileWebSocketServer { class Program { static ConcurrentDictionary<Guid, IWebSocketConnection> sockets = new ConcurrentDictionary<Guid, IWebSocketConnection>(); static ConcurrentDictionary<string, FileUploadSession> uploadSessions = new ConcurrentDictionary<string, FileUploadSession>(); static string baseStoragePath = @"./UploadedFiles"; // 文件存储根目录 static void Main(string[] args) { FleckLog.Level = LogLevel.Debug; // 设置日志级别 var server = new WebSocketServer("ws://0.0.0.0:8181"); server.Start(socket => { socket.OnOpen = () => { Console.WriteLine($"Connection opened: {socket.ConnectionInfo.Id}"); sockets.TryAdd(socket.ConnectionInfo.Id, socket); }; socket.OnClose = () => { Console.WriteLine($"Connection closed: {socket.ConnectionInfo.Id}"); sockets.TryRemove(socket.ConnectionInfo.Id, out _); // 清理该连接可能存在的未完成上传会话 var sessionsToRemove = uploadSessions.Where(s => s.Value.ConnectionId == socket.ConnectionInfo.Id).Select(s => s.Key).ToList(); foreach (var key in sessionsToRemove) { uploadSessions.TryRemove(key, out _); } }; socket.OnMessage = async message => { await ProcessMessage(socket, message); }; socket.OnBinary = async data => { await ProcessBinaryData(socket, data); }; }); Console.WriteLine("WebSocket Server started on ws://0.0.0.0:8181"); Console.ReadLine(); // 阻塞主线程 } // ... 后续的消息处理函数 } public class FileUploadSession { public string TaskId { get; set; } public string FileName { get; set; } public long FileSize { get; set; } public long ReceivedSize { get; set; } public FileStream FileStream { get; set; } public Guid ConnectionId { get; set; } } }

6.2 命令解析与上传逻辑处理

ProcessMessage方法处理所有文本命令。

private static async Task ProcessMessage(IWebSocketConnection socket, string message) { try { using JsonDocument doc = JsonDocument.Parse(message); var root = doc.RootElement; if (!root.TryGetProperty("type", out var typeProp) || typeProp.GetString() != "command") { await SendError(socket, "Invalid message type."); return; } string action = root.GetProperty("action").GetString(); string taskId = root.GetProperty("taskId").GetString(); switch (action) { case "upload_start": await HandleUploadStart(socket, root, taskId); break; case "upload_end": await HandleUploadEnd(socket, taskId); break; case "progress": // 客户端上报进度,服务端可以记录,这里简单忽略或记录到日志 Console.WriteLine($"Progress for {taskId}: {root.GetProperty("transferredSize").GetInt64()}"); break; case "download_request": await HandleDownloadRequest(socket, root, taskId); break; default: await SendError(socket, $"Unknown action: {action}"); break; } } catch (Exception ex) { Console.WriteLine($"Error processing message: {ex.Message}"); await SendError(socket, $"Server error: {ex.Message}"); } } private static async Task HandleUploadStart(IWebSocketConnection socket, JsonElement root, string taskId) { string fileName = root.GetProperty("fileName").GetString(); long fileSize = root.GetProperty("fileSize").GetInt64(); // 安全检查:文件名、路径等 if (string.IsNullOrEmpty(fileName) || fileName.Contains("..") || Path.GetInvalidFileNameChars().Any(fileName.Contains)) { await SendError(socket, "Invalid file name.", taskId); return; } // 限制文件大小(例如100MB) const long maxFileSize = 100 * 1024 * 1024; if (fileSize > maxFileSize) { await SendError(socket, $"File size exceeds limit: {maxFileSize / (1024*1024)}MB", taskId); return; } // 确保存储目录存在 if (!Directory.Exists(baseStoragePath)) { Directory.CreateDirectory(baseStoragePath); } string filePath = Path.Combine(baseStoragePath, fileName); // 处理重名文件(例如添加时间戳) if (File.Exists(filePath)) { string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); string nameWithoutExt = Path.GetFileNameWithoutExtension(fileName); string extension = Path.GetExtension(fileName); fileName = $"{nameWithoutExt}_{timestamp}{extension}"; filePath = Path.Combine(baseStoragePath, fileName); } // 创建上传会话 var session = new FileUploadSession { TaskId = taskId, FileName = fileName, FileSize = fileSize, ReceivedSize = 0, ConnectionId = socket.ConnectionInfo.Id }; try { session.FileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None); uploadSessions[taskId] = session; // 发送确认命令 var ack = new { type = "command", action = "upload_ack", taskId = taskId }; await socket.Send(JsonSerializer.Serialize(ack)); Console.WriteLine($"Upload session started for {fileName} (TaskId: {taskId})"); } catch (Exception ex) { Console.WriteLine($"Failed to create file {filePath}: {ex.Message}"); await SendError(socket, $"Server failed to prepare storage: {ex.Message}", taskId); session.FileStream?.Dispose(); uploadSessions.TryRemove(taskId, out _); } }

6.3 二进制数据接收与文件写入

当客户端开始发送二进制数据块时,服务端的OnBinary事件触发。

private static async Task ProcessBinaryData(IWebSocketConnection socket, byte[] data) { // 关键:我们需要知道这个二进制数据块属于哪个上传任务。 // 一个简单但不够严谨的假设是:客户端在上传过程中不会穿插其他二进制数据。 // 更严谨的做法是让客户端在每个数据块前也发送一个包含taskId的小头,但这会增加协议复杂度。 // 这里我们采用会话关联:一个连接在同一时间只处理一个文件上传。 // 我们可以通过连接ID找到最近的上传会话。 var session = uploadSessions.Values.FirstOrDefault(s => s.ConnectionId == socket.ConnectionInfo.Id && s.FileStream != null); if (session == null) { Console.WriteLine($"Received binary data but no active upload session for connection {socket.ConnectionInfo.Id}"); // 可以发送错误命令给客户端 await SendError(socket, "No active upload session.", null); return; } try { await session.FileStream.WriteAsync(data, 0, data.Length); session.ReceivedSize += data.Length; // 定期向客户端发送进度(可选) if (session.ReceivedSize % (64 * 1024 * 10) == 0) // 每640KB发送一次进度 { var progress = new { type = "command", action = "progress", taskId = session.TaskId, transferredSize = session.ReceivedSize }; await socket.Send(JsonSerializer.Serialize(progress)); } // 检查是否接收完毕 if (session.ReceivedSize >= session.FileSize) { await FinalizeUpload(socket, session); } } catch (Exception ex) { Console.WriteLine($"Error writing data for session {session.TaskId}: {ex.Message}"); await SendError(socket, $"File write error: {ex.Message}", session.TaskId); CleanupSession(session); } } private static async Task FinalizeUpload(IWebSocketConnection socket, FileUploadSession session) { session.FileStream?.Close(); session.FileStream?.Dispose(); uploadSessions.TryRemove(session.TaskId, out _); var complete = new { type = "command", action = "upload_complete", taskId = session.TaskId }; await socket.Send(JsonSerializer.Serialize(complete)); Console.WriteLine($"Upload completed for {session.FileName}"); } private static void CleanupSession(FileUploadSession session) { session.FileStream?.Close(); session.FileStream?.Dispose(); if (session.FileStream != null && session.ReceivedSize < session.FileSize) { // 上传未完成,删除可能已损坏的部分文件 try { File.Delete(Path.Combine(baseStoragePath, session.FileName)); } catch { } } uploadSessions.TryRemove(session.TaskId, out _); }

6.4 文件下载逻辑处理

当客户端请求下载时,服务器需要读取文件并分块发送。

private static async Task HandleDownloadRequest(IWebSocketConnection socket, JsonElement root, string taskId) { string fileName = root.GetProperty("fileName").GetString(); string filePath = Path.Combine(baseStoragePath, fileName); if (!File.Exists(filePath)) { await SendError(socket, $"File not found: {fileName}", taskId); return; } FileInfo fileInfo = new FileInfo(filePath); long fileSize = fileInfo.Length; // 发送下载响应,告知文件大小 var response = new { type = "command", action = "download_response", taskId = taskId, fileSize = fileSize }; await socket.Send(JsonSerializer.Serialize(response)); // 发送开始命令 var start = new { type = "command", action = "download_start", taskId = taskId }; await socket.Send(JsonSerializer.Serialize(start)); // 开始发送文件数据 const int bufferSize = 64 * 1024; // 与服务端发送块大小一致 byte[] buffer = new byte[bufferSize]; try { using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) { int bytesRead; long totalSent = 0; while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length)) > 0) { // 注意:Fleck的Send方法需要完整的数组,如果bytesRead小于buffer.Length,需要截取 byte[] chunk = bytesRead == buffer.Length ? buffer : buffer.Take(bytesRead).ToArray(); await socket.Send(chunk); totalSent += bytesRead; // 发送进度(可选) if (totalSent % (bufferSize * 10) == 0) { var progress = new { type = "command", action = "progress", taskId = taskId, transferredSize = totalSent }; await socket.Send(JsonSerializer.Serialize(progress)); } } } // 发送结束命令 var end = new { type = "command", action = "download_end", taskId = taskId }; await socket.Send(JsonSerializer.Serialize(end)); Console.WriteLine($"File {fileName} sent successfully."); } catch (Exception ex) { Console.WriteLine($"Error sending file {fileName}: {ex.Message}"); await SendError(socket, $"Failed to send file: {ex.Message}", taskId); } } private static async Task SendError(IWebSocketConnection socket, string error, string taskId = null) { var errorMsg = new { type = "command", action = "error", taskId = taskId, error = error }; await socket.Send(JsonSerializer.Serialize(errorMsg)); }

7. 关键问题排查与性能优化技巧

在实际部署和测试中,你肯定会遇到各种问题。这里记录几个我踩过的坑和解决方案。

1. 连接不稳定与断线重传WebSocket连接并非100%可靠,尤其是在移动网络下。我们的协议必须支持断点续传。

  • 上传续传:客户端在发送upload_start命令时,可以附加一个offset参数,表示已传输的字节数。服务器收到后,检查文件是否存在以及大小是否匹配offset,如果匹配,则打开文件流并定位到offset位置,回复upload_ack并告知offset。客户端从offset处开始发送剩余数据。
  • 下载续传:类似,客户端在download_request中携带offset。服务器从文件指定位置开始读取并发送。这需要服务器记录每个任务已发送的字节数,或者客户端在本地记录已接收的字节数。

2. 大文件传输与内存压力一次性将大文件读入内存(File.ReadAllBytes)会导致内存飙升甚至溢出。

  • 始终使用流(Stream):如示例所示,使用FileStream并分块读取/写入,保持内存占用恒定(约等于块大小)。
  • 调整块大小:根据目标平台调整chunkSize。PC端可以大一些(256KB),移动端或WebGL建议小一些(16KB-32KB),并在发送间隔中加入微小延迟(Task.Delay(1)),避免阻塞主线程或导致GC频繁。

3. 并发传输与任务管理一个客户端同时上传/下载多个文件,或者多个客户端连接服务器。

  • 任务ID是关键:每个传输任务必须有唯一的taskId(如GUID),并在所有命令和数据关联中使用它。服务端的uploadSessions字典就是基于taskId来管理的。
  • 连接与任务解耦:一个WebSocket连接可以承载多个任务。在服务端,我们通过ConnectionIdtaskId共同管理会话。清理资源时(如连接断开),需要遍历并清理该连接下的所有任务。

4. 进度更新的频率与性能频繁地发送进度更新命令(比如每收到一个64KB块就发一次)会产生大量小网络包,增加开销。

  • 节流(Throttle):如代码所示,每传输一定量数据(如10个块)或每隔固定时间(如100毫秒)才发送一次进度更新。在Unity客户端,更新UI进度条时也要注意不要每帧都更新,可以使用一个累加器,或者使用ObservableSample操作符进行采样。

5. 文件安全性

  • 路径穿越防护:服务器在接收文件名时,必须检查是否包含..或绝对路径,防止客户端上传文件到系统任意目录。示例中使用了简单的检查。
  • 文件类型过滤:根据业务需求,可以在服务端检查文件扩展名或MIME类型。
  • 病毒扫描:对于用户上传的文件,在生产环境中应考虑集成病毒扫描服务。

6. NativeWebSocket在WebGL平台的限制WebGL平台下的WebSocket是浏览器原生实现,对二进制数据的支持可能因浏览器而异。NativeWebSocket会尝试使用ArrayBuffer。如果遇到问题,可以尝试将二进制数据转换为Base64字符串发送,但这会增加约33%的数据量。对于非关键小文件,这不失为一种兼容性方案。

8. 扩展思路与应用场景

这个基础的WebSocket文件传输框架可以扩展到很多实际场景:

1. 游戏资源热更新这是最直接的应用。游戏启动时,连接资源服务器,检查本地资源版本号,通过WebSocket下载差异资源包。利用其全双工特性,可以实时显示精确的下载进度和速度。相比HTTP,在需要同时处理多个小文件更新时,WebSocket的单连接复用优势明显。

2. 实时日志与数据上报游戏运行时,可以将性能数据、异常日志、用户行为事件通过同一个WebSocket连接实时上报到服务器,服务器甚至可以即时下发配置调整指令。

3. 多人游戏中的自定义内容分享例如,玩家在游戏中创作了一张地图或一个角色皮肤,可以将其打包成文件,通过WebSocket快速分享给房间内的其他玩家。

4. 内网开发工具在团队内部,可以开发一个简单的工具,用于在开发机、测试手机、模拟器之间快速同步构建好的AssetBundle或配置文件,提升开发测试效率。

要实现这些扩展,核心是丰富我们的应用层协议。例如,为“资源更新”场景增加check_versionfile_listpatch_download等命令;为“日志上报”增加log_dataconfig_update等命令。协议的设计要保持向后兼容和可扩展性。

最后,关于性能监控,建议在关键节点(如连接、命令处理、数据块收发)添加简单的日志或计数器,以便在出现问题时能快速定位瓶颈是网络延迟、IO速度还是代码逻辑问题。这套系统虽然简单,但涵盖了实时网络通信的核心模式,理解了它,你就能应对更多复杂的网络交互需求。

返回列表