Cloud Firestore

Firebase

  |   11 min czytania
(Firebase-Firestore 18.0)

Wprowadzenie

Cloud Firestore jest elastyczną, skalowalną, hierarchiczną bazą danych NoSQL w chmurze służącą do przechowywania i synchronizowania w czasie rzeczywistym danych między klientami i serwerem. Oferuje wsparcie w trybie offline co ułatwia budowania responsywnych aplikacji działających niezależnie od jakości połączenia sieciowego. Umożliwia integracje z Google Cloud Platform i funkcjonalnościami Firebase jak np. Cloud Functions czy Authentication oraz innymi zewnętrznymi usługami. Dostęp i modyfikacja danych odbywa się zgodnie z zasadami zachowania bezpieczeństwa i prywatności, które mogą być dodatkowo definiowane za pomocą reguł w konsoli Firebase.

Model

Dane reprezentowane są w postaci dokumentów (zbliżonych w stukurze do formatu JSON) przechowywanych w zorganizowanej kolekcji w hierarchicznej strukturze bazy danych NoSQL (w przeciwieństwie do SQL nie ma tabel ani wierszy). Każdy dokument (document) składa się z nazwy, zbioru par klucz - wartość (prymityw lub obiekt złożony) i może posiadać obiekty zagnieżdzone i podkolekcje. Kolekcje (collection) pełnią rolę kontenera dla różnych dokumentów i nie mogą posiadać innych kolekcji, a w doborze ich zawartości warto zachować logiczny porządek dzieląc dokumenty ze względu na kategorie i przeznaczenie co upraszcza poruszanie się po strukturze w kodzie klienta. Podkolekcja jest kolekcją w dokumencie i służy do budowania zagnieżdzonej struktury folderów.

Model bazy

Ze względu na optymalizacje wydajności dostępu do bazy danych wprowadzony został mechanizm indeksowania, który wyróżnia dwa rodzaje indeksów: single-field (przechowuje posortowaną mapę wszystkich dokumentów zawierających dane pole) i composite (przechowuje posortowaną mapę wszystkich dokumentów zawierających wiele danych pól). Cloud Firestore jest zaprojektowany przede wszystkim z myślą o dużych kolekcjach małych dokumentów. Aby uzyskać dostęp do dokumentu lub kolekcji należy uzyskać referencje - obiekt typu DocumentReference.

private fun createReferences() {
    val database = FirebaseFirestore.getInstance()
    val collectionRef = database.collection("collection")
    val documentRef = collectionRef.document("document")
    val documentRef2 = database.document("collection/document") //or use direct path instead
    val nestedDocumentRef = documentRef.collection("subcollection").document("nested")
}

Typy

Dokumenty mogą przechowywać wartości prymitywów (numeryczne, logiczne, tekstowe), obiekty złożone (koordynaty geograficzne, data i czas), tablice, mapy i referencje do innych dokumentów.

private fun createExampleData() {
    val document = HashMap<String, Any?>()
    document["text"] = "value"
    document["logic"] = true
    document["number"] = 9.99
    document["date"] = Timestamp(Date())
    document["list"] = arrayListOf(1,2,3)
    document["null"] = null
}

private fun createSimilarData() {
    val person = HashMap<String, Any>()
    person["name"] = "John"
    person["surname"] = "Walker"
    person["born"] = 1805

    //structure of data in the same collection don't have to be exactly the same
    val person2 = HashMap<String, Any>()
    person2["name"] = "Jasper"
    person2["second"] = "Newton"
    person2["surname"] = "Daniel"
    person2["born"] = 1850

    //add to database by passing those objects
}

private fun createDataByObject() {
    val person = Person("William", "Grant", 1839)
    //add to database by passing object
}

Zapis

Aby dokonać zapisu w Cloud Firestore należy podać identyfikator dokumentu oraz przekazać obiekt danych metodą set lub w przypadku automatycznego generowania id wystarczy tylko przekazać danę metodą add. Jeśli dokument nie istnieje wówczas zostanie utworzy, w przeciwnym razie jego zawartośc zostanie nadpisana, chyba że zostaną użyte odpowiednie opcje. Dodanie obserwatorów umożliwia śledzenie stanu operacji.

private fun addDataWithId() {
    //create data
    var club = Club("Milan", "Italy", 1899)
    club.uclTrophiesYear = listOf(1963, 1993, 1989, 1990, 1994, 2003)
    club.budget = 1000L

    //put data to database
    database.collection("club").document("acmilan").set(club)
        .addOnSuccessListener {
            //some action
        }
        .addOnFailureListener {
            //some action
        }

    //or use reference.set(club, SetOptions.merge()) for merge if file exists
}

private fun addDataWithoutId() {
    var club = Club("Chelsea FC", "England", 1905)
    database.collection("club").add(club)
        .addOnSuccessListener { 
            //some action 
        }
        .addOnFailureListener { 
            //some action 
        }
}

W celu aktualizacji wybranych pól dokumentu bez nadpisywania całej jego zawartości należy użyć metodę update na referencji. Odwołując się do obiektów zagnieżdzonych należy wykorzystać właściwą notację oddzielając pola i wartości przecinkiem, a w przypadku modyfikacji elementów tablic użyć metod FieldValue.arrayUnion oraz FieldValue.arrayRemove.

private fun updateData() {
    val reference = database.collection("club").document("acmilan")
    reference.update("name", "AC Milan")
    //add some listeners
}

private fun updateArrayData() {
    //add new data
    val reference = database.collection("club").document("acmilan")
    reference.update("uclTrophiesYear", FieldValue.arrayUnion(2007))
    //add some listeners
}

Usunięcie dokumentu następuje poprzez wywołanie metody delete na referencji dokumentu (nie powoduje usunięcia jego podkolekcji), natomiast przypisanie wartości FieldValue.delete() do pola w operacji update powoduje jego usunięcie. Usuwanie całych kolekcji po stronie klient jest niezalecane ze względu na zagrożenia wydajności, wycieku pamięci, bezpieczeńśtwa po stronie klienta wynikające z potrzeby wysyłania wielu żądań.

private fun deleteDocument() {
    database.collection("club").document("acmilan").delete()
    //add some listeners
}

private fun deleteField() {
    val reference = database.collection("club").document("acmilan")

    val updates = HashMap<String, Any>()
    updates["uclTrophiesYear"] = FieldValue.delete()

    reference.update(updates)
    //add some listeners
}

Odczyt

Pobieranie dokumentów odbywa się przez wywołanie metody get na referencji dokumentu lub kolekcji. Dodatkowo można ustawić źródło pobierania danych na serwer lub pamięć cache oraz parsować otrzymane dane do instancji klasy. Rozszerzenie żądania o żłożone zapytania pozwala filtrowanie (where), sortowanie (orderBy) i ograniczanie liczby wyników spełniających założenia (limit).

private fun getDocument() {
    val reference = database.collection("club").document("acmilan")

    //add optional source and listeners and retrieve document
    val source = Source.SERVER //change to Source.CACHE for cached data
    reference.get(source)
        .addOnSuccessListener { document ->
            //some action on DocumentSnapshot instance
            val club = document.toObject(Club::class.java)
        }
        .addOnFailureListener { exception ->
            //some action 
        }
}

private fun getMultipleDocuments() {
    //add some listeners and optional query clausule
    database.collection("club").whereEqualTo("country", "Italy").get()
        .addOnSuccessListener { documents ->
            //some action on QueryDocumentSnapshot instance
            for(document in documents) { }
        }
}

private fun getDocumentsByQueries() {
    //add queries and listeners
    database.collection("club")
        .whereEqualTo("country", "Italy")
        .whereLessThan("year", 1930)
        .orderBy("year", Query.Direction.DESCENDING)
        .limit(5)
        .get()
        .addOnSuccessListener { documents ->
            //some action on QueryDocumentSnapshot instance
        }
}

Ponadto istnieje możliwość nasłuchiwania modyfikacji danych dla dokumentu i kolekcji w czasie rzeczywistym poprzez dodanie obiektu słuchacza z uwzględnieniem źródła i typu zmian.

private fun listenForChanges() {
    val documentRef = database.collection("club").document("acmilan")
    documentRef.addSnapshotListener(EventListener<DocumentSnapshot> { snapshot, exception ->
        //check is exception
        if (exception != null) return@EventListener

        //check source of the changes
        if (snapshot != null && snapshot.metadata.hasPendingWrites()) {
            //local changes
        }
        else {
            //server changes
        }

        //do more job
    })

    val collectionRef = database.collection("club")
    collectionRef.addSnapshotListener(EventListener<QuerySnapshot> { snapshots, exception ->        
        //check type of the change
        for (changes in snapshots!!.documentChanges) {
            //could be: DocumentChange.Type.ADDED, MODIFIED or REMOVED
        }

        //do more job
    })
}

Transakcje

Cloud Firestore wspiera atomowe operacje dla zapisu i odczytu, które są implikowane tylko wtedy kiedy wszystkie operacje zbioru zakończą się pozytywnie, zatem wykonanie zbioru operacji atomowych jest spójne i nierozdzielne. Wyróżnia się dwa typy operacji atomowych: transakcje (transaction) - zbiór operacji odczytu i zapisu na wielu dokumentach oraz zestaw zapisu (batched write) - zbiór operacji zapisu dla wielu dokumentów. Transakcje pozwalają na grupowanie wielu operacji w jedną transakcje i wykorzystywane są przede wszystkim do modyfikacji dokumentów bazując na ich bieżącym stanie. Wykonywane są za pomocą metody runTransaction lub zbioru operacji zapisu na obiekcie WriteBatch.

private fun makeTransaction() {
    val reference = database.collection("club").document("acmilan")

    database.runTransaction { transaction ->
        val snapshot = transaction.get(reference)
        val budget = snapshot.getLong("budget")
        if(budget!! <= 1000)
            transaction.update(reference, "budget", budget + 500L)
    }
}

private fun makeWriteBatch() {
    val batch = database.batch()

    //add new document
    val realMadrid = Club("Real Madrid", "Spain", 1902)
    val rmReference = database.collection("club").document("realmadrid")
    batch.set(rmReference, realMadrid)

    //update current
    val acmReference = database.collection("club").document("acmilan")
    batch.update(acmReference, "budget", 2000L)

    //do more add, update, delete operations

    //commit operations and add some listeners
    batch.commit().addOnCompleteListener {
        //some action
    }
}