如何维护一个C++开源项目--从编译器到Github

前言

2020年年中的时候,我在Github上开源了一个简单的Minecraft模组(下面以tr代称),由于受到社区的广好评,我就一直维护,到现在依然在更新。虽然目前只有180个星星,但是这个小项目也见证了我在C++以及开源这方面不小的成长。因此,趁着没事干这一契机,打算把我之前遇到的问题以及经验写下来,供给其他人参考。

软件开发与维护

设计

​ 本科学软件工程的时候我就知道,要想软件扩展性高,就要进行模块化设计,每个模块各司其职,这样才能方面后续扩展和维护。一开始我也确实做到了这点,但是后期就会发现一个问题,那就是过度设计。这也是我在这里最想说的东西。

​ Tr是一个游戏mod,但是它没有用任何第三方提供的游戏内API,而是使用一些特殊的工具(如HOOK库)来达成某些特殊的目的。当时的我想的是把侵入游戏的部分(也就是HOOK)独立出来做出一个类似第三方API的库,然后mod本身以这个库作为依赖,从而达到解耦的目的,我甚至还想过以后这个API库能够供给其他人使用,达到减小开发成本的目的。(因此你可以发现tr的源码有apimod两个主要目录)。

​ 我也确实是这么做的,我甚至写了一个DBSMod的抽象类,将其放到api目录,然后在mod内写TrapdoorMod并继承这个类。但是后面出现了以下几个问题:

  1. 我无法很好地区分哪些算是所谓的API,而哪些属于mod的部分,这导致后期开发的时候两个模块内的内容比较混乱,api完全没法独立出来
  2. 我根本没有经历去维护一个所谓的第三方API库,前段时间甚至有个外国的社区开发者在dc上问我tr的api怎么用,这直接给我问懵了,因为目前的设计根本没法支持将将API独立出来给别人用,并且我也没办法对这个API的稳定性和可靠性做任何保证
  3. 强行分成两个部分导致了我后期的部分编码不便,最后我不得不重构部分代码

我认为上述问题都是过度设计引起的,既然我一开始只想写mod,而不是模组框架,那么就不该做这种模糊的划分,拆分反而会加剧开发难度。

环境准备

编译器的选择。正如上面所说,因为这里需要侵入exe文件内部,因此为了保证abi的兼容性,我一开始以为只能使用msvc作为编译器。知道后面我才知道,windows上的clang使用的默认后端就是msvc,因此clang也是可行的选择,但是考虑到各类编辑器/IDE对编译器的支持程度,我最后还是选择了msvc

构建系统。因为习惯原因,我选择了Cmake作为这个项目的构建系统,最重要的原因当然是我相对来说更加熟悉,此外,我不喜欢编码被某个ide绑定,因此我没有使用windows上更加常用的vs方案,因此这样就只能使用Visual studio来进行代码编写了。

C++版本。毫无疑问我选择了C++17,使用更新的标准肯定没坏处(不用20是因为这个变化有亿点大,且各个编译器对其支持都不完全)

编码

​ 由于对C++比较熟悉,因此我在这上面遇到的问题并不算多,下面就列举几个有代表性的:

  1. 头文件的相互引用。可以说这是我遇到的最大的问题了,早期的tr由于代码足够简单,所有的代码都在.h中(你还能在早期版本的源码中看到这一个盛况),这导致编译根本过不了,而且对于一个没有经验的开发者来说很难找到错误,不过最后我及时回头,将所有的实现和声明拆开,以后就再也没遇到这个问题了

  2. 全局变量问题。从现在的眼光来看,c++因为其面向对象的特点,是能够相对容易地避免全局变量的(这里就要吐槽下C语言了,到处都是extern),但是当时我并不知道这些,导致早期的tr代码全局变量随处可见。因此为了方便后期维护,我将所有的全局变量写道了一个类里面,然后使用staic对象的方案来获取这对象:

    1
    2
    3
    4
    TrapdoorMod& mod(){
    static TrapdoorMod mod;
    return mod;
    }

规范化

​ 这里的规范化主要是指代码风格和格式化。对于代码风格,我沿用了自己之前写java的那套风格,类名首字母大写,变量名首字母小写等等,也和mojang的代码风格保持相对一致。对于代码格式化问题,我一开始用的CLion进行代码编写,后面因为工程越来越大,加之我电脑性能有限,后面改为用vscode开发,这时候问题就出现了,clion和vscode对C++代码的格式化风格不一样,这样你修改一行代码,保存后文件会全部重新格式化,因此就相当于提交了许多无效信息。

​ CLion的格式化风格只能在自己的配置文件修改,这个配置文件严格来说不属于项目的一部分。总不能每次切换ide就重新配置一次。最后的解决方案是使用clang-format,这个工具可以读取给定的配置文件,对指定的代码使用配置文件规定的风格进行格式化。由于这个工具早就被广泛使用,因此CLion和vscode都支持这个工具。项目目前的配置文件如下所示:

1
2
3
4
5
6
7
#.clang-format
Language: Cpp
BasedOnStyle: Google
UseTab: Never
IndentWidth: 4
NamespaceIndentation: All
ColumnLimit: 100

​ 当然,使用clang-format也给我带来了一些问题,比如在某些情况下,头文件B必须要在头文件A之前include,项目才能正常编译通过。但是每次clang-format自动格式化的时候,就会默认对自定义头文件根据首字母排序,这样A就到了B前面,导致编译失败。解决这个问题办法是在你不需要格式化的代码上下加两个注释,以提示clang-format,下面是一个例子:

1
2
3
4
5
6
// clang-format off
#include "B.h"
#include "A.h"
// clang-format on
//这时候clang-format不会格式化上面两行
int main(){return 0;}

调试

单步调试永远不应该是你的第一选择。虽然*db(lldb,gdb)等这些调试工具十分好用,但是它们也有自己的局限性:不能预防错误,或者说在程序出错时无法立即告诉你错误在哪,而是你发现程序出错后,再使用gdb跑一遍程序,去定位错误位置,然后查错误位置附近的内存数据以查找bug。

使用日志和断言来进行错误语法和检测。你可以在关键位置打上日志,这样可以随时插查看程序的运行情况,而不是等出错后再使用gdb去查看。使用assert()可以让程序在其行为与你的预期不一致的时候提醒你。而且这些输出都可以通过宏来控制,不用在发布的时候再去删除相关代码。

测试

​ 由于tr自身的特殊性(不是可执行文件,而是一个只能附加到进程的dll),并且绝大多数内容都是和游戏内数据进行打交道,但是游戏内部对我们来说就是个黑盒,因此无法使用常规的方法如单元测试等来测试程序的正确性。面对这一点我暂时也没很好的解决方案,基本的测试也就这两个方面:

  1. 从开发者角度,写完后在游戏内进行实际的测试
  2. 从用户的角度,等待用户进行bug反馈

自动打包/发布

​ 这部分内容我在此之前没有任何经验,甚至不知道CI/CD是什么。在开发tr的过程中,每当要我要发release的时候,都得做如下的操作:

  1. 修改编译参数为release模式以提高性能并屏蔽所有debug输出
  2. 重新编译整个文件
  3. 复制dll,配置文件,readme以及其他一些调试文件到某个空文件夹‘
  4. 压缩文件夹为压缩包
  5. 上传压缩包到github并写release note

​ 这些步骤做一两次还觉得无所谓,但是重复次数太多后就觉得很烦且浪费时间,后来我我写了一个脚本自动化帮我完成3-4两个步骤,但是我还是觉得编译和上传这两个步骤太麻烦。

​ 后来我了解到了github action这个东西,它能根据配置文件定义的工作流,在特定的时候自动完成1-5的所有工作。配置文件大概长下面这个样子:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
name: GitHub Actions Demo
on: [ push ]
jobs:
Build:
runs-on: windows-latest
steps:

- name: checkout code
uses: actions/checkout@v3

- name: Pull submodule
run: FetchSDK.cmd
shell: cmd

- name: Download Server
run: |
mkdir D:/BDS
ServerLink=$(cat 'LINK.txt')
curl -L -o D:/BDS/server.zip "$ServerLink"
unzip D:/BDS/server.zip -d D:/BDS > /dev/null
shell: bash

- name: Build Bedrock Library
run: |
cd SDK/Tools
LibraryBuilder.exe -o ..\Lib D:\BDS
shell: cmd

- name: Configure CMake
run: cmake -B ${{github.workspace}}/build -DBETA=PFF -DDEV=OFF

- name: Build
run: cmake --build ${{github.workspace}}/build

- name: Create Artifact
run: |
mkdir out
mkdir out/plugins
mkdir out/plugins/trapdoor
cp ./build/Debug/trapdoor*.dll ./out/plugins/
cp ./src/base/config.json ./out/plugins/trapdoor/
cp ./changelog.md ./out/
cp ./README.md ./out
shell: bash

- name: Upload Actions File
uses: actions/upload-artifact@v1.0.0
with:
name: release
path: ${{github.workspace}}/out

#create release
- name: Prepare Release
run: |
7z a release.zip ${{github.workspace}}/out
python create_release_note.py

- name: Publish Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
body_path: ${{ github.workspace }}/release_note
files: |
release.zip
env:
GITHUB_REPOSITORY: hhhxiao/trapdoor-ll

​ 这个文件定义了从编译到发布release的所有流程,并且可以在github自己提供的免费服务器上运行,不需要我本地操心。但是这个东西唯一的缺点就是编写过程中调试麻烦,由于github提供的机器性能有限,每次编译要等个五六分钟,如果发现后面的脚本有一个地方写错了,这个workflow又要从零开始跑一遍,我当时这个脚本写了20来个版本才把所有流程完全搞定。不过它带来的回报就是现在tr的release完全由这个action自动完成,再也不用我自己走那一套流程了。

社区支持

一个开源软件的发展离不开社区的支持。因此在写代码之外维护仓库也是必要的一环。

文档支持

​ 文档支持分README和详细文档两部分。前者就在github主页,需要用简练的语言介绍项目是做什么的,有什么用,以及如何使用。如果你有心情的话甚至可以画一个logo。后者就是对你开发的工具/库的详细介绍,包括详细的安装流程和使用方法,甚至可以附带编译方法,如何二次开发等内容。为了写tr的文档我甚至去学了一波Vuepres。

社区交流

​ 这部分我也觉得可以分为两部分,一部分面对普通用户,一部分面对开发者。对于普通用户,交流的主要平台就是Issue页面,作为主要维护者,我们应该熟悉issue的label和close功能,label就是给问题打一个tag,表示开发者对这个issue的定性,是bug,还是没事找事等等,而close就是处理完的问题要及时关闭,以免占据issue的版面。

​ 对于开发者,作为一个开源项目,应该都是乐于看到其他开发者对自己项目的pr以及基于该项目的二次开发的。为了方便开发者进行二次开发,我们也应该在文档上写上如何编译,以及简单介绍项目的技术栈和架构等等。此外,也会有部分“蹭提交”的开发者,因为你不管pr了什么,pr了多少内容,都会显示在项目主页上。因此作为维护者我们不应该顾及所谓的“人情”或者为了让项目看起来更加活跃而忽视代码检查,防止一粒老鼠屎坏了一锅粥。特意强调这个就是因为我自己吃了这个的亏,具体的就不细说,反正就当买教训了。