הרחבת Data Connect באמצעות פתרונות מותאמים אישית

על ידי כתיבת פונקציות resolver בהתאמה אישית, אפשר להרחיב את Firebase Data Connect כך שיכלול תמיכה במקורות נתונים אחרים בנוסף ל-Cloud SQL. אחר כך תוכלו לשלב כמה מקורות נתונים (Cloud SQL ומקורות הנתונים שסופקו על ידי הפונקציות המותאמות אישית לפתרון שמות) בשאילתה או במוטציה אחת.

הקונספט של 'מקור נתונים' הוא גמיש. היא כוללת:

  • מסדי נתונים אחרים מלבד Cloud SQL, כמו Cloud Firestore,‏ MongoDB ואחרים.
  • שירותי אחסון כמו Cloud Storage,‏ AWS S3 ואחרים.
  • כל שילוב שמבוסס על API, כמו Stripe,‏ SendGrid,‏ Salesforce ואחרים.
  • לוגיקה עסקית בהתאמה אישית.

אחרי שכותבים פונקציות מותאמות אישית לפתרון שמות כדי לתמוך במקורות הנתונים הנוספים, אפשר לשלב אותם בדרכים רבות בשאילתות ובמוטציות של Data Connect, וליהנות מיתרונות כמו:

  • שכבת הרשאות מאוחדת למקורות הנתונים. לדוגמה, אפשר לאשר גישה לקבצים ב-Cloud Storage באמצעות נתונים שמאוחסנים ב-Cloud SQL.
  • ערכות SDK של לקוחות בטוחות לטיפוס לאינטרנט, ל-Android ול-iOS.
  • שאילתות שמחזירות נתונים מכמה מקורות.
  • הפעלות מוגבלות של פונקציות על סמך מצב מסד הנתונים.

דרישות מוקדמות

כדי לכתוב פתרונות מותאמים אישית משלכם, אתם צריכים:

  • Firebase CLI מגרסה 15.9.0 ואילך
  • Firebase Functions SDK גרסה 7.1.0 ואילך

בנוסף, כדאי להכיר את הכתיבה של פונקציות באמצעות Cloud Functions for Firebase, שבאמצעותה תטמיעו את הלוגיקה של פונקציות ה-resolver המותאמות אישית.

לפני שמתחילים

כבר צריך להיות לכם פרויקט מוגדר כדי להשתמש ב-Data Connect.

אם עדיין לא הגדרתם את המערכת, תוכלו להיעזר באחד מהמדריכים למתחילים:

כתיבת פונקציות resolver בהתאמה אישית

באופן כללי, כתיבת resolver מותאם אישית כוללת שלושה חלקים: קודם, הגדרת סכימה ל-resolver המותאם אישית; אחר כך, הטמעה של ה-resolvers באמצעות Cloud Functions; ולבסוף, שימוש בשדות של ה-resolver המותאם אישית בשאילתות ובשינויים, אולי בשילוב עם Cloud SQL או עם resolvers מותאמים אישית אחרים.

בקטעים הבאים מוסבר איך עושים את זה. כדוגמה, נניח שיש לכם פרטי פרופיל ציבוריים של המשתמשים שמאוחסנים מחוץ ל-Cloud SQL. בקטעי הקוד האלה לא מצוין מאגר נתונים מדויק, אבל הוא יכול להיות Cloud Storage, מופע של MongoDB או כל דבר אחר.

בקטעים הבאים מוצג יישום בסיסי של פונקציית resolver מותאמת אישית שיכולה להעביר את פרטי הפרופיל החיצוניים האלה אל Data Connect.

הגדרת הסכימה של הפותר המותאם אישית

  1. בספריית הפרויקט ב-Firebase, מריצים את הפקודה:

    firebase init dataconnect:resolver

    ממשק Firebase CLI יבקש מכם שם לפתרון המותאם אישית, וישאל אם ליצור דוגמאות להטמעה של פתרון ב-TypeScript או ב-JavaScript. אם אתם פועלים לפי המדריך הזה, אתם יכולים לאשר את שם ברירת המחדל וליצור דוגמאות של TypeScript.

    הכלי ייצור קובץ dataconnect/schema_resolver/schema.gql ריק ויוסיף את הגדרות הרזולבר החדשות לקובץ dataconnect.yaml.

  2. מעדכנים את קובץ schema.gql עם סכימת GraphQL שמגדירה את השאילתות והמוטציות שהפונקציה המותאמת אישית לפתרון שמות תספק. לדוגמה, הנה סכימה של פונקציית resolver בהתאמה אישית שיכולה לאחזר ולעדכן את הפרופיל הציבורי של משתמש, שמאוחסן במאגר נתונים שאינו Cloud SQL:

    # 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
    }
    

הטמעה של הלוגיקה של פותר הבעיות המותאם אישית

בשלב הבא, מטמיעים את הפונקציות לפתרון בעיות באמצעות Cloud Functions. מאחורי הקלעים, תיצרו שרת GraphQL, אבל ל-Cloud Functions יש שיטת עזר, onGraphRequest, שמטפלת בפרטים של יצירת השרת, כך שאתם רק צריכים לכתוב את לוגיקת ה-resolver שמאפשרת גישה למקור הנתונים.

  1. פותחים את הקובץ functions/src/index.ts.

    כשמריצים את הפקודה firebase init dataconnect:resolver שלמעלה, היא יוצרת את ספריית קוד המקור של Cloud Functions ומאתחלת אותה עם קוד לדוגמה ב-index.ts.

  2. מוסיפים את ההגדרות הבאות:

    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);
    

ההטמעות האלה מראות את הצורה הכללית שפונקציית מפענח DNS צריכה לקבל. כדי ליצור resolver מותאם אישית שפועל באופן מלא, צריך למלא את הקטעים עם ההערות בקוד שקורא את מקור הנתונים וכותב אליו.

שימוש בפונקציות לפתרון שמות מותאמות אישית באילתות ובמוטציות

אחרי שהגדרתם את הסכימה של פותר הבעיות המותאם אישית והטמעתם את הלוגיקה שמאחוריו, אתם יכולים להשתמש בפותר הבעיות המותאם אישית בשאילתות ובמוטציות של Data Connect. בהמשך, תשתמשו בפעולות האלה כדי ליצור באופן אוטומטי SDK מותאם אישית ללקוח, שתוכלו להשתמש בו כדי לגשת לכל הנתונים שלכם, בין אם הם מגובים על ידי Cloud SQL, פותרים מותאמים אישית או שילוב של שניהם.

  1. ב-dataconnect/example/queries.gql, מוסיפים את ההגדרה הבאה:

    query GetPublicProfile($id: String!)
        @auth(level: PUBLIC, insecureReason: "Anyone can see a public profile.") {
      publicProfile(userId: $id) {
        name
        photoUrl
        bioLine
      }
    }
    

    השאילתה הזו מאחזרת פרופיל ציבורי של משתמש באמצעות פותר הבעיות המותאם אישית שלכם.

  2. ב-dataconnect/example/mutations.gql, מוסיפים את ההגדרה הבאה:

    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
      }
    }
    

    המוטציה הזו כותבת קבוצה חדשה של נתוני פרופיל למאגר הנתונים, שוב באמצעות הפונקציה לפתרון בעיות בהתאמה אישית. שימו לב שבסכימה נעשה שימוש בהנחיה Data Connect של @auth כדי לוודא שהמשתמשים יכולים לעדכן רק את הפרופילים שלהם. מכיוון שאתם ניגשים למאגר הנתונים שלכם דרך Data Connect, אתם יכולים ליהנות באופן אוטומטי מתכונות של Data Connect כמו זו.

בדוגמאות שלמעלה, הגדרתם פעולות Data Connect שמאפשרות גישה לנתונים ממאגר הנתונים באמצעות פונקציות ה-resolver המותאמות אישית. עם זאת, אתם לא מוגבלים בפעולות שלכם לגישה לנתונים מ-Cloud SQL או ממקור נתונים מותאם אישית יחיד. בקטע דוגמאות אפשר לראות כמה תרחישי שימוש מתקדמים יותר שמשלבים נתונים ממקורות שונים.

לפני כן, כדאי לעבור לקטע הבא כדי לראות את הפתרונות המותאמים אישית בפעולה.

פריסת הפעולות והפונקציה לפתרון בעיות בהתאמה אישית

כמו בכל שינוי בסכימות שלכם ב-Data Connect, אתם צריכים לפרוס אותן כדי שהן ייכנסו לתוקף. לפני שעושים את זה, צריך קודם לפרוס את הלוגיקה של רכיב ה-resolver המותאם אישית שהטמעתם באמצעות Cloud Functions:

firebase deploy --only functions

עכשיו אפשר לפרוס את הסכימות והפעולות המעודכנות:

firebase deploy --only dataconnect

אחרי שמבצעים שינויים בסכימות של Data Connect, צריך גם ליצור ערכות SDK חדשות ללקוח:

firebase dataconnect:sdk:generate

דוגמאות

בדוגמאות האלה מוסבר איך ליישם כמה תרחישי שימוש מתקדמים יותר, ואיך להימנע מטעויות נפוצות.

איך מאשרים גישה למפענח DNS בהתאמה אישית באמצעות נתונים מ-Cloud SQL

אחד היתרונות של שילוב מקורות נתונים ב-Data Connect באמצעות פונקציות מותאמות אישית לפתרון בעיות הוא שאפשר לכתוב פעולות שמשלבות מקורות נתונים.

בדוגמה הזו, נניח שאתם מפתחים אפליקציה לרשתות חברתיות, והטמעתם מוטציה כפונקציית resolver בהתאמה אישית, ששולחת אימייל תזכורת לחבר של משתמש אם הוא לא יצר איתו אינטראקציה במשך זמן מה.

כדי להטמיע את התכונה 'נדנוד', יוצרים פותר מותאם אישית עם סכימה כמו זו שבהמשך:

# A GraphQL server must define a root query type per the spec.
type Query {
  unused: String
}

type Mutation {
  sendEmail(id: String!, content: String): Boolean
}

ההגדרה הזו מגובה על ידי פונקציית Cloud Functions, כמו זו שמוצגת בהמשך:

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);

שליחת אימיילים כרוכה בעלויות ועלולה להיות וקטור פוטנציאלי לניצול לרעה, ולכן חשוב לוודא שהנמען המיועד כבר נמצא ברשימת החברים של המשתמש לפני שמשתמשים ב-sendEmail custom resolver.

נניח שבאפליקציה שלכם, נתונים של רשימת חברים מאוחסנים ב-Cloud SQL:

type User @table {
  id: String! @default(expr: "auth.uid")
  acceptNudges: Boolean! @default(value: false)
}

type UserFriend @table(key: ["user", "friend"]) {
  user: User!
  friend: User!
}

אפשר לכתוב מוטציה שקודם שולחת שאילתה ל-Cloud SQL כדי לוודא שהשולח נמצא ברשימת החברים של הנמען, לפני שמשתמשים ב-resolver המותאם אישית כדי לשלוח את האימייל:

# 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!")
}

בנוסף, הדוגמה הזו ממחישה שמקור נתונים בהקשר של פתרונות מותאמים אישית יכול לכלול משאבים אחרים מלבד מסדי נתונים ומערכות דומות. בדוגמה הזו, מקור הנתונים הוא שירות שליחת אימייל בענן.

הבטחת ביצוע עוקב באמצעות מוטציות

כשמשלבים מקורות נתונים, לרוב צריך לוודא שבקשה למקור נתונים אחד הושלמה לפני ששולחים בקשה למקור נתונים אחר. לדוגמה, נניח שיש לכם שאילתה שמתמללת באופן דינמי סרטון על פי דרישה באמצעות AI API. קריאה ל-API כזו עלולה להיות יקרה, ולכן כדאי להגביל את הקריאה באמצעות קריטריונים מסוימים, למשל שהמשתמש הוא הבעלים של הסרטון או שהמשתמש רכש קרדיטים מסוג מסוים באפליקציה.

ניסיון ראשון להשגת המטרה הזו עשוי להיראות כך:

# 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)
  }
}

הגישה הזו לא תעבוד כי סדר הביצוע של שדות השאילתה לא מובטח. שרת GraphQL מצפה להיות מסוגל לפתור שדות בכל סדר, כדי למקסם את הבו-זמניות (concurrency). לעומת זאת, השדות של מוטציה תמיד נפתרים לפי הסדר, כי שרת GraphQL מצפה שלחלק מהשדות של מוטציה יהיו תופעות לוואי כשהם נפתרים.

למרות שלשלב הראשון בפעולה לדוגמה אין תופעות לוואי, אפשר להגדיר את הפעולה כשינוי כדי לנצל את העובדה ששדות שינוי נפתרים לפי הסדר:

# 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)
  }
}

מגבלות

התכונה 'פותרים בהתאמה אישית' מושקת כגרסת Public Preview ניסיונית. הערה אלה ההגבלות הנוכחיות:

אין ביטויים של CEL בארגומנטים של פונקציית resolver מותאמת אישית

אי אפשר להשתמש בביטויי CEL באופן דינמי בארגומנטים של פונקציית resolver בהתאמה אישית. לדוגמה, אי אפשר:

mutation UpdateMyProfile($newName: String!) @auth(level: USER) {
  updateMongoDocument(
    collection: "profiles"
    # This isn't supported:
    id_expr: "auth.uid"
    update: { name: $newName }
  )
}

במקום זאת, מעבירים משתנים רגילים (לדוגמה, $authUid) ומאמתים אותם ברמת הפעולה באמצעות ההנחיה @auth(expr: ...) שמוערכת בצורה מאובטחת.

mutation UpdateMyProfile(
  $newName: String!, $authUid: String!
) @auth(expr: "vars.authUid == auth.uid") {
  updateMongoDocument(
    collection: "profiles"
    id: $authUid
    update: { name: $newName }
  )
}

פתרון עקיף נוסף הוא להעביר את כל הלוגיקה שלכם ל-resolver בהתאמה אישית ולהשלים את כל פעולות הנתונים מ-Cloud Functions.

לדוגמה, הדוגמה הבאה לא תפעל כרגע:

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.
  )
}

במקום זאת, מעבירים את שאילתת Cloud SQL ואת הקריאה לשירות האימייל לשדה מוטציה אחד, שמגובה על ידי פונקציה:

mutation ForwardToEmail($chatMessageId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
  forwardChatToEmail(
    chatMessageId: $chatMessageId
  )
}

יוצרים Admin SDK למסד הנתונים ומשתמשים בו בפונקציה כדי לבצע את שאילתת Cloud SQL:

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);

אין סוגים של אובייקטים של קלט בפרמטרים של פונקציות resolver בהתאמה אישית

פונקציות resolver בהתאמה אישית לא מקבלות סוגי קלט מורכבים של GraphQL. הפרמטרים צריכים להיות מסוגים סקלריים בסיסיים (String,‏ Int,‏ Date,‏ Any וכו') ו-Enum.

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
}

אי אפשר להשתמש בפתרונות מותאמים אישית לפני פעולות SQL

במוטציה, הצבת resolver בהתאמה אישית לפני פעולות SQL סטנדרטי גורמת לשגיאה. כל הפעולות שמבוססות על SQL צריכות להופיע לפני כל קריאה לפונקציה של resolver בהתאמה אישית.

אין עסקאות (@transaction)

אי אפשר להוסיף פתרונות מותאמים אישית בתוך בלוק @transaction עם פעולות SQL רגילות. אם הפונקציה של Cloud Functions שתומכת ב-resolver נכשלת אחרי שהוספת SQL מצליחה, מסד הנתונים לא יבצע אוטומטית ביטול של הפעולה.

כדי להשיג בטיחות טרנזקציונלית בין SQL לבין מקור נתונים אחר, צריך להעביר את הלוגיקה של פעולת ה-SQL אל תוך Cloud Function, ולטפל באימות ובביטולים באמצעות Admin SDK או חיבורי SQL ישירים.