Laravel 领域驱动教程(五)
十七、六边形驱动开发
六边形架构基本上是另一种思考、组织和建模应用的方式(相对于传统的 MVC 模式架构)。在六边形体系结构中,我们将域模型本身包含在应用的核心中(就像它应该包含的那样),其中的层处理围绕它的域模型中对象的简化和管理(应用层)。最后,还有一个接口层,它包装了所有的东西,并通过使用端口来建立客户端需要满足的接口,为请求进入应用内部并与之交互提供了一种方法。那些满足端口契约的实现被称为适配器。
饭桶
Eric Evans 将六边形结构的工作方式与我们细胞膜允许各种分子进出细胞的方式联系起来。这些分子通过闸门或膜内允许它们通过的通道向两个方向流动。类似地,在六边形架构中,六边形的每个边都可以被认为是细胞膜的通道,代表限制什么可以进出细胞(或应用)的各种端口。在这种情况下,分子代表实现某种目的(在这种情况下是请求和响应)的适配器(具体化)。您可以将端口视为“抽象”,将适配器视为“具体化”端口基本上定义了适配器为应用的各种设施实现的接口。这些接口确保我们的应用可以使用特定的方法,而不管该接口的实现如何。每个请求进入系统的方式取决于请求的类型。我们可以通过使用一个易于使用的 Laravel 请求来抽象处理请求所需的交付机制(该请求是在给定时间从该机器/实例上存在的 PHP 超级全局变量自动构造的)。将它与六边形联系起来就相当于放置了一种主要的“输入”边,这种“输入”边是特定于您的项目需求的任意多边形。可能只有三个或四个侧面对应于系统的各种“中心”关注点,这很可能不会从围绕六边形架构和实现增加的复杂性中受益。另一方面,它甚至可能更复杂,需要形状的附加边。
Note
名称 hexa gonal 实际上并不意味着端口可以有六个边。这种类似六边形的结构实际上根本没有提到数字 6,而是意味着更多地被认为是一个圆形(而不是像分层架构那样的层次结构);它代表“外”和“内”,而不是“上”或“下”。
一个适配器可以代表端口中定义的接口的无限数量的具体实现。一般来说,它们要么向内朝着中心(域模型)行进,要么向外远离中心(响应)。图 17-1 包含与六边形架构相同的概念,只是用圆圈表示。
图 17-1
圆形六边形建筑
如果这个数字看起来很熟悉,那是有原因的。我在第一章中加入了一张类似的图片。这只是一个更高级的建模架构,允许更大的灵活性,并且比分层架构更容易测试。因为端口基本上是进出系统的请求/响应的接口,所以您可以根据需要创建任意多个实现端口接口的适配器。这允许将应用代码的几乎任何部分(域模型之外)换成同一端口接口的其他实现,而不必更改应用内使用这些接口的客户机代码。hexagonal 的另一个好处是,它允许您推迟架构设计决策,直到您对其核心有了更好的理解,对应用的真正需求有了更多的了解。
Note
端口也可以是命令或查询总线。在这种情况下,驱动适配器可能只是正在使用的实际Command或Query的实现,并被注入到控制器中,控制器构造具体的命令或查询,并将其传递到相关的总线。
顺便说一下,如果你想知道为什么之前的形状不是六边形而是圆形,那是因为实际的形状或边数完全是任意的。
通过这种松散耦合的设置,我们可以很容易地将领域关注点与系统的所有其他方面隔离开来,这也是领域驱动设计中的一个中心焦点,允许我们关注最重要的东西——业务逻辑。这种隔离使领域模型成为关注的中心,并将系统的输入和输出放在整个系统架构的边缘。
六边形的外部边缘只是一组指定的输入和输出方式(请求和响应),由端口通过接口(也称为抽象)定义,由适配器实现(具体实现),以促进与位于模型更深层次的应用内部的交互。六边形上的一条边(或圆周内的一点)属于从外部世界到我们的应用的通信的单个入口点(即,每条边都有一个与外部通信的理由)。
接下来,我们将讨论应用的另一个可能层,它位于六边形或圆形的最外侧,由端口(和适配器)组成,负责接受来自外部的请求,并将该请求路由到需要进入内部的位置。它被称为基础设施层,并不总是需要被定义为除其他三层之外的独立层,但是,随着代码库的复杂性随着时间的推移而增长,当我们增加复杂性时,正确建立这样一个层会被证明是有价值的。
就六边形架构而言,图 17-1 中描绘的最外面的区域对应于负责接受请求的层。这是有意义的,因为在 Laravel 中,可以通过定义与进入系统的请求类型相关的特定端点来实现应用的一组访问点。
-
routes/web.php:对应于浏览器请求的端点 -
routes/console.php:对应于 CLI 的端点(Artisan 命令) -
routes/broadcast.php:对应于广播请求的端点 -
routes/api.php:对应于内部或外部 API 请求的端点
六角形的建筑带来了什么
六边形方法在正确执行时会带来以下积极的好处:
-
可维护性
-
减少技术债务
-
更轻松的进步
-
对代码的更改不会影响其他代码
-
更容易、更快速地添加功能,只需更少的代码就能使它们发挥作用
-
更多分离的组件
-
非常少的重复代码
技术债务
技术债务是在项目中积累的任意数量的工作(以开发时间的形式),当决策制定得太快,并且太不注意破坏应用中任何已经存在的特性时。每次我们被迫对旧的、令人厌恶的遗留软件(自然,企业完全依赖它作为其收入流的主要部分)进行“紧急”修复,并且几乎总是包括在应用的随机部分添加相同的低质量代码风格,我们都在增加项目的整体技术债务,并最终导致软件的最终消亡。这是因为随着新的特性不断被添加到现有的代码中,在基础设施级别将所有东西结合在一起的核心基础最终会在自身的重量下崩溃。
技术债务在代码库中累积的一个原因是不适当的架构基础,这可能会很快使推出功能的过程陷入停顿。这样做的原因与前面提到的遗留系统的情况相同:理论上,我们将在坏代码的上面堆积好的代码,并且因为不是所有的错误都在软件测试期间被正确地解决,许多这样的问题很可能只在实时会话期间被发现(可能来自您的一个用户)。
有一些策略和技术可以用来防止这种技术债务感染系统。它们中的大多数都是管理大多数(好的)现代 web 开发项目和应用的最佳实践。
-
基础(Foundation):在你开始在基础上增加新的类和组件之前,这是非常重要的。并不是说它必须是完美的,但是足够的讨论、会议和试验(很可能导致失败)应该根据需要发生,以创建应用的核心基础和结构的起点。正确识别应用的有机分离点(以域、核心域、子域、有界上下文和模块的形式)就属于这一类(如果我可以直接借用域驱动设计上下文中的术语的话)。
-
可维护性:易于维护领域模型对于领域驱动设计和六边形架构都很重要,因为这是不可避免的。不管您在哪个领域工作,在某些时候都需要对核心模型进行重构。在第一次尝试中,您有很小的机会能够获得正确的领域模型。可维护性发挥作用的地方在于,在不影响组成系统的其他组件的情况下,更改代码并使其适应系统的新需求是多么容易。稍后,我们将讨论如何保持应用的长期可维护性,但简单的回答是,我们需要它能够容易地改变。可维护性应该是(或者说必须是)一个长期目标,随着时间的推移,这个目标的复杂性会增加。
-
封装变化:在前一项的基础上展开,任何系统都注定会发生变化。这可能包括改变网页的标题模板上的文本,以彻底修改旧的、过时的过程(同时继续在其上构建新的功能),这些新的功能来自先前添加到代码库的内容。重要的是我们实际上如何着手进行这些改变。最好的方法不仅是在组件/类/集合/名称空间之间画出边界,而且要把一起变化的东西和另一个变化的东西放在同一个模块或组件中。我们甚至可以更进一步,甚至把同时发生变化的拼图的各个部分放在一起,与不同时间发生变化的部分分开。
易于修改
应用和软件总的来说有变化的趋势。如果您以前从未从事过遗留系统的工作,那么您可能不知道修改或扩展多年前编写的旧的和过时的代码会带来什么样的恐惧。然而,对于最新的软件,您应该努力维护清晰分离且结构正确的代码,这些代码通过它们在域中的相应位置进行封装。
我的意思是将变化的部分与不变化的部分分开,并在与代码的其余部分相比以相同速度变化的部分之间做出更具体的区分。将常量分离到它们自己的模块或类中,这样我们就不会有一个以上的理由去改变代码。通过适当的名称空间和目录层次来组织事物,我们使代码更容易阅读、修改,并且在生产中出现问题时更容易修复。更不用说,在粒度级别上测试系统的单个工作部件的能力有所提高。要在代码库中达到这样的水平,接口非常有用。
抽象和具体化
当我们讨论关于六边形设计的抽象和具体化时,我们分别指的是端口和适配器。抽象是存在于组成应用的代码层之间的接口。它们在六边形上的位置是任意的,没有多大关系。重要的是定义正确的接口,以允许与外部世界的应用进行交互。图 17-2 从外部/内部视角展示了该模型的示意图。
图 17-2
六角形的基本方案,包括一些输入请求(浏览器和 API)
在图 17-2 中,我决定用一个六边形来表示应用,以及两种不同类型的输入(请求):一个普通用户从网络浏览器访问应用,一个外部应用或外部脚本通过 API $_GET请求访问应用。他们与应用的第一个交互点发生在最外层,我们称之为接口层。
与 Laravel 相关,这一层将由各种路由、控制器、请求和中间件组成,我们称之为应用层。这一层的原因不仅在于接受和路由输入请求,还在于以某种方式将其转换为应用可以理解的内容,并按照路由中的指定将请求传递给接收方。例如,图 17-2 中的 web 请求可能是点击我们应用中某个页面的结果,甚至是一个直接请求。
-
GET /index.php HTTP/1.1
-
用户代理:Mozilla/5.0(Macintosh;英特尔 Mac OS X 10_12.6) Applewebkit…
-
接受语言:美国
-
Accept-Encoding : gzip,deflate
-
连接:保持活动状态
这是一个独立的请求;它不需要主体部分,因为它是一个简单的 GET 调用,末尾没有查询字符串。这是原始 HTTP,它是互联网使用的语言,几乎所有的通信都使用它,但是我们的应用不使用原始 HTTP。通过 API 请求调用时,情况变得更加复杂。
-
GET {/api/v1/events/message?campaigns = 110001 _ 10001&event = click }
-
HTTP/1.1
-
授权:6302 CB 8 BD 662d 5189 e 051 CEA 48 AE 35153 c 366326
-
接受:申请/json
-
内容类型:多部分/形式数据;boundary =-webkitformboundary 7ma 4 ywxktrzu 0 GW
-
-【WebKit builder 7ma4ywxxzgjw】
幸运的是,我们使用的是 Laravel 框架,它包含了正确的抽象和实现,可以使用 HTTP,并将传入请求中指定的所有内容自动转换为我们可以使用的内容,这些内容可以作为标准依赖项以普通 ol' PHP 对象(POPO)的形式传递,这是 Laravel 的请求组件。我们还从这个组件中得到了向我们的路由、请求或两者添加验证的能力。route 组件在最外层处理验证,它被转换成一个请求对象,并根据请求中指定的验证进行检查,然后它将(现在已经过验证的)Laravel 请求转发给一个控制器对象。
在前面的页面中,我们已经多次讨论了这个请求周期,但是从中可以得出一些重要的结论:请求如何进入应用的内层(域层和核心业务逻辑)并不重要。这是因为,在原始请求到达路由器并被 Laravel 验证之后,它已经可以在应用的其余代码中使用了。简而言之,就是翻译成 PHP 对象。在底层,Symfony 的 HTTP Foundation 组件已经包装了特定于 Laravel 的 helper 方法,为框架提供了额外的功能和能力。许多其他的类、特征和接口组成了处理原始 HTTP 请求的框架部分,将它们转换成一组数据,其余的代码可以对这些数据进行操作。
回到接口和具体化,清单 17-1 展示了一个端口的例子,可以在Illuminate\Foundation\Http\Kernel接口中找到。
<?php
namespace Illuminate\Contracts\Http;
interface Kernel{
/**
* Bootstrap the application for HTTP requests.
*
* @return void
*/
public function bootstrap();
/**
* Handle an incoming HTTP request.
*
* @param \Symfony\Component\HttpFoundation\Request
$request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function handle($request);
/**
* Perform any final actions for the request lifecycle.
*
* @param \Symfony\Component\HttpFoundation\Request
$request
* @param \Symfony\Component\HttpFoundation\Response
$response
* @return void
*/
public function terminate($request, $response);
/**
* Get the Laravel application instance.
*
* @return \Illuminate\Contracts\Foundation\Application
*/
public function getApplication();
}
Listing 17-1A Possible Port-Like Interface That Could Be Implemented with an Adapter
这基本上定义了一个使用框架必须实现的接口。它是 Symfony 最初开发的基本 HTTP 组件的翻版,通过在代码中建立一种清晰的交互方式,并将原始 HTTP 请求转换为框架可以识别和使用的内容,基本上支持了业内大多数现代框架(Drupal、PrestaShop、Laravel、Symfony)。使用这个接口,我们可以编写任何我们想要的代码,只要它满足所需的方法和类型提示。我们甚至可以通过坚持接口中的规范,立即对应用的特性进行编码(即使没有完全工作的数据库),并且它将全部工作(假设我们的实现是正确的)。这就是所谓的接口编码,而不是实现编码。清单 17-2 显示了这个端口的 Laravel 适配器。请注意,我们要理解的不是功能,而是与应用整体相关的高级概念(即接口和实现)。还有,这只是真实类的一部分;完整的实现可以在 https://github.com/laravel/framework/blob/6.x/src/Illuminate/Foundation/Http/Kernel.php 找到。
<?php
namespace Illuminate\Foundation\Http;
use Exception;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\Http\Kernel as KernelContract;
use Illuminate\Foundation\Http\Events\RequestHandled;
use Illuminate\Routing\Pipeline;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Facade;
use InvalidArgumentException;
use Symfony\Component\Debug\Exception\FatalThrowableError;
use Throwable;
class Kernel implements KernelContract
{
/**
* The application implementation. *
* @var \Illuminate\Contracts\Foundation\Application
*/
protected $app;
/**
* The router instance.
*
* @var \Illuminate\Routing\Router
*/
protected $router;
/**
* The bootstrap classes for the application.
*
* @var array
*/
protected $bootstrappers = [/* … */]
...See full code listing on Laravel’s website or API Docs
Listing 17-2A Portion of Laravel’s Core Kernel Implementation (Adapter) for the Interface in Listing 17-1 (Port)
这只是实现类的一小部分,但是我们仍然可以看到这里发生了什么。在高层次上,构造函数接受当前运行的应用的一个实例(App类)和一个Router实例。我已经在类handle()中包含了第一个方法,向您展示了一个请求是如何生成响应的。这是任何 web 框架的全部目标:接收请求并返回响应。在这两者之间发生的任何事情都是我们必须构建的应用的实际部分。为了开发软件,框架提供给我们的是接口和实现,它们可以被连接、扩展等等。
设计和执行合同有什么用?
很高兴你问了!通过在整个原理图中使用接口(端口)作为核心结构,我们增加了系统设计的灵活性。我们在应用中添加了关键点,这样我们实现的组件可以相对容易地用不同的实现(适配器)替换。这里有一个思考这个问题的好方法:
-
一个契约定义了将来可能被替换的应用需求,以便提高代码灵活性和/或应用中可能同时存在多个实现。合同是一个港口。
-
具体实现是满足给定契约要求的解决方案。这些解决方案是适配器。
因此,从理论上讲,如果您希望您的应用是可重用的,或者您正在编写一个 API 甚至一个包供其他应用使用,您可以自由地使用端口(接口),这将允许应用层之间的通信(当请求向六边形的中间移动并再次返回到客户端时)。这带来了六边形方法的另一个特征:“输出”端口,即应用发出请求的端口,比如数据库端口。理论上,我们可以将我们的六边形分成两个半球:一个用于处理来自外部客户端的用户交互(也称为传入请求),另一个用于处理应用向外部服务发出的请求(图 17-3 )。
图 17-3
六边形被分成两个核心半球:UI 和基础设施
看到图 17-3 显示整个半球专用于应用的基础设施部分,您可能会感到惊讶。当我们谈到 DDD 时,我们了解到基础设施问题可以封装到它自己的层中,但在六边形中,它包含了六边形之外的几乎一半区域。这是 DDD 和 HA 之间的主要区别:DDD 根据特定的层将组成应用的片段与代码相关联,每一层都有一个直接的倾向,即什么类型的代码存在于其中,就像它的专业一样;然而,六边形只是将组成应用的组件从外部和内部联系起来。当我们考虑不同的设备、服务、浏览器或其他输入作为请求进入应用的方式时,我们还应该考虑另一方面(基础设施),这涉及到应用调用自身外部的外部系统。
这种分离是如何在代码中实现的,代码是如何建立如图 17-3 所示的边界的?答案当然是接口。我们可以在基础设施层旁边的任何需要的地方放置接口,用于我们期望进入我们的应用的所有类型的输入(这些将是端口),然后为每个不同的类型(即浏览器、CGI 等)实现该接口。),也就是适配器。在六边形的另一边,我们将照着做,为我们向外界发出的请求类型创建一个端口,然后将端口的接口实现为可以轻松插入框架的适配器。
普通请求在六边形架构中如何工作
HA 中当然存在标准的请求/响应周期,但是它实际上的工作方式可能与您想象的不同。一个典型的请求可以跨越多个端口,每个端口都有相应的适配器,由端口的契约实现,并且通常会调用两个半球的功能(这需要成功完成请求/响应周期)。图 17-4 显示了一个请求在高可用性中的高级外观。
图 17-4
六边形体系结构中请求/响应循环的“流程”
港口
从图 17-4 中可以得出的主要概念是,请求首先触及 UI(界面层)中的代码,通过应用的核心,触及后端的基础设施代码(在本例中,它调用外部 MySQL 数据库),后者运行已被基础设施代码翻译成 SQL 查询的逻辑,并将结果返回给应用,在应用中,结果再次被转换成浏览器可以呈现且用户可以看到的内容。图中没有包括所有的工作部分,但是粗线表示了这种请求的控制流。
在这个请求中涉及到多个端口,可以在六边形内的每个逻辑层的边界上找到这些端口。在 PHP 中,端口通常是在业务逻辑内部创建的接口,但也可以是一组接口和/或 dto。
适配器
适配器有两种类型:主(驱动)适配器和次(从动)适配器。在 Laravel 中,适配器通常是控制器、命令总线或传递给命令总线的查询。适配器满足由端口构成的接口。例如,控制器将输入端口接口的提示,以及为了完全实现契约所需的任何依赖关系。然后,控制器将生成的代码和依赖项更深地注入到应用层,在应用层中,逻辑实际上被委托给域层(业务逻辑),响应返回给发出请求的客户端。
代码示例
对于一个使用控制器的适配器的实现的例子,通过使用命令总线作为操作的主干来处理域对象的委托和舞蹈,查看清单 17-3 中的代码。
<?php
//use statements & namespace
class HomeController extends BaseController {
/**
* @var App\Adapters\CommandBus\CommandBus
*/
private $bus;
public function __construct(CommandBus $bus)
{
$this->bus = $bus;
}
public function createTicket()
{
$command = new CreateTicketCommand( Input::all() );
try {
$this->bus->execute($command);
} catch(ValidationException $e) {
return Redirect::to('/tickets/new')->withErrors(
$e->getErrors() );
} catch(\DomainException $e) {
return Redirect::to('/tickets/new')->withErrors(
$e->getErrors() );
}
return Redirect::to('/tickets')->with(['message' =>
'success!']);
}
}
Listing 17-3Example of a “Driven” Adapter
此外,我希望包含一个简单操作的高级透视图,该操作涉及外部客户端向应用发出请求,应用向数据库发出请求,然后将结果返回给客户端。
在图 17-5 中,我们有一个终端用户向我们的应用发出某种请求,该应用以路由器为初始接触点。
图 17-5
通过实现满足端口(端口未示出)描述的要求的 ORM 适配器,利用六边形架构从数据库获取数据的请求操作
路由器(使用端点)将请求路由到路由文件中指定的控制器(在我们的例子中,对于 Laravel 来说很可能是routes/web.php)。控制器实际上是一个适配器,它与 Laravel 中的Request组件一起使用,根据一组规则验证传入的数据,然后它以我们的应用可以看到和使用的形式将经过验证的数据发送给控制器。它将外部请求转化为内部对象,可以传递给我们想要的任何类或函数。控制器和一般的大多数驱动适配器实际上包装了一个端口的接口,而不是实现它。控制器将使用其构造函数中的一个对象进行实例化,该对象实现了相应端口的契约。这与驱动适配器(在基础设施端)的情况不同,因为它们实际上直接实现了端口的接口,而不是包装它。
在图 17-5 中,我们可以看到对面还有另一个适配器(辅助适配器),它是在基础设施边界定义的一个端口的实现。无论实现了什么,这个端口描述的接口都将是那个端口的适配器。在这种情况下,我们创建了一个 ORM 适配器,它附着在那个端口的接口上(这个端口在图中没有显示,但却是隐含的),它连接到雄辩的 ORM 并允许应用访问数据库,如图 17-5 所示。
这里有一个可能的代码解决方案。让我们从让一切正常工作所需的几个端口开始。我们将定义一个可以处理任何 ORM 的通用接口,假设所有接口和与应用的连接点都已正确建立,如清单 17-4 所示。
<?php
namespace App\Domain\Contracts\Persistence\Ports;
use QueryCapabilities;
interface ExternalOrmConnection
{
public function connect();
public function defineQueryCapabilities(
QueryCapabilities $query)
}
Listing 17-4General Port for Establishing an ORM Connection in the Application
使用这种方法,我们可以创建一个满足端口描述的接口的适配器,允许我们(理论上)将任何 ORM 连接到我们的应用。这是一个开始,但并不是我们实现实际功能所需要的全部:一种通过 orange 查询数据库的方法。我们有一个connect()方法,它执行任何配置逻辑,并包含与预期 ORM 建立连接所需的代码。还有一个defineQueryCapabilities()方法,它接受一个QueryCapabilities的实例来描述 ORM 连接的可能性。可以说,我们实际上希望将功能从连接中分离出来,但是它们是如此的相关,以至于我将其中一个作为依赖关系包含在另一个中。让我们创建一个接口,封装我们知道将会需要的基本查询(或任何此类实现的功能)。见清单 17-5 。
namespace App\Concerns\Infrastructure\Persistence\Ports;
interface QueryCapabilities
{
public function select($statement);
public function delete($statement);
}
Listing 17-5Example QueryCapabilities Port
理论上,在这一点上,我所要做的就是实现图 17-5 中定义的接口,将任何 ORM 连接到系统。只要我们基本上将接口中的方法代理到我们正在处理的具体实现(教条、雄辩、推进等)。)这样那些方法就被转发到 ORM,我们可以自由地使用任何我们想要的 ORM。
当然,这是非常有限的,这里只是作为一个例子。清单 17-6 展示了符合前两个端口的一些可能的适配器。
<?php
class EloquentOrm implements QueryCapabilities,
ExternalOrmConnection
{
public function select($statement) {
return DB::select($statement)->all();
}
public function delete($statement)
{
return DB::delete($statement);
}
public function connect()
{
//connection logic
}
}
class DoctrineDbalOrm implements QueryCapabilities,
ExternalOrmConnection
{
public function delete($statement)
{
$stmt = $this->connect()->delete($statement);
return $stmt->execute();
}
public function select($statement)
{
$stmt = $this->connect()->prepare($statement);
return $stmt->fetchAll();
}
public function connect()
{
return $this->getConnection(‘default’);
}
}
Listing 17-6Example Implementations of the Two Port Interfaces Described Earlier
当然,这个例子只考虑了对应于六边形的一条边的单个关注点,在这个例子中,是持续边。然而,添加已定义端口的接口实现的一般过程基本上保持不变:在应用的域层创建一个接口,该接口将定义它所在的边缘所关注的一般结构。一旦定义了高级接口,就可以(也应该)在应用中任何需要注入接口的地方使用该接口的类型提示。请记住,编码到接口,而不是实现。通过这样做,您可以以一种清晰、易于理解的方式添加功能的特定部分,因为该接口的子接口所需要的只是满足约定(由方法名和签名组成)。通过创建封装给定端口的特定实现的适配器来实现这一点。这种设置的美妙之处在于,您不必更改处理子类的周围代码。因为我们已经决定对一个接口进行编码,所以所有的类型提示将与任何为此目的创建的新适配器完美地一起工作,这是在端口中定义的。记住让端口为整个应用的入口点和出口点定义接口。他们根本不应该了解具体的实现。这就是为什么你在构造和形成物体的方式上有如此大的灵活性。
用例
用例是六边形架构中的基本元素,它与要开发的特性相关(并完整地描述了该特性)。用例只是描述使用特定功能的特定原因或动机的场景陈述(它也可以描述上下文)。
用例属于应用层,通常以应用服务的形式出现,它包含用例,并注入了其他依赖项,以便它可以执行用例指定的任何功能。通常,用例在被翻译成代码之前被写成英语句子,并遵循一些基本格式。下面是一个格式化用例的通用方法:
- 作为{角色/用户类型},我想{描述}。
以下是一些例子:
-
作为一名医疗服务提供者,我希望能够通过姓名或生日快速查找患者。
-
作为一名医疗账单开具者,我希望能够对分段数据进行报告,并对给定分段中的所有医疗账单进行累计总计。
-
作为一名管理员,我希望能够轻松登录和注销不同的用户帐户。
这种格式清楚地表明了谁在请求工作以及任务中包含了什么。显然,在编码开始之前,不仅仅需要额外的细节,但是这种格式为开发人员和非开发人员提供了一种描述新特性的简单方法。
在 Laravel 中实现六角形
在 Laravel 中,应用了非 Laravel 应用中存在的相同的基本原则:用封装每层逻辑的边界接口在架构级别上分离关注点。依赖关系指向内部,端口(接口)被留下来定义其边界内逻辑上下文的底层需求。通常,我们可以使用 Laravel 契约来实现这样的边界,这种方法效果很好,因为您可以使用 Laravel 的服务容器(涉及配置服务提供者内部的依赖关系)简单地将给定的接口自动连接到该接口的特定实现。这将把一个特定的子实例(完全实例化并准备好运行,其配置也可以由服务容器作为系统中的一个单独对象来确定)注入到父接口有类型提示的任何地方。这一切都会自动发生。
清单 17-7 展示了一个很好的、简洁的例子,展示了如何在应用中实现事件(假设您想要自己的跨应用事件定制解决方案)。它位于六边形的Event边,可以被认为是一个“实干家”适配器,因为它的核心是一个调度程序。
<?php
namespace App\Events;
use Illuminate\Events\Dispatcher as DispatcherInterface;
class Dispatcher implements DispatcherInterface {
/**
* @var \Illuminate\Events\DispatcherInterface
*/
private $dispatcher;
public function __construct(LaravelDispatcher $dispatcher)
{
$this->dispatcher = $dispatcher;
}
public function dispatch(Array $events)
{
foreach($events as $event)
{
$this->dispatcher->fire($event->name(), [$event]);
}
}
}
?>
<?php
namespace App\Events;
interface EventInterface {
/**
* Return the event name
* @return string=
*/
public function name();
}
?>
<?php namespace App\Events;
trait Eventable {
protected $queuedEvents;
public function flushEvents()
{
$events = $this->queuedEvents;
$this->queuedEvents = [];
return $events;
}
public function raise($event)
{
$this->queuedEvents[] = $event;
}
}
Listing 17-7An Example Solution That Fits the Event Side of the Hexagon
使用清单 17-7 中描述的设置,用户只需实现位于App\Events名称空间中的EventInterface,就可以通过使用Eventable特征轻松地向任何需要分派事件的类添加行为(可能是向消息队列或命令总线)。清单 17-8 就是这样一个事件的例子。
<?php
namespace Hex\Tickets\Events;
use App\Models\Ticket;
use App\Events\EventInterface;
class TicketCreatedEvent implements EventInterface {
/**
* @var \Hex\Tickets\Ticket
*/
private $ticket;
public function __construct(Ticket $ticket)
{
$this->ticket = $ticket;
}
/**
* Return the event name
* @return string
*/
public function name()
{
return 'ticket.created';
}
}
Listing 17-8An Implementation of an Event as Defined by the Interface (Port) Described in the EventInterface Interface
结论
虽然我们没有深入探讨六边形架构的具体细节,但是如果这个想法引起了你的兴趣,那么我鼓励你对六边形架构进行更多的研究,并开始尝试这个范例,因为它非常有用。Hexagonal 更侧重于“进”而不是“出”,而不是传统的“自上而下”的方法(如在层次结构中)。它描述了一组边界,这些边界封装了表示为端口(基本上只是接口)的核心知识,这些端口位于六边形(或圆形)内每一层的边界。术语六边形并不意味着在描述给定应用的所有关注点时只涉及六个边。可以有更多或更少,这取决于应用的环境。适配器是实现接口的对象,因此,由于它们所实现的接口认为它们需要结构,所以可以不加考虑地创建适配器。Laravel 中的 Hexagonal 有一个积极的好处,那就是能够使用 Laravel 的核心契约库,并且在与 Laravel 的服务容器(绑定)并行使用时可以实现很棒的事情。如果你想更深入地研究六角形建筑,可以看看 https://fideloper.com/hexagonal-architecture 和 https://madewithlove.com/hexagonal-architecture-demystified 。
十八、DDL 在现实世界中的应用
我们已经回顾了很多关于遵循领域驱动设计原则开发 Laravel 应用的信息。在这一章中,我们将把这些知识应用到我所面临的各种现实世界的问题中(无论是独自还是在团队中),并寻找这些问题的可能解决方案。我们将以领域驱动的方式来讨论它们,这种方式将重述我们在整本书中学到的概念。我们将把这些概念放到上下文中,这样您就可以从使用 Laravel 和 DDD 中获得最大收益。通过在每一层的边界点提供接口(从六边形架构借用的概念),我们可以使用严格的对象结构,根据属于同一类别的更广泛的概念来定义我们的应用的行为和功能的一般性。这些类别的示例包括:
-
事件:从系统中的任何地方广播自定义事件。事件被实现为驱动适配器,因为它们需要被调度,但是它们可以有一个驱动部分来处理从适当的接收者接收这些事件,这是一个更被动的过程。
-
数据库:访问数据存储以获取运行应用所需的数据。这落在六边形的持续边缘旁边。
-
用户界面:可通过浏览器从图形界面使用。
-
命令、控制器和其他“驱动”问题:来自应用外部的输入请求,作为应用执行的驱动力。
在这一章中,我们将经历一个简单的场景,并勾画出一个粗略的解决方案;然后我们将重构它以增加更多的深度,并重新审视在 Laravel 中制作 API 时所涉及的细节。
真实世界的例子:估计索赔
如果您还记得,患者与提供者有免费或减价支付的预约(假设他们有资格接受护理),提供者必须以特定的方式提交索赔,然后他们才能从联邦政府的 Medi-Cal 部门获得服务报酬。我们的系统提供了一种简单的方式来提交索赔,并具有内置的验证和确认检查,保证满足赔偿要求。
支付给提供者的金额部分取决于为患者提供的服务(通过 CPT 代码获取和记录)以及提供者的特定支付代码表。预计的索赔金额是在将索赔提交给系统之前必须计算的金额,以便提供商(或接待员)可以查看并确保金额合理。我们将根据应用的概念(不一定仅仅是代码)以最适合领域和应用的方式来构建这个功能。
首先,我们需要一个类来表示索赔估计是什么,因为索赔的值基本上是不可变的。(这意味着估算的金额不会改变,尽管计算出的金额本身会随着输入的变化而变化,如 CPT 代码的变化。)因此,索赔估计是一个价值对象。清单 18-1 一个简单的类捕获了这些知识,由一个 DTO 表示。
<?php
namespace Claim\Submission\Domain\ValueObjects\Estimates;
class Estimate
{
private float $amount;
private $codes = [];
public function __construct(float $amount, array $codes=[])
{
$this->amount = $amount;
$this->codes = $codes;
}
public function create($amount, array $codes=[]): Estimate
{
return new self($amount, $codes);
}
public function setAmount(float $amount)
{
$this->amount = $amount;
return $this;
}
public function setCodes(array $codes): Estimate
{
$this->codes = $codes;
}
public function amount(): array
{
return is_null($this->amount) ? null : (float)
$this->amount;
}
public function codes(): float
{
return $this->codes;
}
}
Listing 18-1Basic Class Representing a Claim Estimate
在清单 18-1 中,我们基本上有一个简单的 DTO,其中包含一个静态帮助器方法,该方法返回一个准备就绪的索赔评估实例。这真的没有什么特别的,尽管要注意名称空间:我们与域保持一致,并根据域的构造方式来调整事物。这听起来像是领域驱动的设计!前面的$codes成员变量是一个数组,包含在访问期间完成的特定的、单独的 CPT 代码。$amount变量将保存计算出的值,并且将是一个原始浮点型。请记住,Estimated类只是用于计算金额的实际输入的记录,不包括任何类型的行为——只是数据。
额外要求
在本书的前面,我提到了提供者可以设置两种潜在的支付“类型”来决定他们的报酬的可能性。
-
每次就诊付费:这是每次就诊向提供者支付的固定金额,无论就诊期间完成了什么程序。
-
按程序付费:这种类型的支付需要在提供者的
PaycodeSheet上查找报销申请上的 CPT 代码。
我们如何将这一方面加入到索赔估计金额的计算中?显然,我们可以假设付款类型被记录在数据库中的某个地方(比如providers表中的payment_type_id字段或类似的东西)。如果不是,我们将不得不为这个字段编写一个迁移,并且很可能在创建Provider帐户时填充它,这给我们留下了额外的麻烦,即必须用新的payment_type_id字段的值来填充当前的Provider帐户记录。我们还需要创建一个迁移来创建payment_type_id工作所需的查找表。我们可以将实际值放入同一个迁移中(该表将被命名为payment_types)。
假设我们已经完成了在应用中实现这种支付类型概念的所有细节,现在需要做的就是正确计算索赔的估计金额。由于我们注意到关注点的分离,我们决定将任何输入验证放在一个 Laravel 请求中,该请求被传递给一个控制器方法。我们还决定这个操作需要它自己的端点,所以我们决定给它自己的名称空间Claim\Submission\Domain\Models\Estimate,这个名称空间将存放与评估本身相关的任何值对象。因此,索赔的估计可以简单地认为是一组 CPT 代码加上一个美元金额,它代表了给定索赔的最终估计。它不知道实际确定成本的计算(这可能相当复杂)。我们可以并且应该将一个PaymentType的知识封装成一个模型。
<?php
namespace Claim\Billing\Domain\Models\Payment;
use Illuminate\Database\Eloquent\Model;
class PaymentType extends Model
{
const PER_VISIT = 1;
const PER_PROCEDURE = 2;
/* ... */
}
这为应用代码的其余部分提供了一个很好的参考点,因为开发人员不必依赖于内存,也不必回忆每次访问付费类型实际上对应于 ID 1。我们可以简称为PaymentType::PER_VISIT。
索赔评估服务
我们现在剩下的任务是创建计算估计索赔金额的逻辑。首先要考虑的应该是找出该逻辑的最佳位置。由于这是一个特定于领域的任务,我们可以选择将代码放在领域服务中。域服务专门在域级别上运行,不应该有任何与域不直接相关的问题。诸如促进请求和响应循环以运行计算的应用关注点应该从服务中分离出来,允许它专注于完成一个特定的任务。
畴层
如果您还记得的话,领域层是任何软件应用中包含特定底层领域模型的核心业务逻辑的地方,而构建应用就是为了表示该模型。就索赔项目而言,“域服务”的一个示例是应用中计算 FQHC 向提交索赔的提供商支付的预期金额的部分。查看清单 18-2 以获得这种服务的示例实现。
//Claim\Submission\Domain\Services\ClaimEstimator.php
<?php
namespace Claim\Submission\Domain\Services;
use Claim\Submission\Domain\Models\Providers\Provider;
use Claim\Submission\Domain\Models\PaycodeSheets\PaycodeSheet;
use Claim\Submission\Domain\Models\Payment\PaymentType;
use Claim\Submission\Domain\Models\Payment\PaymentData;
use Claim\Submission\Domain\Models\CptCodes\CptCodeCombo;
use Claim\Submission\Domain\ValueObjects\Estimate\Estimate;
use Claim\Submission\Domain\Services\Payment\ClaimPaymentService;
Use Claim\Submission\Domain\Exceptions\
ComboNotFoundInPaycodeSheetException;
class ClaimEstimator
{
protected Provider $provider;
protected CptCodeCombo $cptCodeComboRepository;
protected PaycodeSheet $paycodeSheetRepository;
protected ClaimPaymentService $claimPaymentService;
public function __construct(
PaycodeSheetRepository $paycodeSheetRepository,
CptCodeComboRepository $cptCodeComboRepository,
ClaimPaymentService $claimPaymentService}
{
$this->paycodeSheetRepository = $paycodeSheetRepository;
$this->cptCodeComboRepository = $cptCodeComboRepository;
$this->claimPaymentServices = $claimPaymentServices;
}
public function estimate(Claim $claim, array $codes): float
{
$provider = $claim->primaryProvider();
$estimateDate = $claim->estimateDate()->toDateTimeString();
//we need to take into account the two different payment
//types described above : Per-Procedure and Per-Visit
$paymentType = $this->findPaymentData($provider);
if ($paymentType === PaymentType::PER_VISIT) {
return $provider->fee_per_visit * $provider->bonus;
} else {
return $this->calculatePerProcedureEstimate(
$provider, $claim, $codes);
}
}
public function findPaymentData(Provider $provider): PaymentData
{
//this way we can add additional payment types with ease:
return PaymentType::fromRequest ($provider->paymentType);
}
public function calculatePerProcedureEstimate(
Provider $provider, Claim $claim, $codes=[])
{
if (!empty($codes)) {
$cptCodeCombo = $this->cptCodeComboRepository
->findComboFromCodes($codes);
$paycodeSheet = $this->paycodeSheetRepository
->byProvider($provider->id);
$estimatedAmount = $this->claimPaymentService
->lookupPriceForCombo(
$paycodeSheet, $provider, $cptCodeCombo);
if (!is_float($estimatedAMount)) {
throw new \ComboNotFoundInPaycodeSheetException;
}
return new Estimate($estimatedAmount, $codes);
}
}
}
Listing 18-2The Domain Service That Will Handle the Calculation of the Claim Estimate
这将意味着在PaymentType模型上创建一个额外的方法,当提供适当的构造时,该方法将返回其自身的一个实例。本质上,这个方法的签名和它的构造函数方法是一样的,如清单 18-3 所示。
<?php
//inside the Estimate value object (Listing 18-1)
use Claim\Submission\Domain\Models\Payment\PaymentType;
class PaymentType extends Model
{
const PER_VISIT = 1;
const PER_PROCEDURE = 2;
public static function fromRequest(int $payType): PaymentType
{
return new self($payType);
}
}
Listing 18-3PaymentType Entity in the System, Allowing Us to Call a Static Method on the Class and Return a Fully Instantiated Object of Itself
这有一个额外的好处,即使用易于记忆的常量变量(不可变的)作为PaymentType类的构造函数的参数,还有一个好处是通过名称而不是数字来引用(PER_VISIT比 1 更容易记忆)。注意,我们已经正确地输入了清单 18-2 中的所有内容。除了采用这种编程风格获得的其他非显而易见的好处之外,还有一些好处,例如减少了开发人员疏忽和运行时的错误数量,以及由于特定化而提高了性能,因为即时编译器(PHP 引擎)在推断参数和变量的类型和值时减少了猜测。起初,你可能看起来没有节省多少计算能力;然而,完成这项工作所需的 CPU 周期越少,你的总体情况就越好。
在清单 18-2 中,我们发现了一些有趣的事情。首先,我们将依赖项很好地作为构造函数参数列出,可以通过 Laravel 的服务容器(通过其依赖项注入机制)自动注入每个依赖项的正确实例。在estimate()方法中,我们委托给另一个方法来获得适当的PaymentType来设置给定的提供者,它使用我们在清单 18-3 中包含的好的帮助器方法作为内联实例化相当静态的值的手段,在某种程度上有点像一个特别的快捷方式。
一旦确定了支付类型,服务将在每次访问费率类型的情况下立即计算估计值,或者在提供者被分配了每次程序支付类型的情况下,需要额外的计算和逻辑来得出估计值。CptCodeCombo需要使用传入的单个 CPT 代码进行查询,以便能够查询PaycodeSheet模型,并且当给定传入索赔上的提供者时,能够导出指定的金额。成员方法calculatePerProcedureEstimate()处理每个过程的计算;然而,如果您注意到,该方法真正做的只是将某些任务委托给构造函数中注入的一个依赖项(另一个名为ClaimPaymentService的域服务)。这清楚地分离了基于影响索赔金额的不同变量计算索赔金额的实际关注点:支付代码表、提供者和代码本身,它们可能会在复杂性和代码行方面发生变化和/或增长。如果将来需要另一个PaymentType,将它集成到当前系统中的成本将是最小的,因为我们已经将这种灵活性内置到了我们的域级组件中。
同时,ClaimPaymentService域逻辑将封装处理估算索赔所涉及的脏活的过程。需要注意的是,尽管我们似乎只是在将我们需要完成的任务委派给其他对象时“推卸责任”,但我们是在支持我们的长期、更高层次的目标,即构建和维护一个其逻辑和域过程很容易推理的应用。代码本身直接反映了编写代码的开发人员对领域的理解程度。通过将流程的大块分割成更小的组件,我们给自己一个更好的代码可重用性的机会,并且让未来的开发人员更容易理解。
警告绝对有过度工程这种东西,它可能会发生,而你直到后来才注意到它。您可以采用几个原则来减少这种情况的发生,例如您不需要它(YAGNI)或者做最简单的工作(DTSTTCPW),但是防止它的真正关键是允许域指导您的架构和编程决策。尽可能将您的决策建立在与领域相关的基础上,并且总是试图在您的代码和它所解决的业务问题之间保持一条相关的线索。
在这一点上,处理查找以确定索赔的估计支付金额的实际实现是任意的,所以我不会在文本中特别包括它;你可以在这本书的在线知识库上看到它。
应用层
既然我们已经解决了索赔估计的领域问题(并且通过领域服务在领域模型中形式化了它们相应的含义),我们仍然需要考虑应用级别的细节。更具体地说,我们将讨论将外部请求传递给应用代码(传入请求)的机制,以及创建将其返回给请求方(传出响应)的方法。dto 有助于确保交付机制(两种方式)被封装到与特定功能保持一致的结构中。
HTTP 请求
让我们定义一个简单的请求来启动我们之前定义的域进程,我们在清单 18-4 中做了这些。
//Claim\Submission\Application\Http\Requests\Estimates\
//EstimateRequest.php
<?php
namespace Claim\Submission\Application\Http\Requests\Estimates;
use App\Http\Requests\Request;
class EstimateRequest extends Request
{
public function authorize()
{
return true;
}
public function rules()
{
return [
'claim' => 'exists:claims,id'
];
}
}
Listing 18-4Request to Get a Claim’s Estimated Value
清单 18-4 中的请求相当简单,你应该不会感到惊讶,因为我们在本书前面已经讨论过类似的类。在请求的rules()方法中,我们只有一个必需的参数:声明。
请记住,在这一点上,所有信息都已经输入到索赔中,除了我们现在正在设置的应付给索赔提供者的估计金额之外,其他信息都是完整的。我们可以从索赔本身中提取出计算成本所需的任何相关信息,因此我们不必包括例如提供商或个人 CPT 代码。事实上,包含这些内容的额外要求是重复的,因为我们应该能够安全地假设声明中存在的 CPT 代码是有效的,并且在达到这一点之前已经在代码中的其他地方得到了验证。$claim变量将包含我们需要的一切,因为它已经在数据库中,我们可以依靠它的状态来确定索赔处于流程的哪个阶段(幸运的是,我们已经在第九章中设置了索赔状态)。
最后那部分听起来有说服力吗?对我来说确实如此,当我在加州美丽的埃尔卡洪(El Cajon)当时工作的地方坐下来设计这个系统时,这正是我最初的想法。直到后来,我才恍然大悟。当我最初整理前一段时,我没有考虑功能性。我从纯技术的角度考虑这个问题,因为我的设计很大程度上基于这样一个事实,即一个完全实例化的Claim对象可以并且将包含创建索赔估计所需的所有相关数据。然而,如果我把注意力放在需要完成的功能上,我就会明白我们试图做的是得到一个索赔的估价。这是一个不需要索赔本身的操作,只需要从索赔中选择一些数据。通过将声明本身耦合到声明估计请求,我实际上将负责估计声明值的机制耦合到了声明本身,而不是正确地指定组件运行所需的最小依赖(构造函数注入)。
这产生了一个小问题,直到后来系统成熟时才被发现,当时请求允许系统中的所有专业用户(非患者)能够根据一组任意的、用户提供的参数运行索赔估计。然而,因为我们本质上已经将整个估计 enchilada 直接耦合到了一个声明,所以我们不可能与这样一个没有Claim对象的上下文兼容。在这一点上,我们不可能决定在到达索赔估计端点时强制前端以某种方式建立一个完整的Claim对象,所以这是不可能的。我们真的必须重构代码,使它适应我们构建它的当前环境之外的情况;这不是一件容易的事,而且很少是这样。熟能生巧。最佳实践成就最佳。
长话短说,我应该坚持设计的功能方面,在这种情况下,将涉及分解请求中所需的内容,以正确地获得响应中返回的结果。最后,我需要做的只是计算一个估计的数量,这需要正确地注入这样一个东西所需的下列依赖项:
-
供应商
-
工资单
-
CPT 代码组合
- 从 CPT 代码数组中导出
下一节将更详细地讨论根据各种环境对系统设计进行建模。
Modeling Contexts
我想包含一个简短的切线讨论,它存在于现实生活的开发中,是任何实时或接近实时的应用经常关注的问题。也就是语境。领域驱动的设计是基于构建模块的概念,或者可以用来导出工作模型或架构的模式,该工作模型或架构捕获了它所代表的领域的全部意图。例如,这里有一个假设的场景。
假设我们想要额外的功能,允许我们覆盖索赔上的原始 CPT 代码组合,也许是在您想要执行“快速编辑”类型的更改的情况下,这种更改独立于索赔提交表单,允许您覆盖索赔上输入的原始代码。这对于审核者在验证原始值有错误来源(很可能是通过直接联系提供者的办公室)后对索赔进行快速一次性更新可能是有用的。在这种情况下,我们可以将有问题的声明作为参数传递给请求,同时传递的还有他们想要用来更新该声明的新代码数组。
然而,这个“一次性”CPT 代码更新的上下文与我们直接从索赔提交上下文中修改整个Claim对象的上下文是不同的。因此,这就引出了一个问题:即使这两个上下文确实在修改相同的数据,它们应该在系统中有自己独立的实现吗?
Laravel 的酷之处在于为像PatchClaimRequest甚至UpdateChangeRequest这样的请求配置验证是多么简单。帮助完成该任务的请求的rules()方法可以是这样的,并且基本上满足我们所有的验证需求。详见清单 18-5 。
<?php
public function rules()
{
return [
'claim' => 'exists:claims,id',
'cptCodes' => 'array',
'cptCodes.*' => 'exists:cpt_codes,id'
];
}
Listing 18-5A Would-Be Rules Configuration That Could Be Used to Validate the Patching of an Already Created Claim
当然,这是假设所需的功能在系统中被视为独立的代码片段。这种方法的好处是与索赔提交屏幕上的“完全更新”相对应,如果您还记得的话,索赔提交屏幕上有一个相当长的需求和验证列表,这些需求和验证是针对传入的索赔运行的,因此它甚至可以到达流程的这个阶段。
在某种程度上,将这个请求与处理修补整个索赔的请求分开,似乎等同于必须在多个位置维护相同的代码。然而,当我们考虑到后端代码基本上是相同的,并且我们实际上可以将修改声明(PatchClaim)的任务放在一个作业中,然后该作业可以被分派到一个工作队列中时,我们发现我们在某种意义上并没有真正重复代码,违反了 DRY 原则。我们实际上正在对这种内联能力进行建模,以将索赔上的 CPT 代码修改为与第一次创建索赔时不同的上下文。
在这种情况下,我认为最好用不同的请求来表示两种上下文,在这两种上下文中,可以在给定的声明中修改、添加或删除 CPT 代码,因为在某些时候,我们可能需要知道这些编辑实际上是在哪里进行的, 如果我们将两种上下文合并到同一个请求中,这将是不可摧毁的(更不用说由于必须重新调整“CPT 代码可以在独立于索赔提交表单的屏幕上更新”这一新场景的所有内容而使验证成为一场噩梦)。 通过 POST 请求处理索赔创建的机制很可能与处理更新索赔的补丁请求的机制不同。这是故意的。更新索赔时,创建索赔的 POST 请求中所需的许多(如果不是大部分)验证在修补请求中是不需要的。我会将这两个单独的用例放在一起考虑,因此我会认为额外的上下文应该单独建模。
控制器
让我们回到考虑给定索赔的估算方面。
协调前面的域逻辑的控制器相当简单,看起来类似于清单 18-6 (一个粗略的草稿)。
//Claim\Submission\Application\Http\Controllers\EstimateController.php
<?php
namespace Claim\Submission\Application\Http\Controllers\Estimates;
use App\Http\Controllers\Controller;
use Claim\Submission\Application\Http\Requests\Estimates\
EstimateRequest;
use Claim\Submission\Domain\Models\Claims\Claim;
use Claim\Submission\Domain\Services\ClaimEstimator;
use Claim\Submission\Application\Exceptions\MissingProcedureException;
use Claim\Submission\Application\Responses\EstimateResponse;
class EstimateController extends Controller
{
protected Claim $claim;
public function estimate(EstimateRequest $request,
ClaimEstimator $claimEstimator, Claim $claim)
{
$this->claim = $claim;
$this->authorize('view', $claim);
try {
$amount = $claimService->estimate(
$claim, $request->cptCodes);
} catch (MissingProcedureException $exception) {
logger()->error("Could not estimate given claim");
return $this->handleMissingProcedure();
}
return EstimateResponse::createFromEstimate(
Estimate::create(
$this->estimatedAmount($amount),
$request->cptCodes
);
}
public function handleMissingProcedure()
{
return response()->json(['errors' => [
"Unknown CPTCode Combo present for Claim or Paycode
Sheet not defined for Provider on Claim: " .
$this->claim->id
]], 422);
}
}
Listing 18-6Basic Estimate Controller Following the Same Standards We’ve Been Employing Throughout the Book
这个控制器做它应该做的事情:接受请求并返回响应。它很可能(在技术上)行得通,但从目前的形式来看,它确实有改进的空间。当审查这段代码时,例如在一个拉请求中,我将在审查注释中首先包括以下内容:
-
控制器通常没有属于类中单个方法的成员变量(例如,添加到类顶部并注入到
estimate()方法中的$claim成员变量)。如果有的话,成员变量应该保留给控制器工作所需的服务或其他依赖项。其他任何事情都可能是代码味道,表明控制器中发生了太多的业务逻辑(或者任何与此相关的事情)。 -
estimate()本身在函数体中包含了太多的逻辑。控制器要做两件简单的事情:接受请求并返回响应。 -
计算评估所涉及的逻辑最好表示为可以排队的作业。
考虑到前面的三个注释,我们决定取出当前驻留在控制器中的所有业务逻辑,最终得到一个干净的控制器,它通过以作业的形式分派特定的业务逻辑胶囊来表达清晰的意图。清单 18-7 展示了我们重构后的控制器和新任务。
//Claim\Submission\Application\Http\Controllers\EstimateController.php
<?php
namespace Claim\Submission\Application\Http\Controllers\Estimates;
use App\Http\Controllers\Controller;
use Claim\Submission\Application\Http\Requests\Estimates\
EstimateRequest;
use Claim\Submission\Domain\Models\Claims\Claim;
use Claim\Submission\Domain\Services\ClaimEstimator;
use Claim\Submission\Domain\Jobs\Claims\EstimateClaimAmount;
use Claim\Submission\Application\Responses\EstimateResponse;
class EstimateController extends Controller
{
protected ClaimEstimator $claimEstimator;
public function __construct(ClaimEstimator $claimEstimator)
{
$this->claimEstimator = $claimEstimator;
}
public function estimate(EstimateRequest $request, Claim $claim)
{
$claim = $this->claim;
$cptCodes = $this->cptCodes;
$this->authorize('view', $claim);
dispatch(new EstimateClaimAmount($claim, $this->cptCodes);
//refresh the Claim since we dispatched it to the queue
$claim = $claim->fresh();
//create a response by fetching the new estimate from DB
return (!is_null($claim->estimate_id)) ?
EstimateResponse::createFromEstimate(
Estimate::find($claim->estimate_id))
: response()->make(['success' => 'false'], 500);
}
}
Listing 18-7The Refactored Version of the Controller in Listing 18-6
从前面的代码示例中调用的新的EstimateClaimAmount作业可能看起来像清单 18-8 。
//Claim\Submission\Domain\Jobs\Claims\EstimateClaimAmount.php
<?php
namespace Claim\Submission\Domain\Jobs\Claims;
use Claim\Submission\Domain\Models\Claims\Claim;
use Claim\Submission\Domain\Services\ClaimEstimator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class EstimateClaimAmount extends ShouldQueue
{
protected Claim $claim;
protected $cptCodes = [];
protected ClaimEstimator $claimEstimatorService;
public function __construct(,
Claim $claim, $cptCodes=[])
{
$this->claim = $claim;
$this->cptCodes = $cptCodes;
}
public function handle(ClaimEstimator $claimEstimator)
{
$claim = $this->claim;
$cptCodes = $this->cptCodes;
try {
$amount = $claimEstimator->estimate(
$claim, $cptCodes);
} catch (MissingProcedureException $exception) {
logger()->error("Could not estimate given claim");
throw new MissingProcedureException("ERROR MSG");
}
$estimate = Estimate::create(
$amount,
$this->cptCodes
);
$claim->estimate_id = $estimate->id;
$claim->save();
]
}
Listing 18-8The New Job Encapsulating the Details of Creating an Estimated Amount for the Given Claim
Tip
无论何时你需要某个 Laravel 组件,无论是作业、控制器、请求等等。,您应该总是从提供的 Artisan 命令开始,为您的组件生成一个空白存根,而不是每次都手工键入整个内容。在前一种情况下,我们可以使用以下内容生成此作业:
php artisan make:job \\Claims\\Submission\\Domain\\Jobs\\Claims\\EstimateClaimAmount
清单 18-8 中的代码应该相当简单。我们创建了一个作业来封装索赔计算的过程,以获得索赔的估计值。我们已经在这个作业的handle()方法中注入了主依赖项(ClaimEstimator),由服务容器自动解析。如果对象需要额外的逻辑来实例化,您可以使用容器的bindMethod()来自定义如何构建作业。您可以向自己选择的服务提供商抛出这样的东西:
$this->app->bindMethod(::class.'@handle', function
($job, $app) {
//custom instantiation logic goes here...
return $job->handle($app->make(EstimateClaimAmount::class));
});
还要注意,我们已经将实际的运行时数据对象注入到构造函数中。这实质上是为队列设置作业。真正接受它的是handle()方法。
基础设施层怎么了?
似乎我们已经从索赔评估过程中省略了整整一层。为什么会这样?Laravel 的工作方式和 web 应用的自然顺序使得与某部分代码相关的基础设施实际上与它周围的代码一起存在内联,而不是分离到它自己的特定层中。这在 Laravel 应用中经常出现,因为 Laravel 提供了以动态方式执行例行数据库查询的方法。
这并不是说基础设施层不会拥有标准的东西,比如存储库或查询构建器来支持应用中的模型。这些类型的东西最好放在专用的基础设施层中;我只是想让您注意,Laravel 中的基础设施层几乎可以看作是 Laravel 本身。正因为如此,基础设施代码分散在应用的大部分中,特别是在实际上做一些事情的类中(比如六角形架构中的驱动适配器),比如 jobs。它以一种非介入的、方便的方式根植于其他层次,这并不总是符合领域驱动设计的要求。DDD 在解决商业问题时更多地采用结构化的、正式的方法。因为我们很早就决定使用一个框架,所以我们必须小心不要滥用它的力量,并且为了得到正确的领域模型,让您在代码中做出的决定严重依赖(如果不是完全依赖)您正在为之制作模型的领域的需求和功能方面。
关于建筑的一些笔记
前面的例子需要经过几个周期的重写、测试、重构和更多的测试才能达到现在的高度。没有好东西来之不易!以我们存储估计值的方式为例。我们不是将估算值保存在索赔本身中(作为与估算值相对应的浮点数或十进制值),而是将估算值单独存储在不同的模型中,然后将该模型的 ID 保存在索赔中。这可能看起来有点非正统。然而,当我们退后一步,从功能的角度来看这个场景时,我们发现这种方法很好地符合领域需求,即能够在没有对特定声明的引用的情况下计算估计值。这将意味着在数据方面完全分离索赔和索赔的估计数额;因此,我们创建了一个单独的模型类来封装索赔估计的概念。
让我们只考虑需要为要计算的估计值导出的数据点,这些数据点将是反映在患者身上完成的程序的相应 CPT 代码。有趣的是,尽管这个功能的名称是索赔估算,但它的“索赔”部分与估算金额的实际计算没有太大关系。该估计实际上是根据 paycode 表(它需要通过提供者的 ID 来查找)、提供者的 ID(正如刚才提到的)以及在就诊期间完成的 CPT 代码(程序)来计算的。这些都不是真正的索赔本身。它们都是与索赔相关的对象。
如果我们创建了一个接受了一个Claim对象的作业,然后取出该对象中运行估算所需的值,我们肯定会节省一点打字的时间,因为我们会让作业的handle()方法只需要一个参数就可以完成它的工作(这也便于记忆)。
class EstimateClaim extends ShouldQueue
{
// ...
public function handle(Claim $claim)
{
//do the work...
}
}
我们现在为自己制造的问题在于Claim模型与索赔估算逻辑的耦合。为什么这是一件坏事?Claim是一个相当大的物体,并且在很大程度上是系统中最重要的物体。就我们的验证上下文而言,它包含许多其他对象、集合、值和数据,在它达到“有效”状态之前需要做大量的工作。
这种耦合显然迫使我们总是拥有一个准备就绪的Claim对象,我们将该对象传递给Estimator服务以获得估计值。现在您可以看到这种方法是如何在应用的其他地方完全可重用的。这就是前面讨论的假设特性的情况,其中一个Biller用户或Provider用户可以在不实际传入一个Claim对象的情况下检索一个索赔估计值,或者一个会计用户想要仔细检查为一个对系统来说相对较新的特定 CPT 代码组合所做的支付是否实际上被设置为检索正确的估计值。它们可能没有一个Claim对象可以传入,因为在这个上下文中根本就没有对象。只有 CPT 代码组合、提供者和该提供者对应的PaycodeSheet参与创建该评估。如果你把它进一步简化,你会发现这个函数的真正输入是提供者和相关的 CPT 代码(可以通过ClaimEstimator服务查找PaycodeSheet,给定 CPT 代码数组的 CPT 代码组合也可以)。以下示例显示了我们的handle()方法中这一微小但有影响的变化:
class EstimateClaim extends Job extends ShouldQueue
{
// ...
public function handle(Provider $provider, $codes)
{
$cptCodeCombo = CptCodeCombo::fromCodes($codes);
$paycodeSheet = PaycodeSheet::byProvider($provider);
}
}
byProvider()和fromCodes()方法是Model类上的简单方便的方法,但是它们也可以很容易地(尽管不那么优雅)成为一个原始的查询构建器链,甚至是一个对预定义的存储库方法的调用。
我们遗漏了什么?事件!
当在系统中实际创建一个评估时,它的其他部分可能需要知道这已经发生。例如,也许会计部门依靠这些估计来预测未来几个月的销售和费用。还可能有实时报告,其中前端读取预测使用作业队列(如 Laravel)或第三方包来反映后端写入预测。
既然是这样,首先想到的应该是事件!事件正是为了这个目的而产生的:通知事件的任何侦听器应用中发生了有趣的事情,从而使任何侦听组件能够添加额外的逻辑,或者用自定义域或应用逻辑对事件做出反应。它们制作起来非常简单,并且应该只反映正确表达系统内已经执行的特定动作所需的最少量的数据。您可以使用 Artisan 创建一个简单的事件存根,就像我们过去所做的那样。
php artisan make:event \\Claims\\Submission\\Domain\\Events\\Claims\\ClaimWasEstimated
要更深入地了解在 Laravel 中创建事件,尤其是领域事件,请查看第十一章。
结论
我们将提醒自己,我们正在使用一个框架,我们作为开发人员的工作依赖于 Laravel 的流程和助手工具中封装的功能的利用,而不是从零开始并向您展示一个没有外部依赖性的天真解决方案。以下是整个框架中最常用和最受欢迎的工具:
-
集合:就支持集合而言,雄辩和 Laravel 有共同之处,这意味着它们本质上是用相同的低级集合抽象构建的,因此对集合方法的每次调用都返回一个新的集合方法,并预先应用了所需的排列。这使得传递对象集合变得非常简单,并且对我们构建规范非常有用。
-
口才 作用域:作用域基本上只是建立在口才
QueryBuilder类之上的语法糖。它们可以被认为是某种“迷你规范”,因为它们描述了一组特定的数据,要么从零开始,要么从一个预过滤的QueryBuilder对象开始,然后向其添加约束(过滤)逻辑,但是在同一模型的上下文中。其方式是通过跟踪一个QueryBuilder对象中的所有过滤和查询细节。使用QueryBuilder对象包含在数据库中查询模型的标准是有意义的。规范本身将消耗Criteria对象来产生一个结果。 -
口才模型:口才使用的抽象
Model类有大量的功能和特性;我劝你自己去查一下(https://github.com/illuminate/database/blob/master/Eloquent/Model.php)。 -
查询构建器:这是构建雄辩术的底层基础设施。它提供了使用 MySQL 的完整抽象,可以处理复杂的查询而无需编写原始 SQL。然而,对于查询构建器上可用的抽象方法无法完成的情况,您总是可以使用
DB::raw()facade 调用来求助于原始 SQL 查询。 -
助手方法:构建 API 驱动的应用时最广泛使用的助手方法有
event()、dispatch()和app()。请确保尽可能少地使用request()和response()助手方法,因为它们往往意味着代码中缺少对系统中的值对象的正确封装,或者具有动态(错误)响应和请求,而这些响应和请求应该封装到 Laravel 请求或响应中,表示某种处理请求和响应的统一方式,从而促进整个系统的一致性。
十九、结论和其他想法
我们在这本书里已经涵盖了相当多的信息。我试图给你一个关于如何以领域驱动的设计方式开发软件的实用知识,这是用 Laravel 实现的。我向您介绍了许多场景,并讨论了一些可能的解决方案,以及可能的故障点。我在书中展示的所有例子都直接取自我开发网络软件的经历。在这一章中,我将回顾一些我们还没有涉及到的或者我们只是简单地提到过的概念。
在这些感兴趣的领域中,您会发现以下内容:
-
架构:以领域驱动的方式构建系统核心骨干的一些备选策略。我们将使用我从头构建的示例应用作为支持结构来展示一个伪假设场景,该场景与跟踪和管理所有订单、销售、跟踪和事务处理的核心实现部分相关,这些都是常见仓库管理系统的典型流程。
-
拥抱 Laravel :使用定制的
Collections和QueryBuilder对象在域级别利用 Laravel(而不是使用雄辩的作用域将它们“内联”到模型中,这将作用域直接耦合到模型中,并且不可重用)。您可以使用快捷方式在模型中无缝地使用这些东西,但是我们必须确保在使用这种快捷方式时,不要破坏模型本身的完整性,也不要创建模糊基础领域的新模型。
然后,我将分享关于实现域驱动的 Laravel 应用的其他想法。这将引导我们进行一些总结性的思考,总结我们已经能够用 Laravel 和领域驱动设计做什么,以及我们为最初解决领域问题所做的选择和实现如何影响应用的未来构造和重构。
架构考虑
有许多方法可以架构一个系统,但是只有几种方法可以正确地确保域模型正确地反映底层核心域以及其中包含的内容。说起来容易做起来难。
在这一节中,我们将勾画出一个可能的架构,当它被实现时,将满足系统的需求。我们将根据领域驱动的设计概念来确定这个架构的某些方面。我们将定义系统空间,以适当地确定领域的不同方面,我们可以用它来开始拼凑系统的整体架构。我们将关注体系结构和系统组件之间的关系,而不是实际编写底层代码来实现我们设计的设计。具体来说,我们将通过一个示例应用来管理典型仓库的所有方面,包括接受订单、履行订单和装运订单;接收库存商品;以及在典型业务流程的每一步跟踪产品的位置(更多细节即将推出—敬请关注)!
仓库管理系统
在第三章中,我们使用了一个类似于仓库管理系统的东西,只是在一个特定的环境中(一个在线鞋类零售商的仓库)。这个例子将是更高层次的,将涵盖更广泛的领域。它基于我在过去几年中完成的一个项目。这非常相关,从这个例子中可以学到很多东西。
让我们想象一下,你被聘为一个自由项目的首席开发人员,负责构建一个包罗万象的仓库管理系统。这意味着它可以处理仓库所做、销售和跟踪的许多方面(如果不是大部分的话)。
从构成我们系统中每个单独仓库的核心的数据的角度来看,功能必须以这样一种方式存储,即允许跨系统中的所有对象轻松访问它——以便数据可以以一种清晰和可管理的方式跨组件共享。从应用的角度来看,您需要一种方法来将这些功能分解成更小的部分,以便它们可以独立地工作,但它们又以一种内聚的方式结合在一起,无缝地提供应用中丰富的功能集。
您与项目经理会面,检查所有不同的功能组,以正确地定义什么将作为应用的域模型的一部分。在这次会议中,您提出了一个在较高层次上描述的应用要包含的所需功能的列表,看起来像下面的过程:
-
第三方订单管理系统接受订单,这应该在我们这边转化为销售订单
-
完成新订单,从货架上挑选商品到装箱
-
将订单装运给销售订单上列出的买方(从仓库发出)
-
接收来自供应商的进货,包括跟踪库存水平和产品位置管理
为了使项目的需求更加清晰,你提出了如图 19-1 所示的高层图表。
图 19-1
完成仓库管理应用所需的功能集的草图
图中实线代表硬性关系,虚线代表隐含关系。这些是在构建模型设计时需要考虑的高级功能类别。通过进一步的讨论,您能够确定该系统中需要的几个核心上下文,以及描述这些上下文的分解的一些子项。
-
指令结束
-
销售订单
-
库存调整(暂时“锁定”某种产品的数量)
-
挑选,打包
-
-
接收商品(采购商品)
-
采购订单
-
库存调整
-
-
储存商品
-
产品管理
-
仓库位置跟踪
-
储存
-
-
运输商品
-
包装
-
库存调整(“锁”被释放,数量减少)
-
对于该系统的要求,这一点更加具体。三个黑色圆点描述了仓库的主要关注点。订单执行包括在每次销售中创建销售订单的概念(这可以由支付网关的成功交易来确定)。然后完成订单,这包括从货架上挑选商品,包装箱子,并将其运出。还需要有一种方法来跟踪进来的商品,它通过采购订单做到这一点,并且随着我们的进行,必须在主库存清单中进行充分的跟踪和计数。最后要考虑的是如何以提高效率的方式在仓库中正确存储商品,这一过程被称为入库。每个产品都必须被跟踪,在库存中被计算,然后根据一些标准系统放置在存储架上,以便在仓库系统中跟踪它们的移动。
从这个列表中,您可以确定需要存在于系统中的核心上下文,以便在新的领域模型中捕获业务模型。因为您精通领域驱动的设计,所以您知道领域模型的组织结构本身应该尽可能地按照它所代表的真实业务流程来建模。考虑到这一点,您确定该系统的四个核心环境如下:
-
订单(接收)
-
接收(传入)
-
存储(内部)
-
运输(发货/可交付)
Note
可以认为存储上下文在技术上是接收的一部分。我将它作为自己的一部分添加到这里,因为以可管理和直观的方式存储这些项目具有挑战性,并且为了运行可能会包含很大一部分逻辑。
系统的某些关键方面支持前面列出的核心领域。这些方面可以被认为是通用子域,因为它们适用于一个以上的上下文(即,跨组件共享),并且或多或少是先前核心域的支持结构。以下是我们需要在该系统中考虑的一些通用子域的示例:
-
库存管理(由订单、接收和装运上下文使用)
-
产品管理(由所有其他有界上下文使用)
-
位置跟踪(用于存储和接收,以及提货和包装)
-
我们将通过其接收销售订单的外部订单系统
知道了概念的图形化表示如何使它们更容易理解,你很快就能设计出一个图表来显示有界上下文,以及一般子域,以及它们如何适用于每个上下文,如图 19-2 所示。
图 19-2
核心上下文和子域之间关系的分解
我们可以推断出,订单上下文需要了解产品管理和库存管理子域。接收上下文将具有与订单上下文相似的知识(也称为依赖性)。存储上下文依赖于产品管理和位置跟踪子域,而运输上下文依赖于所有三个子域。作为一名出色的首席开发人员,您有一种强烈的愿望,要封装术语及其定义,这些术语及其定义将在以后用作各种组件本身的标识符,通常被称为系统的通用语言。这可以用来帮助阐明有界的上下文以及它们之间的各种交互,并且可以被认为是一种上下文图的“草图”。
保持领域的焦点
我们已经规划了实现管理仓库的应用所需的各个部分,这是一个良好的开端。我们必须继续注意我们与领域中的对象的关联,以确保它们直接来自领域本身,而不是来自对它的假设。确保这种领域相关性的一个明确而可靠的方法是尽可能按字面意义命名相应的组件,并保持每个模型的定义尽可能按字面意义,因为它们是在核心领域中定义的。允许领域驱动架构,而不是假设和拥有不准确的信息。
了解了所有这些之后,你决定实现表 19-1 中列出的命名约定,以解决我们之前已经确定的明显问题。
表 19-1
组成示例仓库应用的命名组件
|
组件名称和上下文
|
组件问题
|
属国
| | --- | --- | --- | | 命令 | 与订单相关的问题,包括库存检查、账单/地址信息、订单行、交易和创建销售订单(针对离开仓库的产品)。 | 产品管理、库存管理 | | 接受 | 与跟踪进入仓库的产品相关的所有逻辑,包括仓库内位置放置、库存调整以及与产品管理组件的交互。 | 产品管理、库存管理、位置跟踪 | | 保管 | 创建和维护某种仓库跟踪系统所需的所有逻辑,以允许快速补货和快速履行订单。 | 产品管理、位置跟踪 | | 船舶 | 订单执行最后阶段涉及的逻辑。包括根据订单细节从货架上挑选产品,将产品包装到盒子中,并打印出运输标签。库存变化也必须考虑在内。 | 产品管理、库存管理、位置跟踪 |
请注意我们是如何命名这些组件的(使用无处不在的语言中标识的术语,理想情况下,我们已经与项目保持同步)。这里的术语与领域中的术语相同,字面上的意思与它们在仓库管理上下文中的意思相同。这种方法就是有意的字面设计。
Tip
为了培养一种有意义的无处不在的语言,你需要与最了解领域的人——领域专家——进行多次讨论。随着项目的进展,随着在领域或领域模型中获得新的洞察力,您可以(并且应该)重构您的术语定义。
至此,可以说,我们已经有了作为应用主干的核心概念。它们是应用提供的功能的主要部分,因此需要额外的支持机制才能发挥作用。这些以跨组件使用的各种专门支持子域的形式出现(因此必须以一种允许轻松集成或将工作委托给系统中其他组件的方式来开发)。因为这些实际上不是常规的子域,而是被认为是一般的子域,所以我们应该始终努力在系统的核心组件和对它们进行操作的设施之间实现松散耦合,同时,促进诸如维护库存水平、更新订单状态或挑选和包装订单以准备发货等操作所需的内聚机制。
我们的系统能够足够灵活地处理组件之间的各种交互(特定领域组件之间的内部对象交互)是至关重要的。我们将很快制定出一个可能的目录结构的粗略草案,但是值得注意的是,将域驱动的架构迁移到更分布式的架构(如微服务)是多么简单,而不是从单一应用开始,然后迁移到微服务甚至六边形架构。这不是不可能的,但是转换本身可能会变得相当复杂,因为概念必须重新思考,界限必须重新划定,关注点需要组合或分离,以便它最有意义。
命名空间和目录
我们需要做的最后一个分解来分离应用的关注点可能是所有分解中最重要的一个:在模块级别上的领域分解,它直接对应于(尽可能字面上)底层业务领域中存在的概念、关系和结构。现在我们已经正确定义了有界上下文,我们可以决定将域分成哪些模块(包括它的目录和名称空间结构)。一般来说,但不总是这样,最好的做法是将每个子域与一个域模块对齐,并且该模块应该直接从项目的通用语言中命名。
从我们目前的角度来看(还没有考虑基础设施),我们将创建App\和Domain\名称空间,它们将作为系统的最高级别父类。App\是我们放置促进和指导领域层所需的逻辑的地方。然而,现在让我们关注领域层本身。
我们首先根据之前确定的有界上下文创建子名称空间(记住,我们还必须修改我们的composer.json文件来添加新的Domain根名称空间)。
在表 19-2 中列出的结构中,我们可以将每个父名称空间的内容视为一个容器,其中包含了特定于其相应有界上下文的所有内容(即,创建新销售订单所涉及的逻辑将发生在Order模块中,而将产品放在货架上以便稍后可以快速找到的动作存在于Storage模块中)。
表 19-2
初始名称空间/目录结构
|
命名空间
|
目录
|
组件
|
| --- | --- | --- |
| Domain\Receiving(或Receivements) | /src/Warehouse/Domain/Receiving(或Receivements) | Receiving(来话) |
| Domain\Ordering(或Orders) | /src/Warehouse/Domain/Ordering ( Orders) | Orders(来话) |
| Domain\Storing(或Storage) | /src/Warehouse/Domain/Storage(或Storage) | Storage(内部) |
| Domain\Shipping(或Shipments) | /src/Warehouse/Domain/Shipping/(或Shipments) | Shipping(传出) |
那么,对于我们的系统运行所依赖的通用子域,我们应该把它们放在哪里呢?嗯,它们仍然是域的一部分,所以我们可以将它们保留在Domain\ root名称空间中,或者由组件类型分隔,或者在一个附加层中(带有类似于Domain\Support\的前缀)。
到目前为止,应用的一种可能的名称空间结构如下:
Warehouse
└── Domain
├── Ordering
├── Receiving
├── Shipping
├── Storing
└── Support
├── InventoryManager
├── LocationManager
└── ProductManager
在这个结构中,我们有一个清晰的父名称空间集合,它对应于底层业务的结构。在任何给定的时间,都有一种简单的方法来验证您在规划领域层的轮廓时是否处于正确的道路上。如果您的域模型的原始设计与底层业务结构在接近文字的层次上相关,并且您选择的名称直接来自系统中无处不在的语言,那么您就在正确的轨道上。
因为 Laravel 以某种方式运行(通过将应用的不同部分组织成各种组件,这些组件允许域层与 Laravel 的默认机制提供的其余功能集成),所以我们将坚持使用应用和框架中使用的默认组件。这包括作业(可以分派队列并异步运行)、策略(通过管理谁可以查看、修改或创建模型实例来保护模型的安全性)、雄辩模型(封装模型的特定行为和属性)以及 Laravel 附带的各种其他组件。然而,我们将只使用任何特定的组件,如果我们首先确定它是必要的。我建议您不要为应用的域层创建任何类型的默认目录/名称空间结构,原因有很多(主要的一个原因是遵守 YAGNI——您可能不需要它)。向项目中添加并非 100%必要的类和接口会适得其反,因为这会向应用添加需要维护和保持最新的代码行。项目目录中额外的文件和文件夹只会增加应用的整体复杂性,所以尽量避免将任何不需要的东西放在项目目录中。
查看图层
在我们深入探讨我们的选择之前,让我们花几分钟回顾一下架构的不同层,以及每一层应该属于哪种类型的逻辑。图 19-3 显示了这些层在我们基于 DDD 的应用中的分解。
图 19-3
应用的每一层及其各自的关注点
图 19-3 根据组成应用的层以及每层的主要关注点(焦点)对应用进行了分解。这里没有什么新的东西,但是请注意,concertive 与应用的每个中心层一起运行,可以被认为是它自己的独立层(而以前您可能认为 concertive 中的功能位于基础结构层中)。
绘制架构图
现在,我们将开始规划我们之前确定的每个模块需要的东西。例如,我们可以假设(但实际上不会为其创建代码,除非后来认为有必要)大多数模块至少需要以下内容:
-
Models/:领域模型(业务对象)-
实体
-
价值对象
-
-
Repositories/:访问这些型号的/ Management -
Factories/:抽象更复杂的领域对象的构建的逻辑位置 -
Aggregates/:作为应用中独立单元的实体和值对象的组合位置 -
Services/:作为业务逻辑的一部分出现并且不能包含在实体或值对象中的功能和过程
图 19-4 用图形表示了这一点。
图 19-4
应用及其层的可视化表示,以及我们可能在每个层中找到的一些通用组件
图 19-4 显示了应用的高层次视图,其中涉及的各个层被分成各自的泳道。图中不带阴影的项目表示接口或抽象,带阴影的项目表示此类接口的实现。我将中间件作为一个特殊的项目,因为它本身不是一个类,而是一个更底层的概念。从顶部开始,接口层将支持进入我们系统的各种类型的请求,无论是 API 请求还是来自浏览器的请求。我们将很快更深入地讨论接口层和应用层。
在很大程度上,映射关注于系统的领域层方面,因此,它指出了在领域层中的单个模块的上下文中可能会找到哪些类型的对象。通常情况下,我们需要模型作为我们的实体和集合的基础(以便利用雄辩提供的特性),价值对象和集合形成业务领域中存在的基本对象,以及用于对不符合实体或价值对象的正常形状的流程或过程建模的服务。
因为我们正在使用 Laravel 构建我们的应用,所以将这样的服务建模为 Laravel 作业或实现Queueable和Dispatchable契约的其他类型的域级结构是有意义的,这样它们就可以被推到异步队列中,这通常可以在整个应用中提供高级别的响应。在 Laravel 中最简单的方法是创建一个实现ShouldQueue, InteractsWithQueue和/或SerializesModels特征的Job类(或Command类),然后确保您的配置设置正确,以支持众多受支持队列中的一个,然后噗一声,您就可以开始了。
Tip
当构建作业来封装系统中的业务逻辑时,请记住,一旦它们被推送到异步堆栈上,它们将不会返回任何值。这意味着,在通过 worker 队列运行的作业或命令的上下文中,对实体所做的任何更改只有在从数据库刷新模型属性时才能检测到。这也意味着作业运行所需的任何数据或依赖项都必须在其构造函数中传递,以便在特定于作业的逻辑运行之前获得这些数据或依赖项。
同样在图 19-4 中,请注意Repository组件被放置在域和基础设施层之间,由实现而不是接口来表示(如阴影区域所示)。这样做的原因是,在一个典型的项目中,您将在应用的领域层中定义给定存储库的接口,可以将其命名为类似于Warehouse\Domain\Orders\Contracts\OrderRepositoryInterface的名称。
这将由基础设施层中的一个类来实现,可能叫做Warehouse\Infrastructure\Orders\Repositories\InMemoryOrderRepository。
可以说,在领域层中定义的存储库接口的实现实际上应该位于应用层而不是基础设施层中,因为存储库本身与模型有着密切的联系,所以它可以被视为一个应用服务(位于应用层中),同时也可能属于领域层之外。这也是为什么抽象的Repository接口是在领域层中定义的。(这取决于你,可能不值得花很多时间讨论。)
在图的底部,我在图 19-4 中包含的位于基础设施层的四个文件夹是为了表示保存应用运行所需的重要数据的各个位置。之所以将它们列在基础设施层的上下文中,是因为我们选择了 Laravel 作为构建系统其余部分的底层框架。大多数情况下,这些文件夹存储应用和框架的各个部分使用的某种类型的缓存数据。
请记住,图 19-4 是给你一个可能的架构,你可以用它来构建你的应用。遵循软件的原则和最佳实践是可以的(也是推荐的),软件可以作为一种架构组合在一起。然而,我们想要的是更深思熟虑的东西。让我们看看我们的仓库应用如何适应这个体系结构,以及需要对体系结构进行哪些修改,以允许域作为系统中的底层成分充分发展,这只能通过让域“驱动”来实现。我们将只关注系统中的一个特定模块,即Order模块。图 19-5 展示了一个例子。
图 19-5
使用我们之前设计的松散耦合设计的应用模块示例。请注意,所有依赖项都指向域层内部,并且在每个边界交叉处都有接口
Caution
图表很棒,UML 可以用一种易读的格式传递大量信息。然而,不要犯试图绘制系统中所有事物的图表的错误。你将会被保持所有图表最新所需的维护费用所淹没。如果它们不是最新的,那么它们对我们就没用了。将图表作为工具箱中的一项,你可以用它来灌输更高级或更复杂的问题和过程。抵制把所有东西都画出来的冲动。
在图 19-5 中,我们有一个关于Orders领域模块的仓库管理应用架构的概要。从(INT = interface)的顶部开始,我们已经完全折叠了接口层,并且有了一些应用级别的服务,以便于处理域层和应用层之间存在的应用问题。
如果没记错的话,应用层是直接处理域层中的元素的。例如,应用服务通常是域模型对象的直接客户。在图 19-5 中,我们可以将任何利用域对象来完成某些任务的控制器和命令视为应用层的一部分。如果您注意到了,我们仍然遵守依赖规则,并且所有表示依赖的箭头都指向领域层,而不是远离领域层。
这些命令(或控制器)方法很可能会分派任何作业和服务,或者将需要运行的任何其他逻辑委托给其他组件。通过这些构造,外部世界可以与我们的应用内部进行通信。我们希望在应用中实现关注点的强分离(根据Warehouse域),同时保持这些不同部分的一致(和松散)耦合。实现这一点的更好的方法是将充分描述业务级概念的接口放在一个不同层中的另一个类可以实现的方式中?在这样做的过程中,我们实现了高度的分离,因为我们将业务逻辑本身作为一个抽象,在域模型中表示业务中的一个概念。
在图 19-5 中,你会看到这种模式的几个例子。例如,域层中的OrderRepository框是一个接口,其中有一组声明的方法,任何实现类都必须为这些方法提供定义。这些方法将与Order模型相关,可能包括如下功能:
-
getLineItems(int $orderId): array -
getOrdersByCustomer(int $customerId): Customer -
getAverageOrderTotalBetween(string $date1, string $date2) -
: int
因为这些接口概括了领域中的正式流程,所以我们可以将这样的接口放在领域层中,并在不同的层中实现接口。在OrderRepository的例子中,我们在我们的领域层中为它定义了契约,而实现存在于基础设施层中(如前所示,EloquentOrderRepository)。通过遵循将业务和领域概念或过程开发为一组可由任意数量的客户端实现的一致接口的实践,每个客户端都将拥有自己的定制逻辑或关注点,我们的设计支持开箱即用,因为我们可以保证该给定接口的所有客户端(例如OrderRepository接口的EloquentOrderRepository实现)都将拥有在其中定义的指定方法。这是基本的面向对象编程。
图 19-5 中最后要注意的是集合的位置和它所连接的相应元素。众所周知,在领域驱动的设计中,聚合代表了一种它们自己和边界内的类之间的边界线。这增加了模型的复杂性,但通常比试图在没有聚集根的情况下建模要简单得多。由于复杂性的增加,聚合通常没有任何内置逻辑来保存或从数据存储中检索这些对象。相反,一个集合的“构建”方面通常被放置在一个专用的工厂类中,如图 19-5 所示。
Tip
图 19-5 中的Factory接口位于领域层,其实现位于应用层。我这样做的原因是因为应用层中的类和对象是域对象本身的直接客户,而一个工厂存在的唯一目的就是把一个单一的集合对象放在一起,这当然属于这一类。在现实世界中,您可能会发现存在于基础结构层而不是应用层的实现。这是偏好使然,没太大关系,只要你始终如一,坚持自己的决定。
典型的工厂类应该通过尊重集合的边界来重组集合。工厂实例化聚合的所有必需部分(位于聚合根的边界线内的对象),并且,由于没有更好的词,将其打包成所请求的聚合对象并返回它。通过这种方式,我们基本上已经将数据库中的数据转换成了一个成熟的 PHP 对象,我们可以在应用中使用它,这样我们就可以以面向对象的方式与它们进行交互。然后,我们剩下的关注点是从数据库中保存和检索聚合数据,这最好留给存储库(我们在本书的整个过程中已经非常深入地讨论过了)。
进入应用层
从 DDD 的角度来看,为系统中存在的每个域层模块包含一个应用层模块是很常见的。对于前面的例子,我们可以在Application名称空间下创建一个名称空间,其名称与其对应的域层名称相同:Orders。
在图 19-6 中,我们设计了一个我们的系统将基于的架构,考虑到我们利用 Laravel 框架作为系统的主干,同时仍然保持系统最重要的规则不变(也就是说,允许领域本身驱动应用的开发)。该图略有不同,因为它以比用 DDD 创建的传统架构更直接的方式包含了框架。
图 19-6
我们的仓库项目的应用层的焦点透视。基础设施层未示出
就像我们有一个名为Orders的专用域模块一样,我们也应该在应用层提供一个匹配的模块。正如您所看到的,Laravel 包含的或多或少的“标准”组件显示为在实际的Order模块的外部(也在基础设施层内,这里没有显示)。更有可能的是,我们将需要一个控制器来处理传入的请求,从而在系统中创建、选择、更新或删除订单。还包括雄辩的知识库(它可以更好地位于基础设施层,在图 19-6 中没有显示),其实现最终位于领域层,以及任何第三方系统,我们可以利用它来支持应用的各个方面。
例如,团队可能决定不希望从内部管理应用的身份验证,而是希望将登录和创建新用户的过程委托给其他人。假设我们认为 SaaS 是最好的选择。在网上做了一些研究后,我们发现了一个叫做 Auth0 的东西,这是一个完全集成的系统,以一种清晰的方式处理用户的所有方面(或只是登录部分),支持最先进的静态加密技术,因此我们可以放心(没有双关语)用户的数据在任何时候都尽可能安全。为了将这个服务构建到我们的系统中,我们需要修改默认的LoginController来利用一个定制的存储库类(可能是一个Auth0Repository?)封装了与 Auth0 后端集成所需的逻辑(通过 API)。一个额外的要求是,您必须在LoginController上包含一个方法,该方法将充当“监听器”类型的回调,以便 Auth0 在认证完成后(无论是通过还是失败)进行调用。在这种情况下,Auth0Repository最好位于应用层,因为它是一个应用问题。然而,如果我们正在处理一个存储库,它管理一个聚集对象的保存/检索,那么实现它的合适位置应该是在基础设施层,我们将在下面强调这一点。
基础设施层
最后但同样重要的是,我们有基础设施层,它包含了大部分的 Laravel 默认对象、配置和其他类似的对象,我们称之为基础设施层。正如我们对应用和域层所做的那样,我们将在基础架构层内创建一个名为Orders的新名称空间,它将容纳任何不符合应用或域层要求的订单相关代码。参见图 19-7 中的示例。
图 19-7
仓库应用突出显示的基础结构层
基础设施层非常简单,但是它会随着我们添加功能而增长。如前所述,它还可能包括存储库,这些存储库封装了特定模型的存储/检索知识,或者在我们的领域层中的集合。像往常一样,我们将为对象创建一个接口。假设我们(出于某种原因)需要一个只处理内存(RAM)中的数据和对象的存储库。我们可以使用一个InMemoryOrderRepository,它将实现位于域层的OrderRepository接口。实现本身将存在于接口层中,如图 19-7 所示。你能注意到图 19-7 有什么奇怪的地方吗?如果没有,图 19-8 给出了提示。
图 19-8
图 19-8 中描述的架构的问题区域
域层中有一些元素对基础结构中的另一个元素有指向外的依赖关系,如果我们记得域逻辑位于何处(朝向中间)以及依赖关系应该以哪种方式指向(向内)域层,这应该会让您觉得有问题。让我们通过查看图 19-9 来重温这个概念。
图 19-9
分层体系结构中不同层的复习
因为我们已经选择了 Web 作为编程表达的媒介和画布,在其上以漂亮的代码形式巧妙地描绘艺术,这些代码代表了背后的科学,所以必须考虑实现标准的领域驱动设计。在这种情况下,DDD 的某些方面,如依赖于自身以外的任何东西的域层,通常在 DDD 的上下文中是严格禁止的,但在世界上最强大和最受欢迎的框架之一中似乎在一定程度上是可以接受的。
如果我们观察 Laravel 的构造方式,我们可以看到,例如,Model类扩展了基础Model类,从技术上讲,基础Model类位于基础设施层(位于/vendor目录中)或者可以在它自己的层内(见图 19-4 ),但是无论哪种方式,领域层在技术上仍然依赖于它自身之外的东西。我们如何着手提供满足依赖规则的解决方案?
如果你是从“颠倒依赖关系”的角度来思考问题的,那么你是对的!我们可以做的是在基础设施层中创建一个抽象类来实现一个接口,从而将依赖关系的方向颠倒过来,指向中间的域层。参见图 19-10 中的示例。
图 19-10
反转模型类的依赖关系
拥抱拉勒维尔
对于本章的最后一节,我们将使用以前的示例(如医疗索赔应用)作为参考点,来讨论这些总结主题。
将对象作为一个整体来验证
另一方面,如果您需要额外的定制逻辑作为验证过程的一部分运行(这正是我们在 Claim Validator 中所需要的),那么验证对象(或集合)组合的上下文可以通过实现一个定制的Validation服务来解决,该服务将利用我们刚刚创建的可重用的Validator组件。然后,为了让应用中的其他组件能够轻松访问它,您可以使用 Laravel 的服务容器将别名绑定到您的服务。
为对象组合验证创建验证器服务
要创建一个服务,您需要考虑几件事情,建模和架构师。
-
服务真的需要吗?某种价值对象或实体就足够了吗?
→如果您正在构建的东西不适合某个实体或对象的上下文,服务可能是一个不错的选择。
-
这项服务到底要完成什么?
→当您清楚地确定服务的目标以及它应该为整个应用完成什么时,它有助于缩小范围。
-
服务完成工作需要哪些组件、类、服务、对象、实体和数据?
→定义服务范围之外的代码的任何关系、依赖性或其他关联是有益的。
-
这项服务在哪里最好?在什么上下文或模块中?
→除了确定文件的物理放置位置,它还通过定义服务将位于哪个层来帮助为服务提供上下文。
-
如何测试服务?
→始终致力于创建单元测试和(尽可能)完整的自动化测试套件。
对于我们关于声明验证的特定上下文,我们将假设由于其复杂性而需要该服务,尽管实际上这可能是多余的(步骤 1)。该服务将处理 CPT 代码组合的验证,验证该组合是否可供提供者在其 paycode 表上使用,建立与索赔一起提交的正确文档,并验证患者是否有资格接受护理(步骤 2)。我们将需要访问索赔本身,以及涉及索赔的任何内容(属性和关系),以及 CPT 代码模型、工资代码表(我们仍需定义)和患者资格服务,我们不会构建这些服务,只是验证它是否已为索赔中的患者运行(步骤 3)。一般来说,验证是一个基础设施问题,因为它不一定与领域本身有任何关系,而是与确保给定的模型(在我们的例子中,是Claim模型)处于有效状态有关(步骤 4)。这显然是一个很好的关注点分离,所以它属于基础设施层。就测试而言,一个简单的单元测试覆盖服务中涉及的大部分代码就足够了(步骤 5)。
现在我们对实际构建的内容有了更多的了解,我们可以开始创建一个名为ClaimsValidationService.php的新文件,该文件位于与其有界上下文和模块相对应的名称空间内。该服务将处理任何复杂的验证逻辑,以验证声明的其余部分。此时,大部分验证需要在我们已经通过实现我们在ClaimSubmissionRequest类中指定的规则处理过的索赔提交上运行。我们还没有涉及的一个问题是验证给定的 CPT 代码组合是否存在,并且是否在提供商的 paycode 表中列出。
在我们的服务中,我们可以使用本章中定义的Validation类来构建验证器。我们甚至可以使用我们之前构建的验证器来处理 CPT 代码组合和 paycode 表的另一个验证;然而,我选择使用服务来建模,这样您就可以看到当存在复杂的结构和/或跨越各种检查的多个前置条件/后置条件时如何进行验证,以确保一致性。像这样复杂的验证应该属于专门的服务。为了避免创建低级组件来正确连接这样的服务(即,具有正确的依赖关系并位于正确的上下文/模块中)的麻烦,我们可以转而依赖 Laravel 的Validation组件,甚至对其进行扩展以更好地满足我们的需求。因此,我们将利用 Laravel 的Validation组件。
论作为不一致实践的 Web 开发…
至少可以说,我承认 web 开发是不稳定的。真的没有很多共享资源,尤其是在过去。承认,随着软件工程的原则和实践慢慢进入 web 开发社区,在过去的几十年里事情已经变得更好了。虽然这是真的,但现实是 HTTP 还是一样的。web 编程语言(如 PHP)所基于的底层技术是一种不稳定的底层架构,它源于世界上最古老的协议之一:客户机-服务器模型。增加了会话来缓解这个问题,HTTP 2.0 提供了对原始实现的急需的重构;然而,在我看来,会话只是解决页面跳转之间共享状态的(更大的)问题的权宜之计,HTTP 2.0 并没有非常广泛,也没有像大家想象的那样流行。在全球范围内采用 HTTP 2.0 规范是肯定会发生的,但就目前情况来看,这是一条缓慢的道路。
最重要的是,坚持标准和最佳实践
随着时间的推移,随着应用复杂性的增加,无论使用哪种框架,维护大规模的代码库都变得越来越困难。分离关注点变得更具挑战性,因为要在代码中的逻辑段之间画出分界线。永远记住让领域成为应用开发的驱动力,并努力按照为该上下文设置的无处不在的语言来命名事物。尽可能准确地表达领域。
在本书的前面,我提出了这样一个论点,即除非您计划拥有多个数据库平台,并且您必须在任何给定的时间保持对所有这些平台的访问,否则并不真正需要存储库。不属于这种情况的一个实例是指定由标准描述的特定模型的合格对象集,该标准针对该模型的数据库数据采用条件和约束。在这里,存储库作为一种抽象描述标准本身的方式是有用的。
说所有这些是一回事,但让我们围绕根据标准描述数据集的想法来考虑一些背景。因为我们都知道接口是封装变更及其各种实现的具体细节的方式,所以我们决定最好首先使用一组接口来描述标准的整个概念(这对于在代码中记录想法非常有用,使新来者更容易快速理解契约是什么以及它的用途)。让我们为“标准”的概念建立一个基本的接口,我们可以用它来过滤和约束我们的数据。我们将使用我们的好朋友索赔处理应用作为清单 19-1 中的底层平台。
<?php
namespace Claim\Submission\Domain\Contracts;
interface CriteriaHandler
{
/** Skip any applied criteria during processing */
public function skipCriteria(bool $status=true): Criteria;
/** Return the currently configured criteria */
public function getCriteria() : array;
/** Immediately run the passed in criteria and return results */
public function getByCriteria(Criteria $criteria): array;
/** Add some criteria to the set of criteria to be applied */
public function pushCriteria() : array;
/** Apply any pushed criteria */
public function applyCriteria();
}
Listing 19-1Example Interface to Manage Criteria
在第一个清单中,我们有一些实现Criteria接口所需的方法,包括跳过当前迭代的任何推送标准的功能,将一个或多个标准对象添加到堆栈中,并添加一个助手方法来立即运行传递到getByCriteria()方法中的标准并返回结果(不处理堆栈)。还有一种方法可以一次运行整个标准堆栈(已经被推入堆栈的内容),applyCriteria。这很好地描述了成为一个支持Criteria的对象对我们的应用意味着什么。记下责任。
因为我们希望在我们的标准实现中包含一组丰富的功能,并且因为除了方法和签名之外,我们不能在接口中放置任何东西,所以我们可以创建一个抽象类来容纳跨子类的公共功能。无论如何,不要在需要附加到存储库实现的另一组类(或特征)中这样做,为了类类型的保存,我们可以做的是在基本存储库类中实现Criteria接口。接下来就是创建一个子类,为任何这样的模型扩展这个基本存储库,我们希望能够通过指定Criteria来查询该模型下的数据。在我们这样做之前,让我们确保在我们的系统中为一个基本的Repository类定义一个接口。为了明确标准存储库在一般意义上应该具有的功能,我们将创建接口,就好像我们在它的Model类的每一个实例上都没有口才一样(这意味着我们创建存储库来容纳底层模型被查询的方式)。为了简洁起见,我将省略清单 19-2 中的 PHP 文档块。
<?php
namespace Claim\Submission\Domain\Contracts;
interface Repository
{
public function all(array $columns = ['*']);
public function paginate(int $perpage=1, array $columns = ['*']);
public function find(int $id, array $columns);
public function findBy(string $field, $value, $columns=["*"]);
public function findAllBy(string $field, $value, $columns=["*"]);
public function findWhere(string $where, columns=[“*”]);
public function findOrFail(int $id, $columns=[“*”];
}
Listing 19-2A Description of a Repository Object Without the Functionality of Eloquentat Is Provided in All Models
这个接口有点长,但是它包含了关于模型数据的所有基本需求,包括创建、读取、更新和删除(CRUD)等功能。如果我们把雄辩的 ORM 从等式中去掉,那么我们就需要以一种方式管理模型的数据,这种方式可以很容易地重复,并且可以在代码库的其余部分中任何需要的地方使用。这正是所列出的接口在存储库类的封装中所提供的。
还要注意我们在接口中定义这些方法的方式。这样做的方式是利用原始值,并且不包括对系统中任何其他类的引用。这是理想的,因为依赖关系越少,问题就越少;然而,这并不总是一个可行的解决方案。有时,事物只能存在于另一个已定义的类或接口的上下文中,并且必须包含在新接口的定义中。在定义接口时,尽可能坚持原始值,就像我们在清单 19-3 中所做的那样。
<?php
namespace Claim\Submission\Domain\Repository;
use Claim\Submission\Domain\Contracts\Repository as
RepositoryInterface;
use Claim\Submission\Domain\Contracts\CriteriaHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Container\Container as App;
use Illuminate\Http\Exception\HttpResponseException;
abstract class BaseRepository implements RepositoryInterface,
CriteriaHandler
{
/** Specify the underlying model class */
abstract public function model(): Model;
/** Service Container */
private App $app;
/** The underlying model class name*/
protected string $model;
/** The current stack of criteria */
protected Collection $criteria;
/** Switch to skip criteria */
protected bool $skipCriteria = false;
/** Prevent overwriting same criteria in stack */
protected bool $preventCriteriaOverwriting = true;
public function __construct(App $app, Collection $collection)
{
$this->app = $app;
$this->criteria = $collection;
$this->resetScope();
$this->makeModel();
}
public function all(array $columns = [“*”])
{
$this->applyCriteria();
return $this->model->get($columns);
}
public function query()
{
return $this->model;
}
public function find($id, $columns=[“*”])
{
$this->applyCriteria();
return $this->model->findOrFail($id, $columns);
}
public function findBy($attribute, $value, $columns=[“*”])
{
$this->applyCriteria();
return $this->model->where($attribute, ‘=’,
$value)->first($columns);
}
public function (Criteria $criteria)
{
$this->model = $criteria->apply($this->model, $this);
return $this;
}
public function applyCriteria()
{
if ($this->skipCriteria === true) {
return $this;
}
foreach ($this->getCriteria() as $criteria) {
if ($criteria instanceof Criteria) {
$this->model = $criteria->apply(
$this->model, $this);
}
}
return $this;
}
}
Listing 19-3An Implementation of a Repository as Defined by the Repository and Criteria Interfaces
在清单 19-3 中,我们在一个抽象类中有一个Repository和Criteria接口的通用实现。让我们通过查看图 19-11 来快速查看一下我们目前定义的结构。
图 19-11
当前实现的 UML
图 19-11 描述了一个抽象类实现多个契约(接口)的典型场景。该类不能被直接实例化,而是意味着从扩展而来,以便子类能够“储备”您在抽象类中提供的一组默认功能。现在,根据我们当时的需求,我们可以选择通过为每个接口实现独立的子类来进一步分离关注点,如图 19-12 所示。
图 19-12
我们的存储库和标准接口的更加复杂和灵活的版本
我们现在对整个对象模式有了一个额外的深度层,因为它获取的知识不仅是我们正在构建的东西的特定知识,更重要的是,是领域的特定知识。这样,当与一个Criteria对象结合使用时,我们已经表达了一个存储库的能力,并且我们已经在域的层次上这样做了。这种方法可能是有效的,但是作为一种折衷,可能会导致需要维护的额外类的激增,并且很可能为需要这种能力的每个域对象复制这些额外的类。通过将子类的创建限制在严格的按需基础上,可以避免这种情况,并且最终可以得到一些潜在可重用组件的丰富实现。这就是游戏的名字!尽可能地干燥,这与可重用代码的概念是一致的,并且当您在系统中添加这种严格的关注点分离时,它会变得更加强大。假设应用确实需要这些功能来完成它的工作(并且您或您的团队没有通过定义这种超详细、细粒度的微对象来过度设计它),请确保它们不会以一组命名很长的接口和抽象结束,这些接口和抽象没有真正地将组件作为一个整体来传达,也没有提供它存在的任何理由,即它解决它想要解决的问题的能力。
就代码而言,从清单 19-1 到 19-2 的变化将是最小的,并且只要您编码到一个接口,而不是一个实现,使用这些接口的应用的任何其他部分(通常是应用或域服务)将不必修改。
Tip
值得以更独特的形式重复:
代码到接口,而不是实现。
为了实现这一点,您应该正确地键入任何消费代码,这些代码依赖于接口本身的任何实现的功能。只要领域被清楚地定义,并且你依赖它来指导你在这个和所有其他应用问题上的决定,这应该是自然而然的事情。
这就是对接口而不是对实现进行编码的好处:它允许更大的灵活性,并且增加了在应用的其他地方重用的机会(但这并不能保证)。接口本身可以由无限数量的子类来实现,每个子类指定更细粒度的细节。这是有道理的,因为使用这样一个抽象结构和明确的关注点分离的整个概念是能够定义一个接口的特定实例(也就是适配器),这些实例描述了对象的“模式”(如果你愿意的话)中的特定规则,而这又是我们在接口(也就是端口)中定义的。这使我们能够以一种从一般到特殊的方式创建对象,对象的粒度封装在实现类的范围内。
关于这些图,最后要注意的一点是为什么它们是这样分开的。在定义包含在代码中的功能的概念线和边界的计算中,有哪些元素通过文件系统中的名称空间和物理位置进行划分?
Tip
简而言之,当定义分离应用关注点的界线时,总是尝试在域之后对它们建模,并根据它们的变化率对它们进行分组。以相同频率变化的物体应该放在一起。
如果我们试图将在同一类、模块或接口中以不同速率变化的事物与以该速率变化的其他事物保持在一起,这将在我们修改组件的任何部分或其结构时得到回报,因为它对依赖于它的代码几乎没有影响(如果有的话)。
结束语
领域驱动的设计是解决现代商业问题的实用方法,因为它保持了对领域的关注。在 web 应用世界中,当领域代码达到一定的复杂程度时,几乎总是会使用框架。这有多种原因,包括不必为应用层包含的每个机制重新发明轮子的好处。通过利用 Laravel 作为实现领域驱动设计的一种手段,我们可以设计出满足项目需求的应用,并确保我们的应用的生命周期。当我们热衷于遵循软件开发中的最佳实践和现代标准时,我们发现应用的维护变得更加简单,重构也是如此。扩展应用的功能可以相对容易地实现,并且可以以领域模块的形式出现,这使得根据领域本身封装功能逻辑(例如,Order模块、Ship模块等)变得容易。,来自我们本章开头的仓库应用)。
继续读。继续编码。继续想。保持一致。知识就是力量,缺乏知识就是无知。不要做后者。