« powrót

Aplikacja php - obiektowa architektura

Opublikowano: 2019-08-19 , wyświetlono: 18636

Kiedyś po różnych lekturach naszło mnie na wymyślenie jakiejś atrakcyjnej dla mnie architektury aplikacji w PHP, które przyszło mi pisać. Miała być czytelna, dość prosta w realizacji i konserwacji oraz w miarę elastyczna na rozbudowę.

Staram się w każdym projekcie coś nowego wymyślać czy to w implementacji, czy to w architekturze lub zastosować nową nieznaną mi bliżej technologię. Traci na tym co prawda termin wykonania produktu , ale jego jakość jest lepsza i mnie dają on większą satysfakcję. Poza tym w perspektywie czasu czas poświęcony na moje przemyślenia owocuje niższym kosztem rozbudowy o nowe funkcje i ogólna pielęgnację kodu aplikacji.

Aplikację podzieliłem na trzy warstwy: biznesowa, współpracy z bazą danych i prezentacyjna. Taka architektura została zastosowana przy odświeżeniu tego serwisu i ją opiszę by oprzeć się na realnym rozwiązaniu. Dodatkowo musiałem uwzględnić poprzedni projekt stworzony w perl’u i istniejące struktury danych w bazie mysql.

Pierwszym krokiem było stworzenie klas, które przedstawiają strukturę serwisu. Opiera się on na dwóch obiektach informacji i pojedynczej strony. Informacje mogą być dzielone na kanały (tutaj: artykuły, kody źródłowe i download). Dodatkowym elementem są komentarze, które w oryginalnej wersji były możliwe tylko dla informacji, a w nowej wersji miałyby mieć zastosowanie także do stron

Poniżej diagram klas, które powstały.


Schemat klas

Jak nietrudno zauważyć w każdej klasie powtarzają się metody ParseDbRow() i ToTemplateArray(). Pierwsza służy do pobrania danych do obiektu z wiersza bazy danych, druga do sformatowania właściwości obiektu do tablicy rozumianej przez bibliotekę szablonów.

Przykładową klasę postaram się opisać w komentarzach by mój tok rozumowania wyrazić jaśniej

class Info
{
  // tutaj są zdefinowane publiczne (przesadą by były prywatne metody set/get)
  public $Id;
  public $ChannelID;
  public $Title;
  public $Description;
  public $HtmlPage;
  public $IsDisplay;
  public $Counter;
  public $Status;
  public $Created;
  public $Modified;

  public $Body;
  public $BlockList;
  public $CommentList;

  // tutaj definicja stałych, używanych w obiekcie
  const BLOCK_TEXT = 0;
  const BLOCK_PHOTO = 1;
  const BLOCK_FILE = 2;
  const BLOCK_PAGE = 3;

  const BLOCK_STYLE_TEXT = 1;
  const BLOCK_STYLE_HTML = 2;
  const BLOCK_STYLE_CODE = 3;

  // definicja prefix’u dla biblioteki szablonów
  const PREFIX = "info_";

  // konstuktor
  public function __construct()
  {
    $this->init();
  }

  // ta matoda wypełnia obiekt danymi z wiersza bazy danych
  public function ParseDbRow($row)
  {
    $this->Id = $row[DbSchema::INFO_ID];
    $this->ChannelID = $row[DbSchema::INFO_CHANNEL_ID];
    $this->Title = $row[DbSchema::INFO_TITLE];
    $this->Description = $row[DbSchema::INFO_DESCRIPTION];
    $this->HtmlPage = $row[DbSchema::INFO_HTMLPAGE];
    $this->IsDisplay = $row[DbSchema::INFO_IS_DISPLAY];
    $this->Counter = $row[DbSchema::INFO_COUNTER];
    $this->Status = $row[DbSchema::INFO_STATUS];
    $this->Created = $row[DbSchema::INFO_CREATED];
    $this->Modified = $row[DbSchema::INFO_MODIFIED];
  }

  // tutaj jest zamiana informacji zawartych w obiekcie na tablicę
  // do użycia w bibliotece szablonów
  public function ToTemplateArray()
  {
    $a = array();

    $a[self::PREFIX . "id"] = $this->Id; 
    $a[self::PREFIX . "channel_id"] = $this->Title; 
    $a[self::PREFIX . "title"] = $this->Title; 
    $a[self::PREFIX . "description"] = $this->Description; 
    $a[self::PREFIX . "htmlpage"] = $this->HtmlPage; 
    $a[self::PREFIX . "is_display"] = $this->IsDisplay; 
    $a[self::PREFIX . "counter"] = $this->Counter; 
    $a[self::PREFIX . "status"] = $this->Status; 
    $a[self::PREFIX . "created"] = $this->Created; 
    $a[self::PREFIX . "modified"] = $this->Modified; 
    
    return ($a);
  }

  // to miało służyć dobudowy strony – jak się okazało na razie nie używane
  public function BuildBody()
  {
    $this->Body = "";
    foreach ($this->BlockList as $block)
    {
      $this->Body .= $block->Title . "\n";
      $this->Body .= $block->Content . "\n";
    }

    foreach ($this->CommentList as $comment)
    {
      $this->Body .= $block->Title . "\n";
      $this->Body .= $block->Content . "\n";
    }
  }

  private function init()
  {
    $this->Id = 0;
    $this->ChannelID = 0;
    $this->Title = "";
    $this->Description = "";
    $this->HtmlPage = "";
    $this->IsDisplay = 0;
    $this->Counter = 0;
    $this->Status = 0;
    $this->Created = date("'Y-m-d");
    $this->Modified = date("'Y-m-d");

    $this->Body = "";
  
    $this->BlockList = array();
    $this->CommentList = array();
  }
  
}
  

Tutaj bardzo ważna dygresja, dawno temu chciałem być takim purystą w projektowaniu klas i raczej nie zdecydowałbym się na takie miksowanie, że klasa ma takie związki z bazą danych i metodami prezentacji. Skłaniałem się do tego by warstwa, która odpowiada za łączność z bazą zwracała już obiekt biznesowy. Ale lata mijały, doświadczeń przybywało, pewność siebie wzrastała i zacząłem się poszukiwać rozwiązań czasem niezbyt zgodnych z regułami, ale sprawdzającymi się w praktyce i dających mnie pewną „czystość” tworzonego kodu.

I dlatego była to ważna dygresja bo polecam to każdemu. Trzeba z pokorą, ale i troszeczkę bez kompleksów patrzeć i korzystać z tego co piszą wielcy świata programowania i nie bać się własnych przemyśleń i rozwiązań, które czasem nie są zbyt zgodne z uznanymi regułami.

Np. jeżeli sami piszemy kod i mamy dość duże doświadczenie w danym środowisku co powoduje, że ilość występujących błędów jest minimalna i do tego projekt nie jest zbyt duży (poniżej 50K linii kodu) to nie ma co tworzyć jakiejś wielkiej ilości unit testów. Wystarczy skupić się w testowaniu na przebiegu całego procesu np. „wyświetlenie strony”.

A to co powyżej napisałem jest podstawa każdej książki o TDD.

Wracając do głównego tematu nadszedł czas na jakieś powiązanie zaprojektowanych klas z informacjami przechowywanymi w bazie danych. Poniżej diagram jak wygląda struktura bazy


Schemat klas

Zwykle dla każdego obiektu tworzę oddzielną klasę, która odpowiada za komunikację z tabelami bazy. W moim przypadku zdecydowałem się na rezygnację z klasy która obsługuje listę kanałów (było ich cztery) i przeniosłem wszystko do jednej klasy ListChannel, gdzie odtworzyłem zawartość tabeli z bazy danych. Podobnie postąpiłem z tabelą, która przechowuje bloki zawartości dla artykułu. Ty razem metody, które dokonują odczytu włączyłem do klasy obsługującej tabele informacji.

Wszystkie operacje na bazie zostały zagregowane w klasę DalService, czyli jedną klasę, która odpowiada za współprace z bazą i gorąco polecam takie rozwiązanie. Ja już nie siliłem za bardzo, ale w przyszłości należałoby z klasy DalService wydzielić interfejs co pozwoliłoby zupełnie uniezależnić aplikacje od sposobu przechowywania danych.

Jak kiedyś ten kod na tyle mnie zirytuje to dokonam tej zmiany i dopisze jakiś akapit.

Z tych moich przemyśleń wynikły klasy przedstawione na diagramie poniżej. Dodatkiem jest klasa DbSchema, która składa się tylko ze stałych, które określają nazwy tabel i pól w bazie.

Coś takiego też polecam, bezpośrednie stosowanie nazw pół w kodzie rodzi kłopot, gdy zachodzą zmiany w bazie. Stosując taką jedna klasę, która przechowuje te informacje ewentualnych modyfikacji dokonujemy tylko w jednym miejscu.

W tym momencie można by sobie napisać trochę testów, czy klasa DalService prawidłowo pobiera dane z bazy i będzie prawidłowo zasilać obiekty odpowiedzialne za prezentację.

Tutaj mamy do czynienia z ważnym elementem całej architektury, skupiając wszystkie operacje na bazie w jednej klasie do testowania pozostaje nam tylko ona. W przypadku jakiś błędów sięgamy do odpowiedniej klasy odpowiedzialnej za daną tabelę i tam dokonujemy poprawek. Pokrycie testami klasy DalService i ich pozytywny wynik gwarantuje prawidłową prace klas odpowiedzialnych za tabele, widoki, wywoływanie procedur, funkcji, itp.


Schemat klas

I trochę kodu, który urealnia diagram powyżej

class DalInfo
{
  // zmienna przechowująca klase bazt danych
  private $db;

  public function __construct($connDb)
  {
    $this->db = $connDb;
  }

  public function LoadAll()
  {
    $items = array();
    $info = null;

    $sql = sprintf("select * from %s order by id desc",
      DbSchema::TABLE_INFO);

    $rows = $this->db->ExecuteQuery($sql);
    foreach ($rows as $row)
    {
      $info = new Info();
      //
      // TU WIDAC UZYTECZNOSC i elegancjmetody, która wypełnia obiekt danymi z wiersza tabeli
      // kod jest którtki I elagancki 
      $info->ParseDbRow($row);

      $items[] = $info;
    }

    return ($items);
  }

  public function LoadChannel($channelID)
  {
    $channelID = $this->db->EscapeString($channelID);

    $items = array();
    $info = null;

    $sql = sprintf("select * from %s where id_channel = %d and status = 0 and is_display = 0 order by id desc",
      DbSchema::TABLE_INFO, $channelID);

    $rows = $this->db->ExecuteQuery($sql);
    foreach ($rows as $row)
    {
      $info = new Info();
      $info->ParseDbRow($row);

      $items[] = $info;
    }

    return ($items);
  }

  public function LoadById($id)
  {
    $id = $this->db->EscapeString($id);
    
    $sql = sprintf("select * from %s where id = %d",
      DbSchema::TABLE_INFO, $id);

    $rows = $this->db->ExecuteQuery($sql);
    
    $info = new Info();
    $info->ParseDbRow($rows[0]);

    $info->BlockList = $this->loadBlocks($info->Id);
    $info->CommentList = $this->loadComments($info->Id);
    $info->BuildBody();

    return ($info);
  }

  public function LoadTopRead()
  {
    $items = array();
    $info = null;

    $sql = sprintf("select * from %s where id_channel in (1, 2) and status = 0 and is_display = 0 order by counter desc",
      DbSchema::TABLE_INFO);

    $rows = $this->db->ExecuteQuery($sql);
    foreach ($rows as $row)
    {
      $info = new Info();
      $info->ParseDbRow($row);

      $items[] = $info;
    }

    return ($items);
  }

  private function loadBlocks($infoID)
  {
    $items = array();
    $infoBlock = null;

    $sql = sprintf("select * from %s where id_info = %d order by id",
      DbSchema::TABLE_INFOBLOCK, $infoID);

    $rows = $this->db->ExecuteQuery($sql);
    foreach ($rows as $row)
    {
      $infoBlock = new InfoBlock();
      $infoBlock->ParseDbRow($row);

      $items[] = $infoBlock;
    }

    return ($items);
  }

  private function loadComments($infoID)
  {
    $items = array();
    $comment = null;

    $sql = sprintf("select * from %s where id_info = %d order by id",
      DbSchema::TABLE_COMMENT, $infoID);

    $rows = $this->db->ExecuteQuery($sql);
    foreach ($rows as $row)
    {
      $comment = new Comment();
      $comment->ParseDbRow($row);

      $items[] = $comment;
    }

    return ($items);
  }
  
}
  

Teraz nadszedł czas na prezentację. Tu sobie wymyśliłem takie prezentery oparte o wzorzec Factory. Kod sterujący aplikacji wywołuje fabrykę prezenterów, gdzie na podstawie parametru module zwracany jest odpowiedni obiekt prezentacji. Parametr cmd decyduje o wykonaniu w danym prezenterze metody do wyświetlenia kodu html.

Dodatkowo postanowiłem zdefiniować dwie klasy bazowe, z których mogły by dziedziczyć prezentery dla poszczególnych modułów. Metody klas bazowych tworzą layout serwisu, odpowiadają za wyświetlanie nagłówka, stopki, ewentualnie innych stałych elementów strony. Mam dzięki temu też jakiś przydatny szkielet aplikacji.

Poniżej diagram z klasami prezenterów. Jak widać dla strony głównej jest zdefiniowany oddzielny prezenter. Wynika to ze specyfiki jakimi rządzi się gówna strona serwisu.


Schemat klas

I kod jednego z prezenterów

class HtmlInfo extends HtmlAppPresenter
{
  public function __construct($dal, $tpl)
  {
    parent::__construct($dal, $tpl);
    $this->setTitle("Technikalia");
  }

  protected function doCommand($cmd = "")
  {
    $html = "";
    
    switch ($cmd)
    {
      case ("list") :
        $html .= $this->listForm();
        break;
      case ("detail") :
        $html .= $this->detailForm();
        break;
      case ("comment_add") :
        $this->addComment();
        $html .= $this->detailForm();
        break;
      default: 
        $html .= $this->listForm();
        break;
    }
    
    return ($html);
  }

  // list all 
  //--------------------------------------------------------
  private function listForm()
  {
    $html = "";

    $channelCode = getParameter("channel_code", "article");

    $listChannel = new ListChannel();
    $channel = $listChannel->GetByCode($channelCode);
    $infoList = $this->dal->LoadInfoChannel($channel->Id);

    $html .= $this->tpl->Render("info/start_page/" . $channel->StartPage);
    $html .= $this->tpl->Render("info/list_head.htm");
    $this->tpl->AssignArray($channel->ToTemplateArray());

    $this->tpl->SetFile("info/list_item.htm");
    foreach ($infoList as $info)
    {
      $html .= $this->tpl->AssignArray($info->ToTemplateArray());
      $html .= $this->tpl->Get();
    }
    $html .= $this->tpl->Render("info/list_foot.htm");

    return ($html);
  } 

  // content form
  //--------------------------------------------------------
  private function detailForm()
  {
    $html = "";

    $id = getParameter("info_id", 0);
    $info = $this->dal->LoadInfo($id);
    
    $this->setTitle($info->Title);

    $listChannel = new ListChannel();
    $channel = $listChannel->GetById($info->ChannelID);

    $html = $this->tpl->Render("info/start_page/" . $channel->StartPage);

    $body = "";
    // build content
    // To powinno być przeniesione do klasy Info, metoda jest za duża nie pasuje do idei tej architekury
    foreach ($info->BlockList as $p)
    {
      switch ($p->Type)
      {
        case Info::BLOCK_TEXT: 
          if ($p->StyleID == Info::BLOCK_STYLE_CODE)
          {
            $p->Content = htmlspecialchars($p->Content);
            $this->tpl->SetFile("info/list_block_code.htm");
          }
          else if ($p->StyleID == Info::BLOCK_STYLE_HTML)
          {
            $this->tpl->SetFile("info/list_block.htm");
          }
          else if ($p->StyleID == Info::BLOCK_STYLE_TEXT)
          {
            $this->tpl->SetFile("info/list_block_text.htm");
          }
          else
            $this->tpl->SetFile("info/list_block.htm");
          break;

        case Info::BLOCK_PHOTO: 
          $p->Content = $p->Content;
          $this->tpl->SetFile("info/list_block_image.htm");
          break;

        case Info::BLOCK_FILE: 
          $this->tpl->SetFile("info/list_block_file.htm");
          break;

        case Info::BLOCK_PAGE: 
          $this->tpl->RenderFile($p->Content);
          break;

        default:
          $this->tpl->SetFile("info/list_block.htm");
          break;
      }

      $this->tpl->AssignArray($p->ToTemplateArray());
      $body .= $this->tpl->Get();
    }

    // build comments
    $body .= $this->tpl->RenderArray($info->ToTemplateArray(), "comment/add_form.htm");
    $body .= $this->tpl->Render("comment/list_head.htm");

    foreach ($info->CommentList as $c)
    {
      $this->tpl->SetFile("comment/list_item.htm");
      $this->tpl->AssignArray($c->ToTemplateArray());
      $body .= $this->tpl->Get();
    }
    $body .= $this->tpl->Render("comment/list_foot.htm");

    $this->tpl->SetFile("info/detail.htm");
    $this->tpl->AssignArray($channel->ToTemplateArray());
    $this->tpl->AssignArray($info->ToTemplateArray());
    $this->tpl->Assign("info_body", $body);

    $html .= $this->tpl->Get();
        
    return ($html);
  }

  // add comment
  //--------------------------------------------------------
  private function addComment()
  {
    $id = getParameter("info_id", 0);
    $vcode = getParameter("form_vcode", "");

    if ($vcode != "248")
    {
      return;
    }

    $comment = new Comment();
    $comment->ParseForm();

    $this->dal->AddComment($comment);
  }
}
      
  

Dzięki takiemu podziałowi odpowiedzialności sama aplikacja jest bardzooo krótka.

// app-site.php

ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);


include_once("include.php");

$dal = new DalService();

$appPath = "";
$tpl = new Template();
$tpl->SetPath($appPath . "html/");
$tpl->Assign("app", "app-site.php");

$factory = new PresenterFactory($dal, $tpl);
$presenter = $factory->CreatePresenter();
print $presenter->RenderHtml();

exit;

  

Dla uzupełnienia całości poniżej kod klas bazowych dla prezenterów i fabryka prezenterów.

abstract class HtmlPresenter
{
  protected $dal = null;
  protected $tpl = null;
  protected $isAjax = false;
  
  protected $body;

  public function __construct($dal, $tpl)
  {
    $this->dal = $dal;
    $this->tpl = $tpl;
    $this->body = "";
    $this->isAjax = false;
  }
  
  public function RenderHtml()
  {
    if ($this->isAjax)
      $this->renderAjax();
    else
      $this->render();
      
    return ($this->body);
  }
  
  public function IsAjax()
  {
    return ($this->isAjax);
  }
  
  protected abstract function render();
  protected abstract function renderAjax();

  };

class HtmlAppPresenter extends HtmlPresenter
{
  const DEFAULT_CMD = "home";
  const DEFAULT_MODULE = "home";

  protected $titlePage = "";
  
  public function __construct($dal, $tpl)
  {
    parent::__construct($dal, $tpl);
    $this->checkAjax();
    $this->titlePage = "Chinasoft - strona firmowa";
  }
  
  protected function checkAjax()
  {
    $ajaxFlag = getParameter("ajax_flag", 0);
    if ($ajaxFlag == 0)
      $this->isAjax = false; 
    else
      $this->isAjax = true;
  }
  
  protected function getModule()
  {
    $module = getParameter("module", self::DEFAULT_MODULE);
    return ($module);
  }

  protected function getCommand()
  {
    $command = getParameter("cmd", self::DEFAULT_CMD);
    return ($command);
  }
  
  protected function doCommand($cmd = "")
  {
    return ("");
  }
  
  
  protected function setTitle($title = "Chinasoft - strona firmowa")
  {
    $this->titlePage = "Chinasoft - " . $title;
  }

  protected function header()
  {
    $this->tpl->SetFile("head.html");
    $this->tpl->Assign("title_page", $this->titlePage);
    return ($this->tpl->Get());
  }

  protected function footer()
  {
    $this->tpl->Assign("phpversion", phpversion());
    $this->tpl->SetFile("foot.html");
    return ($this->tpl->Get());
  }
  
  protected function contentWrap($content = "")
  {
    $html = "";
    
    $this->tpl->SetFile("layout/content_start.htm");
    $html .= $this->tpl->Get();
    
    $html .= $content;

    $this->tpl->SetFile("layout/content_stop.htm");
    $html .= $this->tpl->Get();
    
    return ($html);
  }
  
  protected function render()
  {
    $content = $this->doCommand($this->getCommand());
    
    $this->body .= $this->header();
    $this->body .= $this->contentWrap($content);
    $this->body .= $this->footer();
  }

  protected function renderAjax()
  {
    $content = $this->doCommand($this->getCommand());
    $this->body .= $content;
  }
  
};


class PresenterFactory
{
  private $dal;
  private $tpl;
  private $module;
  
  public function __construct($dal, $tpl)
  {
    $this->dal = $dal;
    $this->tpl = $tpl;
  }
  
  protected function getModule()
  {
    $this->module = getParameter("module", "home");
  }
  
  public function CreatePresenter($module = "")
  {
    $presenter = null;
    
    if ($module == "")
      $this->getModule();
    else
      $this->module = $module;
    
    switch ($this->module)
    {
      case ("home") : 
        $presenter = new HtmlHome($this->dal, $this->tpl);
        break;
      case ("info") : 
        $presenter = new HtmlInfo($this->dal, $this->tpl);
        break;
      case ("page") : 
        $presenter = new HtmlPage($this->dal, $this->tpl);
        break;
      default:
        $presenter = new HtmlInfo($this->dal, $this->tpl);
        break;
  }
    
    // check access
    /*
    if (!$presenter->hasAccess())
    {
      $presenter = new HtmlUser($this->db, $this->tpl);
      $module = "user";
      $cmd = "login_form";
    }
    */

    return ($presenter);
  }
};

I na koniec obraz struktury katalogów z plikami klas


Schemat klas


Komentarze: