NuGet Packages mit .NET 6.0 erstellen

Mit NuGet können Software-Komponenten einfach verwaltet und hinzugefügt werden. Diese Pakete werden in Repositories (z.B. www.nuget.org) zur Verfügung gestellt. Viele Firmen bündeln Bibliotheken, die projektübergreifend benötigt werden, in NuGet Pakete und hosten diese in einem eigenen Repository. Das gezeigte Beispiel wurde mit .NET 6.0 durchgeführt, ist aber natürlich auch mit 5.0 und Core nutzbar.

NuGet Pakete packen

Zunächst wird eine Bibliothek erstellt, die als NuGet Paket von anderen Projekten genutzt werden soll.

dotnet new classlib -o MyExampleLib -f net6.0

Die Standard-Klasse „Class1“ kann durch folgende statische Beispiel-Klasse ersetzt werden:

using System;

namespace MyExampleLib
{
    public static class DoSomething
    {
		public static void SayHello(string Name)
		{
			Console.WriteLine($"Hallo {Name}!");
		}
    }
}

Um die Assembly als NuGet Package zu packen, kann entweder die dotnet-CLI verwendet werden:

dotnet pack

oder die Projekteigenschaften in Visual Studio werden angepasst, sodass beim Build ein NuGet Package erstellt wird:

Projekteigenschaften in Visual Studio

Nach dem Pack-Befehl oder dem Build wird eine nupkg Datei erstellt. Die Datei kann mit 7zip geöffnet werden, die eigentliche Assembly befindet sich darin.

MyExampleLib.1.0.0.nupkg

Paket-Manifest

Zusätzlich sollten noch Metadaten (Autor, Version,…) in der Projektdatei im Abschnitt „PropertyGroup“ angegeben werden (MyExampleLib.csproj)

	<PackageId>MyExampleLib</PackageId>
	<Version>1.0.0</Version>
	<Authors>Lukas</Authors>
	<Company>OBG IT Solutions</Company>

NuGet Package in Projekten nutzen

Hierfür wird zunächst eine Konsolenanwendung angelegt, die die Library nutzen soll.

dotnet new console -o MyTestApp -f net6.0

NuGet.config anlegen

Im AppData-Verzeichnis unter NuGet befindet sich die NuGet.Config. In dieser Datei werden die Hauptquellen (nuget.org,…) angegeben.

Um diese Datei im Originalzustand zu belassen, können zusätzliche NuGet.Config Dateien im jeweiligen Ordner erstellt werden (hier: im MyTestApp-Verzeichnis), dadurch stehen dem Projekt und allen Unterverzeichnissen die angegebenen Quellen zur Verfügung. Im MyTestApp-Verzeichnis wird die NuGet.Config Datei erstellt und bekommt folgenden Inhalt:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="local-packages" value="../MyExampleLib/bin/Debug" />
  </packageSources>
</configuration>

Dadurch wird das Verzeichnis, in dem sich das Package von oben befindet, als Quelle angegeben.

Mit dem add-Befehl lässt sich das Package hinzufügen.

dotnet add package MyExampleLib

Die MyTestApp kann nun das Package verwenden:

using System;
using MyExampleLib;

namespace MyTestApp
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
            MyExampleLib.DoSomething.SayHello("Max Mustermann");
        }
    }
}

Mehr Informationen: https://docs.microsoft.com/de-de/nuget/quickstart/create-and-publish-a-package-using-the-dotnet-cli

Plugin Engine mit .NET 5.0

Plugins sind Erweiterungen für Software, die dynamisch hinzugefügt werden können und den Funktionsumfang des Programms erweitern. Hierfür wird Late Binding benutzt. Das bedeutet, dass die Instanz eines Typs während der Laufzeit erzeugt wird, ohne beim Zeitpunkt der Entwicklung zu wissen, dass dieser überhaupt exisiert. Man benötigt also keine Referenz zu der jeweiligen Assembly.

Der vollständige Quelltext kann in GitHub abgerufen werden.

