Crystal 0.7.4の時代に作ったbotを0.9.1に対応させてStreamingを使う

  • 7
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

この投稿は、Crystal Advent Calendar 2015の20日目のもの。

ひとまず 0.9.1で動くようにする

Crystalはまだまだ開発中のため破壊的変更が随所で行われている。その影響で昔作ったこのbotがいろいろと動かなかったため、動くところまで変更に追従した。
新しいバージョンへの移行例としてちょうど良い題材だったから残しておく。

実行結果

Crystal側の変更点

修正diff

というスタイルで記述していく。

CGIモジュールが無くなっていた

Error in ./src/main.cr:1: while requiring "./crybot"

require "./crybot"
^

in ./src/crybot.cr:2: while requiring "cgi": can't find file 'cgi' relative to '/Users/mihyaeru/pj/crystal/crybot/src'

require "cgi"
^

0.9.0で変わったらしい。

(breaking change) The CGI module's funcionality has been moved to URI and HTTP::Params

これでOK。

diff --git a/src/crybot.cr b/src/crybot.cr
index 0fca99d..05487ae 100644
--- a/src/crybot.cr
+++ b/src/crybot.cr
@@ -1,5 +1,5 @@
 require "./crybot/*"
-require "cgi"
+require "uri"

 class Crybot
     def initialize
@@ -26,7 +26,7 @@ class Crybot
                         name = user["screen_name"]?
                         text = (text as String).gsub("@crybot21 ", "")
                         tweet_test = "@#{name} #{text}"
-                        p self.tweet(CGI.escape(tweet_test))
+                        p self.tweet(URI.escape(tweet_test))
                         p tweet_test
                     end
                 end

Timeのインタフェースが変わっていた

Error in ./src/main.cr:6: instantiating 'Crybot#user_stream()'

bot.user_stream
    ^~~~~~~~~~~

in ./src/crybot.cr:18: instantiating 'Client#start_stream(String, String)'

        @client.start_stream "GET", "/user.json", do |body|
                ^~~~~~~~~~~~

in ./src/crybot/client.cr:32: instantiating 'make_request(String, String, String)'

        request = make_request(method, STREAM_HOST, path)
                  ^~~~~~~~~~~~

instantiating 'make_request(String, String, String, Nil)'

in ./src/crybot/client.cr:68: undefined method 'to_i' for Time (did you mean 'to_s'?)

        request.headers["Authorization"] = @signature.authorization_header(request, true, Time.utc_now.to_i.to_s, SecureRandom.hex(32))
                                                                                                       ^~~~

================================================================================

Time trace:

  /Users/mihyaeru/.anyenv/envs/crenv/versions/0.9.1/src/time/time.cr:224

      def self.utc_now
               ^~~~~~~

  /Users/mihyaeru/.anyenv/envs/crenv/versions/0.9.1/src/time/time.cr:225

        new utc_ticks, Kind::Utc
        ^~~

0.7.6で変わったらしい。

(breaking change) Renamed Time.at to Time.epoch, and Time#to_i and Time#to_f to Time#epoch and Time#epoch_f

これでOK。

diff --git a/src/crybot/client.cr b/src/crybot/client.cr
index 07a7799..c6d7868 100644
--- a/src/crybot/client.cr
+++ b/src/crybot/client.cr
@@ -65,7 +65,7 @@ class Client
         request = HTTP::Request.new(method, API_VERSION + path, body: body)
         request.headers["Host"]          = host
         request.headers["Content-type"]  = "application/x-www-form-urlencoded" if method == "POST"
-        request.headers["Authorization"] = @signature.authorization_header(request, true, Time.utc_now.to_i.to_s, SecureRandom.hex(32))
+        request.headers["Authorization"] = @signature.authorization_header(request, true, Time.utc_now.epoch.to_s, SecureRandom.hex(32))
         return request
     end

IOのインタフェースが変わっていた

Error in ./src/main.cr:6: instantiating 'Crybot#user_stream()'

bot.user_stream
    ^~~~~~~~~~~

in ./src/crybot.cr:18: instantiating 'Client#start_stream(String, String)'

        @client.start_stream "GET", "/user.json", do |body|
                ^~~~~~~~~~~~

in ./src/crybot/client.cr:47: no overload matches 'OpenSSL::SSL::Socket#read' with types Int32
Overloads are:
 - OpenSSL::SSL::Socket#read(slice : Slice(UInt8))
 - OpenSSL::SSL::Socket#read(slice : Slice(UInt8))

            line += io.read(chunk_size)
                       ^~~~

0.9.0で変わったらしい。

(breaking change) IO#read() is now IO#gets_to_end. Removed IO#read(count), added IO#skip(count)

これでOK...

diff --git a/src/crybot/client.cr b/src/crybot/client.cr
index c6d7868..94ae2a6 100644
--- a/src/crybot/client.cr
+++ b/src/crybot/client.cr
@@ -44,7 +44,7 @@ class Client
         # read body
         line = ""
         while (chunk_size = io.gets.not_nil!.to_i(16)) > 0
-            line += io.read(chunk_size)
+            line += io.gets(chunk_size) as String
             io.gets # Read \r\n

             if line =~ /^\s+$/

と思いきや、実行はできたけども実行時エラーに。

{"friends" => [198177363]}
invalid Int32:
 (ArgumentError)
[4347956178] *CallStack::unwind:Array(Pointer(Void)) +82
[4347956081] *CallStack#initialize<CallStack>:Array(Pointer(Void)) +17
[4347956040] *CallStack::new:CallStack +40
[4347955297] *Exception@Exception#initialize<ArgumentError, String>:CallStack +33
[4347955229] *ArgumentError#initialize<ArgumentError, String>:CallStack +29
[4347955169] *ArgumentError::new<String>:ArgumentError +97
[4347945122] *String#to_i32<String, Int32, Bool, Bool, Bool, Bool>:Int32 +226
[4347944885] *String#to_i<String, Int32, Bool, Bool, Bool, Bool>:Int32 +37
[4347950319] *String#to_i<String, Int32>:Int32 +79
[4348087281] *Crybot#user_stream<Crybot>:Nil +625
[4347913599] __crystal_main +24159
[4347923200] main +32

どうやらread(count)gets(count)では読み取り時の挙動が若干違うらしく、io.gets"\r\n"を読み飛ばす辺りで読み飛ばせず、次のループのio.gets.not_nil!.to_i(16)にて"\r\n".to_i(16)になるように変わったようだ。

これで今度こそ動いた。

diff --git a/src/crybot/client.cr b/src/crybot/client.cr
index 94ae2a6..1fa6282 100644
--- a/src/crybot/client.cr
+++ b/src/crybot/client.cr
@@ -43,9 +43,12 @@ class Client

         # read body
         line = ""
-        while (chunk_size = io.gets.not_nil!.to_i(16)) > 0
+        while true
+            chunk_size_str = io.gets as String
+            next if chunk_size_str == "\r\n"
+            chunk_size = chunk_size_str.to_i(16)
+
             line += io.gets(chunk_size) as String
-            io.gets # Read \r\n

             if line =~ /^\s+$/
                 line = ""

Streamingを使う

このbotを書いた2015年7月29日時点ではHTTP::ClientにStreaming機能が存在しておらずSocket周りを頑張って自前で実装した。東京 Crystal 勉強会 #1でこのbotの制作をLTした同日、7月30日に公開された0.7.5でStreamingがサポートされるというタイミングの悪さを発揮した。
発表以降、とくに何も手を加えていなかったが、Advent Calendarを機にStreamingの機能を使った版に書き換えてみた。

長々と自前で頑張っていた辛さあふれるこのメソッドが、

def start_stream(method, path)
    request = make_request(method, STREAM_HOST, path)
    io = make_socket(request)
    request.to_io(io)

    # read header
    headers = HTTP::Headers.new
    while line = io.gets
        break if line == "\r\n" || line == "\n"
        name, value = HTTP.parse_header(line)
        headers.add(name, value)
    end

    # read body
    line = ""
    while true
        chunk_size_str = io.gets as String
        next if chunk_size_str == "\r\n"
        chunk_size = chunk_size_str.to_i(16)

        line += io.gets(chunk_size) as String

        if line =~ /^\s+$/
            line = ""
            next
        end

        begin
            yield JSON.parse(line) as Hash
            line = ""
        rescue e
            p e # FIXME: !!!
        end
    end
end

private def make_socket(request)
    socket = TCPSocket.new(request.headers["Host"], 443)
    socket.sync = false
    return OpenSSL::SSL::Socket.new(socket)
end

こんな感じですっきりと書けた。すごい!

def start_stream(method, path)
    request = make_request(method, STREAM_HOST, path)
    @stream_client.exec(request) do |response|
        while true
            line = response.body_io.gets as String
            next if line =~ /^\s+$/
            yield JSON.parse(line) as Hash
        end
    end
end

終わりに

当初の予定ではStreamingの機能を使う変更だけで記事を書こうと思っていたけども、Crystalのバージョンアップで動かなくなった部分が多々あり併記する形にした。新旧バージョンの比較ができて良い感じに収まったと個人的に思っている。

明日の担当は@5t111111さんの予定。

参考