Varieties for Python HTTP APIs: An Instagram Story | by Anirudh Padmarao

Und wir sind zurück! Wie bereits im ersten Teil unserer Blogpost-Reihe erwähnt, ist Instagram Server ein Python-Monolith mit mehreren Millionen Codezeilen und einigen tausend Django-Endpunkten.

In diesem Beitrag geht es darum, wie wir Typen verwenden, um einen Vertrag für unsere Python-HTTP-APIs zu dokumentieren und durchzusetzen. In den nächsten Wochen werden wir Details zu weiteren Tools und Techniken veröffentlichen, die wir entwickelt haben, um die Qualität unserer Codebasis zu verwalten.

Wenn Sie die Instagram-App auf Ihrem mobilen Client öffnen, werden über eine JSON-HTTP-API Anforderungen an unseren Python-Server (Django) gesendet.

Um Ihnen einen Eindruck von der Komplexität der API zu geben, die wir dem mobilen Client zur Verfügung stellen, haben wir:

  • über 2000 Endpunkte auf dem Server
  • Über 200 Felder der obersten Ebene im Client-Datenobjekt, die das Bild, Video oder die Story in der App darstellen
  • Hunderte von Ingenieuren schreiben Code für den Server (und noch mehr auf dem Client!)
  • Täglich werden 100 Commits an den Server gesendet, die die API möglicherweise ändern, um neue Funktionen zu unterstützen

Wir verwenden Typen, um einen Vertrag für unsere komplexe, sich weiterentwickelnde HTTP-API zu dokumentieren und durchzusetzen.

Beginnen wir am Anfang. In PEP 484 wurde eine Syntax zum Hinzufügen von Typanmerkungen zum Python-Code eingeführt. Aber warum überhaupt Typanmerkungen hinzufügen?

Stellen Sie sich eine Funktion vor, die einen Star Wars-Charakter abruft:

def get_character (ID, Kalender):
if id == 1000:
Zeichen zurückgeben (
id = 1000,
name = “Luke Skywalker”,
geburtsjahr = “19BBY” wenn kalender == kalender.BBY sonst …
)

Um die Funktion get_character zu verstehen, müssen Sie ihren Körper lesen.

  • Es wird eine ganzzahlige Zeichen-ID benötigt
  • Es dauert eine Aufzählung des Kalendersystems (z. B. BBY oder “Before Battle of Yavin”).
  • Es gibt ein Zeichen mit den Feldern ID, Name und Geburtsjahr zurück

Die Funktion hat einen impliziten Vertrag, den Sie jedes Mal neu erstellen müssen, wenn Sie den Code lesen. Der Code wird jedoch einmal geschrieben und viele Male gelesen, sodass dies nicht gut funktioniert.

Außerdem ist es schwer zu überprüfen, ob die Aufrufer der Funktion und der Funktionskörper selbst den impliziten Vertrag einhalten. In einer großen Codebasis kann dies zu Fehlern führen.

Betrachten Sie stattdessen die Funktion mit Typanmerkungen:

def get_character (id: int, calendar: Calendar) -> Character:

Bei Typanmerkungen besteht ein expliziter Vertrag. Sie müssen nur die Funktionssignatur lesen, um ihre Ein- und Ausgabe zu verstehen. Ein Typechecker kann statisch überprüfen, ob der Code dem Vertrag entspricht, wodurch eine ganze Klasse von Fehlern beseitigt wird!

Lassen Sie uns eine HTTP-API entwickeln, um ein Star Wars-Zeichen abzurufen, und Typanmerkungen verwenden, um einen expliziten Vertrag dafür zu definieren.

Die HTTP-API sollte die Zeichen-ID als URL-Parameter und das Kalendersystem als Abfrageparameter verwenden. Es sollte eine JSON-Antwort für das Zeichen zurückgeben.

curl -X GET https://api.starwars.com/characters/1000?calendar=BBY{
“id”: 1000,
“Name”: “Luke Skywalker”,
“geburtsjahr”: “19BBY”
}}

Um diese API in Django zu implementieren, registrieren Sie zuerst den URL-Pfad und die Ansichtsfunktion, die dafür verantwortlich sind, eine HTTP-Anforderung an diesen URL-Pfad zu senden und eine Antwort zurückzugeben.

urlpatterns = [
url(“characters//”, get_character)
]

Die Ansichtsfunktion verwendet die Anforderung und die URL-Parameter (in diesem Fall ID) als Eingabe. Die Funktion analysiert und wandelt den Kalenderabfrageparameter um, ruft das Zeichen aus einem Geschäft ab und gibt ein Wörterbuch zurück, das als JSON serialisiert und in eine HTTP-Antwort eingeschlossen ist.

def get_character (Anfrage: IGWSGIRequest, id: str) -> JsonResponse:
Kalender = Kalender (request.GET.get (“Kalender”, “BBY”))
Zeichen = Store.get_character (ID, Kalender)
return JsonResponse (asdict (Zeichen))

Obwohl die Ansichtsfunktion Typanmerkungen enthält, definiert sie keinen starken, expliziten Vertrag für die HTTP-API. Aus der Signatur kennen wir weder die Namen oder Typen der Abfrageparameter noch die Felder in der Antwort oder deren Typen.

Was wäre, wenn wir stattdessen die Signatur der Ansichtsfunktion genau so gestalten könnten wie die der früheren typbeschrifteten Funktion?

def get_character (id: int, calendar: Calendar) -> Character:

Die Funktionsparameter können Anforderungsparameter darstellen (URL-, Abfrage- oder Körperparameter). Der Funktionsrückgabetyp kann den Inhalt der Antwort darstellen. Dann hätten wir einen expliziten, leicht verständlichen Vertrag für die HTTP-API, den der Typechecker durchsetzen kann.

Implementierung

Wie können wir diese Idee umsetzen?

Verwenden wir einen Dekorator, um die stark typisierte Ansichtsfunktion in die Django-Ansichtsfunktion umzuwandeln. Dieses Design erfordert keine Änderungen am Django-Framework. Wir können dasselbe Routing, dieselbe Middleware und andere Komponenten verwenden, mit denen wir vertraut sind.

@api_view
def get_character (id: int, calendar: Calendar) -> Character:

Lassen Sie uns in die Implementierung des api_view-Dekorators eintauchen:

def api_view (Ansicht):
@ functools.wraps (Ansicht)
def django_view (Anfrage, * args, ** kwargs):
params = {
param_name: param.annotation (extrahieren (Anfrage, param))
für param_name param in inspect.signature (view) .parameters.items ()
}}
Daten = Ansicht (** Parameter)
Rückgabe JsonResponse (asdict (Daten))

return django_view

Das ist ein dichtes Stück Code. Lassen Sie uns Stück für Stück darüber nachdenken.

Wir nehmen die stark typisierte Ansicht als Eingabe und verpacken sie in eine reguläre Django-Ansichtsfunktion, die wir zurückgeben:

def api_view (Ansicht):
@ functools.wraps (Ansicht)
def django_view (Anfrage, * args, ** kwargs):

return django_view

Schauen wir uns nun die Implementierung der Django-Ansicht an. Zuerst müssen wir die Argumente für die stark typisierte Ansichtsfunktion konstruieren. Wir verwenden Introspektion mit dem Inspect-Modul, um die Signatur der stark typisierten Ansichtsfunktion zu erhalten und ihre Parameter zu durchlaufen:

für param_name param in inspect.signature (view) .parameters.items ()

Für jeden der Parameter rufen wir eine Extraktionsfunktion auf, die den Parameterwert aus der Anforderung extrahiert.

Dann wandeln wir den Parameterwert aus der Signatur in den erwarteten Typ um (z. B. wandeln wir das Kalendersystem von einer Zeichenfolge in einen Aufzählungswert um).

param.annotation (Auszug (Anfrage, param))

Wir rufen die stark typisierte Ansichtsfunktion mit den von uns erstellten Parameterargumenten auf:

Daten = Ansicht (** Parameter)

Es gibt eine stark typisierte Klasse zurück (z. B. Zeichen). Wir nehmen diese Klasse, wandeln sie in ein Wörterbuch um und verpacken sie in eine JSON-HTTP-Antwort:

Rückgabe JsonResponse (asdict (Daten))

Toll! Jetzt haben wir also eine Django-Ansicht, die die stark typisierte Ansicht umschließen kann. Schauen wir uns zum Schluss diese Extraktionsfunktion an:

def extract (Anfrage: HttpRequest, param: Parameter) -> Beliebig:
wenn request.resolver_match.route.contains (f “<{param}>“):
return request.resolver_match.kwargs.get (param.name)
sonst:
return request.GET.get (param.name)

Jeder Parameter kann ein URL-Parameter oder ein Abfrageparameter sein. Auf den URL-Pfad der Anforderung (den URL-Pfad, den wir als ersten Schritt registriert haben) kann über das Routenobjekt des Django-URL-Resolvers zugegriffen werden. Wir prüfen, ob der Parametername im Pfad vorhanden ist. Wenn dies der Fall ist, handelt es sich um einen URL-Parameter, den wir auf eine Weise aus der Anforderung extrahieren können. Andernfalls handelt es sich um einen Abfrageparameter, den wir auf andere Weise extrahieren können.

Und das ist es! Dies ist eine vereinfachte Implementierung, die jedoch die Hauptideen veranschaulicht.

Datentypen

Der Typ, der zur Darstellung des HTTP-Antwortinhalts verwendet wird (z. B. Zeichen), kann entweder eine Datenklasse oder ein typisiertes Wörterbuch verwenden.

Eine Datenklasse ist eine übersichtliche Methode zum Definieren einer Klasse, die Daten darstellt.

aus Datenklassen Datenklasse importieren@dataclass (eingefroren = True)
Klasse Charakter:
id: int
Name: str
geburtsjahr: str
luke = Charakter (
id = 1000,
name = “Luke Skywalker”,
irth_year = “19BBY”
)

Datenklassen sind die bevorzugte Methode zum Modellieren von HTTP-Antwortobjekten bei Instagram. Sie:

  • Generieren Sie automatisch Boilerplate-Konstruktoren, Equals und andere Methoden
  • werden von Typecheckern verstanden und können typechecked werden
  • kann Unveränderlichkeit mit eingefroren = True erzwingen
  • sind in der Python 3.7-Standardbibliothek oder als Backport im Python Package Index verfügbar

Leider haben wir bei Instagram eine alte Codebasis, die große, untypisierte Wörterbücher verwendet, die zwischen Funktionen und Modulen übergeben werden. Es wäre schwierig, diesen gesamten Code von Wörterbüchern in Datenklassen zu migrieren. Während wir Datenklassen für neuen Code verwenden, verwenden wir typisierte Wörterbücher für Legacy-Code.

Mit typisierten Wörterbüchern können wir Typanmerkungen für Wörterbuch-Clientobjekte hinzufügen und von Typchecking profitieren, ohne das Laufzeitverhalten zu ändern.

aus mypy_extensions importieren Sie TypedDictKlasse Character (TypedDict):
id: int
Name: str
geburtsjahr: str
luke: Character = {“id”: 1000}
Luke[“name”] = “Luke Skywalker”
Luke[“birth_year”] = 19 # Typ Fehler, Geburtsjahr erwartet eine str
Luke[“invalid_key”] # Typ Fehler, invalid_key existiert nicht

Fehlerbehandlung

Die Ansichtsfunktion erwartet, dass wir ein Zeichen zurückgeben. Was tun wir, wenn wir einen Fehler an den Client zurückgeben möchten?

Wir können eine Ausnahme auslösen, die das Framework abfängt und in eine HTTP-Fehlerantwort übersetzt.

@api_view (“GET”)
def get_character (id: str, calendar: Calendar) -> Character:
Versuchen:
return Store.get_character (id)
außer CharacterNotFound:
erhöhen Sie Http404Exception ()

Dieses Beispiel zeigt auch die HTTP-Methode im Decorator, die die zulässigen HTTP-Methoden für diese API angibt.

Die HTTP-API ist stark mit einer HTTP-Methode, Anforderungstypen und Antworttypen typisiert. Wir können die API überprüfen und festlegen, dass eine GET-Anforderung mit einer Zeichenfolgen-ID im URL-Pfad und einer Kalenderaufzählung in der Abfragezeichenfolge akzeptiert werden soll. Anschließend wird eine JSON-Antwort mit einem Zeichen zurückgegeben.

Was können wir mit all diesen Informationen anfangen?

OpenAPI ist ein API-Beschreibungsformat mit einer Vielzahl von Tools, die darauf aufbauen. Wenn wir ein bisschen Code schreiben, um unsere Endpunkte zu überprüfen und daraus eine OpenAPI-Spezifikation zu generieren, können wir dieses Ökosystem von Tools nutzen.

Pfade:
/ Zeichen / {ID}:
bekommen:
Parameter:
– in: Pfad
Name: ID
Schema:
Typ: Ganzzahl
erforderlich: wahr
– in: Abfrage
Name: Kalender
Schema:
Typ: Zeichenfolge
Aufzählung: [“BBY”]
Antworten:
‘200’:
Inhalt:
application / json:
Schema:
Typ: Objekt

Wir können eine HTTP-API-Dokumentation für die get_character-API generieren, die die Namen, Typen und Dokumentationen für die Anforderung und Antwort enthält. Dies ist die richtige Abstraktionsebene für Cliententwickler, die eine Anfrage an den Endpunkt stellen möchten. Sie sollten keinen Python-Code lesen müssen.

API-Dokumentation

Es gibt zusätzliche Tools, die wir erstellen könnten, z. B. ein Tool zum Ausprobieren, um Anforderungen im Browser zu stellen, damit Entwickler ihre HTTP-APIs aufrufen können, ohne Code schreiben zu müssen. Wir könnten sogar typsichere Clients für eine durchgängige Typensicherheit codieren. Damit können wir eine stark typisierte API auf dem Server haben und sie mit einer stark typisierten API auf dem Client aufrufen.

Wir könnten auch einen Abwärtskompatibilitätsprüfer erstellen. Was passiert, wenn wir eine Version des Servers mit den erforderlichen Feldern für ID, Name und Geburtsjahr freigeben und später feststellen, dass wir nicht das Geburtsjahr jedes Charakters kennen? Wir möchten das Geburtsjahr optional machen, aber alte Kunden, die ein Geburtsjahr erwarten, können abstürzen. Obwohl wir einen expliziten Typ für die API haben, kann sich dieser explizite Typ ändern (das Geburtsjahr wechselt von erforderlich zu optional). Wir können die Änderungen an der API verfolgen und Entwickler als Teil ihres Workflows warnen, wenn sie Änderungen vornehmen, die Clients beschädigen können.

Es gibt ein Spektrum von Anwendungsprotokollen, mit denen Maschinen miteinander kommunizieren können.

An einem Ende des Spektrums befinden sich RPC-Frameworks wie Thrift oder gRPC. Sie definieren im Allgemeinen starke Typen für die Anforderung und die Antwort und generieren Code auf dem Client und dem Server, um Anforderungen zu stellen. Sie kommunizieren möglicherweise nicht über HTTP oder serialisieren ihre Daten nicht einmal in JSON.

Am anderen Ende des Spektrums haben wir unstrukturierte Python-Webframeworks ohne expliziten Vertrag für Anfragen oder Antworten. Der von uns gewählte Ansatz erfasst viele der Vorteile strukturierterer Frameworks und kommuniziert weiterhin über HTTP + JSON mit minimalen Änderungen des Anwendungscodes.

Es ist wichtig zu beachten, dass dies keine neue Idee ist. In stark typisierten Sprachen gibt es viele Frameworks, die eine API wie die von uns beschriebene bereitstellen. In Python gibt es auch Stand der Technik mit dem APIStar-Framework.

Wir haben erfolgreich Typen für HTTP-APIs auf Instagram eingeführt. Wir konnten dies in unserer gesamten Codebasis übernehmen, da das Framework für vorhandene Ansichten einfach und sicher zu übernehmen ist. Der Wert ist den Produktingenieuren klar: Die generierte Dokumentation wird zum Mittel, mit dem Server- und Clientingenieure kommunizieren können.

Comments are closed.