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
 
 
 - gem
 
既存のモデル
予約投稿機能の説明に入る前に、まずは既存のモデルを説明させてください。
本ブログシステムでは記事モデル(Article)を持ちます。
Articleテーブルの構造
  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
以上が記事テーブルの構造です。
要点としては、
- is_draftフラグで下書きか否かを表現します
 - is_activeフラグで記事の論理削除フラグを表現します
 - published_atカラムで公開日時を表現します
 
ということになります。表にまとめると以下のような感じです。
| 記事の状態 | is_active | is_draft | published_at | 
|---|---|---|---|
| 公開 | true | false | 過去または現在の日時 | 
| 下書き | true | true | nil | 
| 非表示 | false | * | * | 
Articleモデルのスコープ定義
以上の表をArticleモデルのスコープとして表現すると以下のようになります。
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_at に nil をセットし、published_at に現在日時をセットするような ActiveJob を作成してやればよさそうです。
そして、作ったActiveJob を定期実行させれば予約投稿機能は実現できそうです。
次に、変更の要点を整理してみましょう。
予約投稿機能の要素を挙げてみる
- 記事モデルに予約状態を追加する
 - 予約投稿日時を迎えた記事を公開状態にする ActiveJob
 - ActiveJob を定期実行する機能
 
1. 記事モデルに予約状態を追加する
Articles テーブルにカラム追加
schema.rb には articles テーブルに will_publish_at カラムを追加します。
  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モデルにスコープ追加など
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ファイルを以下のように編集します。
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に以下の記述を追記します。
gem 'whenever'
その後、bundle installを忘れないでください。
以下のコマンドは gem whenever の README.md からの引用となります。
$ cd /apps/my-great-project $ bundle exec wheneverize .
このコマンドを実行することで、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エディタを置き換えたいですね。記事執筆時の快適性を求めていきたいです。
株式会社ラグザイア(luxiar.com)の技術広報ブログです。 ラグザイアはRuby on RailsとC#に特化した町田の受託開発企業です。フルリモートでの開発を積極的に推進しており、全国からの参加を可能にしています。柔軟な働き方で最新のソフトウェアソリューションを提供します。
Discussion