目录
- [TCP 网络编程](#TCP 网络编程)
- [一、V1 - 服务器](#一、V1 - 服务器)
-
- [1.1 服务器的初始化Init接口](#1.1 服务器的初始化Init接口)
- [1.2 服务器的Start接口](#1.2 服务器的Start接口)
- [1.3 编写客户端](#1.3 编写客户端)
- [1.4 V1 - Start接口 多进程版本](#1.4 V1 - Start接口 多进程版本)
-
- [telnet 工具](#telnet 工具)
- [1.5 V2 - Start接口 多线程版本](#1.5 V2 - Start接口 多线程版本)
- [1.6 长服务 与 短服务](#1.6 长服务 与 短服务)
- [1.7 V3 - Start接口 线程池版本](#1.7 V3 - Start接口 线程池版本)
- [二、V2 - 服务器](#二、V2 - 服务器)
-
- [2.1 修改TcpServer](#2.1 修改TcpServer)
- [2.2 增加Command类](#2.2 增加Command类)
- [2.3 完善服务端和客户端并测试](#2.3 完善服务端和客户端并测试)
- [关于 inet_ntoa 和 inet_addr](#关于 inet_ntoa 和 inet_addr)

个人主页:矢望
个人专栏:C++、Linux系统编程、Linux网络编程、C语言、数据结构、Coze-AI、MySQL
TCP 网络编程
一、V1 - 服务器
上期博客使用UDP编写了服务器,和UDP一样,也使用TCP写一个Echo Server服务器。

1.1 服务器的初始化Init接口
cpp
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include "Logger.hpp"
using namespace NS_LOG_MODULE;
static const uint16_t defaultport = 8888;
static const int defaultbacklog = 16;
enum
{
SUCCESS = 0,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
class TcpServer
{
public:
TcpServer(uint16_t port = defaultport)
: _port(port)
{}
void InitServer()
{
// 1、创建Socket
_sockfd = socket(AF_INET, SOCK_STREAM, 0); // TCP
if(_sockfd < 0)
{
LOG(LogLevel::FATAL) << "create socket error!";
exit(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
// 2、填充本地Socket信息
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY; // bind 任意地址
// 3、bind Socket
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind socket error!";
exit(BIND_ERR);
}
LOG(LogLevel::INFO) << "bind socket success!";
// 4、tcp 面向连接,tcp服务器要处于一种listen监听状态
n = listen(_sockfd, defaultbacklog);
if(n < 0)
{
LOG(LogLevel::FATAL) << "listen socket error!";
exit(LISTEN_ERR);
}
LOG(LogLevel::INFO) << "listen socket success!";
}
void Start()
{}
~TcpServer()
{}
private:
int _sockfd;
uint16_t _port;
};
如上,补充好了服务器的Init接口,TCP协议是面向连接的、字节流的 ,因为这个特点,TCP和UDP使用起来有所不同。由于需要打印信息,所以引入了之前写的日志模块。
首先TCP网络通信也需要先创建socket,其中由于是字节流的,所以这个函数的第二个参数是SOCK_STREAM,代表是字节流。其次填充socket信息和UDP一样,第三步绑定socket信息到内核也和UDP一样。不过由于TCP是面向连接的,所以和UDP的步骤多了listen监听套接字。
listen函数将一个已绑定本地地址的主动TCP套接字转换为被动监听套接字,并通知内核开始接受指向该套接字的连接请求,同时指定已完成连接队列的最大长度 。

返回值:成功返回0,失败返回-1并设置错误信息。
| 参数 | 类型 | 说明 |
|---|---|---|
sockfd |
int |
已通过 socket() 创建并 bind() 到本地地址的套接字描述符 |
backlog |
int |
内核为该监听 socket 维护的已完成连接队列(全连接队列)的最大长度,实际值取该值与系统参数 net.core.somaxconn 的较小值 |
TcpServerMain.cc:
cpp
static void Usage(const std::string process)
{
std::cerr << "Usage:\n\t";
std::cerr << process << " local_port" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(10);
}
SET_CONSOLE_LOG_STRATEGY(); // 使用日志的控制台模式
uint16_t server_port = std::stoi(argv[1]);
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(server_port);
tsvr->InitServer();
tsvr->Start();
return 0;
}
编译运行 :

如上,初始化服务器成功。

如上图,再在Start接口填充死循环,再次编译运行,查看到0.0.0.0:8080说明当前进程bind任意地址,端口号8080,并且接收任意ip地址访问0.0.0.0:*,且处于监听状态。
1.2 服务器的Start接口
UDP创建并绑定Socket之后,直接就开始进行IO了,开始网络通信了。但是TCP不行,TCP需要先获取链接,也就是需要等到"客户"上门。就像它给别人打电话或者接听电话时,两端都接通才能通信说话。
所以它需要先进行accept。
accept函数从指定监听套接字的已完成连接队列头部取出第一个已成功完成三次握手的连接,并为其创建一个全新的已连接套接字,返回其文件描述符供后续数据读写使用;若队列为空则默认阻塞等待。

| 参数 | 类型 | 说明 |
|---|---|---|
sockfd |
int |
处于 LISTEN 状态的监听套接字描述符(由 socket+bind+listen 得到) |
addr |
struct sockaddr* |
输出参数,用于返回客户端的协议地址(IP + 端口)。若为 NULL,表示不获取客户端地址信息 |
addrlen |
socklen_t* |
输入输出参数:传入时指向 addr 结构体长度,返回时填充实际地址长度。若 addr 为 NULL,此参数也应置 NULL |
返回值 :成功时返回一个新的已连接套接字 文件描述符(用于与该客户端通信),失败时返回 -1 并设置 errno。
这里有两个文件描述符,以一个例子分清楚它们。
有一家很火爆的餐厅,餐厅外面站着一个拉客的人,他热情的拉客人,拉到客人就叫餐厅服务员服务客人,然后他又站在外面拉客。在这个例子中拉客的人就是我们当前代码的_sockfd,而被喊来的服务员就是accept函数返回的文件描述符。也就是说之前的_sockfd是监听socket,而accept返回的文件描述符是IO socket,一个IO socket就代表一条连接!
所以我就把之前的_sockfd名称更换成_listen_sockfd了。
cpp
void ServiceIO(int sockfd, InetAddr clientaddr) // IO 接口
{
LOG(LogLevel::DEBUG) << "client info is " << clientaddr.ToString();
while(true)
{
// 读
char inbuffer[1024];
ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer));
if(n > 0) // 读成功
{
inbuffer[n] = '\0';
LOG(LogLevel::INFO) << clientaddr.ToString() << "say# " << inbuffer;
// 写
std::string echo_str = "Server Echo# ";
echo_str += inbuffer;
write(sockfd, echo_str.c_str(), echo_str.size());
}
else if(n == 0)
{
LOG(LogLevel::INFO) << "client quit, address: " << clientaddr.ToString();
break;
}
else
{
LOG(LogLevel::ERROR) << "read client error, address: " << clientaddr.ToString();
break;
}
}
}
void Start()
{
while(true)
{
// 5、获取连接
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
int sockfd = accept(_listen_sockfd, (struct sockaddr*)&clientaddr, &len);
if(sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept clientaddr error";
continue;
}
LOG(LogLevel::INFO) << "accept clientaddr success, sockfd: " << sockfd;
// 6、处理新的sockfd
// version0
InetAddr clientaddress(clientaddr);
ServiceIO(sockfd, clientaddress); // 调用IO接口进行网络通信
close(sockfd);
}
}
如上,获取连接成功之后,就可以通过sockfd和客户端进行通信了,调用专门的IO接口进行通信,IO完毕之后即可关闭文件描述符,断开连接 。
在ServiceIO函数中,使用read和write进行读写,因为tcp是面向字节流的,可以使用read和write,而udp面向数据报不可以这样写。
上面如果获取连接失败也是小问题,重写获取下一个即可,所以只是WARNING级别。
之前对struct sockaddr_in进行了封装,所以就不需要手动填写socket信息了。
当前的处理sockfd的方法只是熟悉一下接口,不能用于业务,因为很明显能够看出,当前的Start接口,当接到一个连接之后,就会进入ServiceIO函数的死循环中,再有连接就不能接入了,所以是有缺陷的。
1.3 编写客户端
客户端的编程和UDP相同依旧是先建立socket,然后由OS隐式绑定ip和port。不过和udp不同的是,tcp在之后需要向服务端发起建立连接请求connect,连接成功后才能进行通信工作。
connect函数使一个主动TCP套接字向指定的服务器地址发起三次握手连接,若连接成功则套接字变为ESTABLISHED状态,可用于后续数据传输;若失败则返回错误。
| 参数 | 类型 | 说明 |
|---|---|---|
sockfd |
int |
由 socket() 创建的主动套接字描述符(SOCK_STREAM 类型) |
addr |
const struct sockaddr* |
指向目标服务器协议地址结构(IP + 端口)的指针,如 struct sockaddr_in |
addrlen |
socklen_t |
addr 指向的结构体长度,通常为 sizeof(struct sockaddr_in) |
返回值:成功时返回 0,失败时返回 -1 并设置 errno 以指示具体错误原因。
cpp
static void Usage(const std::string process)
{
std::cerr << "Usage:\n\t";
std::cerr << process << " server_ip server_port" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(3);
}
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
// 1、创建Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0)
{
std::cerr << "create socket error!" << std::endl;
exit(4);
}
// 2、由OS隐式绑定ip和port,并发起建立连接请求
InetAddr serveraddr(server_port, server_ip);
int n = connect(sockfd, (struct sockaddr*)serveraddr.GetNetAddr(), serveraddr.GetLen());
if(n < 0)
{
std::cerr << "connect to " << serveraddr.ToString() << "failed!" << std::endl;
exit(5);
}
std::cout << "connect to " << serveraddr.ToString() << "success!" << std::endl;
// 3、网络通信
while(true)
{
std::string line;
std::cout << "Please Enter# ";
std::getline(std::cin, line);
write(sockfd, line.c_str(), line.size()); // 写
char inbuffer[1024];
int n = read(sockfd, inbuffer, sizeof(inbuffer)); // 读
if(n > 0)
{
inbuffer[n] = '\0'; // 读成功
std::cout << inbuffer << std::endl; // 打印服务端发来的消息
}
else if(n == 0)
{
std::cout << "read end of file" << std::endl;
break;
}
else
{
std::cerr << "read error!" << std::endl;
break;
}
}
close(sockfd);
return 0;
}
编译运行 :

如上,代码逻辑没有问题。
再次通信,查看连接信息:

如上,下面圈的两个就是在系统中建立好的tcp连接,我们发现居然有两条连接,一个网络通信应该是一条连接呀,怎么会是两条呢?那是因为当前服务器端和客户端在同一台主机上,所以看到的就是两条连接,这是分别站在服务器端视角和客户端视角看到的。而实际通信客户端和服务端应该在不同机器上,这时候看到的就是一条连接。
接下来一端部署到Windows,一端部署到Linux,这样再次通信,我们查看结果。

如上图,服务端和客户端各自看到一条连接。
1.4 V1 - Start接口 多进程版本
当前我们的服务端的Start处理连接是单执行流的,无法并发处理,当同时来多个连接时,只能处理第一个,等到第一个断开连接时,第二个才能通信。

我们可以通过父进程获取连接让子进程处理,然后父进程继续等待获取连接的方式处理。
使用多进程版本时,让子进程处理连接,处理完成之后,子进程就会退出。子进程要退出,父进程就要等待子进程,无论是阻塞等待还是非阻塞等待,回收子进程都会阻碍父进程获取连接,原因有很多种,例如父进程获取连接可能会阻塞住,导致子进程无法及时回收等。
那么如何处理呢?最佳实践:忽略SIGCHLD信号signal(SIGCHLD, SIG_IGN)。这样子进程退出之后,就会自动被释放。
还有一种方法就是创建出子进程之后,子进程再创建出孙子进程,然后子进程退出,父进程等待子进程,此时孙子进程就成了孤儿进程,OS成了它的父进程,由系统管理。
方案一:
cpp
void Start()
{
signal(SIGCHLD, SIG_IGN); // 最佳实践,让子进程退出后直接释放
while(true)
{
// 5、获取连接
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
int sockfd = accept(_listen_sockfd, (struct sockaddr*)&clientaddr, &len);
if(sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept clientaddr error";
continue;
}
LOG(LogLevel::INFO) << "accept clientaddr success, sockfd: " << sockfd;
// 6、处理新的sockfd
// version1: 多进程版本
pid_t id = fork();
if(id == 0)
{
// 子进程
InetAddr clientaddress(clientaddr);
ServiceIO(sockfd, clientaddress); // 调用IO接口进行网络通信
close(sockfd);
exit(0); // 子进程退出
}
// 父进程
}
}
方案二:
cpp
void Start()
{
while(true)
{
// 5、获取连接
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
int sockfd = accept(_listen_sockfd, (struct sockaddr*)&clientaddr, &len);
if(sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept clientaddr error";
continue;
}
LOG(LogLevel::INFO) << "accept clientaddr success, sockfd: " << sockfd;
// 6、处理新的sockfd
// version1: 多进程版本
pid_t id = fork();
if(id == 0)
{
// 子进程
if(fork() > 0) exit(0);
// 孤儿进程
InetAddr clientaddress(clientaddr);
ServiceIO(sockfd, clientaddress); // 调用IO接口进行网络通信
close(sockfd);
exit(0); // 孤儿进程退出
}
// 父进程
pid_t rid = waitpid(id, nullptr, 0); // 回收立刻退出的子进程
(void)rid;
}
}

如上图所示,父进程在创建子进程时,父进程的文件描述符表等资源会向子进程拷贝一份,所以父进程和子进程都有_listen_sockfd和sockfd。文件描述符是资源,父进程不需要获取连接产生的sockfd文件描述符,子进程不需要_listen_sockfd,所以应该关闭父子不需要使用的文件描述符。
cpp
pid_t id = fork();
if(id == 0)
{
// 子进程
close(_listen_sockfd); // 关闭自己不需要的fd
if(fork() > 0) exit(0);
// 孤儿进程
InetAddr clientaddress(clientaddr);
ServiceIO(sockfd, clientaddress); // 调用IO接口进行网络通信
close(sockfd);
exit(0); // 孤儿进程退出
}
// 父进程
close(sockfd); // 关闭自己不需要的fd
pid_t rid = waitpid(id, nullptr, 0); // 回收立刻退出的子进程
(void)rid;
编译运行 :

如上,这样就可以并发处理连接了。
telnet 工具
Telnet 是一种远程登录协议,也是同名的命令行工具。它的核心作用就是让你能通过网络,从一台电脑连接到另一台远程设备(如服务器、交换机)上执行命令。
安装指令:sudo yum/apt -y install telnet。
使用这个工具也可以连接我们的服务端进行网络通信。

1.5 V2 - Start接口 多线程版本
当主线程收到连接之后,可以再创建一个新线程让它处理连接,但是主线程需要去等待创建出来的新线程,所以可以将新线程设置成分离状态,这样就不需要再等待了。
线程执行的函数的要求是参数类型和返回值类型都是void*的,如果设置成成员函数默认都有this指针,所以必须把函数设置成静态的。但是设置成静态的话就无法调用类内的ServiceIO函数。除非有this指针,所以在进行函数参数传递的时候,可以传递一个类,这个类内部存在TcpServer类的this指针,这样就可以调用服务了。
cpp
class ThreadData
{
public:
ThreadData(TcpServer *ts, int sockfd, const InetAddr &addr)
: _this(ts)
, _sockfd(sockfd)
, _addr(addr)
{}
~ThreadData()
{
close(_sockfd);
}
public:
TcpServer *_this;
int _sockfd;
InetAddr _addr;
};
static void *handler_route(void *args)
{
ThreadData *td = static_cast<ThreadData*>(args);
pthread_detach(pthread_self()); // 线程分离,不需要主线程等待
td->_this->ServiceIO(td->_sockfd, td->_addr);
delete td;
return nullptr;
}
void Start()
{
// signal(SIGCHLD, SIG_IGN); // 最佳实践,让子进程退出后直接释放
while(true)
{
// 5、获取连接
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
int sockfd = accept(_listen_sockfd, (struct sockaddr*)&clientaddr, &len);
if(sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept clientaddr error";
continue;
}
LOG(LogLevel::INFO) << "accept clientaddr success, sockfd: " << sockfd;
// 6、处理新的sockfd
// version2: 多线程版本
pthread_t tid;
InetAddr clientaddress(clientaddr);
ThreadData *td = new ThreadData(this, sockfd, clientaddress);
pthread_create(&tid, nullptr, handler_route, (void*)td);
}
}
如上,创建新线程时,函数传递ThreadData类参数,这样就解决了在函数内部没有this指针无法调用服务的问题。
编译运行 :

1.6 长服务 与 短服务
当前我们的ServiceIO函数里面的服务类型是长服务,因为是死循环,只要客户端不退出,服务一直都在。
多线程和多进程不擅长处理长服务,因为长服务会长时间占用线程/进程资源,导致系统并发能力急剧下降。如果连接一多很可能会导致系统资源耗尽,并且如果大量连接处于空闲状态会浪费CPU资源,利用率低。
长连接场景:改用 IO 多路复用(如 epoll + 线程池),用少量线程管理海量连接,而不是"一连接一线程"。
所以应该采用短服务,这样用完即释放,线程/进程可以快速回收给下一个请求。
cpp
void ServiceIO(int sockfd, InetAddr clientaddr) // IO 接口
{
// 短连接,短服务
LOG(LogLevel::DEBUG) << "client info is " << clientaddr.ToString();
//读
char inbuffer[1024];
ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer));
if(n > 0) // 读成功
{
inbuffer[n] = '\0';
LOG(LogLevel::INFO) << clientaddr.ToString() << "say# " << inbuffer;
// 写
std::string echo_str = "Server Echo# ";
echo_str += inbuffer;
write(sockfd, echo_str.c_str(), echo_str.size());
}
else if(n == 0)
{
LOG(LogLevel::INFO) << "client quit, address: " << clientaddr.ToString();
}
else
{
LOG(LogLevel::ERROR) << "read client error, address: " << clientaddr.ToString();
}
}
编译运行 :

1.7 V3 - Start接口 线程池版本
将之前封装的单例线程池模块引入进来:
cpp
using task_t = std::function<void ()>;
void Start()
{
// signal(SIGCHLD, SIG_IGN); // 最佳实践,让子进程退出后直接释放
while(true)
{
// 5、获取连接
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
int sockfd = accept(_listen_sockfd, (struct sockaddr*)&clientaddr, &len);
if(sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept clientaddr error";
continue;
}
LOG(LogLevel::INFO) << "accept clientaddr success, sockfd: " << sockfd;
// 6、处理新的sockfd
// version3: 线程池版本
InetAddr clientaddress(clientaddr);
ThreadPool<task_t>::Instance()->Enqueue([this, sockfd, clientaddress](){
this->ServiceIO(sockfd, clientaddress);
});
}
}
现在,主线程获取到连接之后,连接就会入进线程池,交由线程池中的线程处理。
编译运行 :

二、V2 - 服务器
打算写一个SSH服务器,这个服务器会从网络中获取命令,然后交由上层解析执行,并将执行结果返回给服务器,服务器再返回给客户端。这是一个长服务,所以就将V1服务器的ServiceIO接口再改为长服务 。

2.1 修改TcpServer
服务端需要增加一个回调到Command的函数指针,用于进行解析执行工作,它的返回值和参数都是std::string。并且ServiceIO中读完之后,应该将读到的字符串交给回调函数处理,服务器将回调函数返回的字符串进行返回。

如上,SSH的一次对话包含三个部分构成,一个是命令行字符串 [用户名@主机名 当前路径],还有就是用户输入的命令 ,最后是命令的执行结果 。
所以在TcpServer中需要注册两个服务,一个服务获取命令行字符串,先给客户端将获取的字符串发过去,然后等待客户端输入命令,再调用获取命令执行结果的服务。
cpp
using handler_tip_t = std::function<std::string ()>; // 回调,获取命令行字符串
using handler_t = std::function<std::string (std::string)>; // 回调,获取命令执行结果
void Register(handler_tip_t handler_tip, handler_t handler) // 注册服务
{
_handler_tip = handler_tip;
_handler = handler;
}
void ReturnTips(int sockfd, InetAddr address)
{
std::string command = _handler_tip();
write(sockfd, command.c_str(), command.size()); // 向客户端写命令行字符串
}
int HandlerIO(int sockfd, InetAddr address) // 调用回调获取命令执行结果并返回
{
// 读
char inbuffer[1024];
ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer));
if(n > 0) // 读成功
{
inbuffer[n] = '\0';
std::string result;
if(_handler != nullptr)
result = _handler(inbuffer); // 回调,交给上层处理
else
{
LOG(LogLevel::WARNING) << "init is not ready!";
}
write(sockfd, result.c_str(), result.size()); // 向客户端写回命令的执行结果
}
else if(n == 0)
{
LOG(LogLevel::INFO) << "client quit, address: " << address.ToString();
}
else
{
LOG(LogLevel::ERROR) << "read client error, address: " << address.ToString();
}
return n;
}
void Service(int sockfd, InetAddr clientaddr) // IO 接口
{
// 长连接,长服务,小型应用
LOG(LogLevel::DEBUG) << "client info is " << clientaddr.ToString();
while(true)
{
ReturnTips(sockfd, clientaddr);
if(HandlerIO(sockfd, clientaddr) <= 0) // 出现非法情况,服务退出
break;
}
close(sockfd);
}
2.2 增加Command类
这个类首先会获取服务器给它的字符串,然后在类内会有两个服务,一个获取命令行字符串,一个服务解析执行字符串命令,并将执行结果返回给服务器。
为了保证主机的安全,我会设置一个命令白名单,只有在白名单上的命令才可以被执行。
cpp
class Command
{
private:
void LoadCommand() // 允许执行的命令白名单
{
_whitelist.push_back("ls");
_whitelist.push_back("ls -l");
_whitelist.push_back("ls -a -l");
_whitelist.push_back("pwd");
_whitelist.push_back("whoami");
_whitelist.push_back("who");
_whitelist.push_back("ps -aL");
_whitelist.push_back("netstat -antp");
}
bool IsSafe(const std::string &cmd) // 判断命令是否安全
{
for(auto &e : _whitelist)
{
if(e == cmd) return true;
}
return false;
}
public:
Command()
{
LoadCommand();
}
std::string Excute(const std::string &cmd) // 执行字符串命令,返回结果
{
if(!IsSafe(cmd))
return "Bad Man!";
FILE *fp = popen(cmd.c_str(), "r");
if(fp == nullptr)
return "excute error!";
std::string result;
char buffer[1024];
while(fgets(buffer, sizeof(buffer), fp))
{
result += buffer;
}
pclose(fp);
return result;
}
std::string GetCommandString() // 获取命令行字符串并返回
{
// 获取用户名
struct passwd *pw = getpwuid(getuid());
std::string username = (pw ? pw->pw_name : "unknown");
// 获取主机名
char hostname[256];
if (gethostname(hostname, sizeof(hostname)) != 0)
{
snprintf(hostname, sizeof(hostname), "unknown");
}
// 获取当前路径
char cwd[1024];
if (getcwd(cwd, sizeof(cwd)) == NULL)
{
snprintf(cwd, sizeof(cwd), "unknown");
}
// 提取最后一级目录名(短路径)
std::string shortPath = cwd;
size_t pos = shortPath.find_last_of('/');
if (pos != std::string::npos && pos != 0)
{
shortPath = shortPath.substr(pos + 1);
}
return "[" + username + "@" + hostname + " " + shortPath + "]# ";
}
~Command()
{}
private:
std::vector<std::string> _whitelist;
};
如上,实现了获取命令行字符串 和获取命令的执行结果 的接口。
上面命令的执行用到了popen和pclose接口:popen() 启动一个子进程执行 shell 命令,并建立管道用于读写命令的输入/输出;pclose() 关闭管道并等待子进程结束,返回其退出状态。
popen调用成功时返回FILE*流指针,里面包含命令的执行结果,失败返回-1。pclose调用成功返回子进程的退出状态,失败返回-1。
2.3 完善服务端和客户端并测试
服务端:
cpp
static void Usage(const std::string process)
{
std::cerr << "Usage:\n\t";
std::cerr << process << " local_port" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(10);
}
SET_CONSOLE_LOG_STRATEGY(); // 使用日志的控制台模式
uint16_t server_port = std::stoi(argv[1]);
// 命令处理的类对象
std::unique_ptr<Command> command = std::make_unique<Command>();
// 网络通信类
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(server_port);
// 注册服务
tsvr->Register(
[&command]() -> std::string {
return command->GetCommandString();
},
[&command](const std::string &cmd) -> std::string {
return command->Excute(cmd);
}
);
tsvr->InitServer();
tsvr->Start();
return 0;
}
客户端:
cpp
static void Usage(const std::string process)
{
std::cerr << "Usage:\n\t";
std::cerr << process << " server_ip server_port" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(3);
}
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
// 1、创建Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0)
{
std::cerr << "create socket error!" << std::endl;
exit(4);
}
// 2、由OS隐式绑定ip和port,并发起建立连接请求
InetAddr serveraddr(server_port, server_ip);
int n = connect(sockfd, (struct sockaddr*)serveraddr.GetNetAddr(), serveraddr.GetLen());
if(n < 0)
{
std::cerr << "connect to " << serveraddr.ToString() << "failed!" << std::endl;
exit(5);
}
std::cout << "connect to " << serveraddr.ToString() << "success!" << std::endl;
// 3、网络通信
while(true)
{
// 获取命令行字符串
char commandline[1024];
std::string str;
ssize_t n = read(sockfd, commandline, sizeof(commandline));
if(n > 0)
{
commandline[n] = '\0';
str = commandline;
}
else
{
str = "[unknown@uknown None]# ";
}
std::cout << str;
// 获取用户输入
std::string line;
std::getline(std::cin, line);
write(sockfd, line.c_str(), line.size()); // 写
char inbuffer[1024];
n = read(sockfd, inbuffer, sizeof(inbuffer)); // 读
if(n > 0)
{
inbuffer[n] = '\0'; // 读成功
std::cout << inbuffer << std::endl; // 打印服务端发来的消息
}
else if(n == 0)
{
std::cout << "read end of file" << std::endl;
break;
}
else
{
std::cerr << "read error!" << std::endl;
break;
}
}
close(sockfd);
return 0;
}
编译运行 :

如上,服务器成功运行,符合预期。
关于 inet_ntoa 和 inet_addr
我们的服务器内包括线程池内都会调用InetAddr类,这个类内部实现了ip和port与struct sockaddr_in之间的相互转变,其中4字节ip地址转成点分十进制的字符串ip是inet_ntoa的工作,而字符串ip转化成4字节网络字节序ip是inet_addr的工作。都在InetAddr类中的构造函数中进行。



如上,inet_ntoa函数的返回值是char*类型,这个函数在做4字节ip到点分十进制字符串ip的转化时,会将转化好的字符串ip保存在自己内部的静态缓冲区中,并且下一次调用时会覆盖上一次调用的结果,所以这个函数存在线程安全问题。
inet_addr 是线程安全的。它的转换结果通过返回值返回(in_addr_t,即 uint32_t),不依赖静态缓冲区,没有内部可写状态,多个线程同时调用不会互相干扰。
但inet_addr只支持IPv4,所以使用场景不多。
这里给出替代方案,就是inet_ntop和inet_pton,这两个接口很好记。inet_ntop将二进制IP转成字符串(需自己提供缓冲区,线程安全),inet_pton将字符串转成二进制,两者支持IPv4和IPv6,是替代inet_ntoa和inet_addr的现代标准函数。

inet_ntop(二进制 → 字符串)
| 参数 | 类型 | 说明 |
|---|---|---|
af |
int |
地址族:AF_INET 或 AF_INET6 |
src |
const void* |
输入的二进制地址(struct in_addr* 或 struct in6_addr*) |
dst |
char* |
输出字符串缓冲区(由你提供) |
size |
socklen_t |
缓冲区大小(IPv4用INET_ADDRSTRLEN,IPv6用INET6_ADDRSTRLEN) |
返回值 :成功返回dst指针,失败返回NULL(errno设ENOSPC表示缓冲区太小,或EAFNOSUPPORT)

inet_pton(字符串 → 二进制)
| 参数 | 类型 | 说明 |
|---|---|---|
af |
int |
地址族:AF_INET(IPv4)或 AF_INET6(IPv6) |
src |
const char* |
输入的IP字符串(如 "192.168.1.1") |
dst |
void* |
输出缓冲区指针(struct in_addr* 或 struct in6_addr*) |
返回值 :1=成功,0=格式无效,-1=地址族不支持(errno设EAFNOSUPPORT)
所以替换InetAddr的构造函数:
cpp
InetAddr()
{}
InetAddr(const struct sockaddr_in &address)
: _address(address)
, _len(sizeof(_address))
{
char ipstr[32];
inet_ntop(AF_INET, &_address.sin_addr, ipstr, sizeof(ipstr));
_ip = ipstr; // 网络转主机 && 4字节转字符串点分十进制
_port = ntohs(_address.sin_port); // 网络主机
}
InetAddr(uint16_t port, const std::string &ip = "0.0.0.0")
: _ip(ip)
, _port(port)
{
bzero(&_address, sizeof(_address));
_address.sin_family = AF_INET;
inet_pton(AF_INET, _ip.c_str(), &_address.sin_addr); // 主机转网络 && 字符串点分十进制转4字节
_address.sin_port = htons(port); // 主机转网络
_len = sizeof(_address);
}
这样就没有问题了。
总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~