Для этого урока мы собираемся создать одностраничное приложение, используя Laravel 4 и Backbone.js . Обе платформы позволяют очень просто использовать другой движок шаблонов, отличный от их стандартного, поэтому мы собираемся использовать Mustache, который является общим для обоих. Используя один и тот же язык шаблонов на обеих сторонах нашего приложения, мы сможем делиться своими мнениями между ними, избавляя нас от необходимости повторять нашу работу несколько раз.
Наше приложение Backbone будет работать на основе JSON API Laravel 4, который мы разработаем вместе. Laravel 4 поставляется с некоторыми новыми функциями, которые делают разработку этого API очень простой. По пути я покажу вам несколько хитростей, чтобы вы были более организованными.
Все наши зависимости будут управляться менеджерами пакетов, не будет никакой ручной загрузки или обновления библиотек для этого приложения! Кроме того, я покажу вам, как использовать немного дополнительной мощности от некоторых наших зависимостей.
Для этого проекта мы будем использовать:
- Laravel 4 : отличный PHP-фреймворк.
- Mustache.php : PHP движок рендеринга для Усов .
- Mustache.js : движок рендеринга JavaScript для усов .
- Генераторы Джеффри Вея для Laravel 4 : Мы можем улучшить наш рабочий процесс, сгенерировав для нас некоторый шаблонный код, используя эти генераторы.
- Twitter Bootstrap : интерфейсная библиотека, помогающая нам в оформлении.
- PHPUnit : набор для тестирования PHP.
- Насмешка : используется для насмешки объектов PHP во время тестирования.
- Backbone.js : Javascript MVC для нашего одностраничного приложения.
- Underscore.js : зависимость от Backbone и отличный маленький набор функций.
Для завершения этого урока вам понадобятся следующие элементы:
- Composer : Вы можете скачать его с домашней страницы, я рекомендую инструкции по глобальной установке, расположенные здесь .
- Узел + NPM : установщик на домашней странице установит оба элемента.
-
LESS Compiler : если вы работаете на Mac, я рекомендую CodeKit. Однако, независимо от вашей операционной системы, или если вы не хотите платить за CodeKit, вы можете просто установить LESS Compiler для Node.js, введя в командной строке
npm install -g less
.
Часть 1: Базовая архитектура
Прежде всего, нам нужно настроить приложение, прежде чем мы сможем добавить к нему нашу бизнес-логику. Мы выполним базовую настройку Laravel 4 и установим все наши зависимости с помощью наших менеджеров пакетов.
Гит
Давайте начнем с создания репозитория git для работы. Для справки, весь этот репозиторий будет общедоступным по адресу https://github.com/conarwelsh/nettuts-laravel4-and-backbone .
1
2
|
mkdir project && cd project
git init
|
Установка Laravel 4
Laravel 4 использует Composer для установки всех его зависимостей, но сначала нам понадобится структура приложения для установки. Ветвь «разработка» в репозитории Laravel Github является домом для этой структуры приложения. Однако на момент написания этой статьи Laravel 4 еще находился в бета-версии, поэтому я должен был быть готов к тому, что эта структура может измениться в любое время. Добавив Laravel в качестве удаленного репозитория, мы можем вносить эти изменения всякий раз, когда это необходимо. На самом деле, пока что-то находится в бета-режиме, рекомендуется запускать эти команды после каждого composer update
. Тем не менее, Laravel 4 — самая новая и стабильная версия.
1
2
3
4
|
git remote add laravel https://github.com/laravel/laravel
git fetch laravel
git merge laravel/develop
git add .
|
Итак, у нас есть структура приложения, но все файлы библиотеки, которые нужны Laravel, еще не установлены. Вы заметите, что в корне нашего приложения есть файл с именем composer.json
. Это файл, который будет отслеживать все зависимости, которые требуются нашему приложению Laravel. Прежде чем мы скажем Composer загрузить и установить их, давайте сначала добавим еще несколько зависимостей, которые нам понадобятся. Мы будем добавлять:
- Генераторы Джеффри Уэя : некоторые очень полезные команды, которые значительно улучшают наш рабочий процесс, автоматически генерируя для нас заглушки файлов.
- Laravel 4 Mustache : Это позволит нам беспрепятственно использовать Mustache.php в нашем проекте Laravel, так же, как и Blade.
- Twitter Bootstrap : мы будем использовать файлы LESS из этого проекта, чтобы ускорить нашу разработку интерфейса.
- PHPUnit : мы будем делать некоторые TDD для нашего JSON API, PHPUnit будет нашим механизмом тестирования.
- Насмешка . Насмешка поможет нам «насмехаться» над объектами во время нашего тестирования.
PHPUnit и Mockery требуются только в нашей среде разработки, поэтому мы укажем это в нашем файле composer.json.
composer.json
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
{
«require»: {
«laravel/framework»: «4.0.*»,
«way/generators»: «dev-master»,
«twitter/bootstrap»: «dev-master»,
«conarwelsh/mustache-l4»: «dev-master»
},
«require-dev»: {
«phpunit/phpunit»: «3.7.*»,
«mockery/mockery»: «0.7.*»
},
«autoload»: {
«classmap»: [
«app/commands»,
«app/controllers»,
«app/models»,
«app/database/migrations»,
«app/database/seeds»,
«app/tests/TestCase.php»
]
},
«scripts»: {
«post-update-cmd»: «php artisan optimize»
},
«minimum-stability»: «dev»
}
|
Теперь нам просто нужно сказать Composer, чтобы мы выполняли всю работу с ногами! Ниже, обратите внимание на ключ --dev
, мы говорим composer, что мы находимся в нашей среде разработки и что он также должен установить все наши зависимости, перечисленные в "require-dev"
.
1
|
composer install —dev
|
После того как установка завершится, нам нужно будет сообщить Laravel о некоторых наших зависимостях. Laravel использует «поставщиков услуг» для этой цели. Эти поставщики услуг в основном просто сообщают Laravel, как их код будет взаимодействовать с приложением и запускать все необходимые процедуры установки. Откройте app/config/app.php
и добавьте следующие два элемента в массив « providers
». Не все пакеты требуют этого, только те, которые улучшат или изменят функциональность Laravel.
приложение / Config / app.php
1
2
3
4
5
6
|
…
‘Way\Generators\GeneratorsServiceProvider’,
‘Conarwelsh\MustacheL4\MustacheL4ServiceProvider’,
…
|
Наконец, нам просто нужно сделать некоторые общие настройки приложения, чтобы завершить установку Laravel. Давайте откроем bootstrap/start.php
и bootstrap/start.php
Laravel имя нашего компьютера, чтобы он мог определить, в какой среде он находится.
самозагрузки / start.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
|
/*
|—————————————————————————
|
|—————————————————————————
|
|
|
|
|
*/
$env = $app->detectEnvironment(array(
‘local’ => array(‘your-machine-name’),
));
|
Замените «your-machine-name» на имя хоста для вашей машины. Если вы не уверены, какое именно у вас имя компьютера, вы можете просто ввести hostname
в командной строке (на Mac или Linux), независимо от того, что он выводит, это значение, которое принадлежит этому параметру.
Мы хотим, чтобы наши взгляды могли быть переданы нашему клиенту по веб-запросу. В настоящее время наши представления хранятся вне нашей public
папки, что означает, что они не являются общедоступными. К счастью, Laravel позволяет очень легко перемещать или добавлять другие папки просмотра. Откройте app/config/view.php
и измените настройки paths
чтобы они указывали на нашу app/config/view.php
папку. Этот параметр работает как собственный путь включения PHP, он будет проверять каждую папку, пока не найдет соответствующий файл представления, поэтому не стесняйтесь добавлять несколько здесь:
приложение / Config / view.php
1
|
‘paths’ => array(__DIR__.’/../../public/views’),
|
Далее вам нужно будет настроить базу данных. Откройте app/config/database.php
и добавьте в настройки базы данных.
Примечание. Рекомендуется использовать 127.0.0.1
вместо localhost
. Вы получаете некоторое повышение производительности на большинстве систем, а с некоторыми конфигурациями системы localhost
даже не будет правильно подключаться.
Наконец, вам просто нужно убедиться, что ваша папка хранения доступна для записи.
1
|
chmod -R 755 app/storage
|
Laravel теперь установлен со всеми его зависимостями, а также с нашими собственными зависимостями. Теперь давайте настроим нашу установку Backbone!
Так же, как наш composer.json
установил все наши зависимости на стороне сервера, мы создадим package.json
в нашей общедоступной папке, чтобы установить все наши зависимости на стороне клиента.
Для наших клиентских зависимостей мы будем использовать:
- Underscore.js : это зависимость от Backbone.js и удобный набор инструментов для функций.
- Backbone.js : это наш клиентский MVC, который мы будем использовать для создания нашего приложения.
- Mustache.js : версия Javascript нашей библиотеки шаблонов, используя один и тот же язык шаблонов как на клиенте, так и на сервере, мы можем обмениваться представлениями, а не дублировать логику.
общественности / package.json
01
02
03
04
05
06
07
08
09
10
|
{
«name»: «nettuts-laravel4-and-backbone»,
«version»: «0.0.1»,
«private»: true,
«dependencies»: {
«underscore»: «*»,
«backbone»: «*»,
«mustache»: «*»
}
}
|
Теперь просто переключитесь в вашу общую папку и запустите npm install
. После этого давайте переключимся обратно в корень нашего приложения, чтобы подготовиться к остальным командам.
1
2
3
|
cd public
npm install
cd ..
|
Менеджеры пакетов избавят нас от огромного труда, если вы захотите обновить любую из этих библиотек, все, что вам нужно сделать, это запустить npm update
или composer update
. Кроме того, если вы хотите заблокировать любую из этих библиотек в определенной версии, все, что вам нужно сделать, это указать номер версии, а менеджер пакетов будет обрабатывать все остальное.
Чтобы завершить наш процесс установки, мы просто добавим все основные файлы и папки проекта, которые нам понадобятся, а затем протестируем их, чтобы убедиться, что все работает должным образом.
Нам нужно будет добавить следующие папки:
- общественные / виды
- общественные / просмотров / макеты
- общественные / JS
- общественности / CSS
И следующие файлы:
- общественности / CSS / styles.less
- общественности / JS / app.js
- общественности / просмотров / app.mustache
Для этого мы можем использовать однострочник:
1
|
mkdir public/views public/views/layouts public/js public/css && touch public/css/styles.less public/js/app.js public/views/app.mustache
|
Twitter Bootstrap также имеет две зависимости JavaScript, которые нам понадобятся, поэтому давайте просто скопируем их из папки vendor в нашу общую папку. Они есть:
- html5shiv.js : позволяет нам использовать элементы HTML5, не опасаясь, что старые браузеры их не поддержат
- bootstrap.min.js : вспомогательные библиотеки JavaScript для Twitter Bootstrap
1
2
|
cp vendor/twitter/bootstrap/docs/assets/js/html5shiv.js public/js/html5shiv.js
cp vendor/twitter/bootstrap/docs/assets/js/bootstrap.min.js public/js/bootstrap.min.js
|
Для нашего файла макета Twitter Bootstrap также предоставляет нам несколько хороших стартовых шаблонов для работы, поэтому давайте скопируем один в нашу папку макетов для начала:
1
|
cp vendor/twitter/bootstrap/docs/examples/starter-template.html public/views/layouts/application.blade.php
|
Обратите внимание, что я использую расширение лезвия здесь, это может быть просто шаблон усов, но я хотел показать вам, как легко смешивать шаблоны. Так как наш макет будет отображаться при загрузке страницы, и клиент не должен будет его повторно визуализировать, мы можем использовать здесь исключительно PHP. Если по какой-то причине вам нужно визуализировать этот файл на стороне клиента, вам следует переключить этот файл, чтобы использовать вместо этого шаблонизатор Усов.
Теперь, когда у нас есть все наши основные файлы, давайте добавим некоторый стартовый контент, который мы можем использовать, чтобы проверить, что все работает так, как мы ожидали. Я предоставляю вам некоторые основные заглушки, чтобы вы начали.
общественности / CSS / styles.less
Мы просто импортируем файлы Twitter Bootstrap из каталога производителя, а не копируем их. Это позволяет нам обновлять Twitter Bootstrap только composer update
.
Мы объявляем наши переменные в конце файла, компилятор LESS выяснит значение всех своих переменных перед синтаксическим анализом LESS в CSS. Это означает, что, переопределив переменную Twitter Bootstrap в конце файла, значение фактически изменится для всех включенных файлов, что позволяет нам выполнять простые переопределения без изменения основных файлов Twitter Bootstrap.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
/**
* Import Twitter Bootstrap Base File
******************************************************************************************
*/
@import «../../vendor/twitter/bootstrap/less/bootstrap»;
/**
* Define App Styles
* Do this before the responsive include, so that it can override properly as needed.
******************************************************************************************
*/
body {
padding-top: 60px;
}
/* this will set the position of our alerts */
#notifications {
width: 300px;
position: fixed;
top: 50px;
left: 50%;
margin-left: -150px;
text-align: center;
}
/**
* Import Bootstrap’s Responsive Overrides
* now we allow bootstrap to set the overrides for a responsive layout
******************************************************************************************
*/
@import «../../vendor/twitter/bootstrap/less/responsive»;
/**
* Define our variables last, any variable declared here will be used in the includes above
* which means that we can override any of the variables used in the bootstrap files easily
* without modifying any of the core bootstrap files
******************************************************************************************
*/
// Scaffolding
// ————————-
@bodyBackground: #f2f2f2;
@textColor: #575757;
// Links
// ————————-
@linkColor: #41a096;
// Typography
// ————————-
@sansFontFamily: Arial, Helvetica, sans-serif;
|
общественности / JS / app.js
Теперь мы завернем весь наш код в немедленно вызывающую анонимную функцию, которая передает несколько глобальных объектов. Затем мы назовем эти глобальные объекты чем-то более полезным для нас. Также мы будем кэшировать несколько объектов jQuery внутри функции готовности документа.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
//alias the global object
//alias jQuery so we can potentially use other libraries that utilize $
//alias Backbone to save us on some typing
(function(exports, $, bb){
//document ready
$(function(){
/**
***************************************
* Cached Globals
***************************************
*/
var $window, $body, $document;
$window = $(window);
$document = $(document);
$body = $(‘body’);
});//end document ready
}(this, jQuery, Backbone));
|
общественные / просмотров / макеты / application.blade.php
Далее просто файл HTML-макета. Однако мы используем помощник по asset
из Laravel, чтобы помочь нам в создании путей к нашим активам. Рекомендуется использовать этот тип помощника, потому что если вам когда-нибудь удастся переместить проект в подпапку, все ваши ссылки будут работать.
Мы убедились, что включили все наши зависимости в этот файл, а также добавили зависимость jQuery. Я решил запросить jQuery из Google CDN, потому что, скорее всего, посетитель этого сайта уже получит копию этого CDN, сохраненную в кэше в их браузере, что избавит нас от необходимости выполнять HTTP-запрос для него.
Здесь важно отметить, как мы вкладываем нашу точку зрения. Усы не имеют блочных разделов, как у Blade, поэтому вместо этого содержимое вложенного представления будет доступно в переменной с именем раздела. Я укажу на это, когда мы представим эту точку зрения с нашего маршрута.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
<!DOCTYPE html>
<html lang=»en»>
<head>
<meta charset=»utf-8″>
<title>Laravel4 & Backbone |
<meta name=»viewport» content=»width=device-width, initial-scale=1.0″>
<meta name=»description» content=»A single page blog built using Backbone.js, Laravel, and Twitter Bootstrap»>
<meta name=»author» content=»Conar Welsh»>
<link href=»{{ asset(‘css/styles.css’) }}» rel=»stylesheet»>
<!— HTML5 shim, for IE6-8 support of HTML5 elements —>
<!—[if lt IE 9]>
<script src=»{{ asset(‘js/html5shiv.js’) }}»></script>
<![endif]—>
</head>
<body>
<div id=»notifications»>
</div>
<div class=»navbar navbar-inverse navbar-fixed-top»>
<div class=»navbar-inner»>
<div class=»container»>
<button type=»button» class=»btn btn-navbar» data-toggle=»collapse» data-target=».nav-collapse»>
<span class=»icon-bar»>
<span class=»icon-bar»>
<span class=»icon-bar»>
</button>
<a class=»brand» href=»#»>Nettuts Tutorial</a>
<div class=»nav-collapse collapse»>
<ul class=»nav»>
<li class=»active»><a href=»#»>Blog</a></li>
</ul>
</div><!—/.nav-collapse —>
</div>
</div>
</div>
<div class=»container» data-role=»main»>
{{—since we are using mustache as the view, it does not have a concept of sections like blade has, so instead of using @yield here, our nested view will just be a variable that we can echo—}}
{{ $content }}
</div> <!— /container —>
<!— Placed at the end of the document so the pages load faster —>
<script src=»//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js»></script> <!— use Google CDN for jQuery to hopefully get a cached copy —>
<script src=»{{ asset(‘node_modules/underscore/underscore-min.js’) }}»></script>
<script src=»{{ asset(‘node_modules/backbone/backbone-min.js’) }}»></script>
<script src=»{{ asset(‘node_modules/mustache/mustache.js’) }}»></script>
<script src=»{{ asset(‘js/bootstrap.min.js’) }}»></script>
<script src=»{{ asset(‘js/app.js’) }}»></script>
@yield(‘scripts’)
</body>
</html>
|
общественности / просмотров / app.mustache
Далее просто простое представление, которое мы вложим в наш макет.
1
2
3
4
|
<dl>
<dt>Q.
<dd>A.
</dl>
|
Приложение / routes.php
Laravel уже должен был предоставить вам маршрут по умолчанию, все, что мы здесь делаем, — это изменение имени представления, которое будет отображать этот маршрут.
Помните, я говорил вам, что вложенное представление будет доступно в переменной с именем независимо от родительского раздела? Ну, когда вы вкладываете представление, первым параметром функции является имя раздела:
1
|
View::make(‘view.path’)->nest($sectionName, $nestedViewPath, $viewVariables);
|
В нашей команде nest
мы назвали раздел «content», что означает, что если мы выведем $content
из нашего макета, мы получим визуализированное содержимое этого представления. Если бы мы делали это, return View::make('layouts.application')->nest('foobar', 'app');
тогда наше вложенное представление будет доступно в переменной с именем $foobar
.
1
2
3
4
5
6
7
8
9
|
<?php
//backbone app route
Route::get(‘/’, function()
{
//change our view name to the view we created in a previous step
//notice that we do not need to provide the .mustache extension
return View::make(‘layouts.application’)->nest(‘content’, ‘app’);
});
|
Со всеми нашими основными файлами мы можем проверить, чтобы все прошло нормально. Laravel 4 использует новый веб-сервер PHP, чтобы предоставить нам отличную небольшую среду разработки. До тех пор, пока на вашем компьютере разработки не было миллиона виртуальных хостов для каждого проекта, над которым вы работаете!
Примечание: сначала убедитесь, что вы скомпилировали файл LESS!
1
|
php artisan serve
|
Если вы правильно следовали, вы должны истерически смеяться над моим ужасным чувством юмора, и все наши активы должны быть правильно включены в страницу.
Часть 2: Laravel 4 JSON API
Теперь мы создадим API, который послужит основой для нашего приложения Backbone. Laravel 4 делает этот процесс легким.
Руководство по API
Сначала давайте рассмотрим несколько общих рекомендаций, которые следует учитывать при создании нашего API:
-
Коды состояния : Ответы должны отвечать правильными кодами состояния, бороться с искушением просто поместить
{ error: "this is an error message" }
в{ error: "this is an error message" }
вашего ответа. Используйте протокол HTTP в полной мере!- 200 : успех
- 201 : ресурс создан
- 204 : успех, но нет контента для возврата
- 400 : запрос не выполнен // ошибка проверки
- 401 : не аутентифицирован
- 403 : отказ ответить // неверные учетные данные, нет разрешения (не принадлежащий ресурс)
- 404 : не найдено
- 500 : другая ошибка
-
Методы ресурсов . Несмотря на то, что контроллеры будут обслуживать разные ресурсы, они все равно должны иметь очень похожее поведение. Чем более предсказуемым является ваш API, тем легче его реализовать и принять.
- index : вернуть коллекцию ресурсов.
- show : вернуть отдельный ресурс.
- создать : вернуть форму. Эта форма должна детализировать обязательные поля, валидацию и метки как можно лучше. Как и все остальное, необходимое для правильного создания ресурса. Несмотря на то, что это API-интерфейс JSON, здесь очень полезно возвращать форму. И компьютер, и человек могут анализировать эту форму и очень легко расшифровать, какие элементы необходимы для успешного заполнения этой формы. Это очень простой способ «документировать» потребности вашего API.
- store : сохранить новый ресурс и вернуться с соответствующим кодом состояния: 201.
- edit : вернуть форму, заполненную текущим состоянием ресурса. Эта форма должна детализировать обязательные поля, валидацию и метки как можно лучше. Как и все остальное, необходимое для правильного редактирования ресурса.
- обновление : обновить существующий ресурс и вернуться с правильным кодом состояния.
- delete : удалить существующий ресурс и вернуться с соответствующим кодом состояния: 204.
Маршрутизация и управление версиями
API-интерфейсы рассчитаны на некоторое время. Это не похоже на ваш сайт, где вы можете просто изменить его функциональность за мгновение. Если у вас есть программы, использующие ваш API, они не будут вам довольны, если вы что-то измените и их программы прервутся. По этой причине важно использовать версионность.
Мы всегда можем создать «вторую версию» с дополнительной или измененной функциональностью и позволить нашим подписывающимся программам участвовать в этих изменениях, а не принудительно.
Laravel предоставляет нам группы маршрутов, которые идеально подходят для этого, поместите следующий код над нашим первым маршрутом:
1
2
3
4
5
6
7
|
<?php
//create a group of routes that will belong to APIv1
Route::group(array(‘prefix’ => ‘v1’), function()
{
//… insert API routes here…
});
|
Создание ресурсов
Мы будем использовать генераторы Джеффри Вей для генерации наших ресурсов. Когда мы генерируем ресурс, он создает для нас следующие элементы:
- контроллер
- модель
- Представления (index.blade.php, show.blade.php, create.blade.php, edit.blade.php)
- миграция
- Семена
Нам понадобятся только два ресурса для этого приложения: ресурс Post
ресурс Comment
.
Примечание: в недавнем обновлении генераторов я получил ошибку разрешений из-за настройки моих веб-серверов. Чтобы устранить эту проблему, необходимо разрешить запись в папку, в которую генераторы записывают временный файл.
1
|
sudo chmod -R 755 vendor/way/generators/src/Way/
|
Запустите команду generate:resource
1
2
3
|
php artisan generate:resource post —fields=»title:string, content:text, author_name:string»
php artisan generate:resource comment —fields=»content:text, author_name:string, post_id:integer»
|
Теперь вы должны сделать паузу на секунду, чтобы изучить все файлы, созданные для нас генератором.
Настройте сгенерированные ресурсы
Команда generate:resource
сэкономила нам много работы, но из-за нашей уникальной конфигурации нам все еще нужно будет внести некоторые изменения.
Прежде всего, генератор поместил созданные им app/views
папку app/views
, поэтому нам нужно переместить их в папку public/views
1
2
|
mv app/views/posts public/views/posts
mv app/views/comments public/views/comments
|
Приложение / routes.php
Мы решили, что мы хотим, чтобы наш API был версионным, поэтому нам нужно перенести маршруты, созданные генератором для нас, в группу версий. Мы также хотим присвоить пространству имен наши контроллеры с соответствующей версией, чтобы у нас был свой набор контроллеров для каждой создаваемой версии. Также ресурс комментариев должен быть вложен в ресурс posts.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
<?php
//create a group of routes that will belong to APIv1
Route::group(array(‘prefix’ => ‘v1’), function()
{
//… insert API routes here…
Route::resource(‘posts’, ‘V1\PostsController’);
Route::resource(‘posts.comments’, ‘V1\PostsCommentsController’);
});
//backbone app route
Route::get(‘/’, function()
{
//change our view name to the view we created in a previous step
//notice that we do not need to provide the .mustache extension
return View::make(‘layouts.application’)->nest(‘content’, ‘app’);
});
|
Поскольку мы присваиваем пространство имен нашим контроллерам, мы должны переместить их в собственную папку для организации, давайте создадим папку с именем V1
и переместим в нее сгенерированные контроллеры. Кроме того, поскольку мы вложили наш контроллер комментариев под контроллером posts, давайте изменим имя этого контроллера, чтобы отразить взаимосвязь.
1
2
3
|
mkdir app/controllers/V1
mv app/controllers/PostsController.php app/controllers/V1/
mv app/controllers/CommentsController.php app/controllers/V1/PostsCommentsController.php
|
Нам нужно будет обновить файлы контроллера, чтобы они отражали и наши изменения. Прежде всего, нам нужно их пространство имен, и, поскольку они имеют пространство имен, любые классы вне этого пространства имен необходимо будет импортировать вручную с use
оператора use
.
приложение / контроллеры / PostsController.php
1
2
3
4
5
6
7
8
|
<?php
//use our new namespace
namespace V1;
//import classes that are not in this new namespace
use BaseController;
class PostsController extends BaseController {
|
приложение / контроллеры / PostsCommentsController.php
Нам также нужно обновить наш CommentsController
новым именем: PostsCommentsController
1
2
3
4
5
6
7
8
9
|
<?php
//use our new namespace
namespace V1;
//import classes that are not in this new namespace
use BaseController;
//rename our controller class
class PostsCommentsController extends BaseController {
|
Добавление в репозитории
По умолчанию репозитории не являются частью Laravel. Laravel чрезвычайно гибок и позволяет очень легко добавлять их. Мы собираемся использовать репозитории, чтобы помочь нам отделить нашу логику для повторного использования кода, а также для тестирования. Сейчас мы просто настроим использование репозиториев, позже добавим правильную логику.
Давайте создадим папку для хранения наших репозиториев:
1
|
mkdir app/repositories
|
Чтобы сообщить автозагрузчику об этой новой папке, нам нужно добавить ее в наш файл composer.json
. Взгляните на обновленный раздел «Автозагрузка» нашего файла, и вы увидите, что мы добавили его в папку с репозиториями.
composer.json
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
{
«require»: {
«laravel/framework»: «4.0.*»,
«way/generators»: «dev-master»,
«twitter/bootstrap»: «dev-master»,
«conarwelsh/mustache-l4»: «dev-master»
},
«require-dev»: {
«phpunit/phpunit»: «3.7.*»,
«mockery/mockery»: «0.7.*»
},
«autoload»: {
«classmap»: [
«app/commands»,
«app/controllers»,
«app/models»,
«app/database/migrations»,
«app/database/seeds»,
«app/tests/TestCase.php»,
«app/repositories»
]
},
«scripts»: {
«post-update-cmd»: «php artisan optimize»
},
«minimum-stability»: «dev»
}
|
Заполнение нашей базы данных
Семена базы данных являются полезным инструментом, они предоставляют нам простой способ наполнить нашу базу данных некоторым содержанием. Генераторы предоставили нам базовые файлы для заполнения, нам просто нужно добавить некоторые реальные семена.
приложение / базы данных / семена / PostsTableSeeder.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
<?php
class PostsTableSeeder extends Seeder {
public function run()
{
$posts = array(
array(
‘title’ => ‘Test Post’,
‘content’ => ‘Lorem ipsum Reprehenderit velit est irure in enim in magna aute occaecat qui velit ad.’,
‘author_name’ => ‘Conar Welsh’,
‘created_at’ => date(‘Ymd H:i:s’),
‘updated_at’ => date(‘Ymd H:i:s’),
),
array(
‘title’ => ‘Another Test Post’,
‘content’ => ‘Lorem ipsum Reprehenderit velit est irure in enim in magna aute occaecat qui velit ad.’,
‘author_name’ => ‘Conar Welsh’,
‘created_at’ => date(‘Ymd H:i:s’),
‘updated_at’ => date(‘Ymd H:i:s’),
),
);
// Uncomment the below to run the seeder
DB::table(‘posts’)->insert($posts);
}
}
|
приложение / базы данных / семена / CommentsTableSeeder.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
<?php
class CommentsTableSeeder extends Seeder {
public function run()
{
$comments = array(
array(
‘content’ => ‘Lorem ipsum Nisi dolore ut incididunt mollit tempor proident eu velit cillum dolore sed’,
‘author_name’ => ‘Testy McTesterson’,
‘post_id’ => 1,
‘created_at’ => date(‘Ymd H:i:s’),
‘updated_at’ => date(‘Ymd H:i:s’),
),
array(
‘content’ => ‘Lorem ipsum Nisi dolore ut incididunt mollit tempor proident eu velit cillum dolore sed’,
‘author_name’ => ‘Testy McTesterson’,
‘post_id’ => 1,
‘created_at’ => date(‘Ymd H:i:s’),
‘updated_at’ => date(‘Ymd H:i:s’),
),
array(
‘content’ => ‘Lorem ipsum Nisi dolore ut incididunt mollit tempor proident eu velit cillum dolore sed’,
‘author_name’ => ‘Testy McTesterson’,
‘post_id’ => 2,
‘created_at’ => date(‘Ymd H:i:s’),
‘updated_at’ => date(‘Ymd H:i:s’),
),
);
// Uncomment the below to run the seeder
DB::table(‘comments’)->insert($comments);
}
}
|
Не забудьте запустить composer dump-autoload
чтобы composer dump-autoload
Composer узнал о новых файлах миграции!
1
|
composer dump-autoload
|
Теперь мы можем запустить наши миграции и заполнить базу данных. Laravel предоставляет нам одну команду для выполнения обоих:
1
|
php artisan migrate —seed
|
тесты
Тестирование является одной из тех тем в разработке, о которой никто не может спорить о важности, однако большинство людей склонны игнорировать ее из-за кривой обучения. Тестирование на самом деле не так сложно, и оно может значительно улучшить ваше приложение. В этом руководстве мы настроим некоторые базовые тесты, которые помогут нам убедиться в правильности работы нашего API. Мы создадим этот стиль API TDD. Правила TDD гласят, что нам не разрешается писать какой-либо производственный код, пока мы не провалим тесты, которые этого требуют. Однако, если бы я проводил вас по каждому тесту в отдельности, это оказалось бы очень длинным учебником, поэтому в интересах краткости я просто предоставлю вам несколько тестов для работы, а затем правильный код для их создания. тесты проходят потом.
Прежде чем писать какие-либо тесты, мы должны сначала проверить текущий статус теста нашего приложения. Так как мы установили PHPUnit через composer, у нас есть доступные нам двоичные файлы. Все, что вам нужно сделать, это запустить:
1
|
vendor/phpunit/phpunit/phpunit.php
|
Упс! У нас уже есть сбой! Неудачный тест на самом деле является примером теста, который предварительно установлен в нашей структуре приложения Laravel, он сравнивает маршрут по умолчанию, который также был установлен со структурой приложения Laravel. Поскольку мы изменили этот маршрут, мы не можем удивляться, что тест не удался. Однако мы можем просто удалить этот тест, так как он не применим к нашему приложению.
1
|
rm app/tests/ExampleTest.php
|
Если вы снова запустите команду PHPUnit, вы увидите, что тесты не выполнялись, и у нас есть чистый лист для тестирования.
Примечание: возможно, что если у вас есть более старая версия генераторов Джеффри Вей, у вас там будет несколько тестов, созданных этими генераторами, и эти тесты, вероятно, не пройдут. Просто удалите или перезапишите эти тесты, чтобы продолжить.
Для этого урока мы будем тестировать наши контроллеры и наши репозитории. Давайте создадим несколько папок для хранения этих тестов:
1
|
mkdir app/tests/controllers app/tests/repositories
|
Теперь для тестовых файлов. Мы собираемся использовать Mockery для макетирования наших репозиториев для тестов наших контроллеров. Поддельные объекты делают, как следует из их названия, они «насмехаются» над объектами и сообщают нам о том, как с этими объектами взаимодействовали.
В случае тестов контроллера мы не хотим, чтобы на самом деле вызывались репозитории, в конце концов, это тесты контроллера, а не тесты репозитория. Поэтому Mockery настроит нас для использования объектов вместо наших репозиториев и даст нам знать, были ли эти объекты вызваны так, как мы ожидали.
Чтобы осуществить это, мы должны будем сказать контроллерам, чтобы они использовали наши «поддельные» объекты в отличие от реальных вещей. Мы просто скажем нашему Приложению использовать макетированный экземпляр в следующий раз, когда будет запрошен определенный класс. Команда выглядит так:
1
|
App::instance($classToReplace, $instanceOfClassToReplaceWith);
|
Общий процесс насмешек будет выглядеть примерно так:
- Создайте новый объект Mockery, предоставив ему имя класса, над которым он будет работать.
- Сообщите объекту Mockery, какие методы он должен получить, сколько раз он должен получить этот метод и что этот метод должен вернуть.
- Используйте команду, показанную выше, чтобы сообщить нашему Приложению использовать этот новый объект Mockery вместо значения по умолчанию.
- Запустите метод контроллера, как обычно.
- Утвердите ответ.
приложение / тесты / контроллеры / CommentsControllerTest.php
001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
|
<?php
class CommentsControllerTest extends TestCase {
/**
************************************************************************
* Basic Route Tests
* notice that we can use our route() helper here!
************************************************************************
*/
//test that GET /v1/posts/1/comments returns HTTP 200
public function testIndex()
{
$response = $this->call(‘GET’, route(‘v1.posts.comments.index’, array(1)) );
$this->assertTrue($response->isOk());
}
//test that GET /v1/posts/1/comments/1 returns HTTP 200
public function testShow()
{
$response = $this->call(‘GET’, route(‘v1.posts.comments.show’, array(1,1)) );
$this->assertTrue($response->isOk());
}
//test that GET /v1/posts/1/comments/create returns HTTP 200
public function testCreate()
{
$response = $this->call(‘GET’, route(‘v1.posts.comments.create’, array(1)) );
$this->assertTrue($response->isOk());
}
//test that GET /v1/posts/1/comments/1/edit returns HTTP 200
public function testEdit()
{
$response = $this->call(‘GET’, route(‘v1.posts.comments.edit’, array(1,1)) );
$this->assertTrue($response->isOk());
}
/**
*************************************************************************
* Tests to ensure that the controller calls the repo as we expect
* notice we are «Mocking» our repository
*
* also notice that we do not really care about the data or interactions
* we merely care that the controller is doing what we are going to want
* it to do, which is reach out to our repository for more information
*************************************************************************
*/
//ensure that the index function calls our repository’s «findAll» method
public function testIndexShouldCallFindAllMethod()
{
//create our new Mockery object with a name of CommentRepositoryInterface
$mock = Mockery::mock(‘CommentRepositoryInterface’);
//inform the Mockery object that the «findAll» method should be called on it once
//and return a string value of «foo»
$mock->shouldReceive(‘findAll’)->once()->andReturn(‘foo’);
//inform our application that we have an instance that it should use
//whenever the CommentRepositoryInterface is requested
App::instance(‘CommentRepositoryInterface’, $mock);
//call our controller route
$response = $this->call(‘GET’, route(‘v1.posts.comments.index’, array(1)));
//assert that the response is a boolean value of true
$this->assertTrue(!! $response->original);
}
//ensure that the show method calls our repository’s «findById» method
public function testShowShouldCallFindById()
{
$mock = Mockery::mock(‘CommentRepositoryInterface’);
$mock->shouldReceive(‘findById’)->once()->andReturn(‘foo’);
App::instance(‘CommentRepositoryInterface’, $mock);
$response = $this ->call( 'GET' , route( 'v1.posts.comments.show' , array (1,1))); $this ->assertTrue(!! $response ->original); }
//ensure that our create method calls the "instance" method on the repository public function testCreateShouldCallInstanceMethod() {
$mock = Mockery::mock( 'CommentRepositoryInterface' ); $mock ->shouldReceive( 'instance' )->once()->andReturn( array ()); App::instance( 'CommentRepositoryInterface' , $mock ); $response = $this ->call( 'GET' , route( 'v1.posts.comments.create' , array (1))); $this ->assertViewHas( 'comment' ); }
//ensure that the edit method calls our repository's "findById" method public function testEditShouldCallFindByIdMethod() {
$mock = Mockery::mock( 'CommentRepositoryInterface' ); $mock ->shouldReceive( 'findById' )->once()->andReturn( array ()); App::instance( 'CommentRepositoryInterface' , $mock ); $response = $this ->call( 'GET' , route( 'v1.posts.comments.edit' , array (1,1))); $this ->assertViewHas( 'comment' ); }
//ensure that the store method should call the repository's "store" method public function testStoreShouldCallStoreMethod() {
$mock = Mockery::mock( 'CommentRepositoryInterface' ); $mock ->shouldReceive( 'store' )->once()->andReturn( 'foo' ); App::instance( 'CommentRepositoryInterface' , $mock ); $response = $this ->call( 'POST' , route( 'v1.posts.comments.store' , array (1))); $this ->assertTrue(!! $response ->original); }
//ensure that the update method should call the repository's "update" method public function testUpdateShouldCallUpdateMethod() {
$mock = Mockery::mock( 'CommentRepositoryInterface' ); $mock ->shouldReceive( 'update' )->once()->andReturn( 'foo' ); App::instance( 'CommentRepositoryInterface' , $mock ); $response = $this ->call( 'PUT' , route( 'v1.posts.comments.update' , array (1,1))); $this ->assertTrue(!! $response ->original); }
//ensure that the destroy method should call the repositories "destroy" method public function testDestroyShouldCallDestroyMethod() {
$mock = Mockery::mock( 'CommentRepositoryInterface' ); $mock ->shouldReceive( 'destroy' )->once()->andReturn(true); App::instance( 'CommentRepositoryInterface' , $mock ); $response = $this ->call( 'DELETE' , route( 'v1.posts.comments.destroy' , array (1,1))); $this ->assertTrue( empty ( $response ->original) ); }
}
|
приложение / тесты / контроллеры / PostsControllerTest.php
Далее мы будем следовать той же процедуре для PostsController
испытаний
001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
|
<?php
class PostsControllerTest extends TestCase { /**
* Test Basic Route Responses */
public function testIndex() {
$response = $this ->call( 'GET' , route( 'v1.posts.index' )); $this ->assertTrue( $response ->isOk()); }
public function testShow() {
$response = $this ->call( 'GET' , route( 'v1.posts.show' , array (1))); $this ->assertTrue( $response ->isOk()); }
public function testCreate() {
$response = $this ->call( 'GET' , route( 'v1.posts.create' )); $this ->assertTrue( $response ->isOk()); }
public function testEdit() {
$response = $this ->call( 'GET' , route( 'v1.posts.edit' , array (1))); $this ->assertTrue( $response ->isOk()); }
/**
* Test that controller calls repo as we expect */
public function testIndexShouldCallFindAllMethod() {
$mock = Mockery::mock( 'PostRepositoryInterface' ); $mock ->shouldReceive( 'findAll' )->once()->andReturn( 'foo' ); App::instance( 'PostRepositoryInterface' , $mock ); $response = $this ->call( 'GET' , route( 'v1.posts.index' )); $this ->assertTrue(!! $response ->original); }
public function testShowShouldCallFindById() {
$mock = Mockery::mock( 'PostRepositoryInterface' ); $mock ->shouldReceive( 'findById' )->once()->andReturn( 'foo' ); App::instance( 'PostRepositoryInterface' , $mock ); $response = $this ->call( 'GET' , route( 'v1.posts.show' , array (1))); $this ->assertTrue(!! $response ->original); }
public function testCreateShouldCallInstanceMethod() {
$mock = Mockery::mock( 'PostRepositoryInterface' ); $mock ->shouldReceive( 'instance' )->once()->andReturn( array ()); App::instance( 'PostRepositoryInterface' , $mock ); $response = $this ->call( 'GET' , route( 'v1.posts.create' )); $this ->assertViewHas( 'post' ); }
public function testEditShouldCallFindByIdMethod() {
$mock = Mockery::mock( 'PostRepositoryInterface' ); $mock ->shouldReceive( 'findById' )->once()->andReturn( array ()); App::instance( 'PostRepositoryInterface' , $mock ); $response = $this ->call( 'GET' , route( 'v1.posts.edit' , array (1))); $this ->assertViewHas( 'post' ); }
public function testStoreShouldCallStoreMethod() {
$mock = Mockery::mock( 'PostRepositoryInterface' ); $mock ->shouldReceive( 'store' )->once()->andReturn( 'foo' ); App::instance( 'PostRepositoryInterface' , $mock ); $response = $this ->call( 'POST' , route( 'v1.posts.store' )); $this ->assertTrue(!! $response ->original); }
public function testUpdateShouldCallUpdateMethod() {
$mock = Mockery::mock( 'PostRepositoryInterface' ); $mock ->shouldReceive( 'update' )->once()->andReturn( 'foo' ); App::instance( 'PostRepositoryInterface' , $mock ); $response = $this ->call( 'PUT' , route( 'v1.posts.update' , array (1))); $this ->assertTrue(!! $response ->original); }
public function testDestroyShouldCallDestroyMethod() {
$mock = Mockery::mock( 'PostRepositoryInterface' ); $mock ->shouldReceive( 'destroy' )->once()->andReturn(true); App::instance( 'PostRepositoryInterface' , $mock ); $response = $this ->call( 'DELETE' , route( 'v1.posts.destroy' , array (1))); $this ->assertTrue( empty ( $response ->original) ); }
}
|
приложение / тесты / Хранилище / EloquentCommentRepositoryTest.php
Теперь о тестировании репозитория. При написании наших тестов контроллеров мы в значительной степени уже решили, как должна выглядеть большая часть интерфейса для репозиториев. Наши контролеры нуждались в следующих методах:
- findById ($ ID)
- найти все()
- Экземпляр ($ данные)
- магазин ($ данные)
- обновление ($ id, $ data)
- уничтожить ($ ID)
Единственный другой метод, который мы хотим добавить здесь, это validate
метод. В основном это будет частный метод для хранилища, чтобы обеспечить безопасность хранения или обновления данных.
Для этих тестов мы также собираемся добавить setUp
метод, который позволит нам запускать некоторый код в нашем классе перед выполнением каждого теста. Наш setUp
метод будет очень простым, мы просто позаботимся о том, чтобы любые setUp
методы, определенные в родительских классах, также вызывались с использованием, parent::setUp()
а затем просто добавили переменную класса, которая хранит экземпляр нашего репозитория.
Мы снова будем использовать возможности IoC-контейнера Laravel, чтобы получить экземпляр нашего репозитория. Команда App::make()
вернет экземпляр запрошенного класса, теперь может показаться странным, что мы не просто делаем $this->repo = new EloquentCommentRepository()
, но держим эту мысль, мы вернемся к этому на мгновение. Вы , наверное , заметили , что мы задаем для класса под названием EloquentCommentRepository
, но в наших тестах контроллера выше, мы называли наш репозиторий CommentRepositoryInterface
… положили эту мысль на заднем плане , а также … explainations для обоихов идут, я обещаю!
001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
|
<?php
class EloquentCommentRepositoryTest extends TestCase { public function setUp()
{
parent::setUp();
$this ->repo = App::make( 'EloquentCommentRepository' ); }
public function testFindByIdReturnsModel() {
$comment = $this ->repo->findById(1,1); $this ->assertTrue( $comment instanceof Illuminate\Database\Eloquent\Model); }
public function testFindAllReturnsCollection() {
$comments = $this ->repo->findAll(1); $this ->assertTrue( $comments instanceof Illuminate\Database\Eloquent\Collection); }
public function testValidatePasses() {
$reply = $this ->repo->validate( array ( 'post_id' => 1, 'content' => 'Lorem ipsum Fugiat consectetur laborum Ut consequat aliqua.' , 'author_name' => 'Testy McTesterson' ));
$this ->assertTrue( $reply ); }
public function testValidateFailsWithoutContent() {
try {
$reply = $this ->repo->validate( array ( 'post_id' => 1, 'author_name' => 'Testy McTesterson' ));
}
catch (ValidationException $expected ) {
return;
}
$this ->fail( 'ValidationException was not raised' ); }
public function testValidateFailsWithoutAuthorName() {
try {
$reply = $this ->repo->validate( array ( 'post_id' => 1, 'content' => 'Lorem ipsum Fugiat consectetur laborum Ut consequat aliqua.' ));
}
catch (ValidationException $expected ) {
return;
}
$this ->fail( 'ValidationException was not raised' ); }
public function testValidateFailsWithoutPostId() {
try {
$reply = $this ->repo->validate( array ( 'author_name' => 'Testy McTesterson' , 'content' => 'Lorem ipsum Fugiat consectetur laborum Ut consequat aliqua.' ));
}
catch (ValidationException $expected ) {
return;
}
$this ->fail( 'ValidationException was not raised' ); }
public function testStoreReturnsModel() {
$comment_data = array ( 'content' => 'Lorem ipsum Fugiat consectetur laborum Ut consequat aliqua.' , 'author_name' => 'Testy McTesterson' );
$comment = $this ->repo->store(1, $comment_data ); $this ->assertTrue( $comment instanceof Illuminate\Database\Eloquent\Model); $this ->assertTrue( $comment ->content === $comment_data [ 'content' ]); $this ->assertTrue( $comment ->author_name === $comment_data [ 'author_name' ]); }
public function testUpdateSaves() {
$comment_data = array ( 'content' => 'The Content Has Been Updated' );
$comment = $this ->repo->update(1, 1, $comment_data ); $this ->assertTrue( $comment instanceof Illuminate\Database\Eloquent\Model); $this ->assertTrue( $comment ->content === $comment_data [ 'content' ]); }
public function testDestroySaves() {
$reply = $this ->repo->destroy(1,1); $this ->assertTrue( $reply ); try {
$this ->repo->findById(1,1); }
catch (NotFoundException $expected ) {
return;
}
$this ->fail( 'NotFoundException was not raised' ); }
public function testInstanceReturnsModel() {
$comment = $this ->repo->instance(); $this ->assertTrue( $comment instanceof Illuminate\Database\Eloquent\Model); }
public function testInstanceReturnsModelWithData() {
$comment_data = array ( 'title' => 'Un-validated title' );
$comment = $this ->repo->instance( $comment_data ); $this ->assertTrue( $comment instanceof Illuminate\Database\Eloquent\Model); $this ->assertTrue( $comment ->title === $comment_data [ 'title' ]); }
}
|
приложение / тесты / Хранилище / EloquentPostRepositoryTest.php
001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
|
<?php
class EloquentPostRepositoryTest extends TestCase { public function setUp()
{
parent::setUp();
$this ->repo = App::make( 'EloquentPostRepository' ); }
public function testFindByIdReturnsModel() {
$post = $this ->repo->findById(1); $this ->assertTrue( $post instanceof Illuminate\Database\Eloquent\Model); }
public function testFindAllReturnsCollection() {
$posts = $this ->repo->findAll(); $this ->assertTrue( $posts instanceof Illuminate\Database\Eloquent\Collection); }
public function testValidatePasses() {
$reply = $this ->repo->validate( array ( 'title' => 'This Should Pass' , 'content' => 'Lorem ipsum Fugiat consectetur laborum Ut consequat aliqua.' , 'author_name' => 'Testy McTesterson' ));
$this ->assertTrue( $reply ); }
public function testValidateFailsWithoutTitle() {
try {
$reply = $this ->repo->validate( array ( 'content' => 'Lorem ipsum Fugiat consectetur laborum Ut consequat aliqua.' , 'author_name' => 'Testy McTesterson' ));
}
catch (ValidationException $expected ) {
return;
}
$this ->fail( 'ValidationException was not raised' ); }
public function testValidateFailsWithoutAuthorName() {
try {
$reply = $this ->repo->validate( array ( 'title' => 'This Should Pass' , 'content' => 'Lorem ipsum Fugiat consectetur laborum Ut consequat aliqua.' ));
}
catch (ValidationException $expected ) {
return;
}
$this ->fail( 'ValidationException was not raised' ); }
public function testStoreReturnsModel() {
$post_data = array ( 'title' => 'This Should Pass' , 'content' => 'Lorem ipsum Fugiat consectetur laborum Ut consequat aliqua.' , 'author_name' => 'Testy McTesterson' );
$post = $this ->repo->store( $post_data ); $this ->assertTrue( $post instanceof Illuminate\Database\Eloquent\Model); $this ->assertTrue( $post ->title === $post_data [ 'title' ]); $this ->assertTrue( $post ->content === $post_data [ 'content' ]); $this ->assertTrue( $post ->author_name === $post_data [ 'author_name' ]); }
public function testUpdateSaves() {
$post_data = array ( 'title' => 'The Title Has Been Updated' );
$post = $this ->repo->update(1, $post_data ); $this ->assertTrue( $post instanceof Illuminate\Database\Eloquent\Model); $this ->assertTrue( $post ->title === $post_data [ 'title' ]); }
public function testDestroySaves() {
$reply = $this ->repo->destroy(1); $this ->assertTrue( $reply ); try {
$this ->repo->findById(1); }
catch (NotFoundException $expected ) {
return;
}
$this ->fail( 'NotFoundException was not raised' ); }
public function testInstanceReturnsModel() {
$post = $this ->repo->instance(); $this ->assertTrue( $post instanceof Illuminate\Database\Eloquent\Model); }
public function testInstanceReturnsModelWithData() {
$post_data = array ( 'title' => 'Un-validated title' );
$post = $this ->repo->instance( $post_data ); $this ->assertTrue( $post instanceof Illuminate\Database\Eloquent\Model); $this ->assertTrue( $post ->title === $post_data [ 'title' ]); }
}
|
Теперь, когда у нас есть все наши тесты, давайте снова запустим PHPUnit, чтобы посмотреть, как они провалились!
1
|
vendor/phpunit/phpunit/phpunit.php |
У вас должно быть куча сбоев, и на самом деле тестовый набор, вероятно, даже не закончил тестирование до его сбоя. Это нормально, это означает, что мы следовали правилам TDD и написали провальные тесты перед производственным кодом. Хотя обычно эти тесты пишутся по одному, и вы не переходите к следующему тесту, пока не получите код, позволяющий пройти предыдущий тест. Ваш терминал на данный момент должен выглядеть примерно так:
Что на самом деле не работает, так это assertViewHas
метод в наших тестах контроллеров. Отчасти страшно иметь дело с такой ошибкой, когда мы объединили все наши тесты без какого-либо производственного кода вообще. Вот почему вы всегда должны писать тесты по одному за раз, так как вы обнаружите эти ошибки с ходу, а не просто огромное количество ошибок одновременно. А пока, просто следуйте моему примеру в реализации нашего кода.
Обсуждение боковой панели
Прежде чем мы продолжим реализацию, давайте разберемся для быстрого обсуждения на боковой панели ответственности шаблона MVC.
Из Банды Четырех :
Модель — это объект приложения, представление — это экранное представление, а контроллер определяет то, как пользовательский интерфейс реагирует на ввод данных пользователем.
Смысл использования такой структуры заключается в том, чтобы оставаться герметичным и гибким, что позволяет нам обмениваться и повторно использовать компоненты. Давайте рассмотрим каждую часть шаблона MVC и поговорим о его повторном использовании и гибкости:
Посмотреть
Я думаю, что большинство людей согласятся с тем, что представление должно быть простым визуальным представлением данных и не должно содержать много логики. В нашем случае, как разработчики для Интернета, наш вид, как правило, HTML или XML.
- многоразовый : всегда, почти все может создать вид
- гибкость : отсутствие реальной логики в этих слоях делает это очень гибким
контроллер
Если контроллер «определяет, как пользовательский интерфейс реагирует на ввод пользователя», то его обязанностью должно быть прослушивание ввода пользователя (GET, POST, заголовки и т. Д.) И построение текущего состояния приложения. По моему мнению, Контроллер должен быть очень легким и не должен содержать больше кода, чем требуется для выполнения вышеизложенного.
- возможность многократного использования : мы должны помнить, что наши контроллеры возвращают самоуверенное представление, поэтому мы никогда не сможем вызвать этот метод контроллера на практике, чтобы использовать какую-либо логику внутри него. Поэтому любая логика, размещаемая в методах Controller, должна быть специфичной для этого метода Controller. Если логика может использоваться повторно, она должна быть размещена в другом месте.
- гибкость : в большинстве PHP MVC контроллер привязан непосредственно к маршруту, что не оставляет нам большой гибкости. Laravel исправляет эту проблему, позволяя нам объявлять маршруты, использующие контроллер, поэтому теперь мы можем заменять наши контроллеры различными реализациями, если это необходимо:
1
2
3
|
Route::get( '/' , array ( 'uses' => 'SomeController@action' ));
|
модель
Модель — это «объект приложения» в нашем определении из «Банды четырех». Это очень общее определение. Кроме того, мы просто решили разгрузить любую логику, которую необходимо повторно использовать из нашего контроллера, и, поскольку модель является единственным компонентом, оставшимся в нашей определенной структуре, логично предположить, что это новый дом для этой логики. Тем не менее, я думаю, что Модель не должна содержать никакой логики, подобной этой. На мой взгляд, мы должны думать о нашем «объекте приложения», в данном случае как о объекте, который представляет свое место на уровне данных, будь то таблица, строка или коллекция, полностью зависит от состояния. Модель должна содержать не намного больше, чем методы получения и установки данных (включая отношения).
- возможность многократного использования : если мы следуем вышеупомянутой практике и делаем наши Модели объектом, представляющим его место в базе данных, этот объект остается очень многоразовым. Любая часть нашей системы может использовать эту модель и тем самым получить полный и незавершенный доступ к базе данных.
- Гибкость : следуя описанной выше практике, наша Модель является в основном реализацией ORM, это позволяет нам быть гибкими, потому что теперь у нас есть возможность изменять ORM всякий раз, когда мы хотим, просто добавляя новую Модель. Вероятно, у нас должен быть предопределенный интерфейс, которым должна следовать наша Модель, например: all, find, create, update, delete. Внедрение нового ORM будет таким же простым, как обеспечение соответствия ранее упомянутого интерфейса.
вместилище
Просто тщательно определив наши компоненты MVC, мы осиротили все виды логики в ничейной стране. Это где хранилища приходят, чтобы заполнить пустоту. Хранилища становятся посредниками контроллеров и моделей. Типичный запрос будет примерно таким:
- Контроллер получает все пользовательские данные и передает их в хранилище.
- Репозиторий выполняет любые действия по «предварительному сбору», такие как проверка данных, авторизация, аутентификация и т. Д. Если эти действия по «предварительному сбору» выполнены успешно, запрос передается в модель для обработки.
- Модель обработает все данные на уровне данных и вернет текущее состояние.
- Репозиторий будет обрабатывать любые подпрограммы «после сбора» и возвращать текущее состояние контроллеру.
- Контроллер создаст соответствующий вид, используя информацию, предоставленную хранилищем.
Наш репозиторий становится настолько гибким и организованным, насколько мы создали наши контроллеры и модели, что позволяет нам повторно использовать это в большинстве частей нашей системы, а также позволяет менять его для другой реализации, если это необходимо.
Мы уже видели пример замены репозитория для другой реализации в тестах контроллера выше. Вместо того, чтобы использовать репозиторий по умолчанию, мы попросили контейнер IoC предоставить контроллеру экземпляр объекта Mockery. У нас одинаковая сила для всех наших компонентов.
То, что мы достигли здесь, добавив еще один уровень в наш MVC, это очень организованная, масштабируемая и тестируемая система. Давайте начнем расставлять все по местам и сдавать наши тесты.
Реализация контроллера
Если вы ознакомитесь с тестами контроллера, вы увидите, что все, что нас действительно волнует, это то, как контроллер взаимодействует с хранилищем. Итак, давайте посмотрим, как легко и просто делают наши контроллеры.
Примечание: в TDD цель состоит в том, чтобы выполнять не больше работы, чем требуется для успешного прохождения тестов. Итак, мы хотим сделать абсолютный минимум здесь.
приложение / контроллеры / V1 / PostsController.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
<?php
namespace V1; use BaseController; use PostRepositoryInterface; use Input; use View; class PostsController extends BaseController { /**
* We will use Laravel's dependency injection to auto-magically * "inject" our repository instance into our controller */
public function __construct(PostRepositoryInterface $posts ) {
$this ->posts = $posts ; }
/**
* Display a listing of the resource.
*
* @return Response
*/
public function index()
{
return $this ->posts->findAll(); }
/**
* Show the form for creating a new resource. *
* @return Response
*/
public function create()
{
$post = $this ->posts->instance(); return View::make( 'posts._form' , compact( 'post' )); }
/**
* Store a newly created resource in storage. *
* @return Response
*/
public function store() {
return $this ->posts->store( Input::all() ); }
/**
* Display the specified resource. *
* @param int $id * @return Response
*/
public function show( $id ) {
return $this ->posts->findById( $id ); }
/**
* Show the form for editing the specified resource. *
* @param int $id * @return Response
*/
public function edit( $id ) {
$post = $this ->posts->findById( $id ); return View::make( 'posts._form' , compact( 'post' )); }
/**
* Update the specified resource in storage. *
* @param int $id * @return Response
*/
public function update( $id ) {
return $this ->posts->update( $id , Input::all()); }
/**
* Remove the specified resource from storage. *
* @param int $id * @return Response
*/
public function destroy( $id ) {
$this ->posts->destroy( $id ); return »;
}
}
|
приложение / контроллеры / PostsCommentsController.php
001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
|
<?php
namespace V1; use BaseController; use CommentRepositoryInterface; use Input; use View; class PostsCommentsController extends BaseController { /**
* We will use Laravel's dependency injection to auto-magically * "inject" our repository instance into our controller */
public function __construct(CommentRepositoryInterface $comments ) {
$this ->comments = $comments ; }
/**
* Display a listing of the resource.
*
* @return Response
*/
public function index( $post_id ) {
return $this ->comments->findAll( $post_id ); }
/**
* Show the form for creating a new resource. *
* @return Response
*/
public function create( $post_id ) {
$comment = $this ->comments->instance( array ( 'post_id' => $post_id ));
return View::make( 'comments._form' , compact( 'comment' )); }
/**
* Store a newly created resource in storage. *
* @return Response
*/
public function store( $post_id ) {
return $this ->comments->store( $post_id , Input::all() ); }
/**
* Display the specified resource. *
* @param int $id * @return Response
*/
public function show( $post_id , $id ) {
return $this ->comments->findById( $post_id , $id ); }
/**
* Show the form for editing the specified resource. *
* @param int $id * @return Response
*/
public function edit( $post_id , $id ) {
$comment = $this ->comments->findById( $post_id , $id ); return View::make( 'comments._form' , compact( 'comment' )); }
/**
* Update the specified resource in storage. *
* @param int $id * @return Response
*/
public function update( $post_id , $id ) {
return $this ->comments->update( $post_id , $id , Input::all()); }
/**
* Remove the specified resource from storage. *
* @param int $id * @return Response
*/
public function destroy( $post_id , $id ) {
$this ->comments->destroy( $post_id , $id ); return »;
}
}
|
Это не становится намного проще, все, что контроллеры делают, это передает входные данные в репозиторий, получает ответ от этого и передает его в представление, представление в нашем случае — просто JSON для большинства наших методов , Когда мы возвращаем Eloquent Collection или Eloquent Model из контроллера в Laravel 4, объект автоматически анализируется в JSON, что делает нашу работу очень простой.
Примечание: обратите внимание, что мы добавили еще несколько операторов use в начало файла для поддержки других классов, которые мы используем. Не забывайте об этом, когда вы работаете в пространстве имен.
Единственное, что немного сложно в этом контроллере — это конструктор. Обратите внимание, что мы передаем типизированную переменную как зависимость для этого контроллера, но нет никакого смысла в том, что у нас есть доступ к созданию экземпляра этого контроллера, чтобы фактически вставить этот класс … добро пожаловать в внедрение зависимостей! На самом деле мы намекаем нашему контроллеру, что у нас есть зависимость, необходимая для запуска этого класса, и каково его имя класса (или его имя привязки IoC). Laravel использует App::make()
для создания своих контроллеров перед вызовом их. App::make()
будет пытаться разрешить элемент, ища любые привязки, которые мы могли объявить, и / или используя автозагрузчик для предоставления экземпляра. Кроме того, он также разрешит любые зависимости, необходимые для создания экземпляра этого класса для нас, более или менее рекурсивно вызываяApp::make()
на каждой из зависимостей.
Наблюдатель заметит, что то, что мы пытаемся передать как зависимость, — это интерфейс, и, как вы знаете, интерфейс не может быть создан. Вот где все становится круто, и мы фактически уже сделали то же самое в наших тестах. Однако в наших тестах App::instance()
вместо интерфейса мы использовали уже созданный экземпляр. Что касается наших контроллеров, мы фактически скажем Laravel, что всякий раз, когда PostRepositoryInterface
запрашивается экземпляр , на самом деле возвращать экземпляр EloquentPostRepository
.
Откройте свой app/routes.php
файл и добавьте следующее в начало файла
1
2
|
App::bind( 'PostRepositoryInterface' , 'EloquentPostRepository' ); App::bind( 'CommentRepositoryInterface' , 'EloquentCommentRepository' ); |
После добавления этих строк в любое время App::make()
запрашивается экземпляр PostRepositoryInterface
, который создает экземпляр EloquentPostRepository
, который предполагается реализовать PostRepositoryInterface
. Если вам когда-нибудь нужно будет изменить свой репозиторий, чтобы вместо него использовать ORM, отличный от Eloquent, или, возможно, драйвер на основе файлов, все, что вам нужно сделать, это изменить эти две строки, и все готово, ваши контроллеры будут работать как обычно , Фактическая зависимость Controllers — это любой объект, который реализует этот интерфейс, и мы можем определить во время выполнения, что это за реализация на самом деле.
PostRepositoryInterface
И CommentRepositoryInterface
фактически должны существовать и переплеты должны реально выполнить их. Итак, давайте создадим их сейчас:
Приложение / Хранилище / PostRepositoryInterface.php
01
02
03
04
05
06
07
08
09
10
11
12
|
<?php
interface PostRepositoryInterface { public function findById( $id ); public function findAll(); public function paginate( $limit = null); public function store( $data ); public function update( $id , $data ); public function destroy( $id ); public function validate( $data ); public function instance(); }
|
Приложение / Хранилище / CommentRepositoryInterface.php
01
02
03
04
05
06
07
08
09
10
11
|
<?php
interface CommentRepositoryInterface { public function findById( $post_id , $id ); public function findAll( $post_id ); public function store( $post_id , $data ); public function update( $post_id , $id , $data ); public function destroy( $post_id , $id ); public function validate( $data ); public function instance(); }
|
Now that we have our two interfaces built, we must provide implementations of these interfaces. Let’s build them now.
app/repositories/EloquentPostRepository.php
As the name of this implementation implies, we’re relying on Eloquent, which we can call directly. If you had other dependencies, remember that App::make()
is being used to resolve this repository, so you can feel free to use the same constructor method we used with our Controllers to inject your dependencies.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
|
<?php
class EloquentPostRepository implements PostRepositoryInterface { public function findById( $id ) {
$post = Post::with( array ( 'comments' => function ( $q ) {
$q ->orderBy( 'created_at' , 'desc' ); }
))
->where( 'id' , $id ) ->first(); if (! $post ) throw new NotFoundException( 'Post Not Found' ); return $post;
}
public function findAll() {
return Post::with( array ( 'comments' => function ( $q ) {
$q ->orderBy( 'created_at' , 'desc' ); }
))
->orderBy( 'created_at' , 'desc' ) ->get(); }
public function paginate( $limit = null) {
return Post::paginate( $limit ); }
public function store( $data ) {
$this ->validate( $data ); return Post::create( $data ); }
public function update( $id , $data ) {
$post = $this ->findById( $id ); $post ->fill( $data ); $this ->validate( $post ->toArray()); $post ->save(); return $post;
}
public function destroy( $id ) {
$post = $this ->findById( $id ); $post -> delete (); return true;
}
public function validate( $data ) {
$validator = Validator::make( $data , Post:: $rules ); if ( $validator ->fails()) throw new ValidationException( $validator ); return true;
}
public function instance( $data = array ()) {
return new Post( $data ); }
}
|
app/repositories/EloquentCommentRepository.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
<?php
class EloquentCommentRepository implements CommentRepositoryInterface { public function findById( $post_id , $id ) {
$comment = Comment::find( $id ); if (! $comment || $comment ->post_id != $post_id ) throw new NotFoundException( 'Comment Not Found' ); return $comment ; }
public function findAll( $post_id ) {
return Comment::where( 'post_id' , $post_id ) ->orderBy( 'created_at' , 'desc' ) ->get(); }
public function store( $post_id , $data ) {
$data [ 'post_id' ] = $post_id ; $this ->validate( $data ); return Comment::create( $data ); }
public function update( $post_id , $id , $data ) {
$comment = $this ->findById( $post_id , $id ); $comment ->fill( $data ); $this ->validate( $comment ->toArray()); $comment ->save(); return $comment ; }
public function destroy( $post_id , $id ) {
$comment = $this ->findById( $post_id , $id ); $comment -> delete (); return true;
}
public function validate( $data ) {
$validator = Validator::make( $data , Comment:: $rules ); if ( $validator ->fails()) throw new ValidationException( $validator ); return true;
}
public function instance( $data = array ()) {
return new Comment( $data ); }
}
|
If you take a look in our repositories, there are a few Exceptions that we are throwing, which are not native, nor do they belong to Laravel. Those are custom Exceptions that we’re using to simplify our code. By using custom Exceptions, we’re able to easily halt the progress of the application if certain conditions are met. For instance, if a post is not found, we can just toss a NotFoundException, and the application will handle it accordingly, but, not by showing a 500 error as usual, instead we’re going to setup custom error handlers. You could alternatively use App::abort(404)
or something along those lines, but I find that this method saves me many conditional statements and repeat code, as well as allowing me to adjust the implementation of error reporting in a single place very easily.
First let’s define the custom Exceptions. Create a file in your app
folder called errors.php
1
|
touch app/errors.php |
app/errors.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
<?php
class PermissionException extends Exception { public function __construct( $message = null, $code = 403) {
parent::__construct( $message ?: 'Action not allowed' , $code ); }
}
class ValidationException extends Exception { protected $messages ; /**
* We are adjusting this constructor to receive an instance * of the validator as opposed to a string to save us some typing * @param Validator $validator failed validator object */
public function __construct( $validator ) {
$this ->messages = $validator ->messages(); parent::__construct( $this ->messages, 400); }
public function getMessages() {
return $this ->messages; }
}
class NotFoundException extends Exception { public function __construct( $message = null, $code = 404) {
parent::__construct( $message ?: 'Resource Not Found' , $code ); }
}
|
These are very simple Exceptions, notice for the ValidationException, we can just pass it the failed validator instance and it will handle the error messages accordingly!
Now we need to define our error handlers that will be called when one of these Exceptions are thrown. These are basically Event listeners, whenever one of these exceptions are thrown, it’s treated as an Event and calls the appropriate function. It’s very simple to add logging or any other error handling procedures here.
app/filters.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
…
/**
* General HttpException handler */
App::error( function (Symfony\Component\HttpKernel\Exception\HttpException $e , $code ) {
$headers = $e ->getHeaders(); switch ( $code ) {
case 401: $default_message = 'Invalid API key' ; $headers [ 'WWW-Authenticate' ] = 'Basic realm="CRM REST API"' ; break;
case 403: $default_message = 'Insufficient privileges to perform this action' ; break;
case 404: $default_message = 'The requested resource was not found' ; break;
default:
$default_message = 'An error was encountered' ; }
return Response::json( array ( 'error' => $e ->getMessage() ?: $default_message ), $code , $headers ); });
/**
* Permission Exception Handler */
App::error( function (PermissionException $e , $code ) {
return Response::json( $e ->getMessage(), $e ->getCode()); });
/**
* Validation Exception Handler */
App::error( function (ValidationException $e , $code ) {
return Response::json( $e ->getMessages(), $code ); });
/**
* Not Found Exception Handler */
App::error( function (NotFoundException $e ) {
return Response::json( $e ->getMessage(), $e ->getCode()); });
|
We now need to let our auto-loader know about these new files. So we must tell Composer where to check for them:
composer.json
Notice that we added the "app/errors.php"
line.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
{
«require»: {
"laravel/framework" : "4.0.*" , "way/generators" : "dev-master" , "twitter/bootstrap" : "dev-master" , "conarwelsh/mustache-l4" : "dev-master" },
«require-dev»: {
"phpunit/phpunit" : "3.7.*" , "mockery/mockery" : "0.7.*" },
«autoload»: {
«classmap»: [
«app/commands»,
«app/controllers»,
«app/models»,
«app/database/migrations»,
"app/database/seeds" , "app/tests/TestCase.php" , "app/repositories" , "app/errors.php" ]
},
«scripts»: {
"post-update-cmd" : "php artisan optimize" },
"minimum-stability" : "dev" }
|
We must now tell Composer to actually check for these files and include them in the auto-load registry.
1
|
composer dump-autoload |
Great, so we have completed our controllers and our repositories, the last two items in our MVRC that we have to take care of is the models and views, both of which are pretty straight forward.
app/models/Post.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
<?php
/**
* Represent a Post Item, or Collection */
class Post extends Eloquent { /**
* Items that are "fillable" * meaning we can mass-assign them from the constructor * or $post->fill() * @var array
*/
protected $fillable = array ( 'title' , 'content' , 'author_name' );
/**
* Validation Rules * this is just a place for us to store these, you could * alternatively place them in your repository * @var array
*/
public static $rules = array ( 'title' => 'required' , 'author_name' => 'required' );
/**
* Define the relationship with the comments table * @return Collection collection of Comment Models */
public function comments() {
return $this ->hasMany( 'Comment' ); }
}
|
app/models/Comment.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
<?php
/**
* Represent a Comment Item, or Collection */
class Comment extends Eloquent { /**
* Items that are "fillable" * meaning we can mass-assign them from the constructor * or $comment->fill() * @var array
*/
protected $fillable = array ( 'post_id' , 'content' , 'author_name' );
/**
* Validation Rules * this is just a place for us to store these, you could * alternatively place them in your repository * @var array
*/
public static $rules = array ( 'post_id' => 'required|numeric' , 'content' => 'required' , 'author_name' => 'required' );
/**
* Define the relationship with the posts table * @return Model parent Post model */
public function post() {
return $this ->belongsTo( 'Post' ); }
}
|
As far as views are concerned, I’m just going to mark up some simple bootstrap-friendly pages. Remember to change each files extension to .mustache
though, since our generator thought that we would be using .blade.php
. We’re also going to create a few «partial» views using the Rails convention of prefixing them with an _
to signify a partial.
Note: I skipped a few views, as we will not be using them in this tutorial.
public/views/posts/index.mustache
For the index
page view we’ll just loop over all of our posts, showing the post partial for each.
1
2
3
|
{{#posts}} {{> posts._post}} {{/posts}} |
public/views/posts/show.mustache
For the show
view we’ll show an entire post and its comments:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
|
<article>
<h3>
{{ post.title }} {{ post.id }} < small >{{ post.author_name }}</ small > </h3>
<div>
{{ post.content }} </div>
</article>
<div>
< h2 >Add A Comment</ h2 > {{> comments._form }} < section data-role = "comments" > {{#post.comments}} <div>
{{> comments._comment }} </div>
{{/post.comments}} </section>
</div>
|
public/views/posts/_post.mustache
Here’s the partial that we’ll use to show a post
in a list. This is used on our index
view.
1
2
3
4
|
< article data-toggle = "view" data-target = "posts/{{ id }}" > < h3 >{{ title }} {{ id }}</ h3 > < cite >{{ author_name }} on {{ created_at }}</ cite > </article>
|
public/views/posts/_form.mustache
Here’s the form
partial needed to create a post, we’ll use this from our API, but this could also be a useful view in an admin panel and other places, which is why we choose to make it a partial.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
{{#exists}} < form action = "/v1/posts/{{ post.id }}" method = "post" > < input type = "hidden" name = "_method" value = "PUT" /> {{/exists}} {{^exists}} < form action = "/v1/posts" method = "post" > {{/exists}} <fieldset>
< div class = "control-group" > < label class = "control-label" ></ label > < div class = "controls" > < input type = "text" name = "title" value = "{{ post.title }}" /> </div>
</div>
< div class = "control-group" > < label class = "control-label" ></ label > < div class = "controls" > < input type = "text" name = "author_name" value = "{{ post.author_name }}" /> </div>
</div>
< div class = "control-group" > < label class = "control-label" ></ label > < div class = "controls" > < textarea name = "content" >{{ post.content }}"</ textarea > </div>
</div>
<div class=»form-actions»>
< input type = "submit" class = "btn btn-primary" value = "Save" /> </div>
</fieldset>
</form>
|
public/views/comments/_comment.mustache
Here’s the comment
partial which is used to represent a single comment in a list of comments:
1
2
3
4
5
6
7
|
<h5>
{{ author_name }} < small >{{ created_at }}</ small > </h5>
<div>
{{ content }} </div>
|
public/views/comments/_form.mustache
The form needed to create a comment — both used in the API and the Show Post view:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
{{#exists}} < form class = "form-horizontal" action = "/v1/posts/{{ comment.post_id }}/{{ id }}" method = "post" > < input type = "hidden" name = "_method" value = "PUT" /> {{/exists}} {{^exists}} < form class = "form-horizontal" action = "/v1/posts/{{ comment.post_id }}" method = "post" > {{/exists}} <fieldset>
< div class = "control-group" > < label class = "control-label" >Author Name</ label > < div class = "controls" > < input type = "text" name = "author_name" value = "{{ comment.author_name }}" /> </div>
</div>
< div class = "control-group" > < label class = "control-label" >Comment</ label > < div class = "controls" > < textarea name = "content" >{{ comment.content }}</ textarea > </div>
</div>
<div class=»form-actions»>
< input type = "submit" class = "btn btn-primary" value = "Save" /> </div>
</fieldset>
</form>
|
public/views/layouts/_notification.mustache
And here’s the helper view partial to allow us to show a notification:
1
2
3
|
< div class = "alert alert-{{type}}" > {{message}} </div>
|
Great, we have all of our API components in place. Let’s run our unit tests to see where we’re at!
1
|
vendor/phpunit/phpunit/phpunit.php |
Your first run of this test should pass with flying (green) colors. However, if you were to run this test again, you’ll notice that it fails now with a handful of errors, and that is because our repository tests actually tested the database, and in doing so deleted some of the records our previous tests used to assert values. This is an easy fix, all we have to do is tell our tests that they need to re-seed the database after each test. In addition, we did not receive a noticable error for this, but we did not close Mockery after each test either, this is a requirement of Mockery that you can find in their docs. So let’s add both missing methods.
Open up app/tests/TestCase.php
and add the following two methods:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
/**
* setUp is called prior to each test */
public function setUp()
{
parent::setUp();
$this ->seed(); }
/**
* tearDown is called after each test * @return [type] [description] */
public function tearDown()
{
Mockery::close(); }
|
This is great, we now said that at every «setUp», which is run before each test, to re-seed the database. However we still have one problem, everytime you re-seed, it’s only going to append new rows to the tables. Our tests are looking for items with a row ID of one, so we still have a few changes to make. We just need to tell the database to truncate our tables when seeding:
app/database/seeds/CommentsTableSeeder.php
Before we insert the new rows, we’ll truncate the table, deleting all rows and resetting the auto-increment counter.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
<?php
class CommentsTableSeeder extends Seeder { public function run() {
$comments = array ( array(
'content' => 'Lorem ipsum Nisi dolore ut incididunt mollit tempor proident eu velit cillum dolore sed' , 'author_name' => 'Testy McTesterson' , 'post_id' => 1, 'created_at' => date ( 'Ymd H:i:s' ), 'updated_at' => date ( 'Ymd H:i:s' ), ),
array(
'content' => 'Lorem ipsum Nisi dolore ut incididunt mollit tempor proident eu velit cillum dolore sed' , 'author_name' => 'Testy McTesterson' , 'post_id' => 1, 'created_at' => date ( 'Ymd H:i:s' ), 'updated_at' => date ( 'Ymd H:i:s' ), ),
array(
'content' => 'Lorem ipsum Nisi dolore ut incididunt mollit tempor proident eu velit cillum dolore sed' , 'author_name' => 'Testy McTesterson' , 'post_id' => 2, 'created_at' => date ( 'Ymd H:i:s' ), 'updated_at' => date ( 'Ymd H:i:s' ), ),
);
//truncate the comments table when we seed DB::table( 'comments' )->truncate(); DB::table( 'comments' )->insert( $comments ); }
}
|
app/database/seeds/PostsTableSeeder.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
<?php
class PostsTableSeeder extends Seeder { public function run() {
$posts = array(
array(
'title' => 'Test Post' , 'content' => 'Lorem ipsum Reprehenderit velit est irure in enim in magna aute occaecat qui velit ad.' , 'author_name' => 'Conar Welsh' , 'created_at' => date ( 'Ymd H:i:s' ), 'updated_at' => date ( 'Ymd H:i:s' ), ),
array(
'title' => 'Another Test Post' , 'content' => 'Lorem ipsum Reprehenderit velit est irure in enim in magna aute occaecat qui velit ad.' , 'author_name' => 'Conar Welsh' , 'created_at' => date ( 'Ymd H:i:s' ), 'updated_at' => date ( 'Ymd H:i:s' ), )
);
//truncate the posts table each time we seed DB::table( 'posts' )->truncate(); DB::table( 'posts' )->insert( $posts ); }
}
|
Now you should be able to run the tests any number of times and get passing tests each time! That means we have fulfilled our TDD cycle and we’re not allowed to write anymore production code for our API!! Let’s just commit our changes to our repo and move onto the Backbone application!
1
|
git add .
|
Backbone App
Now that we have completed all of the back-end work, we can move forward to creating a nice user interface to access all of that data. We’ll keep this part of the project a little bit on the simpler side, and I warn you that my approach can be considered an opinionated one. I have seen many people with so many different methods for structuring a Backbone application. My trials and errors have led me to my current method, if you do not agree with it, my hope is that it may inspire you to find your own!
We’re going to use the Mustache templating engine instead of Underscore, this will allow us to share our views between the client and server! The trick is in how you load the views, we’re going to use AJAX in this tutorial, but it’s just as easy to load them all into the main template, or precompile them.
маршрутизатор
First we’ll get our router going. There are two parts to this, the Laravel router, and the Backbone router.
Laravel Router
There are two main approaches we can take here:
Approach #1: The catch-all
Remember I told you when you were adding the resource routes that it was important that you placed them ABOVE the app route?? The catch-all method is the reason for that statement. The overall goal of this method is to have any routes that have not found a match in Laravel, be caught and sent to Backbone. Implementing this method is easy:
Приложение / routes.php
1
2
3
4
5
6
7
|
// change your existing app route to this: // we are basically just giving it an optional parameter of "anything" Route::get( '/{path?}' , function ( $path = null) {
return View::make( 'app' ); })
->where( 'path' , '.*' ); //regex to match anything (dots, slashes, letters, numbers, etc) |
Now, every route other than our API routes will render our app view.
In addition, if you have a multi-page app (several single page apps), you can define several of these catch-alls:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
Route::get( 'someApp1{path?}' , function ( $path = null) {
return View::make( 'app' ); })
->where( 'path' , '.*' ); Route::get( 'anotherApp/{path?}' , function ( $path = null) {
return View::make( 'app' ); })
->where( 'path' , '.*' ); Route::get( 'athirdapp{path?}' , function ( $path = null) {
return View::make( 'app' ); })
->where( 'path' , '.*' ); |
Note: Keep in mind the ‘/’ before {path?}. If that slash is there, it’ll be required in the URL (with the exception of the index route), sometimes this is desired and sometimes not.
Approach #2:
Since our front and back end share views… wouldn’t it be extremely easy to just define routes in both places? You can even do this in addition to the catch-all approach if you want.
The routes that we’re going to end up defining for the app are simply:
1
2
|
GET / GET /posts/:id |
app/routes.php
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
<?php
App::bind( 'PostRepositoryInterface' , 'EloquentPostRepository' ); App::bind( 'CommentRepositoryInterface' , 'EloquentCommentRepository' ); //create a group of routes that will belong to APIv1 Route::group( array ( 'prefix' => 'v1' ), function () {
Route::resource( 'posts' , 'V1\PostsController' ); Route::resource( 'posts.comments' , 'V1\PostsCommentsController' ); });
/**
* Method #1: use catch-all * optionally commented out while we use Method 2 */
// change your existing app route to this: // we are basically just giving it an optional parameter of "anything" // Route::get('/{path?}', function($path = null) // { // return View::make('layouts.application')->nest('content', 'app'); // }) // ->where('path', '.*'); //regex to match anything (dots, slashes, letters, numbers, etc) /**
* Method #2: define each route */
Route::get( '/' , function () {
$posts = App::make( 'PostRepositoryInterface' )->paginate(); return View::make( 'layouts.application' )->nest( 'content' , 'posts.index' , array ( 'posts' => $posts ));
});
Route::get( 'posts/{id}' , function ( $id ) {
$post = App::make( 'PostRepositoryInterface' )->findById( $id ); return View::make( 'layouts.application' )->nest( 'content' , 'posts.show' , array ( 'post' => $post ));
});
|
Pretty cool huh?! Regardless of which method we use, or the combination of both, your Backbone router will end up mostly the same.
Notice that we’re using our Repository again, this is yet another reason why Repositories are a useful addition to our framework. We can now run almost all of the logic that the controller does, but without repeating hardly any of the code!
Keep in mind a few things while choosing which method to use, if you use the catch-all, it will do just like the name implies… catch- ALL . This means there is no such thing as a 404 on your site anymore. No matter the request, its landing on the app page (unless you manually toss an exception somewhere such as your repository). The inverse is, with defining each route, now you have two sets of routes to manage. Both methods have their ups and downs, but both are equally easy to deal with.
Base View
One view to rule them all! This BaseView
is the view that all of our other Views will inherit from. For our purposes, this view has but one job… templating! In a larger app this view is a good place to put other shared logic.
We’ll simply extend Backbone.View
and add a template
function that will return our view from the cache if it exists, or get it via AJAX and place it in the cache. We have to use synchronous AJAX due to the way that Mustache.js fetches partials, but since we’re only retrieving these views if they are not cached, we shouldn’t receive much of a performance hit here.
001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
|
/**
*************************************** * Array Storage Driver * used to store our views *************************************** */
var ArrayStorage = function (){ this .storage = {}; };
ArrayStorage.prototype.get = function (key) {
return this .storage[key]; };
ArrayStorage.prototype.set = function (key, val) {
return this .storage[key] = val; };
/**
*************************************** * Base View *************************************** */
var BaseView = bb.View.extend({ /**
* Set our storage driver */
templateDriver: new ArrayStorage, /**
* Set the base path for where our views are located */
viewPath: '/views/' , /**
* Get the template, and apply the variables */
template: function () {
var view, data, template, self; switch (arguments.length) {
case 1:
view = this .view; data = arguments[0]; break;
case 2:
view = arguments[0]; data = arguments[1]; break;
}
template = this .getTemplate(view, false ); self = this ; return template(data, function (partial) {
return self.getTemplate(partial, true ); });
},
/**
* Facade that will help us abstract our storage engine, * should we ever want to swap to something like LocalStorage */
getTemplate: function (view, isPartial) {
return this .templateDriver.get(view) || this .fetch(view, isPartial); },
/**
* Facade that will help us abstract our storage engine, * should we ever want to swap to something like LocalStorage */
setTemplate: function (name, template) {
return this .templateDriver.set(name, template); },
/**
* Function to retrieve the template via ajax */
fetch: function (view, isPartial) {
var markup = $.ajax({ async: false,
//the URL of our template, we can optionally use dot notation url: this .viewPath + view.split( '.' ).join( '/' ) + '.mustache' }).responseText; return isPartial ?
: this .setTemplate(view, Mustache.compile(markup)); }
});
|
PostView
The PostView
renders a single blog post:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
// this view will show an entire post // comment form, and comments var PostView = BaseView.extend({ //the location of the template this view will use, we can use dot notation view: 'posts.show' , //events this view should subscribe to events: {
'submit form' : function (e) {
e.preventDefault();
e.stopPropagation();
return this .addComment( $(e.target).serialize() ); }
},
//render our view into the defined `el` render: function () {
var self = this;
self.$el.html( this .template({ post: this .model.attributes }) ); },
//add a comment for this post addComment: function (formData) {
var
self = this , //build our url action = this .model.url() + '/comments' ;
//submit a post to our api $.post(action, formData, function (comment, status, xhr) {
//create a new comment partial var view = new CommentViewPartial({ //we are using a blank backbone model, since we done have any specific logic needed model: new bb.Model(comment) });
//prepend the comment partial to the comments list view.render().$el.prependTo(self.$( '[data-role="comments"]' )); //reset the form self.$( 'input[type="text"], textarea' ).val( '' ); //prepend our new comment to the collection self.model.attributes.comments.unshift(comment); //send a notification that we successfully added the comment notifications.add({ type: 'success' , message: 'Comment Added!' });
});
}
});
|
Partial Views
We’ll need a few views to render partials. We mainly just need to tell the view which template to use and that it should extend our view that provides the method to fetch our template.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
|
// this will be used for rendering a single comment in a list var CommentViewPartial = BaseView.extend({ //define our template location view: 'comments._comment' , render: function () {
this .$el.html( this .template( this .model.attributes) ); return this;
}
});
//this view will be used for rendering a single post in a list var PostViewPartial = BaseView.extend({ //define our template location view: 'posts._post' , render: function () {
this .$el.html( this .template( this .model.attributes) ); return this;
}
});
|
Blog View
This is our overall application view. It contains our configuration logic, as well as handling the fetching of our PostCollection
. We also setup a cool little infinite scroll feature. Notice how we’re using jQuery promises to ensure that the fetching of our collection has completed prior to rendering the view.
001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
|
var Blog = BaseView.extend({ //define our template location view: 'posts.index' , //setup our app configuration initialize: function () {
this .perPage = this .options.perPage || 15; 0;
this .fetching = this .collection.fetch(); if ( this .options.infiniteScroll) this .enableInfiniteScroll(); },
//wait til the collection has been fetched, and render the view render: function () {
var self = this;
this .fetching.done( function () {
self.$el.html( '' ); self.addPosts(); // var posts = this.paginate() // for(var i=0; i<posts.length; i++) // { // posts[i] = posts[i].toJSON(); // } // self.$el.html( self.template({ // posts: posts // }) ); if (self.options.infiniteScroll) self.enableInfiniteScroll(); });
},
//helper function to limit the amount of posts we show at a time paginate: function () {
var posts; posts = this .collection.rest( this .perPage * this .page); posts = _.first(posts, this .perPage); this .page++; return posts;
},
//add the next set of posts to the view addPosts: function () {
var posts = this .paginate(); for ( var i=0; i<posts.length; i++) {
this .addOnePost( posts[i] ); }
},
//helper function to add a single post to the view addOnePost: function (model) {
var view = new PostViewPartial({ model: model
});
this .$el.append( view.render().el ); },
//this function will show an entire post, we could alternatively make this its own View //however I personally like having it available in the overall application view, as it //makes it easier to manage the state showPost: function (id) {
var self = this;
this .disableInifiniteScroll(); this .fetching.done( function () {
var model = self.collection.get(id); if (!self.postView) {
self.postView = new self.options.postView({ el: self.el });
}
self.postView.model = model; self.postView.render(); });
},
//function to run during the onScroll event infiniteScroll: function () {
if ($window.scrollTop() >= $document.height() - $window.height() - 50) {
this .addPosts(); }
},
//listen for the onScoll event enableInfiniteScroll: function () {
var self = this;
$window.on( 'scroll' , function () {
self.infiniteScroll(); });
},
//stop listening to the onScroll event disableInifiniteScroll: function () {
$window.off( 'scroll' ); }
});
|
PostCollection
Setup our PostCollection
— we just need to tell the Collection the URL it should use to fetch its contents.
1
2
3
4
5
|
// the posts collection is configured to fetch // from our API, as well as use our PostModel var PostCollection = bb.Collection.extend({ url: '/v1/posts' });
|
Blog Router
Notice that we’re not instantiating new instances of our views, we’re merely telling them to render. Our initialize functions are designed to only be ran once, as we don’t want them to run but once, on page load.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
var BlogRouter = bb.Router.extend({ routes: {
"" : "index" , "posts/:id" : "show" },
initialize: function (options) {
// i do this to avoid having to hardcode an instance of a view // when we instantiate the router we will pass in the view instance this .blog = options.blog; },
index: function () {
//reset the paginator this .blog.page = 0; //render the post list this .blog.render(); },
show: function (id) {
//render the full-post view this .blog.showPost(id); }
});
|
Notifications Collection
We’re just going to setup a simple Collection to store user notifications:
1
|
var notifications = new bb.Collection(); |
NotificationsView
This view will handle the displaying and hiding of user notifications:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
var NotificationView = BaseView.extend({ el: $( '#notifications' ), view: 'layouts._notification' , initialize: function () {
this .listenTo(notifications, 'add' , this .render); },
render: function (notification) {
var $message = $( this .template(notification.toJSON()) ); this .$el.append($message); this .delayedHide($message); },
delayedHide: function ($message) {
var timeout = setTimeout( function () {
$message.fadeOut( function () {
$message.remove(); });
}, 5*1000); var self = this;
$message.hover( function()
{
timeout = clearTimeout(timeout); },
function()
{
self.delayedHide($message); }
);
}
});
var notificationView = new NotificationView(); |
Обработка ошибок
Since we used the custom exception handlers for our API, it makes it very easy to handle any error our API may throw. Very similar to the way we defined our event listeners for our API in the app/filters.php
file, we’ll define event listeners for our app here. Each code that could be thrown can just show a notification very easily!
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
$.ajaxSetup({
statusCode: { 401: function () {
notification.add({ type: null , //error, success, info, null message: 'You do not have permission to do that' });
},
403: function () {
notification.add({ type: null , //error, success, info, null message: 'You do not have permission to do that' });
},
404: function () {
notification.add({ type: 'error' , //error, success, info, null message: '404: Page Not Found' });
},
500: function () {
notification.add({ type: 'error' , //error, success, info, null message: 'The server encountered an error' });
}
}
});
|
Слушатели событий
We’ll need a few global event listeners to help us navigate through our app without refreshing the page. We mainly just hijack the default behavior and call Backbone.history.navigate()
. Notice how on our first listener, we’re specifying the selector to only match those that don’t have a data attribute of bypass
. This will allow us to create links such as <a href="/some/non-ajax/page" data-bypass="true">link</a>
that will force the page to refresh. We could also go a step further here and check whether the link is a local one, as opposed to a link to another site.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
|
$document.on( "click" , "a[href]:not([data-bypass])" , function (e){ e.preventDefault();
e.stopPropagation();
var href = $( this ).attr( "href" ); bb.history.navigate(href, true ); });
$document.on( "click" , "[data-toggle='view']" , function (e) {
e.preventDefault();
e.stopPropagation();
var
self = $( this ), href = self.attr( 'data-target' ) || self.attr( 'href' ) ;
bb.history.navigate(href, true ); });
|
Start The App
Now we just need to boot the app, passing in any config values that we need. Notice the line that checks for the silentRouter
global variable, this is kind of a hacky way to be able to use both back-end routing methods at the same time. This allows us to define a variable in the view called silentRouter
and set it to true, meaning that the router should not actually engage the backbone route, allowing our back-end to handle the initial rendering of the page, and just wait for any needed updates or AJAX.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
|
var BlogApp = new Blog({ el : $( '[data-role="main"]' ), collection : new PostCollection(), postView : PostView, perPage : 15, page : 0, infiniteScroll : true });
var router = new BlogRouter({ blog: BlogApp });
if ( typeof window.silentRouter === 'undefined' ) window.silentRouter = true ; bb.history.start({ pushState: true , root: '/' , silent: window.silentRouter }); |
Вывод
Notice that for the Backbone portion of our app, all we had to do was write some Javascript that knew how to interact with the pre-existing portions of our application? That’s what I love about this method! It may seem like we had a lot of steps to take to get to that portion of things, but really, most of that work was just a foundation build-up. Once we got that initial foundation in place, the actual application logic falls together very simply.
Try adding another feature to this blog, such as User listings and info. The basic steps you would take would be something like this:
- Use the generator tool to create a new «User» resource.
- Make the necessary modifications to ensure that the UserController is in the V1 API group.
- Create your Repository and setup the proper IoC bindings in
app/routes.php
. - Write your Controller tests one at a time using Mockery for the repository, following each test up with the proper implementation to make sure that test passes.
- Write your Repository tests one at a time, again, following each test up with the implementation.
- Add in the new functionality to your Backbone App. I suggest trying two different approaches to the location of the User views. Decide for yourself which is the better implementation.
- First place them in their own routes and Main view.
- Then try incorporating them into the overall BlogView.
I hope this gave you some insight into creating a scalable single page app and API using Laravel 4 and Backbone.js. If you have any questions, please ask them in the comment section below!