文章目录
- 网络通信的基础
- 通信模型
- IP地址和端口port
- 网络套接字
- 网络字节序
- 初识UDP与TCP两种协议
- sockaddr结构体家族
- 认识一些网络常用基础函数
- UDP实现简单通信
- TCP实现简单通信
- 总结
网络通信的基础
网络通信是建立在多层协议之下,最终利用数据传输线路进行数据通信。首先必须要认识到,一般我们所使用的应用软件如QQ、微信来进行通信时,数据并不是简简单单的直接通过我们的软件跑到对方的软件那么简单,中间会经过一系列的包装与解包装才行。这就涉及到了分层传输的概念,有 OSI七层模型 和 TCP/IP四层模型,其中OSI模型是逻辑上的模型,并不是特别实用,因此大多采用TCP/IP四层模型。
通信模型
OSI七层模型:
TCP/IP五层(四层)模型:
上面的图可以看出数据在传输的时候确实是通过多层协议进行分层传输的。
那么我们作为程序员,需要关心链路层和网络层的细节吗?实际上是不需要的,主要原因在于我们程序员是搞代码的,我们不需要关注太底层的东西,程序员只需要考虑如何使用提供好的接口,完成传输层的任务就可以了,底层的细节还有整个信息在传输时的处理,全部都有写好的协议来帮我们处理。
那么就会有一个新的概念:协议
协议就是对数据的具体处理方式,面对一个信息,相应的协议会对它进行包装处理,以便于下一层理解与接受,并且适应于各种不同的信息,使得每一层信息的交流都像是在层层对话一样:在发送者看来,自己就是实打实的与接受者在交流,但是实际上是应用层通过协议把数据传给了下一层的传输层。此时对于传输层来说,又像是直接与接收者的传输层进行交流,实际上呢,又把信息再次加工传给了网络层……由此层层往下传,到最后就会传到最底层的物理层,实现真正意义上的信息交流,之后再一路向上把信息一步一步解包装给接收者的各层,最后呈现为应用层拿到了数据,接收者收到了信息,实现了完整的通信过程。
上面我们提到了数据的包装以及解包装,实际上有更专业的叫法:封装和分用
封装的过程就是数据一路到底的过程,分用的过程就是一路向上的过程,由于协议的不同,数据在每层被加入以及去除的信息也不同:
不同的协议层对数据包有不同的称谓,在传输层叫做段(segment),在网络层叫做数据报 (datagram),在链路层叫做帧(frame).
应用层数据通过协议栈发到网络上时,每层协议都要加上一个数据首部(header),称为封装(Encapsulation).首部信息中包含了一些类似于首部有多长, 载荷(payload)有多长, 上层协议是什么等信息.
数据封装成帧后发到传输介质上,到达目的主机后每层协议再剥掉相应的首部, 根据首部中的 “上层协议字段” 将数据交给对应的上层协议处理
封装:
分用:
IP地址和端口port
通信的整体过程我们了解完毕,现在的重点在于如何实现具体两台或多台计算机之间的通信。就如我们对话一般,和对方讲话是需要先知道对方的存在的,对于计算机通信中的发送者而言,则是需要知晓自己发送信息的目的地(接收者的主机),而接收者也需要知道消息从哪里来的(发送者的主机),因此统一使用IP地址来标识一台计算机的主机。例如:127.0.0.1,格式就是####.####.####.####,有四个数字组成(每个数字只有1字节大小),并用小数点分隔开,也称“点分十进制”,大小总共是4个字节(主机转网络时的大小,后面会讲)。
端口唯一标识一个正在进行网络通信的进程。之所以要精准到进程是因为通信一般都是应用程序间的交流,要想实现通信的准确性,就必须要发送给对应主机的对应进程。端口号是一个2字节的无符号整形数字。
🔺注意端口号与进程PID的区别,PID是唯一标识一台计算机内的进程,而端口则是在进程通信的时候才会与对应的进程绑定在一起,两者不能等效。
网络套接字
网络套接字实际上就是一个结构体,里面需要我们配置各种网络传输的属性,这里有一些需要强调的相关知识点:网络字节序、UDP与TCP两种协议。
网络字节序
提到过我们程序员只负责传输层的代码实现,因此也就意味着我们需要网络层的IP,在传输层实现对端口的绑定以及数据的接收和发送工作,那么这里会自然而然涉及到数据的有序问题,我们知道计算机在存储数据时会分为大端和小端存储:
发送者与接收者的存储形式若是出现了相反的情况,就会出现通信错误的情况。为了避免这种情况的出现,我们需要统一信息发送与接收的格式:大端
不管是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据。如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可。
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换:
这几个函数都非常简单,意思也很好理解:h是host的缩写,代表主机序列;而n是network的缩写,代表网络序列;中间的to则是代表转换的方向。结尾的l代表是4字节的数据,s代表是2字节的数据。
例如:本地端口port转网络字节序
htons(port);//函数的返回值就是转化好的数据
初识UDP与TCP两种协议
UDP和TCP是两种不同的传输协议,在套接字中也有所体现,更直观的属性可以直接列个表更容易观察:
UDP和TCP中TCP更加常用,网络传输中的稳定性是非常重要的一个因素。我们接下来可以先接触UDP的使用,原因在于TCP的使用其实是在建立在UDP上的基础之上。可以算是对UDP的完善吧,目前以我的认识是这样的。
sockaddr结构体家族
我们不是要谈套接字吗?这就来了!套接字地址是一个结构体,但是除了sockaddr,还有sockaddr_in和sockaddr_un这两种结构体,原因在于网络协议的不同(网络在发展的过程中的协议并不是都是同一的)。
其中sockaddr_in用于网络通信,而sockaddr_un用于本地通信,那么第一个sockaddr是不是显得很多余?其实这样设计是有原因的:无论是本地通信还是网络通信,我们都希望可以使用同一套操作流程以及接口,那么就需要将不同的结构体进行处理,能够在一个接口里面被统一使用,不难观察,sockaddr_in和sockaddr_un的起始数据都是16位的地址,那么我们可以对他们的指针类型强转为sockaddr类型的,然后接口内部会通过判断开头的16位数据,决定到底是网络通信还是本地通信。
上图是关于sockaddr_in的结构体源代码,观察里面的成员变量,其中sin_family就是域,决定是网络通信还是本地通信;sin_port为端口号;sin_addr为ip地址,不难看出最终该数据类型就是uint32_t的无符号四字节整形数字,也就是把点分十进制的ip由某个函数转成了数字,并且完成了字节序的处理工作。
上述涉及到ip地址转化的函数如下:
函数名:inet_aton
参数:
cp:点分十进制类型的ip地址
inp: sockaddr_in结构中的sin_addr(ip地址的封装结构体)
功能:将点分十进制的ip放进对应的套接字中(本机转网络)
返回值:成功返回非零值;失败返回0。
函数名:inet_ntoa
参数:
in: sockaddr_in结构中的sin_addr(ip地址的封装结构体)
共能:将套接字中的四字节ip转化成点分十进制的ip地址(网络转主机)
返回值:点分十进制类型的ip地址
认识一些网络常用基础函数
函数:socket、bind、recvfrom、sendto 、listen、accept、connect
函数名:socket(用于UDP/TCP)
功能:根据所传参数创建一个套接字(以某种方式读写的文件描述符),类似于缓存区。主要用来进行数据的传输和接收。
参数:
domain: AF_INET(网络通信)、AF_UNIX, AF_LOCAL(本地通信)
type: SOCK_DGRAM(UDP协议)、SOCK_STREAM(TCP协议)
protocol: 0(默认传值)
返回值:成功返回一个文件描述符;失败返回-1,并设置errno。
函数名:bind(用于UDP/TCP)
功能:进程绑定端口号
参数:
sockfd: 套接字
my_addr:套接字地址结构体指针
addrlen: 套接字地址的大小
返回值:执行成功返回0,否则返回-1, 并设置错误代码。
函数名:recvfrom(用于UDP)
参数:
sockfd: 套接字
buf: 用于存放接收数据的空间首地址
len: 存储空间大小
flags: 默认为0
addr: 远端套接字地址(强转成struct sockaddr*,该参数为输出型参数,获取远端套接字地址)
len: 远端套接字地址的大小(输出型参数)
功能:从远端获取信息通过sockfd(套接字)放到buf中
返回值:成功返回接收字节数,失败返回值-1,并设置errno
函数名:sendto(用于UDP)
参数:
sockfd: 套接字
buf: 发送数据的空间首地址
len: 发送数据的大小
flags: 默认为0
addr: 远端套接字地址(强转成const struct sockaddr*,该参数为输入型参数,注意区分于recvfrom函数)
addr_len: 远端套接字地址的大小
功能:通过sockfd(套接字)向远端发送buf内len大小的信息
返回值:成功返回发送的字节数,失败返回值-1,并设置errno
函数名:listen(用于TCP)
参数:
s:监听套接字(是服务端用来专门监听的套接字)
backlog:指定未完成连接队列的最大长度.如果一个连接请求到达时未完成,连接队列已满,那么客户端将接收到错误 ECONNREFUSED. 或者,如果下层协议支持重发,那么这个连接请求将被忽略,这样客户端在重试的时候就有成功的机会。
返回值:函数执行成功时返回0.错误时返回-1,并置相应错误代码 errno
功能:在一个套接字上倾听连接(和UDP主要的不同点:有连接特性!)
函数名:accept(TCP)
参数:
s:监听套接字
addr:远端套接字地址(强转成struct sockaddr*,该参数为输出型参数,获取远端套接字地址)
addrlen: 远端套接字地址的大小(输出型参数)
返回值:此调用在发生错误时返回-1.若成功则返回一个非负整数标识这个连接套接字。(只用于与远端的通信的套接字)
功能:获取远端网络信息和用于连接的套接字
函数名:connect(用于TCP)
参数:
sockfd:连接套接字
addr: 远端套接字地址(强转成const struct sockaddr*,该参数为输入型参数,注意区分于listen函数)
addr_len: 远端套接字地址的大小
返回值:函数执行成功时返回0.错误时返回-1,并置相应错误代码 errno
功能:连接远端与一个套接字
上面介绍的函数中:recvfrom、sendto函数(UDP) 与 accept、connect函数(TCP)有些许相似之处,要注意区分。
UDP实现简单通信
在实现的时候,会出现许多需要记录日志的地方,因此在写有关通信的代码之,先搞一个日志输出的头文件:
//Log.hpp
#pragma once
#include <cstdio>
#include<ctime>
#include<cstdarg>
#include<cassert>
#include<cstring>
#include<cerrno>
#include<stdlib.h>
#define DEBUG 0 //测试
#define NOTICE 1 //注意
#define WARINING 2 //警告
#define FATAL 3 //错误
const char* log_level[]={"DEBUG","NOTICE","WARINING","FATAL"};
void logMessage(int level,const char* format,...) //可变参数,可以不用懂,会用就行
{
assert(level>=DEBUG);
assert(level<=FATAL);
char* name=getenv("USER"); //获取用户的用户名
char logInfo[1024];
va_list ap;
va_start(ap,format);
vsnprintf(logInfo,sizeof(logInfo)-1,format,ap);
va_end(ap);
FILE* out=(level==FATAL)?stderr:stdout;
fprintf(out,"%s | %u | %s | %s\n",\
log_level[level],(unsigned int)time(nullptr),name==nullptr?"unknow":name,logInfo);//最终的输出样式
}
实现的模型:客户端与服务端的通信
服务端:
//udpServer.cc
//需要用到的头文件
#include <iostream>
#include <string>
#include <unordered_map>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>
#include "Log.hpp"
using namespace std;
static void Usage(const string proc) //使用手册
{
cout << "Usage:\n\t" << proc << "port [ip]" << endl;
}
class UdpServer//封装服务器
{
private:
int sockfd_;//套接字
uint16_t port_;//端口号
string ip_; //IP地址
unordered_map<string,struct sockaddr_in> users_; //客户端ip+port 与 套接字地址的关联,记录已经通信的客户(客户表)
public:
UdpServer(int port, string ip = "") : ip_(ip), port_(port), sockfd_(-1)//初始化端口和ip地址,套接字设为-1
{
}
~UdpServer() {}
public:
void checkOnlineUser(string& ip,uint32_t port,struct sockaddr_in peer)//检测发送消息的客户端是否是新客户,不是则加入客户表中
{
string key=ip;
key+=":";
key+=to_string(port);
auto it=users_.find(key);
if(users_.end()==it)
{
users_.insert({key,peer});
}
else
{
//do nothing
}
}
void messageRoutine(string& ip,uint32_t port, string info)
{
string message ="[";
message+=ip;
message+=":";
message+=to_string(port);
message+="]:";
message+=info;
for(auto& user:users_)//消息广播,所有的用户都可以收到消息
{
sendto(sockfd_,message.c_str(),message.size(),0,(const struct sockaddr*)&(user.second),sizeof(user.second));
}
}
void init()//服务器启动之前的初始化
{
// 创建套接字,SOCK_DGRAM模式用于实现UDP通信
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ < 0)
{
logMessage(FATAL, "%s:%d", strerror(errno), sockfd_);
exit(1);
}
logMessage(NOTICE, "socket create success: %d", sockfd_);
// 绑定网络,填充基本信息到结构为sockaddr_in的local套接字地址中
struct sockaddr_in local;
bzero(&local, sizeof(local));//清空结构体
local.sin_family = AF_INET; // 填充协议家族,域;AF_INET表示网络通信
local.sin_port = htons(port_); // 填充服务器端口号
// 填充IP,IP为空的话就接受任意IP地址远端的消息(INADDR_ANY)
local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
if (bind(sockfd_, (const struct sockaddr *)&local, sizeof(local)) == -1)//套接字、套接字地址与服务器进程进行绑定
{
logMessage(FATAL, "bind: %s:%d", strerror(errno), sockfd_);
exit(2);
}
logMessage(NOTICE, "socket bind success: %d", sockfd_);
}
void start()//启动服务器
{
char inbuffer[1024];
char outbuffer[1024];
struct sockaddr_in peer; // 远端(对服务器来说就是客户端)
socklen_t len = sizeof(peer);
while (true)//死循环轮询是否有客户端发送消息
{
ssize_t s = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s >= 0)
{
inbuffer[s] = 0;//有消息,加上'\0'是为了以字符串的形式去使用信息
}
else if (s == -1)
{
logMessage(WARINING, "recvfrom: %s", strerror(errno));
continue;//没有信息,继续轮询检测
}
string peerIp = inet_ntoa(peer.sin_addr);//从peer套接字地址(客户端)中获取远端IP,调用关于IP地址从网络转主机的函数。
uint32_t peerPort = ntohs(peer.sin_port);//从peer套接字地址(客户端)中获取远端的port
checkOnlineUser(peerIp,peerPort,peer);//查看客户表,不在就将其加入
logMessage(NOTICE, "[%s:%d]# %s", peerIp.c_str(), peerPort, inbuffer);
string info(inbuffer,inbuffer+s);//使用string容器构造信息
messageRoutine(peerIp,peerPort,info); //信息输出(广播)
}
}
};
int main(int argc, char *argv[])
{
if (argc != 2 && argc != 3)
{
Usage(argv[0]);
exit(3);
}
uint16_t port = atoi(argv[1]); //获取端口
string ip;
if (argc == 3)
{
ip = argv[2];//获取ip地址
}
UdpServer svr(port, ip); //服务端的实例化
svr.init(); //服务端的初始化
svr.start(); //服务端的开始
return 0;
}
客户端:
#include <iostream>
#include <string>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <cassert>
using namespace std;
static void Usage(string name)
{
cout << "Usage:\n\t" << name << " server_ip server_port" << endl;
}
void *recveAndPrint(void *args)
{
while (true)//一直处于轮询检测状态
{
int sockfd = *(int *)args;
char rev[1024];
struct sockaddr_in tmp;//由于只用到了消息,没用到远端的基本信息,这里仅仅只是为了满足函数的参数要求
socklen_t len = sizeof(tmp);
ssize_t s = recvfrom(sockfd, rev, sizeof(rev) - 1, 0, (struct sockaddr *)&tmp, &len);
if (s > 0)
{
rev[s] = 0;
cout << "server echo# " << rev << endl;//输出收到的消息
}
}
return nullptr;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
string server_ip = argv[1];//服务端的IP地址
uint16_t server_port = atoi(argv[2]);//服务端的端口
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);//创建套接字,SOCK_DGRAM模式用于实现UDP通信
assert(sockfd > 0);
struct sockaddr_in server;//填充套接字
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
pthread_t t;
pthread_create(&t, nullptr, recveAndPrint, (void *)&sockfd);//创建一个线程用于信号的接收
//客户端不需要绑定自己的端口和IP地址:由于一台计算机可能会有多个客户端同时运行,主动绑定端口可能会出现端口重复的问题,所以一般都是再发送信息的时候计算机自动帮我们用户绑定的。
string buffer;
while (true)
{
usleep(100);
cout << "please enter# ";
getline(cin, buffer);
sendto(sockfd, buffer.c_str(), buffer.size(), 0, (const struct sockaddr *)&server, sizeof(server));
}
return 0;
}
大致流程:
先跑一下代码看看效果:
开启三个终端,分别运行服务器和两个客户端
客户端数据的广播与客户输入提示符有点冲突,但是并不影响整体的功能实现。
在运行服务器的时候,并没有直接绑定确定的ip端口,事实上这样做是被推荐的。接受任意IP地址的信息是一种比较好的处理方式。(实际上云服务器也并不支持确定IP地址的绑定操作)而客户端连接的IP地址为127.0.0.1,这个IP地址有点特殊,称之为本地环回地址,可直接用于一台主机的两个不同进程间的网络通信,也可以用真实的服务器IP地址去访问服务器,若是两台不同的计算机之间通信,就必须要用真实的IP地址了。
TCP实现简单通信
这里同样是建立服务器与客户端之间的通信,但是不同于UDP那么简单了,在绑定操作之后还有监听和接收两个操作。在实现TCP时,预备添加更复杂一点的操作,比如添加任务类、日志记录、服务器进程更改为后台进程(守护进程或者精灵进程)、线程池的处理需求方式等等。
其中需要点明的一个知识点就是后台进程,所谓的后台进程,不与任何控制终端相关联,并且周期性地执行某种任务或等待处理某些发生的事件(处理一些系统级的任务)。这样我们一旦启动服务器进程,就会直接脱离当前终端,不会再输出任何运行服务器的日志,转而我们可以设计把日志保存到一个文件中。我这个程序是在云服务器下运行的,这种情况下即使退出了整个终端,服务器仍然会在后台运行,客户端仍可以继续连接服务器使用服务。如果再加入信息广播,那么这就是个简易版的聊天室。
那么既然谈到了后台进程,就得考虑如何将服务端进程转化为后台进程了:
//daemonize.hpp
#pragma once
#include <cstdio>
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
void daemonize()
{
int fd = 0;
//忽略一些信号
signal(SIGPIPE,SIG_IGN);
//创建进程后直接结束父进程
if(fork()>0)
{
exit(0);
}
//调用setsid()函数,使得子进程成为一组进程的组长
setsid();
//打开特殊文件“/dev/null”,相当于回收站,一切输入的数据都会被忽略
if((fd=open("/dev/null",O_RDWR))!=-1)
{
//三次重定向使得所有的输出都指向回收文件
dup2(fd,0);
dup2(fd,1);
dup2(fd,2);
if(fd>2) close(fd);//关闭特殊文件描述符,避免文件描述符泄露
}
}
上面这个函数调用完之后就会使得进程成为后台进程。
接下来任务类:
//Task.hpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
#include "Log.hpp"
using namespace std;
class Task
{
public:
using callback_t = function<void (int, string, uint16_t)>;//方法函数的返回值是void,参数是int、string、uint16_t
Task() : sock_(-1), port_(-1)
{}
Task(int sock, string ip, uint16_t port, callback_t func)//任务的构造函数
: sock_(sock), ip_(ip), port_(port), func_(func)
{}
~Task()
{}
void operator()()//重载(),进行任务的处理
{
logMessage(DEBUG, "线程[%p]处理 %s[%d]的请求, begin...", pthread_self(), ip_.c_str(), port_);
func_(sock_, ip_, port_);
logMessage(DEBUG, "线程[%p]处理 %s[%d]的请求, end...", pthread_self(), ip_.c_str(), port_);
}
private:
int sock_;//网络套接字
string ip_;//IP地址
uint16_t port_;//端口
callback_t func_; // 任务的回调函数
};
单例线程池:
//ThreadPool.hpp
#pragma once
#include <iostream>
#include <cstdio>
#include <cassert>
#include <pthread.h>
#include <unistd.h>
#include <queue>
#include <string>
using namespace std;
#define NUM 10
template <class T>
class ThreadPool
{
private:
ThreadPool(const int &threadNum = NUM) : threadNum_(threadNum), isStart_(false)
{
assert(threadNum_ > 0);
//初始化锁和条件变量
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
ThreadPool(const ThreadPool<T> &) = delete; // 删除拷贝构造
void operator=(const ThreadPool<T> &) = delete; // 删除赋值构造
public:
~ThreadPool()
{
//销毁锁和条件变量
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&cond_);
}
int threadNum()//获取线程个数
{
return threadNum_;
}
static ThreadPool<T> *getInstance() // 申请类的对象
{
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
if (instance == nullptr)
{
pthread_mutex_lock(&mutex);
if (instance == nullptr)
{
instance = new ThreadPool<T>();
}
pthread_mutex_unlock(&mutex);
}
return instance;
}
static void *threadpool(void *arg) //线程的回调函数
{
pthread_detach(pthread_self());//线程分离
ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(arg);
while (true)//抢任务
{
tp->lockQueue();
while (!tp->haveTask())
{
tp->waitForTask();
}
T tmp = tp->pop();
tp->unlockQueue();
tmp();//处理任务
}
return nullptr;
}
void start()//线程池初始化,创建若干个线程
{
assert(!isStart_);
for (int i = 0; i < threadNum_; ++i)
{
pthread_t tmp;
pthread_create(&tmp, nullptr, threadpool, this);
}
}
void put(const T &data)//放入任务
{
lockQueue();
taskQueue_.push(data);
unlockQueue();
choiceThreadForHandler();//选择一个线程来处理任务
}
private:
void lockQueue() { pthread_mutex_lock(&mutex_); }
void unlockQueue() { pthread_mutex_unlock(&mutex_); }
bool haveTask() { return !taskQueue_.empty(); }
void waitForTask() { pthread_cond_wait(&cond_, &mutex_); }
void choiceThreadForHandler() { pthread_cond_signal(&cond_); }
T pop()//删除一个任务
{
T temp = taskQueue_.front();
taskQueue_.pop();
return temp;
}
private:
bool isStart_;//线程池是否已经启动
int threadNum_;//线程个数
queue<T> taskQueue_;//任务队列
pthread_mutex_t mutex_;//锁
pthread_cond_t cond_;//条件变量
static ThreadPool<T> *instance;//单例指针
};
template <class T>
ThreadPool<T> *ThreadPool<T>::instance = nullptr; // 懒汉模式
日志输出:(与UDP的日志输出不同点在于输出文件改成了日志文件)
//Log.hpp
#pragma once
#include <cstdio>
#include <ctime>
#include <cstdarg>
#include <cassert>
#include <cstring>
#include <cerrno>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define DEBUG 0
#define NOTICE 1
#define WARINING 2
#define FATAL 3
#define LOGFILE "serverTcp.log"//日志文件名
const char *log_level[] = {"DEBUG", "NOTICE", "WARINING", "FATAL"};
void logMessage(int level, const char *format, ...)
{
assert(level >= DEBUG);
assert(level <= FATAL);
char *name = getenv("USER");
char logInfo[1024];
va_list ap;
va_start(ap, format);
vsnprintf(logInfo, sizeof(logInfo) - 1, format, ap);
va_end(ap);
FILE *out = (level == FATAL) ? stderr : stdout;
umask(0);
int fd = open(LOGFILE, O_CREAT | O_APPEND | O_WRONLY, 0666);//以只写的方式追加打开日志文件
assert(fd >= 0);
//对标准输出和标准错误流进行重定向,指向日志文件
dup2(fd, 1);
dup2(fd, 2);
fprintf(out, "%s | %u | %s | %s\n",
log_level[level], (unsigned int)time(nullptr), name == nullptr ? "unknow" : name, logInfo);
fflush(out); // 将C缓冲区中的数据刷新到OS
fsync(fd); // 将OS中的数据尽快刷盘
close(fd);
}
服务端与客户端共同使用的头文件集合:
//util.hpp
#pragma once
using namespace std;
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <cassert>
#include <ctype.h>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <signal.h>
#include <pthread.h>
#include "Log.hpp"
#include "Task.hpp"
#include "ThreadPool.hpp"
#include "daemonize.hpp"
#define SOCKET_ERR 1
#define BIND_ERR 2
#define LISTEN_ERR 3
#define USAGE_ERR 4
#define CONN_ERR 5
#define BUFFER_SIZE 1024
服务端:
//serverTCP.cc
#include "util.hpp"
void execCommand(int sock, const string &clientIp, uint16_t clientPort)//程序替换功能函数
{
assert(sock > 0);
assert(!clientIp.empty());
assert(clientPort >= 1024);
char command[BUFFER_SIZE];
while (true)
{
ssize_t s = read(sock, command, sizeof(command) - 1);//通过read直接从连接套接字中读取信息
if (s > 0)
{
command[s] = '\0';
logMessage(DEBUG, "[%s:%d] exec [%s]", clientIp.c_str(), clientPort, command);
string safe = command;
if ((string::npos != safe.find("rm")) || (string::npos != safe.find("unlink")))
{
logMessage(WARINING,"This is not a safe command!");
continue;
}
FILE *fp = popen(command, "r");//调用popen函数直接实现程序替换,并使得输出结果存储在fp指向的文件中,r为文件的读权限
if (fp == nullptr)
{
logMessage(WARINING, "%s,exec %s failed", strerror(errno), command);
break;
}
char message[BUFFER_SIZE];
while (fgets(message, sizeof(message) - 1, fp) != nullptr)//从fp中拿取程序替换结果到message中
{
write(sock, message, strlen(message));//将程序替换的结果通过连接套接字写回到远端
}
pclose(fp);//关闭因程序替换而打开的文件描述符
logMessage(DEBUG, "[%s:%d] exec [%s] done...", clientIp.c_str(), clientPort, command);
}
else if (s == 0)
{
logMessage(DEBUG, "client quit-->%s[%d]", clientIp.c_str(), clientPort);
break;
}
else
{
logMessage(DEBUG, "client %s[%d],read: %s", clientIp.c_str(), clientPort, strerror(errno));
break;
}
}
close(sock);//关闭连接套接字
}
class ServerTcp
{
private:
uint16_t port_;//端口号
string ip_;//IP地址
int listenSock_;//监听套接字
ThreadPool<Task> *tp_;//线程池指针
public:
ServerTcp(uint16_t port, const string ip = "")//初始化各成员变量
: ip_(ip), port_(port), listenSock_(-1)
{}
~ServerTcp()
{}
void init()
{
// 创建监听套接字,SOCK_STREAM决定了是TCP通信
listenSock_ = socket(AF_INET, SOCK_STREAM, 0);
if (listenSock_ < 0)
{
logMessage(FATAL, "sock %s", strerror(errno));
exit(SOCKET_ERR);
}
logMessage(DEBUG, "sock %s:%d", strerror(errno), listenSock_);
// 填充基本的网络信息
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port_);
ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr));
// 绑定套接字与网络信息
if (bind(listenSock_, (const struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "bind %s", strerror(errno));
exit(BIND_ERR);
}
logMessage(DEBUG, "bind %s:%d", strerror(errno), listenSock_);
// 监听套接字
if (listen(listenSock_, 5) < 0)
{
logMessage(FATAL, "listen %s", strerror(errno));
exit(LISTEN_ERR);
}
logMessage(DEBUG, "listen %s:%d", strerror(errno), listenSock_);
// 获取线程池对象(单例模式)
tp_ = ThreadPool<Task>::getInstance();
}
void loop()
{
// 启动线程池
tp_->start();
logMessage(DEBUG, "ThreadPool start success, threadNum is %d", tp_->threadNum());
// 死循环响应客户端网络请求
while (true)
{
// 为获取远程网络信息做准备
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
//使用accept函数,拿到连接套接字
int serviceSock = accept(listenSock_, (struct sockaddr *)&peer, &len);
//通过套接字地址peer获得远端IP和port
string peerIp = inet_ntoa(peer.sin_addr);
uint16_t peerPort = ntohs(peer.sin_port);
if (serviceSock < 0)
{
logMessage(FATAL, "accept %s[%d]", strerror(errno), serviceSock);
continue;
}
logMessage(DEBUG, "accept %s | %s[%d], sock: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock);
Task t(serviceSock, peerIp, peerPort, execCommand);//创建任务
tp_->put(t);//往线程池里面放入任务,线程池会自动进行任务处理
}
}
};
static void Usage(std::string proc)//使用手册
{
std::cerr << "Usage:\n\t" << proc << " port ip" << std::endl;
std::cerr << "example:\n\t" << proc << " 8080 127.0.0.1\n"
<< std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2 && argc != 3)
{
Usage(argv[0]);
exit(USAGE_ERR);
}
uint16_t port = atoi(argv[1]);
string ip;
if (argc > 2)
ip = argv[2];
daemonize(); //使进程变成后台进程
ServerTcp svr(port, ip);
svr.init();//服务端初始化
svr.loop();//开始服务
return 0;
}
客户端:
//clientTcp.cc
#include "util.hpp"
volatile bool quit = false;
static void Usage(std::string proc)
{
std::cerr << "Usage:\n\t" << proc << " serverIp serverPort" << std::endl;
std::cerr << "Example:\n\t" << proc << " 127.0.0.1 8081\n"
<< std::endl;
}
int main(int argc,char* argv[])
{
if(argc!=3)
{
Usage(argv[0]);
exit(USAGE_ERR);
}
string serverIp = argv[1];
uint16_t serverPort = atoi(argv[2]);
int sock=socket(AF_INET,SOCK_STREAM,0);// 创建套接字,SOCK_STREAM决定了是TCP通信
if(sock<0)
{
cerr<<"socket: "<<strerror(errno)<<endl;
exit(SOCKET_ERR);
}
struct sockaddr_in server;
memset(&server,0,sizeof(server));
server.sin_family=AF_INET;
server.sin_port=htons(serverPort);
inet_aton(serverIp.c_str(),&server.sin_addr);
if(connect(sock,(const struct sockaddr*)&server,sizeof(server))!=0)//与远端建立连接(TCP特点)
{
cerr<<"connnect: "<<strerror(errno)<<endl;
exit(CONN_ERR);
}
cout<<"client connnect success,sock: "<<sock<<endl;
string message;
while(!quit)
{
message.clear();
cout<<"please enter# ";
getline(cin,message);
if(strcasecmp(message.c_str(),"quit")==0)
{
quit=true;
}
ssize_t s=write(sock,message.c_str(),message.size());//客户端写入数据
if(s>0)
{
message.resize(1024);
ssize_t s=read(sock,(void*)message.c_str(),1024);//拿取从远端获取的信息反馈
if(s>0)
{
message[s]='\0';
}
cout<<"server echo# "<<message.c_str()<<endl;
}
else if(s<=0)
{
break;
}
}
close(sock);//释放套接字的文件描述符
return 0;
}
上面的代码看起来很吓人,其实都是一些比较基础的操作的结合,只不过是内容稍微丰富了一点,可以慢慢看着代码注释琢磨。
运行实例:
运行结果符合总体预期。
除了本地环回地址,实名IP地址也可以访问:
总结
这一篇的知识比较多,但是核心内容就是围绕套接字的网络内容,熟悉之后就能很快熟练掌握。实在有不懂的地方,可以在评论区提问,大家一起学习讨论。