Railsで、モデルとマイグレーションファイルのそれぞれにON DELETE CASCADEな設定をして挙動を確認してみた

DB上のテーブル間に外部キー制約があり、「親を消したら、親に関係する子も削除する」みたいな処理を行いたいとします。

この場合、テーブルの外部キー定義で ON DELETE を指定することで対応できます。

 
そんな中、Railsでは「親を消したら、親に関係する子も削除する」をどのように実現するか調べてみたところ、

  • モデルで dependent を指定する
  • マイグレーションファイルの外部キー定義で on_delete: :cascade を指定する

の2つの方法があるようでした。

そこで、それぞれの挙動を確認してみたため、メモを残します。

 
目次

 

環境

  • Rails 7.0.2.3
  • SQLite
    • Railsのドキュメントを読む限り、今回の検証範囲ではDBによる違いはなさそうなので、今回は準備が容易なSQLiteで検証しました

 

テーブルの準備

今回は

  • 親 : 子 = 1 : n
  • 子 : 孫 = 1 : n

な関係を持つテーブルを用意します。

 
ジェネレータでそれぞれのモデルを生成します。

# 親
% bin/rails g model Parent name:string

# 子
% bin/rails g model Child name:string parent:references

# 孫
% bin/rails g model Grandchild name:string child:references

 
続いて、1側のモデルからn側のモデルを参照できるよう、1側のモデルに has_many を追加します。
2.3 has_many関連付け | Active Record の関連付け - Railsガイド

また、親を削除したときにモデルのコールバックが動くかどうかも確認するため、ParentとChildにコールバックを追加します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }  # 追加
  after_destroy -> { puts '[Parent] after destroy' }  # 追加

  has_many :children  # 追加
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }  # 追加
  after_destroy -> { puts '[Child] after destroy' }  # 追加

  belongs_to :parent
  has_many :grandchildren  # 追加
end

 
続いて、初期データとしてfixtureを用意します。

今回は「parents.name == 親1 なデータを削除したときに、子・孫がどうなるか」が確認できるデータを用意します。

parents.yml

parent_1:
  name: 親1

parent_2:
  name: 親2

children.yml

child_1_1:
  name: 親1の子1
  parent: parent_1

child_1_2:
  name: 親1の子2
  parent: parent_1

child_2:
  name: 親2の子
  parent: parent_2

grandchildren.yml

grand_child_1_1_1:
  name: 親1の子1の孫1
  child: child_1_1

grand_child_1_1_2:
  name: 親1の子1の孫2
  child: child_1_1

grand_child_1_2:
  name: 親1の子2の孫
  child: child_1_2

 
以上で準備ができました。

 

各テーブルの外部キーにON DELETE CASCADEがない版の動作確認

まずは各テーブルの外部キーに ON DELETE CASCADE がない版で動作を確認してみます。

 

モデルにdependentなし

ここまでの作業で モデルにdependentなし、外部キーに on_delete なし という状態になっているため、まずはこのパターンで動作を確認してみます。

Railsコンソールで Parent.find_by(name: '親1').destroy を実行してみると、エラーになりました。

>> Parent.find_by(name: '親1').destroy

(3.8ms)  SELECT sqlite_version(*)
Parent Load (0.2ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
TRANSACTION (0.1ms)  begin transaction
Parent Destroy (0.9ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
TRANSACTION (0.3ms)  rollback transaction
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': SQLite3::ConstraintException: FOREIGN KEY constraint failed (ActiveRecord::InvalidForeignKey)
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': FOREIGN KEY constraint failed (SQLite3::ConstraintException)

 
SQLiteのドキュメントを見ると

The ON DELETE and ON UPDATE action associated with each foreign key in an SQLite database is one of "NO ACTION", "RESTRICT", "SET NULL", "SET DEFAULT" or "CASCADE". If an action is not explicitly specified, it defaults to "NO ACTION".

https://www.sqlite.org/foreignkeys.html#fk_actions

とありました。

そのため、 NO ACTION

NO ACTION: Configuring "NO ACTION" means just that: when a parent key is modified or deleted from the database, no special action is taken.

になり、Childが参照するParentのidがなくなってしまった結果、Parent - Child 間で整合性が取れなくなったことからエラーになったようです。

 

モデルにdependent: destroy あり

続いて、外部キーの設定は変更しないまま、モデルの has_many のオプション dependent: destroy を指定してみます。

まずは念のためデータを初期化しておきます。

% bin/rails db:fixtures:load

   
次にモデルを変更します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }
  
  has_many :children, dependent: :destroy  # 変更
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }
  
  belongs_to :parent
  has_many :grandchildren, dependent: :destroy  # 変更
end

 
Railsコンソールで実行するとエラーになりませんでした。

ログを見ると、孫 > 子 > 親の順で1つずつSQLを発行して削除していることから、外部キーの制約には引っかからずに消せているようです。

>> Parent.find_by(name: '親1').destroy

   (2.9ms)  SELECT sqlite_version(*)
  Parent Load (0.8ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Load (0.6ms)  SELECT "children".* FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
[Child] before destroy
  Grandchild Load (0.5ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 242255126]]
  Grandchild Destroy (0.4ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 394275835]]
  Child Destroy (0.1ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 242255126]]
[Child] after destroy
[Child] before destroy
  Grandchild Load (0.0ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 393860266]]
  Grandchild Destroy (0.0ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 111204660]]
  Grandchild Destroy (0.1ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 531204236]]
  Child Destroy (0.0ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 393860266]]
[Child] after destroy
  Parent Destroy (0.1ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.7ms)  commit transaction

 

モデルにdependent: delete_all あり

続いて dependent: delete_all を試します。

なお、モデルの設定が has_many だったため dependent に指定するキーは delete_all でしたが、 has_one などは delete になるようです。

 
まずは念のためデータを初期化しておきます。

% bin/rails db:fixtures:load

   
次に、モデルを変更します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }

  has_many :children, dependent: :delete_all  # 変更
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren, dependent: :delete_all  # 変更
end

 
準備ができたため実行するとエラーになります。

Railsガイドによると

:delete_all: 関連付けられたオブジェクトはすべてデータベースから直接削除されます(コールバックは実行されません)。

のため、DBの外部キー制約の設定 (NO ACTION) に従いエラーとなったようです。

>> Parent.find_by(name: '親1').destroy

 Parent Load (0.3ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Delete All (0.9ms)  DELETE FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
  TRANSACTION (0.8ms)  rollback transaction
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': SQLite3::ConstraintException: FOREIGN KEY constraint failed (ActiveRecord::InvalidForeignKey)
path/to/rails_association_sample/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': FOREIGN KEY constraint failed (SQLite3::ConstraintException)

 

各テーブルの外部キーにON DELETE CASCADEがある版の動作確認

続いて、各テーブルの外部キーに ON DELETE CASCADE を設定した上で、各動作を確認してみます。

 

マイグレーションファイルによる環境構築

まずは念のためデータを初期化しておきます。

% bin/rails db:fixtures:load

 
次に、外部キー制約の変更を行うためにマイグレーションファイルを生成します。

テーブルに外部キー制約があるため、 1 : n のn側のテーブルに対するマイグレーションファイルを生成します。

# Child用
% bin/rails g migration ChangeFkToChild

# Grandchild用
bin/rails g migration ChangeFkToGrandchild

 
ファイルができたのでマイグレーションファイルに追記していきます。

マイグレーションファイルで列の変更をする場合は change_column が使えそうです。
https://api.rubyonrails.org/v7.0.3/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-change_column

ただ、今回は外部キーの設定なため、使うのは不適切そうでした。また、change_foreign_key のようなメソッドは見当たりませんでした。

そこで、

  • 既存のデータを削除しない
  • 外部キーの削除 > 外部キーの設定 の順で変更する

を満たすマイグレーションファイルを作成します。

 
Child用

class ChangeFkToChild < ActiveRecord::Migration[7.0]
  def change
    # 既存のFKを削除
    remove_foreign_key :children, :parents

    # FKを追加
    add_foreign_key :children, :parents, on_delete: :cascade
  end
end

 
Grandchild用

class ChangeFkToGrandhild < ActiveRecord::Migration[7.0]
  def change
    # 既存のFKを削除
    remove_foreign_key :grandchildren, :children

    # FKを追加
    add_foreign_key :grandchildren, :children, on_delete: :cascade
  end
end

 
マイグレーションファイルの準備ができたので実行します。

% bin/rails db:migrate

== 20220514011956 ChangeFkToChild: migrating ==================================
-- remove_foreign_key(:children, :parents)
   -> 0.0156s
-- add_foreign_key(:children, :parents, {:on_delete=>:cascade})
   -> 0.0130s
== 20220514011956 ChangeFkToChild: migrated (0.0287s) =========================
== 20220514013014 ChangeFkToGrandhild: migrating ==============================
-- remove_foreign_key(:grandchildren, :children)
   -> 0.0112s
-- add_foreign_key(:grandchildren, :children, {:on_delete=>:cascade})
   -> 0.0119s
== 20220514013014 ChangeFkToGrandhild: migrated (0.0233s) =====================

 
マイグレーション後のDDLを見ると、 on delete cascade が追加されていました。

-- auto-generated definition
create table children
(
    id         integer     not null
        primary key,
    name       varchar default NULL,
    parent_id  integer     not null
        constraint fk_rails_554cba9b33
            references parents
            on delete cascade,
    created_at datetime(6) not null,
    updated_at datetime(6) not null
);

create index index_children_on_parent_id
    on children (parent_id);

 
また、データもそのまま残っていました。

>> Child.all
   (3.6ms)  SELECT sqlite_version(*)
  Child Load (0.3ms)  SELECT "children".* FROM "children"
=> [#<Child:0x000000010ab28ff8 id: 242255126, name: "親1の子2", parent_id: 393698370, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>, #<Child:0x000000010ab58758 id: 393860266, name: "親1の子1", parent_id: 393698370, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>, #<Child:0x000000010ab58690 id: 476916307, name: "親2の子", parent_id: 243142138, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>]

 
これで外部キー制約に ON DELETE CASCADE を付けたときの準備ができました。

 

モデルにdependent なし

モデルから dependent 設定を削除します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }

  has_many :children  # dependent 設定なし
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren  # dependent 設定なし
end

 
Railsコンソールで実行すると、 parents テーブル削除の DELETE 文が1つだけログに出ていました。

>> Parent.find_by(name: '親1').destroy

   (3.7ms)  SELECT sqlite_version(*)
  Parent Load (0.2ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.2ms)  begin transaction
  Parent Destroy (0.7ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.8ms)  commit transaction
=> #<Parent:0x000000010b570fb0 id: 393698370, name: "親1", created_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00>

 
次にデータを確認すると、Parent・Child・Grandchild の各テーブルから対象データが削除されていました。

>> Parent.all
  Parent Load (0.2ms)  SELECT "parents".* FROM "parents"
=> [#<Parent:0x000000010b5b9aa8 id: 243142138, name: "親2", created_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.902472000 UTC +00:00>]
>> Child.all
  Child Load (0.3ms)  SELECT "children".* FROM "children"
=> [#<Child:0x000000010b5f2650 id: 476916307, name: "親2の子", parent_id: 243142138, created_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00, updated_at: Wed, 11 May 2022 14:39:13.895500000 UTC +00:00>]
>> Grandchild.all
  Grandchild Load (0.2ms)  SELECT "grandchildren".* FROM "grandchildren"
=> []

 

モデルにdependent: destroy あり

まずはデータを初期化します。

% bin/rails db:fixtures:load

 
続いて、モデルの has_manydependent: destroy を追加します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }
  
  has_many :children, dependent: :destroy  # 追加
end

 
Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren, dependent: :destroy  # 追加
end

 
Railsコンソールで実行すると削除されました。外部キー制約に ON DELETE CASCADE は設定されていますが、都度 DELETE文を発行し、孫から消していっています。

>> Parent.find_by(name: '親1').destroy

   (3.7ms)  SELECT sqlite_version(*)
  Parent Load (0.2ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Load (0.1ms)  SELECT "children".* FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
[Child] before destroy
  Grandchild Load (0.1ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 242255126]]
  Grandchild Destroy (0.4ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 394275835]]
  Child Destroy (0.1ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 242255126]]
[Child] after destroy
[Child] before destroy
  Grandchild Load (0.0ms)  SELECT "grandchildren".* FROM "grandchildren" WHERE "grandchildren"."child_id" = ?  [["child_id", 393860266]]
  Grandchild Destroy (0.0ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 111204660]]
  Grandchild Destroy (0.1ms)  DELETE FROM "grandchildren" WHERE "grandchildren"."id" = ?  [["id", 531204236]]
  Child Destroy (0.1ms)  DELETE FROM "children" WHERE "children"."id" = ?  [["id", 393860266]]
[Child] after destroy
  Parent Destroy (0.1ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.9ms)  commit transaction
=> #<Parent:0x0000000113b188e8 id: 393698370, name: "親1", created_at: Sat, 14 May 2022 01:36:18.699254000 UTC +00:00, updated_at: Sat, 14 May 2022 01:36:18.699254000 UTC +00:00>

 

モデルにdependent: delete_all あり

まずはデータを初期化します。

% bin/rails db:fixtures:load

 
続いて、モデルの has_many について dependent: delete_all へと追加します。

Parent

class Parent < ApplicationRecord
  before_destroy -> { puts '[Parent] before destroy' }
  after_destroy -> { puts '[Parent] after destroy' }

  has_many :children, dependent: :delete_all  # 変更
end

Child

class Child < ApplicationRecord
  before_destroy -> { puts '[Child] before destroy' }
  after_destroy -> { puts '[Child] after destroy' }

  belongs_to :parent
  has_many :grandchildren, dependent: :delete_all  # 変更
end

 
実行するとデータが削除されました。ログを見ると

  • Parentは、DELETE文の発行とコールバックの実行ログがある
  • Childは、DELETE文の発行のみログがあり、コールバックは実行されていない
  • Grandchildは、なにもログに残っていない

という状態でした。

>> Parent.find_by(name: '親1').destroy

   (3.3ms)  SELECT sqlite_version(*)
  Parent Load (0.5ms)  SELECT "parents".* FROM "parents" WHERE "parents"."name" = ? LIMIT ?  [["name", "親1"], ["LIMIT", 1]]
[Parent] before destroy
  TRANSACTION (0.1ms)  begin transaction
  Child Delete All (0.5ms)  DELETE FROM "children" WHERE "children"."parent_id" = ?  [["parent_id", 393698370]]
  Parent Destroy (0.1ms)  DELETE FROM "parents" WHERE "parents"."id" = ?  [["id", 393698370]]
[Parent] after destroy
  TRANSACTION (0.8ms)  commit transaction
=> #<Parent:0x000000010c3e0988 id: 393698370, name: "親1", created_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00, updated_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00>

 
そこでデータを確認すると、テーブルの ON DELETE CASCADE に従い、孫も削除されているようでした。

>> Parent.all
  Parent Load (0.8ms)  SELECT "parents".* FROM "parents"
=> [#<Parent:0x000000010c473328 id: 243142138, name: "親2", created_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00, updated_at: Sat, 14 May 2022 01:42:00.738531000 UTC +00:00>]
>> Child.all
  Child Load (0.7ms)  SELECT "children".* FROM "children"
=> [#<Child:0x000000010c47a060 id: 476916307, name: "親2の子", parent_id: 243142138, created_at: Sat, 14 May 2022 01:42:00.728159000 UTC +00:00, updated_at: Sat, 14 May 2022 01:42:00.728159000 UTC +00:00>]
>> Grandchild.all
  Grandchild Load (0.6ms)  SELECT "grandchildren".* FROM "grandchildren"
=> []

 

まとめ

外部キーに ON DELETE CASCADE の設定があるかどうかに関わらず、 has_many dependent: destroy は 孫 > 子 > 親 の順番で1つずつDELETEしていました。

もしDELETE文の発行回数を減らすにはマイグレーションファイルの外部キー設定で on_delete: :cascade を指定した上で、

  • has_many には何も指定しない場合、親用のDELETE文を1回発行して削除する
  • has_many dependent: delete_all の場合、親・子に対してDELETEを合計2回発行して削除する

とすれば良さそうでした。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_association-sample

 
今回のプルリクはこちらです。各段階でコミットしています。
https://github.com/thinkAmi-sandbox/rails_association-sample/pull/2

Railsで、eager_loadのLEFT OUTER JOINにて、WHERE句とON句のそれぞれで絞り込みしてみた

Railseager_load を使う中で、 eager_load についてもうちょっと理解を深めたいと思いました。

eager_load についてはWeb上にいろいろな記事はあるものの、自分の理解を深めるためにためしたときのメモを残します。

今回は eager_load のLEFT JOINにて、WHERE句とON句のそれぞれで絞り込みをしてみたときのメモです。

 
目次

 

環境

 

記事の中で使うモデル

以前の記事で使った、 Food > Fruit > Cultivar というモデルを再利用します。
Railsで、関連付けの基本や関連付けにおけるNOT NULL制約まわりをさわってみた - メモ的な思考的な

ER図的にはこんな感じです。各テーブルには name と親に対する外部キーがあります。

 
Railsのモデルは以下とします。

Cultivarの外部キーに NOT NULL 制約はありませんが、今回試す範囲では問題ないです。

# Food
class Food < ApplicationRecord
end

# Fruit
class Fruit < ApplicationRecord
  belongs_to :food
  has_many :cultivars  # Fruit から Cultivar をたぐるときに使う
end

# Cultivar
class Cultivar < ApplicationRecord
  belongs_to :fruit, required: false
end

 
また、Railsのfixtureとして以下を用意します。

foods.yml

kudamono:
  name: 果物

 
fruits.yml

apple:
  name: りんご
  food: kudamono

mandarin:
  name: みかん
  food: kudamono

 
cultivar.yml

fuji:
  name: ふじ
  fruit: apple

shinano_gold:
  name: シナノゴールド
  fruit: apple

shinano_dolce:
  name: シナノドルチェ
  fruit: apple

 
このfixtureを投入すると、各テーブルはこんな感じになります。

なお、各テーブルの id は、fixtureを投入したときに自動で採番したものになります。

foods

id name
225449629 果物

 
fruits

id name food_id
396042782 みかん 225449629
690933842 りんご 225449629

 
cultivar

このテーブルには みかん を親に持つレコードはありません

id name fruit_id
105875372 ふじ 690933842
427894753 シナノゴールド 690933842
835639346 シナノドルチェ 690933842

 

そもそも、eager_load を使うことについて

N+1 問題の発生について

よくあるケースとして、いわゆる N+1 問題が発生するときに使います。

そこで、「fruitsに紐づくcultivarの名前を表示したい」を例にしてためしてみます。

 
まずは、RailsでN+1問題が発生するか気づきやすくなる bullet gem をセットアップします。
flyerhzm/bullet: help to kill N+1 queries and unused eager loading

 
Gemfileに追記します。

group :development do
  gem "bullet"
end

 
セットアップします。

# gemのインストール
% bundle install

# セットアップ
% bin/rails g bullet:install

 
Railsコンソールで以下を入力・実行します。なお、Railsコンソールでも bullet が動作するよう、 Bullet.profile ブロックの中で実行します。
mysql - Bullet gem not logging from Rails console - Stack Overflow

Bullet.profile do
  Fruit.all.map do | fruit |
    puts '=== fruit ============>'
    puts fruit.name

    fruit.cultivars.map do | cultivar |
      puts '=== cultivar ===>'
      puts cultivar.name
    
      cultivar.name
    end
  end
end

 
実行すると以下の結果が表示されます。SQLが3回実行されています。

  Fruit Load (0.2ms)  SELECT "fruits".* FROM "fruits"
=== fruit ============>
みかん
  Cultivar Load (0.2ms)  SELECT "cultivars".* FROM "cultivars" WHERE "cultivars"."fruit_id" = ?  [["fruit_id", 396042782]]
=== fruit ============>
りんご
  Cultivar Load (0.1ms)  SELECT "cultivars".* FROM "cultivars" WHERE "cultivars"."fruit_id" = ?  [["fruit_id", 690933842]]
=== cultivar ===>
ふじ
=== cultivar ===>
シナノゴールド
=== cultivar ===>
シナノドルチェ

=> [[], ["ふじ", "シナノゴールド", "シナノドルチェ"]]

 
bulletのログにも N+1 の発生が記録されます。

USE eager loading detected
  Fruit => [:cultivars]
  Add to your query: .includes([:cultivars])

 

eager_load を使って、 N+1 問題に対応する

次に、 eager_load を使い、 N+1 問題に対応してみます。

同じく、以下をRailsコンソールで実行します。

Bullet.profile do
  Fruit.eager_load(:cultivars).map do | fruit |
    puts '=== fruit ============>'
    puts fruit.name

    fruit.cultivars.map do | cultivar |
      puts '=== cultivar ===>'
      puts cultivar.name
    
      cultivar.name
    end
  end
end

 
実行結果です。なお、読みやすくするためSQLはフォーマットしています。

LEFT OUTER JOINSQLを1回実行し、結果をキャッシュして使っているようです。

SQL (0.3ms) 
SELECT
    "fruits"."id" AS t0_r0,
    "fruits"."name" AS t0_r1,
    "fruits"."food_id" AS t0_r2,
    "fruits"."created_at" AS t0_r3,
    "fruits"."updated_at" AS t0_r4,
    "cultivars"."id" AS t1_r0,
    "cultivars"."name" AS t1_r1,
    "cultivars"."created_at" AS t1_r2,
    "cultivars"."updated_at" AS t1_r3,
    "cultivars"."fruit_id" AS t1_r4
FROM
    "fruits"
    LEFT OUTER JOIN
        "cultivars"
    ON  "cultivars"."fruit_id" = "fruits"."id"

=== fruit ============>
みかん
=== fruit ============>
りんご
=== cultivar ===>
ふじ
=== cultivar ===>
シナノゴールド
=== cultivar ===>
シナノドルチェ
=> [[], ["ふじ", "シナノゴールド", "シナノドルチェ"]]

 
なお、LEFT OUT JOIN を使っているため、上記のSQLの実行結果は

と、子が存在しない みかん のデータも取得して NULL になっています。

 

ActiveRecordの where() により、WHERE句で絞り込む

上記の例では eager_load を使って全件を取得していました。ただ、 eager_load を使ったときに絞り込みを行いたいときもあると思います。

そこで、eager_loadwhere を使って

  • 親側 (外部表・駆動表)
  • 子側 (内部表)

で絞り込むときのケースをそれぞれ見ていきます。

 

親側(外部表・駆動表) での絞り込み

まずは、 eager_load の結果に対し where('fruits.name LIKE "%ん%"') と、親( fruits )側で絞り込みをためしてみます。

Fruit.eager_load(:cultivars).where('fruits.name LIKE "%ん%"').map do | fruit |
  puts '=== fruit ============>'
  puts fruit.name

  fruit.cultivars.map do | cultivar |
    puts '=== cultivar ===>'
    puts cultivar.name
    
    cultivar.name
  end
end

 
この場合、

SQL (0.2ms)  
SELECT
    "fruits"."id" AS t0_r0,
    "fruits"."name" AS t0_r1,
    "fruits"."food_id" AS t0_r2,
    "fruits"."created_at" AS t0_r3,
    "fruits"."updated_at" AS t0_r4,
    "cultivars"."id" AS t1_r0,
    "cultivars"."name" AS t1_r1,
    "cultivars"."created_at" AS t1_r2,
    "cultivars"."updated_at" AS t1_r3,
    "cultivars"."fruit_id" AS t1_r4
FROM
    "fruits"
    LEFT OUTER JOIN
        "cultivars"
    ON  "cultivars"."fruit_id" = "fruits"."id"
WHERE
    (fruits.name LIKE "%ん%")

=== fruit ============>
みかん
=== fruit ============>
りんご
=== cultivar ===>
ふじ
=== cultivar ===>
シナノゴールド
=== cultivar ===>
シナノドルチェ
=> [[], ["ふじ", "シナノゴールド", "シナノドルチェ"]]

と、子が存在しないみかんのデータも取得できています。

SQLの実行結果でもNULLを持つ行が存在しています。

 

子側(内部表) での絞り込み

続いて、 where('cultivars.name LIKE "シナノ%"') と、子側での絞り込みを行います。

Fruit.eager_load(:cultivars).where('cultivars.name LIKE "シナノ%"').map do | fruit |
  puts '=== fruit ============>'
  puts fruit.name

  fruit.cultivars.map do | cultivar |
    puts '=== cultivar ===>'
    puts cultivar.name
    
    cultivar.name
  end
end

 
すると、みかんのデータが取得できなくなりました。

SQL (0.9ms)  
SELECT
    "fruits"."id" AS t0_r0,
    "fruits"."name" AS t0_r1,
    "fruits"."food_id" AS t0_r2,
    "fruits"."created_at" AS t0_r3,
    "fruits"."updated_at" AS t0_r4,
    "cultivars"."id" AS t1_r0,
    "cultivars"."name" AS t1_r1,
    "cultivars"."created_at" AS t1_r2,
    "cultivars"."updated_at" AS t1_r3,
    "cultivars"."fruit_id" AS t1_r4
FROM
    "fruits"
    LEFT OUTER JOIN
        "cultivars"
    ON  "cultivars"."fruit_id" = "fruits"."id"
WHERE
    (cultivars.name LIKE "シナノ%")

=== fruit ============>
りんご
=== cultivar ===>
シナノゴールド
=== cultivar ===>
シナノドルチェ
=> [["シナノゴールド", "シナノドルチェ"]]

 
SQLを実行してみても、みかんの行が存在しません。

 
これは、 eager_load は LEFT OUTER JOIN なことから、 where による WHERE句での絞り込みを行うことで、子側が存在しないデータは抽出されなくなります。

 
そのため、「子側が存在しない場合は結果に含めなくて良い」場合は問題ないですが、「子側が存在しなくても、結果に含めたい」場合には where は使えません。

 

ActiveRecordの関連付けでのスコープにより、ON句で絞り込む

上記の通り、存在しない子側も取得したい場合は where() によるWHERE句での絞り込みは使えないため、ON句で絞り込めるような方法を探します。

すると、ActiveRecordの関連付けでのスコープを使うことで、ON句での絞り込みができることがわかりました。

 
そこで実際に試してみます。

まずは、親側のモデル Fruithas_many を追加で定義します。
4.3.2 has_manyのオプション | Active Record の関連付け - Railsガイド

その際、スコープで結合条件を追加します。
4.3.3 has_manyのスコープについて | Active Record の関連付け - Railsガイド

また、追加した関連付け名 full_cultivars から対象のモデルを推測できないため、 class_name を使い Cultivar を指定します。
4.3.2.3 :class_name | Active Record の関連付け - Railsガイド

それらをまとめると、モデルは以下となります。

class Fruit < ApplicationRecord
  belongs_to :food
  has_many :cultivars

  # スコープを持つ has_many を追加
  has_many :full_cultivars, -> { where('cultivars.name LIKE "シナノ%"') }, class_name: 'Cultivar'
end

 
準備ができたので、ためしてみます。

eager_load にわたす関連付け名は、追加した full_cultivars とします。

また、念のため N+1 が発生していないかどうかを見るため、Bullet.profile も使っておきます。

Bullet.profile do
  # 追加した has_many の関連付け名を指定
  Fruit.eager_load(:full_cultivars).map do | fruit |
    puts '=== fruit ============>'
    puts fruit.name

    fruit.full_cultivars.map do | cultivar |
      puts '=== cultivar ===>'
      puts cultivar.name
    
      cultivar.name
    end
  end
end

 
実行結果です。 ON句で絞り込むSQLが発行されました。

SQL (0.2ms)
SELECT
    "fruits"."id" AS t0_r0,
    "fruits"."name" AS t0_r1,
    "fruits"."food_id" AS t0_r2,
    "fruits"."created_at" AS t0_r3,
    "fruits"."updated_at" AS t0_r4,
    "cultivars"."id" AS t1_r0,
    "cultivars"."name" AS t1_r1,
    "cultivars"."created_at" AS t1_r2,
    "cultivars"."updated_at" AS t1_r3,
    "cultivars"."fruit_id" AS t1_r4
FROM
    "fruits"
    LEFT OUTER JOIN
        "cultivars"
    ON  "cultivars"."fruit_id" = "fruits"."id"
    AND (cultivars.name LIKE "シナノ%")

=== fruit ============>
みかん
=== fruit ============>
りんご
=== cultivar ===>
シナノゴールド
=== cultivar ===>
シナノドルチェ
=> [[], ["シナノゴールド", "シナノドルチェ"]]

 
SQLを実行してみると、みかんの行も取得できていました。

 

まとめ

以上より、 eager_load で絞り込みを行う場合、

  • NULLな行も取得したい場合は、ON句で絞り込むスコープ
  • NULLな行が不要な場合は、WHERE句で絞り込む where()

を使えば良さそうでした。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_association-sample

 
今回のプルリクは以下となります。
https://github.com/thinkAmi-sandbox/rails_association-sample/pull/1

RubyMine 2021.3.3 にて、 debug gemを含むRails 6.1 アプリがデバッグできなかったため、回避策を実施してみた

自分の環境だけかもしれませんが、RubyMine 2021.3.3で debug gem を含む Rails 6.1 アプリがデバッグできなかったため、回避策を実施したときのメモを残します。

 
目次

 

環境

手元の環境は以下ですが、もしかしたら他の環境も同様かもしれません。

 

再現の流れ

Railsとdebug gem の準備

rails new します。

なお、手元ではシステムのRubyRailsを入れず、bundlerで用意したRailsを使って行っています。

% bundle exec rails new rails_debug_6_1 --minimal --skip-bundle

 
RubyMineにて、同一Rubyでプロジェクトごとにgemを管理したいため、rbenv-gemsets を使います。

 
rbenv-gemsetsの準備をします。

# ディレクトリ移動
% cd rails_debug_6_1

# gemsetsを用意
% echo 'rails_debug_6_1' > .rbenv-gemsets

 
この状態で、RubyMineでプロジェクトを開きます。 Run/Debug Configurations を確認します。

Ruby SDKUse project SDK が選択され、 rails_debug_6_1 が入っていることを確認します。

また、Ruby SDKのエラーとして

Run Configuration Error: [No Rails found in SDK]

が出てることを確認します。

 
次に、 .rbenv-gemsets ファイルを開き、gemset名にカーソルを合わせ Create 'rails_debug_6_1' を選択し、gemsetsに追加します。
https://www.jetbrains.com/help/ruby/2022.1/ruby-gemsets.html#create_rbenv_gemset

rbenv-gemsets の準備ができたため、いったんRubyMineを閉じた後、再度開きます。

 
メニューから Preference > Languages & Frameworks > Ruby SDK and Gems を開き、作成したgemsetsが表示されていることを確認します。

gemsetsを有効にするため、チェックボックスにチェックを入れます。

 
 
次に Gemfile を開き、 byebug の代わりに debug を記載します。

group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  # gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
  gem 'debug', platforms: [:mri, :mingw, :x64_mingw]
end

 
bundle install し、必要なgemをインストールします。

インストール後、Ruby SDKにインストール済のgemが表示されることを確認します。

 

Railsアプリの作成

bin/rails g controller foods index でコントローラ・ビュー・ルーティングを生成します。

 
生成したコントローラ (app/controllers/foods_controller.rb) を開き、デバッグ用のコードを入れます。

この a = 1 の部分にブレークポイントを置きます。

class FoodsController < ApplicationController
  def index
    a = 1
  end
end

 
以上で準備ができました。

 

通常起動で動作確認

RubyMineの Run Development... をクリックし、Railsを起動します。

http://localhost:3000/foods/index にアクセスすると、ブラウザにビューの内容が表示されます。

 

デバッグ起動で動作確認

次に、RubyMineの Debug Development をクリックし、Railsデバッグ起動します。

すると、RubyMine Debugerのポップアップが出ます。

The debugging gems are not installed. Would you like to install them?

と表示されるため、 Yes をクリックすると、

  • debase 2.3.2
  • ruby-debug-ide 2.3.3

がインストールされます。

 
その後、デバッグ起動すると、以下が表示されます。

Fast Debugger (ruby-debug-ide 2.3.3, debase 2.3.2, file filtering is supported, block breakpoints supported, smart steps supported, obtaining return values supported, partial obtaining of instance variables supported) listens on 0.0.0.0:60376 ... * Listening on http://0.0.0.0:3000

その状態で http://localhost:3000/foods/index にアクセスしたところ、ブレークポイントでは止まらずに sleep した状態で動作が停止してしまい、デバッグができない状態になっています。

 

debugの代わりにbyebugを使う

一方、debugの代わりにbyebugをインストールするよう Gemfile に記載し、 bundle install 後にデバッグ起動すると、ブレークポイントで止まります。

gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
# gem 'debug', platforms: [:mri, :mingw, :x64_mingw]

 

 

原因など

youtrackを見るとそれっぽいものはいくつかありました。そのため、このあたりは今後改善されるかもしれません。

 

回避策

Gemfileから debug を削除すれば良いかもしれませんが、RubyMine以外で開発する場合に困るかもしれません。

そこで今回は

とします。

まずは USE_RUBYMINE 環境変数を設定します。

vi .zshrc 

 
.zshrc の末尾に追加します。

export USE_RUBYMINE=1

 
.zshrc を再読込し、環境変数が有効になっていることを確認します。

% source .zshrc
% echo $USE_RUBYMINE
1

 
Gemfileを修正します。

group :development, :test do
  # install_ifを追加
  gem 'debug', platforms: [:mri, :mingw, :x64_mingw], install_if: ENV['USE_RUBYMINE'].nil?
end

 
gem類をインストールし直します。

今回は rbenv-gemsets を使っているため、

  • rbenv-gemsets を削除 & 作成
  • bundle install とします。

 
まずは rbenv-gemsets を削除します。今回は Ruby 2.7.6 で作っているため、以下のように指定します。
https://github.com/jf/rbenv-gemset#usage

% rbenv gemset delete 2.7.6 rails_debug_6_1

 
次に、 .rbenv-gemsets ファイルを開き、Create rails_debug_6_1 します。

 
RubyMineを再起動して、gemsetsが空になっていることを確認します。

 
bundle install を行います。

その後 debug がインストールされていないことを確認します。

 
この状態でデバッグ起動し、ブレークポイントで停止することを確認します。

 

その他

RubyMineをデバッグ起動したときに

ruby-debug-ide 2.3.3, debase 2.3.2

と表示されました。

ただ、RubyGemsを見ると、それぞれのgemのバージョンは

でした。

この差はどこにあるのだろうと思ったら、stackoverflowにコメントがありました。

debase 2.3.2 is a proprietary backend bundled with Ruby plugin or RubyMine. You may use opensource gem from rubygems by disabling 'use experimental debugger' setting. – Olivia May 20, 2021 at 10:01

ruby - RubyMine debug gem versions different from public gem versions - Stack Overflow

 

ソースコード

再現用のアプリをGithubに上げました。 main ブランチで再現します。
https://github.com/thinkAmi-sandbox/rails_debug_6_1-sample

 
debug の代わりに byebug としたブランチはこちら。 https://github.com/thinkAmi-sandbox/rails_debug_6_1-sample/tree/feature/replace_from_debug_to_byebug

 
Gemfileに回避策を設定したブランチとGemfileはそれぞれこちら。

Railsで、関連付けの基本や関連付けにおけるNOT NULL制約まわりをさわってみた

Railsにてモデルの関連付けを行おうとしたとき、色々と機能があることに気づきました。

 
そこで、まずはモデルの関連付けの基本およびNOT NULL制約まわりをさわってみたため、メモを残します。

 
目次

 

環境

  • Rails 7.0.2.3
  • RubyMine 2021.3.3
  • rbenv-gemset 0.5.10
    • RubyMineのRuby SDK にて、プロジェクト単位で gem を管理するため

 

環境構築

rails new します。

% bundle exec rails new rails_association_sample --minimal --skip-bundle

 
次に、今回は rbenv-gemsets を使って、グローバルなRubyにgemを入れることなく RubyMine の Ruby SDK として扱えるようにしています。

 
そのため、 .rbenv-gemsets ファイルを用意します。この状態でRubyMineを起動すると、rbenv gemsets が認識されています。

% echo > .rbenv-gemsets rails_association_sample

 
また、モデルにスキーマの情報があったほうが分かりやすいため、 annotate をGemfileに追加します。
ctran/annotate_models: Annotate Rails classes with schema and routes info

group :development do
  gem "annotate"
end

 
必要なgemの記載が終わったため、インストールします。

% bundle install

 
インストール後、 annotate の準備をします。

% bin/rails g annotate:install
      create  lib/tasks/auto_annotate_models.rake

 

関連付けの作成

準備

今回の関連付けのシナリオは

  • 最初に、 FoodCultivar を用意
    • お互い関連付けなし
  • 次に、 Fruit を用意し、 Food > Fruit > Cultivar となるような関連付けを作る

とします。

関連付けの全体はこんな感じです。

 
まずは準備として、2つのマイグレーションファイルやモデルを生成しておきます。

# Food分を生成
% bin/rails g model Food name:string

# Cultivar分を生成
% bin/rails g model Cultivar name:string

 

テーブル生成時に references の引数として単数形で親を指定する

続いて、 FruitFood の間で関連付けがなされるよう、 Fruit モデルを作成します。

Railsガイドには

belongs_to関連付けで指定するモデル名は必ず「単数形」にしなければなりません。上記の例で、Bookモデルのauthor関連付けを複数形(authors)にしてからBook.create(authors: @author)でインスタンスを作成しようとすると、uninitialized constant Book::Authorsエラーが発生します。Railsは、関連付けの名前から自動的にモデルのクラス名を推測します。従って、関連付け名が誤って複数形になってしまっていると、そこから推測されるクラス名も誤った形の複数形になってしまいます。

2.1 belongs_to関連付け | Active Record の関連付け - Railsガイド

と書かれています。

そこで、

の2つに対して単数形で親を指定します。

今回はモデルのジェネレータを使い、マイグレーションとモデルの両方を一括で設定します。

% bin/rails g model Fruit name:string food:references

 
生成されたマイグレーションファイルは以下のとおりです。  

class CreateFruits < ActiveRecord::Migration[7.0]
  def change
    create_table :fruits do |t|
      t.string :name
      t.references :food, null: false, foreign_key: true

      t.timestamps
    end
  end
end

 
生成されたモデルは以下の通りです。

class Fruit < ApplicationRecord
  belongs_to :food
end

 
Railsコンソールで動作することを確認します。

>> food = Food.create(name: '果物')
...
>> fruit = Fruit.create(name: 'りんご', food: food)
  TRANSACTION (0.0ms)  begin transaction
  Fruit Create (4.8ms)  INSERT INTO "fruits" ("name", "food_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "りんご"], ["food_id", 1], ["created_at", "2022-04-24 11:01:47.504418"], ["updated_at", "2022-04-24 11:01:47.504418"]]
  TRANSACTION (1.3ms)  commit transaction
=> #<Fruit:0x000000010ccda7a0 id: 1, name: "りんご", food_id: 1, created_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00, updated_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00>

 
FruitからFoodをたどるようなデータ取得も行えます。

f = Fruit.find(1).food
  Fruit Load (0.7ms)  SELECT "fruits".* FROM "fruits" WHERE "fruits"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Food Load (0.8ms)  SELECT "foods".* FROM "foods" WHERE "foods"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<Food:0x000000010f905a28 id: 1, name: "果物", created_at: Sun, 24 Apr 2022 11:01:31.836138000 UTC +00:00, updated_at: Sun, 24 Apr 2022 11:01:31.836138000 UTC +00:00>

 

既存のモデルに関連付けを追加

ここまでで

  • Food
  • Fruit
  • Cultivar

の3モデルができました。

ただ、 Food - Fruit 間に関連付けはある一方、 Fruit - Cultivar 間には関連付けがありません。

Railsガイドを見ると

  1. belongs_to関連付けを使う場合は、外部キーを作成する必要があります。

3.3 スキーマの更新 | Active Record の関連付け - Railsガイド

とありました。

 
そこで、Fruit - Cultivar 間に関連付けを行うため、子モデルに対して

  • 外部キーを追加
  • belongs_to を追加

を行います。

 

子モデルに外部キーを追加

まず、子である Cultivar モデルから、親である Fruit モデルへの外部キーを設定します。

マイグレーションファイルを生成します。

% bin/rails g migration AddFruitToCultivars fruit:references

 
中身はこんな感じです。

class AddFruitToCultivars < ActiveRecord::Migration[7.0]
  def change
    add_reference :cultivars, :fruit, null: false, foreign_key: true
  end
end

 
良さそうですので、マイグレーションを実行します。

% bin/rails db:migrate

 
この時点でDB上は外部キーの設定はできているものの、データを登録しようとしてもエラーになります。

>> fruit = Fruit.find(1)
  Fruit Load (0.3ms)  SELECT "fruits".* FROM "fruits" WHERE "fruits"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<Fruit:0x000000010f9e6b18 id: 1, name: "りんご", food_id: 1, created_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00, updated_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00>

>> cultivar = Cultivar.create(name: 'ふじ', fruit: fruit)
/path/to/rails_association_sample/gems/activemodel-7.0.2.3/lib/active_model/attribute.rb:211:in `with_value_from_database': 
    can't write unknown attribute `fruit_id` (ActiveModel::MissingAttributeError)

 

子モデルに belongs_to を追加

次に、子である Cultivar モデルにて、親である Fruit モデルに対する belongs_to を追加します。

class Cultivar < ApplicationRecord
  belongs_to :fruit
end

 
再度Railsコンソールで動作確認したところ、エラーは発生しなくなりました。

>> fruit = Fruit.find(1)
   (1.1ms)  SELECT sqlite_version(*)
  Fruit Load (0.2ms)  SELECT "fruits".* FROM "fruits" WHERE "fruits"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<Fruit:0x000000010772a210 id: 1, name: "りんご", food_id: 1, created_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00, updated_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00>

>> cultivar = Cultivar.create(name: 'ふじ', fruit: fruit)
  TRANSACTION (0.1ms)  begin transaction
  Cultivar Create (1.2ms)  INSERT INTO "cultivars" ("name", "created_at", "updated_at", "fruit_id") VALUES (?, ?, ?, ?)  [["name", "ふじ"], ["created_at", "2022-04-24 12:01:44.813548"], ["updated_at", "2022-04-24 12:01:44.813548"], ["fruit_id", 1]]
  TRANSACTION (1.9ms)  commit transaction
=> #<Cultivar:0x000000010779b708 id: 1, name: "ふじ", created_at: Sun, 24 Apr 2022 12:01:44.813548000 UTC +00:00, updated_at: Sun, 24 Apr 2022 12:01:44.813548000 UTC +00:00, fruit_id: 1>

 

NOT NULL制約のない関連付けを作成

ここまで見てきた関連付けの場合、デフォルトでは NOT NULL 制約が設定されています。

そこで、次は NULL を設定できるような関連付けを行ってみます。

 

新規作成するときに、NOT NULL 制約を外す

新規作成時の作業を確認するため、新しいモデルとして UserGroupMember の2つのモデルを用意します。

  • UserGroup : Member = 1 : n
  • Memberの外部キーは NULL を許可

とします。

ER図的にはこんな感じです。

 

ジェネレータでモデルとマイグレーションファイルを作成

# UserGroup分
% bin/rails g model UserGroup name:string

# Member分
% bin/rails g model Member name:string user_group:references

 

マイグレーションファイルを修正し、NOT NULL制約を外す

ジェネレータで生成した Memberマイグレーションファイルを開きます。 NOT NULL 制約を外すため、 null: true とします。

class CreateMembers < ActiveRecord::Migration[7.0]
  def change
    create_table :members do |t|
      t.string :name
      t.references :user_group, null: true, foreign_key: true

      t.timestamps
    end
  end
end

 
マイグレーションを実行します。

% bin/rails db:migrate

 

モデルの belongs_to の引数を修正

この時点でDB上のテーブルの外部キーは NOT NULL 制約が外れています。

ただ、この状態で NULL なデータを登録しようとしても、モデルのバリデーションでエラーになります。

>> m = Member.create!(name: 'Foo', user_group: nil)
   (1.9ms)  SELECT sqlite_version(*)
/path/to/rails_association_sample/gems/activerecord-7.0.2.3/lib/active_record/validations.rb:80:in `raise_validation_error': 
  Validation failed: User group must exist (ActiveRecord::RecordInvalid)

 
そのため、モデルの belongs_to を変更し、NULL設定も可能とします。

class Member < ApplicationRecord
  belongs_to :user_group, required: false
end

 
再度動作確認すると、NULLも設定できるようになっています。

>> m = Member.create!(name: 'Foo', user_group: nil)
   (1.2ms)  SELECT sqlite_version(*)
  TRANSACTION (0.1ms)  begin transaction
  Member Create (0.4ms)  INSERT INTO "members" ("name", "user_group_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "Foo"], ["user_group_id", nil], ["created_at", "2022-04-24 12:26:04.383204"], ["updated_at", "2022-04-24 12:26:04.383204"]]
  TRANSACTION (0.9ms)  commit transaction
=> #<Member:0x00000001099a0970 id: 2, name: "Foo", user_group_id: nil, created_at: Sun, 24 Apr 2022 12:26:04.383204000 UTC +00:00, updated_at: Sun, 24 Apr 2022 12:26:04.383204000 UTC +00:00>

 

既存のモデルに対し、NOT NULL制約を外す

次に、既存の Cultivar にある外部キーを nullable にしてみます。

コンソールでNULLにできないことを確認します。

>> c = Cultivar.create!(name: 'シナノゴールド', fruit: nil)
/path/to/rails_association_sample/gems/activerecord-7.0.2.3/lib/active_record/validations.rb:80:in `raise_validation_error':
  Validation failed: Fruit must exist (ActiveRecord::RecordInvalid)

 

マイグレーションファイルで change_column_null を使う

マイグレーションファイルを独自に作ります。

% bin/rails g migration ChangeForeignKeyToCultivars

 
空のマイグレーションファイルが生成されるため、 change_column_null を使って、NULL制約を変更します。
https://api.rubyonrails.org/v7.0/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-change_column_null

class ChangeForeignKeyToCultivars < ActiveRecord::Migration[7.0]
  def change
    change_column_null :cultivars, :fruit_id, true
  end
end

 
マイグレーションします。

% bin/rails db:migrate

 

モデルの belongs_to の引数を修正

新規作成と同様、モデルの引数に required: false を追加します。

class Cultivar < ApplicationRecord
  belongs_to :fruit, required: false
end

 
再度動作確認すると、外部キーにNULLを設定しても登録できました。

>> c = Cultivar.create!(name: 'シナノゴールド', fruit: nil)
   (1.0ms)  SELECT sqlite_version(*)
  TRANSACTION (0.1ms)  begin transaction
  Cultivar Create (0.4ms)  INSERT INTO "cultivars" ("name", "created_at", "updated_at", "fruit_id") VALUES (?, ?, ?, ?)  [["name", "シナノゴールド"], ["created_at", "2022-04-24 13:41:33.255011"], ["updated_at", "2022-04-24 13:41:33.255011"], ["fruit_id", nil]]
  TRANSACTION (1.0ms)  commit transaction
=> #<Cultivar:0x000000010abf0878 id: 2, name: "シナノゴールド", created_at: Sun, 24 Apr 2022 13:41:33.255011000 UTC +00:00, updated_at: Sun, 24 Apr 2022 13:41:33.255011000 UTC +00:00, fruit_id: nil>

 

関連付けに失敗する方法を確認

ここまでは関連付けに成功する方法を書いてきました。

そこで、次はうまくいかない関連付けのやり方を書いておきます。

 

子モデルにて、親モデルを複数形で指定する

Railsガイドには

belongs_to関連付けで指定するモデル名は必ず「単数形」にしなければなりません。上記の例で、Bookモデルのauthor関連付けを複数形(authors)にしてからBook.create(authors: @author)でインスタンスを作成しようとすると、uninitialized constant Book::Authorsエラーが発生します。Railsは、関連付けの名前から自動的にモデルのクラス名を推測します。従って、関連付け名が誤って複数形になってしまっていると、そこから推測されるクラス名も誤った形の複数形になってしまいます。

2.1 belongs_to 関連付け | Active Record の関連付け - Railsガイド

との記載があります。

 
そこで実際に試してみます。 FoodCultivar だけがある状態からはじめます。

# Food分を生成
% bin/rails g model Food name:string

# Cultivar分を生成
% bin/rails g model Cultivar name:string

 
次に、 Fruit のモデル・マイグレーションファイルを生成します。

% bin/rails g model Fruit name:string foods:references

 
マイグレーションを実行しても、特にエラーとはなりません。

% bin/rails db:migrate

 
生成されたモデルのスキーマを見ても、特に問題なさそうではあります。

# Table name: fruits
#
#  id              :integer          not null, primary key
# ...
#  foods_id   :integer          not null
# ...
# Foreign Keys
#
#  foods_id  (foods_id => foods.id)

 
しかし、Railsコンソールで動作確認すると、Railsガイドにあるようなエラーとなりました。やはり references に指定する親クラスは単数形が良さそうです。

>> food = Food.create(name: '果物')
...
>> fruit = Fruit.create(name: 'りんご', foods: food)
...
Rails couldn't find a valid model for Foods association. Please provide the :class_name option on the association declaration. 
If :class_name is already provided, make sure it's an ActiveRecord::Base subclass. (NameError)
...
uninitialized constant Fruit::Foods (NameError)

 

中間モデルにて一括で関連付けしてみる

Fruit を生成する際、 Food - FruitFruit - Cultivar の2つの関連付けができたら便利かもしれないと思い、試してみることにしました。

まずは FoodCultivar を生成します。

# Food分を生成
% bin/rails g model Food name:string

# Cultivar分を生成
% bin/rails g model Cultivar name:string

 
次に以下のような感じで関連付けをしてみます。

% bin/rails g model Fruit name:string food:references cultivars:references

 
マイグレーションしてみます。

% bin/rails db:migrate

 
しかし、モデルのスキーマを見ると、Fruit に cultivars_id が追加されてしまい、cultivarに向けて外部キーが設定されてしまっているようでした。

残念ながら一括ではできませんでした。

# == Schema Information
#
# Table name: fruits
#
#  id                  :integer          not null, primary key
# ...
#  cultivars_id :integer          not null
#  food_id      :integer          not null
# ...
# Foreign Keys
#
#  cultivars_id  (cultivars_id => cultivars.id)
#  food_id       (food_id => foods.id)

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_association-sample

Python + requests-oauthlib にて、OAuth2.0の認可コードグラントフローでアクセストークンを取得し、リソースサーバのAPIからデータを取得してみた

前回、Rails + Doorkeeperを使ってOAuth2.0の認可サーバを、Rails + OmniAuth を使ってクライアントを作ってみました。
Rails + Devise + OmniAuthにて、Deviseで認証を、OmniAuthでOAuth2.0の認可コードグラントフローの認可だけを扱えるか試してみた - メモ的な思考的な

ただ、せっかくなので、別の言語でOAuth2.0のクライアントを作ってみたくなりました。

 
PythonでOAuth2.0を扱えるパッケージがないかを探したところ、 requests と組み合わせて使える requests-oauthlib がありました。
requests/requests-oauthlib: OAuthlib support for Python-Requests!

そこで、Python + requests-oauthlib を使い、「前回作成した認可サーバ兼リソースサーバからデータを取得する」Pythonスクリプトを作成してみました。

 
目次

 

環境

  • OAuth2.0 クライアント
    • Python 3.10.4
    • requests 2.27.1
    • requests-oauthlib 1.3.1
    • python-dotenv 0.20.0
      • 認可サーバで管理しているクライアントIDとクライアントシークレットをハードコーディングしないようにするため
  • 認可サーバ兼リソースサーバ
    • 前回の記事のサーバを流用
      • rails 7.0.2.3
      • devise 4.8.1
      • doorkeeper 5.5.4

 

認可サーバに、今回使うOAuth Applicationを登録する

前回作成した認可サーバを起動し、以下のURLにアクセスします。
http://localhost:3801/oauth/applications/

以下の内容でOAuth Applicationを登録し、クライアントIDとクライアントシークレットを取得します。

項目
Name 任意(今回は oauthlib_app)
Redirect URI urn:ietf:wg:oauth:2.0:oob
Confidential チェックを入れる
Scopes read

 
なお、Redirect URIについてですが、前回のWebアプリとは異なり、今回のPythonスクリプトではコールバックURLを持っていません。

どうすればよいかを調べたところ、RFC8252 (OAuth 2.0 for Native Apps) の 7.1. Private-Use URI Scheme Redirection を使えば良さそうに見えました。

一方、DoorkeeperのWikiによると、デフォルトでは urn:ietf:wg:oauth:2.0:oob の値を入れることで、ブラウザ上に認可コードを表示させることができるようです。
Authorization Code Flow · doorkeeper-gem/doorkeeper Wiki

そこで、今回はDoorkeeperの仕様に従い、 urn:ietf:wg:oauth:2.0:oob を Redirect URI に設定しています。

 

以上で、認可サーバ側の設定は完了です。

 

実装

認可コードグラントフローを使ってアクセストークンを取得する

requests-oauthlibのドキュメントに従い、実装してみます。
https://requests-oauthlib.readthedocs.io/en/latest/oauth2_workflow.html

なお、ドキュメントの該当ページはPython2な書き方に見えるので、今回はPython3な書き方で実装します。

 

requests-oauthlibでHTTP通信を許可する

OAuth2.0はHTTPS(TLS/SSL)通信を前提にしているため、今回のlocalhostにある認可サーバとの間でHTTP通信をすると、 requests-oauthlib でエラーになってしまいます。

そのため、環境変数 OAUTHLIB_INSECURE_TRANSPORT'1' を設定しておきます。

os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'

 

認可コードの取得

認可コードは

  1. requests-oauthlibOAuth2Session インスタンスを生成する
  2. 生成したインスタンスauthorization_url() を使い、認可エンドポイントのURLを取得する
  3. 取得したURLをブラウザに入力し、認可サーバにログインすることで、認可コードを取得する

という流れで取得します。

 
そこで、まずは OAuth2Sessionのインスタンスを生成します。

生成するときには

  • client_id
  • scope
  • redirect_uri

を指定します。

import os
from dotenv import load_dotenv
from requests_oauthlib import OAuth2Session

load_dotenv()
CLIENT_ID = os.environ.get('CLIENT_ID')
REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
REQUEST_SCOPE = 'read'

session = OAuth2Session(
    client_id=CLIENT_ID,
    scope=REQUEST_SCOPE,
    redirect_uri=REDIRECT_URI
)

 
続いて authorization_url() メソッドを使い、認可エンドポイントのURLを取得します。

AUTHORIZATION_SERVER_BASE_URL = 'http://localhost:3801'
AUTHORIZE_URL = f'{AUTHORIZATION_SERVER_BASE_URL}/oauth/authorize'

authorization_url, state = session.authorization_url(AUTHORIZE_URL)

print('URLをブラウザにコピペし、認可コードを取得してください:', authorization_url)
code = input('表示されている認可コードをコピペしてください: ')

 
ここまでを実行すると、

URLをブラウザにコピペし、認可コードを取得してください: http://localhost:3801/oauth/authorize?response_type=code&client_id=***&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&scope=read&state=***
表示されている認可コードをコピペしてください:

と認可エンドポイントのURLが表示されるとともに、認可コードの入力を待ち受けます。

 
認可エンドポイントのURLをブラウザにペーストして認可サーバにログイン・アプリケーションに対する認可処理を実行すると、認可コードがブラウザ上に表示されます。

 

トークン類の取得

アクセストークンを始めとするトークン類は、 fetch_token() メソッドで取得します。

CLIENT_SECRET = os.environ.get('CLIENT_SECRET')
TOKEN_URL = f'{AUTHORIZATION_SERVER_BASE_URL}/oauth/token'

token = session.fetch_token(
    TOKEN_URL,
    client_secret=CLIENT_SECRET,
    code=code,
)

 
なお、今回の fetch_token() メソッドの今回の戻り値 token の中身には

{
    "access_token": "***",
    "token_type": "Bearer",
    "expires_in": 60,
    "refresh_token": "***",
    "scope": [
        "read"
    ],
    "created_at": 1648867286,
    "expires_at": 1648867346.5057201
}

のように、アクセストークンやリフレッシュトークンが含まれています。

 

アクセストークンを使い、リソースサーバのAPIからデータを取得

アクセストークンが取得できたため、次はリソースサーバのAPIからデータを取得します。

また、 requests-oauthlib ではアクセストークンの有効期限が切れた場合は自動で更新する機能が備わっているため、今回はそれも使います。
https://requests-oauthlib.readthedocs.io/en/latest/oauth2_workflow.html#third-recommended-define-automatic-token-refresh-and-update

自動で更新するには OAuth2Sessionインスタンスを生成する際の引数として

  • auto_refresh_url
    • トークンエンドポイントのURLを指定
  • auto_refresh_kwargs
    • dictとして、クライアントIDとクライアントシークレットを指定
  • token_updater
    • アクセストークンを更新したときに実行されるコールバック関数を指定
      • コールバック関数では、更新後のアクセストークンを保管することを想定

を渡します。

def save_token(token):
  with open(OAUTH_TOKEN_FILE_PATH, 'wb') as f:
    pickle.dump(token, f, protocol=5)
    print('===== tokenを保存しました =====')
      
session = OAuth2Session(
    CLIENT_ID,
    redirect_uri=REDIRECT_URI,
    token=token,
    scope=REQUEST_SCOPE,
    # アクセストークンが有効期限切れの場合、リフレッシュトークンを使って自動更新する
    auto_refresh_url=TOKEN_URL,
    auto_refresh_kwargs={
        'client_id': CLIENT_ID,
        'client_secret': CLIENT_SECRET,
    },
    token_updater=save_token,
)

response = session.get('http://localhost:3801/api/memos/').json()

 

ソースコード全体

以上で個別の実装が終わりましたので、それらを組み合わせてみます。

import os
import pathlib
import pickle

from dotenv import load_dotenv
from requests_oauthlib import OAuth2Session

load_dotenv()

CLIENT_ID = os.environ.get('CLIENT_ID')
CLIENT_SECRET = os.environ.get('CLIENT_SECRET')

REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
REQUEST_SCOPE = 'read'

AUTHORIZATION_SERVER_BASE_URL = 'http://localhost:3801'
AUTHORIZE_URL = f'{AUTHORIZATION_SERVER_BASE_URL}/oauth/authorize'
TOKEN_URL = f'{AUTHORIZATION_SERVER_BASE_URL}/oauth/token'

BASE_DIR = pathlib.Path(__file__).resolve().parent
OAUTH_TOKEN_FILE_PATH = f'{BASE_DIR}/oauth_token.pickle'

# 本来、OAuth2.0はhttps通信が必要
# ただ、今回はlocalhostのASなため、http通信可能にする設定を行っておく
# https://requests-oauthlib.readthedocs.io/en/latest/examples/real_world_example.html
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'


def save_token(token):
    with open(OAUTH_TOKEN_FILE_PATH, 'wb') as f:
        pickle.dump(token, f, protocol=5)
        print('===== tokenを保存しました =====')


def fetch_token():
    # すでにトークン類を取得済の場合は、そのトークン類を使う
    if pathlib.Path(OAUTH_TOKEN_FILE_PATH).is_file():
        with open(OAUTH_TOKEN_FILE_PATH, 'rb') as f:
            return pickle.load(f)

    # トークン類がない場合は、認可コードグラントフローにより取得
    session = OAuth2Session(
        client_id=CLIENT_ID,
        scope=REQUEST_SCOPE,
        redirect_uri=REDIRECT_URI
    )

    authorization_url, state = session.authorization_url(AUTHORIZE_URL)

    print('URLをブラウザにコピペし、認可コードを取得してください:', authorization_url)
    code = input('表示されている認可コードをコピペしてください: ')

    token = session.fetch_token(
        TOKEN_URL,
        client_secret=CLIENT_SECRET,
        code=code,
    )

    return token


def fetch_resource_server(token):
    session = OAuth2Session(
        CLIENT_ID,
        redirect_uri=REDIRECT_URI,
        token=token,
        scope=REQUEST_SCOPE,
        # アクセストークンが有効期限切れの場合、リフレッシュトークンを使って自動更新する
        auto_refresh_url=TOKEN_URL,
        auto_refresh_kwargs={
            'client_id': CLIENT_ID,
            'client_secret': CLIENT_SECRET,
        },
        token_updater=save_token,
    )

    return session.get('http://localhost:3801/api/memos/').json()


def main():
    # トークン類の取得
    token = fetch_token()

    # もしトークン類を保存していない場合、保存しておく
    if not pathlib.Path(OAUTH_TOKEN_FILE_PATH).is_file():
        save_token(token)

    # リソースサーバよりデータを取得
    response_body = fetch_resource_server(token)
    print(response_body)


if __name__ == '__main__':
    main()

 
なお、上記のソースコードを動かすためには、 .env ファイルをPythonスクリプトと同じディレクトリに保存しておきます。

中身はこんな感じで、環境に合わせて値を指定します。

CLIENT_ID=
CLIENT_SECRET=

 

動作確認

Pythonスクリプトを実行して動作を確認してみます。

 

初回実行時

URLをブラウザにコピペし、認可コードを取得してください: http://localhost:3801/oauth/authorize?response_type=code&***
表示されている認可コードをコピペしてください: ***
===== tokenを保存しました =====
{'message': 'ふじ'}

 

アクセストークンが有効な間

{'message': 'ふじ'}

 

トークンの有効期限が切れた時

===== tokenを保存しました =====
{'message': 'ふじ'}

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/requests-oauthlib-sample

Rails + Devise + OmniAuthにて、Deviseで認証を、OmniAuthでOAuth2.0の認可コードグラントフローの認可だけを扱えるか試してみた

RubyOmniAuth gemの用途としてREADMEには

OmniAuth is a library that standardizes multi-provider authentication for web applications. It was created to be powerful, flexible, and do as little as possible. Any developer can create strategies for OmniAuth that can authenticate users via disparate systems. OmniAuth strategies have been created for everything from Facebook to LDAP.

https://github.com/omniauth/omniauth

と書かれています。

 
とはいえ、READMEにあるような認証用途だけではなく、OAuth2.0の認可コードグラントフローをかんたんに扱うためのgemとしても使えそうに感じました。

そこで、「Rails + Devise + OmniAuthにて、Deviseで認証を、OmniAuthでOAuth2.0の認可コードグラントフローの認可だけを扱えるか」試してみたくなったため、調べてみたときのメモを残します。

 
目次

 

今回のシナリオ

OAuth2.0の クライアント (以降、Client)と 認可サーバ兼リソースサーバ (以降、AS兼RS)の計2つのRailsアプリを作成します。

作成した2つのRailsアプリを使って、以下のような流れが実現できるかを確認します。

  1. ClientがDeviseを使い、ユーザーがemailを使ってClientアプリにログイン
  2. ClientがOmniAuthを使い、OAuth2.0の認可コードグラントフローを開始
  3. AS兼RSがDeviseを使い、ユーザーがemailを使ってAS兼RSアプリにログイン
  4. AS兼RSがDoorkeeperを使い、OAuth2.0の認可コードグラントフローに従ってClientへアクセストークンを返却
  5. ClientがFaraday + アクセストークンを使い、AS兼RSのデータ取得用APIからデータを取得

また、「もし5.の時点でアクセストークンの有効期限切れの場合は、アクセストークンを更新してからデータを取得する」みたいなことも試してみます。

 

やらないこと

  • 本番運用で耐えられるような、細かなバリデーションやユースケース
    • 「リフレッシュトークンまわりの挙動」など、今回検証したいことから外れていることは深堀りしない
    • そもそも調査用のコードを入れてるので、そのまま本番運用に持っていくことは想定していない
  • Deviseでのログイン以外の機能を使うこと
    • パスワードリセットやメールアドレス変更は実装しない
  • Clientでアクセストークンの有効期限の事前チェック
    • 今回は、AS兼RSにアクセストークンを投げることで、有効/無効を確認している

 

環境

  • AS兼RSアプリ
    • rails 7.0.2.3
    • devise 4.8.1
    • doorkeeper 5.5.4
  • Clientアプリ
    • rails 7.0.2.3
    • devise 4.8.1
    • omniauth 2.0.4
    • omniauth-oauth2 1.7.2
    • omniauth-rails_csrf_protection 1.0.1
    • oauth2 1.4.9
    • faraday 2.2.0

 

AS兼RSアプリの実装

今回は、以前作ったOIDCのOPと同じようなDevise + Doorkeeperの設定とします。
Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた - メモ的な思考的な

 

初期セットアップ

rails new します。

% bundle exec rails new oauth_as_rs --minimal 

 
必要なgemをGemfileに追加します。

gem 'devise'
gem 'doorkeeper'

group :development do
  gem 'annotate'
end

 
インストールします。

 % bundle install

 
annotate をセットアップします。

% bin/rails g annotate:install
      create  lib/tasks/auto_annotate_models.rake

 
Railsの起動ポートを修正します。

# config/puma.rb

port ENV.fetch("PORT") { 3801 }

 

Deviseのセットアップ

インストールします。

% bin/rails g devise:install
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml

 
次にログインユーザーを管理するモデルの作成です。

OIDCの時は user だったため、今回はDeviseのREADMEにある通り member を使ってみます。

% bin/rails g devise member 
      invoke  active_record
      create    db/migrate/20220329113642_devise_create_members.rb
      create    app/models/member.rb
      invoke    test_unit
      create      test/models/member_test.rb
      create      test/fixtures/members.yml
      insert    app/models/member.rb
       route  devise_for :members

 
AS兼RSアプリで使うDeviseの機能は

  • ユーザー登録
  • ログイン

だけなため、自動生成されたマイグレーションファイル db/migrate/<TIMESTAMP>_devise_create_members.rb を手動で修正します。

# frozen_string_literal: true

class DeviseCreateMembers < ActiveRecord::Migration[7.0]
  def change
    create_table :members do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""
      
      t.timestamps null: false
    end

    add_index :members, :email,                unique: true
  end
end

 
合わせて、 app/models/member.rb のMemberモデルにあるDeviseの機能も最低限にします。

class Member < ApplicationRecord
  devise :database_authenticatable, :registerable, :validatable
end

 

doorkeeperのセットアップ

公式ドキュメントに従いセットアップします。
https://doorkeeper.gitbook.io/guides/ruby-on-rails/getting-started

 

install

% bin/rails generate doorkeeper:install
      create  config/initializers/doorkeeper.rb
      create  config/locales/doorkeeper.en.yml
       route  use_doorkeeper

 

マイグレーションファイルを作成・調整

% bin/rails generate doorkeeper:migration
      create  db/migrate/20220329114138_create_doorkeeper_tables.rb

 
作成したdoorkeeper用のマイグレーションファイルの末尾にある以下について、アンコメントした後にDeviseの認証で使用しているテーブル(members)を指定します。

# Uncomment below to ensure a valid reference to the resource owner's table
add_foreign_key :oauth_access_grants, :members, column: :resource_owner_id
add_foreign_key :oauth_access_tokens, :members, column: :resource_owner_id

 

doorkeeperの設定変更

config/initializer/doorkeeper.rb に対して設定変更を行います。

有効な内容は以下の通りとします。

# frozen_string_literal: true

Doorkeeper.configure do
  orm :active_record
  
  resource_owner_authenticator do
    # ASでの認証にdeviseを使っているため、doorkeeperのドキュメントに従って設定
    # https://doorkeeper.gitbook.io/guides/ruby-on-rails/configuration
    current_member || warden.authenticate!(scope: :member)
  end

  # ASのユーザーは、全員OAuth Applicationを操作可能とする
  admin_authenticator do
    current_member || warden.authenticate!(scope: :member)
  end

  # 認可コードは30秒だけ有効
  authorization_code_expires_in 30.seconds

  # アクセストークンは1分だけ有効
  access_token_expires_in 1.minute

  # リフレッシュトークンを発行するため、古いアクセストークンは無効にする
  revoke_previous_client_credentials_token

  # アクセストークンに加え、リフレッシュトークンも発行する
  use_refresh_token

  # 今回はOAuth2.0のためscopeは任意の値で良いので適当に `read` とする
  default_scopes  :read

  # 今回は認可コードグラントフローだけ使う
  # なお、doorkeeperデフォルトは認可コードグラントフローとクライアントクレデンシャルズグラントフロー
  grant_flows %w[authorization_code]
end

 

AS兼RSのデータ取得用API向けのモデルを作成

モデル member に対して外部キーを持つモデル memo を生成します。

memo モデルはデータ取得用APIで使います。

% bin/rails generate model memo favorite:string member:references
      invoke  active_record
      create    db/migrate/20220329121510_create_memos.rb
      create    app/models/memo.rb
      invoke    test_unit
      create      test/models/memo_test.rb
      create      test/fixtures/memos.yml

 
念のため、 app/models/memo.rb を確認しますが、 member への belongs_to が自動で設定されています。

class Memo < ApplicationRecord
  belongs_to :member
end

 

AS兼RSのデータ取得用APIの作成

コントローラを作成

app/controllers/api/memos_controller.rb としてコントローラを作成します。

このコントローラでは、Doorkeeperのドキュメントに従い

  • アクセストークンがOKであること
    • before_action :doorkeeper_authorize!
  • アクセストークンを元に member からアクセスしてきたユーザーを特定すること
    • @member ||= Member.find(doorkeeper_token.resource_owner_id) if doorkeeper_token

を実装します。
Securing the API - doorkeeper

class Api::MemosController < ApplicationController
  before_action :doorkeeper_authorize!

  def index
    unless current_resource_owner
      return render json: {message: 'deny'}
    end

    memo = Memo.find_by(id: current_resource_owner.id)

    render json: {message: memo ? memo.favorite : 'not found'}
  end

  private def current_resource_owner
    @member ||= Member.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
  end
end

 

ルーティングの作成

API用のルーティングを config/routes.rb に追加します。

また、 DoorkeeperやDeviseのroutesも存在するか確認しておきます。

Rails.application.routes.draw do
  use_doorkeeper
  devise_for :members

  namespace :api do
    resources :memos, only: [:index]
  end
end

 

データの準備

マイグレーションの実行

% bin/rails db:migrate

 

seedの作成と実行

AS兼RSで使う member および memo 用のseedを作成します。

member_foo = Member.new(email: 'foo@example.com', password: 'password')
member_foo.save!

member_bar = Member.new(email: 'bar@example.com', password: 'password')
member_bar.save!

Memo.create(favorite: 'ふじ', member: member_foo)
Memo.create(favorite: 'シナノゴールド', member: member_bar)

 
実行します。

% bin/rails db:seed

 
これでAS兼RSアプリの準備は完了です。

 

Clientアプリの作成

初期セットアップ

rails new します。

% bundle exec rails new oauth_client --minimal

 
必要なものをGemfileに追加

# 追加
# omniauthまわり
gem 'omniauth'
gem 'omniauth-oauth2'

# OmniAuthのWikiにある通り、CSRF対策として追加
gem 'omniauth-rails_csrf_protection'

# リソースサーバーからデータを取得するために使用
# oauth2 gemの依存として使われているが、明示的に記載
gem 'faraday'

# OPからもらった client_id と client_secret を .env ファイルから読み込むために追加
gem 'dotenv-rails', groups: [:development, :test]

# modelにスキーマコメントを追加
group :development do
  gem 'annotate'
end

 
gemをインストールします。

% bundle install

 

セッションストアをActiveRecordにする

OIDCの時同様、 activerecord-session_store でセッションストアをActive Record にしたため、READMEに従い設定を追加します。 https://github.com/rails/activerecord-session_store

 
マイグレーションファイルを作成します。

% bin/rails generate active_record:session_migration

 
config/initializer/session_store.rb を作成して設定を行います。

# ActiveRecordにセッションを保存
Rails.application.config.session_store :active_record_store, :key => '_my_app_session'

 
セッションストアの中身を分かりやすくするよう、JSON形式でデータを保存するようにします。

config/application.rb の末尾に以下を追加します。

# 中身を見やすくするよう、JSON形式で保存
ActiveRecord::SessionStore::Session.serializer = :json

 

起動ポートの修正

クライアントは 3802 で起動します。

# config/puma.rb

port ENV.fetch("PORT") { 3802 }

 

OmniAuthのストラテジーを追加

OmniAuth OAuth2のREADMEに従い、ファイル lib/omniauth/strategies/my_as.rbOmniAuth::Strategies::OAuth2 を継承したストラテジーを作成します。
https://github.com/omniauth/omniauth-oauth2

このストラテジーでは、AS兼RSの情報を記載します。

なお、client_optionsの authorize_urltoken_url はDoorkeeperで自動生成される各エンドポイントを指定します。

require 'omniauth-oauth2'

module OmniAuth
  module Strategies
    class MyAs < OmniAuth::Strategies::OAuth2
      option :name, 'my_as'

      option :client_options, {
        site: 'http://localhost:3801',
        authorize_url: '/oauth/authorize',  # 認可エンドポイント
        token_url: '/oauth/token'  # トークンエンドポイント
      }

      # scope=read としてリクエスト
      option :scope, 'read'
    end
  end
end

 

OmniAuthのinitializerを追加

OmniAuthのREADMEにある Getting Started に従い、 config/initializers/omniauth.rb を作成します。
https://github.com/omniauth/omniauth#getting-started

この initializer ではClientアプリの情報を設定します。

require 'omniauth/strategies/my_as'

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :my_as,
           ENV['CLIENT_ID'],
           ENV['CLIENT_SECRET'],
           request_path: '/auth/my_as',
           callback_path: '/oauth_callbacks'
end

 
なお、 request_pathcallback_path の違いは以下のとおりです。

  • request_path
    • 認可コードグラントフローを開始するためのパス
    • OmniAuthでハンドリングしてくれるため、 config/routes.rb に記載する必要はない
  • callback_path
    • 認可コードグラントフローのリダイレクトエンドポイント
    • OmniAuthでハンドリングしてくれないため、 config/routes.rb に記載する必要がある

 

Deviseのセットアップ

Clientでもログインが必要になるため、AS兼RSと同様なセットアップを行います。

インストールします。

% bin/rails g devise:install
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml

 
Clientで管理するモデルを作成します。

ASとは別のモデル User にしてみます

% bin/rails g devise user 

 
マイグレーションもログインできるだけにします。

# db/migrate/<TIMESTAMP>_devise_create_users.rb

# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      t.timestamps null: false
    end

    add_index :users, :email,                unique: true
  end
end

 
Userモデルも修正します。

# app/models/user.rb 

class User < ApplicationRecord
  devise :database_authenticatable, :registerable, :validatable
end

 

AS兼RSから渡されたアクセストークンなどを保存するモデルを作成

認可コードグラントフローが終わると、AS兼RSから

などが渡されます。

アクセストークンなどは再利用したいため、今回はDBに保存することにします。

% bin/rails generate model authz access_token:string refresh_token:string expires_at:integer user:references

 

リダイレクトエンドポイント用のコントローラを作成

リダイレクトエンドポイントで何をするかはClientアプリで実装する必要があります。

app/controllers/oauth_callbacks_controller.rb として作成し、クレデンシャル情報をモデルに保存する処理を実装します。

class OauthCallbacksController < ApplicationController
  def index
    # AS兼RSアプリから受け取ったクレデンシャル情報を取得
    credentials = request.env['omniauth.auth'].credentials

    # クレデンシャル情報をモデルに保存
    authz = Authz.find_or_initialize_by(id: current_user.id) do |a|
      a.user = current_user
    end

    authz.update(
      access_token: credentials.token,
      refresh_token: credentials.refresh_token,
      expires_at: credentials.expires_at
    )

    # 処理が終わったらルートにリダイレクト
    redirect_to root_path
  end
end

 

AS兼RSアプリから取得した情報を表示するコントローラとビューを作成

今回は

  • ログインしているかどうか
  • AS兼RSアプリで認可処理をしているかどうか
  • AS兼RSアプリからデータを取得できているかどうか

を確認できるようなコントローラとビューを作成します。

 

コントローラの作成

app/controllers/homes_controller.rb として作成します。

なお、実装するときにいくつか調べたことをメモします。

 

Faradayでアクセストークンをリクエストに乗せる方法について

まず、OmniAuthではアクセストークンの取得までしか行わないため、アクセストークンを使ってデータを取得するところは自分で実装する必要があります。

そこで今回はFaraday + アクセストークンを使ってデータを取得するよう実装します。

なお、Faraday 1.x系の場合は、別途 faraday_middleware というgemを追加して、アクセストークンをリクエストに乗せてあげる必要がありました。
faraday_middleware/oauth.md at main · lostisland/faraday_middleware

ただ、Faraday 2系からはFaradayだけでアクセストークンをリクエストに乗せることができるようになりました。
Authentication Middleware | Faraday

private def fetch_resource_server
  conn = Faraday.new do |conn|
    conn.request :authorization, 'Bearer', @access_token
  end

  conn.get('http://localhost:3801/api/memos/')
end

 

リフレッシュトークンを使ってアクセストークンを更新する方法について

上記の通り、Faradayではアクセストークンをリクエストに乗せることはできます。

一方、リフレッシュトークンを使ってアクセストークンを更新することはできないようです。

調べてみたところ、 omniauth-oauth2 gemが依存している oauth2 gem を使えば良さそうでした。

 
そこで、以下のようなprivateメソッドを用意して、アクセストークンを更新するようにしてみました。

private def renew_access_token(authz)
  strategy = OmniAuth::Strategies::MyAs.new(
    nil,
    ENV['CLIENT_ID'],
    ENV['CLIENT_SECRET'],
    )
  client = strategy.client

  token = OAuth2::AccessToken.new(
    client,
    authz.access_token,
    { refresh_token: authz.refresh_token }
  )

  new_credentials = token.refresh!

  authz.update(
    access_token: new_credentials.token,
    refresh_token: new_credentials.refresh_token,
    expires_at: new_credentials.expires_at
  )

  @is_renew = true

  new_credentials.token
end

 

コントローラ全体の実装

エラーハンドリングまわりが甘いですが、今回は試すだけなので気にしないこととします。

class HomesController < ApplicationController
  before_action :authenticate_user!

  def index
    # アクセストークンを取得する
    authz = Authz.find_by(id: current_user.id)

    @access_token = authz.present? ? authz.access_token : nil
    @memos = nil
    @is_renew = false

    if @access_token.present?
      response = fetch_resource_server

      case response.status
      when 200
        @memos = parse_response_body(response.body)

      when 401
        # アクセストークンが不正の場合、リフレッシュトークンを使って新しいアクセストークンを取得する
        @access_token = renew_access_token(authz)

        new_response = fetch_resource_server
        # アクセストークンをリフレッシュしたとしてもエラーになる可能性はあるが、今回は気にしない
        @memos = parse_response_body(new_response.body)

      else
        # TODO: 本番運用する時は真剣に考えること
        # エラーっぽいので、エラーメッセージを入れておく
        @memos = 'エラーです'
      end
    end
  end

  private def fetch_resource_server
    conn = Faraday.new do |conn|
      conn.request :authorization, 'Bearer', @access_token
    end

    conn.get('http://localhost:3801/api/memos/')
  end

  private def parse_response_body(response_body)
    JSON.parse(response_body)['message']
  end

  private def renew_access_token(authz)
    strategy = OmniAuth::Strategies::MyAs.new(
      nil,
      ENV['CLIENT_ID'],
      ENV['CLIENT_SECRET'],
      )
    client = strategy.client

    token = OAuth2::AccessToken.new(
      client,
      authz.access_token,
      { refresh_token: authz.refresh_token }
    )

    new_credentials = token.refresh!

    authz.update(
      access_token: new_credentials.token,
      refresh_token: new_credentials.refresh_token,
      expires_at: new_credentials.expires_at
    )

    @is_renew = true

    new_credentials.token
  end
end

 

ビューの作成

app/views/homes/index.html.erb として作成します。

なお、認可処理を開始するためのフォームは、OmniAuthのWikiに従い methodPOST にしています。
Resolving CVE 2015 9284 · omniauth/omniauth Wiki

<h1>Homes#index</h1>

<% if user_signed_in? %>
  <p><%= current_user.email %>でログインしています</p>

  <% if @access_token %>
    <p>初回の認可処理も実施済です</p>

    <% if @is_renew %>
      <p>アクセストークンの更新を行いました</p>
    <% end %>
  <% else %>
    <p>認可処理がされていないので、以下のボタンを押して認可処理を行ってください</p>
    <%= form_tag('/auth/my_as', method: 'post') do %>
      <button type='submit'>認可処理をする</button>
    <% end %>
  <% end %>

<% else %>
  <a href="<%= new_user_session_path %>">ログインする</a>
<% end %>

<hr>
<p>memo: <%= @memos %></p>

 

ルーティングの作成

AS兼RSアプリから取得した情報を表示するのは root_path とするため、上記で作成したコントローラとメソッドを指定します。

また、OAuthのリダイレクトエンドポイント用のルートも用意します。

Rails.application.routes.draw do
  devise_for :users
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  root 'homes#index'

  # OAuth2.0のリダイレクトエンドポイント用
  resources :oauth_callbacks, only: [:index]
end

 

データの準備

マイグレーションの実行

% bin/rails db:migrate

seedの作成と実行

SeedではClientアプリにログインするユーザーを生成します。

なお、AS兼RSでのログイン特別するため、パスワードは別にしています。

user_foo = User.new(email: 'foo@example.com', password: 'client')
user_foo.save!

user_bar = User.new(email: 'bar@example.com', password: 'client')
user_bar.save!

 
seedを実行します。

% bin/rails db:seed 

 
以上でClientアプリも準備ができました。

 

動作確認

AS兼RSアプリとClientアプリを起動し、動作を確認します。

 

AS兼RSアプリにて、OAuthアプリケーションの登録

以下のURLにアクセスし、AS兼RSアプリにClientアプリの情報を登録します。
http://localhost:3801/oauth/applications

 
登録する内容は以下のとおりです。

項目
Name 任意( my_as など)
Redirect URI http://localhost:3802/oauth_callbacks
Confidential チェックを入れる
Scopes read

 
登録後、クライアントIDとクライアントシークレットが表示されます。

 

Clientアプリの .env ファイルに、クライアントIDとクライアントシークレットを記載

Clientアプリのプロジェクトルートに .env ファイルを用意して、それぞれ記載します。

CLIENT_ID=***
CLIENT_SECRET=***

 

全体の動作確認

Clientアプリのルートにアクセスします。
http://localhost:3802/

すると、ログインが必須なため、ログイン画面が表示されます。

 
ログインすると、認可処理が必要な旨が表示されており、データは表示されていません。そこで、 認可処理をする ボタンをクリックします。

 
すると、AS兼RSアプリのログイン画面に遷移しますので、こちらでもログインします。

 
次は「認可してもよいか」が表示されるため、 Authorize をクリックします。

 
すると Clientアプリに戻り、AS兼RSのデータ取得APIから取得したデータが表示されています。

 
アクセストークンの有効期限は1分ですので、1分以上経過後にリロードすると、アクセストークンを更新した旨も表示されます。

 
以上より、Rails + Devise + OmniAuthにて、Deviseで認証を、OmniAuthでOAuth2.0の認可コードグラントフローの認可だけを扱えました。

 

参考

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_omniauth_without_login-sample

Raspberry Pi 2 + PaSoRi RC-S380 + nfcpyにて、FeliCa読み取り時にPowerOffし、Slackへ通知してみた

以前、Raspberry Pi 2 + パソリ RC-S320 + libpafeにて、FeliCa読み取り時にPowerOffし、Slackへ通知したことがありました。

その後、

などがあったため、 nfcpy を使い、同じことを実現したくなりました。

ただ、いくつかハマったところがあったため、メモを残します。

 
目次

環境

  • Raspberry Pi 2 Model B
  • Raspberry Pi OS 32-bit
    • Released 2022-01-28
    • OSインストール先のmicro SD カード
      • KIOXIA KMSDER45N016G
      • 前回と異なり、bootとrootの両方ともmicroSDに入れている
  • PaSoRi RC-S380
  • 任意のFeliCa (Suica/KURURUなど)
  • systemd 247 (247.3-6+rpi1)
    • systemctl --version にて確認
  • Python 3.9.2
  • nfcpy 1.0.4
  • slack_sdk 3.15.2
  • PyCharm Professional Edition

 
なお、以前の記事とは異なり、今回はSlack BotにてSlackへ通知します。

そのため、今回の記事を試す場合の前提として、

  • Bot token を持つアプリを作成
  • アプリの権限に chat:write を設定

というSlack appを用意してあるものとします。
Basic app setup | Slack

 

ラズパイのセットアップ

macにて、Raspberry Pi Imager にてOSをmicroSDに書き込む

最近のラズパイでは Raspberry Pi Imager を使ってOSをmicroSDに書き込むのが良さそうです。
https://www.raspberrypi.com/software/

今回はOSとして、 Raspberry Pi OS (32-bit) 、バージョンは Released 2022-01-28 を使用します。

なお、 Raspberry Pi Imager 実行後はmacからmicroSDが取り外されてしまいます。

ただ、次の作業を行うため、microSDを抜き差しして、macmicroSDを認識させておきます。

Finderの 場所boot と表示されていればOKです。

 

macにて、SSHを可能にするファイルを作成する

以下のコマンドでSSHを可能にするためのファイルを作成します。

% touch /Volumes/boot/ssh

 
これでmac上でのmicroSDに対する作業は完了したため、macからmicroSDを抜きます。

 

macからラズパイへSSH接続

microSDをラズパイに挿入してから電源を入れ、まずはパスワード認証でSSHできることを確認します。

% ssh pi@raspberrypi.local
The authenticity of host 'raspberrypi.local (***:***:***:***:***:***:***:***)' can't be established.
ECDSA key fingerprint is SHA256:***.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'raspberrypi.local,***:***:***:***:***:***:***:***' (ECDSA) to the list of known hosts.
pi@raspberrypi.local's password: 
Linux raspberrypi 5.10.92-v7+ #1514 SMP Mon Jan 17 17:36:39 GMT 2022 armv7l
...
pi@raspberrypi:~ $

 

ラズパイのログインを公開鍵認証でも可能にする

毎回パスワード認証するのは手間なので、公開鍵認証でもログインできるように設定します。

セキュリティなどを考えると公開鍵認証のみに絞ることも考えられますが、今回は試すだけなので併用する形でOKとしておきます。

 

macにて、SSH接続用のSSH鍵を生成する

macのターミナルでSSH鍵を生成します。今回はパスフレースなしでOKとします。

% ssh-keygen -t rsa -b 4096 -f ~/.ssh/pi_felica_rsa

Enter passphrase (empty for no passphrase): <そのままEnter(パスフレースなし)>

 

SSHの公開鍵をラズパイに登録

ssh-copy-id を使って、macからラズパイへ登録します。

% ssh-copy-id -i ~/.ssh/pi_felica_rsa.pub pi@raspberrypi.local

/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "~/.ssh/pi_felica_rsa.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
pi@raspberrypi.local's password: 

Number of key(s) added:        1

Now try logging into the machine, with:   "ssh 'pi@raspberrypi.local'"
and check to make sure that only the key(s) you wanted were added.

 

公開鍵でラズパイへSSHする

以下でログインできればOKです。

ssh -i ~/.ssh/pi_felica_rsa pi@raspberrypi.local

 

vimを入れる

インストールされているのは vim-tiny なため、ふつうのvimに差し替えます。 RaspberryPi3のセットアップ続き〜VimやNFS設定 - Qiita

# 確認
$ dpkg -l | grep vim
ii  vim-common  2:8.2.2434-3+deb11u1
ii  vim-tiny    2:8.2.2434-3+deb11u1

# アンインストール
$ sudo apt-get --purge remove vim-common vim-tiny

# インストール
$ sudo apt-get install vim

 

ラズパイを固定IP化する

以前の通り、ラズパイを固定IP化します。
Python2 + Scapyで、Raspberry Pi 2 Model B をブリッジにできるか試してみた #router_jisaku - メモ的な思考的な

# 対象のファイルを開く
$ vi /etc/dhcpcd.conf


# 以下を末尾に追加
interface eth0
static ip_address=192.168.0.52/24
static routers=192.168.0.1
static domain_name_servers=192.168.0.1


# 有効化
$ sudo service dhcpcd reload
sending signal HUP to pid 393

 
しばらく待つと、ラズパイでの入力受け付けるようになり、固定IP化されます。

 

Pythonスクリプトの作成

今回の開発方法について

今回は、PyCharmのremote sdk機能を使い

という形で開発してみます。

 

ラズパイのPythonバージョンを確認

以前は2系でしたが、現在は3.9系でした。

# ラズパイ上で確認
$ python --version
Python 3.9.2

 

ラズパイのプロジェクトディレクトリを作成

今回使うPythonスクリプトは以下のディレクトリに入れるため、作成しておきます。

/home/pi/projects/rpi_felica_poweroff_with_nfcpy

 

PyCharmの SSH Interpreter まわりの設定を追加し、動作確認

今回、PyCharmmの SSH Interpreter 機能を使って、PyCharmでラズパイ上のファイルを動かしながら開発を行います。

まずはSSH Interpreter 機能が動作するかを確認します。

 

確認用ファイルを用意

ローカルの任意のディレクトリをPyCharmのプロジェクトルートとして、 <Project root>/src/main.py ファイルを確認用ファイルとして作成します。

main.py の実装は以下です。ラズパイ上で実行できれば Linux と表示されるはずです。

import platform

print(platform.system())

 

PyCharmの設定を追加

3箇所設定を追加します。

 

PyCharmで SSH Configurations を追加

まずはPyCharmにSSH設定を追加します。

  • メニューから Preference > Tools > SSH Configurations を開く
  • 左上の + をクリック
  • 以下を入力
    • Host: 192.168.0.52
    • User name: pi
    • Authentication type: Key pair
    • Private key file: 上記で生成したSSH秘密鍵(ディレクトリより選択)
  • Test Connection ボタンをクリックし、接続が成功すればOK

 

PyCharmに SSH Interpreter を追加

続いて、PyCharmからラズパイのPythonインタプリタを扱えるよう、設定を追加します。

  • メニューから Preference > Project > Python Interpreter を開く
  • 右の歯車マークから Add を選択
  • 左側で SSH Interperter を選択し、右側で Existing server configuration を選択、上記で作成したSSH設定を指定
    • 今回の例だと pi@192.168.0.52:22
  • Next をクリック
  • 以下を設定し、 Finish をクリック
    • Interpreter: /usr/bin/python (デフォルトのまま)
      • 今回はシステムのPythonを使うため
    • Sync folders
      • Local Path: /path/to/rpi_felica_poweroff_with_nfcpy/src
        • プロジェクトルートの下に作った src ディレクトリを指定
      • Remote Path: /home/pi/projects/rpi_felica_poweroff_with_nfcpy
    • Authmatically upload project file to the server にチェックを入れる

 

実行設定を追加

最後に、PyCharmから実行するときの設定を追加します。

  • メニューから Run > Edit Configurations をクリック
  • 左上の + をクリックし、 Python を選択
  • 以下を入力し、OK をクリック
    • Name: 任意
    • Script path: mac上の src/main.py を選択
    • Python interpreter: 上記で作成した Remote *** を選択
  • PyCharmのindexingが走るので、しばらく待つ

 

動作確認

実行ボタンをクリックすると、以下が表示されることを確認します。

ssh://pi@192.168.0.52:22/usr/bin/python -u /home/pi/projects/rpi_felica_poweroff_with_nfcpy/main.py
Linux

 
macのターミナルで実行した時と結果が異なるため、ラズパイ上で実行できていることが確認できました。

% python src/main.py 
Darwin

 

udevのrulesファイルを作成し、PaSoRi RC-S380をラズパイで扱えるようにする

rulesファイルの置き場所について

以前の記事では /lib/udev/rules.d/ の下に置いていました。

ただ、今回nfcpyを使ったところ、 /etc/udev/rules.d/ の下に置いても動作したため、 /etc の方にします。

 

PaSoRiの idVendor と idProduct を確認

以前と同じく確認します。

$ dmesg | grep usb
...
[    4.363804] usb 1-1.2: New USB device found, idVendor=054c, idProduct=06c3, bcdDevice= 1.11
...
[    4.363875] usb 1-1.2: Product: RC-S380/P
[    4.363903] usb 1-1.2: Manufacturer: SONY
[    4.363924] usb 1-1.2: SerialNumber: 0514577
...

 

udevのrulesファイルを作成

nfcpyからPaSoRiを扱えるよう、以前 PaSoRi RC-S320を使ったときのrulesファイルを流用して作成します。
Raspberry Pi 2 + systemd + udevで、USBデバイス挿入時にサービスを起動する - メモ的な思考的な

 
sudo vi /etc/udev/rules.d/90-rc-s380.rules として、以下を設定します。

SUBSYSTEM=="usb", ACTION=="add", ATTRS{idProduct}=="06c3", ATTRS{idVendor}=="054c", GROUP="plugdev", TAG+="systemd", ENV{SYSTEMD_WANTS}+="rc-s380.service", NAME="pasori380"

 
変更点としては、

  • GROUPusb から plugdev へと変更
    • nfcpyのドキュメントに「the device is owned by 'root' but you are 'stephen'. also members of the 'root' group would be permitted. you could use 'sudo' but this is not recommended. it's better to add the device to the 'plugdev' group」とあったため
    • 念のため、 groups pi したところ、 pi : pi adm dialout cdrom sudo audio video plugdev games users input render netdev spi i2c gpio lpadmin だったので、 plugdev で大丈夫そう
  • SYSTEMD_WANTS は後ほど作成するサービスの名前へと変更
  • NAME も、とりあえず変更

です。

 
sudo reboot で再起動した後にSSHでログインしてみると、PaSoRiが認識されていました。

$ systemctl --type device -a --full
  UNIT                        LOAD   ACTIVE   SUB     DESCRIPTION
  dev-bus-usb-001-004.device  loaded active   plugged RC-S380
...

 

nfcpyでFeliCaを読み込む

ラズパイ上で nfcpy をインストールします。

$ pip install nfcpy

 
先ほど用意した main.py を以下のように書き換えます。

import binascii
import nfc

def on_connect(tag):
    idm = binascii.hexlify(tag._nfcid).decode('utf-8')
    print(idm)

    # ラズパイのACT LEDをheartbeatにする
    cmd = shlex.split("echo heartbeat")
    cmd_redirect = shlex.split("sudo tee /sys/class/leds/led0/trigger")
    
    p1 = subprocess.Popen(cmd, stdout=subprocess.PIPE)
    p2 = subprocess.Popen(cmd_redirect, stdin=p1.stdout)
    p1.stdout.close()
    p2.communicate()[0]

if __name__ == "__main__":
    print('読み取りを開始します')
    with nfc.ContactlessFrontend('usb:054c:06c3') as cf:
        cf.connect(rdwr={'on-connect': on_connect})

 
main.py をラズパイに転送した後に実行すると、

ssh://pi@192.168.0.52:22/usr/bin/python -u /home/pi/projects/rpi_felica_poweroff_with_nfcpy/main.py
読み取りを開始します

で停止します。

その状態でPaSoRiFeliCaをタッチすると

011***

のように、FeliCaIDmが表示されます。

また、ラズパイのACT LEDがheartbeatになります。

 

systemdのserviceファイルを作成する

以前同様、systemdでmain.pyをサービスとして動作させるようにします。

serviceファイルは以前のものを流用し、 sudo vi /etc/systemd/system/rc-s380.service で作成します。
Raspberry Pi 2 + パソリ RC-S320 + libpafeにて、FeliCa読み取り時にPowerOffし、Slackへ通知する - メモ的な思考的な

[Unit]
Description=Pasori RC-S380 service
Requires=dev-bus-usb-001-004.device
After=dev-bus-usb-001-004.device

[Service]
Type=simple
User=pi
Restart=on-failure
RestartSec=5
ExecStart=/usr/bin/python /home/pi/projects/rpi_felica_poweroff_with_nfcpy/main.py

 
変更点としては

です。

 

サービスの動作確認

sudo reboot で再起動後、 sytemctl でサービスの状態を確認します。動作しているようです。

$ systemctl status rc-s380.service
● rc-s380.service - Pasori RC-S380 service
     Loaded: loaded (/etc/systemd/system/rc-s380.service; static)
     Active: active (running) since Mon 2022-03-28 07:17:34 BST; 1min 47s ago
   Main PID: 322 (python)
      Tasks: 2 (limit: 1597)
        CPU: 6.196s
     CGroup: /system.slice/rc-s380.service
             └─322 /usr/bin/python /home/pi/projects/rpi_felica_poweroff_with_nfcpy/main.py

 
この状態でPaSoRiFeliCa接触させると、ラズパイのACT LEDがheartbeatになります。

 

Slackへの通知とシャットダウン

通知に使うライブラリについて

以前は os/slacker を使って通知していました。
https://github.com/os/slacker

しかし、現在ではアーカイブされています。

 
ただ、slackerのREADMEからSlack公式の python-slack-sdk へのリンクが張られているため、今回は python-slack-sdk を使ってみます。

 
上記記事では WebClient を使っていましたが、asyncioに対応したものがないのかを調べたところ、別の記事にてpython-slack-sdkでは対応している旨の記載がありました。
asyncio アプリ内から Slack にメッセージを投稿しよう - Qiita

せっかくなので、今回はasyncio版を試してみることにします。

まず、ラズパイ上で必要なものをインストールします。

なお、 python-dotenv は必須ではありませんが、Slackのトークンを .env ファイルから読み込むようにするために使っています。

$ pip install slack_sdk aiohttp python-dotenv

 

asyncio版のslack_sdkで通知とシャットダウンを実装

上記の記事に従い、slack_sdkでの通知を実装します。また、合わせてシャットダウン機能も追加します。

追加後の実装は以下のとおりです。

import binascii
import os
import shlex
import subprocess

import asyncio
import nfc
from dotenv import load_dotenv
from slack_sdk.web.async_client import AsyncWebClient


async def post():
    client = AsyncWebClient(os.environ['SLACK_BOT_TOKEN'])
    response = await client.chat_postMessage(
        channel=os.environ['DESTINATION'],
        text=':zzz:',
    )
    print(response)

def on_connect(tag):
    idm = binascii.hexlify(tag._nfcid).decode('utf-8')
    print(idm)

    # ラズパイのACT LEDをheartbeatにする
    cmd = shlex.split("echo heartbeat")
    cmd_redirect = shlex.split("sudo tee /sys/class/leds/led0/trigger")

    p1 = subprocess.Popen(cmd, stdout=subprocess.PIPE)
    p2 = subprocess.Popen(cmd_redirect, stdin=p1.stdout)
    p1.stdout.close()
    p2.communicate()[0]

    # Slackにポストする
    asyncio.run(post())

    # シャットダウン
    cmd_power_off = shlex.split("sudo systemctl poweroff")
    subprocess.Popen(cmd_power_off)


if __name__ == "__main__":
    load_dotenv()

    print('読み取りを開始します')
    with nfc.ContactlessFrontend('usb:054c:06c3') as cf:
        cf.connect(rdwr={'on-connect': on_connect})

 
なお、 .env ファイルから

を読み込むことから、 /src/.env として環境に合わせて設定します。

SLACK_BOT_TOKEN=
DESTINATION=

 
一通りの準備ができたので、

  • mac上の <プロジェクトルート>/src 以下をラズパイへデプロイ
  • ラズパイの再起動

をしておきます。

 

動作確認

すべての準備が整ったので、全体を通した動作確認をします。

まずはラズパイにSSH接続し、サービスが起動していることを確認します。

# macからSSH
% ssh -i ~/.ssh/pi_felica_rsa pi@raspberrypi.local


# ラズパイ上でサービスの動作確認 (動作中)
$ systemctl status rc-s380.service
● rc-s380.service - Pasori RC-S380 service
     Loaded: loaded (/etc/systemd/system/rc-s380.service; static)
     Active: active (running) since Mon 2022-03-28 07:45:45 BST; 5min ago
   Main PID: 321 (python)
      Tasks: 2 (limit: 1597)
        CPU: 19.731s
     CGroup: /system.slice/rc-s380.service
             └─321 /usr/bin/python /home/pi/projects/rpi_felica_poweroff_with_nfcpy/main.py

 
PaSoRiFeliCaをタッチすると、

  • ラズパイのACT LEDをheartbeatになる
  • 指定したチャンネル(ユーザーのDM)へSlackへ :zzz: のemojiが通知される
  • シャットダウンする

が行われます。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rpi_felica_poweroff_with_nfcpy