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.