使用功能開關更好地實現持續部署

本文轉載自InfoQ上文章http://www.infoq.com/cn/articles/function-switch-realize-better-continuous-implementations


摘要

爲了快速發佈開發完成的功能,現代的互聯網企業通常會以比較快的迭代週期來持續的發佈。但是有時候因爲技術或者業務上的原因,需要在發佈的時候將某些功能隱藏起來。一種解決方案是,在獨立的分支上開發新功能,全部開發測試完成之後,才合併回主幹,準備發佈。這也就是我們經常提到的功能分支(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的實例。其原理如下圖所示:

02.png

類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這個具體類型,現在爲了方便替換,引入一個接口作爲抽象層。

03.png

在得到了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

03.png

  • 針對開關開的時候寫一系列的自動化測試,使用加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判斷等等代碼,減少系統中並存的開關數量。

  • 設計一個開關的時候要非常小心,不要和其他開關有依賴關係。

總的來說,功能開關對於持續發佈,平穩發佈,甚至提高代碼質量都有着積極的作用,是一個值得嘗試的實踐。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章