上个季度我接手了一个消息转发模块,需求一句话:接入三家通知渠道,按优先级下发,失败自动降级。一开始我心想这不就是if-else吗,写着写着发现代码膨胀得厉害,第9个渠道加进来时,主函数已经没法看了。后来我用职责链模式重构了一遍,但用的并不是教科书上那种标准写法,而是几个在C++工程里更实用的变体。
这篇文章就把我实际用过的几种职责链变体整理出来,包括设计动机、适用场景、C++实现要点和调试经验。如果你是刚接触设计模式,标准职责链可以先翻翻书;如果已经在工程里写过职责链但总感觉别扭,那这篇文章应该能给你一些直接用得上的思路。
1. 标准职责链的局限:为什么需要变体
1.1 标准职责链的核心逻辑
职责链模式(Chain of Responsibility)在GoF书里的定义很明确:把多个处理者串成一条链,请求沿着链传递,直到某个处理者决定接手。UML图画出来很漂亮,但落到C++代码里,核心其实就是三样东西:一个Handler基类、一个next指针、一个在派生类里重写的处理函数。
一个最朴素的C++实现长这样:
cpp复制class Request {
public:
explicit Request(int level, std::string msg)
: m_level(level), m_msg(std::move(msg)) {}
int level() const { return m_level; }
const std::string& msg() const { return m_msg; }
private:
int m_level;
std::string m_msg;
};
class Handler {
public:
virtual ~Handler() = default;
void setNext(Handler* next) { m_next = next; }
void handle(const Request& req) {
if (canHandle(req)) {
process(req);
} else if (m_next != nullptr) {
m_next->handle(req);
}
}
protected:
virtual bool canHandle(const Request& req) = 0;
virtual void process(const Request& req) = 0;
private:
Handler* m_next = nullptr;
};
调用方把各Handler手动串起来:
cpp复制auto warn = std::make_unique<WarningHandler>();
auto error = std::make_unique<ErrorHandler>();
warn->setNext(error.get());
warn->handle(Request(3, "磁盘空间不足"));
这套代码本身没什么问题,它把“谁能处理”的判断逻辑拆到了各个Handler内部,请求进来后不用在调用方写一长串条件分支。每个Handler只知道自己处理不了就往下传,代码是干净的。
1.2 三个典型局限
标准职责链最大的问题不是它做错了什么,而是它只覆盖了一种很窄的场景:一个请求最多被一个处理者接收。这种“或”语义在实际C++项目里远远不够用。
第一个局限:它没法表达“多个处理者都要执行”的语义。比如一次网络请求进来,要先解析协议、再校验登录态、再做权限判断、还要记录埋点。这是一条完整的责任链,但每个环节都做自己的事,任何一个环节失败都应当中断整个流程。标准职责链“第二个节点处理完就结束”的行为,在这种场景下完全不合适。
第二个局限:链的装配方式是硬编码的。大家写职责链时通常都是new完一个Handler,接着setNext,再new下一个,链的顺序在编译期就固定了。假如产品经理某天说“调一下校验顺序”,你就得改代码重编译。如果系统里存在多条不同顺序的链,这种硬编码方式的维护成本会指数级上升。
第三个局限:节点之间的数据传递太弱。标准职责链传的是const Request&,也就是说每个处理者只能读不能改,更谈不上在链上累积状态、回写结果。然而真实业务里,请求过了鉴权节点后,很可能会留下一个用户身份对象,供后面路由节点使用。这些信息不通过请求上下文传,就得上全局变量,那代码质量基本就失控了。
说到底,标准职责链是一个很好的思想原型,但它解决的是“谁来处理”的问题。工程里更多时候需要解决的是“按什么顺序、以什么方式、处理哪些步骤”的问题,这时候就必须做变体。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五种常用变体及适用场景
2.1 责任分解型链:从“或”到“与”
第一种变体我在实际项目里用得最多,它把职责链的语义从“找到一个处理者”改成“所有节点按顺序执行,每个节点负责自己那份工作”。业内有人叫它中间件管道(Pipeline),也有人叫它 Filter Chain,本质上都是同一个东西。
核心变化在于:每个节点不再返回void,而是返回一个执行结果,告诉链是否继续往后跑。我用一个枚举表达:
cpp复制enum class StepResult {
GoOn, // 本节点处理完成,继续下一个
Abort // 中断整条链
};
struct Context {
std::string rawData;
bool parsed = false;
uint64_t userId = 0;
bool authorized = false;
std::string output;
};
class IProcessor {
public:
virtual ~IProcessor() = default;
virtual StepResult process(Context& ctx) = 0;
};
class ProcessorChain {
public:
void add(std::shared_ptr<IProcessor> p) {
m_processors.push_back(std::move(p));
}
StepResult run(Context& ctx) {
for (auto& p : m_processors) {
StepResult r = p->process(ctx);
if (r == StepResult::Abort) {
return r;
}
}
return StepResult::GoOn;
}
private:
std::vector<std::shared_ptr<IProcessor>> m_processors;
};
注意这里Context是以非常量引用传给每个节点的,这是和标准职责链最大的区别。每个节点都能从Context里读取前面节点写好的数据,也能往里面塞新数据。比如解析节点写完ctx.parsed = true,后面鉴权节点直接读ctx.userId,不用再解析一遍原始报文。
这种方式最适合校验链、数据清洗链、中间件链。新增一个处理步骤的成本,是写一个新类,然后在装配处add一行。删掉一个步骤,把add行注释掉就行,完全不需要改其他类。
我当时做消息转发模块,就是先用这种方式把整个消息从收包到路由拆成了五个步骤,后面加渠道、加限流策略,主循环一行都没动过。
2.2 优先级链:把顺序从代码里剥离出来
责任分解型链解决了“多个节点都要执行”的问题,但顺序仍然是固定的,按add的先后顺序跑。如果链的顺序需要动态调整,或者同一批处理器在不同场景下有不同优先级,就得引入优先级链。
优先级链的基本思路是:每个处理器注册时自带一个优先级数字,链容器根据优先级自动排序,执行时从高优先级往低优先级扫,命中了就处理,处理完可以根据结果决定是否继续。
C++里用std::multimap实现最方便,因为multimap天然按照key排序:
cpp复制class PriorityChain {
public:
void add(int priority, std::shared_ptr<IProcessor> p) {
m_handlers.emplace(priority, std::move(p));
}
StepResult run(Context& ctx) {
// multimap 默认按 key 升序排列,所以 -100 的优先级会在 100 前面
for (auto& [priority, handler] : m_handlers) {
StepResult r = handler->process(ctx);
if (r == StepResult::Abort) {
return r;
}
}
return StepResult::GoOn;
}
private:
std::multimap<int, std::shared_ptr<IProcessor>> m_handlers;
};
需要说明的是,优先级越大越靠前还是越小越靠前,这是个约定问题。我习惯用“数值越大越先执行”,然后在add的时候把普通处理器放在100,紧急处理器放在1000,新加的兜底逻辑放在0,这样看代码时一眼就能判断顺序。
优先级链最典型的场景是日志分级。我之前在服务里写过一个审计日志模块,debug、info、warning、error四级日志分给不同节点处理,每一级还可以继续传给下一级(比如error日志既进本地文件又上报监控)。如果哪天下线一个日志通道,改一下add的优先级,或者干脆不add,完全不需要动其他日志节点代码。
这种变体的实质是:把“顺序”从代码结构里抽离出来,变成一种可配置的元数据。配置驱动的好处是应对需求变更更快,代价是如果优先级设计得不合理,日志排查时会发现执行顺序很反直觉,所以实践中要控制优先级数值的集中管理,最好定义成常量,不要散落在各种add调用里。
2.3 可跳转链:节点主动选择下一个处理器
前面两种变体,链的遍历控制权都集中在run函数里,节点自己是没有任何发言权的。可跳转链的思路完全反过来:每个节点处理完请求后,可以返回一个标识符,告诉链“我处理完了,下一个交给谁”。
这种变体其实已经非常接近状态机了,区别在于它仍然保留了“链”的外形:所有处理器还是注册在一个容器里,容器通过标识符查找下一个处理器的地址。
cpp复制class IJumpableProcessor {
public:
virtual ~IJumpableProcessor() = default;
// 返回下一个节点的 id;返回空串表示链结束
virtual std::string process(Context& ctx) = 0;
};
class JumpChain {
public:
void registerNode(std::string id, std::shared_ptr<IJumpableProcessor> p) {
m_nodes[std::move(id)] = std::move(p);
}
void run(Context& ctx, const std::string& entryId) {
std::string currentId = entryId;
int maxSteps = m_nodes.size() * 2 + 16; // 保险丝,防止死循环
while (!currentId.empty() && (maxSteps-- > 0)) {
auto it = m_nodes.find(currentId);
if (it == m_nodes.end()) {
// 找不到节点,打个日志再终止
break;
}
currentId = it->second->process(ctx);
}
}
private:
std::unordered_map<std::string, std::shared_ptr<IJumpableProcessor>> m_nodes;
};
这个变体在复杂订单流转、审批流、安装向导这类场景里特别顺手。比如一个订单状态机:订单刚创建走“待支付”节点,支付回调成功后跳转“待发货”,发货后跳转“待收货”,每个节点自己决定下一跳是谁,业务逻辑内聚在每个节点里,新加一个状态不需要改中央控制器。
我特别提醒一点:跳转型链必须加步数上限。我见过一个线上问题,某个节点因为数据异常,next返回了上一个节点的id,消息就在两个节点之间循环转发,CPU直接打满。后来在run函数里加了步数保险丝,超过节点数量的两倍就强制终止并报警,问题才被兜住。
2.4 广播式链:所有节点都执行
广播式链在严格意义上已经不太像“职责链”了,它不关心谁处理,而是让所有注册的处理器都收到请求。这种模式在事件系统、插件钩子、观察者通知里非常常见。
C++实现起来也很简单,本质上就是一个vector里存了一堆回调,发布事件时挨个调用:
cpp复制class EventBus {
public:
void subscribe(std::function<void(const Event&)> cb) {
m_listeners.push_back(std::move(cb));
}
void publish(const Event& e) const {
for (auto& cb : m_listeners) {
cb(e);
}
}
private:
std::vector<std::function<void(const Event&)>> m_listeners;
};
为什么我把广播式也算作职责链变体?因为它和标准职责链解决的是同一个问题:解耦事件的发送者和接收者。区别只在于标准职责链要求“只有一个节点最终处理”,广播式是“所有节点都处理”。在很多实际系统中,这两者是混合使用的:先广播给所有订阅者,再通过链式规则找到最终执行者。
广播式的坑主要集中在订阅者的执行顺序和异常处理上。如果两个订阅者都对同一个事件做写操作,顺序不同结果可能不同。而如果某个订阅者抛了异常,后面的订阅者就收不到通知。我在项目里一般约定:订阅者内部不允许抛异常,所有异常catch在publish里统一处理。这个约定虽然牺牲了一点灵活性,但换来了稳定性和可维护性。
2.5 轻量级函数链:std::function和lambda的组合
前面几种变体都有一个共同点:每个节点都是一个类。如果处理器数量不多、逻辑又很轻,为每个节点定义一个类实在有点杀鸡用牛刀。这时候可以用std::function把节点“函数化”,配合lambda构造一条轻量级链。
cpp复制using HandlerFn = std::function<StepResult(Context&)>;
class FnChain {
public:
void add(HandlerFn fn) { m_fns.push_back(std::move(fn)); }
StepResult run(Context& ctx) {
for (auto& fn : m_fns) {
StepResult r = fn(ctx);
if (r == StepResult::Abort) {
return r;
}
}
return StepResult::GoOn;
}
private:
std::vector<HandlerFn> m_fns;
};
使用的时候直接塞lambda,代码量能省一半以上:
cpp复制FnChain chain;
chain.add([](Context& ctx) -> StepResult {
// 解析协议
return StepResult::GoOn;
});
chain.add([](Context& ctx) -> StepResult {
// 校验登录态
return StepResult::GoOn;
});
这种变体的优点是写起来飞快,特别适合工具类软件、脚本化模块、原型验证。缺点也明显:lambda没有类名,日志里只能看到std::function的调用栈,排查问题时定位不到具体节点。我的经验是能用函数链就不建类,一旦节点超过五六个或者需要复用,马上切换回类版本,避免技术债积累。
2.6 变体对比速查表
| 变体 | 核心语义 | 典型场景 | C++关键实现 |
|---|---|---|---|
| 责任分解型链 | 所有节点依次执行,可中断 | 校验链、中间件、数据清洗 | vector + StepResult枚举 |
| 优先级链 | 按优先级排序后执行 | 日志分级、渠道降级 | multimap按key排序 |
| 可跳转链 | 节点决定下一跳 | 订单状态机、审批流 | unordered_map + id跳转 |
| 广播式链 | 所有节点都收到通知 | 事件总线、插件钩子 | vector |
| 轻量级函数链 | 快速装配的链 | 原型验证、少量节点 | vector |
这个表格可以作为选型参考。实际项目里它们并不互斥,我自己就写过“优先级链 + 责任分解链”的混合体:先按优先级排序,再逐个执行直到某个节点返回Abort。具体怎么组合,完全看业务需要。
3. C++实现职责链变体的几个硬核细节
3.1 节点生命周期与所有权管理
C++由于没有垃圾回收,职责链的首个问题是:这些节点对象到底归谁管?不同的所有权模型直接决定代码的健壮性。
标准职责链教科书里常用裸指针,因为示例代码短,但工程里裸指针串链是最容易踩坑的。假如某个Handler被提前释放,链上还留着指向它的next指针,下一次请求进来就直接悬空访问。我排查过一个很隐蔽的崩溃,就是从数据库配置里动态创建Handler,配置刷新时旧Handler被删了,但链的头部还指着它,下次调用直接段错误。
工程上我建议用shared_ptr统一管理节点生命周期,尤其是链会被多个模块共享的场景。对于责任分解型链,vector<shared_ptr
还要注意一点:节点内部如果存了Context的引用或指针,一定要确保Context的生命周期覆盖整个链的执行过程。我在异步场景中常用shared_ptr
3.2 请求上下文对象的设计
职责链变体能发挥多大威力,很大程度上取决于Context设计得好不好。我见过很多失败的案例,Context被当成垃圾场,所有字段都是optional,节点之间靠猜。
比较稳妥的设计原则有两个。
第一,Context里只放“链上节点需要共享的数据”,不要把中间计算结果全塞进去。比如业务路由节点暂时不需要原始封包的二进制内容,那Context里就没必要放一个vector
第二,字段语义要明确。不要用map<string, any>这种万能容器来录数据,虽然写的时候很爽,但读的人根本不知道key叫什么、value是什么类型。C++17之后可以用std::variant表达有限的类型集合,但我更推荐直接定义结构体字段,编译器能帮我们检查类型安全。
关于回写语义,需要明确一点:节点内修改Context是否会影响后续节点。由于Context是引用传递,修改就是全局生效的。如果某个节点只想临时改一下数据、不希望污染后续流程,那就应该复制一份再改,或者把修改放在链的尾部节点。这个约定最好写在项目文档里,不然两个节点不改Context的默认印象会互相打架。
3.3 异常安全与RAII
C++异常处理在设计模式落地时经常被忽略。链式调用天然有一个问题:如果第3个节点抛异常,前面的节点已经处理完了,后面的节点还没执行,整个系统处于什么状态?
对责任分解型链,我的习惯是:链本身不捕获异常,让异常向上抛给顶层调用方统一处理。这样一旦某个节点失败,链的状态是“前面节点执行完、当前节点失败、后面节点未执行”,语义非常清晰。如果在链里捕获异常并决定continue,很容易造成“第3个节点失败了,但第4个节点还以为前面的步骤都成功了”的错觉。
但节点内部必须保证RAII安全,尤其是持有锁、文件句柄、数据库连接的节点。处理函数里如果提前return或抛异常,析构函数必须负责释放资源。归根结底还是C++的老规矩:资源获取即初始化,尽量用智能指针和RAII容器管理资源,不要手动new/delete。
另外提醒一句:不要在析构函数里做可能抛异常的清理操作,也不要在信号处理函数里放复杂逻辑。这些虽然是C++的基本常识,但在写链式处理时特别容易被忽略,因为链上每个节点通常都会做资源类操作。
3.4 性能:虚函数调用和递归风险
职责链变体的性能核心问题是调用开销,但这个开销往往被高估了。一次虚函数调用在现代CPU上大概是几纳秒到几十纳秒,链上几十个节点也就是微秒级别,相比网络IO、数据库查询动辄毫秒级,完全不是瓶颈。如果业务逻辑本身很重,完全不需要担心虚函数开销。
真正需要小心的是递归遍历。标准职责链的handle函数是递归调用的:每个节点不处理就调用next->handle(req),链长几百个节点时,栈深度可能达到几百层,虽然不至于立刻爆栈,但栈空间浪费明显,而且排查问题时调用栈非常绕。更好的做法是把递归改成迭代循环:先找到能处理的节点,然后直接调用它的process方法。
cpp复制void handle(const Request& req) {
Handler* cur = this;
while (cur) {
if (cur->canHandle(req)) {
cur->process(req);
return;
}
cur = cur->m_next;
}
}
如果链特别长、每次请求都要遍历大部分节点,可以考虑在装配阶段就把节点按类型或哈希值组织起来,比如路由节点直接通过map查找目标处理器,而不是从头扫到尾。这其实就是用空间换时间,在热点路径上效果明显。
4. 实战案例:用责任分解链重构消息分发模块
4.1 业务背景
回到文章开头那个消息转发模块。当时模块本身是给别人提供消息推送服务的,外部调用方把消息塞进来,模块负责把消息投递给正确的下游渠道。最开始实现是主函数里一长串if-else:
先判断消息类型,再检查调用方有没有权限,然后过滤敏感词,再按类型选择渠道,最后记录日志。三个月后这个函数已经300多行,每加一个渠道就要从头看到尾。
我用责任分解型链重构成了五个步骤:协议解析、鉴权、敏感词过滤、业务路由、埋点上报。
4.2 核心代码实现
先定义消息上下文:
cpp复制struct MessageContext {
std::string rawData; // 上游传入的原始数据
bool parsed = false; // 解析步骤是否完成
uint64_t appId = 0; // 调用方应用ID
std::string msgContent; // 解析后的消息正文
std::string msgType; // 消息类型:chat / notice / marketing
bool authorized = false; // 鉴权是否通过
bool filtered = false; // 敏感词过滤是否完成
std::string targetChannel; // 最终选择的下游渠道
bool reported = false; // 埋点是否上报
};
再定义两个具体步骤:
cpp复制class ProtocolParseStep : public IProcessor {
public:
StepResult process(MessageContext& ctx) override {
// 这里假设 rawData 是一个简单的 JSON 字符串
// 实际项目中可能是 protobuf、自研二进制协议等
if (ctx.rawData.empty()) {
return StepResult::Abort;
}
// 解析出 appId、msgType、msgContent...
ctx.parsed = true;
return StepResult::GoOn;
}
};
class AuthStep : public IProcessor {
public:
StepResult process(MessageContext& ctx) override {
if (!ctx.parsed) {
// 依赖前置步骤,顺序不对直接中断
return StepResult::Abort;
}
// 用 ctx.appId 查询权限表
ctx.authorized = (ctx.appId != 0);
return ctx.authorized ? StepResult::GoOn : StepResult::Abort;
}
};
class SensitiveFilterStep : public IProcessor {
public:
StepResult process(MessageContext& ctx) override {
if (!ctx.authorized) return StepResult::Abort;
// 过滤敏感词,命中就改写 msgContent
filterSensitiveWords(ctx.msgContent);
ctx.filtered = true;
return StepResult::GoOn;
}
};
class RouteStep : public IProcessor {
public:
StepResult process(MessageContext& ctx) override {
if (!ctx.filtered) return StepResult::Abort;
// 按 msgType 选渠道
if (ctx.msgType == "chat") {
ctx.targetChannel = "im_channel";
} else if (ctx.msgType == "notice") {
ctx.targetChannel = "push_channel";
} else {
ctx.targetChannel = "email_channel";
}
return StepResult::GoOn;
}
};
class ReportStep : public IProcessor {
public:
StepResult process(MessageContext& ctx) override {
// 上报日志/指标
ctx.reported = true;
return StepResult::GoOn;
}
};
装配入口:
cpp复制class MessageProcessChain {
public:
MessageProcessChain() {
m_chain.add(std::make_shared<ProtocolParseStep>());
m_chain.add(std::make_shared<AuthStep>());
m_chain.add(std::make_shared<SensitiveFilterStep>());
m_chain.add(std::make_shared<RouteStep>());
m_chain.add(std::make_shared<ReportStep>());
}
bool process(std::string raw) {
MessageContext ctx;
ctx.rawData = std::move(raw);
return m_chain.run(ctx) == StepResult::GoOn;
}
private:
ProcessorChain m_chain;
};
主调用点变成:
cpp复制int main() {
MessageProcessChain chain;
std::string raw = R"({"app_id": 123, "type": "chat", "content": "hello"})";
if (chain.process(raw)) {
std::cout << "消息投递成功\n";
} else {
std::cout << "消息被拒绝或处理失败\n";
}
}
4.3 关键设计说明
重构完最大的感受是:主函数变成了两行,所有业务逻辑都被装进了各自的节点类里。以后新增一个“消息内容审核”步骤,只需要写一个新类,然后在构造函数里add一行即可。删除一个步骤,注释掉对应add行,其他任何地方不用改。
这个例子里的每个节点都检查了前置条件,比如AuthStep先判断ctx.parsed,SensitiveFilterStep先判断ctx.authorized。这些检查在正常装配下是多余的,但一旦装配顺序被人为调乱,它们能快速暴露问题,比等到后面步骤数据解析失败再排查高效得多。
如果把需求改成“三个渠道按优先级下发,失败自动降级”,只需要把MessageProcessChain的底层容器从ProcessorChain换成PriorityChain,然后把RouteStep拆成三个渠道节点,每个节点带优先级。主循环不需要改,请求上下文也不需要改,这就是面向接口设计带来的好处。
5. 常见问题与调试技巧实录
5.1 问题速查表
下面整理了我实践过程中遇到过的几类典型问题:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 消息“被吞”,没有走到任何节点处理 | 链上没有任何节点能匹配,或入口节点根本不存在 | 检查链装配是否漏了入口,是否配置了兜底节点 |
| 处理顺序不符合预期 | 优先级数值混乱,或add调用顺序不对 | 打印链上所有节点的优先级和执行顺序 |
| 死循环或栈溢出 | 可跳转链里next返回了自己或前面的节点 | 在run函数加步数上限,超过阈值打warn日志 |
| 某些节点没执行 | 前面的节点返回了Abort,链被中断 | 查看哪个节点返回了Abort,再加日志确认细节 |
| 处理结果与单步测试不一致 | Context被前面某个节点意外修改 | 在关键字段写前后加日志,对比值 |
| 异步场景下崩溃 | Context栈上定义,链还没跑完回调就返回了 | 改用shared_ptr |
这里面的“兜底节点”值得多说两句。我用责任分解型链时,必然会在链尾加一个TerminalStep,它的职责只有一个:打印一条“没有任何节点处理该请求”的日志。因为请求被静默吞掉是最难排查的现象,有兜底节点至少能留下线索。
5.2 两个调试小技巧
第一个技巧:给每个节点加name()方法,日志里带上节点名。类名在debugger里看得到,但在分布式日志系统里往往只能看到线程ID和行号,字段一多就分不清是哪个节点打的日志。加上name()之后,链执行日志会变成:
code复制[MessageProcessChain] enter node: ProtocolParseStep
[MessageProcessChain] enter node: AuthStep
[MessageProcessChain] enter node: SensitiveFilterStep
排查问题时对着这串日志,一眼就能看出执行到哪一步断掉了。
第二个技巧:链装配完成后打一次全量节点列表。这个日志可能只在启动时打印一次,但价值极高。线上排查“为什么这个请求没走限流”时,先看启动日志里限流节点到底装配了没有,比打开代码一遍遍对照add调用快得多。
5.3 扩展思路:把链做成配置驱动
如果项目里有多套不同顺序的链,可以考虑把节点类型和顺序放到配置文件或数据库里,启动时根据配置动态装配。C++实现可以用一个工厂函数,根据字符串类名创建对应的IProcessor实例。
这种做法的收益是链的调整不需要改代码、不需要重新编译,非常适合需要频繁调整策略的系统。代价是需要额外的注册机制,也增加了启动阶段动态装配的复杂度。我的建议是:系统里有两套以上链、并且顺序经常变动时再上配置驱动,否则简单的手写装配完全够用,过度设计反而增加维护成本。
最后再分享一个小技巧:给链里的每个处理器加一个name()方法,记录日志时把当前节点名字打出来,排查线上问题时你一定会感谢这个设计。
