计算机网络编程---UDP客户端与服务端

目录

前言

一、UDP协议基础回顾

二、日志模块设计

三、UDP服务端完整实现

[3.1 服务端类的设计](#3.1 服务端类的设计)

[3.2 代码深入解读:套接字的创建与绑定](#3.2 代码深入解读:套接字的创建与绑定)

[3.3 数据收发的核心循环](#3.3 数据收发的核心循环)

[3.4 回调函数的设计思想](#3.4 回调函数的设计思想)

[3.5 服务端入口程序](#3.5 服务端入口程序)

四、UDP客户端完整实现


前言

在网络编程的世界里,UDP(User Datagram Protocol,用户数据报协议)以其简洁高效的特性,在实时音视频传输、在线游戏、DNS查询等场景中占据着不可替代的地位。与TCP不同,UDP是无连接的、不可靠的传输协议,它不保证数据包的顺序和到达,但换来了更低的延迟和更小的开销。

一、UDP协议基础回顾

在动手写代码之前,有必要先回顾几个核心概念,它们将贯穿整个实现过程。

UDP的特点可以概括为"三无":

  • 无连接:通信前不需要建立连接,直接发送数据

  • 不可靠:不保证数据到达、不保证顺序、不保证不重复

  • 无拥塞控制:发送端可以以任意速率发送数据

但也因此拥有了"三快":

  • 建立连接快:省去三次握手

  • 传输延迟低:没有确认重传机制

  • 资源消耗小:不需要维护连接状态

UDP的数据传输单元称为"数据报",每个数据报都是独立的,最大长度理论上是65507字节(减去IP头和UDP头的开销)。在实际编程中,我们使用sendto()recvfrom()这两个函数,它们会同时处理数据和对端地址信息。

UDP编程的核心流程可以概括为:

服务端:socket() → bind() → recvfrom()/sendto() → close()
客户端:socket() → sendto()/recvfrom() → close()

注意,客户端通常不需要显式调用bind(),系统会自动分配临时端口。服务端则必须bind()到一个众所周知的端口,这样客户端才知道往哪里发送数据。

二、日志模块设计

一个完善的网络程序离不开日志系统。我们先来设计一个轻量级的日志类,它将贯穿整个服务端的运行过程,帮助我们追踪程序的执行状态。

cpp 复制代码
// Log.hpp
#pragma once

#include <iostream>
#include <string>
#include <cstdio>
#include <cstdarg>
#include <ctime>

// 日志级别定义
enum LogLevel
{
    Debug = 0,
    Info,
    Warning,
    Error,
    Fatal
};

// 获取当前时间的字符串表示
inline std::string GetTimestamp()
{
    time_t now = time(nullptr);
    char buffer[64];
    struct tm* tm_info = localtime(&now);
    strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", tm_info);
    return std::string(buffer);
}

// 日志级别转字符串
inline const char* LevelToString(LogLevel level)
{
    switch(level)
    {
        case Debug:   return "DEBUG";
        case Info:    return "INFO";
        case Warning: return "WARN";
        case Error:   return "ERROR";
        case Fatal:   return "FATAL";
        default:      return "UNKNOWN";
    }
}

// 日志类
class Log
{
public:
    void operator()(LogLevel level, const char* format, ...)
    {
        // 低于当前级别的日志不输出
        if(level < currentLevel_) return;
        
        printf("[%s] [%s] ", GetTimestamp().c_str(), LevelToString(level));
        
        va_list args;
        va_start(args, format);
        vprintf(format, args);
        va_end(args);
        
        printf("\n");
    }

    void SetLevel(LogLevel level) { currentLevel_ = level; }

private:
    LogLevel currentLevel_ = Debug;  // 默认输出所有级别
};

日志系统设计要点:

  • 使用可变参数模板,支持类似printf的格式化输出

  • 添加时间戳,便于追踪问题发生的时间

  • 级别过滤机制,生产环境可以只输出警告以上级别

  • 重载了operator(),使用时像函数调用一样简洁

为了方便全局使用,我们在头文件中声明一个全局日志对象:

cpp 复制代码
extern Log lg;

三、UDP服务端完整实现

服务端是UDP通信的核心,它需要完成套接字创建、地址绑定、数据收发三个主要步骤。我们将这些功能封装到UdpServer类中。

3.1 服务端类的设计

cpp 复制代码
// UdpServer.hpp
#pragma once

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

enum
{
    SOCKET_ERR = 1, 
    BIND_ERR
};

typedef std::function<const std::string(const std::string&)> func_t;

const int defaultsockfd = -1;
const std::string defaultip = "0.0.0.0";
const uint16_t defaultport = 8080;
const int size = 1024;

class UdpServer
{
public:
    UdpServer(const uint16_t& port = defaultport, const std::string& ip = defaultip)
        : sockfd_(defaultsockfd), ip_(ip), port_(port), isrunning_(false)
    {}

    void Init()
    {
        // 1. 创建套接字
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if(sockfd_ < 0)
        {
            lg(Fatal, "socket create error, errno: %d, errstring: %s", errno, strerror(errno));
            exit(SOCKET_ERR);
        }
        lg(Info, "socket create success, sockfd: %d", sockfd_);

        // 2. 准备本地地址结构
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = inet_addr(ip_.c_str());
        local.sin_port = htons(port_);

        // 3. 绑定套接字
        if(bind(sockfd_, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            lg(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
            exit(BIND_ERR);            
        }
        lg(Info, "bind success, errno: %d, errstring: %s", errno, strerror(errno));
    }

    void Run(func_t fun)
    {
        isrunning_ = true;
        char inbuffer[size];
        while(isrunning_)
        {
            // 接收数据
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            ssize_t n = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, 
                                (struct sockaddr*)&client, &len);
            if(n < 0)
            {
                lg(Warning, "recvfrom error, errno: %d, errstring: %s", errno, strerror(errno));
                continue;
            }
            
            // 处理数据(调用用户自定义的回调函数)
            inbuffer[n] = 0;
            std::string info = inbuffer;
            std::string echo_string = fun(info);

            // 发送响应
            sendto(sockfd_, echo_string.c_str(), echo_string.size(), 0, 
                   (struct sockaddr*)&client, len);
        }
    }

    ~UdpServer()
    {
        if(sockfd_ > 0)
            close(sockfd_);
    }

private:
    int sockfd_;
    std::string ip_;
    uint16_t port_;
    bool isrunning_;
};

3.2 代码深入解读:套接字的创建与绑定

socket()调用解析:

cpp 复制代码
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
  • AF_INET:指定使用IPv4协议族(Address Family)

  • SOCK_DGRAM:指定套接字类型为数据报套接字,这是UDP的标志

  • 0:自动选择协议,对于SOCK_DGRAM就是UDP

socket()返回的是一个文件描述符,在Linux中一切皆文件,网络套接字也不例外。这个描述符将贯穿整个通信过程。

地址结构初始化:

cpp 复制代码
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(ip_.c_str());
local.sin_port = htons(port_);

sockaddr_in是IPv4专用的地址结构,使用前必须用bzero清零,这是一个好习惯,可以避免残留数据导致的奇怪问题。

字节序转换的学问:

  • inet_addr():将点分十进制IP字符串(如"192.168.1.1")转换为网络字节序的32位整数

  • htons():Host TO Network Short,将16位端口号从主机字节序转换为网络字节序

为什么需要字节序转换?因为不同CPU架构的字节序可能不同------x86是小端序,而网络协议规定使用大端序(网络字节序)。inet_addrhtons帮我们解决了这个跨平台兼容性问题。

bind()调用:

cpp 复制代码
bind(sockfd_, (struct sockaddr*)&local, sizeof(local));

bind将套接字与特定IP和端口关联。对于服务端,这是必须的------客户端需要知道往哪个端口发送数据。IP使用0.0.0.0表示监听本机所有网络接口,这意味着无论客户端从哪个网卡发来数据,服务端都能收到。

3.3 数据收发的核心循环

recvfrom()详解:

cpp 复制代码
ssize_t n = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, 
                     (struct sockaddr*)&client, &len);

参数说明:

  • sockfd_:已绑定的套接字描述符

  • inbuffer:接收缓冲区指针

  • sizeof(inbuffer) - 1:缓冲区大小减1,为字符串结尾的\0预留空间

  • 0:标志位,通常为0

  • &client:用于接收发送方地址信息的结构体

  • &len:地址结构体的长度,注意这是一个值-结果参数

recvfrom会将发送方的地址信息填入client结构体,这样我们就能知道数据来自哪里,也可以用这个地址将响应发回去。

sendto()详解:

cpp 复制代码
sendto(sockfd_, echo_string.c_str(), echo_string.size(), 0, 
       (struct sockaddr*)&client, len);

与recvfrom对称,sendto需要指定目标地址。这里我们使用recvfrom返回的client地址,实现"从哪来回哪去"的应答模式。

3.4 回调函数的设计思想

服务端的Run()方法接受一个func_t类型的函数对象:

cpp 复制代码
typedef std::function<const std::string(const std::string&)> func_t;

这使用了C++11的std::function,它可以包装普通函数、lambda表达式、函数对象等。这种设计将数据处理逻辑从网络框架中解耦出来,符合开闭原则------当需要不同的业务逻辑时,只需传入不同的回调函数,无需修改UdpServer类。

3.5 服务端入口程序

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

const std::string Handler(const std::string& str)
{
    std::string ret = "Server get a message: ";
    ret += str;
    std::cout << ret << std::endl;
    return ret;
}

void Usage(std::string str)
{
    std::cout << "\n\tUsage: " << str << " port[1024+]\n" << std::endl; 
}

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

    uint16_t port = std::stoi(argv[1]);
    std::unique_ptr<UdpServer> svr(new UdpServer(port));
    svr->Init();
    svr->Run(Handler);

    return 0;
}

Handler函数实现了最简单的回显逻辑:收到什么就返回什么,只是加了个前缀。实际项目中,这里可以替换为数据库操作、业务计算等复杂逻辑。

使用std::unique_ptr管理UdpServer对象的生命周期,体现了现代C++的RAII思想。

四、UDP客户端完整实现

客户端的结构比服务端简单很多,因为不需要bind,也不需要处理多客户端。

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

void Usage(std::string str)
{
    std::cout << "\n\tUsage: " << str << " serverip serverport\n" << std::endl; 
}

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

    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    // 准备服务端地址结构
    struct sockaddr_in server;
    bzero(&server, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr(serverip.c_str());
    server.sin_port = htons(serverport);
    socklen_t len = sizeof(server);
    
    // 创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cout << "socket create error" << std::endl;
        return 1;
    }

    std::string message;
    char buffer[1024];
    while(true)
    {
        // 从终端读取用户输入
        std::cout << "Please Enter@ ";
        std::getline(std::cin, message);

        // 发送数据
        sendto(sockfd, message.c_str(), message.size(), 0, 
               (struct sockaddr*)&server, len);

        // 接收服务端响应
        struct sockaddr_in temp;
        socklen_t len1 = sizeof(temp);
        ssize_t s = recvfrom(sockfd, buffer, 1023, 0, 
                            (struct sockaddr*)&temp, &len1);
        if(s > 0)
        {
            buffer[s] = 0;
            std::cout << buffer << std::endl;
        }
    }

    close(sockfd);
    return 0;
}

客户端编程要点:

  • 服务端地址必须在发送前设置好,这个地址在整个通信过程中保持不变

  • 客户端没有调用bind(),操作系统会随机分配一个空闲端口

  • 发送和接收交替进行,形成"请求-响应"模式

  • recvfrom的temp参数会接收到服务端的响应地址,在本场景中这个地址应该与server一致

如果本文对你有帮助,感谢点赞收藏。有任何问题也可以在评论区讨论交流。

相关推荐
剑锋所指,所向披靡!1 小时前
计算机网络的数据链路层
网络·计算机网络
如君愿2 小时前
考研复习 Day 35 | 习题--计算机网络 第七章 网络安全(上)、数据结构 排序算法(上)
数据结构·计算机网络·考研·课后习题
MandalaO_O2 小时前
Web 开发:计算机网络知识梳理
前端·网络·计算机网络
xiaxiaoli_20132 小时前
自己写了个 OpenWrt 设备监控 + 静态 IP 立即生效的 Web UI,分享一下
网络协议·tcp/ip·ui
艾莉丝努力练剑2 小时前
【Linux网络】Linux 网络编程:应用层自定义协议与序列化(3):网络计算器实现和守护进程
linux·运维·服务器·网络·c++·计算机网络·安全
@encryption2 小时前
计算机网络 --- RSTP,MSTP
服务器·网络·计算机网络
派大星的日常2 小时前
Java项目使用webSocket给前端推送数据(Java项目使用WebSocket接口给前端传输数据,通道连接未关闭,但是没有数据返回)
网络·websocket·网络协议
Howrun7772 小时前
从公钥密码学到 HTTPS:一文读懂数字证书与信任链条
网络协议·http·https
一只小白0002 小时前
一篇讲清 HTTP / HTTPS / DNS
网络·网络协议·http