机器学习平台架构实践--面向对象设计

0 / 1155

面向对象的程序设计思想多年来一直是我们进行软件设计的有效的指导思想。由于我们天生理解大自然的机制就是面向对象的(比如我们到了某一个商店,我们会看到门店、售货员、货架、货架上的货物等等,这些都是一个一个的对象,我们认识整个商店也就是去认识商店中的每个对象。),而面向对象程序设计思想恰好与这一机制相一致,所以一个面向对象设计做得好的系统就很容易为我们所理解。

对于一个机器学习平台,应该如何实践面向对象程序设计思想呢?

面向对象的抽象

回顾前面两篇文章的内容,机器学习平台具备这样的架构:

系统架构

看了这个架构,一些重要的对象就自然的浮现了出来。比如数据集(DataSet)、Pipeline、实验(Experiment)、任务(Job)、周期任务(PeriodicalJob)、服务(Service)等。

经过前面关于微服务架构设计的分析,我们可以发现,随着系统的功能越来越复杂,这里的几个对象事实上都逐渐的发展为了某个大的模块或者某个候选的微服务。尽管如此,它们依然是这些模块或者微服务内的核心对象。

这里的对象抽象看似简单,但是如果缺乏经验,经常会出的一个问题是将这些对象与数据库存储的表强耦合在一起。这里的强耦合的表现就是:

  1. 从设计数据库开始,而不是先从面向对象设计开始
  2. 直接使用ORM框架生成的对象作为核心对象使用
  3. 直接使用ORM框架生成的对象操作器(如Spring的JPA中由接口继承而来的XXXRepository)作为领域服务来使用

使用框架生成的代码通常看起来已经可以完成大部分的工作了,这是一个很大的诱惑,如果我们没有保持克制,随意的去使用这些已有的功能,就会使得我们的代码大量的被ORM框架技术所侵入,从而变得臃肿且笨重,难以适应业务的变化。

举一个例子。我们都知道,ORM框架会建模对象间的关系,并在读取关联对象时自动生成一条查询语句到数据集中提取关联对象,这个特性非常好用。但当我们在操作一个比较大的列表时,这样的默认实现就可能会导致非常多的关联表查询发往数据库,这不是我们想要的,因为它会带来极大的性能影响(好的实现是一次性将所有要访问的数据取出)。更有甚者,如果我们的关联对象本身又有很多其他的关联对象,而我们还将其提取方式设置为了eager,那就会导致更多非预期的数据库查询发生。

在机器学习平台这个系统里面,Job将会关联Pipeline,而Pipeline又会关联其拥有的算子节点列表(PipelineNode),一旦我们想要获取一个Job的列表,并显示与其关联的Pipeline的节点PipelineNode的数量时,上面的问题就发生了。

由于ORM框架会诱惑程序员去使用这些便捷的功能,如果团队中有人没有意识到潜在的性能问题而随意使用,上述情况就会频繁发生。

如何避免这个问题呢?其实很简单,我们只需要把数据库或者ORM框架当做一个实现细节来对待就行了。既然是一个实现细节,在设计阶段就不应该做过多的考虑,甚至可以完全不管。那么进行面向对象系统设计的步骤就应该是:

  1. 抽取对象,为其选取一个好的名字
  2. 设计对象的属性和行为(只要能解决当前需求就行,后续根据需求渐进式的添加)
  3. 根据业务需求,设计对象的持久化服务对象及其方法
  4. 根据业务需求,设计业务级服务对象(以业务操作来命名,避免直接CRUD

Job为例,上述每一步的输出可以是:

  1. com.xxx.ml.platform.job.model.Job
  2. 如:Job.isRunning Job.isSparkJob Job.markAsFinished
  3. 定义JobRepository接口,及其行为比如:JobRepository.findById JobRepository.findByStatus JobRepository.save
  4. 定义JobService业务服务对象,及其行为比如:JobService.createJob JobService.terminateJob

完成这样的设计之后,我们就获得了一些单纯的业务对象,而非一个个数据库表的直接映射了。那么数据库表的映射要如何完成呢?以Job为例,我们可以基于ORM框架很容易的实现一个JobRepository。首先我们要新建立一个对应的数据库实体映射对象并生成对应的数据操作对象(如JobDAO),然后将JobRepository接口的实现部分代理到JobDAO的接口,再完成数据库实体映射对象到业务对象的拷贝就完成了。

有人说,这样岂不是多写了一个模型,还得多写一些对象间拷贝的代码?是的,从最终结果上来看,确实多写了一个模型来完成ORM框架所需要的数据库映射,但是这个模型的代码其实非常简单,它不包含任何的业务逻辑,只是单纯的数据。对象属性拷贝的代码更是简单,甚至可以用反射机制来自动完成。

这里的少量新代码是值得的,因为它给我们带来了巨大的灵活性,避免了我们的系统直接依赖于某一个特定的ORM框架。这些灵活性比如:

  1. 可以轻易的替换ORM框架
  2. 可以轻易的替换存储实现,比如从关系型数据库替换为MongoDB这样的文档型数据库
  3. 对象的属性可以允许与数据库字段有差异,比如当对象A中的某个属性b是一个其他对象B的实例,而B又不值得新建一个数据库表来存储时,我们就可以手动的将b序列化为一个Json格式的数据在数据库中进行存储
  4. 可以灵活的使用业务语言进行命名,这也就避免了ORM框架的命名规范侵入到系统设计里

领域驱动设计方法在当下已经成为了一个指导我们进行设计的重要方法,其中推荐的一个实践就是"领域对象不应该有除标准库外的任何依赖"。采用上述面向对象设计的做法就可以实现核心对象(这里的核心对象其实就是领域对象)没有任何除标准库外的依赖。

识别扩展点并进行面向对象抽象

机器学习平台最大的扩展点莫过于算子和算法模型了。从业务的角度出发,应该支持尽量多的算子和算法模型,以便开发机器学习模型的工程师可以在进行特征处理和算法选择时开箱即用。

按照前面的面向对象设计思路,这里的核心对象可以是OperatorAlgorithmModel对象。同时,我们将支持不同类型的这两类对象。比如对于下面这样的特征处理流程,里面就涉及归一化 分桶 独热编码等等算子。

Pipeline Demo

此时,从面向对象设计来看,一个自然的想法就是抽象出一个继承关系了。于是就可以得出下面这样的继承树:

Operator
|-- Normalization
|-- Bucketing
|-- OneHotEncoder
|-- MultiHotEncoder
|-- ...

对于AlgorithmModel,同样可以得出类似下面这样的继承树:

AlgorithmModel
|-- DecisionTree
|-- LinearRegression
|-- GBDT
|-- DeepFM
|-- ...

到这里,很容易产生的一个疑问就是:这些对象如何存储到数据库里面呢?如果我们没有从面向对象的角度出发进行系统设计,而是优先考虑如何在数据库中存储这些数据,那么我们很有可能就只是得出了一个OperatorAlgorithmModel对象。缺乏继承树抽象将给系统带来非常大的问题,可以预见,不同的算子或算法模型将存在差异(如不同的算法需要的参数不同),如果只有一个OperatorAlgorithmModel抽象而没有继承树,处理这些差异的代码就只能放在OperatorAlgorithmModel对象中。这将导致OperatorAlgorithmModel的逻辑非常复杂,最终导致难以维护的代码。

那么,当有了继承树的时候,如何将这些数据有效的存入数据库呢?难道要对每一个子类都建立一张数据库表吗?其实完全没有必要,由于数据库存储只是一个实现细节,那么数据库里面可以只有operatoralgorithm_model表。我们可以在超类OperatorAlgorithmModel中定义一个抽象的获取需要保存的属性的方法,这个方法将返回一个列表,由子类实现,就可以了。

既然算子和算法是系统最大的两个扩展点,我们就需要让添加算子和算法变得足够轻松。有了上述面向对象的抽象,我们就可以将很多公共的逻辑放到父类中去实现,这样添加一个一般的算子和算法就可以很快实现了。

从这里的分析可以发现,好的面向对象设计给系统带来了巨大的灵活性,它使得系统非常易于理解和修改。我们的平台也正是因为建立了这样的设计,才得以快速的演进。

使用TDD辅助进行复杂的模块设计

除了上面简单的面向对象设计,整个系统中还存在一些比较复杂的场景。比如,对于API模块而言,它的作用是作为一个后