Created
January 23, 2017 12:12
-
-
Save dracony/dfb5c81b9c2fe4ec88c193fb1f0304ef to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
За прошлый год в PHPixie добавилось много новых возможностей и несколько компонентов, к тому же немного изменилась стандартная структура бандла чтобы снизить порог вхождения для разработчиков. Так что пришло время создать новый туториал, и в этот раз мы попробуем сделать его чуть по другому. Вместо того чтобы просто скинуть готовый демо проект с описанием, мы будем идти постепенно при чем на каждой итерации у нас будет полностью рабочий сайт. **Мы будем строить простенький цитатник с логином, регистрацией, интеграцией с соцсетями и консольными командами для статистики.** Полная история коммитов тут: . | |
<cut /> | |
**1. Создание проекта** | |
Нам понадобится [Composer](https://getcomposer.org/download/), после его установки запускаем: | |
``` | |
php composer.phar create-project phpixie/project | |
``` | |
Это создаст папку *project* с скелетом проекта и одним бандлом 'app'. Бандлы это Бандлы это модули код, шаблоны, CSS итд. относящиеся к какой-то части приложения. Их можно легко переносить с проекта на проект используя Composer. Мы будем работать только с одним бандлом в котором и будет вся логика нашего приложения. | |
Дальше надо создать виртуальный хост и направит его на папку */web* внутри проекта. Если все прошло гладко то зайдя на *http://localhost/* в браузере вы увидите приветствие. Сразу проверим работает ли роутинг перейдя на *http://localhost/greet*. | |
Если вы на Windows то скорее всего увидите ошибку во время запуска команды *create-project*, это следствия того что на этой ОС PHP функция *symlink()* не работает. Можете просто это проигнорировать, чуть потом я покажу как обойти эту проблему. | |
**[Состояние проекта на этом этапе (Коммит 1)](https://github.com/PHPixie/Demo-Quickstart/tree/8702c5a5f732540d973770edb3604fa719aadef4)** | |
**2. Просмотр сообщений** | |
Начнем с соединения с БД, для этого редактируем */assets/config/database.php*. Проверить соединение можно запуском двух консольных команд с папки проекта: | |
``` | |
./console framework:database drop # удаляет базу если она присутсвует | |
./console framework:database create # создает базу если она отсутсвует | |
``` | |
Дальше создаем миграцию со структурой таблиц в */assets/migrate/migrations/1_users_and_messages.sql*: | |
```php | |
CREATE TABLE users( | |
id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, | |
name VARCHAR(255) NOT NULL, | |
email VARCHAR(255) UNIQUE, | |
passwordHash VARCHAR(255) | |
); | |
-- statement | |
CREATE TABLE messages( | |
id INT PRIMARY KEY AUTO_INCREMENT, | |
userId INT NOT NULL, | |
text VARCHAR(255) NOT NULL, | |
date DATETIME NOT NULL, | |
FOREIGN KEY (userId) | |
REFERENCES users(id) | |
); | |
``` | |
Заметьте что мы используем `-- statement` для разделения запросов. | |
Также сразу добавим немного данных чтобы было чем наполнить базу, для этого создаем файлы в */assets/migrate/seeds/* где имя файла отвечает имени таблицы, например: | |
```php | |
<?php | |
// /assets/migrate/seeds/messages.php | |
return [ | |
[ | |
'id' => 1, | |
'userId' => 1, | |
'text' => "Hello World!", | |
'date' => '2016-12-01 10:15:00' | |
], | |
// .... | |
] | |
``` | |
Полный контент этих файлов можно посмотреть на гитхабе. | |
Теперь запустим еще две консольные команды: | |
``` | |
./console framework:migrate # применить миграции | |
./console framework:seed # наполнить базу данными | |
``` | |
Теперь можно приступить к нашей первой странице. Сперва рассмотрим файл */bundles/app/assets/config/routeResolver.php* в котором настраиваются роуты, то есть прописывается каким ссылкам отвечают какие процессоры. Мы собираемся добавить процессор *messages* который будет отвечать за отображение сообщений. Пропишем его как дефолтный а также сразу добавим роут для главной страницы: | |
```php | |
<?php | |
return array( | |
'type' => 'group', | |
'defaults' => array('action' => 'default'), | |
'resolvers' => array( | |
'action' => array( | |
'path' => '<processor>/<action>' | |
), | |
'processor' => array( | |
'path' => '(<processor>)', | |
'defaults' => array('processor' => 'messages') | |
), | |
// Роут для главной страницы | |
'frontpage' => array( | |
'path' => '', | |
'defaults' => ['processor' => 'messages'] | |
) | |
) | |
); | |
``` | |
Начнем верстку с того что изменим родительский шаблон */bundles/app/assets/template/layout.php* и добавим к нему Bootstrap 4 и свой CSS. | |
```php | |
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<!-- Bootstrap 4 --> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | |
<meta http-equiv="x-ua-compatible" content="ie=edge"> | |
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css"> | |
<!-- Подключаем наш CSS, об этом чуть позже --> | |
<link rel="stylesheet" href="/bundles/app/main.css"> | |
<!-- Если подшаблон не установил имя страницы то используем Quickstart --> | |
<title><?=$_($this->get('pageTitle', 'Quickstart'))?></title> | |
</head> | |
<body> | |
<!-- Navigation --> | |
<nav class="navbar navbar-toggleable-md navbar-light bg-faded"> | |
<div class="container"> | |
<!-- Ссылка на главную страницу --> | |
<a class="navbar-brand mr-auto" href="<?=$this->httpPath('app.frontpage')?>">Quickstart</a> | |
</div> | |
</nav> | |
<!-- Тут будет вставлено тело дочернего шаблона --> | |
<?php $this->childContent(); ?> | |
<!-- Bootstrap dependencies --> | |
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/tether/1.3.7/js/tether.min.js"></script> | |
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/js/bootstrap.min.js"></script> | |
</body> | |
</html> | |
``` | |
Где же создать файл *main.css*? Поскольку все нужные файлы лучше всего держать внутри бандла то это будет папка */bundles/app/web/*. При создании проекта композером на эту папку автоматически создается симлинк с */bundles/app/web* что делает эти файлы доступными с браузера. На Windows вместо создания ярлыка приходится копировать папку, что делает команда: | |
``` | |
# копирует файлы с web директории бандлв в /web/bundles | |
./console framework:installWebAssets --copy | |
``` | |
Теперь создаем новый процессор в */bundles/app/src/HTTP/Messages.php* | |
```php | |
namespace Project\App\HTTP; | |
use PHPixie\HTTP\Request; | |
/** | |
* Просмотр сообщений | |
*/ | |
class Messages extends Processor | |
{ | |
/** | |
* @param Request $request HTTP request | |
* @return mixed | |
*/ | |
public function defaultAction($request) | |
{ | |
$components = $this->components(); | |
// Получаем все сообщения | |
$messages = $components->orm()->query('message') | |
->orderDescendingBy('date') | |
->find(); | |
// Рендерим темплейт | |
return $components->template()->get('app:messages', [ | |
'messages' => $messages | |
]); | |
} | |
} | |
``` | |
**Важно: не забываем прописать его в /bundles/app/src/HTTP.php**: | |
```php | |
namespace Project\App; | |
class HTTP extends \PHPixie\DefaultBundle\HTTP | |
{ | |
// это маппинг имени процессора к его классу | |
protected $classMap = array( | |
'messages' => 'Project\App\HTTP\Messages' | |
); | |
} | |
``` | |
Почти готово, осталось только наверстать сам шаблон *app:messages* который использует процессор, это самая простая часть: | |
```php | |
<?php | |
// Родительский шаблон | |
$this->layout('app:layout'); | |
// Устанавливаем переменную какую | |
// родительский шаблон затем вставит как титул страницы | |
$this->set('pageTitle', "Messages"); | |
?> | |
<div class="container content"> | |
<-- Выводим сообщения --> | |
<?php foreach($messages as $message): ?> | |
<blockquote class="blockquote"> | |
<-- Выводить текст надо используя $_() для защиты от XSS --> | |
<p class="mb-0"><?=$_($message->text)?></p> | |
<footer class="blockquote-footer"> | |
posted at <?=$this->formatDate($message->date, 'j M Y, H:i')?> | |
</footer> | |
</blockquote> | |
<?php endforeach; ?> | |
</div> | |
``` | |
Все, готово, теперь перейдя на http://localhost/ мы увидим полный список сообщений. | |
**[Состояние проекта на этом этапе (Коммит 2)](https://github.com/PHPixie/Demo-Quickstart/tree/361acb0dacfe5e3a89a58400420292dad2acbe3a)** | |
**3. ORM связи и пейджинация** | |
Для того чтобы под каждым сообщением указать пользователя который его создал надо прописать связь между таблицами. В миграциях мы указали что каждое сообщение включает обязательное поле *userId* так что это будет связь Один-ко-Многим. | |
```php | |
// bundles/app/assets/config/orm.php | |
return [ | |
'relationships' => [ | |
// У каждого пользователя несколько сообщений | |
[ | |
'type' => 'oneToMany', | |
'owner' => 'user', | |
'items' => 'message' | |
] | |
] | |
]; | |
``` | |
Добавим новый роут с параметром *page* для разбиения сообщений по страницам: | |
```php | |
// /bundles/app/assets/config/routeResolver.php | |
return array( | |
// .... | |
'resolvers' => array( | |
'messages' => array( | |
'path' => 'page(/<page>)', | |
'defaults' => ['processor' => 'messages'] | |
), | |
// .... | |
) | |
); | |
``` | |
И чуть чуть меняем сам процессор Messages: | |
```php | |
public function defaultAction($request) | |
{ | |
$components = $this->components(); | |
// Создаем запрос | |
$messageQuery = $components->orm()->query('message') | |
->orderDescendingBy('date'); | |
// Передаем запрос в пейджер и сразу указываем количество | |
// сообщений на страницу и список связей которые надо подгрузить | |
$pager = $components->paginateOrm() | |
->queryPager($messageQuery, 10, ['user']); | |
// Выставляем номер текущей страницы исходя из параметра | |
$page = $request->attributes()->get('page', 1); | |
$pager->setCurrentPage($page); | |
// И рендерим темплейт | |
return $components->template()->get('app:messages', [ | |
'pager' => $pager | |
]); | |
} | |
``` | |
Теперь в шаблоне мы можем использовать `$pager->getCurrentItems()` чтобы получить сообщения на данной странице, и `$message->user()` чтобы получить данные об авторе и наверстать пейджер. Не буду копировать сюда полный шаблон страницы, кго можно посмотреть в репозитории. | |
**[Состояние проекта на этом этапе (Коммит 3)](https://github.com/PHPixie/Demo-Quickstart/tree/a3cd3aa05d79db09b54a1a9ff49feba998d51a88)** | |
**4. Авторизация пользователей** | |
Перед тем как позволить пользователям писать свои сообщения надо их авторизировать. Для этого надо указать и расширить сущность пользователя и его репозиторий. Тут важно понять отличие что сущность(Entity) представляет одного пользователя я репозиторий предоставляет методы поиска и создания этих сущностей. Для авторизации по паролю нам надо имплементировать несколько интерфейсов, все это довольно просто. | |
```php | |
// /bundles/app/src/ORM/User.php | |
namespace Project\App\ORM; | |
use Project\App\ORM\Model\Entity; | |
/** Этот интерфейс необходим для логина по паролю */ | |
use PHPixie\AuthLogin\Repository\User as LoginUser; | |
/** | |
* Сущность пользователя | |
*/ | |
class User extends Entity implements LoginUser | |
{ | |
/** | |
* Возвращает хеш пароля этого пользователя. | |
* В нашем случае это просто значение поля 'passwordHash'. | |
* @return string|null | |
*/ | |
public function passwordHash() | |
{ | |
return $this->getField('passwordHash'); | |
} | |
} | |
``` | |
```php | |
namespace Project\App\ORM\User; | |
use Project\App\ORM\Model\Repository; | |
use Project\App\ORM\User; | |
/** Этот интерфейс необходим для логина по паролю */ | |
use PHPixie\AuthLogin\Repository as LoginUserRepository; | |
/** | |
* Репозиторий пользователей | |
*/ | |
class UserRepository extends Repository implements LoginUserRepository | |
{ | |
/** | |
* Ищет пользователя по его id | |
* @param mixed $id | |
* @return User|null | |
*/ | |
public function getById($id) | |
{ | |
return $this->query() | |
->in($id) | |
->findOne(); | |
} | |
/** | |
* Ищет пользователя по логину, в нашем случае это его email. | |
* Но можно искать и по нескольким полям в результате позволяя логинится | |
* и по мейлу и по имени юзера. | |
* @param mixed $login | |
* @return User|null | |
*/ | |
public function getByLogin($login) | |
{ | |
return $this->query() | |
->where('email', $login) | |
->findOne(); | |
} | |
} | |
``` | |
**Важно: не забываем зарегистрировать эти классы в /bundles/app/src/ORM.php** | |
```php | |
namespace Project\App; | |
/** | |
* Тут мы прописываем классы врапперов | |
*/ | |
class ORM extends \PHPixie\DefaultBundle\ORM | |
{ | |
protected $entityMap = array( | |
'user' => 'Project\App\ORM\User' | |
); | |
protected $repositoryMap = [ | |
'user' => 'Project\App\ORM\User\UserRepository' | |
]; | |
} | |
``` | |
Пропишем настройки авторищации в */assets/config/auth.php*: | |
```php | |
// /assets/config/auth.php | |
return [ | |
'domains' => [ | |
'default' => [ | |
// использовать ORM репозиторий для пользователей | |
'repository' => 'framework.orm.user', | |
// Тут мы настраиваем какими способами юзер может авторизироватся | |
'providers' => [ | |
// Включаем поддержку сессий | |
'session' => [ | |
'type' => 'http.session' | |
], | |
// И паролей | |
'password' => [ | |
'type' => 'login.password', | |
// когда пользователь логинится паролем, запомнить его в сессии | |
'persistProviders' => ['session'] | |
] | |
] | |
] | |
] | |
]; | |
``` | |
Осталось только добавить страницу логина, для этого создаем новый процессор: | |
```php | |
<?php | |
namespace Project\App\HTTP; | |
use PHPixie\AuthLogin\Providers\Password; | |
use PHPixie\HTTP\Request; | |
use PHPixie\Validate\Form; | |
use Project\App\ORM\User\UserRepository; | |
use PHPixie\App\ORM\User; | |
/** | |
* Тут будем обрабатывать логин и регистрацию | |
*/ | |
class Auth extends Processor | |
{ | |
/** | |
* @param Request $request HTTP request | |
* @return mixed | |
*/ | |
public function defaultAction($request) | |
{ | |
// Если пользователь уже залогинен, редиректим его на главную | |
if($this->user()) { | |
return $this->redirect('app.frontpage'); | |
} | |
$components = $this->components(); | |
// Строим шаблон и форму | |
$template = $components->template()->get('app:login', [ | |
'user' => $this->user() | |
]); | |
$loginForm = $this->loginForm(); | |
$template->loginForm = $loginForm; | |
// Если форма не засабмичена то просто рендерим темплейт | |
if($request->method() !== 'POST') { | |
return $template; | |
} | |
$data = $request->data(); | |
// В другом случае обрабатываем логин | |
$loginForm->submit($data->get()); | |
// Если форма логина валидна и пользователь успешно залогинился делаем редирект | |
if($loginForm->isValid() && $this->processLogin($loginForm)) { | |
return $this->redirect('app.frontpage'); | |
} | |
// Если нет то просто рендерим страницу | |
return $template; | |
} | |
/** | |
* Обработка логина | |
* | |
* @param Form $loginForm | |
* @return bool Залогинился ли пользователь | |
*/ | |
protected function processLogin($loginForm) | |
{ | |
// Пробуем залогинится | |
$user = $this->passwordProvider()->login( | |
$loginForm->email, | |
$loginForm->password | |
); | |
// Если пароль не подошел или такого пользователя нет, то добавляем ошибку к форме | |
if($user === null) { | |
$loginForm->result()->addMessageError("Invalid email or password"); | |
return false; | |
} | |
return true; | |
} | |
/** | |
* Логаут | |
* @return mixed | |
*/ | |
public function logoutAction() | |
{ | |
// Получаем домен авторизации и забываем пользователя | |
$domain = $this->components()->auth()->domain(); | |
$domain->forgetUser(); | |
// Делаем редирект на главную | |
return $this->redirect('app.frontpage'); | |
} | |
/** | |
* Строим форму логина | |
* @return Form | |
*/ | |
protected function loginForm() | |
{ | |
$validate = $this->components()->validate(); | |
$validator = $validate->validator(); | |
// Используем валидатор документов | |
//(это тот который вы будете использовать в большинстве случаев) | |
$document = $validator->rule()->addDocument(); | |
// Оба поля обязательны | |
$document->valueField('email') | |
->required("Email is required"); | |
$document->valueField('password') | |
->required("Password is required"); | |
// Возвращаем форму для этого валидатора | |
return $validate->form($validator); | |
} | |
/** | |
* провайдер аутентификации какой мы настроили в /assets/config/auth.php | |
* @return Password | |
*/ | |
protected function passwordProvider() | |
{ | |
$domain = $this->components()->auth()->domain(); | |
return $domain->provider('password'); | |
} | |
} | |
``` | |
Осталось только наверстать саму форму авторизации, чтобы не копировать сюда весь код, приведу пример одного поля: | |
```php | |
<-- Добавить класс has-danger если поле не валидно --> | |
<div class="form-group <?=$this->if($loginForm->fieldError('email'), "has-danger")?>"> | |
<-- Само поле ввода с сохранением предыдущего значения --> | |
<input name="email" type="text" value="<?=$_($loginForm->fieldValue('email'))?>" | |
class="form-control" placeholder="Username"> | |
<-- Вывод ошибки если она есть --> | |
<?php if($error = $loginForm->fieldError('email')): ?> | |
<div class="form-control-feedback"><?=$error?></div> | |
<?php endif;?> | |
</div> | |
``` | |
Так же добавляем роуты и ссылки на логин/логаут в хедер и готово, логин работает. | |
**[Состояние проекта на этом этапе (Коммит 4)](https://github.com/PHPixie/Demo-Quickstart/tree/92fc8e0e314a30424e2cfba616932c2d9a294faf)** | |
5. Регистрация | |
Форма регистрации делается по полной аналогии, рассмотрим изменения к процессору Auth: | |
```php | |
/** | |
* форма регистрации | |
* @return Form | |
*/ | |
protected function registerForm() | |
{ | |
$validate = $this->components()->validate(); | |
$validator = $validate->validator(); | |
$document = $validator->rule()->addDocument(); | |
// По умолчанию валидатор не пропускает поля которые не были описаны. | |
// Этот вызов отключает эту проверку и пропускает дополнительные поля. | |
// В нашем случае это hidden поле "register" по какому мы будем определять | |
// логин это или регистрация | |
$document->allowExtraFields(); | |
// Имя обязательное | |
$document->valueField('name') | |
->required("Name is required") | |
->addFilter() | |
->minLength(3) | |
->message("Username must contain at least 3 characters"); | |
// Email is required and must be a valid email | |
$document->valueField('email') | |
->required("Email is required") | |
->filter('email', "Please provide a valid email"); | |
$document->valueField('password') | |
->required("Password is required") | |
->addFilter() | |
->minLength(8) | |
->message("Password must contain at least 8 characters"); | |
$document->valueField('passwordConfirm') | |
->required("Please repeat your password"); | |
// In this callback rule we check that password confirmation matches the password | |
$validator->rule()->callback(function($result, $value) { | |
// If they don't match we add an error to the field | |
if($value['password'] !== $value['passwordConfirm']) { | |
$result->field('passwordConfirm')->addMessageError("Passwords don't match"); | |
} | |
}); | |
// Build a form for this validator | |
return $validate->form($validator); | |
} | |
/** | |
* Process registration | |
* @param Form $registerForm | |
* @return bool Whether the user was successfully registered | |
*/ | |
protected function processRegister($registerForm) | |
{ | |
/** @var UserRepository $userRepository */ | |
$userRepository = $this->components()->orm()->repository('user'); | |
// Check if the email already exists and if so add an error to the form | |
if($userRepository->getByLogin($registerForm->email)) { | |
$registerForm->result()->field('email')->addMessageError("This email is already taken"); | |
return false; | |
} | |
// Hash password and create the user | |
$provider = $this->passwordProvider(); | |
$user = $userRepository->create([ | |
'name' => $registerForm->name, | |
'email' => $registerForm->email, | |
'passwordHash' => $provider->hash($registerForm->password) | |
]); | |
$user->save(); | |
// Manually log the user in | |
$provider->setUser($user); | |
return true; | |
} | |
``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment