SmartStore – Mobilna baza danych na sterydach? Czy ciekawostka informatyczna?

SmartStore? Co to jest?

Baza danych SmartStore została stworzona przez firmę Salesforce i udostępniona w ich Salesforce Mobile SDK. Jest to baza danych typu NoSQL, a jak to firma Salesforce się chwali, jest to jedyna baza typu NoSQL, która jest szyfrowana i która jest dostępna na platformy iOS i Android. Tę bazę danych możemy wykorzystać zarówno w aplikacjach natywnych, jak i tych hybrydowych. Firma Salesforce, zadbała o to, aby wdrożenie i wykorzystanie tego komponentu nie stanowiło większęgo problemu.

Zapytasz co to NoSQL? To grupa baz danych umożliwiająca zapis danych w postaci innej niż tabelaryczna, usystematyzowana struktura (np klucz-wartość, obiekt, dokument), ze sztywnymi relacjami i ograniczeniami. Czy są lepsze niż bazy danych typu SQL? I tak i nie. W pewnych obszarach mają przewagę, a w innych są słabsze. Po prostu do każdego zastosowania, trzeba wybrać to co ma sens.

Dlaczego musisz używać bazy danych typu NoSQL w aplikacjach mobilnych? Nie musisz, ale możesz. Po prostu, analizujesz za i przeciw i wybierasz to co dla Ciebie najlepsze. Oprócz SmartStore’a zapewne masz jeszcze do dyspozycji tradycyjnego SQLite’a. O którym z resztą wspomnę jeszcze za moment.

Uwaga: wszystkie poniższe przykładu zakładają, że pracujemy z aplikacją hybrydową, gdzie większość biznes logiki znajduje się w części HTML5 + JavaScript, natomiast natywna cześć aplikacji, jedynie dostarcza pewnych usług/komponentów dla części webowej. Przykłady są rozpisane w języku JavaScript i poza jawną zależnością od komponentu Cordova (SmartStore wymaga tego w aplikacji), nie ma żadnych innych wymogów. Czyli Twoja aplikacjia hybrydowa może być oparta o jQuery Mobile, Sencha Touch, albo cokolwiek innego z czym czujesz się dobrze.

Jak wykorzystać SmartStore w swoim projekcie?

Aby wykorzystać ten moduł, musisz rozszerzyć swój projekt iOS/Android i dodać do niego udostępnione przez SFDC komponenty, jako zależne podprojekty (iOS) lub projekty w tym samym workspace i wskazane jako library (Android).

Zakładając, że masz aplikację hybrydową, może wyglądać to mniej więcej tak:

Podprojekty aplikacji hybrydowej - uaktywnienie SmartStore

lub tak:

Ustawienia projektu Android z dodanym SmartStore

Jak to działa?

W SmartStore nie posługujemy się terminem tabela, lecz terminem soup. Termin ten został zapożyczony od idei firmy Apple: Soup, który był system pliku dla ich urządzeń typu PDA – Newton.

Nie będę tego terminu tłumaczył na polski, bo jakoś dziwnie czuję się, gdy określam zbiór danych zupą…

Soup to nic innego jak rodzaj danych/obiektów zapisanych w jednym miejscu, to taki odpowiednik tabeli w relacyjnej bazie danych. W SmartStore możemy zdefiniować wiele soup. Czym to się więc różni od tabeli? Brakiem posiadania wymuszonej struktury danych i sztywnych relacji. SmartStore bowiem zapisuje obiekty używając notacji JSON. Oto przykład:

{
    "atrybut1": "Wartość1",
    "atrybut2": 156,
    "atrybutZTablicą": [
        { "klucz": "wartość", "klucz2": "wartość2" },
        { "klucz": "wartość", "klucz2": "wartość2" },
    ],
    "atrybut3": true,
    "obiektDziecko": {
        "atrybutXYZ": "ABCD",
        "atrybutABC": "XYZ",
    }
}

Taki JSON, zostaje zapisany w kolumnie _soup.

Struktura danych zapisanych do pojedynczego zbioru soup może być różna. Nie wszystkie rekordy muszą posiadać te same atrybuty. Wymagania są w zasadzie 2. Po pierwsze zapisujemy do danego soup rekordy należące do jednego zbioru danych, tj. nie zapisujemy 2 różnych typów rekordów, np. A i B, do jednego soup, bo to nie ma sensu. Technicznie jest to możliwe (w końcu co za różnica taki JSON czy inny), ale podczas odczytu można się potem nieźle zdziwić, że czytamy atrybuty A, a tam jest obiekt typu B. Po drugie, rekord powinien posiadać swój unikalny identyfikator. Baza SmartStore posiada swój wewnętrzny sposób na nadawanie ID rekordom. Taki ID trzymany jest w atrybucie _soupEntryId, ale jest to zwykła liczba, automatycznie zwiększana o 1 przy dodawaniu rekordu do tabeli.

Podsumowując, każdy rekord w soup ma przynajmniej 2 atrybuty:

  • _soup – przechowujący obiekt w fomracie JSON
  • _soupEntryId – unikalny Id w rekordu w danych zbiorze soup

Dla każdego z tych zbiorów danych (soup) definiujemy też dodatkową informację: indeksy. Indeksy umożliwiają wskazanie, które z atrybutów zapisanych do soup, będą następnie użyte w wyszukiwaniu rekordów w SmartStore. Przeszukiwanie soup z ustawionymi kryteriami na atrybuty niezaindeksowane, po prostu się nie uda. A na czym taka indeksacja polega?  O tym napiszę w dalszej części posta.

SmartStore podczas pierwszego uruchomienia, tworzy sobie plik bazy danych SQLite, i w tej bazie tworzy sobie następujące pomocnicze tabele:

  • soup_index_map – przechowuję informację o indeksach w zbiorach soup
  • soup_names – przechowuje informację o stworzonych i skonfigurowanych soup, z których można korzystać (zapisywać i wczytywać dane)

Obie tabele nie są widoczne dla użytkownika końcowego, tzn. programista nie korzysta z nich jawnie, to jest coś, to SmartStore używa w kontekście operacji jakie wykonujemy na tej bazie danych, ale robi to samemu.

Dla przykładu pokażę, jak można zamodelować książkę adresową składającą się z 2 rodzajów obiektów: Grupa i Kontakt. Zaprezentowany kod, będzie urywkiem kodu w JavaScript obrazujący możliwości jakie mamy w aplikacji hybrydowej.

Załóżmy, że klasa każdy obiekt typu Grupa musi posiadać nazwę, a dla prostoty, załóżmy, że każdy obiekt typu Kontakt będzie posiadał: Imię, Nazwisko, oraz Identyfikator grupy, do której należy. Poniżej nasze dane, z którymi będziemy pracować dalej.

var grupy = [{
        nazwa : "Rodzina",
        id : 1
    },
    {
        nazwa : "Znajomi",
        id : 2
    }];
 
var kontakty = [{
        imie : "Stefan",
        nazwisko : "Batory",
        id : 1,
        grupa : 1
    },
    {
        imie : "Władysław",
        nazwisko : "Jagiełło",
        id : 2,
        grupa : 1
    },
    {
        imie : "Jan",
        nazwisko : "Sobieski",
        id : 3,
        grupa : 2
    }]

Zakładanie soup

Aby móc gdzieś zapisywać rekordy, musimy stworzyć dla nich dedykowane soup. Wykonujemy to, używając dostępnej funkcji registerSoup. Oprócz tworzenia, można też taki zbiór danych usunąc funkcją removeSoup.

Zakładamy, że w przyszłości będziemy odpytywać bazę o rekordy typu Grupa używając id oraz nazwy w kryteriach zapytania.

navigator.smartstore.registerSoup(
    "Grupa",
    [
        {"path":"id","type":"string"},
        {"path":"nazwa","type":"string"}
    ],
    function successCallback(result) {
        console.log("Sukces: "+JSON.stringify(result));
    },
    function errorCallback(error) {
        console.wanr("Błąd: "+JSON.stringify(error));
    }
);

A także, zakładamy, że w przypadku rekordów typu Kontakt, będziemy używać jedynie nazwiska i id w kryteriach zapytania.

navigator.smartstore.registerSoup(
    "Kontakt",
    [
        {"path":"id","type":"string"},
        {"path":"nazwisko","type":"string"}
    ],
    function successCallback(result) {
        console.log("Sukces: "+JSON.stringify(result));
    },
    function errorCallback(error) {
        console.wanr("Błąd: "+JSON.stringify(error));
    }
);

Tworzenie/Aktualizacja rekordów w soup

Aby wstawić powyższe dane do SmartStore najpierw należy utworzyć soup dla każdego z rodzajów obiektów, a następnie wstawić dane do utworzonych zbiorów.

Wstawienie grup:

navigator.smartstore.upsertSoupEntriesWithExternalId(
    "Grupa",
    grupy,
    "id",
    function successCallback(result) {
        console.log("Grupa - Sukces");
    },
    function errorCallback(error) {
        console.warn("Grupa - Błąd: "+JSON.stringify(error));
    }
);

Wstawienie kontaktów:

navigator.smartstore.upsertSoupEntriesWithExternalId(
    "Kontakt",
    kontakty,
    "id",
    function successCallback(result) {
        console.log("Kontakty - Sukces");
    },
    function errorCallback(error) {
        console.wanr("Kontakty - Błąd: "+JSON.stringify(error));
    }
);

Abyś w pełni zrozumiał co robią kolejne argumenty funkcji, załączam wycinek z jej definicji:

var upsertSoupEntriesWithExternalId = function (soupName, entries, externalIdPath, successCB, errorCB) {
// ...
}

Zauważ proszę, że SmartStore nie posiada oddzielnych funkcji do tworzenia rekordów lub ich aktualizacji. Dziwne? Ale wg mnie całkiem użyteczne. W ramach SmartStore operujemy pojęciem upsert. Upsert jest operacją, która w przypadku nieznalezienia rekordu o podanym id, tworzy nowy rekord w soup, a jeśli taki rekord znajdzie, to aktualizuje go zastępując jego dane, danymi podanymi w parametrze funkcji upsert.

Pobieranie danych

Teraz chcąc pobrać dane dla naszych obiektów musimy zapytać SmartStore o te dane. Możemy to zrobić na 2 sposoby:

  • używając funkcji querySoup,
  • używając funkcji runSmartQuery.

Ta pierwsza, tj. querySoup po prostu odpytuje dany zbiór soup o rekordy używając obiektu pomocniczego: QuerySpec. Druga, używa polecenia napisanego w takim pseudo SQLu, nazwanego SmartSQL. Przy pomocy tego SmartSQL możemy nie tylko pobrać dane, ale wykonać też jakieś bardziej zaawansowane rzeczy na bazie, na przykład dokonać jakichś operacji agregujących, czy użyć wbudowanych w SQLite zestaw funkcji.

Obiekt typu QuerySpec, tworzymy używając gotowych funkcji:

  • buildAllQuerySpec(path, order, pageSize) – pobiera wszistkie dane, brak jakichkolwiek warunków
  • buildExactQuerySpec(path, matchKey, pageSize) – pobiera tylko te dane, gdzie podany atrybut rekordu jest równy podanej wartości
  • buildRangeQuerySpec(path, beginKey, endKey, order, pageSize) – pobiera te dane, których atrybut znajduje się pomiędzy górnym a dolnym ograniczeniem podanym w zapytaniu
  • buildLikeQuerySpec(path, likeKey, order, pageSize) – pobiera dane pasujące do podanego kryterium typu LIKE.

A także specjalne do użycia w przypadku korzystania ze SmartSQL.

  • buildSmartQuerySpec(smartSql, pageSize) – buduje QuerySpec wykonujące podane podelecenie w SmartSQL.

QuerySpec

navigator.smartstore.querySoup(
    'Grupa',
    navigator.smartstore.buildAllQuerySpec("id", null, 1000),
    function successCallback(cursor) {
        console.log(JSON.stringify(cursor));
 
        var printRecords = function printRecordsFn(crsr) {
            // robimy coś z rekordami
            for (var i = crsr.currentPageOrderedEntries.length - 1; i >= 0; i--) {
                console.log(JSON.stringify(crsr.currentPageOrderedEntries[i]));
            }
        };
 
        // wyświetlamy aktualne rekordy
        printRecords(cursor);
 
        // dopóki są jakiekolwiek inne rekordy, pobieramy je
        if (cursor.currentPageIndex < cursor.totalPages) {
        navigator.smartstore.moveCursorToNextPage(cursor,
            function successCallback(newCursor) {
                printRecords(newCursor);
            },
            function errorCallback(error) {
                console.warn(error);
            )
        }
 
        // zamykamy kursor
        navigator.smartstore.closeCursor(cursor);
    },
    function errorCallback(error) {
        console.warn("Błąd: "+JSON.stringify(error));
    }
);

 

SmartSQL

var smartSql = 'SELECT {Grupa:_soup} FROM {Grupa} ORDER BY {Grupa:nazwa} ASC';
 
navigator.smartstore.runSmartQuery(
    navigator.smartstore.buildSmartQuerySpec(smartSql, 1000), // 1000 to rozmiar strony z wynikami
    function(cursor) {
        console.log(JSON.stringify(cursor));
 
        var printRecords = function printRecordsFn(crsr) {
            // robimy coś z rekordami
            for (var i = crsr.currentPageOrderedEntries.length - 1; i >= 0; i--) {
                console.log(JSON.stringify(crsr.currentPageOrderedEntries[i][0]));
            }
        };
 
        // wyświetlamy aktualne rekordy
        printRecords(cursor);
 
        // dopóki są jakiekolwiek inne rekordy, pobieramy je
        if (cursor.currentPageIndex < cursor.totalPages) {
        navigator.smartstore.moveCursorToNextPage(cursor,
            function successCallback(newCursor) {
                printRecords(newCursor);
            },
            function errorCallback(error) {
                console.warn(error);
            )
        }
 
        // zamykamy kursor
        navigator.smartstore.closeCursor(cursor);
    },
    function(err) {
        console.warn(err)
    }
)

Co się dzieje w środku?

W obu przypadkach, pod spodem, komponent SmartStore najwierw sprawdza w swojej wewnętrznej tabeli soup_names w jakiej tabeli trzyma wyspecyfikowany obiekt, np:

SELECT id FROM soup_names WHERE soupName = 'Grupa'

Następnie, SmartStore sprawdza jakie indeksy są zdefiniowane na danej tabelce poprzez wykonanie zapytania:

SELECT path, columnName, columnType FROM soup_index_map WHERE soupName = 'Grupa'

SmartStore przechowuje cały obiekt w notacji JSON w 1 kolumnie w tabeli w bazie danych. Dla każdego z indeksów, tworzona jest oddzielna kolumna, przechowująca wartość tylko tego konkretnego atrybutu danego rekordu. Każda taka kolumna, ma dodatkowo założony indeks, aby móc szybciej wyszukiwać obiekty w oparciu o kryteria na danym atrybucie (kolumnie).

Dopiero wiedząc to w jakiej tabeli w SQLite, oraz w jakich kolumnach (jakie nazwy) trzymane są atrybuty rekordu w danej tabeli, SmartStore odpytuje konkretną tabelkę o dane na przykład takim zapytaniem:

SELECT * FROM (SELECT TABLE_33.soup FROM TABLE_33 ORDER BY TABLE_33_0 ASC ) LIMIT 0,1000

Gdzie TABLE__33 to właściwa nazwa tabeli w SQLite w której trzymane są rekordy typu Grupa, TABLE_33.soup to kolumna trzymająca obiekt zapisany przy pomocy JSON, a TABLE_33_0 to właściwa nazwa kolumny w SQLite, w której trzymany jest indeksowany atrybut.

Jak wspomniałem wcześniej, SmartStore oparty jest o bazę danych SQLite. Zaraz zaraz, przecież miał być bazą NoSQL – powiesz. Miał być i jest. Po pierwsze obiekty typu A zapisane w bazie, nie muszą posiadać takiego samego ustalonego zbioru atrybutów. Zbiór atrybuty można do woli rozszerzyć, i nie wpływa to w ogóle na strukturę bazy danych, do momentu, kiedy nie zaczynami indeksować nowych atrybutów. Nie mamy w tabelach pozakładanych żadnych kluczy obcych. To że jeden obiekt odnosi się do drugiego, to sprawa logiki biznesowej w aplikacji, a nie samej bazy danych. No i na koniec, żeby pobrać jakiś rekord z danego soup – wcale nie trzeba znać SQL 😉

Co mi to da? Dlaczego nie mogę używać zwykłego SQLite?

Pewnie zapytasz: Po co mi to? Znam i używam SQLite (albo inną bazę danych) lub wykorzystuję pliki do przechowywania danych. Wbrew pozorom SmartStore ma sporo plusów. Ma też minusy. Poniżej staram się pokazać wady i zalety w obiektywny sposób, tak aby decyzja o ewentualnym wykorzystaniu tego komponentu w Twojej aplikacji mobilnej była świadoma.

Zalety:

  • gotowy, przetestowany komponent bazy danych do użycia w aplikacji hybrydowej,
  • zapewnia bezpieczeństwo dzięki wbudowanemu szyfrowaniu
  • zmiana formatu danych (dodanie/usunięcie atrybutu z danego rodzaju obiektu) nie zawsze będzie wymagała zmian na bazie, dopóki dany atrybut nie jest używany w kryteriach wyszukiwania w bazie
  • w przypadku integracji z platformą Salesforce.com mamy od ręki dostęp do komponenty SmartSync odpowiedzialnego za synchronizację danych z tą platformą
  • otwarty kod źródłowy umożliwia edycję/rozszerzenie/zmiany w oryginalnym kodzie i dostosowanie komponentu całkowicie do naszych potrzeb

Wady:

  • dodatkowy narzut na SQLite może sprawić, że baza danych nie będzie w 100% optymalna
  • w niektórych przypadkach wadą będzie brak relacyjnego podejścia do danych, np.  brak możliwości ustawiania kluczy obcych
  • aby operować funkcjami SQLite na rekordzie, trzeba wyznaczyć interesujące nas atrybuty, jako indeksowane wartości, inaczej nie będizemy w stanie w prosty sposób się do nich dobrać
  • uzależenienie od komponentu firmy trzeciej
  • w przypadku iOS, nie można wykorzystać razem ze SmartStore’em żadnego elementu związanego z rewelacyjnym frameworkiem jakim jest CoreData
  • problemy z wykorzystaniem pamięci na Android (w swojej przygodzie ze SmartStore otarłem się o problemy z alokacją pamięci, a konkretnie jej zbyt dużym zużyciem)

 


Niniejszy post jest częścią serii postów pod tytułem: Jak stworzyć aplikację mobilną? Jeżeli zainteresowała Cię ta tematyka, to zachęcam do przeczytania pozostałych artykułów powiązanych z tworzeniem aplikacji mobilnych.


 

Zapraszam Cię również do zapoznania się z innymi postami nt hybrydowych aplikacji mobilnych:

Sencha Touch – hybrydowe aplikacje mobilne.

Tworzenie Aplikacji Mobilnych – 3 możliwości: HTML5, natywna, czy hybryda?

 

Dodaj komentarz