...

суббота, 11 января 2014 г.

Удобная генерация URL (ЧПУ). Laravel 4 + сторонние пакеты

Хотелось бы поделиться удобными инструментами для генерации URL и примерами их использования.

Задача стоит не такая уж и большая, но она возникает постоянно, и хочется сократить время, затрачиваемое на написание велосипеда ее решение. Так же хочется избавиться от повсеместного использования вызовов разных классов, методов, функций и так далее при каждой необходимости сгенерировать URL. Ах да, я использую Laravel и инстументы заточены под него.


Ссылки на инструменты


Этого нам вполне хватит.


Постановка задачи




Автоматическая генерация уникальных URL для записей в таблицу БД для доступа к ним по /resource/unique-resource-url вместо /resource/1.


Приступаем




Допустим, нам нужно разбить поиск на сайте по Странам и Городам, но так, чтобы пользователь легко ориентировался, какая область/город выбран при просмотре списка Продуктов сайта.

Начнем с того, что создадим новый проект:



composer create-project laravel/laravel habr_url --prefer-dist




Далее откываем composer.json в корне habr_url и вносим пакеты в require:

{
"name": "laravel/laravel",
"description": "The Laravel Framework.",
"keywords": ["framework", "laravel"],
"license": "MIT",
"require": {
"laravel/framework": "4.1.*",
"ivanlemeshev/laravel4-cyrillic-slug": "dev-master",
"cviebrock/eloquent-sluggable": "1.0.*",
"way/generators": "dev-master"
},
"autoload": {
"classmap": [
"app/commands",
"app/controllers",
"app/models",
"app/database/migrations",
"app/database/seeds",
"app/tests/TestCase.php"
]
},
"scripts": {
"post-install-cmd": [
"php artisan optimize"
],
"post-update-cmd": [
"php artisan clear-compiled",
"php artisan optimize"
],
"post-create-project-cmd": [
"php artisan key:generate"
]
},
"config": {
"preferred-install": "dist"
},
"minimum-stability": "dev"
}




"way/generators": "dev-master" добавим для быстрого прототипирования.

После выполняем комманду composer update в консоли, а после успешно установленных пакетов вносим изменения в app/config/app.php:



<?php

return array(
// ...
'providers' => array(
// ...
'Ivanlemeshev\Laravel4CyrillicSlug\SlugServiceProvider',
'Cviebrock\EloquentSluggable\SluggableServiceProvider',
'Way\Generators\GeneratorsServiceProvider',
),
// ...
'aliases' => array(
// ...
'Slug' => 'Ivanlemeshev\Laravel4CyrillicSlug\Facades\Slug',
'Sluggable' => 'Cviebrock\EloquentSluggable\Facades\Sluggable',
),
);
?>




Класс Slug даст нам возможность генерировать URL из киррилицы, так как стандартный класс Str умеет работать только с латиницей. О Sluggable я расскажу чуть позже.
Генерируем код


php artisan generate:scaffold create_countries_table --fields="name:string:unique, code:string[2]:unique"
php artisan generate:scaffold create_cities_table --fields="name:string, slug:string:unique, country_id:integer:unsigned"
php artisan generate:scaffold create_products_table --fields="name:string, slug:string:unique, price:integer, city_id:integer:unsigned"




Изменяем новые файлы, добавляя внешних ключей:

// файл app/database/migrations/ХХХХ_ХХ_ХХ_ХХХХХХ_create_cities_table.php
class CreateCitiesTable extends Migration {
// ...
public function up()
{
Schema::create('cities', function(Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('slug')->unique();
$table->integer('country_id')->unsigned()->index();
$table->foreign('country_id')->references('id')->on('countries')->onDelete('cascade');
$table->timestamps();
});
}
// ...
}



// файл app/database/migrations/ХХХХ_ХХ_ХХ_ХХХХХХ_create_products_table.php
class CreateProductsTable extends Migration {
// ...
public function up()
{
Schema::create('products', function(Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('slug')->unique();
$table->integer('price');
$table->integer('city_id')->unsigned()->index();
$table->foreign('city_id')->references('id')->on('cities')->onDelete('cascade');
$table->timestamps();
});
}
// ...
}




А так же добавим несколько Стран и Городов в БД через seeds. Открываем папку app/database/seeds и изменяем два файла:

// файл app/database/seeds/CountriesTableSeeder.php
class CountriesTableSeeder extends Seeder {

public function run()
{
$countries = array(
array('name' => 'Россия', 'code' => 'ru'),
array('name' => 'Украина', 'code' => 'ua')
);

// Uncomment the below to run the seeder
DB::table('countries')->insert($countries);
}

}



// файл app/database/seeds/CitiesTableSeeder.php
class CitiesTableSeeder extends Seeder {

public function run()
{
// Uncomment the below to wipe the table clean before populating
// DB::table('cities')->truncate();

$cities = array(
array('name' => 'Москва', 'slug' => Slug::make('Москва'), 'country_id' => 1),
array('name' => 'Санкт-Петербург', 'slug' => Slug::make('Санкт-Петербург'), 'country_id' => 1),
array('name' => 'Киев', 'slug' => Slug::make('Киев'), 'country_id' => 2),
);

// Uncomment the below to run the seeder
DB::table('cities')->insert($cities);
}

}




Тут используется Slug::make($input), который принимает $input как строку и генерирует из нее что-то на подобии moskva или sankt-peterburg.

Теперь изменяем настройки БД:



// файл app/config/database.php
return array(
// ...
'connections' => array(
// ...
'mysql' => array(
'driver' => 'mysql',
'host' => 'localhost',
'database' => 'habr_url',
'username' => 'habr_url',
'password' => 'habr_url',
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
),
),
// ...
);




И вносим схему и данные в БД.

php artisan migrate --seed




И вот что мы получили:



Добавим в модели связей и дополним правила для аттрибутов:



// файл app/models/Product.php
class Product extends Eloquent {
protected $guarded = array();

public static $rules = array(
'name' => 'required|alpha_num|between:2,255',
'slug' => 'required|alpha_num|between:2,255|unique:products,slug',
'price' => 'required|numeric|between:2,255',
'city_id' => 'required|exists:cities,id'
);

public function city()
{
return $this->belongsTo('City');
}
}



// файл app/models/City.php
class City extends Eloquent {
protected $guarded = array();

public static $rules = array(
'name' => 'required|alpha_num|between:2,255',
'slug' => 'required|alpha_num|between:2,255|unique:cities,slug',
'country_id' => 'required|exists:countries,id'
);

public function country()
{
return $this->belongsTo('Country');
}

public function products()
{
return $this->hasMany('Product');
}
}




// файл app/models/Country.php
class Country extends Eloquent {
protected $guarded = array();

public static $rules = array(
'name' => 'required|alpha_num|between:2,255|unique:countries,name',
'code' => 'required|alpha|size:2|unique:countries,code'
);

public function cities()
{
return $this->hasMany('City');
}

public function products()
{
return $this->hasManyThrough('Product', 'City');
}
}




Перепишем методы store в CitiesController и ProductsController.

// файл app/models/CitiesController.php
class CitiesController extends BaseController {
// ...
public function store()
{
$input = Input::all();
$input['slug'] = Slug::make(Input::get('name', '')); // !добавлено
$validation = Validator::make($input, City::$rules);

if ($validation->passes())
{
$this->product->create($input);

return Redirect::route('products.index');
}

return Redirect::route('products.create')
->withInput()
->withErrors($validation)
->with('message', 'There were validation errors.');
}
// ...
}



// файл app/models/ProductsController.php
class ProductsController extends BaseController {
// ...
public function store()
{
$input = Input::all();
$input['slug'] = Slug::make(Input::get('name', '')); // !добавлено
$validation = Validator::make($input, Product::$rules);

if ($validation->passes())
{
$this->product->create($input);

return Redirect::route('products.index');
}

return Redirect::route('products.create')
->withInput()
->withErrors($validation)
->with('message', 'There were validation errors.');
}
// ...
}




И уберем из app/views/cities/create.blade.php, app/views/cities/edit.blade.php, app/views/products/create.blade.php, app/views/products/edit.blade.php соответствующие елементы формы.

Отлично, URL генерируются, но что будет в случает с их дублированием? Возникнет ошибка. А чтобы этого избежать — при совпадении slug нам прийдется добавить префикс, а если префикс ужде есть — то инкрементировать его. Работы много, а элегантности нет. Чтобы избежать этих телодвижений воспользуемся пакетом Eloquent Sluggable.


Первым делом скинем себе в проект конфигурацию для Eloquent Sluggable:



php artisan config:publish cviebrock/eloquent-sluggable




В конфигурационном файле, который находится тут app/config/cviebrock/eloquent-sluggable/config.php изменим опцию 'method' => null на 'method' => array('Slug', 'make'). Таким образом, задача перевода из киррилических символов в транслит и создания URL возложится на класс Slug (вместо стандартного Str, который не умеет работать с киррилицей) и его метод make.

Чем хорош этот пакет? Он работает по такому принцыпу: ожидает, события eloquent.saving*, который отвечает за сохранение записи в БД, и записывает в поле, которое указано в настройках Модели сгенерированный slug. Пример конфигурации:



