C++代理模式全解析:从虚拟代理到CRTP的工程实践
发布时间:2026/10/12 4:22:48 锦皓数字建站

C里的代理模式属于那种“名字简单用起来全是讲究”的设计模式。我刚上手那会儿以为它就是个套壳转发写个类实现和真实对象相同的接口内部藏着真实对象所有调用原样转发。真正在项目里待久了才发现代理模式在C里根本不是一个模式而是一族围绕访问控制问题的模式变体。虚拟代理、保护代理、远程代理、缓存代理、同步代理、日志代理……每个变体共享同一副骨架解决的具体问题却完全不同。标题里的“变体”二字才是理解这个模式的钥匙。这篇文章不打算只贴一份经典类图讲概念而是把我在C工程里实际用过的代理变体逐个拆开给出手头可用的代码示例重点讲清楚每个变体解决什么问题、代码长什么样以及那些只有真正写代码的人才会踩到的坑。适合已经熟悉继承、虚函数、智能指针但还没把代理模式用顺手的C开发者。1. 代理模式为什么在C里如此“善变”1.1 先看一个最小可运行的替身对象代理模式的结构非常朴素一个抽象接口一个真实实现一个代理实现。代理内部持有真实对象的引用或指针接口方法和真实对象一一对应但可以在转发前后插入额外逻辑。#include iostream #include memory #include string #include vector class Image { public: virtual ~Image() default; virtual void display() 0; }; class RealImage final : public Image { public: explicit RealImage(std::string path) : path_(std::move(path)) { loadFromDisk(); } void display() override { std::cout display path_ \n; } private: void loadFromDisk() { std::cout load path_ from disk\n; } std::string path_; }; class ImageProxy final : public Image { public: explicit ImageProxy(std::string path) : path_(std::move(path)) {} void display() override { if (!real_) { real_ std::make_uniqueRealImage(path_); } real_-display(); } private: std::string path_; mutable std::unique_ptrRealImage real_; };客户端这样用std::vectorstd::unique_ptrImage images; images.push_back(std::make_uniqueImageProxy(landscape.png)); // 创建代理时不会触发任何文件加载 // 直到真正需要显示图片的那一刻才加载 for (auto img : images) { img-display(); }这段代码里的ImageProxy就是虚拟代理。它没有在构造阶段创建RealImage而是把创建动作拖延到第一次调用display()时。客户端完全感知不到延迟加载的存在它只看到Image接口。这里有两个细节值得展开。第一real_被声明为mutable std::unique_ptrRealImage原因在于代理类经常会把接口方法写成const版本而const方法不允许修改普通成员变量于是把指针声明为mutable表示“这个成员变量可以被 const 函数修改”。它修改的只是指针本身不是指针指向的对象语义上是安全的。第二这种“套壳再转发”的代码就是后面所有变体的共同底座理解了这一点再看各种变体就不会觉得它们是一堆毫不相关的模式。1.2 C语境给代理模式带来了什么和Java、C#这类语言相比C里实现代理模式有几个绕不开的特性直接影响代码的写法。虚函数是C实现运行时多态的默认手段代理接口依赖虚函数意味着每次调用多了一次间接跳转。性能敏感的场景里这个开销不能忽略。如果目标类型在编译期就完全确定可以走CRTP静态代理省掉虚函数开销代价是牺牲运行时多态的灵活性。C还有一个核心特质是值语义和所有权问题。Java中对象天然是引用创建代理只需要保存一个接口引用真实对象的回收交给垃圾收集器。C里真实对象可能由代理独占也可能被多个代理共享还可能是栈上对象的借用指针。如何处理所有权是设计代理时最先要回答的问题。很多新手把代理设计成持有裸指针然后忘了谁释放谁程序就崩了。另一方面RAII让C代理天然适合做资源管理。传统的代理模式更多是控制“行为”但C代理经常会额外承担“生命周期”职责。这也是为什么智能指针本身就是一种代理而且可能还是现代C里被使用次数最多的代理。语言本身提供了这么多机制每个机制都能衍生出一种代理形态变体多也就不奇怪了。1.3 变体的分类逻辑与适用场景一览我把常用代理变体整理成一张表按“代理在转发前后做了什么额外操作”分类变体核心操作典型场景虚拟代理延迟创建真实对象大图、大文档、昂贵对象保护代理调用前检查权限权限系统、白名单、参数校验远程代理跨进程或跨机器转发RPC客户端、接口封装智能引用代理引用计数、生命周期管理智能指针、写时复制缓存代理缓存方法调用结果数据库查询、算法结果复用同步代理调用前后加锁多线程并发访问共享对象日志代理记录调用信息审计、性能分析、故障复现这些变体并非互斥实际工程里经常组合使用。缓存代理需要考虑线程安全于是内部可能包一层同步代理日志代理往往包在最外层记录所有请求。组合的顺序有讲究后面我会专门展开。2. 四大经典变体逐个拆解2.1 虚拟代理把昂贵对象的创建拖延到最后一刻虚拟代理解决的核心问题是真实对象创建成本太高但用户可能根本不会用到它。最典型的场景是图片查看器程序启动时如果加载目录下所有图片到内存启动速度会慢到不可接受。更合理的做法是先显示缩略列表用户点击某张图才真正加载原图。虚拟代理把真实图片的创建往后拖延到第一次调用display()的时刻。原理层面虚拟代理自身不包含真实对象只保存构造真实对象所需的全部信息代码示例里就是文件路径。第一次访问时通过make_unique创建真实对象后续访问直接透传。这种模式也叫懒加载或惰性初始化。从内存占用角度看虚拟代理只是把开销延后并没有真正避免。如果真实对象最终一定会被创建总占用不会减少只是峰值出现时机变了。使用虚拟代理不能无脑套。如果对象创建开销并不高或者几乎必定会被用到虚拟代理只会增加一层分支判断性能反而更差。比如一个配置对象在进程启动后很快就会被读取懒加载的价值就很小。另一个问题是多线程环境下的初始化竞争两个线程如果同时第一次调用display()可能创建两个真实对象其中一个被覆盖。懒加载需要配合双重检查锁定才能保证单例语义这部分内容我在同步代理部分再细讲。2.2 保护代理在入口处把权限检查做扎实保护代理解决的是“谁可以调用这个对象的方法”的问题。常见实现方式是代理在转发调用之前检查当前调用者的身份或角色不满足就拒绝满足才转发。下面是一个简化的权限系统示例。#include iostream #include memory #include stdexcept #include string enum class Role { Guest, User, Admin }; class Document { public: virtual ~Document() default; virtual void view() const 0; virtual void edit() 0; }; class RealDocument final : public Document { public: explicit RealDocument(std::string content) : content_(std::move(content)) {} void view() const override { std::cout content_ \n; } void edit() override { std::cout document edited\n; } private: std::string content_; }; class ProtectedDocument final : public Document { public: ProtectedDocument(Role role, std::unique_ptrDocument doc) : role_(role), doc_(std::move(doc)) {} void view() const override { if (role_ Role::Guest) { throw std::runtime_error(permission denied); } doc_-view(); } void edit() override { if (role_ ! Role::Admin) { throw std::runtime_error(permission denied); } doc_-edit(); } private: Role role_; std::unique_ptrDocument doc_; };写保护代理时有几个考量点。第一保护代理不是安全边界本身而是安全边界的“门卫”。真实对象绝不能从其他渠道泄露出去否则权限检查形同虚设。C里尤其要避免在初始化阶段把真实对象的裸指针或引用传给外部模块。第二权限检查代码往往高度重复C没有注解和反射机制做不到声明式权限控制只能在每个接口方法里手写判断。如果接口有几十个方法可以考虑用宏或模板辅助生成但要用得克制不然代码可读性会明显下降。第三权限拒绝时要保持接口语义一致。抛出异常后代理内部状态不能被破坏下一次调用如果用户角色变化或者权限配置更新代理应该能正常放行。保护代理也常用来做参数校验代理在转发之前检查输入是否满足约束比如数值范围、字符串非空、枚举合法性。这类代码和权限检查的骨架完全相同只是校验规则不同。2.3 远程代理本地“替身”背后的跨进程调用远程代理解决的是“真实对象不在当前进程、甚至不在当前机器”的问题。客户端拿到的对象是一个本地替身调用方法时替身把参数打包成网络请求发给远端远端执行后把结果传回来。代码骨架可以这样表达不涉及具体通信协议#include stdexcept #include string #include vector struct Request { std::string op; std::vectorint args; }; struct Response { bool ok() const { return success; } int result; bool success; std::string error; }; class Transport { public: Response call(const Request req); }; class Calculator { public: virtual ~Calculator() default; virtual int add(int a, int b) 0; }; class RemoteCalculator final : public Calculator { public: explicit RemoteCalculator(Transport transport) : transport_(transport) {} int add(int a, int b) override { Request req; req.op add; req.args {a, b}; Response resp transport_.call(req); if (!resp.ok()) { throw std::runtime_error(resp.error); } return resp.result; } private: Transport transport_; };使用RPC框架时生成的客户端stub本质就是远程代理只是框架帮你把序列化和网络传输藏了起来。日常写代码时接触远程代理的机会很多容易忽视的点通常在异常语义和超时处理上。远程代理的超时语义很重要。本地对象方法要么成功要么抛异常很少会无限期卡住远程代理如果对端无响应必须设置超时和重试上限否则调用方会一直阻塞。错误处理要统一映射到本地接口的异常体系网络错误、超时、对端业务错误应该转换成客户端能理解的异常或错误码而不是各种底层错误直接穿透到业务层。传输对象要考虑序列化成本大对象远程调用时序列化往往是瓶颈必要时用索引或游标代替全量对象传输。此外还要注意幂等性网络重试可能导致远端执行多次接口必须保证幂等或者代理层做去重。2.4 智能引用代理资源管理和附加行为的天然载体这里需要澄清一个容易混淆的点智能指针本质上是代理。shared_ptr在访问get()或operator-时会执行引用计数加减、删除器管理等逻辑然后才把控制权转交给原始指针。从这个角度说智能指针就是智能引用代理的标准实现而且标准库已经提供了一份质量极高的版本。除了智能指针智能引用代理还常用于访问追踪、延迟析构、写时复制等场景。写一个轻量访问统计代理#include cstddef class Image; class StatsProxy { public: explicit StatsProxy(Image* img) : img_(img) {} Image* operator-() { access_count_; return img_; } std::size_t access_count() const { return access_count_; } private: Image* img_; std::size_t access_count_ 0; };这种代理的问题是它只能透传operator-和operator*无法在不改变使用方式的前提下拦截具体业务方法。如果需要拦截某个特定方法还是得走经典接口代理。智能引用代理的“智能”侧重在指针级操作业务级拦截靠接口代理两个层面的东西不要混用。写时复制代理值得多说一句。多个代理共享同一个对象只有真正需要修改时才拷贝一份独立副本给写操作。这个技巧在共享数据量大且读多写少的场景下能省不少内存和拷贝开销但实现细节包括引用计数、拷贝时机的判断、写操作的隔离复杂度不小使用前要评估收益是否值得。3. 工程中最常见的三个实用变体3.1 缓存代理让昂贵查询只付出一次成本缓存代理是项目里用得最频繁的代理变体。核心思路是真实对象始终存在真实方法可能被反复调用且结果昂贵代理在第一次调用后把结果保存起来后续相同参数直接返回缓存内容。#include iostream #include memory #include string #include thread #include unordered_map #include chrono class ValueStore { public: virtual ~ValueStore() default; virtual std::string get(const std::string key) 0; }; class RemoteValueStore final : public ValueStore { public: std::string get(const std::string key) override { // 模拟一次耗时网络查询 std::this_thread::sleep_for(std::chrono::milliseconds(200)); return value[ key ]; } }; class CachingValueStore final : public ValueStore { public: CachingValueStore() : real_(std::make_uniqueRemoteValueStore()) {} std::string get(const std::string key) override { auto it cache_.find(key); if (it ! cache_.end()) { std::cout cache hit: key \n; return it-second; } std::string value real_-get(key); cache_[key] value; return value; } private: std::unique_ptrValueStore real_; std::unordered_mapstd::string, std::string cache_; };工程里缓存代理要考虑的问题比示例多得多。缓存容量必须有上限。无上限缓存会随着访问键增多慢慢吃掉内存最终造成OOM。要给缓存加容量上限配合淘汰策略比如LRU、LFU或FIFO。这里选择哪个淘汰策略取决于访问分布。如果是最近被访问的未来大概率还会被访问用LRU如果是某些固定键访问频率极高用LFU更合适。缓存失效也需要设计。数据源更新后缓存里还是旧值代理必须有主动失效入口比如update或refresh方法或者给缓存项加过期时间。并发穿透是缓存代理最常见的隐患多个线程同时访问同一个未缓存key时会一起穿透到真实对象。解法是加锁配合双重检查或者用单飞模式让相同key的并发请求合并为一次后端调用。另外缓存返回的是对象引用时要注意修改污染外部修改返回值会直接改变缓存内容简单做法是返回拷贝或不可变对象。3.2 同步代理把线程安全问题隔离在单独一层同步代理的职责是在调用真实对象前获取锁调用后释放锁。真实对象本身不关心线程安全线程安全策略全部收敛在代理层。#include cstddef #include memory #include mutex class Counter { public: virtual ~Counter() default; virtual void increment() 0; virtual std::size_t value() const 0; }; class RealCounter final : public Counter { public: void increment() override { counter_; } std::size_t value() const override { return counter_; } private: std::size_t counter_ 0; }; class SynchronizedCounter final : public Counter { public: SynchronizedCounter() : impl_(std::make_uniqueRealCounter()) {} void increment() override { std::lock_guardstd::mutex lock(mutex_); impl_-increment(); } std::size_t value() const override { std::lock_guardstd::mutex lock(mutex_); return impl_-value(); } private: mutable std::mutex mutex_; std::unique_ptrCounter impl_; };写同步代理有三个容易忽略的细节。mutex_必须声明为mutable原因和虚拟代理里的mutable std::unique_ptr一样value()是const方法但加锁操作本身会修改互斥量的内部状态。如果锁成员不是mutable编译会直接报错。第二个细节是锁的粒度。只对单次方法调用加锁是最低粒度保护但多个方法组成的复合操作比如“先读再写”需要代理层之外的更高层锁来保证原子性。把对象包进同步代理并不代表所有并发问题都解决了复合操作的原子性仍然需要外部设计来保证。第三读多写少的场景可以考虑用std::shared_mutex读方法加共享锁写方法加独占锁并发读不再互相阻塞能显著提升吞吐。如果只有std::mutex所有读操作会被串行化代价可能很大。3.3 日志代理与性能分析代理日志代理在接口方法前后记录信息用途包括审计、调试、性能分析等。它可以在不改动真实对象代码的情况下把调用参数、返回值、耗时等信息记录下来。#include chrono #include iostream #include memory #include thread class Task { public: virtual ~Task() default; virtual void run() 0; }; class RealTask final : public Task { public: void run() override { std::this_thread::sleep_for(std::chrono::milliseconds(30)); } }; class TimingTaskProxy final : public Task { public: TimingTaskProxy() : real_(std::make_uniqueRealTask()) {} void run() override { auto begin std::chrono::steady_clock::now(); real_-run(); auto end std::chrono::steady_clock::now(); std::cout run cost std::chrono::durationdouble, std::milli(end - begin).count() ms\n; } private: std::unique_ptrTask real_; };日志代理的坑往往不在功能而在性能和隐私。日志本身有开销。如果每个接口调用都写一行日志热点路径会被明显拖慢。建议做法是日志级别可配置性能分析代理只在 debug 构建或显式开关开启时生效。日志内容也可能包含敏感信息比如密码、token、用户隐私字段记录之前要做脱敏。另外日志输出要避免重复字段调用参数里的序列化字符串可能很长按需截断否则日志文件会膨胀得很快。除了日志还有一种故障注入代理在测试场景特别好用。代理可以在调用真实对象前随机抛出异常、延迟返回、返回错误码用来模拟下游故障验证系统容错逻辑。这类代理和日志代理同属“测试/运维型代理”不用出现生产主链路但关键时刻能救命。3.4 多层代理组合的顺序问题现实项目里代理很少单层使用。最常见的组合是日志代理在最外层缓存代理在中间同步代理在最内层真实对象在最底层。调用顺序大致是记日志查缓存缓存未命中时加锁查真实对象更新缓存结束后再记日志。组合顺序反了会出大问题。如果把同步代理放在最外层日志代理放在最内层日志代码就会在持锁状态下执行。一旦日志IO速度慢锁会被长时间占用阻塞其他线程。日志IO就不应该出现在锁内。同理缓存查询和网络访问也不应该在锁内执行太久锁的持有时间应该压缩到最小。缓存代理和同步代理结合时经典做法是双重检查锁定。伪代码如下std::string value; if (!cache_.find(key)) { // 第一次检查无锁 std::lock_guardstd::mutex lock(mutex_); if (!cache_.find(key)) { // 第二次检查持锁 value real_-get(key); cache_[key] value; } else { value cache_[key]; // 另一个线程刚好写入 } }第一次检查不带锁避免所有线程都来抢锁第二次检查持锁确保只有一个线程真正访问真实对象。现代C里直接用std::mutex配合普通查找就行不建议折腾无锁版本因为无锁双重检查很容易踩数据竞争的内存序问题。组合代理还有一个设计选择每个代理只保留一个接口指针层层嵌套还是设计一个代理类一次性包含全部逻辑。前者分层清晰、复用性好但每层多一次虚函数调用。后者性能好但职责混杂。大多数业务系统优先选分层性能瓶颈出现之后再合层这是常规做法。4. 现代C给代理模式带来的新形态4.1 移动语义和智能指针让代理更安全C11之后写代理模式比老代码舒服很多。主要变化集中在三个方向。make_unique和make_shared消除了裸指针和手动delete异常安全性也更好。前面所有示例都用unique_ptr持有真实对象析构自动释放中途抛出异常也不会泄漏。移动语义让代理的拷贝成本大幅下降。如果代理内部持有unique_ptr代理本身不可拷贝但可以移动如果持有shared_ptr代理可以拷贝。设计代理时要提前想清楚客户端希望怎样持有代理这决定了成员类型的选择。shared_ptr和weak_ptr直接解决了“真实对象生命周期比代理短”的问题。异步回调场景里代理很可能比真实对象活得更久用shared_ptr能让真实对象安全存活到最后一个代理释放。和经典写法相比老式C代理经常需要手动写拷贝构造、拷贝赋值和析构因为类内部有裸资源指针。现在用智能指针成员这些特殊成员函数要么默认生成要么 default代码干净很多。但也别高兴太早如果你手动写了析构函数移动构造和移动赋值可能不会隐式生成这个坑后面会专门讲。4.2 模板与CRTP零开销的静态代理前面所有变体都依赖虚函数实现多态代价是每次调用要查虚表。如果代理类型在编译期完全确定CRTP奇异递归模板模式可以做出零虚函数开销的代理。#include iostream template typename Derived struct TracingProxy { void run() { std::cout before run\n; static_castDerived*(this)-runImpl(); std::cout after run\n; } }; class MyTask final : public TracingProxyMyTask { public: void runImpl() { std::cout real task executed\n; } };客户端使用MyTask task; task.run();这里MyTask通过继承TracingProxyMyTask获得run()run()内部把调用转发给runImpl()。和经典代理相比有几个本质差异。代理逻辑完全内嵌没有对象指针没有虚表没有堆分配业务对象的布局里只有自己的成员。这是真正的零开销抽象。但这种静态代理的类型是固定的MyTask就是真实对象外面套了一层代理逻辑你没法把另一个类型的对象放进来也就不能享受多态容器。CRTP静态代理适合少量固定类型、性能敏感的代码不适合面向接口编程的大型系统。如果既要编译期分发又要多态可以考虑std::variant存储不同类型的代理再通过std::visit分发这在C17以后是可行的但复杂度比虚函数方案高。4.3 表达式模板代理思想在数值计算中的极致体现表达式模板是C模板元编程里一个著名的惰性求值技术本质上也是一种代理思想。以向量加法为例普通代码写a b c会先计算a b生成临时向量再与c相加产生两次临时对象分配和遍历。表达式模板让operator返回的不是结果向量而是一个“表达式代理”它记录左右操作数的引用和加法操作直到赋值时才真正遍历元素求值从而避免中间临时对象。简化示意如下只展示核心思想template typename L, typename R struct AddExpr { L const lhs; R const rhs; }; template typename Vec struct MyVector { // 注意这是示意代码完整实现还需要处理元素访问等细节 template typename Expr MyVector operator(Expr const expr) { for (std::size_t i 0; i size(); i) { data_[i] expr.lhs[i] expr.rhs[i]; } return *this; } }; template typename L, typename R auto operator(L const lhs, R const rhs) { return AddExprL, R{lhs, rhs}; }这里的AddExpr就是表达式代理。它不执行计算只持有操作数和运算关系。代理这个名词在表达式模板语境里已经不局限于GoF模式但思想一脉相承用轻量占位符控制对真实运算的访问和求值时机。这个例子对绝大多数业务程序员来说不需要亲自实现但理解它之后再去读高性能数值计算库的源码会有帮助。它也提醒我们一点代理思想在C里可以完全走出“接口加虚函数”的框架深入到模板和表达式语法层面。5. 怎么选代理变体、怎么避开那些坑5.1 代理和装饰器、适配器、门面到底怎么区分设计模式圈最常争论的问题就是“这代码到底算代理还是装饰器”。我自己的实用判据有三条。场景动机是第一判据。代理的核心动机是控制访问包括能不能访问、何时访问、如何访问。装饰器的核心动机是给已有对象扩展附加行为比如加日志、加加密、加压缩。缓存代理的控制对象是“对昂贵结果的访问”所以归代理。日志装饰器的动机是“给原有方法加日志行为”归装饰器更合适。真实对象的可见性是第二判据。代理通常需要隐藏真实对象客户端完全不知道真实实现的存在。装饰器的客户端通常知道自己在装饰一个具体对象装饰器也允许被装饰对象被取出。生命周期职责是第三判据。代理经常负责真实对象的创建和销毁虚拟代理和智能引用尤其明显。装饰器没那么关心生命周期它默认装饰对象已经存在。适配器和门面差别更大。适配器解决接口不兼容把A接口转换成B接口接口被改变了。门面解决子系统太多的问题提供一个统一入口接口层级更高。三者对比模式是否改变接口核心动机真实对象是否可见代理否控制访问隐藏装饰器否扩展行为通常可见适配器是适配不兼容接口客户端透过新接口访问门面是更高层简化复杂子系统隐藏子系统内部如果代码里日志代理和日志装饰器长得几乎一模一样怎么办我的建议是别先争模式名称先明确组合顺序和职责边界。命名只是沟通工具代码满足需求、可读性好比套对模式名字更重要。5.2 做选择时先过一遍这几个问题面对“要不要用代理”的设计决策我习惯按顺序问自己几个问题。问题是不是访问控制、生命周期或远程调用引起的如果是代理是合理选择。如果是给现有对象增加行为优先考虑装饰器。真实对象创建成本高且可能不会被使用吗用虚拟代理。创建成本高但必然会被使用吗不需要代理直接创建。方法调用昂贵且结果可复用吗用缓存代理。如果结果几乎不重复缓存只是在浪费内存和增加维护复杂度。有没有多线程并发访问考虑同步代理同时检查复合操作是否真的原子。需要审计或追踪吗用日志代理但注意日志性能和脱敏。性能是决定性因素吗考虑CRTP静态代理或者干脆不用代理直接调用真实对象。还要评估代理层数。每层代理都有CPU和调试成本超过两层时要重新审视方案。如果一个接口有几百个方法为每个方法写转发代码成本很高这时候应当考虑是否只代理部分关键方法或者换用门面模式把复杂子系统包装成更小的接口面。5.3 我实际碰过的坑和排查建议最后分享几条真实教训每一条都对应着项目里实际遇到的问题。第一个坑是“代理继承真实类而不是抽象接口”。有人为了少写接口让代理类直接继承具体实现类然后在内部再持有一个同类型对象导致代理和真实类深度耦合。新增一个方法时真实类和代理类都要改而且继承层级一多很快会出菱形继承问题。正确做法是代理只依赖抽象接口不依赖具体实现类。第二个坑是“const正确性没处理好”。虚拟代理的实现在const方法里创建真实对象成员指针不声明mutable就编不过。同步代理的加锁操作同样需要mutable锁。我的经验是写代理类时先想清楚哪些方法会声明为const然后逐成员检查是否需要mutable并且在注释里说明为什么这里修改是安全且必要的避免其他人误改。第三个坑是“移动拷贝没遵守规则五”。代理内部若持有unique_ptr默认拷贝构造被删除但移动构造仍然可能生成。可一旦你手动写了析构函数移动构造和移动赋值可能不会被隐式生成结果代理对象无法放进需要可移动的容器里或者出现意料之外的拷贝。建议是自定义了析构、拷贝、移动任一成员函数的类把其他几个一起声明或者 default。第四个坑是“组合代理时锁的范围控制不住”。缓存代理包同步代理时顺序反了之后锁内会做缓存遍历、网络访问、日志输出性能急剧下降。我建议固定组合顺序日志在最外缓存其次同步在最内真实对象在最底。每次加新代理层时都重新检查一遍哪些代码运行在锁内锁内代码越少越好。第五个坑是“远程代理的异常语义混乱”。远程代理可能抛出网络异常、超时错误、业务错误如果每个错误都原样透传调用方需要catch很多类型。建议代理层统一做异常映射比如超时统一转成超时异常业务错误统一转成对应状态错误。保持接口异常语义稳定比单纯堆信息量更重要。第六个坑是“测试时把代理和真实对象混为一谈”。代理会在转发前后插入逻辑如果只测真实对象代理层的权限检查、缓存、日志逻辑完全没被覆盖。正确做法是真实对象测一遍代理单独测一遍代理测试要验证附加逻辑是否生效。可以用一个记录调用次数的假真实对象来验证代理是否真的只调用了一次、权限拒绝时是否确实没有触及真实对象以及缓存命中后是否没有访问后备存储。这些坑没什么高深理论但决定了代理模式在真实项目里能不能平稳落地。我个人写C代理模式几年下来最大的体会是代理模式的价值从来不在类图结构里而在它能帮你在不修改真实对象的前提下把日志、缓存、同步、权限这些横切关注点收拢到独立的一层。代理相当于给这些横切关注点安排了一个固定座位让业务代码保持干净。选变体时别贪多能用一个代理解决就不要套两层真要用多层先在一张纸上画清楚调用顺序再动手写代码。如果这篇文章里某个代码片段能让你少踩一个坑我就很满足了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。