0%

如果你的佈署狀況如下

[![](https://3.bp.blogspot.com/-uiFFGMOOPlQ/WmraN8BbjEI/AAAAAAAAIdo/xzVVbXd5NLkz4xpa1wI-gTNN048M5R7ngCLcBGAs/s640/1.png)](https://3.bp.blogspot.com/-uiFFGMOOPlQ/WmraN8BbjEI/AAAAAAAAIdo/xzVVbXd5NLkz4xpa1wI-gTNN048M5R7ngCLcBGAs/s1600/1.png)
原諒我只繪畫醜圖....
有一次更版進到到Master Branch,而CI設定Trigger是Master更動時自動建置,CD接收到CI建置完成後執行佈署機器,而每台機器的Web.Config值都有差異,這時候該怎麼做?

#XML variable substitution

Azure App Service Deploy有提供XML variable substitutuin可以選擇

[![](https://4.bp.blogspot.com/-LN_4j9iX9As/WmrfdZWF3oI/AAAAAAAAId4/Tc9U1W24Lq45Ms0p9qhtBLNX2EJ8sKJ_QCLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-LN_4j9iX9As/WmrfdZWF3oI/AAAAAAAAId4/Tc9U1W24Lq45Ms0p9qhtBLNX2EJ8sKJ_QCLcBGAs/s1600/1.png)

依據註解中說明就是,當這個選項勾選時,他會自動去找專案中的所有.Config檔案,檔案中的appSettings,application Settings , connectionString區塊,如果有Key或Name與設定的變數相同時自動置換,且是發生在Config transforms之後

什麼意思呢? 直接看範例,如果我們的Web.Config裡面長這樣

[![](https://1.bp.blogspot.com/-iEfgD967KZc/WmriR-qREbI/AAAAAAAAIeE/P_zG3I8Ah3EPMA4uADVKwh8LqCImdKpjACLcBGAs/s400/1.png)](https://1.bp.blogspot.com/-iEfgD967KZc/WmriR-qREbI/AAAAAAAAIeE/P_zG3I8Ah3EPMA4uADVKwh8LqCImdKpjACLcBGAs/s1600/1.png)
那他就會在去比對appSettings、connectionString區塊,裡面的Name或是Key有對應到變數設定的話,Value值會自動被替換,而變數設定又在哪邊呢?
[![](https://2.bp.blogspot.com/-bJ9GV3qzx1A/WmrjTH_HnSI/AAAAAAAAIeM/XMf0yAL-XBsfedMAXLy7jbkiS5RcfRViwCLcBGAs/s400/1.png)](https://2.bp.blogspot.com/-bJ9GV3qzx1A/WmrjTH_HnSI/AAAAAAAAIeM/XMf0yAL-XBsfedMAXLy7jbkiS5RcfRViwCLcBGAs/s1600/1.png)

以上面兩張圖來說,那他在WebDeploy到Azure App Service之前就會將我的StaticDomains、DomainValue換掉,因為我們在變數檔案裡面有設定。

這樣我們就能透過建置多個環境的方式,在每個環境中設定好對應的變數值,只要執行一次CI流程,觸發多個CD流程,達到多台同時佈署的目的

[![](https://2.bp.blogspot.com/-Vn7jrg9S6zM/WmrkbLQL9JI/AAAAAAAAIeY/DSoCnAGpUUA_-3oKJhbPD6sj9bdNQ0DPQCLcBGAs/s400/1.png)](https://2.bp.blogspot.com/-Vn7jrg9S6zM/WmrkbLQL9JI/AAAAAAAAIeY/DSoCnAGpUUA_-3oKJhbPD6sj9bdNQ0DPQCLcBGAs/s1600/1.png)

#參數不在appSettings,application Settings , connectionString區塊之中

這邊特別注意,剛剛那種置換的值方法僅適用於上述區塊之中,換句話說,如果我們有環境設定變數,但他又不在這幾個區塊之中,該如何做?

Parameter.xml

還好微軟有提供另一種方式能處理這個問題,就是透過Parameters.xml

建立parameters.xml

[![](https://4.bp.blogspot.com/-DIAJpQ2tUYk/WmrlsIo55sI/AAAAAAAAIeg/PCaPpM04JucMUYC8QFByvv4X3pPcRwjJgCLcBGAs/s320/1.png)](https://4.bp.blogspot.com/-DIAJpQ2tUYk/WmrlsIo55sI/AAAAAAAAIeg/PCaPpM04JucMUYC8QFByvv4X3pPcRwjJgCLcBGAs/s1600/1.png)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<parameters>
<parameter name="ExceptionlessAPIKey" defaultValue="#{ExceptionlessAPIKey}#" >
<parameterEntry
kind="XmlFile"
scope="Web\.config"
match="//exceptionless/@apiKey" />
</parameter>
<parameter name="SessionConnectionString" defaultValue="#{SessionConnectionString}#" >
<parameterEntry
kind="XmlFile"
scope="Web\.config"
match="//configuration/system.web/sessionState/@sqlConnectionString" />
</parameter>
</parameters>

Name : 變數名稱
Scope : 受影響的檔案為和,你可以設定整個專案底下全部的.Config也行
Match : 在這些檔案底下如何尋找要被替換的值

Example 1 :  Web.Config底下從根結點開始找起,找到一個叫做exceptionLess的Tag,然後把apiKey這個屬性換成我設定的值

Example 2 :  Web.Config底下從根結點開始找起,找到configuration底下的system.web底下的sessionState Tag,然後把sqlConnectionString這個屬性換成我設定的值

DefaultValue : 如果在佈署時沒有設定參數值時,預設給的Value

接著在VisualStudio按發行後應該會看到檔案長出SetParameters.xml

[![](https://3.bp.blogspot.com/-s_s3aU7dr74/WmrsRb8ETdI/AAAAAAAAIew/WYlqsjbIk0oj7ow-GOqheH4FJY6QZAUHgCLcBGAs/s640/1.png)](https://3.bp.blogspot.com/-s_s3aU7dr74/WmrsRb8ETdI/AAAAAAAAIew/WYlqsjbIk0oj7ow-GOqheH4FJY6QZAUHgCLcBGAs/s1600/1.png)

打開來會看到

[![](https://1.bp.blogspot.com/-viIUyylwlDg/Wmr6qYwZv-I/AAAAAAAAIgE/C0ISadjZIigW7FQ6sMezcjZwYudaEaLrwCLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-viIUyylwlDg/Wmr6qYwZv-I/AAAAAAAAIgE/C0ISadjZIigW7FQ6sMezcjZwYudaEaLrwCLcBGAs/s1600/1.png)

到這邊我們已經成功將Web.Config的指定位置挖成變數值,等等在CD階段,我們就是要想辦法把SetParameters.xml的Value值換掉

回到CD設定介面

告訴Azure App Service Deploy Task,你有SetParameters.xml

[![](https://4.bp.blogspot.com/-NUpiKPxhPRY/Wmrtx-vh_HI/AAAAAAAAIfE/RRml5NRckGQ6S4uDWJJsfSHhX2H7CWEBgCLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-NUpiKPxhPRY/Wmrtx-vh_HI/AAAAAAAAIfE/RRml5NRckGQ6S4uDWJJsfSHhX2H7CWEBgCLcBGAs/s1600/1.png)

安裝Replace Token Task

為了置換掉SetParameters.xml裡面的值,必須幫我們VSTS安裝套件
[![](https://1.bp.blogspot.com/-Kq1PpE97VYE/WmruR4NMthI/AAAAAAAAIfM/Vu2lwFOUsx4Npju7imY-kYBuW3Frt1exACLcBGAs/s400/1.png)](https://1.bp.blogspot.com/-Kq1PpE97VYE/WmruR4NMthI/AAAAAAAAIfM/Vu2lwFOUsx4Npju7imY-kYBuW3Frt1exACLcBGAs/s1600/1.png)
安裝連結 :  [Replace Token MarketPlace ](https://marketplace.visualstudio.com/items?itemName=qetza.replacetokens)

Azure App Service Deploy之前加上Replace Token Task

[![](https://2.bp.blogspot.com/-jDdk2Q7Mj7I/Wmru-f9N1fI/AAAAAAAAIfU/y2x-uJAWD8wQSwaij7_B6TyOS7T2RmMzQCLcBGAs/s640/1.png)](https://2.bp.blogspot.com/-jDdk2Q7Mj7I/Wmru-f9N1fI/AAAAAAAAIfU/y2x-uJAWD8wQSwaij7_B6TyOS7T2RmMzQCLcBGAs/s1600/1.png)
[![](https://3.bp.blogspot.com/-mTQYjeZIYho/WmrvSLWq_lI/AAAAAAAAIfY/hWpXjtyZkzMf40oTRqx4cfaWoSxvNQ5XgCLcBGAs/s640/1.png)](https://3.bp.blogspot.com/-mTQYjeZIYho/WmrvSLWq_lI/AAAAAAAAIfY/hWpXjtyZkzMf40oTRqx4cfaWoSxvNQ5XgCLcBGAs/s1600/1.png)

設定

[![](https://4.bp.blogspot.com/-j-77BDvLK7A/WmrxsFTAD8I/AAAAAAAAIfo/4PIBYR3ck7Uc0DSDfUcTbjs4bAA2CzM-ACLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-j-77BDvLK7A/WmrxsFTAD8I/AAAAAAAAIfo/4PIBYR3ck7Uc0DSDfUcTbjs4bAA2CzM-ACLcBGAs/s1600/1.png)
**Root Directory : **要替換的目標檔案Root資料夾位置 **Target files : **告訴它我們要找的檔案是誰,以這邊來說就是SetParameters.xml **Token prefix、Token suffix : **比對什麼是它要幫我們置換的,而這邊就是#{xxxx}#包起來的就是參數

這樣設定完之後,它就會在WebDeploy之前執行Replace Token Task,而這個Task會去找到SetParameters.xml,然後找到#{xxx}#符號的,然後用我們之前設定在Variables的參數置換掉

[![](https://2.bp.blogspot.com/-rvxpn3Nkvrw/WmrzQb7-SUI/AAAAAAAAIf0/fMBjj3gSnYwGvbR50p7UZcQQwvGUf5MNgCLcBGAs/s640/1.png)](https://2.bp.blogspot.com/-rvxpn3Nkvrw/WmrzQb7-SUI/AAAAAAAAIf0/fMBjj3gSnYwGvbR50p7UZcQQwvGUf5MNgCLcBGAs/s1600/1.png)

一般為了服務的順暢,一般都會在正式機的Azure App Service開啟Slot服務

好讓服務更新時,能無痛更新且不會中斷Session。

而上一篇有提到如何更新Azure App Service,但如果在有開啟Slot的情形下,設定要做一些變更。

首先先將設定從佈署正式機改成佈署到Slot

[![](https://3.bp.blogspot.com/-8u_M2pW8r6I/Wmq7HICh6mI/AAAAAAAAIcE/XVVdqTZH-houw6m7iXuRw1R1B-QYMuPHgCLcBGAs/s640/1.png)](https://3.bp.blogspot.com/-8u_M2pW8r6I/Wmq7HICh6mI/AAAAAAAAIcE/XVVdqTZH-houw6m7iXuRw1R1B-QYMuPHgCLcBGAs/s1600/1.png)
勾選Deploy to Slot後,會需要選擇**Resource group**與**Slot**,分別選擇好後存檔。

新增一組切換Slot Enviroment

[![](https://4.bp.blogspot.com/-sDn38p_Mnbw/Wmq8ETA_jDI/AAAAAAAAIcM/H_eI0hPhGIwW-a6rxAwMdWy7O7s5vlCAACLcBGAs/s1600/1.png)](https://4.bp.blogspot.com/-sDn38p_Mnbw/Wmq8ETA_jDI/AAAAAAAAIcM/H_eI0hPhGIwW-a6rxAwMdWy7O7s5vlCAACLcBGAs/s1600/1.png)
因為我們是希望佈署到Slot後,再決定要不要跟正式機進行交換,所以是按第一組環境的Add而不是Enviroments Add,兩個差異如下
**Enviroments Add : **同時觸發
[![](https://3.bp.blogspot.com/-MeAkdLz3Cr0/Wmq8tNg9y9I/AAAAAAAAIcY/OscSApLOq4U9Fg19E1Ck7ai5RQO6Yi5agCLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-MeAkdLz3Cr0/Wmq8tNg9y9I/AAAAAAAAIcY/OscSApLOq4U9Fg19E1Ck7ai5RQO6Yi5agCLcBGAs/s1600/1.png)
** **
**DeployToStage Add : **依序
[![](https://3.bp.blogspot.com/-rgl9IIrg7PM/Wmq9FwxUOPI/AAAAAAAAIcc/X4QXZY09ZqwBjKuO14TeLbQdhBY5ZkfCQCLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-rgl9IIrg7PM/Wmq9FwxUOPI/AAAAAAAAIcc/X4QXZY09ZqwBjKuO14TeLbQdhBY5ZkfCQCLcBGAs/s1600/1.png)

設定Swap Slot

新增Azure App Service Manage

[![](https://4.bp.blogspot.com/-cu8E1X0iFtc/Wmq9gXARCNI/AAAAAAAAIck/BhcwVvceOyUPJ_1rBx_57fn5zLIbbynDQCLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-cu8E1X0iFtc/Wmq9gXARCNI/AAAAAAAAIck/BhcwVvceOyUPJ_1rBx_57fn5zLIbbynDQCLcBGAs/s1600/1.png)
[![](https://2.bp.blogspot.com/-rERunRQExFg/Wmq-YRBwp7I/AAAAAAAAIc0/MxgvqqNAZrIPKSGwPgHfBIEB3DHe7-qswCLcBGAs/s400/1.png)](https://2.bp.blogspot.com/-rERunRQExFg/Wmq-YRBwp7I/AAAAAAAAIc0/MxgvqqNAZrIPKSGwPgHfBIEB3DHe7-qswCLcBGAs/s1600/1.png)
跟前面的設定基本上都一樣,只要是Action的地方要選擇**Swap Slots**
** **
** **

加上啟動切換Slot的條件

正式機加上上述設定後帶來了一些好處
1.上線時推程式會先進到Slot,先連到Slot測試確定沒問題
2.讓正式機跟Slot交換,線上服務感受不到中斷,但程式已經更新了
而我們為了安全起見,在切換Slot前會再加一到人工審核,當核准時才會進行切換
[![](https://3.bp.blogspot.com/-NPW1J_euv1g/Wmq_d5ZIgbI/AAAAAAAAIc8/fel0tpckNpY3G9U-Ce-l_7cux4hh69UrgCLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-NPW1J_euv1g/Wmq_d5ZIgbI/AAAAAAAAIc8/fel0tpckNpY3G9U-Ce-l_7cux4hh69UrgCLcBGAs/s1600/1.png)
[![](https://2.bp.blogspot.com/-MUxdoYfNdvI/WmrAesUzntI/AAAAAAAAIdI/rprzi_YpU2I3gU8Nf6WvND6lnqbP5f-TwCLcBGAs/s640/1.png)](https://2.bp.blogspot.com/-MUxdoYfNdvI/WmrAesUzntI/AAAAAAAAIdI/rprzi_YpU2I3gU8Nf6WvND6lnqbP5f-TwCLcBGAs/s1600/1.png)
這樣設定後,切換Slot就會需要審核
第一關審核是佈署到Slot
[![](https://2.bp.blogspot.com/-xPtLxwhLiEM/WmrA85Q54II/AAAAAAAAIdM/w5moJRIMBecavQfl60mVPKB6fhLedXgRgCLcBGAs/s400/1.png)](https://2.bp.blogspot.com/-xPtLxwhLiEM/WmrA85Q54II/AAAAAAAAIdM/w5moJRIMBecavQfl60mVPKB6fhLedXgRgCLcBGAs/s1600/1.png)
第二關審核是將正式機跟Slot切換
[![](https://1.bp.blogspot.com/-rZTHXtthOOk/WmrBvam9k3I/AAAAAAAAIdY/yuP69VL0ZlkFPnQ5CWUd8nTImmr4mWBdACLcBGAs/s400/1.png)](https://1.bp.blogspot.com/-rZTHXtthOOk/WmrBvam9k3I/AAAAAAAAIdY/yuP69VL0ZlkFPnQ5CWUd8nTImmr4mWBdACLcBGAs/s1600/1.png)
下一篇來寫如何在CD階段置換參數值

這邊的目標是當CI完成時,自動將程式佈署到Azure AppService,也就是所謂的CD部分

持續部署(Continuous Deployment)
大部分的持續整合系統允許在建置完成後自動執行程式碼。因此能夠寫一段程式碼來布署應用程式至任何人都可以觀察的測試伺服器。在持續性整合未來的思考發展成像持續性布署邁進。
持續性布署將要求直接將軟體布署至測試環境中,這通常需要額外的自動化機制來防止程式缺陷。

希望
1.CI部分執行完畢後,將程式自動佈署到機器
2.依據不同的CI Task,決定佈署到哪些機器 (測試機 or 多台正式機…等)
3.**人員核准 **(正式機通常需要一定權限核准才能執行佈署)



1.先進到VSTS的Release > Create release Definitions

[![](https://4.bp.blogspot.com/-j4SKGxGUXWc/WmqJEJctL1I/AAAAAAAAIZY/-CHGLoBOXJY8d9VtIaz-35vVkgPS9FuCgCLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-j4SKGxGUXWc/WmqJEJctL1I/AAAAAAAAIZY/-CHGLoBOXJY8d9VtIaz-35vVkgPS9FuCgCLcBGAs/s1600/1.png)

2.選擇Empty Process

[![](https://3.bp.blogspot.com/-yx2eYYmHSqg/WmqJXg1LlmI/AAAAAAAAIZc/8SxeXNTgDkEk7xDragehBih3J8u9hOfkwCLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-yx2eYYmHSqg/WmqJXg1LlmI/AAAAAAAAIZc/8SxeXNTgDkEk7xDragehBih3J8u9hOfkwCLcBGAs/s1600/1.png)

3.Artifacts選擇佈署要用哪一組CI建置的成品

[![](https://4.bp.blogspot.com/-lpW6Y2RApv4/WmqJ2ScPvCI/AAAAAAAAIZo/Hdz-H64BtY0GiD8xdEHt7ZezAOQQUKDCgCLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-lpW6Y2RApv4/WmqJ2ScPvCI/AAAAAAAAIZo/Hdz-H64BtY0GiD8xdEHt7ZezAOQQUKDCgCLcBGAs/s1600/1.png)
[![](https://4.bp.blogspot.com/-1yfWjZ26ZWk/WmqKTGiIrFI/AAAAAAAAIZw/Oq49HUpyphwVZ7FcV-xFVEpUfNB2UpT9gCLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-1yfWjZ26ZWk/WmqKTGiIrFI/AAAAAAAAIZw/Oq49HUpyphwVZ7FcV-xFVEpUfNB2UpT9gCLcBGAs/s1600/1.png)

4.設定Trigger條件

我們希望上一步驟選擇的CI Task建置完成後,自動觸發這個的CD流程,所以要加上Trigger條件
[![](https://3.bp.blogspot.com/-LFkrO0ACJ5w/WmqMbAIfg5I/AAAAAAAAIaM/jGU4bXy_CZo17kKHN4h_1IdZAeNkN37KgCLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-LFkrO0ACJ5w/WmqMbAIfg5I/AAAAAAAAIaM/jGU4bXy_CZo17kKHN4h_1IdZAeNkN37KgCLcBGAs/s1600/1.png)
[![](https://4.bp.blogspot.com/-xUgj-OM9P4Y/WmqMr611LmI/AAAAAAAAIaQ/nfdvmcsj_88OOwqq6OK32BNanBRvghZ6gCLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-xUgj-OM9P4Y/WmqMr611LmI/AAAAAAAAIaQ/nfdvmcsj_88OOwqq6OK32BNanBRvghZ6gCLcBGAs/s1600/1.png)

5.新增Enviroment Task > Azure App Service Deploy

[![](https://2.bp.blogspot.com/-iBKwQJIYa6Q/WmqM79F_lYI/AAAAAAAAIaY/gsTQ7EtWRywGnpyXXO42WAjG--EmzdfNACLcBGAs/s400/1.png)](https://2.bp.blogspot.com/-iBKwQJIYa6Q/WmqM79F_lYI/AAAAAAAAIaY/gsTQ7EtWRywGnpyXXO42WAjG--EmzdfNACLcBGAs/s1600/1.png)
[![](https://1.bp.blogspot.com/-DwVyGiM89Us/WmqNTD0Dv3I/AAAAAAAAIag/QcKJhKXT1RQPC1rYn-E50OCxSaDBwFYegCLcBGAs/s400/1.png)](https://1.bp.blogspot.com/-DwVyGiM89Us/WmqNTD0Dv3I/AAAAAAAAIag/QcKJhKXT1RQPC1rYn-E50OCxSaDBwFYegCLcBGAs/s1600/1.png)

6.設定相關參數

[![](https://3.bp.blogspot.com/-SdtrEkvtTsU/WmqW3HlYaJI/AAAAAAAAIa0/LewJi_YI2qsNJH7MMi5KQNJiVdOwSvF-gCLcBGAs/s1600/1.png)](https://3.bp.blogspot.com/-SdtrEkvtTsU/WmqW3HlYaJI/AAAAAAAAIa0/LewJi_YI2qsNJH7MMi5KQNJiVdOwSvF-gCLcBGAs/s1600/1.png)
**Azure subscription **: 選擇你訂閱的Azure服務,基本如果是同一個帳號,下拉選單就可以看到

Package or folder : 因為我們是採用WebDeploy的方式佈署,所以要輸入Pakage的路徑,紅色區塊請填你CI Task所設定的名稱,CI建置會放置在同名的資料夾底下,之後就是Drop底下建置出來的.zip檔

人員核准

依照上述步驟做完,基本上佈署流程大致上已經完成,但通常會有一些狀況是希望做佈署動作前能夠先透過人來審核,例如正式機佈署。 我們一定不希望隨便人簽入程式後,正式機的CD流程就自動觸發佈署去了。

設定審核人員

[![](https://3.bp.blogspot.com/-9Fe6kin7wkU/WmqYnG25onI/AAAAAAAAIbA/54Z9iUfEdmM2_U9JBtD2SoevurGaoIsbACLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-9Fe6kin7wkU/WmqYnG25onI/AAAAAAAAIbA/54Z9iUfEdmM2_U9JBtD2SoevurGaoIsbACLcBGAs/s1600/1.png)
[![](https://2.bp.blogspot.com/-ztGHArbq6VQ/WmqZCBvHSJI/AAAAAAAAIbE/sHDYhhNkmMI8LIkdzRjrGt__lGLvjb-ggCLcBGAs/s400/1.png)](https://2.bp.blogspot.com/-ztGHArbq6VQ/WmqZCBvHSJI/AAAAAAAAIbE/sHDYhhNkmMI8LIkdzRjrGt__lGLvjb-ggCLcBGAs/s1600/1.png)
**Any Order : **上面所選的人員每一個都要核准才會觸發
**In sequence : **上面所選的人員每一個都要**依序**核准才會觸發
**Any One Order **** : **上面所選的人員**任何一人核准即可**
如果有卡核准的話,Release不會自動執行,而是看到這個畫面
[![](https://2.bp.blogspot.com/-tp3q8i0NZaM/WmqbBeT2hOI/AAAAAAAAIbc/B20xUeyN9YwlKHd7EYOqskqYywkF6SbCACLcBGAs/s400/1.png)](https://2.bp.blogspot.com/-tp3q8i0NZaM/WmqbBeT2hOI/AAAAAAAAIbc/B20xUeyN9YwlKHd7EYOqskqYywkF6SbCACLcBGAs/s1600/1.png)
點兩下進去後可以核准
[![](https://3.bp.blogspot.com/-KP6jYJbXaB4/WmqbqoYwdjI/AAAAAAAAIbo/ASJM3yKXy_8twOKj380r6aoKhzZI3em_QCLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-KP6jYJbXaB4/WmqbqoYwdjI/AAAAAAAAIbo/ASJM3yKXy_8twOKj380r6aoKhzZI3em_QCLcBGAs/s1600/1.png)

選擇特定版本佈署

[![](https://3.bp.blogspot.com/-Yj3LrhbV4OI/WmqcDGeKk8I/AAAAAAAAIbs/5zJArJ9S2JoeganmsxpN0oOLlnHb9mlpgCLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-Yj3LrhbV4OI/WmqcDGeKk8I/AAAAAAAAIbs/5zJArJ9S2JoeganmsxpN0oOLlnHb9mlpgCLcBGAs/s1600/1.png)
[![](https://2.bp.blogspot.com/-SCDaX0YHudE/WmqcPmnMTZI/AAAAAAAAIb0/squNlawHwwgeW54wH2D4ku-wSVQLFrhdgCLcBGAs/s400/1.png)](https://2.bp.blogspot.com/-SCDaX0YHudE/WmqcPmnMTZI/AAAAAAAAIb0/squNlawHwwgeW54wH2D4ku-wSVQLFrhdgCLcBGAs/s1600/1.png)

小結

以上這邊,之後只要有人簽入程式,CI流程有設定Trigger的話就會自動觸發,CI流程沒問題,CD流程就會接著做,再依據有無卡審核做後續行為。
每次的執行狀況也都可以清楚看到狀況
[![](https://1.bp.blogspot.com/-2UvlMPIFHiA/WmqaGN8VLpI/AAAAAAAAIbQ/C1GftxgAeBIqRCPMuJCNQU39Bu32ILIvQCLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-2UvlMPIFHiA/WmqaGN8VLpI/AAAAAAAAIbQ/C1GftxgAeBIqRCPMuJCNQU39Bu32ILIvQCLcBGAs/s1600/1.png)
[![](https://2.bp.blogspot.com/-QXjIEo2-s1M/WmqaX4umkhI/AAAAAAAAIbU/Sg2soTofoawjOVzoZtEC98DLqtFGATFnQCLcBGAs/s640/1.png)](https://2.bp.blogspot.com/-QXjIEo2-s1M/WmqaX4umkhI/AAAAAAAAIbU/Sg2soTofoawjOVzoZtEC98DLqtFGATFnQCLcBGAs/s1600/1.png)
下一篇來說如何透過VSTS來執行切換Slot,感覺還有好幾篇可以寫...

以下是Wiki對於CI的解釋

持續整合(英語:Continuous integration,縮寫CI)是一種軟體工程流程,是將所有軟體工程師對於軟體的工作副本持續整合到共用主線(mainline)的一種舉措。
該名稱最早由[1]葛來迪·布區(Grady Booch)在他的布區方法[2]中提出,不過他並沒有提到要每天整合數次。之後該舉措成為極限編程(extreme programming)的一部份時,其中建議每天應整合超過一次,甚至達到數十次。
[3]在測試驅動開發(TDD)的作法中,通常還會搭配自動單元測試。持續整合的提出主要是為解決軟體進行系統整合時面臨的各項問題,極限編程稱這些問題為整合地獄(integration hell)。

而自己對CI的見解是,透過軟體版本控管機制,持續將每個分支每次修改進行整合,並且透過自動化的測試、佈署、執行報告來控管軟體品質。

而這篇是要寫如何透過VSTS來達成此目標,跟整個設定的過程,先來整理要達成的目標項目。

希望
1.版控更新時,自動建置
2.自動執行單元測試
3.將建置好的檔案放到佈署資料夾(之後讓CD接手,做自動化佈署到正式機、測試機..等)
4.回報上述執行結果

版控更新時,自動建置

1.到VSTS網站頁面,選擇Build and Release  >  +New

[![](https://3.bp.blogspot.com/-XS-Ogl-wsag/WmmPVNrH_bI/AAAAAAAAIV8/cjl3VdVp6gsL11fiftfSCIcec9EsE_gGgCLcBGAs/s640/1.png)](https://3.bp.blogspot.com/-XS-Ogl-wsag/WmmPVNrH_bI/AAAAAAAAIV8/cjl3VdVp6gsL11fiftfSCIcec9EsE_gGgCLcBGAs/s1600/1.png)
** **

2.選擇Empty process

[![](https://4.bp.blogspot.com/-qw_0GPoI3i4/WmmPmH3ClGI/AAAAAAAAIWA/pMV4k7v1uZQlucvGW5U0dy_Mu2MjwCVdQCLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-qw_0GPoI3i4/WmmPmH3ClGI/AAAAAAAAIWA/pMV4k7v1uZQlucvGW5U0dy_Mu2MjwCVdQCLcBGAs/s1600/1.png)

Process Agent queue設定

[![](https://4.bp.blogspot.com/-nv9FB-OKBu8/WmmiC6TnyPI/AAAAAAAAIYs/unfIYRSe9wgUs1490wBHMKwp9_bOsAC_QCLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-nv9FB-OKBu8/WmmiC6TnyPI/AAAAAAAAIYs/unfIYRSe9wgUs1490wBHMKwp9_bOsAC_QCLcBGAs/s1600/1.png)

3.加入Nuget restore Task因為我們專案都有用Nuget,所以需要在建置之前先還原Nuget,設定如下

[![](https://1.bp.blogspot.com/-hFBqYKbzhfI/WmmUkjd318I/AAAAAAAAIWc/VnQOMKDfOWUFlsdbTwtgOzDsuEo1HyDWwCLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-hFBqYKbzhfI/WmmUkjd318I/AAAAAAAAIWc/VnQOMKDfOWUFlsdbTwtgOzDsuEo1HyDWwCLcBGAs/s1600/1.png)
[![](https://1.bp.blogspot.com/-8bU6NhV_axk/WmmSF6nLdzI/AAAAAAAAIWQ/xR8dyhcgSP8e9s4c5pukjKhgmiqKKmyYwCLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-8bU6NhV_axk/WmmSF6nLdzI/AAAAAAAAIWQ/xR8dyhcgSP8e9s4c5pukjKhgmiqKKmyYwCLcBGAs/s1600/1.png)
這邊Feed to use 我選擇Feeds in my Nuget.config,原因是公司專案有用自己開發的Nuget套件,而那些Nuget放的位置就需要自訂的Config讓建置機器知道,否則抓不到那些套件,待會建置就會錯誤,如果專案沒有用內部Nuget Server的套件,就選第一個即可。

通常如果我們有用私人的Nuget套件,一定會在VS設定套件來源,而這個設定檔就會放在底下這個位置
%appdata%\NuGet\NuGet.Config
把檔案放進版控中,就可以讓VSTS選的到了

4.加入Build Solution Task

[![](https://4.bp.blogspot.com/-P83wkuHiW_Q/WmmU9NttxhI/AAAAAAAAIWg/eKcneONO0H0amRUTTrN0KmMSpKnIHFaCgCLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-P83wkuHiW_Q/WmmU9NttxhI/AAAAAAAAIWg/eKcneONO0H0amRUTTrN0KmMSpKnIHFaCgCLcBGAs/s1600/1.png)
[![](https://1.bp.blogspot.com/-VpXPU2_Rkb8/WmmVvzSQSBI/AAAAAAAAIWs/Mpu-mZu4gqEzXQBfEqurgLBwRxTc2fp_ACLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-VpXPU2_Rkb8/WmmVvzSQSBI/AAAAAAAAIWs/Mpu-mZu4gqEzXQBfEqurgLBwRxTc2fp_ACLcBGAs/s1600/1.png)
** ***這邊MSBuild Arguments裡面的PublishProfile=CICD,意思是說我們要用的發行檔名稱,而名稱就做CICD,所以等等我們會回VisualStudio建立一個名叫CICD的發行檔

*Configuration,是說我要用Debug的組態檔來建置,如果你不知道組態檔是什麼,請看

5.建立CICD發行檔

[![](https://4.bp.blogspot.com/-fXHi2n6VZFM/WmmYCGCjFAI/AAAAAAAAIW4/5zbkFrA19Fon58CJBVLTEUJR3Chqz50iQCLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-fXHi2n6VZFM/WmmYCGCjFAI/AAAAAAAAIW4/5zbkFrA19Fon58CJBVLTEUJR3Chqz50iQCLcBGAs/s1600/1.png)
[![](https://1.bp.blogspot.com/-l1gb4UnmpmM/WmmYYZTNDxI/AAAAAAAAIW8/0HjBNGwjRNMIErNyx8lrVf4Hy7TZ88x9ACLcBGAs/s400/1.png)](https://1.bp.blogspot.com/-l1gb4UnmpmM/WmmYYZTNDxI/AAAAAAAAIW8/0HjBNGwjRNMIErNyx8lrVf4Hy7TZ88x9ACLcBGAs/s1600/1.png)
[![](https://1.bp.blogspot.com/-32aEvFZCe6g/WmmZW11jZ0I/AAAAAAAAIXI/2Ei-9zh3KhQx06veBMtbWEa7H4tc3idvgCLcBGAs/s400/1.png)](https://1.bp.blogspot.com/-32aEvFZCe6g/WmmZW11jZ0I/AAAAAAAAIXI/2Ei-9zh3KhQx06veBMtbWEa7H4tc3idvgCLcBGAs/s1600/1.png)
[![](https://2.bp.blogspot.com/-1RqfQqrD0C8/WmmZoZZViaI/AAAAAAAAIXM/34faHBlj3c4KF4xz7FwZnZmWayUpSodlwCLcBGAs/s320/1.png)](https://2.bp.blogspot.com/-1RqfQqrD0C8/WmmZoZZViaI/AAAAAAAAIXM/34faHBlj3c4KF4xz7FwZnZmWayUpSodlwCLcBGAs/s1600/1.png)
你可以先用VS發行看看,應該會在專案的Root資料夾底下長出一個Publish的資料夾,底下內容如下
[![](https://1.bp.blogspot.com/-PzHy8-PeILA/WmmaFi-990I/AAAAAAAAIXU/lms69Y9-8BcER7sAvGlMt2x38tEUFQcagCLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-PzHy8-PeILA/WmmaFi-990I/AAAAAAAAIXU/lms69Y9-8BcER7sAvGlMt2x38tEUFQcagCLcBGAs/s1600/1.png)
之後WebDeply就靠這些檔案了。 回到VSTS繼續設定

6.加入Visual Studio Test Task

[![](https://4.bp.blogspot.com/-QBXq7Esdoro/WmmarbzFXlI/AAAAAAAAIXc/yTYh8E4y8Uwq1NX4NTjHjxUJFoxvAJKvACLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-QBXq7Esdoro/WmmarbzFXlI/AAAAAAAAIXc/yTYh8E4y8Uwq1NX4NTjHjxUJFoxvAJKvACLcBGAs/s1600/1.png)
[![](https://4.bp.blogspot.com/-Ik2JJshPoNw/WmmbErAetqI/AAAAAAAAIXg/iBT1z4W5SfUMPIwsR6Cgk8O5FC2Ox-KjwCLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-Ik2JJshPoNw/WmmbErAetqI/AAAAAAAAIXg/iBT1z4W5SfUMPIwsR6Cgk8O5FC2Ox-KjwCLcBGAs/s1600/1.png)
紅框處是告訴這個Task,如何找到你的單元測試Dll來執行,如果你建立單元測試專案都是用預設的方式,那單元測試的專案名稱應該都會是 xxxTest,所以建置出來的Dll也會是xxxTest.dll,符合紅框預設尋找的條件,所以不用調整

7.加入Copy Files Task

目的是如果前面幾個Task都通過執行到這邊,表示建置沒有問題,且單元測試全部通過,所發行出來的檔案,加下來要把這些檔案複製出來,準備移到成品資料夾
[![](https://3.bp.blogspot.com/-Tr8vDR0SmDU/WmmciCbYKwI/AAAAAAAAIXw/5i0cUOgtWDQLXiCveVshcpqSZa1ZQscNACLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-Tr8vDR0SmDU/WmmciCbYKwI/AAAAAAAAIXw/5i0cUOgtWDQLXiCveVshcpqSZa1ZQscNACLcBGAs/s1600/1.png)
[![](https://3.bp.blogspot.com/-kmgOU-k_3jY/Wmmc_n-HXlI/AAAAAAAAIX0/0P0WAy6oXRUmGStC-upMnLfFPWck3ydFACLcBGAs/s640/1.png)](https://3.bp.blogspot.com/-kmgOU-k_3jY/Wmmc_n-HXlI/AAAAAAAAIX0/0P0WAy6oXRUmGStC-upMnLfFPWck3ydFACLcBGAs/s1600/1.png)
黑框遮掉的部分就跟之前一樣是專案名稱,我的專案名稱是XXX.Application,所以那個區段請自行置換。
另外還記得Publish這個資料夾嗎?這就是我們剛剛在Visual Studio設定在CICD發行檔的建置發行檔存放的位置,套上預設VSTS建置專案的資料夾變數位置,就變成這行的值了

8.將成品資料夾內容丟到Drop資料夾

[![](https://3.bp.blogspot.com/-88XzfFqDwog/WmmeuQ9zKzI/AAAAAAAAIYE/oIV5xH2wEgADVD-jNi-boqu6ZySliBYnwCLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-88XzfFqDwog/WmmeuQ9zKzI/AAAAAAAAIYE/oIV5xH2wEgADVD-jNi-boqu6ZySliBYnwCLcBGAs/s1600/1.png)
[![](https://2.bp.blogspot.com/-RJHr9xJXX60/Wmme3l784sI/AAAAAAAAIYI/Az3zsxDNF38IududQuL3s8zkk7WEDpGmQCLcBGAs/s640/1.png)](https://2.bp.blogspot.com/-RJHr9xJXX60/Wmme3l784sI/AAAAAAAAIYI/Az3zsxDNF38IududQuL3s8zkk7WEDpGmQCLcBGAs/s1600/1.png)
到這邊CI的任務大致完成,已經能將專案自動建置、單元測試、將成品打包準備佈署到Server,接下來如何將這成品佈署到Server就是CD的議題了,留待之後在說。
這邊還有最重要的一點,既然是持續整合,那這些任務如何自動化的持續整合,如果每次版控有異動都還要人來操作這些任務,那就失去了持續整合的意義。

將CI任務加上Trigger條件

[![](https://4.bp.blogspot.com/-mpvJaiE8Apc/WmmgTg2yIJI/AAAAAAAAIYY/7FH7zn14ovMWFtsIKCMYhFcW4k4Hm3SSACLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-mpvJaiE8Apc/WmmgTg2yIJI/AAAAAAAAIYY/7FH7zn14ovMWFtsIKCMYhFcW4k4Hm3SSACLcBGAs/s1600/1.png)
這邊設定意思是說,當這個專案的Develop分支有新的Commit進來時,剛剛設定的那些任務就會被自動化的執行。
而每次執行的結果如下
[![](https://4.bp.blogspot.com/-22JmmaFCkjs/Wmmg5b-NLhI/AAAAAAAAIYg/H9clp3fIeF0NQsHTs96xEr3uSkvnExEfwCLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-22JmmaFCkjs/Wmmg5b-NLhI/AAAAAAAAIYg/H9clp3fIeF0NQsHTs96xEr3uSkvnExEfwCLcBGAs/s1600/1.png)
點擊每一次進去可以看到執行的狀況,或是以及錯誤的Log
[![](https://3.bp.blogspot.com/-GNDMtKmEkwg/WmmhlqGuDTI/AAAAAAAAIYo/7tgSawThmfQw6sYKRu9B9k2TkyHwXBG2ACLcBGAs/s640/1.png)](https://3.bp.blogspot.com/-GNDMtKmEkwg/WmmhlqGuDTI/AAAAAAAAIYo/7tgSawThmfQw6sYKRu9B9k2TkyHwXBG2ACLcBGAs/s1600/1.png)
也可以手動排Queue,讓他立即依據現在最新版本或是特定版本去執行
[![](https://1.bp.blogspot.com/-EJjqIGXvgro/Wmmik_1pz3I/AAAAAAAAIY4/8QLNaTEmJrMhAK871kW4yf_YLSyUlMQ5QCLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-EJjqIGXvgro/Wmmik_1pz3I/AAAAAAAAIY4/8QLNaTEmJrMhAK871kW4yf_YLSyUlMQ5QCLcBGAs/s1600/1.png)
[![](https://3.bp.blogspot.com/-X-V_2Mj63Cs/Wmmi0um07kI/AAAAAAAAIY8/hEtz4yx2s94wJppHAoL2eT6jR0a9DIzgACLcBGAs/s400/1.png)](https://3.bp.blogspot.com/-X-V_2Mj63Cs/Wmmi0um07kI/AAAAAAAAIY8/hEtz4yx2s94wJppHAoL2eT6jR0a9DIzgACLcBGAs/s1600/1.png)
Commit可以填入Git的Commit ID,如果不填就會用最新版執行建置。
下一篇預計接著寫CD的部分,如何接著將以上建置完的檔案分別佈署到Azure AppService以及VM機器上。

參考文章

Create your first build and release | Microsoft Docs

發佈網站時依據組態設定的不同而轉換 Web.Config   -  MRKT

[軟體工程]持續整合 (Continuous integration, CI) 簡介 - 91

常常因為資料分析的需求,會有需要爬網頁資料的時候,而以往爬網頁不外乎將Html拉回來後,依據Tag去拆解資訊。 但現今的網站很大部分都是前端透過API拉版面,以Instagram來說,如果直接透過網址將Html拉回來,會只得到空空的外殼而已,什麼都找不到。 這時候就需要模擬瀏覽器行為來讓Javascript運作,甚至操作瀏覽器去點擊特定按鈕。

[![](https://2.bp.blogspot.com/-6yR04AI5phg/Wmf7S3BE3TI/AAAAAAAAITs/gELt5or7tQkL60jmDRYwb1-YGuNU8cKggCLcBGAs/s400/1.png)](https://2.bp.blogspot.com/-6yR04AI5phg/Wmf7S3BE3TI/AAAAAAAAITs/gELt5or7tQkL60jmDRYwb1-YGuNU8cKggCLcBGAs/s1600/1.png)
Instagram拉回來的網頁就只有一個空殼而已....
透過Selenium.WebDriver,以及Seleium.WebDriver.ChromeDriver套件,可以寫程式操作Chrome的操作行為
[![](https://1.bp.blogspot.com/-DkheSsyDReI/Wmf8FTKy0VI/AAAAAAAAIT0/raZdEEh40xgGJQbTFwhZ2A3aQbOOh4VWACLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-DkheSsyDReI/Wmf8FTKy0VI/AAAAAAAAIT0/raZdEEh40xgGJQbTFwhZ2A3aQbOOh4VWACLcBGAs/s1600/1.png)
[![](https://3.bp.blogspot.com/-vnkv6jsDImY/Wmf8OnewkhI/AAAAAAAAIT4/FnlCPUMVsH05noZ1RVbhxFbWqMIYKZJ5gCLcBGAs/s640/1.png)](https://3.bp.blogspot.com/-vnkv6jsDImY/Wmf8OnewkhI/AAAAAAAAIT4/FnlCPUMVsH05noZ1RVbhxFbWqMIYKZJ5gCLcBGAs/s1600/1.png)

接著來一步一步分析如何透過它來爬網頁

開啟Chrome瀏覽器,並且連到想爬的網頁 : https://www.instagram.com/mercci22/

1
2
3
4
5
6
using (IWebDriver driver = new ChromeDriver())
{

driver.Navigate().GoToUrl("https://www.instagram.com/mercci22/");
}

接著分析目標網頁,會發現所有PO文資料都放在一個Div且Class為_cmdpi裡面

[![](https://1.bp.blogspot.com/-sbreT5cUils/Wmf99JegYUI/AAAAAAAAIUI/oIXZxzMGX7c9LU4uVQ9H0BSJUI67UgdGwCLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-sbreT5cUils/Wmf99JegYUI/AAAAAAAAIUI/oIXZxzMGX7c9LU4uVQ9H0BSJUI67UgdGwCLcBGAs/s1600/1.png)

往下找出每一行、每一格,在div[class=’_cmdpi’]底下會有div[class=’_70iju’]每一行

[![](https://4.bp.blogspot.com/-yluY12_h7T0/Wmf_JJsdiBI/AAAAAAAAIUQ/zLIRDslBbZQEYGRvu0qXp4JoSdObu-lPwCLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-yluY12_h7T0/Wmf_JJsdiBI/AAAAAAAAIUQ/zLIRDslBbZQEYGRvu0qXp4JoSdObu-lPwCLcBGAs/s1600/1.png)

而每一行裡面又有三個Div代表每一格

[![](https://4.bp.blogspot.com/-jnEHEexjkeM/Wmf_roHtA4I/AAAAAAAAIUY/4t6RR_UwEP8-1I7EipD7guWU4Ddyk3d5QCLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-jnEHEexjkeM/Wmf_roHtA4I/AAAAAAAAIUY/4t6RR_UwEP8-1I7EipD7guWU4Ddyk3d5QCLcBGAs/s1600/1.png)

所以就來透過套件的API找出每一行,並點擊每一格吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using (IWebDriver driver = new ChromeDriver())
{
driver.Navigate().GoToUrl("https://www.instagram.com/mercci22/");

//找到Post的Container
var PostContainerElement = driver.FindElement(By.ClassName("_cmdpi"));
//每一行
var Rows = PostContainerElement.FindElements(By.ClassName("_70iju"));
foreach (var row in Rows)
{
var Boxs = row.FindElements(By.XPath("div"));
foreach (var box in Boxs)
{
//點擊每一格讓它展開Dialog
box.Click();
}
}

}

這時候如果你執行程式,應該會看到它開啟Chrome並且連到網址然後點擊每一格打開視窗

[![](https://3.bp.blogspot.com/-UIjpEynpLcA/WmgBKT9R10I/AAAAAAAAIUk/WA64zbd-g-YoQdf33ZVlrfVbXDLsYJkhACLcBGAs/s640/1.png)](https://3.bp.blogspot.com/-UIjpEynpLcA/WmgBKT9R10I/AAAAAAAAIUk/WA64zbd-g-YoQdf33ZVlrfVbXDLsYJkhACLcBGAs/s1600/1.png)

接著來分析彈跳出來的視窗,會發現當視窗開啟時,網頁會出現以下Div[role=’dialog’]這個元素,關閉後就會移除

[![](https://4.bp.blogspot.com/-clgYAUdg4y0/WmgBoMCshoI/AAAAAAAAIUo/E3NwCF5n5i8Cpilg7m-OChlEQEgGHOGAwCLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-clgYAUdg4y0/WmgBoMCshoI/AAAAAAAAIUo/E3NwCF5n5i8Cpilg7m-OChlEQEgGHOGAwCLcBGAs/s1600/1.png)

所以我們要想辦法拿到這個Div Dialog,才有辦法擷取Po文的文案、日期、圖片,找到Dialog後,後面就重複上述步驟分析Tag,會發現

圖片 : 放在Div[class=’_4rbun’]底下的Img Tag
文案 : 放在Img Tag的Alt裡面
時間 : 放在Article > div > div > a > time這個Tag裡面

所以目前程式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using (IWebDriver driver = new ChromeDriver())
{
driver.Navigate().GoToUrl("https://www.instagram.com/mercci22/");

//找到Post的Container
var PostContainerElement = driver.FindElement(By.ClassName("_cmdpi"));
//每一行
var Rows = PostContainerElement.FindElements(By.ClassName("_70iju"));
foreach (var row in Rows)
{
var Boxs = row.FindElements(By.XPath("div"));
foreach (var box in Boxs)
{
//點擊每一格讓它展開Dialog
box.Click();

//取得Dialog底下的Article元素
var article = driver.FindElement(By.XPath("//div[@role='dialog']/div/div/article"));

//如果Dialog裡面放的是影片,則_4rbun會不存在
if (article.FindElements(By.ClassName("_4rbun")).Count == 0)
{
//跳過這則,這次目標只抓出圖片
continue;
}

//第一張圖
var ImgContainer = article.FindElement(By.ClassName("_4rbun"));
var Img = ImgContainer.FindElement(By.TagName("img"));
var Date = article.FindElement(By.XPath("div/div/a/time"));
}
}
}

這時候執行的時候可能會發生Exception,原因嘗試取得Dialog底下的Artilce,但Dialog點擊後產生會有時間差導致

[![](https://4.bp.blogspot.com/-TUSU5eY99xU/WmgWjs_9uBI/AAAAAAAAIVE/QnOtvHvoW38zNLWZQ7m6NpICJluVxyc2QCLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-TUSU5eY99xU/WmgWjs_9uBI/AAAAAAAAIVE/QnOtvHvoW38zNLWZQ7m6NpICJluVxyc2QCLcBGAs/s1600/1.png)

優化這段程式,加上Wait的限制,而Selenium提供兩種Wait的方式

implicitly Wait: 預設等待,當元件暫時找不到時,會嘗試等待,直到timeout時間到。
Explicit Wait: 針對特別元件等待。

參考文件:
Selenium 5. Waits 官方文件

我們這邊加上第一種預設等待

1
2
driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(2);

讀取每個Element時,如果暫時不存在兩秒後TimeOut,之後再執行看看,會發現跑到第二次box.Click()的時候跳Exception。

[![](https://4.bp.blogspot.com/-y0clUXejLl4/WmgZwH5ZmeI/AAAAAAAAIVQ/bDTSCWKn9jkNDem1AxJ-TAQPwPQpW-7hACLcBGAs/s640/1.png)](https://4.bp.blogspot.com/-y0clUXejLl4/WmgZwH5ZmeI/AAAAAAAAIVQ/bDTSCWKn9jkNDem1AxJ-TAQPwPQpW-7hACLcBGAs/s1600/1.png)

原因是當我們打開Dialog時,如果爬完不點擊關閉視窗,會點不到第二隔的元素

[![](https://1.bp.blogspot.com/-gPgFjISg5XI/WmgaJ1qWtUI/AAAAAAAAIVU/ii1yaeglfDgqvpH05tnhPa8Soxe3OszjACLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-gPgFjISg5XI/WmgaJ1qWtUI/AAAAAAAAIVU/ii1yaeglfDgqvpH05tnhPa8Soxe3OszjACLcBGAs/s1600/1.png)
蓋住了第二格元素,所以要執行關閉視窗按鈕
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using (IWebDriver driver = new ChromeDriver())
{
driver.Navigate().GoToUrl("https://www.instagram.com/mercci22/");
driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(2);
//找到Post的Container
var PostContainerElement = driver.FindElement(By.ClassName("_cmdpi"));
//每一行
var Rows = PostContainerElement.FindElements(By.ClassName("_70iju"));
foreach (var row in Rows)
{
var Boxs = row.FindElements(By.XPath("div"));
foreach (var box in Boxs)
{
//點擊每一格讓它展開Dialog
box.Click();

//取得Dialog底下的Article元素
var article = driver.FindElement(By.XPath("//div[@role='dialog']/div/div/article"));

//如果Dialog裡面放的是影片,則_4rbun會不存在
if (article.FindElements(By.ClassName("_4rbun")).Count == 0)
{
//關閉Dialog
driver.FindElement(By.ClassName("_dcj9f")).Click();
//跳過這則,這次目標只抓出圖片
continue;
}

//第一張圖
var ImgContainer = article.FindElement(By.ClassName("_4rbun"));
var Img = ImgContainer.FindElement(By.TagName("img"));
var Date = article.FindElement(By.XPath("div/div/a/time"));

//關閉Dialog
driver.FindElement(By.ClassName("_dcj9f")).Click();
}
}
}

這樣就可以順利地走完每一格,並且把圖片、文案、時間資料都讀出來了

**
**

多圖片的情境

加下來是應用的第二部分,Instagram是可以分享多圖片的,而多張圖片是在點擊向右按鈕後,才會動態透過JS撈出來

[![](https://4.bp.blogspot.com/-S8AFeQpjcHk/WmgbnCX9SgI/AAAAAAAAIVk/2jcAH-RsJ4cg7IN6X2_UwCWVmbNX1BJeACLcBGAs/s400/1.png)](https://4.bp.blogspot.com/-S8AFeQpjcHk/WmgbnCX9SgI/AAAAAAAAIVk/2jcAH-RsJ4cg7IN6X2_UwCWVmbNX1BJeACLcBGAs/s1600/1.png)
所以必須寫程式判斷是否有這個按鈕,如果有,表示有多張圖片,要求Driver去點擊那個按鈕,並且撈取Img的Src路徑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//第一張圖
var ImgContainer = article.FindElement(By.ClassName("_4rbun"));
var Img = ImgContainer.FindElement(By.TagName("img"));
//存放Image的Src List
List<string> ImgUrls = new List<string> { Img.GetAttribute("src") };

//如果有第二張圖以上,則會出現a[class=''_8kphn _by8kl coreSpriteRightChevron']
//直到不再出現表示最後一張圖到了
while (article.FindElements(By.CssSelector("a[class='_8kphn _by8kl coreSpriteRightChevron']")).Count > 0)
{
//點擊按鈕
var nextBtn = article.FindElement(By.CssSelector("a[class='_8kphn _by8kl coreSpriteRightChevron']"));
nextBtn.Click();

//因為Instagram是透過同一個Img Tag動態去換Src,因為程式點擊下一張按鈕太快
//會導致有Img Tag存在,但Src還來不及換,導致抓到空白的Src
//所以不是元素沒出現的問題,只好要求Thread換下一張圖時先暫停0.5秒再抓
Thread.Sleep(500);

Img = ImgContainer.FindElement(By.TagName("img"));
ImgUrls.Add(Img.GetAttribute("src"));
}

這樣就能順利拿到多張圖的路徑了

讀取第二頁的情境

Instagram是滑鼠移到最下方才會動態載入第二頁,所以需要能控制視窗移到最下方來觸發它
```csharp IJavaScriptExecutor js = (IJavaScriptExecutor)driver; //如果爬完第一頁還沒爬完,則執行JS讓視窗滾到最下方,觸發讀取第二頁 js.ExecuteScript("window.scrollTo(0,1000000)");

```

目前綜合以上所提的應用,應該已經能完全將Instagram的網站資料爬回來,只能說Selenium真的是一個強大的東西阿!!

參考文章:
XML XPath的選擇節點語法
Selenium Documentation

本篇使用套件 : RazorEngine   【官方文件 

遙想當年巷口寶之林還開著時,我這鄉野村夫每每需要套Email寄信格式以及一些罐頭訊息時,總是串一堆字串,既醜又難維護

1
2
3
4
5
6
7
8
9
public ActionResult Test(string name)
{

var sb = new StringBuilder();
sb.AppendLine(string.Format(@"Hello {0}, welcome to RazorEngine!",name));

return Content(sb.ToString());
}

如果碰到麻煩一點的,像是什麼狀況要套表頭,某某狀況又要移除Footer,那整個字串的邏輯東拼西湊,最後不執行根本難以看出結果會變成如何,往往就變成維護的黑洞跟死角誰接手誰倒楣

自從我認識了RazorEngine這套件後,世界就變美好了,只要你會套.Net MVC的View,那基本上這個套件絕對是適合你的神兵利器,廢話不多說,我們先來將剛剛版本改成用RazorEngine處理

先去Nuget安裝套件

[![](https://3.bp.blogspot.com/-C5_gHPnJWEc/Wl64J_9mh5I/AAAAAAAAISA/GiHxPzZP9Qs9OyY_eVI4FrxHeD7MELXxQCLcBGAs/s400/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://3.bp.blogspot.com/-C5_gHPnJWEc/Wl64J_9mh5I/AAAAAAAAISA/GiHxPzZP9Qs9OyY_eVI4FrxHeD7MELXxQCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)

套上RazorEngine後,可以改成這樣

1
2
3
4
5
6
7
8
9
10
11
12
13
public ActionResult TestRazorEngine(string name)
{
string template = "Hello @Model.Name, welcome to RazorEngine!";

var Result = Engine.Razor.RunCompile(
templateSource: template,
name: "HelloWorldTemplateKey",
modelType: null,
model: new { Name = name });

return Content(Result);
}

這邊解說一下程式碼,RazorEnginr提供了幾種方法來產生編譯過的Template,分別是

**Run **: 依據帶入的Name去尋找Cache中是否有編譯過的Template,如果Cache沒有這個Name的模板,則會跳Exception

**Compile **: 將帶入的模板內容編譯,並存進Cache

**RunCompile **: 等於上面兩者個結合,先去Cache中找看看有無這個Name的模板,有則直接回傳,沒有則先編譯後存入Cache,並回傳

依據官方文件所提到的,Compile Template是很耗效能的,所以建議都要存入Cache,避免每次去Compile(預設行為就是如此,當然之後也會提到如何改寫)

有了初步的認識後,應該會發現目前的做法跟拼湊字串一樣,如果只是這樣也就失去了使用RazorEngine的意義了,所以下一步是將這個Template內容變成.cshtml,可以大大的增加可讀性跟維護姓

[![](https://3.bp.blogspot.com/-pfz4iweMebY/Wl68R2ISmxI/AAAAAAAAISM/aEjP8lgBlY0krsOinpRLB1Q88251KecqQCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://3.bp.blogspot.com/-pfz4iweMebY/Wl68R2ISmxI/AAAAAAAAISM/aEjP8lgBlY0krsOinpRLB1Q88251KecqQCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)
新增_HelloWorld.cshtml Template
將剛剛的字串貼進去,並且就像我們一般套版一樣,建立對應的ViewModel
[![](https://2.bp.blogspot.com/-cEGoNuCIBPc/Wl68rMWc_6I/AAAAAAAAISQ/wECO1yhBn2My81LPW3lK9aKo9QfXMHnOACLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://2.bp.blogspot.com/-cEGoNuCIBPc/Wl68rMWc_6I/AAAAAAAAISQ/wECO1yhBn2My81LPW3lK9aKo9QfXMHnOACLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)
1
2
3
4
5
public class HellowWorldRazorModel
{
public string Name { get; set; }
}

原本的程式碼改寫成如下

1
2
3
4
5
6
7
8
9
10
public ActionResult TestRazorEngine(string name)
{
var Result = Engine.Razor.RunCompile(
"_HelloWorld",
typeof(HellowWorldRazorModel),
new HellowWorldRazorModel { Name = name });

return Content(Result);
}

但執行之後就會出Exception,原因是”_HelloWorld”這個Template Name它不知道怎麼對應到哪個cshtml模板

[![](https://4.bp.blogspot.com/-8LQ2IYJBnmI/Wl6-F94TFFI/AAAAAAAAISg/FuId2F7jKVYBpu3wOMLY1UPSkOY2P5E2QCLcBGAs/s640/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://4.bp.blogspot.com/-8LQ2IYJBnmI/Wl6-F94TFFI/AAAAAAAAISg/FuId2F7jKVYBpu3wOMLY1UPSkOY2P5E2QCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)

所以我們要自己寫RazorEngine的ITemplateManager :  官方文件參考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class MyTemplateManager : ITemplateManager
{
public ITemplateSource Resolve(ITemplateKey key)
{
// Resolve your template here (ie read from disk)
// if the same templates are often read from disk you propably want to do some caching here.
var template = Tools.ChkCache(key.Name) as string;
if (string.IsNullOrWhiteSpace(template))
{
var FilePath = string.Format("~/Views/RazorTemplate/{0}.cshtml", key.Name);
template = File.ReadAllText(HttpContext.Current.Server.MapPath(FilePath));

Tools.SaveToCache(key.Name, template, 3600, CacheType.Absolute, null);
}
// Provide a non-null file to improve debugging
return new LoadedTemplateSource(template, null);
}

public ITemplateKey GetKey(string name, ResolveType resolveType, ITemplateKey context)
{
// If you can have different templates with the same name depending on the
// context or the resolveType you need your own implementation here!
// Otherwise you can just use NameOnlyTemplateKey.
return new NameOnlyTemplateKey(name, resolveType, context);
// template is specified by full path
//return new FullPathTemplateKey(name, fullPath, resolveType, context);
}

public void AddDynamic(ITemplateKey key, ITemplateSource source)
{
// You can disable dynamic templates completely.
// This just means all convenience methods (Compile and RunCompile) with
// a TemplateSource will no longer work (they are not really needed anyway).
throw new NotImplementedException("dynamic templates are not supported!");
}
}

這邊我只改寫Resolve這個方法,其他都沿用官方文件的範本,內容只要是依據帶入的Name去確認Cache是否已經有Template(因為用公司的底層套件,所以確認Cache跟Save Cache可能跟一般認知的方法不同,請忽略或用.Net提供的Cache方法即可),如果沒有快取,則依據帶入的Name去對應的Folder位置讀取.cshtml並且Cache,然後透過RazorEngine提供的方法LoadedTemplateSource回傳TemplateSource。

這一段可以讓RazorEngine知道如何將Name對應到我們的cshtml,寫完客製的MyTemplateManager後接著是設定

Global.asax加上這段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);

//RazorEngine Setting
var config = new TemplateServiceConfiguration();
config.TemplateManager = new MyTemplateManager();

var service = RazorEngineService.Create(config);
Engine.Razor = service;
}

接著剛剛的程式就能執行了!!

[![](https://3.bp.blogspot.com/-NWxWGypeAdo/Wl7AVFb9jRI/AAAAAAAAISs/24bA9qjOkTwoY1ca8SDc8_OXhuB3vI_2QCLcBGAs/s640/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://3.bp.blogspot.com/-NWxWGypeAdo/Wl7AVFb9jRI/AAAAAAAAISs/24bA9qjOkTwoY1ca8SDc8_OXhuB3vI_2QCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)
RazorEngine也提供Layout的套版方法,可以在cshmtl上面設定對應的Layout,不用像先前提到的,拼字串拼得亂七八糟了
[![](https://3.bp.blogspot.com/-lOts6puSj9Q/Wl7BYAAbwJI/AAAAAAAAIS0/E05MfohLLpgG0Cp2x63uJ8i6ZL-ifJdagCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://3.bp.blogspot.com/-lOts6puSj9Q/Wl7BYAAbwJI/AAAAAAAAIS0/E05MfohLLpgG0Cp2x63uJ8i6ZL-ifJdagCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)
新增_OurLayout.cshmtl
[![](https://3.bp.blogspot.com/-e3Q9NSlCVuk/Wl7Bsff9odI/AAAAAAAAIS4/J0XN1F7PPW0rqsRmvA9yw5cGcTeAjJiawCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://3.bp.blogspot.com/-e3Q9NSlCVuk/Wl7Bsff9odI/AAAAAAAAIS4/J0XN1F7PPW0rqsRmvA9yw5cGcTeAjJiawCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)
Layout的內容
[![](https://3.bp.blogspot.com/-ugzdMhXnb3w/Wl7CFh0QMAI/AAAAAAAAITA/yxDlQmxK1f4X_4zjamWyCEJnoTUWgwfkgCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://3.bp.blogspot.com/-ugzdMhXnb3w/Wl7CFh0QMAI/AAAAAAAAITA/yxDlQmxK1f4X_4zjamWyCEJnoTUWgwfkgCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)

執行結果

[![](https://1.bp.blogspot.com/-t8lTrthvFXQ/Wl7CXvDYDMI/AAAAAAAAITE/k4aTtPG7zsIUmvUG4U5hvI7nyOpV6JzmACLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://1.bp.blogspot.com/-t8lTrthvFXQ/Wl7CXvDYDMI/AAAAAAAAITE/k4aTtPG7zsIUmvUG4U5hvI7nyOpV6JzmACLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)

RazorEngine PartialView的用法

在一般套版,我們可能會把共用的區塊挖成PartialView重複使用,但這邊的用法比較特別,須改用Include這個方法取代

[![](https://4.bp.blogspot.com/-m5NwrfUZcMs/Wl7C11d-lnI/AAAAAAAAITM/AA-QS-UbaEgFXr8MUjx3olooVMgAEkLbwCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://4.bp.blogspot.com/-m5NwrfUZcMs/Wl7C11d-lnI/AAAAAAAAITM/AA-QS-UbaEgFXr8MUjx3olooVMgAEkLbwCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)
新增PartialView
[![](https://1.bp.blogspot.com/-NvEYUWh1nkw/Wl7DBfTkNOI/AAAAAAAAITQ/SBSCU6uWL2ck3U7nncXsEo4q5mdRFboiQCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://1.bp.blogspot.com/-NvEYUWh1nkw/Wl7DBfTkNOI/AAAAAAAAITQ/SBSCU6uWL2ck3U7nncXsEo4q5mdRFboiQCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)
PartialView的內容,而且也可以使用ViewBag傳遞參數
[![](https://1.bp.blogspot.com/-Ll9MHoSaYtM/Wl7Dk_WcOGI/AAAAAAAAITY/8CF1z0SXs2M21_tS3YCH5KrEY6WP6zAAgCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://1.bp.blogspot.com/-Ll9MHoSaYtM/Wl7Dk_WcOGI/AAAAAAAAITY/8CF1z0SXs2M21_tS3YCH5KrEY6WP6zAAgCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)
改寫原本的_HelloWorld.cshtml,套入PartialView

執行結果!!

[![](https://3.bp.blogspot.com/-osb6w9vTl8o/Wl7D2hzXn2I/AAAAAAAAITc/KpzyaZf08Fseqa10QxbEEaIdKAJDELv6gCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://3.bp.blogspot.com/-osb6w9vTl8o/Wl7D2hzXn2I/AAAAAAAAITc/KpzyaZf08Fseqa10QxbEEaIdKAJDELv6gCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)

希望對大家有幫助~

此篇文章用的AutoMapper版本 : 6.2.2 <套件連結>

被同事問到AutoMapper有沒有辦法做泛型的對應,問了一下才發現其他人原來也沒嘗試過這樣的作法,所以來筆記一下供之後參考。

首先對應的Class如下

1
2
3
4
5
6
7
8
9
10
11
12
public class Source<T>
{
public int Page1 {get;set;}
public T Value { get; set; }
}

public class Destination<T>
{
public int Page {get;set;}
public T Value { get; set; }
}

希望能將Source<T>對應到Destination<T>,而這邊的泛型T有兩組分別如下

1
2
3
4
5
6
7
8
9
10
public class Test
{
public string Name {get;set;}
}

public class Test1
{
public string NickName { get; set; }
}

同事原本寫的Mapper Configuration對應如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var config = new MapperConfiguration(cfg =>
{
cfg.CreateMap<Source<Test>, Destination<Test1>>()
.ForMember(d => d.Page, o => o.MapFrom(s => s.Page1))
.ForMember(d => d.Value, o => o.MapFrom(s => s.Value));

cfg.CreateMap<Test, Test1>()
.ForMember(d => d.NickName, o => o.MapFrom(s => s.name));
});

var mapper = config.CreateMapper();
Source<Test> Source = new Source<Test>
{
Page = 1,
Value = new Test
{
name = "hi"
}
};
Destination<Test1> Result = mapper.Map<Source<Test>, Destination<Test1>>(Source);

這樣寫對應會過,但是變成泛型的T有多種可能時,要寫多組的Source與Destination對應,喪失了AutoMapper的重用性

1
2
3
4
5
6
7
8
9
10
cfg.CreateMap<Source<NewClass2>, Destination<NewClass2>>()
.ForMember(d => d.Page, o => o.MapFrom(s => s.Page))
.ForMember(d => d.Value, o => o.MapFrom(s => s.Value));

cfg.CreateMap<Source<NewClass3>, Destination<NewClass4>>()
.ForMember(d => d.Page, o => o.MapFrom(s => s.Page))
.ForMember(d => d.Value, o => o.MapFrom(s => s.Value));

//............一直往下增加

能不能讓Source與Destination設定一次就好,之後只要多寫Generic的Type對應即可,其實AutoMapper是有提供的,方式如下

1
2
3
4
cfg.CreateMap(typeof(Source<>), typeof(Destination<>))
.ForMember("Page",o => o.MapFrom("Page1"))
.ForMember("Value",o => o.MapFrom("Value"));

之後只要針對泛型的Class補充即可,Source對應到Destination就已經設定完了,以上供參考

網站如果希望能提供多語系版本,且網址規則如下該如何實作?

http://abcdefg.com/index             【預設繁體中文】
http://abcdefg.com/zh-TW/index 【繁體中文】
http://abcdefg.com/zh-CN/index  【簡體中文】
http://abcdefg.com/en-US/index  【英文】

先從Action開始,挖個Route參數來抓目前使用者希望的語系為何,並且設定Culture
(因為重點是多語系範例,所以就不考慮大小寫判斷那些,還請暫時忽略)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[Route("~/index")]
[Route("~/{culture}/index")]
public ActionResult Index(string culture)
{
switch (culture)
{
case "zh-CN":
break;
case "en-US":
break;
default:
culture = "zh-TW";
break;
}

//設定多語系
CultureInfo ci = new CultureInfo(culture);
Thread.CurrentThread.CurrentCulture = ci;
Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(ci.Name);
return View();
}

接著新增語系檔資料夾App_GlobalResource
專案右鍵  >  加入  > 加入ASP.NET資料夾 > App_GlobalResource

[![](https://1.bp.blogspot.com/-X0T0qbHbM4g/Wg5DB8tdjCI/AAAAAAAAIQc/OFuD0klzB4sOBimU6auOHx-nduJNgxSagCLcBGAs/s640/1.png)](https://1.bp.blogspot.com/-X0T0qbHbM4g/Wg5DB8tdjCI/AAAAAAAAIQc/OFuD0klzB4sOBimU6auOHx-nduJNgxSagCLcBGAs/s1600/1.png)

新增對應語系的設定檔App_GlobalResources右鍵  >  加入  > 資源檔

[![](https://2.bp.blogspot.com/-yDb53fw_k78/Wg5Dp4kiDQI/AAAAAAAAIQk/7nBQ-vbv1PcOVvNkoS1H6QpdHvBZIIV-ACLcBGAs/s640/2.png)](https://2.bp.blogspot.com/-yDb53fw_k78/Wg5Dp4kiDQI/AAAAAAAAIQk/7nBQ-vbv1PcOVvNkoS1H6QpdHvBZIIV-ACLcBGAs/s1600/2.png)
** **** **
[![](https://2.bp.blogspot.com/-7b1G9a0T0Ec/Wg5EFbj8FoI/AAAAAAAAIQo/xi6QYst8RssqaLd6fxDr9avHWL9XwtCdACLcBGAs/s1600/3.png)](https://2.bp.blogspot.com/-7b1G9a0T0Ec/Wg5EFbj8FoI/AAAAAAAAIQo/xi6QYst8RssqaLd6fxDr9avHWL9XwtCdACLcBGAs/s1600/3.png)
這邊的資源檔名稱是固定的,不能任意更改
** ** 在各個設定檔設定簡單文字,用來判別是否有正確切換語系
[![](https://2.bp.blogspot.com/-_tlyjSac5Mw/Wg5FEUE-MPI/AAAAAAAAIQ4/EqScZ5b_GUMrvQiG6EU5Pg2qyAKmzs8cgCLcBGAs/s640/1.png)](https://2.bp.blogspot.com/-_tlyjSac5Mw/Wg5FEUE-MPI/AAAAAAAAIQ4/EqScZ5b_GUMrvQiG6EU5Pg2qyAKmzs8cgCLcBGAs/s1600/1.png)
[![](https://4.bp.blogspot.com/-SbOTT0V8vy0/Wg5FETAHr3I/AAAAAAAAIQ8/UIejGSO6TDYAihR8r5KtuUMIgV6ERa1ZQCLcBGAs/s640/2.png)](https://4.bp.blogspot.com/-SbOTT0V8vy0/Wg5FETAHr3I/AAAAAAAAIQ8/UIejGSO6TDYAihR8r5KtuUMIgV6ERa1ZQCLcBGAs/s1600/2.png)
[![](https://2.bp.blogspot.com/-7JPMV22L3uc/Wg5FEd-t4dI/AAAAAAAAIQ0/0DFlVpoEqPk2LdRgl-dCU_htY2lLaasPQCLcBGAs/s640/3.png)](https://2.bp.blogspot.com/-7JPMV22L3uc/Wg5FEd-t4dI/AAAAAAAAIQ0/0DFlVpoEqPk2LdRgl-dCU_htY2lLaasPQCLcBGAs/s1600/3.png)

View上面就簡單做,只顯示出對應語系的CultureNow

[![](https://1.bp.blogspot.com/-rkJslBeNSY4/Wg5FaG31wvI/AAAAAAAAIRA/hi6OuzPU7Mw9fmD-tGoDFJ_Z-shVMCpTgCLcBGAs/s400/1.png)](https://1.bp.blogspot.com/-rkJslBeNSY4/Wg5FaG31wvI/AAAAAAAAIRA/hi6OuzPU7Mw9fmD-tGoDFJ_Z-shVMCpTgCLcBGAs/s1600/1.png)

測試

[![](https://2.bp.blogspot.com/-jdlM9kSYyjM/Wg5G94F9tWI/AAAAAAAAIRg/E66m5xlgo64zLs9IcyAenpJUZcdiwm_YQCLcBGAs/s640/Webp.net-gifmaker.gif)](https://2.bp.blogspot.com/-jdlM9kSYyjM/Wg5G94F9tWI/AAAAAAAAIRg/E66m5xlgo64zLs9IcyAenpJUZcdiwm_YQCLcBGAs/s1600/Webp.net-gifmaker.gif)
 好的做到這邊看起來沒什麼問題,但接下來的問題是,是否後續開發都要在每個Action加上這個RouteAttribute
1
2
3
4
5
[Route("~/{culture}/index")]
[Route("~/{culture}/home")]
[Route("~/{culture}/user")]
......

且設定語系的段落勢必也要寫成ActionFilterAttribute,這樣其實增加了開發的困難之外,只要有人忘記加上,那新的頁面就會少了多語系功能。

工程師的美德就是懶,是否有辦法只做一次就讓全站Route都自動設定好呢? 這時候就要用到DefaultDirectRouteProvider 來解決這這個問題了

DefaultDirectProvider能幫我們做什麼?

它能幫我們在註冊全站Route Template的時候做一些邏輯的加工,在這邊的案例應用上,
我們希望在註冊全站的Route的時候都自動幫我們在Template最前面加上{culture}
來達到做一次多語系設定即可

讓我來實作看看,先新增一個CultureRouteProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/// <summary>
/// 多語系Route Provider
/// </summary>
/// <seealso cref="System.Web.Mvc.Routing.DefaultDirectRouteProvider" />
public class CultureRouteProvider: DefaultDirectRouteProvider
{
/// <summary>
/// 取得所指定之動作描述元的一組路由 Factory。
/// </summary>
/// <param name="actionDescriptor">動作描述元。</param>
/// <returns>
/// 一組路由 Factory。
/// </returns>
protected override IReadOnlyList<IDirectRouteFactory> GetActionRouteFactories(ActionDescriptor actionDescriptor)
{
IReadOnlyList<IDirectRouteFactory> actionRouteFactories = base.GetActionRouteFactories(actionDescriptor);

List<IDirectRouteFactory> actionDirectRouteFactories = new List<IDirectRouteFactory>();

foreach (IDirectRouteFactory routeFactory in actionRouteFactories)
{
RouteAttribute routeAttr = routeFactory as RouteAttribute;
if (routeAttr != null && !string.IsNullOrEmpty(routeAttr.Template))
{
//每個Route Template原本的樣子
//已剛剛的Action為例,就是 "~/index"
var template = $"{routeAttr.Template}";

var routeAttribute = new RouteAttribute(template)
{
Order = routeAttr.Order,
Name = routeAttr.Name
};
actionDirectRouteFactories.Add(routeAttribute);

//替每組Action都多加上一組{culture}/RouterTemplateLanguage的多語系RouteMap
//EX: "~/index" 多一組 "~/{culture}/index"
var includeLangTemplate = routeAttr.Template.Replace("~/", string.Format(@"~/{{culture:regex(^(zh\-tw|en\-us|zh\-cn)$)}}/"));

//註冊這組多語系的Route Template
var includeLangRouteAttribute = new RouteAttribute(includeLangTemplate);
includeLangRouteAttribute.Order = routeAttr.Order + 1;
includeLangRouteAttribute.Name = routeAttr.Name;

actionDirectRouteFactories.Add(includeLangRouteAttribute);
}
}

return actionDirectRouteFactories;
}
}

這邊需要特別注意一下,我在culture後面加上了RouteConstraint的正規表示法限制,目的是讓只有zh-tw ,  en-us , zh-cn才會落入這個模板的範圍,如果沒有這段限制,就會變成萬用Route,有點像是{Controller}/{action}/{id}那般,所有網址都會跑到這邊,造成網址大亂!!!

接著將這組CultureRouteProvider在RouteConfig註冊使用

1
2
3
4
5
6
7
8
9
10
11
12
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

//註冊CultureRouteProvider
var constraintsResolver = new DefaultInlineConstraintResolver();
RouteTable.Routes.MapMvcAttributeRoutes(new CultureRouteProvider());
}
}

然後將原本Action那組多語系RouteAttribute拿掉試試看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//拿掉多語系RouteAttribute
[Route("~/index")]
public ActionResult Index(string culture)
{
switch (culture)
{
case "zh-CN":
break;
case "en-US":
break;
default:
culture = "zh-TW";
break;
}

//設定多語系
CultureInfo ci = new CultureInfo(culture);
Thread.CurrentThread.CurrentCulture = ci;
Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(ci.Name);
return View();
}

會發現多語系的功能依然存在,所以CultureRouteProvider有正確運作,自動的幫我們加上了多語系的Route Template。

接著是處理設定語系的段落,不應該讓設定語系的判斷落在每個Action裡面,所以將它獨立拉出來到自定義的CultureFilter中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class CultureFilter : IAuthorizationFilter
{
public List<string> AllowCultures = new List<string>
{
"zh-cn","zh-tw","en-us"
};

public void OnAuthorization(AuthorizationContext filterContext)
{
string culture = string.Empty;
if (filterContext.RequestContext.HttpContext.Request.Url.Segments.Count() & gt; 1)
{
culture = filterContext.RequestContext.HttpContext.Request.Url.Segments[1].Replace("/", string.Empty);
}

if (string.IsNullOrWhiteSpace(culture) ||
!AllowCultures.Any(x = > x.ToLower() == culture.ToLower()))
{
culture = "zh-tw";
}

//設定多語系
CultureInfo ci = new CultureInfo(culture);
Thread.CurrentThread.CurrentCulture = ci;
Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(ci.Name);
}
}

在FilterConfig註冊CultureFilter

1
2
3
4
5
6
7
8
9
10
public class FilterConfig
{
public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
filters.Add(new HandleErrorAttribute());
//多國語系
filters.Add(new CultureFilter());
}
}

將原本的Action所有判斷拿掉會發現多語系的功能還是一切正常

1
2
3
4
5
6
[Route("~/index")]
public ActionResult Index()
{
return View();
}

這樣就差不多大功告成,其實要優化的地方還很多,例如大小寫判斷,多語系應該拉成Enum方便擴充,Route Constraint應該跟隨著多語系的Enum去自動產生….等,因為這是獨立拉出來Demo的程式,就不搞得像Production Code一樣複雜了,知道自己注意一下就好XD

當今天有個網址的需求是 http://abcdefg.com/【使用者暱稱】,使用者暱稱帶到誰的就會到個人頁網址,EX : http://abcdefg.com/toyo 就連到Toyo個人頁面,http://abcdefg.com/steven就連到Steven個人頁,那我們Router可以寫成

1
2
3
4
5
6
7
[Route("~/toyo")]
[Route("~/steven")]
public ActionResult Content()
{
return View();
}

這樣寫的確兩個網址都能連到了,但卻抓不到UserName所以不知道怎麼顯示個人頁,調整一下

1
2
3
4
5
6
7
[Route("~/{UserName}")]
public ActionResult Content(string userName)
{
ViewBag.Name = userName;
return View();
}

下個問題來了,Tom明明不是這邊的用戶,卻也會導到這個Action,導致後端抓不到對應資料顯示錯誤,能不能只有abcdefg.com/Toyo 跟 abcdefg.com/Steven 的時候才導來這,其他什麼阿貓阿狗,甚至是常用的Index、Home、Menu之類的不會跑錯。

這時候RouteConstraint就派上用場了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class UserNameConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
if (values.ContainsKey(parameterName))
{
var UserName = values[parameterName] as string;

return UserName.ToLower() == "toyo" ||
UserName.ToLower() == "steven";
}

return false;
}
}

接著在RouteConfig註冊這組Constraint,讓RouteAttribute可以使用

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

//註冊Router ConStraint
var constraintsResolver = new DefaultInlineConstraintResolver();
constraintsResolver.ConstraintMap.Add("MustUserName", typeof(UserNameConstraint));
routes.MapMvcAttributeRoutes(constraintsResolver);
}
}

將RouteAttribute的UserName加上這個限制

1
2
3
4
5
6
7
[Route("~/{UserName:MustUserName}")]
public ActionResult MyContent(string userName)
{
ViewBag.Name = userName;
return View();
}

這樣只要UserName不是帶Toyo或是Steven的就都不會導到這個Action了。

其實RouteConstraint官方已經提供一下基礎的限制可以使用,例如一定要是Int,字串長度之類的方便用法,而且更重要的是在這案例之中,使用者名稱我們是寫死的,只要把那段改成抓外部來源,例如資料庫之類的,這樣就能後台使用者有新增時,Route的限制就自動更新了

延伸閱讀:
Attribute Routing in ASP.NET MVC 5
DemoShop : ASP.NET MVC Route 自訂限制條件(constraints)的技巧

開放後台給非工程人員編輯Html,往往都要擔負一些Html Tag錯誤整導致破版的風險。這次就碰到上線前發現部分資料區塊缺少了Close Tag,導致只要讀到那些資料的頁面都破版,但上千筆資料請人一筆一筆去檢查又太不切實際。所以就研究了一下是否有套件可以處理這類的問題,結果就發現了一個強大的套件 : HtmlAgilityPack

該套件主要功能是拿來解析網頁,似乎更多人是拿來製作爬蟲工具,但他同時也很貼心的提供API來分析Html Tag是否正確

首先先從Nuget載入該套件

[![](https://2.bp.blogspot.com/-cMfu_9lAHPY/WguV9l_rY0I/AAAAAAAAIQM/3thXpbJDKr4ZQPGb5T7Ff-waUuo7p6tWgCLcBGAs/s640/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://2.bp.blogspot.com/-cMfu_9lAHPY/WguV9l_rY0I/AAAAAAAAIQM/3thXpbJDKr4ZQPGb5T7Ff-waUuo7p6tWgCLcBGAs/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)
然後用以下程式來進行Html修復
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//商品的尺寸報表Html有錯誤
var ps = this.Products.Where(x = > x.SizeReport != null);
foreach (var p in ps)
{
HtmlDocument doc = new HtmlDocument();
//fix when nesting errors are detected
doc.OptionFixNestedTags = true;

//將Html Editor編輯的東西丟進去
doc.LoadHtml(p.SizeReport.ToString());

//將修復後的Html結果存到MemoryStream
MemoryStream stream = new MemoryStream();
doc.Save(stream);
try
{
using (StreamReader reader = new StreamReader(stream, Encoding.Default))
{
stream.Position = 0;
p.SizeReport = reader.ReadToEnd(); //儲存回DB
this.SubmitChanges();
}

}
catch (Exception ex)
{

}
}