20191129のRailsに関する記事は30件です。

Twitterでのコード投稿の見栄えどうにかならんのと思った話

たぶん、長い投稿

きっかけ(こんな呟きを見かけた

出来たもの

キャプチャ.JPG

作成の過程で収穫物

  • Rails5.2での追加分(Active Record Storage含む
  • Twitter Login方法と仕組み、そのたTwitterあれこれ
  • JSの基礎(getElementByIdやsetAttribute、文字カウントなど
  • AWS S3の使い方
  • XSS対策

未だ残る改修すべき箇所

  • 検索結果画面のリダイレクトエラー(多分route.rbの書き順番由来
  • js辺りのエラー(動いてるけど、consoleではjs/mapのルーティングが何とか
  • スマホで
    タグ等を打つの面倒なので、なにか投稿補助ボタンでも
  • 本来の目的をよく考えたら、マークダウンの方は不要なのでは。
  • 作成要件

    Untitled.png

    • マークダウン投稿、シンタックスハイライト
      • gem: redcarpet, rouge(結局syntax-hightlightだけは反映されないまま
    • 投稿から画像生成
    • AWS S3にog:image用の画像を保存

    作成の流れ:予定

    1. rails new codr, git init, heroku create、Active Storage
    2. AWS S3あれこれ
    3. twitter登録、ログイン機能作成

    開発環境

    • vm : Linux Ubuntu (virtualbox + vagrant)
      • Ruby 2.5.1p57
      • Rails 5.2.3
      • Postgresql

    実作業: アプリ作成、諸準備

    rails new codr -d postgresql
    # DB設定等は割愛
    

    Gem

    今回は公開にまで至る予定なので、railsやdeviseの日本語化等も。が、想定ユーザはエンジニアだしと思い、殆ど英語になった。

    Gemfile
    gem 'mini_racer'   # uncomment
    gem 'rails-i18n'   # japanize
    
    # authetication
    gem 'devise' # login
    gem 'omniauth' # SNS login
    gem 'omniauth-twitter' # twitter login
    gem 'devise-i18n'  # japanize devise
    gem 'devise-i18n-views'
    
    gem 'redcarpet'   # for markdown
    gem 'rouge' # for syntax highlight
    
    gem 'meta-tags'
    
    gem 'aws-sdk-s3' # for aws s3
    

    kpumuk/meta-tags:Search Engine Optimization (SEO) for Ruby on Rails applications.は割愛。

    gitignore => rails.credentials.yml

    当初は.gitignoregem 'dotenv'等を使っていた。が、作成途中でRails5.2からのrails.credentials.ymlを知り、利用した。rails.credentials.ymlは暗号化されており、なお、復号化には/config/master.key`を利用。

    irb
    # editor setting
     EDITOR="vi" bin/rails credentials:edit
    # edit credentials.yml
    rails credentials:edit
    # show credential.yml
    rails credentials.yml:show
    
    # herokuにmaster.keyを環境変数として指定
    heroku config:set ENV_VAR="環境変数" --app "アプリ名"
    
    # 追加した変数を使用するには
    Rails.application.credentials.dig(:twitter, :API_Key)
    

    rails gあれこれ

    # devise
    # install devise
    rails g devise:install
    rails g devise User name:String
    
    # Add Admin column to User
    rails g migration AddAdminToUsers
    # add setting at /db/migrate/20191103141531_add_admin_to_users.rb
    add_column :users, :admin, :boolean, default: false
    
    # add views and controllers to modify devise
    rails g devise:controllers users
    rails g devise:views users
    
    # japanize
    # add at /config/application.rb
    config.i18n.default_locale = :ja
    => create /config/locale/devise.view.ja.yml
    
    # scaffold post
    rails g scaffold Post user:references name:string content:text date:datetime
    

    Active Record Associations関連付け

    /app/model/user.rb
    has_many :posts
    
    /app/model/post.rb
    belongs_to :user
    

    投稿関連

    マークダウン投稿

    基本:Redcarpet::Markdown.new(renderer, extensions = {}).render(@post.content)
    オプションやXSS対策等を追加したく、helperメソッドを作成した。

    app/helpers/posts_helper.rb
    Module PostsHelper
      require 'rouge/plugins/redcarpet'
      class RougeRedcarpetRenderer < Redcarpet::Render::HTML
        include Rouge::Plugins::Redcarpet
    
        def header(text, level)  # #や##等がh2、h3となるようにした。
          level += 1
          "<h#{level}>#{text}</h#{level}>"
        end
      end
    
      def markdown(text)
        render_options = {
          filter_html: true,  # do not allow any user-inputted HTML in the output.
          hard_wrap: true,
        }
    
        extensions = {
          autolink: true, # <>で囲まれていない時は、リンクとして認識しない
          fenced_code_blocks: true,   #  ```\n ```内をコード部分と見做す
          lax_spacing: true, 
          no_intra_emphasis: true,
          strikethrough: true,
          superscript: true,
          tables: false,  # テーブルを認識しない
          highlight: true,
          disable_indented_code_blocks: true,
          space_after_headers: false # #の後にスペースが無くても、h1等とする。
        }
        renderer = RougeRedcarpetRenderer.new(render_options)
        Redcarpet::Markdown.new(renderer, extensions).render(text).html_safe
      end
    end
    

    html_safe => sanitize

    html_safeではXSS対策としては駄目と知った。名前詐欺である。
    sanitizeヘルパーを使用した。ホワイトリスト方式。要参照

    app/views/posts/index.html.erb
    # sanitize(html, options = {})
     <div id="capture" class="content">
        <%= sanitize(markdown(@post.content), tags: %w(div img h1 h2 h3 h4 h5 strong em a p pre code ), attributes: %w(class href)) %>
    </div>
    

    投稿内容のデータ化、AWSへの画像保存

    最初はTwitterAPIを利用して、投稿から作成、DBに直接保存した画像でTwitter投稿しようとした。だが、Herokuでは画像が保持されない事、TwitterAPIの変更などいろいろ面倒なことが発生したので、最終的には画像をAWS S3に保存し、og:imageに添付する形を取った。

    1. Webアプリ内で通常投稿
    2. showページ表示(同時にhtml2canvasでBase64としてデータ取得、hidden_fieldに格納
    3. Tweetボタン押す(Postされ、postモデル内でbase64をデコード
    4. Active Storageを通して、AWS S3に保存

    Active Storage

    Rail5.2からの機能で、今までのcarrievaveやpaperclip等を使わずに、クラウドストレージ等へのアップロードが容易になる。今回はAWS S3を使った。

    irb
    # set up
    rails active_storage:install
    # 今回は画像が紐づくPostテーブルが既にあるので、不要
    # rails g resource comment content:text
    rails db:migrate
    
    app/models/post.rb
    class Post < ApplicationRecord
    # 今回は1つの投稿につき、1枚の画像なので。複数なら => has_many_attached :prtscs
      has_one_attached :prtsc
    end
    
    app/config/enviroments/
    # ファイル保存先変更
    # development.rb
    config.active_storage.service = :local
    # production.rb
    config.active_storage.service = :amazon
    

    rails credentials:editでAWSアクセスキーとシークレットキーを追加。

    config/credentials.yml.enc
    aws:
      access_key_id: 
      secret_access_key: 
    
    config/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: ap-northeast-1
      bucket: codr0
    
    Gemfile
    # gemが必要
    gem 'aws-sdk-s3', require: false
    # 今回は不要だったので、入れず。
    gem 'mini_magick'
    

    html2canvas

    参考:htmlを画像化する方法(html2canvasの使い方)
    jsはProgateレベルだったので、DOM操作は初めてで、なんか楽しかったぞ。

    1. Tweetボタン押下時に、画像をPostするためのフォーム、hidden_fieldを用意
    2. html2canvas.jsapp/assets/javascriptsディレクトリ配下に保存。
    3. html上に置くscriptコードを改修
    app/views/posts/show.html.erb
    <%= form_with(model: @post, local: true) do |form| %>
      <%= form.hidden_field :id, value: @post.id %>
      <%= form.hidden_field :prtsc, value: "" %> # idはpost_prtscになる。
      <%= form.submit "Post", class:"btn btn-outline-dark", id:"tweet", value:"tweet" %>
    <% end %>
    
    app/views/layouts/application.html.erb
    <script type="text/javascript">
      html2canvas(document.querySelector("#capture"),{scale:1, width:600}).then(canvas => {
        var base64 = canvas.toDataURL('image/jpeg', 1.0);
        document.getElementById('post_prtsc').setAttribute('value', base64);
     });
    </script>
    

    Base64デコード

    大学で画像処理していたとはいえ、 Base64とは?Blobとは?となり、良い機会だった。

    app/models/post.rb
    attr_accessor :img
    
    def parse_base64(img)
      if img.present?
        # data:image/jpeg;base64,/9j/4AAQSkZJRgABA・・・から/9j/4AA以降を選択取得
        content = img.split(',')[1]
        # 今回は、ユーザによる画像アップロード投稿ではなく、拡張子が決まっている
        filename = Time.zone.now.to_s + '.jpg'
        decoded_data = Base64.decode64(content)
        # String.IO.newにより、アプリ内に一時ファイルを作成しなくて済む
        prtsc.attach(io: StringIO.new(decoded_data), filename: filename)
      end
    end
    

    あとはposts_controllerで、paramsから受け取ったBase64データを上のparse_base64(img)で変換し、保存すれば完了。

    AWS S3

    AWS上での登録、設定、バケット作成等は割愛。

    Tweet button

    公式で生成されるTweetボタンのURLを利用し、押下時にwindow.openでTweet投稿ページを開くようにした。rubyonrailsで用意した変数をjsに渡すgem 'gon'も考えたが、見送った。

    app/views/layouts/application.html.erb
    <script>
      var base = 'https://twitter.com/intent/tweet?url=';
      var pageUrl = 'https://codr0.herokuapp.com/posts/' + document.getElementById('post_id').value;
      var option = '&button_hashtag=Codr0&ref_src=twsrc%5Etfw';
      var href = base + pageUrl + option;
      var twit = document.getElementById('tweet');
      twit.addEventListener('click', function() {
            window.open( href );
          });
    </script>
    

    og:imageに画像添付

    なお、headのmeta情報セットには、gem 'meta-tags'を使用。参照 : kpumuk/meta-tags

    service_url()とurl_for()

    基本的にはどちらも、ActiveStorageに保存したデータのUrlを取得するメソッドの様だ。
    どちらもセキュリティの為にリンクの有効期限が短いみたいだが、違いが分からなかった。今回はTweetボタン押下し、Tweetした際にog:imageとして表示されればいい。

    app/views/posts/show.html.erb
    # 画像がActive StorageでAWS S3に保存されて入れば
    <% if @post.prtsc.attached? %>
      <% set_meta_tags og:{image: @post.prtsc.service_url} %>
    <% end %>
    

    Twitterログイン

    TwitterDeveloperAccountが必要。割愛。

    なお、omniauthは脆弱性が見つかっており、githubの方でもアラートが来るのだが、パッチが無いのだが。クックパッドの人が対処してくれたので、感謝したい。

    app/models/user.rb
    # 参考ページと同じ基礎的な所は割愛する。
    class User < ApplicationRecord
      def self.from_omniauth(auth)
        find_or_create_by!(provider: auth['provider'], uid: auth['uid']) do |user|
          # 一部割愛
          user.username = auth['info']['nickname']
          # SNS登録時は、ダミーメールを登録
          user.email = User.dummy_email(auth)
        end
       end
    
      # SNS登録(providerが存在する)時は、パスワード要求をしない
      def password_required?
        super && provider.blank?
      end
    
      def self.new_with_session(params, session)
        if session['devise.user_attributes']
          new(session['devise.user_attributes']) do |user|
            user.attributes = params
          end
        else
          super
        end
      end
    
      private
    
      def self.dummy_email(auth)
        "#{auth.uid}-#{auth.provider}@example.com"
      end
    end
    

    Twitterのニックネームが取得できるようになったので、元からあるUserのnameテーブルは削除した。

    css

    今回はBootstrapを部分的に使用した。cssの優先順位など収穫があった。

    改修(加筆

    メディアクエリ

    想定ユーザは殆どスマホなのに、PCで作成し、CSSをPCの見た目でやってた。折角SCSSでやってるので、変数を利用した。

    app/assets/stylesheets/scaffold.scss
    # ディスプレイサイズが680pxまでなら。
    $tab: 680px; 
    @mixin tab {
      @media (max-width: ($tab)) {
        @content;
      }
    }
    
    // .box {
    //   @include tab {
    //     background-color: blue;
    //   };
    // }
    

    最後に

    gist等がコードスクショをog:imageで表示してくれたら全て済むんじゃと思った。
    因みにもう1段階先のWebアプリを考えてあるけど、たぶんjsの知識が足りないので、今は無理。
    転職活動中の無職です。働きたい。。。。

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

HamlをRuby on Railsアプリケーションに導入する方法

Hamlとは

Hamlとは、HTMLを簡潔かつ簡単に記述できるマークアップ言語。

Railsで使用する場合の手順

1. Hamlを導入

Gemfileの一番下にコードを記述すると、すべての環境でhamlが使用できる。
記述後は、bundle installを忘れずに

Gemfile
gem 'haml-rails'

2. erbファイルをHamlに変換

rails newにより、erbファイルがすでに作成されているので
拡張子がerbのファイルをhamlに変換する。

一括変換する方法

ターミナルから以下のコマンドを実行。

$ rails haml:erb2haml

ここで、

Would you like to delete the original .erb files? (This is not recommended unless you are under version control.) (y/n)

と聞かれたら、状況に応じてy/nを選択する。

元のerbファイルを削除して構わなければ yを選択しよう。

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

rails-tutorial第2章

scaffold

MVCアーキテクチャではmodel,view,controlerが組み合わさって一つのリソースと考える。scaffoldを使うとリソース単位で自動生成してくれる。
scaffoldをしてサーバーを立ち上げると、その時点からuserリソースを扱える。/users/newとかできる。

migration file

これはrailsとDBが全く別物だから、migration fileに書いて〜を保存するスペース作ってくれない?とお願いをしなければいけない。なので、rails generate scaffold User name:string email:stringなどの指示を出した後はrails db:migrateをしないとDBにリソースを保存することができない。ちなみに、rails db:migrateをせずにrails sをすると、 migration pending(DBに何かお願いすることあるんじゃないの?)というエラーを出してくれる。rails db:migrateはべきとうせいである。つまり何度実行しても追加したmigration fileがなければ何も起こらない。

resources

これはルーティングのパッケージ版。本来 /users => user#indexなど基本のルーティングがパッケージングされてる。

actionのデフォルトview

例えばindexアクションを呼び出した時、renderなどのviewを呼び出す指示が書いてないことがほとんど。その場合は、アクション名からapp/views/users/index.html.erbが呼び出される仕組みになっている。

Qiita
indexアクションの場合。

後半戦

bundle exec

このコマンドはgem fileを読み込んだ上でexec以下のコマンドを実行するというもの。 ex)bundle exec rails --version

テーブルの関連付け

class User < ApplicationRecord
  has_many :microposts
end
class Micropost < ApplicationRecord
  belongs_to :user
  validates :content, length: { maximum: 140 }
end

これによって、User.first.micropostsなどが実行できる。

そもそもなんで上記のコマンドでテーブルが結合される?

これはrailsのデフォルトによるもの。
micropostsテーブルにはuser_idというカラムがある。
model名_idというカラムがあり、結合すると、model名(ここではusersテーブル)のidカラムと自動的に結合してくれるから。

validation

class Micropost < ApplicationRecord
  validates :content, length: { maximum: 140 }
end

これによって投稿の文字数を140文字に制限することができる。

heroku注意点

上記のようにテーブルを作った状態でherokuにアップしようとすると、エラーになってしまう。
それはmigrationfileが開発用には適用されたが、本番環境では適応されていないから。開発環境用のDBと本番環境用のDBが違うことから起きる。

そのため、 heroku run rails db:migrate としてあげる。

つまり、migrationfileを一つでも作ったら、git push heroku masterをした後に上記のコマンドを実行しなければいけないということ。

また、DBは違えど、Active RecordによってSQLが適切に書かれるのでそこを気にする必要はない。

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

【Rails】Docker+Rails環境でpryの`edit`コマンドが使えなかったので、使えるようにした

はじめに

DockerとRailsを使った開発環境でpryのeditコマンドが効かなかったので対処してみたときの記録です。

※Docker環境下での話です。ローカルでは普通に動作しましたので、割愛します。

この記事が役に立つ方

  • あれ?なんかeditコマンド効かなくね?と思っている方

この記事のメリット

  • Docker環境下でpryのeditコマンドが使えるようになる

環境

  • macOS Catalina 10.15.1
  • zsh: 5.7.1
  • Ruby: 2.6.5
  • Rails: 5.2.3
  • Docker: 19.03.5
  • docker-compose: 1.24.1

pryのeditコマンドとは?

以下、公式のWikiです。
Editor integration · pry/pry Wiki · GitHub

The edit command is used to invoke your default editor. This command will load your file once you have finished editing it (unless you pass the -n or --no-reload flag to edit).

pryの画面でそのままデフォルトのエディタを開き、ファイルの編集が出来るコマンドです。

これまでpryの画面上からファイルをいじったりはしていなかったのですが、デバッグ効率が上がるかなと思ったので導入します。

【事前準備】

こちらのドキュメントに従ってDocker環境下でgem pry-railsを使うための準備は済ませておきました。

Using pry-rails with Docker · GitHub

上記ドキュメントの流れは以下の通りです。

docker-compose.yml
app:
  tty: true
  stdin_open: true

上記設定を追加。

Gemfile
group: :development
  gem 'pry-rails'
end

gemを追加。

docker-compose build

ビルド。

docker-compose up

起動。

docker ps

RailsアプリのCONTAINER IDNAMESを確認

docker attach `CONTAINER ID``NAMES`

binding.pry
※好きなところで

これで普通にpryでデバッグ可能な状態になります。

とりあえずeditコマンド使ってみようとする

※Docker環境下です。

とりあえずeditコマンドを叩いてみます。

[1] pry(main)> edit
Error: Please set Pry.config.editor or export $VISUAL or $EDITOR

するとエラー発生
Pry.config.editorか、環境変数$VISUAL$EDITORを設定してねという内容。

先程のWikiの、Setting the default editorのくだりに設定方法が記載されていたので、その通りに進めてみます。

1. ~/.pryrcを作成・設定

pryの設定ファイルは.pryrcです。
今回はなかったので作成しました。

以下設定方法についてです。
Editor integration · pry/pry Wiki · GitHub

今回エディタはvimを使いたかったので、以下のように設定。

~/.pryrc
Pry.config.editor = proc { |file, line| "vim #{file}:#{line}" }

該当のファイルと行に飛んでくれる仕様に例にならってみましたが、うまくいきません。

ローカルの設定ファイルは読み込まれないんですね。

2.Railsアプリのディレクトリ内に.pryrcを配置する。

下記記事を参照し、Railsアプリのルートディレクトリに.pryrcを配置してみることにしました。

docker-compose上のRailsのデバッグを行う - My External Storage

./.pryrc
Pry.config.editor = proc { |file, line| "vim #{file}:#{line}" }

※設定は先程と同様です。

もう一回試します。

[1] pry(main)> edit
Error: `vim ファイル名:行数` gave exit status: 127

別のエラー発生。
惜しい!コマンド自体は正しそうに見えますが、127なるエラーが。

何これ?と思って調べると、
「コマンドが見つからない、もしくはタイポ」
らしいと判明。

...ということは、そもそもvimがない?

3.vimをインストールする

なければいくらコマンドが正しくても、どうしようもないですよね。

調べると、こちらの記事を発見しました。
Docker — docker コンテナの中で vim が使えない場合 - Qiita

Dockerfile
apt-get install vim

上記を追記。

docker-compose build

docker-compose up


rails cなどでpry起動。

[1] pry(main)> edit
#=>vim起動

無事起動!

おわりに

最後まで読んで頂きありがとうございました:bow_tone1:

vimがないという環境もあるのかとびっくりしましたが、ローカルとDocker環境の境目を意識するいい経験になりました:point_up:

これでデバッグ効率が上がりそうです:relaxed:

参考にさせて頂いたサイト(いつもありがとうございます)

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

railsのバージョン変更する

cloneしたgemfileのバージョンにrailsのバージョンを合わせたい

該当のバージョンをインスール

$ gem install -v 5.2.1 rails

エラーが起きる

gem install -v  5.2.1 rails
ERROR:  Error installing rails:
        The last version of sprockets (>= 3.0.0) to support your Ruby & RubyGems was 3.7.2. Try installing it with `gem install sprockets -v 3.7.2` and then running the current command again
        sprockets requires Ruby version >= 2.5.0. The current ruby version is 2.4.2.198.

rubyのバージョンを変更したので、それに合わせて

gemのアップデート
$ gem update --systems
新しくインストールしたrubyの環境にbundleを入れる
$ gem install bundler

そして先ほどのエラー文を読むと

gem install sprockets -v 3.7.2

してください。となっていたのでこれを実行する。

もう一度

$ gem install -v 5.2.1 rails
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

#Rails + rspec + rake タスクで環境変数を利用してタイムゾーンを指定したテストをする例 ( specify local timezone test with rake )

rake

namespace :foo do
  task bar: :environment do |task|
    date = if ENV['ON'].present?
             Date.parse(ENV['ON'])
           else
             Time.current.in_time_zone('Tokyo').to_date.yesterday
           end

    Alice.run!(on: date)
  end
end

spec

require "rails_helper"
require "rake"

describe 'foo' do
  before(:all) do
    @rake = Rake::Application.new
    Rake.application = @rake
    Rake.application.rake_require('bar', [Rails.root.join('lib', 'tasks', 'foo')])
    Rake::Task.define_task(:environment)
  end

  before(:each) do
    @rake[task].reenable
  end

  describe  do
    let(:task) { 'foo:bar' }

    context 'when now is beggining of day at JST' do
      before do
        travel_to '2020-01-01 00:00:00'.in_time_zone('Tokyo')
      end

      it do
        expect(Alice).to receive(:run!).with(on: Date.new(2019, 12, 31))
        @rake[task].invoke
      end
    end

    context 'when now is end of day at JST' do
      before do
        travel_to '2020-01-01 23:59:59'.in_time_zone('Tokyo')
      end

      it do
        expect(Alice).to receive(:run!).with(on: Date.new(2019, 12, 31))
        @rake[task].invoke
      end
    end

    context 'when specify target date' do
      before do
        travel_to '2020-01-01 00:00:00'.in_time_zone('Tokyo')
        allow(ENV).to receive(:[]).with('ON').and_return('2020-02-01')
      end

      it do
        expect(Alice).to receive(:run!).with(on: Date.new(2020, 02, 01))
        @rake[task].invoke
      end
    end
  end
end

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2780

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

【Ruby on Rails】開発環境構築(MacOS)

MacOSの状態を確認

Catalina以降の場合とMojave以前の場合で環境構築の設定が異なります。

OSがMojave以前の場合の環境構築

❶Command Line Toolsを用意

Command Line ToolsはWebアプリケーション開発に必要なソフトウェアをダウンロードするために必要な機能です。

$ xcode-select --install

❷Homebrewを用意

$ cd  #ホームディレクトリに移動
$ pwd #ホームディレクトリにいるかどうか確認
$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"  #コマンドを実行

※処理に時間がかかる可能性のある操作です。

Press RETURN to continue or any other key to abort」と表示されたら、「続けるにはエンターキーを、やめるにはそれ以外の入力をしてください」という意味ですので、エンターキーを入力します。

Password:」と表示されたら、PCを開く際に求められるパスワードを入力します。ターミナル上で文字は表示されませんが、間違いなく入力はされています。パスワードを入力し終わったらエンターキーを押します。

$ brew -v  #Homebrewがインストールされているか確認
Homebrew 1.8.0
$ brew update  #Homebrewを最新状態にする
$ sudo chown -R `whoami`:admin /usr/local/bin  #Homebrewの権限を変更

❸新しいバージョンのRubyをインストール

$ brew install rbenv ruby-build  #Homebrewを用いてrbenvとruby-buildをインストール
$ echo 'eval "$(rbenv init -)"' >> ~/.bash_profile  #PCにおけるどこのディレクトリからも使用できるようにする
$ source ~/.bash_profile  #bash_profileの変更を反映
$ brew install readline  #readlineをインストール
$ brew link readline --force  #どこからも使用できるように
# rbenvを利用してRubyをインストール
$ RUBY_CONFIGURE_OPTS="--with-readline-dir=$(brew --prefix readline)"
$ rbenv install 2.5.1
$ rbenv global 2.5.1  #利用するRubyのバージョンを指定
$ rbenv rehash  #rbenvを読み込んで変更を反映
$ ruby -v  #Rubyのバージョンを確認

先ほどインストールした2.5.1であることが表示されれば完了です。

❹MySQLを用意

MySQLは、Webアプリケーションにおけるデータを蓄積する場所のこと。

# MySQLのインストール
$ brew install mysql@5.6
$ brew reinstall https://raw.githubusercontent.com/Homebrew/homebrew-core/f171f1c74/Formula/mysql@5.6.rb

※処理に時間がかかる可能性のある操作です。

# MySQLの自動起動設定
$ mkdir ~/Library/LaunchAgents 
$ ln -sfv /usr/local/opt/mysql\@5.6/*.plist ~/Library/LaunchAgents
$ launchctl load ~/Library/LaunchAgents/homebrew.mxcl.mysql\@5.6.plist
# mysqlのコマンドをどこからでも実行できるようにする
$ echo 'export PATH="/usr/local/opt/mysql@5.6/bin:$PATH"' >> ~/.bash_profile
$ source ~/.bash_profile
# mysqlのコマンドが打てるか確認する
$ which mysql
# 以下のように表示されれば成功
/usr/local/opt/mysql@5.6/bin/mysql

❺Railsを用意

Railsというフレームワークを用いて、Rubyでアプリケーションを作成します。

$ gem install bundler  #bundlerをインストール
$ gem install rails --version='5.2.3'  #Railsをインストール

※処理に時間がかかる可能性のある操作です。

$ rbenv rehash  #rbenvを再読み込みしておく
$ rails -v  #Railsが導入できたか確認

Mojave以前の場合は、これにてWebアプリケーション開発のための環境構築は完了です。

OSがCatalina以前の場合の環境構築

# zshをデフォルトに設定
$ chsh -s /bin/zsh
# ログインシェルを表示
$ echo $SHELL
# 以下のように表示されれば成功
/bin/zsh

もしecho $SHELLコマンドで、/bin/zshと表示されなかった方は、一度ターミナルを閉じて再度開いた上でもう一度echo $SHELLコマンドを実行します。(“$”が“%”になっている可能性もあります)

❶Command Line Toolsを用意

$ xcode-select --install  #Command Line Toolsをインストール

❸Homebrewを用意

$ cd  #ホームディレクトリに移動
$ pwd #ホームディレクトリにいるかどうか確認
$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"  #コマンドを実行

※処理に時間がかかる可能性のある操作です。

Press RETURN to continue or any other key to abort」と表示されたら、「続けるにはエンターキーを、やめるにはそれ以外の入力をしてください」という意味ですので、エンターキーを入力します。

Password:」と表示されたら、PCを開く際に求められるパスワードを入力します。ターミナル上で文字は表示されませんが、間違いなく入力はされています。パスワードを入力し終わったらエンターキーを押します。

$ brew -v  #Homebrewがインストールされているか確認
$ brew -v
Homebrew 2.1.13
$ brew update  #Homebrewをアップデート
$ sudo chown -R `whoami`:admin /usr/local/bin  #Homebrewの権限を変更

❸新しいバージョンのRubyをインストール

$ brew install rbenv ruby-build  #Homebrewを用いてrbenvとruby-buildをインストール
$ echo 'eval "$(rbenv init -)"' >> ~/.zshrc  #PCにおけるどこのディレクトリからも使用できるようにする
$ source ~/.zshrc  #zshrcの変更を反映
$ brew install readline  #readlineをインストール
$ brew link readline --force  #どこからも使用できるように
# rbenvを利用してRubyをインストール
$ RUBY_CONFIGURE_OPTS="--with-readline-dir=$(brew --prefix readline)"
$ rbenv install 2.5.1

※処理に時間がかかる可能性のある操作です。

$ rbenv global 2.5.1  #利用するRubyのバージョンを指定
$ rbenv rehash  #rbenvを読み込んで変更を反映
$ ruby -v  #Rubyのバージョンを確認

❹MySQLを用意

# MySQLのインストール
$ brew install mysql@5.6
$ brew reinstall https://raw.githubusercontent.com/Homebrew/homebrew-core/f171f1c74/Formula/mysql@5.6.rb

※処理に時間がかかる可能性のある操作です。

# MySQLの自動起動設定
$ mkdir ~/Library/LaunchAgents 
$ ln -sfv /usr/local/opt/mysql\@5.6/*.plist ~/Library/LaunchAgents
$ launchctl load ~/Library/LaunchAgents/homebrew.mxcl.mysql\@5.6.plist
# mysqlのコマンドをどこからでも実行できるようにする
$ echo 'export PATH="/usr/local/opt/mysql@5.6/bin:$PATH"' >> ~/.zshrc
$ source ~/.zshrc
# mysqlのコマンドが打てるか確認する
$ which mysql
# 以下のように表示されれば成功
/usr/local/opt/mysql@5.6/bin/mysql

❺Railsを用意

$ gem install bundler  #bundlerをインストール
$ gem install rails --version='5.2.3'  #Railsをインストール
$ rbenv rehash  #rbenvを再読み込みしておく
$ rails -v  #Railsが導入できたか確認

Catalina以降の場合は、これにてWebアプリケーション開発のための環境構築は完了です。

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

#Rails ( or #Ruby + ActiveSupport ) : Time.zone.now VS Time.current what is difference? when specify Time.zone then used ActiveSupport::TimeWithZone class else use Time class

require 'active_support/core_ext'


# No Time.zone setting

Time.use_zone(nil) { Time.current }
# => 2019-11-29 07:38:04 +0900

Time.use_zone(nil) { Time.current.class }
# => Time



# with Time.zone setting
# Time.current class changed Time to ActiveSupport::TimeWithZone

Time.use_zone('UTC') { Time.current }
# => Thu, 28 Nov 2019 22:38:20 UTC +00:00

Time.use_zone('UTC') { Time.current.class }
# => ActiveSupport::TimeWithZone

Time.use_zone('UTC') { Time.zone.now }
# => Thu, 28 Nov 2019 22:38:26 UTC +00:00

Time.use_zone('UTC') { Time.zone.now.class }
# => ActiveSupport::TimeWithZone



require 'active_support/testing/time_helpers'
include ActiveSupport::Testing::TimeHelpers

# Freeze time
Time.use_zone('UTC') { travel_to Time.parse('2020-01-01 00:00') }

Time.use_zone('UTC') { Time.current == Time.zone.now }
# => true


# NOTE
# Time.parse class not changed to ActiveSupport::TimeWithZone

Time.parse('2020-01-01 00:00')
# => 2020-01-01 00:00:00 +0900

Time.parse('2020-01-01 00:00').class
# => Time

Time.use_zone('UTC') { Time.zone.parse('2020-01-01 00:00') }
# => Wed, 01 Jan 2020 00:00:00 UTC +00:00

Time.use_zone('UTC') { Time.zone.parse('2020-01-01 00:00').class }
# => ActiveSupport::TimeWithZone

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2778

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

#Rails ( or #Ruby + ActiveSupport ) + travel_to : freeze to localtime beggining of year and ref UTC date and time and timestamp

# REQUIRE : on Ruby

require 'active_support/testing/time_helpers'
# => true

require 'active_support/core_ext'
# => true

include ActiveSupport::Testing::TimeHelpers
# => Object


# Travel to JST beggining of Year

travel_to Time.use_zone('Tokyo') { Time.parse('2020-01-01 00:00') }
# => nil


# JST

Time.use_zone('Tokyo') { Time.current }
# => Wed, 01 Jan 2020 00:00:00 JST +09:00

Time.use_zone('Tokyo') { Time.zone.now }
# => Wed, 01 Jan 2020 00:00:00 JST +09:00

Time.use_zone('Tokyo') { Date.current }
# => Wed, 01 Jan 2020


# UTC


Time.use_zone('UTC') { Time.current }
# => Tue, 31 Dec 2019 15:00:00 UTC +00:00

Time.use_zone('UTC') { Time.zone.now }
# => Tue, 31 Dec 2019 15:00:00 UTC +00:00

Time.use_zone('UTC') { Date.current }
# => Tue, 31 Dec 2019


# Unix Timestamp
# are All same at any Timezone

Time.use_zone('Tokyo') { Time.zone.now.to_i }
# => 1577804400

Time.use_zone('UTC') { Time.zone.now.to_i }
# => 1577804400

Time.now.to_i
# => 1577804400

Time.current.to_i
# => 1577804400


# UTC vs JST near date change line

travel_to Time.use_zone('Tokyo') { Time.parse('2020-01-01 08:59') }

Time.use_zone('UTC') { Date.current }
# => Tue, 31 Dec 2019

travel_to Time.use_zone('Tokyo') { Time.parse('2020-01-01 09:00') }
# => nil

Time.use_zone('UTC') { Date.current }
# => Wed, 01 Jan 2020

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2777

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

#Rails ( or #Ruby + ActiveSupport ) の Time.zone.now と Time.current の違いは Time.zone 指定の有無であり、Time class と ActiveSupport::TimeWithZone class が切り替わることが判明

require 'active_support/core_ext'


# No Time.zone setting

Time.use_zone(nil) { Time.current }
# => 2019-11-29 07:38:04 +0900

Time.use_zone(nil) { Time.current.class }
# => Time



# with Time.zone setting
# Time.current class changed Time to ActiveSupport::TimeWithZone

Time.use_zone('UTC') { Time.current }
# => Thu, 28 Nov 2019 22:38:20 UTC +00:00

Time.use_zone('UTC') { Time.current.class }
# => ActiveSupport::TimeWithZone

Time.use_zone('UTC') { Time.zone.now }
# => Thu, 28 Nov 2019 22:38:26 UTC +00:00

Time.use_zone('UTC') { Time.zone.now.class }
# => ActiveSupport::TimeWithZone



require 'active_support/testing/time_helpers'
include ActiveSupport::Testing::TimeHelpers

# Freeze time
Time.use_zone('UTC') { travel_to Time.parse('2020-01-01 00:00') }

Time.use_zone('UTC') { Time.current == Time.zone.now }
# => true


# NOTE
# Time.parse class not changed to ActiveSupport::TimeWithZone

Time.parse('2020-01-01 00:00')
# => 2020-01-01 00:00:00 +0900

Time.parse('2020-01-01 00:00').class
# => Time

Time.use_zone('UTC') { Time.zone.parse('2020-01-01 00:00') }
# => Wed, 01 Jan 2020 00:00:00 UTC +00:00

Time.use_zone('UTC') { Time.zone.parse('2020-01-01 00:00').class }
# => ActiveSupport::TimeWithZone


Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2779

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

初めてのRuby on Rails

はじめに

このQiitaは、プログラミング未経験の初心者がRailsチュートリアルに取り組み
学んだことを記録、振り返るために書いていきます。

今までやったこと

Progateで

  • HTML
  • CSS
  • Ruby
  • Ruby on Rails5
  • Git
  • CommandLine
  • SQL
  • HTMLとCSSを学んだ後に一度架空のホームページの見た目だけ作成

PC環境 

  • MacBook Pro Mid2012(古いです)

  • macOS Mojave ver 10.14.6

エディタ

  • Visual Studio Code ver 1.40.2

最初のアプリケーション

「Hello,World」を表示するプログラム

1.ターミナルでcd..... ホームディレクトリに移動
2.cd environment.....作成したenvironmentディレクトリに移動
3.rails new.....railsアプリの骨組みを作成

ターミナルで

$ rails _5.1.6_ new hello_app

これでrails 5.1.6を使ったhello_appというファイルが作られる

rails newとは

新規ファイルの作成

RsilsはRubyを使ったフレームワークなのである程度必要なファイルなどを自動作成してくれる。

$ rails new blog_app(作りたいプロジェクト名)

これによりblog_appというファイルが作られ、必要なものが中に自動生成される。

rails newのオプション

rails newをする際に様々な条件を付けることができる

Railsのバージョンの指定

railsのバージョンを指定して作成できる

$ rails new _5.1.6_ new blog.app

5.1.6の部分に好きなバージョンを入れることで、そのバージョンで作成できる。

データベースの指定

rails new時、データベースのデフォルトはsqliteになる。
rails new した後に変更することも可能だが、最初から使うデータベースが決まっているなら最初に設定しておける。

$ rails new blog_app -database=mysql

これはMySQLを使うときのコマンド

$rails new blog_app -database=postgresql

これはPostgresqlを使うときのコマンド

そのほかにもrails new するときのオプションはたくさんある

また今度調べよう

Bundler

Bundlerは必要なgemをインストールするコマンド

$ budle

※railsにおけるgemとはrubyのライブラリのこと
rubyに必要な機能の管理場所のようなもの

通常rails newをすると最低限必要なgemは自動でbundlerしてくれる

チュートリアル通りgemの内容を変更し、bundlerを再実行する

gemはテキストエディタでhello_appディレクトリにあるgemfileで触れる

今回は下記の様にgemを書き換える

source 'https://rubygems.org'

gem 'rails',        '5.1.6'
gem 'puma',         '3.9.1'
gem 'sass-rails',   '5.0.6'
gem 'uglifier',     '3.2.0'
gem 'coffee-rails', '4.2.2'
gem 'jquery-rails', '4.3.1'
gem 'turbolinks',   '5.0.1'
gem 'jbuilder',     '2.6.4'

group :development, :test do
  gem 'sqlite3',      '1.3.13'
  gem 'byebug', '9.0.6', platform: :mri
end

group :development do
  gem 'web-console',           '3.5.1'
  gem 'listen',                '3.1.5'
  gem 'spring',                '2.0.2'
  gem 'spring-watcher-listen', '2.0.1'
end

書き換えたらターミナルでbundle

発生したエラー

You have requested:
  spring = 2.0.2 

The bundle currently has spring locked at 2.1.0.
Try running `bundle update spring`

If you are updating multiple gems in your Gemfile at once,
try passing them all to `bundle update`

あなたがリクエストしたのはspring = 2.0.2だけどspring = 2.1.0で設定してるから無理だよ!
変えるならbundle updateしてね!
的なことを言っているので

素直にbundle updateを実行
エラーは治った

そして再度bundle

正常に作動して必要なgemをインストールした

Railsサーバーの立ち上げ

$ rails server

railsはこのコマンドを入れるだけで、ローカルWebサーバーを立ち上げることができる

$ rails s

でもいいらしい

rails s をするときは別ターミナルタブで行った方が他のコマンドも打てるので良い

入力したら

http://localhost:3000/

にアクセスしてみる

サーバーの立ち上げに成功していれば

Yay! You’r on Rails! と可愛らしいイラスト付きで表示が出る。

Railsのサーバーの立ち上げができた

立ち上げること自体は本当に簡単でたった数個の記述で出来た
数行の記述でブログが作れる!みたいな記事もあったので
このお手軽さがrailsが初学者に人気の理由なのかな

Railsチュートリアル自体が最初は細かい部分が省かれている感じなので
rails全体を掴むために疑問点はメモしておき、
確認の1週目ということにして書いてある通りにさくさく進めてみよう

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

rubyのバージョンアップ

rubyのバージョンアップする時の方法

この記事は私の備忘録として記載していきます。
何か間違っている場合があれば、ご指摘していただけると助かります。

現在の環境

ruby 2.6.3
rails 5.2.3

目的

rubyのバージョンを2.6.5にしたいです。

環境にバージョンがあるか確認します。

$ rbenv install --list

もし、指定したいバージョンがなければ

$ brew upgrade ruby-build

をしてください。

もう一度確認で

$ rbenv install --list

rbenvでインストール

指定したいバージョンがあれば次に、rbenvでインストールをします。

$ rbenv install 2.6.

インストールされました!!

インストールしたバージョンを使用する時

$ rbenv versions
  system
  2.4.1
  2.5.1
  2.5.3
* 2.6.3 (set by /Users/user_name/hoge/.ruby-version)
  2.6.
  • 環境全体にrubyバージョンを反映したい時
$ rbenv global 2.6.5
  • 特定のプロジェクトのみ反映したい時
$ rbenv local 2.6.5

もう一度確認で

$ rbenv versions
  system
  2.4.1
  2.5.1
  2.5.3
  2.6.3 
* 2.6.5(set by /Users/user_name/hoge/.ruby-version)

反映されました。

rubyのバージョンごとにgemをインストールしなければいけないので

$ gem install bundler

インストールできました。
Gemfileに使用するバージョンを書き換えて

$ bundle install --path=vendor/bundle

できました。

rubyのバージョンを上げた後にrailsコマンドを使おうとすると、下記のようになってしまった。

ruby.rb
$ rails --version
rbenv: rails: command not found

The `rails' command exists in these Ruby versions:
  2.6.5

新しくインストールしたrubyの中にrailsというgemが入っていないために起こっているようだ。

目的

新しくインストールしたruby環境にrailsをインストールをする
まず、gemをアップデートします

$ gem update --system

次にbundlerをインストールします

$ gem install bundler

その次にrailsをインストールします

$ gem install rails

バージョンを確認します。

$ rails --version
Rails 5.2.3

バージョンが表示されたらrails コマンドが使えるようになっている

まとめ

初めてのQiita記事でした。記事一つ書くだけでも長時間かかってしまいました。
本日はバージョンのアップデートやバージョン指定など頻繁にあるので、備忘録としてここに書かせて頂きました。
どんどん記事を書いてアウトプットしていきたいと思います。

この記事は、下記の記事を参考にさせて頂きました。

rbenvでRubyのバージョンを最新安定版にupdate

rbenvでrubyのバージョンを上げたときに The `rails' command exists in these Ruby versions: となる

とても、勉強になりました。ありがとうございます。

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

【Ruby on Rails】新規アプリケーション作成まとめ

Rubyとは

Ruby(プログラミンング言語)

Rubyはプログラミング言語の一つであり、Javaなどの他のプログラミング言語に比べて、少ないソースコードの記述量でプログラムを組めるなどの特徴があります。(Ruby公式サイト

Ruby on Rails

Ruby on Rails

RubyのWebアプリケーションフレームワークの一つであり、Webアプリケーションフレームワークとは、Webアプリケーションを簡単に作れるようにする骨組みのことです。
フレームワークを使用することで、開発者はより少ない労力で開発を行うことが可能になります。

Rails開発環境の構築

Rails開発ではデータベース(MySQL)やRails自体のインストールなど、まずは開発のできる環境を整える必要があります。(Rails開発環境構築

  • Homebrewのインストール・アップデート
  • rbenv・ruby-buildのインストール
  • Ruby ver.2.5.1のインストール
  • MySQLのインストール
  • MySQLのインストール

新規アプリケーションを作成

$ rails new アプリケーション名
# アプリケーションを新規作成

$ rails new アプリケーション名 -オプション名
# オプションを付けてアプリケーションを作成

Gemの追加

Gem

RailsはGem(じぇむ)と呼ばれる便利なrubyライブラリをインストールして利用することで、より簡単にアプリケーション開発をすることができます。gemには開発を効率化してくれたり、会員登録サービスが簡単にできるなど便利なものがたくさんあります。Railsアプリケーションは全てをゼロから作るのではなくgemのライブラリも利用することでより効率的に開発することができます。

Gemfileに追加したら必ずターミナルでbundle installを実行します。

gem 'Gem名'
# Gemの追加
$ bundle install
# Gemをインストール

データベースの作成

データベース

データベースとはたくさんの情報(データ)が入っている箱のようなものです。この中には様々な種類のデータが存在していて、データを格納したり、取り出すことができます。Railsでは運用環境ごとにひとつのデータベースを持っています。

運用環境

運用環境 概要
development 開発環境。通常、開発をする際に使用する環境。
production 本番環境。アプリケーションを実際にリリースする際に使用する環境。
test テスト環境。アプリケーションの動作をテストする際に使用する環境。

database.ymlファイルの内容に基づいてデータベースを新規作成します。

$ rake db:create
# データベースの作成
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

#Rails ( or #Ruby + ActiveSupport ) + travel で 現地時刻 = JST で特定日付に時間を固定して、それぞれUTC JST timestamp で現在時刻・日付を得る例

# REQUIRE : on Ruby

require 'active_support/testing/time_helpers'
# => true

require 'active_support/core_ext'
# => true

include ActiveSupport::Testing::TimeHelpers
# => Object


# Travel to JST beggining of Year

travel_to Time.use_zone('Tokyo') { Time.parse('2020-01-01 00:00') }
# => nil


# JST

Time.use_zone('Tokyo') { Time.now }
# => 2020-01-01 00:00:00 +0900

Time.use_zone('Tokyo') { Time.current }
# => Wed, 01 Jan 2020 00:00:00 JST +09:00

Time.use_zone('Tokyo') { Time.zone.now }
# => Wed, 01 Jan 2020 00:00:00 JST +09:00

Time.use_zone('Tokyo') { Date.current }
# => Wed, 01 Jan 2020


# UTC


Time.use_zone('UTC') { Time.now }
# => 2020-01-01 00:00:00 +0900

Time.use_zone('UTC') { Time.current }
# => Tue, 31 Dec 2019 15:00:00 UTC +00:00

Time.use_zone('UTC') { Time.zone.now }
# => Tue, 31 Dec 2019 15:00:00 UTC +00:00

Time.use_zone('UTC') { Date.current }
# => Tue, 31 Dec 2019


# Unix Timestamp
# are All same at any Timezone

Time.use_zone('Tokyo') { Time.now.to_i }
# => 1577804400

Time.use_zone('Tokyo') { Time.zone.now.to_i }
# => 1577804400

Time.use_zone('UTC') { Time.now.to_i }
# => 1577804400

Time.use_zone('UTC') { Time.zone.now.to_i }
# => 1577804400

Time.now.to_i
# => 1577804400

Time.current.to_i
# => 1577804400


# Date.today
# Not considered Timezone with Time.zone setting
# its Ruby method refers TZ env

Time.use_zone('Tokyo') { Date.today }
# => Wed, 01 Jan 2020
Time.use_zone('UTC') { Date.today }
# => Wed, 01 Jan 2020

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2776

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

やさしい図解で学ぶ モデル マイグレーション その2 アソシエーション

その1ではテーブルの設計図となるマイグレーションファイルにて紹介しましたが今回は「モデルについて」と「アソシエーション (関連性)」をモデルファイルへ記述する方法をカンタンに学んでいきます。

⭐️モデルについて

まずモデルファイルとは
DBとのデータのやりとりをするために重要なファイルとイメージしてください。

ファイルには
モデル(テーブル)間の関係性を記述したり、
DBにあるテーブルに直接データの保存、呼び出しを可能にする機能を基本的に備えています。

スクリーンショット 2019-11-27 18.11.52.png

上の図では
モデルファイルからDBへ「ActiveRecord」と書かれたパイプや橋のようなものがあり、userやtweetモデルからDB内の同じ名前のテーブルに伸びています。

*ActiveRecordというのは厳密にいうとRailsに標準ライブラリとして採用されているO/Rマッパーのことです。専門用語を使わず簡単に表現すると

DBとアプリケーションとの 「 橋渡し  or  通訳 」

のようなものだと思ってください。

もし、モデルがないと

スクリーンショット 2019-11-28 12.06.15.png

Railsアプリケーションではプログラミング言語「Ruby」を。
DBでは「SQL」をそれぞれ使っているので、言語の違いからそのままではやりとりができません。

そこで
スクリーンショット 2019-11-28 12.44.47.png

とざっくりなイメージですが、ありがたいことにRailsではモデルファイル(ActiveRecord)が通訳のようにRuby→SQLに、SQL→Rubyにと言語を変換してデータベースとのやりとりを円滑に行ってくれているんです。

⭐️アソシエーション(テーブル間の関連性)

アソシエーションとは、つまり
テーブル同士の関連性のことで、テーブル間にみられる対1や対多などの関係性をモデルファイルに明示しなければいけません。

例えば下のER図の場合
スクリーンショット 2019-11-19 15.42.33.png

中間テーブルを含む3つのテーブルがあります。
これらの関係性を各テーブルのモデルファイルに記述していきます。

まず
今回のテーブル群のモデルファイルへの記述方法は以下の3パターンを使用します。
(そのほかにもhas_oneやhas_and_belongs_to_manyなどがあり、オプションも存在しますが今回はこちらの3つのみ)

スクリーンショット 2019-11-25 12.22.31.png

記述方法は
スクリーンショット 2019-11-29 11.15.56.png

belongs_toやhas_oneの場合は単数、
has_manyなどは複数形で記述します。

実際にuserのモデルファイルに記述するとこのようになります。
スクリーンショット 2019-11-27 18.34.37.png

またそのほかのモデルファイルにもアソシエーションを記述し、
さらにER図のようにイメージして書くとこのような関係性になります。

スクリーンショット 2019-11-25 11.53.03.png

中間テーブルcourse_usersにはそれぞれ接続されているテーブルの主キー、つまりは、外部キーuser_idcourse_idを持っていますのでこのような記述となります。

そして中間テーブルと接続しているテーブルのモデルにはそれぞれアソシエーションとして以下のように記述します。
スクリーンショット 2019-11-29 13.42.13.png

今回はイメージを含めて基本的なもののみの紹介です。
そのほかにも便利なオプションや外部キー関連の記述もありますので徐々に学んでいきましょう。

⭐️補足:belongs_to

はじめはbelongs_toというのがちょっとわかりづらいと思いますが、考え方としてはテーブル内に外部キーを持っていた場合、そのモデルファイルにはbelongs_toを書くと思ってください。

例えば、
usesテーブルとtweetsテーブルなるものがあり、関係性が図のようだとすると

tweetsテーブルには外部キーであるuser_idを持っています。
モデルファイルには以下のように記入します。

スクリーンショット 2019-11-29 12.16.40.png

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

やさしい図解で学ぶ モデル マイグレーション その2

その1ではテーブルの設計図となるマイグレーションファイルにて紹介しましたが今回は「モデルについて」と「アソシエーション (関連性)について」を基礎に学んでいきます。

⭐️モデルについて

まずモデルファイルとは
DBとのデータのやりとりをするために重要なファイルとイメージしてください。

ファイルには
モデル(テーブル)間の関係性を記述したり、
DBにあるテーブルに直接データの保存、呼び出しを可能にする機能を備えています。

スクリーンショット 2019-11-27 18.11.52.png

上の図では
モデルからDBへ「ActiveRecord」と書かれたパイプや橋のようなものがあり、userやtweetモデルからDB内の同じ名前のテーブルに伸びています。

*ActiveRecordというのはRailsに標準ライブラリとして採用されているO/Rマッパーのことです。

専門用語を使わず簡単に表現すると

DBとアプリケーションとの 「 橋渡し  or  通訳 」

のようなものだと思ってください。

もし、モデルがないと

スクリーンショット 2019-11-28 12.06.15.png

Railsではプログラミング言語「Ruby」を。
DBでは「SQL」をそれぞれ使っているので、言語の違いからそのままではやりとりができません。

そこで
スクリーンショット 2019-11-28 12.44.47.png

Railsではモデルファイル(ActiveRecord)が通訳のようにRuby→SQLに、SQL→Rubyにと言語を変換してデータベースとのやりとりを円滑に行ってくれているんです。

⭐️アソシエーション(テーブル間の関連性)

アソシエーションとは、つまり
テーブル同士の関連性のことで、テーブル間にみられる対1や対多などの関係性をモデルファイルに明示しなければいけません。

例えば下のER図の場合
スクリーンショット 2019-11-19 15.42.33.png

中間テーブルを含む3つのテーブルがあります。
これらの関係性を各テーブルのモデルファイルに記述していきます。

今回の記述方法は以下の3パターンを使用します。
(そのほかにもhas_oneやhas_and_belongs_to_manyなどがあり、オプションも存在しますが今回はこちらの3つのみ)

スクリーンショット 2019-11-25 12.22.31.png

記述方法は
スクリーンショット 2019-11-29 11.15.56.png

belongs_toやhas_oneの場合は単数、
has_manyなどは複数形で記述します。

実際にuserのモデルファイルに記述するとこのようになります。
スクリーンショット 2019-11-27 18.34.37.png

またそのほかのモデルファイルにもアソシエーションを記述し、
さらにER図のようにイメージして書くとこのような関係性になります。

スクリーンショット 2019-11-25 11.53.03.png

中間テーブルcourse_usersにはそれぞれ接続されているテーブルの主キー、つまりは、外部キーuser_idcourse_idを持っていますのでこのような記述となります。

has_many through

中間テーブルと接続しているテーブルのモデルにはそれぞれアソシエーションとして以下のように記述します。
スクリーンショット 2019-11-29 13.42.13.png

中間テーブルを通ってその先のインスタンスを複数持っていますよという記述になります。

今回も基礎的な部分を図解を用いて記載致しましたので実際の開発ではモデルファイルにもう少し細かく書いていきます。

今回の記事でモデルに関して少しでもイメージが湧いていただけたのなら幸いです。

⭐️補足:belongs_to

初見ではbelongs_toというのがちょっとわかりづらいと思いますが、考え方としてはテーブル内に外部キーを持っていた場合、そのモデルファイルにはbelongs_toを書くと思ってください。

例えば、
usesテーブルとtweetsテーブルなるものがあり、関係性が図のようだとすると

スクリーンショット 2019-11-29 12.16.40.png

tweetsテーブルには外部キーであるuser_idを持っています。
モデルファイルには以下のように記入します。

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

応用カリキュラム 09

renderメソッドをコントローラーで使う

renderを分かりやすくまとめてくれてる

ビューで使う場合と、コントローラで使う場合で動きが違うっぽい。
ビューで使う場合は(主に?)部分テンプレートとして使う。

 <%# _tweet.html.erbファイルをテンプレートとして使う場合 %>
<%= render partial: tweet %>

となるが、
コントローラの場合は、特定のアクションのビューを呼び出す。

render action: :new <%# 同じコントローラ内のnewアクションのビューを呼び出す %>
render template: "review/new" <%# 違うコントローラ内のnewアクションのビューを呼び出す %>

redirect_toと比較して、HTTPリクエストを送らない、のが特徴らしい。

モデルインスタンス.users << current_user で特定のグループのユーザー集合にログインユーザーを追加

モデルインスタンス.save データ保存が成功したか否かを返すメソッド

「if (last_message = messages.last).present?」と記述することで、最新のメッセージを変数last_messageに代入しつつ、メッセージが保存されているかどうかで場合分けを行なう。

if文を省略して1行に書く書き方を三項演算子と呼ぶ

 条件式 ? trueの時の処理 : falseの時の処理

railsにおけるDBの値の表示

まず、DBの値にアクセスするには、コントローラかモデルでインスタンス変数を定義しておく必要がある、と自分は思い込んでいた。

ユーザー管理が必要なサービスでdeviseを適用しているならば、コントローラでのインスタンス変数定義より先にdeviseに関係するインスタンス変数とかローカル変数が定義されており、そのdeviseの変数を介してテーブル(モデル定義時にdeviseオプションをつけたテーブル)にアクセスすることが可能。
更にテーブル(モデル)同士のアソシエーションを繋いで定義しているならば、コントローラでインスタンス変数を定義せずとも、deviseの変数を介してテーブルを参照することができる。current_user.group.messageみたいな表示ができる。

current_user.groups
current_user : device経由でuserモデルを定義した時に、userテーブルと紐づいたインスタンスがcurrent_userをはじめその他の変数に定義されてる
.groups : usersテーブルからアソシエーション定義によって紐づいたgroupsテーブルの情報を取得することができる。

覚えたいことは、ビューで変数を参照したい場合でも必ずしもコントローラでインスタンス変数の定義をする必要はない、ということ。

HTTPリクエストからのルーティング⇨コントローラ⇨ビューについて

アクションに対応するレスポンスとして、ビューを表示するのみ、であったなら
コントローラにアクションメソッドを定義する必要がない。(ちゃんとビューファイルを用意しておけば)ルーティング⇨(コントローラ飛ばして)ビュー、となる。

indexアクションへオプション(通知用メッセージ)付きでリダイレクト

redirect_to ({:action => 'index'}), :notice => 'message'

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

カラムを追加・削除・編集する方法

カラムを追加・削除・編集したい時ってあるよね〜

テーブルを作ったはいいものの後々で必要なカラムが出てくることありますよね?
カラムの洗い出しが甘いって?そのとおり!

ではいってみよう!!


カラムの追加

ターミナルでマイグレーションファイルを作成

rails g migration Addカラム名Toテーブル名 カラム名:型

ターミナル
①
rails g migration AddTitleToTweets title:string

②
rails g migration AddDetialsToTweets title:string date:date
#複数選択も可能
#Detailsの部分は何でもOK
マイグレーションファイル

class AddTitleToTweets < ActiveRecord::Migration[5.2]
  def change
    add_column :tweets, :title, :string
  end
end


class AddTitleToTweets < ActiveRecord::Migration[5.2]
  def change
    add_column :tweets, :title, :string
    add_column :tweets, :date, :date
  end
end



いざ実行!!

ターミナル
rails db:migrate


カラムの削除

ターミナルでマイグレーションファイルを作成

rails g migration Removeカラム名Fromテーブル名 カラム名:型

ターミナル
rails g migration RemoveTitleFromTweets title:string
マイグレーションファイル
class RemoveTitleFromTweets < ActiveRecord::Migration[5.2]
  def change
    remove_column :tweets, :title, :string
  end
end



いざ実行!!

ターミナル
rails db:migrate


カラム名変更

ターミナルでマイグレーションファイルを作成
rails g migration rename_変更前のカラム名_column_to_テーブル名

ターミナル
rails g migration rename_title_column_to_tweets



changeメソッドの中に追記

rename_column :テーブル名, :変更前の名前, :変更後の名前

マイグレーションファイル
class RenameTitleColumnToTweets < ActiveRecord::Migration[5.2]
  def change
    rename_column :users, :title, :theme
  end
end



いざ実行!!

ターミナル
rails db:migrate


データ型変更

ターミナルでマイグレーションファイルを作成

rails g migration ChangeDatatypeカラム名Ofテーブル名

ターミナル
rails g migration ChangeDatatypeTitleOfTweets
マイグレーションファイル
class ChangeDatatypeTitleOfTweets < ActiveRecord::Migration[5.2]
  def change
  end
end



changeメソッドの中に追記
change_column :テーブル名, :カラム名, :新しいデータ型

マイグレーションファイル
class ChangeDatatypeTitleOfTweets < ActiveRecord::Migration[5.2]
  def change
    change_column :tweets, :title, :text
  end
end



いざ実行!!

ターミナル
rails db:migrate



ではまた!

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

カラムにインデックスを貼ろう

インデックスとは

カラムにインデックスを設定することで、データ検索を高速化させることができます!


使い方

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

ターミナル
rails g migration AddIndexToTweets



マイグレーションファイルに、
add_index :テーブル名, :カラム名を追記

マイグレーションファイル
class AddIndexToTweets < ActiveRecord::Migration[5.2]
  def change
    add_index :tweets, :text
  end
end



複数ver.

マイグレーションファイル
class AddIndexToTweets < ActiveRecord::Migration[5.2]
  def change
    add_index :tweets, [:text, :title]
  end
end


デメリットも

①データを保存・更新する速度が遅くなる
②データベースの容量を使う



インデックスの使い所は、
【格納するデータが多い時】
【データの検索が頻繁に行われる時】



ではまた!

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

カラムに制約をかけよう

カラムに制約をかけ、予期せぬデータが保存されるのを防ごう


制約 意味
NOT NULL制約 空データはダメよ
一意性制約 他と被るのはダメよ
外部キー制約 外部キーに対応するテーブルのレコードがないとダメよ


NOT NULL制約

空データは禁止
カラムの後ろにnull: falseを追記

マイグレーションファイル
class CreateTweets < ActiveRecord::Migration[5.2]
  def change
    create_table :tweets do |t|
      t.text :text, null: false
      t.timestamps
    end
  end
end
ターミナル
rails db:migrate

これでtextに値がないツイートは作れないようになりました。


一意性制約

他と同じデータは禁止
add_index :テーブル名, :カラム名, unique: trueを追記

マイグレーションファイル
class CreateTweets < ActiveRecord::Migration[5.2]
  def change
    create_table :tweets do |t|
      t.text :text
    end
    add_index :tweets, :text, unique: true
  end
end
ターミナル
rails db:migrate

これで、同じ内容のテキストは保存できなくなりました。
バルスとツイートできるのは1人だけ。早いもの勝ちとなります。


外部キー制約

外部キーに対応するテーブルのレコードがないとダメ
t.references :対応先のテーブル名, foreign_key: trueを追記

マイグレーションファイル
class CreateTweets < ActiveRecord::Migration[5.2]
  def change
    create_table :tweets do |t|
      t.text :text
      t.references :user, foreign_key: true
    end
  end
end
ターミナル
rails db:migrate

これですべてのツイートが誰かしらのユーザーにひも付きます。
迷子のツイートが現れることはありません。

また、テーブル名_idというカラムが自動で生成されます。
今回はuser_idですね。



ではまた!

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

Railsの基礎の基礎 DB ActiveRecord migration MVC

Railsを学ぶ際の基礎に当たるところをここに記載しておきます。
随時更新予定(2019年11月29日)

DBを操作する言語:SQL

例:Mysql,Sqlite

CRUD
Create/Read/Update/Delete

ActiveRecordとは

Databaseの処理をRubyから簡単に行うための仕組み
SQLをうまく扱うための仕組み ModelとDBのことをさす(厳密には違う)

例:@contacts = Contanct.all

migrationとは

DBの変更をテキストで残すことができる
それによりgitで他の人と変更を共有することができる

MVCとは

Model
View
Controller

実際の流れ

1.controllerが最初にインターネットの通信を判断する役割。どのモデルに処理を依頼するか考える
2.Modelはデータベースからデータをとる、もしくは入れる。そして再びコントローラに戻る。
3.Modelのデータが取れたらそれを表示する。それを表示するViewをコントローラーが考える。

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

Railsの基礎の基礎 DB,ActiveRecord,migration ,MVC,HTTP,CRUD

Railsを学ぶ際の基礎に当たるところをここに記載しておきます。
随時更新予定(2019年11月29日)

DBを操作する言語:SQL

例:Mysql,Sqlite

CRUD
Create/Read/Update/Delete

ActiveRecordとは

Databaseの処理をRubyから簡単に行うための仕組み
SQLをうまく扱うための仕組み ModelとDBのことをさす(厳密には違う)

例:@contacts = Contanct.all

migrationとは

DBの変更をテキストで残すことができる
それによりgitで他の人と変更を共有することができる

MVCとは

Model
View
Controller

実際の流れ

1.controllerが最初にインターネットの通信を判断する役割。どのモデルに処理を依頼するか考える
2.Modelはデータベースからデータをとる、もしくは入れる。そして再びコントローラに戻る。
3.Modelのデータが取れたらそれを表示する。それを表示するViewをコントローラーが考える。

主なHTTPメソッドとCRUD

HTTPとは
Hypertext Transfer Protocolとは、WebブラウザがWebサーバと通信する際に主として使用する通信プロトコルであり、インターネット・プロトコル・スイートのメンバである。

簡単にいうとホームページのファイルとかを受け渡しするときに使う約束

CRUDは?
DBとのやりとり

HTTPメソッド CRUD 意味
Get Select 取得する
Post Insert 作成する
Put Update 全更新する
Patch Update 部分更新する
Delete Delete 削除する
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

db/seeds.rbの中身を変更したらrails db:seedできない。そんなときにありがちな原因

何が起こったか

db/seeds.rbの中身を変更したら、rails db:seedが失敗するようになりました。

# rails db:seed
rails aborted!
SyntaxError: /var/www/sample_app/db/seeds.rb:5: syntax error, unexpected tIDENTIFIER, expecting ')'
            admin: true)
            ^~~~~
/usr/local/bundle/gems/activesupport-5.1.6/lib/active_support/dependencies.rb:286:in `load'
...略

この後やたら長いスタックトレースが流れてきて面食らいます。

原因

原因は、db/seeds.rbで、単に項目区切りのカンマを入れ忘れていたことでした。db/seeds.rbを以下のように修正します。

db/seeds.rb
  User.create(name:                 "Example User",
              email:                "example@railstutorial.org",
              password:             "foobar",
-             password_confirmation: "foobar"
+             password_confirmation: "foobar",
              admin: true)
  ...略
# rails db:seed

今度こそrails db:seedは成功するはずです。なお、rails db:seedが正常に完了した場合は、シェルに何も表示されません。

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

Ruby on Rails と React Native で作る web & モバイルアプリ [モバイルアプリ編]

イントロダクション

目的

この記事は、Ruby on Rails と React Native で作る web & モバイルアプリ [webアプリ編] の続編です。Ruby on Rails で作った web アプリケーションのリソースを API 経由で React Native アプリケーションから操作します。今回は以下のような web とモバイルの両方に対応したアイパス認証付きのシンプルなタスク管理システムを作っています。

スクリーンショット 2019-12-13 11.25.13.pngスクリーンショット 2019-12-13 11.26.12.png

React Native の演習は行いたいけど Ruby on Rails 編には興味がない方は、Ruby on Rails アプリケーションを以下より clone してデプロイするか、今のところは https://zone-web.herokuapp.com/ を生かしているので、こちらを利用してください。

フレームワーク リポジトリ
Ruby on Rails (web) https://github.com/ogihara-ryo/zone-web
React Native (mobile) https://github.com/ogihara-ryo/zone-mobile

想定読者

私自身がまだモバイルアプリケーションを何もリリースしたことのない初学者であるため、初学者による初学者のための演習になっています。写経のみで進めていける演習になっていますが、多少のプログラミング全般の心得は必要になるかもしれません。私は最近 Ruby on Rails で作った web アプリを React Native アプリ対応しようと思っているのですが、この辺りの参考日本語記事が少なくて苦労しているため、同じような立場の人の助けになれれば嬉しく思います。ただ、筆者は前述の通り完全に素人なので、React Native のベストプラクティス等はまるで理解しておらず、とりあえずシンプルに動くものを作る、ぐらいのモチベーションでこの演習を作りました。そのため、技術的に正しくないコードや誤った説明が含まれている可能性があります。何かお気付きのことがありましたら編集リクエストを頂けると嬉しく思います。

もし、この記事を進めていく上で躓いた場合や理解できないことがあった場合は @OgiharaRyo までご連絡頂くか、現在300人ほど参加している技術質問 slack コミュ二ティを運営していますので、こちらで質問して頂ければと思います。(slack 招待リンク)

環境

2019年12月初旬執筆時点の最新版を使用します。

% node -v
v13.3.0

% npm -v
6.13.2

% watchman -v
4.9.0

% expo-cli -V
3.10.2

インストールに関しては先人の知恵と功績によって特に癖はないと思いますが、Mac で Homebrew が入っている環境であれば以下で大体いけます。いけなかったら何とかしてください。

% brew install node
% npm install npm@latest -g
% brew install watchman
% npm install expo-cli --global

React Native アプリケーションの開発

expo init

早速アプリケーションを作っていきましょう。まずは空っぽの React Native アプリケーションを expo init で作成します。Choose a template と言われるので blank のまま return キーで進みます。

% expo init zone-mobile          
? Choose a template: (Use arrow keys)
  ----- Managed workflow -----
❯ blank                 a minimal app as clean as an empty canvas 
  blank (TypeScript)    same as blank but with TypeScript configuration 
  tabs                  several example screens and tabs using react-navigation 
  ----- Bare workflow -----
  minimal               bare and minimal, just the essentials to get you started 
  minimal (TypeScript)  same as minimal but with TypeScript configuration 

次に <The name of your app visible on the home screen> とホームスクリーンに表示するアプリケーションの名前を尋ねられます。ここでは Zone と入力しておきましょう。

? Please enter a few initial configuration values.
  Read more: https://docs.expo.io/versions/latest/workflow/configuration/ › 50% completed
 {
   "expo": {
     "name": "<The name of your app visible on the home screen>",
     "slug": "zone-mobile"
   }
 }

起動確認

早速このまま起動してみましょう。

% expo start

ブラウザーに localhost:19002 が立ち上がります。画面左下に注目してください。

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

開発中アプリケーションの動作確認方法は大きく分けると、シミュレーターを使う方法と実機を使う方法の2つがあります。Xcode と Command Line Tools をインストール済みのマシンであれば、Run on iOS simulator を選択するとシミュレーターが起動します。シミュレーターから Expo アプリケーションを起動してよしなに操作すれば下記のような画面が表示されます。

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

実機を使う場合は、お手元のスマホでストアから Expo Client をインストールして、localhost:19002 に表示されているあなた専用の QR コードを読み込めば Expo Client アプリが起動して上記のような画面が表示されます。ちなみに私は通常 iPhone と Pixel の2台の実機を使いながら iOS と Android の両方を並行して動作確認しながら開発しています。

react-navigation

今回開発するアプリケーションは2つの画面を持ちます。1つはログインフォームを持つログイン画面、もう1つはタスク管理を行うメイン画面です。開発のはじめの第一歩としては、ログイン画面とメイン画面を用意して、お互いを行き来できるような実装を行います。前準備として、react-navigation をインストールします。react-navigation は画面間の遷移をハンドリングしてくれるライブラリです。ドキュメント に従って react-navigation 及びその依存ライブラリ react-native-gesture-handler, react-native-reanimated, react-native-screensexpo install します。

% expo install react-navigation react-native-gesture-handler react-native-reanimated react-native-screens

それではナビゲーションを実装していきます。今回は、App.js にナビゲーションを置いて、src/screens/Login.jssrc/screens/Main.js を互いに遷移できるようなコードを書いていきます。まずはログイン画面とメイン画面を用意します。ただお互いの画面に遷移するためのボタンが1つだけ置いてある簡素な画面です。ButtononPress イベントで this.props.navigation.navigate('main') のようなメソッドコールを行っているのがポイントです。

src/screens/Login.js
import React from 'react';
import { View, Button } from 'react-native';

export default class Login extends React.Component {
  render() {
    return (
      <View>
        <Button
          title="ログイン"
          onPress={() => {this.props.navigation.navigate('main')}}>
        </Button>
      </View>
    );
  }
}
src/screens/Main.js
import React from 'react';
import { View, Button } from 'react-native';

export default class Main extends React.Component {
  render() {
    return (
      <View>
        <Button
          title="ログアウト"
          onPress={() => {this.props.navigation.navigate('login')}}>
        </Button>
      </View>
    );
  }
}

作った2つの画面をナビゲーターにセットします。今回は、createSwitchNavigator を使用します。

App.js
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { createAppContainer, createSwitchNavigator } from 'react-navigation';

import Login from './src/screens/Login'
import Main from './src/screens/Main'

export default function App() {
  const MainNavigator = createAppContainer(
    createSwitchNavigator({
      login: { screen: Login },
      main: { screen: Main }
    })        
  )

  return (
    <View style={styles.container}>
      <MainNavigator />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

これで2画面を遷移できるアプリケーションになりました。

スクリーンショット 2019-12-08 9.51.50.pngスクリーンショット 2019-12-08 9.51.53.png

ログイン

それでは、本格的にログインフォームを実装していきましょう。解説は後にして、一旦全てのコードを一気に実装してしまいましょう。記事内の web アプリケーションの URL は、ご自身のアプリケーションをデプロイされていたら差し替えてください。

src/screens/Login.js
import React from 'react';
import { View, Text, Button, TextInput, ActivityIndicator, StyleSheet } from 'react-native';

export default class Login extends React.Component {
  constructor(props) {
    super(props);
    this.state = { accountId: '', password: '', loading: false, failed: false };
  }

  onSubmit() {
    this.setState({ loading: true })
    return (
      fetch(`https://zone-web.herokuapp.com/api/login.json?account_id=${this.state.accountId}&password=${this.state.password}`)
        .then((response) => response.json())
        .then((jsonData) => {
          this.setState({ loading: false })
          if (jsonData['api_token']) {
            this.props.navigation.navigate('main')
          }
          else {
            this.setState({ failed: true })
          }
        })
        .catch((error) => console.error(error))
    )
  }

  loginButton() {
    if (this.state.loading) {
      return <ActivityIndicator size="small" />
    }
    else {
      return <Button title="ログイン" onPress={() => {this.onSubmit()}} />
    }
  }

  render() {
    return (
      <View>
        {this.state.failed && <Text>ログインに失敗しました。</Text>}

        <TextInput
          style={styles.textInput}
          placeholder="アカウントID"
          onChangeText={(accountId) => this.setState({accountId})}
        />

        <TextInput
          secureTextEntry={true}
          style={styles.textInput}
          placeholder="パスワード"
          onChangeText={(password) => this.setState({password})}
        />

        {this.loginButton()}
      </View>
    );
  }
}

const styles = StyleSheet.create({
  textInput: {
    height: 60,
    width: 300,
    paddingLeft: 20,
    margin: 10,
    borderWidth: 1,
    borderRadius: 8,
  }
});

まずは動かしてみましょう。誤ったアカウントIDやパスワードでログインを試みたらエラーメッセージが表示され、正しいアカウントIDとパスワードでログインを試みたらメイン画面へ遷移することを確認してください。

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

さて、今回実装したのは比較的長いコードではありますが、この Login コンポーネントが何をしているかは、各 state がどのタイミングで更新されて、何に使われているのかを知ることで全容を把握することができます。コードと読み比べてみましょう。

state 更新タイミング 用途
accountId アカウントIDの TextInput にユーザーが入力する度に onChangeText イベントで現在の値がセットされます。 web アプリケーションのログイン API に与えるパラメーターとしてセットします。
password パスワードの TextInput にユーザーが入力する度に onChangeText イベントで現在の値がセットされます。 web アプリケーションのログイン API に与えるパラメーターとしてセットします。
loading web アプリケーションに fetch でリクエストを送る前に true にし、レスポンスを受け取った後に false にします。 ログインボタンが押された後、web アプリケーションと通信している間にログインボタンを ActivityIndicator (いわゆるロード中のクルクル)に差し替えることで、ユーザーに通信中であることを伝えると同時に、ログインボタンを不要に連打されないようにします。
failed ログイン API を叩いて API トークンが返ってこなかった場合はアカウントIDかパスワードが間違っていたとして true にします。 true の場合にログインに失敗した旨をユーザーに通知します。

APIトークンの保存とログインの永続化

ここまでのコードでは、APIトークンが返ってきた場合はログインに成功したとしてナビゲーターをメイン画面に変更していますが、このAPIトークンは後のタスク操作 API をコールするために使用するのでちゃんと保存しておく必要があります。また、API トークンを一度受け取ってしまえば次回以降のアプリケーション起動時はログイン画面を挟まずにメイン画面を表示した方が親切です。今回は AsyncStorage というキーバリューストレージシステムに API トークンを保存します。

src/screens/Login.js
  import React from 'react';
- import { View, Text, Button, TextInput, ActivityIndicator, StyleSheet } from 'react-native';
+ import { View, Text, Button, TextInput, ActivityIndicator, StyleSheet, AsyncStorage } from 'react-native';

  export default class Login extends React.Component {
    constructor(props) {
      super(props);
      this.state = { accountId: '', password: '', loading: false, failed: false };
    }
+
+   async componentDidMount() {
+     if (await AsyncStorage.getItem('api_token')) {
+       this.props.navigation.navigate('main')
+     }
+   }

    onSubmit() {
      this.setState({ loading: true })
      return (
        fetch(`https://zone-web.herokuapp.com/api/login.json?account_id=${this.state.accountId}&password=${this.state.password}`)
          .then((response) => response.json())
          .then((jsonData) => {
            this.setState({ loading: false })
            if (jsonData['api_token']) {
+             AsyncStorage.setItem('api_token', jsonData['api_token']);
              this.props.navigation.navigate('main')
            }
            else {
              this.setState({ failed: true })
            }
          })
          .catch((error) => console.error(error))
      )
    }

componentDidMount は、コンポーネントがツリーに挿入された直後に呼び出されます。この時、AsyncStorage に既に API トークンが入っていた場合はナビゲーターをメイン画面に切り替えています。

ログアウト

最後に、ログアウトの実装も行いましょう。これはシンプルで、AsyncStorage から API トークンを削除してナビゲーターをログイン画面へ切り替えるだけです。

src/screens/Main.js
  import React from 'react';
- import { View, Button } from 'react-native';
+ import { View, Button, AsyncStorage } from 'react-native';

  export default class Main extends React.Component {
+   logout() {
+     AsyncStorage.removeItem('api_token');
+     this.props.navigation.navigate('login')
+   }

    render() {
      return (
        <View>
          <Button
            title="ログアウト"
-           onPress={() => {this.props.navigation.navigate('login')}}>
+           onPress={() => {this.logout()}}>
          </Button>
        </View>
      );
    }
  }

タスクの CRUD

各処理の雑な解説は後回しにして、まずは完成形です。アクションも DOM のレンダリングも全て同じファイルにまとめているので100行ほどあります。

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

src/screens/Main.js
import React from 'react';
import { View, FlatList, TextInput, Button, AsyncStorage, ActivityIndicator, StyleSheet } from 'react-native';
import { CheckBox } from 'react-native-elements';

export default class Main extends React.Component {
  constructor(props) {
    super(props);
    this.state = { taskName: '', tasks: [], loading: '', apiToken: '' };
  }

  async componentDidMount() {
    this.setState({ loading: true, apiToken: await AsyncStorage.getItem('api_token') })
    fetch(`https://zone-web.herokuapp.com/api/tasks.json?api_token=${this.state.apiToken}`)
      .then((response) => response.json())
      .then((jsonData) => (this.setState({ loading: false, tasks: jsonData })))
      .catch((error) => console.error(error));
  }

  submitCreateTask() {
    if (!this.state.taskName) return

    this.setState({ loading: true })
    fetch(`https://zone-web.herokuapp.com/api/tasks`, {
      method: 'POST',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ api_token: this.state.apiToken, task: { name: this.state.taskName } })
    })
    .then(response => response.json())
    .then(json => { this.setState({ tasks: this.state.tasks.concat(json), taskName: '', loading: false }) })
    .catch((error) => console.error(error));
  }

  changeFinished(item) {
    item.finished = !item.finished
    this.setState({ loading: true })

    fetch(`https://zone-web.herokuapp.com/api/tasks/${item.id}`, {
      method: 'PATCH',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ api_token: this.state.apiToken, task: { finished: item.finished } })
    })
    .then(response => this.setState({ loading: false }))
    .catch((error) => console.error(error));
  }

  createTaskButton() {
    if (this.state.loading) return <ActivityIndicator size="small" />
    else return <Button title="作成" onPress={() => {this.submitCreateTask()}} />
  }

  renderTasks() {
    if (this.state.loading) return <FlatList />
    else {
      return(
        <FlatList
          data={this.state.tasks}
          keyExtractor={(item) => item.id.toString()}
          renderItem={({ item }) => (
            <CheckBox title={item.name} checked={item.finished} onPress={() => this.changeFinished(item)} />
          )}
        />
      )
    }
  }

  logout() {
    AsyncStorage.removeItem('api_token');
    this.props.navigation.navigate('login')
  }

  render() {
    return (
      <View>
        <View style={styles.form}>
          <TextInput
            style={styles.textInput}
            placeholder="タスク名"
            value={this.state.taskName}
            onChangeText={(taskName) => this.setState({taskName})}
          />

          {this.createTaskButton()}
        </View>

        {this.renderTasks()}

        <View style={styles.logout}>
          <Button title="ログアウト" onPress={() => {this.logout()}} />
        </View>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  form: { margin: 40 },
  textInput: {
    height: 60,
    width: 300,
    paddingLeft: 20,
    margin: 10,
    borderWidth: 1,
    borderRadius: 8,
  },
  logout: { marginBottom: 20 }
});

react-native-elementsCheckBox を利用するのでインストールします。

% expo install react-native-elements

state

まずはログイン画面と同様に Main コンポーネントの持つ各 state の動きを把握しましょう。

state 更新タイミング 用途
taskName タスク作成フォームの TextInput にユーザーが入力する度に onChangeText イベントで現在の値がセットされます。また、タスク作成ボタンを押した時に TextInput から現在の値を空にするために空文字列をセットします。 web アプリケーションのタスク作成 API に与えるパラメーターとしてセットします。
tasks componentDidMount で web アプリケーションのタスク一覧 API からタスク情報を取得してセットします。また、各タスクの完了状態のチェックボックスを操作した時に、該当のタスクの finished の値をチェックボックスの状態に応じて変更します。 タスク一覧の表示に使います。
loading ログイン画面と同様、web アプリケーションに fetch でリクエストを送る前に true にし、レスポンスを受け取った後に false にします。 ログイン画面と同様、タスク一覧の取得、タスクの作成、タスクの完了状態の更新の API をコールした後、web アプリケーションと通信している間に各種 UI を ActivityIndicator に差し替えることで、ユーザーに通信中であることを伝えると同時に、不要な操作をされないようにします。この挙動は UX を損ねるので気に入らなければ外してしまってユーザーに通信していることを意識させないようにしても良いでしょう。今回は web アプリケーションとやり取りしていることを明示するために操作の度にクルクルが表示されます。
apiToken componentDidMountAsyncStorage から API トークンを取り出してセットします。 毎回 await AsyncStorage.getItem('api_token') してストレージアクセスするのは非効率なので、一度 API トークンを取得したら state にキャッシュしています。

表示

まずはタスク一覧の表示についてです。実際の表示部分は下記です。

  renderTasks() {
    if (this.state.loading) return <FlatList />
    else {
      return(
        <FlatList
          data={this.state.tasks}
          keyExtractor={(item) => item.id.toString()}
          renderItem={({ item }) => (
            <CheckBox title={item.name} checked={item.finished} onPress={() => this.changeFinished(item)} />
          )}
        />
      )
    }
  }

ロード中(web アプリケーションとの通信中)は空っぽの FlatList を表示し、ロード中でない場合は tasks に従ってアイテムを表示しています。React Native では、コンポーネントの state が更新された時にそのコンポーネントの render メソッドが実行されます。つまり、loadingstate が更新される度にこのコンポーネントの render() が走るため、if に入ってきたり else に入ってきたりするわけですね。この FlatList には this.state.tasks を与えて CheckBox をレンダリングしていますが、this.state.tasks に値が入るのは以下の処理です。

  async componentDidMount() {
    this.setState({ loading: true, apiToken: await AsyncStorage.getItem('api_token') })
    fetch(`https://zone-web.herokuapp.com/api/tasks.json?api_token=${this.state.apiToken}`)
      .then((response) => response.json())
      .then((jsonData) => (this.setState({ loading: false, tasks: jsonData })))
      .catch((error) => console.error(error));
  }

タスクの一覧は componentDidMount で行っています。componentDidMount は、render メソッドによる DOM 挿入直後に呼び出されます。このコンポーネントでは componentDidMount で web アプリケーションからタスク一覧を取得しているのでタスク一覧表じまでの順序としては以下のようになります。

  1. 初回の render で 空の FlatList を表示
  2. componentDidMount で API からタスクの一覧を取得
  3. 取得後に loadingtasksstate が変更されたことで再度 render が実行されてタスク一覧を表示

作成

続いてタスクの作成機能について見ていきましょう。render メソッド内のタスク作成フォーム部分は以下です。

        <View style={styles.form}>
          <TextInput
            style={styles.textInput}
            placeholder="タスク名"
            value={this.state.taskName}
            onChangeText={(taskName) => this.setState({taskName})}
          />

          {this.createTaskButton()}
        </View>

今までと同様、ロード中は ActivityIndicator のクルクルを、ロード中でない場合は作成ボタンをレンダリングします。

  createTaskButton() {
    if (this.state.loading) return <ActivityIndicator size="small" />
    else return <Button title="作成" onPress={() => {this.submitCreateTask()}} />
  }

作成ボタンの onPress イベントで呼ばれるのが以下です。タスク名の入力フィールドに何も入力されていなかった場合は何もせずに終了し、何か入力されていた場合は web アプリケーションのタスク作成 API をコールしています。レスポンスを受けたら、tasksstate に今追加したタスクの情報を詰めて、taskName を空にすることでタスク入力フォームのタスク名入力フィールド(TextInput)を空にしています。

  submitCreateTask() {
    if (!this.state.taskName) return

    this.setState({ loading: true })
    fetch(`https://zone-web.herokuapp.com/api/tasks`, {
      method: 'POST',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ api_token: this.state.apiToken, task: { name: this.state.taskName } })
    })
    .then(response => response.json())
    .then(json => { this.setState({ tasks: this.state.tasks.concat(json), taskName: '', loading: false }) })
    .catch((error) => console.error(error));
  }

更新

最後はチェックボックスの状態を変更した時の完了状態更新処理です。チェックボックスのレンダリング部分は、タスク一覧を表示した時の FlatList の各アイテムの中にあります。

  renderTasks() {
    if (this.state.loading) return <FlatList />
    else {
      return(
        <FlatList
          data={this.state.tasks}
          keyExtractor={(item) => item.id.toString()}
          renderItem={({ item }) => (
            <CheckBox title={item.name} checked={item.finished} onPress={() => this.changeFinished(item)} />
          )}
        />
      )
    }
  }

CheckBoxonPress イベントで呼ばれるのが以下です。引数の item は参照渡しなので、item.finished の論理を反転すると tasksstate が更新されます。

  changeFinished(item) {
    item.finished = !item.finished
    this.setState({ loading: true })

    fetch(`https://zone-web.herokuapp.com/api/tasks/${item.id}`, {
      method: 'PATCH',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ api_token: this.state.apiToken, task: { finished: item.finished } })
    })
    .then(response => this.setState({ loading: false }))
    .catch((error) => console.error(error));
  }

以上で React Native の演習は終了です。

終わりに

今回は2記事構成にて Ruby on Rails アプリケーションと React Native アプリケーションの連携を行いました。冒頭にも書きましたが、技術的に正しくないコードや誤った説明があったかもしれないので、何かお気付きのことがありましたら編集リクエストを頂けると嬉しく思います。React Native アプリケーション開発の日本語入門記事は Firebase を使ってのものが多く、既存の web アプリケーションに API を生やしつつモバイル対応するようなシチュエーションを想定した演習は少なく思えたので、本記事がどなたかの役に立てば嬉しく思います。

Ruby on Rails Advent Calendar 17日目は canecco さんの「ローカル通知に画像を表示する話」です。

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

【Rails】基本的なログイン機構【Rails Tutorial 8章まとめ】

ログイン機能

HTTPリクエスト(POST、GETなど)は送信後、リクエストを送信したという記録が残らないため、情報を保存することができない。
ログイン機能を実装するために、sessionメソッドと専用のコントローラを用意する。

Sessionsコントローラ

Sessionsコントローラを作成する。

$ rails generate controller Sessions new

newアクションとビューはログイン画面で使用する。

SessionsコントローラにはUsersコントローラと同様、RESTfulなルーティングを設定する。
resourcesメソッドを使ってもよいが、editやshowなどのアクションは不要なので、必要なルーティングだけを手動で設定する。

config/routes.rb
Rails.application.routes.draw do
  .
  .
  .
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'
  resources :users
end

ルーティングは次のような状態になっている。
スクリーンショット 2019-11-29 2.19.27.jpg
Sessionsコントローラ内に、空のcreateアクションとdestroyアクションを作っておく。

また、Sessionsコントローラ用のテストを名前付きルートで書き直しておく。

test/controllers/sessions_controller_test.rb
class SessionsControllerTest < ActionDispatch::IntegrationTest
  test "should get new" do
    get login_path
    assert_response :success
  end
end

ログインフォーム

新規登録フォームと同様に、ログインフォームを作成する。
フォームフィールドはメールアドレスとパスワード用だけでよい。

app/views/sessions/new.html
<% 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 %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

form_forメソッドの第一引数は:sessionとなっている(次項)。

ログイン失敗時の処理

ログイン失敗時の処理を書いていく。
ログインフォームから送信された情報は、新規登録の時と同様に、params変数にハッシュとして入っている。

params = { session: { email: "foo@bar.com", password: "foobar" } }

よって、キーを使うことでデータにアクセスできる。

params[:session][:email]
params[:session][:password]

これを使ってcreateアクションを作ると次のようになる。

app/controllers/sessions_controller.rb
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      # ユーザーログイン後にユーザー情報のページにリダイレクトする
    else
      # エラーメッセージを作成する
      render 'new'
    end
  end

まず、find_byメソッドを使ってフォームに入力されたメールアドレスと一致するユーザーを探す。
ここで取得したUserオブジェクトは他のところでは使わないので、@付きのインスタンス変数にする必要はない。
また、メールアドレスは全て小文字で保存されているので、downcaseメソッドを使うことを忘れないようにする。

次に、ユーザーが見つかれば、has_secure_passwordのauthenticateメソッドを使って、送信されたパスワードが正しいかどうかチェックする。
ユーザーが存在し、かつパスワードが合っていれば、ログインしてユーザー情報ページにリダイレクトする。
ユーザーが存在しない、またはパスワードが間違っていれば、エラーメッセージを表示して、ログインページに戻る。

エラーメッセージ

ユーザーの新規登録時には、エラーメッセージは@user.errors.full_messagesに配列として入っており、それをeachメソッドを使って取り出して表示していた。
ログインの場合にはUserオブジェクトのようなものが無いので、flashメソッドを使って表示する。

app/controllers/sessions_controller.rb
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      # ユーザーログイン後にユーザー情報のページにリダイレクトする
    else
      flash[:danger] = 'Invalid email/password combination' # 本当は正しくない
      render 'new'
    end
  end

このフラッシュによるエラーメッセージにはバグがある。
フラッシュはページを移動したり再読み込みすると消えるのだが、この場合は直後に再レンダリングしていることが原因で、メッセージが表示されたままになってしまう。
これをテスト駆動開発で解決していく。

ログイン失敗時のテスト

ログイン機能用のテストを作成する。

$ rails generate integration_test sessions_users_login

Sessionsコントローラ用のテストなのに、users_loginという名前は紛らわしいと思ったので、チュートリアルから少し変えている。

フラッシュがちゃんと消えているかも含め、ログイン失敗時のテストを書く。

test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  test "login with invalid information" do
    get login_path
    assert_template 'sessions/new'
    post login_path, params: { session: { email: "", password: "" } }
    assert_template 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
  end
end

手順は次のよう。
①ログインページに移動する。
②ログインページが表示されていることを確認する。
③無効なユーザー情報を持つparamsハッシュを送信する。
④ログインに失敗したために、ログインページが再レンダリングされたことを確認する。
⑤フラッシュによるエラーメッセージの存在を確認する。
⑥適当にページを移動する(今回はルートURL)。
⑦フラッシュによるエラーメッセージが消えていることを確認する。

最後の⑦のところで実際はフラッシュが消えずに残っているので、テストはREDになる。

これを解決するために、flashをflash.nowに書き換える。

app/controllers/sessions_controller.rb
flash.now[:danger] = 'Invalid email/password combination'

flash.nowを使うと、再レンダリングしたページでのみフラッシュが表示されるようになる。

テストがGREENとなることを確認しておく。

ログイン成功時の処理

sessionsヘルパーメソッド

ログイン機能に必要なメソッドは、Sessionsコントローラを作成した際に自動で生成される。
これをApplicationコントローラで読み込んで、どのコントローラでも使えるようにしておく。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

log_inメソッド

sessionメソッドの中にハッシュとしてUserオブジェクトのidを入れることで、ログイン機能を実装できる。

session[:user_id] = user.id

これは様々なところで使うので、Sessionsコントローラのヘルパーメソッドとして作成する。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
end

これを使ってcreateアクションを書き換えると、次のようになる。

app/controllers/sessions_controller.rb
def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

なお、redirect_to userはredirect_to user_url(user)としてもよい。

現在ログインしているユーザー

現在ログインしているユーザー、つまりsession[:user_id]に入っているidを持つユーザーを取得するために、current_userを定義する。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end

  # 現在ログイン中のユーザーを返す (いる場合)
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end
end

ログインしている場合、session[:user_id]とidが一致するユーザーをfind_byメソッドで探して返す。
ここでは||=(or equals)という代入演算子が用いられている。
以下の二文は同じであり

@foo = @foo || "bar"
@foo ||= "bar"

これは「変数の値がnilなら変数に代入するが、nilでなければ代入しない (変数の値を変えない)」という操作である。

  >> @foo
  => nil
  >> @foo = @foo || "bar"
  => "bar"
  >> @foo = @foo || "baz"
  => "bar"

変数@fooに何も入っていない場合、右辺の値が代入される。
何か入っている場合、何も代入されない。

このcurrent_userが呼び出されるたびに、ユーザーを探して@current_userに代入する、という無駄な処理を省く。

ログイン時のレイアウト

ログインしている場合と、ログインしていない場合とで、レイアウトに表示するものを変更する。
例えば、すでログインしている場合は新規登録やログインページへのリンクは不要である。
また、ログインユーザーのみがアクセスできるページへのリンクを表示する。

これは埋め込みRubyとif文を使って次のように書ける。

<% if logged_in? %>
  # ログインユーザー用のリンク
<% else %>
  # ログインしていないユーザー用のリンク
<% end %>

logged_in?メソッド

ここで、ログインしているならばtrueを返し、そうでないならばfalseを返すlogged_in?ヘルパーメソッドを作成する。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end

  # 現在ログイン中のユーザーを返す (いる場合)
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end

  # ユーザーがログインしていればtrue、その他ならfalseを返す
  def logged_in?
    !current_user.nil?
  end
end

変更したヘッダーのレイアウトは以下のよう。

app/views/layouts/_header.html
<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", '#' %></li>
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", '#' %></li>
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

ユーザーのプロフィールページへのリンクは以下のようであり、link_toメソッドの第二引数のcurrent_userは、やはりuser_path(current_user)とも書ける。

<li><%= link_to "Profile", current_user %></li>

ログアウト用のリンクは以下のようであり、第三引数にmethod: :deleteを渡すことで、DELETEリクエストを送信できるようにしている。

<%= link_to "Log out", logout_path, method: :delete %>

ログイン成功時のテスト

digestメソッドとテスト用ユーザー

ログイン機能をテストするために、有効なユーザー情報を持つテスト用のユーザーをfixtureファイルに用意する。
実際のアプリケーションでは、パスワードはbcryptによってハッシュ化されてUserモデルのpassword_digestカラムに保存される。
テストでこれを再現するために、文字列をbcryptと同じ方法でハッシュ化するdigestメソッドを作成する。

app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  validates :password, presence: true, length: { minimum: 6 }

  # 渡された文字列のハッシュ値を返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end
end

内容の理解は不要。
digestメソッドは個々のUserオブジェクトに対して使うものではないので、インスタンスメソッドではなくクラスメソッドで定義している。

テスト用のfixtureファイルは以下のようになる。

test/fixtures/users.yml
  michael:
    name: Michael Example
    email: michael@example.com
    password_digest: <%= User.digest('password') %>

fixtureファイルは全ての行を一段インデントしておかないと謎のエラーを吐くので注意。
また、埋め込みRubyが使用できる。

テスト

有効なユーザー情報でログインし、成功するテストを書く。

test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "login with valid information" do
    get login_path
    assert_template 'sessions/new'
    post login_path, params: { session: { email: @user.email,
                                          password: "password" } }
    assert_redirected_to @user
    follow_redirect!
    assert_template 'users/show'

    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)
  end
end

setupでfixtureファイルのユーザーを@userに入れておく。
fixtureファイルのユーザーはusers(ユーザー名のキー)で取得できる。
このusersはusers.ymlのusersであり、Userモデルは関係ない。
また、users[:michael]と書くのは間違いなので注意する。

テストの手順は以下のよう。
①ログインページに移動し、表示されているか確認する。
②有効なユーザー情報を持つparamsハッシュを送信する。
③ログインが成功し、ユーザーのプロフィールページにリダイレクトすることを確認する(assert_redirected_toを使う)。
④リダイレクト先に移動する。
⑤ユーザーのプロフィールページが表示されていることを確認する。
⑥ログインページへのリンクが消えていることを確認する。
⑦ログアウトページとプロフィールページへのリンクが表示されていることを確認する。

ユーザー登録時にログインする

ユーザーが新規登録をした場合に自動でログインするようにする。
Usersコントローラのcreateアクションで、新規登録に成功した場合の処理にlog_inメソッドを追加する。

app/controllers/users_controller.rb
 def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

ユーザー登録時のログインのテスト

is_logged_in?メソッド

ユーザーの新規登録後に自動でログインできているかのテストを書く。
ここで、ログインしているかどうかを論理値で返すis_logged_in?ヘルパーメソッドを作成する。

test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  fixtures :all
  # Add more helper methods to be used by all tests here...
  include ApplicationHelper

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?
  end
end

このis_logged_in?ヘルパーはSessionsコントローラのlogged_in?ヘルパーメソッドと動作は同じであるが、名前が違う。
アプリケーション用のヘルパーとテスト用のヘルパーは同じ機能でも名前を変えておかないとトラブルの原因になることがあるらしい。
ところで、logged_in?メソッドではcurrent_userを使ってログインを確かめていたが、こちらではcurrent_userが使えないので、sessionを使っている。
(むこうでsessionを使ってもよいと思う)

テスト自体は、ユーザー登録用のテストに一行追加するだけで済む。

test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "valid signup information" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, params: { user: { name:  "Example User",
                                         email: "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!
    assert_template 'users/show'
    assert is_logged_in?
    assert_not flash.empty?
  end
end

ログアウト機能

log_outメソッド

ログアウトするためのlog_outヘルパーメソッドを定義する。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 現在のユーザーをログアウトする
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

sessionの値を削除するためには、session.delete(:user_id)とする。
session[:user_id] = nilでもよい(多分)。
current_userも忘れずにnilにしておく。

Sessionsコントローラのdestroyアクションでlog_outメソッドを使用する。

app/controllers/sessions_controller.rb
def destroy
  log_out
  redirect_to root_url
end

ログアウト後はルートURLにリダイレクトする。

ログアウト機能のテスト

ログイン成功時のテストに加筆する形で、ログアウト機能のテストを書く。

test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest
  .
  .
  .
   test "login with valid information followed by logout" do
    get login_path
    post login_path, params: { session: { email:    @user.email,
                                          password: 'password' } }
    assert is_logged_in?
    assert_redirected_to @user
    follow_redirect!
    assert_template 'users/show'
    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)

    # ログアウト
    delete logout_path
    assert_not is_logged_in?
    assert_redirected_to root_url
    follow_redirect!
    assert_select "a[href=?]", login_path
    assert_select "a[href=?]", logout_path,      count: 0
    assert_select "a[href=?]", user_path(@user), count: 0
  end

ログアウトのassert_selectは、ログインの逆になっている。

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

Ruby on Rails と React Native で作る web & モバイルアプリ [webアプリ編]

イントロダクション

目的

なんか web アプリケーションを作って運用していたら、どうやらモバイルアプリ需要が出てきたことが分かってバタバタ後追いでモバイルアプリ対応することってあるじゃないですか。今回は演習式でそのシミュレーションをします。まず、Ruby on Rails で web アプリケーションを作った後に、API を生やして React Native で web アプリケーションと同じような仕様のモバイルアプリケーションを作ります。

今回作るアプリケーションの完成品は以下のリポジトリに置いていますので、React Native 側の演習のみを行いたい方は Ruby on Rails アプリケーションは以下より clone してデプロイし、モバイルアプリ編 へ進んでください。

フレームワーク リポジトリ
Ruby on Rails (web) https://github.com/ogihara-ryo/zone-web
React Native (mobile) https://github.com/ogihara-ryo/zone-mobile

いきなり言い訳なんですが、Advent Calendar の1枠としてやるには明らかにやりすぎな記事になってしまいました。お詫び申し上げます。Ruby on Rails と React Native のカレンダーで1枠ずつ頂くのではなく1人 Advent Calendar をやれば良かったなぁと12月に入ってから今更少し後悔しています。ただ、私は深く反省しているので、てへぺろ1つで許されることは目に見えています。張り切っていきましょう。

今回作るシステム

スーパーウルトラシンプルなサンプルアプリケーションを作ることにしましょう。今日やるべきタスクを雑に登録してひたすら消化するためだけのタスク管理システムを作りましょう。大人の都合で認証機能を設けた方が記事的に映えるのでログイン必須のシステムが良いですね。/users/:id/tasks を叩けば誰でもそのユーザーの今日のタスク状況が見られるようにしましょう。

スクリーンショット 2019-12-13 11.25.13.pngスクリーンショット 2019-12-13 11.26.12.png

ユースケースとしては、まず各人は朝に今日中に片付けたいタスクを雑に列挙して(まあ朝起きられないおまえらは昼かもしれませんが)、各タスクが終わるたびにチェックを付けていき、プロジェクトマネージャーだか上司だかは URL を共有してもらっているので各人のタスク状況を web から確認できる、みたいな感じにしますか。それ Google スプレッドシートでできるよ。

web アプリケーションでできること

  • サインアップ
  • ログイン
  • タスクの CRUD
  • 他人のタスクも閲覧のみ可能

モバイルアプリケーションでできること

  • ログイン (サインアップは web でやれ)
  • 自身のタスクの CRUD (他人のタスクは web で見ろ)

想定読者

記事を書いた動機は、最近 Ruby on Rails で作った web アプリを React Native アプリ対応しようと思ったら参考日本語記事が少なくて苦労したためですが、この記事としては Ruby on Rails の心得が少しだけある初学者レベルを想定して書いています。Ruby on Rails 自体が初めての方も、写経で雑に理解しながら進めていくことはできるようには少しだけ配慮していますが、あまりにも初歩的で詳細すぎる説明は避けるようにも配慮しています。本記事(webアプリ編)だけでも、ちょっとした Ruby on Rails アプリケーションを作ることができるので、初学者の方は挑戦してみてほしいなぁ...という下心があります。

もし、この記事を進めていく上で躓いた場合や理解できないことがあった場合は @OgiharaRyo までご連絡頂くか、現在300人ほど参加している技術質問 slack コミュ二ティを運営していますので、こちらで質問して頂ければと思います。(slack 招待リンク)

環境

2019年12月初旬執筆時点の最新版を使用します。

% ruby -v
ruby 2.6.5p114 (2019-10-01 revision 67812) [x86_64-darwin19]

% rails -v
Rails 6.0.1

% psql -V
psql (PostgreSQL) 12.1

謝辞

以下のツイートで記事のテストプレイヤーを募集したところ、にしこさんとnonoさんにお声がけ頂き、記事中の不備や手順の漏れを確認及び指摘して頂きました。ありがとうございました。

Ruby on Rails アプリケーションの開発

rails new

オプション欲張りセットの rails new を実行します。本記事では web アプリケーションを Heroku で公開するため、データベースに PostgreSQL を指定しています。その他のたっぷりあるオプションは今回の記事に関係のないものを全て排除しています。ちなみにアプリケーション名に zone と入っているのは、集中して没頭している意識状態になることを「ゾーンに入る」と表現することがあるので、そこから拝借しました。日次でタスクを可視化して消化するだけで、結構集中状態に入りやすくなる気がしませんか?まあそんなことは良いので早く rails new してください。Ruby on Rails アプリケーション開発の始まりです!

% rails new zone-web --database=postgresql --skip-action-mailer --skip-action-text --skip-active-storage --skip-action-cable --skip-javascript --skip-turbolinks --skip-test

データベースを作ってから一応起動を確認しておきましょう。

% cd zone-web
% rails db:create
% rails s

環境にもよりますが、大人の都合で以下よりあなたの開発サーバーが localhost:3000 であることを前提にリンクを記述していきますので異なる方は適時読み替えてください。ということで、localhost:3000 にアクセスしてご機嫌な表示がされたら ok です。

スクリーンショット 2019-11-30 18.55.58.png

モデリング

とりあえず User モデルと Task モデルを作成します。

User

説明
name string ユーザーの名前を保存します。
account_id string 今回はメール認証等は行わないので、とりあえず任意のアカウントID(文字列)とパスワードのペアでログインできるようにします。ここをメールアドレスにしてメール認証のプロセスを挟んでも良いでしょう。
password_digest string ハッシュ化されたパスワードを保存します。このパスワードは復号できません。つまり、ユーザーがどんなパスワードを入力したのかはシステムが後から知ることはできません。

Task

説明
user_id string どのユーザーのタスクであるのかを保存します。
name string タスクの名前を入力します。見積もり時間も詳細も不要です。ただ1行完結なタスク内容が書ければ良いのです。日次でタスクを表示するので作成日も必要ですが、 rails g(enerate) で生成されたマイグレーションファイルはデフォルトで created_at という列にレコードの作成日時が入るようになるのでこれを利用します。ちなみにこれはあまり良い方法ではないので実務では created_at を業務上の都合と結び付けないようにしましょう。

scaffold 等は行わずに Controller と View は温もりのあるお手製で作ります。モデルとマイグレーションファイルは冷たく Generator に頼ります。

% rails g model User name:string account_id:string password_digest:string
% rails g model Task user:belongs_to name:string finished:boolean
% rails db:migrate

migrate した結果はこのようになっているはずです。

db/schema.rb
  create_table "tasks", force: :cascade do |t|
    t.bigint "user_id", null: false
    t.string "name"
    t.boolean "finished"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["user_id"], name: "index_tasks_on_user_id"
  end

  create_table "users", force: :cascade do |t|
    t.string "name"
    t.string "account_id"
    t.string "password_digest"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  add_foreign_key "tasks", "users"

Task モデルには generator が belongs_to :user を追加してくれていますが、User 側が Task を複数持つことはモデルが知らないので一文追加しておきましょう。

app/models/user.rb
  class User < ApplicationRecord
+   has_many :tasks
  end

サインアップ

とりあえず User を作る機能を実装しなければ始まらないので、サインアップ機能を提供しましょう。

サインアップフォーム表示

ルーティング

/signup という URL にアクセスされたら UsersControllernew アクションが呼ばれるようにします。また、サインアップの submit で /signup という URL に POST された時に UsersControllercreate アクションが呼ばれるようにします。

config/routes.rb
  Rails.application.routes.draw do
+   get 'signup', to: 'users#new'
+   post 'signup', to: 'users#create'
  end

Controller

新しく app/controllers/users_controller.rb を作成し、以下のコードを仮置きします。今回は new アクションでフォームが表示されることだけを目的とするので、create アクションはまだ書きません。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  def new
    @user = User.new
  end
end

パスワードハッシュ化の準備

Gemfile でデフォルトでコメントアウトされている bcrypt をコメントインします。

Gemfile
- # gem 'bcrypt', '~> 3.1.7'
+ gem 'bcrypt', '~> 3.1.7'

bundle install します。ちなみに bundle だけ叩くと bundle install されます。

% bundle

User モデルで has_secure_password をコールします。今インストールした bcrypt の力で、これをコールしておくことでモデルの属性に passwordpassword_confirmation(確認入力用) が追加され、合致して save した時に自動で password_digest にハッシュ化されたパスワードがセットされます。

app/models/user.rb
  class User < ApplicationRecord
+   has_secure_password
  end

View

新しく app/views/users/new.html.erb を作成し、ユーザー情報の入力フォームをマークアップしていきましょう。

app/views/users/new.html.erb
<%= form_with model: @user, url: signup_path, local: true do |f| %>
  <p>
    <%= f.label :name, 'お名前' %>
    <%= f.text_field :name %>
  </p>

  <p>
    <%= f.label :account_id, 'アカウントID' %>
    <%= f.text_field :account_id %>
  </p>

  <p>
    <%= f.label :password, 'パスワード' %>
    <%= f.password_field :password %>
  </p>

  <p>
    <%= f.label :password_confirmation, 'パスワード(確認)' %>
    <%= f.password_field :password_confirmation %>
  </p>

  <p><%= f.submit '送信' %></p>
<% end %>

これで localhost:3000/signup にアクセスするとフォームが表示されます。
スクリーンショット 2019-12-01 18.56.01.png

サインアップ処理実装

今の状態で submit すると(送信ボタンを押すと) 「create アクションが見つからないんだが?!」というエラーで怒られます。初学者のあなたは一度 submit してみてエラー画面を目に焼き付けておきましょう。エラーメッセージを目に焼き付けた数が経験値になります。さて、早速 Controller に create アクションを実装していきましょう。

app/controllers/users_controller.rb
  class UsersController < ApplicationController
    def new
      @user = User.new
    end
+
+   def create
+     @user = User.new(user_params)
+     render :new unless @user.save
+   end
+
+   private
+
+   def user_params
+     params.require(:user).permit(:name, :account_id, :password, :password_confirmation)
+   end
  end

user_params メソッドの中身は Strong Parameters といい、ブラウザーから POST されるはずのパラメーターのキーを個別に許可しています。これは、id 等といったサインアップフォームからは設定してほしくない属性(フォームにフィールドを置いていない属性)を特殊な方法で POST されてもデータベースに保存しないための防御機構です。ここで許可した属性以外は、users テーブルにセットしないようにしているわけですね。

続いて、作成後に「ご登録ありがとうございます。」とのテキストを表示できるようにします。新しくapp/views/users/create.html.erb を作ってマークアップします。

app/views/users/create.html.erb
<p>ご登録ありがとうございます。</p>

さて、実装できたら以下のような情報を入力してテストアカウントを作成してみましょう。

項目 入力する情報
お名前 テストアカウント
アカウントID test
パスワード password
パスワード(確認) password

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

送信すると、先ほどの create.html.erb にマークアップしたテキストが表示されるはずです。

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

上手く実装できていれば、users テーブルに1レコード作成されているはずです。rails console から見てみましょう。

% rails c
Running via Spring preloader in process 48857
Loading development environment (Rails 6.0.1)
irb(main):001:0>

一番最近作られた User を参照します。このように先ほど作ったユーザーが表示されれば ok です。

irb(main):001:0> User.last
  User Load (5.6ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<User id: 1, name: "テストアカウント", account_id: "test", password_digest: [FILTERED], created_at: "2019-12-01 11:58:46", updated_at: "2019-12-01 11:58:46">

「ご登録ありがとうございます。」との表示を確認したのに User の情報が表示されず nil が返ってくる場合はコードを見直してみてください。Strong Parameters あたりが怪しいと思います。

異常処理

今のサインアップ機構には以下の問題点があります。

  1. 名前やアカウントIDやパスワードが空でも登録できてしまう
  2. パスワードとパスワード(確認)が違っている場合にユーザーに何も通知されない
  3. 同一のアカウントIDを登録できてしまう

同一のアカウントIDを登録できてしまうと、ログインする時に入力されたアカウントIDでどのユーザーで認証すれか良いか分からなくなるので、アカウントIDは既に登録されている値を使えなくする必要があります。これから、それらの問題を解決していきます。

サーバーサイドバリデーション

まず、Model 側でバリデーションを設定してレコードを作成する時に値の有効性を検証することにしましょう。パスワードを必須であるかや、パスワードとパスワード(確認)が一致しているかは、has_secure_password が検証してくれるので、残りの名前とアカウントIDに関するバリデーションを定義していきます。

app/models/user.rb
  class User < ApplicationRecord
    has_secure_password
+
+   validates :name, presence: true
+   validates :account_id, presence: true, uniqueness: true
  end

これで、名前が必須となり、アカウントIDが必須かつ重複不可となりました。rails console で試してみましょう。名前を空にして、アカウントIDを先ほど画面から作った test にして、パスワードとパスワード(確認)に別の文字列を入れてみます。

irb(main):001:0> User.create!(name: '', account_id: 'test', password: 'password', password_confirmation: 'invalid')
   (0.1ms)  BEGIN
  User Exists? (0.2ms)  SELECT 1 AS one FROM "users" WHERE "users"."account_id" = $1 LIMIT $2  [["account_id", "test"], ["LIMIT", 1]]
   (0.3ms)  ROLLBACK
Traceback (most recent call last):
        1: from (irb):2
ActiveRecord::RecordInvalid (Validation failed: Password confirmation doesn't match Password, Name can't be blank, Account has already been taken)

今回重要なのは最終行です。

ActiveRecord::RecordInvalid (Validation failed: Password confirmation doesn't match Password, Name can't be blank, Account has already been taken)

と書かれていますね。つまり、パスワード(確認)がパスワードと合っていないよ、名前は空にできないよ、アカウントが重複しているよ、と言われています。問題なさそうですね。ちなみに勿論、データベースには保存されていません。不安であれば再び User.last で確かめてみましょう。ちなみに、エラーになる条件は他にもパターンがあります。アカウントIDやパスワードが空の場合です。勿論このままそういったパターンの検証を続けても良いですが、実際の開発ではこういった挙動確認はテストコードを書いて行うことが多いです。ただし、テストの話をすると私がうるさくなるので、本記事ではノーテストでいきます。「ノーテストで開発するのはノーヘルでバイクに乗るようなものです。つまり気持ちいいってことです。」みたいなことを誰か偉い人が言っていたような気がします。わかる。

エラーメッセージの通知

さて、サーバーからバリデーションエラーで弾かれたことをユーザーに通知してやる必要があります。エラーメッセージは、@user.errors.full_messages で配列で取り出すことができます。View の form_with ブロックの一番上にエラーメッセージ表示用のマークアップを行いましょう。

app/views/users/new.html.erb
  <%= form_with model: @user, url: signup_path, local: true do |f| %>
+   <% if @user.errors.any? %>
+     <ul>
+       <% @user.errors.full_messages.each do |message| %>
+         <li><%= message %></li>
+       <% end %>
+     </ul>
+   <% end %>
    ...
  <% end %>

これで、@user インスタンスがエラー情報を持っている場合のみバリデーションエラーのリストを表示することができます。早速動かしてみましょう。先ほど rails c で試してみたことをブラウザーから試してみましょう。以下のスクリーンショットのように、名前を空にして、アカウントIDを先ほど画面から作った test にして、パスワードとパスワード(確認)に別の文字列を入れてみます。

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

この状態で送信ボタンを押して submit すると以下のようにエラーメッセージが表示されます。先ほど rails c で確認したエラーメッセージと同様ですね。

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

ちなみにこのメッセージを日本語にすることもできるのですが、少しコードが散らかって今回作りたいシステムの目的から脱線するので今回は英語のままにしておきます。変なところで投げっぱなし、それがこの記事の雑さです。また、上記2つのスクリーンショットを見比べてみると、ラベルと入力フィールドの間の改行状態が変わっていることが分かります。これは Ruby on Rails (f.text_field)が気を利かせて生成される HTML にエラーであることが分かるような手を加えてくれているのですが、ここで詳しくは解説しません。気になる方は、ブラウザーのデベロッパーツールから生成された HTML のソースコードを読んでみてください。変なところで投げっぱなし、それがこの記事の雑さです。

クライアントバリデーション

さて、ついでにクライアント(ブラウザー)側でも簡易的なバリデーションを設定しておきましょう。今回はとても簡単な「フィールドを必須にする」という実装だけを行います。なんと、HTML5 の input 要素は required 属性を与えるだけでそのフィールドが必須であることをブラウザーに指示してくれます。Ruby のフォームビルダーのメソッド(f.text_field 等)で required 属性を指定するには required: true というオプションを与えてやるだけで ok です。

app/views/users/new.html.erb
    <p>
      <%= f.label :name, 'お名前' %>
-     <%= f.text_field :name %>
+     <%= f.text_field :name, required: true %>
    </p>

    <p>
      <%= f.label :account_id, 'アカウントID' %>
-     <%= f.text_field :account_id %>
+     <%= f.text_field :account_id, required: true %>
    </p>

    <p>
      <%= f.label :password, 'パスワード' %>
-     <%= f.password_field :password %>
+     <%= f.password_field :password, required: true %>
    </p>

    <p>
      <%= f.label :password_confirmation, 'パスワード(確認)' %>
-     <%= f.password_field :password_confirmation, required: true %>
+     <%= f.password_field :password_confirmation, required: true %>
    </p>

この状態でフィールドを空にして submit しようとすると、ブラウザーから以下のスクリーンショットのように警告が表示されてキャンセルされると思います。このスクリーンショットは macOS の Chrome です。

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

「どの道サーバーで弾かれてエラーメッセージも表示されるんだからクライアント側のバリデーションは冗長では?」と思う方もいらっしゃるかもしれませんが、サーバーに問い合わせなくても入力段階で弾けるものは弾いてしまっておいた方が、ユーザーの手間を煩わせずに済むことが多いため、UX の観点からクライアント側でもバリデーションを行うことが多いです。変なところでこだわる、それがこの記事の雑さです。

スタイリング

無事にサインアップの機能を作ることができましたが、フォームがあまりにも簡素で気分が上がらないので少しだけ見た目に気を使うことにしましょう。ただ、この記事では「CSS 絶対に書きたくないでござる!」という気持ちと、「CSS フレームワークに依存した class 付けは絶対にしたくないでござる!」という気持ちと、「CSS フレームワークの導入は CDN だけで終わらせたいでござる!」という気持ちが強いので、CSS を書かなくても class を付けなくても良くて CDN で読み込むだけでそれっぽい見た目になる方法を探します。そこで mini.css を利用します。記事執筆時点で、ここまでに書いたサインアップフォームを一切触らなくても CDN へのリンクを書くだけでスタイルが当たるものを探し回ったところ mini.css が一番それっぽくなったような気がしました。ということで、早速 CDN から mini.css を読み込みます。たった1行書くだけで CSS を丸ごと配信してもらえるなんて至れり尽くせりですね。また、mini.css の ドキュメント(Getting started) を見ると、「viewport はちゃんと指定しておけよ」と書いてあるので従います。各ページ共通のレイアウト(head 要素等) は app/views/layouts/application.html.erb に書かれているので、ここに viewport の指定と CDN へのリンクを設定します。

app/views/layouts/application.html.erb
    <head>
      <title>ZoneWeb</title>
      <%= csrf_meta_tags %>
      <%= csp_meta_tag %>
+     <meta name="viewport" content="width=device-width, initial-scale=1">
+
+     <link rel="stylesheet" href="https://cdn.rawgit.com/Chalarangelo/mini.css/v3.0.1/dist/mini-default.min.css">
      <%= stylesheet_link_tag    'application', media: 'all' %>
    </head>

この状態でサインアップフォームにアクセスすると、それっぽいデザインになっています。required 属性が付いた input 要素のフィールドが未入力だと赤枠で囲まれるのが素敵ですね。

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

まあいろいろと、もう少し何とかしたい感はありますが、一切の class 付けもカスタマイズも行わずにただ CSS を読み込むだけで、ここまでスタイリングしてもらえれば充分です。今回はスタイリングのことは考えずに進めたいので、一旦はこのまま進もうと思います。

認証

次は、サインアップしたユーザーのアカウントIDとパスワードでログインできるようにします。この章では以下のような仕様で実装していきます。

  • ログインしたらトップページへ遷移
  • ログインしていない状態でトップページへアクセスされたらログインページへリダイレクト

トップページ

まず、ログインした時に表示するためのトップページを作ります。トップページはタスクの一覧をいきなり表示するので、TasksControllerindex アクションにルーティングします。

config/routes.rb
  Rails.application.routes.draw do
+   root 'tasks#index'

    get 'signup', to: 'users#new'
    post 'signup', to: 'users#create'
  end

app/controllers/tasks_controller.rb を作成し、 TasksControllerindex アクションを仮実装します。今は、ログイン後にページが表示されれば何でも良いので空っぽのアクションで ok です。

app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  def index; end
end

続いて app/views/tasks/index.html.erb を作成し、View を仮実装します。テンションの上がりそうなテキストを置いておきましょう。

app/views/tasks/index.html.erb
<p>Welcome!!</p>

これで、トップページ(localhost:3000)にアクセスするとテンションが上がります。
スクリーンショット 2019-12-02 11.16.20.png

ログインフォーム実装

サインアップページやトップページを作った時と同様に、サクサクとルーティングと Controller と View を実装していきましょう。少しずつ慣れてきましたか?

config/routes.rb
  Rails.application.routes.draw do
    root 'tasks#index'

    get 'signup', to: 'users#new'
    post 'signup', to: 'users#create'
+
+   get 'login', to: 'sessions#new'
+   post 'login', to: 'sessions#create'
  end
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new; end

  def create; end
end
app/views/sessions/new.html.erb
<%= form_with scope: :session, url: login_path, local: true do |f| %>
  <p>
    <%= f.label :account_id, 'アカウントID' %>
    <%= f.text_field :account_id, required: true %>
  </p>

  <p>
    <%= f.label :password, 'パスワード' %>
    <%= f.password_field :password, required: true %>
  </p>

  <p><%= f.submit 'ログイン' %></p>
<% end %>

<p><%= link_to 'サインアップはこちら', signup_path %></p>

サクサクと実装できましたか?これでログインページ(localhost:3000/login)にアクセスするとフォームが表示されます。
スクリーンショット 2019-12-03 6.11.50.png

あとは、サインアップ完了画面にログインリンクを追加しておきましょう。

app/views/users/create.html.erb
  <p>ご登録ありがとうございます。</p>
+ <p><%= link_to 'ログインページ', login_path %>からログインしてご利用を開始してください。</p>

認証

さて、それではログイン処理を実装していきましょう。いきなり難易度が跳ね上がりますが、あれだったらコピペして後は見なかったことにして切り抜けてください。まずは認証のコア部分を書いていきます。今回は超シンプルな認証にするので devise 等の Gem は使わずに remember me も実装せず、大したセキュリティも意識せずにサクッとお手製で作っていきます。認証系を手書きするのは地獄だと思われるかもしれませんが、これだけです。

app/helpers/sessions_helper.rb
module SessionsHelper
  def login(user)
    session[:user_id] = user.id
  end

  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def logged_in?
    current_user.present?
  end
end

login メソッドはただの代入のように見えますが、これは User の ID をクライアントの Cookie に暗号化して詰めています。この Cookie の中身を復号した値は代入時と同じように session メソッドから呼び出せます。つまり、session[:user_id]User の ID を詰めておけば、次回以降のリクエストでユーザーに Cookie が残っていれば session[:user_id] を参照することで読み出すことができます。

current_user メソッドは、上述の session[:user_id] を使って User のインスタンスを取得して返します。User.find_by(id: session[:user_id]) で Cookie に入っているユーザーの ID でデータベースを照会している訳ですね。||= という演算子の使い方は見慣れない方もいるでしょう。これは、演算子の短絡評価の特性を使って左辺が偽の場合のみ代入が実行するための文法です。論理和演算(||)における短絡評価とは、「左辺が偽なら右辺が真であろうが偽であろうがどうせ偽になるので右辺をそもそも評価する必要ないよね」という評価法です。左辺が真であって初めて右辺を気にするということですね。ここで ||= を使う目的は、同じリクエスト内で複数回 current_user メソッドがコールされた時のデータベースアクセスを初回以外スキップすることです。つまり初回でデータベースから User のインスタンスを引っ張ってきたら、次回以降はインスタンス変数を参照することでデータベースにアクセスするコストを防いでいるということです。

logged_in? メソッドは、見ての通り current_userUser のインスタンスを引っ張れたかどうかを確認するメソッドです。find_by は見つからなかった時に nil を返すので、session[:user_id] が空の場合や存在しないユーザーの ID 等が入っている場合は、logged_in? メソッドが false を返すということです。

さて、ログイン機構を作ったところで、ログインフォームから POST されてきた時の処理を実装しましょう。SessionsControllercreate アクションを実装していきます。

app/controllers/sessions_controller.rb
  def create
    user = User.find_by(account_id: params[:session][:account_id])
    if user&.authenticate(params[:session][:password])
      login user
      redirect_to root_path
    else
      flash.now[:alert] = 'アカウントID、またはパスワードが間違っています。'
      render :new
    end
  end

ユーザーから POST された値(ログインフォームに入力されたアカウントIDとパスワード)は、params[:session] に入っているので、まず User.find_by(account_id: params[:session][:account_id]) でそのアカウントIDのユーザーを引っ張り、次行の if user&.authenticate(params[:session][:password]) でパスワードが正しいかを確認しています。authenticate メソッドは User モデルでコールした has_secure_password メソッドによって提供されていて、引数に与えたパスワードが正しいかどうかを返してくれます。&. という演算子も見慣れない方がいるかもしれません。これは Safe Navigation Operator という演算子で、レシーバーが nil であれば NoMethodError を投げずに nil を返してくれます。昔の Ruby を知っている方は user.try!(:authenticate, params[:session][:password]) と同じだと考えると良いかもしれません。つまり、ユーザーが存在しないアカウントIDを POST してきて find_byUser のインスタンスが引っ張れずに nil を返してきた時に、else に入るために利用しています。if user.present? && user.authenticate(params[:session][:password]) という上述の演算子の短絡評価を使った書き方の方が明示的で好みな方もいるかもしれませんね。余談ですが、& はひとりぼっちで体育座りしている様子に見えるため &. は通称「ぼっちオペレーター」と呼ばれていたりします。

そんなこんなで認証できた場合は if が真となるので、先ほど SessionsHelper に定義した login メソッドをコールして Cookie に User の ID を詰めてトップページにリダイレクトしています。else に入った場合は、POST されたアカウントIDが存在していなかったか、アカウントIDは存在していたけどパスワードが間違っていたかのいずれかなので、エラーメッセージを flash に詰めて new.html.erb を再描画します。詰めたエラーメッセージを表示する処理は後ほど書きましょう。その前に、今のままだと SessionsHelperlogin メソッドが SessionsController からは見えていない問題を解決します。SessionsHelpercurrent_user はあちこちで使うことになるので全ての Controller が継承している ApplicationController で Mix-in します。

app/controllers/application_controller.rb
  class ApplicationController < ActionController::Base
+   include SessionsHelper
  end

これで、ApplicationController を継承している全ての Controller で SessionsHelpercurrent_userlogged_in? を利用できるようになりました。Helper を Controller に Mix-in するのって気持ち悪いですが、現状スッキリとした方法が思い浮かばないのでこのままいきます。

そして、ログインページに、ログインに失敗した時のエラーメッセージを表示します。先ほど Controller の flash.now[:alert] で詰めたエラーメッセージが表示されます。if flash[:alert].present? しても良いですが、まあ flash[:alert] の中身が nil ならどうせ何も出ないので雑に一行置いておきましょう。

app/views/sessions/new.html.erb
  <%= form_with scope: :session, url: login_path, local: true do |f| %>
+   <%= flash[:alert] %>

    <p>
      <%= f.label :account_id, 'アカウントID' %>
      <%= f.text_field :account_id, required: true %>
    </p>
    ...
  <% end %>

最後に、ログインに成功してトップページにリダイレクトされた後、本当にログインに成功しているのかを確かめるために「Welcome!!」 の文字列を「Welcome [ユーザーの名前(User#name)]!!」に変更しましょう。

app/views/tasks/index.html.erb
- <p>Welcome!!</p>
+ <p>Welcome <%= current_user.name %>!!</p> 

これでログイン処理周りの実装は完了です。早速動かしてみましょう!まずは、適当なアカウントIDやパスワードを入力してログインに失敗した場合にエラーメッセージが表示されるかを確認しましょう。
スクリーンショット 2019-12-03 6.12.49.png

次に、正しいアカウントIDとパスワードでログインしてみましょう。先ほど作ったテストアカウント(アカウントID: test, パスワード: password) でログインしてみましょう。いろいろあってテストアカウントがない場合はサインアップページ(localhost:3000/signup)から作りましょう。ログインに成功してユーザー名が表示されれば ok です。
スクリーンショット 2019-12-03 6.06.20.png

ログアウト

ログインできるようになったのでログアウトも実装しましょう。サクサクといきましょう。まずはルーティングを追加します。/login に対する DELETE リクエストを SessionsControllerdestroy アクションにルーティングします。

config/routes.rb
    get 'login', to: 'sessions#new'
    post 'login', to: 'sessions#create'
+   delete 'logout', to: 'sessions#destroy'

SessionsControllerdestroy を実装していきます。この後追加する SessionsHelperlogout メソッドをコールしてログインページにリダイレクトします。

app/controllers/sessions_controller.rb
    def create
      ...
    end
+
+   def destroy
+     logout
+     redirect_to login_path
+   end
  end

そして、実際のログアウト処理です。Cookie から User の ID 情報を削除して、インスタンス変数も nil で初期化するだけです。

app/helpers/sessions_helper.rb
    def logged_in?
      current_user.present?
    end
+
+   def logout
+     session.delete(:user_id)
+     @current_user = nil
+   end
  end

最後にログアウトのリンクを置きます。まあとりあえずトップページにでも置いておきましょう。

app/views/tasks/index.html.erb
  <p>Welcome <%= current_user.name %>!!</p>
+
+ <%= link_to 'ログアウト', logout_path, method: :delete %>

最後と言ったな、あれは嘘だ。まだ大きな問題があります。上記のコードでは link_tomethod オプションを与えることで、生成される a 要素に data-method="delete" 属性を追加して GET ではなく DELETE でリクエストを送ることを期待しています。しかし、現状はそんなことはできずに GET リクエストで /logout にリクエストされてルーティングエラーになります。「えっ?よくやってるけど」と思う方もいらっしゃるかもしれませんが、これは jquery-ujs が提供している機能です。今回は --skip-javascriptrails new したため jquery-ujs なんて高級なものは入っていません。よし、CDN から読み込みましょう。(雑)

app/views/layouts/application.html.erb
      <link rel="stylesheet" href="https://cdn.rawgit.com/Chalarangelo/mini.css/v3.0.1/dist/mini-default.min.css">
      <%= stylesheet_link_tag    'application', media: 'all' %>
+
+     <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
+     <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-ujs/1.2.2/rails.js"></script>
    </head>

ちなみに data-method 属性を持った a 要素で任意のリクエストを投げるためのコードはこれです。なんと、a 要素が押された瞬間に data-method に従ったメソッドでリクエストを投げられるような form 要素を錬金して強制 submit しています。最初から form を組み立てておいても良いなら link_to ではなく button_to を使う方法もあるのですがデザインがいい感じにならなかったので jquery-ujs を読み込んじゃうことにしました、てへぺろ。まあ今回はしばらく使い古した web アプリケーションのモバイルアプリ化が目的なので、ちょっと jQuery ぐらい置いてたり、設計が歪だったりした方がリアルじゃないですか。ちなみに rails-ujs の話がタイムリーなことに別の Advent Calendar の記事で行われていたので、詳しく知りたい方はこちらの記事をどうぞ。

さて、ということで、ログアウト処理が実装できました。ログインしてトップページからログアウトリンクを踏むとログインページに戻されるためです。ブラウザーのデベロッパーツールでログアウト前とログアウト後で Cookie がどのような動きをするかを確認してみても良いかもしれませんね。

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

ログイン必須化

ログインとログアウトの処理は実装しましたが、もう1つだけ問題があります。それは、ログアウトした状態であってもトップページにアクセスできてしまうことです。ログインしていない状態で localhost:3000 にアクセスしてみましょう。このように「current_usernil だから name なんかにはアクセスできませんぞ!!」というエラーが表示されます。

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

ログアウト状態では、session[:user_id] が空なので current_usernil を返すのでしたね、覚えていますか?回避方法としては先ほどの Safe Navigation Operator (通称ぼっちオペレーター)を使って current_user&.name にする手もありますが、そもそも今回の要件としては、トップページはログインユーザー以外アクセスできてはならないということなので、未ログインのユーザーがトップページにアクセスしてきたらログインページにリダイレクトするようにしましょう。

app/controllers/tasks_controller.rb
  class TasksController < ApplicationController
+   before_action :login_required

    def index; end
+
+   private
+
+   def login_required
+     redirect_to login_path unless logged_in?
+   end
  end

before_action にシンボルで渡したメソッドは、この Controller のアクションがコールされる前に実行されます。ここでは、unless logged_in? の時、つまり未ログインの時にログインページにリダイレクトしています。では、ログアウトした状態でトップページにアクセスしてみましょう。ログインページにリダイレクトされており、ログインしたらトップページが表示されれば ok です。あとは肝心のタスク管理ができるようになれば web アプリケーションとしては完成です。

タスクの CRUD

CRUD とは、データの Create, Read, Update, Delete の頭文字を取ったものです。つまり、タスクを作ったり読み出したり更新したり消したりといったコードをこれから書いていきます。今回は Delete は実装しないので、CRU かもしれませんね。

一覧ページと作成フォーム

タスクの一覧ページはトップページとユーザーIDを指定したページの2種類用意します。つまり、localhost:3000localhost:3000/users/:id/tasks の2種類です。:id の部分にはその Userid 属性が入ります。また、タスクの一覧ページに正しくタスクが表示されているかを確認するために、一気にタスク作成フォームまで作ってしまうので create アクションも実装します。今回は index ページにタスク追加フォームを配置するので new の専用フォームはありません。indexcreate を同時に実装していくのは難しそうに感じるかもしれませんが、大丈夫、あなたは写経するだけです。

config/routes.rb
    get 'login', to: 'sessions#new'
    post 'login', to: 'sessions#create'
    delete 'logout', to: 'sessions#destroy'
+
+   resources :users, only: [] do
+     resources :tasks, only: :index
+   end
+   resources :tasks, only: :create

それでは、タスクの一覧を表示するための Controller 処理を書いていきます。今回少し癖があるのは、/users/:id/tasks の URL でアクセスされた場合は :id で指定された User のタスクを取得しますが、そうでない場合はログイン中の User(current_user) のタスクを取得する必要があるということです。

app/controllers/tasks_controller.rb
  class TasksController < ApplicationController
    before_action :login_required
+   before_action :set_user

-   def index; end
+   def index
+     @tasks = @user.tasks.where(created_at: Time.zone.today.all_day)
+   end
+
+   def create
+     @task = Task.create(task_params.merge(user: current_user))
+     redirect_to Rails.application.routes.recognize_path(request.referer)
+   end

    private

    def login_required
      redirect_to login_path unless logged_in?
    end
+
+   def set_user
+     @user = (params[:user_id].present? ? User.find(params[:user_id]) : current_user)
+   end
+
+   def task_params
+     params.require(:task).permit(:name, :finished)
+   end
  end

まずは before_action で今回タスクを表示する User のインスタンスを @user に代入しています。もし、params[:user_id] が存在しているのであれば、/users/:id/tasks からのリクエストなので User.find(params[:user_id]) で対象の User のインスタンスを取得します。params[:id] が存在していなければトップページからのリクエストなので current_user を取得します。index アクションでは、この @user が持っている今日付けのタスクを全て取得しています。create アクションでは、飛んできたパラメーターにログインユーザーの user_id をセットして tasks テーブルのレコードを作成し、元いたページ(トップページ or /users/:id/tasks)にリダイレクトしています。

次に View を書いていきましょう。Welcome メッセージとログアウトリンクだけであったページを、タスク一覧とタスク作成フォームの表示ページとしてマークアップしていきます。

app/views/tasks/index.html.erb
<p><%= @user.name %></p>

<% if @user == current_user %>
  <%= form_with model: Task.new, local: true do |f| %>
    <%= f.text_field :name, required: true %>
    <%= f.submit '作成' %>
  <% end %>
<% end %>

<ul>
  <% @tasks.each do |task| %>
    <li>
      <%= check_box_tag "finished#{task.id}", true, task.finished, data: { id: task.id }, disabled: (@user != current_user) %>
      <%= label_tag "finished#{task.id}", task.name %>
    </li>
  <% end %>
</ul>

<%= link_to 'ログアウト', logout_path, method: :delete %>

<% if @user == current_user %>
  <script>
    $(document).ready(function(){
      $('#task_name').focus();
    });
  </script>
<% end %>

<style>
  li { list-style: none; }

  :checked + label {
    color: gray;
    text-decoration: line-through;
  }
</style>

今回のアプリケーションでは、タスクのチェックボックスにチェックを入れた状態を完了状態とします。ただし、人のタスクを勝手に完了にするわけにはいかないので、disabled: (@user != current_user) で今見ているページが自分のページでない時に限りチェックボックスを非活性にしています。チェックボックスをチェックした時にサーバーにリクエストを送って finished を変更する処理は後ほど書いていきます。今はただチェックボックスを表示するだけのコードです。check_box_tag の各種引数は覚えるかググるかしかないので、まあ適当に察してください。フォームヘルパーの引数順は何百回ググっても覚えられませんね。まあ、都度ググれば良いのです。チェックボックスの活性状態と同様に、タスクの新規作成フォームも if @user == current_user で今見ているページが自分のページである場合のみ表示しています。

今回の View には純粋な HTML の文書構造だけではなく、script 要素と style 要素を使っています。script 要素では、JavaScript でページを表示した時にタスク名の入力フォームにフォーカスが当たるようにしています。こうすることで、タスク名を打って return キーを叩く操作を繰り返すだけで次々にタスクを作っていくことができて快適になります。ここでも jQuery を利用しています。style 要素では軽くデザインを当てていて、li 要素の list-style: none; で箇条書きの を非表示にして、チェックされたチェックボックスの後続の label 要素のテキストカラーをグレーにして取り消し線を付けています。まあどちらも触ってみれば効果が分かるでしょう。

ということで動作確認です。少し複雑な仕様で作り込んだのでテストも複雑です。テストコードを書いた方が早くテストできるのでストレスは溜まりますが、一生懸命ブラウザーで手動確認していきましょう。準備として、別のユーザーをサインアップフォームから作成しておいてください。サインアップを何度か繰り返していて、もう今のデータベースの状態がよく分からなくなってしまった方は、一度データベースをリセットしてゼロから作っていきましょう。

% rails db:migrate:reset
% rails c
irb(main):001:0> User.create(name: 'テストアカウント', account_id: 'test', password: 'password', password_confirmation: 'password')
irb(main):002:0> User.create(name: 'テストアカウント2', account_id: 'test2', password: 'password', password_confirmation: 'password')
irb(main):003:0> exit

2ユーザーをシステムに作成できたらログインとログアウトを繰り返しながら双方のタスクをいくつか作ってみて、以下を確認してみましょう。

  • トップページ(localhost:3000)
    • 自分のタスクが表示されること
    • タスクの作成が行えること
    • チェックボックスをチェックできること

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

  • 自分のタスク一覧ページ(localhost:3000/users/1/tasks)
    • 自分のタスクが表示されること
    • タスクの作成が行えること
    • チェックボックスをチェックできること

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

  • 他人のタスク一覧ページ(localhost:3000/users/2/tasks)
    • 他人のタスクが表示されること
    • タスク作成フォームが表示されていないこと
    • チェックボックスをチェックできないこと

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

タスクの更新

チェックボックスをチェックすることで、tasks テーブルの finished 列を更新します。今回はチェックボックスの状態変更のイベントを拾って Ajax で非同期にサーバーにステータス更新リクエストを投げることにします。まずはルーティングに update アクションを追加します。

config/routes.rb
    resources :users, only: [] do
      resources :tasks, only: :index
    end
-   resources :tasks, only: :create
+   resources :tasks, only: %i[create update]

次に TasksControllercreate アクションの下に update アクションを追加しましょう。上述のルーティングによって /tasks/:id という URL でリクエストが送られてくるので、params[:id]Task を更新します。ただし、更新しようとしているタスクがログインしているユーザーのタスクであった場合のみ更新を行い、ログインしているユーザーのタスクでなかった場合はログインページにリダイレクトしています。勝手に他人のタスクを弄られないようにする配慮です。

app/controllers/tasks_controller
  def update
    task = Task.find(params[:id])
    redirect_to login_path if task.user != current_user
    task.update(task_params)
  end

そして、View の script 要素内にチェックボックスの状態が変化した時に上記の Controller までリクエストを投げる処理を追加します。

app/views/tasks/index.html.erb
    <script>
      $(document).ready(function(){
        $('#task_name').focus();
      });
+
+     $('input[type="checkbox"]').change(function() {
+       $.ajax('/tasks/' + $(this).data('id'),
+         {
+           type: 'patch',
+           data: {
+             task: {
+               finished: $(this).prop('checked')
+             }
+           },
+           dataType: 'json'
+         }
+       )
+     });
    </script>

チェックボックスを触る度にページをリロードされてはストレスなので、Ajax で非同期にリクエストを投げることでページ遷移を挟まずにリクエストを投げています。やや複雑に見えるコードですが、落ち着いて1行ずつ読んでみれば意味は分かるはずです。$('input[type="checkbox"]').change はチェックボックスが変更された場合イベントを拾おうとしています。$.ajax('/tasks/' + $(this).data('id') では Ajax でリクエストを送る URL を組み立てています。変更されたチェックボックスである $(this)data-id 属性を使って /tasks/1 といった URL を作っています。data-id 属性についてピンと来なければ check_box_tag にどんなパラメーターを与えたかと、結果としてどのような HTML が生成されているかをブラウザーからソースコードを見て確認してみましょう。type: 'patch' は PATCH リクエストを送るためです。PATCH がピンと来なければ rails routes コマンドを実行して config/routes.rb によってどのような URL とメソッドがアプリケーションに定義されているかを確認してみましょう。data: { task: { finished: $(this).prop('checked') } } はサーバーに送るパラメーターです。動作確認する時にチェックボックスを操作した時に rails s に出るログを確認してチェックを付けた時と外した時でそれぞれどんなパラメーターが飛んできているかを確認してみましょう。ということで、早速動かしてみましょう。チェック状態がページをリロードしても変わらなければ ok です。

reload.gif

なんと、これで web アプリケーションとしては完成です。日次のタスクをひたすら登録して消化していくには充分な機能ができました。勿論、まだまだ改善の余地はあるのでスタイリングをするなり、ユーザーのお気に入り機能を付けるなり、他人のタスクを見るだけならログイン必須ではなくしたり、いろいろ取り組んでみると力が付くかもしれませんね。とりあえず、本記事で作りたかった web アプリケーションはこれで完成です。お疲れ様でした。

モバイルアプリケーション用 API 実装

ここからは、モバイルアプリケーションからこの web アプリケーションを扱えるように API を実装していきます。外部からでもタスクの情報を取得したり、タスクの完了状態を変更したりできるようにするためのインターフェースを提供するということですね。早速やっていきましょう。

認証

今作った web アプリケーションでは、タスクを見るにも作るにも更新するにもアカウントIDとパスワードによるログイン認証が必要でした。モバイルアプリケーションも同様で、正しいアカウントIDとパスワードを入力したモバイルアプリケーションにのみタスクの操作を許してあげる必要があります。web アプリケーションは、モバイルアプリケーションが正しいアカウントIDとパスワードを送ってきた場合は API トークンを返します。web アプリケーションは、モバイルアプリケーションがパラメーターに乗せてきた API トークンでログインユーザーを判別します。何を言っているのか分からないと思いますので、実装を始めてしまいましょう。作っていくうちに何となく分かってくるはずです。

APIトークンの発行

まずは users テーブルに api_token の列を追加します。

% rails g migration AddApiTokenToUsers api_token:string
% rails db:migrate

そして、users テーブルにレコードを追加する時に自動でAPIトークンを埋めるようにします。何と1行追加するだけで bcrypt が勝手にやってくれます。

app/models/user.rb
  class User < ApplicationRecord
    has_many :tasks

    has_secure_password
+   has_secure_token :api_token

    validates :name, presence: true
    validates :account_id, presence: true, uniqueness: true
  end

これで新規の User に関しては自動で api_token がセットされるようになりましたが、既に存在する users テーブルのレコードの api_token は空のままなので rails console からワンライナーで一気に発行してしまいましょう。ちゃんと発行されたか不安であれば User.first.api_token 等で確認してみましょう。これで API トークンの発行準備は完了です。

% rails c
irb(main):001:0> User.find_each { |user| user.regenerate_api_token }

ログイン

次に、ログイン用のルーティングを追加します。API に関する URL は必ず /api で始めることにしましょう。また、API 用の Controller は Api という名前空間で括ることにしましょう。つまり、ログイン API は URL が /api/login で Controller は app/controllers/api/login_controller.rb にある Api::LoginController クラスとなります。

config/routes.rb
    resources :tasks, only: %i[create update]
+
+   namespace :api do
+     get 'login', to: 'login#show'
+   end

次に Controller を実装していきます。

app/controllers/api/login_controller.rb
class Api::LoginController < ApplicationController
  def show
    @user = User.find_by(account_id: params[:account_id])
    if @user&.authenticate(params[:password])
      render status: :ok, json: { api_token: @user.api_token }
    else
      render status: :unauthorized, json: {}
    end
  end
end

それでは、localhost:3000/api/login?account_id=test&password=password にアクセスしてみましょう。このような API トークンが表示されれば ok です。

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

また、パラメーターに誤ったアカウントIDかパスワードを指定してみましょう。この時は API トークンは返ってきません。

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

タスク CRUD API

認証と一覧の取得

自身のタスクの一覧を返したり、更新させたりという機能を書いていくわけですが、それを要求してきたユーザーが上記の API トークンをちゃんと持っているかを確認する必要があります。ルールとして、リクエストのパラメーターに必ず api_token を与えてもらうことにします。api_token のパラメーターが渡されていない場合、あるいは誤っている場合のリクエストは無視します。

app/controllers/api/tasks_controller.rb
class Api::TasksController < ApplicationController
  before_action :authenticate_by_token

  def index
    @tasks = @user.tasks.where(created_at: Time.zone.today.all_day)
    render json: @tasks.map { |task| { id: task.id, name: task.name, finished: task.finished } } 
  end

  private

  def authenticate_by_token
    @user = User.find_by(api_token: params[:api_token])
    render status: :unauthorized, json: 'Invalid API token' if @user.blank?
  end
end

before_action では、トークンの認証を行い失敗した場合に Invalid API token という文字列とステータスコード 401 を返します。

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

正しい API トークンを与えた場合はタスクの一覧の json が返ってきます。これが @tasks.map { |task| { name: task.name, finished: task.finished } } の結果です。モバイルアプリケーションは、この情報を元にタスクの一覧画面を組み立てるわけですね。

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

作成と更新

続いて作成と更新の API を一気に書いていきます。やることは web アプリケーションとほとんど同じなのでサクサクといきましょう。まずはルーティングに createupdate アクションを追加します。

config/routes.rb
    namespace :api do
      get 'login', to: 'login#show'
-     resources :tasks, only: :index
+     resources :tasks, only: %i[index create update]
    end

そして、Controller に web の時と同様に createupdate アクションを実装していきます。

app/controllers/api/tasks_controller.rb
  class Api::TasksController < ApplicationController
+   skip_forgery_protection

    before_action :authenticate_by_token

    def index
      @tasks = @user.tasks.where(created_at: Time.zone.today.all_day)
      render json: @tasks.map { |task| { id: task.id, name: task.name, finished: task.finished } } 
    end
+
+   def create
+     task = Task.create(task_params.merge(user: @user))
+     render json: { id: task.id, name: task.name, finished: task.finished }, status: :created
+   end
+
+   def update
+     task = Task.find(params[:id])
+     (task.user == @user) ? task.update(task_params) : render(status: :unauthorized)
+   end

    private

    def authenticate_by_token
      @user = User.find_by(api_token: params[:api_token])
      render status: :unauthorized, json: 'Invalid API token' if @user.blank?
    end
+
+   def task_params
+     params.require(:task).permit(:name, :finished)
+   end
  end

create アクションと update アクションの中身はほとんど前回と同じなので問題ないでしょう。問題があるとすれば先頭にしれっと追加された skip_forgery_protection という一行です。これは話すと長くなるのですが、Ruby on Rails はデフォルトで CSRF(Cross-Site Request Forgeries) 保護が行われています。外部の攻撃者から不正なリクエストを受けても弾けるように、POST や PATCH リクエストは認証トークンを添えて送らなければリクエストを処理しません。認証トークンで認証できない場合は Controller の before_action 時点で弾かれるので createupdate といったアクションに入ってくることはありません。skip_forgery_protection はこれを skip_before_action するためのものです。この後 curl で API を叩いてみるので興味のある方は skip_forgery_protection をコメントアウトした状態で API を叩いて rails server のログを確認してみると良いでしょう。Can't verify CSRF token authenticity. というエラーが表示されているはずです。そして作成や更新は行われずにデータベースに変化は起こっていないはずです。ただ、この Controller に関しては、authenticate_by_token メソッドで自前の セキュリティゆるゆるの トークン認証を実装しているので、一旦は CSRF 保護をスキップします。

それでは、動作を確認してみましょう。まずは作成です。ターミナルから curl コマンドを実行してサーバーに POST リクエストを送ってみます。api_token はご自身のものに差し替えてください。先ほどログイン API で GET した API トークンです。

% curl -X POST -d 'task[name]=by API&api_token=8YKPhsDNphD91j4EaQxyf6JF' localhost:3000/api/tasks

実行したらブラウザーのタスク一覧画面か、一覧 API で返ってくる json を確認してみましょう。新しいタスクが追加されていれば ok です。

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

POST による作成が問題なければ、次は更新の PATCH リクエストを送ってみましょう。今作ったタスクを完了状態にしてみましょう。API トークンを差し替えるのは勿論のこと、URL の末尾の Task の ID も今作った Task の ID に差し替えてください。rails consoleTask.last.id を叩けば最後に作った Task の ID が取れます。

% curl -X PATCH -d 'task[finished]=true&api_token=8YKPhsDNphD91j4EaQxyf6JF' localhost:3000/api/tasks/7

再びブラウザーのタスク一覧画面か、一覧 API で返ってくる json を確認してみましょう。完了状態になっている、あるいは finishedtrue になっていれば ok です。

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

ここまで実装できれば、モバイルアプリケーションからタスク管理するための準備は整いました。お疲れ様でした。

デプロイ

最後にこの web アプリケーションをどこかのサーバーに置く必要があります。本記事では無料かつクレジットカードの登録も必要がない Heroku にデプロイする手順を雑に書きますが、お好きなサーバーに上げてもらって大丈夫です。どこにも上げたくなくてお手軽にやりたければ、ngroklocalhost:3000 をポートフォワーディングする手もあります。本記事では「web アプリケーションを作ったった!!」という気分になりたいので、とりあえず Heroku の無料枠にデプロイします。

まずは Heroku にサインアップしてインストールガイドに従って heroku-cli をインストールします。Mac であれば現状は以下です。

% brew tap heroku/brew && brew install heroku

Heroku にログインします。下記のコマンドを実行するとブラウザーからのログインが求められます。

% heroku login

Heroku では Git のリポジトリを push する形でデプロイするので、もしここまで Git 管理していなかった人は雑に commit してください。

% git commit -am "Create task management application"

Heroku にアプリを作ります。このタイミングで URL が割り振られます。

% heroku create
Creating app... done, ⬢ limitless-earth-31665
https://limitless-earth-31665.herokuapp.com/ | https://git.heroku.com/limitless-earth-31665.git

お好みで名前を変更してください。ドメインになります。勿論人と被ると弾かれます。zone-web.herokuapp.com は頂いた。

% heroku rename zone-web
Renaming limitless-earth-31665 to zone-web... done
https://zone-web.herokuapp.com/ | https://git.heroku.com/zone-web.git

heroku create した段階で、git remoteheroku が自動登録されているので、ここに push するだけでデプロイできます。3分ぐらい時間がかかると思うので、お茶でも飲みながら休憩しましょう。

% git push heroku master

無事にデプロイできたら、Heroku 上で rails db:migrate を実行してもらいます。heroku run を頭に付けるだけでコマンドを走らせられるのは素晴らしいですね。

% heroku run rails db:migrate

これで準備は完了です。heroku createheroku rename の時に割り振られた URL へアクセスしてみましょう。ログインページにリダイレクトされれば、ちゃんとアプリケーションが動いています。サインアップして自由にアプリケーションを操作してみましょう。

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

これで、web アプリ編の演習は終了です。お疲れ様でした。モバイルアプリ編 でお会いしましょう。

終わりに

今回の演習では、最小限の認証付きタスク管理アプリケーションを開発し、モバイルアプリケーションに向けた API の実装を行いました。もし完走した方がいらっしゃいましたら、こっそり私(@OgiharaRyo)まで教えて頂けると嬉しいです。冒頭でも言い訳しましたが、Advent Calendar でやるには本当に本当に長い演習になってしまいました。重ねてお詫び申し上げます。

Ruby on Rails Advent Calendar 17日目は bake0937 さんです。

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

【Bootstrap】jQueryライブラリの読み込み【Rails Tutorial 8章まとめ】

BootstrapのjQueryライブラリ

Bootstrapに含まれるCSSのdropdownクラスやdropdown-menuといったドロップダウン機能は、Bootstrapのjavascript(jQuery)ライブラリを読み込まなければ使えない。

application.jsにBootstrapのJavaScriptライブラリを追加する。

app/assets/javascripts/application.js
//= require rails-ujs
//= require jquery
//= require bootstrap
//= require turbolinks
//= require_tree .
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

#Rails ( #Ruby + ActiveSupport ) + Time.use_zone + travel_to で現地時刻=日本時刻=JSTを固定して UTC 変換結果を見て遊ぶ例

  • タイムゾーンの9時間の扱いを一生かかっても覚えられる気がしない
  • 足し算と引き算を間違える確率が65%
  • Rubyで遊んでいたらだんだん分かってきた気がするけれど
  • タイムゾーンに慣れ親しむだけで1週間かかって、3日で忘れる気がした
require 'active_support/core_ext'

# 日本時間の年始の時間
jst_beggining_of_year = Time.use_zone('Tokyo') { Time.local(2020, 01, 01, 00, 00, 00) }

# travel するために必要
require 'active_support/testing/time_helpers'
include ActiveSupport::Testing::TimeHelpers

# 日本時間の年始に時間を固定する
travel_to jst_beggining_of_year

# 今日の日付は 2020-01-01
Date.current
# => Wed, 01 Jan 2020

# 現在時刻は 2020-01-01 00:00:00 で JST のタイムゾーンも持っている
Time.now
# => 2020-01-01 00:00:00 +0900

# こちらも同じく
Time.current
# => 2020-01-01 00:00:00 +0900

# Time.nowはRubyのclassだがちゃんとtravelできている
Time.now.class
# => Time

# Time.currentはRails=ActiveSupportのclassみたいだがtravelできている
# Time.zone= を指定していない場合は Ruby の Time.now と同じように働く気がしたが合ってるかな
Time.current.class
# => ActiveSupport::TimeWithZone

# Time.zone.now メソッドでは、Time.zoneの指定をしていないので何も得られない
# Time.currentとは扱いが違うみたいだ
Time.zone.now
# NoMethodError: undefined method `now' for nil:NilClass

# Time.zone 指定を日本時間にする
Time.zone = 'Tokyo'
# => "Tokyo"

# 現在時刻は 2020-01-01 00:00:00 で JST のタイムゾーンや曜日も持っている
Time.zone.now
# => Wed, 01 Jan 2020 00:00:00 JST +09:00

# Time.zone.now は ActiveSupport の class を持っている
Time.zone.now.class
# => ActiveSupport::TimeWithZone

# 今日の日付は 2020-01-01だ
# こちらも Ruby ではなく Rails = ActiveSupport のもの
Date.current
# => Wed, 01 Jan 2020

# Time.zone 指定を日本時間にする
Time.zone = 'UTC'
# => "UTC"

# UTCでいうと今日の日付はまだ、前年の年末だ
Date.current
# => Tue, 31 Dec 2019

# 日本時間の年始の0時は、UTCでいうと9時間遅い、年末の15時だ 
Time.zone.now
# => Tue, 31 Dec 2019 15:00:00 UTC +00:00


Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2775

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

#Ruby と #Rails の Time や Time.zone の扱いががマジで分からないし混沌としてるのでちょっとだけ整理したい。

Try at

この時間に試しました

  • JST 2019-11-28 hour 19
  • UTC 2019-11-28 hour 10

For Ruby

require ‘active_support/core_ext’

No Time zone setting

タイムゾーン指定がないとメソッドが使えなかったり

Time.zone
# nil

Time.zone.now
# NoMethodError: undefined method `now’ for nil:NilClass

Timezone指定

Time.zone は ActiveSupportのインスタンスになる
タイムゾーンを切り替えるには Time.zone= メソッドを実行

Railsであればapplication.rb の configに書くだろうが

タイムゾーン指定自体は Tokyo でも Asia/Tokyo でも同じようにはからってくれるっぽい
ちなみに Time.zone = ‘JST’ じゃ動かないとか信じられるかい

Time.zone = 'UTC'
=> "UTC"
Time.zone
=> #<ActiveSupport::TimeZone:0x00007f8610941ef0 @name="UTC", @tzinfo=#<TZInfo::DataTimezone: Etc/UTC>, @utc_offset=nil>

=> #<ActiveSupport::TimeZone:0x00007f86108d1a38 @name="Asia/Tokyo", @tzinfo=#<TZInfo::DataTimezone: Asia/Tokyo>, @utc_offset=nil>
Time.zone = 'Tokyo'
=> "Tokyo"
Time.zone

Time.zone = 'Asia/Tokyo'
=> "Asia/Tokyo"
Time.zone
=> #<ActiveSupport::TimeZone:0x00007f86108d1a38 @name="Asia/Tokyo", @tzinfo=#<TZInfo::DataTimezone: Asia/Tokyo>, @utc_offset=nil>


Time.zone = 'JST'
# ArgumentError: Invalid Timezone: JST

UTC

UTCをtimezone指定した場合

Time.zone = UTC
# => “UTC”

Time.zone
# => #<ActiveSupport::TimeZone:0x00007fe40e15ed08 @name=“UTC”, @tzinfo=#<TZInfo::DataTimezone: Etc/UTC>, @utc_offset=nil>

Time.zone.now
# => Thu, 28 Nov 2019 10:00:31 UTC +00:00

Time.zone.parse(2020-01-01 00:00:00)
# => Wed, 01 Jan 2020 00:00:00 UTC +00:00

JST

JSTをtimezone指定した場合

Time.zone = Tokyo
# => “Tokyo”

Time.zone
# => #<ActiveSupport::TimeZone:0x00007fe40d2721a0 @name=“Tokyo”, @tzinfo=#<TZInfo::DataTimezone: Asia/Tokyo>, @utc_offset=nil>

Time.zone.now
# => Thu, 28 Nov 2019 19:00:47 JST +09:00

Time.zone.parse(2020-01-01 00:00:00)
# => Wed, 01 Jan 2020 00:00:00 JST +09:00

Time系

こちらはRuby系なので Time.zone を参照しない、というより Time.zone なんていうメソッドもクラスもない
Rails = ActiveSupport の Time.zone 系メソッドとは分けて考えること

Ruby で最初から出来るかと思いきや、 一部のメソッドは requireが必要という罠 -> Time.parse とか
require active support でも動くようになるので、まさかのactivesupportのメソッドかと思って驚いたが違った

タイムゾーンを変えるには ruby実行時の環境変数を変える

TZ=UTC ruby ...
TZ=Asia/Tokyo ruby ...

環境変数指定もしくはシステムのタイムゾーンによって結果が変わる
Rubyだけの処理でもタイムゾーン情報を持っているようだ

Time.now
# => 2019-11-28 10:19:37 +0000

Time.now
# => 2019-11-28 19:19:43 +0900

# Time.parse / Time.zone.parse

Time.parse(2020-01-01 00:00:00)
# NoMethodError: undefined method `parse’ for Time:Class

Time.parse(2020-01-01 00:00:00)
# => 2020-01-01 00:00:00 +0900

Time.zone = UTC
# => “UTC”

Time.zone.parse(2020-01-01 00:00:00)
# => Wed, 01 Jan 2020 00:00:00 UTC +00:00

RubyっぽいのにRails? current系

nowじゃなくてcurrentっていう名前のメソッドはRubyじゃなくてRails/AcviveSupportのものだ

Time.current
# NoMethodError: undefined method `current’ for Time:Class

require active_support/core_ext

Time.current
# => 2019-11-28 19:21:56 +0900

なのにclassTimeという奇特なやつ

Time.current.class
# => Time

ちなみに Date.today は Ruby のものだが Date.current Date.yesterday Date.tomorrow は Railsのものだという大いなる罠もある

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2774

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

Railsでのjoinsの結果のidについて

Railsでテーブル結合してデータを取得した時、idを参照元の主キーにしたいのに参照先の主キーになってしまっていた。
結論として

Hoge.joins(:fuga).where(fuga_name:"hoge").select("fuga.*,hoge.*")

このようにselectで最後に指定したテーブルの主キーが結合したデータの主キーとなる。

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