0%

各类 AI 工具介绍

问答类产品

ChatGPT

聊天互动式工具(OpenAI 官网

该工具是由 OpenAI 公司,基于 GPT-3 模型,开发的一个聊天互动式工具。

目前应用最广的 AI 工具,个人认为回答正确率最高的AI工具。

其分为 ChatGPT-3.5、ChatGPT-4。

ChatGPT-4: ChatGPT-3.5 升级版产品(官网介绍)

官方宣传提升了逻辑推理能力,提高了回答正确率(GPT-4 Technical Report)

个人体验下来没有感觉到其回答正确率的提升(比如GPT-3.5回答正确率为60%GPT-4回答正确率类比为70%

优点:

  1. 免费使用(ChatGPT-3.5)
  2. 回答正确率高
  3. 可以提问所有问题

缺点:

  1. 需要翻墙
  2. 账号难于注册,账号经常封禁
  3. 使用过程中需要反复重新登陆
  4. 答案来源不详,无法求证
  5. 只能回答 2021年9月前的知识
  6. ChatGPT-4 个人使用需要国外信用卡订阅其每月20美元的服务,
  7. ChatGPT-4 调用起API进行提问,每个问题约为 0.5元(我曾一周花掉了30美元,但听说最近降价了)

chatkit

ChatGPT 套壳网站(调用 OpenAI 的 api)

优点:

  1. 无需翻墙,稳定,不存在账号封禁问题
  2. 无回答次数限制
  3. 可选用多个模型如 ChatGPT-3.5ChatGPT-4Claude
  4. 一次付费永久使用(个人版:30美元,企业版:145美元/50台设备)

缺点:

使用了3个月,暂无发现缺点,该服务高可用。

New Bing

Microsoft Edge 浏览器绑定的类 ChatGPT 工具

优点:

  1. 提供答案来源链接
  2. 使用搜索引擎提供信息,生成答案,具有即时性
  3. 免费使用,账号注册简单

缺点:

  1. 需要使用域名转发来规避中国对该网站的封禁
  2. 对于编程类问题回答正确率不及 ChatGPT 的一半
  3. 必须使用 Microsoft Edge 浏览器打开,或 Chrome 安装插件

Google Bard

  • ChatGPT 相同功能的产品
  • 使用过程中,认为没有 ChatGPT 回答正确率高,建议使用 ChatGPT 替代

HuggingChat

  • 与 ChatGPT 相同功能的产品
  • 可以使用中文提问,但回答是英文

Claude

[文心一言]https://yiyan.baidu.com/)

  • 与 ChatGPT 相同功能的产品
  • 之前使用中语意理解上没有 ChatGPT 好
  • 暂未发现其优点

通义千问

与 ChatGPT 相同功能的产品

使用不多,但是比较看好 阿里背后的云平台的算力支撑的AI服务

讯飞星火

  • 与 ChatGPT 相同功能的产品
  • 之前被曝出后台调用 ChatGPT 的 API(套壳工具)
  • 回答正确率过低,几乎无法使用

工具类 AI 产品

Github Copilot

代码补全工具。

集成于 VSCode、Vistual Studio 等工具中使用。

用于编程过程中使用,相当于高级的补全工具。

优点:

  1. 擅长补全通用算法,或开源库接口
  2. 可以学习你自己代码的风格,帮你生成代码
  3. 可以对于错误的代码给出修正意见
  4. 可以对简单函数生成单元测试
  5. 可以询问示例代码

缺点:

  1. 对于询问代码,生成单元测试和询问功能,需要通过等待列表,通常需要数周到一个月
  2. 业务耦合型代码,它并不擅长
  3. 只能询问代码相关问题,不能询问架构或者概念型问题
  4. 补全正确率差强人意,仅仅是达到了商用水平

ChatPDF

  • 基于上传文件的信息,来回答你的问题
  • 免费额度,120页、10M以内的PDF,一天 3个文件和50个问题
  • 缺点:
  1. 限制太多
  2. 付费需要国外信用卡
  3. 答非所问,几乎不可用

Chat2Doc

  • 基于上传文件的信息,来回答你的问题,中文支持较好
  • 未试用过无法评价

开源类

开源意味着可本地部署

ChatGLM

  • 开源大语言模型,可本地部署
  • 目前最流行的中文大语言模型,衍生产品有很多
  • 基于中文训练,对中文有较好的性能
  • 使用精简的数据集,本地部署只需6G显存
  • 由于我没有显卡,未尝试过本地部署

GPT4ALL

  • 可本地部署,不需要显卡
  • 多种模型可选
  • 一键部署,非常轻量化
  • 部分模型不能使用中文提问
  • 试用时,回答速度稍慢,正确率不高,无法使用(后续该技术继续演进,正确率可能得到优化)

本草

基于中文医学知识的LLaMA微调模型。

  • 医学类问答模型
  • 未试用过

ChatLaw

中文法律问答 AI。

  • 法律类问答模型
  • 未试用过

GitHub Copilot是GitHub和OpenAI合作开发的一个人工智能工具,用户在使用Visual Studio Code、Microsoft Visual Studio、Vim、Cursor或JetBrains集成开发环境时可以通过GitHub Copilot自动补全代码。GitHub于2021年6月29日对开公开该软件,GitHub Copilot于技术预览阶段主要面向Python、JavaScript、TypeScript、Ruby和Go等编程语言。

提高编码效率

Tab 键自动补全代码

1-1

结构体添加字段后,类内函数补全

1-2

C++ 标准库补全

1-3

  • 对比 C++ 标准库手册, 需要阅读繁杂的解释

image

使用注释生成补全代码

  • 通用算法

1-6-1

为代码生成注释

2-1

2-1-1

为函数生成测试用例

1-7

开源库学习

Asio 网络库代码

文件中包含 asio.hpp, 写出类名即可补全对应开源库的示例代码。

1-4-2

Curl API 代码

使用 Github Copilot 的问答功能可以直接询问代码。

1-4-1

提高代码阅读效率

  • 解释代码

2-2

  • 我们假设人是会出错的,所以我们需要调试找寻 BUG
  • 我们假设框架和流程设计基本正确,所以找寻问题的时间远大于改正问题的时间
  • 我们假设人是懒惰的,所以总是倾向于写更少的代码

PPT

调试日志打印类

转换字符串 to_string

应用场景: 想要把某种类型转换为字符串

简单的转换:

1
printf("%d", i);

实际中可能用到的转换:

1
2
3
4
5
6
7
8
9
10
printf("Integers\n");
printf("Decimal:\t%i %d %.6i %i %.0i %+i %i\n", 1, 2, 3, 0, 0, 4, -4);
printf("Hexadecimal:\t%x %x %X %#x\n", 5, 10, 10, 6);
printf("Octal:\t%o %#o %#o\n", 10, 10, 4);

printf("Floating point\n");
printf("Rounding:\t%f %.0f %.32f\n", 1.5, 1.5, 1.3);
printf("Padding:\t%05.2f %.2f %5.2f\n", 1.5, 1.5, 1.5);
printf("Scientific:\t%E %e\n", 1.5, 1.5);
printf("Hexadecimal:\t%a %A\n", 1.5, 1.5);

因为自己使用占用符可能会出现使用错误(VS 编译检测不出来, g++ 有部分警告), 为了正确和格式的统一现使用统一函数封装

基本类型的重载:

1
2
3
4
5
6
7
8
std::string to_string(int i);
std::string to_string(unsigned i);
std::string to_string(unsigned long i);
std::string to_string(unsigned long long x);
std::string to_string(long i);
std::string to_string(long long x);
std::string to_string(short x);
std::string to_string(unsigned short x);

特殊类型的重载:

1
2
3
4
5
6
7
/// 保留两位小数: 输出类似 1.12
std::string to_string(double d);
/// 返回字符串: "true" 或 "false"
std::string to_string(bool x);
/// 重载指针, 输出类似 0x123
template <typename T>
inline std::string to_string(T* const x);

使用 string_stream 拼接字符串

场景: 拼接字符串

C 是如何拼接字符串的?

1
2
3
4
char ac[1024] = {0};
strcat(ac, "Hello");
strcat(ac, "World");
strcat(ac, gos::to_string(123).c_str());

C++ 是如何拼接字符串的?

std::string 怎么实现拼接函数的

/// 编译错误

1
2
std::string str;
str = "123" + "456";

/// 编译成功

1
2
3
std::string str;
std::string strTemp("456");
str = "123" + strTemp;

重载 “+” 操作符,实现字符串拼接

1
std::string operator+(const std::string& strLeft, const std::string& strRight);

调用过程解析

1
2
3
4
5
str = "123" + strTemp;
<=>
str = operator+("123", strTemp);
<=>
str = operator+(std::string("123"), strTemp);

最后的拼接方式为:

1
2
3
4
5
std::string str;
std::string strTemp("World");
str = "Hello" + strTemp + gos::to_string(i);
<=>
str = ("Hello" + strTemp) + gos::to_string(123);

string_stream 是如何拼接字符串的?

1
2
gos::string_stream stream;
stream << "Hello" << "World" << 123;

那么 gos::string_stream 是如何实现的?

查看对象原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
class string_stream
{
public:
string_stream& operator<<(int i)
{
m_str += i;
return *this;
}

...
private:
std::string m_str;
};

解析调用过程:

1
2
3
4
gos::string_stream stream;
stream << "Hello";
<=>
stream.operator<<("Hello");

string_stream 对其他复杂类型的处理

1
2
3
4
5
6
7
8
9
10
11
12
/// 打印 socket 地址
string_stream& operator<<(const SOCKADDR_IN& addr_in);
/// 打印 PID_T
string_stream& operator<<(const PID_T& stPID);
/// 打印 vector
template <typename T>
string_stream& operator<<(const std::vector<T>& vec);
/// 打印 二进制数据
string_stream& operator<<(const std::vector<unsigned char>& vec);
/// 打印 map
template <typename Key, typename Value>
string_stream& operator<<(const std::map<Key, Value>& map);

string_stream 如何转换为 std::string

1
2
3
4
5
6
7
8
class string_stream
{
public:
std::string str()
{
return m_str;
}
};

使用 string_stream 打印结构体

1
2
3
4
5
6
struct STRUCT_T
{
public:
int i;
double d;
};

可以这样打印:

1
2
STRUCT_T st;
printf("%d, %f", st.i, st.d);

gos::string_stream 如何打印:

1
2
3
4
5
6
7
8
gos::string_stream& operator<<(gos::string_stream& out, const STRUCT_T& st)
{
out << st.i << st.d;
return out;
}

gos::string_stream stream;
stream << st;

考虑如下情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct STRUCT_T
{
private:
int i;
double d;
};

/// 该函数是否可行?
gos::string_stream& operator<<(gos::string_stream& out, const STRUCT_T& st)
{
out << st.i << st.d;
return out;
}

解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
struct STRUCT_T
{
private:
int i;
double d;

friend gos::string_stream& operator<<(gos::string_stream& out, const STRUCT_T& st)
{
out << st.i << st.d;
return out;
}
};

string_stream 与 GosLog 的适配

DBG

查看函数定义:

1
2
3
4
5
6
7
template <typename T>
std::string format_dbg_string(const T& x, const std::string& strName)
{
gos::string_stream stream;
stream << strName << "(" << x << ")";
return stream.str();
}
1
2
const char* szMsgName = "MsgName";
GosLog(LOG_INFO, "szMsgName: %s", gos::format_dbg_string(szMsgName, "szMsgName").c_str());

使用辅助宏定义:

1
2
3
4
5
#define DBG(x) gos::format_dbg_string(x, std::string(#x)).c_str()

GosLog(LOG_INFO, "szMsgName: %s", gos::format_dbg_string(szMsgName, "szMsgName").c_str());
<=>
GosLog(LOG_INFO, "%s", DBG(szMsgName));
DBG 给日志带来的改变
  1. 省去了输入变量名称的过程, 见下面示例:
1
GosLog(LOG_ERROR, "CRC error! %s, %s, %s, %s ", DBG(ucLocalCRC16_H), DBG(ucLocalCRC16_L), DBG(ucCRC16_H), DBG(ucCRC16_L);
  1. 可以打印任意类型,使用单一占位符%s规避了占位符错误导致的崩溃,见如下示例:

    1
    2
    3
    4
    5
    6
    7
    8
    int i = 123;
    double d = 1.123456;
    char* szMsgName = "AppGetCfgReq";
    bool b = false;
    GosLog(LOG_ERROR, "%s %s %s %s", DBG(i), DBG(d), DBG(szMsgName), DBG(b));

    /// 输出
    2022-10-16 08:08:43.908 [ERROR] [dis] :i(123) d(1.12) szMsgName(AppGetCfgReq), b(false)
  2. 可以打印结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class STRUCT_T
{
public:
int i;
double d;
friend gos::string_stream& operator<<(gos::string_stream& out, const STRUCT_T& st)
{
out << "STRUCT_T: " << DBG(&st);
out << DBG(st.i);
out << DBG(st.d);
return out;
}
};

GosLog(LOG_ERROR,"%s", DBG(st));
/// 输出
2022-10-16 08:08:43.908 [ERROR] [dis] :STRUCT_T: 0x2389472 i(123)d(1.12)

日志流 log_stream

使用 string_stream 实现字符串拼接, 使用析构函数调用打印日志函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class log_stream
{
public:
template <typename T>
log_stream& operator<<(const T& data)
{
m_stream << data;
return *this;
}

~log_stream()
{
GosLog(LOG_INFO, "%s", m_stream.str().c_str());
}

private:
gos::string_stream m_stream;
};

/// 定义辅助宏
#define LOG gos::log_stream() stream

LOG << "Hello" << "World";

异步日志 log_sync

原理

生产者-消费者模型

异步日志原理

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class log_sync : public GThread
{
public:
void log(const int& level, const std::string& strLog)
{
queue.push(level, strLog);
}

virtual GOS_THREAD_RET ThreadEntry(void* pPara)
{
while (true)
{
std::string strLog = queue.pop();
/// 写入文件
log_to_file("%s", strLog.c_str());
}
}
};

性能比较

测试环境:

  • CPU AMD Ryzen 7 PRO 4750U(8核16线程)

  • 16G

  • 固态硬盘

  • Win11 专业版 22H2

  • VS2022 C++20 Release

  • 测试框架为 google/benchmark

在短字符串和4K长度字符串,分别在 1线程、2线程、4线程和8线程下运行结果如下:

GosLog:
gos_log

log_stream:
log_stream

性能比较:

测试用例 GosLog log_sync GosLog/log_sync
短字符串 、 1线程 93.3478k/s 20.9662k/s 445%
短字符串 、 2线程 99.8429k/s 20.7688k/s 408%
短字符串 、 4线程 47.5319k/s 24.1499k/s 196%
短字符串 、 8线程 44.5061k/s 23.4319k/s 190%
4K 字符串 、 1线程 35.7577k/s 17.9889k/s 199%
4K 字符串 、 2线程 29.3773k/s 19.0816k/s 153%
4K 字符串 、 4线程 29.6178k/s 17.3948k/s 170%
4K 字符串 、 8线程 32.3738k/s 18.317k/s 176%

GosLog 在上面测试速度全面领先与 log_steam, 性能高出 1.54.4 倍。

备注:

图片

优缺点

优点:

  • 可以打印大于 4k 的字符串
  • 多线程调用不会串行等待
  • 不会输出到命令行,可以用作详细打印
  • 可以利用 operator<< 打印, 便于快速编写代码

缺点:

  • 性能远不如 GosLog
  • 调用打印和写入文件之间有延迟,在此期间崩溃会导致部分异步日志来不及写入文件
  • 生产者速度过快会导致丢弃部分异步日志

使用场景

  • 详细打印(如结构体内的成员信息), 不会导致 cmd 里刷新过快
  • 所有收发信令打印
  • 数据库数据打印(从数据库中读取到内存时)
  • 渲染视频帧线程打印(不会阻塞当前线程)

日志宏

日志级别辅助宏

1
2
3
4
#define LOGD gos::log_stream(LOG_DETAIL)
#define LOGI gos::log_stream(LOG_INFO)
#define LOGW gos::log_stream(LOG_WARN)
#define LOGE gos::log_stream(LOG_ERROR)

条件判断日志宏

1
2
3
4
5
6
7
8
#define LOG_IF(condition) ((condition) ? (LOG << #condition << ", ") : DoNothing()))

LOG_IF(vec.size() > 100) << "vec is too large!";
<=>
if(vec.size() > 100)
{
LOG << "vec.size() > 100" << "vec is too large!";
}

同样有日志等级区分:

1
2
3
4
#define LOGD_IF
#define LOGI_IF
#define LOGW_IF
#define LOGE_IF

特殊日志宏

LOG_EVERY_N

每 N 次打印一次日志

用于一些重复打印,如 ATS 报文每秒钟接收,每一条都打印则日志太多,全不打印则无法从日志中查看当前 ATS 报文是否在正常接收

1
2
/// 每 60 次打印一次
LOG_EVERY_N(60) << "Receive ATS Info!";
LOG_FIRST_N

前 N 次打印日志

通常用于程序启动时,确认各个线程是否正常启动,打印前 N 条日志。

1
2
/// 打印前五条日志
LOG_FIRST_N(5) << "Hello World!";
LOG_ONCE

只打印第一次的日志

1
2
3
4
5
While(true)
{
/// 该日志只打印一次
LOG_ONCE << "Thread is start!";
}

字符串类与日志类工具函数的演进路线图

![图片](../resource/2021_09_13_C++调试工具函数介绍_李建聪/Xmind 1666080438328.png)

时间相关类

计时器 tick_count

为了更好的记录开始时间和结束时间, 所以封装该对象来记录并获取对应时间间隔的打印。

1
2
3
4
5
6
7
class tick_count
{
public:
void start();
void finish();
std::string get_time_string();
};

其中 get_time_string() 函数返回 startfinish 的间隔时间, 根据时间间隔大小获取的时间单位不同((s), (ms), (us)), 这样打印可以通过搜索 (s) 来快速定位打印了秒级时间的日志。

查看耗时百分比 stop_watch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class stop_watch
{
public:
void tick(const std::string& strInfo)
{
/// 在消息容器中,记录信息和时间间隔
}

~stop_watch()
{
tick.finish("finish");
/// 遍历消息容器,并打印其间隔时间和百分比
}
};

调用方式:

1
2
3
4
5
6
7
8
9
10
11
void f()
{
gos::stop_watch sw;

st.tick("1");
...
st.tick("2");
...
st.tick("3");
...
}

会出现如下日志:

1
2
3
4
5
[2022-10-18 16:45:36.796] time:   5 (us), percent:  0.18% info: 1  <Function: AppRegisterDataManager::OnLoadRegisterInfoRsp, File: AppRegisterDataManager.cpp:17>
[2022-10-18 16:45:36.796] time: 1 (us), percent: 0.04% info: 2 <Function: AppRegisterDataManager::OnLoadRegisterInfoRsp, File: AppRegisterDataManager.cpp:17>
[2022-10-18 16:45:36.796] time: 2 (ms), percent: 99.74% info: 3 <Function: AppRegisterDataManager::OnLoadRegisterInfoRsp, File: AppRegisterDataManager.cpp:17>
[2022-10-18 16:45:36.796] time: 1 (us), percent: 0.04% info: finish <Function: AppRegisterDataManager::OnLoadRegisterInfoRsp, File: AppRegisterDataManager.cpp:17>
[2022-10-18 16:45:36.796] Total Time: 2ms, info: finish <Function: AppRegisterDataManager::OnLoadRegisterInfoRsp, File: AppRegisterDataManager.cpp:17>

根据百分比找出耗时占比最高的代码片段, 为优化提供思路

记录起止时间 interval_time_factory

为了灵活的获取间隔的时间,把 interval_time 做了一个单例用来全局查看时间间隔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class interval_time_factory
{
public:
void start(const std::string& strKey)
{
m_interval_time.insert(gos::to_string(p), gos_get_uptime_1us());
}

void finish(const std::string& strKey)
{
GosLog("%s interval time is %d", strKey.c_str(), m_interval_time[strKey]);
}

private:
/// key: 唯一主键, value: 间隔时间记录对象
std::map<std::string, interval_time> m_interval_time;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int* f1()
{
int *p = new int;
interval_time_factory::GetInstance().start(gos::to_string(p));
}

void f2(int* p)
{
delete p;
interval_time_factory::GetInstance().finish(gos::to_string(p));
}

f1();
...
f2();

在日志中即可查看该指针从 newdelete 所经过的时间。

应用场景举例:

如视频帧从回调函数进入播放队列,到从队列 pop 后渲染完成后执行 delete, 为了记住该视频帧从回调函数到最终渲染的延迟时间。

对象实例个数 object_counter

用与查看某个对象当前存活的实例有几个.

原理为: 在构造函数中计数加一,析构函数中计数减一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class object : public gos::object_counter
{
};

object obj0;

std::cout << obj0.get_count(); ///< 1
{
object obj1;
std::cout << obj1.get_count(); ///< 2
}

std::cout << obj0.get_count(); ///< 1

对象存活时间 object_live_time

用于查看某个对象,构造函数到析构函数的时间。

1
2
3
4
5
6
7
8
class object : public gos::object_live_time
{
};

{
object obj;
...
} /// obj 在此处调用析构函数

在日志中可以查看该对象的存活时间

性能调优类

判断函数执行时间 PROFILER

1
2
3
4
5
6
7
8
void f()
{
INT64 iStart = gos_get_uptime_1us();
...
INT64 iFinish = gos_get_uptime_1us();

GosLog(LOG_DETAIL, "f is spend time: %lld", iFinish - iStart);
}

实现原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class profiler
{
public:
profiler()
{
INT64 iStart = gos_get_uptime_1us();
}

~profiler()
{
INT64 iFinish = gos_get_uptime_1us();

GosLog(LOG_DETAIL, "f is spend time: %lld", iFinish - iStart);
}

private:
INT64 iStart;
};

/// 定义辅助宏
#define PROFILER() profiler(__FILE__, __FUNCTION__, __LINE__)

PROFILER() 宏定义了一个临时变量,函数析构则该临时变量析构,所以该宏定义计算的是从该宏定义开始,到该函数结束的时间

1
2
3
4
5
void f()
{
PROFILER();
...
}

但同时应注意, 不能在同一作用域调用两次该宏定义.

1
2
3
4
5
void f()
{
PROFILER();
PROFILER(); ///< 编译错误, 重定义
}

判断内存泄漏 MEMORY_CHECK

该宏定义依赖于 Windows 的系统函数。(LinuxC++ Builder 中无法使用)

具体实现也是在构造函数中记录当前程序使用的内存数, 析构函数中记录内存差值后打印是否内存泄漏。

业务辅助类

区间求值函数 clamp

实现功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const int& clamp(const int& value, const int& low, const int& height)
{
if(value < low)
{
/// 数值小于下界, 返回下界值
return low;
}
else if(height < value)
{
/// 数值大于上界, 返回上界值
return height;
}
else
{
/// 返回原始值
return value;
}
}

使用场景:

1
2
3
4
5
6
7
Conf.GetValue("max_timeout", iMaxTimeout);

/// 判断配置项是否配置超出范围
LOG_IF(iMaxTimeout != gos::clamp(iMaxTimeout, 0, 3600) << "config is out of range!";

/// 或者自动限制范围
iMaxTimeout = gos::clamp(iMaxTimeout, 0, 3600);

心跳业务类 heartbeat

把心跳业务封装成对象,用于其他业务调用.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class heartbeat
{
public:
heartbeat(int max_time_ms);
void live();
void is_live();
};

/// 最大超时时间为 3s
heartbeat obj(3000);

/// app 心跳消息来了
obj.live();

/// app 心跳消息来了
obj.live();

/// 在定时器中定期轮询
while(timer())
{
if(obj.is_live())
{
...
}
else
{
...
}
}

超时业务类 timeout

把超时业务封装成对象,用于其他业务调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class timeout
{
public:
timeout(int max_time_ms) : m_heartbeat(max_time_ms)
{
m_heartbeat.live();
}

bool is_timeout()
{
return !m_heartbeat.is_live();
}

private:
gos::heartbeat m_heartbeat;
};

/// 请呼业务呼入时, 定义 60s 超时的对象
timeout obj(60 * 1000);

/// 定时器轮询
if(obj.is_timeout())
{
...
}
else
{
...
}

模糊比较 approxfloat_approx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class approx
{
public:
approx(const int64_t& anchor, const int64_t& diff) : m_anchor(anchor), m_diff(diff) {}

/// 判断数值是否与本对象定义的数值相近, 数值在 [m_anchor - m_diff, m_anchor + m_diff] 返回真
bool equal(const int64_t& number)
{
return std::abs(number - m_anchor) <= m_diff;
}

private:
int64_t m_anchor; ///< 用于比较的锚点值
int64_t m_diff; ///< 浮动的数值
};
1
2
3
4
5
6
7
8
9
10
gos::tick_count timer;
timer.start(); ///< 计时开始
gos_sleep_ms(20);
timer.finish(); ///< 计时结束
int time = timer.get_ms();
/// 判断时间间隔是否在 20 正负 1 的范围
if(gos::approx(20, 1).equal(time))
{
...
}

互斥量辅助类 lock_guard

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class lock_guard
{
public:
lock_guard(gos::mutex& mutex) : m_mutex(mutex)
{
m_mutex.lock();
}

~lock_guard()
{
m_mutex.unlock();
}

private:
gos::mutex& m_mutex;
};

考虑如下情况:

  1. 多个函数返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
gos::mutex mutex;

void f()
{
std::lock_guard<gos::mutex> guard(mutex);

if(condition)
{
return;
}
else if(condition)
{
return;
}
else if(condition)
{
return;
}

return;
}
  1. 返回语句中有被保护数据的读写
1
2
3
4
5
int f2()
{
std::lock_guard<gos::mutex> guard(mutex);
return data;
}
  1. 抛出异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void f3()
{
std::lock_guard<gos::mutex> guard(mutex);

throw std::exception("抛出异常");

double d = 1 / 0;

if(queue.empty())
{
queue.front();
}

...

return;
}
  1. 多个锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
gos::mutex mutex1;
gos::mutex mutex2;

void f()
{
std::lock_guard<gos::mutex> guard1(mutex1);

if(condition)
{
return;
}

std::lock_guard<gos::mutex> guard2(mutex2);

return;
}

获取更新数据的 GetDifferenceBetweenVector

为了比较新旧数据,增加、删除或更新的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @brief 获取新旧两个 vector, 需要添加的元素,需要删除的元素,和重复的元素
* @param vecOld [in] 旧数据
* @param vecNew [in] 新数据
* @param vecAdd [out] 需要添加的元素
* @param vecDel [out] 需要删除的元素
* @param vecUnion [out] 需要更新的元素
* @return true
* @return false
* @author lijiancong(lijiancong@gbcom.com.cn)
* @date 2022-06-02 13:50:34
* @note
*/
template <typename T>
inline bool GetDifferenceBetweenVector(const std::vector<T>& vecOld, const std::vector<T>& vecNew,
std::vector<T>& vecAdd, std::vector<T>& vecDel, std::vector<T>& vecUnion);

使用场景: ATS 数据更新线路图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GetDifferenceBetweenVector(vecOldATS, vecNewATS, vecAdd, vecDel, vecUnion);

for(unsigned i = 0; i < vecAdd.size(); ++i)
{
/// 创建列车图标
}

for(unsigned i = 0; i < vecDel.size(); ++i)
{
/// 删除列车图标
}

for(unsigned i = 0; i < vecUnion.size(); ++i)
{
/// 更新列车图标位置
}

特殊字符转义函数 EscapeCharUtility

我们已知 GJson 是无法解析带有, "\",}, 但是保存调度台短信历史的时候,短信内容可能包含这些特殊字符。

" 转换为 %quotes;\ 转换为 %backslash;

所以采用把特殊字符转换为特定的字符串,再转换成 json 字符串,到服务器后解析出来再替换回来。

1
2
3
4
5
/// 特殊字符转码
gos::EncodeEscapeChar();

/// 特殊字符解码
gos::DecodeEscapeChar();

输入字符串检查对象 input_check

使用场景:

用户输入某个字符串,该字符串需要符合某种规则。

  • 所有字符是数字
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bool IsValid(char* sz)
{
if(!sz)
{
return false;
}

while(sz != '\0')
{
if(*sz < 0 || *sz > 9)
{
return false;
}
}
return true;
}
  • 所有字符是 a~zA~Z0~9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bool IsValid(char* sz)
{
if(!sz)
{
return false;
}

while(sz != '\0')
{
if(!isalnum(*sz))
{
return false;
}
}
return true;
}
  • MAC 地址中可能会出现 十六进制字符的间隔符号可能为 ‘:’、’-‘ 或者 ‘ ‘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool IsValid(char* sz)
{
if (!sz)
{
return false;
}

while (sz != '\0')
{
if (*sz != ':' && *sz != '-' && *sz != ' ' && !(*sz >= 'a' && *sz <= 'f') && !(*sz >= 'A' && *sz <= 'F') &&
!(*sz >= '0' && *sz <= '9'))
{
return false;
}
}
return true;
}
  • 强密码, 要求字符串中有数字、大小写字母和特殊符号
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool IsValid(char* sz)
{
if (!sz)
{
return false;
}

if ((遍历一遍,查看是否拥有至少一个数字字符)
&& (遍历一遍, 查看是否拥有至少一个大写字母)
&& (遍历一遍, 查看是否拥有至少一个小写字母)
&& (遍历一遍, 查看是否有至少一个数字字符))
{
return true;
}
else
{
return false;
}
}

在这些函数中,抽象出来了集中规则。

  • 字符符合某种规则(如数字、十六进制字符)
  • 字符串中所有字符,全部符合、全部不符合和部分符合规则
1
2
3
4
5
bool IsValid(const std::string& str)
{
/// 假如每个字符都符合特定规则(字符为数字)
return all_of(str, IsNumber);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bool IsNumber(const char c)
{
return c >= '0' && c <= '9';
}

typedef bool (*FUNC)(const char);

bool all_of(const std::string& str, FUNC pF)
{
for(unsigned i = 0; i < str.size(); ++i)
{
char c = str.at(i);
if(!pF(c))
{
return false;
}
}
return true;
}

bool IsValid(const std::string& str)
{
return all_of(str, IsNumber);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool IsUpcaseLetter(const char c)
{
return c >= 'A' && c <= 'Z';
}

bool any_of(const std::string& str, FUNC pF)
{
...
}

bool IsValid(const std::string& str)
{
/// 这个字符串有任一大写字母 且 有任一数字
return any_of(str, IsUpcaseLetter) && any_of(str, IsNumber);
}

用法:

input_check

1
2
3
4
5
6
bool IsValid(const std::string& str)
{
gos::input_check filter;
filter.AddRules(any_of, IsNumber).AddRules(any_of, IsUpcastLetter);
return filter.IsValid(str);
}

json 解析对象接口

定义:

1
2
3
4
5
6
7
8
9
10
11
12
class json_parser
{
public:
/// 虚析构函数, 防止内存泄漏
virtual ~json_parser() {}

/// 结构体转 Json 字符串
virtual bool StructToJson(std::string &str) const = 0;

/// Json 字符串转结构体
virtual bool JsonToStruct(const std::string &str) = 0;
};

用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class object : public json_parser
{
public:
int i;
std::string str;

/// 结构体转 Json 字符串
virtual bool StructToJson(std::string &str) const
{
GJsonParam Param;

Param.Add("int", i);
Param.Add("string", str);

strJson = Param.GetString();
return true;
}

virtual bool JsonToStruct(const std::string &str)
{
GJson Json;
return Json.Parse(str) && Json.GetValue("int", i) && Json.GetValue("string", str);
}
};

object obj;

std::string strJson;
/// 获取 json 字符串
obj.StructToJson(strJson);
/// 发送消息
SendMsg(strJson);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
template <typename T>
class json_parser
{
public:
/// 结构体转 Json 字符串
virtual bool StructToJson(std::string &str) const = 0;

/// Json 字符串转结构体
virtual bool JsonToStruct(const std::string &str) = 0;

/// 转化结构体数组到 Json 字符串
bool VectorToJson(const std::vector<T> &vec, std::string &str) const;

/// 转换 Json 字符串到结构体数组
bool JsonToVector(const std::string &strFormatString, std::vector<T> &vec) const;
};

class object : public json_parser<object>
{
public:
virtual bool StructToJson(std::string &str) const
{
...
}

virtual bool JsonToStruct(const std::string &str)
{
...
}
};

std::vector<object> vecObj;

object ObjTemp;
std::string strJson;
/// 数组 转换 json
ObjTemp.VectorToJson(vecObj, strJson);

/// json 转换 数组
ObjTemp.JsonToStruct(strJson, vecObj);

附加介绍: 智能指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class shared_ptr
{
public:
shared_ptr(T* p) : pCount(new int), p(pData)
{
*pCount = 1;
}

shared_ptr(const shared_ptr& stOther)
{
pCount = stOther.pCount;
pData = stOther.pData;

++(*pCount);
}

~shared_ptr()
{
--(*pCount);
if(*pCount == 0)
{
delete pCount;
delete pData;
}
}

private:
int* pCount;
T* pData;
};

void f()
{
shared_ptr obj0; ///< pCount == 1
{
shared_ptr obj1 = stOther; ///< pCount == 2
} ///< obj1 析构, pCount == 1

} ///< obj0 析构, pCount 自减为 0, 执行 delete 操作

在 C/C++ 中,内存管理是一个非常棘手的问题,我们在编写一个程序的时候几乎不可避免的要遇到内存的分配逻辑,这时候随之而来的有这样一些问题:是否有足够的内存可供分配? 分配失败了怎么办? 如何管理自身的内存使用情况? 等等一系列问题。在一个高可用的软件中,如果我们仅仅单纯的向操作系统去申请内存,当出现内存不足时就退出软件,是明显不合理的。正确的思路应该是在内存不足的时,考虑如何管理并优化自身已经使用的内存,这样才能使得软件变得更加可用。本次项目我们将实现一个内存池,并使用一个栈结构来测试我们的内存池提供的分配性能。最终,我们要实现的内存池在栈结构中的性能,要远高于使用 std::allocator 和 std::vector.

  • C++ 中的内存分配器 std::allocator
  • 内存池技术
  • 手动实现模板链式栈
  • 链式栈和列表栈的性能比较

内存池简介

内存池是池化技术中的一种形式。通常我们在编写程序的时候回使用 new delete 这些关键字来向操作系统申请内存,而这样造成的后果就是每次申请内存和释放内存的时候,都需要和操作系统的系统调用打交道,从堆中分配所需的内存。如果这样的操作太过频繁,就会找成大量的内存碎片进而降低内存的分配性能,甚至出现内存分配失败的情况。

而内存池就是为了解决这个问题而产生的一种技术。从内存分配的概念上看,内存申请无非就是向内存分配方索要一个指针,当向操作系统申请内存时,操作系统需要进行复杂的内存管理调度之后,才能正确的分配出一个相应的指针。而这个分配的过程中,我们还面临着分配失败的风险。

所以,每一次进行内存分配,就会消耗一次分配内存的时间,设这个时间为 T,那么进行 n 次分配总共消耗的时间就是 nT;如果我们一开始就确定好我们可能需要多少内存,那么在最初的时候就分配好这样的一块内存区域,当我们需要内存的时候,直接从这块已经分配好的内存中使用即可,那么总共需要的分配时间仅仅只有 T。当 n 越大时,节约的时间就越多。

二、主函数设计

我们要设计实现一个高性能的内存池,那么自然避免不了需要对比已有的内存,而比较内存池对内存的分配性能,就需要实现一个需要对内存进行动态分配的结构(比如:链表栈),为此,可以写出如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>   // std::cout, std::endl
#include <cassert> // assert()
#include <ctime> // clock()
#include <vector> // std::vector

#include "MemoryPool.hpp" // MemoryPool<T>
#include "StackAlloc.hpp" // StackAlloc<T, Alloc>

// 插入元素个数
#define ELEMS 10000000
// 重复次数
#define REPS 100

int main()
{
clock_t start;

// 使用 STL 默认分配器
StackAlloc<int, std::allocator<int> > stackDefault;
start = clock();
for (int j = 0; j < REPS; j++) {
assert(stackDefault.empty());
for (int i = 0; i < ELEMS; i++)
stackDefault.push(i);
for (int i = 0; i < ELEMS; i++)
stackDefault.pop();
}
std::cout << "Default Allocator Time: ";
std::cout << (((double)clock() - start) / CLOCKS_PER_SEC) << "\n\n";

// 使用内存池
StackAlloc<int, MemoryPool<int> > stackPool;
start = clock();
for (int j = 0; j < REPS; j++) {
assert(stackPool.empty());
for (int i = 0; i < ELEMS; i++)
stackPool.push(i);
for (int i = 0; i < ELEMS; i++)
stackPool.pop();
}
std::cout << "MemoryPool Allocator Time: ";
std::cout << (((double)clock() - start) / CLOCKS_PER_SEC) << "\n\n";

return 0;
}

在上面的两段代码中,StackAlloc 是一个链表栈,接受两个模板参数,第一个参数是栈中的元素类型,第二个参数就是栈使用的内存分配器。

因此,这个内存分配器的模板参数就是整个比较过程中唯一的变量,使用默认分配器的模板参数为 std::allocator,而使用内存池的模板参数为 MemoryPool

std::allocator 是 C++标准库中提供的默认分配器,他的特点就在于我们在 使用 new 来申请内存构造新对象的时候,势必要调用类对象的默认构造函数,而使用 std::allocator 则可以将内存分配和对象的构造这两部分逻辑给分离开来,使得分配的内存是原始、未构造的。

下面我们来实现这个链表栈。

三、模板链表栈

栈的结构非常的简单,没有什么复杂的逻辑操作,其成员函数只需要考虑两个基本的操作:入栈、出栈。为了操作上的方便,我们可能还需要这样一些方法:判断栈是否空、清空栈、获得栈顶元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <memory>

template <typename T>
struct StackNode_
{
T data;
StackNode_* prev;
};

// T 为存储的对象类型, Alloc 为使用的分配器, 并默认使用 std::allocator 作为对象的分配器
template <typename T, typename Alloc = std::allocator<T> >
class StackAlloc
{
public:
// 使用 typedef 简化类型名
typedef StackNode_<T> Node;
typedef typename Alloc::template rebind<Node>::other allocator;

// 默认构造
StackAlloc() { head_ = 0; }
// 默认析构
~StackAlloc() { clear(); }

// 当栈中元素为空时返回 true
bool empty() {return (head_ == 0);}

// 释放栈中元素的所有内存
void clear();

// 压栈
void push(T element);

// 出栈
T pop();

// 返回栈顶元素
T top() { return (head_->data); }

private:
//
allocator allocator_;
// 栈顶
Node* head_;
};

简单的逻辑诸如构造、析构、判断栈是否空、返回栈顶元素的逻辑都非常简单,直接在上面的定义中实现了,下面我们来实现 clear()push() 和 pop() 这三个重要的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 释放栈中元素的所有内存
void clear() {
Node* curr = head_;
// 依次出栈
while (curr != 0)
{
Node* tmp = curr->prev;
// 先析构, 再回收内存
allocator_.destroy(curr);
allocator_.deallocate(curr, 1);
curr = tmp;
}
head_ = 0;
}
// 入栈
void push(T element) {
// 为一个节点分配内存
Node* newNode = allocator_.allocate(1);
// 调用节点的构造函数
allocator_.construct(newNode, Node());

// 入栈操作
newNode->data = element;
newNode->prev = head_;
head_ = newNode;
}

// 出栈
T pop() {
// 出栈操作 返回出栈元素
T result = head_->data;
Node* tmp = head_->prev;
allocator_.destroy(head_);
allocator_.deallocate(head_, 1);
head_ = tmp;
return result;
}

总结

本节我们实现了一个用于测试性能比较的模板链表栈,目前的代码如下。在下一节中,我们开始详细实现我们的高性能内存池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// StackAlloc.hpp

#ifndef STACK_ALLOC_H
#define STACK_ALLOC_H

#include <memory>

template <typename T>
struct StackNode_
{
T data;
StackNode_* prev;
};

// T 为存储的对象类型, Alloc 为使用的分配器,
// 并默认使用 std::allocator 作为对象的分配器
template <class T, class Alloc = std::allocator<T> >
class StackAlloc
{
public:
// 使用 typedef 简化类型名
typedef StackNode_<T> Node;
typedef typename Alloc::template rebind<Node>::other allocator;

// 默认构造
StackAlloc() { head_ = 0; }
// 默认析构
~StackAlloc() { clear(); }

// 当栈中元素为空时返回 true
bool empty() {return (head_ == 0);}

// 释放栈中元素的所有内存
void clear() {
Node* curr = head_;
while (curr != 0)
{
Node* tmp = curr->prev;
allocator_.destroy(curr);
allocator_.deallocate(curr, 1);
curr = tmp;
}
head_ = 0;
}

// 入栈
void push(T element) {
// 为一个节点分配内存
Node* newNode = allocator_.allocate(1);
// 调用节点的构造函数
allocator_.construct(newNode, Node());

// 入栈操作
newNode->data = element;
newNode->prev = head_;
head_ = newNode;
}

// 出栈
T pop() {
// 出栈操作 返回出栈结果
T result = head_->data;
Node* tmp = head_->prev;
allocator_.destroy(head_);
allocator_.deallocate(head_, 1);
head_ = tmp;
return result;
}

// 返回栈顶元素
T top() { return (head_->data); }

private:
allocator allocator_;
Node* head_;
};

#endif // STACK_ALLOC_H

// main.cpp

#include <iostream>
#include <cassert>
#include <ctime>
#include <vector>

// #include "MemoryPool.hpp"
#include "StackAlloc.hpp"

// 根据电脑性能调整这些值
// 插入元素个数
#define ELEMS 25000000
// 重复次数
#define REPS 50

int main()
{
clock_t start;

// 使用默认分配器
StackAlloc<int, std::allocator<int> > stackDefault;
start = clock();
for (int j = 0; j < REPS; j++) {
assert(stackDefault.empty());
for (int i = 0; i < ELEMS; i++)
stackDefault.push(i);
for (int i = 0; i < ELEMS; i++)
stackDefault.pop();
}
std::cout << "Default Allocator Time: ";
std::cout << (((double)clock() - start) / CLOCKS_PER_SEC) << "\n\n";

// 使用内存池
// StackAlloc<int, MemoryPool<int> > stackPool;
// start = clock();
// for (int j = 0; j < REPS; j++) {
// assert(stackPool.empty());
// for (int i = 0; i < ELEMS; i++)
// stackPool.push(i);
// for (int i = 0; i < ELEMS; i++)
// stackPool.pop();
// }
// std::cout << "MemoryPool Allocator Time: ";
// std::cout << (((double)clock() - start) / CLOCKS_PER_SEC) << "\n\n";

return 0;
}

二、设计内存池

在节中,我们在模板链表栈中使用了默认构造器来管理栈操作中的元素内存,一共涉及到了 rebind::otherallocate()dealocate()construct()destroy()这些关键性的接口。所以为了让代码直接可用,我们同样应该在内存池中设计同样的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#ifndef MEMORY_POOL_HPP
#define MEMORY_POOL_HPP

#include <climits>
#include <cstddef>

template <typename T, size_t BlockSize = 4096>
class MemoryPool
{
public:
// 使用 typedef 简化类型书写
typedef T* pointer;

// 定义 rebind<U>::other 接口
template <typename U> struct rebind {
typedef MemoryPool<U> other;
};

// 默认构造, 初始化所有的槽指针
// C++11 使用了 noexcept 来显式的声明此函数不会抛出异常
MemoryPool() noexcept {
currentBlock_ = nullptr;
currentSlot_ = nullptr;
lastSlot_ = nullptr;
freeSlots_ = nullptr;
}

// 销毁一个现有的内存池
~MemoryPool() noexcept;

// 同一时间只能分配一个对象, n 和 hint 会被忽略
pointer allocate(size_t n = 1, const T* hint = 0);

// 销毁指针 p 指向的内存区块
void deallocate(pointer p, size_t n = 1);

// 调用构造函数
template <typename U, typename... Args>
void construct(U* p, Args&&... args);

// 销毁内存池中的对象, 即调用对象的析构函数
template <typename U>
void destroy(U* p) {
p->~U();
}

private:
// 用于存储内存池中的对象槽,
// 要么被实例化为一个存放对象的槽,
// 要么被实例化为一个指向存放对象槽的槽指针
union Slot_ {
T element;
Slot_* next;
};

// 数据指针
typedef char* data_pointer_;
// 对象槽
typedef Slot_ slot_type_;
// 对象槽指针
typedef Slot_* slot_pointer_;

// 指向当前内存区块
slot_pointer_ currentBlock_;
// 指向当前内存区块的一个对象槽
slot_pointer_ currentSlot_;
// 指向当前内存区块的最后一个对象槽
slot_pointer_ lastSlot_;
// 指向当前内存区块中的空闲对象槽
slot_pointer_ freeSlots_;

// 检查定义的内存池大小是否过小
static_assert(BlockSize >= 2 * sizeof(slot_type_), "BlockSize too small.");
};

#endif // MEMORY_POOL_HPP

在上面的类设计中可以看到,在这个内存池中,其实是使用链表来管理整个内存池的内存区块的。内存池首先会定义固定大小的基本内存区块(Block),然后在其中定义了一个可以实例化为存放对象内存槽的对象槽(Slot_)和对象槽指针的一个联合。然后在区块中,定义了四个关键性质的指针,它们的作用分别是:

  1. currentBlock_: 指向当前内存区块的指针
  2. currentSlot_: 指向当前内存区块中的对象槽
  3. lastSlot_: 指向当前内存区块中的最后一个对象槽
  4. freeSlots_: 指向当前内存区块中所有空闲的对象槽

梳理好整个内存池的设计结构之后,我们就可以开始实现关键性的逻辑了。

三、实现

MemoryPool::construct() 实现

MemoryPool::construct() 的逻辑是最简单的,我们需要实现的,仅仅只是调用信件对象的构造函数即可,因此:

1
2
3
4
5
// 调用构造函数, 使用 std::forward 转发变参模板
template <typename U, typename... Args>
void construct(U* p, Args&&... args) {
new (p) U (std::forward<Args>(args)...);
}

MemoryPool::deallocate() 实现

MemoryPool::deallocate() 是在对象槽中的对象被析构后才会被调用的,主要目的是销毁内存槽。其逻辑也不复杂:

1
2
3
4
5
6
7
8
9
// 销毁指针 p 指向的内存区块
void deallocate(pointer p, size_t n = 1) {
if (p != nullptr) {
// reinterpret_cast 是强制类型转换符
// 要访问 next 必须强制将 p 转成 slot_pointer_
reinterpret_cast<slot_pointer_>(p)->next = freeSlots_;
freeSlots_ = reinterpret_cast<slot_pointer_>(p);
}
}

MemoryPool::~MemoryPool() 实现

析构函数负责销毁整个内存池,因此我们需要逐个删除掉最初向操作系统申请的内存块:

1
2
3
4
5
6
7
8
9
10
// 销毁一个现有的内存池
~MemoryPool() noexcept {
// 循环销毁内存池中分配的内存区块
slot_pointer_ curr = currentBlock_;
while (curr != nullptr) {
slot_pointer_ prev = curr->next;
operator delete(reinterpret_cast<void*>(curr));
curr = prev;
}
}

MemoryPool::allocate() 实现

MemoryPool::allocate() 毫无疑问是整个内存池的关键所在,但实际上理清了整个内存池的设计之后,其实现并不复杂。具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 同一时间只能分配一个对象, n 和 hint 会被忽略
pointer allocate(size_t n = 1, const T* hint = 0) {
// 如果有空闲的对象槽,那么直接将空闲区域交付出去
if (freeSlots_ != nullptr) {
pointer result = reinterpret_cast<pointer>(freeSlots_);
freeSlots_ = freeSlots_->next;
return result;
} else {
// 如果对象槽不够用了,则分配一个新的内存区块
if (currentSlot_ >= lastSlot_) {
// 分配一个新的内存区块,并指向前一个内存区块
data_pointer_ newBlock = reinterpret_cast<data_pointer_>(operator new(BlockSize));
reinterpret_cast<slot_pointer_>(newBlock)->next = currentBlock_;
currentBlock_ = reinterpret_cast<slot_pointer_>(newBlock);
// 填补整个区块来满足元素内存区域的对齐要求
data_pointer_ body = newBlock + sizeof(slot_pointer_);
uintptr_t result = reinterpret_cast<uintptr_t>(body);
size_t bodyPadding = (alignof(slot_type_) - result) % alignof(slot_type_);
currentSlot_ = reinterpret_cast<slot_pointer_>(body + bodyPadding);
lastSlot_ = reinterpret_cast<slot_pointer_>(newBlock + BlockSize - sizeof(slot_type_) + 1);
}
return reinterpret_cast<pointer>(currentSlot_++);
}
}

四、与 std::vector 的性能对比

我们知道,对于栈来说,链栈其实并不是最好的实现方式,因为这种结构的栈不可避免的会涉及到指针相关的操作,同时,还会消耗一定量的空间来存放节点之间的指针。事实上,我们可以使用 std::vector 中的 push_back() 和 pop_back() 这两个操作来模拟一个栈,我们不妨来对比一下这个 std::vector 与我们所实现的内存池在性能上谁高谁低,我们在 主函数中加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 比较内存池和 std::vector 之间的性能
std::vector<int> stackVector;
start = clock();
for (int j = 0; j < REPS; j++) {
assert(stackVector.empty());
for (int i = 0; i < ELEMS; i++)
stackVector.push_back(i);
for (int i = 0; i < ELEMS; i++)
stackVector.pop_back();
}
std::cout << "Vector Time: ";
std::cout << (((double)clock() - start) / CLOCKS_PER_SEC) << "\n\n";

软件自动化测试是指利用自动化工具和脚本来执行测试任务和验证软件系统的过程。它通过编写脚本和使用自动化工具来模拟用户操作、执行测试用例、比较预期结果和实际结果,从而自动化执行软件测试过程。

自动化测试目的

为什么要使用自动化测试?

  1. 节省人力
  2. 质量保障
  3. 量化代码质量(代码测试覆盖率)
  4. 便于重构
  5. 回归测试的快速迭代

C++ 自动化测试框架的选择

自动化测试框架 GitHub Starts Standard Support Header-only Fixtures Mock BDD-style
Google Test 30.2k C++14 no yes yes no
Catch2 16.8k C++14 yes yes no yes
DOCTest 5.0k C++14 yes yes no no
  • Google Test 使用人数最多,功能最全面。
  • Catch2 易于集成,支持 BDD-style,但是不支持 Mock
  • DOCTest 易于集成,不支持 Mock

GoogleTest 简介

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <gtest/gtest.h>

int f(int i)
{
retrun i;
}

TEST(f,simple_test)
{
ASSERT_TRUE(f(1) == 1);
ASSERT_EQ(f(1), 1);
}

// 主函数
int main(int argc, char** argv)
{
// 初始化 Google Test 框架
::testing::InitGoogleTest(&argc, argv);
// 运行所有测试用例
return RUN_ALL_TESTS();
}

运行结果:

image

  • 改变函数
1
2
3
4
int f(int i)
{
retrun i+1;
}

运行结果:

image

  • 类推其他复杂函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void f(std::string str, std::string& out)
{
out = str;
}

TEST(f, simple_test)
{
std::string in = "Hello world";
std::string out;

f(in, out);

ASSERT_EQ(out, in);
}

image

  • 为失败添加更多说明
1
2
ASSERT(st.IsSuccess()) << "st is not success!";
ASSERT(st.IsSuccess()) << DBG(st);

ASSERT 宏说明

GoogleTest ASSERT 官方说明{: .btn .btn–success}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ASSERT_TRUE(condition);     ///< true
ASSERT_FALSE(condition); ///< false

ASSERT_EQ(val1,val2); ///< val1 == val2
ASSERT_NE(val1,val2); ///< val1 != val2

ASSERT_LT(val1,val2); ///< val1 < val2
ASSERT_LE(val1,val2); ///< val1 <= val2
ASSERT_GT(val1,val2); ///< val1 > val2
ASSERT_GE(val1,val2); ///< val1 >= val2

ASSERT_STRCASEEQ(str1,str2); ///< str1 == str2(忽略大小写)
ASSERT_FLOAT_EQ(val1,val2); ///< 浮点数比较,误差小于 4ULP

/// 数值模糊比较,std::abs(val1 - val2) <= abs_error
ASSERT_NEAR(val1,val2,abs_error);

/// 语句 statement 抛出异常,且异常的类型为 exception_type
ASSERT_THROW(statement,exception_type);
/// 语句 statement 抛出异常,但不限定异常类型
ASSERT_ANY_THROW(statement);
/// 语句 statement 不抛出异常
ASSERT_NO_THROW(statement);

/// 验证该语句导致进程以非零退出状态终止,stderr 输出 matcher
ASSERT_DEATH(statement,matcher);

测试夹具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class class_name : public ::testing::Test
{
public:
class_name()
{
/// 打开数据库链接
m_pDao = new mysql();
}

~class_name()
{
/// 关闭数据库链接
delete m_pDao;
}

CfgDao* m_pDao = nullptr; ///< 数据库实例
};

TEST_F(class_name, description)
{
ASSERT_TRUE(m_pDao->InsertCallHistory(st));
}

多线程测试

GoogleTest 没有提供,一个测试用例,在多个线程中同时调用,以证明其线程安全。

但其实现原理可以手动实现,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

void GosLog(...)
{
...
}

void test_function()
{
/// 本线程先 sleep 2秒
std::this_thread::sleep_for(std::chrono::seconds(2));
/// 调用一万次函数
for(int i = 0 ; i < 10000; ++i)
{
GosLog("Hello world!");
}
}

TEST(f, simple_test)
{
std::deque<std::thread> threads;

for(unsigned i = 0; i < 2; ++i)
{
/// 使用 test_function 创建线程
vec.push_back(std::thread(test_function));
}

/// 等待每个线程结束
for(auto &thread : threads)
{
thread.join();
}
}

提取出重复的语句做测试夹具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include <thread>
#include <condition_variable>
/**
* @brief 多线程测试封装
* @author lijiancong
* @date 2023-01-19 17:27:15
* @note
*/
class mt_unittest : public ::testing::Test
{
protected:
mt_unittest() {}
~mt_unittest() override {}

// add new work item to the pool
template <class F, class... Args>
::testing::AssertionResult invoke(F&& f, Args&&... args)
{
unsigned cpu_thread = std::thread::hardware_concurrency();
if (cpu_thread <= 2)
{
/// 多线程测试要求测试机器, 硬件支持 3 线程以上
return ::testing::AssertionFailure() << "cpu_thread is less than 2. "
<< std::to_string(cpu_thread);
}

auto task = std::bind(std::forward<F>(f), std::forward<Args>(args)...);

std::vector<std::jthread> vec;
for (size_t i = 0; i < cpu_thread - 2; ++i)
{
vec.push_back(std::jthread(
[this, task]() mutable
{
std::unique_lock<std::mutex> mutex(mutex_);
/// 等待同步开始
cv_.wait(mutex, [this] { return ready_; });
/// 执行测试函数
task();
}));
}

{
std::lock_guard<std::mutex> guard{mutex_};
ready_ = true;
}
cv_.notify_all();
return ::testing::AssertionSuccess();
}

private:
std::mutex mutex_;
std::condition_variable cv_;
bool ready_ = false;
};

TEST_F(mt_unittest, GosLogTest)
{
ASSERT_TRUE(invoke(
[]()
{
for(int i = 0 ; i < 10000; ++i)
{
GosLog("Hello world!");
}
}));
}

模板函数测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @brief 擦除vector中特定值的元素
* @param vec [out] 要操作的vector
* @param value [in] 要擦除的值
* @return size_t 擦除元素的个数
* @author lijiancong
* @note
*/
template <typename Container>
inline size_t erase(Container& vec,
const typename Container::value_type& value)
{
typename Container::iterator it =
std::remove(vec.begin(), vec.end(), value);
size_t count = static_cast<size_t>(std::distance(it, vec.end()));
vec.erase(it, vec.end());
return count;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <typename T>
class gos_erase_test : public testing::Test
{
};

TYPED_TEST_SUITE(gos_erase_test, ::testing::Types<
std::vector<char>,
std::deque<char>,
std::string,
std::vector<int>,
std::deque<int>
>);

TYPED_TEST(gos_erase_test, find_simple_char)
{
TypeParam n = {'a', 'b', 'b', 'c', 'd'};
TypeParam cmp{'c', 'd'};
ASSERT_EQ(1, gos::erase(n, 'a'));
ASSERT_EQ(2, gos::erase(n, 'b'));
ASSERT_EQ(cmp, n);
}

Mock 简介

Mock 入门{: .btn .btn–success}

测试场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class SpeakManager
{
public:
bool TalkRequest(int number);
};

class GroupCall
{
public:
bool TalkRequest(int number, SpeakManager& SpeakManager)
{
return SpeakManager->TalkRequest(number);
}
};

TEST(GroupCall, simple_test)
{
auto p = new SpeakManager;
ASSERT_TRUE(Groupcall::GI().TalkRequest(4106, p));
}

由于 SpeakManager::TalkRequest(), 依赖于 SDK 的初始化, 依赖于通话存在,所以为单独测试 GroupCall::TalkRequest() 增加了难度。

实现方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85

#include "gtest/gtest.h"
#include "gmock/gmock.h"

/// 接口类
class SpeakManagerBase
{
public:
virtual bool TalkRequest(int number) = 0;
};

/// 原业务类
class SpeakManager : public SpeakManagerBase
{
public:
/// virtual bool TalkRequest(int number);
bool TalkRequest(int number) override;
};

/// 模拟类
class SpeakManagerMock : public SpeakManagerBase
{
public:
MOCK_METHOD(bool, TalkRequest, (int number), (override));
};

class GroupCall
{
public:
static GroupCall& GI()
{
static GroupCall instance;
return instance;
}

bool TalkRequest(int number, SpeakManagerBase& SpeakManager)
{
return SpeakManager.TalkRequest(number);
}
};

TEST(GroupCall, simple_test)
{
using namespace testing;

SpeakManagerMock SpeakManager;
/// 规定 TalkRequest 在任意入参时,将会总是返回 true
EXPECT_CALL(SpeakManager, TalkRequest(_)).WillRepeatedly(Return(true));
/// 进行测试结果
ASSERT_TRUE(GroupCall::GI().TalkRequest(4106, SpeakManager));
}

TEST(GroupCall, simple_test_1)
{
using namespace testing;

SpeakManagerMock SpeakManager;
/// 规定 TalkRequest 在入参为 4106 时,将会总是返回 true
EXPECT_CALL(SpeakManager, TalkRequest(4106))
.WillRepeatedly(Return(true));
/// 规定 TalkRequest 在入参在大于 4100 并且不等于 4106 的情况下,总是返回 false
EXPECT_CALL(SpeakManager, TalkRequest(AllOf(Gt(4100), Ne(4106))))
.WillRepeatedly(Return(false));

/// 进行测试结果
ASSERT_TRUE(GroupCall::GI().TalkRequest(4106, SpeakManager));

/// 进行测试结果
ASSERT_FALSE(GroupCall::GI().TalkRequest(4105, SpeakManager));
}

TEST(GroupCall, simple_test_2)
{
using namespace testing;

SpeakManagerMock SpeakManager;
/// 规定 TalkRequest 在任意入参时,第一次调用返回 true, 第二次返回 false
EXPECT_CALL(SpeakManager, TalkRequest(_))
.WillOnce(Return(true))
.WillOnce(Return(false));

/// 进行测试结果
ASSERT_TRUE(GroupCall::GI().TalkRequest(4106, SpeakManager));
ASSERT_FALSE(GroupCall::GI().TalkRequest(4106, SpeakManager));
}

自动化测试场景

通常自动化测试的测试用例之间不应存在影响,每个测试用例应拆分为最小可测试单元(函数),测试用例的顺序应互不影响。

工具函数测试

  • SQL 语句组装类测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
TEST(sql_utility, simple)
{
/// insert
gos::sql sql_string;
std::string str = sql_string.insert("operation_log")
.bind("UUID", "555")
.bind("Time", 666)
.bind("UserType", "777")
.bind("UserID", "888")
.bind("LogInfo", "999")
.sql();

ASSERT_EQ(str, "INSERT INTO operation_log(UUID, Time, UserType, UserID, LogInfo) VALUES(\"555\", 666, \"777\", \"888\", \"999\");");

/// delete
gos::sql sql;
std::string strSQL = sql.remove("operation_log")
.where("Time < :time")
.bind("time", 55).sql();
ASSERT_EQ(strSQL, "DELETE FROM operation_log WHERE Time < 55;");
}
  • 时间函数测试
1
2
3
4
5
6
7
8
9
10
11
TEST(gos_sleep_1ms, Simple_Test)
{
gos::tick_count timer;
/// 开始计时
timer.start();
gos_sleep_1ms(1000);
/// 结束计时
timer.finish();
/// 模糊比较,误差不超过 20ms
ASSERT_TRUE(gos::approx(1000, 20).equal(timer.get_ms()));
}

Json 接口函数测试

在客户端与服务端的通信中,如果使用 json 报文来传递事件,那么就存在 结构体序列化成 jsonjson 反序列化成结构体的过程,现在我们来测试这个过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class DC_TO_DIS_LOAD_SDS_HISTORY_C : public gos::json_parser<DC_TO_DIS_LOAD_SDS_HISTORY_C>
{
public:
std::string strDCLongNum; ///< M, 发送者长号码
UINT32 ulBeginTime; ///< M, 开始时间, 秒级时间戳
UINT32 ulEndTime; ///< M, 结束时间, 秒级时间戳
UINT32 ulCountLimit; ///< M, 最多回复记录数

bool operator==(const DC_TO_DIS_LOAD_SDS_HISTORY_C& stOther) const
{
return strDCLongNum == stOther.strDCLongNum &&
ulBeginTime == stOther.ulBeginTime &&
ulEndTime == stOther.ulEndTime &&
ulCountLimit == stOther.ulCountLimit;
}

/// 从 json 报文中解析出数值, 并赋予本结构体
virtual bool StructToJson(std::string& str) const;
/// 从结构体的数值拼装 json 报文
virtual bool JsonToStruct(const std::string& str);

/// 获取一个随机的本结构体
static DC_TO_DIS_LOAD_SDS_HISTORY_C GetRandomObj();
};

TEST(DC_TO_DIS_LOAD_SDS_HISTORY_C, simple_test)
{
auto st = DC_TO_DIS_LOAD_SDS_HISTORY_C::GetRandomObj();

std::string strJson;
/// 序列化
ASSERT(st.StructToJson(strJson));

/// 反序列化
DC_TO_DIS_LOAD_SDS_HISTORY_C stCmp;
ASSERT(stCmp.JsonToStruct(strJson));

/// 判断两个结构体是否相等
ASSERT_EQ(st, strCmp);
}

以上重复的步骤过多,封装后的测试用例较为整洁,如下。

1
2
3
4
5
6
7
8
9
TEST(DC_TO_DIS_LOAD_SDS_HISTORY_C, simple_test)
{
for(unsigned i = 0; i < 10; ++i)
{
auto st = DC_TO_DIS_LOAD_SDS_HISTORY_C::GetRandomObj();

ASSERT_TRUE(gos::test_json_parser(st));
}
}

数据库层自动化测试

数据库函数测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class CfgDaoTests : public ::testing::Test
{
protected:
/// 初始化数据库链接
CfgDaoTests();

/// 释放数据库链接
~CfgDaoTests() override;

/**
* @brief 查询 brd_info 表中是否有记录
* @param find [in]
* @return ::testing::AssertionResult
* @author lijiancong
* @date 2022-07-07 16:59:03
* @note
*/
::testing::AssertionResult HaveRowInBrdInfo(const DB_BRD_INFO_C& find)
{
/// 查询记录
std::vector<DB_BRD_INFO_C> vec;

if (!m_pDao->QueryBrdInfo(vec, 1000))
{
return ::testing::AssertionFailure() << "QueryBrdInfo failed!";
}

for (const auto& element : vec)
{
if (element == find)
{
return ::testing::AssertionSuccess();
}
}
return ::testing::AssertionFailure()
<< "can't find struct! " << DBG(find);
}
};

TEST_F(CfgDaoTests, brd_info_simple_test)
{
/// 创建随机的结构体
DB_BRD_INFO_C stDB = DB_BRD_INFO_C::GetRandomObj();

/// 验证没有该条记录
ASSERT_FALSE(HaveRowInBrdInfo(stDB));

/// 插入
ASSERT_TRUE(m_pDao->InsertBrdInfo(stDB));

/// 验证有该条记录
ASSERT_TRUE(HaveRowInBrdInfo(stDB));

/// 删除该条记录
ASSERT_TRUE(m_pDao->DeleteBrdInfo(stDB.GetKey()));

/// 验证没有该条记录
ASSERT_FALSE(HaveRowInBrdInfo(stDB));
}

TEST_F(CfgDaoTests, call_history_simple_test)
{
...
}

配置文件读取测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class dc_global_config_manager_test : public ::testing::Test
{
protected:
DC_GLOBAL_CONFIG_INFO_C cfg_ = DC_GLOBAL_CONFIG_INFO_C::GetRandomObj();
std::string m_strFile = "dc_global_config_manager_test.ini";

dc_global_config_manager_test()
{
if (gos_file_exist(m_strFile.c_str()))
{
gos_delete_file(m_strFile.c_str());
}

/// 生成配置文件
inifile::IniFile ini_file;

ini_file.SetBoolValue("DCGlobal", "is_use_link_switch",
cfg_.is_use_link_switch_);
ini_file.SetIntValue("DCGlobal", "ping_interval_time_ms",
cfg_.ping_interval_time_ms_);

/// 保存配置文件
ini_file.SaveAs(m_strFile);
}

~dc_global_config_manager_test()
{
if (gos_file_exist(m_strFile.c_str()))
{
gos_delete_file(m_strFile.c_str());
}
}
};

TEST_F(dc_global_config_manager_test, DC_GLOBAL_CONFIG_MANAGER)
{
ASSERT_TRUE(dc_global_config_manager::GI().load(m_strFile));

auto cfg = dc_global_config_manager::GI().get();

EXPECT_EQ(cfg_, cfg);
}

客户端软件集成测试

对于客户端大致有两个调用流程。

image

对于回调业务设计:

image

对于以上解决方案,需要客户端把业务类与界面类分开。

界面只根据输入数据进行更新显示。而业务类管理所有业务(管理业务信息)。

服务器软件集成测试

同样对于服务端程序,一样可以做代理类而测试业务。

image

image

服务器业务函数测试

代码举例:

1
2
3
4
5
6
7
8
9
10
11
VOID TaskAPP::OnAppRequestCallReq(const std::string &app_id,
const APP_TO_DIS_MSG_INFO_C &stAppMsgInfo)
{
if (!AppRegisterManager::GI().HandleAppRequestCallReq(app_id,
stAppMsgInfo, this, m_pDao))
{
GLOGE("AppRegisterManager HandleAppRequestCallReq failed! %s",
DBG(stAppMsgInfo));
return;
}
}

在事件函数中,使用单独的业务函数或业务类,把业务模块(AppRegisterManager)与数据库模块(m_pDao)还有事件调度模块(TaskApp)隔离开后,使用测试普通函数的模式测试该函数。

如何组织工程级别单元测试

使用 CMake 来组织工程文件。

CMake 简介

CMake是一个跨平台的自动化构建工具,用于管理软件项目的构建过程。它使用一种类似于脚本的语法(CMakeLists.txt文件)来描述项目的构建配置和依赖关系,并生成适合不同构建系统(如Make、Ninja、Visual Studio等)的构建脚本。
{: .notice–info}

  • CMake 使用示例:
1
cmake .
  • Windows 下生成的 VS 工程结构

image

  • Linux 下生成的 Makefile:

image

  • 编译工程
1
2
3
4
# 工程全部编译
cmake --build .
# 工程 clean 后单独编译 Release 下的 dis
cmake --build . --target dis_wz2 --config=Release --clean-first
  • 使用 CMake 命令自动运行单元测试:
1
2
3
4
5
# CMakeLists.txt 中增加如下语句,注册运行的单元测试
add_test(NAME GosTest COMMAND gos_unittest)

# 命令行运行 Debug 模式下的注册过的所有单元测试
ctest . -C Debug
  • VS 中可视化界面运行单元测试

image

单个文件夹下的文件组织

1
2
3
4
5
.
├── CMakeLists.txt
├── cp4_debug.cpp
├── cp4_debug.hpp
└── cp4_debug_unittest.cpp

CMakeLists.txt: 工程文件, 其中定义了生成静态库(libcp4_debug.a),生成测试的可执行文件(cp4_debug_unittest.exe)。

多个文件夹下的文件组织

1
2
3
4
5
6
7
8
9
10
11
12
13
.
├── CMakeLists.txt
├── db
│   ├── CMakeLists.txt
│   ├── CfgDao.cpp
│   └── CfgDao.h
│   └── CfgDao_unittest.cpp
├── middle
│   ├── CMakeLists.txt
│   ├── ATSManager.cpp
│   ├── ATSManager.h
│   ├── ATSManager_unittest.cpp
├── dis_main.cpp

服务器中,app/cfg/ 文件夹会分别生成静态库,然后文件 dis_main.cpplibdb.alibmiddle.a 生成 dis.exe

也同时生成对应的测试程序。

如何引用第三方库(如:GoogleTest

1
2
3
4
5
6
7
8
.
├── CMakeLists.txt
├── dis/
│   ├── ...
├── thirdparty/
│   ├── googletest/
│   ├── CMakeLists.txt
| ├─- ...
1
2
# CMakeLists.txt 中增加如下语句,引用第三方库
add_subdirectory(thirdparty/googletest)

然后需要使用该第三方库的工程,包含第三方库的头文件,并链接该第三方库的 libgtest.a 文件,即可正常使用第三方库。

如何确认测试覆盖率

  1. g++ 编译选项中添加: -fprofile-arcs-ftest-coverage
1
2
3
# CMakeLists.txt 中添加
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -fprofile-arcs -ftest-coverage")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -fprofile-arcs -ftest-coverage")
  1. 编译并运行单元测试
  2. 生成覆盖率信息文件
1
gcov <source_file>

直接使用 vim 打开该信息文件 test.c.gcov

image

  1. 使用lcov工具处理.gcov文件并生成可读的代码覆盖率报告。使用以下命令:
1
2
lcov -c -d <directory> -o coverage.info
genhtml coverage.info -o coverage_report
  1. 打开 coverage_report/index.html 查看覆盖率报告

image

image

提交代码前的自动化测试

在提交 push 前,自动生成并运行单元测试, 可以使用 git 自带的 pre-push.

1
2
3
4
5
6
7
8
9
10
11
.
├── .git
│   ├── branches
│   ├── COMMIT_EDITMSG
│   ├── config
│   ├── description
│   ├── FETCH_HEAD
│   ├── HEAD
│   ├── hooks
│   ├── pre-commit.sample
│   ├── pre-push.sample

Linux 下工程目录下, 找到 ./.git/hooks/pre-push.sample 文件,复制一份并重命名为 pre-push,然后修改 pre-push 文件内容为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git push" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the push.
#
# To enable this hook, rename this file to "pre-push".

# 自动格式化代码
python3 ./ClangFormat.py
# 添加格式化后的代码
git add .

# 运行单元测试
cmake -E chdir "build" cmake .. ; cmake --build ./build/ ; cmake -E chdir "build" ctest .. -C Release

# If the tests fail, prevent the push
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi

添加内容后,为该脚本文件增加可执行权限。 sudo chmod +x pre_push

如果该脚本运行过程中,异常结束则不会推送代码到远端,并提示错误。

高低位字节区分: 举例0x1234, 高位字节为 0x12, 低位字节为 0x34

  • 小端字节序:低位字节在低地址,高位字节在高地址,即0x12340x3412形式储存。CPU处理速度快。

280px-Little-Endian.svg

  • 大端字节序:高位字节在低地址,低位字节在高地址,这是人类读写数值的方法。即0x12340x1234形式储存。

280px-Big-Endian.svg

对比

Snipaste_2022-01-07_15-57-25

常见颜色与RGB值

豆沙绿

豆沙绿: 199 237 204 (#C7EDCC)

豆沙绿

护眼黄

护眼黄: 250 249 222 (#FAF9DE)

护眼黄

灰色

字灰色: 155 163 178(#9BA3B2)
背景灰色: 223 225 230(#DFE1E6)

字和背景灰色

蓝色

字蓝色: 41 97 180(#2961B4)
背景蓝色: 222 235 255(#DEEBFF)

字和背景蓝色

绿色

字绿色: 0 102 100(#006664)
背景绿色: 227 252 239(#E3FCEF)

字和背景绿色

字绿色: 19 99 53(#136335)
背景绿色: 198 239 206(#C6EFCE)

字和背景绿色绿色方案2

红色

字红色: 255 0 0(#FF0000)
背景橙色: 253 233 217(#FDE9D9)

字和背景红色

字红色: 128 0 0(#800000)
背景橙红色: 255 80 80(#FF5050)

字和背景红色方案2

字红色: 157 38 66(#9D2642)
背景红色: 255 199 206(#FFC7CE)

字和背景红色方案3

橙色

RGB: 255 214 88(#FFD658)

RGB: 248 178 32(#F8B220)

RGB: 255 153 0(#FF9900)

RGB: 245 105 82(#F56952)

绿色

RGB: 176 234 101(#B0EA65)

蓝色

RGB: 93 209 247(#5DD1F7)

紫色

RGB: 112 102 243(#7066F3)

C++容器的特性与适用场景, PPT

容器类别

首先放上一张来自《C++标准库》中的图片。

STL Container Types

序列式容器(Sequence container)

这是一种有序(ordered)集合,其内每个元素均有确凿的位置—-取决于插入时机和地点,与元素值无关。如果你以追加方式对一个集和置入6个元素,他们的排列次序将和置入次序一致。STL提供了5个定义好的序列式容器:array、vector、deque、list和forward_list。

关联式容器(Associative container)

这是一种已排序(sorted)集合,元素位置取决于其value(或key—-如果元素是个key/value pair)和给定的某个排序准则。如果将六个元素置入这样的集合中,他们的值将决定他们的次序,和插入次序无关。STL提供了4个关联式容器:set、multiset、map和multimap。

无序容器(Unordered (associative) container)

这是一种无序集合(unordered collection), 其内每个元素的每个位置无关紧要,唯一重要的是某特定元素是否位于此集合内。元素值或其安插顺序,都不影响元素的位置,而且元素的位置有可能在容器生命周期中被改变。如果你放6个元素到这种集合内,它们的次序不明确,并且可能随时间而改变。STL内含4个预定义的无序容器:unordered_set、unordered_multiset、unordered_map和unordered_multimap。

  • Sequence容器通常被实现为array或linked list

  • Associative容器通常被实现为binary tree

  • Unordered容器通常被实现为hash table

各种容器使用时机

ContainerSelect

  • 默认情况下应该使用std::vectorstd::vector的内部构造最简单,并允许随机访问,所以数据的访问十分方便灵活,数据的处理也够快。

  • 如果经常要在序列头部和尾部安插和一处元素,应该采用std::deque。如果你希望元素被移除时,容器能够自动缩减内部用量,那么也该使用std::deque。此外,由于std::vector通常采用一个内存区块来存放元素,而std::deque采用多个区块,所以后者可内含更多元素。

  • 如果需要经常在容器中段执行元素安插、移除和移动,可考虑使用std::liststd::list提供特殊的成员函数,可在常量时间内将元素从A容器转移到B容器。但由于std::list不支持随机访问,所以如果只知道list的头部却要造访list的中端元素,效能会大打折扣。和所有“以节点为基础”的容器相似,只要元素仍是容器的一部分,list就不会令指向那些元素的迭代器失效std::vector则不然,一旦超过其容量,它的所有iteratorpointerreference失效。至于std::deque,当它的大小改变,所有iteratorpointerreference都会失效。

  • 如果你要的容器对异常处理使得“每次操作若不成功便无任何作用”,那么应该选用std::list(但是不调用其assignment操作符和sort(), 而且如果元素比较过程中会抛出异常,就不要调用merge()、remove()、remove_if()和unique(),或选用associative/unordered容器(但不调用多元素安插动作,而且如果比较准则的复制/赋值动作可能抛出异常,就不要调用swap()或erase()))。

  • 如果你经常需要根据某个准则查找元素,应当使用“依据该准则进行hash”的std::unordered_setstd::multiset。然而,hash容器内是无序的,所以如果你必须以来元素的次序(order),应该使用std::setstd::multiset,他们根据查找准则对元素排序。

  • 如果想处理key/value pair,请采用unordered_mapstd::unordered_multimap。如果元素次序很重要,可采用std::mapstd::multimap

  • 如果需要关联式数组(associative array), 应采用unordered map。如果元素次序很重要,可采用 std::map

  • 如果需要字典结构,应采用unordered std::multimap。如果元素次序很重要,可采用std::multimap

ContainerTypes

Array Vector Dequeue List Forward List 关联容器 无序容器
可用标准 TR1 C++98 C++98 C++11 C++98 C++98 TR1
数据结构 静态数组 动态数组 数组的数组 双向链表 单向链表 二叉树 哈希表
元素类型 value value value value value set: value
map: key/value
set: value
map: key/value
是否允许重复 只有 multisetmultimap 允许重复 只有 multisetmultimap 允许重复
迭代器类型 随机访问 随机访问 随机访问 双向迭代器 单向迭代器 双向迭代器 单向迭代器
增长/缩小方式 不会增长/缩小 在一端末尾增长 在两端末尾增长 到处增长 到处增长 到处增长 到处增长
是否可以随机访问 差不多
查找元素 非常慢 非常慢 非常快
添加和删除操作是否会使迭代器无效 - 在重新申请内存时无效 总是无效 从不 从不 从不 在重新哈希时
添加和删除操作是否会使引用或指针无效 - 在重新申请内存时无效 总是无效 从不 从不 从不 从不
是否允许保留内存 - - - -
移除元素时释放内存 - 只有在shrink_to_fit()时释放内存 有时 总是 总是 总是 又是
事务安全(成功或没有影响) No 在尾部push/pop事务安全 在头部和尾部push/pop安全 所有的插入和擦除 所有的插入和擦除 假如比较函数没有出现异常,那么单个元素的插入和所有擦除操作都是事务安全的 假如比较函数和哈希函数没有出现异常,那么单个元素的插入和所有擦除操作都是事务安全的

容器的共同能力

  1. 所有容器提供的都是 “value 语义” 而非 “reference 语义”。容器进行元素的安插动作是,内部实施的是 copy 和/或 move 动作, 而不是管理元素的 reference。 如果不想要复制,那么只能使用 std::move 或 保存元素指针(不能使用引用来规避复制)。
  2. 元素在容器内有其特定顺序。每一种容器会提供若干返回迭代器的操作函数,这些迭代器可以用来遍历各个元素。如果你在元素之间迭代多次,你会获得相同的次序(不调用增删函数)
  3. 一般而言,各项操作并非绝对安全,也就是说他们不会检查每一个可能发生的错误。调用者必须确保传给操作函数的实参符合条件。
  4. 都提供如下成员函数
函数 注解
default construct
copy construct
destructor
begin()
end()
cbegin() after C++11
cend() after C++11
clear()
swap() std::array: O(n), 其他容器: O(1)
empty() empty() 的实现可能比 size() == 0 更有效率,尽可能使用该函数
size()
max_size()
empty()
operator==
operator!=
operator< 除了无序容器
operator<= 除了无序容器
operator> 除了无序容器
operator>= 除了无序容器

容器遍历方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// After C++11
for(auto element : container)
{
element;
}

/// 只对拥有随机访问迭代器的容器使用
for(size_t i = 0; i < container.size(); i++)
{
container[i]/container.at(i);
}

/// 所有元素通用
for(auto it = container.begin(); it != container.end(); ++it)
{
*it;
}

size() == 0 与 empty()

C++11 之前 std::list::empty() 函数的时间复杂度可能是 O(n) 也可能是 O(1).

std::array C++11

array

  • 随机访问
  • 固定大小, 编译期确定
  • 大小可为零

应用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// 作为返回值返回
std::array<int, 5> f()
{
std::array<int, 5> a;
return a;
}

/// 作为入参时确定入参大小,不会降级为指针
void f1(const std::array<int, 5>& a)
{
while(i < a.size()){}
while(i < 5){}
}

/// 搭配模板灵活使用
template<size_t N>
void f2(const std::array<int, N>& a)
{
while(i < a.size()){}
}

std::array<int, 6> a;
f2(a);

std::vector

vector

迭代器示意

iterator

reserve_iterator

at() 与 operator[]

at() 成员函数提供边界检查,超出边界时会抛出异常 std::out_of_range

size() 与 capacity()

size() 查看当前有几个元素

capacity() 查看预分配几个元素的空间

size

resize() 与 reserve()

resize(count)

重设容器大小以容纳 count 个元素。

若当前大小大于 count ,则减小容器为其首 count 个元素。

若当前大小小于 count ,

  1. 则后附额外的默认插入的元素
  2. 则后附额外的 value 的副本

resize

reserve(new_cap)

增加 vector 的容量到大于或等于 new_cap 的值。若 new_cap 大于当前的 capacity() ,则分配新存储,否则该方法不做任何事。

reserve() 不更改 vector 的 size 。

若 new_cap 大于 capacity() ,则所有迭代器,包含尾后迭代器和所有到元素的引用都被非法化。否则,没有迭代器或引用被非法化。

reserve

shrink_to_fit() (C++11)

请求移除未使用的容量。

它是减少 capacity() 到 size()非强制性请求。请求是否达成依赖于实现。

若发生重分配,则所有迭代器,包含尾后迭代器,和所有到元素的引用都被非法化。若不发生重分配,则没有迭代器或引用被非法化。

shrink_to_fit

C++11 之前可以使用如下方式,缩减空间

1
2
3
4
5
6
template<typename T>
void ShrinkCapacity(const std::vector<T>& v)
{
std::vector<T> tmp(v);
v.swap(tmp);
}

push_back() 与 push_front()

push_back()

push_front()

insert()

insert()

erase()

erase()

1
2
3
4
5
6
7
8
9
10
11
std::vector<int> vec{1,2,3};
std::vector<int>::iterator it = vec.begin();
while(it != vec.end())
{
if(condition)
{
it = vec.erase(it);
/// 下面这句话为错误
/// vec.erase(it++);
}
}

data()

返回指向作为元素存储工作的底层数组的指针。指针满足范围 [data(); data() + size()) 始终是合法范围,即使容器为空(该情况下 data() 不可解引用)。

拷贝 vector 中的数据到缓冲区

1
2
3
4
5
6
7
8
unsigned char auc[] = {1,2,3,4,5,6};

/// unsigned char 数组转换为 vector
std::vector<unsigned char> vec(auc, auc+sizeof(auc));

/// vector 转换为 unsigned char 数组
unsigned char* puc = new unsigned char[vec.size()];
memcpy(puc, vec.data(), vec.size());

std::vector < bool >

该容器不是 布尔类型的 std::vector, 而是有单独的实现,不应看作普通的 std::vector 使用。

异常处理

  1. 如果 push_back() 安插元素时发生异常,函数将不会产生效用。
  2. 如果元素的 copy/move 操作(包括构造函数和赋值运算符)不抛出异常,这意味着 insert()emplace()emplace_back()push_back()要么成功,要么什么也不发生。
  3. pop_back() 不会抛出任何异常
  4. 如果元素的 copy/move 的操作(包括构造函数和赋值运算符)不抛出异常, erase() 就不抛出异常
  5. swap()clear() 不抛出异常
  6. 如果元素的 copy/move 操作(包括构造函数和赋值运算符)不抛出异常, 那么所有操作不是成功就是不产生效用。这类元素可能是 POD

以上所有保证都基于一个条件: 析构函数不得抛出异常。

std::deque

提供随机访问,接口与 std::vector 几乎一致。可以在首尾快速安插和删除。

deque_base

通常实现为一组独立的区块,第一区块往一个方向发展,最末的区块往另一个方向发展。

deque_base

std::dequestd::vector 比较

相同之处

  • 在中段安插、移除元素的速度相对较慢,因为所有元素都需移动以腾出或填补空间。
  • 迭代器属于随机访问迭代器,可以使用 operator[] \ at()

不同之处

  • std::deque 可在常量时间内快速在首尾增删元素。 std::vector 只能在尾部。
  • 访问元素时, std::deque 多了一个跳转的过程(在各个区块跳转)
  • std::deque 的迭代器不是原始指针(因为各个区块之间不连续), 更没有 data 的成员函数
  • std::deque 不支持对容量和内存分配时机的控制。
  • std::deque 在首尾两端增删元素导致所有元素的迭代器失效(指针和引用仍有效),其他所有增删操作都会导致所有元素的指针、引用和迭代器失效。
  • std::deque 的内存分配优于 std::vector, std::deque 不必在内存分配时复制所有元素。
  • std::deque 会释放不再使用的内存区块。 std::deque 的内存大小是可缩减的, 但要不要这么做,以及如何做,由实现决定。
  • std::deque 不提供容量操作 capacity()reserve()
  • 在内存区块大小有限制的系统中, std::dequemax_size() 可能比 std::vectormax_size() 要大。 因为 std::deque 使用的不止一块内存。

适用场景

  • 需要在两端安插和移除元素
  • 无须指向容器内的元素
  • 要求使用内存会自动缩小

异常处理

原则上 std::deque 提供的异常处理和 std::vector 提供的一样

  • push_front()push_back() 安插元素时发生异常,则该操作不带来任何效应。
  • pop_front()pop_back() 不会抛出任何异常。

std::list

双向链表。

list

std::list 的内部结构完全迥异于 std::arraystd::vectorstd::dequestd::list 自身提供了两个指针,分别指向第一个元素和最后一个元素,如果操纵对应的指针即可。

list_insert

成员函数 splice 示意

list_splice

容器特性

  • 提供 front()push_front()pop_front()back()push_back()pop_back() 等操作函数。
  • 不提供 operator[]at()
  • 不支持随机访问。O(n)
  • 在任何位置插入元素非常快。O(1), 只是改变了指针指向。
  • 迭代器永久有效。插入和删除动作并不会造成指向其他元素的指针、引用和迭代器失效。
  • 异常安全 std::list 的异常处理为: 要么操作成功、要么什么都不发生。
  • 事务安全。 只要不调用赋值操作或 sort(), 并保证元素相互比较时不抛出异常那么std::list可以成为事务安全
  • 空间最优。没有空间重新分配和预分配内存, 没有冗余内存占用
  • 拥有较多的特殊成员函数,相较于 STL 中通用的同名函数,更具有效率。如 mergespliceremovereverseuniquesort

应用场景

特性: 前向迭代器

  • 排序
1
2
3
std::list<int> list;
list.sort();
/// 错误用法: std::sort(list.begin(), list.end());
  • 特殊的排序后显示
1
2
3
4
5
6
7
8
9
10
11
std::list<int> list;
list.push_back(5);
list.push_back(7);
list.push_back(2);
list.push_back(1);
list.push_back(8);
list.push_back(5);

std::vector<std::reference_wrapper<int> > Observer(l.begin(), l.end());

std::sort(Observer.begin(), Observer.end());

特性: 迭代器永不失效

  • 双键结构
1
2
3
4
5
6
7
8
9
10
11
12
template <class LEFT_KEY, class RIGHT_KEY, class VALUE>
class bimap
{
public:

...

private:
std::list<VALUE> m_Value; ///< 用于存放值
std::map<LEFT_KEY, iterator> m_LeftKeyMap; ///< 用于保存左键与值得映射关系的map
std::map<RIGHT_KEY, iterator> m_RightKeyMap; ///< 用于保存右键与值得映射关系的map
};

std::forward_list (C++11)

标准描述

std::forward_list 是支持从容器中的任何位置快速插入和移除元素的容器。不支持快速随机访问。它实现为单链表,且实质上与其在 C 中实现相比无任何开销。与 std::list 相比,此容器在不需要双向迭代时提供更有效地利用空间的存储。

forward_list

std::list 比较

  • std::forward_list 只提供前向迭代器,而不是双向迭代器。没有成员函数rbegin()rend()crbegin()crend()
  • std::forward_list 不提供成员函数 size()
  • std::forward_list 没有指向最末元素的指针。所以没有成员函数如back()push_back()pop_back()
  • 对于所有令元素被安插在或删除于的某特定位置上的成员函数, std::forward_list 都提供特殊版本。原因是你必须传递第一个被处理元素的前一位置,前向迭代器不能回头。
  • insert_after() 代替 insert(), 也额外提供 before_begin()cbefore_begin()

在起始处安插元素

forward_insert_begin

std::setstd::multiset

set

std::setstd::multiset 会根据特定的排序准则,自动将元素排序。两者不同之处在于 std::multiset 允许元素重复而 std::set 不允许。

如果没有传入某个排序准则,就采用默认准则 std::lessoperator< 对元素进行比较。

set

排序准则符合: 严格弱序

详细定义

  1. 必须是非对称的(antisymmetric)。

    operator< 而言, 如果x < y为true, 则y < x为false。

    对判断式(predicate) op()而言,如果op(x, y)为true,则op(y, x)为false。

  2. 必须是可传递的(transitive)。

operator< 而言,如果x < y 为true且y < z为true, 则x < z 为false。

对判断式(predicate) op()而言,如果op(x, y)为true且op(y, z)为tru,则op(x, z)为true。

  1. 必须是非自反的(irreflexive)

    operator< 而言,x < x 永远是false

    对判断式(predicate) op()而言,op(x, x)永远是false。

  2. 必须有等效传递性(transitivity of equivalence)

operator< 而言,假如 !(a<b) && !(b<a) 为true且 !(b<c) && !(c<b) 为 true
那么!(a<c) && !(c<a) 也为true.
对判断式(predicate) op()而言, 假如 op(a,b), op(b,a), op(b,c), 和op(c,b) 都为
false, 那么op(a,c) and op(c,a) 也为false.

简单的来说就是a<b返回true,a=b和a>b返回false。

定制排序规则 operator<

1
2
3
4
5
6
7
8
9
10
11
12
13
class CALL_INFO_C
{
public:
int x;
std::string y;

bool operator<(const CALL_INFO_C& stOther) const
{
return x < stOther.x || (x == stOther.x && y < stOther.y);
}
};

std::set<CALL_INFO_C> set;

其中 std::multiset 的等效元素的次序是随机但稳定的。(C++11以后标准保证新插入的元素,会被放在等效元素群的末尾)

std::setstd::multiset 的能力

  • 通常以平衡二叉树完成。
  • 自动排序的主要优点在于令二叉树于查找元素时拥有良好的性能。其查找函数具有 O(logn) 的时间复杂度。
  • 不能随意改变元素值,因为这会打乱原本正确的顺序。
  • 如果要改变元素值,必须先删除旧元素,再插入新元素。
  • 不提供任何操作函数可以直接访问底层元素
  • 通过迭代器进行元素间接访问,有一个限制: 从迭代器的角度看,元素值是常量.(例如不能使用: std::remove())
  • 其迭代器是双向迭代器(不能使用 std::sort())

std::mapstd::multimap

std::mapstd::multimapkey/value pair 当作元素进行管理。它们可根据 key 的排序准则自动为元素排序。 std::multimap 允许重复元素, std::map 不允许。

map

同样 key 需要可比较且遵循严格弱序。

std::mapstd::multimap 通常以平衡二叉树完成。

map

std::mapstd::multimap 也无法改变 key 的值。只能删除再插入。

operator[]

key不存在, 构造该元素后,返回元素的引用
key存在, 返回元素的引用

所以要警惕如下语句:

1
2
std::map<std::string, int> map;
std::cout << map["string"]; ///< 这里会默认插入一个元素 ("string", 0)
1
2
3
std::map<std::string, int> map;
map["string"] = 1;
map["string"] = 2; ///< 会覆盖前面的值

也可以利用这一特性用来计数:

1
2
3
4
5
6
std::map<char, int> map;
std::string str("Hello World!");
for(unsigned i = 0; i < str.size(); ++i)
{
map[str.at(i)]++;
}

无序容器 (Unordered Container) C++11

C++11 之前因为标准库中没有哈希表类的数据结构,所以很多程序库自己实现了诸如 hash_sethash_multisethash_maphash_multimap

为了避免名称冲突,C++11 标准采用了不一样的名称,使用统一前缀 unordered_, 即unordered_setunordered_multisetunordered_mapunordered_multimap

unordered_map

unordered_setunordered_multisetunordered_mapunordered_multimap 底层实现都是哈希表,所以 key 需要可哈希。

但是在链表是单链还是双链(意味着其迭代器可能不是双向迭代器),重新哈希的时机这些都没有指定,根据实现而定。

unordered_map

定制哈希示例

1
2
3
4
5
6
7
8
9
10
class MY_HASH
{
public:
std::size_t operator()(const CALL_INFO_C& st)
{
return ...;
}
};

std::unordered_map<CALL_INFO_C, int, MY_HASH> map;

容器特性

安插、删除、查找元素大部分是 O(1), 但偶尔发生的重新哈希时间复杂度变为 O(n)

由于其迭代器只保证至少为前向迭代器,因此不提供包括 rbegin()rend()以及不能使用要求双向迭代器的的 STL 函数如std::sort()std::binary_search()

你可以手动强制重新哈希。

重新哈希可能发生在以下调用之后: insert()rehash()rehash()clear()

erase() 函数并不会令指向其他元素的指针、引用和迭代器失效。

insert()emplace() 可能令所有迭代器失效。但不会影响引用的有效性。

当重新哈希过程发生,元素的引用仍然有效。

应用场景

假如缓存中,我们缓存若干最近访问和删除的记录至内存用于快速访问,使得插入记录和读取最近的记录的时间复杂度为 O(1)

  • LRU (Least recently used)

设计接口

  • LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该擦除最久未使用的关键字。
  • 函数 getput 必须以 O(1) 的平均时间复杂度运行。

put

设计思路:

1
2
3
4
5
6

typedef std::pair<int, int> KEY_VALUE;

std::unordered_map<int, std::list<KEY_VALUE>::iterator> map;
std::list<KEY_VALUE> list;

std::unordered_map 特性: 单向迭代器, 增删元素 O(1), 增删元素后迭代器可能失效

std::list 特性: 迭代器永不失效,任意位置插入常量时间 O(1), 访问首尾元素 O(1)

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class LRUCache
{
public:
LRUCache(int capacity) : max_size(capacity) {}

int get(int key)
{
auto res = map.find(key);
if (res != map.end())
{
list.splice(list.begin(), list, res->second);
return res->second->second;
}
else
{
return -1;
}
}

void put(int key, int value)
{
auto res = map.find(key);
list.push_front(std::make_pair(key, value));
if (res != map.end())
{
list.erase(res->second);
map.erase(res);
}
map[key] = list.begin();

/// 检查是否超出了最大数量
if (map.size() > max_size)
{
auto last = list.end();
--last;
map.erase(last->first);
list.pop_back();
}
}

private:
typedef std::pair<int, int> KEY_VALUE;

std::unordered_map<int, std::list<KEY_VALUE>::iterator> map;
std::list<KEY_VALUE> list;
int max_size = 0;
};

特殊容器

std::string

其被定义为: std::basic_string<char>

其中模板入参 char 可以换为 unsigned charwchar

std::stack

后进先出

stack

std::stack 定义如下:

1
2
3
template <typename T,
typename Container = deque<T>>
class stack;

其底层类型默认为 std::deque

之所以不选择 std::vector 是因为在内存管理上 std::dequestd::vector 更有效率。

stack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename T,
typename Container = deque<T>>
class stack
{
public:
T& top()
{
return m_deque.front();
}

void push(const T& value)
{
m_deque.push_front(value);
}

void pop()
{
c.pop_front();
}

private:
Container m_deque;
};

std::queue

先进先出

queue

底层实现默认采用 std::queue

1
2
3
template <typename T,
typename Container = deque<T>>
class queue;

queue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename T,
typename Container = deque<T>>
class queue
{
public:
T& top()
{
return m_deque.front();
}

void push(const T& value)
{
m_deque.push_back(value);
}

void pop()
{
c.pop_front();
}

private:
Container m_deque;
};

std::priority_queue

实现出一个队列,其中的元素按优先级存储。

queue

1
2
3
4
template <typename T,
typename Container = vector<T>,
typename Compare = less<typename Container::value_type>>
class priority_queue;

应用实例:

求数据流中的中位数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class MedianFinder
{
public:
void addNum(int num)
{
if (cnt % 2 == 0)
{
mi.push(num);
num = mi.top();
mi.pop();
mx.push(num);
}
else
{
mx.push(num);
num = mx.top();
mx.pop();
mi.push(num);
}

cnt++;
}

double findMedian()
{
if (cnt & 1)
{
return (double)mx.top();
}
else
{
return (mx.top() + mi.top()) / 2.0;
}
}

private:
std::priority_queue<int, std::vector<int>, std::less<int> > mx;
std::priority_queue<int, std::vector<int>, std::greater<int> > mi;
int cnt = 0;
};

std::bitset

std::bitset 内含一个元素值为 bitbool 值且大小固定的 array。当你需要管理各式flag, 并以 flag 的任意组合来表现变量时, 就可运用 std::bitset

可容纳任意个数的标志位(编译期确定数量)

std::bitset 编译期确定大小 std::vector<bool> 可动态增长。

桶式排序

给 1000 个数字排序, 数字范围 [0, 99]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
unsigned a[1000] = {10, 99, 4, 4, 5, 6, 7};
std::bitset<100> bitset;
int length = sizeof(a) / sizeof(a[0]);

for (unsigned i = 0; i < length; ++i)
{
unsigned pos = a[i];
bitset.set(pos);
}

for (unsigned i = 0; i < bitset.size(); ++i)
{
if (bitset[i])
{
std::cout << i << ", ";
}
}

给 40 亿个 unsigned 数字([0, 99])中寻找不存在的数值

汉明距离

两个整数之间的 汉明距离 指的是这两个数字对应二进制位不同的位置的数目。多用于数据传输中的差错控制。

1
2
3
4
5
6
/// 0 <= x, y <= 2^31 - 1
int hammingDistance(int x, int y)
{
std::bitset<32> temp(x^y);
return temp.count();
}

迭代器介绍

Iterator(迭代器)是一种”能够迭代某序列内所有元素”的对象,可通过改变自寻常pointer的一致性接口来完成工作。Iterator奉行一个纯抽象概念:任何东西,只要行为类似iterator,就是一种iterator。然而不同的的iterator具有不同的行进能力。

迭代器种类

迭代器种类 能力 提供者
Output 迭代器 向前写入 Ostream,inserter
Input 迭代器 向前读取一次 Istream
Forward 迭代器 向前读取 Forward list、unordered containers
Bidirectional 迭代器 向前和向后读取 List、set、multiset、map、multimap
Random-access 迭代器 以随机访问方式读取 Array、vector、deque、string、C-style array

iterator

Output迭代器允许一步一步前行并搭配write动作。因此你可以一个一个元素地赋值,不能使用output迭代器对同一区间迭代两次。事实上,甚至不保证你可以将一个value复制两次而其迭代器不累进。我们的目标是将一个value以下列形式写入一个黑洞。

1
2
3
4
while(...) {
*pos = ...;
++pos;
}

Output 迭代器无需比较操作。你无法检验output迭代器是否有效,或写入是否成功。你唯一可做的就是写入。通常,一批写入动作是以一个”额外条件定义出”的”特定output迭代器”作为结束。
见下表Output迭代器操作

表达式 效果
*iter = val 将val写至迭代器所指的位置
++iter 向前步进(step forward), 返回新位置
iter++ 向前步进(step forward), 返回旧位置
TYPE(iter) 复制迭代器(copy 构造函数)

Input迭代器

Input迭代器只能一次一个以前行方向读取元素,按此顺序一个个返回元素值。

Input迭代器的各项操作

表达式 效果
*iter 读取实际元素
iter->member 读取实际元素的成员(如果有的话)
++iter 向前步进(step forward), 返回新位置
iter++ 向前步进(step forward), 返回旧位置
iter1 == iter2 判断两个迭代器是否相等
iter1 != iter2 判断两个迭代器是否不相等
TYPE(iter) 复制迭代器(copy 构造函数)

Input迭代器只能读取元素一次。如果你复制input迭代器, 并令原input迭代器和新产生的拷贝都向前读取, 可能会遍历到不同的值。
所有的迭代器都具备input迭代器的能力,而且往往更强。Pure input迭代器的典型例子就是”从标准输入设备读取数据”。同一个值不会被读取两次。一旦从input stream读入一个字(离开input缓冲区), 下次读取时就会返回另一个字。

对于input迭代器, 操作符==和!=只用来检查”某个迭代器是否等于一个past-the-end迭代器(指指向最末元素的下一个位置)”.这有其必要, 因为处理input迭代器的操作函数通常会有以下行为。

1
2
3
4
5
6
InputIterator pos, end;

while (pos != end) {
... // read-only access using *pos
++pos;
}

没有任何保证说,两个迭代器如果都不是past-the-end迭代器, 且指向不同位置,他们的比较结果会不相等(这个条件是和forward迭代器搭配引入的)。

也请注意, input迭代器的后置式递增操作符(++iter)不一定会返回什么东西。不过通常它会返回旧位置。
你应该尽可能优先先选用前置式递增操作符(++iter)而非后置式递增操作符(iter++), 因为前者效能更好。因为后者会返回一个临时对象。

Forward(前向)迭代器

Forward迭代器是一种input迭代器且在前进读取时提供额外保证。

表达式 效果
*iter 访问实际元素
iter->member 访问实际元素的成员
++iter 向前步进(返回新位置)
iter++ 向前步进(返回旧位置)
iter1 == iter2 判断两个迭代器是否相等
iter1 != iter2 判断两个迭代器是否不等
TYPE() 创建迭代器(default构造函数)
TYPE(iter) 复制迭代器(拷贝构造函数)
iter1 = iter2 对迭代器赋值(assign)
和input迭代器不同的是, 两个forward迭代器如果指向同一元素, operator==会获得true, 如果两者都递增, 会再次指向同一元素。
例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
ForwardIterator pos1, pos2;

pos1 = pos2 = begin; /// both iterator refer to the same element
if(pos1 != end) {
++pos1; /// pos1 is one element ahead
while(pos1 != end) {
if(*pos1 == *pos2) {
... // precess adjacent duplicates
++pos1;
++pos2;
}
}
}

Forward迭代器由以下对象和类型提供:

  • Class
  • Unordered container
    然而标准库也允许unordered容器的实现提供bidirectional迭代器。
    如果forward迭代器履行了output迭代器应有的条件, 那么它就是一个mutable forward迭代器, 即可用于读取,也可用于涂写。

Random-Access(随机访问)迭代器

Random-access迭代器在bidirectional迭代器的基础上增加了随机访问能里。因此它必须提供iterator算数运算。也就是说,它能增减某个偏移量、
计算距离(difference), 并运用诸如<和>等管理操作符(relational operator)进行比较。

随机访问迭代器的新增操作:

表达式 效果
iter[n] 访问索引位置为n的元素
iter+=n 前进n个元素(如果n是负数, 则改为回退)
iter-=n 回退n个元素(如果n是负数, 则改为前进)
iter+n 返回iter之后的第n个元素
n+iter 返回iter之后的第n个元素
iter-n 返回iter之前的第n个元素
iter1-iter2 返回iter1和iter2之间的距离
iter1 < iter2 判断iter1是否在iter2之前
iter1 > iter2 判断iter1是否在iter2之后
iter1 <= iter2 判断iter1是否不在iter2之后
iter1 >= iter2 判断iter1是否不在iter2之前

Random-access迭代器由以下对象和类型提供:

  • 可随机访问的容器(arrayvectordeque)
  • String(stringwstring)
  • 寻常的C-Style(pointer)

迭代器应用

判断字符串是否为回文。

1
2
3
4
5
6
7
constexpr bool is_palindrome(const std::string_view& s)
{
return std::equal(s.begin(), s.begin() + s.size()/2, s.rbegin());
}

is_palindrome("1000000000个字符"); ///< 时间复杂度: O(1)
is_palindrome(str); ///< 时间复杂度: O(n)。 n = str.size();
1
2
3
4
bool is_palindrome(const std::string& s)
{
return std::equal(s.begin(), s.begin() + s.size()/2, s.rbegin());
}

迭代器失效场景

迭代器非法化

innodb_buffer_pool_size、sync_binlog、innodb_log_file_size

PPC 与 TPC、Reactor 与 Proactor

幸运的是,最近我在学习的时候,无意中在网络上找到一份非常详尽的关于 Linux 服务器网络模型的详细系列文章。作者通过连载的方式,将 iterative、forking(对应专栏的 PPC 模式)、preforked(对应专栏的 prefork 模式)、threaded(对应专栏的 TPC 模式)、prethreaded(对应专栏的 prethread 模式)、poll、epoll(对应专栏的 Reactor 模式)共 7 种模式的实现原理、实现代码、性能对比都详尽地进行了阐述,完美地弥补了专栏内容没有实际数据对比的遗憾。

Linux Applications Performance: Introduction

一文读懂「中台」的前世今生

从 0 开始学架构

25 | 高可用存储架构:双机架构

存储高可用方案的本质都是通过将数据复制到多个存储设备,通过数据冗余的方式来实现高可用,其复杂性主要体现在如何应对复制延迟和中断导致的数据不一致问题。
因此,对任何一个高可用存储方案,我们需要从以下几个方面去进行思考和分析:

  • 数据如何复制?
  • 各个节点的职责是什么?
  • 如何应对复制延迟?
  • 如何应对复制中断?

双机解决方案:
主备、主从、主备 / 主从切换和主主。

主备复制

主备复制是最常见也是最简单的一种存储高可用方案,几乎所有的存储系统都提供了主备复制的功能,例如 MySQL、Redis、MongoDB 等。

下面是标准的主备方案结构图:

主备复制

其整体架构比较简单,主备架构中的“备机”主要还是起到一个备份作用,并不承担实际的业务读写操作,如果要把备机改为主机,需要人工操作。

优缺点分析

主备复制架构的优点就是简单,表现有:

  • 对于客户端来说,不需要感知备机的存在,即使灾难恢复后,原来的备机被人工修改为主机后,对于客户端来说,只是认为主机的地址换了而已,无须知道是原来的备机升级为主机。
  • 对于主机和备机来说,双方只需要进行数据复制即可,无须进行状态判断和主备切换这类复杂的操作。

主备复制架构的缺点主要有:

  • 备机仅仅只为备份,并没有提供读写操作,硬件成本上有浪费。
  • 故障后需要人工干预,无法自动恢复。人工处理的效率是很低的,可能打电话找到能够操作的人就耗费了 10 分钟,甚至如果是深更半夜,出了故障都没人知道。人工在执行恢复操作的过程中也容易出错,因为这类操作并不常见,可能 1 年就 2、3 次,实际操作的时候很可能遇到各种意想不到的问题。

综合主备复制架构的优缺点,内部的后台管理系统使用主备复制架构的情况会比较多,例如学生管理系统、员工管理系统、假期管理系统等,因为这类系统的数据变更频率低,即使在某些场景下丢失数据,也可以通过人工的方式补全。

主从复制

主从复制和主备复制只有一字之差,“从”意思是“随从、仆从”,“备”的意思是备份。我们可以理解为仆从是要帮主人干活的,这里的干活就是承担“读”的操作。也就是说,主机负责读写操作,从机只负责读操作,不负责写操作。

主从复制

优缺点分析

主从复制与主备复制相比,优点有:

  • 主从复制在主机故障时,读操作相关的业务可以继续运行。
  • 主从复制架构的从机提供读操作,发挥了硬件的性能。

缺点有:

  • 主从复制架构中,客户端需要感知主从关系,并将不同的操作发给不同的机器进行处理,复杂度比主备复制要高。
  • 主从复制架构中,从机提供读业务,如果主从复制延迟比较大,业务会因为数据不一致出现问题。
  • 故障时需要人工干预。

综合主从复制的优缺点,一般情况下,写少读多的业务使用主从复制的存储架构比较多。例如,论坛、BBS、新闻网站这类业务,此类业务的读操作数量是写操作数量的 10 倍甚至 100 倍以上。

双机切换

  1. 设计关键
    主备复制和主从复制方案存在两个共性的问题:
  • 主机故障后,无法进行写操作。
  • 如果主机无法恢复,需要人工指定新的主机角色。

双机切换就是为了解决这两个问题而产生的,包括主备切换和主从切换两种方案。
简单来说,这两个方案就是在原有方案的基础上增加“切换”功能,即系统自动决定主机角色,并完成角色切换。
由于主备切换和主从切换在切换的设计上没有差别,我接下来以主备切换为例,一起来看看双机切换架构是如何实现的。
要实现一个完善的切换方案,必须考虑这几个关键的设计点:

主备间状态判断

主要包括两方面:状态传递的渠道,以及状态检测的内容。

状态传递的渠道:是相互间互相连接,还是第三方仲裁?

状态检测的内容:例如机器是否掉电、进程是否存在、响应是否缓慢等。

切换决策

主要包括几方面:切换时机、切换策略、自动程度。

切换时机:什么情况下备机应该升级为主机?是机器掉电后备机才升级,还是主机上的进程不存在就升级,还是主机响应时间超过 2 秒就升级,还是 3 分钟内主机连续重启 3 次就升级等。

切换策略:原来的主机故障恢复后,要再次切换,确保原来的主机继续做主机,还是原来的主机故障恢复后自动成为新的备机?

自动程度:切换是完全自动的,还是半自动的?例如,系统判断当前需要切换,但需要人工做最终的确认操作(例如,单击一下“切换”按钮)。

数据冲突解决

当原有故障的主机恢复后,新旧主机之间可能存在数据冲突。

例如,用户在旧主机上新增了一条 ID 为 100 的数据,这个数据还没有复制到旧的备机,此时发生了切换,旧的备机升级为新的主机,用户又在新的主机上新增了一条 ID 为 100 的数据,当旧的故障主机恢复后,这两条 ID 都为 100 的数据,应该怎么处理?

以上设计点并没有放之四海而皆准的答案,不同的业务要求不一样,所以切换方案比复制方案不只是多了一个切换功能那么简单,而是复杂度上升了一个量级。形象点来说,如果复制方案的代码是 1000 行,那么切换方案的代码可能就是 10000 行,多出来的那 9000 行就是用于实现上面我所讲的 3 个设计点的。

常见架构

根据状态传递渠道的不同,常见的主备切换架构有三种形式:互连式、中介式和模拟式。

互连式

双机切换互联式

你可以看到,在主备复制的架构基础上,主机和备机多了一个“状态传递”的通道,这个通道就是用来传递状态信息的。这个通道的具体实现可以有很多方式:

  • 可以是网络连接(例如,各开一个端口),也可以是非网络连接(用串口线连接)。
  • 可以是主机发送状态给备机,也可以是备机到主机来获取状态信息。
  • 可以和数据复制通道共用,也可以独立一条通道。
  • 状态传递通道可以是一条,也可以是多条,还可以是不同类型的通道混合(例如,网络 + 串口)。

为了充分利用切换方案能够自动决定主机这个优势,客户端这里也会有一些相应的改变,常见的方式有:

  • 为了切换后不影响客户端的访问,主机和备机之间共享一个对客户端来说唯一的地址。例如虚拟 IP,主机需要绑定这个虚拟的 IP。
  • 客户端同时记录主备机的地址,哪个能访问就访问哪个;备机虽然能收到客户端的操作请求,但是会直接拒绝,拒绝的原因就是“备机不对外提供服务”。

互连式主备切换主要的缺点在于:

  • 如果状态传递的通道本身有故障(例如,网线被人不小心踢掉了),那么备机也会认为主机故障了从而将自己升级为主机,而此时主机并没有故障,最终就可能出现两个主机。
  • 虽然可以通过增加多个通道来增强状态传递的可靠性,但这样做只是降低了通道故障概率而已,不能从根本上解决这个缺点,而且通道越多,后续的状态决策会更加复杂,因为对备机来说,可能从不同的通道收到了不同甚至矛盾的状态信息。

中介式

中介式指的是在主备两者之外引入第三方中介,主备机之间不直接连接,而都去连接中介,并且通过中介来传递状态信息,其架构图如下:

双机切换中介式

连接管理更简单:主备机无须再建立和管理多种类型的状态传递连接通道,只要连接到中介即可,实际上是降低了主备机的连接管理复杂度。

例如,互连式要求主机开一个监听端口,备机来获取状态信息;或者要求备机开一个监听端口,主机推送状态信息到备机;如果还采用了串口连接,则需要增加串口连接管理和数据读取。采用中介式后,主备机都只需要把状态信息发送给中介,或者从中介获取对方的状态信息。无论是发送还是获取,主备机都是作为中介的客户端去操作,复杂度会降低。

状态决策更简单:主备机的状态决策简单了,无须考虑多种类型的连接通道获取的状态信息如何决策的问题,只需要按照下面简单的算法即可完成状态决策。

  • 无论是主机还是备机,初始状态都是备机,并且只要与中介断开连接,就将自己降级为备机,因此可能出现双备机的情况。
  • 主机与中介断连后,中介能够立刻告知备机,备机将自己升级为主机。
  • 如果是网络中断导致主机与中介断连,主机自己会降级为备机,网络恢复后,旧的主机以新的备机身份向中介上报自己的状态。
  • 如果是掉电重启或者进程重启,旧的主机初始状态为备机,与中介恢复连接后,发现已经有主机了,保持自己备机状态不变。
  • 主备机与中介连接都正常的情况下,按照实际的状态决定是否进行切换。例如,主机响应时间超过 3 秒就进行切换,主机降级为备机,备机升级为主机即可。

虽然中介式架构在状态传递和状态决策上更加简单,但并不意味着这种优点是没有代价的,其关键代价就在于如何实现中介本身的高可用。如果中介自己宕机了,整个系统就进入了双备的状态,写操作相关的业务就不可用了。这就陷入了一个递归的陷阱:为了实现高可用,我们引入中介,但中介本身又要求高可用,于是又要设计中介的高可用方案……如此递归下去就无穷无尽了。

幸运的是,开源方案已经有比较成熟的中介式解决方案,例如 ZooKeeper 和 Keepalived。ZooKeeper 本身已经实现了高可用集群架构,因此已经帮我们解决了中介本身的可靠性问题,在工程实践中推荐基于 ZooKeeper 搭建中介式切换架构。

模拟式

模拟式指主备机之间并不传递任何状态数据,而是备机模拟成一个客户端,向主机发起模拟的读写操作,根据读写操作的响应情况来判断主机的状态。其基本架构如下:

双机切换模拟式

模拟式切换与互连式切换相比,优点是实现更加简单,因为省去了状态传递通道的建立和管理工作。

简单既是优点,同时也是缺点。因为模拟式读写操作获取的状态信息只有响应信息(例如,HTTP 404,超时、响应时间超过 3 秒等),没有互连式那样多样(除了响应信息,还可以包含 CPU 负载、I/O 负载、吞吐量、响应时间等),基于有限的状态来做状态决策,可能出现偏差。

主主复制

主主复制指的是两台机器都是主机,互相将数据复制给对方,客户端可以任意挑选其中一台机器进行读写操作,下面是基本架构图。

相比主备切换架构,主主复制架构具有如下特点:

  • 两台都是主机,不存在切换的概念。
  • 客户端无须区分不同角色的主机,随便将读写操作发送给哪台主机都可以。

从上面的描述来看,主主复制架构从总体上来看要简单很多,无须状态信息传递,也无须状态决策和状态切换。然而事实上主主复制架构也并不简单,而是有其独特的复杂性,具体表现在:如果采取主主复制架构,必须保证数据能够双向复制,而很多数据是不能双向复制的。

  • 用户注册后生成的用户 ID,如果按照数字增长,那就不能双向复制,否则就会出现 X 用户在主机 A 注册,分配的用户 ID 是 100,同时 Y 用户在主机 B 注册,分配的用户 ID 也是 100,这就出现了冲突。
  • 库存不能双向复制。例如,一件商品库存 100 件,主机 A 上减了 1 件变成 99,主机 B 上减了 2 件变成 98,然后主机 A 将库存 99 复制到主机 B,主机 B 原有的库存 98 被覆盖,变成了 99,而实际上此时真正的库存是 97。类似的还有余额数据。

因此,主主复制架构对数据的设计有严格的要求,一般适合于那些临时性、可丢失、可覆盖的数据场景。例如,用户登录产生的 session 数据(可以重新登录生成)、用户行为的日志数据(可以丢失)、论坛的草稿数据(可以丢失)等。

27 | 如何设计计算高可用架构?

计算高可用的主要设计目标是当出现部分硬件损坏时,计算任务能够继续正常运行。因此计算高可用的本质是通过冗余来规避部分故障的风险,单台服务器是无论如何都达不到这个目标的。所以计算高可用的设计思想很简单:通过增加更多服务器来达到计算高可用。

计算高可用架构的设计复杂度主要体现在任务管理方面,即当任务在某台服务器上执行失败后,如何将任务重新分配到新的服务器进行执行。因此,计算高可用架构设计的关键点有下面两点。

  1. 哪些服务器可以执行任务

第一种方式和计算高性能中的集群类似,每个服务器都可以执行任务。例如,常见的访问网站的某个页面。

第二种方式和存储高可用中的集群类似,只有特定服务器(通常叫“主机”)可以执行任务。当执行任务的服务器故障后,系统需要挑选新的服务器来执行任务。例如,ZooKeeper 的 Leader 才能处理写操作请求。

  1. 任务如何重新执行

第一种策略是对于已经分配的任务即使执行失败也不做任何处理,系统只需要保证新的任务能够分配到其他非故障服务器上执行即可。

第二种策略是设计一个任务管理器来管理需要执行的计算任务,服务器执行完任务后,需要向任务管理器反馈任务执行结果,任务管理器根据任务执行结果来决定是否需要将任务重新分配到另外的服务器上执行。

需要注意的是:“任务分配器”是一个逻辑的概念,并不一定要求系统存在一个独立的任务分配器模块。例如:

  • Nginx 将页面请求发送给 Web 服务器,而 CSS/JS 等静态文件直接读取本地缓存。这里的 Nginx 角色是反向代理系统,但是承担了任务分配器的职责,而不需要 Nginx 做反向代理,后面再来一个任务分配器。

  • 对于一些后台批量运算的任务,可以设计一个独立的任务分配系统来管理这些批处理任务的执行和分配。

  • ZooKeeper 中的 Follower 节点,当接收到写请求时会将请求转发给 Leader 节点处理,当接收到读请求时就自己处理,这里的 Follower 就相当于一个逻辑上的任务分配器。

常见的计算高可用架构:主备、主从和集群

主备

主备架构是计算高可用最简单的架构,和存储高可用的主备复制架构类似,但是要更简单一些,因为计算高可用的主备架构无须数据复制,其基本的架构示意图如下:

高可用主备

主备方案的详细设计:

主机执行所有计算任务。例如,读写数据、执行操作等。

  • 当主机故障(例如,主机宕机)时,任务分配器不会自动将计算任务发送给备机,此时系统处于不可用状态。
  • 如果主机能够恢复(不管是人工恢复还是自动恢复),任务分配器继续将任务发送给主机。
  • 如果主机不能够恢复(例如,机器硬盘损坏,短时间内无法恢复),则需要人工操作,将备机升为主机,然后让任务分配器将任务发送给新的主机(即原来的备机);同时,为了继续保持主备架构,需要人工增加新的机器作为备机。

根据备机状态的不同,主备架构又可以细分为冷备架构和温备架构。

  • 冷备:备机上的程序包和配置文件都准备好,但备机上的业务系统没有启动(注意:备机的服务器是启动的),主机故障后,需要人工手工将备机的业务系统启动,并将任务分配器的任务请求切换发送给备机。
  • 温备:备机上的业务系统已经启动,只是不对外提供服务,主机故障后,人工只需要将任务分配器的任务请求切换发送到备机即可。冷备可以节省一定的能源,但温备能够大大减少手工操作时间,因此一般情况下推荐用温备的方式。

主备架构的优点就是简单,主备机之间不需要进行交互,状态判断和切换操作由人工执行,系统实现很简单。而缺点正好也体现在“人工操作”这点上,因为人工操作的时间不可控,可能系统已经发生问题了,但维护人员还没发现,等了 1 个小时才发现。发现后人工切换的操作效率也比较低,可能需要半个小时才完成切换操作,而且手工操作过程中容易出错。例如,修改配置文件改错了、启动了错误的程序等。
和存储高可用中的主备复制架构类似,计算高可用的主备架构也比较适合与内部管理系统、后台管理系统这类使用人数不多、使用频率不高的业务,不太适合在线的业务。

主从

和存储高可用中的主从复制架构类似,计算高可用的主从架构中的从机也是要执行任务的。任务分配器需要将任务进行分类,确定哪些任务可以发送给主机执行,哪些任务可以发送给备机执行,其基本的架构示意图如下:

高可用主从

主从方案详细设计:

  • 正常情况下,主机执行部分计算任务(如图中的“计算任务 A”),备机执行部分计算任务(如图中的“计算任务 B”)。
  • 当主机故障(例如,主机宕机)时,任务分配器不会自动将原本发送给主机的任务发送给从机,而是继续发送给主机,不管这些任务执行是否成功。
  • 如果主机能够恢复(不管是人工恢复还是自动恢复),任务分配器继续按照原有的设计策略分配任务,即计算任务 A 发送给主机,计算任务 B 发送给从机。
  • 如果主机不能够恢复(例如,机器硬盘损坏,短时间内无法恢复),则需要人工操作,将原来的从机升级为主机(一般只是修改配置即可),增加新的机器作为从机,新的从机准备就绪后,任务分配器继续按照原有的设计策略分配任务。

主从架构与主备架构相比,优缺点有:

  • 优点:主从架构的从机也执行任务,发挥了从机的硬件性能。
  • 缺点:主从架构需要将任务分类,任务分配器会复杂一些。

31 | 如何应对接口级的故障?

解决接口级故障的核心思想:优先保证核心业务和优先保证绝大部分用户。

降级

降级指系统将某些业务或者接口的功能降低,可以是只提供部分功能,也可以是完全停掉所有功能。例如,论坛可以降级为只能看帖子,不能发帖子;也可以降级为只能看帖子和评论,不能发评论;而 App 的日志上传接口,可以完全停掉一段时间,这段时间内 App 都不能上传日志。

降级的核心思想就是丢车保帅,优先保证核心业务。例如,对于论坛来说,90% 的流量是看帖子,那我们就优先保证看帖的功能;对于一个 App 来说,日志上传接口只是一个辅助的功能,故障时完全可以停掉。

常见的实现降级的方式有:

  • 系统后门降级

简单来说,就是系统预留了后门用于降级操作。例如,系统提供一个降级 URL,当访问这个 URL 时,就相当于执行降级指令,具体的降级指令通过 URL 的参数传入即可。这种方案有一定的安全隐患,所以也会在 URL 中加入密码这类安全措施。

系统后门降级的方式实现成本低,但主要缺点是如果服务器数量多,需要一台一台去操作,效率比较低,这在故障处理争分夺秒的场景下是比较浪费时间的。

  • 独立降级系统

为了解决系统后门降级方式的缺点,将降级操作独立到一个单独的系统中,可以实现复杂的权限管理、批量操作等功能。其基本架构如下:

降级

熔断

熔断和降级是两个比较容易混淆的概念,因为单纯从名字上看好像都有禁止某个功能的意思,但其实内在含义是不同的,原因在于降级的目的是应对系统自身的故障,而熔断的目的是应对依赖的外部系统故障的情况。

假设一个这样的场景:A 服务的 X 功能依赖 B 服务的某个接口,当 B 服务的接口响应很慢的时候,A 服务的 X 功能响应肯定也会被拖慢,进一步导致 A 服务的线程都被卡在 X 功能处理上,此时 A 服务的其他功能都会被卡住或者响应非常慢。这时就需要熔断机制了,即:A 服务不再请求 B 服务的这个接口,A 服务内部只要发现是请求 B 服务的这个接口就立即返回错误,从而避免 A 服务整个被拖慢甚至拖死。

熔断机制实现的关键是需要有一个统一的 API 调用层,由 API 调用层来进行采样或者统计,如果接口调用散落在代码各处就没法进行统一处理了。

熔断机制实现的另外一个关键是阈值的设计,例如 1 分钟内 30% 的请求响应时间超过 1 秒就熔断,这个策略中的“1 分钟”“30%”“1 秒”都对最终的熔断效果有影响。实践中一般都是先根据分析确定阈值,然后上线观察效果,再进行调优。

限流

降级是从系统功能优先级的角度考虑如何应对故障,而限流则是从用户访问压力的角度来考虑如何应对故障。限流指只允许系统能够承受的访问量进来,超出系统访问能力的请求将被丢弃。

限流一般都是系统内实现的,常见的限流方式可以分为两类:基于请求限流和基于资源限流。

  • 基于请求限流

基于请求限流指从外部访问的请求角度考虑限流,常见的方式有:限制总量、限制时间量。

限制总量的方式是限制某个指标的累积上限,常见的是限制当前系统服务的用户总量,例如某个直播间限制总用户数上限为 100 万,超过 100 万后新的用户无法进入;某个抢购活动商品数量只有 100 个,限制参与抢购的用户上限为 1 万个,1 万以后的用户直接拒绝。限制时间量指限制一段时间内某个指标的上限,例如,1 分钟内只允许 10000 个用户访问,每秒请求峰值最高为 10 万。

无论是限制总量还是限制时间量,共同的特点都是实现简单,但在实践中面临的主要问题是比较难以找到合适的阈值,例如系统设定了 1 分钟 10000 个用户,但实际上 6000 个用户的时候系统就扛不住了;也可能达到 1 分钟 10000 用户后,其实系统压力还不大,但此时已经开始丢弃用户访问了。

即使找到了合适的阈值,基于请求限流还面临硬件相关的问题。例如一台 32 核的机器和 64 核的机器处理能力差别很大,阈值是不同的,可能有的技术人员以为简单根据硬件指标进行数学运算就可以得出来,实际上这样是不可行的,64 核的机器比 32 核的机器,业务处理性能并不是 2 倍的关系,可能是 1.5 倍,甚至可能是 1.1 倍。

为了找到合理的阈值,通常情况下可以采用性能压测来确定阈值,但性能压测也存在覆盖场景有限的问题,可能出现某个性能压测没有覆盖的功能导致系统压力很大;另外一种方式是逐步优化,即:先设定一个阈值然后上线观察运行情况,发现不合理就调整阈值。

基于上述的分析,根据阈值来限制访问量的方式更多的适应于业务功能比较简单的系统,例如负载均衡系统、网关系统、抢购系统等。

  • 基于资源限流

基于请求限流是从系统外部考虑的,而基于资源限流是从系统内部考虑的,即:找到系统内部影响性能的关键资源,对其使用上限进行限制。常见的内部资源有:连接数、文件句柄、线程数、请求队列等。

例如,采用 Netty 来实现服务器,每个进来的请求都先放入一个队列,业务线程再从队列读取请求进行处理,队列长度最大值为 10000,队列满了就拒绝后面的请求;也可以根据 CPU 的负载或者占用率进行限流,当 CPU 的占用率超过 80% 的时候就开始拒绝新的请求。

基于资源限流相比基于请求限流能够更加有效地反映当前系统的压力,但实践中设计也面临两个主要的难点:如何确定关键资源,如何确定关键资源的阈值。通常情况下,这也是一个逐步调优的过程,即:设计的时候先根据推断选择某个关键资源和阈值,然后测试验证,再上线观察,如果发现不合理,再进行优化。

排队

排队实际上是限流的一个变种,限流是直接拒绝用户,排队是让用户等待一段时间,全世界最有名的排队当属 12306 网站排队了。排队虽然没有直接拒绝用户,但用户等了很长时间后进入系统,体验并不一定比限流好。

由于排队需要临时缓存大量的业务请求,单个系统内部无法缓存这么多数据,一般情况下,排队需要用独立的系统去实现,例如使用 Kafka 这类消息队列来缓存用户请求。

49 | 谈谈App架构的演进

架构设计理念,可以提炼为下面几个关键点:

  • 架构是系统的顶层结构。
  • 架构设计的主要目的是为了解决软件系统复杂度带来的问题。
  • 架构设计需要遵循三个主要原则:合适原则、简单原则、演化原则。
  • 架构设计首先要掌握业界已经成熟的各种架构模式,然后再进行优化、调整、创新。

架构设计文档模板

备选方案模板

需求介绍

[需求介绍主要描述需求的背景、目标、范围等]

随着前浪微博业务的不断发展,业务上拆分的子系统越来越多,目前系统间的调用都是同步调用,由此带来几个明显的系统问题:

性能问题:当用户发布了一条微博后,微博发布子系统需要同步调用“统计子系统”“审核子系统”“奖励子系统”等共 8 个子系统,性能很低。
耦合问题:当新增一个子系统时,例如如果要增加“广告子系统”,那么广告子系统需要开发新的接口给微博发布子系统调用。
效率问题:每个子系统提供的接口参数和实现都有一些细微的差别,导致每次都需要重新设计接口和联调接口,开发团队和测试团队花费了许多重复工作量。

基于以上背景,我们需要引入消息队列进行系统解耦,将目前的同步调用改为异步通知。

需求分析

[需求分析主要全方位地描述需求相关的信息]

5W

5W 指 Who、When、What、Why、Where。

  • Who:需求利益干系人,包括开发者、使用者、购买者、决策者等。
  • When:需求使用时间,包括季节、时间、里程碑等。
  • What:需求的产出是什么,包括系统、数据、文件、开发库、平台等。
  • Where:需求的应用场景,包括国家、地点、环境等,例如测试平台只会在测试环境使用。
  • Why:需求需要解决的问题,通常和需求背景相关

消息队列的 5W 分析如下:

  • Who:消息队列系统主要是业务子系统来使用,子系统发送消息或者接收消息。
  • When:当子系统需要发送异步通知的时候,需要使用消息队列系统。
  • What:需要开发消息队列系统。
  • Where:开发环境、测试环境、生产环境都需要部署。
  • Why:消息队列系统将子系统解耦,将同步调用改为异步通知。

1H

这里的 How 不是设计方案也不是架构方案,而是关键业务流程。
消息队列系统这部分内容很简单,但有的业务系统 1H 就是具体的用例了,有兴趣的同学可以尝试写写 ATM 机取款的业务流程。如果是复杂的业务系统,这部分也可以独立成“用例文档”

消息队列有两大核心功能:业务子系统发送消息给消息队列。业务子系统从消息队列获取消息。

8C

8C 指的是 8 个约束和限制,即 Constraints,包括性能 Performance、成本 Cost、时间 Time、可靠性 Reliability、安全性 Security、合规性 Compliance、技术性 Technology、兼容性 Compatibility

注:需求中涉及的性能、成本、可靠性等仅仅是利益关联方提出的诉求,不一定准确;如果经过分析有的约束没有必要,或成本太高、难度太大,这些约束是可以调整的。

  • 性能:需要达到 Kafka 的性能水平。
  • 成本:参考 XX 公司的设计方案,不超过 10 台服务器。
  • 时间:期望 3 个月内上线第一个版本,在两个业务尝试使用。
  • 可靠性:按照业务的要求,消息队列系统的可靠性需要达到 99.99%。
  • 安全性:消息队列系统仅在生产环境内网使用,无需考虑网络安全;如消息中有敏感信息,消息发送方需要自行进行加密,消息队列系统本身不考虑通用的加密。
  • 合规性:消息队列系统需要按照公司目前的 DevOps 规范进行开发。
  • 技术性:目前团队主要研发人员是 Java,最好用 Java 开发。
  • 兼容性:之前没有类似系统,无需考虑兼容性。

复杂度分析

[分析需求的复杂度,复杂度常见的有高可用、高性能、可扩展等,具体请参考专栏第 10 期的分析]

注:文档的内容省略了分析过程,实际操作的时候每个约束和限制都要有详细的逻辑推导,避免完全拍脑袋式决策。

高可用

对于微博子系统来说,如果消息丢了,导致没有审核,然后触犯了国家法律法规,则是非常严重的事情;
对于等级子系统来说,如果用户达到相应等级后,系统没有给他奖品和专属服务,则 VIP 用户会很不满意,导致用户流失从而损失收入,虽然也比较关键,但没有审核子系统丢消息那么严重。
综合来看,消息队列需要高可用性,包括消息写入、消息存储、消息读取都需要保证高可用性。

高性能

前浪微博系统用户每天发送 1000 万条微博,那么微博子系统一天会产生 1000 万条消息,平均一条消息有 10 个子系统读取,那么其他子系统读取的消息大约是 1 亿次。
将数据按照秒来计算,一天内平均每秒写入消息数为 115 条,每秒读取的消息数是 1150 条;
再考虑系统的读写并不是完全平均的,设计的目标应该以峰值来计算。峰值一般取平均值的 3 倍,那么消息队列系统的 TPS 是 345,QPS 是 3450,考虑一定的性能余量。
由于现在的基数较低,为了预留一定的系统容量应对后续业务的发展,我们将设计目标设定为峰值的 4 倍,因此最终的性能要求是:TPS 为 1380,QPS 为 13800。
TPS 为 1380 并不高,但 QPS 为 13800 已经比较高了,因此高性能读取是复杂度之一。

可扩展

消息队列的功能很明确,基本无须扩展,因此可扩展性不是这个消息队列的关键复杂度。

备选方案

[备选方案设计,至少 3 个备选方案,每个备选方案需要描述关键的实现,无须描述具体的实现细节。此处省略具体方案描述,详细请参考专栏第 11 期]

备选方案 1:

直接引入开源 Kafka[此处省略方案描述]

备选方案 2:

集群 + MySQL 存储[此处省略方案描述]

备选方案 3:

集群 + 自研存储[此处省略方案描述]

备选方案评估

[备选方案 360 度环评,详细请参考专栏第 12 期。注意备选方案评估的内容会根据评估会议的结果进行修改,也就是说架构师首先给出自己的备选方案评估,然后举行备选方案评估会议,再根据会议结论修改备选方案文档]

架构设计模板

总体方案

[总体方案需要从整体上描述方案的结构,其核心内容就是架构图,以及针对架构图的描述,包括模块或者子系统的职责描述、核心流程]

架构总览

[架构总览给出架构图以及架构的描述]

架构总览

架构关键设计点:

  • 采用数据分散集群的架构,集群中的服务器进行分组,每个分组存储一部分消息数据。
  • 每个分组包含一台主 MySQL 和一台备 MySQL,分组内主备数据复制,分组间数据不同步。
  • 正常情况下,分组内的主服务器对外提供消息写入和消息读取服务,备服务器不对外提供服务;主服务器宕机的情况下,备服务器对外提供消息读取的服务。
  • 客户端采取轮询的策略写入和读取消息。

核心流程

  • 消息发送流程

[此处省略流程描述]

  • 消息读取流程

[此处省略流程描述]

详细设计

高可用设计
  • 消息发送可靠性

业务服务器中嵌入消息队列系统提供的 SDK,SDK 支持轮询发送消息,当某个分组的主服务器无法发送消息时,SDK 挑选下一个分组主服务器重发消息,依次尝试所有主服务器直到发送成功;如果全部主服务器都无法发送,SDK 可以缓存消息,也可以直接丢弃消息,具体策略可以在启动 SDK 的时候通过配置指定。

如果 SDK 缓存了一些消息未发送,此时恰好业务服务器又重启,则所有缓存的消息将永久丢失,这种情况 SDK 不做处理,业务方需要针对某些非常关键的消息自己实现永久存储的功能。

  • 消息存储可靠性

消息存储在 MySQL 中,每个分组有一主一备两台 MySQL 服务器,MySQL 服务器之间复制消息以保证消息存储高可用。如果主备间出现复制延迟,恰好此时 MySQL 主服务器宕机导致数据无法恢复,则部分消息会永久丢失,这种情况不做针对性设计,DBA 需要对主备间的复制延迟进行监控,当复制延迟超过 30 秒的时候需要及时告警并进行处理。

  • 消息读取可靠性

每个分组有一主一备两台服务器,主服务器支持发送和读取消息,备服务器只支持读取消息,当主服务器正常的时候备服务器不对外提供服务,只有备服务器判断主服务器故障的时候才对外提供消息读取服务。

主备服务器的角色和分组信息通过配置指定,通过 ZooKeeper 进行状态判断和决策。主备服务器启动的时候分别连接到 ZooKeeper,在 /MQ/Server/[group]目录下建立 EPHEMERAL 节点,假设分组名称为 group1,则主服务器节点为 /MQ/Server/group1/master,备服务器的节点为 /MQ/Server/group1/slave。节点的超时时间可以配置,默认为 10 秒。

高性能设计

[此处省略具体设计]

可扩展设计

[此处省略具体设计。如果方案不涉及,可以简单写上“无”,表示设计者有考虑但不需要设计;否则如果完全不写的话,方案评审的时候可能会被认为是遗漏了设计点]

安全设计

消息队列系统需要提供权限控制功能,权限控制包括两部分:身份识别和队列权限控制。

  • 身份识别

消息队列系统给业务子系统分配身份标识和接入 key,SDK 首先需要建立连接并进行身份校验,消息队列服务器会中断校验不通过的连接。因此,任何业务子系统如果想接入消息队列系统,都必须首先申请身份标识和接入 key,通过这种方式来防止恶意系统任意接入。

  • 队列权限

某些队列信息可能比较敏感,只允许部分子系统发送或者读取,消息队列系统将队列权限保存在配置文件中,当收到发送或者读取消息的请求时,首先需要根据业务子系统的身份标识以及配置的权限信息来判断业务子系统是否有权限,如果没有权限则拒绝服务。

  • 其他设计

[其他设计包括上述以外的其他设计考虑点,例如指定开发语言、符合公司的某些标准等,如果篇幅较长,也可以独立进行描述]

消息队列系统需要接入公司已有的运维平台,通过运维平台发布和部署。
消息队列系统需要输出日志给公司已有的监控平台,通过监控平台监控消息队列系统的健康状态,包括发送消息的数量、发送消息的大小、积压消息的数量等,详细监控指标在后续设计方案中列出。

部署方案

[部署方案主要包括硬件要求、服务器部署方式、组网方式等]

消息队列系统的服务器和数据库服务器采取混布的方式部署,即:一台服务器上,部署同一分组的主服务器和主 MySQL,或者备服务器和备 MySQL。因为消息队列服务器主要是 CPU 密集型,而 MySQL 是磁盘密集型的,所以两者混布互相影响的几率不大。

硬件的基本要求:32 核 48G 内存 512G SSD 硬盘,考虑到消息队列系统动态扩容的需求不高,且对性能要求较高,因此需要使用物理服务器,不采用虚拟机。

架构演进规划

[通常情况下,规划和设计的需求比较完善,但如果一次性全部做完,项目周期可能会很长,因此可以采取分阶段实施,即:第一期做什么、第二期做什么,以此类推]

整个消息队列系统分三期实现:

  • 第一期:实现消息发送、权限控制功能,预计时间 3 个月。
  • 第二期:实现消息读取功能,预计时间 1 个月。
  • 第三期:实现主备基于 ZooKeeper 切换的功能,预计时间 2 周。

验证背景

1
2
3
4
5
6
7
8
#define TO_STR(x)   ((AnsiString)x).c_str()

BOOL Dict::GetID(TcxComboBox *cb, UINT32 *pulID)
{
CHAR *szValue = TO_STR(cb->Text);

return GetID(szValue, pulID);
}

上面的代码中 TO_STR(x) 宏定义,我理解为通过 x 构造出一个临时 AnsiString 的对象, 并对该临时对象调用成员函数 c_str() 获得指向其内部的 const char *
由于匿名的临时对象的生命周期是该行结束即结束,所以获取到的 const char * 指针不可使用。
为了探究这个问题,使用以下代码来验证我的想法。

验证过程

由于 UnicodeString 不是标准库的对象,其源码繁杂且找不到手册,所以猜测 UnicodeStringAnsiString 的实现,分为两种:

  • 分别单独实现
  • UnicodeString 继承自 AnsiString

分别单独实现

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <iostream>
#include <string.h>

#define LOG std::cout

namespace gos
{
template<class T>
std::string to_string(const T* dec)
{
char acText[32];
sprintf(acText, "0x%X", (size_t)dec);
return acText;
}
}

class AnsiString1
{
public:
/// 构造函数
AnsiString1()
{
str = new char[5];
memset(str, 0, sizeof(str));
LOG << "AnsiString1 构造函数 this: " << gos::to_string(this) << ", str: " << gos::to_string(str) << '\n';
}

/// 拷贝构造函数
AnsiString1(const AnsiString1& obj)
{
str = new char[5];
memset(str, 0, sizeof(str));
sprintf(str, obj.str);
LOG << "AnsiString1 拷贝构造函数 this: " << gos::to_string(this) << ", new str(" << gos::to_string(str) << ")" << '\n';
}

/// 析构函数
~AnsiString1()
{
delete[] str;
LOG << "~AnsiString1 析构函数 this: " << gos::to_string(this) << ", delete str("<< gos::to_string(str) << ")" << '\n';
}

const char* c_str()
{
return str;
}

private:
char* str;
};

class UnicodeString1
{
public:
UnicodeString1()
{
LOG << "UnicodeString1 构造函数 this: " << gos::to_string(this) << '\n';
}

~UnicodeString1()
{
LOG << "~UnicodeString1 析构函数" << gos::to_string(this) << '\n';
}

operator AnsiString1()
{
LOG << "operator AnsiString1(): " << gos::to_string(this) << '\n';
return m;
}

private:
AnsiString1 m;
};

C++ Builder 运行结果:

不会崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void test()
{
UnicodeString1 obj;
/// AnsiString1 构造函数 this: 0x19FE64, str: 0xA926560
/// UnicodeString1 构造函数 this: 0x19FE64
const char* sz = ((AnsiString1)obj).c_str();
/// operator AnsiString1(): 0x19FE64
/// AnsiString1 拷贝构造函数 this: 0x19FE60, new str(0xA926520)
/// AnsiString1 拷贝构造函数 this: 0x19FE5C, new str(0xA926510)
/// ~AnsiString1 析构函数 this: 0x19FE5C, delete str(0xA926510)
/// ~AnsiString1 析构函数 this: 0x19FE60, delete str(0xA926520)
LOG << "使用字符串: " << gos::to_string(sz) << '\n';
/// 使用字符串: 0xA926510

/// AnsiString1(obj); ///< E2238 Multiple declaration for 'obj'
/// AnsiString1& r = (AnsiString1)obj; ///< E2357 Reference initialized with 'AnsiString1', needs lvalue of type 'AnsiString1'
}

VS2010 运行结果:

不会崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void test()
{
UnicodeString1 obj;
/// AnsiString1 构造函数 this: 0xC3F914, str: 0x31A9F30
/// UnicodeString1 构造函数 this: 0xC3F914
const char* sz = ((AnsiString1)obj).c_str();
/// operator AnsiString1(): 0xC3F914
/// AnsiString1 拷贝构造函数 this: 0xC3F4FC, new str(0x31AA5B8)
/// AnsiString1 拷贝构造函数 this: 0xC3F4F0, new str(0x31AA600)
/// ~AnsiString1 析构函数 this: 0xC3F4F0, delete str(0x31AA600)
/// ~AnsiString1 析构函数 this: 0xC3F4FC, delete str(0x31AA5B8)
LOG << "使用字符串 sz: " << gos::to_string(sz) << '\n';
/// 使用字符串 sz: 0x31AA600
AnsiString1& r = (AnsiString1)obj;
/// operator AnsiString1(): 0xC3F914
/// AnsiString1 拷贝构造函数 this: 0xC3F518, new str(0x31AA5B8)
/// AnsiString1 拷贝构造函数 this: 0xC3F8F0, new str(0x31AA600)
/// ~AnsiString1 析构函数 this: 0xC3F518, delete str(0x31AA5B8)
const char* sz1 = r.c_str(); ///< warning C4239: 使用了非标准扩展:“初始化”: 从“AnsiString1”转换到“AnsiString1 &” 非常量引用只能绑定到左值
LOG << "使用字符串 sz1: " << gos::to_string(sz1) << '\n';
/// 使用字符串 sz1: 0x31AA600

/// 编译不通过的代码
/// AnsiString1(obj); ///< error C2371: “obj”: 重定义;不同的基类型
}

VS2019 运行结果:

不会崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void test()
{
UnicodeString1 obj;
/// AnsiString1 构造函数 this: 0x8FFDF0, str: 0xA42310
/// UnicodeString1 构造函数 this: 0x8FFDF0
const char* sz = ((AnsiString1)obj).c_str();
/// operator AnsiString1(): 0x8FFDF0
/// AnsiString1 拷贝构造函数 this: 0x8FFD0C, new str(0xA42498)
/// AnsiString1 拷贝构造函数 this: 0x8FFD18, new str(0xA42070)
/// ~AnsiString1 析构函数 this: 0x8FFD18, delete str(0xA42070)
/// ~AnsiString1 析构函数 this: 0x8FFD0C, delete str(0xA42498)
LOG << "使用字符串 sz: " << gos::to_string(sz) << '\n';
/// 使用字符串 sz: 0xA42070

/// 编译不通过的代码
/// AnsiString1(obj); ///< error C2371: “obj”: 重定义;不同的基类型
/// AnsiString1& r = (AnsiString1)obj; ///< error C2440: “初始化”: 无法从“AnsiString1”转换为“AnsiString1 &”, 非常量引用只能绑定到左值
}

g++ 运行结果

不会崩溃

1
2
3
4
5
6
7
8
9
10
11
12
void test()
{
UnicodeString1 obj;
/// AnsiString1 构造函数 this: 0x53EDAFC0, str: 0x9C2010
/// UnicodeString1 构造函数 this: 0x53EDAFC0
const char* sz = ((AnsiString1)obj).c_str();
/// operator AnsiString1(): 0x53EDAFC0
/// AnsiString1 拷贝构造函数 this: 0x53EDAFC8, new str(0x9C2090)
/// ~AnsiString1 析构函数 this: 0x53EDAFC8, delete str(0x9C2090)
LOG << "使用字符串 sz: " << gos::to_string(sz) << '\n';
/// 使用字符串 sz: 0x9C2090~UnicodeString1 析构函数0x53EDAFC0
}

UnicodeString 继承自 AnsiString

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <iostream>
#include <string.h>

#define LOG std::cout

namespace gos
{
template<class T>
std::string to_string(const T* dec)
{
char acText[32];
sprintf(acText, "0x%X", (size_t)dec);
return acText;
}
}

class AnsiString1
{
public:
/// 构造函数
AnsiString1()
{
str = new char[5];
memset(str, 0, sizeof(str));
LOG << "AnsiString1 构造函数 this: " << gos::to_string(this) << ", str: " << gos::to_string(str) << '\n';
}

/// 拷贝构造函数
AnsiString1(const AnsiString1& obj)
{
str = new char[5];
memset(str, 0, sizeof(str));
sprintf(str, obj.str);
LOG << "AnsiString1 拷贝构造函数 this: " << gos::to_string(this) << ", new str(" << gos::to_string(str) << ")" << '\n';
}

/// 析构函数
~AnsiString1()
{
delete[] str;
LOG << "~AnsiString1 析构函数 this: " << gos::to_string(this) << ", delete str("<< gos::to_string(str) << ")" << '\n';
}

const char* c_str()
{
return str;
}

private:
char* str;
};

class UnicodeString1 : public AnsiString1
{
public:
UnicodeString1()
{
LOG << "UnicodeString1 构造函数 this: " << gos::to_string(this) << '\n';
}

~UnicodeString1()
{
LOG << "~UnicodeString1 析构函数" << gos::to_string(this) << '\n';
}

operator AnsiString1()
{
LOG << "operator AnsiString1(): " << gos::to_string(this) << '\n';
return *(AnsiString1*)this;
}
};

C++ Builder 运行结果:

不会崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void test()
{
UnicodeString1 obj;
/// AnsiString1 构造函数 this: 0x19FE64, str: 0xA966560
/// UnicodeString1 构造函数 this: 0x19FE64
const char* sz = ((AnsiString1)obj).c_str() << '\n';
/// AnsiString1 拷贝构造函数 this: 0x19FE60, new str(0xA966520)
/// ~AnsiString1 析构函数 this: 0x19FE60, delete str(0xA966520)
LOG << "使用字符串: " << gos::to_string(sz) << '\n';
/// 使用字符串: 0xA966520

/// AnsiString1(obj); ///< E2238 Multiple declaration for 'obj'
/// AnsiString1& r = (AnsiString1)obj; ///< E2357 Reference initialized with 'AnsiString1', needs lvalue of type 'AnsiString1'
}

VS2010 运行结果:

不会崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void test()
{
UnicodeString1 obj;
/// AnsiString1 构造函数 this: 0xCFFA1C, str: 0x2C49F30
/// UnicodeString1 构造函数 this: 0xCFFA1C
const char* sz = ((AnsiString1)obj).c_str();
/// AnsiString1 拷贝构造函数 this: 0xCFF610, new str(0x2C4A5B8)
/// ~AnsiString1 析构函数 this: 0xCFF610, delete str(0x2C4A5B8)
LOG << "使用字符串 sz: " << gos::to_string(sz) << '\n';
/// 使用字符串 sz: 0x2C4A5B8
AnsiString1& r = (AnsiString1)obj; ///< warning C4239: 使用了非标准扩展:“初始化”: 从“AnsiString1”转换到“AnsiString1 &” 非常量引用只能绑定到左值
/// AnsiString1 拷贝构造函数 this: 0xCFF9F8, new str(0x2C4A5B8)
const char* sz1 = r.c_str();
LOG << "使用字符串 sz1: " << gos::to_string(sz1) << '\n';
/// 使用字符串 sz1: 0x2C4A5B8

/// 编译不过的代码
/// AnsiString1(obj); ///< error C2371: “obj”: 重定义;不同的基类型
}

VS2019 运行结果:

不会崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void test()
{
UnicodeString1 obj;
/// AnsiString1 构造函数 this: 0x3AFCD0, str: 0x682428
/// UnicodeString1 构造函数 this: 0x3AFCD0
const char* sz = ((AnsiString1)obj).c_str();
/// AnsiString1 拷贝构造函数 this: 0x3AFBF8, new str(0x6823F0)
/// ~AnsiString1 析构函数 this: 0x3AFBF8, delete str(0x6823F0)
LOG << "使用字符串 sz: " << gos::to_string(sz) << '\n';
/// 使用字符串 sz: 0x6823F0

/// 编译不过的代码
/// AnsiString1& r = (AnsiString1)obj; ///< error C2440: “初始化”: 无法从“AnsiString1”转换为“AnsiString1 &”, 非常量引用只能绑定到左值
/// AnsiString1(obj); ///< error C2371: “obj”: 重定义;不同的基类型
}

g++ 运行结果

不会崩溃

1
2
3
4
5
6
7
8
9
10
11
void test()
{
UnicodeString1 obj;
/// AnsiString1 构造函数 this: 0xB512D870, str: 0x1D17010
/// UnicodeString1 构造函数 this: 0xB512D870
const char* sz = ((AnsiString1)obj).c_str();
/// AnsiString1 拷贝构造函数 this: 0xB512D878, new str(0x1D17090)
/// ~AnsiString1 析构函数 this: 0xB512D878, delete str(0x1D17090)
LOG << "使用字符串 sz: " << gos::to_string(sz) << '\n';
/// 使用字符串 sz: 0x1D17090
}

结论

  1. 如果按照我们设想的 UnicodeString 的两种实现方式, 使用强制转换符, 在编译器 C++ BuilderVS2010VS2019g++ 都会构造临时对象

  2. 该临时对象在该行结束后马上析构。所以使用从临时对象中获取的指针可能会有问题,但C++ BuilderVS2010VS2019g++ 没有出现编译警告和运行崩溃

  3. 获取临时对象的引用在 C++ BuilderVS2010 中会有编译警告。在 VS2019g++ 中会直接编译错误。