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

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

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

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

编写自定义解析器以支持其他数据源后, 您的 SQL Connect 查询和突变可以多种方式组合这些数据源, 从而提供以下优势:

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

前提条件

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

  • Firebase CLI v15.9.0 或更高版本
  • Firebase Functions SDK v7.1.0 或更高版本

此外,您还应熟悉如何使用 Cloud Functions for Firebase编写函数,因为您将使用这种方式来实现 自定义解析器的逻辑。

准备工作

您应已设置一个项目以使用 SQL Connect

如果您尚未进行设置,可以按照以下其中一个快速入门指南进行设置:

编写自定义解析器

从宏观层面来看,编写自定义解析器分为三个部分:首先,为自定义解析器定义架构;其次,使用 Cloud Functions 实现解析器;最后,在查询和突变中使用自定义解析器字段,可能与 Cloud SQL 或其他自定义解析器结合使用。

请按照接下来几个部分中的步骤了解如何操作。举个例子,假设您在 Cloud SQL 之外存储了用户的公开个人资料信息。这些示例中未指定确切的数据存储区,但它可以是 Cloud Storage、MongoDB 实例或其他任何内容。

以下部分将演示自定义 解析器的框架实现,该解析器可以将外部个人资料信息引入 SQL 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);
    

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

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

现在,您已定义自定义解析器的架构并已 实现支持该架构的逻辑,您可以在自定义解析器中 SQL Connect查询和突变。稍后,您将使用这些操作自动生成自定义客户端 SDK,您可以使用该 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
      }
    }
    

    此突变将一组新的个人资料数据写入数据存储区,同样使用您的自定义解析器。请注意,该架构利用 SQL Connect's @auth 指令来确保用户可以 只能更新自己的个人资料。由于您通过数据存储区 访问 SQL Connect,因此您可以自动利用 SQL Connect的功能,例如此功能。

在上面的示例中,您已定义 SQL Connect 操作,这些操作使用自定义解析器访问数据存储区中的数据 。不过,您的操作并不限于访问 Cloud SQL 或单个自定义数据源中的数据。如需查看将来自多个来源的数据组合在一起的一些更高级 用例,请参阅示例部分。

在此之前,请继续阅读下一部分,了解自定义解析器的实际应用。

部署自定义解析器和操作

与对您的 SQL Connect 架构进行任何更改一样,您必须先部署这些架构,然后它们才会生效。在执行此操作之前,请先部署使用 Cloud Functions 实现的自定义解析器逻辑:

firebase deploy --only functions

现在,您可以部署更新后的架构和操作:

firebase deploy --only dataconnect

SQL Connect 架构进行更改后,您还必须 生成新的客户端 SDK:

firebase dataconnect:sdk:generate

示例

这些示例展示了如何实现一些更高级的用例,以及如何避免常见的陷阱。

使用 Cloud SQL 中的数据授权访问自定义解析器

使用自定义解析器将数据源集成到 SQL 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
  )
}

为数据库生成管理员 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)

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

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