目录
引入
我们将使用udp协议+ipv4协议来写cs之间的网络通信
- 将服务端运行在我们的云服务器上,其他机器可以运行客户端来向服务端发送消息
- 服务端收到消息后,经过一定处理直接发回给相应的客户端
- 并且,我们将一步一步改善我们的代码,让他变得更加美观+拥有更多功能
基础版
服务端将收到的数据经过包装后直接回传给发送端,类似于echo
服务端
思路
封装成一个类,提供运行接口即可
启动后创建套接字,并保持等待数据的状态
头文件+log类
下面是用到的头文件:
#include <netinet/in.h> #include <sys/types.h> #include <sys/socket.h> #include <unistd.h> #include <arpa/inet.h> #include <strings.h> #include <cstring> #include <string> #include <iostream> #include "Log.hpp"
以及用来提示消息的log类
数据格式 -- 时间+消息
#pragma once #include <iostream> #include <time.h> #include <stdarg.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <stdlib.h> #define INFO 0 #define DEBUG 1 #define WARNING 2 #define ERROR 3 #define FATAL 4 // 致命的错误 #define SIZE 1024 class Log { public: Log() { } void operator()(int level, const char *format, ...) { time_t t = time(nullptr); struct tm *ctime = localtime(&t); char leftbuffer[SIZE]; snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(), ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday, ctime->tm_hour, ctime->tm_min, ctime->tm_sec); va_list s; va_start(s, format); char rightbuffer[SIZE]; vsnprintf(rightbuffer, sizeof(rightbuffer), format, s); va_end(s); // 格式:默认部分+自定义部分 char logtxt[SIZE * 2]; snprintf(logtxt, sizeof(logtxt), "%s %s", leftbuffer, rightbuffer); printf("%s\n", logtxt); } ~Log() { } private: std::string levelToString(int level) { switch (level) { case INFO: return "INFO"; case DEBUG: return "DEBUG"; case WARNING: return "WARNING"; case ERROR: return "ERROR"; case FATAL: return "FATAL"; default: return "NONE"; } } };
套接字的初始化
思路
首先创建出套接字文件
- 这里使用udp协议+ipv4网络协议
- 所以,可以确定socket函数中使用的参数为AF_INET, SOCK_DGRAM
然后我们需要构建一个存放了网络地址信息(ip+端口号+其他信息)的结构体
- 传参传的是公共类型的结构体,但实际使用的还是那两个中的一个
- 所以需要手动构建一个
- 初始化可以用memset,也可以用bzero
端口号需要在网络中传输,但它最初是在类内初始化的,是跟随系统字节序列来的
我们无法保证系统一定是大端
所以在将端口号传入结构体中时(网络中使用的端口号就是从这个结构体中存放的),需要转换为网络字节序列
同理,ip地址也需要传输,所以也需要转换为网络序列
(ip地址和端口号的选择在后面讲)
我们虽然使用了系统提供的类型,但这里的变量依然是在用户空间中定义的,并没有和内核中的套接字产生关联
- 套接字需要这个结构体中的数据,将数据传进内核的套接字里,才叫做绑定
- 前面的都只是预备工作,实际的绑定工作由bind函数完成 -- 通过网络文件描述符和结构体对象,将文件信息和网络信息相关联
以上,服务器端的套接字就建立完成
代码
void init() { // 创建套接字文件 sockfd_ = socket(AF_INET, SOCK_DGRAM, 0); if (sockfd_ < 0) { lg(FATAL, "socket create error, sockfd : %d,%s", sockfd_,strerror(errno)); exit(SOCKET_ERR); } // 创建sockaddr结构 struct sockaddr_in addr; socklen_t len = sizeof(addr); bzero(&addr, len); addr.sin_addr.s_addr = inet_addr(ip_.c_str()); addr.sin_family = AF_INET; addr.sin_port = htons(port_); // 绑定套接字信息 int res = bind(sockfd_, reinterpret_cast<const struct sockaddr *>(&addr), len); if (res < 0) { lg(FATAL, "bind error, sockfd : %d,%s", sockfd_,strerror(errno)); exit(BIND_ERR); } lg(INFO, "bind success, sockfd : %d", sockfd_); }
服务器开始运行
思路
服务端负责获取客户端发送的数据
- 使用recvfrom函数获得数据报形式的数据(也就是定义一个缓冲区)
- 且拿到发送方的网络地址信息(结构体),方便将响应数据发回给对方
拿到数据后,进行处理
- 这里在消息前封装一些标识字段 -- ip+端口号+发送时间
处理完后,就发回给发送方
- 使用sendto函数发送数据报
代码
std::string generate_id(const std::string ip, const uint16_t port) { return "[" + ip + ":" + std::to_string(port) + "]"; } std::string process_info(const std::string &info) { time_t t = time(nullptr); struct tm *ctime = localtime(&t); char time_stamp[SIZE]; snprintf(time_stamp, sizeof(time_stamp), "[%d-%d-%d %d:%d:%d]:", ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday, ctime->tm_hour, ctime->tm_min, ctime->tm_sec); std::string id = generate_id(ip_, port_); std::string res = id + time_stamp + info + "\n"; return res; } void run() { init(); // 开始收发数据 char buffer[buff_size]; std::string message; while (true) { memset(buffer, 0, sizeof(buffer)); struct sockaddr_in src_addr; socklen_t src_len = sizeof(src_addr); // 获取数据 ssize_t n = recvfrom(sockfd_, buffer, sizeof(buffer) - 1, 0, reinterpret_cast<struct sockaddr *>(&src_addr), &src_len); if (n < 0) { lg(WARNING, "recvfrom error, errno: %d, err string: %s", errno, strerror(errno)); continue; } buffer[n] = 0; std::string id = generate_id(inet_ntoa(src_addr.sin_addr), ntohs(src_addr.sin_port)); message = id + "sever recvfrom success"; lg(INFO, message.c_str()); // 处理数据 std::string echo_info = process_info(buffer); // 响应给发送端 sendto(sockfd_, echo_info.c_str(), echo_info.size(), 0, reinterpret_cast<const struct sockaddr *>(&src_addr), src_len); message = id + "sever sendto success"; lg(INFO, message.c_str()); } }
注意点 -- ip地址和端口号的来源
ip地址的选择
可以使用默认ip,也可以自己手动传入
udp_server(const uint16_t port = 8080,const std::string ip = "0.0.0.0") : ip_(ip), port_(port), sockfd_(0) { }
#include "udp_server.hpp" int main() { udp_server s; //udp_server s(8080,"127.0.0.1"); s.run(); return 0; }
这里如果服务端直接连接云服务器的公有ip,会报错:
- 因为云服务器禁止直接绑定公网ip
- 一台主机上可能会配置多个网卡,你看到的ip只是其中一个
- 如果就固定地绑定了那一个,你的服务器就只能收到那一个ip地址收到的报文,其他ip收到的就看不到了
- 所以,如果我们将ip地址写成0(也就是那个"0.0.0.0"),就可以将发往这台主机(无论是哪张网卡,它会动态识别是否属于该主机)的所有信息都向上交付给对应端口
- 或者用宏 INIDDR_ANY
- 虚拟机是可以直接绑定ip的
本地环回地址
这里的127.0.0.1指的是本地环回地址
- 如果服务器绑定这个ip,就不会将数据发送到网络,而是贯穿网络协议栈后,重新发回本主机
- 也就是本地通信
端口号
所以,我们一般用大一点的数字分配给我们的服务端
运行情况
#include "udp_server.hpp" #include <cstdlib> using namespace std; int main() { udp_server s; // udp_server s(8080,"127.0.0.1"); s.run(); return 0; }
netstat -nlup
- n表示,把所有可以显示为数字的显示成数字,否则有些会是字符串
- p表示,显示pid信息
用于检查网络状态和网络连接:
每列表示的含义:
- 协议 收发报文的个数 本地ip地址+端口号 可以接收什么客户端的消息
只要可以查到信息,就说明这个服务器已经启动成功了
客户端
思路
和服务端的流程差不多,依然是封装成类,调用启动接口就可以启动客户端
启动后,就等待用户进行输入
初始化
思路
客户端也需要创建自己的套接字,但是不需要手动绑定
- os会为我们自由分配一个端口号(客户端的端口号没有什么要求,只需要保证最基本的唯一性即可),且将本机ip和端口号与创建出的套接字绑定
- os什么时候为我们绑定?
- 首次发送消息的时候,也就是需要网络通信的时候再绑定
因为需要将输入的数据发给服务端,所以就需要提前知道服务端的套接字地址信息
日常生活中,我们一般直接通过网址获得网站提供的服务
网址经过处理后,就是ip地址,而端口号是固定的,一个ip地址就会有对应的固定端口号
所以我们知道网址=客户端知道了ip地址=服务端的套接字被我们获取到
但这里我们写的很简陋,只能手动提供服务端的ip和端口号,然后构建出服务端的网络地址信息(结构体)
(使用的接口还是那些)
代码
struct data { int sockfd_; struct sockaddr_in *paddr_; socklen_t len_; }; data *init() { // 创建套接字文件 int sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (sockfd < 0) { lg(FATAL, "socket create error, sockfd : %d", sockfd); exit(SOCKET_ERR); } // 创建sockaddr结构 struct sockaddr_in *svr_paddr = new sockaddr_in; socklen_t svr_len = sizeof(*svr_paddr); bzero(svr_paddr, svr_len); svr_paddr->sin_addr.s_addr = inet_addr(ip_.c_str()); svr_paddr->sin_family = AF_INET; svr_paddr->sin_port = htons(port_); return new data({sockfd, svr_paddr, svr_len}); }
客户端的运行
思路
就是等待用户输入,然后发送给服务端
然后等待服务端的响应数据
最后将服务端处理后的数据打印出来
代码
void run() { data *d = init(); std::string info; char buffer[buff_size]; while (true) { std::cout << "Please enter:"; std::getline(std::cin, info); // 将消息发送给服务器 sendto(d->sockfd_, info.c_str(), info.size(), 0, reinterpret_cast<const struct sockaddr *>(d->paddr_), d->len_); info.clear(); struct sockaddr_in addr; // 仅用于填充参数,拿到自己的地址信息没啥意义 socklen_t len = sizeof(addr); // 获取数据 ssize_t n = recvfrom(d->sockfd_, buffer, sizeof(buffer) - 1, 0, reinterpret_cast<struct sockaddr *>(&addr), &len); if (n < 0) { lg(WARNING, "recvfrom error, errno: %d, err string: %s", errno, strerror(errno)); continue; } buffer[n] = 0; std::cout << buffer << std::endl; memset(buffer, 0, sizeof(buffer)); } }
运行情况
#include "udp_client.hpp" #include <cstdlib> using namespace std; int main() { udp_client c; //udp_client c(8080,"127.0.0.1"); c.run(); return 0; }
运行情况
这样我们就完成了cs互相通信的过程
改成命令行输入
上面我们是在代码中定义了ip地址和端口号,也可以以命令行的形式,将服务器的ip地址和端口号输入
- 也就是利用main函数的参数
- 只需要改一下主函数代码即可
服务端
#include "udp_server.hpp"
#include <cstdlib>
using namespace std;
//./udp_server port
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cout << "./udp_server port(port>=1024)" << std::endl;
exit(1);
}
udp_server s(atoi(argv[1]));
s.run();
return 0;
}
客户端
#include "udp_client.hpp"
#include <cstdlib>
using namespace std;
//./udp_client ip port
int main(int argc, char *argv[])
{
while (argc != 3)
{
std::cout << "./udp_client ip port(port>=1024)" << std::endl;
exit(1);
}
udp_client s(atoi(argv[2]), argv[1]); // port,ip
s.run();
return 0;
}
运行情况
如果输入的内容不足,就会提示:
然后cs端都运行起来,并发送消息:
远程让服务器执行命令
引入
除了简单的转发消息,我们也可以发送命令,远程让服务器执行命令,且将执行结果传给客户端,让我们可以看到
- 这不就是我们的shell吗
- 其实之前有模拟过简单的shell(创建子进程,使用exec系列函数,让子进程去执行命令)
或者直接用popen函数
它会在内部帮助我们创建一条管道,且创建子进程+执行命令,然后把执行后的结果写入到管道文件中,以文件指针的形式返回给我们
command
- 要执行的shell命令
mode
- 表示要打开的管道的模式
- 看你想要如何与命令交互:
- 如果是只想要查看命令的输出,就以"r"打开 (ls)
- 如果想要将一些数据发送给它作为输入,就以"w"打开 (cat)
分层
所以我们考虑改变一下代码结构,将处理数据与服务器收到数据解耦
- 也就是在服务端处理数据时,使用自定义的处理方法(以回调的方式使用)
代码
服务端类里面修改的不多
在处理数据那里,我们直接调用参数:
void run(func_t func) { init(); // 开始收发数据 char buffer[buff_size]; std::string message; while (true) { memset(buffer, 0, sizeof(buffer)); struct sockaddr_in src_addr; socklen_t src_len = sizeof(src_addr); // 获取数据 ssize_t n = recvfrom(sockfd_, buffer, sizeof(buffer) - 1, 0, reinterpret_cast<struct sockaddr *>(&src_addr), &src_len); if (n < 0) { lg(WARNING, "recvfrom error, errno: %d, err string: %s", errno, strerror(errno)); continue; } buffer[n] = 0; std::string id = generate_id(inet_ntoa(src_addr.sin_addr), ntohs(src_addr.sin_port)); message = id + "sever recvfrom success"; lg(INFO, message.c_str()); // 处理数据 std::string echo_info = func(buffer); // 响应给发送端 sendto(sockfd_, echo_info.c_str(), echo_info.size(), 0, reinterpret_cast<const struct sockaddr *>(&src_addr), src_len); message = id + "sever sendto success"; lg(INFO, message.c_str()); } }
因为我们需要将处理函数放在类外,所以多增加了一个接口,用于外部调用
static std::string get_id() //外部使用 { udp_server obj; return obj.generate_id(obj.ip_, obj.port_); } private: std::string generate_id(const std::string ip, const uint16_t port) { return "[" + ip + ":" + std::to_string(port) + "]"; }
主函数这里多修改了一些:
- 分出两个处理函数
- 把获取时间的功能也单拎了出来
#include "udp_server.hpp" #include <cstdlib> #include <cstdio> using namespace std; std::string get_time() { time_t t = time(nullptr); struct tm *ctime = localtime(&t); char time_stamp[SIZE]; snprintf(time_stamp, sizeof(time_stamp), "[%d-%d-%d %d:%d:%d]:", ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday, ctime->tm_hour, ctime->tm_min, ctime->tm_sec); return time_stamp; } std::string process_info(const std::string &info) { std::string time_stamp = get_time(); std::string id = udp_server::get_id(); std::string res = id + time_stamp + info; return res; } std::string process_command(const std::string &cmd) { std::string time_stamp = get_time(); std::string id = udp_server::get_id(); std::string res; FILE *fp = popen(cmd.c_str(), "r"); if (fp == nullptr) { lg(ERROR, "popen error, errno: %d, err string: %s", errno, strerror(errno)); return ""; } char buffer[buff_size]; memset(buffer, 0, sizeof(buffer)); if (fgets(buffer, sizeof(buffer) - 1, fp) == nullptr) { res = id + time_stamp + "command error"; return res; } while (true) { memset(buffer, 0, sizeof(buffer)); if (fgets(buffer, sizeof(buffer) - 1, fp) != nullptr) { res += buffer; } else { break; } } return res; } int main() { udp_server s; // udp_server s(8080,"127.0.0.1"); s.run(process_command); return 0; }
运行情况
如果输入的不是命令,会提示给用户:
如果是命令,就会返回给我们运行结果: