使用自訂解析器擴充 Data Connect

您可以撰寫自訂解析器,擴充 Firebase Data Connect 以支援 Cloud SQL 以外的資料來源。然後,您可以將多個資料來源 (Cloud SQL 和自訂解析器提供的資料來源) 合併為單一查詢或突變。

「資料來源」的概念相當彈性,It includes:

  • Cloud SQL 以外的資料庫,例如 Cloud Firestore、MongoDB 等。
  • Cloud Storage、AWS S3 等儲存空間服務。
  • 任何以 API 為基礎的整合,例如 Stripe、SendGrid、Salesforce 等。
  • 自訂商業邏輯。

編寫自訂解析器來支援其他資料來源後,Data Connect 查詢和突變就能以多種方式合併這些來源,帶來以下好處:

  • 資料來源的統一授權層。舉例來說,您可以使用儲存在 Cloud SQL 中的資料,授權存取 Cloud Storage 中的檔案。
  • 適用於網頁、Android 和 iOS 的型別安全用戶端 SDK。
  • 查詢會傳回多個來源的資料。
  • 根據資料庫狀態限制函式呼叫。

事前準備

如要編寫自己的自訂解析器,您需要:

  • Firebase CLI 15.9.0 以上版本
  • Firebase Functions SDK 7.1.0 以上版本

此外,您也應熟悉如何使用 Cloud Functions for Firebase 撰寫函式,因為您會使用這項服務實作自訂解析器的邏輯。

事前準備

您應該已設定專案來使用 Data Connect

如果您尚未設定,可以按照任一快速入門指南進行:

撰寫自訂解析器

從高層次來看,編寫自訂解析器有三個部分:首先,定義自訂解析器的結構定義;其次,使用 Cloud Functions 實作解析器;最後,在查詢和突變中使用自訂解析器欄位,可能與 Cloud SQL 或其他自訂解析器搭配使用。

請按照接下來幾節的步驟操作,瞭解如何執行這項操作。舉例來說,假設您將使用者的公開個人資料資訊儲存在 Cloud SQL 以外的位置。這些範例未指定確切的資料儲存區,但可以是 Cloud Storage、MongoDB 執行個體或其他任何項目。

以下各節將示範自訂解析器的架構實作,可將外部設定檔資訊帶入 Data Connect

定義自訂解析器的結構定義

  1. 在 Firebase 專案目錄中執行下列指令:

    firebase init dataconnect:resolver

    Firebase CLI 會提示您輸入自訂解析器的名稱,並詢問是否要以 TypeScript 或 JavaScript 產生範例解析器實作。如果您是按照本指南操作,請接受預設名稱並產生 TypeScript 範例。

    接著,工具會建立空白的 dataconnect/schema_resolver/schema.gql 檔案,並將新的解析器設定新增至 dataconnect.yaml 檔案。

  2. 使用 GraphQL 結構定義更新這個 schema.gql 檔案,定義自訂解析器將提供的查詢和突變。舉例來說,以下是自訂解析器的結構定義,可擷取及更新儲存在 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 可處理相關詳細資料,因此您只需要編寫存取資料來源的解析器邏輯。

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

這些骨架實作項目會顯示解析器函式必須採用的通用形狀。如要建立功能齊全的自訂解析器,您需要填入註解區段,並加入可讀取及寫入資料來源的程式碼。

在查詢和突變中使用自訂解析器

您已定義自訂解析器的結構定義,並實作支援該結構定義的邏輯,現在可以在 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 作業,這些作業會使用自訂解析器存取資料儲存庫中的資料。不過,您在作業中存取 Cloud SQL 或單一自訂資料來源的資料時,不會受到限制。如需結合多個來源資料的進階使用案例,請參閱「範例」一節。

在此之前,請繼續前往下一節,瞭解自訂解析器運作情形。

部署自訂解析器和作業

與變更Data Connect結構定義時相同,您必須部署結構定義,變更才會生效。請先使用 Cloud Functions 部署您實作的自訂解析器邏輯,再執行這項操作:

firebase deploy --only functions

現在您可以部署更新後的結構定義和作業:

firebase deploy --only dataconnect

變更 Data Connect 結構定義後,您也必須產生新的用戶端 SDK:

firebase dataconnect:sdk:generate

範例

這些範例說明如何實作一些更進階的用途,以及如何避免常見的陷阱。

使用 Cloud SQL 中的資料授權存取自訂解析器

將資料來源整合至 Data Connect 的好處之一,就是您可以使用自訂解析器編寫作業,合併資料來源。

在這個範例中,假設您正在建構社群媒體應用程式,且您已將變動實作為自訂解析器,如果使用者一段時間未與好友互動,就會傳送提醒電子郵件給好友。

如要實作提醒功能,請建立自訂解析器,並使用下列結構定義:

# 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 Function 支援,例如:

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自訂解析器前,請務必確認預期收件者已在使用者好友名單中。

假設應用程式中的好友名單資料儲存在 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 以確保傳送者位於接收者的好友清單中,再使用自訂解析器傳送電子郵件:

# 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 伺服器應能以任何順序解析欄位,盡可能提高並行程度。另一方面,變異的欄位一律會依序解析,因為 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)
  }
}

限制

自訂解析器功能以實驗性公開測試版的形式發布。請注意下列目前限制:

自訂解析器引數中沒有 CEL 運算式

您無法在自訂解析器的引數中動態使用 CEL 運算式。舉例來說,您無法執行下列操作:

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

另一個解決方法是將所有邏輯移至自訂解析器,並從 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,並在函式中使用該 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);

自訂解析器參數中沒有輸入物件類型

自訂解析器不接受複雜的 GraphQL 輸入類型。參數必須是基本純量型別 (StringIntDateAny 等) 和 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 作業

在變動中,將自訂解析器放在標準 SQL 作業之前會導致錯誤。所有以 SQL 為基礎的作業都必須出現在任何自訂解析器調用之前。

沒有交易 (@transaction)

自訂解析器無法包裝在 @transaction 區塊中,搭配標準 SQL 作業。如果 SQL 插入作業成功後,解析器支援的 Cloud Function 失敗,資料庫不會自動復原。

如要在 SQL 和其他資料來源之間確保交易安全,請將 SQL 作業邏輯移至 Cloud 函式內,並使用 Admin SDK 或直接 SQL 連線處理驗證和回溯。