65.9K
CodeProject 正在变化。 阅读更多。
Home

简单的C++服务类框架

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.82/5 (6投票s)

2014年6月11日

CPOL

11分钟阅读

viewsIcon

36814

downloadIcon

2317

用于编写Windows服务的简单C++类框架。

引言

Windows 服务是构成许多软件解决方案后盾的后台程序。它们可以在操作系统启动时,用户登录之前启动,并可以一直运行到操作系统关闭。服务程序是作为服务运行的程序,它们由操作系统通过服务控制管理器(SCM)进行管理。服务程序的结构和控制流与普通的用户启动程序不同,这常常对许多初级开发者构成挑战,他们因此会复制平台 SDK 中提供的服务程序示例代码来编写自己的服务。而且,这个过程经常会为每个服务程序重复进行,这不是一种理想的软件工程实践。本文介绍了一个简单的 C++ 类,它抽象了典型服务程序的结构和行为,从而允许代码重用,而无需诉诸于复制粘贴的循环。

背景

如前一节所述,由于服务程序的生命周期和控制流不同,其结构与普通的用户启动程序略有不同。Microsoft 提供了一套特定的 API,服务程序需要与这些 API 接口以向 SCM 注册自身为一个服务。C# 中提供了面向对象的框架,但在 C++ 中没有等效的。CodeProject 上有一个 PJ Naughter 的流行类,CNTService,它提供了类似的框架,但它与 MFC 耦合。一些服务程序可能不希望添加 MFC 的额外负担,在某些情况下,公司政策可能会限制使用 MFC 等库。

本文介绍了一个纯 C++ 编写的精简框架,其中包含了一些在典型 Windows 服务程序中常用的模式的代码。该类及其方法被刻意保持最少,并且在过去 10 多年里帮助我编写了许多服务程序。它面向新手程序员,但有经验的开发者也可能会觉得它有用。

Using the Code

该类框架主要包含两个文件

  • conslsvc.h
  • logfmwk.h

前者声明了模板类 TConsoleService<>,它实现了服务程序逻辑,并且是客户端类(代表服务程序)可以从中派生的基类。该模板接受一个类型参数,即用于内置日志记录机制的文本日志类。派生类的全局实例构成了您的服务程序。logfmwrk.h 声明了几个类,它们被 TConsoleService<> 使用,以提供简单的文本文件日志记录机制。请注意,此日志记录与 Windows 事件日志框架不同。包含文本日志基础设施是因为我处理的许多服务都需要某种简单的文本日志记录机制。

该框架提供了以下主要功能

  • 一个简单的服务程序,除了 Win32 和 STL 外没有其他依赖项。可以在 Visual Studio 中将程序构建为控制台 Win32 程序。
  • 允许服务程序作为普通程序从命令行运行的设施。这允许轻松调试周期,因为服务程序可以直接从 Visual Studio 启动并作为控制台程序运行。
  • 一个可选机制,用于将程序状态消息写入文本日志文件。日志文件支持按会话自动滚动。

要使用代码,请在程序的 main.cpp 文件中,按如下方式声明一个类

class MyServicePrgram : public TConsoleService<FileLogger>
{
    typedef TConsoleService<FileLogger> baseClass;

public:
    MyServicePrgram():
        : baseClass(L"myservice")
    {}
    virtual DWORD run()
    {
        // Do your own service initialization here
        // If initialiation is a lengthy process (>30 seconds), 
        // do it in a  worker thread which can be spawned here. 

        // Base class waits for the quit event, a Win32 event.
        // If you want you can bypass this and have your own
        // wait routine which can also wait on other handles,
        // using, for example, WaitForMultipleObjects().
        DWORD dwRet = baseClass::run();

        // Service has been terminated. Do your service's 
        // deintialization here.
        // Again remember to use worker threads if this takes
        // more than 30 seconds.

        // a return value that will be propagated back to SCM
        return dwRet;
    }
};

// The ConsoleService class non-const static member
ConsoleService* ConsoleService::s_pProgram = 0;

// Declare an instance of the service program. Note that there 
// can be only one instance of TConsoleService<> object per module!
MyServicePrgram _service;

// in the main function call the ConsoleService<>::start() method
extern "C" int WINAPI _tmain( int argc, TCHAR* argv[] )
{
    return _service.start();
}

在此示例中,派生类 MyServiceProgram 的实例就是您的服务程序。在程序主函数中,通过调用派生类实例上的 TConsoleService<>::start() 方法将控制权转移给服务程序。TConsoleService<> 负责必要的 SCM 管道操作,例如注册自身和相关的控制处理程序。

控制处理程序

控制命令是 SCM 发送给服务程序的命令,用于指示操作系统中发生的各种事件。这些也包括让服务停止或暂停自身的命令。每个控制命令都映射到基类中相应命名的虚拟方法。派生类可以重写这些方法,当从 SCM 收到命令时,将调用派生方法。以下是目前支持的控制命令方法

  • onStop() - SERVICE_CONTROL_STOP
  • onPause() - SERVICE_CONTROL_PAUSE
  • onContinue() - SERVICE_CONTROL_RESUME
  • onInterrogate() - SERVICE_CONTROL_INTERROGATE
  • onShutdown() - SERVICE_CONTROL_SHUTDOWN
  • onDeviceEvent() - SERVICE_CONTROL_DEVICEEVENT
  • onHaredwareProfileChange() - SERVICE_CONTROL_HARDWAREPROFILECHANGE
  • onSessionChange() - SERVICE_CONTROL_SESSIONCHANGE
  • onPowerEvent() - SERVICE_CONTROL_POWEREVENT
  • onPreShutdown() - SERVICE_CONTROL_PRESHUTDOWN
  • onUnknownRequest() - 对于上面未涵盖的所有其他控制代码

请注意,某些控制代码(因此它们对应的处理程序方法)仅在某些 Windows 版本中可用。在基类源代码中,这些代码使用 _WIN32_WINNT 版本宏进行条件声明。您可以参考MSDN 文档了解控制代码的详细信息以及每个控制函数参数的含义。此外,Microsoft 在几乎每个主要 Windows 版本中都添加了新的控制命令。在 Windows 7 和 8 中,向上述列表添加了新的控制命令。如果您需要处理一个新命令但上述列表不支持,您可以扩展 switch 块来添加自己的处理程序。

该类的设计可能表明,要接受额外的控制代码,所需做的就是重写 TConsoleService<> 基类的相关方法。虽然这对于某些控制命令是正确的,但对于其他命令,您需要做更多的工作。这是因为 SCM 仅在服务程序表达了对这些命令的兴趣时才发送某些控制命令。服务程序通过在 SERVICE_STATUS.dwControlsAccepted 成员中指定所需的标志来做到这一点。默认情况下,服务程序仅注册以接受 SCM 的 SERVICE_CONTROL_STOP 控制命令。

一个示例是 onShutdown() 通知。要接收关机通知,服务程序必须在 SERVICE_STATUS.dwControlsAccepted 成员中指定 SERVICE_ACCEPT_SHUTDOWN 标志。理想的执行方式是重写 ConsoleService::run() 方法,并在将服务状态切换到 STATE_RUNNING 之前指定附加标志。以下代码片段显示了如何做到这一点。

DWORD run()
{
    // specify flags for additional control commands we accept
    status_.dwControlsAccepted = SERVICE_ACCEPT_SHUTDOWN;

    // call base class method to block on the quit event
    DWORD dwRet = ConsoleService::run();

    // service is stopping, do additional unwind logic here
    return dwRet;
}

请注意,当服务退出 SERVICE_START_PENDING 状态时,SERVICE_ACCEPT_STOP 标志将自动添加到 TConsoleService<>::setServiceStatus() 中。这使得 TConsoleService<>::setServiceStatus() 看起来有点笨拙,但它是一个简单有效的解决方案,可以满足干净设计的要求,即在服务进入 SERVICE_STATE_RUNNING 状态之前,它不应接受任何额外的控制命令。

文本日志记录

如前所述,该类框架还包含一个内置机制,用于将消息写入文本日志文件,该文件独立于 Win32 事件日志机制。此日志记录机制是可选的,并通过类模板参数控制。为此目的提供了两个日志类-- NullLoggerFileLogger。前者将所有日志消息定向到null设备(换句话说,丢弃所有日志消息),而后者将消息写入文本文件。默认情况下,此文本文件的名称与作为 TConsoleService<> 构造函数参数指定的服务名称相同,但带有 .log 扩展名。但是,可以通过重写 TConsoleService<>::getLogFilename() 方法来更改此名称。根据您的服务需求,您可以使用这两个类中的任何一个,或者如果您想将日志消息重定向到完全不同的介质,您可以创建自己的 Logger 类特化,并将其作为类模板参数提供给 TConsoleService<>

日志框架包含两个类

  • Logger - 负责实际的日志记录工作。一个抽象基类,需要为每个日志输出介质进行特化。
  • LogWriter - 负责组合日志消息并将其发送到附加的记录器。客户端使用此类来组合和写入日志消息。

这里没有魔法——这是经过验证且易于使用的可靠日志框架设计。

LogWriter 是编写日志消息的主要接口。每个 LogWriter 实例都可以被分配一个文本标签(作为构造函数参数指定),使用该实例写入的所有消息都将以该文本标签为前缀。这使得属于某个组件的消息易于识别和分组。该类还提供了基于 C++的接口来写入消息。接口是传统 printf(...) 风格日志记录的一种类型安全替代方案,因此能提供更好的运行时安全性。

框架设计基于这样一个理念:服务中的每个类都实例化一个 LogWriter,并使用该实例将消息写入日志文件。这允许为每个实例分配单独的标签,有助于识别生成日志消息的内部模块。通常,我将类名或非常接近的名称作为标签。例如,在附带的示例代码中,使用 lw_ 写入的所有消息都将以 "svcapp" 标签为前缀。

class MyService : public TConsoleService<FileLogger> {
    typedef TConsoleService<FileLogger> baseClass;
public:
    MyService()
        : baseClass(L"myservice")
        , m_lw(L"svcapp", getLogger())
    {}
...
private:
    LogWriter lw_;
};

通过以下方法提供了用于写入消息的接口

class LogWriter {
    ...
public:
    template<class charT>
    TSafeWriter<charT> getStream(int level);

    TSafeWriter<wchar_t> getStreamW(int level); 

    TSafeWriter<char> getStreamA(int level); 
    ...
};

TSafeWriter<>std::basic_ostringstream<> 的一个特化,因此支持 std::ostringstream 支持的所有方法和操作符。getStream 及其变体的唯一参数是附加到消息的经典日志级别。所有 LogWriter 实例都维护对应用程序范围内的 Logger 实例的引用,并且 Logger 实例存储当前的日志级别。更高级别的传入消息不会写入目标介质。声明了几个标准的预定义日志级别作为 Logger 类常量。但由于它是纯整数,客户端类可以自由使用最适合其需求的任何级别。

有了这些设置,日志消息就可以这样写:

lw_.getStreamW(LOG_LEVEL_INFO) << L"Error registering device notification, rc: " << ::GetLastError() << L"\r\n";

请注意,TSafeWriter<> 是用私有构造函数声明的。这是一个故意的设计决策,以防止客户端将对象存储在左值中。这是必需的,因为写入日志流的消息是缓冲的,并且是从返回对象的析构函数中写入输出介质的。使用上面的代码,构造发生在语句行的开头,对象销毁发生在整个语句执行完毕并且在执行下一条语句行之前。如果没有这样做,客户端可以编写类似的代码:

{
   TSafeWriter<wchar_t> ls = getStreamW(100);
   ...
   ls << "Log message 1\r\n";
   ...
   ls << "Log message 2\r\n"; 
   ...
}

虽然上面的代码没有问题,但消息直到 ls 离开作用域时才会被写入输出介质。这会带来多方面的问题。

首先,在多线程系统中,执行顺序可能是这样的:在输出“Log message 1”和“Log message 2”之间,CPU 上下文切换到了另一个线程,该线程也打印了自己的日志消息。由于消息直到局部变量被销毁才会被写入,因此消息写入的顺序将无法反映程序线程的执行顺序。即使在单个语句中的日志消息被组合时,也可能发生这种情况。但由于我们将每条日志语句视为一个原子信息单元,因此日志消息将按其自然线程执行顺序写入。其次,程序可能在消息 1 和消息 2 的打印之间遇到严重错误,导致程序中止。在这种情况下,从日志消息中无法清楚地知道错误发生的具体语句,因为块中的任何消息都尚未写入。

采用具有单语句生命周期的日志流对象可以很好地解决这些问题。如果您想知道 getStream() 如何返回 TSafeWriter<> 实例,它被声明为 TSafeWriter<> 的友元,因此拥有访问 TSafeWriter<> 构造函数的必要权限,因此可以实例化它。

关注点

在原始设计中,TConsoleService<> 是一个纯类,其中日志记录系统是硬编码的。也就是说,它为它创建的每个服务程序都会自动创建一个日志文件。然而,随着类使用场景的演变,某些服务不需要日志文件。即使是创建一个空文件也是不可接受的。因此,该设计被更改为用户可以指定是否创建日志文件(作为构造函数参数)。即使这样也发现不够,因为日志记录仍然受限于预设的介质。因此,最终将此模式抽象出来,并将服务框架设计为模板类,以便可以将所需的日志模式指定为模板参数。这是另一个例子,说明 C++ 的模板功能是如何为代码重用提供一种有效方法的。

历史

  • 2014年6月10日 - 首次发布
  • 2014年6月17日 - 添加了演示解决方案,该解决方案使用框架构建了一个示例服务。此文件在初始版本中未能正确上传。
© . All rights reserved.