敏捷开发Agile是一种开发流程,是一种快速迭代的开发流程,每个开发流程非常短,长到一个月,短到两个星期,就会是一个周期,在这个周期中,每天都要开会同步,每天都要集成。正是因为周期短,才需要持续的做这件事情,如果一个开发周期长达几个月,则不需要持续的集成,最后留几个星期的集成时间一起做也是可以的,但是这样就不能达到互联网公司的快速迭代,也是我们常常看到传统公司的做法。
持续集成往往指对代码的提交,构建,测试的过程,也就是上述的在一起的过程。
持续交付是指将集成好的交付物,例如war,jar,或者容器镜像,部署在联调环境,或者预发环境的过程。
持续部署是指将交付物持续部署在生产环境的过程。
我们常说CICD,CD有时候指的是Delivery交付,有的是指Deployment部署,对于非生产环境,自动部署是没有问题的,对于生产环境,往往还是需要有专人来进行更为严肃的部署过程,不会完全的自动化。
接下来就是DevOps,DevOps不只是CICD,除了技术和流程,还包含文化。例如容器化带来的一个巨大的转变是,原来只有运维关心环境的部署,无论是测试环境,还是生产环境,都是运维搞定的,而容器化之后,需要开发自己写Dockerfile,自己关心环境的部署。因为微服务之后,模块太多了,让少数的运维能够很好的管理所有的服务,压力大,易出错,然而开发往往分成很多的团队,每个模块自己关心自己的部署,则不易出错,这就需要运维一部分的工作让研发来做,需要研发和运维的打通,如果公司没有这个文化,研发的老大说我们不写Dockerfile,则DevOps是搞不定的。
四、从一个持续集成的日常,看上述的几个概念如何实践
持续集成的流程
这是一个持续集成的流程,但是运行起来更加的复杂。
首先,项目开发的流程使用的是Agile,用常见的Scrum为例子。
Scrum
每天早上第一件事情,就是开站会standup meeting,为什么要站着呢?因为时间不能太长,微服务的一个模块,大概需要5-9人的团队规模,如果团队规模太大了,说明服务应该进行拆分了,这个团队规模,是能够保证比较短的时间之内过完昨天的状态的。
一定要大家一起开,而不要线下去更新Jira,虽然看起来一样,但是执行起来完全不一样。只有大家一起开,一起看燃尽图,一起说我昨天做了什么,今天打算做什么,有什么阻碍,才能够让大家都了解情况,不要期望大家会去看别人的Jira,经验告诉你,不会的。
而且这个站会对于开发是比较大的压力,例如你的一个功能block了依赖方的开发,在会议上会暴露出来,大家都知道这件事情了,一天block,两天block,第三天你都不好意思去说了,这会强迫你将大任务,比如原来写1周干一件什么事情,写成小时级别,这样每天你都有的说,昨天完成了一个task,而不是周只在那里说干同样一件事情,而且一旦有了block,team lead会知道这件事情,会帮你赶紧解决这个事情,推进整个项目的进展。让一个技术人员在团队面前承认这件事情我尝试了几天,的确搞不定了,也是一种压力。
站会中的内容其实在前一天晚上就要开始准备了。
持续集成要求每天都提交代码,这样才能降低代码集成的风险,不能埋头写一周一起提交,这样往往集成不成功。怎么样才能鼓励每天都提交代码呢?一个就是第二天的站会,你这个功能代码提交了,单元测试通过了,第二天才能说做完了,否则不算,这就逼得你,将大任务拆成小任务,每天都多次提交。
而且Git的提交方式,是后提交者有责任去merge,保证代码的编译通过和测试通过,你会发现,如果你不及时提交,等你改了一大片代码,别人都提交完了,这一大片的冲突都是你来merge,测试用例不通过的你来fix,所以逼的你有一个小的功能的改动,就尽早提交,pull一下发现没有人提交,赶紧提交。
提交不是马上进入主库,而是需要代码审核,这是把控代码质量的重要的环节。
代码质量的控制往往每个公司都有文档,甚至你可以从网上下载一篇很长很长的Java代码规范。但是我们常常看到的例子是,规范是有,但是虱子多了不咬人,规范太多的,谁也记不住,等于没有规范。
所以建议将复杂的规范通过项目组内部的讨论,简化为简单的10几条军规,深入人心,大家都容易记住,并且容器执行。
代码审核往往需要注意下面的几方面:
- 代码结构:整个项目组应该规定统一的代码组织结构,使得每个开发拿到另一个人的代码,都能看的熟悉的面孔。这也是scrum中提倡的每个开发之间是可替代的,当一个模块有了阻碍,其他人是可以帮上忙的。至于核心的逻辑,估计审核人员也来不及细看,这不要紧,核心逻辑是否通过,不能靠眼睛,要靠测试。
- 有没有注释,尤其是对外的接口,应该有完善的注释,方便自动生成接口文档。
- 异常的处理,是否抛出太过宽泛的异常,是否吞掉异常,是否吞掉异常的日志等。
- 对于pom是否有修改,引入了新的jar。
- 对于配置文件是否有修改,对外访问是否设置超时
- 对于数据库是否有修改,是否经过DBA审核
- 接口实现是否幂等,因为Dubbo和springcloud都会重试接口。接口是否会升级,是否带版本号
- 是否有单元测试
当然还有一些不容易一眼看出来的,可以通过一段时间通过统一的代码review,来修改这些问题:
- 某个类代码长度过长
- 设计是否合理,高内聚低耦合
- 数据库设计是否合理
- 数据库事务是否使用合理
- 代码是否有明显的阻塞
代码审核完毕之后提交上去之后,一个是要通过静态代码审查,可以发现一些可能带来代码风险的问题,例如异常过于宽泛等。
在就是要通过单元测试。我们应该要求每个类都要有单元测试,并且单元测试覆盖率要达到一定的指标。单元测试要有带Mock的模块内的集成测试。
在编译过程中会触发单元测试,单元测试不通过,已经代码覆盖率,都会统计后发邮件,抄送所有的人,这对于研发来讲又是一个压力。
当有一天你的提交break掉了测试,或者代码覆盖率很低,则就像通报批评一样,你需要赶紧去修改。
单元测试完毕之后,就会上传成果物,或者是war或者是jar,一般会用nexus,因为有版本号,有md5,可以保证安装在环境中的就是某个版本的某个包,我们还遇到过有使用FTP的,这样一个是很难保证版本号的维护,升级和回滚比较难弄,另一个是没有md5,很可能包不完整都有可能的,而且一旦发生,很难发现。
如果使用了容器,则还需要编译Dockerfile,使用Docker镜像作为交付,能够实现更好的环境一致性,保证原子的升级和回滚。
每天下班前,当天的代码需要提交到库中去,晚上会做一次统一的环境部署和集成测试。
每天晚上凌晨,会有自动化的脚本将Docker镜像通过编排部署一个完整的环境,然后跑集成测试用例,集成测试用例应该是基于API的,很多的公司是基于UI的,这样由于UI变化太快,还有UI不能覆盖所有的场景,所以还是建议UI和API分离,通过API进行集成测试,有了每天的测试,才能保证每天晚上的版本都是可以交付的版本,也保证我们微服务拆分的时候,尽管改了很多,不会因为新的修改,破坏掉原来能够通过的测试用例,保证不会有了新的,坏了旧的。
这个集成测试或者叫回归测试每天晚上都做,都是在一个全新的环境中,这就是持续部署和持续交付。
如果某一天测试不通过,则会发出邮件来,是因为当天谁的哪个提交,导致测试不通过,抄送所有人,这是另一个压力。
所以第二天的站会上,昨天你完成了哪些功能,是否提交了,是否完成了单元测试,是否通过了集成测试,就都知道了,你需要给大家一个解释,然后进入到新一天的开发。
到了两周,一个周期完毕,可以上线到生产环境了,可以通知有权限的运维进行操作,但是也是通过自动化的脚本进行部署的。
这就是整个过程,层层保证质量,从中可以看到,敏捷开发,持续集成,持续交付,持续部署,DevOps是互相联系的,少了任何一个,整个流程都玩不转。
五、有关代码结构
代码结构往往包括:
- API接口包
- 访问外部服务包
- 数据库DTO
- 访问数据库包
- 服务与商务逻辑
- 外部服务
如果使用Dubbo RPC,则API接口往往在一个单独的jar里面,被服务端和客户端共同依赖,但是使用了springcloud的restful方式就不用了,只要在各自的代码里面定义就可以了,会变成json的方式传递,这样的好处是当jar有多个版本依赖,需要升级的时候,关系非常复杂,难以维护,而json的方式比较好的解决了这个问题。
这个模块提供了哪些接口,只要到API接口这个package下面找就可以了。因为无论是Dubbo还是springcloud,接口的调用都会重试,因而接口需要实现幂等。
访问外部服务的包,这将所有对外的访问独立出来,好处一是可以抽象出来,在服务拆分的时候,可能会用到,例如原来支付的逻辑在下单的模块中,要讲支付独立出来,则会有一个抽象层,涉及到老的支付方式,还是调用本模块中的逻辑,涉及到新接入的支付方式使用远程调用,有了这一层方便的多。好处二是可以实现熔断,当被调用的服务不正常的时候,在这里可以返回托底数据。好处三是可以实现Mock,这样对于单元测试来讲非常好,不用依赖于其他服务,就可以自己进行测试。
DTO和访问数据库的包,看到了这些数据结构,会帮助程序员快速掌握代码逻辑,不知道大家有没有这个体验,你去看一个开源软件的代码,首先要看的是他的数据结构,数据结构和关系看懂了,代码逻辑就比较容易懂了,如果数据结构没看懂,则光看逻辑,就容易云里雾里的。
还有就是核心的代码逻辑和对接口的实现。在这里面是软件代码设计的内功所在,但是却不是流程能够控制的。
六、有关接口设计规范
上面也说过了,Dubbo和Springcloud会对接口进行重试,因而接口需要保持幂等。也即多次调用,应该产生一致的结果,例如转账1元,因为调用失败或者超时重试的时候,最终结果还应该是转账1元,而非调用两次变成转账2元。
- 幂等判断尽量提前,可以使用ID作为判断条件。
- 接口的实现应该尽量避免阻塞,可以使用异步方式提升性能。
- 接口应该包括能够区分不同情况的异常,而非抛出宽泛的Exception,不能吞掉异常。
- 接口的实现要有足够的容错性,以及对不同版本的兼容性。当要引入新接口的时候,使用先添加,后删除的方式。
- 接口应该有良好的注释。
七、有关代码设计
对于代码的设计,这里常说的就是SOLID原则。
- S是单一责任原则,如果你的代码中有一个类行数太长,可能你需要重新审视一下,是不是这个类承担了过多的责任。
- O是开放关闭原则,比较拗口,对扩展开放,对修改关闭。思想是对于代码的直接修改是非常危险的事情,因为你不知道这段代码原来被谁用了,而且当时候用的时候,面临的情况都是怎样的。因而不要贸然修改一段代码,而是选择用接口进行调用,用实现进行扩展的方式进行。当你要实现一段新的功能的时候,不要改原来的代码,也不要if-else,而是应该扩展一种实现,让原来的调用的代码逻辑还是原来的,在新的情况下使用新实现的代码逻辑。
- L是里氏替换原则,如果基于接口进行编程,则子类一定要能够扩展父类的功能,如果不能,说明不应该继承与这个接口。例如你的实现的时候,发现接口中有一个方法在你这里实在对应不到实现,不是接口设计的问题,就是你不应该继承这个接口,绝不能出现not implemented类似之类的实现方法。
- I是接口隔离原则,接口不应该设计的大而全,一个接口暴露出所有的功能,从而使得客户端依赖了自己不需要的接口或者接口的方法。而是应该讲接口进行细分和提取,而不应该将太过灵活的参数和变量混杂在一个接口中。
- D是依赖倒置原则,A模块依赖于B模块,B模块有了修改,反而要改A,就是依赖的过于紧密的问题。这就是常说的,你变了,我没变,为啥我要改。如果基于抽象的接口编程,将修改隐藏在后面,则能够实现依赖的解耦。
以上是模块内部常见的设计原则,对于模块之间,则是对于云原生应用常说的十二要素原则。
十二要素原则
详情可云原生时代下的12-factor应用与实践