Data Annotations in TypeScript

tldr;

Der Artikel zeigt, wie mit Hilfe von Dekoratoren in TypeScript die Validierungsinformationen an ein View-Modell gebunden werden können. Ein injizierbarer Dienst extrahiert diese Daten später und erstellt ein FormGroup-Objekt, das dazu dient, programmatisch die Validierungsinformationen an ein Formular weiterzureichen. Erreicht wird eine drastische Vereinfachung der Erstellung von Templates für Formulare. Der Code zum Artikel ist auf Github zu finden. Literatur, Quellen und Links stehen in Kürze am Ende des Artikels.

Neu — das komplette Projekt jetzt auf Github https://github.com/joergkrause/svogv und via npm https://www.npmjs.com/package/svogv verfügbar. Neu

Motivation

Bei der Entwicklung großer Applikationen ist ein modellgetriebener Ansatz äußerst hilfreich. Dabei beschreiben die Datenmodelle auf der jeweiligen Schicht so umfassend wie möglich die fachliche Domäne. Als ein Element, diese Informationen sprachlich auszudrücken, sind Annotationen.

In .NET lassen sich solche Elemente mit Attributen sehr gut hinzufügen. Es ist praktisch eine weitere Ebene der Beschreibung neben dem regulären Code. Das Einbinden von Metadaten unterstützt das Prinzip Separation of Concerns (SoC) und erleichtert die Automation in nachfolgenden Schichten.

Ein typischer Einsatzfall sind Validierungen. Dabei wird einer Eigenschaft eine Validierungsinformation mitgegeben. Die Beschreibung der Validierung erfolgt dann nicht im Formular oder der Serviceschicht, sondern im Modell.

public class Customer {

    [Required]
    [Maxlength(65)]
    public string Name { get; set; }

}

Aus Sicht des Programmierers erfolgt der Zugriff auf den Kundennamen intuitiv mit Customer.Name. Aus Sicht der Form, die eine validierende Ansicht bereitstellen muss, erfolgt der Zugriff auf die Metadaten über einen eigenen Abfragemechanismus. Zwei unabhängige Vorgänge sind mit zwei unabhängigen Techniken bedient worden — das SoC-Prinzip wurde erfüllt.

Annotation in JavaScript und TypeScript

Moderne Applikation sind zu einem großen Teil Client-getrieben. So finden im Client Frameworks wie Angular 2 Anwendung. Die Modelle werden dann zwangsläufig in JavaScript oder TypeScript geschrieben. In JavaScript sind Annotationen erst experimentell verfügbar, sind aber als sogenannte Decorations via TypeScript bereits heute nutzbar. Das bereits gezeigt Beispiel könnte nun folgendermaßen aussehen:

export class Customer {

    @Required()
    @Maxlength(65)
    public name : string;

}

Soweit, so gut. Während das in der .NET-Variante nun bereits funktioniert, sieht es in Angular 2 eher schlecht aus. Der Grund liegt darin, dass die Attribute aus dem Namensraum System.ComponentModel.DataAnnotations stammen und seit .NET 4.5 als Teil des Frameworks bereitgestellt werden.
In Angular 2 fehlt eine vergleichbare Bibliothek. Es ist also eine gute Gelegenheit, eine solche Erweiterung selbst zu erstellen und dabei mehr über das Validierungsschema von Angular 2 zu erfahren.

Validierung in Angular 2

Angular 2 bietet zwei grundlegenge Verfahren zum Validieren an: Auf Templates basierend oder auf Code basierend. Beide Verfahren schließen sich gegenseitig nicht völlig aus, es ist aber sinnvoll, sich generell für die eine oder andere Strategie zu entscheiden. Die vorlagenbasierte Vaidierung stammt aus dem Modul FormsModule und die codebasierte Version stammt aus ReactiveFormsModule. Üblicherweise werden immer beide importiert:

import { FormsModule, ReactiveFormsModule } from '@angular/forms';

Die Bindung erfolgt bidirektional und die Validierungen werden in HTML 5 beschrieben:


Der Name ist erforderlich

Dieses Verfahren ist direkt und gut lesbar. Es hat nur leider nichts mit unserem Modell zu tun. Die Angabe required und die Reaktion darauf mit name.valid usw. ist handgeschrieben. Dass ganze Prinzip wird bei größeren Anwendungen mühevoll, fehleranfällig und führt zu Inkonsistenzen, weil gleiche Modelle nicht zwingend dieselben Fehlertexte erzeugen.

Glücklicherweise gibt es eine codebasierte Version, die es leichter macht, die Validierung über die Komponente des Formulars zu erzeugen.


{{ customerForm.controls.name.messages.required }}

Hier fehlt nun die Bindung mit ngModel. Außerdem wird die Fehlermeldung der Form entnommen. Dazu wird bei dem Modul ReactiveFormsModule die Klasse FormBuilder benutzt. Über Konstruktorinjektion lässt sich diese bereitstellen:

constructor(public fb: FormBuilder) {   
    this.customerForm = fb.group({
      'Name' : [null, Validators.required]
    })
}

Der erste Wert des Arrays, mit dem das Formularelement beschrieben wird, ist der Standardwert. Danach folgt eine Liste der Validatoren. Die Fehlermeldung ist nicht Bestandteil dieser Angabe. Mit der codebasierten Form ist eine konsistente Darstellung sicher besser beherrschbar, bequem und weniger fehleranfällig ist es nicht.

Verbindung beider Welten

Das Anfangs bereits gezeigt Modell wäre nun ideal, um die Erstellung des Formbuilders zu automatisieren. Dazu müssen die Dekoratoren aber erstmal existieren. Dies müssen wir selbst machen, denn Angular 2 kennt diese Techniken nicht:

import { Required } from '../Decorators/val-required';
import { MaxLength } from '../Decorators/val-maxlength';

export class Customer {

    @Required('Der Name ist anzugeben')
    @Maxlength(65)
    public Name : string;

}

Benutzt werden hier Required und Maxlength.

Der Dekorator Required selbst kann nun folgendermaßen erstellt werden (in der Datei val-required.ts). Ich habe die Funktionen im Code in Kommentare geschrieben:

// Funktion zum Übergeben der Parameter - der Meldungstext ist optional
export function Required(msg?: string) {
    // Die Signatur eines Property-Dekorators 
    function requiredInternal(target: Object, property: string | symbol): void {
        // Rückgabe des internen Dekorators
        new requiredInternalSetup(target, property.toString(), msg);
    }
    // Rückgabe des Dekorators mit der erwarteten Signatur
    return requiredInternal;
}

class requiredInternalSetup {

    private _val: any;

    constructor(public target: any, public key: string, public msg?: string) {
        this._val = target[key];
        // Property löschen
        if (delete this.target[this.key]) {

            // Eine neue Property erstellen
            Object.defineProperty(this.target, this.key, {
                get: this.getter,
                set: this.setter,
                enumerable: true,
                configurable: true
            });

            // Eine Hilfsproperty zum Transport der Metadaten "IsRequired"
            Object.defineProperty(this.target, `__isRequired__${this.key}`, {
                get: function () { return true; },
                enumerable: false,
                configurable: false
            });

            // Eine Hilfsproperty zum Transport der Metadaten "Fehlermeldung"
            Object.defineProperty(this.target, `__errRequired__${this.key}`, {
                value: this.msg || `The field ${this.key} is required`,
                enumerable: false,
                configurable: false
            });
        }
    }

    // property getter
    getter(): any {
        return this._val;
    };

    // property setter
    setter(newVal: any) {
        this._val = newVal;
    };

}

Im Kern ist der Dekorator sehr einfach. Der Aufruf der dekorierten Eigenschaft wird gekapselt. Das ist nicht zwingend erforderlich, wenn in den Zweig nicht eingegriffen werden soll. Dann werden zwei weitere Eigenschaften hinzugefügt. Diese transportieren die Metadaten aus dem Dekorator in das dekorierte Objekt. Hier ist es die Information, dass „Required“ überhaupt benutzt wurde und dann die Fehlermeldung. Die Namen der Eigenschaften sind *__isRequired__Name* und *__errRequired__Name* für das konkrete Beispiel.

Auf diese Daten kann nun überall dort zugegriffen werden, wo das Modell auftaucht. Am besten geht das in Angular 2 mit einem Validierungsdienst, der via Dependency Injection leicht überall bereitgestellt werden kann.

Der Validierungsdienst

Was in der Form benötigt wird, ist klar. Die Beschreibung erfolgt durch ein Objekt vom Typ FormGroup. Es wäre also ideal, wenn sich der Dienst aus den Metadaten alle Informationen beschafft, um dieses Objekt fertig zu erstellen. Der nachfolgend gezeigt Dienst erledigt genau dies. Die einzige Methode build ist statisch und wird nur beim Erstellen einmalig aufgerufen. Als Parameter wird der FormBuilder und der Typ des Modells übergeben.

Erstellt wird eine Gruppe mit den Validatoren in valGroup. Diese Angabe wird von Angular konsumiert. Dann erfolgt noch die Erstellung einer Objektgruppe errGroup die die Fehlermeldungen enthält. Die Strukturen sind sich recht ähnlich, die Fehler werden nach Steuerelementen und Fehlertypen sortiert:

{
    "ctrlname": {
        "required": "Text für Required",
        "maxlength": "Text für Maxlength"
    }
}

So lässt sich der passende Text später bequem über controls.ctrlname.required etc. auslesen. Wir gehen hier davon aus, dass die Namen der Steuerelemente den Namen der Eigenschaften in der View-Modell-Klasse entsprechen. Dies ist nicht nur bequem, es sorgt auch für eine stringente Benennung und damit für Ordnung in den Formularen.

import { Injectable } from '@angular/core';
import { Validators, ValidatorFn, FormBuilder, FormGroup } from '@angular/forms';

@Injectable()
export class FormValidatorService {

    public static build(fb: FormBuilder, target: any): FormGroup {
        let valGroup = {};
        let errGroup = {};
        let validators = [];
        let errmsgs = {};
        for (let propName in target.prototype) {
            let isRequired = `__isRequired__${propName}` in target.prototype;
            if (isRequired) {
                errmsgs["required"] = target.prototype[`__errRequired__${propName}`];
                validators.push(Validators.required);
            }
            let hasMaxLength = `__hasMaxLength__${propName}` in target.prototype;
            if (hasMaxLength) {
                errmsgs["maxlength"] = target.prototype[`__errMaxLength__${propName}`];
                let maxLength = parseInt(target.prototype[`__hasMaxLength__${propName}`], 10);
                validators.push(Validators.maxLength(maxLength));
            }
            let hasMinLength = `__hasMinLength__${propName}` in target.prototype;
            if (hasMinLength) {
                errmsgs["minlength"] = target.prototype[`__errMinLength__${propName}`];
                let minLength = parseInt(target.prototype[`__hasMinLength__${propName}`], 10);
                validators.push(Validators.minLength(minLength));
            }
            let hasPattern = `__hasPattern__${propName}` in target.prototype;
            if (hasPattern) {
                errmsgs["pattern"] = target.prototype[`__errPattern__${propName}`];
                let pattern = target.prototype[`__hasPattern__${propName}`].toString();
                validators.push(Validators.pattern(pattern));
            }
            if (validators.length === 1) {
                valGroup[propName] = ['', validators[0]];
            }
            if (validators.length >= 1) {
                valGroup[propName] = ['', Validators.compose(validators)];
            }
            errGroup[propName] = errmsgs;
        }
        // Gruppe erzeugen
        let form = fb.group(valGroup);
        // Steuerelement erweitern
        for (let propName in errGroup) {
            form.controls[propName]["messages"] = errGroup[propName];
        }
        // Kann direkt benutzt werden
        return form;
    }
}

Das Auswerten der Eigenschaften erfolgt mit einer Schleife. Die Auflistung enthält dabei nur solche Eigenschaften, die bei Erzeugen mit der Bedingung enumerable: true erzeugt wurden. Für alle normalen Eigenschaften trifft dies zu. Für die selbst definierten, die im Dekorator mit Object.createProperty erstellt wurden, trifft dies nicht zu, weil hier explizit enumerable: false geschrieben wurde. So stören die Metadaten-Eigenschaften den normalen Ablauf nicht.

Die folgende Zeile fragt nun die Existenz einer Metadaten-Eigenschaft ab:

let isRequired = `__isRequired__${propName}` in target.prototype;

