paint-brush
La meilleure façon d'utiliser les minuteries dans .NET C#par@ahmedtarekhasan
5,927 lectures
5,927 lectures

La meilleure façon d'utiliser les minuteries dans .NET C#

par Ahmed Tarek Hasan30m2023/03/29
Read on Terminal Reader

Trop long; Pour lire

En utilisant System.Timers.Timer dans votre application C#, vous pourriez rencontrer des problèmes pour l'abstraire et pouvoir couvrir vos modules avec des tests unitaires. Dans cet article, nous discuterons des meilleures pratiques pour relever ces défis. À la fin, vous serez en mesure d'atteindre 100 % de couverture de vos modules.
featured image - La meilleure façon d'utiliser les minuteries dans .NET C#
Ahmed Tarek Hasan HackerNoon profile picture

Comment avoir un contrôle total sur la minuterie et être capable d'atteindre une couverture de 100 % avec les tests unitaires

Lorsque vous utilisez System.Timers.Timer dans votre application .NET C# , vous pouvez rencontrer des problèmes pour l'abstraire et pouvoir couvrir vos modules avec des tests unitaires.


Dans cet article, nous discuterons des meilleures pratiques pour relever ces défis et, à la fin, vous pourrez atteindre une couverture à 100% de vos modules.


Photo de Lina Trochez sur Unsplash

L'approche

Voici comment nous allons aborder notre solution :


  1. Trouvez un exemple très simple sur lequel travailler.


  2. Commencez par la simple mauvaise solution.


  3. Continuez à essayer de l'améliorer jusqu'à ce que nous atteignions le format final.


  4. Résumant les leçons apprises tout au long de notre voyage.


Photo de James Harrison sur Unsplash

L'exemple

Dans notre exemple, nous allons construire une application console simple qui ne ferait qu'une chose simple : utiliser un System.Timers.Timer pour écrire sur la console la date et l'heure toutes les secondes .


Au final, vous devriez vous retrouver avec ceci :


Image par Ahmed Tarek


Comme vous pouvez le voir, c'est simple en termes d'exigences, rien d'extraordinaire.


Photo de Mikael Seegen sur Unsplash, ajustée par Ahmed Tarek

Clause de non-responsabilité

  1. Certaines meilleures pratiques seraient ignorées/abandonnées afin de se concentrer sur les autres meilleures pratiques ciblées dans cet article.


  2. Dans cet article, nous nous concentrerons sur la couverture du module utilisant System.Timers.Timer avec des tests unitaires. Cependant, le reste de la solution ne serait pas couvert de tests unitaires. Si vous souhaitez en savoir plus à ce sujet, vous pouvez consulter l'article Comment couvrir entièrement l'application console .NET C # avec des tests unitaires .


  3. Certaines bibliothèques tierces pourraient être utilisées pour obtenir des résultats presque similaires. Cependant, dans la mesure du possible, je préfère suivre une conception simple native plutôt que de dépendre d'une grande bibliothèque tierce.


Photo de Maria Teneva sur Unsplash, ajustée par Ahmed Tarek

Mauvaise solution

Dans cette solution, nous utiliserions directement System.Timers.Timer sans fournir de couche d'abstraction.


La structure de la solution devrait ressembler à ceci :


Image par Ahmed Tarek


Il s'agit d'une solution UsingTimer avec un seul projet Console TimerApp .


J'ai intentionnellement investi du temps et des efforts dans l'abstraction System.Console dans IConsole pour prouver que cela ne résoudrait pas notre problème avec le Timer.


 namespace TimerApp.Abstractions { public interface IConsole { void WriteLine(object? value); } }


Nous aurions seulement besoin d'utiliser System.Console.WriteLine dans notre exemple ; c'est pourquoi c'est la seule méthode abstraite.


 namespace TimerApp.Abstractions { public interface IPublisher { void StartPublishing(); void StopPublishing(); } }


Nous n'avons que deux méthodes sur l'interface IPublisher : StartPublishing et StopPublishing .


Maintenant, pour les implémentations :


 using TimerApp.Abstractions; namespace TimerApp.Implementations { public class Console : IConsole { public void WriteLine(object? value) { System.Console.WriteLine(value); } } }


Console n'est qu'un mince wrapper pour System.Console .


 using System.Timers; using TimerApp.Abstractions; namespace TimerApp.Implementations { public class Publisher : IPublisher { private readonly Timer m_Timer; private readonly IConsole m_Console; public Publisher(IConsole console) { m_Timer = new Timer(); m_Timer.Enabled = true; m_Timer.Interval = 1000; m_Timer.Elapsed += Handler; m_Console = console; } public void StartPublishing() { m_Timer.Start(); } public void StopPublishing() { m_Timer.Stop(); } private void Handler(object sender, ElapsedEventArgs args) { m_Console.WriteLine(args.SignalTime); } } }


Publisher est une implémentation simple de IPublisher . Il utilise un System.Timers.Timer et le configure simplement.


Il a la IConsole définie comme une dépendance. Ce n'est pas une bonne pratique de mon point de vue. Si vous voulez comprendre ce que je veux dire, vous pouvez consulter l'article Quand ne pas utiliser les conteneurs DI, IoC et IoC dans .NET C# .


Cependant, uniquement pour des raisons de simplicité, nous l'injecterions simplement en tant que dépendance dans le constructeur.


Nous définissons également l'intervalle du minuteur sur 1000 millisecondes (1 seconde) et configurons le gestionnaire pour qu'il écrive le Timer SignalTime sur la console.


 using TimerApp.Abstractions; using TimerApp.Implementations; namespace TimerApp { public class Program { static void Main(string[] args) { IPublisher publisher = new Publisher(new Implementations.Console()); publisher.StartPublishing(); System.Console.ReadLine(); publisher.StopPublishing(); } } }


Ici, dans la classe Program , on ne fait pas grand-chose. Nous créons juste une instance de la classe Publisher et commençons la publication.


L'exécution de ceci devrait se terminer par quelque chose comme ceci :


Image par Ahmed Tarek


Maintenant, la question est, si vous allez écrire un test unitaire pour la classe Publisher , que pouvez-vous faire ?


Malheureusement, la réponse serait : pas trop .


Tout d'abord, vous n'injectez pas le Timer lui-même en tant que dépendance. Cela signifie que vous masquez la dépendance à l'intérieur de la classe Publisher . Par conséquent, nous ne pouvons pas nous moquer ou écraser le minuteur.


Deuxièmement, disons que nous avons modifié le code pour que le Timer soit maintenant injecté dans le constructeur ; encore, la question serait, comment écrire un test unitaire et remplacer le Timer par un mock ou un stub ?


J'entends quelqu'un crier, "Enveloppons le Timer dans une abstraction et injectons-le à la place du Timer."


Oui, c'est vrai, cependant, ce n'est pas si simple. Il y a quelques astuces que je vais expliquer dans la section suivante.


Photo de Carson Masterson sur Unsplash, ajustée par Ahmed Tarek

Bonne solution

C'est le moment d'une bonne solution. Voyons ce que nous pouvons faire à ce sujet.


La structure de la solution devrait ressembler à ceci :


Image par Ahmed Tarek


Il s'agit de la même solution UsingTimer avec un nouveau projet Console BetterTimerApp .


IConsole , IPublisher et Console seraient les mêmes.

Minuteur

 using System; namespace BetterTimerApp.Abstractions { public delegate void TimerIntervalElapsedEventHandler(object sender, DateTime dateTime); public interface ITimer : IDisposable { event TimerIntervalElapsedEventHandler TimerIntervalElapsed; bool Enabled { get; set; } double Interval { get; set; } void Start(); void Stop(); } }


Ce que l'on peut remarquer ici :


  1. Nous avons défini le nouveau délégué TimerIntervalElapsedEventHandler . Ce délégué représente l'événement à déclencher par notre ITimer .


  2. Vous pourriez dire que nous n'avons pas besoin de ce nouveau délégué car nous avons déjà le ElapsedEventHandler natif qui est déjà utilisé par System.Timers.Timer .


  3. Oui c'est vrai. Cependant, vous remarquerez que l'événement ElapsedEventHandler fournit ElapsedEventArgs comme arguments d'événement. Cet ElapsedEventArgs a un constructeur privé et vous ne pourrez pas créer votre propre instance. De plus, la propriété SignalTime définie dans la classe ElapsedEventArgs est en lecture seule. Par conséquent, vous ne pourrez pas le remplacer dans une classe enfant.


  4. Un ticket de demande de modification a été ouvert pour que Microsoft mette à jour cette classe, mais jusqu'au moment de la rédaction de cet article, aucune modification n'a été appliquée.


  5. Notez également que ITimer étend le IDisposable .



Éditeur

 using System; using BetterTimerApp.Abstractions; namespace BetterTimerApp.Implementations { public class Publisher : IPublisher { private readonly ITimer m_Timer; private readonly IConsole m_Console; public Publisher(ITimer timer, IConsole console) { m_Timer = timer; m_Timer.Enabled = true; m_Timer.Interval = 1000; m_Timer.TimerIntervalElapsed += Handler; m_Console = console; } public void StartPublishing() { m_Timer.Start(); } public void StopPublishing() { m_Timer.Stop(); } private void Handler(object sender, DateTime dateTime) { m_Console.WriteLine(dateTime); } } }


C'est presque le même que l'ancien Publisher , à quelques petites modifications près. Maintenant, nous avons le ITimer défini comme une dépendance qui est injectée via le constructeur. Le reste du code serait le même.

Minuteur

 using System; using System.Collections.Generic; using System.Linq; using System.Timers; using BetterTimerApp.Abstractions; namespace BetterTimerApp.Implementations { public class Timer : ITimer { private Dictionary<TimerIntervalElapsedEventHandler, List<ElapsedEventHandler>> m_Handlers = new(); private bool m_IsDisposed; private System.Timers.Timer m_Timer; public Timer() { m_Timer = new System.Timers.Timer(); } public event TimerIntervalElapsedEventHandler TimerIntervalElapsed { add { var internalHandler = (ElapsedEventHandler)((sender, args) => { value.Invoke(sender, args.SignalTime); }); if (!m_Handlers.ContainsKey(value)) { m_Handlers.Add(value, new List<ElapsedEventHandler>()); } m_Handlers[value].Add(internalHandler); m_Timer.Elapsed += internalHandler; } remove { m_Timer.Elapsed -= m_Handlers[value].Last(); m_Handlers[value].RemoveAt(m_Handlers[value].Count - 1); if (!m_Handlers[value].Any()) { m_Handlers.Remove(value); } } } public bool Enabled { get => m_Timer.Enabled; set => m_Timer.Enabled = value; } public double Interval { get => m_Timer.Interval; set => m_Timer.Interval = value; } public void Start() { m_Timer.Start(); } public void Stop() { m_Timer.Stop(); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (m_IsDisposed) return; if (disposing && m_Handlers.Any()) { foreach (var internalHandlers in m_Handlers.Values) { if (internalHandlers?.Any() ?? false) { internalHandlers.ForEach(handler => m_Timer.Elapsed -= handler); } } m_Timer.Dispose(); m_Timer = null; m_Handlers.Clear(); m_Handlers = null; } m_IsDisposed = true; } ~Timer() { Dispose(false); } } }


C'est là que presque toute la magie se produit.


Ce que l'on peut remarquer ici :


  1. En interne, nous utilisons System.Timers.Timer .


  2. Nous avons appliqué le design pattern IDisposable . C'est pourquoi vous pouvez voir le private bool m_IsDisposed , public void Dispose() , protected virtual void Dispose(bool disposing) et ~Timer() .


  3. Dans le constructeur, nous initialisons une nouvelle instance de System.Timers.Timer . Nous appellerions cela la minuterie interne dans le reste des étapes.


  4. Pour public bool Enabled , public double Interval , public void Start() et public void Stop() , nous déléguons simplement l'implémentation au minuteur interne.


  5. Pour public event TimerIntervalElapsedEventHandler TimerIntervalElapsed , c'est la partie la plus importante ; alors analysons-le étape par étape.


  6. Ce que nous devons faire avec cet événement est de gérer le moment où quelqu'un s'abonne/se désabonne de l'extérieur. Dans ce cas, nous voulons refléter cela sur la minuterie interne.


  7. En d'autres termes, si quelqu'un de l'extérieur a une instance de notre ITimer , il devrait pouvoir faire quelque chose comme ceci t.TimerIntervalElapsed += (sender, dateTime) => { //do something } .


  8. À ce moment, ce que nous devrions faire est de faire en interne quelque chose comme m_Timer.Elapsed += (sender, elapsedEventArgs) => { //do something } .


  9. Cependant, nous devons garder à l'esprit que les deux gestionnaires ne sont pas les mêmes car ils sont en fait de types différents ; TimerIntervalElapsedEventHandler et ElapsedEventHandler .


  10. Par conséquent, ce que nous devons faire est d'envelopper l'arrivée de TimerIntervalElapsedEventHandler dans un nouveau ElapsedEventHandler interne. C'est quelque chose que nous pouvons faire.


  11. Cependant, nous devons également garder à l'esprit qu'à un moment donné, quelqu'un pourrait avoir besoin de désabonner un gestionnaire de l'événement TimerIntervalElapsedEventHandler .


  12. Cela signifie qu'à ce moment, nous devons être en mesure de savoir quel gestionnaire ElapsedEventHandler correspond à ce gestionnaire TimerIntervalElapsedEventHandler afin que nous puissions le désinscrire du minuteur interne.


  13. La seule façon d'y parvenir est de suivre chaque gestionnaire TimerIntervalElapsedEventHandler et le gestionnaire ElapsedEventHandler nouvellement créé dans un dictionnaire. De cette façon, en connaissant le gestionnaire TimerIntervalElapsedEventHandler passé, nous pouvons connaître le gestionnaire ElapsedEventHandler correspondant.


  14. Cependant, nous devons également garder à l'esprit que de l'extérieur, quelqu'un peut s'abonner plusieurs fois au même gestionnaire TimerIntervalElapsedEventHandler .


  15. Oui, ce n'est pas logique, mais tout de même, c'est faisable. Par conséquent, par souci d'exhaustivité, pour chaque gestionnaire TimerIntervalElapsedEventHandler , nous conserverions une liste des gestionnaires ElapsedEventHandler .


  16. Dans la plupart des cas, cette liste n'aurait qu'une seule entrée, sauf en cas d'abonnement en double.


  17. Et c'est pourquoi vous pouvez voir ce private Dictionary<TimerIntervalElapsedEventHandler, List<ElapsedEventHandler>> m_Handlers = new(); .


 public event TimerIntervalElapsedEventHandler TimerIntervalElapsed { add { var internalHandler = (ElapsedEventHandler)((sender, args) => { value.Invoke(sender, args.SignalTime); }); if (!m_Handlers.ContainsKey(value)) { m_Handlers.Add(value, new List<ElapsedEventHandler>()); } m_Handlers[value].Add(internalHandler); m_Timer.Elapsed += internalHandler; } remove { m_Timer.Elapsed -= m_Handlers[value].Last(); m_Handlers[value].RemoveAt(m_Handlers[value].Count - 1); if (!m_Handlers[value].Any()) { m_Handlers.Remove(value); } } }


Dans l' add , nous créons un nouveau ElapsedEventHandler , en ajoutant un enregistrement dans m_Handlers le dictionnaire le mappant à TimerIntervalElapsedEventHandler , et enfin en nous abonnant au minuteur interne.


Dans le remove , nous obtenons la liste correspondante des gestionnaires ElapsedEventHandler , en sélectionnant le dernier gestionnaire, en le désinscrivant du minuteur interne, en le supprimant de la liste et en supprimant toute l'entrée si la liste est vide.


Il convient également de mentionner l'implémentation Dispose .


 protected virtual void Dispose(bool disposing) { if (m_IsDisposed) return; if (disposing && m_Handlers.Any()) { foreach (var internalHandlers in m_Handlers.Values) { if (internalHandlers?.Any() ?? false) { internalHandlers.ForEach(handler => m_Timer.Elapsed -= handler); } } m_Timer.Dispose(); m_Timer = null; m_Handlers.Clear(); m_Handlers = null; } m_IsDisposed = true; }


Nous désinscrivons tous les gestionnaires restants du minuteur interne, supprimons le minuteur interne et effaçons le dictionnaire m_Handlers .

Programme

 using BetterTimerApp.Abstractions; using BetterTimerApp.Implementations; namespace BetterTimerApp { public class Program { static void Main(string[] args) { var timer = new Timer(); IPublisher publisher = new Publisher(timer, new Implementations.Console()); publisher.StartPublishing(); System.Console.ReadLine(); publisher.StopPublishing(); timer.Dispose(); } } }


Ici, on ne fait pas encore grand-chose. C'est presque la même que l'ancienne solution.


L'exécution de ceci devrait se terminer par quelque chose comme ceci :


Image par Ahmed Tarek


Photo de Testalize.me sur Unsplash, ajustée par Ahmed Tarek

Le temps des tests, le moment de vérité

Maintenant, nous avons notre conception finale. Cependant, nous devons voir si cette conception peut vraiment nous aider à couvrir notre module Publisher avec des tests unitaires.


La structure de la solution devrait ressembler à ceci :


Image par Ahmed Tarek


J'utilise NUnit et Moq pour les tests. Vous pouvez à coup sûr travailler avec vos bibliothèques préférées.

TimerStub

 using System; using System.Collections.Generic; using BetterTimerApp.Abstractions; namespace BetterTimerApp.Tests.Stubs { public enum Action { Start = 1, Stop = 2, Triggered = 3, Enabled = 4, Disabled = 5, IntervalSet = 6 } public class ActionLog { public Action Action { get; } public string Message { get; } public ActionLog(Action action, string message) { Action = action; Message = message; } } public class TimerStub : ITimer { private bool m_Enabled; private double m_Interval; public event TimerIntervalElapsedEventHandler TimerIntervalElapsed; public Dictionary<int, ActionLog> Log = new(); public bool Enabled { get => m_Enabled; set { m_Enabled = value; Log.Add(Log.Count + 1, new ActionLog(value ? Action.Enabled : Action.Disabled, value ? "Enabled" : "Disabled")); } } public double Interval { get => m_Interval; set { m_Interval = value; Log.Add(Log.Count + 1, new ActionLog(Action.IntervalSet, m_Interval.ToString("G17"))); } } public void Start() { Log.Add(Log.Count + 1, new ActionLog(Action.Start, "Started")); } public void Stop() { Log.Add(Log.Count + 1, new ActionLog(Action.Stop, "Stopped")); } public void TriggerTimerIntervalElapsed(DateTime dateTime) { OnTimerIntervalElapsed(dateTime); Log.Add(Log.Count + 1, new ActionLog(Action.Triggered, "Triggered")); } protected void OnTimerIntervalElapsed(DateTime dateTime) { TimerIntervalElapsed?.Invoke(this, dateTime); } public void Dispose() { Log.Clear(); Log = null; } } }


Ce que l'on peut remarquer ici :


  1. Nous avons défini l'énumération Action à utiliser lors de la journalisation des actions effectuées via notre stub Timer. Cela serait utilisé plus tard pour affirmer les actions internes effectuées.


  2. De plus, nous avons défini la classe ActionLog à utiliser pour la journalisation.


  3. Nous avons défini la classe TimerStub comme un stub de ITimer . Nous utiliserons ce stub plus tard lors du test du module Publisher .


  4. La mise en œuvre est simple. Il convient de mentionner que nous avons ajouté une méthode public void TriggerTimerIntervalElapsed(DateTime dateTime) afin que nous puissions déclencher le stub manuellement dans un test unitaire.


  5. Nous pouvons également transmettre la valeur attendue de dateTime afin d'avoir une valeur connue à affirmer.

PublisherTests

 using System; using BetterTimerApp.Abstractions; using BetterTimerApp.Implementations; using BetterTimerApp.Tests.Stubs; using Moq; using NUnit.Framework; using Action = BetterTimerApp.Tests.Stubs.Action; namespace BetterTimerApp.Tests.Tests { [TestFixture] public class PublisherTests { private TimerStub m_TimerStub; private Mock<IConsole> m_ConsoleMock; private Publisher m_Sut; [SetUp] public void SetUp() { m_TimerStub = new TimerStub(); m_ConsoleMock = new Mock<IConsole>(); m_Sut = new Publisher(m_TimerStub, m_ConsoleMock.Object); } [TearDown] public void TearDown() { m_Sut = null; m_ConsoleMock = null; m_TimerStub = null; } [Test] public void ConstructorTest() { Assert.AreEqual(Action.Enabled, m_TimerStub.Log[1].Action); Assert.AreEqual(Action.Enabled.ToString(), m_TimerStub.Log[1].Message); Assert.AreEqual(Action.IntervalSet, m_TimerStub.Log[2].Action); Assert.AreEqual(1000.ToString("G17"), m_TimerStub.Log[2].Message); } [Test] public void StartPublishingTest() { // Arrange var expectedDateTime = DateTime.Now; m_ConsoleMock .Setup ( m => m.WriteLine ( It.Is<DateTime>(p => p == expectedDateTime) ) ) .Verifiable(); // Act m_Sut.StartPublishing(); m_TimerStub.TriggerTimerIntervalElapsed(expectedDateTime); // Assert ConstructorTest(); m_ConsoleMock .Verify ( m => m.WriteLine(expectedDateTime) ); Assert.AreEqual(Action.Start, m_TimerStub.Log[3].Action); Assert.AreEqual("Started", m_TimerStub.Log[3].Message); Assert.AreEqual(Action.Triggered, m_TimerStub.Log[4].Action); Assert.AreEqual(Action.Triggered.ToString(), m_TimerStub.Log[4].Message); } [Test] public void StopPublishingTest() { // Act m_Sut.StopPublishing(); // Assert ConstructorTest(); Assert.AreEqual(Action.Stop, m_TimerStub.Log[3].Action); Assert.AreEqual("Stopped", m_TimerStub.Log[3].Message); } [Test] public void FullProcessTest() { // Arrange var expectedDateTime1 = DateTime.Now; var expectedDateTime2 = expectedDateTime1 + TimeSpan.FromSeconds(1); var expectedDateTime3 = expectedDateTime2 + TimeSpan.FromSeconds(1); var sequence = new MockSequence(); m_ConsoleMock .InSequence(sequence) .Setup ( m => m.WriteLine ( It.Is<DateTime>(p => p == expectedDateTime1) ) ) .Verifiable(); m_ConsoleMock .InSequence(sequence) .Setup ( m => m.WriteLine ( It.Is<DateTime>(p => p == expectedDateTime2) ) ) .Verifiable(); m_ConsoleMock .InSequence(sequence) .Setup ( m => m.WriteLine ( It.Is<DateTime>(p => p == expectedDateTime3) ) ) .Verifiable(); // Act m_Sut.StartPublishing(); m_TimerStub.TriggerTimerIntervalElapsed(expectedDateTime1); // Assert ConstructorTest(); m_ConsoleMock .Verify ( m => m.WriteLine(expectedDateTime1) ); Assert.AreEqual(Action.Start, m_TimerStub.Log[3].Action); Assert.AreEqual("Started", m_TimerStub.Log[3].Message); Assert.AreEqual(Action.Triggered, m_TimerStub.Log[4].Action); Assert.AreEqual(Action.Triggered.ToString(), m_TimerStub.Log[4].Message); // Act m_TimerStub.TriggerTimerIntervalElapsed(expectedDateTime2); // Assert m_ConsoleMock .Verify ( m => m.WriteLine(expectedDateTime2) ); Assert.AreEqual(Action.Triggered, m_TimerStub.Log[5].Action); Assert.AreEqual(Action.Triggered.ToString(), m_TimerStub.Log[5].Message); // Act m_Sut.StopPublishing(); // Assert Assert.AreEqual(Action.Stop, m_TimerStub.Log[6].Action); Assert.AreEqual("Stopped", m_TimerStub.Log[6].Message); } } }


Maintenant, comme vous pouvez le voir, nous avons le contrôle total et nous pouvons facilement couvrir notre module Publisher avec des tests unitaires.


Si nous calculons la couverture, nous devrions obtenir ceci :


Image par Ahmed Tarek


Comme vous pouvez le constater, le module Publisher est couvert à 100 %. Pour le reste, cela sort du cadre de cet article, mais vous pouvez simplement le couvrir si vous suivez l'approche de l'article Comment couvrir entièrement l'application console .NET C # avec des tests unitaires .


Photo de Jingda Chen sur Unsplash, ajustée par Ahmed Tarek

Derniers mots

Tu peux le faire. Il s'agit simplement de diviser de grands modules en plus petits, de définir vos abstractions, de faire preuve de créativité avec des parties délicates, et puis vous avez terminé.


Si vous souhaitez vous entraîner davantage, vous pouvez consulter mes autres articles sur certaines bonnes pratiques.


Voilà, j'espère que vous avez trouvé la lecture de cet article aussi intéressante que j'ai trouvé l'écrire.


Également publié ici