ActiveRecord の dup の挙動

ひとことで言うと、 ActiveRecord には dirtry な attribute を追跡するための便利なメソッド群が定義されていますが、dup とともにこれらを扱うときには要注意ですよ、というお話です。

_was とか _changed? は便利

dirty な attribute を追跡するってのはどういうことかっていうと、たとえばこういうことです。

# DBからuserをひとり引いてくる
user = User.first
user.name # => "shinpei"
user.name_was # => "shinpei"
user.name_changed? # => false

# name atribute を書き換える
user.name = "nekogata"
user.name # => "nekogata"
user.name_was # => "shinpei"
user.name_changed? # => true

# DBに保存する
user.save!
user.name # => "nekogata"
user.name_was # => "nekogata"
user.name_changed? # => false

要するに「書き変わってるんだけど、未だにDBに保存されていないattributeはどれですか、書き変わる前の値はなんですか」みたいなことを記録してくれてるんですね。オー、便利。

dup すると面白い挙動する

# DBからuserをひとり引いてくる
user = User.first
duped = user.dup

user.name # => "shinpei"
user.name_was # => "shinpei"
user.name_changed? # => false

duped.name #=>"shinpei"
deped.name_was # => ""
deuped.name_changed? # => true

おおお?という感じの挙動ですね。ちょっとバグっぽい感じもする。しかしこれは意図された挙動です。それは ActiveRecord のテストからも確認できます。

rails/dup_test.rb at master · rails/rails · GitHub

test_dup_not_persistedtest_dup_has_no_id、きわめつけに test_dup_with_changes を見てみましょう。

このあたりを読むと、ActiveRecorddup は以下のような使われた方をすることを想定したメソッドであることが見て取れます。

# DBからユーザーを引く
user = User.first 

# (pk以外は) 同じ値を持ったユーザーをDBに保存
user.dup.save!

なるほど? つまり、ActiveRecorddup は、「オブジェクトのコピー」を作るメソッドではなくて、「レコードのコピー」を作るメソッドとして意図されているわけですね。

オブジェクトのコピーが欲しいなら clone を使おう

よけいなことしないでくれ!俺は単にオブジェクトのコピーが欲しいんや!というときにはどうすればいいかというと、clone メソッドを使ってあげましょう。こうすれば普通の挙動をします。

# DBからuserをひとり引いてくる
user = User.first
cloned = user.clone

user.name # => "shinpei"
user.name_was # => "shinpei"
user.name_changed? # => false

cloned.name #=>"shinpei"
cloned.name_was # => "shinpei"
cloned.name_changed? # => false

API docも読もう

dup (ActiveRecord::Core) - APIdock

clone (ActiveRecord::Core) - APIdock

開発環境でのみ、リクエスト毎になんか処理をフックしたい in Ruby

RackMiddleware を使うのが筋が良い気がする。

middleware 用意する

# lib/rack/nyan.rb
module Rack
  class Nyan
    def initialize(app)
      @app = app
    end

    def run(env)
      run_hook
      @app.call(env)
    end

    private
    
    def run_hook
      # なんかする
    end
  end
end

config.ru で開発環境のときだけ middleware を有効にする

# in your config.ru
# snip
if ENV['RACK_ENV'] == 'development'
  require 'rack/nyan'
  use Rack::Nyan
end
# snip

Rails だったら config/environment/development.rb でmiddleware読み込むなどの対応が良さそう。

より筋の良い方法があれば教えてください

Rails で content-type が application/json などの場合、MethodOverrideが効かない件

背景

Rails には MethodOverride の仕組みがあって、これはなにかっていうと、HTTP method が POST の場合でも _method=PUT とかそういうパラメーターを付けてリクエストしてあげると Rails のレイヤーではそのリクエストを PUT として扱ってくれるやつです。ブラウザの form が GET と POST しかサポートしてないけど疑似的に RESTfull にアプリケーションを作りたいみたいなときに重宝するやつですね。

さて、このMethodOverrideですが、content-typeが "application/x-www-form-urlencoded" か "multipart/form-data" のときにしか効いてくれません。これはまあ普通に考えてそれ以外のリクエストは Ajax によるリクエストとかなんか別の(iOSアプリだとかAndroidアプリだとか)クライアントからのリクエストであることが想定できるので、クライアントが PUT とか DELETE とかを送ってくれば良いだけの話なので、合理的と言えると思います。

しかし、その合理的な判断がちょっと困る場合が無い訳ではありません。GET と POST しか通さない邪悪なプロキシの下でインターネット接続をしなければならないことを強いられているひとびとにサービスを提供したい場合などです。この場合、Ajax であっても PUT メソッドや DELETE メソッドが使えないので、しょうがないので POST でリクエストを投げたいな、となったりします。

筋の良い解決方法

この MethodOverride を実際にやってくれてるのは Rails ではなくて Rack::MethodOverride です。実際にそいつを見てみると、_method というパラメータだけではなくて、X-HTTP-Method-Override という拡張ヘッダに PUT や DELETE を指定してあげれば良いことがわかります。まあこれが普通に「筋の良い」解決方法だと思います。

しかし、この「筋の良い」解決方法が使えない場合があります。それは拡張ヘッダを通してくれないような邪悪な(以下略)

最後の手段

というわけで、最後の手段です。既存の RackMiddleware が JSONの中身の _method を見て MethodOverride してくれないなら、そういう Middleware を作っちゃえばいいのです。ほぼ Rack::MethodOverride からのコピペだけど以下みたいな感じ。

module Rack
  class MethodOverrideJson
    HTTP_METHODS = %w(GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK)

    METHOD_OVERRIDE_PARAM_KEY = "_method".freeze

    def initialize(app)
      @app = app
    end

    def call(env)
      if env["REQUEST_METHOD"] == "POST" && env["CONTENT_TYPE"] == "application/json"
        method = method_override(env)
        if HTTP_METHODS.include?(method)
          env["rack.methodoverride.original_method"] = env["REQUEST_METHOD"]
          env["REQUEST_METHOD"] = method
        end
      end

      @app.call(env)
    end

    def method_override(env)
      req = Request.new(env)

      body = req.body.read(req.content_length.to_i)
      req.body.rewind if req.body.respond_to?(:rewind)

      json = JSON.parse(body)
      method = json[METHOD_OVERRIDE_PARAM_KEY] rescue nil
      method.to_s.upcase
    rescue JSON::ParserError
      ""
    end
  end
end

で、この RackMiddleware を Rails に乗せてあげれば、一応これで json で _method を指定した場合でも動くようになります。

結論としては、こういう手段が必要になるのはほんとにコーナーケースであるからこんな手段取らなくていいことが多いし、ほぼコピペという罪を犯しているというのもある。あとこれ一度 req.body を read して rewind してるけど、Rails側でも同じことされるので効率もそんなによくない。ただ、まあ、一応邪悪なプロキシ環境下でも疑似 REST するという目的は達成されているので、邪悪なプロキシ環境下でこういうことをしたいひとは参考にしたら良いんじゃないでしょうか。Rails じゃなくて Rack の層で解決してるので Rails 以外の WAF にも応用できると思います邪悪だしニッチなので gem にはしません。

Rubyにおける「環境」の話

環境というのはスコープと言い換えてもいいけど、ようするに「今実行している行から何が見えて何が触れるのか」という話。一度自分の頭の整理のためにきちんとまとめておきたいと思っていたので昼休みを利用してまとめる。

基本

環境変数

ENVっていうハッシュのこと、これはプロセスにとってグローバルな環境である。つまり、どこからでも参照することが可能。

グローバル変数および定数

これもプロセスにとってグローバルな環境である。

self

どのようなインスタンス変数が見えるか、どのようなprivateメソッドが見えるかというのは言い換えると「self が何を指しているか」ということである。

selfが何を指しているかは基本的には以下の通りである

ローカル変数

メソッド内ならメソッド内で定義したローカル変数のみが見える(クラス内で定義したものやmain内で定義したものは見えないことに注意)。

クラス内ならクラス内で定義したローカル変数のみが見える(main内で定義したものは見えないことに注意)

ブロック内なら、そのブロックの外側で見えるローカル変数が見える。ブロックの内側で定義したローカル変数はブロックの外に影響を与えない。

以上が基本である。

応用

instance_eval

obj.instance_eval do
  p self # => obj
end

すると、block の中で見える self が obj を指すようになる。一方、見えるローカル変数は変わらない。このギャップを利用することでいろいろメタいことができる。

お昼休みおわるのでタイムアップ。

例外あがってきたときに回数制限つきで retry するのどうやったらきれいに書けるの問題 in ruby

1年くらい前(?)にわたしのTLで話題になったやつに1年越しでいい案が生まれたので、こういうのはどうかという記事を書く。 id:kksg 向けのエントリである。

class MyError < StandardError; end

def maybe_fail(max_retry = 1)
  raise MyError, 'error' unless rand(3) == 0
  p 'success!'
rescue MyError => e
  throw e if max_retry <= 0 # retry しすぎてたらそのまま例外投げ直す
  p 'failure!'
  maybe_fail(max_retry - 1)
end


maybe_fail     # 1回リトライする
maybe_fail(10) # 10回リトライする

リトライしたいみたいな処理、基本的に一連の処理であろうからそこはメソッドにくくってしまい、retry するときに再帰するというスンポーである

rack-session-php というニッチなモジュールできたよ〜

何をする gem なのか

Rack から PHP のセッションファイルを読み書きする gem であります。既存の gem にも似たような乃があるのですが、デシリアライズをまじめにやってなかったり、マルチバイト食わせると死んだり、read-only だったりしたので自分で書きました。

Shinpeim/rack-session-php

今後の予定

ニッチなアレですが、もしもphpアプリケーションを on the fly で rails に載せ変えていくような事例があれば是非お使いください。Enjoy!(はたしてそのような闇の深い感じの案件を Enjoy できるのかどうかはまた別の話)

php_session という gem を書いた

何をする gem なのか

RubyからPHPのセッションファイルをいじりたいみたいなこと、あんまりないと思う。

でも、PHP で動いてる大きめのアプリケーションを Ruby に載せ変えたいね〜みたいなことはある気がする。一気に作っちゃってもいいんだけど、ちょっとずつ作っていって、できたところから順にデプロイして、できたところから順にトラフィックRubyのほうに振って行くみたいなことはあるかもしれない。

そういうときに、PHPの側でセッションの実装載せ変えちゃってもいいんだけど、歴史的経緯!!!!!で PHP の側のコードに手を入れるのが怖すぎるみたいなことがあるかもしれない。

だから Ruby から PHP のセッションファイルをいじりたい。

そんなめちゃめちゃニッチな需要に答える gem を書いた。

その名も php_session です。rubygems にももう上がってる。

担当する責務としては、PHP のセッションファイルの読み書き、destroy のみです。session_id の generate とか cookie 云々は Rack::Session の層でやるべきだよねみたいなのあるのでそこは触ってない。

今は file に書かれてること前提でアレしてるんだけど、memcached だとかそういうのもサポートできるようにすべきみたいな気がしているので、いつかそれもやりたい。

Rack::Session に載せれることを意識して書いたコードになってて、この gem を単体で触るってことはあんまりないんじゃないかなと思います。

今後の予定

  • Rack::Session::PHPSession というのを書く。
  • で、さらにそれを Rais で扱えるようにする。

よろしくおねがいします。