C++ 设计模式 十一:代理模式 (读书 现代c++设计模式)
代理模式
文章目录
- 代理模式
- 代理
- 智能指针
- 属性代理
- 虚代理
- 通信代理
- 总结
- **何时需要使用代理模式?**
- **代理模式解决的核心问题**
- **与其他设计模式的协同使用**
- **与其他模式的对比**
- **经典应用场景**
- **实现方式与关键点**
- **注意事项**
代理
今天读第十一种设计模式: 代理模式.
代理模式通常和装饰器模式一起对比出现, 装饰器模式一般适用于为类增添一些额外的功能, 而代理模式则是在尽量保持和原类一致的情况下(尽量保留一致的 API
), 为其他对象提供一种代理以控制对这个对象的访问.
不过Proxy
并不是真正的同质 API
,因为人们构建的不同种类的代理数量相当多并且服务完全不同目的。
代理模式一般有这样的核心结构:
- Subject(抽象主题)
定义RealSubject和Proxy的公共接口,使得Proxy能够替代RealSubject。 - RealSubject(真实主题)
实际执行业务逻辑的对象,Proxy会控制对它的访问。 - Proxy(代理)
持有对RealSubject的引用,负责在必要时创建或控制对RealSubject的访问。
智能指针
既然是 Morden c++
那么我们肯定会用到智能指针, 好的, 这就是我们最熟悉的代理模式例子.
智能指针对普通指针做了封装, 同时增加了引用计数, 重写了部分运算符, 但是在使用时, 我们发现他和普通指针几乎一样.
#include <iostream>
#include <memory>
using namespace std;
struct BankAccount {
int deposit_;
explicit BankAccount(const int deposit) : deposit_{deposit} {}
void deposit() const {
std::cout << "Your account have " << deposit_ << " yuan" << std::endl;
}
};
void test() {
const auto* ba = new BankAccount{1};
ba->deposit();
delete ba;
const auto ba2 = make_shared<BankAccount>(2);
ba2->deposit();
}
int main() {
test();
}
程序输出如下:
Your account have 1 yuan
Your account have 2 yuan
可见,无论ba
是普通指针还是智能指针,*ba
在这两种情况下可以获得底层对象。而且智能指针在某些地方可以用来替代普通指针(前者更安全)。
此外也有一些差异, 我们的 shared_ptr
有更多的功能而且采用引用计数可以自动回收.
属性代理
在其他编程语言中,属性用于指示字段和该字段的一组getter/setter
方法。在 c++
中没有属性,但是如果我们想继续使用一个字段,同时给它特定的访问/修改(accessor/mutator
)行为,我们可以构建一个属性代理.
本质上讲属性代理是一个可以伪装成属性的类,所以我们这样定义:
template <typename T>
struct Property {
T value_;
explicit Property(const T initial_value) : value_{initial_value} {}
explicit operator T() const {
// 执行一些getter操作
return value_;
}
Property& operator=(const T& new_value) {
value_ = new_value;
// 执行一些setter操作
return *this;
}
};
现在从行为上来说, Property
显然伪装得很好, 我们可以像使用一个基本类型那样来使用它, 从本质上说我们的类Property<T>
是 T
的替换, 不管它是什么。它的工作原理是简单地允许与T的转换,并允许两者都使用 value
字段.
struct Creature {
Property<int> strength{10};
Property<int> agility{5};
};
void test() {
Creature creature;
creature.agility = 20;
auto x = creature.strength;
}
一般来说我们在一个字段上的操作也能在属性代理类型的字段上工作.
虚代理
某些情况下我们期望对象只在真正访问时才创造(类似单例的懒汉式)而不是立刻创造.
这种方法被称为延迟实例化. 如果需求已经明确那么可以提前准备. 如果不明确何时实例化对象, 那么我们可以构建一个接受现有对象并使其懒惰的代理, 也就是一个 虚拟代理
, 由于底层对象可能不存在, 所以我们访问这个虚拟代理而非底层对象.
我们用图片加载作为例子:
#include <iostream>
#include <memory>
#include <string>
#include <utility>
// 1. Subject接口
class Image {
public:
virtual ~Image() = default;
virtual void display() const = 0;
};
// 2. RealSubject:真实图片类
class RealImage final : public Image {
public:
explicit RealImage(std::string filename)
: filename_(std::move(filename)) {
loadFromDisk();
}
void display() const override {
std::cout << "Displaying image: " << filename_ << std::endl;
}
private:
void loadFromDisk() const {
std::cout << "Loading heavy image: " << filename_ << " (Costly operation)" << std::endl;
}
std::string filename_;
};
// 3. Proxy:控制图片的延迟加载
class ProxyImage final : public Image {
private:
std::string filename_;
mutable std::unique_ptr<RealImage> real_image_; // mutable允许在const方法中修改
public:
explicit ProxyImage(std::string filename)
: filename_(std::move(filename)) {}
void display() const override {
if (!real_image_) {
// 延迟初始化:仅在第一次调用display时加载真实图片
real_image_ = std::make_unique<RealImage>(filename_);
}
real_image_->display();
}
};
我们现在有了本文开头提到的三个类, 一个抽象的接口, 一个具体类, 一个代理类.
在我们的代理类中, 我们持有一个具体类的智能指针, 默认情况下他是一个 nullptr
, 只有到 display()
被调用时才真正初始化内部的 RealImage
对象, 这样就实现了我们想要的延迟初始化功能.
将输出:
Proxy created. Image not loaded yet.
Loading heavy image: high_res_photo.jpg (Costly operation)
Displaying image: high_res_photo.jpg
Displaying image: high_res_photo.jpg
通信代理
最后是代理模式的另一个应用场景: 远程通信.
假设在 Bar
类型的对象上调用成员函数 foo()
。
典型假设是 Bar
与运行代码的机器分配在同一台机器上,并且我们希望与Bar::foo()
在同一进程中执行。
现在假设我们做出了一个设计决定,将 Bar
及其所有成员移到网络上的另一台机器上。但是我们仍然希望旧代码能够工作.
如果想和以前一样继续,这时候就需要一个通信代理 —— 一个代理“通过线路”的调用的组件,当然如果需要的话也会收集结果。
让我们实现一个简单的乒乓服务(ping-pong service)来说明这一点。首先,我们定义一个接口:
#include <string>
#include <iostream>
using namespace std;
struct Pingable {
virtual ~Pingable() = default;
virtual wstring ping(const wstring& message) = 0;
};
然后构建一个pingpong进程:
struct Pong final : Pingable {
wstring ping(const wstring& message) override { return message + L" pong"; }
};
好的, 现在我们 ping
一个 Pong
,它会将单词 “ pong”
附加到消息的末尾并返回该消息.
这里没有使用 ostringstream&
,而是在每次都时创建一个新字符串, 原因在于这个 API
很容易修改成为 Web
服务。
现在可以这样写测试用例:
void tryit(Pingable& pp) {
wcout << pp.ping(L"ping") << "\n";
}
void test() {
Pong pp;
for (int i = 0; i < 3; ++i) {
tryit(pp);
}
}
这样效果就是打印了3次“ping pong”.
然后的部分作者写了个用 .NET
框架和 REST SDK
的例子, 我也没用过, 直接贴原文了(用水平分割线隔开).
现在,假设你决定将 Pingable
服务重新定位到很远很远的 Web
服务器。也许你甚至决定使用其他平台,例如 ASP.NET
,而不是 C++
:
[Route("api/[controller]")]
public class PingPongController : Controller {
[HttpGet("{msg}")]
public string Get(string msg) { return msg + " pong"; }
} // achievement unlocked: use C# in a C++ book
通过此设置,我们将构建一个名为 RemotePong
的通信代理 这将用于代替 Pong
。微软的 REST SDK
在这里派上了用场。
struct RemotePong : Pingable {
wstring ping(const wstring& message) override {
wstring result;
http_client client(U("http://localhost:9149/"));
uri_builder builder(U("/api/pingpong/"));
builder.append(message);
pplx::task<wstring> task = client.request(methods::GET, builder.to_string())
.then([=](http_response r) {
return r.extract_string();
});
task.wait();
return task.get();
}
};
注1: Microsoft REST SDK 是一个用于处理 REST 服务的 C++ 库。它既是开源的又是跨平台的。你可以在 GitHub 上找到它:https:/ github.com/Microsoft/cpprestsdk.
如果你不习惯 REST SDK
,前面的内容可能看起来有点令人困惑;除了 REST
支持之外,SDK
还使用了并发运行时,这是一个 Microsoft
库,用于并发支持。实现此功能后,我们现在可以进行一个更改:
void test() {
RemotePong pp; // was Pong
for (int i = 0; i < 3; ++i) {
tryit(pp);
}
}
就是这样,你得到相同的输出,但实际的实现可以在地球另一端某个地方的 Docker
容器中的 Kestrel
上运行。
总结
何时需要使用代理模式?
代理模式的核心应用场景是 控制和管理对对象的访问,具体需求场景包括:
- 延迟加载(虚拟代理):
- 当对象创建开销大时(如大文件加载、数据库查询),代理可延迟实际对象的初始化,仅在需要时创建。
- 示例:网页图片的占位符代理,仅在用户滚动到可见区域时加载真实图片。
- 访问控制(保护代理):
- 限制客户端对敏感对象的直接访问,代理可添加权限验证逻辑。
- 示例:文件系统代理检查用户权限后再允许文件操作。
- 远程服务调用(远程代理):
- 代理隐藏远程调用的复杂性(如网络通信、序列化),使客户端像调用本地对象一样操作远程服务。
- 示例:分布式系统中的服务接口代理(如gRPC、REST客户端)。
- 增强功能(智能代理):
- 在访问对象前后添加额外逻辑,如日志记录、缓存、性能监控。
- 示例:数据库查询代理自动缓存结果,减少重复查询。
代理模式解决的核心问题
- 直接访问的局限性:
- 客户端直接访问对象可能导致安全隐患(如权限缺失)或性能问题(如高频请求)。
- 职责分离:
- 将对象访问的控制逻辑(如权限、日志)与实际业务逻辑解耦,遵循单一职责原则。
- 简化复杂性:
- 隐藏对象的实现细节(如远程调用、复杂初始化过程),提供简洁的接口。
与其他设计模式的协同使用
代理模式常与其他模式结合,以实现更灵活的系统设计:
模式 | 协同场景 | 示例 |
---|---|---|
工厂模式 | 代理内部使用工厂创建实际对象,集中管理对象的初始化逻辑。 | 虚拟代理通过工厂延迟创建高开销对象(如大型3D模型)。 |
装饰器模式 | 两者均通过包装对象扩展功能,但代理控制访问,装饰器增强行为。 | 代理添加权限控制,装饰器添加日志功能,共同包装支付服务接口。 |
适配器模式 | 代理控制访问,适配器转换接口,两者结合解决接口兼容性与访问控制的复合问题。 | 旧版API通过适配器转换为新接口,再由代理添加缓存功能。 |
观察者模式 | 代理监控对象状态变化,触发观察者的通知逻辑。 | 文件系统代理监听文件修改事件,通知日志模块记录操作。 |
与其他模式的对比
- 装饰器模式:
- 装饰器:动态添加功能(如颜色、日志),接口与原始对象一致。
- 代理:控制访问(如权限、延迟加载),可能改变对象行为。
- 适配器模式:
- 适配器:解决接口不兼容问题(如USB转Type-C)。
- 代理:解决访问控制问题(如权限校验)。
经典应用场景
- 智能指针(C++):
std::shared_ptr
作为资源代理,管理内存的生命周期,自动释放无需的对象。
- Spring AOP(Java):
- 动态代理实现面向切面编程,统一处理日志、事务等横切关注点。
- RPC框架:
- 客户端通过代理调用远程服务,屏蔽网络通信细节(如Dubbo、gRPC)。
- 缓存代理:
- 为数据库查询接口添加缓存层,减少重复请求对后端服务的压力。
实现方式与关键点
- 定义统一接口:
- 代理类和实际类实现相同的接口,确保客户端无感知。
- 控制访问逻辑:
- 在代理类中嵌入权限检查、延迟加载等逻辑。
- 透明性:
- 客户端无需知道代理的存在,直接通过接口操作。
注意事项
- 避免过度代理:仅在实际需要控制访问时使用,否则会增加系统复杂度。
- 性能权衡:代理的额外逻辑(如网络请求、权限检查)可能引入性能开销。
- 接口一致性:代理必须完全实现目标接口,否则会导致客户端调用失败。
代理模式是 访问控制的守门人,其核心价值在于:
- 安全性与可控性:通过权限校验、延迟加载等机制保护核心对象。
- 解耦与扩展性:分离访问控制逻辑与业务逻辑,支持灵活的功能扩展。
- 透明封装:客户端无需感知代理存在,降低使用复杂度。
适用于需要精细化控制对象访问的场景,是现代分布式系统、安全框架和高性能应用中的基础设计模式。