Dev Life

Ein tiefer Einblick in den Rebuild der Mailgun-App: Teil 1 – Hervorragende Ideen

Seien Sie gegrüßt, liebe Entwickler. Wir freuen uns, Ihnen mitteilen zu können, dass unser Team nach drei langen Jahren der Planung, des Designs und der Entwicklung unser großartiges App-Update veröffentlicht hat. Sicherlich haben wir unermüdlich daran gearbeitet, das Erscheinungsbild unserer Benutzeroberfläche zu aktualisieren, aber wir haben eine noch weitreichendere Überarbeitung vorgenommen an unserem grundlegenden Technologie-Stack. […]
Image for Ein tiefer Einblick in den Rebuild der Mailgun-App: Teil 1 – Hervorragende Ideen

Seien Sie gegrüßt, liebe Entwickler. Wir freuen uns, Ihnen mitteilen zu können, dass unser Team nach drei langen Jahren der Planung, des Designs und der Entwicklung unser großartiges App-Update veröffentlicht hat. Sicherlich haben wir unermüdlich daran gearbeitet, das Erscheinungsbild unserer Benutzeroberfläche zu aktualisieren, aber wir haben eine noch weitreichendere Überarbeitung vorgenommen an unserem grundlegenden Technologie-Stack.

Was bedeutet das für Sie? Bessere Leistung, besseres Interface und skalierbar für zukünftiges Wachstum. Wir reisen in die Vergangenheit, um Ihnen den vollständigen Bericht darüber zu bringen, wie wir dies verwirklicht haben. Begleiten Sie uns auf dieser Reise.

Warum wir diese Reise angetreten haben

Aber warten Sie! Bevor wir in die Vergangenheit springen, ist es wichtig, unsere verbesserte App vorzustellen. „Verbessert“ ist eine Untertreibung. Es mag Sie überraschen, dass dieses UI- und Performance-Facelift kein Umsatztreiber war. Warum haben wir es also getan?

Zwei Gründe:

  1. Die bestehende Nutzererfahrung beeinträchtigte die Leistung für Kunden, die größere Mengen an E-Mails versenden, und erschwerte die Navigation zwischen den Produkten in unserem wachsenden Portfolio. Wenn es nicht einfach zu bedienen ist, dann werden Sie es nicht nutzen.
  2. Wir mussten unseren zentralen API-Datenfluss neu bewerten, um leistungsorientierter und skalierbarer zu sein.

Was wir geschaffen haben, ist Portal, eine epische Schnittstelle, um Sie besser mit Ihren Produkten und Tools für die Zustellbarkeit zu verbinden, jetzt und während wir wachsen. Portal ermöglicht es Ihnen, nahtlos zwischen unseren Mailgun- und Mailgun Optimize Produkten zu wechseln, und bietet ein hochperformantes Erlebnis für unsere Power-Nutzer, die Zehntausende von E-Mails pro Tag versenden.

Bleiben Sie also bei uns, während wir fachsimpeln, uns einklinken und durch die letzten drei Jahre unseres Entwicklungsprozesses reisen.

bill-teds-excellent-adventure-wallpaper-12__1_

Was wir bauen wollten

Wir begannen mit der Erstellung einer langen Liste all der verschiedenen Dinge, die wir wollten – vom Aktualisieren unserer Repository-Struktur bis zur Implementierung eines neuen Deploy-Systems, und wir wussten, dass wir es von Grund auf neu erstellen mussten, da unser SRE-Team von AWS zur Google Cloud migrierte. Wir wollten uns auch von unserem eng gekoppelten monolithischen Design hin zu einem verteilteren Design bewegen. Dies würde für das Team in Zukunft alles einfacher machen: von der Entwicklung umfassender Tests bis hin zu gezielten Rebuilds, ohne sich durch den gesamten Code wühlen oder ihn ändern zu müssen.

„Was wollen wir letztendlich bauen?“ Dies war die erste Frage, die wir beantworten mussten. Die nächste Frage war: „Wo weisen wir Defizite auf?“

Bei jedem Redesign möchten wir immer den Nutzer berücksichtigen, aber ein großer Teil des Antriebs hinter unserem App-Update bestand auch darin, die Entwicklererfahrung zu verbessern. Unser Ziel war es, die Entwicklererfahrung zu verbessern, indem wir die Komplexität bei der Übertragung von Daten von der API auf die gerenderte Seite deutlich reduzierten und die Wartbarkeit verbesserten, indem wir die Gesamtzahl der Muster in unserem Toolkit verringerten.

Geschichte: Unser ursprünglicher Frontend-Stack

Stacks können veralten, und unser ursprünglicher Frontend-Stack wurde vor fast einem Jahrzehnt entwickelt – was 350 in Internetjahren entspricht. Er enthielt einen Python Flask-Webserver, der wahrscheinlich mehr Aufgaben erledigte, als er sollte, und einen sehr eng gekoppelten State aufwies, der mit dem Client geteilt wurde. Er verwendete mehrere Abstraktionsschichten, die ursprünglich dazu gedacht waren, den Abruf von Daten aus unseren APIs zu erleichtern.

Als diese Oberfläche immer größer wurde, verursachten die Muster mehr Overhead als Komfort. Es wurde sehr schwierig, grundlegende Änderungen vorzunehmen, da es so viele Stellen gab, die davon betroffen sein könnten. Zu diesem Zeitpunkt war unser Frontend-Client zu einem Museum veralteter React-Muster geworden.

All unsere Interaktionen mit unseren eigenen öffentlichen und privaten APIs wurden durch mehrere Schichten übermäßiger Code-Komplexität verschleiert. Dies schuf eine riesige Kluft zwischen den Frontend-Entwicklern von Mailgun und ihrem Verständnis unserer APIs und Kundenerlebnisse.

Wie unser Legacy-Framework unserer Skalierbarkeit einen Strich durch die Rechnung machte

Der Flickenteppich an API-Abstraktionen führte nicht nur zu übermäßiger Komplexität, sondern beeinträchtigte auch erheblich unsere Fähigkeit, unsere Anwendung zu skalieren. Wir wussten, dass wir im Laufe der Zeit immer mehr Produkte in die Codebasis integrieren wollten. Wir mussten völlig neu überdenken, wie wir unsere Daten konsumierten.

Als wir das neue Framework planten, wussten wir, dass wir API-Anfragen direkt vom Client an die öffentlichen APIs mit so wenig Middleware wie möglich stellen wollten. Warum? Auf diese Weise wären wir in der Lage, unsere APIs auf die gleiche Weise zu verstehen und zu konsumieren wie unsere Kunden, indem wir „unser eigenes Hundefutter essen“, wie man so schön sagt. Ich weiß nicht, wie es Ihnen geht, aber ich esse lieber Steak als Hundefutter.

Eine Anmerkung aus der Zukunft: API-Anfragen direkt vom Client an die öffentlichen APIs zu stellen, ist ein langfristiges Ziel, da viele der APIs, mit denen unsere Web-App interagiert, noch privat sind. Wir planen, die meisten dieser Dienste nach und nach in den öffentlichen Raum zu verlagern, um eine einheitliche API-Schnittstelle für Portal zu haben.

Das Problem bei der Verwaltung von app-weiten States

Eine weitere wesentliche Änderung, die wir vornehmen wollten, war der Ersatz von Redux, das unseren app-weiten State (etwa 150 States) verwaltete. Jeder State war einem Netzwerkaufruf oder einem Netzwerk von Datentransformationen zugeordnet, und unsere Legacy-Struktur legte nicht wirklich Richtlinien dafür fest, wie er verwendet werden sollte. Das Ergebnis? Viele Redundanzen.

Wenn ein Entwickler einen Aufruf zur Datenmanipulation einführte, erfolgte die Integration über drei Abstraktionsebenen. Zusätzlich hatten wir unsere eigenen benutzerdefinierten Clients für unseren Browser und unseren Flask-Server (der mit all unseren APIs kommunizierte). Also musste ein Entwickler, um eine neue Funktion hinzuzufügen, insgesamt etwa sieben Abstraktionsebenen durchlaufen, und Fehler sind auf jeder Ebene möglich.

Was wir schließlich herausfanden, war, dass 95 % unseres app-weiten States nur ein verherrlichter Netzwerk-Cache waren. Nur zogen wir nicht einmal den Nutzen aus gecachten Daten, sondern machten oft redundante Aufrufe, um Daten anzufordern, die wir bereits hatten, was zu verschachtelten Schichten unnötiger Re-Renders in unseren View-Komponenten führte. Da kann man wohl von einer holprigen Fahrt sprechen.

Diese Systemaufschlüsselung erklärt die Dinge etwas klarer, und obwohl wir niemals schlecht über unsere Ursprünge sprechen würden… sagt ein scherzhaftes Bild mehr als tausend Worte.

Aktuelles System

Unser Team ist die Art von Team, das auf sich selbst aufbaut und iteriert, aber für diesen App-Rebuild mussten wir zurückspulen und einige grundlegende Komponenten unserer Infrastruktur überarbeiten – beginnend mit der Quellcode-Verwaltung.

Einschränkungen des Polyrepos

Ein Polyrepo ist ein Repository, das mehrere Projekte enthält. Wir verwendeten einen Polyrepo-Organisationsstil, was einfach bedeutet, dass jedes Frontend-Projekt sein eigenes Repository hatte, obwohl viele der Projekte die gleichen Aufgaben wiederholten und dieselben Funktionen reproduzierten. Als zentraler Knotenpunkt können Polyrepos zu einer Herausforderung bei der Wartung werden, wenn die Anzahl der gespeicherten Projekte wächst. In vielen Fällen mussten wir jedes Polyrepo gleichzeitig mit denselben Updates bereitstellen, um ein konsistentes Erlebnis über unsere Produkte hinweg zu gewährleisten.

Aus Wartungssicht machte es dies für Teams schwierig, den benötigten Code zu finden und damit zu arbeiten. Die Verwaltung mehrerer Repos mit redundanten Funktionen bedeutet normalerweise, dass sie langsamer reagieren und ressourcenintensiver sind, was ein großes Problem darstellt, wenn Sie häufig Code pushen und pullen. Da Funktionen über mehrere Codebasen verteilt sein können, können Polyrepos die Fehlersuche schwieriger machen als die Suche nach der sprichwörtlichen Nadel im Heuhaufen.

Mit unserer Polyrepo-Struktur vor dem Rebuild wussten wir, dass die Zukunft eine Realität sein würde, in der wir mit einer Taschenlampe durch Repos jagen und nach dem suchen, was wir brauchten, und dass es eine Herausforderung sein würde, zusammenzuarbeiten, zu skalieren und Code zu teilen, während wir wuchsen.

Dies waren unsere Hauptbedenken:

  • Herausfordernde gemeinsame Entwicklung
  • Enge Kopplung
  • Skalierbarkeits-, Verfügbarkeits- und Leistungsmanagement
  • Schwierig, Code zu teilen
  • Duplizierung
  • Inkonsistentes Tooling

Wenn es um die Verwaltung von Quellcode geht, gibt es viele Lösungen, aber wir haben entschieden, dass eine modulare Monorepo-Struktur den Großteil unserer Entwicklungsprobleme in Zukunft lindern würde.

Pläne für ein schickes neues Repo: Ein modularer Ansatz

Ein Monorepo-Ansatz beinhaltet das Speichern des gesamten Codes des Projekts in einem einzigen, großen Repository. Dies würde die Probleme mit dem Teilen von Code und der Duplizierung lösen, die wir bei unserer Polyrepo-Organisation erlebt haben, aber Monorepos sind kein Allheilmittel. Wenn der Code eng gekoppelt ist, müssen Monorepos auch modular konzipiert sein.

Ein modularer Ansatz beinhaltet die Aufteilung eines großen Projekts in kleinere, unabhängige Module, die separat entwickelt, getestet und gewartet werden können. Dies ermöglicht eine größere Flexibilität und Wiederverwendbarkeit sowie die Möglichkeit, einzelne Module einfach zu aktualisieren, ohne den Rest des Projekts zu beeinträchtigen.

Wir wussten, dass eine modulare Monorepo-Lösung es uns ermöglichen würde, effizienter zu sein bei:

  • Code-Generierung
  • Code-Sharing
  • Verteilte Aufgabenausführung und Orchestrierung
  • Caching
  • TypeScript
TypeScript ist eine Obermenge von typisiertem JavaScript, die es Entwicklern erleichtert, Projekte in großem Maßstab zu erstellen. Es verbessert die Entwicklererfahrung durch die Bereitstellung von Schnittstellen, Typ-Aliasen, statischer Code-Analyse zur Entwicklungszeit und anderen Tools, während es Entwicklern ermöglicht, ihren Projekten Typen hinzuzufügen. Für unser Team ist TypeScript eine Fähigkeit, von der wir wussten, dass wir sie suchen wollten, wenn es darum ging, unserem Team neue Mitglieder hinzuzufügen.

Auf die Füße treten: Der Deployment-Tanz

Zweifellos besteht die größte Herausforderung beim Leben in einem Monorepo (monolithisch oder anders) darin, dass das Potenzial, uns gegenseitig auf die Füße zu treten, umso größer ist, je mehr Leute dazu beitragen. Was nützt eine überarbeitete Entwicklererfahrung, wenn Ihr Code bei einem schlechten Merge überschrieben wird? Wenn wir wirklich eine ganzheitliche Überarbeitung unseres Stacks vornehmen, müssen wir uns auch ansehen, wie wir unseren Deployment-Prozess verbessern können. Dies waren die dringendsten Herausforderungen:

  • Skalierbarkeit: Es wäre eine schlechte Idee, den gesamten Code im Mono-Repo für die kleinsten Änderungen, wie z. B. die Behebung eines Tippfehlers in einem Text, neu zu erstellen. Genau das haben wir in unserem alten System gemacht. Der neue Prozess würde die integrierten Funktionen von NX nutzen, um die von der Änderung betroffenen Dateien zu bewerten und nur diese auf granularster Ebene neu zu erstellen.
  • Deployment: Wir haben das meiste Zubehör unserer Deployment-Pipeline beibehalten. Sobald das Image erstellt ist, landet es im GitHub Container Repository. Unser SRE-Team hat unser Deployment-System während der Migration von AWS zu GCP optimiert. Wir können ganz einfach jeden Branch aus unserem Slack-Deployment-Kanal bereitstellen, mit einem schöneren Bericht dank einer kürzlichen Ergänzung, die wir während der Migration erhalten haben.
  • CI;CD: Unser altes System nutzte eine wirklich alte Version von Jenkins, um Tests auszuführen und unsere App-Images hinter einem VPN zu erstellen/verteilen. Wenn der Build fehlschlug, brauchten wir einen hochrangigen Senior-Entwickler, der mit den alten Methoden vertraut war, um ihn zu reparieren. In unserem neuen System planten wir den Wechsel zu GitHub Actions, wo Konfigurationen zugänglich, deklarativ und gut dokumentiert sind.

Miese Herausforderungen und exzellenteste Lösungen

Projekte jeder Art haben Herausforderungen, erwartete und überraschende. Wir wussten, dass wir planen mussten, um einige Probleme zu überwinden, und dass sich die neue Version der App aus mehreren Blickwinkeln auf die Leistung konzentrieren musste.

Identifizierte ProblemeVorgeschlagene Lösungen
Unklare Code-Konventionen
Code-Konventionen beziehen sich auf die Reihe von Programmierstilen, Lösungen und wiederverwendbaren Mustern innerhalb einer Codebasis. Stellen Sie sich ein Team von Schreinern vor, das an einem Projekt arbeitet. Wenn sie 1.000 spezialisierte Werkzeuge auf der Baustelle aufbewahren, ist es wahrscheinlich, dass ein Arbeiter das falsche Werkzeug für den Job wählt oder es unsachgemäß verwendet. Und im Gegensatz zu physischen Werkzeugen können Software-Abhängigkeiten veralten, was zukünftige Updates der Codebasis behindert.
Code-Styleguide
Die Pflege expliziter Coding-Styleguides und einer minimalen Anzahl einfacher, leistungsstarker Muster hilft dabei, Redundanz, Inkonsistenzen und Komplexität zu reduzieren, und kann sogar zu einer verbesserten Leistung führen. Die Wiederverwendung einer kleinen Gruppe verallgemeinerter Lösungen und Muster macht den Code verständlicher und verringert die Wahrscheinlichkeit schwer zu findender Fehler.
Überlasteter State
Wir haben Redux als One-Size-Fits-All-Lösung für den globalen Anwendungs-State verwendet. Allerdings waren 90 % unseres Redux-Stores nur ein verherrlichter Netzwerk-Cache. Redux erfordert mehrere Ebenen an Overhead für jedes State-Mitglied. Dies führte zu vielen Redundanzen und unnötiger Komplexität.
Trennung von Netzwerk-Cache und App-State
Die Trennung der Netzwerk-Cache-Funktionen vom Anwendungs-State ermöglicht es uns, einen viel kleineren State-Footprint zu behalten. Der State kann lokaler implementiert werden. Ein reduzierter Overhead führt zu einer gesteigerten Leistung und gibt den Entwicklern mehr Kontrolle darüber, wie die Daten verwendet werden.
Wir haben React Query als Schnittstelle zu unseren Backend-APIs gewählt, was es uns ermöglicht, Daten einfach asynchron zu verwalten und den Netzwerk-Overhead zu reduzieren.
Mehrschichtige Netzwerkarchitektur
Unsere Anwendung wuchs organisch von einem grundlegenden API-zentrierten Control Panel zu der Suite von Kunden-Tools für mehrere Produkte, die wir heute haben. Anfänglich wurde der Client auf gemeinsamen Python-Tools aufgebaut, die von vielen unserer API-Backends geteilt wurden. Dies führt dazu, dass zahlreiche Middleware-Schichten selbst für den einfachsten API-Aufruf benötigt werden. Dies machte es nicht nur schwierig, den Ursprung bestimmter Daten zu verstehen, es machte auch die Identifizierung von Fehlern im Netzwerk-Stack sehr zeitaufwendig.
Öffentliche API zuerst
Anstatt Anfragen durch mehrere Proxys, Authentifizierungsschichten und Python-Schnittstellen zu leiten, haben wir frühzeitig entschieden, dass wir in jedem möglichen Fall die benötigten Daten direkt vom Client über die öffentlich verfügbaren APIs sammeln würden. Dies reduzierte nicht nur unseren Overhead und unsere Komplexität erheblich, es half uns auch, unsere API-Dienste besser aus der Perspektive unserer Kunden wahrzunehmen. Wir beabsichtigen, diese Initiative in naher Zukunft weiter voranzutreiben und mehr unserer APIs öffentlich zu machen.

Werfen Sie das Kind nicht mit dem Bade aus

Wir haben im letzten Jahrzehnt Hunderttausende von Zeilen Code geschrieben. Angesichts des Maßstabs und Umfangs dieses neuen Plans würde es fast genauso viele Jahre dauern, unsere alten Funktionen zu portieren und in das neue Paradigma umzuschreiben. Das wäre von vornherein zum Scheitern verurteilt. Wir mussten einen Weg finden, diese bestehenden Funktionen weiterhin einzubinden, ohne unsere Ziele für den neuen Stack zu beeinträchtigen. Wir fanden eine Lösung in einer Technologie namens Module Federation.

Mit dem Module Federation Plugin von Webpack konnten wir einen neuen Build unserer alten Anwendung mit minimalen Änderungen erstellen, der in der Lage wäre, bestehende Funktionen in den neuen Portal-Stack zu integrieren. Wir hätten weiterhin das langfristige Ziel, den nun veralteten Code auf neuere Standards zu portieren, aber dies würde es uns ermöglichen, sofort damit zu beginnen, neue Funktionen mit den neuen Vorteilen zu schreiben.

Wir haben einen Plan, aber können wir ihn verwirklichen?

Wir wussten, dass die Konsolidierung unserer Plattformen bei gleichzeitigem kontinuierlichen Wachstum von Funktionen und Produktgruppen einen massiven Aufwand erfordern würde, und unsere Planung beeinflusste, was wir für unseren neuen Frontend-Stack wollten und wie wir das unsterbliche Erbe von antiquierten CSS-Styling-Systemen, veralteten Klassenkomponenten-Mustern und einem klobigen App-State-Management bewältigen würden.

Weil wir von Grund auf neu gebaut haben – insbesondere mit dem Wechsel von AWS zur Google Cloud – war die beste Lösung ein sauberer Schnitt. Ein neues Repo mit besseren, höheren Standards, Module Federation, modularen, gemeinsam genutzten Bibliotheken und einer umfassenderen nutzerorientierten und internen Dokumentation.

New front-end stack; Nx, React, Webpack, TypeScript, Cypress, Jest, ES Links, Prettier, and Mock Service Worker.

Es war eine intensive Planungs- und Vorbereitungsphase. Wie bei allen großen Projekten war die Generierung der Ideen und des Proof of Concept ein technisches und kreatives Unterfangen, an dem nur wenige ausgewählte Personen beteiligt waren.

Phase 2? Die Idee in die Realität umsetzen – und daran waren noch viel mehr Teams beteiligt. Entwickler, ich glaube, unser Abenteuer nimmt gleich eine höchst interessante Wendung.