我不再装模作样地拥有很多朋友,而是回到了孤单之中,以真正的我开始了独自的生活。有时我也会因为寂寞而难以忍受空虚的折磨,但我宁愿以这样的方式来维护自己的自尊,也不愿以耻辱为代价去换取那种表面的朋友。
文章目录
- 一、项目设计
- 1. 游戏房间管理模块
- 1.1 游戏房间的设计
- 1.2 room类的实现
- 1.3 游戏房间管理的设计
- 1.4 room_manager类的实现
- 2. 匹配队列管理模块
- 2.1 匹配队列的设计
- 2.2 match_queue类的实现
- 2.3 匹配队列管理的设计
- 2.4 match_manager类的实现
- 3. 整合封装服务器模块
- 3.1 业务请求分析+通信接口设计
- 3.2 服务器整体框架的实现 + 前端页面业务请求的框架实现
- 3.3 http_callback
- 3.4 wsopen_callback
- 3.5 wsclose_callback
- 3.6 wsmessage_callback
一、项目设计
1. 游戏房间管理模块
1.1 游戏房间的设计
1.
设计游戏房间的主要目的就是为匹配成功的两个用户实现一个小范围的关联关系,即一个游戏房间内有两个下棋的玩家,任意一个玩家的任何请求操作都会被广播给房间中的所有用户,在游戏房间里面的请求其实只有两种,一个是下棋请求,一个是聊天请求,而在游戏大厅中的请求其实也有两个,一个是开始对战匹配请求,一个是停止对战匹配请求。
有人可能会有疑问,为什么游戏房价中任意一个用户的请求操作,都会被广播给房间中的所有用户呢?其实这个很好理解,比如你在自己的前端页面上下了一步棋,其实你不是直接下的,而是先将下棋请求发送给服务器,由服务器来检测你下棋的请求是否成功,如果成功,服务器才会返回响应,但服务器返回响应不是返回给你一个人的,服务器会把响应也返回给你的对手,为什么要这么做呢?道理很简单,你在自己的前端页面上描绘了一颗棋子出现,你对手的棋盘的对应位置需不需要描绘一颗棋子出来呢?当然是需要的啊!要不然你下你的,我下我的,随便下不就好了,你下的棋子,你对手看不着,你对手下的棋子你看不着,那不就乱套了么!所以下棋这样的请求的响应,服务器是要广播给一个房间中的所有用户的!
同样对于聊天请求也是如此,你发送了一段消息,这段消息会被发送给服务器进行敏感词检测,如果成功,则服务器会把这段消息返回给房间中的所有用户,对于不同的用户消息展示的位置是不一样的,对于你来说,消息应该展示在右侧,而站在你对手的角度来说,消息应该展示在他的左侧,所以聊天这样的消息也是要转发给房间中的所有用户的!因为这消息是需要双方都看到的,道理和下棋一样,房间中的两个玩家都要看到这些请求的响应。
2.
在了解上面服务器广播消息的原因之后,我们来看一下一个游戏房间到底需要哪些成员变量属性才可以正好被描绘为一个房间。
每个房间肯定都需要有自己的唯一标识符,那就是rid,其次房间需要有两种状态,一种是游戏开始的状态,一种是游戏结束的状态,为什么要有两种状态呢?因为在不同的房间状态下,用户退出房间的业务处理逻辑是不一致的,如果游戏是开始的,那么某一方用户退出,另一方用户就是不战而胜,因为有用户掉线了啊。如果游戏是结束的装填,那么任意一个用户退出这都是正常的行为,等到房间中的两个用户都退出之后,此时房间正常销毁就可以了。所以还需要state房间状态,player_number房间中的玩家数量。在房间中,还需两个信息,那就是为两个玩家分配白棋还是黑棋的用户id,我们规定,游戏进入游戏房间的用户为白棋选手,我们为白棋选手分配white_id,后进入游戏房间的用户为黑棋选手,为黑棋选手分配black_id。
除了上面的几个信息外,剩下的就是一些句柄了,当游戏房间中胜负已分时,我们要更新数据库中两个用户的信息,所以还需要数据管理模块的句柄,在用户发起聊天或下棋请求时,我们要判断用户此时是否在线,同时还要将请求处理后的响应广播给房间中的所有用户,那么就还需要两个用户的websocket连接,这两个需求的实现,离不开在线用户管理模块句柄,因为在线用户管理模块提供的两个最主要的功能就是判断用户是否在线 和 获取用户对应的websocket连接。同时也需要一个二维数组board来表示棋盘信息。
最后还需要一把锁来保护共享资源,这里给大家下个定义,所有可能被多线程同时访问的资源都叫做共享资源,不考虑访问方式,访问方式可能是修改,也有可能只是拿一下值。
什么情况下,共享资源需要被加锁保护呢?当某一个资源既有可能被修改,又有可能被访问拿值,那么在多线程环境下,就有可能产生安全问题,此时我们要对这样的资源进行加锁保护!如果某一个资源只会被多线程访问,不会被修改,那么这样的资源虽然是共享资源,但其实我们不需要进行加锁保护,因为他不会产生安全问题!
所以只要可能产生线程安全问题,那我们就进行加锁保护!
3.
需要实现的函数有下面这么一串,最主要的接口就是从下向上倒数的五个接口,我们将房间中请求业务的处理都放在这里了,例如handle_request就是处理请求的主接口,根据请求req中请求类型的不同,区分出该请求是下棋请求还是聊天请求,如果是下棋请求,那就在handle_request内部调用handle_chess接口,并返回一个json格式的响应字符串,如果是聊天请求,那就在内部调用handle_chat接口,也返回一个json格式的响应字符串,值得注意的是退出房间的操作不是处理请求到来的接口,他无需返回json响应,只需要将玩家从房间中移除即可,所以他是独立出来的一个业务处理接口,封装成handle_exit。因为需要将业务处理后的响应广播给房间中的所有用户,所以我们在实现一个broadcast接口,用于将外部传入的json响应广播给房间中的所有用户。
其他剩余接口都是一些获取room类中成员变量 或者 设置room类中成员变量的辅助接口,例如向房间中添加白棋和黑棋用户接口,从房间中获取白棋和黑棋用户接口,获取房间中玩家数量,房间状态,房间id等接口。
还有额外的两个私有接口check_win和check,这两个接口是用来判断当用户下完这一步棋之后,胜负是否已分,有没有达到五子连珠。
1.2 room类的实现
1.
这些辅助接口的实现我就不说了,大家看一眼就明白了,对于white_id,black_id和player_number在多线程访问的时候,可能会出现安全问题,所以在修改的时候需要加锁保护。
(值得注意的是,线程安全是一种风险,风险意味着可能会出错,也有可能不会出错。如果不加锁保护,一般在测试数据量较小的情况下,可能不会体现出这种安全问题,但只要数据量上来之后,这种安全问题就会立马体现出来!所以只要涉及到可能产生线程安全问题的成员变量,对这种变量的操作我们就都需要进行加锁保护!
虽然一个项目中可能处处要进行加锁,这会导致服务器的效率会降低一些,但服务器稳定才是最重要的!我们的底线是服务器不能挂掉,同时服务器不能出错!所以加锁保护是一件必要的事情,没得商量!)
2.
在判断胜负这里,其实就是从当前的row和col的下棋位置,分别从竖着的这条线,横着的线,正斜的线,反斜的线四个方向来判断是否有五个颜色相同的棋子,我们是以加减偏移量的方式来判断各个方向上是否有五星连珠,比如横纵偏移量是01,那么加上这个偏移量之后,棋子的位置就会便宜到当前位置的右边,我们就一直向右判断,直到遇到棋盘边界或者遇到颜色不相同的棋子后就停下来,这样就完成了一条线上以当前位置为基准一侧的判断,接下来就是让cur_row和cur_col回到原始的位置,让横纵坐标分别减去这个偏移量,那么此时棋子的位置就会便宜到当前位置的左边,然后再一直向左判断,停止的条件和向右判断一样,这样就完成了横线上是否有五子连珠的判断。其余的三根线判断方式也是如此,最后只要有一根线上满足五星连珠的条件,那么我们就说此刻棋局结束,某一方赢得了胜利。
需要特殊说明一点的是,_board和ret这两个变量都是可能产生安全问题的共享资源,所以在访问他们或者修改他们的时候一定要加锁控制,下面代码中我也是使用了RAII风格的加锁方式来进行保护。
3.
在处理请求字段这里我们需要先了解一下前后端报文格式的设计,因为只有知道了前后端通信的报文格式的协议之后,我们才能解析请求报文,从而判断请求类型是什么,进而做出相应的业务处理,这样的协议一定要在项目实现前双方都确定好,因为这样的通信报文协议一换,前后端交互的报文格式都需要更改,那代码的改动量就会变得非常大,所以一定要提前定制好通信时,报文的格式是什么。
游戏房间中的请求都是websocket请求,所以我们直接采用json序列化和反序列化的方式来进行数据的通信。
下面是下棋请求和下棋请求成功时/失败时的json响应格式。
下面是聊天请求和聊天请求失败时/成功时的json响应格式。
4.
在处理请求时,首先判断一下请求中的房间号是否与本房间相同,如果不相同,那就直接构建一个json响应消息,原因就是房间号不匹配,并且把这个消息广播给房间中的所有用户,这算是一种提前校验的方式,主要用来帮助我们进行将来可能产生的不同种类情况的请求进行处理,大部分情况下,前端那里发送的websocket消息是会发送到对应的房间中的。
校验房间号没问题之后,就通过req中的optype来区分是下棋请求还是聊天请求,如果是下棋请求,则直接调用handle_chess函数,将req传过去,同时返回一个json格式的resp,如果resp里面的winner不为0,那么就说明在用户下完这步棋之后,胜负已分,那我们就需要更新数据库中用户的信息,更新的过程其实就是调用user_table类里面的win和lose,怎么调用呢?很简单,通过user_table类的句柄就可以调用。
此时已分胜负之后,房间的状态信息我们还要更改一下,值得注意的是_state也是可能产生线程安全问题的共享资源,所以我们也要进行加锁保护。
对于聊天请求的处理就比较简单了,不需要更新房间状态信息以及数据库的信息,直接调用handle_chat进行聊天信息的检测就可以了。
如果optype的类型既不是put_chess,又不是chat,那么此时我们就返回一个错误信息,表示当前请求的类型是未知的。
最后将业务处理后的resp响应广播给房间中的所有用户即可。
5.
在下棋请求的业务处理这里,可能一个用户刚下完棋,然后对方就退出游戏房间,关闭前端页面了,那么此时对应的websocket连接就断开了,这个时候游戏房间在线用户管理中就会移除这个用户,所以第一步我们先判断白棋和黑棋用户哪个不在游戏房间在线用户管理中,只要有一个不在,那就说明有一个用户掉线了,另一个获得胜利,返回resp响应信息即可。
如果两个用户都在线,那么就判断当前下棋请求中的下棋位置是否已经有棋子,如果有,那么就返回一个错误信息"您所下的位置已有棋子",如果下棋位置没有其他棋子,那就下棋成功,更改board上面对应位置的值,由于board需要被保护,所以我们进行加锁控制。
下棋成功之后,接下来就是判断下棋之后是否胜负已分,如果胜负已分,那么就将resp中的winner设置为胜利用户的uid,如果胜负未分,那么就将resp中的winner设置为0。
6.
处理聊天请求是比较简单的,其实我们就是进行一个敏感词的检测,比如不允许发送侮辱人的词汇等等,这里只是拿"垃圾"做一个样例,如果后期想要扩展,这里可以在封装一个专门添加敏感词的接口。不过我们这里就不做了。
如果消息中不包含敏感词,那就直接返回resp即可,在handle_request中会统一进行resp的广播。
7.
在处理退出房间的业务时,如果此时房间状态是GAME_START,这个时候如果有玩家退出了房间,那么另一个玩家就是不战而胜,现在已经分出了胜负,那当然就得进行数据库信息的更新了,同时别忘记将房间的状态信息更改为GAME_OVER,因为胜利的玩家在退出房间时,此时也会调用这个接口,这个时候不应该走到if的分支语句里面去,而应该是将房间中玩家数量- -,然后正常返回。
由于state的判断与更改,player_number的更改都是不安全的,所以我直接在if外面进行了加锁保护。
8.
广播消息实现起来也不难,将参数resp进行序列化,然后通过在线用户管理模块的句柄来获得白棋和黑棋玩家的id,通过id来拿到websocket通信的句柄,也就是conn智能指针,通过调用connection类里面的send函数就可以在websocket连接上,发送已经序列化好的body数据。
1.3 游戏房间管理的设计
1.
每个房间都有自己的房间id,所以成员变量需要有一个房间id分配器。由于房间的构造函数需要我们传入user_table和online_manager的两个句柄,所以房间管理这里也需要这两个成员变量。
由于房间可能会存在多个,所以我们需要先描述,再组织,描述的过程我们上面已经完成了,组织的过程,我们通过哈希表来进行组织,构建房间id和房间对象之间的映射关系,当然哈希表不能直接存储房间对象,要不然需要的空间太大了,那我们就存储管理房间对象的智能指针,这个智能指针也必须是shared_ptr,原理我前面应该说过,主要是因为在向哈希表中插入键值对的时候,会发生智能指针的拷贝,所以我们只能使用shared_ptr。
最后封装服务器模块时,服务器模块一般都只知道uid是多少,所以经常调用的接口是通过uid来获取房间信息,所以我们还得构建uid和房间信息的映射关系,但这样其实是没必要的,因为我们已经有了_rooms这个哈希表了,想通过uid找到room_ptr,其实只要衔接上uid和rid的关系就可以,所以我们只需要再搞一个存储uid和rid的哈希表就可以了,这样也能节省一些空间。
最后成员变量肯定离不开互斥锁,因为这里边有三个共享资源,两个哈希表,一个id分配器。对于ut和om句柄的操作我们是不需要保护的,因为这两个模块中所有的接口在当时实现的时候,就实现为线程安全的了,所以不需要被保护。
2.
当两个玩家在游戏大厅中匹配成功之后,我们就应该为这两个玩家创建一个游戏房间,所以必须提供一个create_room接口。通过rid和uid来获取房间详细信息的接口也应该提供出去,以及通过rid来销毁房间,通过uid来删除房间中的指定用户等接口,我们都public提供给外部。
1.4 room_manager类的实现
1.
在创建游戏房间时,需要外部传入对战匹配成功的两个用户的uid,创建游戏房间的前提是,两个用户都必须在游戏大厅在线管理中,只要有一个不在,那就说明有一个用户关闭游戏大厅的页面了,或者是停止匹配了,此时我们是不为这两个用户创建游戏房间的。
当两个玩家都在游戏大厅时,此时就创建出一个游戏房间,同时向这个游戏房间里面添加白棋用户和黑棋用户,其实这里吧还是可以修改的,我们可以直接在room的构造函数里面多加两个参数,分别代表黑棋和白棋用户,这样在创建room对象的时候,就可以直接在构造函数里面传参,而不需要创建房间之后,通过调用add_black_user和add_white_user来进行房间中用户的添加了,这里我也不带要改动了,就这么滴吧,改动和不改动对服务器的效率也没啥大的影响,这里就这么搞了,等后面大家拿到项目源码的时候,如果想改,那就自己改改吧。
创建好房间之后,剩下的操作就是向哈希表中插入键值对,通过哈希表来进行房间的管理,然后在给房间id分配器自增1,最后返回游戏房间的句柄即可。
2.
通过rid来查找room_ptr和通过uid来查找room_ptr,实现起来确实都不难,无非就是在哈希表根据key值来进行键值对的查找,找到之后返回迭代器指向的second即可,如果没有找到,则返回一个空的room_ptr对象即可。
由于上面的操作都是对哈希表进行访问,所以访问的过程都要加锁保护。
3.
在destroy_room里面,首先进行房间信息的判断,如果房间信息为空,那么就说明没有房间,那就直接返回即可。如果不为空,那就需要进行房间的销毁工作,但怎么销毁房间呢?其实很简单,只要从哈希表中移除包含房间room_ptr的键值对就可以销毁房间了,因为整个类里面只有哈希表会一直在堆上存储着管理房间对象的智能指针,一旦智能指针被销毁,那么房间对象所占用的内存也就会被释放。
不光要销毁房间,房间id和用户id的关联键值对也要进行移除,这些步骤都需要进行加锁保护。
在remove_room_user里面,我们需要对特定的某个房间中的某个用户进行移除,那就先通过get_room_by_uid来获取到房间的详细信息,如果房间为空,则什么也不做直接返回。如果不为空,则调用room类里面的handle_exit接口,进行房间中用户的移除。在移除过后,我们需要判断房间中玩家的数量,如果房间中没有玩家了,那么就需要调用destroy_room进行房间的销毁。
2. 匹配队列管理模块
2.1 匹配队列的设计
1.
其实所谓的匹配队列就是阻塞队列,我们匹配队列总共会实现三个,分别对应不同档次天体分数的玩家进行匹配,青铜玩家只匹配青铜选手,白银匹配白银的,黄金匹配黄金的,所以我们一共会实现三个阻塞队列分别代表三种不同档次玩家的匹配过程。
这三个队列其实就是blockqueue,除此之外我们还需要为每个阻塞队列创建出一个匹配的线程,这个线程其实就是消费者,用于判断当前队列中用户的个数是否超过2个,如果超过2个,则出队头的前两个用户,为这两个用户创建游戏房间,以上就是匹配队列的设计思想。
2.
在实现匹配队列的时候,我们选用的底层容器不是STL提供的queue,而是选用双向链表list。
可能存在这么一种情况,大量的青铜选手向服务器发起了对战匹配的请求,那么服务器的青铜阻塞队列就会被塞满大量的用户,当然这也是可能发生的啊,因为这个阻塞队列的消费线程会在队列中元素大于2的时候取出前两个用户,所以你可以想象成阻塞队列里面是有可能堆满很多的等待匹配成功的用户的,因为一个消费线程进行消费,和websocketpp库里面的多线程/我们自己的主线程向阻塞队列立马push用户,这两个操作都是互相阻塞的,因为只要涉及到对阻塞队列进行访问,就会进行加锁保护,所以到底阻塞队列里面会出现多少个用户这是不确定的,有可能100个用户发起对战匹配请求,有10个被消费线程消费了,同时创建了5个游戏房间,也有可能是11被消费线程消费了,即将要创建第6个游戏房间,这些都是不确定的!因为多线程所造成的情况是非常复杂的,我们是无法提前预知判断的!我们能做的只能就是说尽可能的把所有情况都想一遍,让我们的服务器能够解决我们尽力想到的所有业务场景下可能会产生的问题,等实际项目上线时,如果在产生了问题,我们在进行不断的改进就好。
所以就会有阻塞队列中一串用户,中间的某个用户想停止匹配,那么此时阻塞队列就要有能够将这个用户移除的功能,但移除中间的某个用户,STL的queue是无法做到的,所以我们就选用了list这个容器。我们实现的阻塞队列,那是典型的生产消费模型,也就是典型的线程同步与互斥的使用场景,所以锁和条件变量肯定是没的跑啦!匹配队列这里一共也就包含了这三个成员变量。
3.
在匹配队列这里,我们需要向外部提供的成员函数有,push用于向阻塞队列中添加用户,pop用于出队头的用户,同时将用户的uid以输出型参数的方式来让外部获取,remove就是我们最开始提到的,如果队列中间的某个用户突然不想匹配了,那队列需要提供能够从队列中移除特定用户的接口,size用于获取队列中元素的个数,empty用于判断队列是否为空,wait也是比较重要的接口,用来阻塞线程,当队列中元素个数不到2时,消费线程应该在该队列类里面所创建出来的条件变量_cond下阻塞等待,直到队列中元素个数超过2之后,此时唤醒条件变量下阻塞等待的线程,让消费线程进行取队头用户,创建游戏房间的操作。
2.2 match_queue类的实现
1.
在push这里,我们实现的思想是,只要入队一次数据那我们就唤醒一次线程,让线程去看看是否现在能出队用户进行游戏房间的创建了,所以我们将push_back和notify_all两个接口一起放在push里边实现了。对list的操作会涉及到线程安全问题,所以我们进行加锁保护,另外唤醒这个接口也需要在加锁的条件下进行操作,也就是在临界区里面进行操作。
这是为什么呢?因为notify的操作其实就是释放锁,把锁释放,将锁归还给_cond.wait()的线程,哪个线程执行了_cond.wait()而导致在条件变量下等待,那么notify就会把锁归还给哪个线程,你既然都归还锁了,那么前提就是你得有锁啊,那你在哪能有锁呢?只能在临界区的时候才有锁,所以调用push接口的线程(这个线程可能是我们自己服务器的主线程,也有可能是websocketpp库里面的多线程)就会把锁归还给条件变量下等待的消费线程,此时消费线程就可以执行他自己的逻辑了。
(多说一嘴,这里使用的是notify_all而不是notify_one,其实这两个都行,因为我们的项目是一个线程对应一个阻塞队列,一个阻塞队列对应一个条件变量,三者是1 : 1 : 1的,所以你唤醒一个还是唤醒全部,到头来都是一样的,因为一个阻塞队列只会匹配一个消费线程!)
我们这里的pop只是pop一次队头的元素,而不是pop两次,因为你无法保证队列里面此刻就有两个用户,我们以输出型参数的方式来将用户的uid进行返回,将其交给外部,整体的过程都要进行加锁保护,因为我们一直在访问list。
移除指定的用户,我们就不自己操心了,直接调用STL里面list实现的接口remove即可,将指定用户从队列中移除。同样的,依旧需要进行加锁保护。
2.
获取队列元素个数,队列判空这些接口,STL早就帮我们实现好了,我们只需要进行加锁控制就可以。然后我们在实现一个阻塞消费线程的接口,_cond.wait接口必须传递unique_lock的RAII的锁,不能传递lock_guard和原生的mutex锁。
主要是因为,线程在条件变量下等待的时候是抱着锁等待的,也就是在临界区进行等待,而在实际等待期间线程又会释放锁,等线程被唤醒的时候,由于线程是在临界区被唤醒的,所以线程被唤醒之后又要拿着锁,那么这个过程就涉及到任意申请锁和释放锁的操作,所以lock_guard是肯定不行的,因为他是纯RAII风格的锁,只有在对象被销毁的时候才会主动释放锁,无法手动式的任意申请锁和释放锁。
那为什么不选用原生的mutex锁,而选择使用unique_lock呢?主要还是因为unique_lock是RAII的,使用起来要比原生的mutex更为灵活和安全!
C++11线程库
2.3 匹配队列管理的设计
1.
在匹配队列管理这里,我们要创建三个不同档次的匹配队列,对用户进行划分等级的对战匹配,同时还需要创建三个分别匹配不同档次阻塞队列的消费线程。
创建房间肯定得需要room_manager的管理句柄,将用户添加到不同档次的匹配队列里面,那就需要拿到该用户的score分数,那就还需要user_table的管理句柄,在两个用户对战匹配成功后,我们需要给客户端,也就是前端浏览器,返回一个json响应,告知双方用户对战匹配成功,所以还需要一个在线用户管理模块句柄也就是online_manager类的对象指针_mm
2.
由于我们创建出来了三个线程,每个线程肯定要有对应的线程执行入口函数,所以在私有方法里面分别实现了三个线程的入口函数,由于每个线程执行的逻辑都是一样的,所以又实现了一个统一的私有方法handle_match。
公有函数其实就两个接口,当服务器收到客户端的对战匹配请求后,需要将用户添加到指定的匹配队列里面,所以我们提供一个add接口,当服务器收到客户端的停止对战匹配请求后,需要将用户从特定的匹配队列中移除,所以我们提供了一个del接口。
2.4 match_manager类的实现
1.
我们知道线程被创建出来之后,一定是要被join的,否则就会造成线程的PCB资源泄露,但这里不太一样,一旦消费线程跑起来,那就是死循环,需要一直检测是否队列中有两个用户,如果有则pop+创建游戏房间,如果没有则需要一直死循环在条件变量下进行阻塞等待,所以这里无需调用thread.join接口。
在实现时,需要判断出队头的操作是否成功,对于队列中的第一个元素来说,如果取出的操作失败,那就什么都不用做,直接continue,重新进行循环的逻辑执行,如果第一个取出成功,第二个取出失败,那就需要将第一个元素重新放回到队列里面,然后再continue,重新进行循环的逻辑执行。当两个用户都被取出来之后,下一步就是判断两个用户是否都在游戏大厅中,只要有一个不在游戏大厅中,那就说明此时有某个人掉线了,可能断网了,或者他自己关闭游戏大厅的页面了,那么此时我们要把相对的那个人重新放回匹配队列中,然后continue重新执行循环逻辑。
上面的检测成功之后,那就为两个用户创建游戏房间,如果游戏房间创建失败,则将两个用户重新放回阻塞队列中,continue重新执行循环逻辑。如果房间创建成功,那我们就构建响应,表示对战匹配成功,通过conn1和conn2来将响应分别返回给客户端即可。
如果队列中人数不大于2,那么线程在条件变量下阻塞等待即可。如果大于,那就执行上面所说的逻辑。
2.
添加用户到指定匹配队列,则需要先通过_ut句柄拿到玩家的天梯分数值,根据天梯分数的不同,调用不同的阻塞队列,进行玩家的push
3.
移除玩家的逻辑也是同样如此,需要先根据天梯分数确定出玩家所在的匹配队列,然后调用我们之前实现好的remove接口,进行指定玩家的移除
3. 整合封装服务器模块
3.1 业务请求分析+通信接口设计
1.
在整合封装服务器模块这里,需要做的事情有两件,第一件是搭建好一个基本的http/websocket服务器出来,能够完成服务端和客户端的通信,第二件是针对客户端发起的一系列业务请求,进行相应的业务处理,而业务处理的完成,其实就是通过我们前面所实现的一系列模块来进行的。
2.客户端发出的业务请求都有:
1.注册页面register.html的获取请求
2.点击注册提交按钮发起注册请求(请求通过后应跳转到登录页面)
3.登录页面login.html的获取请求
4.点击登录提交按钮发起登录请求(请求通过后应跳转到游戏大厅页面)
5.游戏大厅页面game_hall.html的获取请求
6.用户个人详细信息的获取请求(游戏大厅要展示用户的昵称,总战斗场次,胜利场次等详细信息)
7.发起websocket握手的HTTP请求(进入游戏大厅后,连接要从http切换为websocket)
8.在游戏大厅页面发起对战匹配请求(请求通过后应跳转到游戏房间页面)
9.在游戏大厅页面发起停止对战匹配请求
10.游戏房间页面game_room.html的获取请求
11.进入游戏房间后,发起websocket握手的HTTP请求(页面切换,原来的websocket连接会断开)
12.在游戏房间页面发起下棋请求
13.在游戏房间页面发起聊天请求
14.游戏结束,点击返回大厅按钮,游戏大厅页面game_hall.html的获取请求
3.
在上面的业务请求中,前6个请求都是纯http请求,135都是一种静态资源的请求,也就是请求服务器上面的静态网页,而246是动态功能的http请求,是服务器要做相应的业务逻辑处理的,而不是简单的返回一个html网页就好。从7开始向下的请求可以归类为websocket连接上的请求,包括协议切换的http请求和websocket连接上发起的业务请求。
4.
在上面了解了业务请求的分类之后,接下来还需要了解通信接口的设计,例如请求字段中包含什么值,代表这个请求是获取网页资源的请求,又或是对战匹配的请求,又或是动态登录功能的请求,这些字段的含义需要提前客户端和服务器双方约定好,这样在实现的时候,两者都是遵守协议来进行请求报文或响应报文的发送的。这样才能够成功实现双方的正确业务交互。
本项目中服务器和客户端通信接口采用的是Restful风格,Restful风格其实是依托于http协议来实现的,也就是说前6个http请求的格式都是Restful风格的,请求或响应正文采用json/xml的格式来进行组织,请求头和响应头中GET表示获取资源,POST表示新增资源,PUT表示更新资源,DELETE表示删除资源
—下面是通信接口的设计,如果后面在构建服务器的响应和客户端的请求时,忘记字段的值,可以返回这里来看,看看当时定制的协议是什么样子的。
对于服务器上静态资源的请求,http请求行的请求方法都是GET,后面的就是请求路径,也就是web根目录+具体的资源路径,第三个字段就是http协议版本。
服务器收到http请求之后,就会构建http响应,将响应返回给客户端浏览器,响应正文的内容就是根据http请求中解析出来的uri资源。
当浏览器发起用户注册的动态功能请求时,http请求行的请求方法为POST,意味着向服务器新增资源,请求路径则命名为reg,表示进行用户注册的功能请求,请求正文里面则携带一个Json组织的字符串,包括username和password这两个字段。
当服务器收到请求之后,会进行后端的业务处理,比如看看这个用户是否已经存在过了,如果存在过,则请求失败,我们返回一个失败的响应,响应正文也为json组织的字符串,包括result和reason这两个字段,如果数据库中没有这个新增用户的数据,那就说明请求成功,返回成功的响应就可以,响应正文为json组织的字符串,只需要包括resutl这一个字段就可以了。
登录时的请求行和注册时的请求行大致一样,唯一不同的是url,url为login,表示登录动态功能的请求。请求正文与注册时的正文一致。
当请求成功时,只需要返回result为true的一个json格式的字符串即可,当请求失败时,描述好失败的具体原因即可。
获取客户端信息的http的请求方法应该是GET,url为userinfo,表示客户端此时要请求拿到用户的详细数据,当服务器收到响应后,如果该用户存在,那么就从服务器中拿到用户的详细数据,并构建成为一个json格式的字符串,返回给客户端即可,如果该用户不存在,构建失败的响应返回即可。
当进入游戏大厅后获取完用户的详细信息后,紧接着就要和客户端建立websocket长连接,在前端这里,发起长连接的请求其实只要new一个WebSocket对象就可以,后面讲解服务器模块的时候大家就会知道怎么搞了,至于服务器完成websocket的第二次握手这个过程,我们自己不需要做,websocketpp这个库会自动帮我们完成这个工作。
当websocket连接正式建立成功后,服务器这边会自动调用wsopen_callback回调函数,此时在这个回调函数里面我们给客户端返回一个json响应,下面的图为了方便大家看,所以展现的是没有序列化后的json格式数据,在发送的时候,我们只需要将其序列化一下即可,这里重点是为了让大家对请求和响应的各个字段混个眼熟,后面在组织响应和请求的时候,这些字段前后端一定要匹配上,如果不匹配则肯定会发生错误的,比如解析报文后,看不懂报文里面字段的值。
下面的所有请求和响应就全部都是websocket报文格式的了,和http没有关系了,所以我们直接构建json格式的字符串进行发送即可。
开始对战匹配请求的optype为match_start,表示匹配开始,后端这里需要进行两次的回复,第一次是将用户添加到对应的匹配队列里面了,那么此时给客户端返回一个您已成功加入匹配队列之后的响应,如果没有成功加入匹配队列则还需要填充好reason字段,表明未加入匹配队列的原因。
第二次响应是,如果有其他玩家和当前这个玩家匹配成功了,那服务器也要给客户端返回一个包含match_success的optype字段的json格式的响应字符串。
停止对战匹配的请求字段就是match_stop,如果停止成功,则返回true,如果停止失败,则返回false,同时说明原因。
在进入游戏房间后,首先要做的是重新建立websocket长连接,只不过我们稍微更改一下uri,uri为/room。
当游戏房间成功进入之后,服务器要为客户端返回一个房间的详细信息,例如白棋id,黑棋id,包括自身id,因为房间中的两个玩家都会给服务器发起进入游戏房间的请求,self_id就是服务器告诉各个浏览器客户端他们自己的uid是什么。
下棋请求的字段要包括下棋的行和列,以及下棋用户的uid,还有optype为"put_chess"等。
当下棋失败时要组织好响应,表明下棋失败的具体原因是什么。
如果下棋成功,则也要返回原因,原因包括您胜利了,您失败了,胜负未分等,除此之外还需要在请求的所有字段的基础上,再加一个winner字段,winner为0表示胜负未分,winner为哪个uid,则表明该用户获得了胜利。
聊天请求这里,optype为chat,需要包括message是什么,哪个用户发起的聊天请求,用户所在的房间信息是什么等字段信息。
3.2 服务器整体框架的实现 + 前端页面业务请求的框架实现
1.
在服务器代码模块这里,我们要实现两个部分,一个是搭建出服务器gobang_server类对象,让gobang.cc文件中可以直接实例化出服务器对象,然后调用一个run接口让服务器跑起来,另一个部分也就是最繁琐的部分,就是服务器四个回调函数接口的实现,这四个回调函数中处理了来自客户端所有的业务请求,从连接类型来看,业务请求说白了就是http请求和websocket请求,而这四个回调函数对应了http请求和websocket请求的处理函数,所以业务处理其实就是实现这四个回调函数。
但由于业务太多,所以我们要另外封装出许多的接口,每个接口对应一个业务请求的处理。
static_handler负责处理客户端的静态资源请求,register_handler负责处理注册功能请求,login_handler负责处理登录功能请求,info_handler负责处理获取客户端信息的请求,http_callback是总的http请求的回调函数,也就意味着,所以客户端的http请求都会被发送到这个接口内部进行处理,wsopen_game_hall和wsopen_game_room是在wsopen_callback内部进行调用的,也就是websocket连接建立成功后的回调函数,wsclose_game_hall和wsclose_game_room是在wsclose_callback内部进行调用的,也就是websocket连接断开后的回调函数,wsmessage_game_hall和wsmessage_game_room是在wsmessage_callback内部进行调用的,也就是客户端请求的所有的websocket消息都会被发送到这个接口进行处理。
上面这些接口都是服务器内部私有的业务处理接口,对外只公开服务器的构造函数,和使得服务器跑起来的run接口。
2.
在register.html这里,会发起两次http请求,第一次是通过ajax发起注册功能请求,当请求成功后,还会通过location.replace发起第二次登录页面的获取请求
3.
当跳转到登录页面后,在这个地方也会发起两次http请求,第一次也是通过ajax发起登录功能的请求,当登录成功后,会通过window.location.assign发起第二次获取游戏大厅页面的http请求。
js我也没系统的学过,我也只是能看懂这些业务逻辑请求的代码而已,所以给大家细讲这些代码的原理,我其实是讲不了的,我是做后端的,所以重心也不会放在前端这里,只要能把前端这部分的业务逻辑理清楚,目的就达到了。
4.
在游戏大厅这里,主要是四个请求,首先需要发起一次获取用户详细信息的HTTP请求,在获取完用户详细信息并展示到大厅页面后,再发起一次协议切换的HTTP请求,与服务器建立websocket长连接,建立好websocket长连接之后,大厅中则只会有两种请求,一种是开始对战匹配的请求,一种是停止对战匹配的请求,这两种请求都是需要通过点击按钮来完成的,我们也是通过给按钮添加点击事件,当触发按钮之后,向服务器发送对应匹配的websocket请求。
当websocket消息发送给服务器后,服务器会返回websocket响应消息响应类型也分四种,分别是hall_ready代表游戏大厅已经准备好了,match_start代表用户成功被加入到匹配队列里面,match_stop代表用户已经从匹配队列中移除,match_success代表用户对战匹配成功,如果对战匹配成功也要发起一次http请求,用于获取游戏房间的页面,这个请求也是通过location.replace来发起的。
5.
当进入到游戏房间页面后,第一件事就是协议切换请求,因为原来的websocket长连接已经关闭了。
在建立好websocket长连接之后,游戏房间中就只有两类请求了,一种是下棋请求,一种是聊天请求,这两类请求都是以websocket消息发送给服务器的,自然服务器也会返回响应消息,前端需要根据返回的响应消息来进行棋子的描绘以及消息界面消息的展示。
3.3 http_callback
1.
下面是处理四种http请求业务的回调函数http_callback的总调用逻辑。
下面是封装了统一的http响应的函数,如果对http进行响应的话,则直接调用该接口即可。
下面是解析http中cookie字段的函数,通过调用最早之前我们实现的string_util::split函数来实现的,先截取出来包含Cookie的字段,然后在该字段里面截取出来SSID的内容,这个内容就是服务器需要的会话id,服务器为客户端提供任何服务器之前,都会进行会话验证,判断该用户是否已经登录成功,只有登录成功的用户,服务器才会为其提供服务!
(服务器总不能一股脑为所有用户都提供服务吧,这样绝对是不合理的!)
下面是获取用户信息的业务处理接口,
下面是用户登录请求的业务逻辑处理
下面是用户注册的业务逻辑处理
下面是客户端获取服务器上静态web资源的业务处理
3.4 wsopen_callback
下面是通过cookie来拿到会话详细信息的封装接口,后面服务器提供服务时,会常用会话信息中的用户id来进行业务逻辑处理,所以这里做了统一的封装。
下面是游戏大厅长连接建立成功后的业务处理函数
下面是游戏房间长连接建立成功后的回调函数
下面是总的websocket连接建立成功的回调函数
3.5 wsclose_callback
下面是游戏房间和游戏大厅页面关闭时的逻辑处理
3.6 wsmessage_callback
下面是游戏大厅和游戏房间中websocket消息请求的业务逻辑处理