目录
3.1 创建socket文件描述符(TCP/UDP、客户端 + 服务器)
一、UDP中的socket编程常用接口
1. socket的含义
在未来要实现网络通信,大家实际上写的是应用层与传输层交互的代码。而传输层是属于OS的,这也就注定了未来我们一定要使用系统调用接口。OS为了支持网络编程,也就提供了一系列的网络相关接口。
在上文中讲过了,在网络中通过“ip + port”来表示网络中的唯一主机中的某个进程。这里的“ip + port”其实被称为“socket”,即套接字。
2. sockaddr结构
如果大家仔细观察了socket编程中的接口,会发现其中的很多接口都存在一个“struct sockaddr *”结构体指针。那么这里的这个struct sockaddr结构体是什么呢?
首要大家要知道,套接字其实不止一种。在上面介绍的“ip + port”准确来说叫做“网络套接字”。在现在比较主流的套接字有三种,分别是网络套接字、原始套接字和unix域间套接字。其它套接字暂时不做考虑。
网络套接字可以用于跨主机通信和本地通信,主要使用与应用层开发。
unix域间套接字则只能用于本地通信,它的使用接口和网络套接字是非常相似的。可以将其看成和管道相似的东西。
原始套接字的功能比较特殊。上文中说了数据传输时从当层逐次往下传输,例如应用层的数据就要通过传输层、网络层、数据链路层,层层往下传输。而使用域间套接字就可以做到在应用层跳过传输层直接访问网络层、数据链路层等其他层的内容。这一套接字不是普通用户需要使用,一般是一些比较特殊的场景,比如数据抓包、网络侦测等才会需要使用。
既然有这多种套接字,就说明如果要使用这些套接字,就需要设计出对应种类的套接字接口,这无疑是非常麻烦的。并且这些套接字的接口中有很多功能都是相同的,并没有必要全部重新设计一套。因此,套接字的设计者就想设计一套能够适用多种套接字的接口,由此就诞生了sockaddr结构。
以网络套接字和unix域间套接字为例。网络套接字和unix套接字的结构体可以看成如下所示:
注意,这里的in和un应该看做inet和unix。
在网络套接字和unix域间套接字的结构体中,前2字节被叫做16位地址类型,用于标识是采用网络通信还是本地通信,采用AP_INET和AP_UNIX这样的协议家族中的宏来进行区分。在这后面的内容就不一样了。网络套接字中需要填写ip地址和端口号;而unix域间套接字可以看成和命名管道是一样的,都是通过文件来实现数据交换的,要填写的就是该文件的路径名。如果要实现这两种套接字,就必须要设计两套不同的接口。
设计者为了减少设计的负担,并没有采用设计两套套接字的方案,即不使用上面的两个结构体,而是使用sockaddr结构体:
sockaddr结构体中,前2个字节依然代表16位地址类型。后14字节不重要。在使用时,依然是定义sockaddr_in或sockaddr_un结构体,然后将对应的数据填进去。但是在给函数传参的时候,就需要将结构体强转为sockaddr。对应的函数在接收到这个数据时,会先提取这个结构体的前2个字节,如果它的内容为AF_INET,那么就在这个结构体内将其再强转回sockaddr_in。然后再根据强转回的类型采取不同的处理策略。sockaddr_un同理。通过这种方式,就能够用同一个函数来接收不同的结构体并进行不同的处理了。这种思想其实就是C++中的多态。
大家此时可能会有一个疑惑,那就是为什么这里不是void*呢?void*可以接收任意类型的参数,在这个场景下不是正好适用么,这里为什么还要单独设计一个sockaddr结构,而不是用void*呢?这其实是一个历史遗留问题。void*在这里确实可以用,但是网络通信出现的实在是太早了,当初设计这批接口的时候,void*还没有被加入C的标准中,无法使用void*。当void*被加入C标准中时,这批接口已经被使用了很久了。而C和C++秉持着向下兼容的理念,再加上这些接口还是OS中的接口,也就不敢对这批接口进行修改,只能将其保持原样。
3. socket编程中UDP协议常用接口介绍
这里就介绍几个比较常用的socket接口。
3.1 创建socket文件描述符(TCP/UDP、客户端 + 服务器)
socket函数可以用于创建套接字:
前文中讲过,网络通信其实和进程间通信是一样的,只不过网络通信的共享资源是网络资源而已。但是,当外部的数据需要写入另一台主机时,也是需要通过网络来进行读写的。而socket的作用,其实就是创建一个文件并将这个文件与网卡建立联系,使其可以接收网络中传输的数据。
domain参数,表示你想用哪种方式进行通信:
在这些通信方式中最常用的就是最上面的两个。其中AF_UNIX就是unix域间套接字要用的,表示本地通信。而AF_INET是网络套接字中使用的,表示网络通信。
type,表示你的数据是以什么形式传递的:
在这里几个宏里面,SOCK_STREAM表示字节流;而SOCK_DGRAM表示数据报。OS会根据你所选用的方式打开其对应的协议采取对应的方案。
protocol,表示使用哪种协议进行通信。这里一般默认填0即可。因为在前面的domain和type填好后,其实就已经决定了采用哪种协议。例如domain和type分别填了AF_INET和SOCK_STREAM,就表示使用TCP协议进行通信,此时该函数就默认使用了TCP协议,填0就表示采用前面两个参数所决定的协议。
再来看它的返回值:
socket函数在调用成功时会返回一个文件描述符,调用失败时返回-1。这里的返回值也印证了前文中说的网络通信中要用文件来读写数据的说法。
此时有人就会奇怪了,为什么网络通信可以使用文件来操作呢?追根究底,就是我们所说的“linux下一切皆文件”。在文件系统创建一个文件时,虚拟文件系统会给该文件创建一个文件结构体对象,然后创建文件的进程中是有一个文件描述符表的,这个文件描述符表是一个数组,它的下标就是文件描述符。文件描述符表中保存的是结构体指针,指向对应的文件结构体。这些文件结构体中保存了文件的各种属性和一些函数指针。这些函数指针可以指向OS底层中不同的方法,既可以是键盘,也可以是鼠标。既然这些函数指针可以指向键盘、显示器、鼠标等等硬件,当然也可以指向网卡。因此,我们就可以用操作文件的方法来操作网络了。当然实际中并不是这么简单,还会设计协议栈等内容,这里只是为了方便理解所以省去了中间的过程。
3.2 绑定端口号(TCP/UDP,服务器)
当套接字创建好后,就有了一个文件可以用于收发数据了。但是这里仅仅是拿到了该文件的文件描述符,而这个文件将来要用于那个ip地址下的哪个进程通信,这里还没有指明。
此时就需要用bind函数进行绑定:
该函数可以用于与一个创建出来的socket绑定。简单来讲就是告诉一个文件它未来要与哪个ip下的哪个进程通信。
(1)sockfd
就是使用socket创建套接字时返回的文件描述符。
(2)addr
addr,就是一个sockaddr *的结构体指针。因为大家在这里一般是用网络通信,所以这里就拿sockaddr_in来解释一下这个结构体应该怎么用。
先创建一个sockaddr_in的结构体对象,然后转到它的类型定义中去。在定义时可以用bzero接口将结构体清空:
s表示要清空的数据的指针,n表示要清空的数据的大小
注意,在创建sockaddr_in结构体对象时要包含<arpa/inet.h>头文件,该头文件在bind接口的文档中没有写。如果没有这个头文件,就无法创建sockaddr_in结构体对象。
转到类型定义后,就可以看到如下内容:
这就是sockaddr_in结构体中的内容。在这里面主要关注三个内容。
SOCKADDR_COMMON。转到它的类型定义中去:
在上图的内容中就可以发现,这里其实就是通过这个接口生成了一个sa_family_t的变量。有人可能不理解这里的##是什么意思,这其实就是拼接。假设传进来的sa_prefix的内容是A_,那么这里就会拼接出A_family。
重新看回上面的结构体。上文中说了,在网络通信中,要告诉文件它要与哪个ip下的哪个进程相通信。这个结构体中的sin_port就是端口号,in_port_t就是表示2字节的整数的类型。
sin_addr就是接收ip地址。转到它的类型定义中:
可以看到,这个结构体中就包含了一个s_addr,它的类型为in_addr_t,其实就是uint32_t的重命名,表示一个4字节,32位的整数,用于接收ip地址。
此时大家可能就会有所疑惑了。ip地址这个东西,在实际中无论是在windows下,还是在linux下查找时,返回的都是一个“点分十进制”的字符串,那为什么传的时候却是传一个4字节的整数呢?这里大家要搞清一个问题,“点分十进制”的ip地址,它仅仅是一个表现形式,有较好的可读性,是为了方便外层的用户看的。但在实际的网络传输中,ip地址是一个4字节,32位的整数。
原因就是点分十进制的字符串形式的ip地址除了可读性好以外,一无是处。在使用时不仅需要将其通过字符串分割的方式来进行解析并做各种处理,而且占用还高。一个4个位上全是3位数的ip就需要占15字节。反之整数形式的ip不仅方便读取,而且占用还低。因此,在网络通信中使用的都是整数风格的ip
1. 点分十进制的ip地址转为4字节整数
但是在实际上,因为点分十进制的ip地址可读性好,大众接受程度高,所以在实际使用中也一般是传字符串形式的ip。此时就需要将字符串转化为4字节的整数。要转化也很简单,以“.”作为分隔符获取到对应的数字,然后通过计算将字符串数字转化为整型数字即可。这个动作并不需要用户自己写,直接调用系统接口inet_addr即可:
这个函数中会做两件事。第一件事就是帮我们把点分十进制的ip地址转化为4字节整数;第二件事就是调用htonl函数。因为这个ip地址是需要传给另一台主机的,所以会存在大小端的问题。该函数中会自动调用htonl函数,把一个4字节32位的长整数转化为网络字节序。
2. 字节整数转化点分十进制的ip地址
那4字节的整数ip是如何转化为字符串的呢?转化方式很简单,通过一个结构体就可以实现。假设现在有数字12345,我们只需要定义一个结构体如下:
这个结构体中有4个unsigned char类型的变量。有了这个结构体后,只需要将对应的数字强转为struct ip即可。此时这个数字中的字节就会被默认以char的类型逐个读取。读取完成后再将读取到的内容转化为字符。最后将转化出的字符加上“.”拼接起来即可。
当然,在实际中这个过程并不需要我们来做,直接使用系统中提供的inet_ntoa接口即可:
该接口就是用于将一个in_addr的结构体中的整数ip转化为点分十进制的ip地址。至于这里为什么是结构体,是因为在网络中接收ip和端口号等内容都是通过结构体来实现的。网络套接字所采用的就是sockaddr_in结构体:
这个结构体中还有一个结构体sin_addr,它里面存储的就是4字节整数的ip地址:
至于sockaddr_in结构体中的最后一个成员,就是用于填充数据的,不用管。其实这个结构体中的内容可以看成如下图所示:
(3)addrlen
这个参数就是传入的结构体的大小。
最后再来看bind的返回值:
在绑定成功时返回0,失败则返回-1。
3.3 接收数据
如果想接收数据,可以使用recvfrom接口:
第一个参数sockfd,表示用socket接口创建的用于通信的文件的文件描述符。
第二个参数buf,是一个缓冲区,写入的数据都需要进入这个缓冲区内。
第三个参数len,表示缓冲区的大小。
第四个参数fiags,表示如何获取数据。一般填成0即可,表示阻塞式获取数据。当有数据传入就获取,没有就阻塞等待。
第五个和第六个参数src_addr,和addrlen都是输出型参数。src_addr是一个结构体,用于接收客户端的ip和端口号等内容。addrlen则是这个用于接收这个结构体的大小。
再来看它的返回值:
成功返回接收到的数据的大小。失败则返回-1。
3.4 发送数据给远端服务器
要发送数据给远端服务器,就可以使用sendto接口:
这个接口的参数和接收数据的recvfrom接口一模一样,含义也差不多。
第一个参数sockfd,就是socket接口的返回的文件描述符。
第二个参数buf,就是要传入的数据。
第三个参数len,就是传入的数据的大小。
第四个参数flags,表示如何发送。默认填0,为阻塞式发送。
第五个参数dest_addr,它是一个结构体,里面包含了要向谁发送数据的信息。
第六个参数addrlen,表示这个结构体的大小
再来看它的返回值:
发送成功时,返回发送的数据的字节数;发送失败时,返回-1
4. 查看网络服务器
如果想查看你的liunx中的网络服务器状况,可以输入“netstat -nuap"命令:
该命令在普通用户中使用时会隐藏部分内容,如果想看到所有内容,可以加上sudo。
在该命令的选项中,n表示将所有内容尽可能的以数字形式显示;a表示显示所有的服务器;p表示显示对应的进程。
5. 本地环回
在我们的linux中,一般都存在一个“127.0.0.1”的ip地址,这个ip地址是“一个本地环回”。当使用该ip进行网络传输时,传输的数据会从应用层向下传输,当到达数据链路层时就会向上返回,不会到达物理层:
该ip一般可以用于服务器代码的测试。
6. 云服务器上的网络测试
当大家写好程序后,可能会去使用自己的云服务器的公网ip进行测试。但是,云服务器是虚拟化的服务器,不能直接bind它的公网ip。当然,如果你是在自己的linux机器,例如虚拟机或独立真实的linux环境下,就可以bind自己的公网ip。
7. 服务端ip绑定问题
在服务端中,最好不要指明绑定某一个ip。因为可能出现大家会通过多个ip地址向同一个端口号发数据的情况,如果此时该端口号绑定了一个特定的ip,就会导致除了指明的那个ip以外,其他ip传过来的数据都不会被接收。因此,在实际中要绑定的结构体中的ip最好使用INADDR_ANY:
这个宏其实就是一个全0的ip,可以接收任意ip。
二、实现一个简单的UDP网络程序
因为TCP协议的网络程序实现起来比较复杂,所以这里用UDP协议来实现一个网络程序。
1. 单向通信
在这里,先写一个简单的可以进行单向通信,即客户端向服务端发送消息的程序。
1.1 服务端.hpp文件
(1)整体结构
在服务端中,我们用一个类来封装服务端需要进行的操作。在这个服务端中,该服务端需要有一个提供通信的ip地址和一个端口号来让客户端传入数据,所以需要有对应的ip和port。同时上文中说了,要进行网络通信,首先就需要有通过socket来创建套接字,形成一个文件与网卡建立联系,所以就还需要一个能保存文件描述符的变量来保存socket返回的文件描述符:
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <errno.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
namespace server
{
static const std::string defaultip = "0.0.0.0";//提供一个默认ip
static const int gnum = 1024;
enum//设置退出码
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR
};
class udpServer
{
public:
udpServer(const uint16_t& port, const std::string& ip = defaultip)//构造函数
:_port(port), _ip(ip), _sockfd(-1)
{}
~udpServer()
{}
private:
uint16_t _port;//创建端口号,uint16_t代表一个2字节的整数类型
std::string _ip;//ip地址
int _sockfd;//接收socket函数返回的文件描述符
};
}
在这里的defaultip是一个全0的ip,用于初始化ip。同时为了方便,这里也通过联合体定义了 几种错误。
(2)服务器初始化
服务器的整体结构有了后,在启动服务器之后就需要对服务器进行初始化。初始化的步骤就比较简单了,首先是调用socket函数创建套接字,然后调用bind函数进行绑定。这两个函数的使用方法在上文中已经讲解过了,就不过多赘述:
void initServer() // 服务器初始化
{
//1. 使用socket接口创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);//采用网络通信,数据报形式。即UDP协议
if(_sockfd == -1)
{
std::cerr << "socket error: " << errno << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "socket success: " << errno << " : " << _sockfd << std::endl;
//2. 使用bind接口进行绑定。在服务端中一定要自己bind。因为服务器需要有一个明确的port以供使用,不能随意更改
struct sockaddr_in local;//创建结构体对象
bzero(&local, sizeof(local));//将结构体中的数据清0
local.sin_family = AF_INET;//协议家族,采用本地通信还是网络通信
local.sin_port = htons(_port);//通过htons将端口号转为为网络字节序
// local.sin_addr.s_addr = inet_addr(_ip.c_str());//将点分十进制的ip通过inet_addr转化为4字节整数并转化为网络字节序
local.sin_addr.s_addr = htonl(INADDR_ANY);//支持传入任意ip,方便不同ip传入数据给同一端口号
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));//调用bind进行绑定
if(n == -1)//检测是否绑定成功
{
std::cerr << "bind error: " << errno << strerror(errno) << std::endl;
exit(BIND_ERR);
}
//服务器初始化完成
}
注意,在初始化时,不要让服务器显式bind一个ip地址。因为将来可能会有多个不同ip的客户端通过某个端口向该服务器发送数据,如果显式绑定某个ip,就会导致只有这个ip通过某个端口传来的数据才会被服务器接收,其他ip通过同一个端口传来的数据都无法被接收。
所以填写ip的时候最好就是填写为“INADDR_ANY”,表示可以接收任何ip通过bind的端口传来的数据。
(3)运行服务器
当服务器初始化完成后,就可以准备运行服务器了。大家知道,一个服务器运行起来后,除非用户自行关闭,否则在正常情况下它都是不会退出的。因此,服务器在运行状态就是一个死循环。在这里的这个服务器中,当前主要是用它来接收客户端发来的数据,所以直接使用recvfrom函数来接收即可。同时这里为了方便测试,所以实现让客户端发送数据,然后服务端打印数据即可:
void Start() // 服务器启动
{
char buffer[gnum];//缓冲区
while(true)//一般来讲,一个服务器在启动后,其实是死循环的。
{ //在正常情况下,只要用户不退出,服务器就不会退出
struct sockaddr_in peer; // 创建结构体对象
bzero(&peer, sizeof(peer));
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&peer, &len);//接收客户端传过来的数据
if(s > 0)
{
buffer[s] = 0;
std::string clientip = inet_ntoa(peer.sin_addr);//获取ip,并将4字节整数的网络系列转化为点分十进制
uint16_t clientport = ntohs(peer.sin_port);//获取端口号
std::string message = buffer;
std::cout << clientip << "[" << clientport << "]#" << message << std::endl;
}
}
}
1.2 服务器的.cpp文件
要将这个服务器启动,在这里让它以“程序名 port”的形式启动。在这里,就需要用到main函数中的参数了。大家知道main函数中是有参数的,第一个参数argc表示传进来的命令个数,第二个参数argv[]中则保存了这些命令。第三个参数则是环境变量。
在这里因为是以“程序名 port”的格式启动,所以要传两个参数,如果不足两个参数就结束。按照格式启动后,就可以创建一个服务器的对象然后初始化并启动了:
#include"udpServer.hpp"
#include<memory>
using namespace server;
//127.0.0.1 本地换回。在本主机内的应用层流到数据链路层,然后向上传输。不会达到物理层。
//本地换回主要用于服务器代码的测试
void Usage(std::string proc)
{
std::cout << "\nUsage:\n\t" << proc << " local_port\n\n";
}
//运行程序时,按照./udpServer port的形式运行
int main(int argc, char *argv[])//显示调用main函数中的参数
{
if(argc != 2)//参数不足两个,表示未按照规定传入ip和port
{
Usage(argv[0]);//打印错误
exit(USAGE_ERR);
}
uint16_t port = atoi(argv[1]);//注意,从argv中拿到的参数是字符串,要将其转为整数
std::unique_ptr<udpServer> usrv(new udpServer(port));//使用智能指针来构建对象,使其能够自动析构
usrv->initServer();//初始化服务器
usrv->Start();//启动服务器
return 0;
}
1.3 客户端的.hpp文件
(1)整体结构
客户端主要用于向服务器发送数据,所以它和服务器一样,都需要有自己文件描述符。然后在客户端中,该客户端需要通过某个ip下的某个port向服务器发送数据,所以也需要有对应的ip和port来保存这些数据,用于后面的运行:
#pragma once
#include <iostream>
#include <string>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <errno.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
namespace client
{
enum{USAGE_ERR = 1, SOCKET_ERR};
class udpClient
{
public:
udpClient(const std::string& serverip, const uint16_t& _serverport)//构造函数
:_serverip(serverip), _serverport(_serverport), _sockfd(-1), _quit(0)
{}
~udpClient()
{}
private:
int _sockfd;//文件描述符
std::string _serverip;//向该ip地址发送数据
uint16_t _serverport;//想该端口号发送数据
bool _quit;
};
}
在这里的_quit仅仅只是用于标识客户端的运行状态的。
(2)客户端的初始化
客户端的初始化就比服务端的初始化简单了。客户端初始化用户只需要创建套接字即可。无需bind。注意,这里的无需bind是指用户无需自己写bind,而是让OS自动生成,自动bind。因为在未来网络中会有无数客户端向服务器发送数据,如果用户自己bind了端口,就可能出现两个不同的客户端拥有相同的port,并且向同一个ip下的同一个port发送数据。此时就会导致后发数据的客户端因为该port已经被占用,而无法向服务器发送数据,只能阻塞等待。
基于以上原因,在客户端中,用户无需自己bind,也非常不建议用户自己bind。
void initClient()//初始化客户端
{
//1. 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd == -1)
{
std::cerr << "socket error: " << errno << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "socket success: " << errno << " : " << _sockfd << std::endl;
//2. 客户端也需要使用bind绑定端口,因为在未来网络通信时,无论是客户端还是服务端,都需要通过ip+port来通信
//但是,在客户端这里,不需要显式bind。由OS自动bind即可。因为客户端中只需要有一个端口标定唯一性即可。
//更重要的是,写服务器的是一家公司,而使用服务器的却有无数家公司。如果客户端显式绑定一个port,就可能出现公司A的
//某个进程已经绑定了某个端口号,而另一个公司的某个进程也绑定同一个端口号的情况。如果这两个进程刚好向同一个服务器端口
//发送数据,就会出现A公司的程序先启动占用了该端口号,B公司的程序后启动,就无法使用该端口号,进而出现无法网络通信的情况
}
(3)客户端运行
在这里的客户端只需要承载向服务器发送数据的工作,所以只需要调用sendto函数发送数据即可。该函数的使用在上文中也已经讲过了,这里就不再赘述:
void run()//程序启动
{
std::string message;
while(!_quit)
{
std::cout << "Please Enter#";
getline(std::cin, message);
struct sockaddr_in server;
bzero(&server, sizeof(server));
server.sin_family = AF_INET;//网络通信
server.sin_port = htons(_serverport);//端口号
server.sin_addr.s_addr = inet_addr(_serverip.c_str());//将获取到的点分十进制的ip地址转化为4字节的整数
sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));
//在sendto时,OS检测到没有绑定端口,自动生成一个
}
}
1.4 客户端的.cpp文件
在客户端中,因为是要向某个服务端发送数据,所以按照“程序名 ip port”的格式建立通信。main中的参数的作用在上文的服务器的.cpp文件中,已经讲了,就不过多赘述了:
#include"udpClient.hpp"
#include <memory>
#include <assert.h>
#include <netdb.h>
using namespace client;
void Usage(std::string proc)
{
std::cout << "\nUsage:\n\t" << proc << " local_ip local_port\n\n";
}
//./udpClient ip port
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]);//向服务端的哪个端口号发送数据
std::unique_ptr<udpClient> ucli(new udpClient(serverip, serverport));
ucli->initClient();
ucli->run();
return 0;
}
1.4 客户端与服务端的测试
因为大家可能没有两台不同的linux机器,所以在测试时无法在不同ip地址下测试。但是在大家的机器中有一个统一的地址“127.0.0.1”。该地址是一个本地环回,代表的就是你自己的机器,通过这个ip发送的数据会在协议层中向下传输,当到了数据链路层后就会向上返回,可以用于测试服务器代码。
在linux下打开两个会话窗口并分别运行客户端和服务端:
在上图中,左侧为服务端,右侧为客户端。可以看到,客户端发送给服务端的数据,服务端确实可以收到,此时就已经完成了一次简单的udp协议的网络通信。
2. 客户端与服务端的双向通信
大家知道,在实际中客户端将数据发送给服务端后,并不是让服务端打印一遍数据就完事了,而是要让服务器将这份数据拿取执行特定的操作,然后向客户端返回一份数据。所以在这里,就基于上面的单向通信的代码,实现一个可以让服务端向客户端返回数据的程序。
在这里,就实现一个简单的翻译程序,该程序可以将英文翻译为中文。客户端将一个英文字符串发送给服务端,服务端则将翻译结果返回给客户端。
2.1 使用回调函数
为了让服务端能够拿到客户端传过来的数据进行翻译,所以首先就要在创建对象时将对应的方法传过去,所以在服务端的类中新加入一个变量,用来执行指定的任务:
在这个函数中,该函数也要知道是哪个客户端向其发送了数据,然后还要使用这些数据。所以使用function包装器形成一个类名,返回值为void,参数为分别是ip、端口号和字符串数据。
在启动服务器的时候,就要调用该回调函数:
在服务端的.cpp文件中创建对象时,就要传入对应的执行函数:
2.2 形成字典
要实现这一功能,首先就要有一份字典。这里为了方便,所以就自己创建一个dict.txt文件,该文件中写入几个对应的中英文翻译:
有了这写字符串后,就要将这些字符串保存到一个unordered_map中,以这种方式形成映射关系。
在这里使用的是C++中的文件操作接口,当然,如果你想用C中的操作接口也是一样的。为了测试这些是否能正常读取文件中的内容,就先将文件中的内容打印出来:
在main函数中加入该函数,然后运行程序:
可以正常读取。
文件正常读取测试通过后,就可以修改dictInit函数的代码,将文件中的数据映射到unordered_map中了。
因为在文件中的数据是以“:”进行区分的,所以在进行映射前,首先要切割字符串,按照特定的标志位将文件中的字符串切割为中英两个,然后再插入:
完成字符串插入后,再写一个测试函数,来测试这些字符串是否被正常切割并插入其中:
将字典初始化函数和测试函数都添加到main中,运行程序:
根据打印的结果可以知道,这份数据的插入是正常的。
此时,就可以着手让服务端将翻译结果返回给客户端了。
2.3 完成服务端的返回数据和客户端的接收数据
字典准备好后,就可以开始让客户端直接进行数据的收发了。首先是让客户端返回经过字典查询后的数据,这一操作就可以在之前提前准备好的回调函数中完成:
服务端向客户端发送了数据,客户端就需要对这份数据进行接收。这个工作在客户端的运行函数中调用recvfrom函数即可:
2.4 程序测试
将程序修改好后,就可以着手测试了。将字典的初始化函数添加进主函数,然后运行即可:
可以看到,在客户端这一方,就成功的接收到了服务端经过查询后得到的结果。运行正常。
2.4 支持简单的热加载
在上面的程序中,如果想更新字典中的内容,就必须要关闭服务端,让服务端重新加载字典才行。但如果我们不想关闭服务端就更新字典呢?方法很简单,使用信号就可以了。大家知道,信号是可以被捕捉的,可以通过捕捉信号然后给它传函数的方式,让程序执行其他功能。在这里就可以利用信号的这一特性,捕捉一个特定的信号,然后给这个信号的传一个执行函数,再该函数中再次加载字典即可。
写起来也很简单,在main函数中使用signal函数,然后在执行函数中调用字典初始化函数即可:
写好后,如果你想在服务端运行的时候更新字典的内容,直接发送2号信号给服务端即可。这里就不再演示了。
3. 实现从远端执行linux命令
既然客户端中可以发送字符串数据给服务端进行处理,那我们是不是也可以通过客户端向服务端发送一个linux命令,让服务端执行该命令并将结果返回给客户端呢?答案当然是可以的。
同样是以上面的程序为基础进行修改。要让服务端执行linux命令,只需要在服务端中重新传一个回调函数即可。如果大家看过我以前的文章,在以前的文章中我们就已经完成过一个简易的shell,利用fork创建子进程、exec进程替换等函数实现了使用自己的程序来调用linux命令的功能。在这里,其实是可以直接将那份代码拷贝过来放到回调函数中,然后将一些地方修改一下就可以实现从远端执行linux命令的功能了。
3.1 popen函数
在这里就不用去拷贝代码过来的方法,而是popen函数实现:
这个函数的第一个参数command是一个字符串,第二个type则是打开文件的方式,即C中的r、w、a等。其返回值为打开的管道文件的文件指针。
这个函数的作用,大家可以看成它就相当于“pipe” + “fork” + “exec” = popen。在这个函数中,它会解析你传入的字符串,然后创建子进程和使用程序替换来运行该字符串中的命令,再将结果写入到管道文件中。如果你想从这个管道文件中读取数据,就传入“r”;如果是想写入数据,将传入“w”,是以读端打开还是以写端打开取决于用户自己。
3.2 服务端处理
有了popen函数,要实现远端执行linux命令就很简单了。服务端以读的方式调用popen函数,然后将执行命令后获取到的内容从管道中取出,再将这些内容返回给客户端即可。
同样的,首先在服务端要接收到数据,然后让服务端拿着这份数据去执行对应的命令, 再将命令的结果拿到一个字符串内,然后再将这个字符串发送给客户端即可:
3.3 客户端处理
在客户端中就没什么需要改的了,当然, 你也可以修改一下运行客户端函数中的打印来与上面字典翻译的处理相区分。
3.4 程序测试
程序修改好后,将服务端中传给服务端对象的回调函数修改为我们写的远端执行linux命令的函数,然后运行程序:
可以看到,在服务端就确实获取到了对应linux命令执行后的结果。但是这个程序非常简易,还有很多命令是无法支持的,比如需要一些特殊处理的top命令等。但是,一些比较基础的ls、touch等命令是可以支持的。
4. 实现一个简单的聊天室
现在我们已经实现了客户端到服务端的单向通信和客户端与服务端的双向通信,此时就可以基于以上的程序,实现一个多客户端通过一个服务端进行交互的程序了。在这里就实现一个简单的聊天室,让服务端将接收到的消息同时转发给多个客户端,以实现用户在不同的客户端下进行聊天的功能。
4.1 服务端处理
在这个聊天室的程序中,用一个user类来保存用户的相关信息,再用一个onlineuser类来统一保存所有在线的用户。将这两个类都写在另一个文件中,以方便区分。在onlineuser类中,要提供登录、下线、判断用户是否在线和向所有在线用户发送信息的功能:
#pragma once
#include <iostream>
#include <unordered_map>
#include <string>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
struct user//记录用户信息
{
public:
user(const std::string &ip, const uint16_t &port)
:_ip(ip), _port(port)
{}
~user()
{}
std::string ip() {return _ip;}
uint16_t port() {return _port;}
private:
std::string _ip;
uint16_t _port;
};
class onlineUser//记录在线用户,以ip + port来标识
{
public:
onlineUser()
{}
void addUser(const std::string &ip, const uint16_t &port)//添加在线用户
{
std::string id = ip + '-' + std::to_string(port);
users.insert(std::make_pair(id, user(ip, port)));//将标识用户的id与用户添加到unordered_map中
}
void delUser(const std::string &ip, const uint16_t &port)//删除在线用户
{
std::string id = ip +'-' + std::to_string(port);
users.erase(id);
}
bool isOnline(const std::string &ip, const uint16_t &port)//判断用户是否在线
{
std::string id = ip + '-' + std::to_string(port);
return users.find(id) == users.end() ? false : true;
}
void broadcastMessage(int sockfd, const std::string &ip, const uint16_t &port, const std::string &message)//通过sockfd将信息发送给所有用户
{
for(auto& user : users)
{
struct sockaddr_in client;
bzero(&client, sizeof(client));
client.sin_family = AF_INET;
client.sin_port = htons(user.second.port());
client.sin_addr.s_addr = inet_addr(user.second.ip().c_str());
std:: string s = ip + "-" + std::to_string(port) + "# ";//带上id,打印的时候方便看是否是不同客户端
s += message;
sendto(sockfd, s.c_str(), s.size(), 0, (struct sockaddr *)&client, sizeof(client));
}
}
~onlineUser()
{}
private:
std::unordered_map<std::string, user> users;//将用户存储在unordered_map中
};
在登录功能中,就将对应的用户信息保存到unordered_map中,下线则是将用户数据删除。要实现向所有用户发送信息的功能也是比较简单的,直接遍历unordered_map中的数据,向其中所有的用户发送数据即可。
有了上面的功能,就只需要让服务端将从客户端的数据拿到并根据自行判断用户是否在线决定是否转发消息了。
onlineUser onlineusers;//创建对象保存发送信息过来的客户端ip和port
void routeMessage(int sockfd, std::string clientip, uint16_t clientport, std::string message)
{
if(message == "online") onlineusers.addUser(clientip, clientport);//用户输入online,表示上线,将其加入对象中
if(message == "offline") onlineusers.delUser(clientip, clientport);//用户输入offline,表示下线,从对象中删除
if(onlineusers.isOnline(clientip, clientport))//如果用户在线
{
onlineusers.broadcastMessage(sockfd, clientip, clientport, message);//通过socket创建的文件将信息发送给所有在线用户
}
else//用户不在线,返回信息要求用户在线
{
struct sockaddr_in client;
bzero(&client, sizeof(client));
client.sin_family = AF_INET;
client.sin_port = htons(clientport);
client.sin_addr.s_addr = inet_addr(clientip.c_str());
std::string response = "未登陆,请输入“online”以上线";
sendto(sockfd, response.c_str(), response.size(), 0, (sockaddr *)&client, sizeof(client));
}
}
正常来讲应该是要有登录之类的功能的,这里为了简便,就没有提供登录等功能,而是直接让用户输入“online”来告诉服务端,该用户已经上线,然后输入“offline”来告诉服务端该用户下线。
4.2 客户端处理
在客户端这里,客户端同样只有两个事情要做:向服务端发送数据和接收服务端返回的数据。在这里,为了更好的使用,可以创建一个从线程,让该线程负责从服务端读取数据。然后让主线程负责向服务端传输数据:
static void *readMessage(void *args)//执行读取数据的操作
{
int sockfd = *(static_cast<int *>(args));
pthread_detach(pthread_self());//线程分离
while (true)
{
char buffer[1024];
struct sockaddr_in temp;
socklen_t temp_len = sizeof(temp);
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&temp, &temp_len);
if (n >= 0)
buffer[n] = 0; // 将最后一位置0,表示到这里结束
// std::cout << "服务器翻译结果: " << buffer << std::endl;//字典翻译
std::cout << buffer << std::endl; // 远端执行linux命令
}
return nullptr;
}
void run()//程序启动
{
pthread_create(&reader, nullptr, readMessage, (void *)&_sockfd);
struct sockaddr_in server;
bzero(&server, sizeof(server));
server.sin_family = AF_INET; // 网络通信
server.sin_port = htons(_serverport); // 端口号
server.sin_addr.s_addr = inet_addr(_serverip.c_str()); // 将获取到的点分十进制的ip地址转化为4字节的整数
std::string message;
while(!_quit)
{
//1. 向服务端发送数据
// std::cout << "Please Enter#";
fprintf(stderr, "Please Enter# ");
fflush(stderr);
getline(std::cin, message);
sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));
//在sendto时,OS检测到没有绑定端口,自动生成一个
//2. 接收服务端返回的数据。由reader线程执行
}
}
客户端的类的成员变量:
至于其他地方,客户端中就没有什么需要修改的地方了。
4.3 程序测试
这个简易的聊天室程序其实就是在双向通信的代码的基础上新添加了上面的模块,使得程序可以实现从服务端向单个客户端转发数据到服务端向所有在线客户端转发数据的功能。因此,这个聊天室程序的总体框架上和双向通信代码是没有区别的,只是新增了点模块。
有了这个聊天室程序后,就可以着手测试了。因为这个程序我们并没有一个前端可以以供区分自己输入的数据和聊天框中的数据,就会导致这些数据杂糅在一起,不方便看。因此,这里就通过管道的方式,将服务端返回给客户端的数据都通过放入到一个管道文件中,然后再通过另一个窗口从这个管道文件中读取数据。
linux中创建命名管道的命令是“mkfifo 管道文件名”。在这里,就创建了fifo和fifo1两个管道文件,以模拟出两个不同的客户端:
打开5个会话窗口,在其中一个窗口运行服务端:
再在另外四个窗口分别执行“./程序名 127.0.0.1 8080 > 管道文件名”和“cat < 管道文件名”:
在上图中,执行了“./程序名 127.0.0.1 8080 > 管道文件名”命令的窗口充当输入界面,而执行了“cat < 管道文件名”命令的窗口则充当聊天界面。在执行了“./程序名 127.0.0.1 8080 > 管道文件名”命令的窗口输入test:
可以看到,下面的两个窗口都反馈出了未登陆的信息。当然,这里双方不能通信是因为还没有登陆,所以服务端只是单独向发送数据的客户端返回信息。
输入“online”登陆:
此时就提示客户端某个ip和端口下的用户登陆了。这里左边打印两条登陆,右边打印一条登陆,是因为左边先登陆,先打印出登陆语句。然后右边的后登陆,登陆成功后,服务端将该用户的信息转发给了所有在线用户。因此左边的客户端收到两条信息。它们的端口号不同也证明了这一点。
此时就可以开始聊天了:
可以看到,在上图中两个不同的客户端就在同一个服务端下聊天了,完成了通信。可以通过它们的端口号来进行区分。
如果你只有一台linux机器,就可以使用127.0.0.1这个本地换回地址来测试你的网络代码。如果你有不同的linux机器,就可以通过不同的ip地址来进行测试。结果都是一样的。
三、实现一个简单的windows下的客户端
大家可以将自己的linux机器充当服务器,然后在windows下完成一个简单的客户端,再通过windows客户端来实现网络通信。如果大家愿意,甚至可以对windows下的客户端添加图形化界面,例如使用easyx等软件来实现,以此完成一个简单的以linux充当消息转发服务器的windows下的简易聊天室。
windows下的网络通信并不是本文的重点,所以就简单的实现一个windows下的客户端向linux服务端发送消息,然后linux服务端再将消息处理一下后转发回去的程序。
1.linux下的服务端
1.1 服务端头文件
服务端的头文件和上面的程序的头文件是一模一样的,这里就不再多说。
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <errno.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
namespace server
{
static const std::string defaultip = "0.0.0.0";//提供一个默认ip
static const int gnum = 1024;
enum//设置退出码
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
OPEN_ERR
};
class udpServer
{
typedef std::function<void(int, std::string, uint16_t, std::string)> func_t;//包装器,重名一个类型
public:
udpServer(const func_t callback, const uint16_t& port, const std::string& ip = defaultip)//构造函数
:_callback(callback), _port(port), _ip(ip), _sockfd(-1)
{}
void initServer() // 服务器初始化
{
//1. 使用socket接口创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);//采用网络通信,数据报形式。即UDP协议
if(_sockfd == -1)
{
std::cerr << "socket error: " << errno << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "socket success: " << errno << " : " << _sockfd << std::endl;
//2. 使用bind接口进行绑定。在服务端中一定要自己bind。因为服务器需要有一个明确的port以供使用,不能随意更改
struct sockaddr_in local;//创建结构体对象
bzero(&local, sizeof(local));//将结构体中的数据清0
local.sin_family = AF_INET;//协议家族,采用本地通信还是网络通信
local.sin_port = htons(_port);//通过htons将端口号转为为网络字节序
local.sin_addr.s_addr = htonl(INADDR_ANY);//支持传入任意ip,方便不同ip传入数据给同一端口号
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));//调用bind进行绑定
if(n == -1)//检测是否绑定成功
{
std::cerr << "bind error: " << errno << strerror(errno) << std::endl;
exit(BIND_ERR);
}
}
void Start() // 服务器启动
{
char buffer[gnum];//缓冲区
while(true)//一般来讲,一个服务器在启动后,其实是死循环的。
{ //在正常情况下,只要用户不退出,服务器就不会退出
struct sockaddr_in peer; // 创建结构体对象
bzero(&peer, sizeof(peer));
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&peer, &len);//接收客户端传过来的数据
if(s > 0)
{
buffer[s] = 0;
std::string clientip = inet_ntoa(peer.sin_addr);//获取客户端ip,并将4字节整数的网络系列转化为点分十进制
uint16_t clientport = ntohs(peer.sin_port);//获取客户端端口号
std::string message = buffer;
std::cout << clientip << "[" << clientport << "]#" << message << std::endl;
_callback(_sockfd, clientip, clientport, message);//执行回调函数
}
}
}
~udpServer()
{}
private:
uint16_t _port;//创建端口号,uint16_t代表一个2字节的整数类型
std::string _ip;//ip地址
int _sockfd;//接收socket函数返回的文件描述符
func_t _callback;//回调函数
};
}
1.2 服务端源文件
这里只实现服务端接收消息并转回给客户端的功能,所以只是在上面的程序的基础上修改下回调函数,也不过多说。
#include "udpServer.hpp"
#include <memory>
#include <unordered_map>
#include <fstream>
#include <signal.h>
#include <stdio.h>
#include <string.h>
using namespace server;
void routeMessage(int sockfd, std::string clientip, uint16_t clientport, std::string message)
{
std::string response_message = message;
response_message += " [server echo]";
//返回数据
struct sockaddr_in client;
bzero(&client, sizeof(client));
client.sin_family = AF_INET;
client.sin_port = htons(clientport);
client.sin_addr.s_addr = inet_addr(clientip.c_str());
sendto(sockfd, response_message.c_str(), response_message.size(), 0, (sockaddr *)&client, sizeof(client));
}
void Usage(std::string proc)
{
std::cout << "\nUsage:\n\t" << proc << " local_port\n\n";
}
//运行程序时,按照./udpServer port的形式运行
int main(int argc, char *argv[])
{
if(argc != 2)//参数不足两个,表示未按照规定传入ip和port
{
Usage(argv[0]);//打印错误
exit(USAGE_ERR);
}
uint16_t port = atoi(argv[1]);//注意,从argv中拿到的参数是字符串,要将其转为整数
std::unique_ptr<udpServer> usrv(new udpServer(routeMessage, port));
usrv->initServer();//初始化服务器
usrv->Start();//启动服务器
return 0;
}
2. windows下的客户端
其实windows下的网络编程和linux下的网络编程使用的接口是基本一致的,只有几个地方有所区别。
首先,windows中的网络通信接口都是包含在<WinSock2.h>头文件下的。
其次,在vwindows中要使用网络都是通过网络库来使用的。所以要定义一个“#pragma comment(lib, "ws2_32.lib)”的宏,表示该程序中使用了ws2_32.lib这个库。这个“ws2_32.lib”中的w代表windows;s代表socket;2代表版本;32代表32位机器。
然后,要启动WinSock,并初始化网络库和选择你需要使用的库的版本。
在上面的代码中,WSAData其实就是一个结构体,通过WSAStartup函数将该程序使用的库的版本保存在wsd中,其中MAKEWORD(2, 2)就是表示要使用2.2版本的库。在编译时编译器就会拿着这个版本去对应的库中寻找和使用对应版本的库。
最后一点就是要释放在这个程序中使用的库的资源,直接调用WSACleanup函数即可。
在windows中的客户端与linux下的客户端就只有这四个地方不同,其他函数的使用都是一模一样的,依然是创建套接字,发送数据和接收数据。
还有一点小区别就是linux中socket函数是用int来接收的,而windows中则是用SOCKET来接收的,其实它就是对unsigned __int64类型,即64位的无符号整数类型进行了封装。和int没有太大区别。
#pragma once
#define _WINSOCK_DEPRECATED_NO_WARNINGS
//上面的宏是用于防止inet_addr函数报错
#include <iostream>
#include <string>
#include <string.h>
#include <WinSock2.h>//windows下使用网络所需的头文件
#pragma comment(lib, "ws2_32.lib")//表示使用网络库
using namespace std;
const uint16_t serverport = 8080;
string serverip = "43.142.247.17";
int main(int argc, char argv[])
{
WSAData wsd;
if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0)//初始化WinSock并选择要使用哪个版本的库
{
cout << "WSAStartup Error = " << WSAGetLastError() << endl;
return 0;
}
else
cout << "WSAStartup Success" << endl;
//1. 创建套接字
SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock == SOCKET_ERROR)
{
cout << "socket Error = " << WSAGetLastError() << endl;
return 1;
}
else
cout << "scoket Success" << endl;
//2. 绑定套接字。在客户端中也需要bind,但无需用户自己bind
//3. 建立连接进行通信
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;//协议族
server.sin_port = htons(serverport);//要向哪个端口号发送数据
server.sin_addr.s_addr = inet_addr(serverip.c_str());//要向哪个ip发送数据
#define NUM 1024
string line;
char buffer[1024];
while (true)
{
//3.1 发送数据
cout << "Please Enter# ";
getline(cin, line);
int n = sendto(AF_INET, line.c_str(), line.size(), 0, (struct sockaddr*)&server, sizeof(server));
if (n < 0)
{
cout << "sendto error!" << endl;
break;
}
//3.2 接收数据
struct sockaddr_in peer;
int peerlen = sizeof(peer);
memset(&peer, 0, peerlen);
buffer[0] = 0;//清空
n = recvfrom(sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &peerlen);
if (n > 0)
{
buffer[n] = 0;//设置\0标志结尾
cout << "server返回的消息: " << buffer << endl;
}
else
break;
}
closesocket(sock);//关闭套接字,可用可不用
WSACleanup();//释放使用的库的资源
return 0;
}
此时就可以进行通信了。通信方式和linux下的一样,直接启动程序即可。
如果大家遇到无法通信的情况,可能是你所使用的云服务器的公网ip或端口号未开放,数据被拦截了。因为大家所使用的云服务器大多数都是默认设置为禁止使用公网ip对外通信的,所需需要大家去自己买的云服务器的官网下登录自己的账户,去打开自己的云服务器ip和端口,这里就不再赘述了。