Cloud Firestore iOS Codelab

1. بررسی اجمالی

اهداف

در این کد لبه شما یک برنامه توصیه رستوران با پشتیبانی Firestor را در iOS در سوئیفت خواهید ساخت. شما یاد خواهید گرفت که چگونه:

  1. خواندن و نوشتن داده ها در Firestore از یک برنامه iOS
  2. به تغییرات داده های Firestore در زمان واقعی گوش دهید
  3. از Firebase Authentication و قوانین امنیتی برای ایمن سازی داده های Firestore استفاده کنید
  4. پرس و جوهای پیچیده Firestore را بنویسید

پیش نیازها

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

  • Xcode نسخه 14.0 (یا بالاتر)
  • CocoaPods 1.12.0 (یا بالاتر)

2. پروژه کنسول Firebase را ایجاد کنید

Firebase را به پروژه اضافه کنید

  1. به کنسول Firebase بروید.
  2. Create New Project را انتخاب کنید و نام پروژه خود را "Firestore iOS Codelab" بگذارید.

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

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

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

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

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

Firebase را راه اندازی کنید

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

d5225270159c040b.png

4. داده ها را در 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 را باز کنیم و توضیح دهیم که کدام بخش از پایگاه داده ما باید توسط کدام کاربر قابل نوشتن باشد. در حال حاضر، ما فقط به کاربران تأیید شده اجازه می‌دهیم تا در کل پایگاه داده بخوانند و بنویسند. این برای یک برنامه تولیدی کمی بیش از حد مجاز است، اما در طول فرآیند ساخت اپلیکیشن، ما به اندازه کافی آرامش‌بخش می‌خواهیم تا در حین آزمایش دائماً با مشکلات احراز هویت مواجه نشویم. در پایان این کد، در مورد چگونگی سخت‌تر کردن قوانین امنیتی و محدود کردن امکان خواندن و نوشتن ناخواسته صحبت خواهیم کرد.

در برگه قوانین کنسول Firebase قوانین زیر را اضافه کنید و سپس روی انتشار کلیک کنید.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      //
      // WARNING: These rules are insecure! We will replace them with
      // more secure rules later in the codelab
      //
      allow read, write: if request.auth != null;
    }
  }
}

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

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

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

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

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

5. نمایش داده ها از Firestore

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

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

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

این پرس و جو تا 50 رستوران از مجموعه سطح بالا به نام "رستوران" را بازیابی می کند. اکنون که یک پرس و جو داریم، باید یک شنونده عکس فوری وصل کنیم تا داده ها را از 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(_:) یک شنونده عکس فوری به کوئری اضافه می کند که هر بار که داده ها در سرور تغییر می کنند، view controller را به روز می کند. ما به‌روزرسانی‌ها را به‌طور خودکار دریافت می‌کنیم و نیازی نیست تغییرات را به‌صورت دستی انجام دهیم. به یاد داشته باشید، این شنونده عکس فوری را می توان در هر زمانی در نتیجه تغییر سمت سرور فراخوانی کرد، بنابراین مهم است که برنامه ما بتواند تغییرات را مدیریت کند.

پس از نگاشت دیکشنری‌های ما به ساختارها (به 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)

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

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

391c0259bf05ac25.png

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

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

در اینجا نمونه ای از یک پرس و جو ساده برای واکشی همه رستوران های دیم سام آورده شده است:

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، از مستندات دیدن کنید .

7. نوشتن داده در تراکنش

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

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

خوشبختانه، 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 به طور خودکار آن را چند بار دوباره امتحان می کند. این بدان معناست که شرایط خطای ما به احتمال زیاد یک خطای واحد است که به طور مکرر رخ می دهد، به عنوان مثال اگر دستگاه کاملا آفلاین باشد یا کاربر مجاز نباشد در مسیری که می خواهد بنویسد بنویسد.

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

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

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

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

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

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

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

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

این برای بررسی‌های ما بسیار خوب عمل می‌کند، زیرا ما از قوانین امنیتی برای بیان صریح ضمانت ضمنی که قبلاً در برنامه خود نوشتیم استفاده کرده‌ایم – که کاربران فقط می‌توانند نظرات خود را بنویسند. اگر بخواهیم یک تابع ویرایش یا حذف را برای بررسی ها اضافه کنیم، دقیقاً همین مجموعه قوانین همچنین از تغییر یا حذف نظرات کاربران دیگر توسط کاربران جلوگیری می کند. اما قوانین 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;
    }
  }
}

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

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

9. نتیجه گیری

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

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