Googleスプレッドシートで.NETアプリケーションのデータを管理する

最近(というほどではないかもしれないけど)は色んなログをオンラインデータストレージに放り込むのが流行ってます。Apacheのログとかを放り込んでごりごり解析するTreasureDataとか有名*1ですね。

さて。

ログと言えば艦これ*2
f:id:W53SA:20140219003119p:plain
専ブラが吐き出す艦船建造などのログが貯まってきたので、これをオンラインデータストレージに放り込んでやりたいなというのが今回のお話。


何処に保存しよう

オンラインデータストレージと言ってもファイルをぶち込むだけのアップローダなど、いろいろありますが、今回はリアルタイムにログ情報を淡々とアップロードして適当にダウンロードしたいわけで、ちろっと調べる。

さてどうするか。以下の理由でGoogle Spreadsheet APIを使うことにしました。

ライブラリを取り敢えず組み込む

早速Google CodeからMSI(Google Data API Setup (2.2.0) )を拾ってきて、自作艦これ専ブラのプロジェクトにアセンブリを放り込んでビルドさせてみる。と、コンパイラが文句をいう。

意訳「Json.NETのバージョンが合わない。」

専ブラでJSON解析用に使ってるJson.NETは6.0.1*3で、Google Data APIの再配布バイナリが使ってるJson.NETは4.0.5、これが気に食わないと。

せやな。

古いバージョンのJson.NET使うの馬鹿っぽいのでRedistバイナリ使うのは諦めて、最新版のJson.NETでビルドしたバイナリを作ることにしました。

Google Data APIソースコードをchekcoutし、最新版のJson.NETをthird partyディレクトリに放り込んでビルドしてDLLアセンブリを作ります*4。なお、Google Data APIのプロジェクトは.NET Framework 2.0なので2.0版ビルドのJson.NETアセンブリを使わないと文句言われます。

認証キーをもらおう

使うためにはOAuth2認証が必要です。Developers ConsoleにアクセスしてAPIs & auth→CredentialからClient ID for native applicationにあるClient ID/Secretをもらってきます

f:id:W53SA:20140222215553p:plain

認証する

基本的にはドキュメントのAuthorizing requests with OAuth 2.0にあるようなコードを書けばOAuthUtil.GetAccessToken(OAuth2Parameters parameters)を叩いた時点で関数の引数に渡したOAuth2Parametersオブジェクトにアクセストークン(Access Token)が降って来ます。後はこのアクセストークンを使ってAPIをバシバシ叩けばいい。


いいんですが、このアクセストークンは寿命が1時間(3600secs)しかありません。

一時間たったらまた認証URLを表示するわけではなく、アクセストークンの更新をします。この時、OAuthUtil.GetAccessToken()を叩いた際にアクセストークンと一緒に降って来てる更新トークン(Refresh Token)を使います。前掲のライブラリ内を使う場合は、アクセストークンが切れると勝手に更新トークン使って新しいアクセストークンを取るコードがライブラリ内にあるのでアクセストークンの寿命を気にする必要はないです。

設定を保存する際も、この更新トークンだけ保存しておけば十分だと考えるわけですが、この実装だと認証した後にプログラムを再起動するとアクセストークン忘れるので、更新トークン設定してあってもライブラリが例外*5を投げて死にます。ソース見るとアクセストークンが空文字なのがダメなようで、何か適当な文字*6を放り込んであげたらアクセストークンの更新ルーチンが走ります。

なお、更新トークンを設定した状態でOAuthUtil.RefreshAccessToken(OAuth2Parameters parameters)を叩くとアクセストークンを取ってきてくれます。

OAuthUtil.GetAccessToken()とOAuthUtil.RefreshAccessToken()は返り値voidで宣言されてて処理に失敗すると例外を投げます。

スプレッドシートを作る

(5 Jul 2015 : Document List APIが死んだことによるDrive APIでの場合を追記)

データを記録するスプレッドシートを作りたいなと思ってドキュメントを読んでみる。

It is possible to create a new spreadsheet by uploading a spreadsheet file via the Google Drive API. The Spreadsheets API does not currently provide a way to delete a spreadsheet, but this is also provided in the Google Drive API. For testing purposes, you may create a spreadsheet manually or upload one.

Creating a spreadsheet

何いってんのお前。

f:id:W53SA:20140219034427p:plain

新しいスプレッドシートの作成はSpreadsheets APIの権限にあるってのは嘘ですか。

とか言ってもしゃーないのでGoogle Drive APIについて調べたまだベータ版。なので見なかったこと*7にしてGoogle Data APIに含まれるDocument List APIで作ることにしました。ドキュメントには2012年9月にobsoletedって書いてあるけど気にしない。

using Google.GData.Documents;

/*
GOAuth2RequestFactory requestFactoryはすでに
有効なアクセストークンを持って生成されてるとするよ。
*/

var docsService = new DocumentsService("Application Name Hogeeeee");
docsService.RequestFactory = requestFactory;

var docsEntry = new DocumentEntry();
docsEntry.Title.Text = "新しいスプレッドシートのお名前";
docsEntry.Categories.Add(DocumentEntry.SPREADSHEET_CATEGORY);
docsEntry = docsService.Insert(DocumentsListQuery.documentsBaseUri,docsEntry);

こんな感じでスプレッドシートが出来ました。
Google Document List APIは2015年4月20日に死んでしまいました。ということで、最低限のDrive APIを叩いてファイルを作ることにします。要はSpreadsheetのMIMETypeを指定してinseertできればいいわけです。

string req_json = @"{
""title"": """ + spreadSheetName + @""",
""mimeType"": ""application/vnd.google-apps.spreadsheet""
}";

HttpWebRequest req = WebRequest.Create("https://www.googleapis.com/drive/v2/files") as HttpWebRequest;

req.ContentType = "application/json; charset=UTF-8";
req.Method = "POST";
req.Proxy = _requestFactory.Proxy;
req.Headers.Add("Authorization", string.Format("Bearer {0}",_parameters.AccessToken));

var req_bytes = Encoding.UTF8.GetBytes(req_json);
req.ContentLength = req_bytes.Length;

var rs = req.GetRequestStream();
rs.Write(req_bytes, 0, req_bytes.Length);
rs.Close();

req.GetResponse();

_parametersはData API内で使われているOAuth2Parametersのオブジェクトで、OAuthUtil.GetAccessToken()あたりで設定されているはずですね。

ちなみに、後で出てきますがSpreadsheets APIを叩くために存在するSpreadsheetsServiceオブジェクトをスプレッドシートを追加した後に再生成*8してあげないとSpreadsheetService.Queryが失敗します。

というわけで、スプレッドシートを作るにはDrive APIのアクセス権も必要。つまりOAuth2Parameters.Scopeはhttps://spreadsheets.google.com/feedsだけではダメでhttps://docs.google.com/feeds https://www.googleapis.com/auth/drive.fileも書く*9必要があります。書かないとdocsService.InsertGetResponseで例外が飛びます。

スプレッドシートを取ってくる。

任意のスプレッドシートファイルを開きたかった。

一覧取ってくることしか出来ないっぽい。Retrieving a list of spreadsheetsにありますが、SpreadsheetService.Queryを叩いて貰えるSpreadsheetFeedのEntriesにアクセス可能なスプレッドシート一覧が入ってるので、EntriesをforeachしてSpreadsheetEntry.Title.Textにスプレッドシート名があるんでそれと比較することになりましょう。この一覧、自作ファイルに限定されずマイドライブに見えてるアクセス可能な共有のファイルも含めて列挙してくださるのどうにかしたい*10

ちなみにSpreadsheetEntry.TitleはAtomTextオブジェクトで、同じ名前のファイルを作っても内部では区別されてます。

いじるワークシートを特定する

スプレッドシート内にワークシートを追加するとか、スプレッドシート内のワークシートを探すとか。

特に書くことはないですねぇ。サンプル通りに書いたら動いたんで。

追加したワークシートにちょっかい出す場合はWorksheetFeed.Insert(wsEntry)*11に渡したWorksheetEntryオブジェクトではなく、Insertが返したオブジェクトを使う必要がある、ぐらいですか。

ワークシートのデータを弄る

ワークシートにデータを追加したり、データを書き換えたり。

セル単位で値をいじるAPIと、リスト単位でデータをいじるAPIの二つがあります。

セル単位のAPI

セル単位のAPIは任意位置の値をいじることしか出来ないので、必要に応じてワークシートのサイズを変えておく必要があります。ワークシートサイズを変えるには、Modifying a worksheet's title and sizeを参照。

ワークシート範囲外の値をつつこうとすると例外飛ばします。insertする際も勝手に拡張はしてくれずセルは存在してないとダメってのがアレ*12

値のinsertはこんな感じで出来ますね。updateとかはドキュメントに説明があるんで端折る。

using Google.GData.Spreadsheets;

WorksheetEntry wsEntry = (WorksheetEntry)wsFeed.Entries[page];
CellQuery cellQuery = new CellQuery(wsEntry.CellFeedLink);
CellFeed cellFeed = _service.Query(cellQuery);
CellEntry entry = new CellEntry(1, 1, "firstvalue");
cellFeed.Insert(entry);

複数のセルの値を取得/更新する際はバッチリクエストで一気にできるようですが、値の追加はバッチリクエストできないそうです。値の追加は追加する値を持つCellEntryのコンストラクタでセルを指定できるけど、値の更新はCellFeed.Entriesプロパティをforeachして然るべきCellEntryを探す必要があるしめんどいな。

リスト単位のAPI

リスト単位のAPIは一番上(1行目)をヘッダとみなして、このセルの値が各列の値名となります。値を取得する際は、各列の値が入ったListEntry.Customオブジェクトのコレクションが一列のデータとして取得できます。

このとき、一列目のデータはTitleとして扱われ、各行のListEntryオブジェクトが持つTitleパラメタとして取得できます*13
f:id:W53SA:20140223215820p:plain

値探す如きにforeachくるくるするの面倒いので、IEnumerable<IDictionary<string,string>>くれるようなクエリメソッド、こんな感じでしょうか。

using Google.GData.Spreadsheets;

IEnumerable<IDictionary<string,string>> Query(WorksheetEntry wsEntry)
{
    ListFeed lsFeed = _service.Query(wsEntry);
    var rowList = new List<IDictionary<string, string>>();

    foreach (ListEntry lsRow in lsFeed.Entries)
    {
        var row = new Dictionary<string, string>();
        foreach (ListEntry.Custom element in lsRow.Elements)
        {
            row[element.LocalName] = element.Value;
        }
        rowList.Add(row);
    }
    return rowList;
}


リストAPIは値をinsertで追加する事ができて、ワークシートの行数に収まりきらない場合は勝手に拡張して下に伸びていきます。

ワークシート作成直後など一行目がぜんぶ空っぽな時は値の名前が存在しないので、リスト単位のAPIでは何も出来ません。セル単位のAPIで値を書き込んでおく必要があります。この時、値の名前が地雷原なので要注意。

まず、半角アルファベット大文字・全角アルファベット・半角スペースを使うと値の追加時に例外飛んで死にます。二列名以降のヘッダ名に半角アルファベット大文字を使うと例外は飛ばずに正常終了するけど値が追加されないという凶悪な事態が起きます。大事を取ると「半角アルファベット小文字だけ使う」ことに。なんてこったい。

先述のようにリスト単位のAPIは一列目のデータをTitleとして特別扱いしてるので、一列目と二列目以降で同じデータ渡してもエラーの内容が違うんでしょうなぁ。

例外について

前述したように、処理失敗すると例外を投げてきます。


範囲外のセルにアクセスしたり、リストクエリでカラム名に大文字が入ってるとGoogle.GData.Client.GDataRequestExceptionが飛ぶんですが、この例外オブジェクトは「Execution of request failed」としか教えてくれない。レスポンスボディにおまけ程度のエラーメッセージが入ってますが、これを読むためには、InnerExceptionのSystem.Net.WebExceptionが持つResponseプロパティでSystem.Net.WebResponseオブジェクトを引きずり出した上でGetResponseStreamメソッドでレスポンスを読むためのSystem.IO.Streamオブジェクトを取得してストリームから適当*14に読み出さにゃならん。

マンドクセ。Fiddlerあたりで通信見たほうが早いぞ。GDataRequestException内で読みだして適当なプロパティに放り込んでくれよ。ちなみにOAuth系はSystem.Net.WebExceptionを投げてくるので自力でWebResponse読まなきゃいかんわけですが。

ちなみに、範囲外のセルにアクセスした例外は403 Forbiddenになり、「It looks like someone else already deleted this cell.」とわかりやすいエラーメッセージをくれますが、リストの値名に大文字を使った場合の例外は400 Bad Requestで、「We're sorry, a server error occurred. Please wait a bit and try reloading your spreadsheet.」(1列目ヘッダに大文字アルファベットを使った場合)「Element or attribute do not match QName production: QName::=(NCName':')?NCName.」(1列目ヘッダに全角アルファベット)などの何が悪いのかわからんエラーメッセージを返してくれます。最後の例外はライブラリのバグな気もします*15が。

プロキシ経由のアクセス

GDataRequestFactory.Proxyに適当なSystem.Net.IWebProxy系オブジェクトを渡すと、リクエストの処理にそのプロキシを使うようになります。

しかし、OAuthUtilクラスにはプロキシを設定するすべがありません。なので、結局プロキシを使うためにはSystem.Net.WebRequest.DefaultWebProxyプロパティをいじる必要があります*16

プロセスグローバルにhttpプロキシを変更して問題がある場合はOAuthUtilクラスのスタティックメンバを叩くときだけDefaultWebProxyを変えるとかそんな小細工が必要になります*17

*1:TDが流行り始めたの、ちょうど一年ぐらい前でしたか。

*2:どうでもいいことですが、Excelutf-8など非Shift_JISで書かれたcsv食わすと文字化けします。

*3:6.0.1は2014年2月1日にリリースされた最新版です。

*4:ガンガンwarning出ましたが取り敢えず気にしなかった。

*5:メッセージは「An access token must be provided to use GOAuthRequestFactory」。

*6:hogehogeみたいな適当さでも動く。

*7:あとnugetで拾ったら大量のdependency降って来たのが気に食わなかったんじゃよ。

*8:内部で何かキャッシュしてるのかなと思ってクリアする方法探したけどわからんかったのでオブジェクト再生成しました。

*9:二つのURIを空白でつないだ文字列を設定するわけですね。

*10:SpreadsheetEntry.Authors[0]に著者情報が入ってるけどアカウント名などしかなく「自分のかどうか」の情報は入ってない。

*11:サンプルではSpreadsheetsService.Insert(WorksheetFeed wsFeed,WorksheetEntry wsEntry)叩いてるけど。

*12:飛び飛びにセル追加されるのが嫌、とかかしら。

*13:列データの入ったコレクション要素にも含まれます。

*14:StreamReader使うのが常套手段ですね。

*15:リクエストを見ると値名がおかしくなってる。

*16:さもなくば、アクセストークンが取得できないので実質的にAPI使えない。

*17:ソースコード公開されてるわけだし別にOAuthUtilとOAuthBaseクラスにProxy設定するプロパティ追加してあげれば済む、という説もある。