Detector Pattern

Detektor Pattern

Versionsgeschichte
Version Aktualisierungsdatum Änderung
0.2 05.04.2017 .Net Standard 1.1 / PCL und .Net 4.5 Umsetzung
0.1.1 02.07.2016 Textkorrekturen
0.1 16.05.2016 Erste Fassung

In der UI Entwicklung kommt es vor, dass man mehrere Ereignisse überwachen muss. Nur das Ergebnis des Ersten will man verarbeiten.

Beispiel - Anwendung

Die beschriebene Beispielanwendung mit einer Fake-Kommunikationsschicht ist im Quellcode integriert, incl. Beispiel zur Implementierung von Detektoren.

n der Beispielanwendung wird eine Diagnose durchgeführt. Die Test-Routine besteht aus drei Schritten, die nacheinander ausgeführt werden sollen:

  1. Warten auf den Anschluss des Testgerätes mit dem Diagnose-Kanal
  2. Messung der Spannung in einen vorgegeben Bereich
  3. Abwarten der Trennung der Verbindung

Die Aufgabe könnte einfach sein, wenn keine Nebenbedingungen existieren würden:

  1. Beim Warten auf den Anschluss:
    1. Benutzer kann den Test jederzeit abbrechen (weil er Pause machen will).
    2. Nach einer festgelegten Zeit, muss ein Timeout-Fehler ausgelöst werden, da man davon ausgehen kann, dass das angeschlossene Gerät nicht funktioniert, wenn lange keine Verbindung zu Stande kommt.
  2. Während der Spannungsmessung:
    1. Spannung soll für mindestens zwei Sekunden gehalten werden.
    2. Benutzer kann wieder jederzeit den Test abbrechen.
    3. Die Verbindung zum Testgerät kann jederzeit verloren gehen (wenn man als Transportweg statt USB zum Beispiel WLAN, Bluetooth usw. nutz).
    4. Wenn der geforderte Pegel innerhalb einer definierten Zeit nicht erreicht wird, ist das Testgerät defekt (Timeout-Fehler).
  3. Währen des Wartens auf Abbau der Verbindung:
    1. Benutzer kann jederzeit den Test abbrechen.
    2. Es kommt zu einen Timeout.

Jetzt wird die Lösung nicht mehr linear abbildbar.

Mit Hilfe der Detektoren lässt sich diese Aufgabe sehr elegant lösen.

Screenshots der Beispielanwendung

Umsetzung

Die Umsetzung mit den Detektoren gelingt unkompliziert. Nach dem Betätigen des START-Buttons für die Diagnose wird folgender Code ausgeführt:

private async Task StartDiagnosis() {
  DiagnosisStarted = true;

  if(UserCancelationSource != null) {
    UserCancelationSource.Dispose();
  }
  UserCancelationSource = new CancellationTokenSource();

  // Task 1: Device connected
  var result = await RunDeviceConnectedTest();

  if (IsNegativeResult(result)) {
    return;
  }

  // Task 2: 5V voltage hold for 5 Seconds
  result = await RunVoltageTest();

  if (IsNegativeResult(result)) {
    return;
  }

  // Task 3: Disconnect device
  result = await RunDisconnectTest();

  if (IsNegativeResult(result)) {
    return;
  }

  ShowTestResultScreen();

  DiagnosisStarted = false;
}

Wir schauen uns den zweiten Schritt genauer an. Hier findet man die meisten Nebenbedingungen. In der Zeile 17 wird auf eine Methode RunVoltageTest() gewartet und im Anschluss das Ergebnis ausgewertet. Schauen wir uns die Methode genauer an:

private async Task<ITaskResult<bool>> RunVoltageTest() {
  // Init UI
  CurrentViewModel = new VoltageViewModel(_voltageEvent);

  try {
    var detectors = GetDetectorsForVoltage();
    var result = await detectors.GetResult();

    return result;
  }
  catch (Exception ex) {
    return new TaskResult<bool>(ResultState.Exception, ex: ex);
  }
}

In der Zeile 3 wird die Oberfläche durch MVVM-Pattern getriggert (es wird ein Voltmeter im Programm angezeigt, der die aktuelle Spannung visuell darstellt).

In Zeilen 6 werden die Detektoren initialisiert. In der nachfolgenden Zeile 7 wird schon das Ergebnis erwartet, um den an den Aufrufer zurückzugeben.

Schauen wir uns die Definition der Detektoren für die Aufgabe an:

private IDetector<bool>[] GetDetectorsForVoltage() {
  return new IDetector<bool>[] {
    new TimeoutDetector<bool>(TimeSpan.FromMinutes(1)),
    new CancelationDetector<bool>(UserCancelationSource.Token),
    new DisconnectedDetector<bool>(_connectionEvent, positiveState: ResultState.UserDefinied1),
    new RangeDetector<VoltageEvent, bool>(_voltageEvent,
        new Range(4.5d, 5.5d), TimeSpan.FromSeconds(2))
  };
}

Wir haben hier folgende Detektoren, die als Liste an den Aufrufer zurückgegeben werden:

  1. TimeOutDetector: Schlägt nach Ablauf einer Minute zu
  2. CancelationDetector: Schlägt zu, wenn der Benutzer die Diagnose abbricht
  3. DisconnectedDetector: Schlägt zu, wenn die Verbindung zum Testgerät abbricht
  4. RangeDetector: Schlägt zu, wenn die Spannung für wenigstens 2 Sekunden zwischen 4,5 und 5,5 Volt liegt

Mit der Umsetzung ist der positive Ablauf linear. Pro Schritt werden jeweils die Bedingungen zum Beenden des Schrittes definiert (sowohl positive, als auch negative). Nach der Ausführung des Schrittes muss nur das Ergebnis ausgewertet werden. Dabei muss nicht überlegt werden, was für Events / Ressourcen aufgeräumt werden müssen. Das wird durch die Detektoren selbständig (wenn ausreichend getestet) erledigt.

Die einzelnen Detektoren sind kompakt und übersichtlich. Damit steigen die Testbarkeit und die Wiederverwendung an einer anderen Stelle.

Implementierung

IDetector<T>

using System.Threading.Tasks;

namespace de.webducer.net.Detector.Interfaces {
  /// <summary>
  ///// Base interface for detectors
  /// </summary>
  /// <typeparam name="T">Type of the result</typeparam>
  public interface IDetector<T> {
    /// <summary>
    /// Start the detector. Should be executed very fast
    /// </summary>
    /// <returns></returns>
    Task<ITaskResult<T>> Start();

    /// <summary>
    /// Cleanup the detector (release resources, unregister events etc.)
    /// </summary>
    void Cleanup();
  }
}

Das generische Basisinterface bietet nur zwei Methoden zum Starten und zum Aufräumen des Detektors. Die generische Angabe bezieht sich auf den Ergebnistyp innerhalb der Rückgabeantwort vom startenden Tasks.

In der abstrakten Basisklasse für den Detektor werden die Grundzüge eines Detektors umgesetzt. Zur Handhabung von Task wird TaskCompletionSource verwendet, da das Endergebnis vom Task aus unterschiedlichen Methoden / Threads stammen kann (Behandlungrsoutine eines Events).

DetectorBase<T>

using System.Threading.Tasks;
using de.webducer.net.Detector.Interfaces;

namespace de.webducer.net.Detector.Base {
  /// <summary>
  ///// Base class for detectors
  /// </summary>
  /// <typeparam name="T">Type of the result</typeparam>
  public abstract class DetectorBase<T> : IDetector<T> {
    #region Fields
    protected readonly ResultState PositiveState;
    protected readonly T PositiveResult;
    protected readonly TaskCompletionSourceWrapper<ITaskResult<T>> TcsResult;
    #endregion

    #region Constructors
    protected DetectorBase(ResultState positiveState, T positiveResult = default(T)) {
      PositiveState = positiveState;
      PositiveResult = positiveResult;
      TcsResult = new TaskCompletionSourceWrapper<ITaskResult<T>>(new TaskCompletionSource<ITaskResult<T>>());
    }
    #endregion

    #region IDetector implementation
    /// <summary>
    /// Start the detector. Should be executed very fast
    /// </summary>
    /// <returns></returns>
    public Task<ITaskResult<T>> Start() {
      OnStart();

      return TcsResult.Task;
    }

    /// <summary>
    /// Cleanup the detector (release resources, unregister events etc.)
    /// </summary>
    public void Cleanup() {
      OnCleanup();

      if (!TcsResult.IsResultSet) {
        TcsResult.CheckAndSetResult(new TaskResult<T>(state: ResultState.DetectorNotFinished));
      }
    }
    #endregion

    /// <summary>
    /// Start the detector (as fast as possible)
    /// </summary>
    protected abstract void OnStart();

    /// <summary>
    /// Cleanaup the detector
    /// </summary>
    protected abstract void OnCleanup();
  }
}

Bei einem Cleanup wird automatisch der Status auf DetectorNotFinished gesetzt. Der Start und die Aufräumarbeiten müssen von den konkreten Detektoren durch die Überschreibung der abstrakten Methoden OnStart und OnCleanup implementiert werden.

Hier sind zwei Beispiele für die konkreten Implementierungen eines Detektors:

TimeoutDetector<T>

using de.webducer.net.Detector.Base;
using System;

namespace de.webducer.net.Detector.BaseDetectors {
  /// <summary>
  /// Detector to detect timeouts
  /// </summary>
  /// <typeparam name="T">Result type</typeparam>
  public class TimeoutDetector<T> : DetectorBase<T> {
    #region Fields
    private TimeoutTimer _timer;
    private readonly TimeSpan _timeOut;
    #endregion

    #region Constructors
    public TimeoutDetector(TimeSpan timeOut, ResultState positiveState = ResultState.TimeOut,
      T positiveResult = default(T)) : base(positiveState, positiveResult) {
      if (timeOut.TotalMilliseconds <= 0) {
        throw new ArgumentException("Timeout should be alsways positive", nameof(timeOut));
      }

      _timeOut = timeOut;
    }
    #endregion

    #region DetectorBase<T> Implementation
    protected override void OnCleanup() {
      // unregister event
      _timer?.Dispose();
      _timer = null;
    }

    protected override void OnStart() {
      // register timeout event
      _timer = new TimeoutTimer(_timeOut, OnTimeOut);
    }
    #endregion

    #region Helper
    private void OnTimeOut() {
      // Stop timer, if result already set
      if (TcsResult.IsResultSet) {
        _timer?.Dispose();
        _timer = null;
        return;
      }

      // Generate positive result
      var result = new TaskResult<T>(state: PositiveState, result: PositiveResult);

      // Set result in tasc completion source
      TcsResult.CheckAndSetResult(result);
    }
    #endregion
  }
}

ConnectedDetector<T>

using de.webducer.net.Detector.Base;
using Detector.Example.Events;
using Prism.Events;
using System;

namespace Detector.Example.Detectors {
  public class ConnectedDetector<T> : DetectorBase<T> {
    private readonly ConnectStateEvent _connectEvent;

    public ConnectedDetector(ConnectStateEvent connectEvent, ResultState positiveState = ResultState.Success, T positiveResult = default(T))
      : base(positiveState, positiveResult) {
      if (connectEvent == null) {
        throw new ArgumentNullException(nameof(connectEvent));
      }

      _connectEvent = connectEvent;
    }

    protected override void OnStart() {
      // Register event processing on start
      _connectEvent.Subscribe(OnConnectionStateFired, ThreadOption.BackgroundThread);
    }

    protected override void OnCleanup() {
      // Unregistre event procession on cleanup
      _connectEvent.Unsubscribe(OnConnectionStateFired);
    }

    private void OnConnectionStateFired(ConnectState state) {
      // Check if result already set
      if (TcsResult.IsResultSet) {
        // Unregister event processing
        _connectEvent.Unsubscribe(OnConnectionStateFired);
        return;
      }

      // Check for correct state
      if (state != ConnectState.Connected) {
        return;
      }

      // Process correct state
      // Unsubscribe event processing
      _connectEvent.Unsubscribe(OnConnectionStateFired);

      // Create result
      var result = new TaskResult<T>(state: PositiveState, result: PositiveResult);

      // Set result
      TcsResult.CheckAndSetResult(result);
    }
  }
}

Detektor Erweiterung

Um mehrere Detektoren zu verarbeiten, wurde eine Erweiterungsmethode geschrieben, die den kompletten Lebenszyklus für die Sammlung durchgeht.

  1. Starten aller Detektoren
  2. Warten auf das Ergebnis von dem ersten Detektor
  3. Aufräumen aller nicht beendeter Detektoren

DetectorExtensions.GetResult()

using de.webducer.net.Detector.Base;
using de.webducer.net.Detector.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace de.webducer.net.Detector.Extensions {
  public static class DetectorExtensions {
    /// <summary>
    /// Get result of the first successfull finished detector from list and cleanup all detectors
    /// </summary>
    /// <typeparam name="T">Result type</typeparam>
    /// <param name="detectors">List of detectors</param>
    /// <param name="afterStart">Action to be executed after detectors started (DEFAULT: null)</param>
    /// <param name="errorLog">Action to log errors (DEFAULT: null)</param>
    /// <returns>Result of the first finished detector</returns>
    public static async Task<ITaskResult<T>> GetResult<T>(this IEnumerable<IDetector<T>> detectors,
      Action afterStart = null, Action<string> errorLog = null) {
      // Transform to array, if not array
      var detectorArray = detectors as IDetector<T>[] ?? detectors.ToArray();

      // Start all detectors
      var startedTasks = detectors.Select(s => s.Start()).ToArray();

      // Execute after start action, if any
      afterStart?.Invoke();

      // Default result is exception
      ITaskResult<T> result = new TaskResult<T>(ResultState.Exception);

      try {
        var firstFinishedTask = await Task.WhenAny(startedTasks);
        result = firstFinishedTask.Result;
      }
      catch (Exception ex) {
        // log error
        errorLog?.Invoke(ex.Message);

        // Set exception
        result = new TaskResult<T>(ResultState.Exception, ex: ex);
      }

      // Cleanup all detectors
      try {
        Parallel.ForEach(detectorArray, detector => detector.Cleanup());
        await Task.WhenAll(startedTasks);
      }
      catch (Exception ex) {
        // Do nothing, we have the result and want to cleanup all tasks. Only logging
        errorLog?.Invoke(ex.Message);
      }

      return result;
    }
  }
}

Definieren Sie die Sammlung Ihrer Detektoren so, dass mindestens eines immer zum Ergebnis führt (zum Beispiel über Zeitunterbrechung oder Benutzerabbruch), da sonst asynchron auf ein Ereignis gewartet wird, der nie eintritt.

Den Quellcode finden Sie auf Bitbucket und GitHub. Für Vorschläge, Pull-Requests, Korrekturen usw. bin ich dankbar und hoffe, dass die Idee Ihnen bei Ihrer Entwicklung weiter. In meinem Team hat dieser Ansatz sehr viel gebracht:

  • Weniger Code
  • Wartbarer Code
  • Lesbarer Code
  • Getestete Ereignisbehandlung
  • Viel wenig Stress ;)

Die Basisbibliothek mit aktuell zwei Detektoren (TimeOut und UserCancelation) können Sie von NuGet.org wie folgt installieren:

PM> Install-Package WD.Detector