Семантическое
программирование

или

куда поместить код

Дмитрий Елисеев
elisdn.ru

Обычно мы решаем проблемы заказчика

но иногда вместе с этим добавляем новых

когда не используем инструменты по назначению.

Семантика – значение, смысл
(отдельного слова, оборота речи, ...)

Div:


<body>
    <div>
        <div>

            <a href="/">Home</a>
            <a href="/about">About</a>
            <a href="/contact">Contact</a>

        </div>
    </div>
    <div>
        <div>
            <h1>First article</h1>
            <div>2014.12.06</div>
        </div>
        <p>
            Lorem Ipsum is simply dummy text of
            the printing and typesetting industry.
        </p>
        <div>
            Some, Super, Tags
        </div>
    </div>
    <div>© Site.com</div>
</body>
                    

Article:


<body>
    <header>
        <nav>
            <ul>
                <li><a href="/">Home</a></li>
                <li><a href="/about">About</a></li>
                <li><a href="/contact">Contact</a></li>
            </ul>
        </nav>
    </header>
    <article>
        <header>
            <h1>First article</h1>
            <div>2014.12.06</div>
        </header>
        <p>
            Lorem Ipsum is simply dummy text of
            the printing and typesetting industry.
        </p>
        <footer>
            Some, Super, Tags
        </footer>
    </article>
    <footer>© Site.com</footer>
</body>
                    

Семантика – использование вещей по смыслу

Фильтры

Делаем Market:


class MarketController extends Controller
{
    public function behaviors() {
        return [
            'access' => [
                'class' => AccessControl::className(),
                'rules' => [
                    [
                        'allow' => true,
                        'roles' => ['@'],
                    ],
                ],
            ],
        ];
    }

    public function actionIndex()
    {
        return $this->render('index');
    }
}
                    

и пускаем не всех:


class MarketController extends Controller
{
    ...

    public function actionIndex()
    {
        $user = Yii::$app->user->identity;

        if (empty($user->email) || !$user->emailConfirmed) {
            Yii::$app->session->setFlash('error', 'Подтвердите email для торгов.');
            return $this->redirect([
                '/profile/view',
                'return' => Yii::$app->request->url
            ]);
        }

        return $this->render('index');
    }
}
                    

Что если много action*?

Выносим beforeAction:


class MarketController extends Controller
{
    public function beforeAction($action)
    {
        if (!parent::beforeAction($action)) {
            return false;
        }
        $user = Yii::$app->user->identity;
        if (empty($user->email) || !$user->emailConfirmed) {
            Yii::$app->session->setFlash('error', 'Подтвердите email для торгов.');
            $this->redirect([
                '/profile/view',
                'return' => Yii::$app->request->url
            ]);
            return false;
        }
        return true;
    }

    ...
}
                    

Что если много контроллеров?

Делаем фильтр MarketAccess:


namespace app\filters;

use Yii;
use yii\base\ActionFilter;
use yii\helpers\Url;

class MarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        $user = Yii::$app->user->identity;

        if (empty($user->email) || !$user->emailConfirmed) {
            Yii::$app->session->setFlash('error', 'Подтвердите email для торгов.');
            Yii::$app->response->redirect(Url::to([
                '/profile/view',
                'return' => Yii::$app->request->url
            ]));
            return false;
        }
        return true;
    }
}
                    

и подключаем:


class MarketController extends Controller
{
    public function behaviors()
    {
        return [
            'access' => [
                'class' => AccessControl::className(),
                'rules' => [
                    [
                        'allow' => true,
                        'roles' => ['@'],
                    ],
                ],
            ],
            MarketAccess::className(),
        ];
    }

    public function actionIndex()
    {
        return $this->render('index');
    }
}
                    

Что получаем?

Отделение обязанности проверки

Чистый контроллер без if-ов

Повторное использование в других контроллерах

Тестирование поведения

Чего не получаем?

Отделение бизнес-логики

Где может «аукнуться»?

В API:


class MarketController extends \yii\web\Controller
{
    public function behaviors()
    {
        return [
            MarketAccess::className(), // setFlash + redirect
        ];
    }
}

class MarketController extends \yii\rest\Controller
{
    public function behaviors()
    {
        return [
            MarketAccess::className(), // setFlash (?) + redirect (?)
        ];
    }
}
                    

setFlash (?) + redirect (?)

Может разделить?

Разделяем:


class MarketController extends \yii\web\Controller
{
    public function behaviors()
    {
        return [
            WebMarketAccess::className(), // setFlash + redirect
        ];
    }
}

class MarketController extends \yii\rest\Controller
{
    public function behaviors()
    {
        return [
            ApiMarketAccess::className(), // BadRequestHttpException
        ];
    }
}
                    

И внутри...

WebMarketAccess:


class WebMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        $user = Yii::$app->user->identity;

        if (empty($user->email) || !$user->emailConfirmed) {
            Yii::$app->session->setFlash('error', 'Подтвердите email для торгов.');
            Yii::$app->response->redirect(Url::to([
                '/profile/view',
                'return' => Yii::$app->request->url
            ]));
            return false;
        }
        return true;
    }
}
                    

setFlash + redirect

ApiMarketAccess:


class ApiMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        $user = Yii::$app->user->identity;

        if (empty($user->email) || !$user->emailConfirmed) {
            throw new BadRequestHttpException('Подтвердите email для торгов.');
        }
        return true;
    }
}
                    

BadRequestHttpException

Бизнес-логика:


class WebMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        if (empty($user->email) || !$user->emailConfirmed) {
            ...
        }
    }
}
                

class ApiMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        if (empty($user->email) || !$user->emailConfirmed) {
            ...
        }
    }
}
                    

повторяется

Инкапсулируем в сущность:


class WebMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        if (!$user->isEmailConfirmed()) {
            ...
        }
    }
}
                

class ApiMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        if (!$user->isEmailConfirmed()) {
            ...
        }
    }
}
                    

в isEmailConfirmed():


class User extends ActiveRecord implements IdentityInterface
{
    ...

    public function isEmailConfirmed()
    {
        return !empty($this->email) && $this->emailConfirmed;
    }
}
                    

Вроде хорошо:


class WebMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        if (!$user->isEmailConfirmed()) {
            ...
        }
    }
}
                

class ApiMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        if (!$user->isEmailConfirmed()) {
            ...
        }
    }
}
                    

проверки спрятаны...

но что если:


class WebMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        if (!$user->isEmailConfirmed() || $this->billing->hasDebts($user->id)) {
            ...
        }
    }
}
                

class ApiMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        if (!$user->isEmailConfirmed() || $this->billing->hasDebts($user->id)) {
            ...
        }
    }
}
                    

усложнится логика...

Инкапсулируем в сервис:


namespace app\services;

use app\models\User;

class MarketAccessChecker
{
    private $billing;

    public function __construct(Billing $billing)
    {
        $this->billing = $billing;
    }

    public function allowToMarket(User $user)
    {
        return $user->isEmailConfirmed() && !$this->billing->hasDebts($user->id);
    }
}
                    

инвертируя условие

и подключаем:


class ApiMarketAccess extends ActionFilter
{
    private $checker;

    public function __construct(MarketAccessChecker $checker, array $config = [])
    {
        $this->checker = $checker;
        parent::__construct($config);
    }

    public function beforeAction($action)
    {
        $user = Yii::$app->user->identity;

        if (!$this->checker->allowToMarket($user)) {
            throw new BadRequestHttpException('Подтвердите email и погасите долги.');
        }

        return true;
    }
}
                    

к каждому фильтру:


class WebMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        if (!$this->checker->allowToMarket(Yii::$app->user->identity)) {
            Yii::$app->session->setFlash('error', 'Подтвердите email и ...');
            Yii::$app->response->redirect(...);
            return false;
        }
        return true;
    }
}
                

class ApiMarketAccess extends ActionFilter
{
    public function beforeAction($action)
    {
        if (!$this->checker->allowToMarket(Yii::$app->user->identity)) {
            throw new BadRequestHttpException('Подтвердите email и ...');
        }
        return true;
    }
}
                    

Что получаем?

Отделение бизнес-логики

Unit-тестирование

Фильтры – в фильтры

Кастомные запросы

Что-то выбираем:


class CatalogController extends Controller
{
    public function actionCategory($slug)
    {
        $category = $this->findCategory($slug);

        $query = Product::find()
            ->andWhere(['status' => Product::STATUS_PUBLISHED])
            ->andWhere(['<=', 'published_from', time()])
            ->andWhere(['category_id' => $category->id]);

        $dataProvider = new ActiveDataProvider([
            'query' => $query,
        ]);

        return $this->render('category', [
            'dataProvider' => $dataProvider,
        ]);
    }

    ...
}
                    

И ещё что-то:


class CatalogController extends Controller
{
    public function actionProduct($id)
    {
        return $this->render('product', [
            'product' => $this->findProduct($id),
        ]);
    }

    private function findProduct($id)
    {
        $product = Product::find()
            ->andWhere(['status' => Product::STATUS_PUBLISHED])
            ->andWhere(['<=', 'published_from', time()])
            ->andWhere(['id' => $id])
            ->one();
        if (!$product) {
            throw new NotFoundHttpException();
        }
        return $product;
    }
}
                    

Есть что-то похожее:


class CatalogController extends Controller
{
    public function actionCategory($slug)
    {
        $query = Product::find()
            ->andWhere(['status' => Product::STATUS_PUBLISHED])
            ->andWhere(['<=', 'published_from', time()])
            ->andWhere(['category_id' => $category->id]);
        ...
    }

    private function findProduct($id)
    {
        $product = Product::find()
            ->andWhere(['status' => Product::STATUS_PUBLISHED])
            ->andWhere(['<=', 'published_from', time()])
            ->andWhere(['id' => $id])
            ->one();
        ...
    }
}
                    

Расширяем ActiveQuery:


namespace app\models\query;

use app\models\Product;
use yii\db\ActiveQuery;

class ProductQuery extends ActiveQuery
{
    public function published()
    {
        return $this
            ->andWhere(['status' => Product::STATUS_PUBLISHED])
            ->andWhere(['<=', 'published_from', time()]);
    }
}
                    

и подменяем:


namespace app\models;

use app\models\query\ProductQuery;
use yii\db\ActiveRecord;

class Product extends ActiveRecord
{
    const STATUS_DRAFT = 0;
    const STATUS_PUBLISHED = 1;

    public static function find()
    {
        return new ProductQuery(get_called_class());
    }
}
                    

Теперь вместо этого:


class CatalogController extends Controller
{
    public function actionCategory($slug)
    {
        $query = Product::find()
            ->andWhere(['status' => Product::STATUS_PUBLISHED])
            ->andWhere(['<=', 'published_from', time()])
            ->andWhere(['category_id' => $category->id]);
        ...
    }

    private function findProduct($id)
    {
        $product = Product::find()
            ->andWhere(['status' => Product::STATUS_PUBLISHED])
            ->andWhere(['<=', 'published_from', time()])
            ->andWhere(['id' => $id])
            ->one();
        ...
    }
}
                    

используем published():


class CatalogController extends Controller
{
    public function actionCategory($slug)
    {
        $query = Product::find()->published()
            ->andWhere(['category_id' => $category->id]);
        ...
    }

    private function findProduct($id)
    {
        $product = Product::find()->published()
            ->andWhere(['id' => $id])
            ->one();
        ...
    }
}
                    

Остаётся category_id

Выносим forCategory:


class ProductQuery extends ActiveQuery
{
    public function published()
    {
        return $this
            ->andWhere(['status' => Product::STATUS_PUBLISHED])
            ->andWhere(['>=', 'published_from', time()]);
    }

    public function forCategory($id)
    {
        return $this->andWhere(['category_id' => $id]);
    }
}
                    

$query = Product::find()
    ->andWhere(['status' => Product::STATUS_PUBLISHED])
    ->andWhere(['<=', 'published_from', time()])
    ->andWhere(['category_id' => $category->id]);

$dataProvider = new ActiveDataProvider([
    'query' => $query,
]);
                    

vs


$dataProvider = new ActiveDataProvider([
    'query' => Product::find()->published()->forCategory($category->id),
]);
                    

Понятнее
Проще
Надёжнее

DSL


Message::find()->unread()->forUser($id)->byLastWeek();

Request::find()->published()->fromUser($id)->withoutResponses();

Dialog::find()->betweenUsers($fromId, $toId)->withNewMessages();

Man::find()->holost()->sKvartiroy()->bezVP()->sDohodomOt(2000, 'USD')->...
                    

Смысловой язык

И запрос для категории:


class ProductQuery extends ActiveQuery
{
    public function forCategory($id)
    {
        return $this->andWhere(['category_id' => $id]);
    }
}
                    

может быть сложнее...

Например, таким:


class ProductQuery extends ActiveQuery
{
    public function forCategory($id)
    {
        $ids = [$id];
        $childrenIds = [$id];

        while (
            $childrenIds = Category::find()
                ->select('id')
                ->andWhere(['parent_id' => $childrenIds])
                ->column()
        ) {
            $ids = array_merge($ids, $childrenIds);
        }

        return $this->andWhere(['category_id' => $ids]);
    }
}
                    

чтобы учесть вложенность

Кастомные запросы – в ActiveQuery

Слишком кастомные

ElasticSearch + ActiveRecord:


class CategoriesWidget extends Widget
{
    public function run()
    {
        $aggData = (new \yii\elasticsearch\Query())->from('shop', 'products')
            ->addAggregation('product_counts', 'terms', ['field'=>'category_id'])
            ->search(null, ['search_type' => 'count']);
        $buckets = $aggData['aggregations']['product_counts']['buckets'];
        $counts = ArrayHelper::map($buckets, 'key', 'doc_count');

        $categories = Category::find()->orderBy('name')->all();

        return Menu::widget([
            'items' => array_map(function (Category $category) use ($counts) {
                $count = ArrayHelper::getValue($counts, $category->id, 0);
                return [
                    'label' => $category->name . ' (' . $count . ')',
                    'url' => ['/catalog/category', 'id' => $category->id],
                ];
            }, $categories),
        ]);
    }
}
                    

Вынесем выборку:


namespace app\repositories;

class CategoryRepository
{
    public function getAllWithCounts()
    {
        $aggData = (new \yii\elasticsearch\Query())->from('product', 'products')
            ->addAggregation('product_counts', 'terms', ['field'=>'category_id'])
            ->search(null, ['search_type' => 'count']);
        $buckets = $aggData['aggregations']['product_counts']['buckets'];
        $counts = ArrayHelper::map($buckets, 'key', 'doc_count');

        $categories = Category::find()->orderBy('name')->all();

        return array_map(function (Category $category) use ($counts) {
            $count = ArrayHelper::getValue($counts, $category->id, 0);
            return [
                'category' => $category,
                'count' => $count,
            ];
        }, $categories);
    }
}
                

ViewModel:


namespace app\viewModel;

use app\models\Category;

class CategoryWithCount
{
    public $category;
    public $count;

    public function __construct(Category $category, $count)
    {
        $this->category = $category;
        $this->count = $count;
    }
}
                    

Вынесем выборку:


namespace app\repositories;

class CategoryRepository
{
    /**
     * @return CategoryWithCount[]
     */
    public function getAllWithCounts()
    {
        $aggData = (new \yii\elasticsearch\Query())->from('product', 'products')
            ->addAggregation('product_counts', 'terms', ['field'=>'category_id'])
            ->search(null, ['search_type' => 'count']);
        $buckets = $aggData['aggregations']['product_counts']['buckets'];
        $counts = ArrayHelper::map($buckets, 'key', 'doc_count');

        $categories = Category::find()->orderBy('name')->all();

        return array_map(function (Category $category) use ($counts) {
            $count = ArrayHelper::getValue($counts, $category->id, 0);
            return new CategoryWithCount($category, $count);
        }, $categories);
    }
}
                

И наш бывший виджет:


class CategoriesWidget extends Widget
{
    public function run()
    {
        $aggData = (new \yii\elasticsearch\Query())->from('product', 'products')
            ->addAggregation('product_counts', 'terms', ['field'=>'category_id'])
            ->search(null, ['search_type' => 'count']);
        $buckets = $aggData['aggregations']['product_counts']['buckets'];
        $counts = ArrayHelper::map($buckets, 'key', 'doc_count');
        $categories = Category::find()->orderBy('name')->all();

        return Menu::widget([
            'items' => array_map(function (Category $category) use ($counts) {
                $count = ArrayHelper::getValue($counts, $category->id, 0);
                return [
                    'label' => $category->name . ' (' . $count . ')',
                    'url' => ['/catalog/category', 'id' => $category->id],
                ];
            }, $categories),
        ]);
    }
}
                    

...теперь отдыхает:


class CategoriesWidget extends Widget
{
    private $categories;

    public function __construct(CategoryRepository $categories, $config = [])
    {
        $this->categories = $categories;
        parent::__construct($config);
    }

    public function run()
    {
        return Menu::widget([
            'items' => array_map(function (CategoryWithCount $item) {
                return [
                    'label' => $item->category->name . ' (' . $item->count . ')',
                    'url' => ['/catalog/category', 'id' => $item->category->id],
                ];
            }, $this->categories->getAllWithCounts()),
        ]);
    }
}
                    

Слишком кастомные – в репозитории

Ошибки

PasswordResetRequestForm:


class PasswordResetRequestForm extends Model
{
    public function sendEmail()
    {
        $user = User::findOne([
            'status' => User::STATUS_ACTIVE,
            'email' => $this->email,
        ]);
        if (!$user) {
            return false;
        }
        if (!User::isPasswordResetTokenValid($user->password_reset_token)) {
            $user->generatePasswordResetToken();
            if (!$user->save()) {
                return false;
            }
        }
        return Yii::$app->mailer
            ->compose('passwordResetToken', ['user' => $user])
            ->setTo($this->email)
            ->send();
    }
}
                    

И контроллер:


class SiteController extends Controller
{
    public function actionRequestPasswordReset()
    {
        $model = new PasswordResetRequestForm();
        if ($model->load(Yii::$app->request->post()) && $model->validate()) {
            if ($model->sendEmail()) {
                Yii::$app->session->setFlash('success', 'Check your email.');
                return $this->goHome();
            } else {
                Yii::$app->session->setFlash('error', 'Sorry, we are unable...');
            }
        }
        return $this->render('requestPasswordResetToken', [
            'model' => $model,
        ]);
    }
}
                    

Sorry, не смогла... Логи пустые...
Напишу-ка в обратную связь... Тоже не смогла...

Добавим исключения:


public function sendEmail()
{
    if (!$user = User::findOne([
        'status' => User::STATUS_ACTIVE,
        'email' => $this->email,
    ])) {
        throw new \DomainException('User is not found.');
    }
    if (!User::isPasswordResetTokenValid($user->password_reset_token)) {
        $user->generatePasswordResetToken();
        if (!$user->save()) {
            throw new \RuntimeException('User saving error.');
        }
    }
    if (!Yii::$app->mailer
        ->compose('passwordResetToken', ['user' => $user])
        ->setTo($this->email)
        ->send()) {
        throw new \RuntimeException('Email sending error.');
    }
}
                

И контроллер:


class SiteController extends Controller
{
    public function actionRequestPasswordReset()
    {
        $model = new PasswordResetRequestForm();
        if ($model->load(Yii::$app->request->post()) && $model->validate()) {
            if ($model->sendEmail()) {
                Yii::$app->session->setFlash('success', 'Check your email.');
                return $this->goHome();
            } else {
                Yii::$app->session->setFlash('error', 'Sorry, we are unable...');
            }
        }
        return $this->render('requestPasswordResetToken', [
            'model' => $model,
        ]);
    }
}
                    

сделаем логируемым:


class SiteController extends Controller
{
    public function actionRequestPasswordReset()
    {
        $model = new PasswordResetRequestForm();
        if ($model->load(Yii::$app->request->post()) && $model->validate()) {
            try {
                $model->sendEmail();
                Yii::$app->session->setFlash('success', 'Check your email.');
                return $this->goHome();
            } catch (\Exception $e) {
                Yii::$app->errorHandler->logException($e);
                Yii::$app->session->setFlash('error', 'Sorry, we are unable...');
            }
        }
        return $this->render('requestPasswordResetToken', [
            'model' => $model,
        ]);
    }
}
                

Ошибки – в Exception-ы

Данные формы

DropDownList:


<?php $form = ActiveForm::begin(); ?>

    <?= $form->field($model, 'categoryId')->dropDownList(
        ArrayHelper::map(Category::find()->asArray()->all(), 'id', 'name')
    ) ?>
    <?= $form->field($model, 'name')->textInput() ?>
    <?= $form->field($model, 'text')->textarea(['rows' => 20]) ?>

    ...

<?php ActiveForm::end(); ?>
                    

Много кода в представлениях

Особенно для дерева

PostForm:


class PostForm extends Model
{
    public $title;
    public $categoryId;
    public $text;

    public function rules()
    {
        return [
            [['title', 'categoryId'], 'required'],
            [['title', 'text'], 'string'],
            [['categoryId'], 'integer'],
        ];
    }

    public function categoriesList()
    {
        return ArrayHelper::map(Category::find()->asArray()->all(), 'id', 'name');
    }
}
                    

И вместо хардкода:


<?php $form = ActiveForm::begin(); ?>

    <?= $form->field($model, 'categoryId')->dropDownList(
        ArrayHelper::map(Category::find()->asArray()->all(), 'id', 'name')
    ) ?>
    <?= $form->field($model, 'name')->textInput() ?>
    <?= $form->field($model, 'text')->textarea(['rows' => 20]) ?>

    ...

<?php ActiveForm::end(); ?>
                

вызывем метод:


<?php $form = ActiveForm::begin(); ?>

    <?= $form->field($model, 'categoryId')->dropDownList($model->categoriesList()) ?>
    <?= $form->field($model, 'name')->textInput() ?>
    <?= $form->field($model, 'text')->textarea(['rows' => 20]) ?>

    ...

<?php ActiveForm::end(); ?>
                    

Данные формы – в модель формы

Правила валидации

SignupForm:


class SignupForm extends Model
{
    public $username;
    public $email;
    public $phone;
    public $password;

    public function rules()
    {
        return [
            [['username', 'email', 'phone', 'password'], 'required'],
            ['username', 'match', 'pattern' => '#^[a-z0-9-]+$#is'],
            ['phone', 'match', 'pattern' => '#^7\d+$#s'],
            ['email', 'email'],
        ];
    }
}
                    

ProfileForm:


class ProfileForm extends Model
{
    public $email;
    public $phone;

    public function rules()
    {
        return [
            [['email', 'phone'], 'required'],
            ['phone', 'match', 'pattern' => '#^7\d+$#s'],
            ['email', 'email'],
        ];
    }
}
                    

Регулярки повторяются

Выносим валидаторы:


namespace app\validators;

use yii\validators\RegularExpressionValidator;

class UsernameValidator extends RegularExpressionValidator
{
    public $pattern = '#^[a-z0-9_-]+$#is';
}

...

class PhoneValidator extends RegularExpressionValidator
{
    public $pattern = '#^7\d+$#s';
}
                    

и используем:


class SignupForm extends Model
{
    public $username;
    public $email;
    public $phone;
    public $password;

    public function rules()
    {
        return [
            [['username', 'email', 'phone', 'password'], 'required'],
            ['username', UsernameValidator::className()],
            ['phone', PhoneValidator::className()],
            ['email', 'email'],
        ];
    }
}
                    

Правила валидации – в валидаторы

Бизнес-логику чтения

Есть заказ:


/**
 * @property integer $status
 * @property integer $deliveryDate
 */
class Order extends ActiveRecord
{
    const STATUS_NEW = 1;
    const STATUS_PAID = 2;
    const STATUS_SENT = 3;
    const STATUS_CANCELLED = 4;
}
                    

И есть if:


<?php if (
    $order->status === Order::STATUS_NEW ||
    ($order->status === Order::STATUS_PAID &&
        $order->deliveryDate > time() + 3600 * 24)
): ?>

    <div>...</div>

<?php endif; ?>
                    

Вспоминаем GRASP

1. Information expert

2. Ещё какие-то пункты

Добавляем isNew:


class Order extends ActiveRecord
{
    const STATUS_NEW = 1;
    const STATUS_PAID = 2;
    const STATUS_SENT = 3;
    const STATUS_CANCELLED = 4;

    public function isNew()
    {
        return $this->status === self::STATUS_NEW;
    }
}
                    

И в if:


<?php if (
    $order->status === Order::STATUS_NEW ||
    ($order->status === Order::STATUS_PAID &&
        $order->deliveryDate > time() + 3600 * 24)
): ?>

    <div>...</div>

<?php endif; ?>
                    

И в if:


<?php if (
    $order->isNew() ||
    ($order->status === Order::STATUS_PAID &&
        $order->deliveryDate > time() + 3600 * 24)
): ?>

    <div>...</div>

<?php endif; ?>
                    

Добавляем isPaid:


class Order extends ActiveRecord
{
    public function isNew()
    {
        return $this->status === self::STATUS_NEW;
    }

    public function isPaid()
    {
        return $this->status === self::STATUS_PAID;
    }
}
                    

И в if:


<?php if (
    $order->isNew() ||
    ($order->status === Order::STATUS_PAID &&
        $order->deliveryDate > time() + 3600 * 24)
): ?>

    <div>...</div>

<?php endif; ?>
                    

И в if:


<?php if (
    $order->isNew() ||
    ($order->isPaid() &&
        $order->deliveryDate > time() + 3600 * 24)
): ?>

    <div>...</div>

<?php endif; ?>
                    

И в if:


<?php if (
    $order->isNew() ||
    ($order->isPaid() && $order->deliveryDate > time() + 3600 * 24)
): ?>

    <div>...</div>

<?php endif; ?>
                    

Добавляем isOnDelivery:


class Order extends ActiveRecord
{
    public function isNew() {
        return $this->status === self::STATUS_NEW;
    }

    public function isPaid() {
        return $this->status === self::STATUS_PAID;
    }

    public function isOnDelivery() {
        return $this->deliveryDate < time() + 3600 * 24;
    }
}
                    

И в if:


<?php if (
    $order->isNew() ||
    ($order->isPaid() && $order->deliveryDate > time() + 3600 * 24)
): ?>

    <div>...</div>

<?php endif; ?>
                    

И в if:


<?php if (
    $order->isNew() ||
    ($order->isPaid() && !$order->isOnDelivery())
): ?>

    <div>...</div>

<?php endif; ?>
                    

И в if:


<?php if ($order->isNew() || ($order->isPaid() && !$order->isOnDelivery())): ?>

    <div>...</div>

<?php endif; ?>
                    

Добавляем isCancelable:


class Order
{
    public function isNew() {
        return $this->status == self::STATUS_NEW;
    }

    public function isPaid() {
        return $this->status == self::STATUS_PAID;
    }

    public function isOnDelivery() {
        return $this->deliveryDate < time() + 3600 * 24;
    }

    public function isCancelable() {
        return $this->isNew() || ($this->isPaid() && !$this->isOnDelivery());
    }
}
                    

И в if:


<?php if ($order->isNew() || ($order->isPaid() && !$order->isOnDelivery())): ?>

    <div>...</div>

<?php endif; ?>
                    

И в if:


<?php if ($order->isCancelable()): ?>

    <div>...</div>

<?php endif; ?>
                    

<?php if (
    $order->status === Order::STATUS_NEW ||
    ($order->status === Order::STATUS_PAID &&
        $order->deliveryDate > time() + 3600 * 24)
): ?>
    <?= Html::a('Отменить', ['cancel', 'id' => $order->id]) ?>
<?php endif; ?>
                    

vs


<?php if ($order->isCancelable()): ?>
    <?= Html::a('Отменить', ['cancel', 'id' => $order->id]) ?>
<?php endif; ?>
                    

Понятнее
Проще
Надёжнее

Геттеры

$order->getAddress()

$order->isPaid()

$order->hasProduct($productId)

$order->canAddNewItem()

$order->canBePaidByUser($userId)

читают/считают

Совместные покупки:


/**
 * @property integer $status
 * @property integer $deliveryDate
 *
 * @property Member[] $members
 */
class Order extends ActiveRecord
{
    public function getMembers()
    {
        return $this->hasMany(Member::className(), ['order_id', 'id']);
    }
}
                    

Много участников

с долями по оплате:


/**
 * @property integer $order_id
 * @property integer $user_id
 * @property integer $amount
 * @property integer $status
 */
class Member extends ActiveRecord
{
    const STATUS_WAITING = 1;
    const STATUS_PAID = 2;

    public function getUser()
    {
        return $this->hasOne(User::className(), ['id', 'user_id']);
    }
}
                    

Может ли оплатить:


class Order extends ActiveRecord
{
    ...

    public function canBePaidByUser($userId)
    {
        // Если заказ не в стадии оплаты, то не может:
        if (!$this->isWaitingForPayment()) {
            return false;
        }

        // Если является участником, но ещё не платил, то может:
        foreach ($this->members as $member) {
            if ($member->isForUser($userId) && $member->isWaiting()) {
                return true;
            }
        }

        // Иначе не может:
        return false;
    }
}
                    

И наш Member:


class Member extends ActiveRecord
{
    const STATUS_WAITING = 1;
    const STATUS_PAID = 2;

    public function isForUser($id)
    {
        return $this->user_id === $id;
    }

    public function isWaiting()
    {
        return $this->status === self::STATUS_WAITING;
    }
}
                    

Итого:


<?php if ($order->canBePaidByUser(Yii::$app->user->id)): ?>

    <?= Html::a('Оплатить', ['pay', 'id' => $order->id]) ?>

<?php endif; ?>
                    

Бизнес-логику чтения – в геттеры сущности

Бизнес-логику записи

Внесение денег участником:


class Order extends ActiveRecord
{
    ...

    public function payByUser($userId, $amount)
    {
        if (!$this->isWaitingForPayment()) {
            throw new \DomainException('Оплата заказа недоступна.');
        }
        foreach ($this->members as $member) {
            if ($member->isForUser($userId)) {
                $member->pay($amount);
                return;
            }
        }
        throw new \DomainException('Заявка не найдена.');
    }
}
                    

на его долю:


class Member extends ActiveRecord
{
    ...

    public function pay($amount)
    {
        if (!$this->isWaiting()) {
            throw new \DomainException('Заявка уже оплачена.');
        }
        $this->amount = $amount;
        $this->status = self::STATUS_PAID;
    }
}
                    

Бизнес-логику записи – в методы сущности

Прочий код

Вспомним MarketAccessChecker:


namespace app\services;

use app\models\User;

class MarketAccessChecker
{
    private $billing;

    public function __construct(Billing $billing)
    {
        $this->billing = $billing;
    }

    public function allowToMarket(User $user)
    {
        return $user->isEmailConfirmed() && !$this->billing->hasDebts($user->id);
    }
}
                    

Прочий код – в просто классы

Итого:

  • Фильтры – в фильтры
  • Кастомные запросы – в ActiveQuery
  • Слишком кастомные – в репозитории
  • Ошибки – в Exception-ы
  • Данные формы – в модель формы
  • Правила валидации – в валидаторы
  • Бизнес-логику чтения – в геттеры сущности
  • Бизнес-логику записи – в методы сущности
  • Прочий код – в просто классы

Время вопросов

Всё

- http://elisdn.ru
- http://elisdn.ru/oop-week
- http://elisdn.ru/yii2-shop
- http://slides.elisdn.ru/2017/yiiconf-semantic