MessageEncryptor の互換性を調べてみた

f:id:masm11:20210307174016p:plain

こんにちは、masm11 です。

Rails に MessageEncryptor というクラスがあります。 何かを暗号化/復号する際に便利ですね。

例えば、

KEY = 'somekeysomekeysomekeysomekeysomekeysomekeysomekey'
enc = ActiveSupport::MessageEncryptor.new(KEY, cipher: 'aes-256-cbc')

としておけば、

data = enc.encrypt_and_sign(obj)
obj = enc.decrypt_and_verify(data)

で暗号化と復号ができます。

ところが、Ruby 2.4 で仕様が変わってしまったんです。 KEY がちょうど 32文字でないといけなくなりました。

ちょっと試したところでは、元のコードだと

key must be 32 bytes

というエラーが出ますし、

KEY = 'somekeysomekeysomekeysomekeysome'

と 32文字で切り捨てたキーを指定すると、

ActiveSupport::MessageVerifier::InvalidSignature

というエラーが出て復号できません。

さぁ困ったどうしよう、ということで調べた結果です。

結論は一番最後 (まとめ) にあります。

サンプルを作る

まずは Ruby 2.3.3 + Rails 5.2.4.4 でサンプルを作ります。

enc = ::ActiveSupport::MessageEncryptor.new('0123456789abcdefghijklmnopqrstuvwxyz', cipher: 'aes-256-cbc')

長い文字列がキーです。

enc.encrypt_and_sign('short string')
=> "MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==--8ae97f3fc117a62513fb8ca242f86d893f601c83"

short string という文字列を暗号化してみました。 その結果、長い文字列が得られています。これが暗号化文字列です。

OpenSSL を直接使って復号してみる

OpenSSL は 1.1.1i を使用しました。

上記の通り、サンプルは -- の左と右に分けられます。 このうち左側を取り出し、base64 デコードします。

$ echo MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ== | base64 -d
0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA=--qWtbnFglWUfMEL1KvT52jQ==

また base64 っぽい文字列が出てきました。

-- の左側を base64 デコードします。

$ echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d | od -tx1c
0000000  d1  e6  4e  09  25  d8  d3  2e  c1  5d  4c  88  05  42  47  b9
        321 346   N  \t   % 330 323   . 301   ]   L 210 005   B   G 271
0000020  91  8b  85  39  69  e5  36  d8  16  1c  89  b2  4b  20  84  90
        221 213 205   9   i 345   6 330 026 034 211 262   K     204 220
0000040

バイナリなので od に食わせています。 これが暗号化されたデータそのものです。

一方、-- の右側を base64 デコードします。

$ echo qWtbnFglWUfMEL1KvT52jQ== | base64 -d | od -tx1c                    
0000000  a9  6b  5b  9c  58  25  59  47  cc  10  bd  4a  bd  3e  76  8d
        251   k   [ 234   X   %   Y   G 314 020 275   J 275   >   v 215
0000020

こちらもバイナリなので od に食わせています。 これは iv (initialization vector) です。 暗号化の計算に使う初期値です。 擬似乱数のシードみたいなものと考えると良いでしょう。

また、キーの16進表記が必要なので、キーも od に食わせます。

$ echo -n 0123456789abcdefghijklmnopqrstuvwxyz | od -tx1c
0000000  30  31  32  33  34  35  36  37  38  39  61  62  63  64  65  66
          0   1   2   3   4   5   6   7   8   9   a   b   c   d   e   f
0000020  67  68  69  6a  6b  6c  6d  6e  6f  70  71  72  73  74  75  76
          g   h   i   j   k   l   m   n   o   p   q   r   s   t   u   v
0000040  77  78  79  7a
          w   x   y   z
0000044

ここまで準備ができたら、いよいよ openssl コマンドで復号します。

$ echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d | openssl enc -d -K 303132333435363738396162636465666768696a6b6c6d6e6f70717273747576 -iv a96b5b9c58255947cc10bd4abd3e768d -aes-256-cbc | od -tx1c
0000000  04  08  49  22  11  73  68  6f  72  74  20  73  74  72  69  6e
        004  \b   I   " 021   s   h   o   r   t       s   t   r   i   n
0000020  67  06  3a  06  45  54
          g 006   : 006   E   T
0000026

echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d の部分は先程見ました。 -- の左側を base64 デコードした、暗号化されたデータそのものです。これを openssl の 標準入力に与えています。

openssl enc -d は復号指示です。

-K 303132333435363738396162636465666768696a6b6c6d6e6f70717273747576 はキーの16進表記です。 先程 od でキーの16進表記を調べたので、それを指定します。ただし、キーの32文字までです。

-iv a96b5b9c58255947cc10bd4abd3e768d は iv の16進表記です。 こちらも先程 od で16進表記にしていますので、それを指定します。

-aes-256-cbc はアルゴリズムですね。

その結果を od に食わせています。よく見ると、それらしい文字が見えますね。復号できていそうです。

では、このバイナリから Ruby のオブジェクトを復元してみます。

使用した Ruby は 2.7.2 です。

Marshal.load("\x04\x08\x49\x22\x11\x73\x68\x6f\x72\x74\x20\x73\x74\x72\x69\x6e\x67\x06\x3a\x06\x45\x54")
=> "short string"

復元できました!

署名を検証する

最初に得たサンプル暗号化文字列のうち、-- より右側は署名です。 これを検証してみます。

OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, '0123456789abcdefghijklmnopqrstuvwxyz', 'MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==')
=> "8ae97f3fc117a62513fb8ca242f86d893f601c83"

OpenSSL::HMAC.hexdigest を呼び出しています。

  • SHA1 は固定です。
  • '0123456789abcdefghijklmnopqrstuvwxyz' はキーです。ここではキー全体を指定しています。
  • 'MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==' は暗号化文字列の -- より左です。

このように計算することで、"8ae97f3fc117a62513fb8ca242f86d893f601c83" が得られました。 これはサンプルの暗号化文字列の -- より右側に一致します。

署名が検証できました。

MessageEncryptor の構造

結局、MessageEncryptor はどのような処理をしているのでしょうか? まとめてみました。

  1. 暗号化
    1. シリアライズする (A)
    2. iv を決める (B)
    3. B とキー32文字を使って A を暗号化する (C)
    4. B, C をそれぞれ base64 エンコードする (D, E)
    5. E と D を -- を挟んでつなげる (F)
  2. 署名
    • キー全体を使って F の HMAC を計算する (G)
  3. F と G を -- を挟んでつなげる

上の復号手順は、これを逆にたどったものです。

MessageEncryptor の互換性

ここまで MessageEncryptor のコードを読みながら調べたのですが、 結局、キー32文字とキー全体の両方が必要そうです。 ここに復号できない原因があるわけです。

と、ここまで来て、ふと気づきました。

MessageEncryptor のコンストラクタは以下のようになっています。

def initialize(secret, *signature_key_or_options)
  options = signature_key_or_options.extract_options!
  sign_secret = signature_key_or_options.first
  @secret = secret
  @sign_secret = sign_secret          # <--------------- !!!
  @cipher = options[:cipher] || self.class.default_cipher
  @digest = options[:digest] || "SHA1" unless aead_mode?
  @verifier = resolve_verifier
  @serializer = options[:serializer] || Marshal
end

なんと、署名用のキーが指定できるではないですか。

enc = ::ActiveSupport::MessageEncryptor.new('0123456789abcdefghijklmnopqrstuv', '0123456789abcdefghijklmnopqrstuvwxyz', cipher: 'aes-256-cbc')
enc.decrypt_and_verify('MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==--8ae97f3fc117a62513fb8ca242f86d893f601c83')
=> "short string"

復号できました!

まとめ

Rails がそう簡単に互換性を捨てるはずがありませんね。解ってしまえば簡単なことでした。

enc = ActiveSupport::MessageEncryptor.new(KEY[0..31], KEY, cipher: 'aes-256-cbc')

こうですね。

インゲージではエンジニアを募集しています。 こんなことに熱中できる方、うちで働きませんか? 詳細は以下のページへ!

https://ingage.co.jp/recruit

ではまた!

MITM プロキシを作る

f:id:masm11:20210221003205p:plain

こんにちは、masm11 です。

MITM (man-in-the-middle) プロキシをご存知でしょうか? ブラウザとサーバの間に入って HTTPS の仲介をするのは普通の プロキシと同じですが、通信内容を覗くことができます。 もちろん自由に覗けてしまっては HTTPS の意味がありませんので、 特別な証明書をブラウザにインストールする必要はあります。

今回はそんな MITM プロキシを Ruby で作ってみたいと思います。

先に成果物

というか、既に作って、以下に置いてあります。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb

今回はこれがどのように動いているのか、説明してみたいと思います。

CA 証明書の作成

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L15-L46

このメソッドは Ruby の OpenSSL を使って、CA のオレオレ証明書と 秘密鍵を生成しています。

  # CA の情報を設定
  name = OpenSSL::X509::Name.new
  name.add_entry 'C',  'JP'
  name.add_entry 'ST', 'Osaka'
  name.add_entry 'DC', 'Kita-ku'
  name.add_entry 'O',  'INGAGE Inc.'
  name.add_entry 'CN', 'Masm11 CA'

この辺は、いつも openssl コマンドで CSR を作る時に入力している項目ですね。

  # CA の秘密鍵/公開鍵を生成
  rsa = OpenSSL::PKey::RSA.generate(2048)

RSA 2048bit の秘密鍵/公開鍵を生成しています。

  # CA の秘密鍵を保存
  File.write('ca.pkey', rsa.export(OpenSSL::Cipher::Cipher.new('aes256'), CA_PASSPHRASE))

秘密鍵にパスフレーズを設定してファイルに保存しています。 起動ごとに作る必要はありませんので、作ったものはファイルに保存しておくことにしました。

  # CA 証明書を作成
  cert = OpenSSL::X509::Certificate.new
  cert.not_before = Time.now
  cert.not_after = Time.now + 3600 * 24 * 365

有効期限はとりあえず1年とします。

  cert.public_key = rsa.public_key
  cert.serial = 1
  cert.issuer = name
  cert.subject = name

公開鍵等を設定しています。

  ext = OpenSSL::X509::Extension.new('basicConstraints', OpenSSL::ASN1.Sequence([OpenSSL::ASN1::Boolean(true)]))
  cert.add_extension(ext)

CA の証明書として使えるようにします。

  cert.sign(rsa, sha1)

最後に署名します。

  # CA 証明書を保存
  File.write('ca.crt', cert.to_pem)

こちらも同様に保存しておきます。

CA 証明書の読み込み

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L48-L51

2回め以降の起動時は、CA 証明書はファイルから読み込むことにします。

read_ca_cert はファイルからオブジェクトを生成して返しているだけです。

サーバ証明書の生成

今度は、CA でなくサーバの証明書の生成です。

CA は本物のサーバの秘密鍵を持っていないので、自前で秘密鍵を用意し、 対応する証明書も用意します。その証明書は上で作った CA の秘密鍵で 署名されているわけです。

先程とよく似ているので、違う部分だけ説明します。

  begin
    return OpenSSL::PKey::RSA.new(File.read("mitm-proxy/#{domain}.pkey")),
           OpenSSL::X509::Certificate.new(File.read("mitm-proxy/#{domain}.crt"))
  rescue Errno::ENOENT
  end

こちらも、一度作ったものはファイルに保存することにしました。

ここでは、ファイルから読んでオブジェクトを生成して返しています。 ただしファイルがなくて ENOENT になった場合は次の処理へ進みます。 なお、他の例外が発生した場合は呼び出し元へそのまま投げます。

 name.add_entry 'CN', domain

サーバ証明書の CommonName はそのドメインです。

  crt.issuer = ca_cert.issuer
  crt.subject = name

発行者は CA です。また対象はそのサーバです。

起動時の初期化

ここからは、処理の順を追って説明します。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L152-L154

if ARGV[0] == '--init'
  init_ca_cert
end

初回起動時は --init オプションを付けるものとします。 その場合は CA 証明書を生成します。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L156

ca_pkey, ca_cert = read_ca_cert

CA の秘密鍵と証明書を読み込みます。

Socket.tcp_server_loop('127.0.0.1', 8000) do |sock, addr|
  fork do
    serve(ca_pkey, ca_cert, sock)
    exit(0)
  end
  sock.close
end

TCP のサービスを書く時、接続を受け付けたらプロセスを fork し、 新しいプロセスに処理を任せて、本体は引き続き次の接続を待つ、 という作り方をよくします。今回もそうしました。

127.0.0.1:8000 をプロキシ待ち受けアドレスとします。 そこで接続を待ち受け、接続があったらブロックが呼ばれます。 ブロックの中では、fork して serve メソッドで処理します。 親プロセスではソケットが不要なので閉じておきます。

なお、ここで閉じ忘れると、通信相手 (ブラウザや curl) が 切断を認識できなくてハマります。

処理する

では serve メソッドを見ていきます。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L99-L150

  line = sysread_line(sock).chomp
  if line !~ /\ACONNECT\s+(.*):(\d+)\s/
    raise 'Bad connect: ' + line
  end
  host = $1
  port = $2.to_i

ソケットから 1行読みます。読んだものが CONNECT であることを確認して、 接続先サーバ名とポート番号を取り出します。

なお、Ruby には IO#readIO#sysread が用意されていますが、 これらは混ぜて使うとおかしな挙動になります。 OpenSSL が sysread を使っているようなので、私も sysread を使うことにしました。

IO#readlineIO#read を使っていて、ここでは使えませんので、 sysread 版を自分で作りました。それが sysread_line です。

  loop do
    line = sysread_line(sock).chomp
    break if line == ''
  end

CONNECT 以降の HTTP ヘッダの処理です。ここでは読み飛ばしているだけです。 空行が現れたら HTTP ヘッダの終わりです。

  cs = TCPSocket.open(host, port)
  cs = OpenSSL::SSL::SSLSocket.new(cs)
  cs.connect
  cs.post_connection_check(host)

接続先が得られたので、接続します。TCP で接続し、それを OpenSSL に渡して、 cs.connect で TLS ハンドシェイクをしています。cs.post_connection_check では 証明書の中の CommonName が正しいかをチェックしています。

  sock.syswrite "HTTP/1.1 200 OK\r\n\r\n"

ここまで成功したら、ブラウザに成功レスポンスを返します。

sysread と同様、syswrite を使います。

pkey, cert = get_cert(ca_pkey, ca_cert, host)

サーバの自前秘密鍵と証明書を取得しています。このメソッドは先程上で用意したものです。

  ctxt = OpenSSL::SSL::SSLContext.new('TLSv1_2_server')
  ctxt.cert = cert
  ctxt.key = pkey

ブラウザと TLS ハンドシェイクするための準備です。 TLS のバージョン、証明書、秘密鍵をセットしています。

サーバ側としてハンドシェイクするので、少々手間がかかります。

  sock = OpenSSL::SSL::SSLSocket.new(sock, ctxt)
  sock.accept

ソケットを OpenSSL に渡して、ハンドシェイクしています。

以上で、プロキシ-ブラウザとプロキシ-サーバが TLS で接続できました。 ここから先は、ソケット間の橋渡しをしているだけです。

      rs = IO.select([sock, cs])

ブラウザ側接続またはサーバ側接続のどちらかからデータを読めるようになるまで待ちます。

      rs = rs.first.first

読めるようになったら、この場所にソケットが入っていますので、取り出します。

      if rs == sock
        buf = sock.sysread(1024)
        if buf.nil?    # EOF
          break
        end
        cs.syswrite(buf)

ブラウザ側ソケットから読めるようになった場合の処理です。 最大 1024バイト読んで、EOF でなければサーバ側ソケットへ送ります。

      elsif rs == cs
        buf = cs.sysread(1024)
        if buf.nil?    # EOF
          break
        end
        sock.syswrite(buf)

逆向きの場合の処理です。

いずれかが EOF になった場合は、ループを抜け、プロセスを終了します。 その時に接続が切れます。

試す!

ブラウザで試せばいいのでしょうが、CA 証明書をブラウザに設定する必要がある等、 手間ですので、今回は curl で試します。

https_proxy=http://localhost:8000 curl --cacert ./ca.crt https://www.masm11.me/

プロキシの場所を環境変数で渡しています。

--cacert ./ca.crt で CA 証明書を指定しています。この ca.crt は MITM プロキシが 生成した CA 証明書のファイル名です。

これで試したところ、

luna:mitm-proxy % https_proxy=http://localhost:8000 curl --cacert ./ca.crt https://www.masm11.me/
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {
(以下略)

通信できました!

まとめ

以上、MITM プロキシの説明をしてみました。

最後に説明したループの中で、buf には通信内容が平文で入っています。

今回はブラウザではテストしませんでした。実はブラウザでのテストは大変なのです。 serial が戻ってはいけないし、同じ serial で別の証明書ではいけないし...

インゲージではエンジニアを募集しています。 こんな話題に花を咲かせたいそこのあなた! ぜひうちで働きませんか? (業務ではこんなコードは書きませんが・・) 詳細は以下のページへ!

https://ingage.co.jp/recruit

ではまた!

Rails 5.2 で Rails 6 の credentials 機能を使う

f:id:masm11:20210205220317p:plain

こんにちは、masm11 です。 今回は久しぶりに Rails について書きたいと思います。

Rails 5.1~5.2 で credentials 機能が一新され、 credentials.yml.enc と master.key になりましたね。 そして Rails 6 ではそれを環境ごとに用意できるようになりました。

ただ、もろもろの事情でもうしばらく Rails 5.2 から離れられそうにないので、 Rails 5.2 で Rails 6 のこの機能を使う方法を探してみました。

1. 各ファイルを作る

とりあえず、

bin/rails credentials:edit

と実行すると、config/credentials.yml.encconfig/master.key が 作成されます。

これを config/credentials/ に置きます。

mv config/credentials.yml.enc config/credentials/development.yml.enc
mv config/master.key config/credentials/development.key

以上を development, test, staging, production と、必要なだけ繰り返します。

そして、.gitignore

/config/credentials/*.key

の一行を追加します。*.key は暗号化/復号に使う鍵なので、公開しないようにしましょう。

2. Rails 6 の機能を backport

以下に backport したものが公開されています。

https://gist.github.com/palkan/e27e4885535ff25753aefce45378e0cb

このページに書いてあるとおりに進めます。

  • backport_rails_six_credentials.rb をダウンロードして lib/ に置く。
  • backport_rails_six_credentials_command.rb をダウンロードして lib/ に置く。
  • config/application.rbrequire "rails" っぽい行の下に以下の行を追加。
require_relative '../lib/backport_rails_six_credentials'
  • config/boot.rbrequire "bundler/setup" の下に以下の行を追加。
require_relative '../lib/backport_rails_six_credentials_command'

config/environments/*.rb に以下を追加します。

config.require_master_key = true

3. コマンドを使う

config/credentials/development.yml.enc は暗号化されているので、 そのままでは中身を見ることはできません。中身を表示するには以下のようにします。

bin/rails credentials:show --environment=development

編集するには以下のようにします。環境変数 EDITOR に指定してあるエディタが起動します。

bin/rails credentials:edit --environment=development

例えば、今まで secrets.yml が

development:
  foo: 123
  bar: 456
production:
  foo: 789
  bar: 012

だったなら、--environment=development では

foo: 123
bar: 456

と書き、--environment=production では

foo: 789
bar: 012

と書きます。

4. コード内から使う

Rails.application.credentials.foo とかで読み出せます。

もし、

foo:
  bar: 123

とネストしている場合は、Rails.application.credentials.foo[:bar] です。

まとめ

以上、Rails 6 の credentials 機能を Rails 5.2 で使う方法をご紹介しました。

インゲージではエンジニアを募集中です。詳しくは以下のページへ!

https://ingage.co.jp/recruit

ではまた!

Exchange Online の先進認証に対応しました(3)

f:id:kizashi1122:20201110142108j:plain

id:kizashi1122 です。 前回の記事から2ヶ月が経ってしまいました。 続きを書きましょう。

blog.ingage.jp

時間がだいぶ経ったので、ここで目的と前回までのまとめを整理しておきます。

目的

目的は、「Exchange Online の POP3/SMTP を利用する際に、パスワードではなく先進認証で得られたトークンを使う」です。 Rest API は使いません。

前回までのまとめ

  • Azure AD 側に POP3/SMTP 利用のための設定をおこなう
  • Rails で OAuth ができるところまでもっていく

というところまでです。アクセストークンがゲットできて、手元にあるという状態になります。

SMTP / POP3 にトークンを使ってアクセスする

そんなことができるのかと思いますができます。 Gmail では以下のページに書かれています。大変わかりやすいです。IMAP/POP3/SMTP それぞれの認証の仕方が書かれています。

developers.google.com

SASL XOAUTH2 という方式があるのですね。

次に、Exchange Online のドキュメントを確認します。

docs.microsoft.com

なんだみんなこの SASL XOAUTH2に則って作ってるのか、じゃあGmailもExchange Onlineも同じだなと思うとハマります

SASL XOAUTH2 format と言われる、ユーザ名とアクセストークンを使って一つの文字列を作る、これは同じなんです。これはいいのです。

base64("user=" {User} "^Aauth=Bearer " {Access Token} "^A^A")

実際の認証部分が大事なのです。

SMTP の認証

Gmail のドキュメントでは以下のようなサンプルが書かれています。

[connection begins]
S: 220 mx.google.com ESMTP 12sm2095603fks.9
C: EHLO sender.example.com
S: 250-mx.google.com at your service, [172.31.135.47]
S: 250-SIZE 35651584
S: 250-8BITMIME
S: 250-AUTH LOGIN PLAIN XOAUTH XOAUTH2
S: 250-ENHANCEDSTATUSCODES
S: 250 PIPELINING
C: AUTH XOAUTH2 dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ==
S: 235 2.7.0 Accepted
[connection continues...]

本家ページ上では改行されていますが、注釈に改行は実際はないよと書いているのでここでは改行は削除してます。

なるほど、つまり認証コマンドを投げるときは

AUTH XOAUTH2 <SASL XOAUTH2 フォーマットで生成した文字列>

を発行するんだなと。当然、Exchange Online も同じだろって思うんです。ですが、違います。 Exchange Online のドキュメントにはこう書かれています。

[connection begins]
C: auth xoauth2
S: 334
C: dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ==
S: 235 2.7.0 Authentication successful
[connection continues...]

本家では読みやすさのための改行だったので、ここでは改行は削除しています。

Exchange Online では Gmail と違って認証のために2回コマンドを投げる必要があることがわかります。

AUTH XOAUTH2

dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ==

の2回に分ける必要があるんです。

なんやねん!

せっかく処理を共通化できると思ったのに分岐させる必要がでてくるわけです。

POP3 の認証

想像に難くないですが、POP3 についても、Gmail と Exchange Online には同じような差があります。

Gmail。

[connection begins]
C: AUTH XOAUTH2 dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYX
JlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0
Q2cBAQ==
S: +OK Welcome.
[connection continues...]

1行。

Exchange Online。

[connection begins] 
C: AUTH XOAUTH2     
S: +    
C: dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYX   
JlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0  
Q2cBAQ==    
S: +OK User successfully authenticated. 
[connection continues...]

2行(2回)

この個性はハマりました。
その個性いるかね?と思います。

インゲージではこんなことにもめげないエンジニアを募集しています。 ご応募お待ちしてます。

Rails 6.1 つまみ食い② : ActiveStorage の永続的なURL

おはようございます!

2021年はもっとJSと仲良くなりたい @shutooike です!

今回試すのは ActiveStorage の Permanent URLs です。

セットアップ

blog.ingage.jp

前回の記事↑で作った Rails アプリを今回も使います!

まず ActiveStorage をインストールします

$ dip rails active_storage:install
Creating test_rails_latest_backend_run ... done
Copied migration 20210119162125_create_active_storage_tables.active_storage.rb from active_storage
$ dip rails db:migrate
Creating test_rails_latest_backend_run ... done
== 20210119162125 CreateActiveStorageTables: migrating ========================
-- create_table(:active_storage_blobs, {})
   -> 0.0406s
-- create_table(:active_storage_attachments, {})
   -> 0.0151s
-- create_table(:active_storage_variant_records, {})
   -> 0.0351s
== 20210119162125 CreateActiveStorageTables: migrated (0.0931s) ===============

前回作った Post に画像を添付できるようにします。

app/models/post.rb

class Post < ApplicationRecord
  has_many :comments, dependent: :destroy_async
  has_one_attached :image # <- 追加
end

app/controllers/posts_controller.rb

class PostsController < ApplicationController
.
.
  private
.
.
    # Only allow a list of trusted parameters through.
    def post_params
      params.require(:post).permit(:title, :body, :image) # <- :image を追加
    end
end

app/views/posts/_form.html.erb

.
.
  <div class="field">
    <%= form.label :image %>
    <%= form.file_field :image %>
  </div>
.
.

app/views/posts/show.html.erb

.
.
<div>
  <p><b>Image:</b></p>
  <%= image_tag @post.image %>
</div>
.
.

f:id:shutooike:20210124190158p:plain
新規 Post 作成画面

Create Post を押下すると

f:id:shutooike:20210124190232p:plain
Post 詳細画面

これで Post に画像を添付できるようになりました!

永続的なURL

これまで

ActiveStorage 6.0 までは Blob#service_url は有効期限付きの一時的なURLを返していました。

$ dip rails c
Creating test_rails_latest_backend_run ... done
Running via Spring preloader in process 14
Loading development environment (Rails 6.1.0)
irb(main):001:0> ActiveStorage::Current.host = 'http://localhost:3000'
=> "http://localhost:3000"
irb(main):002:0> Post.last.image.service_url
   (1.4ms)  SELECT sqlite_version(*)
  Post Load (3.6ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]
  ActiveStorage::Attachment Load (2.7ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ?  [["record_id", 4], ["record_type", "Post"], ["name", "image"], ["LIMIT", 1]]
  ActiveStorage::Blob Load (3.1ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
DEPRECATION WARNING: service_url is deprecated and will be removed from Rails 6.2 (use url instead) (called from irb_binding at (irb):2)
  Disk Storage (10.6ms) Generated URL for file at key: hhq2ryr88ot5hkkepsy2xxg7jgxo (http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhhR2h4TW5KNWNqZzRiM1ExYUd0clpYQnplVEo0ZUdjM2FtZDRid1k2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJHTFRnbkoybHVaMkZuWlM1d2JtY0dPd1pVT2hGamIyNTBaVzUwWDNSNWNHVkpJZzVwYldGblpTOXdibWNHT3daVU9oRnpaWEoyYVdObFgyNWhiV1U2RW14dlkyRnNYM0J5YVhaaGRHVT0iLCJleHAiOiIyMDIxLTAxLTI0VDE0OjMyOjE5LjczNFoiLCJwdXIiOiJibG9iX2tleSJ9fQ==--dfa4689e26923e9d0783baaad60aab40953870f4/ingage.png)
=> "http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhhR2h4TW5KNWNqZzRiM1ExYUd0clpYQnplVEo0ZUdjM2FtZDRid1k2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJHTFRnbkoybHVaMkZuWlM1d2JtY0dPd1pVT2hGamIyNTBaVzUwWDNSNWNHVkpJZzVwYldGblpTOXdibWNHT3daVU9oRnpaWEoyYVdObFgyNWhiV1U2RW14dlkyRnNYM0J5YVhaaGRHVT0iLCJleHAiOiIyMDIxLTAxLTI0VDE0OjMyOjE5LjczNFoiLCJwdXIiOiJibG9iX2tleSJ9fQ==--dfa4689e26923e9d0783baaad60aab40953870f4/ingage.png"

なので Blob#service_url が返した URL を開くと最初はこのように表示できますが、

f:id:shutooike:20210124232957p:plain
Blob#service_url が返したURL

5分すると・・・

f:id:shutooike:20210124233244p:plain
5分後

有効期限が切れて表示できなくなります。

irb(main):005:0> url1 = Post.last.image.url
  Post Load (3.4ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]
  ActiveStorage::Attachment Load (3.5ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ?  [["record_id", 6], ["record_type", "Post"], ["name", "image"], ["LIMIT", 1]]
  ActiveStorage::Blob Load (7.8ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
  Disk Storage (1.3ms) Generated URL for file at key: 8lrdasfec0ti6trsc2z2y1bsjibj (http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhPR3h5WkdGelptVmpNSFJwTm5SeWMyTXllako1TVdKemFtbGlhZ1k2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJHTFRnbkoybHVaMkZuWlM1d2JtY0dPd1pVT2hGamIyNTBaVzUwWDNSNWNHVkpJZzVwYldGblpTOXdibWNHT3daVU9oRnpaWEoyYVdObFgyNWhiV1U2Q214dlkyRnMiLCJleHAiOiIyMDIxLTAxLTI0VDE1OjI1OjIxLjc4MloiLCJwdXIiOiJibG9iX2tleSJ9fQ==--2258a7523a81600867d2146764da7a9dce6ebfbd/ingage.png)
=> "http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhPR3h5WkdGelptVmpNSFJwTm5SeWMyTXllako1TVdKemFtbGlhZ1k2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJ..."
irb(main):006:0> sleep 5.minutes
=> 300
irb(main):007:0> url2 = Post.last.image.url
  Post Load (11.4ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]
  ActiveStorage::Attachment Load (23.4ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ?  [["record_id", 6], ["record_type", "Post"], ["name", "image"], ["LIMIT", 1]]
  ActiveStorage::Blob Load (7.6ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
  Disk Storage (1.3ms) Generated URL for file at key: 8lrdasfec0ti6trsc2z2y1bsjibj (http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhPR3h5WkdGelptVmpNSFJwTm5SeWMyTXllako1TVdKemFtbGlhZ1k2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJHTFRnbkoybHVaMkZuWlM1d2JtY0dPd1pVT2hGamIyNTBaVzUwWDNSNWNHVkpJZzVwYldGblpTOXdibWNHT3daVU9oRnpaWEoyYVdObFgyNWhiV1U2Q214dlkyRnMiLCJleHAiOiIyMDIxLTAxLTI0VDE1OjMxOjA2LjAzN1oiLCJwdXIiOiJibG9iX2tleSJ9fQ==--ae964cba7705144602bb150cd790f35c6be9d0f4/ingage.png)
=> "http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhPR3h5WkdGelptVmpNSFJwTm5SeWMyTXllako1TVdKemFtbGlhZ1k2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJ..."
irb(main):008:0> url1 == url2
=> false

Rails Console での検証↑

6.1 から

github.com

6.1 からは config/storage.yml で public: true | false が設定でき、true の場合は永続的なURLが返されるようになりました!

まず public: true を設定に追加します

config/storage.yml

.
.

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>
  public: true
.
.

Rails Console で検証してみます。

$ dip rails c
Creating test_rails_latest_backend_run ... done
Running via Spring preloader in process 14
Loading development environment (Rails 6.1.0)
irb(main):001:0> ActiveStorage::Current.host = 'http://localhost:3000'
=> "http://localhost:3000"
irb(main):002:0> url1 = Post.last.image.url
   (2.4ms)  SELECT sqlite_version(*)
  Post Load (5.7ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]
  ActiveStorage::Attachment Load (3.7ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ?  [["record_id", 6], ["record_type", "Post"], ["name", "image"], ["LIMIT", 1]]
  ActiveStorage::Blob Load (5.7ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
  Disk Storage (7.7ms) Generated URL for file at key: 8lrdasfec0ti6trsc2z2y1bsjibj (http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhPR3h5WkdGelptVmpNSFJwTm5SeWMyTXllako1TVdKemFtbGlhZ1k2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJHTFRnbkoybHVaMkZuWlM1d2JtY0dPd1pVT2hGamIyNTBaVzUwWDNSNWNHVkpJZzVwYldGblpTOXdibWNHT3daVU9oRnpaWEoyYVdObFgyNWhiV1U2Q214dlkyRnMiLCJleHAiOm51bGwsInB1ciI6ImJsb2Jfa2V5In19--e502a110ec7258265e886e90ae501d254087ca1b/ingage.png)
=> "http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhPR3h5WkdGelptVmpNSFJwTm5SeWMyTXllako1TVdKemFtbGlhZ1k2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJ..."
irb(main):003:0> sleep 5.minutes
=> 300
irb(main):004:0> url2 = Post.last.image.url
  Post Load (72.4ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]
  ActiveStorage::Attachment Load (7.0ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ?  [["record_id", 6], ["record_type", "Post"], ["name", "image"], ["LIMIT", 1]]
  ActiveStorage::Blob Load (3.5ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
  Disk Storage (2.8ms) Generated URL for file at key: 8lrdasfec0ti6trsc2z2y1bsjibj (http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhPR3h5WkdGelptVmpNSFJwTm5SeWMyTXllako1TVdKemFtbGlhZ1k2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJHTFRnbkoybHVaMkZuWlM1d2JtY0dPd1pVT2hGamIyNTBaVzUwWDNSNWNHVkpJZzVwYldGblpTOXdibWNHT3daVU9oRnpaWEoyYVdObFgyNWhiV1U2Q214dlkyRnMiLCJleHAiOm51bGwsInB1ciI6ImJsb2Jfa2V5In19--e502a110ec7258265e886e90ae501d254087ca1b/ingage.png)
=> "http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhPR3h5WkdGelptVmpNSFJwTm5SeWMyTXllako1TVdKemFtbGlhZ1k2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJ..."
irb(main):005:0> url1 == url2
=> true

5分経ってもURLは変わっていないですね!🎉

ちなみに Rails 6.1 からは Blob#service_url が非推奨になり、代わりに Blob#url を使います。*1

次回は ActiveStorage が multiple storage services に対応したことを書く予定です!

ではまた!

おまけ

Blob#service_url を Rails Console で試してみると URI::InvalidURIError (bad URI(is not URI?): nil) というエラーが出ました。

$ dip rails c
Creating test_rails_latest_backend_run ... done
Running via Spring preloader in process 14
Loading development environment (Rails 6.1.0)
irb(main):001:0> Post.last.image.blob.service_url
   (1.2ms)  SELECT sqlite_version(*)
  Post Load (4.6ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]
  ActiveStorage::Attachment Load (5.6ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ?  [["record_id", 5], ["record_type", "Post"], ["name", "image"], ["LIMIT", 1]]
  ActiveStorage::Blob Load (5.3ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
DEPRECATION WARNING: service_url is deprecated and will be removed from Rails 6.2 (use url instead) (called from irb_binding at (irb):1)
  Disk Storage (9.8ms) Generated URL for file at key: d7j7u5gnrvzl73uhlse3ch62n7x1 ()
Traceback (most recent call last):
        1: from (irb):1
URI::InvalidURIError (bad URI(is not URI?): nil)

どうやら ActiveStorage::Current.hostnil ぽいので値を入れてあげると無事 URL を返してくれるようになりました!

irb(main):002:0> ActiveStorage::Current.host = 'http://localhost:3000'
=> "http://localhost:3000"
irb(main):003:0> Post.last.image.blob.service_url
  Post Load (8.5ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]
  ActiveStorage::Attachment Load (4.4ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ?  [["record_id", 5], ["record_type", "Post"], ["name", "image"], ["LIMIT", 1]]
  ActiveStorage::Blob Load (4.0ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
DEPRECATION WARNING: service_url is deprecated and will be removed from Rails 6.2 (use url instead) (called from irb_binding at (irb):3)
  Disk Storage (4.6ms) Generated URL for file at key: d7j7u5gnrvzl73uhlse3ch62n7x1 (http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhaRGRxTjNVMVoyNXlkbnBzTnpOMWFHeHpaVE5qYURZeWJqZDRNUVk2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJHTFRnbkoybHVaMkZuWlM1d2JtY0dPd1pVT2hGamIyNTBaVzUwWDNSNWNHVkpJZzVwYldGblpTOXdibWNHT3daVU9oRnpaWEoyYVdObFgyNWhiV1U2Q214dlkyRnMiLCJleHAiOiIyMDIxLTAxLTI0VDE1OjA1OjUxLjU4NloiLCJwdXIiOiJibG9iX2tleSJ9fQ==--bd4bd9aa30ad12d6c2512cf633f94ad49ae22a80/ingage.png)
=> "http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdDVG9JYTJWNVNTSWhaRGRxTjNVMVoyNXlkbnBzTnpOMWFHeHpaVE5qYURZeWJqZDRNUVk2QmtWVU9oQmthWE53YjNOcGRHbHZia2tpUDJsdWJHbHVaVHNnWm1sc1pXNWhiV1U5SW1sdVoyRm5aUzV3Ym1jaU95Qm1hV3hsYm1GdFpTbzlWVlJHTFRnbkoybHVaMkZuWlM1d2JtY0dPd1pVT2hGamIyNTBaVzUwWDNSNWNHVkpJZzVwYldGblpTOXdibWNHT3daVU9oRnpaWEoyYVdObFgyNWhiV1U2Q214dlkyRnMiLCJleHAiOiIyMDIxLTAxLTI0VDE1OjA1OjUxLjU4NloiLCJwdXIiOiJibG9iX2tleSJ9fQ==--bd4bd9aa30ad12d6c2512cf633f94ad49ae22a80/ingage.png"

検証していないですが、Service に Disk を使っているときに開発環境で起こるみたいです。誰かの参考になれば 🙏 🙏

*1:記事内ではごっちゃになってます。すいません!

Ruby の with_index を追う

f:id:masm11:20210122225424p:plain

こんにちは、masm11 です。

先日、Ruby の with_index メソッドを知り、衝撃を受けました。 今回は使い方を簡単に紹介し、更に with_index の実装に迫りたいと思います。

with_index の使い方

Ruby の Array には、Array#eachメソッドがありますね。

array.each do |item|
  puts item.to_s
end

のように使えば、各要素について処理することができます。

また、その添字も欲しいことがあります。そういう場合は、

array.each_with_index do |item, i|
  puts "#{i} #{item}"
end

のように、each の代わりに each_with_index を使えば、添字を同時に受け取ることができます。

しかし限界があります。Array には each_with_object というメソッドがありますが、 これの with_index 版 (要素と object と index の3つを渡してくれるもの) はありません。 つまり、

r = array.each_with_object_with_index('') do |item, obj, i|
  obj << "[#{i}:#{item}]"
end

なんてことはできないわけです。

そこで with_index の出番です。

r = array.each_with_object('').with_index do |(item, obj), i|
  obj << "[#{i}:#{item}]"
end

こうすれば添字も受け取ることができます。

with_index の実装

では、何がどうなって実現できているのでしょうか?

Array#each_with_object はブロックが与えられていない場合には Enumerator を返します。 つまり with_index は Enumerator のメソッドなのです。 each_with_object が繰り返すのではなく、each_with_object は Enumerator を返し、 Enumerator の with_index が繰り返しているのですね。

ソースコードを覗いてみましょう。

Enumerator は Ruby のソースコードの enumerator.c で定義されています。

https://github.com/ruby/ruby/blob/v3_0_0/enumerator.c#L4011

rb_cEnumerator = rb_define_class("Enumerator", rb_cObject);

ここから Enumerator クラスの定義が始まります。

そして、

https://github.com/ruby/ruby/blob/v3_0_0/enumerator.c#L4020

rb_define_method(rb_cEnumerator, "with_index", enumerator_with_index, -1);

これが with_index メソッドの定義です。実体は enumerator_with_index という関数にあるようです。

https://github.com/ruby/ruby/blob/v3_0_0/enumerator.c#L653-L662

static VALUE
enumerator_with_index(int argc, VALUE *argv, VALUE obj)
{
    VALUE memo;

    rb_check_arity(argc, 0, 1);
    RETURN_SIZED_ENUMERATOR(obj, argc, argv, enumerator_enum_size);
    memo = (!argc || NIL_P(memo = argv[0])) ? INT2FIX(0) : rb_to_int(memo);
    return enumerator_block_call(obj, enumerator_with_index_i, (VALUE)MEMO_NEW(memo, 0, 0));
}

これがその実体です。引数 argc, argv は with_index メソッドに対する引数の個数と引数そのもの、 引数 obj は Enumerator オブジェクトそのものを指すのでしょう。

    memo = (!argc || NIL_P(memo = argv[0])) ? INT2FIX(0) : rb_to_int(memo);

ここは、with_index メソッドの引数の処理のようです。 with_index は引数なしで呼び出すと添字は 0 から始まりますが、 引数でいくつから始めるかを指定することもできます。

よく見ていきます。

(!argc || NIL_P(memo = argv[0]))

引数がない場合、または最初の引数が nil の場合。

INT2FIX(0)

0 を Ruby の型に変換したものなのでしょう。

rb_to_int(memo)

こちらは memo を Ruby の整数に変換しているようです。 なお、memo は先程 NIL_P(memo = argv[0]) で代入されていて、つまり最初の引数です。

これで、引数に応じて開始の値が決まりました。

    return enumerator_block_call(obj, enumerator_with_index_i, (VALUE)MEMO_NEW(memo, 0, 0));

このあたりから Ruby の言語処理系に深く入っていくので挫折してしまったのですが、 各要素について enumerator_with_index_i が呼ばれるようです。 enumerator_with_index_i のコードは以下にあります。

https://github.com/ruby/ruby/blob/v3_0_0/enumerator.c#L619-L630

static VALUE
enumerator_with_index_i(RB_BLOCK_CALL_FUNC_ARGLIST(val, m))
{
    struct MEMO *memo = (struct MEMO *)m;
    VALUE idx = memo->v1;
    MEMO_V1_SET(memo, rb_int_succ(idx));

    if (argc <= 1)
        return rb_yield_values(2, val, idx);

    return rb_yield_values(2, rb_ary_new4(argc, argv), idx);
}

冒頭の部分はまずは飛ばして、

    if (argc <= 1)
        return rb_yield_values(2, val, idx);

この部分は、ブロック引数がもともと1つまでの場合の処理です。 その場合は、その引数と添字の2つを引数にして yield しているようです。

また、

    return rb_yield_values(2, rb_ary_new4(argc, argv), idx);

こちらの部分は、ブロック引数がもともと2つ以上の場合で、 その場合は引数を配列にまとめたものと添字の2つを引数にして yield しているようです。

で、冒頭の部分は何かというと、先程の

    return enumerator_block_call(obj, enumerator_with_index_i, (VALUE)MEMO_NEW(memo, 0, 0));

の引数に渡した (VALUE)MEMO_NEW(memo, 0, 0)

static VALUE
enumerator_with_index_i(RB_BLOCK_CALL_FUNC_ARGLIST(val, m))

m に渡ってきていて、

    struct MEMO *memo = (struct MEMO *)m;
    VALUE idx = memo->v1;
    MEMO_V1_SET(memo, rb_int_succ(idx));

と処理されています。1行目で型変換をして、2行目で v1 を取り出して、 3行目でそれに +1 したものを v1 にセットしているんですね。 v1 に添字が格納されている、というわけです。

取り出した値は先程見たように yield に渡されていました。

まとめ

Enumerator#with_index を紹介し、その実装を見てみました。

ふと思ったのですが、このコードは何故 C で書かれているのでしょうか? Ruby で書けるコードは Ruby で書いてしまった方がメンテしやすいと思うのですが。 ただ、毎回クラスを読み込むと起動に時間がかかるので、読み込んだ状態を dump しておく、 といった Emacs のような手法は必要になります。それを避けたいのかもしれませんね。 もしくは単にスピードを追求するためか。

ではまた!

Docker Composeを使って複数のコンテナを管理する

f:id:ingnis:20201120144028j:plain

こんにちは、にしむらです。

今回は複数のコンテナを定義してアプリケーションを開発する方法です。 Docker Composeによって、Rubyとデータベース(MySQL)のコンテナを連携させて開発ができるようにしていきます。

続きを読む