Introduction
Dans les systèmes d’exploitation de type Windows NT, un service (ou service Windows) est un programme qui fonctionne en arrière-plan. Il est similaire à un daemon d’Unix. Un service doit se conformer aux règles d’interface et aux protocoles du Service Control Manager, le composant chargé de la gestion des services. (source : Wikipedia).
Contrairement à une tâche planifiée qui s’exécute de façon périodique, un service Windows fonctionne en continu en arrière-plan et peut même le faire de façon automatique dès le démarrage de la machine.
Dans cet article nous allons créer pas à pas un Service Windows (C# + .NET Core) qui devra effectuer de façon automatique un traitement toutes les 10 secondes, puis nous verrons comment le déployer. Pour nous simplifier le travail nous allons utiliser la librairie Topshelf (GitHub, documentation).
Pourquoi Topshelf ?
Je développais autrefois des Services Windows sans avoir recours à des librairies tierces. J’avais mis en place le code de gestion du Service (service boostrapper), le traitement mais aussi la gestion complète du cycle de vie ou encore l’intégration des fonctionnalités d’installation/désinstallation en ligne de commande. C’était un projet Console et cela me permettait donc de facilement debugger localement sans avoir besoin de systématiquement l’installer/désinstaller, contrairement à un projet de type Windows Service App. J’avais ensuite ajouté mon code de boostrapper, qui finalement était simple et suffisamment robuste pour mes besoins, sur un dépôt NuGet interne qui me permettait ainsi de le réutiliser dans l’ensemble de mes projets de type Service.
Topshelf est une librairie bien connue et très utilisée pour la création de Services Windows en C#, c’est pourquoi j’ai voulu la tester et la comparer avec mon code usuel. Ce que j’ai aimé sur la page de présentation de Topshelf :
- Facile d’utilisation
- Extrêmement configurable
- Efficace ligne de commande
- Compatibilité .NET Core/Framework
- Multi-platforme : hébergement de services sur à la fois Windows et Mono
Passons à la pratique…
Création du projet
Tout d’abord créons une solution que l’on nommera « WinServiceDemo ». Vous pouvez le réaliser via Visual Studio ou en ligne de commande :
Ensuite créons notre projet de type « Console » qu’on pourra nommer « WinServiceDemo.Console » :
Ajoutons le projet à la solution :
Nous avons maintenant notre projet Console prêt, ajoutons-y Topshelf.
Intégration de Topshelf
Topshelf peut être intégré à notre projet via son paquet NuGet, ajoutons-le :
Ajoutons les méthodes de cycle de vie de notre Service dans une nouvelle classe ServiceManager.cs :
Cette classe contient les méthodes suivantes :
- Start() : Appelée au démarrage du service. Permet par exemple l’instanciation de nos classes, elle doit être rapide et ne pas contenir de traitement.
- Stop() : Appelée à l’arrêt du service. Permet par exemple de correctement terminer nos opérations en cours (les annuler ou attendre leur fin) et si besoin nettoyer les références.
- Process() : Appelée à chaque intervalle de temps que l’on définira ultérieurement. C’est ici que l’on doit intégrer nos traitements.
Il est préférable d’ajouter des try/catch dans chacune de ces méthodes si elles sont susceptibles de générer une exception. Les exceptions ne doivent jamais conduire au crash de notre Service, n’oublions pas qu’il fonctionne automatiquement en arrière-plan.
Nous allons maintenant gérer les itérations de notre méthode de traitement. Afin de ne pas saturer le système il va falloir créer une boucle infinie avec une pause, pour cela nous allons utiliser un Timer qui permet de générer des événements récurrents.
Note : Il est important d’utiliser un Timer et non pas un while(true) { Thread.Sleep(delay) ; } car le Timer permet de libérer le thread principal quand il est en pause et ainsi rendre le service réactif à d’autres actions comme une demande d’arrêt ou tout autre événement. Le cas échéant votre Service restera indéfiniment en état « Arrêt en cours… » tant que Process() est en cours. Il serait alors peut-être possible de « bidouiller » avec par exemple un CancellationToken et en modifiant quelque peu le code, mais mieux vaut conserver un Timer qui répond parfaitement au besoin.
Ajoutons dans ServiceManager.cs les 2 variables pour le timer :
Notre Timer doit être configuré pour appeler un événement toutes les 10 secondes, ajoutons ces lignes dans Start() :
L’instanciation permet de créer le timer avec notre intervalle de 10 secondes, Elapsed permet de spécifier quel événement sera appelé à chaque intervalle et Start() permet de le démarrer.
Le Timer ne génère pas d’événement à l’instant 0 occasionnant ainsi le Service à attendre l’intervalle de temps pour s’exécuter la toute première fois au démarrage du Service. Lorsque le Service démarre il n’y a généralement aucun sens de faire attendre la première itération donc ici nous le forçons à s’exécuter au bout d’une seconde. Vous pouvez également appeler Process(null, null); pour l’exécuter mais pour plus de cohérence et un meilleur contrôle de l’ordonnancement j’ai ici préféré réutiliser notre Timer.
Afin de correctement nettoyer nos instances en cas d’arrêt du Service, ajoutons ceci dans Stop() :
Ajoutons une simulation de traitement d’une seconde dans Process() :
Nous avons ici ajouté plusieurs choses :
- un try/catch afin que le service ne soit jamais en erreur.
- en début de méthode nous désactivons (Enabled) le Timer, puis à la fin nous le réactivons. Ceci permet d’éviter que les appels à Process() puissent se chevaucher, par exemple avec nos 10 secondes de pause et s’il met 12 secondes à s’exécuter alors nous aurions eu 2 traitements en parallèle entre la 10ème et 12ème seconde.
- si l’intervalle actuel du Timer est différent de nos 10 secondes (variable TimerInterval) alors nous le mettons à sa valeur de base. Dans notre cas cela ne sera vrai qu’à la première itération seulement afin de pouvoir lancer le Process() plus rapidement. Dans certains cas vous pouvez de la même façon adapter cette valeur, par exemple en cas de forte charge le délai est de 1 seconde, charge moyenne 1 minute et charge faible 10 minutes.
Notre ServiceManager étant prêt, il n’y a plus qu’à le lier à Topshelf. Dans Program.cs, ajoutons le code suivant :
HostFactory.Run() permet de définir le host Service de Topshelf. En paramètre on y mappe les actions du Service avec notre code, ensuite on définit les options puis son nom et description. En fin de méthode nous récupérons et renvoyons le code retour à l’appelant.
Nous avons dorénavant notre application opérationnelle, testons-là.
Debug
Il est très simple de debugger notre application, après tout ce n’est qu’une application Console, certes gérée par Topshelf, mais qui fonctionne de façon totalement transparente. Il suffit alors d’utiliser le traditionnel raccourci « F5 » de Visual Studio ou exécuter la commande « dotnet run ».
Pour tester la partie « host » de Topshelf et son intégration avec le Service Control Manager de Windows nous allons devoir la déployer.
Déploiement
Inutile d’utiliser des commandes « sc.exe » qui peuvent s’avérer fastidieuses à utiliser, notre application est un .exe donc faisons-en sorte de profiter au maximum de ses possibilités. Grâce à Topshef notre « host » prend nativement ces fonctionnalités en charge, voici les étapes :
- Création de l’artifact :
- Installation du Service :
- Démarrage du Service :
Nous aurions pu démarrer le service via la commande « WinServiceDemo.Console.exe start », mais ici nous avons utilisé des commandes Powershell car en appelant notre exécutable alors la méthode Main() de notre classe Program.cs loguerait 2 fois (le .exe de la commande puis le .exe du service), et si vous loguez en fichier alors ces fichiers pourraient être lockés car utilisés par un autre processus.
- Arrêt du Service :
On peut de la même manière ici utiliser la commande stop de Topshelf.
- Désinstallation du Service :
Lorsque vous le désinstallez la méthode Stop() est appelée et votre ServiceManager correctement nettoyé. Cependant j’ai remarqué que cette façon de faire ne l’arrête pas toujours correctement, l’état du service étant marqué comme désactivée et votre WinServiceDemo.Console.exe toujours actif dans le Gestionnaire des Tâches Windows. Ce problème signifie que nous ne pouvons pas copier la nouvelle version de notre application parce que certains fichiers sont en cours d’utilisation et donc verrouillés par le Système d’Exploitation. L’arrêt du service via une commande Powershell puis ensuite le désinstaller ainsi a corrigé ce problème.
Vous pouvez retrouver toutes les commandes CLI dans la documentation de Topshelf.
Intégration pipeline CI/CD
Voici les étapes présentes dans mon Déploiement Continu, mes scripts Powershell se trouvant dans le répertoire /scripts :
1/ Récupérer l’artifact (obtenu via un « dotnet publish »)
2/ Eventuellement renseigner vos variables d’environnement ou valeurs dans appsettings.json
3/ service-stop.ps1 : Arrêter le Service. Si le service n’existe pas (premier déploiement par exemple), cette commande s’auto-ignore
4/ service-uninstall.ps1 : Désinstallation du Service. Si le .exe est manquant (premier déploiement par exemple), cette commande s’auto-ignore
5/ service-wait-locks.ps1 : Dans mon cas j’avais de très longs threads/tâches créés à partir de ma méthode Process() et rendant l’arrêt/désinstallation du Service très long car je souhaitais que les actions en cours se terminent entièrement, cela pouvant provoquer un timeout. Pour éviter des erreurs dues aux locks de fichiers du Système d’Exploitation durant la copie de la nouvelle version, ce script permet de patienter jusqu’à ce qu’il n’y ai plus de lock et que la copie devienne ainsi sûre. Dans mon application j’utilise log4net pour les logs, donc si log4net.dll n’est plus lockés alors le reste des fichiers ne doivent plus l’être non plus. Vous auriez aussi pu utiliser WinServiceDemo.Console.exe comme fichier de vérification.
6/ Copier l’artifact sur votre serveur
7/ service-install.ps1 : Installation du Service
8/ service-start.ps1 : Démarrage du Service
9/ Enjoy!
Quelles alternatives à Topshelf ?
Il y en a plusieurs ! Comme je l’ai évoqué en début d’article, vous pouvez le réaliser « à l’ancienne » en créant un projet « Windows Service App » avec son Installer, ou une application Console en gérant son installation avec des commandes sc.exe ou mieux en créant votre propre bootstrapper. Mais ces alternatives ne sont plus vraiment viables au vu de ce qu’apporte Topshelf.
Encore mieux, si vous utilisez .NET Core 3.0+, vous pouvez créer un projet de type « Worker » (« dotnet new worker »). Les projets Worker intègrent la nouvelle philosophie .NET Core et sont donc similaires à ASP.NET Core dans leur façon d’être configuré et de fonctionner. Vous pouvez ainsi très facilement réutiliser votre code et les intégrer ensemble, mais vous devrez cependant utiliser les commandes sc.exe.
Si vous utilisez des services Cloud alors vous pourrez plutôt être intéressé par basculer vers Azure Functions ou encore AWS Lambda.
Conclusion
Le Service que je devais réaliser était étroitement lié à une ASP.NET Core Web API / .NET Framework avec réutilisation des différentes couches du projet, par ailleurs il devait utiliser certaines librairies legacy incompatibles avec .NET Core, c’est pourquoi j’ai préféré rester sur .NET Framework plutôt que de créer un Worker.
Topshelf m’a permis d’éviter d’écrire du code et m’a donc fait gagner en productivité (et fonctionnalité en cas d’évolution du besoin), d’autant plus qu’il met à disposition énormément de fonctionnalités notamment une très efficace ligne de commande. Comme nous avons pu le voir dans cet article il est très simple de créer un Service Windows en C#, l’aide de Topshelf à simplifié l’utilisation, il est bien documenté, il permet le debug on ne peut plus facilement ainsi qu’il facilite le déploiement. Pour tout futur Service en .NET Framework je le réutiliserais sans la moindre hésitation. Autre atout il fonctionne également parfaitement en .NET Core.
En revanche, il est très probable que la plupart de vos futurs projets soient en .NET Core, et que dans votre Solution vous ayez également de l’ASP.NET Core. Si c’est le cas les projets Worker seront plus appropriés car ils s’intègrent parfaitement à l’écosystème .NET Core. En règle générale, on peut donc dire que pour du .NET Framework Topshelf sera la solution, et que pour du .NET Core (Windows/Linux/Mac) les projets Worker seront plus appropriés.
Vous pouvez retrouver l’ensemble du code source de cet article sur GitHub.