「网络编程」第二讲:网络编程socket套接字(二)_ 简单UDP网络通信程序的实现

「前言」文章是关于网络编程的socket套接字方面的,上一篇是网络编程socket套接字(一),下面开始讲解!

「归属专栏」网络编程

「笔者」枫叶先生(fy)

「座右铭」前行路上修真我

「枫叶先生有点文青病」

「每篇一句」

我认为,每个人都有一个觉醒期,但觉醒的早晚决定个人的命运。 
 ——路遥《平凡的世界》

目录

三、简单的UDP网络程序

3.1 服务端创建

3.1.1 创建套接字 

3.1.2 绑定端口

3.1.3 sockaddr_in结构体

3.1.4 字符串IP和整数IP说明

3.1.5 绑定好端口号的服务端代码

3.1.6 服务端代码

3.2  客户端创建

3.2.1 关于客户端的绑定问题 

3.2.2 客户端代码

3.3 服务端和客户端测试


三、简单的UDP网络程序

接下来进行编写socket套接字代码,先使用的是UDP,边写代码边讲一下socket的接口,还有一些原理

3.1 服务端创建

首先明确,这个简单的UDP网络程序分客户端和服务端,所以我们要生成两个可执行程序,一个是客户端的,另一个是服务端的,服务端充当的是服务器,暂时实现的功能是客户端和服务端简单进行通信,服务端要可以收到客户端发送给服务端的信息,目前就先简单实现这样的功能

下面进行编写服务端的代码

3.1.1 创建套接字 

先介绍创建套接字的函数socket

socket函数

socket函数的作用是创建套接字,TCP/UDP 均可使用该函数进行创建套接字,man 2 socket查看:

 create an endpoint for communication:创建通信端点,即创建通信的一端 

函数:socket

头文件:
        #include <sys/types.h>
        #include <sys/socket.h>

函数原型:
        int socket(int domain, int type, int protocol);

参数:
    第一个参数domain:创建套接字的域,即创建套接字的类型
    第二个参数type:创建套接字时提供的服务类型
    第三参数protocol:创建套接字的协议类别

返回值:
    套接字创建成功返回一个文件描述符,创建失败返回-1,错误码被设置

 下面介绍socket函数的参数

(1)socket函数的第一个参数是domain,用于创建套接字的类型,该参数就相当于 struct sockaddr结构体的前16位,即2字节

该domain参数的选项已经设置好了,我们直接选用即可。该参数的选项很多,我们常用的也就几个:

  • 如果要选择本地通信,则选择 AF_UNIX
  • 如果要选择网络通信,则选择 AF_INET(IPv4)或者 AF_INET6(IPv6)
  • "inet" 是Internet Protocol(IP)的简写

(2)socket函数的第二个参数是type,用于创建套接字时提供的服务类型

该参数的选项也是已经设置好了,我们直接选用即可。该参数的选项很多,我们常用的也就几个:

  • 如果是基于UDP的网络通信,我们采用的就是 SOCK_DGRAM,套接字数据报,提供的用户数据报服务(对应UDP的特点:面向数据报)
  • 如果是基于TCP的网络通信,我们采用的就是 SOCK_STREAM,流式套接字,提供的是流式服务(对应TCP的特点:面向字节流)
  • SOCK_DGRAM对应的英文:socket datagram
  • SOCK_STREAM对应的英文:socket stream
  • 至于第四个 SOCK_RAW 是原始套接字

(3)socket函数的第三个参数是protocol,用于创建套接字的协议类别。

  • 可以指明为TCP或UDP,但该字段一般直接设置为0就可以了。
  • 设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议

socket函数返回值问题

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

解释套接字创建成功返回一个文件描述符的问题

  • 当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,这个网络文件就是“网卡”
  • 文件描述符下标0、1、2依次被标准输入、标准输出以及标准错误占用,
  • 如果程序没有打开其他文件,当套接字创建成功时,文件描述符下标为3的指针就指向了这个打开的 “网络文件”
  • 我们读取、发送数据,就从这个 “网络文件” 进行读取和发送
  • 所以操作网络就像操作文件一般,这个“网络文件”就是一个缓冲区

明确一点

  • 按照TCP/IP四层模型来说,自顶向下依次是应用层、传输层、网络层和数据链路层。
  • 而我们现在所写的代码都叫做用户级代码,也就是说我们是在应用层编写代码
  • 因此我们调用的实际是下三层的接口,而传输层和网络层都是在操作系统内完成的,也就意味着我们在应用层调用的接口都叫做系统调用接口

 

服务端创建套接字编写代码暂时如下:

udpServer.hpp 

#pragma once

#include <iostream>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
using namespace std;

// 错误类型枚举
enum
{
    SOCKET_ERR = 2
};

const static string defaultIp = "0.0.0.0";

class udpServer
{
public:
    udpServer(const uint16_t &port, const string ip = defaultIp)
        : _port(port), _ip(ip)
    {}

    // 初始化服务器
    void initServer()
    {
        // 1.创建套接字
        _socket = socket(AF_INET, SOCK_DGRAM, 0);
        if (_socket == -1)
        {
            cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
            exit(SOCKET_ERR);
        }
        // 2.绑定端口
    }

    // 运行服务器
    void start()
    {}

    ~udpServer()
    {}

private:
    uint16_t _port; // 端口号
    string _ip;     // ip地址
    int _socket;    // 文件描述符
};

 udpServer.cc

#include "udpServer.hpp"
#include <memory>

int main()
{
    std::unique_ptr<udpServer> usvr(new udpServer()); // TODO
    usvr->initServer();                               // 初始化服务器
    usvr->start();                                    // 运行服务器
    return 0;
}

下面进行绑定端口

3.1.2 绑定端口

 绑定端口的函数是bind函数

bind函数

 bind函数的作用是绑定端口号,TCP/UDP 均可使用进行该函数绑定端口,man 2 bind查看:

 bind a name to a socket:将名称绑定到套接字

函数:bind
 
头文件:
        #include <sys/types.h>
        #include <sys/socket.h>
 
函数原型:
         int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
 
参数:
    第一个参数sockfd:文件描述符
    第二个参数addr:网络相关的属性信息
    第三参数addrlen:传入的addr结构体的长度
 
返回值:
    绑定成功返回0,绑定失败返回-1,同时错误码会被设置

  下面介绍bind函数的参数

(1)bind函数的第一个参数是sockfd,用于绑定套接字创建成功返回的文件描述符

(2)bind函数的第二个参数是addr,用于填充网络相关的属性信息,比如IP地址、端口号等

该参数addr的类型是:struct sockaddr *,也就是如图的结构体:

我们要做的工作就是:定义一个 sockaddr_in 的结构体,也就是上图的第二个结构体,然后对该结构体进行内容填充,填完就把给结构体传给 第二个参数addr,需要强制类型转换

3.1.3 sockaddr_in结构体

我们看一下  sockaddr_in 结构体的定义:

可以看到,sockaddr_in 有以下几个成员类型:

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

剩下的字段不关注

其中 __SOCKADDR_COMMON 是一个宏

#define	__SOCKADDR_COMMON(sa_prefix) sa_family_t sa_prefix##family
  •  sa_prefix 代表外部 sin_ 传进来的参数
  • sa_prefix##family,##是进行拼接,意思是 sa_prefix 与 family 进行拼接,
  • sa_prefix就是 sin_,拼接之后就是 sin_family
  • sa_family_t 是16位整数

如图:

 其实就是这个 16位地址类型

sockaddr_in结构体的成员变量 sin_port 是端口号,类型是 in_port_t,16位的整数

sockaddr_in结构体的成员变量 sin_addr,sin_addr里面的内容是32位的整数。sin_addr 自己就是一个结构体,sin_addr 结构体类型是 in_addr

实际就是想说明 IP的类型直接就可以用 int 接收, 端口号需要用 uint16_t 接收

3.1.4 字符串IP和整数IP说明

  • 我们人一般使用的是字符串IP,也就是点分十进制的,比如:“123.2.33.200”,每一位的取值都是 0~255,这种的优点就是方便我们人观看使用,
  • 但是在网络传输中使用的是整数IP,用一个32位的整数来表示IP地址

为什么网络传输中使用的是整数IP??

  • 网络传输数据是寸土寸金的,在网络传输时直接以基于字符串的点分十进制IP的形式进行IP地址的传送,那么此时一个IP地址至少就需要15个字节,
  • 但实际并不需要耗费这么多字节,而整数IP地址只需要4个字节,即12字节。
  • 所以网络传输中使用的是整数IP

但是我们人看一串数字又不方便,比如:123002033200,所以我们人一般使用的是字符串IP

即存在需要把字符串IP转整数IP,整数IP转字符串IP

这些工作不用我们自己做,调用库函数即可

字符串IP和整数IP相互转换的方式

字符串IP转换成整数IP

 inet_addr函数

in_addr_t inet_addr(const char *cp);

只需传入待转换的字符串IP,该函数返回的就是转换后的整数IP

函数做了两件工作:

  1. 字符串IP转换成整数IP
  2. 把整数IP转换成网络字节序

整数IP转换成字符串IP

inet_ntoa函数

char *inet_ntoa(struct in_addr in);

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

这两个函数的头文件都是:

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

  

3.1.5 绑定好端口号的服务端代码

网络字节序与主机字节序之间的转换函数(上一节已经谈过)

#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);

 服务端代码暂时如下:

udpServer.hpp  

#pragma once

#include <iostream>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <string>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

// 错误类型枚举
enum
{
    UAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR
};

const static string defaultIp = "0.0.0.0";

class udpServer
{
public:
    udpServer(const uint16_t &port, const string &ip = defaultIp)
        : _port(port), _ip(ip)
    {}

    // 初始化服务器
    void initServer()
    {
        // 1.创建套接字
        _socket = socket(AF_INET, SOCK_DGRAM, 0);
        if (_socket == -1)
        {
            cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
            exit(SOCKET_ERR);
        }
        // 2.绑定端口
        // 2.1 填充 sockaddr_in 结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local));                   // 把 sockaddr_in结构体全部初始化为0
        local.sin_family = AF_INET;                     // 未来通信采用的是网络通信
        local.sin_port = htons(_port);                  // htons(_port)主机字节序转网络字节序
        local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)
        // 2.2 绑定
        int n = bind(_socket, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
        if (n == -1)
        {
            cerr << "bind error: " << errno << " : " << strerror(errno) << endl;
            exit(BIND_ERR);
        }
        //UDP server 预备工作完成
    }

    // 启动服务器
    void start()
    {
        // 服务器的本质就是一个死循环
        for(;;)
        {
            sleep(1);
        }
    }

    ~udpServer()
    {}

private:
    uint16_t _port; // 端口号
    string _ip;     // ip地址
    int _socket;    // 文件描述符
};

udpServer.cc

#include "udpServer.hpp"
#include <memory>

// 使用手册
// ./udpServer port ip
static void Uage(string proc)
{
    cout << "\nUage:\n\t" << proc << "local_ip local_port\n\n";
}

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Uage(argv[0]);
        exit(UAGE_ERR);
    }

    uint16_t port = atoi(argv[2]); // string to int
    string ip = argv[1];
    std::unique_ptr<udpServer> usvr(new udpServer(port, ip));
    usvr->initServer(); // 初始化服务器
    usvr->start();      // 启动服务器

    return 0;
}

 暂时可以进行编译了

运行

需要按照手册使用,IP随便填一个,端口号暂时用8080, 端口号有讲究的,后面再说

报错:无法分配请求的地址,说明 IP 也不是可以乱填的

下面进行介绍几个IP 

ifconfig显示和配置网络接口的信息

 注:ifconfig全称:interface configuration接口配置

  • 第一个IP:inet 10.0.4.14,这个IP是内网IP
  • 第二个IP: inet 127.0.0.1,这个IP是本地环回,用于本地测试

注:"inet" 是Internet Protocol(IP)的简写

先说第二个 IP,什么是本地环回?? 

我们写的代码在应用层,使用该IP进行通信贯穿不了物理层,通信只在物理层以上进行环回,只能进行本主机通信。通常用这个 IP用于同一台计算机上运行客户端和服务器程序进行通信测试

内网IP到 IP协议再解释

我们暂时先使用本地环回,进行简单测试

服务端已经可以跑起来了

使用命令进行查看该服务端的信息

 netstat 命令

netstat是一个用于显示网络连接、路由表和网络接口信息的命令行工具

netstat:network statistics网络统计

常用选项:

  • -a:all (显示所有连接和监听端口)
  • -t:tcp (仅显示TCP连接)
  • -u:udp (仅显示UDP连接)
  • -n:numeric (以数字形式显示IP地址和端口号)
  • -p:program (显示与连接关联的进程信息)
  • -r:route (显示路由表信息)
  • -s:statistics (显示网络统计信息)

netstat -nuap 进行查看

Foreign Address:(外部地址)是指与本地计算机建立网络连接的远程计算机的IP地址和端口号,也就是客户端连服务器

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

netstat -uap 进行查看 (不以数字显示)

如果我们想让别人可以连到我们的服务端,服务端需要给全网提供服务,IP就要使用公网IP(连云服务器的那个IP)

注:云服务器是虚拟化的服务器,不能直接绑你的公网IP,如果是虚拟机或独立的Linux则可以

需要去到云服务器的控制台打开相应的UDP端口

依旧是绑定失败,所以云服务器是不支持的绑定公网IP的,使用虚拟机或者独立Linux操作系统,那个IP地址就是支持你绑定的

实际上,一款网络服务器,不建议指明绑定一个IP,上面的服务端指定绑定一个IP是错误的用法

比如你运行服务端的机器上有几个网卡,意味着你的服务端上有多个IP, 一台服务器上端口号为8080的服务只有一个。这台服务器在接收数据时,这里的多张网卡在底层实际都收到了数据,如果这些数据也都想访问端口号为8080的服务。此时如果服务端在绑定的时候是指明绑定的某一个IP地址,那么此时服务端在接收数据的时候就只能从绑定IP对应的网卡接收数据 

如果你只绑定指明的一个,其他IP收到的数据包就被丢弃的,这不是我们想要的

实际上,服务器绑定的IP是:INADDR_ANY,这是一个宏,代表 0.0.0.0,叫做任意地址绑定。绑定了该IP,只要是发送给端口号为8080的服务的数据,系统都会可以将数据自底向上全部交给该服务端

修改代码

udpServer.hpp   

#pragma once

#include <iostream>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <string>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

// 错误类型枚举
enum
{
    UAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR
};

const static string defaultIp = "0.0.0.0";

class udpServer
{
public:
    udpServer(const uint16_t &port, const string &ip = defaultIp)
        : _port(port), _ip(ip)
    {}

    // 初始化服务器
    void initServer()
    {
        // 1.创建套接字
        _socket = socket(AF_INET, SOCK_DGRAM, 0);
        if (_socket == -1)
        {
            cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
            exit(SOCKET_ERR);
        }
        // 2.绑定端口
        // 2.1 填充 sockaddr_in 结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local));  // 把 sockaddr_in结构体全部初始化为0
        local.sin_family = AF_INET;    // 未来通信采用的是网络通信
        local.sin_port = htons(_port); // htons(_port)主机字节序转网络字节序
        // 绑定IP方法1:INADDR_ANY
        // local.sin_addr.s_addr = INADDR_ANY;//服务器的真实写法
        // 绑定IP方法2:把外部的构造函数传参去掉,使用我们自己定义的string defaultIp = "0.0.0.0";
        local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)
        // 2.2 绑定
        int n = bind(_socket, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
        if (n == -1)
        {
            cerr << "bind error: " << errno << " : " << strerror(errno) << endl;
            exit(BIND_ERR);
        }
        // UDP server 预备工作完成
    }

    // 启动服务器
    void start()
    {
        // 服务器的本质就是一个死循环
        for (;;)
        {
            sleep(1);
        }
    }

    ~udpServer()
    {}

private:
    uint16_t _port; // 端口号
    string _ip;     // ip地址
    int _socket;    // 文件描述符
};

udpServer.cc

#include "udpServer.hpp"
#include <memory>

// 使用手册
// ./udpServer port
static void Uage(string proc)
{
    cout << "\nUage:\n\t" << proc << " local_port\n\n";
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Uage(argv[0]);
        exit(UAGE_ERR);
    }

    uint16_t port = atoi(argv[1]); // string to int
    //不需要传IP了
    std::unique_ptr<udpServer> usvr(new udpServer(port));
    usvr->initServer(); // 初始化服务器
    usvr->start();      // 启动服务器
    
    return 0;
}

编译运行,端口号8080

netstat -nuap 进行查看

任意地址已经绑定成功,此时我们的服务器才能够被外网访问,意味着该UDP服务器可以在本地主机上,读取发送给端口8080的任何一张网卡里面的数据

3.1.6 服务端代码

接下来就是补充完整服务端的代码了。

服务端要接收客户端发送的消息,接收信息的函数是recvfrom

recvfrom函数

 recvfrom函数的作用是接收信息

receive a message from a socket:从套接字接收消息

函数:recvfrom
 
头文件:
        #include <sys/types.h>
        #include <sys/socket.h>
 
函数原型:
         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:下面解释
    第六个参数addrlen:src_addr结构体的长度
 
返回值:
    成功返回接收到的字节数,失败返回-1,同时错误码会被设置。对等方执行有序关闭后,返回值将为0

socklen_t  是一个32位的无符号整数

recvfrom函数的第五个参数src_addr,src_addr是一个结构体,类型是 struct sockaddr *

第五个参数src_addr 和第六个参数addrlen 是一个输入输出型参数。

第五个参数src_addr用于返回发送数据一方的信息,比如IP、端口号等。就好比别人发消息给你,你得知道对方是谁

我们要做的也是定义一个 sockaddr_in 的结构体,初始化该结构体,把结构体传给第五个参数src_addr,需要强制类型转换

服务端代码如下

udpServer.hpp    

#pragma once

#include <iostream>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <string>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

// 错误类型枚举
enum
{
    UAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR
};

const static string defaultIp = "0.0.0.0";
const static int gnum = 1024;

class udpServer
{
public:
    udpServer(const uint16_t &port, const string &ip = defaultIp)
        : _port(port), _ip(ip)
    {}

    // 初始化服务器
    void initServer()
    {
        // 1.创建套接字
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd == -1)
        {
            cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
            exit(SOCKET_ERR);
        }
        cout << "socket success: " << _sockfd << endl;
        // 2.绑定端口
        // 2.1 填充 sockaddr_in 结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local));  // 把 sockaddr_in结构体全部初始化为0
        local.sin_family = AF_INET;    // 未来通信采用的是网络通信
        local.sin_port = htons(_port); // htons(_port)主机字节序转网络字节序
        // 绑定IP方法1:INADDR_ANY
        // local.sin_addr.s_addr = INADDR_ANY;//服务器的真实写法
        // 绑定IP方法2:把外部的构造函数传参去掉,使用我们自己定义的string defaultIp = "0.0.0.0";
        local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)
        // 2.2 绑定
        int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
        if (n == -1)
        {
            cerr << "bind error: " << errno << " : " << strerror(errno) << endl;
            exit(BIND_ERR);
        }
        // UDP server 预备工作完成
    }

    // 启动服务器
    void start()
    {
        // 服务器的本质就是一个死循环
        char buffer[gnum];
        for (;;)
        {
            struct sockaddr_in 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;
                // 发消息对方的IP
                string clientip = inet_ntoa(peer.sin_addr); // 直接传sin_addr结构体,整数IP 转 字符串IP(点分十进制IP)
                // 发消息对方的端口号
                uint16_t clientport = ntohs(peer.sin_port); // ntohs:网络字节序转主机字节序
                // 发送的消息
                string message = buffer;

                // 打印
                cout << clientip << "[" << clientport << "]" << "# " << message << endl;
            }
        }
    }

    ~udpServer()
    {}

private:
    uint16_t _port; // 端口号
    string _ip;     // ip地址
    int _sockfd;    // 文件描述符
};

 udpServer.cc 没有发生改变

3.2  客户端创建

客户端的功能是可以发送消息给服务端,目前就先简单实现这样的功能

3.2.1 关于客户端的绑定问题 

客户端在初始化时只需要创建套接字就行了,而不需要进行显示绑定操作 

客户端代码如下

udpClient.hpp 

class udpClient
{
public:
    udpClient(const string &serverip, const uint16_t serverport)
        : _ip(serverip), _port(serverport), _socket(-1)
    {}

    // 初始化客户端
    void initClient()
    {
        // 1.创建套接字
        _socket = socket(AF_INET, SOCK_DGRAM, 0);
        if (_socket == -1)
        {
            cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
            exit(2);
        }
        // 2.绑定
        // 客户端必须要进行bind绑定,但是不需要我们自己bind,OS帮我们完成
    }

    // 启动客户端
    void run()
    {}
    
    ~udpClient()
    {}

private:
    uint16_t _port; // 端口号
    string _ip;     // ip地址
    int _socket;    // 文件描述符
};

udpClient.cc

#include "udpClient.hpp"
#include <memory>

// 使用手册
// ./udpClient ip port
static void Uage(string proc)
{
    cout << "\nUage:\n\t" << proc << " server_ip server_port\n\n";
}

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Uage(argv[0]);
        exit(1);
    }

    // 客户端需要服务端的 IP 和 port
    string serverip = argv[1];
    uint16_t serverport = atoi(argv[2]); // string to int
    std::unique_ptr<udpClient> ucli(new udpClient(serverip, serverport));
    ucli->initClient(); // 初始化服务器
    ucli->run();        // 启动服务器

    return 0;
}

 明确一点

  • 客户端是需要服务端的IP和端口号的,没有这些客户端就连不上服务端
  • 也就是说服务端的 IP 和端口号是不能轻易改变的,否则用户端不知道就会连不上服务端
  • 所以现在我们写的需要手动服务端的IP和端口号

关于客户端的绑定问题  

  • 由于是网络通信,通信双方都需要找到对方,因此服务端和客户端都需要有各自的IP地址和端口号
  • 只不过服务端需要进行端口号的绑定,而客户端不需要显示绑定端口号
  • 服务器就是为了给别人提供服务的,因此服务器必须要让别人知道自己的IP地址和端口号,并且端口号和IP不能轻易改变
  • 客户端在通信时虽然也需要端口号,但客户端一般是不进行显示绑定的,客户端访问服务端的时候,端口号只要是唯一就可以了
  • 如果客户端绑定了某个端口号,那么以后这个端口号就只能给这一个客户端使用,就是这个客户端没有启动,这个端口号也无法分配给别人,并且如果这个端口号被别人使用了,那么这个客户端就无法启动了。
  • 所以客户端的端口只要保证唯一性就行了,这个工作由OS完成,操作系统会自动给当前客户端生产一个唯一的端口号并且进行绑定
  • 也就是说,客户端每次启动时使用的端口号可能是变化的,此时只要客户端的端口号没有被耗尽,客户端就永远可以启动

3.2.2 客户端代码

接下来就是补充完整客户端的代码了。

客户端要发送消息给服务端,发送消息的函数是sendto

sendto函数

 sendto函数的作用是发送消息

send a message on a socket:在套接字上发送消息

函数:sendto
 
头文件:
        #include <sys/types.h>
        #include <sys/socket.h>
 
函数原型:
         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:下面解释
    第六个参数addrlen:dest_addr结构体的长度
 
返回值:
    成功返回写入的字节数,失败返回-1,同时错误码会被设置

socklen_t  是一个32位的无符号整数 

第五个参数dest_addr和第六个参数addrlen 是一个输入型参数

第五个参数dest_addr用于发送客户端的IP、端口号数据,发给服务端

我们要做的工作也是定义一个 sockaddr_in 的结构体,然后对该结构体进行内容填充,填完就把给结构体传给第五个参数dest_addr,需要强制类型转换 

客户端代码 

udpClient.hpp 

#pragma once

#include <iostream>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <string>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

class udpClient
{
public:
    udpClient(const string &serverip, const uint16_t serverport)
        : _serverip(serverip), _serverport(serverport), _sockfd(-1),  _quit(false)
    {}

    // 初始化客户端
    void initClient()
    {
        // 1.创建套接字
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd == -1)
        {
            cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
            exit(2);
        }
        cout << "socket success: " << _sockfd << endl;
        // 2.绑定
        // 客户端必须要进行bind绑定,但是不需要我们自己bind,OS帮我们完成
    }

    // 启动客户端
    void run()
    {
        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());// 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)

        string message;
        while ((!_quit))
        {
           cout << "Please Enter# ";
           cin >> message;
           sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
        }
        
    }

    ~udpClient()
    {}

private:
    uint16_t _serverport; // 端口号
    string _serverip;     // ip地址
    int _sockfd;    // 文件描述符
    bool _quit;
};

注:后面全部改了一下_socket 的命名(_sockfd) 

udpClient.cc 没有发生改变

然后进行整体编译,编译没有问题

3.3 服务端和客户端测试

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试,测试成功

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

关掉客户端,再次运行,发送消息给服务端,发现客户端的端口号已经发送改变,也就证明了操作系统会自动给当前客户端生产一个唯一的端口号并且进行绑定

网络测试 

可以将生成的客户端的可执行程序发送给你的其他朋友,进行网络测试,也就是跨主机通信

先使用 sz命令将该客户端可执行程序下载到本地

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

加可执行权限 chmod +x 文件名 

然后运行客户端,给服务端发送消息,客户端需要服务端的IP和端口号

简单的测试就成功了

注:云服务器的端口默认都是关闭的,需要手动打开,在控制台里面

--------------------- END ----------------------

「 作者 」 枫叶先生
「 更新 」 2023.6.18
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
          或有谬误或不准确之处,敬请读者批评指正。

猜你喜欢

转载自blog.csdn.net/m0_64280701/article/details/131235791