架构是为了解决什么问题呢?
我理解,架构设计是为了让庞大的系统拆分,开发人员各司其职负责好其中的一小块,然后再将这些小块组成大块;还有一个目标就是,架构设计是在满足当下业务需求的前提下保证系统的可扩展性,需求是不断变化的,需要设计出一个易于修改和满足新的需求的系统。
但是前端谈架构真的有点尴尬,就是感觉以前端的视野,很难拥有一个完整的视角去观看业务。就比如说 MVC,前端就是 MVC 中的 V,核心业务逻辑跟数据都在服务端。
但是随着互联网的发展,前端项目也越来越具备复杂性。我认为理解一些经典的架构思想,是有自身发展的。
有幸我了解到了一系列超好的文章:
本篇文章就是我阅读了这些文章之后,且按照其中的建议做的实践。
最终 demo 上传到了我的 github: github.com/Chechengyi/…
Domain/领域/实体
这一层代表的就是业务规则,比如订单、用户这些都是业务场景中真实存在的实体,Domain 可以看做需要对真实业务场景进行建模。
Domain 是最业务中最里层、也是最核心的东西,是区分不同业务的点。
Application/应用/用例
用例即用户场景,如:新建一个页面是一个用例,往页面中插入一个节点也是用例。
用例负责编排进出实体的数据流。
用例的改变和实现都不会影响实体。
port/端口
端口就是 Application 与外界沟通的通用接口。
这个接口可以让任意消费者(如用户、其他程序等)通过统一的方式与应用程序交互,而不必知道具体实现细节。我们只需定义好接口,具体的实现可以在需要的时候动态注入。这样做的好处是可以使代码更灵活,便于替换不同的实现而不影响整体结构。
Adaptters/适配器
适配器类的主要作用是将一种接口转换为另一种接口。它实现了接口A并接收一个实现了接口B的对象作为依赖。当适配器被实例化时,它的构造函数会接收一个实现了接口B的对象。然后,这个适配器会被注入到任何需要接口A的地方,并将接收到的方法请求转换并代理给内部实现接口B的对象。
主驱动适配器
职责:负责启动应用程序中的某些操作,例如:UI 界面其实就是一种主驱动适配器,用户点击按钮或者提交表单时就会触发应用程序中的用例。
依赖关系:主驱动器依赖于端口(Application 中的用例可以看做是端口的具体实现),并且注入端口的具体实现,端口以及其具体实现都在应用程序内部(即在 Applacation 层)
被驱动适配器
职责:通常负责对主驱动器的操作做出反应,例如:存储数据或者调用外部 api
依赖关系:被驱动器是接口的具体实现,并且被注入应用程序当中(被注入到 Application 层),业务逻辑只知道接口,而具体实现则在外部,在运行时被注入
这里的端口和适配器的概念,来自六边形架构中。
从上图的例子可以看到,对于像 UI 界面这种调用 Application 层的操作,Application 层负责实现接口。对于 Application 层需要调用其他三方服务,适配器就是一个实现了调用三方服务的接口,然后注入 Application 层
依赖规则
约定源代码依赖规则只能从外部依赖内部,内圈中的任何事物都无法了解外圈的事物,特别是,在内圈中的代码中不得提及外圈中声明的内容的名称。这包括函数、类。变量,或任何其他命名的软件实体。
换句话说就是外圈中的任何东西都无法影响内圈
动手制作一个demo,去应用这些方法论
接下来我会尝试使用这些方法论去开发一个应用程序。
先定义一下这个应用程序的动能:
实现一个最简单的增值服务的功能,不包含后端的服务。前端拉取到“商品列表”和“支付方式”,这里增加一个购物车的功能,前端可选中多个商品,下单,然后由“三方服务”生成订单最后存储本地。
1 领域建模
首先是要对领域进行建模。
我对这一块的知识非常匮乏,在这里我无法列举出 1 2 3 4 来总结应该如何进行建模,只能依葫芦画瓢,反正对于这一点我首先联想到了数据库表的设计。在这里我直接推荐大家去读原文的文章:Part 1. Domain Modelling 看看作者领域建模的思路是如何的。
首先是商品,我们的系统很简单,只需要展示出商品的名称和价格就可以了:
// goods.ts
export type Goods = {
id: UniqueId; // string
name: string;
price: PriceCent;
}
export function totalPrice(goods: Goods[]): Number {
return goods.reduce((total, item) => total + item.price, 0)
}
第二是购物车,购物车也很简单,其实就是选择了多个商品
// cart.ts
import { Goods } from './goods'
export type Cart = {
goodsList: Goods[]
}
// 最好使用纯函数,既不要去更改 cart,而是返回一个新的 cart
export function addGoods(cart: Cart, goods: Goods): Cart {
return {
...cart,
goodsList: [...cart.goodsList, goods]
}
}
export function deleteGoods(cart: Cart, goods: Goods): Cart {
const goodsList = cart.goodsList.filter((item) => item.id !== goods.id)
return {
...cart,
goodsList,
}
}
第三是支付方式,支付方式中,需要展示出支付方式的名称和图标
// payMethod.ts
export type PayMethod = {
id: UniqueId;
name: string;
icon: string;
}
最后是订单:
// order.ts
import { Cart } from './cart'
export type Order = {
orderId: UniqueId;
cart: Cart;
total: PriceCent;
created: DateTimeString;
}
可以看到我们的文件中不只暴露出了类型,还有一些方法。这些方法称之为“数据转换”,用来承接业务中实际的操作。比如购物车,客户会向购物车里添加一个商品或者删除一个商品,我们提供了对应的 api addGoods 和 deleteGoods。
TDD / 测试驱动开发
TDD 的思想是我们首先为实现的功能去编写测试,然后才编写功能本身。
这里我们只以 cart.ts 来举例子,假设下之前贴出来的代码中 addGoods等方法还没实现。
现在先为 cart.ts 写下测试用例(使用的 jest,jest 具体如何使用不展开多说了,如果需要可以到我另外一篇文章看:juejin.cn/post/724208…)
// __test__/card.test.ts
describe('add a goods to cart', () => {
it.todo('add a goods')
})
按照 TDD 的思想,根据业务先写下测试用例,测试用例写好了之后,明确了要实现什么功能,在列举出要实现的功能之后,我们或许还会发现当前的模型设计上的不足。
写好测试用例之后,在去 cart.ts 文件中定义好方法。
然后再继续修改测试用例,比如 addGoods方法,我希望这个方法接收 cart 参数和 goods 参数,将 goods 插入到 cart 之后,然后再返回一个 cart。
import { addGoods, Cart } from '../cart'
const goods1 = {
id: '1',
name: 'Goods 1',
price: 1,
}
describe('add a goods to cart', () => {
it('add a goods', () => {
const cart: Cart = {
goodsList: []
}
const newCart = addGoods(cart, goods1)
expect(newCart).toEqual({
goodsList: [goods1]
})
})
})
写好测试用例之后,执行,此时肯定会报错。因为 addGoods方法还没真正实现,观察一下报的错是不是预期的,
这里返回的值是 undefined 是符合我预期的,如果遇到了其他不符合预期的错误,则说明存在其他问题。
然后再去实现 addGoods方法,通过第一个测试用例之后,就可以考虑增加方法对于的更多测试,如对于边界情况的处理,举个例子:处理传入的 goods 是 null 的情况
it('goods is null', () => {
const cart: Cart = {
goodsList: []
}
const newCart = addGoods(cart, null as unknown as Goods)
expect(newCart).toEqual({
goodsList: []
})
})
然后再修改方法的实现:
export function addGoods(cart: Cart, goods: Goods): Cart {
if (!goods) return cart
return {
...cart,
goodsList: [...cart.goodsList, goods]
}
}
如只想关注领域建模这部分的代码 demo,可切到分支: feat/01-domain-desion
2 用例设计
经过上一步,我们完成了领域的建模,也就是说完成了 domain 层的开发。
现在开始 Application 层的开发,也就是设计用例和实现用例。
之前提到过,用例既实际的用户场景会发生的事。我们目前在开发的应用是一个及简单的商城,其中包含以下几个用例:
- 获取到商品列表,展示到页面上
- 获取支付方式列表展示到页面上
- 向购物车添加商品
- 生成订单且付款
编写应用层接口
用“获取商品列表,展示到页面上” 这条用例来分析一下:
获取商品列表的行为是从 UI 层发起的,它会调用 Application 层的接口(接口的实现就是用例)。
从哪里获取商品列表呢?从云端服务。
获取到商品列表之后,需要吧数据存下来吧?也需要存到一个服务当中
这里就用到了六边形架构(端口-适配器架构) 中的理念:
这样设计有什么好处呢?那就是在应用层不需要去感知具体提供商品服务的服务方,应用层只是依赖了一个接口,这个接口背后的服务可以随便替换。
定义给左侧使用的接口:
// application/ports.input.ts
import { Goods } from '../domain/goods'
export type GetGoodsList = () => Promise<void>
然后是右侧。对于应用层来说,具体存储在什么地方它是不关心的,仍然是通过注入适配器的形式去保存。
// application/ports.output.ts
import { Goods } from '../domain/goods'
export type GetGoodsList = () => Goods[]
export type SaveGoods = (goods: Goods) => void
实现Application层的用例
还是 TDD 的思想,编写一个功能之前,首先去编写测试用例。
// getGoodsList.test.ts
describe('getGoodsList When Called', () => {
it.todo('The API for getting and saving products should be called')
})
接下来编写获取商品列表这个用例。
import { GetGoodsList } from '../ports.input'
import { FetchGoods, SaveGoods } from '../ports.output'
type Dependencies = {
fetchGoods: FetchGoods,
saveGoods: SaveGoods,
}
export const createGetGoodsList = ({
fetchGoods,
saveGoods
}: Dependencies): GetGoodsList => async () => {
const goods = await fetchGoods()
saveGoods(goods)
}
这个用例是接口 GetGoodsList的实现,它是依赖 fetchGoods和 saveGoods接口的,这里我们采取“依赖注入”的形式。在函数式开发中,我使用了以上高阶函数的特性去实现依赖注入。
依赖注入是一种有效的避免代码耦合的方式。在这里避免了 getGoodsList 模块直接去依赖 fetchGoods 和 saveGoods 接口。
完善测试用例
import { createGetGoodsList } from './getGoodsList'
import { FetchGoods, SaveGoods} from '../ports.output'
describe('getGoodsList When Called', () => {
it('The API for getting and saving products should be called', async () => {
const goods: Awaited<Parameters<FetchGoods>> = []
const fetchGoods: FetchGoods = jest.fn(() => Promise.resolve(goods))
const saveGoods: SaveGoods = jest.fn()
const getGoodsList = createGetGoodsList({
fetchGoods,
saveGoods,
})
await getGoodsList()
expect(fetchGoods).toBeCalledTimes(1)
expect(saveGoods).toBeCalledWith(goods)
})
})
使用依赖注入使代码避免耦合,也让单测变的很容易。我们只需要简单的去 mock fetchGoods 方法和 saveGoods 方法就好了。
如以上单测代码而言,我们测试的重点在于,fetchGoods 函数和 saveGoods 函数是否有被正常调用。当测试用例能够正常执行通过时,代表功能也写好了。
Application 层的其他用例的具体实现就不在此多做阐述了,可在源码中查看,本节新增的代码见分支:feat/02-application-desion
3 端口/适配器配合用例,实现具体功能
经过上面一小节,完成了 application 层的开发。
本节会尝试开发出 UI界面,完整的实现功能,接下来以获取商品列表功能实现举例
第一步,实现基础设置。
刚已经提到过,获取商品列表然后再将数据保存,是依赖外部服务的。这里我们将应用程序依赖的服务称为基础设置。
首先来实现,保存商品数据这个功能。
对于我们前端来说,获取到数据然后保存,其实就是保存在当前 js 运行环境的堆内存里。而我们当前的这个项目 UI 是基于 React 去开发的,所以就要考虑到数据的变化去触发 UI 的刷新。为了方便,这里基于 zustand 去实现。
// infrastructure/store/goods/store.ts
import { createStore } from "zustand";
import { Goods } from '../../../domain/goods'
export const goodsList = createStore<Goods[]>(() => [])
// infrastructure/store/goods/store.composition.ts
import { useStore } from "zustand";
import { SelectGoodsList } from '../../../application/ports.input'
import { SaveGoods } from '../../../application/ports.output'
import { goodsList } from './store'
export const saveGoodsList: SaveGoods = goodsList.setState
export const useGoodsList: SelectGoodsList = () => useStore(goodsList)
然后是获取商品列表。
一般情况下,需要调用一个网络请求去获取商品列表,但这里我们没有云端服务,所以采用直接 Mock 数据的形式去实现。
// infrastructure/api/getGoodsList/api.ts
import { FetchGoods } from '../../../application/ports.output'
import { Goods } from '../../../domain/goods'
const goodsMockData = [
{
goodsId: '1',
goodsName: '商品1',
priceValue: 2
},
{
goodsId: '2',
goodsName: '商品2',
priceValue: 1
},
{
goodsId: '3',
goodsName: '商品3',
priceValue: 1.5
},
]
function toDomain(data: typeof goodsMockData):Goods[] {
return data.map((item) => ({
id: item.goodsId,
name: item.goodsName,
price: item.priceValue
}))
}
export const getGoodsListFetch: FetchGoods = () => {
const data = toDomain(goodsMockData)
return Promise.resolve(data)
}
第二步:注入到 Application 层的用例当中
在之间的小节中,我们提到了使用函数式编程高阶函数的特性去实现依赖注入,现在我们要去实现 Application 层运行时的依赖注入。
// application/getGoodsList/getGoodsList.composition.ts
import { getGoodsListFetch } from '../../infrastructure/api/getGoodsList'
import { saveGoodsList } from '../../infrastructure/store/goods'
import { createGetGoodsList } from './getGoodsList'
export const getGoodsList = createGetGoodsList({
fetchGoods: getGoodsListFetch,
saveGoods: saveGoodsList
})
第三步:实现 UI
接下来实现一个商品列表组件。
我们需要再这个商品列表组件里面去触发获取商品列表,然后获取商品列表数据展示出来,也就是说依赖 GetGoodsList接口和 SelectGoodsList接口
// ui/goodsList/goodsList.tsx
import React from 'react'
import { GetGoodsList, SelectGoodsList } from '../../core/application/ports.input'
type Props = {
getGoodsList: GetGoodsList
useGoodsList: SelectGoodsList
}
export function GoodsList({ getGoodsList, useGoodsList }: Props) {
const goodsList = useGoodsList()
React.useEffect(() => {
getGoodsList()
}, [])
return (
<div>
{
goodsList.map((item) => (
<div key={item.id}>
<p>{item.name}</p>
<p>¥{item.price}</p>
</div>
))
}
</div>
)
}
同样的,实现一个 goodsList.composition.tsx 文件用于注入依赖:
import { getGoodsList } from '../../core/application/getGoodsList'
import { useGoodsList } from '../../core/infrastructure/store/goods'
import { GoodsList as Component } from './goodsList'
export const GoodsList = () => Component({
getGoodsList,
useGoodsList,
})
可以看到 useGoodsList方法,我们是直接从 “基础设置(infrastructure)” 中获取的,这似乎是不合理的?
可以再次看上面讲解六边形架构时的图,会发现,其实 Application 层做的事情,就是在连接它的“左侧”和“右侧”。那其实“左侧”和“右侧”可以直接连接,也并不是什么坏事情。
其他的功能实现步骤和思路跟这个大差不差。就不在此在展开篇幅,可以直接看源码。本小节新增的代码在 feat/03-infrastructure-desion 分支可以看到。
4 替换服务,实现“真实”下单、支付订单功能
看了上节结束后的源码,就会发现,我们偷了一个小懒。既 bookOrder 用例依赖的 createOrder 接口和 payOrder 接口,我们并没有在基础设施里真实的去实现,而是 mock 了一个。
然而我会继续偷懒....
其实单独的去编写这节是为了让大家体会到,使用端口/适配器模式去解耦业务逻辑和外部服务带来的好处。在真实的业务场景中,业务逻辑依赖的外部服务是很可能会更换的,如果业务代码和外部服务耦合在一起,更换起来就会很麻烦。
感兴趣的可以尝试接着写,自己实现。我太累了,我不写了....
总结
到这里,这篇文章就结束了。
我的感觉是,一个很简单的功能,好像被写的很复杂。如果我是真实的需要去实现这个简单的应用,我一定不会这样写,过度设计也是一种灾难。
本篇文章的核心思想还是在于,通过实现一个 demo 去理解简单架构、六边形架构、TDD 等这些思想在前端项目开发中的应用。输出了这篇文章之后个人觉得还是收获很大的。
也许文章中有不成熟或者错误的地方,恳请大家指出。
比如:
是否有更好的办法在使用函数式编程的情况下实现依赖注入呢?
在实际的场景当中,这种模式的缺陷是什么?
还有最重要的一点,大家觉得这些思想是否有必要应用到前端开发中呢?
参考文章: