1+1=10

记记笔记,放松一下...

浅谈复杂C++ Qt项目代码组织结构

接前面的开胃菜 QtCreator 下如何优雅使用第三方库,继续聊聊C++ Qt程序中众多的动态库(共享库)如何管理。

c++ qt libraries structure

缘起

用Qt开发一个程序,需要几十上百个的动态库。如果没有可心的包管理工具,那么这些动态库如何管理,还真的让人倍感头疼...

如果还有些强迫症,需要一个跨平台(Windows,Linux,MacOS,Embedded Linux)的方案,且同时支持cmake和qmake,...

同时,这个项目的开发和维护期需要保证5年以上...

注:本文介绍的是一个经过验证的手搓方案。新的应用程序直接考虑conan或者vcpkg可能更好一些。不管怎样,希望文中涉及四种不同的动态库使用方式,能对大家有一定参考意义。

动态库分类

首先,综合考虑动态库的接口稳定性,是否需要自行维护,是否自行开发,是否通用等因素,将动态库分成几类:

  • 第三方代码库:稳定的第三方库,不需要自行维护。直接使用预编译的二进制库,作为开发环境的基本配置。不需要git管理代码,直接体现在脚本和README中即可。
  • 基础代码库(框架库):自行开发的稳定的代码库,一些需要适配或魔改的采用MIT、BSD、LGPL等商业友好协议的比较小的三方库。每个框架(framework)一个git仓库,按版本号发布为动态库,以便于其他程序使用。
  • 业务代码库(可复用模组库):自行开发的模组库,每个模组一个git仓库,每一个库可以独立测试,库中的example可以直接作为公司某些业务的正式程序使用。主要用途还是供上层应用程序使用。
  • 应用程序(特定库):应用程序自身使用的代码库,位于应用程序自身的代码仓库中。

下一步呢?

分类之后,针对不同库,采用不同的应对策略就可以了。本身不复杂...

应用程序代码库

这个最简单,一个git仓库中的应用程序逻辑代码的直接拆分而已。qmake项目的代码结构大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
MyApp/
├── README.md
├── src/
│   ├── mycommon.pri
│   ├── src.pro
│   ├── app/
│   │   ├── main.cpp
│   │   └── app.pro
│   ├── libs/
│   │   ├── mylibs.pri
│   │   ├── lib1/
│   │   │   ├── main.cpp
│   │   │   └── lib1.pro
│   │   └── lib2/
│   │       ├── main.cpp
│   │       └── lib2.pro
├── tests/
└── scripts/

主要点:

  • 各个不同 .pro,通过 .pri 文件进行沟通和共享配置。各个.pro文件内容都很少
  • 比如动态库输出到什么地方,统一宏定义等,统一放到之 mylibs.pri 文件中
  • 尽可能使用qmake的函数定义等功能,以简化项目工程文件中的重复内容

比如,在 src.pro 负责管理这些库(生成到合适位置):

1
2
3
4
TEMPLATE = subdirs

SUBDIRS += libs/lib1/lib1.pro
SUBDIRS += libs/lib2/lib2.pro

app需要使用(找到)这些动态库和头文件,偷懒做法,写qmake函数将其隐藏起来,项目中直接用:

1
MYAPP_LIBS += lib1 lib2

cmake做类似事情,比qmake简单一些。每个文件夹下面放置一个CMakeLists.txt文件,再适当定义符合自己口味的cmake函数可以简化书写。

业务代码库,每个独立业务模块一个git代码仓库

假定我有数十个来自不同部门的的独立模块需要控制。每个模块对应一个git仓库,应该还比较自然吧。

  • 这个仓库,直接对应这个模块。里面的example可以直接反馈给模组开发部门使用。
  • 一系列的这种仓库,单独测试通过后,可以作为git submodule,在上层应用程序中直接使用。

单个git仓库结构

以qmake为例,单个代码的git仓库结构大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
MyLib/
├── README.md
├── mylib_top.pro
├── mylib_common.pri
├── src/
│   └── mylib/
│       ├── mylib.h
│       ├── main.cpp
│       ├── mylib.pri
│       └── mylib.pro
├── tests/
├── docs/
└── examples/

在这个仓库内,可以独立完成单元测试,编译验证。example中的例子也可以直接供其他部分使用。

这个example的好处是:不需要严格的版本号进行管控。About对话框中,只需要显示构建时间、构建所用源码的SHA值,仓库提交记录总数等,可用于追溯即可。

集成方式

不同的业务逻辑代码库进行组合,可以完成一些更复杂的功能,用于构筑上层用户应用程序。

可以将上面的各个git子仓库,直接以git submodule方式,集成到应用程序仓库中,一种组织结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
MyApp/
├── README.md
├── src/
│   ├── mycommon.pri
│   ├── src.pro
│   ├── app/
│   │    ├──app.pro
│   │ 
│   ├── libs/
│   ├── submodulelibs/
│   │   ├── app_submodulelibs.pri
│   │   ├── mylib1/
│   │   │   ├── mylib1_top.pro
│   │   │   └── src\
│   │   │       └── mylib1\
│   │   │           ├── mylib1.h
│   │   │           ├── main.cpp
│   │   │           ├── mylib1.pri
│   │   │           └── mylib1.pro
│   │   └── mylib2/
│   │       ├── mylib2_top.pro
│   │       └── src\
│   │           └── mylib2\
│   │               ├── mylib2.h
│   │               ├── main.cpp
│   │               ├── mylib2.pri
│   │               └── mylib2.pro
├── tests/
└── scripts/

所有的子仓库,统一放置到某个submoduleLibs目录下,在src.pro中,我们告诉它一下(让它统一管理)就好了:

1
2
3
4
TEMPLATE = subdirs

SUBDIRS += submodulelibs/mylib1/src/mylib1/mylib1.pro
SUBDIRS += submodulelibs/mylib2/src/mylib2/mylib2.pro

然后我们需要告诉app.pro,动态库和头文件在什么地方。偷懒的话,自己定义变量,直接写成下面这样:

1
MY_SUBMOD_LIBS += mylib1 mylib2

源码中,直接用

1
2
3
#include "mylib1/mylib1.h"
#include "mylib2/mylib2.h"
//bala bala...

注意:但是这儿我们新增加了一个app_submodulelibs.pri文件。该文件用于对所有的子仓库进行统一控制:

  • 控制动态库输出到什么地方
  • 控制统一使用哪一个版本基础库

所以,前面的业务代码库中的pri文件内,有类似下面的钩子代码(以保证作为子仓库集成时,行为受控):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
exists(../app_submodulelibs.pri) {
    # USER app should provide the .pri file
    include(../app_submodulelibs.pri)

    xxx_BIN_PATH = $$APP_BIN_PATH
    xxx_LIB_PATH = $$APP_LIB_PATH
}else{
    xxx_BIN_PATH = xxx_BUILD_TREE/bin
    xxx_LIB_PATH = xxx_BUILD_TREE/lib

    CONFIG += myqtaddons100
}

cmake

以上使用qmake举例,cmake其实差不多。整个代码完全结构不动,每个文件夹下面添加 CMakeLists.txt 文件,可以保持qmake和cmake两套系统同时工作的(尽管不建议这种左右手互博)。

基础代码库,公司框架库,使用动态库形式发布

这个比较纠结,不能所有代码都用子仓库以源码形式集成到应用程序仓库啊。

那么怎么办?我们可以将其做成framework,采用动态库的形式进行发布。

代码组织结构

这和前面的业务代码库有点相似,只是更繁杂一些/简单一些??:

  • 不用考虑作为子仓库使用,不需要留钩子(hook)
  • 需要作为动态库发布,质量要求更高,不能老改,乱改
  • 如果不能保证ABI兼容,需要支持多版本并存
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
MyQtAddons/
├── README.md
├── myqtaddons.pro
├── myqtaddons_common.pri
├── src/
│   ├── mycore/
│   │   ├── mycore.h
│   │   ├── mycore.pri
│   │   └── mycore.pro
│   ├── mygui/
│   │   ├── mygui.h
│   │   ├── mygui.pri
│   │   └── mygui.pro
│   ├── mywidgets/
│   │   ├── mywidgets.h
│   │   ├── mywidgets.pri
│       └── mywidgets.pro
├── tests/
├── docs/
└── examples/

这个没多少可说的,我们看看打包后结构

发布包结构

为了保持多版本并存,需要注意:

  • 所有头文件放置到以版本号命令的文件夹中
  • 所有动态库添加版本号作为后缀
  • 提供给qmake或cmake使用的文件,包含版本号。

