软件模型:从数据流视图到对象视图

软件代码可以分成两个层次:领域层和技术层。

领域层:描述软件所满足的业务需求。核心业务逻辑在这一层抽象和表述

技术层:代码实现层面,是编程语言实际运行的载体,程序员每天工作的第一手材料。

 

软件模型:从数据流视图到对象视图

 

领域层描述现实生活,是宏观视图,相对的,技术层就是微观视图。正如牛顿力学定律描述宏观物体的运动,而量子力学展现了微观世界的另一幅截然不同的画面,技术层和领域层许多时候应采用不同的思维模式。

面向对象视图在过去20年里的实践证明其适合大多数企业级应用的业务逻辑层开发,在领域层采用此思维方式是合适的。这个领域的经典方法论是领域驱动开发(DDD)。

技术层是否适合采用面向对象视图呢?我认为许多时候不合适。

经验丰富的程序员们,回想我们每天编写的代码,除了业务逻辑,剩下的就是一般人看不懂的技术元素:设备,文件,网络,通信,线程,界面控件,内存数据……等等。处理这些技术元素的代码,在许多业务简单的系统上,甚至占了代码的大部分。这些并不适合用对象来表述。

现在我们来返璞归真,凭自己的直觉来写代码——具体实现的编程语句。

语句是一行一行写的,声明,读取,计算,赋值,间或有分支,循环,中断,跳转。

思路顺着语句顺序向下流动,程序也是按照这个步骤一步一步,带着二进制数据向下传。这是一副小河潺潺流水,大河奔腾向前的图景。

如果我们的程序能像这片山河般井然有序,安定祥和,那该多好。哦,山河……,不好意思,我站在宏观世界的角度去描述微观世界了,应该将自己缩小到分子原子电子的尺寸下看世界。

原子世界的规则是多么简单,他们仅用屈指可数的几个元素,定律,便缔造出宏伟壮丽的世界。世界的本原到了这里,豁然开朗,原来规则如此简单,再也不会烧脑,掉头发了。那我们就用少数的几个元素——设备,文件,网络,线程等——来描绘一个新世界。像乐高积木一样,红橙蓝绿,方块圆柱,几种原材料,随意组合,无限种可能性,我们的生活就丰富多彩了。写情书,用货币到超市买东西,看足球比赛,在高速公路飙车,4甭管有多少伦理规则,在微观世界里,他们的规则就那么几种。看吧,我们程序员就是艺术家——咦?接在水龙头上的水管,另一端怎么接在电脑主机usb接口上了!

上面的例子我们就明白了一个道理:在不同的层面,需要以不同的颗粒度来看问题。毕竟水管的原子是正物质,主机接口的原子也不是反物质,接起来符合自然定律,不会堙灭。但宏观层面上,程序员是无法依靠这个装备糊口的。

领域层——物理意义上的宏观图景,一个个领域各自有各自的规则,适合封装在对象里,降低设计的复杂性——但依然很复杂。毕竟,技术层面的要素——设备,文件,网络,通信,线程,界面控件,内存数据……就那么几种,本来就比对象世界简单。

 

一、技术层

 

我们实际编写代码时,思维和数据顺着代码语句前进,恰似一江春水顺着河道,管道流,技术层上,这样的视图更符合代码编写者的直觉——数据流视图。

接下来的篇幅我们先抛开领域对象层面,不关心水管是否接到USB接口上,单纯从技术层阐述编写代码的一些方法。

从数据流的角度看,软件的运行过程,便是数据从技术层面的几种要素中流入流出的过程。尽管不关心水是不是灌进机箱了,但还是要关心数据流向错误的要素里。让我们一步步来梳理数据流,防止他们到处乱串。

软件模型:从数据流视图到对象视图

 

软件模型:从数据流视图到对象视图

 

 

首先是数据的载体,他们是数据的来源和目的地,常见的有设备,视图,网络,以及内存数据。

其次是管道,管理数据的流向,取出数据,转换数据,输出数据,存储数据到载体。管道的处理是重要的。最常见的错误就是在错误的时机取出,转换,存储数据。

函数a取出数据x,随后调用函数b返回数据y,函数b在返回y之前也更改了x的值,函数a根据x和y计算出z输出。如果函数a并不期望b更改x,那么这个事务就是在错误的时机取出和转换数据。错误的风险在于x可能会被更改,特别是x是来自于函数之外的数据,这就是副作用(side effect),如果多个地方同时依赖x,冲突就可能发生了。纯函数(pure function) 则是安全的,如果一个事务里都是纯函数,基本上就不会因为时机不恰当所造成的逻辑冲突。

所以我们的代码应该尽可能多使用PF,少使用SF(side effect function)。但改变软件的状态终究免不了要输出副作用,那么至少我们要对这些副作用操作reduce(收集)在一起管理。没错,就是redux中的reducer。与此类似的还有ReactiveX系列的pipe(管道)。

什么?!还需要去学redux,ReactiveX这些庞大的框架?学不动了……我也是。我的项目也很小,用框架是牛刀杀鸡,更何况代码已经写的差不多,重构成本有点高。

接下来的篇幅主要介绍笔者自己的代码实践中如何在无框架的情况下应用数据流的思想。如前所述,我们需要一个管道,如果数据是水,水流管理不好,从水龙头到洗衣机的路上,你家的地板上恐怕已经水漫金山了。管道的作用就是约束流,除了入口和出口,水只能沿着管线单方向流动。入口收集(reduce)流,出口泽被世界。

无框架的情况下,如何构建管道——很简单,就是一对大括号,没错!就是函数声明之后包裹代码块的大括号!管道的入口就是启动函数的前几个语句,这几个语句集中从函数外部取数,赋给函数的局部变量。之后,函数内的所有操作,都只能来源于这些局部变量。在函数的末尾,集中执行有副作用的操作,这就是管道的出口。

 

