ROMANCE DAWN for the new world

Microsoft Azure を中心とした技術情報を書いています。

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

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

www.tv-tokyo.co.jp

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

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

  • Install-Package RedDog.Search

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

#Sample.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 }
    },
  (以下、省略)
]
#Program.cs
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件を部分ビューとして返却します。

#NaviController.cs
public class NaviController : Controller
{
    public ActionResult Index()
    {
        return View();
    }
 
    public async Task<ActionResult> Search(string keyword)
    {
        if (Request.IsAjaxRequest())
        {
            var geocode = await GeocodeClient.GetGeocodeAsync(keyword);
            var result = await GourmetClient.SearchAsync(new Location { lat = geocode.results[0].geometry.location.lat, lng = geocode.results[0].geometry.location.lng });
            return PartialView("_SearchResult", result);
        }
        return Content("Ajax 通信以外のアクセスはできません。");
    }
}

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

#GeocodeClient.cs
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件を部分ビューとして返却します。

#SearchClient.cs
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 アクションメソッドを呼び出して、結果を表示します。

#Index.cshtml
@{
    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 からの検索結果をバインドします。

#_SearchResult.cshtml
@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 や地図アプリと連携させれば、孤独のグルメの聖地巡礼に欠かせないアプリを作ることができそうです。

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

blog.shibayan.jp