网络编程socket-Udp

目录

源IP地址和目的IP地址

[1. ​源 IP 地址 (Source IP Address)​​](#1. 源 IP 地址 (Source IP Address))

[2. ​目的 IP 地址 (Destination IP Address)​​](#2. 目的 IP 地址 (Destination IP Address))

源端口号和目的端口号

认识TCP协议和UDP协议

网络字节序

一、IP地址的表达与转换

[1. 字符串形式 → 二进制形式(网络字节序)](#1. 字符串形式 → 二进制形式(网络字节序))

[2. 二进制形式 → 字符串形式](#2. 二进制形式 → 字符串形式)

二、端口号的表达与转换

[1. 主机字节序 → 网络字节序](#1. 主机字节序 → 网络字节序)

[2. 网络字节序 → 主机字节序](#2. 网络字节序 → 主机字节序)

socket编程接口

基础常见API

UDP专属API

TCP专用API

sockaddr结构

UDP网络编程

V1版本-echoserver

UdpServer.hpp

服务端创建套接字

服务端绑定

运行服务器

UdpServer.cc

UdpClient.cc

本地测试


源IP地址和目的IP地址

在 Linux 网络通信中,理解源 IP 地址和目的 IP 地址是网络编程的基础。这两个地址是 IP 数据包的核心组成部分,决定了数据包的来源和去向。

1. ​源 IP 地址 (Source IP Address)​

定义 :发送数据包的设备的 IP 地址

  • 作用
    • 标识数据包的发送方
    • 接收方据此知道应该将回复发送到哪个地址
    • 用于网络路由决策(虽然主要基于目的 IP)
  • 特点
    • 在发送端由操作系统自动设置
    • 对于服务器,通常是服务器绑定的 IP 地址
    • 对于客户端,通常是分配给该网络接口的 IP
  • 示例场景
    • 当你的电脑(192.168.1.100)访问网站时,源 IP 就是 192.168.1.100
    • 当服务器回复时,这个地址就成为目的 IP

2. ​目的 IP 地址 (Destination IP Address)​

定义 :数据包要发送到的目标设备的 IP 地址

  • 作用
    • 标识数据包的最终接收方
    • 路由器根据此地址决定数据包转发路径
    • 目标设备根据此地址判断是否接收该数据包
  • 特点
    • 由发送应用程序明确指定
    • 必须是有效的、可路由的 IP 地址
    • 可以是单播、广播或多播地址
  • 示例场景
    • 当你访问 8.8.8.8 (Google DNS),目的 IP 就是 8.8.8.8
    • 当服务器回复时,这个地址成为源 IP

源端口号和目的端口号

首先我们需要明确的是,两台主机之间通信的目的不仅仅是为了将数据发送给对端主机,而是为了访问对端主机上的某个服务。比如我们在用百度搜索引擎进行搜索时,不仅仅是想将我们的请求发送给对端服务器,而是想访问对端服务器上部署的百度相关的搜索服务。

端口号

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

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

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

由于IP地址能够唯一标识公网内的一台主机,而端口号能够唯一标识一台主机上的一个进程,因此用IP地址+端口号就能够唯一标识网络上的某一台主机的某一个进程。

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

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

核心概念:Socket 通信是跨网络的进程间通信

  1. IP 地址 + MAC 地址 :确保数据包能从一台主机 发送到另一台主机
  2. 端口号 :标识主机上的特定服务进程 (目的端口)和发起通信的客户端进程 (源端口)。
  3. 本质 :Socket 通信的真正参与者是进程 (如浏览器进程 ↔ Web 服务进程)。
  4. Socket 定位 :它是进程间通信 (IPC) 的一种方式,区别于管道、共享内存等本地 IPC ,其特点是跨越网络 进行通信。
  5. 实例 :手机淘宝 App(进程) ↔ 淘宝服务器(进程);手机抖音 App(进程) ↔ 抖音服务器(进程)。

认识TCP协议和UDP协议

TCP 和 UDP 是网络传输层的两大核心协议,负责在网络中的进程之间 传输数据。它们的主要区别在于通信的可靠性、连接机制和传输效率

TCP协议(传输控制协议)​

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

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

UDP(用户数据报协议)​

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

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

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

UDP的存在价值其实体现在五个维度:速度、灵活性、轻量、控制权和特殊场景。比如实时游戏里,丢包可以容忍但延迟不能高;广播场景根本不需要确认;内核协议栈用UDP避免自举问题。

用生活场景比喻可能更直观。比如把TCP比作挂号信(必须签收),UDP就像普通明信片(便宜快速,丢了也不心疼)。重点强调"不可靠≠没用",而是另一种设计哲学。

而且我们要意识到,TCP的可靠性是有代价的------三次握手延迟、头部开销、拥塞控制带来的波动。这些代价在某些场景是无法接受的。就像赛车虽然不安全,但不会因此否定赛车的存在意义。

网络字节序

网络中的大小端问题

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

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

记不住没关系,有句口诀叫"小小小"

及小端模式是小位保存在小的地址处,高位低位举个例子12345,5是最低位,1是最高位。

网络字节序采用的是大端模式

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

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

cpp 复制代码
#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位长整数从主机字节序转换为网络字节序。

通常我们需要把IP地址和端口号地址进行转化(如IP地址:"192.168.1.1",端口号:"8080")

一、IP地址的表达与转换

1. 字符串形式 → 二进制形式(网络字节序)

转换函数​:

  • inet_addr()(传统方法)
cpp 复制代码
#include <arpa/inet.h>

// 方法1: inet_addr (返回 uint32_t)
in_addr_t ip_bin = inet_addr("192.168.1.1");

2. 二进制形式 → 字符串形式

转换函数​:

  • inet_ntoa()(传统方法)
cpp 复制代码
// 方法1: inet_ntoa
struct in_addr addr = { .s_addr = 0xC0A80101 };
char *ip_str = inet_ntoa(addr); // "192.168.1.1"

二、端口号的表达与转换

1. 主机字节序 → 网络字节序

转换函数​:

  • htons()(16位端口号)
  • htonl()(32位值,但端口通常用16位)
cpp 复制代码
uint16_t port_host = 8080;       // 主机字节序
uint16_t port_net = htons(8080); // 网络字节序(大端)

2. 网络字节序 → 主机字节序

转换函数​:

  • ntohs()(16位端口号)
  • ntohl()(32位值)
cpp 复制代码
uint16_t port_net = 0x901F;      // 8080的网络字节序
uint16_t port_host = ntohs(port_net); // 8080

socket编程接口

基础常见API

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

cpp 复制代码
int socket(int domain, int type, int protocol);
  • 功能 :创建一个套接字,返回文件描述符。
  • 参数
    • domain:协议族,如AF_INET(IPv4)、AF_INET6(IPv6)。
    • type:套接字类型,如SOCK_STREAM(TCP)、SOCK_DGRAM(UDP)。
    • protocol:通常设为0,由系统自动选择默认协议。
  • 返回值 :成功返回套接字描述符(非负整数),失败返回-1。

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

cpp 复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 功能 :将套接字绑定到特定的IP地址和端口号。
  • 参数
    • sockfd:socket()返回的套接字描述符。
    • addr:指向sockaddr结构体的指针(实际使用sockaddr_insockaddr_in6)。
    • addrlen:地址结构体的长度。
  • 返回值 :成功返回0,失败返回-1。

close() - 关闭套接字

cpp 复制代码
int close(int fd);
  • 功能​:关闭套接字描述符,释放资源。

  • 参数 :套接字描述符。

  • 返回值 :成功返回0,失败返回-1。

UDP专属API

recvfrom() - 接收数据(UDP)​

cpp 复制代码
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
  • 功能 :接收UDP数据报并获取发送方地址。
  • 参数
    • src_addr:用于存储发送方地址的结构体指针。
    • addrlen:地址结构体的长度(传入传出参数)。
  • 返回值 :成功返回接收的字节数,失败返回-1。

sendto() - 发送数据(UDP)​

cpp 复制代码
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);
  • 功能 :向指定地址发送UDP数据报。
  • 参数
    • dest_addr:目标地址结构体指针。
    • addrlen:目标地址长度。
  • 返回值 :成功返回发送的字节数,失败返回-1。

TCP专用API

listen() - 监听连接(TCP)​

cpp 复制代码
int listen(int sockfd, int backlog);
  • 功能 :将TCP套接字置于监听状态,等待客户端连接。
  • 参数
    • sockfd:已绑定的套接字描述符。
    • backlog:等待连接队列的最大长度(通常设为5以上)。
  • 返回值 :成功返回0,失败返回-1。

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

cpp 复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • 功能 :从监听队列中接受一个客户端连接。
  • 参数
    • sockfd:处于监听状态的套接字。
    • addr:用于存储客户端地址信息的结构体指针。
    • addrlen:地址结构体的长度(传入传出参数)。
  • 返回值 :成功返回新的套接字描述符(用于与客户端通信),失败返回-1。

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

cpp 复制代码
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 功能
    • TCP :向服务器发起连接请求。
    • UDP :指定默认的目标地址(后续可使用send()代替sendto())。
  • 参数 :同bind()。
  • 返回值 :成功返回0,失败返回-1。

sockaddr结构

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

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

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

注意: 实际我们在进行网络通信时,定义的还是sockaddr_in这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为sockaddr*罢了。

sockaddr 结构

cpp 复制代码
//1. sockaddr结构体定义
#include <sys/socket.h>

struct sockaddr {
    sa_family_t sa_family;   // 地址族(Address family)
    char        sa_data[14]; // 地址数据(可变长度,实际不足14字节时填充)
};

sockaddr_in 结构

cpp 复制代码
#include <netinet/in.h>

struct sockaddr_in {
    sa_family_t    sin_family; // 地址族(必须为 AF_INET)
    in_port_t      sin_port;   // 16位端口号(网络字节序)
    struct in_addr sin_addr;   // 32位 IPv4 地址
    unsigned char  sin_zero[8]; // 填充字节(全置0)
};

struct in_addr {
    in_addr_t s_addr;          // 32位 IPv4 地址(网络字节序)
};

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

UDP网络编程

V1版本-echoserver

简单的回显服务器和客户端代码

UdpServer.hpp

服务端创建套接字

我们把服务器封装成一个类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器需要做的第一件事就是创建套接字也就是调用socket函数

cpp 复制代码
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表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"    //日志
#include <functional>

using namespace LogModule;
using func_t = std::function<std::string(const std::string &)>;

const int defaultfd = -1;

class UdpServer
{
public:
    UdpServer(uint16_t port, func_t func)
        : _sockfd(defaultfd),
        //   _ip(ip),
          _port(port),
          _isrunning(false),
          _func(func)
    {
    }

    void Init()
    {
        // 1.创建套接字
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error!";
            exit(1);
        }
        LOG(LogLevel::INFO) << "socket success, sockfd : " << _sockfd;

    }

    void Start()
    {
      
    }
    ~UdpServer()
    {
        if (_sockfd >= 0)
        {
			close(_sockfd);
		}
    }

private:
    int _sockfd;          //文件描述符
    uint16_t _port;       //端口号
    // std::string _ip;   //ip
    func_t _func;         //回调函数
    bool _isrunning;
};

服务端绑定

现在套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。

打个比方:bind()就是给套接字这个"手机"办张手机卡,有了号码才能加入通信网络,否则它只是个与世隔绝的"砖头"。

cpp 复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数说明:

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

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

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

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

cpp 复制代码
class UdpServer
{
public:
    UdpServer(uint16_t port, func_t func)
        : _sockfd(defaultfd),
        //   _ip(ip),
          _port(port),
          _isrunning(false),
          _func(func)
    {
    }

    void Init()
    {
        // 1.创建套接字
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error!";
            exit(1);
        }
        LOG(LogLevel::INFO) << "socket success, sockfd : " << _sockfd;

        // 2. 绑定socket信息,ip和端口, ip(比较特殊,后续解释)
        // 2.1 填充sockaddr_in结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        // IP信息和端口信息,一定要发送到网络!
        // 本地格式->网络序列
        local.sin_port = htons(_port);
        // IP也是如此,1. IP转成4字节 2. 4字节转成网络序列
        local.sin_addr.s_addr = INADDR_ANY;

        // 那么为什么服务器端要显式的bind呢?IP和端口必须是众所周知且不能轻易改变的!
        int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(2);
        }
        LOG(LogLevel::INFO) << "bind success, sockfd : " << _sockfd;
    }

    void Start()
    {}
    ~UdpServer()
    {
        if (_sockfd >= 0)
        {
            close(_sockfd);
        }
    }

private:
    int _sockfd;          //文件描述符
    uint16_t _port;       //端口号
    // std::string _ip;   //ip
    func_t _func;         //回调函数
    bool _isrunning;
};

这里IP设置的代码 local.sin_addr.s_addr = INADDR_ANY;

因为INADDR_ANY是服务器程序的标准配置方式。很多初学者会犯的错误就是硬编码某个IP,导致服务器只能在一个网卡上工作。

从技术角度说,INADDR_ANY的本质是告诉内核"我不挑食",所有网卡的数据我都要。这就像餐厅服务员说"所有桌的客人我都服务",而不是只服务特定桌的客人。在多网卡服务器上,如果不这么设置,就可能漏掉其他网卡过来的请求。

其实再想想,INADDR_ANY其实是个效率优化。想象一下,如果每个网卡都要单独绑定,那得开多少个socket啊!特别是云服务器动不动就十几个虚拟网卡,用INADDR_ANY就省事多了。

运行服务器

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

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

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

cpp 复制代码
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结构体的长度,这是一个输入输出型参数。

注意:

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

UDP服务端发送数据的函数叫做sendto,该函数的函数原型如下:

cpp 复制代码
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,同时错误码会被设置。
cpp 复制代码
 void Start()
    {
        _isrunning = true;
        while (_isrunning)
        {
            char buffer[1024];
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);

            ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (s > 0)
            {
                int peer_port = ntohs(peer.sin_port);  //获取的客户端端口是网络序列->主机序列
                std::string peer_ip = inet_ntoa(peer.sin_addr); // 4字节网络风格的IP -> 点分十进制的字符串风格的IP

                buffer[s] = 0;

                std::string result = _func(buffer);//执行回调函数
                LOG(LogLevel::DEBUG) << "[" << peer_ip << ":" << peer_port<< "]# " << buffer;

                sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr *)&peer, len);//发回客户端
            }
        }
    }

现在服务端的底层已经写完了,而且上层调用非常简单

这里引入命令行参数

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

由于云服务器的原因,后面实际不需要传入IP地址,因此在运行服务器的时候我们只需要传入端口号即可,目前我们就手动将IP地址设置为127.0.0.1。IP地址为127.0.0.1实际上等价于localhost表示本地主机,我们将它称之为本地环回,相当于我们一会先在本地测试一下能否正常通信,然后再进行网络通信的测试。

UdpServer.cc

cpp 复制代码
#include <iostream>
#include <memory>
#include "UdpServer.hpp"

// 仅仅是用来进行测试的
std::string defaulthandler(const std::string &message)
{
    std::string hello = "hello, ";
    hello += message;
    return hello;
}

//./udpserver port
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        return 1;
    }//提示格式为 ./udpserver 端口号
    uint16_t port = std::stoi(argv[1]);

    Enable_Console_Log_Strategy();

    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, defaulthandler);
    usvr->Init();
    usvr->Start();
    return 0;
}

UdpClient.cc

客户端在初始化时也需要创建套接字,之后客户端发送数据或接收数据也就是对这个套接字进行操作。

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

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>

// ./udpclient server_ip server_port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
        return 1;
    }
    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    // 1. 创建socket
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cerr << "socket error" << std::endl;
        return 2;
    }
    
    // 2. 本地的ip和端口是什么?要不要和上面的"文件"关联呢?
    // 问题:client要不要bind?需要bind.
    //       client要不要显式的bind?不要!!首次发送消息,OS会自动给client进行bind,OS知道IP,端口号采用随机端口号的方式
    //   为什么?一个端口号,只能被一个进程bind,为了避免client端口冲突
    //   client端的端口号是几,不重要,只要是唯一的就行!
    // 填写服务器信息
    struct sockaddr_in server;
    memset(&server,0,sizeof(server));
    server.sin_port=htons(server_port);
    server.sin_family = AF_INET;
    server.sin_addr.s_addr=inet_addr(server_ip.c_str());
    while(true)
    {
        std::string input;
        std::cout << "Please Enter# ";
        std::getline(std::cin, input);

        int n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr*)&server, sizeof(server));
        (void)n;

        char buffer[1024];
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
        if(m > 0)
        {
            buffer[m] = 0;
            std::cout << buffer << std::endl;
        }

    }
    return 0;
}

服务器回传消息时还要再创建一个peer(通信对端),你可能会问我们不是只有一个服务器吗?直接用该服务器不行吗?这里是因为我们现在只有一个服务器,不代表客户端只能访问一个,将来可能一个客户端对应多个服务器,我们随机挑一个服务器发送消息时,该服务器内部可能有多个IP和端口它可能从另一个IP和端口来回传消息,所以peer是必须要定义的

本地测试

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

此时我们再用netstat命令查看网络信息,可以看到服务端的端口是8081,客户端的端口是753624。这里客户端能被netstat命令查看到,和我们打印的客户端是一样的也表明动态绑定成功了,这就是我们所谓的网络通信。