Update: The solution for URL Rewriting with ASP.NET 2.0 is released: http://www.urlrewriting.net/!

Update: Erweiterungen bzw. Verbesserungen dazu finden sich in den Kommentaren.

Es war ein harter Kampf, aber ich scheine ihn gewonnen zu haben. Ich habe mich in den letzten Monaten immer wieder mal mit URL Rewriting unter ASP.NET 2.0 beschäftigt, und bin jedes Mal an Grenzen gestoßen, die ich dann je nach Anwendungsfall ganz pragmatisch umschifft habe. Jetzt hatte ich aber die Nase voll und wollte endlich "die Richtige" Lösung finden.

Die Anforderungen:

  • Es sollte möglich sein, beliebige URLs zu simulieren, insbesondere aber welche mit Verzeichnissen. Zum Beispiel sollte man Default.aspx?Id=1 in /Id/1/Default.aspx umschreiben können.
  • Postbacks müssen funktionieren und dabei muss die umgeschriebene URL erhalten bleiben. Das heißt aus /Id/1/Default.aspx darf nach einem Postback kein /Default.aspx?Id=1 werden.
  • Annehmlichkeiten wie Themes und Lokale Ressourcen müssen weiterhin ohne selbstgestrickte Bastellösungen, die nachher kein Mensch mehr warten kann, funktionieren.
  • Forms Authentication muss funktionieren.
  • Es sollte alles mit ASP.NET-Bordmitteln funktionieren. Das heißt, ich möchte keine externe Komponente zukaufen und ich möchte auch am Webserver nichts separat konfigurieren müssen.

Auf den ersten Blick könnte jetzt die Frage aufkommen, was diese vielen Sachen denn überhaupt miteinander zu tun haben - das wird sich sicherlich gleich aufklären ...

Die Lösungs-Ansätze:

Es gibt prinzipiell wie immer 1000 Wege die nach Rom führen, aber meiner Meinung nach nur zwei wirklich durchdachte bzw. ausgereifte, auf die ich mich konzentriert habe.

ASP.NET HTTP module for URL redirections von Fabrice

Eine bewährte und sehr komfortable Lösung, denn mit ihr kann man einfach beliebige Patterns in der Web.Config definieren und ist so völlig flexibel was das Umschreiben seiner URLs anbelangt:

"Madgeek.Web.ConfigRedirections, Madgeek.RedirectModule">
   "true"
         targetUrl="^~/FalseTarget.aspx"
         destinationUrl="~/RealTarget.aspx" />
   "true"
         targetUrl="^~/2ndFalseTarget.aspx"
         destinationUrl="~/RealTarget.aspx" />
            targetUrl="^~/(Author|Category|Tool)([A-Za-z0\d]{8}-?[A-Za-z\d]{4}-?[A-Za-z\d]{4}-?[A-Za-z\d]{4}-?[A-Za-z\d]{12}).aspx$"
         destinationUrl="~/Pages/$1.aspx?$1=$2" />
            targetUrl="^~/SomeDir/(.*).aspx\??(.*)"
         destinationUrl="~/Pages/$1/Default.aspx?$2" />

Vorteile:

  • Quellcode liegt vor
  • Sehr flexibel
  • Sehr schnell

Nachteile:

  • ASP.NET 2.0 Themes usw. funktionieren nicht mehr, sobald man "virtuelle Ordner" verwendet. Denn die werden immer vom aktuellen Punkt aus referenziert. Beispiel: Detail.aspx wird zu /Detail/Default.aspx -> Dann wird App_Themes in /Detail/App_Themes gesucht, anstatt im Root.
  • Postback-Problem 1: selbst wenn man im Root bleibt, hat man das Problem, dass das URL-Rewriting nach einem Postback nicht beibehalten wird, wie oben beschrieben wird dann aus Default_1.aspx wieder /Default.aspx?Id=1 usw.
  • Postback-Problem 2: Verwendet man Ordner in der URL, gehen Postbacks gar nicht mehr. Das liegt am gleichen Problem wie es schon bei Themes auftritt: aus /FakeDirectory/Default.aspx wird dann /FakeDirectory/Default.aspx?Parameter=FakeDirectory ...

Rewriting the URL using IHttpHandlerFactory von Jeff

Hier handelt es sich nicht wirklich um eine fertige URL-Rewriting-Implementierung, sondern viel mehr um ein Toolkit, mit dem sich die Unzulänglichkeiten der ersten Lösung umschiffen lassen:

using System;
using System.IO;
using System.Web;
using System.Web.UI;

public class MyPageFactory : IHttpHandlerFactory
{
   public IHttpHandler GetHandler(HttpContext context, string requestType, 
   string url, string pathTranslated)
   {
      context.Items["fileName"] = Path.GetFileNameWithoutExtension(url).ToLower();
      return PageParser.GetCompiledPageInstance(url, 
      context.Server.MapPath("~/Content.aspx"), context);
   }
   public void ReleaseHandler(IHttpHandler handler)
   {
   }
}

Was passiert hier? Eigentlich zu schön und einfach um wahr zu sein: es wird die aktuelle Anfage hergenommen, deren URL auch überall verwendet wird (Action-Attribut im Formtag, Referenzierung für Links, Themes & Co.), aber auf eine andere physische Datei umgeleitet. Die konkrete Implementierung kann man sich dann innerhalb der GetHandler-Methode selbst schaffen, man ist quasi völlig flexibel.

Vorteile:

  • Quellcode liegt halbwegs gut dokumentiert vor.
  • Postbacks funktionieren.

Nachteile:

  • Nicht die schnellste Lösung (Gedenksekunde beim ersten Aufruf ...)
  • Forms Authentication funktioniert nicht.

Die Lösung

Nachdem ich Stunden meines Lebens damit scheinbar vergeudet habe, eine Lösung für das Forms-Authentication-Problem von Variante 2 zu finden, habe ich dann doch noch einmal die erste Variante ausgepackt und versucht deren Unzulänglichkeiten zu beheben. Mit Erfolg.

Der eigentliche Knackpunkt hier liegt ja in der nicht umgeschriebenen URL im Action-Attribut des Form-Tags sowie der nicht funktionierenden Referenzierung von z.B. Themes. Die Lösung hierfür liefert K. Scott Allen in seinem Blog-Eintrag "The Passion and the Fury of URL Rewriting":

protected override void OnPreInit(EventArgs e)
{
   base.OnPreInit(e);
   HttpContext.Current.RewritePath(HttpContext.Current.Items["VirtualUrl"].ToString(), string.Empty, string.Empty, true);
}

Mittels RewritePath lässt sich die URL für den aktuellen Aufruf wieder so umschreiben, wie man sie haben möchte. Das bedeutet:

  • Funktionierende Postbacks auch mit Verzeichnissen in der umgeschrieben URL
  • Ebenfalls funktionierende Themes, lokale Ressourcen usw.

Den Aufruf hierfür platziert man am besten in einer gemeinsamen Basisklasse, von der man die Seiten erben lässt, die vom URL-Rewriting in der Applikation betroffen sind. Am liebsten hätte ich das natürlich in einem Aufwasch mit dem Rewriting selbst erledigt, aber das wollte leider nicht so wie ich wollte - und ich denke mit der Basisklassen-Lösung kann man durchaus leben, da man so auch Seiten explizit von dem Prozess ausnehmen kann, für die man beispielsweise kein URL-Rewriting benötigt.

Einen letzten kleinen Wermutstropfen gibt es dennoch, für den ich noch keine Lösung finden konnte:

Platziert man z.B. in einer Umgebung "/de/Default.aspx" ein HyperLink-Control das auf eine Datei im gleichen "Verzeichnis" verweisen soll, dann wird dieser Verweis immer aufs Root abgebildet, also auf "/Seite.aspx", und nicht auf "/de/Seite.aspx". Aber für diesen Fall kann man ja getrost "normale" nicht-serverseitige Links setzen, und bei allen anderen Fällen wo man den Pfad relativ zusammenbauen will kann man wieder auf die serverseitige Variante zurückgreifen. Ich habe leider kein passendes Event gefunden, in das man RewritePath() hätte platzieren können, und mit dem dann sowohl die Links als auch die restlichen Elemente einer Seite funktioniert hätten.

Fazit:

