Biblioteka konfiguracji ConfigLib

Projekt zrealizowany na studiach w ramach przedmiotu Techniki Kompilacji. Jest to prosta, a zarazem bardzo przydatna biblioteka pozwalająca wczytywać i zapisywać konfiguracje programu do pliku. Jeśli potrzebujesz szybko dodać plik konfiguracyjny do swojego projektu możesz być zainteresowany tą biblioteką :)

25.08.2017 00:00 ConfigLib

ConfigLib jest to prosta biblioteka pozwalająca wczytywać i zapisywać konfiguracje aplikacji. Projekt został stworzony na potrzeby przedmiotu Techniki Kompilacji, ale jest to bardzo przydatna biblioteka, którą można wykorzystać praktycznie w każdym projekcie, który może wymagać jakichś konfiguracji. Jeśli nie chcesz testować różnych opcji za każdym razem kompilując swój program od nowa, ta biblioteka z pewnością może Ci pomóc.

Biblioteka używa pewnego z góry ustalonego formatu plików tekstowych, aby odczytywać dane. Sam format jest raczej dosyć prosty, więc nauczenie się jego reguł nie powinno stanowić większego problemu. Jedynym minusem jest, że na użytkowniku spoczywa odpowiedzialność zapisania danych w dobrym formacie, dlatego jego znajomość jest wymagana kiedy chcemy korzystać z opcji zapisu. Nie jest to jednak wielki minus, a jeśli przeczytasz dalszy opis z pewnością nie będziesz miał kłopotów z wykorzystaniem funkcji tej biblioteki, a ona choć prosta, naprawdę może przyspieszyć pracę nad projektem :)

Tak jak już wspomniałem, format pliku jest z góry określony. Dlatego właśnie istotne jest, abym wytłumaczył jak on wygląda, a także jakie konstrukcje są dozwolone, a jakie nie. Biblioteka zapisuje konfiguracje do plików tekstowych, ponieważ głównym jej celem było ułatwienie edycji pewnych parametrów programu, bez ponownej kompilacji. Gdyby plik był np. binarny, a nie tekstowy, nadal edycja parametrów byłaby raczej bardzo uciążliwa, dlatego nikogo nie powinien chyba dziwić wybór akurat pliku tekstowego. Myślę, że na początek najlepiej będzie jeśli przedstawię krótki, poprawnie sformatowany plik, aby mniej więcej było wiadomo jak on w ogóle wygląda, a następnie częściowo na jego przykładzie, wytłumaczę jakie są możliwości tego formatu.

Poprawnie sformatowany plik wygląda np. tak:

#Main app config
[MainConfig]
#Resolution width
Res_X: 800;
#Resolution height
Res_Y: 600;
#Fullscreen mode (0 - disabled, 1 - enabled)
Fullscreen: 0;
#Vertical synchronization (0 - disabled, 1 - enabled)
VSync: 1;
[End MainConfig]

Jak widać, plik może być podzielony na wiele sekcji. Każda sekcja zaczyna się od swojej nazwy ujętej w nawiasy kwadratowe. Koniec sekcji zaznaczony jest słowem kluczowym End i nazwą sekcji, a wszystko znowu w nawiasach kwadratowych. W rzeczywistości te właśnie sekcje później są rzutowane przez bibliotekę na obiekty określonych klas w programie. Aby zwiększyć użyteczność pliku i ułatwić edycje osobom, które mogą nie wiedzieć który parametr odpowiada za co, w pliku można używać komentarzy. Każda linia komentarza musi zaczynać się znakiem hash-a (#). Wewnątrz sekcji znajdują się atrybuty. Każdy atrybut opisany jest poprzez identyfikator i wartość. W powyższym przykładzie mamy np. atrybut o identyfikatorze Res_X i wartości 800. Te atrybuty są odczytywane przez bibliotekę i można następnie przypisywać ich wartości do pól obiektu. O to, jak dokładnie to wygląda na razie się nie martw. Później wszystko dokładnie opiszę. Na razie chciałbym skupić się na samym formacie pliku.

Jak już wiesz, wszystko w pliku konfiguracyjnym opiera się na sekcjach (obiektach klas w programie) i ich atrybutach (polach obiektów klas w programie). Teraz zatem możemy bliżej przyjrzeć się jakie wartości mogą przyjmować te atrybuty. Pierwszy przykład, był mało interesujący i nie pokazywał pełnego potencjału tego formatu. Wartościami atrybutów nie muszą być tylko liczby całkowite. Zilustruje to kolejny przykład:

#Sekcja opcji
[AppOptions]
#Wartością atrybutu może być napis
Title: "My title";
#Wartością atrybutu może być liczba całkowita
Health: 15;
#Wartością atrybutu może być liczba rzeczywista
Scale: 0.25;
#Wartością atrybutu może być ciąg wartości
color: (0, 255, 0, 0);
#wartością atrybutu może być obiekt (kolejna sekcja)
AnotherOptions:
[Options2]
Field1: 0;
Field2: 1;
Field3: 0.5;
[End Options2];
#Wartością ciągu także mogą być wszystkie z wymienionych wartości
Array: (12, 15.34, "My title", [Battery] Power: 95.3; [End Battery], (1, 2, "Wolf", 5.4, [Mouse] Size: 0.8; [End Mouse]));
[End AppOptions]

Jak widać wartością atrybutu może być napis (niestety tylko ASCII), liczba całkowita lub rzeczywista, a także ciąg wartości lub nawet kolejny obiekt! Co więcej wszystkie te wartości mogą być także wartością ciągu. To wszystko sprawia, że format choć niezbyt skomplikowany daje jednak spore możliwości. Z odrobiną chęci można z niego skorzystać także do tworzenia dynamicznych obiektów, czy do innych celów.

Teraz, kiedy format pliku jest już znany możemy przejść do tego jak można skorzystać z biblioteki w swoim projekcie. W końcu sam plik, plikiem, ale ostatecznie chcemy mieć z niego jakiś pożytek w naszej aplikacji, a żeby to osiągnąć potrzebny jest jakiś kod :) Otóż już wszystko tłumaczę. Ważnym elementem biblioteki są dwa interfejsy z których trzeba skorzystać, aby biblioteka wiedziała jak ma działać. Jeden z nich to interfejs ISerializable. Jest dostępny po dołączeniu pliku nagłówkowego ISerializable.h w przestrzeni nazw ConfigLib (wszystkie rzeczy z biblioteki są w przestrzeni nazw ConfigLib). Każdy obiekt, który chcemy wczytywać lub zapisywać przy pomocy biblioteki powinien dziedziczyć z tej klasy. Zawiera ona tylko jedną funkcję, która wywoływana jest przy zapisywaniu obiektu do pliku konfiguracyjnego. Funkcja, którą należy zaimplementować w naszej klasie to:

virtual std::string serialize() = 0;

Funkcja ta powinna zwracać obiekt naszej klasy w postaci napisu, który reprezentuje zapis obiektu naszej klasy w formacie pliku konfiguracyjnego. Ponieważ moje tłumaczenie może nie być dostatecznie jasne najlepiej będzie pokazać przykład:

//Plik MyObj.h
#include <ISerializable.h> //Nie zapomnijmy dołączyć nagłówka

class MyObj : public ConfigLib::ISerializable //Dziedziczymy po ISerializable (implementujemy interfejs)
{
public:
  //Tutaj akurat nie musimy nic robić (w ramach przykładu)
  MyObj() {}
  ~MyObj() {}

  //Implementujemy funkcję serializującą
  std::string serialize()
  {
    std::string data;
    data += "[MyObj]\n";
    data += "a: " + std::to_string(a) + ";\n";
    data += "b: " + std::to_string(b) + ";\n";
    data += "name: \"" + name + "\";\n";
    data += "[End MyObj]\n";

    return data;
  }

public:
  //Jakieś pola
  int a;
  float b;
  std::string name;
};

No dobrze, zatem wiesz już jak stworzyć klasę, która będzie mogła zostać zapisana do pliku. W tym wypadku niestety sami musimy zrobić większość i wypisać po prostu w jaki sposób nasza klasa ma wyglądać w formacie pliku. Nadal pozostaje jednak jeszcze jedno pytanie. Jak biblioteka ma utworzyć nasz obiekt po wczytaniu go z pliku? Tutaj z pomocą przychodzi nam kolejny interfejs o którym wspomniałem już wcześniej (bo miały być dwa, a na razie podałem tylko jeden). Drugim ważnym interfejsem jest interfejs IObjectFactory. Jest on dostępny po dołączeniu pliku nagłówkowego IObjectFactory.h (także w przestrzeni nazw ConfigLib). Tym razem musimy napisać klasę, która będzie umiała utworzyć nasze obiekty. Ta klasa musi implementować interfejs IObjectFactory i jego funkcję:

virtual void* create(std::map<std::string, ConfigLib::Attrib>& ObjAttribs, std::string ObjType) = 0;

Funkcja ta ma na celu zwrócenie wskaźnika do obiektu, który właśnie utworzyliśmy. Możemy tworzyć różne obiekty, ponieważ dostarczony zostanie nam identyfikator ObjType. Jego wartość będzie to nazwa sekcji, którą wybraliśmy dla naszego obiektu. W przykładzie obiektu do serializacji użyliśmy sekcji MyObj, tak więc jeśli dostaniemy taką wartość jako ObjType możemy spodziewać się obiektu typu MyObj. Najlepiej wszystko zilustruje kolejny przykład:

//Plik MyObjFactory.h
#include <IObjectFactory.h> //Nie zapomnijmy dołączyć pliku nagłówkowego
#include <MyObj.h>  //Plik z naszą klasą której obiekty chcemy tworzyć

class MyObjFactory : public ConfigLib::IObjFactory //Dziedziczymy po IObjectFactory (implementujemy interfejs)
{
public:
  //Konstruktor i dekonstruktor nie muszą nic robić
  MyObjFactory() {}
  ~MyObjFactory() {}

  //Implementujemy interfejs
  void* create(std::map<std::string, ConfigLib::Attrib>& ObjAttribs, std::string ObjType)
  {
    //Jeśli to nasz obiekt
    if(ObjType == "MyObj")
    {
      MyObj* obj = new MyObj();  //Tworzymy obiekt naszego typu czyli MyObj w przykładzie
      //Wczytujemy wartości z dostarczonych atrybutów
      obj->a = ObjAttribs["a"].int_val;
      obj->b = (float)ObjAttribs["b"].double_val;
      obj->name = ObjAttribs["name"].str_val;

      //Zwracamy wskaźnik do gotowego obiektu
      return obj; 
    }

    //Jeśli nie rozpoznaliśmy typu obiektu zwracamy nullptr
    return nullptr;
  }
};

Zanim przejdziemy dalej, warto poświęcić chwilę na przeanalizowanie w jaki sposób atrybuty są przekazywane do naszej fabryki obiektów. Atrybuty przekazywane są zawsze w formie obiektów klasy Attrib. Klasa ta wygląda następująco:

class Attrib
{
public:
  Attrib() {}
  ~Attrib() {}

public:
  AttribType type;
  std::string str_val;
  int int_val;
  double double_val;
  void* ptr_val;
};

Klasa ta zawiera pola różnych typów tak, aby atrybut mógł mieć różne wartości. Dodatkowo zawarte jest pole type, które mówi nam o tym jaki typ aktualnie jest przechowywany w obiekcie Attrib. Możliwe wartości pola type to:

enum AttribType
{
  STRING = 0,
  INTEGER,
  DOUBLE,
  ARRAY,
  OBJECT
};

W fabryce obiektów atrybuty dostępne są poprzez mapę, która mapuje napisy na obiekty Attrib. Napisy to nazwy naszych atrybutów, a obiekty Attrib, jak już wiemy, pozwalają nam dostać się do wartości atrybutu. Tak więc, jeśli mamy atrybut o nazwie Res_X, aby odczytać jego wartość wystarczy użyć np. kodu:

int res_x = ObjAttribs["Res_X"].int_val;

Przed odczytaniem wartości warto jednak sprawdzić najpierw, czy jest to wartość jakiej się spodziewamy. Możemy zrobić to po prostu sprawdzając pole type naszego obiektu klasy Attrib na przykład w ten sposób:

if(ObjAttribs["Res_X"].type == ConfigLib::AttribType::INTEGER)
  res_x = ObjAttribs["Res_X"].int_val;

W ten sposób unikniemy próby przypisania do naszej zmiennej lub pola innej wartości niż oczekiwana.

Sprawdzanie poprawności otrzymanych wartości na pewno jest dobrym pomysłem, jednak ignorowanie sytuacji kiedy spodziewane dane różnią się od tych otrzymanych nie jest najlepsze. Jeśli użytkownik wprowadzi złe dane w pliku konfiguracyjnym nie będzie nawet wiedział dlaczego aplikacja zaczęła zachowywać się dziwnie lub po prostu przestała działać. Dlatego biblioteka zawiera także specjalną klasę do obsługi błędów, którą sama wykorzystuje, aby wyświetlić status wczytywania danych z pliku konfiguracyjnego. Obiekt klasy ErrorProvider, o której mowa, musi istnieć podczas działania biblioteki, dlatego nic nie stoi na przeszkodzie, aby wykorzystać go także w naszej fabryce obiektów. Aby skorzystać z funkcji jakie oferuje wspomniana klasa należy dołączyć nagłówek ErrorProvider.h. W samym wykorzystaniu biblioteki nie jest istotne jak dokładnie działa ta klasa. Tak naprawdę interesuje nas tylko jedna funkcja:

inline void error(ErrorType type, std::string message, SourcePos pos = SourcePos(), std::string sourceFile = std::string());

Ostatnie dwa argumenty tej funkcji nie są dla nas za bardzo istotne. Są raczej wykorzystywane podczas działania biblioteki, aby wskazać w którym miejscu w pliku wystąpił błąd. Jeśli zostawimy ich wartości domyślne, błąd zostanie uznany za nie związany ze źródłem danych i o to nam chodzi, ponieważ i tak nie wiadomo w którym miejscu został popełniony błąd. Pierwszy argument opisuje typ błędu. Dostępne typy to:

enum ErrorType
{
  ET_WARNING = 0,
  ET_ERROR,
  ET_FATAL_ERROR
};

Typ błędu zostaje po prostu wyświetlony przy jego opisie. Uzbrojeni w tą wiedzę, możemy teraz nieco poprawić kod naszej fabryki obiektów, aby była nieco bezpieczniejsza i informowała użytkownika o ewentualnym błędzie. Nowy kod wyglądałby tak:

//Plik MyObjFactory.h
#include <IObjectFactory.h> //Nie zapomnijmy dołączyć pliku nagłówkowego
#include <ErrorProvider.h>   //Obsługa błędów

class MyObjFactory : public ConfigLib::IObjFactory //Dziedziczymy po IObjectFactory (implementujemy interfejs)
{
public:
  //Konstruktor i dekonstruktor nie muszą nic robić
  MyObjFactory() {}
  ~MyObjFactory() {}

  //Implementujemy interfejs
  void* create(std::map<std::string, ConfigLib::Attrib>& ObjAttribs, std::string ObjType)
  {
    //Jeśli to nasz obiekt
    if(ObjType == "MyObj")
    {
      MyObj* obj = new MyObj();  //Tworzymy obiekt naszego typu czyli MyObj w przykładzie
      //Wczytujemy wartości z dostarczonych atrybutów sprawdzając czy to spodziewane wartości
      if(ObjAttribs["a"].type == ConfigLib::AttribType::INTEGER)
        obj->a = ObjAttribs["a"].int_val;
      else
      {
        error(ConfigLib::ErrorType::ERROR, "Wartosc atrybutu a powinna byc liczba calkowita!");
        delete obj;
        return nullptr;
      }
      if(ObjAttribs["b"].type == ConfigLib::AttribType::DOUBLE)
        obj->b = (float)ObjAttribs["b"].double_val;
      else
      {
        error(ConfigLib::ErrorType::ERROR, "Wartosc atrybutu b powinna byc liczba rzeczywista!");
        delete obj;
        return nullptr;
      }
      if(ObjAttribs["name"].type == ConfigLib::AttribType::STRING)
        obj->name = ObjAttribs["name"].str_val;
      else
      {
        error(ConfigLib::ErrorType::ERROR, "Wartosc atrybutu name powinna byc napisem!");
        delete obj;
        return nullptr;
      }

      //Zwracamy wskaźnik do gotowego obiektu
      return obj; 
    }

    //Jeśli nie rozpoznaliśmy typu obiektu informujemy o tym i zwracamy nullptr
    error(ConfigLib::ErrorType::ERROR, "Nie rozpoznano typu!");
    return nullptr;
  }
};

W ten sposób nasza fabryka obiektów, będzie o wiele bezpieczniejsza i użyteczna.

Ostatnia rzecz, związana z atrybutami to odczytywanie specjalnych wartości takich jak inne obiekty, lub ciągi. Specjalnie na tą okazję przewidziane jest pole ptr_val w klasie Attrib. Jeśli typem naszego atrybutu jest obiekt w polu ptr_val zapisany jest wskaźnik do naszego obiektu. Jeśli chcemy go wykorzystać możemy zrobić to np. w następujący sposób:

MyObj* obj = (MyObj*)ObjAttribs["Object"].ptr_val;

To samo pole ptr_val przechowuje ciągi. Jeśli typem naszego atrybutu jest ciąg wartości pole ptr_val przechowuje wskaźnik do tablicy obiektów Attrib. Dodatkowo, w tym wypadku pole int_val przechowuje długość danego ciągu. Poniżej możesz zobaczyć przykład odczytywania wartości z ciągu:

int elementsCount = ObjAttribs["Array"].int_val;
for(int i = 0; i < elementsCount; ++i)
{
  myTab[i] = ((ConfigLib::Attrib*)ObjAttribs["Array"].ptr_val)[i].int_val;
}

Oczywiście przykład ten pomija sprawdzanie typów i zakłada, że wszystkie wartości ciągu to liczby całkowite. Nie jest to najlepszy pomysł w rzeczywistym zastosowaniu, ale na potrzeby przykładu może być, ponieważ jest o wiele bardziej przejrzyste.

Uff, dobrnęliśmy zatem do końca wszystkich kroków potrzebny do przygotowania aplikacji, aby biblioteka mogła działać prawidłowo. Wydaje się, że było tego dosyć sporo i że nie jest to takie łatwe, ale to tylko pozory. Na początku trzeba zapoznać się ze sposobem działania biblioteki i formatem pliku, ale potem samo jej używanie jest o wiele łatwiejsze. Poza tym teraz został nam już tylko ostatni krok do wykorzystania biblioteki, a on jest już bardzo łatwy. Główną klasą biblioteki jest klasa CLib dostępna w pliku CLib.h. Obiekt tej klasy daje nam wszystkie funkcje biblioteki. Bez zbędnego przedłużania od razu przejdę do przykładu jak można z niego skorzystać, ponieważ to chyba najlepiej pozwoli zrozumieć działanie:

//Plik main.cpp
#include <MyObjFactory.h>  //MyObjFactory i MyObj
#include <CLib.h>  //CLib

int main()
{
  ErrorProvider ep;  //Ten obiekt musi istnieć, aby można było używać biblioteki
  MyObjFactory myFac;  //Nasza fabryka obiektów
  CLib configLoader("Config.txt", ConfigLib::OpenMode::NOT_KEEP, &myFac);  //Obiekt klasy CLib

  MyObj* obj;  //Obiekt, który chcemy wczytać z pliku konfiguracyjnego
  obj = (MyObj*)configLoader.read();

  /*Wykorzystujemy obiekt MyObj...*/

  //Zapisujemy zmodyfikowany obiekt
  configLoader.write(obj);

  //Musimy zniszczyć obiekt, ponieważ został utworzony dynamicznie!
  delete obj;

  return 0;
}

W ten sposób możemy wczytywać i zapisywać obiekty do plików. Biblioteka jest raczej przewidziana do tworzenia jednego obiektu głównego z pliku, który ewentualnie zawiera inne obiekty jako swoje pola, ale funkcja read() klasy CLib wczytuje jeden obiekt, pierwszy na który natrafi w pliku. Jeśli wywołamy ją po raz drugi powinna wczytać kolejny, a jeśli plik się skończy, funkcja zwróci wartość nullptr. Argumenty konstruktora obiektu klasy CLib są raczej proste do odgadnięcia z przykładu, ale na koniec wspomnę jeszcze tylko o drugim argumencie, a mianowicie argumencie openMode. Biblioteka może otwierać plik i zamykać wraz z każdym wywołaniem funkcji read() lub write(...) za co odpowiedzialny jest tryb ConfigLib::OpenMode::NOT_KEEP, lub otwierać plik tylko raz, a następnie zamykać go dopiero na żądanie za co odpowiada tryb ConfigLib::OpenMode::KEEP. Wybór trybu może mieć pewne znaczenie jeśli chodzi o wydajność. Jeśli zamierzamy odczytać tylko jeden obiekt z pliku, pierwszy tryb jest odpowiedni, jeśli natomiast chcemy czytać z pliku dużo różnych obiektów (lub zapisywać), drugi tryb może sprawdzić się lepiej.

Projekt został skompilowany za pomocą Visual Studio 2017 do postaci biblioteki statycznej. Do pobrania dostępny jest folder skompresowany .zip w którym można znaleźć pakiet zawierający wszystko potrzebne do użycia biblioteki w swoim własnym projekcie. Znajduje się tam folder główny z nazwą i wersją biblioteki. W nim znajdują się dwa kolejne foldery. Folder include zawiera wszystkie potrzebne pliki nagłówkowe, natomiast folder lib zawiera same pliki bibliotek statycznych. Pliki z przyrostkiem _d oznaczają, że jest to wersja do testów aplikacji (debug), pliki bez przyrostka są gotowe do użycia w końcowym projekcie (release). Pakiet zawiera pliki zarówno dla systemów 32 jak i 64-bitowych.

Jeśli używałeś w swoich projektach dodatkowych bibliotek z pewnością wszystkie opisane tutaj czynności będą Ci dobrze znane, a jeśli nie, tutaj znajdziesz wskazówki, jak sprawić żeby wszystko mogło działać prawidłowo. Zakładam, że podstawy korzystania ze środowiska Visual Studio są Ci znane, ja wytłumaczę tylko jak dodać bibliotekę (moją, albo dowolną inną). Najłatwiej będzie wytłumaczyć to w krokach:

  1. Otwórz menu ustawień projektu. W tym celu znajdź w pasku narzędzi w lewym górnym rogu okna zakładkę Project, rozwiń ją a następnie wybierz ostatnią opcję TestProject preferences... (TestProject zostanie zastąpione nazwą Twojego projektu)
  2. Z lewej strony okna znajdź zakładkę C/C++ a następnie rozwiń ją i wybierz general. Tam znajdź pole additional include directories i dodaj do niego lokalizację folderu include biblioteki.
  3. Przejdź do zakładki linker (rozwiń ją). Tam w zakładce general znajdź pole additional library directories i dodaj lokalizację folderu lib biblioteki (najlepiej folderu w którym już bezpośrednio są pliki biblioteki a więc dla platformy 64-bitowej np. lib/_x64)
  4. W zakładkach rozwiniętych z zakładki linker znajdź zakładkę input a następnie pole additional dependencies. Dodaj do niego nazwę biblioteki (np. ConfigLib.lib)
  5. Wciśnij przycisk Ok i gotowe! Możesz korzystać z właśnie dodanej biblioteki w swoim projekcie :)