W inżynierii oprogramowania występuje masowe dążenie do tworzenia systemów informatycznych z komponentów wielokrotnego użytku. Komponenty te mogą występować na różnym poziomie abstrakcji oraz złożoność. Artykuł poświęcony jest komponentom najniższego poziomu jakimi są klasy oraz ich obiekty.
Swobodna twórczość
O co właściwie chodzi z tym całym decouplingiem1? W gruncie rzeczy o przejrzystość, elastyczność i elegancję kodu. Wyobraźmy sobie następującą sytuację:
Pewien interfejs, SecurityManager, jest odpowiedzialny za zrealizowanie operacji autentykacji oraz autoryzacji. Po krótkiej analizie stwierdzamy, że chociaż przeprowadzenie operacji autentykacji należy do interfejsu SecurityManager, to już sama czynność sięgnięcia do bazy danych w celu pobrania potrzebnych informacji leży poza tą odpowiedzialnością. Na diagramie widać, że działania na bazie danych delegowane są do pomocniczego interfejsu UserCredentialsDAO.
Pojęcie decouplingu dotyczy powiązań pomiędzy poszczególnymi klasami/interfejsami w systemie, np.: powiązania pomiędzy implementacjami interfejsów SecurityManager i UserCredentialsDAO. Jak zatem zapewnić to powiązanie? Najprostszym możliwym sposobem – w konstruktorze.
public class JdbcSecurityManager implements SecurityManager {
public UserCredentialsDAO userCredentialsDAO;
public JdbcSecurityManager() {
userCredentialsDAO = new JdbcUserCredentialsDAO();
}
Szybko jednak okazuje się, że dana klasa wymaga więcej zależności, aby wykonać swoje zadanie. Dodatkowo chcemy mieć możliwość decydowania, które konkretne implementacje klas zależnych zostaną dostarczone do obiektu. W takim przypadku można zastosować parametryzowane konstruktory.
public class JdbcSecurityManager implements SecurityManager {
public UserCredentialsDAO userCredentialsDAO;
public SecurityHolder securityHolder;
public JdbcSecurityManager( UserCredentialsDAO credentialsDAO,
SecurityHolder securityHolder ) {
this.userCredentialsDAO = credentialsDAO;
this.securityHolder = securityHolder;
}
Rozwiązanie to sprawia kłopot, gdy pisane są testy jednostkowe wykorzystujące klasę JdbcSecurityManager lub jej mocka. Niedogodność polega na tym, że czasem , teście jednostkowym chcemy mieć możliwość podmiany implementacji interfejsów UserCredentialsDAO oraz SecurityHolder. Z tego względu warto do tworzonej klasy , prócz parametryzowan/ch konstruktorów dodać również konstruktor domyślny oraz setery i getery do odpowiednich pól. W klasie wykorzystującej interfejs SecurityManager można wstawić następujący kod:
UserCredentialsDAO credentialsDAO = new JdbcUserCredentialsDAO();
SecurityHolder securityHolder = new SecurityHolder();
JdbcSecurityManager securityManager = new JdbcSecurityManager();
securityManager.setUserCredentialsDAO( credentialsDAO );
securityManager.setSecurityHolder( securityHolder );
Stosując opisaną powyżej strategię zapewniamy utworzenie powiązań pomiędzy obiektami w systemie. Jednocześnie sprawiamy, że obiekty są silnie uzależnione od siebie. Jest tak dlatego, że instancje poszczególnych klas są tworzone w kodzie – klasa nadrzędna tworzy instancje klas z nią współpracujących. O klasach, w których powiązania między nimi są realizowane w ten sposób mówimy, że są coupled. Konsekwencją takiego podejścia jest to, że jakakolwiek podmiana poszczególnych implementacji wiąże się z ingerencją w kod źródłowy. Taki kod jest nieelastyczny oraz trudny w utrzymaniu i rozwoju.
Przeciwwagą kodu, w którym klasy są coupled, jest kod w którym są one decoupled, to znaczy w maksymalnym stopniu od siebie niezależne. Współpracują ze sobą, ale można w łatwy sposób wymieniać poszczególne implementacje, a ingerencja w projekt jest ograniczona do minimum. Czynność zapewniania takiego stanu rzeczy nazywa się decoupling (lub po lekkim spolszczeniu decouplingiem) i jest przedmiotem niniejszego artykułu.
Fabryka
Ponownie odwołując się do pojęcia odpowiedzialności obiektów można zapytać, czy tworzenie nowych obiektów należy do odpowiedzialności klasy JdbcSecurityManager? Nie, gdyż jej zadaniem jest przeprowadzać operacje związane z bezpieczeństwem. Obiekt tej klasy nie może wykonywać swoich działań gdy nie ma obiektów pomocniczych, do których deleguje odpowiednie operacje. Remedium przychodzi w postaci scentralizowanego miejsca, w którym tworzone będą obiekty używane w systemie. Można zaimplementować to rozwiązanie w postaci wzorca SimpleFactory.
public final class ObjectFactory {
public static SecurityManager createSecurityManager(
String type ) {
SecurityManager securityManager = //...
return securityManager;
}
public static UserCredentialsDAO createUserCredentialsDAO(
String type ) {
//...
}
}
Metody fabryki muszą zdecydować, w jaki sposób należy zbudować dany obiekt, np. którą implementację należy utworzyć. Decyzja zostanie podjęta na podstawie parametru type metod fabryki.
public class BankTransferManager {
private SecurityManager securityManager;
public BankTransferManager() {
securityManager = ObjectFactory.createSecurityManager(
"securityManager" );
}
}
W powyższym rozwiązaniu tworzenie klas i powiązań pomiędzy nimi zostało oddelegowane do osobnego obiektu fabryki. Dzięki temu nie klasa decyduje o użytych implementacjach lecz fabryka. Rozwiązanie zapewnia decoupling kodu, gdyż konkretne klasy nie są ze sobą trwale związane (np. poprzez tworzenie zależności w konstruktorach), zatem można w dowolnym momencie podmieniać implementacje modyfikując odpowiednio działanie klasy ObjectFactory. Oczywiście ingerencja w kod jest nieodzowna, lecz dotyczy tylko jednego obiektu, nie wszystkich.
Singleton
Czasem zachodzi konieczność, aby dany obiekt występował tylko raz w systemie, np. SecurityManager jeśli nie chcemy, próba autentykacji użytkownika była podejmowana przez dwa byty.
Może być też tak, że nie ma sensu tworzyć wielu bezstanowych obiektów zajmujących się tylko logiką, np. UserCredentialsDAO, gdyż w zupełności wystarczy tylko jeden.
W powyższych wypadkach stosuje się wzorzec Singleton, aby zapewnić, że dany obiekt zostanie utworzony tylko raz. Poniżej znajduje się przykładowa implementacja Singletonu.
public class LdapSecurityManager implements SecurityManager {
private static LdapSecurityManager instance;
private static LdapSecurityManager getInstance() {
if ( instance == null ) {
instance = new LdapSecurityManager();
}
return instance;
}
}
Klasa ObjectFactory musi skorzystać z metod getInstance() do pobrania obiektu. Jeśli singleton ma być używany w aplikacji wielowątkowej musi być thread-safe. Za Williamem Pughem podaję taką implementację:
public class LdapSecurityManager implements SecurityManager {
private static class SingletonHolder {
private final static LdapSecurityManager instance
= new LdapSecurityManager();
}
private static LdapSecurityManager getInstance() {
return SingletonHolder.instance;
}
}
Należy pamiętać, że jeśli system działa na wielu maszynach wirtualnych, to klasa singletonu będzie ładowana na każdej z nich. Z tego względu zaleca się unikać tego rozwiązania w systemach rozproszonych.
Service Locator
Uogólnieniem ObjectFactory jest wzorzec Service Locator, którego zadaniem jest dostarczenie klientowi żądanej klasy usługowej, z tą różnicą, że klient nie wie skąd pochodzi dana usługa – czy jest lokalna, czy też zdalna.
Kontekst aplikacji
Alternatywą dla ObjectFactory jest zastosowanie obiektu, w którym przechowywany jest tzw. kontekst aplikacji. Kontekst zawiera w sobie wszystko to, czego system potrzebuje do poprawnego działania, np. obiekty realizujące konkretne usługi.
public class LdapSecurityManager implements SecurityManager {
public void authenticate( UserCredentials credentials,
AppContext context ) {
UserCredentialsDAO credentialsDAO
= context.get( "userCredentialsDAO" );
}
}
Każda metoda, w której potrzeba użyć usługi przechowywanej w kontekście będzie przyjmować dodatkowy parametr – AppContext, który umożliwia pobranie potrzebnego obiektu. Oczywiście podczas startu aplikacji należy najpierw zbudować odpowiedni kontekst.
Kiedy zatem używać obiektu kontekstu, a kiedy fabryki? Obiekt kontekstu umożliwia przechowywanie aktualnego stanu aplikacji do którego powinny mieć dostęp wszystkie obiekty (przechowywanie stanu w zewnętrznym obiekcie dostępnym poprzez metody statyczne jest mało eleganckie i nieintuicyjne). Fabryka skupia się tylko na tworzeniu obiektów. Konkretny wybór zależy o bieżących potrzeb. Kontekst, podobnie jak fabryka, zapewnia decoupling obiektów.
Dependency Injection
Konsekwencją stosowania zarówno fabryki jak i kontekstu aplikacji jest sytuacja, w której dana klasa musi zażądać obiektów pomocniczych, których chce użyć, od fabryki lub pobrać je z kontekstu aplikacji. Zatem dany obiekt dba o to, aby odnaleźć potrzebne mu obiekty współpracujące za pomocą fabryki, lokatora lub konktestu. Obiekt dba o swoje zależności.
Przeformułujmy problem w następujący sposób: żądamy takiej architektury aplikacji, w której dostarczony zostanie nam obiekt gotowy do użycia z już rozwiązanymi zależnościami. Takie podejście nosi nazwę Dependency Injection. Obiekty są zarządzane przez tzw. kontener. Przed użyciem należy zdefiniować obiekty oraz powiązania pomiędzy nimi, a następnie pobierać obiekty z kontenera i używać ich w systemie.
Na rysunku widać schemat Dependency Injection. Wszystkie obiekty oraz ich wzajemne relacje zdefiniowane są w zewnętrznym pliku XML, np:
<bean id="userCredentialsDAO"
class="mbartyzel.decoupling.JdbcUserCredentialsDAO" />
<bean id="securityManager"
class="mbartyzel.decoupling.JdbcSecurityManager">
<property name="credentialsDAO" ref="userCredentialsDAO" />
</bean>
Natomiast w systemie pobierane są obiekty z kontenera:
SecurityManager securitManager = BeanFactoryHolder
.getBean( "securityManager" );
Przykłady frameworków dostarczających kontenera DI to np.: SpringFramework, PicoContainer, Google-Guice.
Aby kod aplikacji był decoupled od frameworka oczekujemy, aby nie narzucał zarządzanym przez siebie obiektom implementowania specyficznych interfejsów. Gdyby tak było, kod stałby się zbyt zależny od samego framewokra. Niemożliwa byłaby wtedy sprawna zamiana jednego kontenera na inny, a testowanie utrudnione.
Podsumowanie
W artykule omówione zostały strategie decouplingu w systemach informatycznych takie jak: fabryka, Service Locator, kontekst aplikacji, kontener Dependency Injection. Autor ma nadzieje, że podane przykłady implementacji pozwolą Czytelnikom poprawić jakość tworzonego kodu. Nadrzędnym celem było wskazanie kilku możliwości osiągnięcia tego samego efektu. Mając wybór możemy, w danej sytuacji, świadomie decydować o przewadze jednego rozwiązania nad innym. Pamiętajmy, że jeśli jednym dostępnym narzędziem jest młotek, wszystko zaczyna wyglądać jak gwóźdź...