144 44 23MB
Polish Pages 545 Year 2015
Tytuł oryginału: Agile Software Development, Principles, Patterns, and Practices Tłumaczenie: Radosław Meryk Projekt okładki: Studio Gravite / Olsztyn; Obarek, Pokoński, Pazdrijowski, Zaprucki Materiały graficzne na okładce zostały wykorzystane za zgodą Shutterstock Images LLC. ISBN: 978-83-246-9685-7 Authorized translation from the English language edition, entitled: AGILE SOFTWARE DEVELOPMENT, PRINCIPLES, PATTERNS, AND PRACTICES; ISBN 0135974445; by Robert C. Martin; published by Pearson Education, Inc; publishing as Prentice Hall. Copyright © 2003 by Pearson Education, Inc. All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc. Polish language edition published by HELION S.A., Copyright © 2015. Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie praw autorskich niniejszej publikacji. Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich właścicieli. Autor oraz Wydawnictwo HELION dołożyli wszelkich starań, by zawarte w tej książce informacje były kompletne i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Autor oraz Wydawnictwo HELION nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce. Wydawnictwo HELION ul. Kościuszki 1c, 44-100 GLIWICE tel. 32 231 22 19, 32 230 98 63 e-mail: [email protected] WWW: http://helion.pl (księgarnia internetowa, katalog książek) Drogi Czytelniku! Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres http://helion.pl/user/opinie/zwiwyo_ebook Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję. Pliki z przykładami omawianymi w książce można znaleźć pod adresem: ftp://ftp.helion.pl/przyklady/zwiwyo.zip
Poleć książkę na Facebook.com
Księgarnia internetowa
Kup w wersji papierowej
Lubię to! » Nasza społeczność
Oceń książkę
Spis treści Słowo wstępne Przedmowa O autorach
13 14 20
CZĘŚĆ I
21
ZWINNE WYTWARZANIE OPROGRAMOWANIA
Rozdział 1 Praktyki agile
23
Agile Alliance
24
Manifest Agile Alliance
Zasady Wniosek Bibliografia
24
27 29 29
Rozdział 2 Przegląd informacji o programowaniu ekstremalnym
31
Praktyki programowania ekstremalnego
31
Klient jest członkiem zespołu Historyjki użytkowników Krótkie cykle Testy akceptacyjne Programowanie parami Programowanie sterowane testami Wspólna własność Ciągła integracja Równomierne tempo Otwarta przestrzeń robocza Gra w planowanie Prosty projekt Refaktoryzacja Metafora
32 32 32 33 33 34 34 34 35 35 35 36 37 37
Wniosek Bibliografia
38 38
Rozdział 3 Planowanie
39
Początkowa eksploracja Tworzenie prototypów, dzielenie i szybkość
Planowanie wersji dystrybucyjnych Planowanie iteracji Planowanie zadań
40 40
41 41 41
Półmetek
42
Przebieg iteracji Wniosek Bibliografia
42 43 43
4
SPIS TREŚCI
Rozdział 4 Testowanie Programowanie sterowane testami Przykład projektu w stylu „najpierw test” Izolacja testu Nieoczekiwane wyeliminowanie sprzężeń
Testy akceptacyjne Przykład testów akceptacyjnych Architektura „przy okazji”
Wniosek Bibliografia
Rozdział 5 Refaktoryzacja Generowanie liczb pierwszych — prosty przykład refaktoryzacji Ostateczny przegląd
45 45 46 47 48
49 50 51
51 52
53 54 59
Wniosek Bibliografia
62 63
Rozdział 6 Epizod programowania
65
Gra w kręgle Wniosek
CZĘŚĆ II
66 98
PROJEKT AGILE
101
Symptomy złego projektu Zasady Zapachy a zasady Bibliografia
101 101 102 102
Rozdział 7 Co to jest projekt agile? Co złego dzieje się z oprogramowaniem? Zapachy projektu — woń psującego się oprogramowania Co stymuluje oprogramowanie do psucia się? Zespoły agile nie pozwalają psuć się oprogramowaniu
Program Copy Przykład programu Copy wykonanego zgodnie z metodyką agile Skąd deweloperzy agile wiedzieli, co należy zrobić?
Utrzymywanie projektu w jak najlepszej postaci Wniosek Bibliografia
103 103 104 106 106
106 109 110
110 111 111
Rozdział 8 SRP — zasada pojedynczej odpowiedzialności
113
SRP — zasada pojedynczej odpowiedzialności
113
Czym jest odpowiedzialność? Rozdzielanie sprzężonych odpowiedzialności Trwałość
115 115 116
Wniosek Bibliografia
116 116
Rozdział 9 OCP — zasada otwarte-zamknięte
117
OCP — zasada otwarte-zamknięte Opis Kluczem jest abstrakcja
117 118 118
SPIS TREŚCI
Aplikacja Shape Naruszenie zasady OCP Zachowanie zgodności z zasadą OCP Przyznaję się. Kłamałem Przewidywanie i „naturalna” struktura Umieszczanie „haczyków” Stosowanie abstrakcji w celu uzyskania jawnego domknięcia Zastosowanie podejścia „sterowania danymi” w celu uzyskania domknięcia
Wniosek Bibliografia
Rozdział 10 LSP — zasada podstawiania Liskov LSP — zasada podstawiania Liskov Prosty przykład naruszenia zasady LSP Kwadraty i prostokąty — bardziej subtelne naruszenie zasady LSP Prawdziwy problem Poprawność nie jest wrodzona Relacja IS-A dotyczy zachowania Projektowanie według kontraktu Specyfikowanie kontraktów w testach jednostkowych
Realny przykład Motywacja Problem Rozwiązanie niezgodne z zasadą LSP Rozwiązanie zgodne z zasadą LSP
Wydzielanie zamiast dziedziczenia Heurystyki i konwencje Zdegenerowane funkcje w klasach pochodnych Zgłaszanie wyjątków z klas pochodnych
Wniosek Bibliografia
5
119 120 121 122 122 123 124 125
126 126
127 127 128 129 131 132 132 132 133
133 133 135 136 136
137 139 140 140
140 140
Rozdział 11 DIP — zasada odwracania zależności
141
DIP — zasada odwracania zależności Podział na warstwy
141 142
Odwrócenie własności Zależność od abstrakcji
Prosty przykład Wyszukiwanie potrzebnych abstrakcji
Przykład programu Furnace Polimorfizm dynamiczny i statyczny
Wniosek Bibliografia
Rozdział 12 ISP — zasada segregacji interfejsów Zaśmiecanie interfejsów Odrębne klienty oznaczają odrębne interfejsy Siła oddziaływania klientów na interfejsy
ISP — zasada segregacji interfejsów Interfejsy klas a interfejsy obiektów Separacja przez delegację Separacja przez wielokrotne dziedziczenie
142 143
144 145
146 147
148 148
149 149 150 151
151 152 152 153
6
SPIS TREŚCI
Przykład interfejsu użytkownika bankomatu Poliady i monady
CZĘŚĆ III
153 158
Wniosek Bibliografia
159 159
STUDIUM PRZYPADKU: SYSTEM PŁACOWY
161
Szczątkowa specyfikacja systemu płacowego
162
Ćwiczenie Przypadek użycia nr 1: dodawanie nowego pracownika Przypadek użycia nr 2: usuwanie pracownika Przypadek użycia nr 3: dostarczenie karty pracy Przypadek użycia nr 4: dostarczenie raportu sprzedaży Przypadek użycia nr 5: dostarczenie informacji o opłacie na rzecz związku zawodowego Przypadek użycia nr 6: zmiana danych pracownika Przypadek użycia nr 7: wygenerowanie listy płac na dzień
Rozdział 13 Wzorce projektowe Polecenie i Aktywny obiekt Proste polecenia Transakcje Fizyczny i czasowy podział kodu Czasowy podział kodu
Metoda Undo Aktywny obiekt Wniosek Bibliografia
Rozdział 14 Metoda szablonowa i Strategia: dziedziczenie a delegacja Metoda szablonowa Nadużywanie wzorca Sortowanie bąbelkowe
Strategia Sortowanie jeszcze raz
Wniosek Bibliografia
Rozdział 15 Wzorce projektowe Fasada i Mediator Fasada Mediator Wniosek Bibliografia
Rozdział 16 Wzorce projektowe Singleton i Monostate Singleton Korzyści ze stosowania wzorca Singleton Koszty stosowania wzorca Singleton Wzorzec projektowy Singleton w praktyce
Monostate Korzyści ze stosowania wzorca Monostate Koszty stosowania wzorca Monostate Wzorzec projektowy Monostate w praktyce
Wniosek Bibliografia
162 162 163 163 163 164 164 164
165 166 167 168 168
169 169 173 173
175 176 178 179
181 183
185 185
187 187 188 190 190
191 192 193 193 193
194 196 196 196
200 200
SPIS TREŚCI
Rozdział 17 Wzorzec projektowy Obiekt Null Wniosek Bibliografia
Rozdział 18 Studium przypadku: system płacowy. Pierwsza iteracja Wprowadzenie Specyfikacja
Analiza według przypadków użycia Dodawanie pracowników Usuwanie pracowników Dostarczenie karty pracy Dostarczenie raportów sprzedaży Dostarczenie informacji o opłacie na rzecz związku zawodowego Zmiana danych pracownika Wypłaty
Refleksja: czego się nauczyliśmy? Wyszukiwanie potrzebnych abstrakcji Abstrakcja harmonogramu Sposoby wypłaty Przynależność do związków zawodowych
Wniosek Bibliografia
Rozdział 19 Studium przypadku: system płacowy. Implementacja Dodawanie pracowników Baza danych systemu płacowego Zastosowanie wzorca Metoda szablonowa do dodawania pracowników
Usuwanie pracowników Zmienne globalne
Karty pracy, raporty sprzedaży i składki Zmiana danych pracowników Zmiana klasyfikacji Co ja paliłem?
Realizacja wypłat Czy chcemy, aby deweloperzy podejmowali decyzje biznesowe? Realizacja wypłat dla pracowników ze stałą pensją Realizacja wypłat dla pracowników zatrudnionych w systemie godzinowym Okresy rozliczeniowe: problem projektowy
Program główny Baza danych Podsumowanie projektu systemu płacowego
CZĘŚĆ IV
7
201 204 204
205 205 206
206 207 208 209 209 210 210 212
214 214 214 215 216
216 216
217 218 219 220
223 225
225 231 235 240
244 246 246 248 251
257 257 258
Historia Zasoby
259 259
Bibliografia
259
PODZIAŁ SYSTEMU PŁACOWEGO NA PAKIETY
261
Rozdział 20 Zasady projektowania pakietów Projektowanie z wykorzystaniem pakietów? Ziarnistość: zasady spójności pakietów Zasada równoważności wielokrotnego wykorzystania kodu i dystrybucji (REP) Zasada zbiorowego wielokrotnego użytku (CRP)
263 263 264 264 265
8
SPIS TREŚCI
Zasada zbiorowego zamykania (CCP) Podsumowanie tematyki spójności pakietów
Stabilność: zasady sprzęgania pakietów Zasada acyklicznych zależności (ADP) Cotygodniowe kompilacje Eliminowanie cykli zależności Skutki istnienia cykli w grafie zależności między pakietami Przerywanie cykli Odchylenia
Projekt góra-dół Zasada stabilnych zależności (SDP) Stabilność Metryki stabilności Nie wszystkie pakiety muszą być stabilne Gdzie powinna się znaleźć implementacja projektu wysokiego poziomu?
267 267 267 268 269 270 270
271 272 272 273 274 276
Zasada stabilnych abstrakcji (SAP)
276
Mierzenie abstrakcji Ciąg główny Odległość od ciągu głównego
276 277 278
Wniosek
Rozdział 21 Wzorzec projektowy Fabryka Cykl zależności Fabryki wymienne Wykorzystanie wzorca Fabryka do tworzenia zestawów testowych Znaczenie korzystania z fabryk Wniosek Bibliografia
Rozdział 22 Studium przypadku: system płacowy (część 2.) Struktura pakietów i notacja Zastosowanie zasady zbiorowego domykania (CCP) Zastosowanie zasady równoważności wielokrotnego wykorzystania kodu i dystrybucji (REP) Sprzężenia i hermetyzacja Metryki Zastosowanie wskaźników do aplikacji płacowej Fabryki obiektów Fabryka obiektów dla pakietu TransactionImplementation Inicjowanie fabryk Przebudowa granic spójności
CZĘŚĆ V
266 266
280
281 283 284 284 286 287 287
289 290 291 292 294 296 297 300 300 301 301
Ostateczna struktura pakietów Wniosek Bibliografia
302 304 304
STUDIUM PRZYPADKU: STACJA POGODOWA
305
Rozdział 23 Wzorzec projektowy Kompozyt Przykład: polecenia kompozytowe Wielokrotność czy brak wielokrotności
307 308 309
SPIS TREŚCI
Rozdział 24 Obserwator — ewolucja kodu do wzorca Zegar cyfrowy Wniosek Wykorzystanie diagramów w tym rozdziale
Wzorzec projektowy Obserwator Zarządzanie zasadami projektu obiektowego dla wzorca projektowego Obserwator
Bibliografia
Rozdział 25 Wzorce projektowe Serwer abstrakcyjny i Most Wzorzec projektowy Serwer abstrakcyjny Kto jest właścicielem interfejsu?
Wzorzec projektowy Adapter Wzorzec projektowy Adapter w formie klasy Problem modemu. Adaptery i zasada LSP
Wzorzec projektowy Most Wniosek Bibliografia
Rozdział 26 Wzorce projektowe Pełnomocnik i Schody do nieba — zarządzanie zewnętrznymi interfejsami API Wzorzec projektowy Pełnomocnik Implementacja wzorca projektowego Pełnomocnik w aplikacji koszyka na zakupy Podsumowanie wiadomości o wzorcu projektowym Pełnomocnik Obsługa baz danych, oprogramowania middleware oraz zewnętrznych interfejsów API
Schody do nieba Przykład zastosowania wzorca Schody do nieba
Wniosek Inne wzorce projektowe, które można wykorzystywać z bazami danych Wniosek Bibliografia
Rozdział 27 Analiza przypadku: stacja pogodowa Firma Chmura Oprogramowanie WMS-LC Wybór języka
Projekt oprogramowania systemu Nimbus-LC Historia 24-godzinna i utrwalanie Implementacja algorytmów HiLo
Wniosek Bibliografia Przegląd wymagań dla oprogramowania Nimbus-LC Wymagania użytkowe Historia 24-godzinna Konfiguracja użytkownika Wymagania administracyjne
Przypadki użycia systemu Nimbus-LC Aktorzy Przypadki użycia Historia pomiarów Konfiguracja Administracja
9
311 311 326 327
327 328
329
331 332 333
333 334 334
338 339 340
341 342 345 356 357
359 360
365 365 366 366
367 367 369 369
369 382 384
391 391 391 391 392 392 392
393 393 393 393 393 393
10
SPIS TREŚCI
Plan publikacji wersji dystrybucyjnych systemu Nimbus-LC Wprowadzenie Wydanie I Zagrożenia Produkty projektu Wydanie II Zaimplementowane przypadki użycia Zagrożenia Produkty projektu Wydanie III Zaimplementowane przypadki użycia Zagrożenia Produkty projektu
CZĘŚĆ VI
STUDIUM PRZYPADKU: ETS
Rozdział 28 Wzorzec projektowy Wizytator Rodzina wzorców projektowych Wizytator Wizytator Wzorzec projektowy Wizytator działa jak macierz
Wzorzec projektowy Acykliczny wizytator Wzorzec projektowy Wizytator działa jak macierz rzadka Wykorzystanie wzorca projektowego Wizytator w generatorach raportów Inne zastosowania wzorca projektowego Wizytator
Wzorzec projektowy Dekorator Wiele dekoratorów
Wzorzec projektowy Obiekt rozszerzenia Wniosek Przypomnienie
394 394 394 394 395 395 395 395 395 396 396 396 396
397 399 400 400 403
403 407 407 412
413 416
418 426 426
Bibliografia
426
Rozdział 29 Wzorzec projektowy Stan
427
Przegląd informacji o automatach stanów skończonych Techniki implementacji Zagnieżdżone instrukcje Switch/Case Interpretacja tabeli przejść
Wzorzec projektowy Stan
427 429 429 432
433
SMC — kompilator maszyny stanów
436
Kiedy należy korzystać z maszyn stanów?
439
Wysokopoziomowe strategie obsługi GUI Kontrolery interakcji z GUI Przetwarzanie rozproszone
Wniosek Listingi Implementacja klasy Turnstile.java z wykorzystaniem interpretacji tabeli przejść Klasa Turnstile.java wygenerowana przez kompilator SMC oraz inne pliki pomocnicze
Bibliografia
439 440 441
441 441 441 443
447
Rozdział 30 Framework ETS
449
Wprowadzenie
449
Przegląd informacji o projekcie Wczesny okres 1993 – 1994 Framework?
449 451 451
SPIS TREŚCI
Framework Zespół z roku 1994 Termin Strategia Wyniki
Projekt frameworka
11
452 452 452 452 453
454
Wspólne wymagania dla aplikacji oceniających Projekt frameworka do wyznaczania ocen
454 456
Przypadek zastosowania wzorca Metoda szablonowa
459
Napisać pętlę raz Wspólne wymagania dla aplikacji zdawania Projekt frameworka do zdawania Architektura menedżera zadań
Wniosek Bibliografia
Dodatek A Notacja UML. Część I: Przykład CGI System rejestrowania kursów: opis problemu Aktorzy Przypadki użycia Model dziedziny Architektura Klasy abstrakcyjne i interfejsy na diagramach sekwencji
Podsumowanie Bibliografia
Dodatek B Notacja UML. Część II: STATMUX
460 463 463 469
472 472
473 474 475 475 478 482 492
494 494
495
Definicja statystycznego multipleksera
495
Środowisko oprogramowania Ograniczenia czasu rzeczywistego Procedury obsługi przerwań wejścia Procedury obsługi przerwań wyjścia Protokoły komunikacji
496 496 497 501 502
Wniosek Bibliografia
512 512
Dodatek C Satyra na dwa przedsiębiorstwa
513
Rufus! Inc. Project Kickoff Rupert Industries Projekt Alpha
513 513
Dodatek D Kod źródłowy jest projektem Czym jest projekt oprogramowania?
Skorowidz
525 525
535
12
SPIS TREŚCI
Lista wzorców projektowych ACYKLICZNY WIZYTATOR ADAPTER AKTYWNY OBIEKT DEKORATOR FABRYKA FASADA KOMPOZYT MEDIATOR MENEDŻER ZADAŃ METODA SZABLONOWA MONOSTATE MOST OBIEKT NULL OBIEKT ROZSZERZENIA OBSERWATOR PEŁNOMOCNIK POLECENIE SCHODY DO NIEBA SERWER ABSTRAKCYJNY SINGLETON STAN STRATEGIA WIZYTATOR
403 333 169 413 281 187 307 188 469 176 194 338 201 418 327 341 165 359 332 192 433 181 400
Słowo wstępne Piszę te słowa tuż po opublikowaniu głównego wydania projektu open source Eclipse. Nadal działam „w trybie awaryjnym”, a mój umysł jest zmęczony. Ale jedna rzecz jest dla mnie jaśniejsza niż kiedykolwiek: to ludzie, a nie procesy, są kluczem do produktów. Recepta na sukces jest prosta: praca z osobami mającymi obsesję na punkcie tworzenia oprogramowania, rozwój z wykorzystaniem lekkich procesów, które są dostosowane do każdego zespołu i stale się dostosowują. „Podwójne kliknięcie” na programistach z naszych zespołów ujawnia osoby, które uznają programowanie za sedno rozwoju. Oni nie tylko piszą kod. Oni stale go „trawią”, aby coraz bardziej wgłębiać się w system. Walidacja projektów za pomocą kodu dostarcza informacji zwrotnych. Mają one kluczowe znaczenie dla uzyskania zaufania do projektu. Jednocześnie programiści rozumieją znaczenie wzorców, refaktoryzacji, testów, przyrostowych dostaw, częstego budowania i innych najlepszych praktyk programowania ekstremalnego (ang. Extreme Programming — EP), które zmieniły sposób, w jaki dziś postrzegamy metodyki. Umiejętności w tym stylu programowania są warunkiem sukcesu w projektach o wysokim ryzyku technicznym i zmieniających się wymaganiach. Zwinne wytwarzanie oprogramowania nie jest sednem ceremonii i dokumentacją projektową, ale jest intensywne, jeśli chodzi o codzienne praktyki rozwoju. Niniejsza książka koncentruje się na zastosowaniu tych technik w praktyce. Robert Martin jest długoletnim aktywistą w środowisku programistów obiektowych. Ma swój wkład w praktyki programowania w C++, wzorce projektowe i ogólne zasady projektowania obiektowego. Był wczesnym i bardzo aktywnym zwolennikiem technik EP oraz metod agile. Ta książka bazuje na tym wkładzie. Opisuje pełne spektrum praktyk zwinnego wytwarzania oprogramowania. To ambitne wyzwanie. Robert zwiększa jego trudność, demonstrując wszystko za pomocą analizy przypadków i mnóstwa kodu, tak jak przystało na praktyki agile. Wyjaśnia programowanie i projektowanie, praktycznie to robiąc. W tej książce jest mnóstwo dobrych rad dotyczących rozwoju oprogramowania. Są one dobre zarówno dla osób, które chcą zostać zwinnymi deweloperami, jak i dla tych, którzy pragną doskonalić już posiadane umiejętności. Nie mogłem się doczekać tej książki i nie jestem rozczarowany. Erich Gamma Object Technology International
Dla Ann Marie, Angeli, Micaha, Giny, Justina, Angelique, Matta i Alexis... Nie ma większego skarbu ani bogatszego znaleziska niż towarzystwo mojej rodziny i komfort ich miłości
14
PRZEDMOWA
Przedmowa
Bob, ale ty powiedziałeś, że skończysz tę książkę w zeszłym roku — Claudia Frers, UML World, 1999
Zwinne wytwarzanie oprogramowania to możliwość szybkiego tworzenia oprogramowania w obliczu szybko zmieniających się wymagań. W celu osiągnięcia tej zwinności trzeba stosować praktyki zapewniające niezbędną dyscyplinę i informacje zwrotne. Trzeba przestrzegać zasad projektowania, dzięki którym oprogramowanie stanie się elastyczne i łatwe w utrzymaniu. Trzeba znać wzorce projektowe stworzone w celu wykorzystania tych zasad w konkretnych problemach. Ta książka jest próbą połączenia tych trzech pojęć w funkcjonującą całość. Książka opisuje zasady, wzorce i praktyki. Następnie demonstruje, jak są one stosowane, poprzez dziesiątki różnych analiz przypadków. Co ważniejsze, te analizy przypadków nie zostały przedstawione jako ukończone dzieła. Są to raczej projekty w toku. Możemy zaobserwować, jak projektanci popełniają błędy, jak je identyfikują i na koniec korygują. Zobaczymy ich w roli osób rozwiązujących zagadki, rozważających niejasności i kompromisy. Będziemy obserwować akt projektowania.
PRZEDMOWA
15
Diabeł tkwi w szczegółach Ta książka zawiera mnóstwo kodu Javy i C++. Mam nadzieję, że czytelnicy uważnie przeczytają ten kod, ponieważ w dużym stopniu to kod jest sednem tej książki. Kod jest urzeczywistnieniem tego, co w tej książce chcę przekazać. Książka została napisana zgodnie z powtarzającym się wzorem. Składa się z szeregu analiz przypadków o różnych rozmiarach. Niektóre z nich są bardzo małe, a opisanie innych wymaga kilku rozdziałów. Każde studium przypadku jest poprzedzone materiałem, który ma na celu przygotowanie się do jego analizy. Na przykład studium przypadku systemu płacowego jest poprzedzone rozdziałem opisującym zasady projektowania obiektowego i wzorce stosowane w studium przypadku. Książka rozpoczyna się od omówienia praktyk i procesów stosowanych podczas wytwarzania oprogramowania. Omówienie to jest przerywane wieloma analizami przypadków i przykładami. Następnie przechodzimy do zagadnień projektowych i zasad projektowania, a potem do niektórych wzorców projektowych, dodatkowych zasad projektowania rządzących tworzeniem pakietów oraz kolejnych wzorców. Wszystkim tym tematom towarzyszą analizy przypadków. Trzeba się przygotować na czytanie kodu i analizowanie diagramów UML. Książka, którą masz zamiar przeczytać, ma bardzo techniczny charakter. Zrozumienie jej treści, podobnie jak diabeł, tkwi w szczegółach.
Trochę historii Ponad sześć lat temu napisałem książkę zatytułowaną Designing Object-Oriented C++ Applications using the Booch Method. To było dla mnie coś jak opus magnum. Jestem bardzo zadowolony z wyniku i ze sprzedaży. Niniejszą książkę rozpocząłem z zamiarem napisania drugiego wydania tamtej książki, ale okazało się, że nie przyjęła ona takiej formy. Na stronach tej książki pozostało bardzo niewiele z poprzedniej pozycji. Wykorzystałem nieco ponad trzy rozdziały, a i one zostały znacząco zmienione. Intencja, duch i wiele lekcji z tamtej książki są takie same. Ale nauczyłem się bardzo wiele o projektowaniu i wytwarzaniu oprogramowania w ciągu sześciu lat od ukazania się książki Designing Object-Oriented C++ Applications using the Booch Method. Ta książka odzwierciedla tę wiedzę. Cóż za pół dekady! Designing została wydana tuż przed tym, jak Internet skonsolidował planetę. Od tamtego czasu podwoiła się liczba skrótów, którymi posługujemy się na co dzień. Mamy wzorce projektowe, Javę, EJB, RMI, J2EE, XML, XSLT, HTML, ASP, JSP, serwlety, aplikacje serwerowe, ZOPE, SOAP, C#, .NET itd., itd. Wyznam, że było ciężko utrzymać zgodność treści rozdziałów tej książki z aktualnym stanem wiedzy w branży programowania.
Związki z Boochem W 1997 r. Grady Booch poprosił mnie o pomoc w napisaniu trzeciego wydania jego niezwykle udanej książki Object-Oriented Analysis and Design with Applications. Wcześniej pracowałem z Gradym przy okazji kilku projektów. Byłem też zapalonym czytelnikiem i kontrybutorem w jego różnych dziełach, w tym w notacji UML. Przyjąłem tę propozycję z radością. O pomoc w realizacji tego projektu poprosiłem mojego dobrego przyjaciela Jima Newkirka. W ciągu następnych dwóch lat Jim i ja napisaliśmy kilka rozdziałów do książki Boocha. Oczywiście z powodu wykonywania tamtej pracy nie mogłem poświęcić się niniejszej książce tak bardzo, jak bym chciał, ale czułem, że książka Boocha jest warta tego, by uczestniczyć w jej tworzeniu. Poza tym niniejsza książka była naprawdę po prostu drugim wydaniem Designing, a ja nie byłem do tego przekonany. Jeśli miałbym coś powiedzieć, chciałbym powiedzieć coś nowego i innego.
16
PRZEDMOWA
Niestety, tamta wersja książki Boocha nie była właściwym miejscem, by to zrobić. Trudno znaleźć czas na napisanie książki w normalnych czasach. Podczas burzliwych dni boomu dotcomów było to prawie niemożliwe. Grady był jeszcze bardziej zajęty projektem Rational oraz nowymi przedsięwzięciami, takimi jak Catapulse. Z tego powodu projekt przestał się rozwijać. W końcu zapytałem Grady’ego i wydawnictwo Addison-Wesley, czy mogę zamieścić rozdziały, które napisałem wraz z Jimem, w tej książce. Łaskawie zgodzili się. Z tego powodu kilka analiz przypadków i rozdziałów o UML pochodzi z tego źródła.
Wpływ programowania ekstremalnego Pod koniec 1998 roku pojawiła się koncepcja programowania ekstremalnego (ang. extreme programming — EP), która podała w wątpliwość słuszność naszych przekonań na temat rozwoju oprogramowania. Czy powinniśmy tworzyć wiele diagramów UML przed pisaniem kodu, czy też powinniśmy unikać wszelkiego rodzaju diagramów i po prostu pisać dużo kodu? Czy powinniśmy pisać wiele dokumentów narracyjnych, które opisują nasz projekt, czy też powinniśmy starać się tworzyć opisowy i ekspresywny kod, tak aby dokumenty pomocnicze nie były konieczne? Czy powinniśmy programować w parach? Czy powinniśmy pisać testy przed pisaniem kodu produkcyjnego? Co powinniśmy robić? Ta rewolucja przyszła w dogodnym dla mnie momencie. W drugiej połowie lat 90. Object Mentor pomógł kilku firmom rozwiązać problemy z projektami obiektowymi (ang. object-oriented — OO) i zarządzaniem projektami. Pomagaliśmy firmom realizować ich projekty. W ramach tej pomocy zaszczepialiśmy w zespołach nasze własne poglądy i stosowane praktyki. Niestety, te poglądy i praktyki nie zostały spisane. Należały one raczej do tradycji, która została ustnie przekazana naszym klientom. W 1998 roku zdałem sobie sprawę, że musimy spisać nasze procesy i praktyki, tak aby można było lepiej artykułować je klientom. W tym celu napisałem wiele artykułów na temat procesu wytwarzania oprogramowania w „C++ Report”1. Wspomnianym artykułom brakowało marki. Były one pouczające, a w niektórych przypadkach zabawne, ale zamiast kodyfikować praktyki i poglądy, które faktycznie wykorzystywaliśmy w naszych projektach, były nieświadomym kompromisem z wartościami, które zostały na nas nałożone przez dziesięciolecia. Uzmysłowił mi to Kent Beck.
Związki z Beckiem Pod koniec 1998 roku, kiedy pracowałem nad kodyfikacją procesu rekomendowanego przez witrynę Object Mentor, natknąłem się na pracę Kenta na temat programowania ekstremalnego. Informacje były rozproszone na stronie wiki Warda Cunninghama2 i pomieszane z pracami wielu innych autorów. Mimo to dzięki odrobinie pracy i cierpliwości udało mi się odczytać istotę tego, co pisał Kent. Byłem zaintrygowany, ale sceptyczny. Niektóre z kwestii ujętych w EP były dokładnie „na celu” mojej koncepcji procesu rozwoju oprogramowania. Jednak inne elementy, jak na przykład brak wyodrębnionego etapu projektowania, dziwiły mnie. Kent i ja wywodziliśmy się z bardzo odmiennych środowisk programowania. On był uznanym konsultantem Smalltalka, a ja byłem uznanym konsultantem C++. Tym dwóm światom trudno było się ze sobą komunikować. Pomiędzy nimi była przepaść paradygmatu Kuhna3.
1
Artykuły te są dostępne w sekcji „publications” na stronie http://www.objectmentor.com. Są cztery artykuły. Pierwsze trzy noszą tytuł Iterative and Incremental Development (I, II, III). Ostatni jest zatytułowany C.O.D.E. Culled Object development procEss.
2
http://c2.com/cgi/wiki. Ta strona internetowa zawiera ogromną liczbę artykułów na wiele różnych tematów. Autorami są setki, a może nawet tysiące osób. Mówi się, że tylko Ward Cunningham mógł wszcząć rewolucję społeczną za pomocą kilku linijek w Perlu.
3
W każdej wiarygodnej pracy intelektualnej napisanej w latach 1995 – 2001 musiała pojawić się wzmianka na temat „paradygmatu Kuhna”. Odnosi się on do książki Thomasa S. Kuhna The Structure of Scientific Revolutions, University of Chicago Press, 1962.
PRZEDMOWA
17
W innych okolicznościach nigdy bym nie poprosił Kenta o napisanie artykułu do czasopisma „C++ Report”. Ale zbieżność naszego myślenia o procesie wytwarzania oprogramowania mogła przełamać przepaść językową. W lutym 1999 roku spotkałem się z Kentem w Monachium na konferencji OOP. Wygłaszał wykład na temat EP w pokoju, naprzeciwko którego ja wygłaszałem wykład o zasadach projektowania obiektowego. Ponieważ nie byłem w stanie słyszeć tej wykładu, poszukałem Kenta podczas lunchu. Rozmawialiśmy o EP. Wtedy poprosiłem go, aby napisał artykuł do „C++ Report”. To był świetny artykuł o przypadku, w którym Kentowi wraz ze współpracownikiem udało się wprowadzić gruntowną zmianę projektu w żywym systemie w ciągu niewiele ponad godziny. W ciągu najbliższych kilku miesięcy przeszedłem przez powolny proces porządkowania własnych obaw na temat EP. Najbardziej obawiałem się zaakceptowania procesu, w którym nie ma wyraźnego etapu projektu „z góry”. Uświadomiłem sobie, że wzdragam się przed tym. Czyż nie miałem obowiązku wobec moich klientów oraz wobec branży jako całości, aby uczyć, że projekt jest na tyle ważny, aby poświęcić na niego czas? W końcu zdałem sobie sprawę, że tak naprawdę sam nie praktykowałem tego etapu. Nawet we wszystkich artykułach i książkach, które napisałem na temat projektowania, diagramów Boocha i diagramów UML, zawsze używałem kodu jako sposobu na sprawdzenie, czy diagramy miały sens. W całej mojej praktyce doradzania klientom poświęcałem zaledwie godzinę lub dwie na pomoc w rysowaniu diagramów, a następnie zachęcałem do badania stworzonych diagramów za pomocą kodu. Zrozumiałem, że chociaż terminy związane z technikami EP były mi obce (w znaczeniu „kuhnowskim”4), praktyki kryjące się za tymi terminami były mi znane. Moje inne obawy dotyczące EP były łatwiejsze do pokonania. Zawsze byłem zakamuflowanym zwolennikiem programowania w parach. Techniki EP pozwoliły mi wyjść z ukrycia i cieszyć się ziszczeniem mojego pragnienia programowania z partnerem. „Refaktoryzacja”, „ciągła integracja” i „klient na miejscu” były dla mnie pojęciami bardzo łatwymi do zaakceptowania. Były one bardzo blisko tego sposobu pracy, jaki polecałem moim klientom. Jedna z praktyk EP była dla mnie objawieniem. Projektowanie zgodne ze stylem „najpierw test” (ang. test-first) brzmi niewinnie, kiedy słyszy się o nim po raz pierwszy. Głosi zasadę, aby przed napisaniem kodu produkcyjnego najpierw napisać test. Cały kod produkcyjny pisze się po to, aby testy, które nie przechodzą, zaczęły przechodzić. Nie byłem przygotowany na głębokie konsekwencje, jakie mogło mieć pisanie kodu w ten sposób. Praktyka ta całkowicie zmieniła sposób, w jaki piszę oprogramowanie, i przekształciła go na lepsze. Tę transformację można zaobserwować w tej książce. Pewna część zamieszczonego kodu została napisana przed rokiem 1999. Dla tego kodu nie ma przypadków testowych. Z drugiej strony, cały kod napisany po 1999 roku jest zaprezentowany razem z testami i zazwyczaj testy są umieszczone jako pierwsze. Jestem pewien, że zauważysz różnicę. Jesienią 1999 r. byłem przekonany, że witryna Object Mentor powinna przyjąć techniki EP jako swój proces i że powinienem porzucić moją chęć opisania własnego procesu. Kent zrobił świetną robotę, artykułując praktyki i proces EP. Moje wątłe próby zbladły w porównaniu z jego pracą.
Organizacja książki Książka została podzielona na sześć głównych części, które uzupełniono kilkoma dodatkami. Część I „Zwinne wytwarzanie oprogramowania”
W tej części opisano koncepcję produkcji agile. Zaczyna się od manifestu stowarzyszenia Agile Alliance, zawiera przegląd technik programowania ekstremalnego (EP). Następnie przechodzimy do wielu niewielkich studiów przypadków, które naświetlają niektóre, indywidualne praktyki EP — szczególnie te, które mają wpływ na sposób projektowania i pisania kodu.
4
Jeśli wspomnisz Kuhna w artykule dwa razy, zasługujesz na podwójne uznanie.
18
PRZEDMOWA
Część II „Projekt agile”
Rozdziały w tej części mówią o obiektowym projekcie oprogramowania. Rozdział 1. zawiera pytanie: Co to jest projekt? Omówiono w nim problem i techniki zarządzania złożonością. Rozdział kończy się omówieniem zasad obiektowego projektu klasy. Część III „Studium przypadku: system płacowy” To największe i najbardziej kompletne studium przypadku w tej książce. Opisuje obiektowy projekt i implementację w C++ prostego systemu płacowego. W pierwszych kilku rozdziałach w tej części opisano wzorce projektowe, które wykorzystano w studium przypadku. Ostatnie dwa rozdziały zawierają pełne studium przypadku. Część IV „Podział systemu płacowego na pakiety” Ta część rozpoczyna się od opisania zasad obiektowego projektu pakietu. Następnie przechodzimy do zilustrowania tych zasad poprzez stopniowe pakowanie klas z poprzedniej części. Część V „Studium przypadku: stacja pogodowa” Ta część zawiera jedno ze studiów przypadków, które były pierwotnie planowane do książki Boocha. Studium przypadku Stacja pogodowa opisuje firmę, która podjęła znaczącą decyzję biznesową, i wyjaśnia, w jaki sposób zespół programistów Javy na to zareagował. Tak jak zwykle rozdział rozpoczyna się opisem wzorców projektowych, które będą stosowane, a kończy opisem projektu i implementacją. Część VI „Studium przypadku: ETS” Ta część zawiera opis rzeczywistego projektu, w którym uczestniczył autor. Projekt ten był prowadzony od 1999 roku. Jest to zautomatyzowany system testowy realizowany dla National Council of Architectural Registration Boards, używany do dostarczania i klasyfikowania rejestru egzaminów. Dodatki opisujące notację UML Pierwsze dwa dodatki zawierają kilka niewielkich studiów przypadków, które zostały użyte do opisania notacji UML. Dodatki różne
Jak korzystać z tej książki? Jeśli jesteś programistą... Przeczytaj tę książkę od deski do deski. Ta książka została napisana przede wszystkim dla programistów. Zawiera informacje potrzebne do tworzenia oprogramowania w zwinny sposób. Przeczytanie książki od deski do deski pozwala na zapoznanie się z praktykami, poznanie zasad, wzorców i, na koniec, studiów przypadków, które wiążą je wszystkie razem. Zintegrowanie całej tej wiedzy pomoże programistom realizować własne projekty.
Jeśli jesteś menedżerem lub analitykiem biznesowym... Przeczytaj część I „Zwinne wytwarzanie oprogramowania”. Rozdziały w tej części zawierają wyczerpujący opis zasad i praktyk wytwarzania oprogramowania zgodnie z duchem agile. Przechodzą od wymagań poprzez planowanie do testowania, refaktoryzacji i programowania. W ten sposób uzyskujesz wskazówki dotyczące tworzenia projektów i zarządzania nimi. Wiedza zdobyta w tej części pomoże Ci realizować własne projekty.
Jeśli chcesz się nauczyć UML... Najpierw przeczytaj dodatek A „Notacja UML. Część I: Przykład CGI”. Następnie przeczytaj dodatek B „Notacja UML. Część II: STATMUX”. Potem przeczytaj wszystkie rozdziały z części III „Studium przypadku: system płacowy”. Lektura w tej kolejności da Ci dobre podstawy zarówno w zakresie składni, jak i stosowania UML. Pomoże także w tłumaczeniu UML na takie języki programowania jak Java lub C++.
PRZEDMOWA
19
Jeśli chcesz nauczyć się wzorców projektowych... Aby znaleźć konkretny wzorzec, należy skorzystać z „Listy wzorców projektowych” na stronie 12. Aby ogólnie zapoznać się ze wzorcami, należy przeczytać część II „Projekt agile”. Tam zapoznasz się z zasadami projektowania. Następnie należy przeczytać część III „Studium przypadku: system płacowy”, część IV „Podział systemu płacowego na pakiety”, część V „Studium przypadku: stacja pogodowa” i część VI „Studium przypadku: ETS”. W tych częściach zdefiniowano wszystkie wzorce i pokazano, jak z nich korzystać w typowych sytuacjach.
Jeśli chcesz zapoznać się z zasadami projektowania obiektowego... Przeczytaj rozdział 2., „Projekt agile”, część III „Studium przypadku: system płacowy” oraz część IV „Podział systemu płacowego na pakiety”. W tych rozdziałach opisano zasady projektowania obiektowego i pokazano, jak je stosować.
Jeśli chcesz dowiedzieć się o metodach stosowanych w produkcji agile... Przeczytaj część I „Zwinne wytwarzanie oprogramowania”. W tej części opisano produkcję agile, począwszy od wymagań, poprzez planowanie, testowanie, refaktoryzację i programowanie.
Jeśli chcesz się trochę pośmiać... Przeczytaj dodatek C „Satyra na dwa przedsiębiorstwa”.
Podziękowania Serdeczne podziękowania kieruję do: Lowella Lindstroma, Briana Buttona, Erika Meade, Mike’a Hilla, Michaela Feathersa, Jima Newkirka, Micaha Martina, Angelique Thouvenin Martin, Susan Rosso, Talisha Jefferson, Rona Jeffriesa, Kenta Becka, Jeffa Langra, Davida Farbera, Boba Kossa, Jamesa Grenning Lance S. Lahmana, Dave’a Harrisa, Jamesa Kanze’a, Marka Webstera, Chrisa Biegaya, Alana Francisa, Fran Daniele, Patricka Lindnera, Jake’a Warde’a, Amy’ego Todda, Laury Steele, Williama Pietra, Camille Trentacoste, Vince’a O’Briena, Gregory’ego Dullesa, Lynda Castillo, Craiga Larmana, Tima Ottingera, Chrisa Lopeza, Phila Goodwina, Charlesa Tolanda, Roberta Evansa, Johna Rotha, Debbie Utley, Johna Brewera, Russ Ruter, Davida Vydry, Iana Smitha, Erica Evansa, wszystkich członków grupy Silicon Valley Patterns, Pete’a Brittinghama, Grahama Perkinsa, Philipa i Richarda MacDonald. Korektorzy książki: Pete McBreen (McBreen Consulting) Stephen J. Mellor (Projtech.com) Brian Button (Object Mentor Inc.)
Bjarne Stroustrup (AT & T Research) Micah Martin (Object Mentor Inc) James Grenning (Object Mentor Inc.)
Wielkie podziękowania należą się Grady’emu Boochowi i Paulowi Beckerowi za umożliwienie mi wykorzystania rozdziałów, które były pierwotnie planowane do umieszczenia w trzecim wydaniu książki Grady’ego Object-Oriented Analysis and Design with Applications. Specjalne podziękowania kieruję również do Jacka Reevesa za zezwolenie na wykorzystanie jego artykułu Czym jest projekt?. Kolejne specjalne podziękowania należą się Erichowi Gamma za napisanie słowa wstępnego do tej książki — mam nadzieję, że tym razem czcionki są lepiej dobrane. Wspaniałe, a niekiedy olśniewające ilustracje na początku każdego rozdziału zostały wykonane przez Jennifer Kohnke. Dekoracyjne ilustracje rozrzucone w rozdziałach to piękne produkty pracy Angeli Dawn Martin Brooks, mojej córki — jednej z radości mojego życia.
Zasoby Wszystkie kody źródłowe zamieszczone w tej książce można pobrać ze strony: http://www.helion.pl/ksiazki/zwiwyo.htm.
20
O AUTORACH
O autorach Robert C. Martin Robert C. Martin (wujek Bob) jest profesjonalnym programistą od 1970 roku oraz międzynarodowym konsultantem oprogramowania od 1990 roku. Jest założycielem Object Mentor Inc., zespołu doświadczonych konsultantów, którzy doradzają swoim klientom na całym świecie w dziedzinie C++, Javy, .NET, OO, wzorców projektowych, UML, metodologii agile i programowania ekstremalnego. W 1995 roku Robert napisał bestsellerową książkę Designing Object Oriented C++ Applications using the Booch Method, opublikowaną przez Prentice Hall. Od 1996 do 1999 roku był redaktorem naczelnym czasopisma „C++ Report”. W 1997 roku był redaktorem naczelnym książki Pattern Languages of Program Design 3, opublikowanej przez Addison-Wesley. W 1999 roku był redaktorem książki More C++ Gems, opublikowanej przez Cambridge Press. Wraz z Jamesem Newkirkiem jest współautorem książki XP in Practice, wydanej w 2001 roku przez Addison-Wesley. W 2002 roku napisał długo oczekiwaną książkę Agile Software Development: Principles, Patterns, and Practices, Prentice Hall, 2002. Opublikował dziesiątki artykułów w różnych czasopismach branżowych. Jest częstym prelegentem na konferencjach i targach międzynarodowych. Jest wesoły jak skowronek.
James W. Newkirk James Newkirk jest menedżerem i architektem oprogramowania. Podczas swojej długiej kariery zajmował się różnymi zagadnieniami — począwszy od programowania mikrokontrolerów w czasie rzeczywistym, do usług sieciowych. Był współautorem książki Extreme Programming in Practice, opublikowanej przez Addison-Wesley w 2001 roku. Od sierpnia 2000 roku pracuje z platformą .NET Framework. Współuczestniczył w rozwoju NUnit — narzędzia do testów jednostkowych dla platformy .NET.
Robert S. Koss Dr Robert S. Koss pisze oprogramowanie od 41 lat. Stosował zasady projektowania obiektowego w wielu projektach. Pełnił w nich różne role — począwszy od programisty, do starszego architekta. Dr Koss był nauczycielem w setkach kursów poświęconych technikom programowania obiektowego i językom programowania dla tysięcy studentów z całego świata. Obecnie jest zatrudniony jako starszy konsultant w Object Mentor, Inc.
CZĘŚĆ I Zwinne wytwarzanie oprogramowania
Interakcje między ludźmi są skomplikowane. Ich efekty nigdy nie są bardzo klarowne i ostre, ale znaczenie tych interakcji jest większe od jakiegokolwiek innego aspektu pracy — Tom DeMarco i Timothy Lister Peopleware1, str. 5
Zasady, wzorce i praktyki są ważne, ale to dzięki ludziom mają one jakiekolwiek znaczenie. Jak powiedział Alistair Cockburn2: „Procesy i technologie wywierają drugorzędny wpływ na wyniki projektu. Pierwszorzędny wpływ wywierają ludzie”. Nie możemy zarządzać zespołami programistów tak, jakby były to systemy składające się z elementów napędzanych przez proces. Ludzie nie są „jednostkami oprogramowania obsługującymi wtyczki 3”. Jeżeli nasze projekty mają odnieść sukces, to musimy zbudować zespoły współpracujące ze sobą i samoorganizujące się. Te firmy, które zachęcają do tworzenia takich zespołów, zyskują ogromną przewagę konkurencyjną nad tymi, które są zdania, że zespół zajmujący się rozwojem oprogramowania nie jest niczym więcej niż zbiorem małych człowiekopodobnych istot. Zgrany zespół deweloperów jest najpotężniejszą siłą rozwoju.
1
Wydanie polskie: Czynnik ludzki. Skuteczne przedsięwzięcia i wydajne zespoły, WNT, 2002 — przyp. tłum.
2
Prywatna rozmowa.
3
Termin wymyślony przez Kenta Becka.
22
ROZDZIAŁ 1. PRAKTYKI AGILE
AGILE ALLIANCE
23
R OZDZIAŁ 1
Praktyki agile
Kogut na wieży kościoła, choć wykuty z żelaza, wkrótce zostałby zniszczony przez wichurę, gdyby nie pojął szlachetnej sztuki zwracania się w stronę kierunku każdego wiatru — Heinrich Heine
Wielu z nas przeżyło koszmar projektu, który był prowadzony bez wiodącej praktyki. Brak skutecznych praktyk prowadzi do nieprzewidywalności, powtarzania błędów i zmarnowanego wysiłku. Klienci są rozczarowani przesuwaniem harmonogramów, wzrastającymi kosztami i złą jakością. Programiści są zniechęceni, ponieważ pracują coraz dłużej, by produkować coraz gorsze oprogramowanie. Kiedy raz doświadczymy takiego fiaska, zaczynamy się bać, że to doświadczenie może się powtórzyć. Te obawy motywują do stworzenia procesu, który nakłada ograniczenia na nasze działania. Wymaga określonych wyników i artefaktów. Wymyślamy te ograniczenia i wyniki, bazując na dotychczasowych doświadczeniach. Wybieramy to, co dobrze sprawdziło się w poprzednich projektach. Mamy nadzieję, że sprawdzi się ponownie, i przestajemy się bać. Jednak projekty nie są na tyle proste, aby kilka ograniczeń i artefaktów mogło niezawodnie zapobiec błędom. W miarę popełniania kolejnych błędów diagnozujemy je. W rezultacie dodajemy nowe ograniczenia i artefakty po to, aby zapobiec tym błędom w przyszłości. Po wielu projektach proces staje się ogromny i przeciążony, przez co znacznie utrudnia zrobienie czegokolwiek. Wielki, kłopotliwy proces może stwarzać te same problemy, którym powinien zapobiegać. Może spowolnić zespół do tego stopnia, że harmonogramy zaczną się przesuwać, a koszty nadmiernie rosnąć. Może zmniejszyć responsywność zespołu tak mocno, że zawsze będzie on tworzył zły produkt. Niestety,
24
ROZDZIAŁ 1. PRAKTYKI AGILE
w wielu przypadkach członkowie zespołów zaczynają wtedy uważać, że ich proces jest niedostatecznie rozwinięty. Z tego powodu popadają w rodzaj proceduralnej inflacji, jeszcze bardziej rozbudowując proces. Proceduralna inflacja to dobre określenie stanu rzeczy w wielu firmach zajmujących się produkcją oprogramowania około roku 2000. Choć było jeszcze wiele zespołów, które działały kompletnie bez procedur, coraz powszechniejsze — szczególnie w dużych korporacjach — stawało się przyjmowanie bardzo rozbudowanych, ciężkich procesów (patrz dodatek C).
Agile Alliance Na początku 2001 r. spotkała się grupa ekspertów z branży, aby przedstawić wartości i zasady, które pozwoliłyby zespołom programistycznym na sprawną pracę i szybkie reagowanie na zmiany. Motywacją była obserwacja pracy zespołów programistycznych, które w wielu korporacjach ugrzęzły w bagnie coraz bardziej rozbudowanych procesów. Grupa przyjęła nazwę Agile Alliance4. W ciągu najbliższych kilku miesięcy członkowie grupy stworzyli listę nowych wartości: tzw. Manifest Agile Alliance.
Manifest Agile Alliance Manifest zwinnego wytwarzania oprogramowania. Rozwijając oprogramowanie i pomagając robić to innym, odkrywamy, jak można robić to lepiej. Dzięki tej pracy doszliśmy do następujących wartości:
Ludzie i interakcje ważniejsze niż procesy i narzędzia. Działające oprogramowanie ważniejsze niż kompleksowa dokumentacja. Współpraca z klientem ważniejsza niż negocjacje kontraktu. Reagowanie na zmiany ważniejsze niż przestrzeganie planu.
Chociaż doceniamy to, co podano po prawej stronie, bardziej cenimy to, co znajduje się po lewej. Kent Beck Ward Cunningham Andrew Hunt Robert C. Martin Dave Thomas
Mike Beedle Martin Fowler Ron Jeffries Steve Mellor
Arie van Bennekum James Grenning Jon Kern Ken Schwaber
Alistair Cockburn Jim Highsmith Brian Marick Jeff Sutherland
Ludzie i interakcje ważniejsze niż procesy i narzędzia. Najważniejszym składnikiem sukcesu są ludzie. Dobry proces nie ustrzeże projektu przed niepowodzeniem, jeśli w zespole nie ma silnych graczy, ale zły proces może spowodować, że nawet najsilniejsi gracze będą nieskuteczni. Nawet grupa silnych graczy może przegrać z kretesem, jeśli nie będą działać zespołowo. Silny gracz niekoniecznie musi być doskonałym programistą. Może to być przeciętny programista, ale taki, który dobrze pracuje z innymi. Skuteczna praca z innymi, komunikacja i interakcje są ważniejsze niż surowy talent do programowania. Zespół przeciętnych programistów, którzy dobrze się ze sobą komunikują, ma większe szanse powodzenia niż grupa supergwiazd niepotrafiących współpracować jako zespół. Odpowiednie narzędzia mogą być bardzo ważne dla osiągnięcia sukcesu. Kompilatory, środowiska IDE, systemy kontroli wersji kodu źródłowego itd. Wszystko to jest niezbędne dla prawidłowego funkcjonowania zespołu programistów. Jednak narzędzia łatwo można przecenić. Nadmiar rozbudowanych, nieporęcznych narzędzi jest tak samo zły jak ich brak.
4
agilealliance.org
AGILE ALLIANCE
25
Radzę, aby zaczynać od prostych narzędzi. Nie należy zakładać, że narzędzie nie nadaje się do użytku, dopóki go nie wypróbujemy i nie przekonamy się, że nie da się go używać. Zamiast kupować najlepszy, superdrogi system kontroli kodu źródłowego, lepiej znaleźć darmowy i używać go tak długo, aż uda się nam wykazać, że „z niego wyrośliśmy”. Przed zakupem wielostanowiskowej licencji najlepszego na rynku narzędzia CASE lepiej używać zwykłych białych tablic i papieru milimetrowego tak długo, aż będzie można racjonalnie wykazać, że potrzeba czegoś więcej. Przed wdrożeniem superzaawansowanego systemu baz danych warto spróbować używać kartotekowej bazy danych bazującej na „zwykłych plikach”. Nie należy zakładać, że większe i lepsze narzędzia automatycznie sprawią, że staniemy się lepsi w danej dziedzinie. Często bardziej utrudnią pracę, niż pomogą. Należy pamiętać, że budowanie zespołu jest ważniejsze niż budowanie środowiska. Wiele zespołów i wielu menedżerów popełnia błąd, budując najpierw środowisko i spodziewając się, że zespół stworzy się sam. Zamiast tego lepiej popracować nad stworzeniem zespołu, a potem pozwolić zespołowi skonfigurować środowisko w oparciu o swoje potrzeby. Działające oprogramowanie ważniejsze niż kompleksowa dokumentacja. Oprogramowanie bez dokumentacji to katastrofa. Kod nie jest idealnym medium do wyrażania decyzji projektowych i struktury systemu. Zespół powinien stworzyć dokumenty czytelne dla ludzi, które opisują system i uzasadniają podjęte decyzje. Jednak zbyt obszerna dokumentacja jest gorsza niż zbyt uboga. Wytwarzanie rozbudowanych dokumentów dotyczących oprogramowania zajmuje dużo czasu. Jeszcze więcej czasu pochłania utrzymanie synchronizacji tych dokumentów z kodem. Jeśli dokumentacja nie jest zsynchronizowana z kodem, zamienia się w obszerny, skomplikowany stek kłamstw i staje się znaczącym źródłem dezorientacji. Stworzenie przez zespół i utrzymywanie dokumentu uzasadniającego decyzje projektowe i strukturę projektu to dobry pomysł, ale dokument ten musi być krótki i wyrazisty. Przez „krótki” rozumiem co najwyżej 20 – 30 stron. „Wyrazisty”, czyli taki, który podaje ogólne uzasadnienie decyzji projektowych i tylko struktury najwyższego poziomu w systemie. W jaki sposób szkolić nowych członków zespołu, jeśli dysponujemy tylko dokumentem, który prezentuje krótkie uzasadnienie decyzji projektowych oraz strukturę? Poprzez ścisłą współpracę z nimi. Przekazujemy im wiedzę, siedząc obok nich i pomagając im. Czynimy z nich część zespołu poprzez szkolenia i interakcje. Kod i zespół to dwa elementy, które najlepiej sprawdzają się w roli środka przekazywania informacji nowym członkom zespołu. Kod nie kłamie na temat tego, co robi. Wyodrębnienie z kodu uzasadnienia decyzji projektowych i intencji może być trudne, ale kod jest jedynym jednoznacznym źródłem informacji. Członkowie zespołu posiadają stale zmieniającą się mapę drogową systemu w swoich głowach. Nie ma szybszego i bardziej wydajnego sposobu przekazywania tej mapy drogowej innym niż interakcje międzyludzkie. Wiele zespołów poniosło porażkę, dążąc bardziej do tworzenia dokumentacji niż do rozwoju oprogramowania. Często jest to fatalny błąd. Istnieje prosta zasada — nazywam ją pierwszym prawem dokumentacji Martina — która temu zapobiega: Nie twórz żadnych dokumentów, jeżeli nie są potrzebne natychmiast i nie są znaczące. Współpraca z klientem ważniejsza niż negocjacje kontraktu. Oprogramowania nie można zamówić tak jak innych towarów. Nie można przekazać krótkiego opisu oprogramowania, które nas interesuje, a następnie zlecić, aby ktoś je wykonał, stosując się do ustalonego harmonogramu i za określoną cenę. Próby traktowania projektów programistycznych w taki sposób wiele razy kończyły się niepowodzeniem. Czasami niepowodzenia były spektakularne. Często menedżerowie firm chcieliby przedstawić zespołowi programistów swoje potrzeby, a potem oczekują, że zespół zniknie na jakiś czas i wróci z systemem, który spełnia te potrzeby. Jednak ten tryb pracy prowadzi do słabej jakości i niepowodzeń.
26
ROZDZIAŁ 1. PRAKTYKI AGILE
Udane projekty uwzględniają opinie klientów stale i często. Zamiast zawierać kontrakt lub zlecać pracę, klient zamawiający oprogramowanie ściśle współpracuje z deweloperami, dostarczając im częstych uwag na temat ich pracy. Sporządzenie kontraktu, który określa wymagania, harmonogram i koszt projektu, jest zasadniczym błędem. W większości przypadków warunki określone w takim kontrakcie tracą sens na długo przed ukończeniem projektu5. Najlepsze kontrakty to takie, które regulują sposób współpracy pomiędzy zespołem deweloperskim a klientem. Jako przykład udanej umowy podam kontrakt, który wynegocjowałem w 1994 r. Kontrakt dotyczył dużego, wielowarstwowego projektu obejmującego pół miliona linii kodu. Zespołowi programistów były wypłacane stosunkowo niskie stawki miesięczne. Duże wypłaty były realizowane po dostarczeniu określonych dużych bloków funkcjonalności. Bloki te nie były szczegółowo określone w kontrakcie. Przeciwnie, kontrakt określał, że wypłata za blok nastąpi wówczas, gdy ten blok przejdzie test akceptacyjny klienta. Szczegóły testów akceptacyjnych nie były określone w kontrakcie. W trakcie realizacji tego projektu bardzo ściśle współpracowaliśmy z klientem. Wersje dystrybucyjne oprogramowania były dostarczane niemal w każdy piątek. Do poniedziałku lub wtorku następnego tygodnia klient wręczał nam listę zmian, które mieliśmy uwzględnić w oprogramowaniu. Wspólnie nadawaliśmy tym zmianom priorytety i planowaliśmy realizację w kolejnych tygodniach. Klient współpracował z nami tak ściśle, że testy akceptacyjne nigdy nie były problemem. Wiedział, kiedy blok funkcjonalności spełnia jego potrzeby, bo obserwował, jak on się rozwija z tygodnia na tydzień. Wymagania w tym projekcie stale się zmieniały. Poważne zmiany nie należały do rzadkości. Usuwane były całe bloki funkcjonalności, a na ich miejsce były wstawiane inne. Pomimo tego kontrakt i projekt przetrwały i zakończyły się sukcesem. Kluczem do tego sukcesu była intensywna współpraca z klientem oraz kontrakt, który regulował tę współpracę, choć nie określał ani szczegółów zakresu, ani harmonogramu, ani ceny. Reagowanie na zmiany ważniejsze niż podążanie za planem. To zdolność do reagowania na zmiany często decyduje o sukcesie lub porażce projektu oprogramowania. Kiedy tworzymy plany, musimy mieć pewność, że są one elastyczne i gotowe do uwzględnienia zmian w biznesie i technologii. Przebieg projektu oprogramowania nie może być planowany zbyt daleko w przyszłość. Po pierwsze, może się zmienić otoczenie biznesowe, a to może spowodować zmianę wymagań. Po drugie, klienci mogą zmienić wymagania, gdy zobaczą, jak system zaczyna funkcjonować. I wreszcie nawet gdy znamy wymagania i jesteśmy pewni, że się nie zmienią, nie ma dobrego sposobu oszacowania, ile czasu zajmie ich spełnienie. Początkujący menedżerowie często ulegają pokusie, aby stworzyć piękny wykres PERT lub wykres Gantta dla całego projektu i przykleić go na ścianie. Mają wrażenie, że ten wykres daje im kontrolę nad projektem. Mogą śledzić poszczególne zadania i skreślać je na wykresie, gdy zostaną zrealizowane. Mogą porównywać rzeczywiste daty z datami planowanymi na wykresie i reagować na wszelkie rozbieżności. W praktyce struktura tego wykresu degraduje się. W miarę jak zespół zdobywa wiedzę na temat systemu oraz w miarę jak klienci uświadamiają sobie swoje potrzeby, niektóre zadania na wykresie stają się niepotrzebne. Odkrywane są nowe zadania, które trzeba dodać. Krótko mówiąc, zmienia się kształt planu, a nie tylko terminy. Lepszą strategią planowania jest wykonywanie szczegółowych planów na najbliższe dwa tygodnie, mniej dokładnych planów na najbliższe trzy miesiące i bardzo ogólnych planów na dłuższy okres. Powinniśmy znać zadania, nad którymi będziemy pracować przez najbliższe dwa tygodnie. Powinniśmy z grubsza znać wymagania, nad którymi będziemy pracować przez najbliższe trzy miesiące. I powinniśmy mieć tylko mgliste pojęcie na temat tego, co system będzie robić za rok. To zmniejszenie rozdzielczości planu oznacza, że inwestujemy w szczegółowy plan tylko dla tych zadań, które są przeznaczone do natychmiastowej realizacji. Po wykonaniu szczegółowego planu trudno go zmienić, ponieważ zespół nabiera rozmachu i angażuje się w projekt. Ponieważ jednak plan obejmuje tylko kilka tygodni, pozostała jego część pozostaje elastyczna. 5
Czasami na długo przed podpisaniem kontraktu.
ZASADY
27
Zasady Powyższe wartości zainspirowały opracowanie wymienionych poniżej 12 zasad. Są to cechy, które odróżniają zbiór praktyk agile od ciężkiego procesu: Najwyższym priorytetem jest zadowolenie klienta. Zapewniamy je poprzez ciągłe dostarczanie warto-
6
ściowego oprogramowania. W czasopiśmie „MIT Sloan Management Review” opublikowano analizę praktyk tworzenia oprogramowania, które pomagają firmom budować produkty wysokiej jakości6. W artykule wymieniono wiele praktyk, które miały istotny wpływ na jakość końcowego systemu. Jedną z nich była silna korelacja między jakością a wczesnym dostarczaniem częściowo działającego systemu. W artykule stwierdzono, że „im mniej funkcji w początkowej wersji dystrybucyjnej, tym wyższa jakość wersji końcowej”. Kolejnym wnioskiem płynącym z tego artykułu jest silna korelacja pomiędzy końcową jakością a częstymi dostawami coraz obszerniejszej funkcjonalności. „Im częstsze dostawy, tym wyższa końcowa jakość”. Zgodnie z praktyką agile dostawy powinny być realizowane wcześnie i często. Staramy się dostarczyć podstawowy system w ciągu kilku pierwszych tygodni od rozpoczęcia projektu. Następnie co dwa tygodnie dostarczamy systemy o rosnącej funkcjonalności. Klienci mogą zdecydować o przekazaniu tych systemów do eksploatacji, jeśli uważają, że mają one wystarczającą funkcjonalność. Mogą również dokonać przeglądu istniejących funkcji i przedstawić informacje o zmianach, które chcieliby wprowadzić. Zmieniające się wymagania są mile widziane nawet w późnej fazie rozwoju. Procesy agile uwzględniają zmiany uzasadnione dążeniem klienta do uzyskania przewagi nad konkurentami na rynku. To deklaracja przyjętej postawy. Uczestnicy procesu agile nie boją się zmian. Zmiany w wymaganiach postrzegają jako coś dobrego, ponieważ te zmiany oznaczają, że zespół nauczył się więcej o tym, co trzeba zrobić, aby zaspokoić rynek. Zwinny zespół ciężko pracuje nad tym, aby utrzymać elastyczność struktury tworzonego oprogramowania, dlatego kiedy zmienią się wymagania, ma to minimalny wpływ na system. W dalszej części tej książki poznasz zasady i wzorce projektowania obiektowego, które pomagają w utrzymaniu tego rodzaju elastyczności. Dostarczamy działające oprogramowanie często — od kilku tygodni do kilku miesięcy — z preferencją do krótszej skali czasowej. Dostarczamy działające oprogramowanie. Dostarczamy je wcześnie (po kilku pierwszych tygodniach) i często (co kilka tygodni). Nie czerpiemy satysfakcji z dostarczania zbioru dokumentów i planów. To nie są prawdziwe dostawy. Kładziemy nacisk na dostarczanie oprogramowania, które spełnia potrzeby klienta. Ludzie biznesu i deweloperzy muszą współpracować codziennie w trakcie trwania całego projektu. Aby projekt był zwinny, muszą istnieć znaczące i częste interakcje pomiędzy klientami, deweloperami i interesariuszami. Projekt oprogramowania nie jest bronią typu „wystrzel i zapomnij”. Projekt oprogramowania musi być stale zarządzany. Projekty należy budować wokół zmotywowanych osób. Należy zapewnić im środowisko i wsparcie, którego potrzebują, oraz zaufać, że wykonają zadanie. Zwinny projekt to taki, w którym ludzie są najważniejszym czynnikiem sukcesu. Wszystkie inne elementy — procesy, środowiska, zarządzanie itd. — są uważane za drugorzędne i mogą być zmienione, jeśli wywierają negatywny wpływ na ludzi. Na przykład jeśli przeszkodą dla zespołu jest środowisko biurowe, to należy je zmienić. Jeśli przeszkadzają jakieś etapy procesu, to trzeba wyeliminować te etapy.
Product-Development Practices That Work: How Internet Companies Build Software, „MIT Sloan Management Review”, zima 2001, numer przedruku 4226.
28
ROZDZIAŁ 1. PRAKTYKI AGILE
Najbardziej wydajnym i skutecznym sposobem przekazywania informacji zespołowi projektowemu
oraz wewnątrz zespołu są bezpośrednie rozmowy. W projekcie agile ludzie ze sobą rozmawiają. Rozmowa jest podstawową formą komunikacji. Można tworzyć dokumenty, ale nie dąży się do tego, aby wszystkie informacje o projekcie były przedstawione w formie pisemnej. Zwinny zespół projektowy nie wymaga specyfikacji, planów lub projektów w formie pisemnej. Członkowie zespołu mogą je tworzyć, jeśli postrzegają natychmiastową i znaczącą potrzebę ich istnienia, ale nie są one domyślnym środkiem komunikacji. Jest nim rozmowa. Podstawowym miernikiem postępu jest działające oprogramowanie. W projektach agile postępy mierzy się ilością oprogramowania, które w danym momencie spełnia oczekiwania klienta. Postępów nie mierzymy fazą, którą w danym momencie realizujemy, ani objętością dokumentacji, która została wytworzona, ani ilością napisanego kodu infrastrukturalnego. Projekt jest zrealizowany w 30%, jeśli działa 30% jego koniecznych funkcji. Procesy agile promują równomierny rozwój. Sponsorzy, deweloperzy i użytkownicy powinni mieć możliwość utrzymania stałego tempa przez czas nieokreślony. Zwinny projekt nie jest 100-metrowym sprintem. To raczej maraton. Zespół nie startuje z pełną szybkością, którą stara się utrzymać przez cały czas. Zamiast tego biegnie w dość szybkim, ale równomiernym tempie. Zbyt szybkie tempo prowadzi do wypalenia, „chodzenia na skróty” i końcowej klęski. Zespół agile sam określa swoje tempo. Nie wolno dopuścić do zbyt wielkiego zmęczenia. Nie da się pożyczyć energii z jutra, aby zrobić nieco więcej dziś. Zespół pracuje w tempie, które pozwala zachować najwyższe standardy jakości przez cały czas trwania projektu. Ciągła dbałość o doskonałość techniczną i dobry projekt zwiększa zwinność. Wysoka jakość jest kluczem do wysokiego tempa. Drogą do zapewnienia szybkiego rozwoju jest utrzymanie oprogramowania w maksymalnie czystej i solidnej formie. W związku z tym wszyscy członkowie zespołu agile są zobowiązani do tworzenia kodu o najwyższej jakości, jaką potrafią zapewnić. Nie robią bałaganu, który posprzątają, gdy będą mieli więcej czasu. Jeśli zrobią bałagan, to sprzątają go przed końcem dnia. Prostota — sztuka maksymalizacji ilości wykonanej pracy — ma znaczenie kluczowe. Zespoły agile nie próbują budować wielkich systemów, bujając w obłokach. Zawsze starają się przyjąć najprostszą ścieżkę, która jest zgodna z ich celami. Nie przykładają zbyt dużej wagi do przewidywania przyszłych problemów ani nie starają się bronić przed wszystkimi problemami naraz. Wykonują dziś swoją pracę jak najprościej, dbając o jak najwyższą jakość. Dzięki temu zespoły są przekonane, że z łatwością dokonają zmian, kiedy jutro pojawią się problemy. Najlepsze architektury, wymagania i projekty powstają w samoorganizujących się zespołach. Zwinny zespół to zespół samoorganizujący się. Obowiązki nie są przekazywane z zewnątrz poszczególnym członkom zespołu. Są one przekazywane zespołowi jako całości i to zespół określa najlepszy sposób wykonania zadań. Członkowie zwinnego zespołu wspólnie pracują nad wszystkimi aspektami projektu. Każdy może mieć wkład w całość. Żaden pojedynczy członek zespołu nie jest odpowiedzialny za architekturę, wymagania czy testy. Zespół współdzieli te obowiązki, a każdy członek zespołu może mieć wpływ na sposób ich wypełniania. W regularnych odstępach czasu zespół zastanawia się, jak stać się bardziej wydajnym, a następnie odpowiednio dostosowuje swoje działania. Zwinny zespół nieustannie dostosowuje swoją organizację, zasady, konwencje, relacje itp. Wie, że jego środowisko stale się zmienia, i ma świadomość, że sam również musi się zmieniać w tym środowisku, aby pozostać zwinnym.
BIBLIOGRAFIA
29
Wniosek Zawodowym celem każdego programisty i każdego zespołu projektowego jest dostarczanie możliwie najwyższej wartości swoim pracodawcom i klientom. A jednak projekty programistyczne kończą się niepowodzeniem — i to w przerażającej liczbie przypadków. Pomimo dobrych intencji za co najmniej część z tych niepowodzeń jest odpowiedzialna rosnąca spirala proceduralnej inflacji. Zasady i wartości zwinnego wytwarzania oprogramowania powstały jako sposób na to, by pomóc zespołom przerwać cykl proceduralnej inflacji i skupić się na prostych technikach osiągania celów. W chwili pisania tej książki do wyboru było wiele odmian metodologii zwinnego wytwarzania oprogramowania. Należą do nich SCRUM7, Crystal8, Feature Driven Development9, Adaptive Software Development (ADP)10 i, co najważniejsze, programowanie ekstremalne11.
Bibliografia 1. Kent Beck, Extreme Programming Explained: Embracing Change, Reading, MA: Addison-Wesley, 1999. 2. James Newkirk, Robert Martin, Extreme Programming in Practice, Upper Saddle River, NJ: Addison-Wesley, 2001. 3. James Highsmith, Adaptive Software Development: A Collaborative Approach to Managing Complex Systems, New York, NY: Dorset House, 2000.
7
www.controlchaos.com
8
crystalmethodologies.org
9
Java Modeling In Color With UML: Enterprise Components and Process, Peter Coad, Eric Lefebvre i Jeff De Luca, Prentice Hall, 1999.
10
[Highsmith2000].
11
[Beck1999], [Newkirk2001].
30
ROZDZIAŁ 1. PRAKTYKI AGILE
R OZDZIAŁ 2
Przegląd informacji o programowaniu ekstremalnym
My deweloperzy musimy pamiętać, że świat nie kończy się na technikach EP — Pete McBreen
W poprzednim rozdziale zaprezentowano zarys tego, co nazywamy zwinnym wytwarzaniem oprogramowania. Nie udzielono jednak dokładnej odpowiedzi, co mamy robić. Przedstawiono kilka ogólników i celów, ale to niezbyt daleko posunęło nas w prawidłowym kierunku. W niniejszym rozdziale skorygowano ten problem.
Praktyki programowania ekstremalnego Programowanie ekstremalne jest najbardziej znaną spośród zwinnych metod wytwarzania oprogramowania. Składa się z zestawu prostych, ale współzależnych od siebie praktyk. Praktyki te współpracują ze sobą, tworząc całość, która jest większa od pojedynczych części. Tę całość krótko przeanalizujemy w tym rozdziale, natomiast niektóre części omówimy w kolejnych rozdziałach.
32
ROZDZIAŁ 2. PRZEGLĄD INFORMACJI O PROGRAMOWANIU EKSTREMALNYM
Klient jest członkiem zespołu Chcemy, aby klient i deweloperzy ściśle ze sobą współpracowali. Powinni wzajemnie rozumieć swoje problemy i razem pracować nad ich rozwiązaniem. Kim jest klient? Klient zespołu EP to osoba lub grupa osób, która określa priorytety i funkcje. Czasami klientem jest grupa analityków biznesowych lub specjalistów od marketingu pracujących w tej samej firmie co deweloperzy. Czasami klient jest przedstawicielem użytkownika wykonującym jego polecenia. Innym razem klient jest tą osobą, która płaci za produkt. Ale w projekcie EP niezależnie od tego, kim są klienci, są oni członkami zespołu i pozostają do dyspozycji zespołu. Najlepiej, jeśli klient pracuje w tym samym pomieszczeniu co deweloperzy. Trochę gorzej, jeśli klient działa w odległości kilkudziesięciu metrów od deweloperów. Im większa odległość, tym trudniej klientowi stać się prawdziwym członkiem zespołu. Jeśli klient jest w innym budynku lub w innym państwie, to zintegrowanie go z zespołem jest bardzo trudne. Co zrobić, jeśli klienta po prostu nie może być w pobliżu? Moja rada jest taka, aby znaleźć kogoś, kto może być blisko i kto chce i jest w stanie spełniać rolę prawdziwego klienta.
Historyjki użytkowników Aby zaplanować projekt, musimy wiedzieć coś na temat wymagań, ale nie musimy wiedzieć o tym zbyt dużo. Dla celów planowania musimy wiedzieć tylko tyle, aby móc oszacować wymagania. Można by sądzić, że w celu oszacowania wymagań trzeba znać wszystkie szczegóły, ale to nie do końca jest prawdą. Trzeba wiedzieć, że istnieją szczegóły, i trzeba wiedzieć mniej więcej, jakie rodzaje informacji są wykorzystywane, ale nie trzeba znać wszystkich detali. Konkretne detale dotyczące wymagania mogą z czasem się zmieniać, zwłaszcza gdy klient zaczyna widzieć całość systemu. Nic nie skupia uwagi na wymaganiach lepiej niż obserwacja systemu rodzącego się do życia. Dlatego uchwycenie wszystkich szczegółów wymagania na długo, zanim zostanie ono zaimplementowane, niesie ryzyko straty czasu i przedwczesnego skupienia uwagi. W przypadku korzystania z technik EP poczucie szczegółów wymagania uzyskujemy dzięki omawianiu ich z klientem, ale nie koncentrujemy się na detalach. Klient pisze kilka słów na kartce papieru. Tych kilka słów przypomni nam o rozmowie. Deweloperzy piszą oszacowania na kartce papieru mniej więcej w tym samym czasie, kiedy klient zapisuje swoją historyjkę. Oszacowania bazują na takim sposobie postrzegania detali, jaki uzyskali podczas rozmów z klientem. Historyjka użytkownika jest mnemonikiem stale trwającej rozmowy o wymaganiach. Jest to narzędzie planowania, z którego korzysta klient w celu zaplanowania implementacji wymagania zgodnie z jego priorytetem i szacunkowymi kosztami.
Krótkie cykle W projekcie EP działające oprogramowanie jest dostarczane co dwa tygodnie. W każdej z tych dwutygodniowych iteracji jest tworzone działające oprogramowanie, które dotyczy niektórych potrzeb interesariuszy. Na koniec każdej iteracji system jest prezentowany interesariuszom w celu uzyskania ich opinii. Plan iteracji. Iteracja zazwyczaj trwa dwa tygodnie. Reprezentuje pomocnicze wydanie, które może zostać wdrożone do produkcji, lecz nie musi. Jest to zbiór historyjek użytkowników wybranych przez klienta według budżetu ustalonego przez deweloperów. Deweloperzy ustalają budżet iteracji poprzez zmierzenie kosztu zadań zrealizowanych w poprzedniej iteracji. Klient może wybierać dowolną liczbę historyjek w iteracji, dopóki całkowita wartość szacowanego kosztu nie przekracza budżetu. Po rozpoczęciu iteracji klient zobowiązuje się nie zmieniać definicji ani priorytetu historyjek w tej iteracji. W tym czasie deweloperzy mogą podzielić historyjki na zadania i realizować je w kolejności, która ma największy sens z technicznego i biznesowego punktu widzenia.
PRAKTYKI PROGRAMOWANIA EKSTREMALNEGO
33
Plan wersji dystrybucyjnej. Zespoły EP często tworzą plany wersji dystrybucyjnych, które pokrywają sześć kolejnych iteracji. Jest to tzw. plan wersji dystrybucyjnej (ang. release plan). Opracowanie wersji dystrybucyjnej zazwyczaj zajmuje trzy miesiące. Wersja dystrybucyjna reprezentuje główne wydanie, które zazwyczaj może być wprowadzone do produkcji. Plan wersji dystrybucyjnej składa się z uszeregowanej według priorytetów kolekcji historyjek użytkowników, które zostały wybrane przez klienta zgodnie z budżetem ustalonym przez deweloperów. Deweloperzy ustalają budżet wersji dystrybucyjnej na podstawie kosztu zadań zrealizowanych w poprzedniej wersji. Klient może wybierać dowolną liczbę historyjek w wersji dystrybucyjnej, dopóki łączna wartość szacunkowego kosztu nie przekracza budżetu. Klient decyduje również o kolejności, w jakiej te historyjki będą implementowane w wersji dystrybucyjnej. Jeśli zespół sobie tego życzy, może określić kilka pierwszych iteracji wersji dystrybucyjnej, pokazując, które historyjki będą zrealizowane w każdej z iteracji. Wersje dystrybucyjne nie są rzeźbą w kamieniu. Klient może zmienić zawartość wersji w dowolnym momencie. Może anulować niektóre historyjki, napisać nowe historyjki lub zmienić priorytet historyjek.
Testy akceptacyjne Szczegóły dotyczące historyjek użytkowników są odzwierciedlone w formie testów akceptacyjnych określonych przez klienta. Testy akceptacyjne dla historyjki są pisane bezpośrednio przed lub nawet jednocześnie z implementacją tej historyjki. Są pisane w języku skryptowym, który pozwala na uruchomienie ich w sposób automatyczny i wielokrotny. Ich ogólnym celem jest zweryfikowanie, czy system zachowuje się tak, jak określił klient. Język testów akceptacyjnych rośnie i ewoluuje wraz z systemem. Klienci mogą zatrudnić programistów do stworzenia prostego systemu skryptów lub mogą korzystać z odrębnego działu zapewnienia jakości (ang. quality assurance — QA), który je opracuje. Wielu klientów korzysta z pomocy działów QA do opracowania narzędzia do testów akceptacyjnych oraz do samego pisania testów akceptacyjnych. Kiedy test akceptacyjny przejdzie, jest on dodawany do zbioru testów akceptacyjnych i nigdy nie wolno dopuścić, aby test ponownie nie przechodził. Ten rosnący zbiór testów akceptacyjnych jest uruchamiany kilka razy dziennie, za każdym razem, gdy system jest budowany. Jeśli testy akceptacyjne nie przechodzą, kompilacja jest uznawana za nieudaną. Zatem gdy wymaganie zostanie zaimplementowane, to nigdy nie przestanie być spełnione. System migruje z jednego stanu działania do drugiego i nigdy nie może przestać działać przez dłuższy czas niż kilka godzin.
Programowanie parami Cały kod produkcyjny jest pisany przez pary programistów pracujących razem przy tej samej stacji roboczej. Jedna osoba z każdej pary steruje klawiaturą i wpisuje kod. Druga osoba obserwuje wpisywany kod, zwracając uwagę na błędy i szukając ulepszeń1. Te dwie osoby intensywnie współpracują. Obie są w pełni zaangażowane w akt pisania oprogramowania. Role często się zmieniają. Osoba sterująca klawiaturą może się zmęczyć lub poczuć, że utknęła w martwym punkcie. Wtedy partner przejmuje klawiaturę i zaczyna „prowadzić”. Klawiatura jest przekazywana pomiędzy nimi kilka razy w ciągu godziny. Projektantami i autorami otrzymanego w ten sposób kodu są obaj programiści. Żaden z nich nie wnosi więcej niż połowę. Członkowie par zmieniają się co najmniej raz dziennie. Dzięki temu każdy programista pracuje w dwóch różnych parach każdego dnia. W trakcie iteracji każdy członek zespołu powinien pracować z każdym innym członkiem zespołu. Powinni oni pracować prawie nad wszystkimi funkcjami, które są realizowane w tej iteracji. 1
Widziałem pary, w których jedna osoba sterowała klawiaturą, a druga myszą.
34
ROZDZIAŁ 2. PRZEGLĄD INFORMACJI O PROGRAMOWANIU EKSTREMALNYM
Taki sposób działania znacząco zwiększa rozpowszechnianie wiedzy w zespole. Chociaż specjalności pozostają, a zadania, które wymagają specyficznych umiejętności, zazwyczaj są przydzielane odpowiednim specjalistom, ci specjaliści będą pracować w parach z niemal wszystkimi członkami w zespole. To powoduje rozprzestrzenianie się specjalności w zespole, dzięki czemu członkowie zespołu mogą bez trudu przejmować obowiązki innych. Z badań przeprowadzonych przez Laurie Williamsa2 i Noska3 wynika, że praca parami nie zmniejsza wydajności programistów, a znacznie obniża wskaźnik awaryjności.
Programowanie sterowane testami W rozdziale 4., który poświęcono testowaniu, szczegółowo omówiono programowanie sterowane testami (ang. test-driven development — TDD). W poniższych akapitach zamieszczono szybki przegląd informacji na ten temat. Cały kod produkcyjny pisze się po to, aby testy jednostkowe, które nie przechodzą, zaczęły przechodzić. Najpierw pisze się test jednostkowy, który nie przechodzi, ponieważ funkcja, dla której jest on pisany, nie istnieje. Następnie piszemy kod, który sprawia, że określony test zaczyna przechodzić. Iteracja pomiędzy pisaniem przypadków testowych i kodu jest bardzo szybka — rzędu minut. Przypadki testowe i kod ewoluują wspólnie. Przypadki testowe wpływają na kod w bardzo niewielkim stopniu (przykład można znaleźć w rozdziale 6. „Epizod programowania”). W rezultacie wraz z kodem powstaje kompletny zbiór testów. Te testy pozwalają programistom sprawdzać, czy program działa. Jeśli para wprowadzi niewielkie zmiany, może uruchomić testy, aby upewnić się, czy niczego nie zepsuła. To znacznie ułatwia refaktoryzację (omówioną później). Kiedy piszemy kod po to, aby test zaczął przechodzić, ten kod z definicji staje się sprawdzalny. Dodatkowo istnieje silna motywacja do oddzielenia od siebie modułów tak, aby każdy z nich mógł być niezależnie testowany. Zatem projekt kodu, który jest pisany w ten sposób, ma tendencję do znacznie mniejszych sprzężeń. Istotną rolę w rozbijaniu sprzężeń odgrywają zasady projektowania obiektowego4.
Wspólna własność Para ma prawo do sprawdzenia i poprawienia dowolnego modułu. Żaden z programistów nie jest indywidualnie odpowiedzialny za żaden konkretny moduł lub technologię. Wszyscy pracują nad interfejsem użytkownika (GUI)5. Wszyscy pracują nad middleware. Wszyscy pracują nad bazą danych. Nikt nie ma więcej władzy nad modułem lub technologią niż pozostałe osoby. To nie oznacza, że techniki EP zabraniają specjalizacji. Programista, który specjalizuje się w GUI, najczęściej będzie zajmował się zadaniami dotyczącymi GUI, ale także będzie pracował w parach mających zadania związane z middleware i bazami danych. Jeśli ktoś zechce nauczyć się drugiej specjalności, może zgłosić się do zadań ze specjalistami, którzy jej go nauczą. Nikt nie jest ograniczony do jednej specjalności.
Ciągła integracja Programiści sprawdzają swój kod i integrują go kilka razy dziennie. Zasada jest prosta. Pierwszy programista, który pobierze kod, wygrywa, pozostali muszą kod scalać. W zespołach EP stosowane są nieblokujące systemy kontroli kodu źródłowego. Oznacza to, że programiści mogą pobrać dowolny moduł w każdej chwili, niezależnie od tego, kto jeszcze mógł go pobrać. 2
[Williams2000], [Cockburn2001].
3
[Nosek].
4
Patrz część II.
5
Nie próbuję tutaj zalecać architektury trójwarstwowej. Po prostu wybrałem trzy popularne części technologii oprogramowania.
PRAKTYKI PROGRAMOWANIA EKSTREMALNEGO
35
Gdy programista pobierze moduł ponownie po modyfikacji, musi być przygotowany do scalenia go z wszelkimi zmianami wprowadzonymi przez wszystkie osoby, które pobrały ten moduł przed nim. Aby uniknąć długich sesji scalania, członkowie zespołu oddają swoje moduły bardzo często. Para pracuje nad zadaniem przez godzinę lub dwie. Tworzy przypadki testowe i kod produkcyjny. W pewnym dogodnym punkcie krytycznym, prawdopodobnie na długo przed ukończeniem zadania, para postanawia oddać kod ponownie. Najpierw sprawdza, czy wszystkie testy przechodzą. Integruje swój nowy kod z istniejącą bazą kodu. Jeśli trzeba wykonać scalenie, wykonuje je. Jeśli to konieczne, para konsultuje się z programistami, którzy wcześniej oddali swoje zmiany. Po zintegrowaniu zmian buduje nowy system. Uruchamia wszystkie testy w systemie, ze wszystkimi aktualnymi testami akceptacyjnymi włącznie. Jeśli para zepsuła coś, co wcześniej działało, naprawia to. Po uruchomieniu wszystkich testów kończy operację oddania kodu. Zatem zespoły EP budują system wiele razy każdego dnia. Budują cały system od początku do końca6. Jeżeli wynikiem końcowym systemu jest płyta CD, to wypalają płytę. Jeżeli wynikiem końcowy systemu jest aktywna witryna WWW, instalują tę witrynę WWW — najczęściej na serwerze testowym.
Równomierne tempo Projekt oprogramowania nie jest sprintem — to maraton. Zespół, który po przekroczeniu linii startu zacznie się ścigać tak szybko, jak może, wypali się na długo przed tym, nim zbliży się do ukończenia. Aby szybko finiszować, zespół musi pracować w równomiernym tempie. Zespół musi zachować swoją energię i czujność. Musi celowo działać w stałym, umiarkowanym tempie. W programowaniu EP obowiązuje zasada, że zespół nie może pracować w godzinach nadliczbowych. Jedynym wyjątkiem od tej reguły jest ostatni tydzień przed opublikowaniem wersji dystrybucyjnej. Jeśli zespół jest bliski celu — czyli wersji dystrybucyjnej — i może sobie pozwolić na sprint do mety, to godziny nadliczbowe są dopuszczalne.
Otwarta przestrzeń robocza Zespół pracuje razem w otartym pokoju. Są w nim stoły, na których są ustawione stacje robocze. Na każdym stole są dwie lub trzy takie stacje robocze. Przed każdą stają roboczą stoją dwa krzesła, na których może usiąść para współpracujących programistów. Ściany są pokryte wykresami stanu, schematami podziału na zadania, diagramami UML itp. W tym pokoju słychać szum rozmów. Każda para jest w zasięgu słuchu z każdą inną parą. Każdy bez trudu może usłyszeć, gdy ktoś inny ma kłopoty. Każdy wie, w jakim stanie są inni. Programiści mogą się intensywnie ze sobą komunikować. Można by pomyśleć, że takie środowisko będzie rozpraszać, że w takich warunkach nigdy nie będzie można nic zrobić z powodu ciągłego hałasu i braku skupienia. Jak pokazała rzeczywistość, nic takiego się nie dzieje. Co więcej, nie tylko nie następuje obniżenie produktywności, ale jak wynika z badań przeprowadzonych na Uniwersytecie w Michigan, praca w środowisku „pokoju narad wojennych” może zwiększyć wydajność dwukrotnie7.
Gra w planowanie W kolejnym rozdziale, „Planowanie”, zamieszczono szczegółowy opis gry planistycznej w programowaniu ekstremalnym. W tym punkcie pokrótce ją opiszę. Istotą gry w planowanie jest podział odpowiedzialności pomiędzy przedstawicieli biznesu i deweloperów. Ludzie biznesu (nazywani również klientami) decydują o ważności poszczególnych funkcji, natomiast deweloperzy określają, jak wysokie będą koszty implementacji określonej funkcji. 6
Ron Jeffries mawia: „Od końca do końca to dalej, niż myślisz”.
7
http://www.sciencedaily.com/releases/2000/12/001206144705.htm
36
ROZDZIAŁ 2. PRZEGLĄD INFORMACJI O PROGRAMOWANIU EKSTREMALNYM
Na początku prac nad każdą wersją dystrybucyjną i na początku każdej iteracji deweloperzy określają budżet na podstawie tego, ile byli w stanie zrobić w ostatniej iteracji lub w ostatniej wersji dystrybucyjnej. Klienci wybierają historyjki, których łączne koszty nie przekraczają tego budżetu. Dzięki zastosowaniu tych prostych zasad oraz dzięki krótkim iteracjom i częstym publikacjom wersji dystrybucyjnych klienci i deweloperzy przyzwyczajają się do rytmu projektu. Klienci mają poczucie tempa, w jakim pracują deweloperzy. Bazując na tym poczuciu, klienci będą w stanie określić, jak długo potrwa ich projekt i ile będzie kosztować.
Prosty projekt Zespół EP dąży do tego, aby tworzone przez niego projekty były w maksymalnym stopniu proste i ekspresywne. Co więcej, członkowie zespołu koncentrują się wyłącznie na tych historyjkach, które są zaplanowane w bieżącej iteracji. Nie martwią się historyjkami, które będą realizowane w przyszłości. Zamiast tego starają się przekształcać projekt systemu — od iteracji do iteracji — w taki sposób, aby mieć jak najlepszy projekt dla historyjek, które system implementuje w określonym momencie. To oznacza, że zespół EP zazwyczaj nie rozpoczyna od infrastruktury. Prawdopodobnie nie wybierze w pierwszej kolejności bazy danych. Nie będzie się także zajmować warstwą middleware. Celem pierwszego etapu pracy zespołu będzie doprowadzenie do działania pierwszej partii historyjek w możliwie najprostszy sposób. Zespół doda infrastrukturę dopiero wtedy, gdy przyjdzie czas na opracowanie historyjki, która go do tego zmusi. Programiści ekstremalni kierują się następującymi trzema mantrami: Weź pod uwagę najprostsze rozwiązanie, które może zadziałać. Zespoły EP zawsze starają się znaleźć możliwie najprostszą opcję projektu dla bieżącej partii historyjek. Jeśli implementacja bieżących historyjek może działać z wykorzystaniem zwykłych plików, możemy zrezygnować z bazy danych lub EJB. Jeśli możemy zaimplementować bieżące historyjki za pomocą zwykłego połączenia przez gniazdo, nie musimy korzystać z ORB lub RMI. Jeśli da się zrealizować bieżące historyjki bez wielowątkowości, nie musimy uwzględniać obsługi wielu wątków. Staramy się rozważyć najprostszy sposób implementacji bieżących historyjek. Następnie wybieramy rozwiązanie, które jest tak blisko tej prostoty, jak tylko można się do niej zbliżyć w praktyce. Nie przejmuj się tym, że to będzie później potrzebne. Tak, ale wiemy, że kiedyś baza danych będzie potrzebna. Wiemy, że kiedyś będzie trzeba zastosować ORB. Wiemy, że pewnego dnia trzeba będzie zapewnić obsługę wielu użytkowników. Musimy więc umieścić haki do tych elementów teraz. Zgadza się? Zespół EP poważnie rozważa skutki podjęcia decyzji o dodaniu infrastruktury, zanim stanie się ona bezwzględnie konieczna. Zaczynają od założenia, że ta infrastruktura nie będzie potrzebna. Zespół opracowuje infrastrukturę tylko wtedy, jeśli ma dowód lub przynajmniej bardzo przekonujące przesłanki, że wprowadzenie infrastruktury w określonym momencie będzie bardziej wydajne pod względem kosztów od czekania. Raz i tylko raz. Programiści EP nie tolerują powielania kodu. Wszędzie tam, gdzie znajdą duplikaty, eliminują je. Istnieje wiele źródeł powielania kodu. Najbardziej oczywiste są te fragmenty kodu, które zostały zaznaczone za pomocą myszy i wklejone w wielu miejscach. Kiedy znajdziemy takie fragmenty, eliminujemy je poprzez tworzenie funkcji lub klasy bazowej. Czasem dwa lub więcej algorytmów jest do siebie bardzo podobnych, a jednak różnią się między sobą w subtelny sposób. Przekształcamy je na funkcje lub wykorzystujemy wzorzec Metoda szablonowa8. Niezależnie od źródła dublowania, kiedy zostanie ono odkryte, nie będziemy go tolerować. 8
Patrz rozdział 14., „Metoda szablonowa i Strategia: dziedziczenie a delegacja”.
PRAKTYKI PROGRAMOWANIA EKSTREMALNEGO
37
Najlepszym sposobem na wyeliminowanie nadmiarowości jest tworzenie abstrakcji. W końcu jeśli dwie rzeczy są do siebie podobne, musi istnieć jakaś abstrakcja, która je unifikuje. Tak więc akt eliminacji redundancji zmusza zespół do tworzenia wielu abstrakcji i dalszego zmniejszenia sprzężeń.
Refaktoryzacja9 Zagadnienie refaktoryzacji zostanie opisane bardziej szczegółowo w rozdziale 5. W tym punkcie zamieszczę zwięzły przegląd najważniejszych informacji na ten temat. Kod ma tendencję do „psucia się”. W miarę dodawania kolejnych funkcji i poprawiania kolejnych błędów struktura kodu się degraduje. Jeśli to zlekceważymy, wskutek degradacji kod będzie poplątany i trudny do zarządzania. Zespół EP stara się odwrócić tę degradację poprzez częstą refaktoryzację. Refaktoryzacja jest praktyką polegającą na wprowadzaniu serii drobnych transformacji, które poprawiają strukturę systemu bez wpływu na jego zachowanie. Każda transformacja jest trywialna — sprawia wrażenie, że nie warto jej wprowadzać. Jednak razem łączą się one w znaczne transformacje projektu i architektury systemu. Po każdej maleńkiej transformacji uruchamiamy testy jednostkowe, aby upewnić się, czy nic się nie zepsuło. Następnie wprowadzamy następną transformację, następną i następną. Po każdej z nich uruchamiamy testy. W ten sposób utrzymujemy działający system, jednocześnie przekształcając jego projekt. Refaktoryzację przeprowadza się w sposób ciągły, a nie na końcu projektu, przed opublikowaniem wersji dystrybucyjnej, czy nawet na koniec dnia. Refaktoryzacja jest operacją, którą wykonujemy co godzinę lub nawet co pół godziny. Dzięki refaktoryzacji przez cały czas zachowujemy kod tak czysty, jak to możliwe, i tak ekspresywny, jak to możliwe.
Metafora Metafora jest najmniej rozumianą ze wszystkich praktyk EP. Programiści ekstremalni są praktykami — brak konkretnej definicji sprawia, że czujemy dyskomfort. Rzeczywiście, zwolennicy EP często proponowali usunięcie metafory jako praktyki. A jednak w pewnym sensie metafora jest jedną z najważniejszych praktyk. Weźmy za przykład puzzle. Skąd wiadomo, jak do siebie pasują poszczególne fragmenty? Oczywiście każdy fragment przylega do innego, a jego kształt musi perfekcyjnie uzupełniać elementy, z którymi się styka. Gdybyście byli niewidomi i mieli bardzo wrażliwy zmysł dotyku, moglibyście ułożyć puzzle dzięki uważnemu dotykaniu każdego fragmentu i próbowaniu go we wszystkich pozycjach. Ale jest coś ważniejszego od kształtu elementów, co wiąże puzzle ze sobą. To obraz. Obraz jest prawdziwym przewodnikiem. Obraz jest tak potężny, że jeżeli dwie sąsiednie części obrazu nie mają uzupełniających się kształtów, to wiemy, że producent puzzli popełnił błąd. To jest metafora. To duży obraz, który łączy ze sobą cały system. To wizja systemu, która sprawia, że położenie i kształt wszystkich indywidualnych modułów stają się oczywiste. Jeśli kształt modułu jest niezgodny z metaforą, to wiemy, że ten moduł jest niewłaściwy. Metafora często sprowadza się do systemu nazewnictwa. Nazwy zapewniają słownictwo dla elementów w systemie i pomagają zdefiniować relacje pomiędzy nimi. Na przykład kiedyś pracowałem nad systemem, w którym tekst był przesyłany na ekran z szybkością 60 znaków na sekundę. W tym tempie wypełnienie ekranu zajmuje trochę czasu. W związku z tym pozwoliliśmy programowi, który generował tekst, na wypełnianie bufora. Gdy bufor był pełny, zatrzymywaliśmy program. Gdy bufor się opróżniał, uruchamialiśmy program ponownie. Rozmawialiśmy o tym systemie, porównując go do wywrotek przewożących śmieci na wysypisko. Bufory były małymi ciężarówkami. Ekran był wysypiskiem. Program był generatorem śmieci. Wszystkie nazwy pasowały do siebie i pomagały nam myśleć o systemie jako o całości. 9
[Fowler99].
38
ROZDZIAŁ 2. PRZEGLĄD INFORMACJI O PROGRAMOWANIU EKSTREMALNYM
Oto inny przykład. Kiedyś pracowałem nad systemem analizującym ruch w sieci. Co pół godziny system odpytywał dziesiątki kart sieciowych i pobierał dane monitorowania. Każda karta sieciowa przekazywała niewielki blok danych składający się z kilku pojedynczych zmiennych. Nazwaliśmy te bloki „plastrami”. Plastry były surowymi danymi, które trzeba było analizować. Program analizujący „gotował” plastry, dlatego został nazwany „Tosterem”. Pojedyncze zmienne w obrębie plastrów nazwaliśmy „okruchami”. W sumie była to bardzo przydatna i zabawna metafora.
Wniosek Programowanie ekstremalne to zestaw prostych i konkretnych praktyk, które łączą się w zwinny proces wytwarzania oprogramowania. Proces ten jest wykorzystywany przez wiele zespołów z dobrymi wynikami. EP to dobra, uniwersalna metoda tworzenia oprogramowania. Wiele zespołów projektowych może ją przyjąć bez żadnych zmian. Wiele innych może ją dostosować poprzez dodanie lub modyfikację niektórych praktyk.
Bibliografia 1. Dahl Dijkstra, Structured Programming, Nowy Jork, Hoare, Academic Press, 1972. 2. Daryl Conner, Leading at the Edge of Chaos, Wiley, 1998. 3. Alistair Cockburn, The Methodology Space, Raport techniczny czasopisma „Humans and Technology” HaT TR.97.03 (z dnia 03.10.97), http://members.aol.com/acockburn/papers/methyspace/methyspace.htm. 4. Kent Beck, Programowanie ekstremalne Explained: Embracing Change, Reading, MA: Addison-Wesley, 1999. 5. James Newkirk i Robert Martin, Extreme programming in Practice, Upper Saddle River, NJ: Addison-Wesley, 2001. 6. Laurie Williams, Robert Kessler, Ward Cunningham, Ron Jeffries, Strengthening the Case for Pair Programming, „IEEE Software”, lipiec – sierpień 2000. 7. Alistair Cockburn i Laurie Williams, The Costs and Benefits of Pair Programming, Konferencja na Sardynii XP 2000, odtworzone w książce Extreme Programming Examined, Giancarlo SUCCI, Michele Marchesi, Addison-Wesley, 2001. 8. Nosek J.T, The Case for Collaborative Programming, „Communications of the ACM” (1998): 105 – 108. 9. Martin Fowler, Refactoring: Improving the Design of Existing Code, Reading, MA: Addison-Wesley, 1999.
R OZDZIAŁ 3
Planowanie
Kiedy możesz zmierzyć to, o czym mówisz, i wyrazić to za pomocą liczb, to wiesz
coś o tym, ale kiedy nie możesz tego zmierzyć i przedstawić w liczbach, Twoja wiedza jest skromna i niezadowalająca — Lord Kelvin, 1883
W tym rozdziale zamieszczono opis gry planistycznej w programowaniu ekstremalnym (EP)1. Jest ona podobna do sposobu planowania w kilku innych metodykach agile2, takich jak SCRUM3, Crystal4, Feature Driven Development5 oraz Adaptive Software Development (ADP)6. Jednak żadna z tych metodyk nie charakteryzuje się taką szczegółowością i dyscypliną.
1
[Beck 99], [Newkirk 2001].
2
www.AgileAlliance.org
3
www.controlchaos.com
4
crystalmethodologies.org
5
Peter Coad, Eric Lefebvre i Jeff De Luca, Java Modeling In Color With UML: Enterprise Components and Process, Prentice Hall, 1999.
6
[Higsmith 2000].
40
ROZDZIAŁ 3. PLANOWANIE
Początkowa eksploracja Na początku projektu deweloperzy i klienci starają się zidentyfikować tyle istotnych historyjek użytkowników, ile potrafią. Nie starają się jednak zidentyfikować wszystkich historyjek użytkownika. W miarę postępów projektu klienci kontynuują pisanie nowych historyjek użytkowników. Napływ historyjek użytkowników nie kończy się do chwili zakończenia projektu. Podczas szacowania historyjek deweloperzy pracują razem z klientami. Szacunki są względne, a nie bezwzględne. Zapisujemy kilka punktów na karcie historyjki w celu zaprezentowania jej względnego kosztu. Możemy nie mieć pewności co do tego, ile czasu reprezentuje określony punkt historyjki, ale wiemy, że historyjka składająca się z ośmiu punktów będzie realizowana dwa razy dłużej niż historyjka składająca się z czterech punktów.
Tworzenie prototypów, dzielenie i szybkość Opowieści, które są zbyt duże lub zbyt małe, są trudne do oszacowania. Deweloperzy mają tendencję do niedoceniania dużych historyjek i przeceniania małych. Każdą historyjkę, która jest zbyt duża, należy rozbić na kilka części, które nie są zbyt duże. Wszystkie historyjki, które są zbyt małe, należy połączyć z innymi małymi historyjkami. Na przykład rozważmy historyjkę „użytkownicy mogą bezpiecznie przesyłać pieniądze na swój rachunek oraz pomiędzy swoimi rachunkami”. To jest duża historyjka. Oszacowanie jej będzie trudne i prawdopodobnie niedokładne. Możemy jednak podzielić ją tak, jak pokazano poniżej. Po dokonaniu tego podziału oszacowanie staje się znacznie łatwiejsze:
Użytkownicy mogą się zalogować. Użytkownicy mogą się wylogować. Użytkownicy mogą wpłacić pieniądze na swój rachunek. Użytkownicy mogą wypłacić pieniądze ze swojego rachunku. Użytkownicy mogą przesyłać pieniądze ze swojego rachunku na inny rachunek.
Kiedy historyjka zostanie podzielona lub połączona, należy ponownie ją oszacować. Proste dodanie lub odjęcie szacowanej wartości nie jest mądre. Głównym powodem podziału lub połączenia historyjki jest doprowadzenie jej do takiego rozmiaru, aby oszacowanie było dokładne. Nic ma niczego dziwnego w tym, że po rozbiciu historyjki ocenionej na pięć punktów suma ocen uzyskanych historyjek wynosi dziesięć. Dziesięć to bardziej dokładne oszacowanie. Względne szacunki nie poinformują nas o dokładnym rozmiarze historyjki, więc nie pomogą nam w określeniu, czy należy ją podzielić, czy też scalić. W celu poznania rzeczywistego rozmiaru historyjki potrzebny jest czynnik, który nazywamy prędkością (ang. velocity). Jeśli znamy wartość prędkości, możemy pomnożyć oszacowanie dowolnej historyjki przez prędkość, aby uzyskać rzeczywiste oszacowanie czasu potrzebnego do zaimplementowania tej historyjki. Na przykład jeżeli prędkość wynosi „2 dni na punkt historyjki”, a mamy historyjkę o względnym oszacowaniu czterech punktów, to realizacja historyjki powinna zająć osiem dni. W miarę postępów projektu pomiar prędkości stanie się jeszcze dokładniejszy, ponieważ będziemy mogli zmierzyć liczbę zrealizowanych punktów historyjek na jedną iterację. Na początku projektu deweloperzy prawdopodobnie nie będą mieli zbyt dobrego wyczucia prędkości. Muszą stworzyć pierwsze przybliżenie, wykorzystując dowolny sposób, który ich zdaniem da najlepsze rezultaty. Potrzeba dokładności w tym momencie nie jest szczególnie istotna, więc nie trzeba poświęcać na to zbyt dużo czasu. Często wystarczy poświęcić kilka dni na stworzenie prototypu jednej lub dwóch historyjek, aby uzyskać poczucie prędkości zespołu. Sesja tworzenia prototypu to tzw. spike.
PLANOWANIE ZADAŃ
41
Planowanie wersji dystrybucyjnych Znając prędkość, klienci mogą ocenić koszt każdej z historyjek. Znają też wartość biznesową i priorytet każdej historyjki. To pozwala im wybrać historyjki, które mają być realizowane w pierwszej kolejności. Ten wybór nie jest wyłącznie kwestią priorytetu. Coś, co jest ważne, ale jednocześnie drogie, może być przełożone na okres po zrealizowaniu historyjki, która jest mniej ważna, ale znacznie tańsza. Tego rodzaju wybory to decyzje biznesowe. Przedstawiciele biznesu decydują, które historyjki przyniosą im największy zwrot z każdej zainwestowanej złotówki. Deweloperzy i klienci uzgadniają datę publikacji pierwszej wersji dystrybucyjnej projektu. Zwykle jest to termin oddalony o 2 – 4 miesiące od daty planowania. Klienci wybierają historyjki, które chcą zaimplementować w tej wersji dystrybucyjnej, oraz określają zgrubną kolejność, w jakiej chcą, aby były one zaimplementowane. Klienci nie mogą wybrać więcej historyjek, niż uda się zmieścić w zależności od aktualnej prędkości. Ponieważ prędkość jest początkowo niedokładna, wybór daty jest przybliżony. Jednak dokładność nie jest w tym momencie zbyt istotna. Plan publikowania wersji dystrybucyjnych może być uściślany, w miarę jak oszacowanie prędkości staje się bardziej dokładne.
Planowanie iteracji Następnie deweloperzy i klienci wybierają rozmiar iteracji. Zazwyczaj trwa to dwa tygodnie. Jak już wspomniano, klienci wybierają historyjki, które chcą, by były zaimplementowane w pierwszej iteracji. Nie mogą wybrać więcej historyjek, niż może się zmieścić w zależności od aktualnej prędkości. Kolejność realizacji historyjek w ramach iteracji to decyzja techniczna. Deweloperzy implementują historyjki w kolejności, która ma największy sens z technicznego punktu widzenia. Mogą pracować nad historyjkami seryjnie, zaczynając kolejną po zakończeniu poprzedniej, lub mogą pracować nad wszystkimi historyjkami jednocześnie. To zależy wyłącznie od nich. Klienci nie mogą zmieniać historyjek w iteracji po jej rozpoczęciu. Mogą zmodyfikować lub uporządkować dowolną historyjkę w projekcie, ale nie te, nad którymi deweloperzy aktualnie pracują. Iteracja kończy się w określonym terminie, nawet jeżeli wszystkie historyjki nie są zrealizowane. Szacunki dla wszystkich zakończonych historyjek są sumowane i jest obliczana prędkość dla tej iteracji. Ta miara prędkości jest następnie wykorzystywana do planowania kolejnej iteracji. Zasada jest bardzo prosta. Planowana prędkość dla każdej iteracji jest zmierzoną prędkością poprzedniej iteracji. Jeśli zespół zrealizował w ostatniej iteracji 31 punktów historyjkowych, to powinien zaplanować zrealizowanie 31 punktów historyjkowym w kolejnej iteracji. Prędkość zespołu wynosi 31 punktów na iterację. To sprzężenie zwrotne prędkości pomaga utrzymać synchronizacją planowania z zespołem. W miarę jak zespół zyskuje wiedzę i umiejętności, proporcjonalnie wzrasta prędkość. Jeśli zespół kogoś straci, jego prędkość spadnie. Jeśli architektura ewoluuje w sposób, który ułatwia rozwój, prędkość wzrasta.
Planowanie zadań Na początku nowej iteracji deweloperzy i klienci spotykają się w celu przeprowadzenia planowania. Deweloperzy dzielą historyjki na zadania programistyczne. Zadaniem jest coś, co jeden programista może zaimplementować w 4 – 16 godzin. Historyjki są analizowane przy pomocy klientów, a zadania są wyliczane tak dokładnie, jak to możliwe. Lista zadań jest tworzona na tablicy typu flipchart, białej tablicy lub innym dogodnym medium. Następnie deweloperzy, jeden po drugim, zgłaszają się do zadań, które chcą implementować. Kiedy deweloper zgłasza się do zadania, ocenia to zadanie w dowolnych punktach zadaniowych7.
7
Wielu deweloperów wykorzystuje „idealne godziny programowania” w roli swoich punktów zadaniowych.
42
ROZDZIAŁ 3. PLANOWANIE
Deweloperzy mogą zgłaszać się do każdego rodzaju zadania. Specjaliści od baz danych nie muszą zgłaszać się wyłącznie do zadań dotyczących baz danych. Specjaliści od interfejsu GUI mogą zgłosić się do zadań dotyczących bazy danych, jeśli mają ochotę. Może się to wydawać niewydajne, ale jak się przekonamy, istnieje mechanizm, który tym zarządza. Korzyści są oczywiste. Im więcej deweloperzy wiedzą o całym projekcie, tym projekt jest lepszy, a zespół projektowy jest bardziej poinformowany. Chcemy, aby wiedza na temat projektu rozprzestrzeniała się w zespole niezależnie od specjalizacji. Każdy programista wie, ile punktów zadaniowych udało mu się zaimplementować w ostatniej iteracji. Ta liczba to jego osobisty budżet. Żaden programista nie powinien zgłaszać się do zadań, które przekraczają jego budżet. Wybór zadań trwa do czasu, aż zostaną przydzielone wszystkie zadania albo wszyscy deweloperzy wykorzystają swoje budżety. Jeśli pozostaną jakieś zadania, to deweloperzy negocjują ze sobą, przydzielając zadania na podstawie swoich różnych umiejętności. Jeśli nie ma wystarczająco dużo miejsca, aby przydzielić wszystkie zadania, to deweloperzy proszą klientów o usunięcie zadań lub historyjek z iteracji. Jeśli wszystkie zadania zostaną przydzielone, a deweloperzy nadal mają miejsce w swoich budżetach na więcej pracy, proszą klientów o więcej historyjek.
Półmetek W połowie iteracji zespół przeprowadza spotkanie. W tym momencie połowa historyjek zaplanowanych na iterację powinna być zrealizowana. Jeśli połowa historyjek nie została zrealizowana, to zespół próbuje zmienić przydział zadań i obowiązków tak, aby zapewnić zakończenie wszystkich historyjek do końca iteracji. Jeśli deweloperzy nie potrafią znaleźć takiego nowego podziału, to powinni poinformować o tym klientów. Klienci mogą zdecydować o usunięciu zadania lub historyjki z iteracji. Plan minimum to wyszczególnienie zadań i historyjek o najniższym priorytecie, tak aby deweloperzy unikali pracy nad nimi. Załóżmy na przykład, że klienci wybrali w iteracji osiem historyjek o łącznej wartości 24 punktów historyjkowych. Załóżmy także, że zostały one podzielone na 42 zadania. Spodziewamy się, że na półmetku iteracji będziemy mieć zrealizowanych 21 zadań i 12 punktów historyjkowych. Te 12 punktów historyjkowych musi pochodzić z w pełni zaimplementowanych historyjek. Celem jest zrealizowanie historyjek, a nie tylko zadań. Bardzo niepożądanym scenariuszem jest sytuacja, gdy w chwili dotarcia do końca iteracji zrealizowano 90% zadań, ale nie zrealizowano żadnej historyjki. Na półmetku interesują nas zakończone historyjki, które reprezentują połowę punktów historyjkowych dla iteracji.
Przebieg iteracji Co dwa tygodnie bieżąca iteracja się kończy i rozpoczyna się następna. Na koniec każdej iteracji działająca aplikacja jest demonstrowana klientom. Klienci są proszeni o ocenę wyglądu, stylu i wydajności projektu. Dostarczają opinii w zakresie nowych historyjek użytkowników. Klienci często oglądają postępy. Mogą zmierzyć prędkość. Mogą przewidzieć, jak szybko pracuje zespół, i mogą odpowiednio wcześnie zaplanować historyjki o wysokim priorytecie. Krótko mówiąc, mają kontrolę i wszystkie potrzebne dane do zarządzania projektem zgodnie ze swoimi potrzebami.
BIBLIOGRAFIA
43
Wniosek Z iteracji na iterację i z wersji dystrybucyjnej na wersję dystrybucyjną projekt wchodzi w przewidywalny i wygodny rytm. Każdy wie, czego się spodziewać i kiedy może się tego spodziewać. Interesariusze widzą postępy często i są to postępy znaczące. Zamiast notatników pełnych schematów i planów demonstrowane jest działające oprogramowanie, które można wypróbować, poczuć i wyrazić na jego temat opinię. Deweloperzy widzą rozsądny plan bazujący na własnych szacunkach i kontrolowany przez zmierzoną przez siebie prędkość. Wybierają zadania, z którymi czują się komfortowo, i utrzymują wysoką jakość wykonania. Menedżerowie otrzymują dane w każdej iteracji. Wykorzystują te dane do sterowania i zarządzania projektem. Nie muszą uciekać się do nacisków, gróźb lub odwoływać się do lojalności tylko po to, aby uzyskać dowolny i nierealistyczny termin. Jeśli brzmi to jak przysłowiowa „sielanka”, to w praktyce tak nie jest. Interesariusze nie zawsze są zadowoleni z danych, które uzyskują w trakcie procesu, zwłaszcza na początku. Stosowanie metody agile nie oznacza, że interesariusze otrzymają to, czego chcą. To po prostu oznacza, że będą oni mieli możliwość kontroli nad zespołem w celu uzyskania największej wartości biznesowej jak najmniejszym kosztem.
Bibliografia 1. Kent Beck, Programowanie ekstremalne Explained: Embrace Change, Reading, MA, Addison-Wesley, 1999. 2. James Newkirk i Robert Martin, Extreme programming in Practice, Upper Saddle River, NJ, Addison-Wesley, 2001. 3. James Highsmith, Adaptive Software Development: A Collaborative Approach to Managing Complex Systems, Nowy Jork, Dorset House, 2000.
44
ROZDZIAŁ 3. PLANOWANIE
R OZDZIAŁ 4
Testowanie
Ogień jest próbą dla złota, przeciwności — dla silnych mężczyzn — Seneka (c. 3. 65 r. p.n.e.)
Akt pisania testów jednostkowych jest bardziej aktem projektowania niż weryfikacji. Jest to również bardziej akt dokumentacji niż weryfikacja. Pisanie testów jednostkowych zamyka niezwykłą liczbę pętli sprzężenia zwrotnego, a jedną z nich jest ta, która dotyczy weryfikacji funkcji.
Programowanie sterowane testami A gdyby tak pisać testy przed pisaniem programów? Gdyby tak odmówić implementacji funkcji w programach, dopóki nie będzie testu, który nie przechodzi dlatego, że tej funkcji nie ma? Gdyby tak odmówić dodania nawet jednej linijki kodu do programów, dopóki nie będzie testu, który nie przechodzi z powodu jej braku? Co by się stało, gdybyśmy stopniowo dodawali funkcjonalności do naszych programów przez pisanie najpierw testów, które zawodzą, ponieważ zakładały istnienie tej funkcjonalności, a następnie dążylibyśmy do tego, by te testy się powiodły? Jaki wpływ miałoby to na oprogramowanie, które piszemy? Jakie korzyści czerpalibyśmy z istnienia takich kompleksowych testów? Pierwszą i najbardziej oczywistą korzyścią byłoby to, że dla każdej funkcji programu istniałby test, który weryfikuje jej działanie. Ten zbiór testów spełnia rolę jednokierunkowego sprzęgła dla dalszego rozwoju. Poinformuje nas, kiedy przypadkowo doprowadzimy do awarii pewnych istniejących funkcji. Możemy dodawać funkcje do programu lub zmieniać strukturę programu bez obawy, że w tym procesie zepsujemy jakąś ważną funkcję. Testy poinformują nas, że program nadal zachowuje się poprawnie. W takim przypadku mamy znacznie większą swobodę wprowadzania zmian i poprawek do programu.
46
ROZDZIAŁ 4. TESTOWANIE
Ważniejszym, ale mniej oczywistym skutkiem jest to, że akt pisania testu w pierwszej kolejności zmusza nas do innego punktu widzenia. Musimy postrzegać program, który mamy zamiar napisać, z punktu widzenia programu wywołującego. Z tego względu w tym samym czasie zwracamy uwagę zarówno na interfejs programu, jak i na jego działanie. Dzięki pisaniu testu w pierwszej kolejności projektujemy oprogramowanie zapewniające wygodę wywoływania. Co więcej, pisząc najpierw test, zmuszamy się do projektowania programu, który jest sprawdzalny. Projektowanie programu tak, aby był wywoływalny i sprawdzalny, ma niezwykle istotne znaczenie. Aby oprogramowanie było wywoływalne i sprawdzalne, musi być oddzielone od otoczenia. Tak więc akt pisania testów najpierw zmusza nas do eliminowania sprzężeń w oprogramowaniu. Testy są również nieocenioną formą dokumentacji. Jeśli chcemy się dowiedzieć, jak wywołać funkcję lub stworzyć obiekt, możemy wykorzystać test, który to pokazuje. Testy działają jako zestaw przykładów, które pomagają innym programistom nauczyć się pracy z kodem. Ta dokumentacja jest kompilowalna i wykonywalna. Jest zawsze aktualna. Nie może kłamać.
Przykład projektu w stylu „najpierw test” Niedawno napisałem dla zabawy wersję gry Hunt the Wumpus. Program ten jest prostą grą przygodową, w której gracz chodzi po jaskini, próbując zabić Wumpusa, zanim Wumpus go zje. Jaskinia jest zbiorem pomieszczeń, które są ze sobą połączone za pomocą korytarzy. Każdy pokój może mieć przejścia na północ, południe, wschód lub zachód. Gracz porusza się, informując komputer o tym, w którym kierunku chce się udać. Jednym z pierwszych testów, które napisałem do tego programu, była funkcja testMove z listingu 4.1. Funkcja ta tworzy nowy obiekt WumpusGame, łączy pokój 4. z pokojem 5. za pomocą przejścia od strony wschodniej, umieszcza gracza w pokoju 4., wydaje polecenie do przejścia na wschód, a następnie stwierdza, że gracz powinien być w pokoju 5. Listing 4.1. Funkcja testMove public void testMove() { WumpusGame g = new WumpusGame(); g.connect(4,5,"E"); g.setPlayerRoom(4); g.east(); assertEquals(5, g.getPlayerRoom()); }
Cały ten kod został napisany przed napisaniem jakiejkolwiek części klasy WumpusGame. Posłuchałem rady Warda Cunninghama i napisałem test w taki sposób, w jaki chciałbym go przeczytać. Zaufałem, że mogę doprowadzić do tego, że test przejdzie, poprzez napisanie kodu, który będzie zgodny z wzorcem narzuconym przez test. Jest to tak zwane programowanie intencyjne (ang. intentional programming). Formułujemy zamiar w teście przed jego zaimplementowaniem. Dzięki temu zamiar staje się tak prosty i czytelny, jak to możliwe. Ufamy, że ta prostota i czytelność doprowadzą do dobrej struktury programu. Programowanie intencyjne natychmiast doprowadziło mnie do ciekawych decyzji projektowych. W teście nie wykorzystano klasy Room. Mój zamiar komunikuje działanie połączenia jednego pomieszczenia z innym. Nie wydaje się, abym potrzebował klasy Room dla ułatwienia tej komunikacji. Do reprezentowania pokojów wystarczą liczby całkowite. Czytelnikowi może się to wydawać sprzeczne z intuicją. Przecież można odnieść wrażenie, że ten program dotyczy przede wszystkim pokojów: przemieszczania się pomiędzy pokojami, odkrywania, co znajduje się w pokoju, itp. Czy ten projekt jest wadliwy z powodu braku klasy Room? Mógłbym argumentować, że pojęcie połączeń ma o wiele większe znaczenie w grze Wumpus od pojęcia pomieszczenia. Można by również wykazać, że ten pierwszy test wskazał dobry sposób na rozwiązanie problemu. Rzeczywiście myślę, że tak jest w tym przypadku, ale nie staram się sformułować takiego
PROGRAMOWANIE STEROWANE TESTAMI
47
twierdzenia. Najważniejsze jest to, że test zaprezentował kluczową kwestię projektu na bardzo wczesnym etapie. Akt pisania testów przed kodem jest aktem rozpoznawania decyzji projektowych. Zwróćmy uwagę, że test mówi nam, w jaki sposób działa program. Na podstawie tej prostej specyfikacji większość z nas z łatwością napisałaby cztery metody klasy WumpusGame. Bez większych problemów potrafilibyśmy także wymienić i napisać trzy inne polecenia kierunku. Gdybyśmy później chcieli się dowiedzieć, jak połączyć dwa pomieszczenia lub jak poruszać się w określonym kierunku, to ten test pokaże nam, jak to zrobić w sposób nie budzący wątpliwości. Test działa jak kompilowalny i wykonywalny dokument opisujący program.
Izolacja testu Akt pisania testów przed kodem produkcyjnym często ujawnia obszary w programie, w których trzeba wyeliminować sprzężenia. Na przykład na rysunku 4.1 pokazano prosty schemat UML1 aplikacji płacowej. Klasa Payroll wykorzystuje klasę EmployeeDatabase do pobrania obiektu Employee. Wysyła do obiektu Employee żądanie obliczenia płacy. Następnie przekazuje informację o płacy do obiektu CheckWriter w celu wygenerowania czeku. Na koniec księguje wypłatę do obiektu Employee i zapisuje obiekt z powrotem do bazy danych.
Rysunek 4.1. Model aplikacji płacowej ze sprzężeniami
Zakładamy, że na razie nie napisaliśmy jeszcze kodu tej aplikacji. Jak do tej pory ten schemat po prostu został narysowany na tablicy podczas trwania szybkiej sesji projektowej2. Teraz trzeba napisać testy, które specyfikują zachowanie obiektu Payroll. Istnieje szereg problemów związanych z pisaniem takich testów. Po pierwsze, jakiej bazy danych używamy? Obiekt Payroll musi czytać informacje z jakiejś bazy danych. Czy trzeba napisać w pełni działającą bazę danych, zanim będziemy mogli przetestować klasę Payroll? Jakie dane do niej załadujemy? Po drugie, w jaki sposób sprawdzimy, że został wydrukowany właściwy czek? Nie możemy napisać automatycznego testu, który bierze czek z drukarki i sprawdza kwotę, która na nim figuruje. Rozwiązaniem tych problemów jest zastosowanie wzorca Atrapa obiektu (ang. Mock object)3. Możemy wstawić interfejsy pomiędzy wszystkimi współpracownikami obiektu Payroll i utworzyć namiastki testowe (ang. test stubs), które implementują te interfejsy. Właściwą strukturę pokazano na rysunku 4.2. Klasa Payroll wykorzystuje teraz interfejsy do komunikowania się z obiektami EmployeeDatabase, CheckWriter i Employee. Zostały utworzone trzy obiekty-atrapy, które implementują te interfejsy. Obiekt PayrollTest odpytuje te obiekty-atrapy w celu zweryfikowania, czy obiekt Payroll prawidłowo nimi zarządza.
1
Czytelnikom nie znającym UML polecam zapoznanie się z dwoma dodatkami, w których szczegółowo opisano tę notację. Warto zapoznać się z dodatkami A i B.
2
[Jeffries 2001].
3
[Mackinnon 2000].
48
ROZDZIAŁ 4. TESTOWANIE
Rysunek 4.2. Aplikacja Payroll pozbawiona sprzężeń i korzystająca z obiektów-atrap do testowania
Na listingu 4.2 zaprezentowano intencję testu. Kod tworzy właściwe obiekty-atrapy, przekazuje je do obiektu Payroll, żąda od obiektu Payroll zrealizowania wypłaty dla wszystkich pracowników, a następnie żąda od obiektów-atrap, aby sprawdziły, czy wszystkie czeki zostały poprawnie wydrukowane i czy wszystkie wypłaty zostały prawidłowo zaksięgowane. Listing 4.2. TestPayroll public void testPayroll() { MockEmployeeDatabase db = new MockEmployeeDatabase(); MockCheckWriter w = new MockCheckWriter(); Payroll p = new Payroll(db, w); p.payEmployees(); assert(w.checksWereWrittenCorrectly()); assert(db.paymentsWerePostedCorrectly()); }
Oczywiście ten test sprawdza jedynie to, czy obiekt Payroll wywołał właściwe funkcje z właściwymi danymi. Test faktycznie nie sprawdza, czy czeki zostały wydrukowane. Nie sprawdza też, czy została odpowiednio zaktualizowana rzeczywista baza danych. Zamiast tego sprawdza, czy klasa Payroll — w odosobnieniu — zachowuje się tak, jak powinna. Można by się zastanawiać, do czego służy obiekt MockEmployee. Wydaje się możliwe, aby zamiast atrapy została użyta prawdziwa klasa Employee. Gdyby tak było, to nie miałbym skrupułów, aby jej użyć. W tym przypadku założyłem, że klasa Employee jest bardziej skomplikowana, niż jest to potrzebne do sprawdzenia działania klasy Payroll.
Nieoczekiwane wyeliminowanie sprzężeń Wyeliminowanie sprzężeń z klasy Payroll to dobra rzecz. Pozwala nam używać różnych baz danych i klas drukowania czeków zarówno w celach testowania, jak i rozszerzenia aplikacji. Myślę, że interesujące jest to, że wyeliminowanie sprzężeń było spowodowane potrzebą testowania. Najwyraźniej potrzeba
TESTY AKCEPTACYJNE
49
izolacji testowanego modułu zmusza nas do eliminowania z niego sprzężeń w sposób, który przynosi korzyści dla ogólnej struktury programu. Pisanie testów przed kodem poprawia projekt systemu. Duża część tej książki dotyczy zasad projektowych stosowanych w celu zarządzania zależnościami. Te zasady oferują czytelnikom pewne wskazówki i techniki eliminowania sprzężeń z klas i pakietów. Stosowanie tych zasad jest najbardziej korzystne, jeśli wykorzystuje się je w ramach strategii testów jednostkowych. To testy jednostkowe zapewniają większość bodźców i wskazówek do eliminacji sprzężeń.
Testy akceptacyjne Testy jednostkowe są koniecznymi, ale niewystarczającymi narzędziami weryfikacji. Testy jednostkowe sprawdzają, czy małe elementy systemu działają zgodnie z oczekiwaniami, ale nie weryfikują, czy system działa prawidłowo jako całość. Testy jednostkowe są testami białej skrzynki4, które weryfikują poszczególne mechanizmy systemu. Testy akceptacyjne są testami czarnej skrzynki5, które sprawdzają, czy zostały spełnione wymagania klienta. Testy akceptacyjne są pisane przez osoby, które nie znają wewnętrznych mechanizmów systemu. Mogą one być pisane bezpośrednio przez klienta lub przez pewne osoby związane z klientem, na przykład pracowników działu jakości. Testy akceptacyjne są programami, dlatego są wykonywalne. Jednakże zwykle są pisane za pomocą specjalnych języków skryptowych stworzonych dla klientów aplikacji. Testy akceptacyjne są ostateczną dokumentacją funkcji. Kiedy klient napisze testy akceptacyjne, które sprawdzają, czy funkcja działa poprawnie, programiści mogą odczytać te testy akceptacyjne, aby w pełni zrozumieć funkcję. Tak więc o ile testy jednostkowe służą jako kompilowalna i wykonywalna dokumentacja dla wewnętrznych mechanizmów systemu, o tyle testy akceptacyjne spełniają rolę kompilowalnej i wykonywalnej dokumentacji funkcji systemu. Ponadto fakt pisania testów akceptacyjnych przed kodem ma głęboki wpływ na architekturę systemu. Aby system był sprawdzalny, należy pozbawić go sprzężeń na wysokim poziomie architektury. Na przykład interfejs użytkownika (ang. user interface — UI) musi być oddzielony od reguł biznesowych w taki sposób, aby testy akceptacyjne mogły uzyskać dostęp do tych reguł biznesowych bez konieczności korzystania z UI. We wczesnych iteracjach projektu powstaje pokusa, aby testy akceptacyjne wykonywać ręcznie. Nie jest to wskazane, ponieważ pozbawia te wczesne iteracje dążenia do eliminowania sprzężeń. Źródłem tego dążenia jest bowiem potrzeba automatyzacji testów akceptacyjnych. Świadomość konieczności zautomatyzowania testów akceptacyjnych po uruchomieniu pierwszej iteracji prowadzi do przyjmowania bardzo ważnych kompromisów architektonicznych. Podobnie jak testy jednostkowe kierują nas w stronę podejmowania właściwych decyzji projektowych na niskim poziomie, tak testy akceptacyjne skłaniają do podejmowania lepszych decyzji architektonicznych na wysokim poziomie. Stworzenie frameworka testów akceptacyjnych może wydawać się trudnym zadaniem. Jeśli jednak weźmiemy funkcje tylko z jednej iteracji i utworzymy tylko część frameworka niezbędnego do wykonania tych kilku testów akceptacyjnych, dojdziemy do przekonania, że napisanie tego frameworka nie jest takie trudne. Przekonamy się również, że wysiłek jest wart kosztów.
4
Test, który zna wewnętrzną strukturę testowanego modułu i zależy od niej.
5
Test, który nie zna wewnętrznej struktury testowanego modułu i od niej nie zależy.
50
ROZDZIAŁ 4. TESTOWANIE
Przykład testów akceptacyjnych Rozważmy ponownie aplikację płacową. W pierwszej iteracji musimy mieć możliwość dodawania i usuwania pracowników do i z bazy danych. Musimy także być w stanie tworzyć czeki dla pracowników obecnie zapisanych w bazie danych. Na szczęście musimy brać pod uwagę tylko pracowników etatowych. Pracownikami innego rodzaju zajmiemy się w jednej z późniejszych iteracji. Na razie nie napisaliśmy jeszcze żadnego kodu i jeszcze nie zainwestowaliśmy niczego w projekt. To najlepszy czas, aby zacząć myśleć o testach akceptacyjnych. Tak jak wcześniej użytecznym narzędziem jest programowanie intencyjne. Powinniśmy napisać testy akceptacyjne tak, jak naszym zdaniem powinny one wyglądać, a następnie opracować wokół takiego testu strukturę języka skryptowego i systemu płacowego. Chcemy, aby testy akceptacyjne były wygodne do pisania i łatwe do zmiany. Chcemy, aby były umieszczone w narzędziu do zarządzania konfiguracją i zapisane tak, aby można je było uruchomić w każdej chwili, gdy będziemy mieli na to ochotę. W związku z tym ma sens, aby testy akceptacyjne były napisane w prostych plikach tekstowych. Oto przykład skryptu testu akceptacyjnego: AddEmp 1429 "Robert Martin" 3215.88 Payday Verify Paycheck EmpId 1429 GrossPay 3215.88
W pokazanym przykładzie dodajemy do bazy danych pracownika o identyfikatorze 1429. Nazywa się Robert Martin, a jego miesięczna pensja wynosi 3215,88 dolara. Następnie informujemy system, że jest dzień wypłaty i trzeba zrealizować wypłatę dla wszystkich pracowników. Na koniec sprawdzamy, czy została wygenerowana wypłata dla pracownika o identyfikatorze 1429 z polem GrossPay o wartości $3215.88. Wyraźnie widać, że napisanie takiego skryptu przez klientów będzie bardzo łatwe. Do takiego skryptu można również łatwo dodawać nowe funkcjonalności. Zastanówmy się jednak, co to oznacza dla struktury systemu. Pierwsze dwie linijki skryptu są funkcjami aplikacji płacowej. Możemy nazwać te linijki transakcjami płac. Są to funkcje, których oczekują użytkownicy aplikacji płacowej. Jednak linijka z funkcją Verify nie jest transakcją, której oczekują użytkownicy aplikacji płacowej. Ta linijka to dyrektywa, która jest specyficzna dla testu akceptacyjnego. Tak więc nasz framework testów akceptacyjnych będzie musiał analizować ten plik tekstowy i oddzielać transakcje płacowe od dyrektyw testu akceptacyjnego. Musi wysłać transakcje płacowe do aplikacji płacowej, a następnie użyć dyrektyw testu akceptacyjnego, aby odpytać aplikację płacową w celu weryfikacji danych. To wywiera nacisk architektoniczny na aplikację płacową. Program płacowy będzie musiał akceptować wejście bezpośrednio od użytkowników, a także z frameworka testów akceptacyjnych. Chcemy jak najszybciej scalić te dwie ścieżki wejścia. Wygląda na to, że program płacowy będzie potrzebował procesora transakcji, który będzie w stanie obsługiwać transakcje w formie AddEmp i Payday pochodzące z więcej niż jednego źródła. Trzeba znaleźć jakąś wspólną formę dla tych transakcji, tak aby ilość specjalistycznego kodu była ograniczona do minimum. Jednym z rozwiązań mogłoby być wprowadzanie do aplikacji płacowej transakcji w formacie XML. Oczywiście framework testów akceptacyjnych może generować XML. Wydaje się też prawdopodobne, że interfejs użytkownika systemu płacowego także może generować XML. Możemy więc oczekiwać transakcji, które wyglądają tak:
1429 Robert Martin 3215.88
WNIOSEK
51
Te transakcje mogą trafić do aplikacji płacowej poprzez wywołanie procedury, gniazdo, a nawet wejściowy plik wsadowy. Rzeczywiście, zmiana jednego źródła na inne w trakcie prac projektowych jest trywialnym zadaniem. Dlatego na początku iteracji możemy zdecydować, że będziemy czytać transakcje z pliku, a następnie migrować do API lub gniazda w późniejszej fazie projektu. W jaki sposób framework testów akceptacyjnych wywołuje dyrektywę Verify? Jest oczywiste, że musi istnieć pewien sposób dostępu do danych generowanych przez aplikację płacową. Jak wspominaliśmy wcześniej, nie chcemy, aby framework testów akceptacyjnych potrafił czytać wydrukowane czeki, ale możemy zrealizować następne dobre rozwiązanie. Możemy zlecić aplikacji płacowej generowanie czeków w XML. Framework testów akceptacyjnych może następnie przechwycić ten XML i odpytywać o właściwe dane. Ostatni etap drukowania czeku z XML może być na tyle banalny, aby można go było obsłużyć za pomocą ręcznych testów akceptacyjnych. Dlatego aplikacja płacowa może stworzyć dokument XML, który zawiera wszystkie czeki. Dokument ten może mieć następującą zawartość:
1429 Robert Martin 3215.88
Oczywiście framework testów akceptacyjnych po otrzymaniu takiego dokumentu XML może uruchomić dyrektywę Verify. Jak wspominaliśmy wcześniej, możemy przesłać taki dokument XML przez gniazdo, API bądź plik. W początkowych iteracjach plik wydaje się najprostszym sposobem. Z tego powodu aplikacja płacowa rozpocznie swoje życie od czytania transakcji XML z pliku i wyprowadzania czeków do pliku XML. Framework testów akceptacyjnych będzie czytał transakcje w postaci tekstowej, tłumaczył na XML i zapisywał do pliku. Następnie wywoła aplikację płacową. Na koniec przeczyta wyjściowy XML z programu płacowego i wywoła dyrektywy Verify.
Architektura „przy okazji” Zwróćmy uwagę na wpływ, jaki wywierają testy akceptacyjne na architekturę systemu płacowego. Sam fakt, że zaczęliśmy brać pod uwagę pisanie testów w pierwszej kolejności, bardzo szybko doprowadził nas do zastosowania wejścia i wyjścia w formacie XML. Taka architektura umożliwiła oddzielenie źródeł transakcji od aplikacji płacowej. Spowodowała również oddzielenie mechanizmu drukującego czeki od aplikacji płacowej. To są dobre decyzje architektoniczne.
Wniosek Im prostsze jest uruchomienie zestawu testów, tym częściej będą one uruchamiane. Im częściej będą uruchamiane testy, tym szybciej wykryjemy wszelkie odchylenia. Gdyby udało nam się uruchomić wszystkie testy kilka razy dziennie, to nieprawidłowe działanie systemu nigdy nie trwałoby dłużej niż kilka minut. To rozsądny cel. Po prostu nie pozwalamy na to, aby system wymknął nam się spod kontroli. Kiedy system raz zacznie działać na określonym poziomie, nigdy nie „zsunie się” na niższy poziom. Jednak weryfikacja to tylko jedna z korzyści płynących z pisania testów. Zarówno testy jednostkowe, jak i testy akceptacyjne są formą dokumentacji. Ta dokumentacja jest kompilowalna i wykonywalna, dlatego jest dokładna i wiarygodna. Co więcej, testy są napisane w jednoznacznych językach, które są czytelne dla ich odbiorców. Programiści potrafią czytać testy jednostkowe, ponieważ są one napisane w ich języku programowania. Klienci potrafią czytać testy akceptacyjne, ponieważ są one napisane w języku, który oni sami zaprojektowali.
52
ROZDZIAŁ 4. TESTOWANIE
Prawdopodobnie najważniejszą korzyścią z testowania jest jego wpływ na architekturę i projekt. Aby moduł lub aplikacja były sprawdzalne, muszą być również pozbawione sprzężeń. Im bardziej aplikacja jest sprawdzalna, tym w większym stopniu są z niej wyeliminowane sprzężenia. Akt pisania kompleksowych testów akceptacyjnych i testów jednostkowych ma głęboki wpływ na strukturę oprogramowania.
Bibliografia 1. Tim Mackinnon, Steve Freeman, Philip Craig, Endo-Testing: Unit Testing with Mock Objects. Extreme Programming Examined, Addison-Wesley, 2001. 2. Ron Jeffries et al., Extreme Programming Installed, Upper Saddle River, NJ: Addison-Wesley, 2001.
R OZDZIAŁ 5
Refaktoryzacja
Jedynym czynnikiem, który staje się rzadkością w świecie obfitości, jest ludzka uwaga — Kevin Kelly w czasopiśmie „Wired”
Tematem tego rozdziału jest ludzka uwaga. Chodzi o zwracanie uwagi na to, co robimy, i dbanie o to, abyśmy robili to jak najlepiej. Chodzi o różnicę pomiędzy zrobieniem czegoś tak, żeby działało, a zrobieniem tego dobrze. Chodzi o wagę, jaką przywiązujemy do struktury naszego kodu. Martin Fowler w swojej klasycznej książce Refactoring definiuje refaktoryzację jako „... proces modyfikacji systemu oprogramowania w sposób, który nie wywiera szkodliwego wpływu na działanie kodu na zewnątrz, ale poprawia jego strukturę wewnętrzną1”. Ale po co mielibyśmy poprawiać strukturę działającego kodu? Co ze starym powiedzeniem, „jeśli coś nie jest zepsute, nie należy tego naprawiać”? Każdy moduł oprogramowania spełnia trzy funkcje. Pierwszą jest funkcja wypełniania przez system podczas działania programu. Ta funkcja jest powodem, dla którego moduł istnieje. Drugą funkcją modułu jest możliwość wprowadzania zmian. Prawie wszystkie moduły zmieniają się w trakcie swojego życia, a deweloperzy są odpowiedzialni za to, aby wprowadzanie tych zmian było jak najprostsze. Moduł, w którym wprowadzanie zmian jest trudne, jest uszkodzony i wymaga naprawy, nawet jeśli działa. Trzecią funkcją modułu jest komunikacja z jego czytelnikami. Deweloperzy, którzy nie znają modułu, powinni być w stanie przeczytać i zrozumieć go bez zbędnej gimnastyki umysłowej. Moduł, który niezbyt dobrze komunikuje swoje działanie, jest uszkodzony i wymaga naprawy.
1
[Fowler 99], str. xvi.
54
ROZDZIAŁ 5. REFAKTORYZACJA
Co sprawia, że moduł staje się łatwy do czytania i wprowadzania zmian? Znaczna część tej książki jest poświęcona zasadom i wzorcom, których głównym celem jest pomoc w tworzeniu elastycznych i adaptowalnych modułów. Jednak stworzenie modułu, który jest łatwy do czytania i wprowadzania zmian, wymaga czegoś więcej niż tylko zasad i wzorców. Wymaga uwagi, dyscypliny i pasji do tworzenia piękna.
Generowanie liczb pierwszych — prosty przykład refaktoryzacji2 Rozważmy kod z listingu 5.1. Ten program generuje liczby pierwsze. Jest to jedna wielka funkcja z wieloma zmiennymi w postaci pojedynczych liter i komentarzami, które mają pomóc nam ją przeczytać. Listing 5.1. GeneratePrimes.java, wersja pierwsza /**
* Ta klasa generuje liczby pierwsze do wartości maksymalnej określonej przez użytkownika. * Zastosowano algorytm sita Eratostenesa. *
* Eratostenes z Cyreny, ur. 276 p.n.e. w Cyrenie, Libia — zm. 194 p.n.e. w Aleksandrii. * Pierwszy człowiek, który obliczył obwód Ziemi. * Znany także z prac nad kalendarzem z latami przestępnymi. Prowadził bibliotekę w Aleksandrii. *
* Algorytm jest stosunkowo prosty. W tablicy liczb całkowitych zaczynających się od liczby 2 * skreśl wszystkie wielokrotności 2. Znajdź kolejną nieskreśloną liczbę i skreśl jej wszystkie wielokrotności. * Powtarzaj, aż przekroczysz pierwiastek kwadratowy z wartości maksymalnej. * * @author Robert C. Martin * @version 9 12 grudnia 1999 */
import java.util.*;
public class GeneratePrimes {
/**
* @param maxValue to ograniczenie generowanych wartości. */
public static int[] generatePrimes(int maxValue) { if (maxValue >= 2) // jedyny prawidłowy przypadek {
// deklaracje
int s = maxValue + 1; // rozmiar tablicy boolean[] f = new boolean[s]; int i;
// zainicjowanie tablicy wartością true
for (i = 0; i < s; i++) f[i] = true;
// pozbycie się znanych wartości, które nie są liczbami pierwszymi
f[0] = f[1] = false;
// sito
int j; for (i = 2; i < Math.sqrt(s) + 1; i++) { if (f[i]) // jeśli i jest nieskreślone, skreśl jego wielokrotności. { for (j = 2 * i; j < s; j += i) f[j] = false; // wielokrotność nie jest liczbą pierwszą
2
Początkowo napisałem ten program na potrzeby kursu XP Immersion I, stosując testy napisane przez Jima Newkirka. Kent Beck i Jim Newkirk zrefaktoryzowali ten kod w obecności studentów. Tutaj spróbowałem odtworzyć tę refaktoryzację.
GENEROWANIE LICZB PIERWSZYCH — PROSTY PRZYKŁAD REFAKTORYZACJI
}
55
}
// ile liczb pierwszych jest w tablicy?
int count = 0; for (i = 0; i < s; i++) { if (f[i]) count++; // zwiększenie licznika. } int[] primes = new int[count];
// przeniesienie liczb pierwszych do wyniku
for (i = 0, j = 0; i < s; i++) { if (f[i]) // jeżeli to liczba pierwsza primes[j++] = i; }
}
}
return primes; // zwrócenie liczb pierwszych } else // maxValue < 2 return new int[0]; // zwrócenie tablicy null w przypadku błędnych danych wejściowych.
Test jednostkowy dla funkcji GeneratePrimes zamieszczono na listingu 5.2. Test przyjmuje podejście statystyczne, sprawdzając, czy generator może generować liczby pierwsze dla 0, 2, 3 i 100 elementów. W pierwszym przypadku algorytm nie powinien zwrócić żadnych liczb pierwszych. W drugim przypadku powinna być jedna liczba pierwsza — 2. W trzecim powinny być dwie liczby pierwsze — 2 i 3. W ostatnim przypadku powinno być 25 liczb pierwszych, z których ostatnia to 97. Jeśli wszystkie testy przejdą, to można przyjąć założenie, że generator pracuje. Wątpię, aby ten test pokrywał wszystkie przypadki, ale nie potrafię znaleźć sensownego scenariusza, w którym test przechodzi, a pomimo tego funkcja nie działa. Listing 5.2. TestGeneratePrimes.java import junit.framework.*; import java.util.*; public class TestGeneratePrimes extends TestCase { public static void main(String args[]) { junit.swingui.TestRunner.main( new String[] {"TestGeneratePrimes"}); } public TestGeneratePrimes(String name) { super(name); } public void testPrimes() { int[] nullArray = GeneratePrimes.generatePrimes(0); assertEquals(nullArray.length, 0); int[] minArray = GeneratePrimes.generatePrimes(2); assertEquals(minArray.length, 1); assertEquals(minArray[0], 2); int[] threeArray = GeneratePrimes.generatePrimes(3); assertEquals(threeArray.length, 2); assertEquals(threeArray[0], 2);
56
ROZDZIAŁ 5. REFAKTORYZACJA
assertEquals(threeArray[1], 3);
}
}
int[] centArray = GeneratePrimes.generatePrimes(100); assertEquals(centArray.length, 25); assertEquals(centArray[24], 97);
Do wykonania procesu refaktoryzacji tego program użyłem przeglądarki refaktoryzacji Idea firmy IntelliJ. Za pomocą tego narzędzie można w trywialny sposób wyodrębniać metody oraz zmieniać nazwy zmiennych i klas. Wydaje się dość oczywiste, że główną funkcję należałoby rozbić na trzy oddzielne funkcje. Pierwsza inicjuje wszystkie zmienne i ustawia sito. Druga faktycznie wykonuje algorytm sita, natomiast trzecia ładuje przesiane wyniki do tablicy liczb całkowitych. Aby lepiej pokazać tę strukturę, na listingu 5.3 wyodrębniłem te funkcje do trzech osobnych metod. Usunąłem również kilka niepotrzebnych komentarzy i zmieniłem nazwę klasy na PrimeGenerator. Wszystkie testy nadal przechodziły. Wyodrębnienie tych trzech funkcji zmusiło mnie do wypromowania niektórych zmiennych funkcji do statycznych pól klasy. Sądzę, że to pozwoliło jednoznacznie pokazać, które zmienne są lokalne, a które mają szerszy zasięg. Listing 5.3. PrimeGenerator.java, wersja druga /**
* Ta klasa generuje liczby pierwsze do wartości maksymalnej określonej przez użytkownika. * Zastosowano algorytm sita Eratostenesa. * W tablicy liczb całkowitych zaczynających się od liczby 2 * Skreśl wszystkie wielokrotności 2. Znajdź kolejną nieskreśloną liczbę i skreśl jej wszystkie wielokrotności. * Powtarzaj, aż przekroczysz pierwiastek kwadratowy z wartości maksymalnej. */
import java.util.*; public class PrimeGenerator { private static int s; private static boolean[] f; private static int[] primes;
public static int[] generatePrimes(int maxValue) { if (maxValue < 2) return new int[0]; else { initializeSieve(maxValue); sieve(); loadPrimes(); return primes; // zwrócenie liczb pierwszych } } private static void loadPrimes() { int i; int j;
// ile liczb pierwszych jest w tablicy?
int count = 0; for (i = 0; i < s; i++) { if (f[i]) count++; // zwiększenie licznika. } primes = new int[count];
GENEROWANIE LICZB PIERWSZYCH — PROSTY PRZYKŁAD REFAKTORYZACJI
57
// przeniesienie liczb pierwszych do wyniku
for (i = 0, j = 0; i < s; i++) { if (f[i]) // jeżeli to liczba pierwsza primes[j++] = i; } } private static void sieve() { int i; int j; for (i = 2; i < Math.sqrt(s) + 1; i++) { if (f[i]) // jeśli i jest nieskreślone, skreśl jego wielokrotności. { for (j = 2 * i; j < s; j += i) f[j] = false; // wielokrotność nie jest liczbą pierwszą } } } private static void initializeSieve(int maxValue) {
// deklaracje
s = maxValue + 1; // rozmiar tablicy f = new boolean[s]; int i;
// zainicjowanie tablicy wartościami true
for (i = 0; i < s; i++) f[i] = true;
// pozbycie się znanych wartości, które nie są liczbami pierwszymi
}
}
f[0] = f[1] = false;
W funkcji initializeSieve jest trochę chaosu, dlatego na listingu 5.4 znacznie ją uporządkowałem. Po pierwsze, zastąpiłem wszystkie wystąpienia zmiennej s wywołaniem f.length. Następnie zmieniłem nazwy trzech funkcji na bardziej ekspresywne. Na koniec zreorganizowałem zawartość funkcji initializeArrayOfIntegers (która wcześniej nazywała się initializeSieve), aby łatwiej się ją czytało. Wszystkie testy nadal przechodziły. Listing 5.4. PrimeGenerator.java, wersja trzecia (fragment) public class PrimeGenerator { private static boolean[] f; private static int[] result; public static int[] generatePrimes(int maxValue) { if (maxValue < 2) return new int[0]; else { initializeArrayOfIntegers(maxValue); crossOutMultiples(); putUncrossedIntegersIntoResult(); return result; } } private static void initializeArrayOfIntegers(int maxValue) { f = new boolean[maxValue + 1];
58
}
ROZDZIAŁ 5. REFAKTORYZACJA
f[0] = f[1] = false; // ani liczba pierwsza, ani wielokrotność. for (int i = 2; i < f.length ; i++) f[i] = true;
Następnie przyjrzałem się metodzie crossOutMultiples. W tej funkcji, a także w innych, było wiele instrukcji postaci if(f[i]==true). Intencją było sprawdzenie, czy i jest skreślone, dlatego zmieniłem nazwę f na unCrossed. To jednak doprowadziło do brzydkich instrukcji w postaci unCrossed[i] = false. Uznałem, że podwójna negacja jest myląca. Dlatego zmieniłem nazwę tablicy na isCrossed, a następnie zmieniłem sens wszystkich wartości Boolean. Wszystkie testy nadal przechodziły. Pozbyłem się inicjalizacji, która ustawiała wartości tablicy isCrossed[0] i isCrossed[1] na true i po prostu sprawdziłem, czy któraś z części tej funkcji nie używa tablicy isCrossed dla indeksów mniejszych od 2. Wyodrębniłem wewnętrzną pętlę funkcji crossOutMultiples i nazwałem ją crossOutMultiplesOf. Uznałem także, że zapis if(isCrossed[i]== false) był mylący, dlatego stworzyłem funkcję notCrossed i zmieniłem instrukcję if na if(notCrossed(i)). Wszystkie testy nadal przechodziły. Poświęciłem trochę czasu na pisanie komentarza, który próbował wyjaśnić, dlaczego musimy iterować tylko do wartości pierwiastka kwadratowego z rozmiaru tablicy. Doprowadziło mnie to do wyodrębnienia obliczeń do funkcji, w której mogłem umieścić komentarz wyjaśniający. Pisząc komentarz, zauważyłem, że pierwiastek kwadratowy jest maksymalnym czynnikiem pierwszym dowolnej liczby całkowitej w tablicy. Z tego względu wybrałem tę nazwę dla zmiennych oraz funkcji, które je przetwarzały. Wynik wszystkich przekształceń refaktoryzacyjnych pokazano na listingu 5.5. Wszystkie testy nadal przechodziły. Listing 5.5. PrimeGenerator.java, wersja czwarta (fragment) public class PrimeGenerator { private static boolean[] isCrossed; private static int[] result; public static int[] generatePrimes(int maxValue) { if (maxValue < 2) return new int[0]; else { initializeArrayOfIntegers(maxValue); crossOutMultiples(); putUncrossedIntegersIntoResult(); return result; } } private static void initializeArrayOfIntegers(int maxValue) { isCrossed = new boolean[maxValue + 1]; for (int i = 2; i < isCrossed.length; i++) isCrossed[i] = false; } private static void crossOutMultiples() { int maxPrimeFactor = calcMaxPrimeFactor(); for (int i = 2; i pierwiastek kwadratowy z rozmiaru tablicy,
GENEROWANIE LICZB PIERWSZYCH — PROSTY PRZYKŁAD REFAKTORYZACJI
59
// to q nigdy nie będzie większe niż 1. // Zatem p jest największym czynnikiem pierwszym w tablicy. // Jednocześnie jest to limit iteracji.
}
double maxPrimeFactor = Math.sqrt(isCrossed.length) + 1; return (int) maxPrimeFactor;
private static void crossOutMultiplesOf(int i) { for (int multiple = 2*i; multiple < isCrossed.length; multiple += i) isCrossed[multiple] = true; } private static boolean notCrossed(int i) { return isCrossed[i] == false; }
Ostatnią funkcją do zrefaktoryzowania jest putUncrossedIntegersIntoResult. Metoda ta składa się z dwóch części. W pierwszej części zlicza nieskreślone liczby całkowite w tablicy i tworzy tablicę wynikową o tym rozmiarze. W drugiej przenosi nieskreślone liczby całkowite do tablicy wynikowej. Wyodrębniłem pierwszą część do osobnej funkcji i zrobiłem trochę porządku (patrz listing 5.6). Wszystkie testy nadal przechodziły. Listing 5.6. PrimeGenerator.java, wersja piąta (fragment) private static void putUncrossedIntegersIntoResult() { result = new int[numberOfUncrossedIntegers()]; for (int j = 0, i = 2; i < isCrossed.length; i++) if (notCrossed(i)) result[j++] = i; } private static int numberOfUncrossedIntegers() { int count = 0; for (int i = 2; i < isCrossed.length; i++) if (notCrossed(i)) count++; return count; }
Ostateczny przegląd Następnie jeszcze raz przejrzałem cały program, czytając go od początku do końca — jak ktoś, kto chce przeczytać dowód geometryczny. To bardzo ważny krok. Dotąd refaktoryzowałem fragmenty. Teraz chcę zobaczyć, czy cały program tworzy czytelną całość. Najpierw zdałem sobie sprawę, że nie podoba mi się nazwa initializeArrayOfIntegers. To, co inicjalizujemy, w rzeczywistości nie jest tablicą liczb całkowitych, ale tablicą wartości Boolean. Jednak zastosowanie nazwy initializeArrayOfBooleans nie poprawia sytuacji. W rzeczywistości w tej metodzie anulujemy skreślenie odpowiednich liczb całkowitych, tak aby następnie móc skreślić wielokrotności. Z tego powodu zmieniłem nazwę metody na uncrossIntegersUpTo. Zdałem sobie również sprawę, że nie podoba mi się nazwa isCrossed dla tablicy wartości Boolean. Z tego powodu zmieniłem tę nazwę na crossedOut. Wszystkie testy nadal przechodziły.
60
ROZDZIAŁ 5. REFAKTORYZACJA
Można by pomyśleć, że dość lekko podchodziłem do zmiany nazw, ale dzięki edytorowi refaktoryzacji można sobie pozwolić na tego rodzaju poprawki — ich koszt jest praktycznie zerowy. Nawet bez specjalistycznego narzędzia proste wyszukiwanie z zamianą jest dość tanie. A testy zdecydowanie łagodzą wszelkie szanse na to, aby nieświadomie coś zepsuć. Nie wiem, co ja paliłem, pisząc to wszystko o wartości maxPrimeFactor. Ups! Pierwiastek kwadratowy z rozmiaru tablicy niekoniecznie musi być liczbą pierwszą. Ta metoda nie obliczała maksymalnego czynnika pierwszego. Wyjaśniający komentarz był po prostu błędny. Z tego powodu przepisałem komentarz, aby lepiej wyjaśnić uzasadnienie pierwiastka kwadratowego, i odpowiednio zmieniłem nazwę wszystkich zmiennych3. Wszystkie testy nadal przechodziły. Do czego, u licha, jest to +1? Myślę, że to musiała być paranoja. Obawiałem się, że ułamkowy pierwiastek kwadratowy po konwersji na liczbę całkowitą przyjmie wartość, która będzie zbyt mała, aby mogła służyć jako granica iteracji. Ale to było głupie. Prawdziwą granicą iteracji jest największa liczba pierwsza, która jest mniejsza lub równa pierwiastkowi kwadratowemu z rozmiaru tablicy. W związku z tym pozbyłem się fragmentu +1. Wszystkie testy nadal przechodziły, ale ta ostatnia zmiana wywołała u mnie pewną nerwowość. Rozumiem uzasadnienie zastosowania wartości pierwiastka kwadratowego, ale mam wrażenie, że mogą występować pewne graniczne przypadki, które nie zostały pokryte. Z tego powodu napisałem kolejny test, który sprawdza, czy nie ma wielokrotności w żadnej z list liczb pierwszych od 2 do 500 (patrz funkcja testExhaustive na listingu 5.8). Nowy test przeszedł, a moje obawy zostały rozwiane. Pozostałą część kodu czyta się dość przyjemnie. Myślę więc, że praca jest skończona. Ostateczną wersję zamieszczono na listingach 5.7 i 5.8. Listing 5.7. PrimeGenerator.java, wersja ostateczna /**
* Ta klasa generuje liczby pierwsze do wartości maksymalnej określonej przez użytkownika. * Zastosowano algorytm sita Eratostenesa. * W tablicy liczb całkowitych zaczynających się od liczby 2 * Skreśl wszystkie wielokrotności 2. Znajdź kolejną nieskreśloną liczbę i skreśl jej wszystkie wielokrotności. * Powtarzaj, aż nie będzie wielokrotności w tablicy. */
public class PrimeGenerator { private static boolean[] crossedOut; private static int[] result; public static int[] generatePrimes(int maxValue) { if (maxValue < 2) return new int[0]; else { uncrossIntegersUpTo(maxValue); crossOutMultiples(); putUncrossedIntegersIntoResult(); return result; } } private static void uncrossIntegersUpTo(int maxValue) { crossedOut = new boolean[maxValue + 1]; 3
Kiedy Kent Beck i Jim Newkirk zrefaktoryzowali ten program, obyli się bez pierwiastka kwadratowego. Kent uznał, że zastosowanie pierwiastka kwadratowego było niezrozumiałe i nie było testu, który by nie przeszedł, gdyby iteracje były wykonywane do wartości rozmiaru tablicy. Nie mogę zmusić się do rezygnacji z wydajności. Myślę, że to zdradza moje korzenie — programowanie w języku assemblera.
GENEROWANIE LICZB PIERWSZYCH — PROSTY PRZYKŁAD REFAKTORYZACJI
}
for (int i = 2; i < crossedOut.length; i++) crossedOut[i] = false;
private static void crossOutMultiples() { int limit = determineIterationLimit(); for (int i = 2; i GetName()); PaymentClassification* pc = e->GetClassification(); SalariedClassification* sc = dynamic_cast(pc); assert(sc);
}
assertEquals(2500.00, sc->GetSalary(), .001); PaymentSchedule* ps = e->GetSchedule(); MonthlySchedule* ms = dynamic_cast(ps); assert(ms); PaymentMethod* pm = e->GetMethod(); HoldMethod* hm = dynamic_cast(pm); assert(hm);
Baza danych systemu płacowego Klasa AddEmployeeTransaction korzysta z klasy o nazwie PayrollDatabase. Klasa ta przechowuje wszystkie istniejące obiekty Employee w obiekcie Dictionary, w której kluczem jest identyfikator empID. Klasa zawiera także obiekt Dictionary zawierający odwzorowanie identyfikatorów memberIDs członków związków zawodowych na identyfikatory empID. Strukturę tej klasy pokazano na rysunku 19.3. PayrollDatabase jest przykładem zastosowania wzorca projektowego Fasada (rozdział 15.).
Rysunek 19.3. Statyczna struktura klasy PayrollDatabase
Uproszczoną implementację klasy PayrollDatabase zamieszczono na listingach 19.3 i 19.4. Pokazana implementacja ma ułatwić nam zrealizowanie początkowych przypadków testowych. Nie zawiera jeszcze słownika, który odwzorowuje identyfikatory członków związków zawodowych na egzemplarze obiektów Employee.
220
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Listing 19.3. PayrollDatabase.h #ifndef PAYROLLDATABASE_H #define PAYROLLDATABASE_H #include class Employee; class PayrollDatabase { public: virtual ~PayrollDatabase(); Employee* GetEmployee(int empId); void AddEmployee(int empid, Employee*); void clear() {itsEmployees.clear();} private: map itsEmployees; }; #endif
Listing 19.4. PayrollDatabase.cpp #include "PayrollDatabase.h" #include "Employee.h" PayrollDatabase GpayrollDatabase; PayrollDatabase::~PayrollDatabase() { } Employee* PayrollDatabase::GetEmployee(int empid) { return itsEmployees[empid]; } void PayrollDatabase::AddEmployee(int empid, Employee* e) { itsEmployees[empid] = e; }
Ogólnie rzecz biorąc, implementację bazy danych uważam za szczegóły. Wybór tych szczegółów powinien być odłożony w czasie tak długo, jak to możliwe. To, czy ta konkretna baza danych będzie zaimplementowana w formie systemu RDBMS, zwykłych plików, czy też systemu OODBMS, w tym momencie jest bez znaczenia. W tej chwili ważne jest stworzenie API, które będzie świadczyć usługi bazy danych dla reszty aplikacji. Właściwą implementacją potrzebnej bazy danych zajmiemy się później. Odkładanie implementacji szczegółów dotyczących bazy danych jest rzadką praktyką, ale bardzo korzystną. Decyzje dotyczące bazy danych zazwyczaj mogą czekać do chwili, gdy zdobędziemy większą wiedzę na temat oprogramowania i jego potrzeb. Dzięki oczekiwaniu unikamy problemu zbytniej rozbudowy infrastruktury bazy danych. Zamiast tego implementujemy tylko te mechanizmy bazy danych, które są niezbędne z punktu widzenia realizacji potrzeb aplikacji.
Zastosowanie wzorca Metoda szablonowa do dodawania pracowników Na rysunku 19.4 pokazano dynamiczny model dodawania pracownika. Zwróćmy uwagę, że obiekt AddEmployeeTransaction wysyła komunikaty sam do siebie w celu uzyskania odpowiednich obiektów PaymentClassification i PaymentSchedule. Komunikaty te są zaimplementowane w pochodnych klasy AddEmployeeTransaction. To jest implementacja wzorca projektowego Metoda szablonowa.
DODAWANIE PRACOWNIKÓW
221
Rysunek 19.4. Dynamiczny model dodawania pracownika
Na listingach 19.5 i 19.6 zamieszczono implementację wzorca Metoda szablonowa w klasie AddEm ployeeTransaction. Klasa implementuje metodę Execute() w celu wywołania dwóch czysto wirtualnych funkcji zaimplementowanych w klasach pochodnych. Te funkcje, GetSchedule() i GetClassification(), zwracają obiekty PaymentSchedule i PaymentClassification wymagane przez nowo stworzony obiekt Employee. Następnie metoda Execute() wiąże te obiekty z obiektem Employee i zapisuje obiekt Employee do bazy PayrollDatabase. Listing 19.5. AddEmployeeTransaction.h #ifndef ADDEMPLOYEETRANSACTION_H #define ADDEMPLOYEETRANSACTION_H #include "Transaction.h" #include class PaymentClassification; class PaymentSchedule; class AddEmployeeTransaction : public Transaction { public: virtual ~AddEmployeeTransaction(); AddEmployeeTransaction(int empid, string name, string address); virtual PaymentClassification* GetClassification() const = 0; virtual PaymentSchedule* GetSchedule() const = 0; virtual void Execute(); private: int itsEmpid; string itsName; string itsAddress;
}; #endif
222
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Listing 19.6. AddEmployeeTransaction.cpp #include #include #include #include
"AddEmployeeTransaction.h" "HoldMethod.h" "Employee.h" "PayrollDatabase.h"
class PaymentMethod; class PaymentSchedule; class PaymentClassification; extern PayrollDatabase GpayrollDatabase; AddEmployeeTransaction::~AddEmployeeTransaction() { } AddEmployeeTransaction:: AddEmployeeTransaction(int empid, string name, string address) : itsEmpid(empid) , itsName(name) , itsAddress(address) { } void AddEmployeeTransaction::Execute() { PaymentClassification* pc = GetClassification(); PaymentSchedule* ps = GetSchedule(); PaymentMethod* pm = new HoldMethod(); Employee* e = new Employee(itsEmpid, itsName, itsAddress); e->SetClassification(pc); e->SetSchedule(ps); e->SetMethod(pm); GpayrollDatabase.AddEmployee(itsEmpid, e); }
Na listingach 19.7 i 19.8 pokazano implementację klasy AddSalariedEmployee. Ta klasa dziedziczy po klasie AddEmployeeTransaction. Implementuje metody GetSchedule() i GetClassification() w celu przekazania właściwych obiektów do metody AddEmployeeTransaction::Execute(). Listing 19.7. AddSalariedEmployee.h #ifndef ADDSALARIEDEMPLOYEE_H #define ADDSALARIEDEMPLOYEE_H #include "AddEmployeeTransaction.h" class AddSalariedEmployee : public AddEmployeeTransaction { public: virtual ~AddSalariedEmployee(); AddSalariedEmployee(int empid, string name, string address, double salary); PaymentClassification* GetClassification() const; PaymentSchedule* GetSchedule() const; private: double itsSalary;
}; #endif
USUWANIE PRACOWNIKÓW
223
Listing 19.8. AddSalariedEmployee.cpp #include "AddSalariedEmployee.h" #include "SalariedClassification.h" #include "MonthlySchedule.h" AddSalariedEmployee::~AddSalariedEmployee() { } AddSalariedEmployee:: AddSalariedEmployee(int empid, string name, string address, double salary) : AddEmployeeTransaction(empid, name, address) , itsSalary(salary) { } PaymentClassification* AddSalariedEmployee::GetClassification() const { return new SalariedClassification(itsSalary); } PaymentSchedule* AddSalariedEmployee::GetSchedule() const { return new MonthlySchedule(); }
Implementację klas AddHourlyEmployee i AddCommissionedEmployee pozostawiam Ci jako ćwiczenie do samodzielnego wykonania. Pamiętaj, aby najpierw napisać przypadki testowe.
Usuwanie pracowników Na rysunkach 19.5 i 19.6 pokazano statyczne i dynamiczne modele transakcji używanych do usuwania pracowników.
Rysunek 19.5. Statyczny model transakcji DeleteEmployee
Rysunek 19.6. Dynamiczny model transakcji DeleteEmployee
224
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Na listingu 19.9 pokazano przypadek testowy dla transakcji usuwania pracownika. Na listingach 19.10 i 19.11 zamieszczono implementację klasy DeleteEmployeeTransaction. To jest bardzo typowa implementacja wzorca projektowego Polecenie. W konstruktorze są zapisywane dane, na których metoda Execute() wykonuje działania. Listing 19.9. PayrollTest::TestDeleteEmployee() void PayrollTest::TestDeleteEmployee() { cerr GetClassification(); if (HourlyClassification* hc = dynamic_cast(pc)) { hc->AddTimeCard(new TimeCard(itsDate, itsHours)); } else throw("Próba dodania karty czasu pracy do pracownika, który nie pracuje w systemie godzinowym"); } else throw("Nie ma takiego pracownika."); }
Na rysunkach 19.9 i 19.10 pokazano podobny projekt dla transakcji dostarczania raportów sprzedaży do pracownika wynagradzanego w systemie prowizyjnym. Implementację tych klas pozostawiam czytelnikom jako ćwiczenie do samodzielnego wykonania.
Rysunek 19.9. Statyczny model transakcji SalesReceiptTransaction
Rysunek 19.10. Dynamiczny model transakcji SalesReceiptTransaction
KARTY PRACY, RAPORTY SPRZEDAŻY I SKŁADKI
229
Na rysunkach 19.11 i 19.12 pokazano projekt transakcji przypisywania składek członkom związków zawodowych.
Rysunek 19.11. Statyczny model transakcji ServiceChargeTransaction
Rysunek 19.12. Dynamiczny model transakcji ServiceChargeTransaction
Te projekty pokazują rozbieżność pomiędzy modelem transakcji i podstawowym modelem, który stworzyliśmy. Podstawowy obiekt Employee może być powiązany z wieloma organizacjami, ale model transakcji zakłada, że każde powiązanie oznacza przynależność do związku zawodowego. Tak więc model transakcji nie zapewnia sposobu identyfikacji określonego rodzaju przynależności. Zamiast tego po prostu zakłada, że jeśli dodajemy opłatę składki, to pracownik należy do związku zawodowego. Problem ten rozwiązano w modelu dynamicznym, gdzie następuje przeszukiwanie zbioru obiektów Affiliation zapisanych w obiekcie Employee i sprawdzenie, czy ten zbiór zawiera obiekt UnionAffiliation. W przypadku powodzenia w wyszukiwaniu obiekt ServiceCharge jest przypisywany do odnalezionego obiektu UnionAffiliation. Na listingu 19.16 pokazano przypadek testowy dla transakcji ServiceChargeTransaction. Kod testu po prostu tworzy rekord pracownika godzinowego i dodaje do niego obiekt UnionAffiliation. Sprawdza również, czy pracownik o podanym identyfikatorze członka związku został zarejestrowany w bazie danych PayrollDatabase. Następnie tworzy transakcję ServiceChargeTransaction i wykonuje ją. Na koniec sprawdza, czy właściwy obiekt ServiceCharge rzeczywiście został dodany do obiektu UnionAffiliation powiązanego z obiektem Employee.
230
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Listing 19.16. PayrollTest::TestAddServiceCharge() void PayrollTest::TestAddServiceCharge() { cerr GetServiceCharge(20011101); assert(sc); assertEquals(12.95, sc->GetAmount(), .001); }
Kod a diagramy UML. Kiedy rysowałem diagram UML zamieszczony na rysunku 19.12, sądziłem, że zastąpienie obiektu NoAffiliation listą przynależności do organizacji jest lepszym rozwiązaniem. Uważałem takie rozwiązanie za bardziej elastyczne i mniej złożone. W końcu mogłem dodać informacje o przynależności w każdej chwili i nie musiałem tworzyć klasy NoAffiliation. Jednak podczas pisania przypadku testowego z listingu 19.16 zdałem sobie sprawę, że wywołanie metody SetAffiliation na obiekcie Employee jest lepsze niż wywołanie metody AddAffiliation. W końcu w wymaganiach nie ma żadnej wzmianki o tym, że z pracownikiem może być związany więcej niż jeden obiekt Affiliation, zatem nie ma potrzeby stosowania operatora dynamic_cast w celu wyboru spośród potencjalnie wielu rodzajów. Taka próba wiązałaby się z nadmierną złożonością. Przykład ten pokazuje, dlaczego rysowanie zbyt wielu diagramów UML bez sprawdzania ich w kodzie może być niebezpieczne. Kod może dać nam takie informacje na temat projektu, których nie da się uzyskać za pomocą UML. W tym przypadku umieściłem na diagramach UML struktury, które nie były potrzebne. Być może kiedyś takie struktury się przydadzą, ale na razie nie warto ponosić kosztów ich utrzymywania do nieznanej pory w przyszłości. Wspomniane koszty mogą przewyższać potencjalne korzyści. W tym przypadku mimo że koszty utrzymania wywołania dynamic_cast są stosunkowo niskie, nie mam zamiaru ich ponosić. Znacznie prościej jest zaimplementować klasę bez listy obiektów Affiliation. W związku z tym postanowiłem zastosować wzorzec projektowy Obiekt null zamiast klasy NoAffiliation. Implementację klasy ServiceChargeTransaction pokazano na listingach 19.17 i 19.18. Rzeczywiście jest ona znacznie prostsza bez pętli wyszukującej obiekty UnionAffiliation. Transakcja pobiera obiekt Employee z bazy danych, rzutuje związany z nim obiekt Affillation do obiektu UnionAffilliation, a następnie dodaje do niego obiekt ServiceCharge. Listing 19.17. ServiceChargeTransaction.h #ifndef SERVICECHARGETRANSACTION_H #define SERVICECHARGETRANSACTION_H #include "Transaction.h" class ServiceChargeTransaction : public Transaction { public: virtual ~ServiceChargeTransaction(); ServiceChargeTransaction(int memberId, long date, double charge); virtual void Execute(); private: int itsMemberId;
ZMIANA DANYCH PRACOWNIKÓW
}; #endif
231
long itsDate; double itsCharge;
Listing 19.18. ServiceChargeTransaction.cpp #include #include #include #include #include
"ServiceChargeTransaction.h" "Employee.h" "ServiceCharge.h" "PayrollDatabase.h" "UnionAffiliation.h"
extern PayrollDatabase GpayrollDatabase; ServiceChargeTransaction::~ServiceChargeTransaction() { } ServiceChargeTransaction:: ServiceChargeTransaction(int memberId, long date, double charge) :itsMemberId(memberId) , itsDate(date) , itsCharge(charge) { } void ServiceChargeTransaction::Execute() { Employee* e = GpayrollDatabase.GetUnionMember(itsMemberId); Affiliation* af = e->GetAffiliation(); if (UnionAffiliation* uaf = dynamic_cast(af)) { uaf->AddServiceCharge(itsDate, itsCharge); } }
Zmiana danych pracowników Na rysunkach 19.13 i 19.14 pokazano statyczną strukturę transakcji zmieniających atrybuty pracownika. Tę strukturę łatwo wywnioskować z przypadku użycia nr 6. Wszystkie transakcje pobierają argument EmpID, dzięki czemu możemy utworzyć klasę bazową o nazwie ChangeEmployeeTransaction. Poniżej tej klasy bazowej są klasy zmieniające pojedyncze atrybuty. Są to między innymi klasy ChangeNameTransaction oraz ChangeAddressTransaction. Transakcje, które zmieniają klasyfikacje, mają wspólny cel w tym sensie, że wszystkie modyfikują to samo pole obiektu Employee. Dzięki temu można je pogrupować za pomocą abstrakcyjnej klasy bazowej ChangeClassificationTransaction. To samo dotyczy transakcji zmieniających sposób wypłaty oraz przynależność związkową. Można to zaobserwować na podstawie struktury transakcji ChangeMethodTransaction i ChangeAffiliationTransaction.
232
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Rysunek 19.13. Statyczny model transakcji ChangeEmployeeTransaction
Rysunek 19.14. Statyczny model transakcji ChangeEmployeeTransaction (kontynuacja)
ZMIANA DANYCH PRACOWNIKÓW
233
Na rysunku 19.15 pokazano dynamiczny model wszystkich transakcji modyfikujących dane pracownika. Ponownie można tu zobaczyć zastosowanie wzorca projektowego Metoda szablonowa. W każdym przypadku z bazy danych PayrollDatabase należy pobrać obiekt Employee odpowiadający wartości EmpID. Z tego powodu metoda Execute klasy ChangeEmployeeTransaction implementuje to zachowanie, a następnie wysyła do własnego obiektu komunikat Change. Metoda ta będzie zadeklarowana jako wirtualna i zaimplementowana w klasach pochodnych, jak pokazano na rysunkach 19.16 i 19.17.
Rysunek 19.15. Dynamiczny model transakcji ChangeEmployeeTransaction
Rysunek 19.16. Dynamiczny model transakcji ChangeNameTransaction
Rysunek 19.17. Dynamiczny model transakcji ChangeAddressTransaction
Na listingu 19.19 pokazano przypadek testowy dla transakcji ChangeNameTransaction. Ten przypadek testowy jest bardzo prosty. Wykorzystuje transakcję AddHourlyEmployee w celu stworzenia pracownika o imieniu Bartosz wynagradzanego w systemie godzinowym. Następnie tworzy i wykonuje transakcję ChangeNameTransaction, która powinna zmienić imię tego pracownika na Bogdan. Na koniec pobiera egzemplarz klasy Employee z bazy danych PayrollDatabase i sprawdza, czy imię się zmieniło. Listing 19.19. PayrollTest::TestChangeNameTransaction() void PayrollTest::TestChangeNameTransaction() { cerr GetSchedule(); WeeklySchedule* ws = dynamic_cast(ps); assert(ws);
Na listingach 19.25 i 19.26 pokazano implementację abstrakcyjnej klasy bazowej ChangeClassifica tionTransaction. Ponownie użyto wzorca projektowego Metoda szablonowa. Metoda Change() wywołuje dwie czysto wirtualne funkcje: GetClassification i GetSchedule. Wykorzystuje wartości zwracane przez te funkcje w celu ustawienia sposobu wynagradzania oraz harmonogramu wypłat pracownika identyfikowanego przez obiekt Employee. Listing 19.25. ChangeClassificationTransaction.h #ifndef CHANGECLASSIFICATIONTRANSACTION_H #define CHANGECLASSIFICATIONTRANSACTION_H #include "ChangeEmployeeTransaction.h" class PaymentClassification; class PaymentSchedule; class ChangeClassificationTransaction : public ChangeEmployeeTransaction { public: virtual ~ChangeClassificationTransaction(); ChangeClassificationTransaction(int empid); virtual void Change(Employee&); virtual PaymentClassification* GetClassification() const = 0; virtual PaymentSchedule* GetSchedule() const = 0; }; #endif
Listing 19.26. ChangeClassificationTransaction.cpp #include "ChangeClassificationTransaction.h" ChangeClassificationTransaction::~ChangeClassificationTransaction() { } ChangeClassificationTransaction::ChangeClassificationTransaction(int empid) : ChangeEmployeeTransaction(empid) { } void ChangeClassificationTransaction::Change(Employee& e) { e.SetClassification(GetClassification()); e.SetSchedule(GetSchedule()); }
238
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Na listingach 19.27 i 19.28 pokazano implementację klasy ChangeHourlyTransaction. Ta klasa uzupełnia wzorzec projektowy Metoda szablonowa poprzez zaimplementowanie metod GetClassification() i GetSchedule() odziedziczonych z klasy ChangeClassificationTransaction. Implementuje metodę GetClassification(), która zwraca nowo utworzony obiekt HourlyClassification. Implementuje metodę GetSchedule(), która zwraca nowo utworzony obiekt WeeklySchedule. Listing 19.27. ChangeHourlyTransaction.h #ifndef CHANGEHOURLYTRANSACTION_H #define CHANGEHOURLYTRANSACTION_H #include "ChangeClassificationTransaction.h" class ChangeHourlyTransaction : public ChangeClassificationTransaction { public: virtual ~ChangeHourlyTransaction(); ChangeHourlyTransaction(int empid, double hourlyRate); virtual PaymentSchedule* GetSchedule() const; virtual PaymentClassification* GetClassification() const; private: double itsHourlyRate;
}; #endif
Listing 19.28. ChangeHourlyTransaction.cpp #include "ChangeHourlyTransaction.h" #include "WeeklySchedule.h" #include "HourlyClassification.h" ChangeHourlyTransaction::~ChangeHourlyTransaction() { } ChangeHourlyTransaction::ChangeHourlyTransaction(int empid, double hourlyRate) : ChangeClassificationTransaction(empid) , itsHourlyRate(hourlyRate) { } PaymentSchedule* ChangeHourlyTransaction::GetSchedule() const { return new WeeklySchedule(); } PaymentClassification* ChangeHourlyTransaction::GetClassification() const { return new HourlyClassification(itsHourlyRate); }
Zgodnie z tradycją implementację klas ChangeSalariedTransaction oraz ChangeCommissionedTransaction pozostawiam czytelnikom jako ćwiczenie do samodzielnego wykonania. Podobny mechanizm zastosowano w celu implementacji transakcji ChangeMethodTransaction. Wykorzystano abstrakcyjną metodę GetMethod w celu wybrania właściwej pochodnej klasy PaymentMethod. Tę pochodną przekazano następnie do obiektu Employee (patrz rysunki od 19.22 do 19.25).
ZMIANA DANYCH PRACOWNIKÓW
239
Rysunek 19.22. Dynamiczny model transakcji ChangeMethodTransaction
Rysunek 19.23. Dynamiczny model transakcji ChangeDirectTransaction
Rysunek 19.24. Dynamiczny model transakcji ChangeMailTransaction
Rysunek 19.25. Dynamiczny model transakcji ChangeHoldTransaction
Implementacja tych klas okazała się prosta. Zaimplementowanie ich również pozostawiam czytelnikom jako ćwiczenie do samodzielnego wykonania. Na rysunku 19.26 pokazano sposób implementacji transakcji ChangeAffiliationTransaction. Tak jak wcześniej zastosowano wzorzec projektowy Metoda szablonowa w celu wybrania pochodnej klasy Affiliation, którą należy przekazać do obiektu Employee (patrz rysunki od 19.27 do 19.29).
240
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Rysunek 19.26. Dynamiczny model transakcji ChangeAffiliationTransaction
Rysunek 19.27. Dynamiczny model transakcji ChangeMemberTransaction
Rysunek 19.28. Dynamiczny model transakcji ChangeUnaffiliatedTransaction
Co ja paliłem? Byłem bardzo zaskoczony, kiedy przystąpiłem do implementacji tego projektu. Przyjrzyjmy się bliżej dynamicznym modelom transakcji dotyczących przynależności do organizacji. Można zaobserwować tu pewien problem. Implementację rozpocząłem od napisania przypadku testowego ChangeMemberTransaction. Ten przypadek testowy zamieszczono na listingu 19.29. Przypadek testowy zaczyna się dość prosto. Tworzy pracownika „Bartosz” wynagradzanego w systemie godzinowym, a następnie wykonuje transakcję ChangeMemberTransaction w celu oznaczenia przynależności tego pracownika do związku zawodowego. Następnie sprawdza, czy istnieje obiekt UnionAffiliation związany z pracownikiem Bartosz oraz czy w obiekcie UnionAffiliation ustawiono prawidłowe wartości składek. Listing 19.29. PayrollTest::TestChangeMemberTransaction() void PayrollTest::TestChangeMemberTransaction() { cerr GetDues(), .001); Employee* member = GpayrollDatabase.GetUnionMember(memberId); assert(member); assert(e == member);
Niespodzianka kryje się w kilku ostatnich linijkach tego przypadku testowego. Ten kod zawiera sprawdzenie, czy w bazie PayrollDatabase zarejestrowano przynależność Bartosza do związku zawodowego. W diagramach UML nie ma żadnego elementu, który pozwalałby na uzyskanie takiego potwierdzenia. Diagramy koncentrują się tylko na tym, czy z obiektem Employee powiązano odpowiednią pochodną klasy Affiliation. W ogóle nie zauważyłem na nich żadnego braku. A czy czytelnicy coś zauważyli? Początkowo kodowałem transakcje ściśle według diagramów, a następnie zauważyłem, że test jednostkowy nie przechodzi. Kiedy wystąpił błąd, stało się oczywiste, co zaniedbałem. Czy jednak rozwiązanie tego problemu nie było oczywiste? W jaki sposób doprowadzić do sytuacji, w której przynależność do organizacji rejestruje transakcja ChangeMemberTransaction, natomiast usuwa ją transakcja ChangeUnaffiliatedTransaction? Rozwiązanie polega na dodaniu kolejnej, czysto wirtualnej funkcji do klasy ChangeAffiliationTrans action, o nazwie RecordMembership(Employee*). Funkcję tę zaimplementowano w klasie ChangeMember Transaction w celu powiązania identyfikatora memberId z egzemplarzem klasy Employee. W klasie ChangeUnaffiliatedTransaction metodę tę zaimplementowano w celu usunięcia rekordu opisującego przynależność do związku. Uzyskaną implementację abstrakcyjnej klasy bazowej ChangeClassificationTransaction pokazano na listingach 19.30 i 19.31. Tak jak poprzednio można wyraźnie zauważyć zastosowanie wzorca projektowego Metoda szablonowa. Listing 19.30. ChangeAffiliationTransaction.h #ifndef CHANGEAFFILIATIONTRANSACTION_H #define CHANGEAFFILIATIONTRANSACTION_H #include "ChangeEmployeeTransaction.h" class ChangeAffiliationTransaction: public ChangeEmployeeTransaction { public: virtual ~ChangeAffiliationTransaction(); ChangeAffiliationTransaction(int empid); virtual Affiliation* GetAffiliation() const = 0; virtual void RecordMembership(Employee*) = 0; virtual void Change(Employee&); }; #endif
Listing 19.31. ChangeAffiliationTransaction.cpp #include "ChangeAffiliationTransaction.h" ChangeAffiliationTransaction::~ChangeAffiliationTransaction() { } ChangeAffiliationTransaction::ChangeAffiliationTransaction(int empid)
242
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
: ChangeEmployeeTransaction(empid) { } void ChangeAffiliationTransaction::Change(Employee& e) { RecordMembership(&e); e.SetAffiliation(GetAffiliation()); }
Na listingach 19.32 i 19.33 zamieszczono implementację klasy ChangeMemberTransaction. Ta implementacja nie jest ani szczególnie skomplikowana, ani interesująca. Z drugiej strony, implementacja klasy ChangeUnaffiliatedTransaction zamieszczona na listingach 19.34 i 19.35 jest odrobinę bardziej znacząca. Funkcja RecordMembership musi zdecydować, czy bieżący pracownik jest członkiem związku zawodowego. Jeśli tak jest, to pobiera wartość identyfikatora memberId z obiektu UnionAffiliation i usuwa rekord opisujący przynależność do związku. Listing 19.32. ChangeMemberTransaction.h #ifndef CHANGEMEMBERTRANSACTION_H #define CHANGEMEMBERTRANSACTION_H #include "ChangeAffiliationTransaction.h" class ChangeMemberTransaction : public ChangeAffiliationTransaction { public: virtual ~ChangeMemberTransaction(); ChangeMemberTransaction(int empid, int memberid, double dues); virtual Affiliation* GetAffiliation() const; virtual void RecordMembership(Employee*); private: int itsMemberId; double itsDues; }; #endif
Listing 19.33. ChangeMemberTransaction.cpp #include "ChangeMemberTransaction.h" #include "UnionAffiliation.h" #include "PayrollDatabase.h" extern PayrollDatabase GpayrollDatabase; ChangeMemberTransaction::~ChangeMemberTransaction() { } ChangeMemberTransaction:: ChangeMemberTransaction(int empid, int memberid, double dues) : ChangeAffiliationTransaction(empid) , itsMemberId(memberid) , itsDues(dues) { } Affiliation* ChangeMemberTransaction::GetAffiliation() const { return new UnionAffiliation(itsMemberId, itsDues); } void ChangeMemberTransaction::RecordMembership(Employee* e) { GpayrollDatabase.AddUnionMember(itsMemberId, e); }
ZMIANA DANYCH PRACOWNIKÓW
243
Listing 19.34. ChangeUnaffiliatedTransaction.h #ifndef CHANGEUNAFFILIATEDTRANSACTION_H #define CHANGEUNAFFILIATEDTRANSACTION_H #include "ChangeAffiliationTransaction.h" class ChangeUnaffiliatedTransaction : public ChangeAffiliationTransaction { public: virtual ~ChangeUnaffiliatedTransaction(); ChangeUnaffiliatedTransaction(int empId); virtual Affiliation* GetAffiliation() const; virtual void RecordMembership(Employee*); }; #endif
Listing 19.35. ChangeUnaffiliatedTransaction.cpp #include #include #include #include
"ChangeUnaffiliatedTransaction.h" "NoAffiliation.h" "UnionAffiliation.h" "PayrollDatabase.h"
extern PayrollDatabase GpayrollDatabase; ChangeUnaffiliatedTransaction::~ChangeUnaffiliatedTransaction() { } ChangeUnaffiliatedTransaction::ChangeUnaffiliatedTransaction(int empId) : ChangeAffiliationTransaction(empId) { } Affiliation* ChangeUnaffiliatedTransaction::GetAffiliation() const { return new NoAffiliation(); } void ChangeUnaffiliatedTransaction::RecordMembership(Employee* e) { Affiliation* af = e->GetAffiliation(); if (UnionAffiliation* uf = dynamic_cast(af)) { int memberId = uf->GetMemberId(); GpayrollDatabase.RemoveUnionMember(memberId); } }
Nie mogę powiedzieć, że jestem bardzo zadowolony z tego projektu. Martwi mnie to, że obiekt ChangeUnaffiliatedTransaction zależy od obiektu UnionAffiliation. Mógłbym rozwiązać ten problem, definiując abstrakcyjne metody RecordMembership i EraseMembership w klasie Affiliation. To jednak wymusiłoby zależność klas UnionAffiliation i NoAffiliation od klasy PayrollDatabase. Takie
rozwiązanie także mnie nie satysfakcjonuje2. Pomimo tych wad implementacja w zaprezentowanej formie jest dość prosta i tylko nieznacznie narusza zasadę OCP. Pozytywnym objawem jest to, że bardzo niewiele modułów w systemie zależy od klasy ChangeUnaffiliatedTransaction, zatem jej dodatkowe zależności nie sprawiają zbyt wielkiego problemu.
2
Do rozwiązania tego problemu mógłbym użyć wzorca projektowego Wizytator (rozdział 28.), ale to byłoby zbyt skomplikowane rozwiązanie.
244
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Realizacja wypłat Na koniec nadszedł czas, aby przeanalizować transakcje dotyczące podstawowej funkcjonalności tej aplikacji: transakcji, która instruuje system o realizacji wypłaty dla określonych pracowników. Na rysunku 19.29 zamieszczono statyczną strukturę klasy PaydayTransaction. Rysunki od 19.30 do 19.33 opisują dynamiczne zachowanie.
Rysunek 19.29. Statyczny model transakcji PaydayTransaction
Rysunek 19.30. Dynamiczny model transakcji PaydayTransaction
REALIZACJA WYPŁAT
245
Rysunek 19.31. Dynamiczny model scenariusza „dziś nie ma wypłaty”
Rysunek 19.32. Dynamiczny model scenariusza „dziś jest wypłata”
Rysunek 19.33. Dynamiczny model scenariusza „księgowanie wypłaty”
Te dynamiczne modele wyrażają wiele zachowań polimorficznych. Algorytm wykorzystywany przez komunikat CalculatePay zależy od rodzaju obiektu PaymentClassification powiązanego z obiektem Employee. Algorytm wykorzystywany do określenia tego, czy dzień jest dniem wypłaty, zależy od rodzaju obiektu PaymentSchedule powiązanego z obiektem Employee. Algorytm wykorzystany w celu wysłania wypłaty do obiektu Employee zależy od typu obiektu PaymentMethod. Taki wysoki stopień abstrakcji pozwala na zamknięcie algorytmów dla operacji dodawania nowych rodzajów zatrudnienia, harmonogramów, przynależności do organizacji lub sposobów wypłaty. Algorytmy przedstawione na rysunkach 19.32 i 19.33 wprowadzają pojęcie księgowania wypłaty. Po obliczeniu odpowiedniej kwoty wypłaty i przesłaniu jej do obiektu Employee następuje księgowanie wypłaty — tzn. aktualizacja rekordów reprezentujących wypłaty. Możemy zatem zdefiniować metodę CalculatePay jako mechanizm obliczania wypłaty od ostatniego księgowania do wskazanej daty.
246
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Czy chcemy, aby deweloperzy podejmowali decyzje biznesowe? Skąd wzięło się pojęcie księgowania? Z pewnością nie wspominano o nim w historyjkach użytkowników lub przypadkach użycia. Pojęcie to wprowadziłem jako sposób na rozwiązanie problemu, który wcześniej spostrzegłem. Obawiałem się, że metoda Payday może być wywołana wiele razy z tą samą datą albo z datą w tym samym okresie rozliczeniowym. W związku z tym chciałem uzyskać pewność, że żaden pracownik nie otrzyma wynagrodzenia więcej niż raz. Zrobiłem to z własnej inicjatywy, nie pytając o zdanie mojego klienta. To po prostu wydawało się słuszne. W efekcie podjąłem decyzję biznesową. Zdecydowałem, że w wyniku wielu uruchomień programu płacowego powinniśmy uzyskać różne wyniki. Powinienem zapytać mojego klienta lub menedżera projektu o zdanie na ten temat, ponieważ mogli oni mieć bardzo różne pomysły. Z konsultacji z klientem wynika, że pomysł księgowania wypłat nie jest zgodny z jego intencją3. Klient chciałby uruchamiać system płacowy wielokrotnie i weryfikować generowane płace. Jeśli któraś z nich będzie naliczona niepoprawnie, klient chce poprawić odpowiednie informacje i ponownie uruchomić program płacowy. Klient udzielił mi wskazówki, abym nigdy nie uwzględniał kart czasu pracy lub raportów sprzedaży dotyczących dat spoza bieżącego okresu rozliczeniowego. Tak więc pomysł księgowania musi być odrzucony. W pewnym momencie wydawało się, że będzie to dobry pomysł, ale to nie jest to, czego klient oczekuje.
Realizacja wypłat dla pracowników ze stałą pensją Na listingu 19.36 zamieszczono dwa przypadki testowe. Służą one do sprawdzenia, czy pracownik otrzymuje wynagrodzenie we właściwy sposób. Pierwszy przypadek testowy sprawdza, czy pracownik otrzymuje wypłatę ostatniego dnia miesiąca. Drugi test sprawdza, czy pracownik nie otrzymuje wypłaty w innym dniu niż ostatni dzień miesiąca. Listing 19.36. PayrollTest::TestPaySingleSalariedEmployee void PayrollTest::TestPaySingleSalariedEmployee() { cerr GetGrossPay(), .001); assert("Hold" == pc->GetField("Disposition")); assertEquals(0.0, pc->GetDeductions(), .001); assertEquals(1000.00, pc->GetNetPay(), .001); } void PayrollTest::TestPaySingleSalariedEmployeeOnWrongDate() { cerr Payday(*pc); } } }
Na listingu 19.38 pokazano fragment klasy MonthlySchedule.cpp. Zwróćmy uwagę, że metodę IsPayDate zaimplementowano tak, żeby zwracała true tylko wtedy, kiedy argument date przypada ostatniego dnia miesiąca. Ten algorytm wyraźnie pokazuje, dlaczego potrzebowałem klasy Date. Wykonanie tego rodzaju prostych obliczeń na datach jest bardzo trudne bez dobrej klasy Date. Listing 19.38. MonthlySchedule.cpp (fragment) namespace { bool IsLastDayOfMonth(const Date& date) { int m1 = date.GetMonth(); int m2 = (date+1).GetMonth(); return (m1 != m2); } } bool MonthlySchedule::IsPayDate(const Date& payDate) const { return IsLastDayOfMonth(payDate); } 4
Oryginalny oma.com. To była stacja SPARC kupiona przeze mnie za 6000 dolarów od firmy, która nabyła ją do realizacji projektu, a następnie z niego zrezygnowała. W 1994 roku to był naprawdę dobry interes. Fakt, że maszyna nadal sobie cicho pracuje w sieci firmy Object Mentor, jest świadectwem tego, jak dobry to sprzęt.
248
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Na listingu 19.39 zamieszczono implementację metody Employee::PayDay(). Funkcja ta zawiera implementację ogólnego algorytmu obliczania wynagrodzeń dla wszystkich pracowników. Zwróć uwagę na szczególny sposób wykorzystania wzorca projektowego Strategia. Wszystkie szczegółowe obliczenia przeniesiono do klas strategii: itsClassification, itsAffiliation oraz itsPaymentMethod. Listing 19.39. Employee::PayDay() void Employee::Payday(Paycheck& pc) { double grossPay = itsClassification->CalculatePay(pc); double deductions = itsAffiliation->CalculateDeductions(pc); double netPay = grossPay - deductions; pc.SetGrossPay(grossPay); pc.SetDeductions(deductions); pc.SetNetPay(netPay); itsPaymentMethod->Pay(pc); }
Realizacja wypłat dla pracowników zatrudnionych w systemie godzinowym Realizacja wypłat dla pracowników zatrudnionych w systemie godzinowym jest dobrym przykładem przyrostowego charakteru projektowania zgodnego z metodologią „najpierw test”. Zaczynamy od bardzo prostych przypadków testowych, a następnie przechodzimy do bardziej złożonych. Poniżej zaprezentuję przypadki testowe, a następnie pokażę kod produkcyjny, który powstał na ich podstawie. Najprostszy przypadek testowy znajduje się na listingu 19.40. Dodajemy pracownika wynagradzanego w systemie godzinowym, a następnie realizujemy dla niego wypłatę. Ponieważ nie zarejestrowano żadnych kart czasu pracy, oczekujemy, że na czeku będzie zerowa wartość. Funkcja narzędziowa ValidateHourlyPaycheck reprezentuje kod po przeprowadzonej później refaktoryzacji. Początkowo ten kod był umieszczony wewnątrz funkcji testowej. Ten przypadek testowy przechodzi bez wprowadzania żadnych zmian w pozostałej części kodu. Listing 19.40. TestPaySingleHourlyEmployeeNoTimeCards void PayrollTest::TestPaySingleHourlyEmployeeNoTimeCards() { cerr GetGrossPay(), .001); assert("Hold" == pc->GetField("Disposition")); assertEquals(0.0, pc->GetDeductions(), .001); assertEquals(pay, pc->GetNetPay(), .001); }
Na listingu 19.41 pokazano dwa przypadki testowe. Pierwszy test pokazuje, czy można zapłacić pracownikowi po dodaniu jednej karty czasu pracy. Drugi test sprawdza, czy można zapłacić za nadgodziny w przypadku karty, na której zarejestrowano więcej niż 8 godzin. Oczywiście nie napisałem tych dwóch przypadków testowych w tym samym czasie. Napisałem pierwszy i doprowadziłem do tego, że zaczął przechodzić, a potem napisałem drugi.
REALIZACJA WYPŁAT
249
Listing 19.41. TestPaySingleHourlyEmployeeOneTimeCard void PayrollTest::TestPaySingleHourlyEmployeeOneTimeCard() { cerr GetPayPeriodStartDate(payPeriodEndDate); } void Employee::Payday(Paycheck& pc) { Date payDate = pc.GetPayPeriodEndDate(); double grossPay = itsClassification->CalculatePay(pc); double deductions = itsAffiliation->CalculateDeductions(pc); double netPay = grossPay - deductions; pc.SetGrossPay(grossPay); pc.SetDeductions(deductions); pc.SetNetPay(netPay); itsPaymentMethod->Pay(pc); }
BAZA DANYCH
257
Program główny Program główny systemu płacowego możemy teraz wyrazić w postaci pętli, która analizuje transakcje pochodzące z określonego źródła, a następnie je wykonuje. Statyczny i dynamiczny model programu głównego pokazano na rysunkach 19.34 i 19.35. Idea jest prosta. Klasa PayrollApplication przetwarza zadania w pętli — naprzemiennie żąda transakcji z obiektu TransactionSource oraz żąda od uzyskanych obiektów Transaction wykonania metody Execute. Zwróćmy uwagę na różnice w porównaniu z diagramem z rysunku 19.1. Różnice te prezentują zmianę naszego podejścia w kierunku bardziej abstrakcyjnego mechanizmu.
Rysunek 19.34. Statyczny model programu głównego
Rysunek 19.35. Dynamiczny model programu głównego TransactionSource jest klasą abstrakcyjną, którą można zaimplementować na kilka sposobów. Na statycznym diagramie można zobaczyć klasę pochodną o nazwie TextParserTransactionSource. Jej zadanie polega na odczytaniu wejściowego strumienia tekstowego i analizowaniu transakcji tak, jak opisano w przypadkach użycia. Ten obiekt następnie tworzy właściwe obiekty Transaction i przesyła je do obiektu PayrollApplication. Dzięki oddzieleniu interfejsu od implementacji w klasie TransactionSource źródło transakcji mogło pozostać abstrakcyjne. Na przykład z łatwością można wykorzystać interfejs, aby aplikacja PayrollAppli cation mogła pracować z klasami GUITransactionSource lub RemoteTransactionSource.
Baza danych Teraz gdy dokonaliśmy analizy aplikacji, zaprojektowaliśmy i (w większości) zaimplementowaliśmy wszystkie zadania zaplanowane do zrealizowania w tej iteracji, możemy rozważyć rolę bazy danych. Klasa PayrollDatabase z pewnością hermetyzuje pewien rodzaj utrwalania danych. Obiekty zawarte w obiekcie PayrollDatabase muszą istnieć dłużej, niż trwa konkretne wykonanie aplikacji. W jaki sposób można to zaimplementować? Z pewnością tymczasowy mechanizm stosowany w przypadkach użycia nie jest wystarczający dla rzeczywistego systemu. Mamy do dyspozycji kilka możliwości.
258
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Możemy zaimplementować klasę PayrollDatabase z wykorzystaniem obiektowego systemu zarządzania bazą danych (OODBMS). Takie rozwiązanie pozwoliłoby na rezydowanie rzeczywistych obiektów w trwałej pamięci bazy danych. Dla projektantów takie rozwiązanie wiązałoby się z niewielką ilością dodatkowej pracy, ponieważ zastosowanie systemu OODBMS nie wprowadziłoby zbyt wielu nowych elementów do projektu. Jedną z największych zalet systemów OODBMS jest to, że mają one niewielki wpływ na model obiektowy aplikacji lub wcale nie wywierają wpływu na ten model. Z punktu widzenia projektu baza danych właściwie nie istnieje6. Alternatywnym rozwiązaniem mogłoby być skorzystanie z prostego pliku tekstowego do rejestrowania danych. Po zainicjowaniu obiekt PayrollDatabase mógłby odczytać zawartość tego pliku i stworzyć właściwe obiekty w pamięci. Na koniec działania programu obiekt PayrollDatabase mógłby zapisać nową wersję pliku tekstowego. Oczywiście ta opcja nie byłaby wystarczająca dla firmy zatrudniającej setki tysięcy pracowników lub dla takiej, która chciałaby mieć współbieżny dostęp w czasie rzeczywistym do swojej bazy danych płacowych. Jednak takie rozwiązanie mogłoby wystarczyć w przypadku mniejszej firmy i z całą pewnością mogłoby być zastosowane jako mechanizm testowania pozostałych klas aplikacji bez inwestowania w wielki system bazy danych. Jeszcze inną opcją mogłoby być wykorzystanie do implementacji obiektu PayrollDatabase relacyjnego systemu zarządzania bazą danych (RDBMS). W implementacji obiektu PayrollDatabase można by wtedy wykonać odpowiednie zapytania do systemu RDMBS w celu tymczasowego stworzenia potrzebnych obiektów w pamięci. Chodzi o to, że z punktu widzenia aplikacji bazy danych są po prostu mechanizmem zarządzania pamięcią trwałą. Zazwyczaj nie powinny być one rozpatrywane jako główny czynnik projektu i implementacji. Jak pokazaliśmy w tym przykładzie, decyzję o zastosowaniu konkretnego systemu bazy danych można zostawić na koniec i potraktować jako szczegół implementacji7. W ten sposób możemy odłożyć podjęcie decyzji o zastosowaniu konkretnej implementacji mechanizmów utrwalania. Nie musimy też od razu decydować się na stosowanie konkretnych mechanizmów do testowania pozostałej części aplikacji. Nie wiążemy się również z żadną konkretną technologią czy produktem bazodanowym. Mamy swobodę wyboru potrzebnej bazy danych na podstawie pozostałej części projektu oraz w razie potrzeby zachowujemy swobodę zmiany lub zastąpienia określonego produktu bazy danych w przyszłości.
Podsumowanie projektu systemu płacowego Za pomocą około 50 diagramów i 3300 wierszy kodu pokazaliśmy projekt i implementację jednej iteracji aplikacji płacowej. W projekcie wykorzystywaliśmy wiele abstrakcji i często stosowaliśmy polimorfizm. W efekcie duża część tego projektu jest zamknięta dla zmian w polityce wypłacania uposażeń. Na przykład aplikację można zmodyfikować tak, by obsługiwała pracowników otrzymujących wynagrodzenie co kwartał. Wystarczyłoby wykorzystać mechanizmy wynagrodzeń dla pracowników otrzymujących pensję i dodać nowy harmonogram. Taka zmiana wymagałaby dodania pewnych elementów do projektu, ale niewielka część istniejącego projektu i kodu uległaby zmianom.
6
To podejście jest dość optymistyczne. W prostej aplikacji, takiej jak przykładowy system płacowy, zastosowanie systemu OODBMS miałoby bardzo niewielki wpływ na projekt programu. W miarę jak aplikacja staje się coraz bardziej skomplikowana, wpływ zastosowania systemu OODBMS na projekt programu wzrasta. Pomimo wszystko wpływ systemu OODBMS na projekt programu jest znacznie mniejszy niż wpływ zastosowania systemu RDBMS.
7
Czasami charakter bazy danych jest jednym z wymagań stawianych aplikacji. Systemy RDBMS umożliwiają tworzenie złożonych zapytań i raportów, które mogą być wymienione jako wymagania aplikacji. Jednak nawet wtedy, gdy takie wymagania są formułowane jawnie, projektanci powinni oddzielić projekt aplikacji od projektu baz danych. Projekt aplikacji nie powinien zależeć od żadnego konkretnego rodzaju bazy danych.
BIBLIOGRAFIA
259
Podczas tego procesu rzadko zastanawialiśmy się nad tym, czy przeprowadzamy analizę, wykonujemy projekt, czy implementację. Zamiast tego koncentrowaliśmy się na kwestiach przejrzystości i kompletności. W miarę możliwości staraliśmy się wyszukiwać potrzebne abstrakcje. W efekcie udało się opracować dobry wyjściowy projekt aplikacji płacowej oraz rdzeń klas, które mogą być użyte do rozwiązywania podobnych problemów.
Historia Diagramy zamieszczone w tym rozdziale zostały stworzone na podstawie diagramów Boocha z odpowiedniego rozdziału książki z 1995 roku zatytułowanej Designing Object-Oriented C++ Applications using the Booch Method. Diagramy te stworzyłem w 1994 roku. Podczas ich tworzenia napisałem też część kodu, który je implementuje, aby uzyskać pewność, że diagramy mają sens. Jednak wtedy nie napisałem tyle kodu co teraz. Oznacza to, że tamte diagramy nie były weryfikowane za pomocą kodu i testów. Ten brak weryfikacji był bardzo widoczny. Kod prezentowany w niniejszym rozdziale pisałem w takiej kolejności, w jakiej został zaprezentowany. Za każdym razem przypadki testowe były pisane przed kodem produkcyjnym. W wielu sytuacjach testy były tworzone przyrostowo — ewoluowały w miarę ewolucji kodu produkcyjnego. Kod produkcyjny był pisany w zgodzie ze stworzonymi diagramami, o ile miało to sens. W kilku przypadkach nie miało to sensu, dlatego zmieniłem projekt kodu. Jednym z pierwszych miejsc, gdzie tak się stało, była sytuacja opisana w podrozdziale „Karty pracy, raporty sprzedaży i składki”, kiedy postanowiłem zrezygnować z wielu egzemplarzy obiektów Affiliation wewnątrz obiektu Employee. Inna zmiana nastąpiła w podrozdziale „Zmiana klasyfikacji”, kiedy zdałem sobie sprawę, że nie uwzględniłem rejestrowania przynależności pracownika do związku zawodowego w transakcji ChangeMemberTransaction. Takie zmiany są normalne. Kiedy projektujemy aplikację bez weryfikacji w kodzie źródłowym i testach często popełniamy błędy. Błędy te wykryliśmy w czasie prac nad przypadkami testowymi i kodem źródłowym.
Zasoby Finalną wersję kodu prezentowanego w tym rozdziale można znaleźć na stronie internetowej wydawnictwa Prentice Hall albo pod adresem www.objectmentor.com/PPP.
Bibliografia 1. Ivar Jacobson, Object-Oriented Software Engineering, A Use-Case-Driven Approach, Wokingham, Wielka Brytania: Addison-Wesley, 1992.
260
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
CZĘŚĆ IV Podział systemu płacowego na pakiety
W tej części omówimy zasady projektowania, które pomogą nam podzielić duży system oprogramowania na pakiety. W pierwszym rozdziale tej części omówiono te zasady, w drugim opisano wzorzec, którego będziemy używać, aby poprawić strukturę pakietów, natomiast w trzecim pokażemy sposób zastosowania zasad i wzorca do systemu płacowego.
262
ROZDZIAŁ 20. ZASADY PROJEKTOWANIA PAKIETÓW
PROJEKTOWANIE Z WYKORZYSTANIEM PAKIETÓW?
263
R OZDZIAŁ 20
Zasady projektowania pakietów
Fajny pakiet — Anthony
W miarę wzrostu rozmiarów i złożoności aplikacji potrzebne są mechanizmy wysokopoziomowej organizacji. Chociaż klasy są bardzo wygodną jednostką organizacji niewielkich aplikacji, są one zbyt szczegółowe do tego, aby były stosowane jako jedyna jednostka organizacyjna dla dużych aplikacji. Czasami do zorganizowania większych aplikacji potrzebne są „większe” jednostki od klas. Te większe jednostki nazywane są pakietami. W niniejszym rozdziale zaprezentowano sześć zasad. Pierwsze trzy dotyczą spójności pakietu. Pozwalają one przydzielić klasy do pakietów. Kolejne trzy dotyczą sprzężeń pomiędzy pakietami. Pozwalają one określić sposób wzajemnych zależności pomiędzy pakietami. Ostatnie dwie zasady opisują zbiór metryk zarządzania zależnościami (ang. Dependency Management — DM), które pozwala programistom mierzyć i charakteryzować strukturę zależność swoich projektów.
Projektowanie z wykorzystaniem pakietów? W notacji UML pakiety mogą być wykorzystane jako kontenery dla grup klas. Dzięki pogrupowaniu klas w pakiety możemy wyrazić projekt na wyższym poziomie abstrakcji. Pakiety możemy również wykorzystać do zarządzania rozwojem i dystrybucją oprogramowania. Celem jest podzielenie klas w aplikacji na części według pewnych kryteriów, a następnie przydzielenie klas w ramach tych części do pakietów.
264
ROZDZIAŁ 20. ZASADY PROJEKTOWANIA PAKIETÓW
Ale klasy często zależą od innych klas, a te zależności bardzo często wykraczają poza granice pakietu. W związku z tym pakiety będą wchodziły w relacje zależności pomiędzy sobą. Relacje pomiędzy pakietami wyrażają wysokopoziomową organizację aplikacji i muszą być zarządzane. W związku z tym nasuwa się wiele pytań: 1. Jakie zasady rządzą przydziałem klas do pakietów? 2. Jakie zasady projektowe dotyczą relacji pomiędzy pakietami? 3. Czy pakiety należy projektować przed klasami (według reguły góra-dół)? Czy raczej to klasy należy projektować przed pakietami (według reguły dół-góra)? 4. W jaki sposób są fizycznie reprezentowane pakiety? W języku C++? W Javie? W środowisku programistycznym? 5. Jaki cel spełniają pakiety po utworzeniu? W niniejszym rozdziale przedstawiono sześć zasad projektowych, które regulują tworzenie, wzajemne relacje oraz wykorzystanie pakietów. Pierwsze trzy zasady dotyczą podziału klas na pakiety. Ostatnie trzy opisują wzajemne relacje pomiędzy pakietami.
Ziarnistość: zasady spójności pakietów Trzy zasady spójności pakietów ułatwiają deweloperom podjęcie decyzji dotyczącej sposobu podziału klas na pakiety. Zależą one od wykrycia co najmniej niektórych klas oraz wzajemnych relacji, które je łączą. W związku z tym te zasady opisują podział klas typu „dół-góra”.
Zasada równoważności wielokrotnego wykorzystania kodu i dystrybucji (REP) Ziarnistość wielokrotnego wykorzystywania kodu jest równoważna ziarnistości wydań wersji dystrybucyjnych. Czego oczekujemy od autora biblioteki klas, które planujemy wykorzystać wielokrotnie? Oczywiście chcielibyśmy dobrej dokumentacji, działającego kodu, dobrze określonych interfejsów itp. Istnieją jednak inne rzeczy, których oczekujemy. Po pierwsze: aby warto było poświęcić czas na skorzystanie z kodu napisanego przez inną osobę, chcielibyśmy, aby autor zagwarantował utrzymanie kodu. W końcu jeśli sami mielibyśmy utrzymywać kod, musielibyśmy zainwestować w to ogromną ilość czasu. Ten czas lepiej poświęcić na samodzielne zaprojektowanie mniejszych i lepszych pakietów. Po drugie: chcielibyśmy, aby autor kodu powiadomił nas z wyprzedzeniem o wszelkich planowanych zmianach w interfejsie i funkcjonalności kodu. Ale samo poinformowanie nie wystarcza. Autor musi dać nam możliwość zrezygnowania z używania nowych wersji. Może się przecież zdarzyć, że nowa wersja zostanie wprowadzona w czasie, gdy mamy bardzo napięty harmonogram. Wprowadzone zmiany mogą również być niezgodne z naszym kodem. W obu przypadkach należy podjąć decyzję o odrzuceniu nowej wersji. W takiej sytuacji autor powinien zagwarantować wsparcie dla korzystania ze starej wersji. Równie dobrze może to być czas trzech miesięcy, jak i roku. Powinno to być przedmiotem negocjacji pomiędzy nami a autorem biblioteki. Ale nie może się zdarzyć, aby autor po prostu odmówił nam wsparcia. Jeśli autor nie zgodzi się na wsparcie dla korzystania ze starszych wersji biblioteki, to powinniśmy poważnie zastanowić się nad sensem dalszego korzystania z jego kodu i polegania na jego kaprysach. Problem jest kwestią przyjętej polityki. Ma związek ze wsparciem menedżerskim, które powinno być udzielone, jeśli inne osoby chcą korzystać z naszego kodu. Ale te kwestie polityczne i menedżerskie mają głęboki wpływ na strukturę pakietów tworzonego oprogramowania. W celu zagwarantowania spełnienia wymagań użytkowników korzystających z kodu autorzy muszą zorganizować swoje oprogramowanie w pakiety wielokrotnego użytku, a następnie śledzić numery wersji dystrybucyjnych tych pakietów.
ZIARNISTOŚĆ: ZASADY SPÓJNOŚCI PAKIETÓW
265
Zasada REP mówi, że ziarnistość wielokrotnego wykorzystywania kodu (tzn. pakiet) nie może być mniejsza od ziarnistości wydań wersji dystrybucyjnych. Każde oprogramowanie, które jest wykorzystywane wielokrotnie, musi być również wydawane, a wersjom dystrybucyjnym powinny być nadawane odrębne numery. Wymaganie, aby deweloper napisał klasę, a następnie stwierdził, że spełnia ona warunki komponentu wielokrotnego użytku, jest po prostu nierealistyczne. Możliwość wielokrotnego wykorzystywania powstaje tylko wówczas, gdy istnieje system kontroli wersji, który gwarantuje informowanie, bezpieczeństwo i wsparcie potrzebne potencjalnym użytkownikom kodu. Zasada REP daje nam pierwszą wskazówkę dotyczącą sposobu podziału projektu na pakiety. Ponieważ zdolność wielokrotnego wykorzystywania kodu musi bazować na pakietach, pakiety wielokrotnego użytku muszą składać się z klas wielokrotnego użytku. Zatem przynajmniej niektóre pakiety powinny zawierać zestawy klas wielokrotnego użytku. Wpływ czynników politycznych na sposób podziału oprogramowania może wydawać się niepokojący, ale oprogramowanie nie jest matematycznie czystą jednostką, która może być zorganizowana według czysto matematycznych zasad. Oprogramowanie jest produktem człowieka, który powinien wspierać wysiłki podejmowane przez ludzi. Programy są tworzone i wykorzystywane przez ludzi. Jeśli oprogramowanie ma być wykorzystywane wielokrotnie, to musi być podzielone w sposób, który zagwarantuje użytkownikom wygodę przy realizacji ich zadań. Co to znaczy dla wewnętrznej struktury pakietu? Wewnętrzną zawartość pakietu należy rozpatrywać z punktu widzenia potencjalnych użytkowników kodu. Jeśli pakiet zawiera oprogramowanie, które powinno być wykorzystywane wielokrotnie, to nie powinien jednocześnie zawierać oprogramowania, które nie jest przeznaczone do ponownego użycia. Albo wszystkie klasy w pakiecie są wielokrotnego użytku, albo żadna. Możliwość wielokrotnego wykorzystywania kodu nie jest jedynym kryterium — trzeba także wziąć pod uwagę, kim są użytkownicy kodu. Oczywiście możliwość wielokrotnego wykorzystywania powinna oferować zarówno biblioteka klas kontenerów, jak i framework finansowy. Nie chcemy jednak, aby należały one do tego samego pakietu. Jest wiele osób, które chciałyby skorzystać z klasy kontenerów, a które w ogóle nie są zainteresowane frameworkiem finansowym. W związku z tym chcemy, aby wszystkie klasy w pakiecie były przeznaczone do ponownego wykorzystania przez tę samą grupę odbiorców. Nie chcemy, aby użytkownicy komponentu znaleźli w pakiecie zarówno kilka klas, które są im potrzebne, jak i takie, które zupełnie im się nie przydadzą.
Zasada zbiorowego wielokrotnego użytku (CRP) Klasy należące do pakietu są wykorzystywane wielokrotnie razem. Jeśli korzystamy z jednej klasy z pakietu, to korzystamy ze wszystkich klas należących do tego pakietu. Ta zasada pomaga nam zdecydować o tym, które klasy powinny być umieszczone w pakiecie. Mówi ona, że klasy, które mają być wspólnie wielokrotnie wykorzystywane, powinny należeć do tego samego pakietu. Klasy rzadko są używane wielokrotnie w izolacji. Zazwyczaj klasy wielokrotnego użytku współpracują z innymi klasami, które są częścią abstrakcji wielokrotnego użytku. Zasada CRP mówi, że takie klasy należą do tego samego pakietu. W takim pakiecie klasy mają ze sobą wiele zależności. Prostym przykładem może być klasa kontenerów oraz powiązane z nią iteratory. Klasy te są wykorzystywane wielokrotnie łącznie, ponieważ są ściśle sprzężone ze sobą. Z tego powodu powinny one znaleźć się w tym samym pakiecie. Ale zasada CRP mówi nam nie tylko o tym, które klasy umieścić razem w pakiecie. Mówi również o tym, których klas nie należy umieszczać w pakiecie. Gdy jeden pakiet korzysta z innego, pomiędzy takimi pakietami powstaje zależność. Może się zdarzyć, że pakiet korzystający używa tylko jednej klasy z pakietu. Ten fakt w żaden sposób nie osłabia jednak zależności pomiędzy klasami. Pakiet korzystający w dalszym ciągu zależy od pakietu wykorzystywanego. Za każdym razem, gdy następuje dystrybucja wykorzystywanego pakietu, pakiet korzystający musi być przetestowany i ponownie wydany. Dotyczy
266
ROZDZIAŁ 20. ZASADY PROJEKTOWANIA PAKIETÓW
to również sytuacji, w której używany pakiet został wydany z powodu zmian w klasie, której pakiet wykorzystujący nie używa. Co więcej, zazwyczaj pakiety mają fizyczne reprezentacje w postaci współdzielonych bibliotek, bibliotek DLL czy też archiwów JAR. Jeśli wykorzystywany pakiet jest wydawany w postaci archiwum JAR, to kod korzystający zależy od całego archiwum JAR. Wszelkie zmiany w tym archiwum JAR — nawet zmiany wprowadzane w klasie, której kod korzystający nie używa — będą powodowały konieczność wydania nowej wersji archiwum JAR. Nowy JAR będzie musiał być ponownie wydany, a kod korzystający będzie musiał być przetestowany na nowo. Z tego względu należy pamiętać o tym, że kiedy kod zależy od pakietu, to zależy od wszystkich klas, które należą do tego pakietu. Mówiąc inaczej, należy zadbać o to, aby klasy umieszczone w określonym pakiecie były nierozłączne — tzn. aby nie była możliwa sytuacja, w której kod zależy od jednej klasy, a nie zależy od innych klas. W przeciwnym razie będziemy zmuszeni do przeprowadzania testów i dystrybucji częściej, niż jest to konieczne, co oznacza marnotrawstwo czasu i wysiłków. Z tego powodu zasada CRP mówi nam więcej o tym, kiedy klas nie należy łączyć ze sobą w pakiet, niż o tym, kiedy należy je łączyć. Zasada CRP mówi, że klasy, które nie są ze sobą ściśle związane, nie powinny znaleźć się w tym samym pakiecie.
Zasada zbiorowego zamykania (CCP) Klasy należące do pakietu powinny być wspólnie zamknięte dla tego samego rodzaju zmian. Zmiana, która dotyczy określonego pakietu, dotyczy wszystkich klas w tym pakiecie i nie ma wpływu na żadne inne klasy. Zasada CCP (ang. Common-Closure Principle) jest odpowiednikiem zasady pojedynczej odpowiedzialności w odniesieniu do pakietów. Podobnie jak zasada SRP mówi, że klasa nie powinna być zmieniana z wielu powodów, tak zasada CCP mówi, że pakiet nie powinien być zmieniany z wielu powodów. W większości aplikacji możliwości utrzymania aplikacji są ważniejsze od możliwości wielokrotnego wykorzystywania. Jeśli kod w aplikacji musi się zmienić, to wolelibyśmy, aby zmiany dotyczyły jednego pakietu, a nie wielu. Jeśli zmiany są skoncentrowane w jednym pakiecie, to występuje potrzeba dystrybucji tylko jednego pakietu — tego, który został zmodyfikowany. Tych pakietów, które nie zależą od zmodyfikowanego pakietu, nie trzeba ponownie testować ani wydawać. Zasada CCP mówi nam, aby umieszczać w jednym pakiecie wszystkie klasy, które mogą się zmieniać z tych samych powodów. Jeśli dwie klasy są ze sobą tak ściśle sprzężone — fizycznie lub pojęciowo — że zawsze zmieniają się razem, to powinny one należeć do tego samego pakietu. To minimalizuje nakłady pracy związane z wydawaniem, testowaniem i dystrybucją oprogramowania. Zasada CCP ściśle wiąże się z zasadą otwarte-zamknięte (OCP). Zasada CCP dotyczy „zamykania” w sensie zasady OCP. Zgodnie z zasadą otwarte-zamknięte klasy powinny być zamknięte dla modyfikacji, ale otwarte dla rozszerzeń. Jednak jak się przekonaliśmy, stuprocentowe domknięcie jest nieosiągalne. Domknięcia muszą być wynikiem strategicznych decyzji. Systemy projektujemy w taki sposób, aby były zamknięte na większość rodzajów zmian, których doświadczyliśmy. Zasada CCP wzmacnia ten schemat poprzez pogrupowanie w jednym pakiecie klas, które są otwarte na specyficzne rodzaje zmian. Kiedy wystąpi potrzeba wprowadzenia zmian, to dzięki zastosowaniu takiego podejścia będzie szansa, że zakres tych zmian będzie ograniczony do minimalnej liczby pakietów.
Podsumowanie tematyki spójności pakietów W przeszłości sposób postrzegania spójności był znacznie prostszy w porównaniu z tym, który wynika z ostatnich trzech zasad. Wcześniej uważano, że spójność jest po prostu cechą modułu pozwalającą na wykonywanie jednej i tylko jednej funkcji. Jednak przedstawione trzy zasady spójności pakietów opisują bogatszy rodzaj spójności. Wybierając klasy, które mają być pogrupowane w pakiety, należy wziąć pod uwagę przeciwstawne siły dotyczące możliwości wielokrotnego wykorzystywania i rozwoju oprogramowania.
STABILNOŚĆ: ZASADY SPRZĘGANIA PAKIETÓW
267
Zrównoważenie tych sił z potrzebami aplikacji nie jest trywialne. Co więcej, ta równowaga prawie zawsze jest dynamiczna. Oznacza to, że podział, który jest właściwy jednego dnia, nie musi być właściwy za rok. Tak więc kompozycja pakietów prawdopodobnie będzie z czasem ewoluowała w miarę zmian w projekcie — od możliwości rozwoju do możliwości wielokrotnego wykorzystywania.
Stabilność: zasady sprzęgania pakietów Kolejne trzy zasady dotyczą związków zachodzących pomiędzy pakietami. Tutaj ponownie należy rozważyć sprzeczne punkty widzenia w kwestii możliwości rozwoju i logicznej budowy projektu. Siły, które wpływają na architekturę struktury pakietów, mają charaktery techniczny, polityczny i ulotny.
Zasada acyklicznych zależności (ADP) Graf zależności pomiędzy pakietami nie może zawierać cykli. Czy kiedykolwiek zdarzyło Ci się pracować cały dzień nad czymś, co na koniec dnia zaczęło działać, a gdy następnego dnia przyszedłeś do pracy, to okazało się, że już nie działa? Dlaczego przestało działać? Ponieważ ktoś został w pracy dłużej i wprowadził zmiany w kodzie, od którego zależało oprogramowanie! Taką sytuację nazywam „syndromem następnego poranka”. Syndrom następnego poranka występuje w środowiskach projektowych, w których wielu deweloperów modyfikuje te same pliki źródłowe. W stosunkowo niewielkich projektach, w których bierze udział zaledwie kilku deweloperów, nie stanowi to zbyt dużego problemu. Jednak w miarę rozrastania się projektu oraz zwiększania się liczby członków zespołu projektowego syndrom następnego poranka może stać się koszmarem. W niezdyscyplinowanych zespołach nie należy do rzadkości sytuacja, w której przez całe tygodnie nie można zbudować stabilnej wersji projektu. Każdy z członków zespołu projektowego próbuje zmieniać swój kod, starając się doprowadzić do tego, by działał z wersją kodu zmienionego przez kogoś innego. W kilku ostatnich dziesięcioleciach wyewoluowały dwa rozwiązania tego problemu. Oba rozwiązania mają swoje korzenie w branży telekomunikacyjnej. Pierwsze to „cotygodniowe kompilacje”, natomiast drugie to zasada acyklicznych zależności (Acyclic Dependencies Principle — ADP).
Cotygodniowe kompilacje Zasadę cotygodniowych kompilacji stosuje się w projektach średniej wielkości. Ten mechanizm działa następująco: Wszyscy deweloperzy ignorują zmiany wprowadzane przez innych deweloperów przez pierwsze cztery dni tygodnia. Wszyscy pracują na swoich prywatnych kopiach kodu i nie przejmują się integrowaniem wprowadzanych zmian. Następnie w piątek integrują wszystkie wprowadzone zmiany i kompilują system. Ma to tę wspaniałą zaletę, że pozwala programistom na działanie w odizolowanym świecie przez cztery z pięciu dni tygodnia. Wadą są oczywiście wysokie koszty integracji, które są ponoszone w piątek. Niestety, w miarę rozwoju projektu realizacja prac związanych z integracją i kompilacją kodu w każdy piątek staje się coraz mniej realna. Koszty integracji stale rosną, aż w końcu niektóre prace muszą być wykonywane w sobotę. Kilka takich sobót wystarcza do tego, aby przekonać deweloperów, że integracja w rzeczywistości powinna rozpocząć się w czwartek. W ten sposób początek zadań integracji powoli przesuwa się w kierunku środka tygodnia.
268
ROZDZIAŁ 20. ZASADY PROJEKTOWANIA PAKIETÓW
W miarę obniżania się wartości współczynnika czasu poświęcanego na prace rozwojowe w stosunku do zadań integracji maleje efektywność zespołu. W końcu staje się to tak frustrujące, że deweloperzy lub kierownicy projektu dochodzą do wniosku, że kompilacje powinny być wykonywane co dwa tygodnie. To na jakiś czas wystarcza, ale wraz z rozwojem projektu czas integracji nadal się wydłuża. Ostatecznie taka sytuacja prowadzi do kryzysu. Utrzymanie właściwej efektywności wymaga stałego wydłużania cyklu kompilacji. Trzeba jednak pamiętać, że wydłużony cykl kompilacji zwiększa ryzyko związane z projektem. Integracja i testowanie są coraz trudniejsze do wykonania, a zespół traci możliwość szybkiej weryfikacji kodu z działającym systemem.
Eliminowanie cykli zależności Rozwiązaniem tego problemu jest podzielenie środowiska wytwarzania oprogramowania na pakiety, które można publikować niezależnie. Pakiety stają się odrębnymi jednostkami pracy, które mogą być pobierane przez indywidualnych deweloperów bądź zespoły. Kiedy deweloperzy uznają pakiet za gotowy, wydają jego wersję dystrybucyjną, która może być wykorzystywana przez innych programistów. Pakiet otrzymuje numer wersji dystrybucyjnej i jest umieszczany w katalogu, z którego może być pobrany przez inne zespoły. Następnie prace są kontynuowane w prywatnych obszarach. Wszyscy pozostali korzystają z wersji, która została opublikowana. W miarę opracowywania nowych wersji dystrybucyjnych pakietu inne zespoły mogą zdecydować, czy chcą natychmiast przyjąć nową wersję, czy pozostać przy starej. Jeżeli zdecydują się nie zmieniać wersji, to po prostu w dalszym ciągu korzystają z wersji poprzedniej. Kiedy uznają, że są do tego gotowi, to po prostu przełączają się na nową wersję. A zatem żaden z zespołów nie jest skazany na łaskę bądź niełaskę innego zespołu. Zmiany wykonywane w jednym pakiecie nie muszą mieć natychmiastowego wpływu na inne zespoły. Każdy zespół może sam zdecydować, kiedy dostosować swoje pakiety do nowych wersji dystrybucyjnych wykorzystywanych pakietów. Co więcej, integracja jest przeprowadzana małymi krokami. Nie istnieje jeden punkt w czasie, kiedy wszyscy deweloperzy muszą się spotkać i zintegrować cały swój kod. Jest to bardzo prosty i racjonalny proces, który jest szeroko stosowany. Jednak aby można go było zastosować, trzeba zadbać o zarządzanie strukturą zależności pakietów. W grafie zależności nie może być cykli. Jeśli w strukturze zależności są cykle, to nie można uniknąć syndromu następnego poranka. Rozważmy diagram pakietów z rysunku 20.1. Można na nim zaobserwować typową strukturę pakietów zastosowaną w aplikacji. Funkcja, jaką spełnia ta aplikacja, nie ma znaczenia z punktu widzenia prezentowanego przykładu. Ważna jest struktura zależności pakietów. Zwróćmy uwagę, że ta struktura jest grafem skierowanym. Pakiety są reprezentowane przez węzły, natomiast relacje zależności są krawędziami skierowanymi.
Rysunek 20.1. Struktury pakietów są skierowanym grafem acyklicznym
STABILNOŚĆ: ZASADY SPRZĘGANIA PAKIETÓW
269
Zwróćmy uwagę na jeszcze jeden element. Niezależnie od pakietu, od którego zaczniemy, nie można podążać ścieżką relacji zależności w taki sposób, aby znaleźć się z powrotem w tym samym pakiecie. Te struktury nie mają cykli. Mamy tu do czynienia z acyklicznym grafem skierowanym (ang. Directed Acyclic Graph — DAG). Kiedy zespół odpowiedzialny za pakiet MyDialogs opublikuje nową wersję dystrybucyjną swojego pakietu, można z łatwością znaleźć pakiety, których to dotyczy — wystarczy spojrzeć na strzałki zależności i sprawdzić, jakie pakiety znajdują się po przeciwnej stronie do kierunku tych strzałek. W tym przypadku zmiany wpłyną na pakiety MyTasks oraz MyApplication. Deweloperzy, którzy w tym momencie pracują nad tymi pakietami, będą musieli zadecydować o tym, kiedy powinni zintegrować swój kod z nową wersją dystrybucyjną pakietu MyDialogs. Zwróćmy także uwagę, że opublikowanie wersji dystrybucyjnej pakietu MyDialogs nie wywiera żadnego wpływu na wiele innych pakietów w systemie. Pakiety te nic „nie wiedzą” o pakiecie MyDialogs, a w związku z tym wprowadzenie zmian w pakiecie MyDialogs nie wywiera na nie żadnego wpływu. To bardzo korzystne. Oznacza to, że wpływ wydania nowej wersji dystrybucyjnej pakietu MyDialogs na inne pakiety jest stosunkowo niewielki. Kiedy deweloperzy pracujący nad pakietem MyDialogs zdecydują się na przeprowadzenie testów tego pakietu, to wystarczy, że skompilują ten pakiet i skonsolidują go z wersją pakietu Windows, z której aktualnie korzystają. Nie będzie to miało wpływu na żadne inne pakiety w systemie. To bardzo korzystna sytuacja. Oznacza ona, że deweloperzy pracujący nad pakietem MyDialogs będą mieli stosunkowo niewiele pracy ze skonfigurowaniem testu oraz że będą musieli uwzględnić stosunkowo niewiele zmiennych. Kiedy nadejdzie czas wydania wersji dystrybucyjnej całego systemu, będzie można to zrobić metodą dół-góra. Najpierw zostanie skompilowany, przetestowany i wydany pakiet Windows. Następne będą pakiety MessageWindow i MyDialogs. W dalszej kolejności pakiet Task oraz pakiety TaskWindow i Database. Później należy skompilować pakiet MyTasks i na koniec pakiet MyApplication. Jest to bardzo czytelny proces i łatwy do przeprowadzenia. Wiadomo, jak zbudować system, ponieważ znane są zależności pomiędzy jego częściami.
Skutki istnienia cykli w grafie zależności między pakietami Powiedzmy, że nowe wymaganie zmusza nas do zmiany jednej z klas z pakietu MyDialogs w taki sposób, że musi ona skorzystać z klasy należącej do pakietu MyApplication. Powoduje to powstanie cyklu zależności, jak pokazano na rysunku 20.2.
Rysunek 20.2. Diagram pakietów zawierający cykl
270
ROZDZIAŁ 20. ZASADY PROJEKTOWANIA PAKIETÓW
Występowanie takiego cyklu prowadzi do pewnych bezpośrednich problemów. Na przykład deweloperzy pracujący nad pakietem MyTasks wiedzą, że opracowanie wersji dystrybucyjnej wymaga zapewnienia zgodności z pakietami Task, MyDialogs, Database i Windows. Jeśli jednak występuje cykl, musi być również zapewniona zgodność z pakietami MyApplication, TaskWindow i MessageWindow. Tak więc pakiet MyTasks zależy teraz od wszystkich innych pakietów w systemie. To sprawia, że opublikowanie wersji dystrybucyjnej pakietu MyTasks staje się bardzo trudne. Ten sam problem dotyczy pakietu MyDialogs. Występowanie cyklu sprawia, że pakiety MyApplication, MyTasks i MyDialogs zawsze muszą być publikowane w tym samym czasie. W istocie stają się one jednym dużym pakietem. Z tego powodu wszyscy programiści, którzy pracują nad jednym z tych pakietów, po raz kolejny będą doświadczali syndromu następnego poranka. Ciągle będą oni od siebie zależni, ponieważ każdy z tych zespołów jest zmuszony do korzystania z dokładnie tej samej wersji dystrybucyjnej swoich pakietów. Ale to tylko część problemów. Zastanówmy się, co się stanie, jeśli będziemy chcieli przetestować pakiet MyDialogs. Okazuje się, że musimy skonsolidować wszystkie inne pakiety systemu, łącznie z pakietem Database. Oznacza to, że tylko po to, aby przetestować pakiet MyDialogs, musimy wykonać kompletną kompilację całego systemu. Takie rozwiązanie jest nie do przyjęcia. Jeśli kiedykolwiek zastanawialiście się, dlaczego musicie konsolidować tak wiele różnych bibliotek i tyle zewnętrznego kodu tylko po to, aby uruchomić prosty test jednostkowy jednej ze swoich klas, to prawdopodobnie przyczyną było to, że istnieją cykle w grafie zależności. Występowanie takich cykli sprawia, że wyizolowanie modułów staje się bardzo trudne. Wykonywanie testów jednostkowych i publikowanie wersji dystrybucyjnych jest uciążliwe i stwarza wiele okazji do popełnienia błędów. A w języku C++ czas kompilacji rośnie geometrycznie wraz ze wzrostem liczby modułów. Co więcej, gdy istnieją cykle w grafie zależności, znalezienie właściwej kolejności kompilacji modułów może być bardzo trudne. Czasami prawidłowa kolejność może w ogóle nie istnieć. Może to prowadzić do dużych problemów w językach takich jak Java, które odczytują swoje deklaracje ze skompilowanych plików binarnych.
Przerywanie cykli Zawsze możliwe jest przerwanie cyklu zależności pomiędzy pakietami i odtworzenie grafu zależności do postaci acyklicznego grafu skierowanego. Dostępne są dwa główne mechanizmy wykonywania tej operacji. 1. Zastosowanie zasady odwracania zależności (DIP). W przypadku z rysunku 20.3 moglibyśmy utworzyć abstrakcyjną klasę bazową z interfejsem, którego oczekuje pakiet MyDialogs. Następnie można by umieścić tę abstrakcyjną klasę bazową w pakiecie MyDialogs, a w komponencie MyApplication stworzyć odpowiednią klasę potomną. To powoduje odwrócenie zależności pomiędzy pakietami MyDialogs i MyApplication, a tym samym przerwanie cyklu (patrz rysunek 20.3). Zwróćmy uwagę, że nazwa interfejsu została stworzona na podstawie nazwy klienta, a nie nazwy serwera. To kolejny przykład zastosowania reguły, zgodnie z którą interfejsy należą do klientów. 2. Utworzenie nowego pakietu, od którego zależą zarówno pakiet MyDialogs, jak i MyApplication. Klasę (klasy), od której zależą oba pakiety, należy przenieść do nowego pakietu (patrz rysunek 20.4).
Odchylenia Drugie rozwiązanie pokazuje, że struktura pakietów jest lotna w obliczu zmieniających się wymagań. W rzeczywistości w miarę rozwoju aplikacji struktura zależności pakietów zmienia się i rozrasta. Z tego powodu należy stale monitorować graf zależności pomiędzy pakietami pod kątem obecności cykli. Kiedy takie cykle zostaną znalezione, należy je przerwać. Czasami oznacza to konieczność tworzenia nowych pakietów, a tym samym rozrastanie się struktury zależności.
PROJEKT GÓRA-DÓŁ
271
Rysunek 20.3. Przerywanie cyklu poprzez zastosowanie zasady odwracania zależności
Rysunek 20.4. Przerywanie cyklu poprzez stworzenie nowego pakietu
Projekt góra-dół Problemy, które omówiliśmy do tej pory, prowadzą do oczywistego wniosku. Struktury pakietów nie da się zaprojektować od góry do dołu. Oznacza to, że struktura pakietów nie jest jednym z pierwszych elementów systemu, które powinniśmy zaprojektować. Struktura ta ewoluuje, w miarę jak system rozwija się i zmienia. Może się wydawać, że jest to sprzeczne z intuicją. Można by oczekiwać, że tak wysokopoziomowa dekompozycja jak podział na pakiety powinna również reprezentować dekompozycję funkcjonalną. Kiedy widzimy podział na tak duże grupy, czujemy, że pakiety powinny w pewien sposób reprezentować funkcje systemu. Wydaje się jednak, że diagramy zależności pomiędzy pakietami nie mają takiej cechy. W rzeczywistości diagramy zależności pomiędzy pakietami mają bardzo niewiele wspólnego z opisem funkcji aplikacji. Nie opisują one funkcji, ale raczej odzwierciedlają możliwości kompilacji aplikacji. Dlatego właśnie nie tworzy się ich na początku projektu. Nie ma potrzeby wykonywania kompilacji, a tym samym nie ma powodu, aby tworzyć mapę kompilacji. Jednak w miarę powstawania coraz większej liczby klas na początkowych etapach implementacji i projektowania powstaje coraz większe zapotrzebowanie na zarządzanie zależnościami, tak aby prace nad projektem mogły odbywać się bez syndromu następnego poranka. Co więcej, chcemy, aby zmiany miały charakter lokalny, dlatego zaczynamy zwracać uwagę na spełnienie zasad SRP i CCP oraz grupujemy klasy, które mogą się zmieniać razem.
272
ROZDZIAŁ 20. ZASADY PROJEKTOWANIA PAKIETÓW
Wraz z dalszym rozwojem aplikacji zaczynamy zwracać uwagę na tworzenie komponentów wielokrotnego użytku. Z tego powodu kompozycję pakietów zaczyna dyktować zasada CRP. Na koniec, wraz z pojawieniem się cykli, zaczynamy stosować zasadę acyklicznych zależności (ADP), a graf zależności pomiędzy pakietami zaczyna się zmieniać i rozrastać. Próba zaprojektowania struktury zależności pomiędzy pakietami przed zaprojektowaniem klas prawdopodobnie skończyłaby się niepowodzeniem. W takim przypadku nie wiedzielibyśmy zbyt wiele o wspólnych domknięciach, nie potrafilibyśmy wskazać komponentów wielokrotnego użytku i prawie na pewno stworzylibyśmy pakiety, których graf zależności zawierałby cykle. Z tych powodów struktura zależności pomiędzy pakietami rozwija się i ewoluuje wraz z rozwojem logicznego projektu systemu.
Zasada stabilnych zależności (SDP) Zależności powinny być zgodne z kierunkiem stabilności. Projekty nie mogą być całkowicie statyczne. Aby projekt mógł być właściwie utrzymywany, pewna ulotność jest konieczna. Aby ją osiągnąć, należy zapewnić zgodność z zasadą zbiorowego zamykania (ang. Common-Closure Principle — CCP). Korzystając z tej zasady, tworzymy pakiety, które są wrażliwe na niektóre rodzaje zmian. Te pakiety są projektowane z myślą o ulotności. Oczekujemy od nich, że będą się zmieniały. Żaden pakiet, który zgodnie z oczekiwaniami ma być ulotny, nie powinien zależeć od jakiegokolwiek pakietu, który jest trudny do zmiany! W przeciwnym przypadku ten ulotny pakiet również będzie trudny do zmiany. Przewrotnością branży oprogramowania jest to, że moduł, który zostały zaprojektowany z myślą o łatwym wprowadzaniu zmian, trudno się zmienia dlatego, że ktoś uzależnił swój kod od tego modułu. Pomimo tego, że żadna linijka kodu w module nie będzie wymagała zmiany, to moduł nagle stanie się trudny do zmiany. Dzięki zastosowaniu zasady SDP możemy zapewnić, że moduły, które są przeznaczone do łatwych modyfikacji, nie będą zależne od modułów, które są trudniejsze do zmiany.
Stabilność Spróbujmy postawić monetę jednogroszową na krawędzi. Czy w tej pozycji jest ona stabilna? Prawdopodobnie większość czytelników odpowiedziała, że nie. Jednak jeśli nic się nie wydarzy, moneta pozostanie w tej pozycji przez bardzo długi czas. A zatem stabilność nie ma bezpośredniego związku z częstością zmian. Moneta groszowa nie zmienia się, ale trudno myśleć o niej, że jest stabilna. Według słownika Webstera coś jest stabilne, jeśli „nie można tego łatwo przemieścić1”. Stabilność jest związana z ilością pracy wymaganej do wprowadzenia zmiany. Moneta groszowa nie jest stabilna, ponieważ aby ją przewrócić, potrzeba niewiele wysiłku. Z kolei stół jest bardzo stabilny, ponieważ aby go przewrócić, trzeba sporo wysiłku. Jak można to odnieść do oprogramowania? Jest wiele czynników, które sprawiają, że pakiet oprogramowania staje się trudny do zmiany: jego rozmiar, złożoność, czytelność itp. Na razie zignorujemy wszystkie te czynniki i skoncentrujemy się na czymś innym. Jednym z pewnych sposobów na to, aby pakiet oprogramowania stał się trudny do zmiany, jest uzależnienie od niego wielu innych pakietów oprogramowania. Pakiet z dużą liczbą przychodzących zależności jest bardzo stabilny, ponieważ dostosowanie wszystkich pakietów zależnych wymaga dużo pracy. Na rysunku 20.5 zaprezentowano przykład stabilnego pakietu X. Od tego pakietu zależą trzy inne pakiety, dlatego istnieją trzy istotne powody, aby go nie zmieniać. Mówimy, że pakiet X jest odpowiedzialny za te trzy pakiety. Z kolei pakiet X nie zależy od żadnego pakietu, zatem zmiany wprowadzane na zewnątrz nie mają na niego wpływu. Mówimy, że taki pakiet jest niezależny. 1
Webster’s Third New International Dictionary.
ZASADA STABILNYCH ZALEŻNOŚCI (SDP)
273
Rysunek 20.5. Stabilny pakiet X
Na rysunku 20.6 pokazano przykład bardzo niestabilnego pakietu. Żaden inny pakiet od niego nie zależy. Mówimy o takim pakiecie, że nie jest odpowiedzialny za inne pakiety. Sam pakiet Y zależy od trzech pakietów, zatem mogą na niego wpływać zmiany z trzech źródeł zewnętrznych. Mówimy, że pakiet Y jest od nich zależny.
Rysunek 20.6. Niestabilny pakiet Y
Metryki stabilności W jaki sposób można zmierzyć stabilność pakietu? Jednym ze sposobów jest policzenie zależności przychodzących i wychodzących z tego pakietu. Te wartości umożliwiają obliczenie pozycyjnej stabilności pakietu. Ca (zależności przychodzące — ang. afferent couplings) — liczba klas na zewnątrz pakietu, które
zależą od klas wewnątrz pakietu. Ce (zależności wychodzące — ang. efferent couplings) — liczba klas wewnątrz pakietu, które zależą
od klas na zewnątrz pakietu. I (niestabilność — ang. instability):
I
Ce Ca Ce
Opisana metryka przyjmuje wartości z przedziału [0,1]. Wartość I = 0 oznacza maksymalnie stabilny pakiet. Wartość I = 1 oznacza maksymalnie niestabilny pakiet. Wartości Ca i Ce wyznacza się poprzez zliczanie klas na zewnątrz pakietu, które są zależne od klas wewnątrz danego pakietu. Rozważmy przykład z rysunku 20.7.
Rysunek 20.7. Przykład struktury zależności
274
ROZDZIAŁ 20. ZASADY PROJEKTOWANIA PAKIETÓW
Linie przerywane pomiędzy pakietami reprezentują zależności. Relacje pomiędzy klasami należącymi do tych pakietów pokazują, jak te zależności są zaimplementowane. Występuje tu dziedziczenie oraz relacje asocjacji. Załóżmy, że chcemy obliczyć stabilność pakietu Pc. Jak można zauważyć, istnieją trzy klasy na zewnątrz pakietu Pc, które zależą od klas należących do pakietu Pc. A zatem Ca = 3. Ponadto istnieje jedna klasa na zewnątrz pakietu Pc, od której zależą klasy należące do Pc. Wynika stąd, że Ce = 1, natomiast I = 1/4. W języku C++ takie zależności zazwyczaj są reprezentowane przez instrukcje #include. Metrykę I najłatwiej obliczyć w przypadku, gdy kod źródłowy zorganizowano w taki sposób, że w każdym pliku jest jedna klasa. W Javie metrykę I można obliczyć poprzez zliczanie instrukcji import i kwalifikowanych nazw. Kiedy metryka I wynosi 1, to znaczy, że żaden inny pakiet nie zależy od tego pakietu (Ca = 0) oraz że ten pakiet zależy od innych pakietów (Ce > 0). Jest to najbardziej niestabilny pakiet, jaki może istnieć — nie jest odpowiedzialny i jest zależny. Brak pakietów, które zależą od naszego pakietu, eliminuje powód unikania zmian, a pakiety, od których zależy nasz pakiet, mogą generować wiele powodów do zmian. Z drugiej strony, jeśli metryka I ma wartość 0, to znaczy, że od naszego pakietu zależą inne pakiety (Ca > 0), ale ten pakiet sam nie zależy od innych pakietów (Ce = 0). Pakiet jest odpowiedzialny i niezależny. Taki pakiet jest maksymalnie stabilny. Ze względu na istnienie pakietów zależnych wprowadzanie w nim zmian jest trudne oraz nie istnieją zależności, które mogłyby wymusić wprowadzanie w nim zmian. Zasada SDP mówi, że metryka I pakietu powinna być większa niż metryki I pakietów, od których on zależy (tzn. metryki I powinny maleć w kierunku zależności).Nie wszystkie pakiety muszą
być stabilne Gdyby wszystkie pakiety w systemie były w maksymalnym stopniu stabilne, to takiego systemu nie można by było zmieniać. Nie jest to sytuacja pożądana. W praktyce chcemy projektować strukturę pakietów w taki sposób, aby niektóre pakiety były stabilne, a inne nie. Na rysunku 20.8 pokazano idealną konfigurację dla systemu składającego się z trzech pakietów.
Rysunek 20.8. Idealna konfiguracja pakietów
Pakiety, które można zmieniać, znajdują się w górnej części diagramu i zależą od pakietu stabilnego zamieszczonego na dole. Umieszczenie pakietów niestabilnych na górze diagramu jest użyteczną konwencją, ponieważ wszystkie strzałki wskazujące w górę naruszają zasadę SDP. Na rysunku 20.9 pokazano, jak można naruszyć zasadę SDP. Chcemy, aby pakiet Elastyczny można było łatwo zmieniać. A zatem chcemy, żeby pakiet Elastyczny był niestabilny. Jednak pewien programista pracujący nad pakietem o nazwie Stabilny wprowadził zależność od pakietu Elastyczny. Ta sytuacja narusza zasadę SDP, ponieważ metryka I dla pakietu Stabilny ma znacznie niższą wartość od metryki I pakietu Elastyczny. W rezultacie wprowadzanie zmian w pakiecie Elastyczny już nie będzie łatwe. Wprowadzenie zmian w pakiecie Elastyczny wymusi modyfikowanie pakietu Stabilny oraz wszystkich pakietów, które od niego zależą. Aby rozwiązać ten problem, trzeba w jakiś sposób przerwać zależność pomiędzy pakietami Stabilny i Elastyczny. Po co istnieje ta zależność? Załóżmy, że wewnątrz pakietu Elastyczny istnieje klasa C, z której musi skorzystać inna klasa U wewnątrz pakietu Stabilny (patrz rysunek 20.10).
ZASADA STABILNYCH ZALEŻNOŚCI (SDP)
275
Rysunek 20.9. Naruszenie zasady SDP
Rysunek 20.10. Przyczyna niewłaściwej zależności
Problem ten można rozwiązać, stosując zasadę DIP. Utworzymy interfejs o nazwie IU i umieścimy go w pakiecie o nazwie UInterface. Zadbamy o to, aby ten interfejs deklarował wszystkie metody, z których musi skorzystać klasa U. Następnie modyfikujemy klasę C w taki sposób, aby dziedziczyła po tym interfejsie (patrz rysunek 20.11). To powoduje przerwanie zależności pomiędzy pakietami Stabilny i Elastyczny i wymusza zależność obu pakietów od pakietu UInterface. Pakiet UInterface jest bardzo stabilny (I = 0), natomiast pakiet Elastyczny zachowuje pożądaną niestabilność (I = 1). Kierunek wszystkich zależności odpowiada teraz malejącej wartości metryki I.
Rysunek 20.11. Rozwiązanie problemu naruszenia stabilności z wykorzystaniem zasady DIP
276
ROZDZIAŁ 20. ZASADY PROJEKTOWANIA PAKIETÓW
Gdzie powinna się znaleźć implementacja projektu wysokiego poziomu? Pewna część oprogramowania w systemie nie powinna się często zmieniać. Takie oprogramowanie reprezentuje wysokopoziomową architekturę i decyzje projektowe. Nie chcemy, aby te decyzje były ulotne. Z tego powodu oprogramowanie, które implementuje projekt wysokiego poziomu, powinno być umieszczone w stabilnych pakietach (I = 0). Pakiety niestabilne (I = 1) powinny zawierać tylko takie oprogramowanie, które może się zmieniać. Jeśli jednak wysokopoziomowy projekt zostanie umieszczony w stabilnych pakietach, to kod źródłowy reprezentujący ten projekt będzie trudny do zmiany. To może sprawiać, że projekt stanie się nieelastyczny. W jaki sposób można doprowadzić do sytuacji, w której maksymalnie stabilne pakiety (I = 0) będą na tyle elastyczne, aby wprowadzanie zmian było możliwe? Rozwiązaniem tego problemu jest stosowanie zasady OCP. Zasada ta mówi, że jest możliwe i korzystne tworzenie takich klas, które są wystarczająco elastyczne, by można było je rozszerzać bez konieczności modyfikacji. Jakie klasy są zgodne z tą zasadą? Klasy abstrakcyjne.
Zasada stabilnych abstrakcji (SAP) Pakiet powinien być abstrakcyjny w stopniu odpowiadającym jego stabilności. Powyższa zasada określa związek pomiędzy stabilnością a abstrakcyjnością. Mówi ona, że stabilny pakiet powinien być jednocześnie abstrakcyjny, tak aby jego stabilność nie powodowała trudności z rozszerzaniem. Z drugiej strony, mówi, że pakiet niestabilny powinien być konkretny, ponieważ jego niestabilność pozwala na łatwe wprowadzanie zmian w konkretnym kodzie. A zatem jeśli pakiet ma być stabilny, to powinien zawierać także klasy abstrakcyjne, tak by mógł być rozszerzany. Stabilne pakiety, które są rozszerzalne, są elastyczne i nie wprowadzają niepotrzebnych ograniczeń na projekt. Połączenie zasad SAP i SDP jest odpowiednikiem zasady DIP w odniesieniu do pakietów. To prawda, ponieważ zasada SDP mówi, że kierunek zależności powinien być zgodny z malejącą stabilnością, natomiast zasada SAP mówi, że stabilność systemu implikuje jego abstrakcyjność. A zatem kierunek zależności powinien być zgodny z kierunkiem abstrakcji. Jednak DIP jest zasadą, która dotyczy klas. W przypadku klas nie istnieją odcienie szarości. Klasa jest abstrakcyjna albo nie. Połączenie zasad SDP i SAP dotyczy pakietów i pozwala na to, aby pakiet był częściowo abstrakcyjny i częściowo stabilny.
Mierzenie abstrakcji Metryka A jest miarą abstrakcyjności pakietu. Jej wartość oznacza stosunek klas abstrakcyjnych w pakiecie do całkowitej liczby klas w pakiecie. Nc — liczba klas w pakiecie. Na — liczba klas abstrakcyjnych w pakiecie. Należy pamiętać, że klasa jest abstrakcyjna, jeśli zawiera co najmniej jeden czysty interfejs. Nie można stworzyć egzemplarza klasy abstrakcyjnej. A — abstrakcyjność.
A
Na Nc
Metryka A może przyjmować wartości z przedziału od 0 do 1. Zero oznacza, że pakiet nie zawiera żadnych klas abstrakcyjnych. Wartość 1 oznacza, że w pakiecie znajdują się wyłącznie klasy abstrakcyjne.
ZASADA STABILNYCH ABSTRAKCJI (SAP)
277
Ciąg główny Spróbujmy zdefiniować relację pomiędzy stabilnością (I) a abstrakcyjnością (A). Możemy stworzyć wykres, w którym wartości A będą oznaczane na osi pionowej, natomiast wartości I na osi poziomej. Gdybyśmy zaznaczyli dwa „dobre” rodzaje pakietów na tym wykresie, zobaczymy, że pakiety, które są w maksymalnym stopniu stabilne i abstrakcyjne, znajdują się w górnym lewym punkcie wykresu (0,1). Z kolei pakiety, które są maksymalnie niestabilne i konkretne, znajdują się w dolnym prawym punkcie wykresu (1,0) (patrz rysunek 20.12).
Rysunek 20.12. Wykres A–I
Nie wszystkie pakiety można przypisać do jednej z tych dwóch pozycji. Pakiety charakteryzują się poziomami abstrakcji i stabilności. Na przykład bardzo często jedna klasa abstrakcyjna dziedziczy po innej klasie abstrakcyjnej. Klasa pochodna jest w tym przypadku abstrakcją, która jest zależna od innej abstrakcji. Zatem choć jest ona w maksymalnym stopniu abstrakcyjna, to nie jest maksymalnie stabilna. Jej zależności obniżają jej stabilność. Ponieważ nie można wymusić, aby wszystkie pakiety były umieszczone albo w punkcie (0,1), albo (1,0), musimy założyć, że istnieje zbiór punktów na wykresie A/I, który określa rozsądne pozycje dla pakietów. Ten zbiór możemy wywnioskować poprzez wyszukanie obszarów, w których pakiety nie powinny się znaleźć (czyli stref wykluczenia) (patrz rysunek 20.13).
Rysunek 20.13. Strefy wykluczenia
Rozważmy pakiet w obszarze (0,0). Jest to konkretny pakiet o wysokiej stabilności. Taki pakiet nie jest pożądany, ponieważ jest sztywny. Nie można go rozszerzyć, ponieważ nie jest abstrakcyjny. A ze względu na jego stabilność wprowadzanie w nim zmian jest bardzo trudne. Z tych powodów nie należy oczekiwać, aby dobrze zaprojektowane pakiety znalazły się w pobliżu punktu (0,0). Obszar wokół punktu (0,0) to strefa wykluczenia, zwana strefą problemów (ang. zone of pain).
278
ROZDZIAŁ 20. ZASADY PROJEKTOWANIA PAKIETÓW
Należy zwrócić uwagę, że istnieją przypadki, kiedy pakiety trafiają do strefy problemów. Przykładem może być schemat bazy danych. Schematy bazy danych są notorycznie niestabilne, bardzo konkretne i wiele pakietów od nich zależy. Jest to jeden z powodów, dla których interfejs między aplikacjami obiektowymi a bazami danych jest tak trudny do określenia, a aktualizacje schematu są zazwyczaj bolesne. Innym przykładem pakietu, który znajduje się blisko punktu (0,0), jest pakiet zawierający konkretną implementację biblioteki narzędziowej. Chociaż taki pakiet ma metrykę I o wartości 1, w rzeczywistości może być niezmienny. Rozważmy przykład pakietu zawierającego implementację operacji na łańcuchach znaków. Chociaż wszystkie klasy należące do tego pakietu są konkretne, pakiet jest niezmienny. Obecność takich pakietów w strefie (0,0) jest nieszkodliwa, ponieważ prawdopodobieństwo ich zmiany jest małe. Być może należałoby rozważyć dodanie do wykresu trzeciej osi reprezentującej ulotność. W takim przypadku rysunek 20.13 reprezentowałby płaszczyznę dla współczynnika ulotności równego 1. Rozważmy teraz pakiet w strefie w pobliżu punktu (1,1). Taka lokalizacja także jest niepożądana, ponieważ dotyczy pakietów, które są maksymalnie abstrakcyjne, ale nie mają pakietów zależnych. Takie pakiety są bezużyteczne. Stąd też nazwa — strefa bezużyteczności (ang. zone of uselessness). Wydaje się oczywiste, że pakiety ulotne powinny znajdować się jak najdalej od obu stref wykluczenia. Położenie punktów maksymalnie odległych od obu tych obszarów wyznacza prostą łączącą punkty (1,0) i (0,1). Ta linia jest określana jako ciąg główny (ang. main sequence)2. Pakiety rozmieszczone w punktach należących do ciągu głównego nie są ani „zbyt abstrakcyjne” jak na swoją stabilność, ani „zbyt niestabilne” jak na swoją abstrakcyjność. Nie są ani bezużyteczne, ani też nie stwarzają poważnych problemów. Zależą od innych pakietów w granicach swojej abstrakcyjności, a inne pakiety zależą od nich w granicach ich konkretności. Bez wątpienia najbardziej pożądanymi pozycjami dla pakietów są oba końce ciągu głównego. Z mojego doświadczenia wynika jednak, że mniej niż połowa pakietów w projekcie charakteryzuje się takimi idealnymi właściwościami. Pozostałe pakiety mają najlepsze właściwości wtedy, gdy znajdują się na linii ciągu głównego lub jak najbliżej tej linii.
Odległość od ciągu głównego W ten sposób doszliśmy do ostatniej z metryk. Jeśli oczekujemy, aby pakiety były reprezentowane przez punkty leżące na ciągu głównym lub w jego pobliżu, to możemy opracować metrykę opisującą odległość pakietu od ideału: D — odległość.
D
A I 1 2
Opisana metryka przyjmuje wartości z przedziału [0,~0,707]. D' — odległość znormalizowana:
D ' A I 1 Ta metryka jest znacznie wygodniejsza od metryki D, ponieważ może przyjmować wartości z przedziału [0;1]. Wartość zero oznacza, że pakiet należy do ciągu głównego. Wartość równa 1 oznacza, że określony pakiet jest maksymalnie oddalony od tego ciągu. Dysponując tą metryką, można przeanalizować projekt pod kątem ogólnej zgodności z ciągiem głównym. Dla każdego pakietu można wyznaczyć wartość metryki D: każdy pakiet, dla którego wartość D nie jest bliska 0, można ponownie przeanalizować i przebudować. Z doświadczenia wiem, że tego rodzaju 2
Nazwę „ciąg główny” przyjąłem ze względu na moje zainteresowania astronomią oraz diagramami HR.
ZASADA STABILNYCH ABSTRAKCJI (SAP)
279
analizy bardzo pomagały mi w definiowaniu komponentów, które są łatwiejsze w utrzymaniu i w mniejszym stopniu podatne na zmiany. Możliwe jest również przeprowadzanie analizy statystycznej projektu. Można obliczyć średnią i wariancję wartości metryki D dla wszystkich pakietów należących do projektu. Od dobrego projektu można oczekiwać niskich wartości zarówno średniej, jak i wariancji. Wariancja może służyć do ustanawiania „progów kontrolnych” pozwalających na identyfikację pakietów, które są „wyjątkowe” w porównaniu z pozostałymi (patrz rysunek 20.14).
Rysunek 20.14. Wykres punktowy wartości metryki D
Na wykresie punktowym przedstawionym na rysunku3 widać, że większość punktów reprezentujących pakiety leży w pobliżu ciągu głównego, ale niektóre pakiety znajdują się w odległości przekraczającej odchylenie standardowe (Z = 1). Warto zwrócić uwagę na te specyficzne pakiety. Prawdopodobnie są to pakiety albo bardzo abstrakcyjne z małą liczbą pakietów zależnych, albo bardzo konkretne z dużą liczbą pakietów zależnych. Innym sposobem wykorzystywania opisywanych metryk jest wykreślenie wartości metryki D' dla każdego pakietu na osi czasu. Przykład takiego wykresu zamieszczono na rysunku 20.15. Jak można zauważyć, w ciągu ostatnich kilku publikacji do pakietu Payroll przedostały się dość dziwne zależności. Na wykresie zaznaczono próg kontrolny dla wartości D' = 0,1. Ten próg został przekroczony dla publikacji R2.1. W związku z tym warto sprawdzić, dlaczego interesujący nas pakiet tak bardzo oddalił się od ciągu głównego.
Rysunek 20.15. Wykres czasowy wartości metryki D' pojedynczego pakietu
3
Wykres sporządzono na podstawie fikcyjnych danych.
280
ROZDZIAŁ 20. ZASADY PROJEKTOWANIA PAKIETÓW
Wniosek Metryki zarządzania zależnościami opisane w tym rozdziale pozwalają na ocenę zgodności projektu ze wzorcem zależności i abstrakcji uważanym za „dobry”. Z doświadczenia wynika, że niektóre zależności są dobre, natomiast inne złe. Zaprezentowany wzorzec jest wynikiem takich doświadczeń. Trzeba jednak pamiętać, że metryka sama w sobie nie może być „dobra” lub „zła” — ma jedynie określać ocenę względem jakiegoś standardu. Jest całkowicie możliwe, że standard wybrany w tym rozdziale jest odpowiedni tylko dla pewnych aplikacji, natomiast dla innych jest niewłaściwy. Istnieje także możliwość, że istnieją znacznie lepsze metryki opisujące jakość projektu.
R OZDZIAŁ 21
Wzorzec projektowy Fabryka
Człowiek, który buduje fabrykę, buduje świątynię... — Calvin Coolidge (1872 – 1933)
Zasada odwracania zależności (DIP1) mówi, że powinniśmy preferować zależności od klas abstrakcyjnych oraz unikać zależności od klas konkretnych, szczególnie jeśli te klasy często się zmieniają. Zatem poniższy fragment kodu narusza tę regułę: Circle c = new Circle(origin, 1);
Circle jest klasą konkretną. Z tego powodu moduły tworzące egzemplarze klasy Circle muszą naruszać zasadę DIP. W istocie każda linijka kodu, w której użyto słowa kluczowego new, narusza regułę DIP. W niektórych przypadkach naruszanie reguły DIP jest niemal nieszkodliwe2. Im wyższe prawdopodobieństwo zmian w konkretnej klasie, tym bardziej trzeba się liczyć z problemami uzależniania od tej klasy zewnętrznego kodu. Jeśli jednak konkretna klasa nie zmienia się często, to zależność od niej nie jest szkodliwa. Na przykład tworzenie egzemplarzy klasy String nie jest niczym złym. Zależność od klasy String jest bardzo bezpieczna, ponieważ nie istnieje duże prawdopodobieństwo częstych zmian w tej klasie. Z drugiej strony, jeśli aktywnie rozwijamy aplikację, musimy liczyć się z możliwością występowania wielu konkretnych klas, które często się zmieniają. Uzależnianie kodu od tych klas może być przyczyną problemów. Lepszym rozwiązaniem jest uzależnianie kodu od abstrakcyjnych interfejsów, które pozwalają na skuteczną ochronę przed większością zmian. 1
DIP — zasada odwracania zależności — rozdział 11.
2
To dość dobra wymówka.
282
ROZDZIAŁ 21. WZORZEC PROJEKTOWY FABRYKA
Wzorzec projektowy Fabryka (ang. Factory) umożliwia tworzenie egzemplarzy konkretnych klas oraz uzależnianie kodu wyłącznie od abstrakcyjnych interfejsów. Z tego powodu jest on dużym udogodnieniem w procesie aktywnego rozwoju oprogramowania w przypadku, gdy klasy konkretne często się zmieniają. Przykład scenariusza sprawiającego problemy pokazano na rysunku 21.1. Przedstawiono na nim klasę SomeApp, która zależy od interfejsu Shape. Klasa SomeApp korzysta z egzemplarzy klasy Shape wyłącznie za pośrednictwem interfejsu Shape. Nie wywołuje żadnych szczegółowych metod klas Square ani Circle. Niestety, klasa SomeApp tworzy egzemplarze klas Square i Circle i dlatego zależy od tych konkretnych klas.
Rysunek 21.1. Aplikacja narusza zasadę DIP ze względu na tworzenie egzemplarzy konkretnych klas
Problem można rozwiązać, stosując dla klasy SomeApp wzorzec projektowy Fabryka, tak jak pokazano na rysunku 21.2. W tym przypadku zastosowano interfejs ShapeFactory. Ten interfejs zawiera dwie metody: makeSquare oraz makeCircle. Metoda makeSquare zwraca egzemplarz klasy Square, natomiast metoda makeCircle zwraca egzemplarz klasy Circle. Jednak obie te metody zwracają obiekt implementujący interfejs Shape.
Rysunek 21.2. Zastosowanie wzorca projektowego Fabryka
Kod interfejsu ShapeFactory zamieszczono na listingu 21.1. Implementację tego interfejsu zamieszczono na listingu 21.2.
CYKL ZALEŻNOŚCI
283
Listing 21.1. ShapeFactory.java public interface ShapeFactory { public Shape makeCircle(); public Shape makeSquare(); }
Listing 21.2. ShapeFactoryImplementation.java public class ShapeFactoryImplementation implements ShapeFactory { public Shape makeCircle() { return new Circle(); }
}
public Shape makeSquare() { return new Square(); }
Warto zwrócić uwagę, że zastosowanie wzorca projektowego Fabryka całkowicie rozwiązuje problem zależności od konkretnych klas. Kod aplikacji nie zależy już od klas Circle ani Square, pomimo że egzemplarze tych klas są tworzone. Operacje na tych egzemplarzach są wykonywane za pośrednictwem interfejsu Shape. Aplikacja nigdy nie wywołuje metod, które są specyficzne dla klas Square bądź Circle. Problem zależności od konkretnej klasy przeniesiono na inny poziom. Ktoś musi teraz utworzyć obiekt ShapeFactoryImplementation, ale nikt więcej nie będzie musiał tworzyć egzemplarzy obiektów Square bądź Circle. Obiekt ShapeFactoryImplementation najprawdopodobniej zostanie stworzony wewnątrz funkcji main lub w funkcji inicjalizacyjnej dołączonej do funkcji main.
Cykl zależności Uważni czytelnicy z pewnością zauważyli problem z zastosowaniem wzorca projektowego Fabryka w tej formie. Klasa ShapeFactory zawiera metodę dla każdej pochodnej klasy Shape. W efekcie powstaje cykl zależności, który utrudnia dodawanie nowych pochodnych klasy Shape. Za każdym razem po utworzeniu nowej pochodnej klasy Shape trzeba będzie dodać metodę do interfejsu ShapeFactory(). W większości przypadków oznacza to konieczność ponownej kompilacji i instalacji wszystkich aplikacji klienckich korzystających z interfejsu ShapeFactory3. Aby pozbyć się tego cyklu zależności, trzeba w pewnym stopniu zrezygnować z bezpieczeństwa typów. Zamiast definiować w interfejsie ShapeFactory po jednej metodzie dla każdej pochodnej klasy Shape, możemy zdefiniować w nim jedną funkcję make, która pobiera argument typu String. Dla przykładu przeanalizujmy kod z listingu 21.3. Pokazana technika wymaga zastosowania wewnątrz klasy ShapeFactoryImplementation ciągu instrukcji if else sprawdzających wartość argumentu wejściowego po to, aby wybrać właściwą pochodną klasy Shape, której egzemplarz ma być stworzony. Odpowiedni kod zamieszczono na listingach 21.4 i 21.5. Listing 21.3. Fragment kodu tworzący obiekt Circle public void testCreateCircle() throws Exception { Shape s = factory.make("Circle"); assert(s instanceof Circle); } 3
W Javie nie istnieje taki przymus. Można poradzić sobie bez ponownej kompilacji i instalacji aplikacji klienckich o zmienionym interfejsem, ale jest to ryzykowne.
284
ROZDZIAŁ 21. WZORZEC PROJEKTOWY FABRYKA
Listing 21.4. ShapeFactory.java public interface ShapeFactory { public Shape make(String shapeName) throws Exception; }
Listing 21.5. ShapeFactoryImplementation.java public class ShapeFactoryImplementation implements ShapeFactory { public Shape make(String shapeName) throws Exception { if (shapeName.equals("Circle")) return new Circle(); else if (shapeName.equals("Square")) return new Square(); else throw new Exception( "Klasa ShapeFactory nie może utworzyć " + shapeName); } }
Można by polemizować, że takie podejście jest zbyt niebezpieczne, ponieważ jeśli programista popełni literówkę w nazwie figury, to niedopatrzenie to ujawni się dopiero w czasie wykonywania programu, a nie jako błąd kompilacji. To prawda. Jeśli jednak napiszemy odpowiednie testy jednostkowe i będziemy stosować techniki projektowania „najpierw test”, to błędy te zostaną wychwycone na długo, zanim staną się prawdziwymi problemami.
Fabryki wymienne Jedną z zalet wzorca projektowego Fabryka jest możliwość zastępowania jednej implementacji fabryki inną. W ten sposób można wymieniać w aplikacji całe rodziny obiektów. Dla przykładu wyobraźmy sobie aplikację, która musi mieć możliwość korzystania z wielu różnych implementacji bazy danych. Dla potrzeb przykładu załóżmy, że użytkownicy mogą korzystać ze zwykłych plików bądź z adapterów bazy danych Oracle. Można by skorzystać ze wzorca projektowego Pełnomocnik (ang. Proxy)4 w celu odseparowania aplikacji od implementacji bazy danych. Do stworzenia egzemplarzy klas-pełnomocników również można zastosować fabryki. Właściwą strukturę pokazano na rysunku 21.3. Zwróćmy uwagę, że mamy tam dwie implementacje interfejsu EmployeeFactory. Jedna tworzy klasy-pełnomocników, które działają z płaskimi plikami, natomiast druga tworzy odpowiednie klasy działające z bazą danych Oracle™. Zwróćmy również uwagę, że aplikacja „nie wie”, która z tych implementacji jest używana.
Wykorzystanie wzorca Fabryka do tworzenia zestawów testowych Podczas pisania testów jednostkowych często chcemy przetestować zachowanie modułu w oderwaniu od modułów, z których ten moduł korzysta. Dla przykładu rozważmy aplikację Payroll, która korzysta z bazy danych (patrz rysunek 21.4). Możemy przetestować działanie modułu Payroll całkowicie bez korzystania z bazy danych.
4
Wzorzec projektowy Pełnomocnik zostanie omówiony w dalszej części tej książki, w rozdziale 26. Teraz wystarczy, jeśli zapamiętamy, że klasa pełnomocnika „wie”, w jaki sposób należy czytać poszczególne obiekty z poszczególnych rodzajów baz danych.
WYKORZYSTANIE WZORCA FABRYKA DO TWORZENIA ZESTAWÓW TESTOWYCH
285
Rysunek 21.3. Fabryki wymienne
Rysunek 21.4. Klasa Payroll korzysta z klasy Database
Aby to osiągnąć, możemy skorzystać z abstrakcyjnego interfejsu dla bazy danych. Jedna z implementacji tego abstrakcyjnego interfejsu korzysta z prawdziwej bazy danych. Drugą implementacją jest kod testowy napisany w celu zasymulowania zachowania bazy danych oraz do sprawdzenia, czy odwołania do bazy danych są wykonywane prawidłowo. Właściwą strukturę pokazano na rysunku 21.5. Moduł PayrollTest testuje klasę PayrollModule poprzez kierowanie do niego wywołań. Ponadto implementuje interfejs Database tak, aby móc przechwytywać wywołania, które klasa Payroll kieruje do bazy danych. Dzięki temu obiekt klasy PayrollTest może sprawdzić, czy klasa Payroll zachowuje się prawidłowo. Pozwala również klasie PayrollTest symulować wiele rodzajów błędów bazy danych oraz problemów, które w innym przypadku byłyby trudne do zasymulowania. Taka technika czasami jest określana jako imitowanie (ang. spoofing).
Rysunek 21.5. Klasa PayrollTest imituje klasę Database
286
ROZDZIAŁ 21. WZORZEC PROJEKTOWY FABRYKA
Zastanówmy się, jak klasa Payroll uzyskuje egzemplarz klasy PayrollTest, który jest następnie wykorzystywany zamiast obiektu Database. Oczywiście obiekt klasy Payroll nie tworzy egzemplarza klasy PayrollTest. Pomimo to obiekt Payroll musi jakoś uzyskać referencję do implementacji obiektu Database, z którego będzie korzystał. W niektórych przypadkach całkowicie naturalnym podejściem jest przekazanie przez obiekt PayrollTest do obiektu Payroll referencji do obiektu Database. W innych przypadkach obiekt PayrollTest musi skorzystać ze zmiennej globalnej w celu odwołania się do obiektu Database. W jeszcze innych przypadkach egzemplarz obiektu Database może tworzyć obiekt Payroll. W tym ostatnim przypadku możemy skorzystać ze wzorca projektowego Fabryka, aby „oszukać” obiekt Payroll, tak by stworzył testową wersję obiektu Database. W tym celu wystarczy przekazać do obiektu Payroll alternatywną wersję fabryki. Możliwą strukturę pokazano na rysunku 21.6. Moduł Payroll uzyskuje dostęp do fabryki za pośrednictwem zmiennej globalnej (lub statycznej zmiennej w globalnej klasie) o nazwie GdatabaseFactory. Moduł PayrollTest implementuje interfejs DatabaseFactory i przypisuje referencję do samego siebie do zmiennej GdatabaseFactory. Kiedy obiekt Payroll korzysta z fabryki w celu stworzenia obiektu Database, moduł PayrollTest przechwytuje to wywołanie i przekazuje referencję do samego siebie. Z tego powodu obiekt Payroll jest „przekonany”, że stworzył obiekt PayrollDatabase. Dzięki temu moduł PayrollTest może „oszukać” moduł Payroll i przechwycić wszystkie wywołania do bazy danych.
Rysunek 21.6. Imitowanie fabryki
Znaczenie korzystania z fabryk W przypadku ścisłego przestrzegania zasady DIP fabryki należałoby stosować dla wszystkich zmieniających się klas w systemie. Poza tym możliwości wzorca projektowego Fabryka są bardzo kuszące. Te dwa czynniki czasami powodują, że programiści wpadają w pułapkę nadużywania tego wzorca. Jest to podejście ekstremalne, którego nie polecam. Osobiście początkowo nie korzystam z fabryk. Stosuję je w systemie tylko wtedy, gdy powstaną ku temu ważne powody. Na przykład kiedy zachodzi konieczność użycia wzorca Pełnomocnik, to najprawdopodobniej trzeba skorzystać z fabryki do stworzenia obiektów trwałych. Podobnie jeśli podczas tworzenia testów jednostkowych napotkam sytuacje, w których muszę podszyć się pod twórcę obiektu, to najczęściej korzystam z fabryki. Nie zakładam jednak, że stosowanie fabryk jest koniecznością. Fabryki wprowadzają dodatkową złożoność, której można uniknąć, zwłaszcza we wczesnych fazach rozwijającego się projektu. Domyślne stosowanie fabryk znacznie utrudnia rozbudowę projektu. Utworzenie nowej klasy może wymagać utworzenia nawet czterech nowych klas: dwóch interfejsów reprezentujących nową klasę oraz jej fabrykę oraz dwóch konkretnych klas implementujących te interfejsy.
BIBLIOGRAFIA
287
Wniosek Wzorzec projektowy Fabryka daje duże możliwości. Jest istotnym narzędziem zapewniania zgodności z zasadą odwracania zależności (DIP). Pozwala modułom, które implementują wysokopoziomową strategię, tworzyć egzemplarze klas bez wprowadzania zależności od konkretnych implementacji. Pozwala również zastępować grupy klas całkowicie odmiennymi rodzinami implementacji. Trzeba równocześnie pamiętać, że fabryki wprowadzają dodatkową złożoność, której często można uniknąć. Nadużywanie ich rzadko jest właściwym sposobem postępowania.
Bibliografia 1. Gamma, et al., Design Patterns, Reading, MA: Addison-Wesley, 1995.
288
ROZDZIAŁ 21. WZORZEC PROJEKTOWY FABRYKA
R OZDZIAŁ 22
Studium przypadku: system płacowy (część 2.)
Dobra rada: Jeśli sądzisz, że coś jest inteligentne i wyszukane, uważaj — to prawdopodobnie samouwielbienie — Donald A. Norman, The Design of Everyday Things, 1990
Dotychczas mnóstwo czasu poświęciliśmy na analizę, projektowanie i implementację systemu płacowego. Jednak w dalszym ciągu musimy podjąć wiele decyzji projektowych. Do tej pory nad problemem pracował tylko jeden programista — byłem nim ja. Bieżąca struktura środowiska wytwarzania oprogramowania jest spójna z tym stanem rzeczy. Wszystkie pliki programów są umieszczone w jednym katalogu. W ogóle nie istnieje struktura wyższego poziomu. Nie ma pakietów, podsystemów, oddzielnie publikowanych modułów innych niż cała aplikacja. Takie rozwiązanie w przyszłości się nie sprawdzi. Musimy założyć, że w miarę rozwoju tego programu będzie rosła również liczba osób, które nad nim pracują. Aby projekt stał się wygodny dla wielu deweloperów, trzeba będzie podzielić kod źródłowy na pakiety, które można wygodnie pobierać, modyfikować i testować. Aplikacja płacowa obecnie składa się z 3280 linii kodu, podzielonych na około 50 różnych klas i 100 różnych plików źródłowych. Chociaż nie jest to ogromna liczba, to obsługa tylu jednostek stanowi pewne obciążenie organizacyjne. Jak należy zarządzać plikami źródłowymi? Jak należy podzielić zadania implementacji, aby proces wytwarzania oprogramowania przebiegał sprawnie, a programiści nie wchodzili sobie w drogę? Chcielibyśmy podzielić klasy na grupy, które będą pozwalały indywidualnym programistom lub zespołom na wygodne pobieranie i utrzymywanie.
290
ROZDZIAŁ 22. STUDIUM PRZYPADKU: SYSTEM PŁACOWY (CZĘŚĆ 2.)
Struktura pakietów i notacja Diagram z rysunku 22.1 przedstawia możliwą strukturę pakietów dla aplikacji płacowej. Oceną, czy ta struktura jest właściwa, zajmiemy się później. Na razie ograniczymy się do tego, w jaki sposób taka struktura jest udokumentowana i wykorzystywana.
Rysunek 22.1. Możliwy diagram pakietów aplikacji płacowej
Na stronie 487 zamieszczono opis notacji UML w odniesieniu do pakietów. Zgodnie z konwencją diagramy pakietów są rysowane z zależnościami skierowanymi do dołu. Pakiety na górze zależą od pakietów narysowanych niżej. Zgodnie z rysunkiem 22.1 aplikację płacową podzielono na osiem pakietów. Pakiet PayrollApplication zawiera klasy PayrollApplication oraz klasy TransactionSource i TextParserTransactionSource. Pakiet Transactions zawiera kompletną hierarchię klasy Transaction. Jeśli uważnie przyjrzymy się diagramowi, nie powinniśmy mieć trudności z interpretacją składników innych pakietów. Zależności również powinny być czytelne. Pakiet PayrollApplication zależy od pakietu Transactions, ponieważ klasa PayrollApplication wywołuje metodę Transaction::Execute. Pakiet Transactions zależy od pakietu PayrollDatabase, ponieważ każda z wielu klas pochodnych klasy Transaction bezpośrednio komunikuje się z klasą PayrollDatabase. Pozostałe zależności mogą być uzasadnione w taki sam sposób. Jakie kryteria zostały użyte w celu pogrupowania tych klas w pakiety? Po prostu do tego samego pakietu przyporządkowałem klasy, które sprawiały wrażenie, że wykonują podobne funkcjonalności. Jak dowiedzieliśmy się w rozdziale 20., takie postępowanie nie jest najlepszym pomysłem. Zastanówmy się, co się stanie, jeśli wprowadzimy zmianę do pakietu Classifications. Wymusi to ponowną kompilację i testowanie pakietu EmployeeDatabase, co zresztą wydaje się słuszne. Ale zmiana wymusi także ponowną kompilację i testowanie pakietu Transactions. Z całą pewnością klasa ChangeClassificationTransaction oraz jej trzy klasy potomne z rysunku 19.3 powinny być ponownie skompilowane i przetestowane, ale dlaczego zmiana spowodowała również konieczność ponownej kompilacji i testowania innych modułów?
ZASTOSOWANIE ZASADY ZBIOROWEGO DOMYKANIA (CCP)
291
Z technicznego punktu widzenia te inne transakcje nie wymagają ponownej kompilacji i testowania. Jeśli jednak są one częścią pakietu Transactions oraz jeśli pakiet ma być ponownie opublikowany, aby uwzględnić zmiany wprowadzone w pakiecie Classifications, to rezygnacja z ponownej kompilacji i testowania pakietu jako całości może się wydawać nieodpowiedzialna. Nawet jeśli nie wszystkie transakcje są ponownie skompilowane i przetestowane, sam pakiet musi być ponownie opublikowany i wdrożony, a wtedy wszystkie jego aplikacje klienckie będą wymagały co najmniej weryfikacji, a prawdopodobnie również ponownej kompilacji. Klasy należące do pakietu Transactions nie mają wspólnego domknięcia. Każda z nich jest wrażliwa na własne, konkretne zmiany. Klasa ServiceChargeTransaction jest otwarta na zmiany w klasie ServiceCharge, natomiast klasa TimeCardTransaction jest otwarta na zmiany w klasie TimeCard. W rzeczywistości, jak widać na rysunku 22.1, pewna część pakietu Transactions zależy od prawie wszystkich pozostałych części oprogramowania. Zatem ten pakiet będzie musiał być bardzo często publikowany. Za każdym razem, gdy zmieni się coś w niższych warstwach, trzeba będzie zweryfikować pakiet Transactions i go ponownie opublikować. Pakiet PayrollApplication jest jeszcze bardziej wrażliwy na zmiany: każda zmiana w dowolnej części systemu będzie miała wpływ na ten pakiet, dlatego częstość jego publikowania może być ogromna. Można by pomyśleć, że jest to nieuniknione. Im wyżej pakiet znajduje się w hierarchii, tym większa częstotliwość jego publikowania. Na szczęście nie jest to prawdą, a unikanie tej własności jest jednym z głównych celów programowania obiektowego.
Zastosowanie zasady zbiorowego domykania (CCP) Rozważmy diagram z rysunku 22.2. Pogrupowano na nim klasy aplikacji płacowej zgodnie z ich domknięciami. Na przykład pakiet PayrollApplication zawiera klasy PayrollApplication i TransactionSource. Obie te klasy zależą od abstrakcyjnej klasy Transaction, która należy do pakietu PayrollDomain. Zwróćmy uwagę, że klasa TextParserTransactionSource to inny pakiet, który zależy od abstrakcyjnej klasy PayrollApplication. W ten sposób powstaje odwrócona struktura, w której klasy szczegółowe zależą od klas ogólnych, natomiast klasy ogólne są niezależne. Taka struktura jest zgodna z zasadą DIP. Najbardziej oczywistym przykładem ogólności i niezależności jest pakiet PayrollDomain. Ten pakiet zawiera esencję całego systemu, a pomimo to nie zależy od żadnego innego pakietu! Spróbujmy dokładnie przeanalizować ten pakiet. Zawiera on klasy Employee, PaymentClassification, PaymentMethod, PaymentSchedule, Affiliation oraz Transaction. Ten pakiet zawiera wszystkie najważniejsze abstrakcje w naszym modelu, a mimo to nie ma zależności! Dlaczego? Ponieważ prawie wszystkie klasy, które on zawiera, są abstrakcyjne. Rozważmy pakiet Classifications zawierający trzy klasy potomne klasy PaymentClassification. Zawiera on także klasę ChangeClassificationTransaction wraz z jej trzema klasami potomnymi oraz klasami TimeCard i SalesReceipt. Zwróćmy uwagę, że dowolna zmiana wprowadzona w tych dziewięciu klasach jest odizolowana. Nie ma wpływu na żadne pakiety oprócz pakietu TextParser! Taka izolacja dotyczy także pakietu Methods, pakietu Schedules oraz Affiliations. To dość duży poziom izolacji. Zwróćmy uwagę, że większa część kodu wykonywalnego tych pakietów ma zaledwie kilka zależności lub nie ma ich wcale. Ponieważ żadne inne pakiety nie zależą od tych pakietów, nazywamy je nieodpowiedzialnymi (ang. irresponsible). Kod należący do tych pakietów jest niezwykle elastyczny. Można go zmieniać bez wpływu na wiele innych części projektu. Zauważmy także, że najbardziej ogólne pakiety tego systemu zawierają najmniejszą ilość kodu wykonywalnego. Te pakiety są w dużym stopniu zależne, ale nie zależą od niczego. Ponieważ wiele pakietów od nich zależy, nazywamy je odpowiedzialnymi (ang. responsible), a ponieważ nie zależą od niczego, nazywamy je niezależnymi (ang. independent). Zatem ilość odpowiedzialnego kodu (czyli takiego kodu, w którym zmiany będą miały wpływ na wiele innego kodu) jest bardzo mała. Ponadto ta niewielka część kodu odpowiedzialnego jest jednocześnie niezależna, co oznacza, że zmiany wprowadzone w innych modułach nie będą powodowały konieczności ich zmiany. Ta odwrócona struktura, w której wysoce niezależne i odpowiedzialne klasy ogólne znajdują się na dole, natomiast bardzo nieodpowiedzialne i zależne klasy szczegółowe na górze, jest charakterystyczną cechą projektowania obiektowego.
292
ROZDZIAŁ 22. STUDIUM PRZYPADKU: SYSTEM PŁACOWY (CZĘŚĆ 2.)
Rysunek 22.2. Zamknięta hierarchia pakietów dla aplikacji płacowej
Spróbujmy porównać rysunek 22.1 z rysunkiem 22.2. Zwróćmy uwagę, że klasy szczegółowe zaprezentowane w dolnej części rysunku 22.1 są niezależne i w wysokim stopniu odpowiedzialne. To nie jest właściwe miejsce na szczegóły! Szczegóły powinny zależeć od ważnych decyzji architektonicznych systemu i nie powinny być uzależnione od innych części systemu. Zauważmy również, że pakiety ogólne, które definiują architekturę systemu, są nieodpowiedzialne i w wysokim stopniu zależne. Zatem pakiety określające decyzje architektoniczne zależą od pakietów, które zawierają szczegóły implementacji. To jawne naruszenie zasady SAP. Byłoby lepiej, gdyby to architektura wprowadzała ograniczenia dla szczegółów!
Zastosowanie zasady równoważności wielokrotnego wykorzystania kodu i dystrybucji (REP) Jakie części aplikacji płacowej mogą być wykorzystane wielokrotnie? Gdyby pracownicy innego działu naszej firmy chcieli skorzystać z naszego systemu płacowego, ale używali zupełnie innego zestawu strategii, nie mogliby ponownie użyć pakietów Classifications, Methods, Schedules czy Affiliations. Mogliby jednak skorzystać z pakietów PayrollDomain, PayrollApplication, Application, PayrollDatabase oraz ewentualnie PDImplementation. Z drugiej strony, gdyby pracownicy innego działu chcieli napisać oprogramowanie, które analizuje bieżącą bazę danych pracowników, mogliby użyć pakietów PayrollDomain, Classifications, Methods, Schedules, Affiliations, PayrollDatabase oraz PDImplementation. W każdym przypadku jednostką wielokrotnego użytku jest pakiet.
ZASTOSOWANIE ZASADY RÓWNOWAŻNOŚCI WIELOKROTNEGO WYKORZYSTANIA KODU I DYSTRYBUCJI (REP) 293
Bardzo rzadko, o ile w ogóle, może być wykorzystana wielokrotnie pojedyncza klasa z pakietu. Powód jest prosty: klasy należące do pakietu powinny być ze sobą spójne. Oznacza to, że zależą wzajemnie od siebie i nie mogą być w łatwy i sensowny sposób rozdzielone. Na przykład nie miałoby sensu używanie klasy Employee bez używania klasy PaymentMethod. Aby to zrobić, trzeba by zmodyfikować klasę Employee w taki sposób, aby nie była zależna od klasy PaymentMethod. Z pewnością nie chcemy wspierać takiego rodzaju ponownego użycia, które zmusza nas do modyfikowania wielokrotnie wykorzystywanych komponentów. Z tego powodu jednostką wielokrotnego użytku jest pakiet. Wynika stąd kolejne kryterium spójności, które należy stosować podczas grupowania klas w pakiety: klasy nie tylko powinny mieć wspólne domknięcie, powinny również pozwalać na wielokrotne wykorzystywanie w zgodzie z zasadą REP. Rozważmy jeszcze raz wyjściowy diagram pakietów z rysunku 22.1. Pakiety, które mogłyby być wielokrotnie użyte, takie jak Transactions czy PayrollDatabase, nie mogą być łatwo wykorzystane, ponieważ pociągają za sobą sporo dodatkowego bagażu. Pakiet PayrollApplication jest bardzo mocno uzależniony (zależy od wszystkich części systemu). Gdybyśmy chcieli stworzyć nową aplikację płacową, która używa innego zbioru harmonogramów, metod, przynależności do związków zawodowych i form zatrudnienia, nie moglibyśmy skorzystać z tego pakietu jako całości. Musielibyśmy pobrać pojedyncze klasy z pakietów PayrollApplication, Transactions, Methods, Schedules, Classifications i Affiliations. Podzielenie pakietów w taki sposób niszczy ich strukturę dystrybucji. Nie można powiedzieć, że dystrybucja 3.2 aplikacji PayrollApplication nadaje się do wielokrotnego użycia. Struktura przedstawiona na rysunku 22.1 narusza zasadę CRP. Gdyby więc użytkownik zaakceptował do wielokrotnego użytku fragmenty różnych naszych pakietów, musiałby zmierzyć się z trudnym problemem zarządzania: nie byłby w stanie utrzymać zależności od naszej struktury dystrybucji. Nowa publikacja pakietu Methods miałaby na niego wpływ ze względu na wykorzystanie klasy PaymentMethod. W większości przypadków zmiany będą wprowadzane w klasach, które nie są ponownie wykorzystywane. Pomimo to trzeba śledzić nowe numery wersji dystrybucyjnych pakietu oraz prawdopodobnie ponownie skompilować i przetestować kod. To może być tak trudne do zorganizowania, że użytkownik może się zdecydować na wykonanie kopii komponentów wielokrotnego użytku i rozwijanie tej kopii oddzielnie od naszej. Takie postępowanie nie ma wiele wspólnego z wielokrotnym używaniem. Te dwa fragmenty kodu wkrótce będą różne i będą wymagały utrzymywania niezależnie od siebie, co w efekcie podwoi potrzebne nakłady pracy. Takich problemów nie ma w przypadku zastosowania struktury pokazanej na rysunku 22.2. Pakiety należące do tej struktury są łatwiejsze do ponownego wykorzystania. Pakiet PayrollDomain nie jest obciążony tak dużym bagażem zależności. Może on być wykorzystywany niezależnie od pochodnych klas PaymentMethod, PaymentClassification, PaymentSchedule itd. Spostrzegawczy czytelnicy z pewnością zauważyli, że schemat pakietów z rysunku 22.2 nie do końca spełnia zasadę CRP. Można zauważyć, że klasy należące do pakietu PayrollDomain nie tworzą najmniejszej jednostki wielokrotnego użytku. Klasa Transaction nie musi być ponownie wykorzystywana razem z pozostałą częścią pakietu. Można by zaprojektować wiele aplikacji, które wykorzystują obiekty klasy Employee i jej pola, ale nigdy nie korzystają z obiektu Transaction. To wskazuje na konieczność wprowadzenia zmian w schemacie pakietu, tak jak pokazano na rysunku 22.3. W ten sposób oddzielamy transakcje od elementów, którymi one manipulują. Na przykład klasy należące do pakietu MethodTransactions wykonują operacje na klasach z pakietu Methods. Klasę Transaction przenieśliśmy do nowego pakietu pod nazwą TransactionApplication. Pakiet ten zawiera również klasę TransactionSource oraz klasę TransactionApplication. Te trzy klasy tworzą jednostkę wielokrotnego użytku. Klasa PayrollApplication stała się teraz jednostką scalającą. Zawiera główny program oraz pochodną klasy TransactionApplication o nazwie PayrollApplication, która łączy klasę TextParserTransactionSource z klasą TransactionApplication. Wykonanie tych manipulacji spowodowało dodanie kolejnej warstwy abstrakcji do projektu. Pakiet TransactionApplication może być teraz wykorzystywany przez dowolną aplikację, która pobiera obiekty klasy Transaction za pośrednictwem klasy TransactionSource, a następnie wykonuje na nich metodę Execute. Pakiet PayrollApplication nie może być już wielokrotnie wykorzystywany, ponieważ jest bardzo
294
ROZDZIAŁ 22. STUDIUM PRZYPADKU: SYSTEM PŁACOWY (CZĘŚĆ 2.)
mocno uzależniony od innych pakietów. Jednak zastąpił go pakiet TransactionApplication, który jest bardziej ogólny. Możemy teraz wykorzystywać pakiet PayrollDomain bez żadnych obiektów klasy Transaction. To z pewnością zwiększa możliwość wielokrotnego użycia i łatwość konserwacji projektu, ale kosztem pięciu dodatkowych pakietów i bardziej złożonej architektury zależności. Cena tego kompromisu zależy od oczekiwanego modelu wielokrotnego używania pakietów oraz tempa zmian aplikacji. Jeśli aplikacja jest bardzo stabilna i niewielu klientów z niej korzysta, to wprowadzenie tego rodzaju zmiany jest przesadą. Z drugiej strony, jeżeli wiele aplikacji będzie korzystało z tej struktury lub jeśli oczekujemy wielu zmian w aplikacji, to nowa struktura jest lepsza. Wszystko zależy od właściwej oceny sytuacji, która powinna opierać się na dokładnych danych, a nie na spekulacjach. Najlepszym rozwiązaniem jest rozpoczęcie od prostej struktury i rozwijanie jej w miarę potrzeb. Strukturę pakietów zawsze można rozwinąć, jeśli zajdzie taka konieczność.
Sprzężenia i hermetyzacja Podobnie jak sprzężenia pomiędzy klasami są zarządzane przez granice hermetyzacji w Javie i C++, tak sprzężenia pomiędzy pakietami mogą być zarządzane przez oznaczenia eksportu — tzw. ozdobniki (ang. adornments) — w notacji UML.
Rysunek 22.3. Zaktualizowany diagram pakietów aplikacji płacowej
SPRZĘŻENIA I HERMETYZACJA
295
Jeśli klasa należąca do określonego pakietu ma być wykorzystana w innym pakiecie, ta klasa musi być wyeksportowana. W notacji UML klasy są eksportowane domyślnie. Można jednak wprowadzić dodatkowe oznaczenia, które pokazują, że określone klasy pakietu nie mają być eksportowane. Z opisu pakietu Classifications na rysunku 22.4 widać, że trzy pochodne klasy PaymentClassification są eksportowane, ale klasy TimeCard i SalesReceipt nie należą do tej grupy. To oznacza, że inne pakiety nie będą w stanie korzystać z pakietów TimeCard i SalesReceipt. Te klasy są prywatne dla pakietu Classifications.
Rysunek 22.4. Klasy prywatne w pakiecie Classifications
Czasami może być korzystne ukrycie niektórych klas w obrębie pakietu, aby zapobiec niepotrzebnym sprzężeniom. Pakiet Classifications jest bardzo szczegółowy — zawiera implementacje kilku sposobów wynagrodzeń. W celu utrzymania tego pakietu w ciągu głównym chcemy ograniczyć jego sprzężenia przychodzące. Z tego powodu ukrywamy klasy, o których inne pakiety nie muszą „wiedzieć”. Dobrym przykładem klas prywatnych są klasy TimeCard i SalesReceipt. Te klasy zawierają szczegóły implementacji mechanizmów obliczania wynagrodzenia pracowników. Chcemy pozostawić swobodę zmian tych szczegółów, dlatego musimy zadbać o to, aby inne pakiety nie zależały od ich struktury. Wystarczy spojrzeć na rysunki od 19.7 do 19.10 oraz kod z listingu 19.15, aby zauważyć, że klasy TimeCardTransaction i SalesReceiptTransaction już zależą od klas TimeCard i SalesReceipt. Problem ten można łatwo rozwiązać, jak pokazano na rysunkach 22.5 i 22.6.
Rysunek 22.5. Modyfikacja klasy TimeCardTransaction w celu ochrony prywatności klasy TimeCard
Rysunek 22.6. Modyfikacja klasy SalesReceiptTransaction w celu ochrony prywatności klasy SalesReceipt
296
ROZDZIAŁ 22. STUDIUM PRZYPADKU: SYSTEM PŁACOWY (CZĘŚĆ 2.)
Metryki Jak pokazano w rozdziale 20., możemy obliczyć atrybuty spójności, sprzężeń, stabilności, ogólności i zgodności z ciągiem głównym za pomocą kilku prostych wskaźników. Zastanówmy się jednak, dlaczego to robimy? Parafrazując powiedzenie Toma DeMarco: nie można zarządzać tym, czego nie można kontrolować, a nie da się kontrolować tego, czego nie można zmierzyć1. Skuteczny inżynier oprogramowania bądź menedżer oprogramowania musi być w stanie kontrolować praktyki wytwarzania oprogramowania. Jeśli jednak nie będziemy w stanie ich mierzyć, nigdy nie będziemy potrafili ich kontrolować. Dzięki zastosowaniu heurystyki opisanej poniżej oraz obliczeniu kilku podstawowych wskaźników projektów obiektowych możemy zacząć korelować te wskaźniki z mierzoną wydajnością oprogramowania, a także wydajnością zespołów, które rozwijają to oprogramowanie. Im więcej metryk zdołamy zebrać, tym większą ilością informacji będziemy dysponowali, a co za tym idzie — będziemy mieć większą kontrolę nad oprogramowaniem. Wskaźniki opisane poniżej zostały z powodzeniem zastosowane w wielu projektach prowadzonych od 1994 roku. Istnieje kilka automatycznych narzędzi, które pozwalają je obliczać, ale wykonanie obliczeń ręcznie także nie jest trudne. Można również napisać prosty skrypt powłoki albo program w Pythonie lub Ruby, który przegląda pliki źródłowe i oblicza potrzebne wskaźniki2. H (spójność relacyjna). Jeden z aspektów spójności pakietu może być wyrażony za pomocą średniej
liczby wewnętrznych relacji na klasę. Niech R oznacza liczbę relacji klas wewnątrz pakietu (tzn. takich, które nie łączą klas na zewnątrz pakietu). Przyjmijmy ponadto, że N oznacza liczbę klas wewnątrz pakietu). Dodanie liczby 1 we wzorze zabezpiecza przed przyjęciem wartości H = 0 w przypadku, gdy N = 1. Wartość ta reprezentuje relację łączącą pakiet ze wszystkimi jego klasami.
H
Ca (sprzężenia przychodzące) można zaprezentować jako liczbę klas z innych pakietów, które zależą od klas wchodzących w skład badanego pakietu. Te zależności oznaczają takie relacje łączące klasy jak dziedziczenie i asocjacje. Ce (sprzężenia wychodzące) można reprezentować za pomocą liczby klas należących do innych pakietów, od których są uzależnione klasy wchodzące w skład badanego pakietu. Tak jak poprzednio te zależności oznaczają związki pomiędzy klasami. A (abstrakcyjność lub ogólność) można obliczyć jako iloraz liczby klas abstrakcyjnych (lub interfejsów) w pakiecie do całkowitej liczby grup (i) interfejsów wchodzących w skład tego pakietu3. Opisany wskaźnik przyjmuje wartości z przedziału od 0 do 1. A
R 1 N
Klasy abstrakcyjne Wszystkie klasy
I (niestabilność) można obliczyć jako stosunek liczby sprzężeń wychodzących do całkowitej liczby sprzężeń. Ten wskaźnik także przyjmuje wartości z przedziału od 0 do 1.
1
[DeMarco82], str. 3.
2
Przykładowy skrypt powłoki — depend.sh — można pobrać z sekcji freeware witryny www.objectmentor.com. Można również przyjrzeć się skryptowi JDepend w witrynie www.clarkware.com.
3
Można by sądzić, że lepszy wzór na wskaźnik A powinien mieć postać stosunku liczby funkcji czysto wirtualnych do wszystkich funkcji składowych w ramach pakietu. Jednak w mojej opinii ten wzór za bardzo osłabia wskaźnik abstrakcyjności. Należy pamiętać, że nawet jedna czysto wirtualna funkcja powoduje, że cała klasa jest abstrakcyjna. Fakt abstrakcyjności klasy jest bardziej znaczący od faktu, że klasa zawiera dziesiątki konkretnych funkcji — zwłaszcza jeśli programiści przestrzegają reguły DIP.
ZASTOSOWANIE WSKAŹNIKÓW DO APLIKACJI PŁACOWEJ
I
Ce Ce Ca
D (odległość od ciągu głównego). Ciąg główny jest reprezentowany przez prostą o równaniu A + I = 1. Wzór na wskaźnik D umożliwia obliczenie odległości określonego pakietu od ciągu głównego. Wskaźnik ma wartość z przedziału od 0 do około 0,74. Im bliżej do 0, tym lepiej.
D
297
A I 1 2
D' (znormalizowana odległość od ciągu głównego). Ten wskaźnik reprezentuje wskaźnik D znormalizowany do wartości z przedziału od 0 do 1. Ten wskaźnik jest nieco wygodniejszy w obliczeniach i interpretacji. Wartość zero oznacza, że pakiet należy do ciągu głównego. Wartość jeden oznacza pakiet, który jest w maksymalnym stopniu oddalony od ciągu głównego.
D ' A I 1
Zastosowanie wskaźników do aplikacji płacowej W tabeli 22.1 pokazano, jak przypisano do pakietów klasy należące do modelu aplikacji płacowej. Na rysunku 22.7 pokazano diagram pakietów dla aplikacji płacowej z obliczonymi wszystkimi wskaźnikami. Z kolei w tabeli 22.2 zamieszczono wszystkie wskaźniki obliczone dla każdego pakietu. Każdej zależności pakietu przedstawionej na rysunku 22.7 towarzyszą dwie liczby. Liczba znajdująca się najbliżej pakietu uzależnionego reprezentuje liczbę klas w tym pakiecie, które zależą od pakietu zewnętrznego. Liczba znajdująca się najbliżej pakietu, od którego pakiet zależy, reprezentuje liczbę klas w tym pakiecie, od których zależą klasy pakietu zewnętrznego. Każdemu pakietowi z rysunku 22.7 towarzyszy skojarzona z nim metryka. Wiele z tych metryk wygląda zachęcająco. Na przykład metryki PayrollApplication, PayrollDomain i PayrollDatabase mają wysoką spójność relacyjną i albo znajdują się w ciągu głównym, albo w jego pobliżu. Z kolei pakiety Classifications, Methods i Schedules, ogólnie rzecz biorąc, wykazują słabą spójność relacyjną i są prawie maksymalnie oddalone od ciągu głównego. Liczby te informują nas o tym, że przyjęty przydział klas do pakietów nie jest najlepszy. Jeśli nie znajdziemy sposobu na poprawienie tych wskaźników, nasze środowisko projektowania będzie wrażliwe na zmiany, co z kolei może się wiązać z koniecznością niepotrzebnego publikowania wersji dystrybucyjnych i testowania pakietów. W szczególności mamy do czynienia z pakietami o niskiej wartości wskaźnika abstrakcyjności, na przykład ClassificationTransactions, które zależą od innych pakietów o niskiej abstrakcyjności, jak Classifications. Klasy o niskiej abstrakcyjności zawierają większość szczegółowego kodu, dlatego są podatne na zmiany. To wymusza konieczność publikowania wersji dystrybucyjnych pakietów, które od nich zależą. Oznacza to, że pakiet ClassificationTransactions będzie miał bardzo wysoką wartość częstotliwości publikacji, ponieważ mają na niego wpływ częste zmiany wprowadzane w nim samym, jak i w pakiecie Classifications. Na tyle, na ile to możliwe, chcielibyśmy ograniczyć wrażliwość środowiska wytwarzania oprogramowania na zmiany.
4
Wykreślenie punktu na wykresie wskaźników od A do I poza kwadratem o długości 1 jest niemożliwe. To dlatego, że ani wskaźnik A, ani I nie mogą przekroczyć wartości 1. Ciąg główny jest reprezentowany przez odcinek od punktu (0,1) do punktu (1,0). Punkty wewnątrz kwadratu, które znajdują się najdalej od ciągu głównego, to dwa przeciwległe narożniki obszaru — punkty (0,0) i (1,1). Ich odległość od ciągu głównego można obliczyć za pomocą wzoru: 2 0,70710678... 2
298
ROZDZIAŁ 22. STUDIUM PRZYPADKU: SYSTEM PŁACOWY (CZĘŚĆ 2.)
Tabela 22.1. Przydział klas do pakietów w aplikacji płacowej Pakiet
Klasy w pakiecie
Affiliations
ServiceCharge
UnionAffiliation
Affiliation Transactions
ChangeAffiliation Transaction
ChangeUnaffiliated-Transaction
ChangeMember-Transaction
SalariedClassification
ServiceChargeTransaction Application
Application
Classifications
Commissioned Classification
HourlyClassification
SalesReceipt
Timecard
ChangeClassification-Transaction
ChangeCommissioned-Transaction
ChangeHourly-Transaction
ChangeSalariedTransaction
SalesReceiptTransaction
TimecardTransaction
AddCommissionedEmployee
AddEmployeeTransaction
AddHourlyEmployee
AddSalariedEmployee
ChangeAddressTransaction
ChangeEmployee-Transaction
ChangeNameTransaction
DeleteEmployeeTransaction
PaydayTransaction
Methods
DirectMethod
HoldMethod
MailMethod
MethodTransactions
ChangeDirectTransaction
ChangeHoldTransaction
ChangeMailTransaction
PaymentClassification
Classification Transaction
GeneralTransactions
ChangeMethodTransaction PayrollApplication
PayrollApplication
PayrollDatabase
PayrollDatabase
PayrollDatabase-Implementation
PayrollDatabase-Implementation
PayrollDomain
Affiliation
Employee
PaymentMethod
PaymentSchedule
Schedules
BiweeklySchedule
MonthlySchedule
WeeklySchedule
TextParser Transaction-Source
TextParserTransaction Source
TransactionApplication
TransactionApplication
Transaction
TransactionSource
Jeśli zespół projektowy składa się z dwóch lub trzech programistów, to są oni w stanie efektywnie zarządzać środowiskiem wytwarzania oprogramowania, a potrzeba utrzymywania pakietów na linii ciągu głównego lub blisko niej będzie stosunkowo niska. Jednak im więcej programistów zaangażowanych w projekt, tym trudniej zachować właściwą strukturę środowiska wytwarzania aplikacji. Ponadto nakłady pracy wymagane do uzyskiwania właściwych wartości mierników będą minimalne w porównaniu z nakładami związanymi z ponownym testowaniem i instalacją nawet jednego pakietu5. Warto się więc zastanowić, czy praca poświęcona na wyznaczenie wartości tych metryk jest krótkoterminową stratą, czy zyskiem.
5
W przypadku aplikacji płacowej ręczne wyliczanie statystyk i metryk zajęło mi około dwóch godzin. Gdybym skorzystał z jednego z powszechnie dostępnych narzędzi, potrzebne nakłady pracy na wyznaczenie odpowiednich wskaźników byłyby jeszcze niższe.
ZASTOSOWANIE WSKAŹNIKÓW DO APLIKACJI PŁACOWEJ
299
Rysunek 22.7. Diagram pakietów z metrykami Tabela 22.2. Wskaźniki przypisane do pakietów Nazwa pakietu
N
A
Ca
Ce
R
H
I
Affiliations
2
0
2
1
AffiliationTransactions
4
1
1
7
Application
1
1
1
0
0
Classifications
5
0
8
3
2
ClassificationTransaction
6
1
1
14
5
GeneralTransactions
9
2
4
12
5
Methods
3
0
4
1
0
MethodTransactions
4
1
1
6
PayrollApplication
1
0
0
2
PayrollDatabase
1
1
11
1
PayrollDatabaseImplementation
1
0
0
1
PayrollDomain
5
4
26
0
Schedules
3
0
6
TextParserTransactionSource
1
0
1
TransactionApplication
3
3
9
1
A
D
D•
1
1
0,33
0
0,47
0,67
2
0,75
0,88
0,25
0,09
0,12
1
0
1
0
0
0,06
0,27
0
0,51
0,73
1
0,93
0,17
0,07
0,10
0,67
0,75
0,22
0,02
0,03
0,33
0,20
0
0,57
0,80
3
1
0,86
0,25
0,08
0,11
0
1
1
0
0
0
0
1
0,08
1
0,06
0,08
0
1
1
0
0
0
4
1
0
0,80
0,14
0,20
1
0
0,33
0,14
0
0,61
0,86
20
0
1
0,95
0
0,03
0,05
2
1
0,1
1
0,07
0,10
300
ROZDZIAŁ 22. STUDIUM PRZYPADKU: SYSTEM PŁACOWY (CZĘŚĆ 2.)
Fabryki obiektów Od pakietów Classifications i ClassificationTransactions zależy tak wiele klas, ponieważ wewnątrz nich trzeba tworzyć egzemplarze klas. Na przykład klasa TextParserTransactionSource musi mieć możliwość tworzenia obiektów klasy AddHourlyEmployeeTransaction. Z tego powodu istnieje sprzężenie wychodzące skierowane od pakietu TextParserTransactionSource do pakietu ClassificationTransactions. Podobnie klasa ChangeHourlyTransaction musi mieć możliwość tworzenia obiektów klasy HourlyClassification, zatem istnieje sprzężenie wychodzące od pakietu ClassificationTransactions do pakietu Classifications. Niemal co drugie odwołanie do obiektów wewnątrz wymienionych pakietów odbywa się za pośrednictwem ich abstrakcyjnych interfejsów. Gdyby nie istniała potrzeba tworzenia tych konkretnych obiektów, opisane sprzężenia wychodzące z tymi pakietami również by nie istniały. Na przykład gdyby klasa TextParserTransactionSource nie musiała tworzyć obiektów różnych transakcji, nie zależałaby od czterech pakietów zawierających implementacje transakcji. Problem ten można znacząco złagodzić, stosując wzorzec projektowy Fabryka. Każdy pakiet powinien definiować fabrykę obiektów odpowiedzialną za tworzenie wszystkich publicznych obiektów wchodzących w skład wskazanego pakietu.
Fabryka obiektów dla pakietu TransactionImplementation Na rysunku 22.8 przedstawiono propozycję struktury fabryki obiektów dla pakietu TransactionImplemen tation. Pakiet TransactionFactory zawiera abstrakcyjną klasę bazową, która definiuje czysto wirtualne funkcje reprezentujące konstruktory konkretnych obiektów transakcji. Pakiet TransactionImplementation zawiera konkretną klasę potomną interfejsu TransactionFactory i wykorzystuje konkretne transakcje do tworzenia potrzebnych obiektów.
Rysunek 22.8. Fabryka obiektów dla transakcji
ZASTOSOWANIE WSKAŹNIKÓW DO APLIKACJI PŁACOWEJ
301
Interfejs TransactionFactory zawiera statyczną składową zadeklarowaną jako wskaźnik do egzemplarza tego interfejsu. Ta składowa musi zostać zainicjowana w programie głównym, aby wskazywała na egzemplarz konkretnej klasy TransactionFactoryImplementation.
Inicjowanie fabryk Aby możliwe było tworzenie obiektów za pomocą tych fabryk, statyczne składowe abstrakcyjnych fabryk obiektów będą musiały być zainicjowane w taki sposób, aby wskazywały na właściwe fabryki konkretne. Tę inicjalizację należy przeprowadzić, zanim jakikolwiek użytkownik spróbuje użyć określonej fabryki. Zazwyczaj najlepszym miejscem dla takich operacji jest program główny. Oznacza to, że jest on zależny od wszystkich fabryk oraz wszystkich konkretnych pakietów. W związku z tym każdy konkretny pakiet jest związany przynajmniej jedną relacją przychodzącą z programem głównym. To powoduje nieznaczne oddalenie konkretnych pakietów od ciągu głównego, ale nie można temu zaradzić6. Oznacza to, że program główny trzeba będzie ponownie opublikować za każdym razem, gdy zmienimy którykolwiek z konkretnych pakietów. Oczywiście konieczność ponownej publikacji programu głównego dla każdej zmiany istniałaby niezależnie od opisywanych warunków, ponieważ po wprowadzeniu zmian zawsze trzeba przeprowadzić odpowiednie testy. Na rysunkach 22.9 i 22.10 pokazano statyczną i dynamiczną strukturę programu głównego wraz z relacją z fabrykami obiektów.
Rysunek 22.9. Statyczna struktura programu głównego i fabryk obiektów
Rysunek 22.10. Dynamiczna struktura programu głównego i fabryk obiektów
Przebudowa granic spójności Początkowo odizolowaliśmy pakiety Classifications, Methods, Schedules i Affiliations (patrz rysunek 22.1). Wtedy taki podział wydawał nam się rozsądny. Zakładaliśmy, że inni użytkownicy będą chcieli skorzystać z klas harmonogramów bez używania klas reprezentujących przynależność do związków zawodowych. Opisywany podział został utrzymany także po wyodrębnieniu transakcji do osobnych pakietów. W ten sposób powstała hierarchia dualna. Być może to było zbyt dużo. Diagram przedstawiony na rysunku 22.7 jest bardzo zagmatwany.
6
Zazwyczaj ignoruję sprzężenia wychodzące z programu głównego.
302
ROZDZIAŁ 22. STUDIUM PRZYPADKU: SYSTEM PŁACOWY (CZĘŚĆ 2.)
Skomplikowane powiązania na diagramie pakietów sprawiają, że zarządzanie publikacjami wersji dystrybucyjnych staje się dużo trudniejsze (zakładając, że robimy to ręcznie). Mimo że istnieją narzędzia do automatycznego planowania projektów, które doskonale radzą sobie ze skomplikowanymi diagramami pakietów, większość z nas nie może liczyć na taki luksus. W związku z tym trzeba zadbać o maksymalną prostotę i praktyczność tworzonych diagramów pakietów. Osobiście uważam, że podział według transakcji jest ważniejszy od podziału według funkcjonalności. Dlatego postaramy się połączyć transakcje w ramach jednego pakietu TransactionImplementation (patrz rysunek 22.11). Scalimy też pakiety Classifications, Schedules, Methods i Affiliations w ramach jednego pakietu PayrolImplementation.
Ostateczna struktura pakietów W tabeli 22.3 pokazano ostateczny przydział klas do pakietów. Tabela 22.4 zawiera również arkusz metryk. Na rysunku 22.11 pokazano ostateczną strukturę pakietów wraz z fabrykami obiektów, które przybliżają konkretne pakiety do ciągu głównego.
Rysunek 22.11. Ostateczna postać struktury pakietów aplikacji płacowej
OSTATECZNA STRUKTURA PAKIETÓW
303
Tabela 22.3. Ostateczny przydział klas do pakietów w aplikacji płacowej Pakiety
Klasy w pakietach
AbstractTransactions
AddEmployeeTransaction
ChangeAffiliationTransac tion
ChangeClassificationTransaction
ChangeMethodTransaction
Application
Application
PayrollApplication
PayrollApplication
PayrollDatabase
PayrollDatabase
PayrollDatabaseImplemen tation
PayrollDatabaseImplementation
PayrollDomain
Affiliation
Employee
PaymentMethod
PaymentSchedule
ChangeEmployeeTransaction
PaymentClassification
PayrollFactory
PayrollFactory
PayrollImplementation
BiweeklySchedule
CommissionedClassificati on
DirectMethod
HoldMethod
HourlyClassification
MailMethod
MonthlySchedule
PayrollFactoryImplementation
SalariedClassification
SalesReceipt
ServiceCharge
Timecard
UnionAffiliation
WeeklySchedule
TextParser-TransactionSource
TextParserTransactionSou rce
Transaction-Application
Transaction
TransactionFactory
TransactionFactory
Transaction-Implementation
TransactionApplication
TransactionSource
AddCommissionedEmployee
AddHourlyEmployee
AddSalariedEmployee
ChangeAddressTransaction
ChangeCommissionedTransaction
ChangeDirectTransaction
ChangeHoldTransaction
ChangeHourlyTransaction
ChangeMailTransaction
ChangeMemberTransaction
ChangeNameTransaction
ChangeSalariedTransactio n
ChangeUnaffiliatedTransa ction
DeleteEmployee
PaydayTransaction
SalesReceiptTransaction
ServiceChargeTransaction
TimecardTransaction
TransactionFactoryImplementation
Wartości metryk na tym wykresie są zadowalające. Prezentowana struktura charakteryzuje się bardzo wysoką spójnością relacyjną (częściowo dzięki relacjom łączącym konkretne fabryki z tworzonymi przez nie obiektami). Nie ma również znaczących odchyleń od ciągu głównego. Oznacza to, że sprzężenia pomiędzy pakietami są odpowiednie dla dobrze zaprojektowanego środowiska. Pakiety abstrakcyjne są zamknięte, zapewniają możliwość wielokrotnego wykorzystywania i wchodzą w wiele związków zależności, ale same nie są zależne od innych pakietów. Pakiety konkretne podzielono z myślą o zapewnieniu możliwości ponownego wykorzystania, są silnie zależne od pakietów abstrakcyjnych, ale mają niewiele zależności przychodzących.
304
ROZDZIAŁ 22. STUDIUM PRZYPADKU: SYSTEM PŁACOWY (CZĘŚĆ 2.)
Tabela 22.4. Wartości wskaźników dla pakietów w aplikacji płacowej Nazwa pakietu
N
A
Ca
Ce
R
H
I
A
D
D•
AbstractTransactions
5
5
13
1
0
0,20
0,07
1
0,05
0,07
Application
1
1
1
0
0
1
0
1
0
0
PayrollApplication
1
0
0
5
0
1
1
0
0
0
PayrollDatabase
1
1
19
5
0
1
0,21
1
0,15
0,21
PayrollDatabaseImplementation
1
0
0
1
0
1
1
0
0
0
PayrollDomain
5
4
30
0
4
1
0
0,80
0,14
0,20
PayrollFactory
1
1
12
4
0
1
0,25
1
0,18
0,25
PayrollImplementation
14
0
1
5
3
0,29
0,83
0
0,12
0,17
TextParserTransactionSource
1
0
1
3
0
1
0,75
0
0,18
0,25
TransactionApplication
3
3
14
1
3
1,33
0,07
1
0,05
0,07
TransactionFactory TransactionImplementation
1
1
3
1
0
1
0,25
1
0,18
0,25
19
0
1
14
0
0,05
0,93
0
0,05
0,07
Wniosek Konieczność zarządzania strukturami komponentów powstaje zwłaszcza w dużych projektach tworzonych przez liczne zespoły programistów. Jednak nawet niewielkie zespoły muszą dzielić kod źródłowy, aby programiści nie wchodzili sobie w drogę. Duże programy, w których nie zastosowano właściwego podziału na pakiety, z czasem przyjmują formę nieczytelnej masy złożonej z wielu plików źródłowych.
Bibliografia 1. Benjamin Cummings, Object-Oriented Analysis and Design with Applications, wydanie drugie, 1994. 2. Tom DeMarco, Controlling Software Projects, Yourdon Press, 1982.
CZĘŚĆ V Studium przypadku: stacja pogodowa Kolejne rozdziały zawierają szczegółowe studium przypadku prostego systemu monitorowania pogody. Chociaż jest ono fikcyjne, zostało wykonane z wysokim stopniem realizmu. Napotkamy problemy związane z presją czasu, starszym kodem, ubogimi i zmieniającymi się specyfikacjami, nowymi, niesprawdzonymi technologiami itp. Naszym celem jest pokazanie zastosowania poznanych zasad, wzorców i praktyk w rzeczywistym świecie inżynierii oprogramowania. Tak jak poprzednio podczas prac nad stacją pogodową spotkamy kilka przydatnych wzorców projektowych. W pierwszych rozdziałach, zanim przejdziemy do sedna tego studium przypadku, zajmiemy się opisem tych wzorców.
306
ROZDZIAŁ 23. WZORZEC PROJEKTOWY KOMPOZYT
PRZYKŁAD: POLECENIA KOMPOZYTOWE
307
R OZDZIAŁ 23
Wzorzec projektowy Kompozyt
Wzorzec projektowy Kompozyt (ang. Composite) jest bardzo prosty, ale wywiera znaczący wpływ na projekt. Podstawową strukturę wzorca Kompozyt zaprezentowano na rysunku 23.1. Pokazano na nim diagram hierarchii figur geometrycznych. Klasa bazowa Shape ma dwie klasy potomne o nazwach Circle i Square. Trzecia klasa potomna reprezentuje wzorzec Kompozyt. Klasa CompositeShape zawiera listę wielu egzemplarzy klasy Shape. W przypadku wywołania metody draw() obiektu CompositeShape ta metoda jest delegowana do wszystkich egzemplarzy klasy Shape na liście.
Rysunek 23.1. Wzorzec Kompozyt
308
ROZDZIAŁ 23. WZORZEC PROJEKTOWY KOMPOZYT
Tak więc egzemplarz klasy CompositeShape z punktu widzenia systemu wygląda tak, jakby był pojedynczym obiektem Shape. Można go przekazać do dowolnej funkcji lub obiektu, które pobierają argument Shape. Obiekt ten zachowuje się tak jak zwyczajny obiekt Shape. W rzeczywistości jednak jest on pośrednikiem1 dla grupy egzemplarzy klasy Shape. Na listingach 23.1 i 23.2 zamieszczono przykład implementacji klasy CompositeShape. Listing 23.1. Shape.java public interface Shape { public void draw(); }
Listing 23.2. CompositeShape.java import java.util.Vector; public class CompositeShape implements Shape { private Vector itsShapes = new Vector(); public void add(Shape s) { itsShapes.add(s); }
}
public void draw() { for (int i = 0; i < itsShapes.size(); i++) { Shape shape = (Shape) itsShapes.elementAt(i); shape.draw(); } }
Przykład: polecenia kompozytowe Powróćmy do analizy obiektów Sensors i Command, którą przeprowadziliśmy w rozdziale 13., w podrozdziale „Proste polecenia”. Na rysunku 13.3 pokazano klasę Sensor, która korzysta z klasy Command. Kiedy obiekt klasy Sensor wykryje bodziec, wywołuje metodę do() na obiekcie Command. W tamtym przykładzie nie wspomniałem jednak, że często występują przypadki, kiedy obiekt Sensor musi wykonać więcej niż jedno polecenie. Na przykład kiedy arkusz papieru dotrze do określonego punktu, trzeba uaktywnić odpowiedni sensor optyczny. Ten sensor zatrzymuje silnik, uruchamia inny silnik i włącza właściwe sprzęgło. Początkowo założyliśmy, że każdy obiekt klasy Sensor będzie utrzymywał listę obiektów Command (patrz rysunek 23.2). Szybko jednak zdaliśmy sobie sprawę z tego, że gdy obiekt Sensor musiał uruchomić więcej niż jedno polecenie, zawsze traktował te obiekty Command identycznie. Po prostu iterował po liście obiektów Command i na każdym z nich wykonywał metodę do(). Ta sytuacja idealnie nadaje się do zastosowania wzorca projektowego Kompozyt.
Rysunek 23.2. Obiekt klasy Sensor zawierający wiele obiektów klasy Command
1
Warto zwrócić uwagę na podobieństwo struktury tego wzorca do wzorca Pełnomocnik.
WIELOKROTNOŚĆ CZY BRAK WIELOKROTNOŚCI
309
W tym przypadku pozostawimy klasę Sensor i stworzymy klasę CompositeCommand, jak pokazano na rysunku 23.3.
Rysunek 23.3. Klasa CompositeCommand
To oznacza, że nie musimy modyfikować ani klasy Sensor, ani klasy Command. Udało się obsłużyć mnogość obiektów Command przez obiekt klasy Sensor bez modyfikowania żadnej z tych klas. Jest to przykład zastosowania zasady OCP.
Wielokrotność czy brak wielokrotności Powyższy przykład prowadzi do interesującego wniosku. Udało się doprowadzić do sytuacji, w której obiekty Sensor zachowują się tak, jakby zawierały wiele obiektów Command, bez konieczności modyfikowania klasy Sensor. W praktyce wytwarzania oprogramowania takie sytuacje występują stosunkowo często. Zdarza się, że można skorzystać ze wzorca projektowego Kompozyt, zamiast budować listę wektorów bądź obiektów. Spróbujmy przedstawić to w inny sposób. Związek pomiędzy obiektami Sensor i Command to relacja jeden do jednego. Najpierw ulegliśmy pokusie, aby przekształcić ten związek do postaci jeden do wielu. Jednak zamiast tej zmiany udało się nam uzyskać zachowanie typowe dla relacji jeden do wielu bez faktycznego tworzenia tej relacji. Relacja jeden do jednego jest znacznie bardziej zrozumiała, łatwiejsza do kodowania i utrzymania w porównaniu z relacją jeden do wielu, zatem przyjęty kompromis projektowy bez wątpienia się opłacał. Zastanówmy się, jak wiele relacji jeden do wielu w projektach moglibyśmy zastąpić relacją jeden do jednego, gdybyśmy zastosowali wzorzec projektowy Kompozyt. Oczywiście nie wszystkie relacje jeden do wielu można przekształcić na relacje jeden do jednego za pomocą wzorca Kompozyt. Kandydatami do takiego przekształcenia są tylko takie relacje, w których każdy obiekt z listy jest traktowany identycznie. Na przykład gdybyśmy mieli listę pracowników i przeszukiwali tę listę po to, by znaleźć pracowników, którzy mają dziś wypłatę, prawdopodobnie nie powinniśmy używać wzorca Kompozyt, ponieważ pracownicy nie są traktowani jednakowo. Istnieje jednak pewna grupa relacji jeden do wielu, które kwalifikują się do konwersji na wzorzec projektowy Kompozyt. Korzyści wynikające z zastosowania tego wzorca są znaczące. Zamiast powielać kod związany z utrzymywaniem listy i iterowaniem po niej w każdym kliencie, ten kod można umieścić we wspólnej klasie kompozytowej.
310
ROZDZIAŁ 23. WZORZEC PROJEKTOWY KOMPOZYT
R OZDZIAŁ 24
Obserwator — ewolucja kodu do wzorca
Niniejszy rozdział ma specjalny cel. Omówimy w nim wzorzec projektowy Obserwator (ang. Observer)1, ale to cel drugorzędny. Głównym celem tego rozdziału jest zademonstrowanie sposobu przekształcenia projektu i kodu w celu zastosowania wzorca. W poprzednich rozdziałach skorzystaliśmy z wielu wzorców. Często prezentowaliśmy ich zastosowanie jako „fakt dokonany” bez pokazywania zmian w kodzie potrzebnych do tego, by można było użyć wzorca. Można było stąd wysnuć wniosek, że wzorce są gotowymi konstrukcjami, które wystarczy wstawić do kodu i projektu. Nie zalecam takiego postępowania. Osobiście preferuję przekształcanie kodu, nad którym pracuję, w odpowiednim kierunku. Podczas refaktoryzacji mającej na celu pozbycie się sprzężeń, uproszczenie kodu i poprawienie jego czytelności czasami odkrywam, że kod przyjął postać zbliżoną do określonego wzorca. Kiedy tak się dzieje, zmieniam nazwy używanych klas i zmiennych tak, aby pasowały do wzorca, a następnie zmieniam strukturę kodu tak, by można było skorzystać ze wzorca w bardziej regularnej formie. A zatem kod ewoluuje do wzorca. W niniejszym rozdziale określono prosty problem, a następnie pokazano sposób przekształcenia projektu i kodu w celu rozwiązania tego problemu. Efektem tej ewolucji będzie wzorzec projektowy Obserwator. Na każdym etapie ewolucji opiszę problemy, które staram się rozwiązać, a następnie pokażę kroki, które umożliwiają ich rozwiązanie.
Zegar cyfrowy Załóżmy, że mam obiekt reprezentujący zegar. Ten obiekt przechwytuje z systemu operacyjnego milisekundowe przerwania (znane jako tyknięcia — ang. tics) i zamienia je na aktualną godzinę. Obiekt „wie”, jak obliczyć sekundy z milisekund, minuty z sekund, godziny z minut, dni z godziny itd. „Wie” także, ile jest dni w miesiącu oraz ile jest miesięcy w roku. Potrafi obsługiwać lata przestępne oraz stosować specjalne zasady, które ich dotyczą. Obiekt zegara obsługuje wszystkie operacje związane z czasem (patrz rysunek 24.1). 1
[GOF 95], str. 293.
312
ROZDZIAŁ 24. OBSERWATOR — EWOLUCJA KODU DO WZORCA
Rysunek 24.1. Klasa Clock
Chcielibyśmy stworzyć zegar cyfrowy, który wyświetla się na pulpicie i przez cały czas pokazuje aktualną godzinę. Jaki jest najprostszy sposób realizacji tego zadania? Moglibyśmy napisać następujący kod: public void DisplayTime { while(1) { int sec = clock.getSeconds(); int min = clock.getMinutes(); int hour = clock.getHours(); showTime(hour,min,sec); } }
Powyższe rozwiązanie nie jest jednak optymalne. Taki program zużywa wszystkie dostępne cykle procesora w celu ciągłego wyświetlania godziny. W większości przypadków oznacza to marnotrawstwo, ponieważ wyświetlana godzina będzie taka sama. Takie rozwiązanie mogłoby być odpowiednie w zegarku cyfrowym lub zegarze ściennym, ponieważ oszczędzanie cykli procesora w tego rodzaju systemach nie ma znaczenia. Nie chcemy jednak, aby zegar obciążający procesor w takim stopniu działał na naszym pulpicie. Podstawowy problem polega na opracowaniu sposobu wydajnego przekazywania danych z obiektu klasy Clock do obiektu klasy DigitalClock. Zakładam, że zarówno obiekt Clock, jak i DigitalClock już istnieją. Interesuje mnie sposób ich połączenia. Połączenie to mogę przetestować, sprawdzając, czy dane otrzymane z obiektu Clock są tymi samymi danymi, które przesłałem do obiektu DigitalClock. Prostym sposobem na napisanie takiego testu jest utworzenie jednego interfejsu, który imituje zachowanie obiektu Clock, oraz drugiego, który udaje obiekt DigitalClock. Następnie można napisać specjalne obiekty testowe, które implementują te interfejsy, i sprawdzić, czy połączenie pomiędzy nimi działa zgodnie z oczekiwaniami (patrz rysunek 24.2).
Rysunek 24.2. Testowanie obiektu DigitalClock
ZEGAR CYFROWY
313
Obiekt ClockDriverTest połączy obiekt ClockDriver z dwoma obiektami-makietami za pośrednictwem interfejsów TimeSource oraz TimeSink. Następnie sprawdzi każdy z tych obiektów-makiet w celu zweryfikowania, czy obiekt ClockDriver przesłał informację o czasie od źródła do ujścia. Gdyby zachodziła taka konieczność, obiekt ClockDriverTest mógłby również sprawdzać wydajność połączenia. Sądzę, że na uwagę zasługuje fakt dodania do projektu interfejsów tylko po to, aby można było wykonać test. Aby można było przetestować moduł, musi istnieć sposób odizolowania go od innych modułów w systemie dokładnie tak, jak odizolowaliśmy obiekt ClockDriver od obiektów Clock i DigitalClock. Zastosowanie techniki „najpierw test” umożliwia zminimalizowanie ilości sprzężeń w projektach. W jaki sposób działa obiekt ClockDriver? Jest oczywiste, że aby obiekt ClockDriver działał wydajnie, musi potrafić wykryć zmianę czasu w obiekcie TimeSource. Tylko wtedy, kiedy nastąpi taka zmiana, obiekt powinien przesłać informację o czasie do obiektu TimeSink. Skąd obiekt ClockDriver dowie się, że czas się zmienił? W tym celu mógłby odpytywać obiekt TimeSource, ale to tylko odtworzyłoby problem obciążenia procesora. Najprościej by było, aby obiekt ClockDriver uzyskał informację o zmianie czasu od samego obiektu Clock. Można przekazać obiekt ClockDriver do obiektu Clock za pośrednictwem interfejsu TimeSource, a następnie, kiedy zmieni się czas, obiekt Clock może zaktualizować obiekt ClockDriver. Z kolei obiekt ClockDriver może wtedy ustawić czas w obiekcie ClockSink (patrz rysunek 24.3).
Rysunek 24.3. Wykorzystanie obiektu TimeSource w celu aktualizacji obiektu ClockDriver
Zwróćmy uwagę na zależność pomiędzy obiektami TimeSource a ClockDriver. Występuje ona, ponieważ argumentem metody setDriver jest obiekt ClockDriver. Nie jestem zadowolony z tego rozwiązania, ponieważ oznacza ono, że obiekty TimeSource za każdym razem muszą korzystać z obiektów ClockDriver. Na razie jednak odłożę rozwiązanie problemu zależności do czasu, aż program zacznie działać. Na listingu 24.1 pokazano przypadek testowy dla klasy ClockDriver. Warto zwrócić uwagę, że utworzono w nim obiekt ClockDriver i powiązano z nim obiekty MockTimeSource oraz MockTimeSink. Następnie został ustawiony czas źródła. Po ustawieniu tego czasu oczekujemy, że magicznie znajdzie się w ujściu. Pozostałą część kodu zamieszczono na listingach od 24.2 do 24.6. Listing 24.1. ClockDriverTest.java import junit.framework.*; public class ClockDriverTest extends TestCase { public ClockDriverTest(String name) {
314
ROZDZIAŁ 24. OBSERWATOR — EWOLUCJA KODU DO WZORCA
super(name); } public void testTimeChange() { MockTimeSource source = new MockTimeSource(); MockTimeSink sink = new MockTimeSink(); ClockDriver driver = new ClockDriver(source,sink); source.setTime(3,4,5); assertEquals(3, sink.getHours()); assertEquals(4, sink.getMinutes()); assertEquals(5, sink.getSeconds());
}
}
source.setTime(7,8,9); assertEquals(7, sink.getHours()); assertEquals(8, sink.getMinutes()); assertEquals(9, sink.getSeconds());
Listing 24.2. TimeSource.java public interface TimeSource { public void setDriver(ClockDriver driver); }
Listing 24.3. TimeSink.java public interface TimeSink { public void setTime(int hours, int minutes, int seconds); }
Listing 24.4. ClockDriver.java public class ClockDriver { private TimeSink itsSink; public ClockDriver(TimeSource source, TimeSink sink) { source.setDriver(this); itsSink = sink; }
}
public void update(int hours, int minutes, int seconds) { itsSink.setTime(hours, minutes, seconds); }
Listing 24.5. MockTimeSource.java public class MockTimeSource implements TimeSource { private ClockDriver itsDriver; public void setTime(int hours, int minutes, int seconds) { itsDriver.update(hours, minutes, seconds); }
}
public void setDriver(ClockDriver driver) { itsDriver = driver; }
ZEGAR CYFROWY
315
Listing 24.6. MockTimeSink.java public class MockTimeSink implements TimeSink { private int itsHours; private int itsMinutes; private int itsSeconds; public int getSeconds() { return itsSeconds; } public int getMinutes() { return itsMinutes; } public int getHours() { return itsHours; }
}
public void setTime(int hours, int minutes, int seconds) { itsHours = hours; itsMinutes = minutes; itsSeconds = seconds; }
Teraz kiedy wszystko działa, można pomyśleć o uporządkowaniu kodu. Nie podoba mi się zależność pomiędzy klasami TimeSource i ClockDriver, ponieważ chciałbym, aby z interfejsu klasy TimeSource mogły skorzystać dowolne klienty, a nie tylko obiekty klasy ClockDriver. Aby rozwiązać ten problem, można stworzyć interfejs, który klasa TimeSource wykorzystuje, natomiast klasa ClockDriver implementuje. Temu interfejsowi nadamy nazwę ClockObserver. Spójrzmy na listingi od 24.7 do 24.10. Kod, który się zmienił, oznaczono pogrubieniem.
Rysunek 24.4. Przerwanie zależności klasy TimeSource od klasy ClockDriver
316
ROZDZIAŁ 24. OBSERWATOR — EWOLUCJA KODU DO WZORCA
Listing 24.7. ClockObserver.java public interface ClockObserver { public void update(int hours, int minutes, int seconds); }
Listing 24.8. ClockDriver.java public class ClockDriver implements ClockObserver { private TimeSink itsSink; public ClockDriver(TimeSource source, TimeSink sink) { source.setObserver(this); itsSink = sink; }
}
public void update(int hours, int minutes, int seconds) { itsSink.setTime(hours, minutes, seconds); }
Listing 24.9. TimeSource.java public interface TimeSource { public void setObserver(ClockObserver observer); }
Listing 24.10. MockTimeSource.java public class MockTimeSource implements TimeSource { private ClockObserver itsObserver; public void setTime(int hours, int minutes, int seconds) { itsObserver.update(hours, minutes, seconds); }
}
public void setObserver(ClockObserver observer) { itsObserver=observer; }
To rozwiązanie jest lepsze. Teraz z klasy TimeSource może skorzystać dowolna klasa. Wystarczy, że implementuje interfejs ClockObserver oraz wywołuje metodę SetObserver, przekazując siebie jako argument. Chciałbym, aby informacje o czasie mogły być przekazane do więcej niż jednego obiektu TimeSink. Jedna aplikacja kliencka może implementować zegar cyfrowy. Inna może być wykorzystana w celu dostarczenia informacji o czasie do usługi przypominającej. Jeszcze inna może inicjować wykonywanie kopii zapasowej o ustalonej godzinie. Krótko mówiąc, chciałbym, aby jeden obiekt TimeSource mógł dostarczać informacje o czasie do wielu obiektów TimeSink. W związku z tym zmodyfikuję konstruktor klasy ClockDriver w taki sposób, żeby pobierał sam obiekt TimeSource, a następnie dodam metodę addTimeSink, która pozwala dodawać egzemplarze obiektów TimeSink w dowolnym czasie.
ZEGAR CYFROWY
317
Największą wadą przedstawionego rozwiązania jest korzystanie z podwójnego pośrednictwa. Trzeba poinformować obiekt klasy TimeSource, „kim” jest obiekt klasy ClockObserver, poprzez wywołanie metody setObserver. Trzeba także poinformować obiekt klasy ClockDriver, „kim” są egzemplarze klasy TimeSink. Czy to podwójne pośrednictwo jest naprawdę niezbędne? Jeśli przyjrzymy się klasom ClockObserver i TimeSink, możemy zauważyć, że obie zawierają metodę setTime. Wygląda na to, że klasa TimeSink mogłaby implementować interfejs ClockObserver. Gdyby tak było, to w moim programie testowym można by stworzyć obiekt klasy MockTimeSink, a następnie wywołać metodę setObserver obiektu TimeSource. Dzięki temu można by się pozbyć klasy ClockDriver (oraz interfejsu TimeSink). Zmiany wprowadzone w klasie ClockDriverTest zaprezentowano na listingu 24.11. Listing 24.11. ClockDriverTest.java import junit.framework.*; public class ClockDriverTest extends TestCase { public ClockDriverTest(String name) { super(name); } public void testTimeChange() { MockTimeSource source = new MockTimeSource(); MockTimeSink sink = new MockTimeSink(); source.setObserver(sink); source.setTime(3,4,5); assertEquals(3, sink.getHours()); assertEquals(4, sink.getMinutes()); assertEquals(5, sink.getSeconds());
}
}
source.setTime(7,8,9); assertEquals(7, sink.getHours()); assertEquals(8, sink.getMinutes()); assertEquals(9, sink.getSeconds());
Wynika stąd, że klasa MockTimeSink powinna implementować interfejs ClockObserver zamiast interfejsu TimeSink (patrz listing 24.12). Wprowadzone zmiany dobrze się sprawdziły. Zastanawiam się, dlaczego w ogóle potrzebowałem klasy ClockDriver? Odpowiedni diagram UML zamieszczono na rysunku 24.5. Listing 24.12. MockTimeSink.java public class MockTimeSink implements ClockObserver { private int itsHours; private int itsMinutes; private int itsSeconds; public int getSeconds() { return itsSeconds; } public int getMinutes() { return itsMinutes; }
318
ROZDZIAŁ 24. OBSERWATOR — EWOLUCJA KODU DO WZORCA
public int getHours() { return itsHours; }
}
public void update(int hours, int minutes, int seconds) { itsHours = hours; itsMinutes = minutes; itsSeconds = seconds; }
Rysunek 24.5. Diagram UML bez klasy ClockDriver i interfejsu TimeSink
Wyraźnie widać, że to rozwiązanie jest znacznie prostsze. Możemy teraz obsłużyć wiele obiektów TimeSink, zmieniając funkcję setObserver na registerObserver oraz dbając o to, aby wszystkie zarejestrowane egzemplarze klas implementujących interfejs ClockObserver były zapisane na liście i odpowiednio aktualizowane. To wymaga wprowadzenia jeszcze jednej zmiany w programie testowym. Odpowiednie zmiany zaprezentowano na listingu 24.13. Dodatkowo przeprowadziłem refaktoryzację programu testowego, aby kod stał się krótszy i bardziej czytelny. Listing 24.13. ClockDriverTest.java import junit.framework.*; public class ClockDriverTest extends TestCase { private MockTimeSource source; private MockTimeSink sink; public ClockDriverTest(String name) { super(name); } public void setUp() { source = new MockTimeSource(); sink = new MockTimeSink(); source.registerObserver(sink); }
{
private void assertSinkEquals( MockTimeSink sink, int hours, int minutes, int seconds) assertEquals(hours, sink.getHours());
ZEGAR CYFROWY
}
319
assertEquals(minutes, sink.getMinutes()); assertEquals(seconds, sink.getSeconds());
public void testTimeChange() { source.setTime(3,4,5); assertSinkEquals(sink, 3,4,5); source.setTime(7,8,9); assertSinkEquals(sink, 7,8,9);
} public void testMultipleSinks() { MockTimeSink sink2 = new MockTimeSink(); source.registerObserver(sink2);
}
}
source.setTime(12,13,14); assertSinkEquals(sink, 12,13,14); assertSinkEquals(sink2, 12,13,14);
Zmiany niezbędne do zastosowania nowego modelu są stosunkowo proste. Zmodyfikowaliśmy klasę MockTimeSource w taki sposób, aby wszystkie zarejestrowane obiekty obserwatorów były przechowywane w strukturze typu Vector. W chwili zmiany godziny możemy iteracyjnie przeglądać tę strukturę i wywoływać metodę update dla wszystkich zarejestrowanych egzemplarzy interfejsu ClockObserver. Wprowadzone zmiany zaprezentowano na listingach 24.14 i 24.15. Odpowiedni diagram UML zamieszczono na rysunku 24.6. Listing 24.14. TimeSource.java public interface TimeSource { public void registerObserver(ClockObserver observer); }
Listing 24.15. MockTimeSource.java import java.util.*; public class MockTimeSource implements TimeSource { private Vector itsObservers = new Vector(); public void setTime(int hours, int minutes, int seconds) { Iterator i = itsObservers.iterator(); while (i.hasNext()) { ClockObserver observer = (ClockObserver) i.next(); observer.update(hours, minutes, seconds); }
}
public void registerObserver(ClockObserver observer) { itsObservers.add(observer); }
320
ROZDZIAŁ 24. OBSERWATOR — EWOLUCJA KODU DO WZORCA
Rysunek 24.6. Obsługa wielu egzemplarzy interfejsu TimeSink
To rozwiązanie wygląda dość dobrze, ale nie podoba mi się fakt, że obiekt MockTimeSource musi zajmować się operacjami rejestracji i aktualizacji. To oznacza, że w klasie Clock oraz we wszystkich innych pochodnych interfejsu TimeSource trzeba będzie powielić kod rejestracji i aktualizacji. Uważam, że klasa Clock nie powinna zajmować się rejestracją i aktualizacją. Nie podoba mi się także fakt powielania kodu. Z tego powodu postanowiłem przenieść ten kod na poziom egzemplarzy TimeSource. To oczywiście oznacza konieczność przekształcenia interfejsu TimeSource w klasę. Oznacza to również, że klasa MockTimeSource bardzo się skróci. Wprowadzone zmiany zaprezentowano na listingach 24.16 i 24.17, natomiast właściwy diagram UML pokazano na rysunku 24.7. Listing 24.16. TimeSource.java import java.util.*; public class TimeSource { private Vector itsObservers = new Vector(); protected void notify(int hours, int minutes, int seconds) { Iterator i = itsObservers.iterator(); while (i.hasNext()) { ClockObserver observer = (ClockObserver) i.next(); observer.update(hours, minutes, seconds); } }
}
public void registerObserver(ClockObserver observer) { itsObservers.add(observer); }
Listing 24.17. MockTimeSource.java public class MockTimeSource extends TimeSource { public void setTime(int hours, int minutes, int seconds) { notify(hours, minutes, seconds); } }
ZEGAR CYFROWY
321
Rysunek 24.7. Przeniesienie operacji rejestracji i aktualizacji do klasy TimeSource
To rozwiązanie wydaje się rozsądne. Teraz z klasy TimeSource może skorzystać dowolna klasa. Aby obiekty obserwatorów zostały zaktualizowane, wystarczy wywołać metodę notify. Ale w tym kodzie w dalszym ciągu jest coś, co mi się nie podoba. Obiekt MockTimeSource dziedziczy bezpośrednio z klasy TimeSource. Oznacza to, że także klasa Clock musi dziedziczyć po klasie TimeSource. Z jakiego powodu klasa Clock miałaby zależeć od operacji rejestracji i aktualizacji? Clock jest po prostu klasą, która zarządza informacjami o czasie. Jej zależność od klasy TimeSource wydaje się niepożądana. Wiem, w jaki sposób rozwiązałbym ten problem w języku C++. Stworzyłbym wspólną podklasę klas TimeSource i Clock o nazwie ObservableClock. W klasie ObservableClock przesłoniłbym metody tic i setTime w taki sposób, aby wywoływały metody tic lub setTime obiektu Clock, a następnie wywoływały metodę notify obiektu TimeSource. Omówione rozwiązanie zaprezentowano na listingu 24.18 oraz na rysunku 24.8. Listing 24.18. ObservableClock.cc (C++) class ObservableClock : public Clock, public TimeSource { public: virtual void tic() { Clock::tic(); TimeSource::notify(getHours(), getMinutes(), getSeconds()); }
};
virtual void setTime(int hours, int minutes, int seconds) { Clock::setTime(hours, minutes, seconds); TimeSource::notify(hours, minutes, seconds); }
Rysunek 24.8. Wykorzystanie wielokrotnego dziedziczenia w języku C++ w celu odseparowania klasy Clock od klasy TimeSource
322
ROZDZIAŁ 24. OBSERWATOR — EWOLUCJA KODU DO WZORCA
Niestety, nie mamy takiej możliwości w języku Java, ponieważ w tym języku nie istnieje obsługa wielokrotnego dziedziczenia. Dlatego w Javie trzeba pozostawić rozwiązanie w takiej postaci, w jakiej jest, albo skorzystać z delegacji. Rozwiązanie z wykorzystaniem delegatów pokazano na listingach od 24.19 do 24.21 oraz na rysunku 24.9. Listing 24.19. TimeSource.java public interface TimeSource { public void registerObserver(ClockObserver observer); }
Listing 24.20. TimeSourceImplementation.java import java.util.*; public class TimeSourceImplementation { private Vector itsObservers = new Vector(); public void notify(int hours, int minutes, int seconds) { Iterator i = itsObservers.iterator(); while (i.hasNext()) { ClockObserver observer = (ClockObserver) i.next(); observer.update(hours, minutes, seconds); } }
}
public void registerObserver(ClockObserver observer) { itsObservers.add(observer); }
Listing 24.21. MockTimeSource.java public class MockTimeSource implements TimeSource { TimeSourceImplementation tsImp = new TimeSourceImplementation();
} }
public void registerObserver(ClockObserver observer) { tsImp.registerObserver(observer); } public void setTime(int hours, int minutes, int seconds) { tsImp.notify(hours, minutes, seconds);
Zwróćmy uwagę na to, że klasa MockTimeSource implementuje interfejs TimeSource oraz zawiera referencję do egzemplarza klasy TimeSourceImplementation. Zwróćmy także uwagę, że wszystkie wywołania metody registerObserver obiektu MockTimeSource są delegowane do obiektu TimeSourceImplementation. Zauważmy również, że w metodzie MockTimeSource.setTime wywołano metodę notify egzemplarza obiektu TimeSourceImplementation. To rozwiązanie nie jest eleganckie, ale jego zaletą jest to, że klasa MockTimeSource nie rozszerza innej klasy. Oznacza to, że gdyby trzeba było stworzyć klasę ObservableClock, to rozszerzałaby ona klasę Clock, implementowała interfejs TimeSource i delegowała wywołania do obiektu klasy TimeSourceImplementation (patrz rysunek 24.10). To rozwiązuje problem zależności klasy Clock od operacji rejestracji i aktualizacji, ale cena tego rozwiązania jest dość wysoka.
ZEGAR CYFROWY
323
Rysunek 24.9. Delegowanie obserwatora w Javie
Rysunek 24.10. Zastosowanie delegacji w klasie ObservableClock
Powróćmy na chwilę do rysunku 24.7 — czyli do sytuacji, zanim wpadliśmy w opisaną powyżej pułapkę. Przyjmijmy, że pogodzimy się z koniecznością zależności klasy Clock od operacji rejestracji i aktualizacji. TimeSource to niezbyt dobra nazwa, biorąc pod uwagę operacje, które klasa realizuje. Nazwa ta była właściwa wtedy, kiedy model obejmował klasę ClockDriver. Ale od tamtej pory wiele się zmieniło. Należy zmienić nazwę klasy w taki sposób, aby sugerowała, że klasa realizuje operacje rejestrowania i aktualizacji. We wzorcu projektowym Obserwator taka klasa nosi nazwę Subject. W naszym rozwiązaniu klasa spełnia specyficzne funkcje, dlatego moglibyśmy nadać jej nazwę TimeSubject, ale taka nazwa nie jest zbyt intuicyjna. Moglibyśmy użyć starego przydomka Javy Observable, ale taka nazwa także mi nie odpowiada. TimeObservable? Nie. Być może problemem jest specyfika modelu wzorca Obserwator, która w tym przypadku polega na „wypychaniu” informacji2. Gdybyśmy zastosowali model „wyciągania”, można by przekształcić klasę na generyczną. W takim przypadku moglibyśmy zmienić nazwę klasy TimeSource na Subject, a każdy, kto zna wzorzec projektowy Obserwator, wiedziałby, jaką spełnia funkcję. To nie jest złe rozwiązanie. Zamiast przekazywania informacji o godzinie za pomocą metod notify i update, możemy powierzyć zadanie odpytywania o czas klasie TimeSink, która w tym celu wyśle odpowiednie żądanie do klasy MockTimeSource. Nie chcemy, aby klasa MockTimeSink „wiedziała” cokolwiek o klasie MockTimeSource, dlatego stworzymy interfejs, który klasa MockTimeSink będzie mogła wykorzystać w celu uzyskania godziny. Ten interfejs będą implementowały klasy MockTimeSource (oraz Clock). Interfejsowi możemy nadać nazwę TimeSource. 2
W modelu obserwatora bazującym na „wypychaniu” (ang. push) dane są przesyłane do obserwatorów za pomocą metod powiadamiania i aktualizacji. W przypadku obserwatorów, które „wyciągają” informacje (jest to tzw. model pull) do metod powiadamiania i aktualizacji nie są przekazywane żadne dane. Zamiast tego obserwator musi zapytać obiekt obserwowany o potrzebne informacje. Zobacz [GOF95].
324
ROZDZIAŁ 24. OBSERWATOR — EWOLUCJA KODU DO WZORCA
Ostateczną postać kodu i diagramu UML zaprezentowano na listingach od 24.22 do 24.27 oraz na rysunku 24.11. Listing 24.22. ObserverTest.java import junit.framework.*; public class ObserverTest extends TestCase { private MockTimeSource source; private MockTimeSink sink; public ObserverTest(String name) { super(name); } public void setUp() { source = new MockTimeSource(); sink = new MockTimeSink(source); source.registerObserver(sinkprivate void assertSinkEquals( } private void assertSinkEquals( MockTimeSink sink, int hours, int minutes, int seconds) { assertEquals(hours, sink.getHours()); assertEquals(minutes, sink.getMinutes()); assertEquals(seconds, sink.getSeconds()); } public void testTimeChange() { source.setTime(3,4,5); assertSinkEquals(sink, 3,4,5);
}
source.setTime(7,8,9); assertSinkEquals(sink, 7,8,9);
public void testMultipleSinks() { MockTimeSink sink2 = new MockTimeSink(source); source.registerObserver(sink2);
}
}
source.setTime(12,13,14); assertSinkEquals(sink, 12,13,14); assertSinkEquals(sink2, 12,13,14);
Listing 24.23. Observer.java public interface Observer { public void update(); }
Listing 24.24. Subject.java import java.util.*; public class Subject { private Vector itsObservers = new Vector();
ZEGAR CYFROWY
}
protected void notifyObservers() { Iterator i = itsObservers.iterator(); while (i.hasNext()) { Observer observer = (Observer) i.next(); observer.update(); } } public void registerObserver(Observer observer) { itsObservers.add(observer); }
Listing 24.25. TimeSource.java public interface TimeSource { public int getHours(); public int getMinutes(); public int getSeconds(); }
Listing 24.26. MockTimeSource.java public class MockTimeSource extends Subject implements TimeSource { private int itsHours; private int itsMinutes; private int itsSeconds; public void setTime(int hours, int minutes, int seconds) { itsHours = hours; itsMinutes = minutes; itsSeconds = seconds; notifyObservers(); } public int getHours() { return itsHours; } public int getMinutes() { return itsMinutes; }
}
public int getSeconds() { return itsSeconds; }
Listing 24.27. MockTimeSink.java public class MockTimeSink implements Observer { private int itsHours; private int itsMinutes; private int itsSeconds; private TimeSource itsSource; public MockTimeSink(TimeSource source)
325
326
ROZDZIAŁ 24. OBSERWATOR — EWOLUCJA KODU DO WZORCA
{ }
itsSource = source;
public int getSeconds() { return itsSeconds; } public int getMinutes() { return itsMinutes; } public int getHours() { return itsHours; }
}
public void update() { itsHours = itsSource.getHours(); itsMinutes = itsSource.getMinutes(); itsSeconds = itsSource.getSeconds(); }
Rysunek 24.11. Ostateczna wersja wzorca projektowego Obserwator zastosowana do klas MockTimeSource i MockTimeSink
Wniosek Zatem skończyliśmy. Zaczęliśmy od zaprezentowania problemu projektowego, a następnie w wyniku stosowania ewolucyjnych zmian doszliśmy do kanonicznej postaci wzorca projektowego Obserwator. Niektórzy czytelnicy pewnie uważają, że skoro wiedziałem od początku, że chcę dojść do wzorca Obserwator, to tak zorganizowałem projekt, aby uzyskać założony efekt. Nie będę zaprzeczał. Ale to nie jest najważniejsze. Programiści znający wzorce projektowe mogą po nie sięgać w miarę potrzeb. Zwykle przychodzą im one na myśl po napotkaniu konkretnego problemu projektowego. Warto zadać sobie wtedy pytanie, czy należy bezpośrednio zaimplementować określony wzorzec, czy też wykonać szereg małych kroków, aby doprowadzić projekt do pożądanej postaci. W niniejszym rozdziale zaprezentowaliśmy tę drugą
WZORZEC PROJEKTOWY OBSERWATOR
327
opcję. Zamiast zakładać z góry, że wzorzec Obserwator najbardziej nadaje się do rozwiązania problemu, powoli rozwijaliśmy kod, realizując kolejne fragmenty projektu. W końcu stało się dość oczywiste, że kod zmierza w kierunku wzorca Obserwator, dlatego zmieniłem nazwy i doprowadziłem kod do postaci kanonicznej. Na każdym etapie tej ewolucji mogłem uznać, że problem został rozwiązany, i zaprzestać wprowadzania kolejnych przekształceń. Mogłem także dojść do wniosku, że warto zmienić kurs i pójść w innym kierunku.
Wykorzystanie diagramów w tym rozdziale Niektóre z diagramów, które zostały zaprezentowane w tym rozdziale, narysowałem po to, aby ułatwić czytelnikom zrozumienie tematu. Uznałem, że będzie łatwiej śledzić to, co robię, jeśli zaprezentuję odpowiedni diagram. Gdybym nie próbował pokazywać i objaśniać projektu, nie tworzyłbym diagramów. Jednak kilka spośród pokazanych diagramów wykonałem na własny użytek. Czasami po prostu potrzebowałem spojrzeć na strukturę, którą stworzyłem, abym mógł podjąć decyzję, gdzie pójść dalej. Gdybym nie pisał książki, narysowałbym te diagramy ręcznie na kartce papieru lub na tablicy. Nie marnowałbym czasu na rysowanie ich w aplikacji do tworzenia ilustracji. Nie znam sytuacji, gdy stworzenie rysunku za pomocą programu do rysowania udaje się szybciej niż na skrawku papieru. Po skorzystaniu ze schematów, które pomogły mi stworzyć kod, nie widzę powodu, aby je przechowywać. W każdym przypadku diagramy, które narysowałem dla siebie, były etapem pośrednim. Czy przechowywanie diagramów na tym poziomie szczegółowości przynosi jakąś wartość? Jest oczywiste, że jeśli ktoś chce zilustrować swoje rozumowanie tak, jak ja to robię w tej książce, to tego rodzaju diagramy są bardzo przydatne. Zazwyczaj jednak nie próbujemy dokumentować ewolucyjnej ścieżki kilku godzin kodowania. Zwykle takie schematy mają wartość przejściową i najlepiej je wyrzucić. Na tym poziomie szczegółowości kod jest na ogół wystarczająco dobry w roli własnej dokumentacji. Na wyższych poziomach to nie zawsze jest prawdą.
Wzorzec projektowy Obserwator Gdy udało się przeanalizować przykład i doprowadzić kod do postaci wzorca projektowego Obserwator, może być interesujące przeanalizowanie tego, czym właściwie jest ten wzorzec. Kanoniczną postać wzorca Obserwator zaprezentowano na rysunku 24.12. W tym przykładzie klasa Clock jest obserwowana przez klasę DigitalClock. Klasa DigitalClock rejestruje interfejs Subject klasy Clock. Obiekt klasy Clock wywołuje metodę notify klasy Subject za każdym razem, gdy z jakiegoś powodu zmieni się godzina. Metoda notify klasy Subject wywołuje metodę update dla każdego zarejestrowanego obiektu klasy Observer. W związku z tym obiekt klasy DigitalClock otrzyma komunikat update za każdym razem, gdy zmieni się godzina. Ta sytuacja stwarza okazję do zapytania obiektu klasy Clock o godzinę i wyświetlenia jej.
Rysunek 24.12. Kanoniczna postać modelu „ciągnij” wzorca projektowego Obserwator
328
ROZDZIAŁ 24. OBSERWATOR — EWOLUCJA KODU DO WZORCA
Wzorzec projektowy Obserwator jest jednym z tych wzorców, które — jeśli się je zrozumie — są wykorzystywane wszędzie. Pośrednictwo oferowane przez ten wzorzec jest bardzo atrakcyjne. Można zarejestrować obserwatory dla wszelkiego rodzaju obiektów, zamiast pisać metody na poziomie tych obiektów. Chociaż to pośrednictwo jest przydatnym sposobem zarządzania zależnościami, można łatwo przesadzić z jego wykorzystywaniem. Nadużywanie wzorca projektowego Obserwator sprawia, że systemy stają się bardzo trudne do zrozumienia i analizy. Modele „wypychaj” i „ciągnij”. Istnieją dwa zasadnicze modele wzorca projektowego Obserwator. Na rysunku 24.13 pokazano model „ciągnij” (ang. pull). Nazwa pochodzi stąd, że obiekt klasy DigitalClock musi „wyciągać” informacje o godzinie z obiektu Clock po odebraniu komunikatu update.
Rysunek 24.13. Model „wypychaj” wzorca projektowego Obserwator
Zaletą modelu „ciągnij” jest prostota jego implementacji oraz fakt, że klasy Subject i Observer mogą być standardowymi elementami wielokrotnego użytku w bibliotece. Wyobraźmy sobie jednak, że obserwujemy rekord pracownika z tysiącami pól i że właśnie otrzymaliśmy komunikat update. Które z tysiąca pól się zmieniło? W przypadku wywołania metody update obiektu ClockObserver odpowiedź jest oczywista. Obiekt klasy ClockObserver musi „wyciągnąć” godzinę z obiektu klasy Clock, a następnie tę godzinę wyświetlić. Kiedy jednak wywołamy metodę update na obiekcie klasy EmployeeObserver, odpowiedź nie jest już taka oczywista. Nie wiemy, co się wydarzyło. Nie wiemy, co zrobić. Być może zmieniło się nazwisko pracownika, a może to było jego wynagrodzenie. Być może zmienił się szef. A może chodzi o rachunek bankowy. Potrzebujemy pomocy. Tę pomoc możemy uzyskać dzięki modelowi „pchaj” wzorca projektowego Obserwator. Strukturę modelu „pchaj” wzorca projektowego Obserwator pokazano na rysunku 24.13. Zwróćmy uwagę na to, że zarówno metoda notify, jak i metoda update pobierają argument. Argument jest wskazówką przekazaną od obiektu Employee do obiektu SalaryObserver za pośrednictwem metod notify i update. Ta wskazówka informuje obiekt SalaryObserver o rodzaju zmiany, jaka nastąpiła w rekordzie Employee. Argument EmployeeObserverHint metod notify i update może być wartością jakiegoś typu wyliczeniowego, łańcuchem znaków lub bardziej złożoną strukturą danych zawierającą starą i nową wartość wskazanego pola. Niezależnie od tego, co to jest, wartość ta jest „wypychana” do obserwatora. Wybór pomiędzy dwoma modelami wzorca projektowego Obserwator zależy od złożoności obserwowanego obiektu. Jeśli obserwowany obiekt jest złożony, a obserwator potrzebuje wskazówki, to odpowiedni jest model „pchaj”. Jeśli obserwowany obiekt jest prosty, wtedy dobrze się sprawdza model „ciągnij”.
Zarządzanie zasadami projektu obiektowego dla wzorca projektowego Obserwator Ogólnie rzecz biorąc, wzorcem projektowym Obserwator rządzi zasada otwarte-zamknięte (OCP). Motywacją dla zastosowania wzorca jest możliwość dodania nowych obiektów obserwujących bez konieczności wprowadzania zmian w obiekcie obserwowanym. Tak więc obiekt obserwowany pozostaje zamknięty.
BIBLIOGRAFIA
329
Wystarczy spojrzeć na rysunek 24.12, aby zauważyć, że obiekt klasy Clock można podstawić za obiekt Subject, natomiast obiekt klasy DigitalClock za obiekt Observer. A zatem zastosowano zasadę podstawiania Liskov (LSP). Observer jest klasą abstrakcyjną, a konkretna klasa DigitalClock od niej zależy. Zależą od niej także konkretne metody klasy Subject. A zatem w tym przypadku zastosowano zasadę odwracania zależności (DIP). Można by sądzić, że skoro klasa Subject nie zawiera metod abstrakcyjnych, to zależność pomiędzy klasami Clock i Subject narusza zasadę DIP. Jednak Subject jest klasą, dla której nigdy nie należy tworzyć egzemplarzy. Klasa ta ma sens wyłącznie w kontekście klasy pochodnej. Tak więc klasa Subject jest logicznie abstrakcyjna pomimo tego, że nie zawiera metod abstrakcyjnych. Abstrakcyjność klasy Subject można wymusić w języku C++ poprzez zdefiniowanie czysto wirtualnego destruktora albo poprzez zadeklarowanie konstruktorów tej klasy z modyfikatorem protected. Na rysunku 24.11 można również zauważyć ślady stosowania zasady segregacji interfejsów (ISP). Klasy Subject i TimeSource segregują klienty klasy MockTimeSource, dostarczając specjalizowanych interfejsów dla każdej grupy klientów.
Bibliografia 1. Gamma, et al. Design Patterns, Addison-Wesley, 1995. 2. Robert C. Martin, et al., Pattern Languages of Program Design 3, Addison-Wesley, 1998.
330
ROZDZIAŁ 24. OBSERWATOR — EWOLUCJA KODU DO WZORCA
R OZDZIAŁ 25
Wzorce projektowe Serwer abstrakcyjny i Most
Politycy wszędzie są tacy sami. Obiecują zbudować most nawet tam, gdzie nie ma rzeki — Nikita Chruszczow
W połowie lat dziewięćdziesiątych mocno angażowałem się w dyskusje prowadzone w ramach grupy dyskusyjnej comp.object. Osoby, które publikowały wiadomości w tej grupie dyskusyjnej, spierały się na tematy dotyczące różnych strategii analizy i projektowania. W pewnym momencie zdecydowaliśmy, że konkretny przykład pomoże nam ocenić stanowisko każdego z nas. W związku z tym wybraliśmy bardzo prosty problem projektowy i przystąpiliśmy do prezentowania naszych ulubionych rozwiązań. Problem projektowy był bardzo prosty. Postanowiliśmy zaprojektować oprogramowanie sterujące prostą lampą biurkową. Lampa biurkowa zawiera przełącznik i żarówkę. Można „zapytać” przełącznik, czy jest w stanie włączenia, czy wyłączenia; można też zlecić żarówce, by się zaświeciła lub wyłączyła. Przyjemny, prosty problem do rozwiązania. Dyskusje trwały miesiącami. Każdy z uczestników starał się zasugerować, że wybrany przez niego styl projektowania jest lepszy od tych, które zaproponowali inni. Niektórzy zaproponowali proste podejście polegające na stworzeniu obiektów switch i light. Inni byli zdania, że w rozwiązaniu powinien znaleźć się obiekt lampy zawierający obiekty przełącznika i żarówki. Jeszcze inni sugerowali konieczność zdefiniowania obiektu opisującego elektryczność. Jedna z osób zasugerowała nawet zdefiniowanie obiektu dla przewodu elektrycznego.
332
ROZDZIAŁ 25. WZORCE PROJEKTOWE SERWER ABSTRAKCYJNY I MOST
Pomimo absurdalności większości tych argumentów analiza modelu projektowego w tym przykładzie może być interesująca. Spójrzmy na rysunek 25.1. Z pewnością bez trudu uda nam się zaimplementować ten projekt. Obiekt Switch może odpytywać o stan przełącznika, a następnie wysyłać właściwe komunikaty turnOn i turnOff do obiektu Light.
Rysunek 25.1. Prosty model oprogramowania lampy biurkowej
Co nam się nie podoba w tym projekcie? Powyższy projekt narusza dwie zasady projektu obiektowego: zasadę odwracania zależności (DIP) oraz zasadę otwarte-zamknięte (OCP). Naruszenie zasady DIP można bardzo łatwo dostrzec — istnieje zależność pomiędzy konkretnymi klasami Switch i Light. Zgodnie z zasadą DIP powinniśmy tworzyć zależności od klas abstrakcyjnych. Naruszenie zasady OCP jest nieco mniej bezpośrednie, ale również można je dostrzec. Nie podoba nam się ten projekt, ponieważ zmusza nas do „ciągnięcia” ze sobą klasy Light wszędzie tam, gdzie potrzebujemy klasy Switch. Nie można w łatwy sposób rozszerzyć klasy Switch tak, aby mogła sterować obiektami innymi niż Light.
Wzorzec projektowy Serwer abstrakcyjny Można by sądzić, że dałoby się wydzielić podklasę klasy Switch, która służyłaby do sterowania innym obiektem niż Light w sposób podobny do pokazanego na rysunku 25.3. To jednak nie rozwiązałoby problemu, ponieważ klasa FanSwitch w dalszym ciągu dziedziczy zależność od klasy Light. Wszędzie, gdzie chcielibyśmy zastosować klasę FanSwitch, musielibyśmy „zabrać ze sobą” klasę Light. Taka relacja dziedziczenia również narusza zasadę DIP. W celu rozwiązania problemu zastosujemy jeden z najprostszych wzorców projektowych: Serwer abstrakcyjny (ang. Abstract server) — patrz rysunek 25.2. Dzięki wprowadzeniu interfejsu pomiędzy klasy Switch i Light zapewniliśmy możliwość sterowania przez klasę Switch dowolną klasą, która implementuje ten interfejs. Dzięki temu natychmiast spełniliśmy zarówno zasadę DIP, jak i OCP.
Rysunek 25.2. Zły sposób rozszerzenia klasy Switch
Rysunek 25.3. Rozwiązanie problemu oprogramowania lampki biurkowej z wykorzystaniem wzorca projektowego Serwer abstrakcyjny
WZORZEC PROJEKTOWY ADAPTER
333
Kto jest właścicielem interfejsu? Zwróćmy uwagę na fakt, że nazwa interfejsu pochodzi od klienta. Interfejs nazywa się Switchable zamiast ILight. Pisaliśmy o tym już wcześniej i prawdopodobnie będziemy jeszcze wracać do tego tematu. Interfejsy należą do klientów, a nie do klas pochodnych. Powiązanie logiczne pomiędzy klientem a interfejsem jest silniejsze niż powiązanie logiczne pomiędzy klasą Switch, interfejsem i jego pochodnymi. Powiązanie to jest na tyle silne, że używanie klasy Switch bez klasy implementującej interfejs Switchable nie ma sensu, natomiast jak najbardziej sensowne jest skorzystanie z klasy implementującej interfejs Switchable bez klasy Light. Siła powiązań logicznych jest sprzeczna z siłą powiązań fizycznych. Dziedziczenie jest znacznie silniejszym powiązaniem fizycznym od standardowej relacji. Na początku lat dziewięćdziesiątych uważano, że w projektach decydujące znaczenie powinny mieć związki fizyczne. W renomowanych publikacjach zalecano, aby hierarchie dziedziczenia były umieszczone w tym samym fizycznym pakiecie. To wydawało się sensowne, ponieważ dziedziczenie jest bardzo silnym związkiem fizycznym. Jednak w ciągu ostatnich dekad dowiedzieliśmy się, że fizyczna siła dziedziczenia jest myląca, a hierarchie dziedziczenia zazwyczaj nie powinny być umieszczane we wspólnym pakiecie. Zamiast tego w pakiecie powinny znaleźć się klienty razem z interfejsami, które one wykorzystują. To niedopasowanie siły powiązań logicznych i fizycznych jest artefaktem języków o statycznej kontroli typów, takich jak Java i C++. W językach z dynamiczną kontrolą typów, na przykład Smalltalk, Python i Ruby, takie niedopasowanie nie istnieje, ponieważ w celu osiągnięcia polimorficznego zachowania w tych językach nie jest stosowane dziedziczenie.
Wzorzec projektowy Adapter W projekcie zaprezentowanym na rysunku 25.3 występuje pewien problem. Projekt ten potencjalnie narusza zasadę pojedynczej odpowiedzialności (SRP). Klasy Light i Switchable są ze sobą powiązane, a być może ich zmiany nie będą wynikały z tych samych powodów. Co się stanie, jeśli nie będziemy mogli dodać związku dziedziczenia do klasy Light? Co zrobić, jeśli klasę Light zamówiliśmy od programisty z zewnątrz i nie mamy kodu źródłowego? Albo jak sobie poradzić, jeśli mamy klasę, która ma być kontrolowana przez klasę Switch, a nie możemy zapewnić jej dziedziczenia po interfejsie Switchable? Do tego rodzaju celów można zastosować wzorzec projektowy Adapter1. Na rysunku 25.4 pokazano, jak można zastosować wzorzec projektowy Adapter do rozwiązania naszego problemu. Adapter dziedziczy po interfejsie Switchable i deleguje wywołania do klasy Light. To rozwiązuje nasz problem. Teraz możemy użyć obiektu klasy Switch do kontrolowania dowolnego obiektu, który można włączyć lub wyłączyć. Wystarczy tylko stworzyć odpowiedni adapter. W rzeczywistości obiekty nie muszą nawet zawierać takich samych metod turnOn i turnOff, jakie ma interfejs Switchable. Adapter można zaadaptować do interfejsu obiektu.
Rysunek 25.4. Rozwiązanie problemu sterowania lampą biurową z wykorzystaniem wzorca projektowego Adapter 1
Wzorzec projektowy Adapter był prezentowany wcześniej, na rysunkach 10.2 i 10.3.
334
ROZDZIAŁ 25. WZORCE PROJEKTOWE SERWER ABSTRAKCYJNY I MOST
Nie ma nic za darmo. Zastosowanie wzorca projektowego Adapter wiąże się z kosztami. Trzeba napisać nową klasę, stworzyć egzemplarz adaptera i powiązać z nim adaptowany obiekt. Następnie za każdym razem, kiedy wywołujemy adapter, musimy ponieść koszty czasu i przestrzeni wymaganych do zaimplementowania delegacji. Zatem wyraźnie widać, że adapterów nie należy stosować zawsze. Do rozwiązania większości problemów dobrze nadaje się wzorzec projektowy Serwer abstrakcyjny. Nawet wyjściowe rozwiązanie pokazane na rysunku 25.1 było dobre tak długo, aż dowiedzieliśmy się, że obiekt klasy Switch może być użyty do sterowania innymi obiektami.
Wzorzec projektowy Adapter w formie klasy Rozwiązanie z klasą LightAdapter pokazane na rysunku 25.4 jest znane jako obiektowa forma adaptera. Istnieje inne podejście znane jako klasowa forma adaptera, które pokazano na rysunku 25.5. W tej formie obiekt adaptera dziedziczy zarówno po interfejsie Switchable, jak i po klasie Light. Ta postać jest nieco wydajniejsza od formy obiektowej oraz jest trochę łatwiejsza do zastosowania, ale kosztem wysokiego poziomu sprzężeń związanych z dziedziczeniem.
Rysunek 25.5. Rozwiązanie problemu sterowania lampą biurową z wykorzystaniem wzorca projektowego Adapter w postaci klasowej
Problem modemu. Adaptery i zasada LSP Rozważmy sytuację z rysunku 25.6. Przedstawiono na nim wiele modemów, które korzystają z interfejsu Modem. Interfejs Modem jest zaimplementowany przez kilka klas pochodnych, takich jak HayesModem, USRoboticsModem oraz EarniesModem. To dość częsta sytuacja. Zaprezentowany model spełnia zasady OCP, LSP i DIP. Opracowanie nowych rodzajów modemów nie ma wpływu na aplikacje klienckie interfejsu Modem. Przypuśćmy, że taka sytuacja trwa kilka lat. Załóżmy, że istnieją setki aplikacji klienckich, które z powodzeniem korzystają z interfejsu Modem.
Rysunek 25.6. Problem modemu
WZORZEC PROJEKTOWY ADAPTER
335
Przypuśćmy teraz, że otrzymaliśmy nowe wymaganie od klientów. Istnieje pewna grupa modemów, które nie wybierają numeru. Są one określane jako tzw. modemy dedykowane, ponieważ instaluje się je po obu stronach dedykowanego połączenia2. Jest kilka nowych aplikacji, które używają tych dedykowanych modemów i nie wybierają numerów. Aplikacjom tym nadamy nazwę DedUsers. Jednak nasi klienci chcą, aby wszystkie istniejące klienty modemu mogły korzystać z tych dedykowanych modemów. Dodatkowo powiedzieli nam, że nie chcą modyfikować setek aplikacji klienckich modemu, więc te klienty interfejsu Modem będą wybierały nieistotne numery telefonów. Gdybyśmy to my mieli podjąć decyzję, moglibyśmy zmodyfikować projekt systemu w sposób pokazany na rysunku 25.7. Moglibyśmy zażądać od dostawcy usług internetowych podziału funkcji wybierania numerów i funkcji komunikacyjnych na dwa oddzielne interfejsy. Stare modemy implementowałyby oba interfejsy, a aplikacje klienckie modemów korzystałyby z tych interfejsów. Aplikacje DedUser korzystałyby tylko z interfejsu Modem, natomiast klasa DedicatedModem implementowałaby wyłącznie interfejs Modem. Niestety to wymagałoby wprowadzenia modyfikacji we wszystkich aplikacjach korzystających z modemów. Tego jednak zabronili nam nasi klienci.
Rysunek 25.7. Idealne rozwiązanie problemu modemu
Co można zrobić w takiej sytuacji? Nie możemy rozdzielić interfejsów tak, jak chcieliśmy, a pomimo to musimy znaleźć sposób na to, by wszystkie klienty mogły skorzystać z klasy DedicatedModem. Jednym z możliwych rozwiązań jest dziedziczenie przez klasę DedicatedModem po klasie Modem i zaimplementowanie pustych metod dial i hangup: class DedicatedModem { public: virtual void virtual void virtual void {...} virtual char {...} };
2
public : Modem dial(char phoneNumber[10]) {} hangup() {} send(char c) receive()
Kiedyś wszystkie modemy były dedykowane. Dopiero w ostatnich epokach geologicznych modemy uzyskały zdolność wybierania numerów. We wczesnym okresie jurajskim można było wypożyczyć sporej wielkości modem od firmy telefonicznej i podłączyć go do innego modemu za pomocą dedykowanych łączy, które również dzierżawiło się od firmy telefonicznej (okres jurajski był bardzo dobry dla firm telefonicznych). Jeśli ktoś chciał wybierać numery, musiał wydzierżawić inne urządzenie — tzw. auto dialer.
336
ROZDZIAŁ 25. WZORCE PROJEKTOWE SERWER ABSTRAKCYJNY I MOST
Zdegenerowane funkcje mogą być symptomem naruszenia zasady LSP. Użytkownicy klasy bazowej mogą oczekiwać od metod dial i hangup znaczącej zmiany stanu modemu. Zdegenerowane implementacje w klasie DedicatedModem mogą przeszkadzać w spełnieniu tych oczekiwań. Załóżmy, że aplikacje klienckie oczekują od modemów pozostawania w stanie uśpienia do chwili wywołania metody dial oraz powrotu do stanu spoczynku po wywołaniu metody hangup. Innymi słowy, aplikacje nie spodziewają się od modemów wysyłania żadnych znaków przed wybraniem numeru. Klasa DedicatedModem narusza to oczekiwanie. Zwraca znaki przed wywołaniem metody dial oraz kontynuuje ich wysyłanie nawet po wywołaniu metody hangup. Z tego względu klasa DedicatedModem może doprowadzić do nieprawidłowego działania niektórych klientów modemów. Niektórzy pewnie twierdzą, że problem leży po stronie aplikacji klienckich modemów. Nie zostały napisane dobrze, jeśli wykazują błędy w przypadku przekazania nieoczekiwanych danych wejściowych. Zgadzam się z tym poglądem. Trudno będzie jednak przekonać osoby odpowiedzialne za utrzymywanie klientów modemów do wprowadzenia zmian w oprogramowaniu z powodu dodania nowego rodzaju modemu. To nie tylko narusza zasadę OCP, ale jest po prostu frustrujące. A poza tym nasz klient wyraźnie zabronił nam wprowadzania zmian w aplikacjach klienckich. Rozwiązanie bazujące na „prowizorce”. Możemy zasymulować status połączenia w metodach dial i hangup klasy DedicatedModem. Możemy odmówić zwracania znaków w przypadku, gdy nie wywołano metody dial lub po wywołaniu metody hangup. Wprowadzenie tego rodzaju zmian pozwoli na uniknięcie modyfikowania klientów modemów. Trzeba tylko przekonać programistów aplikacji DedUsers do wywoływania metod dial i hangup (patrz rysunek 25.8).
Rysunek 25.8. Rozwiązanie problemu modemu poprzez symulowanie stanu połączenia w klasie DedicatedModem
Łatwo sobie wyobrazić, że twórcy aplikacji DedUsers nie będą zadowoleni z takiego rozwiązania. W swoich aplikacjach jawnie korzystają z klasy DedicatedModem. Dlaczego mają być zmuszeni do wywoływania metod dial i hangup? Jednak ci programiści jeszcze nie zakończyli pracy nad swoim oprogramowaniem, więc łatwiej będzie ich przekonać do wprowadzenia odpowiednich modyfikacji. Złożona pajęczyna zależności. Kilka miesięcy później, kiedy już będą istniały setki aplikacji DedUsers, nasi klienci poproszą nas o wprowadzenie nowej zmiany. Wydaje się, że przez te wszystkie lata nasze programy nie musiały wybierać międzynarodowych numerów telefonów. Dlatego właśnie metoda dial pobierała argument typu char[10]. Teraz nasi klienci chcą mieć możliwość wybierania numerów telefonicznych o dowolnej długości. Chcą realizować połączenia międzynarodowe, płacić za połączenia kartami kredytowymi, wykonywać połączenia identyfikowane za pomocą numerów PIN itp.
WZORZEC PROJEKTOWY ADAPTER
337
Zastosowanie takiego rozwiązania zmusza do wprowadzenia zmian we wszystkich aplikacjach klienckich. Aplikacje te oczekują reprezentowania numerów telefonów za pomocą tablic char[10]. Nasz klient zaakceptował to rozwiązanie z braku innego wyjścia. Z tego powodu całe rzesze programistów zostały zaangażowane do wprowadzenia odpowiednich zmian w swoich aplikacjach. Nie ma wątpliwości, że trzeba również zmodyfikować klasy w hierarchii modemów, aby uwzględnić nowy rozmiar numerów telefonów. Z tym zadaniem może sobie poradzić nasz niewielki zespół. Niestety, musimy teraz zwrócić się do autorów aplikacji DedUsers i powiedzieć im, że powinni zmodyfikować swój kod! Łatwo sobie wyobrazić, że nie będą z tego zadowoleni. W końcu metoda dial w ogóle nie jest im potrzebna. Wywołują ją tylko dlatego, ponieważ powiedzieliśmy im, że muszą to robić. A teraz ponownie muszą wprowadzić kosztowne zmiany, ponieważ zrobili to, o co ich poprosiliśmy. Jest to rodzaj kłopotliwej plątaniny zależności, z którą można się spotkać w wielu projektach. Prowizorka w jednej części systemu tworzy sieć zależności, która w końcu powoduje problemy w innej części systemu, całkowicie niezwiązanej z tą, w której wprowadziliśmy zmiany. Wzorzec projektowy Adapter w roli koła ratunkowego. Moglibyśmy zapobiec problemom, które napotkaliśmy, gdybyśmy do rozwiązania pierwotnego problemu zastosowali wzorzec projektowy Adapter (patrz rysunek 25.9). W zaprezentowanym modelu klasa DedicatedModem nie dziedziczy po interfejsie Modem. Klienty modemu wykorzystują klasę DedicatedModem za pośrednictwem klasy DedicatedModemAdapter. Obiekt tej klasy implementuje metody dial i hangup w celu zasymulowania stanu połączenia. Adapter deleguje wywołania send i receive do klasy DedicatedModem.
Rysunek 25.9. Rozwiązanie problemu modemu dzięki zastosowaniu wzorca projektowego Adapter
Zwróćmy uwagę, że zaproponowane rozwiązanie eliminuje wszystkie nasze wcześniejsze problemy. Klienty interfejsu Modem mają dostęp do zachowań połączeń zgodnie ze swoimi oczekiwaniami, natomiast aplikacje DedUser nie muszą używać metod dial i hangup. Zmiany w wymaganiach dotyczących numerów telefonów nie będą miały wpływu na aplikacje DedUser. Dzięki zastosowaniu wzorca projektowego Adapter udało się zapewnić zgodność zarówno z zasadą LSP, jak i OCP. Trzeba jednak pamiętać, że prowizorka dalej istnieje. Adapter w dalszym ciągu symuluje stan połączenia. Wiele osób pomyśli pewnie, że to rozwiązanie jest mało eleganckie. Oczywiście zgodzę się z tymi osobami. Warto jednak zwrócić uwagę na to, że wszystkie zależności są skierowane na zewnątrz adaptera (są to zależności wychodzące). Prowizorka (symulowanie stanu połączenia) jest odizolowana od systemu — zaimplementowano ją wewnątrz adaptera, z którego istnienia niewiele osób zdaje sobie sprawę. Jedyną silną zależnością od tego adaptera będzie prawdopodobnie implementacja jakiejś fabryki 3. 3
Więcej informacji na ten temat można znaleźć w rozdziale 21., „Wzorzec projektowy Fabryka”.
338
ROZDZIAŁ 25. WZORCE PROJEKTOWE SERWER ABSTRAKCYJNY I MOST
Wzorzec projektowy Most Na przedstawiony problem można spojrzeć z innej strony. Potrzeba istnienia dedykowanego modemu wprowadziła nowy poziom swobody do hierarchii typu Modem. Początkowo Modem był prostym interfejsem dla zbioru różnych urządzeń sprzętowych. Mieliśmy klasy HayesModem, USRModem i ErniesModem, które były pochodnymi bazowej klasy Modem. Teraz jednak wydaje się, że istnieje sposób uproszczenia tej hierarchii. Można zdefiniować klasy DialModem i DedicatedModem jako pochodne klasy Modem. Te dwie niezależne hierarchie można scalić w sposób pokazany na rysunku 25.10. Każdy z liści hierarchii typów dostarcza funkcji wybierania numerów albo obsługi dedykowanego połączenia dla sprzętu, którym steruje. Obiekt klasy DedicatedHayesModem steruje modemem Hayes w trybie dedykowanym.
Rysunek 25.10. Rozwiązanie problemu modemu dzięki scaleniu hierarchii typów
Ta struktura nie jest idealna. Za każdym razem, gdy dodajemy nowe urządzenie, musimy utworzyć dwie nowe klasy — jedną dla trybu dedykowanego i drugą pozwalającą na wybieranie numerów. Za każdym razem, gdy dodajemy nowy typ połączenia, musimy utworzyć trzy nowe klasy — po jednej dla każdego urządzenia. Jeśli pozostawimy te dwa stopnie swobody, będziemy musieli zaakceptować możliwość szybkiego wzrostu liczby klas potomnych. W sytuacji, gdy w hierarchii typów istnieje więcej niż jeden stopień swobody, można zastosować wzorzec projektowy Most (ang. Bridge). Zamiast scalania hierarchii, można je rozdzielić, a następnie powiązać za pomocą mostu. Odpowiednią strukturę pokazano na rysunku 25.11. Hierarchia typu Modem została podzielona na dwie części. Pierwsza reprezentuje metodę połączenia, natomiast druga — urządzenia sprzętowe. Użytkownicy typu Modem nadal korzystają z interfejsu Modem. Klasa ModemConnectionControlle implementuje interfejs Modem. Pochodne klasy ModemConnectionController zarządzają mechanizmami połączeń. Klasa DialModemController przekazuje wywołania metod dial i hangup do metod dialImp i hangImp w klasie bazowej ModemConnectionController . Następnie te metody delegują wywołania do klasy ModemImplementation , gdzie są one kierowane do odpowiedniego sterownika sprzętowego. Klasa DedModemController implementuje metody dial i hangup w celu zasymulowania stanu połączenia. Przekazuje ona metody send i receive do metod sendImp i receiveImp, skąd są one delegowane do hierarchii ModemImplementation tak jak wcześniej. Zwróćmy uwagę, że cztery funkcje imp klasy bazowej ModemConnectionController są chronione. Wynika to stąd, że mają one być wykorzystywane wyłącznie przez pochodne klasy ModemConnectionController. Nie powinny być wywoływane przez żadną inną klasę.
WNIOSEK
339
Rysunek 25.11. Rozwiązanie problemu modemu z wykorzystaniem wzorca projektowego Most
Zaprezentowana struktura jest złożona, ale interesująca. Można ją było stworzyć bez modyfikowania użytkowników modemów, a pomimo to pozwoliła nam na całkowite odseparowanie strategii połączeń od implementacji urządzeń sprzętowych. Każda pochodna klasy ModemConnectionController reprezentuje odrębną strategię połączenia. Do zaimplementowania tych strategii są wykorzystywane klasy sendImp, receiveImp, dialImp oraz hangImp. Nowe funkcje imp mogą być tworzone bez konieczności dostosowywania użytkowników. Do klas kontrolerów połączeń można również dodawać nowe interfejsy dostawców usług. W ten sposób udało się stworzyć ścieżkę migracji dla klientów modemów w kierunku interfejsów API na wyższym poziomie niż metody dial i hangup.
Wniosek Można by sądzić, że rzeczywisty problem z hierarchią modemów polega na tym, że oryginalny projekt był zły. Należało zauważyć, że połączenia i komunikacja to odrębne pojęcia. Gdyby przeprowadzono dokładniejszą analizę, można by to dostrzec i odpowiednio skorygować projekt. Zatem można by próbować winić projektantów za niedostateczną analizę. Cóż za bzdury! Nie ma czegoś takiego jak wystarczająca analiza. Bez względu na to, ile czasu poświecimy na próby znalezienia doskonałej struktury oprogramowania, zawsze znajdzie się takie żądanie zmiany, które narusza tę strukturę. Nie ma przed tym ucieczki. Nie istnieją struktury doskonałe. Są tylko takie struktury, które próbują zrównoważyć bieżące koszty i korzyści. W miarę upływu czasu i w miarę jak zmieniają się wymagania stawiane systemowi, struktury muszą się zmieniać. Sztuka zarządzania tymi zmianami polega na tym, aby utrzymywać system w stanie jak najprostszym i jak najbardziej elastycznym.
340
ROZDZIAŁ 25. WZORCE PROJEKTOWE SERWER ABSTRAKCYJNY I MOST
Rozwiązanie bazujące na wzorcu projektowym Adapter jest proste i bezpośrednie. Wszystkie zależności są skierowane w odpowiednią stronę, a dodatkowo rozwiązanie jest bardzo proste do zaimplementowania. Rozwiązanie bazujące na wzorcu projektowym Most jest nieco bardziej złożone. Podjęcie decyzji o podążeniu w tym kierunku polecałbym odłożyć do czasu, aż będziemy mieli bardzo mocne dowody na potrzebę całkowitego odseparowania strategii połączenia i komunikacji oraz potrzeby dodawania nowych strategii połączeń. Warto podkreślić, że stosowanie wzorców zawsze wiąże się zarówno z korzyściami, jak i kosztami. Należy stosować te wzorce, które najlepiej nadają się do rozwiązywania bieżących problemów.
Bibliografia 1. Gamma, et al., Design Patterns, Reading, MA: Addison-Wesley, 1995.
R OZDZIAŁ 26
Wzorce projektowe Pełnomocnik i Schody do nieba — zarządzanie zewnętrznymi interfejsami API
Czy ktoś pamięta śmiech? — Robert Plant, The Song Remains the Same
W systemach oprogramowania istnieje wiele barier. Kiedy przenosimy dane z programu do bazy danych, przekraczamy barierę bazy danych. Kiedy wysyłamy wiadomość z jednego komputera do drugiego, przekraczamy barierę sieci. Przekraczanie barier może być skomplikowane. Jeśli nie zachowamy ostrożności, nasze oprogramowanie będzie w większym stopniu dotyczyło pokonywania barier niż rozwiązywania problemów, które powinno rozwiązywać. Wzorce projektowe zaprezentowane w tym rozdziale pomagają pokonać opisane bariery, a jednocześnie pozwalają skoncentrować się na problemie do rozwiązania.
342
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
Wzorzec projektowy Pełnomocnik Wyobraźmy sobie, że piszemy aplikację, która ma obsługiwać koszyk na zakupy w sklepie internetowym. W takim systemie mogłyby się znaleźć obiekty reprezentujące klienta, zamówienie (koszyk) oraz produkty będące przedmiotem zamówienia. Możliwą strukturę takiego systemu pokazano na rysunku 26.1. Zaprezentowana struktura jest uproszczona, ale wystarczy do naszych celów.
Rysunek 26.1. Prosty model obiektowy koszyka na zakupy
Gdybyśmy spróbowali rozwiązać problem dodania nowego towaru do zamówienia, moglibyśmy uzyskać kod zamieszczony na listingu 26.1. Metoda addItem klasy Order tworzy nowy obiekt Item zawierający odpowiedni obiekt Product oraz informację o zamawianej ilości. Następnie metoda dodaje ten obiekt Item do wewnętrznej struktury Vector złożonej z obiektów Item. Listing 26.1. Dodawanie pozycji do modelu obiektowego public class Order { private Vector itsItems = new Vector(); public void addItem(Product p, int qty) { Item item = new Item(p, qty); itsItems.add(item); } }
Wyobraźmy sobie teraz, że te obiekty reprezentują dane, które są przechowywane w relacyjnej bazie danych. Na rysunku 26.2 pokazano tabele i klucze, które mogłyby reprezentować potrzebne obiekty. Aby znaleźć zamówienia składane przez określonego klienta, należy odszukać wszystkie rekordy z wartością identyfikatora tego klienta. Aby znaleźć wszystkie towary w tym zamówieniu, wyszukujemy wszystkie towary z identyfikatorem orderId szukanego zamówienia. Aby znaleźć produkty odpowiadające określonym towarom, należy skorzystać z identyfikatora sku tych produktów.
Rysunek 26.2. Relacyjny model danych koszyka na zakupy
WZORZEC PROJEKTOWY PEŁNOMOCNIK
343
Gdybyśmy chcieli dodać wiersz odpowiadający towarowi do określonego zamówienia, moglibyśmy wykorzystać kod podobny do tego, który pokazano na listingu 26.2. W tym kodzie wykorzystano wywołania JDBC w celu wykonywania operacji bezpośrednio na relacyjnym modelu danych. Listing 26.2. Dodawanie towaru do modelu relacyjnego public class AddItemTransaction extends Transaction { public void addItem(int orderId, String sku, int qty) { Statement s = itsConnection.CreateStatement(); s.executeUpdate("insert into items values(" + orderId + "," + sku + "," + qty + ")"); } }
Choć dwa fragmenty kodu zaprezentowane powyżej bardzo się różnią, to realizują tę samą funkcję logiczną. Oba dodają towar do zamówienia. Pierwszy ignoruje istnienie bazy danych, natomiast drugi akcentuje ten fakt. Jest oczywiste, że w programie dotyczącym koszyka na zakupy występują zamówienia, pozycje zamówień i towary. Niestety, gdybyśmy chcieli skorzystać z kodu z listingu 26.2, musielibyśmy poświęcić mnóstwo pracy na opracowanie instrukcji SQL, obsługi połączeń z bazą danych oraz łączenia ciągów zapytań. Oznaczałoby to poważne naruszenie zasad SRP oraz CCP. Kod z listingu 26.2 łączy ze sobą dwa pojęcia, które mogą się zmieniać z różnych powodów. Pojęcia towarów i zamówień mieszają się z pojęciami schematów relacyjnych baz danych i języka SQL. Zmiana któregoś z tych pojęć z dowolnego powodu ma wpływ na pozostałe. Kod z listingu 26.2 narusza również zasadę DIP, ponieważ strategia programu zależy od szczegółów mechanizmów utrwalania danych. Opisane problemy można złagodzić dzięki zastosowaniu wzorca projektowego Pełnomocnik (ang. Proxy). Aby pokazać jego zastosowanie, stworzymy program testowy, który pokazuje sposób utworzenia zamówienia i obliczenia całkowitej ceny. Najważniejszą część tego programu zamieszczono na listingu 26.3. Listing 26.3. Program testowy tworzy zamówienie i sprawdza poprawność obliczenia ceny public void testOrderPrice() { Order o = new Order("Bogdan"); Product toothpaste = new Product("Pasta do zębów", 129); o.addItem(toothpaste, 1); assertEquals(129, o.total()); Product mouthwash = new Product("Płyn do płukania ust", 342); o.addItem(mouthwash, 2); assertEquals(813, o.total()); }
Prosty program, który pomyślnie przechodzi powyższy test, pokazano na listingach od 26.4 do 26.6. W programie skorzystano z modelu obiektowego z rysunku 26.1. W kodzie nie założono istnienia bazy danych. Przedstawiony kod jest niekompletny pod wieloma względami. Jest to kod w minimalnej postaci niezbędnej do spełnienia testu. Listing 26.4. order.java public class Order { private Vector itsItems = new Vector(); public Order(String cusid) { } public void addItem(Product p, int qty)
344
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
{ }
}
Item item = new Item(p,qty); itsItems.add(item);
public int total() { int total = 0; for (int i = 0; i < itsItems.size(); i++) { Item item = (Item) itsItems.elementAt(i); Product p = item.getProduct(); int qty = item.getQuantity(); total += p.getPrice() * qty; } return total; }
Listing 26.5. product.java public class Product { private int itsPrice; public Product(String name, int price) { itsPrice = price; }
}
public int getPrice() { return itsPrice; }
Listing 26.6. item.java public class Item { private Product itsProduct; private int itsQuantity; public Item(Product p, int qty) { itsProduct = p; itsQuantity = qty; } public Product getProduct() { return itsProduct; }
}
public int getQuantity() { return itsQuantity; }
Na rysunkach 26.3 i 26.4 pokazano sposób działania wzorca projektowego Pełnomocnik. Każdy obiekt, który ma być obsłużony, podzielono na trzy części. Pierwsza to interfejs. Zadeklarowano w nim wszystkie metody, z których muszą skorzystać klienty. Druga część to klasa, która implementuje te metody bez „wiedzy” o bazie danych. Trzecia to pełnomocnik, który „ma świadomość” istnienia bazy danych.
WZORZEC PROJEKTOWY PEŁNOMOCNIK
345
Rysunek 26.3. Statyczny model wzorca projektowego Pełnomocnik
Rysunek 26.4. Dynamiczny model wzorca projektowego Pełnomocnik
Przeanalizujmy klasę Product. Zastosowaliśmy dla niej wzorzec Pełnomocnik poprzez zastąpienie jej interfejsem. Ten interfejs ma dokładnie te same metody co klasa Product. Klasa ProductImplementation implementuje interfejs prawie tak samo jak wcześniej. Klasa ProductDBProxy implementuje wszystkie metody klasy Product w celu pobrania produktu z bazy danych, stworzenia egzemplarza klasy Product Implementation oraz oddelegowania do niej komunikatu. Sposób działania tego wzorca pokazano na rysunku 26.4. Klient wysyła komunikat getPrice do obiektu, który uważa za egzemplarz klasy Product, a który w rzeczywistości jest egzemplarzem klasy ProductDBProxy. Obiekt klasy ProductDBProxy pobiera z bazy danych egzemplarz obiektu ProductImple mentation. Następnie deleguje do niego metodę getPrice. O tym, co się dzieje, „nie wiedzą” ani aplikacja kliencka, ani egzemplarz klasy ProductImplementation. W ten sposób wprowadzono bazę danych do aplikacji bez informowania o tym fakcie zainteresowanych stron. Na tym polega siła wzorca projektowego Pełnomocnik. Teoretycznie można go umieścić pomiędzy dwoma współpracującymi obiektami bez konieczności informowania tych obiektów o tym fakcie. Dzięki temu wzorzec ten można wykorzystać do przekraczania takich barier jak baza danych lub sieć bez konieczności modyfikowania „zainteresowanych stron”. W praktyce zastosowanie klas-pełnomocników nie jest trywialne. Aby zorientować się co do charakteru występujących problemów, spróbujmy dodać wzorzec projektowy Pełnomocnik do prostej aplikacji koszyka na zakupy.
Implementacja wzorca projektowego Pełnomocnik w aplikacji koszyka na zakupy Najprostszą klasę-pełnomocnika można utworzyć dla klasy Product. Dla celów naszego przykładu tabela produktów reprezentuje prosty słownik. Tabela ta zostanie załadowana w jednym miejscu wszystkimi produktami. Na tej tabeli nie są wykonywane inne operacje, dlatego utworzenie klas-pełnomocników jest stosunkowo proste. Na początek potrzebujemy prostego narzędzia dostępu do bazy danych, które umożliwia zapisywanie i pobieranie danych o produktach. Obiekt-pełnomocnik skorzysta z tego interfejsu do wykonywania operacji na bazie danych. Przypadek testowy dla programu, który realizuje te operacje, pokazano na listingu 26.7. Kod z listingów 26.8 i 26.9 pomyślnie przechodzi ten test.
346
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
Listing 26.7. DBTest.java import junit.framework.*; import junit.swingui.TestRunner; public class DBTest extends TestCase { public static void main(String[] args) { TestRunner.main(new String[]{"DBTest"}); } public DBTest(String name) { super(name); } public void setUp() throws Exception { DB.init(); } public void tearDown() throws Exception { DB.close(); }
}
public void testStoreProduct() throws Exception { ProductData storedProduct = new ProductData(); storedProduct.name = "MyProduct"; storedProduct.price = 1234; storedProduct.sku = "999"; DB.store(storedProduct); ProductData retrievedProduct = DB.getProductData("999"); DB.deleteProductData("999"); assertEquals(storedProduct, retrievedProduct); }
Listing 26.8. ProductData.java public class ProductData { public String name; public int price; public String sku; public ProductData() { } public ProductData(String name, int price, String sku) { this.name = name; this.price = price; this.sku = sku; }
}
public boolean equals(Object o) { ProductData pd = (ProductData)o; return name.equals(pd.name) && sku.equals(pd.sku) && price==pd.price; }
WZORZEC PROJEKTOWY PEŁNOMOCNIK
Listing 26.9. DB.java import java.sql.*; public class DB { private static Connection con; public static void init() throws Exception { Class.forName("sun.jdbc.odbc.JdbcOdbcDriver"); con = DriverManager.getConnection( "jdbc:odbc:PPP Shopping Cart"); } public static void store(ProductData pd) throws Exception { PreparedStatement s = buildInsertionStatement(pd); executeStatement(s); } private static PreparedStatement buildInsertionStatement(ProductData pd) throws SQLException { PreparedStatement s = con.prepareStatement( "INSERT into Products VALUES (?, ?, ?)"); s.setString(1, pd.sku); s.setString(2, pd.name); s.setInt(3, pd.price); return s; } public static ProductData getProductData(String sku) throws Exception { PreparedStatement s = buildProductQueryStatement(sku); ResultSet rs = executeQueryStatement(s); ProductData pd = extractProductDataFromResultSet(rs); rs.close(); s.close(); return pd; } private static PreparedStatement buildProductQueryStatement(String sku) throws SQLException { PreparedStatement s = con.prepareStatement( "SELECT * FROM Products WHERE sku = ?;"); s.setString(1, sku); return s; } private static ProductData extractProductDataFromResultSet(ResultSet rs) throws SQLException { ProductData pd = new ProductData(); pd.sku = rs.getString(1); pd.name = rs.getString(2); pd.price = rs.getInt(3); return pd; } public static void deleteProductData(String sku) throws Exception { executeStatement(buildProductDeleteStatement(sku)); } private static PreparedStatement buildProductDeleteStatement(String sku) throws SQLException
347
348
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
{
}
PreparedStatement s = con.prepareStatement( "DELETE from Products where sku = ?"); s.setString(1, sku); return s;
private static void executeStatement(PreparedStatement s) throws SQLException { s.execute(); s.close(); } private static ResultSet executeQueryStatement(PreparedStatement s) throws SQLException { ResultSet rs = s.executeQuery(); rs.next(); return rs; }
}
public static void close() throws Exception { con.close(); }
Następnym krokiem w implementacji wzorca projektowego Pełnomocnik jest napisanie testu, który pokazuje, jak on działa. Ten test dodaje produkt do bazy danych. Następnie tworzy obiekt klasy ProductProxy z identyfikatorem sku właściwym dla tego produktu i próbuje skorzystać z metod dostępowych klasy Product w celu uzyskania dostępu do danych z obiektu-pełnomocnika (patrz listing 26.10). Listing 26.10. ProxyTest.java import junit.framework.*; import junit.swingui.TestRunner; public class ProxyTest extends TestCase { public static void main(String[] args) { TestRunner.main(new String[]{"ProxyTest"}); } public ProxyTest(String name) { super(name); } public void setUp() throws Exception { DB.init(); ProductData pd = new ProductData(); pd.sku = "ProxyTest1"; pd.name = "ProxyTestName1"; pd.price = 456; DB.store(pd); } public void tearDown() throws Exception { DB.deleteProductData("ProxyTest1"); DB.close(); }
WZORZEC PROJEKTOWY PEŁNOMOCNIK
}
349
public void testProductProxy() throws Exception { Product p = new ProductProxy("ProxyTest1"); assertEquals(456, p.getPrice()); assertEquals("ProxyTestName1", p.getName()); assertEquals("ProxyTest1", p.getSku()); }
Aby zastosowany wzorzec mógł działać, musimy oddzielić interfejs klasy Product od jej implementacji. Z tego powodu przekształciłem klasę Product na interfejs i stworzyłem klasę ProductImp, która implementuje ten interfejs (patrz listingi 26.11 i 26.12). Listing 26.11. Product.java public interface Product { public int getPrice() throws Exception; public String getName() throws Exception; public String getSku() throws Exception; }
Listing 26.12. ProductImp.java public class ProductImp implements Product { private int itsPrice; private String itsName; private String itsSku; public ProductImp(String sku, String name, int price) { itsPrice = price; itsName = name; itsSku = sku; } public int getPrice() { return itsPrice; } public String getName() { return itsName; }
}
public String getSku() { return itsSku; }
Zwróćmy uwagę, że dodałem wyjątki do interfejsu Product. Zrobiłem tak, ponieważ pisałem klasę ProductProxy (listing 26.13) w tym samym czasie co klasy Product, ProductImp oraz ProxyTest. Zaimplementowałem po jednej metodzie dostępowej na raz. Jak się przekonamy, obiekt klasy ProductProxy wywołuje bazę danych, a to powoduje zgłoszenie wyjątków. Nie chciałem, aby te wyjątki zostały przechwycone przez klasę-pełnomocnika i tam ukryte, dlatego pozwoliłem, żeby wyszły poza interfejs. Listing 26.13. ProductProxy.java public class ProductProxy implements Product { private String itsSku; public ProductProxy(String sku)
350
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
{
itsSku = sku; } public int getPrice() throws Exception { ProductData pd = DB.getProductData(itsSku); return pd.price; } public String getName() throws Exception { ProductData pd = DB.getProductData(itsSku); return pd.name; }
}
public String getSku() throws Exception { return itsSku; }
Implementacja tej klasy-pełnomocnika jest prosta. Właściwie zaprezentowana implementacja nie pasuje do kanonicznej formy wzorca przedstawionej na rysunkach 26.3 i 26.4. To była niespodzianka. Moim zamiarem było zaimplementowanie wzorca projektowego Pełnomocnik. Kiedy jednak ostatecznie implementacja zmaterializowała się, kanoniczna postać wzorca projektowego przestała mieć sens. Zgodnie z tym, co pokazano poniżej, w kanonicznej postaci wzorca obiekt klasy ProductProxy tworzy obiekt ProductImp w każdej metodzie. Następnie ta metoda powinna być oddelegowana do klasy ProductImp. public int getPrice() throws Exception { ProductData pd = DB.getProductData(itsSku); ProductImp p = new ProductImp(pd.sku, pd.name, pd.price); return p.getPrice(); }
Tworzenie obiektów ProductImp w taki sposób jest całkowitym marnotrawstwem pracy programistów i zasobów komputerowych. Obiekt ProductProxy już dysponuje danymi, które powinny zwracać metody dostępowe klasy ProductImp. Nie ma zatem potrzeby tworzenia obiektów ProductImp, a następnie delegowania do nich metod. To kolejny przykład sytuacji, w której kod źródłowy może odwieść programistę od oczekiwanych wzorców i modeli. Zwróćmy uwagę, że w metodzie getSku klasy ProductProxy z listingu 26.13 posunięto się o jeden krok dalej. Wcale nie odwołano się do wartości identyfikatora sku zapisanej w bazie danych. Do czego miałoby służyć takie odwołanie? Przecież wartość identyfikatora sku jest już dostępna. Można by sądzić, że implementacja klasy ProductProxy w takiej postaci jest bardzo niewydajna. Każda metoda dostępowa odwołuje się do bazy danych. Czyż nie byłoby lepiej, gdyby wartość ProductData została zbuforowana tak, by uniknąć ciągłego sięgania do bazy danych? Wprowadzenie potrzebnej zmiany jest bardzo proste, ale jedyne, co skłania nas do wprowadzenia tej zmiany, to nasze obawy. W tym momencie nie mamy danych sugerujących, że w tym programie występuje problem wydajności. Poza tym zdajemy sobie sprawę z tego, że sam silnik bazy danych korzysta z mechanizmów buforowania. Z tego względu nie mamy pewności, co dałoby nam stworzenie własnego mechanizmu buforowania. Powinniśmy poczekać do czasu wystąpienia problemów z wydajnością, zanim sami stworzymy sobie problemy. Tworzenie pełnomocników dla relacji. Drugim krokiem w implementacji omawianego wzorca jest stworzenie pełnomocnika dla klasy Order. Każdy egzemplarz klasy Order zawiera wiele egzemplarzy klasy Item. W schemacie relacyjnym (rysunek 26.2) ta relacja została uwzględniona w obrębie tabeli Item. Każdy wiersz tabeli Item zawiera klucz rekordu tabeli Order reprezentującego zamówienie, na którym
WZORZEC PROJEKTOWY PEŁNOMOCNIK
351
znalazł się określony towar. Jednak w modelu obiektowym ta relacja jest zaimplementowana za pomocą struktury Vector wewnątrz obiektu klasy Order (patrz listing 26.4). Obiekt-pełnomocnik musi w jakiś sposób przekształcać jedną z tych form w drugą. Rozpoczniemy od stworzenia przypadku testowego, który będzie sprawdzał poprawność klasy-pełnomocnika. Ten test dodaje do bazy danych kilka przykładowych produktów. Następnie uzyskuje obiekty-pełnomocników do tych produktów i wywołuje metodę addItem obiektu OrderProxy. Na koniec zadaje pytanie obiektowi OrderProxy o łączną wartość zamówienia (patrz listing 26.14). Celem tego przypadku testowego jest pokazanie, że obiekt klasy OrderProxy zachowuje się tak samo jak obiekt klasy Order, ale uzyskuje swoje dane z bazy danych zamiast z obiektów rezydujących w pamięci. Listing 26.14. ProxyTest.java public void testOrderProxyTotal() throws Exception { DB.store(new ProductData("Wheaties", 349, "wheaties")); DB.store(new ProductData("Crest", 258, "crest")); ProductProxy wheaties = new ProductProxy("wheaties"); ProductProxy crest = new ProductProxy("crest"); OrderData od = DB.newOrder("testOrderProxy"); OrderProxy order = new OrderProxy(od.orderId); order.addItem(crest, 1); order.addItem(wheaties, 2); assertEquals(956, order.total()); }
Aby ten przypadek testowy przechodził, trzeba zaimplementować kilka nowych klas i metod. Najpierw spróbujemy zaimplementować metodę newOrder klasy DB. Wygląda na to, że ta metoda zwraca egzemplarz obiektu pod nazwą OrderData. Klasa OrderData spełnia podobną rolę co klasa ProductData. To prosta struktura danych reprezentująca wiersz tabeli Order z bazy danych. Jej implementację zamieszczono na listingu 26.15. Listing 26.15. OrderData.java public class OrderData { public String customerId; public int orderId; public OrderData() { }
}
public OrderData(int orderId, String customerId) { this.orderId = orderId; this.customerId = customerId; }
Nie należy zbytnio przejmować się wykorzystaniem publicznych składowych danych. Ta struktura nie jest obiektem w ścisłym znaczeniu. Spełnia jedynie rolę kontenera dla danych. Nie implementuje interesujących zachowań, które warto by zamknąć w klasie. Zadeklarowanie prywatnych zmiennych reprezentujących dane i zdefiniowanie dla nich metod dostępowych get i set byłoby jedynie zbędną komplikacją. Teraz należy napisać metodę newOrder klasy DB. Zwróćmy uwagę, że w kodzie, który ją wywołuje, na listingu 26.14, przekazujemy identyfikator klienta, ale nie przekazujemy wartości orderId. Każdy obiekt klasy Order powinien mieć identyfikator orderId spełniający rolę klucza. Co więcej, w schemacie relacyjnym każdy obiekt Item odwołuje się do tego identyfikatora orderId. W ten sposób jest zaprezentowane
352
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
połączenie obiektu Item z odpowiednim wierszem tabeli Order. Jest oczywiste, że wartość identyfikatora orderId musi być unikatowa. W jaki sposób się ją tworzy? Spróbujmy napisać test, który pokazuje nasze zamiary (patrz listing 26.16). Listing 26.16. DBTest.java public void testOrderKeyGeneration() throws Exception { OrderData o1 = DB.newOrder("Bogdan"); OrderData o2 = DB.newOrder("Bartosz"); int firstOrderId = o1.orderId; int secondOrderId = o2.orderId; assertEquals(firstOrderId+1, secondOrderId); }
Ten test pokazuje, że oczekujemy automatycznej inkrementacji wartości orderId podczas tworzenia każdego egzemplarza klasy Order. Można to z łatwością zaimplementować poprzez odpytanie bazy danych o maksymalną wartość aktualnie używanego identyfikatora orderId i dodanie jedynki do tej wartości (patrz listing 26.17). Listing 26.17. DB.java public static OrderData newOrder(String customerId) throws Exception { int newMaxOrderId = getMaxOrderId() + 1; PreparedStatement s = con.prepareStatement( "Insert into Orders(orderId,cusid) Values(?,?);"); s.setInt(1, newMaxOrderId); s.setString(2,customerId); executeStatement(s); return new OrderData(newMaxOrderId, customerId); }
}
private static int getMaxOrderId() throws SQLException { Statement qs = con.createStatement(); ResultSet rs = qs.executeQuery( "Select max(orderId) from Orders;"); rs.next(); int maxOrderId = rs.getInt(1); rs.close(); return maxOrderId;
Możemy teraz przystąpić do napisania klasy OrderProxy. Tak jak było w przypadku klasy Product, klasę Order trzeba podzielić na interfejs i implementację. Zatem klasę Order przekształcimy w interfejs, natomiast klasa OrderImp stanie się jego implementacją (patrz listingi 26.18 i 26.19). Listing 26.18. Order.java public interface Order { public String getCustomerId(); public void addItem(Product p, int quantity); public int total(); }
Listing 26.19. OrderImp.java import java.util.Vector; public class OrderImp implements Order { private Vector itsItems = new Vector();
WZORZEC PROJEKTOWY PEŁNOMOCNIK
353
private String itsCustomerId; public String getCustomerId() { return itsCustomerId; } public OrderImp(String cusid) { itsCustomerId = cusid; } public void addItem(Product p, int qty) { Item item = new Item(p,qty); itsItems.add(item); }
}
public int total() { try { int total = 0; for (int i = 0; i < itsItems.size(); i++) { Item item = (Item) itsItems.elementAt(i); Product p = item.getProduct(); int qty = item.getQuantity(); total += p.getPrice() * qty; } return total; } catch (Exception e) { throw new Error(e.toString()); } }
W klasie OrderImp musiałem dodać kod obsługi wyjątków, ponieważ interfejs Product zgłasza wyjątki. Trochę denerwują mnie te wyjątki. Implementacje klas-pełnomocników za interfejsem nie powinny wywierać wpływu na ten interfejs, a jednak obiekty-pełnomocnicy zgłaszają wyjątki, które są propagowane przez interfejs. Aby rozwiązać ten problem, zastąpię wszystkie obiekty Exceptions obiektami Error. Dzięki temu nie będę musiał zaśmiecać interfejsów klauzulami throws, a użytkowników tych interfejsów — blokami try-catch. W jaki sposób należy zaimplementować metodę addItem wewnątrz klasy-pełnomocnika? Bez wątpienia klasa-pełnomocnik nie może delegować wywołań do metody OrderImp.addItem! Zamiast tego pełnomocnik powinien wstawić wiersz reprezentujący obiekt Item do bazy danych. Z drugiej strony, naprawdę chcę oddelegować wywołanie OrderProxy.total do wywołania OrderImp.total, ponieważ chcę, aby reguły biznesowe (tzn. strategia obliczania wartości zamówień) były zamknięte w klasie OrderImp. Sens budowania klas-pełnomocników sprowadza się do oddzielenia implementacji bazy danych od reguł biznesowych. Aby można było oddelegować funkcję total, obiekt-pełnomocnik musi stworzyć kompletny obiekt Order razem z wszystkimi obiektami Item, które ten obiekt zawiera. Zatem w metodzie OrderProxy.total trzeba odczytać wszystkie rekordy item z bazy danych, wywołać metodę addItem na pustym obiekcie OrderImp dla każdego odczytanego obiektu item, a następnie wywołać metodę total tego obiektu OrderImp. W związku z tym implementacja klasy OrderProxy powinna przypominać kod pokazany na listingu 26.20.
354
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
Listing 26.20. OrderProxy.java import java.sql.SQLException; public class OrderProxy implements Order { private int orderId; public OrderProxy(int orderId) { this.orderId = orderId; } public int total() { try { OrderImp imp = new OrderImp(getCustomerId()); ItemData[] itemDataArray = DB.getItemsForOrder(orderId); for (int i = 0; i < itemDataArray.length; i++) { ItemData item = itemDataArray[i]; imp.addItem(new ProductProxy(item.sku), item.qty); } return imp.total(); } catch (Exception e) { throw new Error(e.toString()); } } public String getCustomerId() { try { OrderData od = DB.getOrderData(orderId); return od.customerId; } catch (SQLException e) { throw new Error(e.toString()); } } public void addItem(Product p, int quantity) { try { ItemData id = new ItemData(orderId, quantity, p.getSku()); DB.store(id); } catch (Exception e) { throw new Error(e.toString()); } }
}
public int getOrderId() { return orderId; }
WZORZEC PROJEKTOWY PEŁNOMOCNIK
355
Powyższa implementacja sugeruje istnienie klasy ItemData oraz kilku funkcji klasy DB służących do wykonywania operacji na wierszach ItemData. Odpowiedni kod zamieszczono na listingach od 26.21 do 26.23. Listing 26.21. ItemData.java public class ItemData { public int orderId; public int qty; public String sku = "junk"; public ItemData() { }
}
public ItemData(int orderId, int qty, String sku) { this.orderId = orderId; this.qty = qty; this.sku = sku; } public boolean equals(Object o) { ItemData id = (ItemData)o; return orderId == id.orderId && qty == id.qty && sku.equals(id.sku); }
Listing 26.22. DBTest.java public void testStoreItem() throws Exception { ItemData storedItem = new ItemData(1, 3, "sku"); DB.store(storedItem); ItemData[] retrievedItems = DB.getItemsForOrder(1); assertEquals(1, retrievedItems.length); assertEquals(storedItem, retrievedItems[0]); } public void testNoItems() throws Exception { ItemData[] id = DB.getItemsForOrder(42); assertEquals(0, id.length); }
Listing 26.23. DB.java public static void store(ItemData id) throws Exception { PreparedStatement s = buildItemInsersionStatement(id); executeStatement(s); } private static PreparedStatement buildItemInsersionStatement(ItemData id) throws SQLException { PreparedStatement s = con.prepareStatement( "Insert into Items(orderId,quantity,sku) " + "VALUES (?, ?, ?);"); s.setInt(1,id.orderId); s.setInt(2,id.qty); s.setString(3, id.sku); return s;
356
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
} public static ItemData[] getItemsForOrder(int orderId) throws Exception { PreparedStatement s = buildItemsForOrderQueryStatement(orderId); ResultSet rs = s.executeQuery(); ItemData[] id = extractItemDataFromResultSet(rs); rs.close(); s.close(); return id; } private static PreparedStatement buildItemsForOrderQueryStatement(int orderId) throws SQLException { PreparedStatement s = con.prepareStatement( "SELECT * FROM Items WHERE orderid = ?;"); s.setInt(1, orderId); return s; } private static ItemData[] extractItemDataFromResultSet(ResultSet rs) throws SQLException { LinkedList l = new LinkedList(); for (int row = 0; rs.next(); row++) { ItemData id = new ItemData(); id.orderId = rs.getInt("orderid"); id.qty = rs.getInt("quantity"); id.sku = rs.getString("sku"); l.add(id); } return (ItemData[]) l.toArray(new ItemData[l.size()]); } public static OrderData getOrderData(int orderId) throws SQLException { PreparedStatement s = con.prepareStatement( "Select cusid from orders where orderid = ?;"); s.setInt(1, orderId); ResultSet rs = s.executeQuery(); OrderData od = null; if (rs.next()) od = new OrderData(orderId, rs.getString("cusid")); rs.close(); s.close(); return od; }
Podsumowanie wiadomości o wzorcu projektowym Pełnomocnik Ten przykład powinien rozwiać wszelkie fałszywe złudzenia co do elegancji i prostoty korzystania ze wzorca projektowego Pełnomocnik. Stosowanie wzorca projektowego Pełnomocnik nie jest łatwe. Prosty model delegacji sugerowany przez postać kanoniczną rzadko tak zgrabnie się materializuje. Przeciwnie — często delegacje są zastępowane trywialnymi metodami dostępowymi get i set. W przypadku metod zarządzających relacjami 1:N często rezygnuje się z delegacji, które są przenoszone do innych metod — podobnie jak delegacja metody addItem została przeniesiona do metody total. Na koniec trzeba rozwiązać problem buforowania.
WZORZEC PROJEKTOWY PEŁNOMOCNIK
357
W tym przykładzie nie stosowaliśmy żadnych mechanizmów buforowania. Wszystkie testy były wykonywane w czasie poniżej sekundy, dlatego nie było powodu, żeby zbytnio przejmować się wydajnością. Ale w rzeczywistej aplikacji może pojawić się kwestia wydajności i potrzeba inteligentnego buforowania danych. Nie sugeruję, że należy automatycznie implementować strategię buforowania ze względu na obawy o zbytnie obniżenie wydajności. Właściwie z moich doświadczeń wynika, że dodanie mechanizmów buforowania zbyt wcześnie to bardzo dobry sposób na obniżenie wydajności. Osobom, które obawiają się, że wydajność może być problemem, polecam przeprowadzenie eksperymentów, które udowodnią, że to istotnie będzie problem. Kiedy taki dowód zostanie przeprowadzony i tylko wtedy, można zastanowić się nad tym, jak przyspieszyć działanie systemu. Korzyści ze stosowania wzorca projektowego Pełnomocnik. Pomimo wszystkich kłopotów, jakie sprawia stosowanie wzorca projektowego Pełnomocnik, trzeba pamiętać, że stosowanie tego wzorca przynosi jedną ważną korzyść: rozdzielenie pojęć. W naszym przykładzie udało się nam całkowicie oddzielić reguły biznesowe od kodu obsługi bazy danych. Klasa OrderImp nie ma żadnych zależności od bazy danych. Jeśli zajdzie potrzeba zmiany schematu bazy danych lub wymiany „silnika” bazy danych, będzie można to zrobić bez wpływu na klasy Order, OrderImp lub dowolne inne klasy należące do dziedziny biznesowej. W przypadkach, gdy oddzielenie reguł biznesu od implementacji bazy danych ma kluczowe znaczenie, warto rozważyć zastosowanie wzorca projektowego Pełnomocnik. Wzorzec projektowy Pełnomocnik może być używany w celu oddzielenia reguł biznesowych od dowolnego rodzaju kwestii związanych z implementacją. Można go użyć do zabezpieczenia reguł biznesowych przed „zanieczyszczeniem” przez takie technologie jak COM, CORBA, EJB itp. Jest to dobry sposób na oddzielenie zestawu reguł biznesowych projektu od mechanizmów implementacji, które są obecnie w modzie.
Obsługa baz danych, oprogramowania middleware oraz zewnętrznych interfejsów API Posługiwanie się interfejsami API zewnętrznych dostawców jest nieodłącznym problemem inżynierów oprogramowania. Kupujemy systemy obsługi baz danych, programy warstwy pośredniej (ang. midlleware), biblioteki klas, biblioteki obsługi wątków itp. Początkowo korzystamy z tych API za pomocą bezpośrednich wywołań w kodzie aplikacji (patrz rysunek 26.5).
Rysunek 26.5. Początkowa relacja pomiędzy aplikacją a zewnętrznym interfejsem API
Z czasem jednak okazuje się, że kod aplikacji staje się coraz bardziej zanieczyszczony takimi wywołaniami API. Na przykład w aplikacji bazodanowej możemy spotkać coraz więcej ciągów zapytań SQL w kodzie, który zawiera również reguły biznesowe. To może stać się problemem w sytuacji, gdy interfejsy API zewnętrznych dostawców zmienią się. W przypadku baz danych problemy mogą wystąpić również w wyniku zmian w schemacie bazy danych. W miarę wydawania nowych wersji dystrybucyjnych API lub nowych schematów trzeba modyfikować coraz więcej kodu aplikacji, tak by dostosować się do tych zmian.
358
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
Ostatecznie deweloperzy podejmują decyzję o konieczności odizolowania się od tych zmian. Z tego powodu opracowują warstwę aplikacji, która oddziela reguły aplikacji od zewnętrznych interfejsów API (rysunek 26.6). W tej warstwie — zamiast w warstwie reguł biznesowych aplikacji — jest umieszczany cały kod, który korzysta z zewnętrznego API, oraz wszystkie pojęcia związane z API.
Rysunek 26.6. Wprowadzenie warstwy pośredniej w aplikacji
Czasami całe warstwy takiego oprogramowania są kupowane od zewnętrznych dostawców. Przykładem tego rodzaju warstw są mechanizmy ODBC lub JDBC. Ich zastosowanie pozwala oddzielić kod aplikacji od stosowanego silnika bazy danych. Trzeba jednak pamiętać, że te warstwy same są zewnętrznymi interfejsami API, dlatego często trzeba oddzielić kod aplikacji także od nich. Warto zwrócić uwagę na przejściową zależność pomiędzy aplikacją a interfejsem API. W niektórych aplikacjach tego rodzaju pośrednia zależność jest wystarczającym powodem wystąpienia problemów. Na przykład technologia JDBC nie izoluje aplikacji od szczegółów schematu bazy danych. Aby uzyskać jeszcze lepszą izolację, należy odwrócić zależność pomiędzy aplikacją a warstwą pośrednią (patrz rysunek 26.7). Dzięki temu aplikacja nie musi nic „wiedzieć” o zewnętrznym API — ani bezpośrednio, ani pośrednio. W przypadku bazy danych aplikacja nie musi bezpośrednio niczego „wiedzieć” o schemacie. W przypadku warstwy middleware aplikacja nie musi niczego „wiedzieć” o typach danych wykorzystywanych przez procesor warstwy pośredniej.
Rysunek 26.7. Odwrócenie zależności pomiędzy aplikacją a warstwą pośrednią
Właśnie taki układ zależności można osiągnąć w przypadku zastosowania wzorca projektowego Pełnomocnik. Aplikacja w ogóle nie zależy od klas-pełnomocników. Zamiast tego klasy-pełnomocnicy zależą do aplikacji oraz od API. To sprawia, że cała wiedza związana z odwzorowaniem pomiędzy aplikacją a API jest skoncentrowana w klasach-pełnomocnikach.
SCHODY DO NIEBA
359
Rysunek 26.8. Odwrócenie zależności pomiędzy aplikacją a warstwą pośrednią dzięki zastosowaniu wzorca projektowego Pełnomocnik
Z powodu tej koncentracji wiedzy utrzymywanie klas-pełnomocników staje się koszmarem. Za każdym razem, gdy zmieni się API, trzeba zmienić klasy-pełnomocników. Za każdym razem, gdy zmieni się aplikacja, trzeba modyfikować klasy-pełnomocników. Obsługa klas-pełnomocników staje się bardzo uciążliwa. Warto zdawać sobie sprawę, co jest źródłem naszych problemów. Bez stosowania wzorca projektowego Pełnomocnik problemy byłyby rozsiane po całym kodzie aplikacji. W większości aplikacji nie ma potrzeby stosowania klas-pełnomocników. Rozwiązanie polegające na ich stosowaniu jest dość kosztowne. Kiedy spotykam się z rozwiązaniami bazującymi na wzorcu projektowym Pełnomocnik, często zalecam pozbycie się go i użycie w zamian czegoś prostszego. Ale istnieją przypadki, kiedy zdecydowane oddzielenie aplikacji od interfejsu API, jakie gwarantuje zastosowanie wzorca projektowego Pełnomocnik, przynosi korzyści. Takie przypadki prawie zawsze dotyczą dużych systemów, w których często dochodzi do zmian schematu bazy danych lub stosowanych interfejsów API. Inną grupę stanowią systemy pracujące ponad wieloma systemami baz danych i pakietami oprogramowania middleware.
Schody do nieba1 Schody do nieba (ang. Stairway to heaven) to kolejny wzorzec projektowy, który pozwala osiągnąć takie samo odwrócenie zależności jak w przypadku zastosowania wzorca projektowego Pełnomocnik. Wzorzec ten wykorzystuje odmianę wzorca projektowego Adapter w postaci klasowej (patrz rysunek 26.9).
Rysunek 26.9. Wzorzec projektowy Schody do nieba 1
[Martin 97].
360
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
PersistentObject to klasa abstrakcyjna, która ma dostęp do bazy danych. Definiuje dwie metody abstrakcyjne: read i write. Dostarcza również zestaw metod potrzebnych do zaimplementowania metod read i write. Na przykład klasa PersistentProduct wykorzystuje te narzędzia do zaimplementowania metod read i write, które odczytują i zapisują wszystkie pola obiektu Product z bazy danych i do bazy danych. Na takiej samej zasadzie klasa PersistentAssembly implementuje metody read i write do wykonania tych samych operacji na polach klasy Assembly. Klasa ta dziedziczy zdolność odczytywania i zapisywania pól obiektu Product od klasy PersistentProduct i tworzy struktury metod read i write w taki sposób, aby wykorzystywały ten fakt. Przedstawiony wzorzec można wykorzystać tylko w tych językach, które umożliwiają wielokrotne dziedziczenie. Zwróćmy uwagę, że klasy PersistentProduct i PersistentAssembly dziedziczą od dwóch zaimplementowanych klas bazowych. Co więcej, pomiędzy klasą PersistentAssembly a klasą Product zachodzi relacja dziedziczenia w układzie rombu. W języku C++ wykorzystuje się wirtualne dziedziczenie, aby nie dopuścić do tego, żeby dwa egzemplarze klasy Product były dziedziczone przez egzemplarz klasy PersistentAssembly. Potrzeba stosowania dziedziczenia wirtualnego lub podobnych relacji w innych językach oznacza, że korzystanie z prezentowanego wzorca jest dość uciążliwe. Można to odczuć w przypadku hierarchii klasy Product, ale uciążliwość jest minimalna. Korzyść wynikająca z zastosowania tego wzorca projektowego polega na tym, że powoduje on całkowite odseparowanie operacji na bazie danych od reguł biznesowych aplikacji. W tych krótkich fragmentach kodu, gdzie trzeba wywołać metody read i write, można to zrobić za pomocą kodu następującej postaci: PersistentObject* o = dynamic_cast(product); if (o) o->write();
Inaczej mówiąc, zadajemy obiektowi aplikacji pytanie o to, czy jest zgodny z interfejsem PersistentObject, a jeśli tak jest, to wywołujemy metodę read bądź write. Dzięki temu ta część aplikacji, która nie musi „nic wiedzieć” o operacjach odczytu i zapisu, jest całkowicie niezależna od tej części hierarchii, w której występuje klasa PersistentObject.
Przykład zastosowania wzorca Schody do nieba Przykład zastosowania wzorca projektowego Schody do nieba w języku C++ zaprezentowano na listingach od 26.24 do 26.34. Tak jak zwykle najlepiej zacząć od opracowania przypadku testowego. Pakiet CppUnit2 jest nieco zbyt rozbudowany, dlatego na listingu 26.24 uwzględniłem tylko te metody, które dotyczą przypadku testowego. Pierwszy przypadek testowy sprawdza, czy można przekazać w systemie obiekt PersistentProduct w roli obiektu Product, a następnie przekształcić go na obiekt PersistentObject i zapisać na żądanie. Zakładamy, że obiekt PersistentProduct będzie zapisywany w prostym formacie XML. Drugi przypadek testowy weryfikuje to samo dla obiektu PersistentAssembly. Jedyna różnica polega na dodaniu drugiego pola do obiektu Assembly. Listing 26.24. productPersistenceTestCase.cpp {skrócony} void ProductPersistenceTestCase::testWriteProduct() { ostrstream s; Product* p = new PersistentProduct("Cheerios"); PersistentObject* po = dynamic_cast(p); assert(po); po->write(s); char* writtenString = s.str(); 2
Jeden z frameworków testów jednostkowych z rodziny XUnit. Więcej informacji można znaleźć na stronach www.junit.org oraz www.xprogramming.com.
SCHODY DO NIEBA
}
361
assert(strcmp("Cheerios", writtenString) == 0);
void ProductPersistenceTestCase::testWriteAssembly() { ostrstream s; Assembly* a = new PersistentAssembly("Wheaties", "7734"); PersistentObject* po = dynamic_cast(a); assert(po); po->write(s); char* writtenString = s.str(); assert(strcmp("Wheaties" "7734", writtenString) == 0); }
Na listingach od 26.25 do 26.28 zamieszczono definicje i implementacje klas Product i Assembly. W trosce o oszczędność miejsca implementacja tych klas została skrócona. W rzeczywistej aplikacji klasy te zawierałyby metody implementujące reguły biznesowe. Warto zauważyć, że żadna z tych klas nie zawiera kodu związanego z utrwalaniem. Nie istnieją żadne zależności pomiędzy regułami biznesowymi a mechanizmem utrwalania. Na tym właśnie polega sens stosowania tego wzorca projektowego. Listing 26.25. product.h #ifndef STAIRWAYTOHEAVENPRODUCT_H #define STAIRWAYTOHEAVENPRODUCT_H #include class Product { public: Product(const string& name); virtual ~Product(); const string& getName() const {return itsName;} private: string itsName; }; #endif
Listing 26.26. product.cpp #include "product.h" Product::Product(const string& name) : itsName(name) { } Product::~Product() { }
Listing 26.27. assembly.h #ifndef STAIRWAYTOHEAVENASSEMBLY_H #define STAIRWAYTOHEAVENASSEMBLY_H #include #include "product.h" class Assembly : public virtual Product {
362
ROZDZIAŁ 26. WZORCE PROJEKTOWE PEŁNOMOCNIK I SCHODY DO NIEBA
public: Assembly(const string& name, const string& assyCode); virtual ~Assembly(); const string& getAssyCode() const {return itsAssyCode;} private: string itsAssyCode;
}; #endif
Listing 26.28. assembly.cpp #include "assembly.h" Assembly::Assembly(const string& name, const string& assyCode) :Product(name), itsAssyCode(assyCode) { } Assembly::~Assembly() { }
Chociaż charakterystyka zależności jest dobra, na listingu 26.27 występuje artefakt, którego obecność jest związana wyłącznie z zastosowaniem wzorca projektowego Schody do nieba. Klasa Assembly dziedziczy po klasie Product z wykorzystaniem słowa kluczowego virtual. Jest to konieczne z uwagi na zabezpieczenie przed podwójnym dziedziczeniem po klasie Product przez klasę PersistentAssembly. Jeśli wrócimy do rysunku 26.9, zauważymy, że klasa Product jest wierzchołkiem dziedziczenia w układzie rombu3 obejmującego klasy Assembly, PersistentProduct i PersistentObject. Aby nie dopuścić do podwójnego dziedziczenia po klasie Product, dziedziczenie musi być wirtualne. Na listingach 26.29 i 26.30 zamieszczono definicję i implementację klasy PersistentObject. Zwróćmy uwagę, że o ile klasa PersistentObject nic „nie wie” o hierarchii klasy Product, o tyle wydaje się „posiadać wiedzę” o sposobie zapisywania obiektów w formacie XML. Klasa „rozumie”, że obiekty są zapisywane w kolejności: nagłówek, pola oraz stopka. Listing 26.29. persistentObject.h #ifndef STAIRWAYTOHEAVENPERSISTENTOBJECT_H #define STAIRWAYTOHEAVENPERSISTENTOBJECT_H #include class PersistentObject { public: virtual ~PersistentObject(); virtual void write(ostream&) const; protected: virtual void writeFields(ostream&) const = 0;
};
private: virtual void writeHeader(ostream&) const = 0; virtual void writeFooter(ostream&) const = 0;
#endif
3
Czasami żartobliwie nazywana „śmiertelnym rombem śmierci”.
SCHODY DO NIEBA
363
Listing 26.30. persistentObject.cpp #include "persistentObject.h" PersistentObject::~PersistentObject() { } void PersistentObject::write(ostream& s) const { writeHeader(s); writeFields(s); writeFooter(s); s