Umstieg auf Umbraco 9: Das ist wichtig
Dienstag, 15.03.2022
Wir haben unsere LAYKIT-Plattform auf die neueste Version von Umbraco portiert. Diese Version unterscheidet sich vom technologischen Unterbau her schon sehr stark von bisherigen Umbraco-Versionen, weil Umbraco 9 erstmals auf ASP.NET Core basiert. Dies ermöglicht es unter anderem, unsere Umbraco-Lösungen auf Linux zu hosten. Im Folgenden beschreiben wir, was sich alles im Vergleich zur Version 8 geändert hat.
Anlegen von Umbraco-Projekten
Projekte muss man mit dotnet new anlegen. Es reicht also nicht, in Visual Studio ein Web-Projekt anzulegen und dann das passende Nuget-Package zu installieren. Zuerst aber muss man mit
dotnet new -i Umbraco.Templates::*
die Templates für Umbraco installieren. Mit dotnet new umbraco legt man dann ein Umbraco-Projekt an. Die gute Nachricht ist: Für das Anlegen von Dlls, die auf Umbraco Core oder Web zugreifen, erstellt man eine Klassenbibliothek mit dem Zielframework .NET 5.0. Auf meinem System entsteht in VS erst einmal eine Klassenbibliothek mit Zielframework 3.1. Das Zielframework kann man aber leicht ändern. Danach lassen sich mit install-package Umbraco.Cms.Core oder Umbraco.Cms.Web.Infrastructure, bzw. für Backoffice-Erweiterungen auch Umbraco.Cms.Web.Backoffice die Umbraco-Pakete via Nuget laden. Die so erzeugten Projekte können wiederum als Nuget-Package verpackt und in der Zielanwendung installiert werden. Nuget ist im Übrigen der einzige Mechanismus, über den Umbraco-Packages ausgeliefert werden können.
Dependency Injection
Die gesamte DI von Umbraco ist umgeschrieben worden. Sie benutzt jetzt die Interfaces von Microsoft (Microsoft.Extensions.DependencyInjection). Dahinter steht nach wie vor die Implementierung von LightInject.
Die Klasse Current gibt es nicht mehr. Die User werden somit gezwungen, den gesamten Code umzuschreiben, sodass die benötigten Services via Konstruktor-Parameter übermittelt werden. Nun gibt es aber ein paar Klassen in Asp.Net, die werden noch nicht von der DI erfasst, zum Beispiel die API-Filter. Ein Filter muss dann IFilterFactory implementieren. Damit hat man Einfluss auf das Anlegen der Filter und der ServiceProvider wird als Parameter zur Verfügung gestellt. Nun kann man via DI ein Objekt anlegen:
public IFilterMetadata CreateInstance( IServiceProvider serviceProvider ) { return serviceProvider.GetService<ApiDangerousInputFilterAttribute>(); }
Statt new ApiDangerousInputFilterAttribute erzeugt man die Instanz mit serviceProvider.GetService. Auf diese Weise werden eventuelle Konstruktor-Parameter aufgelöst und dem Filter übergeben. serviceProvicer.GetService ist überhaupt das Mittel, wie man beliebige Objekte mit DI anlegen kann. Die Konstruktorparameter werden dann von der DI aufgelöst.
Man braucht nur eine Instanz von ServiceProvider - und das ist die große Schwierigkeit.
Man könnte sich nun die Klasse Current selbst aufbauen. Für den Start der Applikation legt man normalerweise zwei Extension-Methoden UseXxxx und AddXxxx an. Für unser LAYKIT gibt es zwei Versionen davon, die UseLaykit und AddLaykit heißen. Die UseXxx-Methoden bekommen einen IApplicationBuilder als Parameter und können mit app.ApplicationServices beliebige Objekte vom DependencyInjection-Container abrufen.
Ich habe eine Klasse ServicesImpl geschrieben, die statisch initialisiert wird:
ServicesImpl.Initialize( app.ApplicationServices );
Diese ServicesImpl-Klasse kann dann von Current genutzt werden, um auf die Services zugreifen zu können.
public class ServicesImpl { public static ServicesImpl Instance; public static void Initialize(IServiceProvider serviceProvider) { Instance = new ServicesImpl( serviceProvider ); } public ServicesImpl( IServiceProvider serviceProvider ) { contentService = serviceProvider.GetRequiredService<IContentService>(); } IContentService contentService; public IContentService ContentService => contentService; }
Initialize legt die eine Instanz von ServicesImpl an und übergibt den ServiceProvider. Damit kann dann der contentService angelegt werden. Current nutzt dann einfach diese Instanz:
public static class Current { public static ServicesImpl Services { get; private set; } static Current() { Services = ServicesImpl.Instance; } }
So wie im gezeigten Code mit contentService verfahren wir mit anderen Services, die zum Beispiel in statischen Klassen mit Extension-Methoden genutzt werden können.
Komponenten mit langen Parameterlisten, die häufig gebraucht werden
Das ging so ähnlich schon in Umbraco 8, aber dort konnte man sich noch mit Current behelfen, wenn Parameterlisten zu lang wurden. Eine Klasse in Umbraco 8 kann eine kurze Parameterliste haben und ihre Abhängigkeiten mit Current auflösen. Aber das war im Grunde ein schlechter Programmierstil. Besser ist das hier gezeigte Verfahren. Ich nehme hier unseren TrackingHelper als Beispiel. Der hat folgenden Konstruktor:
public TrackingHelper ( ILoggerFactory loggerFactory, IConfiguration configuration, IHostingEnvironment hostingEnvironment, IAppPolicyCache runtimeCache, IUmbracoHelperAccessor umbracoHelperAccessor, IUmbracoContextAccessor umbracoContextAccessor, IHttpContextAccessor httpContextAccessor, IRequestCache requestCache ) ...
Der TrackingHelper wird meist in Views oder Controllern gebraucht. Man legt den TrackingHelper dort nicht jedesmal mit allen Parametern an, weil man die Parameter ja dann selbst in der Parameterliste des Views oder Controllers injizieren müsste. Das ist ein Haufen Tipparbeit. Stattdessen gibt man in der Parameterliste einen Parameter vom Typ TrackingHelper an. Dazu muss der TrackingHelper registriert werden. Das geschieht in einem Composer:
// Erzeugt einen neuen TrackingHelper für jeden Request builder.Services.AddScoped<TrackingHelper>();
Die Parameterliste des TrackingHelpers wird nun von der DependencyInjection automatisch aufgelöst. Statt 8 Parametern braucht man nur einen, um den TrackingHelper zu nutzen.
MapPath
Es gibt in .NET Core kein MapPath mehr. Der Grund dafür ist, dass eine ASP.NET Core-Anwendung auf zwei Pfade verteilt ist. Der Root-Pfad, in dem die Views und das bin-Verzeichnis liegen, sowie der wwwroot, in dem Assets wie .js-Dateien und die Medien liegen. Folgender Controller zeigt, wie man die Pfade ermittelt:
public class TestController : UmbracoApiController { private readonly IHostingEnvironment hostingEnvironment; public TestController(IHostingEnvironment hostingEnvironment) { this.hostingEnvironment = hostingEnvironment; } [HttpGet] public object Get() { var webRoot = this.hostingEnvironment.MapPathWebRoot( "lala" ); var contentRoot = this.hostingEnvironment.MapPathContentRoot( "lala" ); return new { webRoot, contentRoot }; } }
Wichtig: Es gibt zwei Definitionen von IHostingEnvironment, eine von Microsoft und eine von Umbraco. Wir müssen die von Umbraco nehmen. Die Variante von Microsoft ist deprecated. Das Ergebnis des Controller-Aufrufs wird beispielhaft hier gezeigt:
webRoot | "C:\\Projekte\\UmbracoV9\\wwwroot\\lala" |
contentRoot | "C:\\Projekte\\UmbracoV9\\lala" |
Umbraco.Extensions
Ohne using Umbraco.Extensions geht in Umbraco 9 fast gar nichts. Zum Beispiel die Url()-Methode oder Value<T> von IPublishedContent. Wenn also eine Methode, deren Nutzung Ihr gewohnt seid, plötzlich fehlt, hilft es im Zweifelsfall, den Namespace einzubinden und zu sehen, ob die Methode dann auftaucht.
Logging
Die Logger haben keine Methoden mehr mit GetType()-Parametern. Auch die Methodennamen haben sich geändert (LogError, LogInformation...), sodass der gesamte Logging-Code einmal mehr umgeschrieben werden muss. So kommt man zu einem Logger:
class MyClass { ... ILogger<MyClass> logger; public MyClass(ILoggerFactory loggerFactory) { this.logger = loggerFactory.CreateLogger<MyClass>(); } ... }
Wenn der Logger in einer Basisklasse angelegt wird, möchte man im Log natürlich nicht den Typ der Basisklasse sehen. Dann kann man den Logger auch mit GetType() anlegen:
this.logger = loggerFactory.CreateLogger(GetType());
Logger gibt man natürlich nicht an andere Klassen weiter. Statt dessen immer die LoggerFactory weitergeben. Jede Klasse baut sich ihren eigenen Logger und kann damit den Logging-Kontext bestimmen.
Es gibt keine @helper-Methoden mehr
Das ist richtig schade. Aber es gibt mehrere Lösungen, wie man damit umgehen kann.
- Partials. Wenn man ein Partial neu erstellt, gibt es eine xxx.cshtml und eine xxx.cshtml.cs-Datei. In der .cs-Datei kann man ein Model definieren, das dann typsicher die Daten vom aufrufenden in den aufgerufenen Kontext bringt. Diese Lösung haben wir verwendet, um einige sehr große Helper zu portieren. Man muss dazu sagen: So große Helper waren eigentlich nie der Sinn der Sache.
- Functions: Man kann eine kleine function mit Rückgabetyp IHtmlContent erstellen. Dann kann man einen Ausdruck von der Art schreiben:
return @<p>@meinTestText</p>
Der aufrufende Kontext kann die function wie einen Html-Helper aufrufen. Das ist allerdings sehr limitiert. Es ist mir nicht gelungen, Ergebnisse zu erzeugen, die mehr als eine Zeile umfassen. - ViewComponents. Für kleinere Schnipsel kann man den Text als IHtmlContent direkt in der Methode Invoke bzw. InvokeAsync zurückgeben. Oder man bemüht in der ViewComponent einen View. Aber das bringt dann gegenüber der Partial-Lösung unter 1. keine Vorteile.
Es gibt kein App_Code mehr
Alle Controller müssen also in der Haupt-Anwendung mitkompiliert werden, oder man verwendet ein Package-Projekt. Wir haben App_Code bislang verwendet, um schnell austauschbare Controller zu bekommen. Das Anlegen eines eigenen Projekts für so einen Controller ist ziemlich aufwändig. Die meisten davon werden also in der Hauptanwendung landen. Es sei denn, jemand findet eine bessere Lösung. Für Tipps sind wir dankbar.
Kein UrlRewrite mehr
Es gibt keine web.config, es gibt keinen IIS. Also muss das Überschreiben (Rewrite) und Umleiten (Redirect) in der Middleware erfolgen. Dafür gibt es entsprechende Optionen im AppBuilder:
appBuilder.UseRewriter( new RewriteOptions() .AddRedirectToWwwPermanent() .AddRedirectToHttpsPermanent() );
Das kann man vom Environment abhängig machen:
if (!env.IsDevelopment()) { ...// Rewrite auf https }
Published/Unpublished-Events
In Umbraco 8 hatten wir IComponent-Implementierungen. IComponent wird beim Startup gefunden, dann wird Initialize() und Terminate() aufgerufen. In Initialize konnte man sich an Events des MediaService etc. anmelden.
Das wird nun ausgetauscht gegen Implementierungen von INotificationHandler<T>. T kann dann einer der Notification-Typen sein, wie zum Beispiel ContentPublishedNotification. Das Interface hat eine Methode Handle, die ein T als Parameter übernimmt. Der Parameter entspricht im Großen und Ganzen dem Event-Typ, der früher übergeben worden ist. Etwas umständlich wird das, wenn man mehrere Handler in einer Klasse implementieren will. Dann muss man alle Interfaces explizit implementieren, weil die Methode ja immer Handle heißt. Eine solche explizite Implementierung sieht so aus:
void INotificationHandler<ContentPublishedNotification>.Handle( ContentPublishedNotification notification ) { ... }
Für diese Umstände erhalten wir als Gegenwert die Möglichkeit, in den Handlern mit DependencyInjection zu arbeiten.
Kein ConfigurationManager, keine AppSettings und keine Web.config mehr
Eine Web.config wird nur noch für die Belange des IIS vorgehalten, sofern die Applikation dort gehostet wird. Eine Anwendung sollte sich in keiner Weise auf diese Datei beziehen, weil sie unter Linux schlichtweg nicht existiert. Stattdessen gibt es die AppSettings.json-Datei. Diese besteht aus Json-Objekten, die hierarchisch verschachtelt sein dürfen. Wenn man die Objekte nun abfragt, dann separiert man die einzelnen Objekte im Pfad mit Doppelpunkten. Also z.B.:
configuration.GetValue<string>( "Umbraco:CMS:Global:Smtp:From" )
fragt den Absender der Smtp-Settings ab:
{ "Umbraco": { "CMS": { ... "Global": { "Id": "aaf7a7c2-8716-4ae7-844a-cf9951748951", "Smtp": { "From": "person@formfakten.de", "Host": "localhost", "Port": 25, "SecureSocketOptions": "Auto", "DeliveryMethod": "Network", "PickupDirectoryLocation": "", "Username": "person@formfakten.de", "Password": "SuperSecretPassword" }, } } } }
GetValue akzeptiert nur primitive Typen wie string, int, bool. Will man komplexe Objekte abrufen, geht das wie folgt:
public class SmtpSettings { public string From { get; set; } public string Host { get; set; } public int Port { get; set; } public string SecureSocketOptions { get; set; } public string DeliveryMethod { get; set; } public string PickupDirectoryLocation { get; set; } public string Username { get; set; } public string Password { get; set; } } var smtpSettings = new SmtpSettings(); configuration.GetSection( "Umbraco:CMS:Global:Smtp" ).Bind( smtpSettings ); return smtpSettings; // enthält den gesamten Abschnitt
Wahlweise kann man die SmtpSettings auch via DependencyInjection einbinden. Mit
public void ConfigureServices(IServiceCollection services) { services.Configure<SmtpSettings>(Configuration.GetSection("Umbraco:CMS:Global:Smtp")); ... }
Nun kann jeder, der die SmtpSettings braucht, im Konstruktor einfach einen Parameter angeben:
public MyController(IOptions<SmtpSettings> smtpSettingOptions) { this.smtpSettings = smtpSettingOptions.Value; }
und schon sorgt die DependencyInjection für die Werte. Die werden einmal berechnet und bleiben immer gleich. Man bekommt quasi einen Singleton.
Das gleiche Konstrukt mit IOptionsSnapshot liefert immer eine neue Berechnung. Dann gibt es noch IOptionsMonitor. Das wird genauso eingesetzt und bekommt Benachrichtigungen bei Änderungen. Wenn ich das recht verstehe, ist das ein Singleton-Objekt, dessen Werte aber verändert werden, sobald in der appsettings.json die entsprechende Section verändert wurde. Mehr Info findet sich hier.
Vorsicht: TagHelper!
Eigentlich sind die TagHelper eine tolle Erfindung: Man kann kleine Klassen schreiben, die ein Tag repräsentieren und dieses Tag kann dann in Views verwendet werden. Das funktioniert ähnlich wie in Vue, nur serverseitig. Das Problem ist: Es gibt vordefinierte TagHelper in Asp.Net. Siehe ganz unten in diesem Artikel.
Wenn wir also schreiben:
string id = GetMyId(...); <body id="@id">...
dann bekommen wir einen Fehler von der Art: "C# Code not allowed in the attribute section of a tag helper". Wenn Du statt des TagHelpers das pure Element verwenden willst, setze ein Rufezeichen davor:
string id = GetMyId(...); <!body id="@id">...
Es scheint so, als ob das Rufezeichen unser Freund wird.
Fazit
Es gibt doch einige Dinge, die sich in Umbraco 9 geändert haben. Dazu kommt ein völlig neues Programmiermodell, weil wir keine Dll-Referenzen mehr nutzen können. Wir müssen statt dessen Nuget-Packages nutzen. Der Aufwand für die Umgewöhnung wird allerdings durch ein paar Vorteile entschädigt. Zunächst einmal geht der Restart einer Anwendung viel schneller, wie im alten Framework. Zum Anderen können wir unsere Applikationen auch unter Linux hosten. Wünschenswert wäre am Ende noch eine Unterstützung von MySql. Aber man kann ja nicht alles haben.