前言
上文中介绍了怎么在自己的项目中设计自己的日志管理系统。
没有看过的读者可以去看下原文:
》》》 C++高手进阶:如何设计自己的日志管理系统
但是实际上真实的业务开发中,开发者通常不期望去让其它依赖本模块的人知道自己是怎样设计的,特别是自己依赖了哪些第三方库。
那么,这就涉及到一个第三库隐藏的问题。即怎么隐藏自己的设计思路,只让被依赖方看到设计者想让他看到的,而隐藏掉不想让他看到的。
本文就此进行展开讲解下。
日志管理系统的封装
本文还是以前文提到的日志管理系统为例,来看下如何将第三方依赖库进行隐藏。
当我们没有对第三方库进行封装时,我们通常会在头文件中出现这些三方库的痕迹。如:
- 包含了第三方头文件
- 对第三方库中的类进行了前置声明
- 在成员变量中包含了第三方库中的某些指针。
这些种种,如果是头文件自己用还好,如果将这种头文件暴露给其他模块或外部模块用,就太没代码品味了,同时有可能会为公司招来一些版权许可的麻烦。
最好的就是做到外界不感知。
问题把脉
我们回到上一篇文章中,看看它有哪些问题。
这里我摘出代码片段看下:
class BASIC_TOOL_EXPORT Log {
public:
enum class Level {
INFO = 0,
WARN,
ERROR
};
static void init(Level level);
static void uninit();
// 获取日志存储路径及日志记录时间
static void getLogInfos(std::string& logPath, std::string& timeStr);
static void info(const char* format, ...);
static void warn(const char* format, ...);
static void error(const char* format, ...);
static void info(const wchar_t* format, ...);
static void warn(const wchar_t* format, ...);
static void error(const wchar_t* format, ...);
static void flush();
public:
log();
~log();
public:
spdlog::logger* m_pLogger = nullptr;
std::string m_logPath;
};
在这个设计中,我们可以清楚的看到,它在成员basis中包含了spdlog
的logger
指针。
对这种情况我们如何进行隐藏呢?
改造方案
通常比较好的做法是对 Log 类拆出接口类和实现类,将 spdlog::logger* m_pLogger = nullptr;
放到实现类中,这样就可以隐藏实现细节。
class BASIC_TOOL_EXPORT Log {
public:
enum class Level {
INFO = 0,
WARN,
ERROR
};
static void init(Level level);
static void uninit();
// 获取日志存储路径及日志记录时间
static void getLogInfos(std::string& logPath, std::string& timeStr);
static void info(const char* format, ...);
static void warn(const char* format, ...);
static void error(const char* format, ...);
static void info(const wchar_t* format, ...);
static void warn(const wchar_t* format, ...);
static void error(const wchar_t* format, ...);
static void flush();
public:
log();
~log();
};
class LogImpl final
{
using Type = Log::Level;
public:
LogImpl();
~LogImpl();
void init(Type type);
void uninit();
void output(const char* fmt, const va_list args, Type type);
void output(const wchar_t* fmt, const va_list args, Type type);
void output(const char* fmt, Type type);
void flush();
spdlog::logger* m_pLogger = nullptr;
std::string m_logPath;
std::string m_logTimeStr;
};
那么,通过这样的设计,就可以将第三方库的实现细节隐藏起来,只暴露接口。
那么,能否更进一步,将 class Log
中的构造函数和析构函数也隐藏起来,只保留需要暴露给用户使用的接口?
我们先回想下,我们最开始设计的时候准备怎么给用户使用?
我们设计的是,期望用户只包含头文件,就能够直接使用类似于 static void info(const char* format, ...);
这样的接口。
那么,我们如何才能做到这一点呢?
将 class Log
的构造函数、析构函数直接去掉,只保留静态函数。我们知道静态成员函数的调用不需要创建对象,即不依赖类的实例,因此可以直接调用。那就不需要构造函数和析构函数了。
那么实现类又是如何实现构造和析构的呢?类的实例又是什么时候被创建的呢?
通常日志的初始化时间是随着程序启动时,就初始化的,那时候就会调用 Log::init()
方法,来调用 LogImpl::init()
方法。并且整个程序运行的过程中,只有一个实现类的实例,能做到这点的有两种方式:
- 实现类采用单例模式,即保证只有一个实例。
- 创建静态实例,让这个对象的生命周期与程序的生命周期保持一致。
笔者在此采用第二种做法,即创建 static LogImpl g_logImpl;
这样保证:静态对象的构造发生在控制权传递给 main
函数之前,且只发生一次;静态对象的析构发生在 main
函数执行完成之后。
我们在来看下改造之后的代码:
class BASIC_TOOL_EXPORT Log {
public:
enum class Level {
INFO = 0,
WARN,
ERROR
};
static void init(Level level);
static void uninit();
// 获取日志存储路径及日志记录时间
static void getLogInfos(std::string& logPath, std::string& timeStr);
static void info(const char* format, ...);
static void warn(const char* format, ...);
static void error(const char* format, ...);
static void info(const wchar_t* format, ...);
static void warn(const wchar_t* format, ...);
static void error(const wchar_t* format, ...);
static void flush();
};
class LogImpl
{
using Type = Log::Level;
public:
LogImpl();
~LogImpl();
void init(Type type);
void uninit();
void output(const char* fmt, const va_list args, Type type);
void output(const wchar_t* fmt, const va_list args, Type type);
void output(const char* fmt, Type type);
void flush();
spdlog::logger* m_pLogger = nullptr;
std::string m_logPath;
std::string m_logTimeStr;
};
static LogImpl g_logImpl;
void Log::init(Level level)
{
g_logImpl.init(level);
}
void Log::uninit()
{
flush();
g_logImpl.uninit();
}
void Log::flush()
{
g_logImpl.flush();
}
void Log::getLogInfos(std::string& logPath, std::string& logTimeStr)
{
logPath = g_logImpl.m_logPath;
logTimeStr = g_logImpl.m_logTimeStr;
}
void Log::info(const char* fmt, ...)
{
va_list args;
va_start(args, fmt);
g_logImpl.output(fmt, args, Log::Level::INFO);
va_end(args);
}
void Log::warn(const char* fmt, ...)
{
va_list args;
va_start(args, fmt);
g_logImpl.output(fmt, args, Log::Level::WARN);
va_end(args);
}
void Log::error(const char* fmt, ...)
{
va_list args;
va_start(args, fmt);
g_logImpl.output(fmt, args, Log::Level::ERROR);
va_end(args);
}
void Log::info(const wchar_t* fmt, ...)
{
va_list args;
va_start(args, fmt);
g_logImpl.output(fmt, args, Log::Level::INFO);
va_end(args);
}
void Log::warn(const wchar_t* fmt, ...)
{
va_list args;
va_start(args, fmt);
g_logImpl.output(fmt, args, Log::Level::WARN);
va_end(args);
}
void Log::error(const wchar_t* fmt, ...)
{
va_list args;
va_start(args, fmt);
g_logImpl.output(fmt, args, Log::Level::ERROR);
va_end(args);
}
这样,整个层次就清晰许多了。
不过,还是有点瑕疵:
就是用户使用的时候是这样的:
// example
#include "log.h"
void func()
{
...
Log::info("This is a info log");
Log::info("This is a info log: %.3f, %.3f, %.3f", data.x, data.y, data.z);
Log::error("This is a error log");
...
}
这样写,用户需要知道 Log
类,才能使用 info
、warn
、error
等接口。本着怎么好用,怎么设计的原则,我不期望用户感知到或是过多关注到 Log
这个类,那么我们开启对它的再次封装:
#define LOG_INFO(...) Log::info(__VA_ARGS__)
#define LOG_WARN(...) Log::warn(__VA_ARGS__)
#define LOG_ERROR(...) Log::error(__VA_ARGS__)
#define LOG_ERROR_LOCATION Log::error("The error occurred in the line: %d, file : %s", __LINE__, __FILE__)
#define LOG_FLUSH Log::flush()
我们用宏来代替可变参函数,这样用户只需要关注 LOG_INFO
、LOG_WARN
、LOG_ERROR
这几个宏即可。
其中涉及到可变参的技巧知识,如果不了解,可以参考我之前的文章:
》》 C++可变参技巧揭秘:从函数到模板,一网打尽
这样,用户只需要包含 log.h
头文件,就可以使用 LOG_INFO
、LOG_WARN
、LOG_ERROR
这几个宏来输出日志了。
// example
#include "log.h"
void func()
{
...
LOG_INFO("This is a info log");
LOG_INFO("This is a info log: %.3f, %.3f, %.3f", data.x, data.y, data.z);
LOG_ERROR("This is a error log");
...
}
这样用户使用起来就简单了,而且隐藏了 Log
类的实现细节,达到了用户只能看到设计者想让他看到的目的。
总结
我们通过对日志管理系统的封装,将第三方库的实现细节隐藏起来,只暴露接口。
同时,我们又通过宏来简化用户的使用,让用户只关注 LOG_INFO
、LOG_WARN
、LOG_ERROR
这几个宏即可。
至此改造完成!
大家如果有不清楚的,欢迎在评论区交流。