从 2020 年开始思考一个问题:如何让代码更好维护?

当时想到的是顶点控制 + 模块化。所以采取的方案是:所有状态逻辑都由 Vuex 控制,Vue 只有 UI 逻辑与 UI 本身,只需要关心 Vuex 的模块在何时加卸载即可。而这个思路在当时有一个很好的选择:dva.js

从 2021 年开始因为产品经理能力滂臭,以致更新问题:如何尽量让一套业务代码到处可跑。因为这个时候的产品只有三个关键词:短时间、业务趋同、不同的产品UX。采取的方案是:在将 UI 和逻辑分开基础上,对代码分层。逻辑代码都是无依赖 TypeScript,利用 React Hooks 封装调用,依赖倒置

这个做法确实保持了一定时间的血压下降,这也给滂臭产品一个得寸进尺的机会 —— 1.5 人月干了四个项目(真是TMD)。好在时间几乎都花在 UI,程序都用 React,小程序也用 Taro3,所以似乎问题不大

但是 2022 年又把血压抬到原点,这下不得不重新思考这个问题

相关阅读:

血压来源?

问题可以分成两部分:程序设计、产品设计

关于程序设计,在之前做完代码分层之后意识到一个新问题:逻辑跟业务的关系是 1:1,如果业务载体(换平台)或者来源发生变更(换接口),修改成本并没有降低。随着业务增加,最终代码还是得「翻出来糊」

其次产品设计,不知道哪个 B 非得装 B 一个「模组化随时可拔插应用」设计,而这个模组化又不是众所周知的那个意思,即任何可见功能组都应该可以由一个自由下发的配置文件动态决定是否加载

这里不讨论这个产品设计是否存在价值,程序设计应该再进一步分层,而且随着当时的发展趋势,业务逻辑应该进一步不被任何一个主流前端框架绑定

phodal 在《Clean Frontend Architecture:整洁前端架构》这篇文章提到:

在我试图多次去重构代码时,我发现这并不是一件容易的事:太多的交互。导致了 UI 层的代码,很难被抽取出去。

这直接报了大多数应用的身份证号

为了应对这些框架带来的问题,我们选择 Web Components 技术,又或者是微前端技术,从架构上切开我们的业务。但是它们并不是银弹,它们反而是一个累赘,限定了高版本的浏览器,制定了更多的规范。与此同时,不论是微前端技术还是 Web Components,它们都没有解决一个问题:框架绑定应用。

这是很多大公司都会面临的问题

所以,清洁架构和适配器模式,永不过时

相关阅读:

清洁架构

早在之前的思考中就有提到:前端应用程序也是应用程序,应用复杂度只要上来了该用什么还是得用什么。说到底,一个应用程序提供的是 json 还是 ui,本质上都一样

所以,第一个,清洁架构

The Clean Architecture

本质上也是分层,只是这个分层以一组同心圆表示:最里面是精确到「实体」的代码,用于描述「最根本的某个业务的最小单位或者逻辑」,外面一层「使用用例」用于描述「这个应用程序是做什么的,属于应用层面的单位或逻辑」,再外一层基本都可以归为「如何表现」,最外一层就是「这个应用程序具体会怎样表现」

从这个图再结合现实世界,可以发现两个明显的特点:

  1. 实际开发一个软件,越外面的东西越经常变(不管是因为什么导致在变)
  2. 业务逻辑似乎根本不关心 「到底在使用什么技术架构完成」

有一点可以肯定:业务逻辑就应该以最纯粹最没有依赖的方式实现。可能这个时候你会问:现在我就需要 xx 能力作为依赖,我难道不应该先有一个实现了 xx 能力的类我才可以调用吗?虽然是没有这个所谓的 xx 类,但是可以通过接口(interface)啊

例如我有一个发布文章的功能,我希望是「具有登录状态才可以发布」的规则,那么这个时候我完全可以这么做

interface IAccountManager {
  isLogin(): boolean;
  login(): Account;
  logout(): boolean;
}

class PostManager {
  constructor(private accountManager: IAccountManager) {}
}

这样实际上也可以完成「文章管理」的需求,对于这个需求来说,「账号管理」怎么实现我不管,到底是不是也是 TypeScript 实现的我也不管,我只要能调用到就可

六边形架构

六边形架构由 Alistair Cockburn 提出,也称为端口与适配器架构。看起来像是清洁架构的具体版,这都是「依赖倒置」演化之后的结果

Hexagonal Architecture

它相当于把外围的部分分成了端口与适配器,端口就是接口(interface),而适配器就是对某个功能或者对接其他六边形某个端口进行具体实现

举一个具体的例子:端口即上面所说的「我只要能调用就行,谁实现不关心」,而适配器就是需要在合适的位置(例如应用层)实现 IAccountManager

所以六边形架构的特点更明显:极大可能的解耦与核心业务无关的逻辑。「应用逻辑」成为一个「程序架构」的核心,有非常锐利的边界,使得整个应用也呈现一种「这个应用正是用于解决这类问题」。这个问题可以在 Web 解决,可以在 Mobile 解决,可以在 PC 解决,可以在 Embeded 解决,可以在 WebAssembly 解决……

六边形也带来更多的可测试性,因为测试中,只要涉及到外部依赖(例如数据存储)或者 UI,我们就会非常厌烦测试,因为这也很难测试。而现在测试,无非在调用 ILocalStorage 时是使用 MockTestStorage 还是 FirebaseStorage

当出现了以上场景时,「依赖注入」同时也应运被抬上台面。当需要「在什么时候选择什么依赖」这种问题上,应该没有人不会选择「依赖注入」

所以六边形的特点可以总结了:

  1. 可以 TDD
  2. 可以面向接口(面向协议)
  3. 强调依赖注入
  4. 内外分离,实现分离,聚焦分离

领域驱动设计(DDD)

领域驱动设计(Domain-Driven Design)应该分成两个部分讨论:业务方向与技术方向

我主观理解,领域驱动设计更多是一种产品设计,它应该更多与产品经理相关(领域专家是一种熟悉技术架构的产品经理,类似技术美术(TA))。对于技术方向,DDD 几乎跟六边形设计是绑定的

所以,当讨论 DDD 时,技术实现实际上显得没有那么重要,因为这是一门「如何将用户需求转成通用语言同时满足领域隔离与自洽」 的艺术

需要所有合作方都能理解「我们正在做什么」,显然需要统一语言。不然产品跟你「我觉得用户要 xx / 我们产品组一致认为需要 xx」,设计跟你「以我们设计专业角度觉得这里应该这样」,再来个开发「实现不了,我做不到」,Ok

怎样的语言「更通用」,显然是画图或是约定好的术语表

在实践中,我们同样与设计讨论出适合协作的「术语表」,这个可以放在《利用Tailwind CSS 建立设计领域语言》时狠狠展开

另一个,不同的职业在看待同一个问题,角度都是不同的。所以在定义领域模型时,不要以任何一个角度为主要角度去定义,例如最常见的问题就是以「用户会如何使用应用程序」的角度设计应用,这显然是片面的(属于产品经理的)角度

Eric Evans(DDD之父)举过一个货物运输系统例子:

  1. 一个「货物」涉及多个「客户」(托运人、收货人、付款人),每个「客户」承担不同的角色
  2. 「货物」运送「目标」已指定(货物只有一个目标)
  3. 由一系列满足「规格」的「运输行为」完成运送「目标」

这样一个需求的关系就明确了:一个引号内容至少会有一个类用来描述

DDD 的概念显然很多,具体的内容真的需要两本书(《领域驱动设计》《实现领域驱动设计》)拼凑起来才可以非常完整的理解

在前端应用

回到最开始的问题:业务需要所谓的整个应用模组化,如果非要生搬硬套概念,这类似微服务。而微服务现在最常见而且最干净的实践即是 DDD

将整个 DDD 的要求在前端应用实践其实没有什么问题,但是现阶段现阶段的「前端工程师」可能并不能理解,所以在真实应用时会「本地化」

应用组织

我会把应用拆分成交互程序与逻辑程序。交互程序即 React 应用,逻辑程序则不依赖任何一个明显所属某个前端框架的代码

我的主观理解:React 应用是一个表现层应用,它只关心 UI 表现和聚合核心逻辑

React 为主的程序,会分为 UI 和应用服务,即每个业务级别组件依然会拆分成两个文件。类似于这样的结构

- common
  - components
  - infrastructure
  - adaptor(implements)
- app
  - article
    - cover.component
    - article.service
    - article.view
- pages
  - article
    - index
    - [id]
...

而核心逻辑程序则按照 DDD 的思维设计,按照 feature 的角度或者 use case 的角度进行拆分。这里的代码不会有明确的某前端框架依赖,即如果想直接 cli 运行也应该是允许的。目前的实践是这样设计目录:

- domains # 实际上这里是边界(Bounded Context)
  - article
    - models
    - repositories
    - services

思路

因为前端应用整体上说真没有那么大的复杂度,所以 models 会将该域相关的聚合根、实体都放到一起,而 TypeScript 的特性让我直接放弃了值对象(VO),为了降低理解 VO 用 interface 代表即可

repository只有一个工作:把野结构转成实体模型。但对于前端应用来说,通俗理解下 repository 的范围其实很小,最常见的即是后端接口(restful),除此还有 localStorage。至于 IndexedDB,我更期待认识它的人能更多一些

由于「充血模型」,某个实体的操作和逻辑都已经包含在各自实体中,所以领域服务一般来说都是无状态服务,它只负责协调不同实体或者聚合根之间的关系

因为最终的驱动还是由应用决定,而我认为应用服务可以自由组合领域服务。但为了领域边界锐利,领域服务之间不应该互相调用

大多数情况下也不需要互相调用,例如有一个场景:

作为阅读者
我希望能在文章旁边或者下方显示评论
这样我可以阅读文章同时看到相关的评论

按照设计,我会将它们设计成文章域和评论域,他们虽说广义上都属于文章一部分,但是文章并不依赖评论存活,评论不依赖文章存活(评论区随时可以干掉)。所以在核心逻辑上我就这么做了 ArticleComment

但现实世界中评论并不会单独出现,它必定会依赖一个 ArticleId 用于检索。这个时候需要的并不是让 Article 自己去获取 Comment,而是由应用服务组合

对于管理文章的应用服务,它可以随意调用所需要的领域的领域服务,所以我可以这么做:调用文章领域服务 -> 获取某一篇文章 -> 将该文章的 id 调用评论领域服务 -> 获取评论 -> 组合结构

这样,例如 ArticleAppService 内部注入 ArticleServiceCommentService 即可完成工作,而这两个域之间又可以保持干净和锐利

主动动作是可以做到这样互不干涉。但是如果是被动动作呢?例如当文章出现修改,当修改完成时,需要重置点赞数,对当前已有评论添加「过时评论」等等工作(这个例子有点扯淡)。

首先点赞数,如果只是一个值对象这么放着,那么我觉得放在文章域就可以了,如果还有其他逻辑,甚至是点赞溯源(或者给点赞做 Event Source),这样的需求肯定是独立实现。虽然这个场景我依然可以这么做:应用服务发出修改调用 -> 等待调用结束 -> 获取新数据 -> 调用点赞的管理(push清空事件) -> 等待完成 -> 调用评论关于添加「过时评论」方法…

Ok 这样确实可以。现在再来一个例子:有一个领域依赖一个repository,这个repository 具体实现时自己开了一个 websocket,当它自己更新时它希望同时通知其他领域「我更新了」

再回到上一个例子,实际上这个链路也是唯一的,「修改文章」就是有这么一条链路。所以有什么办法在不互相调用的情况下又能让所有领域都灵活的运行起来?这个时候就需要「领域事件」了

拥有「领域事件」之后,很多场景都会变得方便许多。例如创建一个评论,评论可以发出 CommentCreated 事件,这时「通知域」收到这个通知,就可以立刻调用邮件发送通知到文章作者。同时由于「领域事件」的存在,关于埋点(数据跟踪)这种本就跟软件逻辑毫无关系的但又会污染代码流的东西也可以外挂,并且让「是否发送匿名数据以协助提高用户体验」这种 90% 都会被关闭的开关也变得容易实现许多

相关 (打脸) 文章:

联邦模块

这是一个由 Webpack5 提出来的概念:Module Federation,Vite 社区后来也根据这个思路开发了同类插件

相关阅读:

Webpack 预想的场景是「分解大型应用,使应用可以分开构建」

Multiple separate builds should form a single application.

大概的方式:有一个宿主应用(Host)决定需要加载哪些远程(Remote)应用,每一个远程模块都用一个 runtime 文件作为入口,这个 runtime 文件依然像印象中的 webpack 差不多:会编排跟这个远程模块相关的 chunk

我发现中文圈子看到这个特性之后第一反应是「这是一种新的微前端」。这里先不展开谈微前端,一言以蔽之:我认为的微前端场景应该还是「legacy 应用稳定且不好维护但整个产品需要继续迭代」的场景,不同技术栈组合起来的超大应用

联邦显然是分治思想,这不就刚好很适合 DDD 应用,这样下来微应用设计思路也可以更接近微服务思路了

或许我们可以通过类似健康检查的机制检查模块是否可被调用和使用,例如还是博客应用,文章域和评论域都是 remote,这时文章域加载了而评论域没加载,通过边界检查就可以得知是否直接抛弃评论区显示,一切显得自然又无缝

所以结论:或许大部分 web 应用都不需要 DDD,因为应用业务场景还是很简单。但并不是 web 都很简单,据说字节某个应用都已经上千个包依赖了

程序设计服务于所有程序,不管是运行在哪里的程序