台州微网站建设,网站建设电话话术,摄影网站建设开题报告,中山营销网站建设联系方式想回家过年… 文章目录 一、项目前置知识1. websocketpp库1.1 http1.0/1.1和websocket协议1.2 websocketpp库接口的前置认识1.3 搭建一个http/websocket服务器 2. jsoncpp库3. mysqlclient库 二、 项目设计1. 项目模块划分2. 实用工具类模块2.1 日志宏封装2.2 mysql_util2.3 j…想回家过年… 文章目录 一、项目前置知识1. websocketpp库1.1 http1.0/1.1和websocket协议1.2 websocketpp库接口的前置认识1.3 搭建一个http/websocket服务器 2. jsoncpp库3. mysqlclient库 二、 项目设计1. 项目模块划分2. 实用工具类模块2.1 日志宏封装2.2 mysql_util2.3 json_util2.4 string_util2.5 file_util 3. 数据管理模块3.1 数据管理的设计3.2 user_table类的实现 4. 在线用户管理模块4.1 在线用户管理的设计4.2 online_manager类的实现 5. session管理模块5.1 HTTP的cookiesession机制5.2 websocketpp库中定时器的使用5.3 session的设计与实现5.4 session管理器的设计5.5 session_manager类的实现 一、项目前置知识
1. websocketpp库
1.1 http1.0/1.1和websocket协议
1. a. http协议在Linux的学习部分我们就已经学习过了当时http和https是一块学的我们当时其实已经了解了http的大部分知识内容比如http请求和响应的格式各自的报头字段都有哪些cookie和session机制http1.1的长连接策略keep-alive还有请求方法GET和POST等等知识内容这么看来http感觉已经很优秀了为什么还要有websocket协议呢 b. 其实http有一个致命的缺点就是无法支持服务器向客户端主动推送消息传统的CS通信方式都是一问一答的即客户端向服务器发送一个请求服务器向客户端反馈一个响应而在最传统的http1.0版本协议中客户端每和服务器进行一次通信都需要建立一条TCP连接当浏览器访问了服务器上的某个html网页时此时就会在应用层协议http的基础上建立一条短连接而http短连接其实就是tcp短链接如果浏览器此时想要访问web网页中的其他资源那就需要重新再向服务器发起一次http请求以获取到服务器上的对应资源此时原来的http连接就会自动被断开然后重新建立一条短连接这样的方式非常的难受啊因为用户访问某web资源时肯定不可能只访问一个资源啊他一定会向服务器发起多个http请求获取访问多个web资源那如果在传统的http1.0协议下就会频繁的建立和断开连接这会很浪费服务器的时间和网络带宽因为http短连接其实就是tcp短连接本来tcp是一个可靠的高效的有链接的协议但结果http不会用双方通信一次就关闭掉了这也太浪费了 c. 所以在http1.0之后又推出了http1.1协议也就是在请求报头中添加了一个字段Connection:keep-alive也就是http长连接当上层http连接建立成功后下层的tcp连接不会在一次通信之后就断开了而是会在一段时间之后才断开在这段时间里面双方都可以使用该连接进行资源的请求和获取或者是业务的请求和处理确实是比以前要高效的多了但http1.1依旧还存在一个问题就是他的通信模式还是没有变化的也就是一问一答的通信模式不过他已经比原来的http1.0要高效很多了省去了很多不必要的tcp连接建立和断开也减少浪费带宽。
2. 但在实际的用户需求中一问一答这样的模式是远远无法适用于大多数场景的就拿聊天这样简单的功能来说用户1是无法主动将消息发送给用户2的因为他们俩处于局域网中而局域网中的ip地址是不唯一的所以想要实现通信则必须借助中间的服务器角色用户1将消息发送给服务器想要让服务器将消息发送给用户2但这三台机器应用层都使用的是http协议啊所以服务器无法将消息主动推送给用户2只有说当用户2向服务器发送请求询问服务器现在有没有给我发送的消息啊服务器此时才能将用户1发送的消息以response的方式返回给用户1。 这样能通信吗当然是可以的但他的效率很低因为想要客户端想要拿到别人发给自己的消息就必须不断的轮询服务器看看服务器上有没有发送给我的消息如果有那就获取如果没有那就继续轮询这样的效率非常低因为服务器会将一部分的资源浪费在不断的回复轮询这件事上同时也很浪费网络资源。 所以除了原来的http协议外我们还需要一种能够支持服务器向客户端主动推送消息的协议这对服务器或客户端来说是非常重要的事情 3. websocket协议也是基于http协议的来实现的他是网页端和服务器保持长连接的一种消息推送机制。websocket之间的通信和TCP连接之间的通信非常的相似websocket长连接其实也就是tcp长连接即当客户端和服务器建立websocket长连接之后双方就会一直使用这个连接进行通信除非某一方主动意愿的想要断开连接否则其他大部分正常情况连接都是不会断开的所以websocket和tcp是很相似的。 想要建立websocket长连接其实还是需要借助http协议的只不过在原本的http请求报头中多加了一个额外的字段Upgradewebsocket这样就可以完成websocket协议的切换。 4. 下面是websocket协议切换的示意图需要注意的是客户端想和服务器进行通信包括协议切换的请求或者是任何的请求都需要在三次握手建立连接的前提下进行。 这里说一个知识点三次握手是不允许携带任何应用层数据的严格来说原因其实和防止SYN洪水攻击非常的相似之前谈论SYN洪水攻击是在为什么是三次握手而不是其他次握手这个问题(防止SYN洪水攻击最小成本验证全双工通信信道)上讨论的今天这个问题的原因其实就是害怕一个客户端就把服务器搞崩掉如果在第一次握手中携带大量应用层数据则服务器需要开辟内存将收到的数据保存起来并且需要维护建立好的连接而此时客户端并不认为连接建立成功或者压根就不给你建立连接就疯狂的向服务器发送一次握手并同时携带大量的数据这样就会极大的消耗服务器上的资源最终可能导致服务器宕机而二次握手也是不能携带数据的道理和前面的一样客户端只在第一次握手发送的SYN报文段中加入大量的数据而第二次握手服务器发来的SYN报文段客户端也是可以选择丢弃的这么一来无论是一次握手还是二次握手都是不允许携带数据的但第三次握手其实是可以携带数据的因为此时客户端已经认为连接建立成功了双方的消耗是同等的而服务器的配置又比客户端高所以你单主机想要搞掉服务器是不大可能了。 但实际通信中第三次握手也是不携带数据的等到双方连接都建立成功后此时再携带数据看起来更合理一些不过你要是强行想在第三次握手中携带数据也是可以的只不过实际使用的时候大部分情况不会这么做。
5. 等到三次握手成功之后双方已经建立好TCP连接了此时客户端只要发送一个携带Upgrade:Websocket的http请求即可然后服务器返回一个101响应状态码以及switch protocol的状态码描述再配一个http/1.1组成一个状态行添加上其他的响应报头组织成一个响应报文发送回客户端此时就可以完成websocket协议的切换。 后续CS双方就可以使用websocket长连接进行通信了任何一方都可以主动的给对方推送消息非常的方便 三次握手会携带应用层数据吗
1.2 websocketpp库接口的前置认识
1. 由于本项目使用了http和websocket两种应用层协议而websocketpp这个网络库恰好支持了这两种协议所以我们使用了该库作为本项目的依赖库来实现http/websocket服务器。
2. connection_hdl相当于websocket连接的句柄server是endpoint的子类server也就是我们实例化服务器对象的一个类所以想要搭建服务器必须了解endpoint里面声明了哪些接口timer_ptr是一个定时器对象指针配合set_timer这个接口来使用可以在服务器内部设置定时任务这个接口在我们后面的session模块中会用到connection_ptr是websocket连接的智能指针管理对象后面的各个通信模块都会大量用到这个智能指针connection类就是该对象所属类通常用来进行http响应的回复http请求内容的获取以及websocket消息的推送这个指针对象非常的重要。message_ptr是一个专门用来获取websocket请求消息的指针对象可以通过get_payload获取websocket请求的有效载荷数据。 还有四个指定事件的回调函数当服务器上特定事件被触发时服务器对象会自动调用这四个回调函数而这几个回调函数的内容是由程序员来编写的实现服务器对业务的处理逻辑这四个函数中只有set_http_handler是设置http请求的回调函数其他三个都是用于处理websocket连接上消息的回调函数 3. 下面是connection类的实现从接口对应的协议来划分可以分为两类一类是http一类是websocket只有send一个接口是在websocket连接上发送消息的其他的接口全是和http有关的。 4. 下面的这些都是websocketpp定义的一些日志等级http响应状态码websocket发送数据的类型等日志这块我们到时候写项目的时候会自己实现所以会将日志设置为none表示禁止websocketpp打印所有日志。 至于websocket发送数据的类型我们在写项目的时候也不会做改动直接使用text类型发送json风格的字符串响应。 1.3 搭建一个http/websocket服务器
1. 上面说了那么多肯定没啥用干说咋可能学会呢下面还是通过搭建一个服务器来熟悉websocketpp库中接口的使用吧。
2. 搭建服务器其实可以分为两个部分一个是四种回调函数的实现一个是调用wssvr对象进行服务器的各项功能初始化第二个部分隐含了诸多的linux网络的知识细节例如当服务器宕机后立马重启依旧还可以绑定原来的端口号通过调用set_reuse_addr来实现不使用websocketpp库所提供的日志输出函数则可以通过调用接口set_access_channels()传递一个none来实现。 3. 这里需要着重说一下bind的用法bind有两种用法。 一种是绑死参数这样的用法下bind生成的对象在传参给包装器时是不会影响类型的也就是说你可以使用bind来传递任意的可调用对象给包装器而无需关心包装器的类型是什么bind绑定的可调用对象的类型又是什么你想传什么传什么在这种情况下bind不影响传递参数时参数的类型是什么只影响实际调用时的参数是什么实际调用时候的传参其实就是绑死的参数。 这样的用法比较少见常见于某些API的包装器参数功能无法满足我们的需求我们此时想让这个包装器在调用时按照我们所实现的一个函数去执行那么此时就可以采用绑死参数的方式来使用bind 另一种是预留参数位置等到bind生成的可调用对象被调用时再去传参bind提前用占位符来预留参数的位置。
下面的用例代码就可以很好的说明bind生成的可调用对象的类型是完全适配包装器的不管包装器的类型是什么bind生成的可调用对象都可以传过去并且无论最后你怎么给包装器对象传递参数这都是徒劳的因为bind已经将可调用对象print的参数给绑死了callback实际调用的就是print(“rygttm”)这个函数。
另一种用法就是下面的四个回调函数的设置这几个回调函数未来其实是由服务器自己去调用的而不是我们来调用当服务器收到http请求则服务器就会自动调用我们所实现的http_handler类型的回调函数当服务器收到websocket握手的请求则在握手建立好之后会调用我们所实现的open_handler类型的回调函数其他两个类型也是如此这几个类型都是包装器类型重定义的但在回调函数种我们想用服务器类里面的某些接口来实现简单的业务处理所以我们希望把wssvr对象也传到四个回调函数里面而此时的做法就是通过bind来绑定部分参数其余服务器自己调用时传递的参数我们通过占位符给预留出来让服务器自己去传参我们不操这个心这就是bind的第二个用法。 4. 这四个接口中重点实现http_callback和wsmessage_callback在http_callback里面我们打印一下http请求的几个重要信息然后给客户端返回一个简单的html页面。 值得注意的是http响应的返回和websocket消息的发送所调用的API是不一样的我们只需要通过conn这个连接智能指针管理对象调用set_body设置好响应正文调用append_header设置好响应头部字段调用set_status设置好响应状态码然后服务器就会自动构建一个包括状态行响应报头空行响应正文的完整的http响应信息返回给客户端 而对于websocket消息的发送我们也是通过conn这个智能指针来发送的发送的方式非常的简单只要调用send接口即可第一个参数是要发送的websocket有效载荷数据第二个参数缺省值默认是文本类型我们可以传也可以不传这个参数。resp正文的内容其实就是客户端发送的消息我们服务器这里做一个消息的回显回显给客户端同时也把消息打印到服务器上看看消息内容是什么发送websocket数据可以看到调用的正好也是send接口。 5. 我们自己写完服务器的四个回调函数的逻辑之后接下来的三个接口应该是不陌生的其实就是监听端口号看是否有客户端向我们服务器绑定的端口号发起了连接请求如果有那就将三次握手后的连接加入到内核监听队列中这个监听队列的长度一般是5我们也可以自己设置。 服务器调用start_accept就是将内核监听队列中已经完成三次握手的连接拿上来通过这个连接服务器就可以和客户端进行通信了所以三次握手的过程和accept系统调用没有任何关系三次握手的过程是在listen过程中进行的。 最后只要调用websocketpp库中的server类中的接口run就可以将服务器运行起来了到此就完成了wsserver.cc代码的编写。 6. 光实现一个服务端肯定还是不行的http客户端我们可以不用实现直接使用浏览器向服务器发起http请求就可以解决但websocket客户端必须由我们来实现了我们需要自己编写一个wsclient.html的前端页面来充当客户端通过在浏览器打开这个页面来向服务器发起websocket连接建立的请求。 实现客户端主要也是分为两个部分先通过new WebSocket向指定服务器发起websocket连接握手当服务器收到连接请求后服务器会返回一个握手代表双方websocket长连接建立成功前端这边会有一个连接的句柄也就是let定义的ws_hdl通过这个句柄来实现客户端和服务器的websocket通讯类似于服务器的四个回调函数前端这里也有ws_hdl被创建成功后的四个回调函数在onmessage回调函数的参数中是有一个事件evt参数的这个evt保存的是服务器返回的一个普通字符串通过.data的方式就可以访问到里面的内容了如果服务器返回的是json序列化之后的字符串则我们需要先对e.data做json格式的解析然后才能访问到里面的内容但今天我们只是搭建一个样例服务器所以就不搞序列化反序列化那一套了能够实现双方的通信就可以了。 前端这里实现了一个输入框和一个提交按钮我们同时为这个提交按钮添加了一个点击事件用于向服务器发送输入框中用户输入的消息内容服务器会将我们发送的消息重新作为响应返回到前端这里前端的onmessage收到响应事件后会将消息内容通过console.log打印到开发者工具的控制台上我们到时候通过fnf12打开控制台就可以看到这些日志消息了。前端这边除了将消息以日志方式打印出来还做了另一步操作其实就是将输入框中的消息内容清空通过id来获取输入框然后将里面的值置为空串即可。 6. 在观察实验现象前需要说明一点我们今天所实现的前端页面虽然确实是在linux机器上但他不在wsserver里面因为我们没有在里面搞一个web根目录将前端页面放到web根目录中所以想要在浏览器中打开前端页面只能先将html文件放到win机器本地上然后通过打开浏览器来访问websocket服务器以此来实现客户端和服务器通信。 不过不用担心后面实现项目的时候我们会将前端资源放到web根目录下浏览器直接请求服务器上的web资源即可而无需以本地打开html文件的方式来与服务器进行通讯。
通过下面的CS通信可以看到服务器和客户端成功以websocket连接的方式实现了通讯这个前后端通信做的确实比较简陋等后面实现项目的时候CS之间的交互会变得很多到时候就可以更熟练的使用websocket进行通讯了。 2. jsoncpp库
1. 在网络通信中由于传输的数据往往是一个较大的集合这个集合中会容纳多种不同类型的数据所以通信双方往往要对发送的数据做整合封装和拆解这两个步骤用专业一点的词汇来描述就是序列化和反序列化双方使用同一种方案来进行序列化和反序列化保证能够对数据进行合理正确的解析以及对数据打包发送。 这样的序列化和反序列化方案其实我们可以自己做但一般我们不自己写因为应用层已经有大佬帮我们写好了常用的例如xmljsonprotobuf等等本项目中用到的就是json这样的序列化方案。
2. json这个类重载了和[ ]操作这使得构造一个包含多种数据类型的json对象变得非常的方便使用json对象时只需要通过[ ]来使用即可可以传递一个数组的下标一个字符串等等。 StreamWriterBuilder这个类其实就是一个工厂类通过这个工厂类能够生产出一个StreamWriter对象通过这个StreamWriter对象我们就能够进行json格式的序列化。 json在进行序列化时所调用的接口write即将一个json对象序列化为一个json格式的字符串然后这个字符串会被放到输出流对象sout里面我们一般传递的都是stringstream的对象这个stringstream对象内部有一个str()接口通过这个接口我们就可以拿到string类型的可以发送到网络中的字符串了。 CharReaderBuilder也是一个工厂类通过这个工厂类能够生产出一个CharReader对象通过这个CharReader对象就能够进行json格式字符串的反序列化。 json在反序列化时是通过parse接口来将json格式的字符串解析反序列化到Value 类型的root对象中只不过我们需要传入这个json格式的字符串的起始地址和末尾地址。 3. 下面是一个json序列化的案例代码帮助我们进行基本的序列化实现首先定义一个Json::Value对象root然后通过和[ ]运算符向root里面填充需要发送到对端的字段比如添加const string类型的字符串int类型的整数向root中添加一个浮点数数组数组的添加我们不能使用运算符需要借助Json::Value类里面的append接口来实现不断的调用append接口即向数组中不断的添加元素。 真正进行序列化时我们需要先生产一个StreamWriter对象然后调用write接口将root和ss两个对象传递进去调用成功后ss里面的str()就会返回一个string字符串这个字符串就可以直接发送到网络里面通过网络传输到对端主机别忘了释放掉sw这个StreamWriter对象因为这个对象的内存是动态开辟出来的用完了要记得还给操作系统否则会造成内存泄漏。 其实在上面的序列化代码里面隐含了一部分C的语法知识那就是单参数构造从库文件里面我们可以看到他只重载了一些基本类型到Json::Value类型的构造函数为什么上面的代码中能够可以讲18这个整形直接赋值给root呢其实就是因为库里面实现了下面的这些单参数构造函数所谓的赋值可以细分为先通过参数构造出一个value对象然后拿着这个对象来进行赋值给root对象这个赋值的接口是库里面实现了的Value operator(const Value other);构造出来的临时对象刚好是一个常对象正好可以传递。 4. 下面是反序列化的过程首先实例化一个工厂类对象通过这个对象生产出一个CharReader对象然后调用parse接口进行json格式字符串的反序列化解析的过程可能会发生错误(90%的正常情况下不会发生错误)所以可以传一个输出型参数err解析成功之后root对象就是原生的发送方想要发送给我们的内容了。 我们可以通过[“xxx”]来拿到对应的value对象但需要注意的是如果想要拿到里面的值我们还需要做一步类型转换因为json的[ ]重载函数返回的是jsonvalue对象而不是我们想要的内置类型所以还需要进行asIntasCStringasFloat等接口的帮助我们才能访问到里面具体的值。 与序列化相同的是最后别忘记释放动态开辟的内存否则会造成内存泄露。 5. 调用我们实现的上面两个函数之后从打印结果可以看出jsonvalue对象其实是一个{}构成的具有特定格式的一种对象比如添加了换行符制表符包含的内容采用了key : value的形式进行组织对于数组类型的数据value采用了[, , ,]的格式进行组织。 我们反序列化上面json格式的字符串之后打印内容就是简单的逐行打印。 3. mysqlclient库
1. 由于本项目使用的是mysql数据库来存储玩家信息所以在项目前置知识这里我们还需要了解如何通过C风格的API接口来操纵数据库。 1 首先需要初始化一个mysql的句柄这个句柄是很常用的一个概念像文件描述符套接字文件指针这些都可以称之为句柄你可以把他理解为一个魔法棒的存在(没办法这个太不好描述了)我们想要做某件事不能直接去做而是需要借助句柄去做比如你网络通信双方能直接通信吗难道都用嗓子喊一声这肯定是不行的在代码层面上我们就是通过socket套接字来完成通信的比如对文件进行读取写入等操作你是直接对硬盘上的某个文件操作吗其实不是的我们是要在代码层面上通过文件指针来完成这样的操作的mysql也是一样的在代码层面上我们需要一个指针对象通过这个指针对象来对数据库进行增删查改这个指针对象就是mysqlclient库里面定义出来的MYSQL类型后续所有的对数据库的操作都是通过这个类型的指针来完成的。 2 初始化好句柄之后下一步就是连接数据库这个句柄一定是要有操作对象的没有操作对象还玩什么啊在数据库这里句柄的操作对象就是database在文件中操作对象就是文件在网络通讯中操作对象就是socket连接在今天的websocket协议通讯中操作对象那就是websocket连接道理是类似的。连接数据库需要指定mysql所在的主机ip地址mysqld服务的端口号database的名字登录数据库服务的用户名以及密码等mysql这样的服务为了保证安全性是不允许用户跨网络远程登录的必须要求在本地进行登录所以ip地址就是我的云服务器本身的ip地址那就是本地环回地址至于端口号这个我们可以自己在mysql的配置文件中设置如果没有设置过的话则默认就是3306端口。当然连接也有失败的可能在编写项目类的代码时日志输出错误信息是非常重要的一种调试手段所以编写用例代码我们也延续这样良好的代码风格做好差错处理因为mysql_real_connect接口是有可能调用失败的当失败时我们要在服务器上输出错误信息确保后期好定位代码中的错误。 3 连接数据库成功之后下一步就是设置字符集客户端和mysqld服务端要保证字符集是一致的否则我们编写的sql语句都有可能被服务端识别错误导致sql语句无法正常执行服务端默认的编码格式是utf8的所以我们设置客户端的编码格式也是utf8保证双方是一致的编码格式 4 选择要操作的数据库这个接口其实是比较鸡肋的因为操作数据库的信息我们早在调用mysql_real_connect时就填充好了所以这个接口我们就不调用了什么都不做 2. 5 接下来就是让数据库执行对应的sql语句了sql语句共4类只有select的执行逻辑是不一样的因为select需要把数据库中查询显示到的信息展示在我们的终端上而其他的更新删除插入语句是不需要回显的执行成功就是成功了 6 针对select语句MySQL也提供了对应的API例如mysql_store_result就是用来保存select语句查询结果的我们需要自己定义一个MYSQL_RES类型的指针用来指向堆上mysql_store_result帮我们开辟好的一块内存这块内存就是查询结果。 mysql_num_rows用来获取查询结果中的条数mysql_num_fields用来获取查询结果中的列数因为MySQL的存储格式是行列式的所以就需要这两个接口来获取行数和列数。 在拥有res查询结果和结果集的行数和列数之后我们就可以遍历结果集将select查询结果显示到代码终端上了mysql_fetch_row是一个返回数组的接口你把res传给他他会依次逐行返回每行的结果每行的结果就相当于一个char**的数组mysql_fetch_row会给我们返回这个数组的首地址通过这个首地址下标索引就可以拿到每行中所有的列字段值了。 7 上面sql语句的执行完毕之后如果有查询语句的话千万不要忘记释放结果集因为res这个指针指向的内存是mysql_store_result帮我们动态开辟出来的所以一定要调用mysql_free_result来释放结果集。最后我们也要释放句柄因为这个句柄管理的内存也是mysql_init帮我们动态开辟出来的如果不释放则会内存泄露。 二、 项目设计
1. 项目模块划分
1. 项目总体其实可以划分为三个模块一个是数据管理模块也就是进行用户信息的注册存储用户的对战信息等等例如用户名密码总战斗场次胜利场次天梯分数等等信息都是靠数据管理模块来维护的。 另一个是前端页面模块这个模块也是很重要的当前端页面被浏览器获取并运行起来时他就是用户直接接触的一个模块用户在页面里进行的所有操作其实都是一个业务请求这些业务请求都会被发送到服务器上由服务器来对这些请求进行业务逻辑处理客户端可能产生的业务请求有register.html页面的获取获取好页面后用户会输入自己的用户名和密码然后点击提交按钮进行用户的注册点击按钮之后注册的请求就会被发送给服务器服务器会通过数据管理模块来判断这个用户名是否已经存在如果存在则说明注册请求失败服务器返回一个失败的响应如果注册成功则服务器返回一个login.html用户面前就是展示成登录的页面了此时用户就又可以输入用户名密码点击提交按钮进行登录当登录的请求被发送到服务器后服务器会检验用户是否存在如果存在则判断用户名和密码是否正确如果正确说明登录成功此时应该向用户展示游戏大厅game_hall.html的页面进入游戏大厅后客户端还要与服务器建立长连接进行对战匹配的请求如果对战匹配成功则还要跳转到游戏房间页面在游戏房间中还要有下棋聊天等业务请求… 最后一个模块就是项目的主体也就是业务处理模块通过上面的前端模块的分析大概得有10多个业务请求吧所以我们的服务器除了要能和客户端进行通信以外还要能够正确处理这些请求这些处理的逻辑我们统称为业务处理模块。 下面是玩家用户玩游戏的整个逻辑流程图值得注意的是当页面切换时浏览器会主动将原来的websocket连接断开以此来确保资源的释放和网络连接的正常关闭所以当页面从游戏大厅跳转到游戏房间时需要重新建立websocket连接因为原来的连接已经断开了。
2. 但由于业务处理模块非常的繁杂所以业务处理模块我们还要进行细分细分到每个子模块功能的具体实现。 总共包括六个模块的实现数据管理session管理在线用户管理匹配队列管理游戏房间管理最后封装实现服务器模块。每个管理模块实现的原因以及其中的细节我们都放到每个模块中进行讲解这里先预热一下知道项目大概都实现了什么。 2. 实用工具类模块
2.1 日志宏封装
1. 由于在实现项目的时候如果某些接口调用或者逻辑有问题总是会进行日志打印以此来帮助我们进行代码的调试来定位错误所以为了方便后面进行日志的输出我们这里封装一个日志宏通过宏函数来进行调试信息或错误信息的打印。
2. time是一个用于获取时间戳的一个函数即从1970年1月1日到现在过了多少秒然后返回一个time_t类型的对象。 3. localtime函数用于将time_t类型的对象转换成一个结构体类型struct tm在这个结构体内部包含了许多的时间字段信息例如秒分时天月年 4. strftime函数用于将struct tm类型的对象指针进行格式化输出将格式化后的内容放到s缓冲区里面。格式化的形式有很多我们就使用%HMS就可以了分别代表当前的时分秒。
5. 所以通过上面三个函数我们就可以将当前的时间信息输出到一个char buffer缓冲区里面但日志信息光有时间还是不够的还要有输出的内容而C99恰好引入了新特性允许宏中定义可变参数也就是. . .(点点点)代表可变参数所以一个宏函数的实现只需要两个参数就可以了一个是format代表格式化的字符串另一个是. . . 代表格式化的字符串中等待传递的参数。 最后在调用fprintf将格式化后的字符串输出到显示器文件上也就是打印到屏幕终端上在fprintf的第二个参数中可以看到我们好像写了三个字符串啊以前我们使用printf的时候好像只用到了一个字符串啊这样符合语法吗其实是没问题的在ANSI C标准中规定在可变参数中如果两个常量字符串之间没有逗号隔开的话则这几个常量字符串会自动连接。第一个字符串中的第一个参数其实就是格式化输出到buffer里面的时间信息包含时分秒第二个参数是预定义出来的宏__FILE__表示日志输出所在的文件第三个参数是__LINE__表示是文件中的第几行输出的内容format是调用LOG时调用者进行的可变参数的控制对应传递的参数会传给. . . 我们用__VA_ARGS__就可以接收外部调用传进来的可变参数。 为什么要加一个##呢主要是因为调用的时候又可能只是简单打印一串消息而已不会传可变参数进来那么此时__VA_ARGS__就是未定义的调用fprintf就会出错而##的作用就是让__VA_ARGS__和前面的__LINE__宏参数合并当调用者不传可变参数的时候LOG宏函数此时也不会出错因为相当于没有__VA_ARGS__这个参数。 6. 但是光有上面的宏函数还差点意思日志宏应该还要有日志等级的分类例如normal debug error这样的等级所以我们可以预定义出来一个默认的日志等级表示只输出当前等级往上的所有等级的日志消息只需要在原来的LOG里面多加一个level参数然后在实现中多加一个if逻辑条件判断即可。 那每次调用LOG的时候我们都需要自己去传一个日志等级这样用起来感觉还是不方便所以我们在对LOG做一层封装封装出三个不同日志等级的宏函数分别为NLOGDLOGELOG这样使用起来就比较方便了。 2.2 mysql_util
1. 在mysql_util这个类里面封装实现了静态方法mysql_create用于创建并初始化mysql句柄以及设置好客户端的字符集等工作。 2. mysql_exec用于执行mysql语句但这个接口的封装实现只能执行插入更新和删除语句因为只有这三个语句的执行逻辑是一样的他们执行成功后不用做任何额外的操作但查询语句却需要执行额外的操作所以封装实现时我们只封装mysql_query这一个接口如果调用者想要执行查询语句则可以使用我们封装的接口如果想要将查询的结果输出显示到自己的终端则需要自己去实现保存结果集遍历结果集释放结果集等一系列操作。 mysql_destroy用于释放销毁mysql句柄。
2.3 json_util
1. 在json_util这里封装实现序列化和反序列化的静态方法即可在序列化接口里面需要外部传入一个root对象和一个str对象在内部我们会将root中的json格式的数据组织成为一个string对象然后将这个对象赋值给str输出型参数外部就可以拿到序列化后的字符串str了。 在内部实现中我们不在使用普通的指针来管理StreamWriter对象而是使用智能指针unique_ptr来管理这样就不需要我们在手动释放内存了当智能指针销毁时就会自动释放动态申请的内存。 在反序列化这里也是需要外部传入一个json格式的字符串str然后内部将str做反序列化将反序列化后的json格式的value对象赋值给输出型参数root中外部就可以拿到反序列化后的value对象了。 与序列化相同的是我们不在使用普通指针管理CharReader对象也是采用智能指针unique_ptr来进行管理道理相同。 2.4 string_util
1. 由于后面在封装实现服务器的时候每次客户端的请求我们都需要做会话的验证而会话的验证离不开http请求头部字段Cookie: 我们需要获取到cookie中的ssid字段所以要对请求头部中特点的字段作解析拿到特定的值所以在实用工具类这里在实现一个split函数用于进行字符串的解析获取。 下面是http请求头部中Cookie字段的格式内容是以namevalue的形式呈现多个值之间用分号空格来区分开所以如果想要拿到ssid的值则必须进行字符串解析。 cookie中的值是服务器让客户端设置什么cookie里面就携带什么的比如客户端和服务器建立http连接进行登录登录成功后服务器会为该用户建立一个session这个session对应的唯一标识符ssid就是服务器返回的响应头部字段Set-Cookie中设置的当客户端收到http响应后后续客户端所有的请求字段中都会携带Cookie字段无论是websocket请求还是http请求都会携带所以服务器必须保证能够获取请求头部字段中的Cookie字段那么就一定要有能够根据特定分隔符解析字符串的能力 2. 上面说的其实是有瑕疵的比如我说后续客户端所有的请求字段中都会携带Cookie字段无论是http还是websocket请求其实对于websocket请求来说他的头部字段中是压根没有Cookie字段的因为http和websocket的报文格式是不一致的怎么可能有Cookie字段但为什么还能获取到呢 其实是因为在第一次协议切换请求后websocketpp库会将请求中携带的Cookie信息保存下来将保存后的信息设置到connection这个类里面我们调用connection类中的get_request_header来拿到Cookie字段的值时依旧是可以拿到的所以后续即使是websocket请求服务端也能够通过get_request_header来拿到cookie信息因为在第一次协议切换的http请求中websocketpp库已经将cookie信息替我们保存起来了供我们后续调用API来获得这个cookie信息。
3. 在split实现这里需要传入的参数有三个一个是需要解析的字符串src一个是解析时的分隔符sep一个是解析后的内容存放到输出型参数res字符串数组中。 解析的方式也很简单我们定义两个变量pos和idxidx表示下一个分隔符的位置pos表示当前位置搞一个while循环只要idx的值没超过string::npos那就一直向后查找查找到分隔符后判断分隔符的位置是否和pos的位置相同如果相同那就说明pos位置本身就是分隔符那么pos位置就应该向后挪动1位下次从新的pos位置开始查找如果不同那就直接调用substr进行子串的截取将截取后的子串放到res里面然后直到整个字符串遍历完毕后循环结束res中保存的就是以sep为分隔符将src进行截取截取出来的子串内容了。
2.5 file_util
1. 由于后续项目实现时客户端会频繁请求获取服务器上的web前端资源所以服务器需要在http_callback部分实现能够将前端页面发送回客户端的功能而这一功能的实现就少不了文件读取服务器需要将文件内容读取到一个string中然后服务器调用set_body这样的函数将string内容设置为响应正文发送回客户端此时客户端就会显示出来一个前端网页了。 所以文件读取的功能我们也要在使用工具类模块中实现一下未来在处理前端请求web资源的业务时可以直接调用read接口将linux机器上实现的前端html页面能够返回给浏览器客户端。
2. read接口需要外部传入两个参数一个是输入型参数文件名一个是输出型参数body读取文件后文件的内容会被放到body里面外部服务器在获取到文件内容后就会将文件内容返回给浏览器客户端。C操作文件的方式其实就是定义一个ifstream或ofstream对象读取文件是in写入文件是out我们这里就定义一个ifstream的对象以二进制和读取的方式来打开文件。 我们需要获取一下文件的大小这样以便于提前resize开辟好body的空间大小然后将读取出来的文件内容放到body里面。获取文件的大小也是有技巧可言的常见的一种方式就是先调用seekg将文件读取位置移动到文件末尾处然后调用tellg拿到当前的位置大小拿到的这个位置大小其实正好就是该文件的大小获取完文件大小后不要再将文件读取位置调整到开始。 有了文件大小之后我们直接调用body.resize(filesize)进行body的扩容然后调用ifs.read()将文件的内容以二进制的形式存储到body里面最后记得将文件关闭即可其实关闭的这一步我们不搞也行因为ifs对象销毁的时候会自动关闭文件(这话可不是我说的是C primer说的)如果你比较保守的话不放心的话也可以自己去手动调用close来关闭文件。 3. 数据管理模块
3.1 数据管理的设计
1. 数据管理这里的设计分为两个部分一个是数据库中user表结构的设计一个是项目代码中user_table类的设计。用户信息表这里共创建6个字段分别是用户的唯一标识也就是user_id还有usernamepassword用户的天梯分数后续我们会根据天梯分数的不同来判断用户的游戏等级例如1000 ~ 2000是青铜2000 ~ 3000是白银3000 ~ 4000是黄金用户在匹配对战时只能匹配到和自己游戏等级相同的玩家还包括total_count总战斗场次win_count胜利场次。 当用户进入到游戏大厅页面时我们要展示出用户的名称天梯分数总战斗场次胜利场次等详细信息。 2. 我们需要自己设计一个user_table类这个类的主要功能是完成浏览器在向服务器发起的诸多请求中涉及到访问数据库的操作我们将这些操作接口全部封装起来方便后面服务器模块进行调用。 类成员变量是比较简单的因为我们要访问数据库嘛那肯定需要一个MySQL句柄除此之外其实我们还需要一把互斥锁因为websocketpp这个库是多线程实现的我们项目中的各个接口都有可能会在多线程的情况下被调用所以只要涉及到共享资源的访问或者是其他的线程安全问题我们都需要一把锁来进行保护。 有人可能会说人家mysql提供的各个接口本身就是线程安全的啊你搞个互斥锁有什么意义呢其实不然当我们调用mysql_query执行sql语句时mysql_query本身确实是线程安全的如果执行的是增删改这样的sql语句也不会出现线程安全问题但如果是查询语句此时就出现线程安全的问题了。 在查询语句执行后我们是需要调用其他的API来进行结果集的保存遍历释放等操作在执行mysql_store_result之前上一条在数据库中执行的语句必须是select才行但在多线程的情况下你能保证执行完select语句后下一条语句执行的一定是mysql_store_result吗我们的接口是可能会被多个线程调用的啊有可能此时某个用户在注册那执行的就是插入语句但也有可能其他用户在登录那执行的就是查询语句所以你能保证select执行之后下一个执行的API是mysql_store_result吗当然是无法保证的 每个API各自确确实实是一个原子操作是线程安全的但我们现在的需求是希望在查询语句结束后下一个执行的MySQL API一定要是mysql_store_result因为mysql句柄是只有一份的mysql句柄是共享资源多个线程都会访问mysql句柄我们希望mysql_query执行select语句 mysql_store_result这两个操作合起来是一个原子操作如何做到呢那就只能通过加锁来实现所以user_table类的成员变量除mysql句柄外还需要一把互斥锁。 你试想一下如果不加锁A线程拿着句柄在执行select语句执行完select语句后B线程此时想要执行insert语句B线程抢过来这个句柄进行insert语句的执行因为mysql_query是线程安全的所以在执行期间是不会有其他线程来打扰他的A线程执行select语句时也是同样如此现在B线程执行完了A线程又拿着这个句柄执行mysql_store_result了此时mysqld服务直接报错MySQL数据库懵逼了你上一条语句执行的是insert语句啊你现在要让我执行mysql_store_result我给你保存个毛啊你上条语句执行的又不是select此时mysqld服务直接就报错了。 因为mysql句柄是共享资源所以A线程拿到进入API执行流程中那此刻其他线程不能执行任何的API因为mysql API是线程安全的如果是B线程拿到那也是同样如此如果是CDE等线程也是这样的你们随便拿不要紧重要的是查询语句和mysql_store_result合在一起得是原子操作啊否则这就是有问题的啊
3. 需要我们实现的接口有构造析构涉及到用户动态请求功能的处理接口有insert它可以帮助我们向数据库中新增用户的注册信息login负责对登录的用户进行验证看看数据库中是否存在该用户如果存在则比对用户输入的密码是否正确如果正确则说明登录成功同时login会以输出型参数的方式来将数据库中获取到的用户详细信息返回给user变量里面为什么要有这一步呢主要是用户登录成功请求发起发起的请求中会携带用户的username和password这样的信息这些信息是要作为输入型参数来告知login的同时当服务器处理完登录请求后外部其实是要为用户创建session的而创建session需要uid来进行创建所以这里的user就作为了输入输出型参数来使用给外部返回一个用户的详细信息外部想知道哪个信息字段值只要使用json提供的[ ]重载即可使用。除此之外还可以实现一些其他的辅助接口例如通过用户名来获取用户的详细信息通过用户id来获取用户的详细信息因为后面在用户大厅展示用户信息时我们是需要通过user_table类提供的API来获取到用户信息并展示的。此外在实现两个接口id对应的某个用户胜利时要在数据库中更新用户的信息比如total_countwin_countscore30当然也少不了用户失败时的信息更新所以再加一个loseAPI。 3.2 user_table类的实现
1. 构造函数其实就是调用我们上面mysql_util里面实现的多个静态方法调用mysql_create进行句柄的创建析构函数中进行句柄的销毁。
2. 在注册信息这里我们首先要判断输入型参数user中用户信息的完整性只有有一个不完整则注册信息失败如果全部完整我们则编写sql语句进行用户信息的注册sql语句需要sprintf进行格式组织将输入型参数中的username和password字段拿到并格式化到sql语句中最后调用工具类中的mysql_exec执行语句即可。 3. 在登录验证这里其实要做的就是将数据库中对应的信息取出来同时进行密码的校验所以我们直接根据输入型参数user中的用户名和密码字段组织出具有筛选条件的查询语句在进行查询时如果能够在数据库中找到对应的用户信息则我们需要将结果保存到本地所以查询和保存结果这两步必须是一个原子操作那我们就进行RAII风格的加锁控制。 在获取到查询结果集的行数之后我们还需要进行校验如果rowNum大于1则说明用户信息不唯一如果小于1则说明用户信息不存在只有等于1的时候才是符合预期的其实这里的校验也算是稳一手的操作99%的概率这里是不可能出错的。然后通过调用mysql_fetch_row遍历结果集将数据库中的信息拿出来把每个字段填充到user这个输入输出型参数当中最后释放一下结果集就行。 4. 通过用户名来获取用户详细信息的逻辑和上面一模一样唯一不同的就是sql语句的筛选条件改动了而已这里也就不再赘述了。 道理相同仅仅是改变了一下select的筛选条件这里也不在赘述 5. win和lose在实现时其实就是进行数据库信息的更新编写update语句即可然后调用工具类中的mysql_exec执行就完成函数的编写了。
4. 在线用户管理模块
4.1 在线用户管理的设计
1. 由于后期我们会通过用户id来获取到用户对应的websocket连接只有获取到连接之后服务器才能通过连接将自己对于业务的处理结果发送给客户端比如说在后面的游戏房间实现中双方下棋时如果有一方胜利那么此时就应该将谁胜利的消息广播给房间中的双方玩家然后前端页面会进行检测看看服务器发送回来的消息中胜利者是不是我自己如果是我自己那就应该在页面上显示我胜利了如果不是我那就应该显示我失败了所以必须实现一个能够通过用户id来获取用户对应的websocket连接的API这个API就是在线用户管理模块也就是online_manager类中实现的。 在该类里面不仅要有获取游戏大厅用户长连接的API还应该有获取游戏房间用户长连接的API因为我们知道房间和大厅是两个不同的页面使用的长连接也是不同的所以获取这两个长连接的API也是不同的两者是解耦的。
2. 除了上面获取连接的API之外在线用户管理还具有判断一个用户此时是否在线的功能因为用户有可能玩的玩的不想玩了直接关闭前端页面那么后续服务器在进行相关业务处理时就应该进行用户是否在线的判断如果不在线那么服务器就不提供相应的服务如果在线则继续进行业务处理。
3. 为了进行上述功能的实现online_manager需要两个哈希表来分别构建用户id和用户对应的websocket通信连接之间的映射关系由于哈希表是共享资源我们要对哈希表进行插入和删除所以也需要一把互斥锁来保证共享资源访问的安全性。 需要实现的API有当websocket连接建立成功时将用户加入到游戏大厅/游戏房间在线用户管理中当websocket连接断开时将用户从游戏大厅/游戏房间在线用户管理中移除判断当前用户是否还在游戏大厅/游戏房间中通过uid来获取用户在游戏大厅/游戏房间中的长连接。 由于connection_ptr这个类型是websocketpp库里面的server类中定义的所以我们提前typedef了一下这个server类这样使用起来会比较方便 4.2 online_manager类的实现
1. 当服务器与客户端建立好websocket长连接之后那就需要将用户添加到在线用户管理模块中而所谓的加入游戏大厅或房间的在线用户管理其实就是将uid和对应的conn连接构造成键值对插入到_hall_online_user或_room_online_user哈希表中需要多说一嘴的是插入键值对到哈希表中是需要加锁控制的因为哈希表是共享资源在多线程同时访问下如果不加锁控制可能会出现线程安全问题。
2. 当服务器和客户端websocket长连接断开的时候就需要从在线用户管理中将用户进行移除而所谓的移除其实就是从哈希表中找到特定的键值对然后将键值对删除就可以了。同样的由于涉及到对共享资源的访问我们也需要进行加锁控制。 3. 判断用户是否在在线用户管理中其实就是判断uid对应的迭代器是否存在我们直接调用find查找uid对应的迭代器如果迭代器不为end()那就说明当前用户确实在在线用户管理中。同样的访问共享资源需要进行加锁控制。 4. 只要用户在在线用户管理中那我们就可以通过迭代器的方式找到uid对应的connection_ptr然后进行返回即可如果找不到那我们就返回一个空的connection_ptr对象。
5. session管理模块
5.1 HTTP的cookiesession机制
1. 在web开发里面http是一种无状态短连接的通信协议也就是说当客户端和服务器建立了一次http连接完成通信后http连接就会断开下次客户端想要访问服务器的其他web资源时服务器是不知道你这个客户端是谁的服务器不知道你是谁也不知道你现在登没登录那服务器此时给客户端提供服务就是不合理的因为http是无状态的啊他不会保存任何客户端的信息但用户有这样的需求啊比如你现在在B站的网页端你提交用户名和密码进行登录后跳转到B站的主页面你的登录请求是http的如果B站的服务器不报存你的任何信息那当你跳转到B站的主页面的时候B站的服务器不认识你啊为啥要给你提供展示视频等服务呢还有一个例子假设你现在已经登录好了正访问B站的视频呢然后你不小心把网页关闭了当你重新打开时你希望B站的服务器认识你吗你当然希望啊如果他不认识你你打开B站页面后又得重新输入用户名和密码进行登录验证你觉得这样烦不烦啊每次新打开页面我都需要输入用户名和密码烦都烦死了。 所以即使http是无状态的但用户需要他是有状态的那么服务器就会为每个用户浏览器都在后端中创建一个session会话对象默认状态下一个浏览器独占后端服务器的一个session不会出现你在一个浏览器中打开了多个标签页访问web资源那么服务器就会为该网页对应创建多个session的这种情况用来保存用户的状态信息比如用户的uid用户是登录状态还是未登录状态让服务器能够具有识别当前用户是谁的能力
2. 那服务器如何通过session校验当前客户端的状态呢其实除了后端session的创建之外还需要一个cookie信息当客户端访问服务器进行第一次登录后服务器此时就会为客户端创建一个session然后服务器会给客户端返回一个http响应响应头部字段中会有一个Set-Cookie字段后面的值表示的就是服务器让客户端以后发送请求时在他自己的http请求头部都设置一个Cookie字段里面的值就是服务器的Set-Cookie设置的值
http响应的Set-Cookie头部字段 http请求的Cookie头部字段 3. 但Set-Cookie的值应该设置成什么呢如果向下面的图中所示设置的消息内容如果就直接是用户的状态信息的话那么浏览器本地就需要保存一份包含用户状态信息的cookie文件在后面的所有请求中都去携带上用户的登录状态信息这样确实可以保证服务器能够识别客户端但安全性太低因为cookie文件可能会被不法者盗取和篡改不法者可能会冒充客户端向服务器发起请求同时这也会对用户产生无法预料的影响因为用户的信息可以被任意篡改和盗取通过cookie文件就可以拿到。 所以下面这样的方式是不够合理和安全的。 4. 此时就有大佬提出了解决方案在cookie的基础上引入session形成cookiesession机制。即服务器来保存用户的详细状态信息而不是客户端来保存服务器为每一个已经登录的用户创建一个唯一对应的session每一个会话都有自己的会话标识符也就是会话id服务器返回的Set-Cookie字段中不再是用户的详细信息了而是会话id客户端收到响应后会将ssid保存在自己本地的cookie文件中后续每次请求服务器的头部字段都会有Cookie信息服务器只需要拿着请求中的ssid值在本地的session管理模块中找一下看看是否存在对应的session如果存在则看一下用户此时的状态是什么如果是合法的状态那么服务器就会返回一个登录成功的响应信息客户端页面就会发生跳转。 此时不法者就无法盗取到用户的状态信息了因为用户发送的cookie信息中只有一个ssid啊你要ssid有啥用啊用户信息泄露的问题就大大改善了但如果不法者冒充用户向服务器发起请求这个问题是cookiesession解决不了的此时需要配合其他策略来进行解决例如白名单防火墙异地登陆警告等等策略。况且这个问题也不应该由cookie和session机制来解决这是你网络安全需要解决的问题我就是个识别客户端的机制让我解决这种问题干嘛啊。 5.2 websocketpp库中定时器的使用
1. 了解了cookie和session机制之后我们先不急着实现服务器的session模块我们需要首先熟悉一下定时器的使用这是很关键的因为session的销毁其实就是一个定时任务。如果你登陆过后不进行任何的操作session会一直永久保存在服务器吗当然不会如果永久保存不销毁的话随着登录的用户过多那总有一天服务器扛不住可能就宕机了所以session一定是有创建有销毁的当你关闭页面之后session难道也要一直存在吗也是不会的session可能在你关闭页面后会被保存一段时间在一段时间之后session就会定时销毁了。 需要注意的是在某些安全要求高的使用场景下如果30s内无操作则session会自动被销毁迫使用户重新进行登录还有一种情况是为了安全性可能用户切换一个页面那其实就是切换一个websocket长连接则服务器就会将原来的session立马销毁重新创建一个新的session这样一般都是在安全性要求比较高的场景下进行使用只要换连接那就跟着换一个session 但本项目中没有采取这样高级别的安全方式我们的项目在切换页面后使用的session还是原来的session并没有进行更换。
2. websocketpp库中的endpoint类里面实现了一个set_timer接口用于设置定时任务该接口的第一个参数duration表示多长ms时间之后执行该定时任务第二个参数是一个包装器类型包装的可调用对象的返回值是void参数是一个库里面定义的类型。 不过我们压根不用理睬他是个啥包装器类型直接传一个bind绑死参数的可调用对象就行让set_timer在规定时间之后直接执行我们自己传入的可调用对象。
3. 下面的代码希望大家不要感到陌生其实这段代码就是最开始我们搭建http/websocket服务器时的代码只不过在http_callback里面最后两行添加了一个定时任务也就是调用print函数上面我讲过bind的用法bind生成的可调用对象不影响类型只影响实际调用时候的传参所以我们直接绑死print的参数那么在duration毫秒之后set_timer就会自动调用print函数我们无需管set_timer的第二个参数timer_handler是什么类型的包装器直接传个绑死的可调用对象过去就行那么在实际调用timer_handler类型的callback时传任何参数都是没有用的他只会调用print(“rygttm”)这个函数。 4. 通过timer_ptr类型的tp指针接收set_timer的返回值后如果我们想取消定时器重新设置一波定时时间比如我不想5000毫秒后执行任务了而是想在3000毫秒后执行那我们就需要将原先的定时任务取消然后再重新调用set_timer设置新的定时任务。 而取消就需要借助timer_ptr类里面的cancel接口来实现但这个取消接口又特别的坑它会导致定时任务被立即的执行下面在实现session管理模块时我们还要对定时器被取消导致定时任务立即执行这样的行为做特殊处理。
当没有取消定时任务时可以看到客户端发起一次http请求后服务器终端上在10s过后才会打印出rygttm这表明在服务器的http_callback中我们确实设置好了一个10s后执行的定时任务。
当我们取消定时任务之后客户端发起一次http请求服务器调用http_callback都会立马在终端上打印出来rygttm由此可见取消定时任务后定时任务会立马被执行一次。 5.3 session的设计与实现
1. 一个会话应该包含的信息有这个会话本身的标识符也就是会话id还应该有用户id因为每一个session都是和一个用户所关联的所以session中还要包含uid表示这个session是哪个用户的还可以有一个用户状态字段也就是表示用户是unlogin还是login这个字段其实有和没有都行因为我们只会为登陆成功的用户创建会话所以只有某个会话被创建那么这个会话对应的用户状态一定是已登录的。每个会话都会有自己的定时任务例如多少s后销毁或者会话永久存在等等那么会话一定是需要和定时器对象所关联的所以成员变量我们在加一个timer_ptr的定时器对象。 成员函数这里其实实现的都是辅助接口比如外部想获取session中指定的信息时那么session就可以提供一些接口将指定信息进行返回传给外部调用方这些辅助接口的实现都很简单其实就是设置一些成员变量的值啦或者返回成员变量什么的。
2. set_user用于设置会话的成员变量_uid的值get_uid用于获取会话相对应的用户idis_login用于判断当前会话对应的用户是否处于登录状态set_state用于设置会话对应用户的状态set_timer用于设置会话对应的定时器对象这个接口其实就是由session_manager来调用的get_timer用于获取会话对应的定时器对象ssid用于返回会话id构造函数用于设置会话的ssid。 其实上面这些函数都是成对儿出现的每一对儿都和成员变量所对应说白了就是设置一下成员变量的值然后获取一下成员变量的值。
3. 由于session这个类比较简单所以设计和实现我放到一块了实现也是比较简单的大家看一眼就明白了。 5.4 session管理器的设计
1. 对于未来可能存在的多个session对象进行管理那我们肯定需要一个数据结构来将多个session对象组织起来为了更快的查找到特定的session对象我们采用了哈希表这种数据结构。 同时每个session都应该被分配一个session id所以session_manager的成员变量中还要有一个_next_ssid分配器用于给每个session分配唯一的ssid这个分配器听起来特别高大上但其实就是一个自增长的int类型值。 与之前的online_manager和user_table类都相同的是这里涉及到对共享资源_next_ssid和_sessions的访问所以我们这里还需要加一把互斥锁。 session管理器还需要给每个session添加定时任务所以我们还需要一个wsserver类对象用于获取server类中的set_timer接口以此来设置会话的定时任务。
2. 可能会有人有疑问为什么管理会话的智能指针是shared_ptr呢unique_ptr不行吗 主要是因为session管理器管理的不只有一个session他需要通过哈希表将多个session组织起来然后进行管理。哈希表构建会话id和会话智能指针之间的映射关系那么向_sessions这个哈希表中插入键值对时当然就会发生智能指针的拷贝了哈希表有堆上的智能指针函数栈帧里面有我们定义出来的session_ptr因为unique_ptr是禁止拷贝的所以就只能用shared_ptr来对session对象进行管理。 当session对象的引用计数变为0时session就会自动被销毁了。 3. session_manager的构造函数需要外部传入一个wsserver类型的对象create_session负责创建一个会话需要外部传入会话对应用户的uid和用户的状态get_session_by_ssid用于通过ssid来获取到会话管理指针通过这个智能指针就可以拿到会话中所有的详细信息也就是session类里面的所有详细信息。destroy_session用于销毁session其实所谓的销毁就是将哈希表中的键值对移除掉即可释放键值对在堆上对应的内存空间而键值对里面不就有session_ptr吗该智能指针销毁后会以RAII的风格释放session所占用的内存因为实际管理session的智能指针只有堆上这个还存在其他的函数栈帧内开辟的临时的智能指针在离开函数后都会被销毁掉了所以最后一定只剩一个session_ptr在堆上存放着。 set_session_expire_time就是设置会话的过期时间即在指定时间段后执行destroy_session完成session对象的释放这个接口实现起来是比较复杂的append_already_session其实就是配合set_session_expire_time来实现会话的定时销毁的这个接口也是整个session_manager中最繁琐的接口。 我们还预定义了两个宏出来分别代表session此刻是永久存在、session的过期销毁时间过期销毁时间的初始值设置为了30000ms。
5.5 session_manager类的实现
1. 在构造函数中我们自己初始化_next_ssid的值这个会话id分配器的值从1开始进行分配。 创建session时我们上来就直接加锁控制因为下面的代码会涉及到对共享资源的访问。我们将session会话对象开辟在堆上用sp指针来进行管理然后调用session类的接口进行会话相关信息的初始化将会话状态uid等字段填充好最后将sp和_next_ssid构成键值对插入到哈希表中别忘了将_next_ssid进行自增1最后返回sp即可。 get_session_by_ssid也比较简单通过调用哈希表的find接口即可找到ssid对应的键值对是什么如果找不到则返回一个空的智能指针对象如果找到则返回堆上的智能指针即可。 destroy_session的实现也很简单直接调用哈希表的erase接口进行键值对的移除即可。 2. 设置会话的过期时间其实分为四种情况我们需要判断会话原来有没有定时删除的任务有和没有就会细分为两种情况在每种情况下面又都会细分两个子情况也就是看外部给set_session_expire_time传入的时间参数是permanent永久还是timeout。 所以总体的情况就会分为四种对每一种情况都要有不同的处理。 有人可能会有疑问咋能有这么多种状态呢你是不存心搞我啊其实不然 例如当用户在登陆成功后此时服务器会为用户创建一个定时销毁的会话也就是说如果在用户登录成功后用户迟迟不点击一个提示框(前端alert显示的登录框)那么在30s之后这个会话就会被销毁掉这也是为了安全起见如果用户点击了那个提示框页面从登录跳转到游戏大厅那么此时会话就应该从定时销毁变为永久存在因为连接此时会切换为websocket连接后续服务器提供所有的业务处理之前都要在websocket连接的基础上判断会话是否存在如果定时销毁的话服务器都找不到会话了后续的业务处理的服务都提供不了了当游戏大厅页面被关闭时我们又需要从永久存在变为定时销毁还有一种情况是用户已经登录成功了结果不小心把登录页面给关闭掉了用户那就重新输入用户名和密码重新进行登录但此时用户对应的session已经存在了啊所以再次重新进行登录其实就是意味着刷新session定时销毁的时间从定时销毁再到定时销毁。 其实在用户登录成功后完全不需要再重新进行登录只不过存在用户反复登录这样的可能性所以我们需要刷新定时销毁的时间但事实上只要用户登录了一次会话创建成功后如果用户不小心关闭了游戏大厅页面或登录页面也是没有关系的用户可以直接再次请求游戏大厅页面只要重新请求这个过程的时间不超出定时销毁的时间那么是可以成功跳转到游戏大厅页面的因为会话在第一次登录创建成功后还没有被销毁。 3. 第一个if else分支语句中我们什么都不做就好因为会话被创建出来你没有向他添加任何定时任务那他默认就是永久存在的。 第二个if else分支语句中也很简单我们只需要通过调用_svr里面的set_timer接口设置SESSION_TIMEOUT时间之后执行销毁session的任务函数即可也就是调用destroy_session函数这里使用bind时也是采用绑死参数的方式来进行直接绑死参数this和ssid则在SESSION_TIMEOUT时间之后会话就会自动被删除。值得注意的是我们需要接收set_timer的返回值也就是定时器对象然后把这个定时器对象设置到会话的成员变量里面表示这个会话现在已经是有定时销毁的任务了的。 第三个if else分支语句中需要从定时删除设置为永久存在这里实现的时候就比较麻烦了因为我们需要先取消原来会话的定时删除任务然后将会话搞成永久存在。 但是这里就有一个问题取消原来的定时删除任务会导致任务被立即执行啊那也就是说一旦cancel之后会话就会被删除了啊那我们怎么搞出来一个永久存在的会话呢其实很简单我们再往_sessions这个哈希表里面添加一个键值对不就好了吗在函数的最开始部分我们保存过当前会话的会话句柄session_ptr sp啊这是一个临时对象那现在我们重新构建sp和ssid的映射关系搞成一个键值对然后将键值对插入到_sessions里面不就行了吗 话说的一点问题都没有但是吧这里还有一个bug那就是cancel的任务确实会被执行但他不是被立马执行的他是要等websocketpp库里面统一挨个执行定时任务队列里面的定时任务时才会被执行的 所以有可能在我们添加新的键值对之后cancle导致的定时任务destroy_session才会被执行那么此时就会导致我们刚刚立马插入的键值对就被删除掉了那此时会话就没有了这就是一种错误所以一定不能立马添加键值对那怎么添加呢通过设置定时任务来添加 也就是调用set_timer在0ms之后执行append_already_session这个定时任务的执行也不是立马被执行的你可以理解为websocketpp库里面有一个定时任务的队列set_timer的作用就是向这个队列立马添加定时执行的函数元素在等到真正执行定时任务的时候websocketpp会按照队列的先后顺序依次调用并执行这些定时任务所以在设置append_already_session为定时任务后那么该函数在被执行时他的前一个定时任务元素也就是cancle造成的destroy_session一定会先被执行那么此时的逻辑才是正确的 需要多说一嘴的是在unordered_map中如果我们插入具有相同的key的键值对时哈希表并不会报错而是会将新的键值对覆盖掉原来旧的键值对 第四个if else分支语句中需要从定时删除设置为定时删除实现的方式就是在第三个分支语句的基础上多增加了一次的定时删除任务先把原来的取消了然后添加一个永久的会话然后再给这个会话添加定时删除任务最后别忘记把tmp这个定时器对象设置到session这个类的成员变量里面通过调用会话句柄sp指向的set_timer接口来实现一定要区分开两个set_timer接口我们自己实现的set_timer和websocketpp库里面的set_timer重名了但参数和返回值都是不一样的大家一定不要搞混了。 4. session类里面还需要一个接口通过uid来判断用户的会话是否已经存在了如果存在那就返回会话的句柄如果不存在那就返回一个空句柄。 实现这个接口的原因主要是服务器模块处理登录功能的时候需要判断用户是否处于二次登录状态如果是二次登录状态并且第一次会话没有过期那么是不需要重新为用户创建会话的所以我们需要有一个接口来实现通过uid判断会话管理模块中会话是否存在这样的功能。不过这样的方式不太推荐因为遍历的效率太低正确的方式还是当用户反复登录时每次登录服务器都为用户重新创建一个新的定时销毁的session。 这个接口是我自己额外加进去的大家看个乐子就行原生项目里面是没有这个接口的我这样的想法也不太合适刷新定时销毁的过期时间不应该在用户反复登录这里体现况且这样的操作也不合理而且还得遍历哈希表所以最好还是不要提供这个接口 5. 下面我会为大家演示不同情况下会话的创建和销毁过程为了让实验的进度变得快些我将SESSION_TIMEOUT设置为15000ms也就是15s后会话定时删除。
登录成功创建15s后定时销毁的会话我们15s无操作跳转到游戏大厅后游戏大厅页面会向服务器发起websocket长连接请求服务器收到请求的第一件事情就是进行会话验证如果会话不存在则跳转回登录页面进行重新登录并以消息框的方式报错登录过期请重新登录。 页面跳转到游戏大厅后长连接建立成功则session变为永久存在在15s之后也可以看到会话是不会被销毁的。 进入游戏大厅后会话变为永久存在那么当我们关闭游戏大厅页面之后会话就会从永久存在变为定时销毁在服务器终端上可以看到15s过后会话被销毁了。 在初次登录成功后刚创建的会话会保持15s的时间在这段时间里我们可以重新访问游戏大厅重新向服务器发起websocket长连接握手此时会话就会从定时销毁重新变为永久存在并且在15s之后会话是不会被删除的 第一次登录成功后服务器为我们创建了15s后销毁的会话此时我们将页面关闭重新进行登录并且把这个过程控制在15s内完成那么原来的会话过期时间就会被刷新。 上面这种情况大家看个乐子就行项目中正确的刷新会话过期时间应该是下面这种情况上面我自己所说的这种情况如果硬要实现当然是可以实现的但不推荐实现这个因为效率比较低我们需要在后端遍历session管理器的所有键值对并且上面这样的思想也是不合适的正确的想法就应该是用户每一次登录成功服务器为用户创建一次session你把上次用户登录创建好的session给刷新保留下来能提高多少服务器的效率啊提高到没多少你后端还需要遍历session管理器中的所有键值对整体服务器的效率还是降低的 所以我上面叙述的这样的处理方式看个乐子就行下一篇博文讲述封装服务器模块代码时候我会再说明一下登录业务处理的逻辑不要判断处理这种反复登录请求业务从而使用同一个session的情况。 还有一种情况是进入游戏大厅后前端会通过ajax发送http请求来获取到用户详细信息并展示到前端页面上这个过程也会触发刷新会话过期时间。 这种情况是本项目中唯一体现出刷新定时销毁session过期时间的情况上面那种不算仅仅是本人脑子里的一个小idea而已
从实验现象可以看到前后两次登录用的是同一个session第二次登录刷新了第一次登录所创建的session的定时销毁时间。