За прошлый год в PHPixie добавилось много новых возможностей и несколько компонентов, к тому же немного изменилась стандартная структура бандла чтобы снизить порог вхождения для разработчиков. Так что пришло время создать новый туториал, и в этот раз мы попробуем сделать его чуть по другому. Вместо того чтобы просто смотреть на готовый демо проект с описанием, мы будем идти постепенно, при чем на каждой итерации у нас будет полностью рабочий сайт. Мы будем строить простенький цитатник с логином, регистрацией, интеграцией с соцсетями и консольными командами для статистики. Полная история коммитов на гитхабе.
1. Создание проекта
Перед тем как приступить к работе скажите "Привет" в нашем чате, 99% проблем с которыми вы можете столкнутся там решаются почти мгновенно.
Нам понадобится Composer, после его установки запускаем:
php composer.phar create-project phpixie/project
Это создаст папку project с скелетом проекта и одним бандлом 'app'. Бандлы это модули код, шаблоны, CSS итд. относящиеся к какой-то части приложения. Их можно легко переносить с проекта на проект используя Composer. Мы будем работать только с одним бандлом в котором и будет вся логика нашего приложения.
Дальше надо создать виртуальный хост и направит его на папку /web внутри проекта. Если все прошло гладко то зайдя на http://localhost/ в браузере вы увидите приветствие. Сразу проверим работает ли роутинг перейдя на http://localhost/greet.
Если вы на Windows то скорее всего увидите ошибку во время запуска команды create-project, это следствия того что на этой ОС PHP функция symlink() не работает. Можете просто это проигнорировать, чуть потом я покажу как обойти эту проблему.
Состояние проекта на этом этапе (Коммит 1)
2. Просмотр сообщений
Начнем с соединения с БД, для этого редактируем /assets/config/database.php. Проверить соединение можно запуском двух консольных команд с папки проекта:
./console framework:database drop # удаляет базу если она присутствует
./console framework:database create # создает базу если она отсутсвует
Дальше создаем миграцию со структурой таблиц в /assets/migrate/migrations/1_users_and_messages.sql:
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
// /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 который будет отвечать за отображение сообщений. Пропишем его как дефолтный а также сразу добавим роут для главной страницы:
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.
<!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
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:
namespace Project\App;
class HTTP extends \PHPixie\DefaultBundle\HTTP
{
// это маппинг имени процессора к его классу
protected $classMap = array(
'messages' => 'Project\App\HTTP\Messages'
);
}
Почти готово, осталось только наверстать сам шаблон app:messages который использует процессор, это самая простая часть:
<?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)
3. ORM связи и разбиение на страницы
Для того чтобы под каждым сообщением указать пользователя который его создал надо прописать связь между таблицами. В миграциях мы указали что каждое сообщение включает обязательное поле userId так что это будет связь Один-ко-Многим.
// bundles/app/assets/config/orm.php
return [
'relationships' => [
// У каждого пользователя несколько сообщений
[
'type' => 'oneToMany',
'owner' => 'user',
'items' => 'message'
]
]
];
Добавим новый роут с параметром page для разбиения сообщений по страницам:
// /bundles/app/assets/config/routeResolver.php
return array(
// ....
'resolvers' => array(
'messages' => array(
'path' => 'page(/<page>)',
'defaults' => ['processor' => 'messages']
),
// ....
)
);
И чуть чуть меняем сам процессор Messages:
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)
4. Авторизация пользователей
Перед тем как позволить пользователям писать свои сообщения надо их авторизировать. Для этого надо указать и расширить сущность пользователя и его репозиторий. Тут важно понять отличие что сущность(Entity) представляет одного пользователя я репозиторий предоставляет методы поиска и создания этих сущностей. Для авторизации по паролю нам надо имплементировать несколько интерфейсов, все это довольно просто.
// /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');
}
}
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
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:
// /assets/config/auth.php
return [
'domains' => [
'default' => [
// использовать ORM репозиторий для пользователей
'repository' => 'framework.orm.user',
// Тут мы настраиваем какими способами юзер может авторизироватся
'providers' => [
// Включаем поддержку сессий
'session' => [
'type' => 'http.session'
],
// И паролей
'password' => [
'type' => 'login.password',
// когда пользователь логинится паролем, запомнить его в сессии
'persistProviders' => ['session']
]
]
]
]
];
Осталось только добавить страницу логина, для этого создаем новый процессор:
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');
}
}
Осталось только наверстать саму форму авторизации, чтобы не копировать сюда весь код, приведу пример одного поля:
<-- Добавить класс 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)
5. Регистрация
Форма регистрации делается по полной аналогии, рассмотрим изменения к процессору Auth:
/**
* форма регистрации
* @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 тоже обязательный и должен быть валидным
$document->valueField('email')
->required("Email is required")
->filter('email', "Please provide a valid email");
// Обязательный и минимум 8 знаков
$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");
// В этом коллбеке проверяем что пароль и его подтверждение совпадают
$validator->rule()->callback(function($result, $value) {
// Если они разные добавляем ошибку
if($value['password'] !== $value['passwordConfirm']) {
$result->field('passwordConfirm')->addMessageError("Passwords don't match");
}
});
// Строим форму для этого валидатора
return $validate->form($validator);
}
/**
* Обработка регистрации
* @param Form $registerForm
* @return bool Прошла ли регистрация успешно
*/
protected function processRegister($registerForm)
{
/** @var UserRepository $userRepository */
$userRepository = $this->components()->orm()->repository('user');
// Если email уже занят то добавляем ошибку к форме
if($userRepository->getByLogin($registerForm->email)) {
$registerForm->result()->field('email')->addMessageError("This email is already taken");
return false;
}
// Хешируем пароль и создаем пользователя
$provider = $this->passwordProvider();
$user = $userRepository->create([
'name' => $registerForm->name,
'email' => $registerForm->email,
'passwordHash' => $provider->hash($registerForm->password)
]);
$user->save();
// Вручную его логиним
$provider->setUser($user);
return true;
}
Единственное что надо заметить это то что мы добавили скрытое поле register
в HTML код формы, по какому проверяем логин это или регистрация.
Состояние проекта на этом этапе (Коммит 5)
6. Социальный логин
Теперь подключим логин из Facebook и Twitter. Начнем с того что добавим два поля facebookId
и twitterId
к таблице пользователей создав новую миграцию:
/* /assets/migrate/migrations/2_social_login.sql */
ALTER TABLE users ADD COLUMN twitterId VARCHAR(255) AFTER passwordHash;
-- statement
ALTER TABLE users ADD COLUMN facebookId VARCHAR(255) AFTER twitterId;
Теперь нам надо создать приложение на этих платформах и получить appId
и appSecret
. При регистрации правильно указываем Callback Url: http://localhost.com/socialAuth/callback/twitter
для Twitter и http://localhost.com/socialAuth/callback/twitter
для Facebook. Сами эти роуты ми создадим позже, а пока пропишем настройки:
// /assets/config/social.php
return [
'facebook' => [
'type' => 'facebook',
'appId' => 'YOUR APP ID',
'appSecret' => 'YOUR APP SECRET'
],
'twitter' => [
'type' => 'twitter',
'consumerKey' => 'YOUR APP ID',
'consumerSecret' => 'YOUR APP SECRET'
]
];
И включим поддержку социального логина в уже знакомом нам конфиге auth.php
:
// /assets/config/auth.php
<?php
return [
'domains' => [
'default' => [
// ....
'providers' => [
//.....
// Включаем соцлогин
'social' => [
'type' => 'social.oauth',
// После логина запоминаем пользователя в сессию
'persistProviders' => ['session']
]
]
]
]
];
Все, с настройками закончили, возьмемся за код. Помните как нам надо было имплементировать интерфейс в классах репозитории и сущности пользователя для логина по паролю? Теперь туда добавится еще один:
namespace Project\App\ORM\User;
// ....
/** Этот интерфейс позволяет логинится через соцсети */
use PHPixie\AuthSocial\Repository as SocialRepository;
class UserRepository extends Repository implements LoginUserRepository, SocialRepository
{
// ....
/**
* Находит пользователя по его данным полученным из компонента Social.
* Если пользователь не нашелся то возвращает null.
*
* @param SocialUser $socialUser
* @return User|null
*/
public function getBySocialUser($socialUser)
{
// Получаем имя поле в базе в котором хранится социальное id пользователя,
// например twitterId or facebookId
$providerName = $socialUser->providerName();
$field = $this->socialIdField($providerName);
// И делаем поиск по этому полю
return $this->query()->where($field, $socialUser->id())->findOne();
}
/**
* Получает имя поля с социальным id.
* В нашем случае ми делаем это просто добавляя 'Id' к имени провайдера
*
* @param string $providerName
* @return string
*/
public function socialIdField($providerName)
{
return $providerName.'Id';
}
}
И теперь наконец сам новый процессор для авторизации:
namespace Project\App\HTTP\Auth;
use PHPixie\App\ORM\User;
use PHPixie\AuthSocial\Providers\OAuth as OAuthProvider;
use PHPixie\HTTP\Request;
use Project\App\ORM\User\UserRepository;
use Project\App\HTTP\Processor;
use PHPixie\Social\OAuth\User as SocialUser;
/**
* Обрабатываем логин через соцсети
*/
class Social extends Processor
{
/**
* Переводит пользователя на внешнюю страницу логина
* например Twitter или Facebook
*
* @param Request $request HTTP request
* @return mixed
*/
public function defaultAction($request)
{
$provider = $request->attributes()->get('provider');
// Если параметр провайдера пуст, то делаем редирект на страницу логина
if(empty($provider)) {
return $this->redirect('app.processor', ['processor' => 'auth']);
}
// Создаем URL на внешнюю страницу и делаем редирект
$callbackUrl = $this->buildCallbackUrl($provider);
$url = $this->oauthProvider()->loginUrl($provider, $callbackUrl);
return $this->responses()->redirect($url);
}
/**
* Обрабатываем коллбек от провайдера.
* Это и будет та страница http://localhost.com/socialAuth/callback/twitter
* которую мы указали при регистрации нашего приложения.
*
* @param Request $request HTTP request
* @return mixed
*/
public function callbackAction($request)
{
$provider = $request->attributes()->getRequired('provider');
// Опять строим URL страницы коллбека, это нужно при получении токена
$callbackUrl = $this->buildCallbackUrl($provider);
$query = $request->query()->get();
// И вот сама обработка
// Если такой пользователь уже существует в базе то он сразу залогинится.
// В любом случае при успешной авторизации мы получим его соц данные в $userData
$userData = $this->oauthProvider()->handleCallback($provider, $callbackUrl, $query);
// Если что-то пошло не так, например пользователь отклонил авторизацию
// то редиректим его назад на страницу логина
if($userData === null) {
return $this->redirect('app.processor', ['processor' => 'auth']);
}
// Если же авторизация прошла успешно, но он гн залогинился
// значит это он первый раз на сайте и его надо зарегисторировать
if($this->user() === null) {
$user = $this->registerNewUser($userData);
// И после регистрации сразу залогинить
$this->oauthProvider()->setUser($user);
}
// Теперь он точно залогинен, так что возвращаем его на главную страницу
return $this->redirect('app.frontpage');
}
/**
* Регистрация новго пользователя из соцсети
*
* @param SocialUser $socialUser
* @return mixed
*/
protected function registerNewUser($socialUser)
{
/** @var UserRepository $userRepository */
$userRepository = $this->components()->orm()->repository('user');
// Получаем имя пользователя с его соц данных.
// Поскольку у разных провайдеров это поле может быть разное,
// то для этого создаем отдельный метод.
$profileName = $this->getProfileName($socialUser);
// Получаем имя поля куда сохранит его соц id
$socialIdField = $userRepository->socialIdField($socialUser->providerName());
// И создаем пользователя
$user = $userRepository->create([
'name' => $profileName,
$socialIdField => $socialUser->id()
]);
$user->save();
return $user;
}
/**
* Получам имя пользователя из его соц данных
*
* @param SocialUser $socialUser
* @return mixed
*/
protected function getProfileName($socialUser)
{
// В нашем у Twitter и Facebook поле имени одинаковое, так что все просто.
return $socialUser->loginData()->name;
}
/**
* Строим URL для коллбека, куда соцсеть
* перенаправит к нам пользователя после авторизации.
*
* @param $provider
* @return string
*/
protected function buildCallbackUrl($provider)
{
return $this->frameworkHttp()->generateUri('app.socialAuthCallback', [
'provider' => $provider
])->__toString();
}
/**
* Получаем проавайдер OAuth авторизации
*
* @return OAuthProvider
*/
protected function oauthProvider()
{
$domain = $this->components()->auth()->domain();
return $domain->provider('social');
}
}
Дальше прописываем роуты и добавляем ссылки логина в нашу форму авторизации:
// /bundles/app/assets/templates/login.php
<?php $url = $this->httpPath('app.socialAuth', ['provider' => 'twitter']); ?>
<a class="btn btn-lg btn-primary btn-block" href="<?=$url?>">Login with Twitter</a>
<?php $url = $this->httpPath('app.socialAuth', ['provider' => 'facebook']); ?>
<a class="btn btn-lg btn-primary btn-block" href="<?=$url?>">Login with Facebook</a>
Состояние проекта на этом этапе (Коммит 6)
7. Добавление сообщений
Тут собственно нет ничего интересного, еще одна форма, только в этот раз через AJAX для разнообразия. Тут единственное что надо отметить
так это использование блоков в шаблонах для добавления скриптов. И так мы добавляем блок scripts
в родительский шаблон:
<!-- /bundles/app/assets/templates/layout.php -->
<!-- Позволить подшаблонам добавлять свои скрипты в конец страницы -->
<?=$this->block('scripts')?>
Теперь в самом шаблоне messages
мы можем добавить скрипт в этот блок:
<!-- /bundles/app/assets/templates/messages.php -->
<?php $this->startBlock('scripts'); ?>
<script>
$(function() {
// Init the form handler
<?php $url = $this->httpPath('app.action', ['processor' => 'messages', 'action' => 'post']);?>
$('#messageForm').messageForm("<?=$_($url)?>");
});
</script>
<?php $this->endBlock(); ?>
В тот же блок можно добавить контент несколько раз, в результате он будет выведен в порядке добавления. Кстати можно сделать и наоборот,
добавлять контент только в том случае если блок пуст, рассмотрим два примера:
<?php $this->startBlock('test'); ?>
Hello
<?php $this->endBlock(); ?>
<?php $this->startBlock('test'); ?>
World
<?php $this->endBlock(); ?>
<?=$this->block('test')?>
<!-- Получим -->
Hello
World
<!--
Если второй параметр true и блок уже существует, то функция startBlock()
просто возвратит false а следовательно все что внутри if не выполнится.
-->
<?php if($this->startBlock('test', true)): ?>
Hello
<?php $this->endBlock();endif; ?>
<?php if($this->startBlock('test', true)): ?>
World
<?php $this->endBlock();endif; ?>
<?=$this->block('test')?>
<!-- Получим -->
Hello
Еще посмотрим что возвращает новый экшн процессора Messages
после того как было создано новое сообщение:
public function postAction($request)
{
// ....
// Возвратим ORM сущность как простой PHP объект.
// Параметр true так же указывает что надо рекурсивно превратить и подгруженные связи,
// но в данном случае их нет.
//
// А дальше PHPixie автоматически превращает объекты и массивы в JSON при выводе.
return $message->asObject(true);
}
**Состояние проекта на этом этапе (Коммит 7)
8. Консольные команды
Теперь добавим две консольные команды. Тут все по аналогии:
namespace Project\App\Console;
use PHPixie\Console\Command\Config;
use PHPixie\Slice\Data;
/**
* Листинг сообщений в консоли
*/
class Messages extends Command
{
/**
* Настройка команды
* @param Config $config
*/
protected function configure($config)
{
// Описание
$config->description("Print latest messages");
// Добавляем опцию для фильтра по id пользователя
$config->option('userId')
->description("Only print messages of this user");
// Добавляем аргумент для контроля количества сообщений
$config->argument('limit')
->description("Maximum number of messages to display, default is 5");
}
/**
* @param Data $argumentData
* @param Data $optionData
*/
public function run($argumentData, $optionData)
{
// считываем параметр количества
$limit = $argumentData->get('limit', 5);
// Строим запрос
$query = $this->components()->orm()->query('message')
->orderDescendingBy('date')
->limit($limit);
// Если указан userId то добавляем условие к запросу
$userId = $optionData->get('userId');
if($userId) {
$query->relatedTo('user', $userId);
}
// Получаем массив сообщений
$messages = $query->find(['user'])->asArray();
// Если их не нашлось
if(empty($messages)) {
$this->writeLine("No messages found");
}
// Выводим сообщения
foreach($messages as $message) {
$dateTime = new \DateTime($message->date);
$this->writeLine($message->text);
$this->writeLine(sprintf(
"by %s on %s",
$message->user()->name,
$dateTime->format('j M Y, H:i')
));
$this->writeLine();
}
}
}
namespace Project\App\Console;
use PHPixie\Console\Command\Config;
use PHPixie\Database\Driver\PDO\Connection;
use PHPixie\Slice\Data;
/**
* Выводит статистику по сообщениям
*/
class Stats extends Command
{
/**
* Настройка команды
* @param Config $config
*/
protected function configure($config)
{
$config->description("Display statistics");
}
/**
* @param Data $argumentData
* @param Data $optionData
*/
public function run($argumentData, $optionData)
{
// Получаем компонент Database
$database = $this->components()->database();
/** @var Connection $connection */
$connection = $database->get();
// Считаем все сообщения
$total = $connection->countQuery()
->table('messages')
->execute();
$this->writeLine("Total messages: $total");
// Получаем статистику по каждому пользователю
$stats = $connection->selectQuery()
->fields([
'name' => 'u.name',
// sqlExpression позволяет добавить сырой SQL
'count' => $database->sqlExpression('COUNT(1)'),
])
->table('messages', 'm')
->join('users', 'u')
->on('m.userId', 'u.id')
->groupBy('u.id')
->execute();
foreach($stats as $row) {
$this->writeLine("{$row->name}: {$row->count}");
}
}
}
Не забываем прописать их в классе Project\App\Console
:
namespace Project\App;
class Console extends \PHPixie\DefaultBundle\Console
{
/**
* Here we define console commands
* @var array
*/
protected $classMap = array(
'messages' => 'Project\App\Console\Messages',
'stats' => 'Project\App\Console\Stats'
);
}
Готово, теперь посмотрим как они выглядят в консоли:
# ./console
Available commands:
app:messages Print latest messages
app:stats Display statistics
# ....
# ./console help app:messages
app:messages [ --userId=VALUE ] [ LIMIT ]
Print latest messages
Options:
userId Only print messages of this user
Arguments:
LIMIT Maximum number of messages to display, default is 5
# ./console help app:stats
app:stats
Display statistics
И сами результаты роботы:
# ./console app:messages 2
Simplicity is the ultimate sophistication. -- Leonardo da Vinci
by Trixie on 7 Dec 2016, 16:40
Simplicity is prerequisite for reliability. -- Edsger W. Dijkstra
by Trixie on 7 Dec 2016, 15:05
# ./console app:stats
Total messages: 14
Pixie: 3
Trixie: 11
Состояние проекта на этом этапе (Коммит 8)
9. Использование параметров конфигурации
Для того чтобы все параметры зависящие от сервера отделить от самой конфигурации, можно использовать параметризацию.
Все очень просто, создаем файл /assets/parameters.php
в котором лежат параметры, а затем ссылаемся на них из конфигов.
// /assets/parameters.php
return [
'database' => [
'name' => 'phpixie',
'user' => 'phpixie',
'password' => 'phpixie'
],
'social' => [
'facebookId' => 'YOUR APP ID',
'facebookSecret' => 'YOUR APP SECRET',
'twitterId' => 'YOUR APP ID',
'twitterSecret' => 'YOUR APP SECRET',
]
];
И теперь изменяем сами конфиги:
// /assets/config/database.php
return [
// Database configuration
'default' => [
// Ссылки на параметры в /assets/parameters.php
'database' => '%database.name%',
'user' => '%database.user%',
'password' => '%database.password%',
'adapter' => 'mysql',
'driver' => 'pdo'
]
];
// /assets/config/social.php
return [
'facebook' => [
'type' => 'facebook',
'appId' => '%social.facebookId%',
'appSecret' => '%social.facebookSecret%'
],
'twitter' => [
'type' => 'twitter',
'consumerKey' => '%social.twitterId%',
'consumerSecret' => '%social.twitterSecret%'
]
];
Теперь при деплойменте на сервер достаточно только заменить этот один файл. Кстати поскольку это просто PHP код то в нем
можно использовать и привычные конструкции типа if
и switch
чтобы отдавать разные параметры в зависимости от сервера.
Конец
Вот и все, у нас получился полностью функциональный сайт, надеюсь вам понравилось. Если вам интересно и вы хотите узнать больше то заходите к нам в чат, у нас всегда весело, даже если вы не используете сам фреймворк.
А если вы хотите помочь проекту то можете поставить нам звездочку на гитхабе :)
Комментарии (32)
AmdY
25.01.2017 18:18+1>> $components = $this->components();
Ну почему не заюзать нормальный DI с явной инъекцией нужных объектов, а не таскать каштаны голой рукой из огня.jigpuzzled
25.01.2017 18:39Никто не мешает, прошлый гайд показывал как передавать кастомные параметры конструктору вместо использования
$builder
повсюду, все это дальше работает и даже DI контейнер есть. Но в этот раз целевая аудитория более широка и хотелось сделать как можно проще.
Если вы хотите передать кастомные параметры процессору достаточно создать метод типа:
namespace Project\App; class HTTP { // ... public function buildSomeProcessor() { return new HTTP\Some(...); } }
вместо того чтобы прописывать его в
$classMap
, этот$classMap
это шорткат для новичков и совсем не обязательный. Фреймворк общается с бандлом по интерфейсу, так что никакой магии нет и можно менять все что вздумается.
oxidmod
25.01.2017 19:52Как то не зашел мне пикси… И так смотрел, и эдак, ну вот не такой он.
Имхо, мне кажется, что было бы круто, чтобы некий фреймворк стал частью языка (типа как фалькон, только чтобы в коре самого пхп). Или если не фреймворк, то библиотека стандартных (по всяких этих пср) интерфейсов и дефолтная реализация. Естественно с возможностью заменить на свою пхп-шную. Было бы неплохо, имхо.
jigpuzzled
25.01.2017 20:11+1На самом деле хватило бы нормального класса для HTTP Request/Response и простого роутера в стандартной библиотеке. Я уже молчу о том что поддержки HTTP/2 в языке нет ( .
Кстати рекомендую посмотреть на пиксю еще раз, мы чуть изменили дефолтную структуру бандла, чтобы было легче в понимании.
Zazza
25.01.2017 21:59+2Не рассматривал этот фреймворк всерьез никогда, в частности не понятна тема с феечками, как-то отталкивает. Потому спрошу у вас, какая отличительная черта у этого фреймворка? Чем он лучше или чем отличается от других? Спрашиваю, потому что, действительно интересно.
jigpuzzled
25.01.2017 22:47Так тема с феечками уже давно пропала в принцыпе. Осталось только лого и то вполне абстрактное. Вы же не жалуетесь что на гитхабе тема с октокотом )
Черт много, так через комму написать трудно, заходите в чат расскажем )
Zazza
25.01.2017 22:53Покажите октокота, в упор не вижу, везде феечка.
> Черт много, так через комму написать трудно, заходите в чат расскажем
Ну как так. Давайте главные ТРИ отличительные черты, киллер-фичи (и похожие слова). Я во всех живых пхп фреймворках могу выделить подобное, в «феечках» не знаю о чём и зачем.jigpuzzled
25.01.2017 23:48+1ну ок.
ОРМка которая поддержывает связи даже между разными типами бащ данных (можно связать таблицу мускула с коллекцией в монге). Возможность оперирования запросами хитрее (можно сразу одним запросом связать 20 постов к 40 тегам (суммарно 800 связей)) не прибегая к кастрмным запросам все на уровне ОРМ. ОРМ в отличии от елоквента например разделяет понятия репозитория, сущности и заппоса как доктрина. Поддержка Nested Sets с оптимизацией какая описана тут в отдельной статье. Словом в ОРМке фич много.
Система иерархии процессоров, котроая пощволяет настроить мидлвари хитро и качтомно и намного гибче стандартых контроллеров.
Плагабельна система автризации которая держится на интерфейсах.
Отсуствие статики и прочих антипаттернов а также инкапсуляция рантайма в контекст позволяет запустить пикси на ReactPHP практически из коробки.
Действительно независимые компоненты которые легко использовать без фреймворка.
Самый продвинутый из шаблонизаторов которые используют чистый PHP с поддержкой прикручивания своих синтаксов ( например за 2 минуты можно сделать маркдаун, хамл итд темплейтинг.
Отдельная независимая библиотека для базы данных, когда ОРМ недостаточно.
Красивая ровная архитектура.
- Много тестов, почти повсюду полный кавередж, разве кроме последних комплнентов к которым руки ещн не дошли.
jigpuzzled
25.01.2017 23:49Карочн заходите в чат. Многое, типа ровной архитектуры трудно доказать в комментарии.
Zazza
26.01.2017 21:50и всё таки, расскажите про:
- Отдельная независимая библиотека для базы данных, когда ОРМ недостаточно.
jigpuzzled
26.01.2017 22:34oxidmod
26.01.2017 22:44не хочу вас обидеть, но это обычный QueryBuilder, причем вручную придется мапить результаты на модели, как я понимаю
Zazza
26.01.2017 22:51Все таки это query builder, да видно, что вы заворочались на его функционале, это хорошо, но мне проще будет использовать чистый sql, чем выучить весь доступный ООП в вашем подходе.
У меня о вашем фреймворке сложилось мнение: сделаем функционально, соблюдем стандарты и обложим тестами. Подход правильный, желаю удачи в развитии. Но мне, всё же, чего-то не хватает.
На текущий момент, в работе и для личного использования я использую phalcon, laravel, django. Везде меня что-то не устраивает. Создать свой фреймворк? :)
jigpuzzled
26.01.2017 23:04Но в чат к нам все равно заходите) Может со временем понравится =)
Zazza
26.01.2017 09:43Спасибо за ответ.
- Ничего не откомментирую, надо посмотреть/почитать о чём речь.
- Не понял, что тут написано.
- Посмотрю в документации, но от себя скажу (субъективно), что мне не нравится, как раз готовые реализации для авторизации/аутентификации в фреймворках, будь-то yii со свои rdac или laravel с тем, что они сделали в последних версиях, благо можно всё сделать по своему. Я за свободу, как в phalcon или symfony (>2)
- Про антипаттерны — это холливар, то что сделано в laravel, сделано красиво, как надстройка над избыточностью symfony. Мне кажется, что ругать статические вызовы, это больше от непонимания вопроса, что это и зачем.
ReactPHP мне на практике не приходилось использовать, поэтому оценить тут не могу. Тут я phalcon рассматриваю, как некий аналог. - А в симфони они не "действительно" независимые?
- Вот, это как раз, то что при первом взгляде (на самом деле при втором, первый — феечки :) ) на PhpPixie и оттолкнуло, не нравится мне php как шаблонизатор (субъективно).
- Дайте ссылку, где почитать, о чём речь.
- Это спорно. Мне красиво смотреть, как выглядят статические вызовы в laravel, но это как раз и совсем не всем нравится.
- Это хорошо и правильно, но не аргумент. Так как тесты все должны писать, если делают продукт для использования другими.
kruslan
25.01.2017 22:21+2Никогда не понимал: зачем делать то, что можно автоматизировать?
Важно: не забываем прописать его в /bundles/app/src/HTTP.php
Важно: не забываем зарегистрировать эти классы в /bundles/app/src/ORM.php
Не забываем прописать их в классе Project\App\Console
В целом, пример не понравился. Сделать тоже самое на любом другом фреймворке — чуть-ли не 1-в-1 кода (возможно в каких-то даже поменьше будет).jigpuzzled
25.01.2017 22:46Потому что не всегда хочется использовать этот $classMap. Более правильный подход передавать только нужные зависимости а не весь Builder. Тогда можно прописывать свои методы строители.
Пикся старается не привьязывать пользователей к какой-то одной архитектуре.
kruslan
26.01.2017 11:14Более правильный подход передавать только нужные зависимости а не весь Builder.
В чем заключается «более правильность»? Я честно не вижу в этом ни правильности, ни удобства.
Пикся старается не привьязывать пользователей к какой-то одной архитектуре.
Путем создания дополнительной работы? Оно мне надо?jigpuzzled
26.01.2017 12:39+1В чем заключается «более правильность»? Я честно не вижу в этом ни правильности, ни удобства.
Dependency Injection vs Service Location
Путем создания дополнительной работы? Оно мне надо?
Зависит уже от вашего уровня. Зачем вам фреймворк совсем? Поставьте Wordpress п программируйте формы мышкой =)
kruslan
26.01.2017 13:00Dependency Injection vs Service Location
Ну да, ну да… Их использование без лишней работы невозможно, правильно я понял?
Зависит уже от вашего уровня. Зачем вам фреймворк совсем? Поставьте Wordpress п программируйте формы мышкой =)
Именно так и сделаю, если потребуется простенький блог, например. А фреймворки использую для других задач. Троллинг, как способ привлечения пользователя — интересный способ, но не действенный. Уж лучше останусь на Phalcon/Zend, в таком случае. Всего доброго.jigpuzzled
26.01.2017 13:12Я вот не могу понять, для вас прописать строчку в файле это реально лишняя работа? В таком случае вы всегда можете перегрузить 1 метод в 3 строки чтобы сделать поиск классов по папкам и неймспейсам, могу в чате показать как =)
oxidmod
26.01.2017 16:15сделать можно все, вопрос в том, почему я это должен делать? почему фреймворк не делает рутину за меня?
kruslan
26.01.2017 16:41Жаль, что не можете понять… Попробую объяснить.
Абсолютно не важно, сколько строк надо добавить — одну или пару сотен. Важен факт того, что это можно (и, имхо, необходимо) автоматизировать. Фреймворки я использую для того, чтобы избавиться от рутинных операций. Думаю, большинство также. В данном случае фреймворк заставляет (и это именно так) думать не про решение задачи, а про использование самого фреймворка.
вы всегда можете перегрузить 1 метод в 3 строки
Да, конечно могу. Как и многое другое. Но, как и любой нормальный программист, я не люблю делать что-то рутинное и не относящееся к решению конкретной задачи. Если для использования какой-то библиотеки или фреймворка мне необходимо делать «лишние» (читайте — не относящиеся к решению задачи) действия — скорее всего библиотека или фреймворк не подходят для текущей задачи.
Далее… Подход, при котором разработчик чуть-ли не в ручную контролирует подключение каждого файла подходит для энтерпрайза, когда каждый дополнительный элемент должен быть одобрен каждым из десятка начальников и только после этого может быть подключен к основному коду. Но «фея» из другой области. Она не может тягаться с симфони или зендом, она для мелких и средне-мелких задач — для них жизнено необходима автоматизация всего, что только возможно. Ни один человек в здравом уме не будет писать блог на микросервисной архитектуре, верно? Так и тут — не надо навязывать принципы из другой области, имхо.
Ну вот как-то так…jigpuzzled
26.01.2017 18:59Я просто считаю что проще что-то прикрутить в 3 строчки чем потом откручивать =)
Я же не могу за всех угадать кому какая автоматизация надо, кто-то захочет по неймспейсу грузить процессоры, кто-то по папке. А что если папки две или больше итд.
Если бы там много надо было прописывать я бы еще понял, но реально в 3 строчки можно сделать подгрузку по неймспейсу.
oxidmod
26.01.2017 20:33Ну так и сделайте чтобы автоматом грузило по неймспейсу с возможностью подмены лоадера на кастомный. Кому надо переопределит, кому не важно будет юзать дефолтный с автоматической подгрузкой. Прикольно что можно и так и сяк, неприкольно что и так и сяк надо вручную, а не автоматом
jigpuzzled
26.01.2017 22:35Ну пока вы первый кто захотел такую фичу =)
Заходите в чат обсудим, если другим будет интересно то запилить плевое дело =)
codeator
Но есть же laravel? Зачем еще один-то?)
jigpuzzled
Пикся появилась когда Ларавел еще не был популярным. Есть отдельная статья: https://habrahabr.ru/post/309176/
Кстати, с выхода 3.0 мы еще не ломали обратную совместимость =)
codeator
Предпочитаю сравнения не от авторов фреймворков, но в топе гугла к сожалению только статья про вашу популярность и методы ее набора =)
http://andrewcarteruk.github.io/programming/2016/05/09/phpixie-fraud.html