[ASP.NET]重構之路系列v2 – DRY & Top-Down思考方式

[ASP.NET]重構之路系列v2 – DRY & Top-Down思考方式

前言
前一篇文章:
[ASP.NET]重構之路系列v1 – UI, Business logic, Data access概念分開 初試啼聲,獲得各位前輩的讚許,讓我感動萬分,我想大家一定都飽受糾結版的荼毒,才會這麼心有戚戚焉。然而現實是殘酷的,需求不會只有這麼簡單的一個驗證密碼的功能而已,需求就是一直再改變,資訊時代唯一不變的事情就是『改變』。要怎麼樣因應新的需求,擁抱改變,隨著使用者需求改變,讓我們的系統可以逐步逐步的更加穩固,這就是我們這個系列希望達到的目的。

需求說明
上次我們已經做了『驗證密碼』的功能了,今天User提出一個新的需求是『修改密碼』,這個功能分成三塊:

  1. 我們得先驗證原本的密碼正確與否。
  2. 我們得確認User輸入的新密碼有沒輸入錯誤,有沒與確認密碼相同。
  3. 都OK之後,將新的密碼儲存起來。

screen

通常PM都會說:『這個功能跟上次很像啊,應該很快吧,複製貼上就可以了,不用花這麼多時間吧。』

千萬不要相信這種鬼話啊!!如同7-Eleven之父鈴木敏文所說:『妥協是很簡單的,但一旦妥協了,所有的一切就都結束了。』每次的妥協或貪圖方便,都是在欠下『
技術債』,出來跑,總有一天要還的。即使我們作不到『前人種樹,後人乘涼』,至少我們也不要當『前人挖坑,後人憎恨』。

種樹步驟
如需求說明所說,我們基本上按了按鈕就是要做三件事,這邊希望帶出『抽象思考』的方式來設計我們的程式。什麼是抽象思考?以往初學的工程師,總是想到什麼做什麼,做了什麼想什麼,但這樣很容易迷失在自己的邏輯因果循環的叢林中,甚至忘了到底一開始為什麼要設計成這樣。(雖然TDD一開始的起步,也有點像,但精神完全不同,因為TDD一開始就定義好最後要完成什麼樣的功能,絕不是且戰且走)

這邊,我們希望先將需求與思緒整理好,也就是最基本的抽象思考去設計,我們到底需要什麼樣的功能。(有關抽象化可以參考小朱大的兩篇文章:
邁向架構師的暖身運動(2):抽象化的能力[如何學習寫程式] #7 - 一開始就訓練自己的抽象化與分層能力) 我們這邊的抽象化其實很基本,如同第一篇文章我們重構完看到的樣子一樣,把我們想要做的事情先列出來,在還沒看到內容之前,先從三萬英尺的高空來構思我們到底要怎麼設計。

步驟一:
我們先將我們要做的事情想好,給個方法名字,把『深度1』的邏輯先寫出來,而先不去在意方法裡面的細節,我們要決定的是該input什麼參數,以及方法該回傳什麼type,讓我們深度1的邏輯設計可以達成我們的功能需求。當明白了我們的功能需求,明白了每一個方法很乾淨明確的名稱與意義,明白了input/output的期望值,我們設計內容細節,才有意義。

v2-1初構

雖然方法底下長滿了毛毛蟲,但莫急莫慌莫害怕,我們先來檢視一下這樣的邏輯正不正確,如果正確,透過方便的Visual Studio,我們就可以快樂的進行步驟二。

步驟二:
我們將滑鼠游標移到長滿紅色毛毛蟲的方法上,可以看到下拉清單,我們選一下『產生某某class中的某某方法』。另一個方式是用滑鼠右鍵,選『產生』=>『方法Stub』。

v2-1產生方法1

v2-1產生方法2

接著我們可以看到Visual Studio自動幫我們產生的內容,貼心的是連parameter的名字都跟我們一開始抽象化設計的名字一樣,這樣可以讓自動產生的方法也具備對應的可讀性:
v2-1產生方法end

接著我們將原本抽象設計的註解,移至對應的方法中,透過///產生API document的方式來對我們的方法進行說明:

v2-1補上註解

有了這樣的註解,當我們要使用這些方法,或是滑鼠移至該方法上時,就可以看到對應的tip:
v2-1補上註解2

到這邊,步驟二就算結束了。請大家再回顧一下,在我們產生方法外殼的過程中,我們完全沒動到深度1的抽象設計邏輯,但我們要做的事,要產生的殼,要建置通過的步驟已經都完成了。

v2-1建置成功

如果您是屬於設計高階邏輯的人,這個時候工作已經做完了,內部的細節可以交給比較junior的programmer進行設計,這樣的抽象程式有點像spec,也有點像虛擬碼。好處是思考與設計不會被細節所迷惑,好處是我們清楚自己在做什麼,也清楚方法要做到什麼。


步驟三:
這是一步挑戰人性的步驟,當我們要設計VerifyPassword的時候,PM或Leader告知,之前已經有個傢伙寫過一模一樣邏輯的程式,請自行『參閱』。您會怎麼做?

v2-2想偷用複製貼上
請注意,當您打開Validate.aspx.cs,企圖把方法內容選起來,按下複製,想要直接貼到新的這支功能時,相當殘念!您又少了一次成長的機會,您又多了一次技術債留給後人,您又替後面的需求異動多了一層累贅。

DRY=Don't Repeat Yourself,只要兩段程式碼,指的是同一件事,未來需求異動不是看code,而是看domain know-how,在domain中同一件事未來需求變更時,當然都要跟著變。多複製一次,未來就要增加多改一份的成本,就要增加漏改一份的風險。

請不要這麼做(無腦的複製貼上),讓我們靜下心好好想想,既然這一段code在兩個地方用的到,他們是不是屬於同一件事?如果是,未來會不會有第三個第四個地方用的到?未來需求異動,要改一份還是兩份,還是很多很多?當我們判定這樣的驗證密碼邏輯,可能在其他地方還用的到,而且這兩個網頁上這兩個需求的確是指同一件事,那請跟著我這樣做,就能將這個功能的職責分的更清楚與獨立。

先在網站上,加入App_Code的ASP.NET資料夾。
v2-2增加App_code
接著我們定義VerifyPassword這個行為,屬於Authentication的一環,所以我們增加了Service與DataAccess的folder,並增加了對應的Authentication class在裡面。

v2-2增加Service與Dao Class

接著我們來看我們原本v1的程式中,Enum的VerifyStatus是private的,這樣會導致只有該頁面能用。所以我們就先重構Enum的部分,在App_Code先建立一個Utility的folder,建一個EnumUtility的class,裡面要用來放屬於共用Enum的部分。然後,我們先將原本的Enum宣告註解掉,這個時候會發現在VerifyPasswordById的方法,回傳型別出現了紅色毛毛蟲。沒錯,我們要透過IDE來幫我們重構與自動產生。我們在VerifyStatus紅色毛毛蟲上,『產生』=>『產生新的型別』。

v2-2產生Enum
接著將visibility設定成『public』,型別選『Enum』,名稱預設就會是剛剛紅色毛毛蟲的型別名字。加入到我們剛剛建立的class裡面。
v2-2產生Enum to Utility
然後將我們註解掉的Enum的項目,填到EnumUtility的項目中即可。
v2-2將Enum補齊

到這邊,我們的邏輯本體還是沒被修改到,但該讓大家共用的Enum宣告,已經放到該放的位置了。


步驟四:
接著我們來大風吹囉,我們將原本屬於business logic的方法,移到Service裡面的AuthenticationService中。將屬於DataAccess的方法,移至DataAccess的AuthenticationDao中。會發現,原本在同一頁的方法,在Service中找不到他的兄弟了。
v2-3移動至Service
v2-3移動至Dao
所以我們要在Service中,加上Dao類別的初始化,換呼叫Dao類別的方法。頁面也要修改成,呼叫AuthenticationService的VerifyPasswordById的方法。
v2-3移動至Service2
v2-3頁面呼叫Service class
到這邊,該共用的部分,我們都已經移至App_Code相對應的folder裡面。到現在,我們的程式邏輯還是沒變過。我們只有移動了位置,讓各功能模組的職責定位更加清楚。也可以讓團隊開發的時候,可以知道哪一些東西該去哪邊找,進而訂定coding standard。

步驟五:
回到我們新增的需求中,我們到現在還是只有一堆throw new NonImplementationException(),但是我們不用重複開發一樣的程式了。在VerifyPassword的部分,我們只需要呼叫AuthenticationService的VerifyPasswordById的方法即可。當回傳的是Passed時,我們這個方法即回傳true,其他則都當作驗證有誤。這就是基本的DRY原則,我們不重複開發一樣的東西,並不只是為了現在重複開發的成本,而是不遺留技術債給未來的人(那個人很有可能也是自己)。
v2-4重用AuthenticationService方法

其他的部分,我們就簡單的補上實作細節的內容:

v2-4補足方法內容

一樣透過Top-Down的設計方式,再去產生對應的方法外殼即可,這樣做重點不只是省工,而是聚焦。

最後再加上try catch防呆,不讓user看到天書般的錯誤資訊,這是工程師基本的sense。
v2-4 try catch 

步驟六:
到步驟五這邊,基本上就大功告成了。但如果頁面有重複的使用到某一個class,而且代表的都是同一個職責,那我會建議再抽象化一層出來,避免重複的程式一再出現,所以我們會將使用到的class與初始化的部分再獨立出來。例如頁面用到兩次AuthenticationService,AuthenticationService用到兩次AuthenticationDao。我們將其封裝成property,避免重複初始化或是一式多份的情況發生。

v2-5 擷取至property
v2-5 建構式初始化property

大功告成!(重構完記得要慶祝一下!) 來看一下我們新的程式長的多乾淨:

public partial class ModifyPassword : System.Web.UI.Page
{
    public AuthenticationService MyAuthenticationService { get; set; }
    protected void Page_Load(object sender, EventArgs e)
    {
        this.MyAuthenticationService = new AuthenticationService();
    }

    /// <summary>
    /// 先檢查原始密碼是否合法,合法後檢查新密碼與確認密碼是否相同,都OK才將新密碼存進DB。
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    protected void Modify_Click(object sender, EventArgs e)
    {
        //假設id為Session["UserId"]
        string id = Convert.ToString(Session["UserId"]);
        string password = this.OriginalPassword.Text;

        string newPassword = this.NewPassword.Text;
        string confirmPassword = this.NewPassword.Text;

        string result = string.Empty;

        if (this.VerifyPassword(id, password))
        {
            if (this.CheckPasswordSame(newPassword, confirmPassword))
            {
                result = this.SaveNewPassword(id, newPassword);
            }
            else
            {
                //新密碼與確認密碼不符
                result = "新密碼與確認密碼不符";
            }
        }
        else
        {
            //帳號與密碼不符
            result = "密碼輸入錯誤";
        }
        this.Result.Text = result;
    }

    /// <summary>
    /// 將新密碼存入DB
    /// </summary>
    /// <param name="newPassword"></param>
    /// <history>
    /// 忘了加上id參數了,沒有id,後面SQL要Update password就冏了
    /// </history>
    private string SaveNewPassword(string id, string newPassword)
    {
        string result = string.Empty;
        //假設我們的domain know-how認為更改密碼也屬於Authentication的一環,那我們就把這加在AuthenticationService裡
        //AuthenticationService authenticationService = new AuthenticationService();
        try
        {
            MyAuthenticationService.UpdatePassword(id, newPassword);
            result = "更新密碼成功";
        }
        catch (Exception ex)
        {
            result = "更新密碼失敗";
            //here should be logging exception.            
        }

        return result;
    }

    /// <summary>
    /// 檢查新密碼與確認密碼是否相同
    /// </summary>
    /// <param name="newPassword"></param>
    /// <param name="confirmPassword"></param>
    /// <returns></returns>
    private bool CheckPasswordSame(string newPassword, string confirmPassword)
    {
        return (newPassword == confirmPassword);
    }

    /// <summary>
    /// 檢查原始密碼是否合法
    /// </summary>
    /// <param name="id"></param>
    /// <param name="password"></param>
    /// <returns></returns>
    private bool VerifyPassword(string id, string password)
    {
        //AuthenticationService authenticationService = new AuthenticationService();
        var status = MyAuthenticationService.VerifyPasswordById(id, password);
        return (status == VerifyStatus.Passed);      
    }
}

結論
這篇文雖然有點冗長,但希望帶出來的觀念就是:

  1. 不要放縱自己,不要貪圖一時便宜,不要輕忽copy/paste帶來的技術債,不要因此讓自己扼殺了成長的機會。
  2. 抽象思考,不要被技術細節、實作細節所迷惑了,導致寫程式的過程像陷入漩渦的船,迷失了方向。
  3. 重構與Top-Down的開發方式,其實Visual Studio有很多工具可以用,只是端看各位願不願意嘗試與改變。


最後希望這一篇文章對大家在開發與維護上,可以有所幫助。

Sample Code: RefactoringToArchitectureSample-v2.zip

 


blog 與課程更新內容,請前往新站位置:http://tdd.best/