写点什么

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

  • 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:2410608

评论 2 条评论

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

一文读懂Jina生态的Dataclass

Jina AI

多模态机器学习 多模态 跨模态

峰会倒计时3天!硅谷传奇投资人登陆专场,围炉共话分析型数据库的爆发式增长

StarRocks

数据库

阿里前端面试题

loveX001

JavaScript 前端

Qt|控件QPushButton讲解

中国好公民st

qt 按钮 9月月更

写给自己的react面试题总结

beifeng1996

前端 React

分布式系统中如何实现临界资源的互斥访问

华为云开发者联盟

云计算 后端 开发 企业号九月金秋榜

人脸关键点的应用场景及重难点解析丨Dev for Dev 专栏

声网

算法 Dev for Dev 人工智能’

大数据调度平台Airflow(五):Airflow使用

Lansonli

airflow 9月月更

RabbitMQ怎么保证消息不被重复消费以及消息的可靠性

知识浅谈

RabbitMQ 9月月更

Pipy 同一 IP 多个 SSL 域名

Flomesh

Service Mesh 服务网格

【网络安全】记一次杀猪盘渗透实战

网络安全学海

黑客 网络安全 信息安全 渗透测试 WEB安全

“密评”,听说过没

华为云开发者联盟

云计算 网络安全 开发 企业号九月金秋榜

阿里云大数据助力知衣科技打造AI服装行业核心竞争力

阿里云大数据AI技术

人工智能 大数据 模型训练 客户案例

带你认识全新的华为云IoT路网数字化服务

华为云开发者联盟

云计算 后端 物联网 交通 企业号九月金秋榜

算法基础(三)| 二分图解及代码模板

timerring

算法 二分查找 9月月更

慢查询 MySQL 定位优化技巧,从10s优化到300ms

程序知音

Java MySQL 数据库 后端技术 MySQL 数据库

MySQL DDL执行方式-Online DDL介绍

京东科技开发者

Java MySQL 数据 ddl DML

校招 | StarRocks首次Open Day报名ING!

StarRocks

数据库

云原生数字化转型与金融信创建设,鱼和熊掌可兼得

BoCloud博云

云计算 云原生 信创

传媒产业的数字化怎样被小程序影响

Geek_99967b

小程序

react面试如何回答才能让面试官满意

beifeng1996

React

2022-09-21:有n个动物重量分别是a1、a2、a3.....an, 这群动物一起玩叠罗汉游戏, 规定从左往右选择动物,每只动物左边动物的总重量不能超过自己的重量 返回最多能选多少个动物,求一个

福大大架构师每日一题

算法 rust 福大大

linux入门学第一天

乌龟哥哥

9月月更

来自大厂 10+ 前端面试题附答案(整理版)

loveX001

JavaScript 前端

本地服务调用K8S环境中的SpringCloud微服务实战

程序员欣宸

Kubernetes 9月月更

羊了个羊闯关游戏开发(链改代币分红)

开发微hkkf5566

火山语音7篇论文入选国际顶会Interspeech

科技热闻

最新MLPerf基准测试:基于阿里云GPU云服务器的AIACC在图像识别封闭式场景下夺冠

阿里云弹性计算

图像识别 GPU实例 AIACC

如何创建 Angular library 并在生产环境中消费

汪子熙

JavaScript typescript angular library 9月月更

创作者能从设计师那学到什么样的设计原则

宇宙之一粟

读书笔记 设计 读书感悟 设计原则 9月月更

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