Как превратить Silex в полноценный PHP фреймворк

в 13:13, , рубрики: framework, php, silex, symfony, Веб-разработка, метки: , , ,

SilexДавным давно, в далекой-далекой галактике существовали два основных PHP фреймворка — Symfony и ZF, которые подходили для большинства веб-приложений, срендего — большого масштаба. В отличии от них, следующие поколения этих фреймворков ориентированы на веб-приложения только большого масштаба, сайты же среднего, и, тем более, низкого уровня, на них писать более нерелевантно по отношению к затраченному времени. А после перехода во фриланс, большинство моих заказов можно отнести именно к срендему уровню. На фоне этого, начали появляться микро-фреймворки, один из которых — Silex, от разработчиков Symfony. Изначально он ориентирован на простые сайты, но его легко доработать для разработки сайтов посложнее. Из коробки Silex предоставляет возможность маршрутизации запросов, валидации и фильтрации входящих данных и сервайс контейнер. Этого вполне достаточно для расширения Silex-а во что-то более серьезное. Начнем с разделения на каталоги и файлы. Согласно шаблону MVC — у проекта будут три основных части — это модели, шаблоны (вьюшки) и контроллеры. Помимо этих трех частей, у проекта обычно есть дополнительные библиотеки (в т.ч. и сам Silex), конфиги, статические файлы (картинки, JavaScrip-ы, CSS-стили) и точка входа (Bootstrap). Исходя из этого, изначально можно разделить файлы проекта по таким каталогам:

Как превратить Silex в полноценный PHP фреймворк

Файл .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 в полноценный PHP фреймворк
Как превратить Silex в полноценный PHP фреймворк
Для оформления ошибок в 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 ?>

Результат:
Как превратить Silex в полноценный PHP фреймворк
В итоге получился легкий и минималистичный фреймворк, пригодный для разработки веб-приложений от маленьких до крупных.
Исходники — habr.zip.
На таком движке написан Open Source аудиоплеер — oplayer.org (https://github.com/uavn/oplayer).

P.S. Есть и Yii, подходящий для задач любого уровня сложности, но часто приходится работать с Symfony2, а Silex на нее больше похож, чем Yii.

Автор: mr_avi

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js