20191215のRailsに関する記事は21件です。

Railsのバリデーションの仕組みを知らなかったので調べてみました

こちらはLinc'well Advent Calendar 2019の14日目の記事となります。

タイトルの通りですが、これまでRailsを数カ月間使用してきて、

Modelで何十回もバリデーションを定義したり、
create!したらバリデーションに引っかかってActiveRecord::RecordInvalid例外を起こしたり、
何十回もvalid?メソッドを呼び出したりとしてきたのに、

そういえばvalidationの仕組みを何も知らないな。と思ったので、
バリデーション機能をコードを追いながら調べてみて、その過程をまとめてみました。

間違い等見つけていただけましたら、コメント欄でご指摘いただけたら嬉しいです!

また、文中のRailsのコードはtag v6.0.0のものです。

調べた中での一番の学び

いきなりですが、今回調べてみての一番の学びを先に述べておくと、下記の事柄でした。

  • Railsのバリデーション機能は、コールバックを利用して実現されている。

バリデーションと呼ばれる機能の構成

今回調べてみるに当たって、分けたほうが調べやすいと思ったので、
Railsのバリデーション機能を構成しているものを大きく以下の2つに分けてみました。

  1. Modelにvalidates :name, presence: trueのような文言を記述すると、バリデーションを定義できる。1
  2. 1で定義されたバリデーションに応じて、オブジェクトがデータベースに保存されるかが決まる。 (savecreateをするときに、バリデーションに引っかかると保存されない。)

便宜上、以下では1を『バリデーション定義』、2を『バリデーション実行』という言葉で表現しています。

『バリデーション定義』のざっくり概要

そもそもバリデーションとしてModelに書いているvalidatesは何なのかというところですが、
これはクラスメソッドにあたります。
(カスタムバリデーションの定義で使うvalidatevalidate_withも同様です。)

私自身、正直validatesがクラスメソッドであることは意識することはあまりなく、なんとなく使っていますが、
Modelの定義でよく目にする、attr_accessorhas_manyも同様にクラスメソッドです。
(参考: Railsによるメタプログラミング入門 前田 修吾さん)

また、このようなclass定義の直下で呼び出されるクラスメソッドを、書籍『メタプログラミングRuby 第2版』では『クラスマクロ』と紹介しています。

ということで、validatesというクラスメソッドを探して、内容を確認すればどのようにバリデーションが定義されるかが分かるということになります。

『バリデーション実行』のざっくり概要

実際に、createのコードを確認してみると、以下の様にsaveが呼ばれ、そのsaveの中でperform_validationsというバリデーションらしきものが行われていることが分かります。

(ActiveRecordのどこに何が定義されているのかなどは、下記の書籍を参考にさせていただいて探しました。
参考: ActiveRecord完全に理解した kinoppydさん)

active_record/persistence.rb
    def create(attributes = nil, &block)
      if attributes.is_a?(Array)
        attributes.collect { |attr| create(attr, &block) }
      else
        object = new(attributes, &block)
        object.save
        object
      end
    end
active_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.rbvalid?を探して見てみると下記のようになっています。

context ||= default_validation_contextは、このvalid?の呼び出し元の処理がcreateとupdateのどちらによるものかという情報であるcontextを定義しているもので、

contextを引数に取るsuperを呼び出していることから、valid?をオーバーライドしていることが分かります。

active_record/validations.rb
    def valid?(context = nil)
      context ||= default_validation_context
      output = super(context)
      errors.empty? && output
    end

オーバーライドされているvalid?を探してみると、ActiveRecord::ValidationsがincludeしているActiveModel::Validationsというモジュールに、valid?が定義されているのが見つかります。

active_model/validations.rb
    def 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.rb
    def run_validations!
      _run_validate_callbacks
      errors.empty?
    end

そこで、この_run_validate_callbacksを見に行こうと思ったのですが、
コードジャンプしても全文検索しても見つからなかったため、
動的に定義されるメソッドであると推測されました。

が、それらしきものを発見することができず、ググったところ下記の記事が見つかりました。
(参考: Railsのvalidationの実行過程を調べる2

上記の記事を引用させていただくと、この_run_validate_callbacksは、

ざっくりまとめると、当該モデルに設定されたcallbacksの内、 :validateのキーに引っかかるcallbacksを実行していっていると言うことになります。

とのことでした。

この時点では、『:validateのキーに引っかかるcallbacks』というものが一体何なのか知らなかったのですが、
ここまでのバリデーション実行の流れをざっくりまとめると、下のようになりました。

  1. オブジェクトをsaveする際に、valid?が呼ばれる。
  2. valid?から始まる処理の中で、_run_validate_callbacksが実行されることで『:validateのキーに引っかかるcallbacks』というものが実行され、@errorsに何かしらの値が入る(これはこの時点では予想ですが、カスタムバリデーションを定義する時に、record.errors.addのように@errorsに値を入れることからも予想ができます。)
  3. @errorsの中身が空であるかを確認し、空であればtrueを、そうでなければfalseを返し、それによってvalid?の戻り値が決まる。
  4. 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配下に、LengthValidatorInclusionValidatorなどのバリデーションの種類に対応したValidatorクラスがそれぞれ用意されています。)

最後のvalidates_withの呼び出しは、カスタムバリデータを使ったバリデーションの定義と同じ形になります。

active_model/validations/validates.rb
    def 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.rb
    def 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.rb
   define_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_callbacksset_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.rb
    def 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
    end

Callback.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の仕組みを追っていったところ、
とても難しく、本記事にうまくまとめられなそうだったため、また別の記事として調べようと思います。(もしくはこの記事に追記します。)

(完全に僕の実力不足でした。ここが一番気になるところなのにすみません。。)

ということで、一旦ここまでのバリデーション定義の流れをざっくりまとめると、下記のようになりました。

  1. クラスの読み込み時に、validatesといったバリデーション定義のクラスメソッドが実行される。
  2. validatesvalidates_with(カスタムバリデータを用いたバリデーション定義で使うやつ)→validate(カスタムメソッドを用いたバリデーション定義で使うやつ)の順でメソッドが呼ばれる。
  3. validateでは、バリデーションの種類(:presenceとか:length)に応じて、対応するValidatorクラスのインスタンスを生成し、それを引数に含めてset_callbackメソッドを呼び出す。
  4. set_callbackメソッドでは、引数で受け取ったValidatorを使用しながら、バリデーションを実行する処理を含むコールバックを定義する。(そして、このコールバックはvalid?が呼ばれると実行されることになる。)

感想

色々と知らなかったことを知ることができ、とても勉強になりました。

余談として、attr_accessorといったアクセサや、has_manyといったアソシエーションも同じクラスマクロとして調べる対象の候補だったのですが、
ActiveRecord完全に理解した』にて、前者は『すごく長い』、後者は『難しい』と紹介されていたので、特に何も書かれていなかったValidationsを今回は選択してみました。

勉強になったので、今度は他のクラスマクロも同様に調べてみようと思います。
(その前にCallbackとValidatorをきちんと調べてから。)

参考文献

下記の文献を見て色々と勉強させていただきながら、この記事を書いてみました。
どれも勉強になりました。


  1. ここでModelと表している・想像しているものは主にApplicationRecordを継承しているクラスになります。ApplicationRecordはActiveRecord::Baseを継承しており、ActiveRecord::Baseにバリデーションの機能が組み込まれています。 

  2. この記事にたどり着くのが書いてる中で後半の方になってしまったのですが、僕の知りたいことがたくさん書いてあって、とても参考にさせていただきました。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ぼくのかんがえた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でよいと思います。

不要な初期コメント・ファイルを消す

各種ファイルをガッツリ掃除します。

コメント系

.gitignoreGemfileconfig/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.rbproduction.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
end
config/environments/development.rb
Rails.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
end
config/environments/production.rb
Rails.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.yml
default: &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.rb
config.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.rb
config.session_options[:secure] = false

まとめ

rubyを用意してrails newするだけかと思いきや、アプリケーションコードを書くまでに考えるべき共通事項は案外多いです。

これは自分のよくある環境での一つの解でしかなく、開発体制やアプリケーションの規模、全体アーキテクチャによってもかなり変わってくると思いますが、考え方などなにか参考になればと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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にアクセスし、下の画像のような画面が表示されたらセットアップ完了です。

スクリーンショット 2019-12-14 21.17.12.png

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_attachmentsactive_storage_blobsというテーブルが作成されていれば、インストール成功です。

ActiveStorageは、初期設定ではDisk内(storage以下)にアップロードしたファイルデータを保存するようになっているため、amazon s3のストレージを使用する記述を追加します。

まずは使用するストレージの設定(今回だとamazon s3)を以下のファイルに追加してください。

concig/storage.yml
test:
  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 = :amazon

S3のための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.encdevelopment.keyというファイルを作成してくれます。

以下のように編集してください。

development.yml.enc
aws:
  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の関係を以下の図に表します。
スクリーンショット 2019-12-15 17.16.01.png

Userモデルに1つのファイルを紐づける場合

1ユーザーに1つの画像ファイルしか紐づかない場合、上記の図のNが1になります。まずはこのパターンを実装していきたいと思います。

app/models/user.rb
class User < ApplicationRecord
  has_one_attached :avatar
end

Userモデルにavatarという属性を追加しました。今回はavatarという命名にしましたが、用途に合わせて自由に指定することが出来ます。
ActiveStorageでは、ファイルデータを保存するためにそれぞれのテーブルに個別にカラムを用意しなくても上記の記述を追加するだけでactive_storage_attachmentsactive_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.rb
def 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>

では、早速rails sをして、投稿してみましょう!
てす.gif

Userモデルに複数のファイルを紐づける場合

次は複数投稿投稿出来るようにしてみますz。

app/models/user.rb
class 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.rb
def 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をして試してみましょう!
てす2.gif

image_processingを用いたリサイズ

画像投稿した後は、画面サイズに合わせて画像を取得したくなりますよね。Rails6からimage_processingというgemを使用することが推奨されているため、そちらを使用してリサイズを行っていきます。
Gemfileimage_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 %>

スクリーンショット 2019-12-15 21.08.51.png

簡単にリサイズされましたね。

おまけ

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?
=> false

ActiveStorageでは専用のValidationが用意されていないため、各自で作成しなくてはいけません。
簡単にContent_Typeを確認するValidationを作ってみました。

app/models/user.rb
class 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などを使用する際は少し工夫が必要になるかなと思いました。
次回書けたら書きます・・・。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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 install

2.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.rb
class 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
end

Userモデルは以下のようになっています。
デフォルトではdatabase_authenticatable、registerable、recoverable、rememberable、trackable、validatableが使えるようになっています。

app/models/user.rb
class 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.rb
class 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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

db:migrateについて

勉強中に分からなくなったことをまとめて、あとで見返せる様に書いた。
今回はrailsで勉強をしていて、db:migrateが分からなdbかった。

どんな時に使うのか

-railsで使用するデータベース構造を変更する時に使う
-①migration fileを作成する。
-②db:migrationコマンドを実行するとき

今回は②番
Railsだとマイグレーションファイルを実行することで、設計図を元にテーブルを作成したり、カラムを追加したりできる。

rails db:migrate
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Classiエンジニア新人研修での学び方の学び

Classi Advent Calendar 2019の16日目の記事をご覧いただき,ありがとうございます!
2019年新卒エンジニア@willsmileです.自称コーヒー好きですが,コーヒーのことを語ると,教育と学習の方に“脱線”してしまい,「結局,教育のほうが趣味なんじゃないの」と最近自覚した者です.

まえがき

本文では,Classiエンジニア新人研修を通じて,自分自身が「気づいた開発について新たな観点(の一例)」を語り,将来の自分,あるいは他の人にも参考できるように,「どうやって気づいたのか」を一段上の観点から言語化することを試みます.

本題

研修の概要

Classiエンジニア新人研修のカリキュラム担当者が業界で有名なRubyist@igaigaです.Classi入社前に@igaigaさんが著者である「ゼロからわかるRuby超入門」という本を持っています.それを読んでいて,Ruby言語の概念の厳密さと理解しやすさのバランスをよく取れている本という印象を残っています.@igaigaさんとの初対面の自己紹介で,「あの本の著者だ!すごい!」というびっくりから,研修の一日目が始まりました.

研修の題材と進み方について,4日目の記事@yukoonoさんが紹介したように,万葉さんの公開カリキュラムにベースにして,各課題ステップにおいて,実装案の議論・検討,コーディング,コードレビューと修正といった「疑似体験」のサイクルが回します.このプロセスを通じて,わかったこと・気づいたことをメンターと話し合いながら,一緒に整理することで,これからの業務とつながるように,自分自身の理解を構築(再構築)していきます.これからは,実装案の議論・検討について,一個の例を通じて,自分自身の気づきを紹介します.

気づきの経緯

カリキュラムで定義されたタスク管理システムの要件の一つとして,「タスクに優先順位をつけたい」があります(以下の図がシステムの目標状態のイメージ).

Untitled.png
図1.タスクとその優先順位(イメージ)

カリキュラムのステップ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.実装案のメリットとデメリットの比較
Untitled 1.png
注:上の表には,コストという概念を使っているが,この例の場合.プロの人にとって,実装のコストの差があまりなく,適切ではないかもしれないが,ここの意図は「コストというよく見かける判断軸を登場させる」ことである.

それを通じて,「正解って,実はないですね!」ということが初めて気づきました.つまり,それぞれの実装案はメリットとデメリットがあって,どれにするかが具体的な状況に合わせて選択することが重要です.例えば,自分が「最適だ」と思っていた実装案③でも,「何でもかんでもマスターデータにすると,管理するのは大変になっちゃうよ」というデメリットがありますし,最初に却下した実装案①でも,「後からソートしやすいね」というメリットがあります.問題の本質は,コスト,機能の拡張性といったことについてどう考えているのです.もちろん,それについて,ディベロッパーだけではなく,デザイナー,プロダクトマネージャーなどの異なる役割の方を含めて,一緒に議論して考えることは大切だと考えます.
このように,自身の「うまく考えていなかったことで,やっちゃった」経験から,様々な人の知恵を借りて,経験者の補助で振り返ることを通じて,プロのように考える「判断軸」(もちろん,これは氷山の一角にすぎない)を見つけていました.

あとがき

その2ヶ月間の研修を通じて,@igaiga先生,メンターの@nagatashinyaさん,またたくさんのエンジニアの先輩たちのおかけで,今でも「生きている経験」をたくさん得ました.以下の研修卒業の時に,自分のTwitterのメッセージで,本日の話を締めます.
Twitter_message.png
明日の投稿は,メンターの@nagatashinyaさんです.お楽しみに!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

スクリーンショット 2019-12-15 16.07.14.png

コントローラーとルーティングを追加していきます。

rails g controller rooms show
routes.rb
Rails.application.routes.draw do
  get 'rooms/show'
  root 'rooms#show'
end

この時点で、こうなっていれば成功です。

スクリーンショット 2019-12-15 16.16.03.png

②SkyWayのdeveloper登録
Community Editionが無料なので、このプランで登録していきます。
https://webrtc.ecl.ntt.com/signup.html

スクリーンショット 2019-12-15 16.19.29.png

登録して、ダッシュボードに行ったらアプリケーションを作成に進んでください。
screencapture-console-webrtc-free-ecl-ntt-add-2019-12-15-16_20_53.png

ドメイン名の部分は、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で作ったコンポーネントをレンダリングしています。

こうなっていれば成功!
スクリーンショット 2019-12-15 16.31.45.png

ではいよいよSkywayAPIを導入していきます。

hello_vueと同じ階層にjsファイルを作成します。

app/javascript/packs/room.js
import 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/6e1f56678b2eb6318594

githubリポジトリ
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タグに反映されます。

名称未設定.gif

かけたい相手のIDを入力するとP2Pの通信が始まります!
スクリーンショット 2019-12-15 18.27.18.png

スクリーンショット 2019-12-15 18.27.26.png

最後にgit push heroku masterした結果がこれです。
あとで変わるかもしれません。
https://morning-meadow-17444.herokuapp.com/

最後に

いかがでしたでしょうか?

ビデオチャットが簡単に作れるSkywayAPIすごいですね。
本来であれば、Turnサーバー・Stunサーバー立ててーごにょごにょしなきゃいけないと思いますが、そこの部分を全てやってくれます。

もともと複数ユーザーが同時に参加できるカンファレンス式のビデオチャットにする予定だったので、roomという言葉をよく使っています。次回できたら複数参加もできるように実装したいです。

roomよりもchatとかの方がしっくりくるかもしれません。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Ruby on Rails] 他のテーブルを参照してidを取得し、外部キーとしてデータを挿入する

実現したい事

データを挿入するテーブルとは別のテーブルの情報を参照して、該当するデータのidを外部キーとして、元のテーブルにデータを追加する
スクリーンショット 2019-12-15 17.32.24.png

前提

テーブルは生産量のデータを持つ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.rb
class 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
end

permitでは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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Railsチュートリアル 第12章 パスワードの再設定 - PasswordResets#editの動作を、テスト駆動で実装していく

PasswordResetsコントローラーの、ここまでに実装してきた内容に対するテスト

PasswordResetsコントローラーのnewアクションおよびcreateアクションに対するテストは、Railsチュートリアル本文の内容に合わせていくと、以下のような内容になります。

test/integration/password_resets_test.rb
require '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.rb
  require '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.rb
  class 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'

emailという未定義の変数またはメソッドが呼び出されている」というエラーです。「editアクションの呼び出しの際、クエリパラメータにemailが含まれるようにする」必要がありますね。

editアクションの呼び出しの際、クエリパラメータにemailが含まれるようにする

この段階で、PasswordResetsリソースのeditアクションに対するビューを実装していきます。「クエリパラメータにemailが含まれるようにすること」も含める必要がありますね。実装場所は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アクションに対するテストは、逆にどのような実装抜けがあったらテストが失敗するのか

別記事にて解説しています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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 start

Rails側の設定

リクエスト元を設定してあげる。

Production.rb
  config.action_cable.allowed_request_origins = [ 'http://ドメイン', /http:\/\/ドメイン.*/]

WebSocket通信時のadapterを設定

Production.rb
development:
  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.conf
    upstream 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の環境にデプロイする

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Ruby & Rails チートシート

Rubyの論理値の評価

  • A && B
    true(nil, flase以外)が現れたら終わりでそれが論理値になる
  • A || B
    false(nilかfalse)が現れたら終わりでそれが論理値になる

特定のtest methodのみを走らせる

$ rails test test/integration/users_login_test.rb   TESTOPTS="--name test_login_with_valid_information"

本番デプロイ先のURLを確認できる

$ heroku info
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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
end

returnつけたりつけなかったり!!!なんなの!!!

不要なのに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感とか、書いててシックリくることも多いのですが、なんかやっぱ型システム周りの思想の違いは大きいなぁと思ってます。
ダックタイピングって誰得なのかがまだわかってないので、しっくりくる日を待ち望んでます。

かしこ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

エラーメッセージを日本語で表示したい!-機能実装方法について

個人アプリ作成中にエラーメッセージを日本語で表示したい!と思いましたが、想定よりも苦戦したので、以下に記載します。
(想定よりも苦戦した原因はymlファイルの設置と内容の編集です。。笑)

はじめに

-背景:
Tweet機能を持たせたアプリを作成中、文字数制限をValidationで設定していたため、
Tweet時、文字数制限をオーバーしたときにValidationエラーメッセージを出そうとしていました。
この機能自体は、無事実装できましたが、出てくるエラーメッセージのデフォルトは英語になってます。
そこで、今回エラーメッセージを日本語にする方法を調べましたので、以下にまとめます。

機能実装方法

1.rails-i18n(gem)実装

日本語化するにはrails-i18nというgemを使用します。

Gemfile
gem 'rails-i18n'

忘れずにbundle installします。

terminal
bundle install

2.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.yml
ja:
  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%AE

Ruby 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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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コマンドにおけるアクションの自動生成」を行います。対象はneweditの両アクションです。

テストを生成していない

rails generate controllerコマンドにおいて、--no-test-frameworkというオプションは、「テストを生成しない」という意味のオプションです。テストを生成しない理由は、今回実装するPasswordResetsコントローラーにおける、「コントローラーの単体テストは実装せず、統合テストのみでカバーする」という方針に基づくものです。

パスワード再設定用リソースに関するルーティングの定義

「ビューを必要とする」ということは、「リソースにアクセスするためのURLが必要となる」ということでもあります。そうしたURLを定義するのは、config/routes.rbにおけるルーティング定義でしたね。早速、ルーティングを定義していきましょう。

config/routes.rb
  Rails.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リソースで必要となるルーティングは、newedit、およびそれぞれに対してRDBに変更を反映するcreateupdate、以上の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」と表示されたリンクが追加されていますね。

スクリーンショット 2019-12-11 18.21.43.png

但し、現時点で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 skips

1.発展. ログイン画面に、パスワード再設定用のリンクが存在することに対するテストを実装してみましょう。

長くなりましたので、以下の記事に。

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_full.png

新たな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.rb
class 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アクションで定義していくことになります。

スクリーンショット 2019-12-12 8.17.17.png

演習

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.rb
require '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.rb
  class 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
  end

1. 試しに有効なメールアドレスをフォームから送信してみましょう (図 12.6)。どんなエラーメッセージが表示されたでしょうか?

スクリーンショットは以下です。

スクリーンショット 2019-12-14 5.40.10.png

サーバーログには、以下のようなエラーメッセージが残されています。

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 skips

2. コンソールに移り、先ほどの演習課題で送信した結果、(エラーと表示されてはいるものの) 該当するuserオブジェクトにはreset_digestreset_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_digestreset_sent_atには、確かにサーバーログと同じ値が格納されています。

以下のテストが成功する状況であれば、RDBにreset_digestreset_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.rb
require '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.rb
  require '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/という正規表現で示される文字列が含まれていない」ようですね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.rbconfig/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 GemHTTP::StateError: body has already been consumedというエラーを投げるようになります。config/initializers/httprb-response-body.rb
というファイルを作成してHTTP::Response::Bodyreadpartialメソッドと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として作り、それぞれにalicebobを登録してみました。これからこの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: gzipAccept: 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からトゥートを配達しました。リモートサーバ共有の/inboxContent-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=="
  }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Dockerにおけるbinding.pryの使い方

はじめに

今回はdockerでポートフォリオを作成中なのですがこれまで使用していた仮想環境とdockerでbinding.pryの使い方が違ったのでまとめておきます。

全体流れ

gemのインストール

デバッグしたい箇所にpry

コンテナを再起動

railsコンテナにアタッチして接続

デバッグ

gemのインストール

gemfileに記入してbundle installです。開発環境でしか使用しないのでdevelopmentに書いています。
image.png

デバッグしたい箇所にpry

image.png
確認したいメソッド内でbinding.pryを記入します

コンテナを再起動、アタッチで接続

docker psで起動中のコンテナの確認、docker attach コンテナ名でコンテナに接続します。この状態で記入した画面でリロード、操作すると中断されてインスタンス変数の中身の確認等を行うことができます。

ctrキー→p→qの順番で抜けれます

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ユーザー認証ってなんだ??(超初学者)

~投稿者のスペック~

・プログラミング歴2ヶ月
・ポンコツです

 

とあるRailsの教材を進め(掲示板作る系)途中で何回か???な事ありましたが、
前進あるのみでやっとのことでユーザー認証の仕組みまでたどり着けました。

:gear:ユーザー認証って??

システムやアプリケーションを正当に使用できるユーザーかどうかの確認などに使われる。たとえば、LAN上のサーバーにログインする場合、ユーザー名とパスワードでユーザー認証が行われる。〜コトバンク引用〜 

:beginner:はい、意味不明ですね。

ではユーザー認証例見てみましょう。


①Aさんが寒くなってきたのでダウンを買おうとECサイトのユルクロをみました。

②このウルトラヘビーダウンいいじゃん!(カートに入れる)

③Aさんが決済前に誤ってサイトを閉じる

④ダウンどこにあったっけ...とサイトを開くとカートのなかにダウンがある!ラッキー♪


これよくありますよね。
特になにも考えてなかったですけどよくよく考えると不思議ですよね。
調べてみると実はこれもユーザー認証なんですね。

ではもう一度


①Aさんが寒くなってきたのでダウンを買おうとECサイトのユルクロをみました。

サーバーにユルクロのページを見せてとリクエスト、
サーバーは対象のウェブページと会員証をAさんのパソコンに返す

②このウルトラヘビーダウンいいじゃん!(カートに入れる)
③Aさんが決済前に誤ってサイトを閉じる
④ダウンどこにあったっけ...とサイトを開くとカートのなかにまだダウンがある!ラッキー♪

Aさんのパソコン上に保存されている会員証をサーバーが読み取り、さっきカートにダウン入れた人だと判断し表示内容を変更してくれる。


この会員証をCookieと呼び。

Cookieには会員ナンバー(user_id)とその他情報が付いている。

例で言うと

ブラウザは
会員ナンバー3000の人は買い物カゴにダウン×1

を覚えてくれている感じですね。

会員証をサーバーに見せると上記の情報も反映してくれる感じです。

とりあえず今回は超大枠のみで細かいところまで理解できたらまた記事にしようと思います。


:beginner:
超初学者目線のエラー解決までの道のりや勉強のアウトプット用にQiitaを使わせてもらおうかなと思っています(^^)

間違っているところやこっちのがわかりやすいよってあれば教えて下さい。(優しめで)


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.rb
def 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.rb
def 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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【AWS】RDSで作成したMySQLのDBに日本語が保存できないを解決 | Rails on Docker

はじめに

やっとのおもいでAWSにRails on Railsで作成したアプリケーションをデプロイできた!!
と感激していた直後、本番環境でユーザー登録をしてみたところ、こんな画面が登場して絶望しました。

FireShot Capture 009 - We're sorry, but something went wrong (500)_ - techlog-316940954.ap-northeast-1.elb.amazonaws.com.png

ただローカル側のテストではすべてパスしている、本番環境に問題がありそうということが分かったので、様々なパターンでユーザー登録を実行してみたところ「日本語で登録するときのみ」上のエラー画面が表示されることが判明した

解決策

1. パラメータグループを作成して文字コードを修正する

  1. AWSのコンソールから「RDS」のページを開く
  2. 左のメニューから「パラメータグループ」を選択
  3. 右上の「パラメータグループの作成」を選択
  4. パラメータグループファミリーを自身の環境に合わせて選択
  5. グループ名・説明は分かりやすい内容をおまかせで入力

パラメータグループが作成されたら一覧に作成したパラメータグループ名があることを確認してください

2. パラメータグループ一覧から作成したグループを選択

ここからパラメータ値の文字コードを編集していきます

  1. 右上の「パラメータの編集」を選択
  2. 「フィルタ パラメータ」の検索欄に[ character_set ]と入力
  3. 絞り込まれた値を次のように変更する
  • character_set_client : utf8
  • character_set_connection : utf8
  • character_set_database : utf8mb4
  • character_set_results : utf8
  • character_set_server : utf8mb4
  1. 検索欄に[ skip-character ]と入力
  • skip-character-set-client-handshake : 1
  1. 変更が完了したら右上の「変更の保存」を選択

パラメータグループの作成・編集が完了したら、データベースに作成したパラメータグループを適用させます。

3. データベースのパラメータグループを変更

  1. 左のメニューから「データベース」を選択
  2. パラメータグループを設定したいDBを選択
  3. 右上の「変更」を選択
  4. データベース設定 > DB パラメータグループの欄で先程作成したパラメータグループを選択
  5. 次へ」を選択
  6. 変更スケジュールを「すぐに適用」を選んで保存

変更を保存した後、データベースの概要から「設定」タブを選択して、パラメータグループが「同期中」になるのを待ちます(15分ぐらい待ちました)

同期中になったのを確認したら、右上の「アクション」から「再起動」を選択して、概要の情報が「利用可能」になるのを待ちます。

4. 本番環境のデータベースをリセット・作成

  1. terminal からEC2にログイン
$ ssh -i ~/.ssh/xxxxx.pem user@xx.xxx.xxx.xxx
  1. プロジェクトのあるディレクトリに移動して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
  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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6 usersテーブルにpasswordカラムが作成されなくて困った話

目的

  • マイグレーションファイルの記載を間違えてコマンド$ rails db:migrateを実行してしまい、その後記載を正しいものにしてもpasswordカラムが作成されなかった時の解決方などをまとめる。

結論

  • 一旦マイグレーションファイルを削除して再度マイグレーションファイルを作成し記載内容を確かめた上でrails db:migrateを実行した。

困りごとに至った経緯

  1. 自作プロダクトでログイン機能を実装したい。
  2. usersテーブルにはすでにnameカラムとemailカラムと初期設定カラムが作成されていたのでマイグレーションファイルを作成してpasswordカラムの追加を行おうと思った。
  3. コマンド$ rails g migration add_password_to_usersを実行した。作成されたマイグレーションファイル名は「20191211134100_add_password_to_users.rb」である
  4. マイグレーションファイルへの書き込みをしないままコマンド$ rails db:migrateを実行してしまう。
  5. マイグレーションファイルに記載をしていないことが判明、下記の内容を記載するもコードミスをする。(add_columnのnがない)

     class AddPasswordToUsers < ActiveRecord::Migration[6.0]
        def change
          add_colum :users, :password, :string 
        end
     end
    
  6. コードをミスっているマイグレーションファイルをコマンド$ rails db:migrateで反映させてしまう。

  7. マイグレーションファイルのコードミスに気がつき、修正し再度コマンド$ rails db:migrateを実行した。

  8. 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>)
    

解決までの経緯

  1. 現在のテーブルのカラムがどうなっているか知りたかったのでアプリ名/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
    
  2. 前述の結果からusersテーブルにpasswordカラムが存在していないことがわかる。

  3. マイグレーションファイルが空状態やコードミス状態でのコマンド$ rails db:migrateの実行もありなんとなく原因がわかった。

  4. すでに、データベースに反映させたマイグレーションファイルはGit登録してしまっているので当該コミットをrevertするかマイグレーションファイルを作り直して再度コマンド$ rails db:migrateを実行するか迷った。

  5. マイグレーションファイルを再度作成し直してデータベースに反映させることにした。

  6. 下記コマンドを実行して引っ掻き回してしまったマイグレーションファイルを削除する。

    $ rm -rf アプリ名/db/migrate/20191211134100_add_password_to_users.rb
    
  7. 下記コマンドを実行してマイグレーションファイルを再作成した。

    $ rails g migration add_password_to_users
    
  8. 作成されたマイグレーションファイルにミスがない様に記載をした。下記に正しいマイグレーションファイルの内容を記載する。

    class AddPasswordToUsers < ActiveRecord::Migration[6.0]
      def change
        add_column :users, :password, :string 
      end
    end
    
  9. 下記コマンドを実行してマイグレーションファイルの内容をデータベースに反映させた。

    $ rails db:migrate
    >== 20191214093144 AddPasswordToUsers: migrating 
    >===============================
    >-- add_column(:users, :password, :string)
    >-> 0.0144s
    >== 20191214093144 AddPasswordToUsers: migrated (0.0145s) 
    >======================
    
  10. 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
    
    end
    
  11. usersテーブル部分にt.string "password"の記載を発見したので無事passwordカラムが追加されていそう。

  12. 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
    
  13. 値をデータベースに保存する時にtrueが出たためpasswordカラムが作成され、正常に値も格納されたことを確認できた。

  14. 今回の件にはあまり関係無いが、アプリ名/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
    
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む