目录
一、网络通信其实质是什么?
二、编写代码 --------
三、编写一个Sock类
四、Tcp_Server类的编写
五、实现一个消息来回发送的服务器和客户端
六、附加的日志函数
这篇文章我们就主要实现一下如何使用TCP进行完整的网络通信
首先我们需要了解什么事TCP,TCP是一种传输控制协议,我们暂且先记住这个。
现在我们先开始,第一呢,我们要实现客户端和服务端的网络通信,两个不同的主机之间通信是通过什么?答案是每台主机的网络IP,在我们的电脑进行上网的时候,会自动给我们的电脑分配一个该局域网内的IP。
好的,现在我们的两台主机相互的找到了对方,然后再看看我们要做的是什么,两台主机之间的通信对不对!再说清楚一点就是,我们需要使用一台主机上面的APP,给另一台主机上面的APP发消息,而APP再电脑上,启动起来叫做什么?我们对那些跑起来的应用程序用专业一点的称呼,叫做进程!!! ok,也就是需要大家明白我们实际上要实现的是! -- 两台主机中的两个进程之间的通信!!!好,这段话了解到这里。过程类似下图:
接下来我们在来引入一个新的概念:端口号,大家先接受有这个东西,前面我们知道,现在我们通过IP两个主机可以互相找到了,现在的问题是:一台主机不可能永远只跑一个进程吧,就像我们同时启动QQ,微信,腾讯视频,浏览器等等,假设我们要两台主机之间的QQ通信,我们如何在那么多的进程中偏偏唯一的,坚定的选择它呢?,这个问题很关键。那么我们的设计者是如何解决这个问题的呢?接下来是重点:我们可以给每一个进程绑定一个端口号(如何绑定代码会体现的),并且规定一个端口号只能绑定一个进程,注意嗷:这里的一个进程是可以绑定多个端口号的。这个时候,我们在进行网络通信的时候,只需要带上对方的主机IP和进程绑定的端口号,就可以精准的进行网络通信了,是不是很简单呢,哈哈哈
那么代码上我们需要做那些事呢?
1.我们需要获取一个IP对吧
2.我们需要自定义一个端口号和这个IP绑定,形成唯一的对应关系,这里的关系,生成出来的就是socket套接字即:IP+port(端口号) 。
然后我们就可以实现通信的大致逻辑了,网络通信的底层是通过硬件网卡来进行的,对这些硬件的操作需要很高的权限,所以很显然,网络相关的接口是在系统层面的!!!
那么这里为了方便,我们先将封装一个Sock类,以用于服务器更好的编写。
Sock的功能有那些呢?这个类是关于TCP协议的,我们需要知道TCP通信的时候的细节:
TCP的Sock服务编写流程---
1.创建sock套接字:使用socket函数,获取一个用于网络的fd文件描述符,TCP中这个fd也就是sock套接字,叫做监听套接字
2.绑定IP+端口号:将这个fd描述符,通过bind函数,绑定IP+端口号,实现网络通信的必要条件
3.设置监听状态:将这个fd也就是sock套接字,通过listen函数设置为监听状态,这个sock也叫监听套接字
4.接受连接:再用accept函数,通过传入监听套接字,和一个struct sockaddr_in类型的输入输出型参数,接受来自客户端的连接请求,如果连接成功则返回一个套接字,两个进程的网络通信就通过这个新套接字完成!
解释一下第3,4点,因为TCP是面向连接的,所以当我们正式通信的时候,是需要先建立连接的,那么是不是,我们服务器就需要处于一种状态来等待我们来连接呢,这种状态就是监听状态,举个例子方便大家理解,就像我们去一些餐厅吃饭,白天去的时候,我们总是能吃到饭,为什么呢?因为那些餐厅的老板总是在等待客人的到来,以便能及时的为客人服务,这个监听状态就是如此,而被用于设置监听状态的套接字,就叫做监听套接字。
好的,现在大致流程清楚了,还有个问题是,我们如何来理解监听套接字和新返回的套接字呢?!
我们来讲个小故事来方便大家理解:假设嗷,你现在去一个地方旅游,去到了一个吃饭的地方,这个地方有很多的酒楼,而每个酒楼都比较内卷,都雇了一个人,这个人什么都不做,就做一件事,就是去马路边拉客人,路边有人就去拉客拉到店里,假设一个客人是你,这个拉客的叫做李四,李四拉你去自己的酒楼里去,你同意了,于是李四把你带到酒楼里去,让后扯着嗓门喊了一声,“后勤的服务器出来一个,来客人了,招呼着”,于是就出来了一个服务员张三,于是张三就来服务你,张三服务你的时候,这时李四又跑去路边拉客人了,循环往复。
上面的例子:
监听套接字---就是拉客李四,工作就是获取新链接
返回的新套接字---就是服务员张三
这里相信大家应该理解了,两个套接字的作用和意义了。
通过上面的过程,实现了两个连接,我们就可以开始通信啦!
可以直接使用read和write进行套接字的读写。
下面我们就开始战斗!!!
首先写一个Sock类,其中包含了
1.创建sock套接字
2.绑定IP+端口号
3.设置监听状态
4.接受连接
以上的四个基本方法
//写一个Scok类
class Sock
{
public:
Sock(){} // 无用
int Socket(){} //1.创建套接字
void Bind(int sock, std::string ip, uint16_t port){} //2.实现套接字和IP、端口号的绑定
void Listen(int listensock){} //3.设置监听套接字
int Accept(){int listensock, std::string& ip, uint16_t& port} //4.建立连接
~Sock(){} // 无用
};
Socket()实现
通过上面的知识铺垫,我们需要调用socket系统函数获取套接字,如下
AF_INET:网络家族,使用的是IPV4
SOCK_STREAM:使用流式套接字,TCP是面向字节流的
0:默认就好
int Socket()
{
int _listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
logMessage(FATAL, "creat listensocke [%d]-[%s]", errno, strerror(errno));
exit(-1);
}
logMessage(NORMAL, "creat listensocke success is : %d",_listensock);
return _listensock;
}
void Bind(int sock, uint16_t port, std::string ip)实现
bind系统函数需要用到sockaddr_in这个结构体,我们直接定义初始化传参就好
// 2.绑定IP+Port
void Bind(int sock, uint16_t port, std::string ip)
{
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0)
{
logMessage(FATAL, "bind listensocke [%d]-[%s]", errno, strerror(errno));
exit(1);
}
logMessage(NORMAL, "bind listensocke success!");
}
void Listen(int listensock)实现
这个gblock,笔着现在的水平还不能详细解答,目前了解到的就是默认整型20就够了
// 3.设置监听状态
void Listen(int listensock)
{
if (listen(listensock, gblock) < 0)
{
logMessage(FATAL, "set listensocke to listen status fail [%d]-[%s]", errno, strerror(errno));
exit(1);
}
logMessage(NORMAL, "set listent status success");
}
int Accept(int listensock, std::string& ip, uint16_t& port)实现
其中的细节是,如果连接失败再进行下一次连接就行,就像是李四没有拉到客人,再去下一个客人就行,总不能就把店门关了吧
// 4.连接
int Accept(int listensock, std::string& ip, uint16_t& port)
{
struct sockaddr_in addr;
socklen_t len = sizeof addr;
while (true)
{
int serverock = accept(listensock, (struct sockaddr*)&addr, &len);
if (serverock < 0)
{
logMessage(ERROR, "get accept fail [%d]-[%s]", errno, strerror(errno));
}
else
{
logMessage(NORMAL, "accpet success [%d]-[%s]", errno, strerror(errno));
ip = inet_ntoa(addr.sin_addr);
port = ntohs(addr.sin_port);
return serverock;
}
}
}
刚刚我们把Sock封装好了,现在编写这个类就好办多了。
我们直接就是一个大招,看看下面的代码是不是很简单了呢
class Tcp_server
{
public:
Tcp_server(std::string ip = "", uint16_t port = 0)
:_ip(ip), _port(port)
{
Sock c_server;
// 1.创建监听套接字
_listensock = c_server.Socket();
// 2.绑定IP+Port
c_server.Bind(_listensock, _port, _ip);
// 3.设置监听状态
c_server.Listen(_listensock);
}
void Start()
{
Sock c_server;
// 4.连接
while (true)
{
std::string ip;
uint16_t port;
int serversock = c_server.Accept(_listensock, ip, port);
// 开始服务
server(serversock, ip, port); //服务函数等下再编写
close(serversock);
}
}
~Tcp_server()
{
close(_listensock);
}
private:
std::string _ip;
uint16_t _port;
int _listensock;
};
附上server函数:这里实现的就是一个很简单的客户端发送给服务器,服务器再发送回去的逻辑,
大家注意到了没有,我们使用的是系统文件层面的write和read对套接字进行操作,这里我们就可以看出,其实套接字就是文件描述,是不是这里把网络和Linux中的一切皆文件!!!相呼应了呢!
void server(int sock, std::string ip, uint16_t port)
{
char serverBuffer[1024];
while (true)
{
ssize_t s = read(sock, serverBuffer, (sizeof serverBuffer) - 1);
if (s < 0)
{
logMessage(ERROR, "read message fail [%d]-[%s]", errno, strerror(errno));
break;
}
else if (s == 0)
{
logMessage(FATAL, "读端关闭");
break;
}
else if (s > 0)
{
serverBuffer[s] = '\0';
std::cout << "Client --- IP:" << ip << "Port:" << " " << port << std::endl;
std::cout << serverBuffer << std::endl;
}
// 发送回去
write(sock, serverBuffer, sizeof serverBuffer - 1);
}
}
最后我们来看看Tcp_server.cc和Tcp_client.cc实现简单的客户端和服务器
先是Tcp_server.cc的
#include
#include
#include "Tcp_server.hpp"
void Usage(std::string s)
{
std::cout << "Usage "<< s << " port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(-1 );
}
std::string ip = "0.0.0.0";
uint16_t port = atoi(argv[1]);
Tcp_server server(ip, port);
server.Start();
return 0;
}
Tcp_client.cc的
注意,客户端是不用自己绑定IP和端口号的,它自己会自动分配,因为如果我们自己去绑定的话,可能会出现端口号冲突的问题,就会导致另外一个客户端起不来的情况。
#include
#include
#include
#include
#include
#include
#include "Log.hpp"
void Usage(std::string s)
{
std::cout << "Usage "<< s << "ServerIp ServerPort" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(-1);
}
// 编写客户端逻辑
// 1.想要连接的IP
// 2.想要连接的端口
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
struct sockaddr_in addr;
memset(&addr, 0, sizeof addr);
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
int sock = socket(AF_INET, SOCK_STREAM, 0);
// 客户端会自动分配端口号
if (connect(sock, (struct sockaddr*)&addr, sizeof addr) < 0)
{
logMessage(FATAL, "client connect fail [%d]-[%s]", errno, strerror(errno));
exit(-1);
}
logMessage(NORMAL, "connect success [%d]-[%s]", errno, strerror(errno));
while (true)
{
std::string line;
std::cout << "输入发送消息:";
std::getline(std::cin, line);
// write(sock, line.c_str(), sizeof line.c_str());
send(sock, line.c_str(), line.size(), 0);
char buffer[1024];
ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
if (s > 0)
{
buffer[s] = '\0';
std::cout << "recv server message:" << buffer << std::endl;
}
}
return 0;
}
我们将它们编译后,运行起来看看:
这里采用的是Centos7.6版本的操作系统
先把服务器跑起来,这里注意,我们没有指明是服务器是主机的哪一个IP地址,因为我们在代码中使用的默认的“0.0.0.0”这个IP,表示服务器任意IP
回车运行之后,我们再启动客户端
运行之后就是这样
我们来看看
我们发送一个消息试试
可以看到,我们成功的发出去了消息,并且服务器也返回了消息,这里我们就实现了一个简简单单的TCP协议的网络通信服务器;
代码中的logMessage是自己编写的一个用于打印日志信息的函数,给大家分享出来吧
#pragma once
#include
#include
#include
#include
#include
#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
#define DEBUG_SHOW
const char* gLevel[] = {
"DEBUG",
"NORMAL",
"WARNING",
"ERROR",
"FATAL"
};
void logMessage(int level, char* format, ... )
{
#ifndef DEBUG_SHOW
if (level == DEBUG) return;
#endif
// 标准
char stdBuffer[1024];
const time_t curt = time(nullptr);
struct tm* _curt = localtime(&curt);
snprintf(stdBuffer, sizeof stdBuffer, "[%s]-[%d年%d月%d日%d时%d分%d秒]", gLevel[level],
_curt->tm_year + 1900, _curt->tm_mon + 1, _curt->tm_mday, ((_curt->tm_hour + 12) % 24) + 12, _curt->tm_min, _curt->tm_sec);
// 自定义
char logBuffer[1024];
va_list args;
va_start(args, format);
vsprintf(logBuffer, format, args);
va_end(args);
FILE* fd = fopen("log_tcp_server", "w");
fprintf(fd, "%s-%s\n", stdBuffer, logBuffer);
fclose(fd);
}