以Windows为例,代码包发布结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
├── bin/
│   ├── myqtcore100.dll
│   ├── myqtcore100d.dll
│   ├── myqtgui100.dll
│   └── myqtgui100d.dll
│
├── include/
│   └── myqtaddons/
│       └── 1.0.0/
│           ├── myqtcore/
│           │   ├── xxxx1.h
│           │   └── xxxx2.h
│           │
│           └── myqtgui/
│               ├── yyyy1.h
│               └── yyyy2.h
│
├── lib/
│   ├── cmake/
│   │   └── myqtaddons100/
│   │       └── myqtaddons100-config.cmake
│   │
│   ├── myqtcore100.lib
│   ├── myqtcore100d.lib
│   ├── myqtgui100.lib
│   └── myqtgui100d.lib
│
└── mkspecs/
    └── features/
        ├── myqtaddons_moduels100.prf
        └── myqtaddons100.prf

注意:这里面分别为 cmake和qmake提供了 myqtaddons100-config.cmake 和 myqtaddons100.prf,需要什么黑魔法,尽情往里面放就可以。

只不过你能看到,到处充斥着这么多版本号,使用起来麻烦吗???

使用不麻烦

在程序代码中,不体现任何版本号

1
2
3
#include <myqtcore/xxxx1.h>
#include <myqtgui/yyyy2.h>
//bala bala ...

在工程文件中,只需要出现一次版本号

对于cmake工程来说,只需要

1
find_package(myqtaddons100 COMPONENTS core gui REQUIRED)

对于qmake工程来说

1
2
CONFIG += myqtaddons100
MYQTADDONS += core gui

不用统一升级

我们前面提到,业务代码库数十个仓库,都依赖基础框架库,如果框架升级,那不得所有的仓库都变更啊??

其实不需要,比如:

  • submodulelibs/mylib1 使用 myqtaddons100
  • submodulelibs/mylib2 使用 myqtaddons130
  • myapp 使用 myqtaddons150

各个业务库子仓库,单独编译时,比如mylib1会使用它自己指定的 100版本。

但是当它作为myapp的子仓库使用时,会自动使用myapp指定的150版本。

不要求ABI。只需要基础框架库保持源码级别兼容即可。只有做了不兼容的变更时,其他涉及到的业务逻辑库,才需要升级。

第三方库,配置到基本开发环境中

OpenCV是一个很大个头的C++库。把它放置到某个具体的Qt应用程序中不太现实。更不用说这东西迭代挺快,有好多版本。而且即使同一个版本,还有不同的编译选项,造成的不同动态库。

  • OpenCV4.10
  • OpenCV4.10-with-Cuda
  • ...

简单起见,这些统统在外围屏蔽掉,我们项目中只需要直接启用opencv,然后源码中:

1
2
#include <opencv2/opencv2>
// bala bala

cmake工程

OpenCV提供cmake支持,不用手动编写搜索逻辑。直接在CMakeLists.txt中用就行了:

1
2
3
find_package(OpenCV REQUIRED )

target_link_libraries(hello112  ${OpenCV_LIBS})

主打一个:应用程序不关心opencv的具体版本,以及在什么位置。

qmake工程

qmake稍微复杂一下,使用.pri/.prf/.prl“魔法”就行了。

.pro文件中,使用一行代码来启用:

1
CONFIG += opencv

或者友好点,找不到时,让他报错:

1
!load(opencv): message(Fail to find opencv.prf file!) 

总之,将配置基础环境时,将其封装到一个opencv.prf文件中,然后配置qmake能找到这个文件就行(所以,系统中有多套OpenCV库,就可以有很多个opencv.prf文件)。

在Windows下,opencv.prf手动创建就可,Linux下,可以使用类似代码

1
2
3
4
5
6
7
8
9
unix{
    packagesExist(opencv4){
        PKGCONFIG += opencv4
    }else:packagesExist(opencv){
        PKGCONFIG += opencv
    }else{
        error("Fail to find opencv.pc file!")
    }
}

其他

东西太杂,以上只做了简单整理和介绍。实际上,在整个项目中,尽管和构建无关,还有一些python写的向导脚本。

  • 用于生成各个库的模板(取个名字,直接填肉即可,不用考虑qmake,cmake细节)
  • 构建时,让qmake或cmake将关键的环境变量和变量信息记录到一个文件中(如果电脑上有多套Qt、OpenCV等库时这点很重要)
  • 打包脚本根据构建信息,自动提取所有依赖文件,生成安装包

另外如上所有内容,均不依赖于QtCreator,在命令行下都可正常工作(便于CI/CD集成)。