写点什么

使用功能开关更好地实现持续部署

  • 2013-06-24
  • 本文字数:4471 字

    阅读完需:约 15 分钟

摘要

为了快速发布开发完成的功能,现代的互联网企业通常会以比较快的迭代周期来持续的发布。但是有时候因为技术或者业务上的原因,需要在发布的时候将某些功能隐藏起来。一种解决方案是,在独立的分支上开发新功能,全部开发测试完成之后,才合并回主干,准备发布。这也就是我们经常提到的功能分支(feature branch)。本文将介绍如何使用功能开关(feature toggle)来更好地解决这个问题,及其在一个典型 Spring web 应用程序中的具体实现,最后讨论了功能开关和持续集成如何协同工作。

功能分支的问题

功能分支可以帮助我们同时开发多个新功能,而不对发布的节奏造成影响,这解决我们一开始提到的那个持续发布的需求,但是它也会引入很多问题。在 Martin Fowler 的文章中已经很全面的阐述了这些问题,简单总结如下:

  • 分支分出去时间长了往主干合并的时候会出现很多的代码冲突。
  • 在一个分支中修改了函数名字,但是如果在其它分支中大量使用修改前的函数名,则会引入大量编译错误。这点被称为语义冲突(semantic conflict)
  • 为了减少语义冲突,会尽量少做重构。而重构是持续改进代码质量的手段。如果在开发的过程中持续不断的存在功能分支,就会阻碍代码质量的改进。
  • 一旦代码库中存在了分支,也就不再是真正的持续集成了。当然你可以给每个分支建立一个对应的 CI,但它只能测试当前分支的正确性。如果在一个分支中修改了函数功能,但是在另一个分支还是按照原来的假设在使用,在合并的时候会引入 bug,需要大量的时间来修复这些 bug。

功能开关

下面我们来看看功能开关是如何解决上述的问题的。

第一原则,代码库中不再引入任何分支,所有的代码都提交到同一个主线(mainline),在开始开发一个新功能的时候,引入一个布尔值的配置项,使得在该配置项为假时,应用程序的外部行为和没有引入该功能之前保持一致;而在配置项为真时,应用程序才展现出那些新开发的功能。

实现的方式也很直观。在所有跟该功能相关的代码中都会读取该配置项的值,如果配置项值为真,则使用新功能,如果为假,则保持以前的逻辑。我们把在某处代码使用到该布尔配置项称为该处代码使用了该开关

对于一个典型的 Spring 的 web 项目,代码库中会包括 Java 代码、JSP 代码,IOC 配置文件,还有 CSS 和 JS 文件。这些都是代码,根据不同的业务需求,这些代码都有可能会用到开关。为了能够在这些代码中方便地获取开关的值,使用开关,我们需要一些基础设施来支持。

如上图所示。需要在“功能开关”的模块中实现所需要的基础设施,然后配合配置文件的内容来对应用程序的行为进行控制。下面我们就配置文件和基础设施做一些讨论。

功能开关配置文件

Spring 中使用 MessageSource 来实现国际化,其本质上就是从一系列的 properties 文件中读取键值对。我们这里使用这些 properties 文件来存储功能开关的配置项,如这样的项:

featureA.isActivated=true

在 MessageSource 之上我们封装了一层 ApplicationConfig,用来提供便利的方法(如 getMessageAsBoolean 等)来获取配置项的值。

功能开关基础设施

为了在代码中使用到功能开关配置文件的内容。我们需要实现一些基础设施。

Java 代码中

将 ApplicationConfig 的实例 bean 注入到需要应用开关的其他 bean 中,然后在其它 bean 中读取相关配置项。这种注入可以很容易的使用 Spring 来完成。

JSP 中

自定义一个 JSP Tag 来在 JSP 中使用配置文件中的配置项,其使用方法如下:

<ns:config code=“featureA.isActivated” var=”featureValue” />

在调用过该 tag 之后,就可以使用 featureValue 这个变量来引用对应配置项的值了。

IOC 配置文件中

在 Spring 的 IOC 配置文件中,同样可以使用自定义的 Tag 来动态选取 bean 的实例。其原理如下图所示:

类 A 依赖于 B 接口,bean1 和 bean2 是在 Spring 配置文件中定义好的两个实例 bean,他们的类型都是 B 接口的实现类,因此他们都可以被注入到 A 的实例 bean 中。通过开关的控制,可以把不同的实例注入到类 A 的实例 bean 中。

关于 CSS 和 JS,我们并没有再引入更多的基础设施,通过 JSP 中的控制就可以完成对 CSS/JS 的控制。

例子一

问题:开发了一个新的功能,而该功能需要通过主页上的一个链接访问。

利用上述的基础设施,可以这么实现:

  1. 在资源文件中定义该功能开关的状态。

//feature-config.properties

show.link.feature=true
2. 在 JSP 中使用自定义的 ns:config Tag 来读取配置项的值,根据该值决定是否显示链接。

//index.jsp

<ns:config name=“show.link.feature” var=“showLink” />

