View-Components in Umbraco

Freitag, 10.04.2020

Mirko Matytschak

ViewComponents sind eine elegante Ergänzung zu Partial Views in Asp.Net MVC Core. Partial Views sind modulare Views, die unter Nennung ihres Namens aufgerufen werden können. ViewComponents setzen eine schmale Logik-Schicht zwischen den aufrufenden Kontext und dem Partial View. In der ViewComponent kann entschieden werden, welcher View dargestellt werden soll.

Bei der FORMFAKTEN GmbH bauen wir unsere neuen Sites nicht von Grund auf neu auf, sondern wir benutzen eine Plattform, die sich LAYKIT nennt. Diese Plattform basiert auf dem CMS Umbraco. Umbraco seinerseits basiert auf Microsofts Asp.NET und dies auf dem klassischen .NET Framework. Wir fänden es nun elegant, die ViewComponents, die es nur für Asp.NET Core gibt,  auch für unsere Umbraco-Sites zur Verfügung zu haben.

Warum sollten wir das tun?

Unser LAYKIT bringt bereits eine Menge visueller Elemente mit, wie Navigationen, Slideshows, Galerien etc. Für diese Elemente gibt es vorgefertigte Implementierungen als Partial Views, die wir direkt wiederverwenden können. Nun kommt es sehr häufig vor, dass wir solch ein visuelles Element für einen Kunden anpassen müssen. Dazu ändern wir den Source Code des Partial Views.

Auf der anderen Seite kommt es vor, dass die gleichen Elemente im LAYKIT weiterentwickelt werden, eventuelle Fehler beseitigt werden, etc. Das führt dazu, dass es zwei Versionen derselben Datei aus verschiedenen Quellen gibt, die sich immer weiter auseinander entwickeln.

Nun möchten wir ab und an die bestehenden Sites auf den neuesten Stand des LAYKITs bringen. Dabei stören jetzt die veränderten Dateien für die Partial Views. Bei jedem Update müssen die Änderungen, die aus zwei Quellen stammen, sinnvoll zu einem Ergebnis gemischt (gemergt) werden, das im Kontext der Site des Kunden funktioniert. Und das ist aufwändig. Natürlich kann man eine Kopie der vorhandenen Implementierung unter einem neuen Dateinamen anpassen. Aber dann müssen wir den aufrufenden View verändern und handeln uns damit das gleiche Problem ein.

Das Problem hat sich zwar auf die eine Zeile reduziert, die den Partial View aufruft, aber das bedeutet nicht, dass damit die Merge-Konflikte ein Ende haben. Wenn man einige Kunden-Sites zu verwalten hat, kann das zusammengenommen einigen Aufwand bereiten.

ViewComponents und Headless Umbraco

Wir haben aber noch eine zweite Motivation für die Einführung von ViewComponents. Wir nutzen nämlich eine Headless-Implementierung von Umbraco. Hier existiert eine Umbraco-Version ausschließlich für die Erfassung von Contents. Ausgeliefert werden die konkreten Seiten jedoch mit einer ASP.Net Core-Implementierung, die auf einem Linux-Server liegen kann.

Wir wollen nun die verwendeten Views in beiden Varianten unseres LAYKITs, also der Headless- und der normalen Variante möglichst ähnlich halten. In Headless haben wir ViewComponents zur Strukturierung unserer Views zur Verfügung. Im klassischen Umbraco haben wir das nicht. Wenn wir sie also selbst implementieren können, dann können wir von der Ähnlichkeit der Views profitieren.

So funktioniert es in ASP.NET Core

In der MVC-Implementierung von ASP.NET Core hat man das Problem auf eine sehr elegante Weise gelöst. Statt zu schreiben:

@Html.Partial("Header")

schreibt man nun:

@await Component.InvokeAsync("Header")

Dieser Aufruf sucht nun nach einer Komponente mit dem Namen „HeaderViewComponent“. Das ist ein winzig kleines Stück Code, das den View ermittelt, der dargestellt werden soll. Diese Komponenten kann man von einer gemeinsamen Basisklasse ableiten, die dann mit einer zentralen Logik den View auswählt.

