Пользователь
0,0
рейтинг
7 января 2014 в 15:33

Разработка → Создаем поведение (behavior) для Yii2 из песочницы

Yii*, PHP*
Часто, а на самом деле практически всегда, при создании сайта необходимо, чтобы страницы сайта открывались не по 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 существует такая вещь как поведения (behaviors) — функционал, позволяющий использовать одни и те же функции в различных моделях. Итак, напишем поведение для slug'ификации.

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

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


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

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

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

Создаем common/behaviors/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


Ссылки:
zelenin @zelenin
карма
–1,0
рейтинг 0,0
Пользователь
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (28)

  • +2
    если честно не понимаю в чем соль статьи, вы по сути пересказали документацию.

    p.s. не увидел принципиальных отличий с первой веткой.
    • 0
      Немножко замечаний по коду
              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} );
              }
      

      можно упростить до
              $attr = empty( $this->owner->{$this->out_attribute}) ? 
                       $this->in_attribute : $this->out_attribute;
              
              $this->owner->{$this->out_attribute} = $this->generateSlug( $this->owner->{$attr} );
      

      Так вот эта сложная мешанина из атрибутов хотя бы дублироваться не будет.
      и уж никак это не getSlug, ибо метод ничего не возвращает. Это скорее processSlug.
      Да и вообще сомнительная логика с разными атрибутами…
      • 0
        упрощение принято. А в чем сомнительность логики?
    • –1
      Новичкам будет полезно однозначно.
      Чем больше разжеванных примеров, тем лучше.
      • 0
        Лучше бы в качестве примера реализовали что-то более полезное (meta-информация для постов, кеширование и очистка кеша при изменении данных, сериализация массивов/объектов, отправка имейлов по сохранению/изменению… Ибо из статьи они не поймут главного — зачем вообще нужны бихейверы. Этот пример плохо иллюстрирует то, что они создаются для уменьшения дублирования кода.
        • 0
          По-моему, все, что вы предложили в качестве полезного, не больше проиллюстрирует, а может и меньше (отправка имейлов? очистка кэша?).
          Из статьи понятно, что мы можем подключить это поведение теперь к любой модели прописыванием 4 строк, не дублируя больше никакой код.
          Тем не менее, ваше мнение принял.
          • 0
            вы забыли про: добавить поле, добавить его в правила валидации… причем это правило будет дублировать логику в вашем поведении (проверка на уникальность).
            • 0
              Это нужно делать в не зависимости от подключения поведения.
        • –3
          Да успокойтесь вы уже. Статья полезная. Пишу на Yii от случая к случаю, если бы не узнал, что там есть такая вещь как behaviours из этой статьи, то скорее всего лепил бы костыль свой в следующий раз, потому что досконально изучать фреймворк мне нет смысла.
  • +1
    А почему бы не реализовать тот же функционал в виде trait, раз уж задача избавиться от дублирования кода?
    • 0
      А в чем преимущество трейтов?
      • 0
        Тут у меня скорее вопрос: в чем преимущество behavior?
        Для себя в trait вижу только минусы (общая статичность, нетестируемость отдельно от класса), но в контексте задачи статьи не смог понять, чем предложенное решение лучше. Возможно это из-за ограниченности моего опыта разработки на Yii.
        • –1
          Бихейверы в yii появились с первых версий, и они вписываются в концепцию фреймворка (все на магии). Православно было бы использовать композицию и декораторы, но это существенно увеличивает количество кода да и смысла для подобной задачи нету. Собственно я считаю что и бихейвер для подобного делать не нужно.
          • –1
            Ближе к делу. Комментарии не только для того, чтобы критиковать статью, но и предлагать лучшие решения. Не бихейвиор, тогда что?
          • 0
            Вот более конкретное сравнение github.com/drcypher/php-trait-vs-yii-behavior
        • 0
          В данном случае трейт и бихейвиор будут выполнять одно и то же. Но в общем… Хотя зачем пересказывать, дискуссия на эту тему уже была github.com/yiisoft/yii2/issues/1053
      • НЛО прилетело и опубликовало эту надпись здесь
  • 0
    Спасибо, но я бы проверку на уникальность обернул бы в цикл без ифов.
    • 0
      пример можно
      • 0
        $slug=$base=translit($model->{$this->slugAttribute});
        $suffix=0;
        while(!$this->checkUniqueSlug( $slug )) $slug=$base.++$suffix;
        return $slug;
        

        Примерно так, писал на коленке.
        • 0
          разве в таком варианте слаг будет типа slug, slug-2, slug-3?
          • 0
            Ну если добавить к конкатенации тире, то да!
            • –1
              Я имею в виду, что будет slug-1, а он и не нужен, так как префикс должен нести смысловую нагрузку — количество одинаковых слагов
  • 0
    >Если кто может предложить лучшее решение, буду благодарен.
    Дописать в конец primaryKey.
    Где-то видел универсальное решение, /slug-id/, и поиск entity прямо по ид.
    • 0
      то есть testovaya-novost-969 (при id = 969)? Это конечно вариант, и он используется в некоторых решениях (в Joomla по-моему), но не такой красивый. Хотя, сравнивая testovaya-novost-2 и testovaya-novost-969, большой разницы не вижу.
      С другой стороны для вашего варианта все равно придется делать два запроса — для testovaya-novost, а потом для testovaya-novost-969. А вероятность, что будет более двух записей с одним заголовком, достаточно мала для большинства проектов, чтобы экономить на спичках.
      • 0
        Лучше всего в таких случаях добавлять id в начаало или конец slugа, прописать правило разбора в routing и делать выборку из базы по id
        Это позволит в дальнейшем при изменении sluga (часто сеошники любят их менять) сделать в контроллере автоматическую коррекцию url с 301 редиректом на новые ссылки
  • 0
    конечно поведение уже написано и находится в пакете yii2, но все же отпишусь:
    1. автору явно надо почитать про оптимизацию, а конкретно в данном случае:
    return !$this->owner->find()
    ->where( $condition, $params )
    ->one();


    лучше юзать count()

    2. если вы в owner модели перекрыли метод find, к примеру так
    public static function find()
    {
        return parent::find()->andWhere(['status' => 'public']);
    }
    

    то расширение удалит условие установленное в перекрытом методе, что делает потенциально опасным применение данного поведения. Решение заменить where на andWhere
    • 0
      1. exist()

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