Linux--UDP/TCP客户端与服务端模拟实现计算器原理

一,引言

本篇文章,通过模拟实现计算器为原型,理解OSI七层协议中,应用层,表示层,会话层。三者之间的管理。以为为什么现实中用的是TCP/IP模型,和它们之间的关系。模拟网络协议以TCP为例,首先TCP是面向字节流的,也就是收信息和发信息并不一定同步,因此会有首发不全的问题。UDP是数据报并不会有这个问题。第二个问题:序列化的问题,在内存中一般来说都是结构化的存储,而网络传输通常以字节流的形式。其次序列化也有把信息又多变一方便存储,提高传输效率等作用。

二,功能概述

该功能实现的表现为:客户端提供两个数字,以及运行符,服务端进行处理返回结果以及该是否有效。代码逻辑包括如下十个部分:Common.hpp负责错误返回的退出码以及抽象类用于关闭客户端和服务端的拷贝构造,赋值构造功能;InetAddr.hpp负责套接字相关的结构体转化问题;Log.hpp提供日志功能;Mutex.hpp锁的封装;NetCal.hpp负责计算模块;Protocol.hpp负责协议的定制,以及序列化相关问题;Socket.hpp负责对套接字进行封装;TcpClient.hpp负责客户端的发送,以及接收客户端的结果;TCP Server.hpp负责服务器的封装;main.cc负责服务器的启动。

功能概述图如下:

三,协议的封装

在上文中,无论是客户端还是服务端都要进行序列化与反序列化。那么客户端和服务端是如何识别这一套数据的呢,因此就需要进行去规定协议,协议就是客户端和服务端都约定好的一套封装逻辑,客户端按照这个约定好的标准进行序列化,服务端也是知道这个标准的,该服务端也会依靠这个标准进行反序列化,这就是协议。

在这个模拟计算器示例中:对于Protocol.hpp这个类中,首先作为客户端要封装一个类(int _x, int _y,char oper)这三个属性分别表示两个数字以及运算符。服务端也需要一个类(int _result int _code)结果和退出码。

四,具体实现

1,服务器

首先对服务器进行封装,服务器负责创建套接字,绑定,连接。和客户端建立链接。通过多进程的形式实现多台主机对服务器访问:代码如下:

cpp 复制代码
class TcpServer
{
public:
    TcpServer(uint16_t port, ioservice_t service) : _port(port),
                                                    _listensockptr(std::make_unique<TcpSocket>()),
                                                    _isrunning(false),
                                                    _service(service)
    {
        _listensockptr->BuildTcpSocketMethod(_port);
    }
    void Start()
    {
        _isrunning = true;
        while (_isrunning)
        {
            InetAddr client;
            auto sock = _listensockptr->Accept(&client); // 1. 和client通信sockfd 2. client 网络地址
            if (sock == nullptr)
            {
                continue;
            }
            LOG(LogLevel::DEBUG) << "accept success ..." << client.StringAddr();

            // sock && client
            pid_t id = fork();
            if (id < 0)
            {
                LOG(LogLevel::FATAL) << "fork error ...";
                exit(FORK_ERR);
            }
            else if (id == 0)
            {
                // 子进程 -> listensock
                _listensockptr->Close();
                if (fork() > 0)
                    exit(OK);
                // 孙子进程在执行任务,已经是孤儿了
                _service(sock, client);
                sock->Close();
                exit(OK);
            }
            else
            {
                // 父进程 -> sock
                sock->Close();
                pid_t rid = ::waitpid(id, nullptr, 0);
                (void)rid;
            }
        }
        _isrunning = false;
    }
    ~TcpServer() {}

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensockptr;
    bool _isrunning;
    ioservice_t _service;
};

此时就用到了Socket.hpp。关于套接字的封装,代码如下:

cpp 复制代码
 const static int gbacklog = 16;
    // 模版方法模式
    // 基类socket, 大部分方法,都是纯虚方法
    class Socket
    {
    public:
        virtual ~Socket() {}
        virtual void SocketOrDie() = 0;
        virtual void BindOrDie(uint16_t port) = 0;
        virtual void ListenOrDie(int backlog) = 0;
        virtual std::shared_ptr<Socket> Accept(InetAddr *client) = 0;

    public:
        void BuildTcpSocketMethod(uint16_t port, int backlog = gbacklog)
        {
            SocketOrDie();
            BindOrDie(port);
            ListenOrDie(backlog);
        }
   
    };

    const static int defaultfd = -1;

    class TcpSocket : public Socket
    {
    public:
        TcpSocket() : _sockfd(defaultfd)
        {
        }
        TcpSocket(int fd) : _sockfd(fd)
        {
        }
        ~TcpSocket() {}
        void SocketOrDie() override
        {
            _sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
            if (_sockfd < 0)
            {
                LOG(LogLevel::FATAL) << "socket error";
                exit(SOCKET_ERR);
            }
            LOG(LogLevel::INFO) << "socket success";
        }
        void BindOrDie(uint16_t port) override
        {
            InetAddr localaddr(port);
            int n = ::bind(_sockfd, localaddr.NetAddrPtr(), localaddr.NetAddrLen());
            if (n < 0)
            {
                LOG(LogLevel::FATAL) << "bind error";
                exit(BIND_ERR);
            }
            LOG(LogLevel::INFO) << "bind success";
        }
        void ListenOrDie(int backlog) override
        {
            int n = ::listen(_sockfd, backlog);
            if (n < 0)
            {
                LOG(LogLevel::FATAL) << "listen error";
                exit(LISTEN_ERR);
            }
            LOG(LogLevel::INFO) << "listen success";
        }
        std::shared_ptr<Socket> Accept(InetAddr *client) override
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int fd = ::accepdt(_sockfd, CONV(peer), &len);
            if (fd < 0)
            {
                LOG(LogLevel::WARNING) << "accept warning ...";
                return nullptr; // TODO
            }
            client->SetAddr(peer);
            return std::make_shared<TcpSocket>(fd);
        }

    private:
        int _sockfd; // _sockfd , listensockfd, sockfd;
    };

在Accept函数中调用了,InetAddr对象中函数。InetAddr.hpp这个模块主要实现的就是主机与网络之间的相互转化。代码如下:

cpp 复制代码
class InetAddr
{
public:
    InetAddr() {}
    InetAddr(struct sockaddr_in &addr)
    {
        SetAddr(addr);
    }
    InetAddr(const std::string &ip, uint16_t port) : _ip(ip), _port(port)
    {
        // 主机转网络
        memset(&_addr, 0, sizeof(_addr));
        _addr.sin_family = AF_INET;
        inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr);
        _addr.sin_port = htons(_port);
        // local.sin_addr.s_addr = inet_addr(_ip.c_str()); // TODO
    }
    InetAddr(uint16_t port) : _port(port), _ip()
    {
        // 主机转网络
        memset(&_addr, 0, sizeof(_addr));
        _addr.sin_family = AF_INET;
        _addr.sin_addr.s_addr = INADDR_ANY;
        _addr.sin_port = htons(_port);
    }
    void SetAddr(struct sockaddr_in &addr)
    {
        _addr = addr;
        // 网络转主机
        _port = ntohs(_addr.sin_port); // 从网络中拿到的!网络序列
        // _ip = inet_ntoa(_addr.sin_addr); // 4字节网络风格的IP -> 点分十进制的字符串风格的IP
        char ipbuffer[64];
        inet_ntop(AF_INET, &_addr.sin_addr, ipbuffer, sizeof(_addr));
        _ip = ipbuffer;
    }
    uint16_t Port() { return _port; }
    std::string Ip() { return _ip; }
    const struct sockaddr_in &NetAddr() { return _addr; }
    const struct sockaddr *NetAddrPtr()
    {
        return CONV(_addr);
    }
    socklen_t NetAddrLen()
    {
        return sizeof(_addr);
    }
    bool operator==(const InetAddr &addr)
    {
        return addr._ip == _ip && addr._port == _port;
    }
    std::string StringAddr()
    {
        return _ip + ":" + std::to_string(_port);
    }
    ~InetAddr()
    {
    }

private:
    struct sockaddr_in _addr;
    std::string _ip;
    uint16_t _port;
};

这个模块就是根据ip和端口号构造InetAddr对象,以及相互之间的转化。服务端的代码调用逻辑如下图:

当运行该服务器,第一步创建套接字,bind,listen,最后调用accept等待客户端链接,一旦客户端链接,注意,tcpserver中是引用,会以引用的方式返回客户端的结构体信息,以及返回fd新的文件描述符。

之后就是创建子进程,为了防止父进程阻塞等待,影响其他主机进行访问该服务器。又创建孙子进程。(解决上述方法,处理以上代码,还可以通过信号屏蔽的方式,使得父进程不进行等待)。

最后孙子进程进行执行回调函数(来执对应的任务)。

2,协议层

协议层就是针对特定的应用,进行约定特定的协议,来给客户端和服务端在网络传输能过进行序列化和反序列化的一种规定。针对于客户端需要三个参数,针对服务端需要两个参数。在第三节协议的封装中已经列出。除此之外,Tcp是面向字节流,需要保证收发数据的完整性,因此需要一个类进行对已经序列化进行添加报头,已经串联最后的收发数据。具体框架代码如下:

客服端方面:

cpp 复制代码
class Request
{
public:
    Request()
    {
    }
    Request(int x, int y, char oper) : _x(x), _y(y), _oper(oper)
    {
    }
    std::string Serialize()
    {
        // _x = 10 _y = 20, _oper = '+'
        // "10" "20" '+' : 用空格作为分隔符
        Json::Value root;
        root["x"] = _x;
        root["y"] = _y;
        root["oper"] = _oper; // _oper是char,也是整数,阿斯克码值

        Json::FastWriter writer;
        std::string s = writer.write(root);
        return s;
    }

    // {"x": 10, "y" : 20, "oper" : '+'}
    bool Deserialize(std::string &in)
    {
        // "10" "20" '+' -> 以空格作为分隔符 -> 10 20 '+'
        Json::Value root;
        Json::Reader reader;
        bool ok = reader.parse(in, root);
        if (ok)
        {
            _x = root["x"].asInt();
            _y = root["y"].asInt();
            _oper = root["oper"].asInt(); //?
        }
        return ok;
    }
    ~Request() {}
    int X() { return _x; }
    int Y() { return _y; }
    char Oper() { return _oper; }

private:
    int _x;
    int _y;
    char _oper; // + - * / % -> _x _oper _y -> 10 + 20
};

服务端方面:

cpp 复制代码
class Response
{
public:
    Response() {}
    Response(int result, int code) : _result(result), _code(code)
    {
    }
    std::string Serialize()
    {
        Json::Value root;
        root["result"] = _result;
        root["code"] = _code;

        Json::FastWriter writer;
        return writer.write(root);
    }
    bool Deserialize(std::string &in)
    {
        Json::Value root;
        Json::Reader reader;
        bool ok = reader.parse(in, root);
        if (ok)
        {
            _result = root["result"].asInt();
            _code = root["code"].asInt();
        }
        return ok;
    }
    ~Response() {}
    void SetResult(int res)
    {
        _result = res;
    }
    void SetCode(int code)
    {
        _code = code;
    }
    void ShowResult()
    {
        std::cout << "计算结果是: " << _result << "[" << _code << "]" << std::endl;
    }

private:
    int _result; // 运算结果,无法区分清楚应答是计算结果,还是异常值
    int _code;   // 0:sucess, 1,2,3,4->不同的运算异常的情况, 约定!!!!
};

3,封装协议层

首先完成对于客户端的需求以及将需求进行处理之后需要返回的回应结构体之后。最后一个协议对象就是负责对这两者进行整合处理。完整的处理过程就如上图:首先是序列化的问题,和保证完整性的问题。之后对以上问题进行整合,形成服务端收数据和客户端发数据这两个接口。如下:

服务端读取数据:

cpp 复制代码
void GetRequest(std::shared_ptr<Socket> &sock, InetAddr &client)
    {
        // 读取
        std::string buffer_queue;
        while (true)
        {
            int n = sock->Recv(&buffer_queue);
            if(n > 0)
            {
                std::string json_package;
                // 1. 解析报文,提取完整的json请求,如果不完整,就让服务器继续读取
                bool ret = Decode(buffer_queue, &json_package);
                if(!ret)
                    continue;
                // 我敢100%保证,我一定拿到了一个完整的报文
                // {"x": 10, "y" : 20, "oper" : '+'} -> 你能处理吗?
                // 2. 请求json串,反序列化
                Request req;
                bool ok = req.Deserialize(json_package);
                if(!ok)
                    continue;
                // 3. 我一定得到了一个内部属性已经被设置了的req了.
                // 通过req->resp, 不就是要完成计算功能嘛!!业务
                Response resp = _func(req);

                // 4. 序列化
                std::string json_str = resp.Serialize();

                // 5. 添加自定义长度
                std::string send_str = Encode(json_str); // 携带长度的应答报文了"len\r\n{result:XXX, code:XX}\r\n"

                // 6. 直接发送
                sock->Send(send_str);
            }
            else if(n == 0)
            {
                LOG(LogLevel::INFO) << "client:" << client.StringAddr() << "Quit!";
                break;
            }
            else 
            {
                LOG(LogLevel::WARNING) << "client:" << client.StringAddr() << ", recv error";
                break;
            }
        }
    }

如上例子中,中间有一个过程叫做数据的处理,同一在后文中进行讲解,将数据处理过的数据进行整合。最后发送给客户端。

客户端:

cpp 复制代码
 bool GetResponse(std::shared_ptr<Socket> &client, std::string &resp_buff, Response *resp)
    {
        // 面向字节流,你怎么保证,你的client读到的 一个网络字符串,就一定是一个完整的请求呢??
        while (true)
        {
            int n = client->Recv(&resp_buff);
            if (n > 0)
            {
               

                while (Decode(resp_buff, &json_package))
                {
                    // 2. 走到这里,我能保证,我一定拿到了一个完整的应答json报文
                    // 2. 反序列化
                    resp->Deserialize(json_package);
                }
                return true;
            }
            else if (n == 0)
            {
                std::cout << "server quit " << std::endl;
                return false;
            }
            else
            {
                std::cout << "recv error" << std::endl;
                return false;
            }
        }
    }

读取服务端所发送的数据,最后进行数据的展示。

4,应用层--对具体的计算器进行封装

在上文中,给你一个resquest对象的数据,经过处理之后,返回给我一个response的结果对象数据。这内部是如何进行计算的,代码如下:

cpp 复制代码
#pragma once

#include "Protocol.hpp"
#include <iostream>

class Cal
{
public:
    Response Execute(Request &req)
    {
        Response resp(0, 0); // code: 0表示成功
        switch (req.Oper())
        {
        case '+':
            resp.SetResult(req.X() + req.Y());
            break;
        case '-':
            resp.SetResult(req.X() - req.Y());

            break;
        case '*':
            resp.SetResult(req.X() * req.Y());
            break;
        case '/':
        {
            if (req.Y() == 0)
            {
                resp.SetCode(1); // 1除零错误
            }
            else
            {
                resp.SetResult(req.X() / req.Y());
            }
        }
        break;
        case '%':
        {
            if (req.Y() == 0)
            {
                resp.SetCode(2); // 2 mod 0 错误
            }
            else
            {
                resp.SetResult(req.X() % req.Y());
            }
        }
        break;
        default:
            resp.SetCode(3); // 非法操作
            break;
        }

        return resp;
    }
};

如上,进行运算符的判断,是否符合标准,进而对对应的response对象进行赋值返回。最终返回计算过的respnse对象。进而继续进行封装,序列化,反序列化等等。

五,服务端调用解析图

服务端的调用代码以及调用逻辑:

cpp 复制代码
#include "NetCal.hpp"
#include "Protocol.hpp"
#include "TcpServer.hpp"
#include "Daemon.hpp"
#include <memory>

void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " port" << std::endl;
}

// 我的代码为什么要这样写???
// ./tcpserver 8080
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    std::cout << "服务器已经启动,已经是一个守护进程了" << std::endl;

    Daemon(0, 0);
    // daemon(1, 1);

    // Enable_Console_Log_Strategy();
    Enable_File_Log_Strategy();


    // 1. 顶层
    std::unique_ptr<Cal> cal = std::make_unique<Cal>();

    // 2. 协议层
    std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>([&cal](Request &req)->Response{
        return cal->Execute(req);
    });

    // 3. 服务器层
    std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(std::stoi(argv[1]),
        [&protocol](std::shared_ptr<Socket> &sock, InetAddr &client){
            protocol->GetRequest(sock, client);
    });

    tsvr->Start();
    // sleep(5);

    return 0;
}

在这个代码中最重要的最后三层的调用逻辑,如下示意图:

第一步要理解当函数进行这三步调用最开始,会进行这三个对象的创建,以及分别对对象进行初始化。下一步进行调用:

绿色的线代表函数运行结束返回机制。其余颜色的线条代表代码一步步的调用逻辑。其中会注意到,GetRequest函数是循环等待的,只要没有收到数据就循环接收。一旦这个函数结束就代表这这个子进程结束。也就是这个客户端完成对服务端的请求。

六,客户端的调用逻辑图

客户端的代码如下:

cpp 复制代码
void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}

void GetDataFromStdin(int *x, int *y, char *oper)
{
    std::cout << "Please Enter x: ";
    std::cin >> *x;
    std::cout << "Please Enter y: ";
    std::cin >> *y;
    std::cout << "Please Enter oper: ";
    std::cin >> *oper;
}

// ./tcpclient server_ip server_port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);
    std::shared_ptr<Socket> client = std::make_shared<TcpSocket>();
    client->BuildTcpClientSocketMethod();

    if (client->Connect(server_ip, server_port) != 0)
    {
        // 失败
        std::cerr << "connect error" << std::endl;
        exit(CONNECT_ERR);
    }

    std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>();
    std::string resp_buffer;
    // 连接服务器成功
    while (true)
    {
        // 1. 从标准输入当中获取数据
        int x, y;
        char oper;
        GetDataFromStdin(&x, &y, &oper);

        // 2. 构建一个请求-> 可以直接发送的字符串
        std::string req_str = protocol->BuildRequestString(x, y, oper);

        
        // 3. 发送请求
        client->Send(req_str);

        // 4. 获取应答
        Response resp;
        bool res = protocol->GetResponse(client, resp_buffer, &resp);
        if(res == false)
            break;

        // 5. 显示结果
        resp.ShowResult();
    }
    client->Close();
    return 0;
}

调用逻辑图如下:

客户端的调用逻辑十分简单。如上图:

七,总结

借助这个计算器的模拟,本质是了解在自己构造应用层,主要包括那几个方面,以及与OSI七层网络协议直接的关系如下图:

也就是说,以后任何的应用层协议都包括一下几个部分。想要实现应用层协议,就直接如上的几个方面就可以实现的自己的应用层协议。

相关推荐
FightingHg3 小时前
和claude、openclaw交互的一些杂七杂八记录
linux·运维·服务器
深念Y3 小时前
魅蓝Note5 Root + 改内核激活命名空间:让Docker跑在安卓上
android·linux·服务器·docker·容器·root·服务
新兴AI民工3 小时前
【Linux内核二十五】进程管理模块:CFS调度器pick_next_task_fair(一):pick_next_task_fair方法
linux·linux内核
我是一个对称矩阵3 小时前
分区安装Ubuntu系统
linux·运维·ubuntu
小捏哩3 小时前
死锁检测组件的设计
linux·网络·数据结构·c++·后端
信看3 小时前
SIM7600 MQTT TCP UDP 等常用网络功能测试
网络·tcp/ip·udp
mzhan0173 小时前
Linux: sched: pick_next_task_fair 这个函数的功能
linux·运维·算法
认真的薛薛3 小时前
JVM和pod内存关系
linux·运维·jvm
深蓝轨迹3 小时前
TCP/IP 网络模型面试核心考点总结01(基础篇)
网络·tcp/ip·面试