跳转至
内容字体
东观体
上图东观体
OPPO Sans
江城黑体
霞鹜臻楷
代码字体
DejaVu Sans Mono
DejaVu Sans Mono
Google Sans Code
JetBrains Mono
主题切换
返回顶部

服务器底层模块设计

约 2826 个字 46 行代码 3 张图片 预计阅读时间 10 分钟

Buffer模块

基本结构

在前面的项目介绍中已经对Buffer模块的作用进行了详细的介绍,下面直接开始设计Buffer模块

对于一个应用层缓冲区来说,其发送的数据不只是文本数据,还有可能是二进制数据,所以在Buffer类中使用的容器不建议是string,而是考虑直接使用vector,对应的模版类型就是字符类型char即可

考虑完存储的容器之后,再考虑下一个问题:如何表示用于读取的位置和用于写入的位置?实际上这里就可以借鉴大部分容器的设计思路,使用两个指针分别表示可读位置起始和可写位置起始

根据上面的思路,可以设计出Buffer类的基本结构:

C++
1
2
3
4
5
6
7
8
class Buffer {
public:
    Buffer() : read_idx_(0), write_idx_(0) {}
private:
    std::vector<char> buffer_;
    uint64_t read_idx_;  // 读取起始位置(闭)
    uint64_t write_idx_; // 写入起始位置(闭)
};

但是,上面的基本结构存在一个问题,因为容器大小并没有初始化容量,如果直接使用下标去访问容器就会出现越界问题,所以可以考虑给一个默认容量值,使用该值对容器进行初始化:

C++
1
2
3
4
5
6
7
// 缓冲区默认大小
const int default_size = 1024;
Buffer()
    : // ...
    , buffer_(default_size)
{
}

接口设计

在本次项目中,Buffer类首先提供下面的接口:

  1. 获取读位置:直接返回第一个元素的位置+read_idx_即可
  2. 获取写位置:直接返回第一个元素的位置+write_idx_即可
  3. 获取可读数据大小:返回write_idx_ - read_idx_即可
  4. 获取缓冲区起始位置:直接返回第一个元素的地址即可
  5. 获取可写空间大小

对于获取可写空间大小来说,需要考虑两部分:

  1. 可写位置之后还有多少空间:buffer_.size() - write_idx_
  2. 可读位置之前还有多少空间:read_idx_

这两部分加在一起才是实际上空闲的空间

接着,提供下面的接口:

  1. 移动写指针:让读指针按照指定的长度移动,但是需要判断指定的长度是否已经超出缓冲区的容量
  2. 移动读指针:让写指针按照指定的长度移动,但是需要判断指定的长度是否已经超出可读数据大小
  3. 清空缓冲区:将读指针和写指针置为0即可
  4. 确保缓冲区足够空间

对于确保缓冲区足够空间来说,需要考虑下面的情况:

  1. 当指定长度小于写位置之后的空间大小:直接返回。如下图所示:

  2. 当指定长度小于写位置之后的空间大小与读位置之前的空间大小之和:移动缓冲区已有的数据到起始位置,更新读指针和写指针。如下图所示:

  3. 当指定长度大于写位置之后的空间大小与读位置之前的空间大小之和:直接扩容到指定大小即可。如下图所示:

除了上面的接口外,还需要提供向缓冲区写入数据和从其中读取数据的接口,本次实现中提供两种接口:

  1. 只读取/写入数据,不移动读取/写入指针
  2. 读取/写入数据后,移动读取/写入指针

对于第二种接口,只需要调用第一种接口再调用上面实现的指针移动接口即可。所以下面考虑如何实现第一种接口

对于写入数据来说,本次提供针对三种类型的读取方式:

  1. 针对任意类型void*
  2. 针对string类型
  3. 针对Buffer类型

因为后两种类型本质都可以获取到写入数据的起始指针,所以实际上只需要实现第一种类型的接口,后两种类型只需要复用即可。对于写入数据来说,首先需要确保有足够的空间来存储指定大小的数据,所以需要调用前面提到的确保缓冲区足够的接口,接着将数据拷贝到缓冲区的写位置开始即可。这里需要注意一点,void*在C语言中是不支持直接进行指针偏移的,所以需要先将void*强转成char*,然后再进行指针偏移,为了确保安全,本次将其强转成const char*。另外,如果指定长度为0,可以考虑直接返回,而不需要再执行后续的逻辑

有了发送void*数据的接口以后,接下来考虑针对string类型的,此处只需要获取到string中存储的数据即可,这里可以考虑直接使用c_str()接口,当然也可以考虑使用data()接口,但是使用data()接口无法保证文本字符串最后有\0结尾,而考虑到本次使用string类型时都是针对文本数据,所以使用c_str()接口。但是此处会遇到一个问题,因为c_str()接口返回的是const char*,而void*无法接收const数据,所以在拷贝数据时需要使用const_cast将其强转成char*

对于Buffer类型,相对比较容易,直接获取到可读数据的起始地址,再根据可读空间大小的接口,将数据通过void*的写入接口拷贝到缓冲区即可。此处思路很简单,但是如果上面的接口设计使用到了函数重载,那么就需要注意对于Buffer类型的接口来说一定要将可读数据的起始地址转换为void*类型,否则会因为可以隐式类型转换为string类型而错误调用了写入string类型数据的函数,尤其是如果实现了返回值是const char*版本的获取可读数据的起始地址的接口

实现完写入接口后,接下来实现读取接口,本次提供三种读取方式,针对两种类型:

  1. 针对void*类型
  2. 针对string类型
  3. 针对string类型读取一行数据

同样,先设计void*接口,对于读取来说,需要判断指定读取的大小是否小于或者等于可读数据大小,如果不是就直接退出,如果满足就可以将指定长度的数据拷贝到输出型参数对应的空间中,但是需要注意,由于是void*,直接拷贝数据会出现问题,所以需要将其强制转换为char*注意此时不是const char*

接着是针对string类型,对于string类型,可以正常调用针对void*数据的读取接口,这里需要注意,string并没有直接提供访问第一个元素地址并对其进行修改的接口,所以这里可以考虑使用下标访问到第一个元素,再对其进行取地址,但是一定要注意先开辟好string容器的空间,再调用写入接口,否则直接访问string容器内部的元素会出现越界访问的问题

最后是针对读取一行数据存储到string内部,这里考虑两种换行符,一种是\n,还有一种是\r\n,所以提供两个接口用于在字符串中查找这两种字符串,接着在读取函数中根据这两种字符位置截取子串即可读取到一行的数据,本次考虑将换行符同样读取存储到string类型的输出参数中

有了以上接口后,缓冲区类就基本设计完毕了

具体的实现代码可以查看:Buffer类文件

Socket模块

基本结构

本模块是对系统底层接口进行封装,并对一些创建客户端和服务端的操作进行封装,便于上层进行使用。在前面的项目介绍中提到,一个文件描述符对应着一个Socket对象,也就是说,在Socket对象中需要管理一个文件描述符,而这个文件描述符将来就是上层创建完客户端或者服务端时需要填充的字段,所以当前类的基本结构为:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Socket
{
public:
    using ptr = std::shared_ptr<Socket>;
    // 委托构造
    Socket()
        : Socket(-1)
    {
    }

    Socket(int fd)
        : sockfd_(fd)
    {
    }

private:
    int sockfd_;
};

接口设计

Socket模块中,首先提供下面的接口:

  1. 创建监听套接字:调用系统调用socket()并设置监听套接字字段值
  2. 绑定地址信息:调用系统调用bind()并填充对应的地址信息
  3. 启动连接监听:调用系统调用listen()在监听套接字字段上开始监听
  4. 接收客户端的连接:调用系统调用accept()并返回对应用于数据传输的文件描述符
  5. 客户端发起连接:调用系统调用connect()发起连接
  6. 释放套接字:判断套接字字段是否大于-1,如果大于就调用系统调用close()关闭该文件描述符,接着将套接字字段设置为-1,防止上层访问已经关闭的文件描述符
  7. 开启套接字非阻塞:调用系统调用fcntl()设置套接字为非阻塞模式
  8. 开启地址重用

对于开启地址重用接口来说,使用setsockopt()进行设置,使用到的选项是SO_REUSEADDRSO_REUSEPORT,参考代码如下:

C++
1
2
3
4
5
6
7
8
// 开启地址重用
void setReuseAddressAndPort()
{
    int val = 1;
    setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR, (void *)&val, sizeof(int));
    val = 1;
    setsockopt(sockfd_, SOL_SOCKET, SO_REUSEPORT, (void *)&val, sizeof(int));
}

需要注意的是,在实现上面的接口时,如果涉及到类成员函数名称与系统调用冲突,可以使用指定全局命名空间,例如:

C++
1
2
3
4
5
int accpet()
{
    int ret = ::accept(/* ... */);
    // ...
}

接着,提供发送接口和接收接口,在本次实现中,提供两种版本:

  1. 阻塞式发送/接收接口
  2. 非阻塞式发送/接收接口

因为阻塞式和非阻塞式的区别就是选项,所以可以考虑设计阻塞式接口再通过设置对应的选项启用非阻塞式接口。基于这个思路,下面主要考虑阻塞式接口的设计,对于发送和接收接口来说,调用底层的send()recv接口接收数据即可,注意对返回值为小于等于0的情况进行分析

最后,为了便于上层创建服务端和客户端,本次提供两个接口:

  1. 封装客户端创建和发起连接的逻辑
  2. 封装服务端创建和接收连接的逻辑

对于客户端来说,只需要创建套接字再发起连接即可。对于服务端来说,先创建套接字,接着本次考虑到了可能需要对套接字设置非阻塞,所以需要给定一个标记表示是否开启非阻塞,如果开启就调用前面实现的开启非阻塞的接口,接着设置地址重用,最后绑定信息并开启监听即可。一定要注意,开启地址重用必须要在绑定之前

最后,设计一下当前类的析构函数,考虑当前类对象析构时就调用对应的关闭接口即可

具体的实现代码可以查看:Socket类文件

TimingWheel模块

Channel模块

Poller模块

EventLoop模块

Connection模块

Acceptor模块

LoopThreadPool模块

TcpServer模块