Azure Cosmos DBの2つのAPIをPHPで試す


センシンロボティクス開発部の黒田です。
弊社で取り組んでいる「インフラDX」においては、いわゆるIoT的なセンサデータやドローン・UGV等で取得したマルチメディアデータの解析結果データなど、多種多様な時系列データを扱う必要があります。
そこでMicrosoft Azureの「Cosmos DB」をデータインフラとして利用できないか調査したのですが、結構概念やサービス構成がややこしかったり、PHPで利用しようとすると情報が少なかったりしたので、メモも兼ねてまとめたいと思います。


## Azure Cosmos DB (旧DocumentDB)とは
いわゆるNoSQL Databaseで、AWSで言うところのDynamoDBやDocumentDBと同じ部類のマネージドデータベースですが、APIが5種類あるなど結構ややこしいところがあるので一旦整理してみました（全部詳しく調査することはできなかったので、一部不正確なまとめです）

- 高スループットと高可用性
- RUというスループット性能に対する課金
- 5つのAPI（データモデル）がある（以下、個々のAPIにおけるデータモデルの関係性）

| API | Database| Container | Item |
|:-----------|:------------|:----------|:----|
| SQL/Core API  | Database | Container | Document |
| Cassandra API | Keyspace | Table | Row |
| MongoDB API   | Database | Collection | Document |
| Gremlin API   | Database | Graph | Node/Edge |
| Table API     | N/A      | Table | Entity |


今回はこの中で「SQL/Core API」と「Table API」について、調査とPHP(laravel環境)でのお試し実装にトライしました。


## SQL/Core API 概要
全てのデータエントリはjson形式で保存され、極めて自由度が高い非構造データをRDBライクなSQLで取り扱うことができます。
イメージしやすくするため、下記にデータとクエリの例を記載します。

```json
[data]
{
  "id": "1608223603",
  "value": 1.25,
  "device": {
    "type": "drone",
    "name": "SENSYN DRONE 1GO"
  },
  "_rid": "I9pmAKenR8NHFwAAAAAAAA==",
  "_self": "dbs/I9pmAA==/colls/I9pmAKenR8M=/docs/I9pmAKenR8NHFwAAAAAAAA==/",
  "_etag": "0000751a-0000-2300-0000-5fdbdbb80000",
  "_attachments": "attachments/",
  "_ts": 1608244152
}

[query]
SELECT * FROM SensorData s WHERE s.device.type = 'drone'
```

valueやdeviceは開発者が自由に決めたプロパティで、これをSQL内で指定できるというわけです。
おそらく、多種多様なセンサデータのようにプロパティ構造が定義しずらいものには一番利用しやすいAPIになると思われます。


## Table API 概要
こちらはAzure Storage Serviceというコアサービスの１つである「Azure Storage Table」と互換のAPIです。
最も考えられるユースケースとしては、既存のAzure Storage Tableを利用しているシステムにおいて、スループット・可用性向上のためにCosmos DBに移行するというケースだと思われます。
余談ですが、個人的にはこのAzure Storage Serviceが何なのか理解するのに少し時間がかかりました。
サービス名から「AWSのS3のようなものかな」と思っていたのですが、Storage Serviceはあくまでサービス群の名称であり、以下のような複数のServiceが属していたからです

- BLOB Service  : いわゆるS3と同じobject storage
- File Service  : SMBベースのファイル共有マネージドサービス
- Queue Service : クラウドリソース間の非同期メッセージキュー
- Table Service : ベーシックなNoSQLデータベース
- Disk Service  : 仮想ハードディスク

（マニュアルやポータルごとに「Azure BLOB」や「Azure Blob Service」や「Blob Storage」など表記揺れも散見されるので、分かり難さに拍車がかかっています。。）

それはさておき、Table APIはベーシックなNoSQLデータベースなため、SQL/Core APIのような自由度高めのデータ投入や参照はできません。
AWS DynamoDBと同じようにPartitionKeyおよびRowKey(=SortKey)を指定してしか参照できませんし、セカンダリインデックスを作ることもできません。
そのため、キーの設計は検索要件を踏まえてしっかりと設計しておく必要があります。
また、Entity Group Transaction(EGT)という複数のEntityをアトミックに操作する機能もあるのですが、「同一Partitionに属するデータのみ」という制限がついているので、トランザクション管理の観点でも設計しておく必要があります。

データとクエリの例は次のようなイメージになります。

```json
[data]
{
  "PartitionKey": "drone",
  "RowKey": "1608223603",
  "Timestamp": "2020-12-18T10:11:12.1234567Z",
  "value": 1.25,
  "device_type": "drone",
  "device_name": "SENSYN DRONE 1GO"
}

[query(filter)]
$client->queryEntities("SensorData", "PartitionKey eq 'drone'");
```

クエリを含め、あらゆる操作にPartitionKeyを指定する必要があります。


# 試してみる

## SQL/Core API

#### 1. ポータルからCosmos DBアカウントを作成する
基本的にはフォームのデフォルト値でOK

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1144/4a376089-9674-7013-7269-9cb3af6f4a39.png)


#### 2. コンテナの作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1144/88ef67cd-1c47-5ef8-f92c-4c521346a2a6.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1144/3f665680-5305-f652-603e-faf11083a19b.png)


ちなみにデータエクスプローラーからデータ参照・編集可能
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1144/2c84bf07-6eb2-2567-f1e2-afcea9d0b647.png)

#### 3. PHPでのREST API呼び出し実装

残念ながらphp版のCosmos DBがサポートされたSDKはないので、素のREST APIをGuzzleなどで呼び出すしかないです。
とりあえずデータ投入とクエリ実行の実装例です。

```php
<?php

namespace App\Services\Azure\Cosmos;

use App\Exceptions\Error;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;

class AzureCosmosDBClient {
    private $host = 'https://kurocosmos.documents.azure.com';
    private $key = 'Your Access Key';
    private $client;

    public function __construct() {
        $this->client = new Client();
    }

    public function createDocument(
        string $dbId, string $collId, ?string $partitionKey,
        string $json
    ) {
        $url = $this->endpoint("/dbs/{$dbId}/colls/{$collId}/docs");
        $headers = [
            'Content-Type' => 'application/json',
        ];
        if (isset($partitionKey)) {
            $headers['x-ms-documentdb-query-enablecrosspartition'] = 'False';
            $headers['x-ms-documentdb-partitionkey'] = '["'.$partitionKey.'"]';
        }
        $ops = $this->authedOptions('post', 'docs', $collId, $headers, $json);
        return $this->doRequest('POST', $url, $ops);
    }

    public function queryDocuments(
        string $dbId, string $collId, ?string $partitionKey,
        string $query, array $queryParams
    ) {
        $url = $this->endpoint("/dbs/{$dbId}/colls/{$collId}/docs");
        $json = json_encode([
            'query' => $query,
            'parameters' => collect($queryParams)->map(function($v, $k){
                return [
                    'name' => "@{$k}",
                    'value' => $v,
                ];
            })->values()->toArray()
        ]);
        $headers = [
            'Content-Type' => 'application/query+json',
            'x-ms-max-item-count' => 1000,
            'x-ms-documentdb-isquery' => 'True',
            'x-ms-documentdb-query-enablecrosspartition' => 'True'
        ];
        if (isset($partitionKey)) {
            $headers['x-ms-documentdb-query-enablecrosspartition'] = 'False';
            $headers['x-ms-documentdb-partitionkey'] = '["'.$partitionKey.'"]';
        }
        $ops = $this->authedOptions('post', 'docs', $collId, $headers, $json);
        return $this->doRequest('POST', $url, $ops);
    }

    // private

    private function doRequest(string $method, string $url, array $options) {
        $resp = null;
        try {
            switch($method) {
            case 'GET':
                $resp = $this->client->get($url, $options);
                break;
            case 'PUT':
                $resp = $this->client->put($url, $options);
                break;
            case 'DELETE':
                $resp = $this->client->delete($url, $options);
                break;
            case 'POST':
                $resp = $this->client->post($url, $options);
                break;
            default:
                throw Error::InternalError("unexpected method {$method}");
            }
        }
        catch (RequestException $e) {
            \Log::error($e->getResponse()->getBody()->getContents());
            throw $e;
        }
        $content = $resp->getBody()->getContents();
        return json_decode($content);
    }

    private function endpoint(string $path): string {
        return "{$this->host}{$path}";
    }

    private function authedOptions(
        string $verb, string $resourceType, string $resourceLink,
        array $headers=[], ?string $body=null
    ): array {
        $keyType = 'master';
        $tokenVer = '1.0';
        $xMsVersion = '2018-12-31';
        $xMsDate = gmdate('D, d M Y H:i:s T');
        $sig = base64_encode($this->sig($verb, $resourceType, $resourceLink, $xMsDate));

        $options = [
            'headers' => collect([
                'Authorization' => urlencode("type={$keyType}&ver={$tokenVer}&sig={$sig}"),
                'Accept' => 'application/json',
                'Content-Length' => is_null($body) ? 0 : strlen($body),
                'x-ms-version' => $xMsVersion,
                'x-ms-date' => $xMsDate,
            ])->merge($headers)->all(),
        ];
        if (!is_null($body)) {
            $options['body'] = $body;
        }
        return $options;
    }

    private function sig(string $verb, string $resourceType, string $resourceLink, string $dateStr): string {
        $message = "{$verb}\n{$resourceType}\n{$resourceLink}\n{$dateStr}\n\n";
        return hash_hmac('sha256', strtolower($message), base64_decode($this->key), true);
    }
}
```

なお、上記の\$dbIdや\$collIdは、作成時に入力した名称ではなく、作成後に割り当てられた識別子のようです。
次のようにazure cliでも確認できます。

```shell
$ az cosmosdb sql container list -g myresourcegroup -a kurocosmos -d SensorData | jq
[
  {
    "id": "xxxx",
    "location": null,
    "name": "SENSYN",
    "options": null,
    "resource": {
      "_conflicts": "conflicts/",
      "_docs": "docs/",
      "_self": "dbs/I9pmAA==/colls/I9pmAKenR8M=/", # これ
      "_sprocs": "sprocs/",
      "_triggers": "triggers/",
    ...
  }
]
```
とりあえずlaravelのコマンド作成機能を利用し、コマンド経由で叩いてみたいと思います。

#### 4. Write/Read実行
折角なので軽く性能もみるためにWrite操作について呼び出し間隔を変化させて呼び出してみました。
結果だけ記載します。

##### CosmosDB(SQL/Core API) Write

|No|RU|wait(msec)|書き込みDocument数|処理時間|処理/sec|
|:---|:---|:---|:---|:---|:---|
|1|400|10|1000|22.2sec|45.0|
|2|400|8|1000|16.5sec|60.6|
|3|400|5|1000|-|ERROR|

投入データはせいぜい100byte~200byte程度のものでしたが、RUが400だと大体60req/secくらいが上限のように見えました。
この辺りはDynamoDBにしろCosmosDBにしろ、実際の使われ方が読めないと、設定値に頭を悩ますところですね。。

ちなみに、RUの上限オーバー時のエラーは次のようなresponseが返ってきました

```json
{
  "code": "429",
  "message": "Message: {\"Errors\":[\"Request rate is large. More Request Units may be needed, so no changes were made. Please retry this request later. Learn more: http://aka.ms/cosmosdb-error-429\"]}\r\nActivityId: ..."
}
```

##### CosmosDB(SQL/Core API) Read

Read操作については限界値まで試す気力がなかったので、とりあえず10000件の読み込みだけ実施しました。

|No|RU|wait(msec)|読み込みDocument数|処理時間|処理/sec|
|:---|:---|:---|:---|:---|:---|
|1|400|-|10000|0.25sec|40000|

少し時間に振れ幅があったので、キャッシュが効いているかどうかなどが影響していそうです（最後適当）。

## Table API

#### 1. ポータルからCosmos DBアカウントを作成する

APIが異なる場合、Cosmos DBアカウントも別にする必要があるようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1144/7281b421-ca5f-503c-d594-a235d71872d2.png)


#### 2. テーブルの作成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1144/d9e73415-3868-6824-0ca0-71b038fcd96e.png)

テーブル名のみの指定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1144/43b31fc4-ec52-06cf-a38a-62f26a0ac396.png)

こちらもデータエクスプローラで参照・編集が可能（よりDynamoDBライクなUI）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1144/42962225-4cfc-9a08-a7c1-749366aad6ac.png)


#### 3. PHPでのEntity挿入・クエリ実装

こちらはAzure Storage Service用のSDKがPHP向けにあるので、そちらを使用します。

PHP から Azure Storage Table service API または Azure Cosmos DB Table API を使用する方法
https://docs.microsoft.com/ja-jp/azure/cosmos-db/table-storage-how-to-use-php

一応実装例は以下です

```php
<?php

namespace App\Services\Azure\Cosmos;

use App\Exceptions\Error;
use MicrosoftAzure\Storage\Table\TableRestProxy;
use MicrosoftAzure\Storage\Table\Models\EdmType;
use MicrosoftAzure\Storage\Table\Models\Entity;
use MicrosoftAzure\Storage\Table\Models\Filters\Filter;
use MicrosoftAzure\Storage\Table\Models\QueryEntitiesOptions;

class AzureStorageTableClient {
    private $conn_cosmos = 'YOUR COSMOS CONNECTION STRING';
    private $conn_table = 'YOUR STORAGE TABLE CONNECTION STRING';

    private $service;

    public function __construct() {
        $this->service = TableRestProxy::createTableService($this->conn_cosmos);
    }

    public function createEntity(
        string $tableName, string $partitionKey, string $rowKey,
        array $props
    ) {
        $entity = new Entity();
        $entity->setPartitionKey($partitionKey);
        $entity->setRowKey($rowKey);
        collect($props)->each(function($p, $k) use($entity) {
            $entity->addProperty($k, EdmType::propertyType($p), $p);
        });
        $r = $this->service->insertEntity($tableName, $entity);

        return $this->entityToArray($entity);
    }

    public function queryEntities(string $tableName, string $filter) {
        $options = new QueryEntitiesOptions();
        $options->setFilter(Filter::applyQueryString($filter));
        $options->setTop(1000); // Storage Tableの場合は1000を超える値はセットできない模様
        $result = $this->service->queryEntities($tableName, $options);
        $entities = $result->getEntities();
        return collect($entities)->map(function($entity) {
            return $this->entityToArray($entity);
        })->values()->all();
    }

    private function entityToArray(Entity $entity): array {
        return collect($entity->getProperties())->map(function($p, $k) {
            return [ $k => $p->getValue()];
        })->flatMap(function($values) {
            return $values;
        })->toArray();
    }
}

```


#### 4. Write/Read実行

##### CosmosDB(Table API) Write
SQL/Core APIと同様に試したので、結果だけ記載します。

|No|RU|wait(msec)|書き込みEntity数|処理時間|処理/sec|
|:---|:---|:---|:---|:---|:---|
|1|400|100|1000|110sec|9.1|
|2|400|80|1000|90sec|11.1|
|3|400|50|1000|60sec|16.7|
|4|400|30|1000|60sec|16.7|
|5|400|25|1000|-|ERROR|

完全に同じデータではないし、実装も違うには違うのですが、それでもSQL/Core APIと比較すると多少性能は落ちるようですね。
また、Table APIはAzure Storage Table互換なので、Azure Storage Tableを使用して検証してみました（接続文字列をStorage Table用に変えるだけ）

##### Storage Table Write

|No|RU|wait(msec)|書き込みEntity数|処理時間|処理/sec|
|:---|:---|:---|:---|:---|:---|
|1|-|10|1000|20sec|50|
|2|-|8|1000|18sec|55.6|
|3|-|3|1000|11sec|90.9|
|4|-|1|1000|8sec|125|

なんと。。Storage Tableに変えた方が書き込み処理性能が向上してしまいました。。
こうなってくると、CosmosDBのTable APIを使用する意義が結構薄れてしまいますね；
おそらくお金を積んでRUをしっかり増やしてやるとCosmos DBの方が性能が出そうな気はしますが、Storage Tableでもそこそこ性能は出ているのでコストに見合っているのかどうかは悩ましいです。


##### CosmosDB(Table API) Read

とりあえず3000Entityのクエリまでで力尽きました。。

|No|RU|wait(msec)|読み込みEntity数|処理時間|処理/sec|
|:---|:---|:---|:---|:---|:---|
|1|400|-|3000|0.6sec|5000|


##### Storage Table Read

読み込みについてはStorage Tableだと一度のクエリで1000Entityまでしか返却できない制限があるようで、そこはCosmosにするメリットといえそうです。

|No|RU|wait(msec)|読み込みEntity数|処理時間|処理/sec|
|:---|:---|:---|:---|:---|:---|
|1|-|-|1000|0.24sec|4167|


## まとめ的なもの

- 以下のデータベースAPIをPHPで触ってみた
    - Cosmos DBのSQL/Core API
    - Cosmos DBのTable API
    - Storage TableのTable API
- SQL/Core APIについては柔軟なクエリと、key設計にあまり悩まなくてよい点がGood
- Table APIについては正直微妙な結果。既存のStorage TableをあえてCosmos DBに移行して嬉しいことがあるか分からなかった
    - 唯一、一つのクエリで1000件以上とってこれる部分はよいが、Storage Tableを既に使っているのであればnext tokenの処理なども既に実装してしまっているだろうし。。
    - お金を沢山積めばもっとデキる子なのだと信じる
- サービス名の表記揺れやドキュメントの誤記に負けない強い気持ちを持とう

## 参考

Cosmos DBのREST API
https://docs.microsoft.com/ja-jp/rest/api/cosmos-db/

クエリ
https://docs.microsoft.com/ja-jp/rest/api/cosmos-db/querying-cosmosdb-resources-using-the-rest-api


Azure Storage Table の API
https://docs.microsoft.com/ja-jp/rest/api/storageservices/table-service-rest-api