1. 程式人生 > >UI 自動化常用設計模式

UI 自動化常用設計模式

作者:孫高飛

今天我只針對一些實際的場景來說一下如何使用這些設計模式來完善UI自動化。 

工廠

每種語言實現設計模式的方式都不一樣,這裡僅以java為例。 一般來說,工廠模式是為了把建立一個物件的操作都集中在一起管理,其他所有需要用到這個物件的程式碼都呼叫工廠類來建立物件。 在UI自動化中,工廠類有一個重要的作用就是提供資料的能力。 這裡直接上一個例子, 在我的專案中有這樣一個場景, 我們的測試都分模組的, 不同的模組有不同的QA。 測試模型中心模組的QA想要測試的話就需要依賴建模IDE來產出各種各樣的模型。 那根據上一個帖子我講到的一個設計原則--模組間有資料依賴的時候。每個模組自己負責提供對外介面

。 模型IDE的QA需要提供一個可以生產出各種不同模型的API來。 如下:

 

上面我們我們用一個簡單工廠來實現建立各種模型。 其他模組呼叫此工廠方法滿足自己對模型的需求。 如果我們建立模型的型別更復雜的話,可以引入工廠模式和抽象工廠模式。 但實際上我最常用的還是簡單工廠,偶爾用工廠模式抽象工廠基本沒用過。使用設計模式的時候最容易出現的是過度設計, 把過於複雜的模式硬搬到專案中來。 這是不可取的。 

那接下來說一說這個工廠存在的意義吧。 簡單工廠算是設計模式裡最簡單的了, 簡單到它幾乎不是一個什麼模式。 它其實只有一種思想,就是把建立一個東西的操作都統一放到一起,呼叫方只需要知道我要一個東西,我需要把什麼引數傳遞進來就可以得到這個東西。 比如我們的這個例子裡,呼叫方只需要傳遞我需要一個什麼型別的模型的引數。 至於如何建立這個模型它不需要知道,裡面包含了多複雜的UI操作它也不需要知道。 這樣做的好處是:

  • 程式碼複用,我們使用工廠的來建立的東西一般都是比較複雜的,需要很多的步驟才能建立。 如果只是隨便new一下就可以得到的物件也就犯不著專門搞個工廠方法了。 如果任由寫case的人根據自己的想法去建立這些物件,不僅造成了很多的重複程式碼。 而且這些碎片的話的程式碼在後期的維護上也是一個難以接受的事情。
  • 封裝變化,我們把建立模型的所有操作都統一放在一起。之後生產模型的操作發生變化,比如需求變動。那我們只需要改動這一處就可以了。而且呼叫方也完全不感知
  • 解耦,就如開始說的那個設計原則一樣, 呼叫方不感知複雜的模型生產過程, 達到解耦的作用。 在UI自動化中,尤其是業務邏輯特別複雜的大型專案中。 多人協作有個比較重要的點在這裡提一下。 就是解耦,不要讓其他模組的人感知自己模組的任何實現細節。 他們瞭解的越少,操作的越少, 出錯的概率就越小,學習成本就越小。 畫地為界,分而治之。 其實我個人覺得整個設計模式就是在解決兩件事情:解耦和程式碼複用

單例

我們有了上面的工廠方法來幫助我們建立模型, 但是這裡有個問題。 就是我有太多的case依賴這些模型了。 如果每個case都執行一遍上面的操作重新建立一個模型的話會有兩個問題:

  • UI操作尤其耗時,尤其是生產模型這種非同步操作
  • UI本就不穩定,這些重複的操作會增加case失敗的概率

所以我們希望除了有這種建立新模型的能力之外。 還能夠複用之前已經產生的模型。 於是我們就有了使用單例模式的需求。 一般提到單例模式,基本上就是懶漢式,餓漢式什麼的。 但這兩種大概率都是不可用的。 因為首先我們的操作是延遲載入的,只有到了使用的時候才會去UI上執行建立模型的操作。 總不能直接在類載入的時候就執行吧。 至於在不加鎖的情況下判斷一下物件是否為null也是不行的。 因為現在的大規模UI自動化都是併發執行的。 所以可選的方案就是加鎖的雙重檢查機制以及靜態內部類了。 這裡主要講一下靜態內部類吧, 雙重檢查機制估計大家都玩爛了。 如下:

 

  • 靜態內部類不會再LRModel的類載入的時候就載入,而是有人呼叫getInstance 的時候才會載入。所以保證了延遲載入
  • java 的classloader會保證靜態內部類的執行緒安全,所以不用擔心併發的問題

上面是靜態內部類的實現方式,優點是相較於鎖的雙重檢查方來說實現起來簡單,坑少。 比如沒有那個經典的指令重排序的問題。 當然缺點也明顯, 就是一旦建立物件失敗, 那以後就再也沒有機會重新建立物件了。 而UI自動化又是出了名的不穩定。 所以還是要慎重的。 

模板

模板模式在UI自動化中比較常用的原因是在產品中有很多的操作路徑是複用的。 所以我們可以使用模板模式, 把固定的路徑抽象出來,由子類去實現那些獨立的邏輯。 比如:

 


上面是我們的產品引入一份資料的邏輯。 我們的資料引入有很多種型別。 比如從本地引入, 從資料庫引入,從hdfs引入,從ftp上引入等等等等。但是他們的基本步驟都是一樣的(看截圖中的註釋), 所以模板模式的思想是使用父類來規定到執行操作的步驟, 為了程式碼複用所以也會實現一些通用的步驟比如所有的引入都得點選某些button,填寫一些都行。 然後留下一些abstract的方法給子類實現。 這種父類規定骨架,子類實現細節的方式就是模板方法了。 在這裡我們的父類定義好了所有的步驟,但是部分的具體實現細節由子類完成。 這裡我們發現子類需要實現兩個方法

  • 每個資料引入的關於生成table的操作的setTableConfig
  • 每種資料引入的檔案配置方式操作的setFileConfig

當然模板方法也是可以有較深的結構的。 比如上面說的一些引入方式雖然都屬於資料引入,但是也分為兩大類, 一個是結構化資料,一個是圖片資料。 而且凡是屬於結構化資料的引入方式有很多步驟都是相同的。 凡是屬於圖片資料引入的方式的大部分步驟也是相同的。 所以我們繼續有抽象類如下:

 

上面是結構化資料的抽象類。 他實現了父類IDataload的setTableConfig方法。 因為所有結構化資料引入的這個頁面操作都是一樣的。然後才是我們具體的本地檔案的資料引入的類。如下。

 

這個具體的本地檔案引入的類實現了方法setFileConfig。 這樣我們就看到了這個模板模式的全貌。 

  • 基類IDataload負責定義執行步驟,以及個別UI操作的實現。 規定子類必須實現setTableConfig和setFileConfig這兩個方法
  • 類StructureDataLoad繼承基類IDataload,並實現了setTableConfig方法。 因為所有的結構化資料引入在這裡使用的是同樣的頁面
  • 具體的實現類LocalFileDataLoad繼承StructureDataLoad,代表著本地資料引入並實現了針對於本地檔案引入所獨有的頁面操作setFileConfig

所以實際上呼叫方要做的事情就是這樣的

 

模板模式的優點:

  • 程式碼複用, UI上很多操作路徑都是重複的,甚至說不同的業務流程操作中的部分頁面使用的是相同的頁面。 使用模板模式可以很好的整理我們的程式碼結構,將業務邏輯分類並組織起來,可以服用的程式碼由上層的父類實現。

模板模式的缺點:

  • 如果類層級結構較多的時候,維護起來有點麻煩。

策略

策略模式也是非常常用的, 甚至很多時候它是其他模式的基礎。 它的思想也特別簡單。 當初它誕生的原因是為了擺脫大量的if else, 把每個條件分支做一個策略類。 具體原理我就不介紹了,不知道的可以google一下,網上一堆講設計模式的文章,我也講不出什麼花來,我就講在UI自動化中我們怎麼做。 舉一個最簡單的例子。如下:

在我們的測試中,大量的case都需要經過如下的操作步驟:

  • 開啟瀏覽器
  • 登入
  • 進入模型IDE頁面
  • 建立一個工程
  • 建立一個DAG
  • 在DAG頁面上build一個DAG
  • 執行DAG並等待執行結束

既然大量的case都需要執行上面的操作,那我們當然就希望能做到程式碼複用,所以就寫了一個方法來做這個事情。 但是我們發現這些步驟中有一個操作是無法預測的。 也就是如何Build一個DAG, 我們的產品的DAG如下

 

每個DAG中都有不同的運算元組合在一起,形成一個圖形。並且每個運算元有它不同的配置。 要在UI上build一個DAG還是需要很多的操作的。 並且case之間要build的DAG的圖形也是不一樣的。 有的case需要5個運算元組成一個圖形, 有的case可能需要10個運算元組成一個圖形。 這些是完全不一樣的操作, 也就是說雖然我們想寫一個方法來封裝上面所有的操作。但是其中構建DAG這一步是我們預先控制不了也複用不了的。這怎麼辦? 所以我們索性把build DAG的操作定義為一個介面。 如下:

 

它只有一個方法,就是build(), 意思是這個方法要實現build一個DAG的操作。 但具體build一個什麼圖形什麼配置的DAG, 由子類自己實現。 
於是我們有了很多固定圖形的dag的子類, 他們分別實現不同的固定圖形的build 操作。 如下:

 

於是我們建立這個可以用來複用的方法:

 

可以看到這個方法裡我們執行了上面說的所有的步驟,比如開啟瀏覽器,登入,跳轉頁面,建立工程等。 但是在build一個dag的時候,我們依賴一個DagBuilder型別的引數,也就是我們之前的定義的那個介面,當然這個dagbuilder使用了建造者模式,這個我們之後會講。 現在我們在case中就可以很愉快的使用很少量的程式碼完成測試了。 如下:

 

當然熟悉函數語言程式設計的同學會覺得這玩意非常眼熟。 實際上在java8中也完全可以使用lamda表示式來完成DagBuilder的構造

建造者

這裡會涉及到建造者,策略和工廠三種模式的混合使用。可能會比較囉嗦還請大家耐心看完。

建造者模式和工廠模式都是用來建立物件的。 建造者模式適用於一個物件的內部有特別多的屬性需要外部來傳遞的情況。 比如在上一個說策略模式的例子中。我們把Dagbuilder作為策略類,在case呼叫的時候動態傳遞一個具體的Dagbuilder型別決定如何build一個DAG. 那麼剛才我們也看到了一個DAG是非常複雜的,裡面有不同的圖形, 並且即便圖形固定了, 但是裡面的運算元的型別和配置可能都會變化。 比如,按照上面的一個通用的模型訓練的DAG圖形, 我們就可以用下面的程式碼來構建。

 


可以看到上面每個一個node的importToDag的方法中都會有兩個int型別的數字引數。 這個意思是將運算元拖拽到DAG中的哪一個點上。 並且link方法用來連線兩個運算元, build方法會執行UI操作配置當前運算元。 通過這樣一段程式碼就可以構建出上面講策略模式的時候,截圖中的那個DAG圖形。 我們會發現非常多的case都會用到這個圖形。 比如測試所有的模型訓練演算法的時候, 都是走這個DAG圖形的。 所以我們理所應當的會想把這個圖形封裝起來給很多個case使用。 但是雖然case使用的圖形一樣,可是每個運算元的配置可能是不一樣的, 而且可能在某一個節點上使用的運算元都是不一樣的,這需要呼叫方動態的傳遞。 所以builder(建造者)模式是一個包含了很多個零件的物件, 它封裝瞭如何操作這些元件創造出最終呼叫方想要的東西。但是需要呼叫方自由的傳遞這些不同的零件給builder。 首先我們看看這個DAG的builder類中定義要使用的零件。

上面是我們構建這個模型訓練雙輸入DAG所需要的零件。 可以看到由一個數據節點,一個數據拆分運算元,兩個特徵抽取,一個模型訓練,一個模型預測和一個模型預估組成。 而且這些零件都分別有set方法讓呼叫方來設定。然後我們就可以在builder的build方法裡使用本節裡一開始貼出的程式碼來動態的構建圖形了。

策略模式的混用

這裡需要注意一點的是,這些零件大部分都是具體的實體類。 但是有些不是,比如模型訓練演算法,我們規定的是一個抽象型別。 如下:

 

為什麼這麼做呢,因為對於所有要測試模型訓練的case來說。 圖形是固定的, 某些演算法也是固定的。 不論測試什麼模型訓練演算法,都是一個數據下面連線資料拆分演算法,再下面連線兩個特徵抽取演算法。 也就是說對於模型訓練演算法來說,這些流程都是固定的,我們實現就知道該拉取什麼樣的運算元,只是配置需要呼叫方動態傳遞。 但是測試的時候我們有各種不同的模型訓練演算法,這些可不是配置不同,而是連運算元都變了, 所以我們把模型訓練演算法抽象成策略類。我不需要知道到底該拉取哪一個運算元,讓呼叫方動態傳遞就好了。 只要它傳遞的是我規定的策略型別,有規定的方法來設定這個運算元就可以了。

工廠模式的混用

根據上面的策略模式和建造者模式的混用我們就可以比較方便的構建DAG圖形給case使用了。 但是還是有一點麻煩。那就是一個builder需要傳遞的零件太多了。這個體驗有點不友好。 而且我們發現在大多數的模型訓練測試場景下,我們只關心模型訓練演算法的配置引數,而不是很在意其他演算法的配置是什麼樣子的。 這種場景下讓我一個一個的去傳遞這些零件還是有點麻煩。 或者說在有些情況下,我們是可以動態的推匯出其他運算元的配置的。 比如我這次要測試的是邏輯迴歸這個運算元。 那麼邏輯迴歸是一種二分類運算元,那麼其實它只能使用二分類的資料,特徵抽取演算法中只能使用二分類的label處理, 相應的下面也只能連線二分類運算元的預測和評估運算元。 這些都是我們可以動態推匯出來的。 沒有必要讓使用者一個一個的去傳遞。所以我們在builder外面再包一層工廠, 一個建立builder的工廠。如下:

 

如上圖,根據傳遞的模型訓練運算元的型別找到預先匯入的資料,配置好特徵抽取,推匯出所有依賴的運算元配置後。 配置好這個builder並返回給呼叫方。這樣我們通過之前講的fastCreateDag的策略模式的例子。 就可以在case中只寫入非常少量的程式碼就完成了測試用例的編寫:

@Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("GBDT雙輸入")
    @Test
    public void doubleInputGBDT(){
        fastCreateDag(Common.randomString("GBDT2Input"), DagBuilderFactory.getDoubleInputBuilder(new GBDTNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);
    }

    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("SVM雙輸入")
    @Test
    public void doubleInputSVM(){
        fastCreateDag(Common.randomString("SVM2Input"), DagBuilderFactory.getDoubleInputBuilder(new SVMNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }


    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("hetreenet雙輸入")
    @Test
    public void doubleInputHeTreeNet(){
        fastCreateDag(Common.randomString("he2Input"), DagBuilderFactory.getDoubleInputBuilder(new HETreeNetNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }

    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("gbrt雙輸入")
    @Test
    public void doubleInputGbrt(){
        fastCreateDag(Common.randomString("GBRT2Input"), DagBuilderFactory.getDoubleInputBuilder(new GBRTNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }

    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("線性迴歸雙輸入")
    @Test
    public void doubleInputLinearRegression(){
        fastCreateDag(Common.randomString("linearR"), DagBuilderFactory.getDoubleInputBuilder(new LinearRegressionNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }

    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("線性分型迴歸雙輸入")
    @Test
    public void doubleInputLFCRegression(){
        fastCreateDag(Common.randomString("LFCRe"), DagBuilderFactory.getDoubleInputBuilder(new LFCRegressionNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }

    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("邏輯迴歸多分類雙輸入")
    @Test
    public void doubleInputLRMultiClass(){
        fastCreateDag(Common.randomString("LRMulti"), DagBuilderFactory.getDoubleInputBuilder(new LRMultiClassNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }

可以看到上面的每一個模型訓練的case的程式碼量都非常的少。