Category Archives: Microsoft Azure

Semantic Logging Application Block で ETW ログを Azure Search に出力する

先月、Build Insider MEETUP with Grani に参加してきました。C# で Web アプリケーションを作るフレームワークの話のなかで、Semantic Logging Application Block(SLAB)が面白そうだったので、試してみました。

SLAB は、Event Tracing for Windows(ETW)のログを出力できるライブラリであり、Enterprise Library 6.0 に含まれるアプリケーションブロックのひとつです。厳密に型付けされたイベントをベースにしたログは、コンソールアプリ、テキストファイル、SQL Server、Azure Table Storage、Elasticsearch などに出力できます。Elasticsearch に出力できるなら、Azure Search に出力してもよさそうなので試してみました。ASP.NET Web API で発生した例外をハンドリングして、ETW ログを出力するシナリオです。

ETW ログを書き込む

ASP.NET Web API の Web アプリケーションを作成し、ETW ログを書き込むクラスを作成します。EventSource クラスを継承したクラスを作成し、ログを書き込む Error メソッドを実装します。ログに書き込む情報は、HTTP メソッド、URL の絶対パス、例外メッセージの3つとします。

[EventSource(Name = "MyEventSource")]
public sealed class MyEventSource : EventSource
{
    private readonly static MyEventSource log = new MyEventSource();
    private MyEventSource() { }
    public static MyEventSource Log { get { return log; } }

    [Event(1, Level = EventLevel.Error, Message = "An error has occurred")]
    public void Error(string method, string path, string exceptionMessage)
    {
        WriteEvent(1, method, path, exceptionMessage);
    }
}

例外をハンドリングする ExceptionLogger クラスを継承したクラスを作成してロギングします。

public class MyExceptionLogger : ExceptionLogger
{
    public override void Log(ExceptionLoggerContext context)
    {
        var method = context.Request.Method.ToString();
        var path = context.Request.RequestUri.AbsolutePath;
        var exceptionMessage = context.Exception.Message;

        MyEventSource.Log.Error(method ?? "", path ?? "", exceptionMessage ?? "");
    }
}

MyExceptionLogger クラスは、WebApiConfig クラスで有効化しておきます。

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        config.Services.Add(typeof(IExceptionLogger), new MyExceptionLogger());

        (省略)
        );
    }
}

Azure Search のアクセスクラスを作成する

Web アプリケーションのプロジェクトに、Azure Search Library(Preview)を NuGet からインストールします。ログのデータをモデルに詰め替えたいので、AutoMapper も合わせてインストールします。

  • Install-Package Microsoft.Azure.Search -Pre
  • Install-Package AutoMapper -Version 3.3.1

Azure Search に対して、インデックスの作成、ログのアップロード、ログの検索を行うアクセスクラスを作成します。

public class EventSearchClient
{
    private readonly string _serviceName;
    private readonly string _apiKey;
    private readonly string _indexName;

    public EventSearchClient(string serviceName, string apiKey)
    {
        this._serviceName = serviceName;
        this._apiKey = apiKey;
        this._indexName = "event";
    }

    public async Task CreateIndexAsync()
    {
        using (var serviceClient = new SearchServiceClient(this._serviceName, new SearchCredentials(this._apiKey)))
        {
            // インデックスの削除
            if (await serviceClient.Indexes.ExistsAsync(this._indexName))
            {
                await serviceClient.Indexes.DeleteAsync(this._indexName);
            }

            // インデックスの作成
            var definition = new Index()
            {
                Name = this._indexName,
                Fields = new[]
                    {
                        new Field("id", DataType.String)    { IsKey = true },
                        new Field("providerId", DataType.String)  { IsSearchable = true, IsFilterable = true },
                        new Field("eventId", DataType.Int32)  { IsFilterable = true },
                        new Field("keywords", DataType.Int32)  { IsFilterable = true },
                        new Field("level", DataType.Int32)  { IsFilterable = true },
                        new Field("message", DataType.String)  { IsSearchable = true, IsFilterable = true },
                        new Field("opcode", DataType.Int32)  { IsFilterable = true },
                        new Field("task", DataType.Int32)  { IsFilterable = true },
                        new Field("version", DataType.Int32)  { IsFilterable = true },
                        new Field("eventName", DataType.String)  { IsSearchable = true, IsFilterable = true },
                        new Field("timestamp", DataType.DateTimeOffset)  { IsFilterable = true, IsSortable = true },
                        new Field("processId", DataType.Int32)  { IsFilterable = true },
                        new Field("threadId", DataType.Int32)  { IsFilterable = true },
                        new Field("method", DataType.String)  { IsSearchable = true, IsFilterable = true },
                        new Field("path", DataType.String)  { IsSearchable = true, IsFilterable = true },
                        new Field("exceptionMessage", DataType.String)  { IsSearchable = true, IsFilterable = true },
                    }
            };
            await serviceClient.Indexes.CreateAsync(definition);
        }
    }

    public async Task UploadAsync(string json)
    {
        using (var serviceClient = new SearchServiceClient(this._serviceName, new SearchCredentials(this._apiKey)))
        using (var indexClient = serviceClient.Indexes.GetClient(this._indexName))
        {
            // JSON 形式のログを、Azure Search で検索しやすい MyEventEntryModel 型に変換
            var eventEntry = JsonConvert.DeserializeObject<List<MyEventEntry>>(json);
            var documents = eventEntry.Select(x => Mapper.Map<MyEventEntryModel>(x));
            try
            {
                // ログのアップロード
                await indexClient.Documents.IndexAsync(IndexBatch.Create(documents.Select(x => IndexAction.Create(x))));
            }
            catch (IndexBatchException e)
            {
                var message = String.Format(
                    "Failed to index some of the documents: {0}",
                    String.Join(", ", e.IndexResponse.Results.Where(r => !r.Succeeded).Select(r => r.Key)));
                throw new Exception(message);
            }
            await Task.Delay(2000); // 非同期で作成されるので、少し待機する
        }
    }

    public async Task<IEnumerable<MyEventEntryModel>> SearchAsync(string searchText)
    {
        using (var serviceClient = new SearchServiceClient(this._serviceName, new SearchCredentials(this._apiKey)))
        using (var indexClient = serviceClient.Indexes.GetClient(this._indexName))
        {
            var sp = new SearchParameters();
            sp.OrderBy = new List<string> { "timestamp desc" };
            var response = await indexClient.Documents.SearchAsync<MyEventEntryModel>(searchText, sp);
            return response.Results.Select(x => x.Document);
        }
    }
}

UploadAsync メソッドでは、JSON 形式で渡されてきたログを MyEventEntry クラスにデシリアライズしています。MyEventEntry クラスは、Payload プロパティに HTTP メソッド、URL の絶対パス、例外メッセージが内包されていて Azure Search では検索し難いため、AutoMapper でフラットな MyEventEntryModel クラスに詰め替えています。

public class MyEventEntry
{
    public string ProviderId { get; set; }
    public int EventId { get; set; }
    public int Keywords { get; set; }
    public int Level { get; set; }
    public string Message { get; set; }
    public int Opcode { get; set; }
    public int Task { get; set; }
    public int Version { get; set; }
    public Payload Payload { get; set; }
    public string EventName { get; set; }
    public DateTime Timestamp { get; set; }
    public int ProcessId { get; set; }
    public int ThreadId { get; set; }
}

public class Payload
{
    public string method { get; set; }
    public string path { get; set; }
    public string exceptionMessage { get; set; }
}
[SerializePropertyNamesAsCamelCase]
public class MyEventEntryModel
{
    public string Id { get; set; }
    public string ProviderId { get; set; }
    public int? EventId { get; set; }
    public int? Keywords { get; set; }
    public int? Level { get; set; }
    public string Message { get; set; }
    public int? Opcode { get; set; }
    public int? Task { get; set; }
    public int? Version { get; set; }
    public string EventName { get; set; }
    public DateTimeOffset? Timestamp { get; set; }
    public int? ProcessId { get; set; }
    public int? ThreadId { get; set; }
    public string Method { get; set; }
    public string Path { get; set; }
    public string ExceptionMessage { get; set; }
}

MyEventEntryModel クラスには、ポイントが3つあります。

  1. SerializePropertyNamesAsCamelCase 属性により、大文字小文字の違いを意識しないで大丈夫
  2. 値型は NULL を許容する必要がある
  3. Azure Search の日付型は、DateTimeOffset

AutoMapper のマッピング定義は、Global.asax の Application_Start メソッドで設定しておきます。

public class MvcApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        (省略)

        Mapper.CreateMap<MyEventEntry, MyEventEntryModel>()
            .ForMember(dst => dst.Method, opt => opt.MapFrom(src => src.Payload.method))
            .ForMember(dst => dst.Path, opt => opt.MapFrom(src => src.Payload.path))
            .ForMember(dst => dst.ExceptionMessage, opt => opt.MapFrom(src => src.Payload.exceptionMessage))
            .ForMember(dst => dst.Timestamp, opt => opt.MapFrom(src => new DateTimeOffset(src.Timestamp)))
            .ForMember(dst => dst.Id, opt => opt.MapFrom(src => String.Format("{0:D19}", DateTime.MaxValue.Ticks - src.Timestamp.Ticks)));
    }
}

SLAB の Custom Event Sink を作成する

SLAB で任意のストレージにログを出力したい場合には、Custom Event Sink を作成します。IObserver<EventEntry> クラスを継承したクラスを作成し、OnNext メソッドでログの出力を実装します。EventSearchClient クラスの UploadAsync メソッドの呼び出しは、UI スレッドに戻さないように ConfigureAwait(false) で待機する必要があります。

public class AzureSearchSink : IObserver<EventEntry>
{
    private readonly string _serviceName;
    private readonly string _apiKey;

    public AzureSearchSink(string serviceName, string apiKey)
    {
        this._serviceName = serviceName;
        this._apiKey = apiKey;
    }

    public void OnCompleted()
    {
    }

    public void OnError(Exception error)
    {
    }

    public void OnNext(EventEntry value)
    {
        if (value != null)
        {
            using (var writer = new StringWriter())
            {
                new JsonEventTextFormatter().WriteEvent(value, writer);
                Post(writer.ToString());
            }
        }
    }

    private void Post(string body)
    {
        var json = String.Format("[{0}]", body);    // JSON配列
        new EventSearchClient(this._serviceName, this._apiKey).UploadAsync(json).ConfigureAwait(false);
    }
}

ObservableEventListener クラスに AzureSearchSink クラスを設定する拡張メソッドを定義し、Global.asax の Application_Start メソッドで設定します。LogToAzureSearch メソッドのサービス名と API キーは、Azure 管理ポータルから取得した情報を設定してください。

public static class AzureSearchSinkExtensions
{
    public static EventListener CreateListener(string serviceName, string apiKey)
    {
        var listener = new ObservableEventListener();
        listener.LogToAzureSearch(serviceName, apiKey);
        return listener;
    }

    public static SinkSubscription<AzureSearchSink> LogToAzureSearch(this IObservable<EventEntry> eventStream, string serviceName, string apiKey)
    {
        var sink = new AzureSearchSink(serviceName, apiKey);
        var subscription = eventStream.Subscribe(sink);
        return new SinkSubscription<AzureSearchSink>(subscription, sink);
    }
}
public class MvcApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        (省略)

        var listener = new ObservableEventListener();
        listener.EnableEvents(MyEventSource.Log, EventLevel.Error);
        listener.LogToAzureSearch("xxx", "xxx");
    }
}

Web API を作成する

ASP.NET Web API の EventController クラスを作成し、POST メソッドでインデックスの作成、GET メソッドでログの検索を行います。

[RoutePrefix("api/event")]
public class EventController : ApiController
{
    private readonly string _serviceName = "xxx";
    private readonly string _apiKey = "xxx";

    [Route]
    public async Task<IEnumerable<MyEventEntryModel>> Get()
    {
        return await new EventSearchClient(this._serviceName, this._apiKey).SearchAsync("");
    }

    [Route("{searchText}")]
    public async Task<IEnumerable<MyEventEntryModel>> Get(string searchText)
    {
        return await new EventSearchClient(this._serviceName, this._apiKey).SearchAsync(searchText);
    }

    [Route]
    public async Task Post()
    {
        await new EventSearchClient(this._serviceName, this._apiKey).CreateIndexAsync();
    }
}

もうひとつ、例外を発生させる TestController クラスも作成します。

[RoutePrefix("api/test")]
public class TestController : ApiController
{
    [Route("{id:int}")]
    public string Get(int id)
    {
        throw new Exception(String.Format("test{0}", id));
        return "success";
    }
}

結果確認

Google Chrome の Postman を使って、Web API を実行します。

  1. POST api/event/
  2. GET api/test/1/
  3. GET api/test/2/
  4. GET api/test/3/
  5. GET api/event/

上記のような 5 つの Web API を実行すると、Azure Search にインデックスが作成され、発生した例外のログがアップロードされます。結果として、3 件のログを取得できます。

slab01

また、GET api/event/test2/ を実行すると、条件を絞り込んでログを取得することも可能です。

slab02

まとめ

アプリケーションのログは、単に溜め込んでいくだけでは十分な分析や活用ができないので、活用しやすい構造で保管していくことが重要です。SLAB と ETW を使えば、セマンティックな構造化されたログを、任意のストレージに出力することができます。コンソールや Windows Service を使って SLAB を Out of Process でホストさせることも可能です。自前でログを作り込むのもよいですが、SLAB はなかなか面白いライブラリなので、使ってみるのもありだと思いました。

SendGrid の Event Webhook から POST されたデータを Azure DocumentDB に登録する

SendGrid の Event Webhook を利用すると、メールを送信する際に発生したイベントを指定した URL に POST することができます。POST されるデータは、delivered や bounce などのイベントタイプによってスキーマが異なる JSON 配列なので、スキーマフリーの NoSQL ストアである Azure DocumentDB に登録してみました。

Event Webhook から POST される Web API の作成

まず、Azure Preview ポータルから Azure DocumentDB を作成しておきます。10分ほどかかるので、Visual Studio から ASP.NET Web API のプロジェクトテンプレートを作成します。NuGet から、DocumentDB の Client Library をインストールします。

  • Install-Package Microsoft.Azure.Documents.Client -Pre

SendGridController を追加し、Post メソッドを実装します。

[RoutePrefix("api/sendgrid")]
public class SendGridController : ApiController
{
    private static string EndpointUrl = "https://xxx.documents.azure.com:443/";
    private static string AuthorizationKey = "xxx";
    private static string DatabaseID = "SendGridDb";
    private static string CollectionID = "SendGridCollection";

    [Route]
    public async Task Post()
    {
        // Event Webhook から POST された JSON を 取得
        var json = "";
        using (var reader = new StreamReader(await Request.Content.ReadAsStreamAsync()))
        {
            json = reader.ReadToEnd();
        }

        // Azure DocumentDB に登録
        using (var client = new DocumentClient(new Uri(EndpointUrl), AuthorizationKey))
        {
            var database = client.CreateDatabaseQuery()
                            .Where(x => x.Id == DatabaseID).ToArray().FirstOrDefault();
            if (database == null)
            {
                database = await client.CreateDatabaseAsync(new Database { Id = DatabaseID });
            }
            var documentCollection = client.CreateDocumentCollectionQuery(database.CollectionsLink)
                                        .Where(x => x.Id == CollectionID).ToArray().FirstOrDefault();
            if (documentCollection == null)
            {
                documentCollection = await client.CreateDocumentCollectionAsync(database.SelfLink, new DocumentCollection { Id = CollectionID });
            }
            foreach (var doc in JsonConvert.DeserializeObject<List<dynamic>>(json))
            {
                await client.CreateDocumentAsync(documentCollection.DocumentsLink, doc);
            }
        }
    }
}

