上一节,我们用了udp写了一个服务端和客户端之间通信的代码,只要函数了解认识到位,上手编写是很容易的。
本章我们开始编写tcp的服务端和客户端之前通信的代码,要认识一批新的接口,并将我们之前学习的系统知识加进来,做到融会贯通…
对于TCP服务器和UDP服务器的初始化接口,确实有一些相似之处,但是它们在选择字节流进行初始化方面存在一些区别。
class Task
{
// ....
private:
int sock_; // 给用户提供IO服务的sock
uint16_t port_; // client port
std::string ip_; // client ip
callback_t func_; // 回调方法
};
因为每个源文件都要包好多相同的头文件,所以我们将要用到的头文件一并打包在一个头文件里:
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "log.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
一般涉及到struct sockaddr_in
,都要包含这两个头文件:
TCP是面向字节流的:
全双工(Full Duplex)和半双工(Half Duplex)是通信中两种不同的传输模式:
套接字和管道:
int inet_aton(const char *cp, struct in_addr *inp);
in_addr
结构体(inp)中。inet_aton
函数的第一个参数是要转换的IP地址字符串,第二个参数是存储转换结果的结构体指针。listen
函数用于将一个已经建立连接的套接字(通常是一个服务端的套接字)标记为被动模式,开始监听来自客户端的连接请求。
它接受两个参数:sockfd是要设置为被动模式的套接字文件描述符,backlog是指定等待连接队列的最大长度。
accept第一个参数监听到了之后,然后返回一个值之后,再继续去监听。
listen的第二个参数我们以后再讲…
监听socket
,为何要监听呢?
下面的初始化就和之前udp的初始化大差不差了…
void init()
{
// 1. 创建socket
listenSock_ = socket(PF_INET, SOCK_STREAM, 0);
if (listenSock_ < 0)
{
logMessage(FATAL, "socket: %s", strerror(errno));
exit(SOCKET_ERR);
}
logMessage(DEBUG, "socket: %s, %d", strerror(errno), listenSock_);
// 2. bind绑定
// 2.1 填充服务器信息
struct sockaddr_in local; // 用户栈
memset(&local, 0, sizeof local);
local.sin_family = PF_INET;
local.sin_port = htons(port_);
ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr));
// 2.2 本地socket信息,写入sock_对应的内核区域
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_);
// 3. 监听socket,为何要监听呢?tcp是面向连接的!
if (listen(listenSock_, 5 /*后面再说*/) < 0)
{
logMessage(FATAL, "listen: %s", strerror(errno));
exit(LISTEN_ERR);
}
logMessage(DEBUG, "listen: %s, %d", strerror(errno), listenSock_);
// 走到这就意味着允许别人来连接你了
// 4. 加载线程池
// tp_ = ThreadPool::getInstance();
}
accept
函数用于接受客户端连接的请求。它被用于一个已经处于被动监听状态的套接字(通常是服务端的套接字)。
当有新的客户端连接请求到达时,accept
函数将会返回一个新的套接字文件描述符,此后服务端就可以通过这个新的套接字与客户端进行通信。
sockfd
表示要接受连接的套接字文件描述符。addr
指向保存客户端地址信息的结构体指针(可以传入NULL)。addrlen
表示addr
结构体的长度。后面两个参数和
recvfrom
后两个参数的含义一模一样,是想拿到是哪个客户端连接的。
sockfd
是套接字描述符: 用来获取新连接的套接字,叫做监听socket
。指定的网络地址和端口
,等待客户端的连接请求。socket
,主要是进行IO
。与客户端的套接字建立连接
,用于后续的数据传输。accept
函数的阻塞:
accept
函数是在网络编程中用于接受客户端连接的函数。accept
函数时,如果有客户端连接请求到达,它会立即返回一个新的套接字来与该客户端进行通信。accept
函数将会阻塞,即一直等待直到有新的连接请求到达为止。在阻塞状态下,程序会停止执行后续代码,直到有新的连接请求到达或者发生错误。因此,可以将
accept
函数放在一个循环中,反复接受多个客户端连接。需要注意的是,在某些情况下,可以通过设置套接字为非阻塞模式来避免accept
函数的阻塞,这样程序可以继续执行其他操作。
void loop()
{
tp_->start();
logMessage(DEBUG, "thread pool start success, thread num: %d", tp_->threadNum());
while (true)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 4. 获取连接, accept 的返回值是一个新的socket fd ??
// 4.1 listenSock_: 监听 && 获取新的链接-> sock
// 4.2 serviceSock: 给用户提供新的socket服务
int serviceSock = accept(listenSock_, (struct sockaddr *)&peer, &len);
if (serviceSock < 0)
{
// 获取链接失败
logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock);
continue;
}
// 4.1 获取客户端基本信息
uint16_t peerPort = ntohs(peer.sin_port);
std::string peerIp = inet_ntoa(peer.sin_addr);
logMessage(DEBUG, "accept: %s | %s[%d], socket fd: %d",
strerror(errno), peerIp.c_str(), peerPort, serviceSock);
// 提供服务....
}
}
提供的服务,将小写转成大写:
// 大小写转化服务
// TCP && UDP: 支持全双工
void transService(int sock, const std::string &clientIp, uint16_t clientPort)
{
assert(sock >= 0);
assert(!clientIp.empty());
assert(clientPort >= 1024);
char inbuffer[BUFFER_SIZE];
while (true)
{
ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我们认为我们读到的都是字符串
if (s > 0)
{
// read success
inbuffer[s] = '\0';
if (strcasecmp(inbuffer, "quit") == 0)
{
logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
break;
}
logMessage(DEBUG, "trans before: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer);
// 可以进行大小写转化了
for (int i = 0; i < s; i++)
{
if (isalpha(inbuffer[i]) && islower(inbuffer[i]))
inbuffer[i] = toupper(inbuffer[i]);
}
logMessage(DEBUG, "trans after: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer);
write(sock, inbuffer, strlen(inbuffer));
}
else if (s == 0)
{
// pipe: 读端一直在读,写端不写了,并且关闭了写端,读端会如何?s == 0,代表对端关闭
// s == 0: 代表对方关闭,client 退出
logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
break;
}
else
{
logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno));
break;
}
}
// 只要走到这里,一定是client退出了,服务到此结束
close(sock);
logMessage(DEBUG, "server close %d done", sock);
}
recvfrom
和sendto
是专门针对udp发送用户数据报的,它是一 个固定大小的报文,在那里它是专函数专用的,专门为udp提供的。而tcp就通用的多,因为tcp是流式服务,我们这里直接可以当做是处理文件的方式来进行读写。
如果一个进程对应的文件fd,打开了没有被归还,这种现象叫做文件描述符泄漏!
connect
是一个系统调用函数,用于建立与远程主机的连接。它通常用于创建客户端套接字,并将其连接到服务器套接字。
sockfd
:套接字文件描述符,由socket函数创建获得。addr
:指向远程主机的地址结构体的指针,可以是struct sockaddr_in
或struct sockaddr_in6
。addrlen
:远程主机地址结构体的长度。connect 会自动帮我们进行bind!
connect
函数通过sockfd
和addr
参数指定的地址信息,将本地套接字与远程主机的套接字连接起来。如果连接成功,返回0;如果连接失败,返回-1,并设置全局变量errno表示错误类型。
注意:在使用connect函数之前,必须先创建一个套接字,并确保套接字是可用的,可以使用socket函数进行创建。
三个问题:
有了上面的分析,再加上之前udp编写的基础,我们很容易就能将tcp的客户端编写完成:
#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;
}
// ./clientTcp serverIp serverPort
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(USAGE_ERR);
}
std::string serverIp = argv[1];
uint16_t serverPort = atoi(argv[2]);
// 1. 创建socket SOCK_STREAM
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
std::cerr << "socket: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
// 2. connect,发起链接请求,你向谁发起请求呢??当然是向服务器发起请求喽!
// 2.1 先填充需要连接的远端主机的基本信息
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);
// 2.2 发起请求,(隐性的概念)connect 会自动帮我们进行bind!
if (connect(sock, (const struct sockaddr *)&server, sizeof server) != 0)
{
std::cerr << "connect: " << strerror(errno) << std::endl;
exit(CONN_ERR);
}
std::cout << "info : connect success: " << sock << std::endl;
std::string message;
while (!quit)
{
message.clear();
std::cout << "请输入你的消息>>> ";
std::getline(std::cin, message);
if (strcasecmp(message.c_str(), "quit") == 0)
quit = true;
// 向服务器发消息
ssize_t s = write(sock, message.c_str(), message.size());
std::cout << "read before" << std::endl;
if (s > 0)
{
message.resize(1024);
ssize_t s = read(sock, (char *)(message.c_str()), 1024);
if (s > 0) message[s] = 0;
std::cout << "Server Echo>>> " << message << std::endl;
}
else if (s <= 0)
{
break;
}
}
close(sock);
return 0;
}
日志重定向:
之前我们将日志全部都打印在显示器上,这次我们将日志全部都打印到一个文件中,方便以后查看:
// 提供服务, echo -> 小写 -> 大写
// 0.0 版本 -- 单进程 -- 一旦进入transService,主执行流,就无法进行向后执行,只能提供完毕服务之后才能进行accept
transService(serviceSock, peerIp, peerPort);
我们不重定向,方便我们进行实验。
实验结果:
ctrl + c
异常终止的话,文件是只有这个进程打开的,文件的生命周期是随进程的。ctrl + c
掉,操作系统会自动的关闭掉进程所对应的文件描述符。struct file
结构体引用计数减减。多个客户端连接服务器(有问题的):
我们发现一个客户端连接服务器的时候,客户端可以正常的显示出服务器处理过的结果。
但是,一旦我们有两个或者两个以上的客户端连接服务器就会出问题:新连接的客户端会卡在那里。
// 1.0 版本 -- 多进程版本 -- 父进程打开的文件会被子进程继承吗?会的!
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
close(listenSock_); // 建议关掉
transService(serviceSock, peerIp, peerPort);
exit(0); // 任务处理完就退出,进入僵尸
}
// 父进程 -- 父进程不用对外提供服务
close(serviceSock); // 这一步是一定要做的!
// waitpid(); 默认是阻塞等待!WNOHANG
listen
套接字继承下去。close(listenSock_);
建议关掉。listenSock_
文件描述符给写了,可能影响将来accept
。close(serviceSock);
这一步是一定要做的!servicSock
文件描述符是为了让子进程继承下去,自己是不用的,就不应该继续占着,如果不关闭,最后可能导致文件描述符泄漏的问题。我们知道子进程退出之后就会进入僵尸状态!等待父进程回收!
那我们敢让父进程阻塞式等待吗,显然是不能!因为我们的目的是让服务器并发起来,现在还阻塞着。
如果用非阻塞等待WNOHANG
,这是可以的,我们要所有子进程的PID保存起来,非阻塞等待的时候每一次都要轮询所有的子进程,但是比较麻烦。 进程等待复习 - 传送门
(服务函数放在类内,类外都行)
// 1.1 版本 -- 多进程版本 -- 这样写也是可以的
// 爷爷进程
pid_t id = fork();
if(id == 0)
{
// 爸爸进程
close(listenSock_);// 建议关掉
// 又进行了一次fork,让 爸爸进程
if(fork() > 0) exit(0);
// 孙子进程 -- 就没有爸爸 -- 就变成了孤儿进程 -- 被系统领养 -- 孙子进程就交给了系统来回收
transService(serviceSock, peerIp, peerPort);
exit(0);
}
// 父进程
close(serviceSock); // 这一步是一定要做的!
// 爸爸进程直接终止,立马得到退出码,释放僵尸进程状态
pid_t ret = waitpid(id, nullptr, 0); // 就用阻塞式
assert(ret > 0);
(void)ret;
孙子进程,没有了父进程,就变成了孤儿进程,被系统领养,孙子进程就交给了系统来回收,就不用我们来回收了。
子进程是从fork函数开始执行的。 复习传送门
(服务函数放在类内,类外都行)
因为我们是线程函数是设置在类内的方法,所以成员函数第一个参数是隐藏的this指针,我们要设置成静态的。
静态成员函数里要想获取到类内成员变量的话,还要搞一些获取类内成员的接口,我们直接将现这些数据封装一下:
// 先声明一下
class ServerTcp;
class ThreadData
{
public:
uint16_t clientPort_;
std::string clinetIp_;
int sock_;
ServerTcp *this_;
public:
ThreadData(uint16_t port, std::string ip, int sock, ServerTcp *ts)
: clientPort_(port), clinetIp_(ip), sock_(sock), this_(ts)
{}
};
线程函数:
// 类内方法,形参默认带有this指针
static void *threadRoutine(void *args)
{
pthread_detach(pthread_self()); // 设置线程分离
ThreadData *td = static_cast<ThreadData*>(args);
td->this_->transService(td->sock_, td->clinetIp_, td->clientPort_);
delete td;
return nullptr;
}
(此时服务函数放在了类里面)
// 2.0 版本 -- 多线程
ThreadData *td = new ThreadData(peerPort, peerIp, serviceSock, this);
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)td);
// 不可进行线程等待,一等待,主线程就阻塞了,只能用线程分离
不可进行线程等待(pthread_join),一等待,主线程就阻塞了,只能用线程分离。
Task任务需要我们重写:
#pragma once
#include
#include
#include
#include
#include "log.hpp"
class Task
{
public:
// 下面两个等价
// typedef std::function callback_t;
using callback_t = std::function<void (int, std::string, uint16_t)>;
public:
Task():sock_(-1), port_(-1)
{}
Task(int sock, std::string ip, uint16_t port, callback_t func)
: sock_(sock), ip_(ip), port_(port), func_(func)
{}
void operator () ()
{
logMessage(DEBUG, "线程ID[%p]处理%s:%d的请求 开始啦...",\
pthread_self(), ip_.c_str(), port_);
func_(sock_, ip_, port_);
logMessage(DEBUG, "线程ID[%p]处理%s:%d的请求 结束啦...",\
pthread_self(), ip_.c_str(), port_);
}
~Task()
{}
private:
int sock_; // 给用户提供IO服务的sock
uint16_t port_; // client port
std::string ip_; // client ip
callback_t func_; // 回调方法
};
交给线程池处理:
// 3.0 版本 -- 线程池
// transService服务在类外
Task t(serviceSock, peerIp, peerPort, transService);
tp_->push(t);
(服务函数放在类外)
我们在初始化服务器的方法的最后,加了一个启动线程池。 线程池 - 复习
还需要再loop函数循环之前,将线程池中的线程加载好。
我们将服务方法通过Task打包封装一下加载进线程池当中,然后Task有个仿函数里面就是调用回调函数。
之前我们在学C++11的时候,学过bind,我们这里可以用起来:
Task t(serviceSock, peerIp, peerPort, std::bind(&ServerTcp::transService, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));
tp_->push(t);
bind不熟悉的看过来, 复习传送门
(服务函数放在类内)
popen函数:
第一件事情,创建管道,第二件事情,fork会自动帮我们创建子进程,让子进程去执行command代码,子进程执行完了之后,让父进程通过文件能够读到结果。
具体来说,popen函数会创建一个管道,其中写入端口(write end)被父进程保留,而读出端口(read end)被子进程保留。然后,popen函数调用fork创建一个新的子进程,该子进程会继承父进程的文件描述符,包括管道的读写端口。匿名管道用于在父进程和子进程之间进行双向通信。
void execCommand(int sock, const std::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); // 我们认为我们读到的都是字符串
if (s > 0)
{
command[s] = '\0';
logMessage(DEBUG, "[%s:%d] exec [%s]", clientIp.c_str(), clientPort, command);
// 考虑安全
std::string safe = command;
if ((std::string::npos != safe.find("rm")) || (std::string::npos != safe.find("unlink")))
{
break;
}
// 我们是以r方式打开的文件,没有写入
// 所以我们无法通过dup的方式得到对应的结果
FILE *fp = popen(command, "r");
if (fp == nullptr)
{
logMessage(WARINING, "exec %s failed, beacuse: %s", command, strerror(errno));
break;
}
char line[1024];
while (fgets(line, sizeof(line) - 1, fp) != nullptr)
{
write(sock, line, strlen(line));
}
pclose(fp);
logMessage(DEBUG, "[%s:%d] exec [%s] ... done", clientIp.c_str(), clientPort, command);
}
else if (s == 0)
{
// pipe: 读端一直在读,写端不写了,并且关闭了写端,读端会如何?s == 0, 代表对端关闭
// s == 0: 代表对方关闭,client 退出
logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
break;
}
else
{
logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno));
break;
}
}
// 只要走到这里,一定是client退出了,服务到此结束
close(sock); // 如果一个进程对应的文件fd,打开了没有被归还,文件描述符泄漏!
logMessage(DEBUG, "server close %d done", sock);
}
同样的也是通过线程池的方式提供服务:
Task t(serviceSock, peerIp, peerPort, execCommand);
tp_->push(t);
(服务函数放在类外)
但是这时候我们要将客户端的读取服务器返回的消息给屏蔽掉,不然客户端会一直阻塞式(read在等)的等待服务端发消息回来。
备注:
如果我们设置了对应的任务是死循环,那么线程池提供服务,就显得有不太合适了,我们应该给线程池抛入的任务是短任务。