Dane aplikacji możemy przechowywać w różny sposób. Możemy je zapisywać w plikach, w bazie danych lub w chmurze. W naszym przypadku będą to słowniki oraz dane stacji więc dla przejrzystości moglibyśmy wybrać plik xml. Jednak w celach edukacyjnych użyjemy bazy danych SQLite oraz biblioteki ZeosLib (opis jej instalacji znajdziesz w tym miejscu).
Instalacja SQLite
Właściwie nie można mówić o instalacji bo jedyne co musimy zrobić to ściągnąć odpowiedni plik biblioteki SQLite i umieścić go w katalogu ./data/lib. Następnie już z poziomu kodu aplikacji, korzystając z biblioteki ZeosLib, ustanowić połączenie do pliku bazy danych. Jeżeli plik bazy danych jeszcze nie istnieje to zostanie utworzony przy pierwszym uruchomieniu aplikacji.
Pliki biblioteki SQLite można znaleźć pod adresem http://www.sqlite.org lub bezpośrednio w repozytorium TinyRadioPlayer w katalogu ./lib/sqlite/.
- Windows – pobieramy plik sqlite3.7z, rozpakowujemy i umieszczamy w katalogu bin\i386-win32\data\lib
- Linux – pobieramy plik libsqlite3.7z, rozpakowujemy i umieszczamy w katalogu bin/x86_64-linux/data/lib
- MacOSX – pobieramy plik libsqlite.7z, rozpakowujemy i umieszczamy w katalogu bin/i386-darwin/data/lib
Nawiązanie połączenia
Dla przejrzystości przedstawię tylko prosty przykład tworzenia połączenia z bazą danych, resztę znajdziecie w repozytorium na GitHubie. Aby jednak nie zostawiać Was z niepełną wiedzą opiszę również ogólną koncepcję jaka przyświecała mi podczas projektowania dostępu do bazy danych.
Główną część zarządzającą bazą danych umieścimy w pliku BaseRepository. Jest to klasa abstrakcyjna, której zadaniem będzie nawiązanie połączenia z bazą danych, utworzenie schematu nowej bazy oraz podpięcie pozostałych repozytoriów. Dodatkowo znajdą się tutaj również metody abstrakcyjne, których implementacja zostanie przeniesiona do klas dziedziczących. Dla przykładu metoda CreateDML (tworząca domyślne dane) zostanie zaimplementowana w klasie MainRepository. Taki sposób da nam możliwość podpięcia się pod inną bazę danych, np. bazę z ulubionymi stacjami.
Do utworzenia połączenia z bazą danych użyjemy komponentu TZConnection. Jest on częścią pakietu zcomponent więc dodajemy go do Required Packages w oknie Project Inspector.
Teraz możemy nawiązać połączenie z bazą danych.
function TBaseRepository.Connect(const DBName: string): ErrorId; var err: ErrorId; filePath: string; begin err := ERR_OK; if DBName = EMPTY_STR then err := ERR_DB_NO_DATABASE_NAME; if err = ERR_OK then begin err := Disconnect; if err = ERR_OK then begin try FConnection := TZConnection.Create(nil); FConnection.Protocol := 'sqlite-3'; FConnection.LibraryLocation := ConcatPaths([GetApplicationPath, LIB_PATH, {$IFDEF MSWINDOWS} 'sqlite3.dll' {$ENDIF} {$IFDEF LINUX} 'libsqlite3.so' {$ENDIF} {$IFDEF MACOS} 'sqlite3.dylib' {$ENDIF} ]); // Try connect to database if not FileExists(DBName) then begin // check if directory exists, if not create it filePath := ExtractFilePath(DBName); if not DirectoryExists(filePath) then if not CreateDir (filePath) then begin Result := ERR_DB_CREATE_DIR; Exit; end; FConnection.Database := DBName; FConnection.Connect; CreateDB; end else begin FConnection.Database := DBName; FConnection.Connect; SetDBSettings; end; except on E: Exception do begin LogException(EmptyStr, ClassName, 'Connect', E); // err := ERR_DB_CONNECT_ERROR; end; end; end; end; Result := err; end;
Ważne jest, aby prawidłowo ustawić protokół z jakiego korzystamy oraz podać dokładne ścieżki do biblioteki SQLite i pliku bazy danych. Jeżeli plik bazy danych wcześniej nie istniał to przy nawiązaniu połączenia zostanie utworzony.
Tworzenie nowej bazy
Nowo utworzony plik bazy danych nie zawiera tabel oraz danych więc musimy utworzyć je sami.
function TBaseRepository.CreateDB: ErrorId; var err: ErrorId; begin err := ERR_OK; try // Database settings // The Boolean synchronous value controls whether or not the // library will wait for disk writes to be fully written to disk // before continuing. In typical use the library may spend a lot of // time just waiting on the file system. // Setting "PRAGMA synchronous=OFF" can make a major speed difference. FConnection.ExecuteDirect('PRAGMA synchronous = OFF;'); // The temp_store values specifies the type of database back-end to use // for temporary files. // The choices are DEFAULT (0), FILE (1), and MEMORY (2). // The use of a memory database for temporary tables can produce // signifigant savings. DEFAULT specifies the compiled-in default, // which is FILE unless the source has been modified. FConnection.ExecuteDirect('PRAGMA temp_store = MEMORY;'); // The default behavior of the LIKE operator is to ignore case for // .SCII characters FConnection.ExecuteDirect('PRAGMA case_sensitive_like = OFF;'); // Unless already in a transaction, each SQL statement has a new // transaction started for it. This is very expensive, since it requires // reopening, writing to, and closing the journal file for each statement. // This can be avoided by wrapping sequences of SQL statements with // BEGIN TRANSACTION; and END TRANSACTION; statements. // This speedup is also obtained for statements which don't alter // the database. // The keyword COMMIT is a synonym for END TRANSACTION. FConnection.ExecuteDirect('BEGIN TRANSACTION;'); err := CreateDDL; if err = ERR_OK then err := CreateDML; if (err = ERR_OK) and (not FConnection.ExecuteDirect('COMMIT;')) then err := ERR_DB_CREATE_ERROR; if err <> ERR_OK then FConnection.ExecuteDirect('ROLLBACK;'); except on E: Exception do begin LogException(EmptyStr, ClassName, 'CreateDB', E); FConnection.ExecuteDirect('ROLLBACK;'); err := ERR_DB_CREATE_ERROR; end; end; Result := err; end;
Aby przyspieszyć zapis i odczyt z bazy danych zmieniamy kilka ustawień. Przede wszystkim ustawiamy, aby wszystkie tymczasowe tabele były trzymane w pamięci. Dodatkowo wyłączamy synchroniczny zapis co da możliwość dodawania dużej ilości danych bez zbędnego czekanie na ukończenie zapisu do pliku. Wyłączamy również rozróżnianie wielkości liter przy korzystaniu z operatora LIKE.
Cały proces tworzenia schematu i dodawania danych umieszczamy w transakcji. W taki sposób wyeliminujemy konieczność tworzenia transakcji dla każdej operacji.
Tworzenie schematu bazy danych
Schemat bazy danych utworzymy w metodzie CreateDDL.
function TBaseRepository.CreateDDL: ErrorId; var query: TZQuery; err: ErrorId; procedure ExecuteQuery(AQuery: string); begin query.SQL.Add(AQuery); query.ExecSQL; query.SQL.Clear; end; begin err := ERR_OK; try query := TZQuery.Create(nil); try query.Connection := FConnection; // Stations ExecuteQuery( 'CREATE TABLE ' + DB_TABLE_STATIONS + ' (' + 'ID INTEGER PRIMARY KEY NOT NULL, ' + 'Name VARCHAR NOT NULL, ' + 'StreamUrl VARCHAR NOT NULL, ' + 'Description TEXT NULL, ' + 'WebpageUrl VARCHAR NULL, ' + 'GenreCode VARCHAR NULL, ' + 'CountryCode VARCHAR NULL, ' + 'Created INTEGER NOT NULL, ' + 'Modified INTEGER NULL);'); finally query.Free; end; except on E: Exception do begin LogException(EmptyStr, ClassName, 'CreateDDL', E); err := ERR_DB_CREATE_DDL_ERROR; end; end; Result := err; end;
Tworzymy zapytanie i wskazujemy aktywne połączenie z bazą danych. Następnie wykonujemy je co powoduje utworzenie nowej tabeli Stations.
Tworzenie danych
Dane słownikowe oraz informacje o stacjach utworzymy w metodzie CreateDML. Jest to metoda abstrakcyjna, której wywołanie znajduje się w klasie TBaseRepository. Jej implementacja została natomiast przeniesiona do klasy dziedziczącej TMainRepository.
function TMainRepository.CreateDML: ErrorId; var err: ErrorId; begin err := ERR_OK; try err := CreateDictionaries; err := CreateStations; except on E: Exception do begin LogException(EmptyStr, ClassName, 'CreateDML', E); err := ERR_DB_CREATE_DML_ERROR; end; end; Result := err; end; function TMainRepository.CreateStations: ErrorId; var err: ErrorId; stationId: integer; begin err := ERR_OK; // Stations err := StationRepo.AddStation('Radio Kaszebe', 'http://stream3.nadaje.com:8048', EMPTY_STR, 'http://radiokaszebe.pl/', 'Pop', 'PL', stationId); err := StationRepo.AddStation('Radio Malbork', 'http://78.46.246.97:9022', EMPTY_STR, 'https://www.radiomalbork.fm/', 'Pop', 'PL', stationId); err := StationRepo.AddStation('Planeta RnB', 'http://plarnb-01.cdn.eurozet.pl:8216/', EMPTY_STR, 'https://www.planetafm.pl/', 'RnBSoul', 'PL', stationId); Result := err; end; function TStationRepository.AddStation(const StationName: string; const StreamUrl: string; const Description: string; const WebpageUrl: string; const GenreCode: string; const CountryCode: string; out StationId: integer): ErrorId; var query: TZQuery; err: ErrorId; dateNow: integer; begin err := ERR_OK; try StationId := TRepository.GetNewDbTableKey(DB_TABLE_STATIONS); dateNow := GetUnixTimestamp(); query := TZQuery.Create(nil); try query.Connection := TRepository.GetDbConnection; query.SQL.Add( 'INSERT INTO ' + DB_TABLE_STATIONS + ' (ID, Name, StreamUrl, Description, WebpageUrl, GenreCode, CountryCode, Created, Modified) ' + 'VALUES(:ID,:Name,:StreamUrl,:Description,:WebpageUrl,:GenreCode,:CountryCode,:Created,:Modified);' ); query.Params.ParamByName('ID').AsInteger := StationId; query.Params.ParamByName('Name').AsString := StationName; query.Params.ParamByName('StreamUrl').AsString := StreamUrl; if (Description <> EMPTY_STR) then query.Params.ParamByName('Description').AsString := Description; if (WebpageUrl <> EMPTY_STR) then query.Params.ParamByName('WebpageUrl').AsString := WebpageUrl; if (GenreCode <> EMPTY_STR) then query.Params.ParamByName('GenreCode').AsString := GenreCode; if (CountryCode <> EMPTY_STR) then query.Params.ParamByName('CountryCode').AsString := CountryCode; query.Params.ParamByName('Created').AsInteger := dateNow; query.Params.ParamByName('Modified').AsInteger := dateNow; query.ExecSQL; finally query.Free; end; except on E: Exception do begin LogException(EMPTY_STR, ClassName, 'AddDatabaseStation', E); err := ERR_DB_ADD_STATION; end; end; Result := err; end;
W powyższym kodzie widać, że powstało dodatkowe repozytorium o nazwie StationRepo. W taki sposób odseparowaliśmy operacje związane z dostępem do danych stacji. Istnieje również kolejne repozytorium o nazwie DictionaryRepo, które agreguje operacje słownikowe.
Pobieranie danych
Wszystkie operacje na danych zapisanych w bazie danych wykonujemy przy użyciu TZQuery, więc aby pobrać dane wystarczy wykonać komendę SELECT.
Pobranie pojedynczej wartości na przykład maksymalnego ID tabeli może wyglądać mniej więcej tak:
query := TZQuery.Create(nil); try query.Connection := FConnection; // sql query query.SQL.Add('SELECT MAX(ID) FROM ' + TableName + ';'); query.Open; if query.RecordCount = 1 then Result := query.Fields[0].AsInteger; finally query.Free; end;
Pobranie wielu rekordów wygląda podobnie, jedyną różnicą jest konieczność iteracji po obiekcie query:
query := TZQuery.Create(nil); try query.Connection := TRepository.GetDbConnection; query.SQL.Add( 'SELECT ' + ' S.ID, S.Name, S.GenreCode, DRG.Text AS GenreText, S.CountryCode, DRC.Text AS CountryText ' + 'FROM ' + DB_TABLE_STATIONS + ' S ' + 'WHERE S.Name LIKE :StationName;' ); query.ParamByName('StationName').AsString := '%radio%'; query.Open; while not query.EOF do begin myId := query.FieldByName('ID').AsInteger, myName := query.FieldByName('Name').AsString, myGenre := query.FieldByName('GenreText').AsString, myCountry := query.FieldByName('CountryText').AsString query.Next; end; finally query.Free; end;
Podsumowanie
Cały proces zarządzania bazą danych został rozbity na repozytoria. Głównym jest BaseRepository. Jest to klasa abstrakcyjna więc nie możemy utworzyć jej instancji. Jej zadaniem jest ustanowienie połączenia z bazą danych, utworzenie schematu dla nowych baz oraz podpięcie pozostałych repozytoriów takich jak StationRepo oraz DictionaryRepo.
Z BaseRepository dziedziczy MainRepository. Jest to repozytorium, do którego podpięty jest główny plik bazy danych i to właśnie przez te repozytorium wykonujemy wszystkie operacje na bazie danych.
Dodatkowo tworzymy repozytorium Repository, które jest wrapperem dla pozostałych repozytoriów. Tutaj tworzymy instancję MainRepository oraz mapujemy wszystkie operacje bazodanowe, do których powinny mieć dostęp inne obszary aplikacji.
Kod aplikacji dostępny jest na GitHubie. Jeżeli interesują Cię zmiany dotyczące tylko tego wpisu to znajdziesz je tu.
- Tiny Radio Player #01 – Wprowadzenie
- Tiny Radio Player #02 – Instalacja komponentów
- Tiny Radio Player #03 – Roboczy interfejs aplikacji
- Tiny Radio Player #04 – Budujemy silnik
- Tiny Radio Player #05 – Zapis ustawień aplikacji
- Tiny Radio Player #06 – Zmiana języka aplikacji
- Tiny Radio Player #07 – Logowanie błędów
- Jesteś tu => Tiny Radio Player #08 – Baza danych
- Tiny Radio Player #09 – Zarządzanie bazą SQLite
- Tiny Radio Player #10 – Lista stacji radiowych, konfiguracja VirtualTreeView