読者です 読者をやめる 読者になる 読者になる

SavePoint の動き方 (テナント編)

皆さん。こんにちは。MUGENUP の osada です。

5月14日(木) に SavePoint(セーブポイント) という新サービスをリリースしました。

www.savept.com

MUGENUP がクラウドソーシングで培った進行管理プロセス、そしてその集大成である Workstation を リプレースし、どなたでも使えるように SaaS として提供したプロジェクト管理ツールです。

今回は、SavePoint が導入しているマルチテナントについて解説します。

SavePoint はビルディングに似ています。

SavePoint は、savept.com というドメインで運用しています。 しかし実際には、mugenup.savept.com のようなURLでアクセスすることになります。 これは実際にはどうなっているのでしょうか?

f:id:mgnup:20150520011956p:plain

図は、SavePoint の構造をビルに例えたものです。

キーワードは、「フロア、入構証、エレベータ」の3つです。

まず SavePoint ビルがあります。 そこにはフロアがありますが、これがそれぞれのサブドメイン(mugenupの部分)になります *1

次に、ユーザーごとに、入構証が発行されます。 これは、メールアドレスとパスワードで構成され、 1メールアドレスごとに、1つ 発行されます。 これを使って 「SavePoint ビルに入る = ログインする」ことができるようになります。

しかしこれだけでは、フロアに入ることが出来ません。 ビルには入れたけど、エレベータが止まらないのです。

フロアに入るには、その入構証にフロアに入る許可を付与する必要があります。 そうすることで、エレベータがそのフロアに止まることになります。 (許可がないフロアには止まりません = アクセスできません)

逆に言うと、フロア許可証があれば、再度ログインし直す必要がありません。 一つの入構証で、自由にフロアを行き来することができます。

フロア許可証には2段階のレベルあります。

フロア許可証があるので、フロアに降りることはできるようになりました。 しかし今度は、部屋(= プロジェクト・案件)に入ることが出来ません。

部屋ごとにセキュリティがあるため、今度は部屋の許可を貰う必要があります。 この許可は、2種類の人からもらうことができます。

一人は、部屋の管理者(=案件管理者: 詳細は次回に解説します)。

もう一人は、フロア管理者 です。

フロア許可証には、通常レベルとは別に、 全てをコントロールできる管理者権限(Level A)を付与することができます。

これを持つ人が、フロア管理者です。 登録サイトから登録したユーザーは、フロア管理者となります。 *2

このレベルでは、全ての部屋(案件)に自由にアクセスしたり、 フロア管理室(後述)に入ることができるようになります。

ログインはできたけど、何もアクセスできない、というときは、 まだ部屋に入れてもらっていない、ということかもしれません。

フロア管理室でできること

部屋の外側のフロア全体に関わることはフロア管理者しか行うことができません。 例えば、

  • 入フロア許可の登録・剥奪
  • 新しい部屋(案件)の追加・部屋の削除
  • 他の誰かをフロア管理者にすること

などです。

データベースの解説

さて、概念を説明したところで、技術の話に移行します。

SaaS のサービスの場合、マルチテナント、というキーワードが出てきます。 しかし今回は、テナントではなく、フロアという例を出しました。 なぜかというと、「ビル入構証」という概念を伝えたかったからです。

純粋にテナント構造にすると、ユーザーはテナントごとに入構証を持ち歩かなければなりません。 首から3枚も4枚もカードをぶら下げて歩くのは、大変です。

よって SavePoint では、ユーザーのみ、テナントごとのデータベースから切り離して管理しています。

クライアントからサーバにアクセスがあると、Rack ミドルウエアの中で、 リクエストからサブドメインを判別し、データベースが切り替わります。 データベース単位で切り替わるため、他のテナントのデータが混ざることはありません。

しかし、ユーザーテーブルのみ、どのテナントからアクセスしても、 必ず一つのデータベースを見に行くようになっています。

これにより、セキュリティを高めながら、利便性を保つことができるようになっています。

まとめ

  • SavePoint はビルディング構造に似ています
  • 入構証の他に、フロア許可証があります
  • ユーザーは異なるフロアにシームレスに移動することができます。

SavePoint は、「発注者の管理を楽にしよう」 ということを追求して作っているサービスです。

ところが、MUGENUP 自体は、発注元からお仕事をいただく制作側の立場です。

もしSavePoint を100社がご導入いただいて、MUGENUPにお仕事を頂く場合、 通常のサービスでは、一人ごとに100アカウント作ることになるかもしれません。

しかし SavePoint なら何社になっても1人1アカウントです

このように、制作側にとっても使いやすいサービス を目指せるのは、 ドッグフーディングができる弊社の強みだと考えています。

発注者にも、受注者にも使いやすいサービス、を目指して、開発を続けています。

是非お使いください。

募集

弊社は、一緒に SavePoint を作ってくれるエンジニアを募集中です。 無限流開発、ご一緒しませんか?

recruit.mugenup.com

*1:登録サイトではスペースIDと呼んでいます

*2:それ以外で登録したユーザーは、通常レベルです

ActiveJob から見るシリアライズとデシリアライズ

皆さん、こんにちは。プルリクで間違った指摘をして大反省中 の osada です。

プルリクで間違った指摘をして大反省中です。 下記のコードで、何を指摘したか、お分かりになるでしょうか?

class NotificationJob < ActiveJob::Base
  queue_as :default

  def perform(notifiable, user)
    notifiable.notify(user)
  end
end

こんなことを言ってしまったのです。

オブジェクトを丸ごとシリアライズすると、redis の容量を圧迫し、 シリアライズ・デシリアライズにも時間が掛かるので、 クラス名とidを渡して、job の中で取り出して使って下さい。

この発言には2つ、間違いがありました。

  • 1つ目は、オブジェクトが丸ごとシリアライズされると思っていたこと。
  • 2つ目は、ActiveJob は クラス名と id を渡す必要がないことです。

今回は、ActiveJob では、インスタンスをそのまま渡してもよくなったこと、 そして、GlobalID がどのように使われているかを共有します。

話の流れとしては、下記の3つのシリアライズ方法の比較を行います。

  1. python memcache の シリアライズ
  2. resque のシリアライズ
  3. activejob のシリアライズ

Python memcache はインスタンスごとバイナリダンプ

pythonmemcacheclient は、インスタンスを丸ごとダンプします。

In [1]: class A(object):
   ...:     def __init__(self, a):
   ...:         self.a = a
   ...:

In [2]: a = A(1)

In [3]: import memcache

In [5]: mc = memcache.Client(['127.0.0.1:11211'])

In [7]: mc.set("a", a)
Out[7]: True

In [8]: mc.get("a")
Out[8]: <__main__.A at 0x103efbc10>

In [9]: a1 = mc.get("a")

In [10]: a1.a
Out[10]: 1

In [13]: a == a1
Out[13]: False

In [14]: a.__class__ == a1.__class__
Out[14]: True

mc.getで取り出した時に、Aというクラスのインスタンスとして取り出していることがお分かりになるでしょうか。 インスタンスとしては別ものですが、クラスは同じです。

memcache の set では、 オブジェクトをバイナリダンプするpickle というモジュールが動いています。

In [6]: mc._val_to_store_info(a, 0)
Out[6]:
(1,
 89,
 "ccopy_reg\n_reconstructor\np1\n(c__main__\nA\np2\nc__builtin__\nobject\np3\nNtRp4\n(dp5\nS'a'\nI1\nsb.")

12.1. pickle — Python オブジェクトの直列化 — Python 3.4.2 ドキュメント

謎の文字列が格納されていますが、なんとなく読み取れるような? ruby でも marshal を使えば、同じことができると思います。

そしてこれが、一つ目の間違いである、インスタンスを丸ごとダンプしていると考えた理由でした。 きっと rails も、バイナリダンプしているのだろう、と思い込んでしまったのです。

Resque のシリアライズ

では、Rescue はどうしているでしょうか?

[31] pry(main)> class A
[31] pry(main)*   def initialize(a)
[31] pry(main)*     @a = a
[31] pry(main)*   end
[31] pry(main)* end
:initialize
[32] pry(main)> a = A.new 1
#<A:0x007fa192282b80 @a=1>

Aというクラスを定義し、インスタンス化します。

[33] pry(main)> class Q
[33] pry(main)*   @queue = :q
[33] pry(main)*   def self.perform(a)
[33] pry(main)*     p a
[33] pry(main)*   end
[33] pry(main)* end
:perform
[34] pry(main)> Resque.enqueue(Q, a)
true
[35] pry(main)> Resque.keys
[
    [0] "queue:q",
    [1] "queues"
]
[38] pry(main)> Resque.redis.lrange "queue:q", 0, -1
[
    [0] "{\"class\":\"Q\",\"args\":[{\"a\":1}]}"
]

それをQというジョブを作成し、Resque に渡します。 このとき、ジョブQ と、インスタンスaredis に積まれますが、 このとき引数のa が何のクラスだったのか、という情報は消えるようです。 (resque 1.23 です)

redis に積まれた情報だけでは、復元することができません。

なぜかといえば、そもそもインスタンスをダンプしない方針のようです。

If your jobs were run against marshaled objects, they could potentially be operating on a stale record with out-of-date information.

バイナリダンプしてキューに積んだら、 データが更新されてた時に、古いデータのままで使っちゃうかもしれないでしょ?

という感じでしょうか?

resque/resque at 1-x-stable

Resque では、クラスとidを渡して、キューの中で取り出すのが標準です。

さてこれが、2つ目の間違い、クラス名とidを渡して、jobの中で呼び出すという指摘をした原因です。 ActiveJob も同様だろう、と思い込んでしまったのです。

ActiveJob のシリアライズ

ActiveJobでは、GlobalID という機能が入っており、明示的なシリアライズが不要です。 マニュアルに明確に記載がありましたので、確認不足すぎて大反省でした。 Active Job の基礎 — Rails ガイド

バイナリダンプでもないけれども、インスタンスをそのまま渡すことができる、 一体これがどのように動いているのでしょうか?

シリアライズの処理は、activejob-4.2.0/lib/active_job/argument.rb にあります。

def serialize_argument(argument)
  case argument
  when *TYPE_WHITELIST
    argument
  when GlobalID::Identification
    { GLOBALID_KEY => argument.to_global_id.to_s }
  when Array
    argument.map { |arg| serialize_argument(arg) }
  when Hash
    argument.each_with_object({}) do |(key, value), hash|
      hash[serialize_hash_key(key)] = serialize_argument(value)
    end
  else
    raise SerializationError.new("Unsupported argument type: #{argument.class.name}")
  end
end

引数を一つずつ処理しながら、

  1. シリアライズできるもの(TYPE_WHITELIST)
  2. GlobalID::Identification であるもの
  3. 処理できないもの

に分けています(ArrayとHashに対して再帰するのは、引数の処理として勉強になりますね)。

GlobalID::Identification そしてそのメソッドである to_global_id.to_s というのが ActiveRecord を一意に成り立たせているものです。

[1] pry(main)> user = User.first
  User Load (0.5ms)  SELECT  "users".* FROM "users"  ORDER BY "users"."id" ASC LIMIT 1
=> #<User:0x007fd1449c3b88 id: 1, name: "aa", created_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00, updated_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00>
[2] pry(main)> user.to_global_id.to_s
=> "gid://test01/User/1"
[3] pry(main)> user = User.new
=> #<User:0x007fd145d4feb8 id: nil, name: nil, created_at: nil, updated_at: nil>
[4] pry(main)> user.to_global_id.to_s
URI::InvalidURIError: Expected a URI like gid://app/Person/1234: #<URI::Generic gid://test01/User/>

アプリ名、クラス名、id で一意になる文字列を生成しています。 id が無ければ、エラーになります。

そんなわけですから、マニュアルには、ActiveModel にミックスイン、とありますが、 Rails 4.2.0 では、GlobalID というライブラリに変わっており、 ActiveRecord にミックスイン になっていますので、気をつけましょう。

上のコードは、ActiveModel::GlobalIdentificationをミックスインするすべてのクラスで動作します。 このモジュールはActive Modelクラスにデフォルトでミックスインされます。 http://railsguides.jp/active_job_basics.html#globalid

この GlobalID のおかげで、ActiveRecordインスタンスを渡したとき、 global_id の文字列に変換されて積まれる、ということです。

手動でやっていたことを、ライブラリとして組み込んでしまった感じでしょうか。 また、クラス名と、idを渡すのではなく、1つの gid として渡すのも、センスが良いですね。

ActiveJob の デシリアライズ

シリアライズを見てみましょう。

GlobalID::Locator.locate(argument) を使って、 globalid かどうかを確認しています。

def deserialize_argument(argument)
  case argument
  when String
    GlobalID::Locator.locate(argument) || argument
  when *TYPE_WHITELIST
    argument
  when Array
    argument.map { |arg| deserialize_argument(arg) }
  when Hash
    if serialized_global_id?(argument)
      deserialize_global_id argument
    else
      deserialize_hash argument
    end
  else
    raise ArgumentError, "Can only deserialize primitive arguments: #{argument.inspect}"
  end
end

この locate は、下記の手順でデータを取り出します。

  1. string が 妥当なgid かどうか確認
  2. gid があれば locator_for で、適切な finder を取得
  3. finder を使って、データを取得

この finder は、標準で、ActiveRecordFinder が用意されています。 よって、実際は、ActiveRecord.find を使って取り出します。

def locate(gid, options = {})
  if gid = GlobalID.parse(gid)
    locator_for(gid).locate gid if find_allowed?(gid.model_class, options[:only])
  end
end

def locator_for(gid)
  @locators.fetch(normalize_app(gid.app)) { default_locator }
end

@locators = {}

class ActiveRecordFinder
  def locate(gid)
    gid.model_class.find gid.model_id
  end
end

mattr_reader(:default_locator) { ActiveRecordFinder.new }

よって、ActiveRecord 以外でも、自前で locator を用意すれば、使うことが可能です。

custom-app-locator

# Tie a locator to an app.
# Useful when different apps collaborate and reference each others' Global IDs.
#
# The locator can be either a block or a class.
#
# Using a block:
#
#   GlobalID::Locator.use :foo do |gid|
#     FooRemote.const_get(gid.model_name).find(gid.model_id)
#   end
#
# Using a class:
#
#   GlobalID::Locator.use :bar, BarLocator.new
#
#   class BarLocator
#     def locate(gid)
#       @search_client.search name: gid.model_name, id: gid.model_id
#     end
#   end

ただ、実装としては、gid.model_class.find gid.model_id だけですので、 実装したいクラスに、find メソッドid メソッド を用意した方が楽な気がしますね。

まとめ

今回のまとめです。

  1. キューのためにデータをシリアライズするときは、データのズレを考慮し、バイナリダンプしない
  2. 永続化されたモデルは、グローバルIDを発行することで、クラスとidを使うよりも、シンプルに扱える
  3. プルリクを見る時は、謙虚に。しかし臆すること無く。

なお、4.2.0 の時点で、ActiveJob に キーワード引数を使うことはできませんが、 4.2.1 には入るようです。 しばらくは、キーワード引数を使わない実装で、頑張りましょう。 ActiveJob should support passing of keyword arguments to perform method · Issue #18741 · rails/rails

参考

TYPE_WHITELIST はプリミティブ型です

TYPE_WHITELIST = [ NilClass, Fixnum, Float, String, TrueClass, FalseClass, Bignum ]

Resque のダンプは MultiJSON

# Given a Ruby object, returns a string suitable for storage in a
# queue.
def encode(object)
  if MultiJson.respond_to?(:dump) && MultiJson.respond_to?(:load)
    MultiJson.dump object
  else
    MultiJson.encode object
  end

end

[10] pry(main)> MultiJson.dump(a)
"{\"a\":1}"
[11] pry(main)> a1 = MultiJson.load(MultiJson.dump(a))
{
    "a" => 1
}
[12] pry(main)> a.class == a1.class
false

ActiveJob は自前クラスは入れられない

[1] pry(main)> class AJob < ActiveJob::Base
[1] pry(main)*   queue_as :default
[1] pry(main)*   def perform(a)
[1] pry(main)*     p a
[1] pry(main)*   end
[1] pry(main)* end
:perform
[2] pry(main)> class A
[2] pry(main)*   def initialize(a)
[2] pry(main)*     @a = a
[2] pry(main)*   end
[2] pry(main)* end
:initialize
[3] pry(main)> a = A.new 1
#<A:0x007ff4d41b0a50 @a=1>

[4] pry(main)> AJob.perform_later(a)
Enqueued AJob (Job ID: 71481807-dae8-460f-a050-655ab0394bd5) to Resque(default) with arguments: #<A:0x007ff4d41b0a50 @a=1>
ActiveJob::SerializationError: Unsupported argument type: A

[5] pry(main)> AJob.perform_later({a: 1})
Enqueued AJob (Job ID: f9cf4409-f749-4714-81bf-aa309f400b60) to Resque(default) with arguments: {:a=>1}
#<AJob:0x007ff4d2c60128 @arguments=[{:a=>1}], @job_id="f9cf4409-f749-4714-81bf-aa309f400b60", @queue_name="default">

[40] pry(main)> user = User.first
  User Load (0.5ms)  SELECT  "users".* FROM "users"  ORDER BY "users"."id" ASC LIMIT 1
=> #<User:0x007fd142924738 id: 1, name: "aa", created_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00, updated_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00>

[41] pry(main)> AJob.perform_later(user)
Enqueued AJob (Job ID: 016c46b7-4abe-4cae-9605-84c29572faab) to Inline(default) with arguments: gid://test01/User/1
  User Load (1.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1  [["id", 1]]
Performing AJob from Inline(default) with arguments: gid://test01/User/1
#<User id: 1, name: "aa", created_at: "2015-02-28 13:12:38", updated_at: "2015-02-28 13:12:38">
Performed AJob from Inline(default) in 0.47ms
=> #<AJob:0x007fd144f6a6b8
 @arguments=[#<User:0x007fd142924738 id: 1, name: "aa", created_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00, updated_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00>],
 @job_id="016c46b7-4abe-4cae-9605-84c29572faab",
 @queue_name="default">

参考文献

bower に table のカラムの表示/非表示を自由に選べるライブラリを追加してみた

皆さん。こんにちは。MUGENUP のosadaです。

電車を降りようとしたら、スマホを線路に落としました! 駅員さんにお願いして取っていただいたところ、 なんと画面が割れることもなく無事でした!ラッキー! そんな運が良い osada がお送りする今回は、bower のお話です。

js のパッケージ管理ツール Bower。 皆さん使っていらっしゃいますか? 弊社では、bowerパッケージを自動でgemにしてくれるRailsAssetsというサービスを使って、 Rails上でbowerを使っています。

今回はそんなbowerのパッケージを作ってたみたので、レポートします。

要点

  • js ライブラリのデモページは、github pages が良かった
  • githubのソースを、<script> として読み込む rawgit
  • RailsAssets に bower を直ちに反映させたければ、Add Componet

表示するカラムを自分で決められるTable 用 jQuery プラグイン

弊社はクリエイティブの幅広い部分をカバーする会社ですので、 多くの案件と複数の職種が入り乱れて、ゴールを目指します。 すると、同じ案件の情報でも、見たいものが異なるのは自然なことです。 さらにいうと、同じ職種の人でも、見たいものが異なるということも、 よくあります。

そこで作ったのが、table_as_u_like です。

  • Table as u like
  • 機能
    • テーブルの th を読み込みトグルボタンにする
    • トグルによって、カラムの表示/非表示が切り替えられる
    • session によって、個人ごとに状態を維持する

coffeescript で 45行の簡単なプラグインですが、便利に使っています。 今回はこれをbowerに登録しました。

bower に登録する前に

Rails用に開発したので、ライブラリとして公開するのに必要なものが2つありました。

  1. Railsの代わりにcoffeescriptコンパイルする環境
  2. デモ用のページ です。

gulp で coffee を コンパイル

coffeescriptコンパイルだと、 Gruntが思いつきますが、 今回は gulp.js を使ってみました。

設定ファイルが書きやすい、速い、など、Grunt を改良したツールということでしたが、 1つの coffeeコンパイルする程度だと、あまり違いが感じられませんでした。 今度は大規模なものに使ってみたいところです。

  • インストール
npm install -g bower
npm install -g gulp
npm install --save-dev coffee-script
npm install --save-dev gulp-coffee
  • 設定ファイル
gulp.task 'coffee', () ->
  gulp.src files.coffee
    .pipe coffee()
    .pipe gulp.dest('lib')

設定ファイル、というより、シェルスクリプト、といった感じでした。 勿論watchもあるので、安心です。

github pages でデモ用のページを作る

動的な要素が不要な場合、github pagesはとても便利です。

githubsettings から github-pages を有効にすると、 gh-pages というブランチが作られます。 以降はそのブランチへpushすることで、反映されるという簡単構造です。 (多少遅延があります)

jekyll が使えるということでしたので、 大きなページを作る場合は、そちらの方が良いですね。 今回は1ページだったので、 ローカルでサーバを起動して、 開発して、push という手順でした。

こんなときはpythonが大活躍。

python -m http.server

小さなものなら、これで十分ですよね。

github に置いたまま <script> で読み込む rawgit

さて、そんな便利なgithub-pagesですが、唯一困ったのか、 ライブラリの読み込みです。

jquery は cdn を使って読み込めます。

<script src="//code.jquery.com/jquery-2.1.1.min.js"></script>

ですが、github においてあるファイルは、そのままでは読み込めません。 例えば、こう書くと

<script src="https://raw.githubusercontent.com/osdakira/table_as_u_like/master/lib/jquery.table_as_u_like.js"></script>"

こう怒られます。

Refused to execute script from 'https://raw.githubusercontent.com/osdakira/table_as_u_like/master/lib/jquery.table_as_u_like.js' because its MIME type ('text/plain') is not executable, and strict MIME type checking is enabled.

github がこれを禁止していて、理由は、遅いから、 *1 ということのようです。

この制限を迂回するサービスがrawgit.comです。 ヘッダを付与することで、githubへのダイレクトなアクセスを行い、 かつ、キャッシュすることでスピードを上げているようです。 デモの用途であれば*2、使用できるようです。

f:id:mgnup:20141202095930p:plain

そうしてできたデモが Table as u like です。 index.html一枚で、js, css は全て外部から読み込んでいます。

bower に登録する

bower の登録については、既に多くの記事があるので(皆さん、ありがとう!)、概要のみ記載します。

  1. bower をインストール
  2. bower init で対話形式でbower.jsonを作成
  3. bower register で登録
npm install -g bower
bower init
bower register table_as_u_like git@github.com:osdakira/table_as_u_like.git`

とても簡単でした。

RailsAssets に Add Componet する

ところが、bowerには登録されていても、RailsAssetsには出てきませんでした。 RailsAssetsへの登録は非同期で行われているようで、遅延があるようです。 この場合、AddComponetを使うことで、 直ちに反映させることができました。

f:id:mgnup:20141202095955p:plain

bower が自動的にgem になる。なんて便利なんでしょう!

まとめ

以上がjsライブラリbowerに登録してみたレポートです。

  • js ライブラリのデモページは、github pages が良かった
  • githubに置いたまま、<script> として読み込む rawgit
  • RailsAssets に bower を直ちに反映させたければ、Add Componet

そうそう、最後にもう一つ。 線路にものを落とした時は、 何番線の何号車の位置に落としたかを伝えましょう。 びっくりしますし、焦ってしまいますが、 それを伝えることができたら、話はスムーズです。 (私は上りと下りを間違えて、ウロウロしてしまいました)

では、良い geek ライフを!

参考

*1:Heads up: nosniff header support coming to Chrome and Firefox

*2:アクセスが少なく、負荷をかけないもの

【Rails】Scaffoldされたコントローラを読んでみる

こんにちは、MUGENUPの倉成です。 僕はRailsを使い始めてあと少しで丸2年になるのですが、初めの頃は覚えることがたくさんあって、とても大変だった記憶があります。

今回はそんな中でも、Railsで初めてWebプログラミングをする人向けに、コントローラのアクションであるnew,create,showについて解説してみたいと思います。

この記事ではソースコードは説明のため、実際にscaffoldされたコードを抜粋、もしくは省略されているコードを挿入して取り上げます。 なお、使用するコードは

rails g scaffold user name:string email:string postal_code:string address:string

で生成されたコードを元にしており、モデル・ビュー・コントローラはこれを使うことにします。

それではnewから見て行きましょう。

def new : 新規登録フォームを作成

def new
  # (1) Userオブジェクトを生成
  @user = User.new
  # (2) フォームを生成(Rails4では省略されている)
  render :new
end

このアクションでは(1)@userを作成し、(2)では(1)で生成したオブジェクト(@user)とテンプレート(new.html.erb)を使ってhtmlを生成しています。

図にすると以下の様なイメージです。

f:id:mgnup:20140828233234p:plain

今回は@userの全ての属性の初期状態がnilなので、空のinputフォームが生成されています。

さて、次はcreateアクションを見て行きましょう。

def create : 登録 or 差戻し

scaffoldされたcontrollerは複雑で悩むポイントはたくさんあると思うのですが、特に難しいのが行数の多いcreateだと思います。 今回は簡単のためformat.htmlformat.jsonそしてnoticeは一旦脇に置いておいて

def create
  # (1) フォームから渡されたパラメータを使用し、Userオブジェクトを生成
  @user = User.new(user_params)
  # (2) DBへ保存(を試みる)
  if @user.save
    # (3) 新規登録されたユーザーページヘリダイレクト
    redirect_to @user
  else
    # (3') @userを使って、もう一度フォームを表示
    render :new
  end
end

のコードを追いましょう。

(1) @userを生成

@userならnewするときに作ったよ」と思うかもしれません。

しかし、ブラウザからリクエストとして送られてくるのは、フォームに入力されたテキストデータのみのため、改めて@userを生成する必要があります。 同じコントローラでも「一回ごとのリクエストでインスタンス変数が共有化されない」というのはWebアプリが初めてだと取っ付きづらく感じる原因かもしれないですね。

f:id:mgnup:20140828234344p:plain

create内で、フォームからの情報を使って、@userを作ります。

f:id:mgnup:20140828234945p:plain

(2) DBへ保存

(1)で生成した@userの値を検証し、DBへの保存が成功した場合には、(3)redirect_to、失敗した時には(3')renderをします。 saveが失敗するケースとしては「ユーザー名が空白です」「郵便番号は7桁で入力して下さい」のように、バリデーションに引っかかることが多いかと思います。

(3)登録に成功: 登録内容を表示する

ユーザーの登録に成功したら、そのユーザーの登録内容を表示します。 ここでは登録に成功した@userのページを表示するため

redirect_to @user
# 以下と同じ意味
# redirect_to user_path(@user)
# redirect_to /users/#{@user.id}

としています。

しかしnewにあったrenderと違い、redirect_toはテンプレートを使用してHTMLを生成するわけではありませんredirect_toはブラウザに「localhost:3000/users/1にアクセスしなおしてね」と教えるだけです(urlはドメインlocalhost:3000@user.id=1の場合)。

ブラウザはサーバーからのredirectの指示に従ってlocalhost:3000/users/1にアクセスすることで、詳細ページが表示されることになります。

f:id:mgnup:20140829012717p:plain

renderじゃないのはなぜ?

さて、ここでredirect_toを使用していて、render :showとしないのはなぜでしょう? 上の図でも真ん中の2本の矢印は不要に思えますし、create内で@userが既に存在しているので、再度DBから読み直すのは無駄な気がします。

実は、saveに成功時にrender :showとしてしまうと、ページをリロードするたび、新規ユーザー登録処理が発生してしまうという問題があります。 Railsの視点から説明すると、ブラウザをリロードした時には、最後にrenderを呼び出したアクション、今回の場合はcreateアクションが呼ばれてしまうことになります。*1

下はcreateアクションでrender :showをした後、ブラウザをリロードさせた図です。多重登録を防ぐため「フォーム再送信の確認」ダイアログが表示されているのが分かるかと思います。

f:id:mgnup:20140829004303p:plain

今回はscaffoldのコードに習いshowにリダイレクトしましたが、もちろん一覧ページであるindexに飛んでもいいですし、ホームのページ(localhost:3000/)に飛ぶことも多いと思います。 DBの更新が発生した後には、ページを表示するだけのアクションにリダイレクトするということを覚えておきましょう。

また、このpost → redirect → getの一連の流れはその頭文字を取ってPRGパターンと呼ばれているようです。この記事の説明でしっくりこないなぁという方は、ぜひPRGパターンで検索してみてください。

(3')登録に失敗: もう一度入力フォームを表示する

入力内容に不備があった場合、もう一度フォームを表示して、再度情報を入力してもらうため、フォームのテンプレートであるnew.html.erbrenderします。createアクションでrender :newをすると、保存に失敗した@userインスタンス変数をそのまま使えるため不正な項目のみを再度入力してもらうことが出来ます。

f:id:mgnup:20140828235346p:plain

redirect_toじゃないのはなぜ?

一方、再度フォーム画面を表示さるためにredirect_to "users/new"を使ってしまうとフォームは表示させることが出来るのですが、ユーザーがこれまでに入力した内容が全て消え、再度全ての項目を入力してもらわなければなりません。

newの処理を思い出すと、@userの生成時に@user = User.newとしているので、白紙のフォームが表示されてしまうのは納得できるかと思います。

ユーザー登録時に「一箇所ミスがあるくらいで、全ての内容を再入力させるなー!」という経験は一度くらいあるはずですが、このようなシステムを作らないためにはredirect_toではなくrenderを使う必要があるのです。

def show : 登録情報を表示

ユーザーの情報を表示するのがshowの役割です。

createからredirectしてくる場合でも、createで使用した@userは使用できないため、改めてDBからデータを取得し、@userを生成する必要があります。

  def show
    # Rails4ではbefore_actionで呼ばれる
    # (1) params[:id]のユーザーを取得
    @user = User.find(params[:id])
    # (2)Viewを生成(Rails4では省略されている)
    render :show
  end

f:id:mgnup:20140829011625p:plain

@userを作って、View(show.html.erb)のhtmlを生成するということで、フォームなのかテキストなのかという違いがあれど、図はnewと似たものになります。

最後に

さて、今回は僕がRails初心者だった頃に「renderredirect_toは何が違うの?」とか「ここの@userって、こっちのアクションで使えないの?」とか、よく混乱していた部分を中心にRailsのコントローラを解説してみました。 この記事を読んで少しでも理解が深まれば嬉しいです。

この記事はDavid Griffiths著『Head First Rails』の3章を参考にさせていただきました。 この本はRails2系が対象と、やや古くRails3/4では、写経したコードが動かないことが多々あるのですが、Railsの仕組みを知る上ではかなり良書だと思います。 リファレンス的には使用できず、回り道に感じるHead First Railsですが、Railsで初めてWebプログラミングをするという方はこの本から始めてみるとよいかもしれません。

*1:HTTP的な視点で見ると、localhost:3000/usersに対し、再度POSTが発行されている状態です

enctype='multipart/form-data'ってなんだ?

こんにちは、MUGENUPアルバイトの倉成です。

今回は僕が前々から気になっていた、フォームからファイルを送信するときのおまじないenctype="multipart/form-data"について調べてみたので、得られた知識をまとめて見ようと思います。

また、マルチパートの情報を検索していると、HTMLのフォームだけではなく、メールのマルチパートの情報に当たることも多くありました。 調べてみると、HTMLの仕様と電子メールの仕様が似ているのは、どうやら歴史的な経緯があるようなので、後半ではインターネット成長の歴史についても少しだけ触れてみようと思います。

multipart/form-data: ファイルを送るおまじない

それでは、フォームでファイルをアップロードするシチュエーションを考えましょう。

ファイルアップロードをする場合input要素は<input type="file" />を使い、その親のform要素には以下のようにenctype="multipart/form-data"と書く必要があります。

f:id:mgnup:20140827094606p:plain

<form action="URI" method="post" enctype="multipart/form-data">
  <input type="file" name="file" />
</form>

さて、このおまじないenctype="multipart/form-data"はなぜ必要なのでしょうか?

Firebugを使ってリクエストを見る

今回はenctypeの有無によりHTTPリクエストのパラメータがどのように変化するかFirefoxプラグインであるFirebugを使って調べてみようと思います。

なお、今回使用するhtmlのソースコード

<html>
  <!-- enctype指定あり -->
  <form action="http://localhost:4567" method="post" enctype="multipart/form-data">
    <input type="file" name="datafile" />
    <input type="submit" name="submit_btn" />
  </form>
  <!-- enctype指定なし -->
  <form action="http://localhost:4567" method="post">
    <input type="file" name="datafile" />
    <input type="submit" name="submit_btn" />
  </form>
</html>

で、送信するファイルは

hello world!

hogehoge
fugafuga

とします(添付ファイル名はsample.txtです)。

さて、それぞれを試したところFirebugは以下の画像のようになりました。

enctype指定あり

f:id:mgnup:20140826195719p:plain

enctype指定なし

f:id:mgnup:20140826195727p:plain

enctype指定ありでは、Content-DispositionContent-Typeなど添付ファイルに関する情報とともに、ファイルの本文の情報が存在することが分かります。今回はテキストですが、画像や音声などバイナリの情報でも、同様にここにデータが格納されます。 また、--------で始まる行があることも特徴的です。

enctype指定なしでは、filedataにファイル名の情報のみが与えられていて、添付ファイルの情報は見当たりません。 どうも、inputエリアにファイル名のみを指定した

f:id:mgnup:20140825232727p:plain

と同様な印象を受けます。 つまり、enctype="multipart/form-data"を指定しない場合、添付ファイルの情報を送信できていないので、サーバー側では添付ファイルを扱えないという事になりそうです。

なお、-----------------------------146617270317...のような区切り文字をboundaryと呼び、この区切りを使って複数のパートに分割することからマルチパートと呼ばれているようです。boundaryを使うと入れ子構造も再現できるようです、興味のある方は以下のリンクが詳しくまとまっているので御覧ください。

フォームよるファイルアップロードの仕様 - Jakarta Commons FileUploadの利用手順

メールの場合は

boundaryを使ってファイルの情報を送信するというのがmultipart/form-dataの仕組みでした。 しかし、冒頭で述べたようにメールにも同様のマルチパートという仕組みがあるようです。 では、これまで見てきたフォームのマルチパートはメールのマルチパートとどのような関係があるのでしょうか?

ここでは「Webを支える技術」の引用をさせていただきます。

HTTPの最初のバージョン0.9にはヘッダがありませんでした。HTTPの仕様策定が進められるに従って、HTTPで転送する本文のメタデータを表現するために電子メールのメッセージ仕様(RFC822)のヘッダ形式を借りてくる形で追加されました。

Webを支える技術 P.126

つまり本記事では、formつまりHTTPのマルチパートを先に紹介していたのですが時系列で言うとこれは逆で、HTTPの仕様策定時に電子メールの仕様を参考にしたというのが歴史的な流れのようです。マルチパートに限らず、HTTPの仕様が電子メールの仕様に似ているのはこのような経緯があるためなのですね。

f:id:mgnup:20140825232655p:plain

なぜ電子メールの仕様にマルチパートが加わったのか(MIMEが策定されたのか)については

MIME の基礎

で詳しく解説されています。

ざっくりというとMIME策定前の仕様ではメールのヘッダ・本文がASCII限定で、日本語など非ASCIIの言語が正しく認識できなかったり、添付ファイルがうまく扱えなかったという問題があったようです。

multipart/alternative:textとhtmlを同時送信

「マルチパート メール」などで検索してヒットする記事にはmultipart/alternativeに関する記事も多いようです。

multipart/alternativeはマルチパートの構造を利用して一つのメールにtext版とhtml版の2つの内容を含めて送信し、メーラーの設定次第でtexthtmlを選択して表示できるものです。

-----------------------------1948084979928559891542425288
はじめに

こんにちはMUGENUPの倉成です。

-----------------------------1948084979928559891542425288 
<h1>はじめに</h1>

<b>こんにちは</b>MUGENUPの倉成です。
-----------------------------1948084979928559891542425288--

こんなメールを送ると、メーラーの設定次第でプレーンテキストでもリッチテキストでも表示できるようになるわけですね。

さいごに

enctypeを見るたびに「これ、なんだろうなぁー」と思っていたことから始まり、調べて行ったらインターネット成長の歴史的な経緯まで知ることができて、楽しい夏休みの自由研究となりました。 RFCの仕様も機会があればもう少し読んでみようと思います。

HTTPの仕様やRFC策定の歴史については山本陽平さんの「Webを支える技術」を参考にさせていただきました。 URI設計やステータスコードJSON、HTTPメソッドの使い分けなど基礎的な情報が網羅されているので、特にWebプログラミングが初めてのエンジニアさんにおすすめの一冊です。

意外と簡単。HTML5のデスクトップ通知を実装してみる

こんにちは、MUGENUPの倉成です。 最近はWebアプリでもデスクトップ通知が出来るものが増えていますよね。 今日はそんなデスクトップ通知の実装を取り上げてみようと思います。

便利なライブラリ

デスクトップ通知はブラウザによって実装が異なり、各ブラウザの対応は手間がかかるので、今回はクロスブラウザ対応を簡単にできるHTML5-Desktop-Notificationsを使います。 他のデスクトップ通知のライブラリにはnotifyもあり、こちらもHTML5-Desktop-Notificationsと同じくらいのStarが付いているようです。

使い方

さて、ここからはHTML5-Desktop-Notificationsの使い方をREADMEにそって

  1. ブラウザ対応状況の確認
  2. ユーザーに通知の許可を求める
  3. 通知を発行

の3段階で説明していきたいと思います。

なお、本記事はこのコミット時のコードを対象にしており、今後の開発により変更が発生する場合があります。

また、HTML5-Desktop-NotificationsのサンプルコードはAngular.jsベースとなっており、馴染みのない方も多いと思うので、gistにサンプルコードを書いてみました、こちらも合わせて見ていただければと思います。

https://gist.github.com/kuranari-tm/e8d8b6411b90da10910e

Step1(ブラウザが通知に対応しているかチェック)

まずは以下のコードでブラウザがデスクトップ通知に対応しているか確認しましょう。 コードは

notify.isSupported // ブラウザが対応していればtrue, そうでなければfalse

です。なおnotifyHTML5-Desktop-Notificationsで定義されているグローバル変数です。

Step2(ユーザーに通知を求める)

デスクトップ通知は、ユーザーから通知の許可をもらわなければ通知が発行できません。 次は、notify.permissionLevel()ドメインに対する通知の許可状況をチェックします。

許可・拒否の状態は

notify.PERMISSION_DEFAULT // 通知が許可されていない
notify.PERMISSION_GRANTED // 通知が許可されている
notify.PERMISSION_DENIED  // 通知が拒否されている

の3状態で、PERMISSION_DEFAULTとなっている場合は

notify.requestPermission()

で、ユーザーから通知の許可をもらいましょう。

f:id:mgnup:20140820000117p:plain

DEFAULTDENIEDの違いですが、notify.requestPermission()を実行時にDEFAULTでは上のような「デスクトップ通知の表示を許可しますか?」のメッセージが表示されますが、DENIEDではこのメッセージは表示されず、ユーザーがブラウザの設定を変更するまでは通知機能を使用することが出来ません。

その場合、例えばChromeでは以下の箇所からドメインに対する通知の許可をユーザーに行ってもらわなければなりません。

f:id:mgnup:20140820000105p:plain

通知の設定はドメイン単位で保存されるため、初回一度だけ許可をもらえば、その後は通知を自由に発行することが出来ます*1

Step3(通知を発行)

さて、Step2までで設定が終了したので、notify.createNotificationで通知を発行します。

notify.createNotification(String title [, Object options])

この関数を実行すると、MacChrome(version.36)では以下のような表示がされます。

f:id:mgnup:20140820000110p:plain

optionsに渡せるパラメータは

body, icon, tag, timeout

の4つです。

第一引数のtitle、そしてオプションのbody,iconは上の通知画像を見れば、大体どこに対応するか分かるかと思います。ただし、iconが必須パラメータになっていることには注意しましょう。

tagはユニークな値を設定することで、複数のタブでページを開いていた場合に、開いているタブと同じ数の通知が発行されることを防ぐことが出来ます。

timeoutはREADMEには通知が閉じるまでの時間を設定できると書いてありますが、README通りに設定を行っても求める挙動が実現できません。 一定時間経過後に自動で閉じる設定をするには

notify.config({autoClose: 1000}); // 1000[ミリ秒後]に通知を閉じる

とする必要があります。issueに上がっているようにみえるのですが、ちょっとハマりどころです。

なお、通知をクリックした時に何らかの処理をしたいといった処理は本家では実装されていませんが、Fork先では実装されているものもあるようなので、必要があればこちらを参考に機能を追加しても良いかもしれません。

終わりに

gistのサンプルコードでもjsは30行程度で、とても簡単にデスクトップ通知(しかもクロスブラウザ対応まで)を実装することが出来ました。

Webサービスにデスクトップ通知があることで使い勝手が格段に良くなるケースも少なく無いと思うので、意外と簡単に実装できてしまうデスクトップ通知機能、ぜひ一度使ってみてください!

おまけ:ローカルで通知が表示されない場合

セキリティ設定の影響でChromeなど幾つかのブラウザではサーバーを通さないローカルのjsが動作しないことがあります。 その場合は以下のサイトを参考に

$ python -m SimpleHTTPServer 8080

コマンド1つで今すぐWebサーバを起動させるためのワンライナー(Ruby or Python) - 元RX-7乗りの適当な日々

とし、ブラウザからlocalhost:8080にアクセスすると、簡単にサーバー経由で動作を確認することが出来ます。

*1:逆に、一度でもDENIEDの状態にされるとブラウザの設定から許可状態にしてもらわなければならないので、やや面倒なことになります。

【Rails】after_createが発動するタイミングはいつでしょう?

MUGENUPの倉成です。 今回はRailsのCallbackであるafter_createafter_commitの処理順番を改めて確認し、処理順番を誤解していた事によって僕が遭遇した問題ついて記事を書こうと思います。

シチュエーション

ブログ記事の新規投稿があった時、購読者に対してメールを送信する。

ブログの投稿時に通知のメールを送信するような場合、記事の投稿に必要な最低限の処理のみを行い、購読者へのメール送信などリアルタイムな処理が必要でないものは非同期で処理することで、レスポンス速度を向上させることが出来ます。

f:id:mgnup:20140804095733j:plain

非同期処理を行うためのライブラリとしてはresquesidekiqdelayed_jobなど幾つかのものがありますが、弊社ではこの用途にresqueを使っているので、この記事では特にresqueを取り上げて説明します*1

実装

さて、上の要件の実装として、僕は以下のようなコードを書きました。

# app/models/article.rb
class Article < ActiveRecord::Base
  # 記事が作成されたらメール送信タスクをqueueに積む
  after_create do
    Resque.enqueue(SendMail, id)
  end
end
# app/workers/send_mail.rb
class SendMail
  @queue = :send_mail
  def self.perform(article_id)
    # 投稿された記事をDBから読み込む
    article = Article.find(article_id)
    # ブログの購読者にメールを送信
    # ....
    # ....
  end
end

self.perform(article_id)の第一引数article_idResque.enqueue(SendMail, id)の第二引数idと対応しており、 新規記事のIDが渡されることになります。

さて、実際にアプリケーションを立ち上げて実行してみるとself.perform内部で

article = Article.find(article_id)で稀にNoRecordFoundが発生する

という現象が起きていました。

after_createという名のとおり記事は既にcreateされていて、レコードは存在しているはずです。その証拠に既にidは与えられていますよね。

なぜでしょう。

after_createはDBへのコミット直前に実行される

不思議に思い、出力されたログを見てみると、ResqueからのSELECTBEGIN ~ COMMITの内部で実行されていました。

DBはINSERTを実行しても、COMMITされるまでは、レコードは永続化されません。 つまりRailsがDBにCOMMITする前に、外部プロセスであるResqueのプロセスがSELECTをしていたため、NoRecordFoundが発生していまうという状態になっていました。

BEGIN
  INSERT INTO `articles` (`col1`, `col2`) VALUES ('..', '..')
  /* COMMIT前にResqueからのSELECTが発行される */
  SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1
COMMIT

これは、after_createがDBへのコミット直前に実行されることが原因です*2

after_commitを使う

原因がわかりました。 after_createの代わりに、コミット直後に実行されるafter_commit on: :createを使いましょう。

これでCOMMITのあとにResqueの処理が実行されるため、NoRecordFoundが発生することもなくなりました。

BEGIN
  INSERT INTO `articles` (`col1`, `col2`) VALUES ('..', '..')
COMMIT
/* COMMIT後にResqueからのSELECTが発行される */
SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1

BEGIN ~ COMMITの外側でSELECTが実行されていることが、ログでも確認できます。

NoRecordFoundの原因は

稀にNoRecordFoundが発生する状態だったのは「COMMITの前に外部プロセスであるResqueのSELECTが発生するケースがあったため」というのが結論です。なお常に発生するでは無いのは、Resqueが新規ジョブをポーリングする間隔により、Resqueの処理がCOMMIT後に行われることもあったためです。

今回のようにRailscallbackをトリガーにして外部プロセスからDBにアクセスする場合はafter_commitを使うようにしましょう。

なお,同様の注意点は http://apidock.com/rails/ActiveRecord/Callbacks/after_createにも書いてありました*3

おわりに

今回は「after_createはDBにCOMMITされた後に呼び出される」という間違った認識によって遭遇した問題について記事を書かせていただきました。 ResqueなどRails外のプロセスを使う場合にこの記事を思い出していただけたら幸いです。

*1:今回は説明のため使用しませんがメール送信を非同期で行うならresque_mailerが便利です。

*2:コールバックの順番はこちらの記事にとても詳しくまとまっています http://techracho.bpsinc.jp/baba/2013_08_23/12670

*3:Rails 2.3.8のドキュメントですが,この注意点に関しては今のバージョンでも変わらずに使えると思います