【Linux网络】Socket编程UDP


本篇主要是写一个基于 UDP ( User Datagram Protocol ⽤户数据报协议)的简单的回显服务器和客户端代码,Echo server。

1.Server端

1.1 初始化

1.1.1 创建套接字

首先我们要创建套接字,调用函数socket,成功返回一个文件描述符,失败返回-1.

  • 第一个参数就是要做什么通信,有很多选项,进行网络通信就传AF_INET
  • 第二个参数要创建的套接字类型,也有很多,传SOCK_DGRAM
  • 第三个参数是要设定的协议类型,其实这里AF_INET和SOCK_DGRAM就已经能说明是UDP协议了,这个参数设为0。
cpp 复制代码
//UdpServer.hpp文件
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include "MyLog.hpp"

using namespace MyLog;
class UdpServer
{
public:
    UdpServer()
        : _sockfd(-1)
    {
    }

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

    ~UdpServer() {}

private:
    int _sockfd;
};

创建套接字只是相当于打开了网络文件,将来要收发消息时,别人会知道我的ip和端口号吗?所以我们还要对socket进行绑定。

1.1.2 绑定socket信息

主要就是绑定ip和端口,需要用到的函数是bind。成功返回0,失败返回-1.

第一个参数就是前面创建的sockfd,第二个参数是一个结构体,第三个参数是这个结构体的大小

sockaddr结构:

system V标准用于本地进程间通信,详细介绍在: 【Linux】system V共享内存
posix标准用于网络通信,网络通信的本质其实就是进程间通信,这个标准也可以用于进行本地通信。
未来在通信的时候我们会有网络socket、本地socket、原始socket(此处不做介绍),网络socket就用于网络通信,本地socket用于本地通。
因为socket有很多种类,来满足不同的场景,所以socket未来的接口也会不同的规范。
在通信时我们要把数据发给别人,别人也要发给我,所以发出去的数据里一定会有自己的端口号和IP地址,放在一个结构体里,这种 结构 一般用于网络通信, 叫 sockaddr_in
本地套接字通信叫做unix域间通信,原理就是使用套接字接口,A进程和B进程把指定路径下的文件打开,不就是管道嘛,它的结构就叫做 sockaddr_un

要进行网络通信就传sockaddr_in , 要进行本地通信就传sockaddr_un
但是socket的设计者只想提供一种通信接口,所以又有了一个结构叫 sockaddr。这是一个通用的接口,规定不管是 sockaddr_in还是sockaddr_un,前面都要有16位地址类型,AF_INET就是进行网络通信,AF_UNIX就是进行本地通信

sockaddr也有16位地址类型,以后我们进行本地通信就创建 sockaddr_in结构,网络通信就创建sockaddr_un结构,但是传参的时候只能传sockaddr 结构,直接强转就行了。在函数内部会自行区分是网络通信还是本地通信。
所以在绑定socket之前,我们要先填写sockaddr_in结构体的信息。如下是sockaddr_in结构体的具体内容。

sin_port是端口号,sin_addr是IP地址,下面的sin_zero就是填充字段,为了保证结构体大小是固定的,地址类型就是__SOCKADDR_COMMON,就是协议家族,具体如下。

cpp 复制代码
typedef unsigned short int sa_family_t; //就是一个无符号短整数
cpp 复制代码
//in_port_t其实就是uint16_t
/* Type to represent a port.  */
typedef uint16_t in_port_t;

//in_addr里面就是一个in_addr_t的变量,in_addr_t就是uint32_t 
/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
  {
    in_addr_t s_addr;
  };

所以其实这个sockaddr_in里就是有三个整数而已。

回到代码,在填写sockaddr_in结构体的信息之前要清0,这里用到的函数是bzero

cpp 复制代码
    void Init()
    {
        // 1.创建套接字
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd == -1)
        {
            LOG(LogLevel::FATAL) << "创建套接字失败";
            exit(1);
        }
        LOG(LogLevel::INFO) << "creat socket success, sockfd: " << _sockfd;

        //绑定socket信息,IP和端口
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //先清0
        local.sin_family = AF_INET;
        local.sin_port =  // ?
        local.sin_addr.s_addr = //?
    }

将来我们发数据的时候,还要把端口号也要发给别人,这就说明ip和端口信息也要发送到网络,所以我们需要将本地格式转网络序列
TCP/IP协议规定,⽹络数据流应 采⽤⼤端字节序 ,即低地址⾼字节。不管这台主机是⼤端机还是⼩端机, 都会按照这个TCP/IP规定的⽹络字节序来发送/接收数据; 如果当前发送主机是⼩端, 就需要先将数据转成⼤端; 否则就忽略, 直接发送即可。

为使⽹络程序具有可移植性,使同样的C代码在⼤端和⼩端计算机上编译后都能正常运⾏,可以调⽤以下库函数做 ⽹络字节序和主机字节序的转换
下面是相关函数的接口。

  • h表示host,n表示network,l表示32位长整数,s表示16位短整数 ,例如 htonl 表⽰将 32 位的⻓整数从主机字节序转换为⽹络字节序
cpp 复制代码
local.sin_port = htons(_port); 

private:
    uint16_t _port; // 端口号

ip地址也是如此,前面说过IP是uint32_t 类型的,是4字节风格的IP地址,而服务器里保存的IP地址是字符串风格的地址,点分十进制,所以我们需要将ip转为4字节,还要将4字节转为网络序列,这两个步骤不需要我们自己完成,有一个函数叫inet_addr,可以完成这两个步骤。

而且这个函数的返回值类型就是in_addr_t,也就是前面说过的ip地址结构体里的变量的类型。

cpp 复制代码
//UdpServer.hpp文件
#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 "MyLog.hpp"

using namespace MyLog;
class UdpServer
{
public:
    UdpServer(std::string &ip, uint16_t &port)
        : _sockfd(-1),
          _port(port),
          _ip(ip)
    {
    }

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

        // 2.绑定socket信息,IP和端口
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); // 先清0
        local.sin_family = AF_INET;
        local.sin_port = htons(_port); // 主机序列转网络序列
        // ip转4字节,4字节转网络序列
        local.sin_addr.s_addr = inet_addr(_ip.c_str());
    }

    ~UdpServer() {}

private:
    int _sockfd;
    uint16_t _port; // 端口号
    std::string _ip;
};

sockaddr结构填写完成后,就可以绑定了,绑定时要对sockaddr_in结构进行强转。

cpp 复制代码
    void Init()
    {
        // 1.创建套接字
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd == -1)
        {
            LOG(LogLevel::FATAL) << "创建套接字失败";
            exit(1);
        }
        LOG(LogLevel::INFO) << "creat socket success, sockfd: " << _sockfd;

        // 2.绑定socket信息,IP和端口
        // 2.1 填写sockaddr_in结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); // 先清0
        local.sin_family = AF_INET;
        local.sin_port = htons(_port); // 主机序列转网络序列
        // ip转4字节,4字节转网络序列
        local.sin_addr.s_addr = inet_addr(_ip.c_str());
        // 2.2 绑定
        int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind失败";
            exit(2);
        }
        LOG(LogLevel::INFO) << "bind success";
    }

1.2 启动

1.2.1 收消息

一般软件运行起来后就是死循环

cpp 复制代码
public:
    void Start()
    {
        if (_isrunning)
            return; // 不要重复启动
        _isrunning = true;
        while(true)
        {
            
        }
    }
private:
    int _sockfd;
    uint16_t _port; // 端口号
    std::string _ip;
    bool _isrunning;

服务器要先接受数据,再发送消息。

接收消息要用到函数recvfrom,成功返回收到的字节数,失败返回-1。

第一个参数就是套接字;第二个参数是接收数据的缓冲区;第三个参数是这个缓冲区的大小;第四个参数是阻塞IO或者非阻塞IO,阻塞式IO就是没有收到数据的时候进程会一直等,就等同于scanf,这里设置成0,表示默认非阻塞;第四个参数用于存储发送数据的对端的ip和端口号(即记录寄件人地址);第四个参数是src_addr的实际大小。

cpp 复制代码
    void Start()
    {
        if (_isrunning)
            return; // 不要重复启动
        _isrunning = true;
        while (true)
        {
            char buffer[1024];
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer); // 大小要是socklen_t类型
            // 1.收消息
            size_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                buffer[n] = 0; // 手动添加字符串结束标志
                LOG(LogLevel::DEBUG) << "buffer:" << buffer;
        
            }
        }
    }

1.2.2 发消息

发消息用到的接口是sendto

参数和收消息接口类似:第一个参数就是套接字;第二个参数是发消息的缓冲区;第三个参数是这个缓冲区的大小;第四个参数是阻塞IO或者非阻塞IO,这里设置成0,表示默认非阻塞;第四个参数用于存储发送数据的对端的ip和端口号(即给谁发);第四个参数是src_addr的实际大小。

cpp 复制代码
    void Start()
    {
        if (_isrunning)
            return; // 不要重复启动
        _isrunning = true;
        while (true)
        {
            char buffer[1024];
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer); // 大小要是socklen_t类型
            // 1.收消息
            size_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                buffer[n] = 0; // 手动添加字符串结束标志
                LOG(LogLevel::DEBUG) << "buffer:" << buffer;
                // 2.发消息
                std::string echo_string = "server echo# ";
                echo_string += buffer;
                sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&peer, len);
            }
        }
    }

1.3 源文件

cpp 复制代码
//UdpServer.cc文件
#include "UdpServer.hpp"
#include <memory>

// 格式为:udpserver ip port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        std::cout << "Usage:" << argv[0] << " ip port" << std::endl;
        return 1;
    }
    std::string ip = argv[1];
    uint16_t port = std::stoi(argv[2]);
    Refresh_Log_To_Console();
    std::unique_ptr<UdpServer> server = std::make_unique<UdpServer>(ip, port);
    server->Init();
    server->Start();
    return 0;
}

2.Client端

Client端就不做封装了,直接在.cc文件里写。

客户端要访问服务器,在日常生活中网络请求都是由客户端发起,服务器处理。客户端必须知道目标服务器的套接字信息(ip+port),所以客户端启动的时候要传服务器的ip和port,格式应该为udpclient server_ip server_port

但是客户端怎么知道服务器的ip和端口号呢?客户端和服务器是一家公司写的,客户端内部以一定形式内置了服务器的ip和端口号。

cpp 复制代码
// UdpClient.cc文件
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

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

    return 0;
}

客户端也要创建套接字,调用函数socket,成功返回一个文件描述符,失败返回-1。

cpp 复制代码
// 格式为:udpclient server_ip server_port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        std::cout << "Usage:" << argv[0] << " server_ip server_port" << std::endl;
        return 1;
    }
    std::string ip = argv[1];
    uint16_t port = std::stoi(argv[2]);

    // 创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        std::cout << "client创建套接字失败" << std::endl;
        return 2;
    }

    return 0;
}

客户端要不要绑定socket信息呢?要绑定。要不要显示的绑定呢?不要。

因为客户端首次发送消息,OS会自动给client进行bind,OS知道ip,而端口号采用随机端口号的方式,因为一个端口号只能被一个进程bind,采用随机端口号是为了避免client端口号冲突

所以,客户端的端口号是多少并不重要,只要是唯一的就行;服务器端需要显示的bind,因为服务端会被很多个客户端访问,服务端的IP和端口必须是众所周知并且不能轻易改变的,一旦容易改变,客户端可能就访问不到了,就像生活中110或120这样的特殊电话号码是不能轻易改变的,但我们自己使用的手机号码可以任意换。

**客户端要先发送消息,再接收消息。**我们往服务器发消息,需要填写服务器信息。

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

// 格式为:udpclient server_ip server_port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        std::cout << "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.创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        std::cout << "client创建套接字失败" << std::endl;
        return 2;
    }
    // 2.bind,但不需要显示的bind

    // 填写服务器信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server)); // 先请0
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);   // 主机转网络
    server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // ip转4字节,4字节转网络序列

    while (true)
    {
        // 3.发消息
        std::string input;
        std::cout << "Please Enter# ";
        std::getline(std::cin, input); // 从输入流获取到input里
        sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr *)&server, sizeof(server));
    }

    return 0;
}

客户端收消息的时候也要知道是哪个服务器发的,因为一个客户端可能访问多个服务器。

cpp 复制代码
    while (true)
    {
        // 3.发消息
        std::string input;
        std::cout << "Please Enter# ";
        std::getline(std::cin, input); // 从输入流获取到input里
        sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr *)&server, sizeof(server));

        // 4.收消息
        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;
        }
    }

3.绑定IP问题

我们用ifconfig命令查询,会有如下显示。

172.17.55.42是这台机器的内网ip,暂时不管,而这个127.0.0.1,是本地环回

本地环回:要求客户端和服务器必须在一台机器上,表明我们是本地通信,客户端发送数据不会推送到网络,而是在OS内部转一圈直接交给对应的服务器端。

这种ip地址经常用来进行网络代码的测试,因为如果把代码直接扔到网络里,有可能是因为网不好导致结果不对,所以我们先用这个ip先对代码进行测试。

bash 复制代码
#Makefile文件
.PHONY:all
all:udpclient udpserver

udpclient:UdpClient.cc
	g++ -o $@ $^ -std=c++17
udpserver:UdpServer.cc
	g++ -o $@ $^ -std=c++17

.PHONY:clean
clean:
	rm -f udpclient udpserver

编译之后运行两份代码。

然后输入一些内容。

绑定内网IP也可以。

但是不可以直接绑定公网ip,因为公网IP并没有配置到我们的IP上。

并且如果服务器绑定内网IP,而客户端拿着本地环回ip访问,数据就会发不出去。

反过来也不行。

  • netstat -anup:查网络连接状态,a表示所有,n表示数字化,u表示查看udp协议,p表示把进程信息也显示出来

客户端想访问ip为127.0.0.1 端口号为8080的地址信息,根本就不存在

所以如果我们显式的bind,client未来访问的时候,就必须使用server端bind的地址信息。

公网IP不让邦,显式bind的话client又不好访问,所以服务器端不建议显式的bind,那怎么办呢?解决方法如下,把地址改为一个宏。

cpp 复制代码
//UdpServer.hpp文件UdpServer类里的Init函数内部

//local.sin_addr.s_addr = inet_addr(_ip.c_str());
local.sin_addr.s_addr = INADDR_ANY;

这个宏如下,其实就是0,设置成0后就可以ip任意bind,允许接收任何的信息。

所以,server端就不需要传ip,只需要传个端口号就行。

cpp 复制代码
// UdpServer.hpp文件
class UdpServer
{
public:
    UdpServer(uint16_t &port) // 不需要传ip
        : _sockfd(-1),
          _port(port),
          _isrunning(false)
    {
    }

    //...

private:
    int _sockfd;
    uint16_t _port; // 端口号
    //std::string _ip; // 不需要ip
    bool _isrunning;
};
cpp 复制代码
// UdpServer.cc文件
#include "UdpServer.hpp"
#include <memory>

// 格式为:udpserver port
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cout << "Usage:" << argv[0] << " port" << std::endl;
        return 1;
    }
  
    uint16_t port = std::stoi(argv[2]);
    Refresh_Log_To_Console();
    std::unique_ptr<UdpServer> server = std::make_unique<UdpServer>(port);
    
    server->Init();
    server->Start();
    return 0;
}

然后server端只需要绑定端口号,我们再用netstat查询一下网络连接状态。

现在就是显示的0.0.0.0:8080,就意味着用这台机器上的任意ip来访问server,他都可以向客户端发消息了。

前面是绑的本地环回,下面是绑的内网IP,都能给服务器发消息了。

但是公网IP不可以

server获取client的IP和port

通过上面的实验我们会发现在server端我们并不知道消息是谁发送的,所以需要获取一下client的IP和port。client的信息就存储在recvfrom函数的第五个参数里,就是peer。

  • 对于端口号,因为此是端口号是从网络里拿的,所以是网络序列,我们需要将网络序列转主机序列。
  • 对于ip,我们需要的是点分十进制的字符串风格的IP,而从网络里拿到的是4字节网络风格的IP,所以需要用函数inet_ntoa进行转换。
cpp 复制代码
// UdpServer.hpp文件

    void Start()
    {
        if (_isrunning)
            return; // 不要重复启动
        _isrunning = true;
        while (true)
        {
            char buffer[1024];
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer); // 大小要是socklen_t类型
            // 1.收消息
            size_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                buffer[n] = 0;                                  // 手动添加字符串结束标志
                uint16_t peer_port = ntohs(peer.sin_port);      // 网络序列转主机序列
                std::string peer_ip = inet_ntoa(peer.sin_addr); // 4字节网络序列
                LOG(LogLevel::DEBUG) << "[" << peer_ip << ":" << peer_port << "]# " << buffer;

                // 2.发消息
                std::string echo_string = "server echo# ";
                echo_string += buffer;
                sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&peer, len);
            }
        }
    }

4.服务器回调函数

收到消息后,之前我们是把这个buffer消息打印一下,现在我们需要设计一个服务器的回调函数,让这个回调函数来对数据进行处理,处理完成后把结果返回来就行。有了回调函数,在初始化的时候就要要求传对应的回调方法。

cpp 复制代码
// UdpServer.hpp文件
#include <functional>
using namespace MyLog;
using func_t = std::function<std::string(const std::string&)>; // 参数和返回值都为string类型
class UdpServer
{
public:
    UdpServer(uint16_t &port, func_t func) // 初始化时要传回调方法
        : _sockfd(-1),
          _port(port),
          _isrunning(false),
          _func(func)
    {
    }

    void Start()
    {
        if (_isrunning)
            return; // 不要重复启动
        _isrunning = true;
        while (true)
        {
            char buffer[1024];
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer); // 大小要是socklen_t类型
            // 1.收消息
            size_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                buffer[n] = 0;                                  // 手动添加字符串结束标志
                uint16_t peer_port = ntohs(peer.sin_port);      // 网络序列转主机序列
                std::string peer_ip = inet_ntoa(peer.sin_addr); // 4字节网络序列              
                std::string result = _func(buffer); // 回调函数处理,并返回结果
                // 2.把结果发出去       
                sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr *)&peer, len);
            }
        }
    }

private:
    int _sockfd;
    uint16_t _port; // 端口号
    bool _isrunning;

    func_t _func; // 服务器的回调函数
};

下面有个回调方法的例子。

cpp 复制代码
// UdpServer.cc文件
#include "UdpServer.hpp"
#include <memory>

std::string DefaultHandler(const std::string &messages)
{
    std::string s = "***";
    s = s + messages + "***";
    return s;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cout << "Usage:" << argv[0] << " port" << std::endl;
        return 1;
    }
    uint16_t port = std::stoi(argv[1]);
    Refresh_Log_To_Console();
    // 初始化时传回调方法
    std::unique_ptr<UdpServer> server = std::make_unique<UdpServer>(port, DefaultHandler);
    server->Init();
    server->Start();
    return 0;
}

现在UdpServer只用来进行网络通信的,而怎么处理由上层决定。这就是简单的代码的层状结构。

本篇分享就到这里,我们下篇见~

相关推荐
AORO20254 小时前
北斗短报文终端是什么?有什么功能?你能用到吗?
大数据·网络·5g·智能手机·信息与通信
加油_Yeah4 小时前
pycharm 远程连接服务器&添加github copilot
运维·服务器·ide·pycharm·copilot
java_logo4 小时前
Docker 部署 MinerU 教程:打造你的本地 PDF 智能处理中心
linux·运维·人工智能·docker·ai·容器·aigc
硬核子牙5 小时前
gdb单步调试底层实现原理
linux
Dovis(誓平步青云)5 小时前
《剖析 Linux 文件系统:架构、原理与实战操作指南》
linux·运维·服务器
千百元5 小时前
centos查线程数
linux·运维·centos
---学无止境---5 小时前
Linux中基数树标签相关操作函数的实现
linux
张紫娃5 小时前
ipconfig详解
网络·智能路由器
or77iu_N5 小时前
Linux 解压安装(安装tomcat)
linux·运维·tomcat