แมปข้อมูล Cloud Firestore ด้วย Swift Codable

Codable API ของ Swift ซึ่งเปิดตัวใน Swift 4 ช่วยให้เราใช้ประโยชน์จากความสามารถของ คอมไพเลอร์เพื่อแมปข้อมูลจากรูปแบบที่ซีเรียลไลซ์ไปยังประเภท Swift ได้ง่ายขึ้น

คุณอาจใช้ Codable เพื่อแมปข้อมูลจาก Web API กับโมเดลข้อมูลของแอป (และในทางกลับกัน) แต่ Codable มีความยืดหยุ่นมากกว่านั้นมาก

ในคู่มือนี้ เราจะมาดูวิธีใช้ Codable เพื่อแมปข้อมูลจาก Cloud Firestore ไปยังประเภท Swift และในทางกลับกัน

เมื่อดึงข้อมูลเอกสารจาก Cloud Firestore แอปจะได้รับพจนานุกรมของคู่คีย์/ค่า (หรืออาร์เรย์ของพจนานุกรม หากคุณใช้การดำเนินการใดรายการหนึ่งที่แสดงผลเอกสารหลายรายการ)

แน่นอนว่าคุณยังใช้พจนานุกรมใน Swift ได้โดยตรง และพจนานุกรมก็มีความยืดหยุ่นสูงซึ่งอาจตรงกับกรณีการใช้งานของคุณ อย่างไรก็ตาม วิธีนี้ไม่ปลอดภัยสำหรับประเภท และทำให้เกิดข้อบกพร่องที่ติดตามได้ยากได้ง่าย โดยการสะกดชื่อแอตทริบิวต์ผิด หรือลืมแมป แอตทริบิวต์ใหม่ที่ทีมของคุณเพิ่มเมื่อเปิดตัวฟีเจอร์ใหม่ที่น่าตื่นเต้นนั้น เมื่อสัปดาห์ที่แล้ว

ที่ผ่านมา นักพัฒนาแอปหลายรายได้แก้ไขข้อบกพร่องเหล่านี้โดย การใช้เลเยอร์การแมปอย่างง่ายที่ช่วยให้แมปพจนานุกรมกับประเภท Swift ได้ แต่การติดตั้งใช้งานส่วนใหญ่เหล่านี้จะอิงตามการระบุการแมประหว่างCloud Firestoreเอกสารกับ ประเภทที่สอดคล้องกันของโมเดลข้อมูลของแอปด้วยตนเอง

การรองรับ Codable API ของ Swift ใน Cloud Firestore ทำให้การดำเนินการนี้ง่ายขึ้นมาก

  • คุณจะไม่ต้องติดตั้งใช้งานโค้ดการแมปด้วยตนเองอีกต่อไป
  • คุณกำหนดวิธีแมปแอตทริบิวต์ที่มีชื่อต่างกันได้อย่างง่ายดาย
  • โดยรองรับประเภทต่างๆ ของ Swift ในตัว
  • และเพิ่มการรองรับการแมปประเภทที่กำหนดเองได้ง่ายๆ
  • และที่สำคัญที่สุดคือสำหรับโมเดลข้อมูลที่เรียบง่าย คุณไม่จำเป็นต้องเขียนโค้ดการแมปเลย

การแมปข้อมูล

Cloud Firestore จัดเก็บข้อมูลในเอกสารที่แมปคีย์กับค่า หากต้องการดึงข้อมูลจากเอกสารแต่ละรายการ เราสามารถเรียกใช้ DocumentSnapshot.data() ซึ่งจะแสดงพจนานุกรมที่แมปชื่อฟิลด์กับ Any ดังนี้ func data() -> [String : Any]?

ซึ่งหมายความว่าเราสามารถใช้ไวยากรณ์การอ้างอิงของ Swift เพื่อเข้าถึงแต่ละฟิลด์ได้

import FirebaseFirestore

#warning("DO NOT MAP YOUR DOCUMENTS MANUALLY. USE CODABLE INSTEAD.")
func fetchBook(documentId: String) {
  let docRef = db.collection("books").document(documentId)

  docRef.getDocument { document, error in
    if let error = error as NSError? {
      self.errorMessage = "Error getting document: \(error.localizedDescription)"
    }
    else {
      if let document = document {
        let id = document.documentID
        let data = document.data()
        let title = data?["title"] as? String ?? ""
        let numberOfPages = data?["numberOfPages"] as? Int ?? 0
        let author = data?["author"] as? String ?? ""
        self.book = Book(id:id, title: title, numberOfPages: numberOfPages, author: author)
      }
    }
  }
}

แม้ว่าโค้ดนี้อาจดูตรงไปตรงมาและใช้งานง่าย แต่ก็มีความเปราะบาง ดูแลรักษายาก และมีแนวโน้มที่จะเกิดข้อผิดพลาด

ดังที่คุณเห็น เรากำลังทำการสมมติฐานเกี่ยวกับประเภทข้อมูลของฟิลด์เอกสาร ซึ่งอาจถูกต้องหรือไม่ถูกต้องก็ได้

โปรดทราบว่าเนื่องจากไม่มีสคีมา คุณจึงเพิ่มเอกสารใหม่ลงในคอลเล็กชันและเลือกประเภทอื่นสำหรับฟิลด์ได้อย่างง่ายดาย คุณอาจเลือกสตริงสำหรับฟิลด์ numberOfPages โดยไม่ตั้งใจ ซึ่งจะทำให้เกิดปัญหาการแมปที่ค้นหายาก นอกจากนี้ คุณยังต้องอัปเดตโค้ดการแมป ทุกครั้งที่มีการเพิ่มฟิลด์ใหม่ ซึ่งค่อนข้างยุ่งยาก

และอย่าลืมว่าเราไม่ได้ใช้ประโยชน์จากระบบการพิมพ์ที่แข็งแกร่งของ Swift ซึ่งทราบประเภทที่ถูกต้องสำหรับแต่ละพร็อพเพอร์ตี้ของ Book

Codable คืออะไร

ตามเอกสารประกอบของ Apple ระบุว่า Codable คือ "ประเภทที่สามารถแปลงตัวเอง เป็นและออกจากรูปแบบภายนอกได้" ที่จริงแล้ว Codable เป็นนามแฝงของประเภท สำหรับโปรโตคอล Encodable และ Decodable เมื่อทำให้ประเภท Swift เป็นไปตามโปรโตคอลนี้ คอมไพเลอร์จะสังเคราะห์โค้ดที่จำเป็นในการเข้ารหัส/ถอดรหัสอินสแตนซ์ ของประเภทนี้จากรูปแบบที่จัดลำดับ เช่น JSON

ประเภทที่เรียบง่ายสำหรับการจัดเก็บข้อมูลเกี่ยวกับหนังสืออาจมีลักษณะดังนี้

struct Book: Codable {
  var title: String
  var numberOfPages: Int
  var author: String
}

ดังที่เห็น การทำให้ประเภทเป็นไปตาม Codable นั้นแทบไม่มีผลกระทบ เราเพียง ต้องเพิ่มการปฏิบัติตามโปรโตคอลเท่านั้น ไม่จำเป็นต้องเปลี่ยนแปลงอื่นๆ

เมื่อกำหนดค่านี้แล้ว เราจะเข้ารหัสหนังสือเป็นออบเจ็กต์ JSON ได้อย่างง่ายดาย

do {
  let book = Book(title: "The Hitchhiker's Guide to the Galaxy",
                  numberOfPages: 816,
                  author: "Douglas Adams")
  let encoder = JSONEncoder()
  let data = try encoder.encode(book)
} 
catch {
  print("Error when trying to encode book: \(error)")
}

การถอดรหัสออบเจ็กต์ JSON เป็นอินสแตนซ์ Book จะทำงานดังนี้

let decoder = JSONDecoder()
let data = /* fetch data from the network */
let decodedBook = try decoder.decode(Book.self, from: data)

การแมปไปยังและจากประเภทข้อมูลอย่างง่ายในเอกสาร Cloud Firestore
โดยใช้ Codable

Cloud Firestore รองรับชุดข้อมูลหลากหลายประเภท ตั้งแต่สตริงธรรมดาไปจนถึงแมปที่ซ้อนกัน โดยส่วนใหญ่จะสอดคล้องกับประเภทในตัวของ Swift โดยตรง มาดูการแมปข้อมูลประเภทง่ายๆ ก่อนที่จะเจาะลึกถึงข้อมูลที่ซับซ้อนกว่านี้กัน

หากต้องการแมปCloud Firestoreเอกสารกับประเภท Swift ให้ทำตามขั้นตอนต่อไปนี้

  1. ตรวจสอบว่าคุณได้เพิ่มเฟรมเวิร์ก FirebaseFirestore ลงในโปรเจ็กต์แล้ว คุณสามารถใช้ Swift Package Manager หรือ CocoaPods เพื่อดำเนินการดังกล่าวได้
  2. นำเข้า FirebaseFirestore ลงในไฟล์ Swift
  3. จัดรูปแบบข้อความให้เป็น Codable
  4. (ไม่บังคับ หากต้องการใช้ประเภทในListมุมมอง) เพิ่มid พร็อพเพอร์ตี้ลงในประเภทของคุณ และใช้ @DocumentID เพื่อบอก Cloud Firestore ให้ แมปพร็อพเพอร์ตี้นี้กับรหัสเอกสาร เราจะพูดถึงเรื่องนี้อย่างละเอียดด้านล่าง
  5. ใช้ documentReference.data(as: ) เพื่อแมปการอ้างอิงเอกสารกับประเภท Swift
  6. ใช้ documentReference.setData(from: ) เพื่อแมปข้อมูลจากประเภท Swift ไปยังเอกสาร Cloud Firestore
  7. (ไม่บังคับ แต่ขอแนะนำเป็นอย่างยิ่ง) ใช้การจัดการข้อผิดพลาดที่เหมาะสม

มาอัปเดตBookประเภทกัน

struct Book: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
}

เนื่องจากประเภทนี้สามารถเขียนโค้ดได้อยู่แล้ว เราจึงต้องเพิ่มพร็อพเพอร์ตี้ id และ ใส่คำอธิบายประกอบด้วย Wrapper พร็อพเพอร์ตี้ @DocumentID

ใช้ข้อมูลโค้ดก่อนหน้าสำหรับการดึงข้อมูลและการแมปเอกสาร เราสามารถ แทนที่โค้ดการแมปด้วยตนเองทั้งหมดด้วยโค้ดบรรทัดเดียวได้ดังนี้

func fetchBook(documentId: String) {
  let docRef = db.collection("books").document(documentId)

  docRef.getDocument { document, error in
    if let error = error as NSError? {
      self.errorMessage = "Error getting document: \(error.localizedDescription)"
    }
    else {
      if let document = document {
        do {
          self.book = try document.data(as: Book.self)
        }
        catch {
          print(error)
        }
      }
    }
  }
}

คุณสามารถเขียนให้กระชับยิ่งขึ้นได้โดยการระบุประเภทของเอกสาร เมื่อเรียกใช้ getDocument(as:) ซึ่งจะทำการแมปให้คุณ และ ส่งคืนประเภท Result ที่มีเอกสารที่แมป หรือข้อผิดพลาดในกรณีที่ การถอดรหัสล้มเหลว

private func fetchBook(documentId: String) {
  let docRef = db.collection("books").document(documentId)
  
  docRef.getDocument(as: Book.self) { result in
    switch result {
    case .success(let book):
      // A Book value was successfully initialized from the DocumentSnapshot.
      self.book = book
      self.errorMessage = nil
    case .failure(let error):
      // A Book value could not be initialized from the DocumentSnapshot.
      self.errorMessage = "Error decoding document: \(error.localizedDescription)"
    }
  }
}

การอัปเดตเอกสารที่มีอยู่ทำได้ง่ายๆ เพียงเรียกใช้ documentReference.setData(from: ) โค้ดสำหรับบันทึกอินสแตนซ์ Book มีดังนี้ ซึ่งรวมถึงการจัดการข้อผิดพลาดขั้นพื้นฐานบางอย่าง

func updateBook(book: Book) {
  if let id = book.id {
    let docRef = db.collection("books").document(id)
    do {
      try docRef.setData(from: book)
    }
    catch {
      print(error)
    }
  }
}

เมื่อเพิ่มเอกสารใหม่ Cloud Firestore จะดูแลการกำหนดรหัสเอกสารใหม่ให้กับเอกสารโดยอัตโนมัติ ฟีเจอร์นี้จะทำงานได้แม้ว่าแอปจะ ออฟไลน์อยู่ก็ตาม

func addBook(book: Book) {
  let collectionRef = db.collection("books")
  do {
    let newDocReference = try collectionRef.addDocument(from: self.book)
    print("Book stored with new document reference: \(newDocReference)")
  }
  catch {
    print(error)
  }
}

นอกเหนือจากการแมปประเภทข้อมูลอย่างง่ายแล้ว Cloud Firestore ยังรองรับประเภทข้อมูลอื่นๆ อีกหลายประเภท ซึ่งบางประเภทเป็นประเภทที่มีโครงสร้างที่คุณใช้เพื่อสร้างออบเจ็กต์ที่ซ้อนกันภายในเอกสารได้

ประเภทที่กำหนดเองที่ซ้อนกัน

แอตทริบิวต์ส่วนใหญ่ที่เราต้องการแมปในเอกสารเป็นค่าที่เรียบง่าย เช่น ชื่อหนังสือหรือชื่อผู้เขียน แต่ในกรณีที่เราต้องการจัดเก็บออบเจ็กต์ที่ซับซ้อนมากขึ้นล่ะ เช่น เราอาจต้องการจัดเก็บ URL ของปกหนังสือในความละเอียดต่างๆ

วิธีที่ง่ายที่สุดในการดำเนินการนี้ใน Cloud Firestore คือการใช้แผนที่

การจัดเก็บประเภทที่กำหนดเองแบบซ้อนกันในเอกสาร Firestore

เมื่อเขียนโครงสร้าง Swift ที่เกี่ยวข้อง เราสามารถใช้ประโยชน์จากข้อเท็จจริงที่ว่า Cloud Firestore รองรับ URL ได้ ซึ่งเมื่อจัดเก็บฟิลด์ที่มี URL ระบบจะแปลงเป็นสตริงและในทางกลับกัน

struct CoverImages: Codable {
  var small: URL
  var medium: URL
  var large: URL
}

struct BookWithCoverImages: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var cover: CoverImages?
}

โปรดสังเกตว่าเรากำหนดโครงสร้าง CoverImages สำหรับแผนที่ปกในเอกสาร Cloud Firestore อย่างไร การทำเครื่องหมายพร็อพเพอร์ตี้ปกใน BookWithCoverImages เป็น "ไม่บังคับ" ช่วยให้เราจัดการกรณีที่เอกสารบางฉบับอาจไม่มีแอตทริบิวต์ปกได้

หากสงสัยว่าเหตุใดจึงไม่มีตัวอย่างโค้ดสำหรับการดึงหรืออัปเดตข้อมูล คุณจะยินดีที่ทราบว่าไม่จำเป็นต้องปรับโค้ดสำหรับการอ่าน หรือเขียนจาก/ไปยัง Cloud Firestore เนื่องจากทั้งหมดนี้ใช้ได้กับโค้ดที่เราเขียนไว้ในส่วนเริ่มต้น

อาร์เรย์

บางครั้งเราก็ต้องการจัดเก็บชุดค่าในเอกสาร ตัวอย่างที่ดีคือประเภทของหนังสือ เช่น หนังสืออย่างคู่มือพเนจรฉบับกู้โลก อาจจัดอยู่ในหลายหมวดหมู่ ในกรณีนี้คือ "นิยายวิทยาศาสตร์" และ "ตลก"

การจัดเก็บอาร์เรย์ในเอกสาร Firestore

ใน Cloud Firestore เราสามารถสร้างรูปแบบนี้ได้โดยใช้อาร์เรย์ของค่า ซึ่งใช้ได้กับประเภทที่เข้ารหัสได้ (เช่น String, Int ฯลฯ) ตัวอย่างต่อไปนี้ แสดงวิธีเพิ่มอาร์เรย์ของประเภทลงในโมเดล Book

public struct BookWithGenre: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var genres: [String]
}

เนื่องจากวิธีนี้ใช้ได้กับประเภทที่เข้ารหัสได้ทุกประเภท เราจึงใช้ประเภทที่กำหนดเองได้ด้วย สมมติว่าเราต้องการจัดเก็บรายการแท็กสำหรับหนังสือแต่ละเล่ม นอกเหนือจากชื่อของแท็กแล้ว เรายังต้องการจัดเก็บสีของแท็กด้วย เช่น

จัดเก็บอาร์เรย์ของประเภทที่กำหนดเองในเอกสาร Firestore

หากต้องการจัดเก็บแท็กด้วยวิธีนี้ สิ่งที่เราต้องทำคือการใช้โครงสร้าง Tag เพื่อ แสดงแท็กและทำให้แท็กสามารถเข้ารหัสได้

struct Tag: Codable, Hashable {
  var title: String
  var color: String
}

เพียงเท่านี้ เราก็จัดเก็บอาร์เรย์ของ Tags ในเอกสาร Book ได้แล้ว

struct BookWithTags: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var tags: [Tag]
}

สั้นๆ เกี่ยวกับการแมปรหัสเอกสาร

ก่อนที่จะไปดูการแมปประเภทอื่นๆ เรามาพูดถึงการแมปรหัสเอกสาร กันสักครู่

เราใช้ Wrapper พร็อพเพอร์ตี้ @DocumentID ในตัวอย่างก่อนหน้าบางส่วน เพื่อเชื่อมโยงรหัสเอกสารของเอกสาร Cloud Firestore กับพร็อพเพอร์ตี้ id ของประเภท Swift ซึ่งเป็นสิ่งสำคัญเนื่องจากเหตุผลต่อไปนี้

  • ซึ่งช่วยให้เราทราบว่าควรจะอัปเดตเอกสารใดในกรณีที่ผู้ใช้ทำการเปลี่ยนแปลงในเครื่อง
  • List ของ SwiftUI กำหนดให้องค์ประกอบต้องเป็น Identifiable เพื่อ ป้องกันไม่ให้องค์ประกอบกระโดดไปมาเมื่อมีการแทรก

โปรดทราบว่าแอตทริบิวต์ที่ทำเครื่องหมายเป็น @DocumentID จะไม่ได้รับการเข้ารหัสโดยตัวเข้ารหัสของ Cloud Firestore เมื่อเขียนเอกสารกลับ เนื่องจากรหัสเอกสารไม่ใช่แอตทริบิวต์ของเอกสารเอง การเขียนรหัสลงในเอกสารจึงเป็นข้อผิดพลาด

เมื่อทำงานกับประเภทที่ซ้อนกัน (เช่น อาร์เรย์ของแท็กใน Book ใน ตัวอย่างก่อนหน้าในคู่มือนี้) คุณไม่จำเป็นต้องเพิ่มพร็อพเพอร์ตี้ @DocumentID เนื่องจากพร็อพเพอร์ตี้ที่ซ้อนกันเป็นส่วนหนึ่งของเอกสาร Cloud Firestore และ ไม่ได้เป็นเอกสารแยกต่างหาก ดังนั้นจึงไม่จำเป็นต้องมีรหัสเอกสาร

วันที่และเวลา

Cloud Firestore มีประเภทข้อมูลในตัวสำหรับการจัดการวันที่และเวลา และ ด้วยการรองรับ Codable ของ Cloud Firestore ทำให้การใช้ข้อมูลดังกล่าวเป็นเรื่องง่าย

มาดูเอกสารนี้กัน ซึ่งเป็นเอกสารที่แสดงถึงต้นกำเนิดของภาษาโปรแกรมทั้งหมด นั่นก็คือ Ada ซึ่งประดิษฐ์ขึ้นในปี 1843

การจัดเก็บวันที่ในเอกสาร Firestore

ประเภท Swift สำหรับการแมปเอกสารนี้อาจมีลักษณะดังนี้

struct ProgrammingLanguage: Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
}

เราไม่สามารถปล่อยให้ส่วนนี้เกี่ยวกับวันที่และเวลาผ่านไปโดยไม่พูดถึง@ServerTimestamp Wrapper พร็อพเพอร์ตี้นี้เป็นเครื่องมือที่มีประสิทธิภาพเมื่อต้อง จัดการกับแสตมป์เวลาในแอป

ในระบบแบบกระจายใดๆ มีโอกาสที่นาฬิกาในระบบแต่ละระบบ จะไม่ซิงค์กันตลอดเวลา คุณอาจคิดว่าเรื่องนี้ไม่ใช่เรื่องใหญ่ แต่ลองนึกถึงผลกระทบที่อาจเกิดขึ้นหากนาฬิกาทำงานไม่ตรงกันเล็กน้อยสำหรับ ระบบการซื้อขายหุ้น แม้แต่ความคลาดเคลื่อนเพียงมิลลิวินาทีก็อาจส่งผลให้เกิดความแตกต่าง เป็นเงินหลายล้านดอลลาร์เมื่อดำเนินการซื้อขาย

Cloud Firestore จะจัดการแอตทริบิวต์ที่ทำเครื่องหมายด้วย @ServerTimestamp ดังนี้ หากแอตทริบิวต์เป็น nil เมื่อคุณจัดเก็บ (เช่น ใช้ addDocument()) Cloud Firestore จะป้อนข้อมูลลงในช่องด้วยการประทับเวลาของเซิร์ฟเวอร์ปัจจุบัน ณ เวลาที่เขียนลงในฐานข้อมูล หากฟิลด์ไม่ใช่ nil เมื่อคุณเรียกใช้ addDocument() หรือ updateData() Cloud Firestore จะไม่เปลี่ยนแปลงค่าแอตทริบิวต์ วิธีนี้จะช่วยให้ใช้งานฟิลด์ต่างๆ เช่น createdAt และ lastUpdatedAt ได้ง่าย

จุดทางภูมิศาสตร์

ตำแหน่งทางภูมิศาสตร์มีอยู่ทุกที่ในแอปของเรา การจัดเก็บข้อมูลเหล่านี้จะช่วยให้เรา สามารถมอบฟีเจอร์ที่น่าสนใจมากมายให้คุณได้ เช่น การจัดเก็บตำแหน่งสำหรับงานอาจมีประโยชน์ เพื่อให้แอปช่วยเตือนคุณเกี่ยวกับงานได้เมื่อคุณไปถึงจุดหมาย

Cloud Firestore มีประเภทข้อมูลในตัวคือ GeoPoint ซึ่งจัดเก็บลองจิจูดและละติจูดของสถานที่ใดก็ได้ หากต้องการแมปสถานที่ตั้งจาก/ไปยังเอกสาร Cloud Firestore เราสามารถใช้ประเภท GeoPoint ได้

struct Office: Codable {
  @DocumentID var id: String?
  var name: String
  var location: GeoPoint
}

ประเภทที่เกี่ยวข้องใน Swift คือ CLLocationCoordinate2D และเราสามารถแมประหว่าง 2 ประเภทนี้ได้โดยใช้การดำเนินการต่อไปนี้

CLLocationCoordinate2D(latitude: office.location.latitude,
                      longitude: office.location.longitude)

ดูข้อมูลเพิ่มเติมเกี่ยวกับการค้นหาเอกสารตามสถานที่ตั้งจริงได้ที่คู่มือโซลูชันนี้

Enum

Enums อาจเป็นหนึ่งในฟีเจอร์ภาษาที่ถูกประเมินค่าต่ำที่สุดใน Swift เพราะมีอะไรมากกว่าที่เห็น กรณีการใช้งานทั่วไปสำหรับ Enum คือการ สร้างโมเดลสถานะที่ไม่ต่อเนื่องของสิ่งหนึ่งๆ เช่น เราอาจเขียนแอป สำหรับจัดการบทความ หากต้องการติดตามสถานะของบทความ เราอาจต้องใช้ enum Status

enum Status: String, Codable {
  case draft
  case inReview
  case approved
  case published
}

Cloud Firestore ไม่รองรับการแจงนับโดยค่าเริ่มต้น (กล่าวคือ บังคับใช้ชุดค่าไม่ได้) แต่เรายังคงใช้ประโยชน์จากข้อเท็จจริงที่ว่าการแจงนับสามารถพิมพ์ได้ และเลือกประเภทที่เข้ารหัสได้ ในตัวอย่างนี้ เราเลือก String ซึ่งหมายความว่า ค่า enum ทั้งหมดจะได้รับการแมปเป็นสตริงเมื่อจัดเก็บในเอกสาร Cloud Firestore

และเนื่องจาก Swift รองรับค่าดิบที่กำหนดเอง เราจึงปรับแต่งค่าที่อ้างอิงถึงเคสของ Enum ได้ด้วย เช่น หากเราตัดสินใจจัดเก็บเคส Status.inReview เป็น "อยู่ระหว่างตรวจสอบ" เราก็เพียงอัปเดตการแจงนับข้างต้นดังนี้

enum Status: String, Codable {
  case draft
  case inReview = "in review"
  case approved
  case published
}

การปรับแต่งการแมป

บางครั้งชื่อแอตทริบิวต์ของเอกสาร Cloud Firestore ที่เราต้องการ แมปไม่ตรงกับชื่อของพร็อพเพอร์ตี้ในโมเดลข้อมูลของเราใน Swift ตัวอย่างเช่น เพื่อนร่วมงานคนหนึ่งของเราอาจเป็นนักพัฒนา Python และตัดสินใจเลือกใช้รูปแบบ snake_case สำหรับชื่อแอตทริบิวต์ทั้งหมด

ไม่ต้องกังวล Codable ช่วยเราได้

สำหรับกรณีเช่นนี้ เราสามารถใช้ CodingKeys ได้ นี่คือ Enum ที่เราเพิ่มลงในโครงสร้างที่เข้ารหัสได้เพื่อระบุวิธีแมปแอตทริบิวต์บางอย่าง

ลองพิจารณาเอกสารนี้

เอกสาร Firestore ที่มีชื่อแอตทริบิวต์เป็นรูปแบบ snake_case

หากต้องการแมปเอกสารนี้กับโครงสร้างที่มีพร็อพเพอร์ตี้ชื่อประเภท String เรา ต้องเพิ่มการแจงนับ CodingKeys ลงในโครงสร้าง ProgrammingLanguage และระบุ ชื่อของแอตทริบิวต์ในเอกสาร

struct ProgrammingLanguage: Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
  
  enum CodingKeys: String, CodingKey {
    case id
    case name = "language_name"
    case year
  }
}

โดยค่าเริ่มต้น Codable API จะใช้ชื่อพร็อพเพอร์ตี้ของประเภท Swift เพื่อ กำหนดชื่อแอตทริบิวต์ในเอกสาร Cloud Firestore ที่เราพยายาม แมป ดังนั้นตราบใดที่ชื่อแอตทริบิวต์ตรงกัน ก็ไม่จำเป็นต้องเพิ่ม CodingKeys ลงในประเภทที่เข้ารหัสได้ อย่างไรก็ตาม เมื่อใช้ CodingKeys สำหรับ ประเภทที่เฉพาะเจาะจง เราต้องเพิ่มชื่อพร็อพเพอร์ตี้ทั้งหมดที่ต้องการแมป

ในข้อมูลโค้ดด้านบน เราได้กำหนดพร็อพเพอร์ตี้ id ซึ่งเราอาจต้องการใช้เป็นตัวระบุในมุมมอง List ของ SwiftUI หากเราไม่ได้ระบุไว้ใน CodingKeys ระบบจะไม่แมปเมื่อดึงข้อมูล และจะกลายเป็น nil ซึ่งจะส่งผลให้มุมมอง List เต็มไปด้วยเอกสารแรก

ระบบจะไม่สนใจพร็อพเพอร์ตี้ที่ไม่ได้แสดงเป็นเคสใน CodingKeys enum ที่เกี่ยวข้องในระหว่างกระบวนการแมป ซึ่งอาจสะดวกในกรณีที่เราต้องการยกเว้นที่พักบางแห่งไม่ให้แมป

เช่น หากต้องการยกเว้นพร็อพเพอร์ตี้ reasonWhyILoveThis ไม่ให้มีการแมป สิ่งที่ต้องทำคือนำพร็อพเพอร์ตี้ดังกล่าวออกจาก Enum CodingKeys

struct ProgrammingLanguage: Identifiable, Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
  var reasonWhyILoveThis: String = ""
  
  enum CodingKeys: String, CodingKey {
    case id
    case name = "language_name"
    case year
  }
}

บางครั้งเราอาจต้องการเขียนแอตทริบิวต์ที่ว่างเปล่ากลับลงในเอกสาร Cloud Firestore Swift มีแนวคิดของ Optional เพื่อระบุว่าไม่มีค่า และ Cloud Firestore รองรับค่า null ด้วย อย่างไรก็ตาม ลักษณะการทำงานเริ่มต้นสำหรับตัวเลือกการเข้ารหัสที่มีnilค่าคือ ละเว้นตัวเลือกเหล่านั้น @ExplicitNull ช่วยให้เราควบคุมวิธีจัดการตัวเลือก Swift เมื่อเข้ารหัสได้ โดยการติดค่าสถานะพร็อพเพอร์ตี้ที่ไม่บังคับเป็น @ExplicitNull เราจะบอก Cloud Firestore ให้เขียนพร็อพเพอร์ตี้นี้ลงใน เอกสารที่มีค่าเป็น Null หากมีค่าเป็น nil

การใช้โปรแกรมเปลี่ยนไฟล์และโปรแกรมถอดรหัสที่กำหนดเองสำหรับการแมปสี

ในหัวข้อสุดท้ายของการครอบคลุมเรื่องการแมปข้อมูลด้วย Codable เรามาทำความรู้จัก ตัวเข้ารหัสและตัวถอดรหัสที่กำหนดเองกัน ส่วนนี้ไม่ได้ครอบคลุมCloud Firestoreประเภทข้อมูลดั้งเดิม แต่ตัวเข้ารหัสและตัวถอดรหัสที่กำหนดเองมีประโยชน์อย่างมากในCloud Firestoreแอปของคุณ

"ฉันจะแมปสีได้อย่างไร" เป็นหนึ่งในคำถามที่นักพัฒนาแอปถามบ่อยที่สุด ไม่เพียงแต่สำหรับ Cloud Firestore แต่ยังรวมถึงการแมประหว่าง Swift กับ JSON ด้วย มีโซลูชันมากมาย แต่ส่วนใหญ่มุ่งเน้นไปที่ JSON และเกือบทั้งหมดจะแมปสีเป็นพจนานุกรมที่ซ้อนกันซึ่งประกอบด้วยคอมโพเนนต์ RGB

ดูเหมือนว่าควรจะมีโซลูชันที่ดีกว่าและง่ายกว่านี้ ทำไมเราจึงไม่ใช้สีเว็บ (หรือพูดให้เจาะจงมากขึ้นคือสัญกรณ์สีเลขฐานสิบหกของ CSS) ซึ่งใช้งานง่าย (โดยพื้นฐานแล้วเป็นเพียงสตริง) และยังรองรับความโปร่งใสด้วย

หากต้องการแมป Swift Color กับค่าฐานสิบหก เราต้องสร้างส่วนขยาย Swift ที่เพิ่ม Codable ลงใน Color

extension Color {

 init(hex: String) {
    let rgba = hex.toRGBA()

    self.init(.sRGB,
              red: Double(rgba.r),
              green: Double(rgba.g),
              blue: Double(rgba.b),
              opacity: Double(rgba.alpha))
    }

    //... (code for translating between hex and RGBA omitted for brevity)

}

extension Color: Codable {
  
  public init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    let hex = try container.decode(String.self)

    self.init(hex: hex)
  }
  
  public func encode(to encoder: Encoder) throws {
    var container = encoder.singleValueContainer()
    try container.encode(toHex)
  }

}

การใช้ decoder.singleValueContainer() จะช่วยให้เราถอดรหัส String เป็นค่าที่เทียบเท่ากับ Color ได้โดยไม่ต้องซ้อนคอมโพเนนต์ RGBA นอกจากนี้ คุณยัง ใช้ค่าเหล่านี้ใน UI บนเว็บของแอปได้โดยไม่ต้องแปลงค่าก่อน

ซึ่งจะช่วยให้เราอัปเดตโค้ดสำหรับการแมปแท็กได้ ทำให้จัดการ สีของแท็กได้ง่ายขึ้นโดยตรงแทนที่จะต้องแมปด้วยตนเองในโค้ด UI ของแอป

struct Tag: Codable, Hashable {
  var title: String
  var color: Color
}

struct BookWithTags: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var tags: [Tag]
}

การจัดการข้อผิดพลาด

ในตัวอย่างโค้ดข้างต้น เราจงใจให้การจัดการข้อผิดพลาดมีน้อยที่สุด แต่ในแอปเวอร์ชันที่ใช้งานจริง คุณจะต้องตรวจสอบว่าได้จัดการข้อผิดพลาด อย่างเหมาะสม

ต่อไปนี้คือข้อมูลโค้ดที่แสดงวิธีจัดการกับสถานการณ์ข้อผิดพลาดที่คุณอาจพบ

class MappingSimpleTypesViewModel: ObservableObject {
  @Published var book: Book = .empty
  @Published var errorMessage: String?
  
  private var db = Firestore.firestore()
  
  func fetchAndMap() {
    fetchBook(documentId: "hitchhiker")
  }
  
  func fetchAndMapNonExisting() {
    fetchBook(documentId: "does-not-exist")
  }
  
  func fetchAndTryMappingInvalidData() {
    fetchBook(documentId: "invalid-data")
  }
  
  private func fetchBook(documentId: String) {
    let docRef = db.collection("books").document(documentId)
    
    docRef.getDocument(as: Book.self) { result in
      switch result {
      case .success(let book):
        // A Book value was successfully initialized from the DocumentSnapshot.
        self.book = book
        self.errorMessage = nil
      case .failure(let error):
        // A Book value could not be initialized from the DocumentSnapshot.
        switch error {
        case DecodingError.typeMismatch(_, let context):
          self.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
        case DecodingError.valueNotFound(_, let context):
          self.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
        case DecodingError.keyNotFound(_, let context):
          self.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
        case DecodingError.dataCorrupted(let key):
          self.errorMessage = "\(error.localizedDescription): \(key)"
        default:
          self.errorMessage = "Error decoding document: \(error.localizedDescription)"
        }
      }
    }
  }
}

การจัดการข้อผิดพลาดในข้อมูลอัปเดตแบบเรียลไทม์

ข้อมูลโค้ดก่อนหน้านี้แสดงวิธีจัดการข้อผิดพลาดเมื่อดึงข้อมูลเอกสารเดียว นอกเหนือจากการดึงข้อมูลเพียงครั้งเดียวแล้ว Cloud Firestore ยัง รองรับการส่งการอัปเดตไปยังแอปของคุณเมื่อมีการอัปเดต โดยใช้สิ่งที่เรียกว่าเครื่องมือฟัง สแนปชอต ซึ่งเราสามารถลงทะเบียนเครื่องมือฟังสแนปชอตในคอลเล็กชัน (หรือการค้นหา) และ Cloud Firestore จะเรียกเครื่องมือฟังของเราเมื่อใดก็ตามที่มีการอัปเดต

ต่อไปนี้คือข้อมูลโค้ดที่แสดงวิธีลงทะเบียนเครื่องมือฟังสแนปชอต แมปข้อมูล โดยใช้ Codable และจัดการข้อผิดพลาดที่อาจเกิดขึ้น นอกจากนี้ยังแสดงวิธีเพิ่ม เอกสารใหม่ลงในคอลเล็กชันด้วย ดังที่คุณจะเห็นว่าไม่จำเป็นต้องอัปเดตอาร์เรย์ในเครื่องที่เก็บเอกสารที่แมปไว้ด้วยตนเอง เนื่องจากโค้ดในเครื่องมือฟังสแนปชอตจะจัดการให้

class MappingColorsViewModel: ObservableObject {
  @Published var colorEntries = [ColorEntry]()
  @Published var newColor = ColorEntry.empty
  @Published var errorMessage: String?
  
  private var db = Firestore.firestore()
  private var listenerRegistration: ListenerRegistration?
  
  public func unsubscribe() {
    if listenerRegistration != nil {
      listenerRegistration?.remove()
      listenerRegistration = nil
    }
  }
  
  func subscribe() {
    if listenerRegistration == nil {
      listenerRegistration = db.collection("colors")
        .addSnapshotListener { [weak self] (querySnapshot, error) in
          guard let documents = querySnapshot?.documents else {
            self?.errorMessage = "No documents in 'colors' collection"
            return
          }
          
          self?.colorEntries = documents.compactMap { queryDocumentSnapshot in
            let result = Result { try queryDocumentSnapshot.data(as: ColorEntry.self) }
            
            switch result {
            case .success(let colorEntry):
              if let colorEntry = colorEntry {
                // A ColorEntry value was successfully initialized from the DocumentSnapshot.
                self?.errorMessage = nil
                return colorEntry
              }
              else {
                // A nil value was successfully initialized from the DocumentSnapshot,
                // or the DocumentSnapshot was nil.
                self?.errorMessage = "Document doesn't exist."
                return nil
              }
            case .failure(let error):
              // A ColorEntry value could not be initialized from the DocumentSnapshot.
              switch error {
              case DecodingError.typeMismatch(_, let context):
                self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
              case DecodingError.valueNotFound(_, let context):
                self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
              case DecodingError.keyNotFound(_, let context):
                self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
              case DecodingError.dataCorrupted(let key):
                self?.errorMessage = "\(error.localizedDescription): \(key)"
              default:
                self?.errorMessage = "Error decoding document: \(error.localizedDescription)"
              }
              return nil
            }
          }
        }
    }
  }
  
  func addColorEntry() {
    let collectionRef = db.collection("colors")
    do {
      let newDocReference = try collectionRef.addDocument(from: newColor)
      print("ColorEntry stored with new document reference: \(newDocReference)")
    }
    catch {
      print(error)
    }
  }
}

โค้ดที่ใช้ในโพสต์นี้เป็นส่วนหนึ่งของแอปพลิเคชันตัวอย่างที่คุณดาวน์โหลดได้จากที่เก็บ GitHub นี้

ไปใช้ Codable กันเลย

Codable API ของ Swift เป็นวิธีที่มีประสิทธิภาพและยืดหยุ่นในการแมปข้อมูลจากรูปแบบที่ซีเรียลไลซ์ไปยังและจากโมเดลข้อมูลของแอปพลิเคชัน ในคู่มือนี้ คุณได้เห็นแล้วว่าการใช้งานในแอปที่ใช้ Cloud Firestore เป็น ที่เก็บข้อมูลนั้นง่ายเพียงใด

เริ่มจากตัวอย่างพื้นฐานที่มีประเภทข้อมูลอย่างง่าย เราค่อยๆ เพิ่มความซับซ้อนของโมเดลข้อมูลไปเรื่อยๆ โดยที่ยังคงใช้ Codable และการใช้งานของ Firebase เพื่อทำการแมปให้เราได้

ดูรายละเอียดเพิ่มเติมเกี่ยวกับ Codable ได้จากแหล่งข้อมูลต่อไปนี้

แม้ว่าเราจะพยายามอย่างเต็มที่ในการรวบรวมคำแนะนำที่ครอบคลุมสำหรับการแมปเอกสารCloud Firestore แต่คำแนะนำนี้ก็ยังไม่สมบูรณ์ และคุณอาจใช้กลยุทธ์อื่นๆ ในการแมปประเภท โปรดแจ้งให้เราทราบว่าคุณใช้กลยุทธ์ใดในการแมปข้อมูลประเภทอื่นๆ ของ Cloud Firestore หรือการแสดงข้อมูลใน Swift โดยใช้ปุ่มส่งความคิดเห็นด้านล่าง

ไม่มีเหตุผลใดเลยที่จะไม่ใช้การรองรับ Codable ของ Cloud Firestore