DocumentDB でページング処理を行う

「データを一覧してページング表示する」という要件は エンプラ系でも コンシューマー系でも 常にある要件です。
ということで、DocumentDBでデータのページング取得を行う方法を書いておこうと思います。

1. 前提

DocumentDBアカウントには、既に以下のデータが保存されている状態を前提とします。

データベース名: ExampleDB1
コレクション名: ExampleCollection1
ドキュメント: ProductItemドキュメントが1,000件保存されている

ProductItemは以下のようなデータクラスです。

// 保存するドキュメント
using Newtonsoft.Json;

namespace PagingExample
{
  public class ProductItem
  {
    [JsonProperty(PropertyName = "id")]
    public string Id { get; set; }

    public string Name { get; set; }

    public int Price { get; set; }

    public int StockNumber { get; set; }
  }
}

以下のような感じで、適当なデータが入っています。

f:id:daigo-knowlbo:20170507003752p:plain

2. DocumentDB操作管理クラスを用意

DocumentDBを操作する管理クラス(DocumentManager)の実装は以下の通りです。

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Linq;
using Microsoft.Azure.Documents.Client;
using System.IO;
using System.Threading.Tasks;

namespace PagingExample
{
  public class DocumentManager
  {
    private const string EndpointUrl = "https://rddocdb.documents.azure.com:443/";
    private const string PrimaryKey = "[マスターキー]";

    private const string DatabaseID = "ExampleDB1";
    private const string CollectionID = "ExampleCollection1";

    private DocumentClient client;

    // DocumentClientオブジェクトを初期化
    public async Task<bool> InitializeDocumentClient()
    {
      this.client = new DocumentClient(new Uri(DocumentManager.EndpointUrl), DocumentManager.PrimaryKey,
        new ConnectionPolicy
        {
          ConnectionMode = ConnectionMode.Gateway,
          ConnectionProtocol = Protocol.Https
        });

      return true;
    }

    // ProductItemを 10件 ずつ取得
    public async Task<(List<ProductItem>, string)> QueryProductItems(string continuration)
    {
      List<ProductItem> result = null;

      var feedOption = new FeedOptions() {
        MaxItemCount = 10,
        RequestContinuation = continuration };

      Uri documentCollectionUri = 
        UriFactory.CreateDocumentCollectionUri(
          DocumentManager.DatabaseID, DocumentManager.CollectionID);
      var query =
        this.client.CreateDocumentQuery<ProductItem>(
          documentCollectionUri, feedOption)
          .Where( p => p.Price > 700 )
          .AsDocumentQuery();

      var ret = await query.ExecuteNextAsync<ProductItem>();
      
      result = ret.ToList();
      string retContinuration = ret.ResponseContinuation;

      return (result, retContinuration);
    }
  }
}

InitializeDocumentClient()メソッド

DocumentClientオブジェクトを new してメンバーフィールドに保持します。

QueryProductItems()メソッド

引数 continuration は、DocumentDBに「どのページ(というかどの位置から継続抽出するか)」の状態文字列です。1ページ目の取得時には「空文字列」を設定します。
CreateDocumentQuery()を呼び出す際の引数「FeedOptions」で、「1度に取得する件数(MaxItemCount)」と「どのページ(どこから継続取得するかを表す状態文字列(RequestContinuation )」を指定します。
AsDocumentQuery()呼び出して、ExecuteNextAsync()呼び出した結果から、10件の抽出データ(ret.ToList()) と 次のページを読み込む為の継続取得状態文字列(ret.ResponseContinuation)が得られます。
これら2つのデータをタプルで戻り値として呼び出し元に返却しています。
また、サンプルなので Price > 700 という固定的なWhere条件を付加しています。

3. DocumentManagerを呼び出してページングデータ取得

では、前述で用意した DocumentManager を利用してProductItemデータを10件ずつページング取得したいと思います。
以下が実装になります。

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();

// コンソール出力用ページ番号
int pageNo = 0;

// DocumentDBに投げるcontinuration値(1ページ目は空文字列)
string continuration = "";

do
{
  (List<ProductItem> items, string continuration) queryResult = 
    await manager.QueryProductItems(continuration);

  // 次のページを取得するためのcontinuration文字列取得
  continuration = queryResult.continuration;

  // 取得したデータをコンソール出力
  Console.WriteLine(string.Format("page {0}", pageNo));
  Console.WriteLine(string.Format("continuration {0}", continuration));
  foreach (var item in queryResult.items)
  {
    Console.WriteLine(string.Format("{0} {1}", item.Name, item.Price));
  }
  Console.WriteLine("-----");
  pageNo++;
} while (!string.IsNullOrEmpty(continuration)); // continurationが空だと終端ページに達したということ

データ抽出で得られた(Responseされた)Continuation値を、次のデータ抽出(Request)で利用する形です。

4. 実行結果

実行結果は以下の通りです。

page 0
continuration {"token":"+RID:mi01AOyHIgA7AAAAAAAAAA==#RT:1#TRC:10#FPC:AgEAAAB8AD8AABhwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABLcAHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAE=","range":{"min":"","max":"FF"}}
Product_8 800
Product_9 900
Product_18 800
Product_19 900
Product_28 800
Product_29 900
Product_38 800
Product_39 900
Product_48 800
Product_49 900
-----
page 1
continuration {"token":"+RID:mi01AOyHIgBtAAAAAAAAAA==#RT:2#TRC:20#FPC:AgEAAAB2AG8AAGDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHKsAccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAE=","range":{"min":"","max":"FF"}}
Product_58 800
Product_59 900
Product_68 800
Product_69 900
Product_78 800
Product_79 900
Product_88 800
Product_89 900
Product_98 800
Product_99 900
-----
page 2
continuration {"token":"+RID:mi01AOyHIgCfAAAAAAAAAA==#RT:3#TRC:30#FPC:AgEAAABuAJ+ANcABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMAB","range":{"min":"","max":"FF"}}
Product_108 800
Product_109 900
Product_118 800
Product_119 900
Product_128 800
Product_129 900
Product_138 800
Product_139 900
Product_148 800
Product_149 900
-----
page 3
continuration {"token":"+RID:mi01AOyHIgDRAAAAAAAAAA==#RT:4#TRC:40#FPC:AgEAAABoAN8ABhxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABI8AHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMAB","range":{"min":"","max":"FF"}}
Product_158 800
Product_159 900
Product_168 800
Product_169 900
Product_178 800
Product_179 900
Product_188 800
Product_189 900
Product_198 800
Product_199 900
-----
page 4
continuration {"token":"+RID:mi01AOyHIgADAQAAAAAAAA==#RT:5#TRC:50#FPC:AgEAAABiAA8BGHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHIMAccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMAB","range":{"min":"","max":"FF"}}
Product_208 800
Product_209 900
Product_218 800
Product_219 900
Product_228 800
Product_229 900
Product_238 800
Product_239 900
Product_248 800
Product_249 900
-----

...省略...

-----
page 18
continuration {"token":"+RID:mi01AOyHIgC-AwAAAAAAAA==#RT:19#TRC:190#FPC:AgEAAAAKAL+DA8ABBxxwwAE=","range":{"min":"","max":"FF"}}
Product_908 800
Product_909 900
Product_918 800
Product_919 900
Product_928 800
Product_929 900
Product_938 800
Product_939 900
Product_948 800
Product_949 900
-----
page 19
continuration
Product_958 800
Product_959 900
Product_968 800
Product_969 900
Product_978 800
Product_979 900
Product_988 800
Product_989 900
Product_998 800
Product_999 900
-----
続行するには何かキーを押してください . . .

5. HTTP通信内容

DocumentDBライブラリにより、裏側で具体的にどのようなREST API リクエストとレスポンスが行われたのかをFiddlerで確認します。

f:id:daigo-knowlbo:20170507012230p:plain

上記Fiddler画面から以下のことが分かります。

  • 「/dbs/ExampleDB1/colls/ExampleCollection1/docs」へのページごとのリクエストが行われた
  • FeedOptions.MaxItemCount / FeedOptions.RequestContinuation の値がHTTPリクエストヘッダでそれぞれ「x-ms-max-item-count」「x-ms-continuation」して指定されている
  • HTTPレスポンスヘッダ「x-ms-continuation」で、次のページを読み込むための継続状態文字列が返されている

資料

公式ドキュメントでは以下を見ればいいのかな。

docs.microsoft.com

Common DocumentDB REST request headers | Microsoft Docs

Common Azure Cosmos DB REST response headers | Microsoft Docs

DocumentDB のインデックス設定によるRUの変動について

DocumentDBのインデックスについて調べてのでブログに記しておこうと思います。
DocumentDBではデフォルト設定でも結構いい感じに動くようにインデックス設定が行われているのですが、カスタマイズ可能であり、これを最適化するとRUや(インデックス用)データサイズの節約につながります。

※RU = RequestUnit。DocumentDBにおける「単位時間(秒)あたりの処理数の抽象単位」。課金に大きく影響する数値です。

1. デフォルトのインデックス設定

DocumentDBでは、インデックスはコレクションに対して設定可能です。
またコレクションを作成すると、デフォルトでは、以下のようなインデックスポリシーが設定されます。

{
  "indexingMode": "consistent",
  "automatic": true,
  "includedPaths": [
    {
      "path": "/*",
      "indexes": [
        {
          "kind": "Range",
          "dataType": "Number",
          "precision": -1
        },
        {
          "kind": "Hash",
          "dataType": "String",
          "precision": 3
        }
      ]
    }
  ],
  "excludedPaths": []
}

これは、Azureポータル上で「対象DocumentDBアカウント → DataExploere → 対象データベース → 対象コレクション → Scale & Settings」で確認することができます。

f:id:daigo-knowlbo:20170505004326p:plain

「includedPaths」はインデックス適用対象のパス、「excludedPaths」はインデックス所帯対象のパス、という意味になります。
「path: “/*"」とは、「格納されたJSONデータ要素のすべて(配下の階層を含めてすべて)に対して」という意味を持ちます。
そして ”/*" に対して、「Range」「Hash」の2種類のインデックスが設定されています。
それぞれのインデックス種類の意味は、Azure Cosmos DB indexing policies | Microsoft Docsに記述されており、以下が対象箇所の引用になります。

Hash:
 Hash を /prop/? (または/) に使用して、以下のクエリを効率的に処理することができます。
 SELECT FROM collection c WHERE c.prop = “value”
 Hash を /props/[]/? (または / または /props/) に使用して、以下のクエリを効率的に処理することができます。
 SELECT tag FROM collection c JOIN tag IN c.props WHERE tag = 5

Range:
 Range を /prop/? (または/) に使用して、以下のクエリを効率的に処理することができます。
 SELECT FROM collection c WHERE c.prop = “value”
 SELECT FROM collection c WHERE c.prop > 5
 SELECT FROM collection c ORDER BY c.prop

インデックスの種類にはもう1つ「Spatial」があります。これはGEOクエリーを行う為のインデックスとなります。

つまり、コレクションに対するデフォルトのインデックス設定は、一般的な「文字列比較」や「数値比較」による抽出、また並べ替えのためのインデックスが、すべてのドキュメント要素に適用されることになります。

2. 下準備

さて、本投稿では「インデックスの最適化によるRUの変動」を調べるのですが、そのためのデータを準備したいと思います。
以下のPhotoItemクラスをコレクションに保存するドキュメントエンティティとします。

public class PhotoItem
{
  [JsonProperty(PropertyName = "id")]
  public string Id { get; set; }

  // タイトル
  public string Title { get; set; }

  // 「いいね」数
  public int LikeCount { get; set; }

  // 写真のURI
  public string PhotoUri { get; set; }

  // カテゴリ
  public string Category { get; set; }
}

データベース・コレクションは以下のDocumentManagerクラスのInitializeDocumentClient()メソッドで作成します。
データベース名は ExampleDB1、コレクション名は ExampleCollection1、パーティションキーとして Category を設定します。
CreateExampleDocument(PhotoItem photoItem)メソッドは、PhotoItemドキュメントを作成します。

public class DocumentManager
{
  private const string EndpointUrl = "https://rddocdb.documents.azure.com:443/";
  private const string PrimaryKey = "[プライマリキー]";

  private const string DatabaseID = "ExampleDB1";
  private const string CollectionID = "ExampleCollection1";

  private DocumentClient client;

  // データベースとコレクションを作成
  public async Task<bool> InitializeDocumentClient()
  {
    this.client = new DocumentClient(new Uri(DocumentManager.EndpointUrl), DocumentManager.PrimaryKey,
      new ConnectionPolicy
      {
        ConnectionMode = ConnectionMode.Gateway,
        ConnectionProtocol = Protocol.Https
      });

    // ExampleDB1 データベースを作成
    await this.client.CreateDatabaseIfNotExistsAsync(
      new Database { Id = DocumentManager.DatabaseID });

    // ExampleCollection1 コレクションを作成(パーティションキーはCategory)
    Collection<string> partitionPaths = new Collection<string>();
    partitionPaths.Add("/Category");
    var collection = new DocumentCollection { 
                Id = DocumentManager.CollectionID, 
                PartitionKey = new PartitionKeyDefinition() { Paths = partitionPaths } };
    collection = await this.client.CreateDocumentCollectionIfNotExistsAsync(
      UriFactory.CreateDatabaseUri(DocumentManager.DatabaseID),
      collection);

    return true;
  }
  
  // ドキュメントを作成
  public async Task<Document> CreateExampleDocument(PhotoItem photoItem)
  {
    // ドキュメント作成
    var doc = await this.client.CreateDocumentAsync(
      UriFactory.CreateDocumentCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID),
      photoItem);
    
    return doc;
  }

  ...省略

}

以下のコードで DocumentManagerクラス を呼び出してデータベース・コレクション・10,000件のドキュメントを作成します。

// DocumentManagerクラスを呼び出してデータベースを初期化
Random random = new Random();

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();

string[] categories = { "sky", "sea", "mountain", "people" };

for (int i = 0; i < 10000; i++)
{
  PhotoItem item = new PhotoItem()
  {
    Id = i.ToString(),
    Title = string.Format("SampleTitle{0}", i.ToString()),
    LikeCount = random.Next(1000),
    PhotoUri = string.Format("https://xxxxxxx/{0}", i.ToString()),
    Category = categories[i%4]
  };
  var ret = await manager.CreateExampleDocument(item);
  System.Threading.Thread.Sleep(50); // 429しないように緩やかに追加する
  Console.WriteLine(i);
}

3. デフォルト状態での書き込みと読み込み

デフォルトのインデックス状態で書き込みと読み込みを確認してみます。

書き込み

10件のドキュメントを書き込んでみます。

Random random = new Random();

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();

{
  string[] categories = { "sky", "sea", "mountain", "people" };
  for (int id = 10000; id < 10010; id++)
  {

    PhotoItem item = new PhotoItem()
    {
      Id = id.ToString(),
      Title = string.Format("SampleTitle{0}", id.ToString()),
      LikeCount = random.Next(1000),
      PhotoUri = string.Format("https://xxxxxxx/{0}", id.ToString()),
      Category = categories[id % 4]
    };
    var ret = await manager.CreateExampleDocument(item);
  }
}

上記ソースにより送信されたHTTPリクエストは以下のようになります(ここでは1件のみ抽出。続けて実施した10件の書き込みのRUは同一であった為)。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-documentdb-partitionkey: ["sea"]
x-ms-date: Thu, 04 May 2017 16:46:29 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3deHQp5YAx4oaQAkFvxYn0vh9sMbLFqPliTss6TqfrT1Y%3d
x-ms-session-token: 0:10043
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 109
Expect: 100-continue

{"id":"10001","Title":"SampleTitle10001","LikeCount":469,"PhotoUri":"https://xxxxxxx/10001","Category":"sea"}

そして、HTTPレスポンスは以下の通りです。

HTTP/1.1 201 Created
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:05.215 GMT
etag: "00003133-0000-0000-0000-590b5ae50000"
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=3912;documentsCount=10031;collectionSize=6560;
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-quorum-acked-lsn: 10043
x-ms-current-write-quorum: 3
x-ms-current-replica-set-size: 4
x-ms-xp-role: 1
x-ms-request-charge: 6.1
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: 6f53bfbf-d4b1-4ad4-9bf1-b4ce7367636b
x-ms-session-token: 0:10044
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 16:46:28 GMT

...省略...

消費RUは・・・「x-ms-request-charge: 6.1」。
1件の書き込みRUは 6.1 でした。

読み込み

ドキュメントを読み込みを行います。
DocumentManagerクラスに検索用メソッドQueryPhotoItems()を追加します。
QueryPhotoItems()はかなり固定的な実装で、LikeCountが100より大きく、Categoryが sky のドキュメントを50件取得します。

public class DocumentManager {
 ...省略
  public async Task<List<PhotoItem>> QueryPhotoItems()
  {
    List<PhotoItem> result = null;

    var feedOption = new FeedOptions() { MaxItemCount = 50 };

    var query = this.client.CreateDocumentQuery<PhotoItem>(
      UriFactory.CreateDocumentCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID), feedOption)
      .Where(i => i.LikeCount > 100 && 
             i.Category == "sky")
      .Take(50);
    result = query.ToList();

    return result;
  }
}

DocumentManager.QueryPhotoItems()を呼び出す実装は以下の通り。

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();
// LikeCountが>100のドキュメントを50件検索
var result = await manager.QueryPhotoItems();

上記で実行されるHTTPリクエストは以下の通りです。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-continuation: 
x-ms-documentdb-isquery: True
x-ms-max-item-count: 50
x-ms-documentdb-query-enablecrosspartition: False
x-ms-documentdb-query-iscontinuationexpected: False
x-ms-date: Thu, 04 May 2017 16:59:15 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dKF%2fbc5aVHlTffYtoHF4k5qzALl727%2bNdHlERf3plj7U%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Content-Type: application/query+json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 109
Expect: 100-continue

{"query":"SELECT TOP 50 * FROM root WHERE ((root[\"LikeCount\"] > 100) AND (root[\"Category\"] = \"sky\")) "}

HTTPレスポンスは以下の通りです。

HTTP/1.1 200 Ok
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:13.755 GMT
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=4298;documentsCount=10040;collectionSize=6964;
x-ms-item-count: 50
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-xp-role: 2
x-ms-request-charge: 20.32
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: 2f1f663c-09e2-4913-b20d-8ef5ac3d63c2
x-ms-session-token: 0:10052
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 16:59:15 GMT

...省略...

x-ms-request-charge: 20.32。
消費RUは 20.32 でした。

4. LikeCount/Categoryのみのインデックス設定で書き込みと読み込み

インデックスを以下のように変更します。
LikeCount / Categoryのみにインデックスを適用するようにカスタマイズします。

{
  "indexingMode": "consistent",
  "automatic": true,
  "includedPaths": [
    {
      "path": "/LikeCount/*",
      "indexes": [
        {
          "kind": "Range",
          "dataType": "Number",
          "precision": -1
        }
      ]
    },
    {
      "path": "/Category/*",
      "indexes": [
        {
          "kind": "Hash",
          "dataType": "String",
          "precision": 3
        }
      ]
    }
  ],
  "excludedPaths": [
    {
      "path": "/*"
    }
  ]
}

設定はAzureポータルで「設定 → インデックス作成ポリシー」から変更することができます。

f:id:daigo-knowlbo:20170505020619p:plain

書き込み

先程と同様に10件のドキュメントを書き込んでみます。
HTTPリクエストは以下の通りです。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-documentdb-partitionkey: ["mountain"]
x-ms-date: Thu, 04 May 2017 17:03:09 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3deHdnuMnm8pSUdfK4T0ItZPjQzGvmyzqBOEExfbDCuUw%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 114
Expect: 100-continue

{"id":"10010","Title":"SampleTitle10010","LikeCount":782,"PhotoUri":"https://xxxxxxx/10010","Category":"mountain"}

HTTPレスポンスは以下の通りです。

HTTP/1.1 201 Created
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:05.215 GMT
etag: "00004e33-0000-0000-0000-590b5ecd0000"
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=4317;documentsCount=10040;collectionSize=6965;
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-quorum-acked-lsn: 10053
x-ms-current-write-quorum: 3
x-ms-current-replica-set-size: 4
x-ms-xp-role: 1
x-ms-request-charge: 5.52
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: fedec436-d43f-479c-b8c7-8ce095d9dd07
x-ms-session-token: 0:10054
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 17:03:08 GMT

...省略...

消費RUは・・・「x-ms-request-charge: 5.52」。
1件の書き込みRUは 5.52 でした。
デフォルトインデックス状態の 6.1 よりも小さな値になりました。

読み込み

ドキュメントを読み込みを行います。
先程と同様の読み込みを行います。

HTTPリクエストは以下の通りです。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-continuation: 
x-ms-documentdb-isquery: True
x-ms-max-item-count: 50
x-ms-documentdb-query-enablecrosspartition: False
x-ms-documentdb-query-iscontinuationexpected: False
x-ms-date: Thu, 04 May 2017 17:10:13 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dvO5eIEtwI7FZ3%2bD8d8ezEIvH3Hp9EuY1EJ8ZR0Rm2WE%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Content-Type: application/query+json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 109
Expect: 100-continue

{"query":"SELECT TOP 50 * FROM root WHERE ((root[\"LikeCount\"] > 100) AND (root[\"Category\"] = \"sky\")) "}

HTTPレスポンスは以下の通りです。

HTTP/1.1 200 Ok
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:13.755 GMT
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=3929;documentsCount=10050;collectionSize=6597;
x-ms-item-count: 50
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-xp-role: 2
x-ms-request-charge: 20.32
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: 2be70cc0-d86c-40ce-8f72-a2542c954ad9
x-ms-session-token: 0:10063
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 17:10:13 GMT

...省略...

消費RUは 20.32 でした。
検索条件(LikeCount・Category)が、デフォルトインデックス時も、その後のカスタムインデックス時も共に対象となっている為に、読み込み時の消費RUが変化しませんでした。

4. 読み込みインデックスの効きを確認する

前述の確認では デフォルトインデックス時・カスタムインデックス時 共にインデックス対象の項目で検索してしまっていました。
検索条件を以下のようにした場合はどうでしょうか?

var query = this.client.CreateDocumentQuery<PhotoItem>(
  UriFactory.CreateDocumentCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID), feedOption)
  .Where(i => i.LikeCount > minLikeCount && 
              i.Category == "sky" && 
              i.Title == "SampleTitle0")
  .Take(50);

Title項目も検索対象としました(このクエリー条件は現実のビジネス的にはちょっと変な条件指定ですが・・・技術的検証としてとらえてください)。
Titleは「デフォルトインデックス時にはインデックス対象」「カスタムインデックス時にはインデックス対象外」となる項目です。

実施結果は以下のようになります。

デフォルトインデックス時(インデックス対象) : x-ms-request-charge: 5.96
カスタムインデックス時(インデックス対象外) : x-ms-request-charge: 204.98

RUに大きな違いが発生しました。
インデックス外の項目での検索の負荷の高さを確認することができます。

まとめ

DocumentDBではデフォルト状態で「すべての項目に対して Hash と Range のインデックスが設定されている」。
GEOクエリーを行わなければ、何もしないでもある程度いい感じに動いてくれる。
インデックスの最適化で消費RUの最適化が図れる。ただし、適切に実施しないと、消費RUに大きな差が生まれるので注意しなければならない。
といった感じでしょうか。

DocumentDBの(マスター)キーとauthorizationトークンについて

最近 DocumentDB について調べたり、使ったりしております。
DocumentDB は (たしか)2015年にGAしたと思うのですが、GAから日が経っているのに、想像したよりもネット上のブログなどでの資料が少ない気がしています(特に日本語)。
一方 本家のドキュメントは日本語版も含めかなりしっかりしている印象です。

まあ、大抵のエンプラ系システムであれば、NoSQLなんて使わず従来通りのSQL Server(もしくはSQL Database)でOKですからね。
普通の開発現場では、ちょー早くて、無制限にスケール可能で、でもデータ構造設計やパーティション分割やトランザクションや検索にコツが必要な DocumentDB(NoSQL) を欲するケースは少ないのでしょう・・・

でも、私個人的には、DocumentDBを含むクラウドネイティブな技術が好きなんですよね^^

と長くなりましたが本題へ・・・

(マスター)キーとauthorizationトークンの関係

このお話、DocumentDBをREST APIから使用している(使用したことがある)方は分かっていると思いますが、ライブラリ経由でしか使ったことがない方の場合は認識できていないかもしれない、という内容のお話です。
だからDocumentDBを利用した開発においては必須というわけではありません。

DocumentDBを利用する場合、以下の2パターンの方法があります。

  • REST APIを直に呼び出す
  • 各言語用ライブラリを使用する

大抵、後者の各言語用ライブラリを使う事と思います。
Visual Studio & .NET だったらNugetで「Microsoft.Azure.DocumentDB(.NET CoreならMicrosoft.Azure.DocumentDB.Core)」を追加するだけですね。
ライブラリはバックエンドでREST APIを呼び出してくれます。接続プロトコル(ConnectionPolicy.ConnectionProtocol)を HTTPS にしていれば、Fiddlerなんかでその通信内容を確認することができます。

で、DocumentDBに接続する場合には、DocumentClientクラスを以下のようにインスタンス化します。

var client = new DocumentClient(uri, authKey);

認証・認可のために第2引数に認証キー(authKey)を渡します(第1引数uriは、利用するDocumentDBアカウントのURIです)。
インスタンス化した DocumentClient に対して、CreateDocumentQuery()だったりCreateStoredProcedureQuery()だったりのメソッド呼び出しを行うことで対象のDocumentDBに対する操作を行うことができます。
「認証キー(authKey)」というのは、Azureポータルで確認可能なプライマリキー(セカンダリキー)です。
以下の画面キャプチャ。

f:id:daigo-knowlbo:20170504125630p:plain

ではDocumentClientがDocumentDBに対して操作を行う際にはどのような通信を行っているのか確認してみます。
まず、以下のようなDocumentDBの操作を行うクラスを用意します(DocumentManagerクラス)。

public class DocumentManager {
  private const string EndpointUrl = "https://rddocdb.documents.azure.com:443/";

  private const string PrimaryKey = "[(マスター)プライマリキー]";
  
  private const string DatabaseID = "ExampleDB1";
  private const string CollectionID = "ExampleCollection1";
  
  private DocumentClient client;
  
  public async Task<bool> Init() {
    // DocumentClientオブジェクト 作成
    this.client = new DocumentClient(new Uri(DocumentManager.EndpointUrl), DocumentManager.PrimaryKey,
      new ConnectionPolicy
      {
        ConnectionMode = ConnectionMode.Gateway,
        ConnectionProtocol = Protocol.Https
      });
    
    // ExampleDB1 作成
    var db = await this.client.CreateDatabaseIfNotExistsAsync(
      new Database { Id = DocumentManager.DatabaseID });
    
    // ExampleCollection1 作成
    var collection = await this.client.CreateDocumentCollectionIfNotExistsAsync(
      UriFactory.CreateDatabaseUri(DocumentManager.DatabaseID),
      collection);
    
    return true;
  }
  
  public async Task<bool> ReadDocumentCollection() {
    // コレクション情報を読み込む
    var collection = await this.client.ReadDocumentCollectionAsync(
      UriFactory.CreateCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID));

    return true;
  }
}

Init()メソッドは、以下の処理を行います。

  • DocumentClientオブジェクトを作成しprivateメンバーに保持
  • ExampleDB1データベースを作成
  • ExampleCollection1コレクションを作成

ReadDocumentCollection()メソッドは、Init()で作成されたExampleCollection1コレクション情報を読み取ります。

そして、以下のようにdocumentManagerを呼び出しましょう。

var documentManager = new DocumentManager();
var initResult = await documentManager.Init();
var readResult = await documentManager.ReadDocumentCollection();

最後の ReadDocumentCollection() 呼び出しに注目します。
Fiddlerで抜き取ったHTTPS通信は以下の通りです。

f:id:daigo-knowlbo:20170504133809p:plain

「/dbs/ExampleDB1/colls/ExampleCollection1」にGETリクエストを行っています。
つまり↓↓↓のREST APIが呼び出されています。
Get a Collection | Microsoft Docs
HTTP GETのリクエストヘッダで注目すべきは authorization です。
DocumentClientオブジェクト作成時に渡した認証キー(プライマリキー)を元にして authorization 値が生成されています。
厳密には認証キー(プライマリキー)を含む以下の要素から生成されています。

  • HTTP Verb(GETとかPOSTとか)
  • リソースタイプ(CollectionとかDocumentとかのアクセスの対象リソースタイプ)
  • リソースリンク(こんなの・・/dbs/ExampleDB1/colls/ExampleCollection1)
  • 日時(トークン生成日)
  • キー
  • キーのタイプ(masterとかresource)
  • トークンバージョン

日時は注意が必要で、トークン生成時に指定した日時と HTTPヘッダ x-ms-date 値がマッチしている必要があります。

上記からauthorizationトークンを生成するロジックは Access Control on Azure Cosmos DB Resources | Microsoft Docs に書かれておりまして、コードの引用が以下になります。

// authorizationトークンを作成
static string GenerateAuthToken(
  string verb, 
  string resourceType, 
  string resourceLink, 
  string date, 
  string key, 
  string keyType, 
  string tokenVersion)
{
  var hmacSha256 = new System.Security.Cryptography.HMACSHA256 { Key = Convert.FromBase64String(key) };

  verb = verb ?? "";
  resourceType = resourceType ?? "";
  resourceLink = resourceLink ?? "";

  string payLoad = string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}\n{1}\n{2}\n{3}\n{4}\n",
      verb.ToLowerInvariant(),
      resourceType.ToLowerInvariant(),
      resourceLink,
      date.ToLowerInvariant(),
      ""
  );

  byte[] hashPayLoad = hmacSha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(payLoad));
  string signature = Convert.ToBase64String(hashPayLoad);

  return System.Web.HttpUtility.UrlEncode(String.Format(System.Globalization.CultureInfo.InvariantCulture, "type={0}&ver={1}&sig={2}",
    keyType,
    tokenVersion,
    signature));
}

以下のようなコードで認証トークンを生成することができます。

var authorizationToken = 
  GenerateAuthToken(
    "get", 
    "colls", 
    "dbs/ExampleDB1/colls/ExampleCollection1", 
    "Tue, 02 May 2017 10:20:01 GMT", 
    "[プライマリキー]", 
    "master", 
    "1.0");

認証トークン生成して使ってみる

では認証トークンを生成して実際にREST呼び出ししてみましょう。
Fiddlerを使ってHTTPリクエストを投げたいと思います。

1.正常なケース

以下のリクエストを行います。
トークン生成に使用した 日時 と x-ms-date は同一でないといけない点には注意。
f:id:daigo-knowlbo:20170504141751p:plain

いい感じにレスポンスが返ってきました。
f:id:daigo-knowlbo:20170504141832p:plain

2.不正なauthorizationトークンを利用したケース

リクエスト内容とマッチしないauthorizationトークンを使ってリクエストを行います。
f:id:daigo-knowlbo:20170504141857p:plain

401 Unauthorized が返ってきます。
f:id:daigo-knowlbo:20170504141923p:plain

3.期限切れのauthorizationトークンを利用したケース

authorizationトークンは有効期限があります。
次は、有効期限切れになったauthorizationトークンでリクエストを行ってみます。
f:id:daigo-knowlbo:20170504141947p:plain

403 Forbidden が返ってきます。 f:id:daigo-knowlbo:20170504142012p:plain

bodyを見ると親切に以下のようなメッセージが書かれています。

{
  "code":"Forbidden",
  "message":"The authorization token is not valid at the current time. 
             Please create another token and retry 
             (token start time: Thu, 04 May 2017 00:57:01 GMT, 
              token expiry time: Thu, 04 May 2017 01:12:01 GMT, 
              current server time: Thu, 04 May 2017 05:04:48 GMT).\r\n
              ActivityId: f7c026e7-ddea-4c89-9331-4c9b24d50d9e"
}

つまり・・・↓↓↓と言っていますね。DocumentDB、超親切!!

そのトークンは現時刻では無効だよ。
別のトークン作ってリトライしてね。
そのトークンの開始時刻は「Thu, 04 May 2017 00:57:01 GMT」。無効になるのは「Thu, 04 May 2017 01:12:01 GMT」、今のサーバー時刻は「Thu, 04 May 2017 05:04:48 GMT」。

うん、つまりauthorizationトークンの有効期限は15分ってことですね。

まとめ

内容的に まとめ ってこともないのですが、いつも思うことはライブラリにラップされた内容だけでなくその裏側の本当の姿まで理解しておく事は重要だと思います。
そうすると、何か問題が起きた時に色々解決の糸口への道のりが短くなるのだと思っています。

Computer Vision APIでランドマーク認識する

本日は天気が良く、最近にしては珍しく予定が何もなかったのでプチドライブにいってきました。

我が家のある夢の国を出発し、永代橋を渡り、銀座を通り抜け、東京タワーへ、そしてレインボーブリッジ(下の一般道)を渡って夢の国に舞い戻る・・・という。

で、日本が誇るランドマーク「東京タワー」をカメラで撮ってきました。

ランドマークといえば先日2017/4/19に Cognitive Services のいくつかに機能が GA していました・・・(Face API / Computer Vision API / Content Moderator)

ということで、Computer Vision APIのランドマーク認識は、私の撮った写真をきちんと認識してくれるのかどうか試してみましょう!

Computer Vision APIよ、これが何か分かるか!?

f:id:daigo-knowlbo:20170423163959p:plain
※今日は、なんか、真ん中の展望台辺りから霧のようなものが噴霧されていました。

1. Azureに「Cognitive Services APIs」を作成

Cognitive Servicesを利用するためにはAzureに「Cognitive Services APIs」を作成する必要があります。
Azureポータルにログインします。
「+新規」をクリック。

f:id:daigo-knowlbo:20170423161222p:plain

Cognitive Services APIsを選択。

f:id:daigo-knowlbo:20170423161258p:plain

f:id:daigo-knowlbo:20170423161308p:plain

作成。

f:id:daigo-knowlbo:20170423161328p:plain

各種パラメータを適時設定します。

f:id:daigo-knowlbo:20170423161419p:plain
ここでは以下の設定にしました。

  • アカウント名: MyCognitive1
  • APIの種類: 今回はランドマーク認識を行うので「Computer Vision API」となります。
  • 場所: 日本のリージョンはまだないので「東南アジア」にしました。
  • 価格レベル: テストなので無料の F0 にしました(F0は、1分あたり20回・1ケ月あたり5000回の呼び出し制限)。

しばらく待つと作成が完了します。
以下はリソースの一覧画面で確認したところです。

f:id:daigo-knowlbo:20170423161851p:plain

MyCognitive1を選択し「概要」画面で「エンドポイント」をコピーしておきます。

f:id:daigo-knowlbo:20170423161943p:plain

「キー」を選択し「キー1」をコピーしておきます。

f:id:daigo-knowlbo:20170423162047p:plain

エンドポイントとキーはプログラムからAPI呼び出しを行う際に利用します。

2. コンソールアプリを作成

Visual Studio 2017(2015でもいいけど)を起動し、コンソールアプリケーションを作成します。
(前述で作成したCognitive Services(Computer Vision API)を呼び出すアプリです)

で、実装は単純に以下のサンプルをそのまま拝借させていただきました。

azure.microsoft.com

// https://azure.microsoft.com/ja-jp/blog/microsoft-cognitive-services-general-availability-for-face-api-computer-vision-api-and-content-moderator/から引用
// 名前空間、キー設定部分、エンドポイント指定部分を修正しています。

using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;

namespace RecognizinglandmarksExample
{
  static class Program
  {
    static void Main()
    {
      Console.Write("Enter image file path: ");
      string imageFilePath = Console.ReadLine();

      MakeAnalysisRequest(imageFilePath);

      Console.WriteLine("\n\nHit ENTER to exit...\n");
      Console.ReadLine();
    }

    static byte[] GetImageAsByteArray(string imageFilePath)
    {
      FileStream fileStream = new FileStream(imageFilePath, FileMode.Open, FileAccess.Read);
      BinaryReader binaryReader = new BinaryReader(fileStream);
      return binaryReader.ReadBytes((int)fileStream.Length);
    }

    static async void MakeAnalysisRequest(string imageFilePath)
    {
      var client = new HttpClient();

      // Request headers. Replace the second parameter with a valid subscription key.
      //client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", "putyourkeyhere");
      client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", "あなたのキーを設定");

      // Request parameters. You can change "landmarks" to "celebrities" on requestParameters and uri to use the Celebrities model.
      string requestParameters = "model=landmarks";
      //string uri = "https://westus.api.cognitive.microsoft.com/vision/v1.0/models/landmarks/analyze?" + requestParameters;
      string uri = "https://southeastasia.api.cognitive.microsoft.com/vision/v1.0/models/landmarks/analyze?" + requestParameters;
      
      Console.WriteLine(uri);

      HttpResponseMessage response;

      // Request body. Try this sample with a locally stored JPEG image.
      byte[] byteData = GetImageAsByteArray(imageFilePath);

      using (var content = new ByteArrayContent(byteData))
      {
        // This example uses content type "application/octet-stream".
        // The other content types you can use are "application/json" and "multipart/form-data".
        content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
        response = await client.PostAsync(uri, content);
        string contentString = await response.Content.ReadAsStringAsync();
        Console.WriteLine("Response:\n");
        Console.WriteLine(contentString);
      }
    }
  }
}

上記ソースから以下の行を修正します。

client.DefaultRequestHeaders.Add(“Ocp-Apim-Subscription-Key”, “あなたのキーを設定”);

「あなたのキーを設定」部分には、Azureポータルからの「Cognitive Services APIs」作成で得られた「キー1」に置き換えます。

もう1ヶ所、以下のエンドポイント部分も修正対象ですが、こちらは上記ソースでは東南アジア用に修正してあります。

string uri = “https://westus.api.cognitive.microsoft.com/vision/v1.0/models/landmarks/analyze?” + requestParameters;

ビルドしてコンソールアプリを作成します。
(このコンソールアプリでは、Vision APIの呼び出しに単純なHTTP POSTを行っているだけなので、特別なNugetパッケージの追加は必要ありません)

3. 実行

CTRL + F5で実行します。
画像ファイル名を聞かれるので、入力してEnter。

f:id:daigo-knowlbo:20170423163309p:plain

認識の結果がJSONで帰ってきます。

f:id:daigo-knowlbo:20170423163317p:plain

「Tokyo Tower」正解!そして confidence は 0.9743892 とのこと。

まとめ

本投稿は Cognitive Services の本当に触りの部分の薄っぺらな内容でしたが、顔認識・文字認識等々色々な機能が提供されています。BotとかMachine Learningとかと組み合わせるといろいろなことが出来そうですよね。

Global Azure Bootcamp 2017@Tokyo に行ってきた

本日(2017/4/22)、Global Azure Bootcamp 2017@Tokyoに参加してきました。

Global Azure Bootcamp 2017@Tokyo - connpass

ということで、ざっくり(長々?)個人的感想を垂れ流しておきます。

Azureって何よ2017年の最新情報をゆるまとめ

JAZUG女子部の安東 沙織さん・鈴木 可奈さんのセッション。

www.slideshare.net

Azureの概要、そして、この1年のAzureアップデートのまとめを、お二人の軽快な掛け合いトークで説明していただきました。
各技術に深入りするわけではなく、でも実際に日々Azureを使っている中でのポイント的なことであったり、不満点だったりについてお話していただきました。
あと、従来、Azure全体の説明を行う場合、「IaaSの説明をから入り、PaaSの説明に移る」ことが多いですが、「(お二人曰く)そろそろあえてPaaSを先に持ってきた」という点が「うん!いいね!」って思いました。
本スライドで、1年間をササーっと振り返り、箇条書き以上の内容は「ブチザッキ | いわゆる雑記」で、さらにここの技術を深掘る場合は公式Docや公式Blogへ、というところでしょうか。

Azure 2017年3月障害Deep Dive

harutamaさんのセッション。

docs.com

このセッション、事前に想像していたのと違っていて面白かったです。
2017年3月の障害に対するMSの報告文書「Root Cause Analysis」に記された「ストレージサービスのStream Manager」なるものの正体(実体)についてのDeepDiveでした。

ストレージサービスについては以下のようなことは、多くのAzure利用者が把握していること。

  • blobとかtableとかqueueとかfileとかの種類があるよね
  • LRSとかZRSとかGRSとかRA-GRSとかのレプリケーションオプションで耐障害性レベルが上がるよね
  • Premium Storage使うとVirtualMachine1台構成でも99.9%のSLAが付くよね

でも本セッションは、その先のストレージの内部構造についてのお話でした。 この内部構造は、Build 2016におけるセッションスライドとかMicrosoft Researchの論文とかをよく読むと紐解ける情報とのこと(確かそうだったよね・・・記憶あいまい気味)。
この手の話は実は大好きなので楽しかったです。
で、Azureは新機能連発なので最新ドキュメントにばかり目が行ってしまうのですが、過去の論文もしっかり読んでおくべきだと思いました。

bitFlyerを支える仕組み - Azure障害の中1時間未満でサービス復旧を実現-

竹井 悠人さん・萬藤 和久さんのセッション。

www.slideshare.net

ブロックチェーン・ビットコインのシステムを開発・運用しているbitFlyerさんのAzure障害との闘い(?)のご紹介でした。
時系列でAzure障害によるシステムの停止から別リージョンでの復旧の試み、3度の大規模障害から得られたノウハウ(障害対応中に問い合わせたマイクロソフトのサポートからも得られなかったノウハウ達)について説明していただきました。
ここら辺の話を聞きつつ、典型的エンプラの「ノウハウが蓄積されていない事には踏み入れない」精神だとAzureはないんだろうなぁ、とか思いました。私個人的には立ち向かい、それに見合うだけ得られるものを得ていくエンジニア人生を歩みたいです。

サポートエンジニアが紹介する、Azure Portal/CLI 使いこなし

田中孝桂さんのセッション。

docs.com

最近、個人的にも Azure CLI v2 をちょろちょろと使っていたので、でも、その時必要なコマンドのみで、Azure AutomationでVMの自動停止等々 細かなコマンドを知らなかったので、いろいろ勉強になりました。

Azure ML系 「practice over theory」

梅崎 猛さんのセッション。

docs.com

AzureML / Cognitive Service / Bot Framework with QnA / CNTK についてザザーと「こんなすごいのが、こんなに簡単に使えるよー」というビデオデモをたくさん流していただきました。
個人的には、Cognitive Serviceのいくつかは、数日前にGAしていたので、ちょうどお試し利用していたところでした。
AzureMLについては実は使ったことがなかったので、へー、こんな感じで使えるんだあ、と思いました。
Bot Framework with QnAなんかは、私が勤めている会社は自社サービスを主軸として製品サポート部門なんかもあるので、そこらへんにBotや問い合わせ文自動解析のCognitive Serviceがうまく使えればサポートコスト削減にならないだろうか、とぼんやり考えました(実際に実現するのはすんごい大変で、おそらく現段階では会社としては取り組まないだろうし、現実的ではないと思っています)。

DocumentDB DeepDive

近江 武一さんのセッション。

www.slideshare.net

私は最近、DocumentDBに はまって いて、公式ドキュメントを結構読み漁ったので、うんうんって思いながら聞くことができました。
また、以下のことは知らなかったので興味深かったです。

  • 「1KBデータだったら1RU消費」と思っていたのが、その物自体だけでないコストが発生して 2RU くらい消費する 。
  • RUはパーティションごとに等分されて割り当てられる。データが特定パーティションに偏ると、設定RUが無駄に死んだ状態になる。
  • パーティションが内部的には25個に分割されているみたい?(検証中?)

セッション内で、MSの松崎剛さんのブログが紹介されていましたが、このブログ、私は見逃していたのですが、内容的に非常に興味深い有益なものでした。

blogs.msdn.microsoft.com

松崎さんは実は弊社の ISVパートナー担当エバンジェリスト(?)(正式な立ち位置名称が分からないのですが・・・) をしていただいておりまして、いろいろ相談可能・・・なのですが・・・ここら辺のAzure周りのテクノロジーに対する取り組みは、私個人の活動であり、会社としてはあまりAzureには力を入れていないので、つまりアポできませんねぇ・・・

オルターブースさんのAzure事例

松村 優大さん、森田 邦裕さんのセッション。

docs.com

オリジナルソース(ドレッシング)をオーダーできるWebサイトを自社開発・運営されている株式会社オルターブースさんの事例紹介でした。
私は、このセッションを聞いてオルターブースさんのファンになりました。

技術に対して 挑戦的で熱い会社 であると感じたからです。

RCの段階で.NET Core採用しちゃったり、使ってみたい技術で行こうよ!って精神だったり(結果、今時の技術全部乗せ的な)、AzureとAWSの併用活用だったり、Slackへの障害通知、Fulentd→elastic search→kibanaな可視化ロギングだったり、Azure Container / Docker swarmで運用してたり、社員旅行と称してみんなで DE:CODE 2016 行くとか(半分冗談?半分ほんと?)。
私もソース、オーダーさせていただきます!

最後に

あー、あと、お声がけしたい方が何人かいたのですが、タイミングを逃し、そしてコミュ障パワーを発揮しお声がけできませんでした・・・
技術的に聞いてみたいこともあるので、また機会があったらお声がけさせていただきます!
この系統のイベントには今後も引き続き参加させていただきたいと思いますので、皆様よろしくお願いいたします。

DocumentDBのgeospatial機能で地理的検索(位置情報検索)を行う

結構以前からあったものだと思うのですが、Azure DocumentDBの「geospatial(地理空間検索)」機能を使ってみたいと思います。
緯度・経度情報をDocumentDBに保存しておき、以下のような操作を行うことが可能です。

  1. 2点間の距離の算出
  2. 1つの地理的領域が、別のもう1つの領域内に存在するかのチェック
  3. 2つの地理的領域の重なりのチェック

ここでは「地理的領域」という言葉を使用しましたが、DocumentDBでは緯度・経度情報を「点」「ライン」「ポリゴン(領域)」で保持することができます。
これらの緯度・経度情報に対してクエリーをかけることができます。

こんなアプリを作る

本投稿では、上記1番目の「2点間の距離の算出」を利用して以下のようなWebアプリを作成します。

  • 災害時避難施設情報をDocumentDBに保存しておく
  • Webアプリを作成。JavaScriptで現在位置情報を自動取得 → 近隣の避難施設を検索する
  • 避難施設の場所はGooogle Mapsへのリンクを配置することで表示する

画面キャプチャは以下の通りです。

f:id:daigo-knowlbo:20170418235759p:plain

「避難施設を検索」をクリックすると・・・

f:id:daigo-knowlbo:20170418235920p:plain

「地図を表示」をクリックすると・・・Google Mapsが別ウィンドウ表示される。

f:id:daigo-knowlbo:20170418235933p:plain

ソースは以下の github に上げてあります。

github.com

実装の構成は「1ソリューション・3プロジェクト構成」です。
プロジェクト構成は以下の通り。

・CreateEvacuationFacilityDb - DocumentDBデータを作成するコンソールアプリケーションプロジェクト
・EvacuationFacilityLib - DocumentDB操作を行う為のリポジトリクラスとモデルクラスを含むライブラリプロジェクト
・EvacuationFacilityWeb - 災害時避難施設を検索するWebアプリケーションプロジェクト

では以下に、ソースの解説を含めて作成手順を記述いたします。

1. DocumentDBデータを準備する

まず、データソースとなるDocumentDBデータを作成します。

DocumentDB Emulatorを起動

Azure上にDocumentDBを作成してもよいのですが、今回はローカルでお手軽にテスト可能な DocumentDB Emulator を利用します。
DocumentDB Emulatorを起動しておきましょう。タスクトレイにブルーのアイコンが現れますね。

災害時避難施設情報XMLを取得する

災害時避難施設情報は 国土数値情報ダウンロードサービス国土数値情報 避難施設データの詳細 からダウンロードすることができるのでこれを利用します。
平成24年のデータで古い気もしますが、全国の災害時避難施設の名称・住所・緯度経度情報 等々 が得られます。
今回は、データが大きいので、東京 のデータだけを取得しました。
ダウンロードファイルを解凍すると以下のようなイメージです。

f:id:daigo-knowlbo:20170418203917p:plain

この中から「P20-12_13.xml」を利用します。

避難施設情報XMLからDocumentDB保存用データ型に変換

XMLデータをDocumentoDBデータ保存用の型に変換する処理を用意します。
ダウンロードした P20-12_13.xml は以下のようなスキーマです。

// P20-12_13.xml
<?xml version="1.0" encoding="utf-8"?>
<ksj:Dataset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:gml="http://www.opengis.net/gml/3.2" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ksj="http://nlftp.mlit.go.jp/ksj/schemas/ksj-app" gml:id="P20Dataset" xsi:schemaLocation="http://nlftp.mlit.go.jp/ksj/schemas/ksj-app KsjAppSchema-P20-v1_0.xsd">
  <gml:description>国土数値情報 避難施設 インスタンス文書</gml:description>
  <gml:boundedBy>
    <gml:EnvelopeWithTimePeriod srsName="JGD2000 / (B, L)" frame="GC / JST">
      <gml:lowerCorner>20.0 123.0</gml:lowerCorner>
      <gml:upperCorner>46.0 154.0</gml:upperCorner>
      <gml:beginPosition calendarEraName="西暦">1900</gml:beginPosition>
      <gml:endPosition indeterminatePosition="unknown"/>
    </gml:EnvelopeWithTimePeriod>
  </gml:boundedBy>
  <!--図形-->
  <gml:Point gml:id="pt100001">
    <gml:pos>35.686898 139.739538</gml:pos>
  </gml:Point>
  <gml:Point gml:id="pt100002">
    <gml:pos>35.697347 139.760433</gml:pos>
  </gml:Point>
  ... 省略 ...
  <ksj:EvacuationFacilities gml:id="P20_100001">
    <ksj:position xlink:href="#pt100001"/>
    <ksj:administrativeAreaCode codeSpace="AdministrativeAreaCode.xml">13101</ksj:administrativeAreaCode>
    <ksj:name>いきいきプラザ一番町</ksj:name>
    <ksj:address>東京都千代田区一番町12-2</ksj:address>
    <ksj:facilityType>指定避難所、二次避難所</ksj:facilityType>
    <ksj:seatingCapacity>-1</ksj:seatingCapacity>
    <ksj:facilityScale>-1</ksj:facilityScale>
    <ksj:hazardClassification>
      <ksj:Classification>
        <ksj:earthquakeHazard>false</ksj:earthquakeHazard>
        <ksj:tsunamiHazard>false</ksj:tsunamiHazard>
        <ksj:windAndFloodDamage>false</ksj:windAndFloodDamage>
        <ksj:volcanicHazard>false</ksj:volcanicHazard>
        <ksj:other>false</ksj:other>
        <ksj:notSpecified>true</ksj:notSpecified>
      </ksj:Classification>
    </ksj:hazardClassification>
  </ksj:EvacuationFacilities>
  <ksj:EvacuationFacilities gml:id="P20_100002">
    <ksj:position xlink:href="#pt100002"/>
    <ksj:administrativeAreaCode codeSpace="AdministrativeAreaCode.xml">13101</ksj:administrativeAreaCode>
    <ksj:name>お茶の水小学校</ksj:name>
    <ksj:address>東京都千代田区猿楽町1-1-1</ksj:address>
    <ksj:facilityType>指定避難所、地区救援センター</ksj:facilityType>
    <ksj:seatingCapacity>-1</ksj:seatingCapacity>
    <ksj:facilityScale>-1</ksj:facilityScale>
    <ksj:hazardClassification>
      <ksj:Classification>
        <ksj:earthquakeHazard>false</ksj:earthquakeHazard>
        <ksj:tsunamiHazard>false</ksj:tsunamiHazard>
        <ksj:windAndFloodDamage>false</ksj:windAndFloodDamage>
        <ksj:volcanicHazard>false</ksj:volcanicHazard>
        <ksj:other>false</ksj:other>
        <ksj:notSpecified>true</ksj:notSpecified>
      </ksj:Classification>
    </ksj:hazardClassification>
  </ksj:EvacuationFacilities>
  ... 省略 ...
</ksj:Dataset>

上記XMLデータを元に、以下のようなJSONスキーマデータに変換してDocumentDBに保存したいと思います。

{
  "ID": "pt100001",
  "Latitude": 35.686898,
  "Longitude": 139.739538,
  "Location": {
    "type": "Point",
    "coordinates": [
      139.739538,
      35.686898
    ]
  },
  "Name": "いきいきプラザ一番町",
  "Address": "東京都千代田区一番町12-2",
  "FacilityType": "指定避難所、二次避難所"
}

ということで、上記JSONスキーマに該当するモデルクラスとして、以下の EvacuationFacilityInfo クラスを用意します。
「public Point Location」プロパティがポイントで、Point型は「Microsoft.Azure.Documents.Spatial名前空間の型」になります。
この値が地理的データクエリーの対象となります。

// EvacuationFacilityLib\Models\EvacuationFacilityInfo.cs
using Microsoft.Azure.Documents.Spatial;

namespace EvacuationFacilityLib.Models
{
  public class EvacuationFacilityInfo
  {
    // ID
    public string ID { get; set; }
    // 緯度
    public double Latitude { get; set; }
    // 経度
    public double Longitude { get; set; }
    // DocumentDBクエリー用のPoint型
    public Point Location { get; set; }
    // 施設名
    public string Name { get; set; }
    // 住所
    public string Address { get; set; }
    // 施設タイプ
    public string FacilityType { get; set; }
  }
}

XMLデータを EvacuationFacilityInfo オブジェクト配列にコンバートするロジックを実装します(GmlToCustomFormatConvertorクラス)。
実装はごちゃごちゃしていますが、「XPathでクエリーしてモデルクラスに一生懸命変換する」というごり押し実装なので説明は省きます。

// CreateEvacuationFacilityDb\GmlToCustomFormatConvertor.cs
using System.Collections.Generic;
using System.Xml;
using System.Xml.XPath;
using System.Xml.Linq;
using Microsoft.Azure.Documents.Spatial;
using EvacuationFacilityLib.Models;

namespace CreateEvacuationFacilityDb
{
  public class GmlToCustomFormatConvertor
  {
    public static List<EvacuationFacilityInfo> Load(string xmlFilePath)
    {
      var result = new List<EvacuationFacilityInfo>();

      var xDoc = XDocument.Load(xmlFilePath);

      var nsmgr = new XmlNamespaceManager(new NameTable());
      nsmgr.AddNamespace("gml", "http://www.opengis.net/gml/3.2");
      nsmgr.AddNamespace("ksj", "http://nlftp.mlit.go.jp/ksj/schemas/ksj-app");
      nsmgr.AddNamespace("xlink", "http://www.w3.org/1999/xlink"); 

      var points = xDoc.XPathSelectElements("//gml:Point", nsmgr);
      foreach (var point in points)
      {
        var evacuationFacilityInfo = new EvacuationFacilityInfo();

        //var id = point.Attribute("gml:id").Value;
        evacuationFacilityInfo.ID = point.FirstAttribute.Value;
        
        //var pos = point.XPathSelectElement("gml:pos");
        string latitudeAndLongitude = point.Value;
        double dLatitude = double.Parse(latitudeAndLongitude.Split(' ')[0]);
        double dLongitude = double.Parse(latitudeAndLongitude.Split(' ')[1]);
        evacuationFacilityInfo.Latitude = dLatitude;
        evacuationFacilityInfo.Longitude = dLongitude;
        evacuationFacilityInfo.Location = new Point(dLongitude, dLatitude);

        var evacuationFacility = 
          xDoc.XPathSelectElement("//ksj:EvacuationFacilities/ksj:position[@xlink:href='#" + evacuationFacilityInfo.ID + "']", nsmgr).Parent;
        evacuationFacilityInfo.Name = 
          evacuationFacility.XPathSelectElement("ksj:name", nsmgr).Value;
        evacuationFacilityInfo.Address = 
          evacuationFacility.XPathSelectElement("ksj:address", nsmgr).Value;
        evacuationFacilityInfo.FacilityType = 
          evacuationFacility.XPathSelectElement("ksj:facilityType", nsmgr).Value;

        result.Add(evacuationFacilityInfo);
      }

      return result;
    }
  }
}

EvacuationFacilityInfoオブジェクトをDocumentDBに保存

XMLをコンバートして得られたEvacuationFacilityInfoオブジェクトをDocumentDBに保存するリポジトリークラスも作成しましょう。
データベースの作成・コレクションの作成・ドキュメントの保存・ドキュメントの地理的検索まで実装してしまいます。

// EvacuationFacilityInfoRepository.cs
#define LOCAL_DB

using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;

using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Client;

using Microsoft.Azure.Documents.Spatial;

using EvacuationFacilityLib.Models;

namespace EvacuationFacilityLib.Repositories
{
  public class EvacuationFacilityInfoRepository
  {
    /// <summary>
    /// 任意のデータベースID
    /// </summary>
    private static readonly string DatabaseId = "EvacuationFacilityInfoDb";

    /// <summary>
    /// 任意のコレクションID
    /// </summary>
    private static readonly string CollectionId = "EvacuationFacilityInfoCollection";

    /// <summary>
    /// エンドポイント
    /// </summary>
#if LOCAL_DB
    private static readonly string EndPoint = "https://localhost:8081/";
#else
    private static readonly string EndPoint = "[your endpoint]";
#endif

    /// <summary>
    /// 認証キー(固定)
    /// </summary>
#if LOCAL_DB
      private static readonly string AuthKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
#else
    private static readonly string AuthKey = "[your auth key]";
#endif

    private static DocumentClient client;


    /// <summary>
    /// EvacuationFacilityInfoオブジェクトを保存します。
    /// </summary>
    public static void SaveEvacuationFacilityInfos(List<EvacuationFacilityInfo> evacuationFacilityInfos)
    {
      foreach (var info in evacuationFacilityInfos)
      {
        client.UpsertDocumentAsync(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId), info);
      }
    }

    /// <summary>
    /// 緯度経度に対する距離範囲に存在するデータ(避難施設)を取得します。
    /// </summary>
    public static List<EvacuationFacilityInfo> Search(double longitude, double latitude, int distance)
    {
      List<EvacuationFacilityInfo> result = null;

      var query = 
        client.CreateDocumentQuery<EvacuationFacilityInfo>(
          UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId),
          new FeedOptions { MaxItemCount = -1 })
          .Where(i => i.Location.Distance(new Point(longitude, latitude)) < distance);

      result = query.ToList();

      return result;
    }

    /// <summary>
    /// データベース・コレクションの初期化を行います。
    /// </summary>
    public static void Initialize(bool initDatabase = false)
    {
      client = new DocumentClient(new Uri(EndPoint), AuthKey, new ConnectionPolicy { EnableEndpointDiscovery = false });
      if (initDatabase)
      {
        CreateDatabaseIfNotExistsAsync().Wait();
        CreateCollectionIfNotExistsAsync().Wait();
      }
    }

    /// <summary>
    /// 存在しなければデータベースを作成します。
    /// </summary>
    /// <returns></returns>
    private static async Task CreateDatabaseIfNotExistsAsync()
    {
      try
      {
        await client.ReadDatabaseAsync(UriFactory.CreateDatabaseUri(DatabaseId));
      }
      catch (DocumentClientException e)
      {
        if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
          await client.CreateDatabaseAsync(new Database { Id = DatabaseId });
        }
        else
        {
          throw;
        }
      }
      catch
      {
        throw;
      }
    }

    /// <summary>
    /// 存在しなければコレクションを作成します。
    /// </summary>
    /// <returns></returns>
    private static async Task CreateCollectionIfNotExistsAsync()
    {
      try
      {
        await client.ReadDocumentCollectionAsync(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId));
      }
      catch (DocumentClientException e)
      {
        if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
          // DataType.Point に対して SpatialIndex 付加してコレクションを作成
          await client.CreateDocumentCollectionAsync(
            UriFactory.CreateDatabaseUri(DatabaseId),
            new DocumentCollection { Id = CollectionId, IndexingPolicy = new IndexingPolicy(new SpatialIndex(DataType.Point)) },
            new RequestOptions { OfferThroughput = 1000 });
        }
        else
        {
          throw;
        }
      }
    }
  }
}

DatabaseId / CollectionIdは任意ですが、ここではそれぞれ「EvacuationFacilityInfoDb」「EvacuationFacilityInfoCollection」としました。
EndPointはローカルのDocumentDB Emulatorを使用するため「https://localhost:8081/」となります。AuthKeyもEmulatorである為、固定値となります。
エミュレータではなくAzure DocumentDBを利用する場合には、適切なEndPointとAuthKeyを指定してください(あなたのAzureポータルから確認可能)。
Initialize()メソッドで接続用DocumentClientオブジェクトを作成します。引数initDatabaseがtrueの場合は、データベース・コレクションの作成も行います。
CreateCollectionIfNotExistsAsync()メソッドではコレクションを作成します。ここでポイントがあります。地理的クエリーを行う為に、インデックスポリシーとしてSpatialIndexを指定してます。
SaveEvacuationFacilityInfos()メソッドは、EvacuationFacilityInfoオブジェクト配列をDocumentDBに保存します。
Search()メソッドは、データの中から、引数で指定した緯度経度に対する距離範囲内の位置にある避難施設を検索します。
以下の様に距離範囲のクエリー条件を記述することができます。

.Where(i => i.Location.Distance(new Point(longitude, latitude)) < distance);

Microsoft.Azure.Documents.Spatial.Pointクラスのコンストラクタ引数は、(緯度, 経度)ではなく (経度, 緯度) なのでご注意を。

コンバート及びDocumentDB作成処理の呼び出し

上記で用意した各クラス・メソッドを呼び出して「XMLデータからEvacuationFacilityInfoオブジェクトに変換、さらにDocumentDBに保存」までを行うコンソールアプリ「CreateEvacuationFacilityDb」のMain()メソッドを実装します。

// CreateEvacuationFacilityDb\Program.cs
using System.Collections.Generic;

using EvacuationFacilityLib.Models;
using EvacuationFacilityLib.Repositories;

namespace CreateEvacuationFacilityDb
{
  class Program
  {
    static void Main(string[] args)
    {
      // DocumentDB初期化
      EvacuationFacilityInfoRepository.Initialize(true);
      
      // XMLをロードしてカスタムクラスコレクションに変換
      // xmlのパスは書き換えてください!
      List<EvacuationFacilityInfo> evacuationFacilityInfoList = 
        GmlToCustomFormatConvertor.Load(@"J:\Projects\Github\research\DocumentDB\GIS\P20-12_13.xml");

      // カスタムクラスコレクションをDocumentDBに保存
      EvacuationFacilityInfoRepository.SaveEvacuationFacilityInfos(evacuationFacilityInfoList);
    }

  }
}

読み込むXMLファイルのパスは皆さんの環境に合わせて書き換えてください。
CreateEvacuationFacilityDbコンソールアプリを実行すると、DocumentDBにデータが作成・格納されます。

f:id:daigo-knowlbo:20170419005131p:plain

2. Webアプリを作成する

EvacuationFacilityWebアプリを作成します。

DocumentDB接続を初期化

まずGlobal.asax.csのStartで「EvacuationFacilityInfoRepository.Initialize()」を呼び出し、DocumentClientオブジェクトを初期化します。

// EvacuationFacilityWeb\Global.asax.cs
public class MvcApplication : System.Web.HttpApplication
{
  protected void Application_Start()
  {
    // DocumentDB初期化
    EvacuationFacilityInfoRepository.Initialize();

    AreaRegistration.RegisterAllAreas();
    FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
    RouteConfig.RegisterRoutes(RouteTable.Routes);
    BundleConfig.RegisterBundles(BundleTable.Bundles);
  }

コントローラークラスを実装

コントローラクラスを実装します。
検索画面表示の為のHTTP GET用Index()メソッド、「避難施設を検索」クリック時のHTTP POST用Index()メソッドを実装します。
POST時には検索結果をViewBagにしまって resultビュー を表示します。

// EvacuationFacilityWeb\Controllers\HomeController.cs
using System.Web.Mvc;

using EvacuationFacilityLib.Repositories;

namespace EvacuationFacilityWeb.Controllers
{
  public class HomeController : Controller
  {
    public ActionResult Index()
    {
      return View();
    }

    [HttpPost]
    public ActionResult Index(double latitude, double longitude, int distance)
    {
      ViewBag.EvacuationFacilities = EvacuationFacilityInfoRepository.Search(longitude, latitude, distance);

      return View("result");
    }
  }
}

ビューを実装

Index及びResultのビュー定義は以下の通りです。
実行時の緯度経度は navigator.geolocation.getCurrentPosition() で取得します。

// EvacuationFacilityWeb\Views\Home\Index.cshtml
<script type="text/javascript">
  if ("geolocation" in navigator) {
    navigator.geolocation.getCurrentPosition(function (position) {
      // POST用
      $('#latitude').val(position.coords.latitude);
      $('#longitude').val(position.coords.longitude);
      // 表示用
      $('#spanLatitude').html(position.coords.latitude);
      $('#spanLongitude').html(position.coords.longitude);

      $('#currentLocationGmapUrl').attr('href', 'https://www.google.co.jp/maps/place/' + position.coords.latitude + '+' + position.coords.longitude);
    },
    function (err) {
      alert(err.code + ': ' + err.message);
    });
  } else {
    alert('geolocation is not supported.');
}

</script>

<style>
.row {
  padding-top: 5px;
  padding-bottom: 5px;
}
</style>

@using (Html.BeginForm())
{
<div class="row">
  <div class="col-xs-2"></div>
  <div class="col-xs-8">
    近隣の避難施設を検索します。
  </div>
  <div class="col-xs-2"></div>
</div>
<div class="row">
  <div class="col-xs-5 text-right">
    現在地:
  </div>
  <div class="col-xs-7">
    緯度<span id="spanLatitude"></span> 経度:<span id="spanLongitude"></span>
    <br />
    <a id="currentLocationGmapUrl" target="_blank">地図を表示</a>
  </div>
</div>
<div class="row">
  <div class="col-xs-5 text-right">
    距離範囲:
  </div>
  <div class="col-xs-7">
      <select id="distance" name="distance">
        <option value="500">0.5 km 範囲内</option>
        <option value="1000">1 km 範囲内</option>
        <option value="3000">3 km 範囲内</option>
        <option value="5000">5 km 範囲内</option>
      </select>
  </div>
</div>
<div class="row">
  <div class="col-xs-5 text-right">

  </div>
  <div class="col-xs-7">
    <input type="hidden" id="latitude" name="latitude" />
    <input type="hidden" id="longitude" name="longitude" />
    <input type="submit" value="避難施設を検索" class="btn btn-default" />
  </div>
</div>
}
<hr />
「navigator.geolocation.getCurrentPosition()」で現在位置を取得するので、LANだとIPが降られた場所で遠くになるかも・・・

以下は resultビュー です。
検索結果はHomeControllerにより ViewBag.EvacuationFacilities にEvacuationFacilityInfo配列として格納されています。
それらのデータを一覧レンダリングします。 Google Mapsへのリンクはソースにあるように緯度・経度 をクエリーパラメータとすることで実装します。

// EvacuationFacilityWeb\Views\Home\result.cshtml
<h2>検索結果</h2>

<a href="./">戻る</a>

@if (this.ViewBag.EvacuationFacilities != null)
{
  <p>@this.ViewBag.EvacuationFacilities.Count 件</p>
  foreach (EvacuationFacilityLib.Models.EvacuationFacilityInfo facility in this.ViewBag.EvacuationFacilities)
  {
    <p>名称:@facility.Name</p>
    <p>住所:@facility.Address</p>
    <p>施設タイプ:@facility.FacilityType</p>
    <p><a target="_blank" href='@System.String.Format("https://www.google.co.jp/maps/place/{0}+{1}", facility.Latitude, facility.Longitude)'>地図を表示</a></p>
    <hr />
    
  }
}

まとめ

緯度・軽度からのクエリーというのは、なかなか色々なところで使えそうですね。
特にスマホアプリ系では利用用途が多そうに思います。スマホネイティブ(Xamarin Forms含め)なら位置情報もより正確に取得出来、マップを含めたアプリ連携がよりスムーズです。
さらに技術を進めると単純な位置関係だけでなく、道路を考慮した実移動距離となってきますが、Azureに限らずどんどん進化していくような気がしています。

ということで、今回、ソースの解説は結構 雑い かったと思いますが、一式をgithubにあげたので、そちらを見ていただければ幸いです。

Visual Studio for Mac から ASP.NET CoreアプリをAzureにパブリッシュ!

Visual Studio for MacASP.NET CoreアプリをAzure App Service にパブリッシュしてみたいと思います。

では早速。(Visual Studio for MacGUIとウィザードが自動出力するコードのみを使ったノンコーディング操作で行きます。)

1. プロジェクトの作成

Visual Studio for Macを起動し、ASP.NET Core Webアプリケーションを作成します。
メニュー「ファイル → 新しいソリューション」を選択します。
「新しいプロジェクト」ダイアログで「.NET Core → App → ASP.NET Core Web App」を選択して「次へ」をクリックします。

f:id:daigo-knowlbo:20170414014406p:plain

プロジェクト名・ソリューション名を入力して「作成」をクリックします。

f:id:daigo-knowlbo:20170414014430p:plain

カラカラとNugetパッケージの復元が行われるので、完了するまで少し待ちます・・・

2. Azureにパブリッシュ

ソリューションからプロジェクト名を右マウスクリックし「Publish → Publish to Azure」をクリックします。

f:id:daigo-knowlbo:20170414015001p:plain

パブリッシュ済みのAzure App Serviceの一覧がリストされます。
今回は新規に追加したいので左下の「ニュース」をクリックします(ニュース・・・というか 新規 だと思う・・・)。

f:id:daigo-knowlbo:20170414014505p:plain

(そして更に、また謎の日本語のダイアログが表示されます)

f:id:daigo-knowlbo:20170414014516p:plain

Android サービス」・・・実際にはAzure App Serviceが作成されるのでご心配なく・・・
「作成済み」ボタン・・・「作成」と認識してください。
(この辺りは2017/4/14時点なので、そのうち正しい日本語にローカライズされるでしょう・・・)
まあ、Azureではおなじみの アプリ名 や リソースグループ名 価格プラン 等を入力します。
ちなみに今回は「プロジェクト名」は CoreWebApp1 としたのですが、Azureにパブリッシュする「アプリ名」は RdCoreWebApp1 としています。この名称は異なっていてもOKです。

パブリッシュが完了すると、ブラウザで対象アプリが実行されます。

f:id:daigo-knowlbo:20170414020000p:plain

3. Azureポータルで確認

Azureポータルに行き、今回パブリッシュしたアプリ(リソースグループ)を確認したのが以下の画像キャプチャです。

f:id:daigo-knowlbo:20170414015938p:plain

4. まとめ

はい!
サクサクっとVisual Studio for MacからAzureにパブリッシュできました!
Visual Studio for Macが Preview から 正式版 になるのが待ち遠しいです。
そして各種環境の.NET Standard 2.0実装がリリースされれば、更にWindowsMacの敷居が低くなるでしょう^^