Pull to refresh

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

Doctrine ORM *
Я думаю многие со мной согласны, что 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.
Tags:
Hubs:
Total votes 29: ↑22 and ↓7 +15
Views 2.4K
Comments Comments 12