范融:复旦大学软件工程系毕业,曾在金融行业服务多年,对于企业级信息系统、搜索、数据分析具有丰富经验,多次获人行奖项。
目前就职于 UCloud 参与AI 产品的研发和运营工作,其所在团队开发的AI在线服务 PaaS 平台在 2016-2017 年可信云大会上获得了AI行业云服务奖。
前言
大家好,我是来自 UCloud 的范融,非常有幸在这里跟大家做一些 GO 的分享。大家使用 GO 的时候可能会有很直观的印象:在其他语言里面(比如Java)里面都是 Class,而对应的 GO 里面都是 Interface。这个数据类型对我们开发者来说是有很深的影响的。今天给大家分享我们在开发过程当中,应用 Interface 对于我们平台开发有哪些帮助?我们能否从中获得一些启发?今天的分享主要分为这4个部分:
一、UCloud 人工智能平台演进之路
二、分层规划使合作更顺畅
三、面向接口使迭代更灵活
四、Mock测试使系统更稳定
一、UCloud 人工智能平台演进之路
UCloud 人工智能平台的由来
首先介绍一下 UCloud 的人工智能平台诞生的背景,2016年的 AlphaGo 打败了李世石,AI 火了。无论是传统企业还是创业公司,想要跟风投要投资,几乎都会谈到AI,随之涉足AI领域的企业也越来越多。UCloud 是一家2B的创业型公有云公司,需要服务于一些传统行业的公司,因为 UCloud 原本是有很多比如云服务器物理机、云存储等等的 Iaas 的产品,为了做好这个服务,UCloud 希望能够建立 AI 的 PaaS 平台,可以兼容我们在AI开发时层出不穷的算法,然后也有各种的深度学习框架。它对于用户来说是个开箱即用的环境,既可以向上兼容各类算法环境,又可以向下屏蔽各类基础的平台资源(如 CPU,GPU 等)。平台可以通过云厂商对于硬件资源的不断优化,我们的用户可以自然而然享受到性能的红利。最后,通过云上共享资源为用户提供便捷易用的从训练到推理的一站式托管环境,让用户专注于算法,降低AI的准入门槛。
UCloud 在搭建这个平台的时,为什么会选择 GO 语言 ?
UCloud 在搭建这个平台的时,为什么会选择 GO 语言?我觉得有这几个原因:
1、GO 并发和支持强大,适用于服务开发,对于 PaaS 平台来说用的比较多的是后台服务,GO 语言开发非常合适;
2、和特性相关,面向 Interface 支持灵活迭代,因为我们在做AI平台的时候, AI 的技术在飞速的发展,算法可能几个月就有新的论文出来,深度学习的框架,现在 Tensorflow 已经到了13版本了一直在更新,包括底层的技术在更新,就意味这我们在搭建平台的时候不可能像其他传统行业的软件可以先定好一个规划图,跟着规划步骤一步步的走,而是开发平台的时候每一次迭代都随着未来不确定的AI市场的变化进行变化,所以说 GO 其实面向接口的编程,面向Interface的编程是异于每个版本的迭代的,这个对开发来说是很好的红利;
3、我们这个团队成立之初人员较少,鉴于这点,这个平台如果是一个 PaaS 平台,用户来自方方面面,我们没有很多运维的人员针对不同的系统上出现的 BUG 进行修复,然而 GO 提供了很多的自测工具,比如像 go test 和 gomock 这样的一些工具,方便我们在后台的服务开发阶段时就对各种环境进行监测,覆盖各种各样的代码情况。所以,这些就是我们选择 GO 语言的理由。
起初设想 UCloud 人工智能平台是这样的
2017 年我们来建立这个平台时,我们想的很简单,大概是下图这样的框架。首先是对AI来说有两部分,一个是 AI 训练平台,另一个是 AI 在线推理平台。对于高校或者个人做AI训练只要使用AI训练平台就够了。但是对于 2B 的企业,最终 AI 的模型是要对应网上的 WEB 的服务的,因此提供了可以提供负载均衡,灾备容错的在线服务平台。
对于这两个平台,我们希望对底层做一个屏蔽。在算力方面,我们 UCloud 目前是支持 CPU,GPU,KNL 的各类机型,其中 KNL 是微软的至强芯片对 AI 计算有很好的加强;在存储方面,由于一开始就考虑到对于后端的存储是面临很多类型的存储,像 S3,NFS,网络云盘这样的存储,不同的存储接入的方式不一样,背离了我们的做一个平台希望对用户屏蔽的初衷,所以做了一个 Datastore 的存储接入层。无论在云上使用的哪一种存储形式,通过这个 datastore 接入到容器里面具体的程序以后都要本地文件一样读取,这个转换就是 datastore 做的。同时,这个 datastore 还可以再数据访问安全和流量控制上进行管控。
对于两个平台的上层,我们提供统一的 API 接入层,可以通过 restful 的接口与外部通信。同时,为了满足不同用户的使用需求,我们提供两种接入方式:一种是图形化界面,在 ucloud 官网可以进行点选的操作;第二个是 python sdk 为企业级自动化接入服务,用户可以在自己的业务逻辑中内嵌对于 UCloud AI 服务接口的调用。
以上是一开始规划的场景。
随着用户需求小快跑
当我们这个平台在 2017 年 5-9 月时,公测推出这两个平台以后,用户使用之后提出各种各样的需求,我们基本上一个月一个小迭代,下图列的是比较大的突破,比如支持了 GPU 的分布式训练,达到最多是 8 台机器分布式训练,每天机器可达到4张显卡,也就是 32 张显卡同时训练的算力。随着图形计算的网络越来越深,应需求增加了 GPU 计算节点的在线推理服务。接着应行业内传统企业的需求,出于数据安全考虑推出私有云的版本,支持企业自己使用的 openshift 的调度,存储采用私有的存储。随着在线服务的推广,为在线推理提供了公网的鉴权和访问流量控制的支持,使其用户适用群进一步扩大。
2018 年,她慢慢成长为这样
这个平台版本迭代非常快,2018 年它慢慢成为了和用户磨合出来的后续迭代演进的框架。首先增加了日志监控模块,支持用户看到实时的训练状态,同时针对不同情况支持消息流、搜索引擎不同的日志访问形式。其次是计算节点接入模块,在做了私有云后,由于对调度有各种各样开源的要求,这个接入模块能够不同调度框架,整合公有云我们自研的调度和私有云的各类调度。最后,为了满足私有云对于企业定制的需求,我们在 API 接入层把私有云切分出去,方便事业部做对于企业高端客户的定制。
然后大家也可以看到,从 2017 年到 2018 年架构演进的变化还是比较大的,由于我们前期对于这个系统做了一个基于 Interface 的拆分,所以我们的合作和开发来说在团队的成长过程当中应该是非常顺畅的。
二、分层规划使合作更顺畅
挑战一:代码包依赖多
首先讲一下分层的规划对于我们团队的好处,在做这个平台的时我们发现,之前 UCloud 是主要产品都是偏 IaaS 的,但是我们这个是 PaaS 产品,我们发现需要要关注和第三方接洽的非常多:比如说下图列出来的,除了自己的训练平台和在线推理平台模块进行通信,要对于后端各类第三方开源项目(如 Redis, Kafka,Zookeeper 等),要跟 UCloud 自己的公有云的产品进行融合(比如说这些 U 开头的都是 UCloud 的自己公有云的产品),要满足私有云定制化产品的访问需求。这些复杂的依赖关系,导致对于整个代码的框架来说要求会比较高,我们怎么样设计能够让后续尽量少的改这个代码框架?能够延续下去?首先我们要做的一件事情肯定是先这些内容分类,给它着色,黄色的是训练平台和在线推理平台专有的代码,灰色的其实是开源第三方的底层框架,蓝色的部分是我们业务上需要用到的其他产品。
挑战二:组件多
有了这个分类之后,还有一个小问题,组件多了你发现每个功能模块都写成一个工程项目会很困扰。举个例子,训练的有两个模块,一个 API 服务,另一个是管理模块,如果是两个分开的工程管理,对下面的所有的引用这个代码都要重复的写,另外像 Kafka 经历过版本升级,这两个升级的版本是不是同步了,这又是一个问题,这个问题怎么样进行管理的呢?
解决方案:金字塔结构,分工明确
后来我们定了这样的开发的框架,就是金字塔型的,最底层有基础代码层,就是 Uaiframework 把第三方所有的基础的开源库囊括,在上面会进行包装和优化,比如链接池管理,统一日志管理等;再往上是共享代码层,这一层我们将公有云和私有云的产品访问包装在这一层,第二层上建立的单独的工程是 Uaicommon,可以将产品错误码、产品间访问的输入校验放在里面;最上面才是我们自己真正开发的两个主要工程,一个是 UAitrain,另一个是 uaiInterface,通过整理之后我们发现,原来网状的引用就会变成非常整齐自上而下的引用,这第一层会引用第二层的内容,第二层会引用第三层的内容,不会有跨层次引用的情况。
优点
这样做的好处比较明显,第一个是按照层次我们对代码是有一个定义和封装的,职责比较明确,引用的路线也比较明确。当我们需要增加一个功能时,我们只要分清这个功能是产品自身代码,还是公有云、私有云产品交互代码,还是第三方开源库代码,就可以简单找到功能所在的层次。另外,在最底层第三方增加了一个新的开源库就可以找到对应的工程进行更新,而且更新之后所有开发人员的代码都是统一更新不会有版本的错位。
第二个就是解耦,开发的模块非常的清晰,如果底层人员开发公有模块只需要对公有模块进行负责,因为是通过对 Interface 的定义,所以只要保证 Interface 接口的定义签名是准确没有变化的,底层逻辑修改和更新都不要紧,最后是结合接口便于测试。
三、面向接口使迭代更灵活
挑战一:后端类型不断拓展
代码分层以后,每个层次的迭代更新是日常研发需要面临的问题。对于日常研发,第一个挑战是后端类型的不断拓展。在数据元方便,从 S3 的存储扩展到 NFS、网络云盘,每新增一种类型就需要新增对应的适配模块;在调度方面,从自研调度,UCloud 产品 Uaek,再到开源 openshift;在日志管理方面,有 kafka, elastic search。各类后端都根据需求有自己的演进路线,如何在各个模块自我演进的同时保持整体系统的稳健?
挑战二:版本功能升级
还有一个避免不了的情况就是版本的功能升级。比如之前已经规划好的数据结构,但随着业务发展发现数据结构需要拓展;原来只是支持单一的计算节点,但是后来发现要增加异构的各类计算节点;原来设计的功能只支持单节点训练,后来拓展到分布式训练。这些功能随着业务发展,颠覆了原来的设计初衷,如何在代码框架上兼容这些功能的拓展?
解决方案:接口+工厂模式
上面说的这些例子对代码的改动都是非常大,我们不希望每次重构代码,所以基于 Interface 和工厂式的代码结构就帮助我们维护住了整个代码的框架,每次修改只是修改一个非常小的模块。能做到这一点主要是因为 GO 的 Interface 有一个特性:只定义接口不定义实现。GO 相比 JAVA:JAVA 的实现是在事先的类里面主动的声明,如果说Interface功能有更新,需要把所有的代码全部改一遍;但是对于 GO 它的 Interface 是一种主动的查找模式,定义 Interface 的时候并不知道哪些具体的struct会满足条件,但只要某个 struct 绑定的所有 Interface 声明的方法,那这个 struct 就是 Interface 的一个实现了,这个动态的绑定是非常好的特性。
再结合工场模式,有一个 Interface 下面有两个 struct,工场模式是有会有写一个统一的 Interface,这时会有一个 type 或 param 这样的参数,这个参数后面会决定到底是画出来一个 structA还是 structB。这个选择会有所延迟,对于上游引用下游接口来说,不考虑 Interface 实现了两个版本还是三个版本,只要调用一个统一的版本的 newInterface 就可以了,到底是新版本还是老版本,是 structA 还是 structB 这个是下游的定义的。
案例 1 :解决后端类型不断拓展
下面是两个具体的例子,第一个例子是:我怎么解决后端的类型不同?我们把这边的代码做了抽象,把业务逻辑全部剥离掉获得最简单的代码,大家拷贝下来是可以运行成功的。比如说有后端 FileBackendInterface,这个 Interface 要求实现 Download 方法。接下来实现两个 struct,BackendTypeA 和 BackendTypeB,但是对于A和B来说 Download 方法中的具体实现是不一样,这里为了演示方便,就打印了一句话,但是打印的话的内容不一样。有这样的结构之后,我们还需要做的一件事情是为刚才的工场模式是做一个注册项,这里我定义了一个注册项,其实就是一个字典,把 BackendTypeA 和 BackendTypeB 分别注册到字典值上,这样后续就可以利用这个字典表找到是 BackendTypeA 还是 BackendTypeB 了。
下面是使用,我们工场方式只是提供一个 GetFileBackend 方法,但是会有一个参数,这里是通过 typeId 实现的。对于外部使用的人员来说,首先通过某一种途径会获得的当前的后端类型常量然后调用 GetFileBackend 就能获得对应的实现了,然后调用 Download 方法,就可以看到打印出来的结果。如果是传的是常量 DataBackendTypeA 打印的是下载A,如果是常量 DataBackendTypeB 的话打印的就是 B。
案例 2 :解决升级版本过渡问题
版本升级是和前面说的一模一样的例子,唯一的不同的是,我这里有一个 manager,有不同的版本,我可以对每一次实现更新一个版本,发现这次版本是一个重构的升级,比如说训练从单节点变成分布式,这个代码完全不一样,没有办法做到和前面的代码兼容,这样做一件事情,原先的放在那边不要动,再实现一个 2 加上去,通过一个注册函数,在这个代码里面是某一个模块的引用的时候会声明一下当前的 VersionV2,真正调用的时候是 ManagerV2 的代码了,这里把代码写死了不好,实际上实际生产的时候把这个东西是放在配置文件等等灵活可读取的地方就可以灵活的控制后端的行为,比如说要升级,我把版本信息作为配置项加进去,如果不行再降下来就可以了。
这就是接口的好处,一个是功能能够边界清晰,第二个组件升级无感知。
四、Mock测试使系统更稳定
下面讲一个好处,如果你是针对 Interface 开发还有一个红利就是用 Mock 测试模拟所有的输入输出,但有具有一些的挑战。
挑战一:模块依赖外部条件多
刚刚看到代码依赖非常多,比如说这个 JobManager,需要依赖 JobAgent,Zookeeper,Redis,Kafka 等都有依赖。单独测试 JobManager,需要把这些依赖环境都建立好才能开始验证。而且就算环境搭建好了,测试案例的覆盖也是问题。假如 zookeeper 慢了,服务器挂了怎么办?如果是真实的环境搭建各类的外部依赖异常是枚举不完的,但是我们可以尽可能模拟到所有代码的覆盖情况,这时候我们需要用 Mock 帮助我们模拟外部环境。
解决方案
下面看我们如何用 mock 工具解决测试问题。使用 mock 有两个条件:首先 mock 有个二进制的工具需要安装一下;第二个是代码一定要是通过 Interface 写的。必须用 Interface 的原因是,mock工具的原理是根据源代码自动生成一个 Interface 的另一个测试版本 struct 实现,以便在测试过程中把我们的请求导流到 mock 生成的 struct 对象去。
当你用了 mock 这个程序之后,针对 Interface 做一次生成会生成代码,会生成针对你这个 Interface 定义的所有的接口,自动化的生成一段代码,这个代码就是 mock 的 struct,这个 struct 当中有两个成员,可以通过这个方法调用 struct,比如当时有一个函数,再这个 behavior 的时候事先通过代码调用告诉他,这边就会记录在 expectedcalls 里面。
Interface 有一个特性,只要实现了方法就承认是一个 Interface,这里的好处就来了,当你真正使用这个 mock 的 struct 的时候,外界调用是 Interface 其实已经转向了你 mock 生成的 struct,然后他会调用自己的 controller,会从先前的 expectedcalls 里面匹配,如果匹配的话就会把你预期已经写好返回值 return 出来,没有匹配就会报一个没有匹配的错,这样就可以在测试之前把所有可能的输入和输出都设置进去,最后所有的返回值都是预期内的,我们看一下具体的代码怎么做。
案例:mock Redis 包装方法
Redis 接口定义
首先我们要定义一个 Interface,比如说我们包装 redis 访问的 Interface,里面会有很多的方法,我这里精简了一个获得 redis 的 Interface 封装,里面只有一个 HMGetStringField 方法。然后通过安装了 mock 工具程序就有 mockgen,使用命令 mockgen -source 指定代码路径,让他生成一个 .go 的 mock 文件。让我们看一下生成代码是什么样的?
mockgen 生成代码:伪装 Interface
首先是 mock 一部分的代码是伪装 Interface,这里有 struct,这个名字和刚刚的 Interface 的名字是一模一样的,里面有一个 Controller,还有一个 Recorder。你会发现这个 mock 的 struct 也实现了 HMGetStringField 的方法,所以 GO 会承认生成出来的 mock 的 struct 也是我们 UaiRedisConnInterface 的一个实现。
其次是怎么悄悄把结果替换掉的?因为我们看到mock出来的代码中有一个 MockUfRedisConnInterfaceMockRecorder 这个 struct,里面也有一个 HMGetStringField 方法,就是它如实的记录下用户期望的入参出参并包装成 gomock.Call 对象,在后续调用时,把请求结果替换掉包。
mockgen 生成代码:替换请求结果
在测试代码中使用
之前都是自动生成的代码,下面来看实际怎么使用的?在自测文件中,先通过 gomock.NewController 新建一个 controller,有了这个 controller 就调用可以调用 mock 生成出的 NewMockUAIRedisConnInterface 方法创建出 mock 的对象。接下来,配合 EXPECT 方法,指定需要 mock 的函数(这里是 HMGetStringField ),并指定配对的入参和出参。做完这些步骤之后,这个mock就知道对应指定的入参,要给出指定的出参了。接下来非常重要的一步,就是你这边是 mock 的对象目前的类型还只是struct的指针,并不是 Interface,需要做用 UaiRedisConnInterface 做一次强制类型转换。然后就可以像调用平时的 UaiRedisConnInterface 一样直接调用,当然,调用时的入参要与 EXPECT 是设置的入参相同。最后,我们看一下运行的打印结果,因为这里是用断言的匹配,所以打印出 PASS 信息就说明范返回结果和我们预想的一致。通过这种方式事实上可以不用改变你自己的代码,通过 mock 的方式,把你任何的 Interface 替换成一个 mock 的 Interface 测试这样就可以满足你所有测试的条件。
优点
优点一
每个 Interface 都可以通过 mock 的方式给到预期的入参和出参,这个特性用来观察其他组件的表现,每个组件都可以单独进行测试。
优点二
可以通过预设 mock 入参出参覆盖各类的异常情况。前面演示的比较简单,实际上还有其他的异常,这些也可以作为你异常的案例进行设定。
优点三
这些测试案例是可以沉淀的,就是写完了测试程序之后可以放代码库里面,作为一个每次我提交版本都需要验证的测试案例进行回归的测试,这样的话日积月累我们的测试代码越来越丰富,我们程序就会越来越健壮。
总结
最后总结一下,其实使用 Interface 时,在设计阶段我们脑子里面有 Interface 这个东西,就会想到把我们的代码怎么样分层架构,会把代码一层一层分的很清楚,职责很清晰。在开发的时候我们如果是面向 Interface 开发,每个模块之间开发人员的职责也会比较清晰,因为只需要关心下游的 Interface 是怎么定义的就可以完成代码开发,比较易于合作。第三个是测试,我们可以事先用 mock 方式定义好行为,对于每个模块都可以把所有 Interface 的接口 mock 掉,就可以知道里面所有输出输入的情况。
今天的分享就到这里,谢谢大家!
Q&A
提问:你们机器学习平台,你说下载的,用户如果说碰到一些 S3 的,Ucloud 文件是怎么拷贝过去的?
范融:对,我们是这样做一个假设,我们希望方便我们的用户来使用云上的平台,所以我们有一个假设,我们的用户本来就是在他自己的本机上,或者是校园或者是公司的开发机上做代码的运行,所以说一般来说在开发机上数据都是以本地文件的形式保存的,所以我们希望在云端也是这样的体验,包括代码在本地和云端不用改,直接在云端运行,刚刚的接入层,做的事情是会通过我刚刚讲的 Interface 做一个后置的选择,比如说我们的 S3 的存储,不太能远程访问,所以会拷贝上实际的节点上,但是如果是 NFS 是支持多点挂载就不会直接拷贝,然后放到和本地类似定义的路径。
提问:如果说多个分布式训练,不同的 worker 需要不同的定义吗?
范融:就是希望把所有的数据写在一条路径里面,至于说数据的分片这些,我们现在的分布式是这样,我们是自动帮你分布式的,程序按照那个写好,会把数据做切换发到 worker 上,这个不用担心,我们 JobManager 分发会帮你做拆分的。
领取专属 10元无门槛券
私享最新 技术干货