Создание служб Windows в Delphi с использованием VCL

Источник: delphikingdom
Александр Алексеев

Автор: © Александр Алексеев

Статья посвящена вопросам создания служб (сервисов) Windows в Delphi с использованием VCL, т.е. не на Windows API (WinAPI). Она предназначена для людей, собирающихся написать или уже написавших свою первую службу Windows. Статья не претендует на полноту или уникальность. Кое-какие вещи остались за бортом, особенно это касается функций WinAPI. Но их можно посмотреть самостоятельно в справке по Delphi, исходным кодам VCL и MSDN/Platform SDK. Особо глубоких знаний не потребуется - для понимания статьи читатель должен быть знаком со службами Windows на уровне пользователя. В некоторых местах изложение может забегать вперёд. Поэтому если вам встретился непонятный момент - просто пропустите его, возможно, потом он прояснится.

Вступление

Служба или сервис Windows - это обычное приложение Windows, но написанное по определённым правилам. Такие приложения содержат дополнительный код, позволяющий стандартному менеджеру служб Windows (SCM - Service Control Manager) единообразно управлять процессами служб. SCM располагается в файле services.exe, запускается при старте системы и останавливается только при завершении работы ОС. Кстати говоря, в одном exe-файле может размещаться несколько служб. Обычно это делается для экономии ресурсов и упрощения взаимодействия между службами, т.к. службы будут находиться в одном адресном пространстве. В частности, почти все стандартные службы Windows именно так и реализованы: большинство из них находятся в DLL, которые загружаются в svchost.exe.

Все стандартные серверные приложения Windows реализованы в виде служб. SCM позволяет администраторам управлять службами локальной или даже удалённой машины. Службы можно запускать, останавливать, приостанавливать и возобновлять. Кроме этого, SCM следит за выполнением служб и способен выполнять дополнительные действия при внезапной остановке какой-либо службы (внезапная остановка - это когда служба остановилась, но не сообщила об этом).

Службы работают без участия человека, поэтому они не могут иметь пользовательского интерфейса, т.к. почти всегда с этим интерфейсом некому работать. Службы начинают свою работу ещё до того, как пользователь вошёл в систему. Более того, на серверных машинах пользователь может вообще не входить в систему за всё время работы.

Именно поэтому для однородного управления службами служит SCM, а для их настройки - некое внешнее приложение, которое может "общаться" со службой или изменять конфигурацию службы (например, записывая её в реестр). В случае стандартных служб нередко таким конфигурационным приложением выступает оснастка к MMC (Microsoft Management Console). Стандартизация средств управления и конфигурирования упрощает жизнь и разработчикам службы и системным администраторам.

Как и в случае стандартных приложений Windows, Delphi позволяет легко и быстро написать службу, используя VCL (увы, чего не скажешь об оснастке к MMC).

Установка и удаление службы

Нам желательно работать под учётной записью администратора, т.к. по умолчанию, обычный пользователь не имеет права на многие операции со службами, к примеру - на их установку.

Итак, запускаем Delphi, открываем в главное меню пункт File/New/Other, выбираем "Service Application":



Рисунок 1а
Рисунок 1б

После этого Delphi создаст пустое приложение службы. Заметим, что в новом проекте были автоматически сгенерированы две подпрограммы:

procedure ServiceController(CtrlCode: DWord); stdcall;
begin
  Service1.Controller(CtrlCode);
end;

function TService1.GetServiceController: TServiceController;
begin
  Result := ServiceController;
end;

Эти два метода мы никогда трогать не будем. Кроме кода, перед собой мы видим пустое окно: это окно TService - наследника от TDataModule, представляющего нашу службу.

Как уже было сказано, в одном проекте (соответственно, exe-файле) может быть несколько служб. Чтобы добавить ещё одну службу в текущий проект, лезем в File/New/Other и там выбираем "Service" (а не "Service Application", как было ранее). В BDS пункт "Service" в разделе "Delphi Files" появляется только, если текущий проект является проектом службы. В Delphi 7 и ниже пункт "Service" виден всегда, он всегда создаёт пустой модуль с TService.

В это самое окно службы мы можем добавлять невизуальные компоненты, если они нам понадобятся. Визуальные компоненты мы использовать не можем по понятным причинам: в конце концов, мы пишем фоновую службу, которая может работать, даже если пользователь не зашёл в систему, поэтому мы не можем использовать никаких интерактивных средств взаимодействия с пользователем.

Как обычно, даже минимальное VCL приложение обладает необходимым минимумом для функционирования. Пустой проект мы можем скомпилировать и даже установить. Для этого копируем скомпилированный exe-файл в C:\Windows\System32\ (или в эквивалентную папку на вашей машине). Копирование - опционально, но рекомендуется; вообще говоря, exe-файл службы может находиться в любом месте (с учётом некоторых особенностей: например, файл службы не может располагаться на сетевом или subst-диске, т.к. они создаются только при входе пользователя в систему). Затем запускаем exe-файл с параметром "/install", например:

Project1.exe /install


Рисунок 2

Служба сообщит, что она успешно установлена:


Рисунок 3

Сообщение можно убрать, если добавить параметр "/silent". Если такая служба уже есть (точнее - с таким же свойством Name, см. ниже), то установка завершится с ошибкой. В этом случае старую службу надо удалить, и лишь потом добавлять новую. Разумеется, для отладки вовсе не нужно постоянно переустанавливать службу. Достаточно установить в опциях проекта Output Directory в C:\Windows\System32\ (или куда вы там захотели положить службу), зарегистрировать один её раз и потом просто останавливать службу во время компиляции. Когда все службы, расположенные в exe-файле остановлены, этот файл выгружается, и ничто не мешает его перезаписать.

Открываем Панель управления/Администрирование/Службы и любуемся на нашу службу: "Service1".


Рисунок 4

Заметим, что служба установилась в остановленном состоянии с заданными ей свойствами (свойства службы мы рассмотрим ниже).

Для удаления службы запускаем exe-файл с параметром "/uninstall" (дополнительно можно добавить "/silent"), например:

Project1.exe /uninstall

Вообще говоря, служба редко ставится описанным способом, т.е. "руками". Обычно все операции по установке/удалению службы возлагаются на установщик (инсталлятор) приложения. Тем не менее, все службы, написанные на Delphi с использованием VCL, обладают такой вот приятной возможностью самоустановки и самоудаления. Поэтому инсталлятор может просто вызывать exe-файл службы с нужными параметрами (с указанием "/silent").

Свойства службы

Теперь мы приведём свою службу в божеский вид, для этого посмотрим свойства службы:

Name - это основное имя службы, должно быть уникальным в системе, так что его обязательно нужно сменить на что-либо другое. Под этим именем мы будем регистрироваться в реестре, под этим именем будем писать логи, с этим именем будем оперировать, общаясь со службой программно, и т.п. В имени службы рекомендуется использовать только латинские буквы и цифры.

DisplayName - а вот это читабельное название службы. Именно оно отображается в оснастке "Службы". Это та строка, которую обычно видит пользователь (системный администратор). Если оно не задано, то вместо него берётся имя службы из свойства Name. Для службы можно задать более подробное описание, но в Delphi нет подходящего свойства, поэтому задание описания надо будет сделать руками (см. пункт 8).

AllowPause, AllowStop - говорят сами за себя: можно ли приостанавливать и останавливать службу соответственно. Отвечают за доступность соответствующих кнопок в оснастке "Службы". Если вы назначите соответствующие обработчики событий (см. ниже), то эти свойства автоматически переключатся в True. Заметим, что эти свойства не влияют на возможность отправки сообщений вашей службе. Т.е. даже, если вы указали AllowPause = False, никто не запрещает стороннему приложению отправить вашей службе сообщение приостановки SERVICE_CONTROL_PAUSE.

StartType - тип запуска службы по умолчанию. Имеет значение только при установке службы. В остальное время не используется. Отвечает только за то, какой тип запуска будет у вашей службы при её самоустановке. Имеет смысл менять значения только на Auto (автоматический запуск при старте системы), Manual (запуск только по требованию, например, по указанию пользователя или во время запуска зависимой службы) и Disabled (запуск запрещён). Остальные имеют смысл только для драйверов. Ещё раз заметим, что независимо от типа запуска, при самоустановке служба добавляется в остановленном состоянии.

ServiceStartName и Password - имя учётной записи пользователя и её пароль для запуска службы из-под неё. Обычно оставляют пустыми - в этом случае служба пускается из-под учётной записи LocalSystem. Аналогично StartType, эти свойства используются только во время самоустановки службы. Служба, работающая под LocalSystem, по умолчанию может свободно выполнять операции, обычно запрещённые для других учётных записей. Заметим, что при работе из-под LocalSystem, службе не следует обращаться к ключу реестра HKEY_CURRENT_USER (да и вообще, работа с любыми объектами пользователя из службы часто оказывается плохой идеей). Кроме того, нужно быть аккуратным с разделом HKEY_CLASSES_ROOT, т.к. это виртуальный раздел, часть которого берётся из профиля текущего пользователя (HKEY_CURRENT_USER), а часть - из общесистемного (HKEY_LOCAL_MACHINE).

ErrorSeverity - насколько серьёзны сбои в службе. Ignore - пользователя не уведомлять, в журнал не заносить; Normal (по умолчанию) - уведомить пользователя, занести событие в журнал; Severe - если последняя удачная конфигурация еще не используется, загружается именно эта конфигурация. Если она уже используется, загрузка продолжается; Critical - если последняя удачная конфигурация еще не используется, загружается именно эта конфигурация. Если она уже используется, загрузка останавливается и выдаётся синий экран смерти.

Interactive - сервис может взаимодействовать с интерфейсом пользователя: может выводить на консоль окна и принимать ввод от пользователя (так называемые "интерактивные" службы). Не рекомендуется устанавливать в True. Более того, в Windows Vista интерактивные службы запрещены по умолчанию. Интерактивный процесс может быть запущен только из-под учётной записи LocalSystem (при установке свойства в True автоматически сбрасываются свойства ServiceStartName и Password и наоборот).

LoadGroup и Dependencies - используются для указания порядка загрузки, если это важно для вашей службы. В "LoadGroup" вписывается имя группы, в которую будет входить ваша служба (если будет). Примеры можно посмотреть в реестре: HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\ServiceGroupOrder. Т.е. фактически тут определяется, ДО каких служб должна быть запущена ваша служба. Если вы, к примеру, впишете туда "NetBIOSGroup", то службы, зависящие от "NetBIOSGroup" будут ожидать загрузки вашей службы (на самом деле, всё немного сложнее, но не будем на первый раз слишком сильно углубляться в подробности). "Dependencies" же определяет, какие службы должны быть запущены до вас. При добавлении в список зависимости вы должны указывать свойство IsGroup - является ли имя, введённое в Name, именем группы или же это имя службы. Зависимость от группы трактуется следующим образом: будет произведена попытка запуска каждой службы из группы. Ваша служба будет запущена, если хотя одна служба из указанной группы оказалась запущенной. Такая вот весьма странная логика запуска.

Итак, мы разобрали основные свойства службы - остальные вам навряд ли понадобится.

Примечание: часть свойств логически относятся к процессу работы службы, поэтому они отнесены к пункту 4. Для новой службы обычно требуется задать Name, DisplayName и StartType.

События службы

Теперь разберёмся с событиями службы.

OnAfterInstall, OnAfterUninstall, OnBeforeInstall, OnBeforeUninstall - эти события возникают после и до регистрации и удаления службы в системе (разумеется речь идёт только о самоустановке и самоудалении). Чаще всего в этих событиях происходит старт/останов службы, а также заполнение/чистка реестра (например, текстовое описание службы или регистрация источника сообщений для службы, см. ниже).

OnCreate и OnDestroy - ну с этим ясно, возникают при создании и удалении модуля данных, представляющего службу.

OnShutdown - возникает при завершении работы системы. Все исключения в обработчике этого события игнорируются. Exe-приложение будет завершено сразу после выхода из обработчика OnShutdown. Кстати, во время завершения работы системы это событие рассылается всем службам сразу. Поэтому, если ваша служба зависит от ещё какой-то службы, то имейте в виду, что служба, от которой вы зависите, при завершении работы системы может остановиться даже раньше, чем вы получите уведомление OnShutdown.

OnExecute, OnStart, OnStop, OnPause, OnContinue - остальные события можно разделить на две группы: OnExecute и все остальные :) Связано это с типом работы. Вы можете реализовать работу службы двумя способами.

Вариант первый: стартует служба, вы начинаете что-то делать, время от времени уведомляя систему о своём состоянии, потом служба останавливается.

Вариант второй: стартует служба, создаёт рабочие потоки. По уведомлению от системы потоки останавливаются и служба выключается.

Обычно в службах используется второй вариант, но это ваш выбор - мы расскажем об обоих.

Итак, событие OnExecute реализует первый вариант: вы просто вписываете весь код службы в OnExecute, не забывая время от времени вызывать ReportStatus для уведомления системы о своём состоянии (если вы будете выполнять работу длительное время) и ServiceThread.ProcessRequests - для получения от системы сообщений. ServiceThread.ProcessRequests можно рассматривать как аналог Application.ProcessMessages для обычных приложений. При приёме сообщений будут генерироваться соответствующие события: OnStop, OnPause или OnContinue. При возникновении событий вы можете каким-то образом уведомлять код в OnExecute (или использовать для этого изменение состояния службы). Как только вы выходите из OnExecute - служба остановилась. Заметим, что если вы сами вышли из OnExecute, то событие OnStop не генерируется. Т.е. это событие возникает, только если его кто-то прислал.

Для второго варианта реализации вы не должны назначать обработчик OnExecute, но обязаны реализовать, как минимум, OnStart и OnStop. В первом обработчике вы запускаете рабочие потоки службы, а в OnStop - останавливаете. Дополнительно вы можете реализовать OnPause и OnContinue, если ваша служба должна уметь приостанавливаться. Служба будет остановлена, только когда она перейдёт в состояние csStopped. Обычно это бывает после успешного завершения OnStop.

Итак, обычно для реализации минимальной службы требуется написать обработчики OnStart/OnStop и/или OnExecute.

Отметим, что события не обязательно вызываются "логично". Т.е. например, два раза подряд может прийти сообщение OnPause. Хотя подобную последовательность событий с использованием стандартной оснастки "Службы" повторить можно с трудом, но, тем не менее, SCM никак не проверяет сообщения, отправляемые службам. Поэтому никто не мешает любому приложению (с нужными правами, разумеется) отправлять произвольную последовательность сообщений вашей службе. Следовательно, отслеживание правильного текущего статуса - целиком ваша задача. Кстати говоря, в реализации событий TService этот момент не принят во внимание, а так как состояние службы меняется автоматически при поступлении события, то VCL службы могут менять своё состояние весьма странными последовательностями. Это можно легко исправить, если перегрузить protected-методы DoStart, DoStop, DoPause и т.п. В перегруженных вариантах нужно просто отслеживать текущее состояние и вызывать inherited-методы, только если событие согласуется с состоянием.

Помимо перечисленных, служба может принимать и другие события. Они не обрабатываются VCL, их список можно посмотреть в MSDN/Platform SDK в теме "Service Reference/Service Functions/Handler". Для их обработки нужно в службе переопределить protected-метод DoCustomControl. Имеется возможность определить своё собственное сообщение - для этого можно использовать номера с 128 по 255. Отправить сообщение службе можно WinAPI функцией ControlService.

Заметим ещё, что установка свойства FreeOnTerminate в True для рабочих потоков вашей службы обычно является плохой идеей. И вот почему. Кратко рассмотрим несколько возможных сценариев. Предположим, что вы создали поток, не сохранив объект потока и установив FreeOnTerminate := True. Сигнал об остановке службы может прийти в любой момент времени. При остановке службы вы обязаны корректно освободить все ресурсы службы. Как вы собираетесь это сделать, если у вас есть выполняющийся поток? Вы не можете его оставить выполняться, т.к. служба - это вам не обычное приложение. Службы должны быть написаны максимально корректно, поскольку они выполняются со значительными привилегиями и весьма близко к системе. Любые недоработки служб могут значительно пошатнуть стабильность работы системы. Кроме того, вы не можете просто вызвать TerminateThread для потока, т.к. это наихудший способ завершения потока, который ведёт к многочисленным утечкам ресурсов. Вы не можете надеяться на то, что система сама почистит за вами при выходе. И вот почему. Во-первых, сообщения о запуске/остановке могут идти практически друг за другом (например, при перезапуске). Поэтому, exe-файл может вовсе не выгружаться, а, следовательно, никто за вами чистить не будет, и в вашей службе может оказаться утечка ресурсов. Кроме того, при окончательном выходе из процесса (когда отработал весь код основного потока) вызывается ExitProcess. Среди прочих действий, эта функция завершает ещё работающие потоки вызовом TerminateThread, а затем выполняет функции DllMain с кодом DLL_PROCESS_DETACH. Проблема в том, что поток может оказаться прерванным в тот момент, когда он держит блокировку на глобальный ресурс (например, менеджеру памяти; для Delphi это будет вызовы GetMem/FreeMem, большинство операций со строками и т.п.). Следовательно, ближайшее же обращение к этому ресурсу (в данном случае - из функций DllMain) вызовет зависание программы. Именно поэтому так важно завершать все вторичные потоки до выхода из приложения (это касается не только служб). Надеюсь, мы привели достаточно аргументов, чтобы убедить вас о необходимости уведомить поток о завершении и дожидаться его завершения.

Примечание: В новых версиях Windows изменено поведение критических секций при выходе из программы. Вместо блокировки вы получите данные в несогласованном состоянии (но только для ресурсов, защищённых критической секцией, и только начиная с Windows XP). Подробнее см. вопрос №61596.

Хорошо, предположим, вы сохранили объект потока в переменную и при получении сигнала останова вы вызовете ему Free. Но кто сказал, что объект будет существовать в момент вызова Free? Ведь вы же установили FreeOnTerminate, что означает, что поток может завершиться (и, следовательно, объект будет удалён, а в переменной потока будет указатель на мусор), а после этого может прийти сигнал об остановке. Получим Access Violation в службе?

Хорошо, вы добавили обнуление переменной потока при завершении работы потока, а перед Free вы поставили проверку указателя на nil. Но ведь возможна и ситуация, когда сперва выполнится проверка, затем будет удалён объект потока, а только потом вызван его деструктор после вашей проверки. Конечно, можно сделать синхронизацию потоков, гарантирующую корректную работу. Но не будет ли это изобретение велосипеда вместо использования готовых средств VCL?

Методы службы

Теперь окинем взглядом рабочий инструментарий службы: методы объекта TService.

ReportStatus - если вы делаете что-либо слишком долго (дольше, чем число миллисекунд, указанных в свойстве WaitHint), то вы должны вызывать этот метод для сообщения системе, что вы ещё живы. Если вы будете выполнять какие-то действия в обработчиках событий или коде инициализации/завершения слишком долго, то SCM может подумать, что ваша служба зависла, и, в некоторых случаях, завершить её (но чаще всего SCM просто вернёт ошибку приложению, запросившему действие).

LogMessage(Message: String; EventType: DWord; Category, ID: Integer) - заносит в системный журнал сообщение (оснастка "Просмотр событий"). Message - само сообщение, EventType - тип события (EVENTLOG_SUCCESS, EVENTLOG_ERROR_TYPE, EVENTLOG_WARNING_TYPE, EVENTLOG_INFORMATION_TYPE, EVENTLOG_AUDIT_SUCCESS, EVENTLOG_AUDIT_FAILURE), ID - номер шаблона. Касательно логгирования и этой процедуры мы поговорим более подробно чуть ниже.

ParamCount и Param - позволяют просмотреть параметры запуска службы, если таковые были указаны. Параметры запуска указываются на первой вкладке окна со свойствами службы, открытого из оснастки "Службы", и могут задаваться при установке службы. Обычно не используются.

ProcessRequests(WaitForMessage: Boolean) - обычно используется в OnExecute для получения и обработки сообщений от SCM. Некий аналог Application.ProcessMessages. Параметр WaitForMessage отвечает за ожидание сообщений в очереди. Вы никогда не должны ставить его в True, т.к. в этом случае ProcessRequests вернёт управление только в случае завершения работы службы.

Status (свойство) - получить/установить текущее состояние службы: запускается, работает, останавливается и т.п. Как правило, устанавливается автоматически при возникновении соответствующих событий, но вы можете установить его и вручную. Только делать это обычно не следует. Так, например, служба считается остановленной только при установке её статуса в csStopped. Именно это свойство вкупе с WaitHint будет отправлено системе при вызове ReportStatus.

WaitHint (свойство) - указывает время в миллисекундах, через которое ваша служба должна сообщить свой статус. VCL по умолчанию ставит это свойство в 5000 (т.е. 5 секунд), хотя в системе по умолчанию этот параметр принимается равным 2000. Как разработчик службы, вы должны определить, как часто служба должна сообщать о своём статусе. Вы также должны вызывать ReportStatus не реже, чем вы указали в WaitHint. Если вы собираетесь выполнить какие-то длительные действия в обработчике события, то иногда проще вынести эти действия в отдельный поток, а в обработчике события сделать цикл ожидания завершения работы с периодическим вызовом ReportStatus раз в, скажем, WaitHint / 2 миллисекунд. Разумеется, цикл не должен крутиться вхолостую, а ждать наступления момента вызова ReportStatus или завершения работы.

Работа службы

SCM хранит базу данных служб в ключе реестра

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services

Обращаться напрямую к этому ключу вам не нужно, работа с ним происходит программными средствами (функциями WinAPI или обёртками VCL) и оснасткой "Службы". В этой базе данных перечислены все установленные службы и их свойства. Исключение составляет чтение/запись своей конфигурации службы. Удобно все параметры службы хранить в ключе

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Имя-Службы(свойство Name)\Parameters

Служба может считывать свои параметры оттуда при запуске, а приложение-конфигуратор - писать в этот ключ настройки.

При запросе на запуск службы (например, при запуске системы для автостартующих служб или по команде администратора) SCM определяет из базы данных exe-файл службы и параметры его запуска. Затем он смотрит: не запущен ли уже этот exe-файл. Если нет - то SCM запускает приложение службы. После этого SCM ждёт, пока приложение не установит с ним связь. С точки зрения WinAPI установка связи заключается в вызове функции StartServiceCtrlDispatcher, передавая ей описания служб, размещённых в загруженном приложении. Разумеется, в случае использования VCL, эта функция вызывается автоматически при инициализации приложения. Но если запущенное приложение не установит связь с SCM в течение некоторого времени (около 30 секунд), то SCM считает, что приложение либо зависло, либо работает неправильно, либо не является приложением службы и завершает его (через TerminateProcess). По этой причине не рекомендуется размещать длительные операции инициализации приложения в dpr файл службы или в секции initialization модулей. Выход из StartServiceCtrlDispatcher осуществляется только при выгрузке приложения службы (это происходит, когда SCM определяет, что в приложении остановилась последняя служба). На выгрузку приложения SCM также отводит ограниченный отрезок времени, после чего (если приложение так и не закрылось) завершает процесс. Т.е. поток инициализации приложения обычно постоянно находится внутри вызова функции StartServiceCtrlDispatcher. В это время он ждёт сообщений от SCM и обрабатывает их.

Так, дождавшись запуска приложения службы и установления связи с ним, SCM отправит сообщение приложению с указанием запуска запрошенной службы. И если при попытке запуска новой службы SCM обнаружит, что служба размещается в уже запущенном приложении, то он просто отправит приложению сообщение о запуске новой службы и не будет пытаться запустить новый процесс. Если в реестре путь к одному и тому же exe-файлу двух служб указан по-разному, то SCM не сумеет определить, что это один файл и запустит два процесса вместо одного.

Аналогично всему процессу, первое, что должна сделать служба - установить связь с SCM. В случае VCL это происходит автоматически. Сразу после установки связи с SCM вызывается событие OnStart. После этого, если задан обработчик события OnExecute, то выполняется именно он, в противном случае запускается цикл обработки сообщений службы.

Главный поток службы в случае использования VCL не делает никакой особой работы. Обычно это просто обработка заданий от методов Synchronize потоков. Для работы же службы VCL создаёт отдельный поток и в нём вызывает StartServiceCtrlDispatcher. Кроме того, для каждой службы в вашем приложении VCL также создаёт поток, обслуживающий события службы. Поэтому минимальное число потоков в VCL-службе - три (главный, служебный и поток службы). В случае использования OnExecute только поток службы выполняет работу, остальные два почти всё время спят. В сценарии же без OnExecute все три упомянутых потока обычно спят в ожидании сообщений.

OnCreate, OnDestroy и все события установки/удаления службы выполняются в контексте главного потока. События OnExecute, OnStart, OnStop, OnPause, OnContinue и OnShutdown выполняются в контексте потока службы.

Настройки и взаимодействие с пользователем

Одним из часто задаваемых вопросов по службам является примерно такой вопрос: "как я могу из службы сделать X для текущего пользователя?". Здесь "X" может быть, например, установка ловушки (hook), показ окна настроек и т.п.

Но кто сказал, что такой "активный" пользователь только один?

В Windows XP появилась возможность быстрого переключения пользователей, которая позволяет даже на домашней машине получить сценарий, когда к одной машине подключено несколько пользователей. Конечно, можно попробовать переформулировать вопрос, скажем, так: "ну, на самом деле, я имел в виду, что среди всех пользователей надо выбрать только того, кто сейчас использует компьютер". Вот только благодаря медиаприставке может быть два пользователя: один за монитором, а другой - вообще в другой комнате с телевизором и "экстендером", подключенным к этому же компьютеру. Оба они используют компьютер одновременно. Кроме того, в серверных ОС Windows есть служба терминалов (Terminal Services), которая позволяет подключаться к машине одновременно многим пользователям. Каждый из них будет использовать компьютер одновременно с другим! При этом локальный пользователь вообще может даже ни разу не войти в систему. Даже в Windows XP существует урезанная версия этой службы под названием удалённый рабочий стол (Remote Desktop).

Если у вас служба должна взаимодействовать с пользователем, то, по-хорошему, вам потребуется сделать два приложения: собственно службу и графический интерфейс к ней. Служба будет тихо-мирно в фоне выполнять свою работу. Графический интерфейс будет запускаться лишь иногда по требованию пользователя. Он может, например, менять конфигурацию службы в реестре, уведомляя службу об изменениях (или служба сама может следить за изменениями ключа реестра, используя RegNotifyChangeKeyValue). Или давать службе команды через IPC. Или, например, используя ControlService (есть удобное сообщение - SERVICE_CONTROL_PARAMCHANGE). Ну и, конечно, в простейшем случае, служба может просто игнорировать изменения конфигурации во время работы, считывая её только во время запуска.

Только нужно аккуратно писать это дело, не забывая, что служба - одна, а интерфейсов может быть запущено одновременно несколько из разных сессий: следить нужно за синхронизацией и разделением ресурсов. Как мы уже сказали ранее, удобно все параметры службы хранить в ключе

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Имя-Службы(свойство Name)\Parameters

Кроме того, обычно нужно учитывать, что служба обычно запускается под учётной записью Local System, а графическая оболочка - под некоторым пользователем. Поэтому для успешного взаимодействия, скажем, по IPC вам нужно будет явно задавать приемлемые Security Attributes - это те самые параметры, которые в обычной ситуации вы всегда ставите в nil.

Системные логи (регистрация событий)

В принципе, сказанного должно быть вполне достаточно, чтобы написать службу. Но хотелось бы коснуться ещё одного момента: системный логгинг событий.

Сначала стоит сказать несколько слов о том, как работают логи в системе. Когда приложение заносит новый элемент в лог, оно добавляет туда не строку с сообщением, а номер сообщения в таблице сообщений. Для чего это сделано? Ну, во-первых, хранить номера гораздо проще (они занимают существенно меньше места), чем строки. Это экономит место на диске, т.к. не захламляет лог файл однотипными сообщениями типа "служба была успешно запущена". Второй момент - локализация строк. Когда в лог добавляется новый номер строки, то при просмотре логов, из таблицы строк службы может быть извлечена строка, соответствующая текущему языку оператора. Таблица сообщений должна быть подготовлена особо и включена в exe-файл службы или в специально выделенную DLL. Например, служба может иметь по DLL на каждый язык. Сказанное не означает, что в лог файлы могут попасть только заранее определённые статические строки. В сообщениях могут участвовать специальные параметры, вместо которых при добавлении записи в лог будут подставлены данные, сформированные службой. Например, сообщение может иметь вид "Файл %1 не найден". При добавлении этой записи в лог служба указывает номер сообщения в таблице сообщений и дополнительный параметр произвольного содержания - имя файла. При этом в лог файл запишется номер сообщения и последовательность байтов, представляющая собой параметр. При просмотре сообщения оператором символы "%1" будут заменены именем файла, и сообщение станет выглядеть, например так: "Файл C:\test.txt не найден".

Откройте оснастку "Просмотр событий":


Рисунок 5

Теперь откройте редактор реестра (regedit.exe), а в нём - ключ "HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Eventlog":


Рисунок 6

Как видим, каждый подключ в реестре соответствует разделу логов (слева) в "Просмотре событий":

  EventLog
    Application
      AppName
    Security
    System
      DriverName
    CustomLog
      AppName

Вы можете добавлять свои события в один из существующих разделов (обычно, в Application) или создать свой раздел (на диаграмме выше - помечен CustomLog, не поддерживается в Windows NT).

Бонус-пак: создание своего лога.

Если вы захотите добавить свой пункт (в оснастке "Просмотр событий" он появится слева, вместе с Приложение/Безопасность/Система), то вы добавляете подраздел в ключе EventLog:

  EventLog
    CustomLog

В этом подразделе создаёте параметры (все они - необязательные):

DisplayNameFile (REG_EXPAND_SZ) и DisplayNameID (REG_DWORD) - указывают локализованную (т.е., "по-русски") строку, которая будет видна в "Просмотр событий". Как задать эту строку и почему параметров два, мы обсудим ниже.

MaxSize (REG_DWORD) - максимальный размер лог-файла в байтах, у стандартных логов равен 0x80000 (524288).

RestrictGuestAccess (REG_DWORD) - 0 или 1, запретить доступ для гостей.

Retension (REG_DWORD) - время максимальной жизни записей в секундах. По-умолчанию равен 7 дням.

Вне зависимости от того, добавляете ли вы свой раздел логов или используете раздел Application, следующим шагом вы создаёте подраздел с именем своей службы (то, что записано в TService.Name), например:

  EventLog
    Application
      Service1

Далее, в свежесозданном ключе добавляете следующие параметры (а вот они уже обязательны):

EventMessageFile (REG_EXPAND_SZ) - путь к exe, содержащему вашу службу, например "%SystemRoot%\System32\Project1.exe" (без кавычек, разумеется). Можно указать через точку с запятой несколько файлов (exe или DLL), если вы делаете таблицы сообщений в разных файлах.

TypesSupported (REG_DWORD) - типы сообщений, которые вы поддерживаете. Обычно равен 7 (в десятичной форме) - комбинация флагов EVENTLOG_XXX (см. выше в пункте 4), что означает события типа ошибка + уведомление + предупреждение.

Для создания набора строк для логов нам надо создать текстовый файл с расширением .mc, вписать туда набор строк в определённом формате и обработать его компилятором MC (MessageCompiler), после чего результат компиляции собрать в ресурсный файл компилятором ресурсов RC (Resource Compiler), затем готовый .res-файл просто подключается к проекту. Компиляторы MC и RC можно взять из Platform SDK или MSDN. Параметры запуска каждого компилятора можно посмотреть, запустив их из командной строки с параметром /?.

Для компиляции в один файл, подключаемый к exe-файлу службы, удобно составить примерно такой bat-файл:

@echo off
del msg.res > nul
bin\mc -u -U msg.mc
del msg.h > nul
bin\rc -r msg.rc
del msg.rc > nul
del MSG*.bin > nul
echo Done.
pause

Тут предполагается, что мы компилируем msg.mc (в формате Unicode, если же компилируется ANSI-файл, то третья строка будет выглядеть так: "bin\mc -a -U msg.mc"), из него получается msg.rc, msg.h и пачка файлов MSGxxxx.bin (по одному на каждый язык), затем из всего этого собирается файл msg.res, а промежуточные файлы удаляются. Компиляторы должны лежать в подпапке bin текущей папки. Если это не так - в bat-нике нужно подправить пути ("bin\mc" и "bin\rc"). Для подключения готового res-файла добавляем в проект строчку {$R msg.res}, например, сразу после {$R *.dfm}.

.mc-файл имеет примерно такое содержание:

MessageIdTypedef=DWORD
LanguageNames=(English=0x409:MSG00409)
LanguageNames=(Russian=0x419:MSG00419)
MessageId=0x1
Language=English
%1
.
Language=Russian
%1
.
MessageId=0x2
Severity=Error
Language=English
Error: %1
.
Language=Russian
Ошибка: %1
.
MessageId=0x3
Severity=Error
Language=English
Daemon was not configured
.
Language=Russian
Демон не был сконфигурирован
.
MessageId=0x4
Severity=Error
Language=English
Unhandled exception raised: %1
.
Language=Russian
Возникло необработанное исключение: %1
.
MessageId=0x5
Severity=Error
Language=English
Unhandled exception raised at create: %1
.
Language=Russian
Возникло необработанное исключение при инициализации: %1
.
MessageId=0x6
Severity=Error
Language=English
Unhandled exception raised at destroy: %1
.
Language=Russian
Возникло необработанное исключение при завершении: %1
.
MessageId=0x7
Severity=Error
Language=English
Unhandled exception raised at start: %1
.
Language=Russian
Возникло необработанное исключение при запуске: %1
.
MessageId=0x8
Severity=Error
Language=English
Unhandled exception raised: %1
.
Language=Russian
Возникло необработанное исключение при остановке: %1
.

Сначала определяются языки, на которых будут писаться строки в файле (две строки с LanguageNames в начале файла). Идентификаторы языков можно посмотреть в MSDN/Platform SDK в разделе "Table of Language Identifiers". Затем могут определяться SeverityNames и FacilityNames - типы сообщений и типы "авторов" сообщений, но по умолчанию они имеют вполне приличный вид:

SeverityNames=(
  Success=0x0
  Informational=0x1
  Warning=0x2
  Error=0x3
)
FacilityNames=(
  System=0x0FF
  Application=0xFFF
)

Поэтому, обычно эти строки опускаются. Дальше идут собственно сообщения. Сообщение начинается с MessageId=номер. В этих строках сообщениям присваиваются их номера (номера выбираем мы). Именно по этим номерам мы потом будем добавлять их в лог. Кстати, и этот параметр опционален. Если его не указывать, то первое сообщение получит номер 0, а последующие будут увеличиваться на 1. Дальше могут идти наборы из Severity и Facility, которые по умолчанию имеют вид:

Severity=Success 
Facility=Application 

Дальше следует язык сообщения и само сообщение. Точка в начале строки означает конец сообщения. Если языков несколько, они следуют друг за другом. В самих сообщениях можно использовать специальные символы:

%n[!format_specifier!]

Описывает вставку параметра. Каждый параметр занумерован от 1 до 99, format_specifier - необязательный параметр форматирования (по умолчанию - !s!). Список параметров форматирования такой же, как и у функции wsprintf языка C. Например, в примере .mc-файла выше это были параметры вида "%1". В дальнейшем, из службы вместо этих параметров можно будет вставлять произвольные данные.

%0

То же, что и точка в начале строки, но в отличие от точки, в конце сообщения не будет вставлен перенос строки.

%.

Вставляет точку. Может использоваться для вставки точки в начало строки, без обрыва сообщения.

%%

Вставляет знак "%".

Подробнее о содержимом mc-файлов можно прочитать в MSDN/Platform SDK в разделе "Message Text Files". Вот ещё пример mc-файла оттуда:

; /* Sample.mc
;
; This is a sample message file. It contains a comment block, followed by a
; header section, followed by messages in two languages.
;
; */
; // This is the header section. 
MessageIdTypedef=DWORD
SeverityNames=(Success=0x0:STATUS_SEVERITY_SUCCESS
              Informational=0x1:STATUS_SEVERITY_INFORMATIONAL
              Warning=0x2:STATUS_SEVERITY_WARNING
              Error=0x3:STATUS_SEVERITY_ERROR
              )
FacilityNames=(System=0x0:FACILITY_SYSTEM
              Runtime=0x2:FACILITY_RUNTIME
              Stubs=0x3:FACILITY_STUBS
              Io=0x4:FACILITY_IO_ERROR_CODE
              )
LanguageNames=(English=0x409:MSG00409)
LanguageNames=(Japanese=0x411:MSG00411)
; // The following are message definitions.
MessageId=0x1
Severity=Error
Facility=Runtime
SymbolicName=MSG_BAD_COMMAND
Language=English
You have chosen an incorrect command.
.
Language=Japanese
// тут иероглифы :)
.
MessageId=0x2
Severity=Warning
Facility=Io
SymbolicName=MSG_BAD_PARM1
Language=English
Cannot reconnect to the server.
.
Language=Japanese
// тут иероглифы :)
.
MessageId=0x3
Severity=Success
Facility=System
SymbolicName=MSG_STRIKE_ANY_KEY
Language=English
Press any key to continue . . . %0
.
Language=Japanese
// тут иероглифы :)
.
MessageId=0x4
Severity=Error
Facility=System
SymbolicName=MSG_CMD_DELETE
Language=English
File %1 contains %2 which is in error
.
Language=Japanese
// тут иероглифы :)
.
MessageId=0x5
Severity=Informational
Facility=System
SymbolicName=MSG_RETRYS
Language=English
There have been %1!d! attempts with %2!d!%% success%! Disconnect from the server and try again later. 
.
Language=Japanese
// тут иероглифы :)
.

Ладно, в конце-концов мы получили .res-файл и подключили его к проекту. Теперь в нашем exe файле (или в DLL, если вы подключали res-файл к проекту DLL) есть таблица строк, на которую мы можем ссылаться. Если вы решили создать свой раздел для логов, то вы должны помнить, что мы упоминали выше ключи реестра DisplayNameFile (REG_EXPAND_SZ) и DisplayNameID (REG_DWORD). Теперь в DisplayNameFile можно вписать имя нашего exe/dll-файла (с путём), а в DisplayNameID - номер сообщения, которое содержит название нашего раздела логов.

Например, если в файле msg.mc были строки:

MessageId=0x9
Language=English
My custom logs 
.
Language=Russian
Мой раздел логов
.

То в DisplayNameFile можно вписать, к примеру "%SystemRoot%\System32\Project1.exe", а в DisplayNameID - 9.

Ну и теперь же мы можем разобраться с методом TService.LogMessage:

LogMessage (Message: String; EventType: DWord; Category, ID: Integer)

В Message мы передаём параметр (может быть '', если в сообщении нет "%1"), тип события EventType - EVENTLOG_SUCCESS, EVENTLOG_ERROR_TYPE, EVENTLOG_WARNING_TYPE, EVENTLOG_INFORMATION_TYPE. Категория - это любое число, равно 0, если вы не используете категории. Категории полезны для группировки в группы большого количества разнотипных сообщений. Для простых служб, которые вы будете писать в начале, это явно излишнее, поэтому разбирательство с категориями можно отложить на потом. ID - это номер сообщения из MC-файла. Обратим внимание, что в приведённых выше примерах mc-файлов номера сообщений записывались в шестнадцатеричной системе счисления.

Например, чтобы добавить в лог сообщение типа "служба не сконфигурирована" (для самого первого примера mc-файла), пишем:

LogMessage ('', EVENTLOG_WARNING_TYPE, 0, 3);

А чтобы добавить произвольное сообщение:

LogMessage ('произвольный текст', EVENTLOG_INFORMATION_TYPE, 0, 1);

К сожалению, LogMessage имеет ограничения: вы можете использовать лишь 1 параметр.

Для того чтобы использовать несколько параметров, вам придётся написать свой аналог LogMessage, например такой:

...

  fEventLog: THandle;

...

  // где-то в OnCreate для TService:
  fEventLog := RegisterEventSource(nil, PChar(Name));

...

  // где-то в OnDestroy для TService:
  if fEventLog <> 0 then
  begin
    DeregisterEventSource(fEventLog);
    fEventLog := 0;
  end;

...

// новая процедура LogMessage:
  procedure TService.LogMessage(aID: Integer; aType: Integer; aMsg: String = ''; aMsgCount: Integer = 0);
  var
    P: array of PChar;
    D: PChar;
    X, Y: Integer;
  begin
    if fEventLog <> 0 then
      if aMsgCount <= 0 then
        ReportEvent(fEventLog, aType, 0, aID, nil, 0, 0, nil, nil)
      else
      if aMsgCount = 1 then
      begin
        D := PChar(aMsg);
        ReportEvent(fEventLog, aType, 0, aID, nil, 1, 0, @D, nil);
      end
    else 
    begin
      SetLength(P, aMsgCount); 
      for X := 0 to aMsgCount - 1 do
      begin
        Y := Pos(#0, aMsg);
        if Y > 0 then
        begin
          P[X] := PChar(Copy(aMsg, 1, Y - 1));
          aMsg := Copy(aMsg, Y + 1, MaxInt);
        end
        else
        begin
          P[X] := PChar(aMsg);
          aMsg := '';
        end;
      end;
      ReportEvent(fEventLog, aType, 0, aID, nil, aMsgCount, 0, @(P [0]), nil);
    end;
  end;

...

Пример вызова такой процедуры:

LogMessage(11, EVENTLOG_INFORMATION_TYPE, 'параметр 1'#0'параметр 2'#0'параметр 3', 3);

Кстати говоря, сообщения из подготовленной таблицы строк можно ещё извлечь для собственного использования через функцию FormatMessage.

Итак, суммируя сказанное, для добавления поддержки системных логов нужно сделать следующее: пишем набор сообщений в .mc-файл, для компиляции запускаем волшебный bat-ник, а в самой программе вызываем LogMessage.

Дополнительно

К сожалению, при своей самоустановке служба не добавляет комментарий к себе (эта возможность появилась в Windows 2000), а также не регистрирует в реестре источник логов. Поэтому, часто в службу приходится добавлять обработчики событий OnAfterInstall и OnAfterUninstall, например, так:

// После установки
procedure TService.ServiceAfterInstall(Sender: TService);
var
  Reg: TRegIniFile;
begin
  Reg := TRegIniFile.Create(KEY_ALL_ACCESS);
  try
    Reg.RootKey := HKEY_LOCAL_MACHINE;
    // Создаём системный лог для себя
    Reg.OpenKey('\SYSTEM\CurrentControlSet\Services\Eventlog\Application\' + Name, True);
    Reg.WriteString('\SYSTEM\CurrentControlSet\Services\Eventlog\Application\' + Name, 'EventMessageFile', ParamStr(0));
    TRegistry(Reg).WriteInteger('TypesSupported', 7);
    // Прописываем себе описание
    Reg.WriteString('\SYSTEM\CurrentControlSet\Services\' + Name, 'Description', 'Сюда вписывается описание вашей службы.');
  finally
    FreeAndNil(Reg);
  end;
end;

// После удаления
procedure TService.ServiceAfterUninstall(Sender: TService);
var
  Reg: TRegIniFile;
begin
  Reg := TRegIniFile.Create(KEY_ALL_ACCESS);
  try
    Reg.RootKey := HKEY_LOCAL_MACHINE;
    // Удалим свой системный лог
    Reg.EraseSection('\SYSTEM\CurrentControlSet\Services\Eventlog\Application\' + Name);
  finally
    FreeAndNil(Reg);
  end;
end;

По поводу отладки службы. Во-первых, вместо любимого многими программистами ShowMessage в службе удобно использовать процедуру OutputDebugString (объявлена в модуле Windows). Для просмотра отладочных сообщений можно использовать отладчик Delphi (View/Event log) или (что более удобно) - DebugView for Windows by Mark Russinovich

Во-вторых, можно использовать стандартный Run/Attach to process для подключения отладчика Delphi к уже запущенной службе. К сожалению, это не даст отлаживать код инициализации, т.к. к моменту подключения отладчика служба уже должна быть запущена. Впрочем, обходным путём можно решить проблему, например, так: вставить в начало dpr файла что-то вроде Sleep(5000) и за это время сразу после старта нужно успеть подключить отладчик Delphi.

Кстати, для управления службами в Windows есть консольные утилиты net.exe и sc.exe. Вы можете запустить их из командной строки без параметров для просмотра списка выполняемых действий.

Final words

И пару слов в завершение темы.

По поводу обработки ошибок в службах будет интересно посмотреть "вопрос КС №60359"

Ещё могут быть интересны вопросы по поводу программного управления службой (аналог своей оснастки "Службы") - например, "вопрос КС №59588"

Об опасностях использования различных компонент без понимания принципов их работы: "вопрос КС №55674" :)

Кое-что по интерактивным службам: "вопрос КС №58832", "вопрос КС №59632".

Кроме того, на Королевстве есть несколько неплохих статей по вопросу использования стандартных средств защиты Windows

В частности, в статье Пример использования Private Object Security в Delphi можно посмотреть пример готовой службы.

В этой статье не рассматривались вопросы написания приложений для MMC. В Delphi нет поддержки написания таких приложений. Но в Интернете можно найти компоненты, упрощающие их разработку, например: MMC Snapin Framework.


Страница сайта http://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=16705