(メモ) HTTPリクエスト・レスポンスのキャプチャ手段

徳丸本こと「体系的に学ぶ 安全なWebアプリケーションの作り方」を読み中です。

(PDF版: http://bookpub.jp/books/bp/144)

この本は、サンプルの動作環境として、プロキシベースのHTTPモニタリングツール「Fiddler」を前提として書かれています。少し触ってみた感じでは、Fiddlerでは非常にお手軽にHTTPリクエスト・レスポンスの情報が見れて、この目的に限ればWiresharkよりも優れている印象ですが、残念ながらWindows版しか存在しないため、LinuxMacでは動きません。
Fiddler公式サイト: http://fiddler2.com/

ぐぐってみた限りでは、Fiddlerと同様にプロキシとして動作するCharlesというツールが定番のようなので、少し触ってみました。
Charles公式サイト: http://www.charlesproxy.com/

機能的にはCharlesで十分そうではあるものの、私が試した範囲では頻繁にフリーズするので、常用には厳しい印象を受けました。
(フリーズするのは、前述の徳丸本中のサンプルを含む特定のURL接続時の模様。フリーズする条件は特定できず)

以下の動作環境で試しています。

ちなみに、Oracle版のJDK7との組み合わせや、(apt版でない) 汎用Linux版でも試してみましたが、状況は変わりませんでした。

Fiddlerスクリーンショット:

さらに調べたところ、FirefoxFirebugChromeデベロッパーツールでは、HTTPリクエスト・レスポンスの内容を表示する機能があるので、さしあたりはこれで十分かなと思います。(もしかして常識?)

FirefoxFirebugスクリーンショット (「ネットワーク」タブ) :

Chromeデベロッパーツールのスクリーンショット (「Network」タブ):

DataMapperを使ってみる

DataMapper (Ruby向けO/Rマッパー) 以前使ったことがあるものの、最近のバージョンは触っていないので改めて試してみました。

動作環境

DataMapperのインストール

DataMpper 1.2.0 リリースノートより:

# gem install data_mapper dm-sqlite-adapter

実際に試してはいませんが、おそらく事前にlibsqlite3-dev (Ubuntu/Debianの場合) のインストールが必要です。

以下のサンプルプログラムは、DataMpper公式サイト内のGetting Startedを若干アレンジしたものです。

モデル定義

ブログ記事を表現するPost、ブログ記事へのコメントを表現するCommentの2つのモデルオブジェクトを定義します。
モデル定義は、この後作成するスクリプトからrequireできるように単独のファイルとして記述します。
ここでは、テーブルに相当するモデルオブジェクトに属性 (プロパティ) を定義するとともに、オブジェクト間の関連 (association) を has n, belongs_toで定義します。

【サンプルコード】dm_sample_model.rb

require 'data_mapper'

# ブログ記事
class Post
  include DataMapper::Resource

  property :id, Serial
  property :title, String
  property :body, Text
  property :created_at, DateTime

  has n, :comments
end

# コメント
class Comment
  include DataMapper::Resource

  property :id, Serial
  property :posted_by, String
  property :body, Text

  belongs_to :post
end

DataMapper.finalize

マイグレーション

作成したモデル定義に従って、データベース上にテーブル定義を作成します。
ここではデータベースとしてSQLiteを使うため、SQLiteデータベースの接続を記述します。

【サンプルコード】dm_sample_migration.rb

require 'data_mapper'
require 'dm-migrations'
require_relative 'dm_sample_model' # モデル定義

DataMapper::Logger.new($stdout, :debug)
DataMapper.setup(:default, 'sqlite:///tmp/project.db3')

DataMapper.auto_migrate!

【実行結果】

$ ruby dm_sample_migration.rb 
 ~ (0.000342) PRAGMA table_info("posts")
 ~ (0.000034) PRAGMA table_info("comments")
 ~ (0.000025) SELECT sqlite_version(*)
 ~ (0.000057) DROP TABLE IF EXISTS "posts"
 ~ (0.000014) PRAGMA table_info("posts")
 ~ (0.016498) CREATE TABLE "posts" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "title" VARCHAR(50), "body" TEXT, "created_at" TIMESTAMP)
 ~ (0.000076) DROP TABLE IF EXISTS "comments"
 ~ (0.000014) PRAGMA table_info("comments")
 ~ (0.008521) CREATE TABLE "comments" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "posted_by" VARCHAR(50), "body" TEXT, "post_id" INTEGER NOT NULL)
 ~ (0.002999) CREATE INDEX "index_comments_post" ON "comments" ("post_id")

念のため、sqlite3コマンドで生成されたテーブル定義を見てみます。
モデル定義に含まれる関連に従って、commentsテーブル (Commentクラスに対応) に post_id という属性が自動的に定義されていることが分かります。

$ sqlite3 /tmp/project.db3
sqlite> .schema
CREATE TABLE "comments" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "posted_by" VARCHAR(50), "body" TEXT, "post_id" INTEGER NOT NULL);
CREATE TABLE "posts" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "title" VARCHAR(50), "body" TEXT, "created_at" TIMESTAMP);
CREATE INDEX "index_comments_post" ON "comments" ("post_id");
(余談) はまりポイント

dm_sample_migration.rbから、同じディレクトリ上にあるモデル定義 (dm_sample_model.rb) を以下のようにしてもロードに失敗します。

require 'data_mapper'
require 'dm-migrations'
require 'dm_sample_model'
..

【実行結果】

$ ruby dm_sample_migration.rb
/usr/local/lib/ruby/1.9.1/rubygems/custom_require.rb:55:in `require': cannot load such file -- dm_sample_model (LoadError)
	from /usr/local/lib/ruby/1.9.1/rubygems/custom_require.rb:55:in `require'
	from dm_sample_migration.rb:3:in `<main>'

irbからライブラリ検索パス ($:) を確認してみると、カレントディレクトリ ('.') が含まれていないことが分かります。

$ irb
irb(main):001:0> $:
=> ["/usr/local/lib/ruby/site_ruby/1.9.1", "/usr/local/lib/ruby/site_ruby/1.9.1/x86_64-linux", "/usr/local/lib/ruby/site_ruby", "/usr/local/lib/ruby/vendor_ruby/1.9.1", "/usr/local/lib/ruby/vendor_ruby/1.9.1/x86_64-linux", "/usr/local/lib/ruby/vendor_ruby", "/usr/local/lib/ruby/1.9.1", "/usr/local/lib/ruby/1.9.1/x86_64-linux"]

結論としては、古いバージョンのRubyではライブラリ検索パス ($:) にカレントディレクトリが含まれていたのが、Ruby 1.9.2から含まれなくなり、代わりにrequire_relativeを使う必要があります。

記事の投稿

次に、新規のPostオブジェクトをデータベースに追加します。
実際のアプリケーションでは記事投稿フォームなどのUIを通して投稿することになりますが、ここではコマンドラインで実行します。

【サンプルコード】dm_sample_post.rb

# -*- coding: utf-8 -*-
require 'data_mapper'
require_relative 'dm_sample_model'

DataMapper::Logger.new($stdout, :debug)
DataMapper.setup(:default, 'sqlite:///tmp/project.db3')

# Postオブジェクトの永続化
post = Post.create(
  :title      => "DataMapper記事の投稿",
  :body       => "記事本文です。",
  :created_at => Time.now
)

【実行結果】

$ ruby dm_sample_post.rb 
 ~ (0.007423) INSERT INTO "posts" ("title", "body", "created_at") VALUES ('DataMapper記事の投稿', '記事本文です。', '2013-03-03T23:26:59+09:00')

ここで、テーブル作成のときと同様に、sqlite3コマンドでデータベースの内容を確認してみます。

$ sqlite3 /tmp/project.db3
sqlite> select * from posts;
1|DataMapper記事の投稿|記事本文です。|2013-03-03T23:26:59+09:00

記事の検索

DataMapperで永続化したオブジェクトの検索方法はいくつかありますが、ここではfirst (条件にマッチする最初のオブジェクトを取得) を使います。
ちょっと面白いのは、検索した時点ではすべての属性は取得しておらず、属性を参照した時点でSQLのSELECT文が発行される点です (遅延ローディング: lazy loading)。

【サンプルコード】dm_sample_find.rb

# -*- coding: utf-8 -*-
require 'data_mapper'
require 'pp'
require_relative 'dm_sample_model'

DataMapper::Logger.new($stdout, :debug)
DataMapper.setup(:default, 'sqlite:///tmp/project.db3')

post = Post.first(:title => "DataMapper記事の投稿")
pp post
puts "[id] #{post.id}"
puts "[title] #{post.title}"
puts "[body] #{post.body}"
puts "[created_at] #{post.created_at}"

【実行結果】

$ ruby dm_sample_find.rb 
 ~ (0.000358) SELECT "id", "title", "created_at" FROM "posts" WHERE "title" = 'DataMapper記事の投稿' ORDER BY "id" LIMIT 1
#<Post @id=1 @title="DataMapper記事の投稿" @body=<not loaded> @created_at=#<DateTime: 2013-03-03T23:26:59+09:00 ((2456355j,52019s,0n),+32400s,2299161j)>>
[id] 1
[title] DataMapper記事の投稿
 ~ (0.000038) SELECT "id", "body" FROM "posts" WHERE "id" = 1 ORDER BY "id"
[body] 記事本文です。
[created_at] 2013-03-03T23:26:59+09:00

作成したスクリプトによる出力とDataMapperのログが混在してやや分かりづらいですが、ppによる出力時点では @body= となっていて、その後SELECT文が発行されている様子が観察できます。

コメントの投稿

さらに、先ほどの記事に対するコメントの投稿を試してみます。
基本的には記事の投稿と同様ですが、関連 (association) の定義に従って、Commentオブジェクトのpost属性にPostオブジェクトをセットすることで、どの記事に対するコメントかを指定することができます (データベース上のpost_id属性に相当)。

【サンプルコード】dm_sample_comment.rb

# -*- coding: utf-8 -*-
require 'data_mapper'
require_relative 'dm_sample_model'

DataMapper::Logger.new($stdout, :debug)
DataMapper.setup(:default, 'sqlite:///tmp/project.db3')

post = Post.first(:title => "DataMapper記事の投稿")

comment = Comment.create (
  :posted_by => "m-kawato",
  :body => "コメントです。",
  :post => post
)

【実行結果】

$ ruby dm_sample_comment.rb 
 ~ (0.000359) SELECT "id", "title", "created_at" FROM "posts" WHERE "title" = 'DataMapper記事の投稿' ORDER BY "id" LIMIT 1
 ~ (0.000041) SELECT "id", "body" FROM "posts" WHERE "id" = 1 ORDER BY "id"
 ~ (0.015866) INSERT INTO "comments" ("posted_by", "body", "post_id") VALUES ('m-kawato', 'コメントです。', 1)

さらに、データベースの内容をsqlite3コマンドで確認してみます。

$ sqlite3 /tmp/project.db3
sqlite> select * from comments;
1|m-kawato|コメントです。|1

まとめ

この記事では、ブログ記事とコメントの投稿・検索を題材とした簡単なサンプルを通して、O/RマッパーDataMapperの基本的な機能を確認しました。

Ubuntu 12.10上でのavconv (旧ffmpeg) を使ったMP3エンコード

Ubuntu 12.10上で動画ファイル変換ツールavconv (ffmpegからフォークして名称変更したらしい) を使ってMP3エンコードする方法についてメモ。

参考情報: http://ubuntuforums.org/showthread.php?t=1967864

単にavconvとlibmp3lame0を入れただけの状態ではMP3エンコードしてくれないようです。

$ avconv -i infile.flv -c:a libmp3lame -b:a 192k outfile.mp3 
avconv version 0.8.4-6:0.8.4-0ubuntu0.12.10.1, Copyright (c) 2000-2012 the Libav developers
  built on Nov  6 2012 16:49:20 with gcc 4.7.2
[flv @ 0x8b770c0] Estimating duration from bitrate, this may be inaccurate
Input #0, flv, from 'infile.flv':
(略)
    Stream #0.0: Video: flv, yuv420p, 320x180, 765 kb/s, 1k tbr, 1k tbn, 1k tbc
    Stream #0.1: Audio: nellymoser, 44100 Hz, mono, s16, 88 kb/s
Unknown encoder 'libmp3lame'

「参考情報」に挙げたページによると、"ubuntu-restricted-extras" というパッケージを入れれば良いらしいということで、とりあえず何も考えずに入れてみたら無事エンコードできました。

$ sudo apt-get install ubuntu-restricted-extras

$ avconv -i infile.flv -c:a libmp3lame -b:a 192k outfile.mp3 
avconv version 0.8.4-6:0.8.4-0ubuntu0.12.10.1, Copyright (c) 2000-2012 the Libav developers
  built on Nov  6 2012 16:49:20 with gcc 4.7.2
[flv @ 0x9e56880] Estimating duration from bitrate, this may be inaccurate
Input #0, flv, from 'infile.flv':
(略)
    Stream #0.0: Video: flv, yuv420p, 320x180, 765 kb/s, 1k tbr, 1k tbn, 1k tbc
    Stream #0.1: Audio: nellymoser, 44100 Hz, mono, s16, 88 kb/s
Output #0, mp3, to 'outfile.mp3':
(略)
    Stream #0.0: Audio: libmp3lame, 44100 Hz, mono, s16, 192 kb/s
Stream mapping:
  Stream #0:1 -> #0:0 (nellymoser -> libmp3lame)
Press ctrl-c to stop encoding
size=  116303kB time=4962.25 bitrate= 192.0kbits/s    
video:0kB audio:116303kB global headers:0kB muxing overhead 0.000490%

apt-get install ubuntu-restricted-extrasで複数のパッケージがインストールされるので、avconv+libmp3lameの必要条件は特定できませんでしたが、おそらくlibavcodec-extra-53あたりが肝かと思います。

なお、今回変換元に使ったFLVファイルではNellymoserというオーディオコーデックが使われており、古めのプレイヤーや変換ツールでは扱えないようです (という動機でavconvを使う方法を試しました)。

(メモ) Rails+OmniAuthによるTwitterログイン

Ruby/Rails向けの認証連携フレームワークの定番らしいOmniAuthを使って、OAuth経由でTwitterに投稿するサンプルRailsアプリを作ってみました。

参考にしたサイト:

「簡単なOmniAuth」に詳細な使い方が説明されていますが、現在のOmniAuth 1.1と合っていない箇所があるので、適宜公式ドキュメントを参照しています。

動作環境:

Twitterのアプリケーション登録

まず準備作業として、Twitterの開発者サイト (https://dev.twitter.com/) で新規のアプリケーションを登録します。開発者サイトの構成は割と良く変わるようですが、この記事を書いた時点では、ログインした状態で右上の自分のアイコンにマウスオーバー→My Applicationで行けるようです。

My applicationsから "Create a new application" を選択し、必要な情報を入力します。ここで重要なのは "Callback URL" で、TwitterのWebサイト上でユーザ認証した後に、ここに入れたURLにリダイレクト (コールバック) されることになります。今回は、元ネタのASCIIcasts/RailsCastsの記述に合わせて、http://127.0.0.1:3000/auth/twitter/callback とします。

あと、本筋ではありませんが、ここで作成したアプリケーションからTwitterにメッセージを投稿するために、Access TypeをRead onlyからRead and Writeに変える必要があります。この設定は新規アプリケーション作成時にはできなくて、一旦アプリを作成してからSettingsで変更する必要があるようです。

アプリケーションのページに表示されるOAuthのConsumer key・Consumer secretの内容を後で使います。

ライブラリを使うための準備

RailsアプリケーションからOmniAuthおよびTwitterライブラリを使うために、Gemfileへの情報追加とBundlerの実行を行います。

rails newコマンドで作成したRailsアプリ (ここではtwitter-omniauth-testとします) のルート直下にあるGemfileファイルに以下の内容を追加した後で、bundle installを実行します。

twitter-omniauth-test/Gemfileの追加内容:

....
# 以下の2行を追加
gem 'omniauth-twitter'
gem 'twitter'

アプリケーションの動作手順

ここで、OmniAuth+omniauth-twitterを使ったアプリケーションの動作手順 (ページ遷移) について整理しておきます。


  1. まず、ユーザはアプリケーションのトップ画面をブラウザで開きます。この中にある "Sign in with Twitter" のリンクをクリックすると、omniauth-twitterの制御下に移ります。このURLは /auth/twitter となります。
  2. omniauth-twitterは、OAuthのアクセストークンを取得するため、Twitterのサイトにリダイレクトします。
  3. Twitterサイトとユーザの間で認証処理を行います。すでにユーザがTwitterにログイン済みである場合には、ユーザは何もせずに4.に進むことになります。
  4. TwitterサイトからOAuthコールバックURL (Twitter開発者サイトで入力したもの) にリダイレクトします。
  5. OAuthコールバックURLに対応するコントローラの中で、認証に成功したユーザについてのセッションを作成します。具体的には、Twitterから発行されたアクセストークンの格納を行います。
  6. OAuthコールバックURLからアプリケーションのトップ画面にリダイレクトします。この状態では、ユーザはすでにログイン済みなので、"Sign in with Twitter" の代わりに "Sign out" のリンクが現れます。

この動作手順のうち、1、5、6の部分はアプリケーション内で記述する必要があり、それに加えてomniauth-twitterを使うための記述が必要になります。

OmniAuth初期化スクリプトの作成

omniauth-twitterに対してOAuthのConsumer Key/Secretをセットするための初期化スクリプトを追加します。
twitter-omniauth-test/initializers/omniauth.rb:

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :twitter, CONSUMER_KEY, CONSUMER_SECRET
end

CONSUMER_KEY/SECRETには、先ほど取得したConsumer key/secretの内容を入れます。

アプリケーションのトップ画面

ここではビューの中で、ユーザのログイン状態に応じて、ログイン/ログアウト用のリンクを生成します。

コントローラの中で、ユーザがログイン状態であるかどうかを判別するためのヘルパーメソッドを定義します。今回はRailsのセッション機能をそのまま使って、セッション情報にOAuthのアクセストークンが含まれていればログイン状態と判断しています。

twitter-omniauth-test/app/controllers/application_controller.rb:

class ApplicationController < ActionController::Base
  protect_from_forgery
  helper_method :signed_in?

  private
  def signed_in?
    true if session[:oauth_token]
  end 
end

session[:auth_token] の格納については後述します。

ビューでは、先ほど定義した signed_in? メソッドを用いて表示するリンクを切り替えます。
twitter-omniauth-test/app/views/layouts/application_controller.html.erb:

<body>
  <div id="container">
    <div id="user_nav">
      <% if signed_in? %>
        Welcome <%= session[:username] %>!
        <%= link_to "Sign Out", "/signout" %>
      <% else %>
        <%= link_to "Sign in with Twitter", "/auth/twitter" %>
      <% end %>
    </div>
  <%= yield %>
  </div>
</body>

ここで、未ログイン状態のユーザが "Sign in with Twitter" のリンクをクリックすると、omniauth-twitterを介してTwitterサイトにリダイレクトされることになります。

セッションの作成

Twitterサイトでの認証に成功すると、http://localhost:3000/auth/twitter (Twitter開発者サイトで指定したコールバックURL) にリダイレクトされます。
このURLをコントローラsessionsのアクションcreateで処理するために、ルーティングの設定を追加します。

twitter-omniauth-test/config/routes.rb:

TwitterOmniauthTest::Application.routes.draw do
  root :to => "tweet#input"
  get "tweet/input"
  post "tweet/update"
  match "/auth/:provider/callback" => "sessions#create"
  match "/signout" => "sessions#destroy"
end

OAuthを用いた認証に成功すると、OmniAuthにより request.env["omniauth.auth"] にアクセストークンなどの情報がセットされるので、sesssions#createではこれらの情報を取得します。参照元のRailsCastではモデルオブジェクトに情報をセットしていますが、ここでは安直にsessionに入れています。
(RailsのデフォルトではCookieに入ってクライアント側に渡ることになりますが、本来はサーバ側でDBなどに格納するのが正しいはずです)

class SessionsController < ApplicationController
  def create
    auth = request.env["omniauth.auth"]
    session[:oauth_token] = auth.credentials.token
    session[:oauth_token_secret] = auth.credentials.secret
    session[:username] = auth.extra.access_token.params[:screen_name]
    redirect_to root_url, :notice => "Signed in!"
  end

  def destroy
    session[:oauth_token] = nil
    session[:oauth_token_secret] = nil
    session[:username] = nil
    redirect_to root_url, :notice => "Signed out!"
  end
end

ツイート送信

OAuthに関する処理はこれで完了ですが、取得したアクセストークンを使ってTwitterにアクセスできることを確認します。

アプリケーションのトップ画面に以下のようなフォームを作成して、フォームに入力したメッセージをTwitterに送信します。
twitter-omniauth-test/app/views/tweet/input.html.erb:

<h1>Twitter Sample Application</h1>
<p>Enter a message:</p>
<%= form_tag({:action => "update"}, {:method => "post"}) do %>
  <%= text_field_tag(:message) %>
  <%= submit_tag("Submit") %>
<% end %>

ブラウザから見た画面はこんな感じになります。

tweet#update のコントローラでは、先ほど取得したアクセストークンを使用してツイートを投稿します。

twitter-omniauth-test/app/controllers/tweet_controller.rb:

class TweetController < ApplicationController
  def input
  end

  def update
    if signed_in?
      client = Twitter::Client.new(
        :oauth_token => session[:oauth_token],
        :oauth_token_secret => session[:oauth_token_secret]
      )
      client.update(params[:message])
      @result = :success
    else
      @result = :not_signed_in
    end
  end
end

(メモ) Google APIを呼び出すためのOAuth 2.0認可処理

まだちゃんと動くところまで行っていませんが、現時点までの作業メモ。

目的は、Google Calendar APIを用いたWebアプリ (スケジュール入力支援) をRuby on Railsで作ることです。
Google APIにはRuby用のクライアントライブラリも提供されていることもあり、API呼び出し自体はそれほど難しそうではないものの、アクセス権の取得のためにOAuth 2.0への対応が必要になり、むしろこちらの方が手間がかかりそうです。

使用ソフトウェアとバージョン

環境構築 (Google API)

まずGoogle APIを使うための下準備をします。必要な作業は以下の2つ。

  • Google API Consoleでのプロジェクト作成
  • Google APIクライアントライブラリのインストール

いずれもGoogle Calendar APIのホームページからたどれるドキュメントを参照して問題なく完了しました。
なお、Google APIRubyクライアントライブラリは現在alpha版とのことで、まだ実運用環境で使える段階ではないようです。

OAuth 2.0認可処理 (スタンドアロン版)

Railsとの組み合わせの前に、Ruby版Google APIクライアントライブラリ に載っているサンプルを元に、スタンドアロン (というかirb) でOAuth2の挙動を確認します。

途中のステップで、Googleのサイトでの承認操作を行ったあと、そこで発行されるauthorization codeが必要になる都合上、ここではirbを用います。途中で登場する , には、Google APIコンソールから取得した文字列が入ります。

$ irb
irb(main):001:0> require 'google/api_client'
=> true
irb(main):002:0> client = Google::APIClient.new
=> #<Google::APIClient:0x0000000182fbd0 ... >
irb(main):003:0> client.authorization.client_id = <Client ID>
=> <Client ID>
irb(main):004:0> client.authorization.client_secret = <Client Secret>
=> <Client Secret>
irb(main):005:0> client.authorization.redirect_uri = 'urn:ietf:wg:oauth:2.0:oob'
=> "urn:ietf:wg:oauth:2.0:oob"
irb(main):006:0> client.authorization.scope = 'https://www.googleapis.com/auth/plus.me'
=> "https://www.googleapis.com/auth/plus.me"
irb(main):007:0> redirect_uri = client.authorization.authorization_uri
=> #<Addressable::URI:0xc06fc0 URI:https://accounts.google.com/o/oauth2/auth?... >

上記引用部分最後に出てくる URI:... の部分がGoogleに対する承認操作を行うためのURLです。ブラウザからこのURLにアクセスすると、以下のように許可を求められます。ここで「アクセスを許可」を選択すると、Googleにログインしたアカウントの権限で、「Googleでのユーザの把握」という許可をアプリケーション (ここではirb上で動かしているもの) に与えることになります。

ここで「アクセスを許可」を選択した結果、以下のようなコードが出力されます。

以下、irbに戻って、先ほど出力されたコードを張り付けることでOAuth 2.0を用いた認可処理が完了になります。

irb(main):008:0> client.authorization.code = '4/g8B6U7tySrGFXRFvpamZ_KkcEOW2.QiOkz9dGebYXgrKXntQAax0O2E6GdwI'
=> "4/g8B6U7tySrGFXRFvpamZ_KkcEOW2.QiOkz9dGebYXgrKXntQAax0O2E6GdwI"
irb(main):009:0> client.authorization.fetch_access_token!
=> {"access_token"=>"ya29.AHES6ZQ (略)", "token_type"=>"Bearer", "expires_in"=>3600, "id_token"=>"eyJhbGc (略)", "refresh_token"=>"1/vYol_NH (略)"}
irb(main):010:0> result = client.execute(:api_method => client.discovered_api('plus').activities.list, :parameters => {'collection' => 'public', 'userId' => 'me'})
=> #<Google::APIClient::Result:0x00000001adadd0 @request=...>

ここまで一応成功しているようです。

WebSocketの中身を覗いてみる

この記事は、HTML5 Advent Calendar 2012の15日目のエントリーです。

WebSocketは、Webサーバ・ブラウザ間で双方向に通信するための仕様であり、APIプロトコルがそれぞれ以下の規格で定義されています。

Node.js + Socket.IO のようなライブラリを使うと割と簡単にWebSocketが使えますが、中で何が起こっているかもう少し追ってみたいという動機により、tcpdump+WireSharkによるパケットキャプチャを通してWebSocket通信の中身を調べてみました。

作業環境

サーバ側、クライアント側ともホストはWindows 7 (64ビット版) で、サーバはその上の仮想マシンとして動かしています。おそらくOSに依存する要素は特にないはずです。

Node.js用のWebSocketライブラリとしてはSocket.IOが有名ですが、生のWebSocket APIをラップする形で使うことを前提としているため、ここでは素のWebSocket APIとの組み合わせが容易なWebSocket-Nodeをチョイスしました。
WebSocket-Nodeは、npm install websocketでインストールできます。

サンプルの動作手順

ここではごく単純に、ブラウザからWebSocketサーバに接続後、Webサーバ→ブラウザ、ブラウザ→Webサーバと1回ずつメッセージを送る処理を実装することにします。

これを実装したサーバ側・クライアント側のコードはそれぞれ以下のようになります。

サーバ
var http = require('http');
var WebSocketServer = require('websocket').server;
var fs = require('fs');

var server = http.createServer(function(req, res) {
  fs.readFile(__dirname + '/client.html', function(err, data) {
    res.writeHead(200);
    res.end(data);
  });
});
server.listen(8080);

// WebSocketサーバの作成
var wsServer = new WebSocketServer({
    httpServer: server,
    autoAcceptConnections: true
});

// クライアントからのWebSocket接続時の処理
wsServer.on('connect', function(connection) {
  console.log('Connection accepted, protocol version ' + connection.webSocketVersion);
  connection.send('Hello, world');

  // クライアントからのメッセージ受信処理
  connection.on('message', function(message) {
    console.log('Received Message: ' + message.utf8Data);
  });
});
クライアント
<!DOCTYPE HTML>
<html><head></head>
<body>
<script type="text/javascript">
  // WebSocketサーバとの接続 (動作手順1)
  var ws = new WebSocket("ws://192.168.206.132:8080");
  ws.onopen = function() {
    console.log("connected.");
  }

  // サーバからのメッセージ受信処理
  ws.onmessage = function(evt) {
    console.log("Received: " + evt.data);
    ws.send('Good bye.');
  };
  </script>
</body>
</html>

実行結果

クライアント側では、サーバから受け取った "Hello, world" がJavaScriptコンソール上に出力されます。

サーバ側では、クライアントから受け取った "Good bye" がターミナル上に出力されます。

ひとまず期待通りに動くことが確認できました。

パケットキャプチャ

ようやくここからが本題。
先ほど動かしたWebSocketのサンプルについて、クライアント-サーバ間の通信をキャプチャして、WebSocketプロトコル (RFC 6455) の内容と比較してみます。

ここでは、パケットキャプチャのためにtcpdump、それをGUIベースで解析するためにWireSharkという組み合わせを使います。Ubuntuの場合は、いずれもapt-getで入ります。

tcpdumpに指定したオプションはこんな感じ:

$ sudo tcpdump -i eth0 -s 0 -w dump01.cap

出力されるキャプチャファイル (ここではdump01.cap) をWireSharkから読む込むことでプロトコル解析できます。WebSocketプロトコルにも対応しているので楽チンです。

単純にWireSharkからキャプチャファイルを読み込んだだけでは無関係なパケットも入り込むので、Filter: 欄に以下のように指定して、8080ポート (今回WebSoketサーバに指定したListenポート) への入出力かつHTTPだけを表示するようにします。

tcp.port==8080 && http

(httpを指定すると、WebSocketも自動的に含まれるようです)

以降、今回試したサンプルの動作手順に従って、キャプチャ結果とWebSocketプロトコル仕様の対応関係を見ていきます。

1. WebSocket接続 (ハンドシェイク)

RFC 6455の1.2節および4章によると、WebSocket接続はOpening Handshakeと呼ぶ手続きにより開始され、Opening Handshakeはクライアントからのハンドシェイクとサーバからのハンドシェイクにより構成されます。

まず、キャプチャ結果の以下の部分 (No.20) に注目します。

これはクライアント (192.168.206.1)→サーバ (192.168.206.132) のパケットで、以下のようなHTTPリクエストヘッダの形をしています。

GET / HTTP/1.1
Upgrade: websocket
Connection: upgrade
Host: 192.168.206.132:8080
Origin: http://192.168.206.132:8080
Sec-WebSocket-Key: ByrM/ZMQsliJ3ARpSzF6lg==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: x-webkit-deflate-frame

RFC 6455と突き合わせると、これは4.1節に記載されているクライアントのopening handshakeであることが分かります。

その直後の行 (No.22) は以下のようになっています。

これは先ほどとは逆にサーバ→クライアントのパケットで、以下のようなHTTPレスポンスヘッダの形をしています。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: j+6fEtOsvfgsycBMCfLxWNzPFxk=
Origin: http://192.168.206.132:8080

これは、RFC 6455に記載されているサーバのopening handshakeに相当します。

2. サーバ→クライアントのメッセージ

ここまででWebSocketのハンドシェイクは完了で、ここからはWebSocket接続を通したメッセージの送受信が行われることになります。

キャプチャ結果の No.23 の行は以下のようになっています。

これはRFC 6455の5章のデータフレーミング (data framing) に相当します。
フレームの各要素は以下のようになっていることが分かります。

1... .... = Fin: True          # メッセージ中の最後のfragment
.000 .... = Reserved: 0x00     # 予約済みビット (常に000)
.... 0001 = Opcode: Text (1)   # フレームの種類がテキストフレームであることを示す
0... .... = Mask: False        # ペイロードはマスクされない
.000 1100 = Payload length: 12 # ペイロード長は12

Payload
    Text: Hello, world

すなわち、これがサーバからクライアントに送った "Hello, world" に相当するパケットということになります。

3. クライアント→サーバのメッセージ

次にキャプチャ結果のNo.29の行を見てみます。

サーバ→クライアントのメッセージとほとんど変わりませんが、ペイロードがマスキングされた形で送られるという相違があります。

1... .... = Fin: True          # メッセージ中の最後のfragment
.000 .... = Reserved: 0x00     # 予約済みビット (常に000)
.... 0001 = Opcode: Text (1)   # フレームの種類がテキストフレームであることを示す
1... .... = Mask: True         # ペイロードはマスクされる
.000 1001 = Payload length: 9  # ペイロード長は9
Masking-Key: 9539ffdd          # マスキングキーは 9539ffdd

Payload
  Text: d25690b9b55b86b8bb
Unmask Payload
  [Text unmask: Good bye.]

RFC 6455の5.2〜5.3節には、クライアントからサーバへのメッセージでは、ペイロードのマスキングが必須であり、マスクされたペイロードとマスキングキーのXORを取ることで元の値が復元できる旨が書かれています。が、その理由については述べられていません。

少し調べた限りではある種の攻撃を防ぐための目的のようですが、正直なところあまり理解できていないので、今回はこういうものということでお茶を濁しておきます。

おわりに

この記事では、WebSocket APIプロトコルの関係を実装に即して理解するために、簡単なWebSocket通信のサンプルを動かして、WireSharkでパケットを分析しました。
今回は非常に単純なテキストの送受信しか試していませんが、断片化されるような長いペイロードやバイナリ形式など、これ以外のパターンについても同様の分析をすると面白いと思います。また、Socket.IOを使った場合にどのようにメッセージがエンコードされるかも興味のあるところです。

JavaScriptの基本技メモ

JavaScriptをいまひとつ使いこなせていないので、Pragmatic Guide to JavaScriptの1〜3章を参考に、JavaScriptの基本的なテクニックを再学習しました。

動作環境

サンプルプログラムを手元で動かす環境として、node.js (0.8.14) を使いました。今回の範囲ではDOMを使わないので、ブラウザ上で実行するよりもこちらの方がお手軽に試せると思います。

ブラケットを用いたメソッドの動的な選択

オブジェクトのプロパティを参照する手段として、object.memberとobject[member]の2種類がありますが、前者は静的なメンバ名しか指定できないのに対して、後者は実行時の式の評価結果をメンバ名として指定することができるという違いがあります。
メソッドに対しても同様の規則が使えるので、式の評価結果によって異なるメソッドを呼び出すことができます。

if文を使った場合:

myObj = Object();

myObj.hello = function() {
  console.log('Hello.');
}
myObj.bye = function() {
  console.log('Bye.');
}

var sayHello = true;
if (sayHello) {
  myObj.hello();
} else {
  myObj.bye();
}
// => Hello.

ブラケットの記法を使って、上記のif文を以下のように置き換えることができます。

myObj[sayHello ? 'hello' : 'bye']();

無名関数を使ったスコープ作成

キーワードvarを用いたローカル変数のスコープは、変数を定義した関数の内側となるので、以下のように無名関数を使ってスコープを限定することができます。

(function() {
  var localVariable = 'some value';
  ...
})();

試しに、無名関数の内側で定義した変数を、外側から参照してみます。

(function() {
  var message = 'Hello, world';
  console.log('Inside the function: ' + message); 
})();
console.log('Outside the function: ' + message);

実行結果:

$ node local.js 
Inside the function: Hello, world

/tmp/local.js:6
console.log('Outside the function: ' + message);
                                       ^
ReferenceError: message is not defined

応用ワザとして、無名関数からローカル変数をプロパティとするオブジェクトを返すことで、関数の外側からのアクセスが可能になります。

var obj = (function() {
  var message = 'Hello, world';
  return { message: message };
})();

console.log('Outside the function: ' + obj.message);
// => Outside the function: Hello, world

argumentsを用いた可変長引数

関数の内側ではargumentsという名前の配列が自動的に定義され、ここにすべての実引数が入ります。

サンプル:

function func() {
  for (var i = 0; i < arguments.length; i++) {
    console.log('arguments[' + i + ']: ' + arguments[i]);
  }
}

func(10, 20, 'foo');

実行結果:

arguments[0]: 10
arguments[1]: 20
arguments[2]: foo

オブジェクトを用いた擬似的なキーワード引数とデフォルト値

Rubyなどでもよく用いられる、ハッシュを使った擬似的なキーワード引数と同様のテクニックです。ここでは、指定されなかったキーワードについてデフォルト値を設定しています。

function repeat(options) {
  options = options || {}; // optionsがfalseと等価な場合は空のオブジェクトを代入
  for (var opt in repeat.defaultOptions) {
    // 指定されないキーワードについてデフォルト値に置き換え
    if (!(opt in options)) {
      options[opt] = repeat.defaultOptions[opt];
    }
  }

  for(var i = 0; i < options.times; i++) {
    console.log(options.message);
  }
}
// デフォルト値のセット
repeat.defaultOptions = {times: 2, message: 'Hello, world'};

repeat(); //=> 'Hello, world' が2回表示される
repeat({times: 3}); //=> 'Hello, world' が3回表示される
repeat({message: 'Good evening.'}); //=> 'Good evening.' が2回表示される
repeat({times: 1, message: 'Good bye.'}); //=> 'Good bye.' が1回表示される

メモ: falseと評価される値は以下の通り。

  • undefined
  • null
  • 0
  • NaN
  • 空文字列 ('')