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)のコンテナを連携させて開発ができるようにしていきます。

続きを読む

grape で URI パラメータとしてメールアドレスを受け取る

f:id:masm11:20210109214927p:plain

明けましておめでとうございます。masm11 です。今年もよろしくお願いします!

grape をご存知でしょうか? Rails で API を作る時に便利ですね。

今回は、grape を使った API で URI パラメータとしてメールアドレスを受け取ろうと してハマったので、ご紹介します。

環境を用意する

まず Rails 環境を用意します。

gem install rails
mkdir t2
cd t2
rails new .
echo "gem 'grape'" >> Gemfile
bundle install --path=vendor/bundle
bundle exec rails webpacker:install

grape を組み込んでいきます。

まずはルーティングから。

vi config/routes.rb
Rails.application.routes.draw do
  mount Test::API => '/'
end

API の実装を作成します。

vi app/api/test/api.rb
class Test::API < Grape::API
  version 'v1', using: :path
  format :json
  content_type :json, 'application/json'
  prefix :api

  resource '/test' do
    get '/:id' do
      { value: params[:id] }
    end
  end
end

ここではとりあえず URI パラメータとして :id を受け取るようにしてあります。

api の大文字が Api でなく API になるように設定します。

vi config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym 'API'
end

では起動してみます。

bundle exec rails s

API のエンドポイントは http://localhost:3000/api/v1/test/:id です。 ここにアクセスしてみます。

luna:~ % curl http://localhost:3000/api/v1/test/123
{"value":"123"}
luna:~ %

:id が受け取れていますね。

メールアドレスを受け取る

ではメールアドレスを受け取るように改造します。

vi app/api/test/api.rb
class Test::API < Grape::API
  version 'v1', using: :path
  format :json
  content_type :json, 'application/json'
  prefix :api

  resource '/test' do
    get '/:email' do
      { value: params[:email] }
    end
  end
end

params のキーが変わっただけですね。

先ほどと同様に curl でアクセスしてみます。

luna:~ % curl http://localhost:3000/api/v1/test/foo@example.jp
{"value":"foo@example"}
luna:~ % 

なんと .jp が消えています。

他のメールアドレスでも試してみます。

luna:~ % curl http://localhost:3000/api/v1/test/foo@example.co.jp

この場合は RoutingError になりました。

解決策

いろいろなメールアドレスで試してみたところ、拡張子として認識されている のでは、と思い至りました。 Rails は URL の末尾に .html とか .json とかを付けて、希望する レスポンス形式を指定する機能がありますよね。 あれが効いているのでは、ということです。

api.rb の get の行を以下のように変更します。

    get '/:email', requirements: { email: /.*/ } do

このように変更したところ、正しく受け取ることができました!

luna:~ % curl http://localhost:3000/api/v1/test/foo@example.co.jp
{"value":"foo@example.co.jp"}
luna:~ %

systemd - .timer 編

こんにちは、masm11 です。

systemd シリーズの第5回、最終回です。

f:id:masm11:20201202230535p:plain

初回:

前回:

今回は .timer ファイルについて説明します。

.timer ファイルとは

以前から Linux/UNIX を管理している方には、cron の代わり、と言うと 話が早いかと思います。前回比較した inetd よりご存知の方は多いのでは ないかと思います。

この unit ファイル中に指定されている日時に、指定されているサービスを 起動するのです。

.timer ファイルの書き方

[Unit] セクション

.timer ファイルも unit ファイルの一種なので、[Unit] セクションが あります。

[Unit]
Description=ZFS replication timer

こんな感じで良いかと思います。.service の場合と同様、Description は ログ出力の際などに使われます。

[Timer] セクション

.timer ファイル特有のセクションです。

OnCalendar=hourly

これで、1時間おき (毎正時) に service を起動してくれます。 hourly の他に daily や weekly なども指定できます。

でも、毎正時っていろんな処理が起動して負荷が高くなりがちですよね。 そういう時にはこれです。

AccuracySec=300

指定するのは秒数です。OnCalendar で指定した時刻から、ランダムで AccuracySec までずらしてくれます。

Persistent=true

これを true にしておくと、前回 service を起動した日時をディスクに 保存しておいて、次に timer が start した際 (OS 起動時とか) に 「1回飛ばしてる!」と思ったら、その瞬間、service を起動してくれます。

Unit=foo.service

これは起動する service 名を指定します。 指定しなかった場合は、foo.timer のデフォルトは foo.service に なりますので、大抵の場合は指定しなくて良いと思います。

[Install] セクション

このセクションを書いておくと、systemctl enable できるようになります。

[Install]
WantedBy=timers.target

こう書いておくことが多いと思います。

.timer ファイルの書き方

そして、起動するサービスも定義しておく必要があります。 サービスですので .service ファイルに書きます。

[Unit]
Description=ZFS replication service
After=network.target
Requires=network.target

[Service]
Type=oneshot
ExecStart=/etc/systemd/zfs/zfsendsnap.rb zroot/home

ExecStart にサービスのプログラムを指定します。

[Install] セクションは不要です。

あとは、

sudo systemctl start foo.timer

とするだけで、指定日時になると service が起動します。

デバッグ

service を開発中で、timer 経由でなく service を直接起動したい場合もあるでしょう。

その場合は、

systemctl start foo.service

で起動することができます。

まとめ

今回は .timer ファイルの内容について説明しました。

ここまでの5回で、

  • systemctl の使い方
  • .service の書き方
  • unit ファイルの編集方法
  • .socket の書き方
  • .timer の書き方

について、具体例として私がよく使う使い方を交えながら説明してきました。 今回でこのシリーズは終わりです。

systemd はなんとも大きなシステムでとっつきにくいですが、 このくらいでも書けると、だいぶん systemd の世界に親しみを感じるようになります。

少しでも参考にしていただけたら幸いです。

では良いお年を!

【解決】nginx で gzip 圧縮がされない問題

f:id:kizashi1122:20201222093127p:plain

id:kizashi1122 です。 2020年ももう終わりですね。今年最後のエントリです。今年も色々ありました。

その色々あった中で nginx がらみで面白いことがあったのでシェアします。

弊社サービス Re:lation では Ruby on Rails を採用しており、AWS 上で稼働させています。 フロントには ALB をおいて、nginx をはさみ、unicorn が動いています。nginx は静的ファイルへのアクセスやHTTPのリダイレクト処理、固定ヘッダの追加など静的な処理を任せています。

nginx に任せている処理の中に gzip 圧縮があります。これは動的生成コンテンツも圧縮することができ、特にレスポンスがテキストの場合などは圧縮率が高くなり転送サイズはぐっと小さくなります。

現在はほとんどのサービスで gzip 圧縮が使われてると思います。

HTTP リクエストヘッダ

ブラウザがサポートしている圧縮アルゴリズムがあればリクエストヘッダに以下のように設定されます。 br というのは、Brotli というアルゴリズムで圧縮率が高いようです。

Accept-Encoding: gzip, deflate, br

HTTP レスポンスヘッダ

そして、圧縮をサポートしているならば、サーバー側は圧縮してもよいので圧縮して送ります。

content-encoding: gzip

圧縮されたレスポンスのヘッダはこんな感じになります。

nginx の設定で gzip 圧縮を有効にしたい場合は設定ファイルにこのような記述をします(最低限)。

  gzip              on;
  gzip_types        application/json text/plain text/css application/x-javascript text/xml application/xml application/rss+xml text/javascript application/javascript image/x-icon;

text/html は指定しなくてもデフォで圧縮がかかるようです。

さて

とある環境からどうも遅く感じるという報告があり、パフォーマンスのボトルネックを探ってたときにある状況を発見しました。 (そのとある環境からの問題はまだ解決してないのですが)

レスポンスによっては gzip 圧縮が効いてないようなのです。

そのリクエストは POST でした。ははーん、 POST だと効かないのか? いやそうではないようだ。ちゃんと POST のレスポンスでも圧縮されている。

なにが違うのか?

調べていった結果、HTTPのステータスコードが 201 (Created) の場合のみ圧縮が効いてないのです。 nginx のソースをみてみます。

https://github.com/nginx/nginx/blob/master/src/http/modules/ngx_http_gzip_filter_module.c#L228-L240

ここに gzip 圧縮フィルタの関数があります。 if 文をみると HTTP_OK (200) でも NGX_HTTP_FORBIDDEN (403) でも NGX_HTTP_NOT_FOUND (404) でもない場合は処理を抜けて次のフィルタ適用の処理に進んでしまうようです。

微妙だ。

解決策としては、ステータスコードを 201 から 200 に変えるだけ。 これで圧縮が効くようになりました。

めでたしめでたし。