View on GitHub

K.F.Storm

Welcome to K.F.Storm's Home!

Sharpshooter开发日志(2011-11-29)

源代码下载地址:http://sharpshooter.codeplex.com

小组成员:K.F.Storm LastSun XiaoBee_99 Diamond

从现在起写开始日志吧,不然很多花费大量精力完成的工作到后面都会忘掉。

Sharpshooter是我们OOP课程的大作业,一个聊天软件,我们的目标就是尽可能地实现QQ的常用功能。

现在项目已经有那么点样子了,服务器可以查看有哪些客户端连接上了,也可以查看所有收发消息的记录。客户端已经有了主面板和一对一聊天窗口,可以一对一聊天了。

下面记录一下之前遇到的还能想起来的技术问题。

1. 基本的网络通信

我们没有使用TcpListener和TcpClient来通信,而是直接使用Socket类来通信。

Server通信过程:

Server会新建一个Socket实例,调用Socket.Bind方法绑定到本机的一个端口上,然后调用Socket.Listen,就开始监听端口了。然后开始一个循环,利用Socket.Poll函数查看有没有Client在等待连接,如果有,则调用Socket.Accept返回一个新的Socket实例,这个实例可以用来和Client通信。每获得一个新的Socket实例,就新建一个ClientManager实例,用这个ClientManager去负责与相应的Client通信,这样Server就不用管与Client的具体交互了。

ClientManager通信过程:

ClientManger内部有一个消息队列,里面保存着尚未发送的消息,在主循环中,先将消息队列中的消息全部发送出去,再利用Socket.Poll函数查看Client有没有发送消息过来,如果有消息,则接收并处理消息(处理过程中可能会有新消息加入到消息队列中),处理消息完毕后,进入下一次循环。

Client通信过程:

Client内部同样有一个消息队列,里面保存着尚未发送的消息,在主循环中,先将消息队列中的消息全部发送出去,再利用Socket.Poll函数查看ClientManager有没有发送消息过来,如果有消息,则接收并处理消息(处理过程中可能会有新消息加入到消息队列中),处理消息完毕后,进入下一次循环。以上过程与ClientManager非常相似。不同的是,ClientManager总是被动处理消息,而Client是可以主动发送消息的,比如登录请求、发送聊天内容、注销等。这些操作都以Client的成员方法的形式存在,这些成员方法都会把要发送的消息添加到消息队列中,然后马上退出。所以这些操作都是异步的,比如调用LogIn方法后只能保证消息队列中有LogInMessage等待发送,并不能保证LogIn方法返回时就一定登录成功了,也不能保证LogInMessage已经发送了。

2. Server、ClientManager、Client的正常关闭

Server有一个单独的线程用于接收新的Client连接请求,一个Server中会包含很多个ClientManager,而每个ClientManager都有一个单独的线程用于处理与Client的通信,Client也有一个独占线程用于处理与ClientManager的通信。当我们想要关闭Client时,强行中止线程往往不是很好的办法,这里我们采用了一个标记,在Client的Close方法中,设置一个这个标记,然后等待Client的主线程检查这个标记,当Client检查到这个标记后,停止后面的工作,结束线程,这里Close方法才返回,这样就保证了Close方法返回后Client一定已经成功关闭。ClientManager与Server都是用的同样的方法保证正常关闭的。这里用了很多线程线程同步的知识,调错调了好久……

最初的通信过程中是没有使用Socket.Poll方法的,Server是直接调用Socket.Accept方法等待新的Client连接,而ClientManager和Client是调用Socket.Receive方法等待接收数据。Accept方法和Receive方法都是阻塞方法,如果没有连接或者没有数据,就会阻塞线程,直到有连接或数据为止。这样做的坏处是Socket会卡在Accept和Receive那里,直接导致Socket无法正常关闭。后来才发现Socket类还有个Poll方法,第一个参数设置查询时限,第二个参数设置查询类别,返回值为bool型,Poll方法会在时限内尽量返回true,如果过了时限就返回false。以Server为例,如果在100微秒内有新连接,则Socket.Poll(100, SelectMode.SelectRead)会在Client发起连接时返回true(还有其他情况也会返回true,要仔细阅读帮助文档),如果在100微秒内没有新连接,则会在100微秒后返回false。也就是说,如果有连接,则Poll会很快返回,如果没有连接,Poll会在超时后返回。如果返回true,则调用Accept方法建立一个新连接,如果返回false,则不调用Accept方法,继续循环。这样就既能在很多客户端同时连接时保证服务器运行效率,又能在没有客户端连接时不至于让服务器CPU占用率过高。

3. 消息的传递

消息以什么格式存储呢?我们没有使用字符串通信,而是采用了更加高级和方便的二进制序列化方法。使用字符串通信两个麻烦的地方:一是所有的格式都得自己制定,如果制定得不好,会不方便以后扩展;二是必要时必须对字符串转义,转义的规则也得自己制定,同样的,如果没制定好,就不方便以后扩展。而二进制序列化有两个好处:一是不用自己制定序列化规则,一句话就可以把一个实例写入流中;二是序列化后的内容保存得有类型信息,不需要提前告知流中的类型信息就能反序列化出一模一样的类的实例,这对于网络通信尤其有用,因为客户端和服务器都无法事先知道会收到什么样的消息。于是只要定义一个基类Message,然后从Message派生出各种有专门用途的消息就行了,接收方反序列化出的对象完整地保留了类型信息,只要判断一下具体是什么类型的信息,就能分别处理了。

之前我发现了一个诡异的问题,就是当服务器在很短时间内连续发送两次消息时,客户端经常只能收到第一次消息,收不到第二次的消息,我尝试在每次发送消息后让线程Sleep10毫秒,当时测试良好,以为解决问题了。可后来有一次跨局域网测试时,又出现这个问题了,这时我知道了网速也会影响到这个情况的发生,但仍然不知道为什么会这样。今天到网上搜索了一下,才恍然大悟,原因竟然是如此简单:我们建立的是利用字节流传输的Socket,而TCP协议为了提高传输效率,会将数据合在一起发出去,而不是我之前以为的调用一次Send就发送一次,这还不是根本原因,根本原因就在字节流上,因为发送数据其实就是在往流里写入数据,由于我们写的程序接收方每次都会把流读空,所以当发送方把多个数据组合在一起一次性发过来时,或者由于网络原因或性能原因,接收速度慢于发送速度时,就会出现接收一次会读取到多个数据的情况,可能又由于二进制序列化本身的原因,反序列化完成后就不管后面没用到的数据了,这才出现只收到一次消息的情况,其实是两次消息黏在一起了!

知道原因后,解决办法就简单了,每次发送消息时在消息前面加上4个字节的消息大小的信息,接收方首先读取4个字节的大小信息,然后严格按照这个大小读取数据,不多也不少。这样如果有消息黏在一起的情况,也能把两个消息分开,读取完前一个消息后,后一个消息仍然停留在流里,等待读取。

评论

(无)