计算机网络 -- 网络编程基础

一 学习准备

1.1 IP地址

在 前文中我们提到过: IP 是全球网络的基础 ,使用 IP 地址来标识公网环境下主机的唯一性,我们可以根据 目的IP地址 进行跨路由器的远端通信。

但是我们也提到了,通过 IP 地址,只能寻找到目标主机,难道我们的网络通信,是主机与主机之间互相通信吗?显然不是这样的。

我们要让主机接收到任务,并且还需要解包执行任务,主机中什么事物能做这一步呢?

答案是,进程。

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

在通过 IP 地址定位到 目标主机后,我们通过端口号,定位到需要进行通信的进程。

1.2 端口号

端口号 是一个用于标识网络进程唯一性的标识符,是一个 2 字节的整数,取值范围为 [0, 65535],可以通过 端口号 定位主机中的目标进程。

大家是不是感觉很熟悉,将信息从主机 A 中的进程 A 发送至主机 B 中的 进程 B,这不就是 进程间通信 吗?之前学习的 进程间通信 是通过 匿名管道、命名管道、共享内存 等方式实现,而如今的 进程间通信 则是通过 网络传输 的方式实现。

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

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

1.3 端口号与进程PID

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

  • 进程 PID 隶属于操作系统中的进程管理 ,如果在网络中使用 PID,会导致网络标准中被迫中引入进程管理相关概念(进程管理与网络强耦合)。
  • 进程管理 属于 OS 内部中的功能,OS 可以有很多标准,但网络标准只能有一套,在网络中直接使用 PID 无法确保网络标准的统一性。
  • 并不是所有的进程都需要进行网络通信(如单机主机),如果端口号、PID 都使用同一个解决方案,无疑会影响网络管理的效率

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

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

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

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

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

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

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

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

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

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

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

1.4 传输层协议

关于网络的层状结构,我们初级程序员应该主要关注传输层和应用层,因为其它层次太过底层,我们接触不了,更改不了,但是却可以在应用层和传输层借用系统的接口,从而编写程序。

主流的传输层协议有两个:TCPUDP

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

TCP 协议:传输控制协议

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

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

UDP 协议:用户数据协议

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

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

关于 可靠性

TCP 的可靠传输并不意味着它可以将数据百分百递达,而是说它在数据传输过程中,如果发生了传输失败的情况,它会通过自己独特的机制,重新发送数据,确保对端百分百能收到数据。

至于 UDP 就不一样,数据发出后,如果失败了,也不会进行重传,好在 UDP 面向数据报,并且没有很多复杂的机制,所以传输速度很快。

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

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

(你无敌了,孩子)

1.5.网络字节序

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

预备知识

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

如果将数据的高权值存放在内存的低地址处,低权值存放在高地址处,此时就称为 大端字节序.

反之则称为 小端字节序 ,这两种字节序没有好坏之分,只是系统设计者的使用习惯问题,比如VS2022在存储数据时,采用的就是 小端字节序 方案.

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

如果你是网络标准的设计者,你会如何解决?

解决方案1:数据发送前,给报文中添加大小端的标记字段,待数据递达后,对端在根据标志位进行解读,再进行转换。

这个方案实现起来不太方便,并且给每一个报文都添加标记字段这个行为比较浪费。

解决方案2:书同文,车同轨,直接统一标准。 这种解决方案就很彻底了,直接从根源上解决问题,也更方便。

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

发送数据时,将 主机字节序 转化为 网络字节序 ,接收到数据后,再转回 主机字节序 就好了,完美解决不同机器中的大小端差异,同时,每个主机根据自己的大小端存储策略,都应该有对应的转换函数,

在发送/接收时,调用库函数进行转换即可。

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

// 主机字节序转网络字节序
uint32_t htonl(uint32_t hostlong); // l 表示32位长整数
uint32_t htons(uint32_t hostshort); // s 表示16位短整数

// 网络字节序转主机字节序
uint32_t ntohl(uint32_t netlong); // l 表示32位长整数
uint32_t ntohs(uint32_t netshort); // s 表示16位短整数

二 网络的基本通信 -- 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);

等等,上面既然出现了文件描述符这几个字,那么,网络在linux中到底是什么呢?

网络在linux中其实就是个文件,网络通信其实就是服用了文件描述符的解决方案,但肯定有其独特的接口,我们继续讲解。

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

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

2.2.sockaddr 结构体

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

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

  • 可以根据 16 位地址类型,判断是网络通信,还是本地通信

  • 在进行网络通信时,需要提供 IP 地址、端口号 等网络通信必备项,本地通信只需要提供一个路径名,通过文件读写的方式进行通信(类似于命名管道)

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

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

UDP网络程序

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

三 字符串回响

3.1 核心功能

分别实现 客户端与服务端.

客户端向服务端发送消息

服务端收到消息后 回响(向客户端发出)给客户端,有点类似于 echo 指令。

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

3.2 程序结构

注意,这里我们的客户端和服务端,分别是两个不同的进程,以此来模拟真正的网络通信。

因此,我们的程序应该最少分 两个源文件组成,但是这里我们为了更加深入的写入 ,程序由 server.hppserver.cc两个文件组成服务端,``client.hppclient.cc 组成客户端。

3.3 基本框架

3.3.1 server.hpp 文件框架

创建 server.hpp 服务端头文件

在这里,我们只需写一个初始化和启动文件,因为服务端是几乎永不关闭的,

  1. 服务端的初始化是必须的。
  2. 在服务器启动完毕之后,应该一直循环执行任务,我们把循环执行的任务放在启动函数中,就可以模拟服务器,一直接收文件了。

因此,只需要两个额外函数,文件的框架就基本上完善了

cpp 复制代码
#pragma once
#include<iostream>

namespace My_server{

    class server
    {
    private:
        /* data */
    public:
       //构造函数
        server() {
        }
        //析构函数
        ~server(){
        }
        //初始化服务器
        void InitServer(){
        }
        //启动服务器
        void StartServer(){
        }
    };
    
}

3.3.2 server.cc 文件框架

根据上文讲到的 server.hpp 文件,我们知道,程序只需要做两件事

  1. 初始化服务端
  2. 启动服务端
cpp 复制代码
#include<memory>
#include"server.hpp"

using namespace My_server;

int main()
{
    std::unique_ptr<server> msvr(new server());

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

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

    return 0;
}

3.3.3 client.hpp 文件框架

在客户端程序中,我们本来应该模拟一个通话进程,这里我建议和服务端保持一致,让客户端一直打开,然后一直进行通信。

因为这样简单,方便,也不脱离我们项目的核心功能。

cpp 复制代码
#pragma once

#include<iostream>

namespace My_client{
    
    class client{
      private:
      /* data */
      public:
      //构造函数
      client(){
      }
      //析构函数
      ~client(){
      }
      // 初始化客户端
      void InitClient() {
      }
      // 启动客户端
      void StartClient() {
      }
    };
}

3.3.4 client.cc 文件框架

这个就没啥好说的了吧,速度写完,我要玩太刀。

3.3.5 Makefile文件

直接上代码,别问,问就是赶时间。

cpp 复制代码
.PHONY:all
all:server client

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

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

.PHONY:clean
clean:
	rm -rf server client

3.4 服务端

3.4.1 创建套接字(打开文件描述符)

创建套接字使用 socket 函数

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

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

参数解读

  • domain 创建套接字用于哪种通信(网络/本地)
  • type 选择数据传输类型(流式/数据报)
  • protocol 选择协议类型(支持根据参数2自动推导)

返回值:创建成功后,返回套接字(文件描述符,int类型),失败返回 -1

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

第一个参数 可选 AF_INET6 基于 IPv6 标准

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

cpp 复制代码
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstring>
#include <cerrno>
#include <cstdlib>


namespace My_server{

    // 自己规定错误码
    enum
    {
        SOCKET_ERR = 1
    };
    class server
    {
    private:
        /* data */
       int _sock; // 套接字
    public:
       //构造函数
        server() {
        }
        //析构函数
        ~server(){
        }
        //初始化服务器
        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(){
        }
    };
    
}

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

3.4.2 绑定IP地址和端口号

注意: 我这里的服务器是 云服务器,绑定 IP 地址这个操作后面需要修改。

使用 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 结构体地址
  • addrlen 结构体的大小

返回值:成功返回 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)];
};

首先来看看 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。

注意:

  • 需要把主机序列转换为网络序列,可以使用 htons 函数。
  • 需要把点分十进制的字符串,转换为无符号短整数,可以使用 inet_addr 函数,这个函数在进行转换的同时,会将主机序列转换为网络序列
  • 绑定IP地址和端口号这个行为并非直接绑定到当前主机中,而是在当前程序中,将创建的 socket 套接字,与目标IP地址与端口号进行绑定,当程序终止后,这个绑定关系也会随之消失
cpp 复制代码
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <netinet/in.h>
#include <arpa/inet.h>


namespace My_server{

    // 自己规定错误码
    enum
    {
        SOCKET_ERR = 1,
        BIND_ERR
    };

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

    class server
    {
    private:
        /* data */
        int _sock; // 套接字
        uint16_t _port; // 端口号
        std::string _ip; // IP地址(后面需要删除)

    public:
       //构造函数
        server(const std::string ip, const uint16_t port = default_port) 
        :_port(port)
        ,_ip(ip)
        {
        }
        //析构函数
        ~server(){
        }
        //初始化服务器
        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); // 主机序列转为网络序列
            //inet_addr 能将 点分十进制的字符串 转换为 短整数 再转换为网络序列
            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(){
        }
    };
    
}

3.4.3 server.cc 文件的更改

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

using namespace My_server;

int main()
{
    std::unique_ptr<server> msvr(new server("1.111.323.455"));

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

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

    return 0;
}

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

修改代码

  • 云服务器中不需要明确 IP 地址
  • 构造时也无需传入 IP 地址
  • 绑定 IP 地址时选择 INADDR_ANY,表示绑定任何可用的 IP 地址

更改后的 server.hpp 文件和 server.cc 文件

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


namespace My_server{

    // 自己规定错误码
    enum
    {
        SOCKET_ERR = 1,
        BIND_ERR
    };

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

    class server
    {
    private:
        /* data */
        int _sock; // 套接字
        uint16_t _port; // 端口号
        //std::string _ip; // IP地址(后面需要删除)

    public:
       //构造函数
        server( const uint16_t port = default_port) 
        :_port(port)
        {
        }
        //析构函数
        ~server(){
        }
        //初始化服务器
        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); // 主机序列转为网络序列
            //inet_addr 能将 点分十进制的字符串 转换为 短整数 再转换为网络序列
            //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(){
        }
    };
    
}
cpp 复制代码
#include<memory>
#include"server.hpp"

using namespace My_server;

int main()
{
    std::unique_ptr<server> msvr(new server());

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

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

    return 0;
}

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

3.4.4 启动服务器

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

读取信息使用 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 缓冲区的大小
  • flags 读取方式(阻塞/非阻塞)

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

  • src_addr 输入输出型参数,对端(这里指客户端)主机的 sockaddr 结构体,包含了对端的 IP****地址端口号.
  • addrlen 输入输出型参数,对端主机的 sockaddr 结构体大小.
    这个输入输出型参数就类似于送礼时留下自己的信息,待对方还礼时可以知道还给谁,接收信息也是如此,当服务器获取客户端的 sockaddr 结构体信息后,同样可以给客户端发送信息,双方就可以愉快的进行通信了.

返回值:成功返回实际读取的字节数,失败返回 -1

接收消息步骤:

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

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

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

StartServer() 函数 --- 位于 server.hpp 服务器源文件中的 server

cpp 复制代码
 //启动服务器
        void StartServer(){
          
          //服务器是永不停息的,所以需要使用一个 while 死循环
          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.回响给客户端
              // ...
          }
        }

3.4.5 发送信息

发送信息使用 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 的一模一样

  • sockfd 使用哪个套接字进行发送
  • buf 发送数据存放缓冲区
  • len 缓冲区的大小
  • flags 发送方式(阻塞/非阻塞)
  • src_addr 对端主机的 sockaddr 结构体,包含了对端的 IP****地址端口号
  • addrlen 对端主机的 sockaddr 结构体大小

返回值:成功返回实际发送的字节数,失败返回 -1。

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

