czwartek, 8 grudnia 2011

Jak programować obiektowo cz. 5 - metody cz III

let's do something cool:)

W końcu udało mi się napisać mniej więcej (mam nadzieję, że więcej:) wszystko na temat tego, co warto wiedzieć o metodach w teorii. Pora, więc na zobaczenie jak to wszystko sprawdza się w praktyce.

Tak więc mamy nasz przykład z drugiego wpisu:
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 na maila. Dodatkowo powinna istnieć możliwość określenia adresu siedziby kontrahenta.
Po przeanalizowaniu powyższego można dojść do wniosku, że kontrahent bez adresu plus wystawianie faktur, nie jest raczej, czymś co da się zrealizować, więc po 'krótkiej' rozmowie z klientem zmieniamy zdanie 'powinna istnieć możliwość określenia adresu siedziby kontrahenta' na 'Musimy określić adres siedziby kontrahenta'.

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:
<?php
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;
  }

Następnym wymaganiem jest możliwość wystawiania faktur, czyli potrzebujemy pobrać dane, które będą na fakturze. Czyli potrzebujemy otrzymać coś takiego:
Nazwa firmy
ulica
kod pocztowy, miasto
kraj
NIP: NIP firmy
Możemy zauważyć, że oprócz informacji nt. wartości atrybutów (typów podstawowych) obiektu klasy Contractor potrzebujemy również informacji z obiektu agregowanego klasy Address, czyli:
<?php
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ć odpowiednią metodą w klasie Contractor:
  //@class Contractor
  /**
   * @return string
   */
  public function getDataToInvoice()
  {
    return $this->_name.PHP_EOL.
           $this->_address->getDataToInvoice().PHP_EOL.
           'NIP: '.$this->_nip;
  }

Ok, to możemy już wygenerować fakturę.
Następnie należy ją przesłać. Na jaki email?
Dla uproszczenia zakładamy, że faktury są wysyłane do każdego zdefiniowanego kontaktu, więc po pierwsze musimy pobrać w jakiś sposób adres mailowy kontaktu:
<?php
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;
  }

Ostatnim wymaganiem, które mamy zaimplementować jest umożliwienie wykonywania przelewu. Aby wykonać taki przelew potrzebujemy numer konta bankowego, z tym, że to konto musi być przypisane do określonej w przelewie waluty.

Zacznijmy od metod klasy BankAccount.
Po pierwsze, musimy mieć możliwość sprawdzenia, czy dane konto bankowe obsługuje pożądaną walutę:
<?php
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:
<?php
  //@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 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 lub zwracać tablicę z numerami kont. Z tym, że w tym drugim przypadku trzeba byłoby się również zastanowić kto lub co decydowałoby o wykorzystaniu konkretnego numeru konta.

Drugą rzeczą, która podlega indywidualnej ocenie, jest wyrzucanie wyjątku w przypadku nieistniejącego konta bankowego. Osobiście preferuję taki rozwiązanie niż zwracanie pustego stringa bądź wartości false. To drugie (zwracanie false, null) jest niestety częstą praktyką. Dlaczego niestety? Ponieważ typ zwracany powinien zawsze być jednoznaczny.

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]);
        $removed++;
      }
    }
 
    if ($removed > 0)
      return true;

    throw new Exception('There is no bank account for currency: '.$currency);
  }

Działanie pierwszej metody jest chyba jasne:)

Druga metoda szuka istniejącego konta i jeżeli je znajdzie, to usuwa i zwraca wartość true. W innym przypadku wyrzuca wyjątek 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.
W przykładzie zdecydowałem się na określanie konta do usunięcia za pomocą obsługiwanej waluty. Oczywiście działanie takiej operacji w rzeczywistej aplikacji mogłoby opierać się na numerze konta bankowego. Takie kwestie należy wyjaśnić z klientem przed implementacją.

Pozostało nam zarządzanie osobami kontaktowymi.
Ponieważ w naszym przykładzie osoba kontaktowa to nazwa (imię i nazwisko) wraz z powiązanym z nim 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;
  }


Nie będę zajmował się tutaj zapisem nowego kontrahenta oraz edytowanego, ponieważ to już jest zależne od tego, w jaki sposób będziemy przechowywać dane. To samo dotyczy odnajdywania istniejącego kontrahenta.

Warto jeszcze dodać metodę pozwalającą na pobranie wszystkich danych nt. konkretnego obiektu, ponieważ z pewnością jednym z wymagań będzie podgląd danych na temat kontrahenta.
Dane takie zazwyczaj (w kontekście całej aplikacji) mają jeden format np. JSON, więc warto utworzyć interfejs:
<?php
{
  interface JsonEncode
  /**
   * @return string 
   */
  public function gatDataAsJson();
}


Ten interfejs powinny implementować wszystkie utworzone klasy, dzięki czemu w prosty sposób dostaniemy pełne informacje nt. instancji klasy Contractor wywołując na niej metodę getDataAsJson().

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() i addContactPerson() 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źć

9 komentarzy:

  1. Można by się przyczepić do wielu rzeczy, ale ja na razie wspomnę o jednej:
    Address::getDataToInvoice() - po jaką cholerę pchasz tam EOL i formatujesz dane? Tym się powinna zająć klasa szablonu faktury, pobierajac z Adress odpowiednio ->getStreetName(), -> getPostCode(), itd...

    OdpowiedzUsuń
  2. Właśnie po to jest metoda getDataToInvoice(), żeby nie mieć getera na każdą wartość.

    Wymagania w przykładzie są jasne: potrzebujesz wygenerować fakturę, więc wiesz jak te dane powinny wyglądać. Użycie metody getPostalCode() (getStreetName() etc.) jest bezużyteczne, bo nigdy nie zostanie ona wykonana osobno.

    Podążając za Twoim tokiem rozumowania kończysz z klasami, które posiadają getery dla każdego atrybutu, co jest błędne, jeżeli takie działanie nie ma uzasadnienia.

    A co do przyczepiania się do wielu rzeczy, to pisz o wszystkich, ponieważ chętnie skoryguje wszystkie nieprawidłowości:)

    OdpowiedzUsuń
  3. mysle ze scanner raczej miał na myśli że do klasy modelu wsadziłeś kawałek kodu odpowiedzialny za widok. a co jeśli klient będzie chiał żeby zamiast w nowej linijce, wyświetlać te dane w jakiejś tabelce, czy jakkolwiek inaczej zmienić prezentację danych? bedziesz musiał zmienić model.

    OdpowiedzUsuń
  4. Chyba nakładasz na widok zbyt wielką odpowiedzialność:)

    Widokiem w tym przykładzie będzie np. podgląd faktury (sama faktura już jest produktem, czyli modelem, więc nie widokiem).
    Ten widok oczekuje na konkretne dane m.in. od kontrahenta potrzebuje danych adresowych, więc je otrzymuje.
    Te dane wyświetlane są tylko w jednej postaci, nie podlegają obrabianiu, więc powinny zostać zwrócone jako gotowa do użycia wartość.

    Jest jeszcze metoda getDataAsJson(), która również zwraca dane w określonym formacie. Czy tym też powinien zajmować się widok, który otrzymywałby np. tablicę z danymi?
    Nie, ponieważ tak jak napisałem w przykładzie zwracane dane mają zazwyczaj jeden format i w tym formacie dane dotyczące obiektów powinny być zwracane.

    Co innego, gdy musimy mieć możliwość zwracania danych w różnych formatach. Wtedy decydujemy się na utworzenie metody zwracającej nam niezbędny zestaw danych, którego formatowanie przerzucamy na inne obiekty.

    OdpowiedzUsuń
  5. Sebastianie, chwilowo mam trochę urwanie głowy, ale obiecuję (jeśli nie masz nic przeciwko, oczywiście), że od przyszłego tygodnia nawiążemy może coś w stylu dyskusji międzyblogowej - nie, zęby wytykać komuś błędy, czy się kłócić, tylko żeby pokazać czytelnikom, różne podejścia do ciekawych tematów, co Ty na to?

    OdpowiedzUsuń
  6. Jestem jak najbardziej za:)
    Zastanawiam się tylko, w jaki sposób chciałbyś to zrealizować?

    OdpowiedzUsuń
  7. Fajny przykład. Ale każda aplikacja do faktur używa bazy danych. Czekam z niecierpliwością na następną część, w której gdzieś te dane się zapiszą.

    OdpowiedzUsuń
  8. Dobry artykuł. Popraw tylko to zdanie "W tym momencie musimy się wiedzieć w jaki sposób taka edycja będzie się dobywała."

    OdpowiedzUsuń