EndpointUrl と AuthorizationKey には、Azure Preview ポータルから取得できる値を設定してください。スキーマを定義することなく、dynamic 型をそのまま DocumentDB に登録できるところがいい感じです。実装できたら、Azure Websites にデプロイします。

Event Webhook から POST する URL を設定する

SendGrid のダッシュボードで、Apps メニューから Event Notification を Enabled します。

EventWebhook

HTTP Post URL には、デプロイした Web API の URL(http://xxx.azurewebsites.net/api/sendgrid/)を設定します。送信に失敗したメールアドレスの情報を取得したいので、3つのアクションを設定しました。

  • Dropped・・・無効なメールアドレス、バウンスされたメールアドレスなど
  • Deferred・・・受信側メールサーバーから一時的に拒否された
  • Bounced・・・受信側メールサーバーが受信できない

結果確認

存在しないメールアドレス(test@example.com)を宛先としてメール送信すると、DocumentDB に JSON 形式のデータが登録されます。Azure Preview ポータルのクエリ エクスプローラで、DocumentDB に登録されたデータを確認できます。

doc

まとめ

クラウドで展開されるアプリケーションは、パスワードリマインダや登録完了のお知らせなどのトランザクションメールの機能が必要となるケースが多く、メール送信の信頼性は重要なポイントになります。Event Webhook なら、送信に失敗したメールアドレスを通知できるので、対応を自動化することも可能です。

今回は、Azure DocumentDB に登録しましたが、同じような NoSQL ストアである Azure Search に登録してもいいと思います。Azure DocumentDB は、まだ日本にデプロイされていないので、来月 GA する際にはデプロイされてほしいです。

 

SendGrid の Web API v3 で統計情報を取得する

久しぶりに、SendGrid の Web API のドキュメントを読んだら、Web API v3 が公開されていました。Web API v3  は、Basic 認証を使うようになり、API のリクエストの送信先ホストも変更されています。

以前の投稿で、統計情報を取得する General Statistics の Web API を使って、今月のメール送信通数を取得しました。従来の General Statistics が非推奨となり、今後は v3 の Stats を利用することを推奨しています。そこで、統計情報の取得方法を Web API v3 に置き換えてみました。

統計情報を取得する

今回も、今月のメール送信通数を通知してくれる Azure Webjobs を発行することにします。

static void Main()
{
    try
    {
        var sendGridUserName = "xxx";
        var sendGridPassword = "xxx";

        // Basic 認証
        var parameter = Convert.ToBase64String(UTF8Encoding.UTF8.GetBytes(String.Format("{0}:{1}", sendGridUserName, sendGridPassword)));
        var authHeader = new AuthenticationHeaderValue("Basic", parameter);

        // 今月の統計情報を取得
        var startDate = DateTime.UtcNow.AddHours(9).AddDays(-1).ToString("yyyy-MM-01");
        var queryCollection = new Dictionary<string, string>();
        queryCollection.Add("start_date", startDate);
        queryCollection.Add("aggregated_by", "month");
        var queryString = "?" + String.Join("&", queryCollection.Select(x => String.Format("{0}={1}", x.Key, x.Value)));
        var url = "https://api.sendgrid.com/v3/stats" + queryString;

        int delivered = 0;
        using (var client = new HttpClient())
        {
            client.DefaultRequestHeaders.Authorization = authHeader;
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            var response = client.GetStringAsync(url).Result;
            var statistics = JsonConvert.DeserializeObject<List<MySendGridStatisticsV3>>(response);
            delivered = statistics[0].stats[0].metrics.delivered;
        }
        var message = String.Format("今月の送信件数は、{0} 件です。", delivered);

        // メール送信
        var from = "admin@example.com";

        var to = new List<string>();
        to.Add("tony@example.com");
        var smtpapi = new Header();
        smtpapi.SetTo(to);

        var email = new SendGridMessage();
        email.AddTo(from);	// SmtpapiのSetTo()を使用しているため、実際にはこのアドレスにはメールは送信されない
        email.From = new MailAddress(from, "Web Services Adminstrator");
        email.Subject = "SendGrid Statistics";
        email.Text = message;
        email.Headers.Add("X-Smtpapi", smtpapi.JsonString());

        var credentials = new NetworkCredential(sendGridUserName, sendGridPassword);
        new Web(credentials).Deliver(email);
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
    }
}

パラメーターとして、Start_date に月初の日付(2015-03-01)、aggregated_by に month を指定しています。MySendGridStatisticsV3 クラスは、Visual Studio の形式を選択して貼り付けで、JSON をクラスとして貼り付けて作りました。

public class MySendGridStatisticsV3
{
    public string date { get; set; }
    public Stat[] stats { get; set; }
}

public class Stat
{
    public Metrics metrics { get; set; }
}

public class Metrics
{
    public int blocks { get; set; }
    public int bounce_drops { get; set; }
    public int bounces { get; set; }
    public int clicks { get; set; }
    public int deferred { get; set; }
    public int delivered { get; set; }
    public int invalid_emails { get; set; }
    public int opens { get; set; }
    public int processed { get; set; }
    public int requests { get; set; }
    public int spam_report_drops { get; set; }
    public int spam_reports { get; set; }
    public int unique_clicks { get; set; }
    public int unique_opens { get; set; }
    public int unsubscribe_drops { get; set; }
    public int unsubscribes { get; set; }
}

あとは、このコンソールアプリを定期的に実行するスケジュールで WebJobs として発行するだけです。

まとめ

SendGrid の Web API のドキュメントを読んでみると、メールを送信できるだけでなく、いろいろな機能が充実していて面白いです。次回は、Event Webhook にチャレンジしてみたいと思います。

Azure Search で地理空間検索アプリを作ってみる

Azure Search の地理空間検索を使用すると、ある地点から特定の距離内にある検索対象を見つけることができます(現在位置から 5 km 以内にあるすべてのレストランを検索するなど)。この機能を使って、孤独のグルメに登場したお店を検索できる ASP.NET MVC アプリを作成してみます。

お店のデータをアップロードするアプリを作成する

Azure Search に店舗データの Json ファイルアップロードするコンソールアプリを作成します。REST API が公開されていますが、RedDog.Search が便利なので、NuGet からインストールします。

  • Install-Package RedDog.Search

インデックスを作成し、Json ファイルのデータをアップロードします。

[
    {
        "season": 1,
        "episode": 1,
        "title": "江東区門前仲町のやきとりと焼きめし",
        "restaurant": "庄助",
        "location": { "lat": 35.6710663, "lng": 139.796387 }
    },
    {
        "season": 1,
        "episode": 2,
        "title": "豊島区駒込の煮魚定食",
        "restaurant": "和食亭",
        "location": { "lat": 35.7370453, "lng": 139.749954 }
    },
    {
        "season": 1,
        "episode": 3,
        "title": "豊島区池袋の汁なし担々麺",
        "restaurant": "中国家庭料理 楊 2号店",
        "location": { "lat": 35.7300568, "lng": 139.70726 }
    },
  (以下、省略)
]
public class Gourmet
{
    public int Id { get; set; }
    public int Season { get; set; }
    public int Episode { get; set; }
    public string Title { get; set; }
    public string Restaurant { get; set; }
    public Location Location { get; set; }
}
public class Location
{
    public float lat { get; set; }
    public float lng { get; set; }
}

class Program
{
    private static readonly string _serviceName = "xxx";
    private static readonly string _apiKey = "xxx";
    private static readonly string _indexName = "sample";
    private static readonly string _jsonFile = Environment.GetEnvironmentVariable("USERPROFILE") + @"\Desktop\Sample.json";

    static void Main(string[] args)
    {
        using (var connection = ApiConnection.Create(_serviceName, _apiKey))
        using (var client = new IndexManagementClient(connection))
        {
            // インデックスの作成
            var createResponse = client.CreateIndexAsync(new Index(_indexName)
                                .WithStringField("id", f => f.IsKey().IsSearchable().IsRetrievable())
                                .WithIntegerField("season", f => f.IsSortable().IsRetrievable())
                                .WithIntegerField("episode", f => f.IsSortable().IsRetrievable())
                                .WithStringField("title", f => f.IsSearchable().IsRetrievable())
                                .WithStringField("restaurant", f => f.IsSearchable().IsRetrievable())
                                .WithGeographyPointField("location", p => p.IsFilterable().IsSortable().IsRetrievable())
                            ).Result;
            if (!createResponse.IsSuccess)
            {
                throw new Exception(String.Format("{0}:{1}", createResponse.Error.Code, createResponse.Error.Message));
            }

            // データのアップロード
            var json = File.ReadAllText(_jsonFile, Encoding.UTF8);
            var targets = JsonConvert.DeserializeObject<List<Gourmet>>(json);
            foreach (var target in targets.Select((v, i) => new { value = v, index = i }))
            {
                var populateResponse = client.PopulateAsync(_indexName,
                        new IndexOperation(IndexOperationType.Upload, "id", (target.index + 1).ToString())
                            .WithProperty("season", target.value.Season)
                            .WithProperty("episode", target.value.Episode)
                            .WithProperty("title", target.value.Title)
                            .WithProperty("restaurant", target.value.Restaurant)
                            .WithProperty("location", new { type = "Point", coordinates = new[] { target.value.Location.lng, target.value.Location.lat } })
                    ).Result;
                if (!populateResponse.IsSuccess)
                {
                    throw new Exception(String.Format("{0}:{1}", populateResponse.Error.Code, populateResponse.Error.Message));
                }
            }
        }
    }
}

緯度経度は、匿名型を使って GeoJSON としてシリアライズされる形で指定しています。_serviceName と _apiKey には、Azure のプレビューポータルで作成した Azure Search の情報を設定してください。

お店を検索するアプリを作成する

近くのお店を検索できる ASP.NET MVC の Web アプリを作成します。前述と同様に RedDog.Search が便利なので、NuGet からインストールします。

Model と Controller を実装します。Ajax 通信で呼ばれる Search メソッドでは、指定されたキーワードの緯度経度を取得し、最寄りの10件を部分ビューとして返却します。

指定されたキーワードの緯度経度は、Google Geocoding API から取得します。Geocode クラスは、返却される Json 形式のデータを Visual Studio の「Json をクラスとして貼り付ける」機能で作成しました。

public static class GeocodeClient
{
    public static async Task<Geocode> GetGeocodeAsync(string address)
    {
        var result = new Geocode();
        var requestUri = String.Format("http://maps.google.com/maps/api/geocode/json?address={0}&sensor=false", HttpUtility.UrlEncode(address));
        using (var client = new HttpClient())
        {
            client.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("ja-JP"));
            var response = await client.GetStringAsync(requestUri);
            result = JsonConvert.DeserializeObject<Geocode>(response);
        }
        return result;
    }
}

Google Geocoding API から取得した緯度経度で、最寄りの10件を部分ビューとして返却します。

public static class SearchClient
{
    private static readonly string _serviceName = "xxx";
    private static readonly string _apiKey = "xxx";
    private static readonly string _indexName = "sample";

    public static async Task<IEnumerable<Gourmet>> SearchAsync(Location location)
    {
        using (var connection = ApiConnection.Create(_serviceName, _apiKey))
        using (var client = new IndexQueryClient(connection))
        {
            var query = new SearchQuery()
                        .OrderBy(String.Format("geo.distance(location, geography'POINT({0} {1})')", location.lng, location.lat))
                        .Top(10);
            var response = await client.SearchAsync(_indexName, query);
            if (!response.IsSuccess)
            {
                throw new Exception(String.Format("{0}:{1}", response.Error.Code, response.Error.Message));
            }

            return response.Body.Records.Select(x => new Gourmet
            {
                Id = int.Parse(x.Properties["id"] as string),
                Season = (int)(long)x.Properties["season"],
                Episode = (int)(long)x.Properties["episode"],
                Title = x.Properties["title"] as string,
                Restaurant = x.Properties["restaurant"] as string,
            });
        }
    }
}

View を実装します。検索ボタンが押されたら、JQuery で Search アクションメソッドを呼び出して、結果を表示します。

@{
    ViewBag.Title = "Index";
}

<h2>近くの店を探す</h2>

<form class="form-horizontal">
    <div class="form-group">
        <div class="col-md-3">
            @Html.TextBox("keyword", @ViewBag.Keyword as string, new { @class = "form-control", placeholder = "例:西新宿駅, 目黒雅叙園" })
        </div>
        <div class="col-md-9">
            @Html.TextBox("search", "検索", new { type = "button", @class = "btn btn-primary" })
        </div>
    </div>
</form>
<div id="result"></div>

@section scripts
{
    <script>
         $(function () {
            $('#search').click(function () {
                $('#result').load('/Navi/Search', { keyword: $('#keyword').val() });
            })
            if ($('#keyword').val() != "") {
                $('#search').trigger("click");
            }
         })
    </script>
}

部分 View を実装します。Azure Search からの検索結果をバインドします。

@model IEnumerable<WebApplication1.Models.Gourmet>

<p>最寄りの 10 件</p>
<table class="table table-hover">
    <tr>
        <th>
            @Html.DisplayNameFor(model => model.Season)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.Episode)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.Title)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.Restaurant)
        </th>
        <th></th>
    </tr>

    @foreach (var item in Model)
    {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.Season)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Episode)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Title)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Restaurant)
            </td>
        </tr>
    }
</table>

結果確認

例えば、「東京駅」で検索すると、最寄りの10件が表示されます。人形町の黒天丼のお店が一番近いことが分かります。

Gourmet

まとめ

地理空間検索は、Azure Search らしい面白い機能だと思います。Azure で提供される機能には、オンプレミスと同じことが低コストで実現できる機能と、オンプレミスでは難しいことが簡単に実現できる機能がありますが、開発者としては後者の機能に魅力を感じます。スマートフォンの GPS や地図アプリと連携させれば、孤独のグルメの聖地巡礼に欠かせないアプリを作ることができそうです。

最後に、今回のアプリを作成するにあたり、こちらのブログを参考にさせて頂きました。

 

Azure SDK 2.5 の Azure Diagnostics に移行する

(追記)Azure SDK 2.6 の Azure Diagnostics に移行することをお薦めします。

昨年の 12月10日に Azure アカウント宛にメールが来ていて、Cloud Services(Web Role, Worker Role)で Azure Diagnostics を利用している場合、2015年12月9日までに Azure SDK 2.5 から利用できる Diagnostics 2.0 にアップグレードする必要があると告知されていました。以前の記事で書いた Azure Storage REST API の旧バージョン削除の件も、削除予定日が 2015年8月1日 から 2015年12月9日 に延長されています。Storage Client のサポート終了日は、Ver.1.7 ~ 1.5.1 は 2015年12月9日 から 無期限 に延期され、それ以前のバージョンは 2015年12月9日 から 2016年8月1日 に延期されました。Azure Diagnostics は、Web Role や Worker Role のサーバーインスタンスから Azure Storage にログを転送する機能なので、古い REST API の削除が影響するのだと思われます。

  •  Web Role や Worker Role を使っているアプリの多くが Azure Diagnostics を利用している
  • Azure SDK 2.5 は、2014年11月 にリリースされたばかり
  • Azure SDK 1.6 ~ 2.1 は 、2015年11月12日 にサポート終了する

といったことを考えると、現在 Cloud Services 上で動いているほぼ全てのアプリは、パッケージの更新と再デプロイが必要となります。Azure SDK 2.5 では Visual Studio 2010 を使った開発ができないため、Visual Studio 2012 or 2013 への更新が必要なケースもあります。この記事では、Azure Diagnostics を Azure SDK 2.5 に移行する方法を紹介していきます。

Azure SDK 2.5 の Azure Diagnostics とは?

概要や使い方については、このあたりのサイトが参考になります。

Azure SDK 2.4 と 2.5 の Azure Diagnostics には、次のような機能の違いがあります。

  • Event Tracing for Windows(ETW)に対応
  • Azure Virtual Machines でも、診断ログを転送できる
  • 診断構成ファイル(diagnostics.wadcfg → diagnostics.wadcfgx)が変更された
  • 接続文字列の Microsoft.WindowsAzure.Plugins.Diagnostics.ConnectionString は使わない
  • デプロイした直後は診断機能が無効化されているので、PowerShell で有効にしないと Storage に転送されない
  • 開発環境のエミュレータでは、診断ログが Storage に転送されなくなった
  • コードで診断の構成を定義できなくなった
  • ロール単位でのみ構成でき、インスタンス単位では構成できなくなった
  • クラッシュダンプも、診断構成ファイルで構成できるようになった

Cloud Services で Azure Diagnostics を利用する方法

以下の手順で、Cloud Services で Azure SDK 2.5 の Azure Diagnostics を利用することができます。

Visual Studio のプロジェクトで、ロールの WebRole1 のプロパティから診断の有効化と構成が可能です。

wad04

似たような GUI で Azure SDK 2.4 以前も診断構成できましたが、ETW ログやクラッシュ ダンプのタブが追加されています。ここで、Azure Storage に収集したいログの転送レベルや転送期間を構成します。

診断構成したプロジェクトからパッケージを作成し、Azure 管理ポータルからデプロイすると診断機能が無効になっていますので、Visual Studio か PowerShell のどちらかで診断機能を有効にする必要があります。将来的にはポータルからも有効にできそうな気がします。ちなみに、Visual Studio から直接デプロイすると、診断機能が有効になった状態でデプロイできます。

Visual Studio のサーバーエクスプローラ

クラウドサービスのロールを右クリックして、診断を有効にできます。診断構成を変更することも可能です。

wad05

PowerShell の Set-AzureServiceDiagnosticsExtension コマンド

まず、Set-AzureServiceDiagnosticsExtension コマンドの引数の DiagnosticsConfigurationPath で指定する診断構成ファイルを作成します。

  1. Visual Studio のプロジェクトで管理されている diagnostics.wadcfgx を別の場所にコピーする
  2. DiagnosticsConfiguration, PrivateConfig, IsEnabled の3つのタグを削除し、PublicConfig タグの中身だけを残す
  3. StorageAccount タグに診断ログの収集先となる Azure Storage アカウント名を設定する

作成した診断構成ファイルは、次のようなレイアウトになります。スキーマの詳細は、こちらで確認できます。

<?xml version="1.0" encoding="utf-8"?>
<PublicConfig xmlns="http://schemas.microsoft.com/ServiceHosting/2010/10/DiagnosticsConfiguration">
  <WadCfg>
    <DiagnosticMonitorConfiguration overallQuotaInMB="4096">
      <DiagnosticInfrastructureLogs scheduledTransferLogLevelFilter="Error"/>
      <Logs scheduledTransferPeriod="PT1M" scheduledTransferLogLevelFilter="Error" />
      <Directories scheduledTransferPeriod="PT1M">
        <IISLogs containerName ="wad-iis-logfiles" />
        <FailedRequestLogs containerName ="wad-failedrequestlogs" />
      </Directories>
      <WindowsEventLog scheduledTransferPeriod="PT1M" >
        <DataSource name="Application!*" />
      </WindowsEventLog>
      <CrashDumps containerName="wad-crashdumps" dumpType="Mini">
        <CrashDumpConfiguration processName="WaIISHost.exe"/>
        <CrashDumpConfiguration processName="WaWorkerHost.exe"/>
        <CrashDumpConfiguration processName="w3wp.exe"/>
      </CrashDumps>
      <PerformanceCounters scheduledTransferPeriod="PT1M">
        <PerformanceCounterConfiguration counterSpecifier="\Memory\Available MBytes" sampleRate="PT3M" />
        <PerformanceCounterConfiguration counterSpecifier="\Web Service(_Total)\ISAPI Extension Requests/sec" sampleRate="PT3M" />
        <PerformanceCounterConfiguration counterSpecifier="\Web Service(_Total)\Bytes Total/Sec" sampleRate="PT3M" />
        <PerformanceCounterConfiguration counterSpecifier="\ASP.NET Applications(__Total__)\Requests/Sec" sampleRate="PT3M" />
        <PerformanceCounterConfiguration counterSpecifier="\ASP.NET Applications(__Total__)\Errors Total/Sec" sampleRate="PT3M" />
        <PerformanceCounterConfiguration counterSpecifier="\ASP.NET\Requests Queued" sampleRate="PT3M" />
        <PerformanceCounterConfiguration counterSpecifier="\ASP.NET\Requests Rejected" sampleRate="PT3M" />
        <PerformanceCounterConfiguration counterSpecifier="\Processor(_Total)\% Processor Time" sampleRate="PT3M" />
      </PerformanceCounters>
    </DiagnosticMonitorConfiguration>
  </WadCfg>
  <StorageAccount>mystoragename</StorageAccount>
</PublicConfig>

あとは、デプロイ環境に対して Set-AzureServiceDiagnosticsExtension コマンドを実行するだけです。Azure Storage や Service の名前は、環境に合わせて置き換えてください。

$storageContext = New-AzureStorageContext –StorageAccountName <storagename> –StorageAccountKey <key>
Set-AzureServiceDiagnosticsExtension -ServiceName <servicename> -Slot Production -Role WebRole1 -StorageContext $storageContext -DiagnosticsConfigurationPath C:\Users\xxx\Desktop\diagnostics.wadcfgx

診断機能が有効になると、各種のログが Azure Storage に転送されるようになります。

Azure SDK 2.4 以前から移行する方法

Azure Diagnostics には、診断構成にいくつかの方法があります。大きく3つのパターンに分類します。どのパターンでも、Visual Studio で Azure SDK をアップグレードするまでは同じです。アップグレード後の手順が異なるので、順番に説明していきます。

なお、接続文字列の Microsoft.WindowsAzure.Plugins.Diagnostics.ConnectionString は削除されていますので、もしこの接続文字列を使っているコードがあれば、そちらも修正が必要です。

Visual Studio 上の GUI で構成していた場合

Visual Studio のプロジェクトで、ロールの WebRole1 のプロパティから診断構成画面で構成していた場合です。

wad01

Azure SDK 2.0 以降のバージョンで作成したプロジェクトがこのケースに該当するので、最も多いと思われます。プロジェクトのアップグレードが完了すると、ロールの WebRole1 に diagnostics.wadcfg が残っているので削除します。Azure SDK 2.5 からは、diagnostics.wadcfgx を使いますので、右クリックして「診断構成の追加」を実行します。

wad02

あとは、前述した Azure SDK 2.5 で新しくプロジェクトを作成した場合の手順通りに、診断構成を再構成します。

diagnostics.wadcfg ファイルで構成していた場合

Visual Studio のプロジェクトで、WebRole の直下に diagnostics.wadcfg を配置して構成していた場合です。

wad03

Azure SDK 1.x のバージョンで作成したプロジェクトは、このケースに該当します。プロジェクトのアップグレードが完了すると、WebRole の直下に diagnostics.wadcfg が残っているので削除します。ロールの WebRole1 に diagnostics.wadcfgx がありませんので、右クリックして「診断構成の追加」を実行します。あとは、前述した Azure SDK 2.5 で新しくプロジェクトを作成した場合の手順通りに、診断構成を再構成します。

コードで構成していた場合

WebRole クラスの OnStart() メソッドで、Microsoft.WindowsAzure.Diagnostics 名前空間の API を使って構成していた場合です。

public class WebRole : RoleEntryPoint
{
    public override bool OnStart()
    {
        // 診断ログを構成
        DiagnosticMonitorConfiguration config = DiagnosticMonitor.GetDefaultInitialConfiguration();
        config.Logs.ScheduledTransferPeriod = TimeSpan.FromMinutes(1);
        config.Logs.ScheduledTransferLogLevelFilter = Microsoft.WindowsAzure.Diagnostics.LogLevel.Error;
        config.Directories.ScheduledTransferPeriod = TimeSpan.FromMinutes(1);
        DiagnosticMonitor.Start("Microsoft.WindowsAzure.Plugins.Diagnostics.ConnectionString", config);

        return base.OnStart();
    }
}

Azure SDK 1.x のバージョンで作成したプロジェクトは、diagnostics.wadcfg ファイルとコードのどちらでも構成できましたが、コードで構成していたケースは少ないはずです。diagnostics.wadcfg ファイルの方がロールインスタンス開始前のログも取得できますし、構成が簡単だからです。プロジェクトのアップグレードが完了すると、DiagnosticMonitor クラスなどに Obsolete の警告が表示されますので、コードを削除します。ロールの WebRole1 に diagnostics.wadcfgx がありませんので、右クリックして「診断構成の追加」を実行します。あとは、前述した Azure SDK 2.5 で新しくプロジェクトを作成した場合の手順通りに、診断構成を再構成します。

まとめ

Azure SDK 2.5 の Azure Diagnostics では、Event Tracing for Windows(ETW)や Azure Virtual Machines への対応が大きな機能強化ですが、アプリの開発と運用のログを分けて管理できるようになったことも大きなポイントです。今回は、Cloud Services の Diagnostics を旧バージョンから移行する方法を中心に書きましたので、ETWなどの新機能については別の記事にまとめたいと思います。