前言
作为一个从Java转去做大数据的开发,尤其是基于Hiv采用SQL的开发来说,抛弃了使用了很久的OOP,面向对象编程的设计思想后,总觉得有点不习惯。传统的web项目中,对SQL的使用更多还是在数据的增删改查上,而在大数据领域,更多复杂的数据分析,数据交并差的处理,导致SQL代码量急速增加,可维护性大幅降低。而SQL本身就是一个面向过程描述的语言,Java中常见的MVC,MVVP等设计模式也不适合套用在SQL身上。那么,是不是应该存在一种设计模式,适用于面向过程的编程设计呢?
带着这样的疑问,我开始关注面向数据编程。面向数据编程,核心在于数据。我希望数据可以变得更加灵活,方便开发者对它进行加工。同时,加工过程可以做到高内聚,低耦合。带着这样的需求,查阅了很多资料。直到有一次,无意中看到游戏引擎Unity3D采用的ECS设计模式,突发奇想,意识到这是不是可以满足我的需求呢?
关于游戏开发中的ECS简单介绍
ECS是Entity-Component-System三个单词的缩写。最早是在2002年的Game Dungeon Siege上被提出来,是为了解决游戏设计中,物体直接数据交互和性能的问题。
它在游戏开发中的演变逻辑可以参考这篇文章:https://zhuanlan.zhihu.com/p/32787878
简单的说,Entity、Component、System分别代表了三类模型。
实体(Entity):实体是一个普通的对象。通常,它只包含了一个独一无二的ID值,用来标记它是一个独立的对象。通常使用整型数字作为它的实现。
组件(Component):对象一个方面的数据,以及对象如何和世界进行交互。用来标记实体是否需要进行这一方面的处理,通常使用结构体,类或关联数组实现。
系统(System):每个系统不间断地运行(就像每个系统运行在自己的私有线程上),处理标记使用了该系统处理的组件的每个实体。
它跟传统OOP编程有什么不一样呢?
最核心差异点在于:传统OOP编程里,我们会先对编程对象进行虚拟化抽象,将共同的一类数据归到父类或者接口中,子类继承或实现对应的接口。在游戏开发中,父类往往是被锁死的,而一旦需要对逻辑作出修改,要么重写实现,要么继承基类进行覆盖。但游戏策划的创意是天天都可能会变化的。从而造成大量子类重复出现,大幅降低。此外,在对于C++语言中,使用对象池优化时就会造成灾难性的后果——一种类型一个池。
其次,从计算机底层数据传输上来说,传统OOP在传递数据时都是采用对象进行封装。但通常需要用到的数据只是对象中一两个属性。对于大部分web应用上来说,多读取的对象数据影响不大,但对数据密集型计算(例如游戏图像领域),则对性能会产生影响。
而ECS就是可以解决以上问题。ECS全写即“实例-组件-系统”的设计模式。简言之,实例就是一个游戏对象实体,一个实体拥有众多的组件,而游戏系统则负责依据组件对实例做出更新。
举个例子,如果对象A需要实现碰撞和渲染,那么我们就给它加一个碰撞组件和一个渲染组件;如果对象B只需要渲染不需要碰撞,那么我们就给它加一个渲染组件即可。而在游戏循环中,每一个系统都会遍历一次对象,当渲染系统发现对象持有一个渲染组件时,就会根据渲染组件的数据来执行相应的渲染过程。同样的碰撞系统也是如此。
也就是说游戏对象需要什么就会给自己加一个组件。而系统会依据游戏对象增加了哪些组件来做出行为。换言之实例只需要持有必要的数据,由系统负责逻辑就行了。由于只需要持有必要数据,因此对于缓存是非常友好的。这也就是ECS模式能和数据驱动很好结合的一个原因。
对于ECS在数仓建设应用中的一些思考
对于数仓建设,也是一个面向数据驱动的开发。因此我将ECS和数仓的代码联系起来,思考如何将ECS的设计模式在数仓中应用。我给出了以下的一些想法:
一个基本假设:
在数仓中,如果可以抛弃pk依赖后,一张表就是一群Schema的合集。
这是我对数仓中数据构成的根本假设。如果一张表里的其他Schema被PK约束,自然会导致Schema直接产生逻辑关系。如果没有PK,那么各个Schema互相之间是平等的,Schema之间可以互相组合。表只是由一个个的Schema填充而成的。这样听起来是不是很像Entity和Component之间的关系呢?
所以我大胆的列出一个映射关系。
与ECS的关系映射:
Entity对应于数仓中的Table,Component对应Schema,System对应数仓中SQL逻辑。
对于一张表来说,又若干个Schema构成。对于SQL代码来说,它关心的只是要用到的Schema,而不是表的业务逻辑。一张表可以由多个不同的SQL共同产出。所以依赖关系可以是这样的:
SQL只需关心它加工逻辑中需要用到什么Schema,产出什么Schema;Table只需要关心,它的业务逻辑是由哪几个Schema组成;而Schema自己只需要关心,自己代表什么原子含义。
ECS模式下的SQL伪代码简单实现
在SQL语言,我们一般代码会写成这样:
Select A1
From Tbale1
Where Condition1
A1代表我们需要的Schema,Table1是表,Condition1是需要满足的条件。
对于ECS架构来说,这样写违背了System不跟Entity交互的原则。理想的ECS实现是:
Select Table1.A1
Where Condition1
如果不同表中的Schema都是平等的,那么只需要指出使用的是哪个表里的Schema,和对应的加工条件。无需再将表名列入其中。
当然,有人会说,不就是多个From Table嘛,多写这一句话也不会怎么样。
是的,但大多数数仓开发中,并不是简简单单的一张表的处理。往往我们还会遇到很多表之间交并差的情况。这个时候,我们写的最多的代码是:
select t1.a,t2.b
from (
select *
from table1
where condition1
) as t1 left
join table2 as t2
on condition2
对于一个ECS架构,我们的实现是:
select table1.a,table2.b
where condition1 and condition2
这样看起来,代码是不是就简洁明了多了呢?(当然,现阶段SQL语法并不支持这种写法)
另外,我们在处理表数据的时候,经常还会遇到这样一种情况:
insert into tmp_table1
select a1,a2,a3……a31,a32,cast(a33 as bigint) as b1
from table
where condition1
inset into result_table1
select a1,a2,a3……a31,a32,b1+1 as c
from tmp_table1
inset into result_table2
select a1,a2,a3……a31,a32,b1+2 as c
from tmp_table1
从a1到a32 一共32个列名,其实是不需要做任何特殊处理的,只需要根据condition1条件筛选出来。之后我们又要带着a1……a32在两张结果表中进行插入。且不提这样复制粘贴列名操作十分麻烦,容易出错,就是我们是否有必要这么做?
我们的诉求可能只是修改某一张表里的某一列值,但不得不把这张表的其他字段反复提取插入。
根据ECS的设计思想,所有列值都是互相平等的。每张表(Entity)只是由列(Component)填充,Sql(System)只是负责逻辑行为。
那么,实际操作应该是:
insert into tmp_table1
select table.a1,table.a2,table.a3……table.a31,table.a32
where condition1
insert into tmp_table2
select ,cast(table.a33 as bigint) as b1
where condition1
inset into result_table1
from tmp_table1 add colum tmp_table2.b1+1 as c
inset into result_table2
from tmp_table1 add colum tmp_table2.b1+2 as c
(以上都是伪代码)
这样写看上去代码行数没变化,但好处是,如果table中结构发生变更,只需修改上层tmp_table1的结构即可,对结果表无感知。这一点上反而有点像OOP中的继承关系。
总结
思考将ECS设计模式引入数仓设计,本意是希望开发者可以更加关注于逻辑,关注数据如何处理,也就是S的部分。业务则由从列构建表的时候产生。将表结构和数据处理逻辑进行拆分,从而希望能提升SQL代码的可读性和结构性。
SQL本身是一个非常优秀的描述型语言,给数据处理带来了极大的便利。但在表结构越发复杂的今天,我已经感觉到传统的SQL的局限性。希望通过ECS设计模式的思考,可以大家带来更多的启发,可以让SQL代码像其他工程语言一样,简洁优雅。