Opt Technologies Magazine

オプトテクノロジーズ 公式Webマガジン

Clojureをプロダクトに導入した話

Clojure logo by Tom Hickey and Rich Hickey [Public domain], via Wikimedia Commons

最近、弊社で開発しているプロダクトに初めてプログラミング言語Clojureを導入したため、その詳細をご紹介します。

あいさつ

はじめまして、lagénorhynque (a.k.a. カマイルカ)です。

関数型プログラミング言語(特にClojureHaskell)に興味があり、関連する勉強会によく参加しています。

また最近は、Clojure公式サイトの翻訳プロジェクトjapan-clojurians/clojure-site-jaで公式ドキュメントの日本語化作業にも取り組んでいます。

業務では社内での広告オペレーションを支援するシステムを開発するチームで、アプリケーションエンジニアとして主にサーバサイド、時々フロントエンドの開発を担当しています。

先日、開発しているプロダクトの一部機能に初めてClojureを導入したので、本記事で詳しくご紹介します。

Clojure導入の経緯

プロダクトの要件

現在私が関わっているプロダクトは、社内での広告オペレーションに関するタスクを管理するシステムです。

サーバサイドはPHPで開発していますが、今回新たに、定期的なメール送信やDB一括更新などのバッチ機能をPHPよりも適した言語で開発しようということになりました。

Clojureで実装する意義

弊社ではサーバサイドはScalaまたはJavaで開発しているプロダクトが多いため、まず最初にScala, Javaが候補に上がりました。

しかし、開発するバッチ機能は私が主担当の比較的小規模なもので、技術選定は必ずしも社内ですでに実績のある技術でなくても良いという前提で私に委ねられていたため、せっかくの技術的挑戦が可能な機会なのでClojureを使ってみることにしました。

技術選定に際して、以下のような点でClojureが優れていると考えました。

JVM言語としての実用性

ClojureはScala, Kotlinなどと同様にJVM (Java仮想マシン)で動作する言語で、Javaとの高い相互運用性を念頭に設計された言語です。

そのため、JVM上で安定的かつ高速に動作し、Javaのエコシステムで洗練されてきたライブラリ群を必要に応じて自由に活用することができます。

社内にはScalaやJavaを読み書きできるプログラマが多いので、その点で受け入れられやすい(はず)とも考えました。

シンプルで高い表現力と拡張性

ClojureはLispの方言の一つで、動的型付けの関数型言語です。

シンタックスというシンタックスがほとんどないLispで、強力なライブラリ関数、Lispマクロ、プロトコル、マルチメソッドなどの拡張機能によって、静的なオブジェクト指向言語Javaを土台としながらも簡潔で柔軟なコードを書くことができます。

Lispという点で良くも悪くも括弧の多い(他の言語から見ると明らかに奇妙な)シンタックスが注目されがちですが、言語としてのコアが非常に小さいため覚えるべきルールも少なく、学びやすいというメリットもあります。

インタラクティブでインクリメンタルな開発スタイル

Clojureでは言語の動的な性質をフル活用した「REPL駆動開発」が一般的です。

Clojureに限らずLisp系言語では開発環境としてのREPL (read-eval-print loop; 対話型実行環境)を非常に重視しており、エディタ上でソースコードとREPLをシームレスに連携させて対話的に常に動作するコードを積み上げるように開発していくことができます。

このため、今回のような中小規模のシステムを少人数で素早く改善しながら開発するのに適していると感じています。

Clojure導入の道のり

機能実現のためのライブラリ検討

今回開発するバッチは定期的なメール送信やDB更新が主要な機能となる想定のため、本格的なWebアプリケーション向けの構成は不要でしたが、アプリケーション開発の土台を手早く整えるためにマイクロフレームワークLuminusを採用しました。

最終的に、バッチ機能の実現のために主に以下のライブラリを利用して開発を進めています。

プロジェクト構成: Luminus

LuminusフレームワークはClojureScriptによるフロントエンド開発も含めてWebアプリ開発で必要となる典型的なライブラリが一式そろったプロジェクトテンプレートです。

このテンプレートを元にバッチ開発向けに若干アレンジを加えて、以下のようにプロジェクトを構成してみました。

<project name>
├── README.md
├── env  # 環境別の設定
│   ├── dev  # 開発環境の設定
│   │   ├── clj
│   │   │   ├── <project name>
│   │   │   │   └── env.clj  # 開発環境で利用するコード
│   │   │   └── user.clj  # 開発時にREPLに読み込まれる設定
│   │   └── resources
│   │       ├── config.edn  # 設定ファイル
│   │       └── logback.xml  # ログ出力設定
│   ├── prod  # プロダクション環境の設定
│   │   ├── clj
│   │   │   └── <project name>
│   │   │       └── env.clj  # プロダクション環境で利用するコード
│   │   └── resources
│   │       ├── config.edn  # 設定ファイル
│   │       └── logback.xml  # ログ出力設定
│   ├── stg  # ステージング環境の設定
│   │   ├── clj
│   │   │   └── <project name>
│   │   │       └── env.clj  # ステージング環境で利用するコード
│   │   └── resources
│   │       ├── config.edn  # 設定ファイル
│   │       └── logback.xml  # ログ出力設定
│   └── test  # テスト環境の設定
│       └── resources
│           ├── config.edn  # 設定ファイル
│           └── logback.xml  # ログ出力設定
├── log  # アプリケーションログ (Git管理対象外)
├── profiles.clj  # ローカルの独自環境設定 (Git管理対象外)
├── project.clj  # プロジェクト/ビルド定義
├── resources
│   └── sql  # SQLテンプレート
│       ├── members.sql
│       └── test
│           └── common.sql
├── src  # プロダクトコード
│   └── clj
│       └── <project name>
│           ├── batch  # バッチ関連
│           │   ├── core.clj
│           │   └── mail_sending.clj
│           ├── config.clj  # 設定関連
│           ├── core.clj  # アプリケーションのエントリーポイント
│           ├── db  # DBアクセス関連
│           │   ├── core.clj
│           │   └── members.clj
│           └── util  # ユーティリティ関連
│                ├── constants.clj
│                └── core.clj
└── test  # テストコード
    └── clj
        └── <project name>
            ├── batch
            │   ├── core_test.clj
            │   └── mail_sending_test.clj
            ├── db
            │   ├── core_test.clj
            │   └── members_test.clj
            └── util
                 ├── core_test.clj
                 ├── db_data.clj
                 └── fixture.clj
アプリケーション状態管理: mount

Clojureでは、純粋な関数と副作用のある部分を分離し、また、REPLでの開発を円滑にする目的で、アプリケーション内で状態を持つもの(DBコネクション、Webサーバ、スケジューラなど)のライフサイクルや依存関係を管理するためのライブラリ(一種のDI機構)を利用します。

今回はLuminusフレームワーク付属のmountを選択しましたが、他に、広く使われているcomponent、最近注目されているintegrantなどがあります。

DBアクセス: HugSQL

HugSQLはSQLテンプレートを利用したDBアクセスライブラリです。

Clojureのデータ構造でSQLを表現するDSL方式のHoney SQLなどもありましたが、今回の目的では素のSQLのほうが読み書きしやすいと思われることから、Luminusフレームワーク付属でテンプレート方式のこのライブラリを採用しました。

SQLの一部にClojureコードを埋め込む機能や部分的なSQLをスニペットとして再利用可能にする機能もあり、SQLを柔軟に構築する必要がある場合に利用しています。

ジョブスケジューリング: Immutant

Immutantはサーバサイド向けにメッセージング、キャッシュ、スケジューリングなど複数の機能を提供するライブラリです。

今回はその中で、バッチジョブ実行のためにJavaのジョブスケジューラQuartzベースのimmutant.schedulingを利用しています。

日付時刻: clj-time

clj-timeはJavaの日付時刻ライブラリJoda TimeのClojure向けラッパーです。

開発/実行環境はJava 8以降を想定しているので java.time パッケージの日付時刻APIを直接またはClojureラッパーライブラリ経由で利用するという選択肢もありましたが、デファクトスタンダードで扱いやすいこれを利用してみました。

コード品質を底上げするための+αのライブラリ利用

趣味の個人開発ではなくチームでの開発でコード量も少なくないため、バリデーションや静的解析のためのライブラリ/ツール類も整備しました。

仕様記述/バリデーション: schema

Clojureは動的型付けの言語なので、静的言語のようにコンパイル時に型の不整合を検出することは基本的にできません。

この問題に対して、漸進的型付け(gradual typing)によってコンパイル時に静的な型チェックを可能にするライブラリcore.typedも存在します。

しかし、Clojureコミュニティではむしろ、宣言的な仕様記述で実行時にバリデーションを行う(一種の契約プログラミング)ライブラリが支持されているようで、schemaが広く利用されています。

今後はClojure標準ライブラリとして提供される予定のclojure.specが活用できそうですが、現時点ではα版なのでschemaを使っています。

静的解析: cljfmt, eastwood, kibit

ClojureはS式シンタックスのシンプルな言語ですが、それゆえに自由なレイアウトであらゆるコードを書くことができてしまいます。

そこで、標準的なスタイルに従っているかどうかをチェック/自動修正するフォーマッタ(cljfmt)、バグの疑われる怪しげなコードなどを検出するlintツール(eastwood)、Clojureとしてイディオムに従ったコードかどうかをチェックするツール(kibit)を併用することにしました。

いずれもビルドツールLeiningenのタスクとしてコマンドラインから利用可能で、CIでユニットテストとともに実行するようにしています。

チームメンバーへの情報共有

現在は、趣味や業務の補助として以前からClojureを使っていた私が今回のバッチ機能開発の主担当ですが、今後、開発チームの他のメンバーによるレビューや改修が可能になるように、随時情報共有も進めています。

ドキュメントとしてのコード(+ docstring, schema, ユニットテスト)

過剰にドキュメントを整備するよりも読みやすいコードを書くのが第一ということで、フォーマットが整っていてイディオマティックで適切に命名されたClojureコードを書くように努めていますが、より自己文書的なコードになるように以下の3点を徹底しています。

  1. docstringを書く
  2. schemaを付ける
  3. ユニットテストを書く

例えば、今回開発しているバッチ機能には次のような関数の定義があります。

(ns <project name>.db.members
  (:require [<project name>.util.constants :as const]))

;; とある関数の定義

(defn find-request-member-by-id [req-member-id]
  (select-request-member-by-id
   {:target-div-member (:member const/target-div)
    :req-member-id req-member-id
    :status (:valid const/status)}))

これは、(Clojureコードを見慣れない方のご参考に) Pythonでいう以下のようなコードに相当します。

# Pythonでの同等の関数定義

def find_request_member_by_id(req_member_id):
    return select_request_member_by_id({
        'target_div_member': const.target_div['member'],
        'req_member_id': req_member_id,
        'status': const.status['valid']
    })

ClojureにもPythonなどの言語と同様にdocstring (ドキュメンテーション文字列)を埋め込む機能があります。

これを利用して、関数の高レベルな振る舞いや利用者への注意点を書き記しておきます。

(ns <project name>.db.members
  (:require [<project name>.util.constants :as const]))

;; docstring付きの関数定義

(defn find-request-member-by-id
  "メンバーをID指定で検索する。"
  [req-member-id]
  (select-request-member-by-id
   {:target-div-member (:member const/target-div)
    :req-member-id req-member-id
    :status (:valid const/status)}))

このdocstringの情報は関数のシンボルのメタデータに紐付けられ、以下のようにREPL上から doc コマンドで読むことができます(codoxというツールでHTMLのAPIドキュメントを生成することも可能)。

user> (require '[<project name>.db.members :as members])
nil
user> (doc members/find-request-member-by-id)
-------------------------
<project name>.db.members/find-request-member-by-id
([req-member-id])
  メンバーをID指定で検索する。
nil

さらに、先ほど紹介したschemaライブラリを利用することで、以下のように関数定義で引数・戻り値のスキーマを書くことができます。

(ns <project name>.db.members
  (:require [<project name>.util.constants :as const]
            [schema.core :as s]))

;; docstring + schema付きの関数定義

(s/defn find-request-member-by-id :- (s/maybe {s/Keyword s/Any})
  "メンバーをID指定で検索する。"
  [req-member-id :- s/Num]
  (select-request-member-by-id
   {:target-div-member (:member const/target-div)
    :req-member-id req-member-id
    :status (:valid const/status)}))

ここでは、schemaライブラリが独自に提供する schema.core/defn マクロにより、「数値型の引数 req-member-id を受け取って、キーワードをキー、任意の型を値とするマップまたはnilを返す」という仕様/制約を関数定義に付加しています。

このようにスキーマを記述することで、バリデーションを有効化すれば実行時にスキーマとの不整合を即座に検出できるのはもちろん、コード上でも引数や戻り値の制約条件が明確になります。

最後に、REPLでの動作確認を経て、期待する関数の振る舞いをテストコードに残しておきます。

例えば find-request-member-by-id 関数に対しては以下のようなテストを書いています。

(deftest find-request-member-by-id-test
  (testing "有効なメンバーの情報が取得できる"
    (let [req-member-id 1]
      (is (= {:destinations-id 1
              :target-div 0
              :name "Umi SONODA"
              :mail "u.sonoda@otonokizaka.ac.jp"}
             (members/find-request-member-by-id req-member-id)))))
  (testing "無効なメンバーの情報が取得できない"
    (let [req-member-id 2]
      (is (= nil
             (members/find-request-member-by-id req-member-id)))))
  (testing "存在しないメンバーIDの情報が取得できない"
    (let [req-member-id 100]
      (is (= nil
             (members/find-request-member-by-id req-member-id))))))

ClojureではREPL駆動開発が基本で、仕様記述/バリデーションライブラリと組み合わせるとデータ型の不整合などはかなり早期に検出できることが多い実感がありますが、それでもやはり動的言語なのでユニットテストの重要性は大きく、既存仕様の情報共有や安全な機能追加、リファクタリングのためにも十分にテストコードを整備するようにしています。

ユニットテストの充実度を把握するための目安として、cloverageというテストカバレッジ計測ツールも利用しています(ちなみに現時点のテストカバレッジは約96%)。

その他のドキュメント

バッチ機能の概要説明やプロジェクト構成、環境構築方法、実行方法などをプロジェクトのREADMEやWikiに書きまとめています。

また、Clojure未経験者向けにClojureの各種ドキュメントへのリンク集も準備しています。

勉強会

現状の開発チームには私以外にClojureが普通に読み書きできるメンバーがいないため、チームメンバー向けのClojure勉強会を企画しています。

社内にはScalaのほかHaskell, Elixir, Rust, OCamlなど関数型言語/関数型プログラミングが好きなエンジニアも少なくないので、今後"Clojurian"(Clojureプログラマ)が社内で私の他にも増えていくと心強いと思うところです。

Clojureを導入してみて

Clojureをプロダクトのバッチ機能開発に利用し始めて1か月ほどが経ちました。

ここまでの開発体験を振り返ってみて、Clojureでの開発の良いところ、つらいところを簡単にまとめてみます。

Clojureでの開発の良いところ

1. コードを書きながらシームレスにREPLでサクサク試せる(REPL駆動開発)

私自身は今回のプロダクトにClojureを導入する以前からClojurian/Lisperでしたが、改めてREPLベースの開発スタイルの快適さを実感しています。

REPLからほとんど全てのものにアクセスでき、ソースコードとスムーズに繋がっているので、単なるちょっとした実験環境ではなくメインの開発環境、コードを組み上げ、分解し、さらに組み上げる場としてのREPLの有用性を再認識しました(REPLのないClojure開発なんて考えられない!)。

私は普段、SpacemacsClojure layerCIDERプラグインを利用してClojureを書いていますが、他の環境でもREPL連携機能が充実していれば十分快適に開発できる気がします。

2. 標準ライブラリの関数/マクロで簡潔なコードが書ける

Clojureの標準ライブラリ関数/マクロには非常に強力なものが多く、数個の組み合わせで高レベルで高密度な(目的を端的に表現する)コードが書けます。

関数型プログラミングでお馴染みの高階関数 map, reduce などはもちろん、他の多くの言語であれば制御構文として言語組み込みで提供される条件分岐や繰り返しのシンタックスがマクロとしてバリエーション豊かに提供されています。

例えば、条件分岐に関して if-let, if-not, if-some, when, when-let, when-not, when-some, cond, condp, case, cond->, cond->>, etc.

選択肢が多いため、自由に使いこなすにはどのタイミングでどれを使うのがスマートかを学んでいく必要はありますが、Lispマクロは言わば外すこと(desugar)ができるシンタックスシュガーなので macroexpand などの関数を利用してより冗長な(マクロ展開後の)形式を確認することも簡単にできます。

例えば、 if-let を利用した以下の式は

user> (if-let [x 2]
        (* x x)
        -1)
4
user> (if-let [x nil]
        (* x x)
        -1)
-1

(if-let [<束縛フォーム> <条件式>] <trueの場合> <falseの場合>) という形式で、 <条件式> がtruthy (false, nil 以外)と評価されればその値を <束縛フォーム> に束縛して <trueの場合> を評価します。

これを macroexpand でマクロ展開してみると、

user> (macroexpand
       '(if-let [x 2]
          (* x x)
          -1))
(let* [temp__4655__auto__ 2]
  (if temp__4655__auto__
    (clojure.core/let [x temp__4655__auto__]
      (* x x))
    -1))

let (let*)と if の単純だけどよくある組み合わせに帰着することが分かります。

if-let は、値を変数に取って、truthyな場合だけその値を使って本体の処理を行うというパターン(素朴に書けば以下のようなコード)を簡潔に書けるようにしてくれるマクロです。

user> (let [x 2]
        (if x
          (* x x)
          -1))
4
3. 実用的なサードパーティライブラリが多い

Clojureは今年で10年目を迎えたまだまだ新興言語ですが、実用上必要になる機能の多くはすでにClojureライブラリが存在し、もしもなければJavaライブラリを直接利用することができるので、たいていの目的でライブラリがなくて困るということはなさそうです。

既存のライブラリの機能がもの足りなければ利用する側で拡張することも簡単にでき、提供されているAPIが使いづらければ関数/マクロでインターフェースを整えることもできます。

今回の開発では利用していませんが、最近は(JVMではなくJavaScriptの上で動作する) ClojureScriptについてもコミュニティの盛り上がりを感じます。

Clojureでの開発のつらいところ

1. エラーメッセージが分かりづらい

Clojureで開発していて、特にREPL駆動でサクサク快適に開発していて、何より気になるのはエラー発生時のメッセージの分かりづらさです。

ちょっとした引数の渡し間違えでもREPLで素早くコンパイル時または実行時にエラーになるのは非常にありがたいことですが、急に大量のJavaのスタックトレースとともにどちらかというと実装寄りなエラー情報が表示されると、圧倒されてしまうことがあります。

私自身はClojureを書き始める以前にJavaを書いていた期間も長いためJava特有の例外/エラーも見慣れていましたが、Javaに不慣れな人はもちろん、Javaの経験はあってもClojureの実装レベルの多少の知識がないと、エラーメッセージの意味を把握して原因を特定するのに苦戦する可能性があります。

今回の開発で利用しているschemaライブラリや今後広く使われていくと思われるclojure.specによって、関数の事前条件や事後条件、データ構造の形式などが定義されていれば、それに対する違反も比較的分かりやすくエラー出力されるので、エラーメッセージの分かりづらさも今後改善されていくことを期待しています。

2. ライブラリのドキュメントは必ずしも充実していない

Clojureは若い言語とはいえライブラリが充実していて、コミュニティが活発な印象があります。

しかし、OSSである以上やむを得ない面もありますが、ライブラリのドキュメントは必ずしも整備されていない傾向を感じます。

したがって多くの場合、ライブラリの詳細が気になったら実際のコードを読む必要があります(私自身、日常的に標準ライブラリやサードパーティライブラリのコードをREPLから読んでいます)。

もともとドキュメントが多くはない上に、Clojure公式ドキュメントも含めて日本語化されているドキュメントはほとんどないので、英語が苦手だったりClojureを始めたばかりだったりすると少々厳しいかもしれません(公式ドキュメントの翻訳作業は鋭意進行中です!)。

3. Lispの読み書きに慣れるまでが大変(に見える)

最後にしてある意味最大の問題は、やはり良くも悪くもLispだということでしょう。

私自身はClojureを含むLisp族の言語が好きで、再帰的なS式のコードを読み書きすること自体が楽しく感じるほどですが、Lispのコードを本格的に読み書きしたことがないと、慣れるまでの心理的ハードルは高そうです。

Lispのコードを読み書きする上で特に重要だと思う点を挙げると以下のようになります。

  • 個々の括弧の対応関係よりもインデントで読む(だからこそコードフォーマットが重要)

    • Lispコードを読むとき、注目している箇所の一番内側の開き括弧と閉じ括弧の組と、その領域の改行位置やインデントの深さを目印にコードを読むので、全ての括弧がどこで開いてどこで閉じているかを数えるように把握する必要はない
  • ParEdit, ParinferなどのLisp編集プラグインの操作に慣れる(素のエディタでの編集はちょっと大変)

    • どんなエディタでもLispコードを編集することは可能だが、ParEditまたはParinferといった専用プラグインを入れてその操作に慣れれば、括弧の単位で非常に効率良く編集できるようになる

ちなみに、Clojureは従来のLisp方言よりも必要な括弧の数が少なくなるように意図的に設計されており、

  • let, cond などで列挙する個々のグループを括弧で囲まない

  • ->, ->> などの括弧のネストを減らして読みやすくするマクロを多用する

  • Listの '(1 2 3) 、Vectorの [1 2 3] 、Setの #{1 2 3} 、Map の {:a 1 :b 2 :c 3} など括弧を使い分ける

ことから、(信じられないとは思いますが)思ったほど丸括弧だらけにはなりません。

まとめ

以上、初めてプロダクトにClojureを導入した経緯から実際にClojureで開発してみた感想までご紹介しました。

Clojureは日本国内ではまだ本格的な採用事例が少ないようですが、世界的なコミュニティがあり着実に発展を続けている非常に実用的な言語です。

近々、Programming Clojure, Third Edition (『プログラミングClojure 第2版』の原書最新版)も発売されるようで、個人的にとても楽しみです。

今回はClojureをプロダクト開発に導入する初めての事例でしたが、今後も有用なタイミングがあれば技術的な選択肢の一つとしたいと考えています。

ちなみに、今月9/22(金)には弊社主催の勉強会「市ヶ谷Geek★Night#14 市ヶ谷java 〜JVM言語の玉手箱〜」を開催します。

弊社のプロダクト開発で活用しているJVM言語Java, Scala, Clojureを取り上げ、私自身もClojureコミュニティで注目されている新機能clojure.specについて発表を行う予定です。

最後に、Opt Technologiesでは、新規技術も活用しながら価値あるプロダクトを生み出し育てていきたいというエンジニアを募集しています。

興味をお持ちの方はぜひこちらからご応募ください。

※カジュアル面談ご希望の方は、補足欄に「カジュアル面談希望」とご記載ください