@Html.Action war (ist) wohl eine der am häufigsten genutzten Funktionalitäten in ASP.NET MVC. Leider wurde diese Funktionalität in .NET Core ersatzlos gestrichen. Ich schreibe "ersatzlos", weil die Verwendung von ViewComponents eben kein wirklicher Ersatz ist. Sie verhalten sich anders, sie haben keine URL mit der ich sie aufrufen könnte (wie ich das mit den PartialViews, die @Html.Action() zurückgeliefert hat, problemlos tun kann, beispielsweise auch aus Javascript heraus) und die Verwendung ist für die meisten Anwendungsfälle viel zu kompliziert.
Ja, es ist eine schöne Sache, dass diese Komponenten jetzt Bestandteil von ASP.NET Core sind, und sie haben sicherlich ihre Daseinsberechtigung, wenn man mit RazorPages arbeitet ... aber dafür eine andere Funktionalität komplett wegwerfen? Ich frage mich, wer bei Microsoft diese Entscheidung getroffen hat. Das kann kein Softwareentwickler gewesen sein.
Persönlich ist mir die Verwendung der ViewComponents viel zu umständlich, außerdem wird mein Code zerfleddert. Also sucht man nach Alternativen. Eine Möglichkeit ist natürlich, Javascript zu verwenden. Dazu einfach ein div als container in die Seite legen, eine ID darauf und schon kann man beim Laden der Seite via Javascript die benötigten PartialViews nachladen.
Schön ist das aber nicht.
Noch dazu hat Microsoft die wirklich hervorragenden TagHelper eingeführt - und die sind wirklich genial, weil jetzt plötzlich all dieses @Html.TextBoxFor()-Zeug aus den Forms rausfliegen kann und durch etwas ersetzt wird, was wirklich wie ein HTML-Tag aussieht. Leider gibt es aber keinen TagHelper, der @Html.Action() nachbilden würde - also habe ich versucht, einen zu schreiben.
Recherchen im Internet haben einige Lösungswege ergeben, aber keinen zufriedenstellenden. Beispielsweise konnten die dort vorgestellten Lösungen nicht verschachtelt werden (also ... ein TagHelper innerhalb der PartialView, die durch einen TagHelper zurückgeliefert wird). Ich bin daher einen anderen Weg gegangen und dachte mir, warum nicht innerhalb des TagHelpers einfach nochmal einen Request auf die Seite machen, eben mit der URL der Partial View. Immerhin ist ein Request via Javascript auch nichts anderes (auch wenn er bei Ajax gewissermaßen im Hintergrund ausgeführt wird).
Entstanden daraus ist der folgende TagHelper.
Ich wollte natürlich, dass der TagHelper sich wie jeder andere TagHelper verhält. TagHelper bieten unter anderem die Möglichkeit, alle Arten von Daten zu übergeben. Das wollte ich natürlich auch haben. Um das Ganze sauber zu halten, habe ich innerhalb meiner Klasse einige Konstanten definiert, die für die verwendbaren Parameter des TagHelpers stehen:
public class ActionTagHelper : TagHelper
{
private const string _ATTRIBUTENAME_ACTION = "asp-action";
private const string _ATTRIBUTENAME_CONTROLLER = "asp-controller";
private const string _ATTRIBUTENAME_AREA = "asp-area";
private const string _ROUTEVALUESDICTIONARY_NAME = "asp-all-route-data";
private const string _ROUTEVALUESDICTIONARY_PREFIX = "asp-route-";
[... ommitted for brevity ...]
}
Die hier dargstellten Konstanten werden dann in der Folge für die Eigenschaften benutzt, die für die Parameter stehen, die man im TagHelper angeben kann. Außerdem benötigen wir noch zwei Felder (Fields). Eines steht für die verwendeten Routenwerte, beispielsweise asp-route-id für die id. Um diese Werte kümmert sich ASP.NET selbstständig, d.h. wenn die entsprechende Eigenschaft, die die Werte aufnehmen soll, korrekt konfiguriert ist, können wir diese einfach über das prefix asp-route- gefolgt vom benutzten Variablennamen angeben.
Das zweite Feld wird später benötigt und ist vom Typ IUrlHelperFactory. Wir brauchen es um die korrekte URL zusammenzustellen, die aufgerufen werden soll.
private IDictionary _routeValues = null;
private readonly IUrlHelperFactory _urlHelperFactory;
Die Eigenschaften müssen mit dem Attribut HtmlAttributeNameAttribute versehen werden. Die erste Eigenschaft, die gleich gezeigt wird, ist etwas speziell. Sie ist dekoriert mit dem ViewContext-Attribut. In dieser Eigenschaft wird der aktuelle ViewContext gespeichert, was (durch das Attribut) automatisch geschieht. Hier nun die Liste der Eigenschaften für den TagHelper.
[ViewContext]
public ViewContext ViewContext { get; set; }
/// <summary>
/// The name of the action method.
/// </summary>
[HtmlAttributeName( _ATTRIBUTENAME_ACTION )]
public string Action { get; set; }
/// <summary>
/// The name of the controller.
/// </summary>
[HtmlAttributeName( _ATTRIBUTENAME_CONTROLLER )]
public string Controller { get; set; }
/// <summary>
/// The name of the area.
/// </summary>
[HtmlAttributeName( _ATTRIBUTENAME_AREA )]
public string Area { get; set; }
/// <summary>
/// Additional parameters for the route.
/// </summary>
[HtmlAttributeName( _ROUTEVALUESDICTIONARY_NAME, DictionaryAttributePrefix = _ROUTEVALUESDICTIONARY_PREFIX )]
public IDictionary<string, string> RouteValues
{
get { return _routeValues ??= new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase ); }
set { _routeValues = value; }
}
Dazu kommt noch ein Constructor, denn wir benötigen eine IUrlHelperFactory um später einen UrlHelper zu erzeugen. Sie wird über Dependency Injection bereitgestellt.
public ActionTagHelper( IUrlHelperFactory urlHelperFactory )
{
this._urlHelperFactory = urlHelperFactory;
}
Die eigentliche Arbeit wird in der Methode ProcessAsync() geleistet. Dort werden die Werte aus dem TagHelper gesammelt, danach die URL zusammengestellt und aufgerufen. Allerdings müssen wir dabei noch auf eine Sache achten, nämlich die Authentifizierung. Würden wir den Aufruf einfach so machen, dann wären die Authentifizierungsinformationen nicht in dem Aufruf enthalten (wir liefern den Authentifizierungscookie ja nicht mit, weil unser Aufruf nicht vom Browser kommt sondern aus unserem Code - deshalb passiert das nicht automatisch).
Es gibt allerdings eine Möglichkeit, wie wir die Cookies, die im aktuellen Aufruf enthalten sind, an unseren Aufruf weitergeben können. Dazu benötigen wir auch den oben angesprochenen ViewContext, denn dieser hat alle benötigten Informationen. Wir holen uns also alle Cookies aus dem bestehenden ViewContext, packen diese in einen CookieContainer und fügen den unserem Aufruf hinzu. Das machen wir, indem wir einen HttpClientHandler erzeugen, der den CookieContainer aufnehmen kann und diesen dem HttpClient, mit dem wir dann die PartialView holen, übergeben.
Der ViewContext beinhaltet noch einige Informationen mehr, genauer gesagt den gesamten aktuellen Request. Ich nutze diesen hier nur um eine absolute URL zusammenzustellen, die ich dann im HttpClient verwende. Aber wenn einem da noch weitere Dinge einfallen, die man machen kann, der Zugriff darauf ist da. Hier ist der Code für all das, die gesamte Methode ProcessAsync():
public override async Task ProcessAsync( TagHelperContext context, TagHelperOutput output )
{
var routeValues = RouteValues.ToDictionary(
kvp => kvp.Key,
kvp => (object)kvp.Value,
StringComparer.OrdinalIgnoreCase );
if ( !String.IsNullOrEmpty( Area ) )
{
routeValues.Add( "Area", Area );
}
IUrlHelper helper = _urlHelperFactory.GetUrlHelper( ViewContext );
string finalUrl;
if ( String.IsNullOrEmpty( Controller ) )
{
finalUrl = helper.Action( Action, routeValues );
}
else
{
finalUrl = helper.Action( Action, Controller, routeValues );
}
Uri baseAddress = new Uri( $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}" );
var cookieContainer = new CookieContainer();
foreach ( var cookie in ViewContext.HttpContext.Request.Cookies )
{
cookieContainer.Add( baseAddress, new Cookie( cookie.Key, cookie.Value ) );
}
using ( HttpClientHandler handler = new HttpClientHandler() { CookieContainer = cookieContainer } )
{
using ( HttpClient client = new HttpClient( handler ) )
{
client.BaseAddress = baseAddress;
var result = await client.GetAsync( finalUrl );
output.Content.SetHtmlContent( await result.Content.ReadAsStringAsync() );
}
}
}
Damit wäre der TagHelper auch schon fertig. Oben ist ein Link zum SourceCode, dort ist auch ein kleines Beispiel dabei. Selbstverständlich funktioniert der TagHelper sowohl mit "normalen" Controller-Actions als auch mit async-Methoden. Hier nochmal die gesamte Klasse im Überblick (und mit Regions, die ich immer wieder gerne verwende um meine Klassen zu strukturieren).
public class ActionTagHelper : TagHelper
{
#region constants
private const string _ATTRIBUTENAME_ACTION = "asp-action";
private const string _ATTRIBUTENAME_CONTROLLER = "asp-controller";
private const string _ATTRIBUTENAME_AREA = "asp-area";
private const string _ROUTEVALUESDICTIONARY_NAME = "asp-all-route-data";
private const string _ROUTEVALUESDICTIONARY_PREFIX = "asp-route-";
#endregion
#region fields
private IDictionary<string, string> _routeValues = null;
private readonly IUrlHelperFactory _urlHelperFactory;
#endregion
#region properties
[ViewContext]
public ViewContext ViewContext { get; set; }
/// <summary>
/// The name of the action method.
/// </summary>
[HtmlAttributeName( _ATTRIBUTENAME_ACTION )]
public string Action { get; set; }
/// <summary>
/// The name of the controller.
/// </summary>
[HtmlAttributeName( _ATTRIBUTENAME_CONTROLLER )]
public string Controller { get; set; }
/// <summary>
/// The name of the area.
/// </summary>
[HtmlAttributeName( _ATTRIBUTENAME_AREA )]
public string Area { get; set; }
/// <summary>
/// Additional parameters for the route.
/// </summary>
[HtmlAttributeName( _ROUTEVALUESDICTIONARY_NAME, DictionaryAttributePrefix = _ROUTEVALUESDICTIONARY_PREFIX )]
public IDictionary<string, string> RouteValues
{
get { return _routeValues ??= new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase ); }
set { _routeValues = value; }
}
#endregion
#region Processing
public override async Task ProcessAsync( TagHelperContext context, TagHelperOutput output )
{
var routeValues = RouteValues.ToDictionary(
kvp => kvp.Key,
kvp => (object)kvp.Value,
StringComparer.OrdinalIgnoreCase );
if ( !String.IsNullOrEmpty( Area ) )
{
routeValues.Add( "Area", Area );
}
IUrlHelper helper = _urlHelperFactory.GetUrlHelper( ViewContext );
string finalUrl;
if ( String.IsNullOrEmpty( Controller ) )
{
finalUrl = helper.Action( Action, routeValues );
}
else
{
finalUrl = helper.Action( Action, Controller, routeValues );
}
Uri baseAddress = new Uri( $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}" );
var cookieContainer = new CookieContainer();
foreach ( var cookie in ViewContext.HttpContext.Request.Cookies )
{
cookieContainer.Add( baseAddress, new Cookie( cookie.Key, cookie.Value ) );
}
using ( HttpClientHandler handler = new HttpClientHandler() { CookieContainer = cookieContainer } )
{
using ( HttpClient client = new HttpClient( handler ) )
{
client.BaseAddress = baseAddress;
var result = await client.GetAsync( finalUrl );
output.Content.SetHtmlContent( await result.Content.ReadAsStringAsync() );
}
}
}
#endregion
#region Constructor
public ActionTagHelper( IUrlHelperFactory urlHelperFactory )
{
this._urlHelperFactory = urlHelperFactory;
}
#endregion
}