Überwachen von Coding Conventions per Roslyn Analyzer

Das Projekt RK GPXviewer ist ein Modulith in Form einer Desktop Applikation. Das heißt, dass das Programm in mehrere lose gekoppelte Module aufgeteilt ist. Jedes Modul hat hierbei eine öffentliche Schnittstelle und eine nur innerhalb des Moduls sichtbare Logik. Diese Trennung zwischen öffentlicher Schnittstelle und privaten Logikklassen ist hierbei eine strenge Regel. Sie soll sicherstellen, dass das Geflecht an Modulen auch in Zukunft sauber wartbar und erweiterbar bleibt. Nur wie lässt sich die Einhaltung einer solchen Regel am besten sicherstellen? Alle Module befinden sich in der gleichen Projektmappe, eine Regelverletzung wirkt geradezu einladend. Ist eine notwendige Methode oder Eigenschaft gerade nicht in der Schnittstelle enthalten, ist es relativ einfach, ohne Umwege direkt auf die Logik-Klassen zuzugreifen. Man muss lediglich etwas von privat auf öffentlich umstellen – merkt schon keiner. Oder einen Verweis hinzufügen, merkt auch keiner… Damit das nicht passiert, lassen sich für solche Regeln Roslyn Analyzer schreiben. Diese prüfen den Code während des Compile-Vorgangs und geben nach Wahl direkt Fehler oder Warnungen aus. In diesem Artikel möchte ich auf den Roslyn Analyzer eingehen, welchen ich für RK GPXviewer zur Einhaltung obiger Regel umgesetzt habe.

Das Szenario

Das Szenario habe ich bereits in einen anderen Artikel [1] beschrieben, darum hier nur in ein paar Worten. Nachfolgende Abbildung zeigt einen frühen Stand des Moduls GpxFiles. Die öffentliche Schnittstelle des Moduls befindet sich im Ordner (bzw. Namespace) Interface. Alles andere, also etwa die Ordner Logic und Views, ist privat. Die Einzige Ausnahme ist die Klasse GpxFilesModule, da es sich hierbei um den Bootstraper des Moduls handelt.

Die Coding Convention zu diesem Szenario könnte man wie folgt beschreiben:

  • Alle Typen im Namensraum GpxViewer.Modules.<ModulName>.Interface.* müssen mit public gekennzeichnet sein
    • <ModulName> steht dabei für den Namen des Moduls
    • Der * am Ende steht für einen beliebigen Namensraum + Typ-Namen
  • Die Klasse GpxViewer.Modules.<ModulName>.<ModulName>Module muss ebenfalls mit public gekennzeichnet sein
  • Alle anderen Typen innerhalb des Moduls müssen als internal gekennzeichnet sein

Entwicklung eines Roslyn Analyzer

Nun geht es darum, obige Regeln in einem Roslyn Analyzer umzusetzen. Roslyn Analyzer laufen direkt im Compiler und können damit schnelles Feedback darüber liefern, ob Regeln verletzt werden. Im Internet gibt es viele Quellen darüber, wie ein Roslyn Analyer geschrieben wird. Ein gutes Beispiel dazu ist etwa unter [2] zu finden. Alternativ bietet auch die Dokumentation von Microsoft etwa unter [3] einen guten Einstieg.

Im Fall des RK GPXviewer habe ich den Roslyn Analyzer direkt in der gleichen Projektmappe umgesetzt (Siehe nachfolgende Abbildung). Daneben ebenfalls das zugehörige Unittest Projekt. Über letzteres kann sichergestellt werden, dass der eigene Roslyn Analyzer genau das macht, was er soll.

Nachfolgender Codeausschnitt zeigt den eher allgemeinen Teil des umgesetzten Roslyn Analyzer. Ich möchte hier gar nicht auf alle Bestandteile eingehen, da das meiste auch so vom Projekttemplate angelegt wird und entsprechend in oben verwiesenen Quellen erklärt ist. Interessant hier ist die Zeile 20. Hier wird festgelegt, dass sich dieser Roslyn Analyzer um alle Typ-Namen kümmert. Typ-Namen können dabei Namen von Klassen, Interfaces, Strukturen usw. sein. Der Compiler benachrichtigt diesen Roslyn Analyzer also für jeden Typ-Namen, auf die er trifft. Die Benachrichtigung erfolgt in Form eines Aufrufs der Methode AnalyzeSymbol.

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ModuleTypeAccessAnalyzer : DiagnosticAnalyzer
{
    public const string DIAGNOSTIC_ID = "GpxViewerAnalyzers";

    private const string TITLE = "Module Type accessibility";
    private const string MESSAGE_FORMAT = "Type '{0}' has invalid accessibility. Current: {1}, expected: {2}";
    private const string DESCRIPTION = "Only interface types and main type of modules are public, all others musst be internal";
    private const string CATEGORY = "GpxViewer Modules";

    private static readonly DiagnosticDescriptor s_rule = new DiagnosticDescriptor(DIAGNOSTIC_ID, TITLE, MESSAGE_FORMAT, CATEGORY, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: DESCRIPTION);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(s_rule); } }

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
    }

    //...
}

Nachfolgender Codeausschnitt zeigt die Logik der Methode Analyze Symbol, welche schließlich obige Coding Convention umsetzt.

  • In den Zeilen 3 bis 6 wird geprüft, ob man sich gerade in einem Modul und nicht in einem Test-Projekt für das Modul oder wo anders in der Projektmappe befindet. Die Prüfung erfolgt allein anhand des Namespace.
  • In den Zeilen 8 bis 14 wird zunächst der Name des Moduls ermittelt, um anschießend den Namespace der öffentlichen Schnittstelle und den Namen des Bootstrappers zu ermitteln.
  • In den Zeilen 16 bis 17 wird der Namespace und Name des aktuell untersuchten Typs ermittelt.
  • In den Zeilen 19 bis 18 wird die erwartete Sichtbarkeit des aktuell untersuchten Typs ermitteln.
  • In den Zeilen 30 bis 34 schließlich wird ein Fehler ausgelöst, wenn die erwartete Sichtbarkeit von der im analysierten Code abweicht.
private static void AnalyzeSymbol(SymbolAnalysisContext context)
{
    var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;
    var fullNamespace = namedTypeSymbol.ContainingNamespace.ToString();
    if (!fullNamespace.StartsWith("GpxViewer.Modules")) { return; }
    if (fullNamespace.EndsWith(".Tests")) { return; }

    var splittedNamespace = fullNamespace.Split('.');
    if (splittedNamespace.Length < 3) { return; }
    var moduleName = splittedNamespace[2];

    var namespaceInterface = $"GpxViewer.Modules.{moduleName}.Interface";
    var moduleMainTypeNamespace = $"GpxViewer.Modules.{moduleName}";
    var moduleMainTypeName = $"{moduleName}Module";

    var actTypeNamespace = namedTypeSymbol.ContainingNamespace.ToString();
    var actTypeName = namedTypeSymbol.Name;

    var expectedAccessibility = Accessibility.Internal;
    if (actTypeNamespace.StartsWith(namespaceInterface))
    {
        expectedAccessibility = Accessibility.Public;
    }
    else if (actTypeNamespace.Equals(moduleMainTypeNamespace, StringComparison.Ordinal) &&
             actTypeName.Equals(moduleMainTypeName, StringComparison.Ordinal))
    {
        expectedAccessibility = Accessibility.Public;
    }

    if (namedTypeSymbol.DeclaredAccessibility != expectedAccessibility)
    {
        var diagnostic = Diagnostic.Create(s_rule, namedTypeSymbol.Locations[0], namedTypeSymbol.Name, namedTypeSymbol.DeclaredAccessibility, expectedAccessibility);
        context.ReportDiagnostic(diagnostic);
    }
}

Die Logik des Roslyn Analyzer kann per Unittest abgetestet werden. Nachfolgender Codeausschnitt zeigt ein Beispiel. Hier wird zunächst ein kurzes Stück C#-Code in Form eines Strings erzeugt. Anschließend wird die Meldung definiert, welche man vom eigenen Roslyn Analyzer erwartet. Das „|#0:TestingModule|“ im Quellcode ist dabei ein Marker, auf den in Zeile 14 per .WithLocation(0) verwiesen wird. Abschließend lässt man den Roslyn Analyzer laufen und vergleicht das Erwartete mit dem Ergebnis des Roslyn Analyzers.

Das Projekttemplate von .Net erzeugt auch für solche Unittests bereits alles Grundlegende, etwa die Klasse CSharpAnalyzerVerifier. Somit ist es nicht schwierig, eigene Tests genau nach diesem Muster zu schreiben.

[TestMethod]
public async Task MainModuleType_BadCase()
{
    var test = @"
namespace GpxViewer.Modules.Testing
{
    internal class {|#0:TestingModule|}
    {
        //...
    }
}";
    var expected = CSharpAnalyzerVerifier<ModuleTypeAccessAnalyzer>
        .Diagnostic(ModuleTypeAccessAnalyzer.DIAGNOSTIC_ID)
        .WithLocation(0)
        .WithArguments("TestingModule", "Internal", "Public");

    await CSharpAnalyzerVerifier<ModuleTypeAccessAnalyzer>.VerifyAnalyzerAsync(test, expected);
}

Damit die umgesetzten Roslyn Analyzer beim Compile-Vorgang ausgeführt werden, müssen die betroffenen Projekte auf den Roslyn Analyzer verweisen. Hier ein besonderer Hinweis: Da es sich nicht um eine normal verwiesene Dll handelt, müssen am Verweis noch einige Einstellungen gemacht werden. Nachfolgender Codeausschnitt zeigt den relevanten Teil der csproj-Dateien.

<Project>

  <!-- ... -->

  <ItemGroup>
    <ProjectReference Include="..\..\Analyzers\GpxViewer.Analyzers\GpxViewer.Analyzers.csproj"
                      PrivateAssets="all"
                      ReferenceOutputAssembly="false"
                      OutputItemType="Analyzer"/>
  </ItemGroup>

  <!-- ... -->

</Project>

Nun schließlich ist der Roslyn Analyzer aktiv. In nachfolgender Abbildung sieht man direkt ein Negativ-Beispiel. Die Klasse GpxFileRepository ist eine reine Logik-Klasse im Modul GpxFiles. Da dieser hier fälschlicherweise eine öffentliche Klasse ist (public), meckert obiger Roslyn Analyzer und bricht damit den Compile-Vorgang ab.

Fazit

Roslyn Analyzer sind ein eleganter Weg, Prüfungen für eigene Coding Conventions bereits während dem Compile-Vorgang laufen zu lassen. Die Entwicklung ist dabei nicht zuletzt aufgrund vieler Quellen im Internet vergleichbar einfach – zumindest einfacher, als man anfangs erwartet. Für mich gibt es primär zwei Punkte, auf die man achten sollte. Punkt 1 ist das Debugging. Da die Logik direkt im Compiler läuft, ist Debugging nicht so einfach. Fehlersuche im Roslyn Analyzer erfolgt stattdessen primär mit den Unittests. Allgemein ist eine gute Testabdeckung mit Positiv- und Negativ-Tests für die Logik im Roslyn Analyzer hier sehr wichtig. Punkt 2 ist der Projektverweis. Hier können leicht Fehler passieren, die dazu führen, dass der Roslyn Analyzer nicht korrekt ausgeführt wird. An der Stelle aber noch ein Hinweis: Der Verweis muss nicht in jeder csproj-Datei einzeln hinzugefügt werden. Alternativ kann man ihn auch in einer gemeinsamen Directory.Build.props Datei hinzufügen. Dadurch gilt der Verweis für alle csproj-Dateien im gleichen Ordner und in allen Unterordnern.

Quellen

  1. https://www.rolandk.de/wp-posts/2021/03/prism-als-basis-von-modern-strukturierten-applikationen/
  2. https://www.meziantou.net/writing-a-roslyn-analyzer.htm
  3. https://docs.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/tutorials/how-to-write-csharp-analyzer-code-fix

Schreibe einen Kommentar

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.