下图是基于TCP协议的客户端/服务器程序的一般流程:
服务器初始化:
1.调用socket, 创建文件描述符。
2.调用bind, 将当前的文件描述符和ip/port绑定在一起; 如果这个端口已经被其他进程占用了, 就会bind失败。
3.调用listen, 声明当前这个文件描述符作为一个服务器的文件描述符, 为后面的accept做好准备。
4.调用accecpt, 并阻塞, 等待客户端连接过来。
客户端建立连接的过程:
1.调用socket, 创建文件描述符。
2.调用connect, 向服务器发起连接请求。
3.connect会发出SYN段并阻塞等待服务器应答 (第一次)。
4.服务器收到客户端的SYN, 会应答一个SYN-ACK段表示"同意建立连接" (第二次)。
5.客户端收到SYN-ACK后会从connect()返回, 同时应答一个ACK段 (第三次)。
断开连接的过程:
1.如果客户端没有更多的请求了, 就调用close()关闭连接, 客户端会向服务器发送FIN段(第一次)。
2.此时服务器收到FIN后, 会回应一个ACK, 同时read会返回0 (第二次)。
3.read返回之后, 服务器就知道客户端关闭了连接, 也调用close关闭连接, 这个时候服务器会向客户端发送一个FIN (第三次)。
4.客户端收到FIN, 再返回一个ACK给服务器(第四次)。
TCP在建立连接的时候,采用的是3次握手,在断开连接的时候,采用的是4次挥手。
close关闭客户端的文件描述符,close关闭服务端的文件描述符。就是4次挥手中的2次。
数据传输的过程:
1.建立连接后,TCP协议提供全双工的通信服务, 所谓全双工的意思是:在同一条连接中,同一时刻,通信双方可以同时写数据,相对的概念叫做半双工:同一条连接在同一时刻,只能由一方来写数据。
2.服务器从accept()返回后立刻调用read(), 读socket就像读管道一样, 如果没有数据到达就阻塞等待。
3.这时客户端调用write()发送请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答。
4.服务器调用write()将处理结果发回给客户端,再次调用read()阻塞等待下一条请求。
5.客户端收到后从read()返回,发送下一条请求,如此循环下去。
协议是一种 “约定”。socket api的接口,在读写数据时,都是按 “字符串” 的方式来发送接收的。如果我们要传输一些"结构化的数据" 怎么办呢?
struct messge
{
string nick_name;//姓名
string image;//图片
string message;//消息
}
如果我们想发送的是这样的消息。TCP是字节流传输,那么可以就会遇到结构体对齐的问题,这样得到的数据就不对。
我们就需要用序列化和反序列化。
比如,假设我们以冒号作为分割符,将上面的结构体转换成"字符串",nick_name:image:message。这种方法就叫做序列化,然后把这样的"字符串"传到对端,在此之前,我们应该有我们的协议制定:有几个字段,每个字段有什么含义。传到对端之后,按照这样的协议约定,把这样的"字符串"再转换成结构体。这叫做反序列化。
还有一个问题:字符串的整体长度对方是如何知道的呢?
我们需要定制协议:可以设置一个4字节大小的空间,在序列化之后,把这个空间放到序列化之后的字符串的开始之前。这个也叫做自描述长度协议。
我们需要实现一个服务器版的计算器,需要客户端把要计算的两个数发过去,然后由服务器进行计算, 最后再把结果返回给客户端。
约定方案一:
客户端发送一个形如"1+1"的字符串。
这个字符串中有两个操作数,都是整形。
两个数字之间会有一个字符是运算符。
数字和运算符之间没有空格。
约定方案二:
定义结构体来表示我们需要交互的信息。
发送数据时将这个结构体按照一个规则转换成字符串,接收到数据的时候再按照相同的规则把字符串转化回结构体。
下面我们将实现一下大致方案二的代码:
我们让服务器去提供这样的一个计算服务。
在这个Protocol.hpp去定制我们的协议。
我们定制了请求与响应的类来完成我们的操作。那么根据我们刚刚所说的,这两个类都需要序列化和反序列化。
还有我们需要知道发送的字符串的长度以及如何提取报文里的有效载荷。
这里为什么有一个buff和一个inbuff呢?
原因是:这里的一次读取是128字节,那么如果我们发送的字符串长度是大于128字节的,那么就说明一次读取不完,我们就需要把每次读取的数据放到inbuffer里,再做判断。
读取成功之后,我们需要检查inbuffer里是否至少有一个完整的报文。
那么我们如何去检查呢?
这个函数的作用是:检测len和有效载荷必须符合要求,才返回有效载荷和len,不然就是一个检测函数,把len设置成0。
我们以7\r\n100+200\r\n来为例:
这里我们要先判断传进来的字符串中有没有分隔符,如果没有就直接返回空字符串。这里我们把分隔符定义成\r\n:
如果存在这样的分隔符,我们就把分隔符前面的数字提取出来:
提取长度之后,我们要判断这个长度和有效载荷:
如果剩余的长度大于等于intlen说明至少有一个完整的报文,如果小于说明读取的有效载荷不全,就返回空字符串。
有完整的报文,我们就把它提取出来。
但是到这里还没有结束:
我们需要把传进来的字符串中除去刚刚提取出来的报文,不然下一次提取还是这个报文。
这样我们就可以把完整的有效载荷返回。
如果已经获取了一个完整的报文,我们就把它进行反序列化,填充到req对象中。
反序列化完成之后,我们就需要把它进行计算,把结果填充到Response。
这里因为只有我们自己使用,加上了static。
得到计算结果后,我们需要把这个结果序列化成一个字符串。
我们制定协议:在退出码和结果直接添加一个空格。
// "exitCode_ result_"
// "len\r\n""exitCode_ result_\r\n"
如果用户输入quit,就continue直接进行循环判断,不往下写入。
下面我们就需要把输入到message里面的表达式填充到req里。
先把message格式化输入到strtmp里。然后只要有OPS里任意一个字符就切割出左操作数和右操作数。
然后我们把操作数和操作符都填充进去。但是这里不能处理负数。
填充到req里面之后,我们需要进行序列化,然后给前面添加长度。
当发送的消息添加协议之后,发送给服务器,发送成功后进行读取到buff里,然后将读取的数据进行提取数据,这里和服务器端也是一样的。
读取的结果进行反序列化到resp里。然后把退出码和结果打印出来。
我们这里自己去写序列化和反序列化十分麻烦,我们可以使用第三方库来完成这方面工作:
yum install -y jsoncpp-devel
安装成功后,可以看到头文件:
那么我们该如何使用呢?
我们可以采用条件编译的形式,如果我们自己定义了宏就使用我们自己的,否则就使用json。
首先,引入头文件:
#include
第一步,我们要创建json里面的Value对象,它是一个万能对象,能接受任何类型:
Json::Value root;
json是基于KV的,序列化的时候,会将所有的数据内容,转换成为字符串。
然后,我们需要定义一个FastWriter(或者使用StyledWriter)对象,把root这个对象转换成字符串形式:
这样就完成了序列化工作。
反序列化我们需要json里面的Reader对象:
然后将in里面的字符串反序列化到root里,然后再通过root填充到Request里的成员变量里。
那么在编译的时候,因为是第三方库,我们需要在makefile里去指定库:
并且在条件编译的时候,我们可以命令行去定义:
这样就会去使用我们自己定义的协议。
这样说明这里为空,就会使用json。