2011年1月19日水曜日

WebMatrixでAmazon検索

今回はAmazon.co.jpの商品を検索するためのHelperを作ってみたよ。

今まで同様、WebMatrixでスターターサイトがある前提で。今まで作ったヘルパーもちょいちょい拡張しつつ載せていきます。

~/App_Code/Utility.cshtml

@functions {
    public static new dynamic Cache(string key, int expireSeconds, Func<dynamic> thunk){
        var context = new HttpContextWrapper(HttpContext.Current);
        return Cache(context, key, expireSeconds, thunk);
    }
    
    public static new dynamic Cache(HttpContextBase context, string key, int expireSeconds, Func<dynamic> thunk){
        dynamic value = context.Cache[key];
        if(value == null) {
            value = thunk();
            context.Cache.Insert(
                key,
                value,
                null,
                DateTime.Now.AddSeconds(expireSeconds),
                System.Web.Caching.Cache.NoSlidingExpiration
            );
        }
        
        return value;
    }

    public static string WebRequest(Uri uri)
    {
        string response;
        using (var client = new WebClient())
        {
            client.Encoding = System.Text.Encoding.UTF8;
            response = client.DownloadString(uri);
        }
        return response;
    }
}

UtilityヘルパーにはキャッシュだけじゃなくてHTTP GETを実行するメソッドを追加してます。なので郵便番号検索なんかもこっちを呼ぶようにリファクタリングしたほうがいいね。

~/App_Code/Aws.cshtml

@using System.Text
@using System.Security.Cryptography
@using System.Web.Routing;
@using System.Xml.Linq;
@using System.Dynamic;

@functions {

    public static class AwsSetting
    {
        public static XNamespace Namespace = "http://webservices.amazon.com/AWSECommerceService/2010-11-01";
        public static readonly string Host = "ecs.amazonaws.jp";
        public static readonly string Path = "/onca/xml";
        public static readonly string Service = "AWSECommerceService";
        public static readonly string Version = "2010-11-01";

        public static readonly string AWSAccessKeyId = "アクセスキーID";
        public static readonly string SecretAccessKey = "シークレットアクセスキー";
    }

    /*
     * Percent-encode (URL Encode) according to RFC 3986 as required by Amazon.
     * 
     * This is necessary because .NET's HttpUtility.UrlEncode does not encode
     * according to the above standard. Also, .NET returns lower-case encoding
     * by default and Amazon requires upper-case encoding.
     */
    private static string PercentEncodeRfc3986(string str)
    {
        str = HttpUtility.UrlEncode(str, Encoding.UTF8);
        str = str.Replace("'", "%27").Replace("(", "%28").Replace(")", "%29").Replace("*", "%2A").Replace("!", "%21").Replace("%7e", "~").Replace("+", "%20");

        StringBuilder sbuilder = new StringBuilder(str);
        for (int i = 0; i < sbuilder.Length; i++)
        {
            if (sbuilder[i] == '%')
            {
                if (Char.IsLetter(sbuilder[i + 1]) || Char.IsLetter(sbuilder[i + 2]))
                {
                    sbuilder[i + 1] = Char.ToUpper(sbuilder[i + 1]);
                    sbuilder[i + 2] = Char.ToUpper(sbuilder[i + 2]);
                }
            }
        }
        return sbuilder.ToString();
    }

    public static string GenerateSettingQueryString(RouteValueDictionary parameters)
    {
        var pairs = new SortedDictionary<string, string> { 
            {"AWSAccessKeyId",AwsSetting.AWSAccessKeyId},
            {"Service",AwsSetting.Service},
            {"Version",AwsSetting.Version}
        };
        parameters["Timestamp"] = DateTime.Now.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.000Z");
        foreach (var kv in parameters)
            pairs.Add(kv.Key, (string)kv.Value);

        var qs = from kv in pairs
                 select string.Format("{0}={1}", kv.Key, PercentEncodeRfc3986(kv.Value));
        return string.Join("&", qs);
    }

    public static Uri GenerateUri(object parameters)
    {
        return GenerateUri(new RouteValueDictionary(parameters));
    }
    
    public static Uri GenerateUri(RouteValueDictionary parameters)
    {
        var values = GenerateSettingQueryString(parameters);
        var unsigned = string.Join("\n", new[] { "GET", AwsSetting.Host, AwsSetting.Path, values });
        
        byte[] key = Encoding.UTF8.GetBytes(AwsSetting.SecretAccessKey);
        byte[] data = Encoding.UTF8.GetBytes(unsigned);
        var hmac = new HMACSHA256(key);
        var hash = hmac.ComputeHash(data);
        var signature = PercentEncodeRfc3986(Convert.ToBase64String(hash));

        return new Uri(string.Format("http://{0}{1}?{2}&Signature={3}", AwsSetting.Host, AwsSetting.Path, values, signature));
    }

    private static dynamic DynamicExpandoObject(Action<dynamic> init)
    {
        dynamic model = new ExpandoObject();
        init(model);
        return model;
    }
    
    private static string ElementValue(XElement parent, string name)
    {
        var ns = AwsSetting.Namespace;
        if (parent == null)
            return string.Empty;

        var element = parent.Element(ns + name);
        if (element == null)
            return string.Empty;

        return element.Value;
    }
    
    public static IEnumerable<dynamic> ItemSearch(string keywords)
    {
        var uri = GenerateUri(new { 
            Operation = "ItemSearch", 
            SearchIndex = "Books", 
            Keywords = keywords 
        });
        var response = Utility.WebRequest(uri);
        var ns = AwsSetting.Namespace;
        var xml = XDocument.Parse(response).Descendants(ns + "Item");
        
        return from item in xml
               let attrs = item.Element(ns + "ItemAttributes")
               select DynamicExpandoObject(model =>
               {
                   model.ASIN = ElementValue(item,"ASIN");
                   model.DetailPageURL = ElementValue(item, "DetailPageURL");
                   model.Author = ElementValue(attrs, "Author");
                   model.Manufacturer = ElementValue(attrs, "Manufacturer");
                   model.Title = ElementValue(attrs, "Title");
               });
    }
}

サンプル実装を参考にした(というか、もうそのまま持ってきた)のはUrlEncodeするコードだけで。RFC3986に準拠させるための文字列置換だからロジックなんて無いのでいいかなと思いまして。後はちゃんとスクラッチ。無駄にスクラッチ。いや、もう、ホント、だからなに?

アクセスキーIDとシークレットアクセスキーは自分でAmazonから取得したものを使ってね!

取得の仕方にかなり戸惑ったけど、AWSに登録してアカウントページからセキュリティ証明書のページにいけば載ってたよ。APIの詳細はDocumentation Archive : Amazon Web Servicesからドキュメントダウンロードして確認しつつ作ったけど、改行コードの部分が"\n"だけでいいことに気がつくのにすげー時間かかった。

とりあえず、BooksカテゴリのItemSearch APIだけを実装してるけど、いくらでも追加しやすいように作ったから、他のAPIも実装してみるといいかも。あと今回はキャッシュも使ってないけど、キャッシュ組み込みはしたほうがいいかな~、どうかな~。同一キーワードで何度も検索する必要がほとんど無い、よね?

~/amazon.cshtml

@{  
    Layout = "~/_SiteLayout.cshtml";
    Page.Title = "Amazon書籍検索";
}
<form method="post">
    キーワード:
    @Html.TextBox("keywords")
    <input type="submit" value="検索" />
</form>

@if (IsPost)
{
    var keywords = Request["keywords"];
    <ul>
    @foreach (dynamic item in Aws.ItemSearch(keywords)) { 
        <li>
            <a href="@item.DetailPageURL">@item.Title</a>
            <div>@item.Author / @item.Manufacturer</div>
        </li>
    }
    </ul>
}

んで、表示ページのコード。特に変わったことはしてないです。

“ONE PIECE”と入れて実行したのが↓コレ。

aws

もちろんASINでも検索できる。API仕様がそうだから。

"ワンピース"で検索してみる。

aws2

ちゃんと動いてるね。よしよし。これで4 Helpers。あ、WebRequestをヘルパーにしたから5個ってことでいいっすかね。

もちろんASP.NET MVCでも動くよ。

ちなみにヘルパーはロジックが入るので、Visual Studio 2010で開発したほうが楽かも。使う分にはコピペしちゃえばいいからWebMatrixで十分かな?

今の気持ちを表現するならこれしかないね!

20100105_1650081

すません...。

2011年1月16日日曜日

WebMatrixのWebPagesで拡張子が無くてもアクセス出来る理由

タイトル長い。地獄のミサワネタが他人ごとに思えない今日この頃。

WebMatrixでごそごそ遊んでると、何気にページアクセスする際の拡張子指定が無くてもいけることに気が付きますよね。

スターターサイトで試すよー。みんなー、用意はいいかーい?ちなみにWebPagesのソースコードを追いかけてるのでチャラさが足りないエントリですいません。

例えば以下のページ。アドレスバーには”/default.cshtml”って入れて表示した場合ですね。普通です。

ext1

続いて"/default"。同じように表示されますね。拡張子cshtmlは指定してないですね。

ext2

なぜでしょ~か?答えはソースコード中にあり。System.Web.WebPages\WebPageHttpHandler.csとSystem.Web.WebPages\WebPageRoute.csを覗いてみましょう。

WebPageHttpHandlerにGetRegisteredExtensions()とRegisterExtension()が有りまして、これが拡張子を登録したり取り出したりできるやつです。んで、WebPageRouteのほうでそれを見つつ、VirtualPathProvider経由でファイルの存在確認をして、無ければ補完するという処理をしています。これらHttpHandlerとRouteの指定はPreApplicationStartCodeだったり、WebPageHttpModuleだったりです。詳しくはDeep Diveになっちゃうのでソースを見てみるといいかな~、なんて思います。

ソース見ただけじゃよくわんな~い、というかわいこちゃんにお勧めなのがテストプロジェクトを動かしてみるという方法。VS2010必要だけどね。System.Web.WebPages.Test.WebPageRouteTestを眺めつつ、デバッグ実行して追いかけてみると、挙動が分かりやすくていいです。なぜProviderが必要なのか、なぜDIなのか、なんていうのもテストするとよく分かりますよね。

ext7

DynamicModuleUtility.RegisterModuleて誰やねん!的な。ソースないし。まぁ、普通にASP.NETの仕組みだということです。

ext6

つまり、cshtmlとvbhtmlはURLに指定しなくても勝手に補完してくれるので、安心して省略してしまってもいいですよ、っていうことです。VirtualPathProviderがちゃんとキャッシュしてくれてるので2回目以降はちょっと早そう。そのくらい誤差の範囲でネットのほうが桁違いに遅いんだしいいんじゃないの、って思うけど妥協無しデス。

続きまして、ちょっと変わり種。今度は”/”だけ。それでもちゃんと表示されますね。

ext3

なぜかというと、これもまた補完機能が働くからです。今度はどこで?いったい誰が!?

WebPageRouteです。一緒です。はい。

ファイル名指定が無い場合(サブフォルダも含むよ)、”default”→”index”の順に補完しつつ、拡張子も”cshtml”→”vbhtml”で補完されることで見つかります。

試しに以下のように”~/index.cshtml”を作成してみましょう。

<!DOCTYPE html>

<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>インデックス</title>
    </head>
    <body>
        <h1>index.cshtmlなんだぜ!</h1>
    </body>
</html>

“/index”と入れてアクセスすると普通に見れます。

ext4

この状態で、"default.cshtml”を違うファイル名変えて見ましょう。何でもいいです。

んで、"/"にアクセスすると、ちゃんと”default.cshtml”じゃなくて”index.cshtml”の内容が表示されました。

ext5

つまり、初期表示したいファイル名はdefault.cshtml(vbhtml)かindex.cshtml(vbhtml)固定ということですよ。もちろんRoutingとかに手を出せばいけるかもしれないけど、どーだろね。タイミング的に間に合わないかもね。相手はModuleだし。

Thoughts on WebMatrix

そんなことを思ってたら上記エントリを発見。そうだね。URL Rewriteモジュール使えば何とでもなるね、きっと。たぶん処理も間に合う気がする。試してないけど。

WebMatrixでサイトを作成する場合、こんな仕組みで動くのを覚えておくといいと思うよ~。

20100218_1708335

@functionsと@helperの組み合わせ

いろいろ作りたい物があったりするんだけど、お試しする時間も必要でしょう。前回までで@functionsと@helperそれぞれを作ってみたんですが、今回はそれを組み合わせたサンプル。

もちろんASP.NET MVC3でもWebMatrixでも動くわけです。

最初に作ったキャッシュヘルパーにHttpContextBaseを渡さない版のオーバーロードを用意したので、まずはそちらを再掲。

~/App_Code/Utility.cshtml

@functions {
    public static new dynamic Cache(string key, int expireSeconds, Func<dynamic> thunk){
        var context = new HttpContextWrapper(HttpContext.Current);
        return Cache(context, key, expireSeconds, thunk);
    }
    
    public static new dynamic Cache(HttpContextBase context, string key, int expireSeconds, Func<dynamic> thunk){
        dynamic value = context.Cache[key];
        if(value == null) {
            value = thunk();
            context.Cache.Insert(
                key,
                value,
                null,
                DateTime.Now.AddSeconds(expireSeconds),
                System.Web.Caching.Cache.NoSlidingExpiration
            );
        }
        
        return value;
    }
}

続いて、このキャッシュヘルパーを使うものを考えてみたんだけど、郵便番号検索なんてどうかな~。

ゆうびんホームページ - 日本郵便

↑ここにある検索フォームを使って、返ってくるHTMLを解析だ!なんてことをやろうとするとちょっとコード量もかさばる(正規表現でtd class=”data”とればいいだけなんだけど)し、今風じゃない。とてもグレートな郵便番号検索サービスを見つけたのでそちらを使ってみます。

郵便番号データのダウンロード – zipcloud

Google App Engine上にホストされてて、日本郵便のデータを綺麗に正規化してくれてるグレートサービスです。一応規約はチェックしてみたけど、使ってみる人も確認してみてね!エロとかグロとかダメだからね!

ここのサービス実際にはJSONPでも取得できるのでクライアントサイドのテクノロジだけで見事に完結してしまうわけですが、そこをあえてサーバーサイドで行ってみよう(今風じゃないね...)。

~/App_Code/ZipCroud.cshtml

@functions{    
    private readonly static string _api = "http://zipcloud.ibsnet.co.jp/api/search?zipcode=";
    public static dynamic Search(string zipCode){
        var key = string.Format("zipcroud:{0}",zipCode);
        var expire = 60 * 60 * 24; // 1日
        return Utility.Cache(key,expire,()=>{
            var url = string.Format("{0}{1}",_api, zipCode);
            string response;
            using (var client = new WebClient()){
                client.Encoding = System.Text.Encoding.UTF8;
                response = client.DownloadString(url);
            }
            return Json.Decode(response);
        });
    }
}

@helper Render(string zipCode){
    var sw = new System.Diagnostics.Stopwatch();
    sw.Start();
    var result = ZipCroud.Search(zipCode);
    sw.Stop();
    <div>@zipCode<text>の検索結果(@sw.ElapsedMilliseconds ms)</text></div>
    if (result == null || result.status != 200 || result.results == null) {
        @:見つかりませんでした。
    } else {
        <ul>
        @foreach(var zip in result.results){
            <li>@zip.address1 @zip.address2 @zip.address3</li>
        }
        </ul>
    }
}

とにかく↑これをコピペしてZipCroud.cshtmlをApp_Codeフォルダ配下に作成だぜ!

データを取得するSearch(こっちが@functions)と、レンダリングするRender(こっちが@helper)。要するに表示に関するものの場合は@helperにしとけばよろしっていう感じです(んじゃApp_Codeに入れるなよ、っていうツッコミは勘弁してね)。

Json.Decodeヘルパーとか超絶便利なヘルパーが標準搭載されてるので、楽チンぽんですよ。

続いてページの方。こっちはもちろんWebMatrixを使ってWebPagesとして作ります。

~/zip.cshtml

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8" />
        <title>郵便番号検索</title>
    </head>
    <body>
    郵便番号を入れてみてね!
    <form action="zip" method="post">
        <input type="text" name="zipCode" />
        <input type="submit" value="検索" />
    </form>
    @if(IsPost){
        var zipCode = Request.Form["zipCode"];
        @ZipCroud.Render(zipCode)
    }
    </body>
</html>

IsPostとかRequest["zipCode"]っていう部分が気になるけど、まぁ、いいでしょう。MVCの時はちゃんとModelBindしてね。

zip1

起動するとそっけないページが出てきました。

zip2

例えば"0288401"が複数の住所を保持しているらしいので、これを入力した結果が↑これ。

いっぱい出てきた。

ここからキャッシュヘルパーの威力を発揮します。画像拡大してみると分かるんだけど、検索結果の横に634msと表示されてるのが分かると思います。これは、まぁ、だいたいブレるんでこの値そのものにはそれほど重要な意味はないんですよ。なんとなく3桁ミリ秒くらいかかるんだね、っていうのが伝わればそれで良し。

ここのページはPOSTで値を送信してるので、F5を押すか同一の郵便番号を押して再度検索してみましょう。

zip3

今度は驚異の1ms!

そりゃそうですよ。なんせ実際には検索しに行かないでキャッシュから結果を取り出してるだけなんだから。今回はちなみに1日ほどキャッシュするようにしてみました。実際にはAppDomainの再起動なんかでもキャッシュはクリアされるので、こんなもんでしょう。郵便番号の値なのでそれほど頻繁に更新されるものでもないしね!

郵便番号毎にキャッシュするようにしてるので、一度検索してしまうと2回目以降はちょっぱや。

そして、ヘルパー三連投するオレ。つまりこういうことだよ。

20100609_1842417

2026年06月10日の記事一覧

(全 16 件) 安全な“Mythos”級AI「Claude Fable 5」公開 [ITmedia News] Google、同時通訳に近い音声モデル「Gemini 3.5 Live Translate」発表 「Google翻訳」アプリなどで利用可能に ...