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

阅读数:5214 2013 年 6 月 24 日

摘要

为了快速发布开发完成的功能,现代的互联网企业通常会以比较快的迭代周期来持续的发布。但是有时候因为技术或者业务上的原因,需要在发布的时候将某些功能隐藏起来。一种解决方案是,在独立的分支上开发新功能,全部开发测试完成之后,才合并回主干,准备发布。这也就是我们经常提到的功能分支(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>
  3. 在 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 判断等等代码,减少系统中并存的开关数量。
  • 设计一个开关的时候要非常小心,不要和其他开关有依赖关系。

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