目录
一、预备知识
1.端口号
(1)网络通信的目的
我们在前面的网络编程基础就已经讲过,如果主机A的数据想发送到主机B,就需要不断地进行封装再解包的过程。数据的报头中会保存源IP和目的IP,上一节点IP和下一节点IP,每个节点都会根据这些地址选择路径,从而发送信息到B主机。
那么网络传输的意义只是将数据由一台主机发送到另一台主机吗?
显然不是,将数据从计算机A传送到计算机B只是手段,并不是网络通信的目的。
真正进行通信的是用户A和用户B,而两个用户都要在应用程序上读取信息,所以更准确地说是计算机A上的某个应用程序和计算机B上的某个应用程序之间在通信。
所以,网络通信的目的是让两台计算机上的两个进程进行通信。
(2)认识端口号
IP地址可以标识两台计算机在广域网中的唯一性,但是一台计算机上会存在大量的进程,那如何保证计算机A某个进程发送的数据能准确地让计算机B指定的进程接收到呢?
换句话说就是,如何标识一台计算机上一个进程的唯一性呢?答案是:采用端口号port。
端口号有以下特点:
- 端口号是一个2字节16位的整数。
- 端口号可标识一台主机上的一个进程,网络通信中,它可以告诉操作系统要把数据交给哪一个进程。
- 一个端口号只能被一个进程占用。
IP地址可以标识计算机在网络中唯一性,端口号port又能用来标识进程在计算机中唯一性。
所以,如果我们需要寻找全网某一个进程,先通过IP地址查找全网唯一的主机,然后通过端口号port找到该主机唯一的进程。
所以我们使用socket套接字实现网络通信就需要:双方的IP地址 + 双方的端口号port。
(3)网络通信的本质
既然,网络通信实际上是多台计算机上的进程之间通信,那么我们就能确定网络通信的本质是进程间通信。
我们上网的所有行为无非就两种:把数据发出去和把数据读回来。所以,网络通信其实就是数据IO。而Linux下一切皆文件,所以网络在系统看来也是一个"文件",也有维护它的结构体,也有自己的文件描述符。
(4)端口号的作用
我们之前学过,pid就可以在一台主机上标识一个进程,那为什么还要搞一个端口号呢?
其实理论上用pid也完全没问题,但是系统和网络毕竟不是一家。
- 首先,所有的进程都有pid,但不是所有的进程都需要使用网络,我们只需要给需要使用网络的进程提供端口号就可以了。
- 其次,系统内使用pid,网络内使用port标识进程,实现了系统与网络的解耦,确保二者不会相互影响。
- 还有,客户端需要与服务器进行网络通信,标识服务器进程唯一性的数据不能做任何改变。这一点port能做到,pid做不到。
我说明以下最后一点:
比如说,我们用QQ聊天,实际上使用的是手机上的客户端应用,我们使用QQ聊天等都是在向QQ的服务器进程发起网络请求。服务器进程会根据用户的网络请求做出对应的反馈交给用户。
我们在下载了某个可以联网的应用程序以后,该应用就已经绑定了它的服务端对应的IP地址和端口号。服务器的IP地址不会随意变化,服务端的port也不会随意变化,这样每次应用程序都会向这个服务进程发送信息,我们才能正常联网。
如果使用pid代替端口号的话,服务器每重启一次,服务器进程的pid值就会改变,客户端就找不到服务进程了。
另外提一句,绑定了端口号port的进程PCB会被维护在一个哈希表中,port是key值,value是PCB。所以操作系统就能够根据key值找到对应的PCB,然后处理它。
2.认识TCP和UDP协议
TCP和UDP是传输层两个使用较多的协议,具体原理和细节以后会详细讲解,这里只需要大家了解一下它们的特点即可。
TCP协议(Transmission Control Protocol)中文名为传输控制协议,它有以下特点:
- 传输层协议。
- 需要通信双方建立连接。
- 是一种可靠传输,不会发生丢包等问题。
- 面向字节流。
UDP协议(User Datagram Protocol)中文名为用户数据报协议,它有以下特点:
- 传输层协议。
- 不需要通信双方建立连接,直接发生即可。
- 是一种不可靠传输,可能会发生丢包等问题。
- 面向数据报。
我解释一下什么可靠传输和不可靠传输是什么意思?
比如说有一个人搬货,一个人从厂房里搬货,一个人把搬出来的货物装车。
搬货的人如果一股脑地从厂房里往外搬货,一方面,装车的人应付不过来。另一方面,有可能搬货的人会把货物搬错地方,造成货物的遗失。这个就可以认为是不可靠传输。
而如果搬货的人一箱一箱沿着既定的路线拿给装车的人,这样的传输虽然慢一些,但是不会丢失货物。这个就可以认为是可靠传输。
例子中的货物丢失放,在网络中就是封装好的信息没有发送到目的IP,遗失在了网络里,专业的名词就叫丢包。
3.网络字节序
(1)各处数据的大小端
我们其实之前讲过字节序的大小端问题,详情看这里:
计算机分为大端机和小端机,如果两台计算机的字节序不同,那么接收到的数据解释出来意义也完全不同。
所以网络就做了这样的规定:网络中的字节序一律采用大端。
对于大端机,可以直接利用网络发送数据和接收数据,不用做转换。
而对于小端机,在向网络中发送数据时,就需要先将数据转换成大端,再发送到网络中。从网络中接收的大端数据,也需要先转换成小端再使用。
既然网络有这样的规定,那我们在写代码时是否需要先判断一下字节序呢?
其实不需要,正因为机器大小端的判断很繁琐,所以操作系统早就提供了支持主机字节序和网络的字节序相互转换的接口。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回。
如果主机是大端字节序,这些函数不做转换,将参数原封不动返回。
(2)字节序转换接口
我先说一下,下面的uint16_t和uint32_t是unsigned short和unsigned int经过typedef的新名字。
uint32_t htonl(uint32_t hostlong);
头文件:arpa/inet.h
功能:将主机上unsigned int类型的数据转换成对应网络字节序。
参数:uint32_t hostlong是需要转换的unsigned int类型数据。
返回值:返回转换后的数据。
uint16_t htons(uint16_t hostshort);
头文件:arpa/inet.h
功能:将主机上unsigned short类型的数据转换成对应网络字节序。
参数:uint16_t是需要转换的unsigned short类型数据。
返回值:返回转换后的数据。
uint32_t ntohl(uint32_t netlong);
头文件:arpa/inet.h
功能:将从网络上读取的unsigned int类型的数据转换成主机的字节序。
参数:uint32_t是需要转换的unsigned int类型数据。
返回值:返回转换后的数据。
uint16_t ntohs(uint16_t netshort);
头文件:arpa/inet.h
功能:将从网络上读取的unsigned short类型的数据转换成主机的字节序。
参数:uint16_t是需要转换的unsigned short类型数据。
返回值:返回转换后的数据。
我们可以以这样的方式记住这些函数:
h表示host,也就是主机。n表示network,也就是网络。s表示short,对应unit16_t。l表示long,对应uint32_t。to就是英语里的到。
所以,htonl的转换就是主机到网络,数据类型为unsigned int。htons的转换也是主机到网络,数据类型为unsigned short。
ntohl的转换就是网络到主机,数据类型为unsigned int。ntohs的转换也是网络到主机,数据类型为unsigned short。
二、socket套接字
socket 的原意是“插座”,在计算机通信领域,socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。通过socket这种约定,一台计算机既可以接收其他计算机的数据,也可以向其他计算机发送数据。
1.socket接口
下面的两个接口是UDP需要使用的两个接口,TCP还需要使用另外三个接口,这里就不写了。
(1)socket
int socket(int domain, int type, int protocol);
头文件:sys/types.h、sys/socket.h
功能:创建套接字,而且在创建的时候需要指定使用的通信协议。
参数:
int domain是地址族,它决定了创建的套接字进行的是网络通信还是本地通信。
该参数的所有选项都在上面了,最常用的是AF_INET,表示使用IPv4的网络套接字进行网络通信。
int type可指定socket提供的能力类型,比如是面向字节流还是面向用户数据报。
该参数的选项中,SOCK_STREAM表示面向字节流,SOCK_DGRAM表示面向用户数据报,对应了TCP和UDP协议。
int protocol是用来指定具体协议名的,比如TCP或者UDP。但是根据前两个参数就已经确定使用哪个协议了,所以一般设置为0。
返回值:成功创建返回一个文件描述符sockfd,失败则返回-1,并且设置错误码errno。
(2)bind
int bind(int sockfd, const struct sockaddr * addr, socklen_t addrlen);
头文件:sys/types.h、sys/socket.h
功能:将IP地址和端口号port与创建的socket套接字绑定,也相当于将IP地址和端口号port和系统绑定。
参数:int sockfd表示之前使用socket()返回的文件描述符sockfd。
const struct sockaddr * addr是一个struct sockaddr结构体的指针,后面会说这个结构体的问题。
socklen_t addrlen表示sockaddr结构体的大小,单位是字节,socklen_t也是typedef出来的unsigned int类型的32位变量。
返回值:成功返回0,失败返回-1并设置错误码errno。
2.sockaddr结构体
(1)套接字的类型
套接字类型很多,常见的有以下三种:
- 网络套接字——既支持跨主机通信,也支持本地通信。
- 原始套接字——可以跨过传输层(TCP/UDP)访问底层的数据。
- 域间套接字——只支持本地通信。
因为套接字的应用场景不同,所以不同种类的套接字都会有自己的一套系统调用接口。
(2)sockaddr结构体
如果我们使用网络套接字,就需要建立一个sockaddr_in结构体,将IP地址,端口号,以及地址族AF_INET填入。然后通过系统调用bind与系统绑定,从而进行网络通信。
以下是sockaddr_in结构体的定义:
struct sockaddr_in {
short int sin_family; // 地址族,一般为AF_INET
unsigned short int sin_port; // 端口号,必须为网络字节序
struct in_addr sin_addr; // IP地址
unsigned char sin_zero[8]; // 用于填充,使sizeof(sockaddr_in)等于16
};
里面还有一个struct in_addr结构体,定义如下:
其实就是uint32_t类型,只是重命名成了in_addr_t,本质是unsigned int。
struct in_addr {
in_addr_t s_addr;
};
如果我们使用域间套接字,就需要建立一个sockaddr_un结构体,将地址族AF_UNIX和带有路径的文件名填入。然后通过系统调用bind与系统绑定,从而进行网络通信。
以下是sockaddr_in结构体的定义:
struct sockaddr_un {
sa_family_t sun_family; //AF_UNIX
char sun_path[108]; //带有路径的文件名
};
(3)C语言中的多态?
我们再看看前面的参数,bind需要传参的类型是const struct sockaddr*,并不是const struct sockaddr_in*和const struct sockaddr_un*。
所以,我们可以确定,sockaddr_in和sockaddr_un结构体都是基于sockaddr结构体设计的。所以在传参时需要将具体套接字支持的结构体指针转换为socketaddr结构体指针。
下面是socketaddr结构体的定义:
struct sockaddr
{
sa_family_t sin_family;
char sa_data[14];//14字节,包含套接字中的目标地址和端口信息
}
所以对网络通信和域间通信,设计者都使用了同一套接口。可以通过向接口传递不同的参数按需求解决所有套接字的通信。
看到这里是不是感觉很熟悉,如果把sockaddr看做基类,sockaddr_in和sockaddr_un结构体看作sockaddr的子类。一个函数以基类的指针作为参数,函数会通过不同类型的参数选择处理方式。
这不就是多态吗?而且还是在C语言中的多态。不过我们只能说它是基于多态的思想模拟设计的,毕竟C语言语法本身不支持多态。
所以在使用bind系统调用进行网络通信时,我们需要将sockaddr_in强转成sockaddr类型传参指针。
我们再看,如果把这三种结构体放到内存中,我们会发现虽然传入的指针类型相同,但它保存地址族的前16位数据是不同的。
所以,在bind函数内部,它就会根据前16位数据自行判断通信类型,然后再将socketaddr再强制类型转换回之前的类型。然后bind就会执行该结构体的对应操作。
所以,我们能看到sockaddr只是一个用于传参工具类,并不存在它对应的套接字。
三、UDP单向网络编程
既然前面都说到了UDP的socket接口,那我们不妨实现一个UDP通信的小程序。
由于网络通信必定有两方,所以需要设计一个服务端和一个客户端。
1.服务端实现
(1)创建管理服务端的类
首先,需要定义一个类udpserver来管理服务端。成员变量包括服务端的端口号、ip地址还有需要bind绑定的文件描述符。
构造函数中端口号是用户指定的,IP地址是主机本身就有的,文件描述符还没有使用socket创建套接字,所以临时设为-1。
我们使用ifconfig指令查看网络信息:
其中顶格至最左侧的第二行有一个ip地址127.0.0.1。这个IP是本地环回地址,使用这个IP地址,数据就不会经过物理层发出去,而是封装到链路层又会解包返回当前计算机。
但是每台计算机本身又有很多IP地址,比如在网络中会有一个公网IP地址,本地回环也是一个IP地址,局域网IP又是IP地址……。这么多IP地址都可以标识同一台计算机。
如果服务器仅绑定本地环回的IP地址,那么当另一台计算机的客户端想要通过公网IP向计算机发出请求时,由于绑定的IP地址与客户端IP地址不一样,服务端就会忽略客户端的请求。
所以,为了能够让服务端接收所有客户端的请求,我们将服务器绑定的IP地址设为0.0.0.0(转换成uint32_t类型为0)。当bind绑定的IP地址是0.0.0.0的时候,这台计算机就会接收所有网络的请求,也会根据相关的端口处理。
所以,我们将udpSever构造函数的IP地址设为缺省值0.0.0.0,最终代码如下:
#include<arpa/inet.h>
static std::string default_ip = "0.0.0.0";
class udpserver
{
public:
//构造函数
udpserver(const uint16_t& port, const std::string& ip = default_i)
:_port(port)
,_ip(ip)
,_sockfd(-1)
{}
private:
uint16_t _port;//服务端进程的端口号
std::string _ip;//服务器主机ip地址
int _sockfd;//socket返回的文件描述符
};
(2)初始化管理类
虽然调用了构造函数,但还需要建立网络连接,所以增加一个初始化函数initserver()。在其内部我们需要完成套接字的创建和文件描述符的绑定。
- 首先,利用socket创建套接字,并保存到文件描述符。
- 然后,创建一个sockaddr_in结构体,将数据填进去。要注意填入的端口号必须为网络字节序,所以要使用htons函数。
- 还有结构体内的sin_addr结构体内的s_addr为in_addr_t类型,我们现有的ip是string类型,所以需要使用inet_addr函数把char*类型的数据变为in_addr_t再填进去。
in_addr_t inet_addr(const char* cp);
头文件:netinet/in.h、sys/socket.h、arpa/inet.h
功能:将char*类型的ip转为in_addr_t类型的ip
参数:const char* cp表示需要转化的ip字符串
返回值:成功返回0,失败返回-1并设置错误码errno。
- 最后使用bind绑定IP地址。
- 还有一点,在创建套接字或者绑定失败时,我们打印的错误码并不能很直观地告诉我们问题是用户、socket还是bind制造的,所以我们在前面定义一个枚举常量。定义用户出错以USAGE_ERROR错误码退出,创建套接字出错以SOCKET_ERROR错误码退出,绑定出错以BIND_ERROR错误码退出。
所以最终initserver代码如下:
void initserver()
{
//创建套接字,创建失败打印错误原因
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd == -1)
{
std::cerr << "socket error code " << errno << ":" << strerror(errno) << std::endl;
exit(USAGE_ERROR);
}
//绑定IP地址
struct sockaddr_in addr;
addr.sin_family = AF_INET;//通信方式为网络通信
addr.sin_port = htons(_port);//将网络字节序的端口号填入
addr.sin_addr.s_addr = inet_addr(_ip.c_str());//填充结构体
int n = bind(_sockfd, (struct sockaddr*)&addr, sizeof(addr));//绑定IP,绑定失败则
if(n == -1)
{
std::cerr << "bind error code " << errno << ":" << strerror(errno) << std::endl;
exit(BIND_ERROR);
}
}
(3)启动服务端进程
此时准备工作就做完了,再写一个start函数启动进程。
- 首先,服务端进程是要长期运行的,所以它必须是一个死循环
- 在死循环外定义一个buffer缓冲区,缓冲区的空间我用宏定义为1024
- 在循环内使用recvfrom接收数据到buffer,注意看
- 使用网络套接字,需要定义sockaddr_in结构体保存接收的数据。
- 我们使用recvfrom接收数据。
int recvfrom(int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen);
头文件:sys/types.h、sys/socket.h
功能:接收网络中发来的数据
参数:int sockfd表示之前使用socket()返回的文件描述符sockfd。
void* buf表示用于储存网络上发来数据的缓冲区。
size_t len是缓冲区的大小。
int flags是读取方式,一般设为0表示阻塞式等待。
struct sockaddr* src_addr是一个输出型参数,使用网路通信就需要传参一个sockaddr_in的结构体,函数会根据发来的数据信息将这个结构体填充,包括获取数据的来源,包括发送方的地址类型,端口号port和IP地址。
socklen_t addrlen表示sockaddr结构体的大小,单位是字节,socklen_t是typedef的unsigned int类型。
返回值:成功返回读取信息的字节数,失败返回-1并设置错误码errno。
- 读取完成后,如果recvfrom返回大于0的值就证明读到数据了,需要将其打印出来。
- 字符串类型的数据更适合打印,所以还需要用inet_ntoa函数把in_addr_t类型的ip转换为char*类型的ip,我们以后统一叫char*类型的ip地址为点分十进制IP。
- 同样,端口号也要转回用户字节序再使用
- 最后在if语句内打印,自己可以控制格式。
最终代码如下:
void start()
{
char buffer[NUM];//定义一个缓冲
while(1)
{
//读取数据
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(_sockfd, (void*)buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);//给缓冲区留一个\0位置
if(n > 0)//读取到数据n就一定大于0
{
buffer[n] = '\0';//把传过来的\n用\0替代
std::string client_ip = inet_ntoa(peer.sin_addr);
uint16_t client_port = ntohs(peer.sin_port);
printf("%s[%u]#%s\n", client_ip.c_str(), client_port, buffer);
}
}
}
(4)main函数
main函数的char* argv[]储存了我们输入的每个命令行参数,int argc表示参数个数,如果argc为1,说明用户没输入端口号。那我们就打印错误原因并以USAGE_ERROR退出。
如果用户输入端口号,则程序正常运行,构造对象,初始化,运行即可。
代码如下:
using namespace std;
static void Usage(string proc)
{
printf("\nUsage:\n\t%s local_port\n\n",proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 2)//如果没输入端口号,argc保存的命令参数只有一个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[1]);
udpserver* p = new udpserver(port);
p->initserver();
p->start();
return 0;
}
(5)总服务端代码
server.cpp
#include<iostream>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<errno.h>
#include<string.h>
#include<strings.h>
#include<memory>
#include<stdlib.h>
#include<stdio.h>
#define NUM 1024
static std::string default_ip = "0.0.0.0";
enum errorcode
{
USAGE_ERROR = 1,
SOCKET_ERROR,
BIND_ERROR
};
class udpserver
{
public:
//构造函数
udpserver(const uint16_t& port, const std::string& ip = default_ip)
:_port(port)
,_ip(ip)
,_sockfd(-1)
{}
void initserver()
{
//创建套接字,创建失败打印错误原因
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd == -1)
{
std::cerr << "socket error code " << errno << ":" << strerror(errno) << std::endl;
exit(USAGE_ERROR);
}
//绑定IP地址
struct sockaddr_in addr;
addr.sin_family = AF_INET;//通信方式为网络通信
addr.sin_port = htons(_port);//将网络字节序的端口号填入
addr.sin_addr.s_addr = inet_addr(_ip.c_str());//填充结构体
int n = bind(_sockfd, (struct sockaddr*)&addr, sizeof(addr));//绑定IP,绑定失败则
if(n == -1)
{
std::cerr << "bind error code " << errno << ":" << strerror(errno) << std::endl;
exit(BIND_ERROR);
}
}
//启动服务端进程,服务端需要一直运行,所以是一个死循环程序
void start()
{
char buffer[NUM];//定义一个缓冲区
while(1)
{
//读取数据
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(_sockfd, (void*)buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);//给缓冲区留一个\0位置
if(n > 0)//读取到数据n就一定大于0
{
buffer[n] = '\0';//把传过来的\n用\0替代
std::string client_ip = inet_ntoa(peer.sin_addr);
uint16_t client_port = ntohs(peer.sin_port);
printf("%s[%u]#%s\n", client_ip.c_str(), client_port, buffer);
}
}
}
private:
uint16_t _port;//服务端进程的端口号
std::string _ip;//服务器主机ip地址
int _sockfd;//socket返回的文件描述符
};
using namespace std;
static void Usage(string proc)
{
printf("\nUsage:\n\t%s local_port\n\n",proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 2)//如果没输入端口号,argc保存的命令参数只有一个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[1]);
unique_ptr<udpserver> p(new udpserver(port));
p->initserver();
p->start();
return 0;
}
2.客户端实现
(1)创建管理服务端的类
与服务器的实现思路基本一致,只是客户端需要和服务端通信,它需要储存服务端的IP地址和端口号。
class udpClient
{
public:
//构造函数
udpClient(const string& severip, const uint16_t& port)
:_severip(severip)
,_severport(severport)
,_sockfd(-1)
{}
private:
int _socketfd;//套接字文件描述符
string _severip;//服务器IP地址
uint16_t _severport;//服务器的端口号
};
(2)初始化客户端类
与initserver处理基本一致,客户端不需要绑定。
void initsclient()
{
//创建套接字,创建失败打印错误原因
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd == -1)
{
std::cerr << "socket error code " << errno << ":" << strerror(errno) << std::endl;
exit(USAGE_ERROR);
}
//由于客户端只会与服务端进行数据收发,所以不需要绑定
}
(3)运行客户端
设置一个initclient函数,先定义结构体,然后填入服务器数据,最后不断循环用getline函数获取用户输入的信息放到message中,最后用sendto函数发送数据给服务器。
void run()
{
struct sockaddr_in server;
server.sin_family = AF_INET;//通信方式为网络通信
server.sin_port = htons(_port);//将网络字节序的端口号填入
server.sin_addr.s_addr = inet_addr(_ip.c_str());//填充结构体
while(1)
{
printf("Please enter#");
std::string message;
getline(std::cin, message);
sendto(_socketfd, (void*)message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
}
};
(4)main函数
与服务端main函数基本一致。只是命令函参数需要传客户端IP和端口号。
static void Usage(string proc)
{
printf("\nUsage:\n\t%s server_ip server_port\n\n", proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 3)//如果没输入端口号和目的IP,argc保存的命令参数就不是三个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[2]);
string ip = argv[1];
udpclient* p = new udpclient(ip, port);
p->initclient();
p->run();
return 0;
}
(5)客户端代码
所有代码如下:
#include<iostream>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<errno.h>
#include<string.h>
#include<strings.h>
#include<istream>
#include<stdlib.h>
#include<stdio.h>
#define NUM 1024
enum errorcode
{
USAGE_ERROR = 1,
SOCKET_ERROR,
BIND_ERROR
};
class udpclient
{
public:
//构造函数
udpclient(const std::string& ip, const uint16_t& port)
:_ip(ip)
,_port(port)
,_socketfd(-1)
{}
void initclient()
{
//创建套接字,创建失败打印错误原因
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_socketfd == -1)
{
std::cerr << "socket error code " << errno << ":" << strerror(errno) << std::endl;
exit(USAGE_ERROR);
}
//不需要绑定
}
//启动服务端进程,服务端需要一直运行,所以是一个死循环程序
void run()
{
struct sockaddr_in server;
server.sin_family = AF_INET;//通信方式为网络通信
server.sin_port = htons(_port);//将网络字节序的端口号填入
server.sin_addr.s_addr = inet_addr(_ip.c_str());//填充结构体
while(1)
{
printf("Please enter#");
std::string message;
getline(std::cin, message);
sendto(_socketfd, (void*)message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
}
}
private:
int _socketfd;//套接字文件描述符
std::string _ip;//服务器IP地址
uint16_t _port;//服务器的端口号
};
using namespace std;
static void Usage(string proc)
{
printf("\nUsage:\n\t%s server_ip server_port\n\n", proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 3)//如果没输入端口号和目的IP,argc保存的命令参数就不是三个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[2]);
string ip = argv[1];
udpclient* p = new udpclient(ip, port);
p->initclient();
p->run();
return 0;
}
3.运行
XShell开两个窗口先运行服务端进程,再运行客户端进程。运行服务端需要带上端口号,而运行客户端需要服务端的IP地址和端口号。
客户端
服务端
客户端发送什么,服务端就打印什么。
四、UDP双向网络编程
我们实现一个英汉互译的词典,客户端输入英文单词,服务端查它对应的汉语,并且返回给客户端。
1.服务端实现
服务端需要增加先在最上面定义一个用于存储单词与中文意思的map和一个保存单词及其中文意思的带路径的文件string变量。最后还要增加三个函数。实现过程我都在注释里写好了。
在函数的实现中使用了C++文件操作(fstream),我的说明可能不能完全讲明白,所以可以查找一些相关文章。
server.cpp
#include<iostream>
#include<fstream>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<errno.h>
#include<string.h>
#include<strings.h>
#include<memory>
#include<stdlib.h>
#include<functional>
#include<stdio.h>
#include<unordered_map>
#define NUM 1024
static std::string default_ip = "0.0.0.0";
//字典位置
const std::string dictionary("./dict.txt");
//查找数据的map
std::unordered_map<std::string, std::string> dict;
enum errorcode
{
USAGE_ERROR = 1,
SOCKET_ERROR,
BIND_ERROR,
OPEN_ERROR
};
class udpserver
{
typedef std::function<void (int, std::string, uint16_t, std::string)> func_t;
public:
//构造函数
udpserver(const func_t& func, const uint16_t& port, const std::string& ip = default_ip)
:_callback(func)
,_port(port)
,_ip(ip)
,_sockfd(-1)
{}
void initserver()
{
//创建套接字,创建失败打印错误原因
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd == -1)
{
std::cerr << "socket error code " << errno << ":" << strerror(errno) << std::endl;
exit(USAGE_ERROR);
}
//绑定IP地址
struct sockaddr_in addr;
addr.sin_family = AF_INET;//通信方式为网络通信
addr.sin_port = htons(_port);//将网络字节序的端口号填入
addr.sin_addr.s_addr = inet_addr(_ip.c_str());//填充结构体
int n = bind(_sockfd, (struct sockaddr*)&addr, sizeof(addr));//绑定IP,绑定失败则
if(n == -1)
{
std::cerr << "bind error code " << errno << ":" << strerror(errno) << std::endl;
exit(BIND_ERROR);
}
}
//启动服务端进程,服务端需要一直运行,所以是一个死循环程序
void start()
{
char buffer[NUM];//定义一个缓冲区
while(1)
{
//读取数据
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(_sockfd, (void*)buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);//给缓冲区留一个\0位置
if(n > 0)//读取到数据n就一定大于0
{
buffer[n] = '\0';//把传过来的\n用\0替代
std::string client_ip = inet_ntoa(peer.sin_addr);
uint16_t client_port = ntohs(peer.sin_port);
std::string message(buffer);
printf("%s[%u]#%s\n", client_ip.c_str(), client_port, buffer);
//打印完还要使用_callback将中文发送回客户端
_callback(_sockfd, client_ip, client_port, message);
}
}
}
private:
uint16_t _port;//服务端进程的端口号
std::string _ip;//服务器主机ip地址
int _sockfd;//socket返回的文件描述符
func_t _callback;//回调函数
};
//切割字符串
bool cutstring(std::string& target, std::string* key, std::string* value, const std::string& div)
{
auto pos = target.find(div);//在原字符串中查找分隔符
if(pos == std::string::npos)//npos是string类内的一个常量,用于标识一个不存在的位置
//所以没找到分隔符,不需要切割子串
{
return false;
}
else
{
//target.substr是string类内切割子串的函数,可以下标或迭代器的左闭右开区间构造子串
*key = target.substr(0, pos);//从头到分隔符前构造子串key
*value = target.substr(pos+1, pos + target.size());//从分隔符后一个到最后构造子串value
//最后的迭代器位置是pos再向后走一个字符串长度,实际上超过了string管理的空间。如果尾部超过了字符串长,则默认从开始构造到尾部
return true;
}
}
//初始化字典
void initdict()
{
//以下为C++文件操作
//打开文件,构建一个文件流in
std::ifstream in(dictionary, std::ifstream::binary);
//第一个参数是需要打开的文件,需要带路径,第二个参数表示以二进制读写打开该文件
if(!in.is_open())//is_open可以判断当前文件是否被打开
{
std::cerr << "open error code " << errno << ":" << strerror(errno) << std::endl;
exit(OPEN_ERROR);
}
//构建缓冲区line,键值对key和value
std::string line,key,value;
while(getline(in, line))//不断从文件里获取字符
{
if(cutstring(line, &key, &value, ":"))
{
dict.insert(std::make_pair(key, value));
}
}
}
void handler_message(int socketfd, std::string client_ip, uint16_t client_port, std::string message)
{
//构造一个string变量用于返回单词中文
std::string response;
auto pos = dict.find(message);
//查找到单词就把value填入,查不到就把下面的话填入
if(dict.find(message) == dict.end())
{
response = "字典中没有该单词的信息";
}
else
{
response = pos->second;
}
//将网络通信结构体填好
struct sockaddr_in tc;
tc.sin_family = AF_INET;//通信方式为网络通信
tc.sin_port = htons(client_port);//将网络字节序的端口号填入
tc.sin_addr.s_addr = inet_addr(client_ip.c_str());//填充结构体
//将中文发送回客户端
sendto(socketfd, (void*)response.c_str(), response.size(), 0, (struct sockaddr*)&tc, sizeof(tc));
}
using namespace std;
static void Usage(string proc)
{
printf("\nUsage:\n\t%s local_port\n\n",proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 2)//如果没输入端口号,argc保存的命令参数只有一个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[1]);//端口号类型转化
initdict();//初始化词典,把文件中的单词读进去
unique_ptr<udpserver> p(new udpserver(handler_message, port));
p->initserver();
p->start();
return 0;
}
2.客户端实现
客户端只需要在start()函数中增加读取客户端发来的数据并将其打印的部分就可以了。
client.cpp
#include<iostream>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<errno.h>
#include<string.h>
#include<strings.h>
#include<istream>
#include<stdlib.h>
#include<stdio.h>
#include<memory>
#define NUM 1024
enum errorcode
{
USAGE_ERROR = 1,
SOCKET_ERROR,
BIND_ERROR
};
class udpclient
{
public:
//构造函数
udpclient(const std::string& ip, const uint16_t& port)
:_ip(ip)
,_port(port)
,_socketfd(-1)
{}
void initclient()
{
//创建套接字,创建失败打印错误原因
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_socketfd == -1)
{
std::cerr << "socket error code " << errno << ":" << strerror(errno) << std::endl;
exit(USAGE_ERROR);
}
printf("socket sucess:%d\n", _socketfd);
//由于这里服务端不需要向客户端发数据,所以不需要绑定
}
//启动服务端进程,服务端需要一直运行,所以是一个死循环程序
void run()
{
struct sockaddr_in server;
server.sin_family = AF_INET;//通信方式为网络通信
server.sin_port = htons(_port);//将网络字节序的端口号填入
server.sin_addr.s_addr = inet_addr(_ip.c_str());//填充结构体
while(1)
{
//把单词发过去
printf("Please enter#");
std::string message;
getline(std::cin, message);
sendto(_socketfd, (void*)message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
//把中文意思拿回来
struct sockaddr_in chinese;
char buffer[NUM];
socklen_t len = sizeof(chinese);
ssize_t n = recvfrom(_socketfd, (void*)buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&chinese, &len);//给缓冲区留一个\0位置
if(n > 0)//读取到数据n就一定大于0
{
buffer[n] = '\0';//把传过来的\n用\0替代
std::string client_ip = inet_ntoa(chinese.sin_addr);
uint16_t client_port = ntohs(chinese.sin_port);
//打印中文意思到屏幕上
printf("翻译结果:%s\n", buffer);
}
}
}
private:
int _socketfd;//套接字文件描述符
std::string _ip;//服务器IP地址
uint16_t _port;//服务器的端口号
};
using namespace std;
static void Usage(string proc)
{
printf("\nUsage:\n\t%s server_ip server_port\n\n", proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 3)//如果没输入端口号和目的IP,argc保存的命令参数就不是三个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[2]);
string ip = argv[1];
unique_ptr<udpclient> p(new udpclient(ip, port));
p->initclient();
p->run();
return 0;
}
二者配合运行的结果: