More than two years back I wrote an article on how two implement elegant CRUD in Struts2. Actually I had to devote two articles on that subject because the topic was so broad. Today I have taken much more lightweight and modern approach with a set of popular and well established frameworks and libraries. Namely, we will use Spring MVC on the back-end to provide REST interface to our resources, fabulous jqGrid plugin for jQuery to render tabular grids (and much more!) and we will wire up everything with a pinch of JavaScript and AJAX.
Back-end is actually the least interesting part of this showcase, it could have been implemented using any server-side technology capable of handling RESTful requests, probably JAX-RS should now be considered standard in this field. I have chosen Spring MVC without any good reason, but it's also not a bad choice for this task. We will expose CRUD operations over REST interface; the list of best selling books in history will be our domain model (can you guess who is on the podium?)
Few things need explanation. First of all for the purposes of this simple showcase I haven't used any database, all the books are stored in an in-memory map inside a controller. Forgive me. Second issue is more subtle. Since there seems to be no agreement on how to handle paging with RESTful web services, I used simple query parameters. You may find it ugly, but I find abusing Accept-Ranges and Range headers together with 206 HTTP response code even uglier.
Last notable detail is the Page wrapper class:
I could have return raw list (or, more precisely, requested part of the list), but I also need a way to provide convenient metadata like total number of records to the view layer, not to mention some difficulties while marshalling/unmarshalling raw lists.
We are now ready to start our application and do a little test drive with curl:
Response type defaults to XML if none is specified but if we add Jackson library to the CLASSPATH, Spring will pick it up and enable us to use JSON as well:
Nice, now we can work on the front-end, hopefully not making our hands too dirty. With regards to HTML markup, this is all we need, seriously:
Keep in mind that we will implement all CRUD operations, but still, this is all we need. No more HTML. Rest of the magic happens thanks to marvellous jqGrid library. Here is a basic setup:
Technically, this is all we need. URL to fetch the data, pointing to our controller (jqGrid will perform all the AJAX magic for us) and the data model (you may recognize book fields and their descriptions). However, since jqGrid is highly customizable, I applied few tweaks to make the grid look a bit better. Also I didn't like suggested names of metadata, for instance total field returned from the server is suppose to be the total number of pages, not records – highly counter-intuitive. Here are my tweaked options:
Eager to see the results? Here is a browser screenshot:
Good looking, with customizable paging, lightweight refreshing... And our hands are still relatively clean! But I promised CRUD... If you were careful, you have probably noticed few navGrid attributes, dying to be turned on:
The configuration is getting dangerously verbose, but there's nothing complicated out there – for each field we have added few additional attributes controlling how this field should be treated in edit mode. This includes what type of HTML input should represent it, validation rules, visibility, etc. But honestly, I believe it was worth it:
This nicely looking edit window has been fully generated by jqGrid based on our edit options mentioned above, including validation logic. We can make some of the fields visible in the grid hidden/inactive in edit dialog (like id) and vice-versa (cover and comments are not present in the grid, however you can modify them). Also notice few new icons visible in bottom-left corner of the grid. Adding and deleting is possible as well – and we haven't written a single line of HTML/JSP/JavaScript (excluding jqGrid configuration object).
Of course we all know that The User Interface Is The Application, and our interface is pretty good, however sometimes we really want a beautiful and working application. And currently the latter requirement is our Achilles' heel. Not because the back-end isn't ready, this is rather trivial:
Server-side is ready, but when it comes to data manipulation on the client-side, jqGrid reveals its dirty secret – all the traffic to the server is sent using POST like this:
The last attribute (oper=add) is crucial. Not really idiomatic REST, don't you think? If we could only use POST/PUT/DELETE appropriately and serialize data using JSON or XML... Modifying my server so that it is compliant with some JavaScript library (no matter how cool it is), seems like a last resort. Thankfully, everything can be customized with a moderate amount of work.
We have customized HTTP method per operation, serialization is handled using JSON and finally URLs for edit and delete operations are now suffixed with /record_id. Now it not only looks, it works! Look at the browser interaction with the server (note different HTTP methods and URLs):
Here is an example of creating a new resource on browser side:
To follow REST principles as closely as possible I return 201 Created response code together with Location header pointing to newly created resource. As you can see data is now being sent to the server in JSON format.
To summarize, such an approach has plenty of advantages:
Compare this with any web framework out there. And did I mention about this little cherry on our JavaScript frosting: jqGrid is fully compliant with jQuery UI themes and also supports internationalization. Here is the same application with changed theme and language:
Full source code is available on Tomek's GitHub account. The application is self contained, just build it and deploy it to some servlet container.
Back-end is actually the least interesting part of this showcase, it could have been implemented using any server-side technology capable of handling RESTful requests, probably JAX-RS should now be considered standard in this field. I have chosen Spring MVC without any good reason, but it's also not a bad choice for this task. We will expose CRUD operations over REST interface; the list of best selling books in history will be our domain model (can you guess who is on the podium?)
01 | @Controller |
02 | @RequestMapping (value = "/book" ) |
03 | public class BookController { |
04 |
05 | private final Map<Integer, Book> books = new ConcurrentSkipListMap<Integer, Book>(); |
06 |
07 | @RequestMapping (value = "/{id}" , method = GET) |
08 | public @ResponseBody Book read( @PathVariable ( "id" ) int id) { |
09 | return books.get(id); |
10 | } |
11 |
12 | @RequestMapping (method = GET) |
13 | public @ResponseBody Page<Book> listBooks( |
14 | @RequestParam (value = "page" , required = false , defaultValue = "1" ) int page, |
15 | @RequestParam (value = "max" , required = false , defaultValue = "20" ) int max) { |
16 | final ArrayList<Book> booksList = new ArrayList<Book>(books.values()); |
17 | final int startIdx = (page - 1 ) * max; |
18 | final int endIdx = Math.min(startIdx + max, books.size()); |
19 | return new Page<Book>(booksList.subList(startIdx, endIdx), page, max, books.size()); |
20 | } |
21 |
22 | } |
Few things need explanation. First of all for the purposes of this simple showcase I haven't used any database, all the books are stored in an in-memory map inside a controller. Forgive me. Second issue is more subtle. Since there seems to be no agreement on how to handle paging with RESTful web services, I used simple query parameters. You may find it ugly, but I find abusing Accept-Ranges and Range headers together with 206 HTTP response code even uglier.
Last notable detail is the Page wrapper class:
01 | @XmlRootElement |
02 | public class Page<T> { |
03 |
04 | private List<T> rows; |
05 |
06 | private int page; |
07 | private int max; |
08 | private int total; |
09 |
10 | //... |
11 |
12 | } |
I could have return raw list (or, more precisely, requested part of the list), but I also need a way to provide convenient metadata like total number of records to the view layer, not to mention some difficulties while marshalling/unmarshalling raw lists.
We are now ready to start our application and do a little test drive with curl:
01 | <!-- $ curl -v "http://localhost:8080/books/rest/book?page=1&max=2" --> |
02 |
03 | <? xml version = "1.0" encoding = "UTF-8" standalone = "yes" ?> |
04 | < page > |
05 | < total >43</ total > |
06 | < page >1</ page > |
07 | < max >3</ max > |
08 | < rows xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance" xsi:type = "book" > |
09 | < author >Charles Dickens</ author > |
10 | < available >true</ available > |
11 | < cover >PAPERBACK</ cover > |
12 | < id >1</ id > |
13 | < publishedYear >1859</ publishedYear > |
14 | < title >A Tale of Two Cities</ title > |
15 | </ rows > |
16 | < rows xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance" xsi:type = "book" > |
17 | < author >J. R. R. Tolkien</ author > |
18 | < available >true</ available > |
19 | < cover >HARDCOVER</ cover > |
20 | < id >2</ id > |
21 | < publishedYear >1954</ publishedYear > |
22 | < title >The Lord of the Rings</ title > |
23 | </ rows > |
24 | < rows xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance" xsi:type = "book" > |
25 | < author >J. R. R. Tolkien</ author > |
26 | < available >true</ available > |
27 | < cover >PAPERBACK</ cover > |
28 | < id >3</ id > |
29 | < publishedYear >1937</ publishedYear > |
30 | < title >The Hobbit</ title > |
31 | </ rows > |
32 | </ page > |
Response type defaults to XML if none is specified but if we add Jackson library to the CLASSPATH, Spring will pick it up and enable us to use JSON as well:
01 | // $ curl -v -H "Accept: application/json" "http://localhost:8080/books/rest/book?page=1&max=3" |
02 |
03 | { |
04 | "total" : 43 , |
05 | "max" : 3 , |
06 | "page" : 1 , |
07 | "rows" :[ |
08 | { |
09 | "id" : 1 , |
10 | "available" : true , |
11 | "author" : "Charles Dickens" , |
12 | "title" : "A Tale of Two Cities" , |
13 | "publishedYear" : 1859 , |
14 | "cover" : "PAPERBACK" , |
15 | "comments" : null |
16 | }, |
17 | { |
18 | "id" : 2 , |
19 | "available" : true , |
20 | "author" : "J. R. R. Tolkien" , |
21 | "title" : "The Lord of the Rings" , |
22 | "publishedYear" : 1954 , |
23 | "cover" : "HARDCOVER" , |
24 | "comments" : null |
25 | }, |
26 | { |
27 | "id" : 3 , |
28 | "available" : true , |
29 | "author" : "J. R. R. Tolkien" , |
30 | "title" : "The Hobbit" , |
31 | "publishedYear" : 1937 , |
32 | "cover" : "PAPERBACK" , |
33 | "comments" : null |
34 | } |
35 | ] |
36 | } |
Nice, now we can work on the front-end, hopefully not making our hands too dirty. With regards to HTML markup, this is all we need, seriously:
1 | < table id = "grid" ></ table > |
2 | < div id = "pager" ></ div > |
Keep in mind that we will implement all CRUD operations, but still, this is all we need. No more HTML. Rest of the magic happens thanks to marvellous jqGrid library. Here is a basic setup:
01 | $( "#grid" ) |
02 | .jqGrid({ |
03 | url: 'rest/book' , |
04 | colModel:[ |
05 | {name: 'id' , label: 'ID' , formatter: 'integer' , width: 40 }, |
06 | {name: 'title' , label: 'Title' , width: 300 }, |
07 | {name: 'author' , label: 'Author' , width: 200 }, |
08 | {name: 'publishedYear' , label: 'Published year' , width: 80 , align: 'center' }, |
09 | {name: 'available' , label: 'Available' , formatter: 'checkbox' , width: 46 , align: 'center' } |
10 | ], |
11 | caption: "Books" , |
12 | pager : '#pager' , |
13 | height: 'auto' |
14 | }) |
15 | .navGrid( '#pager' , {edit: false ,add: false ,del: false , search: false }); |
Technically, this is all we need. URL to fetch the data, pointing to our controller (jqGrid will perform all the AJAX magic for us) and the data model (you may recognize book fields and their descriptions). However, since jqGrid is highly customizable, I applied few tweaks to make the grid look a bit better. Also I didn't like suggested names of metadata, for instance total field returned from the server is suppose to be the total number of pages, not records – highly counter-intuitive. Here are my tweaked options:
01 | $.extend($.jgrid.defaults, { |
02 | datatype: 'json' , |
03 | jsonReader : { |
04 | repeatitems: false , |
05 | total: function(result) { |
06 | //Total number of pages |
07 | return Math.ceil(result.total / result.max); |
08 | }, |
09 | records: function(result) { |
10 | //Total number of records |
11 | return result.total; |
12 | } |
13 | }, |
14 | prmNames: {rows: 'max' , search: null }, |
15 | height: 'auto' , |
16 | viewrecords: true , |
17 | rowList: [ 10 , 20 , 50 , 100 ], |
18 | altRows: true , |
19 | loadError: function(xhr, status, error) { |
20 | alert(error); |
21 | } |
22 | }); |
Eager to see the results? Here is a browser screenshot:
Good looking, with customizable paging, lightweight refreshing... And our hands are still relatively clean! But I promised CRUD... If you were careful, you have probably noticed few navGrid attributes, dying to be turned on:
01 | var URL = 'rest/book' ; |
02 | var options = { |
03 | url: URL, |
04 | editurl: URL, |
05 | colModel:[ |
06 | { |
07 | name: 'id' , label: 'ID' , |
08 | formatter: 'integer' , |
09 | width: 40 , |
10 | editable: true , |
11 | editoptions: {disabled: true , size: 5 } |
12 | }, |
13 | { |
14 | name: 'title' , |
15 | label: 'Title' , |
16 | width: 300 , |
17 | editable: true , |
18 | editrules: {required: true } |
19 | }, |
20 | { |
21 | name: 'author' , |
22 | label: 'Author' , |
23 | width: 200 , |
24 | editable: true , |
25 | editrules: {required: true } |
26 | }, |
27 | { |
28 | name: 'cover' , |
29 | label: 'Cover' , |
30 | hidden: true , |
31 | editable: true , |
32 | edittype: 'select' , |
33 | editrules: {edithidden: true }, |
34 | editoptions: { |
35 | value: { 'PAPERBACK' : 'paperback' , 'HARDCOVER' : 'hardcover' , 'DUST_JACKET' : 'dust jacket' } |
36 | } |
37 | }, |
38 | { |
39 | name: 'publishedYear' , |
40 | label: 'Published year' , |
41 | width: 80 , |
42 | align: 'center' , |
43 | editable: true , |
44 | editrules: {required: true , integer: true }, |
45 | editoptions: {size: 5 , maxlength: 4 } |
46 | }, |
47 | { |
48 | name: 'available' , |
49 | label: 'Available' , |
50 | formatter: 'checkbox' , |
51 | width: 46 , |
52 | align: 'center' , |
53 | editable: true , |
54 | edittype: 'checkbox' , |
55 | editoptions: {value: "true:false" } |
56 | }, |
57 | { |
58 | name: 'comments' , |
59 | label: 'Comments' , |
60 | hidden: true , |
61 | editable: true , |
62 | edittype: 'textarea' , |
63 | editrules: {edithidden: true } |
64 | } |
65 | ], |
66 | caption: "Books" , |
67 | pager : '#pager' , |
68 | height: 'auto' |
69 | }; |
70 | $( "#grid" ) |
71 | .jqGrid(options) |
72 | .navGrid( '#pager' , {edit: true ,add: true ,del: true , search: false }); |
The configuration is getting dangerously verbose, but there's nothing complicated out there – for each field we have added few additional attributes controlling how this field should be treated in edit mode. This includes what type of HTML input should represent it, validation rules, visibility, etc. But honestly, I believe it was worth it:
This nicely looking edit window has been fully generated by jqGrid based on our edit options mentioned above, including validation logic. We can make some of the fields visible in the grid hidden/inactive in edit dialog (like id) and vice-versa (cover and comments are not present in the grid, however you can modify them). Also notice few new icons visible in bottom-left corner of the grid. Adding and deleting is possible as well – and we haven't written a single line of HTML/JSP/JavaScript (excluding jqGrid configuration object).
Of course we all know that The User Interface Is The Application, and our interface is pretty good, however sometimes we really want a beautiful and working application. And currently the latter requirement is our Achilles' heel. Not because the back-end isn't ready, this is rather trivial:
01 | @Controller |
02 | @RequestMapping (value = "/book" ) |
03 | public class BookController { |
04 |
05 | private final Map<Integer, Book> books = new ConcurrentSkipListMap<Integer, Book>(); |
06 |
07 | @RequestMapping (value = "/{id}" , method = GET) |
08 | public @ResponseBody Book read( @PathVariable ( "id" ) int id) { |
09 | //... |
10 | } |
11 |
12 | @RequestMapping (method = GET) |
13 | public |
14 | @ResponseBody |
15 | Page<Book> listBooks( |
16 | @RequestParam (value = "page" , required = false , defaultValue = "1" ) int page, |
17 | @RequestParam (value = "max" , required = false , defaultValue = "20" ) int max) { |
18 | //... |
19 | } |
20 |
21 | @RequestMapping (value = "/{id}" , method = PUT) |
22 | @ResponseStatus (HttpStatus.NO_CONTENT) |
23 | public void updateBook( @PathVariable ( "id" ) int id, @RequestBody Book book) { |
24 | //... |
25 | } |
26 |
27 | @RequestMapping (method = POST) |
28 | public ResponseEntity<String> createBook(HttpServletRequest request, @RequestBody Book book) { |
29 | //... |
30 | } |
31 |
32 | @RequestMapping (value = "/{id}" , method = DELETE) |
33 | @ResponseStatus (HttpStatus.NO_CONTENT) |
34 | public void deleteBook( @PathVariable ( "id" ) int id) { |
35 | //... |
36 | } |
37 |
38 | } |
Server-side is ready, but when it comes to data manipulation on the client-side, jqGrid reveals its dirty secret – all the traffic to the server is sent using POST like this:
1 | Content-Type: application/x-www-form-urlencoded in the following format : |
2 | id =&title=And+Then+There+Were+None&author=Agatha+Christie&cover=PAPERBACK&publishedYear=1939&available= true &comments=&oper=add |
The last attribute (oper=add) is crucial. Not really idiomatic REST, don't you think? If we could only use POST/PUT/DELETE appropriately and serialize data using JSON or XML... Modifying my server so that it is compliant with some JavaScript library (no matter how cool it is), seems like a last resort. Thankfully, everything can be customized with a moderate amount of work.
01 | $.extend($.jgrid.edit, { |
02 | ajaxEditOptions: { contentType: "application/json" }, |
03 | mtype: 'PUT' , |
04 | serializeEditData: function(data) { |
05 | delete data.oper; |
06 | return JSON.stringify(data); |
07 | } |
08 | }); |
09 | $.extend($.jgrid.del, { |
10 | mtype: 'DELETE' , |
11 | serializeDelData: function() { |
12 | return "" ; |
13 | } |
14 | }); |
15 |
16 | var URL = 'rest/book' ; |
17 | var options = { |
18 | url: URL, |
19 | //... |
20 | } |
21 |
22 | var editOptions = { |
23 | onclickSubmit: function(params, postdata) { |
24 | params.url = URL + '/' + postdata.id; |
25 | } |
26 | }; |
27 | var addOptions = {mtype: "POST" }; |
28 | var delOptions = { |
29 | onclickSubmit: function(params, postdata) { |
30 | params.url = URL + '/' + postdata; |
31 | } |
32 | }; |
33 |
34 | $( "#grid" ) |
35 | .jqGrid(options) |
36 | .navGrid( '#pager' , |
37 | {}, //options |
38 | editOptions, |
39 | addOptions, |
40 | delOptions, |
41 | {} // search options |
42 | ); |
We have customized HTTP method per operation, serialization is handled using JSON and finally URLs for edit and delete operations are now suffixed with /record_id. Now it not only looks, it works! Look at the browser interaction with the server (note different HTTP methods and URLs):
Here is an example of creating a new resource on browser side:
To follow REST principles as closely as possible I return 201 Created response code together with Location header pointing to newly created resource. As you can see data is now being sent to the server in JSON format.
To summarize, such an approach has plenty of advantages:
- GUI is very responsive, page appears instantly (it can be a static resource served from CDN), while data is loaded asynchronously via AJAX in lightweight JSON format
- We get CRUD operations for free
- REST interface for other systems is also for free
Compare this with any web framework out there. And did I mention about this little cherry on our JavaScript frosting: jqGrid is fully compliant with jQuery UI themes and also supports internationalization. Here is the same application with changed theme and language:
Full source code is available on Tomek's GitHub account. The application is self contained, just build it and deploy it to some servlet container.
No comments:
Post a Comment
Note: Only a member of this blog may post a comment.