Für mehr Evolvierbarkeit von Software wie Team müssen wir Software grundsätzlich anders aufgebaut denken, glaube ich. Derzeit stehen Objekte im Vordergrund. Das halte ich für eine überholte Sichtweise. Sie entstammt einer Zeit, da Software weniger umfangreich war und nur mit knappen Ressourcen betrieben wurde.
Historisch hat alles mit einem bunten Gemisch von Anweisungen und Daten innerhalb eines Betriebssystemprozesses angefangen. Der Prozess spannt die Laufzeitumgebung für den Code auf.
Unterprogramme entstanden erst, als Programme umfangreicher wurden. Sie sind ein erster Schritt zur Kapselung von Code. Die Zahl der Anweisungen konnte durch sie weiter steigen, ohne dass die Übersichtlichkeit drastisch gesunken wäre.
Als nächstes entwickelte man Container für Daten. Sie sollten zu größeren Einheiten zusammengefasst werden. Die Sprache C spiegelt diesen Entwicklungsstand wider: sie bietet Unterprogramme (Prozeduren und Funktionen) sowie Strukturen.
Anschließend gab es einen letzte Schritt: die Objektorientierung. Sie hat Strukturen und Unterprogramme zu Klassen zusammengefasst. Dadurch wurde Software nochmal etwas grobgranularer, so dass sich mehr Anweisungen innerhalb eines Prozesses übersichtlich handhaben ließen.
Parallel gab es eine Entwicklung von Bibliotheken. Zuerst wurden in ihnen Unterprogramme und Strukturen zusammengefasst, später Klassen.
Doch auch wenn Bibliotheken schon lange existieren, sind sie aus meiner Sicht nie zu einem gleichwertigen Container neben Klassen aufgestiegen. Sie werden nicht konsequent zur Strukturierung und Entkopplung im Sinne der Evolvierbarkeit eingesetzt. Ihr Hauptzweck ist es, Wiederverwendung zu ermöglichen. Sie sind die Auslieferungseinheiten für Code, der in unterschiedlichen Zusammenhängen genutzt werden können soll.
Die hauptsächliche Strukturierung von Software befindet sich mithin auf dem Niveau der 1990er, als die Objektorientierung mit C++, Delphi und dann Java ihren Siegeszug angetreten hat.
Die Bausteine, aus denen Software zusammengesetzt wird, sind unverändert feingranular; es sind die Klassen. Der Umfang von Software ist seitdem jedoch kontinuierlich gestiegen. Mehr Hauptspeicher macht es möglich. Früher waren es 640 KB oder vielleicht wenige MB; heute sind es 4 GB und mehr.
Nur eine weitere Veränderung hat sich noch ergeben. Die Zahl der Prozesse pro Anwendung ist gewachsen, weil die Zahl der beteiligten Maschinen gewachsen ist. Das Ideal ist zwar immer noch, so wenige Prozesse wie möglich laufen zu haben, doch Mehrbenutzeranwendungen erfordern für Performance und Skalierbarkeit oft mindestens zwei Prozesse, einen für den Client und einen für den Server.
Im Vergleich zur Zahl der in einer Anwendung involvierten Bibliotheken und vor allem der Klassen, ist die Zahl der Prozesse jedoch verschwindend klein. Prozesse sind heute keine Mittel zur Strukturierung von Software im Sinne der Evolvierbarkeit. Sie werden als notwendige Übel betrachtet, um andere nicht-funktionale Anforderungen zu erfüllen.
Komponenten für industrielle Softwareentwicklung
Bibliotheken sind Konglomerate von Klassen. Die gehören inhaltlich zwar zusammen, nur ist ihre Leistung nicht sehr klar definiert. Ihr Angebot, der Kontrakt, ist implizit.
Das ändert sich erst mit Komponenten. Komponenten sind Bibliotheken mit explizitem und separatem Kontrakt.
Mit Komponenten ist es möglich, Software industriell zu entwickeln. An allen Komponenten kann gleichzeitig gearbeitet werden, auch wenn sie gegenseitig Leistungen nutzen. Die Abhängigkeit zur Entwicklungszeit bezieht sich ja nur auf den Kontrakt.
Idealerweise würde Software also mit Komponenten statt Bibliotheken strukturiert. Erstens ist die Kopplung zwischen Komponenten durch den expliziten Kontrakt geringer als zwischen Bibliotheken. Implementationen lassen sich dadurch leichter austauschen [1]. Zweitens steigt durch Komponenten die Produktionseffizienz.
Komponenten verändern gegenüber Bibliotheken allerdings nicht die Granularität von Software. Sie ziehen eher kein neues Abstraktionsniveau ein, wie es Bibliotheken in Bezug auf Klassen und Klassen in Bezug auf Methoden getan haben.
Außerdem verlangen Komponenten wie Bibliotheken und Klassen für eine Zusammenarbeit Übereinstimmung in der Plattform. .NET-Komponenten können .NET-Komponenten nutzen und Java-Komponenten können Java-Komponenten nutzen. Aus meiner Sicht gibt es keine breit praktikable Lösung, um universell plattformübergreifend Komponenten zu koppeln.
Das soll den Wert von Komponenten nicht schmälern. Sie sind sehr hilfreich und weit unterschätzt. Dennoch sollten wir auch noch einen Schritt weiter denken.
Services für Flexibilität
Der Betriebssystemprozess ist heute immer noch die Funktionseinheit, mit der wir am natürlichsten und einfachsten Code entkoppeln. Er spannt einen eigenen Adress- und Sicherheitsraum auf. Und er läuft autonom auf mindestens einem eigenen Thread.
Darüber hinaus ist der Prozess aber auch die Funktionseinheit, die wir mit einer Entwicklungsplattform assoziieren. Ob der Code eines Prozesses mit C, C++, C# oder Ruby oder Python oder Perl oder in Assembler geschrieben wurde, sehen wir dem Prozess nicht an. Von Prozess zu Prozess kann die Plattform wechseln.
Damit wird der Prozess für mich interessant im Sinne der Evolvierbarkeit. Denn die ist begrenzt, solange die Plattform eine systemrelevante Größe ist. Wer sein ganzes Team auf unabsehbare Zeit an eine Entwicklungsplattform bindet, wer den Plattformwechsel also nicht vom ersten Tag an als (wünschenswerte) Möglichkeit im Auge behält, der begibt sich auf einen unglücklichen Pfad von Co-Evolution von Software und Team: beide erstarren im Verlauf von 4-8 Jahren. Nicht nur wird es immer schwieriger, neue Funktionalität in die Codebasis einzupflegen; es wird auch immer schwieriger, echte Innovationen denken zu können und neue Leute ins Team zu holen.
Am Ende wacht dann ein Team nach Jahren auf und findet sich abgehängt. Die Kunst hat sich weiterentwickelt – nur kann man davon nicht profitieren. Weder hat sich das Team kontinuierlich Kenntnisse angeeignet, noch konnten Kenntnisse in die Codebasis einfließen.
Ohne Komponenten gibt es kaum Codeeinheiten, an denen etwas hinter einer Barriere einfach mal ausprobiert werden könnte, wenn die Plattform neue Features hergibt.
Und selbst mit Komponenten gibt es keine Codeeinheiten, an denen eine vielversprechende Plattform ausprobiert werden könnte. Oder ein vielversprechender Bewerber, dessen Plattformkenntnisse leider nicht so tief sind – der aber Experte auf einer anderen Plattform ist.
Der Code ganzer Prozesse ist heute gewöhnlich zu groß, um solche Veränderung zu erlauben. Auf der anderen Seite sind Prozesse die Codeeinheiten, mit denen solche Veränderungen auf Plattformebene einzig möglich sind.
Deshalb plädiere ich dafür, Software aus mehr, viel mehr Prozessen zusammenzusetzen als heute üblich.
Der Zweck dieser Prozesse ist aber ein anderer als der heutige. Heute dienen sie vor allem kundenrelevanten nicht-funktionalen Anforderungen wie Skalierbarkeit oder Sicherheit. Ich möchte sie nun ja aber zu einem Mittel der Flexibilität, der Evolvierbarkeit von Code und Team erheben. Deshalb nenne ich die Prozesse, um die es mir geht, Services.
Ein Service ist für mich ein Prozess mit einem expliziten und separaten und vor allem plattformneutralen Kontrakt.
Ein Service ist mithin eine Kombination aus Komponente und Betriebssystemprozess. Er definiert ganz genau und unabhängig von jeder Implementation, was er leistet. Und er ist autonom, d.h. ausgestattet mit eigenem Adressraum und asynchron gegenüber anderen Services.
Das macht einen Service zu einer vergleichsweise schwergewichtigen Funktionseinheit. Aber ich behaupte, das macht nichts. Es lassen sich in jedem Prozess jeder Anwendung Aspekte finden, die in Services verpackt mit einander arbeiten können, ohne dass dadurch ein Performanceproblem entstünde.
Mein Bild von Software sieht damit so aus:
Die Services innerhalb eines Prozesses können in einer Hierarchie zu einander stehen. Eher sehe ist sie aber auf derselben Ebene, verbunden in einem Netzwerk: a web of services.
Jenachdem, wie der plattformneutrale Kontrakt eines Services aussieht, kann er auch einfach von einem Prozess in einen anderen bewegt werden. Am Ende sind die umschließenden Prozesse nur Hosts für Services, leisten aber nichts selbst für die Domäne. Sie werden quasi unsichtbar.
Software besteht damit aus Services. Und Services können zusammenarbeiten, auch wenn sie auf verschiedenen Entwicklungsplattformen implementiert sind. Innerhalb eines Host-Prozesses können Services geschrieben in C#, Java, Ruby und Python kooperieren.
Solche Vielfalt muss nicht sein. Aber sie kann sein! Es ist möglich, eine Software, deren Services zunächst mit nur einer Plattform implementiert sind, schrittweise zu überführen. Service für Services wird einfach neu geschrieben. Das ist bezahlbar, solange ein Service keine systemrelevante Größe hat. Wenn man es sich nicht leisten kann, einen Service (oder zunächst einmal eine Komponente) neu zu schreiben, falls es schwierig wird mit Veränderungen und Refactoring… dann hat man sich in die Ecke gepinselt. Dann ist die Software wahrlich zum Monolithen erstarrt.
Sie mögen nun einwänden, die Kommunikation zwischen Services sei doch viel zu langsam. Aber wissen Sie das? Haben Sie es ausprobiert, ob Sie höhere Geschwindigkeit brauchen? Ein Roundtrip mit TCP braucht ca. 0,125msec, einer über die I/O-Streams 0,1msec, einer über einen embedded Web-Server wie Nancy im schlechtesten Fall 0,55msec – und ein direkter Aufruf nur 0,00065msec, d.h. er ist rund 500 Mal schneller.
Ja, so ist das. Aber diese im Vergleich schlechteren Werte für cross-service Kommunikation im Vergleich zu cross-component Kommunikation sagt nichts darüber aus, ob cross-service für Ihren konkreten Fall wirklich zu langsam wäre. Haben Sie das überhaupt schon einmal in Erwägung gezogen und ausprobiert? Wahrscheinlich nicht.
Genau dazu möchte ich Sie aber ermuntern. Ich möchte Sie einladen, ein web of services auszuprobieren. Experimentieren Sie damit – denn es gibt etwas zu gewinnen. Sie gewinnen all die Vorteile, die das Internet für seine Evolution und die der an ihm beteiligten Services schon nutzt.
Falls Sie glauben, ich sei der einzige mit solch einer spinnerten Idee, dann schauen Sie einmal bei infoq.com vorbei: Der Vortrag “Micro Services: Java, the Unix Way” bricht ebenfalls eine Lanze für diese Denkweise.
Ich bin überzeugt, wir müssen diesen evolutionären Schritt gehen. Wir müssen ein nächstes Abstraktionsniveau für Funktionseinheiten erschließen. SOA ist mir da schon zuviel. Aber Actors sind mir zuwenig.
Ich glaube mit “einfacheren” Services, die keinen weiteren Anspruch haben als einen separaten plattformneutralen Kontrakt für Funktionalität in einem eigenen Prozess, kann das funktionieren. Technisch ist das kein Hexenwerk. Jeder kann ein web of services aufziehen. REST sehe ich in dieser Hinsicht nicht als hübschen Ansatz für Web-Anwendungen, sondern als grundsätzliches Paradigma der Servicekopplung auch im Kleinen. Für REST braucht es keinen Web-Server; Sie können REST auch mit Service implementieren, der Requests per Standard-Input entgegen nimmt und Responses auf dem Standard-Output liefert.
Es ist vielmehr eine Frage des Willens, so zu denken. Es ist eine Frage Ihres Wertesystems: Was schätzen Sie höher ein? Garantiert bessere Evolvierbarkeit durch Plattformflexibilität – oder vermeintlich benötigte höhere Kommunikationsgeschwindigkeit?
Das ist für mich der Weg für die effizientere Herstellung von evolvierbarer Software: Funktionalität in Komponenten kapseln für bessere Entkopplung innerhalb einer Plattform; Funktionalität in Services kapseln für bessere Entkopplung in plattformneutraler Weise.
Und wenn Sie dadurch nebenbei auch noch Ihre Prozessorkerne besser ausnutzen und die Verteilbarkeit von Code steigt und Sie auf einen größeren Pool von Entwicklern zurückgreifen können bei Neueinstellungen… dann ist das auch nicht schlecht, oder?
Endnoten
[1] Es gibt mindestens zwei Gründe, Funktionseinheiten durch einen expliziten Kontrakt leicht komplett austauschbar zu halten.
Der erste ist leichtere Testbarkeit. Auch wenn es nur eine Implementation des Kontraktes für Produktionszwecke je geben wird, kann es für Tests sehr hilfreich sein, ganz einfach eine Attrappe hinter dem Kontrakt anfertigen zu können.
Der zweite Grund ist die Möglichkeit zur Neuentwicklung. Hinter einem Kontrakt kann die gesamte Implementation ausgetauscht werden, ohne dass Kontraktnutzer davon tangiert würden.
Beispiel: Wenn eine Komponente den Datenbankzugriff kapselt, dann wollen Sie in Tests von abhängigen Komponenten, nicht immer auch tatsächlich auf die Datenbank zugreifen. Also entwickeln sie z.B. eine Attrappe, die Datenbankzugriffe durch Zugriffe auf das Dateisystem simuliert.
Und auch wenn Sie nie beabsichtigen, ein anderes Datenbanksystem als MySQL zu benutzen, kann es Ihnen helfen, die gesamte Implementation der Datenbankzugriffskomponente einfach wegzuschmeißen und neu zu schreiben. Denn Refactoring kann sich irgendwann als zu aufwändig herausstellen.
Ganz zu schweigen davon, dass es mit einer Komponente für den Datenbankzugriff einfach ist, von MySQL umzusteigen auf Oracle oder SQL Server alternativ zu MySQL anzubieten. Doch diese Art der Austauschbarkeit sowie die Möglichkeit zur Wiederverwendung sind für mich eher die geringeren Gründe, mit Komponenten zu arbeiten.