Laden...

T4Routes - Code-Generierung für Asp.net MVC Routen

Erstellt von Sarc vor 12 Jahren Letzter Beitrag vor 11 Jahren 4.869 Views
S
Sarc Themenstarter:in
417 Beiträge seit 2008
vor 12 Jahren
T4Routes - Code-Generierung für Asp.net MVC Routen

Das beigefügte T4-Script dient der Generierung von MVC Routen anhand von XML-basierten Routendefinitionen

Die Verwendung von Routen ist eine tolle Sache in Asp.net MVC. Allerdings hat mich gestört, dass die Namen der Routen als "Magic Strings" im Quelltext auftauchen, es sei denn man macht sich die Mühe und legt für jede Route eine Konstante an. Ausserdem ist bei der Verwendung von Areas, die Definition der Routen nicht mehr an einer Stelle zu verwalten, sondern verteilt auf die einzelnen AreaRegistrations.
Der Grundgedanke war, die Routen in einer XML-Datei zu definieren und darauf basierend die entsprechenden Routen-Definition als Quelltext zu generieren.

Hier ein Beispiel einer möglichen Routen-Definition im XML-Format:

<routes>
	
	<route name="Home" url="" controller="Home" action="Index"/>

	<route name="ConfirmRegistration" url="confirm-registration/{userid}/{confirmationcode}" controller="Account" action="ConfirmRegistration">
		<constraints userid="\\d+"/>
		<!-- or alternativ via explicit node definition
		<constraints>
			<userid>@"\d+"</userid>
		</constraints>
		-->
	</route>

	<route name="Default" url="{controller}/{action}/{id}" controller="home" action="Index" id=""/>

	<!-- Area definitions -->
	<area name="User">
		<route name="Home" url="User" controller="Home" action="Index"/>
		<route name="Account" url="account" controller="Account" action="Index">
			<constraints>
				<auth>new AuthenticatedUserRouteConstraint()</auth>
			</constraints>
		</route>
		
		<route name="Default" url="User/{controller}/{action}/{id}" controller="home" action="Index" id=""/>
	</area>
	
</routes>

Wie in der obigen XML zu erkennen ist, werden alle Routen, also auch Area-Routen im XML definiert. Samt default oder constraint werte.
Jeder route-Knoten besitzt immer ein "name"- und ein "url"-Attribut. Jedes weitere Attribut wird als Default Key-Value-Paar interpretiert, z. B. im obigen Beispiel "controller" oder "action".
Für Constraints kann ein "constraints"-Knoten angelegt werden. Entweder werden die Constraints über Attribute gesetzt oder als separate XML-Knoten (siehe "auth"-Knoten der Account-Route der User-Area).

Ok, also was bringt das ganze. Der daraus generierte Quelltext sieht wie folgt aus:


using System.Web.Mvc;
using System.Web.Mvc.Html;
using System.Web.Routing;
using T4RouteDemo.Routes;

namespace T4RouteDemo.Routes
{
	public static class RouteRegistration
	{
		/// <summary>
		/// Call this method on application start.
		/// </summar>
		public static void RegisterGeneratedRoutes(RouteCollection routeCollection)
		{
			routeCollection.MapRouteEx(RouteNames.Home, "", new { controller="Home", action="Index"}, null, new [] { "T4RouteDemo.Controllers" });
			routeCollection.MapRouteEx(RouteNames.ConfirmRegistration, "confirm-registration/{userid}/{confirmationcode}", new { controller="Account", action="ConfirmRegistration"}, new { userid="\\d+"}, new [] { "T4RouteDemo.Controllers" });
			routeCollection.MapRouteEx(RouteNames.Default, "{controller}/{action}/{id}", new { controller="home", action="Index", id=""}, null, new [] { "T4RouteDemo.Controllers" });
		}
	}
}

namespace T4RouteDemo.Areas.User
{
	partial class UserAreaRegistration
	{		
		public override void RegisterArea(AreaRegistrationContext context)
		{
			BeforeRegisterArea(context);
			context.MapRouteEx(RouteNames.UserArea.Home, "User", new { controller="Home", action="Index"}, null, new [] { "T4RouteDemo.Areas.User.Controllers" }, "User");
			context.MapRouteEx(RouteNames.UserArea.Account, "account", new { controller="Account", action="Index"}, new { auth=new AuthenticatedUserRouteConstraint()}, new [] { "T4RouteDemo.Areas.User.Controllers" }, "User");
			context.MapRouteEx(RouteNames.UserArea.Default, "User/{controller}/{action}/{id}", new { controller="home", action="Index", id=""}, null, new [] { "T4RouteDemo.Areas.User.Controllers" }, "User");
			AfterRegisterArea(context);
		}
		
		// Implement the following partial methods in the other partial class definition if required
		partial void BeforeRegisterArea(AreaRegistrationContext context);
		partial void AfterRegisterArea(AreaRegistrationContext context);
	}
}

namespace T4RouteDemo.Routes
{
	public static class RouteNames
	{	
		/// <summary>Home | ""</summary>
		public const string Home = "Home";
		
		/// <summary>ConfirmRegistration | "confirm-registration/{userid}/{confirmationcode}"</summary>
		public const string ConfirmRegistration = "ConfirmRegistration";
		
		/// <summary>Default | "{controller}/{action}/{id}"</summary>
		public const string Default = "Default";
			
		public static class UserArea
		{
			/// <summary>User_Home | "User"</summary>
			public const string Home = "User_Home";
			/// <summary>User_Account | "account"</summary>
			public const string Account = "User_Account";
			/// <summary>User_Default | "User/{controller}/{action}/{id}"</summary>
			public const string Default = "User_Default";
					
		}
	}
}

Zum einen wird eine RouteRegistration-Klasse erzeugt, deren RegisterGeneratedRoutes-Methode beim Application_Start aufgerufen werden sollte.
Ausserdem wurde eine partielle Klasse UserAreaRegistration erstellt. Bei einer bereits erzeugten Area mit dem Namen User ist die Klasse bereits vorhanden,
jedoch nicht partial. Damit das ganze funktioniert, muss man diese Klasse einfach auch partial machen. Die generierte Klasse kann dadurch die Methode RegisterArea
überschreiben.
Das ganze läuft entsprechende der MVC Philosophie: Convention over Configuration, d.h. es werden gewissen Annahmen getroffen, z.b. das die Klasse UserAreaRegistration heisst und sich im namespace T4RouteDemo.Areas.User befinden, wobei T4RouteDemo hier als Root-Namespace verwendet wurde.

Zuletzt wird natürlich noch eine Klasse RouteNames generiert, welche die Route-Namen enthält. Das ganze inkl. XML-Kommentar, sodass man bei der Verwendung, z. B. über RouteNames.ConfirmRegistration gleich die dahinterliegende URL in der Intellisense erkennt.

Ein weiterer Zusatznutzen ist, dass zu jeder Route auch gleich die entsprechende Area dazugeneriert wird, d.h. man hat keine Probleme wenn es mehrere Controller gibt, die gleich heissen, z.b. "HomeController", jedoch in unterschiedlichen Areas liegen.

Der findige Beobachter hat vielleicht bemerkt, dass die Aufrufe der Routenregistrierung über eine Extensionmethod mit dem Namen "MapRouteEx" erfolgen.
Der Code der Extensionmethods ist wie folgt:

public static class RouteExtensions
	{
		public static void MapRouteEx(this RouteCollection routes, string name, string url, object defaults, object constraints, string[] namespaces, string areaName = null)
		{
			var route = new Route(url, new MvcRouteHandler())
			{
				Defaults = new RouteValueDictionary(defaults),
				Constraints = new RouteValueDictionary(constraints),
				DataTokens = new RouteValueDictionary()
			};

			if (namespaces != null && namespaces.Length > 0)
				route.DataTokens.Add("Namespaces", namespaces);

			if (areaName != null)
				route.DataTokens.Add("area", areaName);

			routes.Add(name, route);
		}

		public static void MapRouteEx(this AreaRegistrationContext areaContext, string name, string url, object defaults, object constraints, string[] namespaces, string areaName)
		{
			MapRouteEx(areaContext.Routes, name, url, defaults, constraints, namespaces, areaName);
		}
	}

Die Namen der Extensionmethods, sowie andere Einstellungen sind im T4-Script konfigurierbar:

/* Config */
var namespaceName = "T4RouteDemo"; // root namespace, which contains the "Controller"-subfolder
var routesNamespaceName = namespaceName + ".Routes"; // the namespace for the generated classes
var routeNamesClassName = "RouteNames";
var routeRegistrationClassName = "RouteRegistration";
var customNamespacesToInclude = new string[] { /* put your namespaces here */ };
var mapRouteExtensionMethodName = "MapRouteEx"; // name of the extension method, that is called from the generated classes
var areaFormatPattern = "{0}_{1}"; // where {0} is the area and {1} is the name of the route. Example: User_Home
/* Config end */

Das ganze lässt sich noch perfektionieren, wenn man der XML-Datei das Custom-Tool "T4ScriptFileGenerator" zuweist. Dadurch wird die Generierung nach jeder Speicherung der XML-Datei ausgeführt. Ein Beispiel hierzu habe ich bereits hier aufgeführt: T4-Skript zur Code-Generierung für übersetzbare Texte

Sorry für den langen Text. Ein lauffähiges Projekt sagt eh mehr als tausend Worte, also am Besten das beigefügte MVC-Projekt herunterladen und ausprobieren.
Ich hoffe es gefällt.

Schlagwörter: T4, Code generierung, asp.net, mvc, routen, routes

16.806 Beiträge seit 2008
vor 11 Jahren

Ich hab noch AreaName in die Route-Generation hinzugefügt, sodass identische Controllernamen und -Actions keine MultipleReference-Exception (InvalidOperationException) mehr werfen.

Die geänderten Zeilen sind 77 für den zusätzlichen Parameter und 124 bis 133 für die Bearbeitung dessen.


<# /* Script created by: Daniel Kailer
Free for commercial use.
\*/#&gt;
<#@ template debug="true" hostSpecific="true" #>
<#@ output extension=".cs" #>
<#@ Assembly Name="System.Core.dll" #>
<#@ Assembly Name="System.Xml" #>
<#@ Assembly Name="System.Xml.Linq" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Xml.Linq" #>
<#@ import namespace="System.Collections" #>
<#@ import namespace="System.Collections.Generic" #><#
	var xmlFilename = Path.Combine(Path.GetDirectoryName(this.Host.TemplateFile), Path.GetFileNameWithoutExtension(this.Host.TemplateFile) + ".xml");
		
	if (!File.Exists(xmlFilename))
		this.Error("File " + xmlFilename + " not found.");
	
	var doc = XDocument.Load(xmlFilename);
	var routes = doc.Root.Descendants("route");
	var areas = doc.Root.Descendants("area");
	
	/* Config */
	var namespaceName = "AbtBenjamin.MVC"; // root namespace, which contains the "Controller"-subfolder
	var routesNamespaceName = namespaceName + ".Routing"; // the namespace for the generated classes
	var routeNamesClassName = "RouteNames";
	var routeRegistrationClassName = "RouteRegistration";
	var customNamespacesToInclude = new string[] { /* put your namespaces here */ };
	var mapRouteExtensionMethodName = "MapRouteEx"; // name of the extension method, that is called from the generated classes
	var areaFormatPattern = "{0}_{1}"; // where {0} is the area and {1} is the name of the route. Example: User_Home
	/* Config end */
#>/*
	Generated file.
	Generated at: <#=DateTime.Now.ToString()#>
\*/
using System.Web.Mvc;
using System.Web.Mvc.Html;
using System.Web.Routing;
using <#=routesNamespaceName#>;
<#=string.Join(Environment.NewLine, customNamespacesToInclude.Select(f => string.Format("using {0};", f) ).ToArray())#>
namespace <#=routesNamespaceName#>
{
	public static class <#=routeRegistrationClassName#>
	{
		/// <summary>
		/// Call this method on application start.
		/// </summar>
		public static void RegisterGeneratedRoutes(RouteCollection routeCollection)
		{
<#
	foreach(var r in routes.Where(f => f.Parent.Name.LocalName.Equals("routes") ))
	{
		var routeData = GetRouteData(r);
		#>			routeCollection.<#=mapRouteExtensionMethodName#>(<#=routeNamesClassName#>.<#=routeData.Name#>, "<#=routeData.Url#>", <#=FormatKeyValues(routeData.DefaultValues)#>, <#=FormatKeyValues(routeData.Constraints)#>, new [] { "<#=namespaceName#>.Controllers" });
<#  }#>
		}
	}
}

<#
	foreach(var area in areas)
	{
		var areaName = area.Attribute("name").Value;
		#>
namespace <#=namespaceName#>.Areas.<#=areaName#>
{
	partial class <#=areaName#>AreaRegistration
	{		
		public override void RegisterArea(AreaRegistrationContext context)
		{
			BeforeRegisterArea(context);
<#
	foreach(var r in area.Descendants("route"))
	{
		var routeData = GetRouteData(r, areaName);
		#>			context.<#=mapRouteExtensionMethodName#>(<#=routeNamesClassName#>.<#=areaName#>Area.<#=routeData.Name#>, "<#=routeData.Url#>", <#=FormatKeyValues(routeData.DefaultValues)#>, <#=FormatKeyValues(routeData.Constraints)#>, new [] { "<#=namespaceName#>.Areas.<#=areaName#>.Controllers" }, "<#=areaName#>");
<#	}#>
			AfterRegisterArea(context);
		}
		
		// Implement the following partial methods in the other partial class definition if required
		partial void BeforeRegisterArea(AreaRegistrationContext context);
		partial void AfterRegisterArea(AreaRegistrationContext context);
	}
}
<# } #>

namespace <#=routesNamespaceName#>
{
	public static class <#=routeNamesClassName#>
	{<#foreach(var r in routes.Where(f => f.Parent.Name.LocalName.Equals("routes") ))
		{
			var routeName = r.Attribute("name").Value;
#>	
		/// <summary><#=routeName#> | "<#=r.Attribute("url").Value#>"</summary>
		public const string <#=routeName#> = "<#=routeName#>";
	<#	}
		#>
		
		<#
	foreach(var area in areas)
	{
		var areaName = area.Attribute("name").Value;
#>
public static class <#=areaName#>Area
		{
		<#
	foreach(var r in area.Descendants("route"))
	{
		var routeName = r.Attribute("name").Value;
		var fullRouteName = string.Format(areaFormatPattern, areaName, routeName);
		#>
	/// <summary><#=fullRouteName#> | "<#=r.Attribute("url").Value#>"</summary>
			public const string <#=routeName#> = "<#=fullRouteName#>";
		<#}#>
			
		}
	<#}#>
}
}
<#+
	RouteData GetRouteData(XElement routeElement, string areaName = null)
	{
		var route = new RouteData();
		route.Name = routeElement.Attribute("name").Value;
		route.Url = routeElement.Attribute("url").Value;

		if(!string.IsNullOrEmpty(areaName))
		{
			route.DefaultValues.Add("AreaName", "\""+areaName+"\"");
		}
		
		foreach(var kvp in routeElement.Attributes().Where(f=> f.Name.LocalName != "name" && f.Name.LocalName != "url" )
										 .Select(f => new KeyValuePair<string, string>( f.Name.LocalName, "\"" + f.Value + "\"")  ))
		{
			route.DefaultValues.Add(kvp.Key, kvp.Value);
		}
		
		var defaultRoute = routeElement.Element("default");
		if( defaultRoute != null )
		{
			foreach(var kvp in defaultRoute.Attributes().Select(f => new KeyValuePair<string, string>(f.Name.LocalName, "\"" + f.Value + "\"") ) )
				route.DefaultValues.Add(kvp.Key, kvp.Value);
				
			foreach(var kvp in defaultRoute.Elements().Select( f => new KeyValuePair<string, string>(f.Name.LocalName, f.Value )))
				route.DefaultValues.Add(kvp.Key, kvp.Value);
		}
		
		var constraints = routeElement.Element("constraints");
		if( constraints != null )
		{
			foreach(var c in constraints.Attributes().Select(f => new KeyValuePair<string, string>(f.Name.LocalName, "\"" + f.Value + "\"") ))
				route.Constraints.Add(c.Key, c.Value);
			
			foreach(var c in constraints.Elements().Select( f => new KeyValuePair<string, string>(f.Name.LocalName, f.Value )))
				route.Constraints.Add(c.Key, c.Value);
		}
		
		return route;
	}
	
	string FormatKeyValues(Dictionary<string,string> dict)
	{
		return dict.Count == 0 ? "null"
			: "new { " + string.Join(", ", dict.Select(f => string.Format("{0}={1}", f.Key, f.Value) ).ToArray()) + "}";
	}
	
	class RouteData
	{
		public string Name;
		public string Url;
		public Dictionary<string, string> DefaultValues = new Dictionary<string, string>();
		public Dictionary<string, string> Constraints = new Dictionary<string, string>();
	}
#>