Wie kann man gefährliches Refactoring einführen, um mit einer Million Benutzern zu arbeiten?


Der Film "Flugzeug", 1980.

So fühlte ich mich, als ich ein weiteres Refactoring auf das Produkt goss. Selbst wenn Sie den gesamten Code mit Metriken und Protokollen abdecken, testen Sie die Funktionalität in allen Umgebungen - dies spart nach der Bereitstellung nicht 100% der Fakaps.

Erste Fakap


Irgendwie haben wir unsere Verarbeitung der Integration mit Google Sheets überarbeitet. Für Benutzer ist dies eine sehr wertvolle Funktion, weil Sie verwenden viele Tools gleichzeitig, die miteinander verknüpft werden müssen - Kontakte an eine Tabelle senden, Antworten auf Fragen hochladen, Benutzer exportieren usw.

Der Integrationscode wurde von der ersten Version an nicht überarbeitet und es wurde immer schwieriger, ihn zu warten. Dies wirkte sich auf unsere Benutzer aus - alte Fehler wurden aufgedeckt, die wir aufgrund der Komplexität des Codes nicht bearbeiten konnten. Es ist Zeit, etwas dagegen zu unternehmen. Es wurden keine logischen Änderungen angenommen - schreiben Sie einfach Tests, verschieben Sie Klassen und Kammnamen. Natürlich haben wir die Funktionalität in der Entwicklungsumgebung getestet und sind zur Bereitstellung gegangen.

Nach 20 Minuten schrieben die Benutzer, dass die Integration nicht funktioniert habe. Die Funktionalität zum Senden von Daten an Google Sheet hat sich verschlechtert. Es stellte sich heraus, dass wir zum Debuggen Daten in verschiedenen Formaten für den Vertrieb und lokale Umgebungen senden. Beim Refactoring treffen wir das Verkaufsformat.

Wir haben die Integration behoben, aber das Sediment vom fröhlichen Freitagabend (und Sie dachten!) Blieb trotzdem. Rückblickend (Treffen mit dem Team, um den Sprint abzuschließen) begannen wir darüber nachzudenken, wie solche Situationen in Zukunft verhindert werden können. Wir müssen die Praxis des manuellen Testens, des automatischen Testens, der Arbeit mit Metriken und Alarmen verbessern, und außerdem kamen wir auf die Idee, Feature-Flags zum Testen des Refactorings zu verwenden In der Tat wird dies diskutiert.

Implementierung


Das Schema ist einfach: Wenn der Benutzer das Flag aktiviert hat, wechseln Sie zum Code mit der neuen Version, wenn nicht, zum Code mit der alten Version:

if ($user->hasFeature(UserFeatures::FEATURE_1)) {
  // new version
} else {
  // old version
}

Mit diesem Ansatz haben wir die Möglichkeit, das Refactoring auf Prod zuerst an uns selbst und dann an den Benutzern zu testen.

Fast von Beginn des Projekts an hatten wir eine primitive Implementierung der Flags-Funktion. In der Datenbank für zwei grundlegende Entitäten, Benutzer und Konto, wurden Feature-Felder hinzugefügt, die eine Bitmaske waren . Im Code haben wir neue Konstanten für Features registriert, die wir dann zur Maske hinzugefügt haben, wenn dem Benutzer ein bestimmtes Feature zur Verfügung steht.

public const ALLOW_FEATURE_1 = 0b0000001;
public const ALLOW_FEATURE_2 = 0b0000010;
public const ALLOW_FEATURE_3 = 0b0000100;

Die Verwendung im Code sah folgendermaßen aus:

If ($user->hasFeature(UserFeatures::ALLOW_FEATURE_1)) {
  // feature 1 logic
}

Beim Refactoring öffnen wir normalerweise zuerst das Flag für das Team zum Testen, dann für mehrere Benutzer, die die Funktion aktiv nutzen, und öffnen sie schließlich für alle, aber manchmal komplexeren Schemata, mehr dazu weiter unten.

Überladenes Place Refactoring


Eines unserer Systeme akzeptiert Facebook-Webhooks und verarbeitet sie über die Warteschlange. Die Warteschlangenverarbeitung wurde nicht mehr ausgeführt, und Benutzer erhielten bestimmte Nachrichten mit einer Verzögerung, was die Erfahrung von Bot-Abonnenten erheblich beeinträchtigen könnte. Wir haben begonnen, diesen Ort umzugestalten, indem wir die Verarbeitung in ein komplexeres Warteschlangenschema übertragen haben. Der Ort ist kritisch - es ist gefährlich, neue Logik auf alle Server zu übertragen, daher haben wir die neue Logik unter dem Flag geschlossen und konnten sie auf dem Produkt testen. Aber was passiert, wenn wir diese Flagge überhaupt öffnen? Wie wird sich unsere Infrastruktur verhalten? Dieses Mal haben wir das Öffnen des Flags auf den Servern bereitgestellt und die Metriken befolgt.

Alle kritischen Datenverarbeitungen haben wir in Cluster unterteilt. Jeder Cluster hat eine ID. Wir haben beschlossen, das Testen eines derart komplexen Refactorings zu vereinfachen, indem wir die Flag-Funktion nur auf bestimmten Servern öffnen. Die Überprüfung des Codes sieht folgendermaßen aus:

If ($user->hasFeature(UserFeatures::CGT_REFACTORING) ||
    \in_array($cluster, Configurator::get('cgt_refactoring_cluster_ids'))) {
  // new version
} else {
  // old version
}

Zuerst gossen wir Refactoring ein und öffneten dem Team die Flaggen. Dann fanden wir mehrere Benutzer, die die cgt-Funktion aktiv nutzten, Flags für sie öffneten und nachschauten, ob alles für sie funktionierte. Und schließlich begannen sie, Flags auf den Servern zu öffnen und den Metriken zu folgen.

Das Flag cgt_refactoring_cluster_ids kann über das Admin-Panel geändert werden. Zunächst weisen wir einem leeren Array den Wert cgt_refactoring_cluster_ids zu, fügen dann jeweils einen Cluster hinzu - [1], sehen uns die Metriken eine Weile an und fügen einen weiteren Cluster hinzu - [1, 2], bis wir das gesamte System testen.

Konfiguration des Konfigurators


Ich werde ein wenig darüber sprechen, was Configurator ist und wie es implementiert wird. Es wurde geschrieben, um die Logik ohne Bereitstellung ändern zu können, beispielsweise wie im obigen Fall, wenn die Logik scharf zurückgesetzt werden muss. Wir verwenden es auch für dynamische Konfigurationen. Wenn Sie beispielsweise verschiedene Caching-Zeiten testen müssen, können Sie es für schnelle Tests herausnehmen. Für den Entwickler sieht dies wie eine Liste von Feldern mit Administratorwerten aus, die geändert werden können. Wir speichern dies alles in einer Datenbank, zwischenspeichern in Redis und in einer Statistik für unsere Mitarbeiter.

Refactoring veralteter Standorte


Im nächsten Quartal haben wir die Registrierungslogik überarbeitet und sie auf den Übergang zur Möglichkeit der Registrierung über mehrere Dienste vorbereitet. Unter unseren Bedingungen ist es unmöglich, die Registrierungslogik so zu gruppieren, dass ein bestimmter Benutzer an eine bestimmte Logik gebunden ist, und wir haben nichts Besseres gefunden, als die Logik zu testen und einen Prozentsatz aller Registrierungsanforderungen bereitzustellen. Dies ist auf ähnliche Weise mit Flags einfach möglich:

If (Configurator::get('auth_refactoring_percentage') > \random_int(0, 99)) {
  // new version
} else {
  // old version
}

Dementsprechend haben wir den Wert von auth_refactoring_percentage im Admin-Bereich von 0 auf 100 festgelegt. Natürlich haben wir die gesamte Autorisierungslogik mit Metriken "verschmiert", um zu verstehen, dass wir die Konvertierung am Ende nicht reduziert haben.

Metriken


Um zu erklären, wie wir beim Öffnen von Flags Metriken folgen, werden wir einen anderen Fall genauer betrachten. ManyChat akzeptiert Facebook-Hooks von Facebook, wenn ein Abonnent eine Nachricht an Facebook Messenger sendet. Wir müssen jede Nachricht gemäß der Geschäftslogik verarbeiten. Für die cgt-Funktion müssen wir bestimmen, ob der Abonnent die Konversation durch einen Kommentar auf Facebook gestartet hat, um ihm als Antwort eine relevante Nachricht zu senden. Im Code sieht es so aus, als würde der Kontext des aktuellen Abonnenten bestimmt. Wenn wir die Widget-ID ermitteln können, ermitteln wir die Antwortnachricht daraus.

Mehr zur Funktion
Facebook api. — . Widget, :

—> —> —> Facebook:



:
—> —>



“ , !” , . , “ !” id , — , id.

Zuvor haben wir den Kontext auf drei Arten definiert. Er sah ungefähr so ​​aus:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //      
  if (null !== $user->gt_widget_id_context) {
    $watcher->logTick('cgt_match_processor_matched_via_context');

    return $user->gt_widget_id_context;
  }

  //      
  if (null !== $user->name) {
    $widgetId = $this->cgtMatchByThread($user);
    if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_thread');

      return $widgetId;
    }

    $widgetId = $this->cgtMatchByConversation($user);
    if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_conversation');

      return $widgetId;
    }
  }

  return null;
}

Der Watcher-Service sendet zum Zeitpunkt des Spiels Analysen. Wir hatten Metriken für alle drei Fälle: Die


Häufigkeit, mit der der Kontext durch verschiedene zeitliche Verknüpfungsmethoden gefunden wurde.

Als Nächstes haben wir eine andere Übereinstimmungsmethode gefunden, die alle alten Optionen ersetzen sollte. Um dies zu testen, haben wir eine andere Metrik erhalten:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //    
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_echo_message');
  }

  //    
  // ...
}

In diesem Stadium möchten wir sicherstellen, dass die Anzahl der neuen Treffer der Summe der alten Treffer entspricht. Schreiben Sie also einfach die Metrik, ohne $ widgetId zurückzugeben: Die


Anzahl der von der neuen Methode gefundenen Kontexte deckt die Summe der Bindungen der alten Methoden vollständig ab.

Dies garantiert uns jedoch nicht in allen Fällen die richtige Übereinstimmungslogik. Der nächste Schritt ist das schrittweise Testen durch Öffnen von Flaggen:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //    
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
    $watcher->logTick('cgt_match_processor_matched_by_echo_message');
  
    //    ,   
    If ($this->allowMatchingByEcho($user)) {
      return $widgetId;
    }
  }

  // ...
}

function allowMatchingByEcho(User $user): bool
{
  //    
  If ($user->hasFeature(UserFeatures::ALLOW_CGT_MATCHING_BY_ECHO)) {
    return true;
  }
  //     
  If (\in_array($this->clusterId, Configurator::get('cgt_matching_by_echo_cluster_ids'))) {
    return true;
  }

  return false;
}

Dann begann der Testprozess: Zuerst testeten wir die neue Funktionalität selbst in allen Umgebungen und an zufälligen Benutzern, die häufig Matching verwenden, indem wir das Flag UserFeatures :: ALLOW_CGT_MATCHING_BY_ECHO öffnen. Zu diesem Zeitpunkt haben wir einige Fälle festgestellt, in denen das Match nicht ordnungsgemäß funktioniert hat, und sie repariert. Dann wurde mit der Einführung auf Servern begonnen: Im Durchschnitt haben wir einen Server an einem Tag in der Woche eingeführt. Vor dem Testen warnen wir den Support, dass er sich die Tickets im Zusammenhang mit der Funktionalität genau ansieht und uns über eventuelle Kuriositäten schreibt. Dank Support und Benutzern konnten mehrere Eckfälle behoben werden. Und schließlich ist der letzte Schritt die Entdeckung aller bedingungslos:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
    $watcher->logTick('cgt_match_processor_matched_by_echo_message');
  
    return $widgetId;
  }

  return null;
}

Neue Implementierung der Flaggenfunktion


Die Implementierung der Flaggenfunktion, die zu Beginn des Artikels beschrieben wurde, hat uns ungefähr 3 Jahre lang gedient, aber mit dem Wachstum der Teams wurde es unangenehm - wir mussten sie beim Erstellen jeder Flagge bereitstellen und vergessen nicht, den Wert der Flaggen zu löschen (wir haben konstante Werte für verschiedene Funktionen wiederverwendet). Vor kurzem wurde die Komponente neu geschrieben und jetzt können wir Flags über das Admin-Panel flexibel verwalten. Flags wurden von der Bitmaske gelöst und in einer separaten Tabelle gespeichert - dies erleichtert das Erstellen neuer Flags. Jeder Eintrag hat auch eine Beschreibung und einen Eigentümer. Das Flaggenmanagement ist transparenter geworden.

Nachteile solcher Ansätze


Dieser Ansatz hat ein großes Minus: Es gibt zwei Versionen des Codes, die gleichzeitig unterstützt werden müssen. Beim Testen müssen Sie berücksichtigen, dass es zwei Zweige der Logik gibt, und Sie müssen alle überprüfen, und dies ist sehr schmerzhaft. Während der Entwicklung gab es Situationen, in denen wir einen Fix in eine Logik einführten, aber einen anderen vergaßen und irgendwann schossen. Daher wenden wir diesen Ansatz nur an kritischen Stellen an und versuchen, die alte Version des Codes so schnell wie möglich zu entfernen. Wir versuchen, den Rest des Refactorings in kleinen Iterationen durchzuführen.

Gesamt


Der aktuelle Prozess sieht folgendermaßen aus: Zuerst schließen wir die Logik unter den Bedingungen der Flags, stellen sie dann bereit und beginnen, die Flags schrittweise zu öffnen. Beim Erweitern von Flags überwachen wir Fehler und Metriken genau, sobald etwas schief geht. Setzen Sie das Flag sofort zurück und beheben Sie das Problem. Das Plus ist, dass das Öffnen / Schließen des Flags sehr schnell geht - es ist nur eine Änderung des Werts im Admin-Bereich. Nach einiger Zeit haben wir die alte Version des Codes herausgeschnitten. Dies sollte die Mindestzeit sein, um Änderungen in beiden Versionen des Codes zu verhindern. Es ist wichtig, Kollegen vor einem solchen Refactoring zu warnen. Wir haben eine Überprüfung durch Github und Verwendung Code Besitzer während einer solchen Refactoring , so dass Änderungen ohne das Wissen des Autors des Refactoring nicht in den Code.

Zuletzt habe ich eine neue Version der Facebook Graph API eingeführt. In einer Sekunde stellen wir mehr als 3000 Anfragen an die API und jeder Fehler ist für uns teuer. Daher habe ich die Änderung mit minimaler Auswirkung unter der Flagge eingeführt - es stellte sich heraus, dass ein unangenehmer Fehler aufgetreten ist, die neue Version getestet und schließlich ohne Sorgen vollständig darauf umgestellt wurde.

All Articles