【仿Muduo库项目】Buffer模块设计实现

目录

一.日志打印宏

二.Buffer模块设计

1.1.功能回顾

1.2.设计

三.Buffer代码实现

3.1.代码总览

3.2.代码实现细节

3.2.1.细节一

3.2.2.细节二

3.2.3.细节三

3.2.4.细节四

3.2.5.细节五

3.3.代码测试


一.日志打印宏

我们日志宏的代码都存放到这个log.hpp

封装一个日志宏,通过日志宏进行日志的打印,将打印的信息前带有系统事件,文件名,行号

例如: [17.22.58 log.cpp:12]打开文件失败

那么具体怎么实现呢?

那么还记得

您可以把__FILE__和__LINE__理解为两个"魔法标记",它们的作用是在编译时,由编译器自动填入当前代码所在的位置信息。

它们的具体含义如下:

  1. FILE
  • 它是什么:它是一个字符串常量。
  • 它代表什么:它代表了**当前源代码文件的完整路径名或文件名。**编译器在处理这行代码时,会将它替换成当前源文件的名字。例如,如果你的文件叫做 main.c,那么 FILE 就会被替换为 "main.c"。在某些编译环境中,它可能会包含完整的文件路径,如 "D:/project/src/main.c"。
  1. LINE
  • 它是什么:它是一个整数常量。
  • 它代表什么:它代表了**当前代码在源文件中的行号。**编译器会将它替换成一个数字,这个数字就是这行代码(即 LINE 这行本身)在文件中的具体行数。如果你把这段代码移动到文件的第50行,那么 LINE 的值在下次编译时就会变成50。
cpp 复制代码
#include<iostream>
//封装一个日志宏,通过日志宏进行日志的打印,将打印的信息前带有系统事件,文件名,行号
// 例如:   [17.22.58 log.cpp:12]打开文件失败
int main()
{
    printf("[%s:%d] Hello World!\n",__FILE__,__LINE__);
}

可以看到。还是很不错的

但是还有一个系统事件呢!这个怎么搞?

我们需要学习一些新的东西

1. time() 函数 - 获取时间戳

cpp 复制代码
time_t t = time(nullptr);

作用:

  • time() 函数返回从 1970年1月1日00:00:00 UTC(Unix纪元)到当前时间的秒数
  • 这个秒数被称为 "时间戳" 或 "Unix时间戳"
  • time_t 是一个整数类型(通常是 long int),用于存储这个秒数

参数:

  • nullptr(或 NULL)表示我们不需要将时间戳存储到额外的地方
  • 如果传入一个 time_t 类型变量的地址,函数也会把时间戳写入那个地址

类比:

想象一个从1970年1月1日开始计时的巨大秒表,time() 就是按下秒表查看当前累积秒数的按钮。

2. localtime() 函数 - 转换成本地时间结构

cpp 复制代码
struct tm* ptm = localtime(&t);

作用:

  • 将 time() 得到的秒数(时间戳)转换成本地时间的各个组成部分
  • 返回一个指向 tm 结构体的指针,这个结构体包含了年、月、日、时、分、秒等各个字段

tm 结构体包含的字段:

  • tm_sec - 秒 (0-59)
  • tm_min - 分 (0-59)
  • tm_hour - 时 (0-23)
  • tm_mday - 一个月中的第几天 (1-31)
  • tm_mon - 月 (0-11,0代表1月)
  • tm_year - 年(从1900年开始的年数,2023年就是123)
  • tm_wday - 一周中的第几天 (0-6,0代表周日)
  • tm_yday - 一年中的第几天 (0-365)
  • tm_isdst - 夏令时标志

重要注意事项:

  • localtime() 返回的是指向静态内存区域的指针,这意味着:
  • 每次调用 localtime() 都会覆盖上次的结果
  • 不需要手动释放这个指针指向的内存

如果需要保存结果,应该复制结构体的内容,而不是直接保存指针

3. strftime() 函数 - 格式化时间字符串

cpp 复制代码
strftime(time_str, 31, "%H:%M:%S", ptm);

作用:

将 tm 结构体中的时间信息按照指定的格式字符串格式化成可读的字符串

参数详解:

  • time_str:目标字符数组,用于存放格式化后的字符串
  • 31:最多写入的字符数(包括结尾的空字符\0)
  • "%H:%M:%S":格式控制字符串

ptm:指向 tm 结构体的指针

常用格式说明符:

  • %H:24小时制的小时 (00-23)
  • %I:12小时制的小时 (01-12)
  • %M:分钟 (00-59)
  • %S:秒 (00-59)
  • %p:AM/PM 指示符
  • %Y:4位数的年份
  • %y:2位数的年份
  • %m:月份 (01-12)
  • %d:日 (01-31)
  • %A:完整的星期几名称
  • %a:缩写的星期几名称
  • %B:完整的月份名称
  • %b或%h:缩写的月份名称

示例格式:

  • "%Y-%m-%d %H:%M:%S" → "2023-12-25 14:30:45"
  • "%a %b %d %H:%M:%S %Y" → "Mon Dec 25 14:30:45 2023"

我们就能写出下面这个

cpp 复制代码
#include<iostream>
#include<ctime>
//封装一个日志宏,通过日志宏进行日志的打印,将打印的信息前带有系统事件,文件名,行号
// 例如:   [17.22.58 log.cpp:12]打开文件失败
int main()
{
    time_t t=time(nullptr);
    struct tm* ptm=localtime(&t);
    char time_str[32];
    strftime(time_str,31,"%H:%M:%S",ptm);
    
    printf("[%s][%s:%d] Hello World!\n",time_str,__FILE__,__LINE__);
}

但是我们不能每次调用的时候都写这么多代码吧

那么我们就需要将它封装成宏函数

此外,我们还需要了解一些宏函数相关的知识

在 C/C++ 中,宏函数必须定义在一行内,或者通过反斜杠(\)进行换行连接。

cpp 复制代码
// ✅ 正确的多行写法(使用反斜杠续行)
#define COMPLEX_MACRO(a, b, c) do { \
    int result = (a) + (b);         \
    if (result > (c)) {             \
        printf("Too big: %d\n", result); \
    } else {                        \
        printf("OK: %d\n", result); \
    }                               \
} while(0)

// 这样子写也行,但是可读性极其差劲
#define COMPLEX_MACRO_ONE_LINE(a, b, c) do { int result = (a) + (b); if (result > (c)) { printf("Too big: %d\n", result); } else { printf("OK: %d\n", result); } } while(0)

C99标准引入了不定参数宏,允许宏接受可变数量的参数。语法类似于可变参数函数,使用 ... 表示可变参数部分,并在替换部分使用 VA_ARGS 来引用这些参数。

cpp 复制代码
#define PRINT(...) printf(__VA_ARGS__)

int main() {
    PRINT("Hello, %s!\n", "world");
    PRINT("Number: %d\n", 42);
    return 0;
}

注意:在C语言中,字符串常量相邻会自动连接成一个字符串

因为format是一个字符串参数,在预处理时,它会被替换成用户传入的字符串,然后与周围的字符串连接,形成一个完整的格式字符串。

cpp 复制代码
#define LOG(format, ...) printf("[%s:%d] " format "\n", __FILE__, __LINE__, __VA_ARGS__)

// 使用示例
int x = 42;
LOG("Value: %d", x);
cpp 复制代码
// 宏展开后的代码:
printf("[%s:%d] " "Value: %d" "\n", __FILE__, __LINE__, x);
// 编译器会处理为:
printf("[%s:%d] Value: %d\n", __FILE__, __LINE__, x);

现在我们就去封装我们的日志打印宏

cpp 复制代码
#define INF 0
#define DBG 1
#define ERR 2
#define LOG_LEVEL DBG

#define LOG(level, format, ...) do{\
        if (level < LOG_LEVEL) break;\
        time_t t = time(NULL);\
        struct tm *ltm = localtime(&t);\
        char tmp[32] = {0};\
        strftime(tmp, 31, "%H:%M:%S", ltm);\
        fprintf(stdout, "[%p %s %s:%d] " format "\n", (void*)pthread_self(), tmp, __FILE__, __LINE__, __VA_ARGS__);\
    }while(0)

但是现在还有一个问题。

如果说我传递的是只是两个参数进去

cpp 复制代码
LOG(DEBUG_LEVEL,"hello World");

那么宏函数的不定参数就会报错啊。

上面的LOG宏定义中,format 和 ... 是分开的,这样调用时就需要至少两个参数(level和format),然后可变参数至少一个(因为__VA_ARGS__至少需要一个参数)。

  • 使用C99标准中的__VA_ARGS__,并确保在调用时至少提供一个参数(但这样就不能完全省略可变参数)。
  • 使用##VA_ARGS(GCC扩展),这样当可变参数为空时,就没有一点问题。

如果你希望允许可变参数为空,则需要使用**##VA_ARGS**。

cpp 复制代码
#define INF 0
#define DBG 1
#define ERR 2
#define LOG_LEVEL DBG

#define LOG(level, format, ...) do{\
        if (level < LOG_LEVEL) break;\
        time_t t = time(NULL);\
        struct tm *ltm = localtime(&t);\
        char tmp[32] = {0};\
        strftime(tmp, 31, "%H:%M:%S", ltm);\
        fprintf(stdout, "[%p %s %s:%d] " format "\n", (void*)pthread_self(), tmp, __FILE__, __LINE__, ##__VA_ARGS__);\
    }while(0)

这样子编译就不会报错了。

同时,我们为了使用方便,我们根据日志等价将这个日志打印宏封装好了三个接口

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

#define INF 0
#define DBG 1
#define ERR 2
#define LOG_LEVEL DBG

#define LOG(level, format, ...) do{\
        if (level < LOG_LEVEL) break;\
        time_t t = time(NULL);\
        struct tm *ltm = localtime(&t);\
        char tmp[32] = {0};\
        strftime(tmp, 31, "%H:%M:%S", ltm);\
        fprintf(stdout, "[%p %s %s:%d] " format "\n", (void*)pthread_self(), tmp, __FILE__, __LINE__, ##__VA_ARGS__);\
    }while(0)

#define INF_LOG(format, ...) LOG(INF, format, ##__VA_ARGS__)
#define DBG_LOG(format, ...) LOG(DBG, format, ##__VA_ARGS__)
#define ERR_LOG(format, ...) LOG(ERR, format, ##__VA_ARGS__)

测试代码

cpp 复制代码
// 最简单的使用方式
int main() {
    // 使用简化宏,只需要传递一个字符串(两个参数:宏本身+字符串)
    DBG_LOG("这是一个简单的调试消息");
    INF_LOG("这是一个信息消息");
    ERR_LOG("这是一个错误消息");
    
    // 带参数的用法
    int x = 10, y = 20;
    DBG_LOG("x = %d, y = %d", x, y);
    
    return 0;
}

注意:最前面这一坨是线程ID.

二.Buffer模块设计

1.1.功能回顾

Buffer模块实现了一个高效的用户态缓冲区,作为通信连接的接收与发送数据的中转站。其核心设计目标是通过减少系统调用的次数来提升I/O效率,同时解决TCP流式传输中常见的"粘包"与"半包"问题。

Buffer模块内部维护了一块预分配的连续内存空间,通过精心设计的读写指针机制来管理数据的存储与读取。这种结构不仅使得数据的追加和移除操作非常高效,还避免了频繁的内存分配与释放带来的性能开销。

接收数据(读操作)处理流程

当从socket读取数据时,并不直接将数据交给上层应用处理,而是先将数据存入Buffer的接收缓冲区中。

这样的设计带来了显著的优势:即使一次读取的数据量不足以构成一个完整的应用层数据包,这些数据也可以安全地暂存在Buffer中,等待后续数据的到达。

