目录
前言:
前文我们使用TCP实现了一个基本的回显功能,本文我们基于TCP再实现一个比较进阶的功能,预想的结果是主机AB通信,A作为服务器,B作为客户端,B输入常见的命令,能收到该命令在A主机的结果。
也就是说B可以作为A的分身执行命令了,那么这里我们是不是还要重新写一遍自定义XShell?当然不用,我们这里使用的函数可以完美解决自定义Xshell的问题。
那么话不多说,直接开始编写代码吧!
InetAddr.hpp
该头文件只是为了让我们后来打印更加方便,并且它没有实质性的干货内容,它要做的工作只是用来返回某个主机的IP地址,或者是端口号,并且因为是网络转主机,所以用到的函数和我们之前用到的还有点差别。
首先,对于IP地址来说,网络转主机,我们之前用的函数是inet_ntoa,参数列表为:
返回值是char*,那么因为它的返回值是静态缓冲区的指针,所以并不是线程安全的,举个例子,因为每次调用都会覆盖这个区域的内容,所以:
#include <iostream>
#include <arpa/inet.h>
int main() {
struct in_addr addr1, addr2;
inet_aton("192.168.1.1", &addr1);
inet_aton("10.0.0.1", &addr2);
// 第一次调用
char* s1 = inet_ntoa(addr1); // -> "192.168.1.1"
// 第二次调用会覆盖上一次的返回内容
char* s2 = inet_ntoa(addr2); // -> "10.0.0.1"
std::cout << "s1: " << s1 << std::endl; // ❗ 输出是 "10.0.0.1",而不是预期的 "192.168.1.1"
std::cout << "s2: " << s2 << std::endl;
return 0;
}
那么因为不是线程安全的,所以不太推荐使用,接下来是第二个函数,inet_ntop,之所以inet_ntop是线程安全是因为它不依赖于任何的内部静态缓冲区,由调用者提供对应的缓冲区,而每个线程都有自己的缓冲区,不共享内存,所以不存在竞争。
对于端口号什么的也没有干货了,以下是完整代码:
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class InetAddr
{
public:
InetAddr(sockaddr_in addr)
:_addr(addr)
{
ToHost(addr);
}
std::string IP()
{
return _ip;
}
uint16_t Port()
{
return _port;
}
std::string Info()
{
return "[" + std::to_string(_port) + "]:" + _ip;
}
private:
void ToHost(sockaddr_in addr)
{
_port = ntohs(addr.sin_port);
char ip_buff[32];
inet_ntop(AF_INET, &addr.sin_addr, ip_buff, sizeof(ip_buff));
_ip = ip_buff;
}
private:
std::string _ip;
uint16_t _port;
sockaddr_in _addr;
};
TcpServer.hpp
对于TcpServer.hpp来说,我们要修改的只是处理业务的核心逻辑,所以对于TcpServer.hpp其他部分,同学们可以移步这篇文章:初识Linux · TCP基本使用 · 回显服务器_linux使用tcp-CSDN博客
那么,对于服务器处理业务的时候,我们依然要求低耦合,所以对于处理业务部分,我们放在另一个头文件中,那么服务器要做的事情只是调用对应的方法即可,对于怎么实现的,它不用关心。
所以我们这里就要用到C++11的function,包装一下对应的方法,因为在原来处理逻辑的上,我们的基本参数是sockfd,用于两个进程通信,那么为了方便打印,我们也可以加上对应的Inetaddr。
所以对于ThreadData部分我们就要改一下:
class ThreadData
{
public:
int _sockfd;
TcpServer *_self;
InetAddr _addr;
public:
ThreadData(int sockfd, TcpServer *self, InetAddr addr)
: _sockfd(sockfd), _self(self), _addr(addr)
{
}
};
那么在Loop里面处理函数的时候,我们保留多线程的方法,源代码几乎没改动,只是加了一个InetAddr:
// service
InetAddr addr(client);
std::cout << "client" << "[" << addr.Port() << "]:" << addr.IP() << std::endl;
pthread_t tid;
ThreadData *td = new ThreadData(sockfd, this, addr);
pthread_create(&tid, nullptr, Excute, td); // 1.成员函数默认有this指针
对于处理函数的时候,也不再是指向某个具体的函数了,而是TcpServer的成员变量:
static void *Excute(void *args)
{
pthread_detach(pthread_self()); // 为了让主线程不等待
ThreadData *td = static_cast<ThreadData *>(args);
td->_self->_service(td->_sockfd, td->_addr);
::close(td->_sockfd);
delete td;
return nullptr;
}
using command_service_t = std::function<void(int, InetAddr)>;
private:
uint16_t _port;
int _listensocket;
command_service_t _service;
对于TcpServer主要的变动只是处理业务逻辑上的方法调用,对于真正的调用方法,我们单独启一个.hpp文件。
Command.hpp
对于Command.hpp来说,是处理业务的核心逻辑,那么它要执行的操作就是收到B主机输入的字符串,并且将结果返回B主机。
那么涉及到的函数是popen:
popen
函数用于在程序中打开一个进程,通过管道与该进程进行通信。这个函数的基本操作就是输入一个命令,我们可以给它设置对应的选项,其中有w r a,分别是写,读,追加。
那么我们这里就写一个简单的读取操作之类的操作就可以了,并且我们实验完之后,我们也能发现我们的Xshell也是这么工作的。即客户端为长服务,我们输入命令然后它执行完给我们返回:
当然了具体怎么工作的,涉及到的协议是哪个我们不关心,我们了解即可。
而对于常见的命令来说,比如rm which whoami pwd,它们中有的十分危险,比如rm,有的毫无威胁,比如pwd,所以我们就可以设置一个白名单,像pwd这种命令,不用检查,直接使用就可以了,像rm这种危险命令,我们应该拉入黑名单,那么这里我们就以文件操作,设置一个白名单,黑名单同理设置:
class Command
{
public:
Command()
{
SafeCommandLoad();
}
private:
void SafeCommandLoad()
{
std::fstream file("command.txt");
if (!file)
{
perror("fstream");
}
std::string line;
while (std::getline(file, line))
{
if (line.empty())
continue;
else
_cmd_str.insert(line);
}
file.close();
}
private:
std::set<std::string> _cmd_str;
};
那么有了白名单之后,也是处理不了对应的业务的,我们需要专门的处理函数,那么针对于处理函数,我们先单独写一个逻辑,然后在另外的函数调用这个函数即可,因为对于service函数来说可能处理的业务不止这一个,所以我们分两个函数进行。
对于Excute函数,我们使用popen函数接受命令行字符串,如果fp不为空,那么文件打开成功,然后是正常的IO,我们定义一个缓冲区,使用getline获取到缓冲区中每行的内容,然后使用字符串接受,最后返回即可。并且不要忘记pclose。
这里有一个很有意思的电,比如touch filename,touch是不会返回任意的字符串的,那么如果我们在返回值不加处理,比如不使用三目操作符,返回的就是空字符串,对于后面使用的send来说,直接发送长度为0的字符串没有关系,但是客户端会一直read,而长度为0的字符串,在TCP中实际上是不会发送任何数据,所以会一直阻塞住。
功能 | read() | recv() |
---|---|---|
阻塞行为 | 有 | 有 |
用于 TCP | ✅ | ✅ |
用于 UDP | ❌(不建议) | ✅ |
支持 flags | ❌ | ✅(如 MSG_PEEK ) |
std::string Excute(std::string cmd_str)
{
FILE *fp = popen(cmd_str.c_str(), "r");
if (fp)
{
std::string result;
char line[1024];
while (std::fgets(line, sizeof(line), fp))
{
result += line;
}
pclose(fp);
return result.empty() ? "success" : result;
// return result.empty() ? "success\n[END]" : result + "[END]";
}
return "excute error";
}
那么在Service函数中要执行的操作就是调用Excute函数,执行主机B输入的命令请求,然后通过sent发送出去,这里的操作就非常类似于之前回显功能的函数了,也没有什么干货。
void Service(int sockfd, InetAddr addr)
{
while (true)
{
char buffer[1024]; // command buffer
ssize_t n = ::recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
std::string result = Excute(buffer);
send(sockfd, result.c_str(), result.size(), 0);
}
else if (n == 0)
{
std::cout << "client quit: " << addr.Info() << std::endl;
break;
}
else
{
std::cout << "read error " << addr.Info() << std::endl;
break;
}
}
}
tcpserverMain.cc
因为我们在TcpsServer.hpp和Command.hpp中选择了低耦合,所以我们分别进行了服务器和业务的之间不同的处理,那么如何绑定服务器和业务呢?
使用bind函数就可以:
int main(int argc, char *argv[])
{
if (argc != 2)
{
perror("parameter error");
exit(-1);
}
uint16_t port = std::stoi(argv[1]);
Command command;
std::unique_ptr<TcpServer> tsver = std::make_unique<TcpServer>(std::bind(&Command::Service, &command, std::placeholders::_1, std::placeholders::_2), port);
tsver->Init();
tsver->Loop();
return 0;
}
本次的功能也没有太多亮点,主要是使用recv和send为之后埋下铺垫,并且了解函数popen的基本使用。
感谢阅读!