Tag Archives: ASP.NET MVC

ASP.NET でクライアントの IP アドレスを取得する

ASP.NET MVC や Web API で、クライアントの IP アドレスを取得する方法をまとめておきます。プロキシサーバーなどを経由して Web サーバーに接続された場合、HTTP ヘッダーの X-Forwarded-For から取得する必要があります。

ASP.NET MVC

サーバー環境変数から取得しているため、HTTP_X_FORWARDED_FOR がキーとなります。

public ActionResult Index()
{
    var clientIp = "";
    var xForwardedFor = Request.ServerVariables["HTTP_X_FORWARDED_FOR"];
    if (String.IsNullOrEmpty(xForwardedFor) == false)
    {
        clientIp = xForwardedFor.Split(',').GetValue(0).ToString().Trim();
    }
    else
    {
        clientIp = Request.UserHostAddress;
    }

    if (clientIp != "::1"/*localhost*/)
    {
        clientIp = clientIp.Split(':').GetValue(0).ToString().Trim();
    }

    ViewBag.Message = clientIp;
    return View();
}

ASP.NET Web API

HTTP ヘッダーの X-Forwarded-For から取得します。OWIN を使っている場合は、MS_HttpContext ではなく、MS_OwinContext の RemoteIpAddress から取得します。

public string Get()
{
    IEnumerable<string> headerValues;
    var clientIp = "";
    if (ControllerContext.Request.Headers.TryGetValues("X-Forwarded-For", out headerValues) == true)
    {
        var xForwardedFor = headerValues.FirstOrDefault();
        clientIp = xForwardedFor.Split(',').GetValue(0).ToString().Trim();
    }
    else
    {
        if (ControllerContext.Request.Properties.ContainsKey("MS_HttpContext"))
        {
            clientIp = ((HttpContextWrapper)ControllerContext.Request.Properties["MS_HttpContext"]).Request.UserHostAddress;
        }
    }

    if (clientIp != "::1"/*localhost*/)
    {
        clientIp = clientIp.Split(':').GetValue(0).ToString().Trim();
    }
    return clientIp;
}

WCF

おまけです。WCF の場合は、HttpRequestMessageProperty から HTTP ヘッダーの情報を取得できます。

public string GetData(int value)
{
    var clientIp = "";
    var properties = OperationContext.Current.IncomingMessageProperties;
    var httpRequestMessage = properties[HttpRequestMessageProperty.Name] as HttpRequestMessageProperty;
    if (httpRequestMessage.Headers["X-Forwarded-For"] != null)
    {
        var xForwardedFor = httpRequestMessage.Headers["X-Forwarded-For"];
        clientIp = xForwardedFor.Split(',').GetValue(0).ToString().Trim();
    }
    else
    {
        var remoteEndpointMessage = properties[RemoteEndpointMessageProperty.Name] as RemoteEndpointMessageProperty;
        clientIp = remoteEndpointMessage.Address;
    }

    if (clientIp != "::1"/*localhost*/)
    {
        clientIp = clientIp.Split(':').GetValue(0).ToString().Trim();
    }
    return clientIp;
}

以上です。

ASP.NET MVC で JSON の一部として PartialView を返す方法

前回の記事では、Ajax 通信でページを部分更新する際の例外処理について記載しましたが、それに関連した内容です。

ASP.NET MVC 5 の Ajax 通信で PartialView を返す際に、JSON の一部として返したいケースがありました。例外処理と似たような感じで、Exception が発生した際にエラーページに遷移するのではなく、メッセージボックスで通知したいシナリオです。元ネタの実装とほとんど同じなのですが、自分なりに整理してみました。

ベースとなる抽象コントローラークラスを用意して、PartialView を含む JSON を返すヘルパーメソッドを実装します。部分ビュー名、バインドするモデル、エラーメッセージを受け取って、JsonResult を返します。

public abstract class MyBaseController : Controller
{
    protected JsonResult PartialViewAsJson(string viewName)
    {
        return PartialViewAsJson(viewName, null, null);
    }

    protected JsonResult PartialViewAsJson(string viewName, string exceptionMessage)
    {
        return PartialViewAsJson(viewName, null, exceptionMessage);
    }

    protected JsonResult PartialViewAsJson(string viewName, object model)
    {
        return PartialViewAsJson(viewName, model, null);
    }

    protected JsonResult PartialViewAsJson(string viewName, object model, string exceptionMessage)
    {
        ViewData.Model = model;
        var viewAsString = "";
        using (var sw = new StringWriter())
        {
            var viewResult = ViewEngines.Engines.FindPartialView(ControllerContext, viewName);
            var viewContext = new ViewContext(ControllerContext, viewResult.View, ViewData, TempData, sw);
            viewResult.View.Render(viewContext, sw);
            viewAsString = sw.GetStringBuilder().ToString();
        }
        return Json(new { partialView = viewAsString, message = exceptionMessage });
    }
}

MyBaseController クラスを継承した HomeController クラスでは、PartialView ヘルパーメソッドではなく、上記で実装した PartialViewAsJson ヘルパーメソッドで ActionResult を返します。

public class HomeController : MyBaseController
{
	[HttpPost]
	public ActionResult AjaxTest()
	{
	    if (Request.IsAjaxRequest())
	    {
	        try
	        {
	            return PartialViewAsJson(viewName: "_AjaxResult");
	        }
	        catch (Exception ex)
	        {
	            return PartialViewAsJson(viewName: "_AjaxResult", exceptionMessage: ex.Message);
	        }
	    }
	    return Content("Ajax 通信以外のアクセスはできません。");
	}
}

ビューでは、 Ajax 通信のレスポンスの JSON を受け取り、エラーメッセージがあれば alert を表示し、なければ部分ビューを表示します。

@section Scripts
{
    @Scripts.Render("~/bundles/jqueryval")
    <script>
        $(function () {
            $('#test').click(function () {
                $.ajax({
                    type: 'POST',
                    url: 'AjaxTest'
                })
                .done(function (data, textStatus, jqXHR) {
                    if (data.message != null) {
                        alert(data.message);
                    }
                    else {
                        $('#ajax-result').html(data.partialView);
                    }
                })
                .fail(function (jqXHR, textStatus, errorThrown) {
                    if (jqXHR.status == 401) {
                        $(location).attr("href", "/Account/Login/");
                    }
                    else {
                        $(location).attr("href", "/Error/AjaxError/");
                    }
                });
            })
        })
    </script>
}

Ajax 通信のレスポンスでは、次のような JSON が返されていることが確認できます。

ajax03

まとめ

ASP.NET MVC で、デスクトップアプリケーションに似たデザインの UI を作るとなると、Ajax 通信でページを部分更新するケースは多くなります。その際に、JSON の一部として PartialView を返すことができると、意外と便利なケースがあるのではないかと思います。

ASP.NET MVC の Ajax 通信で例外を処理する

ASP.NET MVC 5 の Ajax 通信でページを部分更新する際に、どのように例外を処理すべきかを悩んだので、まとめておきます。

現象

Ajax 通信で PartialView を返すアプリをシンプルなコードで実装します。

public class HomeController : Controller
{
        public ActionResult Ajax()
        {
            ViewBag.Message = "Ajax Test page.";
            return View();
        }

        [HttpPost]
        public ActionResult AjaxTest()
        {
            if (Request.IsAjaxRequest())
            {
                return PartialView("_AjaxResult");
            }
            return Content("Ajax 通信以外のアクセスはできません。");
        }
}
<h3 class="text-success">Ajax 通信で返却された部分ビュー</h3>
<h2>AjaxTest</h2>

@Html.TextBox("test", "Test", new { type = "button", @class = "btn btn-default" })
<div id="ajax-result"></div>

@section Scripts
{
    @Scripts.Render("~/bundles/jqueryval")
    <script>
        $(function () {
            $('#test').click(function () {
                $.ajax({
                    type: 'POST',
                    url: 'AjaxTest'
                })
                .done(function (data, textStatus, jqXHR) {
                    $('#ajax-result').html(data);
                });
            })
        })
    </script>
}

Test ボタンを押すと、「/Home/AjaxTest」にリクエストが送信され、ページが部分更新されます。

ajax01

例えば、Form 認証でタイムアウトが発生すると、部分ビューの領域にログインページが表示されてしまいます。

ajax02

また、コントローラーのアクションメソッドで Exception が発生しても、エラーページには遷移しません。

対応

Form 認証でタイムアウトが発生した際にログインページへ遷移されるのは、Web.config で「/Account/Login/」へのリダイレクトを設定しているからです。Ajax 通信の場合は、リダイレクトされたログインページを表示する領域が部分ビューになっているため、上記の現象が発生していました。そのため、Webconfig の設定に頼らずに、View から Ajax 通信したリクエストが返ってきたタイミングでステータスコードを判断して、リダイレクトする方法を取りました。

@section Scripts
{
    @Scripts.Render("~/bundles/jqueryval")
    <script>
        $(function () {
            $('#test').click(function () {
                $.ajax({
                    type: 'POST',
                    url: 'AjaxTest'
                })
                .done(function (data, textStatus, jqXHR) {
                    $('#ajax-result').html(data);
                })
                .fail(function (jqXHR, textStatus, errorThrown) {
                    if (jqXHR.status == 401) {
                        $(location).attr("href", "/Account/Login/");
                    }
                    else {
                        $(location).attr("href", "/Error/AjaxError/");
                    }
                });
            })
        })
    </script>
}

Ajax 通信の場合は、Location ヘッダーにログインページの URL が指定された HttpStatusCode.Found(302)ではなく、HttpStatusCode.Unauthorized(401)を返して欲しいので、前回の投稿で書いた SuppressFormsAuthenticationRedirect プロパティを設定します。

public class HomeController : Controller
{
        protected override void Initialize(RequestContext requestContext)
        {
            if (requestContext.HttpContext.Request.Headers["X-Requested-With"] == "XMLHttpRequest")
            {
                requestContext.HttpContext.Response.SuppressFormsAuthenticationRedirect = true;
            }
            base.Initialize(requestContext);
        }
}

ログインページへのリダイレクトについては、ここまでの実装で対応できますが、エラーページの遷移にも対応させるため、Ajax 通信用のアクションメソッドを追加します。

[AllowAnonymous]
public class ErrorController : Controller
{
    public ActionResult AjaxError()
    {
        return View("Error");
    }
}

まとめ

部分更新ではない通常のページは、Web.config のリダイレクトの設定だけで Form 認証のタイムアウトに対応できますし、HandleErrorAttribute のフィルター属性を全体に適用することで、エラーページに遷移させることができます。

Ajax 通信でページを部分更新する際には、クライアント(View)側で個別に対応しなくてはならないのが面倒ですが、仕方ないのかもしれません。やり方はいろいろあると思いますので、ひとつの例として参考までに。

SuppressFormsAuthenticationRedirect プロパティが便利だった件

.NET Framework 4.5 から追加された SuppressFormsAuthenticationRedirect プロパティが便利でした。フォーム認証を設定している ASP.NET MVC アプリと同じプロジェクトに ASP.NET Web API を実装した場合、Web API の Controller のアクションメソッドで HttpStatusCode.Unauthorized(401)を返しても、クライアント側には Location ヘッダーにログインページの URL が設定された HttpStatusCode.Found(302)に書き変えられた結果が返されます。Web API でこの動作だと都合が悪いときには、SuppressFormsAuthenticationRedirect プロパティに true をセットすると、HttpStatusCode.Unauthorized(401)を返すことができます。

前準備

Visual Studio から、ASP.NET Web API のプロジェクトを作成し、MVC アプリケーションにフォーム認証を設定します。

まず、HomeController クラスに Authorize 属性を付加します。

[Authorize]
public class HomeController : Controller
{
    public ActionResult Index()
    {
        return View();
    }
}

Web.config でフォーム認証を有効にします。

  <system.web>
    <authentication mode="Forms">
      <forms loginUrl="/Account/Login/" />
    </authentication>
  </system.web>

ログインとログアウトのアクションメソッドをもつ AccountController クラスと ビュー を実装します。

[AllowAnonymous]
public class AccountController : Controller
{
    public ActionResult Login()
    {
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Login(string model)
    {
        FormsAuthentication.SetAuthCookie("Guest", false);
        return RedirectToAction("Index", "Home");
    }

    public ActionResult Logout()
    {
        FormsAuthentication.SignOut();
        return RedirectToAction("Login", "Account");
    }
}
@{
    ViewBag.Title = "Login";
}

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    <div class="form-group">
        <input type="submit" value="Login" class="btn btn-default" />
    </div>
}

HttpStatusCode.Unauthorized(401)を返す Web API の TestController クラスを実装します。

public class TestController : ApiController
{
    [Route("api/test")]
    public IHttpActionResult Get()
    {
        return StatusCode(HttpStatusCode.Unauthorized);
    }
}

結果確認

プログラムを実行し、IE の F12 開発者ツールでキャプチャしてみます。GET api/test をリクエストすると、HttpStatusCode.Found(302)が返されてログインページにリダイレクトされていることが分かります。

302

SuppressFormsAuthenticationRedirect プロパティに true をセットするように変更し、再度プログラムを実行します。

public class TestController : ApiController
{
    [Route("api/test")]
    public IHttpActionResult Get()
    {
        HttpContext.Current.Response.SuppressFormsAuthenticationRedirect = true;
        return StatusCode(HttpStatusCode.Unauthorized);
    }
}

今度は、HttpStatusCode.Unauthorized(401)が返されるようになりました。

401

まとめ

いろいろと調べていると、FormsAuthenticationModule に書き換えられたステータスをカスタムモジュールで再度書き換える方法もありました。

.NET Framework 4.5 以降であれば、SuppressFormsAuthenticationRedirect プロパティを使うのが便利そうです。

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

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