Semantik. Der konstante Faktor von Script-Kiddie bis IT-Architekt


16.02.2018 von

https://www.iteratec.de/fileadmin/Bilder/News/iteratec_logo.png https://www.iteratec.de/fileadmin/Bilder/News/iteratec_logo.png iteratec GmbH

Die Grenze zwischen Softwareentwicklung und IT-Architektur ist fließend. Das wird jedem schnell klar, der sich an einer Abgrenzung versucht. Genau so schwer wie die scharfe Grenze zwischen beidem zu finden, ist es jedoch die Punkte zu benennen, die alle Ebenen des Entwicklungs- und Konzeptionsprozesses gemein haben. Ich möchte hier zumindest einen Aspekt beleuchten, auf den das zutrifft: die zentrale Bedeutung von Semantik. Semantisch sinnvolle Einheiten zu konzipieren und zu implementieren ist ein wesentlicher Bestandteil guten Softwaredesigns. Häufig finden Bugs ihren Weg in den Code, die Konzeption neuer Features Umstände werden übersehen oder Software verhält sich unvorhersehbar, wenn man semantisch unstimmige Einheiten konzipiert. Ich möchte dies hier an einigen Beispielen demonstrieren und dabei zeigen, wie sich die Aufgaben aller Beteiligten an diesem Punkt eines Softwareprojekts analog verhalten.

Regel: Konzipiere jede Einheit (von Variable über Methode, Klasse und Schnittstelle bis zu ganzen Anwendungen) so, dass ihre technische Gestalt ihre volle Semantik und nur ihre Semantik widerspiegelt.

Level One: Script-Kiddie

Script-Kiddies schreiben Perl. Immer. Sehr wahrscheinlich liegt es daran, dass es in Perl am einfachsten ist den grauenhaften Code zu erzeugen, den Script-Kiddies lieben. Doch bekanntlich lässt sich in jeder Sprache guter und schlechter Code schreiben.

Nun hat Kiddie ein Script geschrieben, um über eine (fiktive) Facebook-API zu prüfen, ob er bereits mehr als 200 Freunde hat:

use LWP::UserAgent;
my $url = "http://facebook.com/api/friendscount/scriptkiddie";
my $r = HTTP::Request->new(GET => $url);
$r = LWP::UserAgent->new->request($r);
if ($r->is_success) {
    $r = $r->decoded_content;
}
else {
    $r = $r->code
}
if($r > 200) {
    print "Yeah! I've got more than 200 friends!"
}

Dieses Script führt Kiddie seit einigen Jahren jeden Abend aus – und eines Tages erscheint tatsächlich die lang ersehnte Ausgabe.

>> perl friends.pl
Yeah! I've got more than 200 friends!

Leider ist das nicht der Fall. Die Freundeszahl liegt immer noch bei 187 - doch die nicht dokumentierte Facebook-API wird inzwischen nicht mehr unterstützt, weshalb der Request nun immer den StatusCode 404 zurückgibt - was größer als 200 ist.

Wo liegt nun der Fehler? - In der Variable $r. Diese hat möglicherweise zu Anfang noch "Request" bedeutet, dann vielleicht "Response", aber spätestens nach dem if-Block hatte sie keine eindeutige Semantik mehr:

if ($r->is_success) {
    $r = $r->decoded_content;
}
else {
    $r = $r->code
}

Trifft man auf ein fragwürdiges Konstrukt, empfiehlt es sich zwei Fragen zu stellen:

  1. Kannst du $r einheitlich als Gegenstand oder als Eigenschaft beschreiben?
  2. Ist $r auch tatsächlich, was es behauptet zu sein?

In diesem Fall wäre die Antwort auf Frage 1: "$r ist die Antwort, wenn es eine gab, und sonst der StatusCode des Fehlers". Aber ist das ein Gegenstand?

Da bereits die Antwort auf Frage 1 "Nein" lautet, haben wir ein semantisch nicht eindeutiges Konstrukt gefunden. Ein StatusCode ist ein Gegenstand, eine Antwort genauso. $r in unserem Beispiel aber hat keine eindeutige Semantik.

Daher rührt auch der Fehler, den der Autor des Script möglicherweise niemals finden wird, weil er das Script nie wieder ausführen wird. Hätte der if-Block nun so ausgesehen:

if ($r->is_success && $r->decoded_content > 200) {
  print "Yeah! I've got more than 200 friends!"
}
else {
    print "Error: $r->code"
}

... dann wäre der Code vermeintlich komplizierter gewesen, doch wäre der Fehler nicht unterlaufen. Und für einwandfreie Semantik kann man getrost erheblich mehr zahlen als eine logische Verzweigung.

Level Two: Software-Ingenieur (oder: was ist eigentlich objektorientierte Programmierung?)

Einer der wichtigsten Gründe, die für das Paradigma objektorientierter Programmierung sprechen, ist nun, dass sie es erleichtert auf Semantik zu achten, weil sie dem Entwickler unsere obigen Fragen (mehr oder weniger) aufzwingt. Doch auch hier lässt sich ein ähnlicher Fehler machen:

public class NumberOfFriendsChecker {

  BOLD!private boolean hasChecked = false;!BOLD
  private int numberOfFriends;

  public static void main(String[] args) {
    BOLD!boolean enoughFriends = new NumberOfFriendsChecker().isNumberOfFriendsGreaterThan(200);!BOLD
    if(enoughFriends) {
      System.out.println("Enough friends!");
    }
    else {
      System.out.println("Not enough friends.");
    }
  }
  
  public boolean isNumberOfFriendsGreaterThan(int compareValue) {
    return BOLD!hasChecked &&!BOLD numberOfFriends > compareValue;
  }
  
  public void check() {
    try {
      this.numberOfFriends = Integer.valueOf(Request.Get("http://facebook.com/api/friendscount/softwareengineer").execute().returnContent().asString());
    } catch(Exception e) {
      throw new IllegalStateException("Checking failed", e);
    }
    BOLD!hasChecked = true;!BOLD
  }
}

Die entscheidende Frage hier lautet: tut die Methode isNumberOfFriendsGreaterThan(int) was sie verspricht? Die Antwort ist zumindest nicht uneingeschränkt "Ja", denn: die Implementierung setzt voraus, dass vor Aufruf der Methode bereits check() aufgerufen wurde. Da das in diesem Fall nicht geschehen ist, gibt die Methode fälschlich false zurück. Die Anzahl der Freunde ist nicht (zwangsläufig) kleiner als 200, es ist nur noch nicht bekannt, wieviele Freunde der Entwickler hat. Der Fehler ist auch hier, dass die Semantik der Methode nicht einheitlich ist, wir scheitern jedoch erst an der zweiten Frage: die Methode ist von der Intention her semantisch einheitlich, tut aber nicht, was sie verspricht. Sie gibt die Anzahl der Freunde zurück - oder false wenn sie nicht bekannt ist.

Eine mögliche Lösung wäre nun gewesen die Methode so zu ändern, dass sie Boolean statt boolean zurückgibt und im obigen Fall null zurückzugeben. Das ist eine gängige Lösung, doch modelliert sie das Problem auf sehr technische Art und Weise. Die semantisch eindeutigste Lösung wäre vermutlich eine Checked Exception zu deklarieren, die geworfen wird, wenn die Methode zu früh aufgerufen wird:

public class NotCheckedYetException extends Exception {
  ...
}

public class NumberOfFriendsChecker {

  private boolean hasChecked;
  
  ...
  
  public synchronized boolean isNumberOfFriendsGreaterThan(int compareValue) throws NotCheckedYetException {
    BOLD!if(!hasChecked) {
      throw new NotCheckedYetException();
    }!BOLD
    return numberOfFriends > compareValue;
  }
  
  
  
  
  public static void main(String[] args) {
    try {
      boolean enoughFriends = new NumberOfFriendsChecker().isNumberOfFriendsGreaterThan(200);
      ...
    } catch(NotCheckedYetException e) {
      System.out.println("I have made a terrible mistake and I will fix this now.");
    }
  }
}

Die Meinung über die Checked Exceptions, die Java vorsieht, gehen auseinander - spätestens seit die Macher von C# beschlossen haben auf Checked Exceptions zu verzichten. Freilich ließe sich das obige Beispiel auch mithilfe einer RuntimeException umsetzen, doch erleichtern CheckedException die Plausibilisierung enorm. Den obigen Code würde wohl niemand schreiben, da die CheckedException bereits darauf hinweist, dass ein Fehler vorliegt.

Level Three: Senior Softwareingenieur

Wir haben nun gesehen, dass man Fehler in der semantischen Konzeption auf Variablen- wie auf Methodenlevel machen kann. Betrachten wir einmal einen größeren Kontext und nehmen an, wir wären nun zu Facebook gewechselt und gerade im Begriff die besagte API als SpringBoot-Anwendung zu implementieren.

@RequestMapping(value="/api/friendscount/{name}", method=RequestMethod.POST)
public long numberOfFriends(@PathVariable(value="name") String name) {
  User user = this.userRepository.getUserByName(name);
  if(user == null) {
    return 0;
  }
  else {
    return user.getNumberOfFriends();
  }
}

Wir bewegen uns nun auf der Ebene von REST-Schnittstellen. Jetzt ist die obige Methode funktional einwandfrei. Wird die Schnittstelle korrekt aufgerufen, gibt sie die Anzahl der Freunde zurück. Nun ist aber Semantik umso wichtiger, je größer dimensioniert der Kontext ist. An der Semantik dieser Schnittstelle stimmen gleich mehrere Dinge nicht:

  1. Die Methode gibt 0 zurück, wenn der Nutzer nicht existiert. Das ist semantisch problematisch, weil damit 0 zum Fehlercode wird, es aber tatsächlich Menschen ohne Freunde geben soll.
  2. Die Methode ist eine Abfrage ohne Seiteneffekte, verlangt aber die Request Method POST. Das widerspricht der Semantik dieses HTTP-Verbs.
  3. Die Methode wird (wenn keine Exception auftritt) immer den StatusCode 200 zurückgeben, etwa auch dann wenn der abgefragte Nutzer nicht existiert. Das widerspricht der Semantik des StatusCodes.

Die erste Unstimmigkeit haben wir bereits im obigen Abschnitt kennengelernt. "-1" wäre ein besserer Rückgabewert, doch haben wir andere Optionen. Die Punkte 2 und 3 verweisen auf die Semantik des HTTP-Protokolls. In der Begrifflichkeit von Richardson's Maturity Model bewegt sich unser Code auf Stufe 1 (von 3) der korrekten Verwendung von HTTP. Wir verwenden zwar Ressourcen, doch ist die Semantik der Verben und StatusCodes falsch, von Hypermedia (die ohnehin ein Fabelwesen ist) ganz zu schweigen.

Nun ist die Semantik von HTTP keineswegs eine Nebensächlichkeit. Sehen wir auf unseren Fehler Nummer 2: die Verwendung des POST-Verbs hindert die Schnittstelle nicht daran, den korrekten Wert zurückzugeben. Doch bietet die Semantik des Verbs POST sehr viel weniger Garantien als die des Verbs GET. So dürfen POST-Requests Seiteneffekte haben. Das hat eine einfache Konsequenz: einen POST-Request zweimal hintereinander auszuführen kann fundamental verschiedene Ergebnisse haben. Daher verbietet die Semantik von POST, dass Requests etwa durch Proxy-Server gecacht werden. Solche semantischen Spitzfindigkeiten können enorme Konsequenzen haben - hier etwa auf die erzeugte Traffic -, die wir durch ein simples GET verhindern können.

Ähnlich verhält es sich mit unserem Fehler Nummer 3: der StatusCode 200 bedeutet OK. Habe ich mich bei meinem Request vertippt und die Anzahl von Freunden für "scriptkiddie" statt für "softwareengineer" angefragt, kann ich zwar offensichtlich überhaupt nicht tippen, sollte aber dennoch auf meinen Fehler hingewiesen werden. Der richtige StatusCode wäre hier ein 400 (BAD REQUEST):

BOLD!@ResponseStatus(value=HttpStatus.BAD_REQUEST)!BOLD
public class UserNotFoundException extends RuntimeException {}

@RequestMapping(value="/api/friendscount/{name}", method=RequestMethod.BOLD!GET!BOLD)
public long getNumberOfFriends(@PathVariable(value="name" String name) {
  User user = this.userRepository.getUserByName(name);
  if(user == null) {
    BOLD!throw new UserNotFoundException();!BOLD
  }
  else {
    return user.getNumberOfFriends();
  }
}

Das Beispiel von HTTP unterstreicht die Bedeutung von Semantik besonders. Denn niemand zwingt einen Webserver oder einen Client sich unterschiedlich zu verhalten, wenn ein GET- oder ein PUT-Request verarbeitet wird. Halten sich aber alle Seiten an die Semantik der Verben, wird dadurch die Kommunikation erheblich effizienter.

Level Four: IT-Architekt

Wir können hier nahtlos auf das Level des Designs ganzer Anwendungslandschaften übergehen. In den Zeiten von Microservice-Architekturen und Cloud-nativen Anwendungen, die nach Bedarf skalieren, müssen wir immer die Möglichkeit in Betracht ziehen, dass unser Request von einem Service gerade aufgrund zu vieler Anfragen nicht bearbeitet werden kann. Nun ist es für alle Beteiligten besser, wenn der Service den Request nicht beliebig queued und versucht irgendwann der Last Herr zu werden. Der Service sollte nun aber nicht etwa mit einem StatusCode 500 antworten. Der StatusCode ist für unvorhergesehe Fehlersituationen vorgesehen, etwa eine Anwendungs-Exception. Für unseren Fall gibt es einen dedizierten StatusCode: es ist hier absolut valide uns für den Service einen 503 (SERVICE UNAVAILABLE) zurückzugeben. Dies setzt uns darüber in Kenntnis, dass "this is a temporary condition which will be alleviated after some delay". Die Semantik des StatusCodes beinhaltet, dass der Zustand temporär ist, daher dürfen wir den Request wiederholen. Freilich ist "temporär" ein dehnbarer Begriff. Im Idealfall hat der Server noch den Retry-After-Header gesetzt, dann wissen wir wann wir angehalten sind unseren Request zu wiederholen. Andernfalls müssen wir einen Mittelweg finden: wir sind gebeten worden einerseits zu Kenntnis zu nehmen, dass der Server gerade keine Ressourcen hat unsere Anfrage zu bearbeiten, andererseits ermutigt worden die Anfrage zu wiederholen. Was wir in jedem Fall vermeiden müssen ist durch zahlreiche Retrys die Last auf dem Server so weit zu erhöhen, dass er keine Möglichkeit mehr hat die Queue von Anfragen abzuarbeiten. Netflix hat für solche Fälle das Hystrix-Framework implementiert. Unser Big Brother-Service, der automatisiert die Anzahl von Freunden aller bekannten Softwareentwickler abfragt, sähe dann so aus:

@HystrixCommand
public Long getNumberOfFriendsFromFacebook(String developer) {
  return Long.valueOf(Request.Get("http://facebook.com/api/friendscount/" + developer).connectTimeout(2000).execute().returnContent().asString());
}

public Map<String, Long> getNumberOfFriends() {
  List<String> developers = Arrays.asList("scriptkiddie", "softwareengineer");
  Map<String, Long> result = new HashMap<>();
  for(String developer : developers) {
    result.put(developer, this.getNumberOfFriendsFromFacebook(developer));
  }
  return result;
}

Hystrix ist ein sogenannter Circuit Breaker, der dafür sorgt, dass für eine bestimmte Zeit keine weiteren Requests ausgeführt werden, wenn zu viele Requests in kurzer Zeit fehlschlagen. Wir würden Hystrix noch feinkonfigurieren müssen, doch wenn wir von Anfang an alle Aufrufe unserer Nachbarservices als Hystrix-Kommandos implementieren, wird das nur eine kleine Anpassung an einem Property-File sein. Wer noch an der Bedeutung solcher Maßnahmen zweifelt, sei auf ein unterhaltsames Kapitel Michael Nygards verwiesen: "The Exception That Grounded An Airline" (Michael Nygard, Release It!, The Pragmatic Bookshelf 2007: 9-22). Es ist tatsächlich so schlimm, wie es klingt. 

Die Verwendung des CircuitBreakers auf Client-Seite wie die Verwendung des Status 503 auf Server-Seite basieren auf einem einfachen Konzept: Semantik. Nicht zu unterschätzende Teile aller Fehler, die in Softwarearchitektur, -design und -implementierung geschehen, lassen sich (weitgehend) vermeiden, wenn man sich stets die Frage nach der Semantik stellt. Diese Frage nimmt für verschiedene Kontexte unterschiedliche Gestalten an:

  • Wird diese Variable einheitlich verwendet?
  • Tut diese Methode was ihr Name sagt?
  • Modelliert diese Klasse einen einheitlichen Gegenstand oder ist sie nur eine zufällige Zusammenstellung von Feldern und Methoden?
  • Gibt diese REST-Schnittstelle die richtigen StatusCodes zurück?
  • ...

Und so weiter. Man kann hunderte solcher Fragen auflisten, von Script-Kiddie bis IT-Architekt. Doch am Ende bleibt es nur eine: die nach der Semantik.

Diesen Artikel bewerten
 
 
 
 
 
 
 
9 Bewertungen (100 %)
Bewerten
 
 
 
 
 
 
1
5
5
 

Artikel im Warenkorb:

0