コンパイラかく語りき

import { Fun } from 'programming'

jQuery脱却の第一歩 doc-readyをつかってみる

クライアントのJSを書くとき、DOMのロードを待つ必要がある時があります。そんなとき、jQueryなら、以下のように書きますね。

$(document).ready(function() {
  // your code comes here...
})

ただ、JSの小さなモジュールを作るためにいちいちjQueryを使うのかは悩みどころです。 DOMの取得やイベントの付与には、document.getElementByIdやaddEventListenerで事足りてしまうことがあります。

DOMのロード完了待機にも、jQuery以外の方法を使ってみましょう。

doc-ready

npmのパッケージにdoc-readyというものがあります。今回はこれを使ってみます。

公式の説明によると、

Cross browser document ready helper. Supported by IE8+ and good browsers.

とのこと。IE8にも対応しているのが特徴のように見えます。

使い方

まずはdoc-readyをインストール。

$ npm i doc-ready --save

JSファイルで以下のように書きます。

// main.js
import docReady from 'doc-ready'

docReady(() => {
  // your code comes here...
}) 

内部処理

内部的には、以下のようなことを行っています。

  1. docReady.isReadyというプロパティを保持。以下の処理が用意されており、それぞれがisReadyをtrueに変更します。

    • document.readyState === ‘complete’
    • documentのDOMContentLoadedあるいは、readystatechangeイベントが発生する。
    • windowのloadイベントが発生する。
  2. isReady === trueならば、渡された関数(上の//your code comes here…部分)を実行。

また、IE8に対応するため、イベントのバインドにはeventieというパッケージのbindを利用しています。

ライブラリのソースコードこちらです。

これで、jQueryに頼らずにDOMのロード完了を待つことができます。 IE8に対応しない場合は、documentのDOMContentLoadedイベントを使えば良さそうです。

rethinkdbdashを試してみる

RethinkDBのNodeドライバと言えば、公式のドライバがあります。もちろんこれは不満なく使うことができるのですが、どうやらrethinkdbdashという別のドライバもあるようです。

この記事ではrethinkdbdashの特徴や機能について、READMEを訳す形でメモしていきます。

存在意義

rethinkdbdashは、promisesとconnection poolを実験的に使用するために作られました。promiseは公式のドライバにも採用され、スタンスとしては公式ドライバの強化であるようです。

公式ドライバとの違い

1. モジュールロード時に実行する必要がある

// official
const r = require('rethinkdb') 

// rethinkdbdash
const r_not_called = require('rethinkdbdash') // Not enough... [Function: main]
const r_called     = require('rethinkdbdash')() // OK!

2. connectメソッドを呼ぶ必要が無い

ロード時の実行の際、自動的にconnectが行われます。便利ですね。

const r = require('rethinkdbdash')() 
// Creating a pool connected to localhost:28015

3. cursorがArrayになっている

const r = require('rethinkdbdash')()

r.table('user').run().then((result) => {
    console.log(Array.isArray(result)) // true
})

公式ドライバからの移行

完全対応する場合

// これを
var r = require('rethinkdb')

// こうする
var r = require('rethinkdbdash')({
    pool: false,
    cursor: true
})

rethinkdbdashの機能を活かしたい場合

// まずロードを書き換え
var r = require('rethinkdb') // 旧
var r = require('rethinkdbdash')() // 新

// コネクションに関する記述を削除
r.connect({host: ..., port: ...}).then(function(connection) {
    connection.on('error', handleError);
    query.run(connection).then(function(result) {
        // console.log(result);
        connection.close()
    });
}) // 旧

query.run().then(function(result) {
    // console.log(result);
}) // 新

// カーソルに関する記述を削除
r.table('data').run(connection).then(function(cursor) {
    cursor.toArray().then(function(result) {
        // console.log(result):
    })
}) // 旧

r.table('data').run().then(function(result) {
    // console.log(result);
}) // 新

TLSコネクション

RethinkDBはTLSコネクションをサポートしていませんが、以下の手順で実現できます。(※この機能はエクスペリメンタルです)

まず、サーバにTLSプロキシを立てます。

var tls = require('tls');
var net = require('net');
var tlsOpts = {
    key: '', // プライベートキー
    cert: '' // 公式証明書
};
tls.createServer(tlsOpts, function (encryptedConnection) {
    var rethinkdbConn = net.connect({
        host: 'localhost',
        port: 28015
    });
    encryptedConnection.pipe(rethinkdbConn).pipe(encryptedConnection);
}).listen(29015);

それから、安全な接続を行います。

var r = require('rethinkdbdash')({
  port: 29015,
  host: 'place-with-no-firewall.com',
  ssl: true
});

新機能

ドライバインポート

ドライバのインポート時に、以下のオプションを渡すことができます。

  • db: \ - 指定がない場合はデフォルトDBが使われます。
  • user: \ - RethinkDBユーザー。デフォルトはAdmin。
  • password: \ - ユーザーのパスワード。デフォルトは空文字。
  • discovery: \ - デフォルトではfalse。trueならば、定期的にserver_statusテーブルからデータをプルしてホスト一覧を最近に保ってくれる。
  • pool: \ - falseにするとコネクションプールを使わない。
  • buffer: \ - プールで利用可能なコネクションの最小数。デフォルトは50。
  • max: \ - プールで利用可能なコネクションの最大数。デフォルトは100。
  • timeout: \ - コネクションが開かれる時間を指定。デフォルトは20。
  • pingInterval: - もし0以上が指定されたら、コネクションはpingInterval秒ごとにpingされる。デフォルトは-1。
  • timeoutError: \ - エラー時の再接続間隔時間。デフォルトは1000。
  • timeoutGb: \ - 不使用のコネクションをどのくらい保持しておくか。デフォルトは60601000
  • silent: \ - console.error errors。デフォルトはfalse。
  • servers: {host: \, port: \}のArray - 接続するRethinkDBノード。
  • optionalRun: \ - falseにすると結果を残さないクエリ実行。デフォルトはtrue。

シングルインスタンスの場合、hostportを直接渡すことも可能。

// localhost:8080に接続し、他のインスタンスも発見できるようにさせる。
var r = require('rethinkdbdash')({
    discovery: true
});

// localhost:8080にだけ接続する。
var r = require('rethinkdbdash')();

// コネクションプールを作成しない。
var r = require('rethinkdbdash')({pool: false});

// `192.168.0.100`, `192.168.0.101`, `192.168.0.102`から送られてくるクラスタに接続する。
var r = require('rethinkdbdash')({
    servers: [
        {host: '192.168.0.100', port: 28015},
        {host: '192.168.0.101', port: 28015},
        {host: '192.168.0.102', port: 28015},
    ]
});

// `192.168.0.100`, `192.168.0.100`, `192.168.0.102` を含むクラスタに接続しつつ、最大接続数を3000に引き上げ300の接続を利用可能にする。
var r = require('rethinkdbdash')({
    servers: [
        {host: '192.168.0.100', port: 28015},
        {host: '192.168.0.101', port: 28015},
        {host: '192.168.0.102', port: 28015},
    ],
    buffer: 300,
    max: 3000
});

コネクションプール

クエリ発行後にNodeのscriptを閉じたい場合には、プールをdrainする必要がある。

r.getPoolMaster().drain();
var r = require('rethinkdbdash')();

const userTable = r.table('user').run()

r.getPoolMaster().drain()

userTable.then((res) => {
    console.log(res)
})
// Unhandled rejection ReqlDriverError: None of the pools have an opened connection and failed to open a new one.

また、プールマスターはerrorやnewステートをstderr上でロギングする。 logイベントに対してリスナをバインドすることで、ログを扱うことができる。

r.getPoolMaster().on('log', console.log);

その他、プールマスターを通じて実行できるメソッド。

var r = require('rethinkdbdash')();

const userTable = r.table('user').run()
const pointTable = r.table('point').run()

// オープンなコネクションの数。
console.log(r.getPoolMaster().getLength()) // 2

// アイドルなコネクションの数。クエリ実行中でないもの。
console.log(r.getPoolMaster().getAvailableLength()) // 0

// すべてのプールにアクセスできる。
console.log(r.getPoolMaster().getPools())

ストリーム

(TODO: 時間切れのため、あとで追記します。)

ExpressとRethinkDBで作るRESTfulなWebAPI

ExpressとRethinkDBで作るRESTfulなWebAPIです。 RethinkDB公式のこちらを参考にしました。

ひとまず、Expressでルーティングを定義して、RethinkDB上のデータへのCRUD処理ができるところまで。

前提条件

  • RethinkDBをインストールしておく。
  • Express起動前にローカルでRethinkDBを立ち上げておく。

パッケージ

"async": "^2.0.1",
"body-parser": "^1.15.2",
"express": "^4.14.0",
"rethinkdb": "^2.3.3"

config

// ./config.js
module.exports = {
  rethinkdb: {
    host: 'localhost',
    port: 28015,
    authKey: '',
    db: 'rethinkdb_ex'
  },
  express: {
    port: 3000
  }
}

本体

雑なベタ貼りですみません…。都度コメント書いてあります。 ポイントとして、ReQLの実行結果はプロミスが返ります。

// ./server.js
const r = require('rethinkdb')
const express = require('express')
const async = require('async')
const bodyParser = require('body-parser')

const config = require(__dirname + '/config')

const app = express()

// index.htmlとその他のフロントエンドアセットをサーブする。
app.use(express.static(__dirname + '/public'))

app.use(bodyParser.json())

/*
*  すべてのTodoアイテムを取得
*/
const listTodoItems = (req, res, next) => {
  r.table('todos').orderBy({index: 'createdAt'}).run(req.app._rdbConn, (err, cursor) => {
    if(err) {
      return next(err)
    }
    
    cursor.toArray((err, result) => {
      if(err) {
        return next(err)
      }
      
      res.json(result)
    })
  })
}

/*
*  新しくTodoを作成してinsert
*/
const createTodoItem = (req, res, next) => {
  const todoItem = req.body
  todoItem.createAt = r.now()
  
  console.dir(todoItem)
  
  r.table('todos').insert(todoItem, {returnChanges: true}).run(req.app._rdbConn, (err, result) => {
    if(err) {
      return next(err)
    }
    
    res.json(result.changes[0].new_val)
  })
}

/*
*  特定のTodoを取得
*/
const getTodoItem = (req, res, next) => {
  const todoItemID = req.params.id
  
  r.table('todos').get(todoItemID).run(req.app._rdbConn, (err, result) => {
    if(err) {
      return next(err)
    }
    
    res.json(result)
  })
}

/*
* Todoの更新
*/
const updateTodoItem = (req, res, next) => {
  const todoItem = req.body
  const todoItemID = req.params.id
  
  r.table('todos').get(todoItemID).update(todoItem, {returnChanges: true}).run(req.app._rdbConn, (err, result) => {
    if(err) {
      return next(err)
    }
    
    res.json(result.changes[0].new_val)
  })
}

/*
* Todoの削除
*/
const deleteTodoItem = (req, res, next) => {
  const todoItemID = req.params.id
  
  r.table('todos').get(todoItemID).delete().run(req.app._rdbConn, (err, result) => {
    if(err) {
      return next(err)
    }
    
    res.json({success: true})
  })
}

/*
* page-not-found ミドルウェア
*/
const handle404 = (req, res, next) => {
  res.status(404).end('not found')
}

/*
* 500ページを送り返し、エラーをロギングする
*/
const handleError = (err, req, res, next) => {
  console.error(err.stack)
  res.status(500).json({err: err.message})
}

/*
* DB接続をストアして、ポートのリッスンを始める
*/
const startExpress = (connection) => {
  app._rdbConn = connection
  app.listen(config.express.port)
  console.log(`Listening on port ${config.express.port}`)
}

// ルーティング定義
app.route('/todos')
  .get(listTodoItems)
  .post(createTodoItem)

app.route('/todos/:id')
  .get(getTodoItem)
  .put(updateTodoItem)
  .delete(deleteTodoItem)

app.use(handle404)

app.use(handleError)

/*
* RethinkDBに接続して、必要なテーブルを作成してインデックスを貼り、expressを起動する
*/
async.waterfall([
  // RethinkDBに接続
  function connect(callback) {
    r.connect(config.rethinkdb, callback)
  },
  // DBを無ければ作成
  function createDatabase(connection, callback) {
    r.dbList().contains(config.rethinkdb.db).do((containsDb) => {
      return r.branch(
        containsDb,
        {created: 0},
        r.dbCreate(config.rethinkdb.db)
      )
    }).run(connection, (err) => {
      callback(err, connection)
    })
  },
  // テーブルを無ければ作成
  function createTable(connection, callback) {
    r.tableList().contains('todos').do((containsTable) => {
      return r.branch(
        containsTable,
        {created: 0},
        r.tableCreate('todos')
      )
    }).run(connection, (err) => {
      callback(err, connection)
    })
  },
  // インデックスが無ければ作成
  function createIndex(connection, callback) {
    r.table('todos').indexList().contains('createdAt').do((hasIndex) => {
      return r.branch(
        hasIndex,
        {created: 0},
        r.table('todos').indexCreate('createdAt')
      )
    }).run(connection, (err) => {
      callback(err, connection)
    })
  },
  // インデックスをwaitさせる
  function waitForIndex(connection, callback) {
    r.table('todos').indexWait('createdAt').run(connection, (err, result) => {
      callback(err, connection)
    })
  }
], (err, connection) => {
  if(err) {
    console.error(err)
    process.exit(1)
    return
  }
  
  // express起動
  startExpress(connection)
})

このスクリプトを、$ node server.jsで起動します。

様子

左がAdvanced REST clientというクロームAppで、HTTPリクエストを送信しています。右がRethinkDBのAdmin画面で、データの変更を検知できます。

express-rethinkdb-rest-api.gif

Promiseチェーンの中で条件を満たすまで同じ処理を繰り返す(リトライ処理)

Promiseチェーンの中で、特定の条件を満たすまで同じ処理を繰り返したい場合があります。

例: データロード処理

そんな場合のコードサンプルです。

コード

こちら。

const retryPromise = (func, delay) => {
  const retry = (resolve, reject) => func()
    .then((result) => ({ result, isCompleted: (result !== null) }))
    .then(({ result, isCompleted }) => (isCompleted) ? resolve(result) : setTimeout(() => retry(resolve, reject), delay))
    .catch(reject);

  return new Promise(retry);
};

解説

解説をコメントで付け加えました。コード自体は上記のものと同じです。

// func: Promiseを返す関数, delay: リトライの時間間隔
const retryPromise = (func, delay) => {
  // Promiseを返す関数を実行
  const retry = (resolve, reject) => func()
    // Promiseの結果そのものと終了条件をreturn
    .then((result) => ({ result, isCompleted: (result !== null) }))
    // 終了条件を満たしていたらresolveしてチェーンを抜ける
    // そうでなければ、delayミリ秒後に再実行
    .then(({ result, isCompleted }) => (isCompleted) ? resolve(result) : setTimeout(() => retry(resolve, reject), delay))
    .catch(reject);
  
  // retryPromise関数で最初に実行される部分
  return new Promise(retry);
};

パッと見では分かりづらいかもしれません。要は再帰関数的に処理を繰り返しています。 ポイントは再帰する時にreolve, rejectを渡している点です。

またリトライ処理と言えばsetInterval関数が思いつきますが、clearIntervalしないといけないので面倒です。

リトライPromiseを使った例です。

// データfetchを模した関数
const fetchData = () => {
  // 低確率でデータのfetchに成功します
  if(Math.floor(Math.random() * 10) >= 9) {
    console.log('Fetch success!')
    return Promise.resolve({ data: 'foo' })
  } else {
    console.log('Fetch fail...')
    return Promise.resolve(null)
  }
}

const retryPromise = (func, delay) => {
  const retry = (resolve, reject) => func()
    .then((result) => ({ result, isCompleted: (result !== null) }))
    .then(({ result, isCompleted }) => {
      if(isCompleted) {
        return resolve(result)
      } else {
        console.log(`Retry in ${delay}ms`)
        return setTimeout(() => retry(resolve, reject), delay)
      }
    })
    .catch(reject);

  return new Promise(retry);
};

retryPromise(fetchData, 1000)
  .then((result) => {
    console.log('Fetch completed!', result)
  })

実行結果がこちら。

Fetch fail...
Retry in 1000ms
Fetch fail...
Retry in 1000ms
Fetch fail...
Retry in 1000ms
Fetch fail...
Retry in 1000ms
Fetch fail...
Retry in 1000ms
Fetch fail...
Retry in 1000ms
Fetch success!
Fetch completed! { data: 'foo' }

他に良い例がありましたらぜひ。

table-layout: fixed; せずに text-overflow: ellipsis; する

長過ぎる文字列に対して、文末に「…」をつけて省略して表示したい時があります。

これをHTMLのtable要素で実現する方法について書きます。

固定幅テーブル

まず、行が固定幅で良いのなら以下のように書けます。

table {
  table-layout: fixed;
}

td {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

これで、tdの文字列が長すぎる場合にhogehogehoge... のように表示されます。

でも実務では、行は可変幅のままであって欲しいケースがほとんどだと思います。

可変幅テーブル

可変幅を保ったまま、tdの中身を省略記法にしたい場合は以下のとおりです。

table {
  /* table-layout: fixed; */
}
td {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;

  max-width: 0;
}

tdmax-width: 0;を指定します。 これにより可変幅を保ったまま文字列省略ができます。

仕組みについては、こちらをどうぞ。

Why does this behave the way it does with max-width: 0?

サンプルコード

Codepenにサンプルを置いておきました。良かったらどうぞ。

https://codepen.io/chuck0523/pen/dRgmvB

curlに対して、Express上のGraphQLサーバが Must provide query string. を返す

問題

Express上のGraphQLサーバへと、curlでqueryを発行した。

$ curl -XPOST -H "Content-Type:application/graphql"  \
'http://localhost:5000/api/graphql'  \
-d 'query Query { user(_id: 3) { name, mail } }'

すると、以下のようなレスポンスが。

{
  "errors": [
    {
      "message": "Must provide query string."
    }
  ]
}

むむ。

対処法

その1

Expressの立ち上げスクリプトに追記する。

// ./server.js
const express = require('express')
const app = express()
const bodyParser = require('body-parser')

app.use(bodyParser.urlencoded({ extended: false }))
// ↓この行を新規追加
app.use(bodyParser.text({ type: 'application/graphql' }))
app.use(bodyParser.json())

POSTリクエストのbodyをテキストとして解析してくれる。

その2

リクエストの中身をjsonにする。キーをquery(あるいはmutation)として、値に発行したいクエリを設定する。

$ curl -XPOST -H "Content-Type:application/json" \
'http://localhost:5000/api/graphql' \
-d ' { "query":  "Query { user(_id: 3) { name, mail } } " } '

webpacker2.0に上げたら、Herokuへのデプロイが失敗する対処(Configuration config/webpacker.yml file not found. Make sure webpacker:install is run successfully before running dependent tasks)

問題

Herokuへのデプロイが失敗した。 webpacker gemを2.0に上げたのが原因っぽい(元々は1.2でした)。

こちらがエラーログ。

Configuration config/webpacker.yml file not found.
Make sure webpacker:install is run successfully before running dependent tasks

対処

ローカルでrails webpacker:installしてみる。

f:id:chuck0523:20170603213037p:plain

おや。どうやらconfig系のファイルを再生成しようとしているらしい。

自分の場合は、babelrc に手を加えていたので、webpackerが再生成しようとするbabelrcとコンフリクトしてしまった。手動で直しました…。

ちなみに、こちらがリライトされたファイルたち。 f:id:chuck0523:20170603213437p:plain

これらをコミットしたらデプロイ成功しました。まあ、言われたとおりにwebpacker:installし直しただけなんですが。

小言

後方互換性を保っているJavaScriptの世界に、Railsという別の角度からbreaking changeが入る…。なんだか腑に落ちない感じはあります。 そして、webpacker:installは責務として、パッケージインストールだけではなくコンフィグ系ファイルの生成も行うんですね。