在软件设计和软件体系结构方面,有许多带有精美缩写词的理论方法(例如SOLID,CUPID,DDD,DDD,hexagonal architecture,clean architecture,clean architecture,â)。但是,将这些概念转化为实用世界并不总是那么容易,而且尚不清楚它们在哪里重叠。因此,重要的是要考虑所有学术设计模式的实践,并从first principles开始。几乎所有软件设计口味鼓励的一种真正具体的实践是核心和基础架构代码的分离。这篇文章将通过一个现实生活中的实践示例探讨许多好处和挑战,以便最终您将更好地了解任何软件系统如何以及为什么会实施可持续建筑的租户。
什么是核心,什么是基础架构代码?
每个软件系统都由核心和基础架构代码组成。简单地说,基础架构代码是将主要业务逻辑(核心代码)连接到外界(例如,到Web服务器或数据库)的代码。对于核心代码的定义,我们可以参考Matthias Noback's书籍Advanced Web Application Architecture:
规则1:
核心代码不直接取决于外部系统,也不取决于编写的代码用于与特定类型的外部系统进行交互。
规则2:
核心代码不需要特定的环境才能运行,也不需要仅在特定上下文中运行的依赖项。
软件设计的常见症状是,在代码级别上,很难区分两者。这种缺乏分离反映了依赖性的网络,这些网络造成了许多问题,我们将逐渐通过下面的示例逐步浏览。
。出于本博客文章的目的,基础结构代码不应将基础结构混淆为代码(旋转应用程序运行的服务器/云基础架构所需的代码)。强>
一个例子
考虑以下用于发布客户针对给定产品的新评论的Symfony控制器:
#[Route(path: '/store-api/product/{productId}/review', methods: ['POST']]
public function save(
string $productId,
RequestDataBag $data,
SalesChannelContext $context
): NoContentResponse {
/** @var CustomerEntity $customer */
$customer = $context->getCustomer();
$customerId = $customer->getId();
if (!$data->has('name')) {
$data->set('name', $customer->getFirstName());
}
if (!$data->has('lastName')) {
$data->set('lastName', $customer->getLastName());
}
$data->set('customerId', $customerId);
$data->set('productId', $productId);
$this->validate($data, $context->getContext());
$review = [
'productId' => $productId,
'customerId' => $customerId,
'externalUser' => $data->get('name'),
'content' => $data->get('content'),
'points' => $data->get('points'),
'status' => false,
];
if ($data->get('id')) {
$review['id'] = $data->get('id');
}
$this->repository->upsert([$review], $context->getContext());
$this->eventDispatcher->dispatch(
new ReviewFormEvent($productId, $customerId, $data, $context)
);
return new NoContentResponse();
}
代码很简单地读取:控制器接受一些数据,填写基于当前登录的客户的一些倒下数据,验证输入,将评论保存到数据库中并派遣域事件。简而言之,该控制器一次执行的操作太多了。在本文的过程中,我们将使用此示例来研究可能导致的问题,并逐渐重构,以说明如何将核心代码与基础结构代码分开固有地最小化这些问题或完全解决这些问题。
。独立发展基础架构和核心
随着时间的流逝,您对正在工作的基本领域的理解将有所改善,并且您自然会更新您的业务逻辑以反映这一学习。在最好的情况下,您想拥有一个存储此知识的地方。另一方面,如果您想发展业务,可能还有其他用例,您想在不同情况下提供相同的业务功能(发布评论)。因此,需要基础架构代码和核心代码能够独立发展。
可重复使用
在上面的控制器示例中,人们可以很容易地想象,可能会出现新的业务需求,即商人应该能够从现有的审核系统中进行进口审查。在当前的实现中,这是不容易的,因为所有代码都位于一个位置,并且实际上并非可重复使用。在这种情况下,应适用相同的业务规则,并应派遣相同的域事件,因为对于核心域逻辑,可能并不重要。与其复制并粘贴一堆代码以使新用例工作,更好的选择是分开负责从控制器上发布审核的域逻辑,该逻辑在HTTP-API上提供了该功能。
所以我们的代码现在看起来像这样:
#[Route(path: '/store-api/product/{productId}/review', methods: ['POST'])]
public function save(
string $productId,
RequestDataBag $data,
SalesChannelContext $context
): NoContentResponse {
/** @var CustomerEntity $customer */
$customer = $context->getCustomer();
$customerId = $customer->getId();
if (!$data->has('name')) {
$data->set('name', $customer->getFirstName());
}
if (!$data->has('lastName')) {
$data->set('lastName', $customer->getLastName());
}
$data->set('customerId', $customerId);
$data->set('productId', $productId);
$this->postProductReviewService->post($data, $context->getContext());
return new NoContentResponse();
}
class PostProductReviewService
{
public function post(RequestDataBag $data, Context $context): void
{
$this->validate($data, $context);
$review = [
'productId' => $data->get('productId'),
'customerId' => $data->get('customerId'),
'externalUser' => $data->get('name'),
'content' => $data->get('content'),
'points' => $data->get('points'),
'status' => false,
];
if ($data->get('id')) {
$review['id'] = $data->get('id');
}
$this->repository->upsert([$review], $context);
$this->eventDispatcher->dispatch(
new ReviewFormEvent($productId, $customerId, $data, $context)
);
}
}
有了该结构,通过重复使用提取的服务来实现批量导入评论功能相当容易。
注意:新的核心服务仍然期望一个RequestDataBag
参数,这不是最佳的,但将进一步解决。
在需要额外验证的情况下,该重构步骤也极大地有助于,使客户实际上已经在保存审查之前已经购买了该产品。重构后,有一个可以添加该逻辑的地方,我们可以独立于基础结构发展核心代码。
切换基础技术
基础架构代码可能会改变但不应影响核心代码的另一种情况是,何时需要切换基础技术。关于能够(理论上)为完整应用程序切换整个数据存储层而无需影响核心代码的整个数据存储层。我必须承认,我从未见过在现实生活中奏效的事情,而且我认为这些替代者不应该是我们的最终目标,因为这样的应用可能会过多地设计。但是,在不必触摸系统的其余部分而不必触摸应用程序的特定部分的基础技术可能是一个最佳位置。
回到我们的代码示例,也许会出现某些要求,而不应将产品评论保存在关系数据库中,而是将其发布到应该集成的外部审核系统中。为了使此更改更容易,实际上要保存数据的代码应从核心服务中提取(因为这实际上是基础架构代码),并且应介绍接口,以便核心服务仅依赖于接口。
可以这样重构代码:
class PostProductReviewService
{
public function post(RequestDataBag $data, Context $context): void
{
$this->validate($data, $context);
$this->productReviewGateway->postReviewByCustomer($data, $context);
$this->eventDispatcher->dispatch(
new ReviewFormEvent($data->get('productId'), $data->get('customerId'), $data, $context)
);
}
}
interface ProductReviewGateway
{
public function postReviewByCustomer(RequestData $data, Context $context): void;
}
class DalProductReviewDataGateway implements ProductReviewGateway
{
public function postReviewByCustomer(ProductReviewPostData $data, ProductReviewStatus $status, Context $context): void
{
$review = [
'productId' => $data->get('productId'),
'customerId' => $data->get('customerId'),
'externalUser' => $data->get('name'),
'content' => $data->get('content'),
'points' => $data->get('points'),
'status' => false,
];
if ($data->get('id')) {
$review['id'] = $data->get('id');
}
$this->repository->upsert([$review], $context);
}
}
我们使用Shopware DAL来存储评论的事实现在是实施细节。我们可以轻松更改该存储层而无需触摸核心业务逻辑。
注意:找到正确的抽象
创建抽象以将技术决策隐藏到该代码的消费者中时,一个主要的挑战是定义正确的抽象水平。
一方面,很容易创建一个太具体且与leaking technical details as part of the interface的实现相结合的抽象。这导致了一个情况,即使存在抽象,也无法切换实际实现而不会破坏抽象。
另一方面,抽象太通用也可能发生。如果是这种情况,那么抽象的客户必须为实施该抽象实现的常见案例实施解决方案。
因此,找到正确的抽象级别总是在太具体或太一般之间的权衡。
可检验性
能够轻松地为您的任务关键业务逻辑编写单元测试是为了提供高质量软件的先决条件。如果基础架构和核心代码没有分开,则很难编写这些单元测试,因为基本上必须制定整个系统才能执行它们。回顾从一开始的原始单个控制器示例,为了为该代码编写测试,需要设置数据库,售货机DAL需要功能性,这意味着必须设置Symfony容器正确,您必须伪造请求才能完全运行测试。
我们已经可以看到,为了与核心代码分开发展基础架构的重构已经大大改善了可测试性,因为我们现在可以分别测试基础架构代码和核心代码。核心代码特别容易测试,因为我们不需要再设置一个运行的数据库和Symfony容器。相反,我们可以使用ProductReviewGateway
接口的模拟或虚拟实现。唯一使我们的核心代码(PostProductReviewService
)单元测试比需要的唯一一件事是对RequestDataBag
参数形式的HTTP-重新要求的依赖性。为了使其更易于测试,我们可以定义一个封装我们服务需要的实际参数的DTO。
class ProductReviewPostData
{
private function __construct(
private string $productId,
private string $customerId,
private string $name,
private string $content,
private int $points,
) {
}
public static function fromRequestData(
string $productId,
string $customerId,
RequestDataBag $data,
): self {
return new self(
$productId,
$customerId,
$data->get('name'),
$data->get('content'),
$data->get('points'),
);
}
}
本身可以轻松地测试此DTO对象,并且调整后的服务现在也更容易测试:
class PostProductReviewService
{
public function post(ProductReviewPostData $data, Context $context): void
{
$this->validate($data, $context);
$this->productReviewGateway->postReviewByCustomer($data, $context);
$this->eventDispatcher->dispatch(
new ReviewFormEvent($data->getProductId(), $data->getCustomerId(), $data, $context)
);
}
}
现在,该服务现在更容易测试,而且更容易消耗服务API,因为现在在DTO中定义了预期的输入参数,并且不再需要猜测参数及其名称基于在实施中。此外,类型系统为我们提供了一些免费的验证,例如验证所需和可选参数及其基本类型。
控制器现在看起来像这样:
#[Route(path: '/store-api/product/{productId}/review', methods: ['POST'])]
public function save(
string $productId,
RequestDataBag $data,
SalesChannelContext $context
): NoContentResponse {
/** @var CustomerEntity $customer */
$customer = $context->getCustomer();
$customerId = $customer->getId();
if (!$data->has('name')) {
$data->set('name', $customer->getFirstName());
}
if (!$data->has('lastName')) {
$data->set('lastName', $customer->getLastName());
}
$postData = ProductReviewPostData::fromRequestData($productId, $customerId, $data);
$this->postProductReviewService->post($postData, $context->getContext());
return new NoContentResponse();
}
对复杂性的影响
为了将系统保持在可以根据当前需求而轻松更改和进化的状态,必须保持整体系统的复杂性。通常,系统所做的事情越多,它变得越复杂,但是将核心与基础架构代码分开将有助于保持复杂性最小。要查看原始代码所做的不同的事情,我们可以将其分为以下问题:
-
如何转换HTTP-Request参数以使我们的应用程序可以处理它们? (基础架构关注)
-
发布产品评论时适用哪些业务规则? (核心关注)
-
产品评论如何存储在持久存储中? (基础架构关注)
我们的初始代码同时处理了所有三个问题。这意味着,当您想了解这些问题之一的解决方法时,与其他问题相关的代码实际上增加了复杂性,这使得您实际上很难理解的代码的一部分。通过分开关注点并分别处理它们,更容易专注于关注点之一,而不必关心其他人的解决方式。复杂度的水平降低到最低。
我们甚至可以通过移动后备处理(从当前登录的客户中获取名称)来进一步改进代码,从而将其余的DTO也是在一个地方也将其设置为单个位置。因此,代码可能看起来像这样:
#[Route(path: '/store-api/product/{productId}/review', methods: ['POST'])]
public function save(
string $productId,
RequestDataBag $data,
SalesChannelContext $context
): NoContentResponse {
/** @var CustomerEntity $customer */
$customer = $context->getCustomer();
$postData = ProductReviewPostData::fromRequestData($productId, $customer, $data);
$this->postProductReviewService->post($postData, $context->getContext());
return new NoContentResponse();
}
class ProductReviewPostData
{
private function __construct(
private string $productId,
private string $customerId,
private string $name,
private string $content,
private int $points,
) {
}
public static function fromRequestData(
string $productId,
CustomerEntity $customer,
RequestDataBag $data,
): self {
$name = $data->get('name') ?? $customer->getFirstName();
$lastName = $data->get('lastName') ?? $customer->getLastName();
return new self(
$productId,
$customer->getId(),
$name,
$data->get('content'),
$data->get('points'),
);
}
}
通过重构,现在很明显,我们根据登录的用户在请求中添加了一个lastName
字段,以防客户端提供。但是实际上,我们在存储评论时从未在任何地方使用lastName
。产品评论没有区分一个名字和姓氏,只有一个名称字段。因此,在原始代码中,有一个错误的视线,但它被周围的所有意外复杂性隐藏了。通过分开不同的问题,该错误变得明显且易于修复。
class ProductReviewPostData
{
private function __construct(
private string $productId,
private string $customerId,
private string $name,
private string $content,
private int $points,
) {
}
public static function fromRequestData(
string $productId,
CustomerEntity $customer,
RequestDataBag $data,
): self {
$name = $data->get('name') ?? ($customer->getFirstName() . ' ' . $customer->getLastName());
return new self(
$productId,
$customer->getId(),
$name,
$data->get('content'),
$data->get('points'),
);
}
}
协作的改进
将核心代码与基础架构代码分开的另一个重要好处是,它可以帮助您改善与团队中非工程师的合作,无论是QA,设计师还是业务方面的人员。当您将与基础架构相关的代码与核心代码分开时,查看如何实现了您的业务规则以及它们是否匹配域专家的期望是更容易的。在示例情况下,代码中最有趣的部分完全隐藏在$this->validate($data, $context)
调用中。该方法当前看起来像这样:
public function validate(ProductReviewPostData $data, Context $context): void
{
$definition = new DataValidationDefinition('product.create_rating');
$definition->add('name', new NotBlank());
$definition->add('content', new NotBlank(), new Length(['min' => 40]));
$definition->add('points', new GreaterThanOrEqual(1), new LessThanOrEqual(5));
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('customerId', $this->customerId));
$criteria->addFilter(new EqualsFilter('productId', $this->productId));
// ensure that the customer can only post one review per product
$definition->add('id', new EntityNotExists([
'entity' => 'product_review',
'context' => $context,
'criteria' => $criteria,
]));
$this->validator->validate($data, $definition);
}
代码使用Symfony validator组件执行验证。验证的第一部分非常简单:它对数据执行一些基本的一致性检查,例如该名称已填充,评论的内容至少为40个字符,评分点在1到5之间。使用新的代码结构,最好将验证的这一部分移至DTO对象本身,以便甚至不可能创建无效的DTO。仍然可以使用Symfony验证器或在构造函数中手动执行这些检查。
,但更有趣的部分是代码的最后一部分。在那里,它使用一些Shopware Dal Magic来验证客户尚未审查同一产品。这是应该执行的核心业务规则,但是目前,它完全隐藏在验证代码中。为了使它浮出水面,我们想将此支票移至服务的主体中,因为该方法应该像发布评论时发生的事情一样阅读。为此,我们首先创建了此类检查的新界面,因为只有基础架构层才能提供我们在服务中需要的信息,但是这两个层应保持分开。接口可能看起来像这样:
interface ProductReviewConstraints
{
public function hasCustomerAlreadyReviewedProduct(
string $productId,
string $customerId,
Context $context
): bool;
}
使用Shopware dal的实施可能看起来像:
class DALProductReviewConstraints
{
public function hasCustomerAlreadyReviewedProduct(
string $productId,
string $customerId,
Context $context
): bool {
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('productId', $productId));
$criteria->addFilter(new EqualsFilter('customerId', $customerId));
return $this->productReviewRepository->searchIds($criteria, $context)->firstId() !== null;
}
}
说,客户只能在域流中更为明显的商业规则,只能发布产品的评论:
class PostProductReviewService
{
public function post(ProductReviewPostData $data, Context $context): void
{
if ($this->reviewConstraints->hasCustomerAlreadyReviewedProduct(
$data->getProductId(),
$data->getCustomerId(),
$context
)) {
throw new CustomerAlreadyReviewedProductException($data->getCustomerId(), $data->getProductId());
}
$this->productReviewGateway->postReviewByCustomer($data, $context);
$this->eventDispatcher->dispatch(
new ReviewFormEvent($data->getProductId(), $data->getCustomerId(), $data, $context)
);
}
}
使用这样的结构,如果我们以正确的方式或缺少某些内容实现了要求,则很容易与其他同行讨论。很容易发现客户是否实际购买了该产品,没有任何检查,因此它可以引发有关是否以及如何添加此类额外支票的讨论。
结论:将基础架构代码保持在代码库的边界
我们在本文末尾留下的代码绝不是完美的,但是它是基于开头提到的高级体系结构模式进一步重构的完美起点(DDD,hexagonal architecture,clean architecture,clean architecture,â )。例如,DDD的支持者可能会以将业务逻辑封装在丰富的域模型中,而不是让其直接保留在服务本身中的方式来重构服务。但是尤其是在DDD中,您也不需要域模型中的任何外部依赖关系,因此首先将核心代码与基础结构代码分开的必要步骤。
到目前为止,此示例演练应该已经解释了将两者分开的许多好处。即使要求或技术可能会发生很大变化,它也使您和您的同龄人与您和同行的代码库进行互动和发展更容易。最终,这是一种一般模式,无论您在应用程序中使用什么架构,都应该应用,因为它为质量,可持续性和我敢于在您的代码库中工作的具体基础。
。