Я думаю многие со мной согласны, что Doctrine — один из самых мощных и удобных ORM для PHP, но с недавнего времени возможностей оного мне перестало хватать. Начнем с того что невозможно использовать ассоциации с условиями фильтрации, «волшебный» поиск с учетом перевода через I18n и много другого.
Всячески экпериментируя с возможностями Doctrine, я написал кучу нужных и ненужных расширений, которые и решил вывести «в мир». Тем самым и начинаю цикл статей, посвященных практическому написанию всяких упрощающих жизнь свистелок. В процессе я также постараюсь раскрыть методологию разработки, так что возможно наличие взаимоисключающих параграфов в течение статьи, но в итоге они разрешатся.
Начну с самого легкого — с расширения для мультиязычности Doctrine_Template_I18n. Оговорюсь сразу, текста будет много, как и много сумбурной технической информации
Пара слов относительно названий классов, которые я буду использовать в статье: у меня есть свой фреймворк в котором я и реализовывал рассматриваемые дополнения и имена классов попадают под мой нэймспэйс Of, а, касательно того что я рассматриваю — под Of_ExtDoctrine (т.е. Extended Doctrine). Соглашения по названиям я использую те же, что и в Doctrine (собственно как и в ZendFramework, бла-бла-бла… много их, почти стандарт)
Тут больше всего смущает невозможность прямого обращения к переводимым атрибутам, в случаях когда язык изначально был выбран или получен при запросе на сайт, например:
Каждый раз приходится писать стандартные конструкции вроде $record['Translation'][$lang]['title'] (кто не в курсе, доступ к записям возможен через array-notation, а также часто используется HYDRATION_ARRAY для увеличения быстродействия. см. полезную ссылку), что в свою очередь требует передачи значения выбраного языка в шаблон, что часто неудобно.
Добавим эту функциональность, а для этого придется написать свой шаблон для actAs, а также статичный метод для определения языка перевода по умолчанию.
Для начала создадим класс записи и окружение для дальнейшей работы
Как видно мы создали простейшую модель с 2мя переводимыми полями name и descritpion, которые будут доступны через аксессоры
Пришло время создать свой шаблон (пока пустой) и подключить его к нашей модели
Т.к. требуется реализовать интерфейс доступа к переводу для заранее заданного языка, то необходимо определить где это значение будет хранится. Определим методы для уcтановки и доступа к языку в шаблоне:
Также позволю себе вольность добавить опцию 'lang' для определения текущего языка прямо из модели. В принципе не нужно, но вдруг когда-нибудь понадобится :)
В принципе, все просто, если нет текущего языка — возвращаем язык по умолчанию. Преобразование (string) для геттеров поставил что бы всегда возвращало строку, т.к. было дело что вместо строки вкинул в язык экземпляр Zend_Locale, а парсер Translation этого не понял, но про это дальше.
Теперь следует добавить парочку методов для чтения/записи в определенные поля, которые мы будем использовать в дальнейшем:
Здесь тоже ничего сложного. При чтении аттрибута проверяем существует ли перевод для языка и возвращаем его, если нет то null. Это сделано для того что бы не создавать ненужных переводов, т.к. при доступе к несуществующей записи $translation[$lang] автоматически создается экземпляр новой. При записи эта проверка не нужна, т.к. подразумевается что мы потенциально можем создать новый перевод.
С языками разобрались, теперь приступим к реализации доступа к аттрибутам из записи. Целесообразней всего это делать через пару Accessor/Mutator для записей, следовательно нужны методы для доступа к переведенным аттрибутам с учетом языка, что в свою очередь тянет за собой необходимость генерации своих геттеров/сеттеров для каждого аттрибута. Решение очевидно — использовать __call() метод в шаблоне в котором парсить имя вызываемого метода и перенаправлять на уже созданные getTranslatedAttribute()/setTranslatedAttribute() в зависимости он задачи. Вообще-то говоря логичней это было бы делать через __get() и __set(), как это сделано в Doctrine_Record, но, увы, нельзя, т.к. трансляция через эти методы не работает с шаблонами actAs.
Как видно, у нас добавилась новая опция 'fields', такая же как и для шаблона I18n. Она определяет какие поля мы будем использовать для быстрого доступа. Изменим класс Product в соответствии с этой опцией.
Вот, собственно, и все, проверяем:
Это не все возможные тесты, но система работает. Конечно стоило оформить это через unit tests, но откровенно лень.
Есть некоторые ньюансы относительно конкретно этой реализации быстрого доступа к переводимым аттрибутам:
Вот такие дела и я надеюсь, что вам было более-менее понятно что к чему. В следующей части займусь модификацией данного движка доступа для более оптимального расходования ресурсов и расширения возможностей интеграции с различными представлениями результатов поиска (hydration modes), загрузки переводов через join во всех dql запросах автоматически, ознакомлю с практикой использования компоненты Doctrine_Record_Listener в контексте задачи и много другого.
В этой статье код был подсвечен при помощи Source Code Highlighter.
Всячески экпериментируя с возможностями 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.