Doctrine, расширяем возможности любимого ORM-фреймворка! Часть 1.а (I18n, быстрый доступ к переводимым атрибутам)

    Я думаю многие со мной согласны, что Doctrine — один из самых мощных и удобных ORM для PHP, но с недавнего времени возможностей оного мне перестало хватать. Начнем с того что невозможно использовать ассоциации с условиями фильтрации, «волшебный» поиск с учетом перевода через I18n и много другого.

    Всячески экпериментируя с возможностями Doctrine, я написал кучу нужных и ненужных расширений, которые и решил вывести «в мир». Тем самым и начинаю цикл статей, посвященных практическому написанию всяких упрощающих жизнь свистелок. В процессе я также постараюсь раскрыть методологию разработки, так что возможно наличие взаимоисключающих параграфов в течение статьи, но в итоге они разрешатся.

    Начну с самого легкого — с расширения для мультиязычности Doctrine_Template_I18n. Оговорюсь сразу, текста будет много, как и много сумбурной технической информации

    Пара слов относительно названий классов, которые я буду использовать в статье: у меня есть свой фреймворк в котором я и реализовывал рассматриваемые дополнения и имена классов попадают под мой нэймспэйс Of, а, касательно того что я рассматриваю — под Of_ExtDoctrine (т.е. Extended Doctrine). Соглашения по названиям я использую те же, что и в Doctrine (собственно как и в ZendFramework, бла-бла-бла… много их, почти стандарт)

    I18n плагин



    Тут больше всего смущает невозможность прямого обращения к переводимым атрибутам, в случаях когда язык изначально был выбран или получен при запросе на сайт, например:

    echo $record->my_translatable_attribute; // вывод
    $record->my_translatable_attribute = 'something'; // запись


    Каждый раз приходится писать стандартные конструкции вроде $record['Translation'][$lang]['title'] (кто не в курсе, доступ к записям возможен через array-notation, а также часто используется HYDRATION_ARRAY для увеличения быстродействия. см. полезную ссылку), что в свою очередь требует передачи значения выбраного языка в шаблон, что часто неудобно.

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

    Бастрый доступ к переводимым атрибутам



    Для начала создадим класс записи и окружение для дальнейшей работы

    class Product extends Doctrine_Record {
      
      public function setTableDefinition() {
        //....
        $this->hasColumn('name', 'string', 255);
        $this->hasColumn('description', 'string');
        //....
      }
      
      public function setUp() {
        
        $this->actAs('I18n', array(
          'fields'=>array('name', 'description')
        ));
        
      }
      
    }



    require_once 'Doctrine.php';
    spl_autoload_register(array('Doctrine','autoload'));
    $connection = Doctrine_Manager::connection('mysql://user:passw@host/dbname');



    Как видно мы создали простейшую модель с 2мя переводимыми полями name и descritpion, которые будут доступны через аксессоры

    $record->Translation[$language]->name;
    $record->Translation[$language]->description;




    Пришло время создать свой шаблон (пока пустой) и подключить его к нашей модели

    class Of_ExtDoctrine_I18n_Template extends Doctrine_Template {

      // пока пусто
      
    }

    class Product extends Doctrine_Record {
      
      public function setTableDefinition() {
        //....
        $this->hasColumn('name', 'string', 255);
        $this->hasColumn('description', 'string');
        //....
      }
      
      public function setUp() {
        
        $this->actAs('I18n', array(
          'fields'=>array('name', 'description')
        ));

        // добавляем наш шаблон к модели
        $this->actsAs(new Of_ExtDoctrine_I18n_Template());
        
      }
      
    }



    Т.к. требуется реализовать интерфейс доступа к переводу для заранее заданного языка, то необходимо определить где это значение будет хранится. Определим методы для уcтановки и доступа к языку в шаблоне:

    • setDefaultLanguage($language) — static — будет использоваться для установки языка по умолчанию в любом месте нашего кода. Метод статичный т.к. значение этого языка будет одинаково для всех экземпляров шаблона.
    • setLanguage($language) — not static — будет использоваться для конкретной установки языка для моделей по отдельности. Внимание! Т.к. шаблон инициализируется для всей модели один раз и потом используется для каждой записи одинаково, и, если хранить значение текущего языка в каком-либо свойстве класса шаблона, то это значение одно и то же во всех экземплярах модели
    • getDefaultLanguage() и getLanguage() — соответственно используются для получения значений языка, причем если конкретный язык для модели не задан — используется общий


    Также позволю себе вольность добавить опцию 'lang' для определения текущего языка прямо из модели. В принципе не нужно, но вдруг когда-нибудь понадобится :)

    class Of_ExtDoctrine_I18n_Template extends Doctrine_Template {

      protected $_options = array(
        'lang'=>null,
      );
      
      /**
       * Holds default language for all behaviors
       *
       * @var string
       */
      static protected $_defaultLanguage;
      
      /**
       * Holds language for current model behavior
       *
       * @var string
       */
      protected $_language;
      
      public function setUp() {
        
        if ($language = $this->getOption('lang')) $this->setLanguage();

      }
      
      /**
       * Returns default language for all behaviors
       *
       * @return string
       */
      static public function getDefaultLanguage() {
        return (string)self::$_defaultLanguage;  
      }
      
      /**
       * Sets default language for all behaviors
       *
       * @param string $language
       * @return void
       */
      static public function setDefaultLanguage($language) {
        self::$_defaultLanguage = $language;
      }
      
      /**
       * Returns current behavior language
       *
       * @return void
       */
      public function getLanguage() {
        if ($this->_language === null) return self::getDefaultLanguage();
        else return (string)$this->_language;
      }
      
      /**
       * Sets current behavior language
       *
       * @param $language
       * @return string
       */
      public function setLanguage($language) {
        $this->_language = $language;
      }
      
    }



    В принципе, все просто, если нет текущего языка — возвращаем язык по умолчанию. Преобразование (string) для геттеров поставил что бы всегда возвращало строку, т.к. было дело что вместо строки вкинул в язык экземпляр Zend_Locale, а парсер Translation этого не понял, но про это дальше.

    Теперь следует добавить парочку методов для чтения/записи в определенные поля, которые мы будем использовать в дальнейшем:

    class Of_ExtDoctrine_I18n_Template extends Doctrine_Template {

      //...

      /**
       * Returns translatable attribute
       *
       * @param string $field - name of attribute/field
       * @param string $language - custom language of translation
       * @return mixed|null
       */
      public function getTranslatableAttribute($field, $language = null) {
        if ($language === null) $language = $this->getLanguage();
        $translation = $this->getTranslationObject();
        if ($translation->contains($language)) {
          return $translation[$language][$field];
        } else return null;  
      }
      
      /**
       * Sets translatable attribute
       *
       * @param string $field - attribute/field name
       * @param mixed $value - value to set
       * @param string $language - custom language of translation
       * @return void
       */
      public function setTranslatableAttribute($field, $value, $language = null) {
        if ($language === null) $language = $this->getLanguage();
        $translation = $this->getTranslationObject();
        $translation[$language][$field] = $value;
      }

    }



    Здесь тоже ничего сложного. При чтении аттрибута проверяем существует ли перевод для языка и возвращаем его, если нет то null. Это сделано для того что бы не создавать ненужных переводов, т.к. при доступе к несуществующей записи $translation[$lang] автоматически создается экземпляр новой. При записи эта проверка не нужна, т.к. подразумевается что мы потенциально можем создать новый перевод.

    С языками разобрались, теперь приступим к реализации доступа к аттрибутам из записи. Целесообразней всего это делать через пару Accessor/Mutator для записей, следовательно нужны методы для доступа к переведенным аттрибутам с учетом языка, что в свою очередь тянет за собой необходимость генерации своих геттеров/сеттеров для каждого аттрибута. Решение очевидно — использовать __call() метод в шаблоне в котором парсить имя вызываемого метода и перенаправлять на уже созданные getTranslatedAttribute()/setTranslatedAttribute() в зависимости он задачи. Вообще-то говоря логичней это было бы делать через __get() и __set(), как это сделано в Doctrine_Record, но, увы, нельзя, т.к. трансляция через эти методы не работает с шаблонами actAs.

    class Of_ExtDoctrine_I18n_Template extends Doctrine_Template {

      protected $_options = array(
        'lang'=>null,
        'fields'=>null, // добавим новую опцию
      );

      public function setUp() {
        
        //...
        
        // определим пары accessor/mutator для модели
        $fields = $this->getOption('fields');
        if ($fields) {
          foreach ($fields as $field) {
            $this->getInvoker()->hasAccessorMutator($field, 'getTranslatableAttribute'.$field, 'setTranslatableAttribute'.$field);        
          }
        }
        
      }

      //...
      
      /**
       * Covers calls of functions [getTranslatableAttribute|setTranslatableAttribute][fieldname]
       *
       * @param string $method
       * @param array $arguments
       * @return mixed
       */
      public function __call($method, $arguments) {
        $methodAction = substr($method,0,24);
        if ($methodAction == 'getTranslatableAttribute') {
          return call_user_func_array(array($this, $methodAction), array(substr($method, 24)));
        } elseif($methodAction == 'setTranslatableAttribute') {
          return call_user_func_array(array($this, $methodAction), array(substr($method, 24),$arguments[0]));
        }
      }
      
    }



    Как видно, у нас добавилась новая опция 'fields', такая же как и для шаблона I18n. Она определяет какие поля мы будем использовать для быстрого доступа. Изменим класс Product в соответствии с этой опцией.

    class Product extends Doctrine_Record {
      
      public function setTableDefinition() {
        //....
        $this->hasColumn('name', 'string', 255);
        $this->hasColumn('description', 'string');
        //....
      }
      
      public function setUp() {
        
        $this->actAs('I18n', array(
          'fields'=>array('name', 'description')
        ));

        $this->actAs(new Of_ExtDoctrine_I18n_Template(array(
          'fields'=>array('name', 'description') // используем все доступные для перевода поля
        )));
        
      }
      
    }



    Вот, собственно, и все, проверяем:

    $record = new Product();

    $record->Translation['en']->name = 'notebook'; // зададим какой-то перевод вручную

    Of_ExtDoctrine_I18n_Template::setDefaultLanguage('en');

    echo $record->name; // выведет 'nodebook' - верно

    $record->name = 'nuclear weapon'; // зададим другое название через mutator

    echo $record->name; // выведет 'nuclear weapon' - работает!



    Это не все возможные тесты, но система работает. Конечно стоило оформить это через unit tests, но откровенно лень.

    Есть некоторые ньюансы относительно конкретно этой реализации быстрого доступа к переводимым аттрибутам:
    • т.к. используется __call() метод прямо в шаблоне, то в итоге все неизвестные методы в модели будут перенаправлятся в этот шаблон и если вдруг вы захотите добавить другой, с аналогичной системой, то обработчик не сработает.
    • нет интеграции с HYDRATE_ARRAY, быстрый доступ будет недоступен. В принципе эта проблема решена уже, и, забегая вперед, совершенно другой архитектурой доступа. Я расскажу об этом в следующей части.
    • достаточно медленный движок. Куча if, прыжки между методами и т.д.

    Вот такие дела и я надеюсь, что вам было более-менее понятно что к чему. В следующей части займусь модификацией данного движка доступа для более оптимального расходования ресурсов и расширения возможностей интеграции с различными представлениями результатов поиска (hydration modes), загрузки переводов через join во всех dql запросах автоматически, ознакомлю с практикой использования компоненты Doctrine_Record_Listener в контексте задачи и много другого.

    В этой статье код был подсвечен при помощи Source Code Highlighter.
    Поделиться публикацией

    Комментарии 12

      +1
      Хорошая статья. поправьте заголовок «Бастрый доступ к переводимым атрибутам».
        0
        спасибо. Добавил
          +1
          Перенесите в habrahabr.ru/blogs/doctrine/ ;)
            +3
            перенес
            0
            Не хочу умалять ваших заслуг, но в symfony такое уже реализовано и, на мой взгляд и вкус, прямее и прозрачнее. См. sfDoctrineRecord::construct()
            И да — обязательно пишите ещё :)
              0
              но так это симфони ) здесь же речь идет о Doctrine, которую нужно слегка допиливать
                0
                глянул на симфони, конечно. Тут главное не забыть, что переписывается конструктор, а это не наш метод. Идею с фильтрами я буду описывать во второй части статьи как альтернатива существующей системе, но с небольшими свистоперделками внутри, впрочем, достаточно походе на то что было сделано в симфони
                  0
                  переписывается конструктор, а это не наш метод
                  Нам хватает, хехе. Интересно, как вы обошлись без перекрытия конструктора.
                    0
                    так в том-то и дело что бы не создавать новые подклассы, а использовать только шаблоны, фильтры и листенеры. Практически все можно сделать через плагины
                      0
                      Да. Вот в следующем топике и посмотрим ;)
                –1
                Расскажите пожалуйста, что такое HYDRATION_ARRAY (с помощью гугла не удалось найти внятное объяснение)

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое