2013年12月22日日曜日

Node.js向けORM Bookshelfの紹介

Node.js向けRDBを対象としたORM、Bookshelfを紹介します。

以下、Bookshelfの公式ページより:

BookshelfはNode.jsのためのプロミスベースのORMです、Knexというクエリービルダーを元に実装されています。Backbone.jsのモデルとコレクションを継承する形で実装しており、トランザクション、eager/nested-eagerリレーションのローディング、polymoriphic associations、1対1、1対多、多対多のリレーションをサポートしています。
PostgreSQL、MySQL、SQLite3を対象としています。

Bookshelf.js、Knex共にTim Griesser氏によるものです。Bookshelfを利用しているプロジェクトとしては最近話題の(?)Gohstがあります。

使ってみる


実際に使ってみるのがBookshelfの感覚をつかむのに近道と思います、簡便のためSQLite3を利用します。

インストールは以下の通り:

$ npm install knex bookshelf sqlite3 lodash

以下に今回のサンプルコード一式をおいて有ります、

$ git clone git clone https://p_baleine@bitbucket.org/p_baleine/bookshelf-sample.git

テーブルの作成


まずはKnexのマイグレーション機能を利用してテーブルを作成します。KnexのマイグレーションはRailsのそれの機能を極小に絞った簡易版です。以下の設定用モジュール(db/config.js)を作成し:

module.exports = {
  directory: './db/migrations',
  database: {
    client: 'sqlite3',
    connection: {
      filename: './db/sample.sqlite3'
    }
  }
};

以下コマンドを実行して、テーブル作成用のマイグレーションを生成します:

$ ./node_modules/.bin/knex migrate:make create_table -c db/config.js

<日付>_create_table.jsというファイルがdb/migrations配下に生成されます、これを編集します:

exports.up = function(knex, Promise) {
  return Promise.all([
    // postsテーブル
    knex.schema.createTable('posts', function(t) {
      t.increments('id').primary();
      t.string('title').notNullable();
      t.string('content').notNullable();
      t.timestamps();
    }),
    // commentsテーブル
    knex.schema.createTable('comments', function(t) {
      t.increments('id').primary();
      t.string('commenter').notNullable();
      t.string('content').notNullable();
      t.timestamps();
      t.integer('post_id').notNull().references('id').inTable('posts');
    })
  ]);
};

exports.down = function(knex, Promise) {
  return Promise.all([
    knex.schema.createTable('posts'),
    knex.schema.createTable('comments')    
  ]);
};

以下コマンドでマイグレーションを実行します:
  $ ./node_modules/.bin/knex migrate:latest -c db/config.js

これでdb/sample.sqlite3が生成されます、試しにテーブルが作成されているか除いてみます:

$ sqlite3 db/sample.sqlite3
  sqlite> .schema
  CREATE TABLE "comments" ("id" integer primary key autoincrement not null, "commenter" varchar(255) not null, "content" varchar(255) not null, "created_at" datetime, "updated_at" datetime, "post_id" integer not null, foreign key("post_id") references "posts"("id"));
  CREATE TABLE "knex_migrations" ("id" integer primary key autoincrement not null, "name" varchar(255), "batch" integer, "migration_time" datetime);
  CREATE TABLE "posts" ("id" integer primary key autoincrement not null, "title" varchar(255) not null, "content" varchar(255) not null, "created_at" datetime, "updated_at" datetime);

最初のモデル


投稿のモデルのコード(post.js)は以下の通りです:

var Bookshelf = require('bookshelf'),
    databaseConfig = require('./db/config').database,

    sampleBookshelf = Bookshelf.sampleBookshelf = Bookshelf.initialize(databaseConfig);

var Post = exports.Post = sampleBookshelf.Model.extend({
  tableName: 'posts',
  hasTimestamps: true
});

var Posts = exports.Posts = sampleBookshelf.Collection.extend({
  model: Post
});

`Bookshelf.initialize`でコネクションを取得しています、コネクションの取得タイミングについては後述します。モデルとしてPost、コレクションとしてPostsをexportします。

REPLから新しく投稿を保存してみます:
> var Post = require('./post').Post
> new Post({ title: 'Sample blog', content: 'sample content' }).save().then(function(post) {
.....   console.log(post.toJSON());
.....   })
> { title: 'Sample blog',
  content: 'sample content',
  updated_at: Sun Dec 22 2013 09:39:17 GMT+0900 (JST),
  created_at: Sun Dec 22 2013 09:39:17 GMT+0900 (JST),
  id: 1 }

コレクション経由で一覧を取得してみます:
> var Posts = require('./post').Posts
> Posts.forge().fetch().then(function(posts) {
...   console.log(posts.toJSON());
... });
> [ { id: 1,
    title: 'Sample blog',
    content: 'sample content',
    created_at: 1387672757272,
    updated_at: 1387672757272 } ]

リレーション


コネクションの取り扱い


投稿モデルに紐付くコメントモデルを作成してみます。2つ目のモデルの登場でコネクションの取得タイミングが問題になります。Bookshelf.initializeはアプリケーションのどこかのタイミングで一度だけ実行する必要があります。自分はアプリケーション中のモデルの基底クラスとなるBaseModelを作成して、このモジュール内でBookshelf.initializeを実行するようにして対応することが多いです(このやり方はGohstに習いました)。BaseModelのコード(base.js)は以下の通り:

var Bookshelf = require('bookshelf'),
    databaseConfig = require('./db/config').database,

    sampleBookshelf = Bookshelf.sampleBookshelf = Bookshelf.initialize(databaseConfig);

var BaseModel = exports.BaseModel = sampleBookshelf.Model.extend({
  hasTimestamps: true
}, {
  findAll: function() {
    var coll = sampleBookshelf.Collection.forge([], { model: this });
    return coll.fetch.apply(coll, arguments);
  }
});

ついでにfindAllメソッドを定義しておきます、正直モデルとコレクションを別個に保持するのは面倒なので、このfindAllは結構重宝します(これもGohst譲り)。 投稿モデルはBaseModelを継承するように変更します(post.js):

var BaseModel = require('./base');

var Post = pmodule.exports = BaseModel.extend({
  tableName: 'posts'
});

コメントモデル


コメントモデルのコードは以下の通り(commen.js):

var BaseModel = require('./base');

var Comment = module.exports = BaseModel.extend({
  tableName: 'comments'
});

また、投稿モデルからコメントモデルを参照するためにpost.jsのインスタンスメソッドに以下を追加します。

  comments: function() {
    return this.hasMany(require('./comment'));
  }

REPLで試してみます、id 1の投稿に対しコメントを作成してみます:

> var Comment = require('./comment');
undefined
> var comment = new Comment({ post_id: 1, commenter: 'p.baleine', content: 'sample comment' })
undefined
> comment.save().then(function(comment) { console.log(comment.toJSON()); })
[object Object]
> { post_id: 1,
  commenter: 'p.baleine',
  content: 'sample comment',
  updated_at: Sun Dec 22 2013 09:58:57 GMT+0900 (JST),
  created_at: Sun Dec 22 2013 09:58:57 GMT+0900 (JST),
  id: 1 }

投稿の一覧をコメント付きで取得してみます:

> Post.findAll({ withRelated: ['comments'] }).then(function(posts) {
... console.log(posts.toJSON());
... })
[object Object]
> [ { id: 1,
    title: 'Sample blog',
    content: 'sample content',
    created_at: 1387672757272,
    updated_at: 1387672757272,
    comments: [ [Object] ] },
  { id: 2,
    title: 'Sample blog',
    content: 'sample content',
    created_at: 1387673612433,
    updated_at: 1387673612433,
    comments: [] } ]

…肝心のコメントが省略されて見えない…、じゃぁこの投稿モデルだけ取得してみます:

new Post({ id: 1 }).fetch({ withRelated: ['comments'] }).then(function(post) {
... console.log(post.toJSON());
... })
> { id: 1,
  title: 'Sample blog',
  content: 'sample content',
  created_at: 1387672757272,
  updated_at: 1387672757272,
  comments: 
   [ { id: 1,
       commenter: 'p.baleine',
       content: 'sample comment',
       created_at: 1387673937358,
       updated_at: 1387673937358,
       post_id: 1 } ] }

トランザクション


最後に(自分がよくつまずくので)トランザクションについてです。サンプルとしては更にタグテーブルを追加して、投稿を保存する際にタグも追加するようにしてみます。

まずタグテーブル作成のマイグレーション生成:

$ ./node_modules/.bin/knex migrate:make create_tag -c db/config.js

生成されたマイグレーションを編集します:

exports.up = function(knex, Promise) {
  return Promise.all([
    knex.schema.createTable('tags', function(t) {
      t.increments().primary();
      t.string('text').notNullable();
      t.timestamps();
    }),

    knex.schema.createTable('posts_tags', function(t) {
      t.increments().primary();
      t.integer('post_id').notNull().references('id').inTable('posts');
      t.integer('tag_id').notNull().references('id').inTable('tags');
      t.unique(['post_id', 'tag_id'], 'post_tag_index');
    })  
  ]);
};

exports.down = function(knex, Promise) {
  return Promise.all([
    knex.schema.dropTable('tags'),
    knex.schema.dropTable('posts_tags')
  ]);  
};

マイグレーションを実行します:

$ ./node_modules/.bin/knex migrate:latest -c db/config.js

タグモデル(tag.js):

var BaseModel = require('./base');

var Tag = module.exports = BaseModel.extend({
  tableName: 'tags'
});

投稿モデル(post.js)を編集します、タグモデルに対し多対多のリレーションを定義して、`save`メソッドを上書きしています。投稿のsaveして、一度この投稿に紐付くタグを削除した上で、引数にもらったタグを保存する一連の処理をトランザクションでくるんでいます。

var _ = require('lodash'),
    BaseModel = require('./base'),
    Bookshelf = require('bookshelf').sampleBookshelf,
    Comment = require('./comment'),
    Tag = require('./tag');

var Post = module.exports = BaseModel.extend({
  tableName: 'posts',
  comments: function() {
    return this.hasMany(Comment);
  },
  tags: function() {
    return this.belongsToMany(Tag);
  },
  // override
  save: function(params, options) {
    var _this = this;

    return Bookshelf.transaction(function(t) {
      var tags = _this.get('tags');

      _this.attributes = _this.omit('tags');
      options = _.extend(options || {}, { transacting: t });

      return BaseModel.prototype.save.call(_this, params, options)
        .then(function() {
          // この投稿に紐付くタグを一旦全部削除
          return _this.tags().detach(null, options);
        })
        .then(function() {
          // 引数にもらったタグを追加
          return _this.tags().attach(tags, options);
        })
        .then(t.commit, t.rollback);
    }).yield(this);
  }
});

Bookshelf.transactionメソッドでトランザクションを実現します。コールバック関数がトランザクションのインスタンスを受け付けるので、ひき続く各処理saveとかattachとかにこれを引き回す必要があります。自分はよくこれを忘れてつまずいています。

REPLで試してみます、まずタグを作成(面倒くさいからknex経由):

var Post = require('./post') // base.jsを読み込む
var knex = Bookshelf = require('bookshelf').sampleBookshelf.knex
knex('tags').insert([{ text: 'sample1' }, { text: 'sample2' }]).then(console.log);

新しく投稿を作成、タグ`sample1`をつけてみます:

> var post = new Post({ title: 'sample blog', content: 'content', tags: [1] })
> post.save().then(function(post) { console.log(post.toJSON()); }).catch(console.error)
[object Object]
> { title: 'sample blog',
  content: 'content',
  updated_at: Sun Dec 22 2013 10:25:02 GMT+0900 (JST),
  created_at: Sun Dec 22 2013 10:25:02 GMT+0900 (JST),
  id: 3 }

上手くできたかフェッチして除いてみます(さっきの出力の投稿IDを利用):

> new Post({ id: 3 }).fetch({ withRelated: ['tags'] }).then(function(post) {
... console.log(post.toJSON());
... })
[object Object]
> { id: 3,
  title: 'sample blog',
  content: 'content',
  created_at: 1387675502759,
  updated_at: 1387675502759,
  tags: 
   [ { id: 1,
       text: 'sample1',
       created_at: null,
       updated_at: null,
       _pivot_post_id: 3,
       _pivot_tag_id: 1 } ] }

最後に


Bookshelfはプロミスベースなため、例えばExpress.jsで利用してみるとコントローラのコードの見通しが非常に良くなります。またBackboneのモデルを継承して実装されているため、バリデーションのロジック等クライアントサイドとサーバサイドのコードの共有が至極自然に行えます(この場合自分はよくbrowserifyを用います)。あと、これは宣伝ですが、やはりgruntインタフェースでマイグレーションを実行したくてプラグインを作って見ました grunt-knex-migrate。よかったら使ってみてください。

0 件のコメント:

コメントを投稿