目录
1. Tcp协议接口
1.1 listen监听函数
1.2 accept函数
1.3 connect函数
1.4 inet_ntop函数
2. TcpEchoServer服务
2.1 Common.hpp
2.1 TcpServer.hpp
2.1.1 TcpServer类
2.1.2 InitServer函数
2.1.3 Start函数
2.1.4 HandlerRequest函数
2.3 TcpServer.cc
2.4 TcpClient.cc
2.5 运行结果
3. 添加处理指令模块
3.1 CommandExec.hpp
3.2 修改部分
3.3 运行结果
下面是往期文章链接,往期文章主要讲解socket编程的许多预备知识,还详细介绍了socket和bind等网络接口函数的用法。如果不是很了解socket套接字,可以先阅读往期博客。
计算机网络:Socket网络编程 Udp与Tcp协议 第一弹-CSDN博客
1. Tcp协议接口
1.1 listen监听函数
Tcp协议相较于Udp协议,在双方进行通信之前,需要进行连接。正如你是一家饭店老板,客人在用餐前,都需要先跟你进行协商。
listen函数的作用就是将套接字设置为监听状态,以便随时接收传过来的连接。
- 该函数的第一个参数,是连接方套接字的文件描述符。想象一下,如果你有一家饭店位于市区,人潮涌动,竞争也十分激烈。这时,你可能会派遣一位机灵的小伙子到外面去招揽顾客。这个文件描述符就相当于这位负责招揽顾客的小伙子。
- 第二个参数,是在连接请求过多,服务器处理不过来时,能够容纳的新连接的数量。就好比您的餐厅位于人流量极大的市区,一到饭点,餐厅就座无虚席。当餐厅内没有空位时,服务员通常会为等待的顾客提供号码牌,让他们稍作休息。
- 但是,不可能无限制地提供号码牌,因此会有一个上限。这个参数的作用就是限制操作系统接收但尚未处理的连接数量。通常情况下,这个值会设置在10个左右。
该函数返回值是一个整数。如果调用成功,返回0;如果调用失败,返回-1,并且错误码会被设置。
1.2 accept函数
accept函数用于套接字接受连接,并创建新的文件描述给新连接。
- 该函数的第一个参数是连接方套接字的文件描述符,就是上面所说的招揽顾客的小伙子。
- 第二个参数是套接字地址类型的指针变量,是一个输出型参数,用于带出发起连接的主机信息。
- 第三个参数是也是一个输出型参数,用于带出该套接字结构体类型大小。
这就是像上面的listen函数,它的作用是把顾客成功吸引到餐厅。这时,就需要餐厅内的服务员来接待他们。因此,accept函数的返回值是一个文件描述符,它用于处理这些连接的数据读写操作。如果该函数调用失败,会返回-1,错误码会被设置。
1.3 connect函数
connect函数用于建立一个与指定地址的连接。该函数通常由客户端发起,连接指定的服务端,且使用的是Tcp套接字。
- sockfd参数是一个已经创建的文件描述符。
- addr参数是一个指向sockaddr的指针变量,里面需要包含要连接的远程服务器的IP地址和端口号。
- addrlen是表明addr结构体的大小。
1.4 inet_ntop函数
inet_ntop
是一个在计算机网络编程中常用的函数,用于将网络地址转换成字符串形式。以下是函数参数的说明:
af
:地址族(Address Family)。它指定了源地址src
的类型,可以是AF_INET
(用于IPv4),AF_INET6
(用于IPv6)等。src
:指向包含原始网络地址的指针。对于IPv4,这应该是一个struct in_addr
类型;对于IPv6,则是一个struct in6_addr
类型。dst
:指向目标缓冲区的指针,该缓冲区用于存储转换后的字符串形式的地址。size
:目标缓冲区dst
的大小。
2. TcpEchoServer服务
我们写一个服务,接受客户端发起的连接,接受客户端发来的消息,并返回给客户端作响应。
总共有四个文件,分别是Common.hpp、TcpClient.cc、TcpServer.hpp、TcpServer.cc。
- Common.hpp:文件内容主要是一些公共使用的宏,函数和枚举类型的代码。
- TcpClient.cc:tcp服务的客户端。
- TcpServer.hpp:tcp服务的核心模块。
- TcpServer.cc:tcp服务的启动。
2.1 Common.hpp
Common.hpp文件将一些公用的宏和枚举类型放在一起。
- 其中CONV宏,作用是将某个指针变量强转成sockaddr结构体类型指针。
- ExitError枚举类型列举出了一些常见的函数调用失败。这些错误往往需要直接退出程序。
#pragma once
#include <iostream>#define CONV(v) (struct sockaddr*)(v)enum ExitError
{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LISTEN_ERR,ACCEPT_ERR,CONNECT_ERR
};
2.1 TcpServer.hpp
2.1.1 TcpServer类
TcpServer.hpp文件中,主要包含TcpServer类。下面介绍
- TcpServer类中的成员变量有监听文件描述符、服务启动的端口号和服务运行状态。
- 构造函数是一个全缺省函数,可以使用gdefaultport中的端口,也可以外部传参。监听文件描述符初始化为gsockfd,gsockfd是一个全局变量,避免硬编码。服务器运行状态初始化为false,表示未启动。
#ifndef __TCP_SERVER__HPP
#define __TCP_SERVER__HPP#include <iostream>
#include <string>
#include <cstring>
#include <memory>
#include <string.h>#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/wait.h>
#include "Common.hpp"using namespace LogModule;#define BACKLOG 8const static int gsockfd = -1;
const static uint16_t gdefaultport = 8888;class TcpServer
{
public:TcpServer(const uint16_t port = gdefaultport):_listen_sockfd(gsockfd),_port(port),_isrunning(false){}~TcpServer(){if (_listen_sockfd > gsockfd)::close(_listen_sockfd);}
private:int _listen_sockfd;uint16_t _port; //服务器端口号bool _isrunning; //服务器运行状态
};#endif
2.1.2 InitServer函数
InitServer函数,用于初始化整个Tcp服务。
- 首先使用socket函数创建一个套接字,第一个参数传AF_INET,表示网络通信。第二个参数传SOCK_STREAM,表示使用Tcp协议,第三个参数默认传0即可。不过这个函数返回的文件描述符不用于进行IO操作,而是监听新连接。
- 接着,填充网络信息。定义一个sockaddr_in结构体,该结构体内部有三个字段需要填充。第一个字段表示什么通信,填AF_INET,表示网络通信。第二字段填端口号,但是得使用htons函数将主机字节序转换成网络字节序,变成大端模式。
- 第三个字段一般来说需要填写机器的IP地址,但是作为服务器,可能客户端发来的消息需要多台服务器处理不同的信息,如果服务器填写固定的IP地址,那么客户端接受服务器的消息后,只能返回给一台服务器。所以服务器的sin_addr中的s_addr一般设置INADDR_ANY。
- 作为Tcp套接字,不仅需要调用socket和bind函数,还需要调用listen函数设置监听状态。
void InitServer(){// 1.创建套接字_listen_sockfd = ::socket(AF_INET, SOCK_STREAM, 0);if(_listen_sockfd < 0){std::cout << "socket:" << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "socket success, _listen_sockfd is: " << _listen_sockfd << std::endl;// 2、填充网络信息,// 2.1 设置进入内核中,只是填充了结构体!struct sockaddr_in loacl;memset(&loacl, 0, sizeof(loacl));loacl.sin_family = AF_INET;loacl.sin_port = ::htons(_port); //要被发送给对方的,即要发送到网络中!loacl.sin_addr.s_addr = INADDR_ANY;// 2.2 bind 设置如内核中int n = ::bind(_listen_sockfd, CONV(&loacl), sizeof(loacl));if(n < 0){std::cout << "bind: "<< errno << " " <<strerror(errno) << std::endl;exit(BIND_ERR);}std::cout << "bind success" << std::endl;// 3.设置为监听状态int sockfd = ::listen(_listen_sockfd, BACKLOG);if(sockfd < 0){std::cout << "listen: " << errno << " " << strerror(errno) << std::endl;exit(LISTEN_ERR);}std::cout << "listen success" << std::endl;}
2.1.3 Start函数
Start函数用于启动该服务。
- 首先,将_isrunning变量设置为true,表示服务正在运行。
- 接着,写上while循环。调用accept函数,为新连接创建专属的文件描述符,进行通信。accept函数中,第二个参数是可以传入sockadd_in结构体变量指针,使用CONV宏进行强转。可以获取新连接的IP地址和端口号等信息。
- 如果accept函数调用成功,peer结构体变量中会填充客户端的信息。可以通过inet_ntop函数将peer中无符号整数IP地址转换成点分十进制的字符串形式。ntohs函数用于网络字节序列转换成主机字节序列。
- 当连接成功后,我们可以直接调用HandleRquest函数处理请求,进行网络通信。但是单进程通信,只能处理一个连接。可以使用多进程、多线程和线程池。但是这些方案在处理大量请求时,还是不合适,需要使用多路转接技术。
- 多线程版本,使用fork函数创建子进程,但是父进程需要调用waitpid函数阻塞等待子进程,再回收。所以,我们可以让子进程再创建一个子进程,称为孙子进程,然后子进程退出。那么孙子进程变成孤儿进程,由系统的init进程等待它的退出。而父进程不再需要等待子进程,可以继续去执行accept函数,为新连接创建文件描述符。
- 多线程版本,是一有新连接就创建线程去处理。可以调用pthread库中的pthread_create函数创建线程,其中第三个参数使用lambada表达式,调用HandleRequest函数。最后使用pthreate_detach函数是新线程和主线程分离。
void Start(){_isrunning = true;while(true){// 1.获取新连接struct sockaddr_in peer;socklen_t len = sizeof(peer); //必须设定std::cout << "waiting for connection..." << std::endl;int sockfd = accept(_listen_sockfd, CONV(&peer), &len);if(sockfd < 0){std::cout << "accept: " << errno << " " << strerror(errno) << std::endl;continue;}std::cout << "accept success, new sockfd is: " << sockfd << std::endl;// 2.获取客户端信息char ipbuffer[64];const char *ip = ::inet_ntop(AF_INET, &peer.sin_addr, ipbuffer, sizeof(ipbuffer));std::string clientaddr = ip;clientaddr += ":";clientaddr += std::to_string(ntohs(peer.sin_port));std::cout << "client addr is: " << clientaddr << std::endl;// 3.处理请求// version 0 单进程处理// HandleRequset(sockfd);//version 1 多进程模式 pid_t pid = fork();if(pid == 0){::close(_listen_sockfd); //子进程关闭监听套接字//if(fork() > 0) exit(0); // 子进程退出,父进程不用等待// 父进程->子进程退出->孙子进程,// 孙子进程会变成孤儿进程,由操作系统等待HandleRequset(sockfd);exit(0); //子进程退出}int rid = waitpid(pid, NULL, 0);if(rid < 0){std::cout << "waitpid: " << errno << " " << strerror(errno) << std::endl;}//version 2 多线程模式pthread_t tid;pthread_create(&tid, NULL, [](void* arg){int sockfd = *(int*)arg;HandleRequset(sockfd);}, (void*)sockfd);pthread_detach(tid);}_isrunning = false;}
2.1.4 HandlerRequest函数
HandlerRequest函数用于处理与客户端的通信。我们协定传输的数据都是字符串。
- 在开始时,我们定义一个inbuffer字符数组,用于存储从TCP连接中接收到的数据。由于TCP协议是基于字节流的,这意味着数据的传输是连续的,而且没有固定的边界。因此,我们需要使用recv函数来从连接中读取数据,并使用send函数来发送数据。
- 由于TCP连接是全双工的,我们可以在同一个文件描述符上进行读写操作。在这个文件描述符下,有两个独立的缓冲区:一个用于接收数据,另一个用于发送数据。这允许我们在不中断数据流的情况下,同时处理输入和输出。
- 面向字节流类似于读文本文件,我们一次读取的数据不固定,可以多也可以少,所以读取数据的时候可能会读不完整。如果想要保证数据的完整性,我们需要引入协议,就是做一种约定,比如一个报文数据开头是数据的长度,再跟\n接上有效数据,最后结尾再跟\n。这里暂时不对数据完整性进行考虑。
- recv函数的返回值跟read函数类似,返回值大于0,表明是数据的字节数大小。如果等于0,表示读到文件末尾,即客户端关闭。如果小于0,就是调用recv函数失败。
void HandleRequset(int sockfd){char inbuffer[4096];while(true){ssize_t n = ::recv(sockfd, inbuffer, sizeof(inbuffer) - 1, 0);if (n > 0){// 在后面字符串后面加上反斜杠0,以便于输出inbuffer[n] = 0;std::cout << "sockfd-" << sockfd << ": " << inbuffer << std::endl;std::string echo_str = "echo# ";echo_str += inbuffer;::send(sockfd, echo_str.c_str(), echo_str.size(), 0);}else if (n == 0){std::cout << "client closed" << std::endl;break;}else{std::cout << "recv: " << errno << " " << strerror(errno) << std::endl;break;}}// 一定要记得关闭文件描述符// 不然会造成文件描述符泄漏,也是内存泄漏!!!::close(sockfd); }
2.3 TcpServer.cc
Tcp服务端只需要绑定一个端口号,所以在传命令号时,强制传两个参数。其中需要把命令行第二参数转换成整数然后再使用智能指针初始化服务类变量,运行InitServer函数和Start函数即可。
#include "TcpServer.hpp"// ./server_tcp localport
int main(int argc, char *argv[])
{ if(argc != 2){std::cerr << "Usage: " << argv[0] << " localport" << std::endl;return 3;}//std::string ip = argv[1];uint16_t port = std::stoi(argv[1]);std::unique_ptr<TcpServer> svr_uptr = std::make_unique<TcpServer>(port);svr_uptr->InitServer();svr_uptr->Start();return 0;
}
2.4 TcpClient.cc
Tcp服务的客户端,命令行启动该程序时,需要传入访问的IP地址和端口号。
- 首先创建套接字。再填充服务端的网络信息,调用connect函数进行连接。客户端不需要绑定端口号,操作系统会在客户端第一次发起连接时,随机分配一个端口号给客户端。
- 因为Tcp协议是基于字节流的,所以可以使用write函数,直接向套接字绑定的文件描述符中写入数据,就会发送给服务端。读取数据可以使用read函数。
- 我们只处理返回值大于0的情况,如果返回值不大于0,说明服务端有问题。
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "Common.hpp"// ./client_udp serverip serverport
int main(int argc, char *argv[])
{if(argc != 3){std::cerr << "Usage: " << argv[0] << " serverip serverport" << std::endl;Die(ExitError::USAGE_ERR);}std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);// 1.创建socketint sockfd = ::socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){std::cout << "create socket error" << std::endl;exit(SOCKET_ERR);}// 1.1 填充server信息struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_port = htons(serverport);server_addr.sin_addr.s_addr = ::inet_addr(serverip.c_str());int n = ::connect(sockfd, CONV(&server_addr), sizeof(server_addr));if (n < 0){std::cout << "connect error" << std::endl;exit(CONNECT_ERR);}// 2. echo clientstd::string message;while(true){char inbuffer[1024];std::cout << "Please Enter# ";std::getline(std::cin, message);n = ::write(sockfd, message.c_str(), message.size());if (n > 0){int m = ::read(sockfd, inbuffer, sizeof(inbuffer));if(m > 0){inbuffer[m] = 0;std::cout << inbuffer << std::endl;}else{break;}}else{break;}}return 0;
}
2.5 运行结果
下面就是基于tcp协议的网络通信运行结果,右边是服务端,左边是客户端。
当客户端退出时,服务端调用的recv函数返回值为0。
3. 添加处理指令模块
客户端发送指令,让服务端执行,并把执行结果返回给客户端。这其实就类似我们使用Xshell登录云服务器,然后使用命令行输入执行,并返回结果给我们。
3.1 CommandExec.hpp
下面是CommandExec.hpp代码内容。包含Command类,内部成员变量是一个set容器,专门存储可以执行的指令名字,其实也可以不使用白名单策略,但是得注意别乱输入指令。
SafeCheck函数用来检查传进来的指令是否在_while_list中。
popen函数C 语言标准库中的一个函数,用于执行一个命令并获取命令的输出或者向命令发送输入。它会创建一个管道,然后读写端打开。
#pragma once#include <iostream>
#include <string>
#include <cstdio>
#include <set>const int line_size = 1024;class Command
{
public:Command(){_while_list.insert("ls");_while_list.insert("pwd");_while_list.insert("ls -l");_while_list.insert("ll");_while_list.insert("touch");_while_list.insert("whoami");}bool SafeCheck(std::string &comstr){auto iter = _while_list.find(comstr);return iter == _while_list.end() ? false : true;}// 给你一个命令字符串“ls -l”,让你执行,执行完后,把结果返回std::string Execute(std::string comstr){if(!SafeCheck(comstr)){return std::string(cmd + "不支持");}FILE *fp = popen(comstr.c_str(), "r");if(nullptr == fp){return std::string("Failed");}char buffer[line_size];std::string result;while(true){char *ret = :: fgets(buffer, sizeof(buffer), fp);if(!ret) break;result += ret;}pclose(fp);return result.empty() ? std::string("Done") : result;}
private:std::set<std::string> _while_list;
};
3.2 修改部分
TcpServer.hpp中,使用functional函数定义一个新函数指针类型,返回值和参数都是string类型。
- 并在TcpServer类中添加成员变量_handler,处理接受数据。构造函数中需要传递一个变量初始化_handler。
- 在HandlerRequest函数中,将if判断n大于0情况下,调用该变量来处理接受的数据即可。
#include <functional>// .......
using handler_t = std::function<std::string(std::string)>;class TcpServer
{
public:// ......void HandleRequset(int sockfd){char inbuffer[4096];while(true){ssize_t n = ::recv(sockfd, inbuffer, sizeof(inbuffer) - 1, 0);if (n > 0){// 在后面字符串后面加上反斜杠0,以便于输出inbuffer[n] = 0;std::cout << "sockfd-" << sockfd << ": " << inbuffer << std::endl;// std::string echo_str = "echo# ";// echo_str += inbuffer;std::string cmd_result = _handler(inbuffer);::send(sockfd, cmd_result.c_str(), cmd_result.size(), 0);}// ......}::close(sockfd);}// ......
private:int _listen_sockfd;uint16_t _port; //服务器端口号bool _isrunning; //服务器运行状态// 处理上层任务的入口函数handler_t _handler;
};#endif
在TcpServer.cc中,定义一个Command变量cmd,在初始化svr_uptr变量时,使用lambda表达式写一个函数,内部调用Command类中的Excute函数来处理接受的数据。
#include "TcpServer.hpp"
#include "Commandexec.hpp"// ./server_tcp localport
int main(int argc, char *argv[])
{ if(argc != 2){std::cerr << "Usage: " << argv[0] << " localport" << std::endl;return 3;}//std::string ip = argv[1];uint16_t port = std::stoi(argv[1]);Command cmd;std::unique_ptr<TcpServer> svr_uptr = std::make_unique<TcpServer>([&cmd](std::string command){return cmd.Execute(command);}, port);svr_uptr->InitServer();svr_uptr->Start();return 0;
}
3.3 运行结果
运行结果如下,这样我们可以通过添加TcpServer中的回调函数,处理各种业务。当然这样的回调还是比较简单的。
创作充满挑战,但若我的文章能为你带来一丝启发或帮助,那便是我最大的荣幸。如果你喜欢这篇文章,请不吝点赞、评论和分享,你的支持是我继续创作的最大动力!