目次


Web 時代の非リレーショナルデータベース

第 4 回 Apache CouchDB の便利な機能を習得する

Comments

コンテンツシリーズ

このコンテンツは全#シリーズのパート#です: Web 時代の非リレーショナルデータベース

このシリーズの続きに乞うご期待。

このコンテンツはシリーズの一部分です:Web 時代の非リレーショナルデータベース

このシリーズの続きに乞うご期待。

今回の内容

CouchDB のほとんどの機能は第2回、第3回で説明したREST APIによるドキュメントのCRUD操作および、MapReduceによるビュー/クエリ機能にまとめられます。しかし、実際にCouchDBを使うシステムを開発するには、もう少し便利な機能を知っておく必要があります。今回は、そのような便利機能を紹介します。

ドキュメントをまとめて更新する

関係データベースでは、Begin Transaction文などを使用して、複数のCRUDステートメントにまたがってトランザクションのACID特性を保証することができます。CouchDBにおけるACID特性は、1トランザクションである1回のHTTPリクエストで保証されます。そして、複数のHTTPリクエストにまたがってACID特性を保証することは、(CouchDBだけでは)事実上不可能です。

しかし、アプリケーションの中には、複数のドキュメントをまとめて更新したい場合もでてきます。CouchDBは、このような要求に応えるために、複数ドキュメントに対するCUD操作(作成、更新、削除)をまとめて、1回のHTTPリクエストで処理するAPIを備えています[2]。このAPIは、_bulk_docsという名前がついており、http://{host}:{port}/{dbname}/_bulk_docs というURIにCUD操作対象のドキュメント(もちろんJSON形式です)をPOSTすることで利用できます。

// リクエスト 
// POST http://localhost:5984/sample_db1/_bulk_docs
{ "docs" : [
  { "_id" : "this is to be deleted", "_rev" : "2270995587", 
    "_deleted" : true},                                     // ... (1)
  { "_id" : "this is to be updated", "_rev" : "844902897",  
    "title" : "Updated Title"},                             // ... (2)
  { "_id" : "this is to be created",                       
    "title" : "New Title", "content" : "content."},         // ... (3.1)
  { "title" : "New Title", "content" : "content."}          // ... (3.2)
]}

リクエストボディの内容は {“docs” : [ドキュメント,ドキュメント,…]} という形式で、配列の中に、作成、更新、削除するドキュメントをJSON形式で記述していきます。ドキュメントが作成されるか、更新されるか、削除されるかについては以下のルールでまとめられます。

  1. _id値、_rev値を持っていて_deleted値にtrueを指定したドキュメントを送る場合は、該当ドキュメントを削除します。
  2. _id値、_rev 値を持っているドキュメントを送信すると、該当ドキュメントに更新を行います。
  3. _rev値持っていないドキュメントを送信すると、新しくドキュメントを作成します。
    3.1 _id値を持っている場合は、指定された_id値で作成します。
    3.2 _id値を持っていない場合は、CouchDB側で自動生成します。

_bulk_docs APIを使用した場合、作成・更新・削除はすべて成功するか、すべて失敗するかのどちらかです(注1)。成功した場合は、新しいドキュメントの_id値および_rev値のリストが返されます(HTTPのステータスコードは201 Createdとなります)。

// レスポンス
{"ok":true,"new_revs":[
   {"id":"this is to be deleted","rev":"706319612"},
   {"id":"this is to be updated","rev":"3855203081"},
   {"id":"this is to be created","rev":"2529271358"},
   {"id":"2dc0d5e182c16b7d4a511f65f17d7875","rev":"3193048269"}
]}

サーバー上の_rev値と異なる_rev値を使用してリクエストした場合などは失敗の原因になり、次のようなエラーが返ってきます(HTTPのステータスコードは412 Precondition Failedとなります)。

// レスポンス
{"error":"conflict","reason":"Update conflict"}

注1

_bulk_docs APIは最新のバージョン0.9.0 で変更が加えられています。0.9.0以降では、本文の解説で記述した「すべて成功またはすべて失敗」の動作をさせるために、下記のように、all_or_nothing 値をtrueに設定しておく必要があります。

// リクエスト
// POST http://localhost:5984/sample_db1/_bulk_docs
{ "all_or_nothing" : true,
  "docs" : [
  { "_id" : "this is to be deleted", "_rev" : "2270995587", 
    "_deleted" : true},                                     // ... (1)
  { "_id" : "this is to be updated", "_rev" : "844902897",  
    "title" : "Updated Title"},                             // ... (2)
  { "_id" : "this is to be created",                       
    "title" : "New Title", "content" : "content."},         // ... (3.1)
  { "title" : "New Title", "content" : "content."}          // ... (3.2)
]}

ドキュメントにファイルを添付する

CouchDBはドキュメント指向データベースであり、JSONフォーマットで表すことができさえすれば、任意のデータをDBに格納することができますが、アプリケーションによっては画像ファイルやテキストファイルを(JSON形式に構造化せずに、バイナリやテキストデータとして)そのままDBに格納する必要もでてくるでしょう。CouchDBでは、「添付ファイル」という形で、その名の通りJSONドキュメントにファイル添付をすることができます。

JSONドキュメントにファイルを添付するには、JSONフォーマットのトップレベルに、_attachments値として、ファイル名をキーにして、バリューとしてコンテンツの種類(Content-Typeヘッダー値)とオリジナルファイルをbase64エンコードしたデータを埋め込みます。例えば、次のような形式で画像ファイルを添付できます(注2)。

// リクエスト
// PUT http://localhost:5984/sample_db3/document_with_attachments
{  "_id" : "document_with_attachments",
   "title" : "with attachments",
   "_attachments" : {
      "logo.png" : {
         "content_type":"image\/png",
         "data" : "base64エンコードされた文字列"
      }
   }
}

base64エンコードされた文字列は1行で埋め込みます。ライブラリやツールによっては、base64エンコードの際にエンコード後の文字列に改行を埋め込むものがあるので、そのような場合は改行を取り除く必要がでてくるでしょう。

この例では、1つのJSONドキュメントに1つのファイルを添付していますが、1つのドキュメント内でファイル名(_attachmentsにセットするオブジェクトのキー)が重複しなければ、ファイルはいくつでも添付することができます。そして、JSONドキュメントに添付されたファイルの1つ1つにはURIが割り当てられ、その場所は、http://{host}:{port}/{db_name}/{doc_id}/attachment_doc/{file_name} になります。上記の例であれば、http://localhost:5984/sample_db1/document_with_attachments/logo.png となります。このURIに対してGETリクエストを発行すると、CouchDB側でdecodeした結果がクライアントに返されます。保存するときはbase64エンコードをクライアントで行う必要がありましたが、取り出すときにはbase64デコードは不要です。また、CouchDBから返ってくるHTTP レスポンスヘッダーのContent-Typeは"content_type"に設定した値になります。

尚、ファイルを添付したJSONドキュメントをGETした場合は、添付ファイルのメタデータのみが返されるようになっています。

// リクエスト
// GET http://localhost:5984/sample_db1/document_with_attachments

// レスポンス
{
  "_id":"document_with_attachments","_rev":"1390767569",
  "title":"foobar",
  "_attachments":{
    "logo.png":{
      "stub":true,"content_type":
      "image\/png",
      "length":3010
    }
  }
}

データベースを複製する

Webの3層構造において、データベースを分散し、スケーラビリティを確保することは、大きな課題であり、もっとも頭を悩ませるのがデータベース層です。データベース層で取り扱うデータを、うまく分散配置することができれば、Webサーバー、アプリケーションサーバーと同じようにスケールアウト戦略をとることができます。データの分散配置のためには、独立したデータをどう分割するかという問題と、分割されたデータをネットワーク越しにどう組み合わせるかという問題について考えなければなりません。

CouchDBの実装では、後者の問題に対して、同じデータの複製をサイト内の至る所に配置し、どこからでも読み書きできるようにしておくことで解決を試みています。この機能はレプリケーションと呼ばれ、次のような特徴を持ちます。

  • クライアントにより、レプリケーション元とレプリケーション先を指定して実行されます。
  • サーバーに設定を加えて自動的なレプリケーションを行うのではなく、クライアントの要求に応じてレプリケーションが実行されます。
  • 双方向でレプリケーションが可能です。
  • Note A → Node B にレプリケーションを実行した後で、逆向きに、つまり、Node B → Node A にレプリケーションを行うことが可能です。
  • レプリケーションのトポロジは自由であり、Master/Slave 構成からP2P構成まで、アプリケーションの特性に応じて組み合わせることができます。
  • レプリケーションはノード間のリビジョンの差分を転送することで実現されます。
  • 前回のレプリケーションから変更のないドキュメントは転送されません。
  • レプリケーションにより、ドキュメントの競合が発生する場合、競合はCouchDBにより隠蔽されます。
  • 競合の解決が必要な場合は、クライアントから指示を出す必要があります。

実際にレプリケーション機能を利用するには次のように、http://{host}:{port}/_replicate にPOSTリクエストを発行します。

// リクエスト
// POST http://localhost:5984/_replicate
{ 
  "source" : "sample_db1",
  "target" : "sample_db2" 
}

source および target には データベース名かデータベースのURIを使用することができルため、ローカル同士、リモート同士、あるいはローカルからリモート、リモートからローカル、といった様々な形でレプリケーションを実行することができます。上記の場合は、http://localhost:5984 上にある、sample_db1 のデータを同じサーバー上にあるsample_db2に複製します(ローカル同士の複製)。

_replicate APIは、複製のステータスを次のようなJSONドキュメントとしてクライアントに返します。複製のステータスの中には、複製の履歴が含まれています。

// レスポンス
{
  "ok":true,
  "session_id":"9ac1a0483ec71923b8285382968182c2",
  "source_last_seq":11,
  "history":[{
           "start_time":"Sun, 10 May 2009 20:15:13 GMT",
           "end_time":"Sun, 10 May 2009 20:15:13 GMT",
           "start_last_seq":0,
           "end_last_seq":11,
           "missing_checked":5,
           "missing_found":5,
           "docs_read":5,
           "docs_written":5
           },{
           "start_time":"Sun, 10 May 2009 20:14:00 GMT",
           "end_time":"Sun, 10 May 2009 20:14:01 GMT",
           "start_last_seq":0,
           "end_last_seq":11,
           "missing_checked":5,
           "missing_found":5,
           "docs_read":5,
           "docs_written":5
           }]
}

レプリケーションを実行することで、source にあるすべてのドキュメントはtarget上に作成されます。あるいは、source 上で削除されたドキュメントは、targetからも削除されます。ただし、sourceには作成しておらず、target 上に作成したドキュメントが、source上にコピーされるわけではありません。完全にsourceとtargetの内容を同じにするには、source と target を入れ替えて_replicationを再実行する必要があります。

Master/Slave 構成のレプリケーショントポロジを組むのであれば、POST/PUT/DELETEリクエストは常に唯一のMasterに対して発行する、GETリクエストはSlaveに対して発行する、というアプリケーションの条件の下で、sourceにMasterを、target にSlaveを指定して、Slaveの数だけレプリケーションを実行すればよいでしょう。この場合は、競合を気にする必要はなく、Master → Slave へのレプリケーションをどれぐらいの頻度で行うかが問題になるでしょう。

これに対して、POST/PUT/DELETEを複数のサーバーに対して行い、この複数サーバー間でレプリケーションを行う場合は、データの競合の問題が発生します。

例えば、2つのデータベースmaster1とmaster2を用意し、レプリケーション済みの状態で、次のようなドキュメントが存在したとします。

// 同期されたドキュメント
{ "_id":"sample_confliction", "_rev":"2154393544",
  "field1":"this field is to be conflicted."}

ここでmaster1上のsample_conflictionドキュメントを更新します。filed1 に、データベース名を入れておきます。例えば、field1に"updated on masater1"という文字列を設定します。

// リクエスト
// PUT http://localhost:5984/master1/sample_confliction
{"_id":"sample_confliction","_rev":"2154393544",
 "field1":"updated on master1."}

このリクエストが成功すれば、_rev は更新されるのでGETリクエストに対して以下のような結果が返されます。

// リクエスト
// GET http://localhost:5984/master1/sample_confliction

// レスポンス
{"_id":"sample_confliction","_rev":"468910462",
 "field1":"updated on master1"}

同様に、master2側でも同じようにfield1に、データベース名を入れておきます。例えば、field1に"updated on masater2"という文字列を設定します。

// リクエスト
// PUT http://localhost:5984/master1/sample_confliction
{"_id":"sample_confliction","_rev":"2154393544",
 "field1”:"updated on master2."}

このリクエストが成功すれば、_rev は更新されるのでGETリクエストに対して以下のような結果が返されます。

// リクエスト
// GET http://localhost:5984/master2/sample_confliction

// レスポンス
{"_id":"sample_confliction","_rev":"4196901023",
 "field1":"updated on master2"}

では、この状態でmaster1からmaster2へのレプリケーションを実行します。ドキュメントは競合しますが、レプリケーション自体は成功します。

// リクエスト
// POST http://localhost:5984/_replicate
{ 
  "source" : "master1",
  "target" : "master2" 
}

/master2/sample_confliction ドキュメントの内容を確認すると、filed1の値がmaster1で更新した値になっています。

// リクエスト
// GET http://localhost:5984/master2/sample_confliction

// レスポンス
{"_id":"sample_confliction","_rev":"468910462",
 "field1":"updated on master1"}

CouchDBではドキュメントの競合問題は、「後でクライアントが解決する」ことになっています。クライアントから、ドキュメントに対してconflicts=trueというクエリをつけて、ドキュメントに対してGETリクエスト発行すると、CouchDBによって隠蔽されたドキュメントのリビジョン番号のリストを受け取ることができ、それを参照することで競合が発生しているかどうかが確認できます。

// リクエスト
// GET http://localhost:5984/master2/sample_confliction?conflicts=true

// レスポンス
{"_id":"sample_confliction","_rev":"468910462",
 "field1":"updated on master1",
 "_conflicts":["4196901023"]}

_conflicts に格納された配列は、レプリケーションによって(競合として)上書きされたドキュメントの_rev値を示しています。このドキュメントを取り出すには、revというクエリをつけて、ドキュメントに対してGETリクエストを発行することになります (rev指定によるGETリクエストは、競合ドキュメントの参照のみならず、過去のドキュメントを参照する場合に使用するものです)。

// リクエスト
// GET http://localhost:5984/master2/sample_confliction?rev=4196901023

// レスポンス
{"_id":"sample_confliction","_rev":"4196901023",
 "field1":"updated on master2"}

この例で見たように、CouchDBのレプリケーションは、競合を自動的に隠蔽します。そのため、競合が発生するようなレプリケーションを行う場合は、レプリケーション後に競合の可能性のあるドキュメントすべてについて、_conflictsの値を確認し、必要に応じて(競合ドキュメントをクライアントから再PUTするなど)修復処理を実装しなければなりません。また、上記の例では、target上で更新したドキュメントが隠されてしまいましたが、必ずしもsource上で更新したドキュメントがtarget上に反映されるとは限らず、target上の_conflictsとして隠蔽される場合もあります(これはCouchDBが管理している各データベースのupdate_seqという情報に依存します)。

CouchDBは、分散データベースとして扱うことができますが、複数のデータベースにまたがって更新を行い、さらにそのデータベース間を同期させるような使い方をする場合には、このような競合が発生する場合の解決策をアプリケーション側で実装しておく、あるいは、そもそも競合が発生しないような更新方法(例えば_id値によって、更新するサーバーを振り分ける、等)を十分に検討しておく必要があります。CouchDB のWikiには、いくつかのパターンが記述されていますので、そちらも参考にしてください[4]

ドキュメントのリビジョンの詳細

ここでは、もう少し、ドキュメントのリビジョンについて掘り下げて見てみます。

CouchDBは、ドキュメントの更新を、"追加のみ"による操作で行います。つまり、ドキュメントに対してPUTリクエストで更新をかける場合にも、元のドキュメントは失われず、更新後の新しいドキュメントが

ストレージに追加され、ドキュメントのURIは、最後に追加されたデータを参照するようになります。このデータの追加時に、追加するデータにリビジョン、という番号札をつけることで、最新ではないドキュメントも後から参照できるようになっています。

ドキュメントの更新履歴を参照するには、revsというクエリをつけて、ドキュメントに対してGETリクエストを発行します。

// リクエスト
// GET http://localhost:5984/sample_db3/sample?revs=true

// レスポンス
{"_id":"sample","_rev":"398308450",
 "a":"foo",
 "_revs":["398308450","1039380517","4158241787"]}

このようにして得られる_revsの配列に含まれる値を使えば、任意の時点での過去データを参照できます。例えば、最初に作られた時点でのデータを参照する場合は、_revsの末尾を参照します。

// リクエスト
// GET http://localhost:5984/sample_db3/sample?rev=4158241787

// レスポンス
{"_id":"sample","_rev":"4158241787"}

さらに、ドキュメントの削除(DELETEリクエスト)に関しても、同様に追記のみによる操作が行われます。これを確認するため、上記の例に対して、DELETEを発行します。DELETEを発行する場合は、最新の_rev値をクエリパラメーターrevに入れる必要があります。

// リクエスト
// DELETE http://localhost:5984/sample_db3/sample?rev=398308450

// レスポンス
{"ok":true,"id":"sample","rev":"3783912110"}

ここで、PUTやPOST時とおなじく、” “rev” の値が返されていることに注目してください。これは、内部的に削除されたことを示すドキュメント(DELETEドキュメントとでも呼びましょう)が、3783912110というリビジョンで作られたことを意味しています。DELETEドキュメントが最新版である場合には、ドキュメントのURIである/sample_db3/sample に対してGETリクエストを送信しても、404 Not Foundが返される、というのがCouchDBのREST APIの詳細になっています。

さて、ここで、再度同じURIで作成を行います。

// リクエスト
// PUT http://localhost:5984/sample_db3/sample
{ "_id":"sample" }
// レスポンス
{"ok":true,"id":"sample","rev":"445409692"}

さらに、sampleドキュメントの_revsを確認します。

// リクエスト
GET http://localhost:5984/sample_db3/sample?revs=true

// レスポンス
{"_id":"sample","_rev":"445409692","id":"sample",
 "_revs":["445409692","3783912110","398308450",
          "1039380517","4158241787"]}

一度ドキュメントは削除されましたが、削除以前に持っていた、"398308450","1039380517","4158241787"というリビジョン番号は有効で参照可能です。また、DELETEドキュメントのリビジョン、3783912110も含まれています。このリビジョンを指定してドキュメントを確認すると、DELETE操作の正体がわかります。

// リクエスト
// GET http://localhost:5984/sample_db3/sample?rev=3783912110

// レスポンス
{"_id":"sample","_rev":"3783912110","_deleted":true}

つまり、DELTE操作は、上記のような削除ドキュメントの追加、という操作を意味しています。ここで、今回の最初に紹介した_bulk_docs APIでの削除方法を思い出してください。_bulk_docs でのドキュメントの一括削除は、DELETEドキュメントで既存のドキュメントを更新する、という仕掛けで動いています。

// リクエスト
// POST http://localhost:5984/sample_db1/_bulk_docs
{ "docs" : [
  { "_id" : "this is to be deleted", "_rev" : "2270995587", 
    "_deleted" : true}, 
  …

データベースの(不可逆)圧縮

さて、ここまで見たとおり、削除も含めたドキュメントの更新処理はすべてドキュメントの追加によって実装されていますが、これではストレージが肥大化する一方です。従って、実際にシステムとして運用する場合には、必要に古いリビジョンのドキュメントを削除していく必要がでてくるでしょう。

これに対して、CouchDBではcompactionという機能を提供しており、http://{host}:{port}/{dbname}/_compact というURIにPOSTリクエストでアクセスすることで利用可能です。先ほどのsample_db3に対して、compactionを行うには、次のようにします。

// リクエスト
// POST http://localhost:5984/sample_db3/_compact

// レスポンス
{"ok":true}

compaction(圧縮)という名前ですが、実体は、最新のリビジョン以外をストレージから消去する行為です。ストレージから消去削除された古いドキュメントは参照不可能になります。

// リクエスト
// GET http://localhost:5984/sample_db3/sample?revs=true

// レスポンス]
{"_id":"sample","_rev":"445409692","id":"sample",
 "_revs":["445409692","3783912110","398308450","1039380517","4158241787"]}

// リクエスト
// GET http://localhost:5984/sample_db3/sample?rev=4158241787

// レスポンス
{"error":"error","reason":"{{not_found,missing},\"4158241787\"}"}

このように、compaction実行前のドキュメントについては、_revsでリビジョン番号を参照可能ですが、実際にアクセスしてもエラーになります。また、_conflictsに格納されている競合ドキュメントは削除されません。

実際の運用の際には、データベースファイルのバックアップを取得してから、compactionを実行する、といった形になるでしょう。CouchDBのデータベースファイルはUnixシステムでは通常 /usr/local/var/lib/couchdb ディレクトリに{dbname}.couch という形で保管されているので、このファイルをrsyncやcpコマンドなどでバックアップすることになります。このデータベースファイルは、CouchDBサーバーを止める必要がなくオンラインのままバックアップ可能です[5]。バックアップからの復元については、復元先のCouchDBを停止させ、データベースファイルを元のディレクトリに戻してやることで復元可能です。

まとめと次回予告

今回は、CouchDB の様々な便利機能を紹介しました。連載の間隔が長引いてしまったため、CouchDBプロジェクトではすでに新しいバージョンである0.9.0が公開されていますが、今回紹介した内容は、0.8.0および0.9.0で共通の内容です。

次回は、本連載の最終回ということで、0.9.0の最新機能を紹介し、CouchDBがサポートするドキュメント指向アプリケーションについて考察を加えたいと思います。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Web development, Open source
ArticleID=390824
ArticleTitle=Web 時代の非リレーショナルデータベース: 第 4 回 Apache CouchDB の便利な機能を習得する
publish-date=05292009