Более двух лет назад я написал статью о том, как два реализуют элегантный CRUD в Struts2 . На самом деле мне пришлось посвятить две статьи на эту тему, потому что тема была очень широкой. Сегодня я выбрал гораздо более легкий и современный подход с набором популярных и хорошо зарекомендовавших себя фреймворков и библиотек. А именно, мы будем использовать Spring MVC на серверной части для предоставления REST-интерфейса нашим ресурсам, потрясающий плагин jqGrid для jQuery для рендеринга табличных сеток (и многое другое!), И мы соединим все с помощью JavaScript и AJAX.
Back-end на самом деле является наименее интересной частью этой демонстрации, он мог бы быть реализован с использованием любой серверной технологии, способной обрабатывать запросы RESTful, вероятно, JAX-RS теперь следует считать стандартным в этой области. Я выбрал Spring MVC без веской причины, но это тоже неплохой выбор для этой задачи. Мы представим операции CRUD через интерфейс REST; список самых продаваемых книг по истории станет нашей доменной моделью (можете ли вы догадаться, кто на подиуме?)
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@Controller @RequestMapping (value = "/book" ) public class BookController { private final Map<Integer, Book> books = new ConcurrentSkipListMap<Integer, Book>(); @RequestMapping (value = "/{id}" , method = GET) public @ResponseBody Book read( @PathVariable ( "id" ) int id) { return books.get(id); } @RequestMapping (method = GET) public @ResponseBody Page<Book> listBooks( @RequestParam (value = "page" , required = false , defaultValue = "1" ) int page, @RequestParam (value = "max" , required = false , defaultValue = "20" ) int max) { final ArrayList<Book> booksList = new ArrayList<Book>(books.values()); final int startIdx = (page - 1 ) * max; final int endIdx = Math.min(startIdx + max, books.size()); return new Page<Book>(booksList.subList(startIdx, endIdx), page, max, books.size()); } } |
Мало что нуждается в объяснении. Прежде всего, для целей этой простой демонстрации я не использовал никакой базы данных, все книги хранятся в карте памяти внутри контроллера. Прости меня. Второй вопрос более тонкий. Поскольку, похоже, нет соглашения о том, как обрабатывать пейджинг с помощью веб-сервисов RESTful, я использовал простые параметры запроса. Это может показаться уродливым, но я нахожу злоупотребление заголовками Accept-Ranges и Range вместе с 206 HTTP-кодом ответа еще более уродливым.
Последней заметной деталью является класс обёртки страницы:
01
02
03
04
05
06
07
08
09
10
11
12
|
@XmlRootElement public class Page<T> { private List<T> rows; private int page; private int max; private int total; //... } |
Я мог бы вернуть необработанный список (или, точнее, запрошенную часть списка), но мне также нужен способ предоставления удобных метаданных, таких как общее количество записей, в слой представления, не говоря уже о некоторых трудностях при маршалинге / демаршаллинге необработанных списков ,
Теперь мы готовы запустить наше приложение и провести небольшой тест-драйв с помощью curl:
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
|
<!-- $ curl -v "http://localhost:8080/books/rest/book?page=1&max=2" --> <? xml version = "1.0" encoding = "UTF-8" standalone = "yes" ?> < page > < total >43</ total > < page >1</ page > < max >3</ max > < author >Charles Dickens</ author > < available >true</ available > < cover >PAPERBACK</ cover > < id >1</ id > < publishedYear >1859</ publishedYear > < title >A Tale of Two Cities</ title > </ rows > < author >J. R. R. Tolkien</ author > < available >true</ available > < cover >HARDCOVER</ cover > < id >2</ id > < publishedYear >1954</ publishedYear > < title >The Lord of the Rings</ title > </ rows > < author >J. R. R. Tolkien</ author > < available >true</ available > < cover >PAPERBACK</ cover > < id >3</ id > < publishedYear >1937</ publishedYear > < title >The Hobbit</ title > </ rows > </ page > |
Тип ответа по умолчанию равен XML, если ничего не указано, но если мы добавим библиотеку Джексона в CLASSPATH, Spring подхватит ее и позволит нам также использовать 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
28
29
30
31
32
33
34
35
36
|
// $ curl -v -H "Accept: application/json" "http://localhost:8080/books/rest/book?page=1&max=3" { "total" : 43 , "max" : 3 , "page" : 1 , "rows" :[ { "id" : 1 , "available" : true , "author" : "Charles Dickens" , "title" : "A Tale of Two Cities" , "publishedYear" : 1859 , "cover" : "PAPERBACK" , "comments" : null }, { "id" : 2 , "available" : true , "author" : "J. R. R. Tolkien" , "title" : "The Lord of the Rings" , "publishedYear" : 1954 , "cover" : "HARDCOVER" , "comments" : null }, { "id" : 3 , "available" : true , "author" : "J. R. R. Tolkien" , "title" : "The Hobbit" , "publishedYear" : 1937 , "cover" : "PAPERBACK" , "comments" : null } ] } |
Хорошо, теперь мы можем работать с клиентом, надеясь, что наши руки не станут слишком грязными. Что касается разметки HTML, это все, что нам нужно, серьезно:
1
2
|
< table id = "grid" ></ table > < div id = "pager" ></ div > |
Имейте в виду, что мы будем реализовывать все операции CRUD, но, тем не менее, это все, что нам нужно. Нет больше HTML. Остальное волшебство происходит благодаря чудесной библиотеке jqGrid. Вот базовая настройка:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
|
$( "#grid" ) .jqGrid({ url: 'rest/book' , colModel:[ {name: 'id' , label: 'ID' , formatter: 'integer' , width: 40 }, {name: 'title' , label: 'Title' , width: 300 }, {name: 'author' , label: 'Author' , width: 200 }, {name: 'publishedYear' , label: 'Published year' , width: 80 , align: 'center' }, {name: 'available' , label: 'Available' , formatter: 'checkbox' , width: 46 , align: 'center' } ], caption: "Books" , pager : '#pager' , height: 'auto' }) .navGrid( '#pager' , {edit: false ,add: false ,del: false , search: false }); |
Технически это все, что нам нужно. URL-адрес для получения данных, указывающий на наш контроллер (jqGrid выполнит для нас всю магию AJAX) и модель данных (вы можете распознать поля книги и их описания). Однако, поскольку jqGrid обладает широкими возможностями настройки, я применил несколько настроек, чтобы сетка выглядела немного лучше. Также мне не понравились предложенные имена метаданных, например, общее поле, возвращаемое с сервера, должно быть общим числом страниц, а не записей, что крайне нелогично. Вот мои настройки:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
|
$.extend($.jgrid.defaults, { datatype: 'json' , jsonReader : { repeatitems: false , total: function(result) { //Total number of pages return Math.ceil(result.total / result.max); }, records: function(result) { //Total number of records return result.total; } }, prmNames: {rows: 'max' , search: null }, height: 'auto' , viewrecords: true , rowList: [ 10 , 20 , 50 , 100 ], altRows: true , loadError: function(xhr, status, error) { alert(error); } }); |
Хотите увидеть результаты? Вот скриншот браузера:
Хорошо выглядит, с настраиваемой подкачкой страниц, легким освежающим напитком … И наши руки все еще относительно чисты! Но я обещал CRUD … Если вы были осторожны, вы, вероятно, заметили несколько атрибутов navGrid, которые умирали, чтобы их включить:
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
|
var URL = 'rest/book' ; var options = { url: URL, editurl: URL, colModel:[ { name: 'id' , label: 'ID' , formatter: 'integer' , width: 40 , editable: true , editoptions: {disabled: true , size: 5 } }, { name: 'title' , label: 'Title' , width: 300 , editable: true , editrules: {required: true } }, { name: 'author' , label: 'Author' , width: 200 , editable: true , editrules: {required: true } }, { name: 'cover' , label: 'Cover' , hidden: true , editable: true , edittype: 'select' , editrules: {edithidden: true }, editoptions: { value: { 'PAPERBACK' : 'paperback' , 'HARDCOVER' : 'hardcover' , 'DUST_JACKET' : 'dust jacket' } } }, { name: 'publishedYear' , label: 'Published year' , width: 80 , align: 'center' , editable: true , editrules: {required: true , integer: true }, editoptions: {size: 5 , maxlength: 4 } }, { name: 'available' , label: 'Available' , formatter: 'checkbox' , width: 46 , align: 'center' , editable: true , edittype: 'checkbox' , editoptions: {value: "true:false" } }, { name: 'comments' , label: 'Comments' , hidden: true , editable: true , edittype: 'textarea' , editrules: {edithidden: true } } ], caption: "Books" , pager : '#pager' , height: 'auto' }; $( "#grid" ) .jqGrid(options) .navGrid( '#pager' , {edit: true ,add: true ,del: true , search: false }); |
Конфигурация становится опасно многословной, но там нет ничего сложного — для каждого поля мы добавили несколько дополнительных атрибутов, управляющих тем, как это поле должно обрабатываться в режиме редактирования. Это включает в себя, какой тип ввода HTML должен представлять его, правила проверки, видимость и т. Д. Но, честно говоря, я считаю, что это того стоило:
Это красиво выглядящее окно редактирования было полностью сгенерировано jqGrid на основе наших опций редактирования, упомянутых выше, включая логику проверки. Мы можем сделать некоторые поля видимыми в сетке скрытыми / неактивными в диалоге редактирования (например, id) и наоборот (обложка и комментарии в сетке отсутствуют, однако вы можете изменить их). Также обратите внимание на несколько новых значков, видимых в левом нижнем углу сетки. Добавление и удаление также возможно — и мы не написали ни одной строки HTML / JSP / JavaScript (исключая объект конфигурации jqGrid).
Конечно, мы все знаем, что пользовательский интерфейс — это приложение , и наш интерфейс довольно хороший, однако иногда нам очень хочется красивое и работающее приложение. И в настоящее время последнее требование — наша ахиллесова пята. Не потому, что серверная часть не готова, это довольно тривиально:
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
|
@Controller @RequestMapping (value = "/book" ) public class BookController { private final Map<Integer, Book> books = new ConcurrentSkipListMap<Integer, Book>(); @RequestMapping (value = "/{id}" , method = GET) public @ResponseBody Book read( @PathVariable ( "id" ) int id) { //... } @RequestMapping (method = GET) public @ResponseBody Page<Book> listBooks( @RequestParam (value = "page" , required = false , defaultValue = "1" ) int page, @RequestParam (value = "max" , required = false , defaultValue = "20" ) int max) { //... } @RequestMapping (value = "/{id}" , method = PUT) @ResponseStatus (HttpStatus.NO_CONTENT) public void updateBook( @PathVariable ( "id" ) int id, @RequestBody Book book) { //... } @RequestMapping (method = POST) public ResponseEntity<String> createBook(HttpServletRequest request, @RequestBody Book book) { //... } @RequestMapping (value = "/{id}" , method = DELETE) @ResponseStatus (HttpStatus.NO_CONTENT) public void deleteBook( @PathVariable ( "id" ) int id) { //... } } |
Серверная сторона готова, но когда дело доходит до манипулирования данными на клиентской стороне, jqGrid раскрывает свой грязный секрет — весь трафик на сервер отправляется с использованием POST следующим образом:
1
2
|
Content-Type: application /x-www-form-urlencoded in the following format : id =&title=And+Then+There+Were+None&author=Agatha+Christie&cover=PAPERBACK&publishedYear=1939&available= true &comments=&oper=add |
Последний атрибут (oper = add) имеет решающее значение. Не совсем идиоматический REST, тебе не кажется? Если бы мы могли только надлежащим образом использовать POST / PUT / DELETE и сериализовать данные, используя JSON или XML … Изменение моего сервера таким образом, чтобы он был совместим с некоторой библиотекой JavaScript (независимо от того, насколько это круто), кажется последним средством. К счастью, все можно настроить с умеренным объемом работы.
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
|
$.extend($.jgrid.edit, { ajaxEditOptions: { contentType: "application/json" }, mtype: 'PUT' , serializeEditData: function(data) { delete data.oper; return JSON.stringify(data); } }); $.extend($.jgrid.del, { mtype: 'DELETE' , serializeDelData: function() { return "" ; } }); var URL = 'rest/book' ; var options = { url: URL, //... } var editOptions = { onclickSubmit: function(params, postdata) { params.url = URL + '/' + postdata.id; } }; var addOptions = {mtype: "POST" }; var delOptions = { onclickSubmit: function(params, postdata) { params.url = URL + '/' + postdata; } }; $( "#grid" ) .jqGrid(options) .navGrid( '#pager' , {}, //options editOptions, addOptions, delOptions, {} // search options ); |
Мы настроили метод HTTP для каждой операции, сериализация обрабатывается с использованием JSON, и, наконец, URL-адреса для операций редактирования и удаления теперь имеют суффикс / record_id. Теперь это не только выглядит, но и работает! Посмотрите на взаимодействие браузера с сервером (обратите внимание на различные методы HTTP и URL):
Вот пример создания нового ресурса на стороне браузера:
Чтобы как можно точнее следовать принципам REST, я возвращаю 201 Созданный код ответа вместе с заголовком Location, указывающим на вновь созданный ресурс. Как видите, данные теперь отправляются на сервер в формате JSON.
Подводя итог, такой подход имеет множество преимуществ:
- Графический интерфейс очень отзывчив, страница появляется мгновенно (это может быть статический ресурс, обслуживаемый из CDN), а данные загружаются асинхронно через AJAX в облегченном формате JSON
- Мы получаем операции CRUD бесплатно
- REST интерфейс для других систем также бесплатно
Сравните это с любым веб-фреймворком. И я упоминал об этой маленькой вишне на нашем коде JavaScript: jqGrid полностью совместим с темами jQuery UI, а также поддерживает интернационализацию. Вот то же приложение с измененной темой и языком:
Полный исходный код доступен на учетной записи Tomek на GitHub . Приложение является автономным, просто соберите его и разверните в некотором контейнере сервлета.
Справка: CRUD для бедных: jqGrid, REST, AJAX и Spring MVC в одном доме от нашего партнера по JCG Томека Нуркевича в блоге NoBlogDefFound .