Задача стоит не такая уж и большая, но она возникает постоянно, и хочется сократить время, затрачиваемое на написание велосипеда ее решение. Так же хочется избавиться от повсеместного использования вызовов разных классов, методов, функций и так далее при каждой необходимости сгенерировать 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.
Комментариев нет:
Отправить комментарий