Ist diese vorhanden, wird im folgenden Schritt auf weitere Eigenschaften zugegriffen, wie beispielsweise den Meldungstext oder bei der maximalen Länge der Wert, der an Angular weitergereicht werden muss.

Verbesserungsmöglichkeiten

Da es kein eingebautes Weg gibt, zusätzliche Nachrichten an die Form-Steuerelemente zu übertragen, tricksen wir TypeScript ein wenig aus, und nutzen die Index-Notation für zusätzliche Eigenschaften (obj["prop"]). Wenn Ihnen das nicht gefällt, können Sie auch ein Ableitung von FormControl nutzen, wie nachfolgend gezeigt:

import { FormControl } from '@angular/forms';

export class FormControlEx extends FormControl {
    messages: {};
}

Die Zuweisung könnte dann ein wenig eleganter wie folgt geschrieben werden:

(form.controls[propName]).messages = errGroup[propName];

Auf das Ergebnis hat dies keinen Einfluss, lediglich der TypeScript-Code sieht etwas weniger nach klassischen JavaScript aus.

Nutzung

Die Nutzung wurde ja bereits am Anfang kurz angedeutet:


{{ customerForm.controls.name.messages.required }}

Hier wird jetzt klar, wo der Meldungstext herkommt und warum im HTML selbst nun keine Validierungsinformationen mehr hinterlegt werden müssen. Die einzige, wirklich wichtige Bedingung ist hier, dass das Feld selbst exakt den Namen der Eigenschaft im View-Modell haben muss. Hier ist dies name. Das dynamische Erstellen der Vorlagen wäre der nächste Schritt hin zu automatischen Formularen.

Der Vollständigkeit halber hier noch die Dekoratoren für die maximale Länge, minimale Länge und die Prüfung von Eingaben mit regulären Ausdrücken.

export function MaxLength(len: number, msg?: string) {
    // the original decorator
    function maxLengthInternal(target: Object, property: string | symbol): void {
        new maxLengthInternalSetup(target, property.toString(), len, msg);
    }

    // return the decorator
    return maxLengthInternal;
}

class maxLengthInternalSetup {

    private _val: any;

    constructor(public target: any, public key: string, public len: number, public msg?: string) {
        // property value
        this._val = this.target[this.key];
        // Delete property.
        if (delete target[key]) {

            // Create new property with getter and setter and meta data provider
            Object.defineProperty(target, key, {
                get: this.getter,
                set: this.setter,
                enumerable: true,
                configurable: true
            });

            // create a helper property to transport a meta data value
            Object.defineProperty(target, `__hasMaxLength__${key}`, {
                value: this.len,
                enumerable: false,
                configurable: false
            });

            Object.defineProperty(target, `__errMaxLength__${key}`, {
                value: this.msg || `The field ${this.key} has max length of ${this.len} characters`,
                enumerable: false,
                configurable: false
            });

        }
    }

    // property getter
    getter() : any {
        return this._val;
    };

    // property setter
    setter (newVal: any) {
        this._val = newVal;
    };

}
export function MinLength(len: number, msg?: string) {
    // the original decorator
    function minLengthInternal(target: Object, property: string | symbol): void {
        new minLengthInternalSetup(target, property.toString(), len, msg);
    }

    // return the decorator
    return minLengthInternal;
}

class minLengthInternalSetup {

    private _val: any;

    constructor(public target: any, public key: string, public len: number, public msg?: string) {
        // property value
        this._val = this.target[this.key];
        // Delete property.
        if (delete target[key]) {

            // Create new property with getter and setter and meta data provider
            Object.defineProperty(target, key, {
                get: this.getter,
                set: this.setter,
                enumerable: true,
                configurable: true
            });

            // create a helper property to transport a meta data value
            Object.defineProperty(target, `__hasMinLength__${key}`, {
                value: this.len,
                enumerable: false,
                configurable: false
            });

            Object.defineProperty(target, `__errMinLength__${key}`, {
                value: this.msg || `The field ${this.key} has minimum length of ${this.len} characters`,
                enumerable: false,
                configurable: false
            });

        }
    }

    // property getter
    getter() : any {
        return this._val;
    };

    // property setter
    setter (newVal: any) {
        this._val = newVal;
    };

}

Und hier nun der Dekorator für die regulären Ausdrücke:

export function Pattern(pattern: RegExp, msg?: string) {
    // the original decorator
    function patternInternal(target: Object, property: string | symbol): void {
        new patternInternalSetup(target, property.toString(), pattern, msg);
    }

    // return the decorator
    return patternInternal;
}

class patternInternalSetup {

    // property value
    private _val : any;

    constructor(public target: any, public key: string, public reg: RegExp, public msg ?: string){
        this._val = this.target[this.key];
        // Delete property.
        if (delete target[key]) {

            // Create new property with getter and setter and meta data provider
            Object.defineProperty(this.target, this.key, {
                get: this.getter,
                set: this.setter,
                enumerable: true,
                configurable: true
            });

            // create a helper property to transport a meta data value
            Object.defineProperty(this.target, `__hasPattern__${key}`, {
                value: this.reg,
                enumerable: false,
                configurable: false
            });

            Object.defineProperty(this.target, `__errPattern__${key}`, {
                value: this.msg || `The field ${this.key} must fullfill the pattern ${this.reg}`,
                enumerable: false,
                configurable: false
            });
        }
    }


    // property getter
    getter() : any {
        return this._val;
    };

    // property setter
    setter(newVal: any) {
        this._val = newVal;
    };

}

Weitere Dekoratoren könnten für folgende Ausgaben zuständig sein:

  • Bereiche, bei zahlen beispielsweise von 1 bis 100
  • Vergleiche, zwei Eingabefelder müssen übereinstimmen
  • E-Mail
  • Kreditkarten (da gibt es Muster für eine Vorab-Prüfung)
  • Telefonnummern
  • Postleitzahlen
  • Zahlen
  • Datumsangaben

Ebenso ist eine Einbindung eines CustomValidators denkbar, der die Fähigkeit von Angular benutzt, eine benutzerdefinierte Funktion beim Validieren auszuführen. Dann könnte die Prüfung sogar gegen eine Datenbank erfolgen. In jedem Fall ist der Entwickler des HTML-Teils weitgehend von solchen Modell-spezifischen Aktionen verschont.

Freilich lassen sich nun weitere Funktionen steuern, indem weitere Dekoratoren „erfunden“ werden. Hier ein paar Anregungen:

export class SearchQueryViewModel {

    @Required("The Name must be provided")
    @MaxLength(55, "Only 55 characters allowed")
    name: string;

    @Required("The Query must be provided")
    @Custom(QueryCheckValidator)
    @UIHint(UIHInts.TextArea)
    @AdditionalData('cols', 7)
    @AdditionalData('rows', 80)
    query: string;

    @Range(1, 100)
    area: number

    @UIHint(UIHints.DropDown)    
    users : Array;

    @ScaffoldColumn(false)
    @UIHint(UIHints.Hidden)
    id: number;

}

Fazit

Die Nutzung der Dekoratoren erleichtert erheblich die Wartung und Pflege von Applikationen. Sie müssen nun bei Anpassungen nur wenig ins HTML eingreifen und Meldungen sind immer konsistent. Das passt freilich nicht immer, aber getreu dem 80:20-Prinzip werden Sie damit 80% ihrer Formulare leichter erstellen und sich auf die 20% Exoten konzentrieren können.

Dekoratoren sind ein spannendes und mächtiges Programmiermittel, welches Angular 2 bereits umfassend einsetzt. Der Nutzung in eigenem Code steht also nichts entgegen. Das die Bezeichnung vom TypeScript-Transpiler als „experimental* klassifiziert wird, sollte nicht abschrecken. Bei derart umfassender Nutzung ist nicht zu erwarten, dass diese Sprachmerkmal bei der weiteren Entwicklung entfällt oder drastisch geändert wird. Es ist eher zu erwarten, dass die Dekoratoren weiteren Ausbau bekommen und künftig noch mächtiger werden.

Die hier gezeigte Version nutzt Eigenschaften und dekoriert diese. Die Dekoratoren können aber auch auf Klassen, Methoden und auf den Parametern von Methoden eingesetzt werden. Dies erlaubt eine noch umfassendere Nutzung der Modell-Klassen und weitere Automationstechniken.

Automatisierte Validierung in Angular 2
Markiert in: