QDBMは、シンプルながら便利なデータベースライブラリです。データベースというとSQLやリレーショナルデータベースを思い浮かべる人が多いと思いますが、QDBMはそんな高機能なものではありません。「キー」と「値」の組からなるレコードをファイルに保存したり、保存しておいたレコードの中から特定のキーを持つものを取り出す機能を提供するだけです。そのような機能をここでは「ハッシュデータベース」と呼ぶことにします。ハッシュデータベースの特長は、使い方が簡単で、パフォーマンスが高いことです。
QDBMはC言語のライブラリです(他の言語のAPIもありますが)。QDBMにはハッシュデータベースの機能だけでなく、私(QDBMの作者)がプログラミングをする時によく使う機能が詰め込まれています。Cで書いたプログラムは高速に動作するのが利点ですが、C++、Java、Perl、Rubyといった比較的高級な言語では標準的にサポートされるデータ構造やアルゴリズムを自分で実装しなければなりません。そういった作業は面倒ですし、バグを生みやすいものです。そこで、QDBMの登場です。QDBMを再利用すれば、CでのプログラミングがPerlを使っているかのように手軽になります。しかも、UNIXでもWindowsでもMac OS Xでも利用できるので、移植性のあるプログラムが書きやすくなります。
シンプルといいながら、QDBMの機能はなかなか豊富です。データベースとしては、ハッシュ表とB+木が利用できます。メモリ上で扱うユーティリティとしては、リストやマップなどがあります。MIMEやCSVやXMLの解析もできます。しまいには全文検索までできたりするので驚きです。
このチュートリアルではQDBMの使い方を簡単に説明するとともに、基本仕様書の補足を述べます。QDBMの詳細については基本仕様書を御覧ください。なお、ここではハッシュ表やB+木といったデータ構造の説明はしませんので、不慣れな方は適当な本やWebサイトで調べておいてください。
データベースアプリケーションの典型的な例として、「社員番号を入力すると、その内線番号がわかる」というプログラムを考えてみましょう。社員番号をキーとして、それに対応する値である内線番号を検索するということです。
まずはQDBMを使わないでやってみます。社員番号と内線番号の対応表は、CSVテキストでファイルに保存することにします。書式の例を以下に示します。
00001,8-902-1234 00002,7-938-834 00008,4-214-491
レコードを加える関数 `putphonenumber' と、レコードを検索する関数 `getphonenumber' を実装します。
#include <stdio.h> #include <stdlib.h> #include <string.h> #define PHONEFILE "phone" #define LINEBUFSIZ 256 int putphonenumber(const char *id, const char *phone){ FILE *OUT; /* ファイルを追記モードで開く */ if(!(OUT = fopen(PHONEFILE, "a"))) return -1; /* レコードを書き込む */ fprintf(OUT, "%s,%s\n", id, phone); /* ファイルを閉じる */ if(fclose(OUT) != 0) return -1; return 0; } char *getphonenumber(const char *id){ FILE *IN; char line[LINEBUFSIZ], *pivot, *phone; int len; /* ファイルを読み込みモードで開く */ if(!(IN = fopen(PHONEFILE, "r"))) return NULL; /* 各行を読み込む */ while(fscanf(IN, "%s", line) == 1){ /* 区切り文字を処理する */ if(!(pivot = strchr(line, ','))) continue; *pivot = '\0'; pivot++; /* キーの一致判定 */ if(strcmp(line, id) == 0){ /* ファイルを閉じる */ if(fclose(IN) != 0) return NULL; /* メモリを確保して戻り値を生成する */ len = strlen(pivot); if(!(phone = malloc(len + 1))) return NULL; memcpy(phone, pivot, len + 1); return phone; } } /* ファイルを閉じる */ fclose(IN); return NULL; }
`fscanf' を使っている時点でかなり貧弱ですが、きちんと書こうとすると非常に長くなるので妥協しました(ちなみに、255文字を越える行があったら暴走します)。とにかく、この程度の処理でやたら長いコードを書かねばならないのでは悲しくなります。さらに重大な欠点は、検索の処理が遅いということです。ファイルの最初から最後まで(平均的には半分まで)読まなければならないからです。既存のレコードを修正する時にもかなり面倒なことをしなければなりません。
QDBMを使えばもっとエレガントなコードが書けます。上記と同じ機能の関数を実装してみます。
#include <depot.h> #include <stdlib.h> #define PHONEFILE "phone" int putphonenumber(const char *id, const char *phone){ DEPOT *depot; /* データベースを追記モードで開く */ if(!(depot = dpopen(PHONEFILE, DP_OWRITER | DP_OCREAT, -1))) return -1; /* レコードを書き込む */ dpput(depot, id, -1, phone, -1, DP_DOVER); /* データベースを閉じる */ if(!dpclose(depot)) return -1; return 0; } char *getphonenumber(const char *id){ DEPOT *depot; char *phone; /* データベースを読み込みモードで開く */ if(!(depot = dpopen(PHONEFILE, DP_OREADER, -1))) return NULL; /* レコードを探索して戻り値を生成する */ phone = dpget(depot, id, -1, 0, -1, NULL); /* データベースを閉じる */ dpclose(depot); return phone; }
もはやファイル形式はCSVファイルではなく、区切り文字が何であるか気にする必要はありません。プログラマはファイル形式がどうであるかなど考えなくてもよいのです。メモリの確保などもQDBMの内部でやってくれるので、バッファのサイズを気にする必要はありません(解放は必要です)。処理速度を気にする必要もありません。データベースがどんなに大きくても、レコードの追加や削除や検索が一瞬でできます。このように、プログラマをデータ管理の苦悩から解放するのがQDBMの役割です。
上記の例ではQDBMの基本APIであるDepotを利用しています。まず、`DEPOT' という型が登場しています。これは標準ライブラリの `FILE' と同様に、操作対象のファイルの情報を格納している構造体の型です。この型へのポインタをハンドルとして各種の関数に渡すことになります。関数としては、`dpopen'、`dpclose'、`dpput' および `dpget' が登場しています。この四つの使い方を覚えればQDBMの半分は理解したようなものです。
`dpopen' はその名の通り、Depotのデータベースを開く関数です。その結果として `DEPOT' 型の構造体へのポインタが返されます。第1引数にはデータを格納するファイル名を指定します。これは相対パスで指定しても絶対パスで指定しても構いません。第2引数は接続モードを指定します。読み込みと書き込みの両方をするなら `DP_OWRITER' を指定します。ただし、データベースファイルが存在しない場合に新規作成するならば同時に `DP_OCREAT' をビットORとして加える必要があります。この二つを指定すると、`fopen' の `a+' モードとほぼ同じ意味になります。他に `DP_OTRUNC' というフラグがあるのですが、それはファイルを切り詰めることを指示します。それも加えて三つを指定すると `fopen' の `w+' モードとほぼ同じ意味になります。読み込みだけをする場合、`DP_OREADER' を指定します。これは `fopen' の `r' モードとほぼ同じ意味です。第3引数はハッシュ表のバケット数を指定します。とりあえずデフォルト値を意味する `-1' を指定しておけばいいでしょう。
複数のプロセスが同じファイルを読み書きする場合、「レースコンディション」という問題が起こります。同時にファイルに書き込むと、内容が混ざって変になってしまう可能性があるのです。QDBMではそれに対処するために「ファイルロック」をかけます。あるプロセスがデータベースを書き込みモードで開いている場合は、他のプロセスがデータベースを開こうとしてもブロックされるのです。処理が失敗するわけではなく、既にデータベースを開いているプロセスがデータベースを閉じるまで待ってくれるのです。なお、読み込みモード同士であればレースコンディションは起こらないので同時にアクセスすることができます。
`dpclose' はデータベースを閉じる関数です。第1引数には `dpopen' で開いたハンドルを渡します。開いたデータベースは必ず閉じてください。そうしないとデータベースが壊れます(読み込みモードの場合は壊れませんが、メモリリークになります)。
ところで、QDBMを使わない例では、書き込みの際に呼び出す `fprintf' の戻り値をチェックしていません。`fprintf' が失敗した場合は `fclose' もエラーを返すと規定されているからです。同様に、QDBMでもデータベースに一度でも致命的なエラーが起きた場合は `dpclose' がエラーを返すので、エラーチェックを簡略化することができるのです。
`dpput' はデータベースにレコードを追加する関数です。第1引数には `dpopen' で開いたハンドルを渡します。第2引数には、書き込むレコードのキーの内容を保持する領域へのポインタを指定します。第3引数にはその領域のサイズを指定します。それが負数の場合は、第2引数を文字列として扱って、`strlen' の値をサイズとして判定します(文字列とバイナリの両方を簡単に扱えるようにするためです)。第4引数と第5引数は、レコードの値に関して同様にポインタとサイズを指定します。第6引数は書き込みのモードです。データベース内には同じキーを持つ複数のレコードを格納することができないので、既存のレコードのキーと同一のキーを持つレコードを格納しようとした際にどうするかを指示する必要があります。`DP_DOVER' とした場合は、既存のレコードを新しいレコードで上書きします。`DP_DKEEP' とした場合は、既存のレコードを優先し、エラーが返されます(`DP_EKEEP' というエラーコードが外部変数 `dpecode' に設定されます)。
`dpput' の書き込みモードには、`DP_DCAT' による「連結モード」もあります。これを利用することはあまりないかもしれませんが、他のDBMにはない特徴なので説明します。連結モードは、レコードの値として配列を入れる際に便利なのです。例えば、[10,11,12] という三つの数値を要素に持つ配列を格納していて、それを [10,11,12,13] にしたい場合を考えてみます。他のDBMでは、まずそのレコードを検索して、[10,11,12] を獲得してから、それを [10,11,12,13] に加工して値を生成し、元のレコードに上書きで書き込むといったことをしなければなりません。QDBMの場合は、連結モードで [13] を既存のレコードに書き込むだけで同じことができます。配列の要素数が大きい場合にはこの違いはパフォーマンスに大きな影響を及ぼします。
`dpget' はデータベースを検索してレコードを取り出す関数です。第1引数は他と一緒ですね。第2引数と第3引数は `dpput' と同様に検索キーのポインタとサイズを指定します。第4引数と第5引数はとりあえず `0' と '-1' にするものだと思っていただいて結構です。一応説明すると、取り出す領域の開始オフセットと最大サイズを指定します。`-1' は無制限という意味です。例えばレコードの値が `abcdef' の場合に開始オフセットを `1'、サイズを `3' にした場合、`bcd' が取り出されます。戻り値は取り出した値の内容を記録した領域へのポインタです。その領域は `malloc' でヒープに確保されているので、アプリケーションの責任で `free' に渡して解放する必要があります。戻り値の領域は終端にゼロコードが付加されていることが保証されているので、文字列として利用できます。ただし、バイナリを扱う場合には明示的にサイズが知りたいでしょうから、その為に第6引数にサイズを受け取る変数へのポインタを指定することができます(このサイズには終端のゼロコードは勘定されません)。該当のレコードがない場合には `NULL' が返されます(`DP_ENOITEM' というエラーコードが外部変数 `dpecode' に設定されます)。
Depotの関数で他によく使うものとして、`dpout'、`dpiterinit'、`dpiternext' があります。これらについても説明しておきます。
`dpout' はデータベースからレコードを削除する関数です。三つの引数の扱いは `dpget' のものと同じです。該当のレコードがない場合はエラーを返します(`DP_ENOITEM' というエラーコードが外部変数 `dpecode' に設定されます)。
`dpiterinit' と `dpiternext' はデータベースの中のレコードを一つ一つ見ていく場合に使います。先程の内線番号データベースの全レコードを表示する関数の例を以下に示します。
#include <depot.h> #include <stdlib.h> #include <stdio.h> #define PHONEFILE "phone" int printphonenumbers(void){ DEPOT *depot; char *kbuf, *vbuf; /* データベースを読み込みモードで開く */ if(!(depot = dpopen(PHONEFILE, DP_OREADER, -1))) return -1; /* イテレータを初期化する */ dpiterinit(depot); /* 各レコードのキーを取り出す */ while((kbuf = dpiternext(depot, NULL)) != NULL){ /* 各レコードの値を取り出す */ if((vbuf = dpget(depot, kbuf, -1, 0, -1, NULL)) != NULL){ printf("%s: %s\n", kbuf, vbuf); /* 値の領域を解放する */ free(vbuf); } /* キーの領域を解放する */ free(kbuf); } /* データベースを閉じる */ return dpclose(depot) ? 0 : -1; }
全てのレコードを横断的に見ていくことを「トラバーサルアクセス」と呼ぶことにします。トラバーサルアクセスの際には、「イテレータ」を利用します。イテレータは集合に含まれる個々の要素を処理する際に、その要素を一つずつ取り出す機能です。トラバーサルアクセスをはじめる前にはイテレータを初期化します。そして、イテレータが「打ち止め」の合図を返すまで繰り返して呼び出します。
`dpiterinit' はイテレータを初期化する関数です。特に説明は必要ありませんね。
`dpiternext' はイテレータから次のレコードのキーを取り出す関数です。第1引数は他と同じです。例によって戻り値はゼロコードが付加された領域へのポインタです。領域は `malloc' で確保されているので、アプリケーションの責任で `free' してください。明示的にサイズを知りたい場合は第2引数にそれを受け取る変数へのポインタを指定します。もう取り出すレコードがなくなったら `NULL' が返されます(`DP_ENOITEM' というエラーコードが外部変数 `dpecode' に設定されます)。
トラバーサルアクセスで各レコードを辿る順番については規定されていませんので、レコードの順序に依存したプログラミングをしてはいけません(そのような場合はB+木データベースを使いましょう)。また、イテレータを繰り返している途中でレコードを上書きした場合、既に読んだはずのレコードがまた取り出される可能性があります。途中でレコードを削除することに関しては問題ありません。
ここまででDepotの基本的な使い方は説明し終えました。QDBMのそれ以外のAPIもDepotに似たような使い方をしますので、基本仕様書のサンプルコードを見れば理解してもらえると思います。
Curiaは、Depotとほとんど同じ機能とインタフェースを備えますが、ファイルでなくディレクトリとしてデータベースを扱うAPIです。やたらと大量のデータを扱わなければならない場合にはCuriaがお薦めです。データをディレクトリの中の複数のファイルに分散して格納するので、Depotよりも大きな(2GB以上の)データベースを扱うことができます。Depotではハンドルに `DEPOT' へのポインタを使いましたが、Curiaでは `CURIA' へのポインタを使います。そして、Depotでは `dp' で始まっていた関数名が、Curiaでは `cr' で始まります。名前が違うだけで、使い方は全く一緒です。ただし、データベースを開く `cropen' という関数は、データベースの分割数を指定する引数が増えています。
第1、第2、第3引数は `dpopen' のものと全く一緒です。第4引数は、データベースの分割数を指定します。ディレクトリの中に、指定した数だけのファイルが作られます。なお、第3引数で指定した値はその各々のデータベースファイルが持つバケット数になります。戻り値はCuriaのデータベースハンドルとなります。
Curiaには「ラージオブジェクト」を扱う機能もあります。ラージオブジェクトとは、各レコードをファイルとして独立させて保存する仕組みです。ラージオブジェクトにすると通常のレコードより処理速度が落ちますが、データベースのサイズを無限に(ディスクが許す限り)大きくすることができます。なお、ラージオブジェクトのトラバーサルアクセスはサポートされません。
Relicは、NDBMのアプリケーションをすべからくQDBMに乗り換えさせるという野望の下に作られたAPIです。パフォーマンスはオリジナルのNDBMの数倍は出ます。NDBMのアプリケーションはあまり見ないですが、Perl等のスクリプト言語がそのインタフェースを備えています。つまりRelicによって各種のスクリプト言語でQDBMが使えることが保証されるわけです。
あなたのアプリケーションのソースコード中で `ndbm.h' をインクルードしている部分を `relic.h' に書き換え、リンク対象を `ndbm' から `qdbm' に換えて再コンパイルしください。それだけであなたのアプリケーションはQDBMに乗り換えることができます。なお、新たにアプリケーションを書く際には、RelicでなくDepotを利用することをお薦めします。
Hovelは、GDBMのアプリケーションをすべからくQDBMに乗り換えさせるという野望の下に作られたAPIです。パフォーマンスはオリジナルのGDBMの数倍は出ます。GDBMのアプリケーションは市場に多く見られますが、それらをお使いの場合は、ぜひともQDBMに移植してあげてください。パフォーマンスが目に見えてに改善されることうけあいです。
あなたのアプリケーションのソースコード中で `gdbm.h' をインクルードしている部分を `hovel.h' に書き換え、リンク対象を `gdbm' から `qdbm' に換えて再コンパイルしください。それだけであなたのアプリケーションはQDBMに乗り換えることができます。なお、新たにアプリケーションを書く際には、HovelでなくDepotを利用することをお薦めします。
通常、Hovelが生成するデータベースファイルはDepotのものと全く同じものです。しかし、ちょっと細工するとそれをCuriaによるデータベースディレクトリに変更することができます。データベースハンドルを取得する関数 `gdbm_open' を、`gdbm_open2' に書き換えればよいのです。単一のファイルにデータベースを格納している場合は、ファイルサイズが2GBまでという制限にひっかかってしまいますが、`gdbm_open2' を使えばそれを乗り越えることができます。`gdbm_open' を呼び出しているところ以外は全く変更する必要がないというのが嬉しいところです。
第1引数は生成するファイルかディレクトリの名前です。第2引数と第3引数は `gdbm_open' の第3引数と第4引数として渡すものと一緒です。第4引数はバケットの要素数です。第5引数はデータベースファイルの分割数です。第6引数は各レコードのアラインメントです。戻り値は `gdbm_open' と同じくデータベースハンドルです。
Cabinは、データの操作を簡単に行うためのユーティリティを集めたAPIです。密かにQDBMのAPIの中で最も充実しています。特にリストとマップに関連する関数が重宝します。他にも、ファイルやディレクトリを読んだり、文字列を分割したり、CSVやXMLを解析したり、各種の符号化と復号もできます。ここではリストとマップとXMLについて詳しく説明します。
リストとは、順序を持った集合のことです。Cabinが扱うリストには任意の文字列やバイナリを要素として加えることができます。リストの先頭に対して要素の追加と削除ができるとともに、リストの末尾に対して要素の追加と削除をすることもできます(つまりデクです)。また、配列を使って実装されているので、任意の順番の要素の値を高速に参照することができます。以下の例では、`first'、`second'、`third' という文字列を順に末尾から追加した上で、先頭から末尾まで要素の内容を表示しています。
#include <cabin.h> #include <stdlib.h> #include <stdio.h> void listtest(void){ CBLIST *list; int i; /* リストを開く */ list = cblistopen(); /* 要素を末尾から追加する */ cblistpush(list, "first", -1); cblistpush(list, "second", -1); cblistpush(list, "third", -1); /* 先頭から要素の内容を表示する */ for(i = 0; i < cblistnum(list); i++){ printf("%s\n", cblistval(list, i, NULL)); } /* リストを閉じる */ cblistclose(list); }
`CBLIST' 型へのポインタをリストのハンドルとして用います。実際のハンドルは `cblistopen' を呼び出して獲得します。ハンドルを閉じてメモリを解放するには、`cblistclose' を呼び出します。`cblistpush' は末尾に要素を追加します。`cblistnum' はリストの要素数を返します。`cblistval' はリスト内の特定の番号(ゼロからはじまる)の要素を返します。リスト操作の関数はその他にもいくつかあります。
マップとは、キーと値からなるレコードの集合です。Cabinが扱うマップには任意の文字列やバイナリをキーや値に持つレコードを格納することができます。キーが完全に一致するレコードを検索して値を取り出すことができます(実装はハッシュ表です)。マップ内のレコードを先頭から一つずつ取り出すこともできます。なお、各要素は格納した順番で並んでいることが保証されています。以下の例では、キー `one' と値 `first'、キー `two' と値 `second'、キー `three' と値 `third' のレコードを順に格納した上で、その各々を検索して表示しています。
#include <cabin.h> #include <stdlib.h> #include <stdio.h> void maptest(void){ CBMAP *map; /* マップを開く */ map = cbmapopen(); /* レコードを追加する */ cbmapput(map, "one", -1, "first", -1, 1); cbmapput(map, "two", -1, "second", -1, 1); cbmapput(map, "three", -1, "third", -1, 1); /* レコードを検索して内容を表示する */ printf("one: %s\n", cbmapget(map, "one", -1, NULL)); printf("two: %s\n", cbmapget(map, "two", -1, NULL)); printf("three: %s\n", cbmapget(map, "three", -1, NULL)); /* マップを閉じる */ cbmapclose(map); }
`CBMAP' 型へのポインタをマップのハンドルとして用います。実際のハンドルは `cbmapopen' を呼び出して獲得します。ハンドルを閉じてメモリを解放するには、`cbmapclose' を呼び出します。`cbmapput' でレコードを追加します。`cbmapget' でレコードを検索します。マップ操作の関数はその他にもいくつかあります。
XMLを簡単に処理するために、簡易的なパーザが用意されています。このパーザは妥当性検証をせず、書式の検査も厳密でないのが特徴です。したがって、一般的なHTMLやSGMLの解析にも用いることができます。単純な構造のXML文書を処理する際には、DOMやSAXといったAPIを使うよりも便利です。以下の例では、XML文書の中から `id' 属性の値で要素を指定して、そのテキストを取り出して表示します。
#include <cabin.h> #include <stdlib.h> #include <string.h> #include <stdio.h> void showtextbyid(const char *xml, const char *id){ CBLIST *elems; CBMAP *attrs; const char *elem, *attr; char *orig; int i; /* タグとテキストのリストを取得する */ elems = cbxmlbreak(xml, 1); /* リストの各要素をたどる */ for(i = 0; i < cblistnum(elems); i++){ /* 要素を取り出す */ elem = cblistval(elems, i, NULL); /* タグでない場合は読み飛ばす */ if(elem[0] != '<' || elem[1] == '?' || elem[1] == '!' || elem[1] == '/') continue; /* 属性のマップを取得する */ attrs = cbxmlattrs(elem); /* ID要素の値を取り出し、一致を検査する */ attr = cbmapget(attrs, "id", -1, NULL); if(attr && !strcmp(attr, id)){ /* 次の要素を取り出す */ elem = cblistval(elems, i + 1, NULL); if(elem){ /* 実体参照を復元して表示する */ orig = cbxmlunescape(elem); printf("%s\n", orig); free(orig); } } /* 属性マップを閉じる */ cbmapclose(attrs); } /* 要素リストを閉じる */ cblistclose(elems); }
処理対象のXML文書のテキストを `cbxmlbreak' で分解します。例えば `<body><p id="nuts">NUTS&MILK</p></body>' を分解すると、`<body>'、`<p id="nuts">'、`NUTS&MILK'、`</p>'、`</body>' が得られます。そして、各要素を巡回します。1文字目が '<' であればタグか各種の宣言であり、かつ2文字目が '?'、`!'、`/' のいずれでもなければ開始タグまたは空タグであると判断できます。タグに対して `cbxmlattrs' を呼ぶことで属性のマップが得られます。このマップは属性名をキーにして値を取り出すことができます。属性値やテキストセクションの文字列は文書内に出現したままの形式になっています。実体参照を含んだ文字列を復元するには `cbxmlunescape' を用います。
GTK+に付属するGLibやApacheに付属するAPRなどの便利なライブラリが世の中にはありますので、単体でCabinを利用する価値はあまりありません。正直言って、GLibやAPRの方が高機能で、ユーザ数も多く、参考になる情報も多いです。とはいえ、Cabinの方が手軽に使えるので私は好きです。
Villaは、B+木のデータベースを扱うAPIです。B+木データベースにはユーザが指定した順序でレコードが並べられて格納されます。DepotやCuriaはキーの完全一致による検索しかできませんが、Villaを用いると範囲を指定してレコードを検索することができます。また、同じキーを持つ複数のレコードを格納することもできます。例えば、キーを文字列の辞書順(ABC順とかアイウエオ順と同じほぼ意味です)で並べるように指定した場合は、文字列の前方一致検索ができるのです。
とはいえ、基本的な使い方はDepotと一緒です。Depotの説明で挙げた関数をVillaを使って実装しなおしてみます。
#include <depot.h> #include <cabin.h> #include <villa.h> #include <stdlib.h> #define PHONEFILE "phone" int putphonenumber(const char *id, const char *phone){ VILLA *villa; /* データベースを追記モードで開く */ if(!(villa = vlopen(PHONEFILE, VL_OWRITER | VL_OCREAT, VL_CMPLEX))) return -1; /* レコードを書き込む */ vlput(villa, id, -1, phone, -1, VL_DOVER); /* データベースを閉じる */ if(!vlclose(villa)) return -1; return 0; } char *getphonenumber(const char *id){ VILLA *villa; char *phone; /* データベースを読み込みモードで開く */ if(!(villa = vlopen(PHONEFILE, VL_OREADER, VL_CMPLEX))) return NULL; /* レコードを検索して戻り値を生成する */ phone = vlget(villa, id, -1, NULL); /* データベースを閉じる */ vlclose(villa); return phone; }
`VILLA' へのポインタは例によってデータベースハンドルです。`vlopen' でそのハンドルを獲得します。その第3引数の `VL_CMPLEX' は辞書順の比較を行う関数です。開いたハンドルは `vlclose' で閉じます。`vlput' はレコードを格納する関数で、`vlget' はレコードを検索する関数です。
上記の例ではハッシュデータベースと全く同じ使い方をしましたが、順番に基づいてレコードにアクセスする機能がB+木データベースの特徴です。辞書順を例にとって説明します。キーがそれぞれ `one'、`two'、`three'、`four' というレコードを格納したとすれば、それは `four'、`one'、`three'、`two' という順番で並べられて保存されます。検索にはハッシュデータベースと同様に完全一致条件も使えます。さらに、「カーソル」という機能を使って範囲を指定した検索ができます。カーソルはレコードの位置を指し示します。例えば、`one' の場所にカーソルを飛ばすといった指定ができます。`one' がない場合は、`one' の直後の `three' の位置にカーソルが飛ぶことになります(`one' が複数あった場合は、その最初のレコードに飛びます)。カーソルは、現在位置から前に進めたり後ろに戻したりすることもできます。そして、カーソルの位置のレコードの内容を読み出せば、範囲を指定した検索ができるというわけです。以下の例は、`one' から `three' までのレコードのキーと値を表示する関数です。
#include <depot.h> #include <cabin.h> #include <villa.h> #include <stdlib.h> #include <stdio.h> #define WORDFILE "word" #define TOPWORD "one" #define BOTTOMWORD "three" void printwords(void){ VILLA *villa; char *kbuf, *vbuf; int ksiz; /* データベースを読み込みモードで開く */ if(!(villa = vlopen(WORDFILE, VL_OREADER, VL_CMPLEX))) return; /* カーソルを候補の先頭に飛ばす */ vlcurjump(villa, TOPWORD, -1, VL_JFORWARD); /* 各候補を処理する */ do { /* レコードのキーを取り出す */ kbuf = vlcurkey(villa, &ksiz); /* 候補が範囲外であれば抜ける */ if(!kbuf || VL_CMPLEX(kbuf, ksiz, BOTTOMWORD, sizeof(BOTTOMWORD) - 1) > 0){ free(kbuf); break; } /* レコードの値を取り出して表示する */ vbuf = vlcurval(villa, NULL); if(kbuf && vbuf) printf("%s: %s\n", kbuf, vbuf); /* キーと値の領域を解放する */ free(vbuf); free(kbuf); /* カーソルを次に進める */ } while(vlcurnext(villa)); /* データベースを閉じる */ vlclose(villa); }
`vlcurjump' でカーソルを候補の先頭に飛ばしています。`VL_JFORWARD' はこれからカーソルを前に進めていく場合に指定します(候補の末尾に飛ばしてから後ろに戻して行く場合は `VL_JBACKWARD' を指定します)。do-whileループの条件部で `vlcurnext' を呼んでいますが、これがカーソルを前に進めています(データベースの最後まで来たら偽を返すのでループから抜けます)。`vlcurkey' と `vlcurval' はそれぞれカーソルのキーと値を取り出します。`VL_CMPLEX' を明示的に呼んでいる場所がありますが、ここで候補の末尾に来たかどうか判定しています。比較関数は、二つのキーのポインタとサイズを渡して、前者が大きい(つまり後ろに位置すべき)なら正の値、前者が小さい(つまり前に位置すべき)なら負の値、両者が同じならゼロを返すと規定されています。したがって、正の値が返された場合、今取り出したキーは候補の末尾よりも後ろだということになります。
辞書順以外の比較関数も使えるところがVillaのミソです。int型の数値を比較する `VL_CMPINT' や、10進数の文字列を比較する `VL_CMPDEC' といった関数が最初から用意されています。さらに、あなたが自分で定義した関数も比較関数として使うことができます。使い方が面倒だという欠点を除けば、B+木データベースはハッシュデータベースよりも多くのシーンで活用できると思います。ファイルがハッシュデータベースよりも小さかったり、トランザクションが使えるといった特徴も、人によっては嬉しいかもしれません。
Villaは、キュー(FIFO)を永続化する目的でも利用できます。`VL_CMPINT' を比較関数にしてデータベースを開きます。各レコードのキーはint型とし、値には任意のオブジェクトを入れることにします。キューに要素を追加するには、末尾のキーの数値(なければゼロ)をインクリメントしてキーを生成して格納します。キューから要素を取り出すには、先頭のレコードを取り出してから削除すればよいのです。そのような機能を持つラッパ関数を、`qopen'、`qclose'、`qappend'、`qconsume' といった名前で作っておくと小粋ですね。
Odeumは、全文検索用の転置インデックスを扱うAPIです。テキストファイル(またはテキストを含むHTMLやMS-Wordの文書など)は単語の集合とみなせますが、ある単語がどのファイルに含まれるかという情報をデータベースにしたものを転置インデックスと呼びます。本の巻末にある索引は、ある単語がどのページに含まれるかという情報を持っていますが、それと似たようなものです。私がQDBMを開発する契機となったのは、とある全文検索システムの開発で使っていたGDBMのパフォーマンスに限界を感じたことです。その経緯から、QDBM(特にCuria)には転置インデックスの実現に都合のよい特徴がいくつか備わっています。
転置インデックスの核となるのは、ある単語をキーとし、その単語を含むファイルのIDの配列を値とするレコードからなるデータベースです。単語は完全一致で検索できればよいので、データ構造にはハッシュ表を採用しています。転置インデックスのイメージを例示します。ファイル `penguin.txt' には「flightless marine birds」というテキストが格納されていて、ファイル `ostrich.txt' には、「flightless birds in africa」というテキストが格納されているとします。各ファイルには、読み込んだ順番でIDをつけることにします。これらを対象として転置インデックスを作成すると、以下のようになります。IDとファイル名の対応づけは別の「文書データベース」に保存しておきます。
flightless | 1,2 |
marine | 1 |
birds | 1,2 |
in | 2 |
africa | 2 |
その次に読み込んだ文書に「birds」という単語が含まれていた場合には、`birds' の値を [1,2,3] に変更することになります。このように、既存のレコードの値の末尾にデータを追加するという操作が頻繁に発生します。この処理を効率的に行うために、DepotやCuriaの書き込み操作には「連結モード」があるのです。
検索時には、検索語をキーにして値の配列を取り出し、個々のIDに対応するファイル名を文書データベースから取り出して提示することになります。ただし該当の文書が多い場合にその全てを提示してもユーザは困惑するので、先頭の何件かに絞って取り出します。DepotやCuriaの読み込み操作ではレコードの値から特定の部分のみを取り出すことができるので、この処理を効率良く行うことができます。実際は、ファイルにおける単語のスコア(重要度)も配列要素の一部として格納しておいて、それを基準にソートしておくことによって、先頭の要素がユーザにとって意味のあるものにしています。
ファイルには名前以外にも、タイトルや作者名や更新日時といった属性をつけたい場合があります。それらの情報は文書データベースに保存します。Odeumで扱うファイル(以後は文書と呼びます)は、ファイル名の代わりにURIをつけるものとしています。URI以外の属性はユーザがラベルをつけて任意のものを格納できます。
Odeumは、文書のテキストや属性を元のデータから抽出する機能については提供しません。それらはドメインに強く依存するので、共通化することが難しいからです。したがって、アプリケーションがそれを実装する必要があります。Odeumのサンプルアプリケーションは、ローカルのファイルシステムにあるプレーンテキストとHTMLからテキストと属性を抽出できます。あなたのアプリケーションでは、PDFやMS-Wordの文書に対応するのもよいでしょう。Webから文書を取得してもよいでしょう。同じ理由から、Odeumはテキストから単語を抽出する機能についても提供しません。英語と日本語では全く異なる手法でテキストの解析をしなければなりませんが、そういったこともアプリケーションに任されます。Odeumのサンプルアプリケーションでは、単に空白で語を区切るという手法をとっています。あなたのアプリケーションでは、日本語の形態素解析を行うのもよいでしょう。英単語のステミング処理を行ってもよいでしょう。
多くの言語では、同じ単語に対して異体や活用が存在します。例えば「使う/使っ(た)」「go/went」「child/children」などです。そこで、Odeumは各単語を「正規形」および「出現形」の組として扱います。テキストから出現形の単語を切り出したり、それらの正規形を生成する処理はアプリケーションに任されます。転置インデックスでのレコードのキーには正規形が使われます。「child」で検索すれば、「children」を含む文書も該当させられるということです。検索語に対しても正規化の処理を行えば、「children」で検索して「child」を該当にできます。なお、出現形もデータベースに記録されますが、それは検索結果として文書の要約を提示するなどの用途で利用されます(不要な場合は出現形を全て空文字列にして記憶領域を節約できます)。
全文検索システムの善し悪しを評価する際には、スコアリング(ランキング)が重要な要素になります。検索結果が多い場合に、その中から絞り込んでユーザに提示する文書をどうやって選択するかということです。いくら検索速度が速くても、満足のいく検索結果が提示されずに何度も検索したり、無駄な文書を閲覧しなければならないのでは、結局は時間がかかることになってしまいます。Odeumでは、文書を登録する際に、そこに含まれる各単語について以下の式でスコアを算出して、文書IDとともに転置インデックスに格納しています。
ある単語がたくさん出現するということは、その文書でその単語のことについて詳しく説明されている可能性が高いと判断できます。ただし、大きな文書は小さい文書よりも単語の出現数が多くなりますので、その調整をする必要があります。そこで、該当語の出現数を、総語数の自然対数の三乗で割っているのです。なお、テキストの先頭10%以内に出て来る単語は「トピックセンテンス」を構成するとみなして、初出時に限り10000でなく15000を加算しています。
全文検索は通常、複数の検索語を使って行われます。その際には、各検索語のスコアを調整しないと、特定の検索語のスコアの影響が強すぎるという事態が発生します。例えば「the beatles」で検索した場合、「the」のスコアが「beatles」のスコアを圧倒して、「beatles」について知りたいのに、関係ない文書ばかりが提示されることになってしまいます。Odeumは転置インデックスの内容を提示するだけで、それ以後の処理はアプリケーションに任されます。Odeumに付属するサンプルアプリケーションでは以下の式で調整を行っています。
ありふれた単語はスコアを下げ、特徴的な単語はスコアを維持すべきです。そこで、登録されたスコアを、その語を含む文書数の自然対数の二乗で割っているのです(TF-IDF法を強化したものです)。各検索語の調整済みスコアを足したものを文書のスコアとし、その降順で検索結果を提示します。他にもドメインに依存した様々なスコアリング手法があると思いますが、アプリケーションがそれを実装できるのが嬉しいところです。
Odeumのサンプルアプリケーションでは、関連文書検索(類似文書検索と呼んだ方が適切かもしれません)の機能も実装しています。ある文書(種文書と呼びます)に関連した文書の一覧を提示するものです。単語の出現傾向が似通った文書は互いに関連しているという考え方(ベクトル空間モデル)に基づいています。文書をデータベースに登録する際には、各文書に含まれる全単語に対して調整済みスコアを計算し、その上位32語(キーワードと呼びます)の情報を文書と対応づけて登録しておきます。検索時には、種文書のキーワードとスコアを取り出し、それを32次元のベクトルとして表現します。関連度を判定する対象の各文書からもキーワードとスコアを取り出し、種文書のベクトル空間に対応したベクトルを生成します。そうしてできた二つのベクトルのなす角が小さいものは関連度が高いと判定します。実際には、なす角の余弦(0から1の範囲で、完全一致する場合は1になる)が大きいものから提示されることになります。なお、登録された全ての文書を対象として類似度の判定を行うとあまりに時間がかかるので、キーワードでOR検索を行った結果の上位の文書のみを関連度算出の対象としています。
前置きはここまでにして、Odeumの使用方法についての説明に入ります。URIが `http://tako.ika/uni.txt' で、そこから取り出したテキストが「The sun is driven by the Grateful Dead.」で、切り出した単語(正規形/出現形)が「the/The」「sun/sun」「be/is」「drive/driven」「by/by」「the/the」「grateful/Grateful」「die/dead」「(正規形なし)/.」だとしましょう(この処理はアプリケーションが独自に実装してください)(いわゆるストップワードは正規形を空文字列にして表現します)。それを `index' という名前のデータベースに登録する例を示します。
#include <depot.h> #include <cabin.h> #include <odeum.h> #include <stdlib.h> int docregistertest(void){ ODEUM *odeum; ODDOC *doc; /* データベースを開く */ if(!(odeum = odopen("index", OD_OWRITER | OD_OCREAT))) return -1; /* 文書を表現する */ doc = oddocopen("http://tako.ika/uni.txt"); oddocaddword(doc, "the", "the"); oddocaddword(doc, "sun", "sun"); oddocaddword(doc, "be", "is"); oddocaddword(doc, "drive", "driven"); oddocaddword(doc, "by", "by"); oddocaddword(doc, "the", "the"); oddocaddword(doc, "grateful", "Grateful"); oddocaddword(doc, "die", "Dead"); oddocaddword(doc, "", "."); /* 文書を登録する */ odput(odeum, doc, -1, 1); /* 文書の領域を解放する */ oddocclose(doc); /* データベースを閉じる */ if(!odclose(odeum)) return -1; return 0; }
`ODEUM' へのポインタは例によってデータベースハンドルです。`odopen' でそのハンドルを獲得します。開いたハンドルは `odclose' で閉じます。`ODDOC' は文書ハンドルです。各文書の内容は文書ハンドルによって表現されます。`oddocopen' はハンドルを開く関数です。その第1引数で文書のURIを指定します。文書ハンドルは不要になったら `oddocclose' で解放します。テキストの各単語を文書に登録するには、`oddocaddword' を用います。その第2引数は単語の正規形で、第3引数は出現形です。文書を表現したら、それを `odput' でデータベースに登録します。第3引数は、文書データベースに登録する語数を指定します。`-1' にすると全部の語が登録されます。第4引数は、同じURIの既存の文書がある場合に、それを上書きするか否か指定します。
`odput' の第3引数の指定が少し難しいので補足します。ある文書が適切に検索されるために、転置インデックスにおいて、その文書に含まれる全ての単語のレコードの値にその文書のIDが無条件で追加されます。ところで、多くの全文検索システムでは、検索結果の画面で該当の文書の要約を表示します。そのためには、文書データベースの中に、各文書と関連づけて含まれる単語を順番に記録しておく必要があります。検索語の周辺の文を切り出して表示する場合を考えると、検索語が文書中のどこに現れるかは予想できないので、全ての単語を文書データベースに記録しておかなければなりません。あるいは、冒頭の何語かだけ表示する場合には、その語数分の語を登録しておけばよいことになります。そういった決定をアプリケーションに任せるために、文書データベースに登録する語数を指定できるようになっているのです。
以下の例では、`grateful' という単語を含む文書を検索して、そのURIとスコアを表示します。まず転置インデックスを検索して結果の配列を受け取ります。その配列の各要素は文書のIDとスコアの組です。文書の内容を取得するには、文書IDを使って文書データベースに問い合わせます。
#include <depot.h> #include <cabin.h> #include <odeum.h> #include <stdlib.h> #include <stdio.h> void docsearchtest(void){ ODEUM *odeum; ODPAIR *pairs; ODDOC *doc; int i, pnum; /* データベースを読み込みモードで開く */ if(!(odeum = odopen("index", OD_OREADER))) return; /* 転置インデックスを検索する */ pairs = odsearch(odeum, "grateful", -1, &pnum); if(pairs && pnum > 0){ /* 該当の各文書を処理する */ for(i = 0; i < pnum; i++){ /* 文書データベースから文書を取り出す */ if(!(doc = odgetbyid(odeum, pairs[i].id))) continue; /* 文書の内容を表示する */ printf("URI: %s\n", oddocuri(doc)); printf("SCORE: %d\n", pairs[i].score); /* 文書の領域を解放する */ oddocclose(doc); } } /* 検索結果の領域を解放する */ free(pairs); /* データベースを閉じる */ odclose(odeum); }
転置インデックスを検索するのが `odsearch' という関数です。第2引数には正規形の検索語を指定します。第3引数には、検索する文書の最大数を指定しますが、全部取り出す場合は `-1' とします。結果の配列はスコアの降順でソートされていることが規定されています。第4引数には、結果の配列の要素数を格納する変数のポインタを指定します。次に、配列の各要素を処理していきます。`odgetbyid' は文書IDを用いて文書の内容を問い合わせる関数です。転置インデックスの中には既に削除されたり上書きされてIDが変更された文書の情報も入っているので(最適化すれば不要な情報はなくなりますが)、`odgetbyid' は失敗する可能性があります。そういう時は単に無視して次のループに進んでください。文書が取得できたら、あとはそれを表示します。`oddocuri' は文書のURIを返す関数です。他にも文書の情報を取得する関数がいくつか用意されています。
Odeumでは、複数の検索語を用いて、AND条件(検索語の全てを含む)やOR条件(検索語のいずれかを含む)やNOTAND条件(検索語の前者を含むが後者は含まない)といった集合演算を処理するための関数が用意されているほか、全文検索システムの実装に便利なユーティリティ関数が多数提供されます。全文検索システムを実装する際には性能と精度のバランスを考えなければなりませんが、OdeumのAPIはアプリケーションがそれを任意に決められるように設計されています。大規模なインデックスを扱う際には、まず精度を落した検索を行って、その結果がユーザの要求を満たさなければ精度を高めたパラメータで再検索する手法が有効でしょう。