📝

Ruby on Rails で構築するブログシステムにおける予約投稿の実現方法

に公開

本記事では、ブログシステムにおける予約投稿機能をどうやって実装するかをまとめたいと思います。

背景

私は普段.NET C#メインで過ごしていますが、仕事で転機があり Ruby の世界に入りました。ただ Rubyの実力はまだまだなので、ここは一つ、自分の趣味としてブログシステムを作って勉強しようと思い至り、 Ruby on Rails を触り始めました。ついには個人で某ブログサイトを運営しはじめ、Ruby on Rails 8 のブログシステムを運用しています。

それで、たまに出張があったりしてブログが書けない時に、予め記事を書いて予約投稿できたらいいなぁと思って、今回実装したという形になります。

技術スタック

  • OS
    • Ubuntu 24.04.3 LTS
  • プログラミング言語
    • Ruby 3.4.5
  • フレームワーク
    • Ruby on Rails 8.0.2.1
  • 予約投稿機能で使用
    • gem
      • whenever
    • コマンド
      • cron

既存のモデル

予約投稿機能の説明に入る前に、まずは既存のモデルを説明させてください。
本ブログシステムでは記事モデル(Article)を持ちます。

Articleテーブルの構造

db/schema.rb
  create_table "articles", force: :cascade do |t|
    t.integer "user_id", null: false
    t.string "title"
    t.text "content"
    :
    t.boolean "is_draft", default: true, null: false
    t.boolean "is_active", default: true, null: false
    :
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.datetime "published_at"
    :
  end

以上が記事テーブルの構造です。
要点としては、

  1. is_draftフラグで下書きか否かを表現します
  2. is_activeフラグで記事の論理削除フラグを表現します
  3. published_atカラムで公開日時を表現します

ということになります。表にまとめると以下のような感じです。

記事の状態 is_active is_draft published_at
公開 true false 過去または現在の日時
下書き true true nil
非表示 false * *

Articleモデルのスコープ定義

以上の表をArticleモデルのスコープとして表現すると以下のようになります。

app/models/article.rb
class Article < ApplicationRecord
  :
  # 記事の有効性
  scope :active, -> { where(is_active: true) }

  # 下書き
  scope :drafts, -> { where(is_active: true, is_draft: true) }

  # 現在公開されている記事(公開日時が現在時刻以前)
  scope :published, -> { where(is_active: true, is_draft: false) }
  :
end

予約投稿機能

さて、上記で既存の記事の状態を整理しました。
では予約投稿機能を追加してみるとどうなるでしょうか?

まずは、表で状態を整理してみます。

記事の状態 is_active is_draft will_publish_at published_at
公開 true false 過去または現在の日時† 過去または現在の日時†
公開予約 true false 未来の日時 nil
下書き true true nil nil
非表示 false * * *

†:この2つの「過去または現在の日時」はほぼ一致します。

前回の表と比べて、will_publish_atカラムが追加されています。
記事が公開予約状態の時は、この will_publish_atカラムに未来の日時がセットされています。

この公開予約状態の記事を探して、will_publish_at <= Time.current つまり、現在日時がwill_publish_at の日時を過ぎたら、will_publish_atnil をセットし、published_at に現在日時をセットするような ActiveJob を作成してやればよさそうです。

そして、作ったActiveJob を定期実行させれば予約投稿機能は実現できそうです。

次に、変更の要点を整理してみましょう。

予約投稿機能の要素を挙げてみる

  1. 記事モデルに予約状態を追加する
  2. 予約投稿日時を迎えた記事を公開状態にする ActiveJob
  3. ActiveJob を定期実行する機能

1. 記事モデルに予約状態を追加する

Articles テーブルにカラム追加

schema.rb には articles テーブルに will_publish_at カラムを追加します。

db/schema.rb
  create_table "articles", force: :cascade do |t|
    :
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.datetime "published_at"
+   t.datetime "will_publish_at"
    :
  end

Articleモデルにスコープ追加など

app/models/article.rb
class Article < ApplicationRecord

  # バリデーションの追加
+ validates :will_publish_at, presence: true, unless: :is_draft?
  :
  # 記事の有効性
  scope :active, -> { where(is_active: true) }

  # 下書き
  scope :drafts, -> { where(is_active: true, is_draft: true) }

  # 現在公開されている記事(公開日時が現在時刻以前)
  scope :published, -> {
    where(is_draft: false, is_active: true)
+     .where("published_at <= ?", Time.current)
+     .where("will_publish_at <= ?", Time.current)
  }

+ # 予約投稿記事(公開日時が現在時刻より後)
+ scope :scheduled, -> {
+   where(is_draft: false, is_active: true)
+     .where("will_publish_at > ?", Time.current)
+ }
  :
end

Articleモデルにはまずバリデーションとして、「下書きではない場合はwill_publish_atに値を入れなければならない」という制約をつけます。これにより、公開状態か公開予約状態の時には必ずwill_publish_atに値が入った状態になります。

そして、予約投稿記事の :scheduled スコープを追加します。このスコープは予約投稿記事リストなどで使うことが出来ます。

2. 予約投稿日時を迎えた記事を公開状態にする ActiveJob

予約投稿日時を迎えた記事を公開状態にする ActiveJobファイルを作ります。

rails generate job PublishScheduledArticles

生成されたRubyファイルを以下のように編集します。

app/jobs/publish_scheduled_articles_job.rb
class PublishScheduledArticlesJob < ApplicationJob
  queue_as :default

  def perform
    # (1)公開予定時刻を過ぎた予約投稿記事を取得
    articles_to_publish = Article.where(is_draft: false, is_active: true)
                                 .where.not(will_publish_at: nil)
                                 .where(published_at: nil)
                                 .where("will_publish_at <= ?", Time.current)

    articles_to_publish.find_each do |article|
      begin
        # (2)記事を公開状態に変更
        article.update!(
          published_at: Time.current,
          is_draft: false
        )
      rescue => e
        Rails.logger.error "Failed to publish article #{article.id}: #{e.message}"
        Rails.logger.error e.backtrace.join("\n")
      end
    end
  end
end

以上の編集により、(1)公開予定時刻を過ぎた予約投稿記事を取得し、(2)記事を公開状態に変更するのを実現しています。

3. ActiveJob を定期実行する機能

ActiveJobを定期実行する機能を実装するには、Linux環境における cron を使用することで簡単になります。cronの定義をRubyファイルで書けるようになる gem whenever を使用すると簡単です。

Gemfileに以下の記述を追記します。

Gemfile
gem 'whenever'

その後、bundle installを忘れないでください。

以下のコマンドは gem whenever の README.md からの引用となります。

$ cd /apps/my-great-project
$ bundle exec wheneverize .

このコマンドを実行することで、config/schedule.rb が作成されます。

config/schedule.rb
every 1.minute do
  runner "PublishScheduledArticlesJob.perform_later"
end

以上の定義を書くことで、1分おきに 公開予約記事の公開処理ActiveJobが走ります。
もし、1分おきではなく、もうちょっと頻度を低くしたい場合は、

every 1.minute do

のところを例えば5分おきに伸ばしたいなら、

every 5.minute do

に変えればよいです。

ここまでできれば、予約投稿機能は完成です!
後はお好みで記事執筆画面のビューで公開オプションを作ってね♪

最後に、参考までに私のブログシステムの記事執筆画面の公開オプションの一例を載せておきます。

終わりに

なにかの参考になれば幸いです。

あと、私のブログシステムは今後、Ruby on Rails 8.1 beta 1 を導入してみようかなと思っています。ActionTextが改善されるらしい?ので、オレオレ実装したMarkdownエディタを置き換えたいですね。記事執筆時の快適性を求めていきたいです。

ラグザイア

Discussion