背景
Linux下開發存儲系統、網絡庫的時候會用到一系列Linux的系統調用,每一個系統調用都有一些出錯的場景,有些場景很極端,比如記憶體使用達到上限、磁盤寫滿等,如果對其進行測試的話,很難去構造這樣的一個場景,這個時候內建測試就顯得力不存心了,隻能靠單元測試來覆寫這些場景。現在的問題就是如何去mock這些系統調用,然後通過程式傳回對應場景的錯誤碼來模拟各種場景。也就是将對系統函數的依賴注入到程式中。
系統函數的依賴注入
目前實作系統函數的依賴注入的手段有很多,分為編譯期注入,和運作期注入,至于什麼是依賴注入可以參考知乎的一篇文章
如何用最簡單的方式解釋依賴注入,下面介紹幾種依賴注入的方法:
- 虛函數實作依賴注入(運作期注入)
使用傳統的面向對象的手法,借助運作期的延遲綁定實作注入和替換,自己實作一個System接口類,把程式用到的系統調用都用虛函數封裝一層,然後在調用的時候不直接調用系統調用,而是調用的System對應的方法。這樣代碼的主動權就交給了System接口類了。寫單元測試的時候将這個System接口類替換成我們自己的mock對象就可以。完整的示例代碼如下:
// system.h class System { public: virtual int open(const char *path, int oflag, ...) = 0; virtual ssize_t read(int fildes, void *buf, size_t nbyte) = 0; virtual ssize_t write(int fildes, const void *buf, size_t nbyte) = 0; virtual int close(int fildes) = 0; static System* GetInstance(); static void set_instance(System* instance) { instance_ = instance; } private: static System* instance_; }; // 具體的實作 class FileOps : public System { public: int open(const char *path, int oflag, ...) override; ssize_t read(int fildes, void *buf, size_t nbyte) override; ssize_t write(int fildes, const void *buf, size_t nbyte) override; int close(int fildes) override; static FileOps* GetInstance(); }; // system.cc System* System::instance_ = nullptr; // 預設實作是FileOps,mock的時候通過改變這個預設實作進而把主動權從預設實作轉到了mock的實作 System* System::GetInstance() { if (!instance_) { instance_ = FileOps::GetInstance(); } assert(instance_); return instance_; } int FileOps::open(const char *path, int oflag, ...) { return ::open(path, oflag, 0777); } ssize_t FileOps::read(int fildes, void *buf, size_t nbyte) { return ::read(fildes, buf, nbyte); } ssize_t FileOps::write(int fildes, const void *buf, size_t nbyte) { return ::write(fildes, buf, nbyte); } int FileOps::close(int fildes) { return ::close(fildes); } FileOps* FileOps::GetInstance() { static FileOps sys; return &sys; } // 正常調用 main.cc int main() { assert(System::GetInstance() != nullptr); int fd = System::GetInstance()->open("txt", O_RDWR|O_CREAT, 0777); assert(fd > 0); int ret = System::GetInstance()->write(fd, "12345", 5); assert(ret > 0); ret = System::GetInstance()->close(fd); assert(ret == 0); return 0; } // 測試的時候調用如下,模拟一個IO錯誤 // 一個mock版本的實作 test.cc class MockFileOps : public System { public: int open(const char *path, int oflag, ...) override; ssize_t read(int fildes, void *buf, size_t nbyte) override; ssize_t write(int fildes, const void *buf, size_t nbyte) override; int close(int fildes) override; static MockFileOps* GetInstance(); }; int MockFileOps::open(const char *path, int oflag, ...) { return ::open(path, oflag, 0777); } ssize_t MockFileOps::read(int fildes, void *buf, size_t nbyte) { return ::read(fildes, buf, nbyte); } // 模拟的一個IO錯誤 ssize_t MockFileOps::write(int fildes, const void *buf, size_t nbyte) { errno = EIO; return -1; } int MockFileOps::close(int fildes) { return ::close(fildes); } MockFileOps* MockFileOps::GetInstance() { static MockFileOps sys; return &sys; } int main() { // 改變預設實作 System::set_instance(MockFileOps::GetInstance()); assert(System::GetInstance() != nullptr); int fd = System::GetInstance()->open("txt", O_RDWR|O_CREAT, 0777); assert(fd > 0); int ret = System::GetInstance()->write(fd, "12345", 5); assert(ret == -1); // 發生錯誤 perror("write"); ret = System::GetInstance()->close(fd); assert(ret == 0); return 0; }
- 編譯期延遲綁定(編譯期注入)
建立一個命名空間,建立一系列和系統調用同名的方法,間接的調用系統調用,寫測試代碼的時候重新定義這些方法,這就相當于一份代碼有了兩份實作,根據編譯的時候連結哪份代碼來決定是否啟用mock,這個看起來要比基于虛函數的要簡單的多了。完整的示例代碼如下:
// file_ops.h namespace FileOps { int open(const char *path, int oflag, ...); ssize_t read(int fildes, void *buf, size_t nbyte); ssize_t write(int fildes, const void *buf, size_t nbyte); int close(int fildes); } // namespace FileOps // file_ops.cc namespace FileOps { int open(const char *path, int oflag, ...) { return ::open(path, oflag, 0777); } ssize_t read(int fildes, void *buf, size_t nbyte) { return ::read(fildes, buf, nbyte); } ssize_t write(int fildes, const void *buf, size_t nbyte) { return ::write(fildes, buf, nbyte); } int close(int fildes) { return ::close(fildes); } } // namespace FileOps // mock_file_ops.cc namespace FileOps { int open(const char *path, int oflag, ...) { return ::open(path, oflag, 0777); } ssize_t read(int fildes, void *buf, size_t nbyte) { return ::read(fildes, buf, nbyte); } // 這裡做了mock,改變了write的行為 ssize_t write(int fildes, const void *buf, size_t nbyte) { errno = EIO; return -1; } int close(int fildes) { return ::close(fildes); } } // namespace FileOps // 測試程式 int main() { int fd = FileOps::open("txt", O_RDWR|O_CREAT, 0777); assert(fd > 0); int ret = FileOps::write(fd, "12345", 5); if (ret == -1) { perror("write:"); } ret = FileOps::close(fd); assert(ret == 0); return 0; }
兩種方法都比較好實作,前提是代碼在一開始的時候就考慮過這些因素,并按照上述方式來編寫,然後現實總是殘酷的,面對一個已經編碼完成的程式該如何為其編寫系統調用的mock呢?就需要用到連結期墊片(link seam)的方法。
連結期墊片(link seam)
連接配接器墊片的方式一般情況有三種,如下:
- Shadowing functions through linking order (override functions in libraries with new definitions in object files)
- Wrapping functions with GNU's linker option -wrap (GNU Linux only)
- Run-time function interception* with the preload functionality of the dynamic linker for shared libraries (GNU Linux and Mac OS X only)
第一種就是通過連結順序來改變連結的對象,将要mock的對象重新實作一遍,連結的時候連結器會優先使用我們自己實作的同名函數,這樣就可以将目标替換為要mock的對象了,完整代碼如下:
// 一個待測試的對象 int main() { int fd = ::open("txt", O_RDWR|O_CREAT, 0777); assert(fd > 0); int ret = ::write(fd, "12345", 5); if (ret == -1) { perror("write:"); } ret = ::close(fd); assert(ret == 0); return 0; } // 對目标進行mock,mock的對象是write系統調用 typedef ssize_t (*write_func_t)(int fildes, const void *buf, size_t nbyte); // 通過dlsym的RTLD_NEXT擷取write的下一個定義,也就是libc中的定義,如果想在mock中 // 調用真實的write系統調用不能直接用write,因為write已經被mock了,這樣會導緻一直遞歸下去 // 是以這裡通過擷取真實的write調用的位址,進而難道write的調用入口,這樣既可以在mock中調用 // 真實的write調用了 write_func_t old_write_func = reinterpret_cast<write_func_t>(dlsym(RTLD_NEXT, "write")); // 要mock的對象 extern "C" ssize_t write(int fildes, const void *buf, size_t nbyte) { errno = EIO; return -1; }
另外一種就是Linux下獨有的,通過gcc的--wrap選項可以指定要wrap的系統調用,那麼相應的就回去調用帶有
__wrap
字首的對應系統調用實作,比如--wrap=write,那麼在連結的時候就會連結到
__wrap_write
,而真實的write調用變成了
__real_write
。完整代碼例子如下:
// 測試程式 int main() { int fd = ::open("txt", O_RDWR|O_CREAT, 0777); assert(fd > 0); int ret = ::write(fd, "12345", 5); if (ret == -1) { perror("write:"); } ret = ::close(fd); assert(ret == 0); return 0; } // mock對象 extern "C" ssize_t __real_write(int fildes, const void *buf, size_t nbyte); extern "C" ssize_t __wrap_write(int fildes, const void *buf, size_t nbyte) { __real_write(fildes, buf, nbyte); errno = EIO; return -1; }
最後一種就是給系統調用提供一份
mock
實作,并編譯成動态庫,然後通過
LD_LIBRARY_PATH
改變加載動态庫的搜尋路徑讓其優先搜尋mock版本的動态庫,或者是設定
LD_PRELOAD
環境變量,預先加載mock的動态庫。
附錄
- 本文所有代碼見 github
- Advice on Mocking System Calls