目录

1、预备知识

        理解源IP地址和目的IP地址

        理解源MAC地址和目的MAC地址

        网络通信

        端口号

        端口号和进程ID

        源端口号和目的端口号

        认识TCP协议和UDP协议

        网络字节序

2、socket编程接口

        socket常见API

        sockaddr结构

3、实现一个UDP网络程序

        3.1、服务端udpServer.cc文件

                 服务端创建套接字

                 服务端绑定

                 运行服务器

                 main函数命令行参数

                 服务端udpServer.cc总代码

        3.2、客户端udpClient.cc文件

                 mian函数命令行参数

                 客户端创建套接字

                 客户端的绑定问题

                 启动客户端

                 客户端udpClient.cc总代码

        3.3、Log.hpp打印日志函数

        3.4、测试

                 本地测试

                 远端测试

        3.5、实现linux和windows通信

        3.6、代码变形

4、地址转换函数 && inet_ntoa 

        字符串IP转整数IP

        整数IP转IP字符串

        inet_ntoa函数


1、预备知识

理解源IP地址和目的IP地址

IP地址上篇博文已经讲解过,这里简要提下:

  • 每台计算机都有一个唯一的IP地址,如果一台主机上的数据要传输到另一台主机,那么对端主机的IP地址就应该作为该数据传输时的目的IP地址。但仅仅知道目的IP地址是不够的,当对端主机收到该数据后,对端主机还需要对该主机做出响应,因此对端主机也需要发送数据给该主机,此时对端主机就必须知道该主机的IP地址。因此一个传输的数据当中应该涵盖其源IP地址和目的IP地址,目的IP地址表明该数据传输的目的地,源IP地址作为对端主机响应时的目的IP地址。
  • 在数据进行传输之前,会先自顶向下贯穿网络协议栈完成数据的封装,其中在网络层封装的IP报头当中就涵盖了源IP地址和目的IP地址。而除了源IP地址和目的IP地址之外,还有源MAC地址和目的MAC地址的概念。

理解源MAC地址和目的MAC地址

  • 大部分数据的传输都是跨局域网的,数据在传输过程中会经过若干个路由器,最终才能到达对端主机。
  • 源MAC地址和目的MAC地址是包含在链路层的报头当中的,而MAC地址实际只在当前局域网内有效,因此当数据跨网络到达另一个局域网时,其源MAC地址和目的MAC地址就需要发生变化,因此当数据达到路由器时,路由器会将该数据当中链路层的报头去掉,然后再重新封装一个报头,此时该数据的源MAC地址和目的MAC地址就发生了变化。

如上主机1向主机2发送数据的过程中,数据的源MAC地址和目的MAC地址的变化过程如下:

时间轴源MAC地址目的MAC地址
刚开始主机1的MAC地址路由器A的MAC地址
经过路由器A之后路由器A的MAC地址路由器B的MAC地址
. . .. . .. . .

因此数据在传输的过程中是有两套地址:

  • 一套是源IP地址和目的IP地址,这两个地址在数据传输过程中基本是不会发生变化的(存在一些特殊情况,比如在数据传输过程中使用NET技术,其源IP地址会发生变化,但至少目的IP地址是不会变化的)。
  • 另一套就是源MAC地址和目的MAC地址,这两个地址是一直在发生变化的,因为在数据传输的过程中路由器不断在进行解包和重新封装。

网络通信

我们在网络通信的时候,只要让两台主机能够通信就可以了吗?

  • 实际上,在进行通信的时候,不仅仅要考虑两台主机间互相交互数据。
  • 本质上讲,进行数据交互的时候,是用户和用户在进行交互。用户的身份,通常是用程序体现的。程序一定是在运行中的 ---- 进程

所以主机间通信的目的本质是:在各自的主机上的两个进程在互相交互数据。IP地址可以完成主机和主机的通信,而主机上各自的通信进程,才是发送和接受数据的一方。

所以:

  • IP —— 确保主机的唯一性
  • 端口号(port)—— 确保该主机上的进程的唯一性
  • IP + PORT = 标识互联网中唯一的一个进程。—— socket
  • 网络通信的本质:也是进程间通信

socket:

  • socket在英文上有“插座”的意思,插座上有不同规格的插孔,我们将插头插入到对应的插孔当中就能够实现电流的传输。
  • 在进行网络通信时,客户端就相当于插头,服务端就相当于一个插座,但服务端上可能会有多个不同的服务进程(多个插孔),因此当我们在访问服务时需要指明服务进程的端口号(对应规格的插孔),才能享受对应服务进程的服务。

端口号

实际在两台主机上,可能会同时存在多个正在进行跨网络通信的进程,因此当数据到达对端主机后,必须要通过某种方法找到该主机上对应的服务进程,然后将数据交给该进程处理。而当该进程处理完数据后还要对发送端进行响应,因此对端主机也需要知道,是发送端上的哪一个进程向它发送的数据请求。

端口号(port)的作用实际就是标识一台主机上的一个进程。

  • 端口号(port)是传输层协议的内容。
  • 端口号是一个2字节16位的整数;
  • 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理;
  • IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
  • 一个端口号只能被一个进程占用

端口号和进程ID

我们之前在学习系统编程的时候, 学习了 pid 表示唯一一个进程; 此处我们的端口号也是唯一表示一个进程。那么这两者之间是怎样的关系?

  • 网络模块和进程管理模块进行解耦合。进程pid在技术上是可以标定当前主机上某一个唯一的进程,但是实际上不会用进程pid做,进程pid属于进程管理范畴,而端口号属于网络范畴。如果非要用进程pid做两用(既做调度进程管理,又在网络上标定主机的一个唯一进程),无疑是将进程管理和网络强耦合起来了。它可以但不合理。
  • 在我们的系统中,并不是所有的进程都要进行网络通信的。而端口号是一种数字,标定当前主机上某一个唯一的进程,它更加的是一种证明,证明对应的进程是要进行网络通信的。没有端口号,这个进程只是本地间跑某些业务。而有端口号,一定是要对外的。

举个例子:就比如学生都有身份证号,并且是唯一的,但是学校中又使用学号,也是唯一的,但是如果身份证不用了,也不会影响学校,这就完成了解耦,同时学校使用学号,会更方便的对学生进行管理,同时如果有学号就说明是属于这个学校的,但是身份证号是无法区分是不是这个学校的。

问1:底层如何通过port找到对应进程的?

  • 实际底层采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系,当底层拿到端口号时就可以直接执行对应的哈希算法,然后就能够找到该端口号对应的进程。

问2:一个进程可以绑定多个端口号吗?

  • 可以的。未来一个进程在进行网络通信的时候,它可能既和客户端A通信,也和客户端A的子模块通信,所以此进程就会绑定两个端口号。只要能够通过端口号找到同一个进程即可。但是一个端口号不能被多个进程绑定。因为端口号到进程具有唯一性。

源端口号和目的端口号

传输层协议(TCP和UDP)的数据段中有两个端口号,分别叫做源端口号和目的端口号。就是在描述 "数据是谁发的,要发给谁";

  • 比如唐僧取西经的例子,唐僧之所以去西天取经是受唐太宗的委托,唐太宗就是用户,唐僧的始发地是东土大唐,目的地是西天(主机),然后面见如来佛祖(目的端口,唯一的进程)。与其说唐僧取西经,到不如说是“唐太宗和如来佛祖在通信”。
  • 综上:贫僧自东土大唐而来,奉唐王御旨前往西天面见如来佛祖求取真经,东土大唐就是源IP,西天就是目的IP,而唐太宗就是源端口号,而如来佛祖就是目的端口号。

上述源IP,源端口就是一对socket,目的IP,目的端口也是一对socket


认识TCP协议和UDP协议

  • 网络协议栈是贯穿整个体系结构的,在应用层、操作系统层和驱动层各有一部分。当我们使用系统调用接口实现网络数据通信时,不得不面对的协议层就是传输层,而传输层最典型的两种协议就是TCP协议和UDP协议。

TCP协议:

  • TCP协议叫做传输控制协议(Transmission Control Protocol),TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。
  • TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输。其次,TCP协议是保证可靠的协议(也意味着要做更多的事情),数据在传输过程中如果出现了丢包、乱序等情况,TCP协议都有对应的解决方法。
  • 我给你打电话,我们俩不是直接通话的,首先,我要输入你的电话号码,然后拨出去,随后你的电话铃声响了,然后接电话,随后才是正式的沟通。在沟通前的整个预备动作就是建立连接。

UDP协议:

  • UDP协议叫做用户数据报协议(User Datagram Protocol),UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。
  • 使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议本身是不知道的。
  • 我发邮件给你的过程就是无需建立连接的,因为它不需要对方接受人的许可,可以直接发

TCP协议和UDP协议不存在哪个更好的说法:

  • 虽然TCP协议是可靠的,不过这也意味着它为了维持自己的可靠性,一定要做更多的工作,一定会比较复制。而UDP虽然是不可靠的,不需要做过多的工作,不过这也意味着UDP是足够简单的。
  • 不同的场景下用不同的协议方式。比如购物支付的时候,底层的协议就不能用UDP协议,必须采用TCP协议,因为要保证传输的可靠性。而看直播的时候,当人数增多时,一定会导致直播压力倍增,与其让压力增高,倒不如用最基本的UDP协议。网络好了,看的效果好,网络不好,那效果就不好,甚至看不了。UDP协议不会维护太多,自然数据转播的压力就小。

网络字节序

网络中的大小端问题:

计算机在存储数据时是有大小端的概念的:

  • 大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
  • 小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。

如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。但如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的。

  • 例如,现在两台主机之间在进行网络通信,其中发送端是小端机,而接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的。

  • 但由于发送端和接收端采用的分别是小端存储和大端存储,此时对于内存地址从低到高为44332211的序列,发送端按小端的方式识别出来是0x11223344,而接收端按大端的方式识别出来是0x44332211,此时接收端识别到的数据与发送端原本想要发送的数据就不一样了,这就是由于大小端的偏差导致数据识别出现了错误。

由于我们不能保证通信双方存储数据的方式是一样的,因此网络当中传输的数据必须考虑大小端问题。因此TCP/IP协议规定如下:网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据。

  • 如果发送端是小端,需要先将数据转成大端,然后再发送到网络当中。
  • 如果发送端是大端,则可以直接进行发送。
  • 如果接收端是小端,需要先将接收到数据转成小端后再进行数据识别。
  • 如果接收端是大端,则可以直接进行数据识别。

此外还需注意以下几点:

  • 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
  • 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
  • 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址

如下:由于发送端是小端机,因此在发送数据前需要先将数据转成大端,然后再发送到网络当中,而由于接收端是大端机,因此接收端接收到数据后可以直接进行数据识别,此时接收端识别出来的数据就与发送端原本想要发送的数据相同了。

  • 需要注意的是,所有的大小端的转化工作是由操作系统来完成的,因为该操作属于通信细节,不过也有部分的信息需要我们自行进行处理,比如端口号和IP地址。

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,系统提供了四个函数,可以通过调用以下库函数实现网络字节序和主机字节序之间的转换。

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);  //主机转网络
uint16_t htons(uint16_t hostshort); //网络转主机
uint32_t ntohl(uint32_t netlong);   //按4字节为单位主机转网络
uint16_t ntohs(uint16_t netshort);  //按2字节网络转主机
  • 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
  • 例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
  • 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
  • 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。

2、socket编程接口

socket常见API

创建 socket 套接字 (TCP/UDP, 客户端 + 服务器)

int socket(int domain, int type, int protocol);

绑定端口号 (TCP/UDP, 服务器)

int bind(int socket, const struct sockaddr *address, socklen_t address_len);

开始监听socket (TCP, 服务器)

int listen(int socket, int backlog);

接收请求 (TCP, 服务器)

int accept(int socket, struct sockaddr* address, socklen_t* address_len);

建立连接 (TCP, 客户端)

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

sockaddr结构

socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、 IPv6,以及后面要说的UNIX DomainSocket。然而,各种网络协议的地址格式并不相同。

  • 套接字不仅支持跨网络的进程间通信(网络套接字),还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要。网络的设计者想要把跨网络通信和本地通信进行大一统,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,其中sockaddr_in结构体是用于跨网络通信的(网络套接字),而sockaddr_un结构体是用于本地通信的(域间套接字)。
  • 为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockeaddr结构体,该结构体与sockaddr_in和sockaddr_un的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。

  • 此时当我们在传递在传参时,就不用传入sockeaddr_in或sockeaddr_un这样的结构体,而统一传入sockeaddr这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API内部就可以提取sockeaddr结构头部的16位进行识别,如果前16为地址类型是AD_INET,就是网络间通信,如果地址类型是AD_UNIX,就是本地间通信。如上我们就通过通用sockaddr结构,将套接字网络通信和本地通信的参数类型进行了统一。

注意:

  • IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型,16位端口号和32位IP地址。
  • IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。
  • socket API可以都用struct sockaddr* 类型表示,在使用的时候需要强制转化成sockaddr_in;这样的好处是程序的通用性,可以接收IPv4、IPv6,以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数。

我们可以包含如下四个头文件查看或使用sockaddr、sockaddr_in、in_addr的相关信息:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

sockaddr结构:

sockaddr_in结构: 

  • 虽然socket api的接口是sockaddr,但是我们真正在基于IPv4编程时,使用的数据结构是sockaddr_in;这个结构里主要有三部分信息:地址类型,端口号,IP地址。

in_addr结构:

  • in_addr用来表示一个IPv4的IP地址。其实就是一个32位的整数;

3、实现一个UDP网络程序

3.1、服务端udpServer.cc文件

我们把服务器封装成一个UdpServer类,该类里主要有三大函数:

  1. 服务端创建套接字
  2. 绑定服务器
  3. 运行服务器

下面依次演示:


服务端创建套接字

socket接口说明

我们把服务器封装成一个UdpServer类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器首先要创建套接字。创建套接字的函数叫做socket函数,函数原型如下:

#include <sys/types.h>         
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

参数说明:

  • domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于struct sockaddr结构的前16个位。如果是本地通信就设置为AF_UNIX,如果是网络通信就设置为AF_INET(IPv4)或AF_INET6(IPv6)。
  • type:创建套接字时所需的服务类型。其中最常见的服务类型是SOCK_STREAM和SOCK_DGRAM,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM,叫做流式套接字,提供的是流式服务。
  • protocol:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。

返回值说明:

  • 套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。

代码逻辑如下

  • 当我们在进行初始化服务器创建套接字时,就是调用socket函数创建套接字,创建套接字时我们需要填入的协议家族就是AF_INET,因为我们要进行的是网络通信,而我们需要的服务类型就是SOCK_DGRAM,因为我们现在编写的UDP服务器是面向数据报的,而第三个参数之间设置为0即可。
  • 无论创建成功与否,都复用logMessage函数打印相关日志信息
// 初始化
void init()
{
    // 1、创建socket套接字
    sockfd_ = socket(AF_INET, SOCK_DGRAM, 0); // 就是打开了一个文件
    if (sockfd_ < 0)
    {
        logMessage(FATAL, "%s:%d", strerror(errno), sockfd_); // 创建失败
        exit(1);
    }
    logMessage(DEBUG, "socket create success: %d", sockfd_);
    . . .
}

服务端绑定

bind接口说明

  • 当套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。由于现在编写的是不面向连接的UDP服务器,所以初始化服务器要做的第二件事就是绑定。

绑定的函数叫做bind,该函数的函数原型如下:

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数说明:

  • sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
  • addr:网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:传入的addr结构体的长度。

返回值说明:

  • 绑定成功返回0,绑定失败返回-1,同时错误码会被设置。

绑定过程如下

由于绑定时需要用到IP地址和端口号,因此我们需要在服务器类当中引入IP地址和端口号,在创建服务器对象时需要传入对应的IP地址和端口号,此时我们就可以根据传入的IP地址和端口号对对应的成员进行初始化。

  • 1、绑定网络信息,先填充基本信息到struc sockaddr_in结构体。
  • 定义struc sockaddr_in结构体对象local,复用bzero函数对local进行初始化。将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
  • 注意服务器的端口号是要发给对方的,在发送到网络之前需要将端口号设置为网络序列,所以要复用htons主机转网络函数把端口号port_转成网络序列,才能向外发送。
  • 注意ip地址默认是字符串风格点分十进制的(如"43.192.82.142"),我们要复用inet_addr函数将字符串IP转换成uint32_t类型的4字节整数IP(inet_addr除了做转换,还会自动给我们做主机转网络)。注意若ip地址是空的,那就用INADDR_ANY这个宏,否则再用inet_addr函数。这个宏就是0,我们不关心会bind到哪一个ip,任意地址bind,强烈推荐的做法,所有服务器一般的做法
  • 注意:我们使用的是云服务器,云服务器有一些特殊情况:1. 禁止你bind云服务器上的任何确定IP, 只能使用INADDR_ANY,如果你是虚拟机,随意。
  • 2、绑定网络信息,上述local临时变量(struc sockaddr_in结构体对象)是在用户栈上开辟的,要将其写入内核中。复用bind函数完成绑定操作。bind成功与否均复用logMessage函数打印相关日志信息。
  • 由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in*强转为struct sockaddr*类型后再进行传入。
// 初始化
void init()
{
    . . .
    // 2、绑定网络信息,指明ip + port
    // 2.1、先填充基本信息到struc sockaddr_in结构体
    struct sockaddr_in local;
    bzero(&local, sizeof(local)); // memset
    // 填充协议家族,域
    local.sin_family = AF_INET;
    // 填充服务器对应的端口号,此端口号一定是会发给对方的,port_一定会到网络中
    local.sin_port = htons(port_);
    // 服务器默认的ip地址"xx.yy.zz.aaa"是字符串风格点分十进制,要转成4字节IP
    // INADDR_ANY(0): 程序员不关心会bind到哪一个ip,任意地址bind,强烈推荐的做法,所有服务器一般的做法
    // inet_addr:指定填充确定的IP,一般在特殊用途或测试时使用,除了做转换,还会自动给我们做主机转网络
    local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
    // 2.2、bind 网络信息
    if (bind(sockfd_, (const struct sockaddr *)&local, sizeof(local)) == -1)
    {
        logMessage(FATAL, "%s:%d", strerror(errno), sockfd_); // 绑定失败
        exit(2);
    }
    logMessage(DEBUG, "socket bind success: %d", sockfd_);
    // done
}

运行服务器

recvfrom接口说明

UDP服务器的初始化就只需要创建套接字和绑定就行了,当服务器初始化完毕后我们就可以启动服务器了。

  • 服务器实际上就是在周而复始的为我们提供某种服务,服务器之所以称为服务器,是因为服务器运行起来后就永远不会退出,因此服务器实际执行的是一个死循环代码。由于UDP服务器是不面向连接的,因此只要UDP服务器启动后,就可以直接读取客户端发来的数据。

UDP服务器读取数据的函数叫做recvfrom,该函数的函数原型如下:

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

参数说明:

  • sockfd:对应操作的文件描述符。表示从该文件描述符索引的文件当中读取数据。
  • buf:读取数据的存放位置。
  • len:期望读取数据的字节数。
  • flags:读取的方式。一般设置为0,表示阻塞读取。
  • src_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:调用时传入期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数。

返回值说明:

  • 读取成功返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置。

注意:

  • 由于UDP是不面向连接的,因此我们除了获取到数据以外还需要获取到对端网络相关的属性信息,包括IP地址和端口号等。
  • 在调用recvfrom读取数据时,必须将addrlen设置为你要读取的结构体对应的大小。
  • 由于recvfrom函数提供的参数也是struct sockaddr*类型的,因此我们在传入结构体地址时需要将struct sockaddr_in*类型进行强转。

代码逻辑如下

  • 定义inbuffer数组(将来读取到的数据都放在这里),outbuffer数组(将来发送的数据,都放在这里)
  • 服务器是死循环的,用while(true)表示
  • 定义struct sockaddr_in的对象peer,定义len为peer的字节数
  • 复用recvfrom函数读取客户端数据,若返回值s > 0,则把inbuffer数组的下标s处置为0,将其当成字符串。若返回值s < 0,则复用logMessage函数输出错误信息。
  • 读取数据成功后,还需要读取客户端的port端口号和IP地址
  • 定义string类型的peerIp变量保存对方客户端的IP,注意这里给我发来的客户端地址先前已经由字符串风格点分十进制转为4字节整数了,所以现在要复用inet_ntoa函数将4字节整数IP转回字符串类型的IP。
  • 定义peerPort变量保存对方客户端的port,我们获取到的客户端的端口号此时是网络序列,注意要复用ntohs函数将其转为主机序列再进行打印输出
  • 复用logMessage函数按客户端数据,客户端port和IP的格式打印客户端给服务器发过来的消息
// 启动服务器
void start()
{
    // 服务器设计的时候,都是死循环
    char inbuffer[1024];  // 将来读取到的数据,都放在这里
    char outbuffer[1024]; // 将来发送的数据,都放在这里
    while (true)
    {
        struct sockaddr_in peer;      // 输出型参数
        socklen_t len = sizeof(peer); // 输入输出型参数
        // UDP是无连接的,对方给你发了消息,也要给对方回消息,后面两个参数是输出型参数(谁给你发的,消息长度是多少)
        ssize_t s = recvfrom(sockfd_, inbuffer, sizeof(inbuffer), 0, (struct sockaddr *)&peer, &len);
        if (s > 0)
        {
            inbuffer[s] = 0; // 当作字符串
        }
        else if (s == -1)
        {
            logMessage(WARINING, "recvfrom: %s:%d", strerror(errno), sockfd_); // 读取失败
            continue;
        }
        // 读取成功,除了读取到对方的数据,你还要读取到对方的网络地址[ip:port]
        std::string peerIp = inet_ntoa(peer.sin_addr); // 拿到了对方的IP
        uint32_t peerPort = ntohs(peer.sin_port);      // 拿到了对方的port
        // 打印客户端给服务器发过来的消息
        logMessage(NOTICE, "[%s:%d]# %s", peerIp.c_str(), peerPort, inbuffer);
    }
}

main函数命令行参数

  • 利用命令行参数的形式(./udpServer port [ip]),若main函数中argc != 2 && argc != 3,则复用提示信息函数Usage,并exit退出进程
  • 定义port端口为命令行的第二个参数(下标为1的参数)
  • 若argc == 3,则定义ip地址为命令行的第三个参数(下标为2的参数)
  • 将端口号和ip地址传入UdpServer服务器的类里,调用init和start函数
// ./udpServer port [ip]
int main(int argc, char *argv[])
{
    if (argc != 2 && argc != 3)
    {
        Usage(argv[0]);
        exit(3);
    }
    uint16_t port = atoi(argv[1]);
    std::string ip;
    if (argc == 3)
    {
        ip = argv[2];
    }
    UdpServer svr(port, ip);
    svr.init();
    svr.start();
    return 0;
}

服务端udpServer.cc总代码

UdpServer类的成员变量如下

  • port_:服务器必须得有端口号信息,引入端口号port_
  • ip_:服务器必须得有ip地址,定义一个string类型的ip_地址
  • sockfd_:服务器相关信息

UdpServer类的成员函数如下

  • UdpServer构造函数
  • ~UdpServer析构函数:
  • init初始化函数:
  • start启动服务器函数:

main函数命令行参数

总代码如下:

#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "Log.hpp"

// 使用手册
static void Usage(const std::string proc)
{
    // 可以不传ip,但必须传port
    std::cout << "Usage:\n\t" << proc << "prot [ip]" << std::endl;
}

//@brief 我们想写一个简单的udpSever
// 云服务器有一些特殊情况
class UdpServer
{
public:
    UdpServer(int port, std::string ip = "") // 默认参数必须放后面
        : port_((uint16_t)port), ip_(ip), sockfd_(-1)
    {
    }
    ~UdpServer()
    {
    }

public:
    // 初始化
    void init()
    {
        // 1、创建socket套接字
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0); // 就是打开了一个文件
        if (sockfd_ < 0)
        {
            logMessage(FATAL, "socket: %s:%d", strerror(errno), sockfd_); // 创建失败
            exit(1);
        }
        logMessage(DEBUG, "socket create success: %d", sockfd_);

        // 2、绑定网络信息,指明ip + port
        // 2.1、先填充基本信息到struc sockaddr_in结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); // memset
        // 填充协议家族,域
        local.sin_family = AF_INET;
        // 填充服务器对应的端口号,此端口号一定是会发给对方的,port_一定会到网络中
        local.sin_port = htons(port_);
        // 服务器默认的ip地址"xx.yy.zz.aaa"是字符串风格点分十进制,要转成4字节IP
        // INADDR_ANY(0): 程序员不关心会bind到哪一个ip,任意地址bind,强烈推荐的做法,所有服务器一般的做法
        // inet_addr:指定填充确定的IP,一般在特殊用途或测试时使用,除了做转换,还会自动给我们做主机转网络
        local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
        // 2.2、bind 网络信息
        if (bind(sockfd_, (const struct sockaddr *)&local, sizeof(local)) == -1)
        {
            logMessage(FATAL, "bind: %s:%d", strerror(errno), sockfd_); // 绑定失败
            exit(2);
        }
        logMessage(DEBUG, "socket bind success: %d", sockfd_);
        // done
    }
    // 启动服务器
    void start()
    {
        // 服务器设计的时候,都是死循环
        char inbuffer[1024];  // 将来读取到的数据,都放在这里
        char outbuffer[1024]; // 将来发送的数据,都放在这里
        while (true)
        {
            struct sockaddr_in peer;      // 输出型参数
            socklen_t len = sizeof(peer); // 输入输出型参数
            // UDP是无连接的,对方给你发了消息,也要给对方回消息,后面两个参数是输出型参数(谁给你发的,消息长度是多少)
            ssize_t s = recvfrom(sockfd_, inbuffer, sizeof(inbuffer), 0, (struct sockaddr *)&peer, &len);
            if (s > 0)
            {
                inbuffer[s] = 0; // 当作字符串
            }
            else if (s == -1)
            {
                logMessage(WARINING, "recvfrom: %s:%d", strerror(errno), sockfd_); // 读取失败
                continue;
            }
            // 读取成功,除了读取到对方的数据,你还要读取到对方的网络地址[ip:port]
            std::string peerIp = inet_ntoa(peer.sin_addr); // 拿到了对方的IP
            uint32_t peerPort = ntohs(peer.sin_port);      // 拿到了对方的port
            // 打印客户端给服务器发过来的消息
            logMessage(NOTICE, "[%s:%d]# %s", peerIp.c_str(), peerPort, inbuffer);
        }
    }

private:
    uint16_t port_;  // 服务器必须得有端口号信息
    std::string ip_; // 服务器必须得有ip地址
    int sockfd_;     // 服务器的socket fd信息
};

// ./udpServer port [ip]
int main(int argc, char *argv[])
{
    if (argc != 2 && argc != 3)
    {
        Usage(argv[0]);
        exit(3);
    }
    uint16_t port = atoi(argv[1]);
    std::string ip;
    if (argc == 3)
    {
        ip = argv[2];
    }
    UdpServer svr(port, ip);
    svr.init();
    svr.start();
    return 0;
}

3.2、客户端udpClient.cc文件

这里我们不像服务端udpServer.cc一样进行封装成类了。其内部主要框架逻辑如下:

  1. main函数采用命令行参数
  2. 客户端创建套接字
  3. 通讯过程(启动客户端)

下面依次演示


mian函数命令行参数

客户端在启动的时候必须要知道服务端的ip和port,才能进行连接服务端。未来的客户端程序一定是这样运行的:

./udpClient server_ip server_port
  • 如果命令行参数个数argc != 3,复用Usage函数输出相关提示信息,并退出程序
  • 定义string类型的server_ip变量保存命令行的第二个参数
//./udpClient server_ip server_port
//如果一个客户端要连接server,必须要知道server对应的ip和port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    //1、根据命令行,设置要访问的服务器IP和port
    std::string server_ip = argv[1];
    uint16_t server_port = atoi(argv[2]);
    return 0;
}

客户端创建套接字

客户端创建套接字时选择的协议家族也是AF_INET,需要的服务类型也是SOCK_DGRAM,当客户端被析构时也可以选择关闭对应的套接字。与服务端不同的是,客户端在初始化时只需要创建套接字就行了,而不需要进行绑定操作。

int main(int argc, char *argv[])
{
    ...
    // 2、创建客户端
    // 2.1、创建socket套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    assert(sockfd > 0);
    // 不需要绑定
    return 0;
}

客户端的绑定问题

客户端需不需要进行bind绑定呢?

  • 不需要。所谓的“不需要”,指的是:客户端不需要用户自己bind端口信息!因为OS会自动给你绑定

如果我非要自己bind呢?

  • 可以,但是严重不推荐。所有的客户端软件和server服务器在进行通信的时候,client客户端必须得有自己的[ip : port],server服务端也必须得有自己的[ip : port],上面已经对server服务端的ip和port进行绑定。这里客户端没绑不代表没有,因为os会帮我们。
  • 这里不推荐自己bind的原因是:client客户端很多,如果某个客户端绑定了某个端口号,那么以后这个端口号就只能给这一个客户端使用,就是这个客户端没有启动,这个端口号也无法分配给别人,并且如果这个端口号被别人使用了,那么这个客户端就无法启动了。所以客户端的端口只要保证唯一性就行了,因此客户端端口可以动态的进行设置,并且客户端的端口号不需要我们来设置,当我们调用类似于sendto这样的接口时,操作系统会自动给当前客户端获取一个唯一的端口号。
  • 也就是说,客户端每次启动时使用的端口号可能是变化的,此时只要我们的端口号没有被耗尽,客户端就永远可以启动。

为什么server服务端非要自己bind呢?

  • 因为服务器就是为了给别人提供服务的,因此服务器必须要让别人知道自己的IP地址和端口号,IP地址一般对应的就是域名,而端口号一般没有显示指明过,因此服务端的端口号一定要是一个众所周知的端口号,并且选定后不能轻易改变,否则客户端是无法知道服务端的端口号的,这就是服务端要进行绑定的原因,只有绑定之后这个端口号才真正属于自己,因为一个端口只能被一个进程所绑定,服务器绑定一个端口就是为了独占这个端口。

启动客户端

sendto接口说明

  • 启动客户端其实就是让客户端和服务端进行通信,那么就需要客户端向服务端发送信息。UDP客户端发送数据的函数叫做sendto,该函数的函数原型如下:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

参数说明:

  • sockfd:对应操作的文件描述符。表示将数据写入该文件描述符索引的文件当中。
  • buf:待写入数据的存放位置。
  • len:期望写入数据的字节数。
  • flags:写入的方式。一般设置为0,表示阻塞写入。
  • dest_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:传入dest_addr结构体的长度。

返回值说明:

  • 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。

注意:

  • 由于UDP不是面向连接的,因此除了传入待发送的数据以外还需要指明对端网络相关的信息,包括IP地址和端口号等。
  • 由于sendto函数提供的参数也是struct sockaddr*类型的,因此我们在传入结构体地址时需要将struct sockaddr_in*类型进行强转。

代码逻辑如下

  • 定义struct sockaddr_in类型的结构体指针server,复用bzero函数对其清零
  • 填写服务器对应的信息,将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
  • 注意要复用htons主机转网络函数把端口号转成网络序列,才能向外发送。
  • 注意要复用inet_addr函数将字符串IP转换成整数IP
  • 通讯过程是while(true)死循环,复用sendto函数发送消息给server服务端
int main(int argc, char *argv[])
{
    . . .
    // 2.2、填写服务器对应的信息
    struct sockaddr_in server;
    bzero(&server, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());
    // 3、通讯过程
    std::string buffer;
    while (true)
    {

        std::cout << "Please Enter# ";
        std::getline(std::cin, buffer);
        // 发送消息给server
        sendto(sockfd, buffer.c_str(), buffer.size(), 0,
               (const struct sockaddr *)&server, sizeof(server)); // 首次调用sendto函数的时候,我们的client会自动bind自己的ip和port
    }
    return 0;
}

客户端udpClient.cc总代码

这里我们不像服务端udpServer.cc一样进行封装成类了。其内部主要框架逻辑如下:

  1. main函数使用命令行参数:
  2. 客户端创建套接字
  3. 通讯过程

上面已经详细说明,现在来看总代码:

#include <iostream>
#include <cstdlib>
#include <cassert>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;

static void Usage(std::string name)
{
    std::cout << "Usage:\n\t" << name << "server_ip server_port" << std::endl;
}
//./udpClient server_ip server_port
// 如果一个客户端要连接server,必须要知道server对应的ip和port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    // 1、根据命令行,设置要访问的服务器IP和port
    std::string server_ip = argv[1];
    uint16_t server_port = atoi(argv[2]);

    // 2、创建客户端
    // 2.1、创建socket套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    assert(sockfd > 0);

    // 2.2、填写服务器对应的信息
    struct sockaddr_in server;
    bzero(&server, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());
    // 3、通讯过程
    std::string buffer;
    while (true)
    {

        std::cout << "Please Enter# ";
        std::getline(std::cin, buffer);
        // 发送消息给server
        sendto(sockfd, buffer.c_str(), buffer.size(), 0,
               (const struct sockaddr *)&server, sizeof(server)); // 首次调用sendto函数的时候,我们的client会自动bind自己的ip和port
    }

    return 0;
}

3.3、Log.hpp打印日志函数

我们在此文件中封装一个打印日志的logMessage函数。其参数如下:

  • 参数1:level表示输出的日志等级(等级用定义的宏表示)
  • 参数2:format表示格式化的方式(vsnprintf函数需要用到)
  • 参数3:利用可变参数,表示可以传多个参数

logMessage函数的代码逻辑如下:

  • 利用assert函数断言确保level等级是有效的。
  • 利用getenv函数获取当前的USER使用者
  • 定义一个va_list类型的指针表示可变参数列表类型,实际上就是一个char指针fmt。用va_start宏初始化此变量。
  • 利用vsnprintf函数将可变参数格式化输出到一个字符数组logInfo里。
  • 用va_end宏把定义的va_list类型的指针置空。
  • 利用FILE创建一个out文件,如果level是FATAL,则out为stderr,反之为stdout
  • 利用fprintf函数按照日志等级,打印日志的时间,打印日志的用户名的信息输出内容
#pragma once
#include <cstdio>
#include <cstdarg>
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <errno.h>
#include <ctime>

#define DEBUG 0
#define NOTICE 1
#define WARINING 2
#define FATAL 3

const char *log_level[] = {"DEBUG", "NOTICE", "WARINING", "FATAL"};
// 打印日志函数
void logMessage(int level, const char *format, ...) // logMessage(DEBUG, "%d", 10);
{
    // 确保level等级是有效的
    assert(level >= DEBUG);
    assert(level <= FATAL);

    // 获取当前的使用者(环境变量)
    char *name = getenv("USER");

    char logInfo[1024];
    // 定义一个va_list类型的指针表示可变参数列表类型
    va_list ap;
    // 初始化此指针变量
    va_start(ap, format);
    // 将可变参数格式化输出到一个字符数组logInfo里
    vsnprintf(logInfo, sizeof(logInfo) - 1, format, ap);
    // 置空此指针变量
    va_end(ap);

    FILE *out = (level == FATAL) ? stderr : stdout;
    // 日志等级,打印日志的时间,打印日志的用户名
    fprintf(out, "%s | %u | %s | %s\n",
            log_level[level],
            (unsigned int)time(nullptr),
            name == nullptr ? "unknow" : name,
            logInfo);
}

3.4、测试

本地测试

现在服务端和客户端的代码都已经编写完毕,我们可以先进行本地测试,此时服务器没有绑定外网,绑定的是本地环回。现在我们运行服务器时指明端口号为8080,再运行客户端,此时客户端要访问的服务器的IP地址就是本地环回127.0.0.1,服务端的端口号就是8080。

客户端运行之后提示我们进行输入,当我们在客户端输入数据后,客户端将数据发送给服务端,此时服务端再将收到的数据打印输出,这时我们在服务端的窗口也看到我们输入的内容。

补充:netstat 

当我们运行服务端后,可以通过netstat命令来查看当前网络的状态,这里我们可以选择携带nlup选项。netstat常用选项说明:

  • -n:直接使用IP地址,而不通过域名服务器。
  • -l:显示监控中的服务器的Socket。
  • -t:显示TCP传输协议的连线状况。
  • -u:显示UDP传输协议的连线状况。
  • -p:显示正在使用Socket的程序识别码和程序名称。

此时你就可以使用 netstat -nlup 命令查看网络的相关信息。./udpServer的那一行显示的就是我们运行的UDP服务器的网络信息。

我们可以尝试去掉-n选项再查看,此时原本显示IP地址的地方就变成了对应的域名服务器。

  • 其中netstat命令显示的信息中,Proto表示协议的类型,Recv-Q表示网络接收队列,Send-Q表示网络发送队列,Local Address表示本地地址,Foreign Address表示外部地址,State表示当前的状态,PID表示该进程的进程ID,Program name表示该进程的程序名称。其中Foreign Address写成0.0.0.0:*表示任意IP地址、任意的端口号的程序都可以访问当前进程。

当我们把客户端也运行起来,利用netstat命令查看网络信息:可以看到服务端的端口是8080,客户端的端口是36253。这里客户端能被netstat命令查看到,说明客户端也已经动态绑定成功了,这就是我们所谓的网络通信。 


远端测试

静态编译客户端

  • 我们可以将生成的客户端的可执行程序发送给你的其他朋友,进行网络级别的测试。为了保证程序在你们的机器是严格一致的,可以选择在编译客户端时携带-static选项进行静态编译。

此时由于客户端是静态编译的,可以看到生成的客户端的可执行程序要比服务端大得多:

分发客户端

  • 此时我们可以先使用 sz udpClient 命令 将该客户端可执行程序下载到本地机器,然后将该程序发送给你的朋友。而我们分发客户端的过程实际上就是我们在网上下载各种PC端软件的过程,我们下软件下的实际上就是客户端的可执行程序,而与之对应的服务端就在Linux服务器上部署着。

进行网络测试

上述发布的客户端可执行程序是通过我的华为云服务器发送的,现在我用阿里云的服务器打开此可执行程序。

我们通过rz -e命令将该文件放到此阿里云服务器上:

此时会发现我阿里云服务器已经成功上传了由我华为云服务器发布的udpClient可执行程序,不过现在没有可执行权限,我们可以使用chmod +x udpClient命令给它加上可执行权限即可:

  • 华为云的IP:124.71.25.237
  • 阿里云的IP:8.130.85.66

现在运行我华为云的服务端,然后运行此阿里云的客户端,现在我阿里云用华为云的IP地址和端口号作为命令行参数运行客户端。并向华为云服务端发送信息,此时就实现了阿里云和华为云的网络通信:


3.5、实现linux和windows通信

实现了linux的通信,现在让linux作为服务端,windows作为客户端。这样就能实现linux和windows的互相通信了。

windows代码如下:

#pragma warning(disable:4996)
#include <iostream>
#include <string>
#include <cstdio>
#include <cstdlib>
#include <cassert>
#include <WinSock2.h>
#pragma comment(lib, "Ws2_32.lib") //需要包含的链接库

int server_port = 8080;
std::string server_ip = "124.71.25.237";
int main()
{
	// 用作初始化套接字
	WSADATA data;
	// 初始化启动信息(初始套接字)
	(void)WSAStartup(MAKEWORD(2, 2), &data);
	(void)data;
// 2.1、创建socket套接字
    SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    assert(sockfd > 0);

    // 2.2、填写服务器对应的信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());
    // 3、通讯过程
    std::string buffer;
    while (true)
    {

        std::cout << "Please Enter# ";
        std::getline(std::cin, buffer);
        // 发送消息给server
        sendto(sockfd, buffer.c_str(), buffer.size(), 0,
            (const struct sockaddr*)&server, sizeof(server)); // 首次调用sendto函数的时候,我们的client会自动bind自己的ip和port
    }
    closesocket(sockfd);
    WSACleanup();
    system("pause");
	return 0;
}

linux代码不变,现在运行linux服务端,运行windows客户端,就能让windwos客户端向linux服务端发送消息了:


3.6、代码变形

变动1:服务端返回客户端消息

  • 上述代码已经能够实现客户端向服务端发送信息,现在修改代码,让服务端收到客户端发送的信息后,统统转为大写字母再发回到客户端。udpServer.cc文件的代码变动如下:

udpClinet.cc文件的代码变动如下:

测试效果如下:

变动2:模拟聊天室

  • 我们再次更改代码,利用unordered_map容器统计不同端口的用户,此过程用CheckOnlineUser函数实现。当服务端收到客户端发送的消息后,通过路由转发messageRoutine函数将数据分发到不同的用户。

CheckOnlineUser函数:

void CheckOnlineUser(std::string &ip, uint32_t port, struct sockaddr_in &peer)
{
    std::string key = ip;
    key += ":";
    key += std::to_string(port);
    auto iter = users.find(key);
    if (iter == users.end())
    {
        users.insert({key, peer});
    }
    else
    {
        // do nothing
    }
}

messageRoutine函数:

void messageRoutine(std::string ip, uint32_t port, std::string info)
{
    // 获取分发信息的ip和端口号port
    std::string message = "[";
    message += ip;
    message += ":";
    message += std::to_string(port);
    message += "]#";
    message += info;
    // 分发信息
    for (auto &user : users)
    {
        sendto(sockfd_, message.c_str(), message.size(), 0, (struct sockaddr *)&(user.second), sizeof(user.second));
    }
}
  • 再次修改udpClient.cc客户端的代码,创建新线程,让主线程负责发消息,新线程负责收消息,并打印到显示器上。总代码如下:

udpServer.cc代码:

#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <ctype.h>
#include <unordered_map>
#include "Log.hpp"

// 使用手册
static void Usage(const std::string proc)
{
    // 可以不传ip,但必须传port
    std::cout << "Usage:\n\t" << proc << "prot [ip]" << std::endl;
}

//@brief 我们想写一个简单的udpSever
// 云服务器有一些特殊情况
class UdpServer
{
public:
    UdpServer(int port, std::string ip = "") // 默认参数必须放后面
        : port_((uint16_t)port), ip_(ip), sockfd_(-1)
    {
    }
    ~UdpServer()
    {
    }

public:
    // 初始化
    void init()
    {
        // 1、创建socket套接字
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0); // 就是打开了一个文件
        if (sockfd_ < 0)
        {
            logMessage(FATAL, "socket: %s:%d", strerror(errno), sockfd_); // 创建失败
            exit(1);
        }
        logMessage(DEBUG, "socket create success: %d", sockfd_);

        // 2、绑定网络信息,指明ip + port
        // 2.1、先填充基本信息到struc sockaddr_in结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); // memset
        // 填充协议家族,域
        local.sin_family = AF_INET;
        // 填充服务器对应的端口号,此端口号一定是会发给对方的,port_一定会到网络中
        local.sin_port = htons(port_);
        // 服务器默认的ip地址"xx.yy.zz.aaa"是字符串风格点分十进制,要转成4字节IP
        // INADDR_ANY(0): 程序员不关心会bind到哪一个ip,任意地址bind,强烈推荐的做法,所有服务器一般的做法
        // inet_addr:指定填充确定的IP,一般在特殊用途或测试时使用,除了做转换,还会自动给我们做主机转网络
        local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
        // 2.2、bind 网络信息
        if (bind(sockfd_, (const struct sockaddr *)&local, sizeof(local)) == -1)
        {
            logMessage(FATAL, "bind: %s:%d", strerror(errno), sockfd_); // 绑定失败
            exit(2);
        }
        logMessage(DEBUG, "socket bind success: %d", sockfd_);
        // done
    }
    // 启动服务器
    void start()
    {
        // 服务器设计的时候,都是死循环
        char inbuffer[1024];  // 将来读取到的数据,都放在这里
        char outbuffer[1024]; // 将来发送的数据,都放在这里
        while (true)
        {
            struct sockaddr_in peer;      // 输出型参数
            socklen_t len = sizeof(peer); // 输入输出型参数
            // UDP是无连接的,对方给你发了消息,也要给对方回消息,后面两个参数是输出型参数(谁给你发的,消息长度是多少)
            ssize_t s = recvfrom(sockfd_, inbuffer, sizeof(inbuffer), 0, (struct sockaddr *)&peer, &len);
            if (s > 0)
            {
                inbuffer[s] = 0; // 当作字符串
            }
            else if (s == -1)
            {
                logMessage(WARINING, "recvfrom: %s:%d", strerror(errno), sockfd_); // 读取失败
                continue;
            }
            // 读取成功,除了读取到对方的数据,你还要读取到对方的网络地址[ip:port]
            std::string peerIp = inet_ntoa(peer.sin_addr); // 拿到了对方的IP
            uint32_t peerPort = ntohs(peer.sin_port);      // 拿到了对方的port

            CheckOnlineUser(peerIp, peerPort, peer); // 如果存在,什么都不做,如果不存在,就添加

            // 打印客户端给服务器发过来的消息
            logMessage(NOTICE, "[%s:%d]# %s", peerIp.c_str(), peerPort, inbuffer);
            // 消息路由,把收到的消息转发给所有人
            messageRoutine(peerIp, peerPort, inbuffer);

            // 谁给我发消息,就把消息转为大写回过去
            //  for (int i = 0; i < strlen(inbuffer); i++)
            //  {
            //      if (isalpha(inbuffer[i]) && islower(inbuffer[i]))
            //          outbuffer[i] = toupper(inbuffer[i]);
            //      else
            //          outbuffer[i] = toupper(inbuffer[i]);
            //  }

            sendto(sockfd_, outbuffer, strlen(outbuffer), 0, (struct sockaddr *)&peer, len);
        }
    }

    void CheckOnlineUser(std::string &ip, uint32_t port, struct sockaddr_in &peer)
    {
        std::string key = ip;
        key += ":";
        key += std::to_string(port);
        auto iter = users.find(key);
        if (iter == users.end())
        {
            users.insert({key, peer});
        }
        else
        {
            // do nothing
        }
    }

    void messageRoutine(std::string ip, uint32_t port, std::string info)
    {
        // 获取分发信息的ip和端口号port
        std::string message = "[";
        message += ip;
        message += ":";
        message += std::to_string(port);
        message += "]#";
        message += info;
        // 分发信息
        for (auto &user : users)
        {
            sendto(sockfd_, message.c_str(), message.size(), 0, (struct sockaddr *)&(user.second), sizeof(user.second));
        }
    }

private:
    uint16_t port_;  // 服务器必须得有端口号信息
    std::string ip_; // 服务器必须得有ip地址
    int sockfd_;     // 服务器的socket fd信息

    // online user
    std::unordered_map<std::string, struct sockaddr_in> users;
};

// ./udpServer port [ip]
int main(int argc, char *argv[])
{
    if (argc != 2 && argc != 3)
    {
        Usage(argv[0]);
        exit(3);
    }
    uint16_t port = atoi(argv[1]);
    std::string ip;
    if (argc == 3)
    {
        ip = argv[2];
    }
    UdpServer svr(port, ip);
    svr.init();
    svr.start();
    return 0;
}

udpClient.cc代码:

#include <iostream>
#include <cstdlib>
#include <cassert>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
using namespace std;

struct sockaddr_in server;

static void Usage(std::string name)
{
    std::cout << "Usage:\n\t" << name << "server_ip server_port" << std::endl;
}

void *recverAndPrint(void *args)
{
    while (true)
    {
        int sockfd = *(int *)args;
        char buffer[1024];
        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);
        ssize_t s = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&temp, &len);
        if (s > 0)
        {
            buffer[s] = 0;
            cout << "server echo# " << buffer << endl;
        }
    }
}
//./udpClient server_ip server_port
// 如果一个客户端要连接server,必须要知道server对应的ip和port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    // 1、根据命令行,设置要访问的服务器IP和port
    std::string server_ip = argv[1];
    uint16_t server_port = atoi(argv[2]);

    // 2、创建客户端
    // 2.1、创建socket套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    assert(sockfd > 0);

    // 2.2、填写服务器对应的信息
    bzero(&server, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());

    pthread_t t;
    pthread_create(&t, nullptr, recverAndPrint, (void *)&sockfd);
    // 3、通讯过程
    std::string buffer;
    while (true)
    {

        std::cerr << "Please Enter# ";
        std::getline(std::cin, buffer);
        // 发送消息给server
        sendto(sockfd, buffer.c_str(), buffer.size(), 0,
               (const struct sockaddr *)&server, sizeof(server)); // 首次调用sendto函数的时候,我们的client会自动bind自己的ip和port
    }

    close(sockfd);
    return 0;
}

我们创建fifo文件,让所有发送的信息重定向到此文件中,便于我们观察聊天现象,并且启动我的阿里云,和windows的VS,让阿里云,本地,windows共同发送信息到此服务器,现象如下:


4、地址转换函数 && inet_ntoa 

IP地址的表现形式有两种:

  • 字符串IP:类似于124.71.25.237这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址。
  • 整数IP:IP地址在进行网络传输时所用的形式,用一个32位的整数来表示IP地址。

下面是基于IPv4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表示32位 的IP 地址,但是我们通常用点分十进制的字符串表示IP 地址,以下函数可以在字符串表示 和in_addr表示之间转换。


字符串IP转整数IP

字符串转in_addr的函数:

1、inet_ntoa函数的函数原型如下:

int inet_aton(const char *cp, struct in_addr *inp);

参数说明:

  • cp:待转换的字符串IP。
  • inp:转换后的整数IP,这是一个输出型参数。

返回值说明:

  • 如果转换成功则返回一个非零值,如果输入的地址不正确则返回零值。

2、inet_addr函数的函数原型如下:

in_addr_t inet_addr(const char *cp);

参数说明:

  • cp:待转换的字符串IP。

返回值说明:

  • 如果输入的地址有效,则返回转换后的整数IP;如果输入的地址无效,则返回INADDR_NONE(通常为-1)。

3、inet_pton函数的函数原型如下:

int inet_pton(int af, const char *src, void *dst);

参数说明:

  • af:协议家族。
  • src:待转换的字符串IP。
  • dst:转换后的整数IP,这是一个输出型参数。

返回值说明:

  • 如果转换成功,则返回1。
  • 如果输入的字符串IP无效,则返回0。
  • 如果输入的协议家族af无效,则返回-1,并将errno设置为EAFNOSUPPORT

整数IP转IP字符串

in_addr转字符串的函数:

1、inet_ntoa函数的函数原型如下:

char *inet_ntoa(struct in_addr in);

参数说明:

  • in:待转换的整数IP。

返回值说明:

  • 返回转换后的字符串IP。

2、inet_ntop函数的函数原型如下:

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

参数说明:

  • af:协议家族。
  • src:待转换的整数IP。
  • dst:转换后的字符串IP,这是一个输出型参数。
  • size:用于指明dst中可用的字节数。

返回值说明:

  • 如果转换成功,则返回一个指向dst的非空指针;如果转换失败,则返回NULL。

注意:

  • 我们最常用的两个转换函数是inet_addr和inet_ntoa,因为这两个函数足够简单。这两个函数的参数就是需要转换的字符串IP或整数IP,而这两个函数的返回值就是对应的整数IP和字符串IP。
  • 其中inet_pton和inet_ntop函数不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr,因此这两个函数中对应的参数类型是void*。
  • 实际这些转换函数都是为了满足某些打印场景的,除此之外,更多的是用来做某些数据分析,比如网络安全方面的数据分析。

inet_ntoa函数

inet_ntoa这个函数返回了一个char*,很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果。那么是否需要调用者手动释放呢?

man手册上说,inet_ntoa函数,是把这个返回结果放到了静态存储区。这个时候不需要我们手动进行释放。那么问题来了,如果我们调用多次这个函数,会有什么样的效果呢?参见如下代码:

运行结果如下: 

  • 因为inet_ntoa把结果放到自己内部的一个静态存储区,这样第二次调用时的结果会覆盖掉上一次的结果。

问:如果有多个线程调用 inet_ntoa,是否会出现异常情况呢?

  • 在APUE中,明确提出inet_ntoa不是线程安全的函数;
  • 但是在centos7上测试,并没有出现问题,可能内部的实现加了互斥锁;
  • 在多线程环境下,推荐使用inet_ntop,这个函数由调用者提供一个缓冲区保存结果,可以规避线程安全问题;

多线程调用inet_ntoa代码示例如下:

#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
void *Func1(void *p)
{
    struct sockaddr_in *addr = (struct sockaddr_in *)p;
    while (1)
    {
        char *ptr = inet_ntoa(addr->sin_addr);
        printf("addr1: %s\n", ptr);
        sleep(1);
    }
    return NULL;
}
 
void *Func2(void *p)
{
    struct sockaddr_in *addr = (struct sockaddr_in *)p;
    while (1)
    {
        char *ptr = inet_ntoa(addr->sin_addr);
        printf("addr2: %s\n", ptr);
        sleep(1);
    }
    return NULL;
}
 
int main()
{
    pthread_t tid1 = 0;
    struct sockaddr_in addr1;
    struct sockaddr_in addr2;
    addr1.sin_addr.s_addr = 0;
    addr2.sin_addr.s_addr = 0xffffffff;
    pthread_create(&tid1, NULL, Func1, &addr1);
    pthread_t tid2 = 0;
    pthread_create(&tid2, NULL, Func2, &addr2);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    return 0;
}

但是实际在centos7上测试时,在多线程场景下调用inet_ntoa函数并没有出现问题,可能是该函数内部的实现加了互斥锁,这就跟接口本身的设计也是有关系的。

鉴于此,在多线程环境下更加推荐使用inet_ntop函数进行转换,因为该函数是由调用者自己提供缓冲区保存转换结果的,可以规避线程安全的问题。

Logo

CSDN联合极客时间,共同打造面向开发者的精品内容学习社区,助力成长!

更多推荐