All posts by TonyTonyKun

Azure の Storage Client と Diagnostics の旧バージョンサポート終了について

以前の記事で、Azure の Storage Client と Diagnostics の旧バージョンサポート終了に伴う移行方法を書きましたが、サポート終了日の延期が2回ほど告知されているため、改めて整理してみました。

Azure Storage Client

最新の告知では、Ver.1.7 ~ 1.5.1 が 無期限 に延期され、それ以前のバージョンが 2016年8月1日 に延期されました。当初予定されていた 2015年12月9日 にサポート終了されるバージョンはありませんが、Ver 2.0 以降で互換性が保証されない変更が行われていて移行作業が大変なので、早めの対応をお勧めします。

Azure Diagnostics

Cloud Services(Web Role, Worker Role)で診断ログを利用している場合、2015年12月9日 までに Azure SDK 2.5 以降から利用できる新しい診断ログに更新する必要があります。昨年の 12月に Azure サブスクリプション宛に届いたメールで告知されており、延期の告知はありません。Azure SDK 2.5 以降では Visual Studio 2010 を使った開発ができないため、Visual Studio 2013 or 2015 への更新が必要なケースもあります。現在、Azure SDK の最新バージョンは 2.7.1 ですが、以下の手順で移行することができます。

Azure SDK

Cloud Services(Web Role, Worker Role)では、少なくとも最新の 2 つの SDK バージョンをサポートするというサポート ポリシーがあります。Azure SDK 1.6 ~ 2.1 は 、2015年11月12日 にサポート終了しますので、診断ログを利用していなくても、新しいバージョンに移行する必要があります。

HttpClient を使って同期で通信する

HttpClient はとても使いやすいのですが、async / await の非同期処理のデッドロックにハマることがあります。

デッドロック

次のコードは、WPF におけるデッドロックの例です。

private void Button_Click(object sender, RoutedEventArgs e)
{
    var result = this.GetPersonAsync().Result;
    MessageBox.Show("Hello " + result.Name);
}

private async Task<Person> GetPersonAsync()
{
    using (var client = new HttpClient())
    {
        var response = await client.GetAsync("http://xxx.azurewebsites.net/api/person");
        var responseContent = await response.Content.ReadAsStringAsync();
        if (String.IsNullOrEmpty(responseContent))
        {
            return null;
        }
        return JsonConvert.DeserializeObject<Person>(responseContent);
    }
}

neuecc さんがブログに書かれているように、Result(Wait)と await がお互いに待機していることが原因です。対策としては次のどちらかが施されていれば回避できますが、両方とも行ったほうが良よさそうです。

  1. すべてを async / await で統一して実装する
  2. await しているメソッドに ConfigureAwait(false) を実装する

同期版のメソッドを実装する

別のアプローチとして、同期版の GetPerson メソッドを実装してみました。まず、async / await の非同期メソッドを同期処理で呼び出せるヘルパークラスを作ります。こちらのサイトから、そのままコピーしてきました。Unwrap しているところがポイントです。

internal static class AsyncHelper
{
    private static readonly TaskFactory _myTaskFactory = new TaskFactory(CancellationToken.None, TaskCreationOptions.None, TaskContinuationOptions.None, TaskScheduler.Default);

    public static TResult RunSync<TResult>(Func<Task<TResult>> func)
    {
        return AsyncHelper._myTaskFactory.StartNew<Task<TResult>>(func).Unwrap<TResult>().GetAwaiter().GetResult();
    }

    public static void RunSync(Func<Task> func)
    {
        AsyncHelper._myTaskFactory.StartNew<Task>(func).Unwrap().GetAwaiter().GetResult();
    }
}

このヘルパークラスを使って同期版の GetPerson メソッドを実装し、ボタンクリックイベントから呼び出します。この方法なら、デッドロックせずに同期で通信することができます。

private void Button_Click(object sender, RoutedEventArgs e)
{
    var result = this.GetPerson();
    MessageBox.Show("Hello " + result.Name);
}

private Person GetPerson()
{
    return AsyncHelper.RunSync<Person>(() => this.GetPersonAsync());
}

まとめ

非同期メソッドをライブラリで提供している場合、同期メソッドも提供すれば、アプリ側で Result(Wait)することもなく、デッドロックを発生させてしまうことが少なくなると思います。デットロックが起きる可能性はゼロではないので、async / await の非同期処理を正しく理解して使いこなしていく必要はあります。

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;
}

以上です。

SlideShare アカウントが一時停止された話

先日、SlideShare アカウントが一時停止されてしまい、ログインできなくなってしまいました。

Oops! Your account was suspended after violating SlideShare’s Terms of Service and/or Community Guidelines.Please read the Suspended Users FAQ to resolve this issue and log in.

LinkedIn のカスタマーサポートに問い合わせたところ、広告、スパム、マルウェアへのリンクなどの違反するコンテンツの掲載があり、利用規約の違反と判断されたとの回答でした。もう少し詳しい原因を聞いてみると、次のような回答がありました。

Due to the content associated with your “azurewebsites.net” domain, this account has been permanently restricted.For more information about the violation, please consult the link below.http://www.google.com/safebrowsing/diagnostic?site=azurewebsites.net/

Google セーフ ブラウジングで、azurewebsites.net ドメインが引っかかっているようです。

google

SlideShare にアップロードしているスライドと azurewebsites.net ドメインの関連は、このブログの URL を記載しているぐらいです。一時停止を解除してもらい、URL を削除したスライドを再アップロードしました。これが直接の原因なのかは分かりませんが、今のところログインできている状態です。

このブログは、Azure Web Apps 上に WordPress で構築しているのですが、長期的に運用していくなら独自ドメインを検討したほうがよいのかもしれません。

Azure Web Apps に Parameters.xml を使って WebDeploy する

ASP.NET MVC アプリケーションの WebDeploy パッケージを Azure Web Apps にデプロイする際に、Web.config に埋め込まれた Storage などの接続文字列を書き換えるために zip ファイルを展開する作業が非常に手間でした。

調べてみると、デプロイする際に Parameters.xml で Web.config の値を書き換えられることを知りました。この方法なら、zip ファイルを展開する必要はないですし、本番環境で使う接続文字列をソースコード管理に入れることなく、安心して開発できます。

前準備

ASP.NET MVC アプリケーションの Web.config に、Azure Storage の接続文字列を定義します。

<configuration>
  <appSettings>
    <add key="MyStorage" value="UseDevelopmentStorage=true"/>
  </appSettings>
</configuration>

プロジェクトの直下に Parameters.xml を追加し、書き換えルールを定義します。

<?xml version="1.0" encoding="utf-8" ?>
<parameters>
  <parameter name="appSettings_MyStorage" description="Azure Storage の接続文字列">
    <parameterEntry kind="XmlFile" defaultValue="UseDevelopmentStorage=true" scope="\\Web.config$" match="//appSettings/add[@key='MyStorage']/@value" />
  </parameter>
</parameters>

Visual Studio から WebDeploy パッケージを作成すると、WebApplication1.SetParameters.xml に書き換えの設定が追加されていることが分かります。

<?xml version="1.0" encoding="utf-8"?>
<parameters>
  <setParameter name="IIS Web Application Name" value="Default Web Site/WebApplication1_deploy" />
  <setParameter name="appSettings_MyStorage" value="UseDevelopmentStorage=true" />
</parameters>

この value の値を Azure Storage の接続文字列に変更すれば、Web.config を書き換えてデプロイすることができます。

コマンドからデプロイする

WebDeploy のコマンドを使って、デプロイします。「-setParamFile」の引数に SetParameters.xml のパスを渡します。ここでは、Azure Web Apps を 「deploytest」という名前で作成して、デプロイします。ユーザー名やパスワードは、Azure ポータルからダウンロードできる Publish プロファイルに記載されています。

"C:\Program Files\IIS\Microsoft Web Deploy V3\msdeploy.exe" 
	-verb:sync -source:package="C:\Users\xxx\Desktop\Test\WebApplication1.zip" 
	-dest:auto,ComputerName="https://deploytest.scm.azurewebsites.net:443/msdeploy.axd?site=deploytest",UserName='$deploytest',Password='xxx',AuthType='Basic',IncludeAcls='true'
	-enableRule:DoNotDeleteRule -setParam:"IIS Web Application Name"='deploytest' 
	-setParamFile:"C:\Users\xxx\Desktop\Test\WebApplication1.SetParameters.xml"

C# のコードからデプロイする

コマンドはどうも苦手なので、やっぱり C# でコードを書いてデプロイしたいです。以前に帝国兵さんが書かれた記事とほぼ同じですが、SetParameters.xml を渡せるように改良しました。

コンソールアプリケーションなどを作成し、WebDeploy に必要なライブラリを NuGet からインストールしておきます。

引数には、Publish プロファイル、WebDeploy パッケージ、SetParameters.xml のパスを渡します。SetParameters.xml から読み取った値を DeploymentObject のパラメーターにセットしている部分がポイントです。

public static class WebAppsPublisherHelpler
{
    public static DeploymentChangeSummary Publish(string publishSettingsPath, string sourcePath, string parametersPath)
    {
        if (String.IsNullOrEmpty(publishSettingsPath)) throw new ArgumentNullException("publishSettingsPath");
        if (String.IsNullOrEmpty(sourcePath)) throw new ArgumentNullException("sourcePath");
        if (!File.Exists(publishSettingsPath)) throw new Exception(String.Format("{0}: Not found.", publishSettingsPath));
        if (!File.Exists(sourcePath)) throw new Exception(String.Format("{0}: Not found.", sourcePath));
        if (Path.GetExtension(sourcePath).Equals(".zip", StringComparison.InvariantCultureIgnoreCase) == false) throw new Exception("Extension supports only zip.");

        // PublishSettings
        var document = XElement.Load(publishSettingsPath);
        var profile = document.XPathSelectElement("//publishProfile[@publishMethod='MSDeploy']");
        if (profile == null)
        {
            throw new Exception(String.Format("{0}: Not a valid publishing profile.", publishSettingsPath));
        }
        var publishUrl = profile.SafeGetAttribute("publishUrl");
        var userName = profile.SafeGetAttribute("userName");
        var password = profile.SafeGetAttribute("userPWD");
        var siteName = profile.SafeGetAttribute("msdeploySite");
        var webDeployServer = string.Format(@"https://{0}/msdeploy.axd?site={1}", publishUrl, siteName);

        // Set up deployment
        var destinationOptions = new DeploymentBaseOptions{ ComputerName = webDeployServer, UserName = userName, Password = password, AuthenticationType = "basic", IncludeAcls = true, TraceLevel = TraceLevel.Info };
        destinationOptions.Trace += (sender, e) =>
        {
            Trace.TraceInformation(e.Message);
        };
        var syncOptions = new DeploymentSyncOptions { DoNotDelete = true };  // Please change as you want

        DeploymentChangeSummary result;
        try
        {
            // Start deployment
            using (var deploy = DeploymentManager.CreateObject(DeploymentWellKnownProvider.Package, sourcePath, new DeploymentBaseOptions()))
            {
                // Apply package parameters
                foreach (var p in deploy.SyncParameters)
                {
                    switch (p.Name)
                    {
                        case "IIS Web Application Name":
                            p.Value = siteName;
                            break;
                        default:
                            // SetParameters.xml
                            if (!String.IsNullOrEmpty(parametersPath))
                            {
                                var parameters = XElement.Load(parametersPath);
                                var setParameter = parameters.XPathSelectElement(String.Format("//setParameter[@name='{0}']", p.Name));
                                if (setParameter != null)
                                {
                                    p.Value = setParameter.SafeGetAttribute("value");
                                }
                            }
                            break;
                    }
                }
                result = deploy.SyncTo(DeploymentWellKnownProvider.Auto, siteName, destinationOptions, syncOptions);
            }
        }
        catch (Exception)
        {
            throw;
        }
        return result;
    }

}
public static class MethodExtention
{
    public static string SafeGetAttribute(this XElement node, string attribute, string defaultValue = null)
    {
        var attr = node.Attribute(attribute);
        return attr == null ? defaultValue : attr.Value;
    }
}

まとめ

Cloud Services では、構成ファイル(cscfg)に Storage の接続文字列を設定してデプロイできますが、Web Apps でも Parameters.xml を使うと同じようにデプロイできます。業務系の Web アプリケーションであっても、Web Apps ファーストで積極的に使っていきたいと思います。