Man kann doch (fast) alles haben, man muss sich nur etwas anstrengen. Ich bin überzeugt, dass die Kombination aus Maurice' URL-Rewriting-Lösung und RewritePath() eine der besten Varianten zum URL Rewriting mit ASP.NET ist, und ich werde sie demnächst häufiger einsetzen und dann auch sehen ob sie sich in der Praxis so bewährt, wie ich es mir vorstelle.

Download:

Anbei ein kleines Demo-Projekt mit dem ich alle möglichen Fälle die mir eingefallen sind ausprobiere und abfrage (.NET 2.0).

UrlRewriteTest.zip (10,33 KB)

Kommentare

#1 Albert Weinert schrieb am Samstag, 7. Januar 2006 18:07:00:
Danke!

Gerade habe ich nach einer Rewriting Lösung gesucht, ich denke darauf kann ich auch aufbauen.

Ist der base.OnPreRender() aufruf innerhalb von OnPreInit() beabsichtigt?
#2 Thomas schrieb am Samstag, 7. Januar 2006 18:10:00:
Nein natürlich nicht, ist ein Copy&Paste-Fehler, weil ich das nachträglich noch vom PreRender ins PreInit befördert habe ... sorry.

Und gib mir Bescheid wenn du deine Lösung fertig hast, würde mich interessieren auf was für evtl. Probleme du so stößt.
#3 Albert Weinert schrieb am Samstag, 7. Januar 2006 18:32:00:
Mache ich, jedoch werden bei mir "nur" optionale Forms Authentication sowie der Rewrite von virtuellen Seiten zwecks einfachen CMS.

Ausnahmnen über ein rewrite regel sind auch ohne Problem möglich. Scheint wirklich brauchbar zu sein :)

Schön ist auch das ein Umleitung von .php :) sehr einfach möglich ist. Da ich gerade diese Webseite von PHP auf ASP.NET umstelle. Und ohne dies wäre es nicht so prickelnd.

Muss nun erstmal weiter an meinen XmlMembershipProvider und XmlRoleProvider, für die kleine Sicherheit für zwischendurch ohne Datenbank, basteln. Keine Lust für 3 Benutzer und 2 Rollen eine Datenbank anzulegen.
#4 Thomas schrieb am Samstag, 7. Januar 2006 18:51:00:
jo, das ist richtig - aber mit dem membershipprovider kann man das ja relativ schnell erledigen, siehe blogeintrag kürzlich. 2.0 bringt schon ein paar wirklich coole sachen mit, die einem das leben leichter machen ;-)
#5 Albert Weinert schrieb am Samstag, 7. Januar 2006 21:21:00:
Das aufwändigste waren nicht die Provider (waren nicht meine ersten) sondern die ConfigSectionen in der web.config, will man es sauber haben tippt man sich den Wolf. Es gibt zwar einen Generator aber das entsprach nicht dem was ich haben wollte.
#6 Albert Weinert schrieb am Samstag, 7. Januar 2006 23:08:00:
So, ein kleines Problem Entdeckt übergibt man der Seite noch einen Querystring als Parameter

http://localhost/web/test/ding.aspx?sdf=238&hdain

Dann schläge OnPreInit() fehl. Da versucht wird inkl. des QueryString zu rewriten (Fehlermeldung entfallen).

Hiermit geht's auch mit Querystring.

protected override void OnPreInit(EventArgs e)
{

base.OnPreInit(e);
if (HttpContext.Current.Items["VirtualUrl"] != null)
{
string queryString = string.Empty;
string url = HttpContext.Current.Items["VirtualUrl"].ToString();
if (Request.QueryString.Count > 0)
{
url = url.Substring(0, url.IndexOf('?'));
queryString = Request.QueryString.ToString();
}
HttpContext.Current.RewritePath(url, string.Empty, queryString, true);
}
}
#7 Thomas schrieb am Sonntag, 8. Januar 2006 00:04:00:
Sehr schön, das hätte man eigentlich schon vorher abfangen können, bereits beim Rewrite-Module. Andererseits hat man so den Querystring schön zusätzlich mit ... also, eigentlich richtig gut :)

Werde ich erweitern.
#8 Thomas schrieb am Sonntag, 8. Januar 2006 22:44:00:
Update:

OnPreInit ist Mumpitz, das (soweit getestet) frühest mögliche Event ist OnLoad - andernfalls kommen die QueryStrings "nicht durch".

Dein Kommentar