
Czym jest adapter?
Wzorzec projektowy adapter jest całkiem prosty do zrozumienia, ponieważ łatwo jest znaleźć jego odzwierciedlenie w prawdziwym życiu. Pewnie słyszałeś o istnieniu kilku standardów gniazdek elektronicznych. Jadąc do innego kraju można napotkać gniazdko, do którego nie będą pasować wtyczki, te których używamy w Polsce.
Dlatego przed wyjazdem do takowego kraju zazwyczaj zaopatrujemy się w przejściówki, a inaczej mówiąc – w adaptery.
To właśnie adaptery umożliwiają nam korzystanie z naszych urządzeń we wszystkich krajach bez konieczności wymiany wtyczki.
Wzorzec adapter
Na początek zacząłem od prostego przykładu z życia wziętego, abyś mógł zrozumieć koncepcję wzorca. Teraz przejdę do rzeczy – czyli tworzenia oprogramowania i nakreślenia przykładowego problemu, w którym to wzorzec adapter może być dla nas przydatny.
Warto zaznaczyć, że adapter należy do wzorców strukturalnych – czyli takich, które opisują budowę obiektu – ich strukturę.
Przechodząc do mięsa, zacznę od trywialnego przykładu.
Koncepcja
Przypuśćmy, że naszym zadaniem jest przygotować kod, który będzie sprawdzać czy piłka przejdzie przez otwór pudełka. Otwór pudełka jest w kształcie koła.
Na początek stwórzmy klasę odpowiedzialną za reprezentację pudełka.
public class Box { private final int holeRadius; private final List<Circle> content = new ArrayList<>(); public Box(int holeRadius) { this.holeRadius = holeRadius; } public void put(Circle circle) { if (circle.getRadius() > holeRadius) { throw new IllegalArgumentException("Object cannot pass through the box's hole"); } content.add(circle); } } public interface Circle { float getRadius(); }
Klasa Box posiada metodę put, która przyjmuje wszystko co jest okrągle tj. implementuje interfejs Circle. Czas na stworzenie pierwszego obiektu z przekrojem koła.
public class Ball implements Circle { private final float radius; public Ball(float radius) { this.radius = radius; } public float getRadius() { return radius; } }
I wszystko jest w porządku dopóki klient nie zmieni wymagań funkcjonalnych i nie zażyczy sobie wrzucania kostek sześciennych do pudełka.
Na pierwszy rzut oka zadanie nie wydaje się skomplikowane – w końcu wystarczy stworzyć klasę Cube i zaimplementować interfejs Circle.
Tylko czy aby na pewno?
Jest to najprostsze rozwiązanie, ale w tak prostym przykładzie można zauważyć jedną istotną rzecz. Niekoniecznie chcemy, aby klasa Cube była interpretowana jako obiekt z przekrojem koła.
Zauważasz już problem?
Czasami może zdarzyć się sytuacja, w której chcemy uniknąć bezpośredniej zmiany klasy, ponieważ uważamy, że jest to niezgodne z założeniami.
W innej sytuacji może chodzić o klasę, której wszyscy boją się dotknąć. Każda zmiana w takiej klasie może spowodować kilka błędów w systemie.
Na szczęście jest inne podejście do problemu – dla naszej kostki można stworzyć adapter, który jedynie będzie udawał, że kostka ma przekrój koła.
public class Cube { private final float sideLength; public Cube(float sideLength) { this.sideLength = sideLength; } public float getSideLength() { return sideLength; } } public class CubeCircleHoleAdapter implements Circle { private final Cube cube; public CubeCircleHoleAdapter(Cube cube) { this.cube = cube; } @Override public float getRadius() { return 0.5f * cube.getSideLength(); } }
Dzięki tak prostemu zabiegowi otrzymujemy obiekt kompatybilny z pudełkiem, a dodatkowo nie zaburzyliśmy żadnych założeń, ani nic przypadkiem nie popsuliśmy. 😉
Ważna jest analiza poniższego kodu, aby dobrze zrozumieć problem i jego rozwiązanie przy użyciu adaptera.
public class Main { public static void main(String[] args) { Box box = new Box(10); Ball smallBall = new Ball(5); Ball biggerBall = new Ball(12); try { box.put(smallBall); System.out.println("Small ball has been put into box"); } catch (Exception e) { System.err.println("Small ball could not be put into box due to: " + e.getMessage()); } try { box.put(biggerBall); System.out.println("Bigger ball has been put into box"); } catch (Exception e) { System.err.println("Bigger ball could not be put into box due to: " + e.getMessage()); } Cube cube = new Cube(10); // can't be pass to Box.put method, because cube has different interface // box.put(cube); // We need adapter to adapt cube to being a circle CubeCircleHoleAdapter cubeCircleHoleAdapter = new CubeCircleHoleAdapter(cube); // now we can check if cube can be put into box box.put(cubeCircleHoleAdapter); System.out.println("Cube has been put into box"); } }
Rezultat uruchomienia powyższego fragmentu kodu.
Small ball has been put into box Cube has been put into box Bigger ball could not be put into box due to: Object cannot pass through the box's hole
Praktyczne użycie adaptera
Wyobraź sobie, że korzystasz z biblioteki z domeny lotów. Umożliwia ona wyszukiwanie odlotów z podanego miasta. Biblioteki mają to do siebie, że wymagają dostarczenia odpowiednich zależności. Tak samo jest i w tym przypadku. Biblioteka wymaga dostarczenia klienta HTTP zgodnego z opisanym interfejsem.
Okazuje się, że w Twoim projekcie jest już HttpClient. Jesteś szczęśliwy, bo możesz natychmiast zacząć czerpać korzyści z użycia biblioteki.
public class OldHttpClient { public RequestResponse get(String url) { return new RequestResponse(200, ""); } public RequestResponse post(String url, String payload) { return new RequestResponse(200, ""); } }
Przykładowy HttpClient w projekcie
Niestety nic z tego – HttpClient zawarty w twoim systemie nie pasuje do interfejsu wymaganego przez bibliotekę. Jednak wpadasz na super pomysł i zaczynasz od implementacji interfejsu w klasie OldHttpClient.
public interface HttpClient { Response get(String url); Response post(String url, String payload); Response put(String url, String payload); Response delete(String url); @RequiredArgsConstructor @Getter class Response { private final int statusCode; private final String body; } } public class OldHttpClient implements HttpClient { public RequestResponse get(String url) { return new RequestResponse(200, ""); } public RequestResponse post(String url, String payload) { return new RequestResponse(200, ""); } @Override public Response get(String url) { return new Response(200, ""); } @Override public Response post(String url, String payload) { return new Response(200, ""); } @Override public Response put(String url, String payload) { return new Response(200, ""); } @Override public Response delete(String url) { return new Response(200, ""); } }
Próba implementacji interfejsu z biblioteki
Po chwili uświadamiasz sobie, że jest to jednak bez sensu – nazwy metod i argumenty się pokrywają. Potrzebne są zmiany istniejących już metod. Po wszystkim klasa wygląda dużo gorzej i na dodatek jest wymieszana z wymaganiami zewnętrznej biblioteki.
I jest to kolejny przykład, w którym sprawdzi się wzorzec adapter. Dzięki niemu nie zrobimy zamieszania w istniejącym kodzie i przy okazji niczego nie zepsujemy – tyczy się to szczególnie projektów legacy.
Przykładowe wykorzystanie adaptera prezentuje się tak.
@RequiredArgsConstructor public class SearchFlightExternalLib { private final HttpClient httpClient; public List<Flight> searchFrom(Flight.City from) { HttpClient.Response response = httpClient.get("https://flight-provider.com/search?from+" + from.name()); // map response to flights return Collections.emptyList(); } } @RequiredArgsConstructor public class HttpClientFlightLibAdapter implements HttpClient { private final OldHttpClient oldHttpClient; @Override public Response get(String url) { RequestResponse requestResponse = oldHttpClient.get(url); return new Response(requestResponse.getHttpStatus(), requestResponse.getResponse()); } @Override public Response post(String url, String payload) { RequestResponse requestResponse = oldHttpClient.post(url, payload); return new Response(requestResponse.getHttpStatus(), requestResponse.getResponse()); } @Override public Response put(String url, String payload) { return new Response(200, ""); } @Override public Response delete(String url) { return new Response(200, ""); } }
Tak jak w poprzednim przykładzie z obiektami i pudełkiem – udajemy, że pewien obiekt jest czymś innym.
Tym razem stworzyliśmy adapter dla OldHttpClient – nic nie popsuliśmy i klasa OldHttpClient została dostarczona do zewnętrznej biblioteki.
public class Main { public static void main(String[] args) { OldHttpClient oldHttpClient = new OldHttpClient(); // Can't pass OldHttpClient, because class doesn't implement HttpClient interface // SearchFlightExternalLib searchFlightExternalLib = new SearchFlightExternalLib(oldHttpClient); // Need to create adapter for old http client HttpClient adapter = new HttpClientFlightLibAdapter(oldHttpClient); SearchFlightExternalLib searchFlightExternalLib = new SearchFlightExternalLib(adapter); List<Flight> flightsToParis = searchFlightExternalLib.searchFrom(Flight.City.Paris); } }
Różne implementacje
Warto zaznaczyć, że adapter może zostać zaimplementowany na dwa sposoby:
- przy użyciu kompozycji
- lub dziedziczenia.
W powyższych przykładach zastosowałem kompozycję. Jednak nic nie stoi na przeszkodzie, żeby adapter rozszerzał klasę, którą adaptuje (np. Cube) do odpowiedniego interfejsu (np. Circle).
Chociaż podkreślę, że w przypadku języka Java klasa adoptowana może być oznaczona jako final i wtedy kompozycja jest lepszym (praktycznie jedynym) rozwiązaniem.
Kiedy, więc używać?
Każdy wzorzec projektowy ma określone sytuacje, w których powinien zostać użyty. W przypadku adaptera kryterium jest proste, więc jest bardzo łatwo dostrzec możliwość jego wykorzystania.
Wykorzystaj wzorzec w sytuacji, gdy klasa ma niekompatybilny interfejs z usługą, którą chcesz wykorzystać.
Zalety
- Odseparowanie istniejącej części kodu od nowej – czyli system jest otwarty na rozszerzenie i zamknięty na zmiany
- Zachowanie zasady jednej odpowiedzialności – nowe funkcjonalności mogą być wydzielone do osobnej klasy
Wady
- Wzrost trudności zrozumienia kodu – spowodowane jest to wprowadzaniem nowych interfejsów i klas
Podsumowanie
W tym wpisie przedstawiłem wzorzec projektowy adapter. Moim zdaniem, jest jednym z łatwiejszych wzorców do zrozumienia – przynajmniej pod kątem koncepcji. Na pewno warto o nim pamiętać, ponieważ nie raz może ułatwić nam adaptację do wymaganego interfejsu.
Niestety jak każde rozwiązanie ma swoje plusy i minusy. Warto być ich świadomym podczas użycia wzorca adapter.