Rozdział 8: Granice
Rozdział 8: Granice — Jak zachować kontrolę nad własnym systemem
O co chodzi w tej sekcji
Rozdział ten skupia się na problematyce integracji kodu, którego nie jesteśmy autorami — bibliotek zewnętrznych, pakietów open source czy komponentów dostarczanych przez inne zespoły — z naszą własną aplikacją. Główną tezą autora jest to, że na granicach systemów zachodzą procesy, które mogą prowadzić do chaosu, dlatego musimy stosować techniki hermetyzacji i separacji, aby zmiany w zewnętrznych zależnościach nie wymusiły kosztownej przebudowy całego naszego kodu. Chodzi o to, abyśmy to my kontrolowali granice naszego oprogramowania, a nie byli przez nie kontrolowani.
Kluczowe zasady i reguły
- Unikaj przesyłania interfejsów zewnętrznych przez całą aplikację — Dostawcy bibliotek dążą do maksymalnej elastyczności (np.
java.util.Map), co często kłóci się z konkretnymi potrzebami Twojej aplikacji. Przekazywanie takich obiektów jako parametrów lub wyników metod powoduje, że każda zmiana w bibliotece uderza w wiele miejsc w systemie. - Hermetyzuj granice — Zamiast bezpośrednio korzystać z zewnętrznego API w całej aplikacji, zamknij je w dedykowanej klasie (wrapperze). Dzięki temu rzutowanie i zarządzanie typami odbywa się w jednym miejscu, a reszta systemu operuje na czystych, domenowych interfejsach.
- Stosuj testy uczące (Learning Tests) — Zamiast eksperymentować z nową biblioteką w kodzie produkcyjnym, napisz serię testów sprawdzających Twoje zrozumienie zewnętrznego API. Pozwalają one na kontrolowane poznawanie funkcji biblioteki i wykrywanie problemów przed integracją.
- Testy uczące to darmowe ubezpieczenie — Kiedy pojawia się nowa wersja biblioteki, wystarczy uruchomić testy uczące, aby sprawdzić, czy zmiany u dostawcy nie wpłynęły negatywnie na Twoją aplikację. Jeśli testy przechodzą, ryzyko aktualizacji jest minimalne; jeśli nie, natychmiast wiesz o regresji.
- Definiuj granice dla kodu, który jeszcze nie istnieje — Jeśli Twój zespół czeka na moduł od innego zespołu, którego API nie jest jeszcze znane, zdefiniuj własny, idealny interfejs, który odpowiada Twoim potrzebom. Pozwoli to pisać kod bez blokowania prac, a po otrzymaniu gotowego modułu wystarczy dopisać prosty adapter.
- Rób tylko to, czego potrzebujesz — Ograniczaj interfejsy zewnętrzne do podzbioru funkcji, których faktycznie używa Twój system, co ułatwia testowanie i zrozumienie logiki biznesowej.
Przykłady kodu
Przykład 1: Hermetyzacja klasy Map
W tym przykładzie autor pokazuje, jak niebezpieczne jest używanie surowej mapy w wielu miejscach systemu.
Przed refaktoryzacją (kod „brudny”): Pobieranie obiektu z mapy wymaga rzutowania, co zaśmieca kod i utrudnia czytanie.
Map sensors = new HashMap();
// ... gdzieś w kodzie
Sensor s = (Sensor)sensors.get(sensorId);
Nawet użycie typów ogólnych (Generics) nie rozwiązuje problemu nadmiernej elastyczności mapy.
Map<Sensor> sensors = new HashMap<Sensor>();
// ... gdzieś w kodzie
Sensor s = sensors.get(sensorId);
Po refaktoryzacji (kod czysty):
Interfejs Map zostaje ukryty wewnątrz klasy Sensors. Pozostała część aplikacji nie wie o istnieniu mapy i nie może np. przypadkowo wyczyścić jej zawartości metodą clear().
public class Sensors {
private Map sensors = new HashMap();
public Sensor getById(String id) {
return (Sensor) sensors.get(id);
}
// Pozostałe metody ograniczone do potrzeb aplikacji
}
Przykład 2: Testy uczące dla biblioteki log4j
Zamiast czytać całą dokumentację, programista sprawdza działanie biblioteki poprzez testy jednostkowe.
public class LogTest {
private Logger logger;
@Before
public void initialize() {
logger = Logger.getLogger("logger");
logger.removeAllAppenders();
Logger.getRootLogger().removeAllAppenders();
}
@Test
public void basicLogger() {
BasicConfigurator.configure();
logger.info("basicLogger");
}
@Test
public void addAppenderWithStream() {
logger.addAppender(new ConsoleAppender(
new PatternLayout("%p %t %m%n"),
ConsoleAppender.SYSTEM_OUT));
logger.info("addAppenderWithStream");
}
}
Zastosowanie testów uczących pozwoliło odkryć, że domyślny konstruktor ConsoleAppender wymaga dodatkowej konfiguracji, co bez testów mogłoby prowadzić do długiego debugowania.
Przykład 3: Adapter dla nieistniejącego kodu
Gdy API nadajnika nie było jeszcze zdefiniowane, zespół utworzył własny interfejs Transmitter z metodą transmit, co pozwoliło na kontynuowanie prac nad logiką kontrolera.
// Nasz idealny interfejs, nad którym mamy kontrolę
public interface Transmitter {
void transmit(double frequency, Stream stream);
}
// Późniejszy adapter łączący nasz interfejs z API dostarczonym przez inną stronę
public class TransmitterAdapter implements Transmitter {
private ExternalTransmitterAPI api;
public void transmit(double frequency, Stream stream) {
// Mapowanie naszego wywołania na metody zewnętrznego API
api.setFrequency(frequency);
api.sendData(stream);
}
}
Praktyczne wnioski
Po lekturze tej sekcji powinieneś wprowadzić następujące zmiany w swoim sposobie kodowania:
- Przejrzyj publiczne API swoich modułów — Jeśli zwracasz z nich bezpośrednio typy takie jak
Map,Listczy inne skomplikowane obiekty z bibliotek zewnętrznych, zastanów się nad ich opakowaniem w obiekty domenowe. - Zastosuj zasadę „używaj tego, co kontrolujesz” — Kiedy integrujesz nową bibliotekę, napisz dla niej „testy uczące”. Nie traktuj ich jako jednorazowych skryptów — wrzuć je do repozytorium, aby służyły jako strażnicy przy przyszłych aktualizacjach biblioteki.
- Projektuj interfejsy od strony użytkownika, a nie dostawcy — Jeśli potrzebujesz specyficznej funkcji od zewnętrznego systemu, zdefiniuj własny interfejs, który ją opisuje, a następnie użyj wzorca Adapter, aby „przetłumaczyć” go na język obcej biblioteki.
- Ograniczaj punkty styku — Kod graniczny powinien być skoncentrowany w minimalnej liczbie miejsc. Rozsianie wywołań biblioteki zewnętrznej po całym projekcie to przepis na „sztywną” architekturę, którą bardzo trudno zmienić.
- Nie ufaj intuicji przy API firm trzecich — Dokumentacja często kłamie lub jest niepełna. Tylko testy uczące dają pewność, jak biblioteka zachowuje się w Twoim konkretnym środowisku wykonawczym.