复制代码
<c:if test="${showLink}">
<a href="/link/to/new/function">link to new function
</c:if>
  1. 在 Controller 代码中读取开关的值,如果开关状态为关闭,则在访问该功能时直接返回 404。
复制代码
//NewFunctionController.java
......
protected ModelAndView handle(HttpServletRequest request, HttpServletResponse
response, Object command, BindException bindingResult) throws Exception {
if(!applicationConfig.getMessageAsBoolean("show.link.feature")) {
return new ModelAndView("404.jsp");
}
//normal logic
}
......

例子二

问题:我们的产品已经在使用 google map API V2 的服务,现在要升级到 V3。

首先还是要引入一个功能配置项:feature.googleV3Service.isActivated。

google map API V2 相关的逻辑全部存在于一个具体类型 GoogleMapV2Service 中。而 SearchLocationService 直接依赖于 GoogleMapV2Service 这个具体类型,现在为了方便替换,引入一个接口作为抽象层。

在得到了 GoogleMapService 这个抽象层之后,就可以实现新的 GoogleMapV3Service,并且使用它替换原有的 GoogleMapV2Service。最后如果有必要的话再将接口去除掉。

这时候就可以使用上面提到的方法,在 IOC 中使用功能开关基础设施来控制 SearchLocationService 使用的到底是 V2 的还是 V3 的服务。具体的代码如下:

复制代码
<ns:runtime-conditional id="googleMapService"
type="com.service.GoogleMapService"
code="feature.googleV3Service.isActivated">
<ns:targetref="googleMapV3Service"/>
<ns:default-targetref="googleMapV2Service" />
</ns:runtime-conditional>

其中,ns:runtime-conditionl 利用了 Spring 的自定义命名空间技术来侵入到 bean 的装配过程中。整个 Tag 其实是定义了一个名为 googleMapService 的 bean。在这个自定义 Tag 的实现中,它同样会去读取功能开关 (feature.googleV3Service.isActivated)的状态,然后根据这个状态来把正确的 bean 赋给 googleMapService 这个 bean。别人只需要直接使用 googleMapService 这个 bean 就可以了。使用这种方式,我们就可以轻易而安全的在两个 bean 之间切换功能。

功能开关如何支持持续集成

上面两个例子给大家展示了在一个 Spring 应用程序中如何使用功能开关。通过这种方式我们所有的代码,不管有没有完全做完,都存在与一个分支上了,从而也就解决了上面所提到的各种与合并相关的问题。同时还可以方便的通过改变配置文件的方式来更改应用程序的状态,如果在线上发现了问题,也可以快速的关闭该功能。

但是这里面有个问题,在某次应用程序启动的时候,某功能的配置项的值必须是确定的,要么是开启,要么是关闭。那么在持续集成服务器上跑自动化验收测试的时候到底应该如何设置配置项的值呢?

假设我下次发布期望该功能是关闭的,那么我每次跑测试的时候就让该功能关闭,这样可以保证本次发布是安全的。但是在开关开的时候才生效的那些功能是没有办法被自动化测试覆盖的。如果该功能出了问题,持续集成服务器也没法发现。那么在未来准备发布该功能的时候,我们还要再打开开关,做一些自动或者手动的测试,出现 bug(很有可能,因为持续集成服务器并没有保护到我这个功能不被破坏)了再集中修复。这样还是没能完全解决功能分支所带来的问题。

为了解决这个问题,我们引入了两个 CI Job:Regression 和 Progression

  • 针对开关开的时候写一系列的自动化测试,使用加 tag 的方式标记它们为 @feature_on。
  • 当开关打开的时候,因为系统行为变化了,所以之前存在的某些相关的自动化测试就没法再通过,我们把这些因为开关打开而失败的测试标记为 @feature_off,意为只有该开关关掉的时候这些测试才有效。
  • 创建一个叫做 Regression Tests 的 CI Job,即上图黄色的那个圈。其包含了加 @feature_off tag 的测试和没有 tag 的测试。启动应用程序的时候把功能开关关闭,然后跑这个圈里面所有的测试。
  • 创建一个叫做 Progression Tests 的 CI Job,即上图灰色的那个圈。其包含了加 @feature_on tag 的测试,同样也包含了没有 tag 的测试。启动应用程序的时候把功能开关打开,然后跑这个圈里面所有的测试。
  • 每次代码提交都会触发 Regression 和 Progression 两个 Job 的构建。

使用这种方式,我们可以保证应用程序的不同状态在每次提交都是被测试到的,如果有任何问题,可以第一时间发现、修复。增强了对应用程序正确性的信心,也为在产品环境切换开关状态提供了足够的信心。

功能开关的陷阱

上面我们提到的是只有一个功能开关的情况,那么如果有两个,会是什么情形呢?假设两个功能分别叫 A,B。那么理论上来说我们应该测试到 A_on, B_on; A_on, B_off, A_off, B_on; A_off, B_off 这四种情况。也就是说需要有四个 CI Jobs 与之对应,如果有 3 个功能就需要 8 个 Jobs。这显然是不可接受的。

