1. Qiita
  2. 投稿
  3. JavaScript

【JavaScriptでもSQLしたい!】気がつくとそこはKnex.jsという名の天竺だった

  • 72
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

Knex.jsは、JavaScript用のSQL Query Builderで、Bookshelf.jsというORマッパーの基幹モジュールでもあります。Promiseベースで実装されており、トランザクションなども綺麗に書くことができます。Node.jsだけでなく、ブラウザでも使うことができるそうでかなり興味あるのですが、今回はそっちには触れずに置いときます。

Node.jsからRDBMSを使おうとすると、pgだとかmysqlだとか、各データベースシステムに対応したライブラリを使うことになると思います。しかし、これらのライブラリだけでアプリケーションを作ろうとすると、各操作ごとのSQL文をハードコードしなければいけなかったり、スキーマの変更だとかも手動だったり、変なところで時間をとられてしまい開発どころの話ではありません。

ORマッパーは嫌いではないのですが、もともとSQLを使っていて文法を知っている人からすると二度手間感があります。自分の使いなれた言語を使うために、まったく別の文法を学ぶ...となると('ω')????という感じです。それなら、NoSQL使えばいいのでは?という話になるのですが、僕の場合用途が研究・分析寄りなこともあり、SQLの方が圧倒的に使い勝手が良いのでその選択肢もありません。

せめてデータベースの管理だけでも、JavaScriptベースで簡単にできるライブラリが無いものか...。ネットの海をふらふらと彷徨っていたところ、偶然 Knex.jsという名の天竺 にたどり着きました。急いで「Knex.js」で検索をかけたものの、あまり日本語記事は見つからず、見つかったいくつかの記事も先述のORマッパー「Bookshelf.js」の方がメインになっていました。それから、SQL Query Builderで調べたところ、Squel.jsというライブラリもありましたがやはり日本語記事はさっぱり。

おそらくORマッパーの方が一般的に使いやすいからなんでしょうが、どちらかというと僕は SQL文法リスペクトなメソッドチェーンの方が、処理と記述の関係性が明快で好き なので、今回はKnex.jsをメインに話をしたいと思います。

参照: Bookshelf.jsの紹介記事

例示するコードについて

本記事にて紹介するプログラムのソースコードは、すべて僕のGithubリポジトリに上がっています。粗末なもので申し訳ないですが、よろしければご参照ください。

インストール

terminal
npm install --save knex
terminal
npm install -g knex

Knex.jsを使いたいアプリケーションディレクトリ内で、npmからインストールします。CLIが用意されているため、グローバル環境にも入れておいた方が圧倒的に便利です。

それから、自分の使いたいデータベース用のライブラリもインストールしなければいけません。

install
$ npm install --save mysql
$ npm install --save mariasql
$ npm install --save pg
$ npm install --save sqlite3

使い方

初期設定

例として使用するプロジェクトの構造は以下のようになっています。

project
├── db
├── node_modules
│   ├── knex
│   └── pg
└── package.json

今回は、dbというディレクトリ内に、データベース関係のファイルを置いていきます。利用するデータベースはPostgreSQLです。

project
$ knex init

knex initコマンドにより、projectの直下にknexfile.jsが作られます。まずはこの中に、データベースの接続設定を書いていきます。

project/knexfile.js

module.exports = {

    development: {
        client: 'postgresql',
        connection: {
            database: 'my_db',
            user:     'username',
            password: 'password'
        },
        migrations: {
            directory:'./db/migrations'
            tableName: 'knex_migrations'
        }
    },

    staging: {
        client: 'postgresql',
        connection: {
            database: 'my_db',
            user:     'username',
            password: 'password'
        },
        pool: {
            min: 2,
            max: 10
        },
        migrations: {
            directory:'./db/migrations'
            tableName: 'knex_migrations'
        }
    },

    production: {
        client: 'postgresql',
        connection: {
            database: 'my_db',
            user:     'username',
            password: 'password'
        },
        pool: {
            min: 2,
            max: 10
        },
        migrations: {
            tableName: 'knex_migrations'
        }
    }

};

こんな感じのファイルです。もとは、developmentのところはsqlite3の設定が書かれているはずですが、ここではpostgresqlに書き直しています。my_dbには、使いたいデータベース名を、usernameには、データベースにアクセスする際のユーザ名を、passwordには、その際のパスワードを記入します。

マイグレーション

Knex.jsでは、自分のアプリケーションで使うデータのモデルを、マイグレーションファイルを使って管理します。この辺りは、Railsなどを使ったことがある方は馴染み深いのではないかと思います。

まず、knexfile.jsで、migrationsdirectoryにマイグレーションファイルを保存しておくディレクトリのパスを指定します。今回は./db/migrationsを指定しています。このパスは、knexfile.jsからの相対パスとして指定しなければいけません。

project/knexfile.js

 module.exports = {

    development: {
        client: 'postgresql',
        connection: {
            database: 'my_db',
            user:     'username',
            password: 'password'
        },
        //ここ
        migrations: {
            directory:'./db/migrations'
            tableName: 'knex_migrations'
        }
    },

...
//以下略

早速、userというモデルのデータベースを作っていきます。knex migrate:make migration_nameというコマンドを打ちます。migration_nameは、作成されるマイグレーションファイルの名前の一部になります。そのマイグレーションファイルがどんな操作を行うものなのかが分かる名前をつけてあげると良いかと思います。

project
$ knex migrate:make create_user

ユーザのデータベースを作る操作なので、create_userという名前にしました。このコマンドにより、dbの中にmigrationsというディレクトリが作られているはずです。このディレクトリの中には、これから作るすべてのマイグレーションファイルが順に格納されていきます。

さて、いま作ったマイグレーションファイルですが、タイムスタンプ+create_userという名前が付いているはずです。

project
├── db
│   └── migrations
│       └── 20141216110614_create_user.js
├── knexfile.js
├── node_modules
│   ├── knex
│   └── pg
└── package.json
project/db/migrations/20141216110614_create_user.js
'use strict';

exports.up = function(knex, Promise) {
    //ここに、tableについて行う処理を書く    
};

exports.down = function(knex, Promise) {
   //ここに、上記の処理を取り消す処理を書く
};

マイグレーションファイルの中では、exports.upexports.downという関数が定義されています。exports.upには、DBへの操作の実行、exports.downには、exports.upで行った操作を取り消す操作を記入します。exports.upの中に書いた操作がknex migrate:latestの際に実行され、exports.downの中に書いた操作は、knex migrate:rollbackの際に実行されます。

たとえば、今回のcreate_userでは、latestのときにはユーザのテーブルを作る操作になり、rollbackのときにはユーザのテーブルを削除する操作になります。

project/db/migrations/20141216110614_create_user.js
'use strict';

exports.up = function(knex, Promise) {
    return knex.schema.hasTable('users').then(function(exists) {
        if (!exists) {
            return knex.schema.createTable('users', function(t) {
                t.increments('id').primary();
                t.string('name', 100);
                t.string('passwd');
            });
        }else{
            return new Error("The table already exists");
        }
    });
};

exports.down = function(knex, Promise) {
    return knex.schema.hasTable('users').then(function(exists) {
        if (exists) {
            return knex.schema.dropTable('users');
        }
    });
};

このように書くことで、遡及可能なDB操作が実現されます。

この他にも、テーブルの名前を変えたり、カラムを増やしたり、といった、SQLのテーブル構造定義に関わる全操作を、JavaScriptによって記述することができます。詳しくは、Knex.jsドキュメントのSchemaをご参照ください。

では、実際にマイグレーションを実行してみます。

project/db
$ knex migrate:latest

これで、usersテーブルが作られたはずです。データベースにテーブルが作られているかどうかを確認してみましょう。

psql
yoshiyuki=# \d
                   List of relations
 Schema |          Name          |   Type   |   Owner   
--------+------------------------+----------+-----------
 public | knex_migrations        | table    | yoshiyuki
 public | knex_migrations_id_seq | sequence | yoshiyuki
 public | users                  | table    | yoshiyuki
 public | users_id_seq           | sequence | yoshiyuki
(4 rows)
psql
yoshiyuki=# SELECT * FROM users;

 id | name | passwd 
----+------+--------
(0 rows)

もし、テーブルを作り直したい場合は、rollbackを実行してあげます。

project/db
$ knex migrate:rollback

ここで、rollbackを実行すると、直前のlatestにて実行されたマイグレーションファイルのexport.downが呼び出され、テーブルがドロップされます。

テストデータの作成

アプリケーションを作るにあたり、何かしらテストデータが入っていないと動作確認ができない機能というものが必ず存在します。そんなときは、knexのseedを使うと非常に便利です。seedを使うにあたり、knexfile.jsにどのディレクトリをseedファイルの格納場所にするかを指定することができます。もし記述がない場合、自動的に./seeds直下に格納されますが、後々本番環境にデプロイすることを考えると、テスト環境用のseedディレクトリを指定したほうが良いかもしれません。なお、指定する際は、相対パスでの指定になりますのでご注意ください。

project/knexfile.js
//上略

development: {
  client: ...,
  connection: { ... },
  seeds: {
      directory: './db/seeds/dev'
  }
}

//下略

設定が書けたら、knex seed:make seed_nameコマンドを実行します。seed_nameは、作成されるseedファイルの名前になります。どんなデータを挿入するseedファイルなのかが分かるような名前をつけてあげると良いと思います。

project
$ knex seed:make test_users

先ほど作ったユーザのデータベースに、数人のテストユーザデータを入れるseedファイルなので、test_usersという名前にしました。

project
├── db
│   ├── migrations
│   │   └── 20141216110614_create_user.js
│   └── seeds
│       └── dev
│           └── test_users.js
├── knexfile.js
├── node_modules
│   ├── knex
│   ├── pg
│   └── websocket
└── package.json

では、今しがた作られたseedファイルの中身を見てみましょう。

project/db/seeds/dev/test_users.js
'use strict';

exports.seed = function(knex, Promise) {
    knex('tableName').insert({colName: 'rowValue'});
    //knex.insert({colName: 'rowValue'}).into('tableName')とも書ける
};

非常にわかりやすいですね!tableNameには挿入先のテーブル名を、{colName:'rowValue'}には、挿入するテストデータのカラム名と値をペアにしたオブジェクトを入れます。

project/db/seeds/dev/test_users.js
'use strict';

exports.seed = function(knex, Promise) {
    knex('users').insert({name: 'taro', passwd:'taropasswd'});
};

数人のテストデータを入れるには、データを配列にしてinsertに渡してあげればOKです。

project/db/seeds/dev/test_users.js
'use strict';

exports.seed = function(knex, Promise) {
    var testUsers = [
        {name: 'taro',passwd:'taropasswd'},
        {name: 'jiro',passwd:'jiropasswd'},
        {name: 'saburo',passwd:'saburopasswd'}];

    return knex('users').insert(testUsers);
};

編集が終わったら、seedファイルを実行します。

$ knex seed:run

ちゃんとデータベースに追加されているかを、確認してみましょう。

psql
yoshiyuki=# SELECT * FROM users;

 id |  name  |    passwd    
----+--------+--------------
  1 | taro   | taropasswd
  2 | jiro   | jiropasswd
  3 | saburo | saburopasswd
(3 rows)

問い合わせテスト

テーブルの定義も済み、テストデータも入ったことなので、試しに問い合わせを投げてみたいと思います。新しくspecディレクトリを作って、querySpec.jsというファイルに、jasmine-nodeベースのテストを書いていきます。

project
├── db
│   ├── migrations
│   │   └── 20141216110614_create_user.js
│   └── seeds
│       └── dev
│           └── test_users.js
├── knexfile.js
├── node_modules
│   ├── knex
│   ├── pg
│   └── websocket
├── spec
│   └── querySpec.js
└── package.json
project/spec/querySpec.js
var config = {
    client: 'pg',
    connection:{
        user     : 'username',
        password : 'password',
        database : 'my_db'
    },
    migrations: {
        tableName: 'knex_migrations'
    }
}

var knex = require("knex")(config);

describe("Select all from 'users'",function(){
    it("should return all rows of user", function(done){
        knex.select("*").from("users").then(function(rows){
            console.log(rows);
            done();
        });
    });
});

ここで注意していただきたいのは、require("knex")configとして渡しているオブジェクトの書き方です。knexfile.jsと同じなのですが、ここの書き方をミスると、それだけで動かなくてとんでもなくめんどい(経験談)のでご注意ください。僕は自分のミスに気づくまで、二日間ほどひたすらknex.jsのソースコードを読む羽目になり、おかげさまで内部構造にかなり詳しくなりました(白目)。

project
$ jasmine-node spec/
[ { id: 1, name: 'taro', passwd: 'taropasswd' },
  { id: 2, name: 'jiro', passwd: 'jiropasswd' },
  { id: 3, name: 'saburo', passwd: 'saburopasswd' } ]
.

Finished in 0.048 seconds
1 test, 0 assertions, 0 failures, 0 skipped

基本的な使い方は、以上になります。

アプリケーション

このセクションでは、アプリケーションからデータベースを利用するサンプルをお見せします。本当はExpressとか使ったほうが実用的なのですが、ディレクトリ構造がごちゃごちゃするので、WebSocketを使ったサンプルアプリケーションでご説明します。まずは、npmからwebsocketとbluebirdをインストールします。bluebirdは、Node.jsでPromiseを使うためのモジュールです。

project
$ npm install --save websocket bluebird

今回のサンプルアプリケーションですが、トランザクションの話をしたいので、銀行っぽいアプリケーションにします。構成としては、ユーザのクライアントが一つと、銀行となるサーバーが一つがあります。これらのそれぞれに対応するコンポーネントとして、appというディレクトリを作り、その下にclient.jsserver.jsの二つのファイルを設置します。さらに、各機能をまとめたものを、libというディレクトリの下に置きます。今回は、サーバの口座操作機能をまとめるファイルとしてbank.jsを、クライアントの入力部分の機能をまとめるファイルとしてinterface.jsを作りました。

project
├── app
│   ├── client.js
│   ├── server.js
│   └── lib
│       ├── bank.js
│       └── interface.js
├── db
│   ├── migrations
│   │   └── 20141216110614_create_user.js
│   └── seeds
│       └── dev
│           └── test_users.js
├── knexfile.js
├── node_modules
│   ├── knex
│   ├── pg
│   └── websocket
└── package.json

口座の開設

引き出したり、振り込んだりという機能を考える前に、まずはデータベースに口座のカラムを追加しなくてはいけません。ロールバックして構造を書き加えてseedファイルを実行して...というのもアリですが、今回は新しくマイグレーションファイルを作ってしまいます。

$ knex migrate:make add_account_to_user
project
├── app
│   ├── client.js
│   ├── server.js
│   └── lib
│       ├── bank.js
│       └── interface.js
├── db
│   ├── migrations
│   │   ├── 20141216110614_create_user.js
│   │   └── 20141221205038_add_account_to_user.js
│   └── seeds
│       └── dev
│           └── test_users.js
├── knexfile.js
├── node_modules
│   ├── knex
│   ├── pg
│   └── websocket
└── package.json

project/db/migrations/20141221205038_add_account_to_user.js
'use strict';

exports.up = function(knex, Promise) {
    return knex.schema.hasColumn('users','account').then(function(exists) {
        if(!exists){
            return knex.schema.table('users', function (table) {
                table.integer('account').defaultTo(0);
            })
        }else{
            return new Error("The column already exists");
        }
    });
};

exports.down = function(knex, Promise) {
    return knex.schema.hasColumn('users','account').then(function(exists){
        if(exists){
            return knex.schema.table('users', function (table) {
                table.dropColumn('account');
            });
        }
    });
};

上記の例では、口座のカラムであるaccountの初期値(つまり預金残高)を、defaultToメソッドで0に設定しています。この他にも、値の特性やトリガなど、カラムについての設定・操作が可能です。詳しくはKnex.jsドキュメントのChainableをご覧ください。

前回と同じくlatestコマンドでマイグレーションファイルを実行し、テーブルにカラムが追加されていることを確認してみます。

$ knex migrate:latest
psql
yoshiyuki=# select * from users;

 id |  name  |    passwd    | account 
----+--------+--------------+---------
  1 | taro   | taropasswd   |       0
  2 | jiro   | jiropasswd   |       0
  3 | saburo | saburopasswd |       0
(3 rows)

振り込み/引き出し機能の実装

口座のカラムが無事データベースに追加されたので、さっそく振り込み、引き出しを行う機能を作っていきます。

振り込み・引き出しの処理は主に、 読み出し計算書き込み の3ステップから成ります。これらのステップは、リジッドな一連の処理、すなわち トランザクション として実行されなければいけません。この理由は簡単で、データの一貫性(コンシステンシ)を保つためです。

例えば、X円入っている口座があったとします。AさんがY円振り込むのとほぼ同時に、BさんがZ円引き出したとします。

X + Y
X - Z

トランザクションとして上記3ステップが行われない場合、タイミングによっては、Aさんが振り込みがBさんの引き出しに上書きされ 無かったこと にされてしまったり、逆にBさんの引き出しがAさんの振り込みに上書きされて 無かったこと になってしまうことがあります。これでは、銀行の役割を果たすことができません。この例の場合、AさんとBさんの処理の結果、銀行口座の残高はX + Y - Zである必要があります。この+Y-Zを独立した処理として扱うための仕組みがトランザクションです。

PostgreSQLや、MySQLなどでは、デフォルトでトランザクションを利用するための機能が提供されています。この機能を使うために、Knex.jsでは、transactionメソッドが提供されています。transactionメソッドを用いて、口座の操作を抽象化した処理は以下のように書くことができます。

bank_operation
function operation(knex, config){
    return knex.transaction(function(trx) {
        //引き出し元を探してくる
        return trx.select("*").from("users").where({id:config.operator})
        .then(function(rows){
            //引き出し。口座がないときはエラー
            if(!rows[0]) throw new Error("Abort::No such account.");

            var sum = rows[0].account - config.withdraw;
            if(sum > 0) return trx.update("account",sum).from("users").where({id:rows[0].id});
            else throw new Error("Abort::Not enough money!");               
        })
        .then(function(rows){
            //振り込み先を探してくる
            return trx.select("*").from("users").where({id:config.direction})
        })
        .then(function(rows){
            //振り込み。口座がないときはエラー
            if(!rows[0]) throw new Error("Abort::No such account.");        

            var sum = rows[0].account + config.deposit;
            return trx.update("account",sum).from("users").where({id:rows[0].id});
        });
    });
}

exports.operation = operation;

このoperation関数は、引数として受け取るconfigによって挙動を決定します。

usage_of_operation
var config = {
    operator:1,
    direction:2,
    withdraw:100,
    deposit:100
}

operation(knex,config)
.then(function() {
    console.log("success");
})
.catch(function(error) {
    console.error(error);
});

例えば、上記の例では、operator1direction2withdraw100deposit100となっていますが、これは idが1のユーザが、自身の口座から、idが2のユーザの口座に100振り込む という操作に相当します。これがもし、operator1direction1withdraw0deposit100となれば idが1のユーザが、自身の口座に100預ける という操作になり、operator1direction1withdraw100deposit0となれば、 idが1のユーザが、自身の口座から100引き出す という操作になります。

しかし、この例のままだと、口座に対して何もしないときにもupdate命令を投げてしまって非常に無駄なので、少し改良したものをbank.jsに記述します。

project/app/lib/bank.js
var Promise = require("bluebird");

function operation(knex, config){
    return knex.transaction(function(trx) {
        var session = {
            trx:trx,
            config:config
        };

        return withdraw(session)
        .then(deposit)
        .catch(function(err){
            throw err;
        })
        .then(function(session){
            return new Promise(function(resolve,rejected){
                resolve(successMessage(config));
            });
        })
        .catch(function(err){
            throw err;
        });
    });
}

function withdraw(session){
    var trx = session.trx;
    var config = session.config;

    return new Promise(function(resolve, rejected){
        if(config.withdraw > 0){
            //引き出し金額が0じゃないなら実行
            trx.select("*").from("users").where({id:config.operator})
            .then(function(rows){
                //引き出し。口座がないときはエラー
                if(!rows[0]) rejected(new Error("Abort::No such account."));

                var sum = rows[0].account - Number(config.withdraw);
                if(sum > 0) return trx.update("account",sum).from("users").where({id:config.operator});
                else rejected(new Error("Abort::Not enough money!"));               
            })
            .then(function(){
                resolve(session);
            });
        }else{
            //引き出し金額が0なら次へ
            resolve(session);
        }
    });
}

function deposit(session){
    var trx = session.trx;
    var config = session.config;

    return new Promise(function(resolve,rejected){
        if(config.deposit > 0){
            //振り込み金額が0じゃないなら実行
            trx.select("*").from("users").where({id:config.direction})
            .then(function(rows){
                //振り込み。口座がないときはエラー
                if(!rows[0]) rejected(new Error("Abort::No such account."));        

                var sum = rows[0].account + Number(config.deposit);
                return trx.update("account",sum).from("users").where({id:config.direction});
            })
            .then(function(){
                resolve(session);
            });
        }else{
            //振り込み金額が0なら次へ
            resolve(session);
        }  
    });  
}

function successMessage(config){
    var message = "success: ";
    if(config.operation === "withdraw"){
        message += config.operation + " $" + config.withdraw;
    }else{
        message += config.operation + " $" + config.deposit;
    }
    return message
}

exports.operation = operation;

コード全体としては長くなってしまいましたが、withdrawdepositという関数にoperationのプロセスを切り出したことで、無駄なupdate命令を投げないよう条件分岐で捌くとともに、operation関数を少しスッキリさせることができました。

クライアントから口座にアクセスできるようにする

振り込み/引き出し機能ができたので、あとは実際にクライアントから口座の操作ができるようにすれば完成です。ここまでくるとKnex.jsとは直接的に関係はありませんが、参考までに。

サーバーサイド

server.js
var WebSocketServer = require('websocket').server;
var http = require('http');
var knex = require("knex")({
    client: 'pg',
    connection:{
        user     : 'username',
        password : 'password',
        database : 'my_db'
    },
    migrations: {
        tableName: 'knex_migrations'
    },
    pool:{
        min:0,
        max:7
    }
});
var bank = require("./lib/bank.js");

//========USER AUTHORIZE FUNCTION========
function authUser(config){
    var name = config.name,
        passwd = config.passwd;
    return knex.select("*").from("users").where({name:name, passwd:passwd});
}

//========BANKING FUNCTION=========
function banking(config){
    return bank.operation(knex, config)
}


//==========WEB SOCKET SERVER============
var server = http.createServer(function(request, response) {
    console.log((new Date()) + ' Received request for ' + request.url);
    response.writeHead(404);
    response.end();
});

server.listen(8080, function() {
    console.log((new Date()) + ' Server is listening on port 8080');
});

var wsServer = new WebSocketServer({
    httpServer: server,
    autoAcceptConnections: false
});

function originIsAllowed(origin){
    //本当はここでリクエストオリジンを捌く
    return true;
}

wsServer.on('request', function(request) {
    if (!originIsAllowed(request.origin)) {
        request.reject();
        console.log((new Date()) + ' Connection from origin ' + request.origin + ' rejected.');
        return;
    }

    //app-protocolによる、当該オリジンからのアクセスを許可
    var connection = request.accept('app-protocol', request.origin);
    console.log((new Date()) + ' Connection accepted.');

    connection.on('message', function(message) {
        //バイナリは送らないので割愛
        //メッセージは全てutf8で送られてくるとする
        if (message.type === 'utf8') {
            var input = JSON.parse(message.utf8Data);
            if(input.type === "login"){
                authUser(input.config)
                .then(function(rows){
                    if(rows[0]){
                        connection.uid = rows[0].id;
                        connection.sendUTF(JSON.stringify({message:"Login succeeded", state:true}));
                    }else{
                        connection.sendUTF(JSON.stringify({message:"Login failed", state:false}));
                    }
                })
                .catch(function(err){
                    console.log(err);
                });
            }
            if(input.type === "banking"){
                input.config.operator = connection.uid;
                input.config.direction = input.config.direction || connection.uid;

                banking(input.config)
                .then(function(message){
                    connection.sendUTF(JSON.stringify({message:message, state:true}));
                })
                .catch(function(err){
                    console.log(err);
                    connection.sendUTF(JSON.stringify({message:"Banking failed", state:true}));
                });
            }
        }
    });

    connection.on('close', function(reasonCode, description) {
        console.log((new Date()) + ' Peer ' + connection.remoteAddress + ' disconnected.');
    });
});
bank.js
var Promise = require("bluebird");

function operation(knex, config){
    return knex.transaction(function(trx) {
        var session = {
            trx:trx,
            config:config
        };

        return withdraw(session)
        .then(deposit)
        .then(function(session){
            return new Promise(function(resolve,rejected){
                resolve(successMessage(config));
            });
        });
    });
}

function withdraw(session){
    var trx = session.trx;
    var config = session.config;

    return new Promise(function(resolve, rejected){
        if(config.withdraw > 0){
            //引き出し金額が0じゃないなら実行
            trx.select("*").from("users").where({id:config.operator})
            .then(function(rows){
                //引き出し。口座がないときはエラー
                if(!rows[0]) rejected(new Error("Abort::No such account."));

                var sum = rows[0].account - Number(config.withdraw);
                if(sum > 0) return trx.update("account",sum).from("users").where({id:config.operator});
                else rejected(new Error("Abort::Not enough money!"));               
            })
            .then(function(){
                resolve(session);
            });
        }else{
            //引き出し金額が0なら次へ
            resolve(session);
        }
    });
}

function deposit(session){
    var trx = session.trx;
    var config = session.config;

    return new Promise(function(resolve,rejected){
        if(config.deposit > 0){
            //振り込み金額が0じゃないなら実行
            trx.select("*").from("users").where({id:config.direction})
            .then(function(rows){
                //振り込み。口座がないときはエラー
                if(!rows[0]) rejected(new Error("Abort::No such account."));        

                var sum = rows[0].account + Number(config.deposit);
                return trx.update("account",sum).from("users").where({id:config.direction});
            })
            .then(function(){
                resolve(session);
            });
        }else{
            //振り込み金額が0なら次へ
            resolve(session);
        }  
    });  
}

function successMessage(config){
    var message = "success: ";
    if(config.operation === "withdraw"){
        message += config.operation + " $" + config.withdraw;
    }else{
        message += config.operation + " $" + config.deposit;
    }
    return message;
}

exports.operation = operation;

クライアントサイド

client.js
var WebSocketClient = require('websocket').client;
var Promise = require("bluebird");

var client = new WebSocketClient();
var interface = require("./lib/interface.js");

//========WEBSOCKET CLIENT EVENT SETTING========
client.on('connectFailed', function(error) {
    console.log('Connect Error: ' + error.toString());
});

client.on('connect', function(connection) {
    console.log('WebSocket Client Connected:');

    interface.login().then(function(session){
        var data = {};
        data.config = session.user;
        data.type = "login";
        connection.sendUTF(JSON.stringify(data));
        return;
    });

    connection.on('error', function(error) {
        console.log("Connection Error: " + error.toString());
    });
    connection.on('close', function() {
        console.log('app-protocol Connection Closed');
    });
    connection.on('message', function(message) {
        if (message.type === 'utf8') {
            var response = JSON.parse(message.utf8Data);
            if(response.state){
                connection.loginState = true;
                console.log(response.message);
                interface.banking().then(function(session){
                    var data = {};
                    data.config = session.banking;
                    data.type = "banking";
                    connection.sendUTF(JSON.stringify(data));
                    return;
                })
                .catch(function(err){
                    console.log(err);
                    connection.close();
                });
            }
        }
    });   
});

//app-protocolでサーバーに接続
client.connect('ws://localhost:8080/', 'app-protocol');
interface.js
var Promise = require("bluebird");
var readline = require('readline');

//========LOGIN QUESTION FUNCTIONS========
function loginQuestion() {
    //名前とパスワードを聞く
    var session = {};
    session.rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });
    return askName(session).then(askPasswd)
}

function askName(session){
    //名前を聞く
    return new Promise(function(resolve,rejected){
        session.rl.question('What is your name?:', function (name) {
            session.user = {};
            session.user.name = name;
            resolve(session);
        });
    });
}

function askPasswd(session){
    //パスワードを聞く
    return new Promise(function(resolve,rejected){
        session.rl.question('Password:', function (passwd) {
            session.user.passwd = passwd;
            resolve(session);
            session.rl.close();
        });
    });
}


//========BANKING OPERATION INTERFACE FUNCTIONS=========
function bankingQuestion() {
    //何をしたいか聞く
    var session = {};
    session.rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });

    return askOperation(session).then(askDirection)
    .catch(function(err){
        throw err;
    }).then(askAmount);
}

function askOperation(session){
    //どのオペレーションをしたいか聞く
    return new Promise(function(resolve,rejected){
        var message = 'Please input number about what you want to do.\n 1)Withdraw\n 2)Deposit\n 3)Pay\n 4)Quit\n :';
        session.rl.question(message, function (opID) {
            session.banking = {};
            if( Number(opID) === 1 ){
                session.banking.operation = "withdraw";              
            }else if( Number(opID) === 2 ){
                session.banking.operation = "deposit";
            }else if( Number(opID) === 3 ){
                session.banking.operation = "pay";
            }else{
                console.log("See You!");
                session.rl.close();
                rejected("You are Loged out");
            }
            resolve(session);
        });
    });
}

function askDirection(session){
    //振込先を聞く。"pay"以外ではスルー
    return new Promise(function(resolve,rejected){
        if(session.banking.operation !== "pay"){
            resolve(session);
        }else{
            session.rl.question("Who will you pay for? :",function(name){
                session.banking.direction = name;
                resolve(session);
            });
        }
    });
}

function askAmount(session){
    //操作する金額を聞く
    return new Promise(function(resolve,rejected){
        session.rl.question("How much do you want to " + session.banking.operation+ "? :",function(amount){
            session.rl.close();
            if(session.banking.operation === "withdraw"){
                session.banking.deposit = 0;
                session.banking.withdraw = amount;
            }else if(session.banking.operation === "deposit"){
                session.banking.deposit = amount;
                session.banking.withdraw = 0;
            }else{
                session.banking.deposit = session.banking.withdraw = amount;
            }
            resolve(session);
        });
    });
}

exports.login = loginQuestion;
exports.banking = bankingQuestion;

番外編:Knex.js & Gulp.js for Travis CI

すごく頑張ったで証

Knex.jsでは、マイグレーションとシードをプログラム中で実行することができます。つまり、タスクランナーを用いれば、データベース構築の自動化とか、テストデータを入れたデータベースを使ったユニットテストとかができるようになります。そこで、今回はGulpとKnexを用いて、Travis CI上でデータベース自動構築とテストの実行をやってみました。これはもう「やってみたかった」という、ただそれだけでやってみたのですが、 とんでもなくめんどくさかった です。なぜかというと、 Knex.jsの処理終了と、データベースへの反映完了にラグがある ためです。当然といえば当然ですが、マイグレーションやシードの実行を自動化しようとするとこれは非常にめんどくさい問題になってきます。CLIからマイグレーションやシードを実行する場合、このラグは僕たちが物理的にコマンドを打つ時間よりも遥かに小さいことから、無視することができました。しかし、プログラムによる実行となるとそうもいきません。

マイグレーション実行→テーブルが完成→カラムが追加される→シード投入→テスト実行

という流れを、一つずつ状態を確かめ、段階を踏んで進めていかなければいけません。

project/gulpfile.js
var gulp = require('gulp');
var exit = require("gulp-exit");
var jasmine = require('gulp-jasmine');
var Knex = require("knex");
var Promise = require("bluebird");

gulp.task('travis_build',function(){
    var knex = Knex({
        client: 'postgresql',
        connection: {
            database: "travis_ci_test",
            user: "postgres"
        },
        pool: {
            min: 2,
            max: 10
        },
        migrations: {
            directory: "./db/migrations",
            tableName: 'knex_migrations'
        },
        seeds: {
            directory: './db/seeds/dev'
        }
    });
    return knex.migrate.latest()
    .then(function(){
        return new Promise(function(resolve,rejected){
            setTimeout(function readySeed(){
                knex.schema.hasTable("users").then(function(exist){
                    if(!exist) setTimeout(readySeed,100);
                    else resolve(knex);
                });
            },100);
        });
    })
    .then(function(knex){
        return knex.seed.run();
    })
    .then(function(){
        return new Promise(function(resolve,rejected){
            setTimeout(function readyTest(){
                knex.schema.hasColumn("users","account").then(function(exist){
                    if(!exist) setTimeout(readyTest,100);
                    else resolve("Ready to Test!!");
                });
            },100);
        });
    })
    .then(function(message){
        console.log(message);
        return gulp.src('spec/travisSpec.js').pipe(jasmine()).pipe(exit());
    });
});

正直、Promiseがなければ死んでいたかもしれない(真顔)
今回のアプローチは、タイムアウト使って必要なテーブルやカラムが見つかるまでポーズするという、超原始的な方法です。もうちょっとうまいやり方を考えないと、汎用性低すぎてどうしようもないなと思いました。あと、ビルドを自動化するならマイグレーションファイルは一個にまとめた方がいいかもしれないということに、後から気づきました。つらい。

まとめ

この記事では、Knex.jsによる、SQL文法リスペクトなデータベース操作についてご紹介しました。 JavaScriptでもSQLしたい!という方。ぜひお試しください。また、ご意見・ご指摘などもお待ちしております。

以上、何かのお役に立てば幸いです。