Cloud Firestore iOS Codelab

۱. مرور کلی

اهداف

در این آزمایشگاه کد، شما یک برنامه پیشنهاد رستوران با پشتیبانی Firestore در iOS با زبان Swift خواهید ساخت. شما یاد خواهید گرفت که چگونه:

  1. خواندن و نوشتن داده‌ها در Firestore از یک برنامه iOS
  2. به تغییرات در داده‌های Firestore در لحظه گوش دهید
  3. استفاده از احراز هویت فایربیس و قوانین امنیتی برای ایمن‌سازی داده‌های فایراستور
  4. نوشتن کوئری‌های پیچیده Firestore

پیش‌نیازها

قبل از شروع این codelab، مطمئن شوید که موارد زیر را نصب کرده‌اید:

  • نسخه Xcode 14.0 (یا بالاتر)
  • کوکوپادز ۱.۱۲.۰ (یا بالاتر)

۲. نمونه پروژه را دریافت کنید

کد را دانلود کنید

با کلون کردن پروژه نمونه و اجرای pod update در دایرکتوری پروژه شروع کنید:

git clone https://github.com/firebase/friendlyeats-ios
cd friendlyeats-ios
pod update

FriendlyEats.xcworkspace را در Xcode باز کنید و آن را اجرا کنید (Cmd+R). برنامه باید به درستی کامپایل شود و بلافاصله هنگام اجرا از کار بیفتد، زیرا فایل GoogleService-Info.plist را ندارد. این مشکل را در مرحله بعدی برطرف خواهیم کرد.

۳. فایربیس را راه‌اندازی کنید

ایجاد یک پروژه فایربیس

  1. با استفاده از حساب گوگل خود وارد کنسول فایربیس شوید.
  2. برای ایجاد یک پروژه جدید، روی دکمه کلیک کنید و سپس نام پروژه را وارد کنید (برای مثال، FriendlyEats ).
  3. روی ادامه کلیک کنید.
  4. در صورت درخواست، شرایط Firebase را مرور و قبول کنید و سپس روی ادامه کلیک کنید.
  5. (اختیاری) دستیار هوش مصنوعی را در کنسول Firebase (با نام "Gemini در Firebase") فعال کنید.
  6. برای این codelab، به گوگل آنالیتیکس نیاز ندارید ، بنابراین گزینه گوگل آنالیتیکس را غیرفعال کنید .
  7. روی ایجاد پروژه کلیک کنید، منتظر بمانید تا پروژه شما آماده شود و سپس روی ادامه کلیک کنید.

برنامه خود را به Firebase وصل کنید

یک برنامه iOS در پروژه جدید Firebase خود ایجاد کنید.

فایل GoogleService-Info.plist پروژه خود را از کنسول Firebase دانلود کنید و آن را به ریشه پروژه Xcode بکشید. پروژه را دوباره اجرا کنید تا مطمئن شوید که برنامه به درستی پیکربندی شده و دیگر هنگام اجرا دچار مشکل نمی‌شود. پس از ورود به سیستم، باید یک صفحه خالی مانند مثال زیر مشاهده کنید. اگر نمی‌توانید وارد سیستم شوید، مطمئن شوید که روش ورود به سیستم با ایمیل/رمز عبور را در کنسول Firebase در بخش Authentication فعال کرده‌اید.

d5225270159c040b.png

۴. نوشتن داده‌ها در Firestore

در این بخش، مقداری داده در Firestore می‌نویسیم تا بتوانیم رابط کاربری برنامه را پر کنیم. این کار را می‌توان به صورت دستی از طریق کنسول Firebase انجام داد، اما ما این کار را در خود برنامه انجام خواهیم داد تا یک نوشتن اولیه در Firestore را نشان دهیم.

شیء مدل اصلی در برنامه ما یک رستوران است. داده‌های Firestore به اسناد، مجموعه‌ها و زیرمجموعه‌ها تقسیم می‌شوند. ما هر رستوران را به عنوان یک سند در یک مجموعه سطح بالا به نام restaurants ذخیره خواهیم کرد. اگر می‌خواهید درباره مدل داده Firestore بیشتر بدانید، درباره اسناد و مجموعه‌ها در مستندات مطالعه کنید.

قبل از اینکه بتوانیم داده‌ها را به Firestore اضافه کنیم، باید به مجموعه رستوران‌ها ارجاع دهیم. کد زیر را به حلقه for داخلی در متد RestaurantsTableViewController.didTapPopulateButton(_:) اضافه کنید.

let collection = Firestore.firestore().collection("restaurants")

حالا که یک مرجع مجموعه داریم، می‌توانیم مقداری داده بنویسیم. کد زیر را درست بعد از آخرین خط کدی که اضافه کردیم، اضافه کنید:

let collection = Firestore.firestore().collection("restaurants")

// ====== ADD THIS ======
let restaurant = Restaurant(
  name: name,
  category: category,
  city: city,
  price: price,
  ratingCount: 0,
  averageRating: 0
)

collection.addDocument(data: restaurant.dictionary)

کد بالا یک سند جدید به مجموعه رستوران‌ها اضافه می‌کند. داده‌های سند از یک دیکشنری می‌آید که ما آن را از یک ساختار رستوران دریافت می‌کنیم.

تقریباً به اینجا رسیده‌ایم - قبل از اینکه بتوانیم اسناد را در Firestore بنویسیم، باید قوانین امنیتی Firestore را باز کنیم و توضیح دهیم که کدام بخش‌های پایگاه داده ما باید توسط کدام کاربران قابل نوشتن باشد. در حال حاضر، فقط به کاربران احراز هویت شده اجازه می‌دهیم که کل پایگاه داده را بخوانند و بنویسند. این برای یک برنامه کاربردی کمی بیش از حد مجاز است، اما در طول فرآیند ساخت برنامه، ما می‌خواهیم چیزی به اندازه کافی راحت باشد تا در حین آزمایش، دائماً با مشکلات احراز هویت مواجه نشویم. در پایان این آزمایشگاه کد، در مورد چگونگی سخت‌تر کردن قوانین امنیتی و محدود کردن احتمال خواندن و نوشتن ناخواسته صحبت خواهیم کرد.

در تب Rules کنسول Firebase، قوانین زیر را اضافه کنید و سپس روی Publish کلیک کنید.

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{any}/ratings/{rating} {
      // Users can only write ratings with their user ID
      allow read;
      allow write: if request.auth != null
                   && request.auth.uid == request.resource.data.userId;
    }

    match /restaurants/{any} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

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

برنامه را اجرا کنید و وارد سیستم شوید. سپس روی دکمه « جمع‌آوری » در بالا سمت چپ ضربه بزنید، که مجموعه‌ای از اسناد رستوران را ایجاد می‌کند، اگرچه هنوز این را در برنامه مشاهده نخواهید کرد.

سپس، به تب داده‌های Firestore در کنسول Firebase بروید. اکنون باید ورودی‌های جدیدی را در مجموعه رستوران‌ها مشاهده کنید:

اسکرین شات 2017-07-06 ساعت 12.45.38 بعد از ظهر.png

تبریک می‌گویم، شما همین الان داده‌ها را از یک برنامه iOS در Firestore نوشتید! در بخش بعدی یاد خواهید گرفت که چگونه داده‌ها را از Firestore بازیابی کرده و در برنامه نمایش دهید.

۵. نمایش داده‌ها از Firestore

در این بخش یاد خواهید گرفت که چگونه داده‌ها را از Firestore بازیابی کرده و در برنامه نمایش دهید. دو مرحله کلیدی ایجاد یک پرس‌وجو و اضافه کردن یک شنونده snapshot است. این شنونده از تمام داده‌های موجود که با پرس‌وجو مطابقت دارند مطلع می‌شود و به‌روزرسانی‌ها را به صورت بلادرنگ دریافت می‌کند.

ابتدا، بیایید کوئری‌ای بسازیم که لیست پیش‌فرض و فیلتر نشده‌ی رستوران‌ها را نمایش دهد. نگاهی به پیاده‌سازی RestaurantsTableViewController.baseQuery() بیندازید:

return Firestore.firestore().collection("restaurants").limit(to: 50)

این کوئری حداکثر ۵۰ رستوران از مجموعه سطح بالا با نام "restaurants" را بازیابی می‌کند. اکنون که یک کوئری داریم، باید یک snapshot listener برای بارگذاری داده‌ها از Firestore در برنامه خود پیوست کنیم. کد زیر را درست پس از فراخوانی stopObserving() به متد RestaurantsTableViewController.observeQuery() اضافه کنید.

listener = query.addSnapshotListener { [unowned self] (snapshot, error) in
  guard let snapshot = snapshot else {
    print("Error fetching snapshot results: \(error!)")
    return
  }
  let models = snapshot.documents.map { (document) -> Restaurant in
    if let model = Restaurant(dictionary: document.data()) {
      return model
    } else {
      // Don't use fatalError here in a real app.
      fatalError("Unable to initialize type \(Restaurant.self) with dictionary \(document.data())")
    }
  }
  self.restaurants = models
  self.documents = snapshot.documents

  if self.documents.count > 0 {
    self.tableView.backgroundView = nil
  } else {
    self.tableView.backgroundView = self.backgroundView
  }

  self.tableView.reloadData()
}

کد بالا، مجموعه را از Firestore دانلود کرده و آن را در یک آرایه به صورت محلی ذخیره می‌کند. فراخوانی addSnapshotListener(_:) یک شنونده‌ی snapshot به کوئری اضافه می‌کند که هر بار که داده‌ها روی سرور تغییر می‌کنند، view controller را به‌روزرسانی می‌کند. ما به‌روزرسانی‌ها را به صورت خودکار دریافت می‌کنیم و نیازی به اعمال دستی تغییرات نداریم. به یاد داشته باشید، این شنونده‌ی snapshot می‌تواند در هر زمانی به عنوان نتیجه‌ی تغییر سمت سرور فراخوانی شود، بنابراین مهم است که برنامه‌ی ما بتواند تغییرات را مدیریت کند.

پس از نگاشت دیکشنری‌هایمان به ساختارها (به Restaurant.swift مراجعه کنید)، نمایش داده‌ها تنها با اختصاص چند ویژگی به view امکان‌پذیر است. خطوط زیر را به RestaurantTableViewCell.populate(restaurant:) در RestaurantsTableViewController.swift اضافه کنید.

nameLabel.text = restaurant.name
cityLabel.text = restaurant.city
categoryLabel.text = restaurant.category
starsView.rating = Int(restaurant.averageRating.rounded())
priceLabel.text = priceString(from: restaurant.price)

این متد populate از tableView(_:cellForRowAtIndexPath:) از منبع داده نمای جدولی فراخوانی می‌شود، که وظیفه نگاشت مجموعه انواع مقادیر از قبل به سلول‌های منفرد نمای جدولی را بر عهده دارد.

برنامه را دوباره اجرا کنید و تأیید کنید که رستوران‌هایی که قبلاً در کنسول دیدیم، اکنون در شبیه‌ساز یا دستگاه قابل مشاهده هستند. اگر این بخش را با موفقیت تکمیل کرده باشید، برنامه شما اکنون با Cloud Firestore در حال خواندن و نوشتن داده‌ها است!

391c0259bf05ac25.png

۶. مرتب‌سازی و فیلتر کردن داده‌ها

در حال حاضر برنامه ما لیستی از رستوران‌ها را نمایش می‌دهد، اما هیچ راهی برای فیلتر کردن بر اساس نیاز کاربر وجود ندارد. در این بخش شما از پرس‌وجوی پیشرفته Firestore برای فعال کردن فیلتر استفاده خواهید کرد.

در اینجا مثالی از یک پرس و جوی ساده برای دریافت همه رستوران‌های Dim Sum آورده شده است:

let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")

همانطور که از نامش پیداست، متد whereField(_:isEqualTo:) باعث می‌شود کوئری ما فقط اعضایی از مجموعه را دانلود کند که فیلدهایشان محدودیت‌های تعیین‌شده توسط ما را داشته باشند. در این حالت، فقط رستوران‌هایی را دانلود می‌کند که category "Dim Sum" باشد.

در این برنامه، کاربر می‌تواند چندین فیلتر را به صورت زنجیره‌ای برای ایجاد جستجوهای خاص، مانند «پیتزا در سانفرانسیسکو» یا «غذاهای دریایی در لس‌آنجلس بر اساس محبوبیت» ایجاد کند.

RestaurantsTableViewController.swift را باز کنید و قطعه کد زیر را به وسط query(withCategory:city:price:sortBy:) اضافه کنید:

if let category = category, !category.isEmpty {
  filtered = filtered.whereField("category", isEqualTo: category)
}

if let city = city, !city.isEmpty {
  filtered = filtered.whereField("city", isEqualTo: city)
}

if let price = price {
  filtered = filtered.whereField("price", isEqualTo: price)
}

if let sortBy = sortBy, !sortBy.isEmpty {
  filtered = filtered.order(by: sortBy)
}

قطعه کد بالا چندین عبارت whereField و order را برای ساخت یک کوئری مرکب بر اساس ورودی کاربر اضافه می‌کند. اکنون کوئری ما فقط رستوران‌هایی را برمی‌گرداند که با نیازهای کاربر مطابقت دارند.

پروژه خود را اجرا کنید و تأیید کنید که می‌توانید بر اساس قیمت، شهر و دسته‌بندی فیلتر کنید (مطمئن شوید که نام دسته‌بندی و شهر را دقیقاً تایپ می‌کنید). هنگام آزمایش، ممکن است خطاهایی مانند این را در گزارش‌های خود مشاهده کنید:

Error fetching snapshot results: Error Domain=io.grpc Code=9
"The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=..."
UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=...}

دلیل این امر این است که Firestore برای اکثر کوئری‌های ترکیبی به ایندکس نیاز دارد. الزام ایندکس در کوئری‌ها، Firestore را در مقیاس‌پذیری سریع نگه می‌دارد. باز کردن لینک از پیام خطا، به طور خودکار رابط کاربری ایجاد ایندکس را در کنسول Firebase با پارامترهای صحیح پر شده باز می‌کند. برای کسب اطلاعات بیشتر در مورد ایندکس‌ها در Firestore، به مستندات مراجعه کنید .

۷. نوشتن داده‌ها در یک تراکنش

در این بخش، قابلیتی را اضافه خواهیم کرد که کاربران بتوانند برای رستوران‌ها نظر ارسال کنند. تاکنون، تمام نوشته‌های ما جزئی و نسبتاً ساده بوده‌اند. اگر هر یک از آنها خطا داشته باشد، احتمالاً فقط از کاربر می‌خواهیم که آنها را دوباره امتحان کند یا به طور خودکار آنها را دوباره امتحان کند.

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

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

کد زیر را زیر تمام اعلان‌های let در RestaurantDetailViewController.reviewController(_:didSubmitFormWithReview:) اضافه کنید.

let firestore = Firestore.firestore()
firestore.runTransaction({ (transaction, errorPointer) -> Any? in

  // Read data from Firestore inside the transaction, so we don't accidentally
  // update using stale client data. Error if we're unable to read here.
  let restaurantSnapshot: DocumentSnapshot
  do {
    try restaurantSnapshot = transaction.getDocument(reference)
  } catch let error as NSError {
    errorPointer?.pointee = error
    return nil
  }

  // Error if the restaurant data in Firestore has somehow changed or is malformed.
  guard let data = restaurantSnapshot.data(),
        let restaurant = Restaurant(dictionary: data) else {

    let error = NSError(domain: "FireEatsErrorDomain", code: 0, userInfo: [
      NSLocalizedDescriptionKey: "Unable to write to restaurant at Firestore path: \(reference.path)"
    ])
    errorPointer?.pointee = error
    return nil
  }

  // Update the restaurant's rating and rating count and post the new review at the
  // same time.
  let newAverage = (Float(restaurant.ratingCount) * restaurant.averageRating + Float(review.rating))
      / Float(restaurant.ratingCount + 1)

  transaction.setData(review.dictionary, forDocument: newReviewReference)
  transaction.updateData([
    "numRatings": restaurant.ratingCount + 1,
    "avgRating": newAverage
  ], forDocument: reference)
  return nil
}) { (object, error) in
  if let error = error {
    print(error)
  } else {
    // Pop the review controller on success
    if self.navigationController?.topViewController?.isKind(of: NewReviewViewController.self) ?? false {
      self.navigationController?.popViewController(animated: true)
    }
  }
}

درون بلوک به‌روزرسانی، تمام عملیاتی که ما با استفاده از شیء تراکنش انجام می‌دهیم توسط Firestore به عنوان یک به‌روزرسانی اتمی واحد در نظر گرفته می‌شود. اگر به‌روزرسانی روی سرور با شکست مواجه شود، Firestore به طور خودکار آن را چندین بار دوباره امتحان می‌کند. این بدان معناست که وضعیت خطای ما به احتمال زیاد یک خطای واحد است که به طور مکرر رخ می‌دهد، به عنوان مثال اگر دستگاه کاملاً آفلاین باشد یا کاربر مجاز به نوشتن در مسیری که سعی در نوشتن در آن دارد، نباشد.

۸. قوانین امنیتی

کاربران برنامه ما نباید بتوانند هر داده‌ای را در پایگاه داده ما بخوانند و بنویسند. به عنوان مثال، همه باید بتوانند رتبه‌بندی‌های یک رستوران را ببینند، اما فقط یک کاربر احراز هویت شده باید اجازه ارسال رتبه‌بندی را داشته باشد. نوشتن کد خوب در سمت کلاینت کافی نیست، ما باید مدل امنیت داده‌های خود را در سمت سرور مشخص کنیم تا کاملاً ایمن باشد. در این بخش یاد خواهیم گرفت که چگونه از قوانین امنیتی Firebase برای محافظت از داده‌های خود استفاده کنیم.

ابتدا، بیایید نگاهی عمیق‌تر به قوانین امنیتی که در ابتدای codelab نوشتیم، بیندازیم. کنسول Firebase را باز کنید و در تب Firestore به Database > Rules بروید.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{any}/ratings/{rating} {
      // Users can only write ratings with their user ID
      allow read;
      allow write: if request.auth != null
                   && request.auth.uid == request.resource.data.userId;
    }

    match /restaurants/{any} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

متغیر request در قوانین، یک متغیر سراسری است که در همه قوانین موجود است و شرطی که اضافه کردیم، تضمین می‌کند که درخواست قبل از اینکه به کاربران اجازه انجام هر کاری داده شود، احراز هویت شده است. این کار مانع از آن می‌شود که کاربران احراز هویت نشده از API Firestore برای ایجاد تغییرات غیرمجاز در داده‌های شما استفاده کنند. این شروع خوبی است، اما می‌توانیم از قوانین Firestore برای انجام کارهای بسیار قدرتمندتری استفاده کنیم.

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

اولین عبارت match با زیرمجموعه‌ای به نام ratings از هر سندی که متعلق به مجموعه restaurants است، مطابقت دارد. سپس شرط allow write در صورتی که شناسه کاربری review با شناسه کاربر مطابقت نداشته باشد، از ارسال هرگونه review جلوگیری می‌کند. عبارت match دوم به هر کاربر احراز هویت شده اجازه می‌دهد restaurantها را در پایگاه داده بخواند و بنویسد.

این برای نقد و بررسی‌های ما واقعاً خوب عمل می‌کند، زیرا ما از قوانین امنیتی برای بیان صریح تضمین ضمنی که قبلاً در برنامه خود نوشتیم استفاده کرده‌ایم - اینکه کاربران فقط می‌توانند نقد و بررسی‌های خود را بنویسند. اگر قرار بود یک تابع ویرایش یا حذف برای نقد و بررسی‌ها اضافه کنیم، همین مجموعه قوانین دقیقاً مانع از تغییر یا حذف نقد و بررسی‌های سایر کاربران توسط کاربران نیز می‌شد. اما قوانین Firestore را می‌توان به شیوه‌ای جزئی‌تر نیز برای محدود کردن نوشتن در فیلدهای جداگانه در اسناد به جای کل اسناد استفاده کرد. ما می‌توانیم از این روش استفاده کنیم تا به کاربران اجازه دهیم فقط رتبه‌بندی‌ها، میانگین رتبه‌بندی و تعداد رتبه‌بندی‌های یک رستوران را به‌روزرسانی کنند و احتمال تغییر نام یا مکان رستوران توسط یک کاربر مخرب را از بین ببریم.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{restaurant} {
      match /ratings/{rating} {
        allow read: if request.auth != null;
        allow write: if request.auth != null
                     && request.auth.uid == request.resource.data.userId;
      }

      allow read: if request.auth != null;
      allow create: if request.auth != null;
      allow update: if request.auth != null
                    && request.resource.data.name == resource.data.name
                    && request.resource.data.city == resource.data.city
                    && request.resource.data.price == resource.data.price
                    && request.resource.data.category == resource.data.category;
    }
  }
}

در اینجا ما مجوز نوشتن را به create و update تقسیم کرده‌ایم تا بتوانیم در مورد اینکه کدام عملیات باید مجاز باشند، دقیق‌تر باشیم. هر کاربری می‌تواند رستوران‌ها را در پایگاه داده بنویسد و عملکرد دکمه Populate که در ابتدای codelab ایجاد کردیم را حفظ کند، اما پس از نوشتن یک رستوران، نام، مکان، قیمت و دسته آن قابل تغییر نیست. به طور خاص، آخرین قانون ایجاب می‌کند که هر عملیات به‌روزرسانی رستوران، نام، شهر، قیمت و دسته فیلدهای موجود در پایگاه داده را حفظ کند.

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

۹. نتیجه‌گیری

در این codelab، شما یاد گرفتید که چگونه عملیات خواندن و نوشتن مقدماتی و پیشرفته را با Firestore انجام دهید، و همچنین چگونه دسترسی به داده‌ها را با استفاده از قوانین امنیتی ایمن کنید. می‌توانید راه‌حل کامل را در شاخه codelab-complete پیدا کنید.

برای کسب اطلاعات بیشتر در مورد Firestore، به منابع زیر مراجعه کنید: