گسترش اتصال داده با resolvers سفارشی

با نوشتن resolver های سفارشی، می‌توانید Firebase Data Connect گسترش دهید تا علاوه بر Cloud SQL، از منابع داده دیگری نیز پشتیبانی کند. سپس می‌توانید چندین منبع داده (Cloud SQL و منابع داده ارائه شده توسط resolver های سفارشی خود) را در یک پرس و جو یا جهش واحد ترکیب کنید.

مفهوم «منبع داده» انعطاف‌پذیر است و شامل موارد زیر می‌شود:

  • پایگاه‌های داده‌ای غیر از Cloud SQL، مانند Cloud Firestore، MongoDB و موارد دیگر.
  • سرویس‌های ذخیره‌سازی مانند Cloud Storage، AWS S3 و موارد دیگر.
  • هرگونه یکپارچه‌سازی مبتنی بر API، مانند Stripe، SendGrid، Salesforce و موارد دیگر.
  • منطق کسب و کار سفارشی.

زمانی که resolver های سفارشی برای پشتیبانی از منابع داده اضافی خود نوشتید، کوئری‌ها و جهش‌های Data Connect شما می‌توانند آنها را به روش‌های مختلفی ترکیب کنند و مزایایی مانند موارد زیر را ارائه دهند:

  • یک لایه مجوزدهی یکپارچه برای منابع داده شما. به عنوان مثال، دسترسی به فایل‌های موجود در فضای ذخیره‌سازی ابری را با استفاده از داده‌های ذخیره شده در SQL ابری مجاز کنید.
  • SDK های کلاینت ایمن از نوع برای وب، اندروید و iOS.
  • پرس‌وجوهایی که داده‌ها را از چندین منبع برمی‌گردانند.
  • محدود کردن فراخوانی توابع بر اساس وضعیت پایگاه داده شما.

پیش‌نیازها

برای نوشتن resolver های سفارشی خود، به موارد زیر نیاز دارید:

  • فایربیس CLI نسخه ۱۵.۹.۰ یا بالاتر
  • کیت توسعه نرم‌افزاری توابع فایربیس نسخه ۷.۱.۰ یا بالاتر

علاوه بر این، شما باید با نوشتن توابع با استفاده از Cloud Functions برای Firebase آشنا باشید، که نشان می‌دهد چگونه منطق resolver های سفارشی خود را پیاده‌سازی خواهید کرد.

قبل از اینکه شروع کنی

شما باید از قبل یک پروژه برای استفاده از Data Connect تنظیم کرده باشید.

اگر هنوز آماده نشده‌اید، می‌توانید یکی از راهنماهای شروع سریع را برای راه‌اندازی دنبال کنید:

نوشتن resolver های سفارشی

در سطح بالا، نوشتن یک resolver سفارشی سه بخش دارد: اول، تعریف یک طرحواره برای resolver سفارشی شما؛ دوم، پیاده‌سازی resolverهای شما با استفاده از توابع ابری؛ و در نهایت، استفاده از فیلدهای resolver سفارشی شما در پرس‌وجوها و جهش‌ها، احتمالاً همراه با Cloud SQL یا سایر resolverهای سفارشی.

برای یادگیری نحوه انجام این کار، مراحل چند بخش بعدی را دنبال کنید. به عنوان یک مثال انگیزشی، فرض کنید اطلاعات پروفایل عمومی کاربران خود را در خارج از Cloud SQL ذخیره کرده‌اید. محل دقیق ذخیره‌سازی داده در این مثال‌ها مشخص نشده است، اما می‌تواند چیزی مانند Cloud Storage، یک نمونه MongoDB یا هر چیز دیگری باشد.

بخش‌های بعدی، پیاده‌سازی اسکلتی از یک resolver سفارشی را نشان می‌دهند که می‌تواند اطلاعات پروفایل خارجی را به Data Connect بیاورد.

طرحواره (schema) را برای resolver سفارشی خود تعریف کنید

  1. در دایرکتوری پروژه Firebase خود، دستور زیر را اجرا کنید:

    firebase init dataconnect:resolver

    رابط خط فرمان فایربیس (Firebase CLI) از شما نامی برای resolver سفارشی‌تان می‌خواهد و می‌پرسد که آیا می‌خواهد پیاده‌سازی‌های resolver نمونه را در TypeScript یا JavaScript تولید کند. اگر این راهنما را دنبال می‌کنید، نام پیش‌فرض را بپذیرید و نمونه‌های TypeScript را تولید کنید.

    سپس این ابزار یک فایل خالی dataconnect/schema_resolver/schema.gql ایجاد می‌کند و پیکربندی resolver جدید شما را به فایل dataconnect.yaml اضافه می‌کند.

  2. این فایل schema.gql را با یک طرح GraphQL که کوئری‌ها و جهش‌هایی را که resolver سفارشی شما ارائه می‌دهد تعریف می‌کند، به‌روزرسانی کنید. برای مثال، در اینجا یک طرح برای یک 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
    }
    

منطق resolver سفارشی را پیاده‌سازی کنید

در مرحله بعد، resolver های خود را با استفاده از 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);
    

این پیاده‌سازی‌های اسکلتی، شکل کلی یک تابع resolver را نشان می‌دهند. برای ایجاد یک resolver سفارشی کاملاً کارآمد، باید بخش‌های کامنت‌گذاری شده را با کدی پر کنید که منبع داده شما را می‌خواند و می‌نویسد.

استفاده از resolver های سفارشی در کوئری‌ها و جهش‌ها

اکنون که طرحواره‌ی resolver سفارشی خود را تعریف کرده‌اید و منطق پشتیبان آن را پیاده‌سازی کرده‌اید، می‌توانید از resolver سفارشی در کوئری‌ها و جهش‌های Data Connect خود استفاده کنید. بعداً، از این عملیات برای تولید خودکار یک SDK کلاینت سفارشی استفاده خواهید کرد که می‌توانید برای دسترسی به تمام داده‌های خود، چه توسط Cloud SQL، resolverهای سفارشی شما یا ترکیبی از آنها پشتیبانی شوند، از آن استفاده کنید.

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

    این کوئری با استفاده از resolver سفارشی شما، پروفایل عمومی کاربر را بازیابی می‌کند.

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

    این جهش، دوباره با استفاده از resolver سفارشی شما، مجموعه‌ای جدید از داده‌های پروفایل را در پایگاه داده می‌نویسد. توجه داشته باشید که این طرح از دستورالعمل @auth در Data Connect استفاده می‌کند تا اطمینان حاصل شود که کاربران فقط می‌توانند پروفایل‌های خود را به‌روزرسانی کنند. از آنجا که شما از طریق Data Connect به پایگاه داده خود دسترسی دارید، می‌توانید به طور خودکار از ویژگی‌های Data Connect مانند این بهره‌مند شوید.

در مثال‌های بالا، شما عملیات Data Connect را تعریف کرده‌اید که با استفاده از resolverهای سفارشی شما به داده‌های موجود در پایگاه داده شما دسترسی پیدا می‌کنند. با این حال، شما در عملیات خود محدود به دسترسی به داده‌ها از Cloud SQL یا از یک منبع داده سفارشی واحد نیستید. برای موارد استفاده پیشرفته‌تر که داده‌ها را از چندین منبع ترکیب می‌کنند، به بخش مثال‌ها مراجعه کنید.

قبل از آن، برای مشاهده‌ی عملکرد resolverهای سفارشی خود، به بخش بعدی بروید.

پیاده‌سازی resolver و عملیات سفارشی شما

مانند زمانی که هرگونه تغییری در طرحواره‌های Data Connect خود ایجاد می‌کنید، باید آنها را برای اعمال شدن مستقر کنید. قبل از انجام این کار، ابتدا منطق resolver سفارشی را که با استفاده از Cloud Functions پیاده‌سازی کرده‌اید، مستقر کنید:

firebase deploy --only functions

اکنون می‌توانید طرح‌ها و عملیات به‌روز شده را مستقر کنید:

firebase deploy --only dataconnect

پس از ایجاد تغییرات در طرحواره‌های Data Connect ، باید SDK های کلاینت جدیدی نیز ایجاد کنید:

firebase dataconnect:sdk:generate

مثال‌ها

این مثال‌ها نحوه پیاده‌سازی برخی موارد استفاده پیشرفته‌تر و نحوه جلوگیری از مشکلات رایج را نشان می‌دهند.

مجوز دسترسی به یک resolver سفارشی با استفاده از داده‌های Cloud SQL

یکی از مزایای ادغام منابع داده شما در Data Connect با استفاده از resolver های سفارشی این است که می‌توانید عملیاتی بنویسید که منابع داده را ترکیب می‌کنند.

در این مثال، فرض کنید در حال ساخت یک اپلیکیشن رسانه اجتماعی هستید و یک جهش (mutation) به عنوان یک resolver سفارشی پیاده‌سازی کرده‌اید که اگر دوست کاربر مدتی با او تعامل نداشته باشد، یک ایمیل یادآوری برای او ارسال می‌کند.

برای پیاده‌سازی ویژگی nudge، یک 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
}

این تعریف توسط یک تابع ابری پشتیبانی می‌شود، مانند موارد زیر:

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

به عنوان نکته‌ای فرعی، این مثال همچنین نشان می‌دهد که یک منبع داده در زمینه‌ی resolvers سفارشی می‌تواند شامل منابعی غیر از پایگاه‌های داده و سیستم‌های مشابه باشد. در این مثال، منبع داده یک سرویس ارسال ایمیل ابری است.

تضمین اجرای ترتیبی با استفاده از جهش‌ها

هنگام ترکیب منابع داده، اغلب باید قبل از ارسال درخواست به منبع داده دیگر، مطمئن شوید که درخواست به یک منبع داده تکمیل شده است. به عنوان مثال، فرض کنید یک کوئری دارید که به صورت پویا یک ویدیو را با استفاده از یک 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)
  }
}

محدودیت‌ها

ویژگی resolvers سفارشی به عنوان یک پیش‌نمایش عمومی آزمایشی منتشر شده است. به محدودیت‌های فعلی زیر توجه کنید:

هیچ عبارت 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 }
  )
}

راه حل دیگر این است که تمام منطق خود را به یک حل کننده سفارشی منتقل کنید و تمام عملیات داده خود را از توابع ابری انجام دهید.

برای مثال، این مثال را در نظر بگیرید که در حال حاضر کار نخواهد کرد:

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 ادمین برای پایگاه داده خود ایجاد کنید و از آن در تابع برای انجام کوئری 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
}

resolver های سفارشی نمی‌توانند مقدم بر عملیات SQL باشند.

در یک جهش، قرار دادن یک resolver سفارشی قبل از عملیات استاندارد SQL منجر به خطا می‌شود. تمام عملیات مبتنی بر SQL باید قبل از هرگونه فراخوانی resolver سفارشی ظاهر شوند.

بدون تراکنش (@transaction)

نمی‌توان resolverهای سفارشی را با عملیات استاندارد SQL درون یک بلوک @transaction قرار داد. اگر تابع ابری که از resolver پشتیبانی می‌کند پس از موفقیت درج SQL از کار بیفتد، پایگاه داده به طور خودکار به حالت اولیه خود باز نمی‌گردد.

برای دستیابی به امنیت تراکنش‌ها بین SQL و منبع داده دیگر، منطق عملیات SQL را به داخل تابع ابری منتقل کنید و اعتبارسنجی و بازگرداندن‌ها را با استفاده از Admin SDK یا اتصالات مستقیم SQL مدیریت کنید.