FFLIB C++ 異步&類型安全&printf風格的日志庫


摘要

      C++程序的調試一般有調試器、printf、日志文件三種。Linux下的調試器為gdb,關於gdb的使用甚至可以單獨用一本書來說明,但是本章並不會過度討論gdb,讀者可以尋找相關的資料閱讀。Gdb是C++程序調試中非常重要的調試手段,其有如下特點:

  • l 通過增加斷點,可以觀察重點代碼的執行
  • l 若程序出現segmentation fault,gdb可以輸出調用堆棧,方便找到bug之所在
  • l 有些邏輯代碼段非常不容易觸發,可以在gdb環境下通過加斷點、修改內存來強制進入特定的代碼段
  • l 但是gdb不能用於生產環境,在幾百上千在線的服務器程序上執行gdb的attach操作,是不可能接受的

      Gdb絕對是調試期的利器,另外一個調試期使用的既簡單又實用的方法是printf,就是使用c庫的函數printf輸出變量到控制台。其優點是直觀,可以完整的、清晰的觀察程序的運行過程,而不需像gdb一樣暫停程序。另外printf也只能用於開發調試環境,上線時服務器程序都是在后台運行的,printf將會失去作用。更重要的是因為gdb和printf都不會將數據存儲,歷史數據或歷史操作都會在重啟程序后消失。日志文件可以彌補gdb和printf的不足,我們需要一個具有如下功能的日志組件:

  • l 用於調試可以顯示、記錄變量、數據,即能支持像printf一樣可以實時的在控制台輸出顯示,又能將記錄存儲文件,方便搜索查看歷史記錄
  • l 日志應該擁有良好的格式,即方便開發和運維人員的閱讀,又要包含足夠多的信息,例如事件記錄時間、線程id、事件類型,事件的嚴重級別
  • l 日志文件應該被良好的組織,一方面日志應該按照每天單獨文件夾分類,另一方面日志日志文件並應該過大,否則使用編輯器打開、搜索將會非常困難。日志內容也應該組織分類,比如數據庫的操作日志和用戶做任務的日志應該有明確的標志,這樣可以對日志文件進行grep等進行過濾分類查看。
  • l 日志文件必須非常容易配置,當調試時期望看到盡可能多的內容,但是不關心的內容需要被過濾掉,比如調試用戶任務模塊時,可以不顯示數據庫相關日志。在上線后,運維只關心報錯信息,比警告級別低的日志需要被屏蔽。在調試時,開發人員經常會盯着控制台的輸出,相比於普通級別日志內容,錯誤級別的日志更應該引起開發注意力,所以重要級別的日志在控制台輸出時應該有彩色高亮顯示。
  • l 日志組件必須有高效的性能,一方面調用者期望日志組件調用后立即返回不影響邏輯層的效率,另一方面寫文件屬於io操作,比起內存操作慢的多得多。所以要求日志的接口調用是異步的,日志組件單獨開啟線程執行寫文件操作,只有如此才能盡最大程度滿足程序的實時性。

下面來探討一下日志 的實現。

實現類

定義log_t類來封裝對於日志配置、格式化、輸出的操作。log_t主要的功能有:

  • l 支持對日志級別的配置
  • l 支持對日志類別的配置
  • l 支持配置日志內容是否輸出到文件和控制台
  • l 格式化日志
class log_t
{
public:
    log_t(int level_, const string& all_class_, const string& path_, const string& file_,
          bool print_file_, bool print_screen_);
    virtual ~log_t();

    void mod_level(int level_, bool flag_);
    void mod_class(const string& class_, bool flag_);
    void mod_print_file(bool flag_);
    void mod_print_screen(bool flag_);
    bool is_level_enabled(int level_);
    const char* find_class_name(const char* class_);

    void log_content(int level_, const char* str_class_, const string& content_);
};

接口log_content 負責格式化和輸出日志內容,其主要實現代碼如下:

void log_t::log_content(int level_, const char* str_class_, const string& content_)
{
    struct timeval curtm;
    gettimeofday(&curtm, NULL);
    struct tm tm_val = *localtime(&(curtm.tv_sec));

    char log_buff[512];
    ::snprintf(log_buff, sizeof(log_buff), "%02d:%02d:%02d.%03ld %s [%ld] [%s] ",
            tm_val.tm_hour, tm_val.tm_min, tm_val.tm_sec, curtm.tv_usec/1000,
            g_log_level_desp[level_], gettid(), str_class_);

    if (m_enable_file && check_and_create_dir(&tm_val))
    {
        m_file << log_buff << content_ << endl;
        m_file.flush();
    }

    if (m_enable_screen)
    {
        printf("%s%s%s%s\n", g_log_color_head[level_], log_buff, content_.c_str(), g_log_color_tail[level_]);
    }
}

其執行的主要過程如下:

  • l 格式化時間,包括調用時的時分秒以及毫秒,為什么沒有年月日呢?日志目錄已經安裝每天一個文件夾分類了,故這里省略了年月日信息。
  • l 增加日志級別信息,日志級別對應一個字符串描述,如debug級別日志每一行會包含DEBUG字符串。
  • l 記錄線程id,這里並沒有直接使用::pthread_self() 獲取線程id,而是獲取線程在系統中分配的“TID”,要知道線程和進程在內核中都有唯一的id,可以通過top進行查看,top -H –p [pid] 可以查看進程內的所有線程的運行負載情況,如果某個線程運行負載很高,我們需要知道到底是那一部分邏輯是熱點,通過搜索日志,可以知道該線程負責了哪塊邏輯,從而能夠發現問題。
  • l 記錄日志類別
  • l 若配置允許輸出屏幕,那么利用printf輸出,不同的日志級別會有不同的顯示顏色,如printf("\033[1;33mDEBUG\033[0m"), DEBUG 會以黃色輸出。
  • l 若配置允許輸出文件,那么flush到文件中,若日期發生變化,重新創建日期目錄,保證每一天一個文件夾,若單個文件內容超過5000行,會創建新的文件,避免文件內容過大,最終目錄機構如下

clip_image001

異步操作

為了保證日志接口盡可能的快,日志接口都是異步完成的其。時序圖如下:

clip_image003

對於用戶層而言,調用日志組件接口的開銷為日志內容格式化和拷貝字符串到隊列,而相對開銷較大的寫文件、輸出屏幕操作則有日志線程完成,這樣可以最大程度的保證用戶層的高效運行。

我們定義log_service_t封裝異步操作,對於格式化和輸出,log_service_t仍然通過log_t實現,log_service_t的職責有四:

  • l 封裝異步接口,外部直接調用log_service_t的接口,一般log_service_t一單件模式使用
  • l Log_service_t接口模板函數,利用C++的泛型能力確保類型安全,比如當%s參數本應該是user.name()時,卻手誤寫成user,log_service_t的接口保證在編譯器就能報錯。
  • l Log_service_t創建日志線程和日志任務隊列,
  • l Log_service_t在初始化的時候接受配置日志組件的參數,同時它也支持在運行期修改參數,並且線程安全。

關鍵代碼如下:

class log_service_t
{
public:
    log_service_t();
    ~log_service_t();
    int start(const string& opt_);
    int stop();

LOG_IMPL_MACRO(async_logdebug, LOG_DEBUG);
    LOG_IMPL_MACRO(async_logtrace, LOG_TRACE);
    LOG_IMPL_MACRO(async_loginfo, LOG_INFO);
    LOG_IMPL_MACRO(async_logwarn, LOG_WARN);
    LOG_IMPL_MACRO(async_logerror, LOG_ERROR);
    LOG_IMPL_MACRO(async_logfatal, LOG_FATAL);
};

由於各個日志級別的接口代碼都是相似的,使用了LOG_IMPL_MACRO簡化代碼,LOG_IMPL_MACRO定義為:

 

#define LOG_IMPL_NONE_ARG(func, LOG_LEVEL)      \
    inline void func(const char* class_, const char* fmt_)  \
    {     \
        if (m_log->is_level_enabled(LOG_LEVEL))  \
        {     \
            const char* class_name_str = m_log->find_class_name(class_); \
            if (class_name_str)     \
            { \
                m_task_queue.produce(task_binder_t::gen(&log_t::log_content, m_log, LOG_LEVEL, \
                class_name_str, string(fmt_)));     \
            }  \
        }      \
    }

#define LOG_IMPL_ARG1(func, LOG_LEVEL)      \
    template <typename ARG1>    \
    inline void func(const char* class_, const char* fmt_, const ARG1& arg1_) \
    { \
        if (m_log->is_level_enabled(LOG_LEVEL)) \
        {     \
            const char* class_name_str = m_log->find_class_name(class_); \
            if (class_name_str)     \
            {  \
                str_format_t dest(fmt_);  \
                dest.append(arg1_);     \
                m_task_queue.produce(task_binder_t::gen(&log_t::log_content, m_log, LOG_LEVEL, \
                 class_name_str, dest.gen_result()));     \
            } \
        } \
    }

#define LOG_IMPL_MACRO(async_logdebug, LOG_DEBUG)     \
    LOG_IMPL_NONE_ARG(async_logdebug, LOG_DEBUG)      \
    LOG_IMPL_ARG1(async_logdebug, LOG_DEBUG)          \
    LOG_IMPL_ARG2(async_logdebug, LOG_DEBUG)          \
    LOG_IMPL_ARG3(async_logdebug, LOG_DEBUG)        \
    LOG_IMPL_ARG4(async_logdebug, LOG_DEBUG)        \
    LOG_IMPL_ARG5(async_logdebug, LOG_DEBUG)        \
    LOG_IMPL_ARG6(async_logdebug, LOG_DEBUG)

受篇幅所限,沒有附上所有宏展開的代碼,log_service_t初始化的代碼如下:

int log_service_t::start(const string& opt_)
{
    if (m_log) return 0;

    int level = 2;
    string path = "./log";
    string filename = "log";
    bool print_file = true;
    bool print_screen = false;

    arg_helper_t arg(opt_);
    if (!arg.get_option_value("-log_level").empty()) level = ::atoi(arg.get_option_value("-log_level").c_str());
    if (!arg.get_option_value("-log_path").empty()) path = arg.get_option_value("-log_level");
    if (!arg.get_option_value("-log_filename").empty()) path = arg.get_option_value("-log_filename");

    if (arg.get_option_value("-log_print_file") == "false" || arg.get_option_value("-log_print_file") == "0")
    {
        print_file = false;
    }
    if (arg.get_option_value("-log_print_screen") == "true" || arg.get_option_value("-log_print_screen") == "1")
    {
        print_screen = true;
    }

    m_log = new log_t(level, arg.get_option_value("-log_class"), path, filename, print_file, print_screen);
    m_thread.create_thread(task_binder_t::gen(&task_queue_t::run, &m_task_queue), 1);

    return 0;
}

代碼很簡單,也很直觀,需要對讀者解釋的是,初始化接口start的參數為字符串,這樣做的好處是可以從配置文件中讀入日志配置參數后直接傳給log_service_t的start接口,而用戶層完全不需要關心日志配置語法的細節。Start函數創建log_t實例后,創建單獨線程執行任務隊列,而任務隊列中的所有任務就是寫日志內容或輸出日志內容。

格式化

關於格式化輸出,使用使用了模板函數和多態機制保證了絕對的類型安全,這也是相對於sprintf的巨大優越點。class str_format_t 是用來格式化字符串的工具類,它使用sprintf的格式化語法,但是額外提供了排錯和糾錯功能:

  • l 使用sprintf格式化語法,最基本的格式化參數都支持如%d,%u,%ld,%lu,%s,%c,%x,%p,%f甚至形如%04d設置字符串寬度的語法也是支持的
  • l str_format_t 類型安全,格式化參數支持整型、浮點數、字符串cost char*、指針、string,若賦值其他類型參數,則編譯不能通過。
  • l str_format_t 擁有自動糾錯功能,使用sprintf的時候除了類型不安全導致出錯外,最常見的就是sprintf的格式化參數與賦值的參數個數不一致,如sprintf(buff, “%s,%s”, 100);這樣的代碼編譯能夠通過只有運行期才能發現出錯,str_format_t 可以容忍這樣的失誤,當模板字符串中%比賦值的參數多時,str_format_t自動忽略多余的%,若%比后邊的值參數少時,值參數自動追加到字符串尾部,這樣最大程度的避免了出錯和信息丟失。

關於基本類型的格式化模板函數:

template<typename T>
    void append(T content_)
    {
        if (move_to_next_wildcard())
        {
            if (m_fmt_type.type == 'x')
            {
                char buff[64];
                snprintf(buff, sizeof(buff), "0x%x", (unsigned int)content_);
                m_num_buff = buff;
            }
            else
            {
                m_strstream << content_;
                m_strstream >> m_num_buff;
            }
            int width = m_fmt_type.min_len > m_num_buff.length()? m_fmt_type.min_len - m_num_buff.length(): 0;
            for (; width > 0; -- width)
            {
                m_result += m_fmt_type.fill_char;
            }
        }
        else
        {
            m_strstream << content_;
            m_strstream >> m_num_buff;
        }

        m_result += m_num_buff;
        m_strstream.clear();//! clear error bit,not content
        m_num_buff.clear();
    }

關於字符串的特化函數:

void str_format_t::append(const string& str_)
{
    if (move_to_next_wildcard())
    {
        int width = m_fmt_type.min_len > str_.length()? m_fmt_type.min_len -str_.length(): 0;
        for (; width > 0; -- width)
        {
            m_result += m_fmt_type.fill_char;
        }
    }

    m_result += str_;
}

move_to_next_wildcard 每次嘗試移動到下一個%所在的位置,然后用值參數替換%的格式化。move_to_next_wildcard的整個開銷是遍歷字符串的開銷:

bool str_format_t::move_to_next_wildcard()
{
    m_fmt_type.clear();
    char tmp = '\0';

    for (; cur_format_index < m_fmt_len; ++ cur_format_index)
    {
        tmp = m_fmt[cur_format_index];
        if (tmp != '%')
        {
            m_result += tmp;
            continue;
        }

        char next = m_fmt[cur_format_index + 1];
        if (next == '%')
        {
            cur_format_index += 1;
            m_result += next;
            continue;
        }

        //! 支持多種格式化 %c %s, %d, %ld, %u, %lu, %x, %X, 找到格式化的類型
        //for (++cur_format_index; cur_format_index < m_fmt_len; ++ cur_format_index)
        for (unsigned int i = 1 ; i <= 5; ++i)
        {
            char cur = m_fmt[cur_format_index + i];

            if (cur == '\0' || cur == '%')
            {
                break;
            }
            else if (cur == 'c' || cur == 'd' || cur == 'u' || cur == 'x' ||
                     cur == 'f' || cur == 's')
            {
                m_fmt_type.type    = cur;
                m_fmt_type.min_len = ::atoi(m_fmt + cur_format_index + 1);
                cur_format_index   = cur_format_index + i + 1;
                if (next == '0')
                {
                    m_fmt_type.fill_char = next;
                }
                return true;
            }
            else if (cur == 'l')
            {
                char c_num = m_fmt[cur_format_index + i + 1];
                if ('d' == c_num || 'u' == c_num)
                {
                    m_fmt_type.type    = c_num;
                    m_fmt_type.min_len = ::atoi(m_fmt + cur_format_index + 1);
                    cur_format_index   = cur_format_index + i + 2;
                    if (next == '0')
                    {
                        m_fmt_type.fill_char = next;
                    }
                    return true;
                }
            }
        }
        m_result += tmp;

    }
    return false;
}

配置

最基本的log_service_t的start接口提供了初始化日志組件時的配置,配置參數:

enum log_level_e
{
    LOG_FATAL = 0,
    LOG_ERROR,
    LOG_WARN,
    LOG_INFO,
    LOG_TRACE,
    LOG_DEBUG,
    LOG_LEVEL_NUM
};
  • l -log_level 配置日志級別,0-5代表不同的日志級別,枚舉定義如下:
  • l -log_path 配置日志文件存儲的根目錄
  • l -log_filename 配置文件名稱
  • l -log_print_file" 配置日志是否輸出到文件
  • l log_print_screen 配置日志是否輸出到屏幕
  • l - log_class 配置哪些日志類別是開啟的,只有 開啟的類別日志才會被記錄

這其中除了log_path和log_filename不需要運行期配置外,其他的配置都有運行期修改的需求,比如運行期某個類別的日志被關閉了,但是為了跟蹤某問題必須開啟,如果不能動態修改日志配置往往會是開發人員面對問題時束手無策。對於-log_print_file 和-log_print_screen 都是用bool記錄的,-log_level 是整型記錄的,都是直接支持運行期修改的。有的讀者可能指出日志不是有單獨線程嗎,而且使用線程組件的用戶層也可能是多線程的,不就設計到了多線程競爭了嗎?在明白此答案之前,先讓我們縷一縷log中的結構:

  • l 日志的接口會被多線程調用
  • l 異步日志接口會訪問日志的配置,判斷該日志類別或級別是否已開啟,由於只有讀取操作,不需要加鎖。
  • l 日志格式化后投遞到隊列,隊列是線程安全的,只有日志線程會從任務隊列中消費任務。
  • l 運行修改日志配置的操作會投遞到日志線程完成,保證任一時刻只有一個線程修改日志配置

對於-log_print_file 、-log_print_screen 和-log_level 都是多線程讀取訪問,某一時刻一個線程修改,並且三者都是基本類型的,不存在內存地址變化的問題,這樣日志線程修改后會立即生效。但是對於-log_class,被開啟的日志類別都被保存到set<string> 的結構中,多線程對其執行find操作是安全的,這個stl的多線程特性是明確支持的。但是若對set<string> 在運行期執行insert或erase后會使set<string>中的迭代器失效,被壞的情況是會引起讀操作的線程崩潰,所以在運行期絕對不能對老的日志類別容器進行修改。難道日志類別就沒辦法運行期修改了嗎?腦筋急轉彎一下,既然不能修改老的,為什么不創建一個新的,然后用新的替換老的?為了使用這個方法,需要一些小技巧:

l 使用一個指針引用當前日志級別的容器,度線程總是獲取該指針,然后執行find操作find_class_name 根據類別字符串去容器中查找是否存在。這里使用了原子操作ATOMIC_FETCH,在gcc的環境下可以把它定義為:

#define ATOMIC_FETCH(src_ptr)             __sync_add_and_fetch(src_ptr, 0)
typedef set<string>            str_set_t;
    typedef vector<str_set_t*>    ptr_vt_t;
    str_set_t*                    m_enable_class_set;

const char* log_t::find_class_name(const char* class_)
{
    str_set_t* pset = ATOMIC_FETCH(&m_enable_class_set);
    str_set_t::iterator it = pset->find(class_);
    if (it != pset->end())
    {
        return (*it).c_str();
    }
    return NULL;
}

l find_class_name對於存儲日志類別的容器指針使用了原子操作,所以要保證指針對應的數據永遠不被修改,即使有新的配置產生時,老的配置仍然不會被銷毀,利用數據冗余保證無鎖操作的線程安全,最后將新配置容器的指針替換老配置的指針即可,同樣使用原子操作完成

#define ATOMIC_SET(src_ptr, v)            (void)__sync_bool_compare_and_swap(src_ptr, *(src_ptr), v)


void log_t::mod_class(const string& class_, bool flag_)
{
    str_set_t* pset = new str_set_t(m_enable_class_set->begin(), m_enable_class_set->end());
    if (flag_)
    {
        pset->insert(class_);
    }
    else
    {
        pset->erase(class_);
    }

    m_class_set_history.push_back(pset);
    ATOMIC_SET(&m_enable_class_set, pset);
}

總結以上無鎖編程的技巧有:

l 對於stl的對象的多線程讀操作是安全的

l 對於指針可以使用原子操作進行讀取、更新、比較等操作

l 老的容器被保存而不是銷毀,從而保證了獲取了老數據的線程仍然能夠工作正常,日志的類別字符串只有十幾頂多幾十個,這里做數據冗余的內存開銷是可以忽略的。

使用

日志的異步接口是由log_service_t定義的,前邊介紹log_service_t的時候也提到了log_service_t一般是被單件使用的,如果每次調用singleton_t<log_service_t>::instance().async_logdebug(“XX”, “OhNice”);代碼太長了,使用宏封裝單件的操作:

#define LOG singleton_t<log_service_t>::instance()
#define LOGDEBUG(content)  singleton_t<log_service_t>::instance().async_logdebug content
#define LOGTRACE(content)  singleton_t<log_service_t>::instance().async_logtrace content
#define LOGINFO(content)   singleton_t<log_service_t>::instance().async_loginfo  content
#define LOGWARN(content)   singleton_t<log_service_t>::instance().async_logwarn  content
#define LOGERROR(content)  singleton_t<log_service_t>::instance().async_logerror content
#define LOGFATAL(content)  singleton_t<log_service_t>::instance().async_logfatal content

使用宏的好處是,比如logtrace可能會被到處使用了,而有可能其只在調試器有用,那么在release版本時候可以把宏LOGTRACE定義成空操作,當然若日志級別不開啟的情況下,調用LOGTRACE的開銷是很小的,一般情況可以忽略其影響。

使用示例:

int main(int argc, char* argv[])
{

    LOG.start("-log_path ./log -log_filename log -log_class FF,XX -log_print_screen true -log_print_file true -log_level 6");
    LOGDEBUG(("XX", "FFFFF"));
    LOGTRACE(("XX", "FFFFF"));
    LOGINFO(("XX", "FFFFF"));
    LOGWARN(("XX", "FFFFF"));
    LOGERROR(("XX", "FFFFF"));
    LOGFATAL(("XX", "FFFFF"));
    LOG.mod_class("TT", true);

    sleep(1);
    LOGFATAL(("TT", "FFFFF"));

    LOGFATAL(("FF", "DSDFFFFF%s", string("SFWEGGGGGGGGG")));

    LOG.stop();
    return 0;
}

總結:

  • l 日志組件需要盡可能的快從而對於調用者的影響降到最低,使用異步接口可以使日志接口調用后立即返回
  • l 日志的文件組織需要較好的被分類,目錄首先按照時間分類,每天生成一個目錄存儲當天的日志,並且日志文件對大小做了上限,超過限制會重新創建新的文件,保證單個日志文件不會過大
  • l 日志組件被設計成printf的格式化風格,但是增加了類型安全和參數糾錯,不支持的類型會在編譯期發現,值參數數目過多會被追加到字符串尾部,過少則忽略相應的%。
  • l 日志組件是線程安全的,對於日志的配置雖然是多線程無鎖訪問的,仍然支持運行期動態的修改配置,其中使用了原子操作既保證了無鎖編程的高效,又滿足了多線程的穩定。
  • 源碼:https://github.com/fanchy/fflib


免責聲明!

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



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