[Linux网络——Lesson2.socket套接字 && 简易UDP网络程序]

目录

前言

一、😀预备知识

[1-1 🍕IP地址](#1-1 🍕IP地址)

[1-2 🍔端口号概念](#1-2 🍔端口号概念)

[1-3 🍟端口号与进程PID](#1-3 🍟端口号与进程PID)

[1-4 🌭传输层协议](#1-4 🌭传输层协议)

[1-5 🧂网络字节序](#1-5 🧂网络字节序)

[二、😁socket 套接字](#二、😁socket 套接字)

[2-1 🥓socket 常见API](#2-1 🥓socket 常见API)

[2-2 🥚sockaddr 结构体](#2-2 🥚sockaddr 结构体)

[三、😂UDP 网络程序](#三、😂UDP 网络程序)

[3-1 🍳字符串回响](#3-1 🍳字符串回响)

[3-1-1 🐕核心功能](#3-1-1 🐕核心功能)

[3-1-2 🦌程序结构](#3-1-2 🦌程序结构)

🌟服务器设计🌟

[3-1-3 🐈‍⬛创建套接字](#3-1-3 🐈‍⬛创建套接字)

[3-1-4 🐎绑定IP地址和端口号](#3-1-4 🐎绑定IP地址和端口号)

[3-1-5 🦍启动服务器](#3-1-5 🦍启动服务器)

🌟客户端设计🌟

[3-1-6 🫏指定IP地址和端口号](#3-1-6 🫏指定IP地址和端口号)

[3-1-7 🐆初始化客户端](#3-1-7 🐆初始化客户端)

[3-1-8 🦬启动客户端](#3-1-8 🦬启动客户端)

[3-2 🧇大写转小写](#3-2 🧇大写转小写)

[3-2-1 🦊业务处理函数解耦](#3-2-1 🦊业务处理函数解耦)

[3-2-2 🐵大写转小写](#3-2-2 🐵大写转小写)

[3-3 🥞多人聊天室](#3-3 🥞多人聊天室)

[3-3-1 🦝核心功能](#3-3-1 🦝核心功能)

[3-3-2 🐨程序结构](#3-3-2 🐨程序结构)

🌟服务器🌟

[3-3-3 🐸引入环形队列](#3-3-3 🐸引入环形队列)

[3-3-4 🐔引入用户信息](#3-3-4 🐔引入用户信息)

[3-3-5 🐓引入多线程](#3-3-5 🐓引入多线程)

🌟客户端🌟

[3-3-6 🐦多线程化](#3-3-6 🐦多线程化)

总结与提炼


前言

在互联网技术飞速发展的今天,网络通信已成为各类应用系统的核心基石,而UDP作为一种轻量、高效的传输层协议,在实时通信、数据广播等场景中占据着不可替代的地位。网络通信是应用系统的核心,UDP作为轻量高效的传输层协议,在实时通信等场景中应用广泛,掌握其开发方法是网络开发学习者的必备技能。

本指南由浅入深,先通过预备知识夯实IP地址、端口号等基础概念;再详解socket套接字API及结构体使用;最后以字符串回响、大写转小写、多人聊天室三个案例,完整呈现UDP开发流程。

无论是网络开发的入门学习者,还是希望巩固UDP开发技能的技术人员,都能通过本指南清晰掌握核心知识点与实战技巧,逐步提升网络程序开发能力。


一、😀预备知识

1-1 🍕IP地址

在 《[Linux网络------Lesson1.初识计算机网络]》一文中我们提到过: IP 是全球网络的基础 ,使用 IP 地址来标识公网环境下主机的唯一性,我们可以根据 目的IP地址 进行跨路由器的远端通信(将信息从主机 A 发送至主机 Z )

仅仅使用 IP 只能定位到目标主机,并且目标主机不是最终目的地,要想定位目的地,需要依靠 端口号

目标主机中存在很多进程,网络通信实际是不同主机中的进程在进行通信,并非主机与主机直接通信

1-2 🍔端口号概念

我们上网,无非就两种行为,1、把远端的数据拉回到本地 。2、把本地数据推送到远端。而大部分的网络行为都是用户触发的。而在计算机当中,进程代表着用户。进程可能是客户端服务,或者其他服务。

而我们通过网络将数据发给目标主机,实际上最终要落实到某个APP上,也就是某个进程上,比如,我们在刷抖音时,从网络中传输的视频数据不会出现在学习通的APP上,所以:

3、把数据发送到目标主机上,不是目的,而是手段真正目的是为了交给主机上的某一个服务(进程)

当数据解包到最后一层时,需要将数据继续向上分用,只不过这次分用是把数据发送到对应的服务当中。而这个时候,我们就需要知道自己的数据是发送给哪一个服务的。

  • 所以服务必须要有自己的唯一标识符,我们称为 端口号

  • 端口号是一个 2 字节 16 位的整数

  • 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理

  • IP地址用来表示网络中的唯一主机,IP 地址 + 端口号 能够 标识网络上的某一台主机的某一个进程

  • 一个端口号只能被一个进程占用

抛开网络其他知识,将信息从主机 A 中的进程 A 发送至主机 B 中的 进程 B,这不就是 进程间通信 吗?之前学习的 进程间通信 是通过 匿名管道、命名管道、共享内存 等方式实现,而如今的 进程间通信 则是通过 网络传输 的方式实现

所以IP地址+端口号 表示当前服务是在互联网当中的唯一的进程。那么在互联网当中,我们就能找到唯一的彼此,我们只需要这四样信息就可以找到对端主机**{src ip, src port dst ip, dst port},** 通过之前的学习,我们其实就已经了解网络通信的本质:网络通信的本质就是进程间通信

需要进行网络通信的进程有很多,为了方便进行管理,就诞生了 端口号 这个概念,同进程的 PID一样,端口号 也可以用于标识进程

传输层上层就是应用层了,所以在这一层会对数据帧进行解包和分用,而分用到哪个服务就不得而知了,所以在传输层,是一定需要知道每个服务的端口号的。

端口号也是有划分的,它们具体的划分如下:

  • 0 - 1023 : 知名端口号, HTTP, FTP, SSH 等这些广为使用的应用层协议, 他们的端口号都是固定的
  • 1024 - 65535 : 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的.

服务器中的防火墙其实就是端口号限制,只有开放的端口号,才允许进程用于 网络通信

1-3 🍟端口号与进程PID

端口号 用于标识进程,进程 PID 也是用于标识进程,为什么在网络中,不直接使用进程 PID 呢?

  • 进程 PID 隶属于操作系统中的进程管理 ,如果在网络中使用 PID,会导致网络标准中被迫中引入进程管理相关概念(进程管理与网络强耦合

  • 进程管理 属于 OS 内部中的功能,OS 可以有很多标准,但网络标准只能有一套,在网络中直接使用 PID 无法确保网络标准的统一性

  • 并不是所有的进程都需要进行网络通信,如果端口号、PID 都使用同一个解决方案,无疑会影响网络管理的效率

所以综上所述,网络中的 端口号 需要通过一种全新的方式实现,也就是一个 2 字节的整数 port,进程 A 运行后,可以给它绑定 端口号 N ,在进行网络通信时,根据 端口号 N 来确定信息是交给进程 A 的

所以将之前的结论再具体一点:IP + Port 可以标识公网环境下,唯一的网络进程

网络传输中的必备信息组 [目的IP 源 IP || 目的 Port 源 Port]

  • 目的 IP:需要把信息发送到哪一台主机
  • 源 IP:信息从哪台主机中发出
  • 目的 Port:将信息交给哪一个进程
  • 源 Port:信息从哪一个进程中发出

注意: 端口号与进程 PID 并不是同一个概念

进程 PID 就好比你的身份证号,端口号 相当于学号,这两个信息都可以标识唯一的你,但对于学校来说,使用学号更方便进行管理


一个进程可以绑定多个 端口号 吗?一个 端口号 可以被多个进程绑定吗?

端口号 的作用是配合 IP 地址标识网络世界中进程的唯一性,如果一个进程绑定多个 端口号 ,依然可以保证唯一性(因为无论使用哪个 端口号 ,信息始终只会交给一个进程);但如果一个 端口号 被多个进程绑定了,在信息递达时,是无法分辨该信息的最终目的进程的,存在二义性

所以一个进程可以绑定多个端口号,一个 端口号 不允许被多个进程绑定,如果被绑定了,可以通过 端口号 顺藤摸瓜,找到占用该 端口号 的进程

如果某个端口号被使用了,其他进程再继续绑定是会报错的,提示 该端口已被占用


主机(操作系统)是如何根据 端口号 定位具体进程的?

这个实现起来比较简单,创建一张哈希表,维护 <端口号, 进程 PID> 之间的映射关系,当信息通过网络传输到目标主机时,操作系统可以根据其中的 [目的 Port] ,直接定位到具体的进程 PID,然后进行通信

1-4 🌭传输层协议

主流的传输层协议有两个:TCP 和 UDP

两个协议各有优缺点,可以采用不同的协议,实现截然不同的网络程序,关于 TCP 和 UDP 的详细信息将会放到后面的博客中详谈,先来看看简单这两种协议的特点

TCP 协议:传输控制协议

  • 传输层协议
  • 有连接
  • 可靠传输
  • 面向字节流

字节流就像水龙头,用户可以根据自己的需求获取水流量

UDP 协议:用户数据协议

  • 传输层协议
  • 无连接
  • 不可靠传输
  • 面向数据报

数据报则是相当于包裹,用户每次获取的都是一个或多个完整的包裹

关于 可靠性

  • TCP 的可靠传输并不意味着它可以将数据百分百递达,而是说它在数据传输过程中,如果发生了传输失败的情况,它会通过自己独特的机制,重新发送数据,确保对端百分百能收到数据;
  • 至于 UDP 就不一样,数据发出后,如果失败了,也不会进行重传,好在 UDP 面向数据报,并且没有很多复杂的机制,所以传输速度很快

总结起来就是:TCP 用于对数据传输要求较高的领域,比如金融交易、网页请求、文件传输等,至于 UDP 可以用于短视频、直播、即时通讯等对传输速度要求较高的领域

  • 如果不知道该使用哪种协议,优先考虑 TCP,如果对传输速度又要求,可以选择 UDP

1-5 🧂网络字节序

在学习网络字节序相关知识前,先回顾一下大小端字节序

预备知识

  • 数据拥有高权值位和低权值位,比如在 32 位操作系统中,十六进制数 0x11223344,其中的 11 称为 最高权值位 ,44 称为 最低权值位
  • 内存有高地址和低地址之分

如果将数据的高权值存放在内存的低地址处,低权值存放在高地址处,此时就称为 大端字节序 ,反之则称为 小端字节序 ,这两种字节序没有好坏之分,只是系统设计者的使用习惯问题,比如我当前的电脑在存储数据时,采用的就是 小端字节序 方案

通过内存单元可以看到,使用 小端字节序 时数据是倒着放的,大端字节序 就是正着存放了

我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?

  • 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出
  • 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存因此,网络数据流的地址应这样规定: 先发出的数据是低地址,后发出的数据是高地址
  • TCP/IP 协议规定, 网络数据流应采用大端字节序,即低地址高字节
  • 不管这台主机是大端机还是小端机, 都会按照这个 TCP/IP 规定的网络字节序来发送/接收数据
  • 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可

大小端字节序就有点像吃香蕉时的方式,有的人是从头部开始剥皮,有的人是从尾部开始剥皮,两种方式都能吃到香蕉,纯属习惯问题

在网络出现之前,使用大端或小端存储都没有问题,网络出现之后,就需要考虑使用同一种存储方案了,因为网络通信时,两台主机存储方案可能不同,会出现无法解读对方数据的问题

如果你是网络标准的设计者,你会如何解决?
解决方案1数据发送前,给报文中添加大小端的标记字段,待数据递达后,对端在根据标志位进行解读,再进行转换。 这个方案实现起来不太方便,并且给每一个报文都添加标记字段这个行为比较浪费
解决方案2书同文,车同轨,直接统一标准。 这种解决方案就很彻底了,直接从根源上解决问题,也更方便

顶层设计者采用了解决方案2,TCP/IP 协议规定:网络中传输的数据,统一采用大端存储方案,也就是网络字节序, 现在大端/小端称为 主机字节序

发送数据时,将 主机字节序 转化为 网络字节序 ,接收到数据后,再转回 主机字节序 就好了,完美解决不同机器中的大小端差异,可以用下面这批库函数进行转换,在发送/接收时,调用库函数进行转换即可

  • 这些函数名很好记,h 表示 host, n 表示 network, l 表示 32 位长整数, s 表示 16 位短整数。例如 htonl 表示将 32 位的长整数从主机字节序转换为网络字节序,例如将 IP 地址转换后准备发送。
  • 如果主机是小端字节序, 这些函数将参数做相应的大小端转换然后返回
  • 如果主机是大端字节序, 这些函数不做转换,将参数原封不动地返回

二、😁socket 套接字

2-1 🥓socket 常见API

socket套接字提供了下面这一批常用接口,用于实现网络通信

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 创建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);

可以看到在上面这一批 API 中,频繁出现了一个结构体类型 sockaddr,该结构体支持网络通信,也支持本地通信

socket 套接字就是用于描述 sockaddr 结构体的字段,复用了文件描述符的解决方案

2-2 🥚sockaddr 结构体

socket 这套网络通信标准隶属于 POSIX 通信标准,该标准的设计初衷就是为了实现 可移植性 ,程序可以直接在使用该标准的不同机器中运行,但有的机器使用的是网络通信,有的则是使用本地通信,socket 套接字为了能同时兼顾这两种通信方式,提供了 sockaddr 结构体

由 sockaddr 结构体衍生出了两个不同的结构体:sockaddr_in 网络套接字、sockaddr_un 域间套接字,前者用于网络通信,后者用于本地通信

  • 可以根据 16 位地址类型,判断是网络通信,还是本地通信
  • 在进行网络通信时,需要提供 IP 地址、端口号 等网络通信必备项,本地通信只需要提供一个路径名,通过文件读写的方式进行通信(类似于命名管道)
  • 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 结构体指针做为参数

socket 提供的接口参数为 sockaddr*,我们既可以传入 &sockaddr_in 进行网络通信,也可以传入 &sockaddr_un 进行本地通信,传参时将参数进行强制类型转换即可,这是使用 C语言 实现 多态 的典型做法,确保该标准的通用性

为什么不将参数设置为 void****?***
因为在该标准设计时,C语言还不支持 void 这种类型,为了确保向前兼容性,即便后续支持后也不能进行修改了*

关于 socketaddr_in 结构的更多详细信息放到后面写代码时再细谈

三、😂UDP 网络程序

接下来实现一批基于 UDP 协议的网络程序

3-1 🍳字符串回响

3-1-1 🐕核心功能

分别实现客户端与服务器,客户端向服务器发送消息,服务器收到消息后,回响给客户端,有点类似于 echo 指令

该程序的核心在于 使用 socket 套接字接口,以 UDP 协议的方式实现简单网络通信

3-1-2 🦌程序结构

程序由 server.hpp、server.cc、client.hpp、client.cc 组成,大体框架如下

  • 创建 server.hpp 服务器头文件
cpp 复制代码
#pragma once

#include <iostream>

namespace nt_server
{
    class UdpServer
    {
    public:
        // 构造
        UdpServer()
        {} 
        // 析构
        ~UdpServer()
        {} 

        // 初始化服务器
        void InitServer()
        {}

        // 启动服务器
        void StartServer()
        {}

    private:
        // 字段
    };
}
cpp 复制代码
#include <memory> // 智能指针相关头文件
#include "server.hpp"

using namespace std;
using namespace nt_server;

int main()
{
    unique_ptr<UdpServer> usvr(new UdpServer());

    // 初始化服务器
    usvr->InitServer();

    // 启动服务器
    usvr->StartServer();

    return 0;
}
  • 创建 client.hpp 客户端头文件
cpp 复制代码
#pragma once

#include <iostream>

namespace nt_client
{
    class UdpClient
    {
    public:
        // 构造
        UdpClient() 
        {} 
        // 析构
        ~UdpClient() 
        {} 

        // 初始化客户端
        void InitClient() 
        {}

        // 启动客户端
        void StartClient() 
        {}

    private:
        // 字段
    };
}
cpp 复制代码
#include <memory>
#include "client.hpp"

using namespace std;
using namespace nt_client;

int main()
{
  unique_ptr<UdpClient> usvr(new UdpClient());

  // 初始化客户端
  usvr->InitClient();
  
  // 启动客户端
  usvr->StartClient();

  return 0;
}

为了方便后续测试,再添加一个 Makefile 文件

  • 创建 Makefile 文件
bash 复制代码
.PHONY:all
all:server client

server:server.cc
	g++ -o $@ $^ -std=c++11

	
client:client.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -rf server client

准备工作完成后,接下来着手填充代码内容

🌟服务器设计🌟

3-1-3 🐈‍⬛创建套接字

创建套接字使用 socket 函数

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 创建套接字(TCP/UDP	服务器/客户端)
int socket(int domain, int type, int protocol);

函数参数及返回值

  • domain参数表示地址族,指定使用的网络通信或其他通信协议类型。常使用 AF_INET 选项,表示网络通信

domain可选项

  • type参数套接字类型,指定套接字的通信方式

type可选项

最常用的两个选项:

SOCK_STREAM面向连接的流套接字(使用TCP协议)
SOCK_DGRAM无连接的数据报套接字(使用UDP协议)

  • protocol参数指定协议,通常为0表示系统根据type和domain选择合适的协议
  • 返回值文件描述符

因为这里是使用 UDP 协议实现的 网络通信 ,参数2 domain 选择 AF_INET(基于 IPv4 标准),参数2 type 选择 SOCK_DGRAM(数据报传输),参数3设置为 0,可以根据 SOCK_DGRAM 自动推导出使用 UDP 协议

  • AF_INET6 基于 IPv6 标准

接下来在 server.hpp 的 InitServer() 函数中创建套接字,并对创建成功/失败后的结果做打印

  • server.hpp 服务器头文件
cpp 复制代码
#pragma once

#include <iostream>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>

namespace nt_server
{
    // 错误码
    enum
    {
        SOCKET_ERR = 1
    };

    class UdpServer
    {
    public:
        // 构造
        UdpServer()
        {} 
        // 析构
        ~UdpServer()
        {} 

        // 初始化服务器
        void InitServer()
        {
            // 1.创建套接字
            sock_ = socket(AF_INET, SOCK_DGRAM, 0);

            if(sock_ == -1)
            {
                std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            // 创建成功
            std::cout << "Create Success Socket: " << sock_ << std::endl;
        }

        // 启动服务器
        void StartServer()
        {}

    private:
        int sock_; // 套接字
    };
}

文件描述符默认 0、1、2 都已经被占用了,如果再创建文件描述符,会从 3 开始,可以看到,程序运行后,创建的套接字正是 3,证明套接字本质上就是文件描述符,不过它用于描述网络资源

3-1-4 🐎绑定IP地址和端口号

当我们创建完套接字,在OS层面就相当于打开了一个文件,现在需要把文件信息发送到别的主机,而 socket = IP + Port , 所以我们需要将创建的套接字与网络信息关联起来,称为网络中的唯一标识,Linux给我们提供了一个接口 bind :

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 绑定IP地址和端口号(TCP/UDP	服务器)
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
  • sockfd参数文件描述符
  • addr参数指向 sockaddr 结构的指针,表示要绑定的地址信息。它可以是多种地址结构的指针,通常使用 sockaddr_in作为具体的地址结构体。具体结构体中会包含IP地址和端口号信息
  • addrlen参数指定 addr 指向的地址结构体的大小(字节数)。通常使用 sizeof(struct sockaddr_in)来获取具体的大小。这个参数是为了让 bind() 函数能够验证传入的地址结构的长度
  • 返回值0表示绑定成功,-1表示失败,并设置错误码

参数1没啥好说的,重点在于参数2,因为我们这里是 网络通信 ,所以使用的是 sockaddr_in 结构体,要想使用该结构体,还得包含下面这两个头文件

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

sockaddr_in 结构体的构成如下

cpp 复制代码
/* Structure describing an Internet socket address.  */
struct sockaddr_in
{
  __SOCKADDR_COMMON (sin_);
  in_port_t sin_port;			/* Port number.  */
  struct in_addr sin_addr;		/* Internet address.  */

  /* Pad to size of `struct sockaddr'.  */
  unsigned char sin_zero[sizeof (struct sockaddr) -
	   __SOCKADDR_COMMON_SIZE -
	   sizeof (in_port_t) -
	   sizeof (struct in_addr)];
};

其中,sockaddr_in结构体对象有四个待填充字段:

首先来看看 16 位地址类型 ,转到定义可以发现它是一个宏函数,并且使用了 C语言 中一个非常少用的语法 ##(将两个字符串拼接)

cpp 复制代码
/* POSIX.1g specifies this type name for the `sa_family' member.  */
typedef unsigned short int sa_family_t;

/* This macro is used to declare the initial common members
   of the data types used for socket addresses, `struct sockaddr',
   `struct sockaddr_in', `struct sockaddr_un', etc.  */

#define	__SOCKADDR_COMMON(sa_prefix) \
  sa_family_t sa_prefix##family

当给 _SOCKADDR_COMMON 传入 sin 参数后,经过 ## 字符串拼接、宏替换等操作后,会得到这样一个类型

cpp 复制代码
sa_family_t sin_family;

sa_family_t 是一个无符号短整数,占 16 位,sin_family 字段就是 16 位地址类型

接下来看看 端口号 ,转到定义,发现 in_port_t 类型是一个 16 位无符号整数,同样占 2 字节,正好符合 端口号 的取值范围 [0, 65535]

cpp 复制代码
/* Type to represent a port.  */
typedef uint16_t in_port_t;

最后再来看看 IP 地址 ,同样转到定义,发现 in_addr 中包含了一个 32 位无符号整数,占 4 字节,也就是 IP 地址 的大小

cpp 复制代码
/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
{
  in_addr_t s_addr;
};

了解完 sockaddr_in 结构体中的内容后,就可以创建该结构体了,再定义该结构体后,需要清空,确保其中的字段干净可用

  • 将变量置为 0 可用使用 bzero 函数
cpp 复制代码
#include <cstrins> // bzero 函数的头文件

struct sockaddr_in local;
bzero(&local, sizeof(local));

获得一个干净可用的 sockaddr_in 结构体后,可以正式绑定 IP 地址端口号

注:作为服务器,需要确定自己的端口号,我这里设置的是 8888

  • server.hpp 服务器头文件
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

namespace nt_server
{
    // 退出码
    enum
    {
        SOCKET_ERR = 1,
        BIND_ERR
    };

    // 端口号默认值
    const uint16_t default_port = 8888;

    class UdpServer
    {
    public:
        // 构造
        UdpServer(const std::string ip, const uint16_t port = default_port)
            :port_(port), ip_(ip)
        {} 
        // 析构
        ~UdpServer()
        {} 

        // 初始化服务器
        void InitServer()
        {
            // 1.创建套接字
            sock_ = socket(AF_INET, SOCK_DGRAM, 0);

            if(sock_ == -1)
            {
                std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            // 创建成功
            std::cout << "Create Success Socket: " << sock_ << std::endl;

            // 2.绑定IP地址和端口号
            struct sockaddr_in local;
            bzero(&local, sizeof(local)); // 置0

            // 填充字段
            local.sin_family = AF_INET; // 设置为网络通信(PF_INET 也行)
            local.sin_port = htons(port_); // 主机序列转为网络序列
            local.sin_addr.s_addr = inet_addr(ip_.c_str()); // 点分十进制转为短整数,再将主机序列转为网络序列

            // 绑定IP地址和端口号
            if(bind(sock_, (const sockaddr*)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 绑定成功
            std::cout << "Bind IP&&Port Success" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {}

    private:
        int sock_; // 套接字
        uint16_t port_; // 端口号
        std::string ip_; // IP地址(后面需要删除)
    };
}

注意:

  • 需要把主机序列转换为网络序列,可以使用 htons 函数
  • 需要把点分十进制的字符串,转换为无符号短整数,可以使用 inet_addr 函数,这个函数在进行转换的同时,会将主机序列转换为网络序列
  • 绑定IP地址和端口号这个行为并非直接绑定到当前主机中,而是在当前程序中,将创建的 socket 套接字,与目标IP地址与端口号进行绑定,当程序终止后,这个绑定关系也会随之消失
cpp 复制代码
in_addr_t inet_addr(const char *cp);// 将ip字符串转换为四字节ip
  • cp参数待转化的字符串式IP

sin_addr实际上还嵌套了一层结构体 s_addr:

这样,我们就将sockaddr_in结构体填充完毕,而为什么不见我们的 sin_family ***字段填充呢?***实际上:

这个字段在创建sockaddr_in对象时就已经被内置了,所以我们不需要进行填充。

cpp 复制代码
#include <memory> // 智能指针相关头文件
#include "server.hpp"

using namespace std;
using namespace nt_server;

int main()
{
    unique_ptr<UdpServer> usvr(new UdpServer("8.134.110.68"));

    // 初始化服务器
    usvr->InitServer();

    // 启动服务器
    usvr->StartServer();

    return 0;
}

接下来编译并运行程序,可以发现绑定失败了,这是因为当前我使用的是云服务器,云服务器是不允许直接绑定公网 IP 的,解决方案是在绑定 IP 地址时,让其选择绑定任意可用 IP 地址

修改代码

  • 云服务器中不需要明确 IP 地址
  • 构造时也无需传入 IP 地址
  • 绑定 IP 地址时选择 INADDR_ANY,表示绑定任何可用的 IP 地址
  • server.hpp 服务器头文件
cpp 复制代码
class UdpServer
{
public:
	// 构造
	UdpServer(const uint16_t port = default_port)
	    :port_(port)
	{} 

// 初始化服务器
void InitServer()
{
    // ...
    
    // 填充字段
    local.sin_family = AF_INET; // 设置为网络通信(PF_INET 也行)
    local.sin_port = htons(port_); // 主机序列转为网络序列
    // local.sin_addr.s_addr = inet_addr(ip_.c_str()); // 点分十进制转为短整数,再将主机序列转为网络序列
    local.sin_addr.s_addr = INADDR_ANY; // 绑定任何可用IP地址

	// ...
}

private:
	int sock_; // 套接字
	uint16_t port_; // 端口号
	// std::string ip_; // 删除
};
cpp 复制代码
#include <memory> // 智能指针相关头文件
#include "server.hpp"

using namespace std;
using namespace nt_server;

int main()
{
    unique_ptr<UdpServer> usvr(new UdpServer());

    // 初始化服务器
    usvr->InitServer();

    // 启动服务器
    usvr->StartServer();

    return 0;
}

再次编译并运行程序,可以看到正常运行

服务器设置的端口,需要设置为开放状态,如果是本地服务器,可以使用 systemctl start firewalld.service 指令开启防火墙,再使用 firewall-cmd --zone=public --add-port=Port/tcp --permanent 开启指定的端口号

如果是云服务器,就需要通过 控制台,开放对应的端口

3-1-5 🦍启动服务器

当前编写的 回响服务器 需要服务器拥有读取信息,然后回响给客户端的能力

读取信息使用 recvfrom 函数

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 读取信息(TCP/UDP	服务器/客户端)
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

这个函数参数比较多,首先来看看前半部分

  • sockfd参数套接字描述符,指示要接收数据的套接字
  • buf参数指向一个缓冲区的指针,用于存放接收到的数据
  • len参数指定要接收的最大字节数。这个值应该等于或大于 buf 所指向的缓冲区的大小
  • flags参数接收标志,通常情况下可以设置为 0

前半部分主要用于读取数据,并进行存放,接下来看看后半部分

  • src_addr参数指向一个 sockaddr 结构的指针,用于存放发送方的地址信息
  • addrlen参数指向一个 socklen_t 类型的变量,该变量初始应该设置为 src_addr 所指向结构的大小。在函数调用后,它将被填充为实际发送方地址的大小
  • 返回值成功返回接收到的字节数,失败返回-1并设置错误码

这个输入输出型参数就类似于送礼时留下自己的信息,待对方还礼时可以知道还给谁,接收信息也是如此,当服务器获取客户端的 sockaddr 结构体信息后,同样可以给客户端发送信息,双方就可以愉快的进行通信了

接收消息步骤:

  1. 创建缓冲区、对端 sockaddr_in 结构体
  2. 接收信息,判断是否接收成功
  3. 处理信息

所以接下来编写接收消息的逻辑

注意: 因为 recvfrom 函数的参数 src_addr 类型为 sockaddr,需要将 sockaddr_in 类型强转后,再进行传递

  • StartServer() 函数 --- 位于 server.hpp 服务器源文件中的 UdpServer 类
cpp 复制代码
// 启动服务器
void StartServer()
{
    // 服务器是不断运行的,所以需要使用一个 while(true) 死循环
    char buff[1024]; // 缓冲区
    while(true)
    {
        // 1. 接收消息
        struct sockaddr_in peer; // 客户端结构体
        socklen_t len = sizeof(peer); // 客户端结构体大小

        // 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'
        // 传入 0 表示当前是阻塞式读取
        ssize_t n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&peer, &len);

        if(n > 0)
            buff[n] = '\0';
        else
            continue; // 继续读取

        // 2.处理数据
        std::string clientIp = inet_ntoa(peer.sin_addr); // 获取IP地址
        uint16_t clientPort = ntohs(peer.sin_port); // 获取端口号
        printf("Server get message from [%c:%d]$ %s\n",clientIp.c_str(), clientPort, buff);

        // 3.回响给客户端
        // ...
    }
}

  • 发送信息使用 sendto 函数
cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 读取信息(TCP/UDP	服务器/客户端)
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

此函数参数及返回值含义与recvfrom接口相同,除了 addrlen这里不需要取地址。

发送消息时,直接调用 sendto 函数把读取到的信息,回响给客户端即可,如果发送失败了,就简单报个错,为了方便错误码调整,这里顺便把错误码封装成一个单独的 err.hpp 源文件(注意包含头文件)

  • StartServer() 函数 --- 位于 server.hpp 服务器源文件中的 UdpServer 类
cpp 复制代码
// ...
#include "err.hpp"

// ...

// 启动服务器
void StartServer()
{
    // 服务器是不断运行的,所以需要使用一个 while(true) 死循环
    char buff[1024]; // 缓冲区
    while(true)
    {
    	// ...

        // 3.回响给客户端
        n = sendto(sock_, buff, strlen(buff), 0, (const struct sockaddr*)&peer, sizeof(peer));

        if(n == -1)
            std::cout << "Send Message Fail: " << strerror(errno) << std::endl;
    }
}
  • err.hpp 头文件
cpp 复制代码
#pragma once

// 错误码
enum
{
    SOCKET_ERR = 1,
    BIND_ERR
};

万事具备后,就可以启动服务器了,可以看到服务器启动后,处于阻塞等待状态,这是因为还没有客户端给我的服务器发信息,所以它就会暂时阻塞

如何证明服务器正在运行?

可以通过 Linux 中查看网络状态的指令,因为我们这里使用的是 UDP 协议,所以只需要输入下面这条指令,就可以查看有哪些程序正在运行

bash 复制代码
netstat -nlup

netstat 是一个 用来查看网络状态的重要工具

  • 语法netstat [选项]
  • 功能查看网络状态

现在服务已经跑起来了,并且如期占用了 8888 端口,接下来就是编写客户端相关代码

0.0.0.0 表示任意IP地址

🌟客户端设计🌟

3-1-6 🫏指定IP地址和端口号

客户端在运行时,必须知道服务器的 IP 地址端口号 ,否则不知道自己该与谁进行通信,所以对于 UdpClient 类来说,ip 和 port 者两个字段是肯定少不了的

  • client.hpp 客户端头文件
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include "err.hpp"

namespace nt_client
{
    class UdpClient
    {
    public:
        // 构造
        UdpClient(const std::string& ip, uint16_t port)
            :server_ip_(ip), server_port_(port)
        {} 
        
        // 析构
        ~UdpClient() 
        {} 

        // 初始化客户端
        void InitClient() 
        {}

        // 启动客户端
        void StartClient() 
        {}

    private:
        std::string server_ip_; // 服务器IP地址
        uint16_t server_port_; // 服务器端口号
    };
}

这两个参数由用户主动传输,这里就需要 命令行 参数相关知识了,在启动客户端时,需要以 ./client serverIp serverPort 的方式运行,否则就报错,并提示相关错误信息(更新 err.hpp 的错误码)

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

using namespace std;
using namespace nt_client;

void Usage(const char* program)
{
  cout << "Usage:" << endl;
  cout << "\t" << program << " ServerIP ServerPort" << endl;
}

int main(int argc, char* argv[])
{
  if (argc != 3)
  {
    // 错误的启动方式,提示错误信息
    Usage(argv[0]);
    return USAGE_ERR;
  }

  std::string ip = argv[1];
  uint16_t port = stoi(argv[2]);

  unique_ptr<UdpClient> usvr(new UdpClient(ip, port));

  // 初始化客户端
  usvr->InitClient();

  // 启动客户端
  usvr->StartClient();

  return 0;
}
  • err.hpp 错误码头文件
cpp 复制代码
#pragma once

enum
{
    USAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR
};

如此一来,只有正确的输入 [./client ServerIP ServerPort] 才能启动程序,否则不让程序运行,倒逼客户端启动时,提供服务器的 IP 地址端口号

其实在浏览网页时输入的 url 网址,在经过转换后,其中也一定会包含服务器的 IP 地址端口号,配合请求的资源路径,就能获取服务器资源了

3-1-7 🐆初始化客户端

初始化客户端时,同样需要创建 socket 套接字,不同于服务器的是 客户端不需要自己手动绑定 IP 地址与端口号

这是因为客户端手动指明 端口号 存在隐患:如果恰好有两个程序使用了同一个端口,会导致其中一方的客户端直接绑定失败,无法运行 ,将绑定 端口号 这个行为交给 OS 自动执行(首次传输数据时自动 bind),可以避免这种冲突的出现

为什么服务器要自己手动指定端口号,并进行绑定?
这是因为服务器的端口不能随意改变,并且这是要公布给广大客户端看的,同一家公司在部署服务时,会对端口号的使用情况进行管理,可以直接避免端口号冲突

客户端在启动前,需要先知晓服务器的 sockaddr_in 结构体信息,可以利用已知的 IP 地址端口号 构建

综上所述,在初始化客户端时,需要创建好套接字和初始化服务器的 sockaddr_in 结构体信息

  • client.hpp 客户端头文件
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_client
{
    class UdpClient
    {
    public:
        // 构造
        UdpClient(const std::string& ip, uint16_t port)
            :server_ip_(ip), server_port_(port)
        {} 

        // 析构
        ~UdpClient() 
        {} 

        // 初始化客户端
        void InitClient() 
        {
            // 1.创建套接字
            sock_ = socket(AF_INET, SOCK_DGRAM, 0);

            if(sock_ == -1)
            {
                std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create Success Socket: " << sock_ << std::endl;

            // 2.构建服务器的 sockaddr_in 结构体信息
            bzero(&svr_, sizeof(svr_));
            svr_.sin_family = AF_INET; // 设置为网络通信(PF_INET 也行)
            svr_.sin_addr.s_addr = inet_addr(server_ip_.c_str()); // 绑定服务器IP地址
            svr_.sin_port = htons(server_port_); // 绑定服务器端口号
        }

        // 启动客户端
        void StartClient() 
        {}

    private:
        std::string server_ip_; // 服务器IP地址
        uint16_t server_port_; // 服务器端口号
        int sock_;
        struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息
    };
}

如此一来,客户端就可以利用该 sockaddr_in 结构体,与目标主机进行通信了

3-1-8 🦬启动客户端

接下来就是客户端向服务器发送消息,消息由用户主动输入,使用的是 sendto 函数

发送消息步骤

  1. 用户输入消息
  2. 传入缓冲区、服务器相关参数,使用 sendto 函数发送消息

消息发送后,客户端等待服务器回响消息

接收消息步骤:

  1. 创建缓冲区
  2. 接收信息,判断是否接收成功
  3. 处理信息

注:同服务器一样,客户端也需要不断运行

  • StartClient() 函数 --- 位于 client.hpp 中的 UdpClient 类
cpp 复制代码
// 启动客户端
void StartClient() 
{
    char buff[1024];

    while(true)
    {
        // 1.发送消息
        std::string msg;
        std::cout << "Input Message# ";
        std::getline(std::cin, msg);

        ssize_t n = sendto(sock_, msg.c_str(), msg.size(), 0, (const struct sockaddr*)&svr_, sizeof(svr_));

        if(n == -1)
        {
            std::cout << "Send Message Fail: " << strerror(errno) << std::endl;
            continue; // 重新输入消息并发送
        }

        // 2.接收消息
        socklen_t len = sizeof(svr_); // 创建一个变量,因为接下来的参数需要传左值
        n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&svr_, &len);

        if(n > 0)
            buff[n] = '\0';
        else
            continue;

        // 可以再次获取IP地址与端口号
        std::string ip = inet_ntoa(svr_.sin_addr);
        uint16_t port = ntohs(svr_.sin_port);

        printf("Client get message from [%s:%d]# %s\n",ip.c_str(), port, buff);
    }
}

现在左手 服务器 ,右手 客户端,直接编译运行,看看效果:

注:127.0.0.1 表示本地环回(通常用于测试网络程序),因为我当前的服务器和客户端都是在同一机器上运行的,所以就可以使用该 IP 地址,当然直接使用服务器的公网 IP 地址也是可以的

通过 netstat -nlup 指令查看端口使用情况

可以看到,服务器和客户端都成功运行了,OS 给客户端分配的 端口号 是 54450,这是随机分配的,每次重新运行后,大概率都不相同

至此基于 UDP 协议编写的第一个网络程序 字符串回响 就完成了,接下来对其进行改造,编写第二个网络程序

3-2 🧇大写转小写

3-2-1 🦊业务处理函数解耦

基于模块化处理的思想,将服务器中处理消息的函数与启动服务的函数解耦,由程序员传入指定的回调函数

此时业务处理函数已经变成一个模块了,可以自由变换

  • 业务处理函数A:实现大写转小写
  • 业务处理函数C:实现 xxx

服务器在启动时,只需要传入对应的业务处理函数(回调函数)即可

修改 server.hpp 的代码如下

  • 使用 C++11 中的 function 包装器语法,包装出一个符合我们业务处理需求的函数类型

  • server.hpp 服务器头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    // 端口号默认值
    const uint16_t default_port = 8888;
    using func_t = std::function<std::string(std::string)>; // 参数为string,返回值同样为string

    class UdpServer
    {
    public:
        // 构造
        UdpServer(const func_t& func, uint16_t port = default_port)
            :port_(port)
            ,serverHandle_(func)
        {}
        // 析构
        ~UdpServer()
        {} 

        // 初始化服务器
        void InitServer()
        {
            // 1.创建套接字
            sock_ = socket(AF_INET, SOCK_DGRAM, 0);

            if(sock_ == -1)
            {
                std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            // 创建成功
            std::cout << "Create Success Socket: " << sock_ << std::endl;

            // 2.绑定IP地址和端口号
            struct sockaddr_in local;
            bzero(&local, sizeof(local)); // 置0

            // 填充字段
            local.sin_family = AF_INET; // 设置为网络通信(PF_INET 也行)
            local.sin_port = htons(port_); // 主机序列转为网络序列
            // local.sin_addr.s_addr = inet_addr(ip_.c_str()); // 点分十进制转为短整数,再将主机序列转为网络序列
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任何可用IP地址

            // 绑定IP地址和端口号
            if(bind(sock_, (const sockaddr*)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 绑定成功
            std::cout << "Bind IP&&Port Success" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            // 服务器是不断运行的,所以需要使用一个 while(true) 死循环
            char buff[1024]; // 缓冲区
            while(true)
            {
                // 1. 接收消息
                struct sockaddr_in peer; // 客户端结构体
                socklen_t len = sizeof(peer); // 客户端结构体大小

                // 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'
                // 传入 0 表示当前是阻塞式读取
                ssize_t n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&peer, &len);

                if(n > 0)
                    buff[n] = '\0';
                else
                    continue; // 继续读取

                // 2.处理数据
                std::string clientIp = inet_ntoa(peer.sin_addr); // 获取IP地址
                uint16_t clientPort = ntohs(peer.sin_port); // 获取端口号
                printf("Server get message from [%s:%d]$ %s\n",clientIp.c_str(), clientPort, buff);

                // 获取业务处理后的结果
                std::string respond = serverHandle_(buff);

                // 3.回响给客户端
                n = sendto(sock_, respond.c_str(), respond.size(), 0, (const struct sockaddr*)&peer, sizeof(peer));

                if(n == -1)
                    std::cout << "Send Message Fail: " << strerror(errno) << std::endl;
            }
        }

    private:
        int sock_; // 套接字
        uint16_t port_; // 端口号
        func_t serverHandle_; // 业务处理函数(回调函数)
    };
}

现在只需要关注业务处理如何实现,无需考虑具体的网络传输如何实现

3-2-2 🐵大写转小写

现阶段实现一个将大写字符转换为小写字符的函数易如反掌,只需注意一点就好了:对于非大写的字符,不需要进行改动

函数实现完成后,将其作为参数传递给 UdpServer 类型,构造出相应的对象

cpp 复制代码
#include <memory> // 智能指针相关头文件
#include "server.hpp"

using namespace std;
using namespace nt_server;

// 大写转小写(英文字母)
std::string UpToLow(const std::string& resquest)
{
    std::string ret(resquest);

    for(auto &rc : ret)
    {
        if(isupper(rc))
            rc += 32;
    }

    return ret;
}

int main()
{
    unique_ptr<UdpServer> usvr(new UdpServer(UpToLow));

    // 初始化服务器
    usvr->InitServer();

    // 启动服务器
    usvr->StartServer();

    return 0;
}

至此只需要客户端传入一段消息,如果消息中包含了大写字符,我们的服务器就会将其转为小写字符,然后将消息发送给客户端,相当于之前单纯回响字符串的加强版

客户端仍然只需发送消息、接收消息,可以直接使用之前的客户端

重新编译并运行服务器,通过客户端发送信息,可以看到大写字符确实都被转为小写字符了

如果想实现小写转大写,或其他转换需求,只需要重新编写业务处理函数,将其作为参数传递给 UdpServer 类即可

注意: 传递的业务处理函数,在返回值、参数方面,必须与类中的回调函数类型一致

3-3 🥞多人聊天室

3-3-1 🦝核心功能

这是基于 UDP 协议实现的最后一个网络程序,主要功能是 构建一个多人聊天室,当某个用户发送消息时,其他用户可以立即收到,形成一个群聊

在这个程序中,服务器扮演了一个接收消息和分发消息的角色,将消息发送给已知的用户主机

3-3-2 🐨程序结构

将服务器接收消息看作生产商品、分发消息看作消费商品,这不就是一个生动形象的 「生产者消费者模型」 吗?

「生产者消费者模型」 必备 321

  • 3:三组关系
  • 2:两个角色
  • 1:一个交易场所

其中两个角色可以分别创建两个线程,一个负责接收消息,放入 「生产者消费者模型」 ,另一个则是负责从 「生产者消费者模型」 中拿去消息,分发给用户主机

这里的交易场所可以选则 阻塞队列 ,也可以选择 环形队列

关于 「生产者消费者模型」 的更多知识详见 《Linux多线程【生产者消费者模型】

注意: 并非只有客户端 A 可以向环形队列中放消息,所有客户端主机的地位都是平等的,允许存放消息,也允许接收别人发的消息

🌟服务器🌟

3-3-3 🐸引入环形队列

在引入 「生产者消费者模型」 后,服务器头文件结构将会变成下面这个样子

  • 启动服务器,原初始化服务器、启动线程
  • 接收消息,将收到的消息存入环形队列
  • 发送消息,从环形队列中获取消息,并派发给线程

接下来包含环形队列 RingQueue.hpp 相关头文件

这里实现的是多人聊天室,也就不再需要传入回调函数了

  • server.hpp 服务器头文件
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"
#include "RingQueue.hpp"

namespace nt_server
{
    // 端口号默认值
    const uint16_t default_port = 8888;

    class UdpServer
    {
    public:
        // 构造
        UdpServer(uint16_t port = default_port)
            :port_(port)
        {}
        // 析构
        ~UdpServer()
        {} 

        // 初始化服务器
        void StartServer()
        {
            // 1.创建套接字
            sock_ = socket(AF_INET, SOCK_DGRAM, 0);

            if(sock_ == -1)
            {
                std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            // 创建成功
            std::cout << "Create Success Socket: " << sock_ << std::endl;

            // 2.绑定IP地址和端口号
            struct sockaddr_in local;
            bzero(&local, sizeof(local)); // 置0

            // 填充字段
            local.sin_family = AF_INET; // 设置为网络通信(PF_INET 也行)
            local.sin_port = htons(port_); // 主机序列转为网络序列
            // local.sin_addr.s_addr = inet_addr(ip_.c_str()); // 点分十进制转为短整数,再将主机序列转为网络序列
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任何可用IP地址

            // 绑定IP地址和端口号
            if(bind(sock_, (const sockaddr*)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 绑定成功
            std::cout << "Bind IP&&Port Success" << std::endl;
        }

        // 接收消息
        void RecvMessage()
        {
            // 服务器是不断运行的,所以需要使用一个 while(true) 死循环
            char buff[1024]; // 缓冲区
            while(true)
            {
                // 1. 接收消息
                struct sockaddr_in peer; // 客户端结构体
                socklen_t len = sizeof(peer); // 客户端结构体大小

                // 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'
                // 传入 0 表示当前是阻塞式读取
                ssize_t n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&peer, &len);

                if(n > 0)
                    buff[n] = '\0';
                else
                    continue; // 继续读取

                // 2.处理数据
                std::string clientIp = inet_ntoa(peer.sin_addr); // 获取IP地址
                uint16_t clientPort = ntohs(peer.sin_port); // 获取端口号
                printf("Server get message from [%s:%d]$ %s\n",clientIp.c_str(), clientPort, buff);

                // 3.判断是否该添加用户
                // TODO

                // 4.将消息添加至环形队列
                std::string msg = "[" + clientIp + ":" + std::to_string(clientPort) + "] say# " + buff;
                rq_.Push(msg);
            }
        }

        // 广播消息
        void BroadcastMessage()
        {
            while(true)
            {
                // 1.从环形队列中获取消息
                std::string msg;
                rq_.Pop(&msg);

                // 2.将消息发给用户
                // TODO
            }
        }

    private:
        int sock_; // 套接字
        uint16_t port_; // 端口号
        Yohifo::RingQueue<std::string> rq_; // 环形队列
    };
}
3-3-4 🐔引入用户信息

在首次接收到某个用户的信息时,需要将其进行标识,以便后续在进行消息广播时分发给他

有点类似于用户首次发送消息,就被拉入了 "群聊"

目前可以使用 IP + Port 的方式标识用户,确保用户的唯一性,这里选取 unordered_map 这种哈希表结构,方便快速判断用户是否已存在

  • key:用户标识符
  • value:用户客户端的 sockaddr_in 结构体

注意: 这里的哈希表后面会涉及多线程的访问,需要加锁保护 具体实现详见《Linux多线程【线程互斥与同步】》)

  • server.hpp 服务器头文件
cpp 复制代码
#pragma once

// ...
#include <unordered_map>
// ...
#include "LockGuard.hpp"

namespace nt_server
{
    // 端口号默认值
    const uint16_t default_port = 8888;

    class UdpServer
    {
    public:
        // 构造
        UdpServer(uint16_t port = default_port)
            :port_(port)
        {
            // 初始化互斥锁
            pthread_mutex_init(&mtx_, nullptr);
        }
        // 析构
        ~UdpServer()
        {
            // 销毁互斥锁
            pthread_mutex_destroy(&mtx_);
        } 

        // 初始化服务器
        void StartServer()
        {
        	// ...
        }

        // 接收消息
        void RecvMessage()
        {
            // 服务器是不断运行的,所以需要使用一个 while(true) 死循环
            char buff[1024]; // 缓冲区
            while(true)
            {
                // 1. 接收消息
                // ...

                // 2.处理数据
                // ...

                // 3.判断是否该添加用户
                std::string user = clientIp + "-" + std::to_string(clientPort);

                {
                    // 需要加锁保护
                    LockGuard lockguard(&mtx_);
                    if(userTable_.count(user) == 0)
                        userTable_[user] = peer; // 首次出现,需要添加
                }

                // 4.将消息添加至环形队列
                // ...
            }
        }

        // 广播消息
        void BroadcastMessage()
        {
            while(true)
            {
                // 1.从环形队列中获取消息
                // ...

                // 2.将消息发给用户
                std::vector<sockaddr_in> arr;

                {
                    // 从哈希表中读取信息时,需要保护
                    LockGuard lockguard(&mtx_);
                    for(auto &user : userTable_)
                        arr.push_back(user.second);
                }

                for(auto &addr : arr)
                {
                    // 发送消息
                    sendto(sock_, msg.c_str(), msg.size(), 0, (const sockaddr*)&addr, sizeof(addr));
                }
            }
        }

    private:
    	// ...
        std::unordered_map<std::string, struct sockaddr_in> userTable_; // <用户标识符, sockaddr_in 结构体>
        pthread_mutex_t mtx_; // 互斥锁,保护哈希表
    };
}

这里的实现有一个小细节:在进行广播消息时,先在加锁的情况下,将用户的 sockaddr_in 结构体存储,在遍历发送消息

这样做的好处在于可以在一定程度上提高通信效率,因为 sendto 函数涉及 IO 操作,IO 本来就很慢,加锁后就会更慢了,先在加锁情况下将用户 sockaddr_in 结构体保存后,再遍历发送消息就无需加锁了(因为此时没有涉及临界资源的操作)

3-3-5 🐓引入多线程

最后引入 「生产者消费者」 模型中的两种角色:生产者、消费者,也就是两个线程,原生线程库的操作有点麻烦了,我们同样可以搬出之前实现的小组件 Thread.hpp,更加轻松的实现线程操作(具体实现详见《Linux多线程【线程互斥与同步】》)

如何引入多线程?
创建两个线程 AB,将接收消息作为线程 A 的回调函数,广播消息作为线程 B 的回调函数,当两个线程都运行后,整个模型也就动起来了

为了使我们当前服务器的函数对象能成功绑定至 Thread 对象,需要修改 Thread 类(使用 function 包装器)

  • Thread.hpp 线程库类
cpp 复制代码
// ...

// 参数、返回值为 void 的函数类型
// typedef void (*func_t)(void*);
using func_t = std::function<void(void*)>;  // 使用包装器设定函数类型

// ...

因为当前涉及了多线程相关操作,在编译代码时,需要指明使用 pthread 库,将 Makefile 内容更新如下

  • Makefile
bash 复制代码
.PHONY:all
all:server client

server:server.cc
	g++ -o $@ $^ -std=c++11 -lpthread

	
client:client.cc
	g++ -o $@ $^ -std=c++11 -lpthread

.PHONY:clean
clean:
	rm -rf server client
  • server.hpp 服务器头文件
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <unordered_map>
#include <functional>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"
#include "RingQueue.hpp"
#include "LockGuard.hpp"
#include "Thread.hpp"

namespace nt_server
{
    // 端口号默认值
    const uint16_t default_port = 8888;

    class UdpServer
    {
    public:
        // 构造
        UdpServer(uint16_t port = default_port)
            :port_(port)
        {
            // 初始化互斥锁
            pthread_mutex_init(&mtx_, nullptr);

            // 创建线程
            // 注意:因为类内成员有隐含的 this 指针,需要借助 bind 固定该参数
            producer_ = new Thread(1, std::bind(&UdpServer::RecvMessage, this));
            consumer_ = new Thread(2, std::bind(&UdpServer::BroadcastMessage, this));
        }
        // 析构
        ~UdpServer()
        {
            // 等待线程运行结束
            producer_->join();
            consumer_->join();

            // 销毁互斥锁
            pthread_mutex_destroy(&mtx_);

            // 释放对象
            delete producer_;
            delete consumer_;
        } 

        // 初始化服务器
        void StartServer()
        {
            // 1.创建套接字
            sock_ = socket(AF_INET, SOCK_DGRAM, 0);

            if(sock_ == -1)
            {
                std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            // 创建成功
            std::cout << "Create Success Socket: " << sock_ << std::endl;

            // 2.绑定IP地址和端口号
            struct sockaddr_in local;
            bzero(&local, sizeof(local)); // 置0

            // 填充字段
            local.sin_family = AF_INET; // 设置为网络通信(PF_INET 也行)
            local.sin_port = htons(port_); // 主机序列转为网络序列
            // local.sin_addr.s_addr = inet_addr(ip_.c_str()); // 点分十进制转为短整数,再将主机序列转为网络序列
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任何可用IP地址

            // 绑定IP地址和端口号
            if(bind(sock_, (const sockaddr*)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 绑定成功
            std::cout << "Bind IP&&Port Success" << std::endl;

            // 启动线程
            producer_->run();
            consumer_->run();
        }

        // 接收消息
        void RecvMessage()
        {
            // 服务器是不断运行的,所以需要使用一个 while(true) 死循环
            char buff[1024]; // 缓冲区
            while(true)
            {
                // 1. 接收消息
                struct sockaddr_in peer; // 客户端结构体
                socklen_t len = sizeof(peer); // 客户端结构体大小

                // 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'
                // 传入 0 表示当前是阻塞式读取
                ssize_t n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&peer, &len);

                if(n > 0)
                    buff[n] = '\0';
                else
                    continue; // 继续读取

                // 2.处理数据
                std::string clientIp = inet_ntoa(peer.sin_addr); // 获取IP地址
                uint16_t clientPort = ntohs(peer.sin_port); // 获取端口号
                printf("Server get message from [%s:%d]$ %s\n",clientIp.c_str(), clientPort, buff);

                // 3.判断是否该添加用户
                std::string user = clientIp + "-" + std::to_string(clientPort);

                {
                    // 需要加锁保护
                    LockGuard lockguard(&mtx_);
                    if(userTable_.count(user) == 0)
                        userTable_[user] = peer; // 首次出现,需要添加
                }

                // 4.将消息添加至环形队列
                std::string msg = "[" + clientIp + ":" + std::to_string(clientPort) + "] say# " + buff;
                rq_.Push(msg);
            }
        }

        // 广播消息
        void BroadcastMessage()
        {
            while(true)
            {
                // 1.从环形队列中获取消息
                std::string msg;
                rq_.Pop(&msg);

                // 2.将消息发给用户
                std::vector<sockaddr_in> arr;

                {
                    // 从哈希表中读取信息时,需要保护
                    LockGuard lockguard(&mtx_);
                    for(auto &user : userTable_)
                        arr.push_back(user.second);
                }

                for(auto &addr : arr)
                {
                    // 发送消息
                    sendto(sock_, msg.c_str(), msg.size(), 0, (const sockaddr*)&addr, sizeof(addr));
                }
            }
        }

    private:
        int sock_; // 套接字
        uint16_t port_; // 端口号
        Yohifo::RingQueue<std::string> rq_; // 环形队列
        std::unordered_map<std::string, struct sockaddr_in> userTable_; // <用户标识符, sockaddr_in 结构体>
        pthread_mutex_t mtx_; // 互斥锁,保护哈希表
        Thread* producer_; // 生产者
        Thread* consumer_; // 消费者
    };
}

以上就是 多人聊天室 中 server.hpp 服务器头文件的全部设计了,至于 server.cc 服务器源文件,几乎不用修改

cpp 复制代码
#include <string>
#include <vector>
#include <memory> // 智能指针相关头文件
#include <cstdio>
#include "server.hpp"

using namespace std;
using namespace nt_server;

int main()
{
    unique_ptr<UdpServer> usvr(new UdpServer());

    // 启动服务器
    usvr->StartServer();

    return 0;
}

接下来编译并运行程序,可以看到此时有三个线程在运行(一个 server 主线程,一个生产者线程,一个消费者线程)

分别使用两台主机运行客户端,可以看到主机 A 确实可以看到主机 B 发送的信息,不过问题在于 无法实时更新消息,需要自己发送消息后,才能看到别人发的消息

出现这种情况的原因是 客户端只有一个线程,发送消息的后,才能接收消息, 这就很尴尬了,假设这个群聊里有十个用户,那用户 A 岂不是自己至少得发送 9 条消息,才能看到其他九位用户之前发送的消息

所以客户端也需要多线程化,接下来就是对客户端的改造

🌟客户端🌟

3-3-6 🐦多线程化

有了之前 server.hpp 服务器头文件多线程化的经验后,改造 client.hpp 客户端头文件就很简单了,同样是创建两个线程,一个负责发送消息,一个负责接收消息

这里同样使用 Thread.hpp 线程类

  • client.hpp 客户端头文件
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"
#include "Thread.hpp"
#include "LockGuard.hpp"

namespace nt_client
{
    class UdpClient
    {
    public:
        // 构造
        UdpClient(const std::string& ip, uint16_t port)
            :server_ip_(ip), server_port_(port)
        {
            // 创建线程
            recv_ = new Thread(1, std::bind(&UdpClient::RecvMessage, this));
            send_ = new Thread(2, std::bind(&UdpClient::SendMessage, this));
        }

        // 析构
        ~UdpClient() 
        {
            // 等待线程退出
            recv_->join();
            send_->join();

            delete (recv_);
            delete (send_);
        } 

        // 启动客户端
        void StartClient() 
        {
            // 1.创建套接字
            sock_ = socket(AF_INET, SOCK_DGRAM, 0);

            if(sock_ == -1)
            {
                std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create Success Socket: " << sock_ << std::endl;

            // 2.构建服务器的 sockaddr_in 结构体信息
            bzero(&svr_, sizeof(svr_));
            svr_.sin_family = AF_INET; // 设置为网络通信(PF_INET 也行)
            svr_.sin_addr.s_addr = inet_addr(server_ip_.c_str()); // 绑定服务器IP地址
            svr_.sin_port = htons(server_port_); // 绑定服务器端口号

            // 启动线程
            recv_->run();
            send_->run();
        }

        // 发送消息
        void RecvMessage() 
        {
            while(true)
            {
                // 发送消息
                std::string msg;
                std::cout << "Input Message# ";
                std::getline(std::cin, msg);

                ssize_t n = sendto(sock_, msg.c_str(), msg.size(), 0, (const struct sockaddr*)&svr_, sizeof(svr_));

                if(n == -1)
                {
                    std::cout << "Send Message Fail: " << strerror(errno) << std::endl;
                    continue; // 重新输入消息并发送
                }
            }
        }

        // 接收消息
        void SendMessage()
        {
            char buff[1024];
            while(true)
            {
                // 2.接收消息
                socklen_t len = sizeof(svr_); // 创建一个变量,因为接下来的参数需要传左值
                ssize_t n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&svr_, &len);

                if(n > 0)
                    buff[n] = '\0';
                else
                    continue;

                std::cout << "Client get message " << buff << std::endl;
            }
        }

    private:
        std::string server_ip_; // 服务器IP地址
        uint16_t server_port_; // 服务器端口号
        int sock_;
        struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息
        Thread* recv_; // 发送消息
        Thread* send_; // 接收消息
    };
}
cpp 复制代码
#include <iostream>
#include <memory>
#include <memory>
#include "client.hpp"

using namespace std;
using namespace nt_client;

void Usage(const char* program)
{
  cout << "Usage:" << endl;
  cout << "\t" << program << " ServerIP ServerPort" << endl;
}

int main(int argc, char* argv[])
{
  if (argc != 3)
  {
    // 错误的启动方式,提示错误信息
    Usage(argv[0]);
    return USAGE_ERR;
  }

  std::string ip = argv[1];
  uint16_t port = stoi(argv[2]);

  unique_ptr<UdpClient> usvr(new UdpClient(ip, port));

  // 启动客户端
  usvr->StartClient();

  return 0;
}

客户端改造完成后,再次服务器与客户端,可以看到现在已经正常了,多人聊天室 构建完毕

注:因为客户端发送消息、接收消息使用的是同一个文件描述符,属于临界资源,所以显示时出现问题很正常

关于输入、输出消息剥离的问题,可以利用标准输出、标准错误 + 管道的方式进行区分,限于篇幅原因,这里不再阐述

至此基于 UDP 协议实现的多个网络程序都已经编写完成了,尤其是 多人聊天室 ,如果加上简单的图形化界面(比如 EasyXEGE),就是一个简易版的 QQ 群聊


总结与提炼

本指南通过理论与实战结合构建UDP学习路径,核心收获如下:

理论层面,IP地址端口号、网络字节序构成通信底层逻辑 ;socket套接 字API及sockaddr结构体是连接理论与实践的核心技术支撑。

实战层面,三个递进案例展现UDP开发进化:字符串回响搭建基础框架;大写转小写实现业务函数解耦;多人聊天室通过环形队列、用户管理、多线程解决并发同步问题。

学习启示:UDP开发需平衡"轻量高效"与"可靠性设计",通过应用层设计弥补无连接缺陷;"先基础后复杂"及模块化设计思路,对网络编程具普遍指导意义,助力形成清晰编程思维。


结束语

以上是我对于【Linux网络编程】socket套接字 && 简易UDP网络程序

感谢您的三连支持!!!

相关推荐
a123560mh2 小时前
国产信创操作系统银河麒麟常见软件适配(MongoDB、 Redis、Nginx、Tomcat)
linux·redis·nginx·mongodb·tomcat·kylin
赖small强2 小时前
【Linux驱动开发】Linux MMC子系统技术分析报告 - 第二部分:协议实现与性能优化
linux·驱动开发·mmc
网安小白的进阶之路2 小时前
B模块 安全通信网络 第一门课 园区网实现与安全-1
网络·安全
guygg883 小时前
Linux服务器上安装配置GitLab
linux·运维·gitlab
百***35513 小时前
Linux(CentOS)安装 Nginx
linux·nginx·centos
tzhou644523 小时前
Linux文本处理工具:cut、sort、uniq、tr
linux·运维·服务器
顾安r4 小时前
11.19 脚本 最小web控制linux/termux
linux·服务器·css·flask
程序媛_MISS_zhang_01104 小时前
vant-ui中List 组件可以与 PullRefresh 组件结合使用,实现下拉刷新的效果
java·linux·ui
独行soc5 小时前
2025年渗透测试面试题总结-254(题目+回答)
网络·python·安全·web安全·adb·渗透测试·安全狮