Spis treści:

Kategoria:C#


Opóźnione wczytywanie danych w C#

Postawienie problemu

Ci, którzy mają do czynienia z dużymi bazami danych, wywołaniami sieciowymi i innymi, potencjalnie czasochłonnymi operacjami, z pewnością zetknęli się z problemem wydajności. Pewne klasy, struktury może i są duże, ale nie zawsze wszystkie ich pola i właściwości są wykorzystywane. Sprawia to, że u niektórych zapala się lampka! A gdyby tak załadować te dane dopiero wtedy, gdy rzeczywiście będą potrzebne? Z jednej strony otrzymujemy możliwość użycia jednej, ogólnej klasy w dwóch różnych miejscach, a z drugiej, nie ponosimy prawie żadnych kosztów jeżeli pewnej wartości nigdy nie wykorzystujemy.

Tego typu rozwiązania, czyli wykorzystywanie opóźnionego ładowania, są wykorzystywane w wielu ogólnie dostępnych platformach. Jest to dość mocno wykorzystywana technika między innymi w obiektowych nakładkach na bazy danych (ORM). Taki pierwszy z brzegu nHibernate tworzy obiekty, które reprezentują rekordy w bazie, przy okazji tworząc obiekty dla rekordów pozostających w relacji. Przykład: Obiekt osoba zawiera obiekt adresu lub obiekty adresów. Struktura klasy C# jest spójna, bo rzeczywiście - osoba ma adres. Czy jednak zawsze nam ten adres potrzebny? Czy zawsze musimy wykonywać dodatkowe operacje na bazie danych jeżeli potrzebne nam tylko dane osoby? Można tak skonfigurować mapowania, aby adres doczytywany był dopiero w momencie odwołania.

Zdarza się też, że korzystamy z pewnego obiektu globalnego, zwanego czasem singletonem, lub kilku takich obiektów. Czy zawsze musimy go ustawiać jako pole statyczne uzupełniane w momencie tworzenia statycznej klasy przez .NET? Tutaj też można zastosować ładowanie dopiero w momencie odwołania. Jeżeli nikt nigdy nie użyje naszego pola, żadne operacje nie będą wykonywane. Co więcej - każde z pól statycznych może być przygotowane oddzielnie.

To nie jest tak, że opóźnione ładowanie jest rozwiązaniem każdego problemu wydajności przy pobieraniu danych. Opóźnione ładowanie może rozdzielać kod ładowania złożonych struktur, co może mieć wpływ na czytelność kodu. Zawsze lepiej mieć powiązane operacje w jednym miejscu. Po drugie, być może niektóre z pól, wymagają kontekstu. Tak jest w przypadku wspomnianego wcześniej nHibernate. W ramach połączenia z bazą danych opóźnione ładowanie działa znakomicie. Jeżeli jednak obiekt wyjdzie poza kontekst bazy danych i wtedy odwołamy się do nich po raz pierwszy, wykonana zostanie fizyczna próba odczytu, która zakończy się niepowodzeniem. Jeżeli obiekt był już wcześniej wczytany, wszystko będzie działało w porządku.

Opóźnione ładowanie wprowadza też niewielką złożoność do kodu programu - jak się zaraz okaże, przy odrobinie pracy, niewielką.

Przykładowa implementacja klasy ogólnej

Mógłbym zacząć od prostego przykładu opóźnionego ładowania, ale uznałem, że nie ma takiej potrzeby. Od razu przedstawię klasę ogólną, nadającą się do wykorzystania z dowolnym typem. Pójdę nawet o krok dalej - klasa będzie posiadała mechanizm synchronizacji, na wypadek próby dostępu przez dwa różne wątki. Teoretycznie nic się nie powinno wydarzyć, bo w najgorszym przypadku dane byłyby wczytane tyle razy, ile wątków próbowałoby się odwołać do niezainicjalizowanej wartości. Jeżeli nakład pracy nie jest duży i nie ma to wpływu na wydajność, można klasę wyposażyć w taki miły dodatek.

Przyjrzyjmy się zatem przykładowej implementacji pokazanej poniżej:

