当前位置: 首页 > 科技观察

拆虽不易,合则更难!持续集成是微服务化的“基石”

时间:2023-03-21 13:13:17 科技观察

拆卸不易,组装更难!持续集成是微服务粒度、拆解时机、拆解方式的“基石”。持续集成对于微服务的意义:为什么要先拆后拆?因为这是人类处理问题的本质方式:把一个大而复杂的问题转化为许多小问题来解决。因此,当一个系统复杂到一定程度时,当维护一个系统的人数足够多时,解决问题的难度和沟通成本都会大大增加。所以需要拆解成很多项目和团队,分而治之。但是,当每个子团队解决了子问题,是不是整个系统的问题就解决了呢?您可以想象将整辆汽车拆解成零件然后再组装的过程。可想而知,拆起来虽然不容易,但组装起来就更难了。将零件组装成汽车需要各种标准和各种装配线。先来回顾一下拆解过程。初始应用程序主要是单体应用程序。一个Java后端,然后是一个数据库,你基本上就完成了。随着系统复杂度的增加,Java程序首先要做的就是垂直拆分。首先最外层是一个负载均衡器,后面是连接的Nginx,用于不同服务的路由。不同的服务拆分成独立的进程,独立部署。每个服务使用自己的数据库和缓存,解决数据库和缓存的单点瓶颈。数据库采用一主多从的模式进行读写分离,主要针对读多写少的场景。为了承载更多的请求,设置缓存层,将数据缓存在Memcached或者Redis中,提高效率。当然,还有一些跨服务的查询,或者说非结构化数据的查询,引入到搜索引擎中,比关系型数据库的查询要快很多。在高并发的情况下,光靠垂直拆分是不行的,需要真正的面向服务。一个面向服务的架构如下图所示:首先是接入层,主要实现API网关、动态资源和静态资源的分离和缓存,可以在这一层对整个系统进行流量限制。接下来是Web层,也就是controller,提供最外层的API,是对外提供服务的层。下面是复合服务层,有时也叫编排层,Compose层,就是实现复杂逻辑的层。下面是基础服务层,也就是提供原子性基础逻辑的层。下面是缓存和数据库。服务需要治理和相互发现,所以一般会有Dubbo或者SpringCloud这样的框架。所有业务都应该有监控告警,及时发现异常,并通过告警运维进行自动修复或人工修复。对于所有的业务日志,应该格式一致,集中在一起,称为日志中心,这样当发现错误时,可以在一个统一的地方进行调试。所有服务的配置,都有一个统一的管理场所,叫做配置中心。可以通过修改配置中心和分发配置来修改整个集群的配置,比如打开熔断器或者降级开关。通过简单的描述,你会发现从一个简单的单体应用到如此复杂的微服务架构,除了关心如何拆解外,还必须关注:如何控制拆解的风险如何保证代码质量如何保证功能不改变不引入新的bug答案当然是集成,从头开始集成,持续集成,反复重新组装拆分的模块,看能否顺利组合,保证功能不变。如果还不行,我们就结合吧。天知道几个月后能不能合体。不要忘记程序是人写的。你和你老婆长期不交流谈不上默契,更何况是两个程序员。持续集成就是不断尝试集成在一起,如下图所示:为什么我们需要一个统一的代码仓库Git来进行代码管理?用于代码集成。为什么需要构建构建?即需要将代码逻辑整合在一起,这样编译才不会出错。为什么要进行单元测试?模块的功能集成在一起才能正常工作。为什么要联合调试和测试Staging环境?需要在类生产环境中集成和测试不同的模块。最后部署到生产环境,真正把大家各自做的工作结合起来。持续集成就是制定一系列的流程,或者说一系列的规则,把需要在一起的各个层级都规范起来,让大家在一起,逼迫大家在一起。持续集成、持续交付、持续部署、敏捷开发和DevOps之间有什么关系?这些概念很容易混淆,它们之间有什么关系呢?如下图所示:敏捷开发敏捷是一个开发过程,一个快速迭代的开发过程,每个开发过程都很短,长则一个月,短则两周,它会是一个周期,在这个周期中,天天开会,天天整合。正是因为周期短,才需要不断地做这个。如果一个开发周期持续几个月,则不需要持续集成。***留几个星期的整合时间一起做也是可以的,但这达不到互联网公司的快速迭代,这是我们在传统公司经常看到的。持续集成通常指的是提交、构建、测试代码的过程,也就是上面提到的过程在一起。持续交付是指在联调环境或预发布环境中部署集成交付物(如war、jar或容器镜像)的过程。持续部署是指在生产环境中持续部署可交付成果的过程。我们经常谈论CI/CD。CD有时指Delivery交付,有时指Deployment部署。对于非生产环境,自动部署是没有问题的。对于生产环境,往往需要有专人进行比较严肃的部署过程,不会完全自动化。其次是DevOps,DevOps不仅仅是CI/CD,除了技术和流程,还包括文化。比如容器化带来的一个巨大变化就是只有运维才关心环境的部署。无论是测试环境还是生产环境,都是由运维来处理。容器化之后,需要开发编写自己的Dockerfile。环境部署。因为微服务之后,模块太多,少量的运维就可以把所有的服务管理好,压力大,容易出错。然而,开发往往分成许多团队。每个模块都关心自己的部署,不容易出错。.这就需要部分运维工作由研发来完成,研发和运维需要打通。如果公司没有这个文化,研发老大说我们不写Dockerfile,那DevOps就搞不定。从一个持续集成套路,看看上面的概念是如何实践的。如上图所示,这是一个持续集成的过程,但是运行起来比较复杂。首先,项目开发过程使用Agile,以常见的scrum为例。我每天早上做的第一件事就是召开站立会议。为什么我要站着?因为时间不能太长,微服务一个模块需要团队规模大概5-9人。如果团队规模太大,则意味着服务应该拆分。这个团队规模可以保证比较短的时间。完成了昨天的状态。大家一定要一起打开,而不是离线更新Jira。虽然看起来一样,但是执行起来却完全不同。只有一起打开,一起看燃尽图,说说昨天做了什么,今天准备做什么,有什么障碍,大家才能明白情况。不要指望每个人都阅读别人的Jira。经验告诉你,不会。而且这个站会给开发带来很大的压力。比如,如果你的某个功能阻碍了依赖方的开发,会在会上暴露出来。每个人都知道这一点。一天堵,两天堵,第三天堵。不好意思说出来,会逼着你炼化大任务。比如把一周做的事情写出来,写到小时级别,这样每天都可以说昨天完成了一个任务,而不是只在周会上说做同样的事情。而且一旦出现障碍,teamlead就会知道,并会帮助你快速解决,促进整个项目的进展。这几天我试图让技术人员在团队面前承认这一点,但我想不通,这也是一种压力。站会的内容其实是前一天晚上准备好的。持续集成要求每天都要提交代码,这样可以降低代码集成的风险,而且不能一个星期都埋头写,一起提交,这样往往集成不成功。我们如何鼓励团队成员每天提交代码?一是第二天的站会。当你提交功能代??码并通过单元测试后,你可以说第二天就完成了。否则,将不会被计算在内。提交。而且Git的提交方式是后提交者有责任合并,保证代码编译通过和测试通过。你会发现,如果你不及时提交,当你改了一大段代码,别人就会提交。冲突由你合并,失败的测试用例由你修复。所以如果你迫不得已有一个小的功能改动,尽快提交,等你拉过来发现没人提交的时候,就尽快提交。提交并不会立即进入主库,而是需要进行代码审查,这是控制代码质量的重要一环。代码质量控制往往每个公司都有文档,你甚至可以从网上下载很长的Java代码规范。但是我们经常看到的例子是有规范的,但是虱子多了就不咬人了。规范多了,谁也记不住,就是没有规范。因此,建议通过项目组内部讨论,将复杂的规范简化为几条简单的军规,深入人心,便于大家记忆,便于执行。Codereview往往需要关注以下几个方面:代码结构:整个项目组应该规定一个统一的代码组织结构,让每个开发人员在拿到别人的代码时都能看到熟悉的面孔。这也是Scrum提倡的各个开发之间的可替代性。当一个模块受到阻碍时,其他人可以提供帮助。至于核心逻辑,估计审计人员也没有时间仔细看。是否有注释,尤其是对外的接口,要有完整的注释,方便接口文档的自动生成。异常处理,是否抛出过于宽泛的异常,是否吞噬异常,是否吞噬异常日志等。是否修改了pom,引入了新的jar。配置文件是否修改,是否设置外部访问超时时间。至于数据库有没有被修改过,有没有被DBA审计过。接口实现是否幂等,因为Dubbo和SpringCloud都会对接口进行重试。接口是否会升级,是否有版本号。有没有单元测试。当然,还有一些问题不是一眼就能看出来的。这些问题可以通过一段时间的统一codereview来改正:是不是某个类的代码设计的太长了?设计是否合理?高内聚低耦合的数据库设计是否合理?合理的代码是否有明显的阻塞代码审查完成并提交后,一是通过静态代码审查,可以发现一些可能带来代码风险的问题,比如异常过于宽泛。然后是单元测试。我们应该要求每个班级都有一个单元测试,单元测试的覆盖率必须达到一定的目标。单元测试必须在带有模拟的模块内部进行集成测试。编译过程中会触发单元测试。如果单元测试失败,会统计代码覆盖率,发到邮箱,抄送给大家。这对研发来说又是一个压力。当有一天你提交中断,测试丢失,或者代码覆盖率很低,就像举报批评一样,你需要快速修改。单元测试完成后会上传结果,可以是war也可以是jar,一般使用nexus。因为有版本号和md5,所以可以保证环境中安装的包是某个包的某个版本。我们也遇到过一些使用FTP的人,版本号的维护很难保证,升级回滚巷子也很难。另一种是没有md5,有可能是包不完整,一旦发生,很难查出来。如果使用容器,还需要编译Dockerfile并使用Docker镜像作为交付,这样可以实现更好的环境一致性,保证原子升级和回滚。每天下班前,需要将当天的代码提交到库,晚上统一进行环境部署和集成测试。每天凌晨,都会有一个自动化的脚本,将Docker镜像通过编排部署到一个完整的环境中,然后运行集成测试用例。集成测试用例应该是基于API的,很多公司都是基于UI的。这样一来,由于UI变化太快,UI无法覆盖所有场景,所以还是建议UI和API分离。通过API进行集成测试,配合日常测试,可以保证每天晚上的版本都是可交付版本,也可以保证我们在拆分微服务的时候,即使做了很多改动,也不会因为破坏原有的到新的修改。能通过的测试用例保证不出新破旧。这个集成测试或者说回归测试是每天晚上在一个全新的环境中进行的,就是持续部署和持续交付。如果某天测试失败,会发邮件,因为那天谁提交了哪个提交,导致测试失败,大家抄袭,又是一个压力。所以在第二天的站会上,你就会知道你昨天完成了哪些功能,有没有提交,有没有完成单元测试,有没有通过集成测试。需要给大家一个交待,然后进入新的一天。发展。两周内,一个周期完成后,就可以上线到生产环境,可以通知授权的运维进行操作,不过也是通过自动化脚本部署。这就是全过程,层层保证质量。由此可见,敏捷开发、持续集成、持续交付、持续部署与DevOps是相互关联的。如果缺少一个,该过程将无法进行。相关代码结构代码结构往往包括:API接口包访问外部服务包数据库DTO访问数据库包服务和业务逻辑外部服务如果使用DubboRPC,API接口往往在一个单独的jar中,与服务器共同依赖和客户。但是,如果你用的是SpringCloud的restful方式,就不需要了。你只需要在自己的代码中定义它,它就会以json的形式传递。这样做的好处是当jar有多个版本需要升级时,关系很复杂,维护起来很困难,而json方式更好的解决了这个问题。该模块提供了哪些接口,你只需要在API接口包下找到即可。因为不管是Dubbo还是SpringCloud,对接口的调用都会重试,所以接口需要幂等。对外服务访问的封装,将所有对外访问分离出来,有以下三个好处:可以抽象,可以在服务拆分的时候使用。比如原来的支付逻辑在order模块,应该把payment分离出来,会有一个抽象层,涉及到旧的支付方式,或者调用本模块中的逻辑,对新的支付方式使用远程调用,就方便多了。可以实现fuse,当被调用的服务异常时,可以在这里返回backing数据。可以实现mock,非常适合单元测试,可以自己测试,不依赖其他服务。DTO和访问数据库的包,看到这些数据结构有助于程序员快速掌握代码逻辑。不知道你有没有这样的经历。当你看一个开源软件的代码时,首先要看的是它的数据结构。如果理解了数据结构和关系,代码逻辑就更容易理解了。如果不了解数据结构,光看逻辑很容易搞糊涂。还有核心代码逻辑和接口的实现。这就是软件代码设计的内功所在,但不是流程可以控制的。上面说到接口设计规范,Dubbo和SpringCloud都会对接口进行重试,所以接口需要幂等。也就是说,多次调用应该产生一致的结果,比如转1元,因为当调用失败或者超时重试时,最终的结果应该还是转1元,而不是调用两次转2元.幂等判断越早越好,可以用ID作为判断条件。接口的实现要尽量避免阻塞,可以使用异步方法来提高性能。接口应该包含可以区分不同情况的异常,而不是抛出一个宽泛的无法吞噬异常的Exception。接口的实现必须具有足够的容错能力和对不同版本的兼容性。引入新接口时,采用先添加后删除的方法。接口应该有很好的注释。关于代码设计对于代码设计,这里常说的SOLID原则:S是单一职责原则。如果你的代码中有一个类的行数过多,你可能需要重新审视这个类是否承担了过多的责任。责任。O就是开闭原则,有点啰嗦。它对扩展开放,对修改关闭。想法是直接修改代码是一件很危险的事情,因为你不知道这段代码是谁用的,当你用的时候,你面对的是什么样的情况。因此,不要草草修改一段代码,而是选择用接口调用,用实现来扩展。当你要实现一个新的功能时,不要改动原来的代码,也不要使用if-else。相反,您应该扩展实现,使原始调用代码的逻辑保持不变,并在新情况下使用新实现。代码逻辑。L是Liskov替换原则。如果编程是基于接口的,那么子类必须能够扩展父类的功能。如果不是,则意味着它不应该继承这个接口。比如你在实现的时候,发现接口里面有个方法,这里不能实现。不是接口设计的问题,但是不能继承这个接口,一定不能有notimplemented之类的实现方法。我是接口隔离的原则。界面不应设计得庞大而全面。一个接口暴露了所有的功能,使得客户端依赖于它不需要的接口或接口方法。相反,应该将界面细分和提取,不要在一个界面中混合过于灵活的参数和变量。D是依赖倒置原则。模块A依赖于模块B,如果修改了模块B,反而需要修改A,就是依赖太紧密的问题。这就是我们常说的,你变了,我没变,我为什么要变。如果基于抽象接口编程,将修改隐藏在背后,就可以实现依赖的解耦。以上是模块内部通用的设计原则,模块之间,就是常说的云原生应用的十二条原则。相关的配置文件都在代码仓库中,需要管理的配置文件往往在src/main/resource下。配置管理过去都是使用profile来管理,dev、test、production使用不同的配置文件。当配置很多的时候,还是挺痛苦的,不断修改配置。每次上线都要仔细检查各种配置,看得眼花缭乱才敢上线。我们可以将配置分为以下三类:内部配置项(启动后不变,更改需要重启)集中式配置项(配置中心,可以动态下发)外部配置项(外部依赖,与环境相关)是整理配置到时候可以按照三类分类,分别管理。使用容器后,可以将很多内部配置项固化在配置文件中,放到容器镜像中。如果需要在启动时修改,可以在容器启动时通过环境变量在编排文件中修改。依赖的内部服务的地址可以通过在容器平台Kubernetes中配置服务名来发现。只需在配置文件中配置名称即可,无需配置真实地址。Kubernetes可以根据不同的环境自动关联不同的命名空间,大大简化了配置。当然也可以通过服务中心Dubbo和SpringCloud来实现内部服务的相互发现。依赖的外部服务,如MySQL、Redis,在不同的环境下,地址往往是不同的。也可以配置Kubernetes对外服务名,不用一一检查,担心测试环境连到生产环境的IP地址。还有一些需要动态修改的集中配置项,比如限流、降级开关等,需要通过统一的配置中心进行管理。数据库版本代码可以很好的版本化,应用也可以使用镜像进行原子升级和回滚。唯一比较难做的就是如何管理数据库的版本。有一个工具Flyway可以更好地做到这一点。代码中,Flyway需要有如下结构:src/db/migration中的SQL文件,命名规则,如:V1__2017_4_13.sql,V开头+版本号+双下划线+描述,后缀为sql.添加Flyway的Java类实现迁移方法。在数据库中,Flyway会自动增加SCHEME_VERSION表。当服务启动时,会调用Java类的迁移方法。它会对指定路径下的sql语句的版本号进行排序,并按照这个排序执行。当每个SQL文件被执行时,元数据表将按照格式进行更新。当服务重启,Flyway再次扫描SQL时,会检查元数据表中的迁移版本。如果要执行的迁移脚本的版本小于等于当前版本,Flyway会忽略,不会重复执行。但是Flyway从来没有解决数据库升级和回滚的代码兼容性问题。这个问题问的人太多了,代码可以灰度发布,但是为什么数据库是灰度的?代码升级了,发现不对可以回滚,回滚数据库。如果可以停止服务,自然要用数据库快照备份的方式回滚。如果不能停止服务,就只能在代码层面做兼容了。每次涉及到数据库升级,都是一件大事。当然,代码应该有一个开关,保证随时可以切换回原来的逻辑。