Skip to content

Qt客户端架构设计

关注软件的整个生命周期,以一个更高的全局视角,工程化的思想,符合公司战略布局,对软件进行抽象,系统性设计的活动。

它是动态的、持续化的、也是循环连续的,是建立在自己经验之上的。

每一次重构就是一次架构设计,我们应该将遇到的问题分类整理,然后抽象出通用解决模块,升级架构思想。

客户端模块设计

在还未开始开发前,就把0-N所遇到的问题考虑进去,而不是等到软件无法支撑时,再推翻重构代码。在软件开发初期进行架构设计防患于未然。

插件式设计思想

假设我们之前开发的文件存储服务使用的是腾讯云的cos,现在阿里云、华为云、七牛云都有不同程度的优惠给到我们的公司。问题是我们之前的代码完全是按照腾讯云的cos接口进行对接的,修改起来难度极大,此时该怎么办?

什么是插件

台式机主板的设计就是很好的例子,主板上提供了各种类型的接口。比如内存条DDR4规格的内存条外观接口都是一样的,因为只有设计成一样的才能查到主板上

插件的本质就是:对内标准化,对外差异化

我们在设计QT客户端的时间就可以参考这种思想进行插件式模块设计

那么具体该如何在我们的代码中实现插件化呢?

  • 对于同一种插件,使用继承来实现。设计一个父类来描述这一类型的插件,该类型具体的插件继承父类并重新实现具体的逻辑。
  • 对于不同的插件,使用组合的方式来将他们集成到一起。设计一个单例类——插件管理器类来管理所有的插件接口,可以方便的控制启用哪些插件接口。

分层设计思想

对于客户端的设计,我们可以进行以下分层将数据也界面分离进行解耦合

更具体的设计为

其中Dao是数据访问对象(Data Access Object) 的简写形式,更多的专有名词参考专有名词章节。

插件管理器

ManagePlugins

cpp
// .h
#ifndef MANAGEPLUGINS_H
#define MANAGEPLUGINS_H

#include <QObject>
#include "loggerproxy.h"

class ManagePlugins : public QObject
{
    Q_OBJECT
public:
    explicit ManagePlugins(QObject *parent = nullptr);
    void installPlugins();

signals:

private:
    LoggerProxy *_log;
};

#endif // MANAGEPLUGINS_H



// .cpp
#include "manageplugins.h"
#include "loggerqdebug.h"

ManagePlugins::ManagePlugins(QObject *parent)
    : QObject{parent}
{}

void ManagePlugins::installPlugins()
{
    // 安装日志插件
    _log = LoggerProxy::getInstance();
    _log->setLogger(new LoggerQDebug);
}

将插件管理器集成到项目中,在main中添加

cpp
#include "manageplugins.h"

ManagePlugins mp;
mp.installPlugins();

日志模块

日志是比较重要的一个模块,通过日志我们可以记录软件运行状况、人员对软件的操作记录,有了这些信息可以支持我们分析解决软件所产生的问题,有了数据的支撑我们还可以统计并分析战略布局。

了解了日志的作用,我们还需要清楚日志都要记录什么内容。一般来说日志有四要素

  1. 时间:年月日 时分秒 毫秒
  2. 地点:文件 行号 函数
  3. 人物:什么对象
  4. 事件:发生了什么事情

通常情况下,我们还需要对日志进行分级,不同级别的日志保存时限、保存地点是不一样的。级别从低到高大体上应划分以下级别

  • 追踪 TRACE:通常来追踪一些通常情况下不便于打印的信息,比如密码。
  • 调试 DEBUG:最详细的日志级别,通常用于记录程序的详细运行信息。
  • 信息/普通 INFO:用于记录程序运行的重要信息,如启动、关闭等。
  • 警告 WARN:表示潜在的问题,但程序仍能继续运行。
  • 错误 ERROR:表示程序遇到了错误,但还能继续执行。
  • 致命错误 FATAL:最严重的日志级别,表示程序无法继续运行。

通过设置级别,控制日志打印的详细程度。级别越高越优先打印,当级别设置为普通时,则不打印调试级别的日志,普通级别以上的都会打印。

日志文件一般以天为单位进行存放。我们还需要为日志存储在合适的位置,不同级别的日志保存的时限和位置都是有所区别的

日志模块采用插件式封装法,对内提供统一日志记录接口,根据需求对外对接不同的第三方日志库或自定义库,如glog、 log4cxx等。

为了支持存储到云存储/服务器,日志参数可以设计为 (string msg, bool up = false),在一开始设计时就把上传参数考虑进去,免得需要上传时没有相关参数

另外还需要考虑多线程的情况,日志模块应该能够打印线程名,并且是线程安全的。

使用单线程来存储日志可以解决多个线程同时要写日志的需求,并且大数据量大日志写入时不会影响其他线程要写日志的请求,还可以给多线程添加线程标识并写入到日志中。

具体到详细的代码设计方面,第一:我们需要一个日志代理单例类,在该类中添加 infodebugwarn 等信号,需要输出日志的线程调用该类中的方法,在这些方法中发送这些信号。第二:我们需要一个日志类,把日志代理类中的信号与该类中相应的槽函数进行连接。

  • 日志代理单例类
    • 使用宏传递行号、代码文件名、函数名
    • 调用统一的doLog方法发送打印日志的信号

设计日志父类BasicLogger,子类LoggerQDebug。在插件管理器中根据配置安装日志插件。

插件管理器代码

cpp
// .h
#include "loggerproxy.h"
private:
    LoggerProxy *_log;

// .cpp
_log = LoggerProxy::getInstance();
_log->setLogger(new LoggerQDebug);

日志代理单例类LoggerProxy

cpp
#ifndef LOGGERPROXY_H
#define LOGGERPROXY_H

#include <QObject>
#include "basiclogger.h"
#include "global.h"

// 基础日志类,派生该类来实现具体的日志记录

class LoggerProxy : public QObject
{
    Q_OBJECT
public:
    static LoggerProxy* getInstance();
    const LoggerProxy& reset(const QString &file, int line, const QString &func);

    void trace(const QVariant &var, bool up = false)const;
    void debug(const QVariant &var, bool up = false)const;
    void info(const QVariant &var, bool up = false)const;
    void warn(const QVariant &var, bool up = false)const;
    void error(const QVariant &var, bool up = false)const;
    void fatal(const QVariant &var, bool up = false)const;

    void setLevel(GLOBAL::LOG_LEVEL newLevel);
    void setLogger(BasicLogger *newLogger);

private:
    explicit LoggerProxy(QObject *parent = nullptr);
    LoggerProxy(const LoggerProxy&) = delete;
    LoggerProxy& operator=(const LoggerProxy&) = delete;

    void doLog(GLOBAL::LOG_LEVEL level, const QVariant& var, bool up) const;

signals:
    void sigLog(const QString& file, int line, const QString& func, void* threadID, int level, const QVariant& var, bool up) const;


private:
    BasicLogger* _logger = nullptr;
    GLOBAL::LOG_LEVEL _level;
    QString _file;
    int _line;
    QString _func;
};

#define Log LoggerProxy::getInstance()
#define LogTrace Log->reset(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, QT_MESSAGELOG_FUNC).trace
#define LogDebug Log->reset(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, QT_MESSAGELOG_FUNC).debug
#define LogInfo Log->reset(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, QT_MESSAGELOG_FUNC).info
#define LogWarn Log->reset(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, QT_MESSAGELOG_FUNC).warn
#define LogError Log->reset(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, QT_MESSAGELOG_FUNC).error
#define LogFatal Log->reset(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, QT_MESSAGELOG_FUNC).fatal

#endif // LOGGERPROXY_H



#include "loggerproxy.h"
#include <QThread>

LoggerProxy *LoggerProxy::getInstance()
{
    static LoggerProxy lp;
    return &lp;
}

const LoggerProxy &LoggerProxy::reset(const QString &file, int line, const QString &func)
{
    _file = file;
    _line = line;
    _func = func;

    return *this;
}

void LoggerProxy::trace(const QVariant &var, bool up) const
{
    doLog(GLOBAL::LOG_LEVEL::TRACE, var, up);
}

// ... other level func

void LoggerProxy::fatal(const QVariant &var, bool up) const
{
    doLog(GLOBAL::LOG_LEVEL::FATAL, var, up);
}

void LoggerProxy::setLevel(GLOBAL::LOG_LEVEL newLevel)
{
    _level = newLevel;

}

void LoggerProxy::setLogger(BasicLogger *newLogger)
{
    if(!_logger){
        _logger = newLogger;
        connect(this, &LoggerProxy::sigLog, _logger, &BasicLogger::onLog);
    }
}

LoggerProxy::LoggerProxy(QObject *parent)
    : QObject{parent}
{}

void LoggerProxy::doLog(GLOBAL::LOG_LEVEL level, const QVariant &var, bool up) const
{
    if (!_logger) { return; }

    if (level >= _level)
        emit sigLog(_file, _line, _func, QThread::currentThreadId(), level, var, up);

}

日志父类BasicLogger

cpp
#ifndef BASICLOGGER_H
#define BASICLOGGER_H

#include <QObject>

class BasicLogger : public QObject
{
    Q_OBJECT
public:
    explicit BasicLogger(QObject *parent = nullptr);
    ~BasicLogger();

signals:

public slots:
    void onLog(const QString &file, int line, const QString &func, void* threadID, int level, const QVariant &var, bool up = false);

protected:
    virtual void print(const QString &file, int line, const QString &func, void* threadID, int level, const QVariant &var, bool up = false) = 0;
    static QString filePath();

private:
    QThread *_thread;
};

#endif // BASICLOGGER_H


#include "basiclogger.h"
#include "fileutils.h"
#include "global.h"
#include <QThread>
#include <QDate>

BasicLogger::BasicLogger(QObject *parent)
    : QObject{parent}
{
    _thread = new QThread;
    moveToThread(_thread);
    _thread->start();
}

BasicLogger::~BasicLogger()
{
    if (_thread->isRunning())
    {
        _thread->quit();
        if (_thread->wait(1000))
        {
            _thread->terminate();
            _thread->wait(1000);
        }
    }
    delete _thread;
}

void BasicLogger::onLog(const QString &file, int line, const QString &func, void* threadID, int level, const QVariant &var, bool up)
{
    print(file, line, func, threadID, level, var, up);
}

QString BasicLogger::filePath()
{
    QString name = QDate::currentDate().toString(Qt::ISODate);
    return FileUtils::joinPath(GLOBAL::PATH::LOG_DIR, QString::fromLocal8Bit("%1.log").arg(name));
}

子类LoggerQDebug

cpp
#ifndef LOGGERQDEBUG_H
#define LOGGERQDEBUG_H

#include "basiclogger.h"

class LoggerQDebug : public BasicLogger
{
public:
    explicit LoggerQDebug(QObject *parent = nullptr);

protected:
    virtual void print(const QString &file, int line, const QString &func, void* threadID, int level, const QVariant &var, bool up = false);

private:
    static void handle(QtMsgType type, const QMessageLogContext &contex, const QString &msg);
    QThread* m_td = nullptr;
};

#endif // LOGGERQDEBUG_H


#include "loggerqdebug.h"
#include <QDateTime>
#include "global.h"
#include <iostream>

LoggerQDebug::LoggerQDebug(QObject *parent)
    : BasicLogger{parent}
{
    qInstallMessageHandler(handle);
}

void LoggerQDebug::print(const QString &file, int line, const QString &func, void *threadID, int level, const QVariant &var, bool up)
{
    QDateTime dt;
    QString dtStr = dt.currentDateTime().toString(Qt::ISODate);
    QString front = QString::fromLocal8Bit("%1 [%2] %3:%4 [%5] -")
                        .arg(dtStr, GLOBAL::LOG_NAMES[level], file)
                        .arg(line)
                        .arg(func);
    front = front.replace("..\\", "");
    qDebug() << front.toLocal8Bit().data() << var;  // 使用qDebug而不是直接存文件,是为了打印QVariant类型
}

void LoggerQDebug::handle(QtMsgType type, const QMessageLogContext &contex, const QString &msg)
{
    QFile file(filePath());

    QString key("QVariant(QString,");
    QString message = msg;
    message.replace(msg.indexOf(key), key.size(), "").chop(1);

    if (file.open(QIODevice::WriteOnly|QIODevice::Append))
    {
        // 输入到文件流中
        QTextStream stream(&file);
        stream << message << "\n";
        file.close();
    }
    // 用于控制台输出
    std::cout << message.toUtf8().data() << std::endl;
}

输出日志时调用

cpp
LogDebug("This is main func");
// 2025-05-16T15:34:57 [DEBUG] ../../../project_name/src/main.cpp:12 [int main(int, char **)] -  "This is main func"

网关模块

网关我们可以参考生活中的海关,网关主要有以下作用

  • 鉴权: 对没有权限的用户,可以在网关对其请求进行拦截。
  • 转发:如果前端界面直接请求数据,如果这个数据量比较大或者网络延迟比较高导致请求缓慢就会导致前端界面卡顿,通过网关来转发前端的请求就可以解决这个问题。
  • 限流:如果客户端比较多,并且在较短时间内会发起大量请求给后端服务器,那么就会造成服务器压力比较大,此时我们可以直接在客户端网关中限流。
  • 统计:可以在网关统计这个用户对各个接口的请求情况、某个接口的请求耗时等。

网关模块架构图设计

网关模块代码设计

  • 提供统一的send方法
  • 定义路由表

头文件

cpp
#ifndef GATEWAY_H
#define GATEWAY_H

#include <QObject>

class Gateway : public QObject
{
    Q_OBJECT
public:
    static Gateway* getInstance();

private:
    explicit Gateway(QObject *parent = nullptr);
    Gateway(const Gateway&) = delete;
    Gateway& operator=(const Gateway&) = delete;

signals:
};

#endif // GATEWAY_H

源文件

cpp
#include "gateway.h"

Gateway *Gateway::getInstance()
{
    static Gateway gateway;
    return &gateway;
}

Gateway::Gateway(QObject *parent)
    : QObject{parent}
{}

信号中心模块

随着项目越来越复杂,对象之间的通信需求也是越来越多,通常信号都是写在对应的类中的,使用connect连接信号时需要传入发送信号的对象,但是我们不可能在需要发送某个信号时刚好有一个对应的对象。为了解决这个问题,我们可以使用一个单例类来专门管理信号,需要发送信号就获取单例对象的实例进行发送,使用connect连接信号槽时直接连接单例对象和信号的类型即可。

ManagePlugins

cpp
#ifndef MANAGEPLUGINS_H
#define MANAGEPLUGINS_H

#include <QObject>
#include "loggerproxy.h"

class ManagePlugins : public QObject
{
    Q_OBJECT
public:
    explicit ManagePlugins(QObject *parent = nullptr);
    void installPlugins();

signals:

private:
    LoggerProxy *_log;
};

#endif // MANAGEPLUGINS_H



#include "manageplugins.h"
#include "loggerqdebug.h"

ManagePlugins::ManagePlugins(QObject *parent)
    : QObject{parent}
{}

void ManagePlugins::installPlugins()
{
    // 安装日志插件
    _log = LoggerProxy::getInstance();
    _log->setLogger(new LoggerQDebug);
}

单例管理模块

随着系统内模块越来越多,我们会面临一个单例类剧增的问题。为了解决该问题,我们可以使用一个单例管理模块来管理这些单例类。这个单例管理模块也是一个单例类,该类的属性中包含其他的单例对象。也就是

  • 创建一个单例A
  • 其他单例作为单例A的数据成员

GlobalManage

cpp
#ifndef GLOBALMANAGE_H
#define GLOBALMANAGE_H

#include <QObject>

#define GM GlobalManage::getInstance()

// 使用前置的类声明,只在使用某个类的文件中倒入头文件
// 可以避免倒入这个头文件会倒入很多不需要的头文件
class LoggerProxy;

class GlobalManage : public QObject
{
    Q_OBJECT

public:
    static GlobalManage *getInstance();

private:
    explicit GlobalManage(QObject *parent = nullptr);
    GlobalManage(const GlobalManage&) = delete;
    GlobalManage& operator=(const GlobalManage&) = delete;

signals:

public:
    LoggerProxy *_log;
};

#endif // GLOBALMANAGE_H



#include "globalmanage.h"
#include "loggerproxy.h"

GlobalManage::GlobalManage(QObject *parent)
{
    _log = LoggerProxy::getInstance();
}


// 使用 TODO
GM->_log

错误码编码

我对信息编码的理解就是将信息进行归类整理,为每个类赋予固定字符串,称之为信息编码。

如:1c05a3

在公司中各个岗位对于同一款产品都有不同的烦恼

  • 用户:看不懂报错、文件被占用、运行缓慢、查询信息等
  • 老板:看不到业务运行的整体情况、成功率、执行耗时等
  • 产品:无数据统计、无法给老板做出交代、无法优化产品设计等
  • 测试:无法复现场景、只能作为bug日志,后续进行跟踪等
  • 开发:根据日志查看代码,但无法分析出真正的问题等

传统的开发帮助客户解决问题都是通过远程查看日志记录,但是看日志只能解决单次问题,效率极低。

还可以使用统计学知识统计多次数据,分析问题所在。这就涉及如何收集统计数据了,在数据挖掘领域有一个RFM模型用来分析客户价值

我们可以参考这个模型来进行错误码编码的设计,R可以对应为最近一次出错,F对应为出错的频率,M对应为错误的级别。

统计是需要字段的,那么错误日志的字段从何而来呢?

常规日志都是错误码+描述信息。错误码具有确定性,将错误码进行合理编码即可获得统计字段。

具体到代码设计层面,可以编写有层次结构信息的异常类,通过C++的异常捕获机制来实现。也可以使用命名空间定义错误码层次结构

cpp
#define EC_TENCENT "20"
#define EM_TENCENT string("腾讯云")

#define EC_UPLOAD "20"
#define EM_UPLOAD string("上传失败")

定义异常类

cpp
// 父类
class BaseException {
public:
    BaseException(const string& code, const string& msg) : m_code(code), m_msg(msg) {}
        
private:
    string m_code;
    string m_msg;
};

// 子类
class TencentException : public BaseException {
public:
    TencentException(const string& code, const string& msg) : BaseException(EC_TENCENT + code, EM_TENCENT + msg) {}
};

// 抛出异常
int main() {
    try{
        throw TencentException(EC_UPLOAD, EM_UPLOAD);
    }catch (BaseException e){
        cout << e << endl;
    }
}

"code: 2005, msg: 腾讯云 上传失败"

⚠️注意

  • 不要过度设计详细的错误码,根据业务需求,适度即可
  • 错误码不必是数字,可以是英文、希腊字母、特殊符号等
  • 错误码不同位可以没有关联性,也可以是其它级别