В этом руководстве мы создадим легкий сетевой уровень, который сделает чтение и запись данных восхитительно простыми в вашем приложении.

Насколько хорошо было бы иметь однострочные вызовы API?

Мы все знаем о возможностях пакета Google Firebase, особенно Cloud Firestore.

Это база данных NoSQL, состоящая из коллекций, содержащих группы документов. Его относительно просто интегрировать в ваше приложение, и, прежде чем вы это узнаете, вы читаете и записываете данные.

Тем не менее, поскольку это так просто, вы часто можете отклониться от опасного пути написания несложного шаблонного кода для вашего сетевого уровня. Мы все были там… слишком много ленивой копипасты, недостаточно любви и заботы. Мы можем исправить это раз и навсегда с помощью Generics. Давайте проверим это!

Отказ от ответственности

Для целей настоящей статьи используются следующие допущения:

  1. Вы выбрали Cloud Firestore в качестве своей базы данных (хотя аналогичная тактика может применяться к базе данных в реальном времени).
  2. Вы знакомы или используете async/await в iOS 15 или новее.
  3. Вы создали свой проект Firebase в консоли
  4. Вы сгенерировали и импортировали GoogleService-Info.plist в Xcode
  5. Вы добавили Firebase и FirebaseFirestoreSwift через какой-то менеджер зависимостей (SPM, CocoaPods и т. д.)
  6. Вы настроили Firebase в своем AppDelegate

1. Настройте сервис

Первое, что я делаю, это создаю сервисный класс для Firebase. В этом случае этот класс довольно легкий — это просто основа для расширения, если я добавлю какие-либо другие продукты Firebase, такие как Auth или Storage. Я пошел дальше и создал перечисление для своего Collections, чтобы упростить чтение и управление версиями API.

В этом руководстве мой пример — создание приложения для местных библиотек, которые хотят управлять своими книгами и библиотечными карточками.

import Firebase
import FirebaseFirestoreSwift
import Foundation
import SwiftUI

class FirebaseService {
    static let shared = FirebaseService()
    let database = Firestore.firestore()
    
    init() {
        // Optional: Tweak the settings below to your app's needs.
        let settings = FirestoreSettings()
        settings.isPersistenceEnabled = true
        settings.cacheSizeBytes = FirestoreCacheSizeUnlimited
        database.settings = settings
    }
}

enum Collections: String {
    case books = "books-v1"
    case cards = "cards-v1"
    case library = "library-v1"
}

Рекомендация. Выработайте привычку настраивать отдельный класс обслуживания для каждой сторонней интеграции, которую вы подключаете, независимо от их перекрывающихся функций. Это позволяет отделить вашу кодовую базу для быстрого масштабирования при изменении платформ или SDK, т. е. при переносе вашей базы данных с Firebase на AWS или MongoDB, не засоряя ваш сервисный уровень в рефакторинге.

2. Написание общих функций

ПОЛУЧИТЬ функции

Теперь, когда наша служба настроена, мы можем сосредоточиться на следующей задаче — запросе данных из Firestore. Из-за структуры вызовов SDK я создал две отдельные функции для одиночных и пакетных запросов.

Для одного запроса мы можем структурировать нашу общую функцию следующим образом:

extension FirebaseService {
    // 1.
    func getOne<T: Decodable>(of type: T, with query: Query) async -> Result<T, Error> {
        do {
            // 2.
            let querySnapshot = try await query.getDocuments()
            if let document = querySnapshot.documents.first {
                // 3.
                let data = try document.data(as: T.self)
                return .success(data)
            } else {
                print("Warning: \(#function) document not found")
                return .failure(TutorialError.documentNotFound)
            }
        } catch let error {
            print("Error: \(#function) couldn't access snapshot, \(error)")
            return .failure(error)
        }
    }
}
  1. Эта функция принимает два входных параметра: один тип (sigh, при этом Generics не выводится, если вы не передадите это — предложения приветствуются!) получить интересующие вас данные. Поскольку мы попытаемся декодировать наши данные в наш объект, нам нужно, чтобы наш Generic соответствовал Decodable.
  2. Мы используем Firebase SDK для получения документов в коллекции, а затем, поскольку это один запрос GET, мы можем просто получить первый документ (при условии, что ваш Query достаточно специфичен, чтобы был возвращен только один документ, т. е. выборка по первичному ключу).
  3. Если документ существует, мы можем декодировать его из данных как таковых и вернуть в наш Result. В противном случае мы можем поймать любые ошибки и соответствующим образом обработать их здесь.

Точно так же мы можем написать еще одну функцию для получения пакетных результатов и добавить ее в наше расширение FirebaseService.

func getMany<T: Decodable>(of type: T,with query: Query) async -> Result<[T], Error> {
    do {
        // 1.
        var response: [T] = []
        let querySnapshot = try await query.getDocuments()
        
        for document in querySnapshot.documents {
            do {
                // 2. 
                let data = try document.data(as: T.self)
                response.append(data)
            } catch let error {
                print("Error: \(#function) document(s) not decoded from data, \(error)")
                return .failure(error)
            }
        }
        // 3.
        return .success(response)
    } catch let error {
        print("Error: couldn't access snapshot, \(error)")
        return .failure(error)
    }
}
  1. В отличие от нашего одиночного запроса, мы хотим инициализировать список объектов, которые будут возвращены в нашем ответе.
  2. В нашем цикле for мы можем выполнить аналогичное декодирование, но затем добавить его в наш список. Любые ошибки перехватываются здесь и немедленно возвращают ошибку, независимо от того, как далеко мы прошли цикл for. Альтернативой может быть просто запись ошибки в журнал, но продолжение цикла без возврата ошибки.
  3. После завершения цикла for мы возвращаем наш список!

Подготовка к написанию

С помощью наших функций GET мы смогли убедиться, что можем извлекать данные, используя Generics, в соответствии с протоколом Decodable. Однако для написания нам нужно что-то еще, что дает нам гибкость при масштабировании нашего приложения. Введите FirebaseIdentifiable — самый примитивный протокол!

Поскольку каждый документ в нашей базе данных будет иметь уникальный идентификатор, и каждый документ будет называться в соответствии с его идентификатором, это просто архитектурное решение. У нас также есть несколько удобных трюков, которые мы можем использовать позже, чтобы сделать нашу запись записи очень простой для Firestore (!!).

protocol FirebaseIdentifiable: Hashable, Codable {
    var id: String { get set }
}

Функции POST и PUT

То, что мы можем прочитать, мы должны написать. POST и PUT очень похожи, разница лишь в том, как мы вызываем функцию document().

// 1.
func post<T: FirebaseIdentifiable>(_ value: T, to collection: String) async -> Result<T, Error> {
    // 2.
    let ref = database.collection(collection).document()
    var valueToWrite: T = value
    valueToWrite.id = ref.documentID
    do {
        //3.
        try ref.setData(from: valueToWrite)
        return .success(valueToWrite)
    } catch let error {
        print("Error: \(#function) in collection: \(collection), \(error)")
        return .failure(error)
    }
}

func put<T: FirebaseIdentifiable>(_ value: T, to collection: String) async -> Result<T, Error> {
    // 4.
    let ref = database.collection(collection).document(value.id)
    do {
        try ref.setData(from: value)
        return .success(value)
    } catch let error {
        print("Error: \(#function) in \(collection) for id: \(value.id), \(error)")
        return .failure(error)
    }
}
  1. Обе функции POST и PUT принимают одни и те же два входа: значение, которое вы хотите записать, и имя коллекции, в которую вы записываете. Вы заметите, что теперь мы требуем, чтобы наш универсальный объект соответствовал FirebaseIdentifiable. Однако стоит отметить, что этот протокол соответствует Codable, который соответствует Decodable, так что это одно и то же.
  2. Настраиваем ссылку на новый документ коллекции нашей базы данных. Мы также присваиваем идентификатор нашего значения идентификатору этого документа и возвращаем это значение обратно инициатору вызова для отслеживания и обработки по вашему желанию.
  3. Мы, наконец, устанавливаем данные ссылки, и наше значение записано! Затем мы возвращаем наше значение в Result, так как оно наиболее актуально.
  4. Для нашего вызова PUT, поскольку мы пытаемся получить доступ к уже существующему документу, мы устанавливаем нашу ссылку, используя идентификатор значения, который, когда он изначально был записан на шаге 2, также является идентификатором документа.

УДАЛИТЬ Функции

Firebase дает, Firebase забирает. Вот как выглядит наш код удаления, который также добавляется в расширение (подсказка: он очень похож на POST и PUT).

func delete<T: FirebaseIdentifiable>(_ value: T, in collection: String) async -> Result<Void, Error> {
    let ref = database.collection(collection).document(value.id)
    do {
        // 1.
        try await ref.delete()
        return .success(())
    } catch let error {
        print("Error: \(#function) in \(collection) for id: \(value.id), \(error)")
        return .failure(error)
    }
}
  1. Вместо того, чтобы устанавливать данные, Firestore SDK уже покрывает их своей функцией delete(). Однако, если бы вы написали нулевой объект, используя PUT, вы бы получили тот же результат!

3. Полировка API

Теперь, когда наши общие функции настроены, мы можем начать делать некоторые вызовы специально для данных нашего примера! В этом разделе вы увидите, как мы можем вызывать каждую из наших общих функций, а также несколько примеров или вариантов использования, которые помогут вам лучше визуализировать.

Получение некоторых книг

Из-за характера запросов и того, как структурирован Firestore SDK, я реализовал aBookService, который имитирует то, как можно было бы потенциально настроить запросы GET для одиночных или пакетных запросов:

struct BookService {
    static func get(by id: String) async -> Result<Book, Error> {
        do {
            // 1.
            let query = FirebaseService.shared.database
                .collection(Collections.books.rawValue)
                .whereField("id", isEqualTo: id)
                // Note: You can add more compounding queries here.
            
            // 2.
            let data = try await FirebaseService.shared.getOne(of: Book(), with: query).get()
            
            return .success(data)
        } catch let error {
            return .failure(error)
        }
    }
    
    static func get(for libraryID: String) async -> Result<[Book], Error> {
        do {
            // 3.
            let query = FirebaseService.shared.database
                  .collection(Collections.books.rawValue)
                  .whereField("library_id", isEqualTo: libraryID)

            let data = try await FirebaseService.shared.getMany(of: Book(), with: query).get()
            return .success(data)
        } catch let error {
            return .failure(error)
        }
    }
}
  1. Мы структурируем наш запрос на основе id, которые мы ищем. Обратите внимание, что здесь мы также можем составить больше запросов — узнайте больше, изучив документы Firebase.
  2. Мы передаем наш запрос в FirebaseService, чтобы получить наш единственный фрагмент данных. Очень просто.
  3. Как и в шаге 1 выше, мы можем выполнить аналогичный запрос выше, но на этот раз использовать другой параметр для фильтрации. Отсюда мы можем вызвать FirebaseService для получения наших пакетных данных.

Написание некоторых книг

В отличие от чтения из Firestore, запись намного проще, в основном из-за того, что не нужны запросы. Вы можете настроить функции на уровне API, которые вызывают FirebaseService, но если вы хотите сразу перейти к делу, вы можете вызывать записи непосредственно из самого объекта (!!) благодаря FirebaseIdentifiable.

Это дает возможность упростить ваш код и сделать его более читабельным. Все, что вам нужно сделать, это направить объект в нужную коллекцию:

extension FirebaseIdentifiable {
    /// POST to Firebase
    func post(to collection: String) async -> Result<Self, Error> {
        return await FirebaseService.shared.post(self, to: collection)
    }

    /// PUT to Firebase
    func put(to collection: String) async -> Result<Self, Error> {
        return await FirebaseService.shared.put(self, to: collection)
    }

    /// DELETE from Firebase
    func delete(from collection: String) async -> Result<Void, Error> {
        return await FirebaseService.shared.delete(self, in: collection)
    }
}

Здесь вы можете видеть, что они очень похожи друг на друга и просто проходят через тот же Result, который вы ожидаете от самого FirebaseService.

Заворачивать

Ву! Мы сделали это! Или, может быть, мы этого не сделали, и вы прокрутили статью до этого места. В любом случае, чтобы завершить это руководство, вот несколько примеров сценариев, которые действительно демонстрируют, насколько легко вызывать API с помощью этого подхода. Он надежный, но легкий, что позволяет вашему коду быть устойчивым по мере роста и масштабирования.

// ============================================
// EXAMPLE STRUCTS... in your models
// ============================================

struct Library: FirebaseIdentifiable {
    var id: String
    var name: String
    var address: String
    var books: [String]
    var cards: [String]
}

struct Book: FirebaseIdentifiable {
    var id: String
    var libraryID: String
    var name: String
    var author: String
    var pages: Int
    var isCheckedOut: Bool
}

struct Card: FirebaseIdentifiable {
    var id: String
    var libraryID: String
    var name: String
    var expired: Bool
}

// ============================================
// EXAMPLE FUNCTIONS... in your view models
// ============================================

func loadBooks() async {
    do {
        let books = try await BookService.get(for: someLibrary.id)
        // do something with response
    } catch let error {
        // handle failure
    }
}

func create(card: Card) async {
    do {
      let x = try await card.post(to: Collections.cards.rawValue).get()
        // do something with response
    } catch let error {
        // handle failure
    }
}

func update(book: Book) async {
    do {
        let x = try await book.put(to: Collections.books.rawValue).get()
        // do something with response
    } catch let error {
        // handle failure
    }
}

func delete(book: Book) async {
    do {
        try await book.delete().get()
        // do something once deleted
    } catch let error {
        // handle failure
    }
}

Вот и все — спасибо за прочтение, это была моя первая статья на Medium! Надеюсь, вы узнали что-то новое и учтете эту архитектуру в своем следующем проекте iOS. Все отзывы приветствуются.

В следующей статье я покажу вам простое решение, позволяющее сэкономить серьезные $$$ на этих надоедливых ограничениях скорости Firebase, интегрировав локальное кэширование в тот же самый сетевой уровень!

А пока меня можно найти в: Twitter, LinkedIn.