在代码之前

正如标题,写这篇博文的目的其实是想提醒自己,在写代码之前其实有很多事情是值得深思的,可能你会说我们是“敏捷的”,我们就是要“快”,但“敏捷”并不是让你“快点”写代码,它是为了指导你提高开发效率,这并不冲突。最近在接触一个Event+DDD的项目,也会在这里反思一下。

本篇博文将絮叨一下这些事儿:

  • 我们为什么需要“*DD”
  • 前端与后端 - 事件驱动为例,Vuex
  • 又一些“感悟”

“*DD”

作为拥抱“敏捷开发”的你,一定狂热于各种TDD/DDD/BDD等等“驱动开发”的概念,你也一定在你的项目中实际运用了,并有一些或好或不好的评价。作为TDD一员的你,可能会有一些抱怨,认为你现在的工作是“本末倒置”的,你甚至有可能先写代码再写测试,你会觉得测试只是为了保证代码质量,实际上你只是在做“自动化测试”,而不是“测试驱动开发”。

回归到开发的本质,我们应该知道我们是面对需求的,

用机器学习作为一个引子,当下流行的一些分类算法都会有一个重要的环节——特征工程,在这个环节,你需要充分地理解业务,从现实中提炼出各种和结果有关的因素,再加以向量化。比如你在做一个《TA会不会买啤酒》的分类器,你现在已经有了一大堆用户数据,你可能会提取一些年龄、性别、最多购买的类别、购买时间段,甚至有没有买尿布,这样的特征。如果你不太懂机器学习,没有关系,我们并不是想说机器学习,只是希望你发现,这是一个在描述现实世界,描述需求的过程,在这个过程中我们运用“特征”这个概念来阐述我们的需求,然后再运用各种算法去构建代码。

这个过程是不是和你之前的工作模式很像,你会从你的产品经理获取到“任务”,它可能是一份文档,一份邮件,甚至是一句话(ummm...),然后你很开心地完成编码了,你把你的程序和需求都告知了测试人员,他们也通过了测试,最后你们把这些都给回了产品经理。

那么现在,我们将这个过程稍加改造。你会和你的产品经理(或者是代表)一起探讨需求,你们达成了共识,此时他会陪伴你完成一些预先事项——写一些测试用例,此刻你会和他一起将需求转换成另一些东西,比如

describe('啤酒购买者', function() {
	it('must 有一些钱', function() {
	  
	});
	it('should 会先买一个纸尿裤', function() {
	  
	});
	it('can 顺便买别的东西', function() {
	  
	});
});

这样的一个东西,方法里面是空的,当然是由作为程序员的你去实现。但这一步很关键,

你和你的产品经理,用了一些你看得懂,他也应该看得懂的符号描述了你们都想完成的事情

,从而也启发着你去进行开发。此后,你们会对这些用例排一下顺序,做事当然有先来后到,轻重缓急了。你会从第一个开始,以此为基点,构建一些对象,编写一些方法,比如

const assert = require('assert');
const RichMan = require('./model/RichMan');

describe('啤酒购买者', function() {
	it('must 有一些钱', function() {
	  let richMan = new RichMan(100);
	  assert.notEqual(richMan.getMoney(),0);
	});
	it('should 会先买一个纸尿裤', function() {
	  
	});
	it('can 顺便买别的东西', function() {
	  
	});
});

现在你发现,逐步地渐入佳境,你不在担心你的代码会不会偏离需求,你也不用担心你的微小改动会不会影响全局,因为

你的“测试用例”可以反映你的需求,同时又保证了你的代码

此后你会遵循这条轨迹来完成整个故事。实际上,这样也为什么我个人会认为最应该被单元测试的是——业务逻辑代码。

这里的举例是TDD,当然DDD/BDD也是类似的,将需求转换成一种业务人员和开发人员都能理解的格式,开发人员以此为基础进行迭代,不断完成完善代码,而业务人员也能将自己的心声表达得淋漓尽致。你会发现一个重点,作为开发的你,是和业务代表一起来确定这个基础的版图的,“敏捷”教会我们贴近用户,甚至一起工作,其实这也无可厚非(XD)。

读到这里,希望你,也希望我谨记,我们所做的一切都是为了更加贴近需求,将现实世界反应到代码世界,你可以发现很多代码本身是没有问题的,毕竟我们是有学位证的,之所以有BUG,是因为它不符合需求,或者与需求擦肩而过,所以需求的表达至关重要。从另一方面来看,其实你的代码并不是写给你自己看的,而是给你代码的继承者看的,如果只有你,你随便怎样都好,只要你看得懂,你改得动就好了,但实际上,并非如此,而他当然不可能一行一行读你的代码,如果有这些Test Case之类的东西,相信大家都会舒服一些。

“敏捷”也教导我们有勇气,无需束缚于传统的表达方式,如果能够变得更好,何乐而不为呢。

那么为什么我们需要“*DD”呢?

需求,是代码的起点


前端和后端

可能会有一些人,偏激地想要将前端和后端分离,认为做后端的,一般前端功力不怎么好,在这一节我想先将前端和设计分开,前端当然是为了给用户更好的体验,同等功能下你当然会选择使用一个酷炫的界面,而去嫌弃一个80年代的界面,但你不得不承认当下的web前端,需要承担的任务也更多了,你会运用Javascript去构建各种特效,甚至因为功能越来越多,你偏向于使用Vue/Angular这些库来完成你的工作,你可以发现除开HTML和CSS这些展现层的部分,构建Javascript和构建后端代码也有相似之处,当然了,在node.js的世界里面他们更加相似。

现在有一个简单的情景,小明作为一个用户,拥有一个虚拟账户,他可以为账户增加100虚拟币,之后他可以在审计年度余额。

在这里尝试用CQRS的套路简单解析,这个场景中的核心模型是账户,它是用户操作的根角色,这个模型里面拥有属性用来记录各种交易信息,执行额度更变的各种逻辑,另外还有一个专门用来进行审计的模型,它通过账户发生的改变来构建各种审计视图,当然包括了年度余额。在C端,我们有Account来完成额度变化的业务逻辑,在Q端我们有Bank来构建一些审计报表。

当然了这里是面向后端的一些设计,那么前端呢,不妨现在我们只是单纯地考虑有一个这样的页面,需要用JS+HTML+CSS来实现。

我们会有一个用户操作的子页面,它会发布一个增加100虚拟币的行为,你会有一个event bus,它知道完成这个行为,会分发意图到账户以及审计,以让他们变更各自的数据,进而表现在审计子页面。在Vuex的世界里面有着类似的设计,通过“单向数据流”的概念来维护状态的变更,来使得数据流更加清晰,易于维护,对于单页面应用而言可以拆分逻辑。这与事件驱动是由类似之处了。

写到这里,是想提醒自己,不论前端亦或后端代码都是需要设计的,我们应该通过需求,设计出适合的、易于维护的结构和流程。TDD当然可以用于后端业务编写,但也同样使用于前端JS代码维护(在Vue教程中也有一章关于单元测试)。事件驱动的概念可以让我们改造后端,来构建高并发的项目结构,也可以用于前端代码维护数据流。事实上,编写代码的思想是通用的,只是它适不适用于你的情景。

如何在前端应用事件驱动

前一篇文章已经说过了一些事件驱动在后端设计的适用场景,但最普遍的事件驱动应当是在JS的世界里面,你应该已经无数次编写过

document.getElementById('xx').addEventListener('click',function(){});

这样的代码了,这是一个标准的事件驱动,浏览器捕获一个有效动作后,通过发布事件来触发你预设的行为代码。那么就到这里了吗,应当不是的。手上正好有一套很多年前用JSP+Jquery写的小项目,改动是非常痛苦的,在这个项目里面很多界面效果是需要JS来完成的,但当时还没有MVVM的思想,很多时候数据和界面是通过Jquery捆绑在一起的,在改动界面的时候是非常痛苦的,你需要浏览大量的代码,才能确定你的变更有什么影响,以及为什么,当然也有项目小且简单的原因。

但到了MVVM普及的今天,我们也应该转换思想,首先将前端的数据层/业务层/展现层分离,再配合Vue/Angular等框架,将三者结合在一起,就好像你对后端代码的要求,易维护高可用,甚至低耦合高内聚。另外也得宜于网速的提高和打包工具的诞生,让我们有机会做这样的尝试。

我们会有一些模型来维护UI上需要的数据,我们会有一些业务代码用来响应用户操作,而Vue会配合CSS/HTML帮助我们将数据反映到UI。一切看起来很完美,但问题又来了,MVVM的世界里面,数据变更就会影响到UI变化,UI变化又会反映到数据,一不小心就会交错,难以维护。另外,单页面应用的兴起,让用户体验有了质的提升,但也意味着在一个window下面你需要维护多套数据,而这些数据可能还有各种关联。关联意味着耦合,是我们最不想见到的东西。回过头你会发现,浏览器就曾使用了“事件”这种机制,让你的代码和浏览器之间的耦合减少了。

现在你可以想象你拥有一个强大的Event Bus,他可以按你的想法帮你派发事件,你所要做的是要定义事件以及处理过程。那么你就可以在之前分层的基础上做一些横向的事情,你可以将你的业务分割成若干模块(比如账户和审计),他们只尽量关心自己的业务逻辑(账户校验、报表生成等等),当然也拥有自己的数据模型。此刻按照DDD的思路,他们已经是根领域了,事件是一个比较好的外界交互方式,这样可以做到解耦,因为他们关心的不在是对方,而是事件。

你的代码可以不再那么错综复杂,基于事件的单向数据流,让你的行为结果有迹可循,你所要做的则是要谨慎管理事件,正如Vuex的教程中,你应该有一个常量集来管理意图名。

在这里推荐在单页面应用中使用Vuex这类状态管理框架,它们本质上是事件驱动的,通过事件驱动的方式维护了单向数据流。让你的单页面应用可以真正在业务逻辑上做到分离,肮脏的耦合已经被Vuex和一堆Action/Mutation取代了。而你的前端代码也可以像后端代码一样,做到层次分明,甚至你有了单元测试的余地,因为你有了真正的业务逻辑层。何乐而不为。


Others

代码的世界需要理性;颗粒度

极致的面向对象应该是对现实世界的描绘,但我们应该保持理性。你当然可以认为钱是一种对象,但在一个简单的单虚拟币管理业务里面,你的钱只是数字,其实此刻你没有必要对它进行抽象,将它作为一个属性即可。但如果你的世界里面是有多种货币的,那么钱应当是一个单独的概念,因为你会有各种币。我们应该抽象概念,但也应该注意颗粒度,也就是适不适合的问题,一旦发现了有阻碍的地方,应该警惕是否颗粒度需要调整,如果不调整会不会有更多的问题。

这些年也广泛流行着微服务的概念,同样也是颗粒度的问题,我们应该保证我们的应用承受合理的负载。如果你是Java,你当然可以使用强大的线程机制去启动各种CPU运算的任务,但如果你是node.js,你应该思考单线程的机制下面,它是不是做得完,而我们是不是应该分割一些独立的任务到另一个应用,使得他们能够充分利用CPU。

我想代码的设计,也不一定是100%遵循现实世界的,你想要模拟一个人在代码世界里面,但人也是由各种元素组成的,你可以从“组织”/“器官”去解释人,但你也可以从“神经元”/“细胞”去解释,一切取决于你适用的颗粒度,以及你能支付的资源。

低耦合高内聚,是我们不变的主题,适当的拆分,能让我们更加容易的组织代码世界。

代码之前需要先思考

我们当然知道不可能考虑到所有的情况,在代码的过程会有各种各样的问题,但我们也不应该放弃在编码之前的设计,一个初步的设计好比基石,站在坚固的基石上面肯定会更有安全感,但它也只是基石,点到为止。没有经过设计的项目而导致后期难以维护,需要重建的例子,相信大家也或多或少见过了。

编辑于 2018-12-28

文章被以下专栏收录