Более двух лет назад я написал статью о том, как два реализуют элегантный 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
|
@XmlRootElementpublic 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 .





