Toyokumo Tech Blog

トヨクモ株式会社の開発本部のブログです。

Clojureで作るAPI ライフサイクルと依存関係を管理できるようにする

[連載]Clojureで作るAPIの4記事目です。

前回の記事はこちらです。

tech.toyokumo.co.jp

この記事ではREPLを再起動することなくWebサーバーを再起動できるようにしていきます。

なぜ再起動できる必要があるのか

そもそもなんでそんなことが必要なのかというと、コードを書き換えた後で、REPLに持たせている状態を今のコードに合わせて即座に置き換えたいからです。

前回の記事(jetty/run-jetty ring-handler {:port 8000}) という式を使ってWebサーバーを起動しました。 引数に与えている関数 ring-handler と マップ {:port 8000} は立ち上がったサーバーが保持していますから、サーバーを再起動しないことには置き換えることはできません。しかしそのためにいちいちREPLを再起動していては時間がかかってしまって生産的ではありません。

また、このような状態を持つものが複数あり、かつその起動が順序通りに行われる必要がある場合に注意しながら手動で起動していくなんてことはやりたくありません。

そこでそういったことを可能にするライブラリを使って宣言的に再起動できるようにしていきます。

使用するライブラリを追加

ライブラリComponentを追加します。

github.com

Componentは、

'Component' is a tiny Clojure framework for managing the lifecycle and dependencies of software components which have runtime state.

とあるようにまさに今回の目的のために作られたライブラリです。 frameworkとありますが、フレームワークと聞いたときにイメージする例えばRuby on Railsのようなものではなく、シンプルで学習コストも小さいのでご安心ください。

deps.ednを次のようにします。

;; ./deps.edn
{:paths ["src"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        spootnik/unilog {:mvn/version "0.7.30"}
        ;; 追加
        com.stuartsierra/component {:mvn/version "1.1.0"}}}

Componentを実装

ライブラリComponentにおいてはLifecycleというプロトコルを実装しているレコードなどをコンポーネントと呼びます。

(defprotocol Lifecycle
  :extend-via-metadata true
  (start [component]
    "Begins operation of this component. Synchronous, does not return
  until the component is started. Returns an updated version of this
  component.")
  (stop [component]
    "Ceases operation of this component. Synchronous, does not return
  until the component is stopped. Returns an updated version of this
  component."))

startに起動時の処理を書き、stopに停止時の処理を書きます。

コンポーネントという言葉が繰り返し出てきて紛らわしいですが、本記事では次の2つの意味で使っています。

  • ライブラリのComponent
  • ライブラリのComponentの要求に従って作ったコンポーネント

まずはRing handlerを提供するコンポーネントを実装します。 今は単純な関数なのでわざわざコンポーネントにする意味が感じられないですが、例えばテスト環境と本番環境で挙動を変えたい時などでコンポーネントにしておくと切り替えができて便利です。

;; ./src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [com.stuartsierra.component :as component]))

(defn- ring-handler
  [_req]
  {:status 200
   :body "Hello, Clojure API"})

(defrecord Handler [handler]
  component/Lifecycle
  (start [this]
    (assoc this :handler ring-handler))
  (stop [this]
    (assoc this :handler nil)))

次にサーバーを起動停止するコンポーネントを実装してみます。

;; ./src/cljapi/component/server.clj
(ns cljapi.component.server
  (:require
   [com.stuartsierra.component :as component]
   [ring.adapter.jetty9 :as jetty]))

(defrecord Jetty9Server [handler opts server]
  ;; handlerは :handler をキーにもつマップ(= コンポーネント)であることを期待している
  component/Lifecycle
  (start [this]
    (if server
      this
      (assoc this :server (jetty/run-jetty (:handler handler) opts))))
  (stop [this]
    (when server
      (jetty/stop-server server))
    (assoc this :server nil)))

Systemを構築する

ライブラリComponentではシステムというマップを定義して、作成したコンポーネントの依存関係を定義し、まとめて起動停止できるようにします。 依存関係を component/using を使って定義しておくと、ライブラリ側でよしなに起動順序(startを呼ぶ順序)を制御してくれます。

;; ./src/cljapi/system.clj
(ns cljapi.system
  (:require
   [cljapi.component.handler :as c.handler]
   [cljapi.component.server :as c.server]
   [com.stuartsierra.component :as component]))

(defn- new-system []
  (component/system-map
   :handler (c.handler/map->Handler {})
   :server (component/using
            (c.server/map->Jetty9Server {:opts {:join? false
                                                :port 8000}})
            ;; component/usingの第二引数で依存しているコンポーネントを宣言している
            [:handler])))

(defn start []
  (let [system (new-system)]
    (component/start system)))

(defn stop [system]
  (component/stop system))

(defonce system (atom nil))

(defn go []
  (when @system
    (stop @system)
    (reset! system nil))
  (reset! system (start)))

component/start を実行すると起動したコンポーネントが返ってくるので、それをatomで保持しておくことで後から停止できるようにしています。 また、go という関数で繰り返し起動させることができるようにしています。 namespaceを移動して go を実行してみます。

※namespaceを評価しておくのを忘れないようにしてください。

user> (in-ns 'cljapi.system)
#namespace[cljapi.system]
cljapi.system> (go)
12:51:40.387 [nREPL-session-e6ae6d15-5885-4f3b-912b-741243dab0a0] DEBUG org.eclipse.jetty.util.component.ContainerLifeCycle - QueuedThreadPool[qtp773034985]@2e1393e9{STOPPED,8<=0<=50,i=0,r=-1,q=0}[NO_TRY] added {org.eclipse.jetty.util.thread.ThreadPoolBudget@5eabf21,POJO}
12:51:40.390 [nREPL-session-e6ae6d15-5885-4f3b-912b-741243dab0a0] DEBUG org.eclipse.jetty.util.component.ContainerLifeCycle - Server@4f689547{STOPPED}[10.0.9,sto=0] added {QueuedThreadPool[qtp773034985]@2e1393e9{STOPPED,8<=0<=50,i=0,r=-1,q=0}[NO_TRY],AUTO}
# 出力が続く...

{:handler {:handler #function[cljapi.component.handler/ring-handler]},
 :server
 {:handler {:handler #function[cljapi.component.handler/ring-handler]},
  :opts {:join? false, :port 8000},
  :server
  #object[org.eclipse.jetty.server.Server 0x4f689547 "Server@4f689547{STARTED}[10.0.9,sto=0]"]}}

最後に表示されているのがgoの戻り値、つまりstartしたシステムマップです。 :server に起動した( :handler が生成されている)handlerコンポーネントが注入されていることがわかります。

curlで確かめてみます。

$ curl http://localhost:8000
Hello, Clojure API

最後にmain関数も置き換えておきましょう。

;; ./src/cljapi/core.clj
(ns cljapi.core
  (:require
   [cljapi.system :as system]))

(defn -main
  [& _args]
  (system/start))

main関数はサーバー起動時に使う関数で、停止する必要がないので、goではなくstartを使っています。

おわりに

これでREPLを起動したまま、サーバーのように状態を持つものを再起動できるようになりました。

コードはGitHubのリポジトリに上げてあります。 04_ライフサイクルと依存関係を管理できるようにするというタグからご覧ください。

次は開発時に使う関数を分離して、より開発を進めやすくしていきます。


トヨクモでは一緒に働いてくれる技術が好きなエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。