Projekt Aufbau

Zunächst wird das Projekt mittels der dotnet-CLI im Ordner „PluginExample“ angelegt:

mkdir src/
dotnet new classlib -o src\PluginExample.Common -f net5.0
dotnet new classlib -o src\PluginExample.MyPlugin -f net5.0
dotnet new console -o src\PluginExample.HostApp -f net5.0
dotnet new sln
dotnet sln add .\src\PluginExample.Common\ .\src\PluginExample.HostApp\ .\src\PluginExample.MyPlugin\

Damit werden zwei Klassenbibliotheken und eine Hostanwendung erstellt. Die PluginExample.Common Library enthält Interfaces für die Plugins und Hostapplikation (HostApp), sodass diese miteinander kommunzieren können.

PluginExample.Common

In der Common-Library werden Informationen geteilt, die die Plugins sowie die Hostanwendung benötigen.

Hierfür wird ein neues Interface in der Bibliothek erstellt:

namespace PluginExample.Common
{
    public interface IPlugin
    {
        string Name { get;  }
        string FormatString(string input);

    }
}

Das Feld Name soll den Namen des jeweiligen Plugins zurückgeben. Die Methode FormatString formatiert z.B. den input-String auf bestimmte Art (ist also die Funktion des Plugins).

PluginExample.MyPlugin

Dem Plugin (MyPlugin) wird eine Referenz zu PluginExample.Common hinzugefügt:

cd .\src\PluginExample.MyPlugin\
dotnet add reference ..\PluginExample.Common\

Danach wird im Projekt eine Klasse erstellt, die von IPlugin erbt:

using PluginExample.Common;

namespace PluginExample.MyPlugin
{
    public class MyPlugin : IPlugin
    {
        public string Name => "Uppercase";

        public string FormatString(string input)
        {
            // Gibt den input String großgeschrieben zurueck:
            return input.ToUpper();
        }
    }
}

Das Plugin hat in diesem Beispiel die Aufgabe, den input String großgeschrieben zurückzugeben.

PluginExample.HostApp

Die HostApp soll das eigentliche Programm darstellen und die Plugins nutzen. Hierfür wird eine PluginHost-Klasse erstellt:

using PluginExample.Common;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;


namespace PluginExample.HostApp
{
    public class PluginHost
    {

        private List<IPlugin> _plugins = new List<IPlugin>();

        public IEnumerable<IPlugin> Plugins
        {
            get => _plugins;
        }

        public PluginHost(string path)
        {
            if (!System.IO.Directory.Exists(path))
                return;

            foreach(var file in System.IO.Directory.GetFiles(path, "*.dll"))
            {
                // Assembly aus Datei laden:
                Assembly asm = Assembly.LoadFrom(file);

                // alle Klassen ermitteln, die Klassen sind und von IPlugin abgeleitet wurden:
                var classes = asm.GetTypes()
                    .Where(c => c.IsClass && (c.GetInterface("IPlugin") != null))
                    .ToList();

                // alle passenden Klassen der Liste hinzufuegen (Plugin kann mehrere beinhalten)
                classes.ForEach(c => 
                {
                    IPlugin plugin = (IPlugin)asm.CreateInstance(c.FullName, true);
                    _plugins.Add(plugin);

                    Console.WriteLine($"Loaded plugin: {plugin.Name}");
                });
            }
        }
    }
}

Der Aufruf in der Main-Methode könnte dann folgendermaßen aussehen:

            PluginHost host = new PluginHost("plugins/");

            string input = "Hallo Welt!";
            foreach(IPlugin plugin in host.Plugins)
            {
                string output = plugin.FormatString(input);
                Console.WriteLine($"{plugin.Name}: {output}");
            }

Das MyPlugin-Projekt muss manuell erstellt und in den Plugins-Ordner der HostApp eingefügt werden. Hierfür bieten sich PostBuild Ereignisse an, sodass die Assembly automatisch in den richtigen Ordner kopiert wird.

Konfiguration von .NET 5.0-Anwendungen

In diesem Artikel soll beschrieben werden, wie .NET Core Anwendungen konfiguriert werden können. Die Einstellungen können aus JSON-Dateien, Umgebungsvariablen,… bezogen werden. Diese Konfigurationsquellen werden vorallem bei Microservices oder Serveranwendungen genutzt.

Minimalistisches Beispiel

Im nachfolgenden Beispiel wird eine Konsolenanwendung realisiert, die mittels dem ConfigurationBuilder eine Konfiguration selbstständig erstellt und verwendet.

Optionspattern

Um verschiedene Einstellungen zu ordnen wird das Optionspattern genutzt. Dadurch erhalten z.B. verschiedene Anwendungsbereiche nur die für sie relevanten Optionen. Im Folgenden ist eine Beispiel Optionsklasse gezeigt. Das Feld Title bestimmt den Konsolentitel der Anwendung.

public class ConsoleOptions
{
	public string Title { get; set; }
}

Abhängigkeiten

Zunächst müssen folgende NuGet-Pakete, z.B. mit dem Package-Manager oder der .NET CLI eingebunden werden:

PM> Install-Package Microsoft.Extensions.Configuration
PM> Install-Package Microsoft.Extensions.Configuration.Binder
PM> Install-Package Microsoft.Extensions.Configuration.Json

Mit dem Binder-Package kann die Konfiguration als Optionsklasse zur Verfügung gestellt werden. Das JSON-Paket ist ein Konfigurations-Provider, der die Konfiguration aus dem JSON-Format ausliest. Es stehen weitere Provider für INI-Dateien, Kommandozeilenparameter,… zur Verfügung.

Erstellen der Konfiguration

Mit dem ConfigurationBuilder kann die Konfiguration erstellt werden. Die Methode Build gibt anschließend die Konfiguration vom Typ IConfiguration zurück. GetSection gibt die IConfigurationSection mit dem angegebenen Key (hier der Name der Klasse) zurück. Die generische Get-Methode bindet die Konfiguration an ein ConsoleOptions-Objekt. Das erzeugte ConsoleOptions-Objekt kann dann z.B. mittels Dependency Injection an eine Klasse, welche die Optionen benötigt, weitergegeben werden. Um weitere Einstellungen zu verwalten können neue Optionsklassen definiert und aus der jeweiligen Sektion ausgelesen werden.

IConfiguration config = new ConfigurationBuilder()
	.AddJsonFile("test.json")
	.Build();
ConsoleOptions consoleOptions = config.GetSection(nameof(ConsoleOptions)).Get<ConsoleOptions>();

// Setze Konsolentitel:
Console.Title = consoleOptions.Title;

Inhalt der test.json:

{
    "ConsoleOptions": {
        "Title": "OBG IT Solutions GmbH"
    }
}

Konfiguration mit .NET-Hosts

Mit einem generischen Host-Objekt wird einer Anwendung Konfiguration, Dependency Injection, Logging,… zur Verfügung gestellt. Dienste (Ableitungen von IHostedService) können im Host registriert werden. Diese Dienste werden beim Start des Hostes aufgerufen. Diese Technik ist vorallem aus ASP.NET Core bekannt, bei der Serverimplementierungen als Dienste hinzugefügt werden können. Hosts lassen sich aber auch z.B. in Konsolenanwendungen nutzen:

