2011年12月5日月曜日

リストボックスを引っ張って更新

スマートフォンでSNS系のアプリを使っていて
「更新ボタン」を押しているとなんとなく未来感がない。
Twitterの公式アプリ等はそうだが、上側に引っ張るとリストが更新される。

WindowsPhoneでもこれを行なってみようと思ったら

高橋忍氏のブログや
その書籍プログラミングWindowsPhoneでも紹介されています。

んじゃ書く必要ないじゃん。と思いましたけど少し書いてみます。


スタイルを変更
ListBoxの中身はScrollViewerで構成されているので、 ScrollViewerのVerticalCompressionというVisualStateGroupを利用して ListBoxの状態を把握しようってことですね。 上記のブログにはPageごとの設定とありますけど 私的にはアプリケーション内で指定したいのでApp.xmlにApplication.Resoucesとして定義しています。
    <Application.Resources>
        <Style TargetType="ScrollViewer">
            <Setter Property="VerticalScrollBarVisibility" Value="Auto"/>
            <Setter Property="HorizontalScrollBarVisibility" Value="Disabled"/>
            <Setter Property="Background" Value="Transparent"/>
            <Setter Property="Padding" Value="0"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="BorderBrush" Value="Transparent"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="ScrollViewer">
                        <Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}">
                            <VisualStateManager.VisualStateGroups>
                                <VisualStateGroup x:Name="ScrollStates">
                                    <VisualStateGroup.Transitions>
                                        <VisualTransition GeneratedDuration="00:00:00.5"/>
                                    </VisualStateGroup.Transitions>
                                    <VisualState x:Name="Scrolling">
                                        <Storyboard>
                                            <DoubleAnimation Duration="0" To="1" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="VerticalScrollBar"/>
                                            <DoubleAnimation Duration="0" To="1" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="HorizontalScrollBar"/>
                                        </Storyboard>
                                    </VisualState>
                                    <VisualState x:Name="NotScrolling"/>
                                </VisualStateGroup>
                                <VisualStateGroup x:Name="VerticalCompression">
                                    <VisualState x:Name="NoVerticalCompression"/>
                                    <VisualState x:Name="CompressionTop"/>
                                    <VisualState x:Name="CompressionBottom"/>
                                </VisualStateGroup>
                            </VisualStateManager.VisualStateGroups>
                            <Grid Margin="{TemplateBinding Padding}">
                                <ScrollContentPresenter x:Name="ScrollContentPresenter" ContentTemplate="{TemplateBinding ContentTemplate}" Content="{TemplateBinding Content}"/>
                                <ScrollBar x:Name="VerticalScrollBar" HorizontalAlignment="Right" Height="Auto" IsHitTestVisible="False" IsTabStop="False" Maximum="{TemplateBinding ScrollableHeight}" Minimum="0" Opacity="0" Orientation="Vertical" Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}" Value="{TemplateBinding VerticalOffset}" ViewportSize="{TemplateBinding ViewportHeight}" VerticalAlignment="Stretch" Width="5"/>
                                <ScrollBar x:Name="HorizontalScrollBar" HorizontalAlignment="Stretch" Height="5" IsHitTestVisible="False" IsTabStop="False" Maximum="{TemplateBinding ScrollableWidth}" Minimum="0" Opacity="0" Orientation="Horizontal" Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}" Value="{TemplateBinding HorizontalOffset}" ViewportSize="{TemplateBinding ViewportWidth}" VerticalAlignment="Bottom" Width="Auto"/>
                            </Grid>
                        </Border>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </Application.Resources>
この記述によって、アプリケーションのすべてのScrollViewerで圧縮した場合のイベントの発生が可能になります。 ※私には縦の必要がないのでVerticalのみです。
ListBoxから取り出してイベントを登録
上記ブログにある通り(そのままのコード)ListBoxからScrollViewerを取り出して 変更があったというイベントに登録します。
        private void ListBoxCompressionHandling(ListBox targetlistbox)
        {
            VisualStateGroup vgroup = new VisualStateGroup();

            // ListBox の初めに定義されている ScrollViewerを取り出す 
            ScrollViewer ListboxScrollViewer = (ScrollViewer)VisualTreeHelper.GetChild(targetlistbox, 0);

            // Visual State はコントロールテンプレートの常に最上位に定義されている 
            FrameworkElement element = (FrameworkElement)VisualTreeHelper.GetChild(ListboxScrollViewer, 0);
            // Visual State を取り出しその中から 縦横Compression のVisualStateを取り出す 
            foreach (VisualStateGroup group in VisualStateManager.GetVisualStateGroups(element))
                if (group.Name == "VerticalCompression") vgroup = group;

            //縦横Compressionの状態が変わった時のイベントハンドラ 
            vgroup.CurrentStateChanging += new EventHandler<VisualStateChangedEventArgs>(ScrollViewer_CurrentStateChanging);
        } 
でイベントは
        void ScrollViewer_CurrentStateChanging(object sender, VisualStateChangedEventArgs e)
        {
            switch (e.NewState.Name) 
            { 
                case "CompressionTop":
                    break; 
                case "CompressionBottom": 
                    break;
                case "NoVerticalCompression":
                    break; 
                default: 
                    break; 
            } 
        }
で、この関数をLoadなどのイベントで呼び出すのですが Loadは何度も呼び出されるので、画面遷移が多いような画面だったら重複して登録してしまうので 一度だけ登録するようにしておくのが良いでしょう。
で何がしたいか?
これだと高橋忍氏のブログそのまま(アプリで登録した位の違い)です。 私的にはスクロールを「グッ」とした時にだけ、更新をしたいのです。 このままだと少しでも上にするだけでイベントが発生します。 んじゃ、圧縮イベントと圧縮が終わったイベントの時刻で処理してみよう!
アプローチ
        DateTime startTopTime = new DateTime(0);
        DateTime startBottomTime = new DateTime(0);
        DateTime endTime = new DateTime(0);
        private void InitCompression()
        {
            startTopTime = new DateTime(0);
            startBottomTime = new DateTime(0);
            endTime = new DateTime(0);
        }
        private Boolean IsCompressionTop() {
            if (!startTopTime.Equals(new DateTime(0)))
            {
                TimeSpan ts = endTime.Subtract(startTopTime);
                System.Diagnostics.Debug.WriteLine(ts.TotalSeconds);
                if (ts.TotalSeconds >= 0.9)
                {
                    return true;
                }
            }
            return false;
        }

        void ScrollViewer_CurrentStateChanging(object sender, VisualStateChangedEventArgs e)
        {
            switch (e.NewState.Name) 
            { 
                case "CompressionTop":
                    startTopTime = DateTime.Now;
                    break; 
                case "CompressionBottom": 
                    startBottomTime = DateTime.Now;
                    break;
                case "NoVerticalCompression":
                    endTime = DateTime.Now;
                    if (IsCompressionTop())
                    {
                        MessageBox.Show("できたよー");
                    }

                    InitCompression();
                    break; 
                default: 
                    break; 
            } 
        }
こうしてみると、確かにグッとした後にイベント発生ができるんだけど リストを離して戻った時の判定になってしまう。 できれば、グッとしている間にイベントを発生したい。 。。。もっといい方法があるような気がする。

2011年11月19日土曜日

WindowsPhoneの初期化と通信でトラブルヽ(´Д`;)ノ

ちょっと失敗したので書いておく。
WindowsPhoneにおいて、通信は非同期処理しかサポートしていない。
ってのは有名な話だと思いますが、
OAuth認可の部分で少し失敗したのでメモしておく。

Access Tokenが切れていた場合、
Refresh Tokenを用いて再度トークンを取得してくる必要があります。

私はリフレッシュトークンを実装する前

・トークンが存在したら、アプリのメニュー画面
・トークンが存在しなかったら、ログイン画面

としていました。

これに
・トークンが存在しても、期限が切れていたらリフレッシュトークンで再発行処理
という部分を追加しました。

しかし、何度やってもResponseが返って来ませんでした。

結論から言うと既存処理の遷移の為、Defaultの画面が存在しませんでした。
なので画面用のスレッドが作成されず、
そのまま通信を行なってしまって、スレッドが同じになって、
同期処理とみなされていた。(なんかもう少し細かい話がありそうですけど)

って感じのようです。
Defaultを設定してあげて、そこから再発行をしたらすんなり動きました´ω`
この画面がよくありがちな、アプリのロゴを出す画面でして
ほぼ見えない画面なのですが、何かしっくりこないです。

※お陰でBackで終了するという処理も必要になりました。
本人が推奨はしてません


一旦APIキーのみでアクセスできる検索画面にして、
ユーザにログインボタンを提示する画面を作って無駄のないようにしようと考えています。

2011年10月30日日曜日

objectのjsonデータを変換する。

Google+のAPIの戻り値に"object"というオブジェクトデータがあり、
これがC#の予約後に引っかかってしまい、NewtonsoftのJSONパーサで解析をしていると無理になります。

https://developers.google.com/+/api/latest/activities#resource

なので

            private ObjectData objectValue;
            [Newtonsoft.Json.JsonPropertyAttribute("object")]
            public virtual ObjectData Object
            {
                get
                {
                    return this.objectValue;
                }
                set
                {
                    this.objectValue = value;
                }
            }
とすればOKです。

2011年10月28日金曜日

WindowsPhoneで最初の画面を選ぶ

OAuth認証は基本的に最初の一回でOKのはずです。
なのでアクセストークン(もしくはリフレッシュトークン)を持っていて
通信可能な状態だった場合、別の画面を表示する必要があります。

画面を作る
まず縦向きのページを作成します。
分岐を作成する
App.xamlに対して、Startupイベントを指定します。
<Application 
    x:Class="Ziro.PlusPhone.App"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:phone="clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone"
    xmlns:shell="clr-namespace:Microsoft.Phone.Shell;assembly=Microsoft.Phone"
    Startup="Application_Startup">
で、App.xzml.csにコードができるので
        private void Application_Startup(object sender, StartupEventArgs e)
        {
            if (oauth.Load())
            {
                RootFrame.Navigate(new Uri("/MainPage.xaml", UriKind.Relative));
            }
            else
            {
                RootFrame.Navigate(new Uri("/LoginPage.xaml", UriKind.Relative));
            }
        }
という風に条件式を作ってあげて、遷移先を変更してあげます。 私の場合、OAuth認証があるかどうかで判断しています。
デフォルトの画面を読み込ませない
次にプロジェクトの「Properties」内にあるWMAppManifest.xmlの TasksタグにあるDefaultTaskのNavigationPageを削除します。
    <Tasks>
      <DefaultTask Name="_default" NavigationPage="/MainPage.xaml"/>
    </Tasks>
↓
    <Tasks>
      <DefaultTask Name="_default"/>
    </Tasks>
これをやっておかないと強制的にここが優先されます。 これでOKです。

2011年10月27日木曜日

非同期で結果を受け取る

WindowsPhoneの非同期処理の理念は伝わっているけど、、、
C#の言語理解が足りないから実装方法がわからない。
GoogleのAPIの実装を見た時に「うわっこんな実装になるのか。。。なんで?」って
ところが理解できてなかったと再度思うことになった。
何がしたいか?
WindowsPhoneでGoogle+にアクセスしたい。
そこで処理を書いていくと

1.OAuthの部分(+その後の処理)をGUIと切り離したいのでライブラリ化しておきたい。
 -だけどアクセスは非同期処理になるので、データが返ってくるタイミングがわからない。
2.なのでそのデータをどう使うかはPhone側関数の処理にしたい。
3.その関数の引数はJsonの生データ(string)ではなくて、型が決まった状態で受け取りたい。
 -Newtonsoft.JsonのDeserializeObject()を呼び出すのでT型で呼び出したい。

この位の知識で実装してたら、あれ?あれ?って感じになったのでまとめておく。
レスポンスデータ取得後の呼び出し
最終的にT型で関数を呼び出すという目標に対して、
レスポンスが受けとったデータを呼び出すので
        public IJsonResult target;

        string = {レスポンスデータ};
        target.SendJson(data);
みたいな実装が必要なのでまずインターフェースを定義してみた。
これは生成時にT型が必要なのですが、呼び出し時にtargetをインスタンス化する為です。
SendJson()の実装
で、SendJson()の実装はジェネリックを利用して
    public class JsonResult<T> : Ziro.OAuth.IJsonResult where T : JsonData 
    {
        private Action<T> action;
        public JsonResult(Action<T> lambda )
        {
            action = lambda;
        }
        
        public void SendJson(string responseData) {
            //データを解析
            T jsonData = JsonConvert.DeserializeObject<T>(responseData);
            action(jsonData);
        }
    }
として、文字列をT型でデシリアイズして、それを引数にaction()を呼び出しています。
action()は引数がT型のラムダ式を引数に持つ式をコンストラクタで受け取ってます。
リクエストの呼び出し
        private void GetMe()
        {
            oauth.target = new JsonResult<GooglePlusData.Person>(GetPeople);
            oauth.GetPeople("me");
        }

        private void GetPeople(GooglePlusData.Person person)
        {
            //元のスレッドでメイン画面に遷移
            this.Dispatcher.BeginInvoke(() => NavigationService.Navigate(new Uri("/MainPage.xaml", UriKind.Relative)));
        }
って感じでOKです。
GetPeple()はUrlを作成して、GETしています。

これでJsonResultの生成時に型情報と受け取りの実装を渡します。
総括
一瞬、OAuthを直接行う部分のインスタンス化を行う時にT型を使おうとしたのですが、
アクセストークンをやり取りするのがだるかったのでこうしています。
※おそらく後で分散ストレージを使うので、そうしなくても良かったかな?とも思ってます。
こうしておくことで多分他のOAuthを作りたい感じになった時に楽になるかな。。。と勝手に思ってます。

ここに行くまでブヒーって感じでした。
C#の知識が乏しい状況での実装なので、本当にこれでいいのかも不明です。


JavaScript辺りのゆるふわな感じと、Javaの静的な感じが実現できていて素晴らしいなぁ。
結論としては、、、λカワイイ。。。

2011年10月26日水曜日

WindowsPhoneでGoogle+に対してOAuth認証

Google+APIでの認可を書いて、
後日WindowsPhoneへの移植を試みた。。。


しかしそれと同時に衝撃の事実。
DLLの参照設定ができないのだ。



「WindowsPhoneアセンブリのみと連動・・・」・・・なんとっ!

ということでOAuthの生コードを書くことにした。
何か調べるとライブラリは存在するのだが、OAuth1.0っぽいもの(Twitter系)が多くて
勝手に使い物にならないと判断した。

まぁC#触るの自体久しぶりだし、言語になれる意味でやっていきますか。
ってことで結局Google+のOAuthのページを読むことにした。

画面を作ります。

OAuthにはブラウザアクセスが必要なので、ブラウザを準備して
作成した認証コードが必要になるのでテキストと認証用のボタンを準備しておきます。

認証用のURLの発行

上記のURLにある通りにアクセスするURLを作成します。

https://accounts.google.com/o/oauth2/auth?
  client_id=xxxxxxx.apps.googleusercontent.com&
  redirect_uri=urn:ietf:wg:oauth:2.0:oob&
  scope=https://www.googleapis.com/auth/plus.me&
  response_type=code

自分のクライアントIDを添えてブラウザでアクセスします。
scopeに関しては上記のOAuthのサイトでは、別のスコープになってますので注意してください。

これを画面の初期化時にブラウザに設定して呼び出します。
※もちろん本物のアプリは違うタイミングでしょうけど。




こういう風にGoogleの認証ページになります。
認証は前にやった時と同じように行います。で認可が下りると
AuthCodeが発行された画面になります。


アクセストークンを取得する

AuthCodeコードがブラウザ画面に出ますので下にあるテキストボックスに貼付け
「認証」ボタンを押したところで


https://accounts.google.com/o/oauth2/token

のURLに対して

POST /o/oauth2/token HTTP/1.1
Host: accounts.google.com
Content-Type: application/x-www-form-urlencoded

client_id=xxxxxxxxx.apps.googleusercontent.com&
client_secret=xxxxxxxxxxx&
code={AuthCode}&
redirect_uri=urn:ietf:wg:oauth:2.0:oob&
grant_type=authorization_code

というポストを書いてあげます。
「code」は認証した後に発行された認証コードをテキストボックスに設定した値になります。

ここでWindowsPhoneは非同期処理しかないので送信には
BeginGetResponse(),BeginGetResponse()などを利用する必要があります。

これにより


{
  "access_token":"1/fFAGRNJru1FTz70BzhT3Zg",
  "expires_in":3920,
  "token_type":"Bearer",
  "refresh_token":"1/6BMfW9j53gdGImsixUH6kU5RsR4zwI9lUVX-tqf8JXQ"
}

というような文字列が取得できます。
これらの解析にはNewtonsoft.Json.Silverlightを使用しました。


Google+にアクセスする。

さてアクセストークンができたので何はともあれアクセスです。


https://www.googleapis.com/plus/v1/people/me?access_token=xxxxxx


とアクセスしてあげたら自分の情報が取れます。
※meは自分の情報ってことなのでAPI的にはuserIdを指定してあげればOKです。

これでまたJsonが戻ってきますので、解析してあげたらOKなわけです。
OAuth2.0ですので、アクセストークンの時間が切れた場合は
refresh_tokenで再発行を行う必要があります。

その際はアクセストークン時に使用した引数を

refresh_token=1/6BMfW9j53gdGImsixUH6kU5RsR4zwI9lUVX-tqf8JXQ&
grant_type=refresh_token


と変えて再発行すれば再認可は必要なくなります。
※refresh_tokenも期限はあるみたいです。




総括


基本的にはOAuthの説明です。
やったことある方なら通常のGoogleのドキュメントだけでOKだと思います。


詳しいAPIの説明は
https://developers.google.com/+/api/
にあります。


注意すべきは
・現状ではGoogleのクライアントAPIでは、WindowsPhoneアプリは無理なこと。
・WindowsPhoneの非同期等のポリシーなどでWebアクセスが通常ではないこと。
・C#が結構イケテル事。
・API呼び出しはBearerを利用した方がいいと思います。
・更新系のGoogle+APIがまだ

WindowsPhoneとC#になれる時間もいるのでゆっくりクライアントを作っていく予定。

さて、ではWindowsPhoneアプリの開発ですね。

2011年10月23日日曜日

.NET(C#) でGoogleOAuthを行なってみる。


IS12Tを手にいれたけど、何かアプリつくろう!って思って考えてたら
Google+のクライアントがないことに気づいたのでそれを作ってみようと思う。


サンプルを読み込む


とにかくOAuthアクセスを実現する必要があるはずなので
まずはサンプルを取得してきます。

https://developers.google.com/+/downloads
http://code.google.com/p/google-api-dotnet-client/





ここにサンプルとあるのでそれを取得。
※各種OAuthとかの文章ありますけど、ソースとかが古かったりですね。

「Tasks.SimpleOAuth2」というプロジェクトがコンソールでできるやつみたいなので
そちらをスタートプロジェクトにして実行するわけですけど、
「Google.Apis.Samples.TasksOAuth2.ClientCredentials」に
APIキーの設定のがあるの以下のように設定します。




        public static readonly string ClientID = "xxxxxxxx.apps.googleusercontent.com";

        /// <summary>
        /// The OAuth2.0 Client secret of your project.
        /// </summary>
        public static readonly string ClientSecret = "xxxxxxxxxxxxxxxxlOd2y";

        /// <summary>
        /// Your Api/Developer key.
        /// </summary>
        public static readonly string ApiKey = "urn:xxxxxxxxxx";





OAuth用の準備


https://code.google.com/apis/console

に行ってGoogle+APIをONにしておきます。




今は「Courtesy limit: 1,000 queries/day」なので
1日1000回のアクセスまでのようですね。。。。ん?ユーザじゃなくてAPIキーでかな?

API AccessのところにOAuth2.0があるのでそこでキーを発行します。
OAuthでお馴染みのClientIDとSecretですね。






Google PlusのAPIを使ってみる

このサンプルはTasks APIの
PlusのDLLはBinaryサンプルのServiceの中にあります。
これを参照設定して追加してPlusServiceを使えるようにしておきます。



            // Display the header and initialize the sample.
            CommandLine.EnableExceptionHandling();
            CommandLine.DisplayGoogleSampleHeader("Google+ API");

コンソールにGoogleのロゴを出す部分ですね。特に大事なコードではないです。
一応TaskになっていたのでGoogle+に変更。



            // Register the authenticator.
            var provider = new NativeApplicationClient(GoogleAuthenticationServer.Description);
            provider.ClientIdentifier = ClientCredentials.ClientID;
            provider.ClientSecret = ClientCredentials.ClientSecret;
            var auth = new OAuth2Authenticator<NativeApplicationClient>(provider, GetAuthorization);


ここでClientIDとSecretを使ってURLを作成している処理です。
GetAuthrorizarionは、サンプルであるProgram.csにあります。



        private static IAuthorizationState GetAuthorization(NativeApplicationClient arg)
        {
            // Get the auth URL:
            IAuthorizationState state = new AuthorizationState(new[] { PlusService.Scopes.PlusMe.GetStringValue() });
            state.Callback = new Uri(NativeApplicationClient.OutOfBandCallbackUrl);
            Uri authUri = arg.RequestUserAuthorization(state);

            // Request authorization from the user (by opening a browser window):
            Process.Start(authUri.ToString());
            Console.Write("  Authorization Code: ");
            string authCode = Console.ReadLine();
            Console.WriteLine();

            // Retrieve the access token by using the authorization code:
            return arg.ProcessUserAuthorization(authCode, state);
        }





NativeApplicationClient を元にOAuthにアクセスするURLを作成していきます。
IAuthorizarionStateでTasksのGetStringValue()を読んでいるのでPlusServiceにしてあげます。
実行するとこんな感じです。


ReadLine()でOAuthの認証コードを待ち受けます。
※実際の実行タイミングはFetch()するまでアクセスはされません。

Process.Start()で作成したURLにアクセスしますのでこれと同時にブラウザが立ちあがってるはずです。


アクセスを許可するとキーが発行されるのでそれをコンソールにコピペして、Enterします。
タスクに対して





            var service = new PlusService(auth);
            Person me = service.People.Get("me").Fetch();
            CommandLine.WriteLine("     ^2" + me.Name);

            ActivitiesResource.Collection collection = new ActivitiesResource.Collection();
            ActivityFeed feed = service.Activities.List("me",collection).Fetch();
            foreach (Activity list in feed.Items)
            {
                CommandLine.WriteLine("     ^2" + list.Title);
            }
            CommandLine.PressAnyKeyToExit();




と行なってみたら、自分の投稿を取得できました。
はじめ自分のIDを指定して検索してたんですが
「うんなもんOAuthじゃねーヽ(`Д´)ノプンプン」だったんですけど"me"で取得できました。

一旦これでアクセス自体はできたみたいですね。

途中、OAuthとGoogle+のAPIと.NETのAPIを読んでいてパニック起こしました。
誰がどれを作っているのかわからなくなったからです。

上位のクラスとか結構しっかり作ってある感じなので
簡単にOAuth(Google+API)にアクセスすることができます。

これをネタにWindowsPhoneクライアントを作るっていう大きな作業が待ってます。

2011年10月12日水曜日

Jenkinsを利用したJSUnitの実行

普段、Java周りではJenkinsを利用していますが、
静岡ITPro勉強会インフラ部でBTS周りをやる事になったので、
CIの発表としてJenkinsの利用方法をやる事になりました。

通常Javaでテスト作ってMavenで構成して、SVNでつなげるというパターンが多いのですが
勉強会で発表する事もあり(Java屋さんがいない)少しいつもと違う環境で動作させる事にしました。
という事でJavaScriptのプロジェクトを準備して、GitHubに連携って方法を行なってみる事にしました。

継続的インテグレーションの意義


Jenkinsはソース管理システムからソースコードを引っ張ってきて、テストを実行してレポートをする。
というのが王道の使い方です。
これを常時繰り返す事で、常にソースコードはテストを常に実行され、健全な状態に保って行く事が可能です。
Cronみたいなイメージでも使用できますが、レポート等も取れ、可視化できるので
インフラ周りでも使う事は可能だと思います。

開発の現場(出先など)で苦労する点としては
・ビルドサーバを準備できない
・そもそもテストが自動化できてない><。
等と苦労する点があります。

管理者自身を助ける(単体テストの可視化と報告)優秀なやつなのですが
案外管理者等に伝わらない事が問題だと思っています。
※まぁこれは私のちから不足なのですが。

インストール


通常私の開発環境周りではTomcatが多いのですが、
軽いほうがいいかな?っと思い、軽量なコンテナのJettyを採用してみました。

ここからダウンロードしてきます。
ポート変更はetc/jetty.xmlでport(デフォは8080)の部分を変更すれば可能です。

「java -jar start.jar」
※ポートを開ける権限が無ければsudo等で実行してください

でサーバは起動します。
Jenkinsをダウンロードしてきます。

war形式で配布されています。※一瞬インストーラーになってたので焦った><。
「jankins.war」をJettyの「webapps」ディレクトリに展開すると

「http://localhost:8080/jenkins/」で起動画面をみる事ができます。

ジョブ


ジョブを作ります。
通常はMavenを使って構成しているのですが、その辺りに詳しくないとわかりづらいと思ったので
やはりここはフリースタイルで行なってみる事にした。

JSUnitでテストを出きるようにする


Javaのテストは良く書いていますが、JSUnitでテストをするのは初でした。
なのでまずJSUnitを準備してテストできる状況にします。

http://sourceforge.net/projects/jsunit/files/jsunit/2.2/

解凍を行ったら
app,css,images,java,build.xml,testRunner.html
をプロジェクトに置きます。
Javaは通常実行時には要らないのですが、JenkinsでJSUnitプラグインを利用する際に指定するので必要です、
build.xmlはserver.xml等と名前を変えておきます。
※後述するビルドを「build.xml」とする為で、どちらも任意の名前で良い。
配置するディレクトリは「jsunit」とか「test」とかで良いと思います。

<script language="JavaScript" type="text/javascript">

/**
* テストの一覧
*/
function suite() {
var suite = new top.jsUnitTestSuite();
suite.addTestPage('testDesigner.html');
return suite;
}

</script>


というSuiteを用意して増えた時でも大丈夫な状態にしておきます。

<script language="JavaScript" type="text/javascript">
// テスト用のコード
function testMethod() {
assertEquals($('.item').length,3);
assertNotNull($('.content-box'));
}
</script>


というテストメソッドを作っておきます。他のxUnitと同じ感じですね。

でファイルシステムでも良いので「testRunner.html」にアクセスします。



testRunner.htmlからファイルを指定してRunするとこのようにテスト結果が出力されます。

これでテストする準備は整った。

プラグインを入れよう!


普段使っている時でもJenkinsのプラグインには様々なものがあります。
ソース管理はほとんどサポートしていますし、CheckStyle等のコードチェッカー等も充実しています。

今回はGitHub(+Git)とJSUnit(+xUnit)のプラグインを入れます。

ソースを取ってこよう!


ジョブの設定画面で「ソースコード管理システム」で「Git」を選択します。
そこにGitHubにあるリポジトリを指定しておきます。



この時点でビルドすると「ワークスペース」にソースを持ってこれるはずなので
それを確認しておくのが良いでしょう。こんな感じです。




ビルドを設定してテストしよう!


テストを実行する為、ビルドはAntを呼び出します。
Ant呼び出しを設定して、testをターゲットにしておきます。
Antの実行パス等はJenkins自体の設定で指定できます。



ビルドファイルなのですがリポジトリ等に置いてあるAntの設定ファイルを叩きます。
「./{projectName}/test/build.xml」
とかになると思います。

build.xmlは以下※展開してください。

<?xml version="1.0" encoding="utf-8"?>

<project name="JSUnitForJenkins" default="test" basedir=".">
<description>
Jenkins 上で JSUnit によるユニットテストを行う。
</description>
<property environment="jenkins"/>

<!-- プロジェクトのホームディレクトリ -->
<property name="home"
location="${jenkins.WORKSPACE}/drag-designer"/>
<!-- テスト対象のページ -->
<property name="testPage"
location="${home}/test/testSuite.html"/>
<!-- JSUnit の動作するポート番号 -->
<property name="jsunitPort"
value="8090"/>
<!-- Firefox のパス -->
<property name="firefox"
location="/Applications/FireFox.app/Contents/MacOS/firefox"/>
<!-- JSUnit のホームディレクトリ -->
<property name="jsunitHome"
location="${home}/test"/>
<!-- ログを出力するディレクトリ -->
<property name="logsDirectory"
location="${home}/test/logs"/>
<!-- JSUnit 形式のログファイルを JUnit 形式のログファイルに変換する XSLT -->

<!-- TestRunner -->
<property name="testRunner"
location="${jsunitHome}/testRunner.html"/>
<!-- JSUnit Server を起動する build.xml -->
<property name="buildScript"
location="${jsunitHome}/server.xml"/>

<target name="test">
<mkdir dir="${logsDirectory}"/>
<delete includeemptydirs="true">
<fileset dir="${logsDirectory}" includes="**/*"/>
</delete>
<makeurl file="${testRunner}" property="testRunnerURL"/>
<ant antfile="${buildScript}" dir="${jsunitHome}" target="standalone_test">
<property name="browserFileNames" value="${firefox}"/>
<property name="port" value="${jsunitPort}"/>
<property name="logsdirectory" location="${logsDirectory}"/>
<property name="url" value="${testRunnerURL}?testPage=${testPage}"/>
</ant>

</target>
</project>


プロジェクトのホームを設定して対応のSuiteのページを設定。
ブラウザを指定、ログ出力(テスト結果)の箇所、TestRunnerの指定、
JSUnitをコピーした際に名前を変更しておいてserver.xmlを指定します。

この設定でJenkinsはワークスペース上に取ってきたソースを元にJSUnitの実行を行います。
途中Firefoxで実行していますが
これらを増やして、クロスブラウジング等のテストを行うのも可能です。


処理結果をリポートする


Build後の処理として
「Publish testing tools result report」を選択して
JSUnit-2.2を選択して追加します。

**{projectName}/test/logs/*.xml

と、ビルドした際のテスト結果を出力した位置を指定します。これでOKです。



ジョブ実行


これで準備完了ですのでジョブを実行します。
実行時にFirefoxが起動してびっくりしますけど><。

で結果がこれ。



テスト自体はしょぼいですけど、できましたー

問題


・クロスブラウジングのテスト何かもできるなぁ〜と思っていたのですが
 通常動作しているJenkinsサーバはXがないけど動作するのかな?

・今回Firefox使いましたけど、バージョンアップとかの画面出てしまうと
 テスト自体ストップする状況になってしまうような気がする


おまけ


・本当はMylynとGitHubをつなげたIssue管理をやろうとしたのですが
 それは後日ブログに書きます。
・私個人としてはCIでパトランプを回すのが目標なのですが
 後日の勉強会でやった事ある方にパトランプを回す所を見せてもらいました!

パトランプの動画

やってみて、興味を示してくれた方が何人かいらっしゃって
既に実行された方もいるみたいなので、やって良かった!と思いました。
※パトランプの成果が一番大きいかも><。

2011年6月20日月曜日

ブラウザ三国志 経路算出:ChromeExtensionを改良

さて前回ダイクストラで最短経路を算出してみました。
このままではもちろんダメで何がダメかというと、
マップ見るたびに経路計算をしてしまうというダメダメな仕様。

このままでは通常時マップを見る時に計算してしまう。
ただ線引くだけのくせに、、、ジャマ!アンインストール。である。

なのでGUIを作って(無駄に画面だけあったけど><)
そこでON/OFFできるようにする。せっかくなので座標を貯めこむ。

で座標を指定して経路を算出!みたいな感じである。

前回利用したcontent_scriptsとページ間のリソースの共有は基本的にできません。
コンテキストスクリプトは拡張機能というよりWebページに埋め込む。という仕組みである為、
拡張機能とは隔離されているもの。という風にできています。

そこでbackgroundという仕組みを利用します。manifest.jsonに以下を追加します。


"background_page": "html/background.html",


全体はココ

で、イベントによる受け渡しをやるわけですが、以下が概要です。



こういう風にしたいと思います。

座標を保存する

まず、content_script により、51x51マップの時に、座標を吸い上げて以下を行います。


var map = document.getElementById( "map51-content" );
if ( map == null ) {
return false;
}

var pointArray = [];
var mapUlList = map.getElementsByTagName("ul");
for ( var ulIdx = 0; ulIdx < mapUlList.length; ++ulIdx ) {
var mapLiList = mapUlList[ulIdx].getElementsByTagName("li");
for ( var liIdx = 0; liIdx < mapUlList.length; ++liIdx ) {
var linkTag = mapLiList[liIdx].getElementsByTagName("a")[0];
var point = createPoint(linkTag);
pointArray.push(point);
}
}

//バックグラウンドで保存する
chrome.extension.sendRequest( {
action : "save" ,
args : [pointArray]
} , function( response ) {
//戻り値処理
console.log(response);
}
);


以前処理した時と同じように座標オブジェクトをcreatePoint()によって作成しています。
ここで重要なのは、chrome.extension.sendRequest()により、イベントを発生しています。

これはbackgroundで以下のように処理して、イベントを待機しています。


var CMD = {
isSaveMode : isSaveMode ,
save : save ,
analysisDijkstra : analysisDijkstra
};

function init() {
chrome.extension.onRequest.addListener( function ( message , sender , sendResponse) {
var retVal = CMD[message.action].apply(this,message.args);
sendResponse( { values : retVal } );
} ) ;
};


backgroundの処理はブラウザが立ち上がった瞬間に始まりますので
そこでinit()を呼び出してあげます。
CMDはmessageによりメソッド名を送ってそれを実行する為の配列ですね。
自分で判定文を作って、関数を作っても良いのですが、こうしておくと、やりとりが増えても大丈夫です。

listenerですのでsendRequest()を呼んであげたらここが呼び出されるので
「save」というactionを呼び出してる感じ。argsにはオブジェクトのリストを渡してます。

ここでオブジェクトに変換せずにそのままHTMLのDOMを送りたい所ですが、
この通信手段にはJSONが使われているみたいで、その引数の変換時にデータ内に改行が存在してしまい、変換に失敗するようです。

で受け取ったbackgroundはリストにより、座標を保存します。


function save( mapArray ){
for ( var idx = 0; idx < mapArray.length; ++idx ) {
var point = mapArray[idx];
localStorage[point.x + ":" + point.y] = JSON.stringify(point);
}
};


こんな感じですね。

localStorageは「WebStorage」と呼ばれるHTML5的な新しい技術で拡張機能として専用のサンドボックス内にデータを保存するような仕組みです。
配列と同じようにして保存する事が可能です。
"他のブラウザで、、、"って一瞬思いましたが、冷静に考えればChromeExtensionでした><。

localStorageには文字列が保存できるので JSON.stringify(point);で文字列にして保存しています。
※使っている場所ではJSON.parse()を実行しています。

これで座標を保存ができるようになったわけです。
実際にはGUIを利用して、「保存モード」かを判定して処理を行っています。
※それも通信とlocalStorageを利用してから実現しています。


経路の計算を行う

これで十分座標は保存できたと考えて、
次は画面操作との兼ね合いです。とは言っても簡単なHTMLですので、そんなに難しい事はしていません。



こういう画面です。
ソースはココ

※経路表示はまだ実装してないです><

この画面から経路算出の為に座標をbackgroundに渡して
※座標は保存するようにしたので、引数では渡してませんけど><
そこから経路算出を行っています。そこから戻り値で、経路情報を返しています。

ソース自体はgithubにすべておきました。
ダイクストラの大部分は変更していませんが、

・51x51の配列を使わないようにした。
・座標データはlocalStorageにある

って事で、直接座標データを取って処理しています。
※色々改良する余地はあると思います。

さて、これでWebページの情報を残して処理をするという基本アーキテクトの変更は可能になったわけです。
次回はbackgroundを利用して、経路表示も行いたいと思います。

2011年6月17日金曜日

ブラウザ三国志でダイクストラを行ってみる。



「ブラウザ三国志」というゲームがあります。
そのゲームでは領地の奪い合い、砦攻略を目指して軍を進めていきます。
基本ゲームはしませんが、「ソーシャルゲーム」という分野においての勉強としてずっと続けています。いやバランス良くて本当に面白い。。。ってそんな事やってる場合じゃない><


ChromeExtentionの仕組みみたいな部分での調査が大きいので「処理速度」や汚さは勘弁してください。あくまでそんな事できるんだ。程度でお読みください。

思い立ち

さてブラウザ三国志で砦攻略で重要になってくるのが、座標計算です。
次の砦に向かうという時に領地レベル☆1をめがけて経路を作ってつなげていきます。
自分1人でやっていくのはいいのですが、「同盟」を組んで同盟員とつなげていくと

「今度ここー」
「次私ここー」

みたいな事が起こってきます。
それを掲示板等で共有して進んでいくわけです。ソーシャルですねぇー。

で「ここ」って決めてく経路があるのです。
で、既にツール等もあるのですが、せっかくだから自分で作ってみよー!って思ったわけです。
ひとまずChromeExtensionで行きます。

今回は簡単に「51x51マップ」内で指定した座標間を結ぶ。をやってみましょう。

動きとしては
・51x51マップ時のみに動作する
 ・座標を取得してくる
 ・ダイクストラで経路を算出する
 ・算出した経路をHTML表示する

って流れですかね。

Chrome Extentionを作成してみよう!

さて、まずはボタンを表示してみましょう。
ここを元に構成を勉強して、、、とひとまずHTMLを書けました。

こんな感じ。





ブラ三おなじみのアイコンで、
アイコンをクリックするとHTMLを出力してくれるようになりました。

座標データを収集

さて、このツールは何につけても座標データが存在しないといけません。

って事で
・HTMLのデータを取得
・そこから座標データを抜き出す!
という流れを説明します。

まず51x51マップか、、、という判定。
既存のページ(対象のHTML)の要素を取得するには先ほどのページで使った
「browser_action」とは別に「content_scripts」という仕組みを使用する方法があります。

以下をmanifest.jsonに追記します。

"content_scripts": [
{
"matches": [
"http://m21.3gokushi.jp/big_map.php*"
],

"js" : [
"script.js"
],

"run_at": "document_end",
"all_frames": true
]

※要素のつなぎは「,」を忘れずに。

という風に記述します。
matchesはそのページに来たら。jsは使用するJavaScript、run_atはどのタイミングで動作するか?です。
all_frameはすべてのフレームに対して処理を行うか?を記述します。

manifest.jsonは更新したら再読込の必要あります。
これらを思い通りに動かせる(読み込み失敗とかがない)ようになるまでは
Googleのトップページで練習した方が良いかもです。

ブラウザ三国志(mixi)ですが難しいのはページの構成ですね。
URLはもちろんmixiを指しますが、アプリ自体は
「"http://m21.3gokushi.jp/big_map.php*"」にアクセスしています。
なので、それをマッチに入れて、Frameをtrueにしているわけです。

ここまでで、ひとまず「全体地図」のクリックで動作するか確認しておくべきでしょう。

そこまでできたらアプリケーションが使用しているHTMLをハックします。
そうすれば



というコードが存在するので、このデータを


var map51 = document.getElementById( "map51-wrapper" );
if ( map51 == null ) {
return;
}


という感じでデータを取得してきます。
その中に

<a
href=​"/​land.php?x=-22&y=-52#ptop"
onmouseover=​"return gloss('

<​dl class="​bigmap"​>
​<​dt class="​bigmap-caption"​>​空き地<​/​dt>
​<​dd class="​bigmap-subcap"​>​&​nbsp;​<​/​dd>
​<​dt>​座標&​nbsp;​/​&​nbsp;​距離<​/​dt>​
<​dd>​(-22,-52)​&​nbsp;​/​&​nbsp;​[35.36]​<​/​dd>​
<​dt>​戦力<​/​dt>
​<​dd>​★<​/​dd>​
<​dt class="​bottom-popup-l"​>​資源<​/​dt>
​<​dd class="​bottom-popup-r"​>​木1&​nbsp;​岩0&​nbsp;​鉄0&​nbsp;​糧0<​/​dd>​<​/​dl>​'

)​;​" onmouseout=​"nd()​;​">​
1​
</a>


というデータがありますので取得します。

// ULタグを取得
var mapUlList = map51.getElementsByTagName("ul");
for ( var ulIdx = 0; ulIdx < mapUlList.length; ++ulIdx ) {
// 内部のLiタグを取得
var mapLiList = mapUlList[ulIdx].getElementsByTagName("li");
for ( var liIdx = 0; liIdx < mapUlList.length; ++liIdx ) {
// Aタグを取得
var linkTag = mapLiList[liIdx].getElementsByTagName("a")[0];
dijkstra.pointArray[ulIdx][liIdx] = createPoint(linkTag);
}
}


createPoint(linkTag)で座標データを解析しています。


var createPoint = function ( linkTag ) {

var point = {};
point.x = 0;
point.y = 0;
point.team = "";
point.lv = 0;

var lv = replaceAll(linkTag.innerHTML,"\n","");
if ( lv != "1" && lv != "2" && lv != "3" && lv != "4" && lv != "5" ) {
return point;
}
point.lv = parseFloat(lv);
if ( point.lv != 1 ) {
point.lv = parseFloat(lv) * parseFloat(lv);
}
// 文字列に変換
var text = linkTag.outerHTML;

// リンクタグの解析
var firstIdx = text.indexOf("'");
var lastIdx = text.lastIndexOf("'");
var data = text.slice(firstIdx+1,lastIdx);

data = replaceAll(data,"<","<");
data = replaceAll(data,">",">");
data = replaceAll(data,""","'");
data = replaceAll(data,"&","&");
data = replaceAll(data,"&n"," ");
data = replaceAll(data," bsp;"," ");

var dp = new DOMParser();
var doc = dp.parseFromString(data, "text/xml");

var dtTags = doc.getElementsByTagName("dt");
var ddTags = doc.getElementsByTagName("dd");

// タグ数回繰り返す
for ( var idx = 0; idx < dtTags.length; ++idx ) {

var dtTag = dtTags[idx];
var tagVal = dtTag.firstChild.data;

if ( tagVal == "君主名" ) {
point.team = ddTags[idx].firstChild.data;
} else if ( tagVal == "座標 / 距離") {
var tagData = ddTags[idx].firstChild.data;
var startP = tagData.indexOf("(");
var endP = tagData.lastIndexOf(")");
var pointData = tagData.slice(startP+1,endP);
var pointArray = pointData.split(",");
point.x = pointArray[0];
point.y = pointArray[1];
}
}

return point;
};


相手が存在する場合等もありますから、
x,y,同盟名,lv(領地の強さ)を設定したpointオブジェクトを貯めこんでおきます。
パーサを使用していますが、大きなマップ等になったら変更する予定ではあります。
※51x51ではそんなに重くなかった。

。。。さぁこれで座標データの抜き出しはOKです。
ダイクストラを行ってみましょう!

ダイクストラで最短経路を算出

さて座標データはすべて取得できたので、そのデータから最短経路を算出してみましょう。
実際のソースを貼っておきます。



//Map51x51かを確認
var map51 = document.getElementById( "map51-content" );
if ( map51 == null ) {
return;
}

var MAX_X = 51;
var MAX_Y = 51;

// ダイクストラのロジック
var dijkstra = {};

// 座標データ
dijkstra.pointArray = new Array(MAX_X);
for ( var idx = 0; idx < dijkstra.pointArray.length; ++idx ) {
dijkstra.pointArray[idx] = new Array(MAX_Y);
}

// 経路算出
dijkstra.getRoute = function(startX,startY,endX,endY){

// その座標まで行くルート
var workRouteArray = new Array(MAX_X);
// 決定している距離
var pointArray = new Array(MAX_X);
// すべての配列を初期化
for ( var xidx = 0; xidx < workRouteArray.length; ++xidx ) {
workRouteArray[xidx] = new Array(MAX_Y);
pointArray[xidx] = new Array(MAX_Y);
for ( var yidx = 0; yidx < pointArray[xidx].length; ++yidx ) {
workRouteArray[xidx][yidx] = null;
pointArray[xidx][yidx] = Number.POSITIVE_INFINITY;
}
}

// 作業する座標
var workPointArray = new Array();

// 始点オブジェクト
var nowPoint = dijkstra.pointArray[startX][startY];
var nowX = startX;
var nowY = startY;
var nowLen = 0;

// 作業用のリストを取得する
workRouteArray[nowX][nowY] = new Array();
var nowRoute;

while ( true ) {

pointArray[nowX][nowY] = nowLen;
nowRoute = workRouteArray[nowX][nowY];

// 近隣の座標を算出
for ( var addX = -1; addX <= 1; ++addX ) {

var wkX = nowX + addX;
if ( wkX < 0 ) continue;
if ( wkX >= MAX_X ) continue;

// 周りを見渡す
for ( var addY = -1; addY <= 1; ++addY ) {

var wkY = nowY + addY;
if ( wkY < 0 ) continue;
if ( wkY >= MAX_Y ) continue;

// 既に決定済の場合
if ( pointArray[wkX][wkY] != Number.POSITIVE_INFINITY ) {
continue;
}

// その座標のルートを取得
var workRoute = workRouteArray[wkX][wkY];
// そこに行っていいのか?
if ( workRoute === undefined ) {
continue;
}

// 作業中の場所を取得
var point = dijkstra.pointArray[wkX][wkY];

//既に存在した場合
if ( workRoute != null ) {
var workLen = workRoute.length;
var nowRouteLen = nowRoute.length;
//作業用のリストが短い場合
if ( (workLen - 1) <= nowRouteLen ) {
continue;
}

var workLv = 0;
//TODO レベル加算を見る必要あり
for ( var idx = 0; idx < workLen; ++idx ) {
var point = workRoute[idx];
var lv = parseFloat(point.lv);
workLv = workLv + lv;
}

if ( (nowLen + parseFloat(point.lv)) > workLv ) {
continue;
}
}

// 行けない場所だった場合
if ( point.lv == "0" ) {
// 行っちゃダメにする
workRouteArray[wkX][wkY] = undefined;
continue;
}

var newRoute = new Array();
for (var i=0, l= nowRoute.length; i<l; i++) {
newRoute[i] = nowRoute[i];
}

//その地点を設定
newRoute.push(point);
// 作業用のルートを作成
workRouteArray[wkX][wkY] = newRoute;

// 作業の配列に座標を指定
var workPoint = {};
workPoint.x = wkX;
workPoint.y = wkY;

workPointArray.push(workPoint);
}
}

//console.log(workPointArray.length);
//for ( var cnt = 0; cnt < workPointArray.length; ++cnt ) {
//console.log("作業リスト" + workPointArray[cnt].x + ":" + workPointArray[cnt].y);
//}

var minLen = Number.POSITIVE_INFINITY;
var deleteIdx = 0;

// 作業リスト数回繰り返す
for ( var cnt = 0; cnt < workPointArray.length; ++cnt ) {

var workPoint = workPointArray[cnt];
var wkX = workPoint.x;
var wkY = workPoint.y;

var workRoute = workRouteArray[wkX][wkY];
var wkLen = 0;

for ( var idx = 0; idx < workRoute.length; ++idx ) {
var point = workRoute[idx];
var lv = parseFloat(point.lv);
wkLen = wkLen + lv;
}

// 設定できた場合
if ( minLen == Number.POSITIVE_INFINITY || wkLen < minLen ) {
minLen = wkLen;

nowX = wkX;
nowY = wkY;
nowLen = wkLen;
deleteIdx = cnt;
}
}


// 行く所がない場合
if ( minLen == Number.POSITIVE_INFINITY ) {
break;
}
workPointArray.splice(deleteIdx, 1);

// 現在の点を編集
nowPoint = this.pointArray[nowX][nowY];
//console.log("[" + nowPoint.x + "," + nowPoint.y + "]" + nowPoint.lv );
// 終点と同一点だった場合
if ( nowX == endX && nowY == endY ) {
// 作業ルートから取得
return workRouteArray[endX][endY];
}
}

// 決まらなかった場合
return null;
};

var nbsp = String.fromCharCode( 160 );
// 全ての文字列 s1 を s2 に置き換える
function replaceAll(expression, org, dest){
return expression.split(org).join(dest);
}

var createPoint = function ( linkTag ) {

var point = {};
point.x = 0;
point.y = 0;
point.team = "";
point.lv = 0;

var lv = replaceAll(linkTag.innerHTML,"\n","");
if ( lv != "1" && lv != "2" && lv != "3" && lv != "4" && lv != "5" ) {
return point;
}
point.lv = parseFloat(lv);
if ( point.lv != 1 ) {
point.lv = parseFloat(lv) * parseFloat(lv);
}
// 文字列に変換
var text = linkTag.outerHTML;

// リンクタグの解析
var firstIdx = text.indexOf("'");
var lastIdx = text.lastIndexOf("'");
var data = text.slice(firstIdx+1,lastIdx);

data = replaceAll(data,"<","<");
data = replaceAll(data,">",">");
data = replaceAll(data,""","'");
data = replaceAll(data,"&","&");
data = replaceAll(data,"&n"," ");
data = replaceAll(data," bsp;"," ");

var dp = new DOMParser();
var doc = dp.parseFromString(data, "text/xml");

var dtTags = doc.getElementsByTagName("dt");
var ddTags = doc.getElementsByTagName("dd");

// タグ数回繰り返す
for ( var idx = 0; idx < dtTags.length; ++idx ) {

var dtTag = dtTags[idx];
var tagVal = dtTag.firstChild.data;

if ( tagVal == "君主名" ) {
point.team = ddTags[idx].firstChild.data;
} else if ( tagVal == "座標 / 距離") {
var tagData = ddTags[idx].firstChild.data;
var startP = tagData.indexOf("(");
var endP = tagData.lastIndexOf(")");
var pointData = tagData.slice(startP+1,endP);
var pointArray = pointData.split(",");
point.x = pointArray[0];
point.y = pointArray[1];
}
}

return point;
};


console.log("start");
// ULタグを取得
var mapUlList = map51.getElementsByTagName("ul");
for ( var ulIdx = 0; ulIdx < mapUlList.length; ++ulIdx ) {
// 内部のLiタグを取得
var mapLiList = mapUlList[ulIdx].getElementsByTagName("li");
for ( var liIdx = 0; liIdx < mapUlList.length; ++liIdx ) {
// Aタグを取得
var linkTag = mapLiList[liIdx].getElementsByTagName("a")[0];
dijkstra.pointArray[ulIdx][liIdx] = createPoint(linkTag);
}
}

console.log("getRoute()");
var route;
route = dijkstra.getRoute(0,0,50,50);
if ( route != null ) {
console.log("route log:" + route.length);
for ( var idx = 0; idx < route.length; ++idx ) {
var point = route[idx];
console.log("[" + point.x + "," + point.y + "]" + point.lv );
}
}
console.log("end");


このコードでは0,0 - 51,51を算出するわけですが、あくまで動作確認ように行なっているだけです。
実際には、始点座標から終点座標を入力するのが良いですね。

実際のコードのcreatePoint()はLvに対してべき乗の重みを付けています。


画面に表示してみよう

さて、座標は判定できたのでそのままHTMLに表示していきます。

route = dijkstra.getRoute(0,0,50,50);
var pointArray = new Array();

//経路のリストを連想配列に入れる
for ( var idx = 0; idx < route.length; ++idx ) {
var point = route[idx];
pointArray[point.arrayX + "," + point.arrayY] = point;
}

for ( var ulIdx = 0; ulIdx < mapUlList.length; ++ulIdx ) {
// 内部のLiタグを取得
var mapLiList = mapUlList[ulIdx].getElementsByTagName("li");
for ( var liIdx = 0; liIdx < mapUlList.length; ++liIdx ) {

var idx = pointArray[ulIdx + "," + liIdx];
if ( idx === undefined ) {
continue;
}
// Aタグを取得
var liTag = mapLiList[liIdx];
liTag.style.backgroundColor = "#555555";

}
}


配列のポイントがあった方が処理しやすかったので
routeのオブジェクトにarrayX,arrayYを設定しています。

あとはULとLI回回して背景を変更するって感じです。
で出来上がったマップが以下の通り。


コードにより☆5までしか判定してないので最終地点に拠点等が存在すると
経路算出でエラーになります。

ここに載っているように世界にすぐ配信できます。
権利関係微妙ですが他のツール等に比べるとおとなしいので公開しておきました
誰かアイコン作って!(128x128pngしか受け付けない><)

仕事中に呆けて遊んでいたのでやっとアウトプットできた感じ><

今回のマップのダイクストラでの醍醐味は
重さが領地のレベルである事。☆2を進んだ時に遠征だときついだとか
その辺を考慮して重さを設定するところですね。

このソースを利用してマップをDBに貯めこんで計算してとかやれば大規模な遠征の道順等も教えてくれるはずです。
これをつくりはじめた時に、AppEngineを利用して
座標を全ユーザから送信してもらって、それで地図を更新して行くという仕様を考えましたけど
本気で行くと有料レベルになると思って止めました。
※お金発生してもOKですけど、お金取る(CM)とか入れると厄介かなぁと。。。

今回はゲーム周りでしたが、Tweetボタンやいいねボタンがないようなページを
簡単に共有したり、その他他人のWebページに1つエッセンスを加えて処理をやりやすくしたり、
Webのマクロみたいなイメージで処理する事が可能になります。

ChromeExtentionはChromeOSの登場によって
ChromeOS上で動作するアプリケーションを作れる事になります。
ChromeOSがどの程度市場を席巻するかは不明ですが、1つのアプリの公開形態としては
注視しておいても良いでしょう。

さぁ同盟員に書簡して使ってもらおう!

2011年6月13日月曜日

Canvasで絵を書いてみる

さて、メモを操作するようなアプリを考えていて
初めはHTMLでゴリゴリと思っていたんですけど、
四角を書いてそれを線で結びたかったのでCanvasを使って絵を書こうと思いました。
JavaScript部分にはjQueryを使います。

まずはHTML上にCanvasを宣言します。

<body id="body">
<canvas id="canvas"></canvas>
</body>


Canvasを一杯使いたいのでCanvasの要素を取得してきて
それをbodyタグと同じ大きさにします。


var $cvm = $('#canvas');
var width = $(document.body).width();
var height = $(document.body).height();
$cvm.attr("width", width);
$cvm.attr("height", height);


ウィンドウのサイズ変更されたら、、、って考えなきゃだめですね。
TODOにしとこう!

でキャンバスの基本設定


var ctx = $cvm[0].getContext('2d');
ctx.lineWidth = 1;
ctx.globalAlpha = 0.7;
ctx.globalCompositeOperation = "source-over";


getElementById() から取得してgetContext("2d")でもできるのですが
せっかくjQueryを使用しているのでjQueryのオブジェクトから取得。

lineWidthは線の太さ、globalAlhaは透明度ですね。
globalAlhaを設定した場合、globalCompositeOperationを設定します。

このglobalCompositeOperationってのは
ココが分かりやすいです。
文章よりサンプルがわかりやすいかも。

さて四角を書くのですが、それに対してテキストを入れてみます。

ctx.font =textHeight + 'px "ヒラギノ角ゴ Pro"';
// 表示するテキストの幅を取得する
var textWidth = ctx.measureText(item.text).width;
//Itemの幅を設定する
item.width = textWidth;
// テキストの背景を描画
ctx.fillStyle = "white";
ctx.fillRect(item.x, item.y, item.width,textHeight);

//テキストを描画
ctx.textBaseline = "bottom";
ctx.fillStyle = "black";
ctx.fillText(item.text, item.x, item.y + textHeight);


まずContextのfontに対して文字列の大きさ(高さの数値で変数化してます)を設定しています。
その後、テキストの横幅を測っています。ContextのmeasureTextで測ってます。
item.widthというものに残していますがitemはテキストのオブジェクト(描画位置とか持ってる)を
再描画用に持っているので、それに残してるってだけです。(矩形描画時に使ってます)
色をfillStyleで設定してfillRect()で矩形を描画します。

その後、textBaselineでテキストの位置を設定してfillStyleで文字の色を設定して
fillText()で描画を行います。これで矩形内に文字列を埋め込みます。

で要素間を結合したいので、その座標に関して線を引きます。
今回は「ベジエ曲線」という線を使って要素を結合したいと思います。


ctx.beginPath();
var point = new bezierPoint(preItem,item);
ctx.moveTo(point.x1,point.y1);
ctx.bezierCurveTo(point.x2,point.y1,point.x1,point.y2,point.x2,point.y2);
ctx.strokeStyle="#440000";
ctx.stroke();


まずbeginPath()を呼びます。
これは線を描画する際に「さぁ始めるぞ!」っていう事を意味します。
その後、bezirePoint()を呼んでいますが、これは独自処理で線を引くための座標を算出しています。
それで取得してきたpoint(独自のオブジェクト)を元にmoveTo()によって開始点を設定します。
そこからbezierCurveTo()を利用して、終点を設定します。
6個引数がありますが、4つ目までは曲線の付加情報ですね。(もうちょっと凝る予定ですけど)
で5,6の引数が曲線の終点になります。
でstorokeStyleで色を決定し、stroke()で線を描画します。

これをやると



こんな感じになります。

ひとまず少し前に記述していたEvernoteの要素をそのまま出してみました!

2011年6月7日火曜日

Evernoteにアクセスする その7 ユーザ情報編


さてNotebookの内容に入って行こうと思ってたんですけど、
冷静に考えるとユーザ名がない事に気づいた。
ログアウトとかに表示したいですよね。

なのでユーザの情報を取得しようと思います。

THttpClient userStoreTrans;
try {
userStoreTrans = new THttpClient(USER_URL);
} catch (TTransportException e) {
throw new RuntimeException("クライアントアクセス時の例外",e);
} catch (NullPointerException e) {
throw new RuntimeException("クライアントアクセス時の例外",e);
}

TBinaryProtocol userStoreProt = new TBinaryProtocol(userStoreTrans);
UserStore.Client userStore = new UserStore.Client(userStoreProt, userStoreProt);

User user = userStore.getUser(getAccessToken());


こんな感じです。UserStoreを取得するための
USER_URLには「https://sandbox.evernote.com/edam/user」ですね。
ノートの場合とアクセスするUrlが違います。

getUser()に対してアクセストークンを渡してあげます。
これで返って来るUserに情報が入っています。

getEmail()とかあるので「やべー」と思いましたがどうやら値は入ってないようですね。
Userを見る限り、UploadのLimit等も入っているのでその辺りを考慮してアップロード等を
行う必要があるようですが、一応アップ系の処理は行わないので使わないかな?

さてまた寄り道になりましたがひとまずそれを利用してログアウトのリンクを作成。
ノートブックの一覧等を利用して、ひとまずノートを選択するような仕組みにしてみました。

ここからアクセスできますけど、
sandboxのユーザがないと無理ですので、持っている方しかアクセスできません。

2011年6月6日月曜日

Evernoteにアクセスする その6 ノート一覧取得編


さて認証部分も終わったので、そろそろAPIでアクセスしないと
何やってるかわかんなくなってくるのでひとまずアクセスしてみよう!

実際にそこまでEvernote使いこなしているのか?という事も疑問でしたが
「メモ(ノート)を取る」という意味ではすごく良いアプリなんだな。。。と感じてました。

で、メモを活用するというアプリを考えた時に
「んじゃEvernote使えば?」というのが、このAPIにアクセスするきっかけになったわけです。

で、、、当初メモを取るアプリを考えていたんですけど、逆に考える事にしました。
メモはいくらでも、どんな媒体でも取るんだから、メモに情報を付加したり、
メモを取った後に色々眺める機能を作ってみようって。
まぁAPIアクセスを考えれるように、少しは追加とかもやるかもですけど。


で、evernoteにAPIでアクセスするまで、「ノートブック」と「ノート」の概念をあまり考えていませんでした。
なのでまずは一覧化して表示してみましょう。


NoteStore.Client noteStore = getClient();
List notebooks = noteStore.listNotebooks(getAccessToken());

for (Notebook notebook : notebooks) {
String guid = notebook.getGuid();
logger.info(notebook.getName() + ":" + guid);
NoteFilter filter = new NoteFilter();
filter.setNotebookGuid(guid);
NoteList noteList = noteStore.findNotes(getAccessToken(),filter, 0, 100);
for ( Note note: noteList.getNotes() ) {
logger.info(note.getTitle());
}
}


getClient()は前述の通り、SharedIdを元に取得してきます。
それとgetAccessToken()も認証の情報を元に取得してきます。

まずlistNotebooks()によりノートブックの一覧が取得できます。
Notebookには「guid」が存在し、それが一意の識別子になります。

それを元にNoteFilterを準備して、そこにGuidを設定してfindNotes()を行います。
第3、4引数は、オフセットとMaxの件数です。Maxの件数は、、、すべてってのは出来なさそう、、、

ひとまずこんなところですかね。
一応アプリではNotebookの一覧を表示して、クリックしたら、Noteの一覧が出るようなものを考えています。

次はNotebookについて記述していきましょう。

2011年6月4日土曜日

Evernoteにアクセスする その5 OAuth理解編


OAuthサンプルによって解説しようと思っていましたが、
結局暗号化を行って、テスト書いて、色々分かりやすくコードをいじってってこの状態になりました。
以下のコードでアクセスできます。


package jp.co.ziro.evernote.controller;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.TreeMap;
import java.util.logging.Logger;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.binary.Base64;
import org.slim3.util.ApplicationMessage;

/**
*

* EvernoteへのOAuthアクセスクラス
* - 認証URL作成 setCallbackUrl()
* - アクセストークン取得時はsendRequest(tokenSecret)
* で処理を行います。
*
* 認証Urlの作成
* 1.コンストラクタにより、固定値を設定する
* 2.使用側は setCallbackUrl() にコールバックするUrlを設定する
* 3.createAuthorization() の戻り値が認証Urlを取得
* 4.getTokenSecret(),secretを取得(保存)
*
* 認証Urlにアクセスする
* 認可画面が表示され、そこでの結果がコールバックに渡される
*
* コールバックUrlで行う事
* 1.コンストラクタにより固定値を設定する
* 2.oauth_verifier,oauth_tokenがコールバックに呼ばれるのでそれを
* setToken() setVerifier() で設定
* verifierがnullだったら、認証が行われていない。
* 3.sendRequest()を行う。引数は認証Url時に作成したsecret
* 4.oauth_token にリクエストトークン、edam_shard にAPIアクセス用のIdが渡されます。
*
* これらでthriftのAPIを利用して、Evernoteにアクセスできます。
*
*

* @author secondarykey
*/
public class EvernoteRequest {

@SuppressWarnings("unused")
private static Logger logger = Logger.getLogger(EvernoteRequest.class.getName());

/**
* EverNoteのAPI ConsumerKey
*/
private static final String CONSUMERKEY = ApplicationMessage
.get("evernote.api.key");
/**
* EvernoteのAPI ConsumerSecret
*/
private static final String CONSUMERSECRET = ApplicationMessage
.get("evernote.api.secret");

/**
* アクセスするUrl
*/
private static final String BASEURL = ApplicationMessage
.get("evernote.api.baseUrl");

/**
* リクエストToken取得用のUrl
*/
private static final String requestUrl = BASEURL
+ ApplicationMessage.get("evernote.api.requestTokenUrl");

/**
* 認証のUrl
*/
private static final String authorizationUrlBase = BASEURL + ApplicationMessage.get("evernote.api.oauthUrl");

/**
* 文字コード
*/
private static final String CHARSET = "UTF-8";
/**
* キー作成用のGETメソッド名
*/
private static final String REQUEST_METHOD = "GET";
/**
* OAuth nonces用のランダム値生成
*/
private static final Random random = new Random();


/**
* ベースのURL
* @return リクエストするBaseUrl
*/
public static String getBaseUrl() {
return BASEURL;
}

/**
* リクエストするパラメーター
*/
private Map parameters = new TreeMap();

/**
* TokenSecret
* getTokenSecret()時に空の場合、Exceptionを発生
*/
private String tokenSecret = null;
/**
*

* コンストラクタ
* パラメータ固定値を設定する
*

*/
public EvernoteRequest() {
setParameter("oauth_consumer_key", CONSUMERKEY);
setParameter("oauth_timestamp", getTimestamp());
setParameter("oauth_nonce", Long.toHexString(random.nextLong()));
setParameter("oauth_version", "1.0");
//暗号化を行う
setParameter("oauth_signature_method", "HMAC-SHA1");
}
/**
* タイムスタンプを作成
* @return 現在時刻の秒をString化した値
*/
private String getTimestamp() {
return Long.toString(System.currentTimeMillis() / 1000);
}

/**
* コールバックURLを設定する
* @param callbackUrl コールバックUrl
*/
public void setCallbackUrl(String callbackUrl) {
setParameter("oauth_callback", callbackUrl);
}

/**
* リクエストトークンを設定
* @param requestToken コールバックされたリクエストトークン
*/
public void setToken(String requestToken) {
//それぞれをリクエスターに設定
setParameter( "oauth_token", requestToken);
}

/**
* Verifierの設定
* @param verifier コールバックされたverifier値
*/
public void setVerifier(String verifier) {
setParameter( "oauth_verifier", verifier);
}
/**
* リクエスト時の引数設定
* @param name パラメータ名称
* @param value パラメータの値
*/
private void setParameter(String name, String value) {
parameters.put(name, value);
}

/**
* Encodes this request as a single URL that can be opened.
*/
private String getUrl() {
return requestUrl + "?" + join();
}

/**
* HTTP引数の連結
* @return Url引数にして返す
*/
private String join() {
StringBuilder sb = new StringBuilder();
//開始フラグ
boolean firstParam = true;

//引数数繰り返す
for (Map.Entry parameter : parameters.entrySet()) {
//最初の処理
if (firstParam) {
firstParam = false;
} else {
sb.append('&');
}

//キー値を取得
sb.append(parameter.getKey());
//連結子を設定
sb.append('=');
try {
//値をエンコードして設定
sb.append(URLEncoder.encode(parameter.getValue(), CHARSET));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("エンコード時の例外", e);
}
}
return sb.toString();
}

/**
* リクエストを行う
* @param
*
* @throws IOException
* if a problem occurs making the request or getting the reply.
*/
public Map sendRequest(String tokenSecret) {
//Signatureを設定
setParameter("oauth_signature", createSignature(tokenSecret));
//アクセスする
HttpURLConnection connection;
try {
connection = (HttpURLConnection) (new URL(getUrl())).openConnection();
} catch (MalformedURLException e) {
throw new RuntimeException("アクセスに対する例外",e);
} catch (IOException e) {
throw new RuntimeException("アクセスに対する例外",e);
}

int responseCode;
String responseMessage;
try {
responseCode = connection.getResponseCode();
responseMessage = connection.getResponseMessage();
} catch (IOException e) {
throw new RuntimeException("結果取得を行う",e);
}
// リクエストが正常じゃない場合
if (responseCode != HttpURLConnection.HTTP_OK) {
throw new RuntimeException("Server returned error code: " + responseCode + " " + responseMessage);
}

return createResponseMap(connection);
}

/**
*

* 暗号化を行う
* 1. メソッド名&アクセスUrl&HTTPの引数の文字列を作成
* 2. secretをTokenと連結してsecretをバイト化
* 3. Secret値を元にSignatureStringを暗号化を行う
* 4. その値をBase64で16進数かする
*

* @param tokenSecret リクエストToken取得時に発行されたSecret
* @return 暗号化したSecret値
*/
private String createSignature(String tokenSecret) {

String encodeUrl = encode(requestUrl);
String encodeJoin = encode(join());
String signatureBaseString = REQUEST_METHOD + "&" + encodeUrl + "&" + encodeJoin;

// 署名対象のテキストを作成
String secret = CONSUMERSECRET + "&";
//TokenSecretが存在する場合
if (tokenSecret != null) {
secret += tokenSecret;
}
byte[] secretyKeyBytes;
try {
secretyKeyBytes = secret.getBytes(CHARSET);
} catch (UnsupportedEncodingException e2) {
throw new RuntimeException("エンコード時の失敗",e2);
}

String hmac = "HmacSHA1";
SecretKeySpec secretKeySpec = new SecretKeySpec(secretyKeyBytes,hmac);
Mac mac;
try {
mac = Mac.getInstance(hmac);
mac.init(secretKeySpec);
} catch (NoSuchAlgorithmException e1) {
throw new RuntimeException("暗号化インスタンス取得失敗",e1);
} catch (InvalidKeyException e1) {
throw new RuntimeException("暗号化インスタンス取得失敗",e1);
}

byte[] data;
try {
data = signatureBaseString.getBytes(CHARSET);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(CHARSET + " is unsupported!", e);
}
byte[] rawHmac = mac.doFinal(data);
Base64 encoder = new Base64();

return new String(encoder.encode(rawHmac));
}

/**
* 値のUrlエンコード
* @param value エンコードする値
* @return エンコードした値
*/
private String encode(String value) {
String rtnData;
try {
rtnData = URLEncoder.encode(value,CHARSET);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("エンコード時の例外",e);
}
return rtnData;
}
/**
* レスポンスの値を取得
*
* @param connection
* @return
*/
private Map createResponseMap(HttpURLConnection connection) {

BufferedReader bufferedReader;
String result;
try {
bufferedReader = new BufferedReader(new InputStreamReader(
connection.getInputStream()));
result = bufferedReader.readLine();
} catch (IOException e) {
throw new RuntimeException("読み込み時の例外", e);
}

Map responseParameters = new HashMap();
for (String param : result.split("&")) {
int equalsAt = param.indexOf('=');
if (equalsAt > 0) {
String name = param.substring(0, equalsAt);
String value;
try {
value = URLDecoder.decode(param.substring(equalsAt + 1),
CHARSET);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("エンコード時の例外", e);
}
responseParameters.put(name, value);
}
}
return responseParameters;
}

/**
* 認証Urlの作成
* @return
*/
public String createAuthorizarionUrl() {
//リクエスト
Map reply = sendRequest(null);
String requestToken = reply.get("oauth_token");
//認証Urlの作成
String authorizationUrl = authorizationUrlBase + "?oauth_token=" + requestToken;
//TokenSecretを設定する
tokenSecret = reply.get("oauth_token_secret");
return authorizationUrl;
}

/**
* TokenSecretの取得
* @return
*/
public String getTokenSecret() {
if ( tokenSecret == null ) {
throw new NullPointerException("TokenSecretが設定されていない");
}
return tokenSecret;
}


}



まずコンストラクタで各種固定値の設定を行います。


  • oauth_consumer_key:KEY値

  • oauth_signature_method:「PLANTEXT」「HMAC-SHA1」のどちらか

  • oauth_timestamp:現在の秒

  • oauth_nonce:ランダムの値(リクエスト毎に一意)

  • oauth_version:「1.0」固定



Evernoteのサンプルではoauth_signatureにSECRET値を指定していますが、
HMAC-SHA1の際は暗号化を行うので、まだ指定していません。
oauth_signature_methodはPLANTEXT、HMAC-SHA1が存在し、サンプルはPLANTEXTが指定されています。
PLANTEXTを推奨(つうか使って)しているのも珍しい気もします。

HTTPにアクセスする時に利用するMapをTreeMapを利用していますが
暗号化時に引数をソートする必要があるのでTreeMapでKey値のソートを行っているわけです。

コンストラクタで設定した後はコールバックUrl(oauth_callback)を指定します。
Evernoteの認可画面により処理が行われるとそのUrlに戻ってきます。

認可画面に行くためにリクエストトークンを取得しに行きます。
createAuthorizarionUrl()ですね。

ここでsendRequest()でEvernoteにアクセスしています。
oauth_signatureをcreateSignatuer()を行ってHMAC-SHA1の暗号化を行って指定しています。

返ってきたResponseから値を取得します。
レスポンスにリクエストトークン(oauth_token)とSecret(oauth_token_secret)が指定されています。
リクエストトークンはhttps://sandbox.evernote.com/?oauth_token=xxxxとして利用すると認可画面にアクセスする事ができます。
Secret値は、アクセストークン時にシグネチャ値のキー値とする時にConsumerSecretと合わせて使用します。

ここまでが認可画面のリンクを作成する流れです。

コールバックURLを指定していますが、認可画面にアクセスし、操作を行うとこのUrlに戻ってきます。
そちらの処理が以下になります。

認証に成功するとコールバックURLにoauth_token、oauth_verifierが付与されて来ます。
oauth_verifierが存在しない場合は認証で拒否された等ですので、エラー画面等を出力しましょう。

設定されていたら、アクセストークンを取得する為、
このクラスをnewしてインスタンス化します。※設定する値は一緒。

oauth_tokenをsetToken() ,oauth_verifierをsetVerifier()を利用して、設定します。
設定したらsendRequest()によりアクセストークンを取得するのですが、
この時に最初のアクセスで取得したtokenSecretを渡します。

取得に成功するとoauth_tokenにアクセストークン、edam_shardにEvernoteにアクセスする時に必要な値が入ってきます。
このアクセストークンさえあれば何でもできるわけです。


その値を利用してAPIのアクセスです。
thriftのライブラリを使っています。


String noteStoreUrl = noteStoreUrlBase + shardId;
THttpClient noteStoreTrans = new THttpClient(noteStoreUrl);
TBinaryProtocol noteStoreProt = new TBinaryProtocol(noteStoreTrans);

NoteStore.Client noteStore =
new NoteStore.Client(noteStoreProt, noteStoreProt);
List notebooks = noteStore.listNotebooks(accessToken);
for (Notebook notebook : notebooks) {
logger.info("Notebook: " + notebook.getName());
}


サンドボックス+"/edam/note/"+edam_shardの値を作成して
それでTHttpClientを生成、それを利用してTBinaryProtocolを生成しています。

それを利用して生成しているNoteStore.ClientはEvernoteのライブラリですね。
生成したオブジェクトでAPI呼び出しを行っています。
ここではlistNotebooks()です。そこにアクセストークンを渡してアクセスしています。


・・・さて、再度理解を深める為にOAuthをしっかり説明してみました。
説明がヘタすぎますけど、ご了承ください。

まぁとにかく次回はAPIで色々やってみます。

2011年6月3日金曜日

Evernoteにアクセスする その4 HMAC-SHA1編


ここまでサンプルを元にEvernoteにアクセスしてきましたが、
SimpleOAuthRequestの中身を見てOAuthアクセスへの理解を
深めておきましょう。。。。って思ったんですけど、PLANTEXTでアクセスしているので
HMAC-SHA1でアクセスしてみましょう。

以下が元ソースです。
見られる場合は展開してください。


/**
* Copyright 2008 by EverNote Corporation. All rights reserved.
*/

package com.evernote.oauth.consumer;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;

/**
* This is a very simple implementation of an OAuth consumer request which can
* be used to ask an OAuth service provider for either a Request Token or
* an Access Token. It only handles PLAINTEXT authentication, and it only goes
* over a GET transport. As a result, it should only be used over SSL.
*
* @author Dave Engberg
*/
public class SimpleOAuthRequest {

/**
* Random number generator for creating OAuth nonces
*/
private static final Random random = new Random();

/**
* The URL of the OAuth service Provider that we should hit to request a
* token.
*/
private String providerUrl;

/**
* A mapping containing all of the OAuth parameters that will be passed in
* the reply.
*/
private Map parameters = new HashMap();

/**
* Constructs a request object that can be used to make token requests from
* an OAuth provider.
*
* @param providerUrl the base URL to request a Request or Access token
* @param consumerKey the OAuth consumer key, given by the Service Provider
* @param consumerSecret the OAuth consumer secret, given by the Provider
* @param tokenSecret if non-null, this is the previous oauth_token_secret
* that should be used in signing this request. If null, this will assume
* that this message does not include a token secret in its signature
*/
public SimpleOAuthRequest(String providerUrl, String consumerKey,
String consumerSecret, String tokenSecret) {
this.providerUrl = providerUrl;
setParameter("oauth_consumer_key", consumerKey);
String signature = consumerSecret + "&";
if (tokenSecret != null) {
signature += tokenSecret;
}
setParameter("oauth_signature", signature);
setParameter("oauth_signature_method", "PLAINTEXT");
setParameter("oauth_timestamp",
Long.toString(System.currentTimeMillis() / 1000));
setParameter("oauth_nonce",
Long.toHexString(random.nextLong()));
setParameter("oauth_version", "1.0");
}

/**
* Sets one of the query string parameters for the request that will be
* made to the OAuth provider. The value will be URL encoded before adding
* to the URL.
*
* @param name the name of the parameter to be set
* @param value the string value, unencoded
*/
public void setParameter(String name, String value) {
parameters.put(name, value);
}

/**
* Encodes this request as a single URL that can be opened.
*/
public String encode() throws IOException {
StringBuilder sb = new StringBuilder();
sb.append(providerUrl);
boolean firstParam = providerUrl.indexOf('?') < 0;
for (Map.Entry parameter : parameters.entrySet()) {
if (firstParam) {
sb.append('?');
firstParam = false;
} else {
sb.append('&');
}
sb.append(parameter.getKey());
sb.append('=');
sb.append(URLEncoder.encode(parameter.getValue(), "UTF-8"));
}
return sb.toString();
}

/**
* Sends the request to the OAuth Provider, and returns the set of reply
* parameters, mapped from name to decoded value.
*
* @throws IOException if a problem occurs making the request or getting the
* reply.
*/
public Map sendRequest() throws IOException {

HttpURLConnection connection =
(HttpURLConnection)(new URL(encode())).openConnection();
int responseCode = connection.getResponseCode();
if (responseCode != HttpURLConnection.HTTP_OK) {
throw new IOException("Server returned error code: " + responseCode +
" " + connection.getResponseMessage());
}
BufferedReader bufferedReader = new BufferedReader(
new InputStreamReader(connection.getInputStream()));
String result = bufferedReader.readLine();
Map responseParameters = new HashMap();
for (String param : result.split("&")) {
int equalsAt = param.indexOf('=');
if (equalsAt > 0) {
String name = param.substring(0, equalsAt);
String value =
URLDecoder.decode(param.substring(equalsAt + 1), "UTF-8");
responseParameters.put(name, value);
}
}
return responseParameters;
}

}



これをHMAC-SHA1で処理を行う場合は、oauth_signatureの指定に
・HTTPメソッド
・リクエストURL
・リクエストパタメータ
これらをURLエンコードして、&で結合してHMAC-SHA1で16進のダイジェスト値を作って
Base64エンコード後、URLエンコードした値をoauth_signatureに指定します。

オリジナルでEvernoteRequestクラスを作成してアクセスしてみましょう。
※動作確認用なので汚いのは許して><

以下がHMAC-SHA1でアクセスしたソース


/**
* Copyright 2008 by EverNote Corporation. All rights reserved.
*/

package jp.co.ziro.evernote.controller;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.TreeMap;
import java.util.logging.Logger;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.binary.Base64;
import org.slim3.util.ApplicationMessage;

/**
* This is a very simple implementation of an OAuth consumer request which can
* be used to ask an OAuth service provider for either a Request Token or an
* Access Token. It only handles PLAINTEXT authentication, and it only goes over
* a GET transport. As a result, it should only be used over SSL.
*
* @author Dave Engberg
*/
public class EvernoteRequest {

@SuppressWarnings("unused")
private static Logger logger = Logger.getLogger(EvernoteRequest.class.getName());
private static final String consumerKey = ApplicationMessage
.get("evernote.api.key");
private static final String consumerSecret = ApplicationMessage
.get("evernote.api.secret");
private static final String urlBase = ApplicationMessage
.get("evernote.api.baseUrl");
private static final String requestUrl = urlBase
+ ApplicationMessage.get("evernote.api.requestTokenUrl");
/**
* ベースのURL
* @return
*/
public static String getBaseUrl() {
return urlBase;
}

/**
* Random number generator for creating OAuth nonces
*/
private static final Random random = new Random();

/**
* A mapping containing all of the OAuth parameters that will be passed in
* the reply.
*/
private Map parameters = new TreeMap();

/**
* Constructs a request object that can be used to make token requests from
* an OAuth provider.
*/
public EvernoteRequest() {
setParameter("oauth_consumer_key", consumerKey);
//暗号化を行う
setParameter("oauth_signature_method", "HMAC-SHA1");
setParameter("oauth_timestamp", getTimestamp());
setParameter("oauth_nonce", Long.toHexString(random.nextLong()));
setParameter("oauth_version", "1.0");
}
/**
* タイムスタンプを作成
* @return
*/
private String getTimestamp() {
return Long.toString(System.currentTimeMillis() / 1000);
}

/**
* Sets one of the query string parameters for the request that will be made
* to the OAuth provider. The value will be URL encoded before adding to the
* URL.
*
* @param name
* the name of the parameter to be set
* @param value
* the string value, unencoded
*/
public void setParameter(String name, String value) {
parameters.put(name, value);
}

/**
* Encodes this request as a single URL that can be opened.
*/
private String encode() {
return requestUrl + "?" + join();
}

/**
* HTTP引数の連結
* @return
*/
private String join() {
StringBuilder sb = new StringBuilder();
boolean firstParam = true;
for (Map.Entry parameter : parameters.entrySet()) {
if (firstParam) {
firstParam = false;
} else {
sb.append('&');
}
sb.append(parameter.getKey());
sb.append('=');
try {
sb.append(URLEncoder.encode(parameter.getValue(), CHARSET));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("エンコード時の例外", e);
}
}
return sb.toString();
}

/**
* 文字コード
*/
private static final String CHARSET = "UTF-8";
/**
* キー作成用のGETメソッド名
*/
private static final String REQUEST_METHOD = "GET";

/**
* Sends the request to the OAuth Provider, and returns the set of reply
* parameters, mapped from name to decoded value.
*
* @throws IOException
* if a problem occurs making the request or getting the reply.
*/
public Map sendRequest(String tokenSecret) throws IOException {
// 署名対象のテキストを作成
String secret = consumerSecret + "&";
if (tokenSecret != null) {
secret += tokenSecret;
}

String hmac = "HmacSHA1";

String encodeUrl = URLEncoder.encode(requestUrl,CHARSET);
String encodeJoin = URLEncoder.encode(join(),CHARSET);

String signatureBaseString = REQUEST_METHOD + "&" + encodeUrl + "&" + encodeJoin;
logger.info(signatureBaseString);

byte[] secretyKeyBytes = secret.getBytes(CHARSET);
SecretKeySpec secretKeySpec = new SecretKeySpec(secretyKeyBytes,hmac);
Mac mac;
try {
mac = Mac.getInstance(hmac);
mac.init(secretKeySpec);
} catch (NoSuchAlgorithmException e1) {
throw new RuntimeException("暗号化インスタンス取得失敗",e1);
} catch (InvalidKeyException e1) {
throw new RuntimeException("暗号化インスタンス取得失敗",e1);
}

String signature = null;
byte[] data;
byte[] rawHmac;
try {
data = signatureBaseString.getBytes(CHARSET);
rawHmac = mac.doFinal(data);
Base64 encoder = new Base64();
signature = new String(encoder.encode(rawHmac));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(CHARSET + " is unsupported!", e);
}
setParameter("oauth_signature", signature);

HttpURLConnection connection = (HttpURLConnection) (new URL(encode())).openConnection();
int responseCode = connection.getResponseCode();
// リクエストが正常じゃない場合
if (responseCode != HttpURLConnection.HTTP_OK) {
throw new IOException("Server returned error code: " + responseCode
+ " " + connection.getResponseMessage());
}
return createResponseMap(connection);
}

/**
* レスポンスの値を取得
*
* @param connection
* @return
*/
private Map createResponseMap(HttpURLConnection connection) {

BufferedReader bufferedReader;
String result;
try {
bufferedReader = new BufferedReader(new InputStreamReader(
connection.getInputStream()));
result = bufferedReader.readLine();
} catch (IOException e) {
throw new RuntimeException("読み込み時の例外", e);
}

Map responseParameters = new HashMap();
for (String param : result.split("&")) {
int equalsAt = param.indexOf('=');
if (equalsAt > 0) {
String name = param.substring(0, equalsAt);
String value;
try {
value = URLDecoder.decode(param.substring(equalsAt + 1),
CHARSET);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("エンコード時の例外", e);
}
responseParameters.put(name, value);
}
}
return responseParameters;
}
}



・・・本当に汚い、、、
で、これを利用してindex時に


EvernoteRequest oauthRequestor = new EvernoteRequest();
String thisUrl = request.getRequestURL().toString();
String cbUrl = thisUrl.substring(0, thisUrl.lastIndexOf('/') + 1);
cbUrl = cbUrl + "callback";

oauthRequestor.setParameter("oauth_callback", cbUrl);
Map reply = oauthRequestor.sendRequest(null);

String requestToken = reply.get("oauth_token");
String tokenSecret = reply.get("oauth_token_secret");
String authorizationUrl = authorizationUrlBase + "?oauth_token=" + requestToken;

sessionScope("tokenSecret",tokenSecret);


という風にします。
すべての引数を指定した後にoauth_signatureを生成するので
sendRequest()にtokenSecretを持ってきています。(コールバック時に利用)

リクエストトークンとともにtokenSecretは取得できます。
アクセストークンを取得する際に使用するtokenSecretをセッションに残しています。
で以下がコールバックの処理。


EvernoteRequest oauthRequestor = new EvernoteRequest();

String requestToken = requestScope("oauth_token");
String verifier = requestScope("oauth_verifier");
String tokenSecret = sessionScope("tokenSecret");

oauthRequestor.setParameter( "oauth_token", requestToken);
oauthRequestor.setParameter( "oauth_verifier", verifier);

//取得する
Map reply = oauthRequestor.sendRequest(tokenSecret);


こちらはセッションからtokenSecretを取得して指定しているだけですね。

んーAuthrorization Headerでアクセスしたいですよねー。
アクセスもPOSTでもないですし。。。本物はそうしてみようっ。と。

次回、OAuthを読んでいくか、APIアクセスをもう少し行ってみるか悩んでます。
それを記述するのはOAuthの仕様なだけなのでそっち読めば良いかなぁ、、、と。