高效客户端持续集成实践之路

avatar
@阿里巴巴集团
原文链接: mp.weixin.qq.com

—— 安心提交代码,让需求发布不再加班

背景

敏捷开发以用户的需求进化为核心,采用迭代、循序渐进的方法进行软件开发。闲鱼目前采用泳道任务模式进行迭代开发,开发周期是两周一个版本,发版频率比较高,并行开发的业务需求又很多,怎么才能高效的迭代开发?测试资源相对紧缺,如何保证客户端的研发质量?于此同时,迭代过程中,构建、集成以及测试都需要人工干预,沟通成本和出错概率都比较高。

如何有效的解决上面这些问题?首先想到的是持续集成,能够做到自动化、集成测试和及时反馈问题,才能减少开发和测试的成本,提高团队的工程能效。闲鱼在客户端持续集成方案上面做了些探索和实践,本文主要以iOS多bundle的工程为例,讲解下如何用 SpringBootVue实现持续集成方案,将 需求 - 代码 - 测试关联,做到代码结构化并持续集成。

1. 数据模型

1.1 泳道模型

首先,让我们来看下泳道模型,让我们对他有个大体了解。先来看一张图:

这是大家常用的git flow模型,集成分支就是 develop,如果需要开发需求,就从集成分支拉出对应的特性分支 Feature,等开发结束,再将 Feature合并回 develop分支,集成分支测试通过的话,再拉出发布分支 Release,由于 master分支不是很常用,所以在闲鱼这边暂时没用。

这是单个库的情况,都比较好理解,前面背景也介绍过,iOS的工程在拆库,拆库的情况大致如下:

iOS会有一个主工程来管理这些子库,这是8个子库,外加一个主工程,也就意味着会有9个git地址,在开发需求的时候,主要改动的bundle集中在: IFMatrixIFContainer;如果改动的库一多,那就意味着每个库都需要拉出一个 Feature分支。等操作完了,再进行合并到 Develop分支,又是一个不小的工作量。

上面只是介绍了一个需求的情况,如果有n个需求,对于集成人员来说,就是 9*n的工作量。这个只是iOS工程,还有一个android工程,以后可能还有weex的、flutter的,最坏的情况,工作量就是 4*9*n,相信对于任何一个开发/测试/PM,都是一个不小的挑战。

所以,自动集成对于闲鱼来说,迫在眉睫,要想做客户端自动集成,摆在我们面前有这么几个问题:

  1. 多个需求,怎么才能保证有条不紊的集成?

  2. 如何持续集成,方案应该如何设计?

  3. 集成结束,怎样触发测试?

让我们先来看下开发过程标准化,将需求、代码、集成关联起来,做到从源头到结尾的自动化。

1.2 关联需求和代码

需求都是在 Aone平台上面来管理的,每个需求都对应有一个 id,怎么将需求跟代码关联?

注:Aone是一个需求管理平台

闲鱼的解决方案是:在git提交commit中,添加上需求的信息,比如需求的 id。实现的原理就是拦截git commit事件,然后将相关的需求添加到commet中,接下来的问题就是怎么取到相关需求的信息?

有2个方法:

  1. 统一分支命名规范,例如 task /task_< AoneId>_ <desc>

  2. 提交的时候主动输入需求信息,例如 fix  ##<AoneId>

这样在提交的时候,就可以获取到 <AondId>,最终将代码和需求关联起来,结果如下图所示:

第2行就是关联需求的链接,每个commit上面就携带了需求的信息,主要是为了后面定位测试范围。这个需求在测试通过后,可以监听需求状态变更的metaq消息,先合并分支代码,再自动删除分支。

关联需求和代码,详情可参考这篇文章:Hook Git实现代码与需求的一致性

1.3 关联需求和集成项

前面也交代过背景,闲鱼测试组期望是能做到开发阶段和集成阶段都能触发相关的集成和测试件,这就要求我们,要做到需求与集成项关联起来,一个需求对应一个摩天轮的项目。

注:摩天轮是一个构建平台,可以配置相关的模块依赖

