包包版网络游戏大厅+桥牌系统 3.从登录说起
本文讲解了网络游戏大厅的登录部分的Server端实现,包括:自定义网络协议、MemoryStream流的序列化技术、多线程有状态地与客户端通信、异步接收网络包等多种技术。并附有一个Server端的登录模块代码,可以配合着同时发布的Client端exe文件一起使用,来模拟登录的效果。
源码下载:PlayCard.rar(.NET 2.0版)
安装说明:请执行压缩包中的sql文件——创建数据库PlayCard2,并在其中创建PlayerList表和导入5笔数据,然后手动修改Server端App.Config配置文件中的数据库连接字符串。
演示方法:请用VS2005打开解决方案,按F5运行Server端程序。同时打开多个Client端程序,分别使用不同的帐号进行登录。可用的5个帐号(密码都是1):jax;dx;kitty;Anders和Jeffrey。
讲解如下:
Server端编程
首先启动Server端的Monitor,在其构造函数中,创建了一个线程,并调用了startListen方法:




































在上面这个startListen方法中,侦听自身的5151端口,这里使用到了do{…} while (true);这个死循环,在一般编程中,这是不允许的,也只有在Socket通讯中可以这么使用。死循环中,只要收到来自客户端的请求:serverListener.AcceptTcpClient(),就会创建一个Client实例:

同时还会将两个方法OnConnected、Disconnected绑定到Client实例的两个相应的事件上,分别用来处理连接、离线的情况:


接下来一条语句是非常关键的:

这就开始了对消息进行接收并解析的过程,让我们一步步地分析。
话说,Client实例的这个Connect方法,导致了Server端异步接收网络包:





我们看到这个异步方法,从0位置开始,每次读取1024个字节,然后就会异步回调方法myReadCallBack,构成一个循环,直至读取到所有字节,并将其放入SplitBytes实例中并对其进行解析:

解析完成后,要做好善后工作:销毁SplitBytes实例,并开始读取新的网络包。
以上大多是很底层的技术,尤其是这个异步接收网络包的设计,耗费了我不少心血和调试时间。大家可以借鉴一下这个框架。
注意到,这个myReadCallBack方法从逻辑上分为三部分:
1.如果第一次读取到的字节数小于1,表示客户端失去了连接,为此关闭这个连接,触发Disconnected事件后直接返回:

















2.接下来就可以读取字节了:


3.之后我们要判断,这个Socket上的数据流是否读取完了。如果没读取完,也就是if条件为真,就会异步调用myReadCallBack方法,再次读取1024位字节,从而形成循环,直到读取完所有数据流,此时进入if条件的另一分支。


















在另一分支里,会使用BuildText方法反序列化接收到的字节数组,并进行登录验证。如果验证成功,isLogin标记被置为true,从而等待这个Client端线程发送新的数据包;否则,不会再等待这个Client端,而且也不会存储这个线程,如果再次登陆,则一切重头再来。
下面让我们进入BuildText解析函数。

















分为两个阶段:
1.将字节数组进行反序列化成对象obj
2.根据对象obj的Protocol属性,执行不同的逻辑。如501协议代表请求登录,则将obj进一步转化为LoginUser对象,使用CheckUserName方法,对其进行检验:


*注:之所以能将obj转化为LoginUser对象,是因为Client端发送501协议时,就是将其封装在LoginUser对象中并进行序列化的。
接下来的CheckUserName方法,分为两部分逻辑:
1.在DB中进行验证,成功与否封装在LoginUser对象的IsLogin属性中,
2.建立502协议,序列化LoginUser对象u,并发送:


SerializationFormatter类封装了序列化的过程,而下面的Send方法则将序列化后的字符数组发送给原先发送501协议的Client端——这就是有状态连接的好处,轻松的实现了HandShake机制。









注意到,在CheckUserName方法中,我们触发了Connected事件:





从而进一步调用MainThread类的OnConnected方法










这里,在MainThread类中,维护了一个哈希表clientTable,里面存储了所有的Client线程。而OnConnected方法负责将登录成功的Client线程添加到clientTable中。这样,就实现了有状态连接,当这个Client线程再次发送消息到Server端,就会从clientTable中将这个线程找出来。
不光是在MainThread类存储这些线程信息。此外,Server端还维护着一个DataTable,存储着所有在线用户的信息——也就是位于ClientList单例类的clientTable。
在登录成功之后,这个Client端的线程就存储在Server端的clientTable这个哈希表中了。以后Client和Server进行交互,Server都会检查这个哈希表,从而正确找出这个线程,并在上面写消息发送到Client。
最后,讲解一下离线机制,有状态的。MainThread类的OnDisconnected方法,附属到Client对象的OnDisconnected事件。这个事件什么时候被触发呢?在myReadCallBack这个回调方法中,也就是在接收不到数据包的情形下,这意味着用户断开了Socket连接,包括正常退出或拔掉网线的非法退出,那么Server端会立刻得到通知:numberOfBytesRead < 1,于是就触发OnDisconnected事件,执行OnDisconnected方法。
在该方法中,我们会在DB中将这个用户的IsLogin字段修改为0以表示它的离开:

然后分别去除哈希表中相应的线程和DataTable中该用户的相关资料,




最后,遍历哈希表中剩下的用户,逐一向它们发送有人离线的消息,这是一个510协议:
















记住,这里使用的HandShake不是对等的,因为是基于TCP而不是UDP的。也就是说,永远是Client端主动发request到Server端。而Server端则永远是被动的接收request,处理后再response到Client端。
应该说,大部分通信都是这样设计的。举个例子,登录体系的设计,Client端主动发501协议请求Server端;Server端接收后,去数据库验证,将成功与否的信息作为502协议再回复给Client端。
当然也有例外,就是Server端失去与Client端的连接,这时,Server端会接收不到数据包,于是就认为Client端离开了,从而进行一些逻辑处理,向其它Client发送该Client离开的消息(510协议)——这也可以认为是一种退化的HandShake,Server端仍然是被动的。
Client端编程
讨论过Server端编程,再来看Client端的代码,就容易多了,大部分都是重复的技术。带大家走一遍流程。
对于登录而言,可以说Server端编程是在等候501协议,然后去数据库验证,发送502协议;而Client端则是发送501协议,然后等候502协议,然后根据验证结果,进行逻辑处理。Client端使用到的主要函数如GetMsg、BuildText、SendText都和Server端差不多,这里就不多介绍了。
如果Client端的GetMsg接收不到数据包,也会执行Disconnect方法,这同于Server端。
此外,需要注意Client端的TcpClient对象,被封装为一个SocketHelper单件。在进行身份验证的时候,如果成功,会继续沿用这个单件;否则,就销毁它——因为失败的登录没有必要再保留这个单件,这样再次登录就可以建立新的SocketHelper单件了。
我们看到,TcpClient类型对象myClient单独出现在很多地方,比如说接收数据包:myClient.GetStream()。既然这样,为什么还要建立这个单件呢?
如果只有一个LoginForm窗体,那么用不用单件都是一样的。但是接下来进入大厅——也就是MainForm窗体也要用到TcpClient对象进行消息通信——使用同样的端口,所以有一个全局TcpClient对象就非常必要的,所以在这里我对TcpClient对象进行了封装,从而在Client端任何窗体中都是可以访问到的。
一些零零碎碎的技术
通信协议
话说,通信协议这东西,也就是Protocol,是国际组织定的而我们要遵守的,比如说SOAP协议,但是我们也可以定义自己的通信协议。在“包包游戏大厅”中,我将这个自定义协议OO为一个CommonProtocol类,这是一个基类,所有的通信协议都从这个基类派生,如LoginUser实体类就派生于此。为了支持序列化,要在类头加上[Serializable]以及在类中添加一个空的构造函数:





















注意到,这个类具有一个Protocol属性,代表自定义协议的编号,例如501协议请求登陆,502协议验证登陆成功与否,而我们使用到了派生于这个基类的实体类LoginUser作为传输的对象。更多通信协议(及相关实体),请参见:包包版网络游戏大厅 附录1 通信协议。我会在后续章节逐一介绍这些通信协议。
序列化机制
这里我使用到了BinaryFormatter,从而使序列化速度比较快;而流则选用了MemoryStream,专门用于Socket通信,通过
byte[] message = stream.ToArray();
直接将内存流转换为字节数组。
代码如下,注意到,我将其封装成一个静态方法,并放入CommonClassLibrary项目,以供Client端和Server端同时使用。


















通信机制
下面介绍通信机制。其实,用WebService是最简单的,可以穿透防火墙,同时也不需要额外的解析。用Remoting也不错。但是2年前,我正好接触到Socket编程,所以想直接在通信的最底层进行编程,达到练手的目的,于是,便有了上面若干思路。
补充说明:老怪这家伙批评我这篇文章是新手入门级别的,我想了想,也倒是,对于那些网络编程玩家而言,这确实很简单。但是,作为“包包游戏大厅”系列的第3章,此文的作用十分重大。我花了几天时间,把原先8000行代码的游戏大厅精简到现在这个简单的登录程序,就是为了让读者先掌握基本框架,然后带领读者逐步扩展功能。话说,当前的这套框架,是整个游戏大厅的通信基础,再往下,只要写通信协议就可以了,而不需要再关心Socket底层的数据包处理。有兴趣的朋友可以拿我这套框架去开发别的应用程序,而不只局限于游戏大厅。
下一节,让我们沿着这个思路,考察游戏大厅中聊天机制的实现。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· Open-Sora 2.0 重磅开源!
· 周边上新:园子的第一款马克杯温暖上架