MVC w praktyce z composer – tworzymy system artykułów. cz. 1
Ponad trzy i pół roku temu na moim blogu pojawił się cykl MVC w praktyce. Mimo, że minęło już tyle lat od publikacji nadal cieszy się dużą popularnością. Postanowiłem go odświeżyć i wykorzystać aktualne narzędzia przydatne w programowaniu PHP.
Uwaga: Żeby w pełni zrozumieć ideę tego wzorca projektowego czytelnik musi mieć solidne podstawy znajomości PHP oraz potrafić programować obiektowo.
Co nowego?
Najważniejszymi zmianami w kodzie są:
- Wykorzystanie przestrzeni nazw
- Użycie Composer
- Wykorzystanie standardu PSR-4. Więcej o tym standardzie przeczytasz na blogu Dominika Marczuka
- Aplikacja zawiera router do tworzenia przyjaznych linków SEO
Trochę teorii…
Model-View-Controller został zaprojektowany w 1979 roku przez norweskiego programistę Trygve Reenskaug pracującego wtedy nad językiem Smalltalk w laboratoriach Xerox i początkowo nosił nazwę Model-View-Editor.
Ideą tego wzorca jest rozdzielenie kodu odpowiedzialnego za przetworzenie danych od kodu odpowiedzialnego za ich wyświetlanie.
Model-View-Controller zakłada podział aplikacji na trzy główne części:
- Model jest pewną reprezentacją problemu bądź logiki aplikacji.
- Widok opisuje, jak wyświetlić pewną część modelu w ramach interfejsu użytkownika.
- Kontroler przyjmuje dane wejściowe od użytkownika i reaguje na jego poczynania, zarządzając aktualizacje modelu oraz odświeżenie widoków.
Brzmi strasznie, ale w praktyce okazuje się, że to wcale nie jest takie trudne …
No to zaczynamy!
Na samym początku stwórzmy szkielet katalogów i plików:
src/ /* Katalog z kodem aplikacji */ src/Controller /* Miejsce na kontrolery */ src/Model /* Miejsce na modele */ src/View /* Miejsce na widoki */ src/template /* Miejsce na szablony HTML */ src/Engine /* Silnik aplikacji */ src/Engine/Router /* Router aplikacji */ vendor/ /* Tu będą pliki Composer */ .htaccess composer.json /* Konfiguracja aplikacji dla Composer */ config.php /* Konfiguracja aplikacji */ config-router.php /* Tablica dla routera */ index.php
Tworzymy szkielet aplikacji
Mając strukturę katalogów i plików możemy przejść do tworzenia kodu PHP.
Zaczynamy od kilku podstawowych plików
.htacess
Options +FollowSymLinks RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*)$ index.php [NC,L]
Plik config.php zawiera dane dostępu do aplikacji oraz podstawowe ścieżki aplikacji.
<?php define('DATABASE_NAME', '****'); define('DATABASE_USER', '****'); define('DATABASE_HOST', '****'); define('DATABASE_PASSOWD', '****'); define('DIR_VENDOR', '/sciezka/do/katalogu/vendor/'); define('DIR_TEMPLATE', '/sciezka/do/katalogu/template/'); define('DIR_CONTROLLER', '/sciezka/do/katalogu/Controller/'); define('HTTP_SERVER', 'http://adres-aplikacji.pl/');
Aktualizacja 1.07.2015: Ścieżki w pliku config.php muszą być bezwzględne. Z maili od was wynika, że dla części z Was nie było to jasne.
Plik config-router.php zawiera kolekcję linków dla routera. Więcej o routerze wspomnę później.
<?php $collection = new \RacyMind\MVCWPraktyce\Engine\Router\RouteCollection(); $collection->add('category/delete', new \RacyMind\MVCWPraktyce\Engine\Router\Route( HTTP_SERVER.'kategorie/usun/<id>?', array( 'file' => DIR_CONTROLLER.'Category.php', 'method' => 'delete', 'class' => '\RacyMind\MVCWPraktyce\Controller\Category' ), array( 'id' => '\d+' ), array( 'id' => 0 ) )); $collection->add('category/add', new \RacyMind\MVCWPraktyce\Engine\Router\Route( HTTP_SERVER.'kategorie/dodaj', array( 'file' => DIR_CONTROLLER.'Category.php', 'method' => 'add', 'class' => '\RacyMind\MVCWPraktyce\Controller\Category' ) )); $collection->add('category/index', new \RacyMind\MVCWPraktyce\Engine\Router\Route( HTTP_SERVER.'kategorie', array( 'file' => DIR_CONTROLLER.'Category.php', 'method' => 'index', 'class' => '\RacyMind\MVCWPraktyce\Controller\Category' ) )); $collection->add('article/delete', new \RacyMind\MVCWPraktyce\Engine\Router\Route( HTTP_SERVER.'artykuly/usun/<id>?', array( 'file' => DIR_CONTROLLER.'Article.php', 'method' => 'delete', 'class' => '\RacyMind\MVCWPraktyce\Controller\Article' ), array( 'id' => '\d+' ), array( 'id' => 0 ) )); $collection->add('article/one', new \RacyMind\MVCWPraktyce\Engine\Router\Route( HTTP_SERVER.'artykuly/wyswietl/<id>?', array( 'file' => DIR_CONTROLLER.'Article.php', 'method' => 'one', 'class' => '\RacyMind\MVCWPraktyce\Controller\Article' ), array( 'id' => '\d+' ), array( 'id' => 0 ) )); $collection->add('article/add', new \RacyMind\MVCWPraktyce\Engine\Router\Route( HTTP_SERVER.'artykuly/dodaj', array( 'file' => DIR_CONTROLLER.'Article.php', 'method' => 'add', 'class' => '\RacyMind\MVCWPraktyce\Controller\Article' ) )); $collection->add('article/index', new \RacyMind\MVCWPraktyce\Engine\Router\Route( HTTP_SERVER.'artykuly', array( 'file' => DIR_CONTROLLER.'Article.php', 'method' => 'index', 'class' => '\RacyMind\MVCWPraktyce\Controller\Article' ) )); $collection->add('homepage', new \RacyMind\MVCWPraktyce\Engine\Router\Route( HTTP_SERVER.'', array( 'file' => DIR_CONTROLLER.'Article.php', 'method' => 'index', 'class' => '\RacyMind\MVCWPraktyce\Controller\Article' ) )); $router = new \RacyMind\MVCWPraktyce\Engine\Router\Router($_SERVER['REQUEST_URI'], $collection);
index.php
<?php require_once 'config.php'; $loader = include DIR_VENDOR.'autoload.php'; require_once 'config-router.php'; $router = new \RacyMind\MVCWPraktyce\Engine\Router\Router('http://'.$_SERVER["SERVER_NAME"].$_SERVER["REQUEST_URI"]); $router->run(); $file=$router->getFile(); $classController=$router->getClass(); $method=$router->getMethod(); require_once($file); $obj = new $classController(); $obj->$method();
Abstrakcyjne klasy dla kontrolera, widoku i modelu
Kolejną rzeczą jaką stworzymy są abstrakcyjne klasy, które będą dziedziczone przez konkretne kontrolery, widoki i modele.
src/Engine/Controller.php
<?php namespace RacyMind\MVCWPraktyce\Engine; /** * This class includes methods for controllers. * @package RacyMind\MVCWPraktyce\Engine * @author Łukasz Socha <kontakt@lukasz-socha.pl> * @version: 1.0 * @license http://www.gnu.org/copyleft/lesser.html * * @abstract */ abstract class Controller { /** * Przekierowuje na wskazany adres * * @param string $url URL do przekierowania * * @return void */ public function redirect($url) { header("location: " . $url); } /** * Generuje link. * @param $name * @param null $data * @return bool|string */ public function generateUrl($name, $data = null) { $router = new \RacyMind\MVCWPraktyce\Engine\Router\Router('http://' . $_SERVER["SERVER_NAME"] . $_SERVER["REQUEST_URI"]); $collection = $router->getCollection(); $route = $collection->get($name); if (isset($route)) { return $route->geneRateUrl($data); } return false; } }
Klasa Controller zawiera 2 metody. Metoda redirect() przekierowuje użytkownika na wskazany adres URL. Metoda generateUrl() tworzy adres URL na postawie kolekcji zawartej w pliku config.router.php.
src/Engine/Model.php
<?php namespace RacyMind\MVCWPraktyce\Engine; use \PDO; /** * @package RacyMind\MVCWPraktyce\Engine * @author Łukasz Socha <kontakt@lukasz-socha.pl> * @version: 1.0 * @license http://www.gnu.org/copyleft/lesser.html */ /** * This class includes methods for models. * * @abstract */ abstract class Model { /** * object of the class PDO * * @var object */ protected $pdo; /** * It sets connect with the database. * * @return void */ public function __construct() { try { $this->pdo = new PDO('mysql:host=' . DATABASE_HOST . ';dbname=' . DATABASE_NAME, DATABASE_USER, DATABASE_PASSOWD, array(PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8")); $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (DBException $e) { echo 'The connect can not create: ' . $e->getMessage(); } } }
Klasa Model tworzy połączenie z bazą danych. Do manipulacji bazą danych wykorzystuję PDO.
src/Engine/View.php
<?php namespace RacyMind\MVCWPraktyce\Engine; /** * This class includes methods for views. * @package RacyMind\MVCWPraktyce\Engine * @author Łukasz Socha <kontakt@lukasz-socha.pl> * @version: 1.0 * @license http://www.gnu.org/copyleft/lesser.html * * @abstract */ abstract class View{ /** * Generuje link. * @param $name * @param null $data * @return bool|string */ public function generateUrl($name, $data=null) { $router = new \RacyMind\MVCWPraktyce\Engine\Router\Router('http://' . $_SERVER["SERVER_NAME"] . $_SERVER["REQUEST_URI"]); $collection = $router->getCollection(); $route=$collection->get($name); if (isset($route)) { return $route->geneRateUrl($data); } return false; } /** * Wyświetla kod HTML szablonu * * @param string $name Nazwa pliku * @param string $path Ścieżka do szablonu * * @return void */ public function renderHTML($name, $path='') { $path=DIR_TEMPLATE.$path.$name.'.html.php'; try { if(is_file($path)) { require $path; } else { throw new \Exception('Can not open template '.$name.' in: '.$path); } } catch(\Exception $e) { echo $e->getMessage().'<br /> File: '.$e->getFile().'<br /> Code line: '.$e->getLine().'<br /> Trace: '.$e->getTraceAsString(); exit; } } /** * Wyświetla dane JSON. * @param array $data Dane do wyświetlenia */ public function renderJSON($data) { header('Content-Type: application/json'); echo json_encode($data); exit; } /** * Wyświetla dane JSONP. * @param array $data Dane do wyświetlenia */ public function renderJSONP($data) { header('Content-Type: application/json'); echo $_GET['callback'] . '(' . json_encode($data) . ')'; exit(); } /** * Ładuje nagłówek strony */ public function getHeader() { return $this->renderHTML('header', 'front/'); } /** * Ładuje stopkę strony */ public function getFooter() { return $this->renderHTML('footer', 'front/'); } /** * It sets data. * * @param string $name * @param mixed $value * * @return void */ public function set($name, $value) { $this->$name=$value; } /** * It sets data. * * @param string $name * @param mixed $value * * @return void */ public function __set($name, $value) { $this->$name=$value; } /** * It gets data. * * @param string $name * * @return mixed */ public function get($name) { return $this->$name; } /** * It gets data. * * @param string $name * * @return mixed */ public function __get($name) { if( isset($this->$name) ) return $this->$name; return null; } }
Opis najważniejszych metod:
- generateUrl() – podobnie jak w kontrolerze, metody tworzy odpowiedni adres URL na podstawie konfiguracji routera.
- renderHTML() – wyświetla wskazany plik z szablonem HTML.
- renderJSON() – zwraca dane w formacie JSON.
- renderJSONP() – zwraca dane w formacie JSONP.
- getHeader() – wyświetla nagłówek strony.
- getFooter() – wyświetla stopkę strony.
Nagłówek i stopka strony
Nagłówek i stopka będą wyświetlane na każdej podstronie aplikacji.
src/template/front/header.html.php
<!doctype html> <html xmlns="http://www.w3.org/1999/xhtml" lang="pl-PL"> <head> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta charset="utf-8"/> <title>MVC</title> </head> <body> <ul> <li><a href="<?php echo $this->generateUrl('category/add'); ?>">Dodaj kategorię</a></li> <li><a href="<?php echo $this->generateUrl('category/index'); ?>">Lista kategorii</a></li> <li><a href="<?php echo $this->generateUrl('article/add'); ?>">Dodaj artykuł</a> <li><a href="<?php echo $this->generateUrl('article/index'); ?>">Lista artykułów</a> </ul>
src/template/front/footer.html.php
</body> </html>
Routing linków
W aplikacji MVC wykorzystuję opisany już jakiś czas temu router linków. Dzięki temu w łatwy sposób mogę tworzyć linki przyjazne dla SEO. Jeżeli jesteś ciekaw na jakiej zasadzie działa router zapraszam do lektury :).
src/Engine/Router/Route.php
<?php namespace RacyMind\MVCWPraktyce\Engine\Router; /** * Klasa zawiera pojedyńczy element do routingu. * @package RacyMind\MVCWPraktyce\Engine\Router * @author Łukasz Socha <kontakt@lukasz-socha.pl> * @version 1.0 */ class Route { /** * @var string Ścieżka URL */ protected $path; /** * @var string Ścieżka do kontrolera */ protected $file; /** * @var string Nazwa klasy */ protected $class; /** * @var string Nazwa metody */ protected $method; /** * @var array Zawiera wartości domyślne dla parametrów */ protected $defaults; /** * @var array Zawiera reguły przetważania dla parametrów */ protected $params; /** * @param string $path Ścieżka URL * @param array $config Tablica ze ścieżką do kontrolera oraz nazwą metody * @param array $params Tablica reguł przetważania dla parametrów * @param array $defaults Tablica wartości domyślne parametrów */ public function __construct($path, $config, $params = array(), $defaults = array()) { $this->path = $path; $this->file = $config['file']; $this->method = $config['method']; $this->class = $config['class']; $this->setParams($params); $this->setDefaults($defaults); } /** * @param string $file */ public function setFile($file) { $this->file = $file; } /** * @return string */ public function getFile() { return $this->file; } /** * @param string $class */ public function setClass($class) { $this->class = $class; } /** * @return string */ public function getClass() { return $this->class; } /** * @param array $defaults */ public function setDefaults($defaults) { $this->defaults = $defaults; } /** * @return array */ public function getDefaults() { return $this->defaults; } /** * @param string $method */ public function setMethod($method) { $this->method = $method; } /** * @return string */ public function getMethod() { return $this->method; } /** * @param array $params */ public function setParams($params) { $this->params = $params; } /** * @return array */ public function getParams() { return $this->params; } /** * @param string $path */ public function setPath($path) { $this->path =$path; } /** * @return string */ public function getPath() { return $this->path; } /** * Generuje przyjazny link. * @param array $data * @return string */ public function generateUrl($data) { if (is_array($data) && sizeof($data)>0) { $key_data = array_keys($data); foreach ($key_data as $key) { $data2['<' . $key . '>'] = $data[$key]; } $url = str_replace(array('?', '(', ')'), array('', '', ''), $this->path); return str_replace(array_keys($data2), $data2, $url); } else { $url=preg_replace("#<[a-zA-Z0-9]*>#", '', $this->path, 1); return str_replace(array('?', '(', ')'), array('', '', ''), $url); } } }
src/Engine/Router/RouteCollection.php
<?php namespace RacyMind\MVCWPraktyce\Engine\Router; /** * Klasa zawiera kolekcję elementów klasy Route. * @package RacyMind\MVCWPraktyce\Engine\Router * @author Łukasz Socha <kontakt@lukasz-socha.pl> * @version 1.0 */ class RouteCollection { /** * @var array Tablica obiektów klasy Route */ protected $items; /** * Dodaje obiekt Route do kolekcji * @param string $name Nazwa elementu * @param Route $item Obiekt Route */ public function add($name, $item) { $this->items[$name] = $item; } public function get($name) { if (array_key_exists($name, $this->items)) { return $this->items[$name]; } else { return null; } } /** * Zwraca wszystkie obiekty kolekcji * @return array array */ public function getAll() { return $this->items; } }
src/Engine/Router/Router.php
<?php namespace RacyMind\MVCWPraktyce\Engine\Router; /** * Klasa Routera. * @package RacyMind\MVCWPraktyce\Engine\Router * @author Łukasz Socha <kontakt@lukasz-socha.pl> * @version 1.0 */ class Router { /** * @var String URL do przetworzenia */ protected $url; /** * @var array Zawiera objekt RouteCollecion. */ protected static $collection; /** * @var string Ścieżka do kontrolera */ protected $file; /** * @var string Nazwa klasy */ protected $class; /** * @var string Nazwa metody */ protected $method; public function __construct($url, $collection = null) { if ($collection != null) { Router::$collection = $collection; } $url=explode('?', $url); $this->url = $url[0]; } /** * @param array $collection */ public function setCollection($collection) { Router::$collection = $collection; } /** * @return array */ public function getCollection() { return Router::$collection; } /** * @param string $class */ public function setClass($class) { $this->class = $class; } /** * @return string */ public function getClass() { return $this->class; } /** * @param string $file */ public function setFile($file) { $this->file = $file; } /** * @return string */ public function getFile() { return $this->file; } /** * @param string $method */ public function setMethod($method) { $this->method = $method; } /** * @return string */ public function getMethod() { return $this->method; } /** * @param String $url */ public function setUrl($url) { $this->url = $url; } /** * @return String */ public function getUrl() { return $this->url; } /** * Sprawdza czy URL pasuje do przekazanej reguły. * @param Route $route Obiekt reguły * @return bool */ protected function matchRoute($route) { $params = array(); $key_params = array_keys($route->getParams()); $value_params = $route->getParams(); foreach ($key_params as $key) { $params['<' . $key . '>'] = $value_params[$key]; } $url = $route->getPath(); // Zamienia znaczniki na odpowiednie wyrażenia regularne $url = str_replace(array_keys($params), $params, $url); // Jeżeli brak znacznika w tablicy $params zezwala na dowolny znak $url = preg_replace('/<\w+>/', '.*', $url); // sprawdza dopasowanie do wzorca preg_match("#^$url$#", $this->url, $results); if ($results) { $this->url=str_replace(array($this->strlcs($url, $this->url)), array(''), $this->url); $this->file = $route->getFile(); $this->class = $route->getClass(); $this->method = $route->getMethod(); return true; } return false; } /** * Szuka odpowiedniej reguły pasującej do URL. Jeżeli znajdzie zwraca true. * @return bool */ public function run() { foreach (Router::$collection->getAll() as $route) { if ($this->matchRoute($route)) { $this->setGetData($route); return true; } } return false; } /** * @param Route $route Obiekt Route pasujący do reguły */ protected function setGetData($route) { $routePath=str_replace(array('(', ')'), array('', ''), $route->getPath()); $trim=explode('<', $routePath); $parsed_url=str_replace(array(HTTP_SERVER), array(''), $this->url); $parsed_url=preg_replace("#$trim[0]#", '', $parsed_url, 1); // ustawia parametry przekazane w URL foreach ($route->getParams() as $key => $param) { if($parsed_url[0]=='/') { $parsed_url = substr($parsed_url, 1); } preg_match("#$param#", $parsed_url, $results); if (!empty($results[0])) { $_GET[$key] = $results[0]; $temp_url=explode($results[0], $parsed_url, 2); // $parsed_url=str_replace($results[0], '', $temp_url[1]); //$parsed_url=preg_replace($patern, '', $temp_url[1], 1); $parsed_url=$temp_url[1]; } } // jezeli brak parametru w URL ustawia go z tablicy wartości domyślnych foreach ($route->getDefaults() as $key => $default) { if (!isset($_GET[$key])) { $_GET[$key] = $default; } } } /** * Zwraca część wspólną ciągów * @param string $str1 Ciąg 1 * @param string $str2 Ciąg 2 * @return string część wspólna */ protected function strlcs($str1, $str2){ $str1Len = strlen($str1); $str2Len = strlen($str2); $ret = array(); if($str1Len == 0 || $str2Len == 0) return $ret; //no similarities $CSL = array(); //Common Sequence Length array $intLargestSize = 0; //initialize the CSL array to assume there are no similarities for($i=0; $i<$str1Len; $i++){ $CSL[$i] = array(); for($j=0; $j<$str2Len; $j++){ $CSL[$i][$j] = 0; } } for($i=0; $i<$str1Len; $i++){ for($j=0; $j<$str2Len; $j++){ //check every combination of characters if( $str1[$i] == $str2[$j] ){ //these are the same in both strings if($i == 0 || $j == 0) //it's the first character, so it's clearly only 1 character long $CSL[$i][$j] = 1; else //it's one character longer than the string from the previous character $CSL[$i][$j] = $CSL[$i-1][$j-1] + 1; if( $CSL[$i][$j] > $intLargestSize ){ //remember this as the largest $intLargestSize = $CSL[$i][$j]; //wipe any previous results $ret = array(); //and then fall through to remember this new value } if( $CSL[$i][$j] == $intLargestSize ) //remember the largest string(s) $ret[] = substr($str1, $i-$intLargestSize+1, $intLargestSize); } //else, $CSL should be set to 0, which it was already initialized to } } //return the list of matches if(isset($ret[0])) { return $ret[0]; } else { return ''; } } }
Instalacja Composer
Composer jest bardzo przydatnym narzędziem z dwóch powodów. Zapewnia prosty w użyciu autoloader oraz ułatwia dodawanie do projektu zewnętrznych bibliotek.
Opis jak zainstalować composer w systemie znajdziesz tutaj.
W naszej aplikacji plik composer.json wygląda następująco.
{ "name": "RacyMind/MVCWPraktyce", "description": "MVCWPraktyce od Racy Mind", "keywords": ["MVCWPraktyce", "Racy Mind"], "homepage": "http://racymind.pl", "type": "education, mvc", "license": "GNU GPL", "require": { "php": ">=5.3.0" }, "autoload": { "psr-4": { "RacyMind\\MVCWPraktyce\\": "src/" } } }
Tworzymy tabele bazy danych
Na zakończenie tej części stwórzmy tabele kategorii i artykułów w bazy danych:
CREATE TABLE IF NOT EXISTS `categories` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) COLLATE utf8_polish_ci DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_polish_ci; CREATE TABLE IF NOT EXISTS `articles` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(100) COLLATE utf8_polish_ci DEFAULT NULL, `content` text COLLATE utf8_polish_ci, `date_add` datetime DEFAULT NULL, `author` varchar(100) COLLATE utf8_polish_ci DEFAULT NULL, `id_categories` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `id_categories` (`id_categories`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_polish_ci;
W kolejnej części cyklu zaczniemy już dokładniej poznawać idee MVC. Stworzymy fragment aplikacji odpowiedzialny za dodawanie kategorii.