- 投稿日:2019-12-15T23:56:23+09:00
Railsのバリデーションの仕組みを知らなかったので調べてみました
こちらはLinc'well Advent Calendar 2019の14日目の記事となります。
タイトルの通りですが、これまでRailsを数カ月間使用してきて、
Modelで何十回もバリデーションを定義したり、
create!したらバリデーションに引っかかってActiveRecord::RecordInvalid
例外を起こしたり、
何十回もvalid?
メソッドを呼び出したりとしてきたのに、そういえばvalidationの仕組みを何も知らないな。と思ったので、
バリデーション機能をコードを追いながら調べてみて、その過程をまとめてみました。間違い等見つけていただけましたら、コメント欄でご指摘いただけたら嬉しいです!
また、文中のRailsのコードは
tag v6.0.0
のものです。調べた中での一番の学び
いきなりですが、今回調べてみての一番の学びを先に述べておくと、下記の事柄でした。
- Railsのバリデーション機能は、コールバックを利用して実現されている。
バリデーションと呼ばれる機能の構成
今回調べてみるに当たって、分けたほうが調べやすいと思ったので、
Railsのバリデーション機能を構成しているものを大きく以下の2つに分けてみました。
- Modelに
validates :name, presence: true
のような文言を記述すると、バリデーションを定義できる。1- 1で定義されたバリデーションに応じて、オブジェクトがデータベースに保存されるかが決まる。 (
save
やcreate
をするときに、バリデーションに引っかかると保存されない。)便宜上、以下では1を『バリデーション定義』、2を『バリデーション実行』という言葉で表現しています。
『バリデーション定義』のざっくり概要
そもそもバリデーションとしてModelに書いている
validates
は何なのかというところですが、
これはクラスメソッドにあたります。
(カスタムバリデーションの定義で使うvalidate
やvalidate_with
も同様です。)私自身、正直
validates
がクラスメソッドであることは意識することはあまりなく、なんとなく使っていますが、
Modelの定義でよく目にする、attr_accessor
やhas_many
も同様にクラスメソッドです。
(参考: Railsによるメタプログラミング入門 前田 修吾さん)また、このようなclass定義の直下で呼び出されるクラスメソッドを、書籍『メタプログラミングRuby 第2版』では『クラスマクロ』と紹介しています。
ということで、
validates
というクラスメソッドを探して、内容を確認すればどのようにバリデーションが定義されるかが分かるということになります。『バリデーション実行』のざっくり概要
実際に、
create
のコードを確認してみると、以下の様にsave
が呼ばれ、そのsave
の中でperform_validations
というバリデーションらしきものが行われていることが分かります。(ActiveRecordのどこに何が定義されているのかなどは、下記の書籍を参考にさせていただいて探しました。
参考: ActiveRecord完全に理解した kinoppydさん)active_record/persistence.rbdef create(attributes = nil, &block) if attributes.is_a?(Array) attributes.collect { |attr| create(attr, &block) } else object = new(attributes, &block) object.save object end endactive_record/validations.rb# The regular {ActiveRecord::Base#save}[rdoc-ref:Persistence#save] method is replaced # with this when the validations module is mixed in, which it is by default. def save(**options) perform_validations(options) ? super : false end ~省略~ def perform_validations(options = {}) options[:validate] == false || valid?(options[:context]) endそして、この
perform_validations
ではvalidateを実行しないというオプションがない場合に、valid?
を呼びだしており、(Railsガイドでは『バリデーションをトリガする』と表現されていました。)
バリデーション実行の起点となるものはvalid?
であることが窺えます。上記を踏まえると、超ざっくりですがバリデーション実行とは、
valid?
がtrueを返すか、falseを返すかによって、データベースにオブジェクトを保存するかどうかを決めることだと言い換えることができます。順番が前後してしまいますが、先にバリデーション実行の詳細を追ってみた過程を記述していきます。
valid?
から始まる 『バリデーション実行』の処理の詳細を追ってみる上で出てきた
active_record/validations.rb
でvalid?
を探して見てみると下記のようになっています。
context ||= default_validation_context
は、このvalid?
の呼び出し元の処理がcreateとupdateのどちらによるものかという情報であるcontextを定義しているもので、contextを引数に取る
super
を呼び出していることから、valid?
をオーバーライドしていることが分かります。active_record/validations.rbdef valid?(context = nil) context ||= default_validation_context output = super(context) errors.empty? && output endオーバーライドされている
valid?
を探してみると、ActiveRecord::Validations
がincludeしているActiveModel::Validations
というモジュールに、valid?
が定義されているのが見つかります。active_model/validations.rbdef valid?(context = nil) current_context, self.validation_context = validation_context, context errors.clear run_validations! ensure self.validation_context = current_context end(どうして2つのValidationsというモジュールが存在するのかに関して、書籍『メタプログラミングRuby 第2版』の『9.2.3 Validations モジュール』において言及されていました。
元々はActiveRecord::Validations
のみしか存在せず、後から機能がActiveModel::Validations
に分割された経緯や理由が書かれており面白かったです。)そして、この
valid?
を見ると、明らかにバリデーションを実行していそうなrun_validations!
というメソッドの呼び出し箇所があります。
_run_validate_callbacks
はひとまず置いて、
errors.empty?
を見るとインスタンス変数の@errors
の中身の存在確認をしているだけのメソッドであることから、
_run_validate_callbacks
がバリデーションっぽいことをして、@errors
に値を入れるのだなと予測できました。active_model/validations.rbdef run_validations! _run_validate_callbacks errors.empty? endそこで、この
_run_validate_callbacks
を見に行こうと思ったのですが、
コードジャンプしても全文検索しても見つからなかったため、
動的に定義されるメソッドであると推測されました。が、それらしきものを発見することができず、ググったところ下記の記事が見つかりました。
(参考: Railsのvalidationの実行過程を調べる2)上記の記事を引用させていただくと、この
_run_validate_callbacks
は、ざっくりまとめると、当該モデルに設定されたcallbacksの内、 :validateのキーに引っかかるcallbacksを実行していっていると言うことになります。
とのことでした。
この時点では、『:validateのキーに引っかかるcallbacks』というものが一体何なのか知らなかったのですが、
ここまでのバリデーション実行の流れをざっくりまとめると、下のようになりました。
- オブジェクトを
save
する際に、valid?
が呼ばれる。valid?
から始まる処理の中で、_run_validate_callbacks
が実行されることで『:validateのキーに引っかかるcallbacks』というものが実行され、@errors
に何かしらの値が入る(これはこの時点では予想ですが、カスタムバリデーションを定義する時に、record.errors.add
のように@errors
に値を入れることからも予想ができます。)@errors
の中身が空であるかを確認し、空であればtrueを、そうでなければfalseを返し、それによってvalid?
の戻り値が決まる。valid?
の戻り値がtrueなら、オブジェクトを保存する処理が続けられ、falseなら保存する処理が中止される。(『:validateのキーに引っかかるcallbacks』が、Modelに定義されているコールバックの中で、
:validate
キーに対応するActiveSupport::Callbacks::CallbackChain
を指しているということが後で分かりました。)『バリデーション定義』の処理の詳細を追ってみる
バリデーション実行の詳細がある程度分かったことから、
あとは、バリデーション定義の処理の中で、『:validateのキーに引っかかるcallbacks』が定義されているところを見つけることができれば、
バリデーションの機能の全体感を把握することができるので、そこをゴールとして進んでいきます。
validates
がクラスの読み込み時に実行されるところから定義の処理は始まります。処理の中身としては、受け取った引数を(
[:name, :presence: true]
みたいな)、
良い感じに加工し、最終的にvalidates_with
を呼び出すものです。軽く流れを追うと、ブロック引数の
key
には:presence
や:length
などが入り、key = "#{key.to_s.camelize}Validator"
では、"PresenceValidator"といった文字列が作られます。そして、その文字列から
validator = key.include?("::") ? key.constantize : const_get(key)
でクラス名の定数を取得し、validates_with
に引数で渡しています。
(activemodel/lib/active_model/validations
配下に、LengthValidator
、InclusionValidator
などのバリデーションの種類に対応したValidatorクラスがそれぞれ用意されています。)最後の
validates_with
の呼び出しは、カスタムバリデータを使ったバリデーションの定義と同じ形になります。active_model/validations/validates.rbdef validates(*attributes) defaults = attributes.extract_options!.dup validations = defaults.slice!(*_validates_default_keys) raise ArgumentError, "You need to supply at least one attribute" if attributes.empty? raise ArgumentError, "You need to supply at least one validation" if validations.empty? defaults[:attributes] = attributes validations.each do |key, options| key = "#{key.to_s.camelize}Validator" begin validator = key.include?("::") ? key.constantize : const_get(key) rescue NameError raise ArgumentError, "Unknown validator: '#{key}'" end next unless options validates_with(validator, defaults.merge(_parse_validates_options(options))) end endそして、呼び出された
validates_with
が何をしているかというと、引数として渡されたValidatorクラス名を使って、インスタンスを作り、最後にvalidate
メソッド呼び出しの引数に渡しています。この
validate
はカスタムメソッドを使ったバリデーションを定義するときに使うものと同じです。
(validate :expiration_date_cannot_be_in_the_past
みたいに使うやつです。)active_model/validations/with.rbdef validates_with(*args, &block) options = args.extract_options! options[:class] = self args.each do |klass| validator = klass.new(options, &block) if validator.respond_to?(:attributes) && !validator.attributes.empty? validator.attributes.each do |attribute| _validators[attribute.to_sym] << validator end else _validators[nil] << validator end validate(validator, options) end end end続けて、
validate
を見ていきます。すると、
set_callback(:validate, *args, options, &block)
という、いかにも、『:validateのキーに引っかかるcallbacks』に関係していそうなメソッドを呼び出しているのが発見でき、さらに、同じファイルに
define_callbacks :validate, scope: :name
という、これまた関係していそうなメソッドがincluded
の中で呼ばれているのが見つかります。active_model/validations.rbdefine_callbacks :validate, scope: :name_badge: ~省略~ def validate(*args, &block) options = args.extract_options! if args.all? { |arg| arg.is_a?(Symbol) } options.each_key do |k| unless VALID_OPTIONS_FOR_VALIDATE.include?(k) raise ArgumentError.new("Unknown key: #{k.inspect}. Valid keys are: #{VALID_OPTIONS_FOR_VALIDATE.map(&:inspect).join(', ')}. Perhaps you meant to call `validates` instead of `validate`?") end end end if options.key?(:on) options = options.dup options[:on] = Array(options[:on]) options[:if] = Array(options[:if]) options[:if].unshift ->(o) { !(options[:on] & Array(o.validation_context)).empty? } end set_callback(:validate, *args, options, &block) end見つけたは良いものの、
define_callbacks
とset_callback
が何なのか知らなかったので、ググってみると下記の記事が見つかりとても勉強になりました。(参考: ActiveModelでコールバックを使いたい時のまとめ - Qiita | Issus(イシューズ)
https://issus.me/projects/34/issues/71)
(参考: railsのbefore_actionはどうやってうごいているか)上記記事を参考にざっくりまとめると、
define_callbacks :validate
で:validate
というnameを持つ一連のコールバックをまとめるCallbackChain
インスタンスを作成し、
(コード中のコメントでは、sets of events in the object life cycle that support callbacksと表現されてました。)
set_callback(:validate, *args, options, &block)
で、
先ほど定義した:validate
という一連のコールバックの一つとして実行する処理として、引数で渡されたものを設定しています。これで
_run_validate_callbacks
が具体的に何をするのかに繋がりました。
set_callback(:validate, *args, options, &block)
で:validate
というキーに引っかかる様にセットされた処理を実行していくということになります。
(また、複数形であることからも、複数のバリデーションがある場合は、複数回set_callback
が呼ばれることもあるだろうと予想できます。)
set_callback(:validate, *args, options, &block)
でコールバックの一つとして定義された処理こそがバリデーションの中身ということなります。ということで次は、
set_callback(:validate, *args, options, &block)
がどのようなcallbackを定義するのかを追っていきます。active_support/callbacks.rbdef set_callback(name, *filter_list, &block) type, filters, options = normalize_callback_params(filter_list, block) self_chain = get_callbacks name mapped = filters.map do |filter| Callback.build(self_chain, filter, type, options) end __update_callbacks(name) do |target, chain| options[:prepend] ? chain.prepend(*mapped) : chain.append(*mapped) target.set_callbacks name, chain end endCallback.buildで作られる
ActiveSupport::Callbacks::Callback
インスタンスの一例としては、下記のようなものです。
@kind
には:beforeや:afterなどの実行のタイミングを表す値が入り、指定がなかった場合は:beforeが入ります。
@filter
には、引数で渡されたValidatorインスタンスが入り、これがコールバックで実行する処理の元となるっぽいです。そして、それらを最後に
__update_callbacks
で一連のコールバック処理(chain)に追加しています。#<ActiveSupport::Callbacks::Callback:0x0000565553a47960 @chain_config={:scope=>:name, :terminator=>#<Proc:0x0000565549744b50@/usr/local/bundle/gems/activesupport-5.2.2.1/lib/active_support/callbacks.rb:603>}, @filter=#<ActiveRecord::Validations::PresenceValidator:0x0000565553a47cd0 @attributes=[:user], @options={:message=>:required}>, @if=[], @key=#<ActiveRecord::Validations::PresenceValidator:0x0000565553a47cd0 @attributes=[:user], @options={:message=>:required}>, @kind=:before, @name=:validate, @unless=[]>あとは、
ActiveSupport::Callbacks::
が、どのようにActiveModel::Validations::
配下のValidatorクラスのインスタンスを元にして、コールバックの処理を定義・実行しているのかを追えば、恐らく終わりというところまで来ました。ところが、そのコールバックの
ActiveSupport::Callbacks
の仕組みを追っていったところ、
とても難しく、本記事にうまくまとめられなそうだったため、また別の記事として調べようと思います。(もしくはこの記事に追記します。)(完全に僕の実力不足でした。ここが一番気になるところなのにすみません。。)
ということで、一旦ここまでのバリデーション定義の流れをざっくりまとめると、下記のようになりました。
- クラスの読み込み時に、
validates
といったバリデーション定義のクラスメソッドが実行される。validates
→validates_with
(カスタムバリデータを用いたバリデーション定義で使うやつ)→validate
(カスタムメソッドを用いたバリデーション定義で使うやつ)の順でメソッドが呼ばれる。validate
では、バリデーションの種類(:presenceとか:length)に応じて、対応するValidatorクラスのインスタンスを生成し、それを引数に含めてset_callback
メソッドを呼び出す。set_callback
メソッドでは、引数で受け取ったValidatorを使用しながら、バリデーションを実行する処理を含むコールバックを定義する。(そして、このコールバックはvalid?
が呼ばれると実行されることになる。)感想
色々と知らなかったことを知ることができ、とても勉強になりました。
余談として、
attr_accessor
といったアクセサや、has_many
といったアソシエーションも同じクラスマクロとして調べる対象の候補だったのですが、
『ActiveRecord完全に理解した』にて、前者は『すごく長い』、後者は『難しい』と紹介されていたので、特に何も書かれていなかったValidationsを今回は選択してみました。勉強になったので、今度は他のクラスマクロも同様に調べてみようと思います。
(その前にCallbackとValidatorをきちんと調べてから。)参考文献
下記の文献を見て色々と勉強させていただきながら、この記事を書いてみました。
どれも勉強になりました。
Paolo Perrotta(著),角 征典(翻訳)『メタプログラミングRuby 第2版』 オライリージャパン
kinoppyd 著 『ActiveRecord 完全に理解した』技術書典 7(2019年秋)新刊 2019年9⽉22⽇ ver 1.0
- 投稿日:2019-12-15T23:53:29+09:00
ぼくのかんがえたrails new
ここ一年ほど、仕事で自分が新規プロダクトのrails newをすることが多く、そのたびに色々考えながら落ち着いてきた初期設定です。
範囲はrails newを実行してから実アプリケーションコードを書く直前までです。
※ベストプラクティスというよりは自分の好みの側面が強い備忘録的なものとなります。なおリポジトリはこちら https://github.com/cumet04/my-rails-new
ディレクトリを切る
rails new
する前に、まずプロジェクトのディレクトリをざっくり切ります。プロジェクトルートに以下のように切ります:
- backend
- frontend
- infra
- develop
Railsに限らずnodejs系などでもそうですが、近年の開発環境はいわゆるdotfilesが無数にあってわけがわからないため、トップレベルでディレクトリを分けます。
READMEなどを除いてできるだけプロジェクト直下にファイルを置かない想定です。
backend
Railsプロジェクトのルートになるディレクトリです。この下で
rails new
を行います。本記事の内容はこの節以外はほぼこのディレクトリ下の話です。
frontend
nodejsなどのいわゆるfrontend系のコードを置きます。
package.json
がここにある、というようなイメージです。Railsのコードにあまり深く関わってこないため、本記事内やサンプルリポジトリには登場しません。
infra
サーバ構築用のansibleのコードやインフラ用のcloudformationのコードなどを置きます。
デプロイ関連のスクリプトも置いたりします。
develop
開発環境で使うスクリプトやAPIモックなど、プロダクション環境での動作には関係ないものを置きます。
ちょい作業の便利スクリプトや種類の増えたdocker-compose.ymlなどです。
(docker-composeは1つしかなければプロジェクトルートにも置きますが)rails new
ディレクトリを切ったら
backend
下にて適当にrbenvやbundle initなどを行いrails new
します。
ここではオプションを含めコマンドは以下になりました:rails new . \ --skip-action-mailbox \ --skip-action-cable \ --skip-sprockets \ --skip-listen \ --skip-javascript \ --skip-turbolinks \ --skip-webpack-installオプションはアプリケーションの要件やRailsのバージョンによってかなり違うので、都度
rails new --help
とにらめっこして決めます。js系はRailsには触らせない構成なので
javascript
turbolinks
webpack-install
あたりを落としておきます。
Rails5系以前だとcoffee
あたりも落とす必要があったと思います。rails newができたらひとまず生成された
.git
の削除とconfig/application.rb
のapplication名を修正し、そのままgit add .
してcommitします。
あとから初期ファイルのコメント群などをガッツリ消すので、説明コメントなどを見たくなったときにgitの履歴から閲覧できるようにしておきます。
.git
を削除するなら最初から--skip-git
すればいいじゃないと思いますが、初期の.gitignore
が欲しいので作ってから消しています。
慣れれば最初から--skip-git
でよいと思います。不要な初期コメント・ファイルを消す
各種ファイルをガッツリ掃除します。
コメント系
.gitignore
やGemfile
、config/initializers/*
など説明用コメントがたくさんありますが、邪魔なので潔く消します。git履歴に残ってることですし。initializersにはコメントのみのファイルもいくつかあるため、それらはファイルごと消します。
欲しくなったら追加しましょう。credentials.yml.enc / master.key
Rails5.2から追加されたcredentials系の管理機能ですが、個人的には環境変数で差し込む方法に対してメリットが見いだせないので使いません。
credentials.yml.enc
をリポジトリから削除・master.keyをディスクとgitignoreから削除しておけばokです。Gemfile
コメントの他にいくつか使わないものを早めに掃除しておきます。
- byebug -> 自分は使わないのでgroupブロックごと削除(好みです)
group :test
ブロック -> この辺は少なくともRails下のrubyではやらないので消します- tzinfo-data -> まぁwindows上では動かさないですね...
environments設定
config/environments/*
に置いているパラメータ系です。デフォルトでは
environments/
下にある個別のdevelopment.rb
やproduction.rb
に必要な全パラメータが記述されていますが、
これに倣った場合、全環境に全く同じ記述がたくさんあったり環境ごとの差異がわかりにくかったりします。そこで、production環境相当の設定を
config/application.rb
にすべて記述し、developmentなど他の環境についてはproductionとの差分のみ記述することとしました。以下、rails new直後相当の設定を一部整理・移植したものです。
config/application.rb(抜粋)class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 6.0 # rails environments configs config.cache_classes = true config.eager_load = true config.consider_all_requests_local = false config.action_controller.perform_caching = true config.public_file_server.enabled = false config.active_storage.service = :local config.cache_store = :null_store config.action_mailer.perform_caching = false config.log_level = :debug config.log_tags = [ :request_id ] config.logger = ActiveSupport::Logger.new(STDOUT) config.log_formatter = ::Logger::Formatter.new config.active_support.deprecation = :notify config.i18n.fallbacks = true config.active_record.dump_schema_after_migration = false # app configs endconfig/environments/development.rbRails.application.configure do config.cache_classes = false config.eager_load = false config.action_controller.perform_caching = false config.cache_store = :null_store config.public_file_server.enabled = true config.consider_all_requests_local = true config.action_mailer.raise_delivery_errors = false config.action_mailer.perform_caching = false config.active_support.deprecation = :log config.active_record.migration_error = :page_load config.active_record.verbose_query_logs = true config.active_record.dump_schema_after_migration = true endconfig/environments/production.rbRails.application.configure do end外部メールサーバやredisのセッション/キャッシュストアなどを使う場合はそれらのホスト名などが環境によって異なってきますが、それらは環境変数で定義して
application.rb
内でENV.fetch
などで読み込むようにしておくとスマートです。以下、しれっとデフォルトから変更している部分です。
cache_store / public_file_server.enabled
デフォルトではファイルの存在や環境変数でトグルするようになっていますが、運用上は環境によって設定が確定しているはずなので決め打ちにしておきます。
cacheについてはあとでredisなど導入する想定で一旦
:null_store
にしています。ログ出力
アプリケーションログについては環境によらず標準出力に出すようにします。
開発環境では
rails server
やdockerのため標準出力が良いですし、productionやstaging環境などにおいてもpumaと別個でログが出る必要がないのでひとまとめにしておきます。
サーバ上のログファイルの管理をlogrotateで一括でやりたいということもありますし、dockerで運用する場合でもstdout/stderrの二種類のみになっているほうが都合が良いです。その他ミドルウェアなど
要件によって必要・不要などありますが、ミドルウェア類とデバッグ系ツールを入れます。
デバッグ系
better_errorsを入れたいので、Gemfileのdevelopment groupに
gem "better_errors" gem "binding_of_caller"を入れます。
またconfig/environments/development.rb
にBetterErrors::Middleware.allow_ip! "0.0.0.0/0"を追加しておきます。これが無いとdocker環境などでbetter_errorsが表示されません。
また他にも好みで
pry-rails
を入れたりします。mysql
Railsプロジェクトだとだいたい何かしらのDBを使いますし、自分の場合はmysqlを使うことが多いです。
Gemfileにてsqlite3
を削除、mysql2
と(development groupに)annotate
を入れます。development環境でもdockerなりローカルマシンなりにmariadbを用意すればいいのでsqlite3は消します。
またannotateを入れるのであればrails generate annotate:install
しておきます。
config/database.yml
は前述のenvironmentsの方針に則り以下のようになります:config/database.ymldefault: &default adapter: mysql2 encoding: utf8 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 host: <%= ENV.fetch('RAILS_DB_HOST') %> port: <%= ENV.fetch('RAILS_DB_PORT', 3306) %> database: <%= ENV.fetch('RAILS_DB_DATABASE') %> username: <%= ENV.fetch('RAILS_DB_USERNAME') %> password: <%= ENV.fetch('RAILS_DB_PASSWORD') %> development: <<: *default production: <<: *defaultスッキリです。実際にはconnection poolの数くらいは別個で指定します。
redis session store
セッションをredisで持ちます。
Rails5系以前はredis-railsを使っていましたが、Rails6以降はredis-actionpackをそのまま使えば良い1ようです。
Gemfileに
redis-actionpack
を足し、config/application.rb
にsession_storeの設定を入れます:config/application.rbconfig.session_store( :redis_store, servers: { host: ENV.fetch("RAILS_REDIS_HOST"), port: ENV.fetch("RAILS_REDIS_PORT", 6379), namespace: "sessions", }, expire_in: 3.days, secure: true, )また開発環境はhttpsではないので忘れずに以下も入れておきます:
config/environments/development.rbconfig.session_options[:secure] = falseまとめ
rubyを用意して
rails new
するだけかと思いきや、アプリケーションコードを書くまでに考えるべき共通事項は案外多いです。これは自分のよくある環境での一つの解でしかなく、開発体制やアプリケーションの規模、全体アーキテクチャによってもかなり変わってくると思いますが、考え方などなにか参考になればと思います。
- 投稿日:2019-12-15T22:22:08+09:00
Rails 6 ActiveStorageを使用してS3に画像ファイルをアップロードし、取得した画像をリサイズして表示する方法
はじめまして!スタートアップでサーバーサイドエンジニアをやっています。なかのです!
TechTrain Advent Calendar 2019(User ver)の15日目を担当します!
今回はタイトルの通り、Rails 6 ActiveStorageを使用してS3に画像ファイルをアップロードし、取得した画像をリサイズして表示する方法をお話ししたいと思います。はじめに
まず、簡単にActiveStorageについて説明しますと
Active StorageとはAmazon S3、Google Cloud Storage、Microsoft Azure Storageなどの クラウドストレージサービスへのファイルのアップロードや、ファイルをActive Recordオブジェクトにアタッチする機能を提供します。development環境とtest環境向けのローカルディスクベースのサービスを利用できるようになっており、ファイルを下位のサービスにミラーリングしてバックアップや移行に用いることもできます。
アプリケーションでActive Storageを用いることで、ImageMagickで画像のアップロードを変換したり、 PDFやビデオなどの非画像アップロードの画像表現を生成したり、任意のファイルからメタデータを抽出したりできます。(https://railsguides.jp/active_storage_overview.html 参照)
開発環境は以下になります。
Ruby '2.6.5' Rails '6.0.2'準備
今回はUserに紐づく画像ファイルをアップロードしたいと思いますので、scaffoldを用いてさくっと必要な部分を作ります!
$ rails new activerecord-sample
$ cd activerecord-sample
$ rails generate scaffold user name:string
$ rails db:migrate
$ rails s
rails s
でサーバーを立ち上げ、localhost:3000/users
にアクセスし、下の画像のような画面が表示されたらセットアップ完了です。S3にバケットを作成する
Amazon Simple Storage Service(Amazon S3)は、インターネット用のストレージサービスで、データ (写真、動画、ドキュメントなど)を保存しておくために利用されます。使用するためには事前にバケットを作成しておく必要があります。
S3のセットアップは以下の記事を参考にしてみてください。
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/user-guide/create-configure-bucket.html
今回は以下のような設定でバケットを作成しました。
項目 入力・選択 バケット名 activestorage-sample-bucket リージョン ap-northeast-1 (東京) パブリックアクセス許可を管理する このバケットに読み取り・書き込みアクセス権限をする 上記以外 全部デフォルトのまま S3でアクセスキーを作る。
アクセスキーを作る方法は、こちらを参考にしてみてください。
https://tech-blog.s-yoshiki.com/2019/06/1292/IAMユーザー作成後に表示された「アクセスキー ID」と「シークレットアクセスキー」 は後ほど使用しますので誰にも教えないように保管しておいてください。
ActiveStorageの導入
ここから実際にActiveStorageを導入していきたいと思います。
以下のコマンド実行してActiveStorageをinstallしてください。
$ rails active_storage:install $ rails db:migrate
active_storage_attachments
とactive_storage_blobs
というテーブルが作成されていれば、インストール成功です。ActiveStorageは、初期設定ではDisk内(
storage以下
)にアップロードしたファイルデータを保存するようになっているため、amazon s3のストレージを使用する記述を追加します。まずは使用するストレージの設定(今回だとamazon s3)を以下のファイルに追加してください。
concig/storage.ymltest: service: Disk root: <%= Rails.root.join("tmp/storage") %> local: service: Disk root: <%= Rails.root.join("storage") %> amazon: service: S3 access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> region: <%= Rails.application.credentials.dig(:aws, :s3, :region) %> bucket: <%= Rails.application.credentials.dig(:aws, :s3, :bucket) %>次に利用するサービスをActiveStorageに認識させます。
config/environments/development.rb#ファイルをAmazon S3に保存する config.active_storage.service = :amazonS3のためのgemが必要なので、
Gemfile
に以下の記述を追加してbundle installしてください。gem "aws-sdk-s3", require: false$ bundle install最後にS3の環境設定を追加します。
Rails6 から各環境でcredentialsの管理が出来るようになったので、以下のコマンドを入力し、development環境でのcredentialsファイルを作成して、S3の設定を追加してください。$ ./bin/rails credentials:edit --environment developmentこちらのコマンドを入力した際、もしcredentialsが作成されていなかったら新たに
config/credentials
以下にdevelopment.yml.enc
とdevelopment.key
というファイルを作成してくれます。以下のように編集してください。
development.yml.encaws: access_key_id: #先ほど取得したaccess_key_idをいれてください。 secret_access_key: #先ほど取得したsecret_access_keyをいれてください。 s3: region: ap-northeast-1 bucket: activestorage-sample-bucket編集が完了したら、値が取得出来ているか確認してみましょう。
$ rails c irb(main):001:0> Rails.application.credentials.dig(:aws, :access_key_id) => "設定したaccess_key_id" irb(main):002:0> Rails.application.credentials.dig(:aws, :secret_access_key) => "設定したsecret_access_key" irb(main):003:0> Rails.application.credentials.dig(:aws, :s3, :region) => "ap-northeast-1" irb(main):004:0> Rails.application.credentials.dig(:aws, :s3, :bucket) => "activestorage-sample-bucket"値がちゃんと取れていれば、設定完了です。
ActiveRecordの実装
UserとAttachmentとBlobの関係を以下の図に表します。
Userモデルに1つのファイルを紐づける場合
1ユーザーに1つの画像ファイルしか紐づかない場合、上記の図のNが1になります。まずはこのパターンを実装していきたいと思います。
app/models/user.rbclass User < ApplicationRecord has_one_attached :avatar endUserモデルにavatarという属性を追加しました。今回はavatarという命名にしましたが、用途に合わせて自由に指定することが出来ます。
ActiveStorageでは、ファイルデータを保存するためにそれぞれのテーブルに個別にカラムを用意しなくても上記の記述を追加するだけでactive_storage_attachments
とactive_storage_blobs
が裏側でよしなに処理してくれます。また、今回は1つのファイルを添付するためhas_one_attached
という記述をしています。画像を投稿出来るようにフォームを追加します。
app/views/users/_form.html.erb
のブロック内に以下の記述を追加してください。app/views/users/_form.html.erb<div class="field"> <%= form.label :name %> <%= form.text_field :name %> </div> # 以下の部分を追加 <div class="field"> <%= form.file_field :avatar %> </div>ストロングパラメーターもavatarを許可するようにします。
app/controllers/users_controller.rbdef user_params params.require(:user).permit(:name, :avatar) end最後にユーザー詳細画面で画像が表示されるようにしましょう。
app/views/users/show.html.erb<p> <strong>Name:</strong> <%= @user.name %> # 以下を追記 <%= image_tag url_for(@user.avatar) %> </p>Userモデルに複数のファイルを紐づける場合
次は複数投稿投稿出来るようにしてみますz。
app/models/user.rbclass User < ApplicationRecord has_many_attached :images end今度はUserモデルにimagesという属性を追加しました。
userに複数の画像を紐づけるためにhas_many_attached
を使用しています。画像を複数投稿出来るようにフォームを追加します。
app/views/users/_form.html.erb
のブロック内に以下の記述を追加してください。app/views/users/_form.html.erb<div class="field"> <%= form.label :name %> <%= form.text_field :name %> </div> # 以下の部分を追加 <div class="field"> <%= form.file_field :images, multiple: true %> </div>
multiple: true
にすることで画像を一度に複数選択出来るようになります。ストロングパラメーターもimagesを許可するようにします。
app/controllers/users_controller.rbdef user_params params.require(:user).permit(:name, images: []) end最後に、ユーザー詳細画面で画像が表示されるようにします。
app/views/users/show.html.erb<p> <strong>Name:</strong> <%= @user.name %> # 以下を追記 <% @user.images.each do |image| %> <%= image_tag url_for(image) %> <% end %> </p>これで複数投稿が出来るようになったので、
rails s
をして試してみましょう!
image_processingを用いたリサイズ
画像投稿した後は、画面サイズに合わせて画像を取得したくなりますよね。Rails6から
image_processing
というgemを使用することが推奨されているため、そちらを使用してリサイズを行っていきます。
Gemfile
にimage_processing
がコメントアウトされていると思いますので、アンコメントしてbundle install します。gem 'image_processing', '~> 1.2'また、ImageMagickをinstallする必要がありますので、Mac OSXを使用している方は
brew install imagemagick
でinstallしてください。これで設定は完了です。
リサイズして表示するように設定していきます。variantメソッドを用いることで簡単にリサイズを行うことが出来ます。先ほど、複数投稿時に使用したviewに追加して、表示を見てみます。
app/views/users/show.html.erb<% @user.images.each do |image| %> <%= image_tag image.variant(resize_to_limit: [100, 100]) %> <% end %>簡単にリサイズされましたね。
おまけ
ActiveStorageでよく使用するメソッドやValidationについて、軽く書いておきたいと思います。
まず、よく使用するメソッド
attached?
メソッド
添付ファイルを持っているかどうかを調べます。irb(main):001:0> user = User.last => #<User id: 20, name: "なかの", created_at: "2019-12-15 11:24:56", updated_at: "2019-12-15 11:24:56"> irb(main):002:0> user.images.attached? => true irb(main):007:0> user2 = User.new => #<User id: nil, name: nil, created_at: nil, updated_at: nil> irb(main):008:0> user2.images.attached? => false
purge
メソッド
Attach、Blob、S3から添付ファイルを削除します。irb(main):009:0> user = User.last => #<User id: 20, name: "なかの", created_at: "2019-12-15 11:24:56", updated_at: "2019-12-15 11:24:56"> irb(main):010:0> user.images.attached? => true irb(main):011:0> user.images.purge => #<ActiveRecord::Associations::CollectionProxy []> irb(main):012:0> user.images.attached? => falseこちらのメソッドはRollbackが起きた際にS3とデータの不整合が起こりやすいため、使用時は注意しなくてはいけません。ActiveRecordのトランザクションとActiveStorageについての話は、こちらの記事がとてもわかりやすいのでおすすめです。
https://tech.smarthr.jp/entry/2018/09/14/130139
detach
メソッド
Attachから添付ファイルのレコードを削除します。irb(main):001:0> user = User.last => #<User id: 21, name: "なかの", created_at: "2019-12-15 12:26:06", updated_at: "2019-12-15 12:26:06"> irb(main):002:0> user.images.attached? => true irb(main):003:0> user.images.detach irb(main):004:0> user.images.attached? => falseActiveStorageでは専用のValidationが用意されていないため、各自で作成しなくてはいけません。
簡単にContent_Typeを確認するValidationを作ってみました。app/models/user.rbclass User < ApplicationRecord has_one_attached :avatar validate :validate_avatar def validate_avatar errors.add(:avatar, "画像データではありません。") unless image? end def image? return '' unless avatar.attached? %w[image/jpg image/jpeg image/png image/gif].include?(avatar.blob.content_type) end endおわりに
ActiveStorageを使ってみて、思ったより簡単に導入できるので、単純な画像投稿機能などにはオススメだと思いました。
ただ、デフォルトで署名付きURLを取得してしまうため、CDNなどを使用する際は少し工夫が必要になるかなと思いました。
次回書けたら書きます・・・。
- 投稿日:2019-12-15T22:08:52+09:00
Rails:deviseの利用方法
目的
webアプリで必須の認証機能を自動作成してくれる便利なgem「devise」を使ってみたので使い方を説明したいと思います。
環境
rails 5.1.6
deviseの導入
1.gemのインストール
1.1.Gemfileの編集とインストール
Gemfileにdeviseを追加source 'https://rubygems.org' . . . gem 'devise'gemをインストール
$ bundle install2.deviseの設定
devise関連ファイルを追加
以下のような英文が表示されます。1から4まで順番に見ていきます。$ rails g devise:install create config/initializers/devise.rb create config/locales/devise.en.yml =============================================================================== Some setup you must do manually if you haven't yet: 1. Ensure you have defined default url options in your environments files. Here is an example of default_url_options appropriate for a development environment in config/environments/development.rb: config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } In production, :host should be set to the actual host of your application. 2. Ensure you have defined root_url to *something* in your config/routes.rb. For example: root to: "home#index" 3. Ensure you have flash messages in app/views/layouts/application.html.erb. For example: <p class="notice"><%= notice %></p> <p class="alert"><%= alert %></p> 4. You can copy Devise views (for customization) to your app by running: rails g devise:views ===============================================================================2.1.flashメッセージの設定
<p class="notice"><%= notice %></p> <p class="alert"><%= alert %></p>上記を挿入した箇所に「ログインしました」みたいなメッセージが出ます。
タグのすぐ下に挿入します。
以下のファイルのapp/views/layouts/application.html.erb<!DOCTYPE html> <html> <head> <title>DeviseRails5</title> <%= csrf_meta_tags %> <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> </head> <body> <p class="notice"><%= notice %></p> <p class="alert"><%= alert %></p> <%= yield %> </body> </html>2.2.deviseのviewを作成
Deviseの導入で追加されるViewは、以下のコマンドを実行しなければデザインをカスタマイズできないため実行します。
$ rails g devise:view以下の様なファイルが生成されます。
app/views/devise/shared/_links.html.erb (リンク用パーシャル) app/views/devise/confirmations/new.html.erb (認証メールの再送信画面) app/views/devise/passwords/edit.html.erb (パスワード変更画面) app/views/devise/passwords/new.html.erb (パスワードを忘れた際、メールを送る画面) app/views/devise/registrations/edit.html.erb (ユーザー情報変更画面) app/views/devise/registrations/new.html.erb (ユーザー登録画面) app/views/devise/sessions/new.html.erb (ログイン画面) app/views/devise/unlocks/new.html.erb (ロック解除メール再送信画面) app/views/devise/mailer/confirmation_instructions.html.erb (メール用アカウント認証文) app/views/devise/mailer/password_change.html.erb (メール用パスワード変更完了文) app/views/devise/mailer/reset_password_instructions.html.erb (メール用パスワードリセット文) app/views/devise/mailer/unlock_instructions.html.erb (メール用ロック解除文)3.Userモデルの設定
3.1.Userモデル(devise用)を生成
以下を実行。
$ rails g devise Userマイグレーションファイルができます。
デフォルトでは以下のようになってます。db/migrate/20161112121754_devise_create_users.rbclass DeviseCreateUsers < ActiveRecord::Migration[5.1] def change create_table :users do |t| ## Database authenticatable t.string :email, null: false, default: "" t.string :encrypted_password, null: false, default: "" ## Recoverable t.string :reset_password_token t.datetime :reset_password_sent_at ## Rememberable t.datetime :remember_created_at ## Trackable t.integer :sign_in_count, default: 0, null: false t.datetime :current_sign_in_at t.datetime :last_sign_in_at t.string :current_sign_in_ip t.string :last_sign_in_ip ## Confirmable # t.string :confirmation_token # t.datetime :confirmed_at # t.datetime :confirmation_sent_at # t.string :unconfirmed_email # Only if using reconfirmable ## Lockable # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts # t.string :unlock_token # Only if unlock strategy is :email or :both # t.datetime :locked_at t.timestamps null: false end add_index :users, :email, unique: true add_index :users, :reset_password_token, unique: true # add_index :users, :confirmation_token, unique: true # add_index :users, :unlock_token, unique: true end endUserモデルは以下のようになっています。
デフォルトではdatabase_authenticatable、registerable、recoverable、rememberable、trackable、validatableが使えるようになっています。app/models/user.rbclass User < ApplicationRecord # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable end
機能 概要 database_authenticatable サインイン時にユーザーの正当性を検証するためにパスワードを暗号化してDBに登録します。認証方法としてはPOSTリクエストかHTTP Basic認証が使えます。 registerable 登録処理を通してユーザーをサインアップします。また、ユーザーに自身のアカウントを編集したり削除することを許可します。 recoverable パスワードをリセットし、それを通知します。 rememberable 保存されたcookieから、ユーザーを記憶するためのトークンを生成・削除します。 trackable サインイン回数や、サインイン時間、IPアドレスを記録します。 validatable Emailやパスワードのバリデーションを提供します。独自に定義したバリデーションを追加することもできます。 confirmable メールに記載されているURLをクリックして本登録を完了する、といったよくある登録方式を提供します。また、サインイン中にアカウントが認証済みかどうかを検証します。 lockable 一定回数サインインを失敗するとアカウントをロックします。ロック解除にはメールによる解除か、一定時間経つと解除するといった方法があります。 timeoutable 一定時間活動していないアカウントのセッションを破棄します。 omniauthable intridea/omniauthをサポートします。TwitterやFacebookなどの認証を追加したい場合はこれを使用します。 3.2.マイグレーションファイルの編集
ユーザープロファイルにニックネームを実装したいので
Userモデルにのname属性を追加します。db/migrate/[timestamp]_devise_create_users.rbclass DeviseCreateUsers < ActiveRecord::Migration[5.1] def change create_table :users do |t| ## Database authenticatable t.string :name, null: false, unique: true t.string :email, null: false, default: "" t.string :encrypted_password, null: false, default: "" ## Recoverable t.string :reset_password_token t.datetime :reset_password_sent_at ## Rememberable t.datetime :remember_created_at ## Trackable t.integer :sign_in_count, default: 0, null: false t.datetime :current_sign_in_at t.datetime :last_sign_in_at t.string :current_sign_in_ip t.string :last_sign_in_ip ## Confirmable # t.string :confirmation_token # t.datetime :confirmed_at # t.datetime :confirmation_sent_at # t.string :unconfirmed_email # Only if using reconfirmable ## Lockable # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts # t.string :unlock_token # Only if unlock strategy is :email or :both # t.datetime :locked_at t.timestamps null: false end add_index :users, :email, unique: true add_index :users, :reset_password_token, unique: true # add_index :users, :confirmation_token, unique: true # add_index :users, :unlock_token, unique: true end endマイグレーションを実行
$ rails db:migrate以上で、メールアドレスでの認証機能が完成しました。
手作りと比べたら非常に簡単です。カスタマイズも簡単にできます。
感想
これから認証機能を作るときは基本的にdeviseを使うことになりそうですw
- 投稿日:2019-12-15T19:51:57+09:00
db:migrateについて
- 投稿日:2019-12-15T19:08:12+09:00
Classiエンジニア新人研修での学び方の学び
Classi Advent Calendar 2019の16日目の記事をご覧いただき,ありがとうございます!
2019年新卒エンジニア@willsmileです.自称コーヒー好きですが,コーヒーのことを語ると,教育と学習の方に“脱線”してしまい,「結局,教育のほうが趣味なんじゃないの」と最近自覚した者です.まえがき
本文では,Classiエンジニア新人研修を通じて,自分自身が「気づいた開発について新たな観点(の一例)」を語り,将来の自分,あるいは他の人にも参考できるように,「どうやって気づいたのか」を一段上の観点から言語化することを試みます.
本題
研修の概要
Classiエンジニア新人研修のカリキュラム担当者が業界で有名なRubyist@igaigaです.Classi入社前に@igaigaさんが著者である「ゼロからわかるRuby超入門」という本を持っています.それを読んでいて,Ruby言語の概念の厳密さと理解しやすさのバランスをよく取れている本という印象を残っています.@igaigaさんとの初対面の自己紹介で,「あの本の著者だ!すごい!」というびっくりから,研修の一日目が始まりました.
研修の題材と進み方について,4日目の記事で@yukoonoさんが紹介したように,万葉さんの公開カリキュラムにベースにして,各課題ステップにおいて,実装案の議論・検討,コーディング,コードレビューと修正といった「疑似体験」のサイクルが回します.このプロセスを通じて,わかったこと・気づいたことをメンターと話し合いながら,一緒に整理することで,これからの業務とつながるように,自分自身の理解を構築(再構築)していきます.これからは,実装案の議論・検討について,一個の例を通じて,自分自身の気づきを紹介します.
気づきの経緯
カリキュラムで定義されたタスク管理システムの要件の一つとして,「タスクに優先順位をつけたい」があります(以下の図がシステムの目標状態のイメージ).
カリキュラムのステップ6において,この要件を満たすために,モデル作成の段階では,タスクの優先順位をValueがStringのEnumで定義という実装案を考えていました.そう設計した理由として,現在の優先順位の仕様(例:低,中,高)を将来的に変更(例:低,やや低,中,やや高,高)があった時にも,対応しやすくなるからです.
class Task < ApplicationRecord ... enum priority: {low: "low", normal: "normal", high: "high"} ... endこの実装案を採用して,ステップ17までに進んだ時に,「優先順位を使って,タスクをソートできるようになる」といった仕様に対して,優先順位の順番を表す情報がシステムにはなかったため,ソートの実装ができなくなりました.それを解決するために,以下のようなコードを書いてみました.それは「そう!」,自分が書いたコードなのに,いま改めて読んでも驚きほどの複雑なもので,コードのリーダビリティーがかなり低くなりました.
class Task < ApplicationRecord ... enum priority: {low: "low", normal: "normal", high: "high"} PRIORITY_ORDERS = ['high', 'normal', 'low'] scope :priority, -> (order_of_priority) { if %w[asc desc].include?(order_of_priority) if order_of_priority == "desc" priority_orders = PRIORITY_ORDERS elsif order_of_priority == "asc" priority_orders = PRIORITY_ORDERS.reverse end order_by = ['CASE'] priority_orders.each_with_index do |priority, index| order_by << "WHEN priority='#{priority}' THEN #{index}" end order_by <<'END' order(order_by.join(' ')) else raise ActiveRecord::StatementInvalid end } endこの状況を整理して,最終的に,優先順位を”マスター化”する(タスクのAssociationで定義)という実装案にしたが,「なぜこうなったのか?」,「そもそも,このような仕様って,どう選択すれば良いのか」といった疑問を持ち,先輩たちの意見を聞きました.
学び方の学び
更に,この経験をメンターと一緒に振り返って,以下のようなかたちで,それぞれの実装案について,様々な意見をまとめていました.
表1.実装案のメリットとデメリットの比較
注:上の表には,コストという概念を使っているが,この例の場合.プロの人にとって,実装のコストの差があまりなく,適切ではないかもしれないが,ここの意図は「コストというよく見かける判断軸を登場させる」ことである.それを通じて,「正解って,実はないですね!」ということが初めて気づきました.つまり,それぞれの実装案はメリットとデメリットがあって,どれにするかが具体的な状況に合わせて選択することが重要です.例えば,自分が「最適だ」と思っていた実装案③でも,「何でもかんでもマスターデータにすると,管理するのは大変になっちゃうよ」というデメリットがありますし,最初に却下した実装案①でも,「後からソートしやすいね」というメリットがあります.問題の本質は,コスト,機能の拡張性といったことについてどう考えているのです.もちろん,それについて,ディベロッパーだけではなく,デザイナー,プロダクトマネージャーなどの異なる役割の方を含めて,一緒に議論して考えることは大切だと考えます.
このように,自身の「うまく考えていなかったことで,やっちゃった」経験から,様々な人の知恵を借りて,経験者の補助で振り返ることを通じて,プロのように考える「判断軸」(もちろん,これは氷山の一角にすぎない)を見つけていました.あとがき
その2ヶ月間の研修を通じて,@igaiga先生,メンターの@nagatashinyaさん,またたくさんのエンジニアの先輩たちのおかけで,今でも「生きている経験」をたくさん得ました.以下の研修卒業の時に,自分のTwitterのメッセージで,本日の話を締めます.
明日の投稿は,メンターの@nagatashinyaさんです.お楽しみに!
- 投稿日:2019-12-15T18:30:37+09:00
SkyWay API + Rails6 + Vue でビデオチャットアプリを作る
ビデオチャットアプリを作るってハードル高そうですよね?
SkyWayAPIを使うとリアルタイムでの動画通信が簡単にできます。前提知識
ざっくり知っているだけで十分です。
・Websocket
・WebRTC
・RubyOnRails
・Vue.js
・heroku(heroku cliをダウンロードして、heroku loginした状態で始めます)前置き
Railsを使う必要性は、この記事のアプリだとありません。
ユーザーや部屋の管理とかをRDS経由で行うことを前提に実装してみました。
目標物
ユーザー二人がcallIDを使ってビデオチャットをできるようにします。
①WEBアプリ側(Rails)
herokuへのデプロイを念頭に、プロジェクトを作成していきます。
herokuにアプリの初期状態をデプロイするところから始めます。rails new skyway_test --database=postgresql --webpack=vue rails db:create heroku create git add . git commit -m "first commit" git push heroku master #heroku側でデプロイしたアプリをブラウザーで確認 heroku openコントローラーとルーティングを追加していきます。
rails g controller rooms show
routes.rbRails.application.routes.draw do get 'rooms/show' root 'rooms#show' endこの時点で、こうなっていれば成功です。
②SkyWayのdeveloper登録
Community Editionが無料なので、このプランで登録していきます。
https://webrtc.ecl.ntt.com/signup.html登録して、ダッシュボードに行ったらアプリケーションを作成に進んでください。
ドメイン名の部分は、localhostとherokuで自動発行されたドメインを追加します。
それ以外は初期値のままで問題ありません。
それが終わるとアプリ詳細画面にAPIキーが表示されるので、それを控えておきます。③クライアント側の実装(Vue.js)
ではクライアント側の実装です。--webpack=vueオプションでrails newしてない方は、ここで下記のコマンドを実行しましょう。
$./bin/rails webpacker:install:vue
app/views/rooms/show.html.erb#元あった中身を削除、以下を追加 <%= javascript_pack_tag 'hello_vue' %> <%= stylesheet_pack_tag 'hello_vue' %>これで自動生成されているhello_vue.jsをRails側に読み込んで、vue.jsで作ったコンポーネントをレンダリングしています。
ではいよいよSkywayAPIを導入していきます。
hello_vueと同じ階層にjsファイルを作成します。
app/javascript/packs/room.jsimport Vue from 'vue/dist/vue.esm' import Room from '../room.vue' document.addEventListener('DOMContentLoaded', () => { const app = new Vue({ el: '#room', data: { }, components: { Room } }) })これでroomというIDをもつDOMがVueの影響範囲内になりました。
viewにそのDOMを設置して、jsファイルを読み込むタグを編集します。app/views/rooms/show.html.erb<div id="room"> <room /> </div> <%= javascript_pack_tag 'room' %> <%= stylesheet_pack_tag 'room' %>app.vueと同じ階層にroom.vueを作成します。
ここは@n0bisukeさんのqiitaの記事とgithubのリポジトリを参考にしました!
単一コンポーネントに書き換えています。SkyWayのサンプルをVue.jsで書いていくチュートリアル vol1
https://qiita.com/n0bisuke/items/6e1f56678b2eb6318594githubリポジトリ
https://gist.github.com/n0bisuke/88be07a6a16ee72b9bdf4fdcd12a522f自分のAPIキーを入力するのを忘れずに!
app/javascript/room.vue<template> <div id="app"> <video id="their-video" width="200" autoplay playsinline></video> <video id="my-video" muted="true" width="500" autoplay playsinline></video> <p>Your Peer ID: <span id="my-id">{{peerId}}</span></p> <input v-model="calltoid" placeholder="call id"> <button @click="makeCall" class="button--green">Call</button> <br /> マイク: <select v-model="selectedAudio" @change="onChange"> <option disabled value="">Please select one</option> <option v-for="(audio, key, index) in audios" v-bind:key="index" :value="audio.value"> {{ audio.text }} </option> </select> カメラ: <select v-model="selectedVideo" @change="onChange"> <option disabled value="">Please select one</option> <option v-for="(video, key, index) in videos" v-bind:key="index" :value="video.value"> {{ video.text }} </option> </select> </div> </template> <script> const API_KEY = "自分のAPIKEY"; // const Peer = require('../skyway-js'); console.log(Peer) export default { data: function () { return { audios: [], videos: [], selectedAudio: '', selectedVideo: '', peerId: '', calltoid: '', localStream: {} } }, methods: { onChange: function(){ if(this.selectedAudio != '' && this.selectedVideo != ''){ this.connectLocalCamera(); } }, connectLocalCamera: async function(){ const constraints = { audio: this.selectedAudio ? { deviceId: { exact: this.selectedAudio } } : false, video: this.selectedVideo ? { deviceId: { exact: this.selectedVideo } } : false } const stream = await navigator.mediaDevices.getUserMedia(constraints); document.getElementById('my-video').srcObject = stream; this.localStream = stream; }, makeCall: function(){ const call = this.peer.call(this.calltoid, this.localStream); this.connect(call); }, connect: function(call){ call.on('stream', stream => { const el = document.getElementById('their-video'); el.srcObject = stream; el.play(); }); } }, created: async function(){ console.log(API_KEY) this.peer = new Peer({key: API_KEY, debug: 3}); //新規にPeerオブジェクトの作成 this.peer.on('open', () => this.peerId = this.peer.id); //PeerIDを反映 this.peer.on('call', call => { call.answer(this.localStream); this.connect(call); }); //デバイスへのアクセス const deviceInfos = await navigator.mediaDevices.enumerateDevices(); //オーディオデバイスの情報を取得 deviceInfos .filter(deviceInfo => deviceInfo.kind === 'audioinput') .map(audio => this.audios.push({text: audio.label || `Microphone ${this.audios.length + 1}`, value: audio.deviceId})); //カメラの情報を取得 deviceInfos .filter(deviceInfo => deviceInfo.kind === 'videoinput') .map(video => this.videos.push({text: video.label || `Camera ${this.videos.length - 1}`, value: video.deviceId})); console.log(this.audios, this.videos); } } </script> <style scoped> p { font-size: 2em; text-align: center; } </style>skyway javascript SDKをCDN経由で読み込みます。(yarnやnpmで導入できたら多分そっちの方が良い。)
application.html.erb<!DOCTYPE html> <html> <head> <title>SkywayTest</title> <%= csrf_meta_tags %> <%= csp_meta_tag %> <script type="text/javascript" src="https://cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script> <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> <%= javascript_pack_tag 'app![error]() lication', 'data-turbolinks-track': 'reload' %> </head> <body> <%= yield %> </body> </html>カメラとマイクを選んだ時点で、ローカルの映像がvideoタグに反映されます。
最後にgit push heroku masterした結果がこれです。
あとで変わるかもしれません。
https://morning-meadow-17444.herokuapp.com/最後に
いかがでしたでしょうか?
ビデオチャットが簡単に作れるSkywayAPIすごいですね。
本来であれば、Turnサーバー・Stunサーバー立ててーごにょごにょしなきゃいけないと思いますが、そこの部分を全てやってくれます。もともと複数ユーザーが同時に参加できるカンファレンス式のビデオチャットにする予定だったので、roomという言葉をよく使っています。次回できたら複数参加もできるように実装したいです。
roomよりもchatとかの方がしっくりくるかもしれません。
- 投稿日:2019-12-15T18:03:58+09:00
[Ruby on Rails] 他のテーブルを参照してidを取得し、外部キーとしてデータを挿入する
実現したい事
データを挿入するテーブルとは別のテーブルの情報を参照して、該当するデータのidを外部キーとして、元のテーブルにデータを追加する
前提
テーブルは生産量のデータを持つProductions(生産量)テーブルと製品の情報を持つItems(製品)テーブルがあります。Productionsモデルには、idとamount(生産量)、Itemsテーブルと紐づけるためのItem_idを持っています。Itemsテーブルにはidとcode(製品コード)とname(製品名)のカラムを持ちます。また、Goodsテーブルにはすでに、製品の情報が入っているものとします。
今しようとしている事
Productionsコントローラのcreateアクションで、Prodectionsテーブルに生産量と製品情報を追加します。
フォームではamount(生産量)とItemsテーブルのcodeの値を入力する形になっています(フォームでは、nameをItem_idとしています)。
この時、amount(生産量)はそのままProductionsモデルに追加できますが、item_idは外部キーなので当然そのまま入力できません。なので、入力された値からItemsモデルのcode情報を参照して、ItemsのidをProductionsのitem_idに追加します。方法
まずはフォームは簡単にこのようになっています。
index.html<%= form_for [@production], url: productions_path do |f| %> <%= f.text_field :amount %> <%= f.text_field :good_id %> <%= f.submit '追加' %> <% end %>そして、コントローラのcreateアクションを以下のように書きます。
controller/productions_controller.rbclass ProductionsController < ApplicationController def create item_id = Item.find_by(code: params[:production][:item_id]).id Production.create(production_params.merge(item_id: item_id)) end private def production_params params.require(:production).permit(:amount) end endpermitではamountだけを許可して、createアクション内で、find_byを用いて、item_id = Item.find_by(code: params[:production][:item_id]).idと記述し、Itemテーブルでデータを探して、item_idに入れ直しています。
そして、createアクションをする際に、item_idをmergeする事で外部キーとしてidの値を挿入することができました。注意点
フォームで、form_forを使っているので、値を渡すときはparams[:production][:item_id]と書きましょう。params[:item_id]と書くと、値が渡せず、エラーになります。
補足情報
Rails 5.0.7.2
ruby 2.5.1
- 投稿日:2019-12-15T17:24:09+09:00
Railsチュートリアル 第12章 パスワードの再設定 - PasswordResets#editの動作を、テスト駆動で実装していく
PasswordResetsコントローラーの、ここまでに実装してきた内容に対するテスト
PasswordResetsコントローラーの
new
アクションおよびcreate
アクションに対するテストは、Railsチュートリアル本文の内容に合わせていくと、以下のような内容になります。test/integration/password_resets_test.rbrequire 'test_helper' class PasswordResetsTest < ActionDispatch::IntegrationTest def setup ActionMailer::Base.deliveries.clear @user = users(:rhakurei) end test "password resets" do get new_password_reset_path assert_template 'password_resets/new' # メールアドレスが無効 post password_resets_path, params: { password_reset: { email: "" } } assert_not flash.empty? assert_template 'password_resets/new' get new_password_reset_path assert flash.empty? # メールアドレスが有効 post password_resets_path, params: { password_reset: { email: @user.email} } assert_not_equal @user.reset_digest, @user.reload.reset_digest assert_equal 1, ActionMailer::Base.deliveries.size assert_not flash.empty? assert_redirected_to root_url end endテスト実装の前提となる、PasswordResetsコントローラーの
edit
アクションの動作
- 有効なメールアドレスと有効なパスワード再設定用トークンの組み合わせで
edit
アクションが呼び出された場合、type属性がhidden
であるinput要素に当該メールアドレスを格納する
- 後続の
update
アクションで使用するため- 無効な
edit
アクションの呼び出しがあった場合、/ にリダイレクトする
- 無効なメールアドレス
- 有効化されていないユーザー
- 無効なパスワード再設定用トークン
PasswordResetsコントローラーの
edit
アクションに対するテスト上記前提を踏まえた上で、PasswordResetsコントローラーの
edit
アクションに対するテストを追加していきましょう。test/integration/password_resets_test.rbrequire 'test_helper' class PasswordResetsTest < ActionDispatch::IntegrationTest def setup ActionMailer::Base.deliveries.clear @user = users(:rhakurei) end test "password resets" do get new_password_reset_path assert_template 'password_resets/new' # メールアドレスが無効 post password_resets_path, params: { password_reset: { email: "" } } assert_not flash.empty? assert_template 'password_resets/new' get new_password_reset_path assert flash.empty? # メールアドレスが有効 post password_resets_path, params: { password_reset: { email: @user.email} } assert_not_equal @user.reset_digest, @user.reload.reset_digest assert_equal 1, ActionMailer::Base.deliveries.size assert_not flash.empty? assert_redirected_to root_url + # パスワード再設定フォームのテスト + user = assigns(:user) + # メールアドレスが無効 + get edit_password_reset_path(user.reset_token, email: "") + assert_redirected_to root_url + # 無効なユーザー + user.toggle!(:activated) + get edit_password_reset_path(user.reset_token, email: user.email) + assert_redirected_to root_url + user.toggle!(:activated) + # メールアドレスが有効で、トークンが無効 + get edit_password_reset_path('wrong token', email: user.email) + assert_redirected_to root_url + # メールアドレスもトークンも有効 + get edit_password_reset_path(user.reset_token, email: user.email) + assert_template 'password_resets/edit' + assert_select "input[name=email][type=hidden][value=?]", user.email end end
PasswordResetsコントローラーの
edit
アクションに対するテストの実装が完了した時点でテストを実行するとどうなるかPasswordResetsコントローラーの
edit
アクションに対するテストの実装が完了した時点で、一度テストを実行してみます。# rails test test/integration/password_resets_test.rb Running via Spring preloader in process 671 Started with run options --seed 36691 FAIL["test_password_resets", PasswordResetsTest, 2.968936000001122] test_password_resets#PasswordResetsTest (2.97s) Expected response to be a <3XX: redirect>, but was a <200: OK> test/integration/password_resets_test.rb:29:in `block in <class:PasswordResetsTest>' 1/1: [===================================] 100% Time: 00:00:02, Time: 00:00:02 Finished in 2.97318s 1 tests, 9 assertions, 1 failures, 0 errors, 0 skips以下のようなメッセージが出力されてテストが失敗しています。
Expected response to be a <3XX: redirect>, but was a <200: OK>「リダイレクトされるべきところ、リダイレクトされていない」という趣旨のメッセージですね。
無効な
edit
アクションの呼び出しがあった場合、/ にリダイレクトする
- 無効なメールアドレス
- 有効化されていないユーザー
- 無効なパスワード再設定用トークン
以上のようなパラメータで
edit
アクションの呼び出しが行われた場合、/ にリダイレクトされなければならない、という仕様でしたね。このような実装を実現するためには、PasswordResetsコントローラーのbeforeフィルターを使用します。app/controllers/password_resets_controller.rbclass PasswordResetsController < ApplicationController + before_action :get_user, only: [:edit, :update] + before_action :valid_user, only: [:edit, :update] ...略 + + private + + def get_user + @user = User.find_by(email: params[:email]) + end + + # 正しいユーザーかどうか確認する + def valid_user + unless (@user && @user.activated? && + @user.authenticated?(:reset, params[id])) + redirect_to root_url + end + end end「実際の判定と、/ へのリダイレクト」という処理の実体は、
valid_user
メソッドで行われています。get_user
メソッドは、valid_user
メソッドで処理を行う前提として、@user
変数の内容を定義しています。なお、
@user
という変数名がcreate
アクション内でも使われていますが、create
アクションの@user
変数の内容と今回の@user
変数の内容が混ざり合うことはありません。get_user
メソッド・valid_user
メソッドともに、edit
アクションとupdate
アクションにしか関係しないからです。無効な
edit
アクションの呼び出しに対する処理を実装した時点でテストを実行するとどうなるか# rails test test/integration/password_resets_test.rb Running via Spring preloader in process 684 Started with run options --seed 51259 ERROR["test_password_resets", PasswordResetsTest, 3.9712250999982643] test_password_resets#PasswordResetsTest (3.97s) NameError: NameError: undefined local variable or method `email' for #<PasswordResetsController:0x00005629bcfa2850> app/controllers/password_resets_controller.rb:27:in `get_user' test/integration/password_resets_test.rb:28:in `block in <class:PasswordResetsTest>' 1/1: [===================================] 100% Time: 00:00:03, Time: 00:00:03 Finished in 3.97519s 1 tests, 8 assertions, 0 failures, 1 errors, 0 skips以下のようなエラーが出てテストが失敗しています。
NameError: undefined local variable or method `email'「
edit
アクションの呼び出しの際、クエリパラメータに
edit
アクションの呼び出しの際、クエリパラメータにこの段階で、PasswordResetsリソースの
edit
アクションに対するビューを実装していきます。「クエリパラメータにapp/views/password_resets/edit.html.erb
です。app/views/password_resets/edit.html.erb<% provide(:title, 'Reset password') %> <h1>Reset password</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %> <%= render 'shared/error_messages'%> <%= hidden_field_tag :email, @user.email %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :password_confirmation %> <%= f.password_field :password_confirmation, class: 'form-control' %> <%= f.submit "Update password", class: "btn btn-primary" %> <% end %> </div> </div>
hidden_field_tag
というメソッドの意味f.hidden_field :email, @user.email
f.hidden_field
を使った以上のような呼び出しの場合、メールアドレスはparams[:user][:email]
に保存されます。hidden_field_tag :email, @user.email一方、
hidden_field_tag
を使った以上のような呼び出しの場合、メールアドレスはparams[:email]
に保存されます。今回は、
params[:email]
を使った以下のような呼び出しが行われるのが前提です。「正当なクエリパラメータが構築されるためには、受け側にはf.hidden_field
ではなくhidden_field_tag
が必要になる」ということですね。@user = User.find_by(email: params[:email])PasswordResetsコントローラーの
edit
アクションの実装が完了ここまでの実装が完了した時点で、PasswordResetsコントローラーの
edit
アクションに対するテストを実行してみます。# rails test test/integration/password_resets_test.rb Running via Spring preloader in process 749 Started with run options --seed 30899 1/1: [===================================] 100% Time: 00:00:05, Time: 00:00:05 Finished in 5.38865s 1 tests, 13 assertions, 0 failures, 0 errors, 0 skipsテストが成功しました。これで「PasswordResetsコントローラーの
edit
アクションの実装は完了した」といえますね。PasswordResetsコントローラーの
edit
アクションに対するテストは、逆にどのような実装抜けがあったらテストが失敗するのか別記事にて解説しています。
- 投稿日:2019-12-15T17:22:14+09:00
ActionCable + Nginx + Redisの設定
はじめに
このごろSPAでフェッチというマッチングアプリを作っている阿部です。
マッチングアプリと言えばチャット。
ということでチャット機能を作りました。下手にSPAなんかに手を出したので、
WebSocketを本番で使うのに、Redisが必須になってしまいました。備忘録がてらActionCableをデプロイするための記事を書きました。
前提
Rails 5.2.3
Ruby 2.5.1
Puma
Nginx
Vue
Amazon Linux AMI release 2018.03説明すること
- Redisのインストール、実行
- Rails側の設定
- Production.rb
- cable.yml
- Nginxの設定
- /etc/nginx/conf.d/*.conf
Redisのインストール、実行
サーバー上でコマンドを叩き、Redisをインストール
sudo yum install redis --enablerepo=epelサーバー上でコマンドを叩き、Redisを実行
sudo service redis startRails側の設定
リクエスト元を設定してあげる。
Production.rbconfig.action_cable.allowed_request_origins = [ 'http://ドメイン', /http:\/\/ドメイン.*/]WebSocket通信時のadapterを設定
Production.rbdevelopment: adapter: async test: adapter: async production: adapter: redis url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> channel_prefix: example_production
Redisをstartした時のデフォルトのポートが6379の為、redis://localhost:6379。ポート変えたらここも要変更
channel_prefixは複数のアプリケーションでRedisをしようする為に必要らしい。一つのアプリケーションでしかRedisを使用しない場合は不要
Nginxの設定
/etc/nginx/conf.d/exemple.confupstream puma-example-api { server unix:///home/user/htdocs/example/tmp/sockets/puma.sock; } location /cable { proxy_pass http://puma-example-api; proxy_http_version 1.1; proxy_set_header Upgrade websocket; proxy_set_header Connection Upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }参考
Action Cable の概要
Rails5のActionCableをCapistrano経由でデプロイ
Rails5のActionCableをNginxとPumaの環境にデプロイする
- 投稿日:2019-12-15T17:19:49+09:00
Ruby & Rails チートシート
- 投稿日:2019-12-15T17:01:27+09:00
PHPerが久しぶりにRubyを書いて思ったこと
内容
- PHPer(Laravler)が感じたRuby(Rails)への感想
私の背景
- PHP成分が多い人生
- 初期だとFuelPHPとか触ってた
- ここ数年はLaravel使ってた
- Rubyは2011年頃に遊びで触ってた
- メタプログラミングRubyとか読んで興奮した記憶はある
- この当時のRailsを触って「ほーん、なるほど」、みたいに思った記憶もある
- 仕事では結局使わなかったけど、Project Euler をRubyで解いたりしてみてた
といった感じの背景です。
最近Railsで仕事することになったので、PHPer(というかLaraveler)が感じたRailsに対するギャップをいくつか書きたいと思います。
すでにRailsのプロジェクトがあり、そこにJOINした感じです。Autoloadがわからない
PHPのロード
require_once __DIR__ . '/path/to/file.php'Rubyのロード
require "path/to/file"ここまではなんの問題もなくわかるんですが、autoloadがわからない。
PHPの場合
namespace \Foo; use \Foo\Bar\Hoge; class Xxx extends Hoge { }みたいな感じで「利用するクラスのフルパスを書かなければならない」っていう制約があるので、面倒ではあるんだけど「
(appRoot)/Foo/Bar/Hoge.php
にあるんだな」っていうことが分かる。最近はほとんどPSR-4のautoloadが多いと思うので、PSR-4読めばいいと思う
参考: https://qiita.com/inouet/items/0208237629496070bbd4
Ruby(Rails)の場合
module Foo do class Xxx extends Hoge end endなんなん!?!?!?急に
Hoge
どこから出てきたん!?!?!?!?
Hogeって誰なの!?!?!?!ってすごく思った。
https://qiita.com/eggc/items/ae09d32df5d994522ca1
https://qiita.com/tachiba/items/5b293ca8e9430b9bd07e最新のバージョンでどう動くかはわからんですけど「Railsが頑張って探してるんや」って言われて納得。
納得すると同時に「あぁ〜〜〜パスを明示したいんじゃ〜〜〜」という気持ちになる。
カッコがない
PHPのこれが
piyo(bar(foo($x)));Rubyだとこうなる
piyo bar foo xいいんだけど、「どんだけカッコ嫌いなん???」って気持ちで溢れた。
foo
の引数が3つあるときはpiyo bar foo 1,2,3ってなって、なんか、分かるんだけど、なんやねんカッコ書きたいやんって気持ちになる
piyo(bar(foo(1,2,3))シックリくる〜〜!
って思うけどRobocopとか言うやつが「カッコ書くなや」みたいにいってくる。もうやだ。
blockに感じる違和感
[1,2,3].reject {|n| n > 2}.map {|n| n * 2}.map &:to_sこれと
[1,2,3].reject do |n| n > 2 end.map do |n| n * 2 end.map &:to_sこれって同じ感じだけど、なんで構文2つ分けたん。どっちかひとつでいいじゃんね!
end.map
とかにすごくなにか感じる。returnが無い
def hoge(x) if x > 5 return x * 3 end x endreturnつけたりつけなかったり!!!なんなの!!!
不要なのにreturnつけるとRobocopが「てめぇreturnつけてんじゃねぇよ!」とか言ってくるしもう。もう。
RubyDocが無い・・・?
最近のPHPは割と型がしっかり・・・とは言わないけど、少なくとも宣言する際の不便はかなりなくなってきた。
コンパイル用っていうよりは、プログラミングするときの手助けとしての型、という側面でとても便利。
PHP7より前の時代においてもPHPはPHPDoc
で何となく「型が何なのか明示しろや」文化が多少なりとあった。class Foo { /** * @param integer $x * @param integer $y * @return integer */ public function something($x, $y) { return $x * $y; } }みたいな感じで、「俺は型を宣言したいんじゃ!!!」という欲求に応えてくれた。
class Foo { public function something(int $x, int $y): int { return $x * $y; } }最近はこんな感じにスマートにかけてよかったね^^という気持ち
JSにもTypeScript以前にJSDocあったし、当然RubyにもRubyDocがあると思ってた。
そしたら、RubyDocはなんか違うやつだった。ドキュメント作るやつ。
ちゃうねん、そうじゃないねん。一応RDLってやつがあるみたいだけど、なんか違うというか・・・too muchというか・・・。
https://qiita.com/baban/items/0f782691b6e6e7213453
「Rubyの思想・文化的にそういう型宣言とは仲良くない」的なことだってのはわかってるんですが、「引数が何くるのかわからない状況ってのが怖くて、「え・・・string渡してくるバカがいたら死ぬの・・・?」みたいな気持ちになる。「string渡してくるやつがアホい」っていう思想なんだろうけど。
まとめ
なんか他にもいろいろあったと思うんですけど、思い出した範囲で感じた違和感をかきました。
別にRubyをディスってるわけじゃなくて「全然Rubyの世界観に馴染めてない俺」っていう感じです。
ワンライナー書いたりとかRspecのDSL感とか、書いててシックリくることも多いのですが、なんかやっぱ型システム周りの思想の違いは大きいなぁと思ってます。
ダックタイピングって誰得なのかがまだわかってないので、しっくりくる日を待ち望んでます。かしこ
- 投稿日:2019-12-15T16:25:58+09:00
エラーメッセージを日本語で表示したい!-機能実装方法について
個人アプリ作成中にエラーメッセージを日本語で表示したい!と思いましたが、想定よりも苦戦したので、以下に記載します。
(想定よりも苦戦した原因はymlファイルの設置と内容の編集です。。笑)はじめに
-背景:
Tweet機能を持たせたアプリを作成中、文字数制限をValidationで設定していたため、
Tweet時、文字数制限をオーバーしたときにValidationエラーメッセージを出そうとしていました。
この機能自体は、無事実装できましたが、出てくるエラーメッセージのデフォルトは英語になってます。
そこで、今回エラーメッセージを日本語にする方法を調べましたので、以下にまとめます。機能実装方法
1.rails-i18n(gem)実装
日本語化するにはrails-i18nというgemを使用します。
Gemfilegem 'rails-i18n'忘れずにbundle installします。
terminalbundle install2.application.rbを編集
次にconfig/application.rb内に以下を追記しましょう。
config/application.rb#デフォルトのlocaleを日本語(:ja)にする config.i18n.default_locale = :ja #複数のロケールファイルが読み込まれるようpathを通す config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}').to_s]これで、各フォルダにおけるymlファイルを読み込めるようになりました。
3.ymlファイルの設置+編集
まず、ymlファイルを設置します。
今回はpostモデルに関する内容だったので、models配下に設置します。config └── locales └──models └──ja.yml続いて、ymlファイルを編集します。
models/ja.ymlja: activerecord: models: post: 投稿 attributes: post: address: メールアドレス content: 投稿内容ymlの記載はインデント量で構造を表すので、注意が必要です!
(自分はそれで、何度か引っかかりました。。。笑)これで、エラーメッセージが日本語で表示できるようになりました!
今回記載しきれなかった内容もございます。
気になる方がいらっしゃいましたら、参考のページをご確認いただけますと幸いです!以上となります。最後までご覧いただき、ありがとうございました!
今後も学習した事項に関してQiitaに投稿していきますので、よろしくお願いします!
記述に何か誤りなどございましたら、お手数ですが、ご連絡いただけますと幸いです。参考
Ruby on Railsでエラーメッセージを表示する方法【初心者向け】
https://techacademy.jp/magazine/10024[初学者]Railsのi18nによる日本語化対応
https://qiita.com/shi-ma-da/items/7e5c3d75c9a9f51abdd5#4-configlocales%E4%BB%A5%E4%B8%8B%E3%81%AB%E3%83%AD%E3%82%B1%E3%83%BC%E3%83%AB%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E9%85%8D%E7%BD%AERuby on Rails モデルのエラー表示が日本語にならない
https://ja.stackoverflow.com/questions/38880/ruby-on-rails-%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AE%E3%82%A8%E3%83%A9%E3%83%BC%E8%A1%A8%E7%A4%BA%E3%81%8C%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AB%E3%81%AA%E3%82%89%E3%81%AA%E3%81%84
- 投稿日:2019-12-15T14:03:39+09:00
Railsチュートリアル 第12章 パスワードの再設定 - PasswordResetsリソース
PasswordResetsコントローラー
PasswordResetsコントローラーの生成
例によって、
rails generate controller
コマンドでPasswordResetsコントローラーを生成していきます。# rails generate controller PasswordResets new edit --no-test-framework Running via Spring preloader in process 14754 create app/controllers/password_resets_controller.rb route get 'password_resets/edit' route get 'password_resets/new' invoke erb create app/views/password_resets create app/views/password_resets/new.html.erb create app/views/password_resets/edit.html.erb invoke helper create app/helpers/password_resets_helper.rb invoke assets invoke coffee create app/assets/javascripts/password_resets.coffee invoke scss create app/assets/stylesheets/password_resets.scss特筆すべき点は以下です。
- アクションの自動生成を行っている
- テストの生成を行っていない
アクションの自動生成
第11章で扱った「ユーザーの有効化」とは異なり、今回はビューも扱います。そのため、第11章では行わなかった「
rails generate controller
コマンドにおけるアクションの自動生成」を行います。対象はnew
とedit
の両アクションです。テストを生成していない
rails generate controller
コマンドにおいて、--no-test-framework
というオプションは、「テストを生成しない」という意味のオプションです。テストを生成しない理由は、今回実装するPasswordResetsコントローラーにおける、「コントローラーの単体テストは実装せず、統合テストのみでカバーする」という方針に基づくものです。パスワード再設定用リソースに関するルーティングの定義
「ビューを必要とする」ということは、「リソースにアクセスするためのURLが必要となる」ということでもあります。そうしたURLを定義するのは、
config/routes.rb
におけるルーティング定義でしたね。早速、ルーティングを定義していきましょう。config/routes.rbRails.application.routes.draw do get 'password_resets/new' get 'password_resets/edit' root 'static_pages#home' get '/help', to: 'static_pages#help' get '/about', to: 'static_pages#about' get '/contact', to: 'static_pages#contact' get '/signup', to: 'users#new' post '/signup', to: 'users#create' get '/login', to: 'sessions#new' post '/login', to: 'sessions#create' delete '/logout', to: 'sessions#destroy' resources :users resources :account_activations, only: [:edit] + resources :password_resets, only: [:new, :create, :edit, :update] end
PasswordResetsリソースで必要となるルーティングは、
new
とedit
、およびそれぞれに対してRDBに変更を反映するcreate
とupdate
、以上の4つとなります。
HTTPリクエスト URL Action 名前つきルート GET
/password_resets/new new
new_password_reset_path
POST
/password_resets create
password_resets_path
GET
/password_resets/<token>/edit edit
edit_password_reset_url(token)
PATCH
/password_resets/<token> update
password_reset_url(token)
edit
およびupdate
については、「メールに記載されたURLへのアクセスをトリガーとする」というのがポイントです。このような用法においては、_path
ではなくて_url
を使うのでしたね。ログイン画面のビューの実装変更
まずはじめに、ログイン画面のビューにパスワード再設定用のリンクを追加します。
app/views/sessions/new.html.erb<% provide(:title, "Log in") %> <h1>Log in</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(:session, url: login_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> + <%= link_to "(forgot_password)", new_password_reset_path %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> <span>Remember me on this computer</span> <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %> <p>New user? <%= link_to "Sign up now!", signup_path %></p> </div> </div>
当該リンクを追加した後、ログイン画面は以下のようになります。既に「forgot password」と表示されたリンクが追加されていますね。
但し、現時点でPasswordResetsの
new
には何の動作も定義していないため、初期状態のapp/views/password_resets/new.html.erb
を描画した結果が返ってくるだけです。演習 - PasswordResetsコントローラー
1. この時点で、テストスイートが
green
になっていることを確認してみましょう。# rails test Running via Spring preloader in process 14829 Started with run options --seed 46822 46/46: [=================================] 100% Time: 00:00:11, Time: 00:00:11 Finished in 11.83135s 46 tests, 197 assertions, 0 failures, 0 errors, 0 skips1.発展. ログイン画面に、パスワード再設定用のリンクが存在することに対するテストを実装してみましょう。
長くなりましたので、以下の記事に。
2. 表 12.1の名前付きルートでは、_pathではなく_urlを使うように記してあります。なぜでしょうか? 考えてみましょう。
ヒント: アカウント有効化で行った演習 (11.1.1.1) と同じ理由です。
項目「パスワード再設定用リソースに関するルーティングの定義」の最後に記述しました。
新しいパスワードの設定
アカウント有効化処理との類似点と相違点
アカウント有効化処理との類似点
実装の大枠は、11章で行った「アカウント有効化」と類似しています。すなわち、「トークンを含むURLをメールで送信する。当該トークンに対応するダイジェストをRDBに保存する。トークンを含むURLにアクセスされたら、対応する処理を開始する。」という処理の流れについては、アカウント有効化もパスワード再設定も同じです。
アカウント有効化処理との相違点
一方で、パスワード再設定においては、アカウント有効化では考慮する必要のなかった事柄の一つを考慮する必要があります。それは、「パスワード再設定用のリンクには有効期限を設定する」という事柄です。「何らかの理由でパスワード再設定用のリンクが放置された場合に、第三者によるリンクの悪用リスクを下げる」という意味で必要となってきます。
Userモデルに、パスワード再設定に必要な属性を追加する
Userモデルに新たに必要となる属性
Userモデルに新たに必要となる属性は以下の2つです。
- パスワード再設定用トークンに対するダイジェスト
- パスワード再設定メールの送信時刻
パスワード再設定用リンクの有効期限は、「パスワード再設定メールの送信時刻から○時間後」という形で設定していきます。
新たなUserモデルの内容
新たに必要となる属性は、以下の名前とします。
- パスワード再設定用トークンに対するダイジェスト…
reset_digest
- パスワード再設定メールの送信時刻…
reset_sent_at
上記を踏まえた上で、新たなUserモデルの内容を図にすると、以下のようになります。
新たなUserモデルの内容をRDBに反映する
Userモデルに追加する属性に対するマイグレーションを生成する
マイグレーションそのものの名前は
add_reset_to_users
とします。# rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime Running via Spring preloader in process 14894 invoke active_record create db/migrate/[timestamp]_add_reset_to_users.rb以下のマイグレーションが生成されました。クラス名は
AddResetToUsers
となっています。db/migrate/[timestamp]_add_reset_to_users.rbclass AddResetToUsers < ActiveRecord::Migration[5.1] def change add_column :users, :reset_digest, :string add_column :users, :reset_sent_at, :datetime end endいずれも初期値は
nil
とするので、生成されたマイグレーションに手を付ける必要はありません。生成したマイグレーションをRDBに反映する
生成したマイグレーションは、いつものように、
rails db:migrate
コマンドによってRDBに反映します。# rails db:migrate == [timestamp] AddResetToUsers: migrating ================================== -- add_column(:users, :reset_digest, :string) -> 0.0288s -- add_column(:users, :reset_sent_at, :datetime) -> 0.0033s == [timestamp] AddResetToUsers: migrated (0.0334s) =========================パスワード再設定メールの送信用フォーム
フォームの内容そのものは、ログインフォーム(
app/views/sessions/new.html.erb
)に類似するものとなります。以下はapp/views/sessions/new.html.erb
の内容です。app/views/sessions/new.html.erb(再掲)<% provide(:title, "Log in") %> <h1>Log in</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(:session, url: login_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= link_to "(forgot password)", new_password_reset_path %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> <span>Remember me on this computer</span> <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %> <p>New user? <%= link_to "Sign up now!", signup_path %></p> </div> </div>一方で、ログイン用のフォームとはいくつかの相違点もあります。パスワード再設定メール送信用のフォーム側から見た大きな違いを以下に記述していきます(ほかにも小さな違いはいくつかあります)。
- (当然ながら)ビューの場所が異なる
app/views/sessions/new.html.erb
ではなく、app/views/password_resets/new.html.erb
となるform_for
で扱うリソースとURLが異なる
SessionsController#create
ではなく、PasswordResetsController#create
となる- パスワードの入力が省略されている
app/views/password_resets/new.html.erb<% provide(:title, "Password reset") %> <h1>Password reset</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(:password_reset, url: password_resets_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.submit "Submit", class: "btn btn-primary" %> <% end %> </div> </div>この時点で、パスワード再設定メール送信用のHTMLフォームは正しく表示されるようになっています。ただ、「Submit」ボタンを押したときの動作はまだ定義されていません。これから
create
アクションで定義していくことになります。演習
1. リスト 12.4の
form_for
メソッドでは、なぜ@password_reset
ではなく:password_reset
を使っているのでしょうか? 考えてみてください。長くなりましたので、別記事で説明します。
create
アクションでパスワード再設定パスワード再設定フォームのSubmitボタンが押された後の動作となります。実装が必要となるのは、以下の処理です。
- メールアドレスをキーとしてユーザーをRDBから検索する
- 当該ユーザーに対する以下の処理
- パスワード再設定用トークンを発行し、対応するパスワード再設定用ダイジェストでRDBを更新する
- パスワード再設定用トークンを発行した日時をRDBに保存する
- フラッシュメッセージを定義した上で、ルートURLにリダイレクト
- メールアドレスが無効な場合の処理
- フラッシュメッセージを定義した上で、パスワード再設定メールの送信用フォームにリダイレクト
パスワード再設定の統合テスト
Railsチュートリアル本文とは少し順番を捻じ曲げ、パスワード再設定の統合テストを先に生成していきます。
統合テストの生成
統合テストなので、使用するコマンドは
rails generate integration_test
です。名前はpassword_resets
とします。# rails generate integration_test password_resets Running via Spring preloader in process 44 invoke test_unit create test/integration/password_resets_test.rbテストの初期設定
Railsチュートリアル本文の通りに実装していくとすれば、テストの初期設定は以下のようになります。
- 初期化処理
- テストメールの送信状態を初期化する
- 今後のテスト内で使う
@user
変数の内容を定義するtest/integration/password_resets_test.rbrequire 'test_helper' class PasswordResetsTest < ActionDispatch::IntegrationTest def setup ActionMailer::Base.deliveries.clear @user = users(:rhakurei) end test "password resets" do end endメールアドレスが無効な場合の処理
テスト駆動で実装してみました。その顛末は別記事にて。
メールアドレスが有効な場合の処理
こちらもテスト駆動で実装してみました。その顛末は別記事にて。
演習 -
create
アクションでパスワード再設定演習に取り組む環境を整えるために、「メールの送信処理の実装」プロセスを「
send_password_reset_email
メソッドの実装」まで完了した段階で一旦中断し、以下のコードを実装します。app/controllers/password_resets_controller.rbclass PasswordResetsController < ApplicationController def new end def create @user = User.find_by(email: params[:password_reset][:email].downcase) if @user @user.create_reset_digest @user.send_password_reset_email - #TODO: フラッシュメッセージの定義とルートへのリダイレクト + flash[:info] = "Email sent with password reset instructions" + redirect_to root_url else flash.now[:danger] = "Email address not found" render 'new' end end def edit end end1. 試しに有効なメールアドレスをフォームから送信してみましょう (図 12.6)。どんなエラーメッセージが表示されたでしょうか?
スクリーンショットは以下です。
サーバーログには、以下のようなエラーメッセージが残されています。
Completed 500 Internal Server Error in 681ms (ActiveRecord: 73.3ms) ArgumentError (wrong number of arguments (given 1, expected 0)): app/mailers/user_mailer.rb:8:in `password_reset' app/models/user.rb:61:in `send_password_reset_email' app/controllers/password_resets_controller.rb:9:in `create'「引数の数が0でなければならないのに、実際には1つの引数が渡されている」というエラーです。
そういえば、実装済みのテストでも、同じエラーメッセージが表示されていました。
# rails test test/integration/password_resets_test.rb Running via Spring preloader in process 191 Started with run options --seed 20579 ERROR["test_password_resets", PasswordResetsTest, 2.324982799999816] test_password_resets#PasswordResetsTest (2.33s) ArgumentError: ArgumentError: wrong number of arguments (given 1, expected 0) app/mailers/user_mailer.rb:8:in `password_reset' app/models/user.rb:61:in `send_password_reset_email' app/controllers/password_resets_controller.rb:9:in `create' test/integration/password_resets_test.rb:18:in `block in <class:PasswordResetsTest>' 1/1: [===================================] 100% Time: 00:00:02, Time: 00:00:02 Finished in 2.32883s 1 tests, 3 assertions, 0 failures, 1 errors, 0 skips2. コンソールに移り、先ほどの演習課題で送信した結果、(エラーと表示されてはいるものの) 該当するuserオブジェクトには
reset_digest
とreset_sent_at
があることを確認してみましょう。また、それぞれの値はどのようになっていますか?以下のようなサーバーログが残されていたことを前提とします。
Started POST "/password_resets" ...略 Processing by PasswordResetsController#create as HTML Parameters: {"utf8"=>"✓", "authenticity_token"=>"/oPYr3/VP2xSwgvqxft9L9YtfbCUeGvTQbR5MFLr0Bpg/ihNJbwSZCyJwwgmrB0QRsZWxTA7Cx51Is7h5gHEJA==", "password_reset"=>"[FILTERED]", "commit"=>"Submit"} User Load (4.8ms) SELECT "users".* FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "example-2@railstutorial.org"], ["LIMIT", 1]] (0.7ms) begin transaction SQL (15.7ms) UPDATE "users" SET "reset_digest" = ?, "updated_at" = ? WHERE "users"."id" = ? [["reset_digest", "$2a$10$Zln4PTKIyOO8/7TWgmY6nuploURJrovXjWxjiK4LeuAIPnwa.QXk6"], ["updated_at", "2019-12-13 20:39:33.489265"], ["id", 3]] (11.4ms) commit transaction (0.1ms) begin transaction SQL (13.4ms) UPDATE "users" SET "updated_at" = ?, "reset_sent_at" = ? WHERE "users"."id" = ? [["updated_at", "2019-12-13 20:39:33.528786"], ["reset_sent_at", "2019-12-13 20:39:33.526750"], ["id", 3]] (11.7ms) commit transaction# rails console --sandbox >> user = User.find(3) >> user.reset_digest => "$2a$10$Zln4PTKIyOO8/7TWgmY6nuploURJrovXjWxjiK4LeuAIPnwa.QXk6" >> user.reset_sent_at => Fri, 13 Dec 2019 20:39:33 UTC +00:00当該ユーザーの
reset_digest
とreset_sent_at
には、確かにサーバーログと同じ値が格納されています。以下のテストが成功する状況であれば、RDBに
reset_digest
とreset_sent_at
は正しく保存されるはずです。test "reset_digest should save with valid post request" do post password_resets_path, params: { password_reset: { email: @user.email} } assert_not_equal @user.reset_digest, @user.reload.reset_digest end送信メールのテスト
パスワード再設定用メイラーのテストも、アカウント有効化用メイラーのテストと同様のやり方で実装することができます。場所は同じく
test/mailers/user_mailer_test.rb
です。以下、テスト名「password reset」としてテストを追加していきます。
test/mailers/user_mailer_test.rbrequire 'test_helper' class UserMailerTest < ActionMailer::TestCase test "account_activation" do user = users(:rhakurei) user.activation_token = User.new_token mail = UserMailer.account_activation(user) assert_equal "Account activation", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.name, mail.body.encoded assert_match user.activation_token, mail.body.encoded assert_match CGI.escape(user.email), mail.body.encoded end test "password reset" do user = users(:rhakurei) user.reset_token = User.new_token mail = UserMailer.password_reset(user) assert_equal "Password reset", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.reset_token, mail.body.encoded assert_match CGI.escape(user.email), mail.body.encoded end endこの時点で、同テスト、並びにテストスイート全体が成功するはずです。
# rails test test/mailers/user_mailer_test.rb Running via Spring preloader in process 593 Started with run options --seed 63825 2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01 Finished in 1.16035s 2 tests, 16 assertions, 0 failures, 0 errors, 0 skips# rails test Running via Spring preloader in process 606 Started with run options --seed 64282 48/48: [=================================] 100% Time: 00:00:07, Time: 00:00:07 Finished in 7.27392s 48 tests, 213 assertions, 0 failures, 0 errors, 0 skipsテストは成功していますね。
演習 - 送信メールのテスト
1. メイラーのテストだけを実行してみてください。このテストは
green
になっているでしょうか?上述
rails test test/mailers/user_mailer_test.rb
の結果の通り、現時点でのテスト結果はgreen
ですね。2. リスト 12.12にある2つ目の
CGI.escape
を削除すると、テストがred
になることを確認してみましょう。
test/mailers/user_mailer_test.rb
の内容を以下のように変更するとどうなるか、という話ですね。test/mailers/user_mailer_test.rbrequire 'test_helper' class UserMailerTest < ActionMailer::TestCase ...略 test "password reset" do user = users(:rhakurei) user.reset_token = User.new_token mail = UserMailer.password_reset(user) assert_equal "Password reset", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.reset_token, mail.body.encoded - assert_match CGI.escape(user.email), mail.body.encoded + assert_match user.email, mail.body.encoded end end結果は以下のようになります。「メール本文中に、
/rhakurei@example\.com/
という正規表現で示される文字列が含まれていない」という理由でテストが失敗しています。# rails test test/mailers/user_mailer_test.rb Running via Spring preloader in process 619 Started with run options --seed 22084 FAIL["test_password_reset", UserMailerTest, 1.297335600000224] test_password_reset#UserMailerTest (1.30s) Expected /rhakurei@example\.com/ to match ...略 ----==_mimepart_5df563989470_26b2b14dbf885fc55451\r\nContent-Type: text/plain; ...略 http://example.com/password_resets/oUEWQLpWdB7Y7n4ErOw6rg/edit?email=rhakurei%40example.com ...略 ----==_mimepart_5df563989470_26b2b14dbf885fc55451\r\nContent-Type: text/html; ...略 http://example.com/password_resets/oUEWQLpWdB7Y7n4ErOw6rg/edit?email=rhakurei%40example.com ...略 ----==_mimepart_5df563989470_26b2b14dbf885fc55451--\r\n". test/mailers/user_mailer_test.rb:24:in `block in <class:UserMailerTest>' 2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01 Finished in 1.45204s 2 tests, 16 assertions, 1 failures, 0 errors, 0 skips確かに「
/rhakurei@example\.com/
という正規表現で示される文字列が含まれていない」ようですね。
- 投稿日:2019-12-15T13:44:54+09:00
form_tagからform_withに変換したら問題が出た話[Validationエラーメッセージの表示]
個人アプリ作成中に発生した問題の概要と解決についてまとめます。
はじめに(背景と発生した問題内容)
-背景:
Tweet機能を持たせたアプリを作成中、文字数制限をValidationで設定していたため、
Tweet時、文字数制限をオーバーしたときにValidationエラーメッセージを出そうとしていました。
その際に、form_tagを用いて以下の記述をしていました。view<div class="main posts-new"> <div class="container"> <h1 class="form-heading">編集する</h1> <%= form_tag("/posts/#{@post.id}/update") do %> <div class="form"> <div class="form-body"> <% @post.errors.full_messages.each do |message| %> <div class="form-error"> <%= message %> </div> <% end %> <textarea name="content"><%= @post.content %></textarea> <input type="submit" value="保存"> </div> </div> <% end %> </div> </div>こちらの記述を実行し、文字数制限をオーバーすると、Validationエラーメッセージが出てきました。
記述に問題はなさそうです。そこで、最近学習したform_withを使用しようとしたところ、
以下の問題が発生します。-問題:
form_withの記述に変更したところ、Validationエラーメッセージが出なくなってしまった。
記述内容は以下の通り。view<div class="main posts-new"> <div class="container"> <h1 class="form-heading">編集する</h1> <%= form_with url:"/posts/#{@post.id}/update" do |form|%> <div class="form"> <div class="form-body"> <% @post.errors.full_messages.each do |message| %> <div class="form-error"> <%= message %> </div> <% end %> <textarea name="content"><%= @post.content %></textarea> <input type="submit" value="保存"> </div> </div> <% end %> </div> </div>Validationエラーメッセージが出ないこと以外は正常に動いていましたが、やりたいことができてません。。。
そこで、色々と調べた結果、以下のことがわかりました。解決方法
まず結論から、今回の問題はform_withの記載中にlocal:trueと記載する必要がありました!
view#以下のようにlocal:trueと記載します! <%= form_with url:"/posts/#{@post.id}/update",local:true do |form|%> #他の箇所の変更は必要ないですが、念のため全体は以下のようになります。 <div class="main posts-new"> <div class="container"> <h1 class="form-heading">編集する</h1> <%= form_with url:"/posts/#{@post.id}/update",local:true do |form|%> <div class="form"> <div class="form-body"> <% @post.errors.full_messages.each do |message| %> <div class="form-error"> <%= message %> </div> <% end %> <textarea name="content"><%= @post.content %></textarea> <input type="submit" value="保存"> </div> </div> <% end %> </div> </div>上記記載に変更したところ、無事Validationエラーメッセージが出てきました!これで問題解決です!
以下にて今回の問題発生原因についても記載します!原因
今回の問題の原因はform_tag/form_forとform_withの仕様の違いが原因でした。
○form_tag/form_for:
-localは、基本的にtrue設定で記載の必要なし。
-remote(Ajax)は、記載の必要あり。○form_with:
-localは、記載の必要あり。
-remote(Ajax)は、基本的にtrue設定で記載の必要なし。今回はAjaxではなく、localでの処理であったため、form_with使用時はlocal:trueの記載が必要だったことになります!
以上となります。最後までご覧いただき、ありがとうございました!
今後も学習した事項に関してQiitaに投稿していきますので、よろしくお願いします!
記述に何か誤りなどございましたら、お手数ですが、ご連絡いただけますと幸いです。参照
Rails 5.1のform_withでViewにvalidationエラー表示
https://qiita.com/k_senbei/items/a361171f653edcd888ad【Rails】form_with (local: true)について
https://qiita.com/hayulu/items/5bf26656d7433d406ede
- 投稿日:2019-12-15T11:40:17+09:00
Mastodonどうしのお話を盗み聞きしてみる
分散SNSソフトのMastodon、7月にmaster追従勢のトゥートがリモートのタイムラインに表示されなくなることがありました。トゥートを受け取る側のサーバで、
bundle: Rejected Create activity
といったエラーが記録される問題が、シリアライザのキャッシュのキーを改善することでなおりました。え?どゆこと?
ということで、Mastodonサーバどうしがやりとりをするときの様子を眺めてみることにしました。現在のMastodonはActivityPubという規格にしたがって実装されています。まず規格の内容を確認してから、Mastodonの実装の内容と稼働の様子を眺めてみます。
これは、分散SNSアドベントカレンダーの12月16日分の記事です。15日は@svchost@pawoo.netさんによる「otajodon.comで学んだLTLの功罪」でした。17日は焼肉ハブられたマンさんによる「プログラミング知識が皆無の俺が自鯖を1から作ってみた話」です。
まとめ
サーバへのリクエストやレスポンス、また、サーバからのリクエストやレスポンスなどを記録するようにコードや設定を追加したMastodonサーバを2台稼働させて、リモートアカウントの検索やフォローをして、お互いのやりとりを観察してみました。
リモートアカウントを検索すると、まずはWebFingerでリモートアカウントについての基本的な情報を得て、その後、WebFingerで得られたURLに対してHTTP署名をつけたリクエストを送り、リモートアカウントについてのより詳細な情報を取得していました。いっぽう、リモートサーバはWebFingerの受信に呼応してローカルサーバのインスタンスアカウントについての情報を、HTTP署名をつけて取得しに来ました。結果的に、ローカルサーバにはリモートアカウントの公開鍵を含めた情報が、リモートサーバにはローカルサーバのインスタンスアカウントについての公開鍵を含めた情報が記録されました。
リモートサーバのアカウントをフォローすると、ローカルサーバのジョブキューからリモートアカウントのInboxへActivitPubのFollow ActivityをPOSTリクエストとして送りました。ローカルアカウントによるHTTP署名がリクエストヘッダに付いていました。
ローカルサーバにトゥートを投稿すると、ローカルサーバのジョブキューからリモートアカウントの共有InboxへActivitPubのCreate ActivityをPOSTリクエストとして送りました。ローカルアカウントによる署名がリクエストヘッダとリクエストボディに付いていました。
Mastodonの配達しているトゥートがリモートに届かなくなっていたのは、シリアライザのキャッシュによって署名が不正な状態になっていたからのようです (結局よくわかってないや)。
今回眺めたリクエストのやり取りを再確認すると、ローカルサーバのドメインは、User Agentリクエストヘッダ、HTTP署名、また、ActivityPubでやりとりされる内容や署名に含まれていました。署名には、データベースのマイグレーションの時に生成されるインスタンスアカウントの鍵対やアカウントの生成の時に生成される鍵対が使われます。リモートアカウントとやりとりがあった時点で、リモートサーバに鍵対とドメインの対応づけが記録されます。あらかじめサーバのドメインを決めておくことと、鍵対をなくさないことが大事そうです。
ActivityPubでの規定
ActivityPubは分散ソーシャルネットワークサービスのための規格で、クライアントとサーバの間の通信、また、サーバどうしの通信について、投稿内容のやりとりのためのAPIを規定しています。ActivityPubの概要については、分散SNSアドベントカレンダーの12月7日の記事[Fediverse][Protocol] ActivityPubを深堀してみる でlocalYouserさんが解説してくださってます。Objects章では、「なりすましを防ぐため、サーバは受け取ったcontentを検証する必要がある(should)」と規定されています。でも具体的な方法は規定してないんだよね。
Mastodonはといえば、ActivityPubのサーバどうしの通信の規格に準拠した実装として、WebFingerでアカウントを見つけ、HTTP signatures spec (この記事ではHTTP署名と書きます)に従ってinboxへの配送内容を認証します。この記事ではこの部分を更に掘りすすんでみます。
ログをたくさん記録するようにする
この記事では、2019年11月21日ごろのMastodonを改造してローカルサーバへのリクエストやリモートサーバへのリクエストを記録するようにしました。改造後のコードはzunda/mastodonのlog-requestsブランチにもあります。
Railsにデバッグログを記録してもらう
config/environments/production.rb
やconfig/initializers/sidekiq.rb
にあるように、RAILS_LOG_LEVEL
環境変数を設定することでRailsやSidekiqが記録するログのレベルを設定することができます。$ heroku config:set RAILS_LOG_LEVEL=debugログレベルを
debug
にするとPostgresとのやりとりも教えてもらえます。セッションIDなど秘密にしなきゃいけない情報も記録されちゃうのでプロダクション環境で記録するのは避けたほうが良さそうです。User Load (1.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 ORDER BY "users"."id" ASC LIMIT $2 [["id", 1], ["LIMIT", 1]] SessionActivation Exists (1.3ms) SELECT 1 AS one FROM "session_activations" WHERE "session_activations"."user_id" = $1 AND "session_activations"."session_id" = $2 LIMIT $3 [["user_id", 1], ["session_id", "dc7ab5cdb2885d9845f10a7cda8a0ba8"], ["LIMIT", 1]] SessionActivation Load (0.9ms) SELECT "session_activations".* FROM "session_activations" WHERE "session_activations"."session_id" = $1 LIMIT $2 [["session_id", "dc7ab5cdb2885d9845f10a7cda8a0ba8"], ["LIMIT", 1]]ローカルサーバへのリクエストとレスポンスを記録してもらう
lib/request_logger.rb
にRackミドルウェアを書いて、config/application.rb
から参照するようにしました。
config/application.rb
への変更点:diff --git a/config/application.rb b/config/application.rb index e1f7ae707..91355737b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -16,6 +16,7 @@ require_relative '../lib/mastodon/version' require_relative '../lib/devise/two_factor_ldap_authenticatable' require_relative '../lib/devise/two_factor_pam_authenticatable' require_relative '../lib/chewy/strategy/custom_sidekiq' +require_relative '../lib/request_logger' Dotenv::Railtie.load @@ -121,6 +122,8 @@ module Mastodon config.middleware.use Rack::Attack config.middleware.use Rack::Deflater + config.middleware.use RequestLogger + config.to_prepare do Doorkeeper::AuthorizationsController.layout 'modal' Doorkeeper::AuthorizedApplicationsController.layout 'admin'
lib/request_logger.rb
の内容# Add below into config/application.rb: # # config.middleware.use 'RequestLogger' # # Copied from https://gist.github.com/jugyo/300e93d6624375fe4ed8674451df4fe0 # and modified # # Please DO NOT USE this for production apps. # This will leak clients' credentials to app logs. # class RequestLogger def initialize app @app = app end def call(env) request = ActionDispatch::Request.new env begin response = @app.call(env) req_body = request.body.read log(request, req_body, response) rescue Exception => exception log(request, req_body, response, exception) raise exception end response end def log(request, req_body, response, exception = nil) status, res_headers, res_body = response h = { request: { method: request.method, fullpath: request.fullpath, headers: Hash.new{|h,k| h[k] = Array.new }, }, response: { status: status, headers: res_headers, }, } request.env.each do |k, v| if k.start_with?('HTTP_') h[:request][:headers][k] << v end end if x = parse(req_body) h[:request][:body] = x end if x = parse(res_body.body) h[:response][:body] = x end if exception h[:exception] = { type: exception.class.name, message: exception.message, } end Rails.logger.info(h.to_json) rescue Exception => exception Rails.logger.error(exception.message) end def parse(body) unless body.blank? begin data = JSON.parse(body) rescue JSON::ParserError body.force_encoding('utf-8') data = body.valid_encoding? ? body[0...512] : body.b[0...512].dump end return data else return nil end end endブラウザとのやり取りだけではなく、リモートのMastodonサーバからのリクエストを記録してもらえます。
{ "request": { "method": "GET", "fullpath": "/.well-known/webfinger?resource=acct:zundan-mastodon-httplog.herokuapp.com@zundan-mastodon-httplog.herokuapp.com", "headers": { "HTTP_VERSION": [ "HTTP/1.1" ], "HTTP_HOST": [ "zundan-mastodon-httplog.herokuapp.com" ], "HTTP_CONNECTION": [ "close" ], "HTTP_USER_AGENT": [ "http.rb/3.3.0 (Mastodon/3.0.1; +https://zundan-mastodon-remote.herokuapp.com/)" ], "HTTP_X_REQUEST_ID": [ "9a6a523d-9b82-460b-8b87-00ce0568556c" ], "HTTP_X_FORWARDED_FOR": [ "34.207.111.209" ], "HTTP_X_FORWARDED_PROTO": [ "https" ], "HTTP_X_FORWARDED_PORT": [ "443" ], "HTTP_VIA": [ "1.1 vegur" ], "HTTP_CONNECT_TIME": [ "1" ], "HTTP_X_REQUEST_START": [ "1575881663884" ], "HTTP_TOTAL_ROUTE_TIME": [ "0" ] } }, "response": { "status": 200, "headers": { "Server": "Mastodon", "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "X-XSS-Protection": "1; mode=block", "Vary": "Accept", "Date": "Mon, 09 Dec 2019 08:54:23 GMT", "Content-Type": "application/jrd+json; charset=utf-8", "Cache-Control": "max-age=259200, public" }, "body": { "subject": "acct:zundan-mastodon-httplog.herokuapp.com@zundan-mastodon-httplog.herokuapp.com", "aliases": [ "https://zundan-mastodon-httplog.herokuapp.com/actor" ], "links": [ { "rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://zundan-mastodon-httplog.herokuapp.com/about/more?instance_actor=true" }, { "rel": "self", "type": "application/activity+json", "href": "https://zundan-mastodon-httplog.herokuapp.com/actor" } ] } } }セッションIDなど秘密にしなきゃいけない情報も記録されちゃうのでプロダクション環境で記録するのは避けたほうが良さそうです。
httplog gemに外部サービスとのやりとりを記録してもらう
Mastodonが利用している
httplog
Gemではアプリのコードが外部とhttpで通信をする際のログの内容を設定することができます。Mastodonから提供されているイニシャライザconfig/initializers/httplog.rb
を下記のように書き換えて、リクエスト・レスポンスヘッダを記録してもらいます。HttpLog.configure do |config| config.logger = Rails.logger config.color = { color: :yellow } config.log_connect = false config.log_request = true config.log_headers = true config.log_data = false config.log_status = true config.log_response = false config.log_benchmark = false end下記のように、まずリクエストの概要とヘッダを、続いてステータスコードとレスポンスヘッダが記録されます。
[httplog] Sending: GET https://zundan-mastodon-remote.herokuapp.com/.well-known/webfinger?resource=acct:remote@zundan-mastodon-remote.herokuapp.com [httplog] Header: User-Agent: http.rb/3.3.0 (Mastodon/3.0.1; +https://zundan-mastodon-httplog.herokuapp.com/) [httplog] Header: Connection: close [httplog] Header: Host: zundan-mastodon-remote.herokuapp.com [httplog] Status: 200 [httplog] Header: Connection: close [httplog] Header: Server: Mastodon [httplog] Header: X-Frame-Options: DENY [httplog] Header: X-Content-Type-Options: nosniff [httplog] Header: X-Xss-Protection: 1; mode=block [httplog] Header: Vary: Accept, Accept-Encoding, Origin [httplog] Header: Date: Mon, 09 Dec 2019 00:50:20 GMT [httplog] Header: Content-Type: application/jrd+json; charset=utf-8 [httplog] Header: Cache-Control: max-age=259200, public [httplog] Header: Etag: W/"b91adfc5323491547a25eb7469712cf9" [httplog] Header: X-Request-Id: d2a1abeb-d777-48fa-9f82-5c170802f190 [httplog] Header: X-Runtime: 0.263245 [httplog] Header: Transfer-Encoding: chunked [httplog] Header: Via: 1.1 vegurここで、
config.log_data = true
としてレスポンスのボディを記録させてしまうと、app/lib/request.rb
でレスポンスのボディを利用する時に、http
GemがHTTP::StateError: body has already been consumed
というエラーを投げるようになります。config/initializers/httprb-response-body.rb
というファイルを作成してHTTP::Response::Body
のreadpartial
メソッドとto_s
メソッドにモンキーパッチを当てて、レスポンスをもらいながらログを記録することにしました。# frozen_string_literal: true # # Monkey patch for # https://github.com/httprb/http/blob/v3.3.0/lib/http/response/body.rb module HTTP class Response class Body def readpartial(*args) stream! chunk = @stream.readpartial(*args) if chunk chunk.force_encoding(@encoding) Rails.logger.debug("Response body: #{chunk}") unless chunk.blank? end chunk end def to_s return @contents if @contents raise StateError, "body is being streamed" unless @streaming.nil? begin @streaming = false @contents = String.new("").force_encoding(@encoding) while (chunk = @stream.readpartial) @contents << chunk.force_encoding(@encoding) Rails.logger.debug("Response body: #{chunk}") unless chunk.blank? chunk.clear # deallocate string end rescue @contents = nil raise end @contents end end end end下記のようなログを記録してくれます。
Response body: {"subject":"acct:remote@zundan-mastodon-remote.herokuapp.com","aliases":["https://zundan-mastodon-remote.herokuapp.com/@remote","https://zundan-mastodon-remote.herokuapp.com/users/remote"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://zundan-mastodon-remote.herokuapp.com/@remote"},{"rel":"self","type":"application/activity+json","href":"https://zundan-mastodon-remote.herokuapp.com/users/remote"},{"rel":"http://ostatus.org/schema/1.0/subscribe","template":"https://zundan-mastodon-remote.herokuapp.com/authorize_interaction?uri={uri}"}]}あ、この実装だと当然リクエストボディは記録できないんですよね。今は時間がないので、リクエストを受けた側で記録されたリクエストボディを確認することにします。
サーバの作成
そういうわけで、ログをたくさん記録するサーバを2つ、ローカルサーバとして
zundan-mastodon-a.herokuapp.com
を、そしてリモートサーバとしてzundan-mastodon-b.herokuapp.com
として作り、それぞれにalice
とbob
を登録してみました。これからこの2人のやりとりを覗いていきますね。データベースのマイグレーションを走らせて、アカウントを1つ登録した段階で、実は2つのアカウントが登録されています。id: -99のものはインスタンスどうしの通信に代理として使われるようです。
> SELECT id, username, domain, actor_type FROM accounts; id | username | domain | actor_type -----+---------------------------------+--------+------------- -99 | zundan-mastodon-a.herokuapp.com | | Application 1 | alice | | (2 rows)通信内容の記録
リモートサーバのアカウントを探す
作りたてのサーバ2つを稼働させて、ローカルサーバのaliceとしてブラウザからログインして、リモートサーバのbobを検索してみました。
下記のように、まずはWebFingerでリモートアカウントについての基本的な情報を得て、その後、WebFingerで得られたURLに対してHTTP署名をつけたリクエストを送り、リモートアカウントについてのより詳細な情報を取得していました。いっぽう、リモートサーバはWebFingerの受信に呼応してローカルサーバのインスタンスアカウントについての情報を、HTTP署名をつけて取得しに来ました。結果的に、ローカルサーバにはリモートアカウントの公開鍵を含めた情報が、リモートサーバにはローカルサーバのインスタンスアカウントについての情報が記録されました。
ローカルサーバのブラウザインターフェースの検索窓にフォーカスが移ると
/api/v1/suggestions
へのGETリクエストがHTTP_ACCEPT: application/json, text/plain, */*
で送られるようです。結果は空でした。{ "response": { "status": 200, "headers": { "Server": "Mastodon", "Content-Type": "application/json; charset=utf-8" }, "body": [] } }ブラウザから
/api/v2/search?q=@bob@zundan-mastodon-b.herokuapp.com&resolve=true&limit=5
へのGETリクエストが届き、処理が始まりました。検索リクエストが届くと、まずリモートサーバへのWebFingerを送りました。
Sending: GET https://zundan-mastodon-b.herokuapp.com/.well-known/webfinger?resource=acct:bob@zundan-mastodon-b.herokuapp.com Header: User-Agent: http.rb/3.3.0 (Mastodon/3.0.1; +https://zundan-mastodon-a.herokuapp.com/) Header: Connection: close Header: Host: zundan-mastodon-b.herokuapp.comリモートサーバからは検索対象についての情報がStatus 200、
Content-Type: application/jrd+json; charset=utf-8
で返却されました。{ "subject": "acct:bob@zundan-mastodon-b.herokuapp.com", "aliases": [ "https://zundan-mastodon-b.herokuapp.com/@bob", "https://zundan-mastodon-b.herokuapp.com/users/bob" ], "links": [ { "rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://zundan-mastodon-b.herokuapp.com/@bob" }, { "rel": "self", "type": "application/activity+json", "href": "https://zundan-mastodon-b.herokuapp.com/users/bob" }, { "rel": "http://ostatus.org/schema/1.0/subscribe", "template": "https://zundan-mastodon-b.herokuapp.com/authorize_interaction?uri={uri}" } ] }次にリモートサーバからは、
/actor
へのGETリクエストが届きました。リモートサーバの/actor#main-key
によるHTTP署名が付いていました。インスタンスアカウントについての情報を返しました。リモートサーバはリクエストの送り先をどうやって決めたんだろう?
{ "request": { "method": "GET", "fullpath": "/actor", "headers": { "HTTP_VERSION": [ "HTTP/1.1" ], "HTTP_HOST": [ "zundan-mastodon-a.herokuapp.com" ], "HTTP_CONNECTION": [ "close" ], "HTTP_USER_AGENT": [ "http.rb/3.3.0 (Mastodon/3.0.1; +https://zundan-mastodon-b.herokuapp.com/)" ], "HTTP_DATE": [ "Thu, 12 Dec 2019 07:48:57 GMT" ], "HTTP_ACCEPT_ENCODING": [ "gzip" ], "HTTP_ACCEPT": [ "application/activity+json, application/ld+json" ], "HTTP_SIGNATURE": [ "keyId=\"https://zundan-mastodon-b.herokuapp.com/actor#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date accept\",signature=\"VGtjjtIGDriQ4vyF3BF7rdTafcI21qGc2AuDYeoA840mZmkHWArgT7MPYCor75e54QnL+LTRJS8QPD6o0aJKWd4rvMItu+RkzJamcwaAFskIrs12gvYU0bnk9Oy7gfYRBBFfLUjDTYE96t5Q1Prub0uzPrZCxesDY2vB0Kc3zLZ4zkHyk4eXHa7Lk7Cu/owj4RBc4yume7OIYkQnPMZxpPbroGAC/DiwxNiqxaKjXybkcWcGnedQdDvj/kqk1dNOiVbabMVLs0qmHrCSd+rv2pJhabz9oW8uG05LcT3NHyAzgAuDCCOLhyjxQQkx87h2Qyt5hiFFRoTKf+mSt+xBWA==\"" ] } }, "response": { "status": 200, "headers": { "Server": "Mastodon", "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "X-XSS-Protection": "1; mode=block", "Date": "Thu, 12 Dec 2019 07:48:57 GMT", "Content-Type": "application/activity+json; charset=utf-8", "Cache-Control": "max-age=600, public" }, "body": { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "toot": "http://joinmastodon.org/ns#", "featured": { "@id": "toot:featured", "@type": "@id" }, "alsoKnownAs": { "@id": "as:alsoKnownAs", "@type": "@id" }, "movedTo": { "@id": "as:movedTo", "@type": "@id" }, "schema": "http://schema.org#", "PropertyValue": "schema:PropertyValue", "value": "schema:value", "IdentityProof": "toot:IdentityProof", "discoverable": "toot:discoverable" } ], "id": "https://zundan-mastodon-a.herokuapp.com/actor", "type": "Application", "inbox": "https://zundan-mastodon-a.herokuapp.com/actor/inbox", "preferredUsername": "zundan-mastodon-a.herokuapp.com", "url": "https://zundan-mastodon-a.herokuapp.com/about/more?instance_actor=true", "manuallyApprovesFollowers": true, "publicKey": { "id": "https://zundan-mastodon-a.herokuapp.com/actor#main-key", "owner": "https://zundan-mastodon-a.herokuapp.com/actor", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1y0tCWNc8P1befA0KFj6\n91zJBo5g0sBIO0xEQVDO/7N3wotXjQHiHnX/HrMAe1giuQXVpbStp4aLygRtyH/p\nQNTp2R4mOm7Ov+XRWbK5VM0fzYHyooK7fkI0dGnF2cqGJdyD7VS9OynJ1ii9nHiv\nfdWlea27H5TDAnq3NO7rBIaW9qaPg80uJSn4sfC7X0ktnuiDp0syUIUqcBgiXaXU\nsFkeu2H5pCSEdU8O5NUSs/gln6eEhz9AMbGtA/+U9ilGh+oUxkdBeFWe/5xAdV/U\nUaDtNO0l/djezoIFN5WK0vI6UvBHBtGhJsQifQux1fukhxERw30XAG9bpxMbvnsr\nEwIDAQAB\n-----END PUBLIC KEY-----\n" }, "endpoints": { "sharedInbox": "https://zundan-mastodon-a.herokuapp.com/inbox" } } } }面白いことに、ログにはリモートサーバからの同じリクエストがもう1つ、それに対応するレスポンスと一緒に記録されていました。
次に、リモートサーバから、インスタンスアカウントへのWebFingerが届いて情報を返しました。
{ "request": { "method": "GET", "fullpath": "/.well-known/webfinger?resource=acct:zundan-mastodon-a.herokuapp.com@zundan-mastodon-a.herokuapp.com", "headers": { "HTTP_VERSION": [ "HTTP/1.1" ], "HTTP_HOST": [ "zundan-mastodon-a.herokuapp.com" ], "HTTP_CONNECTION": [ "close" ], "HTTP_USER_AGENT": [ "http.rb/3.3.0 (Mastodon/3.0.1; +https://zundan-mastodon-b.herokuapp.com/)" ] } }, "response": { "status": 200, "headers": { "Server": "Mastodon", "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "X-XSS-Protection": "1; mode=block", "Vary": "Accept", "Date": "Thu, 12 Dec 2019 07:48:57 GMT", "Content-Type": "application/jrd+json; charset=utf-8", "Cache-Control": "max-age=259200, public" }, "body": { "subject": "acct:zundan-mastodon-a.herokuapp.com@zundan-mastodon-a.herokuapp.com", "aliases": [ "https://zundan-mastodon-a.herokuapp.com/actor" ], "links": [ { "rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://zundan-mastodon-a.herokuapp.com/about/more?instance_actor=true" }, { "rel": "self", "type": "application/activity+json", "href": "https://zundan-mastodon-a.herokuapp.com/actor" } ] } } }リモートサーバでは返却された情報をPostgresに格納しました。
INSERT INTO "accounts" ("username", "domain", "public_key", "created_at", "updated_at", "uri", "url", "locked", "last_webfingered_at", "inbox_url", "shared_inbox_url", "protocol", "featured_collection_url", "fields", "actor_type", "discoverable", "also_known_as") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING "id" [["username", "zundan-mastodon-a.herokuapp.com"], ["domain", "zundan-mastodon-a.herokuapp.com"], ["public_key", "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1y0tCWNc8P1befA0KFj6\n91zJBo5g0sBIO0xEQVDO/7N3wotXjQHiHnX/HrMAe1giuQXVpbStp4aLygRtyH/p\nQNTp2R4mOm7Ov+XRWbK5VM0fzYHyooK7fkI0dGnF2cqGJdyD7VS9OynJ1ii9nHiv\nfdWlea27H5TDAnq3NO7rBIaW9qaPg80uJSn4sfC7X0ktnuiDp0syUIUqcBgiXaXU\nsFkeu2H5pCSEdU8O5NUSs/gln6eEhz9AMbGtA/+U9ilGh+oUxkdBeFWe/5xAdV/U\nUaDtNO0l/djezoIFN5WK0vI6UvBHBtGhJsQifQux1fukhxERw30XAG9bpxMbvnsr\nEwIDAQAB\n-----END PUBLIC KEY-----\n"], ["created_at", "2019-12-12 07:48:57.890086"], ["updated_at", "2019-12-12 07:48:57.890086"], ["uri", "https://zundan-mastodon-a.herokuapp.com/actor"], ["url", "https://zundan-mastodon-a.herokuapp.com/about/more?instance_actor=true"], ["locked", true], ["last_webfingered_at", "2019-12-12 07:48:57.882914"], ["inbox_url", "https://zundan-mastodon-a.herokuapp.com/actor/inbox"], ["shared_inbox_url", "https://zundan-mastodon-a.herokuapp.com/inbox"], ["protocol", 1], ["featured_collection_url", ""], ["fields", "{}"], ["actor_type", "Application"], ["discoverable", false], ["also_known_as", "{}"]]次にローカルサーバからGETリクエストをリモートサーバの
/users/bob
に送りました。Accept-Encoding: gzip
、Accept: application/activity+json, application/ld+json
で、/actor#main-key
によるHTTP署名が付いていました。リモートサーバからのレスポンスは200でボディは下記のようなものでした。{ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "toot": "http://joinmastodon.org/ns#", "featured": { "@id": "toot:featured", "@type": "@id" }, "alsoKnownAs": { "@id": "as:alsoKnownAs", "@type": "@id" }, "movedTo": { "@id": "as:movedTo", "@type": "@id" }, "schema": "http://schema.org#", "PropertyValue": "schema:PropertyValue", "value": "schema:value", "IdentityProof": "toot:IdentityProof", "discoverable": "toot:discoverable" } ], "id": "https://zundan-mastodon-b.herokuapp.com/users/bob", "type": "Person", "following": "https://zundan-mastodon-b.herokuapp.com/users/bob/following", "followers": "https://zundan-mastodon-b.herokuapp.com/users/bob/followers", "inbox": "https://zundan-mastodon-b.herokuapp.com/users/bob/inbox", "outbox": "https://zundan-mastodon-b.herokuapp.com/users/bob/outbox", "featured": "https://zundan-mastodon-b.herokuapp.com/users/bob/collections/featured", "preferredUsername": "bob", "name": "", "summary": "<p></p>", "url": "https://zundan-mastodon-b.herokuapp.com/@bob", "manuallyApprovesFollowers": false, "discoverable": null, "publicKey": { "id": "https://zundan-mastodon-b.herokuapp.com/users/bob#main-key", "owner": "https://zundan-mastodon-b.herokuapp.com/users/bob", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVY0KCfnhep54ESjcBqs\nj5Ht3u1ovmM1Z+5HYUYJBiDJyPJ0887lqtrPhG8jbj4+FxDO7wlVx9E7WuXhu357\nQ8FUNxtZB9bXlKwuZTy8zF47k0QUHzeroCB09qrxu+FCTbxQP1fAI/ADWO5/1CvD\n8cxGu5ZarN9fiOLMe2JsS9rSKTt5q+ckC29tfTeMlXBlblIyxjViE/u4TRS3hYKB\n2bN5pqCJvz+d4to/iMizNJlD5js4q31hfiVicGvrLwInFJ9mmCHVJQ4iSwBcZhPm\nwOWS4gGBJIUtCFklje3f9uc6csvrgftY3BrXx+Mb7MaSYOQQDrBX5HOqqoPbaNKq\nEwIDAQAB\n-----END PUBLIC KEY-----\n" }, "tag": [], "attachment": [], "endpoints": { "sharedInbox": "https://zundan-mastodon-b.herokuapp.com/inbox" } }このリクエストとレスポンスも、もう1度繰り返し記録されていました。
次にローカルサーバからはGETリクエストをリモートサーバの
/users/bob/outbox
に送りました。上記と同様のリクエストヘッダを付けHTTP署名していました。リモートサーバからのレスポンスは200でボディは下記のようなものでした。{ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://zundan-mastodon-b.herokuapp.com/users/bob/outbox", "type": "OrderedCollection", "totalItems": 0, "first": "https://zundan-mastodon-b.herokuapp.com/users/bob/outbox?page=true", "last": "https://zundan-mastodon-b.herokuapp.com/users/bob/outbox?min_id=0&page=true" }次にローカルサーバからはGETリクエストをリモートサーバの
/users/bob/following
に送りました。上記と同様のリクエストヘッダを付けHTTP署名していました。リモートサーバからのレスポンスは200でボディは下記のようなものでした。{ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://zundan-mastodon-b.herokuapp.com/users/bob/following", "type": "OrderedCollection", "totalItems": 0, "first": "https://zundan-mastodon-b.herokuapp.com/users/bob/following?page=1" }次にローカルサーバからはGETリクエストをリモートサーバの
/users/bob/followers
にも送りました。上記と同様のリクエストヘッダ、HTTP署名でした。リモートサーバからのレスポンスは200でボディは下記のようなものでした。{ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://zundan-mastodon-b.herokuapp.com/users/bob/followers", "type": "OrderedCollection", "totalItems": 0, "first": "https://zundan-mastodon-b.herokuapp.com/users/bob/followers?page=1" }ローカルサーバはPostgresにリモートアカウントの情報を格納しました。
INSERT INTO "accounts" ("username", "domain", "public_key", "created_at", "updated_at", "note", "uri", "url", "last_webfingered_at", "inbox_url", "outbox_url", "shared_inbox_url", "followers_url", "protocol", "featured_collection_url", "fields", "actor_type", "discoverable", "also_known_as") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING "id" [["username", "bob"], ["domain", "zundan-mastodon-b.herokuapp.com"], ["public_key", "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVY0KCfnhep54ESjcBqs\nj5Ht3u1ovmM1Z+5HYUYJBiDJyPJ0887lqtrPhG8jbj4+FxDO7wlVx9E7WuXhu357\nQ8FUNxtZB9bXlKwuZTy8zF47k0QUHzeroCB09qrxu+FCTbxQP1fAI/ADWO5/1CvD\n8cxGu5ZarN9fiOLMe2JsS9rSKTt5q+ckC29tfTeMlXBlblIyxjViE/u4TRS3hYKB\n2bN5pqCJvz+d4to/iMizNJlD5js4q31hfiVicGvrLwInFJ9mmCHVJQ4iSwBcZhPm\nwOWS4gGBJIUtCFklje3f9uc6csvrgftY3BrXx+Mb7MaSYOQQDrBX5HOqqoPbaNKq\nEwIDAQAB\n-----END PUBLIC KEY-----\n"], ["created_at", "2019-12-12 07:48:58.125482"], ["updated_at", "2019-12-12 07:48:58.125482"], ["note", "<p></p>"], ["uri", "https://zundan-mastodon-b.herokuapp.com/users/bob"], ["url", "https://zundan-mastodon-b.herokuapp.com/@bob"], ["last_webfingered_at", "2019-12-12 07:48:57.982457"], ["inbox_url", "https://zundan-mastodon-b.herokuapp.com/users/bob/inbox"], ["outbox_url", "https://zundan-mastodon-b.herokuapp.com/users/bob/outbox"], ["shared_inbox_url", "https://zundan-mastodon-b.herokuapp.com/inbox"], ["followers_url", "https://zundan-mastodon-b.herokuapp.com/users/bob/followers"], ["protocol", 1], ["featured_collection_url", "https://zundan-mastodon-b.herokuapp.com/users/bob/collections/featured"], ["fields", "[]"], ["actor_type", "Person"], ["discoverable", false], ["also_known_as", "{}"]]これでやっと、ブラウザから
/api/v2/search?q=@bob@zundan-mastodon-b.herokuapp.com&resolve=true&limit=5
へのGETリクエストへのレスポンスを、Status 200、Content-Type: application/jrd+json; charset=utf-8
で返すことができました。{ "accounts": [ { "id": "2", "username": "bob", "acct": "bob@zundan-mastodon-b.herokuapp.com", "display_name": "", "locked": false, "bot": false, "created_at": "2019-12-12T07:48:58.125Z", "note": "<p></p>", "url": "https://zundan-mastodon-b.herokuapp.com/@bob", "avatar": "https://zundan-mastodon-a.herokuapp.com/avatars/original/missing.png", "avatar_static": "https://zundan-mastodon-a.herokuapp.com/avatars/original/missing.png", "header": "https://zundan-mastodon-a.herokuapp.com/headers/original/missing.png", "header_static": "https://zundan-mastodon-a.herokuapp.com/headers/original/missing.png", "followers_count": 0, "following_count": 0, "statuses_count": 0, "last_status_at": null, "emojis": [], "fields": [] } ], "statuses": [], "hashtags": [] }最後に、ブザウザから
/api/v1/accounts/relationships?id[]=2
へのGETリクエストが届き、Status 200、Content-Type: application/jrd+json; charset=utf-8
で下記のボディを返却しました。[ { "id": "2", "following": false, "showing_reblogs": false, "followed_by": false, "blocking": false, "blocked_by": false, "muting": false, "muting_notifications": false, "requested": false, "domain_blocking": false, "endorsed": false } ]ここで、今後の観察で2つのサーバの動作の比較がしやすいように、リモートサーバのbobからローカルサーバのaliceも検索しておきました。
アカウントをフォローする
ローカルサーバのaliceとして上記で見つけたリモートサーバのbobをフォローしてみます。ブラウザからリモートアカウントをフォローすると、SidekiqからロモートサーバにFollow Activityが送られました。HTTP署名はフォロー元のローカルアカウントによるものでした。
リモートアカウントをフォローするには、ブラウザでリモートアカウントの検索する必要がありました。レスポンスは上記と同様で、ローカルサーバでのリモートアカウントのID 2が含まれています。
次に、ブラウザからローカルサーバの
/api/v1/accounts/relationships?id[]=2
へのGETリクエストが届きました。レスポンスはStatusは200でボディは下記の通り:[ { "id": "2", "following": false, "showing_reblogs": false, "followed_by": false, "blocking": false, "blocked_by": false, "muting": false, "muting_notifications": false, "requested": false, "domain_blocking": false, "endorsed": false } ]最後にブラウザからローカルサーバの
/api/v1/accounts/2/follow
にPOSTリクエストが届きました。リクエストボディは下記の通り。{ "reblogs": true }これに対してのローカルサーバからのレスポンスはStatus 200でボディは下記の通り。この時点ではフォローの処理は進んでおらず、後ほどSidekiqからリモートサーバへのリクエストが送られます。
{ "id": "2", "following": true, "showing_reblogs": true, "followed_by": false, "blocking": false, "blocked_by": false, "muting": false, "muting_notifications": false, "requested": false, "domain_blocking": false, "endorsed": false }ジョブキューの処理を進め、ローカルサーバのSidekiqからリモートサーバの
/users/bob/inbox
へPOSTリクエストを送りました。ローカルサーバの/alice#main-key
によるHTTP署名が付いていました。リクエストボディは下記の通り、ActivitPubのFollow Activityでした。リモートサーバからは、Status 202でボディの無いレスポンスが返されました。{ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://zundan-mastodon-a.herokuapp.com/b984cd13-58e4-4465-aad4-980a82b6e8fa", "type": "Follow", "actor": "https://zundan-mastodon-a.herokuapp.com/users/alice", "object": "https://zundan-mastodon-b.herokuapp.com/users/bob" }次に、ローカルサーバのSidekiqからリモートサーバの
/users/bob/collections/featured
へGETリクエストを送りました。ローカルサーバの/actor#main-key
によるHTTP署名が付いていました。リモートサーバからは、Status 200で下記のレスポンスが返されました。{ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://zundan-mastodon-b.herokuapp.com/users/bob/collections/featured", "type": "OrderedCollection", "orderedItems": [] }リモートサーバのbobからもローカルサーバのaliceをフォローしておきました。
トゥートを配送する
ローカルサーバにaliceとしてブラウザからトゥートを投稿しました。ブラウザからは、
/api/v1/statuses
にPOSTリクエストが届きました。リクエストのボディは下記の通り:{ "status": "テストですと", "in_reply_to_id": null, "media_ids": [], "sensitive": false, "spoiler_text": "", "visibility": "public", "poll": null }下記のようなレスポンスをStatus 200で返しました。ちゃんと日本語だと判定されてる。
{ "id": "103309317046763904", "created_at": "2019-12-15T01:57:57.183Z", "in_reply_to_id": null, "in_reply_to_account_id": null, "sensitive": false, "spoiler_text": "", "visibility": "public", "language": "ja", "uri": "https://zundan-mastodon-a.herokuapp.com/users/alice/statuses/103309317046763904", "url": "https://zundan-mastodon-a.herokuapp.com/@alice/103309317046763904", "replies_count": 0, "reblogs_count": 0, "favourites_count": 0, "favourited": false, "reblogged": false, "muted": false, "bookmarked": false, "pinned": false, "content": "<p>テストですと</p>", "reblog": null, "application": null, "account": { "id": "1", "username": "alice", "acct": "alice", "display_name": "", "locked": false, "bot": false, "created_at": "2019-12-12T07:42:58.184Z", "note": "<p></p>", "url": "https://zundan-mastodon-a.herokuapp.com/@alice", "avatar": "https://zundan-mastodon-a.herokuapp.com/avatars/original/missing.png", "avatar_static": "https://zundan-mastodon-a.herokuapp.com/avatars/original/missing.png", "header": "https://zundan-mastodon-a.herokuapp.com/headers/original/missing.png", "header_static": "https://zundan-mastodon-a.herokuapp.com/headers/original/missing.png", "followers_count": 1, "following_count": 1, "statuses_count": 1, "last_status_at": "2019-12-15T01:57:57.239Z", "emojis": [], "fields": [] }, "media_attachments": [], "mentions": [], "tags": [], "emojis": [], "card": null, "poll": null }ローカルアカウントaliceはリモートアカウントbobにフォローされているので、Sidekiqからトゥートを配達しました。リモートサーバ共有の
/inbox
にContent-Type: application/activity+json
で、alice#main-key
によるHTTP署名を付けて下記の内容をPOSTしました。Create Activityで、HTTPヘッダだけではなくリクエストボディのObjectにも署名が付いていました。Status 202でボディの無いレスポンスが返されました。{ "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri", "inReplyToAtomUri": "ostatus:inReplyToAtomUri", "conversation": "ostatus:conversation", "sensitive": "as:sensitive", "toot": "http://joinmastodon.org/ns#", "votersCount": "toot:votersCount" } ], "id": "https://zundan-mastodon-a.herokuapp.com/users/alice/statuses/103309317046763904/activity", "type": "Create", "actor": "https://zundan-mastodon-a.herokuapp.com/users/alice", "published": "2019-12-15T01:57:57Z", "to": [ "https://www.w3.org/ns/activitystreams#Public" ], "cc": [ "https://zundan-mastodon-a.herokuapp.com/users/alice/followers" ], "object": { "id": "https://zundan-mastodon-a.herokuapp.com/users/alice/statuses/103309317046763904", "type": "Note", "summary": null, "inReplyTo": null, "published": "2019-12-15T01:57:57Z", "url": "https://zundan-mastodon-a.herokuapp.com/@alice/103309317046763904", "attributedTo": "https://zundan-mastodon-a.herokuapp.com/users/alice", "to": [ "https://www.w3.org/ns/activitystreams#Public" ], "cc": [ "https://zundan-mastodon-a.herokuapp.com/users/alice/followers" ], "sensitive": false, "atomUri": "https://zundan-mastodon-a.herokuapp.com/users/alice/statuses/103309317046763904", "inReplyToAtomUri": null, "conversation": "tag:zundan-mastodon-a.herokuapp.com,2019-12-15:objectId=1:objectType=Conversation", "content": "<p>テストですと</p>", "contentMap": { "ja": "<p>テストですと</p>" }, "attachment": [], "tag": [], "replies": { "id": "https://zundan-mastodon-a.herokuapp.com/users/alice/statuses/103309317046763904/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://zundan-mastodon-a.herokuapp.com/users/alice/statuses/103309317046763904/replies?only_other_accounts=true&page=true", "partOf": "https://zundan-mastodon-a.herokuapp.com/users/alice/statuses/103309317046763904/replies", "items": [] } } }, "signature": { "type": "RsaSignature2017", "creator": "https://zundan-mastodon-a.herokuapp.com/users/alice#main-key", "created": "2019-12-15T01:57:57Z", "signatureValue": "fpAmHqxW3L0hPuQGU3JLjliWGt0LtIbKDOg2uASmRs4qkLcXWP+dK5XHO6y8GulLZDMbN864JU9c9c17148HxfWmDKzV2ImlStDU/EqLit4M8tC+a+8AC05VYcKxD6xXmS6OZt3xaMxrb2Fx9MZZ64tegDDugDEN0DQpeNuABrwKgxyuOe4dHbw6OIU47iyRyxWiPjOpaL4b8r8x2MzYIDV1rwbqnuqJCbJJdPZK4Se1n0YnLmtRXQvL800o214J//nWKZ2PAyaKiR3ViEGi2szEZBUkIK+dcsKADreaC8s3JS/DfcSVRDMKJwFUvQwjstWhwT3kfNIAOqXU6Lv20Q==" } }
- 投稿日:2019-12-15T11:36:01+09:00
Dockerにおけるbinding.pryの使い方
はじめに
今回はdockerでポートフォリオを作成中なのですがこれまで使用していた仮想環境とdockerでbinding.pryの使い方が違ったのでまとめておきます。
全体流れ
gemのインストール
↓
デバッグしたい箇所にpry
↓
コンテナを再起動
↓
railsコンテナにアタッチして接続
↓
デバッグgemのインストール
gemfileに記入してbundle installです。開発環境でしか使用しないのでdevelopmentに書いています。
デバッグしたい箇所にpry
コンテナを再起動、アタッチで接続
docker psで起動中のコンテナの確認、docker attach コンテナ名でコンテナに接続します。この状態で記入した画面でリロード、操作すると中断されてインスタンス変数の中身の確認等を行うことができます。
ctrキー→p→qの順番で抜けれます
- 投稿日:2019-12-15T11:20:18+09:00
ユーザー認証ってなんだ??(超初学者)
~投稿者のスペック~
・プログラミング歴2ヶ月
・ポンコツです
とあるRailsの教材を進め(掲示板作る系)途中で何回か???な事ありましたが、
前進あるのみでやっとのことでユーザー認証の仕組みまでたどり着けました。
ユーザー認証って??
システムやアプリケーションを正当に使用できるユーザーかどうかの確認などに使われる。たとえば、LAN上のサーバーにログインする場合、ユーザー名とパスワードでユーザー認証が行われる。〜コトバンク引用〜
はい、意味不明ですね。
ではユーザー認証例見てみましょう。
①Aさんが寒くなってきたのでダウンを買おうとECサイトのユルクロをみました。
②このウルトラヘビーダウンいいじゃん!(カートに入れる)
③Aさんが決済前に誤ってサイトを閉じる
④ダウンどこにあったっけ...とサイトを開くとカートのなかにダウンがある!ラッキー♪
これよくありますよね。
特になにも考えてなかったですけどよくよく考えると不思議ですよね。
調べてみると実はこれもユーザー認証なんですね。ではもう一度
①Aさんが寒くなってきたのでダウンを買おうとECサイトのユルクロをみました。
サーバーにユルクロのページを見せてとリクエスト、
サーバーは対象のウェブページと会員証をAさんのパソコンに返す
②このウルトラヘビーダウンいいじゃん!(カートに入れる)
③Aさんが決済前に誤ってサイトを閉じる
④ダウンどこにあったっけ...とサイトを開くとカートのなかにまだダウンがある!ラッキー♪
Aさんのパソコン上に保存されている会員証をサーバーが読み取り、さっきカートにダウン入れた人だと判断し表示内容を変更してくれる。
この会員証をCookieと呼び。
Cookieには会員ナンバー(user_id)とその他情報が付いている。
例で言うと
ブラウザは
会員ナンバー3000の人は買い物カゴにダウン×1を覚えてくれている感じですね。
会員証をサーバーに見せると上記の情報も反映してくれる感じです。
とりあえず今回は超大枠のみで細かいところまで理解できたらまた記事にしようと思います。
超初学者目線のエラー解決までの道のりや勉強のアウトプット用にQiitaを使わせてもらおうかなと思っています(^^)間違っているところやこっちのがわかりやすいよってあれば教えて下さい。(優しめで)
- 投稿日:2019-12-15T05:42:32+09:00
Deviseのサインイン後のリダイレクト先は5パターン
結論
以下、5パターン。上から順に優先度が高い。
usersというモデルにdeviseをマッピングさせた場合、
- ログイン前にアクセスしようとしたページ
- user_root_path(as: user_root_pathを指定したパターン)
- user_root_path(名前空間 + rootで設定したパターン)
- root_path(deviseマッピングとは関係ないroot_path)
- "/"
詳細(サインインの観点から)
「サインイン後のリダイレクト先を設定したい!!」
となると、after_sign_in_path_forメソッドを使いますが、
きちんと理解しておかないと、「なんかうまくいかないな」という状態になりがちです。
しっかりやっていきましょう。下記は、after_sign_in_path_forのソースです。
Git_Hub:after_sign_in_path_forメソッドdevise/lib/devise/controllers/helpers.rbdef after_sign_in_path_for(resource_or_scope) stored_location_for(resource_or_scope) || signed_in_root_path(resource_or_scope) endさすが有名gemです。メソッド名でなんとなくわかりますね!!
- stored_location_for(resource_or_scope)
未ログイン時にアクセスしようとしたページがあった場合、サインインした後に飛ばす。
- signed_in_root_path(resource_or_scope) いきなりログインページにアクセスした場合は、このメソッドの戻り値の場所に飛ばす。
ログイン後のリダイレクト先の理解には、
signed_in_root_path(resource_or_scope)メソッド
の理解がキーになりそうです。早速見てみましょう。
devise/lib/devise/controllers/helpers.rbdef signed_in_root_path(resource_or_scope) # モデルオブジェクトが渡されると、deviseでマッピングされたスコープ(usersとか)を返す。 # もしdeviseでマッピングしているモデルがadminという名前空間に属している場合、:admin_usersになる。 scope = Devise::Mapping.find_scope!(resource_or_scope) # deviseを使うモデルが複数ある場合、mappings(ハッシュ)のうち[scope]を取得し、そのrouter_nameを代入する。 # router_nameは、Devise::Mappingに対してoptionが渡されないとnilになるから基本nilっぽい。 router_name = Devise.mappings[scope].router_name home_path = "#{scope}_root_path" # router_nameは基本nilっぽいので、実行されない。selfはobjectクラスのmain。 context = router_name ? send(router_name) : self # contextに対してhome_pathを呼べるなら実行。trueはhome_pathがプライベートメソッドでも呼ぶよの意味。 if context.respond_to?(home_path, true) context.send(home_path) # contextに対して、root_pathを呼べるなら実行。 elsif context.respond_to?(:root_path) context.root_path # ただのroot_pathが呼べるなら実行。 elsif respond_to?(:root_path) root_path # トップページにリダイレクト else "/" end endふむふむ。
- context.respond_to?(home_path, true)
- context.respond_to?(:root_path)
の違いだけちょっとあいまいだが、
admin/users#indexとかの名前空間(admin)があるパターンは、
context.respond_to?(home_path, true)で実行される名前空間(admin)がないパターン(usersのみ)は
context.respond_to?(:root_path)で実行されると理解した。
home_path = "#{scope}_root_path"
の#{scope}は、
adminとかの名前空間 + deviseでマッピングしているモデル名となる。最終的にuser_root_pathとなるのは同じだが、内部的には意味が違うのではないだろうか。。。
下記は、user_root_pathで、home_pathと合致する。
devieでマッピングされたモデルに対して、home_pathはあるか?と聞いている。
context.respond_to?(home_path, true)
get to: "users#index", as: user_root_path下記は、user_root_pathだが、home_pathとは合致しない。
deviseでマッピングされたモデルに対して、root_pathはあるか?と聞いている。
context.respond_to?(:root_path)
namespace users do root to: "users#index" end認識違いあれば、ご指摘ください!!
参考URL
- 投稿日:2019-12-15T05:01:09+09:00
【AWS】RDSで作成したMySQLのDBに日本語が保存できないを解決 | Rails on Docker
はじめに
やっとのおもいでAWSにRails on Railsで作成したアプリケーションをデプロイできた!!
と感激していた直後、本番環境でユーザー登録をしてみたところ、こんな画面が登場して絶望しました。ただローカル側のテストではすべてパスしている、本番環境に問題がありそうということが分かったので、様々なパターンでユーザー登録を実行してみたところ「日本語で登録するときのみ」上のエラー画面が表示されることが判明した
解決策
1. パラメータグループを作成して文字コードを修正する
- AWSのコンソールから「RDS」のページを開く
- 左のメニューから「パラメータグループ」を選択
- 右上の「パラメータグループの作成」を選択
- パラメータグループファミリーを自身の環境に合わせて選択
- グループ名・説明は分かりやすい内容をおまかせで入力
パラメータグループが作成されたら一覧に作成したパラメータグループ名があることを確認してください
2. パラメータグループ一覧から作成したグループを選択
ここからパラメータ値の文字コードを編集していきます
- 右上の「パラメータの編集」を選択
- 「フィルタ パラメータ」の検索欄に[ character_set ]と入力
- 絞り込まれた値を次のように変更する
character_set_client : utf8
character_set_connection : utf8
character_set_database : utf8mb4
character_set_results : utf8
character_set_server : utf8mb4
- 検索欄に[ skip-character ]と入力
skip-character-set-client-handshake : 1
- 変更が完了したら右上の「変更の保存」を選択
パラメータグループの作成・編集が完了したら、データベースに作成したパラメータグループを適用させます。
3. データベースのパラメータグループを変更
- 左のメニューから「データベース」を選択
- パラメータグループを設定したいDBを選択
- 右上の「変更」を選択
- データベース設定 > DB パラメータグループの欄で先程作成したパラメータグループを選択
- 「次へ」を選択
- 変更スケジュールを「すぐに適用」を選んで保存
変更を保存した後、データベースの概要から「設定」タブを選択して、パラメータグループが「同期中」になるのを待ちます(15分ぐらい待ちました)
同期中になったのを確認したら、右上の「アクション」から「再起動」を選択して、概要の情報が「利用可能」になるのを待ちます。
4. 本番環境のデータベースをリセット・作成
- terminal からEC2にログイン
$ ssh -i ~/.ssh/xxxxx.pem user@xx.xxx.xxx.xxx
- プロジェクトのあるディレクトリに移動してDBをdropする
[xxx@xx.xxx.xxx.xxx] $ cd myapp [xxx@xx.xxx.xxx.xxx myapp] $ docker-compose run web bundle exec rails db:drop RAILS_ENV=production DISABLE_DATABASE_ENVIRONMENT_CHECK=1
- データベースを作成してmigrateする
[xxx@xx.xxx.xxx.xxx myapp] $ docker-compose run web bundle exec rails db:create RAILS_ENV=production [xxx@xx.xxx.xxx.xxx myapp] $ docker-compose run web bundle exec rails db:migrate RAILS_ENV=production
docker-compose up
して、日本語でユーザー登録してみたところ無事に登録が完了してくれました!参考
Elastic Beanstalkで一緒に作ったMySQLの日本語対応 | Qiita
本番環境のdbをリセットする方法 | Qiita
- 投稿日:2019-12-15T00:12:56+09:00
Rails6 usersテーブルにpasswordカラムが作成されなくて困った話
目的
- マイグレーションファイルの記載を間違えてコマンド
$ rails db:migrate
を実行してしまい、その後記載を正しいものにしてもpasswordカラムが作成されなかった時の解決方などをまとめる。結論
- 一旦マイグレーションファイルを削除して再度マイグレーションファイルを作成し記載内容を確かめた上で
rails db:migrate
を実行した。困りごとに至った経緯
- 自作プロダクトでログイン機能を実装したい。
- usersテーブルにはすでに
name
カラムとpassword
カラムの追加を行おうと思った。- コマンド
$ rails g migration add_password_to_users
を実行した。作成されたマイグレーションファイル名は「20191211134100_add_password_to_users.rb」である- マイグレーションファイルへの書き込みをしないままコマンド
$ rails db:migrate
を実行してしまう。マイグレーションファイルに記載をしていないことが判明、下記の内容を記載するもコードミスをする。(add_columnのnがない)
class AddPasswordToUsers < ActiveRecord::Migration[6.0] def change add_colum :users, :password, :string end endコードをミスっているマイグレーションファイルをコマンド
$ rails db:migrate
で反映させてしまう。マイグレーションファイルのコードミスに気がつき、修正し再度コマンド
$ rails db:migrate
を実行した。rails consoleにてusersテーブルのidが1のレコード情報をuserに格納しpasswordカラム情報を追記しようとしたところ下記のエラーが発生した。(passwordカラムなんて存在しないぜという感じのエラー)(エラーコード以外のrails console内での出力は省略)
irb(main):001:0> user = User.find_by(id:1) irb(main):002:0> user.password = "0000" Traceback (most recent call last): 1: from (irb):2 NoMethodError (undefined method `password=' for #<User:0x00007fd0e2424fe0>)解決までの経緯
現在のテーブルのカラムがどうなっているか知りたかったので
アプリ名/db/schema.rb
を確認しに行った。下記に問題発生時のschema.rbを記載する。ActiveRecord::Schema.define(version: 2019_12_14_093144) do create_table "posts", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", force: :cascade do |t| t.text "content" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.decimal "study_time", precision: 10 t.text "hash_tag" end create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.string "email" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false end end前述の結果からusersテーブルにpasswordカラムが存在していないことがわかる。
マイグレーションファイルが空状態やコードミス状態でのコマンド
$ rails db:migrate
の実行もありなんとなく原因がわかった。すでに、データベースに反映させたマイグレーションファイルはGit登録してしまっているので当該コミットをrevertするかマイグレーションファイルを作り直して再度コマンド
$ rails db:migrate
を実行するか迷った。マイグレーションファイルを再度作成し直してデータベースに反映させることにした。
下記コマンドを実行して引っ掻き回してしまったマイグレーションファイルを削除する。
$ rm -rf アプリ名/db/migrate/20191211134100_add_password_to_users.rb下記コマンドを実行してマイグレーションファイルを再作成した。
$ rails g migration add_password_to_users
作成されたマイグレーションファイルにミスがない様に記載をした。下記に正しいマイグレーションファイルの内容を記載する。
class AddPasswordToUsers < ActiveRecord::Migration[6.0] def change add_column :users, :password, :string end end下記コマンドを実行してマイグレーションファイルの内容をデータベースに反映させた。
$ rails db:migrate >== 20191214093144 AddPasswordToUsers: migrating >=============================== >-- add_column(:users, :password, :string) >-> 0.0144s >== 20191214093144 AddPasswordToUsers: migrated (0.0145s) >======================passwordカラムが追加されているか確かめるため
アプリ名/db/schema.rb
を確認しに行った。下記に問題発生時のschema.rbを記載する。ActiveRecord::Schema.define(version: 2019_12_14_093144) do create_table "posts", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", force: :cascade do |t| t.text "content" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.decimal "study_time", precision: 10 t.text "hash_tag" end create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.string "email" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.string "password" end endusersテーブル部分に
t.string "password"
の記載を発見したので無事passwordカラムが追加されていそう。rails consoleをでusersテーブルのidが1のユーザのpasswordが登録できるか試してみた。(エラーコードは出なかったためrails console内での出力は省略)
$ cd アプリ名フォルダ $ rails console irb(main):001:0> user = User.find_by(id:1) irb(main):002:0> user.password = "0000" irb(main):003:0> user.save User Update (0.5ms) UPDATE `users` SET `users`.`updated_at` = '2019-12-14 09:35:32.920487', `users`.`password` = '0000' WHERE `users`.`id` = 1 (0.7ms) COMMIT => true値をデータベースに保存する時にtrueが出たためpasswordカラムが作成され、正常に値も格納されたことを確認できた。
今回の件にはあまり関係無いが、
アプリ名/app/models/user.rb
にpasswordカラムの空欄を弾く様にバリデーションを追記した。下記にuser.rbの内容を記載する。class User < ApplicationRecord validates :email, {presence: true, uniqueness: true} validates :name, {presence: true} validates :password, {presence: true} end