youtube、twitterの埋め込み

アプリにyoutube動画とtwittertweetを表示させてみました。
APIを使う方法とローカルで埋め込む方法がありますが、今回はローカルで埋め込む方法でやっていきます。
レイアウトが崩れてますが完成形はこんな感じです。

Image from Gyazo

ではアプリの新規作成からやっていきましょう。

rails new embed_app
cd embed_app

で新規作成、移動したら

rails g scaffold Article title:string youtube_url:string twitter_url:string
rails db:migrate

scaffoldしてmigrateします。
viewはslimで書くのでgem slim-railshtml2slimを入れてます。

youtubeの埋め込み

まずはyoutubeからいきます。
記事詳細ページに埋め込みたいのでshow.html.slimを編集しましょう。

p#notice = notice

p
  strong Title:
  = @article.title
p
  strong Youtube url:
  .embed-youtube
  = content_tag 'iframe', nil, width: 560, height: 315, src: "https://www.youtube.com/embed/#{@article.split_id_from_youtube_url}", \
    frameborder: 0, gesture: 'media', allow: 'encrypted-media', allowfullscreen: true
p
  strong Twitter url:
  = @article.twitter_url

=> link_to 'Edit', edit_article_path(@article)
'|
=< link_to 'Back', articles_path

youtube動画の埋め込みはこんな感じです。
src@article.split_id_from_youtube_urlですが、モデルで

class Article < ApplicationRecord
  def split_id_from_youtube_url
    youtube_url.split('/').last
  end
end

こんなメソッドを定義しています。
splitメソッドで、受け取ったyoutube_urlを引数で指定した「/」で区切り、配列に入れています。
lastメソッドでその配列の一番後ろのものを取得しています。

Image from Gyazo

配列の一番後ろはyoutube動画の固有のIDである、11桁の英数字です。
動画の「共有」をクリックすると下のような共有用URLが表示されます。

https://youtu.be/L11fcH1J5tw

これをフォームに入力するとsplit_id_from_youtube_urlメソッドが動画のIDを切り出して

https://www.youtube.com/embed/L11fcH1J5tw

という形に変換してくれます。
(動画を埋め込む際はhttps://www.youtube.com/embed/動画IDという形式にする必要があります)
しかし

https://www.youtube.com/watch?v=L11fcH1J5tw

のような形式を入力するとうまく動画を埋め込むことができません。
先程のメソッドに当てはめるとwatch?v=L11fcH1J5twが取得されて

https://www.youtube.com/embed/watch?v=L11fcH1J5tw

というURLになってしまうからです。
そこでif文でこのように分岐させてみました。(もうちょっといい感じに書けそうですがうまくリファクタリングできませんでした)

strong Youtube url:
.embed-youtube
- if @article.youtube_url.include?('watch?v=')
  = content_tag 'iframe', nil, width: 560, height: 315, src: "https://www.youtube.com/embed/#{@article.youtube_url.last(11)}", \
    frameborder: 0, gesture: 'media', allow: 'encrypted-media', allowfullscreen: true
- else
  = content_tag 'iframe', nil, width: 560, height: 315, src: "https://www.youtube.com/embed/#{@article.split_id_from_youtube_url}", \
    frameborder: 0, gesture: 'media', allow: 'encrypted-media', allowfullscreen: true

動画のURLをそのままコピペしたらif、共有URLをコピペしたらelseに分岐して、
どちらのパターンでも動画が埋め込みできるようになりました。

twitterの埋め込み

次はtwitterの埋め込みです。

  .embed-twitter
    blockquote.twitter-tweet
      a href="#{@article.twitter_url}"
  script async="" charset="utf-8" src="https://platform.twitter.com/widgets.js"

こんな感じです。
https://publish.twitter.comのフォームにツイートのURLを入力すると埋め込み用のコードがコピーできます。
それをslim記法に直して必要なものだけ取り出したのが上記のコードです。
ただしhref属性を動的に変えるため"#{@article.twitter_url}"としています。

最終的なコードがこちらです。

p#notice = notice

p
  strong Title:
  = @article.title
p
  strong Youtube url:
  .embed-youtube
  - if @article.youtube_url.include?('watch?v=')
    = content_tag 'iframe', nil, width: 560, height: 315, src: "https://www.youtube.com/embed/#{@article.youtube_url.last(11)}", \
      frameborder: 0, gesture: 'media', allow: 'encrypted-media', allowfullscreen: true
  - else
    = content_tag 'iframe', nil, width: 560, height: 315, src: "https://www.youtube.com/embed/#{@article.split_id_from_youtube_url}", \
      frameborder: 0, gesture: 'media', allow: 'encrypted-media', allowfullscreen: true
p
  strong Twitter url:
  .embed-twitter
    blockquote.twitter-tweet
      a href="#{@article.twitter_url}"
  script async="" charset="utf-8" src="https://platform.twitter.com/widgets.js"

=> link_to 'Edit', edit_article_path(@article)
'|
=< link_to 'Back', articles_path

最後に

無事、埋め込むことができました。
youtubetwitterの埋め込みは汎用性が高そうですね。
今回は以上です。ありがとうございました。

参考サイト

動画と再生リストを埋め込む - YouTube ヘルプ

タイムラインを埋め込む方法

【Rails】YoutubeとTwitterをAPIを使わずに記事に埋め込む - Ruby on Rails Learning Diary

railsアプリに投稿されたYouTubeURLを自動的に埋め込み表示させる方法~無理やり編~ - Qiita

simple_form

simple_formとは

入力フォームを簡単に作成できるgemです。

導入方法

Gemfileに

gem 'simple_form'

としてbundle installします。

rails g simple_form:install

bootstrapを適用したい場合は下記
rails g simple_form:install --bootstrap

こちらのコマンドを実行します。

フォームを作成

あとはviewファイルにフォームを作成するだけです。

# slimで書いてます

= simple_form_for user do |f|
  .field
    = f.input :name
  .field
    = f.input :age
  .field
    = f.input_field :gender, as: :radio_buttons
  .field
    = f.input :birthday, start_year: Time.now.year, end_year: Time.now.year-100
  .actions
    = f.button :submit

# schema
t.string :name, null: false
t.integer :age
t.integer :gender, null: false, default: 0 # enum :gender { male: 0, female: 1 }
t.date :birthday

t.timestamps

Image from Gyazo

こんな感じでシンプルにフォームを作ることができます。

オプション色々

オプションを付けることで色々できます。

・ラベルを変更

= f.input :name, label: '名前'

Image from Gyazo

・ラベルを非表示に

= f.input :name, label: false

Image from Gyazo

・エラー文をカスタマイズ

= f.input :name, error: 'エラー文をカスタマイズ'

Image from Gyazo

・ヒントを表示

= f.input :name, hint: 'ヒントを表示'

Image from Gyazo

プレースホルダーを表示

= f.input :name, placeholder: 'プレースホルダー'

Image from Gyazo

asオプションではHTMLのtypeを変えることができます。
チェックボックス

= f.input :name, as: :boolean

Image from Gyazo Image from Gyazo

・ファイル

= f.input :name, as: :file

Image from Gyazo Image from Gyazo

まあnameカラムにこんなの付けないですね、苦しい例ですみません…
as: :オプションでtypeが変えられるのは分かって頂けたかと思います。

最後に

と、こんな感じで色々とできます。
その名の通りシンプルなので分かりやすく、form_withより記述量が減るので便利ですね。
今回は以上です。ありがとうございました。

参考サイト

GitHub - heartcombo/simple_form: Forms made easy for Rails! It's tied to a simple DSL, with no opinion on markup.

【Rails】simple_form使い方基礎 - Qiita

Active Storageで画像をアップロード

Active Storageとは

ファイルアップロードを簡単に実装できるgemです。
railsの標準のgemで、rails5.2から追加されました。

scaffoldアプリの作成

rails new active_storage_app

名前はactive_storage_appとしておきます。
rails newしたら

cd active_storage_app

で移動して

rails g scaffold User name:string
rails db:migrate

scaffoldで元になるアプリを作成します。

Active Storageの導入

rails active_storage:install

このコマンドでactive_storage_blobsactive_storage_attachmentsの2つのテーブルが作成されるマイグレーションファイルが生成されるので

rails db:migrate

とします。
active_storage_blobsはアップロードしたファイルを保存するテーブルで active_storage_attachmentsは中間テーブルです。
一人のユーザーに対して一枚のアバター画像をもつ1対1の関係のとき、
モデルにhas_one_attached :カラム名を記述します。

class User < ApplicationRecord
  has_one_attached :avator
end

カラム名、と言いましたが、usersテーブルにはnameカラムしか作っていませんでした。
しかし、わざわざマイグレーションファイルを作成してavatorカラムを追加して…としなくても、
has_one_attachedを書くだけで大丈夫です。

画像サイズの設定

画像以外に、画像のサイズを設定できるようにもしたいので、

rails g migration add_avator_width_to_users

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

class AddAvatorWidthToUsers < ActiveRecord::Migration[5.2]
  def change
    add_column :users, :avator_width, :integer
  end
end

usersテーブルにinteger型のavator_widthカラムを追加し、

rails db:migrate

とします。
画像のサイズはvalidationで100px~500pxに制限しておきます。
また、avator_widthを空欄で登録しようとすると弾かれてしまうのでallow_nil: trueも書いておきます。

class User < ApplicationRecord
  has_one_attached :avator
  validates :avator_width,  numericality: { greater_than_or_equal_to: 100, less_than_or_equal_to: 500 }, allow_nil: true
end

コントローラのストロングパラメータでは

class UsersController < ApplicationController

 # 略

    def user_params
      params.require(:user).permit(:name, :avator, :avator_width)
    end
end

avatoravator_widthをpermitしておきます。

viewを整形

viewの方も整えていきます。
画像ファイルと画像サイズのフォームを作成し

_form.html.erb

<%= form_with(model: user, local: true) do |form| %>
  <% if user.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:</h2>

      <ul>
      <% user.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :name %>
    <%= form.text_field :name %>
  </div>

  <div class='field'>
    <%= form.label :avator %>
    <%= form.file_field :avator %>
  </div>

  <div class='field'>
    <%= form.label :avator_width %>
    <%= form.number_field :avator_width %>
  </div>


  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

ユーザー詳細ページでそれを表示させます。

show.html.erb

<p id="notice"><%= notice %></p>

<p>
  <strong>Name:</strong>
  <%= @user.name %>
</p>

<% if @user.avator.attached? %>
  <%= image_tag @user.avator, width: @user.avator_width %>
<% end %>

<%= link_to 'Edit', edit_user_path(@user) %> |
<%= link_to 'Back', users_path %>

attached?メソッドはhas_one_attachedを設定することで使えるメソッドです。
userがavatorを持っていればtrueを、持っていなければfalseを返します。
ユーザー詳細ページを見てみると

Image from Gyazo

ちゃんと画像が表示されていますね。

画像サイズを調整

ユーザー詳細ページのアバター画像には、フォームに入力した画像サイズが適用されています。
ユーザー一覧ページにもアバターを表示させたいですが、小さいサイズに調整したいです。
画像処理ツールのImageMagickImageMagickrailsで使うためのgem、mini_magickを使います。(ImageMagickのインストール方法は省略します)
Gemfileにmini_magickがコメントアウトされていると思うので、コメントアウトを外してbundle installしたら、
ユーザー一覧ページを編集して、サイズ調整した画像を表示させましょう。

<p id="notice"><%= notice %></p>

<h1>Users</h1>

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @users.each do |user| %>
      <tr>
        <td><%= user.name %></td>
        <td><%= image_tag user.avator.variant(resize: '50x50').processed if user.avator.attached? %></td>
        <td><%= link_to 'Show', user %></td>
        <td><%= link_to 'Edit', edit_user_path(user) %></td>
        <td><%= link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New User', new_user_path %>

variant(resize: 50x50)で画像サイズを変換しています。
processedはすでにそのサイズで保存されている画像があれば変換処理は行われず、すぐにURLが返されます。
最初の呼び出しだけ多少時間がかかりますが、それ以降の呼び出しでは時間がかかることはありません。

ではユーザー一覧ページを見てみましょう。

Image from Gyazo

画像サイズが変換されて表示されていますね。

しかしもしアプリの機能が増えて色んなページで画像を表示するとなったときに書き換えるのが大変です。
Decoratorを使って表示に関する処理を切り出してみます。

gem ActiveDecoratorを導入

Gemfile

gem 'active_decorator'

としてbundle installします。
次にでUserモデルに対するdecoratorを作成します。

rails g decorator user

このコマンドでapp/decorators/user_decorator.rbというファイルが生成されました。
このファイルにviewで呼び出すメソッドを書いていきます。

module UserDecorator
  def avator_url(version = :origin)

    command = case version
              when :small
                { resize: '50x50' }
              when :midium
                { resize: '150x150' }
              when :large
                { resize: '300x300' }
              else
                false
              end

    command ? avator.variant(command).processed : avator
  end
end

viewで呼び出す際は

<%= image_tag user.avator_url(:midium) if user.avator.attached? %>

このようにすることで引数によってサイズを変えることができます。

Image from Gyazo

<%= image_tag user.avator_url(:large) if user.avator.attached? %>

Image from Gyazo

これなら引数を変えてあげるだけですね。

最後に

実際にrails newから作ってみると理解しやすかったです。
小さなアプリだとあまり恩恵がないですが規模が大きくなるほどメンテナンス性などを考える必要がありそうです。
最後まで読んでいただきありがとうございました。

参考サイト

【Rails】 Active | Pikawaka - ピカ1わかりやすいプログラミング用語サイト

Active Storage の概要 - Railsガイド

Active StorageのVariantの指定方法いろいろ - Qiita

【Ruby on Rails】Gem「ActiveDecorator」の紹介 View向けのメソッドを定義する - ざきの学習帳(旧 zackey推し )

GitHub - amatsuda/active_decorator: ORM agnostic truly Object-Oriented view helper for Rails 4, 5, and 6

rails/activestorage at main · rails/rails · GitHub

Punditで権限管理

ユーザーによってページの表示を許可、拒否したり、アクションを制限したりしたいことがあると思います。
今回は認可の仕組みを提供してくれるgem Punditを使って実装してみたいと思います。

単純なuserのCRUD機能とログイン機能を持ったアプリを作りました。
userはadmin(管理者)とgeneral(一般ユーザー)の2種類います。
adminがログインした時には制限はありませんが、generalがログインした時はユーザーの編集、削除ができないようにしてみます。
adminであるtanakaでログインすると

Image from Gyazo

こんな感じです。
generalのyamadaでログインしても今は同じ画面になります。
Punditを導入してgeneralができることを制限してみましょう。

Punditの導入

Gemfileに

gem 'pundit'

としてbundle installして、コントローラでincludeします。

class ApplicationController < ActionController::Base
  include Pundit
end

下記のコマンドで

rails g pundit:install

app/policies/配下にapplication_policy.rbが作成されます。

class ApplicationPolicy
  attr_reader :user, :record

  def initialize(user, record)
    @user = user
    @record = record
  end

  def index?
    false
  end

  def show?
    false
  end

  def create?
    false
  end

  def new?
    create?
  end

  def update?
    false
  end

  def edit?
    update?
  end

  def destroy?
    false
  end

  class Scope
    attr_reader :user, :scope

    def initialize(user, scope)
      @user = user
      @scope = scope
    end

    def resolve
      scope.all
    end
  end
end

Policyの作成

userに関するpolicy(方針)を記述するファイルuser_policy.rbを作成し、

class UserPolicy < ApplicationPolicy
  def index?
    true
  end

  def edit?
    user.admin?
  end
end

ApplicationPolicyを継承させ、アクションを定義します。
admin?enum

class User < ApplicationRecord
  enum role: { general: 0, admin: 1 }
end

このように定義して使えるようになったメソッドです。
def アクション名? endで認可ルールを記述します。

def index?
  true
end

def edit?
  user.admin?
end

index?はtrueが返るので制限はかかりません。
edit?にはuserがadminの場合に許可しtrueが返ります。
userにはデフォルトでcurrent_userが入るようになっているみたいです。
adminでなければPundit::NotAuthorizedErrorが発生します。

コントローラでPunditを呼び出し

コントローラ側で

class UsersController < ApplicationController
  before_action :set_user, only: %i[ show edit update destroy ]

  def index
    authorize(User)
    @users = User.all
  end

  def edit
    authorize(@user) # 編集は特定のユーザー情報が必要なので「@user」で特定のユーザーを引数に指定
  end

  private

  def set_user
    @user = User.find(params[:id])
  end
end

authorizeメソッドを記述することで該当するPolicy(この場合UserPolicy)を確認してくれます。
indexアクションからはindex?アクションを確認し、index?アクションはtrueを返すのでadminでもgeneralでもユーザー一覧が表示されます。
editアクションからはedit?アクションを確認し、edit?アクションはuser.admin?となっているのでadminの場合だけtrueが返り、編集ページへアクセスできます。

Viewで表示を制限

View側でもpolicyメソッドを使って表示自体を制限できます。

<% @users.each do |user| %>
  <tr>
    <td><%= user.name %></td>
    <td><%= user.role %></td>
    <td><%= link_to 'Show', user %></td>
    <% if policy(user).edit? %>
      <td><%= link_to 'Edit', edit_user_path(user) %></td>
    <% end %>
    <% if policy(user).destroy? %>
      <td><%= link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } %></td>
    <% end %>
  </tr>
<% end %>

このようにすると「Edit」「Destroy」のリンク自体、adminでログインしている場合でないと表示されなくなります。

Image from Gyazo

もしusers/1/editのように直接URLからアクセスしようとしても

def edit?
  user.admin?
end

これにより弾くことができます。

Pundit::NotAuthorizedError

権限がないgeneralで編集ページにアクセスしようとするとPundit::NotAuthorizedErrorが発生します。

Image from Gyazo

エラーページを用意してそちらを表示させることができます。
public配下に403.htmlファイルを作成します。

<!DOCTYPE html>
<html>
<head>
  <title>権限がありません</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
</head>

<body>
  <p>権限がありません。</p>
</body>
</html>

そしてapplication_controller.rb

class ApplicationController < ActionController::Base
  include Pundit

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized # これと

  private

  def user_not_authorized #これを追加
    render file: 'public/403.html', status: :forbidden, layout: false
  end
end

このようにします。
わざとエラーを発生させると

Image from Gyazo

エラーページに遷移しました。

また、config/application.rbにconfig.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :forbiddenの記述をし、
config/environments/development.rbのconfig.consider_all_requests_localをtrueからfalseに変更してサーバーを再起動することでもエラーページに遷移できました。

最後に

動作確認しながら書きましたが、最低限だけ書こうとして端折ったところもあるのでもしかしたら矛盾があるかもしれません。
あったらごめんなさい。
今回は以上です。ありがとうございました。

参考サイト

Punditを使って権限を管理する - Qiita

Punditをなるべくやさしく解説する - Qiita

Punditの使い方 - Wataruの技術備忘録

GitHub - varvet/pundit: Minimal authorization through OO design and pure Ruby classes

FormObject

FormObjectについて学んだのでまとめてみます。

FormObjectとは

form_withのmodelにActiveRecord以外のオブジェクトを渡すデザインパターン(設計手法)です。
メリットとしては
・DBを使わないフォームでも、ActiveRecordを利用した場合と同じお作法を利用できるので可読性が増す
・他の箇所に分散されがちなロジックをFormObject内に集めることができる
などがあるようです。

RailsでDBのレコードにアクセスする際、生のSQLではなくActiveRecordが提供するクエリメソッドを使います。
User.allとかUser.find(1)とかですね。
これはモデルクラスがApplicationRecordを、ApplicationRecordがActiveRecord::Baseを継承しているから使えるものです。
ではモデルに紐づかないクラス、つまりActiveRecordを継承しないクラスを作りたい時はどうするか。
DBに保存する必要はないがActiveRecordの機能は使いたい、という場合にどうするか、記事の検索フォームを例に見てみます。

検索フォーム

# view

= form_with model: @search_articles_form, scope: :q, url: admin_articles_path, method: :get, html: { class: 'form-inline' } do |f|
  => f.select :author_id, Author.pluck(:name, :id) , { include_blank: '著者' }, class: 'form-control'
  .input-group
    = f.search_field :title, class: 'form-control', placeholder: 'タイトル'
# controller

def index
  authorize(Article)

  @search_articles_form = SearchArticlesForm.new(search_params)
  @articles = @search_articles_form.search.order(id: :desc).page(params[:page]).per(25)
end

private

def search_params
  params[:q]&.permit(:title, :author_id)
end

検索のロジックを書くapp/forms/search_article_form.rbをいうファイルを作成し、その中でActiveModel::Modelをincludeします。
コントローラーから切り出すことでFatControllerを防ぎます。

class SearchArticlesForm
  include ActiveModel::Model # これ
  include ActiveModel::Attributes

  attribute :author_id, :integer
  attribute :title, :string

  def search
    relation = Article.distinct

    relation = relation.by_author(author_id) if author_id.present?
    title_words.each do |word|
      relation = relation.title_contain(word)
    end
    relation
  end

  private

  def title_words
    title.present? ? title.split(nil) : []
  end
end
# model

class Article < ApplicationRecord
# 略
  scope :by_author, ->(author_id) { where(author_id: author_id) }
# 略

ActiveModelActiveRecordの中でデータベースに関連する部分以外の機能を切り出したモジュールです。
これをincludeすることでActiveRecordを継承していないクラスでもActiveRecordの便利な機能が使えるようになります。
また、include ActiveModel::AttributesとありますがこれはActiveRecordにあるAttribute APIという機能を使うためのものです。
Attribute APIActiveRecordを操作する際にクラス属性の型を意識しなくても、指定の型へ変換してくれています。

Image from Gyazo

このようにid、created_atを文字列で渡しましたが型が変換されています。
このActiveModel::Attributesをincludeしてattribute :属性, 型とすることで属性、型を定義できます。

最後に

FormObjectをうまく使うとコントローラの肥大化を防げそうです。
何でもかんでもコントローラに詰め込まず、上手に切り出して可読性の高いコードを書けるようになりたいです。

参考サイト

form objectを使ってみよう - メドピア開発者ブログ

【Rails】FormObject で Controller を綺麗に - stmn tech blog

ActiveModel とActiveModel::Attributes について - WeBlog

Active Model の基礎 - Railsガイド

【Rails】「ActiveModel::Attributes」が便利という話 - 日々の学びのアウトプットするブログ

ActiveModel::Attributes が最高すぎるんだよな。 - Qiita

バッチ処理の実装

今回は記事投稿アプリにバッチ処理の実装をしてみます。
最終的にはこんなことをやりたいです。

やりたいこと

記事の状態が「下書き」「公開」「公開待ち」と3種類あります。
例えば記事の公開日を1日後にすると記事の状態は「公開待ち」になります。
時間が経って公開日を過ぎたら自動で記事が公開されるようにしたいです。

必要なもの

・Rakeタスク
・cron
・whenever
これらを使って自動で記事が公開される仕組みを作っていきます。
具体的に言うと1時間毎に記事の公開日時が現在時刻を過ぎているか判定し、過ぎていれば記事の状態を「公開」に変更します。
このように一定の間隔で処理を行うことをバッチ処理と言います。

Rakeとは

Rakeとは、rubyで処理内容を定義できるビルドツールです。
このRakeが実行する処理内容をRakeタスクと呼び、Rakefileに定義します。

cronとは

cronとは、Unix系のOSに標準で備わっている仕組みです。
「この時間にこのプログラムを実行」「毎日のこの時間になったらこのプログラムの実行」という感じで定期的にコマンドを実行するためにメモリ場で常に命令を待機しているプロセス(=デーモンプロセス)です。

wheneverとは

cronの設定を、rubyの簡単な文法で扱えるようにしたライブラリです。

Rakeタスクファイルを作成

では進めていきましょう。
まずはRakeファイルを作成し、そこに実現したい処理を書いていきます。
記事の状態に関する処理なので名前はarticle_stateとしておきます。

rails g task article_state

こちらのコマンドを実行するとlib/tasks以下にarticle_state.rakeが作成されます。

namespace :article_state do

end

この中に処理を書いていきます。

namespace :article_state do
  desc '公開日時が過去の日付の「公開待ち」記事があれば、ステータスを「公開」に変更する' # desc = description(説明)
  task update_article_state: :environment do # environmentはDBとのやりとりが必要な際に記述します
     # ここに処理を書きます
  end
end

こんな感じです。
で、処理なんですが、

Article.where(state: :publish_wait).each do |article|
  if article.publishd_at <= Time.current
    article.state = 'published'
  end
end

のようにしてもいいのですが、モデルのscopeを使うとスッキリ書けるようなので使ってみます。

models/article.rb

enum state: { draft: 0, published: 1, publish_wait: 2 }

scope :past_published, -> { where('published_at <= ?', Time.current) }

現在時刻と比べて公開日が過去になっている記事を取得して、past_publishedという名前を付けています。
これを使って

namespace :article_state do
  desc '公開待ちの中で、公開日時が過去になっているものがあれば、ステータスを「公開」に変更されるようにする'
  task update_article_state: :environment do
    Article.publish_wait.past_published.find_each(&:published!)
  end
end

このようにしました。
publish_waitenumの定義により使えるようになったものでstateがpublish_waitの記事を取得します。
past_publishedは先ほど定義したscopeです。
これを連結して公開日が過去かつ公開待ちの記事を取得しています。

データの取得には、eachだとデータが大量にあった場合にメモリを圧迫するのでfind_eachを使っています。
こちらはデフォルトでは最大1000件のデータを取得して、処理が終わると次の1000件、という感じでメモリへの負荷が少ないです。

(&:published!)の部分はまだ理解できていないのですが、{ |article| article.published! }を省略した形のようです。
article.published!が記事のstateを公開に変更しているのは分かるのですがあとがちょっと分かりません。
学習用に、あるアプリの改修という形で実装しているため分からないところが出てきますがご容赦ください
参考になりそうなサイトだけ貼っておきます。
Ruby: アンパサンドとコロン`&:`記法について調べてみた|TechRacho(テックラッチョ)〜エンジニアの「?」を「!」に〜|BPS株式会社

gem wheneverの導入

Rakeタスクが出来たのでcronの設定をするのですが、まずはgemwheneverをインストールします。
Gemfileに

gem 'whenever', require: false

としてbundle installします。
require: falseとするのはこのGem自体がRailsアプリケーションに反映するものではなく、ターミナル(言わばOS)に反映させるものだから、だそうです。

bundle exec wheneverize .

このコマンドでconfig/schedule.rbというファイルが作成されるのでここにcronの設定を記述します。

# Rails.rootを使用するために必要
require File.expand_path(File.dirname(__FILE__) + '/environment')
# cronを実行する環境変数
rails_env = ENV['RAILS_ENV'] || :development
# cronを実行する環境変数をセット
set :environment, rails_env
# cronのログの吐き出し場所
set :output, "#{Rails.root}/log/cron.log"
# 1時間毎にrakeタスクを実行
every 1.hour do
  rake 'article_state:update_article_state'
end

cronに設定を反映させる

schedule.rbに設定の記述はしましたがまだ反映がされていません。

bundle exec whenever 

で設定にエラーがないかを確認し、

bundle exec whenever --update-crontab 

でcronに設定を反映します。

clone -l

で設定されているcronを確認できます。

最後に

難しかったですが使いこなせればできることの幅が広がりそうです。
間違いなどあればご指摘いただければと思います。
今回は以上です。ありがとうございました。

参考サイト

Railsでwheneverを使ってバッチを回す!(rakeタスク、cron) - Qiita

RakeタスクとCronを使って1時間ごとにタスクを実行する - WeBlog

Rails のコマンドラインツール - Railsガイド

library rake (Ruby 3.0.0 リファレンスマニュアル)

Railsのモデルのscopeを理解しよう - Qiita

【Rails】 | Pikawaka - ピカ1わかりやすいプログラミング用語サイト

GitHub - javan/whenever: Cron jobs in Ruby

assign_attributesメソッド

記事投稿アプリで公開日が過去の日付なら「公開」、未来の日付なら「公開待ち」と状態を変えるのにつまづいたので忘備録として残しておきます。
前提として記事の状態stateenumで定義されています。

# draft: 下書き, published: 公開, publish_wait: 公開待ち
enum state: { draft: 0, published: 1, publish_wait: 2 }

article_controller.rbのupdateアクションがこのようになっています。

before_action :set_article, only: %i[edit update destroy]

def update
  authorize(@article)

  if @article.update(article_params)
    flash[:notice] = '更新しました'
    redirect_to edit_article_path(@article.uuid)
  else
    render :edit
  end
end

def article_params
  params.require(:article).permit(
    :title, :description, :state, :published_at, :author_id
  )
end

def set_article
  @article = Article.find_by!(uuid: params[:uuid])
end

@article.update(article_params)で情報を受け取り更新処理しています。
しかし下記のように、状態を公開日付を未来の日付にして更新した場合は、状態は公開ではなく公開待ちになるようにしたいのですが、現状のupdateアクションでは公開日がまだ来ていないにも関わらず、公開されてしまいます。

Image from Gyazo Image from Gyazo

これは@article.update(article_params)updateメソッドでarticle_paramsを受け取ったときにDBにstateが公開という情報が保存されるからです。
ユーザーが、公開日を未来の日付、状態を公開で更新してしまうことは考えられるので対策が必要です。
ここでassign_attributesメソッドを使います。

assign_attributesメソッドとは

引数で指定したattributes(属性)を変更するメソッドです。
ただしDBへの保存はされないので別途saveメソッドで保存します。

これを踏まえ、このような流れにしたいです。
① assign_attributesメソッドで変更を受け取る
現在時刻が公開日を過ぎているか、いないかでstateを公開、公開待ち、と振り分ける
③ saveメソッドで保存

ではまずupdateアクションに①、③を書いていきます。

def update
  authorize(@article)

  @article.assign_attributes(article_params) # ①
  # ②
  if @article.save # ③
    flash[:notice] = '更新しました'
    redirect_to edit_article_path(@article.uuid)
  else
    render :edit
  end
end

こうなりました。
あとは②の処理をどのようにするかですが、FatControllerにならないようmodelにメソッドとして切り出しました。

models/article.rb

def publishable? # 現在時刻が公開日を過ぎていればtrueを返す
  Time.current >= published_at
end

def change_state
  return if draft? # draft?はstateがdraftかどうか判定する trueならreturnによりここで処理が終わる(nilが返る)
  self.state = if publishable? # stateがpublishedなら:published(公開)を返す
                 :published
               else # それ以外(publish_waitしか残ってないのでpublish_wait)なら:publish_wait(公開待ち)を返す
                 :publish_wait
               end
end

enumで定義しているのでdraft?のように直感的にstateの状態を確認できます。(draftならtrue、違えばfalseを返す)
あとはこれを②の位置に入れます。

def update
  authorize(@article)

  @article.assign_attributes(article_params) # ①
  @article.change_state # ②
  if @article.save # ③
    flash[:notice] = '更新しました'
    redirect_to edit_article_path(@article.uuid)
  else
    render :edit
  end
end

ではこれで再度確認してみます。
未来の日付で状態を公開にし

Image from Gyazo

更新すると

Image from Gyazo Image from Gyazo

無事、状態が公開待ちに変わりました。

最後に

updateメソッドとはassign_attributes + saveだったんですね。
間に何か処理を挟みたいときにassign_attributesを使うと良さそうです。
updateもupdate_attributesのalias(別名)なので
・update_attributes(DB更新あり)
・assign_attributes(DB更新なし)
のように並べてみると分かりやすいですね。
では今回は以上です。ありがとうございました。

参考サイト

まとめてオブジェクトの属性を変更したい時に便利!assign_attributesメソッド - その辺にいるWebエンジニアの備忘録

ActiveModel::AttributeAssignment

Active Recordのattributesの更新メソッド | 酒と涙とRubyとRailsと

ActiveRecord の attribute 更新方法まとめ - Qiita