Implementierungen inkrementell verändern mit dem Branch By Abstraction Pattern

Um eine bestehende Software Komponente umzubauen erstellt der Software Entwickler in der Regel einen Feature-Branch. In diesem Feature-Branch werden alle notwendigen Änderungen vorgenommen und das Ergebnis zurück in den Haupt-Branch gemerged.

Unangenehm wird dieses Vorgehen, wenn die Änderungen aufwendig und langwierig sind. Denn je länger die Änderungen dauern, desto schwieriger wird es, die Änderungen in den Haupt-Branch zu integrieren. Dies liegt weniger an den Änderungen im Feature-Branch, sondern an den zwischenzeitlichen Änderungen am Haupt-Branch, der durch andere Feature-Branches unaufhörlich verändert wird.

Eine Möglichkeit der Merge-Hölle von langlebigen Branches zu entgehen ist das Branch By Abstraction Pattern (Paul Hammant). Die Idee bei dierser Vorgehensweise ist es, nicht den bestehenden Code großflächig zu ändern, sondern eine Neuimplementierung zu schaffen und die Altimplementierung dadurch abzulösen. Damit die Neuentwicklung nicht im leeren Raum entwickelt wird, ist der erste Schritt eine Fassade für die Altimplementierung zu schaffen.

Die Fassade wird innerhalb eines kurzen Feature-Branch bereitgestellt und in den Haupt-Branch gemerged. Daraufhin können alle Entwickler ihren Code gegen die neue Fassade entwicklen. Das Verhalten ihrer Komponenten ändert sich nicht, da alle Anfragen direkt an die Altimplementierung durchgereicht werden.

Während dessen wird in einem neuen Feature-Branch die Neuimplementierung begonnen. Da es sich hier um neuen Source Code handelt, ist die Gefahr von Mergekonflikten gering. Sobald die Neuimplementierung fertiggestellt ist, kann sie statt der Altimplementierung verwendet werden. Je nach Art der Bereitstellung der Neuimplementierung, kann dies in einem Schritt für das gesamte System geschehen oder sukzessiv, Teilsystem für Teilsystem.

Anhand einer einfachen Methode aus dem DiffEvaluatorprozessor soll das Vorgehen erläutert werden.

public DiffEvaluatorProcessor {
  // ...
  private boolean createDifferClassFile(TypeElement differType, List<DiffMethod> methods, Set<DiffBean> beans) {
    // ...
  }
}

Die createDifferClassFile Methode soll weitreichend verändert werden, aber ein langlebiger Feature-Branch vermieden werden.

public DiffEvaluatorProcessor {
  private final DiffClassFileCreator creator;

  @Override
  public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    creator = new LegacyDiffClassFileCreator();
    //...
  }

  private boolean createDifferClassFile(TypeElement differType, List<DiffMethod> methods, Set<DiffBean> beans) {
    creator.create(differType, methods, beans)
  }
  // ...
}

Um den Eingriff nicht in einem einzigen langlebigen Feature-Branch zu bearbeiten wird zu erst eine Abstraktionsschicht eingefügt. In diesem Fall wird die createDifferClassFile Methode in eine eigene Klasse LegacyDiffClassFileCreator extrahiert und über das Interface DiffClassFileCreator angesprochen. Diese Änderung wird in den Haupt-Branch gemerged und allen Entwicklern steht der LegacyDiffClassFileCreator zur Verfügung.

Danach wird in einem neuen FeatureBranch mit der Implementierung der Klasse DefaultDiffClassFileCreator begonnen. Da es kaum Abhängigkeiten zum bestehenden Code gibt, kann ohne große Furcht vor dem Merge, in diesem Branch gearbeitet werden. Innerhalb des Branch wird jedoch eine andere Implementierung der createDifferClassFile Methode verwendet.

public DiffEvaluatorProcessor {
  private final DiffClassFileCreator creator;

  @Override
  public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    creator = new DefaultDiffClassFileCreator();
    //...
  }

  private boolean createDifferClassFile(TypeElement differType, List<DiffMethod> methods, Set<DiffBean> beans) {
    creator.create(differType, methods, beans)
  }
  //...
}

Ist die Entwicklung des Features beendet und der Branch gemerged, steht allen Entwicklern die neue Implementierung zur Verfügung.

Häufig ist ein so abrupter Übergang aber nicht erwünscht, weil die komplexe Funktionlität noch nicht hinreichend nachgebaut wurde, Unsicherheit über die vollständige Funktionalität herrscht oder einfach der Wechsel zur alten Funktionalität möglich sein soll. In diesem Fall kann mit Hilfe eines Dekorators ein Umschalter eingefügt werden.

public class ToggleDiffClassFileCreator implements DiffClassFileCreator {
  private final DiffClassFileCreator oldImplementation;
  private final DiffClassFileCreator newImplementation;
  private DiffClassFileCreator current;

  public ToggleDiffClassFileCreator(DiffClassFileCreator oldImplementation, DiffClassFileCreator oldImplementation) {
    this.oldImplementation = oldImplementation;
    this.newImplementation = newImplementation;
    current = newImplementation;
  }
 
  public void create(TypeElement differType, List<DiffMethod> methods, Set<DiffBean> beans) {
    current.create(differType, methods, beans);
  }

  public toggle() {
    current = current == newImplementation ? oldImplementation : newImplementation; 
  }
}

Dieser Schalter verwendet nach der Instantiierung die Neuimplementierung und kann durch die Methode toggle auf die Altimplementierung schalten.

public DiffEvaluatorProcessor {
  private final ToggleDiffClassFileCreator creator;

  @Override
  public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    creator = new ToggleDiffClassFileCreator(new LegacyDiffClassFileCreator(), new DefaultDiffClassFileCreator());
    //...
  }

  protected void toggle() {
    creator.toggle();
  } 

  private boolean createDifferClassFile(TypeElement differType, List<DiffMethod> methods, Set<DiffBean> beans) {
    creator.create(differType, methods, beans)
  }
  // ...
}

Für den DiffEvaluatorProcessor könnte dieser Ansatz genutzt werden, um bei fehlerhaften Compiler Ergebnissen mit einem eigenen Kommandozeileparameter auf die Altimplementierung umzuschalten.

In einer Spring Boot Anwendung sind die beiden Implementierung häufig @Service annotierte Klassen. Um zwischen beiden Services unkompliziert zu wechseln kann dies über eine Property realisiert werden.

@Service
@ConditionalOnProperty(name="toggle", havingValue="true")
@Primary
public class DefaultImplementation implements TestService {
//..
}

@Service
public class LegacyImplementation implements TestService {
//..
}

Solange die Property toggle nicht definiert ist oder nicht den Wert true besitzt, ist der Service LegacyImplementation aktiv. Wird die Property auf true gesetzt und die Anwendung neu gestartet, dann ist der Service DefaultImplementation aktiv.

Eine weitere Verbeserung ist das Verify Branch By Abstraction Pattern (Steve Smith). Hier werden beide Implementierungen während der Umstellungsphase genutzt. Die Resultate beider Implementierungen werden verglichen und auf Unterschiede entsprechend reagiert. Entweder wird eine Exception geworfen oder eines der beiden Resultate zurückgeliefert. Hat man mehr Vertrauen zur Neuimplementierung, dann ihren Wert, ansonsten den der Altimplementierung. In beiden Fällen bekommen die Entwickler die Aufgabe die Unterschiede zu prüfen und ggf. die Neuimplementierung anzupassen.

Die bisherige create Methode, schrieb sofort in eine Datei. Wenn beide Implementierungen in die selbe Datei schreiben, wird ein Vergleich schwierig. Daher wird das Erzeugen des Sourcecodes und das Schreiben voneinader getrennt.

public DiffEvaluatorProcessor {
  private final ToggleDiffClassSourceCreator creator;

  @Override
  public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    creator = ToggleDiffClassSourceCreator(new LegacyDiffClassFileCreator(), new DefaultDiffClassSourceCreator());
    //...
  }

  protected void toggle() {
    creator.toggle();
  } 

  private boolean createDifferClassFile(TypeElement differType, List<DiffMethod> methods, Set<DiffBean> beans) {
    String source = creator.create(differType, methods, beans)
    write(differType, source);
  }
  // ...
}

Die modifizierte create Methode des umbenannten DiffClassSourceCreator Interfaces liefert die generierten Sourcen zurück. Diese können zwischen alter und neuer Implementierung verglichen und bei einem Fehler eine SourceVerifyException geworfen werden. Diese Änderung kann, wie die vorherigen Änderungen, in einem kurzlebigen Feature-Branch vorgenommen werde, der nur geringe Auswirkungen auf andere Entwicklungen hat.

public class VerifyDiffClassSourceCreator implements DiffClassSourceCreator {
  private final DiffClassFileCreator oldImplementation;
  private final DiffClassFileCreator newImplementation;

  public VerifyDiffClassFileCreator(DiffClassFileCreator oldImplementation, DiffClassFileCreator oldImplementation) {
    this.oldImplementation = oldImplementation;
    this.newImplementation = newImplementation;
  }
 
  public String createSource(TypeElement differType, List<DiffMethod> methods, Set<DiffBean> beans) {
    String legacySource = oldImplementation.createSource(differType, methods, beans);
    String defaultSource= newImplementation.createSource(differType, methods, beans);
    if (!Objects.equals(legacySource, defaultSource)) {
      throw new SourceVerifyException(legacySource, defaultSource);
    }
    return defaultSource;
  }
}

Der um den VerifyDiffClassSourceCreator ergänzte DiffEvaluatorProcessor prüft die neue Implementierung gegen die alte und kann bei einem Fehler mit der Altimplementierung verwendet werden.

public DiffEvaluatorProcessor {
  private final ToggleDiffClassSourceCreator creator;

  @Override
  public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    DiffClassSourceCreator oldImplementation = new LegacyDiffClassFileCreator();
    VerifyDiffClassFileCreator verify = new VerifyDiffClassFileCreator(oldImplementation, new DefaultDiffClassFileCreator());
    creator = new ToggleDiffClassSourceCreator(oldImplementation , verify);
    //...
  }

  protected void toggle() {
    creator.toggle();
  } 

  private boolean createDifferClassFile(TypeElement differType, List<DiffMethod> methods, Set<DiffBean> beans) {
    String source = creator.create(differType, methods, beans)
    write(differType, source);
  }
  // ...
}

Mit einer kleinen Änderung am ToggleDiffClassFileCreator ist ein erneuter Compileraufruf unnötig, da sich der Schalter selbst betätigt.

public class ToggleDiffClassFileCreator implements DiffClassFileCreator {
  private final DiffClassFileCreator oldImplementation;
  private final DiffClassFileCreator newImplementation;
  private DiffClassFileCreator current;

  public ToggleDiffClassFileCreator(DiffClassFileCreator oldImplementation, DiffClassFileCreator oldImplementation) {
    this.oldImplementation = oldImplementation;
    this.newImplementation = newImplementation;
    current = newImplementation;
  }
 
  public void create(TypeElement differType, List<DiffMethod> methods, Set<DiffBean> beans) {
    try {
      current.create(differType, methods, beans);
    } catch (SourceVerifyException e) {
      toggle();
      current.create(differType, methods, beans);
    }
  }

  public toggle() {
    current = current == newImplementation ? oldImplementation : newImplementation; 
  }
}

Da die Altimplementierung keine SourceVerifyException werfen kann, wird nur einmal umgeschaltet. Für einen einzelnen Compilierungsvorgang kein notwendiges Feature, aber bei einer selten ausgeführten Backendfunktionalität eine gute Absicherung.

Das Branch By Abstraction Pattern, ergänzt um das Verify Branch By Abstraction Pattern, gibt den Entwicklern die Möglichkeit, längerfristige Änderungen an einer Anwendung vorzunehmen, ohne langlebige Feature-Branches in Kauf zu nehmen.