前端的清晰架构
不久前,我做了一个关于前端清晰架构的演讲。在这篇文章中,我将总结那次演讲,并详细扩展一些我当时没有时间解释的细节和概念。
我将在这里放置一些有用的链接,这些链接在您阅读时会派上用场:
计划是什么
首先,我们将讨论什么是清晰架构以及熟悉领域、用例和应用程序层等概念。然后,我们将讨论这如何适用于前端,以及是否值得使用。
接下来,我们将按照清晰架构的规则设计一个饼干商店的前端。最后,我们将从头开始实现其中一个用例,以查看它是否可用。
该商店将使用React作为其UI框架,只是为了展示这种方法也可以与之一起使用。(以及因为这篇文章基于的演讲是针对已经使用React的开发人员的😄)尽管React不是必需的,但您也可以使用本文中展示的所有内容与其他UI库或框架一起使用。
在代码中将有一些TypeScript,但只是为了展示如何使用类型和接口来描述实体。今天我们将看到的所有内容都可以在没有TypeScript的情况下使用,只是代码不会那么具有表现力。
今天我们几乎不会谈论面向对象编程,所以这篇文章不应该引起任何严重的过敏反应。我们将在最后仅提及一次面向对象编程,但这不会阻止我们设计应用程序。
此外,今天我们将跳过测试,因为它们不是本文的主要话题。我会牢记可测试性,并在路上提到如何改进它。
最后,这篇文章主要是关于您掌握清晰架构的概念。文章中的示例都是简化的,所以它不是如何编写代码的字面指南。理解这个概念,思考如何在您的项目中应用这些原则。
在文章的末尾,您可以找到与清晰架构相关且在前端更广泛使用的方法论列表。因此,您可以根据项目的规模找到最合适的方法。
现在,让我们深入研究吧!
架构与设计
设计基本上是将事物拆分...以便它们可以重新组合。...将事物分成可以组合的事物,这就是设计。 — 里奇·希基(Rich Hickey)。设计组合与性能
正如引言中的这句话所说,系统设计是将系统分离,以便以后可以重新组装。最重要的是,可以轻松地组装,而不需要太多工作。
我同意这一观点。但我认为架构的另一个目标是使系统具有可扩展性。对程序的需求不断变化。我们希望程序易于更新和修改,以满足新的需求。清晰架构可以帮助实现这一目标。
清晰架构
清晰架构是一种根据它们与应用程序域的接近程度将职责和功能部分分离的方式。
通过域,我们指的是我们用程序来建模的现实世界的一部分。这是反映现实世界中变化的数据转换。例如,如果我们更新产品的名称,将旧名称替换为新名称就是一种域变换。
清晰架构通常被称为三层架构,因为它的功能被分成了几层。关于清晰架构的原始文章提供了一个带有突出显示层的图示:
层次图:域在中心,应用程序层围绕其周围,适配器层在外部
域层
中心是域层。它是描述应用程序的主题领域的实体和数据,以及转换该数据的代码。域是区分一个应用程序与另一个应用程序的核心。
您可以将域视为如果我们从React切换到Angular或更改一些用例,将不会发生变化的内容。对于商店来说,这些是产品、订单、用户、购物车和更新其数据的功能。
域实体的数据结构和它们的转换本质与外部世界无关。外部事件触发域变换,但不确定它们将如何发生。
向购物车添加项目的功能不关心项目是如何添加的:是用户自己通过“购买”按钮添加的,还是通过促销代码自动添加的。在两种情况下,它都会接受项目并返回包含添加的项目的更新购物车。
应用程序层
在域周围是应用程序层。该层描述了用例,即用户场景。它们负责在发生某个事件后发生的事情。
例如,“添加到购物车”场景是一个用例。它描述了在按钮被点击后应该采取的操作。它是一种“编排器”,它告诉我们:
去服务器,发送请求; 现在执行这个域的转换; 现在使用响应数据重新绘制UI。
此外,在应用程序层中还有端口,即我们的应用程序希望与外部世界进行通信的规范。通常,端口是一个接口,是一种行为契约。
端口充当我们的应用程序愿望与现实之间的“缓冲区”。输入端口告诉我们应用程序希望外部世界如何与它联系。输出端口说明应用程序将如何与外部世界通信以使其准备好。
我们将稍后更详细地查看端口。
适配器层
最外层包含与外部服务的适配器。适配器用于将外部服务的不兼容API转换为与我们的应用程序愿望兼容的API。
适配器是降低我们的代码与第三方服务代码之间耦合度的一种很好的方式。低耦合度减少了当更改一个模块时需要更改其他模块的需求。
适配器通常分为:
驱动适配器—向我们的应用程序发送信号; 被动适配器—从我们的应用程序接收信号。
用户最常与驱动适配器互动。例如,UI框架处理按钮点击的工作是由驱动适配器完成的。它与浏览器API(基本上是第三方服务)一起工作,并将事件转换为我们的应用程序能够理解的信号。
被动适配器与基础架构进行交互。在前端,大多数基础架构是后端服务器,但有时我们可能直接与其他服务进行交互,比如搜索引擎。
请注意,离中心越远,代码功能就越“面向服务”,离我们的应用程序领域知识越远。这在以后决定任何模块应该属于哪个层次时将变得重要。
依赖规则
三层架构有一个依赖规则:只有外部层可以依赖内部层。这意味着:
域必须是独立的; 应用程序层可以依赖于域; 外部层可以依赖于任何东西。
只有外部层可以依赖于内部层。图片来源:herbertograca.com
有时可以违反这个规则,尽管最好不要滥用它。例如,有时在域中使用一些类似库的代码可能很方便,尽管不应该存在依赖关系。当我们查看源代码时,将会看到一个例子。
不受控制的依赖方向可能导致代码变得复杂和混乱。例如,违反依赖规则可能导致:
循环依赖,其中模块A依赖于B,B依赖于C,C依赖于A。
测试难度大,需要模拟整个系统来测试一个小部分。
耦合度过高,导致模块之间的互动脆弱。
清晰架构的优点
现在让我们谈谈这种代码分离给我们带来了什么优势。它有几个优点。
独立的领域
所有主要的应用程序功能都被隔离并集中在一个地方——领域中。
领域中的功能是独立的,这意味着它更容易进行测试。模块的依赖性越少,测试所需的基础设施越少,模拟和存根也越少。
一个独立的领域也更容易根据业务期望进行测试。这有助于新开发人员理解应用程序应该做什么。此外,独立的领域有助于更快地查找从业务语言到编程语言的“翻译”中的错误和不准确之处。
独立的用例
应用程序场景、用例是分开描述的。它们决定了我们将需要哪些第三方服务。我们使外部世界适应我们的需求,而不是相反。这使我们有更多的自由选择第三方服务。例如,如果当前的付款系统开始收费过高,我们可以快速更改。
用例代码也变得简单、可测试和可扩展。稍后我们将在示例中看到这一点。
可替代的第三方服务
由于适配器,外部服务变得可替代。只要我们不更改接口,实现接口的外部服务不重要。
这样,我们创建了一个变更传播的屏障:别人的代码的变更不会直接影响我们自己的代码。适配器还限制了应用程序运行时中错误的传播。
清晰架构的成本
架构首先是一种工具。与任何工具一样,清晰架构除了好处之外还有成本。
需要时间
主要成本是时间。不仅需要设计时间,还需要实施时间,因为直接调用第三方服务通常比编写适配器更容易。
此外,提前思考系统所有模块之间的互动也很困难,因为我们可能事先不知道所有的要求和约束。在设计时,我们需要考虑系统如何变化,并为扩展留出余地。
有时过于冗长
总的来说,清晰架构的规范实现并不总是方便,有时甚至有害。如果项目很小,完全实现可能会导致过度,增加新开发人员的入门门槛。
为了在预算或截止日期范围内完成工作,可能需要进行设计折中。我将通过示例向您展示我所说的这种折中。
可能会增加入职难度
完全实现清晰架构可能会增加入职难度,因为任何工具都需要知道如何使用它。
如果在项目开始时进行了过度设计,以后将更难引入新的开发人员。您必须记住这一点,保持代码简单。
可能会增加代码量
前端特有的问题是清晰架构可能会增加最终捆绑包中的代码量。我们提供给浏览器的代码越多,它就需要下载、解析和解释的越多。
必须密切关注代码量,并决定在哪里取巧:
也许可以更简单地描述用例;
也许可以直接从适配器访问领域功能,绕过用例;
也许我们需要调整代码拆分等等。
如何降低成本
您可以通过取巧和牺牲架构的“清洁度”来减少时间和代码量。我一般不是激进方法的拥护者:如果打破某个规则更加务实(例如,好处将高于潜在成本),我会打破它。
因此,您可以暂时对清晰架构的某些方面感到不满,而没有任何问题。然而,绝对值得投入的最低资源量是两个方面。
提取领域
提取的领域有助于理解我们总体上正在设计什么以及它应该如何工作。提取的领域使新开发人员更容易理解应用程序、其实体以及它们之间的关系。
即使我们跳过其他层,仍然可以更容易地使用和重构提取的领域,而不会分散在代码库中。其他层可以根据需要添加。
遵守依赖规则
第二个不可抛弃的规则是依赖规则,或者更确切地说是它们的方向规则。外部服务必须适应我们的需求,而不是相反。
如果您感到自己正在“微调”代码以便它可以调用搜索API,那么就有问题了。最好在问题扩散之前编写适配器。
设计应用程序
既然我们已经谈论了理论,现在可以进入实践。让我们设计一个饼干商店的架构。
这家商店将销售不同种类的饼干,这些饼干可能包含不同的成分。用户将选择饼干并订购它们,并在第三方支付服务中支付订单。
首页将展示我们可以购买的饼干。只有在我们已经通过身份验证时,才能购买饼干。登录按钮将带我们进入登录页面,我们可以在那里登录。
商店主页 (不要在意它的外观,我不是网页设计师 😄)
成功登录后,我们将能够将一些饼干放入购物车。
选定的饼干购物车
当我们把饼干放入购物车后,我们可以下订单。付款后,我们会在订单列表中获得一个新订单,并且购物车会被清空。
我们将实现结账用例。您可以在源代码中找到其余的用例。
首先,我们将定义我们将拥有哪些实体、用例和广义功能。然后让我们决定它们应该属于哪个层级。
设计领域
应用程序中最重要的部分是领域。这是应用程序的主要实体及其数据转换所在之处。我建议您从领域开始,以便在代码中准确地表示应用程序的领域知识。
商店的领域可能包括:
每个实体的数据类型:用户、饼干、购物车和订单;
用于创建每个实体的工厂,或者如果您使用面向对象编程,那么是类;
以及用于该数据的转换函数。
领域中的转换函数应仅依赖于领域规则,而不依赖于其他内容。此类函数可能包括:
计算总成本的函数;
用户口味的检测;
确定商品是否在购物车中等。
领域实体图
设计应用层
应用层包含了用例。一个用例总是有一个执行者、一个动作和一个结果。
在商店中,我们可以区分:
产品购买场景;
支付,调用第三方支付系统;
与产品和订单的交互:更新、浏览;
根据角色访问页面。
用例通常是根据主题领域来描述的。例如,“结账”场景实际上包括几个步骤:
从购物车中检索商品并创建新订单;
支付订单;
如果支付失败,则通知用户;
清空购物车并显示订单。
用例函数将是描述此场景的代码。
此外,在应用层中还有端口,用于与外部世界进行通信的接口。
用例和端口图
设计适配器层
在适配器层中,我们声明了与外部服务的适配器。适配器使第三方服务的不兼容API与我们的系统兼容。
在前端中,适配器通常是UI框架和API服务器请求模块。在我们的情况下,我们将使用:
UI框架; API请求模块; 本地存储适配器; 将API响应适配器和转换器应用到应用程序层。
适配器图,按照驱动适配器和被驱动适配器进行分割
请注意,功能越"服务式",离图表中心越远。
使用MVC类比
有时候很难确定某些数据属于哪个层次。与MVC的一个小(不完整的!)类比可能会有所帮助:
- 模型通常是领域实体,
- 控制器是领域转换和应用层,
- 视图是驱动适配器。 这些概念在细节上有所不同,但相当相似,这个类比可以用来定义领域和应用代码。
进一步细节:领域
一旦我们确定了需要哪些实体,我们就可以开始定义它们的行为。
我将立刻展示项目中的代码结构。为了清晰起见,我将代码分为不同的文件夹层次。
src/
|_domain/
|_user.ts
|_product.ts
|_order.ts
|_cart.ts
|_application/
|_addToCart.ts
|_authenticate.ts
|_orderProducts.ts
|_ports.ts
|_services/
|_authAdapter.ts
|_notificationAdapter.ts
|_paymentAdapter.ts
|_storageAdapter.ts
|_api.ts
|_store.tsx
|_lib/
|_ui/
领域代码位于domain/目录中,应用程序层位于application/目录中,适配器位于services/目录中。我们将在最后讨论这种代码结构的替代方案。
创建领域实体
我们的领域将包括以下四个模块:
- 产品(product)
- 用户(user)
- 订单(order)
- 购物车(shopping cart)
主要角色是用户(user)。我们将在会话期间将用户数据存储在存储中。我们希望对这些数据进行类型化,因此我们将创建一个领域用户类型。
用户类型将包含ID、姓名、邮箱以及偏好和过敏症列表。
// domain/user.ts
export type UserName = string;
export type User = {
id: UniqueId;
name: UserName;
email: Email;
preferences: Ingredient[];
allergies: Ingredient[];
};
用户将在购物车中放置Cookie。让我们为购物车和产品添加类型。该项目将包含ID、名称、以便士计算的价格和成分列表。
// domain/product.ts
export type ProductTitle = string;
export type Product = {
id: UniqueId;
title: ProductTitle;
price: PriceCents;
toppings: Ingredient[];
};
在购物车中,我们只会保留用户放入其中的产品列表:
// domain/cart.ts
import { Product } from './product';
export type Cart = {
products: Product[];
};
成功付款后,将创建一个新订单。让我们添加一个订单实体类型。
订单类型将包含用户ID、订购产品列表、创建日期和时间、状态以及整个订单的总价格。
// domain/order.ts
export type OrderStatus = 'new' | 'delivery' | 'completed';
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
检查实体之间的关系
以这种方式设计实体类型的好处是我们已经可以检查它们的关系图是否与现实相符:
实体关系图 我们可以看到并检查:
主要角色是否真的是用户, 订单中是否有足够的信息, 某些实体是否需要扩展, 未来是否会遇到扩展性问题。
此外,已经在这个阶段,类型将帮助突出显示实体之间的兼容性错误和信号传递的方向。
如果一切符合我们的期望,我们可以开始设计领域转换。
创建数据转换
我们刚刚设计的类型的数据将经历各种事情。我们将添加物品到购物车中,清空购物车,更新物品和用户名等等。我们将为所有这些转换创建单独的函数。
例如,为了确定用户是否对某种成分或偏好过敏,我们可以编写函数hasAllergy和hasPreference:
// domain/user.ts
export function hasAllergy(user: User, ingredient: Ingredient): boolean {
return user.allergies.includes(ingredient);
}
export function hasPreference(user: User, ingredient: Ingredient): boolean {
return user.preferences.includes(ingredient);
}
使用addProduct和contains函数来将物品添加到购物车并检查物品是否在购物车中:
// domain/cart.ts
export function addProduct(cart: Cart, product: Product): Cart {
return { ...cart, products: [...cart.products, product] };
}
export function contains(cart: Cart, product: Product): boolean {
return cart.products.some(({ id }) => id === product.id);
}
我们还需要计算产品列表的总价格,为此我们将编写totalPrice函数。如果需要,我们可以在该函数中添加各种条件,例如促销代码或季节性折扣。
// domain/product.ts
export function totalPrice(products: Product[]): PriceCents {
return products.reduce((total, { price }) => total + price, 0);
}
为了允许用户创建订单,我们将添加createOrder函数。它将返回与指定用户和其购物车关联的新订单。
// domain/order.ts
export function createOrder(user: User, cart: Cart): Order {
return {
cart,
user: user.id,
status: 'new',
created: new Date().toISOString(),
total: totalPrice(products)
};
}
请注意,在每个函数中,我们构建API,以便我们可以轻松地转换数据。我们按照我们想要的方式接受参数并给出结果。
在设计阶段,还没有外部限制。这使我们能够尽可能接近主题域反映数据转换。转换越接近现实,检查它们的工作就越容易。
详细说明:共享内核
您可能已经注意到我们在描述领域类型时使用了一些类型,例如Email、UniqueId或DateTimeString。这些是类型别名:
// shared-kernel.d.ts
type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;
通常我使用类型别名来消除原始类型的过度使用。
我使用DateTimeString而不仅仅使用字符串,以使使用的字符串类型更加清晰。类型与主题领域越接近,处理错误时就会更容易。
指定的类型在shared-kernel.d.ts文件中。*共享内核*是代码和数据,其依赖关系不会增加模块之间的耦合。关于这个概念的更多信息可以在“DDD,Hexagonal,Onion,Clean,CQRS,...How I put it all together”中找到。
在实际应用中,共享内核可以这样解释。我们使用TypeScript,使用它的标准类型库,但我们不将它们视为依赖项。这是因为使用它们的模块可能彼此不了解,保持解耦。
并非所有的代码都可以被分类为共享内核。最重要的限制是这样的代码必须与系统的任何部分兼容。如果应用程序的一部分是用TypeScript编写的,而另一部分是用另一种语言编写的,那么共享内核可能只包含可以在两个部分中使用的代码。例如,以JSON格式的实体规范是可以的,但TypeScript助手不行。
在我们的情况下,整个应用程序都是用TypeScript编写的,因此类型别名可以被分类为共享内核。这种全局可用的类型不会增加模块之间的耦合,并且可以在应用程序的任何部分中使用。
详细介绍:应用程序层
现在我们已经理解了领域,可以继续讨论应用程序层。这一层包含了使用案例。
在代码中,我们描述了场景的技术细节。使用案例是描述在将项目添加到购物车或进行结账后数据应该发生什么的说明。
使用案例涉及与外部世界的互动,因此涉及使用外部服务。与外部世界的互动是副作用。我们知道,在没有副作用的情况下,更容易使用和调试函数和系统。而我们的大多数领域函数已经编写为纯函数。
为了结合干净的转换和与不纯的世界的互动,我们可以使用应用程序层作为不纯的上下文。
纯转换的不纯上下文
纯转换的不纯上下文是一种代码组织方式,其中:
首先执行一个副作用来获取一些数据; 然后对该数据进行纯转换; 然后再次执行一个副作用来存储或传递结果。
在“将商品放入购物车”使用案例中,看起来是这样的:
首先,处理程序将从存储中检索购物车状态; 然后,它会调用购物车更新函数,将要添加的项目传递给它; 然后,它会将更新后的购物车保存到存储中。
整个过程是一个“三明治”:副作用,纯函数,副作用。主要逻辑反映在数据转换中,与世界的所有通信都隔离在一个命令式外壳中。
功能架构:副作用,纯函数,副作用
不纯的上下文有时被称为命令式外壳中的功能核心。Mark Seemann 在他的博客中写到了这一点。这是我们在编写用例函数时将使用的方法。
设计用例
我们将选择并设计结账用例。这是最具代表性的用例,因为它是异步的,并与许多第三方服务进行交互。其余的场景和整个应用程序的代码可以在 GitHub 上找到。
让我们思考一下在这个用例中我们想要实现什么。用户有一个带有饼干的购物车,当用户点击结账按钮时:
我们想要创建一个新订单; 在第三方支付系统中支付订单; 如果支付失败,通知用户; 如果支付成功,将订单保存在服务器上; 将订单添加到本地数据存储中以在屏幕上显示。
就API和函数签名而言,我们希望将用户和购物车作为参数传递,并让函数自行完成其他所有操作。
type OrderProducts = (user: User, cart: Cart) => Promise<void>;
理想情况下,当然,用例不应该接受两个单独的参数,而应该接受一个将所有输入数据封装在内部的命令。但是我们不想使代码过于臃肿,所以我们将保持这种方式。
编写应用程序层接口
让我们更仔细地看看用例的步骤:订单的创建本身是一个领域函数。其他一切都是我们想要使用的外部服务。
重要的是要记住,外部服务必须适应我们的需求,而不是相反。因此,在应用程序层,我们将描述不仅仅是用例本身,还包括这些外部服务的接口——接口。
这些接口首先应该方便我们的应用程序使用。如果外部服务的API与我们的需求不兼容,我们将编写一个适配器。
让我们考虑一下我们将需要的服务:
- 支付系统;
- 用于通知用户事件和错误的服务;
- 用于将数据保存到本地存储的服务。
我们将需要的服务
请注意,我们现在正在讨论这些服务的接口,而不是它们的具体实现。在这个阶段,对我们来说描述所需的行为非常重要,因为这是我们在应用程序层描述场景时将依赖的行为。
具体实现如何实现这个行为还不重要。这使我们可以推迟关于使用哪些外部服务的决策,直到最后一刻——这使代码的耦合度最小化。我们将稍后处理具体实现。
还要注意,我们按功能将接口进行了划分。与支付相关的一切都在一个模块中,与存储相关的在另一个模块中。这样将更容易确保不混淆不同第三方服务的功能。
支付系统接口
饼干存储是一个示例应用程序,因此支付系统将非常简单。它将有一个tryPay方法,接受需要支付的金额,并在响应中发送一条确认一切正常的消息。
// application/ports.ts
export interface PaymentService {
tryPay(amount: PriceCents): Promise<boolean>;
}
我们不会处理错误,因为错误处理是一个独立的大主题 😃
是的,通常支付是在服务器上完成的,但这是一个示例,让我们在客户端上完成一切。我们可以轻松地与我们的API通信,而不是直接与支付系统通信。这个改变只会影响这个用例,其余的代码保持不变。
通知服务接口
如果出现问题,我们必须告诉用户。
用户可以以不同的方式收到通知。我们可以使用UI,可以发送邮件,可以用用户的手机振动(请不要这样做)。
总的来说,通知服务最好也是抽象的,这样我们现在不必考虑具体实现。
让它接受一条消息,然后以某种方式通知用户:
// application/ports.ts
export interface NotificationService {
notify(message: string): void;
}
本地存储接口
我们将在本地存储中保存新订单。
这个存储可以是任何东西:Redux,MobX,任何你喜欢的JavaScript框架。存储可以分为不同实体的微型存储,也可以是一个大的存储库,用于存储所有应用程序数据。现在这些都不重要,因为这些是具体的实现细节。
我喜欢将存储接口分为每个实体的独立接口。一个用于用户数据存储的独立接口,一个用于购物车的独立接口,一个用于订单存储的独立接口:
// application/ports.ts
export interface OrdersStorageService {
orders: Order[];
updateOrders(orders: Order[]): void;
}
在这个示例中,我只制定了订单存储接口,其余部分可以在源代码中看到。
用例函数
让我们看看是否可以使用创建的接口和现有的领域功能来构建用例。正如我们之前描述的,脚本将包括以下步骤:
验证数据; 创建订单; 支付订单; 通知问题; 保存结果。
自定义脚本的所有步骤如下图所示:
首先,让我们声明我们将要使用的服务的存根。TypeScript 将会提醒我们,相应的变量中的接口还没有实现,但目前并不重要。
// application/orderProducts.ts
const payment: PaymentService = {};
const notifier: NotificationService = {};
const orderStorage: OrdersStorageService = {};
现在,我们可以像使用真正的服务一样使用这些存根。我们可以访问它们的字段,调用它们的方法。当将用例从业务语言“翻译”成软件语言时,这非常有用。
现在,创建一个名为orderProducts的函数。在函数内部,我们首先创建一个新订单:
// application/orderProducts.ts
//...
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
}
在这里,我们利用了接口是行为约定的事实。这意味着在将来,这些存根实际上会执行我们现在期望的操作:
// application/orderProducts.ts
//...
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
// Try to pay for the order;
// Notify the user if something is wrong:
const paid = await payment.tryPay(order.total);
if (!paid) return notifier.notify('Оплата не прошла 🤷');
// Save the result and clear the cart:
const { orders } = orderStorage;
orderStorage.updateOrders([...orders, order]);
cartStorage.emptyCart();
}
请注意,用例不直接调用第三方服务。它依赖于接口中描述的行为,因此只要接口保持不变,我们不关心哪个模块实现它以及如何实现它。这使得模块可以替换。
详细介绍:适配器层
我们已经将用例“翻译”成了TypeScript。现在我们必须检查现实是否符合我们的需求。
通常情况下,它并不符合。因此,我们使用适配器来调整外部世界以满足我们的需求。
绑定UI和用例
第一个适配器是一个UI框架。它将浏览器的原生API与应用程序连接起来。在订单创建的情况下,它是“结账”按钮和单击处理程序,将启动用例函数。
// ui/components/Buy.tsx
export function Buy() {
// Get access to the use case in the component:
const { orderProducts } = useOrderProducts();
async function handleSubmit(e: React.FormEvent) {
setLoading(true);
e.preventDefault();
// Call the use case function:
await orderProducts(user!, cart);
setLoading(false);
}
return (
<section>
<h2>Checkout</h2>
<form onSubmit={handleSubmit}>{/* ... */}</form>
</section>
);
}
让我们通过一个钩子提供用例。我们将在其中获取所有服务,并作为结果,从钩子中返回用例函数本身。
// application/orderProducts.ts
export function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
async function orderProducts(user: User, cookies: Cookie[]) {
// …
}
return { orderProducts };
}
我们使用钩子作为“弯曲的依赖注入”。首先,我们使用hooks useNotifier、usePayment、useOrdersStorage来获取服务实例,然后使用useOrderProducts函数的闭包将它们提供给orderProducts函数。
重要的是要注意,用例函数仍然与其余代码分离,这对于测试很重要。在文章的最后,当我们进行审查和重构时,我们将完全提取它,并使其更容易进行测试。
支付服务实现
用例使用PaymentService接口。让我们来实现它。
对于支付,我们将使用虚拟API存根。同样,我们现在不被迫编写整个服务,我们可以以后再写,最重要的是实现指定的行为:
// services/paymentAdapter.ts
import { fakeApi } from './api';
import { PaymentService } from '../application/ports';
export function usePayment(): PaymentService {
return {
tryPay(amount: PriceCents) {
return fakeApi(true);
}
};
}
fakeApi函数是一个在450毫秒后触发的超时,模拟来自服务器的延迟响应。它返回我们传递给它的参数。
// services/api.ts
export function fakeApi<TResponse>(response: TResponse): Promise<TResponse> {
return new Promise((res) => setTimeout(() => res(response), 450));
}
我们明确地为usePayment的返回值进行了类型标注。这样TypeScript将检查该函数是否实际返回一个包含接口中声明的所有方法的对象。
通知服务实现 让通知变成一个简单的警报。由于代码解耦,以后重写这个服务不会成为问题。
// services/notificationAdapter.ts
import { NotificationService } from '../application/ports';
export function useNotifier(): NotificationService {
return {
notify: (message: string) => window.alert(message)
};
}
本地存储实现
让本地存储成为React.Context和hooks。我们创建一个新的上下文,将值传递给提供程序,导出提供程序,并通过hooks访问存储。
// store.tsx
const StoreContext = React.createContext({});
export const useStore = () => useContext(StoreContext);
export const Provider: React.FC = ({ children }) => {
// ...Other entities...
const [orders, setOrders] = useState([]);
const value = {
// ...
orders,
updateOrders: setOrders
};
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>;
};
我们将为每个功能编写一个钩子。这样我们不会破坏ISP,至少从接口的角度来看,它们将是原子的。
// services/storageAdapter.ts
export function useOrdersStorage(): OrdersStorageService {
return useStore();
}
此外,这种方法将使我们能够为每个存储定制额外的优化:我们可以创建选择器、记忆化等。
验证数据流图
现在让我们验证用户在创建的用例期间如何与应用程序进行通信。
用例数据流图
用户与UI层进行交互,只能通过接口访问应用程序。也就是说,如果我们愿意,我们可以更改UI。
用例在应用程序层处理,告诉我们确切需要哪些外部服务。所有的主要逻辑和数据都在领域中。
所有外部服务都隐藏在基础架构中,受到我们的规范约束。如果我们需要更改发送消息服务,唯一需要在代码中修复的是新服务的适配器。
这个方案使代码具有可替代性、可测试性,并且能够适应不断变化的需求。
可以改进的地方
总的来说,这已经足够让你入门并初步理解干净架构。但我想指出一些我简化了的内容,以使示例更容易理解。
这部分内容是可选的,但它将使您更全面地了解“不切角落”的干净架构可能会是什么样子。
我想强调一些可以做的事情。
使用对象而不是数字表示价格
您可能已经注意到,我使用一个数字来描述价格。这不是一个好的做法。
// shared-kernel.d.ts
type PriceCents = number;
数字只表示数量,而不表示货币,没有货币的价格是没有意义的。理想情况下,价格应该是一个带有两个字段的对象:value(值)和currency(货币)。
type Currency = 'RUB' | 'USD' | 'EUR' | 'SEK';
type AmountCents = number;
type Price = {
value: AmountCents;
currency: Currency;
};
这将解决存储货币以及在更改或添加货币时节省大量精力和神经的问题。在示例中,我没有使用这种类型,以免复杂化它。然而,在实际代码中,价格将更类似于这种类型。
另外,价格的值也值得一提。我总是将货币的金额保留在通货最小单位中。例如,对于美元来说,是美分。
以这种方式显示价格允许我不考虑除法和小数值。在处理货币时,这是特别重要的,如果我们想要避免浮点数计算引起的问题。
按功能而不是按层分割代码
代码可以按功能而不是按层分割在文件夹中。一个功能将是下图中的一个部分。
这种结构更可取,因为它允许您单独部署特定的功能,这通常很有用。
组件是六边形饼的一部分。图片来源:herbertograca.com
我建议阅读《DDD、Hexagonal、Onion、Clean、CQRS... How I put it all together》中的相关内容。
我还建议看一下 Feature Sliced,它在概念上与组件代码划分非常相似,但更容易理解。
注意跨组件使用
如果我们谈论将系统分割为组件,那么还值得提到代码的跨组件使用。让我们回想一下订单创建函数:
import { Product, totalPrice } from './product';
export function createOrder(user: User, cart: Cart): Order {
return {
cart,
user: user.id,
status: 'new',
created: new Date().toISOString(),
total: totalPrice(products)
};
}
该函数使用来自另一个组件——产品的totalPrice。这种用法本身没有问题,但如果我们想将代码分割成独立的功能,就不能直接访问其他功能的功能。
您还可以在《DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together》和 Feature Sliced 中看到绕过此限制的方法。
使用品牌类型而不是别名
对于共享内核,我使用了类型别名。它们很容易操作:您只需创建一个新类型并引用一个字符串等。但它们的缺点是TypeScript没有机制来监视它们的使用和强制执行它。
这似乎不是一个问题:所以有人使用了字符串而不是DateTimeString——那又怎样?代码仍然会编译。
问题在于,即使使用了更广泛的类型(在聪明的话语中,前提条件被削弱了),代码仍然会编译。首先,这使得代码更加脆弱,因为它允许您使用任何字符串,而不仅仅是特定质量的字符串,这可能会导致错误。
其次,这使得阅读变得混乱,因为它创建了两个真相来源。不清楚是否真的只需要在那里使用日期,或者是否基本上可以使用任何字符串。
有一种方法可以使TypeScript理解我们需要特定的类型——使用品牌,即品牌类型。品牌使我们能够跟踪类型的使用方式,但会使代码变得稍微复杂一些。
注意领域中可能的依赖关系
下一件令人不满意的事是在createOrder函数中在领域中创建日期的地方的副作用。
import { Product, totalPrice } from './product';
export function createOrder(user: User, cart: Cart): Order {
return {
cart,
user: user.id,
// This line:
created: new Date().toISOString(),
status: 'new',
total: totalPrice(products)
};
}
我们可以怀疑在项目中新建Date().toISOString()将会重复很多次,希望将其放入某种辅助工具中:
// lib/datetime.ts
export function currentDatetime(): DateTimeString {
return new Date().toISOString();
}
然后在领域中使用它:
// domain/order.ts
import { currentDatetime } from '../lib/datetime';
import { Product, totalPrice } from './product';
export function createOrder(user: User, cart: Cart): Order {
return {
cart,
user: user.id,
status: 'new',
created: currentDatetime(),
total: totalPrice(products)
};
}
但我们立即记住,在领域中我们不能依赖任何东西,那么我们应该怎么做呢?一个好主意是createOrder应该以完整的形式接受订单的所有数据。日期可以作为最后一个参数传递:
// domain/order.ts
export function createOrder(user: User, cart: Cart, created: DateTimeString): Order {
return {
user: user.id,
products,
created,
status: 'new',
total: totalPrice(products)
};
}
这还可以使我们在依赖于库的情况下不违反依赖关系规则。如果我们在领域函数外部创建日期,那么日期很可能会在用例内部创建并作为参数传递:
function someUserCase() {
// Use the `dateTimeSource` adapter,
// to get the current date in the desired format:
const createdOn = dateTimeSource.currentDatetime();
// Pass already created date to the domain function:
createOrder(user, cart, createdOn);
}
这将保持领域的独立性,并使测试变得更容易。
在我选择的示例中,我出于两个原因选择不关注这个问题:首先,它会分散注意力,其次,如果只使用语言特性,依赖自己的辅助工具是没有问题的。这样的辅助工具甚至可以被视为共享核心,因为它们只减少了代码重复。
保持领域实体和转换的纯净性
在createOrder函数内部创建日期真正不好的地方在于副作用。副作用的问题在于它们使系统变得不如您所希望的那样可预测。帮助应对这个问题的方法是在领域中进行纯数据转换,也就是不产生副作用的转换。
创建日期是一种副作用,因为调用Date.now()的结果在不同时间是不同的。另一方面,纯函数在具有相同参数的情况下始终返回相同的结果。
我得出的结论是,最好尽量保持领域的清晰。这样更容易测试,更容易移植和更新,也更容易阅读。在调试时,副作用会大大增加认知负荷,领域不是存放复杂和令人困惑的代码的地方。
注意购物车和订单之间的关系
在这个小例子中,订单包括购物车,因为购物车只代表了产品的列表:
export type Cart = {
products: Product[];
};
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
如果购物车中有与订单无关的其他属性,则此方法可能不适用。在这种情况下,最好使用数据投影或中间DTO。
作为一种选择,我们可以使用“产品列表”实体:
type ProductList = Product[];
type Cart = {
products: ProductList;
};
type Order = {
user: UniqueId;
products: ProductList;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
使用户案例更易于测试
用户案例也有很多需要讨论的地方。目前,orderProducts函数很难在不涉及React的情况下进行测试,这不太好。理想情况下,应该能够以最小的努力进行测试。
当前实现的问题在于钩子提供了用户案例访问UI的功能:
// application/orderProducts.ts
export function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
const cartStorage = useCartStorage();
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
const paid = await payment.tryPay(order.total);
if (!paid) return notifier.notify('Oops! 🤷');
const { orders } = orderStorage;
orderStorage.updateOrders([...orders, order]);
cartStorage.emptyCart();
}
return { orderProducts };
}
在典型的实现中,用户案例函数应该位于钩子之外,服务应该通过最后一个参数或通过依赖注入传递给用户案例:
type Dependencies = {
notifier?: NotificationService;
payment?: PaymentService;
orderStorage?: OrderStorageService;
};
async function orderProducts(
user: User,
cart: Cart,
dependencies: Dependencies = defaultDependencies
) {
const { notifier, payment, orderStorage } = dependencies;
// ...
}
然后,钩子将成为一个适配器:
function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
return (user: User, cart: Cart) =>
orderProducts(user, cart, {
notifier,
payment,
orderStorage
});
}
然后,通过传递所需的服务作为依赖项,可以测试orderProducts函数。
配置自动依赖注入
在应用程序层中,我们现在通过手动注入服务:
export function useOrderProducts() {
// Here we use hooks to get the instances of each service,
// which will be used inside the orderProducts use case:
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
const cartStorage = useCartStorage();
async function orderProducts(user: User, cart: Cart) {
// ...Inside the use case we use those services.
}
return { orderProducts };
}
但总的来说,这可以自动化并使用依赖注入来完成。我们已经看过通过最后一个参数进行注入的最简单版本,但您可以进一步配置自动注入。
在这个特定的应用程序中,我认为设置依赖注入并不太有意义。这会分散注意力并使代码过于复杂。而在React和钩子的情况下,我们可以将它们用作“容器”,返回指定接口的实现。是的,这是手动工作,但它不会增加入门门槛,并且对新开发人员来说更容易阅读。
在实际项目中可能更加复杂的是什么
帖子中的示例经过精心雕琢,故意保持简单。显然,现实远比这个示例更令人惊讶和复杂。因此,我还想讨论在使用清洁架构时可能出现的常见问题。
分支业务逻辑
最重要的问题是我们缺乏了解的主题领域。想象一家商店有产品、折扣产品和报废产品。我们如何正确描述这些实体?
是否应该有一个“基础”实体,可以进行扩展?这个实体应该如何扩展?是否应该有额外的字段?这些实体是否应该互斥?如果简单的实体之外有另一个实体,用户案例应该如何行为?是否应该立即减少重复?
可能会有太多的问题和太多的答案,因为团队和利益相关者都不知道系统应该如何实际行为。如果只有假设,您可能会陷入分析瘫痪。
具体的解决方案取决于具体的情况,我只能推荐一些通用的事情。
不要使用继承,即使它被称为“扩展”。即使看起来接口真的被继承了。即使看起来“嗯,这里显然有一个层次结构”。只需等待。
代码中的复制粘贴并不总是邪恶的,它是一种工具。制作两个几乎相同的实体,看看它们在实际中的行为,观察它们。在某个时候,您会注意到它们要么变得非常不同,要么它们只在一个字段上不同。将两个类似的实体合并成一个比为每种可能的情况和变体创建检查更容易。
如果您仍然必须扩展某些东西…
请记住协变、逆变和不变性,以免无意中增加更多工作。
在选择不同的实体和扩展之间时,使用BEM中的块和修饰符的类比。当我在BEM的上下文中考虑它时,它对我来说很有帮助,以确定我是否有一个独立的实体或一个“修改器扩展”的代码。
相互依赖的用户案例
第二个重要问题涉及到使用案例,其中一个使用案例的事件触发另一个使用案例。
我所知道并且对我有帮助的唯一处理方法是将使用案例拆分成更小的、原子的使用案例。它们将更容易组合在一起。
总的来说,这种脚本的问题通常是与编程中的实体组合的另一个大问题有关。
关于如何有效地组合实体已经有很多资料,甚至有一个完整的数学部分。我们不会在这方面深入讨论,这是一个单独的帖子主题。
结论
在这篇帖子中,我概述了并稍微扩展了我在前端中关于清洁架构的演讲。
这不是黄金标准,而是不同项目、范例和语言的经验的编译。我认为这是一种方便的方案,可以解耦代码并创建独立的层、模块和服务,这些层、模块和服务不仅可以单独部署和发布,而且还可以在需要时从一个项目转移到另一个项目。
我们没有涉及面向对象编程,因为架构和面向对象编程是正交的。是的,架构涉及实体组合,但它不会规定组合的单位是对象还是函数。您可以在不同的范例中使用这个,正如我们在示例中看到的那样。
至于面向对象编程,我最近写了一篇关于如何在面向对象编程中使用清洁架构的帖子。在这篇帖子中,我们在画布上编写了一个树图生成器。
要了解如何将这种方法与其他内容(如芯片切片、六边形架构、CQS等)结合使用,我建议阅读DDD、六边形、洋葱、清洁、CQRS等等……如何将它们结合起来以及这个博客的一整系列文章。非常具有深刻见解,简洁明了。