前端的Clean Architecture

小红星闪啊闪
English

在开始之前:

计划是什么

首先,我们要大概的介绍一下什么是clean architecture,然后熟悉一下domain,用例和分层的概念。然后我们来讨论一下这些怎么用在前端,和是否有这个必要。

接下来,我们用clean architecture的原则来设计一个饼干商店。并从头实现某个用例来看看可行性。

这个商店的界面部分会使用React,这样我们可以看看这个原则是否可以和React公用,虽然React不是必须的,其实什么UI框架、库都是可以的。

在代码里会有一部分TypeScript,只是用来展示如何使用类型和接口来描述实体。我们今天看到的代码都可以不用TypeScript,除非无法表达。

本文基本不会谈论OOP,所以这个文章应该不会触动某些人的神经。只会在文末提一次,但是和实际我们的应用没什么太大关系。

而且本文也不会提及测试,因为这不是本文的重点。

机构和设计

设计就是把事物拆分。。。用一种之后可以拼接在一起的方法。。。把事物拆分成可以组合在一起的事物就是设计 -- Rich Hickey《设计、重组和性能》

系统设计,如上引用,就是为了日后可以重组而进行的系统分割。很重要的一点就是日后的重组不会耗费太多资源。

我(作者)很同意,但是架构的另外一个目标也是不得不考虑的,那就是可扩展性。对应用的需求是不断变更的。我们需要我们的程序可以快速的更新或者修改以满足新的需求。Clean architecture在这方面可以一显身手。

Clean Architecture

Clean architecture是一种根据应用的域(domain)的相似性来分割职责和功能块的方法。

域(domain)是由真实世界抽象而来的程序模型。是真实世界数据转化在程序的映射。

Clean architecture总是会用到一个三层架构,在这里功能被分层。原始的clean architecture则提供了一张如下的图:
image.png

图片来自这里

域(domain)分层

在中心的是域(domain)层。这里是描述应用的主题区域的实体和数据,以及数据转换的代码。域(domain)是区分不同程序的核心。

你可以把它理解为当我们从React换到Angular,或者改变某些用例的时候不会变的那一部分。在饼干商店这个例子里就是产品、订单、用户、购物车和更新这些数据的方法。

数据结构和他们之间的转化与外部世界是相互隔离的。外部世界会触发域的转化但是并不会决定他们如何运行。

给购物车增加物品的方法并不关心这个数据是如何加上去的:用户点击“购买”按钮或者使用了促销卡之类的。两种情况都会增加一个物品,并且返回一个更新之后的购物车对象。

应用层(Application Layer)

围在域(domain)外面的是应用层。这一层描述了用例(use cases),比如某些用户场景。他们描述了某些事件发生后会发生什么。

比如,“添加到购物车”这个场景是一个用例,它描述了再点击这个按钮之后会发生什么。就像是某种“指挥家”:

  • 向server发送一个请求
  • 执行域(domain)转换
  • 根据返回的数据更新UI

同时,在应用层还会有一些接口(port)--它描述了外部世界如何和应用层沟通。一般来说一个接口就是一个interface,一个行为契约。

接口(port)也可以被认为是一个现实世界和应用程序的“缓冲区”。输入Port告诉我们应用要如何接受外部的输入,同样输出Port告诉我们会如何告知外部直接应用的信息。

下面来看一些细节:

适配层

最外层包含了对外部的各种适配器。这些适配器要把外面不兼容的API转换成应用需要的样子。

这些适配器可以极大的降低我们和外部第三方代码的耦合。降低耦合意味着只要很好的代码修改就可以适配其他模块的变化。

适配器一般分为:

  • 驱动型 -- 向我们的应用发送消息的
  • 被动型 -- 接受我们的应用所发送的消息

用户最长接触的是驱动型适配器。比如,处理UI层发送的点击事件就是一个驱动型适配器。它会根据浏览器API把一个事件转换为一个我们的应用可以理解的信号。

驱动型适配器和我们的基础设施相交互。在前端,最常见的基础设施就是后端。当然也会和其他的服务直接交互。

注意,离中心越远,也就是离应用的域(domain)越远,代码的功能就越是“面向服务”的。这在后面我们要决定一个模块是哪一层的时候是非常重要的。

依赖规则

三层架构有一个以来规则:只有外层的可以依赖内层。也就是:

  • 域(domain)必须独立
  • 应用层可以依赖于域(domain)
  • 最外层可以依赖于任何东西

image.png

某些时候这条铁律可以违反,不过尽量不要这么做。比如:有时在域的范围内可以使用一些外部的“库”一样的代码,即使这个时候其实应该是没有依赖的。在讨论源码的时候我们会看到这个例子。

依赖方向不受控的代码会变得非常复杂和难以维护。比如,不遵守依赖规则会:

  • 循环依赖,A模块依赖B模块,B模块依赖C模块,然后C模块又依赖于A模块
  • 低可测,即使测试一小块功能也不得不模拟整个系统
  • 高耦合,模块之间的调用极易出问题

Clean Architecture的优势

现在我们来讨论下代码分割可以给我们带来怎样的好处。

分割域(domain)

所有的应用的功能都是独立的,并且集中在一个地方 -- 域。

域的功能是独立的也就是说它更容易测试。模块的依赖越少,测试的时候需要的基础设施就越少,mock和桩模块也就越少。

一个相对独立的域也很容易测试它是否满足需求。这让新手更容易理解应用是做什么的。另外,一个独立的域也让从需求到代码实现中出现的错误和不准确更容易排除。

独立的用例(Use Case)

应用的使用场景和用例都是独立描述的。它表明了我们所需要的第三方服务。我们让外部服务为我们所用,而不是削足适履。这让我们有更多的空间可以选择合适的第三方服务。比如,一旦一个收费服务收取更高的佣金的时候我们可以很快的换掉它。

用例的实现代码也是扁平的,已测试,有足够的扩展性。我们会在后面的代码看到这一点。

可更换的第三方服务

适配器让外部服务变容易更换。只要我们不更换接口,那么实现这个接口的是哪个第三方服务是无关紧要的。

这样可以建立一个修改传播的屏障:修改是某个人的修改,不会直接影响我们。适配器也会在应用运行时减少bug的传播。

Clean Architecture的代价

架构首先是一个工具。和所有工具一样,clean architecture带来好处的同时并不是没有代价的。

时间

消耗最多的是时间。设计和实现都需要消耗额外的时间,因为我们在开始的时候就知道所有的需求和约束。在设计的时候我们就需要留意哪些地方会发生修改,并为此留下修改的空间。

有时会过度冗余

一般来说,经典的clean architecture实现会带来不便,有时甚至有害。如果是一个小项目的,完全照本宣科的实现会抬高实现门槛,劝退新手。

为了满足资金或者交付日期,不得不做一些取舍。后面会用代码来说明这些取舍都是什么。

上手更难

完全的按照clean architecture的实现会让新手上路更难。任何的工具都需要先了解这个工具是如何运行的。

如果你在项目初期就过度设计,后面就会增加新同学的上手难度。记住这一点,尽量保持代码的简单。

增加代码量

对于前端来说,实践clean architecture会增加打包后的体积。我们给浏览器的代码越多,它就不得不花更多的下载和解析的时间。

代码量的问题需要从一开始就把控好,能少的地方尽量少:

  • 让用例更加简单
  • 直接和适配器交互,绕开用例
  • 使用代码分割

如何减少代价

可以适度损失代码的纯洁性来减少上面说到的损失。我(作者)不是一个激进的人,如果可以获得更大的好处要破坏一些代码的纯洁性,那也是可以的。

所以,不必方方面面都遵守clean architecture的条条框框。但是最低限度的两条需要认真对待:

