五、高级 Laravel
在这一章中,我们将探索 Laravel 的一些更高级的主题。我们将关注 Laravel 的特性、工具和快捷方式,它们与本书中使用的示例和支持代码直接相关。这并不意味着对 Laravel 的每一个特性都有一个深入的、包罗万象的解释,当你需要澄清 Laravel 的任何东西时,你应该直接参考 Laravel 的文档。
出于我们的目的,我们将关注以下主题:
-
Laravel 应用的周期(流程)
-
服务提供商
-
服务容器和
$app
变量 -
队列和 Laravel 作业
-
契约
-
事件
在您对 Laravel 中的一些更高级的特性有了更好的理解之后,我们将在本章结束时把所有的主题联系起来,讨论一些高级的方法来构建不同的部分以满足我们的应用的需求。Laravel 中的工具可供您随意使用。我们希望最终实现的不仅仅是拥有一组可供我们使用的工具,而是以一种允许我们为需要运行以满足客户请求的各种命令和服务形成一个可靠的开发管道的方式来定位这些工具。我们只需要根据领域驱动的方法来使用它们,以打造一个坚实的、可重用的基础,我们可以在以后再次推动额外的需求。可以将这个管道想象成一组可重复的步骤,这些步骤实现了一些与领域相关的任务或过程,这些任务或过程可以重复无数次,以实现类似的结构,但支持完全不同的指令。它们并不是一成不变的,但是当您从事使用 Laravel 编写的真实项目时,它们应该会为您提供足够的指导。
Laravel 应用的周期
Laravel 通过在框架引导期间发生的一系列操作以可预测的方式运行。所有传入的请求首先命中/public/index.php
文件(有时称为前端控制器,它加载 Composer 自动加载器(/vendor/autoload.php
) ,然后从bootstrap/app.php
文件加载应用。该文件采取的第一个动作是创建一个Application
实例(服务容器的一个实例)。看起来是这样的:
// ddl/app/bootstrap.php
$app = new Illuminate\Foundation\Application(
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
);
这段代码创建了 main Application
类的一个新实例,它接受 Laravel 应用的位置作为参数。这可以在.env
文件中配置,该文件保存了整个应用中使用的主要配置值。这些值将被 Laravel 获取并注入到应用的各个部分。在前面的例子中,如果$_ENV['APP_BASE_PATH']
没有被定义(即不在.env
文件中),它将默认为当前目录。
根据请求的类型(是来自普通浏览器还是通过控制台命令),Laravel 将分别利用app\Http\Kernel
类或Console/Kernel
类。无论使用哪个Kernel
类,Laravel 都会将传入的请求传递给内核,然后内核会为特定环境加载适当的配置和设置。内核定义了应用中使用的所有中间件,并将它们注册在框架的Application
对象中的一种类似堆栈的结构中。Laravel 中的中间件层很重要,因为它根据流程中使用的Kernel
类的类型和进入应用的请求类型,为应用提供了被请求的特定环境。有两种主要类型的请求:web 请求(基于浏览器)和 API 请求(基于 HTTP 动词)。
Laravel 中的中间件负责处理各种任务,并设置 Laravel 的一些基本功能。这些包括但不限于以下内容:
Web 请求中间件
-
会话设置
-
Cookie 加密
-
CSRF 保护
-
证明
API 请求中间件
- 节流请求
路由中间件
-
缓存头
-
URL 签名验证
-
批准
路由中间件可以附加到路由文件中定义的单个路由或路由组(主要是/routes/app.php
和/routes/web.php
)。中间件还负责确定应用是否处于维护模式,如果是,则将用户重定向到临时维护页面。
所有这些中的关键角色是Application
内核。在高层次上,内核的handle()
负责两件事:
-
接收请求
-
返回响应
内核是现存的几乎所有主要 PHP web 应用框架的基础,实际上最初是由 Symfony 框架开发的,也是为 Symfony 框架开发的。在决定使用哪个内核之后,还要执行几个额外的引导任务,包括读取环境变量、加载在/config
文件夹中定义的配置、注册应用外观以及引导服务提供者。
服务提供者在config/app.php
文件中指定,并由应用通过分别运行每个提供者的register()
方法和每个提供者的boot()
方法来加载。我们稍后将更深入地讨论服务提供商。加载完所有服务提供程序后,请求将被发送到路由器,路由器将根据各自的 routes 文件中的配置进行调度。路由器接受请求,并将其转发给指定的控制器进行处理,或者甚至可以在路由定义内内联处理请求的内容。我们在前一章中介绍了一些路由原则,但只是重述一下,路由将请求转发给指定的控制器进行处理和操作,然后返回某种响应(无论是响应 API 调用的 JSON 对象还是显示在浏览器上的完整网页)。
服务提供商
服务提供者拥有框架的所有主要特性,可以说是框架中最重要的方面。默认提供程序位于app/Providers
目录中。下面是 Laravel 文档中关于服务提供者的描述:
“服务提供者负责引导所有框架的各种组件,比如数据库、队列、验证和路由组件。因为他们引导和配置框架提供的每一个特性,所以服务提供商是整个 Laravel 引导过程中最重要的方面。”
—拉勒维尔文档
从这句话中,我们可以得出结论,服务提供者是定义功能配置细节的特定部分的手段,以便它可以被框架拾取和识别,然后通过自动加载或服务容器绑定的方式供应用的其余部分使用。您可以将服务提供者视为将应用结合在一起的粘合剂,并允许对框架和应用进行扩展和添加。框架本身也利用它们来注册较低级别的组件,这些组件构成了 Laravel 提供的特性集。一些服务提供者被设置成只在需要的时候才被加载(称为延迟加载或延迟加载)。
每个服务提供者包含两个主要的方法:register()
和boot()
。
寄存器()
register()
方法是任何服务提供者在引导框架时首先调用的。在这里,您可以包含服务提供者的任何逻辑,这些逻辑可以在不使用任何其他服务提供者的情况下完成。register 方法在服务容器被完全实例化之前运行,并为服务提供者提供服务的应用的任何部分执行任何先决逻辑。
您应该只将东西绑定到服务容器中,比如类或服务,而不应该将东西从服务容器中取出(因为在每个提供者上调用register()
方法时,它还没有完全构建或实例化。如果您需要使用其他服务,或者需要使用已经绑定到容器的东西,或者注册任何路由、监听器或类似的东西,请使用boot()
方法。
清单 5-1 为某个应用提供了一个示例ServiceProvider
,该应用实例化了一个用于与某个 Redis 数据库接口的类。
// An example service provider
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Predis\Client;
class RedisServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->singleton(Redis::class, function ($app) {
return new Client(
config('database.redis.options.default'));
});
}
}
Listing 5-1Service Provider’s register() Method
在清单 5-1 中,我们有一个标准的服务提供者,它定义了一个单一服务(单一服务是一个特定的类,在任何给定的时间只能存在一个)。该服务通过使用在redis
键下的/config/database.php
中定义的配置创建一个实例来使用Predis\Client
类,如清单 5-2 所示。
// /ddl/config/redis.php
return [
// ...
"redis" => [
"client" => "phpredis",
"options" => [
"cluster" => "redis",
"prefix" => "laravel_database_",],
"default" => [
"url" => null,
"host" => "127.0.0.1",
"password" => null,
"port" => "6379",
"database" => 0,
],
"cache" => [
"url" => null,
"host" => "127.0.0.1",
"password" => null,
"port" => "6379",
"database" => 1,
],
]
]
];
Listing 5-2The Default Configuration for a Redis Connection, Located in app/config/database.php
如果您还没有猜到,config()
方法的参数对应于指定配置文件(.php
)中特定设置的位置,该文件位于app/config
目录中,它返回一个单一的多维数组,其中的键对应于配置值所对应的配置“节点”。database.redis
的第一部分,即句点左边的所有内容(即database
),对应于文件名。第二部分是句点左边的所有内容,对应于从该文件返回的数组的键。在前面的例子中,它指的是config/database.php
文件中的redis['options']['default']
键。
启动()
在boot()
方法中,您可以包含任何依赖于其他配置、服务或系统其他方面的逻辑。它在服务容器被 Laravel 实例化之后运行,因此,它被允许使用其他服务提供者提供的任何其他功能或服务容器中存在的东西。
当服务提供者需要依赖项来配置应用的这一部分时,您可以将它们注入到boot()
方法中,它们将作为参数自动传入供您使用,如清单 5-3 所示。
<?php
class ClaimServiceProvider {
// properties and register() method
public function boot(ClaimRepository $claimRepository, CptCodeRepository $cptCodeRepository)
{
$claim = $claimRepository->findBy('patient_id', 3345);
$cptCode = $cptCodeRepository->whereIn('cpt_code',
$claim->cpt_codes);
//... additional logic
}
}
Listing 5-3Example boot() Method in a Service Provider
这要归功于 Laravel 的服务容器,我们将在接下来讨论它。我们将开始讨论 Laravel 的服务容器的许多不同方面,这些方面使它不同于您过去可能使用过的任何其他服务容器(它使大多数服务容器相形见绌)。一个ServiceProvider
的boot()
方法实际上可以用来配置系统的任何方面,并且因为服务容器在这个方法被调用时是完全加载和准备好的,所以你能够在系统中注入任何依赖或者利用任何你想要的其他服务。一开始,您可能会发现使用服务容器有些困难,因此,如果您不知道如何将所有内容插入其他内容,也不用担心。在尝试编写自己的服务提供者或尝试为服务的依赖项建立注入策略之前,请关注我们在本章中讨论的高级主题。
服务容器
Laravel 的服务容器确实非常出色,使用它的好处也是让 Laravel 成为如此出色的框架的部分原因。整个应用中的依赖注入由服务容器处理,就像“服务”的管理和定义一样(因为没有更好的词),通过实例化对象或以特定的方式解析依赖。
重要的是要注意,简单的依赖关系,比如那些不需要任何额外的配置或参数被实例化的依赖关系,是由服务容器自动处理的,并且是在代码中没有任何额外逻辑的情况下注入的。清单 5-4 显示了文档中服务容器的一个例子。
<?php
namespace Claim\Submission\Application\Controllers;
use App\Http\Controllers\Controller;
use App\Repositories\UserRepository;
use App\User;
class UserController extends Controller
{
/**
* The user repository implementation.
*
* @var UserRepository
*/
protected $users;
/**
* Create a new controller instance.
*
* @param UserRepository $users
* @return void
*/
public function __construct(UserRepository $users)
{
$this->users = $users;
}
/**
* Show the profile for the given user.
*
* @param int $id
* @return Response
*/
public function show($id)
{
$user = $this->users->find($id);
return view('user.profile', ['user' => $user]);
}
}
Listing 5-4Controller with Dependencies Injected That the Service Container Can Resolve by Itself
$app 变量
在 Laravel 中,$app
变量不仅仅是一些普通的配置对象或数据容器,而是应用在任何给定时间的全局状态。如果您可以访问$app
变量,那么您就可以访问几乎每一个组件、配置设置、特定于框架的功能,以及注册到服务容器的任何对象或服务,因为它实际上就是服务容器。所有的服务提供者都有一个在他们的父类中设置的$app
属性。
Tip
请务必查看课程Illuminate\Support\ServiceProvider
;它的源代码位于/vendor/laravel/framework/src/Illuminate/Support/ServiceProvider.php
。在这个抽象的父类中有许多有趣的事情,包括它如何加载视图、路由、语言设置、配置、命令以及系统的重要功能和设置的其他方面。这些是系统中每个服务提供者继承的默认参数和行为,因此非常值得花额外的精力去阅读和理解源代码。
$app
变量还用于将类和对象“绑定”到服务容器,以抽象这些类和服务的设置、引导或前不变检查,从而为它们的实例化提供手段。将东西绑定到服务容器的方法是编写一个ServiceProvider
,它将处理设置/配置服务所涉及的所有方面,并提供一种方法来获取服务上下文中包含的对象的特定实例。理想情况下,但不总是这样,这些类分组和实现应该与领域保持一致。也就是说,我们希望使用核心领域中涉及的思想、概念、业务规则和不变量作为区分我们的类、模块和任何其他概念的指南。
Note
我使用术语 service 来指代一个特定的上下文或者一组类,它们组成了一个特定的特性或者完成了一个特定的目标。它比模块粒度大,比单个对象或类粒度小。
不继承接口或不需要任何配置或附加参数来实例化的简单对象可以由服务容器使用反射来构建(清单 5-5 )。像这样简单的事情不需要服务定义。
<?php
namespace Claim\Submission\Application\Services;
class ClaimNotificationService
{
protected $claimRepository;
public function __construct(ClaimRepository $claimRepository)
{
$this->claimRepository = $claimRepository;
}
public function doStuff()
{
// do something with $this->claimRepository...
}
}
class ClaimRepository extends Repository
{
public function findBy($field, $value)
{
return Claim::where($field, $value)->get();
}
}
Listing 5-5Service Requiring a Dependency on a ClaimRepository Class That Can Automatically Be Resolved Using Reflection
在清单 5-5 中,要获得ClaimNotificationService
的一个实例(不需要手动提供它的依赖关系,内联到你实际使用的地方)需要做的事情如下:
use Claim\Submission\Application\Services\ClaimNotificationService;
// anywhere in the code where you would have access to $app — like a
// service provider, or anywhere the helper method app() is available
$claimNoticationService = $this->app->make(
ClaimNotificationService::class);
如您所见,我们可以简单地将类名传递给$app
的make()
方法来解析我们需要的特定类实例,而无需手动指定依赖关系。在幕后,这是通过反射实现的,我们不会在本书中讨论。现在,只需要知道后端发生了一些神奇的事情,允许服务容器解析简单的依赖关系,就像前面的例子一样。
绑定到服务容器
根据官方文档,系统中的大部分绑定将在服务提供商内部完成。有不同的方法可以将某些东西绑定到容器,以便您可以在以后使用它(只需很少的前期工作),但整个想法基本上是设置如何实例化服务或类;然后,不需要在多个客户端上下文中再次手动完成所有这些工作,您只需引用服务容器的特定绑定名称,它将完全按照您定义的那样在服务提供者内部构建您的服务或类。它基本上是你需要的任何类或对象的定义实例的注册表,以及如何正确配置这些类的说明。
如果服务提供者实现了接口\Illuminate\Contracts\Support\DeferrableProvider
,那么关于如何构建特定对象的指令将只能按需运行和获取(即延迟加载)。如果服务提供者没有实现这个接口,那么每次运行服务容器时,都将构建有问题的对象并将其存储在内存中。
假设我们有一个名为CoolService
的类,它有一个方法doCoolThing()
,我们想把它绑定到容器上以便于访问。要将单个类或对象绑定到服务容器中,我们将使用:
$this->app->bind('CoolService', \App\Services\CoolService::class);
以后当我们想要使用这个很酷的服务时,我们必须像这样从服务容器中提取它:
$coolService = app()->make('CoolService');
$result = $coolService->doCoolThing();
如果出于某种原因,CoolService
类需要额外的设置或配置才能被实例化,只需提供一个闭包,封装设置它所需的代码(这将在下一节中详细介绍)。
使用服务容器
举个例子,我们希望清单 5-5 中的通知服务能够支持 SMS 通知、电子邮件通知和 Slack 通知。我们不想在每个应该相同的类之间重复代码。确保这一点的一种方法是抽象出通知的交付机制(在本例中我称之为处理程序)。
处理这种情况的一种方法是在通知程序服务中添加一个方法,该方法将接受要发送的特定类型的通知。然后,我们可以将通知服务的整个“如何”部分封装到一组三个行为中,每个行为对应一种类型,这些行为将根据传递给这个新方法的类型参数进行加载。这个理论上的类图的 UML 看起来类似于图 5-1 。
图 5-1
一种可能的面向对象设计方法是使用抽象来解决拥有多种通知类型的需求
基本上,我们有两个定义的接口,Notification
和NotificationHandler
。AbstractNotification
实现了Notification
,并添加了由ExceptionNotification
和InfoNotification
类扩展的通知“类型”和“处理程序”的概念。这些类代表了通过send()
方法发送消息的方法,但是依赖于图左侧的另一组类来实际完成发送部分。图 5-1 中的Notification
处理程序接口有三个子类,每种消息类型一个。每个类都有一个handle()
方法,负责将消息传输到所需的媒介(一个 Slack 通知、一封电子邮件或一条文本消息)。抽象类使用接口,这样就可以很容易地交换实现来替换交付机制,而不会破坏应用的其他部分。我们已经用这个设计很好地封装了通知系统的变化。
现在我们已经有了通知系统的总体架构,为了发送一条消息而实际实例化这样一个系统的代码似乎有点多。例如,按照现在的情况,对于您想通过应用发送的每条通知消息,您必须运行类似于清单 5-6 中的代码。
// example of using the above design
$context = $request->notification->isError() ? 'exception'
: 'info';
$notificationType = $request->notification->type;
switch ($notificationType) {
case 'sms':
$notificationHandler = new SmsNotifierHandler();
break;
case 'email':
$notificationHandler = new EmailNotificationHandler();
break;
case 'slack':
$notificationHandler = new SlackNotificationHandler();
break;
default:
throw new InvalidNotificationTypeException();
}
if ($context == 'exception') {
$notificationContext = new ExceptionNotification();
} else if ($context == 'info') {
$notificationContext = new InfoNotification();
}
//finally, send our message
$notificationContext->setNotificationHandler($notificationHandler)->
send($request->notification->message);
Listing 5-6A Possible Usage of the Design in Figure 5-1 as Done by Hand, Not Using the Service Container or Dependency Injection
这只是一个不切实际的解决方案。我们如何使用 Laravel 的服务容器来帮助我们呢?
我们可以创建一个 FQCN 的别名(使用我们想要的任何名称空间,即使它完全是虚构的),它将指向InfoNotification
或ExceptionNotification
,然后自动调用其对应的setNotificationHandler()
方法来设置正确的类型。清单 5-7 展示了一个例子,它使用闭包作为定义逻辑的手段来构建期望的对象/服务。(这很可能在AppServiceProvider
内部完成,它是整个系统用于应用关注和配置的提供者。)
$this->app->bind('SlackExceptionNotifier', function($app) {
$notificationContext = new ExceptionNotification();
$notificationContext->setNotificationHandler (new
SlackNotificationHandler());
return $notificationContext;
});
Listing 5-7Example Service Container Binding for the Aforementioned Notification System
基本上,我们在这里所做的就是在服务容器中的标识符SlackExceptionNotifier
下创建一个条目,当运行时,它将执行匿名函数中包含的逻辑并返回结果,在本例中是一个完全实例化、许可和认证的通知对象,供您用来发送 Slack 通知。下面是它的用法示例:
$slackExceptionService = app()->make('SlackExceptionNotifier');
$slackExceptionService->send("Some slack message");
请注意,这不是处理本例中提到的用例的最佳方式,主要是因为您必须编写总共六个服务(三个通知类型*两个通知上下文)来处理每种可能的组合。在现实世界中,这可能有些过头了;然而,它服务于我们所需要的目的:演示在服务容器中绑定和检索对象/服务。除此之外,不要过多地研究它,因为即使在最初的系统设计中,你也可能会发现缺陷。
对象和服务不是唯一可以绑定到容器的东西。您还可以选择绑定单例类,这意味着在应用中的任何给定时间,该类的对象只有一个副本。
$this->app->singleton('ClaimsApi', function ($app) {
return new Claim\Application\Api($app->make('ClaimHttpClient'));
});
您还可以将特定的实例绑定到容器,并期望从容器中检索到相同的实例后,将它返回给您。
$notifier = new ExceptionNotification();
$notifier->setNotificationHandler(
new SlackNotificationHandler()
);
$this->app->instance('SlackExceptionNotifier', $notifier);
如您所见,我们简单地实例化了一个ExceptionNotification
的实例,并使用容器的instance()
方法将该实例存储在标识符SlackExceptionNotifier
下,这是您在检索'SlackExceptionNotifier':
的实例时作为参数传递给$app
的make()
方法的内容
app()->make('SlackExceptionNotifier')->send('some slack message');
。
将接口绑定到实现
这可以说是 Laravel 服务容器最酷的地方,也是这个框架如此强大和灵活的原因。因此,我们现在知道了如何将一些东西绑定到服务容器,比如类、实例和对象,这些东西需要创建额外的逻辑,并且可以在系统中的任何地方使用。
服务容器还有另一个特性,允许您将接口绑定到实现。这个功能强大的原因是,它允许您动态地传递一个具体的类,该类充当给定接口的实现。一旦设置好,Laravel 将为应用提供该实现,只要它实现的接口在应用中被引用。这允许您拥有单个接口的不同实现,并允许您通过简单地修改服务提供者内部的一行代码来交换实现。
要将实现绑定到接口,可以使用以下语法:
$this->app->bind('Claim\Submission\Domain\ClaimRepositoryInterface',
'Claim\Submission\Infrastructure\Repositories\ClaimRepository');
Laravel 将会看到这段代码,并在它找到对Claim\Submission\Domain\ClaimRepositoryInterface
的引用的任何地方自动注入实现(Claim\Submission\Infrastructure\Repositories\ClaimRepository
)。这是一个强大的特性,也是 Laravel 如此强大的部分原因。
我们来看一个例子。假设您有一个开源代码库,其中包括一个需要持久化的图书模型,以跟踪图书的信息,如作者、ISBN 和其他普通图书的定义特征。您还有模型的消费者,他们使用图书信息进行研究和跟踪。问题在于,因为代码意味着共享,所以您不知道图书信息将被持久化的确切方式。大多数情况下,用户会利用 MySQL 来满足其存储和持久性需求,但也有一些用户更愿意将书籍存储在 Redis 甚至其他非关系型持久性机制中,如 MongoDB 和 Elasticsearch。您不知道用户最终会将哪一个用于自己的实现,所以您需要考虑所有三种变化,以使您的代码对其他人非常有用。
我们可以做的是创建一个接口来处理书籍的持久性,然后为每个客户端的特定持久性机制实现该接口。这样,我们可以为每个客户提供他们需要与我们的图书模型交互的特定入口点。一旦我们定义了我们的实现,我们只需要将接口与服务容器中所选机制的实现绑定,Laravel 将为我们处理剩下的事情!
图 5-2 提供了该系统的示意图,以便于说明。
图 5-2
一个接口 BookRepository 及其可能的实现,每个实现都特定于一个给定的持久层
这看起来相当不错:对于我们能想到的每一种持久性机制,我们都有一个BookRepository
的实现(显然还有更多,但这也适用于演示)。唯一缺少的是告诉应用使用哪个存储库实现的设置。有很多方法可以解决这个问题(这个列表并不全面)。
-
在
.env
文件中提供一个BOOK_REPO_TYPE
参数,该参数将通过一个配置文件获取并加载到应用的内存中 -
让用户通过
AppServiceProvider
直接在服务容器中硬编码他们的特定存储库 -
让用户在单独的服务提供者中指定存储库
无论您决定以哪种方式集成一个特定的存储库版本,您都可以通过在服务提供者中定义来配置存储库的类型,这样它就可以在任何找到作为类型提示的BookRepository
接口的地方使用该存储库(清单 5-8 )。
$bookRepoType = "RedisBookRepository"; // this is derived from one of
// the methods listed above
$this->app->bind('Domain\Books\BookRepository',
'Interface\Repository\RedisBookRepository');
Listing 5-8The Service Container Configuration Needed to Support All Possible Repositories in the Application
一旦我们准备好了,剩下唯一要做的事情就是确保我们不直接依赖于任何子存储库类(清单 5-9 )。
// ..some controller
// EXAMPLE OF WHAT NOT TO DO
public function generateBookList(
RedisBookRepository, outputFormat="csv") {
// do stuff
}
Listing 5-9An Example of What Not to Do...Rely on a Concrete Method
除了系统无法工作这一明显问题之外,这段代码还违反了编程中的一条重要原则。不要依赖具体,要依赖抽象。为了在前面的代码中实践这个原则,我们可以简单地用更抽象的东西替换掉RedisBookRepository
,这样我们就可以利用这个generateBookList()
方法。BookRepository
类非常适合这里,因为它包含了所有的实现,同时将实际的细节留给了子类。代替清单 5-9 中的代码,使用一个更粗糙的类定义作为参数将产生清单 5-10 中的代码。
public function generateBookList(
BookRepository, outputFormat="csv") {
// do stuff
}
Listing 5-10A Better Approach to Defining the Dependency of a Book Repository
Note
你能找出清单 5-9 和清单 5-10 中不同的原则吗?我给你一个提示:不止一个,它们都属于坚实的原则。
需要记住的重要一点是,像这样的定义应该只在必要的时候使用,因为十有八九,简单地用适当的依赖关系类型提示参数就足够了。这被称为自动注入,大多数时候,你甚至不需要告诉 Laravel 如何构建一个特定的对象…它将通过反射自行确定。
队列和 Laravel 作业
在 Laravel 中,您可以选择合并一些需要在Job
类中运行的逻辑。您可能希望异步运行该作业,或者甚至将该作业推迟到以后运行。一个队列通常是一些外部队列系统,比如亚马逊 SQS、RabbitMQ、Redis 或 MySQL。Laravel 提供了将这些服务连接到您的应用的快速简单的方法。这些服务的配置可以在config/queue.php
文件中找到。虽然在本书的后面我们不会深入到设置队列系统,但是我们将使用作业来封装一次性任务,并且您可以使用队列系统来处理这些作业。有关为一个受支持的队列系统配置驱动程序的更多信息,请参见位于 https://laravel.com/docs/6.x/queues
.
的 Laravel 文档
如前所述,队列只是一堆等待被某个已配置的队列系统分派和处理的作业。队列通常以一种易于识别的方式分组,即特定队列处理哪种作业,例如“默认”或“主要”将作业推送到不同队列的能力以一种更易于管理的方式对作业进行了分段,并有助于确定哪里需要这些作业。对于大型应用来说尤其如此,在这些应用中,有多个作业在不同的时间出于不同的原因而启动。有时,您需要执行的作业是资源密集型的,可能需要很长时间才能运行(至少从用户的角度来看)。由于 PHP 是一种解释型语言,所以可以使用队列来发送作业并立即向用户返回响应,这样用户就不会在那里等着想为什么您的应用或页面“坏了”
响应时间和页面加载时间不仅对用户体验很重要,而且对谷歌搜索引擎排名的网站质量也很重要。响应时间长的网页通常是应用中的错误来源,作为一名开发人员,我负责的许多调试项目都与“永远”需要加载的代码库部分有关。当这种情况发生时,队列系统可以解决由于加载时间过长而产生的许多问题。
队列如何工作
简而言之,队列根据选定的队列驱动器进行操作。如前所述,Laravel 内置了对许多队列管理器的支持。您可以将队列驱动程序看作是一个处理作业分派并跟踪它们的驱动程序,这样它们就不会运行超过一次(也是为了记录已经分派的作业)。除此之外,作业本身可以包含处理特定任务所需的所有逻辑。如果您需要分派一个事件,以便数量为 x 的侦听器能够听到它并相应地采取行动,那么您应该在那个handle()
方法中这样做。如果您需要在当前任务之后分派另一个任务,只需将该逻辑放入任务的handle()
方法中。您可以将任何需要的依赖项作为构造函数参数,分配给您的Job
类的属性,这样当代码到达handle()
时,它们就可以运行了。
调度作业时,如果不指定队列标识符(队列名),它将默认为“默认”队列,使用您在配置中启用的任何队列驱动程序。那个驱动程序真正做的只是告诉 Laravel 在哪里存储已经处理的作业和当前在堆栈上的作业。当使用“数据库”队列驱动程序时,作业将全部存储在名为jobs
的 SQL 表中。下面是如何在您的config/queue.php
文件中配置它:
'default' => env('QUEUE_DRIVER', 'database')
但是,默认情况下不包含该表,因此您需要运行创建该表所需的命令,如下所示:
php artisan queue:table
这将创建一个名为_create_jobs_table.php
的迁移,以当前时间戳为前缀。之后,您需要运行migrate
命令在数据库中创建表。
php artisan migrate
要创建作业,可以发出以下 Artisan 命令:
php artisan make:job JobClassName
这将在App\Jobs
中产生一个JobClassName
类(记住这是默认目录,不一定是我们将在本书中使用的目录)。为了创建作业实际做的内容,您只需将任何依赖项注入到它的构造函数中,并将您想要执行的逻辑放在handle()
方法中。Laravel 会自动将依赖项直接注入到作业中,供您在handle()
中使用。
例如,清单 5-11 显示了一个用于更新某个患者的主治医师的作业。
class ChangePatientPhysician implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $patientProviderRepository;
/**
* Create a new job instance.
*
* @param Patient $patient
* @param Provider $newProvider
* @return void
*/
public function __construct( PatientProviderRepository
$patientProviderRepository)
{
$this->patientProviderRepository = $patientProviderRepository;
}
/**
* Execute the job.
*
* @return void
*/
public function handle(Patient $patient, Provider $newProvider)
{
//verify this patient is registered under this provider
$isRegistered = $this->patientProviderRepository
->patientRegisteredFor(
$patient, $provider);
if ($isRegistered) {
$patient->provider()->associate($provider);
$patient->save();
} else {
throw new PatientNotRegisteredWithProviderException();
}
}
}
Listing 5-11An Example Laravel Job That Accepts $patient and $provider Objects, Then Modifies the Patient’s Set Provider to Be the One Passed into the Handle Method
要分派前一个作业,可以使用清单 5-12 中的语法。
// most likely in a controller somewhere
public function updatePatientsProvider($patient, $provider)
{
//dispatch the job, passing in parameters that correspond to the
//signature of the Job's handle() method
ChangePatientPhysician::dispatch();
}
Listing 5-12Dispatching a Job from a Controller Method
正如您所看到的,在 Laravel 中创建和分派作业是轻而易举的事情,它使用底层的队列驱动程序连接来促进作业通过工作队列。如果出于某种原因,您不想使用队列,而是希望作业立即运行,那么您可以使用dispatchNow()
方法。
ChangePatientPhysician::dispatch();
在本书的后面,当我们为Claims
模型构建 API 时,我们将大量使用 jobs。
拉勒维尔合同
Laravel 附带了一长串标准化接口,包括任何 web 应用中使用的一些最常见的模式、类和组件。契约都存在于Illuminate\Contracts
名称空间中,框架提供的特性、工具和支持是契约的具体实现。你也可以在 GitHub 的 https://github.com/illuminate/contracts
.
找到它们,你可以在Illuminate\
名称空间的某个地方找到每个契约的实现。例如,清单 5-13 显示了一个描述命令总线的契约,它是由 Laravel 实现的,用来调度作业,就像上一节讨论的那样。
// Illuminate\Contracts\Bus\Dispatcher
namespace Illuminate\Contracts\Bus;
interface Dispatcher
{
/**
* Dispatch a command to its appropriate handler.
*
* @param mixed $command
* @return mixed
*/
public function dispatch($command);
/**
* Dispatch a command to its appropriate handler in the current process.
*
* @param mixed $command
* @param mixed $handler
* @return mixed
*/
public function dispatchNow($command, $handler = null);
/**
* Determine if the given command has a handler.
*
* @param mixed $command
* @return bool\
*/
public function hasCommandHandler($command);
/**
* Retrieve the handler for a command.
*
* @param mixed $command
* @return bool|mixed
*/
public function getCommandHandler($command);
/**
* Set the pipes commands should be piped through before dispatching.
*
* @param array $pipes
* @return $this
*/
public function pipeThrough(array $pipes);
/**
* Map a command to a handler.
*
* @param array $map
* @return $this
*/
public function map(array $map);
}
Listing 5-13Laravel’s Contract for a Standard Command Bus
这里我们有一个描述命令总线的基本接口,包括实现要定义的类所需的方法,以及每个类在注释掉的文档块中应该做什么的细节。通过浏览这个接口,我们可以很快推断出任何实现它的东西都将定义有dispatch()
、dispatchNow()
、hasCommandHandler()
、getCommandHandler()
、pipeThrough()
和map()
方法。Laravel 用来处理调度命令和任务的实现太长了,不能放在这本书里;然而,我强烈要求在 https://github.com/laravel/framework/blob/6.x/src/Illuminate/Bus/Dispatcher.php
.
检查一下。注意,这个类实际上用一个方法dispatchToQueue(),
扩展了另一个接口,这是QueueingDispatcher
接口所需要的。
不要与嵌套接口混淆。从版本 6 开始,Laravel 改进了契约的接口和实现方式,但从长远来看,它们更有意义,有逻辑结构。他们不得不这样做,看起来好像他们都以某种形式被用于产生框架本身的特性和功能!尽管我们不会在本书中大量使用契约,但它是理解的一个重要基础。
事件
Laravel 中的事件是一个强大的工具,可以让应用的其他区域知道发生了一些有趣的事情。事件的另一面是侦听器,它遵循基本的观察者模式。侦听器(观察者)基本上只是简单的类,它们被设置为在应用中发生特定事件(被观察到)后运行。请注意,我在提到事件时使用了过去式,这是正确的,因为从技术上讲,所有事件都发生在*过去。你看的最后那句话,其实现在已经是过去了。因此,所有事件都应该有它所做的的过去式版本。*以下是一些常见事件的例子:
-
UserWasRegistered
-
AccountWasDeactivated
-
ChangedPatientsPrimaryPhysician
-
ClaimWasSubmitted
-
SomethingHasHappened
你明白了。
所有事件配置都在app/Providers/EventServiceProvider.php
文件中指定。该文件中的类有一个名为$listen
的受保护属性,它是包含每个事件类型及其后续事件侦听器的数组。默认情况下,该属性中有一个元素(清单 5-14 )。
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
];
Listing 5-14The Default Event Listeners That Come with Laravel
这意味着“一旦由Registered
类定义的事件发生,启动包含在SendEmailVerificationNotification."
中的监听器,你可以在开发过程中手动将你的事件和它们对应的监听器一起添加到这个数组中。另一个更好的选择是简单地添加事件(使用完全限定的名称空间)和它们相应的监听器(观察者),如清单 5-15 所示。我创建它仅仅是为了演示,而且我没有在$listen
数组中包含任何类。
protected $listen = [
//...
ClaimWasSubmitted::class => [
NotifyAccountManager::class,
SendEmailSubmissionConfirmation::class,
\Infrastructure\Services\UpdateElasticSearch::class,
];
];
Listing 5-15Example $listen Array Inside the App\Providers\EventServiceProvider Class
根据您的喜好更改EventServiceProvider
之后,您可以运行下面的 Artisan 命令来生成包含在$listen
数组中的所有类,在它们各自的默认文件夹(App\Events
和App\Listeners
)中或者在 FQCN 中指定的目录中(假设该目录已经使用composer.json
) 中的自动加载器进行了正确配置):
php artisan event:generate
Laravel 的股票事件和听众
另一种思考方式是将其与时事通讯的工作方式进行比较。时事通讯将有一个接收它的订户(即听众)列表,因此当作业被分发(即分派)时,只有列表上的订户将接收它,但是对于世界上的其他人来说,它甚至不存在。侦听器显然会包含一些一旦事件被调度就要执行的逻辑。
例如,在清单 5-16 中,我们可以推断出有一个Registered
事件,一旦一个新用户在系统中注册,该事件很可能会被分派,并且一个名为SendEmailVerificationNotification
的Listener
会监听该事件,该事件很可能会向该用户发送一封确认电子邮件(假设他们的电子邮件是注册页面上的一个表单)。通过查看监听器的源代码,我们看到(当然),Laravel 已经考虑到了这一点(清单 5-16 )。
<?php
namespace Illuminate\Auth\Listeners;
use Illuminate\Auth\Events\Registered;
use Illuminate\Contracts\Auth\MustVerifyEmail;
class SendEmailVerificationNotification
{
/**
* Handle the event.
*
* @param \Illuminate\Auth\Events\Registered $event
* @return void
*/
public function handle(Registered $event)
{
if ($event->user instanceof MustVerifyEmail && !
$event->user->hasVerifiedEmail()) {
$event->user->sendEmailVerificationNotification();
}
}
}
Listing 5-16Example $listen Array Inside the App\Providers\EventServiceProvider Class
从这段源代码中我们可以清楚地看到,它监听了由类Illuminate\Auth\Events\Registered
定义的Registered
事件。它还具有仅当用户在注册期间提供电子邮件时才发送通知的逻辑。发送通知所需的所有逻辑都包含在侦听器的handle()
方法中。它是自封装的。
从 Laravel 5.8.9 开始,您可以配置所谓的自动事件发现,而不是在EventServiceProvider
中手动注册事件。当它被激活时,Laravel 将扫描您指定的任何监听器目录,并将任何监听器自动注册到事件中。每当 Laravel 遇到一个带有已定义的handle()
方法的监听器类时,Laravel 会将这些方法注册为事件监听器,用于该方法签名中类型暗示的任何事件(清单 5-17 )。
<?php
use App\Events\PodcastProcessed;
class SendPodcastProcessedNotification
{
/**
* Handle the given event.
*
* @param \App\Events\PodcastProcessed
* @return void
*/
public function handle(PodcastProcessed $event)
{
// do work here
}
}
Listing 5-17Example Listener That Has the Corresponding Event Type-Hinted in the handle() Method
在前面的事件中,当 Laravel 点击这个类并看到handle()
方法时,它将向侦听器PodcastProcessed
注册事件(SendPodcastProcessedNotification
)。要配置这个特性,您需要覆盖EventServiceProvider
中的shouldDiscoverEvents
方法(清单 5-18 )。
public function shouldDiscoverEvents()
{
return true;
}
Listing 5-18Enabling Event Auto-registration in Laravel
要指定希望 Laravel 扫描哪些目录来自动注册事件(默认情况下只包括App\Listeners
目录),您可以用希望 Laravel 扫描的侦听器的路径覆盖discoverEventsWithin()
方法(清单 5-19 )。
public function discoverEventsWithin()
{
return [
'App\Listeners',
'Domain\Listeners'
];
}
Listing 5-19Specifying Locations Where Laravel Will Scan to Auto-register Events
事件是通过它们使用的一个名为SerializesModels
的特征生成的。这一特性使得事件可以很容易地序列化传递给它的任何有说服力的模型。如果你触发的事件应该停止其他事件的传播,只需从handle()
方法返回false
。
如果您正在使用队列,您可以通过向您的监听器类添加一个ShouldQueue
特征来获得该功能。有关定制特定队列的更多信息,请参见 https://laravel.com/docs/6.x/events#queued-event-listeners
.
调度事件
要分派事件,使用event()
助手,向其传递Event
类的名称。
$podcast = Podcast::find(345);
event(new SendPodcastProcessedNotification($podcast));
事件通常用于促进应用中的松散耦合,并有助于保持事物的分离。例如,不是直接从注册控制器发送电子邮件确认,从而耦合电子邮件逻辑和注册逻辑,而是能够从控制器调度一个事件,SendEmailConfirmation
监听器将拾取该事件并相应地采取行动,这提供了一个完全解耦的解决方案,使您的注册逻辑专注于注册。这是事件和监听器的一个常见用例,Laravel 提供的功能非常有用。
结论
本章涵盖了 Laravel 的一些优点,我们将在本书的其余部分使用它们来实现一种合理的领域驱动设计方法。通过利用 Laravel 作业、解耦事件和监听器,以及 Laravel 的标准请求/响应流和框架的所有特性,我们可以将它们串联起来,创建一个标准化的操作“流”,以封装我们的业务逻辑,并允许我们创建分离和松散耦合的组件。我们将在本书的后面更深入地讨论我们将如何创建我们的业务流程的整个流程。
现在,考虑一下 Laravel 中可用的不同组件如何很好地满足一个标准的现代 web 应用的需求。Laravel 本身是建立在依赖注入容器之上的,它可以根据应用的需要基于每个服务进行配置。我们还可以使用服务容器将特定类型对象的特定实例绑定到代码中引用该对象接口的任何位置。这为我们提供了一种灵活、干净、简洁的方式来管理依赖关系,这也是 Laravel 的服务容器如此强大的部分原因。Laravel 作业非常适合封装域模型中定义良好、自包含的一次性任务,这种方式可以轻松地与队列系统集成,以支持作业的异步处理(在 Laravel 中也很容易做到这一点,您将在后面的章节中看到)。当应用中发生有趣的事情时(比如一个作业被分派到一个队列中,然后实际运行),我们可以使用一个Event
来标记该作业的发生,然后通过编写一个Event
监听器在应用中的任何地方对该事件做出反应。
六、构建索赔处理系统
本书中有两个核心概念,它们是使用 Laravel 作为主干框架成功设计和实现模型驱动、架构良好的 web 应用的关键。这本书的结构有时可能看起来有点奇怪,但我试图在您需要的所有先决知识与您将使用这些知识创建的实际实现之间取得平衡,同时兼顾这两个重要主题。坦率地说,有很多东西需要学习,这取决于你开始阅读这本书时的专业水平。我深入研究了 DDD 中涉及的各种概念以及与 Laravel 相关的潜在对应代码,我认为在您的实际应用中实现这些想法和概念时,这种方式对您最为有益。我将通过为您提供这些概念将成为良好用例的情况,让您对这些概念有一个坚实的理解,这样您就可以使用 Laravel 框架作为实现领域驱动设计的一种方式来尝试自己的实现,无论是在您自己的项目中还是在您公司的项目中。以下是选择 DDD 中涉及的各种模式的良好用例,以便我们可以充分探索它们为我们的开发工作带来的价值:
-
掌握每个模式的核心用途,以及使用每个模式的利弊(无论该模式是来自 DDD 的技术、灵活性还是战略支柱)
-
帮助解决你在野外可能会遇到的问题(我也遇到过)
-
让您对应用的内部工作原理有一个清晰的认识,并帮助您使用 DDD 模式定义应用的高级结构,以细化各种领域层组件的特性
-
当使用 DDD 的技术模式作为实现核心组件的一种方式来构建核心组件时,要考虑架构问题,同时也要记住,它们只是简单的可能性,而不是具体的解决方案
此外,请记住,在没有适当考虑其对系统其余部分的潜在影响的情况下,不应该放弃任何想法,这是基于用业务中无处不在的语言描述的核心业务规则。几乎总是有不止一种方法来做某事,这些潜在的解决方案通常不会在没有几个小时的讨论、计划、原型、意识到你在这个过程中搞砸了一些事情,然后在你获得了对该领域更细粒度部分的额外洞察力之后纠正错误的情况下出现。这是一个不小的壮举,并且将(大部分时间)需要大量的先决学习、提问、记录和重构,以真正获得对领域模型的有价值的洞察力。
也就是说,可能有(而且很可能是)比我在书中概述的方法更好的方法,我向你发出挑战,让你成为提出这种方法的人。我用在 DDD 发现的信息、最佳实践和技术模式的知识武装你,你可以在你的工作项目或你自己的项目中使用它们来创建一个结构良好的应用,它具有一个丰富的模型,充分地捕获业务所表达的逻辑和规则,以做它所做的来赚钱。我发现的最好的方法是给你一些好的例子,以及用来得出潜在解决方案的思考过程——所有这些都清晰地展示出来,这样你就可以看到我为达到最终目标所采取的步骤。如果您能够理解技术考虑(DDD 结构的一个支柱)并使用模型和架构设计技术(DDD 的另一个支柱)作为使用这些技术模式的手段,那么您可以使用这些模式作为您的工具来构建新的需求或特性(第三个支柱),这是用于在 DDD 中表达领域模型的技术方面和结构。
也就是说,这一章像胶水一样把我建议用来构建 web 应用软件的两个中心概念粘在一起(提示:它们在这本书的标题里)。我将向你介绍一些我自己有幸解决的现实生活中的问题。我们将扩展我在前一章给你的医疗索赔例子,讨论我们将使用 DDD 构建块设计的各种主题(即,那些与领域驱动设计的技术模式相关的主题),我们将使用 Laravel 实现构建块模式作为实现的手段。我们将以 API 优先、测试驱动的方式来全面建立核心业务对象和在这些对象上运行的流程,并且我们将小心遵循我们所知的最佳实践和标准来编写任何高质量、可维护的代码。
我们将把 DDD 的教导作为一种将质量融入软件的手段。具体来说,我们将重点关注以下内容:
-
开发一个干净的、语言无关的 API,它将清楚地描述我们正在构建的系统的一部分,给企业一个有用的领域模型
-
为涉及的业务术语、操作和领域对象开发一个精炼的、良好的定义(无处不在的语言)
-
简要定义 DDD 的核心概念(工具)
-
仓库
-
服务
-
领域事件
-
总计
-
-
描述什么是有界上下文,以及如何使用它们在我们的业务逻辑中划分域和子域,然后利用这些上下文创建一个上下文图,最终创建一组模型,作为我们的应用的域和名称空间结构的主干
-
请记住,我们可以使用敏捷方法、迭代开发、持续建模和持续集成等过程来构建高质量的企业级 web 应用,这些应用易于测试、具有内聚性和松散耦合性,并且能够准确、完整地代表构建应用的领域
-
重构代码,使之与业务领域模型更加一致,这对于创建可维护的软件非常重要,这些软件可以扩展以解决未来新功能需求带来的问题
那我们开始吧!
医疗索赔提交
我们将在前一章的基础上扩展我们的医疗索赔讨论,并更深入地讨论我们将在本章其余部分使用的所需背景信息。
我们公司是怎么赚钱的?通常,医疗计费系统中涉及的索赔提交过程往往很困难,因为它涉及多个步骤和一个精确的检查和平衡系统,政府必须遵守这些步骤和系统才能为服务提供者开出支票。我们公司开发了一个应用,有助于缓解手动提交索赔过程中的棘手问题。但是,要做到这一点,应用必须跟踪和执行各种州管理任务,以验证索赔 100%准确,并准备好提交给联邦合格健康中心(FQHC)。
我们将通过建立索赔的不同状态来实现这一点,它还会跟踪通过我们系统的每个索赔。例如,必须对索赔进行审查,以确保其准确性,这样 FQHC 就不会以CORRECTION_NEEDED
状态拒绝索赔,从而延迟对提供商的付款。
基本上,可以将我们公司提供的服务看作是索赔的交换所,这样索赔在第一次提交给 FQHC 时就会被接受,提供商也会得到报酬。这一点之所以重要,是因为当提供商直接向 FQHC 提交索赔时,会发生大量的退回索赔(和延迟付款)。接待员在选择对每个病人进行的程序时会犯太多的错误;这就是所谓的痛点。
我们的公司在市场上找到了一个利基市场,并用一个漂亮的新应用填充了这个市场,该应用使提供商(医生)的办公室能够使用我们根据联邦索赔审查流程设计的工具和流程提交整个索赔。该流程验证所有必需的数据是否存在,并将索赔排队等待审查。审查团队在审查过程中负责核实索赔,确保所有患者数据、医疗状况、接受护理的描述、代表对患者进行的各种程序的 CPT 代码以及大量其他医生/保险提供商信息都在手边,并且在将索赔标记为“审查者批准”之前都是 100%正确的
此时,索赔已准备好提交给 FQHC 进行计费,以验证金额,然后处理付款并向提供商开出支票。我们拦截支票,拿走我们的收入,然后在扣除费用和开支后,把剩余的钱付给供应商。然而,为了让联邦实体批准对提供者的支出补偿,必须保证索赔是正确的。索赔是分批提交和支付的,每批大约有 100 至 1000 份单独的索赔。
与我们新的索赔提交系统相比,通过纸质表格、传真机和影印文件提交索赔的“传统方式”大约需要五到十倍的时间。这个过程过去是 100%手工完成的,当时没有计算机检查来确保索赔表上的数据在实际提交给联邦政府之前是有效的。没有任何措施来确保程序代码组合(又称 CPT 代码组合)代表提供者为患者完成的实际付费工作。官方报销申请提交流程还有许多其他(相当严格的)要求,如果不满足这些要求,将强制要求将报销申请退回给提供商,要求他们在尝试再次提交报销申请之前进行必要的更正。
医疗程序代码
索赔根据预先确定的成本结构进行支付,该成本结构基于一种叫做工资代码表的东西。FQHC 确定向提供商支付多少费用的方式取决于提供商注册时的成本结构类型。这两种结构如下:
-
每次就诊付费:这种成本结构规定,无论患者接受了何种常规、服务或程序,提供者每次从享有医疗福利的注册患者处接受就诊时,都要为其服务支付预定的固定金额。他们每次就诊都获得相同的固定金额。金额由提供商和 FQHC 商定,但我们公司实际上向提供商签发支票。通常,每次访问的付费金额从 100 美元到 150 美元不等。请注意,虽然他们不确定支付给提供者的金额,但每次就诊付费计划仍然要求使用程序代码(也称为 CPT 代码)跟踪每个相关程序。
-
按程序付费:这就是事情变得复杂的地方。处于按程序付费结构下的医生根据他们为患者提供的程序获得报酬。通过分析一组给定的单个程序(称为 CTP 代码)来确定数量。每个 CPT 代码代表一个在病人身上完成的医疗程序(x 光检查,使用石膏来修复断臂等)。).确定索赔金额的方法是分析列出的各组 CPT 代码,并在一个名为 paycode sheet 的东西中查找 CPT 代码组合。
CPT 代码组
让事情变得更加复杂的是,FQHC 根据这些 CPT 代码组确定付款,称为 CPT 代码组合。这里的大问题是,这些组合非常具体,其中大多数都有包含在索赔中的要求。一些 CPT 组合组可能包含多个相同的 CPT 代码,或者可能具有一个 CPT 代码,该代码要求只有在另一个特定的 CPT 代码存在于同一组中时才被视为有效。
还存在其他要求。每个 CPT 代码组合对应于一个或多个 CPT 代码,这些代码可能具有先决条件要求,可能仅包含在另一组 CPT 代码中,或者可能用于特定的步骤序列中,每个步骤又具有自己的 CPT 特定要求。医生可能对患者执行的所有可用程序都被建模为一系列 CPT 代码组合。这就是 FQHC 如何确定这些程序的确切支付金额。所有可能的组合及其预定的费用金额(支付给提供商)都存储在特定的支付代码表中。
工资代码表跟踪所有这些 CPT 组合,这些组合代表在每个患者身上完成的各种程序,并以不可读的预设格式建模和表达。这要求提供者的办公室手动查找他们想要使用的每个 CPT 代码,然后确保他们与索赔一起提交的 CPT 组合与特定 paycode 表中的有效条目相对应。
例如,以下是一个(伪造的)CPT 代码列表,这些代码对应于因呼吸问题接受治疗的患者:
-
胸部 x 光检查完成(代码 3892)
-
已订购血液工作(代码 3332)
-
通过喷雾器向患者施用硫酸沙丁胺醇(代码 4523)
这三个代码都出现在一个索赔中,因为它们都发生在同一次访问中。FQHC 有一项严格的政策,规定任何患者每天只能就诊一次,因此在某一天为该患者进行的每项手术都必须包括在相应的报销申请中,他们不会接受每位患者每天超过一次的报销申请。
这些代码可以在给定的支付代码表中找到,该支付代码表包含提供者从治疗患者中获得的指定金额。表 6-1 显示了支付代码表中的示例记录,该记录规定了支付给具有前三个 CPT 代码的索赔的金额。
表 6-1
对应于一组 CPT 代码的薪资代码表中的记录
|
CPT 代码 id
|
CPT 代码
|
数量
|
| — | — | — |
| 3; 38; 420; | 3392, 3332, 4523 | $150 |
作为系统提供者(应用的主要用户和我们销售团队关注的中心客户)的一项功能,应用应该以某种方式保存支付代码表数据以及在内部关系数据库中与索赔一起提交的数据,解析出提交的索赔的代码组合,在相关的支付代码表中查找该组合,并将索赔的估计金额附加到索赔的元数据中。
图 6-1 分解 CPT 编码系统。
图 6-1
医疗索赔提交流程中涉及的工作流程
在图 6-1 中,我们从左上角开始,提供者(或提供者办公室或诊所的接待员)登录到我们的应用,并转到创建新索赔页面。在这个页面上有各种表格输入,用于成功处理索赔所需的各种信息。完成并提交表单需要以下数据:
-
服务日期(DOS),即在患者身上完成手术的日期
-
相关的医生日志/患者治疗史,称为进展记录
-
在患者身上完成的程序,通过 CPT 代码组合进行跟踪,根据与给定支付代码表中定义的 CPT 代码组合相对应的金额,CPT 代码组合必须有效
-
提供商信息
-
NPI 号(国家提供商识别号)
-
提供商的名称、位置、执照/医学学位
-
提供者从事的相关业务
-
-
基本患者信息
-
名,中间名,姓
-
出生日期,性别
-
头发颜色,眼睛颜色,体重
-
社会保险号
-
当前街道地址
-
紧急联系人
-
-
病人的文件必须存档。这包括以下内容:
-
患者身份证或身份证复印件
-
患者医疗福利卡的复印件
-
接受护理的合同副本(或电子存储)
-
此外,当用户提交索赔时,会出现许多自动验证任务,包括检查索赔是否符合所有以前的要求,验证 DOS 是否在去年内,以及通过查找提供商办公室提供的上传文档来验证患者资格。
在没有人工参与的情况下,应用尽可能地验证了索赔的所有数据后,索赔被物理地提交给系统,并以状态PENDING_REVIEW
保存。一旦索赔被正式提交,在后端,我们有几个额外的流程在它到达审查员之前运行。首先,我们希望自动化患者资格要求,以便应用实际上可以在线访问 Medi-Cal 页面,通过操作 DOM 提交患者信息,抓取返回的响应,然后将该响应附加到索赔中。我们将在本章的后面处理这个问题。
提交时运行的另一个任务是验证索赔中的患者实际上已在该提供者处注册。如果注册没有完成,应用应该不允许用户继续进行声明,并返回到更正声明。索赔上的提供者链接到一个支付代码表,该表确定每个程序代码分组将向提供者支付多少。在审查索赔之前,需要做的最后一件事是估计索赔的金额。这是通过在工资代码表中查找 CPT 组合并在索赔中附上“估计金额”来完成的。如果无法从薪资代码表中解析出金额,则表明该薪资代码表中不存在 CPT 代码组合,必须在将索赔发送到 FQHC 之前添加该组合。
在自动化流程全部运行之后,索赔在发出之前会提交给审查小组进行审查。审查者再次检查索赔上的所有文档,并手动验证索赔上提交的所有内容(以及应用创建并附加到索赔的字段);审查者要么批准它,要么认为它需要更正,在这种情况下,索赔被发送回提供者进行更正(索赔状态表明这一点:CORRECTION_NEEDED
)。在评论页面上还有一个注释部分,评论者可以用来输入关于索赔的具体注释或评论,这些注释附在索赔上;然后,提交索赔的提供者会收到一个通知,告知索赔需要关注。他们可以更正并再次发送,重新开始这个过程。
如果索赔准备就绪,审查者将其标记为REVIEWER_APPROVED
,并将索赔发送到 FQHC 进行计费审查。在 FQHC 工作的计费用户调出与索赔相关联的数据,并根据估计金额进行最终检查,确保该金额对应于该提供商各自的支付代码表中列出的有效 CPT 组合。完成后,记账人将索赔标记为BILLER_APPROVED
,三周后,我们公司将代表提供商收到一份薪水支票。然后我们把钱分发给他们。否则,记账方将该索赔标记为BILLER_CORRECTION_NEEDED
,然后再次将其发送回提供方进行更正(此时提交流程重新开始)。总的来说,这个过程非常简单,但是有很多验证和确认进入索赔,以确保它将被接受和支付。
我们在建造什么?
现在您已经对索赔提交流程有了一些了解,我们可以开始合理化我们希望如何实现这个例子的特性。然而,首先,界定我们在建设什么是至关重要的。我将带您在这个应用中设置项目的上下文图并定义有界的上下文。
请记住,该应用被视为“企业应用”,因为它管理多种用户类型,提供身份验证和授权管理,并且存在于各种环境中。在我们根据边界、域、上下文和子域(我们稍后会谈到所有这些)设计出应用后,我们将重点设计和实现系统的几个不同方面,具体如下:
-
索赔提交流程,包括所有必需的检查和平衡以及状态代码更改。
-
角色系统,定义系统中各种用户类型的角色,以及在执行系统任务(即授权)时它们各自的检查。
-
根据输入的 CPT 代码组合及其在给定提供商的薪资代码表中的相应行确定索赔金额的过程。
-
刮除器将刮除 Medi-Cal 部位并返回检查结果的图像,以确定患者是否有资格获得福利。该流程在索赔创建时自动运行。
-
验证索赔的所有数据点并核实其准确性,以及提交索赔所需的文件。
识别域、子域和有界上下文
那么,我们从哪里开始?我们识别系统需求中涉及的域、子域和有界上下文;然后我们开始构建我们天真的原型;最后,我们提炼概念,直到我们有一个有效的、工作的领域模型。
表 6-2 更好地代表了上一节中的列表,同时也指定了未知项(当我们检查它们时,您可以回来填充空白)。
表 6-2
索赔示例要求
|
要求
|
描述
|
领域
|
子整环
|
公元前
|
| — | — | — | — | — |
| 提交索赔 | 索赔提交流程模型 | 提交索赔 | - | - |
| 索赔核实 | 核实提交的索赔 | 提交索赔 | - | - |
| 索赔估计 | 确定索赔的预期金额 | 提交索赔 | - | - |
| 资格刮刀 | 用于 Medi-Cal 患者资格验证的刮刀 | 提交索赔 | - | - |
| 角色系统 | 基于每个用户的用户授权 | 作家(author 的简写) | - | - |
在表 6-1 中,我已经简单地包含了上述列表的需求,并给出了它们的核心领域。以下是 Eric Evan 对核心领域的评论,摘自他的书,《领域驱动设计参考》(狗耳出版社,2014):
《把模型简化。定义一个核心领域,并提供一种方法来轻松地将其与大量支持模型和代码区分开来。突出最有价值和最专业的概念。把核心做小。”
—埃里克·埃文斯
基于我们对核心域应该是什么的理解,我似乎在表 6-1 中把域说得太宽泛了。Evans 指出,核心域应该很小,并且应该很容易与支持或促进核心域中的模型的其他模型区分开来。最初,我只为每个主请求包含了两个核心域:CLAIM SUBMIT 和 AUTH。如果我们再仔细考虑一下,我们会发现提交索赔的过程应该不同于验证索赔的过程。然而,这两个概念是密切相关的(也就是说,如果一开始就没有索赔提交,就根本无法进行索赔验证)。我们甚至可以得出这样的结论:索赔确认是索赔提交过程整体的一部分。我们需要能够以这样一种方式分割领域,即应用的结构由更小的部分编织在一起,每个部分都可能有自己的上下文、无处不在的语言,甚至是负责维护和发布周期的独立团队。
DDD 提供了一些有价值的工具,给你一些在核心域、子域和有界上下文之间画边界的想法。一般来说,系统中每一个有界上下文通常有一个子域,多个子域/有界上下文共同生活在同一个核心域中。一旦这样做了,我们就开始了解封装了系统的各种关注点的模块是如何工作的,并且可以看到不同领域协同工作以在系统内实现一个单一的、明确定义的目标的完整视角。
有了这些知识,让我们重温一下表 6-1 。起初,我们可能认为索赔提交应用的概念应该有不同的核心域,例如,一个处理提交过程本身,另一个验证提交的索赔。在意识到它们是不同的关注点,但是彼此又如此相关,以至于一个(即索赔验证)离不开另一个(即索赔提交)之后,我们得出结论,这两个应该在同一个核心域中是有意义的。
现在,让我们回顾一下索赔评估需求。索赔的估计金额是一个计算值,它依赖于一个流程,该流程确定每个索赔的特定 CPT 组合,并在给定的支付代码表中为该组合确定一个金额。对我来说,这意味着评估索赔的过程不能在没有 CPT 组合和provider_id
的情况下发生,而这两件事只是在索赔的意义和上下文中一起发生。然而,当从高层次上看这件事时,我想到的一个细节是,索赔评估发生在成功提交索赔之后。我们应该将它建模为一个不同的核心领域吗?我不这么认为,原因如下:提交索赔并验证索赔是否准确以及是否包含所有必需的数据和文档的行为发生在提交过程的同时,但它实际上发生在索赔被视为“已提交”之前
只有在这种情况发生后,才能计算出索赔支出的估计金额。总的来说,这些事实让我(我希望你也是)认识到它们是同一个整体过程的不同背景。索赔提交由提供商完成,我们的应用会在正式“提交”之前自动验证其准确性,然后根据与 CPT 代码组合和支付代码表相关的不同流程将估计金额附加到索赔中。现在,我们将估计索赔金额逻辑建模为同一个核心域 CLAIM SUBMIT 中的另一个有界上下文。此列表中与索赔本身相关的唯一其他项目是通过抓取 Medi-Cal 资格页面并向其提供患者信息(包括其 Medi-Cal 编号)以及提供护理的提供者来验证患者资格并获取结果的流程。在技术上“提交”索赔后,也会触发此流程尽管如此,我们有许多不同的方法可以选择来拆分应用。
-
我们可以让它保持原样(也就是说,让两个核心域声明 SUBMIT 和 AUTH ),只给每个域中的元素分配不同的子域。
-
We could make the core domains a little more granular, which may look something like Figure 6-2.
图 6-2
对构成我们应用核心领域的概念的初步深入研究
-
We can choose to create each core domain in light of the various models existing within the system. In this regard, the AUTH bounded context would basically be absorbed by the surrounding contexts (Figure 6-3).
图 6-3
按用户类型细分的概念
以这种方式分离应用的主要缺点是,我们现在必须将缺失的 AUTH 上下文作为一个独立的、不统一的概念来处理,它在不同的用户类型中有不同的实现。基本上,这样做不是最好的,因为我们会将授权关注点的单独实现混合到定义它们的每个特定用户类型中,导致公然违反关注点分离和 DRY 原则(代表“不要重复自己”),因为我们必须定义每个特定用户在每个上下文中可以做什么,如果我们有一个 AUTH 服务或类似东西的单一实现,这并不是很糟糕,但这里不是这样。不理想。让我们找到一个更好的结构,它更有意义,更容易适应领域模型。
-
We can combine the idea presented in option 1 of this list with that in option 2 and have the core domains separated by the two primary concerns living within the core domain that revolves around a claim, while making the variations between each concern explicit and obvious by specifying the subdomains each of them target and then creating a bounded context for each one of these subdomains. Figure 6-4 shows what that might look like.
图 6-4
将概念分成三个中心力量(如果你愿意的话),它们在领域模型的核心工作
-
We could also split up the domain in terms of the central model existing in our feature requirements: the claim. In this case, the claim would be the center of attention, and different contexts can be created in regard to its association to a claim, particularly the contexts that a claim is in initially and after it’s submitted (Figure 6-5).
图 6-5
根据系统中的中心实体存在的域:声明
Note
关于前面的可能性列表中的选项 5,AUTH 域有两个相似的探测上下文,但是有一个重要的区别。认证是一个与确保用户是他们所声称的那个人有关的概念,如果是的话,就进行认证。另一方面,授权直接处理验证被认证的用户是否被允许做某事,通常基于用户在系统中的角色(这属于权限范畴)。
除了选项 3 之外,最有可能为前面列表中的所有项目创建一个有效的用例。根据 Eric Evans 的说法,我们应该寻找将核心域从支持核心域的域中分离出来的方法。在我们的例子中,对于这个例子,核心域是索赔本身,围绕索赔的各种关注点,即验证、资格审查、用户授权检查,以及索赔提交过程中发生的任何其他操作。当面临一个将影响应用中定义的结构和模块的重要决策时,很难决定实现最佳结果的最佳方案。在这种情况下,我通常会求助于我认为正确的东西,比如 DDD 哲学中的定义和抽象。对于这个场景,我想到的是 Evan 对抽象核心的定义,如下所示:
即使是核心领域模型通常也有如此多的细节,以至于很难传达全局…因此:确定模型中最基本的不同概念,并将它们分解到不同的类、抽象类或接口中。设计这个抽象模型,使其表达重要组件之间的大部分交互。将这个抽象的整体模型放在它自己的模块中,而专门的、详细的实现类留在它们自己的由子域定义的模块中。
—埃里克·埃文斯
那么,为了我们的目的,在我们的医疗索赔领域模型中,什么会被认为是“最与众不同的概念”?
-
提交索赔
-
验证索赔
-
提交前验证
-
检查适当的文件
-
验证 CPT 代码+ CPT 组合
-
验证表单提交的数据
-
-
提交后验证
- 患者资格刮刀
-
-
估算索赔金额
-
授权和认证问题
我认为我们应该将索赔作为这个项目的核心领域,子领域是提交、验证和索赔金额估计过程。在不同的上下文中,我们有应用的身份验证/授权部分。这最好定义为通用子域(业务中的一个概念,其存在是为了促进关键的业务关注,但不是业务关注本身)。这些子域是“通用的”,因为它们是可重用的、解耦的组件,在整个代码库中的许多地方都会被调用。图 6-6 显示了我们将使用的最终结构。
图 6-6
我们应用关注点的最终分离
此时,我们已经将应用分成了更易于管理的部分,并定义了各种子域,这些子域是核心域不可或缺的一部分,有助于将系统中的声明转换为提供者的工资。
模块
在 DDD,有一个模块的概念,它是一组特定类的边界,这些类基于领域中的概念逻辑地分组在一起,并以无处不在的语言命名。模块直接对应于领域中的项目,既作为构造应用领域层的手段,又作为表达通用语言结构的手段。模块应该与其他模块松散耦合,但是模块中的类本身应该与同一模块中的其他类紧密结合。
模块如何与有界上下文相关联?图 6-7 显示了 DDD 战略设计的层级。
图 6-7
DDD 组织和结构的战略分解
我们可以看到,核心域是顶级关注点,核心域由各种子域表示,每个子域都有对应的有界上下文。有界的上下文本身再次被分解,并被分成各种模块,这些模块一起表示构成子域中所表达的思想的逻辑。在我们的例子中,图 6-8 显示了模块结构的可能配置。
图 6-8
在我们的 claims 示例项目中,模块、有界上下文、子域和核心域的结构
Note
这本书没有详尽、完整地列出构建这个示例项目的每一行代码,而是给出了用 Laravel 翻译应用各个部分的可能方法。关于这个项目的完整代码清单和书中所有其他的源代码,请访问 Apress 网站。
现在我们已经介绍了一些架构设计解决方案,并且有了一个适合应用和领域模型需求的解决方案,我们可以开始使用 Laravel 构建组件本身,并开始看到我们的应用成形。
创建 Laravel 组件
让我们看看是否可以将它分解成类,我们将在不同的模块中使用这些类来完成我们需要做的事情。要做到这一点,我们需要对领域有一个清晰的概念,这样我们最终创建的模型才能用文字的方式表达它。如果您已经定义了模块的一般结构,并且知道它们所处的有界上下文,那么在定义更细粒度的结构(类)时,最好的起点是确定每个上下文中需要的模型,并在模块的范围内定义它们,注意使类名与无处不在的语言中的术语一致。由于它将在应用中的任何地方使用,以确定对域中给定资源的授权,我们将从关注通用子域开始,在本例中,是用户和角色。
用户
现在,我们知道会有不同的用户在不同的时间使用系统,这些用户中的每一个都必须有一个登录名和一个定义好的角色,这样我们的系统就知道如何识别每一个用户,以及他们能做什么和不能做什么以及能看到什么。Laravel 附带了一个标准的User
类,它扩展了另一个User
类,而后者又从雄辩的基础model
扩展来定义管理用户的各种设施。父类User
使用各种特征来访问 Laravel 的认证和授权特性。
关于如何继续的第一个想法可能是创建额外的User
类,它们也从基类(别名为Authenticatable),
扩展而来,然后为每种类型的User
类提供它们自己的可用方法和属性的子集。它看起来有点像图 6-9 。
图 6-9
应用中所需的各种用户类的初始设计
这种设计有许多问题,列举如下:
-
有一个额外的
User
类,它似乎对我们的应用或领域没有明确的目的。这是相当尴尬的,并留下了误解的空间。 -
事实上,我们违反了关注点分离和 Demeter 定律,因为我们混淆了系统中用户的概念和系统中权限的概念,而它们显然是不同的。
-
我们将认证和授权问题与
User
定义混在一起,并创建了两个额外的类,它们也扩展了同一个父类。问题是“角色”的明确概念已经丢失。我们应该认为用户有一个或多个角色,而不是用户是这些角色中的一个(这意味着它确实属于内联的User
定义)。 -
当执行身份验证或权限检查时,我们将不断地在三个
User
类之间周旋,而不是只有一个User
类可以传递和管理。对我来说那听起来像是一个巨大的痛苦! -
这种设计根本不可扩展。如果我们需要添加额外的用户类型(或者更准确地说,用户角色),那么我们就不得不在系统中创建额外的类和对象,每个类和对象都有自己的实现,并在系统的其余部分进行有效性检查。
这种设计的最大问题可以在列表的第三和第五项中找到。用户和角色的概念应该在同一个模块中分开,而不是在同一个类中。我们应该将这些问题相互分离,并明确这两组类。
话虽如此,图 6-10 显示了之前设计的一个更好的版本,考虑到了我们将用户和角色模型分开的决定(没有双关的意思)。
图 6-10
设计用户及其相应角色的更好方法
正如你在图 6-10 中看到的,我们现在已经将“用户角色”的概念封装在它自己的类中,并且我们已经定义了几个常量来反映系统中使用的各种角色。这种设计非常灵活,因为我们可以将任何我们想要的角色附加到一个特定的User
对象上。它也是可伸缩的,因为我们可以在不添加类的情况下向系统添加角色。我们能够满足系统当前和未来的需求,同时将系统中的类的总数保持在尽可能低的水平。我们在正确的轨道上!然而,还有一个问题:我们不希望角色的概念泄露到User
对象中,也不希望用户的概念泄露到Role
对象中。我们如何完成这样的事情?我现在不会给你所有的细节,但是我会给你一个提示:我们可以使用一个数据透视表作为角色表和users
表之间的中介,实际上被称为user_role
表(或role_user
)。
The Cost of Bad Design
优秀的程序员既是艺术家也是科学家。我们将需求钉在一门科学上,并使用我们的画图构造这些问题的各种解决方案(在这种情况下,它将是 DDL 或 PHP,取决于您如何看待它)。当我们认识到设计或模型逻辑的清晰性时,就像我们在这里所看到的用户和角色的分离,我们应该总是试图在我们的应用的画布中捕捉知识:设计。好的设计在努力;更差的设计更难。想想看:一个好的设计几乎没有开销,因为你不必改变一堆东西来补偿应用不同部分的另一个变化,因为程序员遵循适当的标准和最佳实践来设计他们的软件,其中之一就是严格遵守关注点的分离。在一个设计糟糕、没有经过深思熟虑或规划不当的系统中,你不可能获得这些很酷的好处。修复和维护一堆垃圾(有人称之为真正的 web 应用,只是碰巧被黑客攻击到足以使其在技术上“工作”的程度)的成本远远超过了您要处理的成本、挫折、头痛、架构更改和维护水平,直到最后有一天您不得不(或者更确切地说,不得不)从头重写它,或者采用更高级的解决方案,如反腐败层,来解决糟糕的设计留下的问题。无论糟糕的设计的原因是否与范围蔓延、对领域的误解、对被误解的领域的错误陈述有关,或者完全缺乏关于管理领域的较低级业务规则和政策的经验和知识,一个设计糟糕的系统都要复杂得多、困难得多、昂贵得多,对于像我们这样聪明的开发人员来说根本不是一个选择。
我花时间为你精心创作了一首诙谐的诗,它将帮助你记住设计的主要焦点应该是领域,以及如何最好地将其建模为软件(尽管,正如伏尔泰曾经说过的,“诙谐的说法证明不了什么……”).
抓紧时间,
敲定设计,
但是不要浪费时间,
关于你发现的问题,
如果他们不在域中,
现在,把它们留在身后。
我试图说明的另一点是,基础设施和应用层的问题不应该影响领域层;领域层应该影响基础设施和应用层。
设计建模
为了继续建模过程,我们需要解决一个由 Laravel 的默认名称空间和目录结构引起的问题。问题是,它们并不是面向一个干净的、分离的架构,这个架构完全表达了我们的领域模型的意图。这是我们将在下一章研究的内容,下一章将着重于创建一个标准组件来映射我们的应用中需要的许多用户角色。我们的应用被认为是一个企业 web 应用,这意味着我们必须正确地跟踪、管理和检查所有试图访问受保护的服务(例如搜索病人)或受保护的实体(及其相应的字段)的用户,以确保他们被允许调用特定的服务或选择、修改或删除给定的实体(在 API 的上下文中称为资源)。这方面的一个例子是制定一项政策,只允许搜索属于给定诊所或提供商的患者,他们在医疗系统中注册为患者的主治医生。
结论
在本章中,我们看了一下本章和后续章节中使用的示例声明应用的各个领域级方面。我们讨论了医疗服务提供者目前面临的一个实际问题,即以“旧方式”完成索赔提交,以获得对合格患者服务的补偿。由于对来自系统的索赔进行了严格的审查,许多索赔由于一些错误(或缺少信息)而被延迟支付。唯一的验证工作是用肉眼手工完成的。
我们的应用解决了所有这些问题,并为进入系统的所有索赔提供了各种检查和平衡的解决方案,因此返回的提交数量要少得多,其中 99%在第一次尝试时就被接受。该应用作为企业级应用存在,需要用户角色和服务等组件,例如以细粒度方式进行身份验证和授权,以便通过登录和凭据支持系统中的所有用户。提交过程本身由各种规模较小的过程组成,这些过程相互协作以产生预期的结果。我们尝试了一种可能的组件(模块)结构,使用了一些通用的最佳实践,最重要的是,使用领域中的核心概念,按照领域驱动的设计来制作一个粗略的模型结构。我们看了一些概念,如有界上下文、核心域和一般子域。
七、领域的建模和实现
现在我们对领域在现实世界中的工作方式有了一些了解,我们可以开始开发领域关注点,并创建一些基本的领域对象,这将帮助我们产生一个覆盖所有核心需求的解决方案。
Note
本章中包含的领域模型对象的目录将会随着本书在第八章中继续这个项目而改变,当我们有了一些在代码中表示我们领域的更好方法的额外见解时。
定义主索赔模型
首先,让我们定义我们将在系统的其余部分使用的主Claim
模型,如图 7-1 所示。
图 7-1
索赔模型原型的首次尝试
Claim
模型有两个部分;顶部的一个定义了索赔的属性。我们需要为属性所代表的类创建模型和迁移。).目前,属性是 FQHC 向提供商付款所需数据的一部分。在 UML 的底部是一个方法列表,这些方法对索赔状态做各种事情,或者拉入也是需求的一部分的附加数据(例如,getDocuments()
获得给定索赔的所有相关文档)。
应用中有许多涉及声明的部分也需要建模。有时,最好从系统的高层次视图开始,这样就可以弄清楚类之间的关系。要做的一个重要区别是,我们在系统中包含了两种实际类型的用户,Provider
和Patient
,它们在模型中明确地用于域逻辑,而不是身份验证或授权(这将在应用层)。如果我们试图在这个类中建模安全问题(这将公然违反关注点分离原则),我们应该导入User
类。我们还需要在我们的类别映射中使这种包含显式化。
图 7-2 显示了在一个声明中的高级概述。
图 7-2
索赔类别的属性
请注意,有两组类,每组都包含在一个边界内,该边界将类组与应用的其余部分分开,对应于我们在前一章中设计的两个模块:Auth 模块和 Claim Submission 模块。实际的Claim
模型可以在索赔提交模块中找到,并充当任何封闭类的看门人。
如果您还没有猜到,我们正在将索赔提交模块中的类聚合起来,用Claim
模型作为聚合根。声明集合边界内的任何封闭类都只能通过声明本身来访问。例如,与索赔一起提交的ProgressNote
只能通过$claim->progressNote
访问。ClaimStatus;
也是如此,它是通过$claim->status
访问的。现在,如果您查看另一个模块,它被表示为驻留在同一父名称空间下的一组常规类。围绕它画出的边界并不表示一个集合,而只是一组相关的类。Claim
模型显式地使用了Provider
和Patient
模型,既用于身份验证/授权,也作为断言将索赔提交到审查队列所需的需求的一种方式。让我们使用 Artisan 命令来充实模型,如清单 7-1 所示。
php artisan make:model App\\Models\\Claim -a
php artisan make:model App\\Models\\Patient -a
php artisan make:model App\\Models\\Practice -a
php artisan make:model App\\Models\\Provider -a
php artisan make:model App\\Models\\ProgressNote -a
php artisan make:model App\\Models\\Document -a
php artisan make:model App\\Models\\ClaimStatus --migration
Listing 7-1Artisan Commands to Create the Domain Models of Our System
清单 7-1 中的命令,除了最后一个,为它们对应的模型创建以下每一个:
-
位于
App\Models
的模型类 -
位于
database/migrations
的迁移类 -
database/factories
中的一个试验工厂 -
app/Http/Controllers
中的一个控制器
让我们从最简单的开始,定义我们的ClaimStatus
类和支持它的迁移,如清单 7-2 和 7-3 所示。
// ddl/app/Models/ClaimStatus
<?php
...
class ClaimStatus
{
const PENDING_REVIEW = 1;
const REVIEWER_APPROVED = 2;
const CORRECTION_NEEDED = 3;
const BILLER_CORRECTION_NEEDED = 4;
const BILLER_APPROVED = 5;
public $table = 'claim_status';
protected $fillable = ['*'];
}
Listing 7-2The ClaimStatus Class
注意,我们省略了将created_at
和updated_at
时间戳添加到状态表的代码,考虑到这是一个静态引用(即查找)表,这些代码是没有用的。
// ddl/database/migrations/{YOUR_TIMESTAMP}_create_claimstatus_table
<?php
...
use App\Models\ClaimStatus;
function up()
{
Schema::create('claim_status', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('name');
$table->string('slug');
$table->string('code');
});
ClaimStatus::create([
'id' => 1,
'name' => 'Pending Review',
'slug' => 'pending_review',
'code' => 'PENDING_REVIEW'
]);
ClaimStatus::create([
'id' => 1,
'name' => 'Reviewer Approved',
'slug' => 'reviewer_approved',
'code' => 'REVIEWER_APPROVED'
]);
ClaimStatus::create([
'id' => 1,
'name' => 'Correction Needed',
'slug' => 'correction_needed',
'code' => 'CORRECTION_NEEDED'
]);
ClaimStatus::create([
'id' => 1,
'name' => 'Biller Correction Needed',
'slug' => 'biller_correction_needed',
'code' => 'BILLER_CORRECTION_NEEDED'
]);
ClaimStatus::create([
'id' => 1,
'name' => 'Biller Approved',
'slug' => 'biller_approved',
'code' => 'BILLER_APPROVED'
]);
}
Listing 7-3ClaimStatus Migration File
Note
我们在ClaimStatus
模型中定义了一组常量,以便于将其与应用的其他部分区分开来。现在,我们不必记住 ID 为 1 的ClaimStatus
表示一个PENDING_REVIEW
状态,我们可以直接引用App\Models\ClaimStatus::PENDING_REVIEW.
同样需要注意的是我们在迁移运行时添加的记录——它们都对应于我们在顶部设置的模型常数。
本文中不会列出所有的模型和迁移,但是您可以在 GitHub repo 中找到这个例子和其他例子的所有代码。我没有提供Practice, ProgressNote,
或Document
的文本定义,因为它们是琐碎的实现——请在线查找这些类的定义。然而,我将内联定义Claim, Provider,
和ClaimStatus
类,因为它们是独一无二的,并且对当前的讨论非常重要。
让我们完成Provider
类(请访问网站获取移植代码)。为此,请查看清单 7-4 。
// ddl/app/Models/Provider
<?php
...
Class Provider extends Model
{
public $table = 'providers';
protected $fillable = ['fname', 'lname', 'address', 'practice_id', 'npi_number'];
public function practice()
{
return $this->hasOne(Practice::class, 'practice_id', 'id');
}
}
Listing 7-4The Provider Eloquent Model
Provider
类相当简单。有一些标准标识符,包括姓名和地址,以及他们工作的诊所和国家提供者标识符(NPI),这是美国每一位执业医生在政府注册后在美国行医的 ID。我们在这里定义的唯一关系是对应的Practice
(我们用在$fillable
数组中指定的practice_id
字段明确了这一点)。
现在我们可以通过使用关系将Claim
模型中的模型联系在一起,如清单 7-5 所示。
// dll/app/Models/Claim.php
<?php
// ...
namespace App\Models;
class Claim
{
public $table = 'claims';
protected $fillable = ['cpt_code_combo_id', 'provider_id', 'patient_id', 'progress_note_id', 'date_of_service', 'status_id'];
//relations:
public function provider()
{
return $this->hasOne(Provider::class);
}
public function patient()
{
return $this->hasOne(Patient::class);
}
public function progressNotes()
{
return $this->hasMany(ProviderNote::class);
}
public function cptCodeCombo()
{
return $this->hasOne(CptCodeCombo::class);
}
public function status()
{
return $this->hasOne(ClaimStatus::class, 'status_id', 'id');
}
}
Listing 7-5The Claim Model
清单 7-6 展示了Claim
模型的移植。
// dll/database/migrations/{YOUR_TIMESTAMP}/_create_claim_table.php
<?php
...
class CreateClaimsTable extends Migration
{
Schema::create('claims', function (Blueprint $table) {
$table->bigIncrements('id');
$table->integer('status_id')->unsigned();
$table->integer('provider_id')->unsigned();
$table->integer('patient_id')->unsigned();
$table->integer('cpt_code_combo_id')->unsigned();
$table->integer('progress_note_id')->unsigned();
//define foreign keys:
$table->foreign('practice_id')
->references(Practice::class)
->on('id');
$table->foreign('provider_id')
->references(Provider::class)
->on('id');
$table->foreign('patient_id')
->references(Patient::class)
->on('id')
$table->foreign('cpt_code_combo_id')
->references(CptCodeCombo::class)
->on('id');
$table->foreign('progress_note_id')
->references(ProgressNote::class)
->on('id');
$table->foreign('status_id')
->references(ClaimStatus::class)
->on('id');
$table->datetime('date_of_service');
$table->timestamps();
});
}
Listing 7-6Migration File for the Claim Model
在这一点上,我们已经勾勒出了声称*存在所需的基本模型。*我们尚未讨论通过提交和验证流程促进索赔所需的任何结构,也未对流程中涉及的所有不同步骤和状态的管道进行建模。我们将在本书的后面部分讨论所有这些内容。这些问题在 DDD 和拉腊维尔的技术水平和经验方面更先进。我保证我们会到达那里。
让我们谈谈总量。一个集合基本上是一个对象,它由分离的、单独的对象组成,这些对象使用一组组件组合在一起,这样它们可以被记录、分解和重构,并在面对领域中复杂对象的代码实现时作为一个可能的解决方案。聚合有助于在持久化数据时防止数据不一致,方法是采用一些使它们有用的原则。
-
原子数
-
一致性
-
完整
-
持久性
确保这一点的一种方法是,当系统要求您需要随时跟踪给定域对象的状态时,使用事务来管理一组事件或域模型的持久性。事件记录就是一个例子。当应用中发生了其他上下文想要知道的有趣的事情时,事件的持久性和重新创建就很难作为一个大块来管理。
通过使对象成为一个具有适当边界的集合,您可以采用一些技术,使其在持久性级别上更容易保存和加载。在我看来(我希望你也是如此),在我们的示例模型中,聚合非常适合索赔对象;然而,好像在书中有他们自己的章节,我将把细节留到以后。
实现用户和角色设计
既然我们已经确定了设计,让我们编码出一个原型。首先是数据库。Laravel 中的数据库问题位于 Laravel 项目目录的/database
文件夹中。该目录中有几个子文件夹:
-
migrations/
:数据库经历的增量转换,以保持一致性并访问 concertive 提供的一系列开箱即用的功能,这样您通常不必直接接触数据库,而是依靠简单的 Artisan 命令php artisan migrate
。 -
factories/
:生成虚拟数据的工厂,这些虚拟数据在应用中作为模型使用,并用于单元测试。 -
seeds/
:运行另一个命令时可以插入的测试数据:php artisan db:seed
。
我们需要构建代表系统中不同角色的模型。下面是生成我们需要的模型和迁移的命令,因此我们可以表示角色的概念:
php artisan make:model Role --migration
这将产生类似如下的内容:
Model created successfully.
Created Migration: 2019_10_03_033254_create_roles_table
哇,看看这个。我们能够用一个命令生成模型和相应的数据库表。我们先修改迁移;然后我们可以构建我们的领域模型。打开ddl/db/migrations/*_create_roles_table,
,其中*
是命令运行时的时间戳,然后找到方法up()
。然后修改这个方法,看起来像清单 7-7 。
public function up()
{
Schema::create('roles', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('name');
$table->string('slug');
$table->timestamps();
});
}
Listing 7-7Roles Migration File
基本上,在清单 7-7 中,我们定义了我们的roles
表,每个角色对应一个定义为 BIGINT 数据类型的角色 ID,它是自动递增的,并被设置为表的主键。接下来的两个字段是对应于角色名称和一个 slug 的字符串类型,因此我们可以很容易地在一个 HTTP 查询中表示它们(例如,/fqhc-biller/billClaim
)。最后,我们将基本时间戳created_at
和updated_at
添加到表格中以供参考。
在我们实际运行迁移命令之前,让我们定义我们的模型(稍后我将向您展示为什么)。查看列表 7-8 。
// ddl/app/Role.php
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Role extends Model
{
const ROLE_ADMIN = 1;
const ROLE_PRACTICE = 2;
const ROLE_PROVIDER = 3;
const ROLE_BILLER = 4;
protected $fillable = ['name', 'slug'];
}
Listing 7-8Role Domain Model: Role.php
当我们在设计模型时,我们提前知道值将是什么,那么我们应该决定一个标识,系统可以用它来传递不同的角色类型,我们作为开发人员可以用在Role
模型中定义的常数来引用它。这些值将在整个应用中保持一致。我们在执行数据库迁移之前进行建模的原因是,前面显示的 id 和角色名称的值需要与数据库中的roles
表中定义的值保持一致。也就是说,roles
表显然必须保存与存在于Role
模型中的对应表相同的值。
$fillable
数组保存了一个字段列表,这些字段允许由雄辩的各种方法自动生成(例如,如果我们试图将一个值插入一个不在这个列表中的字段,那么使用create()) --auto
以这种方式将数据插入数据库将会失败)。把$fillable
想象成一个白名单。
首先,我们在迁移类中导入新定义的Role
模型,这样我们可以使用更易于阅读的语法(Role::ROLE_ADMIN).
重新打开Role
模型,并将其附加到文件顶部的use
语句列表中:
// ddl/app/Role.php
use App\Models\Role;
让我们将清单 7-9 中的内容添加到角色迁移文件中up()
方法的末尾。
public function up()
{
//...
Role::create([
'id' => Role::ROLE_ADMIN,
'name' => 'Administrator',
'slug' => 'admin'
]);
Role::create([
'id' => Role::ROLE_PRACTICE,
'name' => 'Practice',
'slug' => 'practice'
]);
Role::create([
'id' => Role::ROLE_PROVIDER,
'name' => 'Provider',
'slug' => 'provider'
]);
Role::create([
'id' => Role::ROLE_BILLER,
'name' => 'Fqhc Biller',
'slug' => 'fqhc-biller'
]);
}
Listing 7-9Modified Migration to Include Standardized IDs, Names, and Slugs
因此,我们在这里所做的是将预定义的角色添加到数据库中,这正是我们在Role
模型中建立它们的方式。现在,我们有了一组记录在案的角色,可以附加到我们的用户,以确定应用中各个点的授权,并且我们有了一种简单的方法,可以使用我们在模型中定义的常量来引用它们。现在,当我们运行php artisan migrate
命令时,我们将创建数据库表并且用我们在清单 7-9 中定义的角色填充它。
我们还需要修改User
类来包含新的角色,就像我们在清单 7-10 中所做的那样。
// ddl/app/User.php
//import the Role class so we can use it here:
use App\Models\Role;
// add the following as a new method on the User class:
public function roles()
{
return $this->belongsToMany(Role::class, 'role_id', 'user_id');
}
Listing 7-10Modifying
the User Model to Support the New Roles We Defined
要完成这个例子,我们还有很多工作要做。其中一些需要对 DDD 和 DDL 内部发现的更高级的主题和技术有更深入的理解。这就是为什么我们将在几章的课程中涵盖所有这些内容。我们已经使用系统的知识,使用雄辩作为 ORM 来勾画模型。我们有我们的基本领域模型集,我们从雄辩的Model
类扩展而来,给我们超能力,让我们能够更容易地管理和更好地促进它们,操纵它们,保持它们,或者破坏它们。
当你把雄辩的特性和 DDD 的技术结合起来,你会得到一个丰富的领域模型。它之所以丰富,是因为该模型直接对应于业务本身所基于的底层业务规则和策略。该模型将反映对核心领域的深刻见解和理解。这些模型很容易相互区分,就像它们在现实世界中一样,并且很容易被公司或部门的所有成员识别。这(并不奇怪)是因为它已经被公司的领域专家很好地建立、迭代地改进并完全同意。这是 DDD 的终极目标。
现实检查:现实世界中的成功
项目进行到这一步并不容易。在我看来,使用框架比不使用要好,使用 Laravel 比其他任何框架都好。至少,我想给你一些你可以在自己的过程中使用的例子。在接下来的几章中,我们将深入讨论 DDD 过程,以及如何用 Laravel 实现它们。
我们的索赔示例的主要目标是构建一个解决方案,该解决方案将接受传入的数据,在属性级别和对象级别对其进行验证,并将索赔作为一个集合进行跟踪,以便可以独立保存索赔的各个部分,并且可以从索赔在任何给定时间所处的状态获得状态。我们还应该在某种事务日志中记录我们正在做的事情,这既是为了调试的目的,也是为了关于应用或其活动部分的简单信息。我们需要以一种模块化的方式设计它,并且在适当的位置有适当的边界来分隔或分组域对象,以试图反映它们的真实世界结构。我们需要能够识别由其他对象组成的对象,并且我们应该能够以一种简单的方式重建这样一个对象。
也就是说,我们在设计中遇到了一些障碍: Laravel 的默认目录结构。 Laravel 建立在一个整体结构中,因为我们已经选择使用 PSR-4 自动加载 composer,目录结构与类所在的名称空间完全相同。我们不可能在一个目录中定义这个声明示例所需的所有模型和对象…有多个部分对应于使其工作的领域中的各种组件。我们有必要摆脱一个适合所有人的单一目录解决方案。尽管它完成后在技术上仍然是单片的,但它将使我们处于向微服务或六边形架构发展的有利位置。微服务对组件分离产生了更深远的影响,因为它要求将领域逻辑分割成单个、自封装的“服务”,这些服务分布在几个服务器(传统方式)或多个容器(使用 Docker & Kubernetes)。
结论
虽然本章中介绍的体系结构是一种稳定的体系结构,在当今的开发中广泛使用,但它的设置非常复杂,需要对各种与开发运营相关的概念和专业知识有较高的理解和应用知识,才能正确设置虚拟专用服务器(VPS)、企业级网络和子网,并建立正确的防火墙规则和路由规范。在本书中,我们不打算构建一个分布式系统,但是我们要做的是上一步……将我们的业务逻辑封装到由领域结构(我们称之为有界上下文和模块)规定的自然边界中,这样它们可以分布在不同的节点上。这最终成为一个领域模型,其内部活动部分完全分离,可以独立开发,由不同部门管理,并且可以独立部署,而不需要部署整个代码库。微服务或面向服务的架构还有许多其他好处——不胜枚举。
回到手头的问题——我们需要一种简单的方法来重构 Laravel 附带的名称空间和目录,以便我们可以在我们的领域模型中采用关注点分离,并有一个坚实的主干来帮助我们构建通过我们在本章和前面章节中讨论的管道提交和处理索赔所需的剩余功能,以及管理用户类型和角色——确保系统中的权限、策略和受保护资源仅由允许使用它们的用户设置和访问。下一章将致力于这样做……到下一章结束时,我们将为我们的各种模块和类建立一个架构,在此基础上实现更复杂的特性。
对我们来说幸运的是,Laravel 有内置的设施来定制行为、结构以及我们需要定制的几乎所有其他方面,以满足我们独特的项目要求和指导方针。我们将在下一章探讨这一点以及更多!
八、模块化 Laravel
在前一章中,我提到我们在开发索赔处理应用示例时遇到了某种障碍,我们从本书开始就一直在慢慢定义这个示例。我们正在构建的应用有许多不同的有界上下文、一个通用子域和一堆不同的模块,这些模块封装了我们在应用中需要的各种特性和组件。如果我们在新的 Laravel 安装中使用现成的标准目录结构,我们很快就会发现,一旦我们在实现未来的功能请求时开始在应用中构建更复杂的内容,事情就会变得混乱、分散,并且几乎无法维护。
我们计划让我们的应用有较长的生命周期,这意味着它需要能够承受变化。在任何软件项目中,变化都有不同的形式。特性被添加或删除,初始版本中应该包含的内容的优先级也在不断变化。更不用说范围蔓延,这本身就是一个很难处理的问题,尤其是在结构化的团队环境中工作时。如果我们将应用的所有逻辑放在一个单一的名称空间/目录结构中,我们将没有强大的团队动力,也不知道在给定的时间里谁在处理应用的哪些部分。多个部门将花费更多的时间来解决开发过程中出现的问题,而这些时间将更好地用于完成工作。此外,应用的部署必须一次完成,这为错误创造了空间,并且当完全不同的部分被修改时,还会导致应用的其他不相关部分无缘无故地中断。这是无法预测的,会导致面向用户的站点第一次出现错误,因为整个应用必须部署来修复一个 bug。
这些都是合理的可能性,在现实世界中,发生的速度有时比你所能掌控的要快。为了防止这种事情发生,我建议花足够的时间来考虑这些特性对我们整个系统的影响,并适当地计划出满足这些需求的组件结构。通过适当地分离我们的代码,以符合从域模型派生的标准,我们正在为成功做准备,因为我们可以在域模型中定义的相应目录和名称空间中独立地构建应用的不同方面。因为模块和类是用通用语言中的术语命名的,所以不存在哪个模块做什么的问题。
在本章中,我们将花一些时间来看看 Laravel 附带的名称空间结构,这样在修改它之前,您可以很好地理解这个结构。这也将让我们知道需要为我们自己的项目需求修改什么。索赔项目将被分解成相应的部分,这将允许我们将核心域模型分离成它的各种组件,并形成一个目录和名称空间方案,它将满足项目定义的需求。这种最初的尝试并不完美,但它将为你获得一些关于如何改变 Laravel 默认行为的知识打下坚实的基础。它还将为您提供一些关于按照有界上下文分割项目和识别任何通用子域的指导方针。
在后面的章节中,随着我们对领域本身以及 Laravel 的内部工作方式和默认机制的更多了解,我们将细化我们的结构并重构名称空间和结构。
Laravel 的默认结构
我曾经问过 Laravel 框架的创建者 Taylor Otwell,他是否在编写框架时考虑到了领域驱动设计的概念和实践。他回答说,他在构建框架时没有将 DDD 纳入其中,而是依靠自己的经验和专业知识来构建最终成为世界上最流行的基于 PHP 的 web 框架。
在看到 Laravel 开箱即用的目录结构后,答案并不难理解(你可以在图 8-1 中看到股票根目录)。对于简单的应用来说,这种结构没有任何问题,但是对于域驱动的应用来说,这是行不通的。
图 8-1
Laravel 的默认目录结构
如果我们打开相应名称空间结构中的文件,我们会发现它符合几乎所有现代 PHP 框架都使用的 PSR-4 自动加载标准。让我们分解 Laravel 的默认名称空间以及它们包含的内容。
默认结构
让我们来看一下 Laravel 用来查找文件的默认名称空间(如表 8-1 所示)。一旦我们对 Laravel 的工作方式有了更好的理解,我们就可以开始充实扩展 Laravel 所需的修改。总而言之,我们将更好地理解 Laravel 的结构,以及我们可以保留和修改它的哪些方面。
表 8-1
Laravel 中的名称空间及其在应用中的对应层
|
目录
|
命名空间
|
描述
|
| — | — | — |
| /app/Http/Controllers
| App\Controllers\
| 框架中的所有控制器 |
| /app/Http/Requests
| App\Http\Requests\
| 应用中的所有请求 |
| /app/Http/Middleware
| App\Http\Middleware\
| 所有中间件 |
| /app/Http/Jobs
| App\Http\Jobs\
| 所有工作 |
| /app/Policies
| App\Policies\
| 授权策略(基于模型) |
| /app/Providers
| App\Providers\
| 应用中的所有服务提供商 |
| /app/Events
| App\Events\
| 生成的事件 |
| /database
| | 基础设施问题(数据库) |
| /database/seeders
| | 包含在每个表的基础上在数据库中创建虚拟记录的类 |
| /database/factories
| | 通过从一组标准中生成新的实例,为单元测试系统模型提供工厂 |
| /database/migrations
| | 一个重要的目录,包含数据库的所有变更和创建规范 |
| /resources/views
| | 存放刀片视图文件 |
| /routes
| | 申请途径 |
| /tests
| Tests\Unit
| 单元测试 |
现在很清楚,我们需要的是一个新的应用结构,一个满足所有先前指导方针的结构,一个专注于有效表达领域的结构。目录的名称表明,目录中的大部分代码很可能与应用层相关,这是有意义的,因为它是一个整体应用的应用框架,该应用不应该由一个以上的部门或几个程序员在给定时间内分发、共享或开发。对于领域驱动的设计,我们想要的是一个尽可能接近实际领域的目录和名称空间结构,这是我们不可能以系统的应用为中心,用目录的方式来完成的。对于我们在前一章中构建的索赔应用的用例来说,名称空间太普通了。
我们希望能够查看该结构,并很好地了解应用实际上做了什么,因为目录结构对应于底层的业务模型,而且目录是以无处不在的语言中定义的术语命名的。这一部分很关键,值得重复:
- 使模块、类、子域、域以及所有其他与域相关的组件的名称空间反映出在通用语言中达成一致的术语。
通过遵循这个简单的实践,项目的目录和名称空间结构将根据业务域的构造方式被真实地建模,因此,查看项目的根文件夹的任何公司成员都将容易理解。应用由域关注点分解。应用中的模块代表业务本身运行的相同格式,它们使用公司无处不在的语言中的术语进行分离和命名。
幸运的是,Laravel 足够灵活,能够支持我们可以处理的几乎任何目录结构,不管我们在工作的特定领域中可能遇到的复杂程度如何。我们可以利用 Laravel 强大的扩展特性来重新连接名称空间,使之更加领域驱动,这是一个很好的观点。
正如您将在本章后面发现的,尽管 Laravel 的构建不一定没有考虑 DDD 的概念和程序,但这并不意味着它没有使用最佳实践和可靠的标准。具体来说,这里展示的最佳实践是开放/封闭原则。开放/封闭原则基本上意味着一个应用、程序或框架应该对修改封闭,但对扩展开放。这相当于我们应该能够改变 it 的行为或结构,而不必修改构成应用内部设施的核心文件,相反,我们应该有一个预先确定的方法来扩展应用的行为,通常是通过使用附加类或修改高级服务类,甚至修改配置文件或常量。Laravel 可以让你做到这一点。
服务提供商
Laravel 与目录和名称空间结构相关的配置可以在框架源代码的深处找到…不太适合直接修改它,因为它位于vendor/
文件夹中。Laravel 使用称为服务提供商的设施来配置系统的各个方面。服务提供者是配置您的应用的中心位置,是注册各种服务、创建服务定义中所需的各种类的实例或配置服务容器的好地方,服务容器为 Laravel 中用于应用层和基础设施层的核心服务提供动力,它们也是我们将用来配置应用的域层的地方。
Note
有关服务提供商以及如何在 Laravel 中使用服务提供商的更多信息,请查看第四章,或参考位于 https://laravel.com/docs/6.0/providers
的 Laravel 文档。
一般来说,服务提供者用于定义服务容器内的各种注册、绑定、单件和别名,这些服务容器可用于创建复杂的、结构良好的和精心制作的服务定义,这些服务定义可在整个系统中使用。在我们开始这些修改的编码部分之前,让我们定义我们希望我们的结构如何寻找索赔项目。
索赔申请的结构
回到我们在前一章关注的索赔示例,我们需要一种方法来组织我们的领域层的结构,同时仍然记住其他层。
Note
尽管我们决定将“索赔估计”的概念作为一个单独的有界上下文,但是由于大量的补充信息,我们将不会在本书的后面部分重点关注该领域的建模,因为一次阅读这些信息太枯燥了。我们将在后面的章节中探讨应用的索赔评估特性的先决条件(例如 CPT 代码组合、支付代码表以及各个 CPT 代码应该如何分组)。现在,让我们关注索赔的提交和验证问题。
我们需要为我们的应用设计一个更好的结构,一个指示业务关注点的结构,它的类和模块是有意命名的,用无处不在的语言定义术语。
构造名称空间和目录的准则
在 DDD 中描述了一套指导原则,这将有助于确保我们在定义应用目录及其名称空间的主干结构时使用最佳实践,该指导原则在 Eric Evan 的蓝皮书中首次正式介绍和强调。
-
对模块、类、有界上下文和名称空间使用通用语言中的名称。
-
不要根据特定的模式或构建块来命名任何东西(例如实体、工厂等)。);坚持使用通用语言中的术语。我的观点是,如果它们被包含在一个与无处不在的语言中的一个概念相关的文件夹中,作为一种在技术层面上分离功能的手段,那么它们将是很好的,但是只限于从自然语言的领域中定义的边界的范围内。
-
以这样一种方式创建名称空间,使得其他名称空间中的各种类或组件之间的耦合非常少。做到这一点的一个好方法是坚持关注点分离原则,使用领域中的术语。
-
随着对项目需求或领域知识的深入了解,重构名称空间和目录名以包含新的见解,就像我们重构代码本身一样。
-
避免使用商业产品名称作为名称空间,因为它们变化太频繁。
最重要的是,作为给定业务模型(及其自然结构)基础的概念和隔离应该驱动架构和领域模型的设计。无论您在哪个领域工作,适用于业务本身的有机模型和过程都应该指导关于应用结构的方向和决策,以及实体的选择和它们之间的界限。当领域的各个方面被适当地划分到构成应用所提供的全部功能的各种有界限的上下文和模块中时,并且当这些功能按照它们所代表的真实世界的业务对象进行建模时,我们会看到许多好处,如下所示:
-
代码是领域模型的清晰表示,这很可能涉及到开发人员和领域专家的共同努力。
-
领域的每个部分位于代码中的什么位置,在什么上下文中细粒度的部分对应于什么粗粒度的上下文,这是显而易见的。
-
在哪里放置额外的模块或类的问题更容易回答,因为系统有定义良好的模块,这些模块很容易通过无处不在的语言中的术语来识别。
-
它使我们有可能将我们的应用过渡到一个更加隔离、定义更加清晰的微服务或六边形架构。
-
我们可以彼此独立地开发每个有界上下文(甚至在模块级别),这意味着它们可以使用不同的语言,并且可以由单独的小组或部门来处理。
-
尽管在部署时您仍然需要部署整个代码库,但是应用作为独立的组件将更容易管理,并且您将更接近于将其转换为完全分布式的架构(如微服务)。
索赔属于哪一类?
我们将不得不修改这个结构,使其不那么通用,而更具体地针对我们的索赔处理示例。由于我们还没有设计到可以开始绘制构建块组件的状态,我们将需要用代码实现概念验证,我们将使用我们所知道的来勾画出一个粗略的目录和名称空间结构,记住我们可以总是并且应该总是重构模型及其实现,以反映该领域中获得的任何知识。
我们对这款应用了解多少?我们知道有界的上下文,我们知道子域,我们对我们需要的模块有一个粗略的了解。我们需要清楚明确地区分有界上下文,将它们彼此以及与应用的其他部分适当地分开。这种分离应该基于领域的轮廓,或者存在于业务领域中的概念或部门的自然分割。我们还想记住,我们使用的是分层架构,并且层本身也提供了足够的分离手段。让我们从我们的有界上下文开始(注意,现实中有更多,但是到目前为止,在本书中我们将集中于我们在第六章中描述的三个)。见图 8-2 。
图 8-2
我们索赔处理器的有界环境
简单回顾一下,流程如下:
-
索赔通过提供商(或诊所)的办公室进入系统,仅允许由
Practice
和Provider
用户类型输入。 -
填写初始表单后,在实际提交给审核小组进行审核之前,会出现自动流程,以确保索赔符合所有有效标准,能够提交给审核人员,此时索赔已“提交”这些流程包括以下内容:
-
验证患者/提供者注册
-
确保所有必需的文件都附在索赔中
-
验证患者资格
-
查找 paycode 表并验证 CPT 代码组合(我们将在本书后面讨论)
-
-
虽然在此模型中还没有表达出来,但审查流程是提交索赔后接下来要做的事情,此时,在将索赔的所有数据发送到 FQHC 进行付款报销之前,都要经过审查者的手动验证。
通用子域
authorization/authentication 上下文是一个通用子域,因为它跨越了所有其余的上下文,并深入到模块本身的核心。这些有时被称为横切关注点(也就是说,一个应用的特定部分足够通用,可以作为一个给定软件问题的事实上的标准解决方案,跨所有其他有界的上下文来实现)。虽然它们不是项目的主要焦点,但是它们在系统中扮演着一些重要的角色,这是系统正常运行所必需的。像这样的问题应该被隔离并封装到它们自己的名称空间目录中。
Note
一般情况下,通用子域使用第三方现成的解决方案来实现,在这些解决方案中,构建或维护它的成员被认为是通用子域所属领域的专家。这方面的一个很好的例子是 Laravel 决定将框架过去通过 Artisan make:auth
命令提供的授权框架的 UI 部分 86 化,以支持使用专门做这件事的第三方供应商:管理用户的授权和认证、创建和管理组、创建和执行权限检查、创建用户角色,以及完成现代授权管理系统支持的任何其他功能。厂商是 Auth0 ( https://auth0.com/
)。
以下是一些跨领域问题的例子:
-
日志:错误可能发生在我们系统的任何一层。通常,某种形式的记录器有一个单独的实现,在整个应用的许多地方都可以使用,包括用于调试和信息目的的所有三个架构层。
-
安全性:认证(“登录”)本身是一个通用子域。封装在该关注点中的功能负责管理用户、权限和角色,因为它们被用作在系统中实施安全策略的标准方式。
-
通知:我们的应用必须为应用中的每个交付机制(API 端点、网页等)启用通知。)这样我们就可以在用户提出请求后,向用户传达(或不传达)应用的状态和状况/良好状态。
从现在开始,我们将把安全问题建模为一个独立的“通用子域”,如图 8-3 所示。
图 8-3
新形成的有界上下文(左)和它们对 Auth 的使用,Auth 是一个通用子域,也包含域为每个用户类型实现的用户类
不要失去视线
既然我们已经尝试了在上下文层次上分离关注点,我们就有了一个粗略的草案,从各方面来看,这是系统可能的最高层次的视图,至少对于这次讨论来说是这样。然而,请记住,您创建领域驱动设计的最终目标,简而言之,等同于构建一个模型,在这个模型中,边界将各种组件分开,以便它们反映真实世界中对应组件的结构和上下文。总是试图让领域和领域的模型保持步调一致。我们希望设计一个应用,它能揭示意图,有意义,并与他们存在的要实施的策略和业务规则相关。我们希望能够在模型中捕获领域的所有古怪之处,最终,通过进行频繁的提交,一次狭隘地关注单个任务,遵循编码指南和最佳实践,并采用从持续迭代范例中借用的概念,努力改进模型以尽可能地符合真实世界的领域。
对于任何关于架构设计的粗略草案或松散规范,我们需要记住的是向后工作,看看给定的设计是否仍然满足该领域的条件和要求,并以简单、结构化和明确的方式恰当地表示它。让我们快速浏览一下这个领域,看看我们创建的有界上下文是否与我们到目前为止建立的模型一致。通过重新检查我们的工作并确保设计符合领域的需求,我们为成功做好了准备,特别是当我们的项目有一个完整的 CI/CD 管道设置为在每次提交时自动运行时。此外,自动运行的测试套件(通常与 CI/CD 管道放在一起)甚至可以进一步稳定软件项目。
DDizing Laravel
在本章的持续时间里,我们将讨论一个潜在的解决方案来实现一个利用 Laravel 框架的能力的架构,同时保持 DDD 所建议的概念和对领域模型的关注。
通过分层架构
为了创建这个 DDD 友好的 Laravel 应用,我们必须包含一个新的名称空间和目录结构,因为默认的名称空间和目录结构不能满足我们的需要(过于单一)。对于我们的第一次尝试,我们将采用分层架构模式,并尝试将 monolith 划分为与我们现在应该习惯看到的三个架构层相对应的各种组件:域、应用和基础设施层。
定制 Laravel 以满足我们的需求(与 DDD 概念和实践相关)的第一步是运行当前的股票目录/名称空间,并将每个目录/名称空间分类到相应的层中。我们已经开始了这个过程(见表 8-1 )。这是这样做的结果,但首先,快速回顾一下每一层包含的内容:
-
应用层
- 负责编排、组织和封装域行为并控制数据访问
-
基础设施层
- 通过实现领域层中定义的抽象接口(通过依赖倒置),处理持久性机制和日志记录等问题
-
域层
- 任何 DDD 项目的核心和灵魂(也应该是每个 web 开发项目的核心和灵魂),包含了使业务实际运行的核心和基础
表 8-2 列出了 Laravel 默认架构中的各层。
表 8-2
根据每个目录所属的层定义股票结构
|
目录
|
建筑学的
层
|
描述
|
| — | — | — |
| /app/Http/Controllers
| 应用 | 控制器接受进入系统的请求,并通过一个单一的、明确定义的结构(称为 API)响应不同的交付机制。控制器可以被认为是外部请求和内部域过程之间的仲裁者。 |
| /app/Http/Requests
| 应用 | 封装请求,并将它们与交付机制和域/基础设施问题分开。 |
| /app/Http/Middleware
| 应用 | 中间件在请求/响应周期中的特定时间运行,通常执行应用层中的操作。 |
| /app/Jobs
| 领域 | 作业可以被认为是“命令”,或者是封装在单个Job
类中的狭义任务。 |
| /app/Policies
| 应用 | 系统的策略在请求级别控制对每个模型的访问;认证问题是应用层的问题。 |
| /app/Providers
| 应用 | 我们将使用服务提供者来配置我们的自定义目录结构,并为第三方软件包提供一种配置方式。 |
| /app/Events
| 领域 | 当领域层中发生了特定的事情时,事件将被触发,并将作为该动作的记录,该动作可能会触发其他动作。 |
| /database
| 基础设施 | 数据库目录包含所有数据库问题和模式配置,它们在基础结构层中运行。 |
| /database/seeders
| 基础设施 | 种子是数据库的测试记录。 |
| /database/factories
| 基础设施 | 数据库测试工厂。 |
| /database/migrations
| 基础设施 | 迁移包含一个详细的、每次更改的数据库模式更改记录。 |
| /resources/views
| 应用 | 这也可以放入一个单独的层,称为“视图层”或“表示层”,通常只包含 UI 问题 |
| /routes
| 应用 | 路由定义了可以进入的内容和 URI 的配置,最终导致控制台命令运行或网页显示 |
| /tests
| * | 在一个构造良好的系统中,通常会找到覆盖应用中每一层的测试 |
在表 8-2 中需要注意的是,大多数目录都封装了应用问题(即属于应用层)。当您考虑到 Laravel 是一个“应用框架”这一事实时,这是有意义的换句话说,在典型的 web 开发项目中,它在整个系统中处理尽可能多的应用级关注点,因此,默认结构中的应用关注点构成了目录和名称空间的大部分是正确的。
事实上,从 Laravel 的文档中可以看出,它打算为您提供最实用的解决方案,以解决最常见的应用级别的问题,这样您就可以专注于对任何 DDD 应用来说最重要的事情:领域。它允许应用的“连接点”具有灵活性。我指的是将各种组件连接在一起的代码,比如服务提供者和依赖注入。
也就是说,我认为这里最好的方法是让 Laravel 做它擅长做的事情,即管理各种各样的应用问题。我们将重新构造名称空间的布局,在应用中分割出各种有界的上下文。我们将保留根应用文件夹,这是 Laravel 的默认项目文件夹,但只保留适用于整个应用并且不适合我们声明的有界上下文的文件和目录。这些包括服务提供者、策略、控制台内核和负责分配中间件组和中间件路由的App\Http\Kernel
。
我们将按照以下步骤对其他所有内容进行分析:
-
分析项目根目录中的组件、类或模块,并确定其所在的层。
-
确定每个组件属于哪个有界的上下文(或者它们是否出现在所有上下文中),或者它们是否应该留在通用的
app/
文件夹中,因为它们属于整个系统而不是单个上下文。 -
将项目从它们在根项目目录中的默认位置移动到它们在一个或多个有界上下文中各自的位置(在某些情况下,我们将不得不复制应该包含在每个模块中的结构的一部分,作为属于它们相应上下文的独立构造*)。*
-
一旦我们确定了一个稳定的结构,我们将对配置和服务提供者进行必要的修改,让 Laravel 知道在哪里可以找到与系统中各种端点相对应的代码。
-
修改
composer.json
文件,以包含在上一步中创建的新名称空间结构的自动加载,并创建必要的提供者,以将我们的项目代码与 Laravel 的内部机制挂钩。 -
不断重新审视核心需求,以确保我们的设计满足系统的需求和要求。
开始吧。
步骤 1:分析项目根目录结构
如果我们采用一个标准的 Laravel 安装,并仔细检查它的默认目录结构,我们很可能总是会发现结构的某些部分,要么是某个特定项目不需要的,要么是对于我们所需要的结构来说过于死板。Laravel 中的标准结构并不是模块化的,而是将其大部分“项目代码”塞在/app
目录中。对于我们这些想要使用 DDD 实践来构建应用的人来说,这并不理想。
一个好的第一步是分析 Laravel 中默认的目录结构,并将其与您项目的需求进行比较。这可以让您了解如何修改名称空间结构以符合您的需求。例如,如果您正在构建一个五页的信息网站,只有一个联系表单,可能还有一些内嵌的图形,您很可能不需要 Laravel 提供的大量组件,也不需要(或想要)为这样一个琐碎的项目实现 DDD。另一方面,您可能会发现项目的范围相当复杂,最好用更正式的名称空间结构来表示,因此您可能倾向于将app/
目录分成更易于管理的层(即应用、域、基础设施和接口层)。
步骤 2:确定每个有界上下文需要哪些组件
看起来我们实际上正在处理两个有界上下文以及一个通用子域,我们将遍历默认结构,并确定在每个有界上下文中需要哪些(声明提交、声明验证)以及哪些属于通用子域(授权/认证)。在表 8-3 中需要注意的是,我们在第二列中确定的有界上下文不一定反映给定的结构或名称空间。例如,索赔提交上下文(因此用户可以通过浏览器与应用交互)和 Auth 子域(因此用户可以登录、注销和注册)将需要 Views 组件。
表 8-3
在我们的索赔示例中,每个有界上下文中需要的组件
|
拉勒韦尔分量
|
索赔上下文
|
说明
|
| — | — | — |
| → Http→控制器→请求→中间件 | 提交索赔索赔验证认证 | 所有的 web 应用都有相同的基本流程:接收请求,返回响应。这些组件驱动这一基本需求,因此我们需要它们用于所有的上下文。 |
| →工作 | 提交索赔索赔验证 | 作业将封装特定领域的知识,并提供执行特定的一次性操作的方法。 |
| →政策 | 认证 | 尽管策略围绕给定的模型,但一般来说,对身份验证的关注是对应用的关注,这是有意义的,因为应用层是域层的直接客户端。 |
| →供应商 | 通用(应用/)提交索赔索赔验证认证 | 应用的所有层都需要服务提供商,包括驻留在/app
中的通用服务提供商。每个 BC 也有自己的,并且有一个内置的授权关注点:AuthServiceProvider
。 |
| →事件 | 通用(应用/)提交索赔索赔验证认证 | 事件将发生在应用的所有层:领域事件、授权事件和系统范围的事件。 |
| → Artisan 命令 | 提交索赔索赔验证 | 对于各种一次性任务和定期维护,控制台命令非常方便。我们希望我们的两个 BC 都有一个Console\Command
名称空间。 |
| →例外 | 通用(app/
)提交索赔索赔验证认证 | 异常可能在应用的任何层随时发生,包括一般的系统问题和身份验证错误。 |
| →视图 | 提交索赔认证 | 我们最初需要输入数据的视图来创建索赔以及登录/注销 UI。验证组件不应该需要它,因为它的功能发生在后端。 |
| →路线 | 提交索赔索赔验证认证 | 为了允许从外部资源访问我们的应用,我们需要路由。我们还需要能够将事情路由到后端,所有的业务连续性,我们正在从应用中删除默认的通用路由文件。 |
| →测试 | 提交索赔 o 单位 o 功能索赔验证 o 单位通用/授权 | 所有 BC 都有某种形式的测试,或者是单元测试,或者是单元测试和功能测试。我们可能有也可能没有授权、认证或其他通用组件的测试。当我们这样做的时候,我们可以为他们腾出空间。 |
这并不表示在每个相应的上下文中都应该有一个views/
文件夹。我们将在本章的后面详细介绍我们的应用的结构。
表 8-3 是一个目录,可以这么说,默认结构与我们试图实现的内容之间的关系,将很好地作为每个上下文需要哪些组件的通用指南,稍后将用于确定支持它的名称空间和目录结构。在的大蓝皮书 (Eric Evans)中,他指出,通常,框架会强迫你进入一种特定的风格,或者把你限制在它们自己采用的结构中,这使得按照我们想要的方式实现事情变得更加困难…DDD 建议的方式。
然而,我的目的是在一个两者相辅相成而不是互相争斗的框架内展示 DDD 的一种可能的实现。Laravel 将主要用于其设施和主干特性,为应用层中的应用级问题提供答案。这将使我们能够集中精力实现 DDD 定义的模式和实践,而不必过多地担心应用问题或“夹缝”中的地方,这些地方经常是导致开发和维护失败和浪费时间的原因。
Tip
DRY 原则在编程界是一件大事。如果你还不知道 DRY 原则是什么,它是一个简单的缩写,意思是“不要重复自己”这是一条很好的建议,也是我们在开发软件时应该一直尝试采纳的建议。
步骤 3:重组项目目录
这本书的这一部分很难写,仅仅是因为有太多不同的方法去设计一个领域驱动的结构,没有一个答案是 100%确定的方法来创建系统的架构。所以,我认为最好用索赔的例子来记录我自己的经历。首先,我想简单介绍一下 claims 应用的背景,它启发了本文中使用的示例。
A Brief History of the Claim Application
我工作的应用由我们的团队继承,最初是作为一种管理与联邦监管的 Medi-Cal 计划相关的索赔提交流程的方法而开发的,从本质上增加了自有医疗机构可以支持的患者就诊次数,并允许医疗提供者更加专注于他们最擅长的事情:诊断和治疗患者。离岸团队利用了 Laravel 框架,但是离岸团队最初建立的整体架构结构设计得很差(如果它实际上是设计的在任何种类的战略规划的意义上),并且实现是臃肿的。关注点分散在整个应用中,对代码或语法标准的采用没有任何一致性,等等。然而,尽管很糟糕,它还是起作用了。
在作为一个创收的企业对医疗提供商的 web 应用在市场上进行初步构建和概念验证后,所有者决定雇用一个内部开发团队来接管该项目,目标是实现长期可持续性,并能够快速高效地推出新的业务功能。因为该公司是一家初创公司,他们面临着大多数初创公司在开发软件时的典型需求:在整个应用开发过程中,需要随时添加或删除功能。虽然这一愿景最终成为了现实,但最初当第一个内部团队进入代码库时,他们的大部分时间都在灭火(例如,修复 bug)。
由于离岸团队缺乏经验以及其他问题,使得与十人开发团队进行远程工作成为一个更大的挑战(如语言交流障碍),一些概念被误解,其他概念被歪曲或毁容。这经常会留下一堆代码,使存储库变得臃肿;“工作”代码几乎正常工作。修复 bug 通常意味着应用停止运行,我们会赔钱。放下一切,修复应用。当这种情况发生时,团队中的每个开发人员都跳上他们的计算机,在浏览了一连串的堆栈跟踪、异常转储和日志文件之后,开始相互交换想法,看问题可能是什么以及如何在尽可能少的时间内最好地修复它。
我想说的是,我工作了一年多的应用,作为这个例子的基础,绝不是以领域驱动的方式构建的,甚至也不像任何优秀的开发人员所认为的“最佳实践”在解决领域问题的代码的不同部分周围,边界通常是模糊的,因此我们的任务后来变成了将领域的各个部分重构为单独的新结构,这些新结构或多或少地被设置为好像它们是微服务(即,独立的、领域驱动的模块,在领域的每个部分周围有适当放置的屏障),然后我们使用反腐败层模式在同一个整体应用中实现这些微服务,作为将新的自包含上下文集成到原始代码库的一种方式。原始客户端仍然用于相同的调用,只是这些调用都必须更改为指向在我们的反腐败层中建立的新上下文。
我将要向你们展示的将反映我自己的经历,将突出我所面临的考验和错误,并将展示我所决定的最终结构。这将最好地反映以特定领域的形式设计和建模软件的真实世界的经验,并显示在提出一个可行的解决方案方面期望什么,该解决方案将做的不仅仅是“工作”它最终将为在其上构建任何其他未来组件提供基础,并且它可以被分解为独立的微服务,这相当容易,因为它们是根据核心域的自然边界构建的,这些边界将一个域概念组与另一个域概念组隔离开来。那个项目的目标是最终达到一个分布式系统,这是一件困难的事情;然而,您在近期(例如发现缺失的空白或领域中未知的角落)和长期(例如独立部署各种组件并让不同的团队、部门或完全用不同的编程语言开发它们的能力)都获得了回报。
框架应用关注点
既然我们已经有了对每一层中的组件进行分类的明确方法,我们需要想出一个同样好的方法来根据它们各自的有界上下文来组织它们,我们将通过目录结构和名称空间选择来完成。
Tip
请记住,这不是最终的结构,而只是一个粗略的草案。稍后,我们将移动东西并重构这个结构,以便更好地组织。当我们在项目中引入六边形方法时,就会出现这种情况。
让我们看看项目结构在修改之后,但在安装我们自己的有界上下文和域层之前是什么样子,如图 8-4 所示。
图 8-4
索赔项目的新目录和名称空间结构
Note
关于剧透,请参见本书网站或 Apress.com 的该项目的知识库。
在图 8-4 中,我们基本上展示了相对于几页前我们在表 8-2 中定义的应用的修改结构。这里介绍的重新安排在架构上为我们做了一些事情。
-
我们有一个更加清晰的关注点分离,因为我们使用 Laravel 自带的设置,将所有应用于系统的应用范围的组件作为一个整体(而不是我们的有界上下文)保存在项目的根目录中…大多数情况下。
-
Laravel 解决应用问题的默认组件仍然在默认的
/app
文件夹中,并且对应于App\
名称空间,这使得我们可以更容易地识别给定类的作用和位置。 -
所有的领域问题都被整齐地封装在
/src
文件夹中,我们需要在我们的composer.json
文件中设置这个文件夹,这样它就能识别我们的领域层的新名称空间:Claim
。 -
我们已经将 Laravel 附带的几个目录移到了我们的域层的边界内(比如
Jobs
、Events
等)。).
然而,这种结构也有缺点,因为在软件开发中,几乎所有其他看起来完美的东西都有缺点。我想到的主要缺点是架构似乎有点分散。然而,这样做的原因可能是因为实现这一点的开发人员不习惯于 Laravel 风格的目录结构,并且一开始试图记住所有这些文件夹以及它们的作用可能会有点困难。我觉得多一点经验和对框架的使用将有助于减轻这一点。
我们设计的结构反映了 Laravel 的结构。除了添加了src/
文件夹之外,它几乎包含了自框架安装以来就存在的所有其他组件。我们只是对项目根目录下的文件夹做了一些更改,这些文件夹通常存在于传统的 Laravel 应用中,并以 DDD 的名义重新使用。这个决定将帮助我们保持应用的秩序,并允许我们直接关注核心领域模型和领域层中存在的应用的各个方面,并在我们着手开发各种将拼图拼在一起的部件时为我们带来回报。因此,即使整体结构看起来有点忙,也有一个疯狂的方法,这将随着我们继续关注我们的领域而展开。
附加目录
我们需要更多的目录来实现系统的各个方面。我认为最好是在维护我们赖以构建架构的核心领域驱动焦点,同时仍然允许框架承担应用关注的重担之间找到一些中间地带,这是它做得最好的。我们将使用 Laravel 的标准目录和名称空间名称,尽管我们可能会将它们重新定位或复制到其他有界的上下文中。在不使初始结构过于僵硬的情况下,我们需要为这些组件腾出空间,如下面的列表所示:
-
控制台(命令):应用范围的 Artisan 命令。
-
异常:应用范围的异常。
-
Http。
-
控制器。
- Auth。
-
中间件:标准的 Laravel 中间件。
-
请求:在应用范围的上下文中不需要。
-
移动到域层。
-
-
事件:应用中的全局事件。
-
监听器:全局事件的全局监听器。
-
模型:目前唯一适用于应用的模型是
User
模型,我们可以稍后决定迁移它。 -
观察者:适用于整个系统的观察者。
-
策略:策略与域对象相关,并根据给定的模型命名。移动到域层。
-
提供者:在高层次上配置系统的 Laravel 标准提供者。
请注意,我们正在为应用于全局上下文(或系统/应用范围的上下文)的应用的移动部分蚀刻出一个全局上下文。我们将使用这个结构作为有界上下文的模板,但是请记住,我们不希望只是为了维护约定或遵守框架名称限制而添加文件夹。就软件开发而言,简单总是比复杂好,小总是比大好,这表明细粒度组件通常比粗粒度组件好。然而,真正重要的是对象和类的粒度级别是否反映了底层领域的粒度级别。如果我们构建一个简单的单页面应用,其中包含一个在提交时发送到后端的表单和另一个用于确认消息的页面,我们可能不需要几个小组件(包括代表提交的表单数据的Form
模型,可能通过 IP 地址进行跟踪)。另一方面,如果我们从头开始构建一个电子商务平台,我们将需要一个非常复杂的模型阵列来容纳事情发生时发生的所有逻辑(用户查看产品并为该产品选择所需的属性和变化;用户将产品添加到购物车,然后进行结账;等等。).无论您决定创建的模型和组件有多粗糙或多精细,如果您将它们建模为现实生活中的业务规则和底层策略(从业务领域发展而来,并以无处不在的语言命名),就不会出错。
领域问题
让我们深入到src/
文件夹,把我们最初的域结构放在一起。请记住,此时的最终目标是设计一个布局,让我们以领域为中心的组件能够独立生存和开发。我们将尽我们所能,利用我们目前能够获得的关于核心领域(声明)的知识。我们打算利用各种实践并保持高质量,同时依靠基本原则来做出决策。
目录结构:索赔提交上下文
对于领域模型中的每一层,每个有界上下文都有一个文件夹:Application
、Infrastructure
和Domain
。因为我们在很大程度上保留了/app
文件夹,因为它是在一个普通的 Laravel 安装中,所以我们现在基本上可以认为这个文件夹是框架所关注的。/app
现在包含了 Laravel 特有的物品,这将使我们在以后需要时更容易添加更多的物品。这使得src/
文件夹成为与域相关的系统的实际“模型层”。然而,在领域的保护伞下,作为一个整体,需要一个清晰的边界来围绕它的子层次架构问题,这就是为什么我选择为每一层包含一个目录(图 8-5 )。这也将支持长期增长,因为任何特定的代码片段都应该立即存在于何处,这一点很清楚。
图 8-5
提交受限的上下文结构
关于我们构建系统布局的方式,需要注意的另一点是,我们利用了无处不在的语言中的术语,并且只为模式或“类型”(即工厂、控制器等)创建分离。)位于直接属于域(并从 UL 中提取)的父名称空间之下,并且实际上属于域模型中的给定上下文。
因此,我们在图 8-5 中所做的是将每一个标准关注点(用 Laravel 的术语来说)分解到有界上下文中的相应层。请记住,我们希望每个 BC 都独立于其他 BC(松散耦合),以便有一天我们可以将它们拆分成独立的微服务,这样它们甚至可以放置在不同的服务器上,或者由不同的部门或团队开发。这相当于有界上下文需要包含所有的标准组件,使其能够独立运行。当它们达到这一点时,它们被认为是松散耦合的、可重用的组件,甚至可以单独部署或用不同的语言编写。
以下是关于图 8-5 中结构的一些附加说明:
-
之前,我们将策略确定为一个应用问题;我们在提交上下文中包含索赔策略,因为我们希望我们的上下文尽可能独立,并且与应用的其余部分分离。我们希望将它们放在各自的上下文中,这与该策略适用的模型相同。例如,ClaimPolicy 将存在于提交上下文中,因为
Claim
域模型也存在于其中。 -
我考虑过创建一个
Database
文件夹,它位于有界上下文的基础设施层中,只存放该上下文的数据库问题(迁移、种子、测试工厂),但是,现在,我决定保留标准的根/database
文件夹的位置不变,并在其中存放所有数据库问题。我对此的理由是,迁移等是系统范围的过程,可能会跨越许多上下文,这将很难单独管理每个上下文自己的database/
文件夹,试图分割域逻辑是徒劳的。这是应用的一个区域,不应该在有限的上下文中分割,而应该留在全局的、应用范围的上下文中。 -
索赔提交上下文中的基础设施包含了我们将在本书后面访问的许多模式。这个想法是,我们在我们的
Domain\Contracts
名称空间中定义接口(用于与持久性相关的存储库、工厂或类),然后在Infrastructure\
名称空间中实现它们,这允许我们维护一个域驱动的方法,并保留“D”的实体(依赖倒置)。基础设施支持模型,处理持久的问题,并为域层的居民提供跨请求操作的方法(由于 HTTP 除了会话之外没有默认的状态感)。 -
在有界上下文的每一层中都有一个
Service
目录,以允许它们是自包含的,并且彼此适当地分开。有关不同层中不同服务的复习,请参阅第一章。以下是您可能会在每种情况下发现的一些示例:-
Application\Service
-
laimLoggingService
-
ProviderNotificationService
-
EmailNewsletterService
-
-
Domain\Service
-
ClaimSubmissionService
-
ProviderRegistrationService
-
PaycodeSheetVerificationService
-
-
Infrastructure\Service
FilterSpecificationService
-
-
尽管我们根据目录包含的模式来命名目录,但是我们是在有限的上下文范围内这样做的。我们仍然保持着实现和底层业务领域之间的连续性,并且在将来会继续这样做,用无处不在的语言中表达的术语来命名事物。
目录结构:声明验证上下文
索赔验证上下文将具有与索赔提交上下文相同的核心结构。一个明显的区别是,验证上下文根本不需要自己的一组刀片视图或表示层,因为它将用于自动验证,并且在索赔数据出现问题或违反索赔提交的前置和后置条件时,将简单地向提交上下文返回某种类型的特定Exception
类,然后提交上下文将处理异常并通过其表示层通知用户。
步骤 4:更新配置
为了使我们之前设计的结构实际工作,我们需要让 Laravel 知道在我们的有界上下文中哪里可以找到各种组件,因为我们编写的大部分代码最终将存在于这两种上下文之一:声明提交或声明验证。
app/Providers/routeserviceprovider . PHP
因为我们采用了新的目录来存储有界上下文的各种路由,所以我们需要告诉 Laravel 在哪里加载这些路由,以及我们希望如何为我们的域层构造路由。我们在app/Providers/RouteServiceProvider.php
文件中做任何特殊的路由配置(清单 8-1 )。
<?php
namespace App\Providers;
class RouteServiceProvider extends ServiceProvider
{
/**
* This namespace is applied to your controller routes.
*
* In addition, it is set as the URL generator's root namespace.
*
* @var string
*/
protected $namespace = 'App\Http\Controllers';
protected $submission_namespace = 'Claim\Submission\Application\Http\Controllers';
protected $submission_dir = 'src/Claim/Submission/Application/Routes/’;
/**
* Define your route model bindings, pattern filters, etc.
*
* @return void
*/
public function boot()
{
parent::boot();
}
/**
* Define the routes for the application.
*
* @return void
*/
public function map()
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
{
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web.php'));
Route::middleware('web')
->namespace($this->submission_namespace)
->prefix('submission')
->group(base_path($this->submission_dir . 'web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless
*
* @return void
*/
protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
Route::prefix('submission/api')
->middleware('api')
->namespace($this->submission_namespace . '/Api')
->group(base_path($this->submission_dir . ‘api.php’));
}
}
Listing 8-1Modified RouteServiceProvider, with Updates Shown in Bold:/ddl/app/Providers/RouteServiceProvider.php
Note
如果你一直跟随,你将需要通过 Laravel 命令artisan route:clear
清除你的路线缓存,并且总是可以通过artisan route:list.
验证你的路线是否被 Laravel 选取。如果运行 clear cache 命令给你一个你不认识的错误,请在本章末尾再次尝试运行它。
本质上,除了加载 Laravel(在/routes
内)附带的标准路由之外,我们在这里所做的是添加对我们的域相关上下文的支持,并说明在哪里可以找到应用的定制路由。mapWebRoutes()
方法通过Route
facade 提供的namespace()
函数给出一个定制的路由名称空间。路由名称空间定义了特定名称空间的位置,该名称空间将是属于该名称空间(和有界上下文)的控制器将位于其下的容器。在我们的例子中,我们正在配置提交上下文的路由,所以我们希望将这个名称空间设置指向我们为这个有界上下文定义的主Controller
名称空间:Claim\Submission\Application\Http\Controllers
。我们指定它位于目录src/Claim/Submission/Application/Routes/
中。现在,当我们在一个路由文件中定义我们的路由时,就像前面提到的文件名为web.php
的目录中的路由一样,我们可以指定这个语法来引用控制器,这样就可以很容易地配置我们的应用的路由结构:SubmissionController@index
。
它指向类Claim\Submission\Application\Http\Controllers\SubmissionController
并调用包含在index()
方法中的逻辑,该方法被设置为位于文件src/Claim/Submission/Application/Http/Controllers/SubmissionController.php
中。
路由前缀只是应用主 URL 之后的 URI 的一部分。我们所做的是配置路由文件中定义的路由(web 路由的web.php
和 API 路由的api.php
),分别以进程/submission
和/submission/api
开始。然后,我们将路由前缀和我们通过路由外观使用其group()
方法给它们的名称空间组合在一起,传递一组应该应用链式配置的有效路由作为其参数,这是我们在相应的路由文件web.php
和api.php
中定义的,如下所示:
<?php
// configured to run SubmissionController::index() when the route
// "/submission" is hit with a GET HTTP request
Route::get('/', 'SubmissionController@index');
// configured to run SubmissionController::submit() when the route
// "/submission/submit" is hit with a POST HTTP request
Route::post(‘/submit’, ‘SubmissionController@submit’);
app/Providers/eventserviceprovider . PHP
Laravel 的事件系统使用 Symfony 的事件组件,并提供了一个额外的配置层,用于配置事件和侦听器的位置,以及关于应用中事件和侦听器的其他功能。我们想告诉 Laravel 这些事件的侦听器在哪里,我们可以通过将这个方法添加到EventServiceProvider
类来做到这一点,这将覆盖它在父类中定义的默认方法(清单 8-2 )。
/**
* Get listener dirs that should be used to discover Events.
*
* @return array
*/
protected function discoverEventsWithin()
{
return [
$this->app->path('Listeners'),
$this->app->path(base_path(
'src/Claim/Submission/Application/Listeners')),
];
}
Listing 8-2Updates to the EventServiceProvider to Specify the Location of the Listeners
步骤 5:创建新的 ClaimSubmissionProvider
随着我们在整本书中继续这个例子,我们将需要一个地方来放置我们的更通用级别的配置和定制,这些配置和定制是特定于特定的有界上下文的。我已经决定在每个 BC 的Application
目录中放置一个Providers
名称空间,以便每个目录可以包含多个提供者,按照领域模型的既定业务需求进行分隔,并以无处不在的语言命名。
Note
因为我们使用了在composer.json
中指定的完全分离的名称空间,Artisan 命令行工具的make:*
命令集将无法正常工作,无法在域上下文的边界内创建组件。为了解决这个问题,您可以发出make:*
命令而不指定生成文件的 FQDN,然后将它移动到您需要的位置并修改名称空间,或者简单地创建一个新文件并复制预先存在的提供者的内容并在需要的地方进行修改。为了简单起见,我选择了创建-粘贴-编辑方法,而不是后者。
在名称空间Claim\Submission\Application\Providers\ClaimSubmissionProvider
中创建一个新的.php
文件,并将清单 8-3 中所示的代码放入其中。
// ddl/src/Claim/Submission/Application/Providers/
ClaimSubmissionProvider.php
<?php
namespace Claim\Submission\Application\Providers;
use Illuminate\Support\ServiceProvider;
class ClaimSubmissionProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
$this->loadMigrationsFrom(__DIR__ .
'/../../Infrastructure/Database/migrations');
//$this->loadTranslationsFrom(__DIR__.'/../resources/lang',
'domain-driven-laravel');
// $this->loadViewsFrom(__DIR__.'/../resources/views',
'domain-driven-laravel');
//$this->loadMigrationsFrom(
__DIR__.'/../database/migrations');
// $this->loadRoutesFrom(__DIR__.'/routes.php');
}
public function register()
{
$this->mergeConfigFrom(__DIR__.
'/../config/claim_submission.php',
'domain-driven-laravel');
}
}
Listing 8-3The New ClaimSubmissionProvider Service Provider
Class
到目前为止,我们的服务提供商在其引导方法中只包含一个设置,这就是上下文迁移所在的位置。在这种情况下,我们将迁移保存在Infrastructure
文件夹中,在Database
名称空间内,并使用标准的 Laravel 命名约定,用于框架提供解决方案的标准数据库问题(其中之一是通过将数据库模式变更递增地记录到所谓的数据库迁移中来前滚和回滚数据库模式变更的能力)。
还要注意,我在这里加入了一些额外的方法,作为我们调整 Laravel 默认行为的其他方法的参考。它们被注释掉是因为我们还不太需要它们,但是例如,当我们想要将视图和翻译拆分到它们单独的上下文中,或者甚至拥有不同的语言文件来支持国际用户时,只需简单地改变服务提供商的引导方法就可以做到这一点。最后,在清单 8-3 中,您会注意到 register 方法只是从config/claim_submission.php
文件中加载任何已定义的配置值。有关配置的更多信息,请查阅第四章。
Utilizing Third-Party Packages, Frameworks, Tools
在当今这个时代,我们经常会看到各种不同的技术被独立开发出来,但最终都聚集在一起,提供一个完整的工作应用。公司通常只有精选的几个基础技术栈来作为公司软件架构的基础:Solaris、Linux/Unix、Windows 和其他一些技术。核心堆栈的选择应该基于系统的需求、基础设施的支持以及开发团队的经验。如果每个人都非常了解 PHP,并且该项目需要一个基于 web 的架构,该架构可以从一个框架中构建以节省时间和成本,那么开源是更好、更实用的方法。但是,如果您希望在技术体系中获得支持,包括客户支持和软件维护/更新/功能,并且您正在寻找易于使用的产品,那么基于 Windows 的技术体系可能是您的最佳选择。
创业公司倾向于采用开源开发,因为它广泛、廉价且有效。有开源解决方案适合几乎所有常见的开发和互联网需求。很多时候,一个架构的成功实现支持一个狭隘的基于利基的业务策略的特定需求,包括找到预构建的库、框架、工具或任何其他东西,这些东西在没有大量开销的情况下增加业务价值和/或加速开发过程并提高最终产品的质量。然后在实现业务逻辑时利用这些第三方库的功能,并将它们组合在一起以实现一些共同的目标或构建一组功能。
尽管这是以一种或另一种形式作为开发 web 应用软件的手段基本上跨越整个行业的循环,但我们必须小心不要混淆该领域的意图。与此同时,许多这样的包和框架,包括 Laravel,都是按照高质量标准和现代最佳实践构建的,因此利用它们并不一定是件坏事。归根结底,这些包的可扩展性如何,以及修改代码的库存分布以满足您的需求有多容易(这就是开源的伟大之处!).我选择 Laravel 作为我的标准 web 开发框架,因为除了其他原因之外,它简单明了,易于扩展和定制。
第六步:后退一步
在这一点上,我们已经勾画出了我们的 claims 示例项目的边界,并且很好地理解了如何划分领域以使其更易于管理。然而,这种架构远非完美。如果您已经注意到,每个有界上下文中的架构层之间的界限经常是模糊的,因为某些组件应该放在哪里并不总是显而易见的。这就好像我们在和拉勒维尔战斗,迫使它屈从于我们的意志。
向前发展,我们需要寻找我们可以架构系统的方法,以便领域仍然是最高优先级,但是使用我们的领域层可以扩展的 Laravel 组件、特征和接口。通过这种方式,我们保持了一种专注于领域的方法来制作应用,同时还能获得 Laravel 提供的最大好处。
问题在于分层架构的封装规则。它声明域不能依赖于它自己层之外的任何东西。本质上,这意味着,因为我们有一些 Laravel 类或接口想要在领域层的边界内扩展,我们将被迫从不同的层扩展到我们的领域模型中。事实上,当我们后退一步,看看我们正在处理的整个范围时,我们发现在我们迄今为止已经建立的架构中,确实没有合适的地方可以容纳任何存在于/vendor
文件夹中的第三方代码或框架库(并且默认情况下没有包含在股票 Laravel 安装中)。但是,当我们试图在一个简单的 UML 图中对此建模时,我们可以看到,对于每一条指向错误方向(即“远离”域层)的线,确实存在一个依赖关系,这表明当依赖关系应该将指向域层时,域依赖于位于域层之外的代码和组件,因此域外的代码依赖于域模型中的类(图 8-6 )。
图 8-6
依赖朝着错误的方向发展
打破规则?
为了给我们的应用形成一个更好的架构,我们将不得不打破一条纯粹主义者极力保护的规则。我来解释一下。
我们需要灵活地思考和理解应用的开发。Laravel 是一个框架。这本身就违背了 DDD 的一个基本观点,即框架在领域驱动的项目中使用起来过于严格。作为开发人员,我们需要理解软件和 web 开发的本质,我们很难真正“从零开始”构建任何东西我自己都不知道上一次我从一个空目录和一个空白 PHP 文件开始编写整个程序是什么时候,而不仅仅是一个脚本或一次性的。简单的事实是,纯粹主义者的心态只能存在于完美的世界中。鉴于这个世界远非完美,我们需要在思维过程和观点上更现实一点。
我们为应用创建新结构的第一次尝试在技术上是可行的,但这远不是一个优化的解决方案。使 Laravel 对我们有价值的组件需要一个地方,以便我们的代码可以利用和扩展它。显然,这个地方是/vendor
文件夹,我们在/vendor
文件夹中引用的项目都有一个结构化的名称空间,位于不同的包中。然而,当您考虑到,在分层的架构中,领域层(也称为业务规则和“低级”策略,对应于您正在使用的实现)应该是自封装的。领域层中的任何东西都不应该知道、使用或依赖于它自己的层之外的任何东西。然而,它可以依赖并使用也位于域层中的类或对象。那么,我们应该如何使用任何第三方包、库,甚至框架,而不让领域层内的项目利用其层外的任何代码呢?
有人可能会说,解决这个难题的一个可能的办法是,要么最小化(或者全部移除)任何第三方依赖,或者更荒谬的是,在你的项目中根本不依赖框架。这显然不是一个非常可行的解决方案。软件应该被扩展、构建,并且最好在其他项目中重用,以提供一些给定的功能集,从而避免为需要相同功能的每个项目重写逻辑。无论如何,这就是我们的想法,并且在完美的世界中也是如此,但是现实是,软件重用通常是一件具有挑战性的事情,而且更具挑战性的是,在尽可能利用外部代码的同时,以一种可扩展的、灵活的、与应用的其他部分松散耦合的方式来实现。
事实上,这是现代软件开发领域的驱动概念,被称为快速应用开发。快速开发的思想是使用尽可能多的第三方包、库和框架来实现您的特性、业务逻辑和您需要系统在功能上做的任何事情,从而释放您的时间和精力来关注更高层次的问题和对系统成功至关重要的重要的、必需的部分(这里指的是领域模型)。所有第三方代码都可供您用来构建许多其他应用共有的特性和功能。例如,在 GitHub 和开源包中有很多关于软件中常见问题的库,包括日志、ORM 问题或 CRUD 包。这些都是为了易于使用,并允许您依赖第三方库,这样您就不必为这些常见的问题重新发明轮子。
另一个可能的解决方案是反转依赖关系,就像依赖关系反转原理一样。这个原则的一般概念被分解成两个独立的规则。
-
抽象不应该依赖于细节。细节(具体的实现)应该依赖于抽象。
-
高层模块不应该依赖低层模块。两者都应该依赖于抽象(例如接口)。
因此,这对我们来说意味着我们可以使用接口(抽象)来使依赖指向另一个方向(向内指向域模型)。对于我们的使用来说,问题在于没有一种明确的方法来从领域模型中提取抽象;它们应该是自包含的,并且拥有与该层中的领域相关的所有知识。此外,因为我们希望在我们的领域模型中利用雄辩的框架,反转依赖关系仍然会给我们留下一个知道领域层之外的事情的领域模型。
进化范式
在过去的二十年中,我们所见证的框架、库、包和工具的爆炸已经产生了足够的影响,完全改变了我们编写代码的方式;然而,自 20 世纪 80 年代和 90 年代以来,我们思考代码的方式并没有太大的改变。当然,我们更加意识到我们的代码所产生的影响,并因此更加小心地选择为使软件工作而创建的功能的最佳位置,并且我们已经学会了在成功项目中经常发生的决策中灵活地让领域专家和利益相关者参与进来。然而,我们思考软件的方式基本上和很久以前一样。自 20 世纪 60 年代以来,面向对象编程就一直存在,并且仍然是用于构建大多数现代软件和 web 应用的最流行的通用范例。四人帮于 1994 年发布的设计模式处女作仍然是所有这些最新和最伟大的框架和“不能没有”的包背后的驱动力,这些框架和包使我们的生活更容易,我们的成功潜力更大。
在我看来,过去几十年发生的事情只是 web 开发编程所经历的自然进化过程;我们只是在最近才采用了那些很久以前发现的标准,但这些标准在今天的 web 开发行业中仍然适用。最近,在前端 web 开发实践和语言的发展中可以看到另一个例子。Vue 之类的框架。JS、Angular 和 React 都源于 70 年前发现的编程范例。虽然看起来前端 web 开发已经呈现出一种新的形式,但实际上所有正在发生的事情与 2000 年左右一般 web 开发所发生的事情是一样的:它正在发展。编程语言的数量在过去 20 年左右也出现了爆炸式增长,其目的通常是成为工具,专门满足随着 web 开发需求而出现的新发现的业务需求。
围绕网络开发的思维过程也发生了类似的事情。纯粹主义者的心态有着良好的意图,并且决不是错误或坏的,但是在我看来,作为一个对任何特定技术、语言、过程或架构方向的“纯粹主义者”,在我们知道如何做某事的当前方式和完成同一件事的新的(通常是改进的)方式之间设置了一个精神障碍,尽管是潜意识的,导致我们作为开发人员的发展能力停滞不前。正如我们所知,我们对开发的理解(理论上)是基于我们不得不花费时间和精力去理解的最佳实践,甚至花费更多的时间和精力在我们的应用中有效地使用*。通常,我们努力实现的这种理解恰恰会阻碍我们利用行业的下一个进步。当然,这些进步采取了许多不同的形式,从长期受欢迎的语言的新版本(PHP 7.0、JavaScript ES6 和 HTML 5,仅举几个例子)到引入全新的开发过程思维方式(Angular 和 Vue)。JS),但它们的最终结果最终都是一样的:一个工作软件、web 应用、API 接口,或者更高级的东西,比如以 Google RPC (gRPC)协议缓冲区为中心的微服务架构。您可以在 https://grpc.io
找到更多关于协议缓冲区的信息。*
*现在,这并不是说设计模式、坚实的原则以及其他关于软件架构和 web 开发的标准和实践不会存在太久。相反,理解这些类型的通用概念对于成为一名优秀的开发人员至关重要,如果使用正确,它们将为创建一个长寿、高质量的软件铺平道路。但是,我们必须小心的是,在我们个人的 web 开发实践中,我们完全服从于单一的关注点或范式。很久以前,人们认为世界是平的。我敢打赌,如果你回到那个时代,提出地球是圆的,你会被嘲笑,因为你没有受过教育的论点而被嘲笑,甚至可能被杀。尽管地球确实是圆的,但当时的心态是它是平的。在古希腊人引入这一理念后,其他人花了相当长的时间(我可以想象)才赶上并接受这一理念。web 开发也是如此。这需要时间,许多实验,许多尝试和失败,才能让整个社区接受并采用一种新的不同的标准。
思想的转变
我们必须对我们的思维进行的最大改变涉及到依赖倒置原则和领域封装实践,这是领域驱动设计的核心。重要的是,我们将我们的领域模型封装在某种概念边界中,该边界将领域的关注点与处理领域层对象的应用或基础结构的关注点分开(无论是持久化它们、初始化它们、重新创建它们、记录它们,等等)。).
有各种方法来处理架构 web 应用。例如,在野外(或者每当开始一项新工作或者继承一个遗留代码库时),往往会发生的情况是,项目的开发不是由最佳实践指导的,而是严格遵循著名的“让它工作就好!”范例。在创业公司中,经常会发生的情况是,无论他们建立了什么样的系统来为他们赚钱,或者为投资者提供概念证明,让他们将钱投入到业务中,这些系统都是用拼凑起来的代码构建的,这些代码很乱,很难维护,甚至更难教给其他人。快速应用开发范例与这一概念并行运行:从尽可能多的预构建内容开始开发过程,然后定制您需要的内容以创建所需的功能,拼凑(此时)“粗略的”业务策略和实践,这些策略和实践实际上仍在应用开始时定义和建立,将其推出到生产中,清洗并重复。
从技术或编程的角度来看,这种方法本身没有任何问题;毕竟,编程游戏的名字就是代码可重用性和抽象。当代码库增长时,问题通常会出现,并且在某一点上在其自身基础设施的压力下崩溃,从而使应用变得无用,或者随着业务需求变得越来越复杂,增加对应用进行修改和添加的时间。在这种情况下,拥有软件的公司通常会被迫做出一个关键的决定,要么整个重构系统,要么两害相权取其轻,从头开始创建整个系统。后一种选择已经在编码行业中被一次又一次地证明几乎总是失败,因为当你从头开始时,你必须用这个新的应用解决你用旧的应用解决的所有问题、争论和故障。
我们都应该更容易接受新的想法,不要让我们对事物如何运作的现有理解或我们对做某事的最佳方式的知识干扰新的和更好的思维过程、工具、概念和实践。我们的数字生态系统中不断发展的实践和标准使我们的行业不那么具有可塑性,当然,这个行业是一个不断重新定义和重塑自己以满足现代业务需求不断增长的行业。
回到我们手头的问题,我们可以做的是打破规则,让我们的领域实体扩展雄辩的 base Model
类,理解我们只是使用它作为一种手段,允许领域层内的模型和事物通过扩展它们或通过特征包含它们来利用框架内的特性,而不是其他。如果我们坚持只依赖框架提供的外部类或特征,那么我们可以保持应用的整体结构完整,仍然清晰地将各种关注点划分到相应的层中,仍然在层之间建立边界,同时仍然利用 Laravel 的功能。
结论
在最坏的情况下,不符合当前标准和最佳实践的架构师和开发人员将“确定”整个系统是糟糕的,他们必须从头开始重写它——基本上是为了重写它。然后,尽管从逻辑上看,第二次实现可能会更快,但下一个版本注定会有与上一个版本相同的问题和缺点。原因可能各不相同。
-
难以建模的过于复杂的领域
-
缺乏设计满足所有项目要求的质量体系所需的适当经验的架构师
-
缺乏一般软件开发经验(低水平或高水平)的开发人员
-
缺乏领域经验的开发人员(尽管您显然不希望让新人负责整个系统或部分系统的架构设计)
-
阻碍进步的公司政治
-
“专家”是公司工程团队的一部分,他们阻止技术进步和新的、改进的流程和标准
-
其他团队成员不愿意改变他们自己的方式来学习新的、经常改进的开发实践
-
团队中的一些成员(甚至是一个成员)不愿意接受自动化过程,即使它可以节省时间、精力和金钱
-
思想狭隘
-
没有学习的能力或愿望
-
还有很多很多其他人…
这种软件开发方法效率不高;然而,它可能实际上看起来并没有那么糟糕,因为,在那个时候,找到足够的包和库在您的应用中使用,然后将它们粘合在一起,通常构建起来非常快(这就是为什么它被称为快速应用开发)。这在某种程度上是一种危险的误解,当企业因为前面列出的原因成长并成功时,这种误解会产生深远的后果。
减轻这种情况的一种方法是使用好的设计原则和设计模式以及抽象和接口,这实际上等同于好的架构。我们发现,当我们在构建系统时采用良好的原则和标准时,我们允许自己在某种意义上灵活地计划变化。通过使用接口和/或抽象类来定义应用中发生变化的各个部分,我们让代码对扩展开放,但对修改关闭。以这种方式,通过简单地扩展该接口并将其插入到应用中,其他代码可以在我们的应用中交互和使用。像这样的事情可能会变得复杂,但是正确使用和理解 Laravel 的服务容器,包括如何设置实现,以便在满足某些内部条件时自动加载和运行。服务容器功能强大,为我们提供了一定程度的灵活性,这对于管理依赖关系和创建基于上下文操作的定制依赖注入配置非常有用。有关服务容器的更多信息,请参见第四章。
在这一章中,我们给出了应用结构的一个起点。虽然各层之间的各种概念和界限在这一点上可能有点模糊,但我们可以利用我们所拥有的东西来共同打造必要的功能,以使我们的应用起步。这种结构远非完美,我们将在本书的其余部分进行修改,当它有意义时。只要它有助于阐明应用的意图,只要我们能够利用框架完成所有 web 应用中的常见任务,我们就会这样做。
但是我们不能忽视这个领域。虽然这一章是大量涉及 Laravel 相关的章节,但是我们应该始终保持领域是最重要的东西的观念,我们前进的最好方式是允许领域驱动应用的构造。通过允许我们的模型实现雄辩的基类,并通过实现诸如可调度事件、服务和 Laravel 作业之类的功能,我们可以访问 Laravel 的所有特性,而不会违反太多规则。我们将开始看到的是,我们在本书中介绍的分层架构只是构建应用(如用 DDL 创建的应用)的一种方式。有更好的方法来分离事物,我们将在后面探索。现在,我们有了一个可以工作的结构,这将为我们构建项目所需的初始功能提供基础。稍后,我们将开始开发一种更好的方法,通过利用用例和六边形架构来构建组件。*