// файл app/models/City.php
class City extends Eloquent {
protected $guarded = array();

public static $rules = array(
'name' => 'required|alpha_num|between:2,255',
'country_id' => 'required|exists:countries,id'
);

// Настройка генерации
public static $sluggable = array(
'build_from' => 'name',
'save_to' => 'slug',
);

public function country()
{
return $this->belongsTo('Country');
}

public function products()
{
return $this->hasMany('Product');
}
}




При совпадении с уже существующим slug, в новый будет добавлен префикс -1, -2, и так далее. К тому же, мы можем избавиться от не нужного правила для slug и в методе CitiesController@store убрать строчку $input['slug'] = Slug::make(Input::get('name', ''));.

То же сделаем и для Product:



// файл app/models/Product.php
class Product extends Eloquent {
protected $guarded = array();

public static $rules = array(
'name' => 'required|alpha_num|between:2,255',
'price' => 'required|numeric|between:2,255',
'city_id' => 'required|exists:cities,id'
);

public static $sluggable = array(
'build_from' => 'name',
'save_to' => 'slug',
);

public function city()
{
return $this->belongsTo('City');
}
}




Еще более интересную вещь мы можем сделать с этим slug, если перепишем $sluggable в Модели City таким образом:

// файл app/models/City.php
class City extends Eloquent {
protected $guarded = array();

public static $rules = array(
'name' => 'required|alpha_num|between:2,255',
'slug' => 'required|alpha_num|between:2,255|unique:cities,slug',
'country_id' => 'required|exists:countries,id'
);

public static $sluggable = array(
'build_from' => 'name_with_country_code',
'save_to' => 'slug',
);

public function country()
{
return $this->belongsTo('Country');
}

public function products()
{
return $this->hasMany('Product');
}

public function getNameWithCountryCodeAttribute() {
return $this->country->code . ' ' . $this->name;
}
}




Да, мы можем выбрать не существующее поле из Объекта, и добавить его как хелпер.

Немного изменив CitiesTableSeeder добъемся желаемого результата:



// файл app/database/seeds/CitiesTableSeeder.php
class CitiesTableSeeder extends Seeder {

public function run()
{
// Uncomment the below to wipe the table clean before populating
// DB::table('cities')->truncate();

$cities = array(
array('name' => 'Москва', 'country_id' => 1),
array('name' => 'Санкт-Петербург', 'country_id' => 1),
array('name' => 'Киев', 'country_id' => 2),
);

// Uncomment the below to run the seeder
foreach ($cities as $city) {
City::create($city);
}
}

}




Теперь откатим миграции и зальем их по новой вместе с данными:

php artisan migrate:refresh --seed



Добавим немного маршрутов:



// файл app/routes.php
// ...
Route::get('country/{code}', array('as' => 'country', function($code)
{
$country = Country::where('code', '=', $code)->firstOrFail();

return View::make('products', array('products' => $country->products));
}));

Route::get('city/{slug}', array('as' => 'city', function($slug)
{
$city = City::where('slug', '=', $slug)->firstOrFail();

return View::make('products', array('products' => $city->products));
}));

Route::get('product/{slug}', array('as' => 'product', function($slug)
{
$product = Product::where('slug', '=', $slug)->firstOrFail();

return View::make('product', compact('product'));
}));




И добавим несколько шаблонов:

<!-- файл app/views/nav.blade.php -->
<ul class="nav nav-pills">
@foreach(Country::all() as $country)
<li><a href="{{{ route('country', $country->code) }}}">{{{ $country->name }}}</a>
@endforeach
</ul>



<!-- файл app/views/products.blade.php -->
@extends('layouts.scaffold')

@section('main')

@include('nav')

<h1>Products</h1>

@if ($products->count())
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>Name</th>
<th>Price</th>
<th>City</th>
</tr>
</thead>

<tbody>
@foreach ($products as $product)
<tr>
<td><a href="{{{ route('product', $product->slug)}}}">{{{ $product->name }}}</a></td>
<td>{{{ $product->price }}}</td>
<td><a href="{{{ route('city', $product->city->slug) }}}">{{{ $product->city->name }}}</a></td>
</tr>
@endforeach
</tbody>
</table>
@else
There are no products
@endif

@stop



<!-- файл app/views/product.blade.php -->
@extends('layouts.scaffold')

@section('main')

@include('nav')

<h1>Product</h1>

<table class="table table-striped table-bordered">
<thead>
<tr>
<th>Name</th>
<th>Price</th>
<th>City</th>
</tr>
</thead>

<tbody>
<tr>
<td>{{{ $product->name }}}</td>
<td>{{{ $product->price }}}</td>
<td>{{{ $product->city->name }}}</td>
</tr>
</tbody>
</table>

@stop




На этом все.

Демо и Git


Ошибки, как обычно в личку. Предложения и критику — в комментарии. Спасибо за внимание.


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at fivefilters.org/content-only/faq.php#publishers.


Комментариев нет:

Отправить комментарий