目录
再谈协议
我们之前写的代码,实际上已经有了一些协议的味道,因为我们客户端和服务端读写时,都将数据当成字符串。写多线程远程命令执行时,客户端和服务端都将数据当成shel指令。但是协议是一个双方约定好的结构化的数据,所以我们这里顶多算是有一些协议的味道。
为什么协议要是一个结构化的数据呢?我们在聊天时,当我们发送一条消息,通常除了显示这条消息外,还会显示头像、昵称等,如果客户端将消息、头像、昵称分别发给服务端,服务端再分别转发给其他群聊用户,这显然不如客户端将消息、头像、昵称一起发给服务端,客户端再将这些数据一起发给其他群聊用户。在C/C++中,要将消息、头像、昵称这3个数据弄成一个,就需要使用结构体/类。这个结构化数据就是协议。因为通信双方都认识里面的内容。
序列化与反序列化
假设我们需要实现一个服务器版的计算器。我们需要客户端把要计算的两个数发过去,然后由服务器进行计算,最后再把结果返回给客户端。
约定方案一:
- 客户端发送一个形如"1+1"的字符串;
- 这个字符串中有两个操作数,都是整形;
- 两个数字之间会有一个字符是运算符;
- 数字和运算符之间没有空格;
- ...
约定方案二:定义一个双方都认识的结构体,在这个结构体中定义要计算的数以及怎么计算,然后将整个结构体发送给服务端
此时是有问题的,因为客户端可能是不同的,如机器的位数可能不同,导致对齐方式不同,进而导致结构体大小可能不一样;另外,可能服务端是C++写的,客户端是其他语言写的。读取就会出错。这种方法可以,但是对服务端和客户端的要求都很高。所以,最好不要以二进制传结构体来进行通信。OS的协议传的就是结构化的二进制,因为OS的语言都是一样的,都是C语言,并且有严格的平台化定制。但是应用层不能这样。
对于方案二,应该这样:
- 定义结构体来表示我们需要交互的信息;
- 发送数据时将这个结构体按照一个规则转换成字符串,接收到数据的时候再按照相同的规则把字符串转化回结构体;
这个过程叫做"序列化"和"反序列化。
将消息由多变1的过程,我们称为序列化;将序列化之后的字符串转成结构化数据的过程,称为反序列化。像我们之前写的多线程远程执行命令,正常来说应该将shell指令分成指令和选项,当我们接收到由指令和选项构成的字符串时,应该将它分成两个部分,并定义一个结构体来描述它。
- 为什么序列化?因为方便网络发送,将3个字符串打包成1个报文,方便发送
- 为什么反序列化?因为曾经序列化了,而上层要使用的是结构化的数据在应用层,要将数据结构化,因为我们要对数据进行先描述,再组织。
当我们进行序列化之后,在网络通信的过程中,不需要关心发送的是什么,只需要知道是字节流即可。也就是说,客户端发送的都是字符串,服务端接收时直接按照字符串接收即可。并且接收到数据后,网络服务器不对接收到的消息做任何解释,由上层解释。
所以,所谓的协议定制,本质其实就是在定制双方都能认识的,符合通信和业务需要的结构化数据。所谓的结构化数据,其实就是struct或者class。对于一套完整的协议,除了要定义出双方都认识的结构化数据,还要有对这个结构化数据进行序列和反序列化的方案。
无论我们采用方案一,还是方案二,还是其他的方案,只要保证,一端发送时构造的数据在另一端能够正确的进行解析,就是ok的。这种约定,就是应用层协议。但是,为了让我们深刻理解协议,我们打算自定义实现一下协议的过程。
自定义应用层协议实现网络版计算器
概述:我们这里采用方案二来做,因为这样可以更加清晰地认识到自定义协议的过程。我们要引入序列化和反序列化,可以自己完成,但是我们直接采用现成的方案 --- jsoncpp。TCP相较于UDP更加复杂,所以这里直接使用TCP的代码。因为使用的是TCP,所以我们要对字节流的读取进行处理
自定义协议
因为我们是基于TCP通信的自定义协议,所以自定义协议时,有3个关键步骤:
- 完成协议的定制,就是定义结构化数据
- 确定序列化与反序列化方案
- 确保报文完整性
协议定制与序列化与反序列化方案
在这里,我们除了要完成协议的定制外,还要完成序列化和反序列化的方案。
所谓定制协议,就是要有结构化的数据。
// 请求
class Request
{
private:
int x;
int y;
char oper;
};
// 响应
class Response
{
private:
int result; // 结构
int code; // 错误码,0表示成功,1,2,3,4表示不同错误
};
客户端将要计算的式子构建一个Request,序列化后发给服务端,服务端反序列化后,计算完成构建一个Response,再序列化后讲Response发送给客户端,客户端就拿到了计算结果。序列化和反序列化可以自己完成,也可以通过jsoncpp库来完成。
序列化方案一(自己做):将x和y都转换成字符串,因为oper原本就是字符,所以我们是可以保证这三个转换后都是不包含空格的,所以,我们可以使用空格作为分隔符 --- "x oper y",这个字符串就是序列化后的结果。反序列化时只要根据空格再还原回来即可。
序列化方案二:序列化这件事情在网络通信中是都要做的,所以一定是有比较成熟的方案的,像xml、json、protobuf。为了增加可读性,我们使用json。调用相应的接口,就可以将结构化的数据转换成json风格的字符串。我们是C++,所以使用的是jsoncpp。
// json风格的字符串
{
"x": 10,
"y": 5,
"oper": "+"
}
{"x": 10, "y": 5, "oper": "+"}
这两种都是json风格的字符串,左边有更多的\n,是一对一对的key:value组成的,每一对用”,"隔开。这一整个就是一个字符串。上面的可读性更好,但是网络传输时还是建议使用下面的。
确保报文完整性
现在我们进行了协议的定制,并且也确定好了序列化与反序列化的方案。我们知道,TCP是全双工+面向字节流的。因为是面向字节流的,所以无法保证我们一次读取就能读取到完整的报文,就像上面读取json风格的字符串时,可能一次只读取到了半条,1条半等等都是有可能的。要想将确保报文完整性这个问题解决,就需要了解一下下面的知识:了解TCP的全双工与面向字节流
当我们创建一个TCP套接字时,OS会在传输层为这个套接字创建两个缓冲区,一个叫发送缓冲区,一个叫接收缓冲区。所以,我们发送数据的本质就是将数据拷贝到发送缓冲区中。所以,我们之前使用到的系统调用write、send不是将数据发到网络中,而是将数据拷贝到发送缓冲区当中。数据什么时候发送到网络中完全由OS自主决定。当主机读取数据时,若接收缓冲区中没有数据,那么就会阻塞在read处,直到接受缓冲区有了数据,就将数据拷贝到buffer中。所以,read、recv本质也是拷贝。所以,主机A向主机B发送的一整套流程就是主机A将数据拷贝到发送缓冲区,通过网络将数据拷贝到主机B的接受缓冲区。
为什么TCP支持全双工?因为一个套接字对应两个缓冲区,发送时使用发送缓冲区,接收时使用接收缓冲区,互不影响。如何让一个文件描述符关联两个缓冲区?struct file中有一个成员变量:
void* private_data;
当我们创建的是一个普通的文件对象时,这个成员变量是没用的,但是当我们创建的是一个网络文件时,private_data就会指向一个struct socket类型的对象。
struct socket {
socket_state state; // 套接字状态(如 SS_CONNECTED)
short type; // 套接字类型(如 SOCK_STREAM)
unsigned long flags; // 标志位
struct file *file; // 关联的文件结构(VFS 文件描述符)
struct sock *sk; // 指向底层网络协议的控制块(如 TCP/UDP)
const struct proto_ops *ops; // 协议操作函数集(如 .bind, .connect)
};
struct sock里面有成员变量:
struct sk_buff_head sk_receive_queue; // 接收队列(存放数据包)
struct sk_buff_head sk_write_queue; // 发送队列(待发送的数据包)
一个服务器,可能会有多个客户端,这些客户端都可能会给这个服务器发送消息,所以这个服务器是会同时接收到非常多的报文的,这些报文可能在网卡、链路层、网络层等等,也就是说,OS内部可能存在大量的报文。对于客户端所在的主机,上面可能不止有一个客户端,对于这些客户端,请求回来的报文也可能都存在于OS当中。既然OS内部会有非常多的报文,那么OS就需要管理报文。先描述,再组织。所以,所谓报文,就是一个结构化的数据。在OS内,管理报文的结构是struct sk_buff。
struct sk_buff_head {
/* 这两个字段必须放在开头,以兼容 `struct sk_buff` */
struct sk_buff *next; // 指向链表中的第一个 sk_buff
struct sk_buff *prev; // 指向链表中的最后一个 sk_buff
__u32 qlen; // 队列长度(当前链表中的 sk_buff 数量)
spinlock_t lock; // 自旋锁(保护队列并发访问)
};
所以,只要将发送的报文连接到sk_write_queue中,发送队列就有了,只要将报文连接到sk_receive_queue中,接收队列就有了。所以,所谓缓冲区,实际上就是一个链表。注意:这里说的缓冲区是传输层的缓冲区。假设我们要发送数据,将报文连接到发送缓冲区后,不是从这里直接发到网络,而是需要继续向下一层封装的。接收报文也是同理。
TCP叫传输控制协议:因为TCP是属于OS的,所以TCP是可以做到传输控制的。用户使用write将数据拷贝到发送缓冲区后,其实就是将数据放入到了OS的一个数据结构当中,接下来,数据什么时候发,怎么发,发送出错了怎么办,完全由TCP自己控制。
面向字节流:假设现在主机A要发送一个完成序列化的字符串给主机B,就会将这个字符串拷贝到TCP的发送缓冲区当中,OS并不会关心这个字符串有几个字节、是什么含义等。假设这个字符串是20字节,而主机B的接收缓冲区只剩下10字节了。TCP是会进行控制的,所以TCP一定能够通过某种方式知道主机B的接收缓冲区只剩下10个字节了,所以TCP就只能发送10个字节过去,也就是只发送了这个报文的一部分。如果这一部分报文拷贝到主机B的接收缓冲区后,瞬间就被读取了,此时读取就算是出问题了。TCP发多少我们是控制不了的,TCP只按照自己的规则发,至于是否发全了,TCP是不管的。所以,TCP是面向字节流的。TCP需要在应用层保证自己报文的完整性。此时就需要对read读到的数据进行判断,若不完整,下次再读一部分拼接,直到报文完整,才进行反序列化。所以,这就是之前我们使用read和recv时,说这里不完善的原因,因为buffer中的报文不一定是完整的。另外,不一定拿到是半条,可能拿到两条、、一条半等,因为TCP为了保证效率,可能将发送缓冲区中与这边接收缓冲区匹配的区间的报文全发过来。UDP是面向数据报的,凡是发出去的报文,一定是一个完整的报文。之前在进行文件操作时,向文件中写入是很容易的,但是从文件中读取总是需要十分小心。因为文件也是面向字节流的。
为了保证报文的完整性,需要对上面序列化的方案进行调整。
对于方案一,我们弄成了一个字符串"x oper y",如果有多个序列化后的字符串埃在一起,是难以区分什么时候读完了一个字符串的。此时可以使用一些特殊字符将这些字符串隔开,比方说可以使用'\n',此时就可以一行一行读,一行就是一个字符串。今天使用head_length\n"x oper y"\n的形式。其中"x"、"oper"、"y"是报文的真正内容,head_length是报文真正内容的长度。通过这种形式是可以保证可以读到完整的报文的。一直读,直到读到'\n',说明读到的内容就是一个数字,然后就可以从这个位置+1,并向后读取数字个字节,此时读到的就是报文真正的内容了,因为知道了后面的长度,就能够保证读完。"x oper y"称为有效载荷,head_length\n就是报头。我们还可以在head_length前面再带上一个code\n,表示是哪一种协议。
如果序列化采用的是方案二,里面带有\n'也是没问题的,因为已经获取到了有效载荷的长度,可以直接读完。我们代码中直接使用head_length\njson风格的字符串\n。当我们要发图片、视频时,也是直接将图片、视频的内容往后面跟,前面带上一个图片、视频内容的长度和\n。所以,这种方法是通用的。
正式开始写自定义协议部分的代码
有了上面的概念铺垫之后,我们就可以根据那3个步骤来完成自定义协议的代码了
class Request
{
public:
Request(int x = 0, int y = 0, char oper = 0)
:_x(x),
_y(y),
_oper(oper)
{}
bool Serialize() // 序列化
{}
bool Deserialize() // 反序列化
{}
int X() const { return _x; }
int Y() const { return _y; }
char Oper() const { return _oper; }
private:
int _x;
int _y;
char _oper;
};
class Response
{
public:
Response(int result = 0, int code = 0)
:_result(result),
_code(code)
{}
bool Serialize() // 序列化
{}
bool Deserialize() // 反序列化
{}
int Result() const { return _result; }
int Code() const { return _code; }
void SetResult(int res) { _result = res; }
void SetCode(int code) { _code = code; }
private:
int _result; // 结果
int _code; // 出错码,0表示计算成功,1,2,3,4表示相应的错误
};
除了结构体内数据的含义是约定之外,计算顺序也是一种约定。出错码0,1,2,3,4,..的含义也是约定。当我们将协议定好了之后,Request和Response必须提供序列化和反序列化的方法。接下来使用jsoncpp库完成序列化与反序列化的工作。
先来看Request的:
bool Serialize(std::string& out_string) // 序列化
{
Json::Value root;
root["x"] = _x;
root["y"] = _y;
root["oper"] = _oper;
Json::StreamWriterBuilder wb;
std::unique_ptr<Json::StreamWriter> w(wb.newStreamWriter());
std::stringstream ss;
w->write(root, &ss);
out_string = ss.str();
return true;
}
bool Deserialize(std::string& in_string) // 反序列化
{
Json::Value root;
Json::Reader reader;
bool parsingSuccessful = reader.parse(in_string, root);
if (!parsingSuccessful)
{
std::cout << "Failed to parse JSON: " << reader.getFormatedErrorMessages() << std::endl;
return false;
}
_x = root["x"].asInt();
_y = root["y"].asInt();
_oper = root["oper"].asInt();
return true;
}
在json风格的字符串中,oper会被转换成它的ASCII值存储。这里将其转化为整数又赋值给字符类型,就会变为字符了。
bool Serialize(std::string& out_string) // 序列化
{
Json::Value root;
root["result"] = _result;
root["code"] = _code;
Json::StreamWriterBuilder wb;
std::unique_ptr<Json::StreamWriter> w(wb.newStreamWriter());
std::stringstream ss;
w->write(root, &ss);
out_string = ss.str();
return true;
}
bool Deserialize(std::string& in_string) // 反序列化
{
Json::Value root;
Json::Reader reader;
bool parsingSuccessful = reader.parse(in_string, root);
if (!parsingSuccessful)
{
std::cout << "Failed to parse JSON: " << reader.getFormatedErrorMessages() << std::endl;
return false;
}
_result = root["result"].asInt();
_code = root["code"].asInt();
return true;
}
这样,我们就完成了序列化与反序列化的工作,接下来完成添加报头和去除报头的接口。
const std::string Sep = "\r\n"; // 定义分隔符
// 添加报头
// {json} -> len\r\n{json}\r\n
// 这里的message是一个序列化后的字符串
bool Encode(std::string& message)
{
if(message.size() == 0) return false;
// 下面的第二个分隔符是可以不要的。在这里加上是为了方便查看效果
std::string package = std::to_string(message.size()) + Sep + message + Sep;
message = package;
return true;
}
// 去除报头
// package是一个从客户端接收到的报文,我们要去除报头后,将内容放到content中
bool Decode(std::string& package, std::string& content)
{
auto pos = package.find(Sep);
if(pos == std::string::npos) return false;
// 提取报头
std::string content_length_str = package.substr(0, pos);
// 将报头转换为数字
int content_length = std::stoi(content_length_str);
// 获取完整报文的长度
int full_length = content_length_str.size() + content_length + 2 * Sep.size();
// 如果package的长度小于一条完整的报文,就无法进行去除报头
if(package.size() < full_length) return false;
// 获取有效载荷
content = package.substr(pos + Sep.size(), content_length);
// 将我们拿走的这条报文从package中删除,所以package一定要是引用
package.erase(0, full_length);
return true;
}
业务
我们的业务就是计算,所以,要定义一个用于计算的类。
class Calculator
{
public:
Calculator() {}
Response Execute(const Request &req) // 给计算器一个Request,返回一个Response
{
Response resp;
switch (req.Oper())
{
case '+':
resp.SetResult(req.X() + req.Y());
break;
case '-':
resp.SetResult(req.X() - req.Y());
break;
case '*':
resp.SetResult(req.X() * req.Y());
break;
case '/':
{
if (req.Y() == 0)
{
resp.SetCode(1); // 1 就是除0
}
else
{
resp.SetResult(req.X() / req.Y());
}
}
break;
case '%':
{
if (req.Y() == 0)
{
resp.SetCode(2); // 2 就是mod 0
}
else
{
resp.SetResult(req.X() % req.Y());
}
}
break;
default:
resp.SetCode(3); // 3 用户发来的计算类型,无法识别
break;
}
return resp;
}
~Calculator() {}
};
服务端
因为我们这个网络版计算器是基于TCP的,所以服务端直接对之前的服务端进行修改即可。我们的服务端只进行IO,所以它不需要知道上层业务是什么,只需要知道自己会接收到来自客户端发过来的字符串即可。服务端接收到来自客户端的字符串后,通过回调函数将其交给上层处理,然后将处理结果在发送给客户端即可。
// 交给线程池的任务的类型
using task_t = std::function<void()>;
// 上层业务
using handler_t = std::function<std::string (std::string&)>;
#define BACKLOG 8
static const uint16_t gport = 8080;
class TcpServer
{
public:
TcpServer(handler_t handler, int port = gport):_handler(handler), _port(port), _isrunning(false)
{}
void InitServer()
{
// 1. 创建TCP套接字
_listensockfd = ::socket(AF_INET, SOCK_STREAM, 0);
if(_listensockfd < 0)
{
LOG(LogLevel::FATAL) << "socket error";
Die(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "socket create success, sockfd is: " << _listensockfd;
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
// 2. 绑定
int n = ::bind(_listensockfd, CONV(&local), sizeof(local));
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
Die(BIND_ERR);
}
LOG(LogLevel::INFO) << "bind success, sockfd is: " << _listensockfd;
// 3. 将套接字设置为监听状态
n = ::listen(_listensockfd, BACKLOG);
if(n < 0)
{
LOG(LogLevel::FATAL) << "listen errno";
Die(LISTEN_ERR);
}
LOG(LogLevel::INFO) << "listen success, sockfd is: " << _listensockfd;
}
void HandlerRequest(int sockfd)
{
LOG(LogLevel::INFO) << "HandlerRequest, sockfd is: " << sockfd;
char inbuffer[4096];
std::string package; // 从客户端读取到的消息就放在package中
while(true)
{
ssize_t n = ::recv(sockfd, inbuffer, sizeof(inbuffer) - 1, 0);
if(n > 0)
{
inbuffer[n] = '\0';
LOG(LogLevel::INFO) << "\n" << inbuffer;
package += inbuffer; // 这里使用+=是因为读到的inbuffer不一定是完整的
// 调用上层的处理方法处理任务
std::string cmd_result = _handler(package);
// 如果package中没有一条完整的报文,就继续读
if(cmd_result.empty()) continue;
::send(sockfd, cmd_result.c_str(), cmd_result.size(), 0);
}
else if(n == 0)
{
// 客户端已经退出了,应该退出处理逻辑,重新获取连接
LOG(LogLevel::INFO) << "client quit: " << sockfd;
break;
}
else
{
// 读取失败
break;
}
}
::close(sockfd);
}
void Start()
{
_isrunning = true;
while(_isrunning)
{
// 1. 获取新连接
struct sockaddr_in peer;
socklen_t peerlen = sizeof(peer);
LOG(LogLevel::DEBUG) << "accept ing ...";
int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen);
if(sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept error: " << strerror(errno);
continue;
}
// 获取连接成功
LOG(LogLevel::INFO) << "accept success, sockfd is: " << sockfd;
InetAddr addr(peer);
LOG(LogLevel::INFO) << "client info: " << addr.Addr();
// 处理请求, 构建任务, 并将任务交给线程池
task_t f = std::bind(&TcpServer::HandlerRequest, this, sockfd);
ThreadPool<task_t>::getInstance()->Equeue(f);
}
}
void Stop()
{
_isrunning = false;
}
~TcpServer()
{}
private:
int _listensockfd; // 监听套接字
uint16_t _port;
bool _isrunning;
// 处理上层任务的入口
handler_t _handler;
};
这里的修改主要是handler_t和HandlerRequest的修改。因为当传入的package是有完整的报文时,拿取报文后需要删除掉这条报文,所以接收package的参数一定要是引用,所以将handler_t的参数改成了引用。HandlerRequest主要是增加了一个package,用于处理报文不完整的情况。
现在我们的服务端已经能够根据报文是否完整来决定如何处理数据,当完整时,就进行计算,然后将计算结果返回给客户端;若不完整,则继续读取。那么我们上层就应该提供一个函数,来判断一个package中是否有完整的一条报文,若有,将这条报文提取,然后反序列化并转换成Resquest,然后进行计算,获得Response,再将其序列化,然后添加报头,将其返回给服务端,让其发送给客户端。
using cal_fun = std::function<Response(const Request &req)>;
// package一定会有完整的报文吗??不一定把
// 不完整->继续读
// 完整-> 提取 -> 反序列化 -> Request -> 计算模块,进行处理
class Parse
{
public:
Parse(cal_fun c) : _cal(c)
{}
std::string Entry(std::string &package)
{
// 1. 判断报文的完整性!
std::string message;
std::string respstr;
while (Decode(package, message))
{
LOG(LogLevel::DEBUG) << "Content: \n" << message;
if (message.empty())
break;
// 2. 反序列化, message是一个曾经被序列化的request
Request req;
if (!req.Deserialize(message))
break;
// 3. 计算
Response resp = _cal(req);
// 4. 序列化
std::string res;
resp.Serialize(res);
LOG(LogLevel::DEBUG) << "序列化: \n" << res;
// 5. 添加长度报头字段!
Encode(res);
LOG(LogLevel::DEBUG) << "Encode: \n" << res;
// 6. 拼接应答
respstr += res;
}
LOG(LogLevel::DEBUG) << "respstr: \n" << respstr;
return respstr;
}
private:
cal_fun _cal;
};
客户端直接调用的函数,就是类Prase中的成员函数Entry
int main()
{
ENABLE_CONSOLE_LOG();
// 1. 计算模块,应用层
Calculator mycal;
// 2. 解析报文,报文解析层
Parse myparse([&mycal](const Request& req){
return mycal.Execute(req);
});
// 3. 通信模块,网络层
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>([&myparse](std::string& package){
return myparse.Entry(package);
});
tsvr->InitServer();
tsvr->Start();
return 0;
}
这样,我们的软件就是3层的。TCP服务端,也就是网络层取调用Parse中的接口,也就是报文解析层的接口,只有报文解析层判断传过来的报文中有至少一条完整报文时,才会去钓鱼计算模块的接口,也就是应用层的接口,然后就能完成计算,再进行序列化、添加报头即可将结果返回给TCP服务端,然后将其发送回客户端。
客户端
这里也是对之前的客户端进行部分修改即可。这里我们对客户端就进行简单处理了,直接认为它读到来自服务端的消息是完整的。
// ./client_tcp server_ip server_port
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cout << "Usage:./client_tcp server_ip server_port" << std::endl;
return 1;
}
std::string server_ip = argv[1];
int server_port = std::stoi(argv[2]);
int sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0)
{
std::cout << "create socket failed" << std::endl;
return 2;
}
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(server_port);
server_addr.sin_addr.s_addr = inet_addr(server_ip.c_str());
// 与服务端建立连接
int n = ::connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if(n < 0)
{
std::cout << "connect failed" << std::endl;
return 3;
}
// 与服务端通信
std::string message;
while(true)
{
int x, y;
char oper;
std::cout << "input x: ";
std::cin >> x;
std::cout << "input y: ";
std::cin >> y;
std::cout << "input oper: ";
std::cin >> oper;
Request req(x, y, oper);
// 1. 序列化
req.Serialize(message);
// 2. 添加报头
Encode(message);
// 发送
n = ::send(sockfd, message.c_str(), message.size(), 0);
if(n > 0)
{
char inbuffer[1024];
int m = ::recv(sockfd, inbuffer, sizeof(inbuffer), 0);
if(m > 0)
{
inbuffer[m] = '\0';
std::string package = inbuffer;
std::string content;
// 直接认为读到的报文是完整的,所以直接去除报头
Decode(package, content);
// 反序列化
Response resp;
resp.Deserialize(content);
// 得到结构化数据
std::cout << resp.Result() << "[" << resp.Code() << "]" << std::endl;
}
else break;
}
else break;
}
::close(sockfd);
return 0;
}
现在来测试一下是否能够通过服务端获得计算之后的结果。
此时是可以正常通信的
之前说过OSI七层模型在实际中会实现成TCP/IP的五层,为什么?主要区别是上三层,现在来看一下上三层。
我们刚刚写的代码,有3层结构,在TCP/IP中统称为应用层。在OSI七层模型中,计算的代码就属于应用层。对于协议中的数据类型,应用层只负责使用;固有数据格式就是我们定的协议,它和网络标准数据格式的转换就是序列化。所以,表示层就是我们自定义协议和添加长度报头的那部分代码;会话层就是我们代码中的TcpServer。当有了一个请求,我们是使用线程池中的线程来建立连接,处理完请求后就断开连接。我们的会话层就是使用线程池中的线程来管理一个连接的建立和断开。完成了数据通信,这就是数据流动的逻辑通路。总结:负责IO的那一层就是会话层。
所以,这三层与我们刚刚的分层是一一对应的。TCP/IP协议中,为什么将这三层压缩成一层应用层,而下四层不变呢?下四层是永远不变的,会将其整合到OS中。对于会话层,我们刚刚就使用了多进程、多线程、线程池,未来还有多路转接;表示层与协议定制有关,对于不同的场景、不同的人、不同的公司定的协议都不一样,无法将其整合到OS内部;应用层是基于表示层的,所以表示层变了应用层也必然变化。所以,这三层无法整合到OS内部,需要由程序员自己维护。
进程间关系与守护进程
前台进程与后台进程
对于这个后台进程,我们将这个1称为当前任务的任务号。想将一个后台进程杀掉,可以使用fg 任务号将其弄到前台,再使用ctrl+c。位于后台的进程,运行时状态是S,前台是S+,少了+。
ctrl+z是给前台进程发信号,暂停前台进程,因为对于暂停的进程是不能占据终端的,所以会被放到后台。想将一个位于前台的进程放到后台,可以ctrl+z,让这个进程暂停,暂停后这个进程就到后台了,再bg1,让这个进程重新运行,此时这个进程就到后台运行了。
将我们的服务器放到后台,可以使用jobs查看有几个后台进程。所以,进程分为前台进程和后台进程。当我们运行起来一个前台进程时,这个前台进程的父进程是bash,并且指令是无法执行的。结论:
- 任何登录Linux,任何时刻只允许有一个前台进程,多个或0个后台进程。因为标准输入只有一个,若有多个前台进程,输入的是交给哪一个前台进程呢?
- 命令行启动任何进程,bash进程自动变成后台进程,直到前台进程结束。标准输入是交给前台进程的,所以ls、pwd都是交给前台进程的,此时的前台进程并不是bash,所以无法执行指令。父进程创建子进程,父进程先退,子进程就会变成后台进程。注意:后台进程无法从标准输入读取,但是可以从标准输出输出
进程组
我们通过管道将三个进程建立关系,这三个进程是兄弟关系,会发现,我们使用ctrl+z将前台进程放到后台时,这3个进程是一起被放到后台的,后台放前台也一样。PGID的意思是进程组,所以这3个进程是属于同一个组的。所以,进程不仅仅有父子关系、兄弟关系,还有组内关系。会以这个组内第一个被创建的进程的PID作为这个组的组ID。这个PGID是进程的PCB中包含的一个字段。进程组一般是为了完成一个任务,或者作业的。进程组与任务是相辅相成的,没有进程组就没有任务,没有任务也没有进程组。这也是为什么后台进程前面的数字叫任务号的原因。多进程集连时,进程组内就有多个进程;若没有多进程,只有1个进程,那么这一个进程就单独称为一个进程组或任务。所以,现在./server_tcp更准确的说不是启动一个进程,而是启动一个服务任务。
守护进程
我们之前的服务端,运行时XShell都不能关闭,这是不应该的。我们现在已经有了一个服务了,我们想将这个服务变成一个真正的服务,就是要将其变成一个守护进程。
TTY就是这三个任务和那个终端文件相关联。TPGID表示的是当前处于前台的进程组ID。SID是什么呢?SID也叫session ID。当我们登录云服务器时,云服务器会给我们创建一个会话(session)和bash进程,并将两者关联起来。一台云服务器可能是会有多个用户登录的,所以会创建多个session,所以云服务器需要管理session。所以,在外面看来,session就是OS内的一个数据结构。将session的ID信息、地址与bash进程关联起来,此时就是session与bash关联了。登录时,bash进程会启动一个终端文件。当我们启动一个进程或多个进程时,像刚刚启动了3个进程,bash进程创建3个子进程,将这3个子进程的PPID设为bash的ID,PGID设置成第一个启动的子进程的PID,关系就维护起来了。未来bash创建的所有子进程,都会指向同一个终端文件,因为文件描述符会被继承。但是,在创建进程组时,进程组与bash都是属于同一个session的。一个session中,只允许有一个前台进程,实际上就是谁能使用这个终端文件。所以,无论是前台进程,还是后台进程,都属于一个会话。
无论是xShell启动,还是虚拟机,都是这样,Windows启动也是一样,只是bash是图形化界面。
假设现在前台是bash,比时退出登录呢?会适、终端文件都会被释放后台进程组也可能会受到影响。我们今天想要一个不受影响的进程组要怎么办呢?可以由bash创建一个子进程或子进程组,并让这个子进程或进程组独立成为一个会话。此时当bash所对应的客户端退出时,这个子进程或子进程组所对应的会话是不会受到影响的。守护进程就是这样的。所以,守护进程也是后台进程的一种,但有一个本质区别,后台进程仍然属于当前会话,而守护进程属于自己独立的一个会话。
现在来谈谈后台进程组可能受到影响,这个影响是什么意思?当我们将登录退出后,如果还有后台进程在运行,这个后台进程是不会被关闭的,但是可能会产生一些错误,如已经无法向终端文件打印。这就是影响。
关于守护进程:
- 守护进程往往要脱离终端。因为它已经不需要与终端进行I0了,而是从网络中I0。当然,日志是与磁盘文件IO。
- 守护进程其实就是一个孤儿进程。
如何让一个进程成为一个守护进程呢?
方法一:直接使用库函数
#include <unistd.h>
int daemon(int nochdir, int noclose);
方法二:手动完成守护进程化
#include <unistd.h>
pid_t setsid(void);
setsid用于创建一个新的会话,并使得调用进程成为该会话的首进程和新进程组的组长,同时脱离原控制终端。要想调用这个函数成功,有一个条件:调用的进程不能是进程组的首进程。为了保证条件成立,可以利用fork出的子进程与父进程属于同一个进程组,并且父进程是首进程。所以,可以让父进程fork出子进程,然后让父进程退出,这样,子进程不是进程组的首进程,且与父进程共享代码。我们这里使用方法二来完成。
#define ROOT "/"
#define devnull "/dev/null"
void Daemon(bool ischdir)
{
// 1. 守护进程一般要屏蔽掉特定的异常信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
// 2. 成为非组长
if(fork() > 0) exit(0);
// 3. 建立新会话
setsid();
// 4. 每个进程都会有自己的CWD,是否将当前进程的CWD更改为根目录
if(ischdir)
chdir(ROOT);
}
有些服务是需要将守护进程的工作路径设置为根目录的,这样未来守护进程对配置文件、临时数据访问时就可以采用绝对路径进行访问了。这样未来将可执行程序放到哪里都可以直接找到需要的资源。设不设置由用户决定,所以给Daemom添加一个参数,表示是否要将工作路径设置为根目录。
int main()
{
ENABLE_CONSOLE_LOG();
// Daemon();
// 1. 计算模块,应用层
Calculator mycal;
// 2. 解析报文,报文解析层
Parse myparse([&mycal](const Request& req){
return mycal.Execute(req);
});
// 3. 通信模块,网络层
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>([&myparse](std::string& package){
return myparse.Entry(package);
});
tsvr->InitServer();
tsvr->Start();
return 0;
}
未来Daemon函数是这样运行的。进去时是父进程,出来时就是子进程了。我们刚刚说过,守护进程是要脱离终端的。此时可以将文件描述符0、1、2直接关闭,但是如果后序有cout等就会出错。在Linux下,有一个在/dev下的字符文件null。这是一个黑洞文件,向这个文件中写入任何内容,系统会默认全部丢弃。读取内容也是什么都读不到的。对于脱离终端,可以直接关闭文件描述符,若我们不想关闭文件描述符,但已经不关心打印的结果了。此时就可以对0、1、2进行重定向到/dev/null。我们在这里再增加一个参数,表示是要直接关闭文件描述符,还是进行重定向。
#define ROOT "/"
#define devnull "/dev/null"
void Daemon(bool ischdir, bool isclose)
{
// 1. 守护进程一般要屏蔽掉特定的异常信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
// 2. 成为非组长
if(fork() > 0) exit(0);
// 3. 建立新会话
setsid();
// 4. 每个进程都会有自己的CWD,是否将当前进程的CWD更改为根目录
if(ischdir)
chdir(ROOT);
// 5. 已经编程守护进程,不需要和用户的输入输出、错误进行关联了
if(isclose)
{
::close(0);
::close(1);
::close(2);
}
else
{
int fd = ::open(devnull, O_WRONLY);
if(fd > 0)
{
// 重定向
::dup2(fd, 0);
::dup2(fd, 1);
::dup2(fd, 2);
::close(fd);
}
}
}
int main()
{
ENABLE_FILE_LOG();
Daemon(false, false);
// 1. 计算模块,应用层
Calculator mycal;
// 2. 解析报文,报文解析层
Parse myparse([&mycal](const Request& req){
return mycal.Execute(req);
});
// 3. 通信模块,网络层
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>([&myparse](std::string& package){
return myparse.Entry(package);
});
tsvr->InitServer();
tsvr->Start();
return 0;
}
第二个参数传入的是false,日志就不能向显示器打印了。我们再将我们的服务端跑起来
可以看到,此时服务端和bash已经不属于同一个会话了。这个过程就是将服务部署到了我们的云服务器上。此时可以将XShell关掉,这个服务端就在云服务器上24小时运行了。杀掉守护进程与杀掉普通进程是一样的。