Jahr des Abenteuers mit Graphen-Python

Jahr des Abenteuers mit Graphen-Python


Bild


Hallo allerseits, ich bin ein Python-Entwickler. Letztes Jahr habe ich mit Graphen-Python + Django ORM gearbeitet und während dieser Zeit versucht, ein Werkzeug zu entwickeln, um die Arbeit mit Graphen bequemer zu machen. Als Ergebnis habe ich eine kleine graphene-framework Codebasis und eine Reihe von Regeln erhalten, die ich gerne teilen möchte.


Bild


Was ist Graphen-Python?


Laut graphene-python.org dann:


Graphen-Python ist eine Bibliothek zum einfachen Erstellen von GraphQL-APIs mit Python. Seine Hauptaufgabe besteht darin, eine einfache, aber gleichzeitig erweiterbare API bereitzustellen, um das Leben der Programmierer zu erleichtern.

Seine Hauptaufgabe besteht darin, eine einfache, aber gleichzeitig erweiterbare API bereitzustellen, um das Leben der Programmierer zu erleichtern.


Ja, in Wirklichkeit ist Graphen einfach und erweiterbar, aber es scheint mir zu einfach für große und schnell wachsende Anwendungen. Eine kurze Dokumentation (ich habe stattdessen den Quellcode verwendet - er ist viel ausführlicher) sowie das Fehlen von Standards zum Schreiben von Code machen diese Bibliothek nicht zur besten Wahl für Ihre nächste API.


Wie dem auch sei, ich entschied mich, es im Projekt zu verwenden und stieß glücklicherweise auf eine Reihe von Problemen, nachdem ich die meisten gelöst hatte (dank der reichhaltigen undokumentierten Eigenschaften von Graphen). Einige meiner Lösungen sind rein architektonisch und können ohne mein Framework sofort verwendet werden. Der Rest von ihnen benötigt jedoch noch eine Codebasis.


Dieser Artikel ist keine Dokumentation, sondern in gewissem Sinne eine kurze Beschreibung meines Weges und der Probleme, die ich auf die eine oder andere Weise gelöst habe, mit einer kurzen Begründung für meine Wahl. In diesem Teil habe ich mich mit Mutationen und damit verbundenen Dingen befasst.


Der Zweck dieses Artikels ist es, ein aussagekräftiges Feedback zu erhalten , daher werde ich auf Kritik in den Kommentaren warten!


Hinweis: Bevor Sie den Artikel weiterlesen, empfehle ich Ihnen dringend, sich mit GraphQL vertraut zu machen.




Mutationen


Die meisten Diskussionen über GraphQL konzentrieren sich auf das Abrufen von Daten, aber jede Plattform mit Selbstachtung erfordert auch eine Möglichkeit, die auf dem Server gespeicherten Daten zu ändern.

Beginnen wir mit Mutationen.


Betrachten Sie den folgenden Code:


 class UpdatePostMutation(graphene.Mutation): class Arguments: post_id = graphene.ID(required=True) title = graphene.String(required=True) content = graphene.String(required=True) image_urls = graphene.List(graphene.String, required=False) allow_comments = graphene.Boolean(required=True) contact_email = graphene.String(required=True) ok = graphene.Boolean(required=True) errors = graphene.List(graphene.String, required=True) def mutate(_, info, post_id, title, content, image_urls, allow_comments, contact_email): errors = [] try: post = get_post_by_id(post_id) except PostNotFound: return UpdatePostMutation(ok=False, errors=['post_not_found']) if not info.context.user.is_authenticated: errors.append('not_authenticated') if len(title) < TITLE_MIN_LENGTH: errors.append('title_too_short') if not is_email(contact_email): errors.append('contact_email_not_valid') if post.owner != info.context.user: errors.append('not_post_owner') if Post.objects.filter(title=title).exists(): errors.append('title_already_taken') if not errors: post = Utils.update_post(post, title, content, image_urls, allow_comments, contact_email) return UpdatePostMutation(ok=bool(errors), errors=errors) 

UpdatePostMutation ändert den Beitrag mit der angegebenen id unter Verwendung der übertragenen Daten und gibt Fehler zurück, wenn einige Bedingungen nicht erfüllt sind.


Man muss sich nur diesen Code ansehen, da er sichtbar wird, dass er nicht erweiterbar und nicht unterstützbar ist, weil:


  1. Zu viele Argumente der mutate Funktion, deren Anzahl sich erhöhen kann, selbst wenn wir weitere zu bearbeitende Felder hinzufügen möchten.
  2. Damit Mutationen auf der Client-Seite gleich aussehen, müssen sie errors und ok , damit ihr Status und was immer zu verstehen ist.
  3. Suchen und Abrufen eines Objekts in mutate Funktion. Die Mutationsfunktion arbeitet mit Fasten, und wenn sie nicht vorhanden ist, sollte die Mutation nicht auftreten.
  4. Überprüfen von Berechtigungen in einer Mutation. Eine Mutation sollte nicht auftreten, wenn der Benutzer nicht das Recht dazu hat (einen Beitrag bearbeiten).
  5. Ein nutzloses erstes Argument (eine Wurzel , die für Felder der obersten Ebene immer None , was unsere Mutation ist).
  6. Eine unvorhersehbare Reihe von Fehlern: Wenn Sie keinen Quellcode oder keine Dokumentation haben, wissen Sie nicht, welche Fehler diese Mutation zurückgeben kann, da sie nicht im Schema berücksichtigt werden.
  7. Es gibt zu viele Vorlagenfehlerprüfungen, die direkt in der mutate werden, bei der die Daten geändert werden, und nicht eine Vielzahl von Prüfungen. Die ideale mutate sollte aus einer Zeile bestehen - einem Aufruf der Nachbearbeitungsfunktion.

Kurz gesagt, mutate sollte Daten ändern , anstatt sich um Aufgaben von Drittanbietern wie den Zugriff auf Objekte und die Überprüfung von Eingaben zu kümmern. Unser Ziel ist es, zu etwas zu gelangen wie:


  def mutate(post, info, input): post = Utils.update_post(post, **input) return UpdatePostMutation(post=post) 

Schauen wir uns nun die obigen Punkte an.




Benutzerdefinierte Typen


Das email Feld wird als Zeichenfolge übergeben, während es sich um eine Zeichenfolge eines bestimmten Formats handelt . Jedes Mal, wenn die API eine E-Mail empfängt, muss sie ihre Richtigkeit überprüfen. Die beste Lösung wäre also, einen benutzerdefinierten Typ zu erstellen.


 class Email(graphene.String): # ... 

Dies mag offensichtlich erscheinen, ist aber erwähnenswert.




Eingabetypen


Verwenden Sie Eingabetypen für Ihre Mutationen. Auch wenn sie an anderen Orten nicht wiederverwendet werden können. Dank der Eingabetypen werden Abfragen kleiner, sodass sie leichter zu lesen und schneller zu schreiben sind.


 class UpdatePostInput(graphene.InputObjectType): title = graphene.String(required=True) content = graphene.String(required=True) image_urls = graphene.List(graphene.String, required=False) allow_comments = graphene.Boolean(required=True) contact_email = graphene.String(required=True) 

An:


 mutation( $post_id: ID!, $title: String!, $content: String!, $image_urls: String!, $allow_comments: Boolean!, $contact_email: Email! ) { updatePost( post_id: $post_id, title: $title, content: $content, image_urls: $image_urls, allow_comments: $allow_comments, contact_email: $contact_email, ) { ok } } 

Nachher:


 mutation($id: ID!, $input: UpdatePostInput!) { updatePost(id: $id, input: $input) { ok } } 

Der Mutationscode ändert sich zu:


 class UpdatePostMutation(graphene.Mutation): class Arguments: input = UpdatePostInput(required=True) id = graphene.ID(required=True) ok = graphene.Boolean(required=True) errors = graphene.List(graphene.String, required=True) def mutate(_, info, input, id): # ... if not errors: post = Utils.update_post(post, **input.__dict__) return UpdatePostMutation(errors=errors) 



Basismutationsklasse


Wie in Absatz 2 erwähnt, müssen Mutationen errors und ok damit ihr Status und die Ursachen immer verstanden werden können . Es ist einfach genug, wir erstellen eine Basisklasse:


 class MutationPayload(graphene.ObjectType): ok = graphene.Boolean(required=True) errors = graphene.List(graphene.String, required=True) query = graphene.Field('main.schema.Query', required=True) def resolve_ok(self, info): return len(self.errors or []) == 0 def resolve_errors(self, info): return self.errors or [] def resolve_query(self, info): return {} 

Ein paar Anmerkungen:


  • Die Methode " resolve_ok " ist resolve_ok , sodass wir nicht selbst " ok berechnen müssen.
  • Das query ist die Query , mit der Sie Daten direkt innerhalb der Mutationsanforderung abfragen können (Daten werden nach Abschluss der Mutation angefordert).
     mutation($id: ID!, $input: PostUpdateInput!) { updatePost(id: $id, input: $input) { ok query { profile { totalPosts } } } } 

Dies ist sehr praktisch, wenn der Client nach Abschluss der Mutation einige Daten aktualisiert und den Unterstützer nicht auffordern möchte, diesen gesamten Satz zurückzugeben. Je weniger Code Sie schreiben, desto einfacher ist die Wartung. Ich habe diese Idee von hier übernommen .


Mit der Basismutationsklasse wird der Code zu:


 class UpdatePostMutation(MutationPayload, graphene.Mutation): class Arguments: input = UpdatePostInput(required=True) id = graphene.ID(required=True) def mutate(_, info, input, id): # ... 



Wurzelmutationen


Unsere Mutationsanfrage sieht jetzt so aus:


 mutation($id: ID!, $input: PostUpdateInput!) { updatePost(id: $id, input: $input) { ok } } 

Es ist keine gute Praxis, alle Mutationen in einem globalen Bereich zu enthalten. Hier sind einige Gründe warum:


  1. Mit der wachsenden Anzahl von Mutationen wird es immer schwieriger, die gewünschte Mutation zu finden.
  2. Aufgrund eines Namespace ist es erforderlich, "den Namen seines Moduls" in den Namen der Mutation aufzunehmen, z. B. " Post update .
  3. Sie müssen id als Argument an die Mutation übergeben.

Ich schlage vor, Wurzelmutationen zu verwenden . Ihr Ziel ist es, diese Probleme zu lösen, indem Mutationen in separate Bereiche unterteilt und Mutationen von der Logik des Zugriffs auf Objekte und der Zugriffsrechte auf diese befreit werden.


Die neue Anfrage sieht folgendermaßen aus:


 mutation($id: ID!, $input: PostUpdateInput!) { post(id: $id) { update(input: $input) { ok } } } 

Die Anforderungsargumente bleiben gleich. Jetzt wird die Änderungsfunktion innerhalb des post "aufgerufen", wodurch die folgende Logik implementiert werden kann:


  1. Wenn id nicht an post , wird {} . Auf diese Weise können Sie weiterhin Mutationen innerhalb durchführen. Wird für Mutationen verwendet, für die kein Stammelement erforderlich ist (z. B. zum Erstellen von Objekten).
  2. Wenn id wird, wird das entsprechende Element abgerufen.
  3. Wenn das Objekt nicht gefunden wird, wird None zurückgegeben, und dies schließt die Anforderung ab. Die Mutation wird nicht aufgerufen.
  4. Wenn das Objekt gefunden wird, überprüfen Sie die Rechte des Benutzers, um es zu bearbeiten.
  5. Wenn der Benutzer keine Rechte hat, wird None zurückgegeben und die Anforderung abgeschlossen. Die Mutation wird nicht aufgerufen.
  6. Wenn der Benutzer Rechte hat, wird das gefundene Objekt zurückgegeben und die Mutation erhält es als root - das erste Argument.

Daher ändert sich der Mutationscode zu:


 class UpdatePostMutation(MutationPayload, graphene.Mutation): class Arguments: input = UpdatePostInput() def mutate(post, info, input): if post is None: return None errors = [] if not info.context.user.is_authenticated: errors.append('not_authenticated') if len(title) < TITLE_MIN_LENGTH: errors.append('title_too_short') if Post.objects.filter(title=title).exists(): errors.append('title_already_taken') if not errors: post = Utils.update_post(post, **input.__dict__) return UpdatePostMutation(errors=errors) 

  • Die Wurzel der Mutation - das erste Argument - ist jetzt ein Objekt vom Typ Post , über das die Mutation durchgeführt wird.
  • Die Autorisierungsprüfung wurde in den Stammmutationscode verschoben.

Wurzelmutationscode:


 class PostMutationRoot(MutationRoot): class Meta: model = Post has_permission = lambda post, user: post.owner == user update = UpdatePostMutation.Field() 



Fehlerschnittstelle


Um eine Reihe von Fehlern vorhersehbar zu machen, müssen sie im Design berücksichtigt werden.


  • Da Mutationen mehrere Fehler zurückgeben können, sollten Fehler eine Liste sein.
  • Da Fehler durch verschiedene Typen dargestellt werden, muss für eine bestimmte Mutation eine bestimmte Union Fehlern existieren.
  • Damit die Fehler einander ähnlich bleiben, müssen sie die Schnittstelle ErrorInterface . ErrorInterface wir sie ErrorInterface . Lassen Sie es zwei Felder enthalten: ok und message .

Daher müssen Fehler vom Typ [SomeMutationErrorsUnion]! . Alle Untertypen von SomeMutationErrorsUnion müssen ErrorInterface implementieren.


Wir bekommen:


 class NotAuthenticated(graphene.ObjectType): message = graphene.String(required=True, default_value='not_authenticated') class Meta: interfaces = [ErrorInterface, ] class TitleTooShort(graphene.ObjectType): message = graphene.String(required=True, default_value='title_too_short') class Meta: interfaces = [ErrorInterface, ] class TitleAlreadyTaken(graphene.ObjectType): message = graphene.String(required=True, default_value='title_already_taken') class Meta: interfaces = [ErrorInterface, ] class UpdatePostMutationErrors(graphene.Union): class Meta: types = [NotAuthenticated, TitleIsTooShort, TitleAlreadyTaken, ] 

Es sieht gut aus, aber es gibt zu viel Code. Wir verwenden die Metaklasse, um diese Fehler im laufenden Betrieb zu generieren:


 class PostErrors(metaclass=ErrorMetaclass): errors = [ 'not_authenticated', 'title_too_short', 'title_already_taken', ] class UpdatePostMutationErrors(graphene.Union): class Meta: types = [PostErrors.not_authenticated, PostErrors.title_too_short, PostErrors.title_already_taken, ] 

Fügen Sie der Mutation die Deklaration der zurückgegebenen Fehler hinzu:


 class UpdatePostMutation(MutationPayload, graphene.Mutation): class Arguments: input = UpdatePostInput() errors = graphene.List(UpdatePostMutationErrors, required=True) def mutate(post, info, input): # ... 



Auf Fehler prüfen


Es scheint mir, dass sich die mutate nur um die Mutation der Daten kümmern sollte. Um dies zu erreichen, müssen Sie den Code für diese Funktion auf Fehler überprüfen.


Ohne die Implementierung ist hier das Ergebnis:


 class UpdatePostMutation(DefaultMutation): class Arguments: input = UpdatePostInput() class Meta: root_required = True authentication_required = True #   ,    True   # An iterable of tuples (error_class, checker) checks = [ ( PostErrors.title_too_short, lambda post, input: len(input.title) < TITLE_MIN_LENGTH ), ( PostErrors.title_already_taken, lambda post, input: Post.objects.filter(title=input.title).exists() ), ] def mutate(post, info, input): post = Utils.update_post(post, **input.__dict__) return UpdatePostMutation() 

Vor dem Starten der mutate Funktion wird jeder Checker (das zweite Element der Mitglieder des checks Arrays) aufgerufen. Wenn True zurückgegeben wird, wird der entsprechende Fehler gefunden. Wenn keine Fehler gefunden werden, wird die mutate Funktion aufgerufen.


Ich werde erklären:


  • Überprüfungsfunktionen verwenden dieselben Argumente wie mutate .
  • Validierungsfunktionen sollten True wenn ein Fehler gefunden wird.
  • Berechtigungsprüfungen und das Vorhandensein des Stammelements sind recht allgemein gehalten und in den Meta Flags aufgeführt.
  • authentication_required fügt eine Berechtigungsprüfung hinzu, wenn True .
  • root_required fügt eine Prüfung " root is not None " hinzu.
  • UpdatePostMutationErrors nicht mehr erforderlich. Abhängig von den Fehlerklassen des checks Arrays wird im checks eine Vereinigung möglicher Fehler erstellt.



Generika


DefaultMutation im letzten Abschnitt verwendete pre_mutate fügt eine pre_mutate Methode hinzu, mit der Sie die Eingabeargumente ändern können, bevor Sie nach Fehlern pre_mutate und dementsprechend die Mutation aufrufen.


Es gibt auch ein generisches Starter-Kit, das den Code kürzer macht und das Leben erleichtert.
Hinweis: Derzeit ist der generische Code spezifisch für Django ORM


Kreatemutation


Benötigt einen der model oder create_function . Standardmäßig sieht create_function aus:


 model._default_manager.create(**data, owner=user) 

Dies mag unsicher aussehen, aber vergessen Sie nicht, dass es in graphql eine integrierte Typprüfung sowie Mutationen gibt.


Es bietet auch eine post_mutate Methode, die nach create_function mit Argumenten (instance_created, user) aufgerufen wird, deren Ergebnis an den Client zurückgegeben wird.


Updatemutation


Ermöglicht das update_function . Default:


 def default_update_function(instance, user=None, **data): instance.__dict__.update(data) instance.save() return instance 

root_required ist standardmäßig True .


Es bietet auch eine post_mutate Methode, die nach update_function mit Argumenten (instance_updated, user) aufgerufen wird, deren Ergebnis an den Client zurückgegeben wird.


Und das brauchen wir!


Endgültiger Code:


 class UpdatePostMutation(UpdateMutation): class Arguments: input = UpdatePostInput() class Meta: checks = [ ( PostErrors.title_too_short, lambda post, input: len(input.title) < TITLE_MIN_LENGTH ), ( PostErrors.title_already_taken, lambda post, input: Post.objects.filter(title=input.title).exists() ), ] 

DeleteMutation


Ermöglicht das delete_function . Default:


 def default_delete_function(instance, user=None, **data): instance.delete() 



Fazit


Dieser Artikel berücksichtigt nur einen Aspekt, obwohl er meiner Meinung nach der komplexeste ist. Ich habe einige Gedanken zu Resolvern und Typen sowie zu allgemeinen Dingen in Graphen-Python.


Es fällt mir schwer, mich selbst als erfahrenen Entwickler zu bezeichnen, daher freue ich mich über Feedback und Vorschläge.


Den Quellcode finden Sie hier .

Source: https://habr.com/ru/post/de461939/


All Articles