对象池模式(Object Pool Pattern)
对象池模式是一种创建型设计模式,它维护一组预先初始化的对象——称为“池”——以供使用,而不是按需创建和销毁它们。它通过四个简单的步骤工作:
- 初始化池:创建并加载对象到池中。
- 查找合适的对象:根据特定条件选择一个可用的对象。
- 获取对象:将该对象保留供独占使用。
- 释放对象:在使用后将对象归还到池中。
为什么使用对象池模式?
主要优势是高效的资源管理和并行化协同,特别是在运行并行测试时。它还能解决一些常见问题:
- 会话冲突:防止多个测试使用相同的会话。
- 资源耗尽:确保用户/账户不会被过度使用。
- 杀虫剂悖论:通过刷新对象避免陈旧的测试数据。
- 数据发酵:确保旧数据在系统更新后仍然有效。
最佳使用场景: 当您有有限数量的对象(如测试账户),无法在测试执行期间动态生成时。
替代方法(以及为何失败)
没有对象池的情况下,常见的变通方法包括:
- 每个测试分配:在每个测试的“Arrange”部分分配用户。
- 套件级分配:在
@beforeAll
钩子中分配用户。
这两种方法都会引入测试依赖性,需要严格的执行顺序以避免冲突。这种手动操作可能导致性能下降,即使测试被均匀地重新分配到不同的套件中。
例如,如果一个套件有 20 个测试,另一个套件有 50 个测试,并行执行时,较小的套件完成后会有一条线程空闲。重新分配测试有所帮助,但并不具有可扩展性。
为什么对象池模式胜出
对象池可以保证:
- 每次测试只能获取一个用户。
- 测试独立运行,互不冲突。
- 当工作线程数等于池大小时,性能保持最优。
然而,当多个工作线程同时访问池时,可能会出现竞争条件。常见的解决方案包括互斥锁(mutexes)、信号量(semaphores)和线程安全结构,但 JavaScript 的单线程特性使问题复杂化。
在 Playwright 中实现对象池
Playwright Test 为每个工作线程提供了一个隔离的环境,没有共享状态。预加载用户会创建私有池,而互斥锁无法跨工作线程同步。以下是几种实现方式:
- 锁文件方法:当获取用户时创建一个锁文件(例如
users_copy.json.lock
)。这种方法可以防止冲突,但可能引入 I/O 开销。 -
API 服务器(最佳解决方案) :一个集中式的 API 管理池,确保所有工作线程之间的同步。我喜欢这种方法,因为它提供了以下优势:
- 所有工作线程的单一真实来源。
- 内置同步机制,防止竞争条件(需要自行实现同步)。
- 使用现代后端框架易于实现。
事实上,您至少需要两个 API 端点:
-
/acquire
—— 预留对象。 -
/release
—— 使用后归还对象。
此外,Playwright 的内置 webServer
可以在每次测试运行前启动此 API。
playwright webServer示例
CI 挑战与水平扩展
直到我们引入水平扩展(即在多台机器上运行 Playwright 测试)之前,这两种解决方案都表现良好。每台机器都会创建自己的私有池,使得同步变得不可能。
锁文件方法在此情况下失效,因为锁文件仅限于每台本地机器。为了克服这个问题,我们可以调整设置:
- 将 API 服务器作为单独的步骤运行:在运行 Playwright 测试之前,通过在
.yaml
文件中添加一个步骤来启动 API 服务器。 - 网络可访问性(前提条件) :确保所有机器都能访问部署 API 的服务器。
github action .yaml示例
这种方法在所有机器之间集中管理池,确保无缝的用户管理,并防止水平扩展时的竞争条件。
结论
这是我实现的 API 服务器,以及其他提到的方法:https://github.com/eotsevych/pw-object-pool.git
我在运行测试前启动这个服务器,您可以轻松将其复用于您的目的。
提示:如果您想引入新属性或修改对象结构,请不要忘记更新对象接口(我的是 User
)。
对于 /acquire
端点,我传递了用户的角色和 workerId
,以便跟踪哪个工作线程获取和释放了每个用户。