artisansdk / cqrs
CQRS(命令查询责任分离)的基础包,兼容Laravel。
Requires
- php: >=7.0|>=8.0
- artisansdk/contract: dev-master
- artisansdk/model: 1.0.x-dev
- illuminate/container: ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0
- illuminate/database: ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0
- illuminate/queue: ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0
- illuminate/support: ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0
- psr/log: ^1.0 | ^2.0 | ^3.0
Requires (Dev)
- dms/phpunit-arraysubset-asserts: ^0.5.0
- illuminate/cache: ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0
- illuminate/pagination: ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0
- illuminate/validation: ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0
Suggests
- artisansdk/event: CQRS is a pattern used in event-driven applications. The artisansdk/event package is a general purpose event sourcing library for event stores.
- illuminate/cache: CQRS supports automatic caching and cache-busting of queries using Laravel's CacheManager class.
- illuminate/pagination: CQRS supports integration of LengthAwarePaginator by using Query::paginate() method.
- illuminate/validation: CQRS supports automatic creation of a Validator using rules arrays with argument validation.
This package is auto-updated.
Last update: 2024-09-17 18:40:15 UTC
README
CQRS(命令查询责任分离)的基础包,兼容Laravel。
目录
安装
该软件包像任何其他PHP软件包一样安装到PHP应用程序中
composer require artisansdk/cqrs
依赖项
此软件包依赖于Laravel软件包的一些辅助依赖。而不是依赖整个框架,如果需要使用依赖的功能,则由开发人员负责满足这些辅助依赖。虽然Laravel确实为这些依赖项提供了开箱即用的软件包,但如果您在Laravel之外安装,则可能需要配置应用程序以实现这些依赖项的接口。
以下说明如果您需要在Laravel之外使用相应功能,应额外安装哪些软件包
-
illuminate/container
:IoC容器必须由框架提供并注入到ArtisanSdk\CQRS\Dispatcher
中。Laravel将自动通过构造函数中的类型提示接口执行此操作,但如果您手动使用Dispatcher::make()
或依赖于Command::make()
或类似的静态函数,则技术上将直接依赖于Illuminate\Container\Container
。 -
illuminate/bus
:依赖于Laravel中的命令总线,以执行链式队列作业。虽然这不是必需的,但如果您打算进行复杂的队列操作,则需要此辅助依赖项以进行实际的作业调度。另请参阅框架辅助函数。 -
illuminate/events
:使用ArtisanSdk\CQRS\Buses\Transaction
命令包装器需要此软件包,该软件包随Laravel一起提供。本质上,依赖项依赖于框架将事件调度到框架层并将它们返回到CQRS软件包级别的功能。 -
illuminate/database
:使用ArtisanSdk\CQRS\Buses\Transaction
或ArtisanSdk\CQRS\Query
类需要此数据库软件包以提供数据库事务和查询语句。 -
illuminate/pagination
:使用ArtisanSdk\CQRS\Query::paginate()
方法需要此软件包以返回分页结果。 -
illuminate/queue
:使用Laravel框架的任何队列功能都需要此软件包。这包括对模型进行序列化以用于作业和事件,或从作业和命令与队列交互。 -
illuminate/validation
:您只需安装此依赖项,如果您希望查询和命令的参数自动通过一组验证规则或自定义传递的验证器进行验证。CQRS包为此Laravel验证包提供支持,但这不是必需的。
框架辅助函数
Laravel包括几个helpers.php
文件,这些文件公开了全局函数,任何框架都可以实现这些函数。这进一步将此包与Laravel解耦。因此,如果此包在Laravel之外使用,您将需要实现这些辅助函数(就像此包在测试目的那样)。
-
app()
用于解析依赖项以进行静态调用,例如Command::make()
,它可以从IoC容器中自动解析命令。当传递一个引用容器中绑定类名的字符串时,该函数应返回该类的构建实例。 -
dispatch()
主要用于通过ArtisanSdk\CQRS\Concerns\Queueable
特质辅助函数dispatchNextJobInChain()
的链式、排队命令。函数应接受一个类并将其传递给框架的命令总线。对于基于Laravel的应用程序,可以通过安装illuminate\bus
来满足此要求,它提供了Illuminate\Bus\Dispatcher
作为命令总线。
使用指南
命令
命令实现了ArtisanSdk\Contract\Runnable
接口,这使得它既是可调用的又是可执行的。命令的预期用途是执行某种类型的“写”操作或完成一项工作单元,并返回其结果。异步命令将返回一个承诺,而同步命令将返回结果本身或什么都没有。
如何创建命令
使用命令的基本示例是创建一个扩展ArtisanSdk\CQRS\Command
类的类并实现run()
方法,在命令运行后返回您想要的任何值。您可以使用构造函数方法注入任何命令依赖项。参数依赖项是隐式必需的,调用者必须满足这些要求,否则开发人员必须抛出异常以确保在执行关键命令逻辑之前所有必需的参数都已传递并进行了验证。
namespace App\Commands; use App\User; use ArtisanSdk\CQRS\Command; class SaveUser extends Command { protected $model; public function __construct(User $model) { $this->model = $model; } public function run() { $user = $this->model; $user->email = $this->argument('email'); $user->save(); return $user; } }
如何运行命令
有几种方法可以分发命令。第一种方法是简单地创建一个ArtisanSdk\CQRS\Dispatcher
实例,然后在其上调用command()
,这将返回一个包裹在参数构建类中的新命令实例。然后,您可以在调用run()
或直接调用构建器之前将任何任意参数链式到命令上。您还可以在构建器上调用arguments()
,传递一个参数数组。
使用分发器运行命令
$user = ArtisanSdk\CQRS\Dispatcher::make() ->command(App\Commands\SaveUser::class) ->email('johndoe@example.com') ->run();
静态运行命令
或者,您可以简单地使命令静态化,这将创建一个命令构建器实例。
$user = App\Commands\SaveUser::make() ->email('johndoe@example.com') ->run();
从任何地方运行命令
使用任何类(例如:控制器)上的ArtisanSdk\CQRS\Concerns\CQRS
辅助特质可以允许您通过简单地调用$this->dispatch()
或$this->command()
并将命令的类名作为参数传递来直接分发命令。这将返回一个命令构建器实例。基本的ArtisanSdk\CQRS\Command
使用了这个特质,因此子命令可以在同一个命令中执行。
namespace App\Http\Controllers; use App\Commands\SaveUser; use App\Http\Controllers\Controller; use ArtisanSdk\CQRS\Concerns\CQRS; use Illuminate\Http\Request; class UserController extends Controller { use CQRS; public function post(Request $request) { return $this->command(SaveUser::class) ->email($request->input('email')) ->run(); } }
手动运行命令(不使用命令总线)
上述示例中的命令都最终通过分发器路由命令,该分发器实现了一个基本的命令总线,用于支持许多基于命令的应用程序需要的几个支持场景,包括事件、排队和事务。虽然您可以并且应该始终分发命令,但您也可以通过简单地使用容器自动解析或手动构建它来手动执行命令,然后调用命令上的run()
方法或直接调用类。
$user = (new App\Commands\SaveUser(new App\User)) ->arguments([ 'email' => 'johndoe@example.com', ]) ->run();
这将绕过分发器设置的命令总线,因此将跳过分发器提供的任何附加包装功能。
如何创建事件命令
有时,您希望代码的其他部分能够知道特定命令的处理过程。您可能希望在命令执行之前或之后根据命令的结果执行某些代码。通过使用分发器,您只需在应事件化的任何命令上实现 ArtisanSdk\Contract\Eventable
接口,就可以轻松地完成此操作
namespace App\Commands; use App\User; use ArtisanSdk\Contract\Eventable; use ArtisanSdk\CQRS\Command; class SaveUser extends Command implements Eventable { protected $model; public function __construct(User $model) { $this->model = $model; } public function run() { $user = $this->model; $user->email = $this->argument('email'); $user->save(); return $user; } }
在实现事件化合约后,会在命令运行之前和之后触发事件。在事件之前,将给出传递给命令的参数,而在事件之后,将给出命令本身的结果。触发的事件是 ArtisanSdk\CQRS\Events\Event
的一个实例。
静音事件化命令
虽然在实际执行命令之前和之后触发事件很有用,但有时您希望在不触发监听器的情况下运行事件化命令。事件化命令在命令和命令构建器上都有辅助方法,以便更容易地使用此用例。您可以通过调用 silence()
来静音命令,通过调用 silenced()
来检查命令是否被静音,通过调用 silently()
来静音运行。
$user = App\Commands\SaveUser::make() ->email('johndoe@example.com') ->silently();
如何在事务中运行命令
通常,您会创建一个执行多个数据库写入到不同表或多个记录的命令。或者,您可能有一个执行多个子命令的命令,并且需要对命令的整体执行有一定程度的原子性。如果子命令或二级写入失败,您将想要回滚命令。这种样板逻辑在需要将其写入每个命令中是令人烦恼的,因此这个包通过在应事务化的任何命令上实现 ArtisanSdk\Contract\Buses\Transactional
接口,提供了一个简单的方法来完成此操作
namespace App\Commands; use ArtisanSdk\Contract\Buses\Transactional; use ArtisanSdk\CQRS\Command; class SaveUser extends Command implements Transactional { protected $model; public function __construct(User $model) { $this->model = $model; } public function run() { $user = $this->model; $user->email = $this->argument('email'); $user->save(); return $user; } }
现在,如果命令因任何原因抛出异常,命令或子命令中执行的查询将回滚。如果一切按预期进行,则像往常一样提交事务。这种方法的好处是,它可以通过手动调用命令来轻松绕过事务模型进行测试,从而绕过事务包装器。
终止事务化命令
有时,您想回滚事务而不抛出异常,同时仍然返回满足调用者响应期望的结果。在这种情况下,命令应调用 abort()
然后返回结果。事务包装器仍然会回滚,但不会冒泡任何异常。
namespace App\Commands; use App\User; use ArtisanSdk\Contract\Buses\Transactional; use ArtisanSdk\CQRS\Command; class ChangePassword extends Command implements Transactional { protected $model; public function __construct(User $model) { $this->model = $model; } public function run() { if( ! $user = $this->user($email) ) { $this->abort(); return false; } $user->password = $this->argument('password'); $user->save(); return $user; } protected function user() : User { return $this->model ->where('email', $this->argument('email')) ->first(); } }
上面的示例更改了匹配电子邮件地址的用户密码。如果电子邮件地址不匹配任何已知用户,而不是抛出异常,我们只是回滚并返回 false
。如果我们执行了任何其他写入查询,那么那些查询将被回滚。
使用 Abort 静音 After 事件
然而,终止命令的主要好处是,如果命令被终止,则不会触发 after 事件。这在命令实际上作为作业排队时很有用,并且作业已经被处理或不再需要,因此不应抛出异常并冒被标记为失败的作业的风险,而可以简单地终止并仍然被工作器视为成功的作业。例如,想象一下,一个电子邮件被排队在15分钟后发送,但在那15分钟内发生了某种操作,使得这样的电子邮件变得不相关或冗余:那么当命令作为排队的作业执行以发送电子邮件时,可以进行预检查以确定是否应该运行该命令,如果不应该运行,则可以终止该命令。
检查命令是否被终止
abort()
和 aborted()
方法是命令的公共方法,也可以用于您可能想要根据命令池中的一个命令何时被终止来终止多个命令的情况。您还可以使用 aborted()
方法来检查命令是否被终止,以更好地确定如何处理命令的结果。
如何将命令用作事件处理器
当应用程序触发事件时,事件订阅者可以将事件广播给所有已绑定的事件监听器。每个监听器提供一个 handle()
方法,该方法接收事件作为参数并执行一些任意逻辑。此处理程序本质上与命令相同,因此命令可以用作命令处理程序。handle()
方法的默认行为是从事件对象中提取 payload
属性,并将其作为参数传递给命令构建器,然后通过调用命令的 run()
方法来自执行。
首先,您需要创建一个应触发的事件。这些事件需要扩展 ArtisanSdk\CQRS\Events\Event
,它提供了将传递给命令的参数负载。在我们的示例事件中,我们接受一个类型提示的 App\User
模型作为构造函数的唯一参数,以确保事件是以正确的负载类型创建的。然后,我们将此模型分配给传递给父构造函数的数组中的 user
键。父构造函数将正确地将此参数分配给负载属性。
namespace App\Events; use App\User; use ArtisanSdk\CQRS\Events\Event; class UserSaved extends Event { protected $user; public function __construct(User $user) { $this->user = $user; } }
接下来,我们需要创建一个命令,当它运行完成后将触发此事件。对于非传统的事件名称,您需要在命令的 beforeEvent()
和 afterEvent()
方法中向调度器提供自定义事件名称。在我们的情况下,我们只需返回一个字符串,作为调度器构建并传递给事件构造函数的 App\User
类名。这将把 run()
返回的 App\User
传递给事件的构造函数。
namespace App\Commands; use App\Events\UserSaved; use ArtisanSdk\Contract\Eventable; use ArtisanSdk\CQRS\Command; class SaveUser extends Command implements Eventable { protected $model; public function __construct(User $model) { $this->model = $model; } public function run() { $user = $this->model; $user->email = $this->argument('email'); $user->save(); return $user; } public function afterEvent() { return UserSaved::class; } }
虽然调度器自动处理所有间接引用,但可以总结为手动构建和调用以下内容。
$command = (new App\Commands\SaveUser(new App\User())); $builder = new ArtisanSdk\CQRS\Builder($command); $user = $builder->email('johndoe@example.com')->run(); $event = new App\Events\UserSaved($user);
接下来,我们需要创建另一个命令,并将其绑定为任何触发 App\Events\UserSaved
事件的监听器。
namespace App\Commands; use ArtisanSdk\CQRS\Command; class SendUserWelcomeEmail extends Command { public function run() { $user = $this->argument('user'); // ... the $user is an instance of `App\User` and can be used in a Mailable } }
发送电子邮件的实际逻辑已被省略,但如您所见,可以通过将监听器连接起来从将自动传递给命令的参数中获取 App\User
模型。这可以通过简单地连接监听器来实现。建议您按照 Laravel 的文档在 App\Providers\EventServiceProvider
类中使用 $listen
属性连接监听器,但以下示例展示了如何手动将事件处理程序作为事件监听器订阅。
event()->listen(App\Events\UserSaved::class, App\Commands\SendUserWelcomeEmail::class);
现在每当触发 App\Events\UserSaved
事件时,App\Commands\SendUserWelcomeEmail
命令的 handle()
方法将使用事件作为参数被调用。这反过来会解包事件,并将事件的有效负载作为参数传递给命令,然后自执行。触发事件相当于手动调用。
$command = (new App\Commands\SaveUser(new App\User())); $builder = new ArtisanSdk\CQRS\Builder($command); $user = $builder->email('johndoe@example.com')->run(); $event = new App\Events\UserSaved($user); $handler = (new App\Commands\SendUserWelcomeEmail()); $result = $handler->handle($event);
如何将命令排队为作业
在上面的示例中,我们发送电子邮件,这通常被认为是一个不关键于响应成功的后台进程。通常在这种情况下会使用队列作业。但如果您这么考虑,那么作业实际上只是事件的定义和它的事件处理程序,它是为了稍后执行而不是立即执行而排队的。由于命令可以是这样自我执行的事件处理程序,因此处理程序也可以作为作业排队。这个包通过简单地实现 ArtisanSdk\Contract\CQRS\Queueable
接口并在您希望排队的命令上添加 ArtisanSdk\CQRS\Concerns\Queues
特性,使排队处理变得简单。
namespace App\Commands; use ArtisanSdk\CQRS\Command; use ArtisanSdk\CQRS\Triats\Queue; use ArtisanSdk\Contract\CQRS\Queuable; class SendUserWelcomeEmail extends Command implements Queueable { use Queues; // ... same as before but it'll now be queued }
现在每当触发 App\Events\UserSaved
事件时,App\Commands\SendUserWelcomeEmail
命令将被排队,然后由队列工作者执行。现在命令支持所有相同的方法和属性,如 $connection
、$queue
和 $delay
,因此您可以使用定义的默认值配置您的命令,或者让调用者通过 onConnection()
、onQueue()
等。来决定。
如何将命令作为作业在队列上运行
虽然事件处理器排队更为常见,因为事件本质上是非同步的,但一些命令也适合后台处理。这些命令并不是运行而是排队。因此,这个包使得显式排队实现 ArtisanSdk\Contract\CQRS\Queueable
的命令变得非常简单。
$job = App\Commands\SendUserWelcomeEmail::make() ->email('johndoe@example.com') ->queue();
您不必在命令上调用 run()
,只需调用 queue()
。这个方法的神奇之处在于,ArtisanSdk\CQRS\Builder
类将排队命令包装起来,将参数传递给一个通用的 ArtisanSdk\Contract\Event\Event
实现。这个事件是真实底层 ArtisanSdk\Contract\CQRS\Queueable::queue($event)
方法的填充。
虽然调度器自动处理所有间接引用,但可以总结为手动构建和调用以下内容。
$job = (new App\Commands\SendUserWelcomeEmail()) ->queue(new ArtisanSdk\CQRS\Events\Event([ 'email' => 'johndoe@example.com', ]));
返回待处理作业,当对象被销毁时,框架调度器会将它推送到队列。通过访问作业,可以在调度之前进一步定制作业,包括调用熟悉的方法,如 onConnection
、onQueue
、delay
和 chain
。
如何使命令无效化查询
文档正在编写中。请谅解混乱,并考虑提交一个拉取请求以改进文档。
查询
查询实现了 ArtisanSdk\Contract\Query
接口,这使得它既可调用又可运行,因此与命令不可区分。查询的预期用途是执行某种“读取”操作或从数据存储中获取结果。异步查询将返回一个承诺,而同步命令将阻塞程序执行,直到返回结果。
如何创建查询
使用查询的基本示例是创建一个扩展 ArtisanSdk\CQRS\Query
类的类。这个抽象类将 __invoke()
前传到 run()
。该类还包括一个简写的 get()
方法,该方法将 run()
前传,使其感觉更接近于使用 DB::table()->get()
或 Eloquent::query()->get()
方法。通过实现返回要由 get()
方法执行的查询构建器的 builder()
方法。因此,仅通过实现返回查询结果的 run()
方法,就可以利用查询总线。
您可以使用构造函数方法注入任何查询依赖项,例如 Eloquent 模型、服务类等。参数依赖项隐式要求调用者满足这些要求,否则开发者必须抛出异常以确保在执行关键查询逻辑之前传递并验证所有必需的参数。
重要:虽然使用 Eloquent ORM 可能会清理或转义参数,但此包不会对传递到查询类的参数是否安全做出假设。在执行数据后端之前,请确保验证和清理值。
抽象的 ArtisanSdk\CQRS\Query
类实际上假定您正在使用 Laravel 的数据库 ORM 和查询构建器。因此,run()
方法调用一个抽象的 builder()
来获取 SQL 构建器。如果您使用的是,例如,RESTful API 作为查询后端,则需要实现此 builder()
方法或将其存根。
平面文件实现
假设您有一个包含 PHP 数组的州缩写和名称的 resources/lang/en/states.php
文件,那么以下查询就是所需的最小实现。注意,builder()
方法被存根以满足父类的抽象定义。此外,注意我们不需要使用数据库,因为结果可以从系统磁盘上的平面文件中加载。
namespace App\Queries; use ArtisanSdk\CQRS\Query; class GetStates extends Query { public function builder() { // required to satisfy abstract parent } public function run() { return trans('states'); } }
以下是调用此查询获取州的方法
$states = App\Queries\GetStates::make()->get();
HTTP API 实现
同样,您可以使用 HTTP API 作为数据后端,并使用 HTTP 客户端(如 Guzzle)获取结果
namespace App\Queries; use ArtisanSdk\CQRS\Query; use GuzzleHttp\Client as Guzzle; class GeocodeIP extends Query { protected $http; public function __construct(Guzzle $http) { $this->http = $http; } public function builder() { // required to satisfy abstract parent } public function run() { // Require the argument and validate as an IPv4 address $ip = $this->argument('ip', ['ipv4']); // Generate a URL to injected with the IP address $url = sprintf('https://freegeoip.app/json/%s', $ip); // Use Guzzle to get the geocoded response $response = $this->http->get($url); // Parse the JSON body of the response return json_decode($response->getBody()->getContent()); } }
在这个示例中,我们使用动态查询参数来构建HTTP请求,当我们调用get()
来执行请求时。记住,get()
被转发到我们的自定义run()
实现,所以一切正常工作。关于如何流畅地构建命令参数的所有知识也适用于查询。
$result = App\Queries\GeocodeIP::make() ->ip('104.131.182.33') ->get(); echo $result->zip_code; // 07014
数据库实现
回到那个builder()
方法。如前所述,该包假设您将使用Eloquent ORM或至少使用数据库抽象层,因此builder()
方法旨在用于返回一个查询构建器。因此,一个支持查询模型的实现可能如下所示
namespace App\Queries; use App\User; use ArtisanSdk\CQRS\Query; class ListUsers extends Query { protected $model; public function __construct(User $model) { $this->model = $model; } public function builder() { $query = $this->model->query(); $order = $this->option('order', 'id', ['in:id,name,email,created_at,updated_at']); $sort = $this->option('sort', 'desc', ['in:asc,desc']); $query->orderBy($order, $sort); if( $keyword = $this->option('keyword', null, ['string', 'max:64']) ) { $this->scopeKeyword($query, $keyword); } return $query; } // This method could be called anything, but naming it similar to Eloquent // helps clarify the intent of such builder abstractions to protected methods. protected scopeKeyword($query, string $keyword) { $wildcard = sprintf('%%s%', $keyword); return $query->where(function($query) use ($wildcard) { return $query ->orWhere('name', 'LIKE', $wildcard) ->orWhere('email', 'LIKE', $wildcard); }); } }
我们不需要定义run()
方法,因为父类会自动执行所需的$this->builder()->get()
调用,以便在运行时返回查询的结果。向查询传递参数可以让你在调用时自定义结果。
// Get the users with default arguments: sort desc by name $users = App\Queries\ListUsers::make()->get(); // Get the users using custom arguments which are validated in the builder $users = App\Queries\ListUsers::make() ->order('name') ->sort('asc') ->keyword('john') ->get();
如何获取查询结果
基本查询实现了get()
,还实现了方便的paginate()
方法。
// Get the ?page=# results of users with only the name and email columns $paginator = App\Queries\ListUsers::make()->paginate(10, ['name', 'email']);
此外,如果您需要检查查询,可以调用toSql()
而不是直接调用get()
或builder()
来进一步自定义查询,以用于一次性查询执行。
// select * from `users` order by `name` desc $sql = App\Queries\ListUsers::make() ->order('name') ->toSql(); // Bypass the run() method and execute against the builder directly $users = App\Queries\ListUsers::make() ->order('name') ->builder() ->limit(10) ->get(); // Customize the builder outside of the query $query = App\Queries\ListUsers::make(); $query->order('name'); $builder = $query->builder(); // get the builder outside of the query $builder->whereIn('id', [1, 2, 3]); // a customization to the query $users = $query->get(); // since $builder is referenced, query executes against customized builder
创建基类以帮助执行涉及单个结果的常用查询是一种常见做法,包括扩展接口以包括first()
或firstOrFail()
等查询执行方法。
namespace App\Queries; use App\User; use ArtisanSdk\CQRS\Query; class FindUserByEmail extends Query { protected $model; public function __construct(User $model) { $this->model = $model; } public function builder() { return $this->model->query() ->where('email', $this->argument('email', ['email'])); } public function run() { return $this->builder()->first(); } public function firstOrFail() { return $this->builder()->firstOrFail(); } public static function find(string $email) { return static::make()->email($email)->run(); } public static function findOrFail(string $email) { return static::make()->email($email)->firstOrFail(); } }
运行此查询的方法有很多,包括
$user = ArtisanSdk\CQRS\Dispatcher::make() ->query(App\Queries\FindUserByEmail::class) ->email('johndoe@example.com') ->run(); // or get() $user = App\Queries\FindUserByEmail::make() ->email('johndoe@example.com') ->get(); // Throw Illuminate\Database\Eloquent\ModelNotFoundException if not found $user = App\Queries\FindUserByEmail::make() ->email('johndoe@example.com') ->firstOrFail(); // Returns null if not found $user = App\Queries\FindUserByEmail::find('johndoe@example.com'); // Throw Illuminate\Database\Eloquent\ModelNotFoundException if not found $user = App\Queries\FindUserByEmail::findOrFail('johndoe@example.com');
您还可以在控制器或任何包含ArtisanSdk\CQRS\Concerns\CQRS
特质的任何服务中执行查询
namespace App\Http\Controllers; use App\Commands\FindUserByEmail; use App\Http\Controllers\Controller; use ArtisanSdk\CQRS\Concerns\CQRS; use Illuminate\Http\Request; class UserController extends Controller { use CQRS; public function show(Request $request, string $email) { return $this->query(FindUserByEmail::class) ->email($email) ->firstOrFail(); } }
如何创建事件查询
有时您想让代码的其余部分意识到已执行特定查询。您可能想在查询之前或之后根据查询的结果执行一些代码。通过使用分发器,您只需在应该进行事件化的任何查询上实现ArtisanSdk\Contract\Eventable
接口即可轻松完成此操作
namespace App\Queries; use App\Post; use ArtisanSdk\Contract\Eventable; use ArtisanSdk\CQRS\Query; class MostPopularPosts extends Query implements Eventable { protected $model; public function __construct(Post $model) { $this->model = $model; } public function builder() { return $this->query() ->orderBy('views', 'desc') ->take($this->option('limit', 10, 'is_integer')); } }
在添加了可事件化合同实现后,将在命令运行之前和之后触发事件。在事件之前,将给出传递给查询的参数,而在事件之后,将给出查询本身的结果。触发的事件是ArtisanSdk\CQRS\Events\Event
的实例。
以上查询的一个用例示例是,可以使用事件处理程序通知帖子作者他们的帖子现在正在网站上展示。或者,可以在执行之前启动仪器,并在事件之后捕获作为查询执行所花费时间的延时。
有关可事件化命令的所有其他事件总线行为也适用于命令。有关详细信息,请参阅有关可事件化命令的文档。
如何创建缓存查询
也许您想缓存查询的结果,因为相同的查询参数不会经常改变。通过简单地实现ArtisanSdk\Contract\Cacheable
并设置查询类上的公共属性$ttl
(以秒为单位),这个包使这变得非常容易。查询总线将处理所有缓存键创建和缓存清除,使用Laravel的默认缓存驱动程序。
namespace App\Queries; use App\Post; use ArtisanSdk\Contract\Cacheable; use ArtisanSdk\CQRS\Query; class MostPopularPosts extends Query implements Cacheable { public $ttl = 60 * 60 * 24 * 7; // 1 week cache // ... same logic as above }
您还可以在查询构建器上动态调用->ttl($seconds)
来自定义查询结果的TTL。您可以自定义public $key
属性以设置查询的自定义键,但默认情况下,值将基于查询本身生成哈希。这使得具有独特查询的缓存可以在单独自动生成的键下缓存。
如何清除缓存查询
虽然缓存很棒,但有时您需要绕过缓存或清除缓存。
// Get the results and cache them for future query execution $posts = MostPopularPosts::make()->get(); // Secondary calls return the cached results $cached = MostPopularPosts::make()->get(); // Bust the cache then get the results $busted = MostPopularPosts::make()->busted()->get(); // This is shorthand for cache busted results $busted = MostPopularPosts::make()->fresh();
请参阅ArtisanSdk\CQRS\Buses\Cached
类,以获取更多公开方法,可用于自定义查询的缓存机制,包括绕过缓存、设置自定义键、使用基于标签的缓存以及使用不同的缓存驱动。缓存总线可能是使用Eloquent模型时使用查询总线最有说服力的理由,因为虽然Eloquent模型是Active Record实现,具有许多查询构建器功能,但它们默认不处理领域参数验证和缓存,且操作简便。
事件
事件自动解决是如何工作的
事件命名遵循将命令名的现在祈使动词时态变为事件之前进行时态、事件之后变为过去时态的规则。这由Evented
包装器处理,具体由resolveProgressiveTense()
和resolvePastTense()
方法处理。使用动作动词常见结尾的正则表达式映射,可以相当可靠地将命令名转换为动词。例如,“create”变为“creating”和“created”。如果找不到相应的变形来映射,则解析器将默认使用“executing”和“executed”作为通用事件名称。
需要帮助:如果您遇到可以改进的变形案例,请查看
Evented::$progressiveMap
和Evented::$pastMap
,并提出一个包含针对您用例的推荐更改的问题或拉取请求。
自动解析逻辑并不完美,因此您有时仍需要根据需要自定义事件名称,此包提供了该功能。
如何自定义前后事件
有时您可能会使用一个非传统的命令名,或者由于英语语言的古怪性而难以变形事件名。在这些情况下(以及所有需要明确性的情况下),您可以在一个Eventable
命令中添加beforeEvent
和afterEvent
方法。以下说明了如何为自定义事件命名约定自定义事件之前和之后的事件。
namespace App\Commands; use App\Events\NewPasswordSet; use App\Events\ChangingPassword; use ArtisanSdk\Contract\Eventable; use ArtisanSdk\CQRS\Command; class ChangePassword extends Command implements Eventable { public function beforeEvent(array $arguments) { return ChangingPassword::class; } public function run() { $user = $this->argument('user'); $user->password = $this->argument('password'); return $this->save($user); } public function afterEvent($result) { return NewPasswordSet::class; } }
修改事件所需要做的就是返回一个字符串形式的类名。或者,如果您想自己构建事件或者需要根据命令执行的結果进行事件切换,则可以检查传递给事件之前的$arguments
或传递给事件之后的$result
,然后简单地返回一个事件对象。如果您不自己构建事件对象,则惯例是将在构造函数中将参数和结果注入到引用的事件类中。
这再次说明了可以自定义事件。在实践中,建议遵循更简单的命名约定,使得命令将是App\Commands\Password\Change
,而事件将是App\Events\Password\Changing
和App\Events\Password\Changed
。
命令和事件命名的推荐约定
虽然您可以使用beforeEvent()
和afterEvent()
方法来定制任何事件的分派以符合您选择的命名空间或命名约定,但通常遵循合理的约定并让自动解析自行处理会更简单。以下是一个推荐约定,用于使用命名空间区分命令和事件之前和之后的事件的命名。
- 命令应该是一个单词的动作动词,用现在祈使动词时态书写。
- 查询可以像命令一样措辞,或作为定义结果集的名词。
- 事件应该是命令名的进行时态(事件之前)和过去时态(事件之后)变形。
- 当多个类具有相同的名称时,应使用命名空间来保证唯一性。
以下将进一步解释这些基本规则。
命令应使用现在祈使句的形式,只包含动作。命令应仅表示动作,而命名空间应组织动作的逻辑使用。例如,为注册用户的命令命名 RegisterUser
,可以考虑简单地命名为 Register
。命令的参数是一个名称和电子邮件,而不是用户模型,因此如果需要,可以命名为 RegisterWithNameAndEmail
,返回的值将是用户模型。
现在为了区分这个注册命令和注册团队或其他领域模型,考虑使用命名空间。你可以使用 App\Commands\User
作为命名空间,这将产生 App\Commands\User\Register
,这是在 App\Commands
命名空间下对相关类的逻辑分组。或者,你可以使用面向服务的类分组,在 App\User\Commands\Register
下创建一个服务边界,在 App\User
命名空间下。
现在查询得到一些东西,因此它也是一个命令,但可以用更具体的同义词来表示抽象的 Query
和通用的“获取”,例如 App\Queries\User\Find
或 App\Queries\User\Search
。毕竟,你是在寻找一个用户或搜索用户集合,并获取查询的结果。你也可以在服务边界下组织,如 App\User\Queries\Find
和 App\User\Queries\Search
。
你还可以根据结果来命名你的查询,使其更加流畅。例如,你可能想要获取最近注册的用户作为标准查询。这可以是 App\Queries\User\RecentlyRegistered
或 App\User\Queries\RecentlyRegistered
。你可能决定将列参数化,使其仅为 Recent
,这样它就可以用于最近注册、最近更新等。遵循此约定,你可以考虑将查询视为 GetRecentlyRegistered
,并从查询中删除前缀 Get
,因为流畅的代码将使用语法中的 get()
: $this->query(RecentlyRegistered::class)->get()
。
如果命令或查询是事件驱动的,则事件将自动解决,除非使用 beforeEvent()
和 afterEvent()
方法进行自定义。为了帮助自动解决事件,首先确保命令是一个单动作词,如 Create
、Register
、Modify
等。再次确保它是现在时态。由于“before”事件表明即将发生某事,或在异步系统中正在发生,因此将现在时态命令名称转换为进行时态事件名称是有意义的。因此,Register
命令在开始时触发 Registering
事件。
此外,当命令完成时,动作完成,因此 after 事件应表示发生了什么。after 事件将现在时态命令名称转换为过去时态事件名称。因此,Register
命令在完成时触发 Registered
事件。同样适用于像 Modify
这样的奇怪命令,它转换为 Modifying
和 Modified
的 before 和 after 事件。自动解决逻辑工作得相当好,但并不总是正确地得到事件名称,因此请始终在开发期间记录事件,以验证正在触发什么,以及你的命令是否正在触发正确的事件。
有时你可能会遇到一些不规则的命令名称,例如 App\Commands\User\SetStatus
。虽然你可以尝试将其重命名为 App\Commands\User\Status\Set
以符合命名空间规范,但这往往会导致代码库中不必要的、人为的扩展。此外,自动解析可能会错误地使用 Setted
来表示过去式。像 SetStatus
这样的命令仍然使用现在时态,因此自然进行时的事件名称应该是 SettingStatus
,过去式事件名称应该是 StatusSet
。正如你所见,之前的事件将动词放在名词之前,而之后的事件则将其放在名词之后。英语的奇怪之处在于“set”的过去式和现在式动词形式相同,这是自动解析无法处理的。因此,如果你需要,可以使用命令上的 beforeEvent()
和 afterEvent()
方法自行定义这些事件。
因此,一个过去的事件可以成为另一个命令的参数,从而使领域逻辑可以通过“当 [过去事件] 时 [现在命令]”规则进行编码。例如,“当用户注册时发送激活邮件”。这有助于理解,尽管事件已经触发(例如:用户已注册),但这并不意味着必须立即执行事件的处理程序(例如:发送激活邮件)。这个延迟或延迟(技术上排队)的命令仍然将过去事件的有效负载作为当前参数用于其自己的延迟执行。因此,可以完全忽略命令的返回值,并构建一个基于事件系统的异步程序执行。
你也可能更喜欢将实际根或聚合模型简单地命名为 Model
,然后需要进一步分离命名空间以指示它是哪个模型。例如,而不是 App\User
,你可能组织为 App\Models\User
或 App\User\Models\User
。但是,当你有很多模型时,很难看到服务边界在哪里,即在 User
模型聚合和所有相关模型之间。因此,最好是组织为 App\Models\User\User
并删除 User
的冗余,简化为 App\Models\User\Model
。在 App\User\Models
的情况下,聚合和所有相关模型都组织在 App\User\Models
服务边界下,因此将 App\User\Models\User
重命名为 App\User\Models\Model
的唯一原因是为了突出该模型是 App\User
服务边界的根聚合。
使用将类逻辑分组到命名空间中的方法(这是常见的Web应用程序架构约定)将如下所示
App
├─ Commands
└─ User
└─ Register
├─ Events
└─ User
├─ Registered
└─ Registering
├─ Models
└─ User
└─ Model
└─ Queries
└─ User
├─ Find
└─ Search
或者,围绕服务边界将类分组到命名空间中的方法(这是面向服务的架构约定)将如下所示
App
└─ User
├─ Commands
└─ Register
├─ Events
├─ Registered
└─ Registering
├─ Models
└─ Model
└─ Queries
├─ Find
└─ Search
此包不关心你如何组织事物,但你可能会发现按照服务边界组织事物将有助于减少命名和组织决策,并为以后的服务打包提供清晰的分离。
关注点
该包的主要功能通过一组基类公开,但这些类是由一组基特性组成的。你可以在你的应用程序代码中使用这些特性,即使不需要完全的CQRS,这些特性也证明了它们是应用程序的有用和一致的API。
在您的类中使用CQRS
ArtisanSdk\CQRS\Concerns\Arguments
是一个特性,它为类提供参数和选项,包括所有相关的验证逻辑和默认解析器。该特性的公共方法有
Arguments::arguments($arguments)
获取或设置参数Arguments::argument($name, $validator)
获取一个参数并验证它Arguments::option($name, $default, $validator)
获取一个可选参数并提供一个默认值Arguments::hasOption($name)
检查可选参数是否存在
ArtisanSdk\CQRS\Concerns\CQRS
是提供 CQRS 模式主要交互 API 的特质。通常在控制器、控制台命令或其他类中包含此特质,以便直接使用命令构建器和分发器发送命令。该特质的可用方法(大多数是受保护的)有:
CQRS::dispatcher()
获取Dispatcher
的实例。实例不是单例,所以每个发送的命令都会通过一个唯一的分发器(命令总线)运行。通常用于$this->dispatcher()->dispatch($class)->run()
来组合可运行的类然后运行它。它也可以用来动态转发事件,如$this->dispatcher()->creating($user)
,这将触发一个带有用户作为参数的Creating
事件。CQRS::call($class, $arguments)
直接组合并运行带有传入参数的类。CQRS::command($class)
用于使用分发器组合命令但不运行它(请使用call()
代替)。CQRS::query($class)
用于使用分发器组合查询但不运行它(请使用call()
代替)。CQRS::event($event, $payload)
用于组合带有有效负载的后续事件并使用分发器触发它。CQRS::until($event, $payload)
用于组合带有有效负载的前置事件并使用分发器触发它。
ArtisanSdk\CQRS\Concerns\Handle
是一个特质,命令可以使用它来实现 ArtisanSdk\Contract\CQRS\Handler
接口,这样事件对象就可以传递到命令的 handle()
方法,并且命令将通过命令分发器使用事件属性作为参数来运行。此外,如果命令是可排队的,则命令的执行将被延迟作为排队的作业。当作业从队列中解决出来时,命令将被直接调用,绕过处理器但仍然使用事件属性作为参数。
ArtisanSdk\CQRS\Concerns\Queues
是一个包装特质,用于使事件或命令的行为类似于排队作业。它还允许命令以排队作业的方式与命令交互。此特质预期用于将其使用的类作为可排队的作业。有关如何自定义属性(如 $connection
、$queue
和 $delay
)或执行命令的链式操作作为排队作业的说明,请参阅 Laravel 的文档。
ArtisanSdk\CQRS\Concerns\Save
是一个特质,它有助于保存 Eloquent 模型,特别是像 artisansdk\model
提供的自验证模型。它仅提供了一个 save($model)
公共方法,确保模型被保存或抛出异常,如果保存则返回保存的模型。请参阅有关在命令中保存模型的说明。
ArtisanSdk\CQRS\Concerns\Silencer
是一个特质,它可以阻止在运行命令或查询时触发事件。该特质的公共方法有:
Silencer::silence()
:将静音标志设置在命令上,以便不触发事件。Silence::silenced()
:一个布尔检查,用于查看命令是否被静音。这被事件命令包装器用来确定是否应该触发事件。Silence::silently()
:这是一个简写方法,用于$command->silence()->run()
,这样您只需使用$command->silently()
就可以静默运行一个命令。
使用参数验证器
需要参数的命令和查询通常有很多样板代码来验证参数值。为了抽象化这一点,该包提供了一种简单的方式来内联常见的验证器,并使用可调用对象传递更多领域特定的验证器。您可以使用一个简单的返回布尔值的闭包,一个用于检查参数匹配的类或接口名,一个用于参数的Laravel验证规则数组,或者一个预构建的Laravel验证器实例。
namespace App\Commands; use App\Invoice; use App\Coupon; use ArtisanSdk\CQRS\Command; use Illuminate\Validation\Factory as Validator; class CalculateInvoice extends Command { public function run() { // Validate the argument is simply set with a non empty value $number = $this->argument('number'); // Validate the argument matches the Invoice class $invoice = $this->argument('invoice', Invoice::class); // Validate the argument against a rule of validation rules... $subtotal = $this->argument('subtotal', ['integer', 'min:0']) // ...or construct it manually yourself for something more complicated $subtotal = $this->argument('subtotal', Validator::make($this->arguments(), [ 'subtotal' => ['integer', 'min:0', 'lte:total'], ])); // Validate the argument against a custom callable... $coupon = $this->argument('coupon', function(string $code, string $argument) { return $this->couponExists($code, $argument); }); // ... or just reference a method on a callable class $coupon = $this->argument('coupon', [$this, 'couponExists']); } public function couponExists(string $code, string $argument) { return Coupon::where('code', $code)->exists(); } }
使用选项默认值
以下代码演示了使用选项而不是参数的使用。仅根据选项的存在(本质上是一个标志),您可以执行一些受保护的代码,或者根据选项值的显式检查(如果存在)。在以下示例中,如果选项未设置,则默认行为是发票不保存。
namespace App\Commands; use App\Invoice; use ArtisanSdk\CQRS\Command; class CalculateInvoice extends Command { public function run() { $invoice = $this->argument('invoice', Invoice::class); if( $this->hasOption('save') && true === $this->option('save')) { $invoice->save(); } return $invoice; } }
选项的默认值默认为null
。您还可以为列表中未出现的选项设置显式的默认值。以下使用相同的示例进行演示。结果是在未显式设置为false的情况下,发票总是被保存。
namespace App\Commands; use App\Invoice; use ArtisanSdk\CQRS\Command; class CalculateInvoice extends Command { public function run() { $invoice = $this->argument('invoice', Invoice::class); if( $this->option('save', true) ) { $invoice->save(); } return $invoice; } }
有时,当选项未设置且需要解析默认值时,您可能需要执行一些更昂贵的逻辑工作。例如,您可能希望在没有传递用户作为选项的命令或查询时默认到已认证的用户。在Laravel中,这将对数据库产生打击,这被认为是昂贵的,如果默认选项实际上没有被使用,则是不必要的。因此,最好是推迟这项昂贵的工作。该包支持默认选项的可调用对象解析器,确保工作被懒加载,直到确实需要默认值。
namespace App\Commands; use App\Invoice; use App\User; use ArtisanSdk\CQRS\Command; class CalculateInvoice extends Command { public function run() { $invoice = $this->argument('invoice', Invoice::class); // This is wasteful since you have to resolve the user even when not used // $editor = $this->option('editor', auth()->user()); // Resolve the authenticated user as the default using a closure... $editor = $this->option('editor', function(string $option) { return auth()->user(); }); // ... or just reference a method on a callable class $editor = $this->option('editor', [$this, 'resolveUser']); $invoice->editor()->associate($user); $invoice->save(); return $invoice; } public function resolveUser(string $option) : User { return auth()->user(); } }
在命令中保存模型
如果您使用ArtisanSdk\CQRS\Concerns\Save
特质或包含此特质的ArtisanSdk\CQRS\Command
,则可以快速保存Eloquent模型,包括由artisansdk\model
提供的自我验证模型。只需在命令或控制器中调用save()
并传递应保存的模型即可。如果模型无法保存(无法验证),则将引发异常。如果模型可以保存,则返回保存的实例。使用此辅助特质可以显著简化命令,并确保保存操作的一致性。
namespace App\Commands; use ArtisanSdk\CQRS\Command; class CalculateInvoice extends Command { public function run() { $invoice = $this->argument('invoice'); $invoice->total = 100; return $this->save($invoice); } }
除了简单地保存模型外,该特质还格式化CLI应用程序(如Artisan命令和PHPUnit)的错误,使其更易于阅读。
使用Silencer
有时,您可能不希望事件驱动的命令触发事件。例如,假设您正在使用SendPasswordResetEmail
命令发送电子邮件,该命令通常由UserPasswordReset
事件触发。但是,当用户注册时,ResetUserPassword
命令被调用,而您不希望发送常规的密码重置电子邮件。相反,您希望触发重置账户密码的逻辑,并使用SendAccountActivationEmail
命令在UserRegistered
事件上发送账户激活。这所有的一切都可以使用ArtisanSdk\CQRS\Concerns\Silencer
特质实现,该特质已被基类ArtisanSdk\CQRS\Command
使用。
为了实现上述示例,您可能需要编写以下代码:
namespace App\Commands; use App\User; use App\Commands\ResetUserPassword; use App\Events\UserPasswordReset; use App\Events\UserRegistered; use ArtisanSdk\CQRS\Command; use ArtisanSdk\Contract\Eventable; class RegisterUser extends Command implements Eventable { protected $user; public function __construct(User $user) { $this->user = $user; } public function run() { $user = new User(); $user->email = $this->argument('email'); $this->save($user); return $this->command(ResetUserPassword::class) ->user($user) ->silently(); } public function afterEvent() { return UserRegistered::class; } } class ResetUserPassword extends Command implements Eventable { public function run() { $user = $this->argument('user'); $user->password = null; return $this->save($user); } public function afterEvent() { return ResetUserPassword::class; } }
扩展
在Builder上使用宏
在App\Providers\AppServiceProvider@boot
(Laravel默认位置)中
ArtisanSdk\CQRS\Builder::macro('attempt');
现在您可以在任何支持此方法的可调用对象上调用attempt()
,并将任何传递的参数转发。
ArtisanSdk\CQRS\Builder::macro()
方法还支持一个可选参数,该参数接受一个可调用对象或闭包。传递给闭包的闭包将$this
绑定到构建器的上下文中,因此它们的行为与构建器上的方法完全相同,可以访问构建器的其他受保护方法,如forwardToBase()
。因此,您可以使用基于闭包的宏来自定义构建器。
ArtisanSdk\CQRS\Builder::macro('attempt', function(...$arguments) { try { return $this->run(); } catch (Exception $error) { throw new App\Exceptions\Error(sprintf($arguments[0], $error->getMessage())); } });
从您的应用程序代码中,您将能够尝试执行任何命令,并返回上下文异常,而无需在所有地方编写丑陋的try/catch逻辑。
$user = App\Commands\SaveUser::make() ->email('johndoe@example.com') ->attempt('User could not be saved: %s');
在Builder上使用混入
文档正在编写中。请谅解混乱,并考虑提交一个拉取请求以改进文档。
运行测试
该软件包经过100%的行覆盖率和路径覆盖率进行单元测试。您可以通过简单地克隆源代码、安装依赖项,然后运行./vendor/bin/phpunit
来运行测试。此外,开发依赖项还包括一些Composer脚本,可以帮助进行代码格式化和覆盖率报告。
composer test
composer watch
composer fix
composer report
有关它们的执行和报告输出的更多详细信息,请参阅composer.json
。请注意,composer watch
依赖于watchman-make
。此外,composer report
假设运行行覆盖率报告的Unix系统。通过设置min = 80
的命令设置值来设置您最低的行覆盖率要求。
许可
版权所有(c)2018-2023 Artisan Made, Co
此软件包采用MIT许可证发布。有关商业许可条款,请参阅随每个代码副本一起分发的LICENSE文件。