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

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

Enum อาจเป็นหนึ่งในฟีเจอร์ภาษาที่ถูกประเมินค่าต่ำเกินไปมากที่สุดใน 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 รองรับค่าดิบที่กำหนดเอง เราจึงปรับแต่งได้ว่าค่าใดจะอ้างอิงถึงกรณี Enum ใด ตัวอย่างเช่น หากเราตัดสินใจจัดเก็บเคส Status.inReview เป็น "อยู่ระหว่างตรวจสอบ" เราก็สามารถอัปเดต enum ด้านบนได้ดังนี้

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 เราต้องเพิ่ม enum 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 ซึ่งเราอาจต้องการใช้เป็นตัวระบุในมุมมอง SwiftUI List หากเราไม่ได้ระบุไว้ใน CodingKeys ระบบจะไม่แมปเมื่อดึงข้อมูล และจะกลายเป็น nil ซึ่งจะส่งผลให้มุมมอง List เต็มไปด้วยเอกสารแรก

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

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

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

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

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

ดูเหมือนว่าน่าจะมีโซลูชันที่ดีกว่าและง่ายกว่านี้ ทำไมเราไม่ใช้สีเว็บ (หรือพูดให้เจาะจงกว่านั้นคือสัญกรณ์รหัสสีแบบเลขฐาน 16 ของ 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 ยังรองรับการส่งอัปเดตไปยังแอปของคุณเมื่อเกิดขึ้น โดยใช้ Listener สแนปชอต ซึ่งเราสามารถลงทะเบียน Listener สแนปชอตในคอลเล็กชัน (หรือการค้นหา) และ Cloud Firestore จะเรียกใช้ Listener ของเราเมื่อมีการอัปเดต

ต่อไปนี้คือข้อมูลโค้ดที่แสดงวิธีลงทะเบียน 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