尝试在PHP中测试Azure Cosmos DB的两种API

我是Sensor Robotics开发部的黑田。
在我们公司进行的“基础设施DX”项目中, 我们需要处理各种类型的时序数据,包括所谓的IoT传感器数据、无人机和UGV等获取的多媒体数据的分析结果,等等。
因此,我们调查了是否可以将Microsoft Azure的“Cosmos DB”作为我们的数据基础设施。但是,我们发现概念和服务配置相当复杂,而且在尝试使用PHP时缺乏相关信息。因此,我想将这些信息整理成备忘录。

Azure Cosmos DB是一个旧称为DocumentDB的数据库服务。

所谓的NoSQL数据库,就像AWS中的DynamoDB和DocumentDB一样,属于托管数据库的一类,但由于具有5种不同的API等复杂性,我试图将其整理一下(由于无法对所有内容进行详细调查,因此可能有些不准确)。

    • 高スループットと高可用性

 

    • RUというスループット性能に対する課金

 

    5つのAPI(データモデル)がある(以下、個々のAPIにおけるデータモデルの関係性)
APIDatabaseContainerItemSQL/Core APIDatabaseContainerDocumentCassandra APIKeyspaceTableRowMongoDB APIDatabaseCollectionDocumentGremlin APIDatabaseGraphNode/EdgeTable APIN/ATableEntity

这次,我们在调查和在PHP(Laravel环境)中尝试实现中,重点关注了”SQL/Core API”和”Table API”。

SQL/Core API 概述

所有的数据条目都以JSON格式存储,可以使用类似关系型数据库(RDB)的SQL来处理非常灵活的非结构化数据。
为了更容易理解,下面将提供数据和查询的示例。

[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 Table兼容的API,它是Azure Storage Service这个核心服务之一。
最常见的用例是在使用现有的Azure Storage Table的系统中,为了提高吞吐量和可用性而迁移到Cosmos DB。
顺便一提,对我个人来说,理解这个Azure Storage Service花了一些时间。
从服务名来看,我以为它可能类似于AWS的S3,但实际上Storage 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)的功能,可以原子操作多个实体,但是它也有一个限制,即“仅限属于同一Partition的数据”,所以在事务管理方面也需要进行设计。

数据和查询的示例可以类似以下的方式表示。

[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 可以被中国原生语言改写成:

SQL/核心 API

1. 通过门户创建Cosmos DB账户。

基本上,使用表单的默认值就可以了。

image.png

2. 创建容器

image.png
image.png
image.png

3. 使用PHP实现REST API调用。

很遗憾,目前没有支持PHP版的Cosmos DB SDK,因此只能使用原始的REST API通过Guzzle等方式调用。
以下是数据插入和查询执行的示例实现。

<?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进行如下确认。

$ 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操作。这里只记录结果。

宇宙数据库(SQL/Core API)写入
NoRUwait(msec)書き込みDocument数処理時間処理/sec140010100022.2sec45.024008100016.5sec60.6340051000-ERROR

投入数据大小大约为100字节至200字节,但是当 RU 值为400时,似乎上限大致为60个请求/秒。
无论是 DynamoDB 还是 CosmosDB,在不知道实际使用情况的情况下,对于设置值都会感到困扰。

顺便提一下,RU超过上限时会返回如下的错误响应。

{
  "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: ..."
}
宇宙数据库(SQL/Core API)读取

由于对于Read操作没有试探到极限值的精力,所以我们只进行了10000次读取。

NoRUwait(msec)読み込みDocument数処理時間処理/sec1400-100000.25sec40000

由于时间有点波动,看起来可能会受到缓存是否生效等影响。(最后随便说的)

表格 API

通过门户创建一个Cosmos DB账户

如果API不同,似乎还需要将Cosmos DB账户分开。

image.png

2. 创建桌子

image.png

只指定表名

image.png

这也可以在数据浏览器中进行查看和编辑(更类似于DynamoDB的用户界面)。

image.png

3. 在PHP中实现实体插入和查询。

由于Azure Storage Service专门为PHP开发了一个SDK,因此我们会使用该SDK。

如何使用PHP从Azure存储表服务API或Azure Cosmos DB表API?
https://docs.microsoft.com/ja-jp/azure/cosmos-db/table-storage-how-to-use-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. 写/读进行

宇宙数据库(表 API)写操作

由于我已经试过SQL/Core API,所以只记录结果。

NoRUwait(msec)書き込みEntity数処理時間処理/sec14001001000110sec9.1240080100090sec11.1340050100060sec16.7440030100060sec16.75400251000-ERROR

虽然数据不完全相同,实现方式也有所不同,但与SQL/Core API相比,性能似乎有所降低。另外,Table API与Azure Storage Table兼容,因此我已经尝试使用Azure Storage Table进行验证(只需将连接字符串更改为适用于Storage Table)。

存储桌子写入
NoRUwait(msec)書き込みEntity数処理時間処理/sec1-10100020sec502-8100018sec55.63-3100011sec90.94-110008sec125

太意外了。。换成了Storage Table后,写入处理性能居然提升了。
这样一来,使用CosmosDB的Table API的意义变得相当薄弱了;
可能要花钱增加RU,才能让Cosmos DB的性能更好一些,但是Storage Table的性能也还不错,所以是否物有所值还是一个令人犹豫的问题。

宇宙数据库(表 API)阅读

暂时只能查询到3000个实体,无法再继续。

NoRUwait(msec)読み込みEntity数処理時間処理/sec1400-30000.6sec5000
存储表阅读

似乎在读取方面,Storage Table只能在一次查询中返回最多1000个实体的限制,所以选择Cosmos似乎有其优点。

NoRUwait(msec)読み込みEntity数処理時間処理/sec1–10000.24sec4167

总结的东西

    • 以下のデータベース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/zh-cn/rest/api/cosmos-db/

查询
https://docs.microsoft.com/zh-cn/rest/api/cosmos-db/querying-cosmosdb-resources-using-the-rest-api

Azure Storage Table 的 API

https://docs.microsoft.com/zh-cn/rest/api/storageservices/table-service-rest-api

广告
将在 10 秒后关闭
bannerAds