45
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

そうだ、Batch処理を使おう!【Rails】【タグ機能】【Whenever】【cron】

Last updated at Posted at 2021-12-18

はじめに

こんにちは!DMM WEBCAMP Advent Calendar 2021 :christmas_tree: 19日目を担当させていただく@smasa1112です!

今回で初のqiita投稿ですのでお見苦しいところ多々あるかと思います
どうか温かい目で見守っていただけますと幸いです…

本日は初学者の方が中級者になるときに使うであろうBatch処理とそれを有効に使えるであろうタグ機能に関連することを書いていきます!

目次

  1. Batch処理とは
  2. 目標
    1. 完成形
    2. ER図
  3. 実装
    1. 使用環境
    2. タグ機能の処理
    3. Batch処理
  4. 最後に
  5. 参考記事

1. Batch処理とは

調べてみると…

バッチ(Batch)は「ひと束」「一群」「1回分にまとめる」という意味で、バッチ処理はあらかじめ登録した一連の処理を自動的に実行する処理方式を指す。(大塚商会 IT用語辞典)
https://www.otsuka-shokai.co.jp/words/batch-processing.html

つまり
単純な作業をまとめてする
処理のことです!

今回バッチ処理する部分はタグ機能で投稿に結びついていない不要なタグを削除する部分です!

2. 目標

今回はこの機能を活かして以下の目的を達成します!
  • 投稿にタグ付けできる
  • 使わないタグを自動で削除できる

それではやっていきましょう!

2.1 完成形

まず完成形を示します!
こちらのようにタグ付け投稿を行いまた一定時間で不要なタグを削除するようにします

投稿部分

投稿.gif

削除部分

cron.gif

2.2 ER図

この機能を実現するためのER図を示します!
今回は機能実現だけが目標なので最低限です!
ER図.drawio.png

3. 実装

実装は参考記事にも記載しますが以下の記事を多いに参考にしました。こちらの記事をベースに書いていきます。
https://qiita.com/you8/items/b2394104c6f9865f5d46

また作成したコードは私のgithubレポジトリに挙げてあるので参考にしたい方は見てみてください!

タグを簡単に作成できるGem(参考記事)もあるかと思いますが今回は勉強のため、そちらを使用せずに作成します!

3.1 使用環境

主に以下の通りです。Batch処理にはUNIX系OSのcronを使用します
特になんの変哲もありません

  • Ruby 2.6.3
  • Rails 6.1.4.4
  • whenever 1.0

3.2 タグ機能の処理

まずBatch処理をする土台のプログラムを作っていきます!
コードは長くなるため重要な部分だけ示します!他の部分どうなってるか気になる方は是非Githubを見るかTwitterに直接ご連絡ください!

モデルの作成

大事な部分はpost.rb内の以下の部分です。中間テーブルであるtag_mapテーブルを介してPostレコードから直接Tagレコードが取得できるようにします。

post.rb
has_many :tags, through: :tag_maps
Modelソースコード
post.rb
class Post < ApplicationRecord
  has_many :tag_maps, dependent: :destroy
  has_many :tags, through: :tag_maps
  validates :context, presence: true

  def update_and_create_tags(post_tags)
    # その投稿が持っているタグを列挙
    current_tags = self.tags.pluck(:name) unless self.tags.nil?
    # 変更があった場合に削除されるアソシエーションを列挙
    # 配列同士で減算できる!すごい!!!
    old_tags = current_tags - post_tags
    # 今回保存されたものと現在の差を新しいタグとする。新しいタグは保存
    new_tags = post_tags - current_tags

    # Destroy old taggings
    old_tags.each do |old_name|
      #self.tags.delete Tag.find_by(name:old_name)
      #railsっぽい書き方に直すなら…
      tag=self.tags.find_by(name:old_name)
      self.tag_maps.find_by(tag_id: tag.id).destroy
    end

    # Create new taggings
    new_tags.each do |new_name|
      post_tag = Tag.find_or_create_by(name:new_name)
      # 配列に保存
      # self.tags << post_tag
      # railsっぽい書き方に直すなら…
      TagMap.create(tag_id: post_tag.id, post_id: self.id)
    end
  end
end

tag.rb
class Post < ApplicationRecord
  has_many :tag_maps, dependent: :destroy
  has_many :tags, through: :tag_maps
  validates :context, presence: true

  def update_and_create_tags(post_tags)
    # その投稿が持っているタグを列挙
    current_tags = self.tags.pluck(:name) unless self.tags.nil?
    # 変更があった場合に削除されるアソシエーションを列挙
    # 配列同士で減算できる!すごい!!!
    old_tags = current_tags - post_tags
    # 今回保存されたものと現在の差を新しいタグとする。新しいタグは保存
    new_tags = post_tags - current_tags

    # Destroy old taggings
    old_tags.each do |old_name|
      #self.tags.delete Tag.find_by(name:old_name)
      #railsっぽい書き方に直すなら…
      tag=self.tags.find_by(name:old_name)
      self.tag_maps.find_by(tag_id: tag.id).destroy
    end

    # Create new taggings
    new_tags.each do |new_name|
      post_tag = Tag.find_or_create_by(name:new_name)
      # 配列に保存
      # self.tags << post_tag
      # railsっぽい書き方に直すなら…
      TagMap.create(tag_id: post_tag.id, post_id: self.id)
    end
  end
end

tag_map.rb
class TagMap < ApplicationRecord
  belongs_to :post
  belongs_to :tag
  validates :post_id,presence:true
  validates :tag_id,presence:true
end

コントローラーの作成

重要な点としてはアソシエーションしたレコードに対して、preloadを使用してレコード取得時にSQLクエリが何度も発行されることを防ぎました。N+1問題に関しては参考記事を見てください。

posts_controller.rb
  def index
    #N+1問題解決のためにpreload
    @posts = Post.preload(:tags).all
    @post = Post.new
  end
Controllerソースコード
posts_controller.rb
class PostsController < ApplicationController
  def index
    #N+1問題解決のためにpreload
    @posts = Post.preload(:tags).all
    @post = Post.new
  end

  def create
    @post = Post.new(post_params)
    if @post.save
      #送られてきたParamを,で分割
      tag_list = params[:post][:tag_strs].split(",")
      @post.update_and_create_tags(tag_list)
      redirect_to posts_path
    else
      @posts=Post.all
      render :index
    end
  end


  def update
    @post= Post.preload(:tags).find(params[:id])
    if @post.update(post_params)
      tag_list = params[:post][:tag_strs].split(",")
      @post.update_and_create_tags(tag_list)
      redirect_to posts_path
    else
      @tag_list= @post.tags.pluck(:name).join(",") unless @post.tags.nil?
      render :edit
    end
  end

  def destroy
    post= Post.preload(:tags).find(params[:id])
    post.destroy
    redirect_to posts_path
  end

  def edit
    @post= Post.preload(:tags).find(params[:id])
    @tag_list= @post.tags.pluck(:name).join(",") unless @post.tags.nil?
  end

  private

  def post_params
    params.require(:post).permit(:context)
  end
end

class TagsController < ApplicationController
  def index
    @tags = Tag.preload(:posts).all
  end
end

ビューの作成

工夫した点は特になく雑に作成しただけです。

Viewソースコード
posts/index.html.erb
<h1>投稿一覧</h1>
<table>
  <thead>
    <tr>
      <th style="width:15%;">ID</th>
      <th style="width:25%;">投稿内容</th>
      <th style="width:15%;">付属タグ一覧</th>
      <th style="width:15%;"></th>
      <th style="width:15%;"></th>
    </tr>
  </thead>
  <tbody>
    <% @posts.each do |post| %>
      <tr>
        <td><%= post.id %></td>
        <td><%= post.context %></td>
        <td>
          <% post.tags.each do |tag| %>
            <%= tag.name %><br>
          <% end %>
        </td>
        <td><%= link_to "編集", edit_post_path(post.id) %></td>
        <td><%= link_to "削除", post_path(post.id), method: :delete %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<h1>新規投稿</h1>
<%= form_with model:@post, url: posts_path, local: true do |f| %>
  <%= f.label :context, "投稿内容" %><br>
  <%= f.text_field :context %><br>
  <%= f.label :tag_strs, "付属したいタグ" %><br>
  <%= f.text_field :tag_strs %><br>
  <p><b>タグはカンマ(,)区切りで入力してください!</b></p>
  <%= f.submit "投稿作成" %>
<% end %>
posts/edit.html.erb
<h1>編集</h1>
<%= form_with model:@post, url: post_path(@post), local: true do |f| %>
  <%= f.label :context, "投稿内容" %><br>
  <%= f.text_field :context %><br>

  <%= f.label :tag_strs, "付属したいタグ" %><br>
  <%= f.text_field :tag_strs,value: @tag_list %><br>

  <p><b>タグはカンマ(,)区切りで入力してください!</b></p>
  <%= f.submit "投稿作成" %>
<% end %>
tags/index.html.erb
<h1> タグ一覧 </h1>
<table>
  <thead>
    <tr>
      <th>タグ名</th>
      <th>結びついた投稿数</th>
    </tr>
  </thead>
  <tbody>
    <% @tags.each do |tag| %>
      <tr>
        <td><%= tag.name %></td>
        <td><%= tag.posts.size %></td>
      </tr>
    <% end %>
  </tbody>
</table>

ここまででの完成形

投稿.gif

3.3 Batch処理

最後にBatch処理に移っていきます。
作業としては

  • 結びつくPostがないTagを全抽出
  • それらを一定時間で削除

といったことをやっていきます!Batch処理にはWheneverというgemを使います!

gemの導入・初期化

Gemfile
# cron
gem 'whenever', require: false

CLIでインストールと有効化を行いましょう
そうするとconfig内にschedule.rbが作成されます

CLI
$ bundle install --path vendor/bundle
$ bundle exec wheneverize .

wheneverの設定

まずBatch処理用のファイルを読み込めるようにconfig/application.rbを作成しましょう

config/application.rb
require_relative "boot"

require "rails/all"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module TagBatch
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 6.1

    ### 追記部分 ###
    # lib内のBatchファイルを読めるようにする
    config.paths.add 'lib', eager_load: true
    ###############

    # Configuration for the application, engines, and railties goes here.
    #
    # These settings can be overridden in specific environments using the files
    # in config/environments, which are processed later.
    #
    # config.time_zone = "Central Time (US & Canada)"
    # config.eager_load_paths << Rails.root.join("extras")
  end
end

使用するBatch処理を作成しましょう
抽出の部分が納得いっていないのでSQLの勉強をちゃんとしないといけないと思いました…

lib/batch/remove_unuse_tag.rb
class Batch::RemoveUnuseTag
  def self.remove_unuse_tag
    # 付属するPostがないタグを抽出
    tag_count_hash=Tag.joins(:posts).group(:id).count
    Tag.all.each do |tag|
      unless tag_count_hash[tag.id]
        tag.destroy
      end
    end
    p "結びつきのないタグを全て削除しました"
  end
end

Batchファイルを作成したらwheneverの設定を行いましょう

config/schedule.rb
# root_path認識に必要
require File.expand_path(File.dirname(__FILE__) + "/environment")
# cronを実行する環境変数
rails_env = Rails.env.to_sym
# 環境変数の設定
set :environment, rails_env
# ログの出力先の設定
set :output, 'log/cron.log'

#どれくらいの期間で何をするか決める
#1週間とかにしたい場合には
#every 1.week do
every 3.minute do
  begin
    runner "Batch::RemoveUnuseTag.remove_unuse_tag"
  rescue => e
    Rails.logger.error("aborted rails runner")
    raise e
  end
end

最後にCronを有効化しましょう!!

CLI
$ bundle exec whenever
$ bundle exec whenever --update-crontab
$ sudo systemctl start crond

完成です!!

cronを停止したい場合には以下のコマンドを打ちましょう

CLI
$ sudo systemctl stop crond

4. 終わりに

最後まで見ていただき本当にありがとうございました!
初めての記事で分かりづらい部分もあったかと思いますが、皆様の理解の一助となりましたら幸いです :bow_tone1:

Let's Batch処理!!

明日は@harikaさんの記事となります!お楽しみに!

昨日の記事 執筆者:@hidemitsuaoki

5. 参考記事

45
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?