Jak programować obiektowo? cz. 4 – metod ciąg dalszy
|
Sporo czasu minęło od ostatniego wpisu z serii i chciałbym Was za to serdecznie przeprosić. Zdaję sobie sprawę z tego, że wielu z Was pewnie już przyjęło za pewnik, że kolejny się nie pojawi i będzie to jeszcze jedna seria, której autor nie zaczął tego, co rozpoczął. Chciałem Was jednak zapewnić, że pomimo sporych opóźnień i problemów organizacyjnych na przestrzeni kilku ostatnich miesięcy, moje plany pozostają nie zmienione. I mam nadzieję, że Wasza chęć zgłębienia aspektów programowania zorientowanego obiektowo również 🙂
Żeby nie przedłużać przejdę do tego, na co tak długo kazałem Wam czekać. Dzisiaj obiecany kod, który prezentuje to, co omówiłem w poprzednim wpisie. Tak, więc najwyższa pora aby zobaczyć jak to wszystko sprawdza się w praktyce.
Przed przeczytaniem dalszej części radzę odświeżyć sobie pamięć i spojrzeć na część drugą, ponieważ dzisiejszy przykład będzie bezpośrednią kontynuacją tego, co tam zaczęliśmy tworzyć.
Kod daj mnie tu…
Jak już pamiętacie tworzymy system dla firmy transportowej. Jednym z głównych części składowych ma być baza kontrahentów. Przy dodawaniu kontrahenta użytkownik musi określić jego NIP oraz unikalną nazwę. Musi istnieć możliwość wystawiania i przesyłania drogą mailową faktur za usługi firmy oraz musi być możliwość wykonania przelewu na konto kontrahenta z którego usług korzystała firma. Firma jest międzynarodowa, więc musi istnieć możliwość obsługi różnych walut. Niektórzy kontrahenci mają kilka osób, do których chcą aby były wysyłane faktury. Dodatkowo powinna istnieć możliwość określenia adresu siedziby kontrahenta.
Po przeanalizowaniu powyższego można dojść do wniosku, że „kontrahent bez adresu” oraz funkcjonalność wystawiania faktur nie jest czymś co da się zrealizować. Decydujemy się na rozmowę z klientem i wspólnie decydujemy się wprowadzić zmianę w wymaganiach - zdanie „powinna istnieć możliwość określenia adresu siedziby kontrahenta” zmieniamy na „adres siedziby kontrahenta jest niezbędny do jego utworzenia”.
Implementacja wymagań
Wiemy, że każdy kontrahent musi posiadać NIP, unikalną nazwę oraz adres siedziby czyli istnienie kontrahenta bez tych danych nie ma żadnego sensu. Dzięki temu jesteśmy w stanie określić jakie parametry powinien przyjmować konstruktor:
class Contractor
{
/**
* @param string $name
* @param string $nip
* @param Address $address
*/
public function __construct($name, $nip, Address $address)
{
$this->_name = $name;
$this->_nip = $nip;
$this->_address = $address;
}
Kolejną funkcjonalnością, którą mamy zaimplementować jest możliwość wystawiania faktur. Aby to zrobić potrzebujemy pobrać dane, które są niezbędne do wystawienia faktury, czyli:
- Nazwa firmy
- ulica
- kod pocztowy, miasto
- kraj
- NIP firmy
Jak łatwo zauważyć, oprócz informacji nt. wartości atrybutów, które są typów prostych obiektu klasy Contractor
potrzebujemy również informacji z obiektu agregowanego klasy Address
. W wyniku tej potrzeby może powstać nam taki kod:
class Address
{
/**
* @return string
*/
public function getDataToInvoice()
{
return $this->_street . ' ' . $this->_houseNumber . PHP_EOL .
$this->_zipCode . ', ' . $this->_city . PHP_EOL .
$this->_country;
}
Kiedy już posiadamy tę metodę, możemy utworzyć analogiczną metodę w klasie Contractor
:
//@class Contractor
/**
* @return string
*/
public function getDataToInvoice()
{
return $this->_name . PHP_EOL .
$this->_address->getDataToInvoice() . PHP_EOL .
'NIP: '.$this->_nip;
}
I na koniec tego paragrafu pytanie do Was - jakie są minusy takiego rozwiązania? Czy w swojej aplikacji zdecydowalibyście się na inne rozwiązanie? Jakie?
Wysyłanie maili
Ok, dane już mamy, więc do wygenerowania faktury już niczego więcej nam nie trzeba. Fakturę jednak trzeba będzie jeszcze przesłać. Tylko gdzie? Na jaki e-mail? W pierwszej iteracji decydujemy się na wysyłanie faktury do każdego zdefiniowanego kontaktu, więc po pierwsze musimy dobrać się w jakiś sposób do ich adresów mailowych:
class ContactPerson
{
/**
* @return string
*/
public function getEmailAddress()
{
return $this->_email;
}
I na koniec musimy zwrócić adresy mailowe wszystkich kontaktów kontrahenta:
//@class Contractor
/**
* @return string[]
*/
public function getEmailAddresses()
{
$emailAddresses = array();
foreach ($this->_contactPersons as $contactPerson)
$emailAddresses[] = $contactPerson->getEmailAddress();
return $emailAddresses;
}
A gdzie numer konta?
Ostatnią rzeczą, którą mamy zaimplementować jest umożliwienie wykonywania przelewu. Żeby taka operacja była w ogóle możliwa niezbędny jest nam numer konta bankowego kontrahenta, z tym, że to konto musi obsługiwać określoną w przelewie walutę.
Zacznijmy od metod klasy BankAccount
. Po pierwsze, musimy mieć możliwość sprawdzenia, czy dane konto bankowe obsługuje pożądaną walutę:
class BankAccount
{
/**
* @param string $currency
* @return boolean
*/
public function isSupportCurrency($currency)
{
return $this->_currency === $currency;
}
Po drugie, musimy mieć możliwość pobrania numeru tego konta bankowego:
//@class BankAccount
/**
* @return string
*/
public function getNumber()
{
return $this->_number;
}
I po trzecie, należy zaimplementować możliwość wyciągnięcia tych danych z obiektu klasy Contractor
:
//@class Contractor
/**
* @throws Exception
* @return string
*/
public function getBankAccountNumberForCurrency($currency)
{
foreach ($this->_bankAccounts as $bankAccount)
{
if ($bankAccount->isSupportCurrency($currency))
return $bankAccount->getNumber();
}
throw new Exception('There is no bank account number for currency: '.$currency);
}
Dla uproszczenia logiki zdecydowałem się na zwracanie pierwszego numeru konta wspierającego daną walutę. Oczywiście w przypadku realizacji takiej aplikacji trzeba byłoby zastanowić się, czy dopuszczamy, aby kontrahent posiadał tylko jedno konto dla określonej waluty czy też zwracamy kolekcję z numerami kont. W przypadku dopuszczenia istnienia większej ilości kont dla danej waluty niezbędne byłoby również przemyślenie tego, kto lub co może decydować o wykorzystaniu konkretnego numeru konta. Jakieś globalne ustawienia? Jakiś algorytm z odpowiednimi regułami? Albo pozwalamy użytkownikowi wybrać jedno konto z listy poprawnych Takie rzeczy bezwzględnie należałoby przedyskutować z klientem.
Drugą rzeczą, nad którą należy się zastanowić jest sposób obsługi braku danego konta. Wyrzucenie wyjątku czy null
? Czy może jeszcze coś innego. Ja zdecydowałem się na wyrzucenie wyjątku, ale może null
byłby odpowiedniejszy? A może wszystko zależy od wymagań? Jaka jest Wasza opinia?
Operacje na obiektach
Oczywiście musimy jeszcze zaimplementować kilka metod, które pozwolą nam na edytowanie obiektów klasy Contractor
.
Zacznijmy od klasy Address
. Z wymagań wiemy, że kontrahent musi posiadać jeden adres, a co za tym idzie nie możemy go usunąć lub dodać kolejnego. Nic jednak nie stoi na przeszkodzie, aby taki adres kontrahenta zmienić:
//@class Contractor
/**
* @param Address $address
*/
public function changeAddress(Address $address)
{
$this->_address = $address;
}
Następnie musi istnieć możliwość wykonywania operacji na kontach bankowych klienta. Numeru konta bankowego raczej nie zmienimy, a dla uproszczenia zakładam, że waluty, którą obsługuje dane konto bankowe, również nie jesteśmy w stanie zmienić. Tak więc pozostaje nam dodawanie i usuwanie:
//@class Contractor
/**
* @param BankAccount
*/
public function addBankAccount(BankAccount $bankAccount)
{
$this->_bankAccounts[] = $bankAccount;
}
/**
* @param string $currency
* @throws Exception
* @return boolean
*/
public function removeBankAccountForSpecificCurrency($currency)
{
$removed = 0;
foreach ($this->_bankAccounts as $key => $bankAccount)
{
if ($bankAccount->isSupportCurrency($currency))
{
unset($this->_bankAccounts[$key]);
return true;
}
}
throw new Exception('There is no bank account for currency: '.$currency);
}
Działanie pierwszej metody jest chyba jasne. Niemniej jednak, czy nie wydaje się Wam, że czegoś w niej brakuje?
Druga metoda szuka istniejącego konta i jeżeli je znajdzie, to usuwa, a następnie zwraca wartość true
. W innym przypadku wyrzuca wyjątek informujący o tym, że nie istnieje konto bankowe obsługujące daną walutę. Dlaczego wyjątek? Bo tak naprawdę staramy się wykonać niedozwoloną operację, czyli usunięcie czegoś, czego tak naprawdę nie ma. A dlaczego nie false
? Bo jeżeli chcecie sprawdzić istnienie danego konta to, w myśl Single responsibility principle powinna powstać jeszcze jedna metoda, która umożliwia takie sprawdzenie.
W przykładzie zdecydowałem się na określanie konta do usunięcia za pomocą obsługiwanej waluty (zakładając, że może istnieć jedno konto per waluta). Oczywiście działanie takiej operacji w rzeczywistej aplikacji mogłoby opierać się na numerze konta bankowego (a może wręcz powinno?). Przy założeniu jednego konta dla danej waluty efekt końcowy jest ten sam, ale zastanówcie się jak to ma się do zasady Open/Closed?
Zarządzanie osobami kontaktowymi
Ponieważ w naszym przykładzie osoba kontaktowa to nazwa (imię i nazwisko) wraz z powiązanym z nią adresem email, również zdecyduję się na uproszczenie i uniemożliwienie edycji wartości tych atrybutów dla istniejących osób kontaktowych. W związku z tym mamy również do zaimplementowania dodawanie i usuwanie osób kontaktowych:
//@class Contractor
/**
* @param ContactPerson
*/
public function addContactPerson(ContactPerson $contactPerson)
{
$this->_contactPersons[] = $contactPerson;
}
/**
* @param string $name
* @throws Exception
* @return boolean
*/
public function removeContactPerson($name)
{
foreach ($this->_contactPersons as $key => $contactPerson)
{
if ($contactPerson->getName() === $name)
{
unset($this->_contactPersons[$key]);
return true;
}
}
throw new Exception('There is no contact person with name: '.$name);
}
Dodawanie osoby kontaktowej jest proste.
Natomiast implementując metodę usuwającą osobę kontaktową można zauważyć, że niezbędna do jej wykonania jest metoda getName()
. Tak więc mamy:
//@class ContactPerson
/**
* @return string
*/
public function getName()
{
return $this->_name;
}
Po utworzeniu tych wszystkich metod warto zastanowić się również nad edycją wartości atrybutów typów podstawowych obiektów klasy Contractor
. Mamy dwa takie atrybuty: name
i NIP
. W tym momencie musimy się dowiedzieć w jaki sposób taka edycja będzie się odbywała. Czy będą to dwie osobne akcje tzn. użytkownik systemu edytuje albo nazwę kontrahenta albo NIP. Czy może będzie to edycja wszystkich danych. Zazwyczaj zezwala się na pełną edycję i zakładam, że w tym przypadku tak jest. Dlatego też wystarczy nam tylko jedna metoda:
//@class Contractor
/**
* @param string $name
* @param string $nip
*/
public function changeData($name, $nip)
{
$this->_name = $name;
$this->_nip = $nip;
}
Kilka uwag na koniec
Gdyby ten kod był implementowany w rzeczywistej aplikacji to należałoby pamiętać jeszcze o kilku rzeczach:
- warto sprawdzać typ parametrów podstawowych (pozostałe można w PHP typować) i wyrzucać wyjątek
InvalidArgumentException
w przypadku, gdy są nieprawidłowe - metody
addBankAccount()
iaddContactPerson()
są niewystarczające, ponieważ należałoby jeszcze uwzględnić, co w przypadku dodawania takiego samego obiektu, bądź obiektu, który posiada takie same wartości dla pól unikalnych (np. numer konta bankowego) - wszelkie wątpliwości należy rozwiewać poprzez rozmowę z klientem, a nie na podstawie intuicji, ponieważ ta niestety może nas zawieść.
I jeszcze na koniec zapraszam do odpowiedzi na zadane przeze mnie pytania, zapraszam do dyskusji nad stworzonym kodem, ciekaw jestem Waszych uwag i przemyśleń.
Pozostałe artykuły z cyklu
- Jak programować obiektowo? cz. 9 – klasy abstrakcyjne
- Jak programować obiektowo? cz. 8 – interfejsy
- Jak programować obiektowo? cz. 7 – final
- Jak programować obiektowo? cz. 6 – wartości stałe
- Jak programować obiektowo? cz. 5 – ach ten static…
- Jak programować obiektowo? cz. 4 – metod ciąg dalszy
- Jak programować obiektowo? cz. 3 – czym jest metoda?
- Jak programować obiektowo? cz. 2 – atrybuty klasy
- Jak programować obiektowo? cz. 1 – wstęp