Давным давно, в далекой-далекой галактике существовали два основных PHP фреймворка — Symfony и ZF, которые подходили для большинства веб-приложений, срендего — большого масштаба. В отличии от них, следующие поколения этих фреймворков ориентированы на веб-приложения только большого масштаба, сайты же среднего, и, тем более, низкого уровня, на них писать более нерелевантно по отношению к затраченному времени. А после перехода во фриланс, большинство моих заказов можно отнести именно к срендему уровню. На фоне этого, начали появляться микро-фреймворки, один из которых — Silex, от разработчиков Symfony. Изначально он ориентирован на простые сайты, но его легко доработать для разработки сайтов посложнее. Из коробки Silex предоставляет возможность маршрутизации запросов, валидации и фильтрации входящих данных и сервайс контейнер. Этого вполне достаточно для расширения Silex-а во что-то более серьезное. Начнем с разделения на каталоги и файлы. Согласно шаблону MVC — у проекта будут три основных части — это модели, шаблоны (вьюшки) и контроллеры. Помимо этих трех частей, у проекта обычно есть дополнительные библиотеки (в т.ч. и сам Silex), конфиги, статические файлы (картинки, JavaScrip-ы, CSS-стили) и точка входа (Bootstrap). Исходя из этого, изначально можно разделить файлы проекта по таким каталогам:
Файл .htaccess, или его аналог для нужного веб-сервера должен переадресовывать все запросы, за исключением статики (папка web), на index.php, и выглядеть он должен примерно следующим образом:
RewriteEngine On
RewriteRule ^web/(.*) web/$1 [L]
RewriteRule ^ index.php [L]
* Соответственно должен быть включен mod_rewrite и AllowOverride равен All.
Изначально файл index.php должен подключать Silex из директории vendor и все контроллеры из директории controller.
// index.php
require_once __DIR__.'/vendor/autoload.php';
$app = new SilexApplication();
foreach ( glob(__DIR__."/controller/*.php") as $filename ) {
require_once $filename;
}
$app->run();
Таким образом, теперь мы можем создать файлы контроллеров, например controller/index.php, в которых объявлять нужные action-ы.
// controller/index.php
$app->get('/', function () use ($app) {
return 'Hello Habr';
});
Дальше больше, теперь нужно подключить какой-то обработчик шаблонов (вьюшек), Silex предлагает Twig, но для проектов небольшого уровня сложности его я считаю излишним. Для того, чтобы писать вьюшки на чистом PHP, хорошего и красивого костыля для Silex-а не оказалось, пришлось написать самому. К нему есть всего несколько требований, подключение в виде сервиса, вызов метода рендера с передачей параметров, вьюшки, которую нужно сгенерить и лэйаута, в который вьюшка должна бысть встроена. Также нужно иметь возможность вызова другого контроллера из вьюшки, что нужно, например, для рендера каких-то блоков на сайте, данные которого хранятся в БД.
// vendor/Art/View.php
namespace Art;
use SymfonyComponentHttpKernelHttpKernelInterface;
use SymfonyComponentHttpFoundationRequest;
class View {
private $app = null;
private $blocks = array();
public function __construct($app) {
$this->app = $app;
}
public function render( $layout, $template, $vars = array() ) {
$path = __DIR__ . '/../../view';
foreach ($vars as $key => $value) { $$key = $value; }
$app = $this->app;
ob_start();
require $path . '/' . $template;
$content = ob_get_clean();
if ( null == $layout ) {
return $content;
}
ob_start();
require_once $path . '/' . $layout;
$html = ob_get_clean();
return $html;
}
function renderController($uri) {
$request = $this->app['request'];
$sign = strpos($uri, "?") ? "&" : "?";
$uri = "{$uri}{$sign}subrequest=1";
$subRequest = Request::create(
$uri, 'get', array(), $request->cookies->all(),
array(), $request->server->all()
);
if ( $request->getSession() ) {
$subRequest->setSession( $request->getSession() );
}
$response = $this->app->handle(
$subRequest, HttpKernelInterface::SUB_REQUEST, false
);
if ( !$response->isSuccessful() ) {
throw new RuntimeException(sprintf(
'Error when rendering "%s" (Status code is %s).',
$request->getUri(), $response->getStatusCode()
));
}
return $response->getContent();
}
}
Для его подключения модифицируем index.php
// index.php
// ...
require_once __DIR__ . '/vendor/Art/View.php';
$app['view'] = $app->share(function () use ($app) {
return new ArtView($app);
});
// ...
Для более простого подключение вендоров, а также для будущего подключения моделей, добавим в бутстрап функцию автолоад.
// index.php
// ...
spl_autoload_register(function( $className ) {
// Namespace mapping
$namespaces = array(
"Art" => __DIR__ . "/vendor/Art",
"Model" => __DIR__ . "/model"
);
foreach ( $namespaces as $ns => $path ) {
if ( 0 === strpos( $className, "{$ns}\" ) ) {
$pathArr = explode( "\", $className );
$pathArr[0] = $path;
$class = implode(DIRECTORY_SEPARATOR, $pathArr);
require_once "{$class}.php";
}
}
});
// Services
$app['view'] = $app->share(function () use ($app) {
return new ArtView($app);
});
// ...
Теперь создадим лэйаут и вьюшку для главной страницы и модифицируем контроллер для работы с ArtView.
<!-- view/layout.phtml -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Silex — это круто</title>
</head>
<body>
<?php echo $this->renderController('/test/') ?>
<?php echo $content ?>
</body>
</html>
<!-- view/index/hello.phtml -->
Hello <?php echo $name ?>
// contorller/index.php
$app->get('/', function () use ($app) {
$name = "Habr";
return $app['view']->render('layout.phtml', 'index/hello.phtml', array(
'name' => $name
));
});
$app->get('/test/', function () use ($app) {
$test = "Test";
return $app['view']->render(null, 'index/test.phtml', array(
'test' => $test
));
});
Получим вывод:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Silex — это круто</title>
</head>
<body>
Test Hello Habr</body>
</html>
Для хранения конфигурационных файлов напишем простой обработчик-парсер ini-файлов.
; conf/app.ini
[db]
dsn = "mysql://root@localhost/habr;charset=utf8"
// index.php
// ...
// Config
$app['conf'] = $app->share(function () use ($app) {
$data = parse_ini_file( __DIR__ . '/conf/app.ini', true );
return $data;
});
// ...
Следующим шагом будет подключение ORM, для работы с базой. Silex предлагает Doctrine 2, но, как и с Twig, для проектов небольших Doctrine 2 неоптимальная. Вместо нее я использую минималистичный PHP ActiveRecord.
// index.php
// ...
// PHPActiveRecord
require_once __DIR__ . '/vendor/AR/ActiveRecord.php';
ActiveRecordConfig::initialize(function($cfg) use ($app) {
$cfg->set_model_directory( __DIR__ . '/model');
$cfg->set_connections(array(
'production' => $app['conf']['db']['dsn']
));
$cfg->set_default_connection('production');
});
// ...
Создадим базу habr и таблицу test
CREATE DATABASE `habr` DEFAULT CHARSET=utf8;
CREATE TABLE `habr`.`author` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `habr`.`book` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`authorId` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
KEY `authorId` (`authorId`),
CONSTRAINT `book_ibfk_1` FOREIGN KEY (`authorId`) REFERENCES `author` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Создадим модели
// model/Author.php
namespace Model;
class Author extends ActiveRecordModel {
static $table_name = 'author';
static $has_many = array(
array('books', 'foreign_key' => 'authorId', 'class_name' => 'ModelBook'),
);
}
// model/Book.php
namespace Model;
class Book extends ActiveRecordModel {
static $table_name = 'book';
static $belongs_to = array(
array('author', 'class_name' => 'ModelAuthor', 'foreign_key' => 'authorId'),
);
}
Добавим в бутстрап вывод ошибок для локальной версии и зарегистрируем еще один сервис — для генерации ссылок.
// index.php
// ...
if ( '127.0.0.1' == $_SERVER['REMOTE_ADDR'] ) {
$app['debug'] = true;
}
// ...
// UrlGenerator
$app->register(new SilexProviderUrlGeneratorServiceProvider());
Сделаем выборку
// controller/index.php
// ...
$app->get('/authors/', function () use ($app) {
$authors = ModelAuthor::all();
return $app['view']->render('layout.phtml', 'index/authors.phtml', array(
'authors' => $authors
));
});
$app->get('/book/{id}.html', function ($id) use ($app) {
$book = ModelBook::find_by_id($id);
if ( !$book ) {
$app->abort(404, "Book {$id} does not exist.");
}
return $app['view']->render('layout.phtml', 'index/book.phtml', array(
'book' => $book
));
})->bind('book');
<!-- view/index/authors.phtml -->
<?php foreach ( $authors as $author ): ?>
<div>
<?php echo $author->name ?>
<div>
<?php foreach ( $author->books as $book ): ?>
<div>
<a href="<?php echo $app['url_generator']->generate('book', array('id' => $book->id)) ?>"><?php echo $book->name ?></a>
</div>
<?php endforeach ?>
</div>
</div>
<?php endforeach ?>
<!-- view/index/book.phtml -->
<?php echo $book->name ?>
(<?php echo $book->author->name ?>)
Получим вывод:
Для оформления ошибок в Silex-е есть специальный обработчик.
// index.php
$app->error(function (Exception $e, $code) use ($app) {
// if ( $app['debug'] ) {
// return;
// }
return $app['view']->render('layout.phtml', 'error.phtml', array(
'msg' => $e->getMessage(),
'code' => $code
));
});
<!-- view/error.phtml -->
<h1><?php echo $code ?> <?php echo $msg ?></h1>
Следующая необходимая вещь в любом фреймворке — это формы. Для построения форм Silex предлагает SymfonyForm, но с его зависимостями Silex превращается в Symfony, поэтому используем HTML_QuickForm2.
Качаем в vendor и подключаем:
// index.php
// ...
// HTML_QuickForm2
set_include_path(
get_include_path() . PATH_SEPARATOR .
__DIR__ . "/vendor/QuickForm2"
);
require_once __DIR__ . '/vendor/QuickForm2/HTML/QuickForm2.php';
require_once __DIR__ . '/vendor/QuickForm2/HTML/QuickForm2/Renderer.php';
// ...
Пропишем контроллер
// controllers/index.php
// ...
$app->match('/form/', function () use ($app) {
$form = new HTML_QuickForm2('author', 'post', array('action' => ""));
$form->addElement('text', 'name')
->setlabel('Имя автора')
->addRule('required', 'Поле обязательно для заполнения');
$form->addElement('button', null, array('type' => 'submit'))
->setContent('ОК');
if ( $form->isSubmitted() && $form->validate() ) {
$values = $form->getValue();
$author = new ModelAuthor;
$author->name = $values['name'];
$author->save();
// post POST redirect
return new SymfonyComponentHttpFoundationRedirectResponse(
$app['url_generator']->generate('authors')
);
}
return $app['view']->render('layout.phtml', 'index/form.phtml', array(
'form' => $form
));
});
И последняя важная вещь — это постраничная навигация. Для нее можно использовать модуль Pagerfanta. Качаем в vendors, подключаем.
Добавляем неймспейс Pagerfanta в автолоадинг:
// index.php
// ...
// Namespace mapping
$namespaces = array(
"Art" => __DIR__ . "/vendor/Art",
"Model" => __DIR__ . "/model",
"Pagerfanta" => __DIR__ . "/vendor/Pagerfanta"
);
Напишем адаптер для работы с PHP ActiveRecord:
// vendor/Art/PfAdapter.php
namespace Art;
class PfAdapter implements PagerfantaAdapterAdapterInterface {
private $classname = null;
private $params = null;
public function __construct( $classname, $params = array() ) {
$this->classname = $classname;
$this->params = $params;
}
public function getNbResults() {
$params = array(
'select' => 'COUNT(*) as cnt',
);
if ( $this->params ) {
$params = array_merge($this->params, $params);
}
$cnt = call_user_func_array(
array($this->classname, "all"),
array($params)
);
if ( !$cnt ) {
return 0;
}
return $cnt[0]->cnt;
}
public function getSlice($offset, $length) {
$params = array(
'limit' => $length,
'offset' => $offset
);
if ( $this->params ) {
$params = array_merge($params, $this->params);
}
return call_user_func_array(
array($this->classname, "all"),
array($params)
);
}
}
Добавим паганицию к выборке:
// controller/index.php
// ...
$app->get('/authors/', function () use ($app) {
$ipp = 3;
$p = $app['request']->get('p', 1);
$adapter = new ArtPfAdapter('ModelAuthor', array(
'conditions' => 'id < 1000',
'order' => 'id DESC'
));
$pagerfanta = new PagerfantaPagerfanta($adapter);
$pagerfanta->setMaxPerPage($ipp);
$pagerfanta->setCurrentPage($p);
$view = new PagerfantaViewDefaultView;
$html = $view->render($pagerfanta, function($p) use ($app) {
return $app['url_generator']->generate('authors', array('p' => $p));
}, array(
'proximity' => 3,
'previous_message' => '« Предыдущая',
'next_message' => 'Следующая »'
));
return $app['view']->render('layout.phtml', 'index/authors.phtml', array(
'pagerfanta' => $pagerfanta,
'html' => $html
));
})->bind('authors');
// ...
<!-- view/index/authors.phtml -->
<?php $results = $pagerfanta->getCurrentPageResults() ?>
<?php if ( $results ): ?>
<?php foreach ( $results as $author ): ?>
<div>
<?php echo $author->name ?>
<div>
<?php foreach ( $author->books as $book ): ?>
<div>
<a href="<?php echo $app['url_generator']->generate('book', array('id' => $book->id)) ?>"><?php echo $book->name ?></a>
</div>
<?php endforeach ?>
</div>
</div>
<?php endforeach ?>
<?php if ( $pagerfanta->haveToPaginate() ): ?>
<div class="pagerfanta">
<?php echo $html ?>
</div>
<?php endif ?>
<?php else: ?>
Ничего не найдено
<?php endif ?>
Результат:
В итоге получился легкий и минималистичный фреймворк, пригодный для разработки веб-приложений от маленьких до крупных.
Исходники — habr.zip.
На таком движке написан Open Source аудиоплеер — oplayer.org (https://github.com/uavn/oplayer).
P.S. Есть и Yii, подходящий для задач любого уровня сложности, но часто приходится работать с Symfony2, а Silex на нее больше похож, чем Yii.
Автор: mr_avi