了解“网络协议”

目录

认识"网络协议"

网络协议的概念

结构化数据的传输

序列化和反序列化

简易网络版计算器

封装套接字

规定协议

服务端代码

客户端代码


认识"网络协议"

网络协议的概念

网络协议是通信计算机双方必须共同遵从的一组约定,比如怎么建立连接、怎么互相识别等。后面简称为:"协议"。

协议,顾名思义,就是双方在交流或者通信时都需要遵守的一套已经明确制定好的规则。但这套规则的价值,远不止于"约束"本身,它更是实现高效、可靠和规模化协作的基石。

**首先,协议通过标准化消除了不确定性。**想象一下,如果双方没有事先约定好使用哪种语言,信息以何种格式排列、收到信息后该如何回应,那么通信将充满混乱和误解。协议就好似一张精密的施工图纸,确保了所有参与者对"如何建造一次成功的通信"有着完全一致的理解。

其次,协议是实现互操作性的关键。 在由不同制造商、使用不同技术构成的复杂系统中,协议是唯一的"通用语言"。正是得益于全球统一的TCP/IP协议,你手中的华为手机、苹果电脑和千里之外由思科设备搭建的服务器才能无缝对话。协议打破了技术壁垒,让异构互联成为可能。

总而言之,协议在网络通信中发挥着至关重要的作用,认识协议,了解协议是学习网络的重中之重。

除此之外,协议又分好多种,一些核心的协议,比如说:IP协议,HTTP/HTTPS协议,DNS协议(后面都会讲解)还有前面简单了解的UDP/TCP协议......诸如此,还有很多,不一一列举。

结构化数据的传输

通信双方在进行网络通信时:

  • 如果需要传输的数据是一个字符串,那么直接将这一个字符串发送到网络当中,此时对端也能从网络当中获取到这个字符串。
  • 但如果需要传输的是一些结构化的数据,此时就不能将这些数据一个个发送到网络当中。

以网络版计算器为例,客户端发送给服务端的每个请求,都必须包含左操作数、右操作数和操作符这类结构化数据。若将这些数据项分多次发送,服务端在接收后还需额外进行数据重组,这无疑增加了处理的复杂性。因此,更优的做法是客户端将数据打包后一次性发送,确保服务端每次都能收到一个完整的请求。常见的客户端打包方式主要有以下两种。

将结构化的数据组合成一个字符串

约定方案一:

  • 客户端发送类似"1+1"的字符串。
  • 这个字符串只能有两个操作数,并且操作数全是整数。
  • 两个操作数中必须要有一个字符表示运算符。
  • 字符串内不允许存在空格。

此时对于客户端想要发送的机构话的数据就组合成了一个字符串,然后将这个字符串发送到网络当中,此时服务端每次从网络当中获取到的就是这样一个已经约定好格式的字符串,然后服务端再以约定好的协议对字符串进行解析,此时服务端就能够从这个字符串当中提取出相应的结构化数据。

定制结构体+序列化和反序列化

约定方案二:

  • 定制结构体来表示需要交互的信息。
  • 发送数据时将这个结构体按照一个规则转换成网络标准数据格式,接收数据时再按照相同的规则把接收到的数据转化为结构体。
  • 这个过程叫做"序列化"和"反序列化"。

客户端可以定制一个结构体,将需要交互的信息定义到这个结构体当中。客户端发送数据时先对数据进行序列化,服务端接收到数据后再对其进行反序列化,此时服务端就能得到客户端发送过来的结构体,进而从该结构体当中提取出对应的信息。

无论我们采用方案一,还是方案二,还是其他的方案,只要保证,一端发送时构造的数据,在另一端能够正确的进行解析,就是ok的。这种约定就是应用层协议。

序列化和反序列化

序列化和反序列化:

  • 序列化是将对象的状态信息转换为可以存储或传输的形式(字节序列)的过程。
  • 反序列化是把字节序列恢复为对象的过程。

OSI七层模型中表示层的作用就是,实现设备固有数据格式和网络标准数据格式的转换。其中设备固有的数据格式指的是数据在应用层上的格式,而网络标准数据格式则指的是序列化之后可以进行网络传输的数据格式。

序列化和反序列化的目的

  • 在网络传输中,序列化的目的是为了方便网络数据的发送与接收,无论是何种杨hi是的数据,经过序列化后都会变为二进制序列,此时底层在进行网络数据传输时,就会统一看作都是二进制数据。
  • 序列化后的二进制序列只有在网络传输的时能够被底层识别,上层应用是无法识别序列化后的二进制序列,因此需要将从网络中获取到的数据进行反序列化,将二进制序列转化为应用层能够识别的数据格式。

因此我们可以认为网络通信和相应的服务是在不同层级的,在进行网络通信时,底层看到的都是序列化后生成的二进制序列的数据,而在进行业务处理时看到的则是反序列化后生成的可识别数据。对应的数据之间的转换,则需要对数据进行相应的序列化与反序列化。

简易网络版计算器

下面我们就自制定协议,然后实现一个简易的网络版本计算器。来直观感受一下协议。

封装套接字

首先我们先对套接字的一切函数进行封装,包括创建套接字,绑定,设置监听状态等等。对此类代码已很熟练,就直接给出,不在多说。

复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>


// 先创建 sockfd 文件描述符
// 绑定端口号 bind
// 设定监听模式 listen
// 接受请求
// 建立连接
// 最后 最好要关闭sockfd  close


// 定义了操作系统内核中等待处理的连接请求队列的最大长度   --》  listen函数相关
const int backlog = 10;

enum
{
    SockErr = 2,
    BindErr,
    ListenErr,
};

class Sock
{
public:
    Sock()
    {

    }
    ~Sock()
    {

    }
public:
    void Socket()
    {
        sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
        if(sockfd_ < 0)
        {
            std::cerr << "sockfd error" <<   strerror(errno) << "," << errno << std::endl;
            exit(SockErr);
        }
    }
    void Bind(uint16_t port)
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = INADDR_ANY;
        local.sin_port = htons(port);

        if(bind(sockfd_, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            std::cerr << "bind error" <<   strerror(errno) << "," << errno << std::endl;
            exit(BindErr);
        }
    }
    void Listen()
    {
        if (listen(sockfd_, backlog) < 0)
        {
            std::cerr << "listen error" <<   strerror(errno) << "," << errno << std::endl;
            exit(ListenErr);
        }
    }
    int Accept(std::string *clientip, uint16_t *clientport)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int newfd = accept(sockfd_, (struct sockaddr*)&peer, &len);
        if(newfd < 0)
        {
            std::cerr << "accept error" <<   strerror(errno) << "," << errno << std::endl;
            return -1;
        }

        char ipstr[64];
        inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));
        *clientip = ipstr;
        *clientport = ntohs(peer.sin_port);

        return newfd;
    }
    bool Connect(const std::string &ip, const uint16_t &port)
    {
        struct sockaddr_in peer;
        memset(&peer, 0, sizeof(peer));
        peer.sin_family = AF_INET;
        peer.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr));

        int n = connect(sockfd_, (struct sockaddr*)&peer, sizeof(peer));
        if(n == -1) 
        {
            std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;
            return false;
        }
        return true;
    }
    void Close()
    {
        close(sockfd_);
    }
    int Fd()
    {
        return sockfd_;
    }
    
private:
    int sockfd_;
};
规定协议

那么下面我们采用方案二,进行规定一下协议。

首先要明确的是,协议头格式一般分为两大部分,分别是报头与有效载荷。有效载荷一般指的就是网络数据,而报头负责承载控制信息以保障数据传输。

我们规定""左操作数+空格+运算符+空格+右操作数"为有效载荷,而报头为:"有效载荷长度"。

为了明确区分报头与有效载荷之间的界线,我们用"\n"用于做二者之间的分隔符。

整体的协议头格式就成了:"len"\n"有效载荷"\n。

作为客户端只需要对应输入左操作数与运算符和右操作数即可,当然作为设计者来说,我们不能明确规定客户端输入的格式,所以我们可以挨接收对应部分的数据,比如类似下面的接收方式:

当客户端服务器输入对应的数据后,就需要先对数据进行序列化,转化为二进制序列,然后再对有效载荷进行封装上报头,这一步就为编码。对此就可以在网络中进行传输,当网络数据到达对端的服务端,服务端要先进行的是解码,将报头与有效载荷进行分离,得到有效载荷,然后再对有效载荷进行反序列得到对应得网络数据。同样相应得道理,服务端返回处理后得数据也要进行相应的操作。

在此过程中客户端与服务端的序列化与反序列化是略有不同的,所以我们要分别定义两个结构体分别来,所以我们将客户端的封装为Request,服务端封装为Response。对此Request需要添加成员变量:左操作数(x),运算符(op),右操作数(y)。Response需要添加成员变量:结果(result),答案标记符(code)(用于标记答案是否正确或计算是否符合运算规则)。

整体的结构大致如下:

复制代码
#pragma once

#include <iostream>
#include <string>

class Request
{
public:
    Request()
    {

    }
    Request(int data1, int data2, char oper) :x(data1) ,y(data2) ,op(oper)
    {}
    bool Serialize(std::string *out) // 序列化
    {

    }
    bool Deserialize(const std::string &in)    // 反序列化
    {

    }
public:
    // x op y
    int x;
    int y;
    char op; // + - * / %
};

class Response
{
public:
    Response(int res, int c) : result(res), code(c)
    {}
    bool Serialize(std::string *out) // 序列化
    {

    }
    bool Deserialize(const std::string &in)    // 反序列化
    {

    }
public:
    int result;
    int code; // 0,可信,否则!0具体是几,表明对应的错误原因
};

根据我们刚才规定的要求,我们要对要发送的网络数据进行序列化,对于客户端,此时就需要先进行序列化来构建报文的有效载荷,又因为我们已经规定了协议的有效载荷的格式为:我们规定"左操作数+空格+运算符+空格+右操作数"。所以我们就要严格按照规定来,当然为了方便,我们将重复可替代的字符使用的宏替代。

复制代码
const std::string blank_space_sep = " ";
const std::string protocol_sep = "\n";

那么按照要求进行客户端的序列化,需要先定义一个字符串,然后对应添加左操作数,添加空格再添加右操作数。当然我们的操作数是还需要先转化为字符串的格式,这时需要调用接口to_string即可。

复制代码
    bool Serialize(std::string *out) // 序列化
    {
        // 序列化:构建报文的有效载荷
        // x op y
        std::string s = std::to_string(x);
        s += blank_space_sep;
        s += op;
        s += blank_space_sep;
        s += std::to_string(y);
        
        *out = s;
        return true;
    }

同样对于反序列化需要做的就是将有效载荷 "x op y" 赋值到成员变量中,对于这里可以避免自定义函数,可以调用已存在的函数接口find系列找到对应的下标,然后结合substr。

主要是先调用find找到第一个空格的下标,对此就找到了x的下标范围,然后在用rfind找到第二个空格的下标,对此就找到了y的下标范围,对此op就是第一个下标的下一个。当然需要注意的就是substr是拷贝左开右闭的区间范围。

实现代码如下:

复制代码
    bool Deserialize(const std::string &in)    // 反序列化
    {
        // 将有效载荷 "x op y" 赋值到成员变量中
        std::size_t left = in.find(blank_space_sep);
        if(left == in.npos) return false;
        std::string part_x = in.substr(0, left); // 左开  右闭

        std::size_t right = in.rfind(blank_space_sep);
        if(right == in.npos) return false;
        std::string part_y = in.substr(right+1);

        if (left + 2 != right) return false;

        op = in[left + 1];
        x = std::stoi(part_x);
        y = std::stoi(part_y);

        return true;
    }

对此服务端的Response中的序列化与反序列化也是同样的思路。可以自己尝试一下,这里就直接给出代码吧。

复制代码
    bool Serialize(std::string *out) // 序列化
    {
        // 序列化:构建报文的有效载荷
        // "result code"
        std::string s = std::to_string(result);
        s += blank_space_sep;
        s += std::to_string(code);

        *out = s;
        return true;
    }
    bool Deserialize(const std::string &in)    // 反序列化
    {
        // 将有效载荷 "result code" 赋值到成员变量中
        std::size_t pos = in.find(blank_space_sep);
        if(pos == in.npos) return false;

        std::string part_result = in.substr(0, pos);
        std::string part_code = in.substr(pos + 1);

        result = std::stoi(part_result);
        code = std::stoi(part_code);

        return true;
    }

对此我们就完成了协议规定的有效载荷的部分,剩下的就是报头部分,报头部分分别由编码与解码组成。

  • 编码其就是将原有的有效载荷变为报文,报文的组成即为:报头+有效载荷。按照要求我们的报头即为有效载荷的长度。其中我们为了明确分割报头与有效载荷的界限,我们在其中间添加了\n。同样明确分割相邻报文之间的界限,我们也添加了\n。这都需要在编码中实现。
  • 解码就是将我们已经规定好的:"len"\n"有效载荷"\n形式报文进行分割,然后将有效载荷提取存储起来。其分割主要就是就是靠\n。

编码与解码实现起来还是十分容易的,只需要将已经封装好的字符串组装与将组装好的字符串分割。

复制代码
std::string Encode(std::string &content) // 编码
{
    // 将原有的有效载荷变为报文   
    // 报文格式:
    // "len"\n"有效载荷"\n
    std::string package = std::to_string(content.size());
    package += protocol_sep;
    package += content;
    package += protocol_sep;
    
    return package;
}

bool Decode(std::string &package, std::string *content) // 解码
{
    // "len"\n"有效载荷"\n
    // 将报文的有效载荷提取   package 提取 存到 content
    std::size_t pos = package.find(protocol_sep);
    if(pos == package.npos) return false;

    std::string len_str = package.substr(0, pos);
    std::size_t len = std::stoi(len_str);
    
    std::size_t total_len =  len_str.size() + len + 2;
    if(package.size() < total_len) return false;

    *content = package.substr(pos + 1, len);

    return true;
}

当然我们现在的解码还是有一点小bug的,因为我们的package并不是一定只存储单个的网络数据。可能存储多个网络数据,所以可以在最后添加一行代码:

复制代码
// earse 移除报文 
package.erase(0, total_len);
服务端代码

服务器端首先需要实现计算器的核心逻辑。鉴于本示例仅支持基本的加、减、乘、除、取余运算,且实现较为简单,这部分并非本文重点。因此,我们直接给出实现代码。

当然我们规定错误,使用枚举,其中错误码1为除0错误,错误码2为取余为0错误,错误码3为其他错误。

复制代码
enum
{
    Div_Zero = 1,
    Mod_Zero,
    Other_Oper
};

class ServerCal
{
public:
    ServerCal()
    {
    }
    Response CalculatorHelper(const Request &req)
    {
        Response resp(0, 0);
        switch (req.op)
        {
        case '+':
            resp.result = req.x + req.y;
            break;

        case '-':
            resp.result = req.x - req.y;
            break;

        case '*':
            resp.result = req.x * req.y;
            break;

        case '/':
        {
            if (req.y == 0)
                resp.code = Div_Zero;
            else
                resp.result = req.x / req.y;
        }
        break;

        case '%':
        {
            if(req.y == 0)
                resp.code = Mod_Zero;
            else
                resp.result = req.x % req.y;
        }
        break;

        default:
            resp.code = Other_Oper;
        break;
        }
        return resp;
    }
    ~ServerCal()
    {
    }
};

除此之外,改类我们还要实现对客户端发来的数据请求进行处理,其步骤为先进行解码得到有效载荷,然后进行反序列化提取字符串中的左操作数,运算符,右操作数。然后进行调用我们上面的逻辑代码进行计算,然后将得到的result与code进行序列化转化为字符串得到有效载荷,最后进行编码添加报头转化为完整的报文,便可在网络中进行传输。

复制代码
class ServerCal
{
public:
    // 调用该函数,然后进行返回
    std::string Calculator(std::string &package)
    {
        std::string content;
        // 先解码
        // 反序列化
        // 计算
        // 序列化
        // 编码
        // 才可以返回
        bool r = Decode(package, &content); // "len"\n"10 + 20"\n
        if(!r) 
            return "";
        // "10 + 20"
        Request req;
        r = req.Deserialize(content); // "10 + 20" ->x=10 op=+ y=20
        if (!r)
            return "";
        
        content = "";
        Response resp = CalculatorHelper(req); // result=30 code=0;
        // resp.DebugPrint();

        resp.Serialize(&content);  // "30 0"
        content = Encode(content); // "len"\n"30 0

        // std::cout << content << std::endl;
        return content;
    }
};

至此,服务端的协议设计部分已全部完成。接下来,我们将基于此协议编写TCP服务端的具体实现。该实现代码主要借鉴了前文已详细阐述的通用TCP服务器框架,鉴于其并非本篇重点,我们将直接引用相关代码。这里我们代码采用上篇文章提到但没有提供代码的父子进程版的服务端。

复制代码
#pragma once
#include <functional>
#include <string>
#include <signal.h>
#include "Socket.hpp"

using func_t = std::function<std::string(std::string &package)>;

class TcpServer
{
public:
    TcpServer(uint16_t port, func_t callback) : port_(port), callback_(callback)
    {
    }
    bool InitServer()
    {
        listensock_.Socket();
        listensock_.Bind(port_);
        listensock_.Listen();
        std::cout << "init server .... done" << std::endl;
        return true;
    }
    void Start()
    {
        signal(SIGCHLD, SIG_IGN);
        signal(SIGPIPE, SIG_IGN);
        while (true)
        {
            std::string clientip;
            uint16_t clientport;
            int sockfd = listensock_.Accept(&clientip, &clientport);
            if (sockfd < 0)
                continue;
            std::cout << "accept a new link, sockfd" << sockfd << ", clientip: " << clientip.c_str() << ", clientport: " << clientport << std::endl;
            // 提供服务
            if (fork() == 0)
            {
                listensock_.Close();
                std::string inbuffer_stream;
                // 数据计算
                while (true)
                {
                    char buffer[1280];
                    ssize_t n = read(sockfd, buffer, sizeof(buffer));
                    if (n > 0)
                    {
                        buffer[n] = 0;
                        inbuffer_stream += buffer;
                        std::cout << "debug:" << inbuffer_stream.c_str() << std::endl;

                        std::string info = callback_(inbuffer_stream);
                        if (info.empty())
                            continue;
                        write(sockfd, info.c_str(), info.size());
                    }
                    else if (n == 0)
                        break;
                    else
                        break;
                }

                exit(0);
            }
            close(sockfd);
        }
    }
    ~TcpServer()
    {
    }

private:
    uint16_t port_;
    Sock listensock_;
    func_t callback_;
};

最后只需要在添加服务端的启动代码即可完成服务端的代码。

复制代码
#include "TcpServer.hpp"
#include "ServerCal.hpp"
#include <unistd.h>

static void Usage(const std::string &proc)
{
    std::cout << "\nUsage: " << proc << " port\n" << std::endl;
}

// ./servercal 8080 (端口号)
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(0);
    }
    uint16_t port = std::stoi(argv[1]);

    // 这是设计一个回调函数,将cal中的函数Calculator绑定为回调函数
    ServerCal cal;
    TcpServer *tsvr = new TcpServer(port, std::bind(&ServerCal::Calculator, &cal, std::placeholders::_1));

    tsvr->InitServer();
    tsvr->Start();

    return 0;
}

到此,服务端代码就完成了。

客户端代码

鉴于其并非本篇重点,我们将直接给出引用的相关代码。

复制代码
#include <iostream>
#include <string>
#include <ctime>
#include <cassert>
#include <unistd.h>
#include "Socket.hpp"
#include "Protocol.hpp"

static void Usage(const std::string &proc)
{
    std::cout << "\nUsage: " << proc << " port\n"
              << std::endl;
}

// ./clientcal ip port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    std::string serverip = argv[1];
    uint16_t serverprot = std::stoi(argv[2]);

    Sock sockfd;
    sockfd.Socket();
    bool r = sockfd.Connect(serverip, serverprot);
    if (!r)
        return 1;

    srand(time(nullptr) ^ getpid());
    int cnt = 1;
    const std::string opers = "+-*/%";

    std::string inbuffer_stream;
    while (1)
    {
        // 对于客户端对数据的处理步骤:
        // 先构造变量对象
        // 后对数据序列化
        // 编码
        // 才可以发送
        std::cout << "===============第" << cnt << "次测试....., " << "===============" << std::endl;
        std::cout << "请输入左操作数:";
        int x;
        std::cin >> x;

        std::cout << "请输入运算符:";
        char oper;
        std::cin >> oper;
        if (opers.find(oper) == std::string::npos)  // 如果没找到
        {
            std::cout << "运算符输入错误,请重新输入。" << std::endl;
            continue;
        }

        std::cout << "请输入右操作数:";
        int y;
        std::cin >> y; 

        std::cout << "构造请求成功" << std::endl;

        Request req(x, y, oper);
        std::string package;
        req.Serialize(&package);
        package = Encode(package);

        write(sockfd.Fd(), package.c_str(), sizeof(package));

        // 对于接受数据
        // 先接受
        // 对其解码
        // 反序列化
        // 才可以答应获得的数据

        char inbuff[128];
        size_t n = read(sockfd.Fd(), inbuff, sizeof(inbuff)); // 我们也无法保证我们能读到一个完整的报文
        if (n > 0)
        {
            inbuff[n] = 0;
            inbuffer_stream += inbuff;
            std::cout << inbuffer_stream << std::endl;
            std::string content;
            bool r = Decode(inbuffer_stream, &content);
            assert(r);

            Response resp;
            r = resp.Deserialize(content);
            assert(r);

        }

        std::cout << "=================================================" << std::endl;
        sleep(1);

        cnt++;
    }

    sockfd.Close();

    return 0;
}

最后的运行结果如下:

通过测试,加法,乘法,除法,取余都正确。同时对于除法与取余的错误时code也是对应的。

除此之外,对于错误的运算符也会有报错。

相关推荐
Doro再努力4 小时前
Linux01:基础指令与相关知识铺垫(一)
linux·运维·服务器
恒者走天下4 小时前
选cpp /c++方向工作职业发展的优缺点
c++
Cyan_RA94 小时前
Linux 远程Ubuntu服务器扩展硬盘后,将/home目录移动到新的硬盘空间上(把新硬盘的分区挂载到/home目录) 教程
linux·运维·ubuntu
_dindong4 小时前
Linux网络编程:Socket编程TCP
linux·服务器·网络·笔记·学习·tcp/ip
七宝大爷4 小时前
深度解析英伟达DGX与HGX服务器——从架构差异到场景选择
运维·服务器·架构
一匹电信狗5 小时前
【LeetCode_160】相交链表
c语言·开发语言·数据结构·c++·算法·leetcode·stl
wanhengidc5 小时前
服务器的安全性如何?
运维·服务器·安全·游戏·智能手机
卷卷的小趴菜学编程5 小时前
Linux网络之----序列化和反序列化
网络·序列化·反序列化·守护进程·jsoncpp·进程组·前后台进程
tianyuanwo5 小时前
tar打包过滤指定目录指南
linux·tar·过滤式打包