【计网】自定义协议与序列化(一) —— Socket封装于服务器端改写

🌎 应用层自定义协议与序列化

文章目录:

Tcp协议Socket编程

应用层简介

序列化和反序列化

重新理解read/write/recv/send及tcp的全双工

Socket封装

服务器端改写


🚀应用层简介

我们程序员写的一个个解决我们实际问题,满足我们日常需求的网络程序, 都是在应用层。

不论是Udp Socket编程还是Tcp Socket编程,所传的数据都是字符串类型的的数据,但是如果我们想要传输结构化的数据呢?什么是结构化的数据?其实在我们第一次说计算机网络时就已经提到过,结构化的数据就是协议 ,其本质就是 双方约定好的结构化的数据

比如,我们如果要实现一个网络版的计算器,我们需要客户把待计算的两个数发过去,由服务器进行计算,最后把结果返回给客户端。如果我们依旧采用传统的Socket编程,不论是Tcp还是Udp Socket编程,都无法保证所收到的数据是完整的,比如:客户端要发送 123 * 123,但是服务器此时缓冲区快满了,只能收到 123 * 1,这个时候服务器端就会以 123 * 1作为客户端的请求进行处理,如此以来就会导致客户端想得到的结果不匹配,更有甚者,剩下的23后一批发来会不会导致新的客户端数据出现问题呢?

所以这里我们准备了两套方案:

方案一

客户端发送一个形如"1+1"的字符串,这个字符串中有两个操作数,都是整形,两个数字之间会有一个字符是运算符,运算符只能是 + ,数字和运算符之间没有空格

方案二

定义结构体来表示我们需要交互的信息,发送数据时将这个结构体按照一个规则转换成字符串, 接收到数据的时候再按照相同的规则把字符串转化回结构体。这个过程我们叫做 序列化 反序列化


🚀序列化和反序列化

首先先简单理解一下什么是序列化与反序列化,我们可以通过下图简单了解:

仅仅是上面一张图我认为理解还是不够的,要更好的理解序列化和反序列化需要从下面入手:

✈️重新理解read/write/recv/send及tcp的全双工

在重新理解这些接口之前,我们先来回顾一下进程向发消息到磁盘的过程:

首先,用户需要发送消息,那么OS就会从文件描述符表中把3号文件描述符通过进程pcb返回给用户,用户此时通过write()接口对文件进行写数据,字符串会从write接口通过文件描述符表找到struct file对象,从而找到内核缓冲区,将字符串拷贝到缓冲区当中。随后缓冲区就会定期的向磁盘文件中刷新数据。

这个我们在系统部分已经说过了,但是为什么要说这个呢?实际上,如果今天,我们把磁盘这个外设换为网络,实际上就变成了网络通信!我们之前说过,传输层和网络层协议属于操作系统内核的一部分! 如果今天主机想要通过网络进行Tcp协议通信,那么 在传输层OS会维护两个缓冲区,一个 发送缓冲区一个 接收缓冲区

在应用层,我们以双方约定好的协议,将数据进行序列化处理成一批字符串。我们前面在使用Socket编程的时候,直观上,我们都认为是send/sendto直接将数据发送给了对端,recv/recvfrom直接从对端接收数据。实际上双方的IO系统调用并不会直接作用于网络。如果是发送端,则调用write/send/sendto 接口发送到传输层的发送缓冲区所以 write/send 本质是拷贝函数。将待发送的数据拷贝到发送缓冲区。

而发送数据则是由 Tcp 协议自主决定如何发送数据,而Tcp通过网络向对端发送数据,实际上就是 将自己发送缓冲区的内容通过网络拷贝到对方的接收缓冲区当中所以 在传输层看来,是双方的操作系统在进行通信! 随后,对端的接收缓冲区就会通过 read/recv 等接口将数据拷贝到应用层 ,所以 read/recv 接口本质也是拷贝函数!最后将序列化的字符串交给上层,上层再根据协议进行反序列化,最终拿到相应的数据。

如果对端接收缓冲区内没有数据,那么 read/recv 接口就会阻塞等待,为什么会阻塞等待?因为缓冲区里没数据,而 本质上是因为调用read/recv接口的进程在等待数据的到来才会做下一步动作,从而将进程状态从运行态转变为阻塞态,当收到数据的时候再从阻塞态转为运行态 。同样,如果主机A通过write/send 接口没有数据需要发送,也会阻塞等待。如果我们单单看发送方,有人把数据往发送缓冲区内写,OS把发送缓冲区的内容发送走,这难道不就是一个简单的生产消费者模型吗生产者是用户,消费者是OS,交易场所是发送缓冲区 。同样对于接收端来说,OS将数据通过网络拷贝到了接收缓冲区,上层用户需要通过 read/recv 来取出数据,那么对于接收方来说,也是一个生产消费者模型,只不过 生产者为 OS, 消费者为用户,交易场所为接收缓冲区

由上面的例子,你认为 阻塞的本质是什么是在进行同步! 为什么会是同步?因为通信双方需要等待数据的发送或者接收,而他们接收的过程无非就是发送端将数据拷贝到发送缓冲区,tcp再通过网络将将数据拷贝到对方的接收缓冲区中,对端用户需要调用 read/recv 拷贝接收缓冲区的数据到应用层。由此观之,通信的本质就是拷贝! 那么我们主机 A 在通过发送缓冲区给主机B的接收缓冲区发消息的时候,主机B不也可以通过自己的发送缓冲区给主机A的接收缓冲区发消息吗?它们之间互不干扰,所以这就是Tcp支持全双工的原因! 在Socket编程中我们说,一个 sockfd 也是支持全双工的,也是因为,sockfd既可以向发送缓冲区中发数据,也可以从接收缓冲区中拷贝数据


✈️Socket封装

我们对Socket进行封装,使其以后无论是tcp协议还是udp协议,变得更加简洁,更加有条理性,这是因为创建一个套接字的方式方法是比较套路化的,所以我们可以对其进行封装,我们把创建套接字,绑定ip和端口,网络监听,网络接收,发起链接,分别封装为五个 纯虚函数,将来让子类进行强制重写:

cpp 复制代码
class Socket
{
public:
    virtual void CreateSocketOrDie() = 0;             // 创建套接字
    virtual void BindSocketOrDie(InetAddr &addr) = 0; // 绑定套接字
    virtual void ListenSocketOrDie() = 0;             // 监听套接字
    virtual socket_sptr Accepter(InetAddr *addr) = 0;
    virtual bool Connector(InetAddr &addr) = 0;

public:
};

如果我们想要创建服务器端与客户端的tcp socket通信,我们只需要调用不同的虚函数即可,当然也可以通过这些虚函数来组成udp socket通信,不过这里我们就不再实现udp的socket了:

cpp 复制代码
class Socket
{
public:
    virtual void CreateSocketOrDie() = 0;             // 创建套接字
    virtual void BindSocketOrDie(InetAddr &addr) = 0; // 绑定套接字
    virtual void ListenSocketOrDie() = 0;             // 监听套接字
    virtual socket_sptr Accepter(InetAddr *addr) = 0; // 接收链接
    virtual bool Connector(InetAddr &addr) = 0;		  // 发起链接

public:
    void BuildListenSocket(InetAddr &addr)// 创建tcp套接字,绑定并监听
    {
        CreateSocketOrDie();
        BindSocketOrDie(addr);
        ListenSocketOrDie();
    }

    bool BuildClientSocket(InetAddr &addr)// 创建客户端套接字
    {
        CreateSocketOrDie();
        return Connector(addr);
    }
};

那么,我们如果想要建立Tcp连接,我们就可以在TcpSocket类当中继承Socket类,这样我们就可以对基类成员虚函数进行重写,而重写的所有内容实际上就是我们之前写的TcpSocket的内容,这里我就不再过多赘述:

cpp 复制代码
const static int gbacklog = 8;
using socket_sptr = std::shared_ptr<Socket>;// 重命名 Socket 类型的智能指针

enum
{
    SOCKET_ERROR = 1,
    BIND_ERROR,
    LISTEN_ERROR,
    USAGE_ERROR,
};

class TcpSocket : public Socket
{
public:
    TcpSocket(int fd) : _sockfd(fd)
    {
    }

    void CreateSocketOrDie() override
    {
        // 创建链式套接字
        _sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd < 0)
        {
            LOG(FATAL, "socket error");
            exit(SOCKET_ERROR);
        }
        LOG(DEBUG, "socket create success, sockfd is: %d", _sockfd);
    }

    void BindSocketOrDie(InetAddr &addr) override
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(addr.Port());
        local.sin_addr.s_addr = inet_addr(addr.IP().c_str());

        int n = ::bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            LOG(FATAL, "bind error");
            exit(BIND_ERROR);
        }
        LOG(DEBUG, "bind success, sockfd is: %d", _sockfd);
    }

    void ListenSocketOrDie() override
    {
        int n = ::listen(_sockfd, gbacklog);
        if (n < 0)
        {
            LOG(FATAL, "listen error");
            exit(LISTEN_ERROR);
        }
        LOG(DEBUG, "listen success, sockfd is: %d", _sockfd);
    }

    socket_sptr Accepter(InetAddr *addr) override// 返回一个智能指针,以便于将来可以通过智能指针对基类成员方法进行调用
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int sockfd = ::accept(_sockfd, (struct sockaddr *)&peer, &len);
        if (sockfd < 0)
        {
            LOG(WARNNING, "accept error");
            return nullptr;
        }
		// accpet成功
        *addr = peer;
        socket_sptr sock = std::make_shared<TcpSocket>(sockfd);
        return sock;
    }

    bool Connector(InetAddr &addr) override
    {
        struct sockaddr_in server;
        memset(&server, 0, sizeof(server));
        server.sin_family = AF_INET;
        server.sin_port = htons(addr.Port());
        server.sin_addr.s_addr = inet_addr(addr.IP().c_str());
        int n = connect(_sockfd, (struct sockaddr *)&server, sizeof(server));
        if (n < 0)
        {
            std::cerr << "connect error" << std::endl;
            return false;
        }
        return true;
    }

private:
    int _sockfd;
};

这样写的好处是main函数和客户端的工作量就降低了很多,我们可以通过多态式的调用来完成Socket通信:

cpp 复制代码
// 多态式调用
std::unique_ptr<Socket> listensock = std::make_unique<TcpSocket()>;
listensock->BuildListenSocket();// 直接建立起了连接

std::unique_ptr<Socket> clientsock = std::make_unique<TcpSocket()>;
clientsock->BuildClientSocket();// 客户端Socket套接字建立

这样,listensock或者clientsock虽然表面调用的是Socket基类,但是由于基类内的纯虚函数都在子类实现,所以会间接调用子类对父类纯虚函数的重写,这就是多态式调用。而以上对于Socket的封装,内置抽象函数(纯虚函数),需要子类强制重新实现的这种方式,是一种设计模式 ,称为 模版方法模式


✈️服务器端改写

除此之外,我们把Socket进行了封装,那么TcpServer也就不需要像Tcp Socket编程那样进行写了,为了松耦合,我们把TcpServer类冗余部分全部删除,TcpServer帮我们的目的是:创建套接字,获取客户端链接,再去处理请求 三个部分,至于如何处理请求,就不该是TcpServer类所关心的了。

首先,我们不再需要原本的初始化部分,因为我们对Socket进行了封装,我们只需要在TcpServer构造函数中进行调用即可:

cpp 复制代码
TcpServer(int port)
    : _localaddr("0", port)// 0表示接收任意地址
    , _listensock(std::make_unique<TcpSocket>())// 子类对象构造父类指针,以便于多态式调用
    , _isrunning(false)
{
    _listensock->BuildListenSocket(_localaddr);// 多态式调用, 构建了ListenSocket, 一步到位
}

private:
    InetAddr _localaddr;
    std::unique_ptr<Socket> _listensock;
    bool _isrunning;

一个Socket的智能指针就可以实现多态式调用进行初始化Tcp套接字部分,对于具体的任务是如何处理的虽然TcpServer不该关心,但是如何分配任务以及分配任务的方式是我们需要操心的,在上一篇文章中我们有四种分配方式,后面三种选择任何一种都可,在这里我选择多线程的方式分配任务。

不需要知道具体的任务细节,只需要TcpServer其提供一个可调用的任务接口即可,我们使用function将Service接口封装为一个新的类型 io_service_t类型,在初始化部分与线程回调函数部分我们都可以对其进行调用:

cpp 复制代码
using namespace socket_ns;
// socket_ns exists: using socket_sptr = std::shared_ptr<Socket>;

class TcpServer;

// 对任务进行封装
using io_service_t = std::function<void (socket_sptr sockfd, InetAddr client)>;

class ThreadData
{
public:
    ThreadData(socket_sptr fd, InetAddr addr, TcpServer* s)
        :sockfd(fd)
        ,clientaddr(addr)
        ,self(s)
    {}
public:
    socket_sptr sockfd;// using socket_sptr = std::shared_ptr<Socket>;
    InetAddr clientaddr;
    TcpServer *self;
};

// TcpServer的目的是为了创建套接字,获取链接,再去处理,具体如何处理,就不需要TcpServer关心了
class TcpServer
{
public:
    TcpServer(int port, io_service_t service)
        : _localaddr("0", port)
        , _listensock(std::make_unique<TcpSocket>())
        , _service(service)
        , _isrunning(false)
    {
        _listensock->BuildListenSocket(_localaddr);
    }

    // 线程回调函数
    static void* HandlerSock(void* args)
    {
        pthread_detach(pthread_self());
        ThreadData* td = static_cast<ThreadData*>(args);
        td->self->_service(td->sockfd, td->clientaddr);
        delete td;
        return nullptr;
    }

    void Loop()
    {
        _isrunning = true;
        // 不能直接收数据,必须先获取连链接
        while(_isrunning)
        {
            InetAddr peeraddr;
            socket_sptr normalsock = _listensock->Accepter(&peeraddr);// // using socket_sptr = std::shared_ptr<Socket>;
            if(normalsock == nullptr) continue;

            pthread_t t;
            ThreadData *td = new ThreadData(normalsock, peeraddr, this);
            pthread_create(&t, nullptr, HandlerSock, td);// 将线程分离
        }
        _isrunning = false;
    }

    ~TcpServer()
    {}
private:
    InetAddr _localaddr;// 本地ip + port
    std::unique_ptr<Socket> _listensock;
    bool _isrunning;

    io_service_t _service;
};

在Loop函数中,由于在TcpServer中,我们重写了Accept()方法,所以我们不需要在Loop中写裸的accept()原生接口了,我们直接使用_listensock进行调用Accept()方法,会返回一个TcpSocket的对象,对象中本就存在sockfd。这样我们就可以成功获取连接了,再将其交给线程去处理即可。


相关推荐
八年。。3 分钟前
MATLAB 中有关figure图表绘制函数设计(论文中常用)
开发语言·笔记·学习·matlab
相醉为友8 分钟前
002 MATLAB语言基础
开发语言·matlab
lx学习15 分钟前
Python学习34天
开发语言·python·学习
chnming198729 分钟前
STL之算法概览
开发语言·c++·算法
雯0609~32 分钟前
c#:winform调用bartender实现打印(包含打印机的选择以及实际打印)
开发语言·c#
CV大法好34 分钟前
刘铁猛C#入门 026 重写与多态
开发语言·c#
爱上语文36 分钟前
Http 响应协议
网络·后端·网络协议·http
風清掦40 分钟前
C/C++ 每日一练:在矩阵中查找特定值
c语言·c++·算法
THRUSTER1111143 分钟前
Java学习笔记--继承的介绍,基本使用,成员变量和成员方法访问特点
java·开发语言·笔记·学习·学习方法·继承·intellij idea
沉河不浮44 分钟前
Java基础——(一)Java概述
java·开发语言