简介:基于Qt5版本的QtService服务库,是一份从Qt4的qt-solutions迁移而来的后台服务组件,面向需要用Qt编写Windows或Linux系统服务的C++开发者。它保留QtService原有接口,同时加入64位编译配置,解决了Qt5环境下常见的移植构建问题,适合有Qt基础、想快速集成服务能力的开发者。资源共52个文件,以C++源码(.cpp/.h)、Qt工程文件(.pro)、Visual Studio项目配置(.vcxproj)为主体,另含QDoc格式API文档、HTML帮助页及示例工程,压缩包仅96KB,体积轻量但结构完整。已有2457人学习下载,作者在多个实际项目中验证过稳定性,工程文件依照个人需求定制,读者可灵活调整编译选项;配合文档和示例,能快速理解服务封装逻辑,免去从零移植、编译踩坑的时间成本。
1. 从"把程序变成服务"说起:QtService解决的是什么问题
有段时间我一直在处理一个挺磨人的需求:手头有个基于Qt5写的后台数据采集程序,原本一直由人工启动、最小化到托盘运行,结果客户那边电脑重启后总是忘了开,数据就断档。最初想了个"土办法",把快捷方式丢进启动文件夹,结果客户机器上有时候会弹UAC,有时候启动顺序不对,反正就是不稳定。后来被逼得不得不去研究"正经做法"——把Qt程序做成Windows服务,让系统自己拉起它,登录不登录都照样跑。
系统服务这件事,本身并没什么新鲜的。Windows上有服务控制管理器(SCM),Linux上有systemd或者init.d,都是为了管理后台进程的生命周期。问题在于,一个跑QT事件循环的图形程序,要变成无界面的服务进程,中间有一大堆细节:服务如何注册、如何接收启动/停止命令、如何与SCM交互、如何处理没有桌面会话时的文件访问和日志。全手写的话,Windows下要处理SERVICE_TABLE_ENTRY、ServiceMain、HandlerEx、SetServiceStatus那一套Win32 API;Linux下要处理daemonize、pid文件、信号处理。一套项目要跨平台支持,等于要写两套底层,而且这两套代码的坑密度都还不小。
QtService正好把这一层封装掉了。它最早是Qt Solutions组件库的一员,后来在Qt 4时代被很多企业项目使用,到了Qt5虽然不再是官方发布包的一部分,但源代码仍然可用,而且因为接口稳定,不少老项目和新项目都在继续用。它的核心思路是:你用普通QApplication的方式写逻辑,继承一个QtService类,实现start和stop两个虚函数,然后库自己负责和操作系统的服务机制对接。安装、卸载、启动、停止、查询状态这些零碎操作,它通过命令行参数和QtServiceController全部封装好了,开发者的主要精力可以放在业务逻辑上。
说白了,QtService就是一个“胶水库”,把Qt的事件循环翻译成系统服务能听懂的语言。做完那个数据采集程序的服务化改造之后,我后来又在其他几个项目里用过这个库,逐渐攒了一些和Qt5配合使用的经验和坑。这篇文章就把这些内容梳理出来,适合已经有一定Qt基础、但没碰过服务开发的同学做参考。
2. 编译与集成:Qt5环境下获取源码和CMake手工接入
2.1 先搞清楚现在的源码状态
不少人在网上搜QtService,会找到一堆老链接,大多是Qt 4时代的产物,点进去下载回来的工程文件也是 .pro 格式,用的是qmake。需要先澄清一点:Qt官方很久不再把QtService作为正式发布组件了,官方仓库里的代码也长期处于"维护但低频"的状态。不过这并不意味着它就不能用,恰恰相反,它的接口非常稳定,从Qt4到Qt5再到兼容版本的Qt6过渡,核心接口基本没怎么变过。
想要拿到源码,通常有两个途径:一个是去GitHub上找Qt Solutions的历史镜像仓库,搜索qt-solutions-qt-service这种名字;另一个是用集成在Qt安装包里的旧版例子,有时候Qt安装目录自带了一部分补充组件。拿到源码之后,你会发现核心文件不算多:qt-solutions-qt-service/src目录下面主要有qtServiceBase.cpp/h、qtServiceController.cpp/h、qt_unixsocketserver等文件,Windows平台还依赖一个qtServiceUtils.pri或类似文件来定义平台相关的东西。整个库的体量不大,完全可以以源码形式塞进自己的工程,没必要非得编成独立动态库。
2.2 CMake手动集成的完整步骤
因为我这些年一直用CMake管理构建,所以没有走qmake的路线。下面这套接入流程我在Qt 5.12到5.15的工程里都验证过,可以直接抄作业。
第一步,把下载下来的QtService源码目录整个拷贝到项目third_party/qt_service/下面,保留src子目录。第二步,在CMakeLists.txt里新建一个静态库目标,把src里的.cpp文件加进去。需要注意,qt_unixsocketserver.cpp这个文件在Windows下不需要编译,它是Linux下用来做服务间通信的,条件编译的时候要排除掉。
set(QT_SERVICE_SRC ${CMAKE_CURRENT_SOURCE_DIR}/third_party/qt_service/src/qtServiceBase.cpp ${CMAKE_CURRENT_SOURCE_DIR}/third_party/qt_service/src/qtServiceController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/third_party/qt_service/src/qtservice_p.h ) add_library(QtService STATIC ${QT_SERVICE_SRC}) target_include_directories(QtService PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/third_party/qt_service/src) target_link_libraries(QtService PUBLIC Qt5::Core)第三步,在自己的可执行程序目标上链接这个静态库,并且需要在编译时定义一个宏。QtService的代码里有个关键开关,默认情况下它把main的入口也封装了,如果你的程序想自己控制main函数展开,就需要加上-DQT_SERVICE_EXPORT或者根据源码里的预编译条件调整。我的经验是,直接以源码方式编译时,把QT_NO_QT_SERVICE_CONTROLLER的定义去掉、确认QT_SERVICE_EXPORT相关宏按源码注释设置,避免重复定义main入口。
add_executable(MyDaemon main.cpp MyService.cpp) target_link_libraries(MyDaemon PRIVATE QtService Qt5::Core Qt5::Network) target_compile_definitions(MyDaemon PRIVATE QTSERVICE_MAIN)这里补充一句为什么要用静态库而不是直接编译成DLL:服务程序通常以标准用户身份安装到系统里,动态库的路径解析、依赖顺序都容易出问题,静态链接之后生成一个独立的exe,部署省心太多了。另外,QtService库本身只依赖QtCore,不会把你的可执行文件体积撑大多少。
2.3 编译时常见的几个报错
接入过程中我踩过的三个问题,基本可以覆盖大多数人会遇到的编译失败场景。
第一个,找不到Q_OBJECT或者元对象编译器报错。QtService的类里是有Q_OBJECT宏的,如果你在CMake里忘了加AUTOMOC,或者AUTOMOC没正确扫描到third_party目录,moc就不生成,链接的时候会报一堆undefined reference。解决办法是确保set(CMAKE_AUTOMOC ON),并且target_include_directories里包含src目录。
第二个,和Qt5自带的QLocalSocket/QLocalServer版本冲突。QtService库里为了做服务命令通信,在Windows下会用到命名管道,某些版本里它自己实现了一套socket封装,如果同一工程里又引入了QtNetwork里的QLocalSocket,偶尔会有符号重复。这个坑出现频率不算高,但一旦出现,可以先检查是不是同时引用了Qt5::Network和QtService里的socket源文件导致的,把重复文件从编译列表里移除即可。
第三个,Qt 5.15之后用高版本编译器编译,某些老代码没写nullptr转换。比如qtServiceBase.cpp里和字符串转换有关的地方,用C++17标准编译时可能报警告甚至报错。遇到这种问题,我都是直接小范围改源码,把reinterpret_cast补上,或者把老式QString::fromAscii改为QString::fromUtf8。
3. 理解服务生命周期:start/stop回调和事件循环的关系
3.1 QtService的类体系与职责分配
源码看多了之后会发现,QtService库的核心其实就三个角色。
第一个是QtServiceBase,这是服务类本身,负责封装操作系统服务控制逻辑,包括注册回调、处理SCM命令、维护状态。它的内部有一个ServicePrivate实现类,在Windows平台会进入ServiceMain函数,然后调用一个消息循环来等待控制命令。第二个是QtServiceController,这是一个独立的、专门和正在运行的服务进行通信的工具类,它不运行在服务进程内部,而是运行在外部进程里,例如控制台程序或安装程序。第三个角色是命令辅助入口,也就是库提供的QtService::exec()或者说main替代机制,它统一解析argc/argv里的服务控制参数。
理解了这个角色分配,再去看代码就很清晰了:你的服务进程内部,主要重写的是QtServiceBase那一侧;install、uninstall、start、stop这些控制命令,实际上是通过外部进程调用QtServiceController完成的。
3.2 回调函数里的代码到底什么时候跑
刚开始写服务程序的人最容易犯的错误,是把初始化逻辑一股脑塞进构造函数,然后把耗时任务扔给start()函数,最后发现服务启动超时被SCM杀掉。
QtService的流程是这样的:服务被SCM启动后,库内部先完成基础初始化,然后调用你重写的start()函数,start()返回之前,库会把服务状态标记为"正在启动",如果start()执行时间过长,SCM会认为服务启动失败。官方推荐的做法是start()里只做轻量的准备工作,然后立刻返回,真正耗时的工作放到一个新的线程,或者用Qt的事件队列异步执行。
class MyService : public QtService { public: MyService(int argc, char **argv) : QtService(argc, argv, "MyDataCollector") { setServiceDescription("Data collection background service"); setStartupType(QtServiceController::Automatic); } protected: void start() override { // 轻量启动,立即返回 workerThread = new QThread(); worker = new CollectorWorker(); worker->moveToThread(workerThread); connect(workerThread, &QThread::started, worker, &CollectorWorker::run); workerThread->start(); } void stop() override { // 通知线程安全退出并等待 worker->requestInterruption(); workerThread->quit(); workerThread->wait(3000); } private: QThread *workerThread = nullptr; CollectorWorker *worker = nullptr; };千万要记住,stop()里也不能做太长时间的清理逻辑,SCM等待服务停止也是有超时时间的,默认一般是30秒,但生产环境里配较短超时很常见。如果stop()阻塞太久,服务会被SCM标记为"停止失败",短时间内无法重启。
3.3 服务进程里的QApplication事件循环
再有就是我之前一直没想明白的一点:服务程序到底要不要QApplication?答案是有的,而且必须有。
QtService底层会创建一个QCoreApplication的实例,在Windows服务环境里,它不是QApplication,因为没有图形界面,也不需要QApplication。这意味着,你在服务里不能直接用QWidget相关的东西,凡是依赖QGuiApplication的模块也都要避开。初始化的时候,库内部创建的是QCoreApplication,并保留对它的引用,所以你在start()函数里可以通过QCoreApplication::instance()拿到应用实例,然后正常连接信号槽、使用QTimer、使用QNetworkAccessManager,这些都依赖Qt的事件循环。
事件循环在服务里是通过exec()进人主循环的。也就是说,QCoreApplication::exec()或者说service.exec()一旦进入,服务进程就持续运行,直到收到停止命令。这一点和普通Qt GUI程序非常相似,区别只是没人会看到任何界面。
4. 安装、卸载与调试:命令行参数的隐藏玩法
4.1 服务控制命令的完整清单
QtService的代码里内置了一套命令行约定,你不需要自己写参数解析器。以编译出的MyDaemon.exe为例,常见的调用方式如下:
# 安装服务,注册到SCM,并设置为自动启动 MyDaemon.exe -install # 卸载服务 MyDaemon.exe -uninstall # 启动服务(要求服务已安装) MyDaemon.exe -start # 停止服务 MyDaemon.exe -stop # 暂停服务 MyDaemon.exe -pause # 继续运行暂停的服务 MyDaemon.exe -resume # 查询服务状态 MyDaemon.exe -status这些命令本质上是通过QtServiceController去和SCM通信的,和你的服务进程是不是在运行没有关系。比如你执行-install的时候,服务程序会被当作控制程序来运行,它不会进入服务主逻辑,只是调用SCM的API把服务注册信息写入注册表。
开发的时候,还有一个特别有用的参数:-debug。带上这个参数启动,服务不会真正注册到SCM,而是以前台进程的模式运行,控制台窗口会保留,直接输出日志。这个模式对排查初始化崩溃、路径问题非常有用。
4.2 服务参数的存储位置
小细节:QtService在安装服务的时候,会把命令行参数里的可执行文件路径记录下来。Windows服务注册表里,ImagePath这一项的值是一个带引号的完整路径,例如"C:\Program Files\MyApp\MyDaemon.exe"。如果程序目录里还有需要访问的配置文件,服务默认的工作目录很可能不是这个exe所在目录,而是系统目录,所以程序内部不能依赖相对路径。
关于服务启动类型、服务描述、显示名称,可以在服务类的构造函数里配置:
MyService::MyService(int argc, char **argv) : QtService(argc, argv, "MyDataCollector") { setServiceDescription("Data collection service"); // Automatic:开机自动启动;Manual:手动 setStartupType(QtServiceController::Automatic); }Windows下可以通过sc qc MyDataCollector命令查看配置是否生效,也可以直接用sc start MyDataCollector、sc stop MyDataCollector来快速测试,不一定非要每次都用QtServiceController。
4.3 调试模式下要注意的差异
我说句实在话,服务程序最坑的不是写代码,而是老大难问题"本地调试好好的,一进服务就崩"。
因为服务进程没有交互式桌面,权限上下文、环境变量、工作目录全部和普通双击运行不一样。用-debug模式能解决一部分问题,但它模拟不了SCM传入的身份和Session隔离。比较务实的调试策略是:先用-debug模式把业务逻辑跑通,再安装成服务,配合Windows事件查看器和自定义日志文件做现场排查。不要试图在Visual Studio里F5直接调试已安装的服务,那个流程很痛苦,我试过几次,还是日志法最有效。
5. 服务场景里的三座大山:路径、身份与日志
5.1 工作目录不是你想的那样
普通双击程序,工作目录默认是启动位置的目录。但在Windows Service里,SCM启动服务进程时,工作目录几乎总是C:\Windows\System32,这是无数服务化程序的第一个坑。你在代码里用什么QFile("config.ini"),它会去System32目录下找。解决办法只有一招:绝对路径。在main函数很早的时候,根据可执行文件路径拼出程序目录,然后统一封装一个ConfigManager:
QString appDir = QCoreApplication::applicationDirPath(); QString configPath = appDir + "/config.ini";注意,QCoreApplication::applicationDirPath()返回的是exe所在的路径,这个路径在服务进程和普通进程下拿到的是同一个值,所以用它作为基准是最稳的。
5.2 Session 0隔离带来的权限变化
从Vista开始,Windows引入了Session 0隔离,服务进程运行在Session 0,普通用户登录桌面的进程运行在Session 1及以后。这带来的直接后果是:服务进程无法直接显示UI、无法访问用户桌面上的文件(除非路径写死且权限放开)。QtService本身不管这些,它只是确保服务能运行,但业务代码里一旦涉及特定用户的目录,例如C:\Users<用户名>\AppData,就得评估权限。
有个常见的做法是让服务以LocalSystem身份运行,这样权限很大,基本上什么目录都能读,但随之而来的是安全隐患,如果业务逻辑里有网络监听,被攻破就等于整个机器沦陷。另一个做法是用专门的域用户账号或服务账号运行服务,然后在代码里通过QFileInfo::setPermissions或显式ACL设置目录访问权限。我的建议是:在Windows服务里尽量把数据读写集中在ProgramData或者自己创建的数据目录,不要和服务进程账号的HKCU较劲。
补充一句Linux平台的情况,QtService在Unix-like系统上通过写pid文件、捕获SIGTERM信号来模拟服务控制,行为上和Windows差异不小,但好在权限模型相对清晰,大多是文件权限问题,不涉及Session隔离。
5.3 日志问题的务实解法
服务程序不能像普通程序那样把日志输出到stdout骗自己,除非你用-debug模式。生产运行下,SCM不会为你保留任何标准输出。这时候有几种选择:第一种是QtService本身提供日志重定向能力,在构造函数里setLogFile("C:/ProgramData/MyApp/service.log"),它会定期把qDebug、qWarning写到文件里。第二种是自己封装一个文件日志Logger,每次写日志前按日期滚动。第三种是接第三方日志库,比如spdlog或QsLog。
三种我都用过,我的偏好是:业务日志用spdlog,写滚动文件,设置最大大小和备份数;QtService自己的日志层留空,只在非常早期初始化崩溃时用。原因很简单,QtService的日志重定向实现比较基础,不支持格式化、不支持分级、没有滚动,长期跑会产生一个巨大的文件。
如果服务崩溃在启动阶段,日志文件又没来得及被spdlog创建,那QtService的setLogFile就是你最后的救命稻草,它能记录到库内部的初始化事件。所以哪怕最终不用它记录业务日志,也建议设置一个基础日志路径。
6. 那些我至今还在用的排错流程和检查清单
服务开发做得多了,我形成了自己的一套固定排查流程,遇到问题先按顺序过一遍,省了不少诊断时间。
第一,确认服务有没有真的安装。执行sc query MyDataCollector,如果返回的服务状态是1060错误或者其他提示,说明SERVICE_NAME写错了。检查注册表HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\MyDataCollector,看看ImagePath路径是否完整。
第二,确认程序是不是真的转起来了。Windows事件查看器里,Windows日志-系统,来源为Service Control Manager,里面会有服务的启动/停止记录,包括错误码。如果启动失败,错误码通常能直接定位问题,比如操作系统找不到指定路径,十有八九是ImagePath错了;服务在启动期间崩溃,错误码后面经常跟着异常模块。
第三,打开配置文件审计。在-debug模式下加上--log-verbose之类的参数,让程序在启动早期就输出当前工作目录、配置文件路径、日志文件路径、当前用户名。这几条信息至少能排除掉一半的环境问题。
第四,验证服务账号有没有文件访问权限。这一步最容易被忽略。在服务配置里临时切到LocalSystem跑一次,如果程序就能正常运行,那基本是文件权限问题而不是代码逻辑问题。
做完这些还解决不了,那就只能加日志重编译了。这很痛苦,但服务开发和普通程序开发不同,远程调试的成本极高,所以日志从一开始就要当作第一优先级来做。我现在的习惯是,服务程序的启动日志必须含时间戳和功能模块名,第一时间能看出走到了哪一行。
如果让我给一个基于Qt5版本的QtService项目做个总结性建议,那就是:不要试图让它做超越服务范畴的事情,不要依赖桌面GUI,不要对工作目录做任何假设,把所有配置文件的基准目录都用applicationDirPath拼出来,日志按天滚动,启动和停止回调保持轻量。做到这几条,这个库用起来会非常顺手。
本文还有配套的精品资源,点击获取