ASP.NET MVC - Arbeiten mit View- und SubmitModels
Microsoft und dessen Evangelisten bzw. Unabhängige lehren in ASP.NET MVC Tutorials (MVCNerdDinner, MVCMusicStore) immer wieder, dass Entitäten - also Datenbank-Objekte, die in der Regel mit Hilfe von OR-Mappern geladen werden - direkt an die View übertragen werden: davon halte ich aber nichts.
Warum?
Für mich und mein Software-Architektur-Verständnis, haben Datenbankentitäten nichts in der Oberflächen-Schicht zu suchen!
Des weiteren wäre es nicht so einfach möglich, weitere Informationen an die View zu übermitteln. Oft werden gerade in RIAs aber nicht nur die Eigenschaften der jeweiligen Entität angezeigt, nein, oft eben auch viele andere, vielleicht auch verschiedene Dinge bzw. Elemente angezeigt.
Microsoft macht dies in seinen Tutorials zwar mit Hilfe des ViewBags; aber dieser ist eben untypisiert und sollte in meinen Augen daher vermieden werden.
Stattdessen habe ich mir von WPF das Prinzip er ViewModels, die auch in vielen anderen Bereich der GUI-Entwickelung verwenden werden, abgeschaut. Das heißt: jede View hat sein eigenes ViewModel.
Fallbeispiel: Benutzer-Registrierung
Wir wollen in auf unserer Seite eine Ansicht erstellen, die dem Benutzer die Möglichkeit bietet, sich zu registrieren. In der Regel ist dies die EMail-Adresse und das zukünftige Passwort, das zudem doppelt eingetippt werden soll.
Erstes Problem: Das Passwort darf nicht im Klartext in der Datenbank gespeichert werden!
Also hat unser Benutzer folgendes Datenbankmodell (Prinzip des SHA256-Hashes):
public class Benutzer
{
public String EMail { get; set; }
public String PassHash { get; set; }
public String PassSalt { get; set; }
public DateTime RegistrierDatum { get; set; }
}
Und folgende Eingabefelder in der Ansicht:
<input type="email" id="EMail" name="Email" value="" autocomplete="off" required=required />
<input type="password" id="Pass1" name="Pass1" value="" autocomplete="off" required=required />
<input type="password" id="Pass2" name="Pass2" value="" autocomplete="off" required=required />
Und dabei merken wir: Hui, die Eingabefelder unterscheiden sich schon jetzt vom Modell. Der Ansatz, der oft in den Tutorials gezeigt wird, kann also nicht funktionieren!
Damit nun wirklich eine flexible Lösung geschaffen werden kann, brauchen wir das Prinzip des View- und SubmitModels.
Ich weiß aber nicht, ob der Name SubmitModel hier korrekt ist; ich habe ihn einfach verwendet und sehe mich hier auch als Vorreiter. Es gibt einfach bisher meines Wissens niemanden, der ein ähnliches Prinzip in der ASP.NET MVC-Welt verwendet hat.
Infrastruktur
Alle meine ViewModels und SubmitModels haben eine Basis-Klasse, die das weitere Arbeiten in der Anwendung erleichtert, die Tücken von ASP.NET MVC umgeht und viel Code einspart.
Das BaseViewModel
Alle meine Seiten der Anwendung bieten die Möglichkeit, dass eine Fehlermeldung im Kopf der Seite angezeigt wird; z.B. wenn ein Formular nicht korrekt ausgefüllt wurde oder ein Fehler aufgetreten ist, der nicht automatisch korrigiert werden konnte. Aus diesem Grund bietet es sich natürlich an, dass für die Meldungen entsprechende Eigenschaften vorgehalten werden. Je nach Bedarf und Seite können natürlich noch andere Standardelemente hier eingetragen werden:
public class BaseViewModel
{
public BaseViewModel()
{
SuccessMessages = new List<String>( );
InfoMessages = new List<String>( );
ErrorMessages = new List<String>( );
}
public IList<String> SuccessMessages { get; private set; }
public IList<String> InfoMessages { get; private set; }
public IList<String> ErrorMessages { get; private set; }
}
Das BaseSubmitModel
Microsoft zeigt in seinen Beispielen, dass eine Entität an einer POST-Action erwartet wird. Das hat den sehr feinen Vorteil, dass eine Entität direkt gefüllt wird und wir uns - zumindest in der optimalen Theorie - um nichts mehr kümmern müssen. Ebenso wird die Entität automatisch validiert. An unserem Benutzer-Modell oben sehen wir aber, dass dies nicht funktioniert. Problemfaktoren sind, dass sich zum Beispiel die Felder zwischen SubmitModel und eigentlichem Ziel-Entität unterscheiden, oder, dass der Benutzer einfach nicht alle Felder editieren soll, kann oder darf.
Damit ist der vermeintliche Vorteil dahin!
Es funktioniert einfach in realen Anwendungen nicht, sondern leider nur in den "optimalen" Beispielen. Aus diesem Grund deaktiviere ich Grundsätzlich auch den ModelState - ich kann ihn ja eh nicht nutzen. Unsere SubmitModels sollen aber auf eine Validierung nicht verzichten; im Gegenteil: sie soll so einfach wie möglich durchführbar sein. Die Lösung: die Schnittstelle IValidatableObject. Mit ihrer Hilfe können wir die einfachsten, wie auch kompliziertesten Abhängigkeiten des SubmitModels validieren:
public abstract class BaseSubmitModel : IValidatableObject
{
/// <summary>
/// Abstrakte Methode. Zwingt der ergebenden Klasse, dass sie diese Methode für eigene Validierungen implementieren muss.
/// </summary>
public abstract Boolean IsValid( out IEnumerable< ValidationResult > validationResults );
/// <summary>
/// Diese Implementierung ist Pflicht (IValidatableObject).
/// Sie führt die Validierung der erbende Klassen aus und gibt die Fehler zurück.
/// </summary>;
public IEnumerable<ValidationResult> Validate( ValidationContext validationContext )
{
IEnumerable< ValidationResult > errorCollection;
IsValid( out errorCollection );
return errorCollection;
}
}
Beide Methoden ermöglichen ein manuelles Validieren (IsValid) wie auch das automatische Validieren über den ValidationContext (Validate) (wenn man den ModelState doch verwenden möchte).
Zurück zum Fallbeispiel
Ich überlege mir daher immer zu Beginn: was wird der Nutzer eingeben wollen bzw. müssen? Die dazugehörigen Eingabefelder habe ich ja weiter oben bereits gezeigt. Anhand dessen bilde ich mir mein SubmitModel:
// Spezifische Validierung dieses SubmitModels
public Boolean IsValid( out IEnumerable< ValidationResult > validationResults )
{
var errors = new List< ValidationResult >( );
{
// Wurde eine E-Mail angebeben?
if( String.IsNullOrEmpty( EMail ) )
{
errors.Add( new ValidationResult( "Field 'EMail' is empty.", new[ ] { "EMail" } ) );
}
// Hier könnte man prüfen, ob es sich wirklich um eine E-Mail handelt
// Überprüfen der Passwörter
if( String.IsNullOrEmpty( Pass1 ) )
{
errors.Add( new ValidationResult( "Field 'Password' is empty.", new[ ] { "Pass1" } ) );
}
if( String.IsNullOrEmpty( Pass2 ) )
{
errors.Add( new ValidationResult( "Field 'Password match' is empty.", new[ ] { "Pass2" } ) );
}
if( !String.IsNullOrEmpty( Pass1 ) && !String.IsNullOrEmpty( Pass2 ) )
{
if( Pass1 != Pass2 )
{
errors.Add( new ValidationResult( "Passwords does not match.", new[ ] { "Pass1", "Pass2" } ) );
}
// Hier könnte man nun noch validieren, ob das Passwort irgendwelchen Anforderungen (Länge, enthaltenen Zeichen) genügt
}
}
validationResults = errors;
return !errors.Any( );
}
Anschließend überlege ich mir: was benötigt der Benutzer zur Registrierung?
In diesem Falle nichts :-)
public class BenutzerRegistrierungViewModel : BaseViewModel
{
public BenutzerRegistrierungSubmitModel SubmitModel { get; set; }
}
Es wäre aber denkbar, dass der Anwender seine Herkunft aus einer SelectBox auswählen muss. Hier könnte man nun die entsprechend verfügbaren Länder mit in die View übertragen!
Warum habe ich nun das SubmitModel in das ViewModel integriert?
Angenommen, der User gibt falsche oder zu wenig Daten ein, dann möchte ich ihm ja nicht zumuten, dass er alle Felder erneut ausfüllen muss (Ausnahme: Passwort-Felder und CAPTCHAs).
Die Ansicht hätte demnach folgendes Aussehen:
<input type="email" id="EMail" name="Email value="@( Model.SubmitModel.EMail )" autocomplete="off" required />
<input type="password" id="Pass1" name="Pass1 value="" autocomplete="off" required />
<input type="password" id="Pass2" name="Pass2 value="" autocomplete="off" required />
Integration in die Actions
Für ein Formular benötigen wir in der Regel zwei Actions: eines für die GET-Abfrage und eines für das Absenden des Formulars, die POST-Anfrage. Die Erklärung dessen wie immer direkt im Code:
Hilfsmethode, um das ViewModel zu erstellen:
Verwende ich immer, sobald zwei Actions mit dem gleichen ViewModel arbeiten und der Inhalt der gleiche sein soll.
private BenutzerRegistrierungViewModel GenerateBenutzerRegistrierungViewModel(BenutzerRegistrierungSubmitModel submitModel)
{
// Erstellen er ViewModel-Instanz
var viewModel = new BenutzerRegistrierungViewModel();
// Wenn kein SubmitModel übergeben wurde (i.d.R. die GET-Anfrage), dann erstellen wir eines
// Optional: Standardwerte zuweisen.
viewModel.SubmitModel = submitModel ?? new BenutzerRegistrierungSubmitModel();
// An dieser Position könnten wir nun Standardwerte beliefern.
// Das heißt, dass hier zum Beispiel das Füllen der Auswahlliste für die
// Herkunft des Benutzers, das ich vorhin angesprochen habe, erfolgen kann
return viewModel;
}
>Erstmaliger Aufruf (HTTP-GET):
public ActionResult BenutzerRegistrierung( )
{
// Erstellen des ViewModels
var viewModel = GenerateBenutzerRegistrierungViewModel( null );
// Laden der ansicht
return View( "BenutzerRegistrierung.cshtml", viewModel );
}
Aufruf nach dem Absenden des Formulars (HTTP-POST)
[HttpPost]
public ActionResult BenutzerRegistrierung( BenutzerRegistrierungSubmitModel submitModel )
{
// Erstellen des ViewModels
var viewModel = GenerateBenutzerRegistrierungViewModel( submitModel ); // Mitgabe des SubmitModels!
// Wurde ein SubmitModel wirklich mitgeliefert?
if ( submitModel == null)
{
// Wenn das passiert, dann stimmt in der Regel etwas beim Submit nicht.
// Wir brauchen es hier aber nicht neu initialisieren, da das SubmitModel über GenerateBenutzerRegistrierungViewModel wäre erstellt worden.
return View( "BenutzerRegistrierung.cshtml", viewModel );
}
// Gibt es logische Fehler im SubmitModel?
IEnumerable< ValidationResult > validationResults;
if ( ! submitModel.IsValid( out validationResults ) )
{
// Ja, das SubmitModel hat logische Fehler!
// Kopieren der Fehlermeldungen in das ViewModel
foreach( var valResult in validationResults )
{
viewModel.ErrorMessages.Add( valResult.Message );
}
// Zurück zur Eingabe
return View( "BenutzerRegistrierung.cshtml", viewModel );
}
// Was die Validierung des SubmitModels nicht übernehmen kann: Überprüfung, ob es die E-Mail bereits gibt!
using( var benutzerRepository = new BenutzerRepository ( <myDatabaseContext> ) )
{
if ( benutzerRepository.EMailExists( submitModel.EMail ) )
{
viewModel.ErrorMessages.Add( String.Format( "Die E-Mail Adresse '{0}' existiert bereits.", submitModel.EMail ) );
// Zurück zur Eingabe
return View( "BenutzerRegistrierung.cshtml", viewModel );
}
// Ok, wir können den Benutzer nun anlegen.
var neuerBenutzer = new Benutzer( );
{
neuerBenutzer.EMail = submitModel.EMail;
neuerBenutzer.RegistrierDatum = DateTime.Now;
}
// Unsere Vorgabe ist es, das Passwort zu verschlüsseln (SHA256).
// Für SHA1 benötigen wir einen Salt, der möglichst für jeden User dynamisch sein soll und etwas komplizierter sein soll.
// Ich benutzer für dieses Beispiel nun einfach den Timestamp des Registrierungsdatums.
// Optimal wäre es, wenn der Salt bei jeder Passwortänderung anders wäre!
neuerBenutzer.PassSalt = neuerBenutzer.RegistrierDatum.ToString("yyyyMMddHHmmss");
using( var sha = new SHA256Managed( ) )
{
var passAndSalt = String.Concat( submitModel.Pass1, neuerBenutzer.PassSalt ); // Sehr simples salzen des Passworts!
neuerBenutzer.PassHash = sha.ComputeHash( passAndSalt ).ToString( );
}
// Anlegen des neuen Benutzers
benutzerRepository.Add ( neuerBenutzer );
}
// An dieser Stelle wurde der Benutzer erfolgreich. Umleitung zu einer Übersichtsseite
// Evtl. noch verschicken von einer Mail, dass der Benutzer seine EMail bestätigen muss.
// Hier sind die Vorgene ja verschieden.
}
Fazit
Wir haben hier eine saubere und zuverlässliche Lösung für das Behandeln von Eingaben und das Anzeigen von Ansichten; zudem halten wir uns daran, die Schichten sauber zu trennen. Weiterhin könnte das ViewModel dazu verwendet werden, auch in anderen Oberflächen seinen Einsatz zu finden.