抽离域(Domain)

对域的抽离可以帮助我们理解我们正在设计的是什么,它是如何工作的。抽离出来的域也会让其他的开发同学更容易理解应用是如何运作的。

即使抛开其他几层不谈,分离的域也更加容易重构。因为它的代码没有分散在应用的各个地方。其他层可以更具需要添加。

遵守依赖规则

第二个不能抛弃的规则是依赖规则,或者说是他们的方向。外部的服务需要适配到内部的服务,而不是反方向。

如果你觉得直接调用一个搜索API也没什么问题,那么这就是问题所在了。最好在问题没有扩散之前写一个适配器。

设计应用

之前都是务虚,现在我们来写一些代码。我们来设计一下这个饼干店的架构吧。

这个饼干店要售卖不同种类、不同配方的饼干。用户可以选择饼干并下单,之后使用第三方的支付服务来下单。

还首页有可以买的饼干展示。我们只能在认证之后才可以购买饼干。登录按钮会把我们带到登录页。

image.png

(界面有点丑,因为没有设计师帮忙)

成功登录之后,我们就可以往购物车里加饼干了。

image.png

当购物车里有饼干之后就可以下单了。支付之后,生成订单并清空购物车。

我们会实现上面说的功能,其他的用例可以在源码中找到。

首先我们定义广义上的实体、用例和功能。之后把他们划分到不同的层里。

设计域(domain)

程序开发中最重要的是就是域的处理。这是实体和数据转换的所在。我建议从域开始在代码中可以精确的展现域知识(domain knowledge)。

店铺的域包括:

  • 不同实体的类型:User、Cookie、Cart和Order
  • 如果你是用OOP实现的,那么也包括生成实体的工厂和类
  • 以及这些数据转换的方法

域(domain)里的数据转换方法应该是只依赖于域的规则,而不是其他。比如方法应该是:

  • 计算总价的方法
  • 检测用户口味的方法
  • 检测一个物品是否在购物车的方法

image.png

设计应用层

应用层包含用例,一个用例包含一个执行人、一个动作和一个结果。

在饼干店这个例子里:

  • 一个产品购买场景
  • 支付,调用第三方支付系统
  • 与产品和订单的交互,更新和搜索
  • 根据角色不同访问不同页面

用例一般都是用主题领域描述,比如购买流程有以下步骤:

  • 获取购物车里的物品,并新建一个订单
  • 支付订单
  • 如果支付失败,通知用户
  • 支付成功,清空购物车,显示订单

这个用例最后会变成完成这个功能的代码。

同时,在应用层还有各种和外界沟通是需要的接口。
image.png

设计应用层

在适配器层,我们声明连接外部服务的适配器。适配器让不兼容的外部服务和我们的系统兼容。

在前端,适配器一般是UI框架和对后端的API请求模块。在本例中我们会用到:

  • UI框架
  • API请求模块
  • 对本地存储的适配器
  • API返回到应用层的适配器

image.png

使用MVC做类比

有时我们数据是属于哪一层的。一个小的(也许不完整)的MVC的类比可以用的上:

  • Model一般都是域实体
  • 控制器(Controller)一般是与转换或者应用层
  • 试图是驱动适配器

这些概念在细节上不尽相同但是内行非常相似,这个类比可以用在定义域和应用代码。

深入细节:域

一旦我们决定了我们所需要的实体,我们就可以定义相关的行为了。

我现在就会给你看项目的代码细节。为了容易理解我把代码分为不同的目录:

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目录下,适配器都在service目录下。我们会讨论目录结构是否会有其他的可行方案。

新建域实体

在域内包含了四个实体:

  • product
  • user
  • order
  • shopping cart

这些实体最重要的是user。在会话中,我们会把用户实体存储起来。同时我们会给user增加类型。

用户实体包含IDnamemail以及preferencesallergies数组。

// domain/user.ts

export type UserName = string;
export type User = {
  id: UniqueId;
  name: UserName;
  email: Email;
  preferences: Ingredient[];
  allergies: Ingredient[];
};

用户可以把饼干放进购物车,我们也给购物车和饼干加上类型。

// 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[];
};

在支付成功之后,会新建一个订单。我们也给订单实体加上类型:

// domain/order.ts

export type OrderStatus = "new" | "delivery" | "completed";

export type Order = {
  user: UniqueId;
  cart: Cart;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};

理解实体之间的关系

给实体添加类型之后,可以检查实体关系图和实际情况是否符合
image.png

我们可以检查的点:

  • 主要的参与者是否是一个user
  • 在订单里是否有足够的信息
  • 是否有些实体需要扩展
  • 在未来是否有足够的可扩展性

同时,在这个阶段,类型可以帮助识别实体之间的信息和调用的错误。

创建数据转换

用例的行为会发生在不同的实体之间。我们可以给购物车添加物品、清空购物车、更新物品和用户名称,等。我们会分别新建方法来完成上述功能。

比如,为了判断某个用户对不同的口味是喜欢还是厌恶。我们可以定义hasAllergyhasPreference

// 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);
}

方法addProductcontains用来给购物车添加物品和检查一个物品是否在购物车里

// 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),
  };
}

在设计阶段是包含外部约束的。这让我们的数据转换尽量贴近主题域。而且转换越贴近实际,就越容易检查代码是否可靠。

细节设计:共享的内核

你也许已经注意到我们在描述域的时候的一些类型,比如:EmailUniqueId或者DateTimeString。这些都是类型别名:

// shared-kernel.d.ts

type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;

我一般使用类型别名避免原始类型偏执

我用DateTimeString而不是string来更加清晰的表明这个字符串是用来做什么的。这些类型越贴近实际,以后排除就越容易。

这些类型都在shared-kernel.d.ts文件里。Shared Kernel是一些代码和数据,他们不会增加模块之间的耦合度。更多关于这个话题的内容,你可以在DDD, Hexagonal, Onion, Clean, CQRS, ...How I put it all together找到。

在实践中,共享内核可以这样解释。我们用了typescript,用了它的标准类型库,但是我们不认为这是依赖。这是因为使用了它的模块相互之间一样维持了“知识最少"原则,一样是低耦合的。

并不是所有的代码都可以作为共享内核。最主要的原则是这样的代码必须和系统处处兼容。如果应用一部分是用typescript开发的,一部分是其他语言。那么,共享核心只可以包含两种语言都可以工作的部分。比如,实体说明用JSON是没问题的,但是用typescript就不行。

在我们的例子里,整个应用都是用typescript写的,所以类型别名完全可以当做共享核心的一部分。这样的全局可用的类型并不会增加模块之间的耦合,并且可以在应用的任何地方使用。

深入细节: 应用层

现在我们已经完成了域这一部分的设计,我们以考虑应用层了。这一层包含i了用例

在代码里会包括每个场景的细节。一个用例描述了添加一个物品到购物车或者购买的时候包括的一系列步骤。

用例包含了应用和外部服务的交互。与外部服务的交互都是副作用。我们知道调用或者调试没有副作用的方法更简单一些。我们的域的方法都是纯方法。

为了集合内部的纯方法和外部的非纯世界,我们可以把应用层当做非纯的上下文。

非纯上下文域纯数据转换

一个非纯上下文和纯数据转换是这样一种代码组合:

  • 首先执行副作用获取数据
  • 之后对数据执行纯数据转化
  • 最后执行一个副作用,存储或者传递数据

在”往购物车添加物品“这个用例,看起来是这样的:

  • 首先,可以从数据库里获取购物车的状态
  • 然后调用方法把可以存进购物车的物品更新到购物车里
  • 之后把更新的购物车存到数据库里

整个过程就是一个三明治:副作用、纯方法、副作用。
image.png

非纯上下文有时叫做功能核心,因为它是异步的,和第三方服务有很多交互,这样的称呼也很有代表性的。其他的场景和代码都在github上。

让我们来想一想,通过整个用例我们要达到什么。用户的购物车里有一些饼干,当用户点击购买按钮的时候:

  • 我们要新建一个订单
  • 使用第三方支付系统支付
  • 支付失败,通知用户
  • 支付成功,把订单保存到后端
  • 在本地存储保存订单数据,并在页面上显示

在API或者方法的签名上,我们会把用户和购物车都作为参数,然后让这个方法把其他的都完成了。

type OrderProducts = (user: User, cart: Cart) => Promise<void>;

最好的情况,当然不是分别接收两个参数,而是把他们封装一下。但是,目前我们先保持现状。

编写应用层的接口

我们再来看看用例的细节:新建订单本身是域的方法。其他的都是我们用到的外部方法。

谨记一点,外部方法要适配我们的需要而不是反过来。所以,在应用层,我们不仅要描述用例本身,也要定义调用外部服务的接口。

我们来看看需要的服务:

  • 支付服务
  • 通知用户事件、错误的服务
  • 把数据保存在本地存储的服务

image.png

注意我们讨论的是这些服务的接口,不是他们的实现。在这一阶段,描述必要的步骤非常重要。因为,在应用层的某些场景里,这些都是必要的。

如何实现现在不是重点。这样我们可以在最后再考虑调用哪些外部服务,这样代码才能尽量保证低耦合。

同时需要注意,我们会根据功能点分割接口。支付相关的都在一个模块下,存储相关的在另外一个。这样可以确保不同的第三方服务不会混在一起。

支付系统接口

饼干点是一个简单的应用,所以支付系统也很简单。它会包含一个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,任何可以存储的都可以。存储空间可以是为每个不同的功能点分割出来的,也可以是全部都放在一起的。现在这个不重要,因为这些都是实现的细节。

我喜欢把存储接口为每个实体做分割。一个单独的接口存储用户数据,一个存储购物车,一个存储订单:

// application/ports.ts

export interface OrdersStorageService {
  orders: Order[];
  updateOrders(orders: Order[]): void;
}

这个例子里只有订单存储的接口,其他的在GitHub。

用例方法

我们来看看能不能用域方法和刚刚建的接口来完成一个用例。脚本将包含如下步骤:

  • 验证数据
  • 新建订单
  • 支付订单
  • 通知问题
  • 保存结果

image.png

首先,我们定义出来我们要调用的桩模块。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>
  );
}

我们通过一个hook来实现用例。我们会把所有的服务都放在里面,最后返回用例的方法:

// application/orderProducts.ts

export function useOrderProducts() {
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();

  async function orderProducts(user: User, cookies: Cookie[]) {
    // …
  }

  return { orderProducts };
}

我们使用hook来当作一个依赖注入。首先我们使用hooksuseNotifierusePaymentuseOrdersStorage来获取服务实例,然后我们用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方法是一个定时方法,将在450ms之后执行。这样来模拟一个从后端返回的请求。它会把我们传入的参数返回。

// services/api.ts

export function fakeApi<TResponse>(response: TResponse): Promise<TResponse> {
  return new Promise((res) => setTimeout(() => res(response), 450));
}

通知服务的实现

通知在本例中将是一个简单的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,再export出去让其他的模块可以通过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>
  );
};

我们会给每一个功能点都写一个hook。这样我们不会破坏服务接口和存储,至少在接口的角度来说他们是分离的。

// services/storageAdapter.ts

export function useOrdersStorage(): OrdersStorageService {
  return useStore();
}

同时这样的方法让我们可以给每个存储额外的优化,我们可以新建selector、缓存等。

验证数据流图

现在我们来验证一下用户可以如何与应用交互。

image.png

用户通过UI层与应用交互,但是UI层也是通过特定的接口与应用交互。所以,想换UI就可以换。

用例是在应用层处理的,这让我们很清楚需要什么外部服务。所有的主数据和逻辑都在域层。

所有的外部服务都放在基础架构,并且遵守我们的规范。如果我们需要更换发送消息服务,只需要修改外部服务的适配器。

这样的模式让代码更加容易随着需求的变更而替换、扩展、测试。

什么可以更好

总体来说,这些已经足够让你了解什么是clean architecture了。但是我得指出那些地方为了让demo简单而做了简化。

这一节不是必须的,但是会给出一个扩展,让大家了解一个没有缩水的clean architecture是什么样子的。

我会着重说明还有哪些事情可以做:

使用对象而不是数字来表示价格

你应该注意到了,我使用数字表示了价格。这不是一个好方法:

// shared-kernel.d.ts

type PriceCents = number;

一个数字只表明了数量而没有表明货币种类,一个没有货币的价格是没有意义的。理想状况下是价格有两个字段表示:值和货币。

type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number;

type Price = {
  value: AmountCents;
  currency: Currency;
};

这样就可以省去大量的存储和处理货币的精力了。在实例中没有这么做是为了让这个例子尽量简单。在真实的情况里,价格的结构会更加接近上面的写法。

另外,价格的单位也很重要。比如美元的最小单位是分。这样显示价格就可以避免计算小数点后面的数字,也就可以避免浮点数计算了。

使用功能点分割代码,而不是按照层

代码建在那个目录下是按照功能点分割的,而不是按照层分割。一个功能点就是下面饼图的一部分。

下图的这个结构更加清晰。你可以分别部署不同的功能点,这在开发中很有用。

image.png

图片来自这里

同时强烈建议读一下上图所在的文章。

同时建议阅读功能拆分,概念上和组件代码拆分很相似,但是更容易理解。

注意跨组件代码

在我们讨论系统拆分的时候,就不得不说道跨组件代码使用的问题。我们再来看看新建订单的代码:

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),
  };
}

这个方法是用了从别的代码Product模块引入的totalPrice。这样使用本身没有什么问题,但是如果我们要把代码划分到独立的功能的时候,我们不能直接访问其他功能的代码。

你也可以在这里这里这找到方法。

使用类型标签,而不是类型别名

在核心代码里我用了类型别名。这样很容易操作,但是缺点也很明显,TypeScript没办法更多的发挥它的强项。

这似乎不是个问题,即使用了string而不是DateTimeString类型也不会怎么样,代码还是可以编译成功。

问题是约束松的类型是可以编译的(另一个说法是前置条件削弱)。首先这样会让代码变得脆弱,因为这样你可以用任意的字符串,显然会导致错误。

有个办法可以让TypeScript理解,我们想要一个特定的类型 -- 使用类型标签。这些标签会让类型更加安全,但是也增加了代码复杂度。

注意域里的可能的依赖

下一个要注意的在新建订单的时候会新建一个日期:

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()会在项目里重复很多次,我们最好把它放进一个helper里。

// 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);
}

这让域更加独立,更容易测试。

在这个例子里因为两点原因我不会主要关注这一点:偏离主线。而且只是依赖自己的helper也没什么问题。尤其这个helper只是用了语言特性。这样的helper甚至可以作为共享的核心(kernel),还能减少重复的代码。

注意购物车和订单的关系

在这个例子里,订单包含了购物车,因为购物车只是代表了一列产品:

export type Cart = {
  products: Product[];
};

export type Order = {
  user: UniqueId;
  cart: Cart;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};

如果购物车有其他的和订单没有关联的属性,恐怕会出问题。比如最好使用数据映射或者中间DTO

作为一个选项,我们可以用ProductList实体。

type ProductList = Product[];

type Cart = {
  products: ProductList;
};

type Order = {
  user: UniqueId;
  products: ProductList;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};

让用例更容易测试

用例有很多可以讨论的。现在orderProducts方法脱离开React之后很难测试,这就很不好了。理想状态下,它应该可以在最小代价下测试。

问题是现在用了hook实现了用例。

// 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 };
}

在经典的实现中,用例方法可以放在hook的外面,其他的服务可以做为参数或者使用DI传入用例:

type Dependencies = {
  notifier?: NotificationService;
  payment?: PaymentService;
  orderStorage?: OrderStorageService;
};

async function orderProducts(
  user: User,
  cart: Cart,
  dependencies: Dependencies = defaultDependencies
) {
  const { notifier, payment, orderStorage } = dependencies;

  // ...
}

hook可以作为适配器。

function useOrderProducts() {
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();

  return (user: User, cart: Cart) =>
    orderProducts(user, cart, {
      notifier,
      payment,
      orderStorage,
    });
}

这之后hook的代码就可以当做一个适配器,只有用例还留在应用层。orderProdeucts方法很容易就可以被测试了。

配置自动依赖注入

在应用层我们都是手动注入依赖的:

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和hooks的情况下,他们可以当作返回某个接口实现的容器。是的,这样是手动实现,但是这样不会增加入门的门槛,也让新加入的开发者更容易理解代码。

哪些在实际开发中更复杂

本文使用的代码专门提炼简化过了。事实上实际的开发要复杂很多。所以我也是想讨论一下在实际使用clean architecture的时候有哪些问题会变得棘手。

分支业务逻辑

最重要的问题是我们对主题域所知不多。假设店铺里有一个产品,一个打折的产品,一个注销的产品。我们怎么能准确的描述这些实体呢?

需要一个可以被扩展的实体基类么?这个实体应该如何被扩展呢?要不要额外的字段?这些实体需要保持互斥关系么?用例要如何处理更加复杂的实体呢?

业务有太多的问题,有太多的答案。因为开发者和相关人都不知道系统运行的每个细节。如果只有假设,你会发现你已经掉入分析无力的陷阱。

每种情况都有特定的解决方法,我只能推荐几种概略的方法。

不要使用继承,即使它有时候被叫做"扩展"。即使是看起来像接口,其实是继承。

复制粘贴的代码服用并非完全不可以。建两个一样的实体,然后观察他们。有时候他们的行为会很有达的不同,有时候也只有一两个字段的区别。合并两个非常相近的实体比写一大堆的检查、校验好很多。

如果你一定要扩展什么的话。。

记住协变、逆变和不变,这样你就不会遇到工作量突然增加的事情了。

使用类似于BEM概念来选择不同的实体和扩展。使用BEM的上下文来思考,让我受益很大。

相互依赖的用例

第二个问题是用例相关的。当一个用例需要另外的一个用例来出发的时候会引发的问题。

我唯一知道,也是对我帮助很大的一个方法就是把用例切分成更小的,更加原子的用例。这样他们更加容易组合在一起。

一般来说,出现这个问题是另外一个大问题的结果。这就是实体组合。

已经有很多人写过这个专题的文章了。比如这里有一整章关于这个话题的方法论。但是这里我们就不深入了,这个话题足够写一篇长文的。

结尾

在本文里,我们介绍了前段的clean architecture。

这不是一个黄金准则,更是一个在很多的项目、图标和语言上积累的经验。我发现一个非常方便的方法可以帮助你解耦你的代码。让层、模块和服务尽量独立。不仅在发布、部署上也变得独立,更是让你从一个项目到另一个项目到时候也更加容易。

我们没有多讲OOP,因为OOP和clean architecture是正交到。是的,架构讨论的是实体的组合,它不会强制开发者用类或者是方法作为单位。

至于OOP,我写了一篇如何在clean architecture中使用OOP

阅读 861

full stack dev stills
back-end: Nodejs front-end: iOS, Android, Js

时不我待

903 声望
1.9k 粉丝
0 条评论

时不我待

903 声望
1.9k 粉丝
文章目录
宣传栏