Unity客户端接入Skynet网关:从协议设计到粘包拆包实战 简介面向Unity客户端与Skynet服务端联调需求的开发者这份源码包围绕sproto协议展示了完整的双向通信实现尤其适合正在学习游戏后端框架或需要快速搭建通信原型的读者。压缩包共3个文件以.inscode脚本、.html说明页和.gitignore配置为主整体仅有7KB内容精简便于直接查看核心代码和工程结构。已有207人学习查看对于一个小体量示例资源具备一定参考价值。包内提供了客户端sayhello消息与服务端heartbeat消息的收发示例包含协议文件转C#脚本的关键过程可帮助理解从Skynet环境搭建到Unity端连接的整条链路同时index.html可作为可视化参考inscode脚本则适合在云端或本地快速运行调试读者可对照源码理解协议定义、消息封装、连接建立与心跳维持等细节并复用到实际项目的通信模块设计中。 Unity客户端接Skynet服务端这事儿说难不难说简单也真不简单。我见过太多人在第一步就卡住网关地址配好了TCP也连上了结果服务端收到的是一堆乱码或者客户端直接闪退查了半天发现是字节序对不上。更常见的坑是粘包半包处理不到位客户端疯狂发消息服务端解析出来全是错位的垃圾数据。这篇文章就拿一套能跑的完整代码讲透从协议设计、C#客户端封装到Lua网关逻辑最后把我在实际项目中踩过的坑和排错思路一并甩出来。适合刚接触Skynet的Unity客户端开发也适合后端想快速给Unity开一个通信通道的人。这套方案最终实现的效果是Unity客户端能稳定连接Skynet网关按照自定义的二进制协议收发消息支持心跳保活、断线重连、大数据包拆包同时服务端能按消息类型分发到对应逻辑服务处理。麻雀虽小五脏俱全跑通之后往里面填业务字段就能用。1. 先搞清楚Unity和Skynet到底怎么配合1.1 为什么用Skynet做游戏服务端聊通信之前得先想清楚Skynet在这条链路里扮演什么角色。Skynet是一个基于Actor模型的轻量级服务端框架核心是“服务”这个概念一个服务就是一个独立的Lua虚拟机服务之间通过消息投递通信底层由Skynet节点调度。开发的时候一般按业务拆服务比如登录服务、房间服务、数据中心各管一摊活。Unity作为客户端本质上是“外部世界”。Skynet内部服务之间互相发消息是同一套机制但Unity不在Skynet节点内部所以必须经过网络层进网关。网关通常叫Gate是所有外部连接的入口它持有每个客户端的连接句柄fd然后把收到的消息转发给相应的内部服务内部服务处理完再通过网关把响应推回客户端。这个模型很清晰但很多项目死在第一步网关怎么接、消息怎么编解码、连接断了怎么通知逻辑层。1.2 通信链路的整体架构画一个最简单的链路手画不依赖工具Unity客户端 (C#) │ TCP长连接, 自定义二进制协议 ▼ Skynet网关服务 (Lua) │ 服务间消息投递 ▼ Skynet逻辑服务 (Lua)客户端只管和网关通信不感知后端有多少个服务。网关收到一条客户端消息后根据消息头里的msg_id或cmd字段决定转发给哪个服务。这种设计的好处是换逻辑服务时客户端完全无感只要协议不变前后端并行开发互不阻塞。从实测来看这个架构下Unity端的核心工作量不在业务而在三块Socket连接管理、字节缓冲区的粘包拆包、C#结构体和字节流的互转。后端的核心工作量则在网关的消息分发和连接生命周期管理。2. 通信协议设计一切从“报文格式”开始2.1 定长包头 变长包体协议怎么定直接决定后面所有代码怎么写。我建议用“定长包头 变长包体”的结构这是游戏通信里最稳妥的方案没有之一。包头固定8字节包含两个int字段包体长度4字节和消息ID4字节。包体就是具体的业务数据长度由包头里的长度字段决定。[包体长度: 4字节][消息ID: 4字节][包体数据: N字节]为什么包头要固定8字节而不是直接读流到结束因为TCP是流协议没有消息边界。如果你不定包头接收方根本不知道一条消息到哪结束。定长包头的好处是接收方先积累8字节解析出长度字段再根据长度等待剩余数据这样就能准确切出每一条完整消息。消息ID用于区分请求和响应。比如客户端登录消息ID是10001服务端返回登录结果消息ID是10002。如果后面加了心跳消息ID就是10003。用数字而不是字符串的好处是省流量、解包快而且C#和Lua互转整数都很方便。2.2 字节序统一一个大坑必须从第一天解决字节序是跨语言通信最容易翻车的点。C#的BitConverter默认用本机字节序Windows/Linux通常是Little Endian小端而Skynet的socket库自带包头封装用的是Big Endian大端默认读取包头时按大端解。如果你自定义协议不做统一就会出现“我发的是1你收到的是16777216”这种诡异问题。建议在项目里立一个规矩所有包头字段统一用大端Big Endian也就是网络字节序。包体里的字段也尽量统一。C#端用BinaryWriter默认是小端所以要么手动逆序要么自定义写字节的方法。我习惯写两个工具函数WriteInt和ReadInt内部自己处理字节序这样就不会到处犯低级错误。public static void WriteInt(Stream stream, int value) { // 统一转大端先转网络字节序再写 byte[] bytes BitConverter.GetBytes(value); if (BitConverter.IsLittleEndian) { Array.Reverse(bytes); } stream.Write(bytes, 0, bytes.Length); }Lua端同样要配套。Skynet的socket库提供了pack和unpack但那是给包头用的。自定义包体建议用string.pack并指定大端格式-- 表示大端I 表示无符号int local body string.pack(I4, msg_id) .. ... -- 拼包体字段字节序这玩意儿前后端各写各的时候最容易出问题。一定要在联调第一天就把这个规则写进文档我吃过亏后面细讲。3. Unity侧客户端封装C#代码落地3.1 Socket连接与状态管理Unity端核心类我封装成SkynetClient负责Socket连接、数据收发、心跳和重连。构造函数传入地址和端口连接用BeginConnect异步方式避免阻塞主线程导致Unity卡顿。public class SkynetClient { private Socket socket; private bool isConnected; private const int HEADER_SIZE 8; private byte[] buffer new byte[1024 * 64]; private MemoryStream recvStream new MemoryStream(); public void Connect(string host, int port) { socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.BeginConnect(host, port, OnConnected, null); } private void OnConnected(IAsyncResult ar) { socket.EndConnect(ar); isConnected true; socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, OnReceive, null); } public void Close() { isConnected false; if (socket ! null) { socket.Close(); socket null; } } }文件里留了个HEADER_SIZE 8常量后面拆包会用到。状态管理上要注意Unity切后台、断网、被系统强杀Socket的状态都不一样。建议加一个ConnectionState枚举Connecting/Connected/Reconnecting/Closed在Update里根据状态自动重连。这个细节对移动端尤其重要后面单独说。3.2 粘包拆包接收缓冲区的正确打开方式TCP是流协议消息不保证一次到达也不保证每次到达的数据刚好是一条完整消息。客户端可能一次收到半条、一条半、两条半。拆包的核心思路把收到的数据append到一个累积缓冲区然后循环从缓冲区里提取完整消息直到剩余字节不足以构成一条完整消息为止。private void OnReceive(IAsyncResult ar) { int bytesRead socket.EndReceive(ar); if (bytesRead 0) { // 连接被对端关闭 isConnected false; return; } recvStream.Write(buffer, 0, bytesRead); // 循环拆包 while (true) { recvStream.Position 0; if (recvStream.Length HEADER_SIZE) { break; // 连包头都不够 } // 读取包头 byte[] header new byte[HEADER_SIZE]; recvStream.Read(header, 0, HEADER_SIZE); int bodyLen ReadInt(header, 0); int msgId ReadInt(header, 4); if (recvStream.Length - HEADER_SIZE bodyLen) { // 包体还没齐回退缓冲区 ResetStreamWithRemaining(header); break; } byte[] body new byte[bodyLen]; recvStream.Read(body, 0, bodyLen); DispatchMessage(msgId, body); // 处理完后把剩余数据压缩到流头部 TrimStream(); } socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, OnReceive, null); }这里有个很关键的操作ResetStreamWithRemaining和TrimStream。当包头获取到了但包体不足时要把这个包头重新塞回缓冲区不然下次接收会把包头丢掉。每次处理完一条完整消息后要把流里剩余的数据挪到最前面方便下一轮循环继续读。很多初学者就是栽在这里收到半包后直接把数据清了结果对端重发数据就乱了。我这里的实现用MemoryStream做累积缓冲实时性够用。如果对性能有要求可以换成byte[] offset的手写环形缓冲但Unity端一般网络流量不大MemoryStream完全够用。3.3 发送封装与心跳机制发送时同样要按协议拼包头。包体我们先用byte[]传递实际业务可以用BinaryWriter或MemoryStream组装。发送前先发包头再发包体。为了简单可以直接把包头和包体拼到同一个byte[]里再Send避免两次发送的间隙被对端误判为两条独立消息。public void Send(int msgId, byte[] body) { if (!isConnected || socket null) return; byte[] header new byte[HEADER_SIZE]; WriteInt(header, 0, body.Length); WriteInt(header, 4, msgId); byte[] packet new byte[HEADER_SIZE body.Length]; Buffer.BlockCopy(header, 0, packet, 0, HEADER_SIZE); Buffer.BlockCopy(body, 0, packet, HEADER_SIZE, body.Length); socket.BeginSend(packet, 0, packet.Length, SocketFlags.None, OnSend, null); }心跳是长连接必备。游戏服务端通常会把空闲超过一定秒数的连接踢掉比如120秒。客户端如果在这期间没有发任何数据就会被服务端断掉表现就是“玩着玩着突然掉线”。所以客户端需要一个心跳线程或携程每隔30秒发一条心跳消息消息ID是10003包体空。private IEnumerator HeartBeatLoop() { while (true) { yield return new WaitForSeconds(30f); if (isConnected) { Send(10003, new byte[0]); } } }服务端收到心跳后刷新这个连接的最后活跃时间超时未活跃就主动关闭。两端配合连接才能稳定。4. Skynet侧网关服务Lua代码落地4.1 网关服务的基本结构Skynet侧我写一个gate.lua用socket库监听端口。Skynet的socket库是异步的用callback注册接收函数不会阻塞服务。网关启动时绑定端口然后每个客户端连接都会得到一个新的fd通过fd区分不同的客户端。local skynet require skynet local socket require skynet.socket local client_fds {} -- 记录所有连接的fd local function on_client_data(fd, msg) -- msg 是收到的原始数据可能包含不完整的消息 -- 需要和客户端一样做累积缓冲解析包头 end local function on_client_connect(fd) log(client connected, fd) client_fds[fd] {} socket.start(fd) -- 重要不start不会触发回调 end local function on_client_disconnect(fd) log(client disconnected, fd) client_fds[fd] nil -- 通知逻辑层玩家下线 end skynet.start(function() socket.register(gate, function(fd, msg) if msg then on_client_data(fd, msg) else on_client_disconnect(fd) end end) local listen_fd socket.listen(0.0.0.0, 8888) socket.start(listen_fd, function(fd, addr) on_client_connect(fd) end) skynet.error(gate listen on 0.0.0.0:8888) end)这里有个Skynet新手最容易忽略的点socket.start(fd)一定要在连接建立后调用否则收不到数据回调。socket.register(gate, ...)注册的是全局的回调函数所有fd的数据都会走到这个回调里所以拆包的逻辑必须在这个函数里做。4.2 Lua端的粘包拆包做法Skynet的socket库不会帮你拆包收到的msg只是“某次网络接收到的原始字节片段”。和C#端一样Lua端也要维护每个fd的累积缓冲。我把缓冲存在client_fds[fd].cache字符串里每次收到新数据就做字符串拼接然后循环解析包头。local HEADER_SIZE 8 local function read_int_from_cache(str, offset) -- 大端读取4字节 local b1 string.byte(str, offset 1) local b2 string.byte(str, offset 2) local b3 string.byte(str, offset 3) local b4 string.byte(str, offset 4) return (b1 24) | (b2 16) | (b3 8) | b4 end local function split_packages(fd, new_data) local cache client_fds[fd].cache .. new_data local packages {} while #cache HEADER_SIZE do local body_len read_int_from_cache(cache, 0) local msg_id read_int_from_cache(cache, 4) if #cache - HEADER_SIZE body_len then local body string.sub(cache, HEADER_SIZE 1, HEADER_SIZE body_len) table.insert(packages, { msg_id msg_id, body body }) cache string.sub(cache, HEADER_SIZE body_len 1) else break end end client_fds[fd].cache cache return packages end注意Lua的字符串索引从1开始string.sub的结束位置是包含的所以HEADER_SIZE body_len的写法要对齐。这一步写错就是“服务端解析出来的消息体比实际长一个字节”或者“越界读到了下一包的包头”这种恶心bug。4.3 消息分发与业务逻辑对接拆出完整的{ msg_id x, body y }包后网关要按msg_id转发给对应的逻辑服务。我习惯维护一张路由表msg_id区间对应不同的服务名。比如1~10000是登录相关发到login_service10001~20000是房间相关发到room_service。Skynet的消息投递用的是skynet.send(service_name, lua, ...)。local route_map { [10001] login_service, [10002] login_service, [20001] player_service, } local function dispatch_package(fd, pkg) local service_name route_map[pkg.msg_id] if not service_name then log(unknown msg_id, pkg.msg_id) return end -- 把客户端的fd和消息体转发给目标服务 skynet.send(service_name, lua, client_msg, fd, pkg.msg_id, pkg.body) end逻辑服务处理完需要把响应送回网关再通过网关发回客户端。这里我再加一个专门的处理逻辑服务调用网关暴露的接口send_to_client。因为fd和客户端的对应关系只有网关知道逻辑服务不能直接持有fd只能通过网关间接发送。-- 在网关中再注册一个接口 skynet.dispatch(lua, function(_, _, cmd, fd, msg_id, body) if cmd send_to_client then -- 组装包头包体发送 local header string.pack(I4I4, #body, msg_id) socket.write(fd, header .. body) end end)这个链路设计初期可能觉得绕实际跑起来很清晰网关管连接生命周期逻辑服务管业务双方只通过自定义消息对接互不干扰。5. 常见问题与排查技巧实录5.1 乱码和数据错位多半是字节序或粘包我把实际项目中遇到的高频问题整理成一张速查表基本覆盖了Unity和Skynet联调时80%的坑现象可能原因排查方式客户端发过去的值变成大数字节序不一致打印包头字段的十六进制对比服务端解析到半个包就报错粘包拆包逻辑缺失在网关打日志看缓存长度连接建立后立刻断开服务端socket.start未调用检查connect回调里是否有start客户端收不到任何响应网关没有send_to_client接口检查逻辑服务是否调用网关接口登录成功后偶发掉线心跳超时检查心跳间隔和服务端超时阈值收到消息顺序错乱多线程并发SendUnity端保证主线程调用Send或加锁其中“字节序不一致”的症状最迷惑人。我遇到过一次客户端发msg_id 1服务端解析出来是16777216排查了两个小时最后发现是C#端用BinaryWriter默认小端写了包头Lua端按大端读。从那以后我在代码注释和文档第一行都写上“包头字段一律大端”血泪教训。5.2 半包的调试技巧盯着第一个包的头8字节调试粘包半包最有效的方式是“日志灌水法”。在网关的split_packages里打印每次接收到的原始字节长度以及拆包前后cache的长度变化。skynet.error(string.format(recv fd%d len%d cache_len%d, fd, #new_data, #client_fds[fd].cache))然后在客户端那边故意把一条大消息拆成多次Send比如循环10次每次发一小段看服务端日志是否在最后一次才拆出完整包。如果日志显示cache长度一路增长最后成功拆包说明逻辑正确如果某一步cache长度变负说明你处理剩余数据时越界了。5.3 断线重连移动端必须处理的常态Unity的Socket不会告诉你对端是否存活只有在你尝试发送时才发现连接已断。所以客户端要有“心跳超时检测”每30秒发一次心跳同时记录lastSendTime在Update里检查如果当前时间 - lastSendTime 45秒认为连接已经死了主动Close并进入重连流程。重连的策略我用的是“指数退避”第一次1秒后重连第二次2秒第三次4秒最多间隔15秒。这样避免服务端抖动时客户端疯狂重连打爆网关。重连成功后要重新做一次登录认证拿新的会话凭证而不是简单复用旧的连接。这是很多人忽略的细节导致重连后数据对不上。再补充一个Unity特有的坑Unity生命周期。OnApplicationPause切后台的时候Socket可能被系统挂起回来之后要检查连接状态如果不健康就走重连流程。千万别依赖Socket自己恢复它在移动端真的不可靠。6. 协议扩展的小建议上面这套代码跑通后你就有了一条完整的双向通道。接下来要扩展业务时建议直接引入更结构化的序列化方案比如Sproto或者Protobuf。Sproto和Skynet同源在Lua侧解析方便但C#侧支持较弱Protobuf则两边都很成熟Unity有现成运行时Lua侧也能通过pb模块解析。我自己的经验是如果项目很小纯手写二进制够了一旦消息字段超过20个建议立刻切Protobuf手写解析会变成维护噩梦。切换时需要重新定义协议文件然后生成C#和Lua两边的代码协议层改动不影响网络层所以这套Socket封装可以原封不动继续用。最后再分享一个调试神器。Unity端我封装了LogPacket方法把每次发送和接收的消息ID、包体长度、耗时打出来Skynet端在dispatch_package里也打同样的日志。两边日志拼一起前后端的问题在上线之前基本都能提前暴露别等线上出了问题再抓瞎。连接管理、拆包、字节序、心跳、重连这五件事搞定Unity和Skynet的通信就踏实了。剩下的工作就是在协议表里填你的业务字段了。本文还有配套的精品资源点击获取