Создаем поведение (behaviour) для Yii2

от автора

Часто, а на самом деле практически всегда, при создании сайта необходимо, чтобы страницы сайта открывались не по id сущности в базе, а по текстовому идентификатору, назовем его slug.

post/view/1 => post/view/testovaya-novost

(из url’а стоило бы убрать и view, но урок не о том)

Самым примитивным путем можно создать в таблице post поле slug, в модели Post соответственно появляется новый атрибут, в представление (view) добавляем новый input, в который ручками вбиваем slug.

 <?php  use yii\helpers\Html; use yii\widgets\ActiveForm;  /**  * @var yii\web\View           $this  * @var common\models\Post     $model  * @var yii\widgets\ActiveForm $form  */ ?>  <div class="post-form">  	<?php $form = ActiveForm::begin(); ?>  	<?= $form->field( $model, 'name' )->textInput( [ 'maxlength' => 255 ] ) ?> 	<?= $form->field( $model, 'slug' )->textInput( [ 'maxlength' => 255 ] ) ?> 	<?= $form->field( $model, 'content' )->textarea( [ 'rows' => 6 ] ) ?>  	<div class="form-group"> 		<?= Html::submitButton( $model->isNewRecord ? Yii::t( 'app', 'Create' ) : Yii::t( 'app', 'Update' ), [ 'class' => $model->isNewRecord ? 'btn btn-success' : 'btn btn-primary' ] ) ?> 	</div>  	<?php ActiveForm::end(); ?>  </div>

Но ручками это делать не всегда интересно (да кого я обманываю, вообще неинтересно), поэтому мы дописываем в модель методы, которые при сохранении модели генерируют slug автоматически из name, проверяют его уникальность в таблице (ведь мы по slug’у будем извлекать post из базы, а, значит, slug не может быть не уникальным), ну и, возможно, транслитерируем его (тестовая-новость => testovaya-novost) — это тоже может быть полезно.
Что ж, пишем, привязываемся к событию, тестируем — все работает. И тут при разработке сайта мы сталкиваемся с тем, что slug’и еще нужны в модели Page. А еще в каталоге для товара — пусть это будет модель Item. Можно пойти по пути наименьшего сопротивления — копипаста. Но…

В Yii существует такая вещь как поведения (behaviours) — функционал, позволяющий использовать одни и те же функции в различных моделях. Итак, напишем поведение для slug’ификации.

В нашей модели Post (она же \commoin\models\Post на всякий случай) подключаем еще не созданное поведение:

public function behaviors() { 	return [  		'slug' => [ 			'class' => 'common\behaviors\Slug', 			'in_attribute' => 'name', 			'out_attribute' => 'slug', 			'translit' => true 		] 	]; }

Создали функцию behaviours, необходимую для подключения, прописали класс, в котором будем находиться поведение и передали в этот класс три атрибута:
1. in_attribute — атрибут модели, из которого будет генерироваться slug (в разных моделях он может отличаться, например name или title)
2. out_attribute — это атрибут соответственно slug’а (slug или alias)
3. translit — тут все понятно

При создании поведения был еще четвертый атрибут — unique, но потом я исключил этот функционал, т.к. очень редко нужно, чтобы slug был не уникальным.

Упомяну, что я использую структура приложения yii2-app-advanced, то есть у меня есть папки backend и frontend, в которых лежат контроллеры и вьюшки, и папка common, в которой общие модели и поведения.

Создаем common/behaviours/Slug.php:

<?php  namespace common\behaviors;  use yii; use yii\base\Behavior; use yii\db\ActiveRecord;  class Slug extends Behavior { 	public $in_attribute = 'name'; 	public $out_attribute = 'slug'; 	public $translit = true;  	public function events() 	{ 		return [ 			ActiveRecord::EVENT_BEFORE_VALIDATE => 'getSlug' 		]; 	}	 }

Класс наследуем от yii\base\Behavior, прописываем три атрибута с начальными установками, создаем метод events, который привяжет поведение к какому-то событию при сохранении модели. Так как slug обычно необходим и может быть прописан в rules как required, то привяжем генерацию slug’а до валидации.

ActiveRecord::EVENT_BEFORE_VALIDATE => 'getSlug'

Теперь создадим метод getSlug:

public function getSlug( $event ) { 	if ( empty( $this->owner->{$this->out_attribute} ) ) { 		$this->owner->{$this->out_attribute} = $this->generateSlug( $this->owner->{$this->in_attribute} ); 	} else { 		$this->owner->{$this->out_attribute} = $this->generateSlug( $this->owner->{$this->out_attribute} ); 	} }

Сам объект модели передается в поведение как $this->owner. Таким образом slug нам будет доступен через обращение к $this->owner->slug или в нашем случае $this->owner->{$this->out_attribute}, так как название атрибута slug’а передается в переменную $this->out_attribute.
Делаем проверку пуст ли slug при сохранении и, если пуст, то генерируем его из name (заголовок записи). Если же не пуст, то обрабатываем поступивший slug.

private function generateSlug( $slug ) { 	$slug = $this->slugify( $slug ); 	if ( $this->checkUniqueSlug( $slug ) ) { 		return $slug; 	} else { 		for ( $suffix = 2; !$this->checkUniqueSlug( $new_slug = $slug . '-' . $suffix ); $suffix++ ) {} 		return $new_slug; 	} }

В первой строке метода мы функцией slugify убираем ненужные символы и переводим в транслит, если нужно. Давайте сразу ее и рассмотрим:

private function slugify( $slug ) { 	if ( $this->translit ) { 		return Inflector::slug( TransliteratorHelper::process( $slug ), '-', true ); 	} else { 		return $this->slug( $slug, '-', true ); 	} }

Что такое транслит? Это передача национальных символов их аналогами в стандартной латинице. Большинство сниппетов, найденных в зарубежном интернете, очищают текст только от умляутов, крышечек и прочих символов (‘À’ => ‘A’, ‘Á’ => ‘A’, ‘Â’ => ‘A’, ‘Ã’ => ‘A’,), то есть из «грязной» латиницы делают «чистую». Это делает и стандартный хелпер yii2 yii\helpers\Inflector::slug (кстати, за время создания поведения этот метод был несовместимо изменен — разработка над yii2 пока продолжается). В рунете же соответственно добавляют еще замену кириллицы на латиницу. Но хотелось бы создать максимально гибкую транслитерацию. В последней версии yii\helpers\Inflector::slug используется php-расширение intl, в том числе транслитерирующее даже китайские иероглифы, но, как я понимаю, по умолчанию оно не включено (php 5.5.6). Но у замечательного разработчика 2amigos, знакомого всем интересующимся yii, было найдено дополнение transliterator-helper (оно в свою очередь использует идеи из drupal’а, насколько я помню). Представляет оно некоторое количество php-файлов, в которых описаны большинство символов и их замена в латинице.
Добавляем в composer.json зависимость "2amigos/transliterator-helper": "2.0.*", обновляемся и теперь нам доступен dosamigos\helpers\TransliteratorHelper:

return Inflector::slug( TransliteratorHelper::process( $slug ), '-', true );

Транслитерируем, очищаем от неалфавитных символов, пробелы заменяем на черточку "-".

Если же транслит нам не нужен, то:

return $this->slug( $slug, '-', true );

Метод slug (урезанная версия yii\helpers\Inflector::slug без транлитерации):

private function slug( $string, $replacement = '-', $lowercase = true ) { 	$string = preg_replace( '/[^\p{L}\p{Nd}]+/u', $replacement, $string ); 	$string = trim( $string, $replacement ); 	return $lowercase ? strtolower( $string ) : $string; }

Вернемся к generateSlug:

private function generateSlug( $slug ) { 	$slug = $this->slugify( $slug ); 	if ( $this->checkUniqueSlug( $slug ) ) { 		return $slug; 	} else { 		for ( $suffix = 2; !$this->checkUniqueSlug( $new_slug = $slug . '-' . $suffix ); $suffix++ ) {} 		return $new_slug; 	} }

Второй строчкой мы проверяем slug на уникальность в базе. Напомню, что неуникальный slug нам не нужен, так как мы будем по нему извлекать данные из таблицы.

private function checkUniqueSlug( $slug ) { 	$pk = $this->owner->primaryKey(); 	$pk = $pk[0];  	$condition = $this->out_attribute . ' = :out_attribute'; 	$params = [ ':out_attribute' => $slug ]; 	if ( !$this->owner->isNewRecord ) { 		$condition .= ' and ' . $pk . ' != :pk'; 		$params[':pk'] = $this->owner->{$pk}; 	}  	return !$this->owner->find() 		->where( $condition, $params ) 		->one(); }

Первичный ключ у нас теоритически может быть и не id, поэтому находим его функцией primaryKey(). Дальше делаем запрос в таблицу на предмет существования такого slug’а. Если же запись не новая, а мы делаем update (!$this->owner->isNewRecord), то slug уже может существовать и делаем исключение данного id:

$condition .= ' and ' . $pk . ' != :pk';

Функция возвращает true, если slug уникален, и false, если нет. Дальше:

if ( $this->checkUniqueSlug( $slug ) ) { 	return $slug; } else { 	for ( $suffix = 2; !$this->checkUniqueSlug( $new_slug = $slug . '-' . $suffix ); $suffix++ ) {} 	return $new_slug; }

Если slug уникален, мы его возвращаем, присваиваем атрибуту модели и сохраняем модель в базу. Если же не уникален, то добавим цифровой суффикс testovaya-novost-2

for ( $suffix = 2; !$this->checkUniqueSlug( $new_slug = $slug . '-' . $suffix ); $suffix++ ) {}

Методом перебора находим первый свободный суффикс и добавляем его к slug’у. Решение подсмотрено в WordPress, но мне не нравится, что для каждого суффикса мы делаем по запросу, соответственно при занятых testovaya-novost, testovaya-novost-2, testovaya-novost-3, testovaya-novost-4, testovaya-novost-5 нам нужно будет сделать 6 запросов для проверки уникальности. Если кто может предложить лучшее решение, буду благодарен.

Итак, slug сгенирован, передан в модель, сохранен в базу, а получившееся поведение мы используем в других моделях.

Полный текст поведения:

<?php  namespace common\behaviors;  use dosamigos\helpers\TransliteratorHelper; use yii; use yii\base\Behavior; use yii\db\ActiveRecord; use yii\helpers\Inflector;  class Slug extends Behavior { 	public $in_attribute = 'name'; 	public $out_attribute = 'slug'; 	public $translit = true;  	public function events() 	{ 		return [ 			ActiveRecord::EVENT_BEFORE_VALIDATE => 'getSlug' 		]; 	}  	public function getSlug( $event ) 	{ 		if ( empty( $this->owner->{$this->out_attribute} ) ) { 			$this->owner->{$this->out_attribute} = $this->generateSlug( $this->owner->{$this->in_attribute} ); 		} else { 			$this->owner->{$this->out_attribute} = $this->generateSlug( $this->owner->{$this->out_attribute} ); 		} 	}  	private function generateSlug( $slug ) 	{ 		$slug = $this->slugify( $slug ); 		if ( $this->checkUniqueSlug( $slug ) ) { 			return $slug; 		} else { 			for ( $suffix = 2; !$this->checkUniqueSlug( $new_slug = $slug . '-' . $suffix ); $suffix++ ) {} 			return $new_slug; 		} 	}  	private function slugify( $slug ) 	{ 		if ( $this->translit ) { 			return Inflector::slug( TransliteratorHelper::process( $slug ), '-', true ); 		} else { 			return $this->slug( $slug, '-', true ); 		} 	}  	private function slug( $string, $replacement = '-', $lowercase = true ) 	{ 		$string = preg_replace( '/[^\p{L}\p{Nd}]+/u', $replacement, $string ); 		$string = trim( $string, $replacement ); 		return $lowercase ? strtolower( $string ) : $string; 	}  	private function checkUniqueSlug( $slug ) 	{ 		$pk = $this->owner->primaryKey(); 		$pk = $pk[0];  		$condition = $this->out_attribute . ' = :out_attribute'; 		$params = [ ':out_attribute' => $slug ]; 		if ( !$this->owner->isNewRecord ) { 			$condition .= ' and ' . $pk . ' != :pk'; 			$params[':pk'] = $this->owner->{$pk}; 		}  		return !$this->owner->find() 			->where( $condition, $params ) 			->one(); 	} }

Код подключения поведения дан выше. А пример транслитерации:

тест Тест й test 我爱 中文 Ψ ᾉ Ǽ ß Ц => test-test-y-test-wo-ai-zhong-wen-ps-a-ae-ss-c

Ссылки:

ссылка на оригинал статьи http://habrahabr.ru/post/208328/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *