网络编程socket(上)

目录

一、预备知识

1.1 端口号

1.2 初步认识TCP协议与UDP协议

1.3 网络字节序

二、socket编程接口

2.1 常见socketAPI

2.2 sockaddr结构

三、UDP网络程序

3.1 服务端初始化

3.1.1 服务端创建套接字

3.1.2 服务端绑定

3.1.3 字符串IP VS 整数IP

3.2  启动运行服务端

3.3 客户端初始化

3.3.1 客户端创建套接字

3.3.2 客户端的绑定问题

3.4 启动运行客户端

3.5  本地测试

3.6 INADDR_ANY

3.7 回声功能

3.8 网络测试


一、预备知识

1.1 端口号

socket通信本质

通过IP地址和MAC地址能够将数据发送到对端主机了,但实际上是想将数据发送给对端主机上的某个服务进程,此外,数据的发送者也不是主机,而是主机上的某个进程,比如当使用浏览器访问时,实际就是浏览器进程向对端服务进程发起的请求

socket通信本质上就是两个进程之间在进行通信,只不过这里是跨网络的进程间通信。比如逛淘宝和刷抖音的动作,实际就是手机上的淘宝客户端进程和抖音客户端进程在和对端服务器主机上的淘宝服务进程和抖音服务进程之间进行通信

进程间通信的方式除了管道、消息队列、信号量、共享内存等方式外,还有套接字,只不过前者是不跨网络的,而后者即可以跨网络也可以不跨网络

端口号

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

而端口号的作用正是标识一台主机上的某一个进程:

  • 端口号是传输层协议的内容
  • 端口号是一个2字节16位的整数
  • 端口号用来标识一个进程,使得操作系统知道当前数据要交给哪一个进程处理
  • 一个端口号只能被一个进程占用

当数据在传输层进行封装时,就会添加上对应源端口号和目的端口号的信息。这时通过源IP地址+源端口号就能够在网络上唯一标识发送数据的进程,通过目的IP地址+目的端口号就能够在网络上唯一标识接收数据的进程,此时就实现了跨网络的进程间通信

注意: 因为端口号是隶属于某台主机的,所以端口号可以在两台不同的主机当中重复,但是在同一台主机上进行网络通信的进程的端口号不能重复。此外,一个进程可以绑定多个端口号,但是一个端口号不能被多个进程同时绑定

prot VS PID

端口号(port)可唯一标识一台主机上的某个进程,进程ID(PID)的作用也是唯一标识一台主机上的某个进程,那在进行网络通信时为什么不直接用PID来代替port呢?

进程ID(PID)是用来标识系统内所有进程的唯一性的,属于系统级的概念;端口号(port)是用来标识需要对外进行网络数据请求的进程的唯一性的,属于网络的概念

虽然可以使用PID来标识网络进程的唯一性,但会使得系统部分与网络部分交错,导致耦合度较高

并且一个进程可以绑定多个端口号,但一个进程只能对应一个PID

如何通过port找到对应的进程?

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

1.2 初步认识TCP协议与UDP协议

网络协议栈贯穿整个体系结构,在应用层、操作系统层和驱动层都有存在。使用系统调用实现网络通信时,不得不面对的协议层就是传输层,而传输层最典型的两种协议就是TCP协议和UDP协议

TCP协议

TCP协议被称为传输控制协议(Transmission Control Protocol),TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议

TCP协议是面向连接的,若两台主机之间想要进行数据传输,那么必须先建立连接,连接建立成功后才能进行数据传输。其次,TCP协议是保证可靠的协议,数据在传输过程中若出现了丢包、乱序等情况,TCP协议都有对应的解决方案

UDP协议

UDP协议被称为用户数据报协议(User Datagram Protocol),UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议

使用UDP协议进行通信时无需建立连接,若两台主机之间想要进行数据传输,直接将数据发送给对端主机即可,但也意味着UDP协议是不可靠的,数据在传输过程中若出现了丢包、乱序等情况,UDP协议是不知情的

既然UDP协议是不可靠的,那为什么还要有UDP协议的存在?

可靠是需要做更多工作的,TCP协议虽然是一种可靠的传输协议,但TCP协议在底层需要做更多的工作,因此TCP协议底层的实现较为复杂

UDP协议虽然是一种不可靠的传输协议,但UDP协议在底层不需要做过多的工作,因此UDP协议底层的实现比TCP协议更为要简单,UDP协议虽然不可靠,但能够快速的将数据发送给对方

编写网络通信代码时具体采用TCP协议还是UDP协议,完全取决于上层的应用场景。若应用场景严格要求数据在传输过程中的可靠性,此时就采用TCP协议;若应用场景允许数据在传输出现少量丢包,那么优先选择UDP协议,因为UDP协议足够简单且足够快捷

注意: 一些优秀的网站在设计网络通信算法时,会同时采用TCP协议和UDP协议,当网络流畅时就使用UDP协议进行数据传输,而当网速不好时就使用TCP协议进行数据传输,动态的调整后台数据通信的算法

1.3 网络字节序

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

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

若编写的程序只在本地机器上运行,是不需要考虑大小端问题的,同一台机器上的数据采用的存储方式是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。

但若涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的

例如,两台主机之间进行网络通信,发送端是小端机,接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的,但小端机与大端机对内存中数据的解读方式是不相同的

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

如何解决大小端差异信息?

TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据

  • 若发送端是小端,先将数据转成大端,然后再发送到网络中
  • 若发送端是大端,则可直接进行发送
  • 若接收端是小端,先将接收到数据转成小端后再进行数据识别
  • 若接收端是大端,则可直接进行数据识别

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

大部分的大小端的转化工作由操作系统来完成,该操作属于通信细节。也有部分的信息需要程序员自行进行处理,如端口号和IP地址

为什么网络字节序采用的是大端?而不是小端?

  • TCP在Unix时代就有了,以前Unix机器都是大端机,因此网络字节序采用的就是大端,但之后人们发现用小端能简化硬件设计,所以现在主流的机器都是小端机,但协议已经不便更改了
  •  大端序更符合现代人的读写习惯

网络字节序与主机字节序之间的转换

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

#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

函数名中的 h 表示host,n 表示network,l 表示32位长整数,s 表示16位短整数

  • 如htonl()表示将32位长整数从主机字节序转换为网络字节序
  • 若主机是小端字节序,则函数将参数做相应的大小端转换然后返回
  • 若主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回

二、socket编程接口

2.1 常见socketAPI

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

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

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

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

监听套接字:(TCP,服务器)

int listen(int sockfd, int backlog);

接收请求:(TCP,服务器)

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

建立连接:(TCP,客户端)

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

2.2 sockaddr结构

sockaddr结构的出现

套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,sockaddr_in结构体用于跨网络通信,sockaddr_un结构体用于本地通信

为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了 sockaddr 结构体,该结构体与sockaddr_in和sockaddr_un的结构都不相同,但这三个结构体头部的16个bit位一样,这个字段被称为协议家族

此时传参时,就不用传入sockeaddr_in或sockeaddr_un结构体,而统一传入sockeaddr这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明是要进行网络通信还是本地通信。在这些API内部会提取sockeaddr结构头部的16位进行识别,进而得出是要进行网络通信还是本地通信,然后执行对应的操作。此时就通过通用sockaddr结构,将套接字网络通信和本地通信的参数类型进行了统一

注意: 实际在进行网络通信时,定义的还是sockaddr_in结构体变量,只不过在传参时需要将该结构体变量地址的类型强转为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结构体指针做为参数

为什么不使用void*代替struct sockaddr*类型? 

可以将这些函数的struct sockaddr*参数类型改为void*,此时在函数内部也可以直接指定提取头部的16个比特位进行识别,最终也能够判断是需要进行网络通信还是本地通信,那为什么还要设计出sockaddr这样的结构呢?

实际在设计这一套网络接口的时候C语言还不支持void*,于是就设计出了sockaddr这样的解决方案。并且在C语言支持了void*之后也没有将它改回来,因为这些接口是系统接口,系统接口是所有上层软件接口的基石,系统接口是不能轻易更改的,否则引发的后果是不可想的,这也就是为什么现在依旧保留sockaddr结构的原因

三、UDP网络程序

3.1 服务端初始化

3.1.1 服务端创建套接字

将服务器封装成一个类,当定义出一个服务器对象后需要马上初始化服务器,而初始化服务器需要做的第一件事就是创建套接字

socket函数

  • 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函数属于什么类型的接口?

网络协议栈是分层的,按照TCP/IP四层模型,自顶向下依次是应用层、传输层、网络层和数据链路层。而现在所写的代码被称为用户级代码,即是在应用层编写代码,因此调用的实际是下三层的接口,而传输层和网络层都是在操作系统内完成的,所以socket()属于系统调用接口

socket函数底层做了什么?

socket函数是被进程所调用的,而每一个进程在系统层面上都有PCB(task_struct)、文件描述符表(files_struct)以及对应打开的各种文件。而文件描述符表里面包含了一个数组fd_array,其中数组中的0、1、2下标依次对应的就是标准输入、标准输出以及标准错误

当调用socket函数创建套接字时,实际相当于打开了一个"网络文件",打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的"网络文件",最后3号文件描述符作为socket函数的返回值返回给了用户

其中每一个struct file结构体中包含的就是对应打开文件各种信息,比如文件的属性信息、操作方法以及文件缓冲区等。其中文件对应的属性在内核当中是由 struct inode 结构体来维护的,而文件对应的操作方法实际就是一堆的函数指针(比如read*和write*)在内核当中就是由 struct file_operations 结构体来维护的。而文件缓冲区对于打开的普通文件而言对应的一般是磁盘,但对于现在打开的"网络文件"而言,文件缓冲区对应的就是网卡

对于普通文件而言,当用户通过文件描述符将数据写到文件缓冲区,然后再把数据刷到磁盘上就完成了数据的写入操作。而对于socket函数打开的"网络文件"而言,当用户将数据写到文件缓冲区后,操作系统会定期将数据刷到网卡里面,网卡负责数据发送,数据最终就发送到了网络中

代码实现

进行初始化服务器创建套接字时,就是调用socket函数创建套接字,创建套接字时需要填入的协议家族为AF_INET,因为要进行的是网络通信,而需要的服务类型就是SOCK_DGARM,因为现在编写的UDP服务器是面向数据报的,而第三个参数之间设置为0即可

//UdpServer.h
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>

using std::cout;
using std::cerr;
using std::endl;

class UdpServer
{
public:
    bool InitServer();
    ~UdpServer();
private:
    int _socket_fd;
};
//UdpServer.cc
#include "UdpServer.h"

bool UdpServer::InitServer() {
    _socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
    if(_socket_fd < 0) {
        cerr << "socket fail" << endl;
        return false;
    }
    cout << "socket create success" << endl;
    return true;
}

UdpServer::~UdpServer() {
    if(_socket_fd > 0) close(_socket_fd);
}

当析构服务器时,可以将 _socket_fd 对应的文件进行关闭,但实际上不进行该操作也行,因为一般服务器运行后是就不会停下来的

#include "UdpServer.h"

int main() 
{
    UdpServer* server = new UdpServer;
    server->InitServer();
    return 0;
}

运行程序后套接字创建成功,对应获取到的文件描述符为3,因为0、1、2默认被标准输入流、标准输出流和标准错误流占用,此时最小的、未被利用的文件描述符就是3

3.1.2 服务端绑定

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

bind函数

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

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

struct sockaddr_in结构体

在绑定时需要将网络相关的属性信息填充到一个结构体当中,然后将该结构体作为bind函数的第二参数传入,就是 struct sockaddr_in 结构体

在 /usr/include/linux/in.h 中可以找到 struct sockaddr_in 的定义

  • sin_family:表示协议家族
  • sin_port:表示端口号,是一个16位的整数
  • sin_addr:表示IP地址,是一个32位的整数

剩下的字段一般不做处理,当然也可以进行初始化

其中 sin_addr 的类型是 struct in_addr,实际该结构体中只有一个成员,该成员就是一个32位的整数,IP地址实际就存储在这个整数中

如何理解绑定?

在进行绑定的时候需要将IP地址和端口号告诉对应的网络文件,此时就可以改变网络文件当中文件操作函数的指向,将对应的操作函数改为对应网卡的操作方法,此时读数据和写数据对应的操作对象就是网卡了,所以绑定实际上就是将文件和网络关联起来

代码实现

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

class UdpServer
{
public:
    UdpServer(uint16_t port,string ip):_socket_fd(-1),_port(port),_ip(ip) {}
    bool InitServer();
    ~UdpServer();
private:
    int _socket_fd;
    uint16_t _port;
    string _ip;
};

套接字创建完毕后就需要进行绑定,但在绑定之前我需先定义一个 struct sockaddr_in 结构变量,将对应的网络属性信息填充到该结构当中。由于该结构体中还有部分选填字段,因此最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量中

在发送到网络之前需要将端口号设置为网络序列,由于端口号是16位的,因此需要使用htons()函数将端口号转为网络序列。此外,由于网络当中传输的是整数IP,需要调用inet_addr()函数将字符串IP转换成整数IP(同时转成网络序列),然后再将转换后的整数IP进行设置

当网络属性信息填充完毕后,由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将 struct sockaddr_in* 强转为 struct sockaddr* 类型后再进行传入

bool UdpServer::InitServer() {
    //创建套接字
    _socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
    if(_socket_fd < 0) {
        cerr << "socket fail" << endl;
        return false;
    }
    cout << "socket create success , fd:" << _socket_fd << endl;

    //填充网络通信相关信息
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port);
    local.sin_addr.s_addr = inet_addr(_ip.c_str());
    //绑定
    if(bind(_socket_fd, (struct sockaddr*)&local, sizeof(local)) < 0) {
        cerr << "bind fail" << endl;
        return false;
    }
    cout << "bind success" << endl;

    return true;
}

3.1.3 字符串IP VS 整数IP

网络传输数据时寸土寸金,若在网络传输时直接以基于字符串的点分十进制IP的形式进行IP地址的传送,那么此时一个IP地址就需要15个字节,但实际并不需要耗费这么多字节

IP地址实际可以划分为四个区域,其中每一个区域的取值都是0~255,而这个范围的数字只需要用8个bit位即可表示,因此只需要32个bit位就能表示一个IP地址。其中这个32位的整数的每一个字节对应的就是IP地址中的某个区域,将IP地址的这种表示方法称之为整数IP

采用整数IP的方案表示一个IP地址只需要4个字节,并且也能表示同样的含义,因此在网络通信时就没有用字符串IP而用的是整数IP,减少网络通信时数据的传送

字符串IP和整数IP相互转换

转换的方式有很多,比如可以定义一个位段A,位段A中有四个成员,每个成员的大小都是8个bit位,这四个成员就依次表示IP地址的四个区域,共32个bit位

然后再定义一个联合体IP,该联合体中有两个成员,其中一个是32位的整数,其代表的就是整数IP,还有一个就是位段A类型的成员,其代表的就是字符串IP

由于联合体的空间是成员共享的,因此设置IP和读取IP的方式如下:

  • 当想以整数IP的形式设置IP时,直接将其赋值给联合体的第一个成员即可
  • 当想以字符串IP的形式设置IP时,先将字符串分成对应的四部分,然后将每部分转换成对应的二进制序列依次设置到联合体中第二个成员当中的p1、p2、p3和p4即可
  • 当想取出整数IP时,直接读取联合体的第一个成员即可
  • 当想取出字符串IP时,依次获取联合体中第二个成员当中的p1、p2、p3和p4,然后将每一部分转换成字符串后拼接到一起即可

注意: 在操作系统内部实际用的就是位段和枚举,来完成字符串IP和整数IP之间的相互转换的

inet_addr函数

将字符串IP转换成整数IP,转为网络序列

in_addr_t inet_addr(const char *cp);

inet_ntoa函数

将整数IP转换成字符串IP,转为主机序列

char *inet_ntoa(struct in_addr in);

传入inet_ntoa函数的参数类型是 in_addr ,因此在传参时不需要选中 in_addr 结构中的32位的成员传入,直接传入in_addr 结构体即可

3.2  启动运行服务端

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地址、端口号等(输出型参数,但不可设置为nullptr、NULL)
  • addrlen:调用时传入期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度(输入输出型参数)

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

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

提供启动服务器接口

服务端通过recvfrom函数读取客户端数据,可以先将读取到的数据视为字符串,将读取到的数据的后一个位置设置为'\0',此时就可以将读取到的数据进行输出,同时也可以将获取到的客户端的IP地址和端口号也一并进行输出

获取到的客户端的端口号此时是网络序列,需要调用ntohs函数将其转为主机序列再进行打印输出。获取到的客户端的IP地址是整数IP,需要通过调用inet_ntoa函数将其转为字符串IP(转成主机序列)再进行输出

void UdpServer::Start()
{
    char buffer[SIZE];
    while(true) 
    {
        struct sockaddr_in ping;
        socklen_t length =  sizeof(ping);
        ssize_t size = recvfrom(_socket_fd, buffer, SIZE - 1, 0, (struct sockaddr*)&ping, &length);
        if(size > 0) {
            buffer[size] = '\0';
            uint16_t port = ntohs(ping.sin_port);
            string ip = inet_ntoa(ping.sin_addr);
            cout << "[" << ip << ":" << port << "]#" << buffer << endl;
        }
        else {
            cerr << "recvfrom fail" << endl;
        }
    } 
}

注意:若调用recvfrom函数读取数据失败,可以打印一条提示信息,但不要让服务器退出,服务器不能因为读取某一个客户端的数据失败就退出

引入命令行参数

鉴于构造服务器时需要传入IP地址和端口号,可以引入命令行参数。此时当运行服务器时在后面跟上对应的IP地址和端口号即可

目前使用IP地址127.0.0.1。IP地址为127.0.0.1等价于localhost表示本地主机,被称为本地环回,数据只在本地协议栈中流动,不经过网络。先在本地测试能否正常通信,然后再进行网络通信测试

int main(int argc, char* argv[]) 
{
    if(argc < 3) {
        cerr << "Usage " << argv[0] << " ip port" << endl;
        return 1;
    }
    UdpServer* server = new UdpServer(string(argv[1]),atoi(argv[2]));
    server->InitServer();
    server->Start();
    return 0;
}

agrv数组里面存储的是字符指针,而端口号是一个整数,需要使用atoi函数将字符串转换成整数。然后就可以用这个IP地址和端口号来构造服务器了,服务器构造完成并初始化后就可以调用Start函数启动服务器了

客户端代码还没有编写,可以使用 netstat命令 来查看当前网络的状态,这里可以选择nlup选项

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

去掉 n选项,原本显示IP地址的位置变成了对应的域名服务器

Proto 表示协议的类型,Recv-Q 表示网络接收队列,Send-Q 表示网络发送队列,Local Address 表示本地地址,Foreign Address 表示外部地址,State 表示当前的状态,PID表示该进程的进程ID,Program name 表示该进程的程序名称

其中 Foreign Address 写成0.0.0.0:*表示任意IP地址、任意的端口号的程序都可以访问当前进程

3.3 客户端初始化

3.3.1 客户端创建套接字

将客户端封装成一个类,当定义出一个客户端对象后也需要对其进行初始化,而客户端在初始化时也要创建套接字,之后客户端发送数据或接收数据也就是对这个套接字进行操作

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

class UdpClient
{
public:
    UdpClient(string server_ip,uint16_t server_port):_server_ip(server_ip),_server_port(server_port) {}
    bool InitClient();
    ~UdpClient() {}
private:
    int _socket_fd;
    string _server_ip;
    uint16_t _server_port;
};


bool UdpClient::InitClient() 
{
	_socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
	if (_socket_fd < 0){
		std::cerr << "socket create error" << std::endl;
		return false;
	}
	return true;
}

UdpClient::~UdpClient() { if(_socket_fd < 0) close(_socket_fd); }

3.3.2 客户端的绑定问题

由于是网络通信,通信双方都需要找到对方,因此服务端和客户端都需要有各自的IP地址和端口号,只不过服务端需要进行端口号的绑定,而客户端不需要

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

客户端在通信时虽然也需要端口号,但客户端一般是不进行绑定的,客户端访问服务端的时候,端口号只要是唯一的就行了,不需要和特定客户端进程强相关

若客户端绑定了某个端口号,那么以后这个端口号就只能给这一个客户端使用,就是这个客户端没有启动,这个端口号也无法分配给别人,并且若这个端口号被别人使用了,那么这个客户端就无法启动了。所以客户端的端口只要保证唯一性就行了。因此客户端端口可以动态的进行设置,客户端的端口号就不需要程序员设置,当调用类似于sendto()这样的接口时,操作系统会自动给当前客户端获取一个唯一的端口号

客户端每次启动时使用的端口号可能是变化的,此时只要端口号没有耗尽,客户端就可以正常启动

3.4 启动运行客户端

当客户端初始化完毕后就可以将客户端运行起来,由于客户端和服务端在功能上是相互补充的,既然服务器是在读取客户端发来的数据,那么客户端就应该向服务端发送数据

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*类型强转

提供启动客户端接口

客户端要发送数据给服务端,可以让客户端获取用户输入,不断将用户输入的数据发送给服务端

客户端中存储的服务端的端口号此时是主机序列,需要调用htons()函数将其转为网络序列后再设置进struct sockaddr_in结构体。客户端中存储的服务端的IP地址是字符串IP,需要通过调用inet_addr()函数将其转为整数IP(同时转成网络序列)后再设置进struct sockaddr_in结构体

void UdpClient::Start()
{
    string message;
    struct sockaddr_in receive;
    memset(&receive, 0, sizeof(receive));
    receive.sin_port = htons(_server_port);
    receive.sin_family = AF_INET;
    receive.sin_addr.s_addr = inet_addr(_server_ip.c_str());

    while(true)
    {
        cout << "please Enter#";
        getline(cin, message);
        sendto(_socket_fd, message.c_str(), strlen(message.c_str()), 0, (struct sockaddr*)&receive, sizeof(receive));
    }
}

引入命令行参数

引入命令行参数,运行客户端时直接在后面跟上对应服务端的IP地址和端口号即可

int main(int argc, char* argv[])
{
    if(argc < 3) {
        cerr << "Usage " << argv[0] << " ip port " << endl;
        return 1;
    }
    string serve_ip = argv[1];
    uint16_t serve_port = atoi(argv[2]);
    UdpClient* client = new UdpClient(serve_ip, serve_port);
    client->InitClient();
    client->Start();
    return 0;
}

3.5  本地测试

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

使用netstat命令查看网络信息,可以看到服务端的端口是8081,客户端的端口是36577,客户端也已动态绑定成功了

3.6 INADDR_ANY

进行网络测试,直接让服务端绑定公网IP,此时这个服务端就可以被外网访问

将服务端设置的本地环回改为博主的公网IP,此时重新运行服务端的时候会发现服务端绑定失败

由于云服务器的IP地址是由对应的云厂商提供的,这个IP地址并不一定是真正的公网IP,这个IP地址是不能直接被绑定的,若需要让外网访问,此时需要bind 0。系统当中提供的一个INADDR_ANY(宏值),对应的值就是0

若需要让外网访问,那么进行绑定时就应该绑定INADDR_ANY,此时服务器才能够被外网访问

绑定INADDR_ANY的好处

当一个服务器的带宽足够大时,一台机器接收数据的能力就约束了这台机器的IO效率,因此一台服务器底层可能装有多张网卡,此时这台服务器就可能会有多个IP地址,但一台服务器上端口号为8081的服务只有一个。这台服务器在接收数据时,多张网卡在底层实际都收到了数据,可能这些数据也都想访问端口号为8081的服务

此时若服务端在绑定的时候是指明绑定的某一个IP地址,那么此时服务端在接收数据的时候就只能从绑定IP对应的网卡接收数据。而若服务端绑定的是INADDR_ANY,那么只要是发送给端口号为8081的服务的数据,系统都会可以将数据自底向上交给该服务端

因此服务端绑定INADDR_ANY这种方案是强烈推荐的,所有的服务器具体在操作时采用的也是这种方案

若你既想让外网访问你的服务器,但又想指向绑定某一个IP地址,那么就不能使用云服务器,此时可以选择使用虚拟机或者自定义安装的Linux操作系统,那个IP地址就是支持自定义绑定的,而云服务器是不支持的

更改代码

bool UdpServer::InitServer() {
    //创建套接字
    _socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
    if(_socket_fd < 0) {
        cerr << "socket fail" << endl;
        return false;
    }
    cout << "socket create success , fd:" << _socket_fd << endl;

    //填充网络通信相关信息
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port);
    local.sin_addr.s_addr = INADDR_ANY;
    //绑定
    if(bind(_socket_fd, (struct sockaddr*)&local, sizeof(local)) < 0) {
        cerr << "bind fail" << endl;
        return false;
    }
    cout << "bind success" << endl;

    return true;
}

此时再重新编译运行服务器时就不会绑定失败了,并且此时再用netstat命令查看时会发现,该服务器的本地IP地址变成0.0.0.0,这意味着该UDP服务器可以在本地读取任何一张网卡里面的数据

3.7 回声功能

由于在进行网络测试的时候,当客户端发送数据给服务端时,服务端会将从客户端收到的数据进行打印,因此服务端是能够看到现象的。但客户端一直在向服务端发送数据,在客户端这边看不出服务端是否收到了自己发送的数据

服务端代码编写

可以将该服务器改成一个回声服务器。当服务端收到客户端发来的数据后,除了在服务端进行打印以外,服务端可以调用sendto函数将收到的数据重新发送给对应的客户端

服务端在调用sendto函数时需要传入客户端的网络属性信息,但服务端是知道客户端的网络属性信息的,因为服务端在此之前就已经通过recvfrom函数获取到了客户端的网络属性信息

void UdpServer::Start()
{
    char buffer[SIZE];
    while(true) 
    {
        struct sockaddr_in ping;
        socklen_t length =  sizeof(ping);
        ssize_t size = recvfrom(_socket_fd, buffer, SIZE - 1, 0, (struct sockaddr*)&ping, &length);
        if(size > 0) {
            buffer[size] = '\0';
            uint16_t port = ntohs(ping.sin_port);
            string ip = inet_ntoa(ping.sin_addr);
            cout << "[" << ip << ":" << port << "]#" << buffer << endl;
        }
        else {
            cerr << "recvfrom fail" << endl;
        }

        string echo_message = "server echo:";
		echo_message += buffer;
		sendto(_socket_fd, echo_message.c_str(), echo_message.size(), 0, (struct sockaddr*)&ping, length);
    } 
}

客户端代码编写

当客户端发完数据给服务端后,由于服务端还会将该数据重新发给客户端,因此客户端发完数据后还需要调recvfrom来读取服务端发来的响应数据,客户端接收到服务端的响应数据后,将数据原封不动的打印出来就行了

此时客户端发送给服务端的数据,除了在服务端会打印显示以外,服务端还会将数据再重新发回给客户端,此时客户端也会接收到响应数据然后将该数据进行打印

void UdpClient::Start()
{
    string message;
    struct sockaddr_in receive;
    memset(&receive, 0, sizeof(receive));
    receive.sin_port = htons(_server_port);
    receive.sin_family = AF_INET;
    receive.sin_addr.s_addr = inet_addr(_server_ip.c_str());

    while(true)
    {
        cout << "please Enter#";
        getline(cin, message);
        sendto(_socket_fd, message.c_str(), strlen(message.c_str()), 0, (struct sockaddr*)&receive, sizeof(receive));

        char buffer[SIZE];
		struct sockaddr_in tmp;
		socklen_t length = sizeof(tmp);
		ssize_t size = recvfrom(_socket_fd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&tmp, &length);
		if (size > 0) {
			buffer[size] = '\0';
			cout << buffer << endl;
		}
    }
}

 

3.8 网络测试

此时可以使用 sz命令 将该客户端可执行程序下载到本地机器,然后将该程序发送给你的朋友

当你的朋友收到这个客户端的可执行程序后,可以使用 rz命令 或拖拽的方式将这个可执行程序上传到他的云服务器上,然后使用 chmod命令 给该文件加上可执行权限

先将服务端启动,然后你的朋友将你的IP地址和端口号作为命令行参数运行客户端,就可以访问你的服务器了

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

GG_Bond20

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值