免费下载案例集|20+数字化领先企业人才培养实践经验 了解详情
写点什么

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

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

评论 2 条评论

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

Etcd API 未授权访问漏洞修复

TiDB 社区干货传送门

监控 实践案例 故障排查/诊断

Vue.nextTick核心原理

yyds2026

Vue

武汉web前端培训学习前景如何

小谷哥

深度解读Webpack中的loader原理

Geek_02d948

webpack

GPU服务器到底有什么作用?

Finovy Cloud

云渲染 GPU渲染 云渲染平台

大专学历通过大数据培训好找工作吗?

小谷哥

软件测试 | 测试开发 | 工作多年,技术认知不足,个人成长慢,职业发展迷茫,该怎么办?

测吧(北京)科技有限公司

测试

java培训学习后怎么样

小谷哥

聊聊Vuex原理

yyds2026

Vue

记一次TiDB数据库报错的处理过程

TiDB 社区干货传送门

管理与运维

使用Online unsafe recovery恢复v6.2同城应急集群

TiDB 社区干货传送门

实践案例 集群管理 管理与运维 数据库架构设计 6.x 实践

文盘Rust -- 把程序作为守护进程启动

TiDB 社区干货传送门

开发语言

破局 NFT 困境:实用型 NFT 是什么?

TinTinLand

区块链 NFT 元宇宙 web3

干啥啥都行,这次又拿了第一名!

青藤云安全

网络安全 主机安全 青藤云安全

对比四大智能合约语言:Solidity 、Rust 、 Vyper 和 Move

One Block Community

区块链 程序员 编程语言 Solidity Move

TiDB上云之TiDB Operator

TiDB 社区干货传送门

集群管理 TiDB 底层架构 管理与运维 数据库架构设计

用低代码平台搭建低代码平台

iofod jude

佛萨奇1.0 2.0矩阵公排项目系统开发详情

开发微hkkf5566

在web前端学习中如何学习知识点

小谷哥

我偷偷学了这5个命令,打印Linux环境变量那叫一个“丝滑”!

wljslmz

Linux 运维 环境变量 11月月更

Spark+ignite实现海量数据低成本高性能OLAP

张磊

大数据 spark 分布式数据库 Ignite 内存计算

解读Vue3模板编译优化

yyds2026

Vue

手写一个webpack插件

Geek_02d948

webpack

企业内部即时通讯工具WorkPlus,支持内网私有化部署

WorkPlus

基于OpenHarmony L2设备,如何用IoTDeviceSDKTiny对接华为云

华为云开发者联盟

云计算 华为云 企业号十月 PK 榜

如何通过机器学习赋能智能研发协作?

LigaAI

人工智能 智能化 LigaAI 研发协作平台 亚马逊云科技

学历通过大数据培训学习合适吗?

小谷哥

看直播,领报告 |《勒索软件的认识与防御指南》最新发布!

青藤云安全

网络安全 勒索病毒 主机安全 勒索 青藤云安全

COSCon'22 第七届中国开源年会圆满落幕

腾源会

开源

固定QPS异步任务功能再探

FunTester

文盘Rust -- 起手式,CLI程序

TiDB 社区干货传送门

开发语言

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