Home .NET Création et déploiement d’un Service Windows C# avec Topshelf

Création et déploiement d’un Service Windows C# avec Topshelf

  Gilles, Architecte technique .NET 20 min 20 août 2020

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 :

md src
cd src
dotnet new sln -n "WinServiceDemo

Ensuite créons notre projet de type « Console » qu’on pourra nommer « WinServiceDemo.Console » :

dotnet new console -n "WinServiceDemo.Console"

Ajoutons le projet à la solution :

dotnet sln add "WinServiceDemo.Console"

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 :

cd "WinServiceDemo.Console"
dotnet add package Topshelf --version 4.2.1

Ajoutons les méthodes de cycle de vie de notre Service dans une nouvelle classe ServiceManager.cs :

namespace WinServiceDemo.Console
{
    public class ServiceManager
    {
        public void Start()
        {
        }

        public void Stop()
        {
        }

        private void Process(object sender, ElapsedEventArgs eventArgs)
        {
        }
    }
}

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 :

using System;
using System.Timers;

namespace WinServiceDemo.Console
{
    /// <summary>
    /// Service manager containing service actions and managing processing.
    /// </summary>
    public class ServiceManager
    {
        /// <summary>
        /// The timer interval
        /// </summary>
        private static readonly double TimerInterval = 10_000; //10 seconds
        /// <summary>
        /// The service timer
        /// </summary>
        private Timer _timer;

Notre Timer doit être configuré pour appeler un événement toutes les 10 secondes, ajoutons ces lignes dans Start() :

        {
            //Initialize your service related instances here.
            //Do not do it at service level otherwise ressources won't be properly disposed on stop/restart of the service.

            System.Console.WriteLine("Starting service...");

            //Initialize timer:
            //Note: First time you have to wait for interval before first tick.
            //To start quickly we set a short interval that will be overwritten on next tick.
            //You could also call: Process(null, null);
            _timer = new Timer(1_000);
            //Set action associated to each tick
            _timer.Elapsed += Process;
            //Start the timer
            _timer.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() :

        public void Stop()
        {
            //Clean your ressources here

            System.Console.WriteLine("Stopping service...");

            //Stop timer
            _timer.Stop();
            //Dereference tick action
            _timer.Elapsed -= Process;
            //Dispose timer
            _timer.Dispose();

            //Note: you can create a while(true) here waiting for you pending process to properly end.
            //In that case, you can for example add an IsProcessing property and set while(IsProcessing) { }
            //In your Process() method, set "IsProcessing = true;" at the start and "false" at the end.
            //If your processes can overlap, you neet to improve this code accordingly.
        }

Ajoutons une simulation de traitement d’une seconde dans Process() :

        {
            //Trick to avoid processes to overlap in case process duration longer than timer tick duration
            //If your Process() is slow or you need exact same time between 2 starts you have to do it in another way
            _timer.Enabled = false;

            System.Console.WriteLine("Processing...");
            
            try
            {
                //Make sure there is no error thrown from the process method

                //Your processing code here...
                //...
                System.Threading.Thread.Sleep(1_000); //Simulate a 1 second operation
                //...
            }
            catch (Exception ex)
            {
                System.Console.WriteLine($"Exception: {ex.Message}");
            }

            System.Console.WriteLine("Processing... DONE!");

            //If interval is different from defined interval we set it to default value.
            //This is the case on Start() method, we set a small interval to tick Process() method asap.
            //You can also adapt interval depending on charge (e.g. 1sec hight charge, 1min avg charge, 10mins low charge)
            if (_timer.Interval != TimerInterval)
                _timer.Interval = TimerInterval;
            //Unpause timer, it can resume
            _timer.Enabled = true;

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 :

        static void Main(string[] args)
        {
            //Configure service host
            var rc = HostFactory.Run(configure =>
            {
                //Service actions
                configure.Service<ServiceManager>(s =>
                {
                    s.ConstructUsing(() => new ServiceManager());
                    s.WhenStarted(i => i.Start());
                    s.WhenStopped(i => i.Stop());
                    s.WhenShutdown(i => i.Stop());
                });

                //Service settings
                configure.RunAsLocalSystem();
                configure.StartAutomatically();
                configure.SetStopTimeout(TimeSpan.FromSeconds(10));
                configure.OnException(ex => { System.Console.WriteLine($"Windows Service level exception: {ex.Message}"); });

                //Service description
                configure.SetServiceName("WinServiceDemo.Console");
                configure.SetDisplayName("WinServiceDemo.Console");
                configure.SetDescription("C# Windows Service demo using Topshelf");
            });

            //Exit code
            var exitCode = (int)Convert.ChangeType(rc, rc.GetTypeCode());
            System.Console.WriteLine($"Exit code: {exitCode}");
            Environment.ExitCode = exitCode;
        }

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 :

  1. Création de l’artifact :
dotnet build
dotnet publish
  1. Installation du Service :
WinServiceDemo.Console.exe install
  1. Démarrage du Service :
Get-Service -Name "WinServiceDemo.Console" | Start-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.

  1. Arrêt du Service :
Get-Service -Name "WinServiceDemo.Console" | Stop-Service

On peut de la même manière ici utiliser la commande stop de Topshelf.

  1. Désinstallation du Service :
WinServiceDemo.Console.exe uninstall

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

$service = Get-Service -Name "$(ApplicationName)" -ErrorAction SilentlyContinue
if ($service.Length -gt 0) {
    Write-Host "Stopping $(ApplicationName) service"
    Get-Service -Name "$(ApplicationName)" | Stop-Service
} else {
    Write-Host "Service $(ApplicationName) not found"
}

4/ service-uninstall.ps1 : Désinstallation du Service. Si le .exe est manquant (premier déploiement par exemple), cette commande s’auto-ignore

Write-Host "Create path if needed: $(ServicePath)"
New-Item -ItemType Directory -Force -Path $(ServicePath)

$exePath = "$(ServicePath)\WinServiceDemo.Console.exe"
Write-Host "Executable path: $exePath"
if (Test-Path $exePath) {
  $command = "$exePath uninstall"
  Write-Host "Executable found, uninstalling: $command"
  iex $command
}

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.

while($isLocked)
{
    if (Test-Path $file) {
        #File exists: neet to check if locked
    } else {
        # File missing, thus nothing should be locked
        $isLocked = $false;
    }

    Write-Host "File $(ServicePath)\log4net.dll is locked. Waiting for the lock to be released. Next try in 10 seconds..."
    Start-Sleep -s 10
    try {
        # [IO.File]::OpenWrite($file).close();
        Rename-Item -Path "$file" -NewName "log4net.dll.old"
        $isLocked = $false;
    }
    catch {
        $isLocked = $true;
        Write-Warning $Error[0]
    }
}

6/ Copier l’artifact sur votre serveur

7/ service-install.ps1 : Installation du Service

$exePath = "$(ServicePath)\WinServiceDemo.Console.exe"

$command = "$exePath install"
Write-Host "Installing: $command"
iex $command

8/ service-start.ps1 : Démarrage du Service

Write-Host "Starting: $(ApplicationName)"
Get-Service -Name "$(ApplicationName)" | Start-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.

Lire les articles similaires

Laisser un commentaire

Social Share Buttons and Icons powered by Ultimatelysocial