Ist doch selbstverständlich, oder? Scheint nicht so, zumindest nicht, wenn man den vielen Ansätzen von Microsoft folgt. Da gibt es seit ASP.NET 1.0 die Validation Controls für WebForms und mit ASP.NET MVC hielt eine Validierung über DataAnnotations Einzug. Letzteres ist auch verdammt sexy, da die Konfiguration über Attribute wirklich schön sauber ist. Deshalb habe ich es im ersten MVC-Projekt auch versucht zu verwenden.
Mir ist das Ganze aber ziemlich schnell auf die Füße gefallen. Von hausgemachten Problemen (Bools validieren, Passwort-Vergleich) einmal abgesehen, kommen dann auch schnell Szenarien, die einen ins Grübeln bringen.
Beispiel gefällig? Wie wäre es z.B. mit einer Benutzerregistrierung, bei der überprüft werden soll, ob ein Benutzername bereits vergeben wurde? Das geht nur mit komplexerer Logik, die einen Datenbankaufruf beinhaltet. So etwas möchte ich eigentlich nicht unbedingt im Frontend wissen und in ein Attribut packen.
Dazu kommt DRY. Denn was, wenn man nicht nur ein Web-, sondern auch ein Desktop-Frontend oder eine mobile Version erstellt? Dann muss die komplette Geschäftslogik, nichts anderes ist Validierung von Daten ja letztendlich auch, mehrfach implementiert werden.
Ich habe mich also, so schwer es mir fiel, von den hübschen DataAnnotations wieder verabschiedet und die Logik wieder dorthin gepackt, wohin sie gehört: in den Business Layer. Um mir die Arbeit zu erleichtern, gerade in Hinblick auf meinen Schwerpunkt ASP.NET MVC, habe ich mich am Beispiel "Validating with a Service Layer" orientiert. Mir gefallen aber keine magischen Strings, weshalb ich das Beispiel etwas abgewandelt habe.
1: public interface IValidationState
2: {
3: void AddError<T>(Expression<Func<T, object>> property, string errorMessage);
4: bool ContainsKey<T>(Expression<Func<T, object>> property);
5: bool IsValid { get; }
6: }
Außerdem sehe ich davon ab, es als Konstruktor-Parameter dem Service mitzugeben und verwende es stattdessen lieber auf Methodenebene (was den Kontext klarer abgrenzt).
Die Verwendung ist ziemlich simpel:
1: private void ValidateClientToCreate(Client client, IValidationState validationState)
2: {
3:
4: if(string.IsNullOrWhiteSpace(client.Name))
5: validationState.AddError<Client>(c => c.Name, "Name fehlt");
6:
7: if (string.IsNullOrWhiteSpace(client.Alias))
8: validationState.AddError<Client>(c => c.Alias, "Alias fehlt");
9:
10: }
Um es mit ASP.NET MVC nutzen zu können (geht auch mit 1.0), habe ich ebenfalls einen Wrapper geschrieben:
1: public class ViewModelValidationState : IValidationState
2: {
3:
4: public ViewModelValidationState(ModelStateDictionary modelState)
5: {
6: ModelState = modelState;
7: }
8:
9: private readonly ModelStateDictionary ModelState;
10:
11: public void AddError<T>(Expression<Func<T, object>> property, string errorMessage)
12: {
13: ModelState.AddModelError(GetKey(property), errorMessage);
14: }
15:
16: public bool ContainsKey<T>(Expression<Func<T, object>> property)
17: {
18: return ModelState.ContainsKey(GetKey(property));
19: }
20:
21: public bool IsValid
22: {
23: get { return ModelState.IsValid; }
24: }
25:
26: private static string GetKey<T>(Expression<Func<T, object>> property)
27: {
28: if (property.Body is UnaryExpression)
29: return ((UnaryExpression)property.Body).Operand.ToString().Split('.')[1];
30: return ((MemberExpression)property.Body).Member.Name;
31: }
32:
33: }
Nett an der Sache ist, dass man flexibel bleibt. So habe ich z.B. in einem Fall bei der Registrierung die Validierung der Kerndaten, die für jede Anwendung gleich ist, quasi an der Grenze zur Domäne, in den Service gepackt.
Aber natürlich kann es vorkommen, dass im Client selbst noch weitere Überprüfungen stattfinden müssen - z.B. die Prüfung, ob beim Formular das Passwort korrekt wiederholt wurde um Fehler zu vermeiden, oder ob die AGB akzeptiert worden sind. Das sind Dinge, die nicht in den Service gehören, weil sie in diesem Kontext schlicht nicht existent sind.
Also kombiniert man die Prüfungen einfach:
1: [HttpPost]
2: public ActionResult Index(SignUp model)
3: {
4:
5: if(!model.AgreesWithTerms)
6: ModelState.AddModelError("AgreesWithTerms", "AGB ...");
7:
8: if(model.UserPassword != model.UserPasswordConfirmation)
9: ModelState.AddModelError("UserPasswordConfirmation", "Passwort ...");
10:
11: var client = new Client();
12: var user = new User();
13:
14: if (ClientService.CreateClient(client, user, new ViewModelValidationState(ModelState)))
15: {
16: return RedirectToAction("success");
17: }
18:
19: return View(model);
20:
21: }
Ebenfalls gewrappt habe ich es natürlich für Tests:
1: public class MockValidationState : IValidationState
2: {
3:
4: private bool isValid = true;
5: private readonly Dictionary<string, string> errors = new Dictionary<string, string>();
6:
7: private static string GetPropertyName<T>(Expression<Func<T, object>> property)
8: {
9: if (property.Body is UnaryExpression)
10: return ((UnaryExpression)property.Body).Operand.ToString().Split('.')[1];
11: return ((MemberExpression)property.Body).Member.Name;
12: }
13:
14: public void AddError<T>(Expression<Func<T, object>> property, string errorMessage)
15: {
16: errors.Add(GetPropertyName(property), errorMessage);
17: isValid = false;
18: }
19:
20: public bool ContainsKey<T>(Expression<Func<T, object>> property)
21: {
22: return errors.ContainsKey(GetPropertyName(property));
23: }
24:
25: public bool IsValid
26: {
27: get { return isValid; }
28: }
29:
30: }
Verwendung:
1: [Test]
2: public void Darf_der_Name_nicht_Null_sein()
3: {
4: var validationState = new MockValidationState();
5: Sut.CreateClient(new Client { Name = null }, new User(), validationState);
6: Assert.IsTrue(validationState.ContainsKey<Client>(c => c.Name));
7: }
Ob dies der Weisheit letzter Schluss ist, mal sehen ;-). Aber es erscheint mir momentan allemal sinnvoller als die Validierung komplett im GUI abzuwickeln, dazu lässt es sich sehr bequem nutzen.