まだプログラマーですが何か?

プログラマーネタとアスリートネタ中心。たまに作成したウェブサービス関連の話も http://twitter.com/dotnsf

タグ:api

このブログでも何度か Node.js のネタを扱ってますが、非同期処理に悩まされることが多いので、自分の理解の意味でもまとめておくことにしました。

まず理解の大前提として、Node.js はシングルスレッドで動作するため、いわゆる並列処理はできない仕様になっています。そして非同期に処理を実行することができます。これによって並列処理ができなくても、何かの時間のかかる処理があった場合にその終了を待たずに次の処理に進むことができることを意味しています。 ただ、この辺りがややこしく難解になっていることも事実です。

例えば REST API などの HTTP リクエストを行って、その結果が得られたら、その得られた結果の JSON オブジェクトの値を使って処理をする、などというよくあるケースでもこの問題に直面します。

まずは何が難解なのかを紹介します。REST API だとローカル環境で気軽に試せないので、わざと実行に時間のかかる関数を用意して説明します。

例えばこのような処理を考えてみます:
// test.js

// わざと1秒かけてから、パラメータの2倍の数値を出力する関数
function func1( x ){
  setTimeout( function(){
    console.log( 2 * x );
  }, 1000 );
}

// 初期値を設定して出力
var n0 = 10;
console.log( 'n0 = ' + n0 );

// 初期値を上記関数のパラメータに入れて実行
func1( n0 );

この中では func1 という関数を定義しています。setTimeout を使ってわざと1秒(1000ミリ秒)待ってから、パラメータの値の2倍を画面に出力する、という関数です。 この関数を n0 (=10 に設定)という変数をパラメータにして実行する、というものです。なので "20" という結果が出力されることを期待しています。

この内容を test.js というファイルに保存して、実行してみます(青字部分が出力結果):
$ node test
n0 = 10
20

期待通りに "20" と出力されました。とりあえずここまでは成功です。


さて問題はここからです。上記例では関数 func1 の中で console.log が実行されて出力までを行いました。これを func1 からは与えた数値を2倍した結果を受け取るようにして、func1 の外側で出力するように変更してみます。深く考えずにやるとこんな感じでしょか?
// test.js(注 正しく動きません)

// わざと1秒かけてから、パラメータの2倍の数値を出力する関数
function func1( x ){
  setTimeout( function(){
    console.log( 2 * x );
  }, 1000 );
}

// わざと1秒かけてから、パラメータの2倍の数値を戻す関数
function func2( x ){
  setTimeout( function(){
    return ( 2 * x );
  }, 1000 );
}

// 初期値を設定して出力
var n0 = 10;
console.log( 'n0 = ' + n0 );

// 初期値を上記関数のパラメータに入れて実行し、戻り値を出力
var n1 = func2( n0 );
console.log( 'n1 = ' + n1 );

func1 をほぼコピペして func2 という関数を作りました。console.log の代わりに return にして、値を返すようにしています。呼び出し元からはこの func2 を実行して、得られた結果を出力するようにしています。

これを先程と同様に実行するとこうなります:
$ node test
n0 = 10
n1 = undefined

n0(10)の値の2倍の "20" という結果を期待していたのですが、"undefined" と表示されてしまいます(正確に書くと、上記の2行はすぐに表示されますが、更に1秒くらい経過してから終了します)。この理由は最初に書いたように func2 は非同期に実行されているので、return の行が実行されるまでには1秒かかります。しかしその前に(return の行が実行される前に)関数そのものの処理は終了してしまいます。つまり値が戻る前に(戻っていない値を受け取ることになっている)n1 という変数を出力しているので "undefined" になっているのでした。

このように、期待通りに動かなかった理由は明白なのですが、ではどうすればこの関数が期待通りに動く(1秒後に与えられた結果を戻り値として戻し、受け取った側がその値を出力する)ようにできるでしょうか?これが今日紹介する大きなテーマです。


結論を先に紹介すると、ここで Promise オブジェクトを使って関数を修正し、受け取った側もその変更に合わせて一部書き直す必要があります。具体的には以下のように修正します:
// test.js

// わざと1秒かけてから、パラメータの2倍の数値を出力する関数
function func1( x ){
  setTimeout( function(){
    console.log( 2 * x );
  }, 1000 );
}

// わざと1秒かけてから、パラメータの2倍の数値を戻す関数
function func2( x ){
  return new Promise( function( resolve ){
    setTimeout( function(){
      resolve( 2 * x );
    }, 1000 );
  });
}

// 初期値を設定して出力
var n0 = 10;
console.log( 'n0 = ' + n0 );

// 初期値を上記関数のパラメータに入れて実行し、戻り値を出力
func2( n0 ).then( function( n1 ){
  console.log( 'n1 = ' + n1 );
});

まず関数 func2 側は、Promise オブジェクトを新規に作成します。Promise オブジェクトは処理が成功した場合の関数をパラメータに指定します。上図だと
function( resolve ){
  setTimeout( function(){
    resolve( 2 * x );
  }, 1000 );
}

という関数がパラメータに指定されているので、成功するとこの関数が実行されます(今回は使っていませんが、第二パラメータを指定した場合は失敗時に実行する処理を指定したことになります)。この処理の中で1秒待って、指定したパラメータを2倍して resolve とする、ということになります。

そしてこの関数 func2 を呼び出す側も少し変更が必要になります。 func2() 関数の実行結果をそのまま変数として受け取るのではなく、成功した場合(今回の例だと1秒待って2倍になった値が返された場合)の処理を .then() 内に渡して処理することになります。この then 内の処理で計算結果(resolve で処理された内容)を n1 という変数で受け取って console.log で表示する、という内容にしています。

こうして修正した test.js を実行すると、以下のような結果になります(実際には n0 = 10 がすぐに表示され、1秒くらい待ってから n1 = 20 の行が表示されて終了します):
$ node test
n0 = 10
n1 = 20

Node.js の関数内で非同期処理を実行して、その非同期処理の終了を待って値を受け取るような関数を作る場合は、Promise オブジェクトを使って上記のように記述します、という紹介でした。非同期実行に慣れていないと、この辺りで戸惑うことが多いと感じたので、まとめておきました。


Watson API の1つで、複数の選択肢の中から自分の与えた条件に合うアイテムをトレードオフ判断で絞り込んでくれる、という便利な Tradeoff Analytics API があります(ありました)が、残念ながら5月11日をもって新規インスタンスの生成ができなくなってしまいました(作成済みインスタンスは2018年4月まで使えるようです。詳しくはこちら)。

Bluemix に Watson API が追加された当初から存在していた API の1つであるだけでなく、個人的にもデモなどで使っていて、非常にわかりやすくて便利な API だっただけに残念でした。

このアナウンスがされたタイミングから準備はしていたのですが、実はこの Tradeoff Analytics と同様の(近い互換性を持つ)API を自分でオープンソース化前提で作っています。まだ途中といえば途中ですが、制約付きで一応動く状態にはなっています。新インスタンスが生成できなくなったこのタイミングで一旦公開してみようと思いました:
https://github.com/dotnsf/yatradeoff


なお、この API を実際にアプリケーションサーバー上で稼働させているものもこちらに用意しました。ただ API だけ使いたい人はこちらをどうぞ:
https://yatradeoff.au-syd.mybluemix.net/dilemmas


この URL にブラウザで GET アクセスしても何も起こりません(エラーメッセージが表示されるだけ)が、POST アクセスすると API として動きます。その際にはユーザー名: username &パスワード: password を指定してください(オリジナル同様 Basic 認証です)。


元の Tradeoff Analytics API 同様、/dilemma という POST API エンドポイント1つだけが定義されています。ポストする JSON 型インプットデータのフォーマットには互換性があります。

API の実行結果としての JSON データにも互換性をもたせました。ただ現状はオリジナルのサブセットになっています。現状、以下の3点の制約事項があります:
(1) preferable_solutions データは常にブランクになります
(2) 解析結果は solutions に含まれますが、'FRONT' ステータスを持つデータだけが含まれます
(3) map information を出力するようパラメータで指定しても map information は返されません



ただ1点、オリジナルにはなかった優先順位を考慮する機能も加えています。エンドポイント URL のパラメータに prioritised=1 を追加して実行(例: /dilemma?prioritised=1)すると、インプットデータの JSON データは優先順位順に解釈されます(先にある方が高い優先順位であると解釈されて実行されます。例えば「項目AとBを両方ともトレードオフの材料にする」のではなく、「項目AとBを両方ともトレードオフの材料にするが、Aの方をBよりも優先する」というトレードオフを可能にしています)。


そして、このパラメータを付けて実行した時の API 実行結果も優先順位順に返されます。先にある方がよりオススメな結果、ということになります。まあ preferable_solutions の代わりという位置付けです。


※実はこの機能を付けたくて自分で API を改良した、という経緯があります。


結果の JSON テキストの solutions 内がトレードオフの結果です。オリジナル API では全ての選択肢に対して 'FRONT' か 'EXCLUDE' かの結果を含めていましたが、現状この API では 'FRONT' のもの(トレードオフ判断の結果、選択肢の候補として残ったもの)だけが返される点に注意してください。


IBM Watson Summit 2017 開催記念作品!


自分だけではないと思いますが、可愛らしいフリー素材を数多く公開していただいている「いらすとや」http://www.irasutoya.com/)さんには、大変お世話になっております。



僕の場合はプレゼンテーション内のイラストに多く使わせていただいています。中にはいらすとやさんの素材だけでサイトや資料を作ってしまう職人さんもいらっしゃるようです。

ある程度「いらすとや」を使っていて感じたことは「目的の素材をうまく見つけるのが難しい」ということです。「昔こんな感じのイラストを見た記憶があるんだけど、どのカテゴリーだったっけな?」とか、「この人が使ってるこのイラストと同じものを使いたい」とか、自分の記憶が曖昧だったり、これというキーワードが思いつかない時に目的のイラストをうまく検索できないことがたまにあるのでした。


で、その解決策になるかどうかわかりませんが、興味半分でこんなサイトを作ってみました:
「いらすとや検索」



↑見た通りのサイトです。「なんとなく」覚えているイラストをなんとなく描いて、search して、そのイラストに似た「いらすとや」画像を探す、というものです。PCであればマウスで、スマホの場合はタップで描きます。検索結果は最大100件表示され、その中に含まれていれば目的のページに(クリックで)移動できる、というものです:
2017042501

    ↓

2017042502


例えばこの↑例、「鳩」で検索すればすぐに見つかりますが、「鳥」で検索するとなかなか候補が出てきません。「鳩のイラスト」とまで認識できていればテキスト検索でも探せるのですが、そこまでハッキリを覚えていないようなケースでも「たしかこんな感じの・・・」というイラストが描ければ検索できるようになっています(たぶんw)。


今のところ描くイラストは黒線一本のみで描く必要があります。描き直しに消しゴムなどはなく、reset する必要があります。編集機能にはまだ制約が多いですが、シンプルさを重要視しました(ということにします)!
2017042503



なお、このサイトはコグニティブエンジンである IBM WatsonVisual Recognition(画像認識) API を使って、あらかじめ学習させたイラストからの類似画像を人工知能のテクノロジーを使って検索する、という仕組みで実装しています。いらすとや内の全ページをクロールする方法が分からなかったので、現在は「リクエスト」ラベルから辿れる画像を対象にしています。仕組みはシンプルですが、実はそこそこなテクノロジーが裏に潜んでいたりします。


・・・まあ、ネタにどうぞ(笑)。
 

また、このアプリを作る上で、以下の2つの情報を参考にしました。HTML5 の Canvas にマウス移動(とスマホのタッチ)で線を描画するワザと、特にスマホのタッチで描画をする際に画面のスクロールを強制的に止めるワザです:

JavaScript でマウス座標を取得し、Canvas上に線を描画


先日、IBM の NoSQL DBaaS である Cloudant のデータベース単位でのバックアップ/リストアを行うツールを作って公開しました:

本エントリでは、そもそも何故このようなツールを作ったのか、実現においてどのような制約事項があり、どのように制約を回避したか、その背景を技術者視点で紹介します。


【そもそもそんなツールが必要なのか?】
「必要なのか?」と言われると、結構鋭い視点だと思います。Cloudant はいわゆる「マルチマスターレプリケーション」型の DBaaS であり、データは複数のサーバーインスタンスに分散して格納されます。つまり「どこか1つのサーバーが死んだ場合でもデータがどこかに残っている」ことはこの仕組自体の中で提供されています。これで充分、と考える人や用途であれば不要という判断もアリです。

とはいえ、データベースを手軽に丸ごと引越したり、(精神衛生上の問題から)手元にバックアップを残しておきたいと考えたり、そのバックアップファイルをストレージに保存してストレージ代を稼ぎたいクラウドベンダー/インテグレーター側の事情(苦笑)などからバックアップとリストアを行う需要はあると思っています。


【ツールがないとバックアップ/リストアはできないのか?】
これも難しい質問です。まず Cloudant はいわゆる CRUD 操作に REST API が用意されており、基本的にはこの REST API を使って読み書き更新削除検索・・といった操作を行うことになります。この API のリファレンスはこちらを参照ください:
https://console.ng.bluemix.net/docs/services/Cloudant/api/index.html#api-reference

このリファレンスを読むと分かるのですが、データベースに対して「データや添付ファイルも含めた全ドキュメントデータを取得する API」も「複数のドキュメントデータをまとめて書き込む API」も、どちらも存在しています。以下の記述では前者を「データ取得 API」、後者を「データ書込 API」と呼ぶことにします。

これら2つの API を組み合わせればバックアップとリストアは実現できそうに見えます。が、実はいくつかの問題があり(一部は後述します)、その中でも最も大きなものは「データ取得 API で得られるアウトプットデータと、データ書き込み API でインプットするデータのフォーマットが異なる」という点です。より正確に表現すると「データ取得 API で得られるアウトプットデータを、そのままデータ書込み API のインプットデータとして使った場合、書込み処理自体は成功し、全てのデータがインポートされるが、書き込まれた個々のドキュメントデータの JSON データとしてのフォーマット(データ階層)が元のデータのフォーマットとは異なる」という結果が待っているのでした。つまりバックアップ元とリストア先のデータベースの中身(データの JSON のフォーマット)が同じではなくなってしまうのでした。


シンプルに表現すると、バックアップ元のデータベースに以下のようなデータが入っていたとすると、、
{ "_id": "001", "name1": 123, "name2": "value1", "name3": true },
{ "_id": "002", "name1": 456, "name2": "value2", "name3": false },
{ "_id": "003", "name1": 789, "name2": "value3", "name3": true },
  :

上記 API でバックアップ&リストアした先のデータは以下のようになります:
{ "_id": "001", "doc": { "_id": "001", "name1": 123, "name2": "value1", "name3": true } },
{ "_id": "002", "doc": { "_id": "002", "name1": 456, "name2": "value2", "name3": false } },
{ "_id": "003", "doc": { "_id": "003", "name1": 789, "name2": "value3", "name3": true } },
  :

バックアップ元では、例えば "name1" という属性の値を参照しようとすると、(document).name1 でアクセスできた内容が、リストア先だと (document).doc.name1 という方法でアクセスすることになる、という違いが生じます。


ここは考えようで、「元のデータが残っているかどうか」という観点では残っているといえます。ただ JSON データとして見たときの階層情報が異なっていて、全く同じデータではない(特定の属性の値を取得する場合の取得方法は異なる)のです。これを許容できるかどうかの問題とも言えますが、一般的にバックアップ/リストアという言葉からイメージするのはデータフォーマットの変更が許されるものではないと思っています。その意味では上記 API をそのまま使って実現する方法は選択できなくなります。

また細かいことですが、データ書込み API を実行するには事前にデータベースを新規に作成しておく必要があります。わざわざ「データベース作成+文書リストア」という2度手間をかけるのも不便だし、そういった利便性まで含めて考えると1発で実現できるなんらかのツールがあるべきだし必要、という判断になります。


【ツールでの実現方法】
上記の理由により、リストア先でもデータフォーマットを変えずに復元できるよう、API をそのまま使う方法ではなく、処理の途中にフォーマット変換を行うようなバックアップ/リストア処理を行うツールを作ります。

この時点で考えられる実装アルゴリズムには以下のようなものが挙げられます:
(1) バックアップ時には上記のデータ取得 API をそのまま使い、その結果をバックアップファイルとする。リストア時にはバックアップファイルからデータを1つずつ取り出し、リストアしたい部分だけを取り出して1件ずつ書き込む。
(2) バックアップ時には上記のデータ取得 API をそのまま使い、その結果をバックアップファイルとする。リストア時には、まず上記のデータ書込み API で一括書込みした時にデータフォーマットが乱れないような形にバックアップファイルを加工する。その上でデータ書込み API を実行して一括書き込みする
(3) バックアップ時にはまず上記のデータ取得 API をそのまま使い、その結果をデータ書込み API 用に加工した上で保存し、バックアップファイルとする。リストア時にはデータ書き込み API をそのまま実行して一括書き込みする
  :


いずれも理論的には実現できそうなのですが、ここで API とは別の制約が問題になります。上記の (1) の方法で(データを1件ずつ)リストアすることは可能なのですが、その際に「Cloudant のスタンダードプランでは 1 秒間に 10 件しか書込みリクエストを処理できない」という制約事項を意識する必要があります。例えばバックアップファイルに 11 件以上のドキュメントが存在していた場合、データを書き込む API は1秒間に 10 回しかコールできません(どこまで厳密にこの制約が管理されているのかはわかりませんが、私自身がリクエスト上限によるエラー "You've exceeded your current limit of 10 requests per second for write class. Please try later." の発生を確認しています)。このエラーを回避しようとすると、プログラム内で実行タイミングを測り、10回書込みを実行したら、次の書込みリクエストは1秒以上待ってから実行する、という本質的ではない管理が必要になります(そしてこれは Node.js のような非同期処理を基本とする実行環境では非常に面倒な問題になります)。また仮にそこまで作り込んだとしても、多くのドキュメントを持つデータベースのリストアには非常に多くの時間がかかり、その多くは待ち時間となる可能性が高くなります。バックアップ取得にかかる時間に対し、リストアにあまりに多くの時間が取られる、という非生産的な制約ができてしまうので、上記の (1) の方法は断念することにしました。また上記の (1) ~ (3) 以外にも1件ずつ取り出して、1件ずつ書き込んで・・・という方法も考えられますが、同じ理由で非現実な方法となってしまうだけでなく、API の実行回数に比例するコスト上昇という問題も抱えてしまいます。

というわけで、(2) か (3) の方法での(一回の API 実行でまとめて書き込むような方法での)実装が候補として考えられるのですが、今回自分は (3) の方法を取ることにしたのでした。つまりバックアップ時にデータ取得 API を使って一括取得したデータをそのまま保存するのではなく、リストア用(データ書込み API 用)に加工してからバックアップファイルとして保存する、という方法です。そしてリストア時にはまずリストア先のデータベースを作成(既存の場合は削除した上で作成)した上で、取得したバックアップファイルをそのままデータ書込み API に渡して一括書込みを行うことにします。
バックアップツールの処理
  • データ取得 API を使って、バックアップ元データベースから全文書を一括取得
  • 取得した結果をデータ書込み API で一括書込みできるようなフォーマットに変換してから保存
リストアツールの処理
  • リストア先に指定されたデータベースが既に存在していたら削除する
  • リストア先データベースを作成
  • データ書込み API でバックアップファイル内の全文書データを一括書込み

なお、この方法であれば Node.js のような非同期処理の実行環境でも(一括で書込みを行うだけなので)あまり意識せずに実装することができます。

また、この (3) の方法であれば (2) の方法に比べてリストア時にはこのツールを使わなくても、バックアップファイルを指定して(curl などで)データ書込み API を直接実行してもリストアできる、という副産物的なアドバンテージもあります。


と、まあ色々を事情や背景を書き並べましたが、要は「自分はバックアップを手元に置きたい派」で、「自分で手軽にバックアップを取りたくて作った」のです。で、色々調べているうちに Cloudant API の制約事項の勉強にもなったし、一応動くものもできたし、いい勉強にもなりました。


マンホールマップに「スマホからもっと簡単に投稿できるようにしたい」という要望に応える新機能を用意しました。具体的には Twitter から投稿可能にしました。というわけで、以下の機能を使う前提として、スマホに Twitter アプリが導入されている必要があります。


また、この機能を使うには、Twitter で @Manholemap_Bot をフォローしてください( #manhotalk_bot と似ていてややこしいですが間違えないでくださいw)。この機能のために作成した新しいボットのアカウントです:
2017030801


試しに三鷹のこのマンホールを投稿してみることにします。この画像がスマホの中に保存されているものとします:
mitaka


お持ちの各種スマホ(やPC)から、フォローした上記アカウントへのメンションでマンホール画像を送付してください。メンションとはメッセージの頭に @Manholemap_Bot (大文字小文字は区別しないので、全て小文字でもOKです)を付けて、画像を添付して投稿してください:
IMG_0365


基本的にスマホ側での作業はこれだけで投稿できます。以下はPCでの作業を想定しています。少し(最大5分)待つと、投稿した画像がマンホールマップに反映されます:
2017030802


投稿した本人(と同じ Twitter アカウントでログインした状態)がその画像ページにアクセスした場合は編集ボタンが表示され、投稿の編集が可能になります:
2017030803


位置やテキストなど、必要に応じて編集して、最後に「更新」します:
2017030804


残念ながらまだいくつかの制約事項があります:
(1) テキストを同時にツイートできない
(2) 元の画像に位置情報が含まれていても反映されない(Twitter の仕様)

色々調査しながらにはなりますが、今後のアップデートで少しずつ便利にしていくつもりです。


なお、この機能はアプリケーション開発者向けに公開しているマンホールマップ API を使って作成したものです。誰でも使えるものなので、興味をお持ちの方はこちらから仕様書をどうぞ:
http://manholemap.juge.me/dev.jsp



このページのトップヘ