在数据库中,就可以将 产品 - 需求 - 摩天轮项目关联起来,每个 projectId就对应一个摩天轮的项目,每个 摩天轮项目会对应很多个配置项,数据库中就有了集成项的数据关系,包括工程之间的依赖关系。

接下来第2个问题:如何持续化集成,方案应该如何设计?

2. 自动集成框架

数据中已经存储了 需求 - 摩天轮项目的关系,怎么将关联的代码应用起来的,做到可持续集成?闲鱼目前采用webservice承载服务之间的串联,形成了一个 pipline模式。

2.1 平台架构

此平台使用 springboot搭建,采用前后端分离的设计,服务端对外暴露的接口都是restful,前端用 Vue编写,通过 axios发送AJAX请求与服务端通讯。信息来源除了gitlab、摩天轮和Aone平台之外,还会在本地数据库存储一份关系映射表。

这个图中,可以看到主要分成几个大块: 数据层业务层接口层前端UI+客户端。整个平台算是一个大的客户端,所以针对 gitlab基础服务MTL基础服务Aone基础服务Jenkins基础服务都作为一个数据层。本地的数据库,主要保存 需求 - 代码 - 打包的映射关系,比如子库的代码变更,需要触发摩天轮工程打包,需要反向查找。

此服务在日常环境的一台服务器,但是会另外一个问题:摩天轮是在预发环境的,日常环境和预发环境本身是网络隔离的,也就无法直接调用摩天路提供的 hsf服务。为了解决这个问题,我们在预发环境,搭建了一套桥接服务,通过vipserver来中转服务。

2.2 事件驱动

整个平台是由事件驱动,主要分3部分:Merge Request、Gitlab Push和机械触发(包括手工、定时)。

Gitlab提供了很人性化的接口,可以监听到代码的变更,配置方法也很简单,见下图:

主要处理的是 push eventmerge request,平台提供一个post的restfull的接口,然后配置在gitlab项目里面,就可以监听到代码变更。

                                                                        
  1. /**

  2. * 监控gitlab webhook的主入口

  3. * @param payload

  4. */

  5. @RequestMapping (value = "webhook", method = RequestMethod .POST )

  6. public void webhooks( @RequestBody String payload ) {

  7.    logger .info (payload );

  8.     GitlabHookEvent event = JSON .parseObject (payload , GitlabHookEvent. class);

  9.    eventService .dispatchGitlabEvent (event );

  10. }

注:gitlab的push和merge事件会有重复发送的情况,所以需要做一下去重的处理

在这边会解析出GitlabHookEvent,然后再由 GitlabEventService去分发,再由集成模块去触发打包,那就让我们来看下持续打包的解决方案。

2.3 持续打包

持续构建,优先要解决的是bundle之间的依赖,现在只支持单项依赖的处理,映射关系会在数据库中会保存一份,当需要触发一个摩天轮项目构建,就可以解析出它对应的依赖库。主体流程如下图所示:

通常情况下,子bundle会改动多个,拿到需要构建的子bundle列表之后,先检测子bundle是否需要重新打包,检测规则:可以根据最后一次commit信息和上一次集成成功的时间差,如果差值大于一个阈值,表明不需要重新打包;否则加入到打包队列里面。

主工程+子bundle的一个集合作为一个整体集成任务,添加到打包任务队列里面,由于没办法获取到摩天轮打包成功的metaq消息的回调,只能去轮询结果。先检测子bundle是否已经结束,如果已经结束,则触发主工程的打包;如果没有子bundle在打包,就检查主工程是否结束。

  1. private void triggerMTLBuildInterval(FMPackageTask task, MTLProduct product, int mtlProjectId){

  2.        // 分析子模块

  3.        ArrayList<MTLBuildConfig> modulesConfigs = gitlabMTLBridge.getModuleBuildConfigList(mtlProjectId);

  4.        if (modulesConfigs != null) {

  5.            for (MTLBuildConfig moduleConfig : modulesConfigs) {

  6.                boolean rebuild = isNeedRebuildForConfig(moduleConfig);

  7.                if (!rebuild) {

  8.                    continue;

  9.                }

  10.                // 检测当前是否在打包,如果再打包,需要取消当前的编译

  11.                MTLBuildResult latestBuildResult = mtlService.getLatestBuildResult(moduleConfig.id, null);

  12.                if (latestBuildResult != null){

  13.                    String status = latestBuildResult.buildStatus;

  14.                    if (status.equals(MTLBuildStatus.RUNNING.getValue()) ||

  15.                            status.equals(MTLBuildStatus.WAITING.getValue())){

  16.                        mtlService.cancelBuildTask(product.rpc_key, latestBuildResult.id);

  17.                        logger.info("【取消打包】:" + moduleConfig.toString());

  18.                    }

  19.                }

  20.                // 执行打包操作

  21.                int resultId = triggerBuildWithConfig(moduleConfig);

  22.                if (resultId != 0) {

  23.                    task.moduleConfigs.add(moduleConfig);

  24.                }

  25.            }

  26.        }

  27.        // 如果有子工程,需要先打自工程,然后再打主工程

  28.        if (task.moduleConfigs.isEmpty()){

  29.            triggerBuildWithConfig(task.mainConfig);

  30.        }

  31.    }

异常情况的处理,比如任何一个子bundle失败,则需要取消整个构建任务。等构建结束,会通过 ApplicationEvent广播事件,需要的service监听到结果,再做相关的处理。

                                                                                
  1. /**

  2. * 广播构建事件

  3. * @param task

  4. */

  5. private void sendApplicationEvent( FMPackageTask task ){

  6.     ApplicationEventMTLPackage event = new ApplicationEventMTLPackage( context);

  7.    event .task = task ;

  8.    context .publishEvent (event );

  9. }

接下来要看下第3个问题:自动集成结束,怎样触发CI测试?

3. 集成测试

现在我们已经得到了构建结果,不管成功还是失败,都会触发相关的CI测试,怎么确定测试校验件的测试范围来提高测试效率?

首先要解决2个问题:

1.怎么定义测试范围?

对于客户端来说,基于页面来回归是比较合适,所以跟测试系统定的协议,按照页面的scheme来回归,这样做还有个好处,就是可以定制化相关的参数,而且还支持weex和flutter页面。

2.怎么确定测试范围?

在前面的文章中,我们也提到了,现在 需求 - 代码 - 构建现在是关联的,针对每次集成,都会有相关的驱动事件。

  1. Merge Request:有相关的mr,就可以拿到commits列表

  2. Push:针对每次push,也可以拿到相关的commits列表

  3. 机械触发:可以拿到一定时间间隔的commits列表

针对上面3个事件源,都可以拿到commits列表,接着就可以拿到文件修改列表、修改的人员、以及关联的需求;拿到上面这些信息,就可以框定出代码变动范围。

每次在跑CI测试的时候,就能知道这是改的哪个需求。

示例代码如下:

  1. /**

  2. * 获取修改范围

  3. * @param projectId

  4. * @param commits

  5. * @return

  6. */

  7. public FMCITriggerParam getChangeScope(int projectId, String branch, ArrayList<GitlabCommit> commits){

  8.    // 获取平台信息

  9.    Repo projectRepo = repoMapper.getRepoByProjectId(projectId);

  10.    String platform = "ios";

  11.    if (projectRepo != null){

  12.        platform = projectRepo.platform;

  13.    }

  14.    // 提交人员

  15.    ArrayList<String> authors = getCommitsAuthors(commits);

  16.    // 修改的文件

  17.    ArrayList<String> changeFiles = commitService.getCommitsChangeFiles(projectId, commits);

  18.    // 修改范围

  19.    ArrayList<String> pages = getPagesByFiles(projectId, changeFiles);

  20.    // 触发方式

  21.    ArrayList<String> triggerTypes = new ArrayList<>();

  22.    triggerTypes.add("uiauto");

  23.    triggerTypes.add("monkey");

  24.    FMCITriggerParam change = new FMCITriggerParam();

  25.    change.projectid = projectId;

  26.    change.platform = platform;

  27.    change.mergerequestid = 0;

  28.    change.branchName = branch;

  29.    change.userlist.addAll(authors);

  30.    change.pages = String.join(";", pages);

  31.    change.triggertype.addAll(triggerTypes);

  32.    return change;

  33. }

获取到提交人员、修改范围等信息后,测试件可以提示相关的错误。

4. 结果统计

下面是在一周之内集成的数量,以7.8 - 7.15号的周期为例(iOS工程):

从上面是已分支维度统计,

  1. 集成分支 develop,每天都会定时触发相关集成

  2. 需求分支,在开发周期内,会有较高的触发量

从事件触发的维度上:

  1. 主要还是以定时触发为主

  2. 在开发周期内,push触发的数量会有所增长

目前,集成的需求比较少,所以数量也相对较少,但总体来说整个方案是稳定的,后续会完善相关的数据统计,比如构建时长、需求开发时长等。

5. 踩坑纪要

在开发过程中,踩过一些坑也做一些记录。

5.1 Axios网络请求

由于采用前后端分离的设计,所以在调试的时候会有跨域的问题,解决方案就是在vue的config中做相关的代理设置。

  1. proxyTable: {

  2.  '/fishci': {

  3.    target: 'http://127.0.0.1:8090', //api端口

  4.    // changeOrigin: true,   //允许跨域

  5.    pathRewrite: {

  6.      '^/fishci': '/'

  7.    }

  8.  }

  9. }

跨域的问题解决了,但是在对接buc认证的时候,需要重定向请求,前端用的axios,无法拦截到302的返回。为了解决这个问题,服务端也做了相关处理,服务端将302的返回转化成200的返回,并将重定向的内容放在response里面,然后再有前端axios拦截到进行处理。

首先在 configuration里面添加相关的 Filter,添加相关配置 registration.addInitParameter("HTTP_302_JSON_RESPONSE","json");,前端在请求的时候,以json结尾的请求,如果需要重定向,就能获取到200的返回,只不过重定向的内容会在response里面以文本形式呈现,然后再去做拦截。

                                                                                        
  1. axios .interceptors .response .use ((response ) => {

  2.     if (response .status === 200 && response .data .hasError ) {

  3.         return window .location = "<重定向的链接>";

  4.     }

  5.     return response ;

  6. }, function ( error) {

  7.     return Promise. reject( error);

  8. });

5.2 集成限流

单个需求的打包业务逻辑相对简单,但是由于push或merge都会触发全量打包,频率会比较高,就需要做相关的限流逻辑,如下图所示:

会有2个队列保存当前的打包任务: 执行队列等待队列

  1. 新来Feature2,当前已经有重复任务已经在集成,会放在等待队列,如果有重复的任务,则删除

  2. 新来Feature5,当前没有相同任务正在执行,直接添加到执行队列

  3. 执行队列已经达到最大容量5,新来的Feature6添加到等待队列

当执行队列里面任务结束,会从等待队列里面选取一个没有在打包的任务,放到执行队列里面。

6. 结语

本篇文章主要是整理,在泳道开发模式下,如何有效的提高客户端工程能效所做的实践。现在主体流程已经串通,接下来就可以有针对性的统计相关数据,比如构建的时间长短、测试有效性的度量,有了这些数据就可以对客户端的集成效率有整体的度量,再反向的优化客户端的集成方案。

在整套方案中,测试校验很重要,如何做到高效的测试?原则上成本比较低的,跑的频次可以高点,例如:代码检测、单元测试;成本比较高的,频次可以低点,例如:UI Automation。总体来说,现在从 需求 - 代码 - 构建都关联起来,就可以统计出一个需求的代码提交量、构建数量和bug数量,反向的对需求就有了一个度量,比如需求拆分的好坏、开发周期的长短等。最终目的,作为一个客户端团队,能够做快速的迭代业务,提高各团队之间的协同效率,从而在整体上提高能效。在闲鱼,我们推崇无人化的方式解决问题,如果你也是一个对技术有追求的同学,欢迎加入我们。

简历投递:guicai.gxy@alibaba-inc.com

识别二维码,关注【闲鱼技术】公众号

参考文档

  1. Git工作流指南

  2. iView - A high quality UI Toolkit based on Vue.js

  3. Spring Boot

  4. GitHub - axios/axios: Promise based HTTP client for the browser and node.js

  5. https://cn.vuejs.org/