前端的清洁架构
不久前,我做了一个关于前端简洁架构的演讲。在这篇文章中,我将概述这次演讲的内容,并进行一些扩展。
我将在这里放置各种有用内容的链接,当您阅读时它们会派上用场:
计划是什么
首先,我们将讨论什么是“清晰架构”,并熟悉领域层、用例层和应用层等概念。然后,我们将讨论如何将其应用于前端,以及它是否值得。
接下来,我们将遵循简洁架构的规则设计一个 Cookie 存储的前端。最后,我们将从头开始实现一个用例,看看它是否可用。
该商店将使用 React 作为其 UI 框架,只是为了展示这种方法也可以用于 React。(而且这篇文章的讨论对象是已经使用 React 的开发者😄)虽然 React 并非必需,但您也可以将本文中展示的所有内容与其他 UI 库或框架结合使用。
代码中会用到少量 TypeScript,但仅用于演示如何使用类型和接口来描述实体。今天要讲的所有内容都可以在没有 TypeScript 的情况下使用,只是代码的表达能力会略逊一筹。
今天我们几乎不讨论 OOP,所以这篇文章应该不会引起什么严重的不适。我们只会在文章结尾提到一次 OOP,但这不会妨碍我们设计应用程序。
另外,我们今天会跳过测试,因为它们不是本文的主题。不过,我会继续关注可测试性,并在此过程中讨论如何改进它。
最后,这篇文章主要帮助你掌握“清晰架构”的概念。文中的示例经过简化,并非字面意义上的代码编写指导。理解这个概念,并思考如何在你的项目中运用这些原则。
在文章的最后,你可以找到一系列与清晰架构相关且在前端应用更广泛的方法。因此,你可以根据项目的规模找到最合适的方法。
现在,让我们开始吧!
建筑与设计
设计的本质在于将事物拆开……然后重新组合起来。……将事物分解成可组合的部分,这就是设计的本质。——
Rich Hickey。《设计构图与性能》
题词中引用道,系统设计就是将系统分离,以便日后重新组装。最重要的是,系统组装起来轻松便捷,无需过多工作。
我同意。但我认为架构的另一个目标是系统的可扩展性。程序的需求在不断变化。我们希望程序易于更新和修改,以满足新的需求。清晰架构可以帮助实现这一目标。
清洁架构
清晰架构是一种根据职责和功能部分与应用领域的接近程度来分离它们的方法。
领域是指我们用程序建模的现实世界的一部分。这是反映现实世界变换的数据变换。例如,如果我们更新了产品名称,那么用新名称替换旧名称就是一种领域变换。
整洁架构 (Clean Architecture) 通常被称为三层架构,因为其中的功能被划分成多个层。关于整洁架构的原始文章提供了一个突出显示各层的图表:
图片来源:cleancoder.com。
领域层
核心是领域层。它包含描述应用程序主题领域的实体和数据,以及转换这些数据的代码。领域是区分不同应用程序的核心。
你可以把领域想象成从 React 迁移到 Angular 或某些用例发生变化时不会改变的东西。在商店的例子中,领域包括产品、订单、用户、购物车以及用于更新其数据的功能。
领域实体的数据结构及其转换的本质与外部世界无关。外部事件会触发领域转换,但并不决定转换将如何发生。
将商品添加到购物车的功能并不关心商品的具体添加方式:用户通过“购买”按钮自行添加,还是通过优惠码自动添加。在这两种情况下,系统都会接受该商品,并返回包含已添加商品的更新购物车。
应用层
领域层周围是应用层。这一层描述用例,即用户场景。它们负责处理某些事件发生后的处理。
例如,“添加到购物车”场景就是一个用例。它描述了点击按钮后应该采取的操作。它就像一个“协调器”,它会说:
- 前往服务器,发送请求;
- 现在执行域转换;
- 现在使用响应数据重新绘制 UI。
此外,在应用层,还有端口——应用程序希望与外界如何通信的规范。通常,端口是一个接口,一个行为契约。
端口充当着应用程序的期望与现实之间的“缓冲区”。输入端口告诉我们应用程序希望如何与外界联系。输出端口则说明应用程序将如何与外界通信以做好准备。
稍后我们将更详细地了解端口。
适配器层
最外层包含与外部服务的适配器。适配器的作用是将外部服务不兼容的 API 转换为符合我们应用程序需求的 API。
适配器是降低我们代码与第三方服务代码之间耦合度的好方法。低耦合减少了当其他模块发生变化时,也需要修改某个模块的情况。
适配器通常分为:
- 驱动——向我们的应用程序发送信号;
- 驱动——接收来自我们应用程序的信号。
用户最常与驱动适配器交互。例如,UI 框架对按钮点击事件的处理就是驱动适配器的工作。它与浏览器 API(本质上是一个第三方服务)协同工作,并将事件转换为应用程序可以理解的信号。
驱动适配器与基础设施交互。在前端,大部分的基础设施是后端服务器,但有时我们也可能直接与一些其他服务交互,比如搜索引擎。
请注意,我们离中心越远,代码功能就越“面向服务”,它离我们应用程序的领域知识就越远。这一点在我们稍后决定任何模块应该属于哪一层时非常重要。
依赖规则
三层架构有一个依赖规则:只有外层可以依赖于内层。这意味着:
- 该域必须是独立的;
- 应用层可以依赖于域;
- 外层可以依赖任何东西。
图片来源:herbertograca.com。
有时这条规则是可以违反的,但最好不要滥用。例如,有时在领域中使用一些“类似库”的代码会很方便,即使它们之间不应该存在依赖关系。我们将在查看源代码时看到一个这样的例子。
依赖项方向不受控制会导致代码复杂且混乱。例如,违反依赖项规则可能会导致:
- 循环依赖,其中模块 A 依赖于 B,B 依赖于 C,C 依赖于 A。
- 可测试性差,必须模拟整个系统才能测试一小部分。
- 耦合度太高,导致模块之间交互脆弱。
清洁架构的优势
现在我们来谈谈这种代码分离给我们带来了什么。它有几个优点。
独立域名
所有主要应用程序功能都被隔离并收集在一个地方 - 在域中。
领域中的功能是独立的,这意味着更容易测试。模块的依赖关系越少,测试所需的基础设施就越少,所需的模拟和存根也就越少。
独立域也更容易根据业务预期进行测试。这有助于新开发人员掌握应用程序的功能。此外,独立域还能帮助更快地查找从业务语言到编程语言的“翻译”过程中的错误和不准确之处。
独立用例
应用场景和用例分别描述。它们决定了我们需要哪些第三方服务。我们让外部世界适应我们的需求,而不是反过来。这让我们在选择第三方服务时拥有更大的自由。例如,如果当前的支付系统收费过高,我们可以快速更换。
用例代码也变得扁平、可测试且可扩展。我们将在稍后的示例中看到这一点。
可替换的第三方服务
由于适配器的存在,外部服务变得可替换。只要我们不改变接口,哪个外部服务实现该接口都无所谓。
这样,我们就创建了一道变更传播的屏障:其他人代码的变更不会直接影响我们自己的代码。适配器还能限制应用程序运行时中错误的传播。
清洁架构的成本
架构首先是一种工具。与任何工具一样,清晰架构除了有好处之外,也有其成本。
需要时间
主要成本是时间。不仅设计需要时间,实现也需要时间,因为直接调用第三方服务总是比编写适配器更容易。
提前考虑系统所有模块的交互也很困难,因为我们可能无法事先了解所有需求和约束。在设计时,我们需要考虑系统如何变化,并留出扩展空间。
有时过于冗长
一般来说,清晰架构的规范实现并不总是方便的,有时甚至是有害的。如果项目规模较小,那么完整实现则会显得矫枉过正,反而会增加新手的入门门槛。
您可能需要在设计上做出一些权衡,以控制预算或满足截止日期。我将通过示例向您展示我所说的这种权衡的具体含义。
可能会使入职更加困难
全面实施清洁架构可能会使入职变得更加困难,因为任何工具都需要如何使用它的知识。
如果在项目初期过度设计,以后引入新开发人员会更加困难。你必须牢记这一点,并保持代码简洁。
可能会增加代码量
前端特有的一个问题是,简洁的架构会增加最终打包的代码量。我们提供给浏览器的代码越多,它需要下载、解析和解释的代码就越多。
必须注意代码量,并决定在哪里偷工减料:
- 也许可以更简单地描述用例;
- 也许直接从适配器访问域功能,绕过用例;
- 也许我们需要调整代码分割等等。
如何降低成本
你可以通过偷工减料、牺牲架构的“整洁度”来减少时间和代码量。我通常不喜欢激进的做法:如果打破规则更务实(例如,收益高于潜在成本),我就会打破它。
所以,你可以暂时对清洁架构的某些方面犹豫不决,完全没有问题。然而,绝对值得投入的最低资源有两点。
提取域
提取的领域有助于理解我们正在设计的总体内容以及它应该如何工作。提取的领域使新开发人员更容易理解应用程序、其实体以及它们之间的关系。
即使我们跳过其他层,提取出的领域不会分散在代码库中,因此使用起来仍然更容易。其他层可以根据需要添加。
遵守依赖规则
第二条不可忽视的规则是依赖关系,或者更确切地说是方向性。外部服务必须适应我们的需求,否则就无法满足。
如果您觉得您正在“微调”代码以便它能够调用搜索 API,那么肯定有问题。最好在问题蔓延之前编写一个适配器。
设计应用程序
理论讲完了,我们来实践一下。我们来设计一个 Cookie 存储的架构。
该商店将出售不同种类的饼干,这些饼干可能含有不同的成分。用户将选择并订购饼干,并通过第三方支付服务支付订单。
主页上会展示可供购买的 Cookie。只有通过身份验证才能购买 Cookie。点击登录按钮后,我们会跳转到登录页面进行登录。
(别介意它的外观,我不是网页设计师😄)
成功登录后,我们将能够在购物车中放入一些cookie。
当我们把饼干放进购物车后,就可以下单了。付款后,我们会在列表中看到一个新的订单,并且购物车会被清空。
我们将实现结账用例。其余用例可以在源代码中找到。
首先,我们要定义广义上的实体、用例和功能。然后,我们来决定它们应该属于哪一层。
设计域
应用程序中最重要的是领域。它是应用程序的主要实体及其数据转换的所在。我建议你从领域入手,以便在代码中准确地表达应用程序的领域知识。
商店域可能包括:
- 每个实体的数据类型:用户、cookie、购物车和订单;
- 用于创建每个实体的工厂,或者如果使用 OOP 编写则为类;
- 以及该数据的转换函数。
域中的变换函数应该仅依赖于域的规则,而不依赖于其他任何规则。例如,这样的函数可以是:
- 计算总成本的函数;
- 用户的口味偏好检测
- 确定某件商品是否在购物车中等等。
设计应用层
应用层包含用例。每个用例通常包含一个参与者、一个动作和一个结果。
在商店中,我们可以区分:
- 产品购买场景;
- 支付,调用第三方支付系统;
- 与产品和订单的互动:更新、浏览;
- 根据角色访问页面。
用例通常根据主题领域进行描述。例如,“结账”场景实际上包含以下几个步骤:
- 从购物车中检索商品并创建新订单;
- 支付订单;
- 如果付款失败,通知用户;
- 清空购物车并显示订单。
用例函数将是描述此场景的代码。
此外,在应用层还有端口——与外界通信的接口。
设计适配器层
在适配器层,我们声明了与外部服务的适配器。适配器使第三方服务不兼容的 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/
。我们将在最后讨论此代码结构的替代方案。
创建域实体
我们将在领域中有 4 个模块:
- 产品;
- 用户;
- 命令;
- 購物車。
主要参与者是用户。我们将在会话期间将有关用户的数据存储在存储中。我们需要对这些数据进行类型化,因此我们将创建一个域用户类型。
用户类型将包含 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 {
user: user.id,
cart,
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
请注意,我们在每个函数中都构建了 API,以便我们可以轻松地转换数据。我们接受参数并根据需要给出结果。
在设计阶段,尚无任何外部约束。这使我们能够尽可能贴近主题领域地反映数据转换。转换越贴近现实,就越容易检验其有效性。
详细设计:共享内核
你可能注意到了我们在描述域类型时使用的一些类型。例如,Email
或UniqueId
。DateTimeString
这些是类型别名:
// shared-kernel.d.ts
type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;
我通常使用 type-alias 来摆脱原始的痴迷。
我使用DateTimeString
而不是string
,以便更清楚地说明使用了哪种字符串。类型越接近主题区域,发生错误时处理起来就越容易。
指定的类型在文件中shared-kernel.d.ts
。共享内核是指代码和数据,它们之间的依赖不会增加模块之间的耦合度。更多关于此概念的信息,请参阅“DDD、六边形、洋葱、Clean、CQRS……我如何将它们整合在一起”。
实际上,共享内核可以这样解释。我们使用 TypeScript,使用它的标准类型库,但我们不将它们视为依赖项。这是因为使用它们的模块可能彼此之间没有任何了解,并且保持解耦。
并非所有代码都能归类为共享内核。最主要也是最重要的限制在于,此类代码必须与系统的任何部分兼容。如果应用程序的一部分用 TypeScript 编写,而另一部分用其他语言编写,则共享内核可能只包含可在两个部分中使用的代码。例如,JSON 格式的实体规范可以,但 TypeScript 助手则不行。
在我们的例子中,整个应用程序都是用 TypeScript 编写的,因此内置类型的类型别名也可以归类为共享内核。这种全局可用的类型不会增加模块之间的耦合,并且可以在应用程序的任何部分使用。
细节:应用层
现在我们已经搞清楚了领域,可以进入应用层了。这一层包含用例。
在代码中,我们描述场景的技术细节。用例描述了在将商品添加到购物车或进行结账后数据应该如何处理。
用例涉及与外部世界的交互,因此也涉及外部服务的使用。与外部世界的交互是副作用。我们知道,没有副作用的函数和系统更容易使用和调试。而且我们的大多数领域函数都已经写成了纯函数。
为了将干净的转换和交互与不纯的世界结合起来,我们可以使用应用层作为不纯的上下文。
纯变换的不纯上下文
纯转换的不纯上下文是一种代码组织,其中:
- 我们首先执行一个副作用来获取一些数据;
- 然后我们对该数据进行纯粹的转换;
- 然后再次执行副作用来存储或传递结果。
在“将商品放入购物车”用例中,它看起来像这样:
- 首先,处理程序将从商店检索购物车状态;
- 然后它会调用购物车更新功能,传递要添加的商品;
- 然后它会将更新后的购物车保存在存储中。
整个过程就像一个“三明治”:副作用函数、纯函数、副作用函数。主要逻辑体现在数据转换上,所有与外界的通信都被隔离在一个命令式的外壳里。
在命令式 shell 中,非纯上下文有时被称为函数式核心。Mark Seemann在他的博客中对此进行了阐述。这也是我们在编写用例函数时将使用的方法。
设计用例
我们将选择并设计结账用例。它是最具代表性的用例,因为它是异步的,并且与许多第三方服务交互。其余场景以及整个应用程序的代码,您可以在GitHub上找到。
让我们思考一下在这个用例中我们想要实现什么。用户有一个包含 Cookie 的购物车,当用户点击结帐按钮时:
- 我们想要创建一个新的秩序;
- 通过第三方支付系统支付;
- 如果付款失败,通知用户;
- 如果通过,则将订单保存在服务器上;
- 将订单添加到本地数据存储以显示在屏幕上。
就 API 和函数签名而言,我们希望将用户和购物车作为参数传递,并让函数自行完成其他所有操作。
type OrderProducts = (user: User, cart: Cart) => Promise<void>;
当然,理想情况下,用例不应该接受两个单独的参数,而应该接受一个将所有输入数据封装在其内部的命令。但我们不想增加代码量,所以就保留这个。
编写应用层端口
让我们仔细看看这个用例的步骤:订单创建本身是一个领域函数。其他所有部分都是我们想要使用的外部服务。
重要的是要记住,外部服务必须适应我们的需求,而不是其他方式。因此,在应用层,我们不仅会描述用例本身,还会描述这些外部服务的接口——端口。
端口首先应该方便我们的应用使用。如果外部服务的 API 不符合我们的需求,我们才会编写适配器。
让我们考虑一下我们需要的服务:
- 支付系统;
- 向用户通知事件和错误的服务;
- 将数据保存到本地存储的服务。
请注意,我们现在讨论的是这些服务的接口,而不是它们的实现。在这个阶段,描述所需的行为对我们来说很重要,因为这是我们在描述场景时在应用层所依赖的行为。
这种行为究竟如何实现目前还不重要。这让我们可以把使用哪些外部服务的决定推迟到最后一刻——这使得代码耦合度最低。我们稍后再讨论具体实现。
另请注意,我们按功能划分了界面。所有与支付相关的功能都放在一个模块中,与存储相关的功能则放在另一个模块中。这样可以更轻松地确保不同第三方服务的功能不会混淆。
支付系统接口
Cookie 存储是一个示例应用程序,因此支付系统会非常简单。它只会有一个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、或者其他任何你感兴趣的 js 库。这个仓库可以拆分成多个用于不同实体的微存储,也可以是一个用于存储所有应用数据的大型仓库。目前这也不重要,因为这些都是实现细节。
我喜欢将存储接口划分成针对每个实体的独立接口。例如,用户数据存储需要一个单独的接口,购物车数据存储需要一个单独的接口,订单数据存储也需要一个单独的接口:
// 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("Oops! 🤷");
// 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 };
}
我们使用钩子来实现“弯曲的依赖注入”。首先,我们使用钩子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 就会检查该函数是否返回一个包含接口中声明的所有方法的对象。
通知服务实现
让通知变得简单alert
。由于代码是解耦的,所以以后重写这个服务不会有问题。
// services/notificationAdapter.ts
import { NotificationService } from "../application/ports";
export function useNotifier(): NotificationService {
return {
notify: (message: string) => window.alert(message),
};
}
本地存储实现
假设本地存储为 React.Context 和 hooks。我们创建一个新的 context,将值传递给 provider,导出 provider,并通过 hooks 访问存储。
// store.tsx
const StoreContext = React.createContext<any>({});
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 层只能通过端口访问应用程序。也就是说,我们可以根据需要更改 UI。
用例在应用层处理,它告诉我们需要哪些外部服务。所有主要逻辑和数据都在领域层。
所有外部服务都隐藏在基础设施中,并遵循我们的规范。如果我们需要更改发送消息的服务,我们唯一需要在代码中修复的就是新服务的适配器。
该方案使得代码可替换、可测试、可扩展以满足不断变化的需求。
哪些方面可以改进
总而言之,这足以让你入门并对清晰架构有一个初步的了解。但我想指出的是,为了让示例更容易理解,我做了一些简化。
本节是可选的,但它将帮助您更深入地了解“没有偷工减料”的干净架构是什么样的。
我想强调一些可以做的事情。
使用对象代替数字来表示价格
你可能注意到我用数字来描述价格。这不是一个好的做法。
// shared-kernel.d.ts
type PriceCents = number;
数字仅表示数量而不表示货币,没有货币的价格毫无意义。理想情况下,价格应该设计为一个包含两个字段的对象:值和货币。
type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number;
type Price = {
value: AmountCents;
currency: Currency;
};
这将解决存储货币的问题,并在商店更改或添加货币时节省大量精力和精力。为了避免复杂化,我在示例中没有使用此类型。然而,在实际代码中,价格应该更接近此类型。
另外,值得一提的是价格的价值。我总是把货币数量保持在流通货币的最小比例。例如,对于美元来说,就是美分。
用这种方式显示价格让我不用考虑除法和小数部分。对于货币来说,这一点尤其重要,尤其当我们想避免浮点运算的问题时。
按功能而不是层来拆分代码
代码可以按“功能”而不是“层”拆分到文件夹中。一个功能可以是下图中的一部分。
这种结构更为可取,因为它允许您单独部署某些功能,这通常很有用。
图片来源:herbertograca.com。
我建议阅读“DDD、六边形、洋葱、清洁、CQRS……我如何将它们组合在一起”。
我还建议看一下Feature Sliced,它在概念上与组件代码划分非常相似,但更容易理解。
注意跨组件的使用
如果我们谈论将系统拆分成组件,那么跨组件的代码使用也值得一提。让我们回顾一下订单创建函数:
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
此函数使用totalPrice
另一个组件(产品)的功能。这种用法本身没有问题,但如果我们想将代码拆分成独立的功能,就无法直接访问其他功能的功能。
您还可以在“DDD、六边形、洋葱、清洁、CQRS、...我如何将它们组合在一起”和功能切片中看到解决此限制的方法。
使用品牌类型,而不是别名
对于共享内核,我使用了类型别名。它们操作起来很简单:只需创建一个新类型并引用,例如一个字符串。但它们的缺点是 TypeScript 没有机制来监控它们的使用并强制执行。
这似乎不是什么问题:所以有人用string
而不是DateTimeString
——那又怎样?代码可以编译。
问题恰恰在于,即使使用了更宽泛的类型,代码也能编译通过(用更巧妙的话来说,就是先决条件被弱化了)。这首先会使代码更加脆弱,因为它允许你使用任何字符串,而不仅仅是特殊类型的字符串,这可能会导致错误。
其次,它读起来很混乱,因为它创建了两个事实来源。不清楚你是否真的只需要在那里使用日期,或者你是否基本上可以使用任何字符串。
有一种方法可以让 TypeScript 理解我们想要的特定类型——使用品牌化(品牌类型)。品牌化可以精确跟踪类型的使用方式,但会使代码稍微复杂一些。
注意域中可能存在的依赖关系
接下来令人痛苦的事情是在函数的域中创建一个日期createOrder
:
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
// Вот эта строка:
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
我们怀疑这new 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 {
user: user.id,
cart,
created: currentDatetime(),
status: "new",
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);
}
这将保持域独立并且也使其更容易测试。
在示例中,我选择不关注这一点,原因有二:这会分散注意力,偏离主题;而且,如果辅助函数只使用了语言特性,我认为依赖它本身也没什么问题。这样的辅助函数甚至可以被视为共享内核,因为它们只是减少了代码重复。
注意购物车和订单之间的关系
在这个小例子中,Order
包括Cart
,因为购物车仅代表产品列表:
export type Cart = {
products: Product[];
};
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
Cart
如果 中存在与 无关的附加属性,则此方法可能不起作用Order
。在这种情况下,最好使用数据投影或中间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 };
}
在规范实现中,用例函数将位于钩子之外,并且服务将通过最后一个参数或通过 DI 传递给用例:
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
可以通过将所需的服务 mochas 作为依赖项传递来测试该函数。
配置自动依赖注入
在应用程序层,我们现在手动注入服务:
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 };
}
但一般来说,这可以通过依赖注入自动化完成。我们已经通过最后一个参数了解了最简单的注入版本,但您可以更进一步,配置自动注入。
在这个特定的应用程序中,我认为设置 DI 没什么意义。它会分散注意力,并使代码过于复杂。而对于 React 和 hooks,我们可以将它们用作返回指定接口实现的“容器”。没错,这需要手动操作,但它不会增加入门门槛,而且对于新开发者来说更容易理解。
实际项目中可能会更复杂
文章中的例子经过精炼,并刻意简化。显然,生活远比这个例子更加令人惊奇和复杂。因此,我也想谈谈在使用清晰架构时可能出现的常见问题。
分支业务逻辑
最重要的问题是我们缺乏相关知识的主题领域。想象一下,一家商店有一款商品、一款打折商品和一款减价商品。我们如何正确地描述这些实体?
是否应该有一个可以扩展的“基础”实体?这个实体究竟应该如何扩展?是否应该添加其他字段?这些实体应该互斥吗?如果存在另一个实体而不是简单实体,用户案例应该如何表现?是否应该立即减少重复?
问题和答案可能太多,因为团队和利益相关者都还不清楚系统实际应该如何运作。如果只有假设,你可能会陷入分析瘫痪。
具体解决办法要看具体情况,我只能推荐一些一般性的东西。
不要使用继承,即使它被称为“扩展”。即使接口看起来确实是继承的。即使它看起来“嗯,这里显然存在一个层次结构”。等等。
代码中的复制粘贴并不总是邪恶的,它是一种工具。创建两个几乎相同的实体,观察它们在实际中的行为,并观察它们。在某些时候,你会注意到它们要么变得非常不同,要么实际上只在一个字段上有所不同。将两个相似的实体合并为一个比为所有可能的条件和变量创建检查更容易。
如果您仍需要扩展某些内容...
记住协变、逆变和不变性,这样你就不会意外地做出超出你应该做的工作。
在选择不同的实体和扩展时,可以参考BEM 中的块和修饰符。在 BEM 的语境下,它能帮助我判断代码中究竟是单独的实体,还是一个“修饰符-扩展”。
相互依赖的用例
第二大问题是相关用例,其中一个用例的事件会触发另一个用例。
解决这个问题的唯一方法,我知道并且对我有帮助,就是把用例分解成更小的原子用例。这样更容易组合在一起。
总的来说,此类脚本的问题是编程中的另一个大问题——实体组合——造成的。
关于如何高效地组合实体,已经有很多文章进行了阐述,甚至还有一整节数学知识。我们不会深入探讨,那是另一个主题,需要另写一篇文章来讨论。
结论
在这篇文章中,我概述并扩展了我关于前端清洁架构的讨论。
它并非黄金标准,而是对不同项目、范式和语言经验的总结。我发现它是一种便捷的方案,可以让你解耦代码,并创建独立的层、模块和服务,这些层、模块和服务不仅可以单独部署和发布,还可以根据需要在项目之间迁移。
我们没有涉及面向对象编程 (OOP),因为架构和面向对象编程是相互独立的。诚然,架构讨论了实体组合,但它并没有规定组合的单位应该是对象还是函数。正如我们在示例中所见,你可以在不同的范式中使用面向对象编程。
至于 OOP,我最近写了一篇关于如何将简洁架构与 OOP 结合使用的文章。在这篇文章中,我们在画布上编写了一个树形图片生成器。
想要了解如何将这种方法与其他方法(例如芯片切片、六边形架构、CQS 等)完美结合,我推荐阅读《DDD、六边形架构、洋葱架构、Clean、CQRS……我是如何将它们整合在一起的》以及本博客的整个系列文章。文章非常有见地,简洁明了,切中要点。
来源
实践设计
- 清洁架构
- 模型-视图-控制器
- DDD、六边形、洋葱、Clean、CQRS……我如何将它们整合在一起
- 端口和适配器架构
- 不仅仅是同心层
- 使用 L-Systems、TypeScript 和 OOP 系列文章生成树