PHPでHandlerSocketを使ってみる

[HandlerSocket](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL)はMySQL用のNoSQLプラグインで、[MariaDB5.3から標準で含まれる](https://mariadb.com/kb/en/mariadb/what-is-mariadb-53/#nosql-style-interfaces)ようになりました。

とある案件でPHPからHandlerSocket(以下HS)を使いたくなったのでそのメモ。

# TL;DR

* PHP用のHSクライアントとしてPECL拡張ライブラリを使う
* ライブラリのドキュメントが古く、使い方が分からないのでソース読んだりして解読したよ

# HSクライアント

[Pure PHP版](https://github.com/husccexo/php-handlersocket-core)とかいくつかあるみたいですが、こちらのPECL拡張を使います。

https://github.com/kjdev/php-ext-handlersocketi

HS使いたいところってパフォーマンスが要求されるような場所なんで、少しでも速いやつがいいでしょうと。(比較したことないですが…)
ただ、リポジトリをみると分かるようにドキュメントは存在しません。
githubの前のリポジトリである[google code](https://code.google.com/archive/p/php-handlersocket/)上に多少ありますが、古くて使えないです。

↓にPHP classファイルの作りかけみたいのがあるので一応参考になるんですが、こちらも古いです。
https://github.com/kjdev/php-ext-handlersocketi/blob/master/handlersocketi%20class
(ソースをみると[filterプロトコル](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL/blob/master/docs-ja/protocol.ja.txt#L48)に対応していそうですが、上記のclassには[オプション](https://github.com/kjdev/php-ext-handlersocketi/blob/master/handlersocketi%20class#L168-L178)がありません)

PECL拡張のソースは読んだことなかったので、雰囲気(?)で読んでいきながらあとはローカルにインストールして試していきました。
(HSプロトコルは難しくないのでそんなには難しくない)
ちなみに今回の用途が参照だけだったので、参照系しかさわっていません＞＜

## constructor

PECL拡張をインストールすると`HandlerSocketi`クラスが使えるようになります。

```php
$hs = new \HandlerSocketi($host, $port, $options);
```

host名とport番号を指定してあとはoptionを渡せます。
host名以外はoptionalです。

option内は以下のようなパラメータを渡せるようです。

| key | type | desc. |
|:-:|:-:|:-:|
| timeout  | integer  | connectionのtimeout、たぶんsec  |
| persistent  | ?  | 永続接続か都度接続かを指定できそうだが不明  |

すいません、力不足で`persistent`はよく分かりませんでした…
ソース上は以下の箇所ですが詳しい方がいらっしゃればコメントいただけるとありがたいです。
https://github.com/kjdev/php-ext-handlersocketi/blob/master/handlersocketi_class.c#L344-L348

## openIndex

HSだとまず使用するindexを開く必要があります。
プロトコルの[open_index命令](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL/blob/master/docs-ja/protocol.ja.txt#L27)ですね。

上記のコードで作った`HandlerSocketi`インスタンスを使うと以下のように使えます。

```php
/* @var \HandlerSocketi_index */
$hsi = $hs->openIndex($dbname, $table, $fields, $options);
```

インスタンスメソッドたちはsnake_caseのものとcamelCaseものがありますが、中身は同じでただのaliasになっているみたいなので、camelCaseを使います。
PHPなので。

`$dbname`と`$table`は説明不要だと思います。
`$fields`は配列で取得したい列名を渡します。
プロトコルでいうと[この辺](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL/blob/master/docs-ja/protocol.ja.txt#L38)です。

肝心のINDEXを指定する引数がないですが、これは`$options`内で指定します。
これはHSではINDEXが必須ではなく、省略するとPRIMARYが使われるためです。

`$options`で指定できるパラメータは以下です。

| key | type | desc. |
|:-:|:-:|:-:|
| index  | string  | openするindex名、省略されるとPRIMARYが使われる。なおPRIMARYを明示的に使う場合は大文字で渡す必要がある  |
| filter  | string &#124; array | filterで使用する列名、複数指定したい場合は配列を渡せます。プロトコルの[この辺](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL/blob/master/docs-ja/protocol.ja.txt#L39)  |
| id | integer? | indexidを指定したいとき用なのかな? |

返り値は`HandlerSocketi_index`インスタンスになります。
具体的なクエリはこのインスタンスに対して実行していくことになります。

`id`については[この辺](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL/blob/master/docs-ja/protocol.ja.txt#L33-L34)の`indexid`を直接指定したい場合に使うんじゃないかと思われます。
基本は自分で指定しなくても、`openIndex`内でいれてくれるので気にする必要はなさそうです。
(開いたindexを使い回したい場合とかで使うのかな?)
ちなみにこの`indexid`は`HandlerSocketi_index`インスタンスの`getId`メソッドから取得可能です。

## find

参照時にメインで使うと思われるメソッドです。
プロトコルの[find命令](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL/blob/master/docs-ja/protocol.ja.txt#L48)ですね。

`HandlerSocketi_index`インスタンスを使って以下のように使えます。

```php
try {
    $res = $hsi->find(['=' => [$user_id, $status]], ['limit' => 10, 'offset' => 5, 'safe' => true]);
} catch (\HandlerSocketi_Exception $e) {
    var_dump($e);
}
```

引数の前に返り値の説明からします。
正常に実行されると取得データの配列が返されます。
注意すべき点としては連想配列では返ってこないということです。
純粋な2次元行列として返ってきますが、列の順序は`openIndex`で渡した`$fields`の順序で保障されるので、`array_combine`などを使えば連想配列にすることができます。

また失敗すると第二引数で`safe => 1`を指定すると失敗時に例外を投げるようになります。
エラーメッセージは`getError`で直近のエラーを取得可能ですが大した情報を返してはくれないようなので困りものです…
ものによってはなんのメッセージも入ってこないことがあるので役にたたないと思っておいた方がよさそうです。

第一引数は連想配列を渡します。
keyはプロトコロルでいうところの[\<op>](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL/blob/master/docs-ja/protocol.ja.txt#L68-L69)をいれます。
`=`, `>`, `>=`, `<`, `<=`をサポートしています。
valueはプロトコロでいうところの[\<vn>](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL/blob/master/docs-ja/protocol.ja.txt#L73)をいれます。
複数指定したい場合は配列をいれることができます。
順序が保障されるのでプロトコル通り、`openIndex`の`index`で指定したINDEXのKEY列に対応します。

第二引数も連想配列を渡します。
パラメータは以下でいずれもoptionalです。

| key | type | desc. |
|:-:|:-:|:-:|
| safe | boolean | trueだと失敗時にExceptionを投げる |
| limit  | integer  | 下のoffsetとの組合せでSQLのLIMIT句と同様。省略すると1,0になる。limitだけを指定することも可能  |
| offset  | integer  | 上記を参照  |
| in | integer &#124; string &#124; array | WHERE IN (...) みたいなことをやりたいときに使う。使い方が若干分かりにくいところがあるのでで後述する  |
| filter | integer &#124; string &#124; array | 行取得の際のfilter。これも分かりにくいので後述  |
| while | integer &#124; string &#124; array | 使い方はfilterとほぼ同じ、ただstop filterとして使う。こちらも後述する  |

### Schema

説明のために具体的なtableがあった方がいいので、以下のDDLを使用します。

```sql
CREATE TABLE `articles` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `title` varchar(255) DEFAULT NULL,
  `body` text,
  `status` enum('private', 'public', 'draft') DEFAULT 'draft',
  `user_id` int(10) unsigned NOT NULL,
  `created_at` int(10) unsigned NOT NULL,
  `updated_at` int(10) unsigned NOT NULL,
  PRIMARY KEY (`id`),
  KEY `i1` (`user_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

### `in` オプション

まずはPrimary Keyを使った以下のクエリをHSを使うとどうなるかをみてみます。

```sql
SELECT id, title, user_id, status FROM articles WHERE id IN (10, 20, 30) LIMIT 10;
```

LIMIT句はおまけです。

```php
$hsi = $hs->openIndex($dbname, 'articles', ['id','title','user_id','status'], ['index'=>'PRIMARY']);
// $fake_idは無視される
$res = $hsi->find(['=' => $fake_id]], ['in' => [10, 20, 30], 'limit' => 10, 'safe' => true]);
/* [
       [
           10,
           "title10",
           ...
       ],
       [
           20,
           "title20",
           ...
       ],
       [
           30,
           "title30",
           ...
       ]
   ]
*/
```

[公式ドキュメント](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL/blob/master/docs-ja/protocol.ja.txt#L79-L80)にあるように、`find`の第一引数が無視されます。

次に複合INDEXを使う以下のクエリの場合をみてみます。

```sql
SELECT id, title, user_id, status FROM articles WHERE user_id = 100 AND status IN ('private', 'public') LIMIT 10;
```

INDEX`i1`を使いたいわけですが、以下のようになります。

```php
$user_id = 100;
$hsi = $hs->openIndex($dbname, 'articles', ['id','title','user_id','status'], ['index'=>'i1']);
// $fake_statusは無視される
$res = $hsi->find(['=' => [$user_id, $fake_status]], ['in' => [1 => ['private', 'public']], 'limit' => 10, 'safe' => true]);
/* [
       [
           10,
           "title10",
           100,
           "public",
           ...
       ],
       [
           20,
           "title20",
           100,
           "draft",
           ...
       ],
       ...
   ]
*/
```

複合INDEXの1列目であるuser_idを`find`の第一引数で指定しますが、複合INDEXなので配列を渡しています。
(`[$user_id, $fake_status]`のこと)
このうち今回は`$fake_status`だけが無視されます。
これは第二引数の`in`オプションで`1`を指定しているためです。
つまりこの`1`は第一引数のvalueで渡した配列の添字をさす、ということになります。

### `filter`オプション

`filter`オプションを使用するために、以下のクエリを考えてみます。
(実用性は考慮しません)

```sql
SELECT id, title, user_id, created_at FROM articles WHERE
user_id = 100 AND created_at >= unix_timestamp()-86400 AND created_at < unix_timestamp()-3600
LIMIT 10;
```

HSだと以下のようになります。

```php
$user_id = 100;
$now = time();
$hsi = $hs->openIndex($dbname, 'articles', ['id','title','user_id','status'], ['index'=>'i1', 'filter'=>['created_at']);
// $fake_statusは無視される
$res = $hsi->find(
  ['=' => $user_id],
  [
    'filter' => [['created_at', '>=', $now-86400], ['created_at', '<', $now-3600]],
    'limit' => 10,
    'safe' => true,
  ]
);
```

なかなか長くなってきましたね。
FILTERについては[ここ](https://github.com/DeNA/HandlerSocket-Plugin-for-MySQL/blob/master/docs-ja/protocol.ja.txt#L81-L87)に書かれています。
filterとwhileの2種類があると書かれていますが、`filter`オプションで使われるのはfilterの方です。
ややこしいですね。。
ちなみにwhileの構文は上記の`filter`とほぼ同じですが、挙動はドキュメントある通りにstop filterとして働くので、filterにmatchしないレコードが出現した時点で取得をやめます。

さてプロトコルの記述通り、filter(whileも)を使う場合はopen_index命令で使用するカラムを指定する必要があります。
上記のクエリだと`created_at`をfilterで使うので、`openIndex`メソッドの第二引数で`filter`パラメータとして渡します。

filterに複数条件を指定するとANDとして扱われるので、`find`メソッドの第二引数の`filter`パラメータで複数条件を指定します。
中身は`[field名, オペレータ, 値]`のように指定します。
オペレータには`=`, `>`, `>=`, `<`, `<=`を使用することができます。