返回目录
本文讲解了网络游戏大厅的登录部分的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实例:
 Client newClient = new Client(serverListener.AcceptTcpClient());
同时还会将两个方法OnConnected、Disconnected绑定到Client实例的两个相应的事件上,分别用来处理连接、离线的情况:
 newClient.Connected += OnConnected;
 newClient.Disconnected += OnDisconnected;
接下来一条语句是非常关键的:
 newClient.Connect();
这就开始了对消息进行接收并解析的过程,让我们一步步地分析。
话说,Client实例的这个Connect方法,导致了Server端异步接收网络包:
我们看到这个异步方法,从0位置开始,每次读取1024个字节,然后就会异步回调方法myReadCallBack,构成一个循环,直至读取到所有字节,并将其放入SplitBytes实例中并对其进行解析:
 BuildText(sb.ReceiveAllByte);
解析完成后,要做好善后工作:销毁SplitBytes实例,并开始读取新的网络包。
以上大多是很底层的技术,尤其是这个异步接收网络包的设计,耗费了我不少心血和调试时间。大家可以借鉴一下这个框架。
注意到,这个myReadCallBack方法从逻辑上分为三部分:
1.如果第一次读取到的字节数小于1,表示客户端失去了连接,为此关闭这个连接,触发Disconnected事件后直接返回:
2.接下来就可以读取字节了:
 sb.AddBytes(recByte, numberOfBytesRead);
 recByte = new byte[1024];
3.之后我们要判断,这个Socket上的数据流是否读取完了。如果没读取完,也就是if条件为真,就会异步调用myReadCallBack方法,再次读取1024位字节,从而形成循环,直到读取完所有数据流,此时进入if条件的另一分支。
在另一分支里,会使用BuildText方法反序列化接收到的字节数组,并进行登录验证。如果验证成功,isLogin标记被置为true,从而等待这个Client端线程发送新的数据包;否则,不会再等待这个Client端,而且也不会存储这个线程,如果再次登陆,则一切重头再来。
下面让我们进入BuildText解析函数。
分为两个阶段:
1.将字节数组进行反序列化成对象obj
2.根据对象obj的Protocol属性,执行不同的逻辑。如501协议代表请求登录,则将obj进一步转化为LoginUser对象,使用CheckUserName方法,对其进行检验:
 LoginUser u = (LoginUser)obj;
 CheckUserName(u.UserID, u.Password, u.IP);
*注:之所以能将obj转化为LoginUser对象,是因为Client端发送501协议时,就是将其封装在LoginUser对象中并进行序列化的。
接下来的CheckUserName方法,分为两部分逻辑:
1.在DB中进行验证,成功与否封装在LoginUser对象的IsLogin属性中,
2.建立502协议,序列化LoginUser对象u,并发送:
 byte[] message = SerializationFormatter.GetSerializationBytes(u);
 Send(message);
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以表示它的离开:
 DB.UpdateStatus(temp.UserID, "0");
然后分别去除哈希表中相应的线程和DataTable中该用户的相关资料,
 clientTable.Remove(temp.UserID);
 DataRow findRow = ClientList.Instance().FindUserRow(temp.UserID);
 ClientList.Instance().RemoveClient(temp.UserID);
最后,遍历哈希表中剩下的用户,逐一向它们发送有人离线的消息,这是一个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底层的数据包处理。有兴趣的朋友可以拿我这套框架去开发别的应用程序,而不只局限于游戏大厅。
下一节,让我们沿着这个思路,考察游戏大厅中聊天机制的实现。
发表评论
newClient.Connected += OnConnected;
newClient.Disconnected += OnDisconnected;
如果这样是不是在do while里给事件挂好多方法啊?
这样写是不是会好一点
:if(newClient.Connected == null)
newClient(sender, e);
#3楼 [楼主]2008-07-27 23:03 |
@非空
这倒不会,每个客户端一个newClient,下次再进来,直接使用这个newClient,不会再调用这里“给事件挂好多方法”。你设置个断点调试一下,就知道了。
噢,我设置看下^_^,还有就是请教包哥一下,在回调方法myReadCallBack里又调了BeginRead 我看MSDN上说 BeginRead中的回调方法用的是线程池的线程,如果是这样的话当数据量大的时候就一直在BeginRead 完了回调 的循环会不会把线程耗光啊?
#5楼 [楼主]2008-07-27 23:23 |
@非空
这倒没想过。我这套程序,当初只是写来玩玩,把一些灵感实现出来。
博客园有没有异步调用的高手啊,解释解释这个问题啊!
--引用--------------------------------------------------
包建强: @非空
这倒没想过。我这套程序,当初只是写来玩玩,把一些灵感实现出来。
博客园有没有异步调用的高手啊,解释解释这个问题啊!
--------------------------------------------------------
明天写个死循环,再打开任务管理器看看回调方法会不会耗光 o(∩_∩)o...哈哈!
好好休息天天向上!
博主不简单,自己开发的东西,拿出来分享.很不简单啊!
#17楼 [楼主]2008-07-29 10:34 |
@myjia
PlayCard的2.1版本没有这个文件和这个设置,请你确定下载的版本是正确的。
#19楼 [楼主]2008-07-29 11:02 |
@myjia
刚刚测试过,用VS2008打开时需要转换。打开后,按F5编译,仍然可以运行。查看Project属性,发现仍然是使用.NET2.0编译的,所以运行良好。
强烈建议,包包游戏大厅,改名为“老包”游戏大厅
包包这个名字,我念着都肉麻~~
我想请教一下,在大厅里,如何实现桌子与人物点击的结合呢?就是点击一个位置,人物头像出现。桌子是用控件绘制的,还是操作GDI呢,如果是IMG控件,在桌子多时,其性能是非常低的
#24楼 [楼主]2008-07-31 09:01 |
@横刀天笑
今天要在此基础上,介绍聊天的实现,还有大厅的建立。所以重发旧文,以免读者摸不着头脑。
收藏下,自己也有打算做这样的一个东西,不过不是游戏用
#26楼 [楼主]2008-07-31 09:05 |
@炮灰向前冲
桌子是一个控件,里面每个位置是一个button,桌子布是图片,对于Client端,这点性能算是还不算什么。而且,button因为是.NET内部支持的控件,所以性能会好一些。
@LZ能不能下次做游戏的时候不要以桥牌来说啊,说个什么斗地主的都行啊,通俗点,有几个人会玩桥牌啊,看包兄写文章的意思,好像就是写给少数人看的,不写的新奇的东西出来觉的没有成就感啊?
#29楼 [楼主]2008-07-31 09:23 |
@姜敏
两年前写的代码,没考虑现在普及的事情,所以选择了我最喜欢的桥牌。不过这次重构,准备实现象棋或拱猪的游戏,这样大家容易接受一些。
博客园首页固然不能说什么文章都放,也并不一定要放些别人一看就觉的特别高深的东西.它是一个技术沟通平台,只要是园友精心写的code,我觉的通俗一点并没有什么坏处,并不会因为通俗而失去文章的意义,所谓简约而不简单,不知道包兄对我的看法是否同意呢?如有冒犯的地方还望谅解.
posted @ 2008-07-31 08:46 包建强 阅读(2153) 评论(30)
#1楼 61.48.45.*2008-07-27 22:49 | good [未注册用户]
你这算啥?
#33楼 [楼主]2008-07-31 09:36 |
@Jeffrey Zhao
小赵说的是,我忘了重构这个数据结构了,下一版加聊天功能时改一下。
#34楼 [楼主]2008-07-31 09:38 |
@丁学
别有用意!
#36楼 [楼主]2008-07-31 09:57 |
@飘过
因为今天讲聊天机制实现。
#40楼 [楼主]2008-07-31 10:47 |
@客家网络
请仔细读我的myReadCallBack方法,因为是异步循环的机制,这就确保了接受的两个消息不会混在一起,会被先后处理。
这个机制和我原来写的一个游戏大厅的机制几乎一样
但是 如果一个client 的所有异步read 都是自己上一次read结束后开始的
lock (myClient.GetStream()) 就显得没有必要了
我的游戏大厅完全没有lock过client的 stream 也从未因为多线程访问而出过错误(50-1000连接 6x24 运行半年左右) 所以是不是lock了没有必要的部分造成了多余的开销呢
--引用--------------------------------------------------
包建强: @非空
这倒没想过。我这套程序,当初只是写来玩玩,把一些灵感实现出来。
博客园有没有异步调用的高手啊,解释解释这个问题啊!
--------------------------------------------------------
对了 不是线程池 是完成端口
挑刺:
(1) 赞同韦恩卑鄙的意见,不必要lock,不存在冲突的可能性;
(2) recByte 太小,不够容纳一个packet;
(3) 每次监听完了再new一个recByte 无必要 recByte = new byte[1024];
(4) SplitBytes需要动大手术,性能问题
@包建强
TCP协议和OS协议栈都无法保证2个消息不能混在一起。同时,也无法保证你一次异步处理的是一个完整消息。比如,一个消息是2000byte长,分两次发,第一个packet收到了,OS回调,读取清空了TCP Socket缓冲区,在缓冲区中没数据了,NetworkStream.DataAvailable 傻傻的为 false,于是就当成整条消息处理了,而此时消息的后半部分还没到呢。局域网内压力小时这种现象很少,压力大时或者非局域网就会出现这类问题。
|