IT Security – Customized Security mit IdentityServer

Im ersten Teil dieses Themenschwerpunktes wurde eine grundlegende Problematik in der Softwareentwicklung aufgezeigt: Informatiker sind nicht notwendigerweise IT-Security-Experten und IT Security ist kein nebenläufiger Aspekt der Software-Entwicklung, den man mehr oder weniger dem Zufall überlassen kann. In diesem zweiten Teil soll IdentityServer vorgestellt werden, ein Framework, mit dem wir bei SDX im Einsatz für den Kunden gute Erfahrungen gemacht haben.

IdentityServer

IdentityServer ist ein von Dominick Baier and Brock Allen gegründetes und weitergepflegtes OpenSource-Projekt, das im Wesentlichen aus einem Framework für die Authentifizierung und Autorisierung mittels OAuth 2.0  und OpenID für ASP.NET-Core-Anwendungen besteht. Ein charakteristisches Merkmal von IdentityServer ist, dass es nicht nach dem Werkzeugkastenprinzip funktioniert, sondern bereits funktionierende Workflows bereitstellt, in die man gezielt eingreifen kann, um den jeweiligen Business-Anforderungen gerecht zu werden. Das zugrundeliegende Prinzip ist die konsequente Nutzung der in ASP.NET Core üblichen Dependency Injection.

Ein typischer Workflow sieht wie folgt aus:

  • Ein Benutzer möchte mittels einer URL auf eine geschützte Ressource zugreifen
  • Der Server, der die Ressource verwaltet (im Folgenden „Ressourcenserver“ genannt), stellt fest, dass der Benutzer nicht authentifiziert ist, also seine Identität noch nicht nachgewiesen hat.
  • Der Benutzer wird auf eine Login-Seite umgeleitet, die sich entweder auf dem Server befinden kann oder von einem externen Anbieter (z. B. Microsoft, Google oder einem separaten, unternehmenseigenen Server) bereitgestellt wird.
  • Wenn der Benutzer sich erfolgreich eingeloggt hat, erstellt der Server für ihn ein Token (üblicherweise im JWT-Format), das nicht nur für die Authentifizierung des Benutzers genutzt werden kann, sondern weitere Informationen zum Benutzer enthält.
  • Die Sicherheit des Tokens gegen Fälschungen basiert auf einer digitalen Signatur mit einem kryptografischen Schlüssel, deren Authentizität und Integrität der Ressourcenserver mittels eines Zertifikats überprüfen kann.
  • Der Ressourcenserver kann nun anhand der Informationen aus dem Token entscheiden, ob der Benutzer die für den Zugriff auf die Ressource gewünschten Anforderungen erfüllt, also dafür autorisiert ist.

Der Inhalt eines JWT-Tokens könnte folgende Gestalt haben:

{
  "nbf": 1557136178,
  "exp": 1557143378,
  "iss": "https://my-product-site-site.net",
  "aud": [
    "https://my-product-site-site.net/resources",
    "my-product"
  ],
  "amr": [
    "pwd"
  ],
  "client_id": "my-product-client",
  "sub": "0fd314a1-2c33-492a-827d-b540afa0e86e",
  "auth_time": 1557136178,
  "idp": "local",
  "role": [
    "user",
    "operator"
  ],
  "given_name": "Annalena",
  "family_name": "Schmidt",
  "email": "annalena.schmidt@my-product-site-site.net",
  "email_verified": true,
  "scope": [
    "openid",
    "offline_access",
    "customer_service"
  ]
}

Claims und Role-Based Security

Für fast alle diese Informationen gibt es standardisierte Claim-Arten. Das „sub“-Claim enthält z. B. typischerweise eine technische Benutzer-ID und „email_verified“ gibt an, dass der Benutzer die hinterlegte Emailadresse bestätigt hat. Besonders nützlich sind auch die im „role“-Claim enthaltenen Werte. Im Beispiel hat der Benutzer die Rollen „user“ und „operator“. Dieser Umstand lässt sich nutzen, um Endpunkte auf dem Server auf einfache Weise abzusichern.

Die auf dem Ressourcen-Server aufgerufene Komponente könnte in ASP.NET Core z. B. so aussehen:

[Authorize]
public IActionResult Index()
{
	// ...
}

[Authorize(Roles="operator, admin")]
public IActionResult GetCustomerInfo()
{
	// ...
}

[Authorize(Roles="admin")]
public IActionResult DeleteCustomer()
{
	// ...
}

Bei entsprechender Konfiguration des Servers reicht dieses Authorize-Attribut bereits aus, um den Zugriff auf den jeweiligen Endpunkt zu beschränken. Auf „Index()“ dürfen alle authentifizierten Benutzer unabhängig von ihrer Rolle zugreifen, während für den Zugriff auf „GetCustomerInfo()“ zusätzlich die Rolle „operator“ und/oder „admin“ erforderlich ist. „DeleteCustomer“ ist nur für Benutzer mit der Rolle „admin“ aufrufbar. Im Fehlerfall liefert der Server den HTTP-Statuscode 401 („Unauthorized“) zurück.

Selbstverständlich benötigt man für eine solche einfache rollenbasierende Autorisierung kein zusätzliches Framework wie IdentityServer. Was aber wäre, wenn als zusätzliche Business-Anforderung dazu käme, dass bestimmte Benutzer „GetCustomerInfo()“ und andere Methoden, die sensible Kundendaten zurückliefern, nur innerhalb ihrer offiziellen Arbeitszeit abrufen können sollen?

Customizing

In solchen Situationen erweist es sich von Vorteil, wenn man keinen in sich geschlossenen Authentifizierungs- und Autorisierungsserver verwendet, sondern die volle Kontrolle über die entsprechenden Prozesse hat.

In der Regel wird man eine Datenbank oder ein wie auch immer aufgebautes Verzeichnis haben, in der der Benutzer mit der technischen ID „0fd314a1-2c33-492a-827d-b540afa0e86e“ (Annalena Schmitt) mit den Rollen „user“ und „operator“ verknüpft ist.

Um das beschriebene Ziel zu erreichen, könnte man den Rollennamen ändern (z. B. von „operator“ in „limited-op“), ohne die „Authorize“-Attribute anzupassen. Dies würde zunächst dazu führen, dass Annalena Schmitt (vgl. Inhalt des beispielhaften JWT-Tokens) und andere Operatoren, die nicht zusätzlich die Rolle „admin“ haben, gar nicht mehr auf Methoden wie „GetCustomerInfo()“ zugreifen können. Im zweiten Schritt würde man die IdentityServer-Infrastruktur auf Seiten des Authentifizierungs- und Autorisierungsservers mittels Dependency Injection verändern bzw. erweitern, z. B. in der Datei „Startup.cs“ mit der Zeile:

services.AddScoped<IUserClaimsPrincipalFactory<IdentityExpressUser>, CustomUserClaimsPrincipalFactory>();

Die neue Komponente „CustomUserClaimsPrincipalFactory” könnte von der IdentityServer-Komponente abgeleitet worden sein, die sie später ersetzt (verkürzte Darstellung):

public class CustomUserClaimsPrincipalFactory : IdentityExpressUserClaimsPrincipalFactory<IdentityExpressUser, IdentityExpressRole>
{
    protected override async Task<ClaimsIdentity> GenerateClaimsAsync(IdentityExpressUser user)
    {
        var claimsIdentity = await base.GenerateClaimsAsync(user);
        var claims = user.Claims.ToList();
        var limitedOperatorRole = user.Claims.FirstOrDefault(x => x.ClaimType == JwtClaimTypes.Role && x.ClaimValue == "limited-op");

        if (limitedOperatorRole != null && DetermineIsActiveOperator(user))
        {
            claimsIdentity.AddClaim(new Claim(JwtClaimTypes.Role, "operator"));
        }

        return claimsIdentity;
    }

    private bool DetermineIsActiveOperator(IdentityExpressUser user)
    {
       // Implementation Details ...
    }
}

In der Methode „DetermineIsActiveOperator“ würde dann ermittelt werden, ob die Authentifizierung innerhalb der Arbeitszeiten des Operators stattfindet. In diesem Fall würde die Rolle „operator“ dann dynamisch hinzugefügt. Weitere denkbare Anpassungen wären das Verbergen des „limited-op“-Role-Claims, sofern man dieses nicht für Log-Zwecke erhalten möchte, sowie eine geeignete Verkürzung der Lebensdauer des Tokens, da die Arbeitszeiten bei diesem Ansatz nur im Moment der Authentifizierung berücksichtigt werden würden.

Wie man erkennen kann, sind solche Anpassungen mit wenigen Code-Zeilen möglich, ohne dass in den restlichen Authentifizierungs- und Autorisierungsworkflow eingegriffen wird. Die Herausforderung für einen Software-Entwickler besteht hauptsächlich darin, die richtige Stelle in der IdentityServer-Infrastruktur zu identifizieren, nicht aber den Workflow in allen Feinheiten verstehen zu müssen.

Trotzdem kann natürlich eine kritische Masse an business-spezifischen Anforderungen erreicht werden, ab der es sinnvoll ist, einen IT-Security-Experten hinzuziehen. Gleiches gilt für die Authentifizierung und Autorisierung in heterogenen Umgebungen mit voneinander abweichenden Sicherheitsanforderungen, z. B. Workflows, die gleichzeitig von einer mobilen App und einer Web-Applikation genutzt werden.

Fazit

IdentityServer bietet eine standardisierte, ständig weiterentwickelte Basis für die Abbildung von benutzerspezifischen Authentifizierungs- und Autorisierungsprozessen, die so mit schlüsselfertigen Identity-Provider-Lösungen nicht abgedeckt werden könnten. Statt den gesamten Prozess in Eigenregie neu zu entwickeln, was das Risiko von Sicherheitslücken birgt, müssen nur einzelne Komponenten des vorgegebenen Prozesses erweitert oder ersetzt werden.

 

IT Security – Das Bewusstsein schärfen

Security VogelstraußprinzipQuelle Bild: gemeinfrei

Jährlich entstehen deutschen Unternehmen durch Angriffe auf IT-Systeme Schäden im zweistelligen Milliardenbereich. Achim Berg, der Präsident des Branchenverbandes Bitkom, warnt: „Mit ihren Weltmarktführern ist die deutsche Industrie besonders interessant für Kriminelle“.  Indessen stellt das Bundesamt für Sicherheit in der Informationstechnik (BSI) eine Häufung von „komplexen Cyber-Vorfällen“ fest und rät zu regelmäßigen Mitarbeiterschulungen.

Und doch gibt es eine Gruppe von Beteiligten, bei denen oft stillschweigend davon ausgegangen wird, dass sie bei IT Security schon wissen, was sie tun: Informatiker.

Informatiker = IT-Security-Spezialist?

Woher dieser Vertrauensvorschuss kommt, ist schwer zu sagen. In einem typischen Informatik-Studium an einer deutschen Universität oder Fachhochschule lassen sich die verpflichtenden Lehrveranstaltungen mit entsprechenden Inhalten an einer Hand abzählen – wenn nicht sogar an 1-2 Fingern. Erschwerend kommt hinzu, dass IT Security zu den am wenigsten sichtbaren Aspekten einer Software gehört und oft erst dann in den Mittelpunkt des Interesses rückt, wenn es damit ein offensichtliches Problem gibt. Am Gravierendsten könnte aber sein, dass eine den Erwartungen gerecht werdende Auseinandersetzung mit dem Thema IT Security ein Vorgehen erfordert, das sich grundlegend von der typischen Arbeitsweise eines Software-Entwicklers unterscheidet.

Komplexe Software wird in Teamarbeit entwickelt, bei der größere Herausforderungen in Teilaufgaben zerlegt werden, die von einzelnen Personen oder Unterteams gelöst werden können. Hierbei erweist es sich als besonders effizient, möglichst wenige Annahmen über die interne Funktionsweise von Software-Komponenten zu treffen, an deren Entwicklung man nicht direkt beteiligt ist. Diese Betrachtungsweise ist eng mit dem als Qualitätsmerkmal geltenden Prinzip des „Loose Coupling“ verbunden, das auf die Minimierung von Abhängigkeiten zwischen Software-Komponenten abzielt.

Bei allen offensichtlichen Vorteilen führt diese Form des Arbeitens in der Praxis oft dazu, dass sich die an dem Projekt beteiligten Entwickler nur für jene Teile einer Software verantwortlich fühlen, zu denen sie direkt beigetragen haben. Nicht selten werden dabei Annahmen über die restliche Software getroffen, die zu einem späteren Zeitpunkt nicht mehr zwingend gegeben sein müssen. Wenn beispielsweise ein für eine Desktop-Anwendung konzipierter Login-Mechanismus ohne Anpassungen in einer App für Mobilgeräte genutzt wird, könnten aufgrund unterschiedlicher Schutzmechanismen z. B. sensible Informationen wie Authentifizierungstoken in die Hände eines Angreifers gelangen.

IT-Security als Hauptaspekt

Es ist daher unerlässlich, dass es Projektbeteiligte gibt, die sich kontinuierlich einen Gesamtüberblick über die Software und ihre Verwendung verschaffen – auch und vor allem in Hinblick auf sicherheitskritische Aspekte. Nicht selten ist hierfür eine einzige Person zuständig, die z. B. als Software-Architekt noch viele anderen Aufgaben und Verantwortlichkeiten im Projekt hat. Mit wachsender Komplexität und durch äußerliche Faktoren, von personellen Veränderungen bis hin zu kurzfristig umzusetzenden neuen Anforderungen, kann dieses Modell schnell an seine Grenzen stoßen. Eine zusätzliche Herausforderung ergibt sich daraus, dass viele Softwareprojekte nicht ständig weiterentwickelt, sondern irgendwann „in Produktion“ übergeben werden. Dieser Schritt bringt es fast immer mit sich, dass bisher verantwortliche Personen aus dem Projekt ausscheiden. Spätestens zu diesem Zeitpunkt wird es schwierig bis unmöglich, auf neue oder bis dahin nicht als gravierend eingestufte Situationen mit IT-Security-Bezug angemessen zu reagieren.

Zusammengenommen machen diese Überlegungen deutlich, dass die Auffassung von IT Security als nebenläufiger Aspekt, mit dem alles zum Besten steht, wenn alle nur ordentlich ihre Arbeit machen, zu IT Security nach dem Vogelstrauß-Prinzip führt: Man hat das Thema gelegentlich angesprochen und jeder der Beteiligten meint, getan zu haben, was er im Rahmen seiner Aufgaben und Möglichkeiten dafür tun konnte. Aber im Prinzip bestand die Berücksichtigung von IT-Security-Aspekten am Anfang im grenzenlosen Vertrauen in die Fähigkeiten der beteiligten Software-Entwickler und später im grenzenlosen Vertrauen in die Fähigkeit einzelner Personen, stets den Überblick zu behalten.

Fazit

Durch Mängel in der IT-Sicherheit entstehen jedes Jahr enorme materielle und immaterielle Schäden. Trotzdem ist das Thema IT-Security selbst bei Informatikern nicht im Mittelpunkt, sondern wird bei der Softwareentwicklung oft als Nebensache betrachtet. Häufig fehlt auch die erforderliche IT-Security-Kompetenz. Dies führt dazu, dass es in der Regel niemanden im Entwicklungsteam gibt, der die Hauptverantwortung für diesen Bereich übernimmt. IT-Security muss deshalb während des Lebenszyklus der Software künftig stärker in den Fokus gerückt werden.

Aktuelle ASP.NET-Entwicklung im Enterprise-Umfeld

Wer heutzutage mit der Entwicklung einer Web-Anwendung beginnen möchte, hat die Qual der Wahl: Dutzende teils diametral entgegengesetzte Konzepte und viele verschiedene Frameworks wollen ausprobiert und gegeneinander abgewogen werden. Doch irgendwann kommt der Punkt, an dem es eine Entscheidung zu treffen gilt. Wenn es aber nicht um einen softwaretechnischen Versuchsballon, sondern um eine reale Anwendung im Enterprise-Umfeld geht, ist diese Entscheidung gerade für Microsoftaffine im Moment nicht so einfach.

ASP.NET 5 bringt viele Änderungen

Die in Q1 2016 anstehende Veröffentlichung von ASP.NET 5 bringt nicht nur ein paar kosmetische Verbesserungen. Es werden in großem Stil alte Zöpfe abgeschnitten. Der bekannte .NET-Entwickler und Autor Stephen Walter schreibt hierzu in seinem Blog:

"ASP.NET 5 is the most significant new release of ASP.NET in the history of the ASP.NET framework — it has been rewritten from the ground up."

Drei der größten Änderungen sind:

  • ASP.NET WebForms ist tot. Es wird in Zukunft nur noch ASP.NET MVC geben
  • ASP.NET MVC, Web API und SignalR verschmelzen zu einer Plattform (ASP.NET MVC 6)
  • ASP.NET Hosting wird künftig auch auf Maschinen mit Linux und OS X möglich sein.?

Zum Zeitpunkt der Veröffentlichung dieses Beitrags befindet sich ASP.NET 5 noch in der Beta-Phase, dürfte aber inzwischen im Wesentlichen „feature complete" sein. Der erste Release Candidate (RC1) ist für November 2015 angekündigt und soll noch einige Stabilisierungen bringen.

Nun könnte man argumentieren, dass der Reifegrad fortgeschritten genug ist, um eine neue Software gleich auf der neuen Plattform zu entwickeln. Die Vorteile liegen auf der Hand:

  • Die beteiligten Entwickler eignen sich Kenntnisse zu aktuellen Technologien an, mit denen sie sich in den meisten Fällen früher oder später ohnehin beschäftigen werden müssen.
  • Die Software bekommt einen modernen Unterbau anstatt auf veraltete Technik aufzusetzen
  • Etwaige Migrationsaufwände reduzieren sich in den kommenden Jahren erheblich.

Warum es oft noch etwas dauern wird

Was könnte also dagegen sprechen? Zum einen ist der produktive Einsatz von Vorabversionen im Enterprise-Umfeld vielfach nicht möglich. Software-Hersteller sichern sich für diesen Fall üblicherweise mit Ausschlussklauseln oder gar mit Verboten ab. Anderseits nimmt die Entwicklung von Enterprise-Software in der Regel einige Zeit in Anspruch, sodass im Moment der Produktivnahme wahrscheinlich keine Beta-Version mehr zum Einsatz kommen muss. Es könnten aber Zusatzaufwände entstehen, falls es zwischen dem RC1 und der finalen Version in einzelnen Bereichen noch Änderungen geben sollte. Auch wenn diese Änderungen in den meisten Fällen überschaubar bis nicht vorhanden sind, ist das ein weiterer Konjunktiv, der die Verwendung einer vom Hersteller noch nicht freigegebenen Version schwierig macht.

Im konkreten Fall von ASP.NET 5 gibt es außerdem eine weitere Randbedingung: Die gewohnte Tool-Unterstützung setzt Visual Studio 2015 voraus, es müssen also entsprechende Lizenzen vorhanden sein. Aufgrund der zahlreichen Änderungen gegenüber der Vorgängerversion ist die Unterstützung älterer Versionen von Visual Studio äußerst fraglich. Der plattformübergreifende Ansatz von ASP.NET 5 bringt es zwar mit sich, dass in Zukunft häufiger als bisher alternative Entwicklungsumgebungen (z. B der Atom-Texteditor mit OmniSharp) zum Einsatz kommen werden. Im Enterprise-Bereich ist Visual Studio für die Microsoft-Plattform aber so gut wie immer gesetzt.

Welche Optionen hat man also, wenn sich der Pre-Release-Status von ASP.NET 5, die Abhängigkeit von der 2015er-Version von Visual Studio oder etwas anderes als Showstopper erweist? Auch wenn es im Einzelfall auf die jeweiligen Randbedingungen ankommt, halte ich es für zu kurzsichtig, die Situation im Vorfeld der neuen Version von ASP.NET einfach auszublenden und stur auf ASP.NET 4 bzw. ASP.NET MVC 5 zu setzen. Die Evolution der Web-Entwicklung schreitet in rasantem Tempo voran und macht vor der Software, die man heute entwickelt, nicht Halt. Auf Technologien zu setzen, die zum Zeitpunkt des Projektstarts bereits überholt sind, führt absehbar dazu, dass die Entwickler sich mit den Problemen von gestern beschäftigen müssen, anstatt die Herausforderungen von morgen zu meistern. Auf Dauer wird eine solche Konstellation auch und vor allem eines sein, nämlich ein erheblicher Kostentreiber.

Mögliche Lösungen

Es gibt verschiedene Wege aus diesem Dilemma. Ein Weg könnte darin bestehen, verstärkt auf JavaScript-Frameworks zu setzen und den Migrationsaufwand dadurch überschaubar zu halten, dass man ASP.NET MVC quasi nur noch als Hosting-Plattform verwendet. Die Auswahl der geeigneten JavaScript-Frameworks ist aber keineswegs trivial. Hierbei gibt es Kriterien zu berücksichtigen wie den Reifegrad eines Frameworks, die Unterstützung verschiedener Browser und Gerätetypen, die Qualität der Dokumentation, die Abhängigkeit oder Unabhängigkeit von bestimmten Herstellern, die Möglichkeit kommerziellen Supports usw. Vor allem sind aber die JavaScript-Frameworks, in eher noch höherem Maße als ASP.NET, den turbulenten Entwicklungen im Web-Umfeld ausgesetzt. Auch bei den aktuell populärsten Frameworks kann man bestenfalls erahnen, ob diese in zwei Jahren noch regelmäßig aktualisiert und verbessert werden. Ein Beispiel aus der jüngeren Vergangenheit ist AngularJS, das sich schon fast zum Quasi-Standard für Single-Page Applications entwickelt hatte, bevor dieser Zustand beinahe über Nacht mit einem großen Fragezeichen versehen wurde, als zahlreiche nicht-rückwärtskompatible Änderungen für die nächste Version angekündigt wurden.

Enterprise-Anwendungen werden fast immer über einen Zeitraum von mehreren Jahren betrieben, manchmal sogar deutlich über die ursprünglich geplante Lebensdauer hinaus. Deswegen gelten für Enterprise-Anwendungen höhere Ansprüche in Sachen Stabilität und Kontinuität, als für die volatilen Web-Plattformen, in deren Umfeld die meisten JavaScript-Frameworks entstanden sind. Entscheidend ist zum einen die Konzeption einer für den Einsatzzweck optimal geeigneten Software-Architektur. Zum anderen sind die zu verwendenden Technologien und Frameworks mit Bedacht auszuwählen und zwar so, dass man sich von einzelnen Komponenten bei Bedarf auch wieder trennen kann. Das entspricht im Grunde dem Prinzip der losen Kopplung, einem in der Software-Entwicklung seit Langem bewährten Entwurfsmuster.

Übertragen auf die aktuelle Situation im ASP.NET-Umfeld bedeutet das, dass man in den nächsten Monaten am meisten davon hat, wenn man seine Schwerpunkte in Bereichen setzt, die von den Neuerungen durch ASP.NET 5 wenig bis gar nicht betroffen sind. Für die Projektplanung kann das bedeuten, dass man reine Frontend-Features und reine Backend-Features tendenziell vorziehen sollte und Themen, die sich durch mehrere Bereiche ziehen (cross-cutting concerns), tendenziell eher nach hinten schieben sollte. Dann ist man in der Lage, ein heute auf der Basis von ASP.NET 4 begonnenes Projekt ohne große Migrationsaufwände auf Basis von ASP.NET 5 fortzusetzen.

Diagnose: Akute Frameworkeritis

Es ist noch nicht so lange her, da war JavaScript für viele .NET-Entwickler ein eher unappetitliches Nebenprodukt von ASP.NET WebForms. Die Ursünde von JavaScript steht in einer Reihe mit der Darreichung des verbotenen Apfels vom Baum der Erkenntnis: JavaScript ist nicht streng typisiert und bietet Freiheiten, nach denen der .NET-Entwickler nie gefragt hat. Obendrein beschleicht ihn schon nach seiner ersten Zeile JavaScript-Code das ungute Gefühl, sich einen nicht zu testenden Wartungsalptraum eingehandelt zu haben, der ihn bis in den Schlaf verfolgen wird.

Nicht viel anders verhält es sich mit HTML und CSS, die dem gestandenen .NET-Entwickler wie die hässliche Nachgeburt des Internetzeitalters erscheinen. Damit konstruierte Seiten besitzen nach seiner Erkenntnis die Unart, sich prinzipiell völlig anders als gedacht darzustellen – und das sowieso noch einmal unterschiedlich in jedem Browser, wenn nicht gar in jeder Browserunterversion.

Umso deprimierender mag es für den .NET-Entwickler sein, dass dieses Konglomerat aus JavaScript und HTML/CSS, welches aus seiner Sicht längst zurecht von der Bildfläche hätte verschwinden sollen, sich auf eben dieser Bildfläche breitmacht wie kein anderer Technologie-Stack. Was soll der .NET-Entwickler tun, wenn er von seinem Arbeitgeber oder Kunden zu dem aberwitzigen Unterfangen getrieben wird, ausgerechnet in den Giftschrank der clientseitigen Web-Alchemie zu greifen, um eine Software von nennenswerter Stabilität zu entwickeln?

"Das Blau ist mir nicht blau genug", schreibt mein Kollege Sven Martens. Ach, wenn es denn nur das Blau wäre! Manch ein Backend-affiner .NET-Entwickler, der bislang erfolgreich einen Bogen um das ganze "JavaScript-Geraffel" gemacht hatte, sieht bei seinen ersten, von leidenschaftlicher Verachtung begleiteten Schritten in der bunten Welt der vermeintlich modernen Web-Technologien vor allem Rot.

Rettung ist in Sicht

Doch unser .NET-Entwickler ist längst nicht der einzige, der den Webtechnologie-Wildwuchs am liebsten mit Stumpf und Stiel ausreißen würde. Kluge Köpfe in aller Welt haben Ideen und Konzepte entwickelt, um diesen auf ein erträgliches Maß zurecht zu stutzen, bis JavaScript zumindest rudimentär einer höheren Programmiersprache ähnelt. Typisierte Variablen müssen her, Vererbungshierarchien für dies und das, Model Bindings in alle erdenklichen Richtungen, eine Art querybasierende Abfragesprache, Code-Beautifier, Code-Minifier, Paketierer und so weiter.

Mehrmals in der Woche läuft der Arbeitsrechner heiß, wenn die neuesten Schmerzlinderungen über NuGet ihren Weg ins Visual-Studio-Projekt finden – oder zumindest ein Teil davon, beim Rest muss man dann noch selbst etwas Hand anlegen. Aber das ist ja kein Problem, man trägt sich einfach kurz in eine Mailing-Liste ein und schon wird man informiert, wann der nächste Nightly Build zum Download bereitsteht. Ab und zu – aber wirklich nur ganz selten – mag es vorkommen, dass ein Contributer aus Pakistan, Kirgisien oder der sächsischen Schweiz es bei seinem hehren Ansinnen, den Code nach dem Pfadfinderprinzip schöner zurückzulassen als er ihn vorgefunden hat, mit der Rückwärtskompatibilität nicht so genau nimmt. Oder es wird ein neues Feature eingebaut, das leider nur zu 99% mit einem anderen arbeitserleichternden Framework kompatibel ist. Alles halb so schlimm – denn auf der im Quellcode angegeben Webseite des jamaikanischen Autors, der scheinbar auch eine Surfschule und ein Blog für lustige Katzenbilder betreibt, heißt es schon seit einer Woche sinngemäß, dass eine Lösung in Arbeit wäre.

Wo gehobelt bzw. geframeworkt wird, da fallen Späne. Daran werden sich die auch Kollegen von der internen IT, denen nach Projektende die dankbare Aufgabe zufällt, dieses schöne Stück Software am Laufen zu halten und punktuell zu erweitern, schnell gewöhnt haben.

Auf zu neuen Ufern

Auf unseren .NET-Entwickler wartet indes die nächste Herausforderung, womöglich in Form eines weiteren Web-Projektes. Aber damit kennt er sich nun aus – dachte er zumindest, bis er feststellte, dass in seinem neuen Projekt nur 3 der 21 Frameworks und Tools zum Einsatz kommen, mit denen er sich sein erstes Web-Projekt erträglich gemacht hat. Dafür hat ein anderer Entwickler bereits vier Monate nach Projektstart 16 andere Helferlein eingebunden, die allesamt ähnlich vielsprechend und vergleichbar nützlich sind. Man versteht sich unter Gleichgesinnten, auch wenn man über die eine oder andere Entscheidung noch einmal diskutieren müsste. Da gäbe es zum Beispiel dieses Productivity Tool, mit dem der .NET-Entwickler im letzten Projekt schon ein paar Nächte lang Erfahrung gesammelt hat. Das hat nämlich dieses eine geniale Feature, ohne dass man heutzutage gar nicht mehr auskommen kann. Zum Glück ist der andere Entwickler in diesem Punkt ganz offen und es macht ihm auch nichts aus, dass die bereits vorhandene Code-Basis dafür etwas umgestrickt werden muss. Im Grunde wollte er sich sowieso noch in dieses neue Tool einarbeiten, es fehlte ihm bislang einfach nur die Zeit dafür. Die Bedienung der anderen Tools und Frameworks erlernt sich ja auch nicht von selbst.

Außerdem hat er Ärger mit dem Kunden, weil seit dem letzten Skript-Update in unregelmäßigen Abständen ein Testbild mit einer surfenden Katze in der Produktgalerie erscheint. Aber auch dieses Problemchen wird nach der Einführung des neuen Tools bestimmt behoben sein.

Integrationstests vs. Stored Procedures – Teil 3

Am Anfang dieser Artikelserie wurde zunächst die Herausforderung aufgezeigt, die sich aus der durchgängigen Verwendung von Stored Procedures in Kombination mit umfangreichen Integrationstests ergibt. Im zweiten Teil der Artikelserie wurde dann gezeigt, wie sich der Zugriff auf Stored Procedures für CRUD-Operationen soweit abstrahieren lässt, dass man generische Methoden für eine beliebige Anzahl von Datenentitäten verwenden kann. Dadurch wird es auch möglich, für alle auf diese Weise aufrufbaren Stored Procedures die gleichen Integrationstests zu verwenden.

Doppelte Sicherheit

Für das Testen der Datenzugriffsmethoden bzw. der dahinterliegenden Stored Procedures sollten einige Voraussetzungen erfüllt sein:

  • Es wird eine Testdatenbank mit allen relevanten Tabellen und Stored Procedures benötigt. Empfehlenswert ist z. B. die Verwendung eines Datenbankprojektes, mit dem sich die Testdatenbank bequem aus Visual Studio heraus generieren lässt.
  • Man sollte für Testzwecke (und nur für diese) eine alternative Implementierung der Datenzugriffsmethoden haben, sodass man die tatsächlichen Datenzugriffsmethoden unabhängig voneinander testen kann.
  • Für jede Entität müssen bei dem im Folgenden vorgestellten Verfahren mindestens zwei Testdatensätze bereitgestellt werden. Eine Alternative hierzu wäre eine automatisierte Testdatengenerierung.

Des Weiteren wird ein geeignetes Test-Framework (z. B. das Visual Studio Testing Framework oder NUnit) benötigt. Zwar handelt es sich bei allen Tests nicht um autarke Unit-Tests, da sich die Stored Procedures nicht losgelöst von den Datenzugriffsmethoden testen lassen. Das Bestreben sollte aber dahin gehen, die Abhängigkeiten von weiteren Komponenten so gering wie möglich zu halten. Aus diesem Grund ist auch die angesprochene alternative Implementierung wichtig. Die im zweiten Teil der Artikelserie vorgestellten Hilfsmethoden für die Auswertung der Attribute können erneut zum Einsatz kommen, sollten dann aber idealerweise mit Unit Tests auf ihre Funktionstüchtigkeit getestet worden sein.

Es bietet sich an, für die alternativen Datenzugriffsmethoden konventionelle SQL-Statements zu verwenden. Da diese Methoden nur im Testprojekt bzw. auf der Testdatenbank verwendet werden, ist der Grundsatz, dass alle Datenbankzugriffe in der eigentlichen Anwendung per Stored Procedure zu erfolgen haben, davon nicht berührt.

Die alternative Implementierung der Datenzugriffsmethoden könnte so aussehen:

   1: public IDataReader DirectSelect(Type type, int primaryKey)

   2: {

   3:     var tableName = GetTableName(type);

   4:     var primaryKeyName = GetPrimaryKeyName(tableName);

   5:  

   6:     using (var connection = new SqlConnection("ConnectionString"))

   7:     {

   8:         connection.Open();

   9:         var cmd = new SqlCommand("SELECT * FROM " + tableName + " WHERE " + primaryKeyName + " = " + primaryKey, connection);

  10:         return cmd.ExecuteReader();

  11:     }

  12: }

   1: public void DirectDelete(Type type, int primaryKey)

   2: {

   3:     var tableName = GetTableName(type);

   4:     var primaryKeyName = GetPrimaryKeyName(tableName);

   5:  

   6:     using (var connection = new SqlConnection("ConnectionString"))

   7:     {

   8:         connection.Open();

   9:         var cmd = new SqlCommand("DELETE FROM " + tableName + " WHERE " + primaryKeyName + " = " + primaryKey, connection);

  10:         cmd.ExecuteNonQuery(); 

  11:     }

  12: }

   1: public int DirectInsert(object item)

   2: {

   3:     var type = item.GetType();

   4:     var tableName = GetTableName(type);

   5:     var columnMappings = GetColumnMappings(type, false);

   6:  

   7:     var columnDict = new Dictionary<string, object>();

   8:  

   9:     foreach (var currentColumnMapping in columnMappings)

  10:     {

  11:         var property = currentColumnMapping.Key;

  12:         var columnName = currentColumnMapping.Value;

  13:         var rawValue = property.GetValue(item, null);

  14:         var valueType = property.PropertyType;

  15:         object finalValue;

  16:  

  17:         if (valueType == typeof(string))

  18:         {

  19:             finalValue = "'" + rawValue + "'";

  20:         }

  21:         else if (valueType == typeof(DateTime))

  22:         {

  23:             finalValue = string.Format("'{0:yyyy-MM-dd}'", rawValue);

  24:         }

  25:         else if ((valueType == typeof(int)) || (valueType == typeof(int?)))

  26:         {

  27:             if (rawValue == null) { continue; }

  28:             finalValue = rawValue;

  29:         }

  30:         else if ((valueType == typeof(decimal)) || (valueType == typeof(decimal?)))

  31:         {

  32:             if (rawValue == null) { continue; }

  33:             finalValue = ((decimal)rawValue).ToString(CultureInfo.InvariantCulture);

  34:         }

  35:         else if (valueType == typeof(bool))

  36:         {

  37:             finalValue = (bool)rawValue ? 1 : 0;

  38:         }

  39:         else

  40:         {

  41:             throw new InvalidOperationException("Unerwarteter Typ '" + valueType.Name + "' in Spalte '" + columnName + "'");   

  42:         }

  43:  

  44:         columnDict.Add(columnName, finalValue);

  45:     }

  46:  

  47:     var sql = string.Format("INSERT INTO {0} ({1}) VALUES ({2}); SELECT CAST(SCOPE_IDENTITY() AS INT)", tableName, string.Join(",", columnDict.Keys), string.Join(",", columnDict.Values));

  48:  

  49:     using (var connection = new SqlConnection("ConnectionString"))

  50:     {

  51:         connection.Open();

  52:         var cmd = new SqlCommand(sql, connection);

  53:         var identity = (int)cmd.ExecuteScalar();

  54:         return identity > 0 ? identity : -1;   

  55:     }            

  56: }

Eine Doppelimplementierung der Datenzugriffsmethode für UPDATE-Operationen ist nicht zwingend erforderlich.

Blaupausen für die Integrationstests

Der Aufbau der einzelnen Integrationstests ist ähnlich, unterscheidet sich aber von CRUD-Operation zu CRUD-Operation, da jeweils etwas anderes getestet wird:

INSERT:

  1. Datensatz in die Datenbank einfügen mit der zu testenden Methode
  2. Datensatz aus der Datenbank auslesen mit der direkten Methode
  3. Prüfen, ob die gelesenen Werte den geschriebenen Werten entsprechen
  4. Datensatz aus der Datenbank löschen mit der direkten Methode

SELECT:

  1. Datensatz mit der direkten Methode in die Datenbank einfügen
  2. Datensatz aus der Datenbank auslesen mit der zu testenden Methode
  3. Datensatz aus der Datenbank auslesen mit der direkten Methode
  4. Prüfen, ob die gelesenen Werte in beiden Fällen identisch sind
  5. Datensatz aus der Datenbank löschen mit der direkten Methode

UPDATE:

  1. Datensatz mit der direkten Methode in die Datenbank einfügen
  2. Den Datensatz in der Datenbank aktualisieren mit der zu testenden Methode
  3. Datensatz aus der Datenbank auslesen mit der direkten Methode
  4. Prüfen, ob die gelesenen Werte in beiden Fällen unterschiedlich sind
  5. Datensatz aus der Datenbank löschen mit der direkten Methode

DELETE:

  1. Datensatz in die Datenbank einfügen mit der direkten Methode
  2. Datensatz mit der zu testenden Methode aus der Datenbank löschen
  3. Prüfen, ob kein Datensatz mehr in der Datenbank ist mit der direkten Methode

Konkretes Beispiel

Im Folgenden soll das in diesem Abschnitt beschriebene Vorgehen exemplarisch anhand eines Tests für die im zweiten Teil der Artikelserie vorgestellte UPDATE-Datenzugriffsmethode gezeigt werden. In der Praxis braucht man natürlich auch für diese Methode mehr als einen Test. Denkbare wären z. B. Tests, die das Verhalten beim Hinzufügen doppelter Primary Keys prüfen usw.

Es wird davon ausgegangen, dass die Testklasse von einer Basis-Testklasse abgeleitet ist. In dieser Test-Basisklasse würden die Methoden DirectSelect, DirectDelete und DirectInsert implementiert sein. Weiterhin wird davon ausgegangen, dass sich die zu testenden Methoden sich in einer Klasse mit dem Namen DataAccess befinden.

Für diesen Test wird mit zwei Entitäten ein Vorher-/Nachher-Vergleich durchgeführt. Deswegen ist es notwendig, dass sich beide Entitäten in allen schreibbaren Werten mit Ausnahme des Primärschlüssels unterscheiden. In realen Projekten ist es sinnvoll, die Testdatengenerierung auszulagern oder zu automatisieren. Für das Beispiel ist es aber hinreichend, diese direkt zu instanziieren.

   1: [Test]

   2: public void AllProductPropertiesCanBeUpdated()

   3: {

   4:     var firstProduct = new Product { Name = "Some Product", InventoryNumber = 1 };

   5:     var secondProduct = new Product { Name = "Another Product", InventoryNumber = 2 };

   6:     

   7:     DoUpdateTest(firstProduct, secondProduct);

   8: }

In der Hilfsmethode DoUpdateTest() liegt die im Abschnitt “Blaupausen für die Integrationstests” beschriebene Prüflogik:

   1: private void DoUpdateTest(object firstItem, object secondItem)

   2: {

   3:     var type = firstItem.GetType();

   4:     var tableName = GetTableName(type); 

   5:  

   6:     // Sicherstellen, dass beide Entitäten unterschiedliche Werte haben

   7:     var columnMappings = GetColumnMappings(type, false);

   8:  

   9:     var primaryKeyName = GetPrimaryKeyName(tableName);

  10:     PropertyInfo primaryKeyProperty = null;

  11:  

  12:     foreach (var currentColumnMapping in columnMappings)

  13:     {

  14:         var property = currentColumnMapping.Key;

  15:         var columnName = currentColumnMapping.Value;

  16:  

  17:         var firstValue = property.GetValue(firstItem, null);

  18:         var secondValue = property.GetValue(secondItem, null);

  19:  

  20:         if (columnName == primaryKeyName)

  21:         {

  22:             // Primärschlüssel ermitteln

  23:             primaryKeyProperty = property;

  24:         }

  25:         else if (firstValue == secondValue)

  26:         {

  27:             // Alle Properties bis auf den Primärschlüssel müssen unterschiedlich sein

  28:             throw new InvalidOperationException("Die Eingangsdaten müssen unterschiedlich sein");

  29:         }

  30:     }

  31:  

  32:     if (primaryKeyProperty == null)

  33:     {

  34:         throw new InvalidOperationException("Der Primärschlüssel konnte nicht ermittelt werden");

  35:     }

  36:  

  37:     var primaryKeyValue = 0;

  38:  

  39:     try

  40:     {

  41:         // Die erste Entität wird in die Datenbank geschrieben.

  42:         primaryKeyValue = DirectInsert(firstItem);

  43:  

  44:         if (primaryKeyValue <= 0)

  45:         {

  46:             throw new InvalidOperationException("Der Primärschlüssel des erzeugten Datensatzes muss größer als 0 sein");

  47:         }

  48:  

  49:         // Beide Objekte erhalten den Primärschlüssel des eingefügten Datensatzes.

  50:         primaryKeyProperty.SetValue(firstItem, primaryKeyValue, null);

  51:         primaryKeyProperty.SetValue(secondItem, primaryKeyValue, null);

  52:  

  53:         // Erzeugen der Testkomponente

  54:         var dataAccess = new DataAccess();

  55:  

  56:         // Aufrufen der zu testenden Methode

  57:         dataAccess.Update(secondItem);

  58:  

  59:         // Es gilt zu testen, ob alle schreibbaren Werte der ersten Entität durch

  60:         // die Werte der zweiten Entität ersetzt wurden.

  61:         using (var reader = DirectSelect(type, primaryKeyValue))

  62:         {

  63:             Assert.IsTrue(reader.Read());

  64:  

  65:             foreach (var currentColumnMapping in columnMappings)

  66:             {

  67:                 var property = currentColumnMapping.Key;

  68:                 var columnName = currentColumnMapping.Value;

  69:  

  70:                 var expectedValue = property.GetValue(secondItem, null);

  71:                 var actualValue = reader[columnName] != DBNull.Value ? reader[columnName] : null;

  72:  

  73:                 // Alle ausgelesenen Werte müssen mit den Werten der zweiten Entität identisch

  74:                 // sein, die sich allesamt von den Werten der ersten Entität unterscheiden, was

  75:                 // zuvor geprüft wurde. Wenn das der Fall ist, wurden über die Stored Procedure

  76:                 // alle schreibbaren Werte ersetzt und der Test wird erfolgreich durchlaufen.

  77:                 Assert.AreEqual(expectedValue, actualValue);

  78:             }

  79:         }

  80:     }

  81:     finally

  82:     {

  83:         // Löschen, was sich löschen lässt.

  84:         if (primaryKeyValue > 0)

  85:         {
  87:         }

  86:             DirectDelete(type, primaryKeyValue);

  88:     }

  89: }

Wie man sieht, gibt es innerhalb der Methode DoUpdateTest() keinen direkten Bezug zu der Klasse Product. Alle benötigten Informationen lassen sich aus den zuvor festgelegten Namenskonventionen und den Metadaten, mit denen die Klasse angereichert wurde, ermitteln. Für weitere, nach dem gleichen Prinzip aufgebaute Klassen, kann daher die gesamte Testlogik einfach übernommen werden.

Normalerweise ist es nicht üblich, in einer Testmethode auch die Gültigkeit der Testdaten zu prüfen, wie es im obigen Beispiel gemacht wurde. In der Praxis haben sich solche Selbsttests aber als sehr nützlich erwiesen, da einer der häufigsten Fehler in der Bereitstellung unvollständiger Testdaten bestand. Dies passiert erfahrungsgemäß besonders oft beim Hinzufügen neuer Properties mit einem Default-Wert, also bei fast allen Properties, die hinzugefügt werden. In einer realen Anwendung kann es aber sinnvoll sein, die Selbsttests von den eigentlichen Testmethoden zu trennen.

Fazit

Der Vorgabe, Datenbankzugriffe grundsätzlich nur über Stored Procedures abzuwickeln, muss nicht im Widerspruch zu dem Anspruch, eine möglichst große Testabdeckung zu erzielen, stehen. Mit dem in dieser Artikelserie vorgestellten Weg lässt sich der Datenzugriff über die Stored Procedures so vereinheitlichen, dass man nicht mehr mit unzähligen, fragmentierten Tests zu kämpfen hat. Am besten ist, wenn man überhaupt nicht in die Verlegenheit kommt, in großem Stil Copy-and-Paste zu betreiben.

Da sich die Testlogik beliebig oft wiederverwenden lässt, lohnt es sich, diese zu verbessern. Die Erfahrung in der Praxis zeigt, dass durch Erweiterungen und Korrekturen der gemeinsam genutzten Testlogik nicht selten Fehler in bereits ausreichend getestet geglaubten Programmteilen finden.

Natürlich lässt sich auf diese Weise nicht jedes erdenkliche Szenario bewältigen. Insbesondere bei komplexen Abhängigkeiten zwischen den Entitäten und bei einer Verlagerung von erheblichen Teilen der Business-Logik in die Datenbank bzw. die Stored Procedures, stößt das Prinzip an seine Grenzen. Doch auch in solchen Anwendungen gibt es fast immer auch noch eine Vielzahl von klassischen Datenentitäten, für die sich die vorgestellte Methodik wiederum hervorragend eignet.

Integrationstests vs. Stored Procedures – Teil 2

Im ersten Teil dieser Artikelserie wurden die Widersprüche zwischen zwei im Allgemeinen als vorteilhaft geltenden Ansätzen in der Softwareentwicklung deutlich gemacht: Der ausschließlichen Verwendung von Stored Procedures für Datenbankzugriffe sowie dem Anspruch, möglichst den gesamten Code einer Business-Anwendung automatisiert zu testen. Im zweiten Teil der Serie geht es um die für die Überwindung dieser Widersprüche notwendigen Voraussetzungen.

Es geht nicht ohne Regeln

In fast allen Projekten im Enterprise-Bereich wird die Datenzugriffsschicht nicht von einem einzelnen Entwickler umgesetzt, sondern von mehreren Entwicklern. Bei langlaufenden Projekten ist es oft sogar so, dass manche der Beteiligten sich nicht einmal kennen lernen, weil sich ihre Projekteinsätze zeitlich nicht überschneiden.

Vor der Automatisierung sollte deshalb immer die strikte Einhaltung einiger grundlegender Regeln kommen. Im Folgenden soll anhand eines ausführlichen Beispiels gezeigt werden, wie zu diesem Zweck verfahren werden kann. Viele Wege führen zum Ziel, aber es ist wichtig, dass man sich für einen entscheidet.

Denkbar wären folgende Namenskonventionen:

  • .Stored Procedures sollten nach dem gleichen Prinzip benannt werden:

    – SP_<Tabellenname>_Insert

    – SP_<Tabellenname>_Update

    – SP_<Tabellenname>_Delete

    – SP_<Tabellenname>_SelectById usw.

  • Die Namen der technischen Schlüssel sollten demselben Prinzip folgen.

    – Denkbar wäre: <TabellenName>Id

  • Gleiches gilt für die Namen der Parameter für die Stored Procedures, z. B.:

    – @CountryCode für die Spalte CountryCode usw.

Die Einhaltung einfacher Regeln dieser Art mag im ersten Moment selbstverständlich erscheinen. In der Praxis sieht es erfahrungsgemäß oft anders aus: Im schlimmsten Fall folgt jeder Entwickler nur seinen eigenen Präferenzen. Durch diese Auswüchse wird die Umsetzung automatisierter Integrationstests unnötiger erschwert. So wäre es zwar prinzipiell möglich, die Parameter einer Stored Procedure aus der Datenbank auszulesen. Diesen Umweg kann man sich durch Einhaltung der o. g. Regeln sparen. Ein weiterer positiver Nebeneffekt einheitlicher Namenskonventionen besteht darin, dass man beim Wiederverwenden vorhandenen Codes genau weiß, an welchen Stellen Änderungen von Nöten sind, weil es immer die gleichen Stellen sind. In diesem Fall ist Copy-and-Paste weitgehend unproblematisch, weil der entstehende Code Gegenstand der automatisierten Tests sein wird.

Entitäten mit Metadaten-Attributen anreichern

Mit der systematischen Benennung der Datenbankelemente ist der erste Schritt zur Automatisierung getan. Ein zweiter, denkbarer Schritt wäre, die Entitäten der Anwendung mit Metadaten für die Informationen anzureichern, die sich nicht ohne Weiteres aus den Namenskonventionen herleiten. Ein gängiger Weg in C# ist, hierfür eigene Attribute zu definieren. Der genaue Inhalt der Attribute richtet sich nach den Anforderungen der jeweiligen Anwendung.

Im Folgenden wird von einem relativ einfachen Beispiel ausgegangen, das nach Belieben erweitert werden kann:

   1: public class TableMappingAttribute : Attribute

   2: {

   3:     public string Name { get; set; }

   4: }

   1: public class ColumnMappingAttribute : Attribute

   2: {

   3:     public string Name { get; set; }

   4:     public bool IsReadonly { get; set; }

   5: }

Mit dem TableMapping-Attribut wird eine Verbindung zwischen der Entität in C# und der passenden Datenbanktabelle hergestellt. Analog dazu werden mit dem ColumnMapping-Attribut die relevanten Properties der Entität mit Spalten verbunden. In beiden Fällen könnte beim Weglassen des Namens davon ausgegangen werden, dass dieser mit dem Klassen- bzw. Property-Namen identisch ist. Mit IsReadonly können Properties gekennzeichnet werden, die nur für Select-Operationen relevant sind, also z. B. mittels eines JOINs aus einer anderen Tabelle erzeugt wurden.

Eine mit den vorgestellten Attributen angereicherte Entität könnte so aussehen:

   1: [TableMapping(Name = "Product")]

   2: public class Product

   3: {

   4:     [ColumnMapping]

   5:     public int ProductId { get; set; }

   6:     

   7:     [ColumnMapping]

   8:     public string Name { get; set; }

   9:     

  10:     [ColumnMapping(Name="InventoryNo")]

  11:     public int InventoryNumber { get; set; }

  12:  

  13:     [ColumnMapping(IsReadonly = true)]

  14:     public decimal PurchasePrice { get; set; }

  15: }

Eine mögliche Alternative zu dem auf Attributen basierenden Ansatz wäre eine Erweiterung der Namenskonventionen. So könnte per Konvention festgelegt sein, dass die Tabellen- und Spaltennamen identisch mit den Klassen- bzw. Property-Namen auf .NET-Seite sind. Für Properties, die nur gelesen, aber nicht geschrieben werden sollen, könnte man ebenfalls eine Namenskonvention einführen (z. B. mit einem Präfix oder Suffix im Spaltennamen).

Für die Veranschaulichung des Prinzips werden an dieser Stelle die vorgestellten Attribute verwendet werden. Durch mittels der Attribute hinterlegten Metadaten hat man alle Informationen für den Aufruf der Stored Procedures für CRUD-Operationen zusammen, nämlich:

  • Die Namen der aufzurufenden Stored Procedures (per Namenskonvention)
  • Den technischen Primärschlüssel (per Namenskonvention)
  • Die Namen aller relevanten Tabellenspalten (per Attribut)
  • Somit auch die Namen aller Parameter (per Attribut und Namenskonvention)
  • Die Möglichkeit, nur für den Lesezuggriff relevante Spalten mit „Readonly“ zu kennzeichnen (per Attribut)

Einbindung der Metadaten-Attribute

Die vorgestellten Attribute können nun in entsprechenden Methoden ausgelesen und berücksichtigt werden:

   1: public string GetTableName(Type type)

   2: {

   3:     var tableMapping = type.GetCustomAttributes(typeof(TableMappingAttribute), true).FirstOrDefault() as TableMappingAttribute;

   4:  

   5:     if (tableMapping == null)

   6:     {

   7:         // Kein passendes Attribut gefunden

   8:         return null;

   9:     }

  10:  

  11:     if (string.IsNullOrEmpty(tableMapping.Name))

  12:     {

  13:         // Default-Tabellennamen setzen (z. B. "Products" wenn der Klassenname "Product" ist)

  14:         return type.Name + "s";

  15:     }

  16:     

  17:     // Schema unberücksichtigt lassen 

  18:     return tableMapping.Name.Split('.').Last();

  19: }

  20:  

  21: public string GetPrimaryKeyName(string tableName)

  22: {

  23:     // Per Konvention festgelegt

  24:     return tableName + "Id";

  25: }

Der Tabellenname wird benötigt, um daraus den Namen der Stored Procedures daraus abzuleiten. Falls die Tabelle und die Stored Procedure z. B. unterschiedliche Schemata haben sollen, kann man das TableMapping-Attribut um ein passendes Property erweitern, das dann in der Methode zu berücksichtigen wäre.

   1: public Dictionary<string, string> GetColumnMappings(Type type, bool includeReadOnlyProperties)

   2: {

   3:     // Nur die relevanten Properties berücksichtigen

   4:     var properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public)

   5:                          .Where(x => x.GetCustomAttributes(typeof(ColumnMappingAttribute), true).Any());

   6:  

   7:     var mappingDict = new Dictionary<string, string>();

   8:  

   9:     foreach (var currentProperty in properties)

  10:     {

  11:         var columnMapping = (ColumnMappingAttribute)currentProperty.GetCustomAttributes(typeof(ColumnMappingAttribute), true).First();

  12:  

  13:         if (string.IsNullOrEmpty(columnMapping.Name))

  14:         {

  15:             // Default-Spaltennamen setzen (entspricht Property-Name)

  16:             mappingDict.Add(currentProperty.Name, currentProperty.Name);

  17:             continue;

  18:         }

  19:  

  20:         // Spaltennamen aus Property übernehmen

  21:         mappingDict.Add(currentProperty.Name, columnMapping.Name);

  22:     }

  23:  

  24:     return mappingDict;

  25: }

  26: public Dictionary<PropertyInfo, string> GetColumnMappings(Type type, bool includeReadonlyProperties)

  27: {

  28:     // Nur die relevanten Properties berücksichtigen

  29:     var properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public)

  30:                          .Where(x => x.GetCustomAttributes(typeof(ColumnMappingAttribute), true).Any());

  31:  

  32:     var mappingDict = new Dictionary<PropertyInfo, string>();

  33:  

  34:     foreach (var currentProperty in properties)

  35:     {

  36:         var columnMapping = (ColumnMappingAttribute)currentProperty.GetCustomAttributes(typeof(ColumnMappingAttribute), true).First();

  37:  

  38:         if (!includeReadonlyProperties && columnMapping.IsReadonly)

  39:         {

  40:             // Readonly-Property überspringen

  41:             continue;

  42:         }

  43:  

  44:         if (string.IsNullOrEmpty(columnMapping.Name))

  45:         {

  46:             // Default-Spaltennamen setzen (entspricht Property-Name)

  47:             mappingDict.Add(currentProperty, currentProperty.Name);

  48:             continue;

  49:         }

  50:  

  51:         // Spaltennamen aus Property übernehmen

  52:         mappingDict.Add(currentProperty, columnMapping.Name);

  53:     }

  54:  

  55:     return mappingDict;

  56: } 

Das Column-Mapping-Dictionary wird später dazu verwendet, um die Inhalte der Entität von der .NET-Welt in die SQL-Server-Welt zu übertragen. Während man vorher womöglich für jede Stored Procedure eine korrespondierende Methode in .NET geschrieben hätte, kommt man nun im Idealfall mit einer Methode pro CRUD-Operation für alle ähnlich aufgebauten Entitäten aus. Dieses Prinzip soll beispielhaft anhand einer bewusst einfach gehaltenen Update-Methode gezeigt werden:

   1: public void Update<T>(T item)

   2: {

   3:     var itemType = item.GetType();

   4:     

   5:     var storedProcName = "SP_" + GetTableName(itemType) + "_Update";

   6:     var columnMappings = GetColumnMappings(itemType, false);

   7:  

   8:     using (SqlConnection connection = new SqlConnection("ConnectionString"))

   9:     {

  10:         using (SqlCommand cmd = new SqlCommand(storedProcName, connection))

  11:         {

  12:             cmd.CommandType = CommandType.StoredProcedure;

  13:  

  14:             foreach (var currentColumnMapping in columnMappings)

  15:             {

  16:                 var property = currentColumnMapping.Key;

  17:                 var columnName = currentColumnMapping.Value;

  18:                 var value = property.GetValue(item, null);

  19:                 

  20:                 if (!SqlDbTypeDict.ContainsKey(property.PropertyType))

  21:                 {

  22:                     throw new InvalidOperationException("Unerwarteter Typ: " + property.PropertyType);

  23:                 }

  24:  

  25:                 cmd.Parameters.Add("@" + columnName, SqlDbTypeDict[property.PropertyType]).Value = value;

  26:             }

  27:  

  28:             connection.Open();

  29:             cmd.ExecuteNonQuery();

  30:         }

  31:     }

  32: }

Das für die Übersetzung von Type zu SqlDbType benötigte Dicitionary sieht so aus:

   1: public static Dictionary<Type, SqlDbType> SqlDbTypeDict = new Dictionary<Type, SqlDbType>

   2: {

   3:     { typeof(string), SqlDbType.VarChar },

   4:     { typeof(DateTime), SqlDbType.DateTime },

   5:     { typeof(int), SqlDbType.Int },

   6:     { typeof(decimal), SqlDbType.Money },

   7:     { typeof(bool), SqlDbType.Bit }

   8:     // usw.

   9: };

Weitere CRUD-Operationen nach dem gleichen Prinzip

Die Methoden für die anderen CRUD-Operationen können vergleichbar einfach umgesetzt werden. Bei der Methode für die SELECT-Operation würde das Mapping in umgekehrter Richtung erfolgen und die mit „Readonly“ gekennzeichneten Spalten miteinbeziehen. Die Methode für die INSERT-Operation würde ggf. die Identität der erzeugten Tabellenzeile zurückgegen. Besonders einfach ist die Methode für die DELETE-Operation, da lediglich der Primary Key benötigt wird, der sich per Namenskonvention aus dem Tabellenname herleiten lässt.

Natürlich werden in der Praxis nicht alle Entitäten so trivial aufgebaut sein wie das Beispiel Product. Bei wesentlich komplexeren Entitäten muss abgewogen werden, ob es sich lohnt, das System in geeigneter Weise zu erweitern (z. B. durch Hinzufügen und Berücksichtigen weiterer Attribute). In manchen Fällen kann eine Sonderlösung aber nach wie vor der bessere Weg sein, vor allem wenn eine Wiederverwendung der Attribute für eine zweite Entität nahezu ausgeschlossen ist.

Im nächsten Teil dieser Artikelserie soll der Bogen zum Thema Integrationstests geschlagen werden. Es gilt zu zeigen, welche Auswirkungen das vorgestellte Prinzip, den Zugriff auf die Stored Procedures mittels Konventionen und Metadaten-Attributen zu vereinheitlichen, auf diesen Bereich hat.

Integrationstests vs. Stored Procedures – Teil 1

Niemand wird in Abrede stellen, dass bei der Entwicklung von Business-Anwendungen im Enterprise-Bereich hohe softwaretechnische Standards einzuhalten sind. Oftmals werden diese Standards in projektübergreifend geltenden Reglements festgelegt, in denen Abweichungen nur in begründeten Ausnahmefällen zugelassen sind. Das ist auch notwendig, weil langlaufende Projekte ohne solche Regeln nicht zu pflegen wären.

In der Praxis können aber auch problematische Wechselwirkungen zwischen gängigen „Best Practices“ entstehen­. So  auch zwischen zwei im Allgemeinen als wünschenswert geltenden Vorgehensweisen: Der Verwendung von Stored Procedures anstelle von direkten Zugriffen auf die Datenbank und dem Anstreben einer hohen automatisierten Testabdeckung.

Mit erheblichem Aufwand verbunden

Durch die ausschließliche Verwendung von Stored Procedures wird eine weitere Ebene („Layer“) zwischen dem Data Access Layer der Anwendung und der Datenbank geschaffen. Das bringt einige Vorteile und Möglichkeiten mit sich, aber auch einen nicht unerheblichen Nachteil: Mit regulären Unit Tests, d. h. ohne Zuhilfenahme einer Testdatenbank, lässt sich nicht sicherstellen, dass eine Stored Procedure wirklich das tut, was sie tun soll.

Nun könnte man zwar dagegen halten, dass dies beim direkten Zugriff auf die Datenbank auch nicht anders wäre. Tatsächlich kann man bei modernen OR-Mappern wie dem Entity Framework aber davon ausgehen, dass die jeweiligen Komponenten bereits vom Hersteller so ausgiebig getestet wurden, dass so gut wie immer gültige SQL-Statements erzeugt werden. Bei selbst geschriebenen Stored Procedures sind Integrationstests im Sinne einer annähernd umfassenden Testabdeckung dagegen unumgänglich.

Integrationstests gehören zu den zeitaufwendigsten Arten von Tests. Dazu gehört in diesem Fall nicht nur das Generieren geeigneter Testdaten, sondern eine Doppelimplementierung der typischen CRUD-Funktionen (Create, Read, Update, Delete), üblicherweise mittels konventionellen SQL-Statements innerhalb des Testprojekts. Nur so kann jede auf einer Stored Procedure basierende Methode isoliert von den anderen Methoden getestet werden. Des Weiteren muss darauf geachtet werden, dass etwaige parallel laufende Tests nicht die gleichen Daten verändern. Auch mit der Bereinigung der Datenbank muss man sich befassen.

Die Praxis: Copy-and-Paste

Doch das ist noch längst nicht alles: Selbst kleinere Business-Anwendungen greifen auf eine hohe zweistellige bis dreistellige Anzahl von Datenbanktabellen zu. Wenn man den beschriebenen Weg einschlagen will oder muss, bedeutet das also, dass man bereits für die CRUD-Operationen hunderte von Stored Procedures und noch mehr Integrationstests schreiben wird.

Was aber natürlich niemand tut – schließlich gibt es zwischen vielen Datenentitäten so große Ähnlichkeiten, dass man das Rad nicht ständig neu erfinden muss. In den Projektalltag übersetzt bedeutet das fast immer Copy-and-Paste in großem Stil. Das muss nicht schlecht sein, schließlich wird dadurch sichergestellt, dass die Tests autark voneinander lauffähig und veränderbar sind.

Die Copy-and-Paste-Methode bringt aber auch eine Reihe von Nachteilen mit sich:

1. Es handelt sich immer noch um manuell erstellte Tests, deren Qualität maßgeblich vom Detailwissen eines bestimmten Entwicklers zu einem bestimmten Zeitpunkt abhängig ist.

2. Sofern die Tests nicht von Anfang an mit Bedacht organisiert werden, ist später oft kaum mehr zu erkennen, welche Tests für eine bestimmte Entität essentiell wichtig sind.

3. Wenn ein Test sich zu einem späteren Zeitpunkt als fehlerhaft oder unvollständig erweist, müssen auch etliche Kopien korrigiert werden. Es besteht die Gefahr, dass einige davon inzwischen so verändert wurden, dass sie übersehen werden.

4. Zwischen Entitäten gibt es oft Abhängigkeiten. Wenn eine der beteiligten Entitäten verändert wird, kann das auf zahlreiche Tests Auswirkungen haben, ohne dass diese Tests verlässlich „rot“ werden. Auf diese Weise kann eine trügerische Gewissheit entstehen.

Copy-and-Paste gilt nicht ohne Grund als ein Anti-Pattern – und als solches erweist es sich auch hier, sobald größere Veränderungen an wichtigen Datenbankentitäten vorgenommen werden. Eben dies dürfte in vielen langlaufenden Projekten nicht die Ausnahme, sondern die Regel sein. In den weiteren Teilen dieser Artikelserie soll ein Weg aufgezeigt werden, mit dem sich die durchgängige Verwendung von Stored Procedures und das Anstreben einer weitgehenden automatisierten Testabdeckung besser miteinander in Einklang bringen lassen.

Single Page Applications – Teil 4: Ein Code-Beispiel

Nachdem in den ersten drei Teilen dieser Artikelserie das Thema Single Page Applications aus verschiedenen Blickwinkeln betrachtet wurde, folgt nun ein praktisches Beispiel: Der Implementierung eines einfachen, aber effektiven Routing-Mechanismus mit jQuery und ein wenig ASP.NET WebForms.

Routing und das Bewahren des Back-Buttons

Ein naheliegendes Problem bei einer Single Page Application, die ja nur aus einer einzigen Seite besteht, ist die Unmöglichkeit, mit dem Back-Button des Browsers zu den zuvor besuchten Unterseiten zurück zu navigieren. Nun gibt es zwar auch viele ASP.NET-WebForms-Anwendungen, auf die diese Aussage zutrifft, doch das hat andere (manchmal durchaus gute) Gründe, auf die an dieser Stelle nicht näher eingegangen werden soll. Bei Single Page Applications ist dieses Problem recht einfach lösbar, indem man logische Pfade (Routes) definiert, wie man sie z. B. von ASP.NET MVC kennt. Anders als bei ASP.NET MVC wird das Routing aber im Wesentlichen nicht auf Server- sondern auf Client-Seite durchgeführt. Mit etwas mehr Aufwand kann man das Routing wie bei ASP.NET MVC für den Kontrollfluss innerhalb der Single Page Application lösen. Die folgende URL könnte z. B. dazu dienen, den Kunden-Datensatz mit der ID 65 im Editiermodus aufzurufen: http://localhost/customer/edit/65

Solche URLs bieten aber nur dann einen echten Mehrwert, wenn man sie an andere Personen weiterschicken oder als Bookmark speichern kann. Das geht bei einer Single Page Application nicht einfach so, weil vom Web-Server erst die URL aufgelöst und dann der JavaScript-Code ausgeführt wird. Deshalb würde der Aufruf dieser URL erst einmal zu einem Fehler führen, da der Web-Server keine entsprechende Seite finden würde. Man muss dem Web-Server also mitteilen, wie er die Route auflösen soll, was bei ASP.NET mit einer Code-Zeile erledigt ist. Allerdings muss man dann auch im JavaScript-Code beim ersten Aufruf der Seite ein entsprechendes logisches Routing durchführen. Alles in allem lässt sich sagen, dass Routing mit einem gewissen Programmieraufwand verbunden ist, der sich bei Single Page Applications aber meistens lohnt. Bei Internet-Seiten ist das Routing zudem für die Suchmaschinenoptimierung wichtig.

Ein praktisches Beispiel

Web-Anwendungen haben oft eine Vielzahl an Unterseiten, zwischen denen man über Links oder Buttons hin- und her navigieren kann. In den meisten Fällen gibt es keinen Grund, warum eine Single Page Application mit diesem den Benutzern vertrautem Prinzip brechen sollte. Statt einzelner Seiten kann man DIVs verwenden, die, je nach Zustand der Anwendung, ein- und ausgeblendet werden. Im HTML-Code könnte das so aussehen:

   1: <div id="ContentWrapper">

   2:     <ul>

   3:         <li><a class="navLink" href="Welcome">Willkommen</a></li>    

   4:         <li><a class="navLink" href="Customer">Kunden</a></li>

   5:         <li><a class="navLink" href="Product">Produkte</a></li>

   6:     </ul>

   7:     <hr />

   8:     <div class="section" id="WelcomeSection">

   9:         <p>Sektion: Willkommen</p>   

  10:     </div>

  11:     <div class="section" id="CustomerSection">

  12:         <p>Sektion: Kunden</p>   

  13:     </div>

  14:     <div class="section" id="ProductSection">

  15:         <p>Sektion: Produkte</p>   

  16:     </div>

  17: </div>

Oben auf der Seite befindet sich also eine Liste von Links (die man mit CSS noch nebeneinander anordnen könnte), gefolgt von einer Liste von Sektionen. Wenn man auf einen Link klickt, soll die dazugehörige Sektion (und nur diese) eingeblendet werden. Im nachfolgenden JavaScript-Teil wird als einziges Hilfsmittel jQuery verwendet werden. Hier und da könnte man sich das Leben mit der einen oder anderen JavaScript-Bibliothek sicher noch einfacher machen, aber die Anwendung ist auch so schon sehr überschaubar.

Im Ready()-Event der Webseite, das ausgelöst wird, wenn diese vollständig geladen ist, wird deren Inhalt nach Sektionen durchsucht, die eine IDs haben. Aus diesen IDs wird, ohne das Suffix “Section”, ein Array gebildet, das einer zuvor (und der Einfachheit halber global) deklarierten Variable zugewiesen wird:

   1: knownSections = $('#ContentWrapper .section[id]').map(function () {

   2:     return this.id.replace(/Section/, '');

   3: }).get();

Dieses Array wird innerhalb der Navigationsfunktion genutzt. Diese erhält als Parameter eine URL, deren letztes Element separiert und ausgewertet wird. Das wäre bei http://localhost/Product “Product”. Nun wird ermittelt, ob das Array mit den bekannten Sektionen das Element enthält, was bei “Product” zuträfe. Andernfalls, also z. B. bei URLs wie http://localhost/ oder http://localhost/Test123, würde im Else-Zweig die erste Sektion genommen werden, womit sichergestellt ist, dass sich die Anwendung zu jedem Zeitpunkt in einem definierten Zustand befindet. Mit der HTML5-Funktion window.history.pushState() kann die URL-Zeile des Browsers gesetzt und die entsprechende URL im Browser-Verlauf abgelegt werden. Der restliche Code dient dazu, die anderen Sektionen auszublenden und die ausgewählte Sektion einzublenden.

   1: function showSectionByUrl(incomingUrl) {

   2:     var lastUrlPart = incomingUrl.substr(incomingUrl.lastIndexOf('/') + 1);

   3:     var visibleSection;

   4:  

   5:     if ($.inArray(lastUrlPart, knownSections) > -1) {

   6:         visibleSection = '#' + lastUrlPart + 'Section';

   7:  

   8:     } else {

   9:         window.history.pushState('', '', knownSections[0]);

  10:         visibleSection = '#' + knownSections[0] + 'Section';

  11:     }

  12:     

  13:     $('.section:not(' + visibleSection + ')').css('display', 'none');

  14:     $(visibleSection).css('display', 'block');

  15: }

Nun müssen noch die für die Navigation vorgesehenen Links mit einer Funktion versehen werden. Im ersten Schritt wird dazu das Default-Verhalten der Links verhindert, sodass sie keine Aktion auf dem Server auslösen. Dann wird, sofern die URL von der aktuellen URL abweicht, die weiter oben definierte Navigationsfunktion aufgerufen. Auch diesmal wird window.html.pushState() aufgerufen, um die URL in den Browser-Verlauf einzufügen.

   1: $('.navLink').click(function (e) {

   2:     e.preventDefault();

   3:     var targetUrl = e.target.href;

   4:     

   5:     if (targetUrl != location.href) {

   6:         window.history.pushState(null, null, targetUrl);

   7:         showSectionByUrl(targetUrl);

   8:     }

   9: });

Die Verwendung von window.history.pushState() setzt einen HTML5-fähigen Browse wie Internet Explorer 10 voraus. Wenn man Benutzern, die die Anwendung mit einem älteren Browser aufrufen, hierüber informieren möchte, kann man mit der folgenden Hilfsfunktion ermitteln, ob der verwendete Browser das HTML5-Routing mit window.history.pushState() unterstützt:

   1: function getIsHtml5HistoryApiAvailable() {

   2:     return window.history && ('pushState' in window.history);

   3: }

Damit die Anwendung später auch auf den Back-Button reagiert, muss noch das "Popstate”-Event mit einem Event-Handler versehen werden, der wiederum die Navigationsfunktion aufruft:

   1: $(window).bind('popstate', function () {

   2:     showSectionByUrl(location.href);

   3: });

Zu guter Letzt muss man dafür sorgen, dass der Web-Server auf Urls wie bei http://localhost/Product nicht vergeblich nach einer Webseite mit diesem Namen sucht, sondern dem Anwender die gewünschte Sektion zeigt, sodass dieser die URL wie gewohnt mit Copy & Paste verwenden kann. Hierzu erweitert man die Application_Start()-Funktion in der Global.asax-Datei um ein entsprechendes Routing, das wie folgt aussehen könnte:

   1: private void Application_Start(object sender, EventArgs e)

   2: {

   3:     RouteTable.Routes.MapPageRoute("spa-browse", "{section}", "~/Default.aspx");

   4: }

Damit wäre die kleine Beispielanwendung im Prinzip fertig. Jetzt müsste man die Sektionen natürlich noch mit Inhalten füllen, beispielsweise mit Tabellen, an die man dann via Knockout.js passende Datensätze bindet. Spätestens dann wird man noch etwas mehr serverseitigen Code benötigen, um die Daten per JSON-Call bereitstellen und abspeichern zu können. Da dies aber auch in ASP.NET WebForms sehr einfach realisiert werden kann, ist der Einstieg in die Welt der Single Page Applications gar nicht so schwer. Daher soll an alle Entwickler, die bis zu diesem Teil der Serie durchgehalten haben, der Aufruf ergehen, selbst eine Single Page Application zu schreiben und damit herumzuexperimentieren!

Single Page Applications – Teil 3: Mögliche Fallstricke

Wie im vorangegangenen Teil dieser Artikelserie beschrieben, bieten Single Page Applications eine ganze Reihe von Vorteilen gegenüber klassischen Web-Anwendungen. Eines lässt sich von Single Page Applications allerdings nicht behaupten, nämlich, dass diese besonders einfach zu entwickeln sind.

Vorwissen erforderlich

Bevor man mit der Entwicklung von Single Page Applications beginnen kann, muss man sich solide Grundkenntnisse in den Web-Standards HTML(5), CSS und JavaScript angeeignet haben. Da pures JavaScript eine mühselige und fehleranfällige Angelegenheit ist, sollte man mit der Verwendung einer JavaScript-Bibliothek wie jQuery vertraut sein, die den Zugriff auf das Document Object Model (DOM) erleichtert und vereinheitlicht. Die heute gängige Form der Entwicklung in JavaScript nutzt zudem dessen Fähigkeiten als dynamische, funktionale Sprache mit Prototyping voll aus und ist nicht mit dem Codieren simpler Hilfsmethoden zu vergleichen. Darüber hinaus sollte man sich der Herausforderungen der protokollnahen Web-Entwicklung bewusst sein, insbesondere der Statuslosigkeit des HTTP-Protokolls und der asynchronen Kommunikation zwischen dem Browser (Client) und dem Web-Server.

Dieses Wissen kann nicht bei jedem Entwickler als vorhanden vorausgesetzt werden. Speziell für ASP.NET WebForms lässt sich sagen, dass Microsoft große Anstrengungen unternommen hat, um die Feinheiten der Web-Programmierung vor dem Entwickler zu verbergen und ein windowsähnliches Programmiermodell im Web zu ermöglichen. Jeder, der längere Zeit mit ASP.NET WebForms gearbeitet hat, weiß, dass das nur bedingt funktioniert, sich aber mit Workarounds und/oder der exzessiven Nutzung der Session meistens recht gut in den Griff bekommen lässt. Bei der Entwicklung einer Single Page Application kommt man hingegen nicht umhin, sich um eine Reihe von Aufgaben selbst zu kümmern, die einem von ASP.NET WebForms abgenommen werden. Darin liegt aber auch zugleich die Chance, wieder der eigene Herr über den Kontrollfluss innerhalb der Web-Anwendung zu werden, anstatt sich darauf zu verlassen, dass ASP.NET WebForms sich in allen erdenklichen Szenarien wie erwartet verhält.

Strukturierung ist essenziell

Niemand ist heutzutage noch darauf angewiesen, seinen Code in einem einfachen Text-Editor schreiben zu müssen. So bietet z. B. Microsoft Visual Studio eine recht gute Unterstützung für die Entwicklung on JavaScript, von Autovervollständigung (IntelliSense) über Syntax-Fehlererkennung bis hin zur Möglichkeit, den Code zur Laufzeit mit Breakpoints zu debuggen.

Bei der Strukturierung des Codes und dessen Aufteilung auf eine oder mehrere Datei(en) hat man hingegen die totale Freiheit. Das mag zwar im ersten Moment angenehm sein, führt aber in Projekten mit mehreren Beteiligten schnell dazu, dass sehr unterschiedliche Systematiken auf einander treffen. Im schlimmsten Fall kann der Versuch eines erfahrenen Entwicklers, seinen Code möglichst systematisch aufzubauen, einem unerfahrenen Entwickler wie ein Buch mit sieben Siegeln erscheinen. Deshalb ist das Festlegen verbindlicher Coding Guidelines in größeren Projekten unverzichtbar. Das gilt zwar ohnehin für alle Programmiersprachen, bei einer flexiblen Skriptsprache wirkt sich der Verzicht auf ein gemeinsames Grundgerüst besonders schnell negativ aus. Wenn ein Testing-Framework zum Einsatz kommen soll, was bei unterschiedlichen Arten von Anwendungen auch unterschiedlich wichtig ist, sollte man sich möglichst früh dafür entscheiden – am besten, bevor die erste Zeile Code geschrieben wird.

JavaScript-Bibliotheken – Fluch und Segen zugleich

Der Großteil der im letzten Abschnitt getroffenen Aussagen zur Strukturierung von JavaScript-Code lässt sich auch auf die Einbindung von JavaScript-Bibliotheken übertragen. Von diesen gibt es inzwischen eine unüberschaubare Anzahl, mit der so gut wie jedes in der Web-Entwicklung auftretende Problem mit wenigen Funktionsaufrufen gelöst werden kann. Und das Beste: Fast alle Bibliotheken sind für private wie kommerzielle Projekte frei verwendbar. Doch leider hat die Sache gleich mehrere Haken: Fast immer gibt es nicht nur verschiedene Lösungen, sondern verschiedene Lösungswege, die möglicherweise unterschiedlich gut mit dem Rest der Anwendung harmonieren. Das gilt auch für das Zusammenspiel der JavaScript-Bibliotheken untereinander, beispielsweise in Randbereichen wie Lokalisierung und Mehrsprachigkeit. Die populärste Lösung muss nicht immer die am besten geeignet sein, auch wenn hohe Downloadzahlen auf Sourceforge & Co. und eine hohe Anzahl Suchmaschinentreffer oft für einen recht hohen Reifegrad der Bibliothek sprechen. Durch die Quelloffenheit von JavaScript lässt sich miese Code-Qualität viel schwerer als bei vorkompilierten Programmiersprachen kaschieren.

Das mit Abstand größte Problem im Zusammenhang mit JavaScript-Bibliotheken liegt allerdings in ihrem unüberlegten Einsatz in Situationen, in denen man mit geringem Mehraufwand auch mit vorhandenen Mitteln zum Ziel kommen würde. Eine Anwendung, die nur wenige Benutzereingaben zulässt, benötigt kein extrem mächtiges Validierungs-Framework, ebenso wenig wie eine Anwendung, deren Bedienelemente ohnehin zu winzig für die Verwendung mit Touch-Geräten sind, spezielle Gesten unterstützen muss. Das soll aber kein Plädoyer gegen JavaScript-Bibliotheken sein. Die Verwendung von jQuery oder vergleichbaren Alternative ist eigentlich immer sinnvoll, weil dadurch die Entwicklung in JavaScript und der Zugriff auf viele der typischsten HTML-Features vereinfacht und vereinheitlicht wird. Da gerade bei Single Page Applications sehr viel auf Client-Seite stattfindet, machen bestimmt auch noch einige weitere Bibliotheken Sinn, wenn sich dadurch wichtige bzw. häufig benötigte Aspekte innerhalb der Anwendung deutlich besser umsetzen lassen.

Man sollte aber nicht aus den Augen verlieren, dass mit jeder zusätzlichen Bibliothek Komplexität der Anwendung und der Dokumentationsbedarf steigen. Große Software-Projekte werden oft mehrere Jahre später als ursprünglich geplant abgelöst. Für webbasierende Anwendungen bedeutet das fast immer, dass sie mehrere Browser-Generationen überdauern werden. Die unbedachte Einbindung zu vieler Bibliotheken, vor allem solchen, bei denen nicht absehbar ist, ob sie in ein paar Jahren noch jemand pflegen wird, kann nur in eine Sackgasse führen. Und das ist genau die Art von Sackgasse, die die Umstellung vieler Unternehmensanwendungen von Internet Explorer 6 auf eine neuere Version zu einem sehr aufwändigen und sehr teuren Unterfangen gemacht haben und machen.

Es muss nicht immer MVC sein

Bei Microsoft ist man sich der wachsenden Bedeutung von Single Page Applications bewusst. Diese waren in letzter Zeit immer wieder Gegenstand von Microsoft-Vorträgen, wie dem von Steve Sanderson bei den Tech Days 2012. Mittlerweile bietet Microsoft sogar ein ASP.NET Single Page Application Template für Visual Studio 2012 bzw. Web Tools 2012 zum Download an. Auffällig ist, dass Single Page Applications bei Microsoft immer oder fast immer in Kombination mit ASP.NET MVC thematisiert werden. Diese Verbindung ist naheliegend, weil ASP.NET MVC direkt auf dem Web-Programmiermodell aufbaut, ohne, wie bei ASP.NET WebForms, eine Abstraktionsebene darüber zu konstruieren. Da aber für Single Page Applications in der Thin-Server-Variante das Backend keine große Rolle mehr spielt, können diese grundsätzlich auch auf Basis von ASP.NET WebForms realisiert werden. Dies kann besonders für Entwickler, die sich nicht zusätzlich in ASP.NET MVC einarbeiten möchten, oder innerhalb von Projekten, in denen ASP.NET WebForms gesetzt ist, ein gangbarer Weg sein.

Der praktische Teil dieser Artikelserie, der im nächsten Teil folgen soll, wird deshalb bewusst mit ASP.NET WebForms umgesetzt werden. Die Unterschiede zu einer Umsetzung in ASP.NET MVC werden jedoch, so viel sei an dieser Stelle schon verraten, überschaubar sein.

Single Page Applications – Teil 2: Selling Points

Im ersten Teil dieser Artikelserie wurde die neue Bedeutung von JavaScript hervorgehoben und der Begriff Single Page Application (SPA) definiert. Bei dieser Definition wurden bereits einige Eigenschaften beschrieben, die Single Page Applications von klassischen Web-Anwendungen unterscheiden. Diese Aussagen gilt es zu vertiefen und weitergehende Betrachtungen anzustellen.

Geschwindigkeit zählt

Im Internet kursieren zahlreiche Blogartikel und lesenswerte Auszüge aus Büchern, die zum gleichen Schluss kommen: Ein Computersystem hat ungefähr 0,1 Sekunden, um auf die Aktion eines Benutzers zu reagieren, ohne dass dieser die Verzögerung wahrnimmt. Punktuell auftretende Verzögerungen, wie sie beispielsweise beim Speichern einer größeren Datenmenge auftreten, sind für den Benutzer in den meisten Fällen noch nachvollziehbar. Sehr viel schwieriger ist es zu vermitteln, wenn eine Anwendung mehrere Sekunden für den Wechsel zum nächsten trivialen Datensatz benötigt oder eine gefühlte Ewigkeit damit verbringt, den Inhalt einer gerade geöffneten Drop-Down-Liste vom Server zu laden. Während die meisten Firmen ihren Kunden solche Antwortzeiten nicht zumuten, hält sich bei unternehmensintern genutzter Software hartnäckig die Annahme, dass die Geschwindigkeit im Grunde egal ist, solange die Software funktioniert. Das war 1982 schon falsch und ist heute, da schnelle und leicht zu bedienende Elektronik längst zur Massenware geworden ist, falscher denn je.

Die gute Nachricht ist, dass Single Page Applications die Geschwindigkeitsprobleme, die viele klassische Web-Anwendungen im Laufe ihrer Weiterentwicklung bekommen, fast immer wirkungsvoll vermeiden. Der Schlüssel dazu ist die Verlagerung von großen Teilen der Programmlogik auf den Client. Die optimierten JavaScript-Engines moderner Browser und der hohe Reifegrad von umfangreichen JavaScript-Bibliotheken wie jQuery ermöglichen heute so niedrige Antwortzeiten, wie man sie bis vor zwei, drei Jahren eigentlich nur von proprietären Browser-Plugins gewöhnt war. JavaScript ist zwar nach wie vor keine RIA-Technologie im engeren Sinne, aber vieles von dem, für das man bis vor kurzem RIA-Technologien zu brauchen glaubte, vom interaktiven Web-Charting bis hin zum Browser-Spiel, wird heute schon zunehmend oder fast ausschließlich mit JavaScript umgesetzt.

Ständig verbunden?

Die digitale Welt begleitet uns heutzutage in vielen Lebenssituationen, vom Strichcode gegen Baby-Verwechslung auf der Neugeborenen-Station bis hin zum QR-Code auf dem Grabstein. Doch auch unter weniger endgültigen Umständen ist man selbst in dicht besiedelten Gegenden wie dem Rhein-Main-Gebiet manchmal plötzlich offline, besonders unterwegs. Ein weiteres Phänomen sind von den Telefonanbietern beworbene Smartphone-Tarife mit LTE und Internet-Flatrate, bei der dann im Kleingedruckten zu lesen ist, dass die Geschwindigkeit nach Inanspruchnahme eines Datenvolumens von 200 MB für den Rest des Monats auf ca. 8 KB pro Sekunde gedrosselt wird. Aus diesen und anderen Gründen sind Webseiten und Web-Applikationen, die auf fast jede Aktion hin mit dem Server kommunizieren, für die mobile Nutzung nicht zu empfehlen.

Im Gegensatz dazu kommen Single Page Applications, die nach dem Thin-Server-Prinzip entwickelt worden sind, mit einem Minimum an Kommunikation zwischen Server und Client aus. Das bedeutet nicht, dass Single Page Applications per se offline genutzt weitergenutzt werden können, da hierfür vom Entwickler geeignete Voraussetzungen geschaffen werden müssen. Der Aufwand ist aber in den meisten Fällen überschaubar, sofern die Endgeräte, auf denen die Single Page Application verfürgbar gemacht werden soll, über einen HTML5-fähigen Browser verfügen. Ist dies der Fall, so können die Daten der Anwendung im Local Storage des Browsers zwischengespeichert und zu einem späteren Zeitpunkt mit dem Datenbestand des Servers abgeglichen werden. Falls mehrere Benutzer auf die gleichen Datensätze zugreifen oder sich die Datensätze auf Serverseite aus anderen Gründen verändern, kann hierdurch aber eine neue Komplexität entstehen, die bei der Entwicklung der Anwendung berücksichtigt werden muss.

One Application – Multiple Devices

Während der Markt für PCs und Notebooks seit Längerem schrumpft, liegen die Absatzzahlen für Tablet-PCs weit über den Prognosen. Gleichzeitig gibt es bei den Smartphones einen anhaltenden Trend zu Displaygrößen jenseits von 4 Zoll – bei manchen Modellen muss man sich inzwischen sogar fragen, ob es sich noch um ein Smartphone handelt. Software-Anbieter sehen sich in diesem Umfeld oftmals gezwungen, die gleiche Anwendung auf mehreren Plattformen in unterschiedlichen Programmiersprachen zu entwickeln. Die damit verbundenen Zusatzaufwände und –kosten sind beträchtlich, ebenso wie die damit oft verbundenen Verzögerungen in Punkto Time-to-Market.

Ein Ausweg aus diesem Dilemma können wiederum Single Page Applications sein, wenn auch nicht für jedes denkbare Szenario. Da sie im Browser laufen, muss man für Anwendungen, die den direkten Zugriff auf die Gerätehardware benötigen, Umwege wie den über Cordova (ehemals PhoneGap) gehen. Da es aber auf den meisten Plattformen möglich ist, in HTML und JavaScript geschriebene Anwendungen in eine native Hülle einzubetten, sind die Möglichkeiten inzwischen fast unbegrenzt. Spätestens seit Microsoft JavaScript zu einem Eckpfeiler für die App-Entwicklung unter Windows 8 gemacht hat, dürfte klar sein, dass der HTML/CSS/JavaScript-Technologie-Stack wie kein anderer von der Diversifizierung der Gerätetypen und Betriebssysteme profitiert hat. Single Page Applications sind innerhalb der Familie der JavaScript-basierenden Anwendungen die Variante, deren User Experience die größte Nähe zu nativen Apps verspricht, bis zu dem Punkt, an dem es fast nicht mehr möglich ist, diese voneinander zu unterscheiden.

Klarere Verhältnisse

Bei Vergleichen zwischen Silverlight und JavaScript wurde in der Vergangenheit oft hervorgehoben, dass Silverlight ein einheitliches Programmiermodell bietet, anders als JavaScript in Kombination mit einem serverseitigem Backend. Das ist zwar etwas kurz gegriffen, weil man Node.js außen vor lässt, aber zumindest für den .NET-Bereich stimmt das schon. Bei Single Page Applications nach dem Thin-Server-Prinzip besteht dieses Problem aber in weit geringerem Maße als beispielsweise bei klassischen ASP.NET-WebForms-Anwendungen. Das gilt insbesondere für die Code-Duplizierung zwischen Client und Server, etwa bei der Validierung der meisten Benutzereingaben. Bei einer Single Page Application nach dem Thin-Server-Prinzip sind die Zuständigkeiten klar zugunsten des Clients verteilt und die Kommunikation zwischen Client und Server findet über definierte Endpunkte statt. Somit lässt das Argument gegen den Durcheinander, das ursprünglich ein Argument für Silverlight und gegen Anwendungen mit hohem JavaScript-Anteil war, sich – mit ein paar Abstrichen – inzwischen auch als Argument für Single Applications und gegen den für ASP.NET-WebForms-Anwendungen typischen Code-Mischmasch verwenden.

Nachdem es in diesem Teil der Artikelserie in erster Linie um die Vorzüge von Single Applications ging, soll der nächste Teil den Herausforderungen, die ihre Entwicklung mit sich bringt, gewidmet sein.