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

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

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

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

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

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

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

ด้วยการรองรับของ Cloud Firestore สำหรับ Codable API ของ Swift สิ่งนี้จะง่ายขึ้นมาก:

  • คุณจะไม่ต้องติดตั้งโค้ดการแมปด้วยตนเองอีกต่อไป
  • การกำหนดวิธีจับคู่แอตทริบิวต์ด้วยชื่อที่แตกต่างกันนั้นเป็นเรื่องง่าย
  • มีการรองรับ 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
โดยใช้โค้ดเอเบิล

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

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

  1. ตรวจสอบให้แน่ใจว่าคุณได้เพิ่มเฟรมเวิร์ก FirebaseFirestore ให้กับโปรเจ็กต์ของคุณแล้ว คุณสามารถใช้ Swift Package Manager หรือ CocoaPods เพื่อดำเนินการดังกล่าวได้
  2. นำเข้า FirebaseFirestore ลงในไฟล์ Swift ของคุณ
  3. ปรับประเภทของคุณให้เป็น Codable
  4. (ไม่บังคับ หากคุณต้องการใช้ประเภทในมุมมอง List ) เพิ่มคุณสมบัติ id ให้กับประเภทของคุณ และใช้ @DocumentID เพื่อบอก Cloud Firestore ให้แมปสิ่งนี้กับ ID เอกสาร เราจะพูดถึงเรื่องนี้โดยละเอียดด้านล่าง
  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 จะดูแลการกำหนด ID เอกสารใหม่ให้กับเอกสารโดยอัตโนมัติ ใช้งานได้แม้ในขณะที่แอปออฟไลน์อยู่

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 ทั้งหมดนี้ใช้ได้กับโค้ดที่เรา ได้เขียนไว้ในส่วนเริ่มต้น

อาร์เรย์

บางครั้งเราต้องการเก็บชุดของค่าไว้ในเอกสาร ประเภทของหนังสือเป็นตัวอย่างที่ดี หนังสืออย่าง The Hitchhiker's Guide to the Galaxy อาจแบ่งออกเป็นหลายประเภท ในกรณีนี้คือ "Sci-Fi" และ "Comedy":

การจัดเก็บอาร์เรย์ในเอกสาร 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]
}

ข้อมูลสั้นๆ เกี่ยวกับการแมป ID เอกสาร

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

เราใช้ wrapper คุณสมบัติ @DocumentID ในตัวอย่างก่อนหน้านี้บางส่วนเพื่อจับคู่ ID เอกสารของเอกสาร 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 และเราสามารถแมประหว่างสองประเภทนั้นด้วยการดำเนินการต่อไปนี้:

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

หากต้องการเรียนรู้เพิ่มเติมเกี่ยวกับการสืบค้นเอกสารตามสถานที่ตั้ง โปรดดู คู่มือวิธีแก้ปัญหานี้

เอนัม

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

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

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

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

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

การปรับแต่งการทำแผนที่

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

ไม่ต้องกังวล: Codable ครอบคลุมถึงเราแล้ว!

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

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

เอกสาร Firestore ที่มีชื่อแอตทริบิวต์ Snake_cased

ในการแมปเอกสารนี้กับโครงสร้างที่มีคุณสมบัติชื่อประเภท String เราจำเป็นต้องเพิ่ม CodingKeys enum ให้กับโครงสร้าง 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 ที่เกี่ยวข้องจะถูกละเว้นในระหว่างกระบวนการแม็ป สิ่งนี้อาจสะดวกจริง ๆ หากเราต้องการแยกคุณสมบัติบางอย่างออกจากการแมปโดยเฉพาะ

ตัวอย่างเช่น หากเราต้องการแยกคุณสมบัติ reasonWhyILoveThis ออกจากการแมป สิ่งที่เราต้องทำคือลบมันออกจาก CodingKeys enum:

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

การใช้ตัวเข้ารหัสและตัวถอดรหัสแบบกำหนดเองสำหรับการแมปสี

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

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

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

เพื่อให้สามารถแมป 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 จะโทรหาผู้ฟังของเราเมื่อใดก็ตามที่มี เป็นการอัพเดต

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

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