If you want to execute a Clojure app based on schedule

プログラムを定期的に実行したいという要求は実際に何かしら作っていると、まあそれなりの頻度であると思う。 Clojure において定期的な処理の実行を実現させたいなら、 Quartzite というライブラリを使うと幸せになれそうです。

Quartzite を使うメリット

最初に簡単にメリットを説明しておきます。

  • Clojure で書ける
    • Clojure の表現力を手に入れることが出来る
    • スケジュール設定などの表現に幾つかパターンがあり使いやすい
      • cron like な表現は勿論、カレンダーベースでの指定も可能
    • Java が動けば動く
    • cron に頼らなくて良い
      • 簡単に理解出来ない cron の設定ファイルを読まなくて良い
      • cron を使えなくても良い
      • 毎回 JVM を起動しなくて良くなるので心が穏やかになる

元々のライブラリは Quartz という Java のライブラリなのも安心度が高いですね。

シンプルな定期実行アプリを作ってみる

まず簡単な 10 秒に 1 回 Hello, world というだけのアプリを作ってみましょう。

$ lein new quartzite-example
$ cd quartzite-example
$ rm -rf src/quartzite_example
$ mkdir src/clj/quartzite_example

下の 2 行は本当は簡単なアプリを作るだけなら必要無いですが後のほうで使いたいのでこうしています。とりあえず、触ってみたい人はその 2 行を飛ばしてしまってください。

次に project.clj を編集します。

(defproject quartzite-example "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}

  :dependencies [[org.clojure/clojure "1.7.0"]
                 [clojurewerkz/quartzite "2.0.0"]]

  :uberjar-name "quartzite-example.jar"
  :source-paths ["src" "src/clj"]
  :aot :all
  :main quartzite-example.core)

src/clj/quartzite_example/core.clj (最初の方で 2 行飛ばした人は src/quartzite_example/core.clj ) を開き実際にコードを以下のように書いてみます。

(ns quartzite-example.core
  (:require [clojurewerkz.quartzite.jobs :refer [defjob] :as j]
            [clojurewerkz.quartzite.scheduler :as qs]
            [clojurewerkz.quartzite.schedule.simple :refer [schedule with-interval-in-seconds repeat-forever]]
            [clojurewerkz.quartzite.triggers :as t])
  (:gen-class))

(defjob ExampleJob [ctx]
  (println "Hello, world"))

(defn -main [& args]
  (let [s       (-> (qs/initialize) qs/start)
        job     (j/build
                 (j/of-type ExampleJob)
                 (j/with-identity (j/key "job.qe.1")))
        trigger (t/build
                 (t/with-identity (t/key "trigger.qe.1"))
                 (t/start-now)
                 (t/with-schedule
                   (schedule
                    (repeat-forever)
                    (with-interval-in-seconds 1))))]
    (qs/schedule s job trigger)))

書いたら uberjar して実行してみましょう。

$ lein uberjar && java -jar target/quartzite-example.jar

色々とログが出てその後延々と Hello, world が出力され続けていますよね。適当に Ctrl + C などで止めてしまって問題ありません。

Quartzite について簡単に説明しておくと Quartzite は大きく分けてジョブ、トリガー、スケジューラーという 3 つの要素から成り立っています。ジョブが実処理の部分にあたり、トリガーは文字通りトリガーでジョブの起動条件を定義するもので、最後にスケジューラーはそれらを登録する先です。またこの例では出ていませんが、ジョブを永続化させたり実行中のスケジューラーが落ちたりしたらリカバリ(再実行)させることが出来たりしますし、スケジュールの指定方法として cron like な指定も出来たりカレンダーベースの間隔指定もできます。

ちなみに、もし毎週金曜日に実行したいのであれば次のようなトリガーを定義できます。

;; [clojurewerkz.quartzite.schedule.calendar-interval :refer [schedule with-interval-in-weeks]]
;; これらを require しておいて…
(t/build
 (t/with-identity (t/key "for-every-friday"))
 (t/start-at (next-friday))
 (t/with-schedule
   (schedule
    (with-interval-in-weeks 1))))

next-friday などという関数は定義されていないので自分で定義する必要がありますが、次の金曜日を求めてその Date 型のオブジェクトを返すようにすれば問題なさそうです。

さて、ここまででとりあえず趣味で使う程度なら便利そうなことが分かりました。ただ、ここまでだと一度アプリが停止するたびにジョブやトリガーの情報が全て失われてしまって少々不便そうです。ということで永続化をしてみましょう。