Das folgende Beispiel erstellt einen Host und fügt eine Konfiguration in ConfigureHostConfiguration hinzu (wie oben). Um vergleichbar mit dem Beispiel von oben zu sein, wurde die Standard-Konfiguration nicht genutzt. Mit Configure werden die Konfigurationsdienste hinzugefügt. Hier wird wie oben, die Sektion ConsoleOptions aus der Konfiguration ausgelesen. Als Beispiel wurde ein einfacher Service (MyService) hinzugefügt, der die Konfiguration konsumieren soll. Für jeden Dienst oder Programmabschnitt kann dadurch ohne Umstände eine eigene Konfigurationssektion und Optionsklasse zur Verfügung gestellt werden.

    class Program
    {
        static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) => 
            Host.CreateDefaultBuilder(args)
            .ConfigureHostConfiguration(configHost =>
            
            {
                configHost.SetBasePath(System.IO.Directory.GetCurrentDirectory());
                configHost.AddJsonFile("test.json");
            })
            .ConfigureServices((hostContext, services) =>
            {
                services.AddHostedService<MyService>();
                services.Configure<ConsoleOptions>(hostContext.Configuration.GetSection(nameof(ConsoleOptions)));
            });
    }

Der Beispiel-Service MyService kann die Konfiguration dann per Konstruktor Injektion entgegennehmen. Das initalisierte IOptions-Objekt verfügt in der Eigenschaft Value über die konfigurierte Instanz vom Typ ConsoleOptions.

public class MyService : IHostedService
{
    private readonly ConsoleOptions _options;
    public MyService(IOptions<ConsoleOptions> options)
    {
        _options = options.Value;
    }
//...
}

Dependency Injection in C#

Bei der Dependency Injection (Abhängigkeitsinjektion) handelt es sich um ein Entwurfsmuster, mit dem Abhhängigkeiten von Objekten einfacher verwaltet werden können. Wird ein Objekt neu erstellt, werden benötigte Referenzen zum Beispiel im Konstruktor übergeben.
Das hat den Vorteil, dass das erstellte Objekt nicht verantwortlich für seine Abhängigkeiten ist und die Kopplung (coupling) zwischen den Objekten verringert wird. Die Verknüpfungen können damit z.B. in Factories zentral gesteuert werden.

Minimalbeispiel einer Dependency Injection in C#

Bei dem nachfolgenden Beispiel könnte es sich um eine Anwendung handeln, die Rechnungsdaten aus einer Datenbank bezieht. Sie verfügt über eine Klasse (DatenbankService), die ausschließlich Daten aus einer Datenbank liest und schreibt. Über eine weitere Klasse (DatenArchiv) sollen Daten ausgewertet und verarbeitet werden. Diese Klasse erhält die Daten aus dem Datenbank-Service. Der Service soll mittels Dependency-Injection übergeben werden.

Abhängigkeit Datenbank-Service

Mit der DatenbankService-Klasse soll auf eine Datenbank direkt zugegriffen werden.

public class DatenbankService
{
     public void SchreibeDaten()
     {
          Console.WriteLine("Schreibe Daten in Datenbank....");
     }
}

Constructor Injection

Mit der DatenArchiv-Klasse sollen Daten verarbeitet (gelesen, gespeichert, ausgewertet,…) werden. Der Konstruktor der Klasse erwartet ein Objekt vom Typ DatenbankService. Die Referenz wird privat in der Klasse festgehalten. Somit kann in den Methoden auf den Datenbankservice zugegriffen werden, ohne dass die DatenArchiv-Klasse an der Initialisierung des Services beteiligt ist.

public class DatenArchiv
{
     private readonly DatenbankService _datenbankService;

     public DatenArchiv(DatenbankService datenbankService)
     {
          _datenbankService = datenbankService;
     }

     public void AktualisiereDaten()
     {
          _datenbankService.SchreibeDaten();
          // ...
     }
}

Aufruf im Client

Der Client kann nun den Service erstellen und die Referenz dem DatenArchiv-Objekt übergeben.

DatenbankService dbService = new DatenbankService();
DatenArchiv datenArchiv = new DatenArchiv(dbService);
datenArchiv.AktualisiereDaten();
// ...

Ergänzungen

Weiter bietet es sich an, ein Interface zu entwerfen und den DatenbankService in einer Factory zu erstellen. Dadurch könnten z.B. Services für verschiedene Datenbank-Systeme (SQLite, MySQL, MongoDB,…) zur Laufzeit ausgetauscht werden, ohne die DatenArchiv-Klasse anpassen zu müssen. Mittels Dependency Injection kann dem Datenbank-Service zustzälich die Konfiguration (Datenbank Connectionstring,…) übergeben werden.

Microsoft.Extensions.DependencyInjection

Auf die Nutzung von generischen .NET Hosts wird im Post Konfiguration von .NET 5.0 Anwendungen eingegangen. Mit einem Host wird einer Applikation das Verwalten der Lebenszeit von Diensten, Konfiguration, Logging,… zur Verfügung gestellt. Dies ist vorallem bekannt aus ASP.NET Core. Daneben können Abhängigkeitsinjektionen damit stark vereinfacht werden.

Konsolenanwendung

Die Klasse DatenbankService wurde von oben übernommen. Das Interface beschreibt die Funktionsweise, welche die Klasse DatenbankService von oben zur Verfügung stellt. So können leicht unterschiedliche Implementierungen eingepflegt werden, ohne den Code anpassen zu müssen.

public interface IDatenbankService
{
    void SchreibeDaten();
}
public class DatenbankService : IDatenbankService
{
     public void SchreibeDaten()
     {
          Console.WriteLine("Schreibe Daten in Datenbank....");
     }
}

Der Dienst, welcher im .NET Host gehostet wird, nimmt die Abhängigkeit im Konstruktor, wie im ersten Teil, entgegen:

public class MyService : IHostedService
{
        private readonly IDatenbankService _dbService;
        public MyService(
            IDatenbankService dbService)
        {
            _dbService = dbService;
        }
//...
        static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices((hostContext, services) =>
            {
                services.AddHostedService<MyService>();
                services.AddScoped<IDatenbankService, DatenbankService>();
            });
    }

Bei der Konfiguration des gehosteten Dienstes wird die Abhängigkeit mit AddScoped mit dem Typ IDatenbankService und einer Implementierung als DatenbankService hinzugefügt. Ohne Interface könnte auch AddScoped<MyDiService>() definiert werden. Scoped bedeutet dass die Abhängigkeit innerhalb eines Bereiches nicht neu initalisiert wird, alternativ stehen AddSingleton (Instanz wird insgesamt nur einmal erstellt) und AddTransient (bei jeden Aufruf eine neue Instanz).

Abhängigkeiten von Diensten

Wird ein zweiter Dienst hinzugefügt, der den Funktionsumfang eines anderen benötigt, löst das Framework dies automatisch:

public class DatenbankRepository
{
     public DatenbankRepository(IDatenbankService dbService)
     {
          //...
     }
}
services.AddScoped<IDatenbankService, DatenbankService>();
services.AddScoped<DatenbankRepository>();

Datenbank-Repository wird das initalisierte IDatenbankService Objekt zur Verfügung gestellt. Der Empfänger des Dienstes kann das DatenbankRepository als Abhängigkeit, ohne Kenntnis vom DatenbankService, entgegen nehmen.

DI in ASP.NET Core

In ASP.NET Core können mit der gleichen Technik in der Methode ConfigureServices der Startup-Klasse, Dienste hinzugefügt werden:

// Beispiel eines Blazor Servers:
public void ConfigureServices(IServiceCollection services)
{
        services.AddRazorPages();
        services.AddServerSideBlazor();
        services.AddScoped<IDatenbankService, DatenbankService>();
        services.AddScoped<DatenbankRepository>();
}

In einer Razor-Page kann der Dienst direkt genutzt werden:

@inject MyBlazorExample.DatenbankRepository dbRepository

@code {
    //...
    protected override async Task OnParametersSetAsync()
    {
        var customers = await dbRepository.GetAllCustomersAsync()
        // ...
    }
}

Projekte mit der .NET Core-CLI verwalten

Die dotnet-CLI ist ein Kommandozeilenprogramm mit dem .NET-Anwendungen plattformunabhängig verwaltet und erstellt werden können. Es ist Bestandteil des .NET SDK.

Erstellen einer Projektmappe

Im folgenden Abschnitt soll kurz gezeigt werden, wie Projekte mittels der CLI verwaltet werden können. Der Aufbau des Projektes orientiert sich am gängigen dotnet Layout.

Zunächst wird ein Hauptverzeichnis erstellt. Mit dem new sln-Befehl kann eine Solution erstellt werden (z.B. für Visual Studio, CI-Software,…).

mkdir obgapp/
cd obgapp/
dotnet new sln

Anlegen von Projekten

Im nächsten Schritt wird eine Klassenbibliothek innerhalb des Hauptverzeichnisses erstellt:

mkdir src/
mkdir src/obgapp.Common/
cd src/obgapp.Common/

Mit dem Befehl dotnet new kann nun ein Projekt angelegt werden. Aus dem Namen des Verzeichnisses wird der Projektname sowie die Namespaces abgeleitet.

dotnet new classlib

Mit sln add kann das Projekt der Projektmappe hinzugefügt werden:

cd ../..
dotnet sln add src/obgapp.Common/

Das Beispielprojekt soll nun noch über eine HttpApi verfügen, die die obgapp.Common-Bibliothek referenziert. Im Hauptverzeichnis wird hierfür ein neues Projekt angelegt:

mkdir src/obgapp.ApiHost/
cd src/obgapp.ApiHost/
dotnet new webapi
dotnet add reference ../obgapp.Common

Der Befehl add reference fügt eine Referenz zum aktuellen Projekt hinzu. Mit sln add kann das Projekt wie oben beschrieben der Solution hinzugefügt werden.

Ausführen und Erstellen

dotnet run Ausgabe

Mit den Befehlen run und build wird ein Projekt ausgeführt, bzw. nur erstellt.

Um die HttpApi zu starten kann run im Projektorder ausgeführt werden:

cd obgapp.ApiHost/
dotnet run

Markdown Tag für ASP.NET Core 5.0

Im folgenden Beispiel zeigen wir, wie man mit einem TagHelper, Markdown-Code direkt in Views rendern kann. Das Markdown wird mit Hilfe von Markig in HTML umgewandelt.

Markdown-Syntax in einer README

Die Einbettung des Markdown-Codes könnte wie folgt in der Razor-Datei angegeben werden:

@{
    ViewData["Title"] = "Home Page";
    var md = "# Test\n- *Hello World!*\n - __Test__";
}

<markdown md-content="@md"/>

Benötigte NuGet-Packages

  • markdig
  • Microsoft.AspNetCore.Mvc.TagHelpers

MarkdownTagHelper

Zunächst muss ein TagHelper hinzugefügt werden. Hierfür wird eine neue Klasse erstellt. Der MarkdownTagHelper ist eine Ableitung der Klasse TagHelper. Der <markdown>-Tag wird in der Process-Methode durch eine Div-Sektion ersetzt. Dazwischen wird der umgewandelete Markdown-Code eingefügt. TagStructure gibt in dem Fall an, dass ein einfacher Tag ausreicht (ohne schließenden Tag).

    [HtmlTargetElement("markdown", TagStructure = TagStructure.WithoutEndTag)]
    public class MarkdownTagHelper : TagHelper
    {
        public string MdContent {get;set;}

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            // Markdown will be rendered between div
            output.TagName = "div";
            output.TagMode = TagMode.StartTagAndEndTag;

            output.Content.SetHtmlContent(Markdown.ToHtml(MdContent));
        }
    }

Import der TagHelper

In der Datei _ViewImports.html kann der Import für den TagHelper angegeben werden. Der Name MarkdownTagExample stellt dabei den Namen des Projektes dar. Durch „*“ werden alle TagHelper(-Ableitungen) der jeweiligen Assembly hinzugefügt und können in den Views genutzt werden:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, MarkdownTagExample

Weitere Informationen zu den TagHelper gibt es unter: https://docs.microsoft.com/de-de/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-5.0