完结篇:把服务端接上客户端

前言

10 月 21 日新建这个仓库的时候,我给它的定位是“面向后端岗位的分布式塔防作品集”。说白了,就是想拿它去面试。服务端拆四个进程,TCP、protobuf、MySQL、Redis,该有的都有,唯一没有的是“能玩”。

十七天后,提交记录停在 20 次。Go 写了 3989 行,不算 protoc 生成的代码;C# 写了 1121 行,同样不算生成的 Base.cs 和 Config.cs。今天客户端打包出了 exe,点进去能注册、登录、选关、部署、放技能、看结算,这一版早期技术测试就算完结了。

完结不等于做完。没做的部分我放在后面单独讲,先把最后这两天的事记下来。

客户端之前,先确认 C# 能编过

我用的是团结引擎 1.11.0,版本号 2022.3.62t17,2D 模板。机器上装的就是它,画个塔防够用。问题是我没写过 C#,也没正儿八经用过 Unity。

所以第一件事不是画界面,是验证“这段 C# 到底能不能过编译”。等编辑器重编译太慢,我搭了个 dotnet classlib,netstandard2.1,LangVersion 9,把团结引擎 Editor\Data\Managed 下的 UnityEngine dll 和 Google.Protobuf.dll 引进来,命令行直接 build。报错秒出,改完再 build,一分钟能迭代十几轮。这招省下来的等待时间,大概比写代码的时间还多。

第一个坑是 protobuf 的依赖链。ToByteArray() 报 CS1061,因为我只 using Api;,忘了 using Google.Protobuf;。补上之后又报程序集加载失败:Google.Protobuf 3.28.3 依赖 System.Memory,System.Memory 又依赖 System.Buffers、System.Numerics.Vectors、System.Runtime.CompilerServices.Unsafe。四个 dll 少一个都不行,全塞进 Assets/Plugins/ 才安静。

这种错误没有技术含量,但很消耗人。你以为是代码写错了,其实是工程里少了一个文件。

网络层:14 个字节的帧头又踩一遍

服务端的帧是 [4B BodyLen][2B msgId][8B connID][payload],大端。客户端这边 connID 填 0,网关转发时会覆盖成它分配的连接号,回包靠这个号找到是哪条连接。

写 C# 的 Send 时,我盯着这 14 个字节看了很久:

int len = payload.Length;
byte[] buf = new byte[4 + 2 + 8 + len];
buf[0] = (byte)(len >> 24); buf[1] = (byte)(len >> 16);
buf[2] = (byte)(len >> 8);  buf[3] = (byte)(len);
buf[4] = (byte)(msgId >> 8); buf[5] = (byte)(msgId);
Array.Copy(payload, 0, buf, 14, len);

之前用临时脚本手拼帧头,把长度写成了 10 + payload,网关当成一帧读完就没了,连发 70 条只处理 1 条。那次之后我学乖了,客户端直接按协议写,不自己发明格式。

收包比发包麻烦。TCP 不保证一次 read 就是一个完整包,得有个缓冲:攒着,够 14 字节先看长度,再够 14 + bodyLen 才消费,多了留到下一轮。这段 ParseFrames() 不长,却是整个客户端最容易出事的地方,粘包、半包、边界上那一两个字节,全靠它。

还有两件小事。

一是心跳。网关 10 秒收不到任何包就断,客户端用 InvokeRepeating("SendPing", 5f, 5f) 每 5 秒发一次。看起来最没技术含量的一段,少了它,登录完坐着不动都会掉线。

二是线程。TcpClient 的读循环跑在后台线程,Unity 的 API 只能在主线程碰。我做了个 ConcurrentQueue<Action>,读线程只往里塞回调,Update() 里挨个取出来执行。谁要是直接在读线程里改 UI,编辑器不会报错,运行起来就是随机崩,还找不到原因。

发送这边加了 lock (sendLock)。心跳在主线程定时发,业务发包在读线程收到回包之后发,两个线程共用同一个 NetworkStream,不加锁迟早交错。这个坑在压测里表现为“偶发发送异常”,日志里一天出现两三次,很难复现。

注册和登录也串行化了。服务端 login 是每个请求一个 goroutine,并发处理。客户端如果把 1003 和 1001 连着发,登录可能先于建号执行,然后告诉你账号不存在。改成收到 1004 回包再发 1001,问题消失。这种“两边都对,合起来错”的 bug,只能靠日志里那两行时间戳看出来。

界面:一个空物体,六个页面

Unity 的常规做法是在编辑器里拖 Canvas、拖按钮、挂预制体。我没这么干,整个 UI 是代码生成的:场景里只放一个空物体,挂 GameUI,Awake 里建 Canvas、建页面、建控件。

原因很实际。第一,我不熟编辑器,拖出来的东西在场景文件里是一堆 guid,团结引擎的 guid 还是 base64 44 字符,肉眼对不上,出了问题只能猜。第二,代码生成的界面能进 git、能 diff,改一个坐标就是改一行数字。

代价也清楚:美术没法参与,样式全靠手写颜色和尺寸。这个演示客户端不需要好看,能用就行。

页面一共六个:登录、注册、主界面、选关、战斗、结算,另外加一个设置浮层和一个 Toast。SetPage() 把六个物体挨个 SetActive,逻辑直白到不用解释。字体用 Font.CreateDynamicFontFromOSFont 拿系统里的微软雅黑,拿不到再退到内置字体,省得往仓库里塞字体文件。

有个小地方卡了我一下。战斗画面由 BattleRenderer 画,不在战斗页时本该直接 SetActive(false)。但这个脚本万一和 Main Camera 挂在同一个物体上,关掉它等于把相机也关了,界面直接黑。后来改成只翻脚本里的 BattleActive 开关,物体不动。这种“看起来是同一件事,其实不是”的问题,Unity 里到处都是,我宁愿多写一行防御。

还有点击穿透。战斗页顶部有个“退出战斗”按钮,点它的时候鼠标坐标也会落到战场上,于是一边退出、一边部署了一个干员。加一句 EventSystem.current.IsPointerOverGameObject() 就挡住了。这类问题在网页里也常见,只是换了套 API。

战斗画面:白方块和红圆点

没有美术资源,所有图形都是代码里现做的。白方块是一张 4×4 的纯白 Texture2D,圆点是一张 64×64 的贴图,逐像素算到圆心的距离,超过 1 就透明。Sprite.Create 的时候把 pixelsPerUnit 设成贴图尺寸,一格正好一个世界单位。

圆心和边界的判断就一句:

float dx = (x + 0.5f) / n * 2f - 1f;
float dy = (y + 0.5f) / n * 2f - 1f;
float d = Mathf.Sqrt(dx * dx + dy * dy);
px[y * n + x] = (d <= 1f) ? Color.white : new Color(1f, 1f, 1f, 0f);

地图 9 格宽、2 格高,一格一个世界单位。摄像机改成正交,位置摆在地图中心,orthographicSize 按屏幕宽高比算,保证 16:9 和更窄的窗口都不裁边。

底图是几块色块:整块浅灰底板、第 2 行一条淡红色的敌人路径、最右边一条蓝紫色的基地、几条白色网格线。干员是浅蓝方块,加一条深色的“枪线”表示朝向,血量越低颜色越暗;敌人是红圆点。

敌人的坐标是连续浮点,不是格子整数。pos_x 从 0 走到 8,速度单位是格/秒。所以渲染直接 (pos_x + 0.5) * CELL,出来是平滑移动;要是按整数取整画,敌人会一格一格瞬移,看着像卡了。这个坑在服务端那篇里写过,客户端这边又验证了一次。

部署的交互也简单:点第 1 行的空格,左半边朝左、右半边朝右,方向跟着鼠标在格子里的位置走。点已有干员就是撤回。Q 放技能,R 演示断线重连。顶部用 OnGUI 画一行 HUD:基地血量、费用、帧号、敌人和干员数量。

不精致,但一局打起来,谁在打谁、漏没漏怪、费用够不够,一眼能看清。

按 R 的那一下

断线重连是服务端花心思最多的部分,也是我在客户端最想验证的一段。

网关发现客户端 10 秒没消息,关掉会话,给 battle 发一条 2010 内部通知。battle 把房间标成“等人回来”,tick 不停,快照继续每 10 帧存一份。30 秒内同一个 uid 带着旧 room_id 重新进房,就换绑新的 connID,从最近的快照恢复,接着往下推。超过 30 秒,房间销毁。

客户端这边,按 R 之后:先存 savedRoomId,把 socket 关掉,等 1.5 秒,重新连接网关,用同一个账号登录,登录成功后再带旧房间号发 2000。

验收标准只有一条:帧号连续,不归零。第一次跑通的时候我盯着日志,重连之后第一条推送的帧号接着上一帧往下走,不是从 0 重来。那一刻比打赢一局还舒服。

有个细节要处理:主动关 socket 的时候,读线程会读到 0,触发 OnDisconnected,UI 一跳就回登录页,重连流程直接断。加一个 reconnecting 标志位,主动断开期间不触发掉线回调,重连成功再清掉。

打包:104 MB 一个文件夹

构建设置里做了三件事:平台选 Windows x86_64,添加已打开的 SampleScene,播放器设置取消全屏、改成 1280×720 窗口。

产物不是“一个 exe”。无底深城.exe 只有 464 KB,真正的引擎在 TuanjiePlayer.dll,46 MB;_Data 放资源和脚本程序集;还有 MonoBleedingEdge、TuanjieCrashHandler64.exe 和 GFSDK_Aftermath_Lib.x64.dll。整个目录加起来 104 MB,少一个文件都打不开。我试过把 exe 单独拷到桌面,双击没反应,就是这个原因。

构建还会生成一个 无底深城_BurstDebugInformation_DoNotShip 目录,名字已经把意思写在脸上了,发行时不要带。

.gitignore 原来把 Build/ 忽略了。今天我把这条改掉,让打包产物进仓库:这个项目是作品集,别人 clone 下来最想看的是一双击就能跑的东西,不是听我讲一遍“你要先装团结引擎”。代价是仓库变大,但值得。

压测:一次让我改主意的实验

写完客户端,我把压测又跑了一遍。数字比任何截图都有说服力,所以这段单独讲。

场景是 200 个连接同时在线,每个都走注册、登录、心跳,其中 30 个进房打一局。基线结果:连接成功率 100%,30 局全胜,错误率 0%,战斗路径 p95 6.8 毫秒。看起来挺好,坏在认证:注册 p95 13.5 秒,登录 p95 14.2 秒。

pprof 抓下来,96.71% 的 CPU 时间在 blowfish.encryptBlock。bcrypt 单次 60 到 70 毫秒,是故意的安全成本,不能省。真正的问题是串行:网关到 login 只有一条连接,读循环里同步处理,400 个请求在同一条连接上排队,队尾自然等到 14 秒。login 进程 CPU 才 3.2%,机器 32 个核闲着 31 个,全在等。

第一版优化很直接:每个请求开一个 goroutine。认证 p95 从 14 秒掉到 646 毫秒,快了 22 倍。然后战斗路径 p95 从 6.8 毫秒涨到 208 毫秒,恶化 30 倍。

原因不难猜:bcrypt 并发开满,login CPU 拉到 84.7%,同机的 battle 和 gateway 被抢了 CPU。认证是快了,延迟敏感的战斗链路被拖死。我要是一个觉得“并发越高越好”的人,到这一步就收工了,因为认证的数字确实漂亮。

最终版加了 -max-inflight,默认 8。bcrypt 只用四分之一的核,剩下的留给战斗。认证 p95 1.7 秒,战斗 p95 7.8 毫秒,两边都过线。三轮数字摆在一起很说明问题:

  • 基线:战斗 6.8ms,认证 14059ms,login CPU 3.2%
  • 并发 64:战斗 208ms,认证 646ms,login CPU 84.7%
  • 上限 8:战斗 7.8ms,认证 1730ms,login CPU 24.9%

这件事教我的不是“限流”两个字,是一个顺序:先测,再改,改完再测。拍脑袋把并发拉满,是拿一个指标换另一个指标,还未必换得过。

文档和那两个 bat

文档写了五份:protocol.md 是消息号全表和字段,architecture.md 是 C4 上下文图、容器图和五张时序图,deploy.md 是依赖、启动顺序、端口和 FAQ,pitfalls.md 十五条踩坑,loadtest-report.md 是三轮压测对比。

写这些不是为了给别人看,是给三天后的自己看。四个进程加一个客户端,隔两天回来,我肯定记不清“battle 要先起还是 gateway 先起”。

所以还有两个 bat:启动服务端.bat 和 停止服务端.bat。启动顺序是 datasave(9997) → login(9999) → battle(9998) → gateway(8888),battle 启动时要读配置,login 要先在,网关最后起。每次手动开四个窗口太蠢,而且容易开错顺序。

停止服务端.bat 更简单,按进程名挨个停。Windows 下面重新编译 exe 会提示文件被占用,先停再编,这是我第三次被同一个提示耽误之后才写进去的。

十七天学到的东西

写具体点,不然就成了空话。

协议先定。 proto 改一行,服务端和客户端一起重生成,编译器会把所有没改到的地方指出来。要是用字符串拼 JSON,改字段名就得全局搜,漏一处运行时才炸。

日志比断点好用。 四个服务加一个客户端,断点只能停一个进程,剩下四个在跑。每条关键路径打一行带 uid、room_id、connID 的日志,出问题时按时间顺序排一遍,比单步调试快得多。

先测再改。 上面压测那段就是例子。直觉给的答案经常是反的,尤其是性能问题。

跨语言边界要留余量。 Go 的 int64 到 C# 是 long,protobuf 的 uint64 到 C# 是 ulong,帧头是大端。这三件事单独看都简单,凑在一个项目里,错一次就要查半天。

文档是给未来的自己写的。 不是给面试官,也不是给接手的人,是给下周忘了 -max-inflight 为什么是 8 的那个我。

完结的标准不是没有 TODO。 是知道剩下的 TODO 在哪、为什么留着、下次从哪开始。

最后

项目在 https://github.com/Strandingwhale/MyGame-EarlyTest。服务端四个进程、客户端一个 exe、文档五份、压测报告一份,都在里面。

从 10 月 21 日的空仓库,到今天能点进去打完一局,中间返工过几次,也有一个 bug 从下午查到晚上。现在回头看,最值钱的不是那 3989 行 Go,是那些我能讲清楚“为什么这么写”的地方:为什么网关只留一个入口,为什么快照要每 10 帧存一次,为什么 login 的并发上限是 8 而不是 64。

早期技术测试版本到这里就交卷了。下周可能会补 Grafana 和单元测试,也可能先放一放去准备面试。不管哪种,这个仓库先停在这。

晚安。

posted @ 2026-11-07 23:37  布里吉斯  阅读(0)  评论(0)    收藏  举报