Fun with Flags – Feature Flags

„Bazinga!“

Sheldon Cooper

Bei der schnellen Entwicklung von neuen Featuren gerät man irgendwann an den Punkt, dass die Synchronisation der Features Branches mit dem Main-Branch immer schwieriger und die Abhängigkeit zwischen den Feature Branches immer komplexer wird. Eine mögliche Hilfe in dieser Situation sind Feature Flags.

Mit Feature Flags bzw. Feature Toggles ist ein Team in der Lage, einzelne Feature der Anwendung gezielt zu aktivieren oder deaktivieren. Die Feature Flags können dabei auf vielfältigste Weise eingesetzt werden und in unterschiedlichster Art implementiert sein. Manche Feature Flags bestehen nur aus einem einzelnen Bit und andere sind eine vollwertige Backend-Lösung.

private static final boolean ANCESTOR_REPORT_ENABLED = true;
//...
if (ANCESTOR_REPORT_ENABLED) {
  generateAndSaveReport();
}

Das simpelste Feature Flag ist eine Verarbeitungssteuerung durch eine Konstante im Programmcode. Möchte der Entwickler das Feature Ancestor-Report deaktivieren, dann ändert er die Konstante und deployed eine neue Version der Software. Der Nachteil eines solch primitiven Feature Flags ist das erneute Deployen der Anwendung und den vorherigen Tests der geänderten Anwendung.

if (Features.flag("ancestor-member-plus").isEnabled()) {
  generateAndSaveReport();
}

In diesem Beispiel wird über eine kleine Fluent API geprüft, ob ein spezielles Feature Flag aktiviert ist. Diese Aktivierung könnte beispielsweise über eine Konfigurationsdatei, einem Datenbankeintrag oder eine Systemvariable erfolgen. Der Vorteil dieses Ansatzes ist es, dass über automatisierte Test beide Szenarien, also deaktiviertes und aktiviertes Feature getestet werden können.

Von einem Feature Flag sollte tatsächlich immer nur dann geredet werden, wenn ein Feature durch eine irgendwie geartete Konfiguration aktiviert oder deaktiviert werden kann. Eine Änderung des Quellcode sollte nicht notwendig sein.

Bevor die Möglichkeiten von Feature Flags aufgezählt werden, muss aber auch die Kehrseite der Medaille Erwähnung finden. Eine Anwendung mit einigen schaltbaren Featuren ist komplexer als eine Anwendung, die nur eine Teil dieser Features bietet. Dies bedeutet mehr Absprachen, mehr Implementierung und viel mehr Tests.

Bei dem Einsatz von Feature Flags unterscheiden sich die einzelnen Szenarien durch die Beteiligten, der Lebensdauer eines Feature Flags und dem eigentlichen Ziel das erreicht werden soll.

  • Release Toggle Der Klassiker unter den Feature Flags. Mit ihm wird gesteuert, welche Feature für die nächsten Releases aktiviert sein sollen. Üblicherweise werden Features so lange nicht aktiviert, bis sie vollständig implementiert wurden. Wurde ein Feature aktiviert und funktioniert wie erhofft, dann wird das Feature Flag aus dem Code entfernt.
  • Operational Toggle Ein Schalter für das Maintenance Team. Damit können gezielt Systeme oder Teilsysteme aktiviert oder deaktiviert werden. Dies kann nützlich sein, wenn die Last auf den Servern zu groß ist oder eine Fehlfunktion das Gesamtsystem gefährdet. Im Gegensatz zum Release Toggle bleibt ein Operational Toggle langfristig im Quellcode. Bei machen Operational Toggle wird auch von einem Kill Switch oder Explosive Bolts gesprochen. Der Kill Switch ist ein Deaktivierungsschalter und Explosive Bolts dienen, wie echte Sprengbolzen, zur Unterbrechung von Verbindungen zu Drittsystemen.
  • Environment Toggle Wenn unterschiedliche Umgebungen unterschiedliche Feature Ausprägungen benötigen, dann verwendet man diese Schalter. Beispielsweise soll ein Mail-Service auf der Test Umgebung keine E-Mails verschicken. Auf der Stage oder Integration Umgebung nur an einen besonderen Personenkreis und auf der Produktion selbstverständlich an alle Adressaten.
  • Experimental Toggle Dieser Schalter unterscheidet sich stark von den vorherigen Schaltern. Bei einem Experimental Toggle wird ein Feature nicht für alle Anwender aktiviert oder deaktiviert, sondern nur für einen gewissen Teil. Einsatzgebiete sind A/B Tests, Canary Launch und Dark Launching.
    Bei A/B Tests wird ein neues Feature getestet, in dem eine ausgewählte Gruppe der Anwender das neue Feature erhält. Die restlichen Anwender spielen dabei die Kontrollgruppe. Je nach Ergebnis der Tests wird dann das neue Feature für alle Anwender aktiviert, weiter angepasst oder verworfen.
    Bei einem Canary Launch erhält nur eine kleine zufällige Zahl von Anwendern das neue Feature. Sie entsprechen dabei den Kanarienvögeln aus früheren Bergwerken, die schnell an giftigen Gasen starben und so den Bergleuten das Leben retteten. Funktioniert ein Canary Launch nicht wie erhofft, dann kann das Feature zurückgezogen werden und nur einige Anwender haben eine schlechte Erfahrung gemacht. Bei einem positiven Canary Launch wird schrittweise die Anzahl der Benutzer mit dem Feature erhöht, bis letztendlich alle Anwender das Feature erhalten.
    Beim Dark Launching wird ein Feature zwar aktiviert, es hat aber noch keinen tatsächlichen Einfluss auf das Gesamtsystem. Beispielsweise können neue Reports erstellt oder neue Schnittstellen verwendet werden, ohne dass die Ergebnisse tatsächlich genutzt werden. Dies dient dazu, die Auswirkungen der neuen Features auf Resourcennutzung, Zeitverhalten, u.a. zu testen.
  • Permission Flags Ähnlich wie beim vorherigen Schalter werden Features für Gruppen von Anwendern aktiviert bzw. deaktiviert. Dies können Beta Testers für neue, unfertige Features sein, Premium User für kostenpflichtige Features oder Expert User für heikle Feature sein. Manchmal wird auch von Champagne Brunch gesprochen. In diesem Fall handelt es sich um neues Feature, dass erst für eine ausgewählten Gruppe von Anwendern freigeschaltet wird.

Die Entwicklung neuer Features durch Feature Flags beschleunigt sich in der Hauptsache durch den Einsatz des Release Toggles. Da unfertige Features bereits ausgeliefert werden, können Entwicklerteams Neuentwicklungen in kurzlebigen Feature-Branches ohne aufwendige Merges weiterentwickeln. Es muss nicht mehr darauf gewartet werden, wann ein neues Feature in den Release Branch gemerged werden darf und wie groß bis dahin die Abweichungen geworden sind. Ist ein Feature weit genug entwickelt, dann kann es aktiviert werden oder eben noch geraume Zeit auf seine Markteinführung warten.

Die anwenderunabhängigen Feature Flags können in Java relativ leicht implementiert werden. Release Toggle, Operational Toggle und Environment Toggle benötigen nur eine Konfiguration, die den gewünschten Schaltzustand beschreibt. Es bieten sich dabei für einfache Java Programme eine Property Datei oder Kommandozeilenparameter an. Für Spring Boot Anwendungen bietet sich außerdem die Verwendung von Profilen, Conditional Beans, AOP, einen Actuator und natürlich der application.properties an.

Bei den anwenderspezifischen Feature Flags kommt es stark auf die verwendete Technologie an. Existiert ein Gruppen- oder Rollenmodel, so sind Permission Flags und Experimental Toggle relativ einfach abzubilden.

Für das zufällige Selektieren einer kleinen Gruppe von Anwendern gibt es verschiedene Varianten. Üblicherweise schneidet man die Gesamtemenge in einer gewisse Anzahl von sogenannten Kohorten und wähl eine von ihnen aus. Eine recht simple Variante der Kohortenbildung für eine Spring Boot Anwendung ist hier skizziert.

public Optional<Integer> userCohort() {
    return Optional.ofNullable(SecurityContextHolder.getContext())
        .map(SecurityContext::getAuthentication)
        .filter(Authentication::isAuthenticated)
        .map(Authentication::getPrincipal)
        .map(Principal.class::cast)
        .map(Principal::getName)
        .map(n -> n.hashCode() % 100);
  }

Die Methode userCohort liefert für jeden angemeldeten Nutzer eine Zahl zwischen 0 und 99, die sich aus dem Hashcode seines Logins modulo 100 ergibt. Bei der Annahme, dass dieser Wert recht gleichmäßig über die Anwender verteilt ist, ergibt sich für jeden Anwender eine eindeutige Zuordnung zu einer von 100 Kohorten. Damit kann für ein Canary Release eine dieser Kohorten ausgewählt werden und 1% der Anwender erhalten das neue Feature.

Bei der Verwendung von Feature Flags sollte darauf geachtet werden, dass sie nicht überall im Code verstreut genutzt werden. Dies macht die Software unübersichtlich und ebnet den Weg in die Legacy Software. Um hier das kurze Beispiel eines Release Toggles etwas auszuweiten.

public class NewsletterGenerator {
  public Newsletter createNewsletter(Member member) {
    Newsletter newsletter = createBasicNewsletter(member);
    if (Features.flag("ancestor-member-plus").isEnabled()) {
      generateAndAddReport(newsletter, member);
    }
    return newsletter;
  }
}

Die Methode createNewsletter erzeugt einen Newsletter für ein Mitglied. Wenn das Feature Flag ancestor-member-plus geschaltet ist, dann wird ein Report erzeugt und an den Newsletter angehängt. Ungünstig ist hier, dass die Klasse NewsletterGenerator Kenntnisse über das Feature Flag ancestor-member-plus besitzen muss. Neben dieser einen Stelle kann es noch einige weitere geben und neben dem einen Feature Flag noch eine Reihe anderer.

Genaugenommen muss der NewsletterGenerator nur wissen, ob er einen Report erstellen und dem Newsletter hinzufügen soll. Das diese Funktion aktuell zum Feature ancestor-member-plus gehört, sollte hier nicht bekannt sein. Außerdem könnte sich sogar diese Verknüpfung noch ändern, weil vielleicht diese Funktion doch außerhalb des Features bereitgestellt werden, also früher oder später in den Release gelangen soll.

Um die Abhängigkeit vom Feature Flag aus dem NewsletterGenerator zu entfernen, versteckt man sie hinter einer Indirektion. In diesem Fall die Klasse NewsletterFeatures, die Informationen über die vorhandenen Features liefert.

public class NewsletterFeatures {
  public boolean isNewsletterWithAncestorReport() {
    return Features.flag("ancestor-member-plus").isEnabled();
  }
  // ...
}

Neben der Methode isNewsletterWithAncestorReport kann die Klasse noch viele weitere Methoden bereitstellen, die Auskunft über Feature Flag gesteuerte Funktionen geben.

public class NewsletterGenerator {
  private final NewsletterFeatures features;

  public NewsletterGenerator(NewsletterFeatures features) {
    this.features = features;
  }

  public Newsletter createNewsletter(Member member) {
    Newsletter newsletter = createBasicNewsletter(member);
    if (features.isNewsletterWithAncestorReport()) {
      generateAndAddReport(newsletter, member);
    }
    return newsletter;
  }
}

Aber auch diese Variante kann noch verbessert werden. Im NewsletterGenerator findet sich noch immer die Entscheidung ob ein Report dem Newsletter hinzugefügt wird oder nicht. Bei langlebigen Feature Flags und ggf. einiger weiterer Feature Flags kann der Einsatz von Pattern den Quellcode wartbarer gestalten.

public interface NewsletterModifier {
  Newsletter modify(Newsletter, Member);

  default NewsletterModifier and(NewsletterModifier before) {
        Objects.requireNonNull(before);
        return (n, m) -> modify(before.modify(n, m));
    }
}

Mit Hilfe des NewsletterModifier erhält der NewsletterGenerator keinerlei Informationen mehr über ein spezielles Feature, sondern nur noch eine Strategie, wie der Newsletter weiter anzupassen ist.

public class NewsletterGenerator {
  private final NewsletterModifier modifier;

  public NewsletterGenerator(NewsletterModifier modifier) {
    this.modifier = modifier;
  }

  public Newsletter createNewsletter(Member member) {
    Newsletter newsletter = createBasicNewsletter(member);
    return modifier.modify(newletter, member);
  }
}

Damit ist der NewsletterGenerator völlig entkoppelt von irgendwelchen Feature Flags oder den dahinterstehenden Feature. Erzeugt wird der NewsletterGenerator beispielsweise über eine Factory Methode.

public NewsletterGenerator create(NewsletterFeatures features) {
  return new NewsletterGenerator(features.isNewsletterWithAncestorReport ? new AddAncestorReportModifier() : (n, m) -> n);
}

Wenn das Feature aktiviert ist, dann erhält der NewsletterGenerator einen AddAncestorReportModifier als NewsletterModifier übergeben. Dieser erfüllt die gleichen Aufgaben wie die ursprüngliche Methode generateAndAddReport. Im anderen Falle erhält er einen NewsletterModifier, der nur den übergebenen Newsletter durchreicht und somit keinerlei Modifikationen vornimmt.

Sollte noch ein weiteres Feature Flag für Newsletter hinzukommen, beispielsweise für lustige Zitate, dann ändert sich nur die Factory Methode.

public NewsletterGenerator create(NewsletterFeatures features) {
  NewsletterModifier forReporting = features.isNewsletterWithAncestorReport ? new AddAncestorReportModifier() : (n, m) -> n;
  NewsletterModifier forQuotes = features.isNewsletterWithFunnyQuotes ? new AddFunnyQuotesModifier() : (n, m) -> n;
  return new NewsletterGenerator(forReporting.and(forQuotes));
}

Abhängig von dem jeweiligen Feature Flag wird die passende Strategy für das Feature ausgewählt und dann beide miteinander verkettet.

Neben der klaren und übersichtlichen Struktur, gibt es auch nur eine schwache Kopplung zwischen den Klassen und eine gute Testbarkeit des Codes.

Im nächsten Beitrag zu Feature Flags geht es um einige Best Practices im Spring Boot Umfeld.

Schreibe einen Kommentar