包包版网络游戏大厅+桥牌系统 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方法:

        public MainThread()
        
{
            clientTable 
= new Hashtable();

            serverThread 
= new Thread(new ThreadStart(startListen));
            serverThread.Start();

            AddLog(
"Socket Server Started");
        }


        
public void startListen()
        
{
            
try
            
{
                
//Start the tcpListner 
                serverListener = new TcpListener(5151);
                serverListener.Start();
                
do
                
{
                    
//Create a new class when a new Chat Client connects 
                    Client newClient = new Client(serverListener.AcceptTcpClient());

                    newClient.Connected 
+= OnConnected;
                    newClient.Disconnected 
+= OnDisconnected;

                    
//Connect to the clients 
                    newClient.Connect();
                }

                
while (true);
            }

            
catch(Exception ex)
            
{
                AddLog(
"Error: " + ex.Message);
                serverListener.Stop();
            }

        }

     在上面这个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端异步接收网络包:

        public void Connect()
        
{
            AsyncCallback GetStreamMsgCallback 
= new AsyncCallback(myReadCallBack);
            myClient.GetStream().BeginRead(recByte, 
01024, GetStreamMsgCallback, null);
        }

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

        BuildText(sb.ReceiveAllByte);

     解析完成后,要做好善后工作:销毁SplitBytes实例,并开始读取新的网络包。

  

     以上大多是很底层的技术,尤其是这个异步接收网络包的设计,耗费了我不少心血和调试时间。大家可以借鉴一下这个框架。

     注意到,这个myReadCallBack方法从逻辑上分为三部分:

         1.如果第一次读取到的字节数小于1,表示客户端失去了连接,为此关闭这个连接,触发Disconnected事件后直接返回:

                lock (myClient.GetStream())
                
{
                    numberOfBytesRead 
= myClient.GetStream().EndRead(ar);

                    
if (numberOfBytesRead < 1)
                    
{
                        
//If a value less than 1 received that means that client disconnected 
                        myClient.Close();
                        
//raise the Disconnected Event 
                        if (Disconnected != null)
                        
{
                            EventArgs e 
= new EventArgs();
                            Disconnected(
this, e);
                        }

                        
return;
                    }

                }

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

                sb.AddBytes(recByte, numberOfBytesRead);
                recByte 
= new byte[1024];

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

                if (myClient.GetStream().DataAvailable)
                
{
                    myClient.GetStream().BeginRead(recByte, 
0, recByte.Length, new AsyncCallback(myReadCallBack), myClient.GetStream());
                }

                
else
                
{
                    BuildText(sb.ReceiveAllByte);
                    sb.Dispose();

                    
if (isLogin)
                    
{
                        
lock (myClient.GetStream())
                        
{
                            AsyncCallback GetStreamMsgCallback 
= new AsyncCallback(myReadCallBack);
                            myClient.GetStream().BeginRead(recByte, 
01024, GetStreamMsgCallback, null);
                        }

                    }

                }

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

 

     下面让我们进入BuildText解析函数。

        public void BuildText(byte[] dataByte)
        
{
            BinaryFormatter formatter 
= new BinaryFormatter();
            MemoryStream stream 
= new MemoryStream(dataByte);
            CommonProtocol obj 
= (CommonProtocol)formatter.Deserialize(stream);
            stream.Close();

            
string Protocol = obj.Protocol;

            
switch (Protocol)
            
{
                
case "501":
                    LoginUser u 
= (LoginUser)obj;
                    CheckUserName(u.UserID, u.Password, u.IP);
                    
break;
            }

        }

     分为两个阶段:

     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机制。

        public void Send(byte[] byteMessage)
        
{
            
lock (myClient.GetStream())
            
{
                BinaryWriter writer 
= new BinaryWriter(myClient.GetStream()); ;
                writer.Write(byteMessage);
                writer.Flush();
            }

        }

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

        if (Connected != null)
        
{
            EventArgs e 
= new EventArgs();
            Connected(
this, e);
        }

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

        public void OnConnected(object sender, EventArgs e)
        
{
            
//Get the client that raised the event 
            Client temp = (Client)sender;

            
//Add the client to the Hashtable 
            clientTable.Add(temp.UserID, temp);

            AddLog(
"Client Connected:" + temp.UserID);
        }

     这里,在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协议:

                Disconnect u = new Disconnect();
                u.Protocol 
= "510";
                u.UserID 
= temp.UserID;
                u.DeskNumber 
= DeskNumber;
                u.DeskPosition 
= DeskPosition;

                
byte[] message = SerializationFormatter.GetSerializationBytes(u);

                Client tempClient;
                DataTable dt 
= ClientList.Instance().GetUserList(HallNumber);
                
foreach (DataRow row in dt.Rows)
                
{
                    
string uid = (string)row["UserID"];
                    tempClient 
= (Client)clientTable[uid];
                    tempClient.Send(message);
                }

 

     记住,这里使用的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端使用到的主要函数如GetMsgBuildTextSendText都和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]以及在类中添加一个空的构造函数:

    [Serializable()]
    
public class CommonProtocol
    
{
        
private string protocol;

        
public CommonProtocol()
        
{
        }


        
public string Protocol
        
{
            
get
            
{
                
return protocol;
            }

            
set
            
{
                protocol 
= value;
            }

        }

    }

     注意到,这个类具有一个Protocol属性,代表自定义协议的编号,例如501协议请求登陆,502协议验证登陆成功与否,而我们使用到了派生于这个基类的实体类LoginUser作为传输的对象。更多通信协议(及相关实体),请参见:包包版网络游戏大厅 附录1 通信协议。我会在后续章节逐一介绍这些通信协议。

 

     序列化机制

     这里我使用到了BinaryFormatter,从而使序列化速度比较快;而流则选用了MemoryStream,专门用于Socket通信,通过

          byte[] message = stream.ToArray();

     直接将内存流转换为字节数组。

     代码如下,注意到,我将其封装成一个静态方法,并放入CommonClassLibrary项目,以供Client端和Server端同时使用。

    public class SerializationFormatter
    
{
        
private SerializationFormatter()
        
{

        }


        
public static byte[] GetSerializationBytes(object obj)
        
{
            BinaryFormatter formatter 
= new BinaryFormatter();
            MemoryStream stream 
= new MemoryStream();
            formatter.Serialize(stream, obj);
            
byte[] message = stream.ToArray();
            stream.Close();

            
return message;
        }

    }

 

     通信机制

     下面介绍通信机制。其实,用WebService是最简单的,可以穿透防火墙,同时也不需要额外的解析。用Remoting也不错。但是2年前,我正好接触到Socket编程,所以想直接在通信的最底层进行编程,达到练手的目的,于是,便有了上面若干思路。

 

     补充说明:老怪这家伙批评我这篇文章是新手入门级别的,我想了想,也倒是,对于那些网络编程玩家而言,这确实很简单。但是,作为“包包游戏大厅”系列的第3章,此文的作用十分重大。我花了几天时间,把原先8000行代码的游戏大厅精简到现在这个简单的登录程序,就是为了让读者先掌握基本框架,然后带领读者逐步扩展功能。话说,当前的这套框架,是整个游戏大厅的通信基础,再往下,只要写通信协议就可以了,而不需要再关心Socket底层的数据包处理。有兴趣的朋友可以拿我这套框架去开发别的应用程序,而不只局限于游戏大厅

 

     下一节,让我们沿着这个思路,考察游戏大厅中聊天机制的实现。

 

posted @ 2008-07-20 08:46  包建强  Views(6175)  Comments(46Edit  收藏  举报