这里就涉及到使用功能开关的一个很重要的原则,功能开关之间不要有依赖。也就是说不管别的开关状态如何,只要 A 的开关是开的,那么所有的 @featureA_on 的测试就一定可以通过,所有 @featureA_off 的测试就一定不能通过。如果保证每个功能都是相互独立的,我们就不需要测试所有的组合,还是只需要测试两种就可以了。比如这次发布需要的状态时 A_on, B_off,那么在 Regression 中就按照这个状态起应用程序,并跑相关的测试;在 Progression 中按照 A_off, B_on 的状态起应用程序,并跑相关的测试。

因此需要限制项目中功能开关的数量,并且严格避免开关之间的依赖,才能真正从功能开关中获益。

总结

可以看到,功能开关相比功能分支确实能给我们带来很多的好处:减少合并,真正持续集成,在产品环境可以打开或者关闭某个功能,等等。但是这些好处也不是免费得到的。总结一下使用功能开关需要做的:

  • 创建使用功能开关的基础设施。在本例中就是在 JSP 中访问资源文件,通过自定义的 Spring namespace 来动态确定 bean 的值等等。
  • 在代码中根据功能开关的状态来决定代码的行为。很有可能会引入比较多的 if/else 在 Java 代码,JSP 代码中。
  • 需要跑两个 CI Jobs。不过在时间上其实并没有太多的损耗,因为我们可以通过加一个 CI slave 并行跑就可以了。
  • 在某功能已经稳定地在产品环境下跑了一段时间之后,要及时拆掉跟这个开关相关的配置,if/else 判断等等代码,减少系统中并存的开关数量。
  • 设计一个开关的时候要非常小心,不要和其他开关有依赖关系。

总的来说,功能开关对于持续发布,平稳发布,甚至提高代码质量都有着积极的作用,是一个值得尝试的实践。

2013-06-24 11:2410506

评论 2 条评论

发布
用户头像
最后一幅配图有问题,与前一个重复
2020-06-11 09:12
回复
用户头像
没看懂,
2019-10-14 16:21
回复
没有更多了
发现更多内容

C/C++最佳实践

jiangling500

c c++ 最佳实践

c++bind函数使用

良知犹存

c++

6小时搞定云原生:从基础概念到上手实践

京东科技开发者

云原生

一文搞懂ReactNative生命周期的进化

凌宇之蓝

react.js 面试 大前端 React Native

1024!奈学教育致敬程序员3+2战略发布会重磅来袭

奈学教育

1024 奈学教育

架构作业:一致性hash

Nick~毓

编码之路,与君共勉

yes

程序人生

在线EXCEL编辑器-Luckysheet

奇异石榴果

Java 开源 Excel bigtable js

项目吐槽之需求分析一

Geek_XOXO

项目管理 pmp

服了,这款开源类库可以帮你简化每一行代码

沉默王二

Java GitHub 后端 hutool

java安全编码指南之:线程安全规则

程序那些事

java安全编码 java安全 java安全编码指南 java代码规范 java代码安全

Docker架构

混沌畅想

Docker 容器 Docker架构

一份超级完整实用的PyCharm图解教程,8K字赶紧收藏起来

计算机与AI

Python IDLE 开发环境

一文带你读懂 Swift 社区最新开源的算法库

镜画者

ios swift 算法 apple

iOS性能优化 — 二、卡顿监控及处理

iOSer

性能优化 编程语言 监控 ios开发 卡顿

勾魂!在Github白嫖左程云1470页数据结构与算法+视频

996小迁

Java 架构 面试

Linux内核系统结构

Linux 操作系统 内核 系统调用 操作系统结构

架构师训练营第2期 第1周 作业二:学习总结.md

老腊肉

Scikit-Learn中的特征排名与递归特征消除

计算机与AI

学习 数据科学 特征选择 降维 scikit-learn

发布3个月获得5K Star的Luckysheet - 基于MIT协议的开源电子表格

奇异石榴果

Java 开源 大前端 Excel html/css

【得物技术】谈谈缓存的一二三四五

得物技术

缓存 架构 技术 缓存穿透 缓存击穿

程序员喜欢的 5 款最佳最牛代码比较工具

程序员生活志

编程 工具

week04 作业

xxx

week04总结

xxx

甲方日常 36

句子

工作 随笔杂谈 日常

分布式缓存架构,消息队列,负载均衡

garlic

极客大学架构师训练营

架构师训练营第2期 第1周 作业一:食堂就餐卡系统设计

老腊肉

「架构师训练营」第 1 周作业 - 食堂就餐卡系统设计

小黄鱼

极客大学架构师训练营

Java中的5大队列,你知道几个?

王磊

Java

spring-boot-route(二十三)开发微信公众号

Java旅途

Java Spring Boot

分布式文件存储数据库 MongoDB

哈喽沃德先生

数据库 nosql mongodb mongo 非关系型数据库

使用功能开关更好地实现持续部署_DevOps & 平台工程_崔力强_InfoQ精选文章