git/GitHubを便利に使うHubを少し読む(3)

git/GitHubを便利に使うRuby製ツールのコマンドラインツール「Hub」のソースコードを少し読んだので、あれこれメモ。これこれの続き。

GitHubAPIをラップする lib/hub/github_api.rb は、クラス定義関連を抜き出すと以下。

module Hub
  class GitHubAPI
    module Exceptions
    module HttpMethods
      module ResponseMethods
    module OAuth
    include HttpMethods
    include OAuth
    class FileStore
    class Configuration

HttpMethodsとOAuthは、定義直後に include している。ということは、これらはソースコードの読み手に意味的な構造を伝えているだけで、名前空間を切り分けているわけでもなければ、Hub::GitHubAPI以外のクラスでincludeしようとか、そういう理由から module としているわけではないのか?

試しに、以下のようにフラットな構造にして、テストを通してみる。

-    module HttpMethods
+#    module HttpMethods
       # Decorator for Net::HTTPResponse
       module ResponseMethods
         def status() code.to_i end
@@ -223,9 +223,9 @@ module Hub
         end
         return http
       end
-    end
+#    end
 
-    module OAuth
+#    module OAuth
       def apply_authentication req, url
         if (req.path =~ /\/authorizations$/)
           super
@@ -253,10 +253,10 @@ module Hub
           res.data['token']
         end
       end
-    end
+#    end
 
-    include HttpMethods
-    include OAuth
+#    include HttpMethods
+#    include OAuth
 

あれ、テストが落ちる。なんだろうと思ったら、module OAuthの apply_authentication では、実は super で継承ツリーの上にいるメソッドを呼んでいるのだった。関係するところを抜き出すと、

    module OAuth
      def apply_authentication req, url
        if (req.path =~ /\/authorizations$/)
          super
        else
          user = url.user || config.username(url.host)


    module HttpMethods

      def apply_authentication req, url
        user = url.user || config.username(url.host)
        pass = config.password(url.host, user)
        req.basic_auth user, pass
      end

    include HttpMethods
    include OAuth

となっている。moduleを include するというのは、本当にメソッドをクラスに混ぜ込んでいるわけではない。includeした瞬間にRuby処理系は「Class.new」相当の無名クラスを生成して、このクラスを継承チェーンに挿入している。挿入するのは、includeするクラスと、そのクラスのスーパークラスの間。例えば、

module M
  def foo
    puts "I'm a module."
  end
end

class C
  include M

  def foo
    puts "I'm a class."
    super
  end
end

C.new.foo

としたとき、この super は、M#fooの呼び出しに相当するので、

$ ruby include.rb
I'm a class.
I'm a module.

となる。つまり、include順も重要ということで、

    include HttpMethods
    include OAuth

をひっくり返すと、これまたテストがコケる。HttpMethodsとOAuthとで重複しているメソッドは、apply_authenticationだけなので、この2つを継承順に注意して逆リファクタリングすると、テストは通る。OAuthのapply_authenticationのsuperの部分に、HttpMethodsのapply_authenticationを展開して、それをHttpMethodsのほうに混ぜ込んで、moduleの階層を消す。

diff --git a/lib/hub/github_api.rb b/lib/hub/github_api.rb
index 32eb3ad..0696d48 100644
--- a/lib/hub/github_api.rb
+++ b/lib/hub/github_api.rb
@@ -115,7 +115,7 @@ module Hub
     # - proxy_uri(with_ssl)
     # - username(host)
     # - password(host, user)
-    module HttpMethods
+#    module HttpMethods
       # Decorator for Net::HTTPResponse
       module ResponseMethods
         def status() code.to_i end
@@ -197,9 +197,17 @@ module Hub
       end
 
       def apply_authentication req, url
-        user = url.user || config.username(url.host)
-        pass = config.password(url.host, user)
-        req.basic_auth user, pass
+        if (req.path =~ /\/authorizations$/)
+          user = url.user || config.username(url.host)
+          pass = config.password(url.host, user)
+          req.basic_auth user, pass
+        else
+          user = url.user || config.username(url.host)
+          token = config.oauth_token(url.host, user) {
+            obtain_oauth_token url.host, user
+          }
+          req['Authorization'] = "token #{token}"
+        end
       end
 
       def create_connection url
@@ -223,21 +231,9 @@ module Hub
         end
         return http
       end
-    end
-
-    module OAuth
-      def apply_authentication req, url
-        if (req.path =~ /\/authorizations$/)
-          super
-        else
-          user = url.user || config.username(url.host)
-          token = config.oauth_token(url.host, user) {
-            obtain_oauth_token url.host, user
-          }
-          req['Authorization'] = "token #{token}"
-        end
-      end
+#    end
 
+#    module OAuth
       def obtain_oauth_token host, user
         # first try to fetch existing authorization
         res = get "https://#{user}@#{host}/authorizations"
@@ -253,10 +249,10 @@ module Hub
           res.data['token']
         end
       end
-    end
+#    end
 
-    include HttpMethods
-    include OAuth
+#    include HttpMethods
+#    include OAuth
 
     # Filesystem store suitable for Configuration
     class FileStore

つまり、OAuthはOAuthのときのための差分を定義するモジュールということ。2つしかメソッドがない。

で、HttpMethodsが何をしているかというと、内部DSLをGitHubAPIクラスに提供している。Hub::GitHubAPIの典型的なAPIリクエストのメソッドは、以下のような感じになる。

    # Public: Fork the specified repo.
    def fork_repo project
      res = post "https://%s/repos/%s/%s/forks" %
        [api_host(project.host), project.owner, project.name]
      res.error! unless res.success?
    end

GitHubへのAPIコールは、すべてのこの形式で抽象化されている。文字列に変数を評価して埋め込むとき、#{} を使ったインターポレーションをすることがRubyでは多いけど、複数のデータを渡すなら、このように、String#% で流しこむのも見やすくていいかもなと思った。

ポイントは、

  • HTTP POST/GET は、post/getというメソッド名で抽象化されている
  • projectというオブジェクトは実際には色々あって、ダックタイピングの典型ぽい

というところかなと思う。

で、例えば、getのほうは、

def get url, &block
  perform_request url, :Get, &block
end

となっていて、さらにperform_requestを呼んでいる。パラメータで渡されている :Get は、

def perform_request url, type
  url = URI.parse url unless url.respond_to? :host

   require 'net/https'
   req = Net::HTTP.const_get(type).new request_uri(url)

のように、Net::HTTP::Getのクラスへの参照を持つ定数を引っ張りだすときに、const_getのリフレクションを利用する場面で使われている。

github_api.rb を見ていて改めて思ったけど、どんどん細かくメソッドを分けることで挙動に名前をつけるというのが、Rubyっぽい。例えば、コマンドラインから与えられたプロジェクトが存在するかどうかを確かめる GitHubAPI#repo_exists? というメソッドは以下のように、1行になっている。

    # Public: Fetch data for a specific repo.
    def repo_info project
      get "https://%s/repos/%s/%s" %
        [api_host(project.host), project.owner, project.name]
    end

    # Public: Determine whether a specific repo exists.
    def repo_exists? project
      repo_info(project).success?
    end

repo_infoを呼び出して、それが success? かどうかを確かめている。この success? というのは、net/httpのNet::HTTPResponseに混ぜ込むために用意してある、Hub::GitHubAPI::HTTPMethods::ResponseMethodsに以下のように定義されている。

    module HttpMethods
      # Decorator for Net::HTTPResponse
      module ResponseMethods
        def status() code.to_i end
        def data?() content_type =~ /\bjson\b/ end
        def data() @data ||= JSON.parse(body) end
        def error_message?() data? and data['errors'] || data['message'] end
        def error_message() error_sentences || data['message'] end
        def success?() Net::HTTPSuccess === self end

実際にこのモジュールは、perform_requestで、

        res = http.start { http.request(req) }
        res.extend ResponseMethods

という風に利用されている。HTTPリクエストを出して、戻ってきたレスポンスに対して、、、毎回そのオブジェクトにデコレータとしてのメソッドを混ぜ込んでいる。

いくらなんでも毎回混ぜ込むとかCPUリソースを何だと思ってるんや! という気も一瞬したけど、Hubは人間の使うコマンドラインツールなので、ハッキリ言ってIOの絡まないCPUリソースだけの処理時間なんて誤差もいいところ。インターネットで行ってこいするHTTPリクエストのラウンドトリップが所要時間を決定するので、こういう動的なやり方のパフォーマンス上のデメリットを気にするやつは豚に食われて死ねってことか。

ところで、1行の定義で済むメソッドを1行に書くときに、

class Foo
  def foo; puts "foo" end
end

class Foo
  def foo() puts "foo" end
end

の2つの流儀があり得る。ぼくは前者を使っていた。それは「続けて書いたらRubyのパーザには分かるまい」という配慮だったけど、考えてみれば、元々メソッド呼び出しのときのカッコ・コッカを省略しているという話なのだし、読み手であるプログラマにとっての分かりやすさを考えると、カッコ・コッカを入れたほうがいいような気もしてきた。それによって、Ruby処理系にも分かるし、人間にも分かる。

カッコ・コッカのほうがセミコロンよりも、Ruby特有の事情がないぶんプログラマ一般での通用度が高いような気もするけど、でもセミコロンが文末区切りの明示に使われるのだというのも別に不自然でも分かりづらいわけでもない。この辺は単に取り決めの問題かしら。