定期実行アプリを永続化する

永続化というのはとどのつまり、適当なデータベースにジョブやトリガーの稼働状況を残しておいて、アプリ自体が何かしらの影響で止まったりしても問題ないようにしましょうということですね。簡単なのでさくさくっとやってしまいます。

さて、最初の方で src/clj を作りましたが、 src/java も作りましょう。

$ cd quartzite-example
$ mkdir -p src/java/quartzite_example/quartz

プロジェクトの定義も更新する必要があるので project.clj を修正しましょう。

(defproject quartzite-example "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.7.0"]
                 [clojurewerkz/quartzite "2.0.0"]
                 [org.postgresql/postgresql "9.4-1201-jdbc41"]]

  :uberjar-name "quartzite-example.jar"
  :resource-paths ["resources"]
  :java-source-paths ["src/java"]
  :source-paths ["src" "src/clj"]
  :aot :all
  :main quartzite-example.core)

足したのは JDBC のドライバーと Java のソースディレクトリ、リソースディレクトリですね。今回は PostgreSQL を DB に採用します。

できたら次に src/java/quartzite_example/quartz/DynamicClassLoadHelper.java ファイルを作り以下のような記述をします。

package quartzite_example.quartz;

import clojure.lang.DynamicClassLoader;
import org.quartz.spi.ClassLoadHelper;

public class DynamicClassLoadHelper extends DynamicClassLoader implements ClassLoadHelper {
    public void initialize() {}

    @SuppressWarnings("unchecked")
    public <T> Class<? extends T> loadClass(String name, Class<T> clazz)
        throws ClassNotFoundException {
        return (Class<? extends T>) loadClass(name);
    }

    public ClassLoader getClassLoader() {
        return this;
    }
}

何故、こういうクラスローダーヘルパーが必要なのかというと Clojure と Quartzite の元である Quartz がそれぞれ独自のクラスローダーを持っているためであり、 Quartz が Clojure の作ったクラスを読み込みたいけど JVM のセキュリティモデルのせいでそれができないからみたいです。

See also: Quartz and Clojure Class Loaders

Note

今回は PostgreSQL を使っていますが、もし MongoDB を使いたい場合は上のリンクを参照してください。 MongoDB 以外であれば今回の例をそのまま使うことができます。

次に resources/quartz.properties を以下のように記述します。

#============================================================================
# Configure Main Scheduler Properties
#============================================================================

org.quartz.scheduler.instanceName: QuartziteExample
org.quartz.scheduler.instanceId: qe1

org.quartz.scheduler.skipUpdateCheck: true

# 先ほど作ったクラスローダーヘルパーを指定します
org.quartz.scheduler.classLoadHelper.class=quartzite_example.quartz.DynamicClassLoadHelper

#============================================================================
# Configure ThreadPool
#============================================================================

org.quartz.threadPool.class: org.quartz.simpl.SimpleThreadPool
org.quartz.threadPool.threadCount: 5
org.quartz.threadPool.threadPriority: 5

#============================================================================
# Configure JobStore
#============================================================================

org.quartz.jobStore.misfireThreshold: 3600000

# 今回は Immutant などと一緒に使うわけではないのので JobStoreTX を指定
org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX
# PostgreSQL
org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
org.quartz.jobStore.useProperties=false
org.quartz.jobStore.dataSource=db
org.quartz.jobStore.isClustered=true

#============================================================================
# Configure Datasources
#============================================================================
org.quartz.dataSource.db.driver=org.postgresql.Driver
org.quartz.dataSource.db.URL=<database url here>
org.quartz.dataSource.db.user=user_name
org.quartz.dataSource.db.password=password
org.quartz.dataSource.db.maxConnections: 5
org.quartz.dataSource.db.idleConnectionValidationSeconds
org.quartz.dataSource.db.validationQuery: select 0

このプロパティファイルについて深く知りたい方は こちら を参照してください。

次に実データベースを用意しましょう。 PostgreSQL を適当にローカルにインストールしてください。次に Quartz の Download ページからバージョン 2.1.7 をダウンロードします。

Quartz Download page

Note

Quartzite が Quartz をラップしているので、ラップしてあるバージョンをダウンロードしてください。今回使うバージョンで依存性となっているのが Quartz 2.1.7 だったのでこのバージョンを指定しています。 またバージョンが違うと DDL が微妙に違ったりしてハマる原因になります(実際ハマった)。

Quartz をダウンロードしたら解凍して docs/dbTables の中にあるクエリを対象の DB に流しておきます。 例えば PostgreSQL だとこんな感じ

\i tables_postgres.sql

さてジョブの永続化をするのにするとテーブルが綺麗に作成されるので \dt (PostgreSQL) とでも打ち込んで確認しておきましょう。

このまま先ほどと同じプログラムを実行してみてもいいのですが、折角なので少し修正します。

(ns quartzite-example.core
  (:require [clojure.string :as str]
            [clojurewerkz.quartzite.conversion :as qc]
            [clojurewerkz.quartzite.jobs :refer [defjob] :as j]
            [clojurewerkz.quartzite.scheduler :as qs]
            [clojurewerkz.quartzite.schedule.simple :refer [schedule with-interval-in-seconds with-repeat-count]]
            [clojurewerkz.quartzite.triggers :as t])
  (:gen-class))

(defn now []
  (java.util.Date.))

(defjob ExampleJob [ctx]
  (let [detail (qc/from-job-detail (.getJobDetail ctx))]
    (println (str (get-in detail [:key :name]) ": " (now)))))

(defn new-schedule [id]
  {:job
   (j/build
    (j/of-type ExampleJob)
    (j/with-identity (j/key (str "job.qe." id))))
   :trigger
   (t/build
    (t/with-identity (t/key (str "trigger.qe." id)))
    (t/start-now)
    (t/with-schedule
      (schedule
       (with-repeat-count 10)
       (with-interval-in-seconds 3))))})

(defn -main [& args]
  (let [s (-> (qs/initialize) qs/start)
        {:keys [job trigger]} (new-schedule 1)]
    (try
      (qs/schedule s job trigger)
      (catch Exception e#))))

ただの Hello, world から現在時刻を出力するように変更しました。またスケジューラの作成方法も少し修正していますし、今回のトリガーは 3 秒に 1 回実行され 10 回実行したらトリガーが破棄されるようになっています。つまりこのジョブが全て実行されるまでに 30 秒程かかるわけですね。

先ほどと同様に uberjar して実行します。

$ lein uberjar
$ java -jar target/quartzite-example.jar
job.qe.1: Fri Aug 21 18:30:49 JST 2015
^C%

実行結果が 1 つ確認できたら一度上のように止めてしまいます。そのまま 1 分以上待って再実行してみましょう。

$ date
Fri Aug 21 18:32:13 JST 2015

$ java -jar target/quartzite-example.jar
job.qe.1: Fri Aug 21 18:32:18 JST 2015
job.qe.1: Fri Aug 21 18:32:18 JST 2015
job.qe.1: Fri Aug 21 18:32:18 JST 2015
job.qe.1: Fri Aug 21 18:32:18 JST 2015
job.qe.1: Fri Aug 21 18:32:18 JST 2015
job.qe.1: Fri Aug 21 18:32:18 JST 2015
job.qe.1: Fri Aug 21 18:32:18 JST 2015
job.qe.1: Fri Aug 21 18:32:18 JST 2015
job.qe.1: Fri Aug 21 18:32:18 JST 2015
job.qe.1: Fri Aug 21 18:32:18 JST 2015
^C%

このように一瞬で全ての待機ジョブ(正確には不発ジョブ)が実行できたのが確認できたと思います。これは org.quartz.jobStore.misfireThreshold の設定値によって復帰したときのジョブの実行がされるかが決定されます(今回は 1 時間以内に復帰出来てれば拾うようになっています)。 またトリガーの書き方で実行できなかったジョブの取り扱いを変更することもできます。なので、先ほどの設定値が大きくても復帰時に何も実行しないということも可能です。

まとめ

Quartzite を使うことで簡単に定期実行するアプリを書くことが出来ました。もう少し丁寧に紹介したいところですが、 Quartz 自体のドキュメントがそこそこ膨大なのもあって僕自身全てをまだ理解できていません。

それから今回は Web アプリではなかったので Quartzite を使いましたが Web アプリ中に定期実行するような処理を埋め込んでしまいたい場合は Immutant を使ってもいいと思います。 Immutant が Quartz をラップしているので Quartzite とは使い方が多少違うのですが、おおよそ同じように使えるのでいいと思います。

Note

サイボウズスタートアップスでは Clojure で Web アプリケーション開発したいエンジニアを募集しています。