2015 年 7 月

Volume 30 Number 7

Azure の詳細 - Event Hubs で分析と表示を実現する (第 3 部)

Bruno Terkaly | 2015 年 7 月

この連載にお付き合いありがとうございます。最終回の今回は、これまでの作業最大の目標をいよいよ実現します。つまり、屋外に設置した Raspberry Pi デバイスが生成するデータを表示します。今回は、モノのインターネット (IoT) のシナリオに関する 3 部構成の連載の最終回です。前回までの内容については、4 月号 (msdn.microsoft.com/magazine/dn948106) と 6 月号 (msdn.microsoft.com/magazine/mt147243) をご確認ください。

今月の大きな目標は、Web サイトとして動作する Node.js アプリケーションを作成し、モバイル デバイスにデータを提供して、降雨データを棒グラフとして表示するという、極めてシンプルなものです。モバイル デバイスから直接データ ストアにアクセスするのは賢明ではないため、Node.js Web サーバーを用意します。

この機能は、ASP.NET Web API や Microsoft Azure API Apps などのテクノロジで実現できます。アーキテクトの多くは、モバイル デバイス自体ではなく、サーバーの中間層でデータのフィルター処理、並び替え、および解析を行うことを推奨しています。モバイル デバイスは処理能力が低く、ネットワーク経由でデバイスに大量の情報を送信するのが好ましくないためです。要するに、複雑な処理はサーバーで行い、デバイスには表示を担当させます。

作業を始める

今月のサンプルを実行するには、4 月号と 6 月号で説明した作業を完了している必要があります。完了していない場合は、独自の DocumentDB データ ストアを手動で構築してもかまいません。

事前に DocumentDB データ ストアを作成しているものとして、まずは Azure ポータルに移動し、DocumentDB データベースを表示します。ポータルには、データの表示や、さまざまなクエリを実行できる便利なツールがあります。データが存在することを検証しその基本構造を把握したら、Node.js Web サーバーの構築を開始することができます。

開発者は一般的に、ソフトウェアのビルド時にソフトウェアを検証します。手始めに Mocha や Jasmine などのツールを使用して単体テストを行う開発者もいます。モバイル クライアントをビルド前には、たとえば、Node.js Web サーバーが想定どおりに動作することを確認しておくのが妥当でしょう。Fiddler などの Web プロキシ ツールを使用するのもアプローチの 1 つです。Web プロキシ ツールでは、簡単に Web 要求 (HTTP GET など) を発行して、応答をネイティブ JSON 形式で表示できます。このようにすると便利な理由は、モバイル クライアントの作成時に問題が発生しても、Web サービスに関連する問題ではなく、モバイル クライアントに関連する問題であることに確信が持てるようになるためです。

iOS や Android デバイスが普及していますが、説明を簡単にするために、ここでは Windows Phone デバイスをクライアントとして使用します。おそらく、最適なアプローチは Web ベースのテクノロジになります。Web ベースのテクノロジであれば、モバイル デバイスに限らず、どのようなデバイスからでもデータを使用や表示が可能です。Xamarin などのネイティブ クロスプラットフォーム製品を使用するのも 1 つのアプローチです。

Web ベース モバイル クライアントのビルドに利用できる情報源はいくつかあります。W3C 標準化団体がその最たるものです。詳細については、標準化ドキュメント (bit.ly/1PQ6uOt、英語) を参照してください。Apache Software Foundation もこの分野に関して多くの取り組みを行っています。詳細については、cordova.apache.org (英語) を参照してください。ここでは、コードが単純で、デバッグと説明が簡単な Windows Phone に注目します。

ポータルにおける DocumentDB

ここからは、以前の作業で作成した都市と気温のデータ用の DocumentDB データ ストアを土台に進めていきます。DocumentDB データベース (TemperatureDB) を表示するには、メニュー項目の [参照] をクリックして、[DocumentDB Accounts] (DocumentDB アカウント) をクリックします。また、データベースを右クリックして、メイン ページにタイルとしてピン留めすると、メイン Azure ポータル ページをダッシュボードにすることができます。DocumentDB データ ストアを操作するためのポータル ツールの使用方法については、bit.ly/112L4X1 (英語) にわかりやすくまとめられています。

新しいポータルの非常に便利な機能の 1 つは、クエリを実行して DocumentDB の実データを表示できる点です。これにより、都市と気温データをネイティブ JSON 形式で確認できます。その結果、Node.js Web サーバーを簡単に変更したり、必要なクエリを構築する作業が大幅に簡素化されます。

Node.js Web サーバーのビルド

Node.js Web サーバーの作成時に必要な最初の作業の 1 つは、DocumentDB データ ストアへの接続です。必要な接続文字列は Azure ポータルで確認できます。ポータルから接続文字列を取得するには、TemperatureDB DocumentDB ストアを選択して、[All Settings] (すべての設定)、[Keys] (キー) の順にクリックします。

ここで、2 つの情報が必要になります。1 つは、DocumentDB データ ストアの URI、もう 1 つは、Node.js Web サーバーからの安全なアクセスを強制するための情報セキュリティ キー (ポータルでは主キー) です。接続とデータベースの情報は次のコードのとおりです。

{
  "HOST"       : "https://temperaturedb.documents.azure.com:443/",
  "AUTH_KEY"   : "secret key from the portal",
  "DATABASE"   : "TemperatureDB",
  "COLLECTION" : "CityTempCollection"
}

Node.js Web サーバーは構成情報を読み取ります。すべての URI はグローバルに一意になるため、ホスト フィールドは構成ファイルごとに異なります。認証キーも一意です。

以前、気温データベースに TemperatureDB という名前をつけました。各データベースは複数のコレクションを保持できますが、今回は CityTemperature というコレクション 1 つだけにします。コレクションは、ドキュメントのリストにすぎません。このデータ モデルでは、都市とその 12 か月の気温データで 1 つのドキュメントが構成されます。

Node.js Web サーバーのコードの詳細部分には、Node.js 用のアドオン ライブラリの広範なエコシステムを活用できます。このプロジェクトでは、2 つのライブラリ (Node パッケージ) を使用します。1 つ目のパッケージは、DocumentDB 機能に対応します。DocumentDB パッケージをインストールするコマンドは、「npm install documentdb」です。2 つ目のパッケージは構成ファイルの読み取りに対応します。コマンドは「npm install nconf」です。これらのパッケージにより、Node.js の既定のインストールには存在しない追加機能を利用できます。DocumentDB を使用した Node.js アプリケーション作成に関する広範なチュートリアルについては、Azure ドキュメント (azure.microsoft.com/ja-jp/documentation/articles/documentdb-nodejs-application/) を参照してください。

Node.js Web サーバーには、7 つのセクションがあります (図 1 参照)。Section 1 は、インストールされている一部のパッケージへの接続を定義し、コードの後半部分でアクセスできるようにします。Section 1 では、モバイル クライアントが接続する既定のポートも定義しています。Azure にデプロイすると、ポート番号は Azure で管理されるため、process.env.port の構成になっています。

図 1 Node.js Web サーバーのビルド

// +-----------------------------+
// |        Section 1            |
// +-----------------------------+
var http = require('http');
var port = process.env.port || 1337;
var DocumentDBClient = require('documentdb').DocumentClient;
var nconf = require('nconf');
// +-----------------------------+
// |        Section 2            |
// +-----------------------------+
// Tell nconf which config file to use
nconf.env();
nconf.file({ file: 'config.json' });
// Read the configuration data
var host = nconf.get("HOST");
var authKey = nconf.get("AUTH_KEY");
var databaseId = nconf.get("DATABASE");
var collectionId = nconf.get("COLLECTION");
// +-----------------------------+
// |        Section 3            |
// +-----------------------------+
var client = new DocumentDBClient(host, { masterKey: authKey });
// +-----------------------------+
// |        Section 4            |
// +-----------------------------+
http.createServer(function (req, res) {
  // Before you can query for Items in the document store, you need to ensure you
  // have a database with a collection, then use the collection
  // to read the documents.
  readOrCreateDatabase(function (database) {
    readOrCreateCollection(database, function (collection) {
      // Perform a query to retrieve data and display
      listItems(collection, function (items) {
        var userString = JSON.stringify(items);
        var headers = {
          'Content-Type': 'application/json',
          'Content-Length': userString.length
        };
        res.write(userString);
        res.end();
      });
    });
  });
}).listen(8124,'localhost');  // 8124 seemed to be the
                              // port number that worked
                              // from my development machine.
// +-----------------------------+
// |        Section 5            |
// +-----------------------------+
// If the database does not exist, then create it, or return the database object.
// Use queryDatabases to check if a database with this name already exists. If you
// can't find one, then go ahead and use createDatabase to create a new database
// with the supplied identifier (from the configuration file) on the endpoint
// specified (also from the configuration file).
var readOrCreateDatabase = function (callback) {
  client.queryDatabases('SELECT * FROM root r WHERE r.id="' + databaseId +
    '"').toArray(function (err, results) {
    console.log('readOrCreateDatabase');
    if (err) {
      // Some error occured, rethrow up
      throw (err);
    }
    if (!err && results.length === 0) {
      // No error occured, but there were no results returned,
      // indicating no database exists matching the query.           
      client.createDatabase({ id: databaseId }, function (err, createdDatabase) {
        console.log('client.createDatabase');
        callback(createdDatabase);
      });
    } else {
      // we found a database
      console.log('found a database');
      callback(results[0]);
    }
  });
};
// +-----------------------------+
// |        Section 6            |
// +-----------------------------+
// If the collection does not exist for the database provided, create it,
// or return the collection object. As with readOrCreateDatabase, this method
// first tried to find a collection with the supplied identifier. If one exists,
// it is returned and if one does not exist it is created for you.
var readOrCreateCollection = function (database, callback) {
  client.queryCollections(database._self, 'SELECT * FROM root r WHERE r.id="' +
    collectionId + '"').toArray(function (err, results) {
    console.log('readOrCreateCollection');
    if (err) {
      // Some error occured, rethrow up
      throw (err);
    }
    if (!err && results.length === 0) {
      // No error occured, but there were no results returned, indicating no
      // collection exists in the provided database matching the query.
      client.createCollection(database._self, { id: collectionId },
        function (err, createdCollection) {
        console.log('client.createCollection');
        callback(createdCollection);
      });
    } else {
      // Found a collection
      console.log('found a collection');
      callback(results[0]);
    }
  });
};
// +-----------------------------+
// |        Section 7            |
// +-----------------------------+
// Query the provided collection for all non-complete items.
// Use queryDocuments to look for all documents in the collection that are
// not yet complete, or where completed = false. It uses the DocumentDB query
// grammar, which is based on ANSI - SQL to demonstrate this familiar, yet
// powerful querying capability.
var listItems = function (collection, callback) {
  client.queryDocuments(collection._self, 'SELECT c.City,
    c.Temperatures FROM c where c.id="WACO- TX"').toArray(function (err, docs) {
    console.log('called listItems');
    if (err) {
      throw (err);
    }
    callback(docs);
  });
}

Section 2 は config.json ファイルを読み取ります。config.json ファイルには、データベースとドキュメント コレクションを含む接続情報が含まれています。常に、接続情報に関連する文字列リテラルを取得して構成ファイルに個別に配置するようにします。

Section 3 は、DocumentDB の操作に使用するクライアント接続オブジェクトです。この接続は DocumentDBClient のコンストラクターに渡されます。

Section 4 は、モバイル クライアントが Node.js Web サーバー アプリケーションに接続すると実行されるコードを示しています。createServer は Node.JS アプリケーションの中核となるプリミティブで、イベント ループや HTTP 要求の処理に関するさまざまな概念を含みます。この構成の詳細については、bit.ly/1FcNq1E (英語) を参照してください。

これは、Node.js Web サーバーに接続するクライアントの高レベルのエントリ ポイントを示しています。また、Node.js ノードの他の部分を呼び出して、DocumentDB から JSON データを取得する役割も担っています。データ取得後、データは JSON ベースのペイロードとしてパッケージ化され、モバイル クライアントに提供されます。createServer 関数のパラメーター (http.createServer(function (req, res)...) として要求オブジェクトと応答オブジェクトが使用されます。

Section 5 から DocumentDB のクエリ処理が始まります。DocumentDB データ ストアは複数のデータベースを含むことができます。Section 5 の目的は、DocumentDB URI のデータを絞り込み、特定のデータベースを指定することです。この場合、そのデータベースは TemperatureDB です。ここには、直接使用しない追加のコードもありますが、参考のために含めています。他に、データベースが存在しない場合にデータベースを作成するコードもあります。Section 5 以降のロジックのほとんどは、先ほどインストールした DocumentDB npm パッケージに基づいています。

Section 6 はデータ取得プロセスの次の手順を示しています。このコードは、Section 5 のコードの結果として自動的に呼び出されます。Section 6 では、さらにデータを絞り込みます。Section 5 で指定したデータベース (Temperature­DB) を使用してドキュメント コレクションをドリルダウンします。CityTemperature コレクションに対する where 句を含む select ステートメントに注目してください。これには、コレクションが存在しない場合にコレクションを作成するコードが含まれています。

Section 7 はデータをモバイル クライアントに返す前に実行される最後のクエリを示しています。わかりやすくするために、テキサス州の Waco という都市の気温データを返すようにクエリをハードコーディングしています。実際のシナリオでは、(ユーザー入力またはデバイスの位置に基づいて) モバイル クライアントから都市が渡されます。Node.js Web サーバーでは渡された都市を解析して、Section 7 の where 句に追加します。

Node.js Web サーバーはこれで完成し、実行の準備が整いました。サーバーは、実行後、モバイル デバイスからのクライアント要求を無期限に待機します。この Node.js Web サーバーを、開発用コンピューターでローカルに実行します。この時点で、Fiddler を使用して Node.js Web サーバーのテストを始めるのがよいでしょう。Fiddler を使用すると、Web サービスに対して HTTP 要求 (この場合は GET) を発行して、応答を確認できます。Fiddler で動作を検証しておくと、モバイル クライアントの作成前に問題を解決するのに役立ちます。

これで、モバイル クライアントの作成準備が整いました。モバイル クライアントには、XAML UI と (プログラミング ロジックが存在する) CS 分離コードの 2 つの基本アーキテクチャ コンポーネントが含まれます。図 2 のコードに、モバイル クライアントのビジュアル インターフェイスのマークアップを示します。

図 2 モバイル クライアント メイン画面棒グラフの XAML マークアップ

<Page
  x:Class="CityTempApp.MainPage"
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:local="using:CityTempApp"
  xmlns:d="https://schemas.microsoft.com/expression/blend/2008"
  xmlns:mc="https://schemas.openxmlformats.org/markup-compatibility/2006"
  mc:Ignorable="d"
  xmlns:charting="using:WinRTXamlToolkit.Controls.DataVisualization.Charting"
  Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
  <Grid>
    <!-- WinRTXamlToolkit chart control -->
    <charting:Chart
      x:Name="BarChart"
      Title=""
      Margin="5,0">
      <charting:BarSeries
        Title="Rain (in)"
        IndependentValueBinding="{Binding Name}"
        DependentValueBinding="{Binding Value}"
        IsSelectionEnabled="True"/>
      </charting:Chart>
  </Grid>
</Page>

WinRTXamlToolkit を組み込んでいるのがわかります。WinRTXamlToolkit は CodePlex (bit.ly/1PQdXwO、英語) で確認できます。このツールキットには、興味深いさまざまなコントロールが含まれています。ここで使用するのは、グラフ コントロールです。データをグラフにするには、名前/値のコレクションを作成して、コントロールにアタッチします。今回の場合、特定の都市における各月の降雨データの名前/値のコレクションを作成します。

最終的なソリューションを示す前に、重要な注意点を説明します。開発者によっては、Web ベース アプローチを採用するためにネイティブ Windows Phone アプリケーションを使用することに異議を唱える人もいます。

今回ビルドするモバイル クライアントは、さまざまな近道をして、必要最低限の機能を実現しています。たとえば、棒グラフは Windows Phone クライアントを実行するとすぐに表示されます。これは、OnNavigatedTo イベントから Node.js Web サーバーへの Web 要求があるからです。要求は、Windows Phone クライアントが起動すると自動的に実行されます。これは、図 3 のモバイル クライアント コードの Section 1 に示すとおりです。

図 3 モバイル クライアント用コード

public sealed partial class MainPage : Page
{
  public MainPage()
  {
    this.InitializeComponent();
    this.NavigationCacheMode = NavigationCacheMode.Required;
  }
  // +-----------------------------+
  // |        Section 1            |
  // +-----------------------------+
  protected override void OnNavigatedTo(NavigationEventArgs e)
  {
    HttpWebRequest request = (HttpWebRequest)WebRequest.Create(
      "http://localhost:8124");
    request.BeginGetResponse(MyCallBack, request);
  }
  // +-----------------------------+
  // |        Section 2            |
  // +-----------------------------+
  async void MyCallBack(IAsyncResult result)
  {
    HttpWebRequest request = result.AsyncState as HttpWebRequest;
    if (request != null)
    {
      try
      {
        WebResponse response = request.EndGetResponse(result);
        Stream stream = response.GetResponseStream();
        StreamReader reader = new StreamReader(stream);
        JsonSerializer serializer = new JsonSerializer();
        // +-----------------------------+
        // |        Section 3            |
        // +-----------------------------+
        // Data structures coming back from Node
        List<CityTemp> cityTemp = (List<CityTemp>)serializer.Deserialize(
          reader, typeof(List<CityTemp>));
        // Data structure suitable for the chart control on the phone
        List<NameValueItem> items = new List<NameValueItem>();
        string[] months = { "Jan", "Feb", "Mar", "Apr", "May", "Jun",
          "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
        for (int i = 11; i >= 0; i-- )
        {
          items.Add(new NameValueItem { Name = months[i], Value =
            cityTemp[0].Temperatures[i] });
        }
        // +-----------------------------+
        // |        Section 4            |
        // +-----------------------------+
        // Provide bar chart the data
        await this.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
        {
          this.BarChart.Title = cityTemp[0].City + ", 2014";
          ((BarSeries)this.BarChart.Series[0]).ItemsSource = items;
        });
      }
      catch (WebException e)
      {
        return;
      }
    }
  }
}
// +-----------------------------+
// |        Section 5            |
// +-----------------------------+
// Data structures coming back from Node
public class CityTemp
{
  public string City { get; set;  }
  public List<double> Temperatures { get; set; }
}
// Data structure suitable for the chart control on the phone
public class NameValueItem
{
  public string Name { get; set; }
  public double Value { get; set; }
}

また、Section 1 では、localhost 上で実行される Node.js Web サーバーへの接続も行っています。もちろん、Azure Web サイトなどのパブリック クラウドで Node.js Web サーバーをホストしている場合は、別のエンドポイントを指定します。Web 要求の発行後、BeginGetResponse を使用してコールバックを設定します。Web 要求は非同期なので、コードではコールバック (MyCallBack) が設定されます。これが Section 2 につながります。Section 2 ではデータの取得、処理、グラフ コントロールへの読み込みを行います。

Section 1 で記述した非同期 Web 要求に対する Section 2 のコールバックは、ペイロードを処理して、Web サービスに送り返します。コードでは Web 応答を処理して、JSON 形式にデータをシリアル化します。Section 3 では、Section 5 で定義する 2 つのデータ構造に JSON データを変換します。目標は、名前/値の配列 (リスト) のリストを作成することです。NameValueItem クラスは、グラフ コントロールに必要な構造です。

Section 4 では、GUI スレッドを使用して、名前/値のリストをグラフ コントロールに割り当てます。項目の割り当ては items ソース コレクションで確認できます。await this.Dispatcher.RunAsync 構文では、GUI スレッドを利用してビジュアル コントロールを更新します。なお、データの処理と Web 要求の作成を行うスレッドと同じスレッドを使用してビジュアル インターフェイスを更新しようとすると、コードは適切に機能しません。

これで、モバイル クライアントを実行できるようになりました。ただし、おそらくいくつかの気温データが不足しているため、棒グラフの一部のコントロールが表示されない可能性があります。

まとめ

データを取り込み、保存して表示するまでの、エンド ツー エンドの IoT シナリオの説明を試みた 3 部構成の連載もこれで終了です。連載の第 1 部では、Ubuntu で実行する C プログラムを使用して Raspberry Pi デバイスで実行するコードをシミュレーションし、データを取り込みました。気温センサーからキャプチャしたデータは Azure Event Hubs に挿入できます。ただし、ここで保存されるデータは一時的なものであるため、データを永続的なストアに移行する必要があります。

永続的なストアについては第 2 部で取り上げました。バックグラウンド プロセスで Event Hubs からデータを取得し、Azure SQL Database と DocumentDB に移行しました。今回の第 3 部では、Node.js を実行する中間層を使用して、この永続的なストアに保存されたデータをモバイル デバイスに公開しました。

作成したアプリケーションには、機能の追加や強化の余地が数多くあります。たとえば、検討する分野としては、機会学習や分析があるでしょう。棒グラフの表示によって、「何が起こったか」という基本的な疑問の答えがわかります。では、「何が起こるか」という 1 歩進んだ疑問はどうでしょうか。つまり、将来の雨を予測できるでしょうか。最終的には「将来の予測に基づいて、今日は何をすればよいだろうか」という疑問の解決を目指すことになるでしょう。


Bruno Terkaly は、デバイスに依存しない、業界をリードするアプリケーションやサービスを開発できるようにすることを目標にするマイクロソフトのプリンシパル ソフトウェア エンジニアです。テクノロジが実現可能かどうかという視点を通り越して、米国で最高のクラウド商談やモバイル商談を進めることを担当しています。ISV が評価、開発、配置を行う際に、アーキテクチャに関するガイダンス提供したり、技術的な細かいサポート作業を行うことによって、パートナーがアプリケーションを市場に投入できるようにサポートしています。また、フィードバックを提供したり、ロードマップに影響を与えて、クラウドやモバイルのエンジニアリング グループと密接に連携することも行っています。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの David Magnotti、Leland Holmquest、および Nick Trogh に心より感謝いたします。
David Magnotti (マイクロソフト)、Leland Holmquest (マイクロソフト)、Nick Trogh (マイクロソフト)

David Magnotti は、ソフトウェア開発を専門とするマイクロソフトのプレミア フィールド エンジニアです。主に、概念実証の構築、アドバイザリ サービスの実施、ワークショップの提供などを通じて、マイクロソフトの企業顧客をサポートしています。よく、クラッシュ ダンプのデバッグや、Windows パフォーマンス トレースの分析をしています。

Leland Holmquest は、Microsoft サービスのナレッジ マネージメント プラットフォームのソリューション マネージャーです。MSDN の長年のファンであり、実行家のサポーターです。

Nick Trogh は、マイクロソフトのテクニカル エバンジェリストであり、開発者がマイクロソフト プラットフォームでソフトウェアの構想を実現できるようサポートしています。ブログ、Twitter、開発者向けイベントの講演などの活動もしています。