Wzorzec projektowy Object Pool - okładka

Wzorzec projektowy Object Pool

Opublikowano Kategorie Czysty kodCzas czytania 8min

Object Pool to wzorzec projektowy, który pomoże Ci ograniczyć konstruowanie kosztownych w tworzeniu lub utrzymaniu obiektów. Będzie przydatny również przy limitowaniu liczby obiektów w pamięci. Może to być przydatne w obsłużeniu ograniczeń technologicznych np. maksymalnej liczby połączeń, jakie może obsłużyć baza danych. W tym artykule poznasz specyfikę wzorca Object Pool oraz dowiesz się, kiedy warto go wykorzystać. Przykłady kodu w artykule przygotowane zostały w TypeScripcie.

Ten wpis jest kolejnym wpisem z serii o wzorcach projektowych. Jeśli chcesz poznać inne wzorce projektowe lub dowiedzieć się czym są wzorce projektowe, to koniecznie sprawdź mój wpis o wzorcach projektowych.

Object Pool w teorii

Idea wzorca polega na stworzeniu puli obiektów zawierającej obiekty, wykorzystaniu ich na żądanie i zwrócenie do puli, gdy już ich nie potrzebujemy. Dzięki temu obiekty mogą być wielokrotnie wykorzystywane zamiast tworzone i niszczone za każdym razem. Elementami wzorca są:

  • Pula obiektów – przechowuje wcześniej utworzone obiekty. Do przechowywania obiektów można wykorzystać np. kolejkę czy stos;
  • Obiekty przechowywane w puli;
  • Klienty korzystające z obiektów z puli.

Diagram klas przedstawiający strukturę wzorca Object Pool

Z punktu widzenia działania wzorca najistotniejsze są składowe klasy ObjectPool, czyli metody pozwalające na pobranie obiektów z puli i ich zwrócenie. W podanym przykładzie są to metody acquire()release(). Ważne jest również ustawienie limitu obiektów w puli. Rozmiar puli można ustawić na sztywno, ale można też pokusić się o dynamiczne szacowanie tej wartości w zależności od np. dostępnej pamięci RAM.

Oprócz ustalenia limitu obiektów należy również zastanowić się, jak chcemy obsłużyć sytuację, gdy pula jest pusta. Do dyspozycji mamy kilka strategii:

  • Tworzenie nowych obiektów nawet ponad zdefiniowany rozmiar puli. Takie podejście może być przydatne w aplikacjach, gdzie elastyczność jest ważniejsza niż kontrola nad liczbą obiektów;
  • Oczekiwanie przez klienta na zwrot któregoś z elementów do puli;
  • Rzucenie wyjątkiem informującym o braku obiektów w puli.

Również samo tworzenie obiektów w puli może przebiegać na kilka sposobów:

  • Cała pula obiektów może być tworzona wraz z powstaniem obiektu puli;
  • Obiekty możemy tworzyć na żądanie. Wtedy pula zapełnia się stopniowo wraz ze wzrostem zapotrzebowania na obiekty z puli aż do wypełnienia limitu.

Wzorzec znajduje zastosowanie w grach jako sposób na optymalizację przez ponowne użycie obiektów już istniejących w pamięci. Innym dość popularnym miejscem wykorzystania wzorca są aplikacje wykorzystujące połączenia ze światem zewnętrznym. Może to być np. wykorzystanie protokołu HTTP z wykorzystaniem nagłówka Keep-Alive czy łączenie się z bazami danych.

Object Pool a Pyłek

Zasada działania wzorca Object Pool może wydawać się zbliżona do zasady działania wzorca Pyłek. Różnice między nimi są następujące:

  • Celem Object Pool jest ponowne użycie wcześniej utworzonych obiektów, aby ograniczyć ich kosztowne tworzenie i niszczenie natomiast celem Pyłka jest zaoszczędzenie pamięci przez współdzielenie danych/obiektów;
  • Object Pool działa na zasadzie „wypożyczania” obiektów z puli, gdzie używany obiekt jest niedostępny dla innych wypożyczających oraz zwracany po użyciu. Pyłek minimalizuje zużycie pamięci przez wielokrotne wykorzystanie tych samych obiektów.
  • Obiekty „wypożyczane” z puli mogą być mutowalne, natomiast Pyłki powinny być niemutowalne.

Praktyczny przykład

W wielu przypadkach nie będzie potrzeby implementowania wzorca samodzielnie. Gotowe implementacje można znaleźć na przykład w ASP.NET Core, Unity, AWS SDK czy driverze dla MySQL w Node.js.

Jednak nie zawsze będzie możliwość skorzystania z gotowca. Wtedy warto wiedzieć jak zaimplementować omawiany wzorzec samodzielnie. Do praktycznego przedstawienia wzorca przygotowałem fragment kodu zarządzający pulą połączeń do bazy danych. Implementacja składa się z dwóch klas — klasy MySqlConnection definiującą połączenie z bazą oraz klasy ConnectionPool. W klasie połączenia dodałem mock metody query() i mock stanu połączenia. W realnym zastosowaniu klasa implementowałaby całą logikę komunikacji z bazą danych. Przy omawianiu wzorca jest to nieistotne.

Najważniejsza z punktu widzenia działania wzorca jest logika w klasie ConnectionPool. Zdecydowałem się na wariant z natychmiastowym wypełnieniem puli oraz rzuceniem wyjątku, gdy pula jest pusta.


class MySqlConnection {
  private readonly _state: string = 'active';

  public get state(): string {
    return this._state;
  }

  public query<T>( value: string ): T {
    // execute query

    return [] as T;
  }
}

class ConnectionPool {
  private _pool: MySqlConnection[] = [];

  public constructor( private readonly _maxSize: number = 10 ) {
    for (let i = 0; i < this._maxSize; i++) {
      this._pool.push( this._createConnection() );
    }
  }

  public acquire(): MySqlConnection {
    if ( this._pool.length ) {
      return this._pool.pop()!;
    }

    throw new Error( 'No connections available!' );
  }

  public release( obj: MySqlConnection ): void {
    if (this._pool.length >= this._maxSize) {
      throw new Error( 'The pool is full!' );
    }

    this._pool.push( obj );
  }

  private _createConnection(): MySqlConnection {
    return new MySqlConnection();
  }
}

const pool = new ConnectionPool();

const conn = pool.acquire();
console.log( conn.state );

pool.release( conn );

Problemy w pracy z Object Pool

Analiza kodu pozwala znaleźć pierwsze problemy mogące się pojawić przy wykorzystaniu Object Pool.

Pierwszy problem to konieczność pamiętania o zwrocie połączenia do puli. Rozwiązania tego problemu widzę dwa. Pierwsze to tworzenie nowego elementu puli, jeśli wypożyczony element przez jakiś czas do niej nie wrócił. Wada tego rozwiązania to ryzyko wycieku pamięci, jeśli niezwrócone połączenia nadal będą znajdować się w pamięci. Drugim rozwiązaniem może być czasowe wypożyczenie. Jeśli element nie zostanie zwrócony w określonym czasie, to wraca do puli automatycznie. Wtedy pula musiałaby przechowywać referencje do wszystkich elementów. Najlepszym rozwiązaniem byłoby zawsze zwracać elementy do puli, jednak warto pomyśleć o zabezpieczeniu się, gdyby w jakimś miejscu w kodzie o tym zapomniano. Nie mamy gwarancji, że taki przypadek zostanie przeoczony w trakcie implementacji lub podczas code review.

Drugi problem, jaki widzę w tym konkretnym przykładzie to możliwość korzystania z elementu z puli nawet po jego zwróceniu. Zmienna conn cały czas przechowuje referencję do elementu z puli. Analizując problem, rozważałem trzymanie informacji czy połączenie jest w puli w samym połączeniu. Niestety obiekt musiałby wtedy pozwalać ustawiać tę wartość z zewnątrz, by pula mogła ją ustawiać. Oznacza to, że równie dobrze mógłby to robić klient korzystający z obiektu puli i obejść zabezpieczenie.

Moim zdaniem sensowniejszą alternatywą jest opakowanie połączenia w dodatkową warstwę abstrakcji przy pobieraniu z puli i rozpakowywanie przez zwracaniu. Wtedy, jeśli odwołamy się do zmiennej conn po zwrocie do puli, to rzucony zostanie błąd. ConnectionPool musiałby wtedy zwracać obiekt nowej klasy i przy zwrocie rozpakować go i wywołać na nim metodę release(). Implementacja mogłaby wyglądać jak na poniższym przykładzie.


class Connection {
  private _connection: MySqlConnection | null;

  public constructor( connection: MySqlConnection ) {
      this._connection = connection;
  }

  public query( value: string ): T {
    if ( !this._connection ) {
      throw new Error( 'Connection already released!' );
    }

    return this._connection.query( value );
  }

  public release(): MySqlConnection {
    const connection = this._connection;

    if ( !connection ) {
      throw new Error( 'Connection already released!' );
    }

    this._connection = null;

    return connection;
  }
}

Dalej widzę tu problem z możliwością wywołania metody release() z poziomu klienta. Uważam to jednak za mniejszy problem niż ten pierwotny.

Trzeci problem to ryzyko oddania zepsutego lub zmodyfikowanego obiektu. Taki przypadek również należałoby odpowiednio obsłużyć. Można próbować naprawić taki obiekt lub przywrócić do ustawień domyślnych. Można również odrzucać uszkodzone elementy i zastępować je nowymi co jednak nieco zaburza sens wykorzystania wzorca. Intencją wzorca jest ograniczenie tworzenia kosztownych obiektów.

Podsumowanie

Jeśli nie miałeś/aś jeszcze okazji wykorzystać wzorca Object Pool, to zachęcam, by przetestować go w praktyce. Zastosowanie dla wzorca Object Pool znaleźć jest nietrudno. Warto jednak pamiętać o potencjalnych trudnościach, jakie można napotkać przy jego wykorzystaniu.

Z kolei, jeśli już znałeś/aś omawiany wzorzec, to zachęcam do podzielenia się swoimi doświadczeniami w komentarzu.

Jeśli interesują Cię inne wzorce projektowe, to przygotowałem całą serię artykułów, gdzie omawiam wzorce opisane m.in. w Design Patterns: Elements of Reusable Object-Oriented Software autorstwa Bandy Czworga. Książkę możesz kupić, klikając TEN link. Kupując z tego linku, wspierasz rozwój bloga.

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Powiadom o
guest

0 komentarzy
oceniany
najnowszy najstarszy
Inline Feedbacks
View all comments