Dienste programmieren mit .NET

11.11.2003 von THOMAS WOELFER 
Mit der Win32-API gestaltete sich das Programmieren von Windows-Diensten mühsam und fehleranfällig. Wie Sie mit dem .NET-Framework jetzt schnell und einfach Ihren eigenen Windows-Dienst erstellen, zeigt dieser Beitrag.

Die so genannten Windows-Dienste gibt es schon seit Windows NT 3.1 und natürlich auch in 2000, XP und 2003. Diese Dienste bieten den Vorteil, dass sie auch laufen, ohne dass eine Person am Rechner angemeldet ist: Der Rechner bietet dann eben einen Dienst an, den andere Rechner nutzen können.

Dienste schaffen aber auch Probleme -sie sind relativ kompliziert zu programmieren, zu debuggen und zu installieren. .NET und das Visual Studio machen die Entwicklung von Diensten jedoch erheblich einfacher, als das mit der Win32-API der Fall ist. Dennoch gibt es immer noch genügend Fallstricke.

Bevor es an die Entwicklung des ersten eigenen Dienstes geht, sind einige Hintergrundinformationen unabdingbar. Ein Dienst ist ein Programm, das beim Booten des Rechners automatisch gestartet werden kann und so lange läuft, wie der Rechner eingeschaltet ist. Dazu muss kein Anwender angemeldet sein, da der Dienst in einem speziellen Windows-Kontext läuft. Hier werden entweder die Anmeldedaten eines Benutzer-Accounts verwendet oder der lokale System-Account.

Das hat verschiedene Konsequenzen - unter anderem die, dass ein Dienst nicht einfach von einem Anwender gestartet und benutzt werden kann. Vielmehr unterliegt die Kontrolle der Dienste dem Service Control Manager, einer zentralen Windows-Komponente. Daher muss ein Dienstprogramm dem Service Control Manager bekannt gemacht werden. Für Dienste, die mit der .NET-Klassenbibliothek entwickelt wurden, gibt es dazu das spezielle Werkzeug InstallUtil.exe.

Die kompletten Quelltexte unserer Beispielprogramme können Premium-Kunden über die "Links zum Beitrag" kostenlos herunterladen.

Dienste und deren Modi

Ist der Dienst installiert, muss er auch noch in den Betriebsmodus versetzt werden. Dienste können sich nämlich in verschiedenen Zuständen befinden: "Angehalten", "Gestoppt" und "Gestartet". Es gibt auch noch einige Zwischenmodi, diese sind jedoch vorerst nicht von Bedeutung. Ferner ist für einen Dienst festzulegen, wie und ob er gestartet werden kann oder darf.

Den Startmodus, den Status eines Dienstes und die zu verwendenden Account-Daten legen Sie bei der Installation fest. Nachträgliche Änderungen lassen sich über das Applet "Dienste" in der Systemsteuerung vornehmen.

Wird ein Rechner nie oder nur selten neu gestartet, resultiert daraus eine sehr lange Laufzeit eines Dienstes. Das bedeutet auch, dass ein Dienst gegen kleinere Programmierfehler potenziell anfälliger ist als ein normales Anwendungsprogramm. Liegt in einem Dienst beispielsweise ein Speicherleck vor, so kann selbst ein kleines Leck im Laufe der Zeit eine ganze Menge Speicher reservieren und nicht wieder freigeben. Da der Dienst jedoch nicht wie ein Anwendungsprogramm hin und wieder beendet und neu gestartet wird, erfolgt auch nie wieder eine Freigabe des verbrauchten Speichers. Irgendwann wird der Dienst deswegen versagen oder zumindest die Systemleistung erheblich beeinflussen.

Natürlich kann ein Dienst auch aus anderen Gründen versagen - zum Beispiel durch einen normalen Programmfehler, der sich erst nach einiger Zeit oder durch das Eintreten besonderer Umstände bemerkbar macht. Bei Diensten passiert dann das Gleiche wie bei normalen Programmen: Sie stürzen ab.

Nun ist der Sinn eines Dienstes aber der, dass er ständig verfügbar ist. Darum sieht der Service Control Manager die Möglichkeit vor, einen abgestürzten Dienst automatisch neu zu starten, den Rechner neu zu booten oder ein beliebiges Programm auszuführen, etwa um eine E-Mail oder SMS an den Administrator zu senden.

Dienste unter .NET

Ein Windows-Dienst, der auf der ".NET Service Klasse" basiert, hat mindestens zwei Methoden: OnStart() undOnStop(). OnStart wird beim Start des Dienstes aufgerufen - im Falle eines Autostart-Dienstes also beim Start des Rechners. OnStop kommt zum Einsatz, wenn der Dienst beendet wird, sei es durch Herunterfahren des Rechners oder über das Dienste-Applet der Systemsteuerung.

Der Service Control Manager kümmert sich darum, dass die beiden Methoden OnStart und OnStop aufgerufen werden: Wie aber stellt man dann den eigentlichen Dienst zur Verfügung? In der Methode OnStart() kann man das nicht tun, denn dann würde die Methode nicht terminieren und der Service Control Manager würde das als ein Fehlschlagen des Dienstestarts interpretieren. In OnStop() hingegen ist es zu spät, noch irgendwelche Aktionen durchzuführen: Es macht schließlich wenig Sinn, die Dienste erst anzubieten, wenn der Rechner gerade dabei ist, heruntergefahren zu werden.

Anderer Code wird aber nicht aufgerufen - ein Dienst hat keine Funktion main() oder einen ähnlichen Eintrittspunkt. Alles was es gibt, ist eben OnStart(). Man braucht also einen Weg, um in dieser Methode Code auszuführen, der so lange läuft, bis er in OnStop() angehalten wird. Die Lösung ist einfach: Man öffnet in OnStart() einen zusätzlichen Thread, den man in OnStop() wieder schließt.

Dienste brauchen Threads

Threads sind nichts Besonderes, denn jedes Windows-Programm hat mindestens einen, und man muss sich beim Programmieren normalerweise nicht darum kümmern, dass es einen Thread gibt. Man geht einfach immer implizit davon aus, dass innerhalb eines Programms keine zwei Funktionen asynchron zueinander aufgerufen werden können. Alles wird immer nacheinander in nur einem Thread ausgeführt.

Komplizierter wird die Sache allerdings, wenn man mit mehreren Threads arbeitet. In einem solchen Fall kann es plötzlich passieren, dass eine Funktion, die das Resultat einer anderen Funktion erwartet, schon läuft, bevor die andere Funktion überhaupt beendet wurde. Das erschwert die Arbeit mit mehreren Threads erheblich und führt meist dazu, dass man die Verwendung paralleler Threads vermeidet - auch wenn es Anforderungen gibt, die nur mit multiplen Threads befriedigend zu lösen sind.

Bei der Programmierung von Diensten kommt man jedoch um multiple Threads nicht herum: Allerdings ist das hier nicht besonders kompliziert, denn effektiv gibt es nur einen Thread, der die Aufgabe des Dienstes erledigt. OnStart und OnStop kommen diesem Thread nicht in die Quere.

Ein Thread besteht im Wesentlichen aus einer Funktion, die beim Start des Threads aufgerufen wird. So lange sie nicht von selbst terminiert oder von außen beendet wird, läuft der Thread.

Die Thread-Klasse vereinfacht die Arbeit

Einen Thread starten Sie, indem Sie einfach eine neue Instanz der Thread-Klasse erzeugen. Dazu brauchen Sie aber erst einmal ein ThreadStart-Objekt, das die Informationen über den zu startenden Thread beinhaltet. Die wichtigste Information ist die Adresse der Funktion, die als Eintrittspunkt in den Thread dienen soll. Das ThreadStart-Objekt ist ein so genannter Delegate mit einer ganz bestimmten Signatur, die die Thread-Funktion ebenfalls aufweisen muss. Die Signatur ist einfach: Die Funktion bekommt keine Parameter und hat keinen Rückgabewert. Im Beispielprogramm hat die Thread-Funktion den Namen "ComThread".

Ist das Thread-Objekt erzeugt, kann man den Thread starten lassen, indem man die Methode Start() des Objekts aufruft:

protected override void OnStart(string[] args)
{
thread = new Thread( new ThreadStart( this.ComThread));
thread.Start();
}

Um die etwas komplexen Zusammenhänge nochmals zu rekapitulieren: Die Methode OnStart() des Dienstprogramms wird vom Service Control Manager aufgerufen. Innerhalb dieser Methode wird ein neuer Thread gestartet, der den eigentlichen Dienst beinhaltet. Mit diesem können andere Programme von außen kommunizieren. Das Programm mit der OnStart()-Methode lässt sich zudem ausschließlich über den Service Control Manager ausführen, ein direkter Start ist nicht möglich. Schwere Kost - auch wenn der bisher geschriebene Quellcode gerade einmal drei Zeilen umfasst.

Aufräumen nicht vergessen

Wird der Rechner heruntergefahren oder stoppen Sie den Dienst über das Dienste-Applet, ruft der Service Control Manager die Methode OnStop() auf. Hier sollte Ihr Code benutzte Ressourcen wieder aufräumen oder relevante Zustände auf der Festplatte speichern, damit Sie beim nächsten Aufruf von OnStart() wieder darauf zurückgreifen können.

In unserem "Beispiel" wäre es nicht unbedingt notwendig, irgendetwas aufzuräumen. Den in OnStart() begonnenen Thread sollten Sie aber trotzdem dezidiert beenden, da das gute Programmierpraxis ist. Einen Thread brechen Sie mit der Methode Abort() des Thread-Objekts ab, und genau das passiert auch in OnStop():

protected override void OnStop()
{
thread.Abort();
}

Implementierung des Dienstes

Jetzt geht es noch um die eigentliche Aufgabe des Threads. Unser Beispiel implementiert dabei eine kleine Remote-Shell - allerdings ohne echte Shell-Funktionalität: Man kann zwar Kommandos an die Shell senden, diese sendet jedoch immer nur das gleiche Echo zurück. Die tatsächliche Shell-Funktionalität können Sie aber leicht hinzufügen.

Die Kommunikation findet ganz einfach mit Hilfe der TcpClient-Klasse aus .NET statt:

Int32 port = 6000;
IPAddress localAddr = IPAddress.Parse("127.0.0.1");
TcpListener server = new TcpListener(localAddr, port);
server.Start();
Byte[] bytes = new Byte[256];
String data = null;
while(true)
{
TcpClient client = server.AcceptTcpClient();
data = null;
NetworkStream stream = client.GetStream();
Int32 i;
i = stream.Read(bytes, 0, bytes.Length);
data = System.Text.Encoding.ASCII.GetString(bytes, 0, i);
data = "Kommando erhalten";
Byte[] msg = System.Text.Encoding.ASCII.GetBytes( data);
stream.Write(msg, 0, msg.Length);
client.Close();
}

Im Wesentlichen besteht der Thread aus einer Schleife, in der er auf die Verbindungsaufnahme durch einen TCP-Client wartet, diese abarbeitet und beantwortet. Dabei muss man beachten, dass diese Art der Kommunikation noch nicht wirklich ausreichend ist: Sobald Daten anliegen, werden diese auch gelesen. Wenn Sie also mit dem Dienst per Telnet Verbindung aufnehmen, bekommen Sie Probleme beim Tippen. Schon die ersten Zeichen werden sofort als Kommando interpretiert - komplette Kommandos können Sie gar nicht eingeben.

Um das zu verbessern, müssten Sie das Beispiel noch erweitern. Dazu könnten Sie sich zum Beispiel ein bestimmtes Kommando-Trennzeichen einfallen lassen: Erst nach Erhalt dieses Zeichens arbeitet der Dienst das Kommando ab. Dazu müssten so lange Daten eingelesen werden, bis das "Kommando-Ende"-Zeichen eingeht. Im Beispiel wird das aber nicht getan.

Hilfe-Tools: Das InstallUtil

Nach dem Kompilieren des Projekts geht die Komplexität aber gleich weiter. Beim Versuch, das fertige Programm zu starten, erhalten Sie eine Fehlermeldung: Das Programm kann ohne Installation nicht gestartet werden, da der Dienst dem Service Control Manager nicht bekannt ist. Das gilt auch für das Debuggen: Man kann das Programm nicht einfach so debuggen. Stattdessen muss man den Dienst installieren, starten und sich dann vom Debugger aus mit dem Dienst verbinden.

Die Installation des Dienstes erfolgt mit dem Programm InstallUtil aus dem .NET Framework. Nur geht auch das nicht ohne Weiteres. InstallUtil ist zwar in der Lage, .NET Assemblies zu installieren - auch solche, die Windows-Dienste beinhalten -dazu braucht es aber noch ein paar weitere Informationen: Schließlich kann InstallUtil nicht raten, was für eine Art Dienst installiert werden soll.

Um diese Informationen unterzubringen, benötigt man eine ProjectInstaller-Komponente. Sie setzt sich aus einem ServiceProjectInstaller und einem ServiceInstaller zusammen. Der Grund dafür ist einfach: Ein Service-Projekt kann mehrere Dienste beinhalten. Darum muss man für das Projekt einen Installer haben, der die Informationen über die ausführbare Datei enthält. Ferner braucht man für jeden Service im Projekt einen ServiceInstaller, der die Informationen über den spezifischen Dienst enthält. Die Vorlagen für diese Komponenten sind übrigens nur in der Professional-Variante von Visual Studio enthalten.

Man kann das Projekt ganz einfach um die benötigten Komponenten erweitern, indem man in den Eigenschaften der Service-Komponente auf "AddInstaller" klickt. Dadurch erscheint eine neue Komponente in der Solution-Ansicht. In der Designer-Ansicht der Komponente kann man dann die Eigenschaften des ProjectInstaller und des ServiceInstaller bearbeiten.

Hier ist hauptsächlich der ServiceInstaller von Interesse. Er benötigt einen sinnvollen Namen (DisplayName), der dann später beispielsweise im Dienste-Applet der Systemsteuerung angezeigt wird. Ferner muss man einen eindeutigen internen Namen (ServiceName) vergeben, unter dem der Dienst identifizierbar ist. Dieser wird zum Beispiel verwendet, wenn per Kommandozeile (net start) auf den Dienst zugegriffen wird. Schließlich kann man hier auch noch den Starttyp des Dienstes und die Abhängigkeiten von anderen Diensten angeben.

Installation und Deinstallation

Wird das Projekt nun übersetzt, erhalten Sie ein Executable, das mit InstallUtil tatsächlich installiert werden kann. Verwenden Sie dazu den Aufruf:

InstallUtil WindowsService1.exe

InstallUtil legt dabei eine Log-Datei im aktuellen Verzeichnis an, in der die Abläufe der Installation mitprotokolliert werden. Geht bei der Installation etwas schief, können Sie diese Log-Datei auf Fehlermeldung überprüfen.

Ist der Dienst einmal installiert, gibt es gleich das nächste Problem: Es stellt sich die Frage, wie man den Dienst wieder loswird - und zwar spätestens, wenn man einen Fehler beseitigt und die nächste Version des Dienstes übersetzt hat. Beim Versuch, diese Version dann zu installieren, erhalten Sie eine Fehlermeldung, dass der gegebene Dienst bereits installiert ist. Man muss ihn also erst deinstallieren.

Wie nicht anders zu erwarten, geht das natürlich nicht so, wie man denkt. Weder im Dienste-Applet noch in der Anwendung "Software" der Systemsteuerung findet sich ein Weg, den Dienst wieder loszuwerden.

Das erledigen Sie wieder über InstallUtil, indem Sie es mit dem Parameter /uninstall und dem Namen des Dienstes aufrufen. Danach können Sie die neue Version installieren.

Ist das geschehen, können Sie den Dienst aber noch immer nicht nutzen oder debuggen. Dazu ist er zunächst zu starten. Das geht zum Beispiel über den Server-Explorer in Visual Studio, das Dienste-Applet oder die Kommandozeile mittels net start. Dann ist es endlich so weit: Der Dienst ist benutzbar. Zum Testen können Sie zum Beispiel den Telnet-Client von Windows verwenden.

Dienste debuggen

Bleibt noch die Frage nach dem Debuggen: Da der Debugger den Dienst nicht starten kann, ist es auch nicht möglich, einfach im Debugger "F5" zu drücken und den Quellcode schrittweise abzuarbeiten. Stattdessen verbinden Sie sich mit dem laufenden Dienst, indem Sie den Befehl "Processes" (Prozesse) im Debug-Menü von Visual Studio aufrufen. Dieser öffnet einen Dialog, der die gerade aktiven Prozesse anzeigt. Wenn Sie dort die Option "Show System Processes" einschalten, dann taucht auch der eigene Dienst in der Liste auf.

Drücken Sie auf "Attach", so öffnet sich eine weitere Dialogbox, über die Sie einstellen, für welche Sprache Sie den Debugger mit dem Dienst verbinden wollen. Hier sind aber die richtigen Einstellungen schon von selbst aktiviert. Nach einem Klick auf "OK" können Sie im Quellcode-Editor ganz normal Haltepunkte setzen und damit beginnen, den Dienst nach Fehlern zu untersuchen.

Dienst kontrollieren mit dem eigenen Service-Controller

Oft ist ein Dienst nur dafür gedacht, die eigenen Programme mit bestimmten Fähigkeiten auszustatten. In einem solchen Fall will man sicherlich die Möglichkeit haben, einen Dienst programmatisch zu kontrollieren: Man will zum Beispiel einen Dienst unter Umständen zwischenzeitlich anhalten und dann wieder starten. Wie so etwas funktioniert, findet sich als Beispiel im Service-Controller-Projekt (SC).

Damit ein Dienst angehalten und wieder gestartet werden kann, muss er diese Funktionalität zunächst einmal unterstützen. Dazu setzen Sie in den Eigenschaften des Dienstes das Flag "CanPauseAndContinue" auf true. Das reicht aber nicht aus - man muss außerdem zwei Methoden der Basisklasse überladen: OnPause() und OnContinue(). In unserem Beispiel wird dabei einfach ein Flag gesetzt. Wenn "paused" den Wert true hat, stellt der Dienst seine Tätigkeit ein. Wird dann der Dienst befragt, liefert er nicht länger die Zeichenfolge "Kommando erhalten", sondern einen anderen konstanten Text:

if( paused) data = "Service pausiert";
else data = "Kommando erhalten";

Ist das im Dienst vorgesehen, kann man einen kleinen Service-Controller programmieren, der einen laufenden Dienst pausiert und einen pausierten wieder weiterlaufen lässt.

In unserem Beispiel haben wir den Service Controller als Anwendung angelegt, die sich nur im Notifizierungsbereich der Task-Leiste bemerkbar macht. Mit einem Doppelklick auf das Icon erhält der Anwender eine kleine Dialogbox, die lediglich über einen Button verfügt. Damit kann der Anwender den laufenden Dienst pausieren und einen pausierenden weiterlaufen lassen.

Notifizierungs-Icon

Zunächst zur Dialogbox und dem Notifizierungs-Icon: Dazu legen Sie einfach eine neue "Windows-Forms"-Anwendung an. Auf das Form ziehen Sie einen Timer und ein "NotifyIcon"-Objekt, dem Sie auch noch ein Icon zuweisen können. Die Timer-Behandlung wird nur einmal durchgeführt. Beim ersten Timer-Event setzt der Code den Timer auf "Disabled" und versteckt die Dialogbox. Dadurch wird sie direkt nach dem Start unsichtbar:

private void OnElapsed(object sender, System.Timers.ElapsedEventArgs e)
{
this.timer1.Enabled = false;
this.Hide();
}

Um die Dialogbox wieder sichtbar zu machen, brauchen Sie einen EventHandler für das Event DoubleClick des NotifyIcon-Objekts. In diesem Handler machen Sie die Dialogbox wieder sichtbar:

private void OnDblClick(object sender, System.EventArgs e)
{
this.Show();
}

Dann brauchen Sie noch einen Button auf dem Form, mit dem Sie den Zustand des Dienstes auf pausiert setzen können. Hierzu benötigen Sie aber auch Zugriff auf den Dienst selbst. Den erhalten Sie über ein passendes ServiceController-Objekt: Öffnen Sie den Server-Explorer in Visual Studio und dann die Liste der Dienste auf dem lokalen Rechner.

Dort finden Sie auch den eigenen TestService. Über das Objektmenü des Dienstes fügen Sie ihn zum Designer dazu. Das Form bekommt dann eine neue Member-Variable, mit der Sie Zugriff auf den Dienst haben.

Im Eventhandler des Buttons können Sie diese Variable verwenden, um den Dienst zu pausieren oder wieder weiterlaufen zu lassen:

private void button3_Click(object sender, System.EventArgs e)
{
if( this.serviceController1.CanPauseAndContinue)
{
System.ServiceProcess.ServiceControllerStatus s = this.serviceController1.Status;

if( s == System.ServiceProcess.ServiceControllerStatus.Running)
{
this.serviceController1.Pause();
}
else
{
this.serviceController1.Continue();
}
this.Hide();
}
}

Fazit

Die Programmierung von Diensten unter Windows gehört in der Tat zu den aufwendigeren Aufgaben: Container-Projekte, asynchrone Aufrufe von Start- und Stop-Methoden, multiple Threads und Interprozesskommunikation legen dem Programmierer genauso Steine in den Weg wie die komplizierte Installation und Aktivierung von Diensten. Die .NET-Klassen machen die Sache deutlich einfacher, als das mit der normalen Win32 API der Fall ist.

Das vorliegende Beispiel eignet sich bereits als eine einfach zu erweiternde Vorlage für weitere Server-Dienste. Egal welche Art von Dienst Sie auf Ihrem Rechner, in Ihrem LAN oder über Ihren Server anbieten möchten: Mit dem vorliegenden Projekt haben Sie zumindest die komplette benötigte Infrastruktur bereits zur Hand und können sich um die eigentliche Problemstellung kümmern. (mha)