存储与执行之间代码会发生变化
对于对象任务,Queue::createObjectPayload() 会执行 serialize(clone $job),并将结果放入载荷的 data.command。即使启用了加密,在此之前也会进行同样的序列化。
worker 一侧的 CallQueuedHandler::getCommand() 会对已保存的数据执行 unserialize(),并将恢复后的任务交给 Dispatcher。通常的 handle() 会使用此时 worker 正在运行的代码和服务容器来执行。
如果没有重启长时间运行的 worker,更新后旧代码仍会继续运行。在滚动部署中,不仅要考虑”旧投递方与新 worker”的组合,还要考虑”新投递方与旧 worker”的组合。
判断类的重命名或删除时,不能只看用户是否直接调用了该类。等待中、延迟中、等待重试的任务以及失败任务的保留期间,也都属于兼容性的范围。
SerializesModels 保存的内容
Illuminate\Foundation\Queue\Queueable 在内部使用了 SerializesModels。它不仅负责替换模型,还提供列举任务自身属性的 __serialize() 和还原值的 __unserialize()。
与声明默认值相等的值不会被保存
__serialize() 会排除 static、未初始化等属性,并且当属性的当前值与声明默认值严格相等时也会省略保存。
'csv',保存的数组中就不会包含 format。如果之后的版本把声明默认值改为 'json',旧任务也可能会使用 'json'。这不是单纯外观上的默认值变更,而是旧数据读取方式的变更。
恢复时不会重新调用构造函数
__unserialize() 会为当前类中存在、且保存数组中也有对应键的属性赋值。它不会执行通过构造函数进行的输入补全或验证。
'csv' 是构造函数参数的默认值。它不是属性本身的声明默认值,因此在恢复不含 format 的旧载荷时不会起到补全作用。如果新增的类型化属性保持未初始化状态,之后访问时就会失败。
此外,public 属性的键就是名称本身,protected 属性的键带有表示可见性的前缀,private 属性的键还包含类名。将属性从 public 改为 private 的变更也要用旧载荷进行验证。
将标识符与执行服务分开
将执行所需的标识符和选项保存在任务中,而 API 客户端或 Exporter 等执行服务则注入到handle() 中。如果把服务传给构造函数并保留下来,其内部状态也可能成为保存对象。
下面的示例为旧版本中只持有 reportId 的同名任务添加了格式选择和载荷版本。ReportExporter 是包提供并绑定到容器的契约,假设它拥有 export(string $reportId, string $format): void。
- 旧载荷中没有
format时,使用声明默认值'csv'。 - 旧载荷中没有
payloadVersion时,按声明默认值1处理。 - 新建任务时,构造函数会把
payloadVersion设为2。由于与声明默认值不同,这个值会被保存。 - 新任务的
format为'json'时,该值也会被保存。为'csv'时会被省略,因此今后也要保持声明默认值'csv'。
传递模型时要与快照区分开
将模型直接保存在属性中时,SerializesAndRestoresModelIdentifiers 会将其替换为包含类、ID、连接名、已加载关联名等信息的 ModelIdentifier。恢复单个模型时,会通过 firstOrFail() 从数据库重新获取,并加载目标关联。
这并不是固定投递时属性或关联数据的机制。
- 如果记录在执行前被更新,恢复后引用的是更新后的内容。
- 事先应用于关联的筛选条件不会被保存,因此需要在执行时重新附加必要的条件。
- 不需要的关联可以通过
withoutRelations()或#[WithoutRelations]排除在保存对象之外。 - 如果模型已被删除,任务可能会在
handle()之前的恢复阶段失败。
#[DeleteWhenMissingModels]。不过,包不应擅自将丢弃设为默认行为,而应向用户公开失败方针。如果采用只传递 ID 并在 handle() 中查询的设计,则由自己控制该查询的失败或跳过。
如果需要投递时的内容,就明确保存所需的值,或者将不可变的报表请求等记录到数据库并传递其 ID。要区分单纯传递模型与保存业务数据快照这两件事。
提交后投递是另一个问题
处理在事务中创建的记录时,要确保 worker 不会在提交之前开始读取。afterCommit() 会等到打开的父事务提交后再投递,回滚时则丢弃该投递。它并不是解决类名、属性兼容性或外部处理重复执行的功能。
将 fake 与旧载荷的测试分开
通常的Queue::fake() 用于记录投递。仅仅 assertPushed() 通过,并不代表已经确认了存储、恢复,或新版本对旧载荷的处理。
当前类的往返可以这样验证。
serializeAndRestore() 会在 fake 内执行 serialize() 和 unserialize()。但它不会验证向真实后端的投递、worker 的执行以及旧版本代码的生成。
保留用旧版本生成的 fixture
在旧版本的GenerateReport 只持有 reportId 的状态下,生成不包含真实数据的固定任务。以下是在旧版本的测试环境中执行一次的准备代码。tests/fixtures/ 需要事先准备好。
TestCase 是在包的测试环境中准备的基类。传给 unserialize() 的只能是自己管理的 fixture,不要传入外部输入。
此外,还要用测试用的 Exporter 执行恢复后旧任务的 handle(),验证到输出和保存结果为止。传递模型时,要另外验证更新、删除、连接和关联的重新获取;在实际使用的队列连接上,还要另外验证提交后投递和重试。
在投递新格式之前先更新读取方
1
准备能够读取旧格式的版本
先发布保持旧类名和旧值解释、同时也能读取新格式的代码。用 fixture 不仅确认恢复,也确认处理结果。
2
确认没有残留的旧 worker
在使用方应用的部署流程中加入 worker 的替换。对于通常的
queue:work,使用 php artisan queue:restart 请求在处理完当前任务后退出,并确认进程管理器启动了新版本。重启信号会使用缓存。3
启用新格式的投递
在读取方完成支持之后,再通过功能开关等切换到新格式的投递。如果仅发布同一份代码就会开始生成新格式,就需要暂停投递,或将新旧队列与对应的 worker 分开等,确保不会交给旧 worker 的流程。
4
在重试和回滚期间也要持续维护
还要确认失败任务的重试以及在任务链中稍后投递的任务。如果在保存新格式后回退到旧代码,要验证旧代码也能读取。不要仅仅因为队列曾经清空过一次就删除对旧类或旧格式的支持。
queue:restart 并不是重写已保存载荷的命令。在包的更新说明中,要写明支持的格式、所需的 worker 替换以及废弃旧格式的条件。同样的变更也要记录在版本兼容性管理和 Changelog 的运用中。