Набор велосипедов Yii разработчика

    От автора

    Когда автор пишет пост на хабре, он старается дать читателям максимально полную и полезную информацию по теме. А вот если правильного ответа или решения нет? Тогда этот пост просто пища для ума, а ценность заключается в коллективном разуме.

    Зашел я из далека, не спорю, но надеюсь на ваше понимание и поддержку. Озвучивая решения повседневных проблем разработчика, в частности на фреймворке Yii, я предлагаю решение нашей команды. При этом, интересуют идеи сообщества. Ну, довольно пудрить вам мозги.
    Вперёд.


    Модно, молодёжно, trait'ы


    Бывают такие ситуации, когда модель выполняет метод N(), и при этом возвращает true / false и это прекрасно. Но обычно пользователю не понятно почему произошла ошибка и как жить с этим дальше, нужны подробности. Хорошо, если логика простая и вы не перфекционист — вынес чуть-чуть бизнес логики в контроллер и вывел ошибки с подробностями — но мы то не такие!
    А если метод может вывести до 20 разных ошибок, почему Вася не может купить пирожок или запостить комментарий.
    В Yii есть прекрасный метод validate() у модели, но он точно завязан на валидации данных самой модели и не подходит, если вы создали абстрактный метод не связанный напрямую с моделью.

    Как же быть?
    А вот так
    trait CustomError {
        private $_errorMessages = [];
    
        /**
         * Use in method :  return $this->setCustomErrorMessage(message);
         *
         * @param array $errorMessages
         * @return false
         */
        public function setCustomErrorMessage($errorMessages)
        {
            if(!is_array($errorMessages))
                $errorMessages = [$errorMessages];
    
            $this->_errorMessages = $errorMessages;
    
            return false;
        }
    
        /**
         * @param string $errorMessage
         */
        public function addCustomErrorMessage($errorMessage)
        {
            $this->_errorMessages[] = $errorMessage;
        }
    
        /**
         * @return array
         */
        public function getCustomErrorMessages()
        {
            return $this->_errorMessages;
        }
    
        /**
         * @return mixed
         */
        public function getCustomErrorMessageFirst()
        {
            return reset($this->_errorMessages);
        }
    
        /**
         * @return void
         */
        public function clearCustomErrorMessages()
        {
            $this->_errorMessages = [];
            return;
        }
    }
    


    Простой код как 5 копеек, но очень упрощает жизнь. Пример:
    class Blog extends CActiveRecord {
    
        use CustomError; // Подключаем наш трайт
    
        public function checkPrivacyCreate() { // Проверяем, может ли пользователь написать коммент
               ...
                $parent_post = $this->getPost($this->parent_post_id);
                if (empty($parent_post))
                    return $this->setCustomErrorMessage(Yii::t('blog', 'post_not_found'));
               ...
            return true;
        }
    }
    
    // использование в контроллере
    public function actionAddPost() {
          ....
          if (!$model->addPost())
                    Tools::jsonError($model->getCustomErrorMessages()); // выводим в JSON формате ошибку
          ...
    }
    


    Что же мы получаем на выходе? Мы получаем адекватные методы, которые возвращают bool значение, а не винегрет возможных ответов, от int до string. Никакого дублирование кода, чистый DRY. Хотя нет, уверен, что умные люди придумают вариант почище, ну что же, было бы здорово!

    Долой модные штуки, только консоль, только хардкор!


    В Smartprogress мы используем continuous integration и каждый коммит проходит несколько стадий, от тестирование на локале, тестирование на дев сервере, тестирования на продакшене и тестирование на пользователях.

    О чём это, а да, о том, что у нас аж 6 баз данных. По две на каждый этап, рабочая и тестовая. Сказать, что мы молимся на миграции — ничего не сказать. Но вот незадача, Yii migrate команда не предлагает никакого адекватного решения для такого зоопарка баз. Да, через ключи вы можете указать нужное соединение, но делать это каждый раз долго, нудно, ЛЕНЬ (лень это то чувство, вызывающее симпатию у программистов даже больше, чем мужская солидарность. Что уж тут говорить, этот пост рожден в объятиях этой жрицы программисткого искусства)

    Ох и потянуло меня, давайте как все любят, бац бац и…
    решение
    <?php
    Yii::import('system.cli.commands.MigrateCommand');
    class MigratecomboCommand extends MigrateCommand {
        public $connections = array('db', 'db_test'); // Название компонентов коннектов из вашего конфига
    
        public function actionUp($args)
        {
            if(($migrations=$this->getNewMigrations())===array())
            {
                echo "No new migration found. Your system is up-to-date.\n";
                return 0;
            }
    
            $total=count($migrations);
            $step=isset($args[0]) ? (int)$args[0] : 0;
            if($step>0)
                $migrations=array_slice($migrations,0,$step);
    
            $n=count($migrations);
            if($n===$total)
                echo "Total $n new ".($n===1 ? 'migration':'migrations')." to be applied:\n";
            else
                echo "Total $n out of $total new ".($total===1 ? 'migration':'migrations')." to be applied:\n";
    
            foreach($migrations as $migration)
                echo "    $migration\n";
            echo "\n";
    
            if($this->confirm('Apply the above '.($n===1 ? 'migration':'migrations')."?"))
            {
                foreach($migrations as $migration)
                {
                    foreach($this->connections as $connectionId) { // !!! Вся магия здесь, мы прогоняем миграцию по всем конектам
                        $this->connectionID = $connectionId;
                        if($this->migrateUp($migration)===false)
                        {
                            echo "\nMigration failed. All later migrations are canceled.\n";
                            return 2;
                        }
                    }
                }
                echo "\nMigrated up successfully.\n";
            }
        }
    
        public function actionDown($args)
        {
            $step=isset($args[0]) ? (int)$args[0] : 1;
            if($step<1)
            {
                echo "Error: The step parameter must be greater than 0.\n";
                return 1;
            }
    
            if(($migrations=$this->getMigrationHistory($step))===array())
            {
                echo "No migration has been done before.\n";
                return 0;
            }
            $migrations=array_keys($migrations);
    
            $n=count($migrations);
            echo "Total $n ".($n===1 ? 'migration':'migrations')." to be reverted:\n";
            foreach($migrations as $migration)
                echo "    $migration\n";
            echo "\n";
    
            if($this->confirm('Revert the above '.($n===1 ? 'migration':'migrations')."?"))
            {
                foreach($migrations as $migration)
                {
                    foreach($this->connections as $connectionId) {
                        $this->connectionID = $connectionId;
                        if($this->migrateDown($migration)===false)
                        {
                            echo "\nMigration failed. All later migrations are canceled.\n";
                            return 2;
                        }
                    }
                }
                echo "\nMigrated down successfully.\n";
            }
        }
    
        private $_db;
        protected function getDbConnection()
        {
            if(($this->_db=Yii::app()->getComponent($this->connectionID)) instanceof CDbConnection)
                return $this->_db;
    
            echo "Error: CMigrationCommand.connectionID '{$this->connectionID}' is invalid. Please make sure it refers to the ID of a CDbConnection application component.\n";
            exit(1);
        }
    }
    


    Немного поясню, в методе Up/Down мы проходимся в цикле по всем коннектам и по очереди применяем нашу миграцию к каждой базе.
    Решение элементарное до нельзя. По моему даже где то подсмотренное, каюсь. Но теперь, достаточно одной команды, которую можно выполнить даже в пятницу вечером будучи в «абстрактном» состоянии.
    yiic migratecombo up(/down/create/...)
    

    И ваши миграции применяться ко всем существующим базам, указанным в переменной $connections.

    Но есть нюансы. Если вы решите, как то по хитрому выполнить миграцию, не используя стандартные методы Yii, а напрямую через базу, то:
    class m140317_060002_fill_search_column extends CDbMigration
    {
    	public function up()
    	{
            $goals = $this->getDbConnection() // Обратите внимание, вместо Yii::app()->db->createCommand... мы используем $this->getDbConnection()
                ->createCommand("SELECT id, `name` FROM goals WHERE `moderated` != 'deleted'")
                ->queryAll();
    


    Тестирование, юнит, функциональное, на кроликах


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

    В функциональном тестирование первое, с чем я столкнулся, это то, что почти все функции сайта доступны только авторизированным пользователям, а как известно окружение для каждого теста девственно чисто.
    Мы и это решили
    class WebTestCase extends CWebTestCase
    {
            public $loginRequired = false;
    
            protected function setUp()
    	{
    		parent::setUp();
                    $this->setBrowser('*firefox');
    		$this->setBrowserUrl(TEST_BASE_URL);
                    $this->prepareTestSession();
            
                    if($this->loginRequired) {
                        $this->login();
                    }
    	}
    }
    


    Код метода логина приводить не буду, там всё сугубо индивидуально. Теперь достаточно в классе теста указать loginRequired = true и ваш тест будет выполнять авторизированный по всем правилам пользователь.

    Немогу не посоветовать молодым и неопытным тестировщикам как я, замечательный инструмент Faker для генерации фиктивных, но максимально реалистичных данных. Незаменимая вещь для DataProvider
    Маленький пример
    class MyTest extends CDbTestCase
    {
         public function newUserProvider() { // генерим 3 случайных набора данных
            $faker = \Faker\Factory::create('ru_RU');
            $array = array();
    
            for($i=0; $i<3; $i++) {
                $array[$i]['user']['name'] = $faker->name;
                $array[$i]['user']['address'] = $faker->address;
                $array[$i]['user']['country'] = $faker->country;
            }
            return $array;
        }
    
       /**
         * @param $user
         * @dataProvider newUserProvider
         */
        public function testCreate($user) // Этот тест выполнится 3 раза и каждый раз с разными данными
        {
             $model = new User('signup');
             $model->name = $user['name'];
             ...
             $model->save()
        }
    
    }
    



    Конечно, это не все хитрости и плюшки, которые мы родили за долгий период разработки Smartprogress.
    Есть еще много решение и улучшений, но я бы хотел попросить вас, дорогие читатели, поделиться своими мыслями и наработками по теме. Наверняка у каждого разработчика есть настоящий зоопарк хелперов и готовых решение для самых разных задач.
    Надеюсь вы поделитесь ими со мной и всем сообществом habrahabr.
    SmartProgress
    0.00
    Сервис постановки и достижения целей
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 9

      +1
      Так и не понял, зачем CustomError нужен модели вместо стандартного addError(), он ведь не обязательно должен работать в паре с валидацией. На ум приходит разьве что случай, когда у нас класс не является предком CModel
        0
        Проблема в том, что addError имеет сигнатуру: addError($attribute,$error) а если у вас метод не связан с каким то определенным атрибутом, то его использование некорректно.
          +3
          Атрибут это не только столбцы таблицы ведь, поэтому ничего не мешает использовать виртуальный атрибут.
          class Blog extends CActiveRecord {
              public function checkPrivacyCreate() { // Проверяем, может ли пользователь написать коммент
                     ...
                      $parent_post = $this->getPost($this->parent_post_id);
                      if (empty($parent_post)) {
                          $this->addError('privacy', Yii::t('blog', 'post_not_found'));
                          return false;
                      }
                     ...
                  return true;
              }
          }
          
          // использование в контроллере
          public function actionAddPost() {
                ....
                if (!$model->addPost())
                          Tools::jsonError($model->getErrors()); // или конкретные ошибки $model->getErrors('privacy');
                ...
          }
          

          KISS!
            +1
            Как было сказано выше, атрибут может быть не существующий, я в таких случаях error использую
          0
          1. Тут не нужен трейт, достаточно бихейвера.
          2. Используйте исключения, для удобства можно сделать базовый класс аля AccessDeniedException, и либо использовать его, либо дальше строить иерархию. Тогда не будет неоднозначностей — функция всегда возвращает bool, а в случае ошибки возвращает exception, который мы можем обработать и что-то сделать, или ничего не делать и позволить yii показать 403.*-ю ошибку. Если же речь идет о формах — то там тоже все уже предусмотрено.
            0
            С исключениями довольно не просто. Метод может не обязательно вернуть ошибку связанную только с доступом, причин для ошибки может быть очень много и писать под каждый исключение и их обработку довольно затруднительно, но соглашусь, что в теории это правильнее и стоит подумать о переходе на обработку исключений.
              +1
              Чем лучше бехайверы?
              phpStorm не умеет их распознавать, нет автодополнения. В нашем проекте мы отказались от использования этого механизма.
                +1
                бехайверы не лучше, но я предпочитаю в трейты выносить только какие-то обертки, которые не делают ничего больше чем проксируют вызовы к сторонним компонентам и скрывают простыню кода. У вас же в трейте хранится именно какая-никакая но логика.

                и еще — по поводу ваших изменений в миграциях, не проще ли разные окружения настраивать через конфиг? Да и по поводу ключей — у вас же должно быть все автоматизированно, зачем что-то делать вручную?
                  0
                  Да, конечно, окружение настроено через конфиг. Но как минимум в одном окружение есть 2 коннекта, для рабочей и для тестовой базы.
                  Постоянно набирать в консоли две команды с разными конфигами довольно нудное занятие, плюс если появится, например 3-й коннект, то всё еще более усложнится. А в нашем случае достаточно вызывать одну команду и она примениться ко всем коннектам.
                  В команде работают фронт эндщики и заставлять их следить за всеми настройками и правильно вызывать каждый раз миграцию, это, скажем, себе дороже.

            Only users with full accounts can post comments. Log in, please.