C++11實現一個輕量級的AOP框架


AOP介紹

  AOP(Aspect-Oriented Programming,面向方面編程),可以解決面向對象編程中的一些問題,是OOP的一種有益補充。面向對象編程中的繼承是一種從上而下的關系,不適合定義從左到右的橫向關系,如果繼承體系中的很多無關聯的對象都有一些公共行為,這些公共行為可能分散在不同的組件、不同的對象之中,通過繼承方式提取這些公共行為就不太合適了。使用AOP還有一種情況是為了提高程序的可維護性,AOP將程序的非核心邏輯都“橫切”出來,將非核心邏輯和核心邏輯分離,使我們能集中精力在核心邏輯上,例如圖1所示的這種情況。

圖1 AOP通過“橫切”分離關注點

  在圖1中,每個業務流程都有日志和權限驗證的功能,還有可能增加新的功能,實際上我們只關心核心邏輯,其他的一些附加邏輯,如日志和權限,我們不需要關注,這時,就可以將日志和權限等非核心邏輯“橫切”出來,使核心邏輯盡可能保持簡潔和清晰,方便維護。這樣“橫切”的另外一個好處是,這些公共的非核心邏輯被提取到多個切面中了,使它們可以被其他組件或對象復用,消除了重復代碼。

  AOP把軟件系統分為兩個部分:核心關注點和橫切關注點。業務處理的主要流程是核心關注點,與之關系不大的部分是橫切關注點。橫切關注點的一個特點是,它們經常發生在核心關注點的多處,而各處都基本相似,比如權限認證、日志、事務處理。AOP 的作用在於分離系統中的各種關注點,將核心關注點和橫切關注點分離開來。

實現AOP的一些方法

  實現AOP的技術分為:靜態織入和動態織入。靜態織入一般采用抓們的語法創建“方面”,從而使編譯器可以在編譯期間織入有關“方面”的代碼,AspectC++就是采用的這種方式。這種方式還需要專門的編譯工具和語法,使用起來比較復雜。我將要介紹的AOP框架正是基於動態織入的輕量級AOP框架。動態織入一般采用動態代理的方式,在運行期對方法進行攔截,將切面動態織入到方法中,可以通過代理模式來實現。下面看看一個簡單的例子,使用代理模式實現方法的攔截,如代碼清單1所示。

代碼清單1代理模式攔截方法的實現

#include<memory>
#include<string>
#include<iostream>
using namespace std;
class IHello
{
public:

    IHello()
    {
    }

    virtual ~IHello()
    {
    }

    virtualvoid Output(const string& str)
    {
        
    }
};

class Hello : public IHello
{
public:
    void Output(const string& str) override
    {
        cout <<str<< endl;
    }
};

class HelloProxy : public IHello
{
public:
    HelloProxy(IHello* p) : m_ptr(p)
    {

    }

    ~HelloProxy()
    {
        delete m_ptr;
        m_ptr = nullptr;
    }

    void Output(const string& str) final
    {
        cout <<"Before real Output"<< endl;
        m_ptr->Output(str);
        cout <<"After real Output"<< endl;
    }

private:
    IHello* m_ptr;
};


void TestProxy()
{
    std::shared_ptr<IHello> hello = std::make_shared<HelloProxy>(newHello());
    hello->Output("It is a test");
}

測試代碼將輸出:

Before real Output
It is a test
Before real Output

  可以看到我們通過HelloProxy代理對象實現了對Output方法的攔截,這里Hello::Output就是核心邏輯,HelloProxy實際上就是一個切面,我們可以把一些非核心邏輯放到里面,比如在核心邏輯之前的一些校驗,在核心邏輯執行之后的一些日志等。

雖然通過代理模式可以實現AOP,但是這種實現還存在一些不足之處:

1.不夠靈活,不能自由組合多個切面。代理對象是一個切面,這個切面依賴真實的對象,如果有多個切面,要靈活地組合多個切面就變得很困難。這一點可以通過裝飾模式來改進,雖然可以解決問題但還是顯得“笨重”。

2.耦合性較強,每個切面必須從基類繼承,並實現基類的接口。

我們希望能有一個耦合性低,又能靈活組合各種切面的動態織入的AOP框架。

  1. 需要的技術

  要實現靈活組合各種切面,一個比較好的方法是將切面作為模板的參數,這個參數是可變的,支持1到N(N>0)切面,先執行核心邏輯之前的切面邏輯,執行完之后再執行核心邏輯,然后再執行核心邏輯之后的切面邏輯。這里,我們可以通過可變參數模板來支持切面的組合。AOP實現的關鍵是動態織入,實現技術就是攔截目標方法,只要攔截了目標方法,我們就可以在目標方法執行前后做一些非核心邏輯,通過繼承方式來實現攔截,需要派生基類並實現基類接口,這使程序的耦合性增加了。為了降低耦合性,這里通過模板來做解耦,即每個切面對象需要提供Before(Args…)或After(Args…)方法,用來處理核心邏輯執行前后的非核心邏輯。

  支持任意類型和數量的切面組合,我們通過可變模版參數實現即可,關於可變模板參數的用法和使用技巧,讀者可以參考我在《程序員》2015年2月刊上的文章《泛化之美--C++11可變模版參數的妙用》。

  為了實現切面的充分解耦合,我們的切面不必通過繼承方式實現,而且也不必要求切面必須具備Before和After方法,只要具備任意一個方法即可,給使用者提供最大的便利性和靈活性。實現這個功能稍微有點復雜,復雜的地方在於切面可能具有某個方法也可能不具有某個方法,具有就調用,不具有也不會出錯。問題的本質上是需要檢查類型是否具有某個方法,在C++中是無法在運行期做到這個事情的,因為C++像不托管語言c#或java那樣具備反射功能,然而,我們可以在編譯期檢查類型是否具有某個方法。下面來看看是如何實現在編譯期檢查某個類型的方法是否存在的。我們先定義一個檢查方法是否存在的元函數(關於元函的概念數讀者可以參考我在《程序員》2015年3月刊上的文章《C++11模版元編程》)。

template<typename T>
struct has_member_foo
{
private:
    template<typename U> static auto Check(int) -> decltype(std::declval<U>().foo(), std::true_type());

    template<typename U> static std::false_type Check(...);
public:
    enum{ value = std::is_same<decltype(Check<T>(0)), std::true_type>::value }; 
}; 

  這個has_member_foo的作用就是檢查類型是否存在非靜態成員foo函數,具體的實現思路是這樣的:定義一個兩個重載函數Check,利用C++的SIFNA(Substitution failure is not an erro--替換失敗不算錯)特性,由於模板實例化的過程中會優先選擇匹配程度最高的重載函數,在模板實例化的過程中檢查類型是否存在foo函數,如果存在則返回std::true_type,否則返回std::false_type,最后檢查Check函數的返回類型即可知道類型是否存在foo函數。需要注意的是這里用到了C++11中auto+decltype實現的返回類型后置特性,用這個特性的主要目的是我們無法直接得到Check的返回類型,我們需要借助模板參數T才能得到Check的返回類型(關於返回類型后置這個特性的詳細用法和應用場景介紹,讀者可以參考《深入應用C++11:代碼優化與工程級應用》一書第一章的內容)。我們來看一下實現檢查很關鍵的一行代碼:

template<typename U> static auto Check(int) -> decltype(std::declval<U>().foo(), std::true_type());

  我們用到了std::declval<U>().foo(),std::declval不會受到類型U是否存在共有構造函數的影響,也不會實例化類型,它僅僅是為了配合decltype來獲取函數foo的返回類型,如果獲取成功則表明存在foo函數,否則就會替換失敗,而選擇默認的Check函數。decltype在這里還有另外一個妙用,它可以通過逗號表達式連續推斷多個函數的返回類型,假如我們不僅僅是要推斷類型是否存在foo函數,我們還希望推斷類型是否存在foo1函數,這時我們僅僅需要在加一個逗號表達式就行了:

template<typename U> static auto Check(int) -> decltype(std::declval<U>().foo(),std::declval<U>().foo1(), std::true_type());

  這樣我們就可以同時判斷某個類型是否具有foo和foo1函數了,decltype在這里體現了良好的擴展性。

  雖然我們通過has_member_foo可以檢查出來類型是否存在foo函數,但是還存在一個問題,即重載函數的問題,假設我們有多個重載的foo函數,這個has_member_foo就不一定能檢查出來,因為decltype(std::declval<U>().foo())僅僅檢查的是不含參數的foo函數,因此這個has_member_foo還不夠完美,我們還需要改進它,讓它能對所有的重載函數有效。借助可變模板參數就可以輕松解決這個問題了:

template<typename T, typename... Args>
struct has_member_foo
{
private:
    template<typename U> static auto Check(int) -> decltype(std::declval<U>().foo(std::declval<Args>()...), std::true_type());

    template<typename U> static std::false_type Check(...);
public:
    enum{ value = std::is_same<decltype(Check<T>(0)), std::true_type>::value };
};

改進之后的has_member_foo就可以對所有版本的重載函數進行檢查了,具體用法是這樣的:

cout << has_member_foo<MyStruct>::value << endl; //判斷是否存在無參的foo函數
cout << has_member_foo<MyStruct, int>::value << endl;//判斷是否存在含int參數的foo函數

再借助std::enable_if在編譯期根據has_member_foo::value可以做一個分支選擇就可以解決前面提出的問題:如果類型具有某個方法就調用,不具有也不會出錯。這個問題解決之后我們就可以實現一個AOP框架了

  1. 完整的實現

下面AOP完整的實現,我們對has_member_foo通過宏做了一個擴展,讓它方便對所有的非成員函數進行檢查,然后再借助std::enable_if和變參實現對方法的攔截。

代碼清單2 AOP的實現

#define HAS_MEMBER(member)\
template<typename T, typename... Args>struct has_member_##member\
{\
private:\
        template<typename U> static auto Check(int) -> decltype(std::declval<U>().member(std::declval<Args>()...), std::true_type()); \
    template<typename U> static std::false_type Check(...);\
public:\
    enum{value = std::is_same<decltype(Check<T>(0)), std::true_type>::value};\
};\

HAS_MEMBER(Foo)
HAS_MEMBER(Before)
HAS_MEMBER(After)

#include <NonCopyable.hpp>
template<typename Func, typename... Args>
struct Aspect : NonCopyable
{
    Aspect(Func&& f) : m_func(std::forward<Func>(f))
    {
    }

    template<typename T>
    typename std::enable_if<has_member_Before<T, Args...>::value&&has_member_After<T, Args...>::value>::type Invoke(Args&&... args, T&& aspect)
    {
        aspect.Before(std::forward<Args>(args)...);//核心邏輯之前的切面邏輯
        m_func(std::forward<Args>(args)...);//核心邏輯
        aspect.After(std::forward<Args>(args)...);//核心邏輯之后的切面邏輯
    }

    template<typename T>
    typename std::enable_if<has_member_Before<T, Args...>::value&&!has_member_After<T, Args...>::value>::type Invoke(Args&&... args, T&& aspect)
    {
        aspect.Before(std::forward<Args>(args)...);//核心邏輯之前的切面邏輯
        m_func(std::forward<Args>(args)...);//核心邏輯
    }

    template<typename T>
    typename std::enable_if<!has_member_Before<T, Args...>::value&&has_member_After<T, Args...>::value>::type Invoke(Args&&... args, T&& aspect)
    {
        m_func(std::forward<Args>(args)...);//核心邏輯
        aspect.After(std::forward<Args>(args)...);//核心邏輯之后的切面邏輯
    }

    template<typename Head, typename... Tail>
    void Invoke(Args&&... args, Head&&headAspect, Tail&&... tailAspect)
    {
        headAspect.Before(std::forward<Args>(args)...);
        Invoke(std::forward<Args>(args)..., std::forward<Tail>(tailAspect)...);
        headAspect.After(std::forward<Args>(args)...);
    }

private:
    Func m_func; //被織入的函數
};
template<typenameT> using identity_t = T;

//AOP的輔助函數,簡化調用
template<typename... AP, typename... Args, typename Func>
void Invoke(Func&&f, Args&&... args)
{
    Aspect<Func, Args...> asp(std::forward<Func>(f));
    asp.Invoke(std::forward<Args>(args)..., identity_t<AP>()...);
}

  在上面的代碼中,“template<typename T> using identity_t = T;”是為了讓vs2013能正確識別出模板參數,因為各個編譯器對變參的實現是有差異的。在GCC下,“msp.Invoke(std::forward<Args>(args)..., AP()...);”是可以編譯通過的,但是在vs2013下就不能編譯通過,通過identity_t就能讓vs2013正確識別出模板參數類型。這里將Aspect從NonCopyable派生,使Aspect不可復制。關於NonCopyable的實現請讀者參考8.2節的內容。上面的代碼用到完美轉發和可變參數模板,關於它們的用法,讀者可以參考第2章和第3章內容。

  實現思路很簡單,將需要動態織入的函數保存起來,然后根據參數化的切面來執行Before(Args…)處理核心邏輯之前的一些非核心邏輯,在核心邏輯執行完之后,再執行After(Args…)來處理核心邏輯之后的一些非核心邏輯。上面的代碼中的has_member_Before和has_member_After這兩個traits是為了讓使用者用起來更靈活,使用者可以自由的選擇Before和After,可以僅僅有Before或After,也可以二者都有。

  需要注意的是切面中的約束,因為通過模板參數化切面,要求切面必須有Before或After函數,這兩個函數的入參必須和核心邏輯的函數入參保持一致,如果切面函數和核心邏輯函數入參不一致,則會報編譯錯誤。從另外一個角度來說,也可以通過這個約束在編譯期就檢查到某個切面是否正確。

  下面看一個簡單的測試AOP的例子,這個例子中我們將記錄目標函數的執行時間並輸出日志,其中計時和日志都放到切面中。在執行函數之前輸出日志,在執行完成之后也輸出日志,並對執行的函數進行計時,如代碼清單3所示。

代碼清單3帶日志和計時切面的AOP

struct TimeElapsedAspect
{
    void Before(int i)
    {
        m_lastTime = m_t.elapsed();
    }

    void After(int i)
    {
        cout <<"time elapsed: "<< m_t.elapsed() - m_lastTime << endl;
    }

private:
    double m_lastTime;
    Timer m_t;
};

struct LoggingAspect
{
    void Before(int i)
    {
        std::cout <<"entering"<< std::endl;
    }

    void After(int i)
    {
        std::cout <<"leaving"<< std::endl;
    }
};

void foo(int a)
{
    cout <<"real HT function: "<<a<< endl;
}

int main()
{
    Invoke<LoggingAspect, TimeElapsedAspect>(&foo, 1); //織入方法
cout <<"-----------------------"<< endl;
    Invoke<TimeElapsedAspect, LoggingAspect>(&foo, 1);

    return 0;
}

測試結果如圖3所示。

圖3 AOP切面組合的測試結果

  從測試結果中看到,我們可以任意組合切面,非常靈活,也不要求切面必須從某個基類派生,只要求切面具有Before或After函數即可(這兩個函數的入參要和攔截的目標函數的入參相同)。

總結

  輕量級的AOP可以方便的實現對核心函數的織入,還支持切面的組合,但也存在不足之處,比如不能像Java的AOP框架一樣支持通過配置文件去配置切面,仍然需要手動對核心函數配置切面,如果需要通過配置文件去配置切面可以考慮使用AspectC++。

  備注:本文節選自《深入應用C++11:代碼優化與工程級應用》,這本書是為了和廣大讀者分享學習和應用C++11的經驗和樂趣,告訴讀者C++11的新特性是如何綜合運用於實際開發中的,具有實踐的指導作用。特此感謝機械工業出版社授權。

本文是我發表於《程序員》7月刊,轉載請注明出處。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM