April 3, 2013

將Logging Framework去耦(decouple)的必要性

大多數的人在決定將系統導入log機制時通常會先選擇幾個較為知名且開源的logging framework如Enterprise Library的Logging Application Block、log4netNLog等,除了避免重複造輪子浪費時間外,這些framework也提供了不少強大的功能還附上原始碼。這裡不談如何去選擇一個合適的logging framework,畢竟個人偏好及政治因素可能佔上大部份,也就是那句老話,it depends。

上述framework都有各自的抽象層及實作,通常很直覺地當我們安裝好時,就直接在程式碼中使用起來,例如NLog使用factory method pattern去建立一個Logger實作類別
Logger logger = LogManager.GetCurrentClassLogger();
logger.Debug("this is a Debug level");
而log4net則使用factory method pattern建立一個ILog介面
ILog log = LogManager.GetLogger(typeof(Program));
log.Debug("this is just a debug message");

接下來諸如上面的程式碼片段便開始散佈在各個使用logging framework的類別中。這樣會有什麼問題嗎?

在不做unit test和不更換logging framework(例如將log4net換成NLog)的前提下,的確是不會有什麼問題。硬要說的話,就是違反了dependency inversion principle和program to an interface, not an implementation兩個原則,造成client code直接與logging framework相依,耦合度提高。

基於這兩個原則,我們可以利用facade pattern建立一個client code要做log時呼叫用的抽象層,例如
public interface ILogService
{
    void Debug(string message;
    void Info(string message);
    void Warn(string message);
    void Error(string message);
    // 以下省略
}
接下來建立一個類別來實作ILogService介面,而這個實作類別裡直接使用logging framework,等於是將實際log的功能再委託給logging framework,以下面為例是直接呼叫NLog提供的API。
public class LogService : ILogService
    {
        private static Logger _logger = LogManager.GetCurrentClassLogger();

        #region ILogService Members

        public void Debug(string message)
        {
            if (!_logger.IsDebugEnabled)
            {
                return;
            }

            if (string.IsNullOrWhiteSpace(message))
            {
                throw new ArgumentNullException("message");
            }

            _logger.Debug(message);
        }

        // 以下省略

        #endregion
    }

接著在client code只要搭配IoC container就可以反轉相依性讓client code相依於前面建立的抽象層(ILogService)。
ILogService logService = ObjectFactory.GetInstance<ILogService>();
logService.Debug("This is a debug level");

如此便符合上述兩項原則,但為了符合這兩項原則還得大費周章建立一個抽象層來使用,真的值得嗎?以下就兩個面向來討論

Testability
將原本相依於logging framework實作的程式反轉成相依於自行建立的抽象(ILogService)類別可以提高程式碼單元測試的可測性,讓我們可以專注在待測的類別,並使用mocking framework如Rhino Mocks透過mock或stub模擬ILogService的操作,藉以輔助我們實際要測試的功能,如此我們就不必理會ILogService實際上的實作(LogService)如何運作,因為我們可以透過mock/stub模擬出我們要它(ILogService)作的行為。例如下面unit test的程式碼片段
[ExpectedException(typeof(RegisterUserAccountException))]
[TestMethod]
public void Register_ExistedUser_ThrowRegisterUserAccountException()
{
    RegisterRequest request = new RegisterRequest()
    {
        Username = "petechen",
        Password = "petechen"
    };

    IUserAccountRepository userAccountRepository = MockRepository.GenerateStub<IUserAccountRepository>();

    userAccountRepository.Stub(c => c.CreateUserAndAccount(request.Username, request.Password)).IgnoreArguments().Throw(new Exception());

    ILogService logService = MockRepository.GenerateStub<ILogService>();

    IUserAccountService userAccountService = new UserAccountService(userAccountRepository, ILogService logService);

    userAccountService.Register(request);
    logService.AssertWasCalled(c => c.Error("exception occurs");
}
實際要測試的類別為UserAccountService,但因為它相依了兩個介面IUserAccountRepository及ILogService,於是我們使用mock/stub來模擬這兩個介面的行為,這樣我們就可以專注在UserAccountService的功能(Register)是否如我們預期(丟出RegisterUserAccountException)般地執行。

如果沒有建立ILogService這個抽象層的話會有什麼問題嗎?問題會出現在你可能無法預期LogService這個實作類別或是所使用的logging framework會為你帶來的意外效果。舉個例子,當LogService丟出一個ArgumentNullException出來。但以上面的程式碼片段來看,我們要測試的是當執行IUserAccountService.Register時要丟出RegisterUserAccountException而且錯誤訊息還要被記錄到。另一個意外效果則是會發生在logging framework的設定檔沒有正確地被設定,這時也會出現無預期的錯誤,導致在執行unit test之前,必須先確認logging framework的設定檔是否正確。但在這種情況下所做的測試已經不是unit test而是integration test了,因為所做的測試和系統環境設定因素(外在因素)有關。

雖說如此,以NLog來說,即便我們直接相依於它,在沒有任何設定檔的情況下,即使呼叫它所提供的API(如Trace, Debug, Warn等)也不會丟出exception。的確,我也曾經想過是否不要隔著一層抽象層(ILogService),因為只要它不會丟出exception,看起來似乎也不會影響到我的unit test。但如果你的unit test如上面片段一樣,想要在丟出exception時也檢查是否有執行到log錯誤訊息的程式碼(logService.AssertWasCalled(c => c.Error("exception occurs");),這時你還是會需要透過mock去檢查。


Flexibility
相依於抽象層另一個帶來的好處就是讓程式碼更具彈性及可抽換性。為什麼呢?因為程式碼中不再相依於特定的logging framework。當你想把log4net換成NLog時,只需要修改LogService實作類別,或是建立另一個新的LogService實作類別,並在IoC container中將ILogService的實作改指向新對應的實作類別即可一次更新整個系統,因為所有程式碼都是呼叫ILogService,所以根本不需要修改原本的程式碼。
ObjectFactory.Initialize(x =>
{
    //x.For<ILogService>().Use<LogService>();
    x.For<ILogService>().Use<LogServiceForNLog>();
});
此外,修改之後也只需要佈署ILogService新的實作類別即可(也許就只有一支DLL檔),並不需要將整個系統重新佈署,大大地降低佈署的複雜度和出錯的可能性。當然你可能會質疑在開發中或系統已上線再更換logging framework的機率。的確,像log4net或NLog這種等級的logging framework已經是相對穩定許多,除非它們有很嚴重的bug或效能的問題,我想更換的機率是不高,但所謂的彈性就是要預防這種未知的變動。

上述提到的兩點不僅是針對logging framework,也可套用在其它的third-party元件。本篇文章不下任何定論。因為有的人不做unit test或是根本沒時間做,又或是直接就做integration test。有的人認為更換logging framework的事根本不會發生。如果我只做integration test又只使用固定一套logging framework,何必額外花心思和時間在這上頭。所以去除耦合度的必要性?It always depends!

參考

No comments: