Pełnomocnik to obiekt, który jest tworzony w miejsce pierwotnego obiektu. Najczęściej wykonuje on „brudną” robotę i angażuje obiekt pierwotny tylko wtedy, gdy jest rzeczywiście potrzebny. Jeśli takie pojęcia jak lazy initialization, pamięć podręczna czy walidacja nie są Ci obce, to na pewno spotkałeś się z Proxy!
Czym jest Proxy?
Proxy to strukturalny wzorzec projektowy. Wzorce strukturalne wyjaśniają, jak składać obiekty i klasy w większe struktury, zachowując przy tym ich elastyczność i efektywność. Proxy to obiekt tworzony w miejsce obiektu pierwotnego. Co więcej, powinien on być wykorzystywany zamiast obiektu pierwotnego i rozszerzać go o dodatkowe pola czy metody. Rozszerzanie skojarzyło Ci się z dziedziczeniem? Słusznie. W tym przypadku Proxy i obiekt pierwotny dziedziczą ten sam interfejs. Oznacza to, że Proxy i obiekt pierwotny mają te same metody – ale mimo to robią różne rzeczy. Proxy jest pośrednikiem między miejscem wywołania akcji a jej wykonania. Wykonuje różne dodatkowe czynności, zanim właściwa akcja zostanie wywołana. Myślę, że najlepiej będzie posłużyć się przykładem z życia i diagramem UML. Jeśli jeszcze nie wiesz, jak stworzyć diagram i na czym polegają relacje między obiektami, to zachęcam Cię do zapoznania się z moim poprzednim artykułem: „Podstawy UML – diagramy klas”.
Życiowy przykład wzorca Proxy
Niech naszym obiektem pierwotnym będą drzwi. Drzwi, jak to mają w zwyczaju, służą do otwierania pomieszczenia (metoda open). Jednak gdyby nasze mieszkania nie były wyposażone w zamki na klucze, nie byłyby tak bezpieczne. Dlatego drzwi z zamkiem potraktuję jako jedną całość i nazwę antywłamaniowymi. Będą stanowić Proxy. Robią one to samo, co zwykłe drzwi (implementacja interfejsu DoorInterface), ale dodatkowo korzystają z metody checkKeys w celu sprawdzenia, czy otwieramy mieszkanie odpowiednimi kluczami. Spójrz na diagram ilustrujący ten wzorzec:
Po analizie przykładu wzorzec Proxy powinien być dużo bardziej zrozumiały.
Kiedy użyć Proxy?
Warto korzystać z Proxy wtedy, gdy nie chcemy od razu wywoływać metody z obiektu pierwotnego. Możesz go użyć np. w takich działaniach jak:
- Ograniczenie kosztownych operacji przy wykorzystaniu buforowania, czy cache – zamiast ponownie pobierać jakiś plik z API, możesz go zapisać lokalnie i wczytać z pamięci.
- Kontrola dostępu do miejsca docelowego – czyli uwierzytelnianie użytkownika; zupełnie jak w przypadku naszych drzwi antywłamaniowych z przykładu.
- Walidacja przesyłanych danych – walidacja poszczególnych pól formularza przed wysłaniem go na serwer.
- Ukrycie szczegółów implementacyjnych (np. dodatkowych ustawień dla miejsca docelowego), które nie są istotne z perspektywy miejsca wywołania akcji lub niwelują powielanie kodu – jest to np. cachowanie zasobów HTML, CSS czy JavaScript w celu szybszego wczytywania stron internetowych. Odbywa się przy pomocy silnika przeglądarki.
Wzorzec Proxy w JS
Własne Proxy
Zastosujmy wzorzec w kodzie JavaScript. Posłużę się przedstawioną wcześniej analogią. Na początku stworzę interfejs DoorInterface, który będzie udostępniał metodę open. Jeśli nie znasz interfejsów, zapraszam Cię do zapoznania się z artykułem „4 filary programowania obiektowego”.
export default class DoorInterface {
open() {
throw new Error('Not implemented');
}
}
Teraz stwórzmy oryginalną klasę Door.
import DoorInterface from './interface/DoorInterface.js';
export default class Door extends DoorInterface {
#isOpened = false;
open() {
this.#isOpened = true;
console.log(`Door is ${this.#isOpened && 'opened 😁'}`);
}
}
Implementuje ona wcześniej zdefiniowany interfejs. Tworzymy metodę open, która informuje nas, czy udało się otworzyć drzwi. Zwróć uwagę, że obiekty tego typu mogą już istnieć w Twoim projekcie. Aby kontrolować dostęp do nich, stwórz im pełnomocnika! Zgodnie z diagramem naszym Proxy ma być klasa AntiBurglarDoor. Wykorzystamy ten sam interfejs, ale dostęp do metody open będzie obwarowany przez metodę checkKeys.
import DoorInterface from './interface/DoorInterface.js';
import Door from './Door.js';
export default class AntiBurglarDoor extends DoorInterface {
#originalDoor;
constructor(originalDoor) {
super();
if (!(originalDoor instanceof Door)) {
throw new Error(
'The original door must be an instance of DoorInterface'
);
}
this.#originalDoor = originalDoor;
}
checkKeys() {
// nie mamy właściwych kluczy
const hasCorrectKeys = false;
return hasCorrectKeys;
}
open() {
// jeśli mamy właściwe klucze, to otwórz drzwi
if (this.checkKeys()) {
this.#originalDoor.open();
return;
}
console.log('Door closed 😲 You do not have the correct keys');
}
}
W metodzie checkKeys „na sztywno” zdefiniowaliśmy zmienną hasCorrectKeys z wartością false. W Twoim projekcie powinna znaleźć się tam odpowiednia logika biznesowa, która stanowi istotę pełnomocnika. Stworzone klasy użyję w głównym pliku projektu – index.js.
import Door from './Door.js';
import AntiBurglarDoor from './AntiBurglarDoor.js';
const door = new Door();
const antiBurglarDoor = new AntiBurglarDoor(door);
// korzystam z oryginalnego obiektu
door.open(); // Door is opened 😁
// korzystam z Proxy
antiBurglarDoor.open(); // Door closed 😲 You do not have the correct keys
Nasz pełnomocnik spełnił swoją funkcję – zablokował dostęp do pomieszczenia, sprawdzając poprawność klucza. Kod z przedstawionych przykładów możesz podejrzeć w repozytorium.
Obiekt Proxy wbudowany w JavaScript
Największa do tej pory aktualizacja ECMAScript, czyli ES6, przyniosła ze sobą dużo praktycznych nowości. Jedną z nich jest możliwość korzystania z obiektu Proxy, który obecnie cieszy się niemal całkowitym wsparciem każdej z przeglądarek. Proxy to nowy konstruktor globalny, który przyjmuje dwa argumenty:
- obiekt pierwotny,
- obiekt stanowiący procedurę obsługi, tzw. handler.
Tak jak w przypadku wzorca Proxy, tworzymy obiekt-pomocnika, wykorzystujący obiekt pierwotny. Dzieje się to za pomocą słowa kluczowego new Proxy(). Pomocnik dziedziczy wszystkie metody od obiektu pierwotnego. Aby skorzystać z wbudowanego obiektu Proxy, powinieneś wykorzystać następującą strukturę:
import OriginalObject from './OriginalObject.js';
const originalObject = new OriginalObject();
const originalObjectProxy = new Proxy(originalObject, {
get(object, key) { },
set(object, key, newValue) { }
})
Jak widzisz, pierwszym parametrem new Proxy() jest obiekt pierwotny, drugim zaś obiekt handler – zawiera on metody get oraz set zwane też pułapkami (traps),-target). To właśnie za pomocą tych dwóch metod możesz kontrolować pobieranie i ustawianie właściwości w oryginalnym obiekcie. Dokumentacja przedstawia również inne pułapki, z którymi możesz się zapoznać. Ja zostanę przy tych dwóch, gdyż w zupełności mi wystarczą. Zaprezentuję Ci wykorzystanie obiektu Proxy. Posłużymy się przykładem, w którym odpytamy opensourcowe API. Jego zadaniem jest podawanie informacji o pogodzie. Nasz obiekt pierwotny będzie miał za zadanie:
- pobierać informację o pogodzie we wskazanej lokalizacji,
- zwracać pobraną pogodę po użyciu metody showWeather.
Stworzymy również pełnomocnika. Jego rolą będzie kontrola czasu, w którym pobrano informację o pogodzie za pomocą obiektu pierwotnego. Jeśli czas między kolejnymi pobraniami będzie krótszy niż 10 sekund, to zatrzymamy ten proces. Czas mogę zapisywać np. za pomocą zmiennej, którą przechowam w localStorage. Do dzieła! Aby iść zgodnie z duchem OOP, stworzę interfejs AskWeatherInterface.js:
export default class AskWeatherApiInterface {
getWeather() {
throw new Error('Not implemented');
}
}
Mówi nam on, że klasa potomna musi zaimplementować metodę getWeather. Klasa-dziecko to WeatherApi.js. Za pomocą fetch oraz async/await pobierzemy informacje o aktualnych warunkach pogodowych:
import AskWeatherApiInterface from './interface/AskWeatherApiInterface.js';
export default class WeatherApi extends AskWeatherApiInterface {
#latitude;
#longitude;
constructor(latitude, longitude) {
super();
this.#latitude = latitude;
this.#longitude = longitude;
}
async getWeather() {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${
this.#latitude
}&longitude=${this.#longitude}¤t_weather=true`;
const response = await fetch(url);
const data = await response.json();
const weather = data.current_weather;
return weather;
}
}
Dwa pola prywatne #latitude oraz #longitude określają lokalizację, z której pobierzemy pogodę. Zbudowaliśmy metodę getWeather, która zwraca obiekt weather. Prezentuje się on tak:
Pozostaje nam stworzyć pomocnika, który będzie pilnował, by requesty do API nie były częstsze niż co 10 sekund. Korzystam z obiektu Proxy i tworzę plik withProxy.js:
const WAIT_TIME = 1000 * 10;
const canCallApiAgain = () => {
const lastCallTime = localStorage.getItem('callTime');
if (!lastCallTime) {
return true;
}
const currentTime = new Date().getTime();
return currentTime - lastCallTime > WAIT_TIME;
};
const logCallTime = () => {
const currentTime = new Date().getTime();
localStorage.setItem('callTime', currentTime);
};
const withProxy = weatherApiObject =>
new Proxy(weatherApiObject, {
get(object, key) {
if (key === 'getWeather') {
if (!canCallApiAgain()) {
throw new Error(
'You can call API only once per 10 seconds'
);
}
logCallTime();
return async function () {
const weather = await object[key]\();
return weather;
};
}
}
});
export default withProxy;
Funkcje canCallApiAgain oraz logCallTime stanowią funkcje pomocnicze. Pierwsza sprawdza, czy minęło 10 sekund od ostatniego requestu do API, a druga za pomocą klucza callTime zapisuje czas ostatniego calla w localStorage. Istotą jest funkcja withProxy. Za pomocą new Proxy tworzymy pomocnika obiektu weatherApi. Przekażemy go w parametrze, gdy będziemy pisać kod w głównym pliku index.js. Pułapka get(object, key) sprawdza, czy możemy użyć któregoś z kluczy (key) w oryginalnym obiekcie (object). Jeśli klucz to getWeather – wywołujemy funkcję o tej nazwie. Właśnie w tym miejscu sprawdzam, czy możemy ponownie odpytać API. Jeśli tak, to zapisujemy czas odpytania przez wywołanie logCallTime i prosimy o dane. Jeśli nie, rzucamy błąd. Zamiast rzucania błędu moglibyśmy również przekazywać ostatnio pobraną odpowiedź. Wykonaj to jako zadanie dodatkowe – to dobry sposób na przećwiczenie Twojej znajomości Proxy! Przejdźmy do ostatniej części kodu, czyli użycia klasy WeatherApi oraz funkcji withProxy. Tworzę plik index.js:
import WeatherApi from './WeatherApi.js';
import withProxy from './withProxy.js';
const weatherApi = new WeatherApi(52.23, 21.01);
withProxy(weatherApi).getWeather().then(weather => {
console.log(weather);
});
Gotowe! Stworzyliśmy obiekt odpytujący API o pogodę. Jest on chroniony przez Proxy. Kod przedstawiony powyżej znajdziesz w repozytorium na GitHubie.
Podsumowanie
Wiesz już, czym jest wzorzec Proxy. Teraz będziesz mógł go wykorzystać, by:
- ukryć szczegóły związane z implementacją logiki biznesowej,
- wykonać walidację danych poza obiektem realizującym logikę np. formularza,
- cachować pobrane zasoby czy kontrolować dostęp do metod odpytujących API.
To tylko kilka przykładów zastosowania pełnomocnika. Może znajdziesz dla niego odpowiednie miejsce w swoim projekcie?
Źródła
- Aleksander Shvets, Wzorce projektowe. Nowoczesny podręcznik, Refactoring.Guru, 2022
- https://www.patterns.dev/posts/proxy-pattern/
- https://shebang.pl/javascript/es6-bez-tajemnic/es6-obiekty-posredniczace/
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
- Dokumentacja UML w wersji 2.4
- https://github.com/mateuszjansowa/article-patterns-js-proxy

