如何维护一个C++开源项目--从编译器到Github
前言
2020年年中的时候,我在Github上开源了一个简单的Minecraft模组(下面以tr代称),由于受到社区的广好评,我就一直维护,到现在依然在更新。虽然目前只有180个星星,但是这个小项目也见证了我在C++以及开源这方面不小的成长。因此,趁着没事干这一契机,打算把我之前遇到的问题以及经验写下来,供给其他人参考。
软件开发与维护
设计
本科学软件工程的时候我就知道,要想软件扩展性高,就要进行模块化设计,每个模块各司其职,这样才能方面后续扩展和维护。一开始我也确实做到了这点,但是后期就会发现一个问题,那就是过度设计。这也是我在这里最想说的东西。
Tr是一个游戏mod,但是它没有用任何第三方提供的游戏内API,而是使用一些特殊的工具(如HOOK库)来达成某些特殊的目的。当时的我想的是把侵入游戏的部分(也就是HOOK)独立出来做出一个类似第三方API的库,然后mod本身以这个库作为依赖,从而达到解耦的目的,我甚至还想过以后这个API库能够供给其他人使用,达到减小开发成本的目的。(因此你可以发现tr的源码有api
和mod
两个主要目录)。
我也确实是这么做的,我甚至写了一个DBSMod
的抽象类,将其放到api
目录,然后在mod
内写TrapdoorMod
并继承这个类。但是后面出现了以下几个问题:
- 我无法很好地区分哪些算是所谓的API,而哪些属于mod的部分,这导致后期开发的时候两个模块内的内容比较混乱,api完全没法独立出来
- 我根本没有经历去维护一个所谓的第三方API库,前段时间甚至有个外国的社区开发者在dc上问我tr的api怎么用,这直接给我问懵了,因为目前的设计根本没法支持将将API独立出来给别人用,并且我也没办法对这个API的稳定性和可靠性做任何保证
- 强行分成两个部分导致了我后期的部分编码不便,最后我不得不重构部分代码
我认为上述问题都是过度设计引起的,既然我一开始只想写mod,而不是模组框架,那么就不该做这种模糊的划分,拆分反而会加剧开发难度。
环境准备
编译器的选择。正如上面所说,因为这里需要侵入exe
文件内部,因此为了保证abi的兼容性,我一开始以为只能使用msvc
作为编译器。知道后面我才知道,windows上的clang
使用的默认后端就是msvc
,因此clang也是可行的选择,但是考虑到各类编辑器/IDE对编译器的支持程度,我最后还是选择了msvc
构建系统。因为习惯原因,我选择了Cmake
作为这个项目的构建系统,最重要的原因当然是我相对来说更加熟悉,此外,我不喜欢编码被某个ide绑定,因此我没有使用windows上更加常用的vs方案,因此这样就只能使用Visual studio来进行代码编写了。
C++版本。毫无疑问我选择了C++17,使用更新的标准肯定没坏处(不用20是因为这个变化有亿点大,且各个编译器对其支持都不完全)
编码
由于对C++比较熟悉,因此我在这上面遇到的问题并不算多,下面就列举几个有代表性的:
头文件的相互引用。可以说这是我遇到的最大的问题了,早期的tr由于代码足够简单,所有的代码都在
.h
中(你还能在早期版本的源码中看到这一个盛况),这导致编译根本过不了,而且对于一个没有经验的开发者来说很难找到错误,不过最后我及时回头,将所有的实现和声明拆开,以后就再也没遇到这个问题了全局变量问题。从现在的眼光来看,c++因为其面向对象的特点,是能够相对容易地避免全局变量的(这里就要吐槽下C语言了,到处都是
extern
),但是当时我并不知道这些,导致早期的tr代码全局变量随处可见。因此为了方便后期维护,我将所有的全局变量写道了一个类里面,然后使用staic
对象的方案来获取这对象:1
2
3
4TrapdoorMod& mod(){
static TrapdoorMod mod;
return mod;
}
规范化
这里的规范化主要是指代码风格和格式化。对于代码风格,我沿用了自己之前写java
的那套风格,类名首字母大写,变量名首字母小写等等,也和mojang的代码风格保持相对一致。对于代码格式化问题,我一开始用的CLion进行代码编写,后面因为工程越来越大,加之我电脑性能有限,后面改为用vscode开发,这时候问题就出现了,clion和vscode对C++代码的格式化风格不一样,这样你修改一行代码,保存后文件会全部重新格式化,因此就相当于提交了许多无效信息。
CLion的格式化风格只能在自己的配置文件修改,这个配置文件严格来说不属于项目的一部分。总不能每次切换ide就重新配置一次。最后的解决方案是使用clang-format
,这个工具可以读取给定的配置文件,对指定的代码使用配置文件规定的风格进行格式化。由于这个工具早就被广泛使用,因此CLion和vscode都支持这个工具。项目目前的配置文件如下所示:
1 |
|
当然,使用clang-format也给我带来了一些问题,比如在某些情况下,头文件B必须要在头文件A之前include,项目才能正常编译通过。但是每次clang-format
自动格式化的时候,就会默认对自定义头文件根据首字母排序,这样A就到了B前面,导致编译失败。解决这个问题办法是在你不需要格式化的代码上下加两个注释,以提示clang-format
,下面是一个例子:
1 |
|
调试
单步调试永远不应该是你的第一选择。虽然*db(lldb,gdb)
等这些调试工具十分好用,但是它们也有自己的局限性:不能预防错误,或者说在程序出错时无法立即告诉你错误在哪,而是你发现程序出错后,再使用gdb
跑一遍程序,去定位错误位置,然后查错误位置附近的内存数据以查找bug。
使用日志和断言来进行错误语法和检测。你可以在关键位置打上日志,这样可以随时插查看程序的运行情况,而不是等出错后再使用gdb
去查看。使用assert()
可以让程序在其行为与你的预期不一致的时候提醒你。而且这些输出都可以通过宏来控制,不用在发布的时候再去删除相关代码。
测试
由于tr自身的特殊性(不是可执行文件,而是一个只能附加到进程的dll),并且绝大多数内容都是和游戏内数据进行打交道,但是游戏内部对我们来说就是个黑盒,因此无法使用常规的方法如单元测试等来测试程序的正确性。面对这一点我暂时也没很好的解决方案,基本的测试也就这两个方面:
- 从开发者角度,写完后在游戏内进行实际的测试
- 从用户的角度,等待用户进行bug反馈
自动打包/发布
这部分内容我在此之前没有任何经验,甚至不知道CI/CD
是什么。在开发tr的过程中,每当要我要发release的时候,都得做如下的操作:
- 修改编译参数为release模式以提高性能并屏蔽所有debug输出
- 重新编译整个文件
- 复制dll,配置文件,readme以及其他一些调试文件到某个空文件夹‘
- 压缩文件夹为压缩包
- 上传压缩包到github并写release note
这些步骤做一两次还觉得无所谓,但是重复次数太多后就觉得很烦且浪费时间,后来我我写了一个脚本自动化帮我完成3-4
两个步骤,但是我还是觉得编译和上传这两个步骤太麻烦。
后来我了解到了github action
这个东西,它能根据配置文件定义的工作流,在特定的时候自动完成1-5
的所有工作。配置文件大概长下面这个样子:
1 |
|
这个文件定义了从编译到发布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了多少内容,都会显示在项目主页上。因此作为维护者我们不应该顾及所谓的“人情”或者为了让项目看起来更加活跃而忽视代码检查,防止一粒老鼠屎坏了一锅粥。特意强调这个就是因为我自己吃了这个的亏,具体的就不细说,反正就当买教训了。