启动函数(){

   取出内存数据x;

 

   y1=设备状态处理(输入x);

   y2=视图数据(输入x);

   y3=网络请求(输入x);

   y4 = f(x)

 

  更改视图(x,y1,y2,y3,y4);

  持久化(x,y1,y2,y3,y4);

  更改内存数据z;

}

 

函数的中间部分处理加工数据,加工的数据来源于前面的局部变量,加工后的结果用于接下来的副作用语句块。函数里面可能也会调用其他函数,这些子函数也是管道,是主管道的支管道。支管道的数据只能来源于主管道,也就是只能来源于自己的参数。

 

软件模型:从数据流视图到对象视图

以下是笔者总结的一些原则

 

纯函数(PF)

1、只产生数据,不修改数据;

2、只有启动函数可以从函数外部读数据,纯函数的数据来自于参数;

限制启动函数内部的PF只能读取参数里的数据,方便阅读者追踪数据来源。

 

副作用函数(SF)

1、数据来源于启动函数和内部PF的输出。

理由同上,方便阅读者追踪数据来源

 

2、副作用语句(包括调用的副作用函数)集中在函数语句块的末尾。

3、SF不能处于中间步骤,比如SF->PF->SF。

除了下面第5个原则所说的情况一般,到目前为止笔者尚未遇到必须把PF插在SF和SF中间的情况,出现这种情况,请思考是不是自己的设计有问题。如果PF必须出现在这个位置,应该将PF和相邻的SF合并成一个SF,当然,合并的二者应该有逻辑上的相关性下。没有相关性?十有八九是你设计的问题了。

 

4、同一批事务中各个SF不能改变相同的外界数据。

前面有提到技术层的几个基本要素,在此作详细的解释。

设备:如获取网络状况,ip地址,地理位置等等设备相关的信息;

视图:界面控件的属性,如输入框文本,控件的可见可用性;

网络:网络CURD请求调用的相关操作;

内存数据:诸如在当前组件对象上声明的各种基础类型变量,自己定义的类实例,这一块是个大杂烩,后续研究会区分更多不同的类型出来。

依笔者的经验,以上四个要素在写代码是可以放心隔离的,彼此不会有什么依赖。在启动函数中,这四个要素的代码可以各自集中在一处写,也可以抽离到各自的函数里。写代码的时候像这样有意识的归类,就尽可能保证各个SF不会改变相同的外界数据。如果有两个SF有相同的副作用,很可能他们在逻辑上时有相关性的,应该并入一个SF里去。如果觉得彼此在语义上并不合适并在一起,很有可能是你的上一层事务的设计有问题。

 

5、同一批事务中,如果不得不在中间步骤调用有副作用的网络操作,领域模型,尽可能只在这次事务中只调用一次有副作用的网络/领域模型

许多时候,网络操作和领域模型有自己内部的业务逻辑,通过他们获取数据的同时,内部执行了副作用。当然,你可以重构网络操作和领域模型,在他们内部拆分出PF和SF两个操作,以符合前述原则。但有时候基于性能或其他因素的考虑,更倾向于合并执行,那就尽可能只在这次事务中只调用一次他们,如果多次调用,就有冲突隐患。

 

领域模型

1、领域模型是对业务逻辑的抽象,一般采用面向对象的思想,不必遵循函数式编程规范

2、模型适配器是领域模型和技术层的中介,领域模型一般不需要考虑技术层的需要,技术层使用领域层的数据可通过模型适配器进行转换

 

在数学上,两个矢量互相垂直,则称他们正交。正交的两个矢量相互不受影响。借用这个概念,如果两块代码相互不受影响,则称两块代码正交。显然,如果彼此没有引用相同的变量,那么他们是正交的;就算有引用相同的变量,如果二者是两个不同的事务,如果两个事务独立运行,那么也是正交的。

上述的原则,就是为了尽可能正交化多个代码块。当然,原则本来就是用来违反的……软件开发没有银弹,机械的遵守原则,有时候反而变成邯郸学步。原则不是禁令,是尺子,是灯塔,当发现自己的代码明显丑陋时,可以借他衡量好坏。

到此本文已基本阐述完面向数据流视图的技术层代码的编写规范。下面简单阐述从技术层到领域层过渡的一些话题。面向对象编程是一个庞大的话题,不在本文的论述范围内。

 

二、领域层

 

水分子只是上下震动,彼此前后搭配的节奏,就造成了水波前进的假象。

前面技术层的数据流视图,又该按照什么节奏,演奏出对象世界的图景。我们可不愿意水管真的接到USB接口上,跑路是不负责任的。

领域层使用对象来描述业务,这些对象,在技术层以内存数据的形式存在。切记,在技术层你始终要将领域对象视作数据源,如果你编写的代码属于管道,在管道中出现对象之间相互通信的行为,很容易就违反上述的原则。

有些时候,一个组件的业务逻辑十分简单,比如只有一个文本框,文本内容构成我们的全部业务逻辑,这时候是否需要将文本变量封装到一个对象里呢。在我看来 :

class Model{ string textvalue; } 和 string textValue 都是业务数据,不必过度设计。封装成对象,和前述的代码块,都是为了从语义上区分代码文本,降低开发者思维负荷。如无此收益,封装即冗余。

模型适配器也不是必须品。只有当领域层的数据并不直接满足管道的需要时,才需要另外编写一个PF或者代码块转换一下数据格式。一般情况下,管道可以直接在领域对象上存取数据。

把业务逻辑封装在领域对象里,只暴露数据接口供技术层元素存取,领域层和技术层上下同心,各司其职,一副和谐的世界由此诞生。