Skip to content

Instantly share code, notes, and snippets.

@RusAlex
Last active January 7, 2022 12:48
Show Gist options
  • Star 19 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save RusAlex/0397b42e0f052ec84948 to your computer and use it in GitHub Desktop.
Save RusAlex/0397b42e0f052ec84948 to your computer and use it in GitHub Desktop.

Разделение приложения на слои

Многие, кто писал приложение на yii 1.x, (на самом деле как я понимаю все нижеописанное актуально и для yii 2.x) и если проект достаточно сложный,n в какой то момент приходил к ситуации, что модели становились толстые, что, количество сценариев в модели растет, методы beforeSave, beforeValidate, afterValidate становятся неуправляемые и все это превращается в нетестируемый, неуправляемый код. И тут появляется то самое чувство, что ты делаешь что-то не так.

Вариантов решения на самом деле наверное несколько, но я почему-то созрел и увидел только на основе Layered Architecture. Достаточно логично и легко применимый шаблон для приложения в описанной ситуации.

Итак вы в ситуации когда менять и добавлять логику сложно, но бизнес требует. Две важные установки, который приходится следовать:

  • Новую логику добавляем используя новую предложенную архитектуру.
  • Старую логику, перед изменением оцениваем объем работ по изменению в текущем виде, и если это достаточно затратно по времени, а может быть и нервная система будет страдать, то опять же переписываем уже
  • измененную логику под новую архитектуру.

Как она теперь выглядит эта "новая архитектура" ?

  1. Ваша модель больше не используется для валидации пользовательского ввода. Вся старая валидация остается там, а вот для новой логики, больше не пишем валидацию в модели.
  2. Больше не добавляете ничего нового в метод

пример: Ваша бизнес логика требует ввод новых данных от пользователя и сохранение этих данных в базу. Пусть это будет просто ModerateComment. Фича, позволяющая вам модерировать комментарий для вашего блога. Все просто, старый метод потребовал бы от нас просто добавленеи нового сценария и вызова метода "save". У модели Comment. И может быть этот простой путь покажется вам проще нижеописанного, но вы понимаете, это просто пример который сильно упрощен и этим путем мы уже написали в своем приложении модель на сотню килобайт и даже такая простая задача требует от нас теперь очень много времени, потому что старую логику приходится перепроверять и не стараться не сломать.

Мы будем использовать теперь несколько новых для нас сущностей. Для примера новая логика будет названа "ModeratePost"

  1. ModerateModel - для валидации пользовательских данны
  2. ModerateService - сущность которая будет непосредственно писать и читать в/из базы данных.

Введение двух новых сущностей как раз и позволяет нам изолировать новую логику от старого приложения. В UML это будет выглядеть примерно так:

   +---------------+           +-----------------+
   | ModerateModel |           | ModerateService |
   |               |           |                 |
   +---------------+           +-----------------+
   |               |           |                 |
   |               |           |                 |
   |               |           |                 |
   |               |           |                 |
   |               |           |                 |
   +---------------+           ++----------------+

Наша модель теперь ответственна только за валидацию пользовательских данных, а Service за действия связанные с базой данных.

Такой подход позволяет изолированно производить тестирование модели, не затрагивая базу данных. Ну а также тестировать код, который что-то пишет или читает из базы с помощью phpunit only или в паре с codeception. Знаете yii фикстуры ? phpunit тоже их умеет.

Ну теперь немного кода:

class ModerateModel extends Model
{
   /**
    * yii валидация, здесь можно описать правила.
    * Для нашего простого примера это только 1 поле is_moderated.
    * Пример для yii 1.x
    */
   public function rules()
   {
      return ['is_moderated', 'in', 'range' => [1], 'allowEmpty' => false];
   }
}
/**
 * Класс для работы с базой данных.
 * Не наследуется ни от каких классов фреймворка. Yii умеет только
 * ActiveRecord для работы с базой, мы уже наелись проблем этого
 * паттерна.
 */
class ModerateService
{
  /**
   * Ставит колонку is_moderated в таблице комментариев в 1.
   */
  public function moderate(Comment $comment)
  {
    $command = Yii::app()->db->createCommand('UPDATE comments SET
    is_moderated=1 WHERE id=:comment_id')->execute([':comment_id' => $comment->id]);
  }

}

Теперь нам не достает только соответсвующего экшена в контроллере.

public actionModerate()
{
  $comment = $this->loadModel($_GET['id']);
  $service = new ModerateService;
  $model = new ModerateModel;
  $model->setAttributes($_POST['ModerateModel']);
  if ($model->validate()) {
    $service->moderate($comment);
  }
}

Итак что нам дает такой подход: написание новой логики никак не использует старый код, в который уже не хочется лезть из за страха что-то сломать. Новая логика полностью изолирована и легко может быть протестирована. А это немаловажно: иметь тестируемый код. Отсюда вытекает такая хорошая вещь, как возможность целиком выпилить всю логику, ответственную за модерацию, просто удалив 2 файла и один экшен, без страха что-то сломать.

И еще одна особенность, наш ModerateService не наследуется ни от какого класса фреймворка, в будущем, когда выйдет новая версия фреймворка, вам не потребуется переписывать этот класс. А придется переписать только класс ModerateModel который используется для валидации. А ведь со временем приложение обрастает логикой и чем меньше вам придется переписать, чтобы сменить фреймворк тем лучше. Фреймворко-независимое приложение это уже сейчас становится важным. Но это тоже целый подход.

@nepster-web
Copy link

Мне кажется этот подход немного сомнительным. Тоесть сама идея отличная, а вот реализация крайне сомнительна.

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

Итак у нас есть следующие задача:

  • Просмотр всех постов (пагинация, фильтры поиска, сортировка. В общем полный набор)
  • Форма создания поста (много полей, дата время, много валидации и дополнительные условия)
  • Просмотр одного поста
  • Рейтинг

Конечно если мы все это дело постараемся впихнуть в одну модель, то действительно будет ощущение неловкости и повышенный холестерин.

Но мы можем сделать следующее:
Просмотр всех постов
Для этого мы создаем отдельную модель PostSearch и всю логику для отображения, поиска и сортировки выносим в нее.

Форма создания поста
Для этого мы создаем еще одну модель PostForm, которая будет отвечать за создания поста и решать дополнительные условия (валидация, обработка данных, запись в базу и тп).

Не забудем еще про кастомные квери
PostQuery scope модель, будет содержать методы для упрощения выборки, например status(1), выберет все статьи со статусом 1.

Модель Post
Которая содержит общие данные, например список и перевод атрибутов, название таблицы, реляции и методы для выборки.

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

@yupe
Copy link

yupe commented Jul 27, 2015

@nepster-web имхо, но для чего в Уии2 придумали PostSearch и PostQuery я не очень понимаю.
Для этого всего давно уже есть паттерн Repository, который делает все тоже самое. Кроме того, PostForm, на мой взгляд не должен ничего писать в БД. Это всего лишь форма, которая имеет набор полей и правила их валидации. Писать должен как раз PostRepository::create(PostForm $post). Как-то так.

@yupe
Copy link

yupe commented Jul 27, 2015

@RusAlex я бы еще убрал захардкоженные:
$service = new ModerateService;
$model = new ModerateModel;

Из контроллера, в Уии2 можно это сделать через DI и конструктор контроллера, например +)

@nepster-web
Copy link

@yupe, это вы хотите сделать попытку организовать в yii2 похожий подход как в symfony 2 ?
У меня мало опыта в использовании паттерна репозитория, есть ли у вас не большой пример демонстрирующий пример на практике ?

@cyhalothrin
Copy link

@yupe, @nepster-web, интересен момент сохранения связанных данных, например, доступов пользователей к объекту etc. За это будет отвечать PostRepository::create()?

@nepster-web
Copy link

@cyhalothrin, я еще честно говоря не понял в чем профит использования отдельной модели для сохранения, а почитать и вникнуть в паттерн репозитроий не хватает времени.

Но если рассмотреть простой пример, например создать запись в блоге, то выходит следующее:

  • контроллер принимает данные и отдает их в модель PostForm
  • модель PostForm осуществляет валидацию и тут необходима запись в базу данных.
    но так как данные уже все есть нам хватит 1 строчки $model->save()
    выходит, что нужно создавать отдельную модель PostRepository::create() для строки $model->save() ?

С другой стороны, если есть связанные модели, которые тоже нужно сохранить, например для записи в блоге нужно сохранить список тегов. То в таком случае может быть PostRepository и будет оправдан, хотя я не совсем понимаю, как это можно красиво организовать.

Сейчас мои экшины выглядят так:

    public function actionIndex()
    {
        $model = new PostForm(['scenario' => 'user-create']);
        if ($model->load(Yii::$app->request->post())) {
            if ($model->validate()) {
                if ($model->save(false)) {
                    Yii::$app->session->setFlash('success', Yii::t('post', 'SUCCES_ASK'));
                } else {
                    Yii::$app->session->setFlash('danger', Yii::t('post', 'FAIL_ASK'));
                }
                return $this->redirect(['index']);
            } else if (Yii::$app->request->isAjax) {
                Yii::$app->response->format = Response::FORMAT_JSON;
                return ActiveForm::validate($model);
            }
        }

        return $this->render('index', [
            'model' => $model
        ]);
    }

Тоесть на каждую сущность я держу свою модель:
Post - общая модель актив рекорд.
PostForm - (extend Post) - модель для формы.
PostSearch - (extend Post) - модель для поиска.
PostQuery - кастомные квери.

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

А вот как это оптимизировать еще лучше добавив PostRepository я не совсем понимаю.

*Плюс ко всему я наткнулся на еще одну проблему, возможно она тоже будет интересной. *
Суть такова, что есть очень много вариантов запросов например:
- Все статьи со статусом 1
- Все статьи со статусом 1 + джоин юзер
- Все статьи со статусом 1 + джоин юзер + профиль
- Все статьи со статусом 1 + джоин все теги + джин юзер + профиль + еще что-то

И таких вариантов может быть около 5 - 7 на одну сущность. Я старался всю выборку инкапсулирвоать в методы модели. Но выходит нужно делать 25 методов на все случаи жизни или делать 1 божественный метод с 25 условиями и параметрами.

И все это ради того, чтобы избежать прямых вызовов AR в виде или контроллере. Есть ли у кого мысли на этот счет?

@lynicidn
Copy link

афтар пишы исчо, тема клевая, поднимал ее - не одобрили, но сам активно юзаю

@cyhalothrin
Copy link

@nepster-web, я так понимаю что в примере @yupe PostForm не наследывается от Post, а от Model

Это всего лишь форма, которая имеет набор полей и правила их валидации.

Т.е. это для валидации и указания полей которые модель Post не может иметь в ее таблице, например, для сохранения связанных данных. И вся ответственность за сохранение модели и всех необходимых связей переходит к Repository, что должно решать проблему указанную в посте:

что модели становились толстые, что, количество сценариев в модели растет, методы beforeSave, beforeValidate, afterValidate становятся неуправляемые

@nepster-web
Copy link

@cyhalothrin
возможно, но если засунуть скажем много логики в beforeSave, beforeValidate, afterValidate и ограничиться моделью PostForm и так для всех, то я вам скажу не так уж много мусора получается .

Но еще есть момент, когда несколько приложений, например в шаблон адвенсед.
Когда PostForm нужна например для админки и PostForm нужна для сайта, где условия и логика может не много отличаться.

Тогда если это все дело скинуть в PostService нужно будет проставлять дополнительные условия. Тут тоже вопросы.

@Vashkatsi
Copy link

@nepster-web

  • Все статьи со статусом 1
  • Все статьи со статусом 1 + джоин юзер
  • Все статьи со статусом 1 + джоин юзер + профиль
  • Все статьи со статусом 1 + джоин все теги + джин юзер + профиль + еще что-то

Тут, мне кажется, нет другого выхода кроме как прямой вызов AR в модели. Скажите пожалуйста, проблемы такого подхода?

@nepster-web
Copy link

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

@ValeriiTsarov
Copy link

Если есть большое разнообразие логики (много разных запросов), то Вам должен помочь шаблон Спецификация.
https://ru.wikipedia.org/wiki/%D0%A1%D0%BF%D0%B5%D1%86%D0%B8%D1%84%D0%B8%D0%BA%D0%B0%D1%86%D0%B8%D1%8F_(%D1%88%D0%B0%D0%B1%D0%BB%D0%BE%D0%BD_%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F)

@hunter-kaan
Copy link

@nepster-web

  • Все статьи со статусом 1
  • Все статьи со статусом 1 + джоин юзер
  • Все статьи со статусом 1 + джоин юзер + профиль
  • Все статьи со статусом 1 + джоин все теги + джин юзер + профиль + еще что-то

Разве это не решается при помощи кастомного Query?

что-то в стиле

Post::find()
            ->active()
            ->withUser()
            ->withProfile()
            ->withSomething()

@devkpv
Copy link

devkpv commented Nov 9, 2015

Смешались в кучу кони, люди.
Класс ModerateService можно написать примерно так:

class ModerateService
{
  private $command;

  public function __construct(Command $command) {
    $this->command = $command;
  }

  public function moderate(Comment $comment)
  {
    return $this->command
        ->update('comment', ['is_moderated' => 1], 'id = :id')
        ->execute([':id' => $comment->id]);
  }
}

В этом случае мы пользуемся уже QueryBuilder'ом. AR для меня пока и остался загадкой для разработки действительно сложных приложений. Приходится его откидывать, т.к. слишком большая область отвественности получается. Зато отлично подходит для реализации CRUD'a, прототипирования и реализации простых вещей. Главное во время останавливаться и понимать где стоит использовать AR, а где уже стоит выделить логику в некий сервисный слой.

Как было выше сказано, в контроллере стоит вызывать наш сервис через DI, предварительного его там зарегистрировав. Так же не стоит напрямую обращаться к глобальным переменным вроде $_GET, $_POST etc.

public actionModerate()
{
  $comment = $this->loadModel(Yii::$app->request->getQueryParam('id'));
  $model = new ModerateModel();
  $model->setAttributes(Yii::$app->request->post('id'));
  if ($model->validate()) {
    Yii::$container->get('ModerateService')->moderate($comment);
  }
}

@yiimar
Copy link

yiimar commented Dec 11, 2015

loadModel(), setAttributtes() в контроллере...
Не точнее ли вынести это в форму, (кстати это же советовал и vova07). Там же можно проводить и валидацию.
В контроллере будет только

public actionMy()  
{
   $model = new MyForm();
   return $this->render('my', ['model' => $model,]);
}

Формально, контроллеру (здесь и везде подразумевается действию контроллера) надо знать, только свое представление, и что ввод данных обслуживается формой MyForm. Метод init() в классе Model есть (точнее в предке- классе Object). Всю логику туда.
Ну и, конечно, если нужны дополнительные данные для вывода в представлении, то в контроллере добавятся (один или несколько)

    ...
    $mymodel = Mymodel::makeData();

Для не очень крупных проектов, имхо, это применимо.

Буду признателен, коллеги, за Ваше мнение. СПС.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment