Durch das Schreiben benutzerdefinierter Resolver können Sie Firebase Data Connect so erweitern, dass neben Cloud SQL auch andere Datenquellen unterstützt werden. Anschließend können Sie mehrere Datenquellen (Cloud SQL und die von Ihren benutzerdefinierten Resolvern bereitgestellten Datenquellen) in einer einzigen Abfrage oder Mutation kombinieren.
Das Konzept einer „Datenquelle“ ist flexibel. Dazu gehören:
- Andere Datenbanken als Cloud SQL, z. B. Cloud Firestore, MongoDB und andere.
- Speicherdienste wie Cloud Storage und AWS S3
- Alle API-basierten Integrationen wie Stripe, SendGrid und Salesforce.
- Benutzerdefinierte Geschäftslogik.
Nachdem Sie benutzerdefinierte Resolver geschrieben haben, um Ihre zusätzlichen Datenquellen zu unterstützen, können Sie sie in Ihren Data Connect-Abfragen und ‑Mutationen auf viele Arten kombinieren. Das bietet unter anderem folgende Vorteile:
- Eine einheitliche Autorisierungsebene für Ihre Datenquellen. Sie können beispielsweise den Zugriff auf Dateien in Cloud Storage mit in Cloud SQL gespeicherten Daten autorisieren.
- Typsichere Client-SDKs für Web, Android und iOS.
- Abfragen, die Daten aus mehreren Quellen zurückgeben.
- Eingeschränkte Funktionsaufrufe basierend auf dem Datenbankstatus.
Vorbereitung
Wenn Sie eigene benutzerdefinierte Resolver schreiben möchten, benötigen Sie Folgendes:
- Firebase CLI v15.9.0 oder höher
- Firebase Functions SDK v7.1.0 oder höher
Außerdem sollten Sie mit dem Schreiben von Funktionen mit Cloud Functions for Firebase vertraut sein, da Sie damit die Logik Ihrer benutzerdefinierten Resolver implementieren.
Hinweis
Sie sollten bereits ein Projekt eingerichtet haben, um Data Connect verwenden zu können.
Wenn Sie noch nicht eingerichtet sind, können Sie einer der Kurzanleitungen folgen:
Benutzerdefinierte Resolver schreiben
Das Schreiben eines benutzerdefinierten Resolvers besteht im Wesentlichen aus drei Teilen: Zuerst definieren Sie ein Schema für Ihren benutzerdefinierten Resolver, dann implementieren Sie Ihre Resolver mit Cloud Functions und schließlich verwenden Sie Ihre benutzerdefinierten Resolverfelder in Abfragen und Mutationen, möglicherweise in Verbindung mit Cloud SQL oder anderen benutzerdefinierten Resolvern.
In den nächsten Abschnitten erfahren Sie, wie Sie das tun. Nehmen wir als Beispiel an, dass Sie öffentliche Profilinformationen für Ihre Nutzer außerhalb von Cloud SQL speichern. Der genaue Datenspeicher wird in diesen Beispielen nicht angegeben, könnte aber z. B. Cloud Storage oder eine MongoDB-Instanz sein.
In den folgenden Abschnitten wird eine grundlegende Implementierung eines benutzerdefinierten Resolvers gezeigt, mit dem diese externen Profilinformationen in Data Connect aufgenommen werden können.
Schema für benutzerdefinierten Resolver definieren
Führen Sie im Verzeichnis Ihres Firebase-Projekts folgenden Befehl aus:
firebase init dataconnect:resolverDie Firebase CLI fordert Sie auf, einen Namen für Ihren benutzerdefinierten Resolver anzugeben, und fragt, ob Beispielimplementierungen für Resolver in TypeScript oder JavaScript generiert werden sollen. Wenn Sie dieser Anleitung folgen, übernehmen Sie den Standardnamen und generieren Sie TypeScript-Beispiele.
Das Tool erstellt dann eine leere
dataconnect/schema_resolver/schema.gql-Datei und fügt derdataconnect.yaml-Datei Ihre neue Resolverkonfiguration hinzu.Aktualisieren Sie diese
schema.gql-Datei mit einem GraphQL-Schema, das die Abfragen und Mutationen definiert, die Ihr benutzerdefinierter Resolver bereitstellt. Hier ist beispielsweise ein Schema für einen benutzerdefinierten Resolver, der das öffentliche Profil eines Nutzers abrufen und aktualisieren kann, das in einem anderen Datenspeicher als Cloud SQL gespeichert ist:# dataconnect/schema_resolver/schema.gql type PublicProfile { name: String! photoUrl: String! bioLine: String! } type Query { # This field will be backed by your Cloud Function. publicProfile(userId: String!): PublicProfile } type Mutation { # This field will be backed by your Cloud Function. updatePublicProfile( userId: String!, name: String, photoUrl: String, bioLine: String ): PublicProfile }
Benutzerdefinierte Resolver-Logik implementieren
Als Nächstes implementieren Sie Ihre Resolver mit Cloud Functions. Im Hintergrund erstellen Sie einen GraphQL-Server. Cloud Functions bietet jedoch die Hilfsmethode onGraphRequest, die die Details dafür übernimmt. Sie müssen also nur die Resolver-Logik schreiben, mit der auf Ihre Datenquelle zugegriffen wird.
Öffnen Sie die Datei
functions/src/index.ts.Als Sie
firebase init dataconnect:resolveroben ausgeführt haben, wurde mit dem Befehl dieses Cloud Functions-Quellcodeverzeichnis erstellt und mit Beispielcode inindex.tsinitialisiert.Fügen Sie die folgenden Definitionen hinzu:
import { FirebaseContext, onGraphRequest, } from "firebase-functions/dataconnect/graphql"; const opts = { // Points to the schema you defined earlier, relative to the root of your // Firebase project. schemaFilePath: "dataconnect/schema_resolver/schema.gql", resolvers: { query: { // This resolver function populates the data for the "publicProfile" field // defined in your GraphQL schema located at schemaFilePath. publicProfile( _parent: unknown, args: Record<string, unknown>, _contextValue: FirebaseContext, _info: unknown ) { const userId = args.userId; // Here you would use the user ID to retrieve the user profile from your data // store. In this example, we just return a hard-coded value. return { name: "Ulysses von Userberg", photoUrl: "https://example.com/profiles/12345/photo.jpg", bioLine: "Just a guy on a mountain. Ski fanatic.", }; }, }, mutation: { // This resolver function updates data for the "updatePublicProfile" field // defined in your GraphQL schema located at schemaFilePath. updatePublicProfile( _parent: unknown, args: Record<string, unknown>, _contextValue: FirebaseContext, _info: unknown ) { const { userId, name, photoUrl, bioLine } = args; // Here you would update in your datastore the user's profile using the // arguments that were passed. In this example, we just return the profile // as though the operation had been successful. return { name, photoUrl, bioLine }; }, }, }, }; export const resolver = onGraphRequest(opts);
Diese Skelettimplementierungen zeigen die allgemeine Form, die eine Resolver-Funktion haben muss. Damit ein benutzerdefinierter Resolver vollständig funktioniert, müssen Sie die kommentierten Abschnitte mit Code füllen, der Daten aus Ihrer Datenquelle liest und in sie schreibt.
Benutzerdefinierte Resolver in Abfragen und Mutationen verwenden
Nachdem Sie das Schema Ihres benutzerdefinierten Resolvers definiert und die zugrunde liegende Logik implementiert haben, können Sie den benutzerdefinierten Resolver in Ihren Data Connect-Abfragen und ‑Mutationen verwenden. Später verwenden Sie diese Vorgänge, um automatisch ein benutzerdefiniertes Client-SDK zu generieren, mit dem Sie auf alle Ihre Daten zugreifen können, unabhängig davon, ob sie von Cloud SQL, Ihren benutzerdefinierten Resolvern oder einer Kombination aus beidem unterstützt werden.
Fügen Sie in
dataconnect/example/queries.gqldie folgende Definition hinzu:query GetPublicProfile($id: String!) @auth(level: PUBLIC, insecureReason: "Anyone can see a public profile.") { publicProfile(userId: $id) { name photoUrl bioLine } }Mit dieser Abfrage wird das öffentliche Profil eines Nutzers mithilfe Ihres benutzerdefinierten Resolvers abgerufen.
Fügen Sie in
dataconnect/example/mutations.gqldie folgende Definition hinzu:mutation SetPublicProfile( $id: String!, $name: String, $photoUrl: String, $bioLine: String ) @auth(expr: "vars.id == auth.uid") { updatePublicProfile(userId: $id, name: $name, photoUrl: $photoUrl, bioLine: $bioLine) { name photoUrl bioLine } }Mit dieser Mutation wird ein neuer Satz von Profildaten in den Datastore geschrieben. Dazu wird wieder Ihr benutzerdefinierter Resolver verwendet. Im Schema wird die
@auth-Anweisung von Data Connect verwendet, damit Nutzer nur ihre eigenen Profile aktualisieren können. Da Sie über Data Connect auf Ihren Datenspeicher zugreifen, können Sie automatisch Data Connect-Funktionen wie diese nutzen.
In den obigen Beispielen haben Sie Data Connect-Vorgänge definiert, die mit Ihren benutzerdefinierten Resolvern auf Daten aus Ihrem Datenspeicher zugreifen. Sie sind jedoch nicht darauf beschränkt, Daten entweder aus Cloud SQL oder aus einer einzelnen benutzerdefinierten Datenquelle abzurufen. Im Abschnitt Beispiele finden Sie einige komplexere Anwendungsfälle, bei denen Daten aus mehreren Quellen kombiniert werden.
Bevor Sie das tun, fahren Sie mit dem nächsten Abschnitt fort, um sich Ihre benutzerdefinierten Resolver in Aktion anzusehen.
Benutzerdefinierten Resolver und Vorgänge bereitstellen
Wie bei allen Änderungen an Ihren Data Connect-Schemas müssen Sie sie bereitstellen, damit sie wirksam werden. Stellen Sie zuerst die benutzerdefinierte Resolver-Logik bereit, die Sie mit Cloud Functions implementiert haben:
firebase deploy --only functionsJetzt können Sie die aktualisierten Schemas und Vorgänge bereitstellen:
firebase deploy --only dataconnectNachdem Sie Änderungen an Ihren Data Connect-Schemas vorgenommen haben, müssen Sie auch neue Client-SDKs generieren:
firebase dataconnect:sdk:generateBeispiele
In diesen Beispielen wird gezeigt, wie Sie einige erweiterte Anwendungsfälle implementieren und häufige Fehler vermeiden.
Zugriff auf einen benutzerdefinierten Resolver mit Daten aus Cloud SQL autorisieren
Einer der Vorteile der Integration Ihrer Datenquellen in Data Connect mithilfe benutzerdefinierter Resolver besteht darin, dass Sie Vorgänge schreiben können, die Datenquellen kombinieren.
Angenommen, Sie entwickeln eine Social-Media-App und haben eine Mutation als benutzerdefinierten Resolver implementiert, die eine Erinnerungs-E-Mail an einen Freund eines Nutzers sendet, wenn dieser seit einiger Zeit nicht mit dem Nutzer interagiert hat.
Um die Funktion für Erinnerungen zu implementieren, erstellen Sie einen benutzerdefinierten Resolver mit einem Schema wie dem folgenden:
# A GraphQL server must define a root query type per the spec.
type Query {
unused: String
}
type Mutation {
sendEmail(id: String!, content: String): Boolean
}
Diese Definition wird durch eine Cloud-Funktion wie die folgende unterstützt:
import {
FirebaseContext,
onGraphRequest,
} from "firebase-functions/dataconnect/graphql";
const opts = {
schemaFilePath: "dataconnect/schema_resolver/schema.gql",
resolvers: {
mutation: {
sendEmail(
_parent: unknown,
args: Record<string, unknown>,
_contextValue: FirebaseContext,
_info: unknown
) {
const { id, content } = args;
// Look up the friend's email address and call the cloud service of your
// choice to send the friend an email with the given content.
return true;
},
},
},
};
export const resolver = onGraphRequest(opts);
Da das Senden von E‑Mails sowohl für Sie kostspielig als auch ein potenzieller Vektor für Missbrauch ist, sollten Sie sicherstellen, dass der beabsichtigte Empfänger bereits in der Freundesliste des Nutzers enthalten ist, bevor Sie Ihren benutzerdefinierten sendEmail-Resolver verwenden.
Angenommen, in Ihrer App werden Freundeslistendaten in Cloud SQL gespeichert:
type User @table {
id: String! @default(expr: "auth.uid")
acceptNudges: Boolean! @default(value: false)
}
type UserFriend @table(key: ["user", "friend"]) {
user: User!
friend: User!
}
Sie können eine Mutation schreiben, die zuerst Cloud SQL abfragt, um sicherzustellen, dass sich der Absender in der Freundesliste des Empfängers befindet, bevor der benutzerdefinierte Resolver zum Senden der E‑Mail verwendet wird:
# Send a "nudge" to a friend as a reminder. This will only let the user send a
# nudge if $friendId is in the user's friends list.
mutation SendNudge($friendId: String!) @auth(level: USER_EMAIL_VERIFIED) {
# Step 1: Query and check
query @redact {
userFriend(
key: {userId_expr: "auth.uid", friendId: $friendId}
# This checks that $friendId is in the user's friends list.
) @check(expr: "this != null", message: "You must be friends to nudge") {
friend {
# This checks that the friend is accepting nudges.
acceptNudges @check(expr: "this == true", message: "Not accepting nudges")
}
}
}
# Step 2: Act
sendEmail(id: $friendId, content: "You've been nudged!")
}
Nebenbei bemerkt zeigt dieses Beispiel auch, dass eine Datenquelle im Kontext benutzerdefinierter Resolver auch andere Ressourcen als Datenbanken und ähnliche Systeme umfassen kann. In diesem Beispiel ist die Datenquelle ein Cloud-E-Mail-Versanddienst.
Sequenzielle Ausführung mit Mutationen sicherstellen
Wenn Sie Datenquellen kombinieren, müssen Sie oft dafür sorgen, dass eine Anfrage an eine Datenquelle abgeschlossen wird, bevor eine Anfrage an eine andere Datenquelle erfolgt. Angenommen, Sie haben eine Abfrage, mit der ein On-Demand-Video dynamisch mit einer KI-API transkribiert wird. Ein solcher API-Aufruf kann teuer sein. Daher sollten Sie ihn an bestimmte Kriterien knüpfen, z. B. dass der Nutzer das Video besitzt oder dass der Nutzer in Ihrer App Premium-Guthaben gekauft hat.
Ein erster Versuch, dies zu erreichen, könnte so aussehen:
# This won't work as expected.
query BrokenTranscribeVideo($videoId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
# Step 1: Check quota using SQL.
# Verify the user owns the video and has "pro" status or credits.
checkQuota: query @redact {
video(id: $videoId)
{
user @check(expr: "this.id == auth.uid && this.hasCredits == true", message: "Unauthorized access") {
id
hasCredits
}
}
}
# Step 2: Trigger expensive compute
# Only triggers if Step 1 succeeds? No! This won't work because query field
# execution order is not guaranteed.
triggerTranscription: query {
# For example, might call Vertex AI or Transcoder API.
startVideoTranscription(videoId: $videoId)
}
}
Dieser Ansatz funktioniert nicht, da die Ausführungsreihenfolge von Abfragefeldern nicht garantiert ist. Der GraphQL-Server muss Felder in beliebiger Reihenfolge auflösen können, um die Parallelität zu maximieren. Andererseits werden die Felder einer Mutation immer in der Reihenfolge aufgelöst, da der GraphQL-Server davon ausgeht, dass einige Felder einer Mutation beim Auflösen Nebeneffekte haben könnten.
Auch wenn der erste Schritt des Beispielvorgangs keine Nebeneffekte hat, können Sie den Vorgang als Mutation definieren, um davon zu profitieren, dass Mutationsfelder in der Reihenfolge aufgelöst werden:
# By using a mutation, we guarantee the SQL check happens FIRST.
mutation TranscribeVideo($videoId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
# Step 1: Check quota using SQL.
# Verify the user owns the video and has "pro" status or credits.
checkQuota: query @redact {
video(id: $videoId)
{
user @check(expr: "this.id == auth.uid && this.hasCredits == true", message: "Unauthorized access") {
id
hasCredits
}
}
}
# Step 2: Trigger expensive compute
# This Cloud Function will ONLY trigger if Step 1 succeeds.
triggerTranscription: query {
# For example, might call Vertex AI or Transcoder API.
startVideoTranscription(videoId: $videoId)
}
}
Beschränkungen
Die Funktion für benutzerdefinierte Resolver wird als experimentelle öffentliche Vorschau veröffentlicht. Beachten Sie die folgenden aktuellen Einschränkungen:
Keine CEL-Ausdrücke in benutzerdefinierten Resolver-Argumenten
CEL-Ausdrücke können nicht dynamisch in den Argumenten eines benutzerdefinierten Resolvers verwendet werden. Folgendes ist beispielsweise nicht möglich:
mutation UpdateMyProfile($newName: String!) @auth(level: USER) {
updateMongoDocument(
collection: "profiles"
# This isn't supported:
id_expr: "auth.uid"
update: { name: $newName }
)
}
Übergeben Sie stattdessen Standardvariablen (z. B. $authUid) und validieren Sie sie auf Vorgangsebene mit der sicher ausgewerteten @auth(expr: ...)-Anweisung.
mutation UpdateMyProfile(
$newName: String!, $authUid: String!
) @auth(expr: "vars.authUid == auth.uid") {
updateMongoDocument(
collection: "profiles"
id: $authUid
update: { name: $newName }
)
}
Eine weitere Problemumgehung besteht darin, die gesamte Logik in einen benutzerdefinierten Resolver zu verschieben und alle Datenvorgänge über Cloud Functions auszuführen.
Das folgende Beispiel funktioniert derzeit nicht:
mutation BrokenForwardToEmail($chatMessageId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
query {
chatMessage(id: $chatMessageId) {
content
}
}
sendEmail(
title: "Forwarded Chat Message"
to_expr: "auth.token.email" # Not supported.
content_expr: "response.query.chatMessage.content" # Not supported.
)
}
Verschieben Sie stattdessen sowohl die Cloud SQL-Abfrage als auch den Aufruf des E-Mail-Dienstes in ein Mutationsfeld, das von einer Funktion unterstützt wird:
mutation ForwardToEmail($chatMessageId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
forwardChatToEmail(
chatMessageId: $chatMessageId
)
}
Generieren Sie ein Admin SDK für Ihre Datenbank und verwenden Sie es in der Funktion, um die Cloud SQL-Abfrage auszuführen:
const opts = {
schemaFilePath: "dataconnect/schema_resolver/schema.gql",
resolvers: {
query: {
async forwardToEmail(
_parent: unknown,
args: Record<string, unknown>,
_contextValue: FirebaseContext,
_info: unknown
) {
const chatMessageId = args.chatMessageId as string;
let decodedToken;
try {
decodedToken = await getAuth().verifyIdToken(_contextValue.auth.token ?? "");
} catch (error) {
return false;
}
const email = decodedToken.email;
if (!email) {
return false;
}
const response = await getChatMessage({chatMessageId});
const messageContent = response.data.chatMessage?.content;
// Here you call the cloud service of your choice to send the email with
// the message content.
return true;
}
},
},
};
export const resolver = onGraphRequest(opts);
Keine Eingabeobjekttypen in benutzerdefinierten Resolverparametern
Benutzerdefinierte Resolver akzeptieren keine komplexen GraphQL-Eingabetypen. Parameter müssen einfache skalare Typen (String, Int, Date, Any usw.) und Enums sein.
input PublicProfileInput {
name: String!
photoUrl: String!
bioLine: String!
}
type Mutation {
# Not supported:
updatePublicProfile(userId: String!, profile: PublicProfileInput): PublicProfile
# OK:
updatePublicProfile(userId: String!, name: String, photoUrl: String, bioLine: String): PublicProfile
}
Benutzerdefinierte Resolver können SQL-Vorgängen nicht vorangestellt werden.
Wenn Sie in einer Mutation einen benutzerdefinierten Resolver vor Standard-SQL-Vorgängen platzieren, führt das zu einem Fehler. Alle SQL-basierten Vorgänge müssen vor allen benutzerdefinierten Resolveraufrufen stehen.
Keine Transaktionen (@transaction)
Benutzerdefinierte Resolver können nicht in einem @transaction-Block mit Standard-SQL-Vorgängen umschlossen werden. Wenn die Cloud-Funktion, die den Resolver unterstützt, nach einem erfolgreichen SQL-Einfügevorgang fehlschlägt, wird die Datenbank nicht automatisch zurückgesetzt.
Um die Transaktionssicherheit zwischen SQL und einer anderen Datenquelle zu gewährleisten, verschieben Sie die SQL-Vorgangslogik in die Cloud-Funktion und verarbeiten Sie Validierung und Rollbacks mit dem Admin SDK oder direkten SQL-Verbindungen.