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",
        ・・・
    }
}

JSON と相互変換するクラスは XML の場合と同様に JAXB のアノテーションを付与するだけです。

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'
        }
    ]
});

なお、alias: 'widget.エイリアス名' と定義しておけば、xtype でエイリアス名を指定できます。

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>

*1:Ext.data.Model

*2:@JsonIgnoreProperties を付けない場合、JSON に余計なデータが含まれていると例外が発生します

*3:http://localhost:8080/fits_sample/book?_dc=・・・&page=1&start=0&limit=25 の URL に GET する

*4: http://localhost:8080/fits_sample/book?_dc=・・・ の URL に POST する