public class Deferred<T>
{
  private Func<T> lookupFunc;
  private bool isInitialized;
  private T value;

  public Deferred(Func<T> lookupFunc)
  {
    this.lookupFunc = lookupFunc;
  }

  public T Value
  {
    get
    {
      // jeżeli obiekt jest już wczytany, zwróć go
      if (isInitialized)
      {
        return value;
      }
      else
      {
        // zablokuj dostęp dla innych wątków
        lock (this)
        {
          // jeżeli inny wątek w międzyczasie wczytał dane
          if (!isInitialized)
          {
            value = lookupFunc();
            isInitialized = true;
          }
        }
        return value;
      }
    }
  }
}

Kod nie jest długi, ale kilka elementów wymaga wyjaśnień. Parametr konstruktora jest kluczem - to metoda, która powinna się wykonać w momencie pierwszego odwołania i zwrócić pożądaną wartość. Może to być dowolne wyrażenie lambda, co pozwala tworzyć drzewa wyrażeń, czy przekazywać parametry dostępne lokalnie w miejscu wywoływania konstruktora. Daje to ogromną swobodę, a dla większości zastosowań, jeżeli nie wykorzystujemy zaawansowanych właściwości wyrażeń, nie jest trudne i skomplikowane.

Pole isInitialized określa stan klasy: jeżeli dane są załadowane, wtedy wartość jest równa true, a same dane zapamiętane są w polu value. W przeciwnym razie dane należy pobrać, co jest zabezpieczone przed próbą jednoczesnego dostępu przez wątki w sekcji lock. Dane wczytywane są przez... wywołanie metody lambda przekazanej w konstruktorze. Na końcu ustawiamy isInitialized i kończymy zabawę.

Jak działa blokowanie wątków? Gdy pierwszy z wątków wejdzie do sekcji lock, wszystkie pozostałe czekają na zwolnienie blokady, czyli zakończenie pracy przez wątek zajmujący blokadę. W tym momencie isInitialized będzie ustawione na true. Sprawi to, że pozostałe ominą wnętrze instrukcji warunkowej odpowiedzialnej za czasochłonne wczytywanie danych.

Tak przygotowaną klasę można już wykorzystać w kodzie.

Sposób użycia opóźnionego wczytywania

Przyjrzyjmy się poniższemy fragmentowi, w którym pokazany jest sposób wykorzystania klasy:

// deklaracja zmiennej z opóźnionym wczytywaniem
Deferred<DateTime> deferred = new Deferred<DateTime>(() => DateTime.Now);

// Tutaj dane zostaną wczytane
Console.WriteLine(deferred.Value);

// Tutaj mamy gotową wartość, żadna operacja nie będzie wykonywana
Console.WriteLine(deferred.Value);

Fragment jest opisany, więc nie wymaga chyba dodatkowych wyjaśnień.

Opóźnione ładowanie jako pole klasy

Pokazany powyżej przykład jest solą klasy Deferred. Taki klocek może być częścią dowolnej klasy jako pole Deferred, ale może też być obudowany właściwością. Miejmy świadomość, że dostęp do wartości wczytywanej w momencie wywołania realizowany jest przez właściwość Value. Właściwość w klasie będzie zatem miała wydłużony zapis, czyli ObiektKlasy.Właściwość.Value zamiast spodziewanego ObiektKlasy.Właściwość. Przyjrzyjmy się przykladowemu rozwiązaniu, które ukrywa szczegół implementacyjny opóźnionego ładowania:

public class SampleItem
{
  private Deferred<DateTime> data = new Deferred<DateTime>(() => DateTime.Now);
  public DateTime Data { get { return data.Value; } }
}

// ... gdzieś w kodzie

// Nie wiemy, że w środku jest opóźnione ładowanie
SampleItem item = new SampleItem();

// Tutaj dane zostaną wczytane
Console.WriteLine(item.Data);

// Tutaj mamy gotową wartość, żadna operacja nie będzie wykonywana
Console.WriteLine(item.Data);

W pokazanym przykładzie wszystkie szczegóły implementacyjne ukryte są wewnątrz klasy. Jest to zalecane postępowanie z punktu widzenia programowania obiektowego. Użytkownik korzystający z klasy SampleItem nie jest świadomy, że w środku zastosowaliśmy trik z opóźnionym ładowaniem - a to tylko dwie linijki kodu, co jest niewiele większym wysiłkiem niż napisanie zwykłej właściwości.

Użycie zmiennej lokalnej klasy

Klasa Deferred ma szerokie możliwości i nie jestem w stanie ich wszystkich tutaj przedstawić. Zdarza się jednak dość często, że funkcja pobierająca dane wymaga pewnych argumentów. Jak sobie z tym poradzić? Przyjrzyjmy się jeszcze jednemu przykładowi:

public class User
{
  public int ID { getset; }
  private Deferred<string> deferredDescription;
  public string Description { get { return deferredDescription.Value; } }

  public User(int id)
  {
    this.ID = id;
    deferredDescription = new Deferred<string>(() => GetDescription(id));
  }

  // Długotrwała operacja wczytania danych
  private string GetDescription(int id)
  {
    return string.Format("Tajne dane użytkownika {0}.", id);
  }
}

// ... gdzieś w kodzie

// Nie wiemy, że w środku jest opóźnione ładowanie
User user = new User(4);

// Tutaj dane zostaną wczytane
Console.WriteLine(user.Description);

// Tutaj mamy gotową wartość, żadna operacja nie będzie wykonywana
Console.WriteLine(user.Description);

Typ razem przykład jest bardziej rzeczywisty. Przypuśćmy, że mamy klasę użytkownika, który posiada identyfikator i być może inne zwykłe pola (pominięte ze względu na czytelność) oraz właściwość wczytywaną z opóźnieniem. Dane dodatkowe (opóźnione) potrzebują identyfikatora, więc należy te dane jakoś przekazać. Jedną z możliwości jest przekazanie parametru wprost do wyrażenia lambda, tak jak w powyższym przykładzie. Ja to zrobiłem w konstruktorze, ale równie dobrze można to zrobić poczas ustawiania właściwości ID (metoda set właściwości ID).

Jeżeli parametrów jest dużo to można pójść jeszcze dalej i przekazać do wyrażenia lambda wskaźnik do całej klasy z parametrami, a być może wskaźnik do samej siebie, czyli this.

Wnioski

Opóźnione ładowanie ma dużo zastosowań i wykraczają one poza ten wpis. Być może jeszcze do tego tematu wrócę, przy okazji innego. Z innych ciekawych zastosowań można wspomnieć pamięć podręczną ostatniej wartości (cache). Przypuśćmy, że potrzebujemy informacji o pewnym obiekcie na podstawie identyfikatora. Ustawienie go na konkretną, nową wartość sprawia, że tworzony jest nowy obiekt Deferred dla tego identyfikatora, a pierwsze odwołanie do wartości pobiera ją. Dopóki nie zmieniamy identyfikatora, pole Deferred trzyma wartość. Zmiana identyfikatora znów tworzy nowy obiekt Deferred, tym razem z innym parametrem. Poprzedni traci ważność, bo ginie, a nowy obiekt Deferred jest w stanie niezainicjalizowanym. Znów pierwsze pobranie wartości ustawia pole, które będzie dostępne dopóki po raz kolejny tego identyfikatora nie zmienimy. Ot taki prosty cache.

Stosowanie opóźnionego wczytywania nie powinno być nadużywane. Kiedy warto zastosować, to najlepiej ocenić samemu na podstawie własnego, konkretnego przypadku.

Kategoria:C#

, 2013-12-20

Brak komentarzy - bądź pierwszy

Dodaj komentarz
Wyślij
Ostatnie komentarze
Dzieki za rozjasnienie zagadnienia upsert. wlasnie sie ucze programowania :).
Co się stanie gdy spróbuję wyszukać:
SELECT * FROM NV_Airport WHERE Code='SVO'
SELECT * FROM V_Airport WHERE Code=N'SVO'
(odwrotnie są te N-ki)
Będzie konwersja czy nie znajdzie żadnego rekordu?