使用自定义解析器扩展 Data Connect

通过编写自定义解析器,您可以扩展 Firebase Data Connect 以支持除 Cloud SQL 之外的其他数据源。然后,您可以将多个数据源(Cloud SQL 和自定义解析器提供的数据源)合并到单个查询或变更中。

“数据源”的概念非常灵活。其中包括:

  • Cloud SQL 以外的数据库,例如 Cloud Firestore、MongoDB 等。
  • Cloud Storage、AWS S3 等存储服务。
  • 任何基于 API 的集成,例如 Stripe、SendGrid、Salesforce 等。
  • 自定义业务逻辑。

编写自定义解析器以支持其他数据源后,Data Connect 查询和突变可以多种方式组合这些数据源,从而带来以下好处:

  • 适用于数据源的统一授权层。例如,使用 Cloud SQL 中存储的数据授权对 Cloud Storage 中的文件进行访问。
  • 适用于 Web、Android 和 iOS 的类型安全客户端 SDK。
  • 返回来自多个来源的数据的查询。
  • 根据数据库状态限制了函数调用。

前提条件

如需编写自己的自定义解析器,您需要具备以下条件:

  • Firebase CLI v15.9.0 或更高版本
  • Firebase Functions SDK v7.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);
    

这些框架实现展示了解析器函数必须采用的一般形式。如需创建功能完善的自定义解析器,您需要使用可读取和写入数据源的代码填充注释部分。

在查询和 mutation 中使用自定义解析器

现在,您已经定义了自定义解析器的架构,并实现了支持该架构的逻辑,接下来您可以在 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 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自定义解析器。

假设在您的应用中,好友列表数据存储在 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 操作之前

在 mutation 中,将自定义解析器放在标准 SQL 操作之前会导致错误。所有基于 SQL 的操作都必须出现在任何自定义解析器调用之前。

无交易 (@transaction)

自定义解析器无法封装在包含标准 SQL 操作的 @transaction 块中。如果支持解析器的 Cloud Function 在 SQL 插入成功后失败,数据库不会自动回滚。

为了在 SQL 和其他数据源之间实现事务安全性,请将 SQL 操作逻辑移至 Cloud Function 内,并使用 Admin SDK 或直接 SQL 连接来处理验证和回滚。