智能数据包重组机制:Buffer模块与协议解析器紧密配合,能够智能处理数据包的边界问题。

其工作流程如下:

  1. 数据暂存每次socket可读事件触发时,都会将新到达的数据追加到Buffer接收缓冲区的末尾。

  2. 完整性检查:上层应用可以随时检查Buffer中是否包含完整的数据包。这通常通过协议定义的特定方式实现(如长度字段、分隔符或自定义的验证逻辑)。

  3. 数据提取:当检测到至少一个完整数据包时,Buffer会将这个完整的数据包从缓冲区中提取出来,交付给上层应用处理,同时从缓冲区中移除已处理的数据。

  4. 等待拼接:如果当前缓冲区中的数据不足以构成完整数据包,Buffer会保留这些"半包"数据,等待下一次数据到达。新数据会被追加到现有数据之后,然后再次尝试完整性检查,形成新旧数据的无缝拼接。

这种机制确保了无论数据包如何被TCP流分割,应用层都能获得完整、正确的消息单元,极大地简化了上层协议的实现复杂度。

发送数据(写操作)优化策略

在数据发送方面,Buffer模块同样扮演着关键角色。如果直接将数据通过send系统调用发送,当内核发送缓冲区已满时,调用线程可能会被阻塞,直到缓冲区有足够的空间。这种阻塞行为会严重影响服务器的并发性能和响应能力。

Buffer模块通过发送缓冲区提供了优雅的解决方案:

  1. 异步发送队列 :**当上层应用需要发送数据时,并不直接调用send,而是将数据先写入Buffer的发送缓冲区。**这个缓冲区充当了一个待发送数据的队列。

  2. 事件驱动发送:Buffer模块与EventLoop的事件监控机制协同工作。当数据被写入发送缓冲区后,会启用对应socket的写事件监控。只有当socket真正可写(内核发送缓冲区有空闲空间)时,才会触发写回调函数。

  3. 智能发送调度 :在写事件回调中,Buffer会尝试将发送缓冲区中的数据通过send系统调用发送出去。如果一次无法发送全部数据,剩余数据会保留在发送缓冲区中,等待下一次可写事件的到来。

  4. 流量控制:Buffer模块还能与TCP的拥塞控制机制良好配合。当网络拥塞导致发送缓慢时,Buffer会自动暂存待发送数据,避免应用层频繁尝试发送而浪费CPU资源。

1.2.设计

缓冲区设计思想:

内存管理:

缓冲区使用一块连续的内存空间来存储数据。这块内存空间是动态分配的,可以根据需要自动扩容。我们这里使用vector<char>来充当这块连续的内存空间。

空间划分:

通过两个关键位置指针来管理缓冲区:

  • 读取位置指针:标记当前可以从哪个位置开始读取数据

  • 写入位置指针:标记当前可以在哪个位置开始写入新数据

这两个指针将缓冲区划分为三个逻辑区域:已读取区域(可被覆盖)、待读取区域(有效数据)、空闲区域(可写入新数据)。

  • a. 已读区域:从内存起始位置到读位置之前(不包括读位置)的区域,这部分数据已经被读取,可以被后续写入覆盖。
  • b. 未读区域:从读位置到写位置之间的区域,这部分数据已经写入但尚未被读取。
  • c. 空闲区域:从写位置到内存空间末尾的区域,这部分空间尚未使用。
  1. 写入操作流程

当需要向缓冲区写入数据时,系统会按照以下逻辑执行:

第一步:确定写入起始点

系统总是从写入位置指针所指向的位置开始写入新数据,这保证了数据的顺序性。

第二步:空间检查与处理

写入前必须确保有足够的空闲空间,系统会进行智能的空间管理:

  • 情况一:如果写入位置指针后面的空闲空间足够,直接在该区域写入

  • 情况二:如果后面空间不足,但前面有可回收空间(已被读取的数据空间),系统会将现有的有效数据整体移动到缓冲区起始位置,腾出连续的大块空间

  • 情况三:如果总空闲空间不足,系统会自动扩容,增加缓冲区总容量

第三步:更新指针位置

数据成功写入后,写入位置指针会相应向后移动,指向下一次写入的起始位置。

  1. 读取操作流程

当从缓冲区读取数据时,遵循以下规则:

第一步:可读性验证

系统首先检查是否有数据可读,可读数据量为写入位置指针减去读取位置指针的差值。只有当这个值大于0时才允许读取操作。

第二步:数据读取

从读取位置指针指向的位置开始,按照请求的数据量进行读取。系统会确保读取的数据是连续且完整的。

第三步:指针更新

数据成功读取后,读取位置指针会向后移动,标记这些数据已被消费,相应的空间可被后续写入操作重用。

3.操作总览

这是一个环形缓冲区类的完整功能描述列表:

  1. 初始化函数:创建缓冲区对象时,将读写位置指针都设置为起始位置,并为内部存储区域分配默认大小的空间。

  2. 获取起始地址函数:返回缓冲区内部存储区域的第一个位置的地址。

  3. 获取写入位置地址函数:返回缓冲区中下一个数据写入操作应该开始的位置的地址。

  4. 获取读取位置地址函数:返回缓冲区中下一个数据读取操作应该开始的位置的地址。

  5. 计算尾部空闲空间函数:计算从当前写入位置到缓冲区末尾之间的空闲空间大小。

  6. 计算头部空闲空间函数:计算从缓冲区起始位置到当前读取位置之间的空闲空间大小。

  7. 计算可读数据大小函数:计算缓冲区中已经写入但尚未被读取的数据总量。

  8. 移动读取位置函数:将读取位置指针向后移动指定长度,表示已经读取了相应长度的数据。

  9. 移动写入位置函数:将写入位置指针向后移动指定长度,表示已经写入了相应长度的数据。

  10. 确保写入空间函数:检查缓冲区是否有足够空间写入指定长度的数据,如果空间不足则进行数据移动或缓冲区扩容操作。

  11. 写入数据函数:将外部数据拷贝到缓冲区的当前写入位置,但不更新写入位置指针。

  12. 写入数据并更新位置函数:将外部数据拷贝到缓冲区,然后自动更新写入位置指针。

  13. 写入字符串函数:将字符串数据拷贝到缓冲区的当前写入位置,但不更新写入位置指针。

  14. 写入字符串并更新位置函数:将字符串数据拷贝到缓冲区,然后自动更新写入位置指针。

  15. 写入另一缓冲区数据函数:将另一个缓冲区中可读取的数据拷贝到当前缓冲区,但不更新写入位置指针。

  16. 写入另一缓冲区数据并更新位置函数:将另一个缓冲区中可读取的数据拷贝到当前缓冲区,然后自动更新写入位置指针。

  17. 读取数据函数:将缓冲区中指定长度的数据拷贝到外部缓冲区,但不更新读取位置指针。

  18. 读取数据并更新位置函数:将缓冲区中指定长度的数据拷贝到外部缓冲区,然后自动更新读取位置指针。

  19. 以字符串形式读取数据函数:将缓冲区中指定长度的数据转换为字符串返回,但不更新读取位置指针。

  20. 以字符串形式读取并更新位置函数:将缓冲区中指定长度的数据转换为字符串返回,然后自动更新读取位置指针。

  21. 查找换行符函数:在缓冲区的可读数据范围内搜索换行符的位置。

  22. 获取一行数据函数:从当前读取位置开始,读取直到第一个换行符(包括换行符)的所有数据,但不更新读取位置指针。

  23. 获取一行并更新位置函数:从当前读取位置开始,读取直到第一个换行符(包括换行符)的所有数据,然后自动更新读取位置指针。

  24. 清空缓冲区函数:将读写位置指针都重置为起始位置,逻辑上清空所有数据,但保留已分配的内存空间。

按照需求,我们很快就能写出下面这个框架

cpp 复制代码
// 定义缓冲区的默认大小
#define BUFFER_DEFAULT_SIZE 1024

// 这是一个环形缓冲区实现类,用于高效管理数据读写操作
class Buffer {
private:
    std::vector<char> _buffer; // 使用vector进行动态内存空间管理,存储实际数据
    uint64_t _reader_idx;      // 读偏移,表示下一个读取操作应该开始的位置
    uint64_t _writer_idx;      // 写偏移,表示下一个写入操作应该开始的位置

public:
    // 构造函数:初始化读写偏移为0,并预分配默认大小的缓冲区空间
    Buffer();
    
    // 获取缓冲区的起始地址:返回内部vector的第一个元素的地址
    char *Begin();
    
    // 获取当前写入起始地址:缓冲区起始地址 + 写偏移量
    char *WritePosition();
    
    // 获取当前读取起始地址:缓冲区起始地址 + 读偏移量
    char *ReadPosition();
    
    // 获取缓冲区末尾空闲空间大小:计算写偏移之后到缓冲区末尾的空闲空间
    uint64_t TailIdleSize();
    
    // 获取缓冲区起始空闲空间大小:计算缓冲区起始位置到读偏移之间的空闲空间
    uint64_t HeadIdleSize();
    
    // 获取可读数据大小:计算写偏移减去读偏移,即已写入但尚未读取的数据量
    uint64_t ReadAbleSize();
    
    // 将读偏移向后移动指定长度:表示已读取指定长度的数据,更新读位置
    void MoveReadOffset(uint64_t len);
    
    // 将写偏移向后移动指定长度:表示已写入指定长度的数据,更新写位置
    void MoveWriteOffset(uint64_t len);
    
    // 确保可写空间足够:检查是否有足够空间写入指定长度的数据,不够则处理
    void EnsureWriteSpace(uint64_t len);
    
    // 写入数据:将外部数据拷贝到缓冲区,但不更新写偏移
    void Write(const void *data, uint64_t len);
    
    // 写入数据并移动写偏移:写入数据后自动更新写偏移
    void WriteAndPush(const void *data, uint64_t len);
    
    // 写入字符串:将字符串数据拷贝到缓冲区,但不更新写偏移
    void WriteString(const std::string &data);
    
    // 写入字符串并移动写偏移:写入字符串后自动更新写偏移
    void WriteStringAndPush(const std::string &data);
    
    // 写入另一个缓冲区的数据:将另一个缓冲区的可读数据拷贝到当前缓冲区
    void WriteBuffer(Buffer &data);
    
    // 写入另一个缓冲区的数据并移动写偏移:写入后自动更新写偏移
    void WriteBufferAndPush(Buffer &data);
    
    // 读取数据:将缓冲区的数据拷贝到外部缓冲区,但不更新读偏移
    void Read(void *buf, uint64_t len);
    
    // 读取数据并移动读偏移:读取数据后自动更新读偏移
    void ReadAndPop(void *buf, uint64_t len);
    
    // 以字符串形式读取指定长度的数据:读取数据并转换为字符串,不更新读偏移
    std::string ReadAsString(uint64_t len);
    
    // 以字符串形式读取数据并移动读偏移:读取后自动更新读偏移
    std::string ReadAsStringAndPop(uint64_t len);
    
    // 查找CRLF位置:在可读数据中搜索换行符,用于按行读取
    char *FindCRLF();
    
    /*
    通常获取一行数据,这种情况针对是按行读取
    */ 
    // 获取一行数据:从当前读取位置读取到第一个换行符(包含换行符)
    std::string GetLine();
    
    // 获取一行数据并移动读偏移:读取一行后自动更新读偏移
    std::string GetLineAndPop();
    
    // 清空缓冲区:重置读写偏移,逻辑上清空但保留内存空间
    void Clear();
};

三.Buffer代码实现

3.1.代码总览

我们把代码放到这个buffer.hpp里面

cpp 复制代码
#pragma once
#include"log.hpp"

#include <iostream>
#include <vector>
#include <string>
#include <cassert>
#include <cstring>
#include <ctime>
#include <functional>
#include <unordered_map>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <memory>
#include <typeinfo>
#include <fcntl.h>
#include <signal.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <sys/eventfd.h>
#include <sys/timerfd.h>

// 定义缓冲区的默认大小
#define BUFFER_DEFAULT_SIZE 1024

// 这是一个环形缓冲区实现类,用于高效管理数据读写操作
class Buffer
{
private:
    std::vector<char> _buffer; // 使用vector进行动态内存空间管理,存储实际数据
    uint64_t _reader_idx;      // 读偏移,表示下一个读取操作应该开始的位置
    uint64_t _writer_idx;      // 写偏移,表示下一个写入操作应该开始的位置

public:
    // 构造函数:初始化读写偏移为0,并预分配默认大小的缓冲区空间
    Buffer() : _reader_idx(0), _writer_idx(0), _buffer(BUFFER_DEFAULT_SIZE) {}

    // 获取缓冲区的起始地址:返回内部vector的第一个元素的地址
    char *Begin()
    {
        // _buffer.begin() 返回一个迭代器(iterator),指向vector的第一个元素。
        // *_buffer.begin() 解引用这个迭代器,得到第一个元素的引用(即char&)。
        // &*_buffer.begin() 取得这个引用的地址,即得到指向第一个元素的指针(char*)。
        return &*_buffer.begin();
    }

    // 获取当前写入起始地址:缓冲区起始地址 + 写偏移量
    char *WritePosition()
    {
        return Begin() + _writer_idx;
    }

    // 获取当前读取起始地址:缓冲区起始地址 + 读偏移量
    char *ReadPosition()
    {
        return Begin() + _reader_idx;
    }

    // 获取缓冲区末尾空闲空间大小:计算写偏移之后到缓冲区末尾的空闲空间
    uint64_t TailIdleSize()
    {
        return _buffer.size() - _writer_idx;
    }

    // 获取缓冲区起始空闲空间大小:计算缓冲区起始位置到读偏移之间的空闲空间
    uint64_t HeadIdleSize()
    {
        return _reader_idx;
    }

    // 获取可读数据大小:计算写偏移减去读偏移,即已写入但尚未读取的数据量
    uint64_t ReadAbleSize()
    {
        return _writer_idx - _reader_idx;
    }

    // 将读偏移向后移动指定长度:表示已读取指定长度的数据,更新读位置
    void MoveReadOffset(uint64_t len)
    {
        if (len == 0)
            return; // 如果移动长度为0,直接返回不执行任何操作
        // 向后移动的大小,必须小于或等于可读数据大小,否则越界
        assert(len <= ReadAbleSize());
        _reader_idx += len; // 更新读偏移
    }

    // 将写偏移向后移动指定长度:表示已写入指定长度的数据,更新写位置
    void MoveWriteOffset(uint64_t len)
    {
        // 向后移动的大小,必须小于或等于尾部空闲空间大小,否则越界
        assert(len <= TailIdleSize());
        _writer_idx += len; // 更新写偏移
    }

    // 确保可写空间足够:检查是否有足够空间写入指定长度的数据,不够则处理
    void EnsureWriteSpace(uint64_t len)
    {
        // 如果尾部空闲空间大小足够,直接返回,不需要额外操作
        if (TailIdleSize() >= len)
        {
            return;
        }

        // 尾部空闲空间不够,判断加上起始位置的空闲空间大小是否足够
        if (len <= TailIdleSize() + HeadIdleSize())
        {
            // 总体空闲空间足够,将数据移动到起始位置以腾出连续空间
            uint64_t rsz = ReadAbleSize(); // 保存当前可读数据大小
            // 把可读数据拷贝到起始位置,覆盖已被读取的区域
            std::copy(ReadPosition(), ReadPosition() + rsz, Begin());
            _reader_idx = 0;   // 将读偏移归0,因为数据现在从起始位置开始
            _writer_idx = rsz; // 将写偏移设置为可读数据大小
        }
        else
        {
            // 总体空间不够,则需要扩容,不移动数据,直接扩展缓冲区大小
            DBG_LOG("RESIZE %ld", _writer_idx + len); // 记录调试信息
            _buffer.resize(_writer_idx + len);        // 调整缓冲区大小为所需大小
        }
    }

    // 写入数据:将外部数据拷贝到缓冲区,但不更新写偏移
    void Write(const void *data, uint64_t len)
    {
        // 1. 保证有足够空间,2. 拷贝数据进去
        if (len == 0)
            return;                             // 如果数据长度为0,直接返回
        EnsureWriteSpace(len);                  // 确保缓冲区有足够空间
        const char *d = (const char *)data;     // 将数据指针转换为char*类型
        std::copy(d, d + len, WritePosition()); // 拷贝数据到写入位置
    }

    // 写入数据并移动写偏移:写入数据后自动更新写偏移
    void WriteAndPush(const void *data, uint64_t len)
    {
        Write(data, len);     // 先写入数据
        MoveWriteOffset(len); // 然后移动写偏移
    }

    // 写入string字符串:将字符串数据拷贝到缓冲区,但不更新写偏移
    void WriteString(const std::string &data)
    {
        return Write(data.c_str(), data.size()); // 将string转换为字符数组并写入
    }

    // 写入string字符串并移动写偏移:写入字符串后自动更新写偏移
    void WriteStringAndPush(const std::string &data)
    {
        WriteString(data);            // 写入字符串
        MoveWriteOffset(data.size()); // 根据字符串长度移动写偏移
    }

    // 写入另一个缓冲区Buffer的数据:将另一个缓冲区的可读数据拷贝到当前缓冲区
    void WriteBuffer(Buffer &data)
    {
        return Write(data.ReadPosition(), data.ReadAbleSize()); // 写入可读数据
    }

    // 写入另一个缓冲区Buffer的数据并移动写偏移:写入后自动更新写偏移
    void WriteBufferAndPush(Buffer &data)
    {
        WriteBuffer(data);                    // 写入缓冲区数据
        MoveWriteOffset(data.ReadAbleSize()); // 根据写入的数据量移动写偏移
    }

    // 读取数据:将缓冲区的数据拷贝到外部缓冲区,但不更新读偏移
    void Read(void *buf, uint64_t len)
    {
        // 要求要获取的数据大小必须小于或等于可读数据大小
        assert(len <= ReadAbleSize());
        std::copy(ReadPosition(), ReadPosition() + len, (char *)buf); // 拷贝数据到目标缓冲区buf
    }

    // 读取数据并移动读偏移:读取数据后自动更新读偏移
    void ReadAndPop(void *buf, uint64_t len)
    {
        Read(buf, len);      // 先读取数据
        MoveReadOffset(len); // 然后移动读偏移
    }

    // 以字符串string形式读取指定长度的数据:读取数据并转换为字符串,不更新读偏移
    std::string ReadAsString(uint64_t len)
    {
        // 要求要获取的数据大小必须小于或等于可读数据大小
        assert(len <= ReadAbleSize());
        std::string str;    // 创建字符串对象
        str.resize(len);    // 预分配字符串空间
        Read(&str[0], len); // 读取数据到字符串中
        return str;         // 返回字符串
    }

    // 以字符串string形式读取数据并移动读偏移:读取后自动更新读偏移
    std::string ReadAsStringAndPop(uint64_t len)
    {
        assert(len <= ReadAbleSize());       // 检查长度是否合法
        std::string str = ReadAsString(len); // 读取数据为字符串
        MoveReadOffset(len);                 // 移动读偏移
        return str;                          // 返回字符串
    }

    // 查找CRLF位置:在可读数据中搜索换行符,用于按行读取
    char *FindCRLF()
    {
        // 从读取位置开始查找换行符,搜索范围为可读数据大小
        char *res = (char *)memchr(ReadPosition(), '\n', ReadAbleSize());
        return res; // 返回找到的位置,如果没有找到返回NULL
    }

    //通常获取一行数据,这种情况针对是按行读取
    // 获取一行数据:从当前读取位置读取到第一个换行符(包含换行符)
    std::string GetLine()
    {
        char *pos = FindCRLF(); // 查找换行符位置
        if (pos == NULL)
        {
            return ""; // 未找到换行符,返回空字符串
        }
        // +1是为了把换行字符也取出来,计算从当前位置到换行符的长度
        return ReadAsString(pos - ReadPosition() + 1);
    }

    // 获取一行数据并移动读偏移:读取一行后自动更新读偏移
    std::string GetLineAndPop()
    {
        std::string str = GetLine(); // 获取一行数据
        MoveReadOffset(str.size());  // 根据读取的数据长度移动读偏移
        return str;                  // 返回读取的行
    }

    // 清空缓冲区:重置读写偏移,逻辑上清空但保留内存空间
    void Clear()
    {
        // 只需要将偏移量归0即可,实际数据留在内存中但会被后续写入覆盖
        _reader_idx = 0;
        _writer_idx = 0;
    }
};

3.2.代码实现细节

3.2.1.细节一

cpp 复制代码
// 获取缓冲区的起始地址:返回内部vector的第一个元素的地址
char *Begin()
{
    return &*_buffer.begin();
}

这是一个经典的获取vector内部数据指针的写法。

让我们分解一下:

  • _buffer.begin() 返回一个迭代器(iterator),指向vector的第一个元素。
  • *_buffer.begin() 解引用这个迭代器,得到第一个元素的引用(即char&)。
  • &*_buffer.begin() 取得这个引用的地址,即得到指向第一个元素的指针(char*)。

为什么不能直接使用 _buffer.data() 呢?

在C++11及以后,vector提供了data()成员函数,它返回指向底层数组的指针。所以,在C++11中,我们可以直接使用_buffer.data()来获取指针。

但是,注意:这里的代码可能是在C++11之前编写的,或者为了兼容没有data()方法的旧版本。另外,即使有data(),使用迭代器的方式也是通用的。

另外,注意:_buffer是std::vector<char>类型,它的元素是char,所以begin()返回的是std::vector<char>::iterator,解引用后得到char,然后取地址得到char*。

但是,这里有一个潜在的问题:如果vector为空,_buffer.begin()返回的迭代器等于end(),那么解引用它是未定义行为。因此,在调用Begin()之前,必须确保缓冲区不为空。然而,在构造函数中,我们使用_buffer(BUFFER_DEFAULT_SIZE)进行了初始化,所以缓冲区大小至少为BUFFER_DEFAULT_SIZE(非零),因此这里调用是安全的。

3.2.2.细节二

cpp 复制代码
// 获取当前写入起始地址:缓冲区起始地址 + 写偏移量
char *WritePosition()
{
    return Begin() + _writer_idx;
}

我们知道Begin()返回的是char*,而这个_writer_idx又是一个uint_64,两者相加等于啥??

这就需要去复习一下指针运算了!!

cpp 复制代码
// 假设在内存中的布局(每个int占4字节):
// 地址:   0x1000    0x1004    0x1008    0x100C    0x1010
// 数据:   numbers[0] numbers[1] numbers[2] numbers[3] numbers[4]
// 值:     10        20        30        40        50

int *p = numbers;  // p = 0x1000

// 指针加1不是加1个字节!
p = p + 1;        // p 现在指向 0x1004,不是 0x1001!
                 // 实际上:0x1000 + 1 * sizeof(int) = 0x1000 + 4 = 0x1004

我们看个例子就明白了

cpp 复制代码
char chars[5];    // 每个char占1字节
int ints[5];      // 每个int通常占4字节
double doubles[5]; // 每个double通常占8字节

char *pc = chars;
int *pi = ints;
double *pd = doubles;

pc = pc + 1;    // 地址加 1 字节
pi = pi + 1;    // 地址加 4 字节  
pd = pd + 1;    // 地址加 8 字节

char类型的特点:char 在C/C++中定义为1字节。这是标准规定的。

指针算术的自动缩放:

  • 当对 char* 指针加一个整数 N 时,编译器会自动计算:新地址 = 原地址 + N × sizeof(char)
  • 因为 sizeof(char) = 1,所以实际计算简化为:新地址 = 原地址 + N

所以Begin() + _writer_idx;返回的就是真实的地址

3.2.3.细节三

cpp 复制代码
std::copy(first, last, d_first);

这是·一个专门用于复制数据的

  • first: 源范围的起始位置(迭代器)
  • last: 源范围的结束位置(迭代器)
  • d_first: 目标范围的起始位置(迭代器)

也就是说将[first,last)的数据复制到d_first位置上去

注意范围是半开区间 [first, last)

  • 包含first指向的元素
  • 不包含last指向的元素
  • 所以实际复制的是从first到last-1的元素

示例1

cpp 复制代码
#include <algorithm>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> src = {1, 2, 3, 4, 5, 6, 7, 8};
    std::vector<int> dst(4);  // 预先分配空间
    
    // 复制src中的前4个元素到dst
    std::copy(src.begin(), src.begin() + 4, dst.begin());
    
    // 输出结果
    for (int val : dst) {
        std::cout << val << " ";  // 输出: 1 2 3 4
    }
    
    return 0;
}

示例2

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    std::vector<int> data = {1, 2, 3, 4, 5};
    std::vector<int> result(10, 0);  // 10个元素,都初始化为0
    
    // 从第3个位置开始复制(索引2)
    std::copy(data.begin(), data.end(), result.begin() + 2);
    
    std::cout << "复制后的结果:";
    for (int num : result) {
        std::cout << num << " ";  // 输出:0 0 1 2 3 4 5 0 0 0
    }
    
    return 0;
}

3.2.4.细节四

cpp 复制代码
// 查找CRLF位置:在可读数据中搜索换行符,用于按行读取
    char *FindCRLF()
    {
        // 从读取位置开始查找换行符,搜索范围为可读数据大小
        char *res = (char *)memchr(ReadPosition(), '\n', ReadAbleSize());
        return res; // 返回找到的位置,如果没有找到返回NULL
    }

我们注意到这里使用了memchr函数

memchr 是 C 标准库中的一个函数,用于在内存块中搜索特定字符。它位于 <cstring> 头文件中(C++中)或 <string.h>(C中)。

函数原型:

cpp 复制代码
void* memchr(const void* ptr, int ch, size_t count);

参数:

  • ptr:指向要搜索的内存块的指针。
  • ch:要搜索的字符(以 int 形式传递,但会被转换为 unsigned char)。
  • count:要搜索的字节数。

返回值:

  • 如果找到字符,则返回指向该字符的指针。
  • 如果未找到,则返回空指针(nullptr)。

工作原理:

memchr 从 ptr 开始的位置,逐字节地搜索,直到找到字符 ch 或搜索完 count 个字节。

注意:

  • 搜索是以字节为单位,因此通常用于搜索内存块中的某个字节值。
  • 字符 ch 会被转换为 unsigned char,因此即使传入负数,也会被当作 0~255 的范围。

示例1

cpp 复制代码
#include <iostream>
#include <cstring>

int main() {
    const char* str = "Hello, World!";
    char target = 'W';
    
    // 搜索字符 'W'
    const char* result = static_cast<const char*>(memchr(str, target, strlen(str)));
    
    if (result != nullptr) {
        std::cout << "找到字符 '" << target << "' 在位置: " << (result - str) << std::endl;
        std::cout << "从该位置开始的字符串: " << result << std::endl;  // 输出: World!
    } else {
        std::cout << "未找到字符 '" << target << "'" << std::endl;
    }
    
    return 0;
}

实例2

cpp 复制代码
#include <iostream>
#include <cstring>

int main() {
    const char* text = "Hello\nWorld\nC++";
    
    // 找第一个换行符
    const char* pos = static_cast<const char*>(memchr(text, '\n', strlen(text)));
    
    if (pos != nullptr) {
        std::cout << "找到换行符了!\n";
        std::cout << "换行符在位置:" << (pos - text) << "\n";
        
        // 显示第一行
        std::cout << "第一行:";
        for (int i = 0; text[i] != '\n'; i++) {
            std::cout << text[i];
        }
        std::cout << "\n";
        
        // 显示第二行
        std::cout << "第二行:" << (pos + 1) << "\n";
    } else {
        std::cout << "没有换行符\n";
    }
    
    return 0;
}

3.2.5.细节五

cpp 复制代码
// 以字符串string形式读取指定长度的数据:读取数据并转换为字符串,不更新读偏移
    std::string ReadAsString(uint64_t len)
    {
        // 要求要获取的数据大小必须小于或等于可读数据大小
        assert(len <= ReadAbleSize());
        std::string str;    // 创建字符串对象
        str.resize(len);    // 预分配字符串空间
        Read(&str[0], len); // 读取数据到字符串中
        return str;         // 返回字符串
    }

我们这里为什么使用

cpp 复制代码
Read(&str[0], len); // 读取数据到字符串中

这里为什么使用&str[0]来表示数组地址

  • std::string 本质上是一个字符数组的封装
  • 在内存中,它存储一个 char 数组(就像 char str[])
  • 通过 str[0] 可以访问第一个字符
  • &str[0] 获取这个字符的地址(也就是整个字符串缓冲区的起始地址)

为什么不直接传递str进去?

  • str 是一个 std::string 对象,不是字符指针
  • Read() 函数需要一个指向内存缓冲区的指针(地址)

3.3.代码测试

cpp 复制代码
#include <iostream>
#include "Buffer.hpp"

int main() {
    std::cout << "=== Buffer类测试开始 ===" << std::endl;
    
    // 1. 创建Buffer对象
    Buffer buf;
    std::cout << "1. 创建Buffer对象" << std::endl;
    
    // 2. 测试基本属性
    std::cout << "\n2. 初始状态测试:" << std::endl;
    std::cout << "可读数据大小: " << buf.ReadAbleSize() << std::endl;
    std::cout << "头部空闲空间: " << buf.HeadIdleSize() << std::endl;
    std::cout << "尾部空闲空间: " << buf.TailIdleSize() << std::endl;
    
    // 3. 测试写入数据
    std::cout << "\n3. 写入数据测试:" << std::endl;
    std::string test_str = "Hello, Buffer!";
    buf.WriteStringAndPush(test_str);
    std::cout << "写入字符串: \"" << test_str << "\"" << std::endl;
    std::cout << "写入后可读数据大小: " << buf.ReadAbleSize() << std::endl;
    std::cout << "写入后尾部空闲空间: " << buf.TailIdleSize() << std::endl;
    
    // 4. 测试读取数据
    std::cout << "\n4. 读取数据测试:" << std::endl;
    std::string read_str = buf.ReadAsStringAndPop(test_str.size());
    std::cout << "读取字符串: \"" << read_str << "\"" << std::endl;
    std::cout << "读取后可读数据大小: " << buf.ReadAbleSize() << std::endl;
    
    // 5. 测试多行数据
    std::cout << "\n5. 多行数据测试:" << std::endl;
    std::string line1 = "这是第一行\n";
    std::string line2 = "这是第二行\n";
    std::string line3 = "这是第三行";
    
    buf.WriteStringAndPush(line1);
    buf.WriteStringAndPush(line2);
    buf.WriteStringAndPush(line3);
    
    std::cout << "写入3行数据后,可读数据大小: " << buf.ReadAbleSize() << std::endl;
    
    // 6. 测试按行读取
    std::cout << "\n6. 按行读取测试:" << std::endl;
    std::cout << "第一行: \"" << buf.GetLineAndPop() << "\"";
    std::cout << "第二行: \"" << buf.GetLineAndPop() << "\"";
    std::cout << "剩余数据: \"" << buf.ReadAsStringAndPop(buf.ReadAbleSize()) << "\"" << std::endl;
    
    // 7. 测试大容量写入和缓冲区自动调整
    std::cout << "\n7. 大容量写入测试:" << std::endl;
    std::string large_data(2048, 'X');  // 创建2048个'X'的字符串
    buf.WriteStringAndPush(large_data);
    std::cout << "写入2048字节数据后:" << std::endl;
    std::cout << "可读数据大小: " << buf.ReadAbleSize() << std::endl;
    std::cout << "缓冲区大小: " << buf.ReadAbleSize() + buf.HeadIdleSize() + buf.TailIdleSize() << std::endl;
    
    // 8. 测试清空缓冲区
    std::cout << "\n8. 清空缓冲区测试:" << std::endl;
    buf.Clear();
    std::cout << "清空后,可读数据大小: " << buf.ReadAbleSize() << std::endl;
    
    // 9. 测试查找功能
    std::cout << "\n9. 查找换行符测试:" << std::endl;
    std::string text = "第一行内容\n第二行内容\n";
    buf.WriteStringAndPush(text);
    
    char* crlf_pos = buf.FindCRLF();
    if (crlf_pos) {
        std::cout << "找到换行符,位置: " << (crlf_pos - buf.ReadPosition()) << std::endl;
        std::cout << "第一行内容: \"" << buf.GetLineAndPop() << "\"";
    } else {
        std::cout << "未找到换行符" << std::endl;
    }
    
    // 10. 综合测试
    std::cout << "\n10. 综合测试:" << std::endl;
    buf.Clear();
    
    // 写入不同类型的数据
    buf.WriteStringAndPush("整数: ");
    int num = 42;
    buf.WriteAndPush(&num, sizeof(num));
    
    buf.WriteStringAndPush(",浮点数: ");
    float pi = 3.14f;
    buf.WriteAndPush(&pi, sizeof(pi));
    
    std::cout << "写入混合数据后,可读数据大小: " << buf.ReadAbleSize() << std::endl;
    
    // 读取并显示
    std::string str_part = buf.ReadAsStringAndPop(13);  // "整数: " + 4字节整数
    std::cout << "读取文本部分: " << str_part << std::endl;
    
    std::cout << "\n=== Buffer类测试结束 ===" << std::endl;
    
    return 0;
}
相关推荐
阿巴~阿巴~几秒前
IPv4地址的边界与智慧:特殊用途、枯竭挑战与应对策略全景解析
运维·服务器·网络·网络协议·tcp/ip·ipv4·ipv4地址枯竭
开开心心就好3 分钟前
系统清理工具清理缓存日志,启动卸载管理
linux·运维·服务器·神经网络·cnn·pdf·1024程序员节
海奥华23 分钟前
Golang Channel 原理深度解析
服务器·开发语言·网络·数据结构·算法·golang
源远流长jerry6 分钟前
TCP 与 TLS 层面 HTTP/1 升级到 HTTP/2
网络协议·tcp/ip·http
松涛和鸣10 分钟前
48、MQTT 3.1.1
linux·前端·网络·数据库·tcp/ip·html
希赛网13 分钟前
网工备考,华为ENSP基础配置命令
服务器·网络·网络工程师·华为认证·命令行·ensp命令·网工备考
大小鱼鱼鱼与鱼.13 分钟前
linux磁盘扩展
linux·运维·服务器
Solar202516 分钟前
构建高可靠性的机械设备企业数据采集系统:架构设计与实践指南
java·大数据·运维·服务器·架构
北京耐用通信21 分钟前
工业通信中的“工业战狼”!耐达讯自动化CAN转PROFIBUS网关
网络·人工智能·物联网·网络协议·自动化·信息与通信
晚枫歌F23 分钟前
基于DPDK实现UDP收发理解网络协议
网络·网络协议·udp