Funktionelles C #: Primitive Besessenheit

    Dies ist der zweite Artikel aus der Mini-Artikelserie über funktionales C #.


    Was ist eine primitive Besessenheit?


    Kurz gesagt, dies ist der Fall, wenn primitive Typen (Zeichenfolge, Int usw.) hauptsächlich zur Modellierung der Anwendungsdomäne verwendet werden. So könnte beispielsweise die Kundenklasse in einer typischen Anwendung aussehen:

    public class Customer
    {
        public string Name { get; private set; }
        public string Email { get; private set; }
        public Customer(string name, string email)
        {
            Name = name;
            Email = email;
        }
    }
    

    Das Problem hierbei ist, dass Sie, wenn Sie einige Geschäftsregeln durchsetzen müssen, die Validierungslogik im gesamten Klassencode duplizieren müssen:

    public class Customer
    {
        public string Name { get; private set; }
        public string Email { get; private set; }
        public Customer(string name, string email)
        {
            // Validate name
            if (string.IsNullOrWhiteSpace(name) || name.Length > 50)
                throw new ArgumentException(“Name is invalid”);
            // Validate e-mail
            if (string.IsNullOrWhiteSpace(email) || email.Length > 100)
                throw new ArgumentException(“E-mail is invalid”);
            if (!Regex.IsMatch(email, @”^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$”))
                throw new ArgumentException(“E-mail is invalid”);
            Name = name;
            Email = email;
        }
        public void ChangeName(string name)
        {
            // Validate name
            if (string.IsNullOrWhiteSpace(name) || name.Length > 50)
                throw new ArgumentException(“Name is invalid”);
            Name = name;
        }
        public void ChangeEmail(string email)
        {
            // Validate e-mail
            if (string.IsNullOrWhiteSpace(email) || email.Length > 100)
                throw new ArgumentException(“E-mail is invalid”);
            if (!Regex.IsMatch(email, @”^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$”))
                throw new ArgumentException(“E-mail is invalid”);
            Email = email;
        }
    }
    

    Darüber hinaus fällt genau derselbe Code in die Anwendungsschicht:

    [HttpPost]
    public ActionResult CreateCustomer(CustomerInfo customerInfo)
    {
        if (!ModelState.IsValid)
            return View(customerInfo);
        Customer customer = new Customer(customerInfo.Name, customerInfo.Email);
        // Rest of the method
    }
    public class CustomerInfo
    {
        [Required(ErrorMessage = “Name is required”)]
        [StringLength(50, ErrorMessage = “Name is too long”)]
        public string Name { get; set; }
        [Required(ErrorMessage = “E-mail is required”)]
        [RegularExpression(@”^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$”, ErrorMessage = “Invalid e-mail address”)]
        [StringLength(100, ErrorMessage = “E-mail is too long”)]
        public string Email { get; set; }
    }
    

    Offensichtlich verstößt dieser Ansatz gegen das DRY-Prinzip . Dieses Prinzip besagt, dass jede Domain-Information eine einzige maßgebliche Quelle in unserem Anwendungscode haben sollte . Im obigen Beispiel haben wir 3 solcher Quellen.

    Wie kann man die Besessenheit von Primitiven loswerden?


    Um die Besessenheit von Grundelementen zu beseitigen, müssen wir zwei neue Typen hinzufügen, die die Validierungslogik aggregieren. Auf diese Weise können wir Doppelarbeit beseitigen:

    public class Email
    {
        private readonly string _value;
        private Email(string value)
        {
            _value = value;
        }
        public static Result Create(string email)
        {
            if (string.IsNullOrWhiteSpace(email))
                return Result.Fail(“E-mail can’t be empty”);
            if (email.Length > 100)
                return Result.Fail(“E-mail is too long”);
            if (!Regex.IsMatch(email, @”^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$”))
                return Result.Fail(“E-mail is invalid”);
            return Result.Ok(new Email(email));
        }
        public static implicit operator string(Email email)
        {
            return email._value;
        }
        public override bool Equals(object obj)
        {
            Email email = obj as Email;
            if (ReferenceEquals(email, null))
                return false;
            return _value == email._value;
        }
        public override int GetHashCode()
        {
            return _value.GetHashCode();
        }
    }
    public class CustomerName
    {
        public static Result Create(string name)
        {
            if (string.IsNullOrWhiteSpace(name))
                return Result.Fail(“Name can’t be empty”);
            if (name.Length > 50)
                return Result.Fail(“Name is too long”);
            return Result.Ok(new CustomerName(name));
        }
        // Остальная часть класса такая же, как Email
    }
    

    Der Vorteil dieses Ansatzes besteht darin, dass es im Falle einer Änderung der Validierungslogik ausreicht, diese Änderung nur einmal zu reflektieren.

    Beachten Sie, dass der Konstruktor der Email-Klasse geschlossen ist. Die einzige Möglichkeit, eine Instanz davon zu erstellen, besteht darin, die statische Create-Methode zu verwenden, die alle erforderlichen Überprüfungen durchführt. Mit diesem Ansatz können wir sicherstellen, dass alle Instanzen der E-Mail-Klasse während ihres gesamten Lebens in gutem Zustand sind.

    So kann der Controller diese Klassen verwenden:

    [HttpPost]
    public ActionResult CreateCustomer(CustomerInfo customerInfo)
    {
        Result emailResult = Email.Create(customerInfo.Email);
        Result nameResult = CustomerName.Create(customerInfo.Name);
        if (emailResult.Failure)
            ModelState.AddModelError(“Email”, emailResult.Error);
        if (nameResult.Failure)
            ModelState.AddModelError(“Name”, nameResult.Error);
        if (!ModelState.IsValid)
            return View(customerInfo);
        Customer customer = new Customer(nameResult.Value, emailResult.Value);
        // Rest of the method
    }
    

    Ergebnisinstanzen und ErgebnisTeilen Sie uns ausdrücklich mit, dass die Create-Methode möglicherweise fehlschlägt. In diesem Fall können Sie den Grund durch Lesen der Error-Eigenschaft ermitteln.

    So sieht die Kundenklasse nach dem Refactoring aus:

    public class Customer
    {
        public CustomerName Name { get; private set; }
        public Email Email { get; private set; }
        public Customer(CustomerName name, Email email)
        {
            if (name == null)
                throw new ArgumentNullException(“name”);
            if (email == null)
                throw new ArgumentNullException(“email”);
            Name = name;
            Email = email;
        }
        public void ChangeName(CustomerName name)
        {
            if (name == null)
                throw new ArgumentNullException(“name”);
            Name = name;
        }
        public void ChangeEmail(Email email)
        {
            if (email == null)
                throw new ArgumentNullException(“email”);
            Email = email;
        }
    }
    

    Fast alle Schecks wurden auf E-Mail und Kundenname verschoben. Die einzige verbleibende Validierung ist eine Nullprüfung. Wir werden im nächsten Artikel sehen, wie wir es loswerden können.

    Was sind also die Vorteile, wenn man die Besessenheit von Primitiven loswird?

    • Wir schaffen die einzige maßgebliche Wissensquelle für jedes Problem, das durch unseren Code gelöst wird. Keine Vervielfältigung, nur sauberer und trockener Code.
    • Strengeres Typensystem. Der Compiler arbeitet für uns mit aller Macht: Jetzt ist es unmöglich, einer Eigenschaft vom Typ Email fälschlicherweise ein Objekt vom Typ CustomerName zuzuweisen. Ein solcher Code wird nicht kompiliert.
    • Eingabewerte müssen nicht überprüft werden. Wenn wir ein Objekt der Klasse Email oder CustomerName erhalten, können wir zu 100% sicher sein, dass es sich im richtigen Zustand befindet.

    Eine kleine Bemerkung. Einige Entwickler neigen dazu, primitive Typen während einer einzelnen Operation mehrmals zu "umbrechen" und "zu erweitern":

    public void Process(string oldEmail, string newEmail)
    {
        Result oldEmailResult = Email.Create(oldEmail);
        Result newEmailResult = Email.Create(newEmail);
        if (oldEmailResult.Failure || newEmailResult.Failure)
            return;
        string oldEmailValue = oldEmailResult.Value;
        Customer customer = GetCustomerByEmail(oldEmailValue);
        customer.Email = newEmailResult.Value;
    }
    

    Es ist am besten, benutzerdefinierte Typen in der gesamten Anwendung zu verwenden und sie nur dann für Grundelemente bereitzustellen, wenn sie über die Domäne hinausgehen. Beispielsweise werden sie in der Datenbank gespeichert oder in HTML gerendert. Versuchen Sie in Ihren Domänenklassen immer, benutzerdefinierte Typen zu verwenden. In diesem Fall ist der Code einfacher und besser lesbar:

    public void Process(Email oldEmail, Email newEmail)
    {
        Customer customer = GetCustomerByEmail(oldEmail);
        customer.Email = newEmail;
    }
    

    Einschränkungen


    Leider ist das Erstellen von Wrapper-Typen in C # nicht so einfach wie beispielsweise in F #. Dies wird sich wahrscheinlich in C # 7 ändern, wenn Mustervergleich und Datensatztypen auf Sprachebene implementiert sind. Bis dahin müssen wir uns mit der Ungeschicklichkeit dieses Ansatzes auseinandersetzen.

    Aus diesem Grund sind einige primitive Typen nicht zu verpacken. Beispielsweise kann der Typ "Geldbetrag" mit einer einzelnen Invariante, der besagt, dass der Geldbetrag nicht negativ sein kann, als normale Dezimalzahl dargestellt werden. Dies wird zu einer gewissen Verdoppelung der Validierungslogik führen, aber trotzdem wird dieser Ansatz auch langfristig eine einfachere Lösung sein.

    Verwenden Sie wie gewohnt den gesunden Menschenverstand und wägen Sie jeweils die Vor- und Nachteile von Entscheidungen ab.

    Fazit


    Mit unveränderlichen und nicht primitiven Typen kommen wir dem Entwurf von C # -Anwendungen in einem funktionaleren Stil näher. Im nächsten Artikel werden wir diskutieren, wie der "Minderung des Milliarden-Dollar-Fehlers" gelindert werden kann.

    Quellcode



    Andere Artikel in der Reihe



    Englische Version des Artikels: Functional C #: Primitive Obsession

    Jetzt auch beliebt: