Toyokumo Tech Blog

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

Clojureで作るAPI ルーターを追加する

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

これまでWebアプリケーションの開発をするための準備をしてきましたが、これからは実際にWebアプリケーションとしての機能を追加していきます。 今回の記事ではWebアプリケーションにルーターを追加していきます。

ルーターの必要性

今まで作ってきたアプリケーションはどのパスにアクセスしても同じレスポンスを返します。 ここにルーターを加えることでリクエストされたパスとHTTPメソッドによって異なった処理を呼び出すことができるようにします。

ルーターとRingの関係性についてはこちらの記事で詳しく解説しているので参照してください。

tech.toyokumo.co.jp

以降はこちらの内容をご理解いただいた前提で進めていきます。

reititを追加する

ルーターライブラリの選択肢は複数ありますが、今回は今最も有力な選択肢であるreititを使用します。

github.com

まずはdeps.ednに追加します。reititは複数のモジュールが含まれるので必要なものだけを選択して加えることもできますが、今回は簡単のためにまとめて追加します。

{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        ;; 追加
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

Handlerを書きやすくするためのライブラリとしてmetosin/ring-http-responseも加えています。 次項ですぐに使用します。

Handlerを追加する

試しにHandlerを3つ追加してみます。 発展していくことを考慮してnamespaceも分けておきます。

;; ./src/cljapi/handler/health.clj
(ns cljapi.handler.health
  (:require
   [ring.util.http-response :as res]))

(defn health
  "ヘルスチェックに対応するためのHandlerとして意図しています"
  [_]
  (res/ok "Application is runnig"))
;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [ring.util.http-response :as res]))

(defn hello [_]
  (res/ok "Hello world"))

(defn goodbye [_]
  (res/ok "Goodbye!"))

ring.util.http-responseRingのレスポンスマップを意図が分かりやすく生成するためのユーティリティを提供してくれるライブラリです。 (res/ok "hoge") を実行すると {:status 200 :headers {} :body "hoge"} が返ります。

Routerを定義する

前項で作成したHandlerを使ってRouterを定義します。

;; ./src/cljapi/router.clj
(ns cljapi.router
  (:require
   [cljapi.handler.api.greeting :as api.greeting]
   [cljapi.handler.health :as health]
   [reitit.ring :as ring]))

(def router
  (ring/router
   [["/health" health/health]
    ["/api"
     ["/hello" api.greeting/hello]
     ["/goodbye" api.greeting/goodbye]]]))

ring/router に与えているベクタがreititを利用したrouterの定義です。 構文は見たままで上記の範囲は理解しやすいと思います。 詳しい解説は次の公式ページを参照してください。

cljdoc.org

RouterからRing Handlerを作る

前項で作成したRouterからRing Handler作り、それをHandlerとしてWebサーバーに与えるようにします。

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

(defn- build-handler
  []
  (ring/ring-handler router/router))

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

ここまで書けたらREPLを立ち上げて (user/go) としてcurlで試してみます。

$ curl http://localhost:8000/health
Application is runnig
$ curl http://localhost:8000/api/hello
Hello world
$ curl http://localhost:8000/api/goodbye
Goodbye!

定義したパスに応じて異なるHandlerが呼ばれ、異なるレスポンスが返ってきていることが確認できます。

開発生産性を引き上げる

ここまででルーターを使う根本の目的は達成できていますが、長く開発を続けていく上では十分ではありません。

試しに src/cljapi/handler/api/greeting.cljhello を次のようにして保存してみます。

(defn hello [_]
  (res/ok "Hello cljapi"))

その上で curl http://localhost:8000/api/hello を実行してもレスポンスは変わりません。 変更した関数を評価しても変わりません。

現状でHandlerの変更を反映するには、変更したHandlerとそれを使用しているRouterをいずれも評価した上で (user/go) をREPLで評価してWebサーバーを再起動する必要があります。 この状態では

  1. 変更した関数などの評価を忘れる
  2. 毎回goでSystemを再起動するのが手間がかかるかつ遅い

という問題があります。 開発生産性を引き上げるためにそれぞれの問題を解決します。

評価のし忘れを防ぐ

評価のし忘れを防ぐために、開発環境で立ち上がっているアプリケーションにアクセスがきた時に、変更があったnamespaceを再読み込みできるようにします。

ring-develにある wrap-reload というRing Middlewareがこの機能を提供してくれます。

Rinig MiddlewareについてはRing Middlewareとは何かをご参照ください。 本項はこの内容を理解していただいた前提で進めていきます。

ライブラリを依存関係に加えます。

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ;; 追加
        ring/ring-devel {:mvn/version "1.9.5"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

リクエスト時の自動的な再評価は開発時だけ有効化したいので、 profile をSystemから渡すことで開発時かどうかの判別ができるようにします。

;; ./src/cljapi/system.clj
(ns cljapi.system
  (:require
   [cljapi.component.a :as c.handler]
   [cljapi.component.server :as c.server]
   [cljapi.config :as config]
   [clojure.tools.logging :as log]
   [com.stuartsierra.component :as component]
   [unilog.config :as unilog]))

(defn- new-system [{:as config :keys [:profile]}]
  (component/system-map
   ;; config内のprofileをHandler Componentに渡すように変更している
   :handler (c.handler/map->Handler {:profile profile})
   :server (component/using
            (c.server/map->Jetty9Server (:server config))
            [:handler])))

(defn- init-logging! [config]
  (unilog/start-logging! (:logging config)))

(defn start [profile]
  (let [config (config/read-config profile)
        system (new-system config)
        _ (init-logging! config)
        _ (log/info "system is ready to start")
        started-system (component/start system)]
    (log/info "system is started")
    started-system))

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

後は開発時のHandlerに適用するだけです。 Ring Middlewareの適用方法は上記の記事で解説した通常通りの関数実行を行う方法でももちろんできますが、 今回はreititの提供するMiddlewareの適用方法を使います。

;; ./src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]
   [ring.middleware.lint :as m.lint]
   [ring.middleware.reload :as m.reload]
   [ring.middleware.stacktrace :as m.stacktrace]))

(defn- build-handler
  [profile]
  ;; ring/ring-handlerの引数を変更しています
  ;; 第1引数はこれまで通りrouter
  ;; 第2引数はdefalut-handlerですがこれは今のところはnilにしておきます
  ;; 第3引数のマップにmiddlewareを複数加えています
  (ring/ring-handler
   router/router
   nil
   {:middleware (if (= profile :prod)
                  []
                  ;; 開発時に適用するRing Middlewareです。
                  ;; 今回はwrap-reloadだけでなく、ring-develの提供する他の開発用のMiddlewareもついでに適用しています。
                  [;; 指定したディレクトリ以下の変更を検知してリロードさせるMiddleware
                   ;; オプションはデフォルトそのままをあえて可視化のために書いています
                   [m.reload/wrap-reload {:dirs ["src"]
                                          :reload-compile-errors? true}]
                   ;; リクエストマップとレスポンスマップがRingの仕様を満たしているかをチェックするMiddleware
                   m.lint/wrap-lint
                   ;; 例外をわかりやすく表示してくれるMiddleware
                   [m.stacktrace/wrap-stacktrace {:color? true}]])}))

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

今回使用しているreititのMiddlewareの適用方法は、通常の関数実行で適用した時と視覚的に順序が逆であることに注意が必要です。

関数実行でスレッディングマクロを使って適用した場合は次のようになりますが、

(-> handler
    (hatena-middleware)  ; 前処理3番目、後処理1番目
    (debug-middleware)   ; 前処理2番目、後処理2番目
    (no-mean-middleware) ; 前処理1番目、後処理3番目
    )

reititの提供する適用方法を利用すると次のようになります。

{:middleware [hatena-middleware   ; 前処理1番目、後処理3番目
              debug-middleware    ; 前処理2番目、後処理2番目
              no-mean-middleware  ; 前処理3番目、後処理1番目
              ]}

本項では自動で再評価するやり方について書いてきましたが、そもそもどの開発環境にもすべてのnamespaceを一括で再読み込みするコマンドがあります。 ですので評価のし忘れは開発に慣れてくればコマンド1回で解決する問題ではあります。

自動で評価してしまうということは、評価する前後での変化を見たいなどの柔軟性を失ってしまうということでもありますから、 慣れてきたらオフにするか選択できるようにするなどの対応を入れることも選択肢の1つだと思います。

Systemの再起動を減らす

Webサーバーは起動時のHandlerを参照しているため、今のままでは評価したものを反映するにはWebサーバーを再起動する必要があります。

この問題を解決するために、マルチメソッドを使います。

マルチメソッドは動的ポリモーフィズムを実現するための機能の1つです。 defmulti の定義に使った関数の戻り値がdispatchするための値となり、 defmethod で定義した値と一致した defmethod が呼び出されます。 以下に簡単な例を示します。

(defmulti currency
  "この下に定義している関数の戻り値がdispatchを実現するための関数となる。
   以下の例ではマップmのキー :type のバリューでdispatchしようとしている。"
  (fn [m] (get m :type)))

;; Clojureのキーワードは関数としても使えるから次のようにも書ける
;; (defmulti currency :type)

(defmethod currency :yen
  [{:keys [:amount]}]
  (str "¥" amount))

(defmethod currency :dollar
  [{:keys [:amount]}]
  (str "$" amount))

(defmethod currency :euro
  [{:keys [:amount]}]
  (str "€" amount))

;; :default はマッチするものがない時に使われます
(defmethod currency :default
  [_]
  "unknown")

(currency {:type :yen
           :amount 500})
;; => "¥500"

(currency {:type :dollar
           :amount 500})
;; => "$500"

(currency {:type :euro
           :amount 500})
;; => "€500"

(currency {:type :pound
           :amount 500})
;; => "unknown"

Routerからはマルチメソッドの定義(抽象)にだけ依存するようにし、実装には依存しないようにします。 そうすることでHandlerの実装が変わってもWebサーバーの再起動が不要になります。

まずマルチメソッドの定義を用意します。

;; ./src/cljapi/handler.clj
(ns cljapi.handler
  (:require
   [ring.util.http-response :as res]))

(defmulti handler
  "引数はリクエストマップ
   reititで作られたHandler(reitit.ring/ring-handlerで作ったHandler)を通すと
   リクエストマップには :reitit.core/match というキーにマッチしたrouteのdataが含まれる。
   そこからマッチしたrouteの名前を取り出す。
   またRingの仕様により :request-method にHTTP Methodが含まれる。
   この2つを組み合わせて [route-name, request-method] という2つの情報でHandlerの実装とマッチさせる。"
  (fn [req]
    [(get-in req [:reitit.core/match :data :name])
     (get req :request-method)]))

(defmethod handler :default
  [_]
  (res/not-found "not found"))

Routerの定義をこのマルチメソッドに依存するように書き換えます。

;; ./src/cljapi/router.clj
(ns cljapi.router
  (:require
   ;; 具体的なhandlerへの依存が消えている
   [cljapi.handler :as h]
   [reitit.ring :as ring]))

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api"
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]]]))

今までは ["/health" health/health] だったところが ["/health" {:name ::health :handler h/handler}] に変わっています。 これはreititのRoute Dataという仕様で、 マップを書くとこれがDataとしてリクエストマップに含まれるようになります。 defmulti handler の関数ではこれを利用してマッチしたHandlerの名前を取り出しているということです。

Handlerの実装も書き換えます。

;; ./src/cljapi/handler/health.clj
(ns cljapi.handler.health
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/health :get]
  [_]
  (res/ok "Application is runnig"))
;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/hello :get]
  [_]
  (res/ok "Hello world"))

(defmethod h/handler [::r/goodbye :get]
  [_]
  (res/ok "Goodbye!"))

いずれも関数だったHandlerがマルチメソッドの実装(defmethod)に変わっています。 マッチさせるためのデータも [Route名のキーワード HTTPメソッド] となっていることがわかります。

最後に、この状態では defmethod しているHandlerの実装のnamespaceはどこからも読み込まれていないので、 Handler Componentからrequireしておきます。

(ns cljapi.component.handler
  (:require
   ;; ここにrequireを足している
   [cljapi.handler.api.greeting]
   [cljapi.handler.health]
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]
   [ring.middleware.lint :as m.lint]
   [ring.middleware.reload :as m.reload]
   [ring.middleware.stacktrace :as m.stacktrace]))

(defn- build-handler
  [profile]
  (ring/ring-handler
   router/router
   nil
   {:middleware (if (= profile :prod)
                  []
                  [[m.reload/wrap-reload {:dirs ["src"]
                                          :reload-compile-errors? true}]
                   m.lint/wrap-lint
                   [m.stacktrace/wrap-stacktrace {:color? true}]])}))

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

ここまで書けたらREPLを再起動して動作を確認してみましょう。

$ curl http://localhost:8000/health
Application is runnig
$ curl http://localhost:8000/api/hello
Hello world
$ curl http://localhost:8000/api/goodbye
Goodbye!

これまで通りのことは変わりなくできています。

次にHandlerを書き換えて保存してからもう一度curlを投げてみます。

;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/hello :get]
  [_]
  (res/ok "Hello cljapi!!")) ;; ここを変更

(defmethod h/handler [::r/goodbye :get]
  [_]
  (res/ok "Goodbye!"))
$ curl http://localhost:8000/api/hello
Hello cljapi!!

これで目的だった、「ファイルを変更して保存し、再度APIアクセスをしたら変更が反映される」状態を作ることができました。

おわりに

ここまででルーターをアプリケーションに適用し、かつ生産的に開発を続けていく準備もできました。

コードはGitHubのリポジトリに上げてあります。 10_ルーターを追加するというタグからご覧ください。

次はRing Middlewareを使ってより本格的なAPIサーバーとして必要な機能を足していきます。


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

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

Clojureで作るAPI テストできるようにする

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

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

tech.toyokumo.co.jp

現在開発しているプロジェクトにはテスト対象自体がまださほどありませんが、先にテストを書ける環境を整えておくことは今後の開発の健全性のために重要です。 そこでこの記事では、テストを実行できる環境を整えていきます。

最初のテスト

テスティングライブラリには公式のclojure.testを使っていきます。

かんたんに設定を読み込む関数のテストを書いてみます。

;; ./test/cljapi/config_test.clj
(ns cljapi.config-test
  (:require
   [cljapi.config :as sut]
   [clojure.test :refer [deftest testing is]]))

(deftest read-config-test
  (testing "usable profiles"
    (is (map? (sut/read-config :dev)))
    (is (map? (sut/read-config :test)))
    (is (map? (sut/read-config :prod)))
    (is (thrown? AssertionError (sut/read-config :hoge))))

  (testing "profile included"
    (is (= :dev
           (:profile (sut/read-config :dev))))
    (is (= :test
           (:profile (sut/read-config :test))))
    (is (= :prod
           (:profile (sut/read-config :prod))))))

deftest で1つのテストを定義します。一般的にはテストしたい関数名に -test をつけた名前をつけます。 is がclojure.testで中心となるアサーションを行うマクロです。 testing はドキュメンテーションのためのもので、エラー時にどこでエラーが起こっているのかを把握するために書いておくと便利です。

テストはどの開発環境でもエディタ上から実行できます。 テストを書くときはショートカットを使って実行しながらテストを書いていけるようにすべきです。 以下に各エディタごとにテストの実行方法へのリンクを用意しました。参照して実行してみてください。

開発環境自体のセットアップ方法は以下のまとめ等から開発環境の構築方法を参照してください。

tech.toyokumo.co.jp

テストランナー

ローカル環境はもちろんCI上でも全てのテストをまとめて実行できる環境を作ります。 そのためのテストランナーとしてkaochaを使います。

github.com

deps.edn:test aliasを追加します。

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}}
 :aliases {:dev {:extra-paths ["dev"]}
           ;; 追加
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

次にkaocha公式のおすすめに従って、テストを実行するスクリプトを追加します。

$ mkdir -p bin
$ echo '#!/usr/bin/env sh' > bin/kaocha
$ echo 'clojure -M:test "$@"' >> bin/kaocha
$ chmod +x bin/kaocha

最後にkaochaの設定を追加します。 tests.edn に設定を記述します。 最初は公式ドキュメントのサンプルをそのまま利用します。

;; ./tests.edn
#kaocha/v1
{:tests [{;; Every suite must have an :id
          :id :unit

          ;; Directories containing files under test. This is used to
          ;; watch for changes, and when doing code coverage analysis
          ;; through Cloverage. These directories are *not* automatically
          ;; added to the classpath.
          :source-paths ["src"]

          ;; Directories containing tests. These will automatically be
          ;; added to the classpath when running this suite.
          :test-paths ["test"]

          ;; Regex strings to determine whether a namespace contains
          ;; tests. (use strings, not actual regexes, due to a limitation of Aero)
          :ns-patterns ["-test$"]}]

 :plugins [:kaocha.plugin/print-invocations
           :kaocha.plugin/profiling]

 ;; Colorize output (use ANSI escape sequences).
 :color?      true

 ;; Watch the file system for changes and re-run. You can change this here to be
 ;; on by default, then disable it when necessary with `--no-watch`.
 :watch?      false

 ;; Specifiy the reporter function that generates output. Must be a namespaced
 ;; symbol, or a vector of symbols. The symbols must refer to vars, which Kaocha
 ;; will make sure are loaded. When providing a vector of symbols, or pointing
 ;; at a var containing a vector, then kaocha will call all referenced functions
 ;; for reporting.
 :reporter    kaocha.report/documentation

 ;; Enable/disable output capturing.
 :capture-output? true

 ;; Plugin specific configuration. Show the 10 slowest tests of each type, rather
 ;; than only 3.
 :kaocha.plugin.profiling/count 10}

ここまでできたら実際に実行してみます。

$ bin/kaocha
--- unit (clojure.test) ---------------------------
cljapi.config-tets
  read-config-test
    usable profiles
    profile included

1 tests, 7 assertions, 0 failures.

Top 1 slowest kaocha.type/clojure.test (0.02938 seconds, 98.0% of total time)
  unit
    0.02938 seconds average (0.02938 seconds / 1 tests)

Top 1 slowest kaocha.type/ns (0.02196 seconds, 73.3% of total time)
  cljapi.config-test
    0.02196 seconds average (0.02196 seconds / 1 tests)

Top 1 slowest kaocha.type/var (0.02085 seconds, 69.6% of total time)
  cljapi.config-test/read-config-test
    0.02085 seconds cljapi/config_test.clj:6

Makefileに追加

最後に繰り返し実行できるようにMakefileに追加します。 今後CI上で実行することを考慮して1つでもテストが失敗したらその時点でテストを終了するようにオプションをつけておきます。

.PHONY: format
format:
    cljstyle check

.PHONY: lint
lint:
    clj-kondo --lint src

.PHONY: static-check
static-check: format lint

.PHONY: clean
clean:
    rm -fr target/

# 追加
.PHONY: test
test:
    bin/kaocha --fail-fast

.PHONY: build
build: clean
    clojure -T:build uber

make test を実行して確認しておいてください。

おわりに

ここまでで1つ単体テストを書き、それをテストランナーを使ってテストを実行できることが確認できました。

コードはGitHubのリポジトリに上げてあります。 09_テストできるようにするというタグからご覧ください。

次はWebサーバーにRouterを追加し、複数のパスに対して異なるレスポンスを返すことができるようにしていきます。


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

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

Clojureで作るAPI ロギングできるようにする

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

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

tech.toyokumo.co.jp

この記事ではロギングをできるようにしていきます。

Javaのロギングの歴史的経緯と現在

Clojureアプリケーションは多くの部分でJavaのライブラリに依存していることから、ClojureアプリケーションのロギングにJavaのロギングの歴史的経緯が関わってきます。

Javaのロギングの歴史的経緯は複雑です。この項ではそれを必要最低限理解するための情報を提供したいと思います。 この項を飛ばして進むことも可能です。興味のある方はお付き合いいただければと思います。

まずJavaのロギングライブラリの構造について理解するためには デザインパターン (ソフトウェア) - Wikipedia の1つである Facadeパターン について理解する必要があります。

リンク先を読んでいただけたけると、Facadeパターンとは実装の詳細を隠蔽し、簡単に使えるインターフェースを用意することだと理解いただけると思います。 理解いただけたという前提で本題のJavaのロギングライブラリの解説に移っていきますが、全てをこの記事に書き切ることはできないので、良い記事を2つ紹介します。

まずこちらの記事の図を見ながらイメージを掴んでいただければと思います。

qiita.com

次にこちらのスライドで登場するライブラリの名前をなんとなくでも頭に入れてください。

www.slideshare.net

これらの記事からわかることをまとめます。

  • よく名前を見るJavaのロギングライブラリにはFacadeと実装がある
  • 今のデファクトスタンダードのFacadeはCommons LoggingではなくSLF4J
  • SLF4Jはさまざまなログ出力実装へのbinding(2.0.0からはproviderと言われる)も同時に提供しており、それらのbindingはログ出力実装をSLF4Jを使用しているように変換し、SLF4J+自分が設定したログ出力実装の組み合わせに適合させてくれる
  • LogbackはSLF4Jと一緒に使うログ出力実装としてSLF4Jの作者が作ったもので、SLF4JとLogbackの組み合わせは広く使われている。SLF4Jの公式にも次の記述がある
    • Logback's ch.qos.logback.classic.Logger class is a direct implementation of SLF4J's org.slf4j.Logger interface. Thus, using SLF4J in conjunction with logback involves strictly zero memory and computational overhead.

簡単ですがここまで理解できれば、実際にログ出力をするための知識としては十分です。

unilogを使って設定する

この連載では上記で説明したように最もスタンダードな組み合わせだと思われるSLF4JとLogbackの組み合わせを使っていきます。

Logbackはクラスパス上の logback.xml というXMLファイルで設定ファイルを記述します。 しかしXMLファイルの記述はちょっと大変です。 また各ログ実装のproviderを1つずつ調べて依存関係に入れていくのも手間です。

そこでClojureのデータとしてLogbackの設定を書けるようにしてくれ、また各ログ実装のproviderをまとめて依存関係に入れてくれるClojureライブラリであるunilogを使います。

github.com

いつもなら deps.edn に依存の記述を追加するところですが、実はunilogはすでにClojureで作るAPI Web サーバーを立ち上げる - Toyokumo Tech Blogで追加していました。

ですのでClojureのロギングのFacadeである clojure.tools.logging だけを新たに依存に加えます。

github.com

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"} ;; 追加
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

つまり、clojure.tools.logging -> SLF4J -> Logbackという図式を作ります。

ログ出力の設定を config.edn に記載します。

;; ./resources/config.edn
{:logging {:level #profile {:dev :debug
                            :default :info}
           :console #profile {:prod {:encoder :json}
                              :default true}
           :overrides {"org.eclipse.jetty" :info}}

 :server {:opts {:host "localhost"
                 :port #long #profile {:default 5000
                                       :dev 8000}
                 :join? false}}}

:logging というキーのバリューに設定を定義してあります。

  • :level ログレベルです。開発中はdebugレベル以上、それ以外ではinfoレベル以上で出力されるようにしています。
  • :console 標準出力に出す設定です。本番ではJSONで出力し、それ以外ではデフォルトのフォーマットで出力されるようにしています。
  • :overrides ログレベルを特定のパッケージに対して設定しています。 org.eclipse.jetty はWebサーバーですが、開発時にdebugレベルだとログが多すぎるので抑制しています。

Systemと統合する

それではSystemと統合してログ出力を足していきましょう。

;; ./src/cljapi/sytem.clj
(ns cljapi.system
  (:require
   [cljapi.component.handler :as c.handler]
   [cljapi.component.server :as c.server]
   [cljapi.config :as config]
   [clojure.tools.logging :as log]
   [com.stuartsierra.component :as component]
   [unilog.config :as unilog]))

(defn- new-system [config]
  (component/system-map
   :handler (c.handler/map->Handler {})
   :server (component/using
            (c.server/map->Jetty9Server (:server config))
            [:handler])))

(defn- init-logging! [config]
  (unilog/start-logging! (:logging config)))

(defn start [profile]
  (let [config (config/read-config profile)
        system (new-system config)
        _ (init-logging! config)
        _ (log/info "system is ready to start")
        started-system (component/start system)]
    (log/info "system is started")
    started-system))

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

init-logging! でunilogを使ってロギングの初期化をしています。 これを start に組み込むことで開発時と本番環境のいずれにおいても初期化がされるようになります。

REPLを立ち上げて (go) を評価することで実際にログが出力されることを確かめてみましょう。

INFO [2022-07-01 15:49:00,546] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - cljapi.system system is ready to start
INFO [2022-07-01 15:49:00,582] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - org.eclipse.jetty.server.Server jetty-10.0.9; built: 2022-03-30T16:46:32.527Z; git: a9eaf8d5d73369acf610ce88f850c0d56c4b1113; jvm 11.0.15+9-LTS
INFO [2022-07-01 15:49:00,650] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - org.eclipse.jetty.server.handler.ContextHandler Started o.e.j.s.ServletContextHandler@27fdbafe{/,null,AVAILABLE}
INFO [2022-07-01 15:49:00,657] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - org.eclipse.jetty.server.AbstractConnector Started ServerConnector@1eb67498{HTTP/1.1, (http/1.1)}{localhost:8000}
INFO [2022-07-01 15:49:00,666] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - org.eclipse.jetty.server.Server Started Server@4b8e5772{STARTING}[10.0.9,sto=0] @6911ms
INFO [2022-07-01 15:49:00,666] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - cljapi.system system is started

Jettyが出すログと共に自分で記述したログが同じフォーマットで出力されていることが確認できます。

次にビルドしてJARを実行したときのログを見てみます。

$ make build
$ java -jar target/cljapi.jar
{"@timestamp":"2022-07-01T15:19:41.566+09:00","@version":"1","message":"system is ready to start","logger_name":"cljapi.system","thread_name":"main","level":"INFO","level_value":20000}
{"@timestamp":"2022-07-01T15:19:41.608+09:00","@version":"1","message":"jetty-10.0.9; built: 2022-03-30T16:46:32.527Z; git: a9eaf8d5d73369acf610ce88f850c0d56c4b1113; jvm 11.0.15+9-LTS","logger_name":"org.eclipse.jetty.server.Server","thread_name":"main","level":"INFO","level_value":20000}
{"@timestamp":"2022-07-01T15:19:41.672+09:00","@version":"1","message":"Started o.e.j.s.ServletContextHandler@76e3b45b{/,null,AVAILABLE}","logger_name":"org.eclipse.jetty.server.handler.ContextHandler","thread_name":"main","level":"INFO","level_value":20000}
{"@timestamp":"2022-07-01T15:19:41.678+09:00","@version":"1","message":"Started ServerConnector@5d3b58ca{HTTP/1.1, (http/1.1)}{localhost:5000}","logger_name":"org.eclipse.jetty.server.AbstractConnector","thread_name":"main","level":"INFO","level_value":20000}
{"@timestamp":"2022-07-01T15:19:41.687+09:00","@version":"1","message":"Started Server@7f2d31af{STARTING}[10.0.9,sto=0] @902ms","logger_name":"org.eclipse.jetty.server.Server","thread_name":"main","level":"INFO","level_value":20000}
{"@timestamp":"2022-07-01T15:19:41.687+09:00","@version":"1","message":"system is started","logger_name":"cljapi.system","thread_name":"main","level":"INFO","level_value":20000}

設定通り、JSONで出力されていることが確認できます。

おわりに

ここまでで、ロギングのライブラリと設定を適切に行い、実際にログ出力できていることが確認できました。

コードはGitHubのリポジトリに上げてあります。 08_ロギングできるようにするというタグからご覧ください。

次はテストの設定を行い、簡単なテストを書いてみます。


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

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

Clojureのリンターclj-kondoの使い方

開発本部の飯塚です。

今回は Clojure のリンターである clj-kondo の使い方をちょっと踏み込んだところまで説明できたらと思います。

github.com

Clojure でプログラムを書いている人であればすでにほとんどの方は使っているのではと思うのですが、 もし使っていなければ使う手助けに、使っている場合はこんな事もできるかという発見に繋がれば良いなと思います。

なお clojure-lsp は clj-kondo を使っていて、Calva は clojure-lsp を使っているので、直接 clj-kondo を使っていなくても恩恵に預かってる方も多いかと思います。

導入

まずは clj-kondo コマンドが使えるようにしましょう。 導入手順は以下にまとまって、各種パッケージマネージャーでも利用可能なので導入で手間取ることはないかと思います。

github.com

各エディタでの設定については以下からご利用のエディタの記事を参照してください。

tech.toyokumo.co.jp

使い方

エディタ連携が出来ているとエディタ上で警告やエラーを表示してくれますが、一旦 clj-kondo コマンドでの使い方を説明します。(後段でも clj-kondo を利用するため)

$ clj-kondo --lint リント対象のファイルもしくはディレクトリ

基本はこの --lint オプションです。 リント対象が複数ある場合は --lint src:test のようにコロン区切りで指定できます。

以下に簡単な実行例を載せておきます。 使われていない private な var に関して警告が出ていることがわかるかと思います。

$ cat src/foo/core.clj
(ns foo.core)

(def ^:private hello "world")

$ clj-kondo --lint src
src/foo/core.clj:3:16: warning: Unused private var foo.core/hello
linting took 189ms, errors: 0, warnings: 1

それ以外のオプションは --help オプションの出力を参照してください。

設定

clj-kondo では様々な種類のリンターが用意されていて、それぞれに対して細かく設定を定義することができます。

github.com

設定ファイルはいくつかの場所に配置可能ですが、基本的にはプロジェクト単位で設定内容も変わるのでプロジェクトディレクトリ配下に .clj-kondo/config.edn を用意するのが一般的かと思います。

以下に設定されがちな内容を含む config.edn のサンプルを載せておきます。

{;; 各種リンターの設定
 :linters {;; 未解決できない var に関する設定
           :unresolved-symbol {:exclude [;; foo という var すべてを除外
                                         foo
                                         ;; clojure.core/seq 内で使われている bar という var を除外
                                         (clojure.core/seq [bar])]}}

 ;; 既存のリントを使い回す設定
 :lint-as {;; mydef は clojure.core/def とみなす
           foo.core/mydef clojure.core/def}

 ;; 除外設定
 :output {:exclude-files [;; ファイルの除外
                          "foo/bar.clj"
                          ;; ディレクトリの除外
                          "baz"]}}

その他、どんなリンターがあり、どのように設定するかは以下を参照してください。 github.com

マクロ

clj-kondo は Clojure のコードを静的に解析して結果を出しているため、 コンパイル時に別の式に展開されるマクロを使ったコードはそのままでは正しくチェックしてくれません。 (ビルトインで提供されているマクロは clj-kondo 側でよしなにしてくれるので、主に自前で定義したマクロが対象です)

例えば以下ようなマクロの場合、clj-kondo は mydef の展開結果を知らないので fooUnresolved symbol 警告を出してしまいます。

(defmacro mydef [sym value]
  `(def ~sym ~value))

(mydef foo 1)

この警告を解決するには lint-as を使う方法と hooks を使う方法の2つがあります。 lint-as については config.edn のサンプルとして既に紹介していました。

 ;; 既存のリントを使い回す設定
 :lint-as {;; mydef は clojure.core/def とみなす
           foo.core/mydef clojure.core/def}

この設定により mydefclojure.core/def とみなされるので fooUnresolved symbol にならなくなります。

Hooks

では以下のようなマクロだった場合はどうでしょうか? 引数の順番がおかしいので今回は lint-as は使えません。

(defmacro myrdef [sym value]
  `(def ~value ~sym))

(myrdef 2 bar)

この例に限らず、少し複雑なことをマクロで実現しようと思うと lint-as でカバーできないことは多々あるかと思います。 そのときに使えるのが Hooks という機能です。

github.com

Hooks では babashka/sci を使って展開結果を clj-kondo に教えてあげることで、マクロを正しくチェックできるようにする機構です。 展開処理は前述の通り Clojure 本体ではなく babashka/sci により行われるので、defmacro のコードとは別に展開するためのコードが必要になることに注意が必要です。

まずは展開処理を用意します。細かい書き方は Hooks のドキュメントを参照してください。ここでは雰囲気だけ掴んでください。 プロジェクトディレクトリ配下に .clj-kondo/hooks/myrdef.clj を以下の内容で作ってください。

(ns hooks.myrdef
  (:require
   [clj-kondo.hooks-api :as api]))

(defn myrdef
  [{:keys [node]}]
  (let [[value sym] (rest (:children node))
        new-node (api/list-node
                   (list* (api/token-node 'def)
                          sym
                          value))]
    {:node new-node}))

次に用意した Hook を利用する設定を .clj-kondo/config.edn に追加します。

{:lint-as {foo.core/mydef clojure.core/def}

 ;; 追加
 :hooks {:analyze-call {foo.core/myrdef hooks.myrdef/myrdef}}}

これにより clj-kondo は (myrdef 2 bar)(def bar 2) に展開されることを知れ、ビルドインの clojure.core/def に対するものと同じチェックを実施してくれます。

上記の例だけだと任意のマクロに対して Hooks を実際に設定するのは難しいと思います。 clj-kondo では以下のリポジトリにていくつかのライブラリに対する Hooks を含む設定を公開しているので、ライブラリ側の実装と設定を見比べると理解しやすいかと思います。

github.com

ライブラリが提供する設定の取り込み

各種リンターの設定や Hooks の設定は自プロジェクトで定義している関数やマクロ向けのものはともかく、 外部ライブラリで提供されているものに関しても自分で設定するのは面倒です。(特に Hooks は展開するためのコードを書かないとなので、ライブラリ側の動作を把握する必要があり難易度が高い)

幸い clj-kondo ではライブラリが提供する clj-kondo 向け設定を取り込む機能があります。

例として seancorfield/next-jdbc が提供する clj-kondo 向け設定を取り込む方法を紹介します。 まず deps.edn に seancorfield/next-jdbc への依存を追加します。

{:paths ["src"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        ;; 追加
        com.github.seancorfield/next.jdbc {:mvn/version "1.2.780"}}}

そして以下のコマンドを実行してください。

$ clj-kondo --lint "$(clojure -Spath)" --copy-configs --skip-lint

すると .clj-kondo/com.github.seancorfield/next.jdbc 配下に設定が取り込まれていることが確認できるかと思います。 これらの設定は自動的に読み込まれるので .clj-kondo/config.edn で Hooks の設定を追加する必要はありません。

このように設定が難しい Hooks についてもライブラリ作者が提供さえしてくれていれば簡単に利用することが可能です。 ちなみにライブラリ側で提供している設定の実体は以下のように resources/clj-kondo.exports 配下に置かれたファイルになります。

https://github.com/seancorfield/next-jdbc/tree/develop/resources/clj-kondo.exports/com.github.seancorfield/next.jdbc

CI の設定

ここまででローカルでの clj-kondo のチェックならびに設定変更の方法がある程度わかっていただけたかと思います。 なので次は CI でもローカル同様にチェックできるようにしましょう。

弊社では CI に GitHub Actions を使っているので、GitHub Actions を使った例に限られること予めご承知おきください。

ただやることは至極簡単で .github/workflows/lint.yml を以下の内容で用意するだけです。

name: Lint
on: push

jobs:
  clj_kondo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: DeLaGuardo/setup-clojure@9.2
        with:
          clj-kondo: 'latest'
      - run: clj-kondo --lint src

clj-kondo コマンドのセットアップに使っている DeLaGuardo/setup-clojure は clj-kondo 以外にも Clojure に関するいくつかのコマンドのセットアップに対応しているので何かとお世話になるかと思います。

github.com

番外編: 解析データの利用

clj-kondo はリンターなのでリンターとしてのみ使っている方がほとんどだと思います。 ここでは clj-kondo の大きな強みの1つとして解析データを利用できることを知ってもらえればと思います。 (clojure-lsp はこの解析データを使って様々な機能を提供しているはずで、拙作の vim-iced でもこの解析データを利用した機能を提供しています)

github.com

おもむろに以下のコマンドをプロジェクト配下で実行してみてください。 JSON が大量に出力されたかと思います。これが clj-kondo による解析データです。

$ clj-kondo --lint src:test --config '{:output {:analysis true :format :json}}'

この解析データでは例えば以下ようなことがわかります。(勿論これがすべてではありません)

  • namespace の定義情報(どのファイルでどのnsが定義されているか)
  • namespace の利用情報(どのファイルでどのnsがどのnsをどういうエイリアスで利用しているか)
  • var の定義情報(どのファイルでどのvarがどこに定義されているか)
  • var の利用情報(どのファイルでどのvarがどのvarを利用しているか)

では実用の例として統一されていない namespace エイリアスを検出するためのデータ取得を紹介します。

$ clj-kondo --lint src:test --config '{:output {:analysis true :format :edn}}' \
    | jet -q ':analysis :namespace-usages (map (select-keys [:to :alias])) (filter :alias) (remove (= :alias #jet/lit sut)) distinct' \
    | bb -I '(->> (first *input*) (group-by :to) (filter #(>= (count (second %)) 2)) clojure.pprint/pprint)'

jq のクエリは私には覚えきれなかったので、出力フォーマットは EDN として jet を使って解析データの整形をしています。(Clojurian としてはこちらのクエリの方が覚えやすい) やっていることは :namespace-usages の中からどのnsをどのエイリアスで使っているかの抜き出しが基本です。 sut の除外などはプロジェクトによっては不要だと思うのでクエリは適宜修正してください。

これを実行すると同じ ns 名に異なるエイリアスが2つ以上あるものを列挙してくれます。 今回は無理やりワンライナーにしましたが、出力した JSON/EDN をREPL 上であれこれいじるのも楽しいかと思います。

最後に

いかがだったでしょうか? ここまでの内容を通して最初に書いた以下の文が達成できていたら幸いです。

もし使っていなければ使う手助けに、使っている場合はこんな事もできるかという発見に繋がれば良いなと思います。


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

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

Toyokumoでやったこと・学んだことまとめ

こんにちは、開発本部の石井です。個人的に関心のある分野に転向するために2022年の7月で退職することになったのですが、これは弊社でやったこと・学んだことをまとめておく良いタイミングなのでは?と思い、退職エントリの形でまとめてみることにしました。

やったこと

入社してから現在までに弊社でやったことについてまとめてみたいと思います。

入社時点で、私はWeb技術のことを何も分かっていませんでした。入社時の挨拶で「走りながら学ぶかんじでいきます」などと言ったような記憶があります。結局、製品の全体像を意識しながら開発を進められるようになるまで1年くらいかかりました。それまでの間(そしてその後も)サポートしてくれた方々には頭が上がらないです。

メール認証の導入

kintone連携サービスの管理画面ログインにいわゆるマジックリンクを使用できるようにしました。

アーキテクチャの複数の層にまたがった変更で、かつJWTやメールなどの仕様をきちんと把握することも必要だったりと大変でした。思うように動作しない原因が実はインフラの設定にあると気づかず、1〜2週間くらいリリースを遅らせたのもいい思い出です。Webの技術要素を広く知ることの大切さを実感できた経験でした。

新型コロナウイルスワクチン予約への対応

新型コロナウイルスの流行に伴い、kViewerFormBridgeのワクチン予約用途での利用が急増することがありました。

アクセス規模の大きい利用例であり、予期されるリクエストスパイクに備えた対応が迫られました。と同時に、自分が携わっているサービスが多くの人に利用されていることやステークホルダーの存在を意識する出来事でもありました。適切な人に適切な情報を提供することが如何に重要かということを身を以て学びました。

また、ソフトウェアテストを使用した品質担保の重要性を知ったのもちょうどこの時期でした。

Toyokumo kintoneApp認証

kViewerFormBridgeへのToyokumo kintoneApp認証の導入を行いました。

Toyokumo kintoneApp認証とは、kintone連携製品間をまたがったメールアドレスベースの認証基盤です。この仕組を公開ビューや公開フォームから使えるようにすることで、ビューやフォームの公開先を柔軟に制御することが可能になります。内部的には、複雑なリレーションを含むテーブル設計や多くの実装を必要とする非常に骨のあるリリースでした。幸運にも、私はこのリリースの設計策定段階から参加することができました。設計段階で「SQLアンチパターン」で紹介されているまんまのパターンを踏みそうになって制止が入ったり、実装段階ではフレームワークによる抽象化レイヤーのひとつ下の層にまで潜って問題解決策を探ったりしました。いかにも、自分はソフトウェアエンジニアだなあという気分で仕事をしていた記憶があります。

また、この時期はちょうど新しい開発体制への過渡期でした。タスク管理ツールの導入やデザインチームとの協業体制の策定などが同時に行われ、チーム内外の連携がぎこちなかった印象があります。しかし、完成品はこれらの要素がなければ絶対に叶わなかったクオリティのものとなり、やれば結果になるもんだなあと思いました。

公開ビュー/公開フォームのカスタムURL

公開ビューと公開フォームのURLを、もともとのランダムな文字列から変更できるようにしました。

これまではチームの先輩が決めた方針通りに実装を進めることが多かったのですが、このプロジェクトでは、仕様策定からリリースまでをはじめて自分主導で進めることになりました。ソフトウェアテストやチーム内外の情報共有についてこれまでの経験した学びが活きる場面も多く、私の中でちょっとした記念碑的なプロジェクトになっています。

また、1on1で「この機能に期待してる知り合いがいる」みたいな話を上司から聞いていたのもモチベーションになりました。

学び

在職中の発見や学びは数え切れないほどありますが、その中でも特に覚えておきたいと思ったものを備忘録として残しておきたいと思います。なお、筆者はWebエンジニア歴3年程度の若造ですので、チラ裏程度のつもりでご笑覧ください。

関係者の目線に立つことについて

入社当初、エンジニアとして自分がやるべきことのすべては以下のことだと考えていました。

  • 製品をより便利で喜ばれるものに改善する
  • 製品に関する質問に答える

極端な話「これを改善したら便利になるんだからいいじゃないか」「聞かれたときに回答できたら十分」などと考えているところがありました。振り返って思うに、入社当初の私には、製品に対する変更が製品を使って仕事をする人の活動に影響を及ぼしているという視点が欠けていました。また、製品に関わる人は使用者だけに留まらないこと、たとえば製品の販売・問い合わせへの対応・未来で開発にジョインするなどの関わり方があるということも、あまり理解していませんでした。これらの人の視点では、たとえば以下のようなことがあるかもしれません。

  • ある変更が、その部分を前提とした誰かのワークフローを阻害するかもしれない
  • 実装を見ないと分からない知識が、製品を使う上で生じる問題の解決に役立つかもしれない
  • +αのソースコードコメントが、誰かがその箇所の影響範囲を正確に把握する助けになるかもしれない
  • 仕様や事実よりもその経緯が知りたい場面の方が多いかもしれない

これらをどのような行動につなげるかはおそらくケースバイケースですが、自社のWebサービスをもつ企業で自分が働く上で、財産となる視点を得られた気がしています。

目的をもった勉強について

エンジニアとして働き始める以前の私にとって、技術勉強は自らの好奇心を満足させるためのものでした。しかし、仕事のための勉強の場合はより目的意識をもつことが要求されます。弊社で働く中で、私は以下のような目的で勉強することが有効であることを学びました。

経験を得ること
先人の経験を再利用するための勉強。代表的な例としては、アンチパターンを学んで先人の二の轍を踏まないようにすることなど。

理想像を知ること
たとえば技術設計などにおける理想的な状態を知るための勉強。開発に対して特に主体的だった同僚や先輩は、こうした理想像に関する知識が豊富だった。

自分が"あるある"な失敗をしそうなとき、描いている理想像がズレてるときなどに、関連する技術リファレンス等を紹介してもらえる環境は恵まれていると思います。弊社がそういう環境だったことは、ほぼ未経験だった自分にとってありがたいことでした。

過度な情報共有と注意すべきことについて

入社当初の私のスローガンは「情報過多」でした。これは文字通り「過度に情報を発信しよう」という意識をあらわしていて、その目的は、自分が今何に取り組んでいて、どう対処しようとしていて、何に悩んでいて、どういう発見をしたかということを常にトラッキングできるようすることでした。なぜ過度にやろうと決めたのかというと、経験の浅い新人にとっては報告するべき重要な情報とそうでない情報の見分けがつかないと思ったからです。このアプローチは、たとえば以下のような形で一定の成功をおさめました。

  • 想定していた方針が誤っていたときに指摘してもらえた。おかげで、実行する前に軌道修正できた
  • よい解決策が思いつかなくて困ったときに詳しい人からコメントをもらえた。おかげで問題に対する理解が深まったり問題が解決したりした

この「情報過多」は方針としては悪くなかったと思っているのですが、現在の私は情報の性質を見極めた上で注意して共有を行う方が適切であると考えています。弊社の業務においては、たとえば以下のような分類と注意点を意識しました。

  • notificationを伴うもの
    急いで対応することが求められる情報、あるいは影響範囲が広い情報の共有。このケースでは、他者の作業に割り込んでしまう可能性がある点に注意する。notificationを伴った情報共有を頻繁に行うことは、生産性を重視する集団ではご法度である。

  • notificationを伴わないもの
    誰かがアクセスしたいと思ったときにすぐアクセスできるようにしておく情報の共有。

    • 再利用するもの
      技術的なtipsや経験から獲得した教訓など。知識にあたるもの。このケースでは、「これらの情報を再利用可能な状態にするのは発信者の責任である」という点に注意する。ただメモしただけの情報は再利用されずに捨てられがちなので、たとえばメタデータをつけるなど、再利用のための工夫が必要である。

    • 再利用しないもの
      進捗状況やタスク管理情報など。このケースでは、情報を常に最新に保つよう注意する。発信と受信のタイミングが異なることがほとんどなので、いつ受信されてもよい状態を保つことが重要である。

「当たり前じゃないか」と思う方も多いかと思いますが、実体験をもとにこうした気づきを「言葉」でなく「心」で理解できるようになったのは自分にとって大きな成長でした*1。ひとえに弊社という環境とサポートしてくれた方々の優しさの賜物です。


トヨクモでは一緒に働いてくれる成長に飢えたエンジニアを募集しております。

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

*1:会社の入っている目黒駅atre2ビルの2階にはプロシュート食べ放題のお店があります。

Clojureで作るAPI 設定をednで管理する

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

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

tech.toyokumo.co.jp

この記事ではアプリケーションの設定をClojureのソースではない設定ファイルで管理できるようにしていきます。

なぜ設定ファイルで管理する必要があるのか

実際のアプリケーションでは、開発環境・ステージング環境・本番環境と複数の環境で動くようにしなければいけません。 DBの接続先や外部のAPIキーなど、動作環境によって切り替えたい値は複数あります。

設定値は固定値だけでなく環境変数から読み込んだものであったりするでしょうが、設定値を各所に書き散らかしてしまうと保守性が下がるのでそれらを一箇所にまとめて管理したいです。

今回はそういったことを実現していきます。

aeroを追加

この目的のためにaeroというライブラリがあります。こちらを使っていきましょう。

github.com

まずライブラリを deps.edn に追加します。

;; ./deps.edn
{:paths ["src" "resources"] ;; resourcesを追加
 :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"}
        ;; ライブラリを追加
        aero/aero {:mvn/version "1.1.6"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

前回の記事でJARファイルにビルドできるようにしました。 多くのケースでJARをビルドした環境とそれを実行する環境とは異なるはずです。 ビルドした環境にはあるが実行環境にないファイルをビルドしたJARアプリケーションから参照したい場合は、そのファイルがJARに含まれるようにする必要があります。

:paths にresourcesを追加すると、resources下のファイルがJARに含まれるようになります。 その上で設定ファイルを ./resources/config.edn というパスに配置することで実行環境でも設定ファイルを参照できるようにします。

設定ファイルの追加

./recources/config.edn に設定をaeroの作法に従って書いていきます。

;; ./resources/config.edn
{:server {:opts {:host "localhost"
                 :port #long #profile {:default 5000
                                       :dev 8000}
                 :join? false}}}

ひとまず前回までの記事で立ち上げたWebサーバーの設定だけ追加しています。

ほとんど単なるClojureのMapですが、2つだけタグリテラルを使っています。

#longは基本的に戻り値はStringであるところをlong型でパースして取得することを可能にします。他には #double#keyword などがあります。

#profileはprofileがMapのキーに一致する値を返します。profileはconfig.ednをパースする関数を呼び出すときに引数として与えます。この例だと :profile:dev なら8000、それ以外なら5000ということです。

これ以外にもタグリテラルはあります。ここでは解説しませんが、今後新しいものが登場するたびに説明するのでご安心ください。

設定の読み込み

cljapi.config/read-config に設定を読み込む関数を定義します。

;; ./src/cljapi/config.clj
(ns cljapi.config
  (:require
   [aero.core :as aero]
   [clojure.java.io :as io]))

(defn read-config [profile]
  {:pre [(contains? #{:dev :prod :test} profile)]}
  (-> (io/resource "config.edn")
      (aero/read-config {:profile profile})
      (assoc :profile profile)))

:pre は初登場ですが、これは関数の事前条件をチェックするために使います。上記の例だと引数profileが、:dev, :prod, :testのいずれかでなければ AssertionError 例外が投げられます。

io/resource はクラスパスに含まれるパスの一致するファイルから java.net.URLを取得する関数です。先ほどconfig.ednはresources下に配置し、resourcesをdeps.ednで :paths に指定してあるのでURLが取得できるということです。

REPLで確かめてみましょう。

read-config

Systemとの統合

最後にSystemと統合し、開発環境では :dev profileで、本番環境では :prod profileで起動できるようにします。

まず cljapi.system を次のようにします。

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

(defn- new-system [config]
  (component/system-map
   :handler (c.handler/map->Handler {})
   :server (component/using
            (c.server/map->Jetty9Server (:server config))
            [:handler])))

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

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

これまで c.server/map->Jetty9Server に直接Mapを与えていたところをconfig.ednから読み込んだ設定を使うようにしています。

開発用の user も修正します。

;; ./dev/user.clj
(ns user
  (:require
   [cljapi.system :as system]))

(defonce system (atom nil))

(defn start []
  (reset! system (system/start :dev))) ;; startに :dev を与える修正

(defn stop []
  (when @system
    (reset! system (system/stop @system))))

(defn go []
  (stop)
  (start))

ここまで修正ができたら次の2つを確かめてみましょう。

  • REPLを立ち上げて (go) を評価して curl http://localhost:8000 を実行すると Hello, Clojure API と返ってくる
  • make build して java -jar target/cljapi.jar を実行して curl http://localhost:5000 を実行すると Hello, Clojure API と返ってくる

profileによって異なる設定が反映されていることが確認できました。

おわりに

ここまでで、設定ファイルをソースコードの外に配置し、コンテキストによって異なる設定でアプリケーションを起動できるようになりました。

コードはGitHubのリポジトリに上げてあります。 07_設定をednで管理するというタグからご覧ください。

次はロギングの設定を行なっていきます。


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

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

Clojureで作るAPI ビルドして実行できるようにする

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

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

tech.toyokumo.co.jp

この記事ではビルドしてJARファイルを生成し、 java コマンドでアプリケーションを実行できるようにしていきます。

JARファイルとは

まずJARファイルとは、Javaのライブラリやアプリケーションを配布するためにJavaクラスファイル、メタデータ、リソース(textや画像など)を1つのファイルにまとめた物です。

詳細については英語版のWikipediaをご覧ください。

en.wikipedia.org

JARファイルの他に uberjar という言葉もありますが、これは実行に必要な全てのファイルやデータを含んだJARファイルを指します。 アプリケーションを実行するためには、uberjarであった方が都合がいいため、この先はJARファイルという言葉をubarjarであるという前提のもとで使用します。

tools.buildを使う

この目的のためにtools.buildというClojure公式ライブラリがあります。こちらを使っていきましょう。

github.com

基本的に公式サイトに載っている使い方の通りにやればうまくいきます。

まずライブラリを :build というaliasに追加します。

;; ./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"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

次に build.clj にビルドスクリプトを用意します。

;; ./build.clj
(ns build
  (:require
   [clojure.tools.build.api :as b]))

(def class-dir "target/classes")
(def basis (b/create-basis {:project "deps.edn"}))
(def uber-file "target/cljapi.jar")

(defn uber [_]
  (b/copy-dir {:src-dirs ["src" "resources"]
               :target-dir class-dir})
  (b/compile-clj {:basis basis
                  :src-dirs ["src"]
                  :class-dir class-dir})
  (b/uber {:class-dir class-dir
           :uber-file uber-file
           :basis basis
           :main 'cljapi.core}))

最後にmain関数となる cljapi.core/-main を実行できるようにするために、このnamespaceをクラスとして生成させます。 そのためには :gen-class をつけます。

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

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

ここまで記述できたら clojure -T:build uber を実行すれば実行可能なJARファイルを生成できます。

$ clojure -T:build uber
# targetディレクトリにJARファイルが生成されました。
# 中身を見てみます。
$ cd target
$ tree -L 3
.
├── classes
│   ├── cljapi
│   │   ├── component
│   │   ├── core$_main.class
│   │   ├── core$fn__1158.class
│   │   ├── core$loading__6789__auto____140.class
│   │   ├── core.class
│   │   ├── core.clj
│   │   ├── core__init.class
│   │   ├── system$fn__1153.class
│   │   ├── system$loading__6789__auto____142.class
│   │   ├── system$new_system.class
│   │   ├── system$start.class
│   │   ├── system$stop.class
│   │   ├── system.clj
│   │   └── system__init.class
│   ├── com
│   │   └── stuartsierra
│   └── ring
│       ├── adapter
│       ├── core
│       └── util
└── cljapi.jar

実行して確認してみましょう。

$ java -jar target/cljapi.jar
# 別のterminalでcurlしてみる
$  curl http://localhost:8000
Hello, Clojure API

Makefileに追記

オプションを覚えるということはしたくないので、Makefileにも追記しておきます。

# ./Makefile
.PHONY: format
format:
  cljstyle check

.PHONY: lint
lint:
  clj-kondo --lint src

.PHONY: static-check
static-check: format lint

.PHONY: clean
clean:
  rm -fr target/

.PHONY: build
build: clean
  clojure -T:build uber

おわりに

ここまでで、単一のJARファイルを生成して java コマンドでアプリケーションを起動できるようになりました。

コードはGitHubのリポジトリに上げてあります。 06_ビルドして実行できるようにするというタグからご覧ください。

次は設定を外部ファイルで管理できるようにして、開発環境、ステージング環境、本番環境で値を変えられるようにしていきます。


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

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