简洁架构、六边形架构、TDD 的思想,用于实现一个简单的前端应用程序

462 阅读14分钟

架构是为了解决什么问题呢?

我理解,架构设计是为了让庞大的系统拆分,开发人员各司其职负责好其中的一小块,然后再将这些小块组成大块;还有一个目标就是,架构设计是在满足当下业务需求的前提下保证系统的可扩展性,需求是不断变化的,需要设计出一个易于修改和满足新的需求的系统。

但是前端谈架构真的有点尴尬,就是感觉以前端的视野,很难拥有一个完整的视角去观看业务。就比如说 MVC,前端就是 MVC 中的 V,核心业务逻辑跟数据都在服务端。

但是随着互联网的发展,前端项目也越来越具备复杂性。我认为理解一些经典的架构思想,是有自身发展的。

有幸我了解到了一系列超好的文章:

dev.to/bespoyasov/…

dev.to/bespoyasov/…

本篇文章就是我阅读了这些文章之后,且按照其中的建议做的实践。

最终 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 addGoodsdeleteGoods

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的实现,它是依赖 fetchGoodssaveGoods接口的,这里我们采取“依赖注入”的形式。在函数式开发中,我使用了以上高阶函数的特性去实现依赖注入。

依赖注入是一种有效的避免代码耦合的方式。在这里避免了 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 等这些思想在前端项目开发中的应用。输出了这篇文章之后个人觉得还是收获很大的。

也许文章中有不成熟或者错误的地方,恳请大家指出。

比如:

是否有更好的办法在使用函数式编程的情况下实现依赖注入呢?

在实际的场景当中,这种模式的缺陷是什么?

还有最重要的一点,大家觉得这些思想是否有必要应用到前端开发中呢?

参考文章:

Clean Architecture on Frontend