err.hpp 头文件

cpp 复制代码
#pragma once

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

完整的服务端启动函数

cpp 复制代码
 //启动服务器
        void StartServer(){
          
          //服务器是永不停息的,所以需要使用一个 while 死循环
          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.回响给客户端
              // ...

               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;
             }
          }
        }

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

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

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

cpp 复制代码
netstat -nlup

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

0.0.0.0 表示任意IP地址

3.5 客户端

3.5.1 指定IP地址和端口号

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

client.hpp 客户端头文件

cpp 复制代码
#pragma once

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

namespace My_client{
    
    class client{
      private:
      /* data */
      std::string server_ip;//服务端 IP 地址
      uint16_t server_port;//服务器端口号

      public:
      //构造函数
      client(const std::string& ip,uint16_t port)
      :server_ip(ip)
      ,server_port(port)
      {}
      //析构函数
      ~client(){
      }
      // 初始化客户端
      void InitClient() {
      }
      // 启动客户端
      void StartClient() {
      }
    };
}

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

更新后的错误码:

cpp 复制代码
#pragma once

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

client.cc 客户端源文件

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


using namespace My_client;

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

int main(int argc,char *argv[]){

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

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

    std::unique_ptr<client> mcit(new client(ip,port));
    //初始化客户端
    mcit->InitClient();
    //启动客户端
    mcit->StartClient();

    return 0;
}

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

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

3.5.2 客户端的初始化

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

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

毕竟在现实生活中,一般客户端只有一个,而客户端有成百上千个。
为什么服务器要自己手动指定端口号,并进行绑定? 这是因为服务器的端口不能随意改变,并且这是要公布给广大客户端看的,同一家公司在部署服务时,会对端口号的使用情况进行管理,可以直接避免端口号冲突。

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

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

client.hpp 客户端头文件

cpp 复制代码
#pragma once

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

namespace My_client{
    
    class client{
      private:
      /* data */
      std::string server_ip;//服务端 IP 地址
      uint16_t server_port;//服务器端口号
      int _sock;
      struct sockaddr_in _svr;
      public:
      //构造函数
      client(const std::string& ip,uint16_t port)
      :server_ip(ip)
      ,server_port(port)
      {}
      //析构函数
      ~client(){
      }
      // 初始化客户端
      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;
          // 绑定服务器IP地址
         _svr.sin_addr.s_addr=inet_addr(server_ip.c_str());
         //绑定服务器端口号
         _svr.sin_port=htons(server_port);
      }
      // 启动客户端
      void StartClient() {
      }
    };
}

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

3.8.启动客户端

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

发送消息步骤

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

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

接收消息步骤:

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

注:同服务器一样,客户端这里我们设置的也需要不断运行

StartClient() 函数 --- 位于 client.hpp 中的 client

cpp 复制代码
 // 启动客户端
      void StartClient() {
       
       char buff[1024];
        // 1. 启动客户端
        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;
        }

        //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);
      }
      }

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

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

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

相关推荐
大丈夫立于天地间8 小时前
ISIS协议中的数据库同步
运维·网络·信息与通信
Dream Algorithm8 小时前
路由器的 WAN(广域网)口 和 LAN(局域网)口
网络·智能路由器
IT猿手8 小时前
基于CNN-LSTM的深度Q网络(Deep Q-Network,DQN)求解移动机器人路径规划,MATLAB代码
网络·cnn·lstm
吴盐煮_8 小时前
使用UDP建立连接,会存在什么问题?
网络·网络协议·udp
hyshhhh9 小时前
【算法岗面试题】深度学习中如何防止过拟合?
网络·人工智能·深度学习·神经网络·算法·计算机视觉
Hellc0079 小时前
轮询、WebSocket 和 SSE:实时通信技术全面指南(含C#实现)
网络
xujiangyan_10 小时前
nginx的反向代理和负载均衡
服务器·网络·nginx
GalaxyPokemon10 小时前
Muduo网络库实现 [十] - EventLoopThreadPool模块
linux·服务器·网络·c++
忆源11 小时前
SOME/IP-SD -- 协议英文原文讲解9(ERROR处理)
网络·网络协议·tcp/ip