Kontextabhängige Datenvalidierung

Die Idee stammt von Jimmy Nilsson – Applying Domain-Driven Design and Patterns. Er hat nach einer Möglichkeit gesucht, die immer wiederkehrende Aufgabe, Daten zu validieren, flexibel und kontextabhängig zu gestalten, und zwar so, dass man es nur einmal schreiben muss.

Wie läuft normalerweise so eine Validierung ab? Man will wissen, ob eine Instanz als solche allen Vorschriften entspricht, und wenn nicht, dann welche Felder passen nicht. Jeder, der jemals Webanwendungen geschrieben hat, weiß, wie mühsam und langweilig es ist, jeden Eingabewert auf Gültigkeit zu testen. (Hier geht allerdings nicht unbedingt um Formulare, da kann man ja die Validierung z.B. bei ASP.MVC 2.0 mit Data Annotations durchführen.)

Also zurück zu den Anforderungen: um die verschiedenen Regeln wiederverwendbar zu machen, braucht man diese von einem Interface abzuleiten:

namespace ValidationFramework
{
    public interface IRule
    {
        bool IsValid { get; }
        int IdRule { get; }
        string[] BooleanFieldsThatMustBeTrue { get; }
        string Message { get; }
    }
}

IsValid sagt aus, ob der Regel verletzt wurde. Die IdRule ist dafür da, um diese Regeln einfacher identifizieren zu können. BooleanFieldsThatMustBeTrue kann dafür verwendet werden, um Vorbedingungen zu prüfen. Message braucht wohl keine Erklärung.
Jetzt kann man verschiedene Regeln und eine Basisklasse für gemeinsame Funktionalitäten definieren:

using System.Collections.Generic;
using System.Linq;
using System;

namespace ValidationFramework
{
    public abstract class RuleBase : IRule
    {
        private readonly object m_value;
        private readonly int m_idRule;
        private readonly string[] m_booleanFieldsThatMustBeTrue;
        private readonly object m_holder;

        protected RuleBase(int idRule, string[] fieldsConcerned, string fieldname, object holder)
        {
            m_value = GetPropertyValue(holder, fieldname);
            m_booleanFieldsThatMustBeTrue = fieldsConcerned;
            m_holder = holder;
            m_idRule = idRule;
        }

        private static object GetPropertyValue(object holder, string fieldname)
        {
            return holder.GetType().GetProperty(fieldname).GetValue(holder, null);
        }

        protected object GetValue()
        {
            return m_value;
        }
        protected object GetHolder()
        {
            return m_holder;
        }

        public abstract bool IsValid { get; }
        public int IdRule
        {
            get { return m_idRule; }
        }

        public string[] BooleanFieldsThatMustBeTrue
        {
            get { return m_booleanFieldsThatMustBeTrue; }
        }

        public abstract string Message { get; }

        protected bool BooleanFieldsConcernedAreTrue()
        {
            return
                m_booleanFieldsThatMustBeTrue.Select(a => (bool)m_holder.GetType().GetProperty(a).GetValue(m_holder, null)).
                    Select(b => b).Count() == m_booleanFieldsThatMustBeTrue.Length;
        }
    }

    public class DateIsInRangeRule : RuleBase
    {
        private readonly DateTime m_minDate;
        private readonly DateTime m_maxDate;

        public DateIsInRangeRule(DateTime minDate, DateTime maxDate, int idRule, string fieldName, object holder) : base( idRule, null,fieldName, holder)
        {
            m_minDate = minDate;
            m_maxDate = maxDate;
        }

        public override bool IsValid
        {
            get { 
                var value = (DateTime) GetValue();
                return value >= m_minDate && value <= m_maxDate;
            }
        }

        public override string Message
        {
            get {
                return IsValid
                           ? string.Empty
                           : string.Format("Das Datum ist nicht in gültigen Bereich: {0}-{1}", m_minDate, m_maxDate); }
        }
    }

    public class MaxStringLengthRule : RuleBase
    {
        private readonly int m_maxLength;

        public MaxStringLengthRule(int maxLength, int idRule, string fieldname, object holder) : base(idRule, null, fieldname, holder)
        {
            m_maxLength = maxLength;
        }

        public override bool IsValid
        {
            get { return GetValue().ToString().Length <= m_maxLength; }
        }

        public override string Message
        {
            get {
                return IsValid
                           ? string.Empty
                           : string.Format("Die zugelassene Länge von {0} Zeichen wurde überschritten",m_maxLength); }
        }
    }
}

Jetzt, da die Grundlagen stehen, schauen wir mal, wie man die Validierungsregeln festlegen würde.
Hier ist eine ganz einfache Beispielklasse:

using System;

namespace ValidationFramework
{
    public class Account
    {
        public Account()
        {
            Created = DateTime.Now;
            Address = string.Empty;
        }

        public DateTime Created { set; get; }
        public string Address { get; set; }
        public bool Activated { get; set; }
    }
}

Jetzt definieren wir die Regeln, wonach eine Instanz valide ist oder nicht. Die neue Eigenschaft IsValidated soll diese Information speichern.

    public class Account
    {
        private readonly IList<IRule> m_validationRules = new List<IRule>();

        public Account()
        {
            Created = DateTime.Now;
            Address = string.Empty;
        }

        public DateTime Created { set; get; }
        public string Address { get; set; }
        public bool Activated { get; set; }
        public bool IsValidated { get; set; }

        private void SetupValidationRules()
        {
            m_validationRules.Add(new DateIsInRangeRule(new DateTime(1990,1,1),DateTime.Now,1,"Created",this ));
            m_validationRules.Add(new MaxStringLengthRule(10,2,"Address",this));
        }
    }

Um Regeln auch dynamisch setzen zu können, wird eine Methode AddValidationRule(IRule) definiert

 
    public class Account
    {
...    
        public void AddValidationRule(IRule rule)
        {
            m_validationRules.Add(rule);
        }
...
    }

Nun müssen wir diese Regeln nur noch auswerten. Dafür wird in RuleBase eine statische Methode definiert und in der Beispielklasse die Methode IEnumerable GetBrokenRules()

    public abstract class RuleBase : IRule
    {
...
        public static IEnumerable<IRule> CollectBrokenRules(IList<IRule> rulesToCheck)
        {
            return rulesToCheck.Where(a => !a.IsValid).Select(a => a);
        }
    }
    public class Account
    {
...
        public IEnumerable<IRule> GetBrokenRules()
        {
            SetupValidationRules();
            return RuleBase.CollectBrokenRules(m_validationRules);
        }
...
    }

Um zu beweisen, dass es funktioniert, hier ein Paar Tests:

using System;
using System.Linq;
using NUnit.Framework;

namespace ValidationFramework.Tests
{
    [TestFixture]
    [Category("DateIsInRangeRule")]
    public class If_the_CreationDate_of_the_Account_is_in_range
    {
        [Test]
        public void Then_the_property_Created_is_valid()
        {
            var sut = new Account { Created = DateTime.Now.AddYears(-1) };
            Assert.That(sut.GetBrokenRules().Count(),Is.EqualTo(0));
        }
    }
    [TestFixture]
    [Category("DateIsInRangeRule")]
    public class If_the_CreationDate_of_the_Account_is_to_old
    {
        [Test]
        public void Then_the_property_Created_is_not_valid()
        {
            var sut = new Account { Created = new DateTime(1989,1,1)};
            Assert.That(sut.GetBrokenRules().Count(), Is.EqualTo(1));
            Assert.That(sut.GetBrokenRules().First().IdRule, Is.EqualTo(1));
        }
    }

    [TestFixture]
    [Category("MaxStringLengthRule")]
    public class If_the_Address_of_the_Account_is_to_long
    {
        [Test]
        public void Then_the_property_Address_is_not_valid()
        {
            var sut = new Account { Address = "12345678901"};
            Assert.That(sut.GetBrokenRules().Count(), Is.EqualTo(1));
            Assert.That(sut.GetBrokenRules().First().IdRule,Is.EqualTo(2));
        }
    }
}

Mit den vorhandenen Tests kann man nun refaktorisieren um die Prinzipien der Separation Of Concern einzuhalten. Außerdem ist nun Zeit, auch über die Kontextbezogenheit nachzudenken.
Die Klasse Account wird wahrscheinlich durch irgendeinen ORMapper gefüllt und braucht auf keinem Fall die Verantwortung, die Validierungsregeln zu verwalten. Deshalb kann man das Specification-Pattern von DDD anwenden, und diese Regeln in so eine Spezifikation verschieben:

using System;
using System.Collections.Generic;

namespace ValidationFramework
{
    public interface IValidationSpecification
    {
        IList<IRule> GetValidationRules();
    }

    public class AccountValidationSpecification : IValidationSpecification
    {
        private readonly Account m_objectToValidate;

        public AccountValidationSpecification(object objectToValidate)
        {
            m_objectToValidate = (Account) objectToValidate;
        }

        public IList<IRule> GetValidationRules()
        {
            return new List<IRule>
                       {
                           new DateIsInRangeRule(new DateTime(1990, 1, 1), DateTime.Now, 1, "Created",
                                                 m_objectToValidate),
                           new MaxStringLengthRule(10, 2, "Address", m_objectToValidate)
                       };
        }
    }
}

Die Spezifikationen werden selbstverständlich von einer Factory geliefert und sie werden per Setter Injection gesetzt.

    public  class ValidationSpecificationFactory
    {
        public static IValidationSpecification Create<T>(object objectToValidate)
        {
            if (typeof(T) == typeof(Account))
                return new AccountValidationSpecification(objectToValidate);
            throw new NotSupportedException();
        }
    }
    public class Account
    {
...
        public void SetValidationSpecification(IValidationSpecification specification)
        {
            foreach (IRule rule in specification.GetValidationRules())
                m_validationRules.Add(rule);
        }
...
    }
//Der Aufruf ist dann
var account = new Account();
account.SetValidationSpecification(ValidationSpecificationFactory.Create<Account>(account));

Dadurch ist die Bedingung, Kontextabhängige Validierungsregeln festlegen zu können, erfüllt.

Man kann generische Spezifikationen definieren, die die Regeln für verschiedene Zwecke, z.B. für Persistieren oder auch einfach nur für Akzeptieren definieren:

   
    public interface IValidationSpecification
    {
        IList<IRule> GetValidationRules();
        IList<IRule> GetValidationRulesRegardingPersistence();
    }

Hier der komplette Quellcode zum Herunterladen.

SOLID Principles

Im vorherigen Artikel habe ich drei der fünf wichtigsten Prinzipien des OOD (Objektorientiertes Design) benannt, ohne mehr darüber zu schreiben. Das würde ich jetzt gerne nachholen.
Diese Prinzipien wurden von Robert C.Martin (a.k.a. Uncle Bob) unter dem Namen S.O.L.I.D. Principles zusammengefasst:

  • SRP: The Single Responsibility Principle
  • OCP: The Open Closed Principle
  • LSP: The Liskov Substitution Principle
  • DIP: The Dependency Inversion Principle
  • ISP: The Interface Segregation Principle

Das Thema wurde von Uncle Bob in mehreren Blogartikeln, Podcasts und vor allem in seinem Buch sehr ausführlich erklärt. Auch andere Entwickler haben darüber geschrieben, zum Beispiel hat Stefan Lieser darüber eine ganze Artikelserie in dotnetpro veröffentlicht.
Ich habe das erste Mal vor einem Jahr in diesem Podcast von Hanselman und Uncle Bob darüber gehört, und während der letzten 12 Monaten wurde ich durch die tägliche Arbeit überzeugt, dass man mit diesen Regeln solide Anwendungen bauen kann.

Was bedeuten also diese Akronyme:

SRP: The Single Responsibility Principle

Die Definition lautet:

A class should have only one reason to change.

Diese Regel ist wahrscheinlich die einfachste und wird wahrscheinlich am meisten verletzt. Wer kennt nicht Klassen, die sowas tun, wie Daten speichern, manipulieren, E-Mails versenden und all das eventuell auch noch loggen. Das war früher eine ganz normale Vorgehensweise. Was passiert aber, wenn die Datenbank-Struktur sich verändert hat? Dann musste man nicht nur diese ändern sondern auch all die Klassen – meistens Verwaltungen oder Manager genannt – die all diese Verantwortlichkeiten hatten. Und darum geht es hier: eine Klasse darf nur einen Grund für Änderungen haben, sie darf nur eine Verantwortlichkeit haben. Also wenn man mehrere Gründe erkennen kann, warum sich eine Klasse verändert, dann wurde dieses Prinzip verletzt. Und das gilt nicht nur für Klassen, sondern auch für andere Funktionseinheiten wie Funktionen, Klassen, Assemblies, Komponenten, alle auf ihre Abstraktionsebene betrachtet.

OCP: The Open Closed Principle

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

Jede Funktionseinheit soll erweiterbar sein, also darf nicht zu viele Abhängigkeiten haben, weil die diese Freiheit stark oder ganz einschränken können. Wenn man allerdings ein verändertes Verhalten implementieren will, soll das nicht durch Veränderung des Codes sondern durch hinzufügen von neuen Funktionen passieren.
Das ist nur durch ausreichende Abstraktion zu erreichen. Wenn die Kernfunktionalität in eine abstrakte Basisklasse gekapselt ist, kann man das neue Verhalten in einer abgeleiteten Klasse implementieren.

LSP: The Liskov Substitution Principle

Dieses Prinzip wurde von Barbara Liskov definiert und es lautet so:

Subtypes must be substitutable for their base types.

Einfach übersetzt: jede abgeleitete Klasse einer Basisklasse muss diese Klasse so implementieren, dass sie durch diese jeder Zeit ersetzbar ist. Jedes Mal, wenn man eine Basisfunktion so implementiert, dass diese was ganz anderes tut, als man grundsätzlich erwarten würde, verletzt man dieses Prinzip. Das berühmteste Beispiel ist das Rechteck und das Quadrat. Auf den ersten Blick meint man, dass ein Quadrat ein spezialisiertes Rechteck ist. Was passiert aber, wenn man die Länge oder die Breite eines Quadrates setzt? Es muss jeweils die andere Eigenschaft auch gesetzt werden, sonst wäre es ja kein Quadrat ;). Das ist aber ein Verhalten, was man bei einem Rechteck niemals erwarten würde. Also würde diese Ableitung das Liskovsche Substitutionsprinzip grob verletzen.

DIP: The Dependency Inversion Principle

Die Definition lautet

1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
2. Abstractions should not depend upon details. Details should depend upon abstractions

Dieses Prinzip ist sehr einfach zu erklären (Beispiel für die Verletzung sieht man ja im vorherigen Artikel): Keine Klasse sollte fremde Klassen instanziieren, sondern diese als Abstraktionen (z.B. Interfaces) in Form eines Parameters bekommen. Das führt dazu, dass die fremde Klasse als Black Box fungieren kann, ihre Veränderungen würden nicht zu Veränderung dieser konkreten Klasse führen.

ISP: The Interface Segregation Principle

Das Prinzip bezieht sich auf “fette” Interfaces:

Clients should not be forced to depend on methods they do not use.

Ein Interface ist der veröffentlichte Kontrakt einer Klasse, eines Moduls. Je mehr Methoden darin registriert wurden, vor allem, wenn sie sehr ähnlich sind oder wenn sie keine selbsterklärende Namen haben, dann ist das eine Zumutung gegenüber der Clients, des Verwenders. Er könnte dazu gezwungen sein, den Code der dahinter stehenden Implementierung anzusehen, was die Erstellung des Interfaces sinnlos macht. Dieses soll ja als Black Box fungieren, nicht als eine Herausforderung für den Entwickler.