Ext JS MVC + RESTEasy - 一対多の関連を持つモデルデータを JSON で送受信する
Ext JS 4 の MVC 機能を使ったクライアントと RESTEasy を使った JAX-RS サーバー間を JSON データで連携するサンプルを作成してみました。
サンプルのソースは http://github.com/fits/try_samples/tree/master/blog/20120225/
送受信する JSON は以下のような入れ子を含んだものを扱います。
詳細は後述しますが、Ext JS MVC のモデル*1を使ってこのようなデータを送信(POST)するには少し工夫が必要でした。
{ "id":"1", "title":"ブック1", "comments":[ {"content":"コメント1"}, {"content":"コメント2"} ] }
サーバー処理 (RESTEasy で JSON を送受信)
RESTEasy で JSON を扱うには以下のような選択肢があり、今回は resteasy-jackson-provider を採用しました。
- resteasy-jackson-provider を使用
- resteasy-jettison-provider を使用
resteasy-jettison-provider の場合、以下のような JSON(ルート要素名の book が付いてしまう)を送信していまい、Ext JS との連携に不都合そうなのでやめました。
resteasy-jettison-provider を使った場合の JSON データ例
{ "book": { "id":"1", "title":"ブック1", ・・・ } }
src/main/java/fits/sample/model/Book.java
package fits.sample.model; ・・・ import javax.xml.bind.annotation.XmlRootElement; @XmlRootElement public class Book { public String id; public String title; public List<Comment> comments = new ArrayList<>(); ・・・ }
src/main/java/fits/sample/model/Comment.java
package fits.sample.model; import javax.xml.bind.annotation.XmlRootElement; import org.codehaus.jackson.annotate.JsonIgnoreProperties; @XmlRootElement //クラス定義に存在しない JSON データのプロパティを無視するように設定 @JsonIgnoreProperties(ignoreUnknown=true) public class Comment { public String content; ・・・ }
ちなみに、@JsonIgnoreProperties はクラス定義に存在しない余計なデータが JSON に含まれていても無視するためのものです。*2
今回は Ext JS からの JSON データ内に "fits.model.book_id" という余計なデータが含まれるので、これを無視するために付与しています。
JAX-RS のリソースクラスは以下の通りです。
複数の Comment を追加した Book データを JSON で送信して、JSON で受信した Book データを標準出力するだけの処理を実装しました。
src/main/java/fits/sample/resource/BookResource.java
package fits.sample.resource; ・・・ @Path("book") public class BookResource { @GET @Produces("application/json") public Collection<Book> findBookList() { ArrayList<Book> result = new ArrayList<>(); Book b1 = new Book("1", "テスト1"); b1.comments.add(new Comment("・・・・")); b1.comments.add(new Comment("サンプル")); result.add(b1); Book b2 = new Book("2", "テスト2"); result.add(b2); return result; } @POST @Consumes("application/json") public Response addBook(Book book) { System.out.printf("id: %s, title: %s\n", book.id, book.title); for (Comment comment : book.comments) { System.out.printf(" content : %s\n", comment.content); } return Response.ok(true).build(); } }
クライアント処理 (Ext JS で JSON を送受信)
Ext JS MVC では Ext.data.Model のサブクラスとしてモデルクラスを定義します。
なお、belongsTo(Ext.data.BelongsToAssociation)や hasMany(Ext.data.HasManyAssociation)を用いる事でモデル間の関連が定義できるようになっています。
上で作成した Book.java や Comment.java と同様の内容を実装すると以下のようになります。hasMany によって Fits.model.Book クラスに comments() というメソッドが自動的に付与され、このメソッドを実行する事で Filts.model.Comment 用に生成された Store を取得できます。
src/main/webapp/app/model/Book.js
Ext.define('Fits.model.Book', { extend: 'Ext.data.Model', fields: [ {name: 'id', type: 'string'}, {name: 'title', type: 'string'} ], //一対多の関連を定義 hasMany: [ {model: 'Fits.model.Comment', name: 'comments'} ] /* 以下でも可 associations: [ {type: 'hasMany', model: 'Fits.model.Comment', name: 'comments'} ] */ });
src/main/webapp/app/model/Comment.js
Ext.define('Fits.model.Comment', { extend: 'Ext.data.Model', fields: [ {name: 'content', type: 'string'} ] });
RESTEasy と JSON データを送受信するための Store クラスを定義します。
src/main/webapp/app/store/Books.js
Ext.define('Fits.store.Books', { extend: 'Ext.data.Store', //JSON データと相互変換するモデル model: 'Fits.model.Book', proxy: { type: 'rest', //JSON データの送受信先 URL url: 'book' }, autoLoad: true });
上記の Fits.store.Books を使ってグリッド表示するビューを作成すると以下のようになります。
Comments 欄は Fits.model.Book に関連付けられた複数の Fits.model.Comment を表示するために XTemplate を使ってテンプレート処理しています。(以下の rec.comments() で Fits.model.Comment 用の Store を取得しています)
src/main/webapp/app/view/BookGrid.js
Ext.define('Fits.view.BookGrid', { extend: 'Ext.grid.Panel', alias: 'widget.bookgrid', //Store の設定 store: 'Books', tbar: [ {xtype: 'button', text: 'Add', action: 'add'}, {xtype: 'button', text: 'Refresh', action: 'refresh'}, {xtype: 'tbseparator'} ], columns: [ {header: 'Id', dataIndex: 'id', width: 40}, {header: 'Title', dataIndex: 'title', width: 200}, {header: 'Comments', flex: 1, renderer: function(value, meta, rec) { var tpl = new Ext.XTemplate( '<ol>', '<tpl for="items">', '<li>{data.content}</li>', '</tpl>', '</ol>' ); //複数 Comment をテンプレート処理 return tpl.apply(rec.comments().data); }}, ] });
これで RESTEasy から以下のような JSON データを GET *3 し、
[{"id":"1","title":"テスト1","comments":[{"content":"・・・・"},{"content":"サンプル"}]},{"id":"2","title":"テスト2","comments":[]}]
以下のような画面表示を行います。
次に JSON データの送信処理部分です。
Add ボタンを押下した際に Fits.model.Book データを作成してサーバーに送信するようにしてみます。
データの追加やサーバーへの送信は Store を通じて行えば簡単ですので、"get<ストア名>Store()" というメソッドを使って Books Store を取得し、データの追加と sync() メソッドによるサーバーへの反映を行います。
src/main/webapp/app/controller/Books.js
Ext.define('Fits.controller.Books', { extend: 'Ext.app.Controller', //使用する Store の定義 //(この定義で get<ストア名>Store() メソッドが使えるようになる) stores: ['Books'], //使用する Model の定義 models: [ 'Book', 'Comment' ], init: function() { console.log('init samples'); this.control({ 'bookgrid button[action=add]': { click: this.addBook }, 'bookgrid button[action=refresh]': { click: function() { this.getBooksStore().load(); } } }); }, //Book データの追加 addBook: function() { //Book データの作成 var data = Ext.create('Fits.model.Book', { title: '追加データ' }); //Comment データの追加 data.comments().add({content: 'コメント1'}); data.comments().add({content: 'コメント2'}); data.comments().add({content: 'コメント3'}); //Fits.store.Books の取得 var store = this.getBooksStore(); //Store へのデータ追加 store.add(data); //サーバーへ追加データを送信(POST) store.sync(); } });
ここで注意点があります。
実は上記の sync() でサーバー側に送信されるのは以下のような comments が欠けた JSON データです。
{"id":"","title":"追加データ"}
Ext JS 4.0.7 では hasMany などで定義した関連データ(associations)は POST による送信対象とならない事が原因です。
具体的には、Ext.data.writer.Writer の getRecordData() メソッドの戻り値を元に JSON データが作成されるのですが、このメソッドでは関連データ(associations)を一切取得しないため、送信データから欠落します。
回避策として Writer のサブクラスを作成する事も考えられますが、今回は Ext.data.writer.Json で getRecordData() をオーバーライドする事で解決しました。
メソッドをオーバーライドするには以下のようにします。ここでメソッドの引数は arguments で参照し、オーバーライド前のメソッドは callOverridden() で呼び出せます。
クラス名.override({ メソッド名: function() { ・・・ }, ・・・ });
Ext.application の launch で、Ext.data.writer.Json の getRecordData() をオーバーライドする処理を実装してみました。
オーバーライド元の getRecordData() を呼び出して取得したデータに関連データ(associations)の内容を追加しています。(associations のキー毎に配列を用意してデータを格納)
src/main/webapp/app.js
//自作の View や Controller をロードするための設定 Ext.Loader.setConfig({ enabled: true }); Ext.application({ name: 'Fits', appFolder: 'app', // app/view/Viewport.js を使うための設定 autoCreateViewport: true, controllers: [ 'Books' ], launch: function() { // JSON データに関連(associations)データを追加するための処理 Ext.data.writer.Json.override({ // getRecordData のオーバーライド getRecordData: function() { //オーバーライド元のメソッド呼び出し var data = this.callOverridden(arguments); var record = arguments[0]; if (record.associations.length > 0) { Ext.Array.each(record.associations.keys, function(key) { data[key] = []; //関連毎の Store 取得 var assocStore = record[key](); Ext.Array.each(assocStore.data.items, function(assocItem) { data[key].push(assocItem.data); }); }); } return data; } }); } });
上記で Add ボタンの押下時に以下のような JSON が POST *4 され、
{"id":"","title":"追加データ","comments":[{"content":"コメント1","fits.model.book_id":""},{"content":"コメント2","fits.model.book_id":""},{"content":"コメント3","fits.model.book_id":""}]}
以下のようにサーバー出力される事を確認できます。(入れ子の JSON データが適切に処理されています)
サーバー出力例
・・・ org.apache.catalina.startup.Catalina start 情報: Server startup in 1364 ms id: , title: 追加データ content : コメント1 content : コメント2 content : コメント3
最後に index.html と Viewport.js、pom.xml、web.xml はそれぞれ以下のような内容です。
src/main/webapp/index.html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <title>ExtJS4 MVC JSON + RESTEasy Sample</title> <link rel="stylesheet" href="http://cdn.sencha.io/ext-4.0.7-gpl/resources/css/ext-all.css" /> <script type="text/javascript" src="http://cdn.sencha.io/ext-4.0.7-gpl/ext-all.js"></script> <script type="text/javascript" src="app.js"></script> </head> <body> </body> </html>
src/main/webapp/view/Viewport.js
Ext.define('Fits.view.Viewport', { extend: 'Ext.container.Viewport', layout: 'border', requires: [ 'Fits.view.BookGrid' ], items: [ { region: 'center', // Fits.view.BookGrid の表示 xtype: 'bookgrid' } ] });
pom.xml
<project・・・> ・・・ <repositories> <repository> <id>jboss</id> <url>http://repository.jboss.org/nexus/content/groups/public/</url> </repository> </repositories> <dependencies> <dependency> <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-jaxrs</artifactId> <version>2.3.1.GA</version> </dependency> <dependency> <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-jackson-provider</artifactId> <version>2.3.1.GA</version> </dependency> </dependencies> <build> <finalName>fits_sample</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>2.3.2</version> <configuration> <encoding>UTF-8</encoding> <source>1.7</source> <target>1.7</target> </configuration> </plugin> </plugins> </build> </project>
src/main/webapp/WEB-INF/web.xml
<web-app ・・・> <context-param> <param-name>resteasy.resources</param-name> <param-value>fits.sample.resource.BookResource</param-value> </context-param> <filter> <filter-name>Resteasy</filter-name> <filter-class> org.jboss.resteasy.plugins.server.servlet.FilterDispatcher </filter-class> </filter> <filter-mapping> <filter-name>Resteasy</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> </web-app>