DiagnosticSource ist ein einfacher, aber sehr nützlicher Satz von APIs (verfügbar im NuGet-Paket System.Diagnostics.DiagnosticSource ), mit denen einerseits verschiedene Bibliotheken benannte Ereignisse über ihre Arbeit senden können und andererseits Anwendungen diese Ereignisse und Prozesse abonnieren können sie.
Jedes dieser Ereignisse enthält zusätzliche Informationen (Nutzdaten). Da Ereignisse im selben Prozess wie das Senden verarbeitet werden, können diese Informationen fast alle Objekte enthalten, ohne dass eine Serialisierung / Deserialisierung erforderlich ist.
DiagnosticSource wird bereits in AspNetCore, EntityFrameworkCore, HttpClient und SqlClient verwendet. Dadurch können Entwickler eingehende / ausgehende http-Anforderungen, Datenbankanforderungen, Zugriffsobjekte wie HttpContext
, DbConnection
, DbCommand
, HttpRequestMessage
und viele andere HttpRequestMessage
Objekte, falls erforderlich.
Ich beschloss, meine Geschichte über DiagnosticSource in zwei Artikel zu unterteilen. In diesem Artikel werden wir das Funktionsprinzip des Mechanismus anhand eines einfachen Beispiels analysieren. Im nächsten Artikel werde ich auf Ereignisse in .NET eingehen, die mit ihm verarbeitet werden können, und einige Beispiele für seine Verwendung in OZON.ru zeigen.
Beispiel
Betrachten Sie ein kleines Beispiel für das Abfangen von Datenbankabfragen, um besser zu verstehen, wie DiagnosticSource funktioniert. Stellen Sie sich vor, wir haben eine einfache Konsolenanwendung, die eine Anforderung an die Datenbank sendet und das Ergebnis in der Konsole anzeigt.
public static class Program { public const string ConnectionString = @"Data Source=localhost;Initial Catalog=master;User ID=sa;Password=Password12!;"; public static async Task Main() { var answer = await GetAnswerAsync(); Console.WriteLine(answer); } public static async Task<int> GetAnswerAsync() { using (var connection = new SqlConnection(ConnectionString)) {
Der Einfachheit halber habe ich SQL Server in einem Docker-Container ausgelöst.
Docker laufen docker run --rm --detach --name mssql-server \ --publish 1433:1433 \ --env ACCEPT_EULA=Y \ --env SA_PASSWORD=Password12! \ mcr.microsoft.com/mssql/server:2017-latest
Stellen Sie sich nun vor, wir haben eine Aufgabe: Wir müssen die Ausführungszeit aller Abfragen an die Datenbank mithilfe der Stopwatch
messen und die Paare "Request" - "Runtime" in der Konsole anzeigen.
Natürlich können Sie den QuerySingleAsync
Aufruf einfach QuerySingleAsync
Code QuerySingleAsync
, der die Stopwatch
erstellt und startet, sie nach Ausführung der Abfrage stoppt und das Ergebnis anzeigt, aber es gibt mehrere Schwierigkeiten gleichzeitig:
- Was ist, wenn die Anwendung mehr als eine Anfrage hat?
- Was passiert, wenn der Code, der die Anforderung ausführt, bereits kompiliert ist, als NuGet-Paket mit der Anwendung verbunden ist und wir keine Möglichkeit haben, ihn zu ändern?
- Was ist, wenn die Abfrage an die Datenbank nicht über Dapper, sondern beispielsweise über EntityFramework erfolgt und wir keinen Zugriff auf das
DbCommand
Objekt oder den generierten Abfragetext haben, der tatsächlich ausgeführt wird?
Versuchen wir, dieses Problem mit DiagnosticSource zu lösen.
Verwenden des NuGet System.Diagnostics.DiagnosticSource-Pakets
Nachdem Sie das NuGet-Paket von System.Diagnostics.DiagnosticSource verbunden haben , müssen Sie zunächst eine Klasse erstellen, die die für uns interessanten Ereignisse behandelt:
public sealed class ExampleDiagnosticObserver { }
Um mit der Verarbeitung von Ereignissen zu beginnen, müssen Sie eine Instanz dieser Klasse erstellen und als Beobachter im statischen Objekt DiagnosticListener.AllListeners
(im System.Diagnostics
Namespace) registrieren. Wir tun dies ganz am Anfang der Hauptfunktion:
public static async Task Main() { var observer = new ExampleDiagnosticObserver(); IDisposable subscription = DiagnosticListener.AllListeners.Subscribe(observer); var answer = await GetAnswerAsync(); Console.WriteLine(answer); }
In diesem Fall ExampleDiagnosticObserver
uns der Compiler zu Recht mit, dass die ExampleDiagnosticObserver
Klasse die IObserver<DiagnosticListener>
-Schnittstelle implementieren soll. Lassen Sie es uns implementieren:
public sealed class ExampleDiagnosticObserver : IObserver<DiagnosticListener> { void IObserver<DiagnosticListener>.OnNext(DiagnosticListener diagnosticListener) { Console.WriteLine(diagnosticListener.Name); } void IObserver<DiagnosticListener>.OnError(Exception error) { } void IObserver<DiagnosticListener>.OnCompleted() { } }
Wenn wir diesen Code jetzt ausführen, wird Folgendes in der Konsole angezeigt:
SqlClientDiagnosticListener SqlClientDiagnosticListener 42
Dies bedeutet, dass irgendwo in .NET zwei Objekte vom Typ DiagnosticListener
mit dem Namen "SqlClientDiagnosticListener"
registriert sind, die bei der Ausführung dieses Codes funktionierten.
Hier sind sie auf github.comEs gibt tatsächlich drei davon, aber da wir SqlTransaction
nicht verwendet SqlTransaction
, haben nur zwei funktioniert:
Die IObserver<DiagnosticListener>.OnNext
wird bei der ersten Verwendung für jede Instanz des DiagnosticListener
, die in der Anwendung erstellt wird, einmal aufgerufen (normalerweise werden sie als statische Eigenschaften erstellt). Jetzt haben wir nur den Namen der DiagnosticListener
Instanzen in der Konsole angezeigt. In der Praxis muss diese Methode diesen Namen überprüfen und, wenn wir Ereignisse aus dieser Instanz verarbeiten möchten, mit der Subscribe
Methode Subscribe
.
Ich möchte auch darauf hinweisen, dass wir beim Aufrufen von DiagnosticListener.AllListeners.Subscribe
als Ergebnis ein subscription
, das die IDisposable
Schnittstelle implementiert. Das Aufrufen der Dispose
Methode für dieses Objekt führt zur Abmeldung, die in der IObserver<DiagnosticListener>.OnCompleted
.
Implementieren wir den IObserver<DiagnosticListener>
erneut:
public sealed class ExampleDiagnosticObserver : IObserver<DiagnosticListener> { private readonly List<IDisposable> _subscriptions = new List<IDisposable>(); void IObserver<DiagnosticListener>.OnNext(DiagnosticListener diagnosticListener) { if (diagnosticListener.Name == "SqlClientDiagnosticListener") { var subscription = diagnosticListener.Subscribe(this); _subscriptions.Add(subscription); } } void IObserver<DiagnosticListener>.OnError(Exception error) { } void IObserver<DiagnosticListener>.OnCompleted() { _subscriptions.ForEach(x => x.Dispose()); _subscriptions.Clear(); } }
Jetzt ExampleDiagnosticObserver
uns der Compiler mit, dass unsere ExampleDiagnosticObserver
Klasse auch die IObserver<KeyValuePair<string, object>>
-Schnittstelle implementieren soll. Hier müssen wir den IObserver<KeyValuePair<string, object>>.OnNext
, die als Parameter KeyValuePair<string, object>
akzeptiert, wobei der Schlüssel der Name des Ereignisses ist und der Wert ein anonymes Objekt (normalerweise) mit beliebigen Parametern ist, die wir verwenden können nach eigenem Ermessen. Fügen wir eine Implementierung dieser Schnittstelle hinzu:
public sealed class ExampleDiagnosticObserver : IObserver<DiagnosticListener>, IObserver<KeyValuePair<string, object>> {
Wenn wir jetzt den resultierenden Code ausführen, wird Folgendes in der Konsole angezeigt:
System.Data.SqlClient.WriteConnectionOpenBefore { OperationId = 3da1b5d4-9ce1-4f28-b1ff-6a5bfc9d64b8, Operation = OpenAsync, Connection = System.Data.SqlClient.SqlConnection, Timestamp = 26978341062 } System.Data.SqlClient.WriteConnectionOpenAfter { OperationId = 3da1b5d4-9ce1-4f28-b1ff-6a5bfc9d64b8, Operation = OpenAsync, ConnectionId = 84bd0095-9831-456b-8ebc-cb9dc2017368, Connection = System.Data.SqlClient.SqlConnection, Statistics = System.Data.SqlClient.SqlStatistics+StatisticsDictionary, Timestamp = 26978631500 } System.Data.SqlClient.WriteCommandBefore { OperationId = 5c6d300c-bc49-4f80-9211-693fa1e2497c, Operation = ExecuteReaderAsync, ConnectionId = 84bd0095-9831-456b-8ebc-cb9dc2017368, Command = System.Data.SqlClient.SqlComman d } System.Data.SqlClient.WriteCommandAfter { OperationId = 5c6d300c-bc49-4f80-9211-693fa1e2497c, Operation = ExecuteReaderAsync, ConnectionId = 84bd0095-9831-456b-8ebc-cb9dc2017368, Command = System.Data.SqlClient.SqlComman d, Statistics = System.Data.SqlClient.SqlStatistics+StatisticsDictionary, Timestamp = 26978709490 } System.Data.SqlClient.WriteConnectionCloseBefore { OperationId = 3f6bfd8f-e5f6-48b7-82c7-41aeab881142, Operation = Close, ConnectionId = 84bd0095-9831-456b-8ebc-cb9dc2017368, Connection = System.Data.SqlClient.SqlConnection, Stat istics = System.Data.SqlClient.SqlStatistics+StatisticsDictionary, Timestamp = 26978760625 } System.Data.SqlClient.WriteConnectionCloseAfter { OperationId = 3f6bfd8f-e5f6-48b7-82c7-41aeab881142, Operation = Close, ConnectionId = 84bd0095-9831-456b-8ebc-cb9dc2017368, Connection = System.Data.SqlClient.SqlConnection, Stat istics = System.Data.SqlClient.SqlStatistics+StatisticsDictionary, Timestamp = 26978772888 } 42
Insgesamt werden wir sechs Veranstaltungen sehen. Zwei davon werden vor und nach dem Öffnen der Verbindung zur Datenbank ausgeführt, zwei - vor und nach dem Ausführen des Befehls und zwei weitere - vor und nach dem Schließen der Verbindung zur Datenbank.
Jedes Ereignis enthält eine Reihe von Parametern wie OperationId
, ConnectionId
, Connection
, Command
, die normalerweise als Eigenschaften eines anonymen Objekts übergeben werden. Sie können typisierte Werte für diese Eigenschaften beispielsweise mithilfe von Reflection abrufen. (In der Praxis ist die Verwendung von Reflexion möglicherweise nicht sehr wünschenswert. Wir verwenden DynamicMethod, um Ereignisparameter abzurufen.)
Jetzt können wir das anfängliche Problem lösen - die Ausführungszeit aller Anforderungen an die Datenbank messen und zusammen mit der ursprünglichen Anforderung in der Konsole anzeigen.
Ändern Sie die Implementierung der Write
Methode wie folgt:
public sealed class ExampleDiagnosticObserver : IObserver<DiagnosticListener>, IObserver<KeyValuePair<string, object>> {
Hier fangen wir die Start- und Endereignisse der Anfrage an die Datenbank ab. Bevor wir die Anforderung ausführen, erstellen und starten wir die Stoppuhr und speichern sie in einer Variablen vom AsyncLocal<Stopwatch>
, um sie später wieder zu erhalten. Nach dem Ausführen der Anforderung erhalten wir die zuvor gestartete Stoppuhr, stoppen sie, rufen den ausgeführten Befehl über Reflektion aus dem Wertparameter ab und drucken das Ergebnis auf der Konsole aus.
Wenn wir jetzt den resultierenden Code ausführen, wird Folgendes in der Konsole angezeigt:
CommandText: SELECT 42; Elapsed: 00:00:00.0341357 42
Es scheint, dass wir unser Problem bereits gelöst haben, aber ein kleines Detail bleibt übrig. Tatsache ist, dass wir beim Abonnieren von DiagnosticListener
Ereignissen auch Ereignisse empfangen, die für uns nicht interessant sind. Da für jedes Ereignis ein anonymes Objekt mit Parametern gesendet wird, kann dies zu einer zusätzlichen Belastung des GC führen.
Um diese Situation zu vermeiden und Ihnen mitzuteilen, welche Ereignisse von DiagnosticListener
verarbeitet werden sollen, können Sie beim Abonnieren einen speziellen Delegaten vom Typ Predicate<string>
angeben, der den Namen des Ereignisses als Parameter verwendet und true
zurückgibt true
wenn dieses Ereignis verarbeitet werden soll.
IObserver<DiagnosticListener>.OnNext
in unserer Klasse IObserver<DiagnosticListener>.OnNext
:
void IObserver<DiagnosticListener>.OnNext(DiagnosticListener diagnosticListener) { if (diagnosticListener.Name == "SqlClientDiagnosticListener") { var subscription = diagnosticListener.Subscribe(this, IsEnabled); _subscriptions.Add(subscription); } } private bool IsEnabled(string name) { return name == "System.Data.SqlClient.WriteCommandBefore" || name == "System.Data.SqlClient.WriteCommandAfter"; }
Jetzt wird unsere Write
Methode nur für die Ereignisse "System.Data.SqlClient.WriteCommandBefore"
und "System.Data.SqlClient.WriteCommandAfter"
aufgerufen.
Verwenden des NuGet Package.Extensions.DiagnosticAdapter von Microsoft
Da die Ereignisparameter, die wir von DiagnosticListener
erhalten, normalerweise als anonymes Objekt übergeben werden, kann das Abrufen durch Reflexion zu teuer sein. Glücklicherweise gibt es ein Microsoft.Extensions.DiagnosticAdapter NuGet-Paket, das dies für uns mithilfe der Laufzeitcodegenerierung aus dem System.Reflection.Emit
.
Um dieses Paket beim Abonnieren von Ereignissen aus einer Instanz von DiagnosticListener
anstelle der Subscribe
Methode zu verwenden, müssen Sie die SubscribeWithAdapter
Erweiterungsmethode verwenden. Die Implementierung der IObserver<KeyValuePair<string, object>>
in diesem Fall nicht mehr erforderlich. Stattdessen müssen wir für jedes Ereignis, das wir behandeln möchten, eine separate Methode deklarieren und diese mit dem Attribut DiagnosticNameAttribute
(aus dem Microsoft.Extensions.DiagnosticAdapter
Namespace) kennzeichnen. Die Parameter dieser Methoden sind die Parameter des Ereignisses, das verarbeitet wird.
Wenn wir unsere ExampleDiagnosticObserver
Klasse mit diesem NuGet-Paket neu ExampleDiagnosticObserver
, erhalten wir den folgenden Code:
public sealed class ExampleDiagnosticObserver : IObserver<DiagnosticListener> { private readonly List<IDisposable> _subscriptions = new List<IDisposable>(); void IObserver<DiagnosticListener>.OnNext(DiagnosticListener diagnosticListener) { if (diagnosticListener.Name == "SqlClientDiagnosticListener") { var subscription = diagnosticListener.SubscribeWithAdapter(this); _subscriptions.Add(subscription); } } void IObserver<DiagnosticListener>.OnError(Exception error) { } void IObserver<DiagnosticListener>.OnCompleted() { _subscriptions.ForEach(x => x.Dispose()); _subscriptions.Clear(); } private readonly AsyncLocal<Stopwatch> _stopwatch = new AsyncLocal<Stopwatch>(); [DiagnosticName("System.Data.SqlClient.WriteCommandBefore")] public void OnCommandBefore() { _stopwatch.Value = Stopwatch.StartNew(); } [DiagnosticName("System.Data.SqlClient.WriteCommandAfter")] public void OnCommandAfter(DbCommand command) { var stopwatch = _stopwatch.Value; stopwatch.Stop(); Console.WriteLine($"CommandText: {command.CommandText}"); Console.WriteLine($"Elapsed: {stopwatch.Elapsed}"); Console.WriteLine(); } }
Auf diese Weise können wir jetzt die Ausführungszeit aller Abfragen an die Datenbank aus unserer Anwendung messen, praktisch ohne den Code der Anwendung selbst zu ändern.
Erstellen eigener DiagnosticListener-Instanzen
Wenn Sie DiagnosticSource in der Praxis verwenden, abonnieren Sie in den meisten Fällen vorhandene Ereignisse. Sie müssen höchstwahrscheinlich keine eigenen Instanzen von DiagnosticListener
erstellen und keine eigenen Ereignisse senden (nur wenn Sie keine Bibliothek entwickeln), daher werde ich mich nicht lange mit diesem Abschnitt befassen.
Um eine eigene Instanz von DiagnosticListener
zu erstellen, müssen Sie diese irgendwo im Code als statische Variable deklarieren:
private static readonly DiagnosticSource _myDiagnosticSource = new DiagnosticListener("MyLibraty");
Danach können Sie zum Senden eines Ereignisses ein Design des Formulars verwenden:
if (_myDiagnosticSource.IsEnabled("MyEvent")) _myDiagnosticSource.Write("MyEvent", new { });
Weitere Informationen zum Erstellen eigener DiagnosticListener
Instanzen finden Sie im DiagnosticSource-Benutzerhandbuch , in dem die Best Practices für die Verwendung von DiagnosticSource aufgeführt sind.
Fazit
Das Beispiel, das wir untersucht haben, ist sicherlich sehr abstrakt und in einem realen Projekt wahrscheinlich nicht nützlich. Es zeigt jedoch perfekt, wie dieser Mechanismus zur Überwachung und Diagnose Ihrer Anwendungen verwendet werden kann.
Im nächsten Artikel werde ich eine Liste der mir bekannten Ereignisse geben, die über DiagnosticSource verarbeitet werden können, und einige praktische Beispiele für deren Verwendung zeigen.