在分层理念中,一种通用的分层思想,是将应用分为“数据层”“逻辑层”“表现层”,在每层内,我们又可以细分。你可能会想,“分层?有必要吗?”就像我们接触毒药一样,离开了剂量谈毒是没有意义的,同样的道理,离开了具体的业务复杂度谈分层,也是没有意义的。在极为简单的应用中,我们当然要追求快速高效立马上线,但在一些企业应用中,却需要我们慢条斯理,在长达数年的岁月里慢慢推进一套系统的演进。我们谈分层,大多是在这类有比较复杂的业务逻辑的系统中去谈,这类系统可能在具体界面的呈现上实现起来并不复杂,甚至没有什么交互上的难度。但是,这类系统中的前端开发者们,常常还是很抓狂,因为一个逻辑可能被折腾死,最后一定会思考,我们如何才能合理的区分哪些代码是业务的,哪些代码是交互的,应该如何组织代码才能高效的解决自己遇到的烦恼?
本文将阐述我在工作中的思考与解决的方案。相信它可以帮助那些与我曾经一样遇到此类烦恼的小伙伴。
我们前端在开发一个业务的时候,总是先从界面出发,看着界面想我这里要怎么做怎么做,等把界面交互大致写出来之后,再把产品文档里面的业务逻辑作为一些判断条件加入到写好的交互代码中,最终交付。我能这么讲出来,说明这里面有很大的问题。问题在哪里呢?我们用一段假代码来看看:
export default {
template: `
<form @submit="handleSubmit">
<input type="number" v-model="price" placeholder="单价" />
<input type="number" v-model="count" placeholder="数量" />
<input type="number" :value="total" disabled />
<span v-if="save">折扣10%</span>
<span>
<input type="text" v-model="code" @change="handleChangeCode" placeholder="优惠码" />
<button type="button">查询</button>
<span v-if="codeChecked">优惠码有效</span>
</span>
<button>提交</button>
</form>
`,
data() {
return {
price: 0,
count: 0,
code: '',
codeChecked: false,
}
},
computed: {
total() {
return this.price * this.count * (1 - this.save) * (this.codeChecked ? 0.9 : 1)
},
save() {
return this.price * this.count > 100 ? 10 : 0
},
},
methods: {
handleCheckCode() {
ajax.post('...', this.code).then(res => {
this.codeChecked = !!res
})
},
handleChangeCode() {
this.codeChecked = false
},
handleSubmit(e) {
e.preventDefault()
// ....
// 一大堆校验逻辑
const { price, count, code, codeChecked } = this
const data = { price, count }
if (codeChecked) {
data.code = code
}
// 提交数据
// 。。。
},
},}
你看,也就简简单单几个字段,就让代码开始有点点混乱了,要搞清楚每一个字段与其他字段之间的关联,你需要通读整个组件的代码,而随着业务的越来越积累,这个看似简单的组件,会慢慢撑开,字段从这几个慢慢撑到10多个,甚至20、30多个,字段与字段之间的关联性,以及每一个字段和它的提示语在什么情况下才展示出来,等等,越来越复杂。当这个业务持续增长超过1年后,你发现这个组件已经满目全非,根本不敢改一行代码,因为你怕一改就影响整个业务。
为什么呢?是什么东西,冥冥中让我们的代码走向不可维护呢?
我认为,一个重要原因在于:我们的代码同时承载了业务的逻辑和界面的交互逻辑。比如上面的codeChecked对于整个业务而言,是非必需的,但是对于交互而言是必须的,你必须用一个状态去控制提示语是不是要展示出来。因此,上面这段代码中,用于完成业务目标的price, count, code,和用于完成交互任务的codeChecked被放在一起管理。而且更糟糕的是,其中在handleSubmit中,用于交互的codeChecked却成为了控制code字段是否提交的开关,这直接让业务逻辑和交互逻辑耦合在一起,在未来的开发中,你不可能把这两部分解耦开,因为这个逻辑写死了。
正因为这种线性的开发思维,让我们写的组件随着业务的扩展,越来越难以高效的维护,直到最后不敢修改一行。我称这种情况为“缠线定律”,即一根线在比较短的时候不会打结,到一定长度后容易打结,当很长的时候一定会打结,所以无论你的耳机线材质多有韧性,只要直接塞在裤兜里,一定会打结。那么怎么避免呢?就像避免耳机线打结一样,我们需要用一个耳机线盒把规整的线圈管理起来,有了盒子的约束和隔离,耳机线打结的几率微乎其微。同样的道理,我们需要对我们的代码重新进行管理,让原本线性的逻辑表达,按照一定的结构重新梳理,并把这些结构用合理的文件结构进行框定,从而做到不打结。
解决代码逻辑打结的第一个杀手锏是领域建模。领域建模是指,我们先抛开软件的界面、实现逻辑、运行环境等应用层面的东西,转换自己的角色,把自己当作一个业务人员,问自己我用这套系统要完成什么业务目的,梳理出业务流程,指明不同角色在业务流程中的责任,画出业务的示意图,并最终用代码把它表达出来。
我们以往的做法是直接写代码,然后去和需求方沟通,边沟通边改。但是我们经常遇到这样的情况,在一个天朗气清的工作日,我们开心的去和业务方沟通下一步业务,结果业务方突然冒出一句听上去自然而然的但却和你之前写的代码不一致的地方,这个时候,你一定会大喊一声“稍等一下,刚才那个地方……”然后是两个小时的重新确认和5个小时的重新编码!这样的场景在我有限的工作经历中,也经历了不少次。
解决这一问题的有效办法,是DDD提供的沟通方法论,在开始编码之前,建立领域模型。实际上,领域模型包含两个部分,一部分叫统一语言,说的直白些,就是图纸,在你的业务部门里任何人都能看的懂,另一部分是与图纸等效的建模代码,在未来的日子里,任何的沟通,大家只会基于图纸来明确某个细节,而不会关心你写的代码,如果你的实现与图纸不一致,那明显是你的问题,而不是图纸。
好了,接下来我们来聊一聊怎么做出个图纸等价的建模代码。
我们要清楚在这个过程中,其实主要包含3类对象,一类是描述业务的实体对象,是业务所围绕的核心概念,你的公司所做的业务,本质上就是在创建和处理这些对象。一类是描述工作流程的服务对象,它们主要是对实体对象的处理过程、逻辑、事件,是使得业务产生实际效果的非实体对象。最后一类是用于辅助完成编程任务的程序对象,用以解决在特定编程语言下面,怎么让你的业务的部分能够反馈到计算机系统中,用计算机系统的方式运行起来。我们进行领域建模,主要针对第一类和第二类。
面向对象是DDD的核心方法,我们在具体编程时,通过创建和关联各种class完成模型。贫血和充血之争一直是一个问题,我认为在前端语境下,模型一定是充血的,因为前端建模要为交互留足空间。
以前文的例子为例,我们可以建立这样的模型
clas Order {
price = 0
count = 0
code = ''
total = 0}
这种就是所谓的贫血模型,它只能告诉你有什么,但是具体的业务你需要另外封装出来,这显然不可能在前端领域成为合理的建模方式。怎么做呢?我们要对每一个字段进行业务说明,可以这样:
import { Model, meta, state, Int, Validator } from 'tyshemo'
class Order extends Model { @meta({
type: Number,
label: '单价',
required: true,
validators: [
Validator.required('单价必填'),
],
})
price = 0
@meta({
type: Int,
label: '数量',
required: true,
validators: [
Validator.required('数量必填'),
],
})
count = 0
@meta({
type: String,
label: '优惠码',
checked: false,
checking: false
watch() {
const view = this.use('code')
view.checked = false
view.checking = true
ajax.post('...', this.code).then(res => {
view.checked = !!res
}).finally(() => {
view.checking = false
})
},
drop() {
return this.use('code').checked
},
validators: [
determine(code) {
return !!code && !this.use('code').checking = false
},
validate() {
return this.use('code').checked
},
message: '优惠码无效',
],
})
code = ''
@meta({
type: Number,
label: '总额',
compute() {
const { save } = this.use('total')
const { checked } = this.use('code')
return this.price * this.count * (1 - save) * (checked ? 0.9 : 1)
},
save() {
return this.price * this.count > 100 ? 10 : 0
},
saveMessage() {
return this.save ? '折扣10%' : ''
},
disabled: true,
drop: true, // 由后台计算,这个字段仅前端展示,不提交
})
total = 0}
我们写完上面这个模型,它是充血的,它完整的描述了对应业务实体的所有字段,以及每个字的的具体业务阐释。而且更重要的是,基于这一模型设计,我们可以从meta信息中,阅读每一个字段关于自己的全部逻辑。这种设计的思路很清晰,就是字段本身的逻辑应该放在字段的旁边,集合在一起,阅读关于字段本身的业务逻辑,只需要关注这一处代码,而不需要跨多个上下文去理解。要了解一个字段的全部逻辑,基本上可以在对应的meta中获得全部信息(必要的时候,需要阅读整个模型的相关方法,找出多个字段有关联逻辑的业务)。阅读这段代码,你不仅能理解代码本身的意思,而且还能掌握业务的知识。
你可能会想,我这些字段要怎么用。但是不要着急,到目前为止,我们只关心业务,不关心界面和交互。
领域模型帮我们描绘了有关这个业务的核心对象的各种逻辑,但是,我们的这个业务实体会面对很多场景,每一个场景下,可能存在有些特定的转化逻辑,这就需要我们在领域模型的基础上,提供对应场景的服务。简单讲,你可以把领域服务想象成领域模型实例的处理工厂,在这些处理中,我们是为了描述特定场景下的业务需求,所以,领域服务仍然是业务描述,和UI无关。
一般而言,我们在不需要的时候,就不需要领域服务。
怎么讲?在领域模型的分类中,除了实体、值,还有一类叫“聚合”的模型,大部分情况下,在聚合中我们就可以调动子模型完成各种处理,因此,如果通过聚合就可以完成不同场景的业务处理需求,我们就不需要领域服务。但是,假如实在没有办法,我们就应该考虑用领域服务完成业务描述。
以上面的例子为例,同样是订单,我们可能面临创建和编辑两种业务场景。编辑的时候,和新建稍有不同,需要从服务端接口拉取数据,并填充,而创建时则不需要。这也就意味着,相同的领域模型,具有多态性。如何解决呢,我们可在领域模型之上,提供领域服务,用以在不同场景下进行调用。此处的处理方式有两种,一种是直接对类进行扩展,编辑的时候,使用扩展的类,比如:
class OrderService {
static toEdit(Order) {
return class OrderEdit extends Order {
constructor() {
super()
ajax.get('...').then(data = this.fromJSON(data))
}
}
}}
// 使用OrderService.toEdit(Order)
另一种方式是直接在服务内对实例进行数据填塞。例如:
class OrderService {
static recoverOrder(order, order_id) {
ajax.get(`.../${id}`).then(data => order.fromJSON(data))
}}
总而言之,领域模型是相对比较普遍的业务描述,而领域服务是相对比较特殊的业务描述。
另外,一般来讲,服务需要遵循无状态的原则,状态一般会放在领域模型中。
至此为止,我们的编码还没有涉及UI或交互。这其实有悖以往的编程经验,“怎么界面都还没有开始写就已经有一大堆代码了?”是的,这是我们实现目标“把业务逻辑从交互代码中解救出来“的必经之路。我们要有一层专门去完成业务逻辑,而领域层就是做业务逻辑的。领域层是静态的,描述性质的,因此,可以承载业务知识体系。
有了核心的业务逻辑了,接下来,我们就要考虑在应用中完成界面和交互,这和后端完全不同,后端实施DDD,没有这一层,业务到DO就结束了,而前端则还要继续,完成人机交互的真实效果。所以,我在某些场合讲,前端DDD比后端在某些方面更复杂(当然,后端也很复杂,需要考虑很多数据持久化相关的架构问题)。
而且,在我们的产品文档中,经常会这样描述:
当用户点击“提交”按钮的时候,该订单被发送给检验员进行核对。
很明显,产品经理在写这句话文档时,是在描述一个业务过程。“点击提交按钮”这个动作是交互层面的,它无法由后端完成,后端只能完成这个动作之后的跟随动作,也就是“订单被发送给检验员”。那么,“点击提交按钮”才能触发“订单被发送给检验员”这个业务逻辑,你能说不是业务逻辑吗?这种事情往往有屁股坐哪里哪里就是真理的意味,后端人员不管理任何交互行为,因此,他们斩钉截铁的说“这不是业务逻辑”,其实,他们想要表单的是“这不是我们后端的业务逻辑“。这就有点变味了,产品文档中的一句话,只有一半是业务逻辑,你觉得说得通吗?所以,我在很多场景下都讲,交互有两种,一种是界面交互,一种是业务交互。在这个例子中,“点击提交按钮”就是业务交互。
作为前端开发者,需要分清楚“界面”和“交互”存在一定的区别。界面,交互,它们在某些情况下是统一体,不可分割,但是在另外一些情况下,却是独立的,或者说“业务交互”是可以独立于界面存在的。
以上面这个“点击提交按钮”为例。你知道这个“点击”动作是一个click事件,但是我想问的是,你现在知道这个按钮是以什么样的界面展示的么?是红色的按钮,还是灰色的?是方角的还是圆角的?是短的,还是长条的?是不是都不清楚?或许产品经理在写下这句话时,确实脑海中有一个界面的形状,但是在业务本身的过程中,这里是没有界面的,它是一句抽象描述,对于编码而言,就是一个抽象的表达,因此,我说这里要建立交互模型。
什么是交互模型?
就是在没有界面的情况下,对产品文档中的业务交互进行的建模。一般情况下,交互模型会引用领域模型和领域服务,同时,它还会被用到视图层中,交给视图层使用。说白了,站在视图层编程的角度讲,你可以把交互模型和我们平时讲的“状态管理器”划一个约等号,交互模型的实例向视图层提供状态属性和方法,属性用于视图层进行渲染,而方法用于事件回调。
在上面的例子中,我们创建这样的交互模型:
import { Controller } from 'nautil'class OrderEditController {
static model = Order
// 需要在视图层赋值
onError = null
recover(order_id) {
OrderService.toEdit(this.model, order_id)
}
async submit() {
const errors = this.model.validate()
if (errors.length) {
this.onError?.(errors.message)
return
}
const data = this.model.toData()
const res = await ajax.post('xxx', data) // 这个接口可能就是我们上面说的发送给检验员
return res
}}
这样,我们就创建好了一个交互模型。你看它的表达是否很清晰呢?而此时,你有没有发现,到现在为止,你还没有写任何的视图层面的代码。到目前为止,我们已经把需求文档中,有关业务的部分完全表达出来了,用领域模型和领域服务表达了业务实体及对应的处理逻辑,用交互模型表达了某些业务交互。是不是很神奇,在没有开始写界面的时候,我们就已经完成了大部分逻辑的编写。
等一等,在进入下一个部分之前,我还要在补充一点。
假如你的业务系统有PC端和APP端,其中PC端是基于react的,APP端是基于react native的,到目前为止,你有没有发现,由于我们上述代码中没有任何视图层的编码,所以,我们上述的代码全部都是可以在两端复用的,但是由于react和react native视图层编程方式不同,而且,设计稿也会不一样,PC和APP的设计稿几乎不可能一样,所以,视图层的代码,我们必须一定肯定是会有两份的(当然,还有一种多端同构的方案,你可以了解一下我写的框架 nautil https://github.com/tangshuang/nautil )。现在,业务交互逻辑都已经完成了,两端虽然需要写自己的视图层代码,但是,这些与业务相关的逻辑,却不需要再重新编写了,可以拿过来就用。你可以把两端的代码放在一个git仓库中,这样,就可以直接共用一份业务代码。
另外,前端的单元测试是很难做的,因为UI测试非常麻烦,虽然也能做,但是效率并不高。而将业务的领域模型和交互模型独立出来之后,你可以发现,虽然我做不了UI测试,但是我可以做业务逻辑的测试,这样,我可以保证我的业务逻辑是准确的,在持续的维护中,有测试用例做保障,任何人的改动所带来的破坏,都是不允许的,这就保证我们的业务层面的逻辑是OK的。
有人多次给我评论讲,前端就应该是胖UI。对于这一点我不置可否,不过在我看来,胖UI的前提是在剖离业务逻辑,纯界面交互的情况下讲胖UI才是准确的。以react为例,我们的一个react应用中有组件,有状态管理,有路由管理,这些都是应该的,但问题在于,是因为基于react的视图层处理导致我们的代码臃肿了,还是因为我们一边写界面交互一边处理业务逻辑把代码撑肥了呢?
回到我们文首的例子中,在我们有了建模成果后,我们可以写界面了:
import { Component } from 'nautil'import { Form, FormItem } from 'react-tyshemo-form'import { Toast } from '...some toast library...'class OrderForm extends Component {
constructor(props) {
this.controller = new OrderController()
this.controller.onError = Toast.error
}
onInit() {
const { id } = this.props
this.controller.recover(id)
}
async handleSubmit = (e) => {
e.preventDefault()
const res = await this.controller.submit()
const { ... } = res
// ... 做一些跳转之类的
}
render() {
return (
<Form model={this.controller.model} onSubmit={}>
<FormItem name="price" component={['input', { type: 'number' }]} />
<FormItem name="count" component={['input', { type: 'number' }]} />
<FormItem name="code" component="input" />
<FormItem name="total" render={({ value, onChange, saveMessage }) => {
return (
<span>
<input value={value} onChange={onChange} />
{saveMessage ? <span>{svaeMessage}</span> : null}
</span>
)
}} />
<button>提交</button>
</Form>
)
}}
现在,你可以发现,我们在视图层,主要是对已经写好的controller进行操作和使用,在视图层的所有代码,基本上都是和界面与界面交互相关的,而几乎没有看到任何业务的影子。
我们基于类似的思路,可以把写好的领域模型、交互模型再次用到react native,甚至跨一个框架,用到vue中去,因为它们本身和框架无关,所以你在任何框架中都可以使用它们。
然而,这里会有一个问题,不同的框架要使用这些代码,还存在一个和框架进行结合的东西,比如vue的响应式系统是基于Object.defineProperty或Proxy的,react是基于内部的fiber的,angualr是基于脏检查的,这就导致不同的框架里面,你想要使用同一套代码的话,你就需要有一个把建模代码和框架的响应式系统连接起来的东西,比如上面我用到了react-tyshemo-form,它就是一个连接工具。此外,比较优雅的工具有Mobx,你可以了解一下这个工具,利用Mobx来写controller,将非常有利于在vue或react中使用相同一个class,因为它提供了覆盖全框架的连接工具。
在前端这样去思考和实践,是和我们以往的一些习惯不符的,这需要我们慢慢体会。现在,我并不需要你立即接受这种开发思维,但是你可以先了解它,直到有一天,你突然发现,你的业务系统开始在庞大的组件网络中变得难以维护时,可以再找出这篇文章,阅读一下,获得一些思路,然后重新梳理你的代码组织。
这样的代码组织还面临一个问题,我想你也会思考到这个问题,就是:模型、控制器、视图,应该放在不同的目录中,还是放在同一个目录中?我认为这个问题还是需要根据实际的情况来看。但是,就我个人而言,更倾向于将一个模块的模型、控制器、视图放在一个模块目录中,这个模块从某些意义上,可以从这个项目拖到另外一个需要这个模块的项目中去,你只需要在顶层的应用上,组织和使用这个业务模块。但是,在一些情况下,比如你有多端同时一起开发,那么就要好好考虑,在实践中摸索,到底应该怎么组织代码目录。