Es ist so gedacht, dass man dann für jeden Partial View eine solche Komponente schreibt. Aber selbst das kann man sich sparen. Mit einer eigenen Implementierung von IViewComponentSelector, die man beim Systemstart als Service registriert, kann man dafür sorgen, dass alle Views mit derselben Komponente ermittelt werden.

Wenn man einmal so weit ist, dann kann man diese Komponente eine View-Implementierung suchen lassen, die im Views-Verzeichnis in einem Ordner „Custom“ liegt. Ist diese nicht vorhanden, wird die Standard-Implementierung verwendet. Damit lässt sich jederzeit ein View durch eine spezialisierte Implementierung ersetzen, wenn diese denn im Verzeichnis Custom zu finden ist.

ViewComponents für Umbraco

Umbraco basiert auf dem klassischen ASP.NET, dort gibt es keine ViewComponents. Davon abgesehen, dass das Schlüsselwort await in Views nicht funktioniert. Aber das wäre für uns das geringere Problem. Was wir wollen, ist eine ähnliche Logik, in der wir einen View für eine Komponente auswählen können, wenn wir einen Aufruf wie folgt machen:

@Component.Invoke("Header")

Wir brauchen also für unsere Web Pages ein Property Component, das die Magie dieses Aufrufs irgendwie leisten kann. Dazu leiten wir eine Klasse von der Standard-View-Klasse von Umbraco ab, die UmbracoViewPage heißt. So sieht die Implementierung aus:

public class ComponentViewPage<T> : UmbracoViewPage<T>
{
    IViewComponentHelper component;
    public IViewComponentHelper Component 
    {
        get
        {
            if (component == null)
                component = new ViewComponentHelper( Html );
            return component;
        }
    }

    public override void Execute()
    {
        // Hier  ist nichts zu tun
    }
}

public class ComponentViewPage : ComponentViewPage<IPublishedContent>
{
}

Wie Sie sehen, werden hier zwei Versionen der Klasse definiert, eine generische und eine, die mit dem Model-Typ IPublishedContent arbeitet. Was wir tun müssen, um Components in unseren Views nutzen zu können, ist eine Änderung der inherits-Anweisung:

@inherits ComponentViewPage

Ist das Model von einem anderen Typ als IPublishedContent, wird der Typ als generischer Parameter angegeben:

@inherits ComponentViewPage<MyType>

Das Rendern von Views ist eine hochkomplizierte Sache. Wesentlich einfacher ist es, wenn man den HtmlHelper eines Views parat hat. Dieser wird von der Basisklasse UmbracoViewPage in den ViewComponentHelper übernommen. Das kann nicht im Konstruktor von ComponentViewPage geschehen, da der HtmlHelper zu diesem Zeitpunkt noch undefiniert ist. Aber zum Zeitpunkt des Renderns ist der HtmlHelper initialisiert und kann von daher an den ViewComponentHelper übergeben werden. Deshalb legen wir den ViewComponent Helper erst bei der ersten Nutzung an (Zeile 8).

Nun wird es interessant, zu erfahren, wie der ViewComponentHelper funktioniert. Hier ist der Source Code:

public class ViewComponentHelper : IViewComponentHelper
{
    static object lockObject = new object();
    static IEnumerable<Type> viewComponentTypes;
    static ConcurrentDictionary<Type, ConstructorInfo> typeActivatorCache 
        = new ConcurrentDictionary<Type, ConstructorInfo>();
    private readonly HtmlHelper htmlHelper;

    static ViewComponentHelper()
    {
        lock(lockObject)
        {
            if (viewComponentTypes == null)
            {
                viewComponentTypes = TypeFinder.FindClassesOfType<IViewComponent>();
            }
        }
    }

    public ViewComponentHelper(HtmlHelper htmlHelper)
    {
        this.htmlHelper = htmlHelper;
    }

    public IHtmlString Invoke( string name, object arguments = null )
    {
        string typeName;
        if (name.EndsWith( "ViewComponent" ))
            typeName = name;
        else
            typeName = name + "ViewComponent";
        var type = viewComponentTypes.FirstOrDefault(t=>t.Name == typeName);

        // Default component type
        if (type == null)
            type = typeof( ViewComponent );

        return InternalInvoke( type, name, arguments );
    }

    public IHtmlString Invoke( Type componentType, object arguments = null )
    {
        var name = componentType.Name;
        var p = name.IndexOf( '`' );
        if (p > -1)
            name = name.Substring( 0, p );
        return InternalInvoke( componentType, name, arguments );
    }

    IHtmlString InternalInvoke( Type componentType, string name, object arguments = null )
    {
        if (arguments == null)
        {
            arguments = ((WebViewPage) htmlHelper.ViewDataContainer).Model;
        }

        var constructor = typeActivatorCache.GetOrAdd(componentType, (t) =>
        {
            var constructors = t.GetConstructors().ToList();
            constructors.Sort( ( c1, c2 ) => c1.GetParameters().Length - c2.GetParameters().Length );

            var factory = Current.Factory;

            foreach (var constr in constructors)
            {
                bool canResolve = true;

                foreach (var p in constr.GetParameters())
                {
                    if (factory.TryGetInstance(p.ParameterType) == null)
                    {
                        canResolve = false;
                        break;
                    }
                }

                if (canResolve)
                {
                    return constr;
                }
            }
            return null;
        } );

        if (constructor == null)
            throw new Exception( $"Can't find constructor for type {componentType.FullName}" );

        List<object> parameters = new List<object>();
        foreach (var p in constructor.GetParameters())
        {
            parameters.Add( Current.Factory.GetInstance( p.ParameterType ) );
        }

        var component = (ViewComponent) constructor.Invoke( parameters.ToArray() );
        component.HtmlHelper = this.htmlHelper;
        component.Name = name;

        return component.Invoke( arguments );
    }

    public IHtmlString Invoke<T>( object arguments )
    {
        return Invoke( typeof( T ), arguments );
    }
}

Der ViewComponentHelper hat die Aufgabe, eine ViewComponent zu finden, die dem angegebenen Parameter entspricht, und eine Instanz davon anzulegen. Ist der Parameter ein Type-Objekt, ist die Sache einfach. Aber wenn der Parameter ein String ist, muss eine Komponente gefunden werden, die diesem String entspricht. Wir gehen hier den einfachsten Weg und sagen, dass ein Typ existieren muss, dessen Name dem Muster

name + "ViewComponent" 

enstprechen muss. Dazu müssen wir erst einmal wissen, welche Komponenten-Typen es überhaupt gibt. Dafür stellt Umbraco eine Klasse TypeFinder zur Verfügung, die das für uns übernimmt:

TypeFinder.FindClassesOfType<IViewComponent>();

Wir suchen im statischen Konstruktor (Zeile 9) alle ViewComponent-Typen und heben sie in einer statischen Liste auf. So können wir bei jeder Nutzung des ViewComponentHelpers die Komponente schnell finden.

Hier bleibt anzumerken, dass ASP.NET MVC Core hier eine weitere Abstraktion bemüht, die auch in unserer Implementierung sinnvoll wäre. Dort gibt es ein Interface IViewComponentSelector, das die Aufgabe übernimmt, eine ViewComponent zu suchen, wenn nur ein String vorgegeben wird. Das können wir mit einer entsprechenden Implementierung nutzen, um alle Views mit einer einzigen zentralen ViewComponent rendern zu lassen. Diese Abstraktion haben wir uns hier gespart und die Implementierung direkt in der Methode Invoke vorgenommen.

Dependency Injection

Die eigentliche Arbeit des Renderns geschieht in der Variante von Invoke, die ein Type-Objekt als ersten Parameter nimmt (Zeile 41). Hier wissen wir also schon den Datentyp der Komponente und müssen sie nur noch anlegen. Hier tut sich eine kleine Problematik auf. Wenn wir mit DependencyInjection arbeiten wollen, sodass die injizierten Services in unseren Views zur Verfügung stehen, müssen wir die zur Verfügung stehenden Konstruktoren untersuchen und einen geeigneten für unseren Aufruf aussuchen.

Wenn es einen Default-Konstruktor ohne Parameter gibt, verwenden wir diesen. Wir ermitteln also alle Konstruktoren des Typs, sortieren sie nach Parameterlänge und fangen bei dem Konstruktor mit der kürzesten Parameterliste an.

Gibt es keinen Default-Konstruktor, müssen wir einen Konstruktor finden, für den wir alle Parameter auflösen können. Diese Arbeit erleichtern wir uns, indem wir die Factory von Umbraco nutzen, um Objekte der angegebenen Parametertypen zu erhalten.

Dieser Mechanismus ließe sich in einem TypeActivator-Interface abstrahieren und dann wäre die Implementierung von Umbraco unabhängig. Für unser LAYKIT können wir uns aber direkt an Umbraco binden. Wir durchlaufen also alle Parametertypen und versuchen, sie aufzulösen. Wenn das für einen gegebenen Konstruktor funktioniert, wird dieser auch verwendet. Wir cachen den gefundenen Konstruktor im Dictionary typeActivatorCache, sodass bei späteren Aufrufen immer sofort der Konstruktor verwendet wird.

Nun müssen wir nur noch den Konstruktor aufrufen und die mit der Factory aufgelösten Objekte als Parameter übergeben. Dann haben wir eine ViewComponent, die wir zum Rendern verwenden können und haben den kompliziertesten Teil der Implementierung hinter uns. Zum Rendern rufen wir component.Invoke() auf. Vorher aber übergeben wir noch den HtmlHelper an die Komponente, die dann den Helper verwenden kann, um den View im richtigen Kontext zu rendern.

Die View-Komponente ist dann ziemlich simpel:

public interface IViewComponent
{
    IHtmlString Invoke( object model );
}

public class ViewComponent : IViewComponent
{
    internal HtmlHelper HtmlHelper { get; set; }
    internal string Name { get; set; }

    public virtual IHtmlString Invoke(object model)
    {
        var name = Name;
        if (name == null)
            throw new NullReferenceException( $"{GetType().Name}.Invoke: name of the component is null" );

        var relPaths = new String[]
        {
            $"/Views/Custom/Components/{name}.cshtml",
            $"/Views/Custom/Components/{name}/Default.cshtml",
            $"/Views/Shared/Components/{name}.cshtml",
            $"/Views/Shared/Components/{name}/Default.cshtml",
            $"/Views/{name}.cshtml",
            $"/Views/Partials/{name}.cshtml"
        };

        string foundPath = null;
        foreach(var relPath in relPaths)
        { 
            var path = IOHelper.MapPath(relPath);
            if (File.Exists( path ))
            {
                foundPath = relPath;
                break;
            }
        }

        if (foundPath == null)
            throw new Exception( $"Can't find View '{name}'.\nTried to find it in {String.Join( "\n", relPaths )}" );

        return HtmlHelper.Partial( foundPath, model );
    }
}

Wir suchen die View-Implementierung an verschiedenen Plätzen und können sie mit htmlHelper.Partial rendern. Wenn nun alle ViewComponents von der Klasse ViewComponent abgeleitet werden, können wir davon ausgehen, dass Views immer auf die gleiche Weise gefunden werden. Die gezeigte Implementierung spiegelt mehr oder minder die Logik, nach der Views in ASP.NET MVC Core gefunden werden. Wir können die Logik jetzt jedoch an unsere Bedürfnisse anpassen und vor dem Ordner „Shared“ erst einmal den Ordner „Custom“ durchsuchen. Damit erhalten Views, die in diesem Ordner liegen, den Vorzug vor Views, die im Ordner „Shared“ liegen. Damit haben wir unser Ziel erreicht.

Einen Kommentar verfassen

Anleitung zur Textformatierung

Zum Formatieren des Textes verwenden Sie [b][/b] und [i][/i]. Verwenden Sie [url=http://ihre-site]Text[/url] für Links.

* Pflichtfelder