データ登録・更新を Windows Azure Web サイトの Web ジョブで行う

前回の投稿で「IPv4 アドレス割り当て先検索」な Web アプリケーションを Windows Azure Web サイト上に作成したことを書きました。このアプリケーションには「IP アドレスの割当先情報」と「国名コードと日本語表示名の対応情報」が必要なこと、またこの情報は変動するものであることから、Windows Azure Web サイトの Web ジョブ機能(2014/03/15時点ではプレビュー版)で一日一回実行するデータ取得プログラムを作成しています。Web ジョブについては「ブチザッキ」さんのサイトで詳しく説明されているので、ここでは今回作成してみた「情報を取得・加工してファイルを作成するプログラム」について書いてみます。

プログラムは、上記2種類のうち、「国名コードと日本語表示名の対応情報」を対象にします。

プログラムは、Windows Azure Web サイトの Web ジョブで実行することが前提なので、コンソール・アプリケーションの形式になります。

必要となるプログラムの機能は次のものです。

  • Web ページにアクセスして HTML 文書を取得する
  • HTML 文書から必要な情報を取得する
  • 取得した情報を扱いやすい形式でファイルに出力する
  • ログを出力する

必要な HTML ファイルはウィキペディアの ISO 3166-1 から取得できます。なお、今回記載するプログラムを試してみる場合には、動作テストで頻繁にアクセスするのは先方の負担になるので、ローカルに Web サーバーを立てて予め取得した HTML 文書をローカルな Web サーバーから取得できるようにするのが望ましいです。わたしはローカルな Web サーバーとして Microsoft WebMatrix を利用しています(テストするときだけ立ち上げればいいので 😉 )。

次に、HTML ファイルからのデータの取り出しについてです。正規表現を使って取り出す方法もありますが、今回は HTML 文書を SGMLReader を使ってパースする方法をとります(NuGet から取得できます。)。SGMLReader は Syste.Xml.XmlReader の派生クラスとして実装されているので、System.Xml.Linq.XDocument.Load メソッドを通すことで LINQ を用いたデータ操作ができます。HTML 文書の DOM ツリーを LINQ を用いて操作できるので、正規表現を用いたデータ操作ロジックを考えるより遥かに楽ができます。

次にファイルの出力について。標準出力に出して、リダイレクトでファイルに書き出しても良いのですが、サービス運用中にファイルの書き換えを行うことになるので、アクセス制御と使用中だったときのリトライを実装するために(このウェブアプリにそこまでアクセスがあるのかという話はおいておいて :mrgreen: )、プログラムから直接ファイルに書き出す方法をとります。データの形式はタブ区切りの CSV とします(区切り文字をタブ文字(国名に含まれることはまず無い文字)とすることで文字列を引用符で囲まない省力化をしています)。

次にログの出力について。標準エラー出力に出して、ファイルにリダイレクトして書き出す方法をとります。

標準エラー出力を使うので、.NET Framework 上のプログラムからの標準入力・標準出力・標準エラー出力の取り扱いについて確認してみました。Microsoft Developer Network の .NET Framework クラス ライブラリ だけでは不明な点もあるので、先日公開された Microsoft Reference Source でソースコードにもあたってみました。こういう確認ができるようになったのは素晴らしいですね 🙂

まず、前提知識として、標準入力、標準出力、標準エラー出力は、シェルがプログラムの開始・終了時にオープン・クローズを行うので、アプリケーション側で気にする必要はないです。

Stream はそれでいいとして、じゃあ Console.In, Console.Error, Console.Out プロパティで取得できる TextReader や TextWriter は? ということで、調べてみました。

TextReder, TextWriter は抽象クラスで、Console.In 等で取得できる TextReader 等の実態はスレッド・セーフにパッケージした StreamReader, StreamWriter です。そしてその StreamReader, StreamWriter が保持している Stream は OS コールで取得される標準入力、標準出力、標準エラー出力への Stream になっています(なので、オープン・クローズはシェル側で行ってくれる)。

StreamReader, StreamWriter で扱っているアンマネージリソースは Stream だけなので、Stream がちゃんと閉じられれば StreamReader, StreamWriter で Dispose メソッドが実行されなくても大丈夫ということになります。実際、Console.Write メソッド等では、標準出力等への出力などのために作成した StreamWriter 等で利用する Stream は開きっぱなしになっています(SetIn メソッド等の Stream の切り替えが行われる場合は閉じられます)。

以上で前提の話が終わったので、プログラムに入ります。
コンソール・プロジェクトを作成します(プロジェクト名は「GetCountryCodes」としています)。
最初に NuGet から「SGMLReader」をプロジェクトにインストールします(sgml で検索すると一覧に出てきます)。

次に「CountryCode」クラスを作成します。HTML 文書から取り出したデータを格納するクラスです。

namespace GetCountryCodes
{
    class CountryCode
    {
        public string Alpha2 { get; set; }
        public string Alpha3 { get; set; }
        public string Name { get; set; }
        public string Place { get; set; }
    }
}

次に「NetworkStream」クラスを作成します。

using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;

namespace GetCountryCodes
{
    class NetworkStream
    {
        public NetworkStream() { }

        public static Stream GetFile(string urlString)
        {
            var task = GetFileAsync(urlString);
            task.Wait();
            return task.Result;
        }

        public static async Task<Stream> GetFileAsync(string urlString)
        {
            var memStream = new MemoryStream();
            using (var client = new HttpClient())
            using (var response = await client.GetAsync(urlString))
            {
                if (response.IsSuccessStatusCode)
                {
                    using (var content = response.Content)
                    {
                        await content.CopyToAsync(memStream);
                    }
                }
                else
                {
                    var message = string.Format("{0} {1}", (int)response.StatusCode, response.ReasonPhrase);
                    throw new WebException(message, WebExceptionStatus.ProtocolError);
                }
            }
            memStream.Position = 0;
            return memStream;
        }
    }
}

GetFile メソッドは urlString のサイトから取得した HTML 文書を格納した MemoryStream を返します。

次に「CountryCodeUtil」クラスを作成します。

using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Xml.Linq;

using Sgml;

namespace GetCountryCodes
{
    class CountryCodeUtil
    {
        public CountryCodeUtil() { }

        public static IReadOnlyCollection<CountryCode> GetCountryCodes(Stream htmlStream)
        {
            var result = new List<CountryCode>();

            using (var sr = new StreamReader(htmlStream, Encoding.UTF8))
            {
                // HTML をパースする
                var xml = CountryCodeUtil.ParseHtml(sr);

                var table = xml.Descendants("table")
                    .Where(p => p.Attribute("class") != null && p.Attribute("class").Value == "sortable wikitable");
                var trs = table.Descendants("tr");
                foreach (var item in trs)
                {
                    var tds = item.Descendants("td");
                    if (tds.Count() != 0)
                    {
                        var tdArray = tds.ToArray();
                        result.Add(new CountryCode
                        {
                            Alpha2 = tdArray[4].Value,
                            Alpha3 = tdArray[3].Value,
                            Name = tdArray[0].Value.Trim(),
                            Place = tdArray[5].Value
                        });
                    }
                }
            }

            return result;
        }

        private static XDocument ParseHtml(TextReader reader)
        {
            using (var sgmlReader = new SgmlReader { DocType = "HTML", CaseFolding = CaseFolding.ToLower })
            {
                sgmlReader.InputStream = reader;
                return XDocument.Load(sgmlReader);
            }
        }
    }
}

GetCountryCodes メソッドは、HTML ファイルの Stream を受け取り、HTML 文書から必要なデータを抽出した CountryCode のコレクションを返します。

次に「Program.cs」です。

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Text;

namespace GetCountryCodes
{
    /// <summary>
    /// GetCounyryIp.azurewebsites.net (IPアドレスが割り当てられた国を表示するサイト)用 Web Job
    /// 国別コード一覧を取得して、サイト の App_Data フォルダに格納する
    /// 標準入力、標準出力、標準エラー出力は、シェルがプログラムの開始・終了時にオープン・クローズを行うので、アプリケーション側で気にする必要はない。
    /// </summary>
    class Program
    {
        private const string FILE_URL = "http://ja.wikipedia.org/wiki/ISO_3166-1";
        //private const string FILE_URL = "http://localhost:19011/";
        private const string FILE_PATH = @"\site\wwwroot\App_Data\countryCodes.txt";
        //private const string FILE_PATH = @"countryCodes.txt";
        private const int RETRY_LIMIT = 5;
        private static Random _random = new Random();
        private const int MIN_WAIT = 10;
        private const int MAX_WAIT = 200;

        static void Main(string[] args)
        {
            var stderr = Console.Error;
            stderr.WriteLine(DateTime.Now);
            var path = Environment.GetEnvironmentVariable("HOME");
            stderr.WriteLine("フォルダー パス: {0}", path);

            Stream stream = null;
            try
            {
                stream = NetworkStream.GetFile(FILE_URL);
                writeFile(CountryCodeUtil.GetCountryCodes(stream), path + FILE_PATH);
            }
            catch (AggregateException ae)
            {
                ae.Handle((x) =>
                {
                    if (x is HttpRequestException)
                    {
                        var inner1 = x.InnerException;
                        if (inner1 is WebException)
                        {
                            var inner2 = inner1.InnerException;
                            if (inner2 is SocketException)
                            {
                                stderr.WriteLine(inner2.Message);
                                return true;
                            }
                        }
                    }
                    stderr.WriteLine(x.Message);
                    return true;
                });
            }
            catch (Exception e)
            {
                stderr.WriteLine(e.Message);
            }
            finally
            {
                if (stream != null)
                {
                    stream.Dispose();
                }
            }
        }

        static void writeFile(IReadOnlyCollection<CountryCode> countryCodes, string outPath)
        {
            var stderr = Console.Error;
            var times = 0;
            while (times < RETRY_LIMIT)
            {
                FileStream fs = null;
                StreamWriter writer = null;
                var isBusy = false;
                try
                {
                    fs = File.Open(outPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None);
                    fs.SetLength(0);
                    writer = new StreamWriter(fs, Encoding.UTF8);
                    foreach (var n in countryCodes)
                    {
                        writer.WriteLine("{0}\t{1}", n.Alpha2, n.Name);
                    }
                    stderr.WriteLine("fs: {0}", fs.Length);
                    times = int.MaxValue;
                }
                catch (IOException e)
                {
                    if (e.HResult == -2147024864) // ERROR_SHARING_VIOLATION 0x80070020
                    {
                        ++times;
                        if (times >= RETRY_LIMIT)
                        {
                            throw new IOException("ファイルが使用中のため開くことができませんでした。");
                        }
                        isBusy = true;
                    }
                    else
                    {
                        stderr.WriteLine("{0}: {1}", e.GetType().Name, e.Message);
                        throw;
                    }
                }
                catch (Exception e)
                {
                    stderr.WriteLine("{0}: {1}", e.GetType().Name, e.Message);
                    throw;
                }
                finally
                {
                    if (writer != null)
                    {
                        writer.Dispose();
                    }
                    if (fs != null)
                    {
                        fs.Dispose();
                    }
                    if (isBusy)
                    {
                        System.Threading.Thread.Sleep(_random.Next(MIN_WAIT, MAX_WAIT));
                    }
                }
            }
        }
    }
}

コメントアウトしているのは、テスト時に使った URL とファイルパスです。

ここまででプログラムが完成です。

次に動かすためのバッチファイル「Run.bat」です。

@echo off
.\GetCountryCodes.exe 2>> %HOME%\site\GetCc.txt

これは Web ジョブ設置用に書いているので、テスト時には 2>> test.txt 等に書き換えてください。

Windows Azure Web サイト上で出力されたログファイルは FTP クライアントソフトや Kudu のコンソール機能を利用することで取得することができます。


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です