image За прошлый год в 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)


  1. codeator
    25.01.2017 17:50

    Но есть же laravel? Зачем еще один-то?)


    1. jigpuzzled
      25.01.2017 17:56

      Пикся появилась когда Ларавел еще не был популярным. Есть отдельная статья: https://habrahabr.ru/post/309176/

      Кстати, с выхода 3.0 мы еще не ломали обратную совместимость =)


      1. codeator
        25.01.2017 18:01
        +2

        Предпочитаю сравнения не от авторов фреймворков, но в топе гугла к сожалению только статья про вашу популярность и методы ее набора =)

        http://andrewcarteruk.github.io/programming/2016/05/09/phpixie-fraud.html


  1. AmdY
    25.01.2017 18:18
    +1

    >> $components = $this->components();
    Ну почему не заюзать нормальный DI с явной инъекцией нужных объектов, а не таскать каштаны голой рукой из огня.


    1. jigpuzzled
      25.01.2017 18:39

      Никто не мешает, прошлый гайд показывал как передавать кастомные параметры конструктору вместо использования $builder повсюду, все это дальше работает и даже DI контейнер есть. Но в этот раз целевая аудитория более широка и хотелось сделать как можно проще.


      Если вы хотите передать кастомные параметры процессору достаточно создать метод типа:


      namespace Project\App;
      
      class HTTP
      {
           // ...
           public function buildSomeProcessor()
           {
                return new HTTP\Some(...);
           }
      }

      вместо того чтобы прописывать его в $classMap, этот $classMap это шорткат для новичков и совсем не обязательный. Фреймворк общается с бандлом по интерфейсу, так что никакой магии нет и можно менять все что вздумается.


  1. oxidmod
    25.01.2017 19:52

    Как то не зашел мне пикси… И так смотрел, и эдак, ну вот не такой он.


    Имхо, мне кажется, что было бы круто, чтобы некий фреймворк стал частью языка (типа как фалькон, только чтобы в коре самого пхп). Или если не фреймворк, то библиотека стандартных (по всяких этих пср) интерфейсов и дефолтная реализация. Естественно с возможностью заменить на свою пхп-шную. Было бы неплохо, имхо.


    1. jigpuzzled
      25.01.2017 20:11
      +1

      На самом деле хватило бы нормального класса для HTTP Request/Response и простого роутера в стандартной библиотеке. Я уже молчу о том что поддержки HTTP/2 в языке нет ( .


      Кстати рекомендую посмотреть на пиксю еще раз, мы чуть изменили дефолтную структуру бандла, чтобы было легче в понимании.


  1. Zazza
    25.01.2017 21:59
    +2

    Не рассматривал этот фреймворк всерьез никогда, в частности не понятна тема с феечками, как-то отталкивает. Потому спрошу у вас, какая отличительная черта у этого фреймворка? Чем он лучше или чем отличается от других? Спрашиваю, потому что, действительно интересно.


    1. jigpuzzled
      25.01.2017 22:47

      Так тема с феечками уже давно пропала в принцыпе. Осталось только лого и то вполне абстрактное. Вы же не жалуетесь что на гитхабе тема с октокотом )


      Черт много, так через комму написать трудно, заходите в чат расскажем )


      1. Zazza
        25.01.2017 22:53

        Покажите октокота, в упор не вижу, везде феечка.

        > Черт много, так через комму написать трудно, заходите в чат расскажем
        Ну как так. Давайте главные ТРИ отличительные черты, киллер-фичи (и похожие слова). Я во всех живых пхп фреймворках могу выделить подобное, в «феечках» не знаю о чём и зачем.


        1. Zazza
          25.01.2017 22:54

          Поправка, я понял о каком октокоте речь. Вопрос снимаю.


        1. jigpuzzled
          25.01.2017 23:48
          +1

          ну ок.


          1. ОРМка которая поддержывает связи даже между разными типами бащ данных (можно связать таблицу мускула с коллекцией в монге). Возможность оперирования запросами хитрее (можно сразу одним запросом связать 20 постов к 40 тегам (суммарно 800 связей)) не прибегая к кастрмным запросам все на уровне ОРМ. ОРМ в отличии от елоквента например разделяет понятия репозитория, сущности и заппоса как доктрина. Поддержка Nested Sets с оптимизацией какая описана тут в отдельной статье. Словом в ОРМке фич много.


          2. Система иерархии процессоров, котроая пощволяет настроить мидлвари хитро и качтомно и намного гибче стандартых контроллеров.


          3. Плагабельна система автризации которая держится на интерфейсах.


          4. Отсуствие статики и прочих антипаттернов а также инкапсуляция рантайма в контекст позволяет запустить пикси на ReactPHP практически из коробки.


          5. Действительно независимые компоненты которые легко использовать без фреймворка.


          6. Самый продвинутый из шаблонизаторов которые используют чистый PHP с поддержкой прикручивания своих синтаксов ( например за 2 минуты можно сделать маркдаун, хамл итд темплейтинг.


          7. Отдельная независимая библиотека для базы данных, когда ОРМ недостаточно.


          8. Красивая ровная архитектура.


          9. Много тестов, почти повсюду полный кавередж, разве кроме последних комплнентов к которым руки ещн не дошли.


          1. jigpuzzled
            25.01.2017 23:49

            Карочн заходите в чат. Многое, типа ровной архитектуры трудно доказать в комментарии.


            1. Zazza
              26.01.2017 21:50

              и всё таки, расскажите про:


              1. Отдельная независимая библиотека для базы данных, когда ОРМ недостаточно.


              1. jigpuzzled
                26.01.2017 22:34

                1. oxidmod
                  26.01.2017 22:44

                  не хочу вас обидеть, но это обычный QueryBuilder, причем вручную придется мапить результаты на модели, как я понимаю


                1. Zazza
                  26.01.2017 22:51

                  Все таки это query builder, да видно, что вы заворочались на его функционале, это хорошо, но мне проще будет использовать чистый sql, чем выучить весь доступный ООП в вашем подходе.


                  У меня о вашем фреймворке сложилось мнение: сделаем функционально, соблюдем стандарты и обложим тестами. Подход правильный, желаю удачи в развитии. Но мне, всё же, чего-то не хватает.


                  На текущий момент, в работе и для личного использования я использую phalcon, laravel, django. Везде меня что-то не устраивает. Создать свой фреймворк? :)


                  1. jigpuzzled
                    26.01.2017 23:04

                    Но в чат к нам все равно заходите) Может со временем понравится =)


                    1. Zazza
                      26.01.2017 23:25
                      +1

                      Я уже даже боюсь к вам в чат идти


                      1. jigpuzzled
                        26.01.2017 23:28

                        Gitter по ходу упал, так что в чат не зайти =)


          1. Zazza
            26.01.2017 09:43

            Спасибо за ответ.


            1. Ничего не откомментирую, надо посмотреть/почитать о чём речь.
            2. Не понял, что тут написано.
            3. Посмотрю в документации, но от себя скажу (субъективно), что мне не нравится, как раз готовые реализации для авторизации/аутентификации в фреймворках, будь-то yii со свои rdac или laravel с тем, что они сделали в последних версиях, благо можно всё сделать по своему. Я за свободу, как в phalcon или symfony (>2)
            4. Про антипаттерны — это холливар, то что сделано в laravel, сделано красиво, как надстройка над избыточностью symfony. Мне кажется, что ругать статические вызовы, это больше от непонимания вопроса, что это и зачем.
              ReactPHP мне на практике не приходилось использовать, поэтому оценить тут не могу. Тут я phalcon рассматриваю, как некий аналог.
            5. А в симфони они не "действительно" независимые?
            6. Вот, это как раз, то что при первом взгляде (на самом деле при втором, первый — феечки :) ) на PhpPixie и оттолкнуло, не нравится мне php как шаблонизатор (субъективно).
            7. Дайте ссылку, где почитать, о чём речь.
            8. Это спорно. Мне красиво смотреть, как выглядят статические вызовы в laravel, но это как раз и совсем не всем нравится.
            9. Это хорошо и правильно, но не аргумент. Так как тесты все должны писать, если делают продукт для использования другими.


  1. kruslan
    25.01.2017 22:21
    +2

    Никогда не понимал: зачем делать то, что можно автоматизировать?

    Важно: не забываем прописать его в /bundles/app/src/HTTP.php

    Важно: не забываем зарегистрировать эти классы в /bundles/app/src/ORM.php

    Не забываем прописать их в классе Project\App\Console


    В целом, пример не понравился. Сделать тоже самое на любом другом фреймворке — чуть-ли не 1-в-1 кода (возможно в каких-то даже поменьше будет).


    1. jigpuzzled
      25.01.2017 22:46

      Потому что не всегда хочется использовать этот $classMap. Более правильный подход передавать только нужные зависимости а не весь Builder. Тогда можно прописывать свои методы строители.


      Пикся старается не привьязывать пользователей к какой-то одной архитектуре.


      1. kruslan
        26.01.2017 11:14

        Более правильный подход передавать только нужные зависимости а не весь Builder.


        В чем заключается «более правильность»? Я честно не вижу в этом ни правильности, ни удобства.

        Пикся старается не привьязывать пользователей к какой-то одной архитектуре.


        Путем создания дополнительной работы? Оно мне надо?


        1. jigpuzzled
          26.01.2017 12:39
          +1

          В чем заключается «более правильность»? Я честно не вижу в этом ни правильности, ни удобства.

          Dependency Injection vs Service Location


          Путем создания дополнительной работы? Оно мне надо?

          Зависит уже от вашего уровня. Зачем вам фреймворк совсем? Поставьте Wordpress п программируйте формы мышкой =)


          1. kruslan
            26.01.2017 13:00

            Dependency Injection vs Service Location

            Ну да, ну да… Их использование без лишней работы невозможно, правильно я понял?

            Зависит уже от вашего уровня. Зачем вам фреймворк совсем? Поставьте Wordpress п программируйте формы мышкой =)

            Именно так и сделаю, если потребуется простенький блог, например. А фреймворки использую для других задач. Троллинг, как способ привлечения пользователя — интересный способ, но не действенный. Уж лучше останусь на Phalcon/Zend, в таком случае. Всего доброго.


            1. jigpuzzled
              26.01.2017 13:12

              Я вот не могу понять, для вас прописать строчку в файле это реально лишняя работа? В таком случае вы всегда можете перегрузить 1 метод в 3 строки чтобы сделать поиск классов по папкам и неймспейсам, могу в чате показать как =)


              1. oxidmod
                26.01.2017 16:15

                сделать можно все, вопрос в том, почему я это должен делать? почему фреймворк не делает рутину за меня?


              1. kruslan
                26.01.2017 16:41

                Жаль, что не можете понять… Попробую объяснить.

                Абсолютно не важно, сколько строк надо добавить — одну или пару сотен. Важен факт того, что это можно (и, имхо, необходимо) автоматизировать. Фреймворки я использую для того, чтобы избавиться от рутинных операций. Думаю, большинство также. В данном случае фреймворк заставляет (и это именно так) думать не про решение задачи, а про использование самого фреймворка.

                вы всегда можете перегрузить 1 метод в 3 строки


                Да, конечно могу. Как и многое другое. Но, как и любой нормальный программист, я не люблю делать что-то рутинное и не относящееся к решению конкретной задачи. Если для использования какой-то библиотеки или фреймворка мне необходимо делать «лишние» (читайте — не относящиеся к решению задачи) действия — скорее всего библиотека или фреймворк не подходят для текущей задачи.

                Далее… Подход, при котором разработчик чуть-ли не в ручную контролирует подключение каждого файла подходит для энтерпрайза, когда каждый дополнительный элемент должен быть одобрен каждым из десятка начальников и только после этого может быть подключен к основному коду. Но «фея» из другой области. Она не может тягаться с симфони или зендом, она для мелких и средне-мелких задач — для них жизнено необходима автоматизация всего, что только возможно. Ни один человек в здравом уме не будет писать блог на микросервисной архитектуре, верно? Так и тут — не надо навязывать принципы из другой области, имхо.

                Ну вот как-то так…


                1. jigpuzzled
                  26.01.2017 18:59

                  Я просто считаю что проще что-то прикрутить в 3 строчки чем потом откручивать =)
                  Я же не могу за всех угадать кому какая автоматизация надо, кто-то захочет по неймспейсу грузить процессоры, кто-то по папке. А что если папки две или больше итд.


                  Если бы там много надо было прописывать я бы еще понял, но реально в 3 строчки можно сделать подгрузку по неймспейсу.


                  1. oxidmod
                    26.01.2017 20:33

                    Ну так и сделайте чтобы автоматом грузило по неймспейсу с возможностью подмены лоадера на кастомный. Кому надо переопределит, кому не важно будет юзать дефолтный с автоматической подгрузкой. Прикольно что можно и так и сяк, неприкольно что и так и сяк надо вручную, а не автоматом


                    1. jigpuzzled
                      26.01.2017 22:35

                      Ну пока вы первый кто захотел такую фичу =)
                      Заходите в чат обсудим, если другим будет интересно то запилить плевое дело =)