CRPT奇异递归模板模式

C++ CRTP 详解:奇异递归模板模式的原理与应用

在 C++ 编程中,模板不仅仅用于泛型编程,还可以发挥出“元编程”的威力。CRTP,即“奇异递归模板模式”,正是一种利用模板实现静态多态和代码复用的设计模式。本文将从原理、示例、优缺点以及新标准对其的改进等多个角度详细讲解 CRTP。


CRTP 简介

CRTP 的英文全称为 Curiously Recurring Template Pattern,顾名思义,就是让一个类在继承时将自身作为模板参数传给其基类。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 基类模板
template <typename Derived>
class Base {
public:
// 调用派生类的成员函数 implementation()
void interface() {
static_cast<Derived*>(this)->implementation();
}
// 如果派生类没有重写,基类可以提供一个默认实现
void implementation() {
// 默认行为
}
};

// 派生类,将自身作为模板参数传入基类
class Derived1 : public Base<Derived1> {
public:
void implementation() {
std::cout << "Derived1::implementation()" << std::endl;
}
};

上述代码中,Derived1 继承自 Base<Derived1>,基类中通过 static_castthis 转换为派生类指针,从而在调用 interface() 时能够调用到派生类重写后的 implementation() 方法。这种技术利用了 C++ 模板延迟实例化的特性,可以在编译期实现类似于多态的效果,而无需虚函数的运行时开销

CRTP 的原理与实现

1. 静态多态

传统的多态通常依赖于虚函数(运行时多态),这需要为每个对象存储虚表指针并进行动态查找,虽然这种方式非常灵活,但也会带来一定的性能开销。相比之下,CRTP 实现的是静态多态,即在编译期就确定调用哪个函数,从而避免了虚函数的额外成本。

示例代码(静态多态):

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
#include <iostream>
using namespace std;

template <typename Derived>
class Shape {
public:
// 定义统一接口,调用派生类实现的 do_draw()
void draw() {
static_cast<Derived*>(this)->do_draw();
}
};

class Circle : public Shape<Circle> {
public:
void do_draw() {
cout << "Drawing a circle" << endl;
}
};

class Square : public Shape<Square> {
public:
void do_draw() {
cout << "Drawing a square" << endl;
}
};

int main() {
Circle c;
Square s;
c.draw(); // 输出 "Drawing a circle"
s.draw(); // 输出 "Drawing a square"
return 0;
}

在这个例子中,Shape 模板类定义了一个 draw() 方法,该方法内部将 this 强制转换为派生类指针,从而调用派生类的 do_draw()。这样就实现了在编译期确定调用函数的目标,避免了虚函数表查找

Mixin 与代码复用

CRTP 也常用于 mixin 风格的设计,即在不使用虚函数的情况下为类添加额外功能。例如,我们可以通过 CRTP 为一个类增加对象计数器,统计某个类对象的创建和销毁情况:

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
#include <iostream>

template <typename T>
class ObjectCounter {
public:
ObjectCounter() { ++objects_created; ++objects_alive; }
ObjectCounter(const ObjectCounter&) { ++objects_created; ++objects_alive; }
protected:
~ObjectCounter() { --objects_alive; }
public:
static int getObjectsCreated() { return objects_created; }
static int getObjectsAlive() { return objects_alive; }
private:
static int objects_created;
static int objects_alive;
};

template <typename T>
int ObjectCounter<T>::objects_created = 0;

template <typename T>
int ObjectCounter<T>::objects_alive = 0;

class MyClass : public ObjectCounter<MyClass> {
// 其它成员
};

int main() {
MyClass a, b;
std::cout << "MyClass created: " << MyClass::getObjectsCreated() << std::endl;
std::cout << "MyClass alive: " << MyClass::getObjectsAlive() << std::endl;
return 0;
}

这里,每个派生类都通过独立实例化的 ObjectCounter<T> 来统计自己对象的数量,因为模板参数不同(如 MyClass 与其他类)生成的是不同的基类类型

3链式调用实现

在实际项目中,链式调用(fluent interface)是一种常见的设计风格,但如果基类方法返回的是基类引用,继承的派生类特有方法就无法连贯调用。利用 CRTP,可以让基类返回正确的派生类引用,从而实现链式调用。例如:

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
#include <iostream>
#include <ostream>

template <typename Derived>
class Printer {
public:
explicit Printer(std::ostream& os) : stream(os) {}

template <typename T>
Derived& print(const T& data) {
stream << data;
return static_cast<Derived&>(*this);
}

template <typename T>
Derived& println(const T& data) {
stream << data << std::endl;
return static_cast<Derived&>(*this);
}

private:
std::ostream& stream;
};

class CoutPrinter : public Printer<CoutPrinter> {
public:
CoutPrinter() : Printer(std::cout) {}
// 增加派生类特有的接口
CoutPrinter& setColor(const std::string& color) {
// 设定控制台颜色(示例)
std::cout << "[Color:" << color << "]";
return *this;
}
};

int main() {
CoutPrinter().print("Hello ").setColor("red").println("World!");
return 0;
}

通过 CRTP,使得 print()println() 返回的是 CoutPrinter& 而非基类引用,从而可以连续调用 setColor()


CRTP 与动态多态的比较

在传统的动态多态中,我们通常使用虚函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
class Base {
public:
virtual void func() = 0;
virtual ~Base() = default;
};

class Derived : public Base {
public:
void func() override {
std::cout << "Derived::func()" << std::endl;
}
};

这种方式的优点是可以将不同派生类统一存放在一个基类指针容器中,但缺点在于每个对象需要额外存储虚表指针,且每次调用都需要通过虚函数表查找,存在一定性能开销。CRTP 则通过编译期静态绑定实现多态,消除了运行时开销,但牺牲了“同一基类指针统一管理”的能力,因为不同模板参数实例化出的基类类型是不同的

CRTP 的优势与局限

优势

  1. 高效性
    CRTP 完全在编译期解析,不产生虚函数调用的开销,有利于内联和编译器优化。

  2. 灵活性
    利用模板参数可以让基类访问派生类的成员、方法,方便实现接口默认实现和代码复用。

  3. 零运行时成本
    不需要虚表指针、动态绑定,适用于性能敏感场景。

局限

  1. 代码膨胀
    每个派生类实例化出一个独立的基类模板可能导致生成代码增多。

  2. 不可统一管理
    由于基类模板实例化类型不同,不便将不同派生类对象存放于同一容器中,需要借助额外的抽象层(例如虚基类)来解决。

  3. 错误提示复杂
    模板错误消息往往较为晦涩,不易调试。

  4. 设计要求较高
    需要保证派生类在继承时满足基类所要求的接口,否则静态转换(static_cast)可能出错。

Rust 的错误处理:别拿类型系统当护身符

@[toc]

大多数 Rust 程序员在处理错误时表现得像被编译器吓坏了的孩子。
他们害怕 Result、害怕 ?、害怕 lifetimes,最后写出一堆看起来“类型安全”的垃圾。
问题不在语言,而在思维:错误处理是设计问题,不是语法问题。


一、错误的核心:调用者到底需要知道什么?

如果调用者需要知道错误的来源,就枚举(enumeration)。
如果调用者只需要知道“出错了”,就擦除(erasure)。


错误示例:不区分错误来源

1
2
3
4
fn copy_data(mut reader: impl Read, mut writer: impl Write) -> Result<(), std::io::Error> {
std::io::copy(&mut reader, &mut writer)?;
Ok(())
}

看起来很简洁对吧?问题是:调用者根本不知道是读挂了还是写炸了。

在网络服务器里,这两个错误是完全不同的:

  • 输入流失败可能意味着磁盘或 socket 损坏(致命)
  • 输出流失败可能只是客户端断开(可以忽略)

但现在它们都被混在一个 std::io::Error 里,你的调用者只能瞎猜。


正确示例:枚举错误来源

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
#[derive(Debug)]
pub enum CopyError {
In(std::io::Error),
Out(std::io::Error),
}

impl std::error::Error for CopyError {}
impl std::fmt::Display for CopyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CopyError::In(e) => write!(f, "input error: {}", e),
CopyError::Out(e) => write!(f, "output error: {}", e),
}
}
}

fn copy_data(mut reader: impl Read, mut writer: impl Write) -> Result<(), CopyError> {
let mut buf = [0; 4096];
loop {
let n = reader.read(&mut buf).map_err(CopyError::In)?;
if n == 0 { break; }
writer.write_all(&buf[..n]).map_err(CopyError::Out)?;
}
Ok(())
}

现在调用者能区分错误来源,可以决定不同的策略。
这才叫语义清晰。类型系统不是目标,它是防止你撒谎的手段。


二、错误信息越多不代表更好

有的人以为“详细 = 专业”,于是他们搞出这种 monstrosity:

过度设计的灾难

1
2
3
4
5
6
7
#[derive(Debug)]
enum DecodeError {
InvalidHeader(u32),
UnsupportedCompression(String),
MalformedChunk(usize),
IoError(std::io::Error),
}

然后每个错误都被上报、打印、打 tag。
问题是:上层调用者根本不在意。
他们只想知道“图片读不出来”,不在乎是哪个 bit 出问题。


实用设计:擦除无关信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[derive(Debug)]
struct ImageError(String);

impl std::fmt::Display for ImageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "image decoding failed: {}", self.0)
}
}

impl std::error::Error for ImageError {}

fn decode_image(data: &[u8]) -> Result<Image, ImageError> {
// 内部可以区分多种错误
let header = parse_header(data).map_err(|_| ImageError("invalid header".into()))?;
decompress(header).map_err(|_| ImageError("decompression failed".into()))?;
Ok(Image::new())
}

用户得到的是干净的 ImageError,无需知道底层细节。
擦除复杂度,不是隐藏错误,而是隔离不必要的信息。


三、特殊情况是设计的失败

很多人写函数时遇到“理论上不会失败”的情况,就乱来。

错误示例:用 Option 偷懒

1
2
3
fn parse_number(s: &str) -> Option<i32> {
s.parse().ok()
}

看起来简洁,但 None 到底是什么意思?
是字符串不是数字?是空字符串?是 I/O 出错?
调用者完全没法判断——你直接剥夺了他们的恢复能力。


正确示例:用 Result 明确语义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[derive(Debug)]
struct ParseNumberError;

impl std::fmt::Display for ParseNumberError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid number format")
}
}

impl std::error::Error for ParseNumberError {}

fn parse_number(s: &str) -> Result<i32, ParseNumberError> {
s.parse().map_err(|_| ParseNumberError)
}

Result 表示“有错误发生”;Option 表示“没有值返回”。
混用这两个类型,是 API 设计的懒惰行为。
如果函数可能失败,就让类型系统告诉调用者这件事。


四、操作符不是魔法,是糖衣

有人以为 ? 很神秘,其实它就是:

1
2
3
4
match expr {
Ok(v) => v,
Err(e) => return Err(e.into()),
}

所以关键不在 ?,而在 From trait


错误示例:只实现了 Into

1
2
3
impl Into<MyError> for std::io::Error {
fn into(self) -> MyError { MyError::Io(self) }
}

然后惊讶地发现:? 报错,“trait bound not satisfied”。
因为 ? 调用的是 From::from,不是 Into::into


正确示例:实现 From,一切通顺

1
2
3
impl From<std::io::Error> for MyError {
fn from(e: std::io::Error) -> Self { MyError::Io(e) }
}

从此你可以愉快地写:

1
2
3
4
5
fn read_file() -> Result<String, MyError> {
let mut buf = String::new();
std::fs::File::open("config.txt")?.read_to_string(&mut buf)?;
Ok(buf)
}

五、try block:清理不该被跳过的东西

很多人喜欢:

1
2
3
4
5
6
fn run() -> Result<(), Error> {
let conn = connect()?;
do_stuff(&conn)?;
conn.close()?; // 这一行永远执行不到
Ok(())
}

一旦 do_stuff 出错,close() 永远不会跑到。
Rust 的 ? 让你早退,但不会帮你擦屁股。


正确做法:try block

1
2
3
4
5
6
7
8
fn run() -> Result<(), Error> {
let conn = connect()?;
let r = try {
do_stuff(&conn)?;
};
conn.close()?;
r
}

这才是可靠的错误处理:不丢资源,不绕逻辑
try {} 块让你能在出错时执行 cleanup,而不破坏 ? 的流畅性。


六、永远别为“优雅”破坏用户空间

有些库作者干了这种蠢事:

1
2
3
4
5
// v1.0
pub fn foo() -> Result<(), Box<dyn Error>>;

// v1.1
pub fn foo() -> Result<(), Box<MyError>>;

然后他们说:“签名没变呀!编译器不会报错!”

但用户代码里:

1
2
3
4
5
6
7
match foo() {
Err(e) => {
if let Some(ioe) = e.downcast_ref::<std::io::Error>() {
// ...
}
}
}

现在全部崩了。
如果用户能 downcast,那类型擦除就是你的 API 一部分。
别假装不是。
这类破坏兼容性的更改,永远是破坏性更新(breaking change)。


深入理解 Rust 的内存模型:变量、值与指针

深入理解 Rust 的内存模型:变量、值与指针

Rust 以“内存安全”著称,但想真正写出高效、安全的 Rust 代码,就必须理解它背后的内存模型。本篇文章将带你从 变量、值与指针 的概念出发,逐步理解 栈(stack)、堆(heap)和静态内存(static memory),以及 Rust 的核心机制——所有权、借用和生命周期。同时我们还会结合 C++ 的内存模型 做详细对比,方便已经熟悉 C++ 的读者更快地建立对应理解。


变量、值与指针

在 Rust 中:

  • 值(Value):类型与具体数据的组合。例如,6u8 是一个 u8 类型的值,它在内存中的表示是字节 0x06"Hello world" 是字符串值,它的底层表示是 UTF-8 字节序列。
    • 这里的概念和C++中的字面值是类似的,而 Hello world这种字符串,Rust的类型是 &str,和C++中的 const char* 是类似的
  • 变量(Variable):是一个“命名的位置”,用来存储值。变量通常存放在栈上。
  • 指针(Pointer):保存某个内存地址的值,可以解引用访问其指向的数据。Rust 的引用(&T&mut T)本质上就是指针。
1
2
3
4
5
let x = 42;
let y = 43;
let var1 = &x;
let mut var2 = &x;
var2 = &y;

这里:

  • 值有四个:4243x 的地址、y 的地址。
  • 变量有四个:x, y, var1, var2

与 C++ 对比

在 C++ 中:

  • :表现形式类似,例如 int x = 42; 中,42 就是值,存放在 x 里。

  • 变量:C++ 的变量和 Rust 类似,通常存储在栈上。但 C++ 不会在编译器层面禁止未初始化使用,例如:

    1
    2
    int a;  // 未初始化
    std::cout << a; // 未定义行为(UB)

    Rust 则会强制报错,避免 UB。

  • 指针与引用

    • C++ 引用(int&)类似 Rust 的 &T,但 C++ 允许把引用绑定到临时对象上,容易导致悬垂引用;Rust 编译期禁止此类情况。
    • C++ 指针(int*)则与 Rust 的原始指针(*const T, *mut T)接近,需要开发者自己保证安全性。

高层与低层的变量模型

Rust 高层模型(High-level model)

  • 把变量看作值的“名字”。
  • 值的使用抽象为 数据流(flow),一旦值被移动,数据流断开,变量失效。
  • 编译器借用检查器会验证是否存在非法的数据流。

Rust 低层模型(Low-level model)

  • 把变量看作一块内存槽。
  • 赋值覆盖旧数据,引用则是内存的地址。

与 C++ 对比

  • C++ 更接近低层模型:变量就是一块内存,指针/引用直接操作地址。编译器不会阻止危险操作。
  • 在高层抽象上,C++ 缺乏 Rust 那样的“所有权”和“借用检查器”。程序员需要依靠编码规范、智能指针(std::unique_ptr, std::shared_ptr)或工具(如 ASan, Valgrind)来避免内存错误。

示例对比:

1
2
int* p = nullptr;
*p = 42; // 未定义行为:空指针解引用

Rust 中:

1
2
3
4
let p: *mut i32 = std::ptr::null_mut();
unsafe {
*p = 42; // 编译器要求 unsafe 块,显式提示风险
}

Rust 把危险操作显式隔离,C++ 则允许直接执行。


三大内存区域

1. 栈(Stack)

  • Rust:函数调用分配栈帧,局部变量生命周期受作用域限制。

  • C++:相同机制,但 C++ 不会禁止返回局部变量的引用:

    1
    2
    3
    4
    int& foo() {
    int x = 42;
    return x; // 返回悬垂引用,UB
    }

    Rust:

    1
    2
    3
    4
    fn foo() -> &i32 {
    let x = 42;
    &x // 编译错误:借用不满足生命周期
    }

2. 堆(Heap)

  • Rust:通过 Box<T>Vec<T> 等安全抽象分配,释放由所有权系统自动管理。

  • C++:需要手动 new/delete,或使用智能指针:

    1
    auto p = std::make_unique<int>(42); // 自动释放

    Rust 的 Box<T> 类似 unique_ptr,但由编译器强制约束,避免误用。

3. 静态内存(Static Memory)

  • Rust:static 变量在程序整个运行期存在,'static 生命周期显式建模。
  • C++:全局变量、静态变量在整个程序运行期间存在,但缺乏生命周期建模,容易导致初始化顺序问题(static initialization order fiasco)。

小结

Rust 的内存安全性来自于编译期的严格规则,而 C++ 则更多依赖程序员经验:

特性 Rust C++
未初始化变量 编译时报错 运行时 UB
指针安全 引用/借用受检查,原始指针需 unsafe 任意指针操作,易出错
所有权 强制存在,编译器跟踪 无,需靠约定或智能指针
生命周期 类型系统显式建模 无,需人工推理
内存回收 RAII + 编译器保证 RAII,但需谨慎设计

可以看到,Rust 在很多地方对 C++ 进行了“强制收紧”,牺牲部分灵活性换取编译期的安全性。对于熟悉 C++ 的开发者,可以把 Rust 看作是“有更强类型约束和更严格规则的现代 C++”。

高频交易系统

第三方库/框架

项目启动 main.py

通过参数调用函数

通过获取 main.py 的启动参数来调用不同的函数 进行编译等操作

编译C++代码

  1. 清理旧的 build bin lib 目录
  2. 切换到build目录然后cmake .. os.system("cmake ..") os.chdir("build")
  3. 最后使用make编译C++代码 有返回值 如果是0则表示编译完成

启动项目

  1. 先kill掉以前的进程 休眠1秒
  2. 查看是否需要rebuild
  3. 创建tmux的会话来达到创建进程的目的 123

bash守护进程脚本

主要是为了监控进程状态

  • while true; do:无限循环,执行指定的程序并等待其退出。
  • 如果程序退出码是 0,表示正常退出;如果是 130,表示用户通过 Ctrl+C 中断,脚本会结束。
  • 如果程序被系统杀死(退出码 137,OOM Killer),脚本会尝试清理 Node.js 进程。
  • 如果程序崩溃,则会等待 0.1 秒后重启程序。

针对每一个交易所都会启动一个监控和进程

核心程序

  1. [[#加载配置文件|加载配置文件]]
  2. [[#初始化共享内存|初始化共享内存]]
  3. 启动WebSocket服务器 监听客户端请求

加载配置文件

使用的是[[#^ea87cf|glaze框架]]解析json文件

初始化共享内存

  1. 通过[[#MemoryManager类]]来管理多个进程 不同语言之间的共享内存
    1. 这里调用时直接给server核心进程分配共享内存
    2. 方便后续其他进程需要共享内存时直接进行分配
  2. 初始化数据结构
    1. 获取配置文件中所有的交易所名称和交易对名称 并且进行去重
    2. 这里去重使用方法是这样的
      1. 先将列表排序sort(vec.begin(), vec.end());
        1. 算法里的sort用的是一种混合排序
          1. 大多数情况用的是快排
          2. 元素少的时候用插入排序
          3. 递归深度过深的时候变成堆排序
      2. 去除相邻的重复元素vec.erase(unique(vec.begin(), vec.end()), vec.end());
        1. unique是用来移动的 把相邻的重复元素全部放到最后 返回值是最后一个有效值的尾部迭代器 也就是第二个3的迭代器

          1. {1, 1, 2, 3, 3, 4, 4} => {1, 2, 3, 4, 3, 4, 4}
          2. 可以用双指针来实现
            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            15
            16
            17
            18
            19
            template<class Iterator>
            Iterator unique(Iterator first, Iterator last)
            {
            if(first == last) // 如果是空容器 直接返回
            return last;
            Iterator result = first; // result 用于指向去重的最后一个元素
            ++ first; // 从第二个元素开始遍历比较 此时的first是待比较的元素
            while(first != last)
            {
            // 因为result之前的元素一定是不重复的,所以可以直接用result位置的元素和first待比较的元素进行比较
            if(*result != *first) // 如果不同 就移动到result的下一个位置
            {
            ++result;
            *result = *first;
            }
            ++ first;
            }
            return ++result;
            }
        2. 然后再用erase进行移除

        3. 好处就是不需要额外开空间

  3. 初始化交易窗口和对照信息
  4. 对每个交易所 交易对 交易窗口 生成唯一的id
  5. 分配内存

MemoryManager类

其实就相当于一个简单的共享内存池,管理的内存单位是字节

成员变量

  • base_ptr 指向进程地址空间的共享内存区域的指针
  • total_size 共享内存总大小
  • allocations 一个小的std::map 用于存储已经分配的内存区域的名称和对应偏移量的大小 ^1a71d1
    • 是从string -> Allocation的映射
    • Allocation 主要包含了offset和size 标记起始位置和偏移量
  • last_pos 记录上一次分配内存的结束位置 确保内存分配不会重叠
  • shm_name 共享内存的名称 用于创建共享内存的区域

成员函数

  • 构造函数
    • shm_open, shm_unlink 可以申请和释放指定名称的共享内存文件对象,返回值是一个文件描述符
    • ftruncate 用来更改共享内存对象大小 也就是给共享内存对象申请空间
    • mmap 内存映射 用于将共享内存对象中的内容映射到进程地址空间 获取到共享内存的指针
  • 析构函数
    • munmap释放共享内存
  • allocate
    • 用于分配一块共享内存并且指定名称
    • 说是分配 其实核心原理就是把指定大小的内存置零 然后返回分配后的起始位置指针
  • get_ptr
    • 获取指定名称的共享内存
    • 这里是通过上面的[[#^1a71d1|allocations]]的map 来获取到对应的共享内存块 然后在通过共享内存的基地址+偏移量 返回指定共享内存块的地址
  • get_names
    • 获取所有的内存块名称
  • get_offset
    • 获取指定内存块的偏移量

LockfreeArray类

高效无锁的并发数据访问模式,实现高效的读写操作,采用version确保在并发读写过程中的数据一致性

项目问题

  1. 实习的工作内容是什么
    1. 进行交易所的API对接
    2. 完成部分重构工作
    3. 实现一个Websocket的pool
  2. 整个交易系统你做了什么优化
    1. 将cerr输出日志改为使用CLOG输出日志
    2. 尽可能使用Websocket进行数据订阅和请求发送
    3. 将Python代码转为C++代码和Rust代码
      1. 调用延迟从0.5ms降低到小于0.1ms
  3. 你觉得还有哪些可以优化的点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
gantt
dateFormat YYYY-MM-DD
title 软件开发甘特图示例
section 设计
需求 :done, des1, 2023-11-06,2023-11-08
原型 :active, des2, 2023-11-09, 3d
UI设计 : des3, after des2, 5d
未来任务 : des4, after des3, 5d
section 开发
学习准备理解需求 :crit, done, 2023-11-06,24h
设计框架 :crit, done, after des2, 2d
开发 :crit, active, 3d
未来任务 :crit, 5d
耍 :2d
section 测试
功能测试 :active, a1, after des3, 3d
压力测试 :after a1 , 20h
测试报告 : 48h