进入正题前
前端开发是最让人生畏的,因为你要关心的太多了,调用后台接口和第三方等的异步操作,核心功能的控制,界面的样式设计,与用户交互及提示……稍不留神,代码就成一团乱麻了。
“哈哈,你说的这些,我早已驾轻就熟了!”
没错,但我今天要说的并不是技术,而是编程习惯,设计习惯!
好好看一下你的代码,问问自己过几个月再来看,或者现在让另一个无关的人来看,将花多长时间能看懂呢? 如果要对你的系统的样式,交互,功能逻辑做一些大的调整,修改和扩充,又需要花多长时间呢,或者你宁愿重启一个项目?
是的,面对一个接一个的开发任务,我们只想着赶快完成,面对一个又一个的bug,我们只想着赶紧将其补起来,却很少会静下心来想一想,怎样让事情变得更好!
好了,进入正题前再说最后一件事!
其实这篇文章写不写都无所谓,因为本文不过是我在践行前人所说的几大原则时,所感所想而已,我没有创造任何东西。
说白了,本文就是结合公司近期的一些典型的开发实践,找出其中的问题,并给出自己的思考和解决方案,虽然说的是前台,但其中道理在任何开发中都是适用的。
从故事谈抽取模块
问题的发酵
1. 现在来开发一个简单的卷库商品的系统,消费者能正确看到商品列表,每个商品能看到试卷的基本描述字段信息,能展示试卷的试题结构!
经过一段时间开发者实现了这个功能,具体有两个模块一个是列表list模块,控制分页展示的具体数据的获取,刷新跳转等功能。另一个是item模块,这个模块负责单个商品的样式展示,内容控制,点击交互等。
2. 接下来有另一个需求,用户会购买试卷,所以有一个我的试卷列表,这个列表也是一个商品展示,和前面基本是一样的。
看了一下具体的数据结构,我的试卷myPaper 在mongo中冗余了卷库商品,对应字段是 paper,其他字段是订单相关,个人相关的信息。这太简单了,所有的功能都已经开发完了,只需要做一点点修改就行了!
最终的做法如下再加个模块 my-paper-list 我的试卷列表模块,和list类似的功能,然后调用item模块,显示内容是myPaper.paper就行了。
不过卷库商品,卷库订单 两个后台并不是一个人做的,两个paper字段并不是完全一致的,但这视乎也无伤大雅,只需要在js中简单的处理一下,或在html中用 || 运算符就可以了。
3.后来又加了一个卷库推荐paperRcmd的功能,数据结构和myPaper类似,冗余了paper,外加推荐相关的一些信息。于是我们又故技重施了一次,不一样的地方又做了一些适配处理。
第一阶段的开发就这样完成了,整个过程似乎很顺畅,重用性也很高,简单的逻辑包装,让组件适配了三种模型。
4. 但是好景不长,第二个迭代产品经理就想在按钮上做点事情:列表只有购买按钮,但有些商品已经购买了,所以应该显示另外一个按钮点击应该进入在线作答,或作答完成显示查看作答结果按钮。
目前的设计,好像只能在那个item上下功夫了,好在开发人员,逻辑感还不错,能够理清各种关系,最终正确的实现了这个需求!——但是这个原本就包含了三种模型的组件,已经变得很复杂了,为了判断按钮,卷库推荐和卷库商品都要取一下购买信息,再加上之前做的一些适配,又是参照卷库商品的,三种模型已经变得是 你中有我,我中有你了。
最终的崩溃
过了好几个月后,大家认为之前的很多东西是有问题的,比如在显示优分率信息时是对每个商品额外进行了一次查询,因为优分率不是固定的,是随着作答人数和作答情况不断变化的,这个效率太低,特别是在app端,所以需要改一下,对列表做一个查询模型。
而且之前的商品有些字段设计的不合理,比如学科字段,以前用的是列表,可是试卷应该只有一个学科。所以应该用普通字段。
在这好几个月里,之前的核心开发人员走了两个,当然还有剩下的。
这次大的修改,查询模型这一块是另一个人做的,他对数据结构进行了全新的设计,删掉了很多无用的结构,将不合理的模型结构进行了调整,这是好事!
因为数据模型改动的太多,页面很多内容显示不出来了,这时致命的问题来了,我们发现前端几乎是没法修改的,看着那些非常相似却又完全不同的变量,比如myGoods,goods,rcmdGoods,而且学科,年级这类的字段取值都还有一段又一段的if-else逻辑,不敢改,也不知道怎么改。
当时的清晰与自信呢?早已没有了,那不过是因为你对业务非常熟悉,过了这么久没有人会清楚的记得每一个细节的,现在在你眼前的不过是乱麻一般的逻辑判断!开发人员不得不硬着头皮,凭借自己丰富的开发经验和判断力,再加上对业务还是有那么一点印象,还是能做一些修复工作的(注意是修复,因为这时已经没有开发的乐趣了,只有自己都不敢保证的压力),直到真正棘手的问题出现:
详情里的学科显示不出来了,将字段名字改对就好了,但是本来没出问题的推荐商品的学科,因为你的修改又显示不出来了。
开发人员最终得出结论:无法修改!除非你愿意花时间重新去梳理一下里面繁杂的逻辑结构,但如果这么做,还不如做一下重构,将问题彻底解决,也许重构花的时间更少。
思考解决方案
之所以要把这个过程写的这么详细,因为这是真实且普遍存在的,看到这个过程你可能会觉得有很多不合理的,但我们自己往往在不知不觉中就这样做了!
想一想这个过程,最开始为什么要拆分,拆成list和item?当然是为了简单,如果太多事情在一起,就会超出我们控制的范围。为什么第二步新的业务要共用,还是为了简单,共用代码更容易保持风格的一致,逻辑的一致,修改时更简单,不用四处寻找。
这就是抽取模块的两个根本目的:每个模块只做一件事,就看起来简单,很容易被我们掌控;抽取模块,更容易共用,更容易修改维护代码,以及保持一致。
上面那个例子做的其实还不错了,只是没有做的更彻底。开发人员重构的最终方案是:
卷库商品,我的试卷,试卷推荐,三个模块是相互独立的,都用自己独立的list和item,因为取数据的方式,路由及参数的方式,字段类似但结构命名等都不太一致,所以应该完全独立开来,互不干扰。
另外还有一系列共用组件,主要是些界面组件,因为这三个模块唯一相同的就是界面展示了,这些共用组件不再接收像商品,我的商品,推荐商品这样的参数了。比如前面的基础信息组件,展示的是学科,年级,学制,分数,类型,那么就只接受学科,年级,学制,分数,类型这些参数,这个组件不用关心,也不应该关心,这些字段来自谁,该怎么取。不是这样吗?
展望一下新方案,未来某个业务比如推荐试卷又有大的改动,大家认为这样冗余数据的方式不好,不利于数据一致性的维护,想改成关系型的遵循范式的,这个数据结构又要重新设计了,修改起来好像也不难,因为它对应的list和item只有它自己,单一的事情我们总是很自信,因为它足以让我们确定修改不会顾此失彼。
界面也要做大的改动,当前的界面太传统太没创意了,想做的炫一些,没问题,三个摸块界面是完全共用的,我们不需要四处修改 还生怕有漏掉的,而且界面模块只有界面的业务,这修改起来太简单了。
对比一下新方案和以前的方案,很容易发现其中的关键就在于你怎么定义你的共用组件了。我的理解是共用组件一定要遵循迪米特法则,一个共用组件只需要定义好我需要的参数,以及返回我处理的结果,不应该知道对方是什么业务,也不应该关心对方怎么处理我返回的结果。(或者你也可以说抽取组件一定要遵循单一职责原则,失败案例中的共用组件掺杂了外在业务,比如不同试卷的数据结构和字段取法。这些法则本来就是相通的。)
最后再重复一下,抽取模块的两个目的:保持业务简洁,容易掌控;共用代码,容易修改!
要遵循的关键原则:单一职责原则和迪米特法则。(注意抽取模块目的不是为了避免重复代码,不要抽取过度,那样反而更复杂,一切是为了简单。)
抽取模块时我们常犯的错
上面的例子展现了我们常犯的错误,及犯错误的过程。其实我们公司的前台代码,有很多类似的错误,随处可见。
不遵循迪米特法则,造成业务扩散
这是我们的开发非常常见的问题!!
举个购买商品服务的例子,我们通常都是这样定义这个服务的:buy( goods )
具体实现就是取到goods的id,版本号,再取到全局变量userid,就获取了访问后台的全部参数了,然后就执行异步访问后台接口的操作,并返回promise。
但这样定义对修改扩展不够友好,想象一下,如果要购买的商品有多种类型,比如上面的试卷商品和推荐试卷,难道又要做一次适配吗?就算只有一种类型,如果某天又对数据机构进行大改,这个服务也必须跟着改,不是吗?
这个服务不只有自己要实现的功能,还要关心是谁调用我的,怎么适配各种调用者,未遵循迪米特法则,造成业务扩散。
现在我们这样定义这个接口,buy( goodsId, goodsVersion), 如果你想做的更绝,让其通用性更强,可以这样buy( goodsId, goodsVersion, userId) 因为不同系统 全局变量userid的取法可能不同。结果又会怎样?
看到了吧,上面的两种情况扩展和大改,都不需要修改这个服务了,这个服务始终是稳定的——在你遵循了迪米特法则或单一职责法则时,自然而然的就满足了开闭原则。
如果你觉的这个例子发生的可能性太小,那么看一下我们的前端代码吧:写一个服务,传入业务实体作为参数,几乎每个服务都是这么写的,很惊讶吗?
按业务拆分,不按职责拆分,严重违背单一职责
用户交互 与 控制逻辑 相耦合,就是按业务拆分引起的直接后果。
比如说下架一个商品,需要弹出一个确认提示,根据用户的选择情况执行操作。如果写的好点的,大概就是这样的了,将整体抽取一个服务,服务是这样的。
public function(goods){
toaster({
……
}).then(function(select){
If(select) down(goods);
});
}
private down(goods){
……
}
开发时我们习惯这样想,上架商品的功能比复杂,就抽取一个上架商品的服务吧,于是就自然而然的成了这样。这样没问题啊,但界面交互属于表现view层,上架商品属于业务处理层,他们属于两种职责,不应该在同一个文件中。
也许你觉得我太苛刻了,的确,因为如果你写的服务只有这一处用到了,这样是不会出现大问题的,甚至你不抽取服务也是可以的,而且目前我们开发中抽取的服务,绝大部分确实都是只用一次(专用的)!
即使我们的服务都是专用的,这样写依然有问题,现在回想一下我们的开发。
弹出什么样的框,应该提示什么,这是产品经理说了算的,而上架的接口调用,是后台开发者定义的。任何一种变更,这个服务都需要修改,也就是说这个服务是很不稳定的;任何一种的变更,我们都能直接想到应该修改哪里,是表现层的弹框,还是核心层的实现,但是由于我们将这些代码写在一起,每次都要花时间去看一些无关的代码,需要花更多时间去找到代码。
没有人会注意这些细节,因为它引起的麻烦还不足以引起我们的注意。
其实正确的服务应该是这样的:
private down(goodsId){
……
}
这就完全可以了,至于那个弹框,谁调用就自己定义,本服务根本不用关心,也不应该关心。
想象一下,假如下架商品的交互有多种,有的不需要提示直接下架,有的提示信息不一样,有的提示后还要再做一些额外操作再下架,这三种情况都比较一下吧!
另外如果不断尝试,我们会发现保持这样做的另一个好处:服务基本是稳定的,而业务基本是整体性的,每一个模块的controller包含了所有的业务处理,交互处理,界面展现,当然因为你抽取了很多服务和组件,这种整体性的全面是抽象意义上的,整个模块的结构清晰的一览无余,却又非常简洁和容易掌控。
再来看一下我们的前台代码,有多少服务是这样的?很多人进入了一个误区,认为拆分就是要将业务打散,但我认为拆分更多的是为了让业务更抽象,更易于理解,打散业务反而让系统变得不易维护!
不要再按业务拆分了,按职责,隐藏其实现细节,保持业务的整体性!
总结
原本以为‘如何写出好代码’这篇文章不会花太多时间,可是现在用了3天(趁着工作任务不是很多,断断续续的写的)才写了一半,想表达心中的想法的确不容易,但关于抽取模块却已经表达完了,所以这篇文章就这样命名了!
回头看这篇文章,基本没有什么废话,里面的每一句话都是基于我在实际开发中的看到过程,了解到的常见的思考习惯,潜意识心态,以及我自己真实的感受。看了太多的不够优雅的代码,经历过产品需求被反反复复的修改,经历过后台业务架构颠覆式的重新设计,尝过了各种修改困难的苦果,可以说我从没有间断过对抽取模块这个问题的学习、领悟和探索,现在我用最简单的例子和话语将其表达出来,献给大家,虽然还是有些枯燥,但还是希望大家能认真读一下。
但我要强调的,也是最重要的,还是在后续工作时,多在实践中思考怎么让代码更好,只有实践中运用和体会,才能真正有所收获!