领域驱动设计实践
何为前端数据治理?
在5年前,我凭借一身胆,硬生生写完一个电子商务微商城,一个订餐系统,一个会议室抢订系统,一个基于ajax的内容管理后台,那时候的前端工具莫过于jQuery和sea.js,但总体而言,这些系统的实现只要耐心和时间都够,也能写出来。但随着时间流逝,我先后进入了国际知名基金评级机构Morningstar和国内社交王国腾讯,逐渐和业务系统打上了深刻的交道。于是慢慢发现,早年自己所完成的引以为傲的作品,实际上没有什么内在的干货,仅仅是可用而已,而谈不上优雅。究其本源,在那个年代,除了前端业界没有特别丰富的工具之外,还有一个重要的原因,即我自身因为经验单薄,对设计模式也好,对业务系统的抽象能力也好,都无法让我站在更高的设计层面去看待所开发的具体业务。
“业务”这个词在我这些年的开发生涯中是一个核心词汇。我从业虽然只有5年,但因为我是硕士毕业,加上本科期间的实习、自己倒腾,我的开发经验也已超过10年光景。这些年中,前期我几乎没有关注“业务”这个词,那个阶段我主要关注“功能”,对照着交互稿或设计稿,根据需求口述实现功能为主。以功能为导向的前端开发和以业务为导向的前端开发是不同的。前者关注快速实现,快速迭代,满足用户需要,提升用户体验,多面向toC产品。而后者关注准确性,稳定性,满足客户需要,保证逻辑没有丝毫差错,多面向toB产品,甚至是内部系统。业务开发也讲究快速迭代,但是和面向C端用户的产品迭代不同,B端产品的迭代之快,恨不得早上的需求中午就要给到。但是,这种极速迭代并不会持续的均匀的散布在开发周期中,很多情况下它是随机的突发的。除了极速迭代,业务开发的另一个特点是多迭代并行,在我自己的经历中,同一时间基本上都有2个以上任务同时进行,只有较少的时间只有1个任务,这种情况下相对比较闲适,会做一些技术建设,而最多情况下,我一周内有4个迭代并行推进,并且在这一周内,发布了3个迭代的成果。
面对业务系统开发的特性,慢慢的,我总结出一些规律。其中包括,“性能并不那么重要”“准确性高于一切”“功能可以先上,后续再优化”等等一系列在其他领域开发中,令人不可思议的违背“常理”的规律。
真的违背常理吗?其实并不是,面对围绕“业务”进行开发的系统,很多在C端大众性产品中看似成熟的经验,现在或许不适用,或者并不那么急迫。例如,大众性产品强调“秒开”这种体验,虽说在业务性产品中做到这一点是很不错,但是如果实在做不到,只要保证数据准确,业务逻辑准确,慢一点点也并无大碍。再例如,某些产品会遇到流量喷发,比如双十一这种场景,但是在业务性系统中,这种场景也基本不会遇到,虽说在某些时刻确实也会给服务器带来一些压力,比如周一业务方要开会,业务团队成员着急录入数据时,会有一小段时间的流量高峰,但这种压力基本上不足以摧毁系统。只有当业务系统需要对接业务团队外部人员时,才需要提供一个能够承受特大流量的服务,但实际上,这种场景,相当于在业务系统的基础上,部署一个大众性产品服务,也就是基于B端产品的C端产品,所以,理念上已经横跨了两个领域,不能因此而否定前面对B端产品的结论。
“业务“不是一个简单的词汇
如果你长时间在开发针对大众的C端产品,你可能对“业务”这个词不是很理解。不过,我举一些例子你就能明白。DNSPod是腾讯云旗下的一款DNS服务产品,最初,它围绕DNS这个业务,完成前后端的一系列技术建设,以支持业务的稳定可靠,从而才能实现最终的商业价值,如今已经在业务层面全面铺开,你或许可以猜一猜现在DNSPod为腾讯云带来多少收入?CODING是国内一家知名的围绕研发生命周期管理为业务的产品,它需要涉及开发体验、效能、管理、安全等等方面的技术,而围绕这一业务出发,它需要不断探索和优化产品内的细节,现在,它已经被腾讯收购,成为一站式软件研发管理业务的赢家。
如果非要再直白一点区分业务型产品和功能型产品,我认为可以这么解释,前者是持续合作的企业级产品,后者是一锤子买卖的普众型产品。虽然两者都能赚钱,但是赚钱方式不同,前者是跪着喊客户爸爸,然后挣很多钱;后者是利用自己的资源、体验吸引用户使用,然后搞各种增值服务策略,想方设法让用户掏钱,而且单价不会很高。前者开张吃三年,吃完三年又三年;后者靠走量一波达到巅峰,然后结束找下一波,只有形成垄断的巨头可以在部分产品上持续盈利。2018年开始,腾讯全面拥抱B端G端客户,并且随后小马哥提出“产业互联网”概念,2019年,腾讯云收入过百亿,我也领到了刻有腾讯云百亿纪念文字的纪念手机。
业务型产品的盈利能力和C端产品孰高孰低,在产业互联网时代尚无定论,但不可否认,业务型产品的潜力已经完全暴露,入场B端产品的企业越来越多,在2019年之前,字节跳动以今日头条、抖音、懂车帝等大众消费型产品崛起,在2020年,飞书趁疫情期间远程办公和协同工作需求的增加,也快速崛起,和阿里钉钉、腾讯企业微信形成新的鼎立之势。
以上这些,是我在长时间和“业务”开发打交道过程中的体会。也是我打算写一系列文章来探讨有关前端数据治理的动因。
业务准确性和数据
虽然在前文我没有提到“数据治理”这个词,但是我反复强调“准确性”,实际上,这个词是关键,是迫使我思考前端数据治理的核心原因。但是,在开始探讨前端数据治理的内容之前,我必须将为什么要去探讨前端数据治理的现实原因讲清楚。正是因为业务开发的特性,让我们不得不更多的思考数据,特别是前端这个弱环境(动态赋值、弱类型、对象引用、自动垃圾回收等)下,怎么确保“准确性”。
我刚开始进入Morningstar进行业务系统开发的时候,仍然以功能开发的思维开始上手,结果碰了一鼻子灰。首先,不懂业务场景,会对为什么要把数据设计成这种结构产生疑惑,觉得不符合道理,既不利于前端读取,也不利于http传输,用户体验太不好了。
// 某接口吐出的数据形式
{
data: [
{ key: 'some', value: 1111 },
{ key: 'another', value: 222 },
]
}
我想象中的合理的数据格式应该是这样:
{
data: {
some: 1111,
another: 222,
}
}
多么精简干净舒服且爽。
但是实际上,在业务系统中,一个数据集合除了要知道数据的值以外,还需要知道其他元数据。我再仔细去看接口吐出的其他信息,其实可能还会包含如下信息:
{
data: [
{
key: 'some',
value: 1111,
data_type: 'int', // 数据本身的类型
display_formatter: '2f', // 数据在当前接口用来展示的类型,表示展示的时候要体现2位小数
}
]
}
业务场景的不同,会使得数据的使用不同。如果我们以固有的思维去质疑具体的业务逻辑,就会发现自己年轻莽撞且自以为是。
对于业务开发而言,比实现功能更重要的,是描述业务本身,而且必须是准确描述。所以很多业务系统都不约而同的选择用Java或C#来开发,除了.Net平台限制外,重要的原因在于通过语言特定的面向对象编程特性和比较强的类型系统,来确保对业务描述的准确。在开始功能开发之前,我们需要建立一套准确的领域模型,将属性、操作、事件抽象为独立统一体,这样才能保证程序员首先对业务有一定的理解和认知,然后才是业务流程、功能、交互的开发。
既然提到交互,那么我们来看一个具体的例子。
在涉及金融相关的系统中,有一个切换币种的交互。用户点击某个切换图标,会弹出一个模态框,用以让用户选择将要切换到哪一种币种,用户点击对应的币种后,模态框关闭。然而,实际上,这里交互虽然结束了,但是业务逻辑并未就此打住。一旦币种切换,那么意味着业务对象的其他字段信息需要全部重新按新币种计算。假如用户账户上有500万USD,切换为CNY之后,账户上的钱如果需要换算过来,那么需要通过汇率计算得到新的数额。汇率是取实时的汇率呢?还是通过定时任务拉取到自己的库中暂存呢?另外,由于浮点数计算问题,会导致先从USD切换到CNY,在切换回来,反复几次,会不会出现原来的500万,结果变成了499.99万?钱去了哪里?所以,看上去是一个切换币种的问题,实际上,它背后是一整套金融换算和数据管理的问题。
前端数据治理
我们做前端开发,虽然可能不会涉及到上述的后端数据管理问题,但是在前端,仍然面临复杂的业务数据管理问题。由于前端的数据不会自动产生,而是需要从服务器端拉取,所以,本质上,前端的数据全部是运行时的,虽然前端也可以采用一些持久化技术实现数据存储,例如我之前全面介绍过的IndexedDB,但是总体而言,前端仍然是在动态地使用内存即时的消费来自其他来源的数据,就像RAM和ROM,前端数据对应的就是RAM。
前端消费数据的方式千变万化,以我们熟悉的React为例,我们从后端拉取的数据,往往需要转化为组件或应用的state,再由React消费state,完成界面渲染和更新。也就是说,React消费的数据,已经是二手货,甚至好几手后面的,在层层传透过程中,出错在所难免,如果不建立一套确保数据准确性一致性的机制,很难让业务方放心把前端工作交给开发团队。
反例比比皆是,在手机端,用户从一个列表进入一个详情界面,进行一些更新操作,随着业务流转,新的数据被请求下来,并更新了当前这个详情界面。用户从这个详情界面返回到上一页,也就是列表界面,却发现,列表界面代表刚才修改的业务对象下方的一排小字内的数据并没有更新。这种场景几乎每个开发团队都遇到过。不同的技术架构里处理这个问题的方法不同,比如通过一个全局事件进行监听,当内部详情页发生变化时,列表页也要重新请求一次接口,以刷新页面。又或者,直接将列表页的对象和详情页的对象绑定,修改详情页对象就会同时修改列表页对象。等等,实现的方式各有各的不同。这是我们在功能开发中的惯用思维。
除了由于前端弱环境带来的语言层面的问题,由于业务需求多样性代码的问题也很复杂。同一个业务,在不同条件下的逻辑可能却不同。
比如,同一个字段,理论上表达的是同一个东西,但是在A页要展示成四舍五入成整数,而在B页要展示成永远含两位小数。对同一个业务对象的编辑表单中,X这个字段也面临复杂逻辑,当业务刚刚创建好时,你可以随意修改X字段的值,而且它是可选的,可填可不填;但是当业务经过一轮审批之后,X字段变成必填;而等到业务完成审批之后,X字段变成不可修改(其他字段可以修改)但要展示出来给人看。如果按照功能开发的思想处理这些问题,我们要写很难维护的判断逻辑来处理这些问题,稍有疏忽,就会出差错。
前端数据的即时性、流动性、多态性,特别是在业务系统中既要求准确,又要求适应多变的需求,单纯靠数据管理是无法完成的。数据治理是数据管理的高级阶段。从程度上讲,数据管理是从杂乱无章到有章可循,是方法论上的提升,我之前写过一篇《前端状态管理设计——优雅与妥协的艺术》专门探讨过前端状态管理的问题。而数据治理是从可用到有用的升华,是价值观的质变。一个业务,从原来乱七八糟,到使用状态管理器集中统一管理,调试和变化都可以顺藤摸瓜找到数据变化的顺序,这是数据管理的效果。而从这种“基本满足”的状态,上升到“有条不紊,万变不离其宗”,不管React层面怎么写,业务对象的内在关联永远保持,无论运行时状态怎么变化流动,都遵循着业务的逻辑描述,从数据的产生到消亡,都在按照某种约束运行而不会超出这个范围,同时数据质量、数据安全也因这种约束得以保障,即时出错,也有明确的告警,这就是数据治理。关于这一理念,实际上,我在我的播客节目《Robust:程序员的TALK PLACE》 中也提到过类似的理念。
“数据治理涵盖了从前端业务系统、后端业务数据库再到业务终端的数据分析,从源头到终端再回到源头,形成的一个闭环负反馈系统。“
我提出来“前端数据治理”,是希望站在前端的角度,重新去思考前端在业务开发时所面临的问题,而非纯粹去套数据治理的概念。前端数据治理是一个狭义的概念,它虽然会涉及和后端的交互,但我们不需要侵入后端,也不需要从系统整体层面去设计,我们只需要站在前端本身角度,重新审视功能开发的惯性思维,找到一种适合不同场景下的前端开发心态。
小结
本文从我的个人经历出发,慢慢展开聊到前端在业务系统开发下的特殊性,指出业务开发和功能开发的不同,并且阐述数据是业务“准确性”的核心要素,最后引出前端数据治理的概念。从本质上讲,前端数据治理更多的是有关策略、设计模式层面的问题,而非具体的编程实现问题,所以和我们原有的编程习惯并不冲突,冲突的地方在于思维方式,我们如果领悟到前端在解决业务复杂逻辑中数据保持约束的规律之后,就会发现,我们一贯的编程技巧仍然在具体问题中受用,只是在一开始,我们就会对业务系统中的数据管理采取另一种抽象的处理方式,这种方式可能性能稍差,却是我们保障业务准确性的重要一步。
前端数据治理之“元数据”
“前端数据治理“这个知乎栏目,我打算从另一个纬度去讨论前端应用开发。这个栏目的核心话题都是围绕“业务”这个词。之所以要再强调,是因为有少数朋友在阅读时,思路会跑偏,用非本栏目讨论范围内的内容进行无意义的互怼,我觉得没有必要。任何阅读和讨论,一定需要一个前置条件,本栏目的前置条件就是“业务型应用开发“。在这类场景中,由于业务本身的流转逻辑复杂,流转过程中不同的对象状态变化相互之间还存在关联性,所以开发过程中往往比较痛苦,这也是我开这个栏目,梳理这些开发问题,以帮助需要相关思路的开发者获得参考的初衷。
本文主要讨论“元数据”这个话题。“元数据”的简单定义就是“关于数据的数据”,直白的说,就是关于值本身的描述的集合。对应到开发中,就是表结构,我们在设计数据库表时,需要对每个字段的类型、长度、默认值等进行规定,这些内容,就是关于这个字段的值的元数据。
和后端开发不同,前端是面向交互编程,因此,和后端相比,前端建模更多是为了给视图交互服务。
现在,我们进入到具体的场景进行“元数据”的讨论。我们现在有一个简单的商城系统,商城系统的核心对象有三个:消费者(用户)、商品、订单。围绕这三个核心对象,商城的整套业务流转在运行。现在,我们要为商品建模了,我们来想一想,在那些业务环节(交互过程中)会需要商品?
- 商品的上架(一个提交表单)
- 商品信息的修改(一个修改表单)
- 商品的展示(商品详情页)
- 商品的引用(下单过程中,订单拉取商品部分信息进行展示或计算)
有人会说,用户已购买的商品也是,但我想说,用户已购买的商品和我们这里的核心对象商品是不同的,用户已购买的商品属于订单的子对象,用户下单买完之后,订单当时商品的信息是固定到订单信息中,相当于对商品对象进行了主要信息的克隆,和这里的核心对象商品已经脱钩了。
综合上面的这些情况,实际上,我们面临的交互主要有两个:1)表单 2)展示。
在考虑设计“商品”这个对象的元数据时,我们要分开上面两种场景进行设计。当然,对于我而言,我虽然是从两种场景去设计,但是我在模型中将所有的元数据集中在一起,在两种场景下都可以使用该模型。
接下来,我们来看商品价格这个字段。
我们先考虑展示的时候这个字段要准备哪些可能需要的东西:
- 样式类:字体大小、颜色等
- 值类:保留多少位小数,前面是否加¥等货币符号
- 名类:“价格”这个词,是否需要区分当前系统是中文和英文
- 格式类:是否需要千分位分隔符,或者根据用户所在国家进行数字格式化
你看,我们一下子就让价格这个字段丰富起来了。上面这些都是我能想到的,但不一定全。不同的电商系统中,这些东西估计都是需要的,所以,实际上,我们已经总结出“价格”这个字段的通用“元数据”了。
那么,具体在编程上怎么去实现呢?我在 tyshemo 中定义了 Meta 类,该类其实是一个抽象类,用于定义元数据。现在,我们尝试定义一下价格这个字段:
import { Meta } from 'tyshemo'
class Price extends Meta {
static name_zh = '价格'
static name_en = 'Price'
static font_size = 18
static font_color = '#660000'
static formatter = pipe(
thousands, // 千分位分隔符
fixed(2), // 保留两位小数
currency, // 我自己写了一个currency函数来自动添加货币符号
)
}
你看,我们已经定义了 Price 的不少元数据了。
接下来,我们来看看表单中的情况。表单,我一直一来都认为是前端开发中,最复杂的场景之一,因为它要处理的东西实在有点多。不过对于价格这个字段,我们感知上应该还好,不会有特别复杂的逻辑。
- 是否必填?
- 是否只读?(某些情况下,一经发布,不允许修改商品价格)
- 是否隐藏?
- 是否禁用?
- 提交到后端时是 price 字段,还是 good_price 字段?
- 最大值/最小值?
- 用户填写的时候是否需要千分位格式化?(这个就有点难度了)
- 真实值和用户看到的值是否一致(是否需要省略小数部分)?
- 校验逻辑?(这个应该是表单标配)
你看,Price 瞬间就又复杂了很多不是吗?这些都不是全部的,我只把自己所能想到的都列了出来。现在看看代码实现:
import { Meta, Validator } from 'tyshemo'
const { max, min, required } = Validator
class Price extends Meta {
static required = true
static readonly = function() {
return this.is_expired // 模型上的 is_expired 字段为 true 时,价格只读,不能被修改
}
static setter = v => v === '' ? 0 : +v
static getter = v => v === 0 ? '' : v + '' // 当价格为 0 时,输入框为空
static validators = [
required('价格必须填写'),
min(0), // 不能小于 0
max(9999999),
]
static to = 'good_price' // 传给后端时使用 good_price 字段
static is_need_thousands = true // 给一个标记,由视图层处理交互逻辑
}
表单中可能还会有其他的一些限制,总之,我们尽可能覆盖到各种场景,并在 Meta 中提前定义好这些元数据。当然,有的时候,在元数据中,有些定义是比较抽象的,比如上面的 is_need_thousands 这个属性,如果单纯看它,从字面意思确实可以理解,意思是需要千分位分隔符,但是问题在于,具体怎么实现呢?所以,这里就比较抽象,它实际上是前端约定好的一套交互协议,如果 meta 中 is_need_thousands 为 true,那么我们在前端视图层实现时,就会采用一个特殊的数字输入框组件,这个组件自带了方便的千分位分隔符能力。但是,对于非前端人员,阅读到这里,就会存在一定的理解障碍。这也是属于前端私有的领地。
另外,我们可能还会在元数据中定义一些其他的属性:
- 数据类型是什么?
- 后台接口中,如何获取 price 字段的值?是直接读 data.price 还是读 data.good_price?
- 在什么情况下不需要上传这个字段?
- 当这个字段的值发生变化时,是否需要执行某个函数?
这些属性的定义,都可以通过 tyshemo 这套系统来实现。
我们回到元数据这个话题。我在读大学的时候,我们管理学领域有一门分支学科,叫“信息资源管理”,里面提到一个论点,“管理要超前到信息产生之前“,也就是说对信息的管理,要在信息产生之前就进行,这叫“超前管理”。怎么做到超前管理呢?就是事先定义好元数据系统。当然,真正的工作并非那么简单,但是,从中可以看到,元数据是保证我们业务逻辑按照我们预先设计的规则进行的。
最近接到一个新的需求,我们要统一规划系统中所有字段的基本逻辑,包括所有字段的中英文、展示格式(一个字段可能多套)、校验规则等。这是一个复杂的需求,最理想的状态是做成一套线上系统,有点像 Headless CMS,可以自己对字段进行自定义。这个需求本身是复杂的,但我们单纯从前端角度来看,这个需求实际上可以为前端提供丰富的元数据,一旦这些规则都是定义好的,我们就可以通过一种协议,从后端拉取有关这个字段的元数据,获得该字段的所有规则,这有助于我们统一化前端字段的展示和编辑逻辑,完成之后,前端不需要自己再手写各种各样的校验逻辑,不需要再用像素眼盯着屏幕检查是否按照设计要求展示字体、颜色,有了这套系统,前端实际上代码量会下降一个量级(当然,实际上,复杂度提升了很多,对于那些反复强调“到时候新人看不懂”的团队,不适合拥有这类系统)。
什么是业务逻辑?
我写《前端数据治理之道》围绕一个中心词汇:业务。但我在一些文章的评论中收到“业务交给后端去处理,前端做展示就好了”类似如此的思想。这类思想我个人的理解,更多是站在后端开发者的角度看问题,因此,我打算写一篇文章,专门讨论什么是业务逻辑。
很明显,在部分场景下,前端是不可能只做展示的,随着B端、G端产品的强势,这种“前端只做展示”的应用架构已经不符合需求了。我观察到,业务在开发层面有两种存在形式:数据的业务,人机交互的业务。站在后端开发的角度,只看到第一种,但在实际开发中,前端开发需要兼顾两种业务,而且,很大程度上后一种需要以前一种为基础。
数据的业务由持久化数据和逻辑代码构成,基于这两个基础,可能还需要搭配各种系统来实现,例如需要消息通知系统来在每一个业务节点上推送催办消息给特定用户,例如需要利用定时任务系统来拉取第三方数据,例如需要队列服务来解决长串任务堆叠。总之,我们传统的后端系统基本上只关注这一层面的业务。
在前端,基于数据的业务也同样存在,举个例子,我们做了一套数据视图系统,对于用户而言,他们可以通过挑选几个字段的所有数据进行统计,并为了研究不同因子变化带来的结果变化,直接修改某些值来进行计算结果。很显然,这里用户进行的这些操作不会对线上数据库产生任何影响,因此也就不需要向后端提交数据。这些数据被存在客户端内存中,一旦用户离开这个界面,这些临时数据就可以销毁。如果按传统做法,前端必须将一大堆数据post到后端接口,由接口返回计算结果,然后再由前端来渲染。但是很明显,相同的计算在前后端执行得到的结果是一样的,而传统方式还要付出网络交互和前后端耦合的代价,而这类计算仅仅当前这个用户用到一次,不需要持久化,交给后端处理,还要让后端考虑性能、并发等风险。可见,这一业务虽然是数据业务,却可以在前端完成。
交互的业务是本专栏要关注的重点。人机交互大发展是当代计算机系统发展的重要一面,如果只重视数据业务而不关注人机交互,那么丑陋的命令行模式就可以完成大部分计算任务。但是如所有人所见,如今的业务系统需要用户在一个界面,结合界面上呈现的多维信息,完成不止一个操作。而且为了提升用户体验,产品设计人员想尽一切办法,让用户明晰每一个操作的作用,避免做了错误操作。现代应用的交互复杂度,以及交互后面所蕴含的业务逻辑,有时,让人望而生畏。
业务的逻辑,表面上可以用一大堆if…else来概括,但实际上除了判断之外,它还可能涉及形式。
很多人对“形式”这个词不敏感,但如果你研究过形而上学或符号学,就会不再那么轻松。形式在交互中极为重要,我们用一个具体的场景来解释。
现在有一个表单,里面有一个输入框,该输入框对应的字段,需要关联到系统中已有的某个对象,但也有可能用户不选择关联,使用输入的值作为结果。而如何用户选择使用输入的值作为结果,那么就有可能输入一个系统中已经存在的对象,而该字段的规则是不允许系统中有重复值。在这个场景中,是暂时没有数据参与的,也就是说,它的整个业务必须依靠前端来实现。
除了单点的复杂交互业务之外,还有连续的流程交互业务。以一个审批流为例,一个审批单发起、审批A、审批B、结项,这个流程环节必须走。暂不考虑流程分支流问题,后端自有后端的方式交互这一审批流管理起来,现在我要问的是,前端做了什么来合理管理流程业务?是只展示后端输出的内容,还是自己构建了一套流程模型?
现在我们再来看看什么是业务逻辑。
实际上,我们口口声声的业务逻辑,是只用代码实现的真实业务的规则映射。注意“规则”这个词,简单说,一个业务中,存在什么逻辑,可以通过在纸上画出不同业务对象之间的联系和约束,并将这些联系和约束一条条列出来,形成一个列表,而这列表中的每一条,就是一条规则,这些规则的总和,就是这个业务的业务逻辑,而且是全部业务逻辑,你不能再多列出一条了。
既然是一条条的规则,那么我们就可以在代码层面对规则进行管理。对于前端开发者来说,最熟悉的规则管理,莫过于路由管理。现在,我们做一个思想实验,将每一个route对应一条业务规则,每一个url对应某一时刻后端接口输出的业务数据,随着后端接口业务数据的变化,不同的业务规则会被使用,而没有匹配到的规则会被屏蔽,从而,在界面上呈现出根据业务逻辑而提供对应的人机交互的效果。
由于规则是有限的,我们可以借鉴有限状态机的开发范式来实现对业务逻辑的开发。有限状态机,很好的为我们提供了在不同规则之间转换的一种思路。当业务从一个状态切换到另一个状态时,可以很清晰的获取当前状态,以及下一步我可以做什么。业务切换也是这样,我们明确当前可以做什么,也知道下一步可以做什么,到具体要做什么,要看用户做了什么操作。
但和有限状态机不同,业务流转并不是状态切换,每一次流转,都可能牵涉一堆东西,例如从一个阶段进入下一个阶段,参与业务的业务对象变了,我讲过,业务流程是实体的进出和状态变化的总和,所以单纯靠有限状态机是解决不了的,但我们可以借鉴状态机切换状态的这个切换编程模式。
由于我并没有将所描述的这套东西实现成框架或库,所以无法再具体到细节处。
最后,前端处理业务逻辑不仅不是多余,而是当下复杂应用的趋势和要求。目前而言,前端领域还没有强大的针对这个领域的库或框架,因此,需要我们积极探索,重拾软件设计的技术体系,寻找更多可能性。
前端数据源治理
在我看来,前端数据问题分3个层次,分别是:
- 原始数据请求
- 数据请求抽象
- 数据源管理抽象
我们通过Restful API也好,websocket也好,或者本地缓存也好,都可以获得一些数据。但这些数据的获取和使用,如何在项目中具有更优雅的设计,至今我还没有看到满意的答案。我在《漫谈前端数据层》一文中提出,可以在前端借鉴数据仓库的概念,设计可以从我们常见的react数据请求编码方式中解脱出来的方案。不过在那篇文章中,对获取数据的这个环节的思考并不非常成熟。这篇文章表达我经过思考后,对这一问题的最终解答。
我们常见的做法,有两种,一种比较原始,直接在react组件中使用axios等库发出请求,请求回来后通过setState把数据拿来使用。另一种结合状态管理器,在独立的模块文件中,把相关的接口全部集中起来,形成一堆基于axios的async函数,并在状态管理器中调用这些函数,用到thunk、saga之类的工具来实现状态异步更新。这两种方式在我看来,都是原始方案。
所谓“原始”,就是直接按照ajax请求的思维,把最底层的xhr通过一层封装暴露出来,在使用时,需要遵循axios等库的细节,思维层面仍然是发出xhr之后等待接口返回结果的思维。它的问题在于,它只解决了ajax请求本身的问题,而如果你需要对数据进行处理、检查,或者对数据有什么要求,就需要自己解决。而之所以说“原始”,就是因为解决这些数据问题,都是散落在各个组件内,或者集中在一个文件的async函数内,每个地方都有类似的痕迹。而使用的人在通过浏览器devtool看数据时,并不清楚你真实处理成了什么数据。总而言之,原始数据请求的方式,就是看上去好像有封装,本质上和最早写xhr没有本质区别。
往上一层,我们要屏蔽这种底层的xhr编程方式。也就是我们要抽象数据请求本身。这听起来比较绕,但你一开始可以理解为,我们要做一层封装,把xhr的那种请求方式隐藏起来,让我们像做某种无感知的操作一样。我写了一个库专门做这一层,你可以了解一下我发明的这套ScopedRequestLanguage。它的核心思想是“把请求进行描述”。当你看到“描述”这个词的时候,你往往就会把它和“抽象”联系在一起。描述的内容总是静态的,但是基于这些静态的东西,我们却可以一眼了解关于该请求的细节,甚至在脑海中勾勒出一个场景发生时,具体将会出现什么状况。
我们用一个例子来解释。现在,我有这样一段描述:
GET "/api/xxx" -> {
name: string;
price: number;
total: number;
}
你看,这段描述你是可以读懂的吧,我想但凡做过web开发的前后端程序员都能读懂这段代码。它是讲“你用一个GET请求/api/xxx接口时,将会得到一个含有name, price, total字段的数据对象“。我们基于描述来执行请求,演示代码如下:
const data = await requester.run(`
// 上面的描述文本
`)
我们通过抽象请求本身,把请求转化为描述文本,进而实现屏蔽底层xhr请求的林林总总。而描述的作用就非常多,除了能够准确的获得需要的数据之外,它还可以被转化为用以检测后端接口是否按照既定规则给数据的检查器,也可以在前后端同学之间共同阅读,从而建立起更好的沟通模式。
虽然通过描述我们屏蔽了底层发出请求的细节,然而,这远远不够,因为它在屏蔽底层细节的时候,并没有屏蔽数据请求这个过程。
再往上一层,我们要屏蔽数据请求过程本身。我们要抽象的,是数据源本身。前端消费数据,虽然大部分来自Restfull API,但时常也需要从localStorage等其他地方拉取数据。单从从API请求数据而言,我们仍然需要谨慎的理解,“数据是不是还在请求?”,“数据回来了吗?”,“后端是不是出问题了?”等等类似的问题。我们要做的,就是屏蔽这些问题。
我们把数据源进行抽象后,对于数据的消费者,也就是业务代码的撰写者,思维上是消费数据,而不是请求数据。两个字的差别,就是一次思维上的飞跃。我们把数据源抽象为一个看不到内部细节的球,现在,我们要消费数据,于是我们向球说“我要你的数据”,于是它就把数据给你了。至于它的数据来自哪里,你并不需要关心,它向你屏蔽了数据请求的细节。
我们还是用代码来举例,假如我们现在有一个数据源叫做ProjectDataSource
,现在我们要消费这个数据源的数据,我们只需要:
const data = dataRepo.get(ProjectDataSource)
此时,你得到的data就是你需要的数据。你可能会想“咦,我没看到你请求数据呀?你这个数据准不准确哦?”。这是你在用上一层的思维思考问题。你现在需要升级你的思维,你不要关心数据源内部的数据是怎么来的,就是是上帝说要有数据然后就凭空出现了数据,你也不要关心。你要关心的是,它给的数据,是否是按照我们的约定来给的,此时,你需要用上typescript。
const data: IProjectData = ...
也就是说,对于你来说,只要它给你的数据符合你对数据类型的要求,那么你管他数据是哪里来的,你就认为这数据是合法的就好了,至于数据的准确性,你需要交给提供数据源的抽象引擎来做。这个抽象引擎解决你所考虑的所有问题。而你只需要消费这个数据即可。
好了,现在,让我们进入到球的内部,看看球里面到底发生着什么。
一个数据源的建立,最底层,还是要走ajax那一套,只是说,我们这些底层的东西,被提前封印在球内了。球内代码大致如下:
const defaultValue: IProjectData = { ... }
const ProjectDataSource = dataRepo.source(async () => {
const data: IProjectData = await requester.run(`
// 上面的描述文本
`)
return data
}, defaultValue)
我们用上了第二层的抽象。但是它只负责请求的部分,而构成数据源的,除了请求,实际上还可以加入其他很多东西,例如缓存,你可以在第一个函数中加入一些缓存的逻辑,你也可以在这里对数据进行检查和提示。同时,它的第二个参数是默认值,就像 [state, setState] = useState(0) 在默认状态时为0一样,这个默认值保证了数据源在没有发出请求时,也是可用的,不会对界面造成破坏。
我在几年前写了一个叫databaxe的库,专门去做类似的数据源管理,前年在思考了很久之后,写了一个叫algeb的库,借鉴hooks的思维重新整理了这一整套数据源管理的逻辑。你可以通过这个库的思想,去窥探这一层次的内容。数据请求和数据管理是两码事,你一定要把他们分开,在你的项目中,应该更看重数据管理,因为数据管理的方式会通过抽象,屏蔽它底层的请求细节,让你的开发,具有“将不同的对象串联起来“的特征。
这就是我关于前端数据源的一些思考。当然,其实还有一点比较重要,就是你需要把3层的东西,结合到你的项目中去使用,而不是独立于你项目之外运行。我在我们项目中深度实践了这种抽象和整合,你可以关注本专栏,或者我的微信wwwtangshuangnet,通过我后续的文章了解相关的内容。
-
algeb的链接错了#1313 1 2024-04-23 11:51
-
已更正,谢谢指出#1314 回复给#1313 否子戈 2024-04-23 16:45
再谈前端数据源管理
上一篇文章介绍了我的前端数据源治理思想,其中提到了非常重要的数据源的概念。在推动团队按照这个数据源思想进行实施时,受到了不小的阻力。除了团队成员对架构、业务特性对技术的要求、长远眼光等的认识不足外,还有一点就是分层设计对直来直去的前端开发很难接受,因为要写多个文件更多代码,而且分散在不同处的代码看上去都是在为一件事服务。
在前端很少讲一些编程原则,所谓SOLID在前端根本行不通,但是,想要写出健壮的,长期可持续维护的代码,就必须去理解这些在其他编程领域通用的设计模式、原则、范式。
多态,在数据源上其实是比较容易体现的。听上去比较难理解。我认为前端应用因为和数据请求绑的太死,导致你需要通过处理异步(也就是Promise的resolved)来解决更新界面的问题。比如angular里面,你必须得注入$q来触发界面更新,在react里面,你得随时随地调用setState来更新。这使得异步动作被死死的绑定在组件中,而且你必须按照异步思维撰写组件。但在我看来,特别是react中,我们应该是同步思维,我们的天上会有一个神在观察世界的变化,然后将世界刷新成新的模样。对于身处这个世界的人,我们不需要也不能够观察这个变化,这个变化在世界之外,就像这个变化在组件之外,不应该在组件内部一样。而直接在组件内部写异步请求就是企图僭越去做神该做的事情,例如下面的代码:
function SomeComponent() {
const [data, setData] = useState({})
useEffect(() => {
fetchData().then((data) => {
setData(data)
})
}, [])
return (
<div>{data.title}</div>
)
}
虽然代码本身没有错,但是这种写代码的意图是有问题的,这里的思维是我一进来默认是{},然后我发起一个请求,这个请求一定会从外面得到一个数据,我要用这个数据出发一个变化。
这个思路是怎样的?这个思路一定是有一个动线的,先怎样,好暂时结束,再怎样,结束。也就是说你在这个函数里面,你是需要自己去想象一种难以理解的流动性的。
我们来看看另一种思维:
function SomeComponent() {
const [data] = useData(someSource)
return (
<div>{data.title}</div>
)
}
这里,这个组件从实际行为上,也会和前面一样被渲染两次,但是思路完全变了。对于我这个组件而言,我的动线是,拿数据,渲染,结束。虽然会被渲染两次,但是无论被渲染多少次,都是这样的一个思维方式。
你一看代码,心想,这有什么难的,不就是封装了一个hook函数吗?
但是你得到这个结论的时候,实际上是从结果反推你想要的解释。而真正理解了的人,他一定是从自己的思维开始出发,最终得到了这样的结果,实际上是完全不同的两种思维方式。即使你通过“封装”反推出“和我写的两次渲染没有差别“,也无法否定别人”拿数据,渲染,结束“这么一条动线的思维是更优秀的。
如果你能理解这一点,那么,我们就可以把这个问题想的更深。
数据从哪里来?一定是通过ajax请求吗?如果哪一天换成rpc呢?
数据的来源是多态,但是对于我们上面这个组件本身而言,我并不需要为每一种情况去做区分,我只需要统一一种处理方式即可,所以的数据来源,对我来说,我并不需要关心,我只是用它而已,而且我也知道这个data.title就是我需要的。面对这个多态,我们最好是用ts去写出它的接口,然后有不同的数据源,也就是我上面传入的someSource对象来具体实现,我们后续可以传入另一种实现someSource2,但是这并不影响我们任何的代码逻辑,只是在someSource2自己内部实现时,稍有不同而已。
有了这样的理解,我相信,逐渐的,你也会尝试自己去寻找一种在前端和数据源之间,可以有效将不同数据源进行管理起来的方式。比较幸运的是,我走的比较快一些。
前端业务建模的内涵
我们抛开前端的实现,去理解和整理业务建模的具体内容,我梳理出这些概念:业务实体、业务事件、业务逻辑、流程阶段、交互、视图。
业务实体
实体一般用名词表示。一项业务可能包含多个业务实体,一个业务实体可以理解为一个用于表达清晰内容的对象,一般是一个由多个字段组成的对象。业务实体的作用是定义实体包含哪些字段,以及字段本身的基本元数据,这些元数据包含但不限于:该字段的类型、该字段是否是多个值、该字段是否包含字段解释说明、该字段是否有某些特殊的标记、该字段是否一定要依赖另外的字段等等。这些基础信息被定义在实体中。
业务事件
事件一般用动词表示。业务事件是在业务实体的基础上的真实业务表达。实际上,我们在讲“业务”时,往往是指一个“业务事件”,例如我所在的投融行业,“投资”“融资”就是对应的事件,在投融行业,你需要去了解一次投资/融资行为,都会发生哪些事件,这些事件是由谁,在什么时间、地点发起,持续了多久时间,过程中都必须发生哪些事情。当然,对于业务事件这个概念本身,它不包含“过程中发生了哪些事”,因为这些事处于业务事件之外,只是与它有强烈关联罢了。在建模时,我们的系统中,往往是以围绕业务事件为中心进行的,特别是在前端,因为前端涉及用户的交互,而在界面上一次性呈现的,往往是这一业务事件的全部,而几乎不会以某个业务实体为单位进行独立呈现。这也就意味着,在前端,实际比在后端要进行更复杂的串联思考。
事件边界
如上所述,一个业务事件往往囊括多个业务实体,比如一次投资行为,要囊括投资方、被投方、投资团队(业务人员)、基金方(如果存在SPV),同时要囊括该次投资的交易信息、各类合同文件等等,而其中的每一个实体,往往右内涵自己更细的实体描述,例如被投方要包含被投的公司/主体、其管理层人员、上市信息、已有的投资人等等信息。不过,我们仍然要为一次投资事件划定边界,该事件包含了上述实体,但这一关系也可能仅仅是引用关系,而不是从属关系;另一方面,该事件还会有自己的流程,我们都知道投资流程非常复杂,设计的钱、人、机构都很多,但是,这些信息往往并不是该次投资的本体信息,它们是附带出来的,我们可以在项目里面加一个流程的引用,也可以在流程里加一个项目的引用,都是可以的,因此,这两者就有比较明确的边界,我们建模的时候不需要把流程的问题考虑在该事件内。
总而言之,“业务事件”虽然用动词表示,但是往往具有名词意义。比如,我现在要“做一次投资”,这里的“投资”本身虽然是动词,但在“做一次”的动词前提下,它就是名词。而名词的建模往往具备通用的方法论。
业务逻辑
业务逻辑本质上是业务规则,所谓规则,就是一条一条的前提与指令集合,这在本栏目的其他文章中有解释。所谓“前提与指令”,就是“当A的时候,应该B“,A是前提,B是指令。而指令在前端是非常难以固定化的,因为在前端,指令除了变更数据、状态,往往还要求UI层面作出响应,而后端在这一点上就轻松很多,常常只需要变更数据或状态即可。因此,在前端,这一部分不得不分开,虽然它们原本应该耦合在一起,但是从前端的特性出发,我们必须把它们原子化拆分,其中“前提”往往被集中管理,而“指令”则被分散,有的不设计UI层面,只涉及数据和状态,就可以和“前提”并且放置,而涉及UI层面的,只能在视图层中管理。而真正要执行该逻辑时,我们必须从多个地方取出“前提”和“指令”,然后进行运行。
另外,“前提”和“前提”之间往往还有一定的先后、嵌套顺序,但是常常“指令”又仅对单一“前提”进行响应,这也就意味着在管理上其实需要非常小心,即使做了非常强的封装,也必须考虑分散管理和集中使用之间的冲突所带来的不确定性。而大部分前端系统出现业务层面的bug,都是因为这个管理和使用的不当造成的。
我们要探索一种合理且高效的业务逻辑管理模式,在这种模式下,前提和前提之间的顺序关系可以很好维护,与此同时,前提和指令具有绑定关系,有这样的机制的话,当逻辑开始运行,它们就必须按照特定的顺序,以及在特定的节点上执行特定指令,如此运行,这一就可以避免很多问题。
流程阶段
流程往往是针对事件的,可以说它是事件的衍生品,当一个业务(事件)发生时,如果它没有特殊的需要,就不需要流程,它仅仅成为一个事件的记录。但是,如果这一业务需要不同层面的人在不同的情况下参与进来,最为常见的参与方式就是审批,当然还有补充信息、修订错误、线下处理某些事情后记录到线上等等。流程分阶段,人员在这个事件中的出场和离场也可能随着阶段的迁跃而发生。同时,非常重要的一点是,事件中所包含的实体的字段,在不同阶段可能会附加对应的业务逻辑。例如,在阶段1时字段a不需要填写,但是到了阶段2就必须填写。
狭义的流程往往只发生在事件出现之后,但是广义的流程从事件出现之前就开始了。比如我们现在准备开始投资一家公司了,那么我们其实在开始之前就已经做了非常多准备工作,例如收集该公司的各种信息,与该公司的一些高层进行了接触等等。但是这些准备阶段的工作记录,常常因为我们不确定是不是准备作出投资计划,而不知道把这些信息放在哪里,也就是“在事件开始之前”,这些信息已经有了,但是因为“事件还没有开始”,所以,这些信息放在哪里呢?不管放在哪里,一旦我们准备开始投资这家公司,在系统中去创建这次投资事件时,这些信息就会马上拉取出来,作为该事件的一些信息。也就是说,对于投资事件的流程而言,在事件开始之前就已经存在了,只是它是概念上的,而非真正存在。
基于“广义流程”的理解,我们的事件业务逻辑的管理,就可以被关联到流程的阶段上,因为这个事件从还未开始到事件结束,都处在流程中,而大部分逻辑都可以被确定在某个流程阶段。(当然,有些逻辑是很底层的,应该直接在实体或事件模型中写死,例如某一个字段具有特定的业务性质,它的值一定是另外两个字段计算而来,这种逻辑就不属于流程上的逻辑,而是底层逻辑。)
交互
交互是真正意义上的“动词”,所有的文档中有关交互的描述都是“要干什么”“应该怎样”“必须如何”,例如“字段A大于10时,弹出提示语xxxx“,其中”弹出提示语“就是一个交互行为。而这一整段话就是一个业务逻辑,但是注意,它是一个“业务的交互逻辑”,因为它是基于业务的某个前提,执行交互的某个指令。
在前端,处理交互其实是很麻烦的,因为它一半具有视图层面的意义,一半具有非视图层面(操作数据)的意义。因此,我们要想办法把交互从中解放出来。我们需要建立交互模型,它一半提供给视图层,让视图去调用它,作为视图层的响应,另一半它要读取和操作数据,使得业务发生演进。
另外,下面我们会讲到,视图本质上是抽象的状态的具体表现,在交互模型中,我们可以提前定义好作为视图抽象的状态,并且在面对某个与视图有关的交互指令时,操作其状态,从而达到与视图层完全没关系的效果。
视图
视图是前端最熟悉的部分,也是业务系统最无关紧要的部分,因为对于非常多的业务系统而言,其核心价值在于数据,即使没有前端的界面,只要你有手段让我准确获得数据,我也是可以接受的。当然,如果没有前端的界面,数据的创建、补充、修订,审批的进行,文件的上传,都是非常麻烦的,所以,不能因为它对于业务无关紧要就直接否定它的存在。
视图的本质是抽象的状态的具体表现,也就是著名的公式ui=f(state),在业务系统中,我们需要对业务数据状态化,使“业务的数据+界面的状态”成为一个大状态,交给视图,视图拿到这个大状态之后,按照不同端的特征进行渲染,提供人机交互的入口,调用交互模型的方法,响应新的大状态的变更。
在很多情况下,视图具有相似性,对视图进行建模,可以有效的解决此类复用问题,例如在PC上有一个罗列字段的列表,在APP上也有一个同样的列表,只不过它们的长相不同,但是它们都是一个列表,有label和value两个部分,value部分还需要进行一些格式化处理,以及一些操作上的按钮。这些相同的部分,我们可以在模型(用抽象类更好)中撰写,再在两端各自实现界面的呈现。
DDD理念在C端产品前端开发中的应用初探
我在以往的文章中讨论前端实施基于DDD理念的应用架构,往往是立足于复杂的业务系统(往往是B端应用),我认为只有围绕一个处理复杂业务为中心而搭建的系统,才需要从DDD的理念去思考和实践,但在最近,(基于我的切身体会)我发现这一想法是错误的,DDD不仅复杂的B端业务系统的有用工具,而且在C端常规的业务应用中,也具备指导作用。
之前我之所以强调只有B端业务需要DDD,是因为我觉得DDD本身所带来的复杂性很强,需要沉淀的东西很多,对于开发而言具有阻碍性,会拖慢开发进度,特别是在追求快速迭代的C端会拖慢进度。同时,我也认为C端应用不存在“业务”的概念,也就无所谓用DDD去解决业务核心复杂性。但是,当我现在开始参与C端应用开发时,我发现,即使是C端应用,它也是有业务的。当一个应用是以用户完成某种目标,需要在多方数据中进行博弈时,就开始拥有一定的业务属性。所以,现在来看,除了类似微博、新闻、短视频等这类纯粹娱乐的应用之外,其他大部分应用都具备业务性质,大到类似淘宝一类的电商平台,小到政府的一个办事小程序,甚至一个乘车码,背后都有多方数据在博弈,都具备业务,甚至一旦这种博弈的强度提升(例如加入风控相关的逻辑),其业务复杂度就会呈指数级增长。
因此,我现在改变了我的想法,我认为,DDD作为一种指导架构设计、技术实现、设计研发的理念,无论在B端C端还是哪里,只要系统具备业务性质,都具备指导意义。
1 应用架构设计
在其他文章中我已经聊了很多有关基于DDD来设计应用架构的内容,这里就不再赘述。我现在想要讲的是一些更微观的东西。
C端应用是否应该在一开始,就进行复杂的架构设计?
我认为需要。C端应用在早期一定是追求快速迭代的,因此会积累非常多的债务。当债务积累到一定程度时,一定会阻碍开发迭代的效率,因此,应该重构。不过,按照《重构》的讲法,重构应该持续,时时刻刻在重构,但是这种只存在理想情况下。我们这里所指的重构,一定是在一个阶段,对整个项目代码进行刮骨疗伤式的全部重来。但是,我们会遇到一个非常巨大的问题,就是原有的业务逻辑可能在重写时,被疏忽而遗漏掉。而前端用来对抗这种不确定性的武器不多。
一种方案是老代码完全不要动,甚至继续迭代,虽然痛苦,但是起码能保证业务逻辑没有被破坏。在将来有机会,再利用新架构,对这块业务进行推倒重来的重构,通过灰度、AB等方式逐步迁移。手段有多种,例如微前端、MPA(多页应用,不同URL跳转)、在新架构下写一个可支持原始代码运行的沙盒工具……
另一种则是直接全部重写,且不必去考虑原始实现,翻出产品文档,按照文档的逻辑重新实现,像写一个新功能一样。而这样做,意味着我们可以在线上跑两套代码,相互验证,也是不错的选择。
如果说B端系统像一座笨重的基地,通过不断的在原来的基础上叠加呈现堆积如山的模式来实现系统的扩张,那么C端应用就像一张星链网,通过不断增加数量以铺张出更大的面积的模式来实现业务的扩张,两者的一个是纵向发展一个是横向发展,看上去,纵向发展的B端系统更需要架构,来实现部分和整体的融洽,而横向发展的C端应用似乎可以分布式存在,甚至可以各自独立。但当我们回到开发层面,我们就会发现,这种分布式扩张的模式,更难把握架构设计,因为它们看上去可以独立,但是它们大部分又要共通,如果不通,当某些部分需要升级时,不可能到所有节点上去一个一个升级。
因此,把薄薄的星链,按照厚厚的基地来设计,才能在源头解决将来的很多问题。
有人会说,你前面不是讲C端要快速迭代,前期要快吗?是,但并不意味着要快,就不能按DDD来设计,也不是说按DDD来实现,就一定是慢。在架构设计中,有些东西我们必须去做,而如何做好这些,却各有各的不同。
例如,我们必然会写数据请求的部分,毕竟在后端看来,“前端不过就是渲染界面而已“,没有数据来源,实在是巧妇难为无米之炊。可以说,如今的前端应用,99.9%都会有数据请求这个部分,剩下的0.1%可能是古老的php或jsp输出的网站。那么,如何去设计数据请求和数据源管理,就是八仙过海各显神通了。
再例如,通用的底层UI组件库,每个项目组都有一套,那么如何去设计这套组件库,就各执一词,各自把牛B吹上天,最后在业务中用时,仍然是不断的改来改去。
我们需要遵循一种理念,让这些共通的东西在项目设计之初就稳定下来,不是最好的才是最好的,合适的才是最好的。一个团队一定有自己的特殊性,在团队内能形成一种无形的设计模式,就必须要去遵循它,即使在别人看来很奇怪。
我们要找到一种感觉,即在项目的架构层面,可以去避免将来可能出现的问题。如果去避免呢?很简单,就是无论将来的技术发生什么变化,这些复杂的代码都可以被再次使用。没错,只要我们的代码设计为脱离具体环境,是一种纯粹的业务、逻辑的时候,就可以再将来被重复使用,即使可能将来有更好的实现方式,但是起码用上这些老代码不需要什么成本就可以继续使用它们的逻辑。
例如数据请求,当我们从vue2升级到vue3时,这部分代码不需要被修改就可以直接使用。例如UI组件库,当我们需要web和小程序拥有相同样式时,不需要修改即可使用需要的组件。一旦我们的架构设计为这种脱离了具体环境的设计,就可以在将来立于不败之地。
2 从细节去重新审视自己
让我们现在来想象一个场景,需求里是这么描述的:“当库存不足时,界面上需要展示库存不足的提示“。很常见的一个描述对吧。现在问题来了,这里”展示提示“我们是怎么实现的呢?80%的人是不是如下:
<StockTips :isShow="isStockTipsShow"></StockTips>
我们用一个isStockTipsShow状态来控制提示的是否展示,然后就是对isStockTipsShow进行定义或变更:
isStockTipsShow.value = stock === 0;
或者:
get isStockTipsShow() {
return this.data.stock === 0;
}
总而言之,我们在不断考虑用于控制“展示不展示”的实现。但是,你有没有想过一个问题,当我们将来需要将“展示提示“的条件进行修改,例如”当库存不足,且用户参与过某活动,且系统中存在某种情况“,那么我们如何为其提供这种控制逻辑呢?
如果此时,我们换一种视角,我们创建一个属性,名为 isStockEmpty
而且将它从vue组件的状态中脱离出来,和vue没有任何关系。于此同时,我们甚至把“提示”这个看上去是UI层面的东西,也变成了一种抽象的符号,与vue无关,例如这样:
subscribe(isStockEmpty, StockEmptyTips)
而这个代码,脱离了任何环境,都运行正常。至于在UI上是不是要展示这个提示,则完全是在UI层去实现它。
呃……isStockTipsShow
和 isStockEmpty
有区别吗?有!本质的区别。
isStockTipsShow
是在想我这个界面如何去控制,而 isStockEmpty
是在想从业务上讲我什么情况下代表没有库存了。当时间经历了很久,我们需要将vue2迁移到vue3使用时,就会发现,前一种思维想要改动这个逻辑是万万不敢,而后一种思维则是对组件随便改,但可复用的业务逻辑绝对不改。我们通过写vue代码去适应业务,而非写业务代码去适配vue组件。
你看,一个简简单单的命名,从业务的角度去命名,和从UI组件的角度去命名,就会对我们将来带来可怕的差别。其本质,是思维上的差异。
3 模块
将有关业务的琐琐碎碎集中在一起,就是一个业务模块,但很让人崩溃的是,模块往往没有单一的出口,你不可能从单一出口使用这个业务,而必须在使用这个模块的外围应用(可能是另外一个模块)选择不同的出口进行使用,这里最复杂的,就是我们需要对这些出口进行编排。而往往,业务模块会被多个外围应用使用,否则就没有必要作为模块存在。不同的外围应用调用相通出口时,则会让应用中编排业务的调用变得混乱。而且还会遇到嵌套中的模块,同时依赖同一模块,更加复杂的编排和依赖关系。
而一种工作流(workflow)的模式,则可以解决这种编排的难以琢磨问题。
业务数据和状态的变更,总是存在于某种业务的流转之中,而这个流转模型,可以确保业务流转在被不同模块调用时保持一致性。工作流模式在前端如何去实践我自己也在探索中,我只是认为,能够确保被不同地方调用的同时,还能确保业务的一致性,是一种很酷的模式。
4 代码量
我最近发现,tree shaking并不好用,控制代码量,使得线上代码体积减少,其实是很难的一件事。但是,……但是这和DDD有什么关系?
当我们采取一种架构策略时,在具体代码实现上,常常会因为架构本质的区别而导致最终的代码量差距巨大。架构的理想程度,常常和代码体积成正比,因为,要实现某种理想的架构,代码分散、粘合、为了实现模式的故意使然、符合架构的文件目录结构设计等等,都会增加最终的代码量。
因此,我们不应该追求最理想化,我们必须向现实低头,过渡理想化,并不是最好,相反,过渡一定带来代价。在实现DDD架构时,我们应该保持C端应用本身的轻量化特征,取精华的部分。