Task Scheduler 在 Windows Vista® 中得到了彻底的革新。尽管有一些相似之处,但新的 Task Scheduler(称为 Task Scheduler 2.0)比原来的工具(自 Windows® 98 起便已存在)要强大许多。它不再仅仅是一个供最终用户使用的工具,而是一个用于设计和管理复杂后台操作的强大平台——甚至在很多情况下,它可以避免对 Windows 服务进行开发。
假设您的项目需要自动检查更新。您可以考虑编写一个在后台运行的 Windows 服务,每隔几天就会检查是否有更新。如果服务不是必须全天候运行,那么可以设计一个计划任务,每隔几天才运行一次,检查是否有更新,然后停止。更好的是,您可以确保它只在用户登录后才运行,这样没有人执行更新时就不会不必要地消耗资源。
Windows Vista 的很多部件都用新的 Task Scheduler 来管理后台任务,这一简单事实证明了 Microsoft 开发人员认为它会大有作为。另外一个好处是减少了原本在处理所有这些任务时必然要创建的新服务数量。同时,它还可以简化 Windows 管理和诊断,因为通过询问任务来了解它在做什么比找出黑盒子 Window 服务在忙什么要简单得多。对于本身就构成 Windows 一部分的任务,Task Scheduler 也充当任务主机,将许多任务特定的进程合并成一个,从而进一步减少承载这些操作所需的资源。
在本专栏中,我将探讨主要概念和组成 Task Scheduler 的构造块,以便您尽快上手并立即开始从这个出色的新服务受益。

 

任务服务和存储
故事还得从 Task Scheduler 服务说起,该服务负责对任务进行实际计划。由于 Task Scheduler 使用文件系统来存储任务信息,所以可以在没有服务帮助的情况下枚举和准备任务,但是在没有服务的情况下不可能坚持很久。未来版本的 Task Scheduler 确实可能会在很大程度上改变存储格式和位置,因此建议不要指望它。实际上,不依赖于直接访问存储这一点很关键,因为 Task Scheduler 不能识别直接写入文件系统的任务,并将可能拒绝运行已被篡改的任务。既然 Task Scheduler 是 Windows Vista 如此不可或缺的一部分,管理员就不再可能只是禁用它。这对于开发人员来说是一个好消息,因为它意味着您可以安全地依赖它来执行后台操作,并将任务存储抽象化。
按照惯例,任务会根据公司、产品和组件名称进行分组。例如,Windows 磁盘碎片整理程序将其任务存储在 \Microsoft\Windows\Defrag 子文件夹中。每个任务都存储为由 Task Scheduler 架构定义的 XML 文档。然后即可使用诸如 XmlLite 的 XML 分析器直接创建任务(请参阅我以前的《MSDN® 杂志》文章,位于 msdn.microsoft.com/msdnmag/issues/07/04/Xml/),或使用大量由 Task Scheduler API 提供的扩展 COM 接口。这些 COM 接口还允许您直接提供和查询任务的 XML 定义。这样,您就可获得以下好处:使用 XML 编写和查询任务,同时避开任务的实际存储。Task Scheduler 还可以远程访问。

 

使用任务服务
ITaskService 接口是通向 Task Scheduler API 的网关。您将需要的所有定义都位于 taskschd.h 头文件中。当然,您也会需要最新的 Windows SDK,其中包含对 Windows Vista 的支持。首先,创建一个本地的进程内实例,如下所示:
CComPtr<ITaskService> service;
HR(service.CoCreateInstance(__uuidof(TaskScheduler)));
(在这些示例中,我使用 HR 宏来明确标识方法返回需要被检查的 HRESULT 的位置;这可以替换为适当的错误处理方法——不管是引发异常还是返回 HRESULT 自身都可以。)
接下来,调用 Connect 方法,与您所选计算机上的任务计划程序建立连接。Connect 方法的定义如下:
HRESULT Connect(
    VARIANT computer, VARIANT user, 
    VARIANT domain, VARIANT password);
虽然 VARIANT 类型的使用令人遗憾,但是在使用方便的活动模板库 (ATL) CComVariant 派生类方面,处理起来却非常容易。要连接到本地计算机,只要省略计算机名称即可。要使用调用方的身份或有效的令牌进行连接,则不要指定用户名。要使用本地计算机的当前域,则不要指定域名。最后,如果指定了用户名,则仅指定一个密码。因此,调用最简单形式的 Connect 方法(如下所示)即可连接到本地计算机的 Task Scheduler 服务:
HR(service->Connect(
    CComVariant(),   // local computer
    CComVariant(),   // current user
    CComVariant(),   // current domain
    CComVariant())); // no password
现在便可执行各种不同的操作,例如创建任务和枚举已有的文件夹和任务。因为在将任务存储到文件夹之前无法使用它们,因此第一步是要了解 ITaskFolder 接口。ITaskService 的 GetFolder 方法会为指定文件夹返回一个 ITaskFolder 接口指针。反斜杠表示根任务文件夹,如下所示:
CComPtr<ITaskFolder> folder;
HR(service->GetFolder(CComBSTR(L"\\"), &folder));
然后可以为您的应用程序创建一个文件夹,如下所示:
CComPtr<ITaskFolder> newFolder;
HR(folder->CreateFolder(
    CComBSTR(L"Company\\Product"),
    CComVariant(), &newFolder));
CreateFolder 可选的第二个参数为新创建的文件系统文件夹指定安全描述符。如果省略这一步,该文件夹便从它的父项继承安全描述符。您可以使用安全描述符定义语言 (SDDL) 覆盖默认的访问控制。例如,为了向管理员和本地系统提供完全控制,可以使用以下 SDDL:
HR(folder->CreateFolder(
    CComBSTR(L"Company\\Product"),
    CComVariant(L"D:(A;;FA;;;BA)(A;;FA;;;SY)"),
    &newFolder));
简单地说,D: 表明后面的访问控制项 (ACE) 可以作为自定义访问控制列表 (DACL) 的组成部分。第一项表明允许访问 (A),并且将文件全部 (FA) 授予内置管理员 (BA)。第二项表明将相同的访问权限授予本地系统 (SY)。请记住,安全描述符定义只用于创建 Product 文件夹,而父文件夹 Company 仍将使用继承的安全描述符来创建。同时确保您不会拒绝访问 Local System 帐户,因为那是 Task Scheduler 服务使用的身份,当计划程序无法访问其应该计划的任务时肯定会发生意外行为!
给定一个已有的文件夹,您可以使用 ITaskService 的 GetFolder 方法来打开相对于根任务文件夹的文件夹。注意,ITaskFolder 会提供同一个方法来打开相对于该接口实例所代表文件夹的文件夹:
CComPtr<ITaskFolder> productFolder;
HR(service->GetFolder(
    CComBSTR(L"Company\\Product"),
    &productFolder));

CComPtr<ITaskFolder> componentFolder;
HR(productFolder->GetFolder(
    CComBSTR(L"Component"),
    &componentFolder));
图 1 阐明了本专栏下载随附的 Task Scheduler Explorer 示例应用程序。它可供您方便地查看任务,并且在开始使用任务计划程序 API 时很有帮助。
C++  TaskScheduler msdn杂志_xml
图 1 Task Scheduler Explorer (单击该图像获得较大视图)

 

任务定义
在创建任务之前,需要根据任务定义对其建模。图 2 说明了组成任务定义的各个部分。由于任务定义的复杂性及其在创建任务时的必要性,所以我建议不要立即开始创建任务。首先了解一下基本构造块会有所帮助。在开始深入讨论定义操作和触发器的具体细节之前,让我们简单了解一下任务定义的构成和创建方式。

C++  TaskScheduler msdn杂志_xml_02  Figure 2 任务定义组成部分

任务 定义
操作 一个或多个操作定义了任务将执行的工作。
触发器 一个或多个触发器指示何时启动任务。
主体 授权和审核是基于安全上下文的。
设置 这些设置控制任务的运行时行为和限制。
数据 可供操作使用的字符串。
RegistrationInfo 管理记帐信息。
首先,要求 Task Scheduler 服务创建一个可以填充的空任务定义:
CComPtr<ITaskDefinition> definition;
HR(service->NewTask(0, // reserved
                    &definition));
在特定文件夹中注册任务定义之前,需要对它填充至少一个操作,以及可选的触发器和其他信息。准备好之后,就可以使用 RegisterTaskDefinition 方法在文件夹中注册它,如下所示:
CComPtr<IRegisteredTask> registeredTask;
HR(folder->RegisterTaskDefinition(
    CComBSTR(L"Task"),
    definition,
    TASK_CREATE_OR_UPDATE,
    CComVariant(), // user name
    CComVariant(), // password
    TASK_LOGON_INTERACTIVE_TOKEN,
    CComVariant(), // sddl
    &registeredTask));
系统会返回一个 IRegisteredTask 接口指针,代表新注册的任务。此接口可用于启动或停止任务,以及检索与任务有关的运行时信息。
RegisterTaskDefinition 的第一个参数指定新任务的名称。由于这个值也可用来命名保存在文件夹中的文件,因此它必须符合文件系统的命名约定。此参数可以设置为空值,该方法会生成一个 GUID 以用作名称,但是不建议这么做,因为对计算机的管理员来说,这没有什么意义或帮助。第二个参数指定描述即将创建的任务的任务定义。我将在下面的部分中深入探讨更多细节。第三个参数指定 TASK_CREATION 枚举中的一个值。最常见的值是 TASK_ CREATE 和 TASK_UPDATE,而 TASK_CREATE_OR_UPDATE 只是两者的位或。
接下来的三个参数指定注册凭据。RegisterTaskDefinition 将确保指定的用户对已注册任务至少有读取权限,即使您在随后的参数中指定的 DACL 没有特别对其授权。Task Scheduler 支持多种不同的登录技术。前一个示例使用 TASK_LOGON_INTERACTIVE_TOKEN,表明只有在为指定的用户给定交互式登录会话时才运行任务。如果未提供用户名和密码,它就会假设调用方的身份。因为要求进行交互式登录会话,所以不会存储密码。
TASK_LOGON_PASSWORD 是另一个选择,它指示 Task Scheduler 为任务创建一个批处理登录会话。在这种情况下,必须同时提供用户名和密码,并且必须授予该帐户“作为批处理作业登录”特权。
还有一个选择是 TASK_LOGON_S4U,它提供了更为安全的方法。它利用 S4U (Service-for-User) 登录,代表指定用户运行任务,但是不需要存储密码。由于 Task Scheduler 在本地系统帐户内运行,所以它可以创建 S4U 登录会话,并接收不仅可用于标识、而且可在本地计算机上进行模拟的令牌。通常 S4U 令牌仅适用于标识。
除了在注册任务时提供的凭据以外,IPrincipal 接口还可进一步控制将针对任务而提供的安全上下文。特别是,对于为任务承载任务计划程序引擎的进程而言,IPrincipal 提供了一个控制其执行级别的简便方法。首先,查询用于相关主体对象的任务定义,然后根据需要设置 RunLevel 属性。这一步需要在注册任务定义之前完成。以下是一个示例:
CComPtr<IPrincipal> principal;
HR(definition->get_Principal(&principal));
HR(principal->put_RunLevel(TASK_RUNLEVEL_HIGHEST));
TASK_RUNLEVEL_HIGHEST 表明应为特定的用户在最不具限制性的安全上下文中运行任务。假设用户是管理员,它会为进程提供不受用户帐户控制 (UAC) 限制的令牌。另一个选择是 TASK_RUNLEVEL_LUA,它提供一个受限制的,或未经提升的令牌。

 

任务操作
Task Scheduler .NET
尽管 不存在用于 Task Scheduler 的 Microsoft® .NET Framework 包装,但是因为有一种称为运行时可调用包装 (RCW) 的公共语言运行库 (CLR) 功能,因此它还是非常易于使用的。
既然 Task Scheduler 提供了易于进行脚本编写的 COM 接口,因此不费吹灰之力便可使用托管代码中的 API。只需使用 Visual Studio®“添加引用”对话框来引用类型库即可。由于 Task Scheduler 2.0 的类型库命名为 TaskScheduler1.1 Type Library,所以会产生一点误解。不过,Visual Studio 会创建一个互操作程序集,该程序集会提供 COM 接口的所有托管定义,因此用 RCW 便可直接在托管代码中构造和引用不同的 COM 对象。
下面是一个示例,它阐明了本专栏第一部分中代码示例的 C# 同等功能代码,以及连接到 Task Scheduler 服务并为您产品的任务创建一个文件夹:
ITaskService service = new TaskSchedulerClass();
service.Connect(null, // local computer
                null, // current user
                null, // current domain
                null); // no password

ITaskFolder folder = service.GetFolder("\\");
ITaskFolder newFolder = folder.CreateFolder(
    "Company\\Product", "D:(A;;FA;;;BA)(A;;FA;;;SY)");
从这里开始,您可以继续创建任务定义、操作、触发器等等,完全以您喜欢并且针对 .NET Framework 的语言进行。
在为下一步工作进行了铺垫之后,让我们来了解一下任务计划程序所提供的操作类型的多样性。用 IActionCollection 接口指针(由任务定义的 Actions 属性返回)可创建任务并将其添加到任务定义中:
CComPtr<ITaskDefinition> definition;
HR(service->NewTask(0, // reserved
                    &definition));

CComPtr<IActionCollection> actions;
HR(definition->get_Actions(&actions));
IActionCollection 提供 Create 方法,可创建新的操作对象并将其添加到集合中。这些操作本身可通过派生自 IAction 的接口来公开。以下是相关的示例:
CComPtr<IAction> action;
HR(actions->Create(TASK_ACTION_EXEC, &action));
TASK_ACTION_EXEC 是迄今为止最为常见的操作类型,它说明一种命令行操作,并可用于启动所有类型的程序。它甚至可以启动文档和通过 Windows 外壳注册的其他文件类型。要设置操作特定的属性,需要先查询类型特定的接口,然后 CComQIPtr 会妥善处理这项工作:
CComQIPtr<IExecAction> execAction(action);
假如有一个 IExecAction 接口指针,现在即可将操作配置为运行您选择的命令。下面是一个示例,它启动 Sysinternals Contig 工具,对我的虚拟硬盘驱动器映像进行碎片整理:
HR(execAction->put_Path(CComBSTR(L"g:\\Tools\\contig.exe")));
HR(execAction->put_Arguments(CComBSTR(L"-s g:\\VirtualMachines")));
尽管运行一个命令对管理员来说并不麻烦,但是对于开发人员来说这并不总是最合适的解决方案,因为命令并不知道它在任务中所代表的操作,也不知道它甚至是计划任务的一部分。庆幸的是,Task Scheduler 提供了另一种操作类型,它旨在解决此问题。TASK_ACTION_COM_HANDLER 操作类型创建一个 COM 服务器,并向其查询您必须要实现的 ITaskHandler 接口。这样便在操作和 Task Scheduler 引擎之间建立一个通信渠道,使应用程序特定的操作和 Task Scheduler 之间的集成更简洁。
下面介绍创建 COM 操作的方法:
CComPtr<IAction> action;
HR(actions->Create(TASK_ACTION_COM_HANDLER, &action));

CComQIPtr<IComHandlerAction> comAction(action);
HR(comAction->put_ClassId(CComBSTR(
    L"{25C6DB11-4ADC-4e89-BA47-04576C7AA46A}")));
您无疑需要一个 COM 服务器,它用指定的类标识符 (CLSID) 进行注册,并实现 ITaskHandler 接口。图 3 提供了借助于 ATL 实现 ITaskHandler 的 COM 类的示例。

C++  TaskScheduler msdn杂志_xml_02  Figure 3 COM 操作类
class DECLSPEC_UUID("25C6DB11-4ADC-4e89-BA47-04576C7AA46A")
DECLSPEC_NOVTABLE CoSampleTask :
    public CComObjectRootEx<CComMultiThreadModel>,
    public CComCoClass<CoSampleTask, &__uuidof(CoSampleTask)>,
    public ITaskHandler
{
public:
    DECLARE_REGISTRY_RESOURCEID(IDR_SAMPLETASK)
    BEGIN_COM_MAP(CoSampleTask)
        COM_INTERFACE_ENTRY(ITaskHandler)
    END_COM_MAP()

private:
    STDMETHODIMP Start(IUnknown* taskScheduler, BSTR data);
    STDMETHODIMP Stop(HRESULT* actionResult);
    STDMETHODIMP Pause();
    STDMETHODIMP Resume();
};

此外还有另外一个步骤需要执行,截至撰写本文为止它还没有编入任何文档,但是如果没有这一步,您的 COM 操作将无法加载。当 Task Scheduler 引擎使用您的 CLSID 调用 CoCreateInstance 函数时,它预期使用 CLSCTX_LOCAL_SERVER 上下文将您的 COM 服务器加载到单独的进程中(除非您的操作是 Windows 本身的一部分,在这种情况下它可能会进行进程内加载)。为了能获得成功,COM 服务器需要配置成允许在替代进程中激活。从技术上说,您可以使用传统的进程外组件,但是这在大多数情况下都不太常见,也不太实用。实际上通过更新注册代码便可方便地解决这个问题。更新您的 ATL 注册以包括 DllSurrogate 值。以下是一个示例:
HKCR
{
    NoRemove AppID
    {
        '%APPID%' = s 'SampleTask'
        {
            val DllSurrogate = s ''
        }
        'SampleTask.DLL'
        {
            val AppID = s '%APPID%'
        }
    }
}
COM 操作在很多方面都要优于其他操作类型。值得注意的是,COM 操作是允许以妥善和可预测的方式停止操作的唯一操作类型。当 Task Scheduler 引擎创建 COM 类的实例之后,它会调用 Start 方法,并提供可用来与 Task Scheduler 通信的接口指针。然后 Start 方法会查询 ITaskHandlerStatus 接口,并为任务提供一个简单的机制,将其进度和最终完成情况通知给任务计划程序。该操作可以用两种方式结束。用户可以决定使用各种方法来结束任务,所有这些方法最终都会调用 COM 类的 Stop 方法。或者,您的 COM 类可以通过调用 ITaskHandlerStatus 的 TaskCompleted 方法来报告它已完成自己的职责。
提供的另一个有用操作类型允许任务发送电子邮件消息。与前一个操作类型不同,此类型对于执行操作或管理任务的用处要小一些,但是它非常适合于作为通知机制使用。下一部分将讨论触发器,但现在我们只需要知道任务可以由事件启动,而不是传统的基于日历或时间的触发器。以下是创建电子邮件操作的方法:
CComPtr<IAction> action;
HR(actions->Create(TASK_ACTION_SEND_EMAIL, &action));

CComQIPtr<IEmailAction> emailAction(action);
HR(emailAction->put_From(CComBSTR(L"kenny@example.com")));
HR(emailAction->put_To(CComBSTR(L"karin@example.com")));
HR(emailAction->put_Subject(CComBSTR(L"subject")));
HR(emailAction->put_Body(CComBSTR(L"body")));
HR(emailAction->put_Server(CComBSTR(L"mail.example.com")));

 

任务触发器
Task Scheduler 提供各种触发器,允许您在无用户干预的情况下启动任务。可供使用的有基于时间和日历的常见触发器,以及很多基于事件的有用触发器。
任务定义的 Triggers 属性返回的 ITriggerCollection 接口指针可用来创建任务并将其添加到任务定义中:
CComPtr<ITriggerCollection> triggers;
HR(definition->get_Triggers(&triggers));
ITriggerCollection 提供了一个 Create 方法,该方法可以创建新的触发器对象,并将其添加到集合中。触发器本身可通过派生自 ITrigger 的接口来公开。以下是一个触发器的示例,可能适用于上一部分中启动碎片整理操作的示例:
CComPtr<ITrigger> trigger;
HR(triggers->Create(TASK_TRIGGER_WEEKLY, &trigger));

CComQIPtr<IWeeklyTrigger> weeklyTrigger(trigger);
HR(weeklyTrigger->put_StartBoundary(CComBSTR(
    L"2007-01-01T02:00:00-08:00")));
HR(weeklyTrigger->put_DaysOfWeek(0x01)) // Sunday
TASK_TRIGGER_WEEKLY 是 11 个触发器类型之一,新 Task Scheduler 的初始版本中提供了这些触发器。图 4 提供了可供您使用的触发器类型概要,以及与各个触发器相关的 COM 接口。StartBoundary 属性指出了将触发任务的最早日期,以及将在当天启动任务的时间。DaysOfWeek 属性指定要在某星期的哪几天运行任务。它使用了位掩码,所以您可以根据需要合并任意天数。在上例中的触发器会导致任务在每个星期日的凌晨 2 点开始运行。PST 始于 2007 年的第一天。

C++  TaskScheduler msdn杂志_xml_02  Figure 4 触发器类型

类型 接口 用法和选项
TASK_TRIGGER_EVENT IEventTrigger Windows 事件日志定义的事件。
TASK_TRIGGER_TIME ITimeTrigger 当日时间;可选的随机延迟。
TASK_TRIGGER_DAILY IDailyTrigger 每 n 天;可选的随机延迟。
TASK_TRIGGER_WEEKLY IWeeklyTrigger 指定的周天数;每 n 周;可选的随机延迟。
TASK_TRIGGER_MONTHLY IMonthlyTrigger 指定的月天数;指定的月数;可选的月份的最后一天;可选的随机延迟。
TASK_TRIGGER_MONTHLYDOW IMonthlyDOWTrigger 指定的周天数;指定的月周数;指定的年月数;可选的每月的最后一周;可选的随机延迟。
TASK_TRIGGER_IDLE IIdleTrigger 当计算机空闲时。
TASK_TRIGGER_REGISTRATION IRegistrationTrigger 当创建或更新任务时;可选的延迟。
TASK_TRIGGER_BOOT IBootTrigger 当启动计算机时;可选的延迟。
TASK_TRIGGER_LOGON ILogonTrigger 当用户登录时;可选的延迟。
TASK_TRIGGER_SESSION_STATE_CHANGE ISessionStateChangeTrigger 各种会话事件。

 

下一步是什么?
有很多无法抗拒的原因让我们使用 Windows Vista 及更高版本中的新 Task Scheduler。如果您已依赖于 Windows 以前版本中的 Task Scheduler,您肯定会为所有的新特点和功能拍手叫好。实际上,Task Scheduler 可能很快就会替代现在为应用程序编写的许多自定义构建的计划程序。
在本专栏中我还未来得及介绍 Task Scheduler 的所有功能。如果花点时间研究一下 Windows SDK (msdn2.microsoft.com/en-us/library/aa383614.aspx) 中的文档,您会发现一些其他功能,包括直接用 XML 格式编写任务的功能、对任务执行时所在的安全上下文拥有更多控制、枚举和管理运行中任务的功能等等!