https://tech.redplanetlabs.com/2021/06/03/tour-of-our-250k-line-clojure-codebase/?utm_source=rss&utm_medium=rss&utm_campaign=tour-of-our-250k-line-clojure-codebase

25万行のClojureコードベースのツアー

2021年6月3日~ ネイサン・マーズ

レッド・プラネット・ラボでは、何年も前から新しいタイプの開発者ツールを静かに開発しています。私たちのツールは、大規模なエンド・ツー・エンドのアプリケーションを構築するためのコストを何桁も削減してくれます。

私たちのコードベースは、25万行のClojureで構成されており、ソースコードとテストコードに均等に分けられています。これは、世界最大のClojureコードベースの1つです。この記事では、このような規模のプロジェクトをチーム内で理解できるようにコードを整理する方法、Clojureのユニークな特性を活用した開発およびテストのテクニック、使用している主要なライブラリの概要をご紹介します。

言語の中のカスタム言語

私たちのコードベースの最もクールな部分の一つは、その基盤となる新しい汎用言語です。この言語のセマンティクスはClojureとは大幅に異なりますが、異なる動作を表現するためにマクロを使用して完全にClojure内で定義されています。また、ASMライブラリを使用してバイトコードに直接コンパイルします。システムの残りの部分は、この言語とバニラClojureの両方を使って構築され、シームレスに相互運用されています。

バニラClojureにはない、この言語の特徴的な機能の1つがファーストクラスの継続です。この言語の継続の表現方法は、非同期、並列、リアクティブ・プログラミングに非常に適しています。これらはすべて、私たちが構築している大規模な分散インフラの基礎となるものです。

Clojureで全く異なるセマンティクスを持つ新しい言語を構築できるということは、Clojureがいかに強力であるかを示しています。レキシング、パーシング、データタイプ、名前空間、不変のデータ構造、そしてClojureとJVMのライブラリエコシステム全体など、この方法で言語を構築すると「無料で」得られるものがたくさんあります。最終的に、私たちの新しい言語はClojureの中で定義されているので、ClojureとJVMの両方とのシームレスな相互運用性の恩恵を受けることができます。

大多数のアプリケーションでは、私たちのような完全な言語を開発する必要はないでしょう。しかし、焦点を絞ったDSLが適している使用例はたくさんあり、その例もあります。Clojureでは、マクロやメタプログラミングによってコードの解釈方法をカスタマイズすることができますが、これは非常に強力な機能です。

型/スキーマチェック

どんなコードベースでも、作成、管理、操作されるデータが中心となります。システム内を飛び交うデータを慎重かつ明確にドキュメント化することが必要です。同時に、型やスキーマのアノテーションはオーバーヘッドを増やしてしまうので、やりすぎないように慎重に行うことが重要です。

私たちはコードベース内でデータ型を定義するために、Schemaライブラリを使用しています。使いやすく、任意の述語や列挙型、unionなど、型以外のスキーマ制約を柔軟に定義できるのが気に入っています。私たちのコードベースには約600の型定義があり、そのほとんどがSchemaを使ってアノテーションされています。

Schemaの周辺にはdefrecord+というヘルパーがあり、検証も行うコンストラクタ関数を定義しています(例えばFoo型の場合、”->valid-Foo “と “map->valid-Foo “を生成します)。これらの関数は、スキーマチェックに失敗した場合、記述的な例外を投げます。

Clojureには静的な型チェックはありません。また、静的な型チェックでは、スキーマを使用して定義するすべての種類の制約をチェックすることはできません(例:数値の値が特定の範囲内にあること)。私たちは、どちらかにスキーマチェックを挿入するだけでよいことがわかりました。

  • 型の構築では、自動生成された「有効な」コンストラクタ関数がすべてのセレモニーを取り除きます。レコードを作成するときにエラーを検出することは、後で使用するときよりもはるかに優れています。作成時には、問題をデバッグするために必要なコンテキストがあるからです。
  • コードベースの中には、様々な種類のものが流れる戦略的な場所がいくつかあります。

関数の引数や戻り値の型については、たまに注釈をつける程度です。その代わり、コードを理解するためには、名前の付け方に一貫性があれば十分だと考えています。コードベースには約500のアサーションが含まれていますが、これらは単純な型チェックではなく、より高レベルのプロパティに関するものがほとんどです。

私たちがスキーマの定義と実施のためにとったアプローチは、軽量で包括的であり、邪魔になることはありません。Clojureに静的な型付けがないことは、Clojureを使ったことのない多くのプログラマーを不安にさせますが、私たちが言えることは、コードをどのように構成するかを少し考えれば、まったく問題にならないということです。そして、物事を動的に行うことは、静的な型システムでは不可能な強い制約を課すことができることを意味します。

マルチモジュールのリポジトリ設定

私たちのコードベースは1つのgit repoに存在し、4つのモジュールで実装を分割しています。

  • “core”は、コンパイラの定義と、それに対応する並列プログラミングの抽象化を含んでいます。
  • “distributed”は、これらの並列プログラミングの抽象化を分散クラスタとして実装します。
  • “rpl-specter”は、Specterの内部フォークで、大量の機能を追加しています。
  • “webui”: 製品のフロントエンドを実装しています。

ビルドにはLeiningendeps.ednを使用しています。deps.ednファイルでローカルターゲットを依存関係にあるものとして指定する機能は、私たちのマルチモジュールのセットアップにとって重要であり、ソースツリーの基本的な構成は次のようになっています。

project.clj
deps.edn
rpl-specter/project.clj
rpl-specter/deps.edn
core/project.clj
core/deps.edn
distributed/project.clj
distributed/deps.edn
webui/project.clj
webui/deps.edn

deps.ednファイルから「distributed」の部分を抜粋してみました。

{:deps {rpl/core {:local/root "../core"
                  :deps/manifest :deps}
        ...
        }
  ...
  }

この設定により、いずれかのモジュール内で開発を行い、明示的な Maven の依存関係を作らなくても、他のモジュールのソース変更を自動的に確認することができます。

テストの実行や REPL のロードのためにコードベース全体をロードするのは非常に時間がかかるため(主にカスタム言語を使用したコードのコンパイルが原因)、AOT コンパイルを多用して開発を高速化しています。私たちはほとんどの時間を「distributed」で開発しているので、「core」をAOTコンパイルしてスピードアップしています。

Specterによるポリモーフィック・データ

Specterは、データ構造、特に入れ子や再帰的なデータを扱う能力を強化するために開発されたライブラリです。Specterは、データ構造への「パス」の概念に基づいています。パスは、データ構造のルートから任意の数の値に「ナビゲート」することができます。パスにはトラバース、ビュー、フィルタなどが含まれ、それらは深く合成可能です。

コンパイラはコードを抽象的な表現にコンパイルし、言語で可能な操作の種類ごとに明確なレコードタイプを持っています。すべての操作タイプには、統一された方法で公開しなければならないさまざまな属性があります。例えば、これらの属性の1つに「必要なフィールド」があります。これは、その操作のクロージャ内にあるフィールドで、仕事をするのに必要なものです。このポリモーフィックな動作を表現する典型的な方法は、次のようなインターフェースやプロトコルを使うことです。

(defprotocol NeededFields
  (needed-fields [this]))

このアプローチの問題点は、クエリのみをカバーしていることです。コンパイラのいくつかのフェーズでは、抽象表現全体のフィールドを書き換えなければならず(例えば、シャドウイングを除去するために変数を一意にするなど)、このプロトコルはそれをサポートしていません。このプロトコルに(set-need-fields [this fields] )メソッドを追加することもできますが、固定数の入力フィールドを持つデータ型にはきれいに適合しません。また、ネストされた操作に対してもうまく構成できません。

その代わりに、Specterの「プロトコルパス」機能を使って、様々なコンパイラタイプの共通属性を整理しています。ここでは、私たちのコンパイラの一部を紹介します。

(defprotocolpath NeededFields [])

(defrecord+ OperationInput
  [fields :- [(s/pred opvar?)]
   apply? :- Boolean
   ])

(defrecord+ Invoke
  [op    :- (s/cond-pre (s/pred opvar?) IFn RFn)
   input :- OperationInput])

(extend-protocolpath NeededFields Invoke
  (multi-path [:op opvar?] [:input :fields ALL]))

(defrecord+ VarAnnotation
  [var :- (s/pred opvar?)
   options :- {s/Keyword Object}])

(extend-protocolpath NeededFields VarAnnotation
  :var)

(defrecord+ Producer
  [producer :- (s/cond-pre (s/pred opvar?) PFn)])

(extend-protocolpath NeededFields Producer
  [:producer opvar?])

例えば、”Invoke”は、他の関数を呼び出すことを表す型です。:op フィールドには、静的な関数や、クロージャ内の関数への var 参照を指定することができます。他のパスは、関数呼び出しの引数として使用されるすべてのフィールドに移動します。

この構造は非常に柔軟で、Specterと直接統合することで、クエリと同じように簡単に修正を表現することができます。例えば、次のような一連の操作で、必要なフィールドすべてに「-foo」というサフィックスを付加することができます。

(setval [ALL NeededFields NAME END] "-foo" ops)

一連の動作で使用されるユニークなフィールドのセットを求める場合、コードは

(set (select [ALL NeededFields] ops))

プロトコルパスは、データ自体を多義的にして、Specterの高度な能力と統合できるようにする方法です。これにより、他の方法では必要となる操作用のヘルパー関数の数が大幅に減り、コードベースがはるかに理解しやすくなります。

Componentによる複雑なサブシステムの構築

私たちが構築している分散システムを構成するデーモンは、何十ものサブシステムで構成されており、それらはお互いに依存しながら構築されています。サブシステムは特定の順番で起動する必要があり、テストでは特定の順番で停止させる必要があります。また、テストでは、一部のサブシステムにモックを注入したり、一部のサブシステムを完全に無効にしたりする機能が必要です。

私たちはComponentライブラリを使用して、ライフサイクルを管理し、代替の依存関係を注入したり、サブシステムを無効にしたりする柔軟性を備えた方法でサブシステムを構成しています。内部的には、「defrcomponent」ヘルパーを構築して、フィールドと依存関係の宣言を統一しています。例えば、私たちのコードベースから

(defrcomponent AdminUiWebserver
  {:init      [port]
   :deps      [metastore
               service-handler
               cluster-retriever]
   :generated [^org.eclipse.jetty.server.Server jetty-instance]}

  component/Lifecycle
  ...
  )

これは、起動したシステムマップからフィールド “metastore”、”service-handler”、”cluster-retriever “を自動的に取得し、コンポーネントの実装のクロージャーで利用できるようにします。コンポーネントのコンストラクタでは1つのフィールド “port “を期待し、起動時には別のフィールド “jetty-instance “を内部のクロージャに生成します。

また、”start-async “と “stop-async “というプロトコル・メソッドでコンポーネント・ライフサイクル・パラダイムを拡張しました。コンポーネントの中には、初期化や廃棄の一部を他のスレッドで行うものがありますが、システムの残りの部分(特に後述する決定論的シミュレーション)にとって、これらをノンブロッキングで実行できることが重要でした。

私たちのテストインフラは、依存性注入を行うためにComponentをベースにしています。例えば、私たちのテストコードを見てみましょう。

(sc/with-simulated-cluster
  [{:ticker (rcomponent/noop-component)}
   {:keys [cluster-manager
           executor-service-factory
           metastore]
    :as   full-system}]
  ...
  )

最初のマップは依存性注入マップで、このコードでは「ティッカー」コンポーネントを無効にしています。「ティッカー」は、シミュレーションテストで時々時間を進める原因となりますが、このテストでは時間を明示的にコントロールしたいので、これを無効にしています。この依存性注入マップは、システム内のあらゆるコンポーネントを上書きしたり無効にしたりするために使用することができ、テストを書くのに必要な柔軟性を提供します。

with-redefsを使ったテスト

Clojureには、”with-redefs “というマクロがあり、そのフォームのスコープ内で実行される関数を、他のスレッドも含めて再定義することができます。これはテストを書く上で非常に重要な機能であることがわかりました。

with-redefsを使って、テスト対象の依存関係にある特定の動作をモックし、その機能を分離してテストできるようにすることもあります。また、フォールトトレランスをテストするために障害を注入するためにも使用します。

私たちのコードベースで最も興味深いwith-redefの使い方は、ソースコードに挿入するno-op関数と一緒に使用することです。これらの関数は効果的に構造化されたイベントログを提供し、テストが何に興味を持っているかに応じて動的にアラカルトの方法で利用することができます。

このパターンをどのように利用しているか、私たちのコードベースにある何百もの例の中から一つを紹介します。私たちのシステムのある部分では、ユーザーが指定した作業を分散して実行し、次のことを行う必要があります。1) 作業に失敗したら再試行し、2) 閾値を超えて作業が成功したら、その進捗を耐久性のある複製されたストアにチェックポイントする。このテストの1つは、作業が最初に試みられたときに障害を発生させ、システムが作業を再試行することを検証します。

作業を実行するソース関数は “process-data!”と呼ばれ、以下はその関数からの抜粋です。

(when (and success? retry?)
  (retry-succeeded)
  (inform-of-progress! manager))

“retry-succeeded “は、(defn retry-succeeded [] )のように定義されたno-op関数です。

また、”checkpoint-state!”という全く別の関数では、進捗情報の複製とディスクへの書き込みが終了した後に、no-op関数である “durable-state-checkpointed”が呼び出されます。私たちのテストコードでは

(deftest retry-user-work-simulated-integration-test
  (let [checkpoints     (volatile! 0)
        retry-successes (volatile! 0)]
    (with-redefs [manager/durable-state-checkpointed
                  (fn [] (vswap! checkpoints inc))

                  manager/retry-succeeded
                  (fn [] (vswap! retry-successes inc))]
      ...
      )))

そして、テストの本文では、正しい内部イベントが正しいタイミングで発生しているかどうかをチェックします。

何よりも、このアラカルトイベントログアプローチはno-op関数に基づいているため、コードが実稼働する際のオーバーヘッドは基本的に発生しません。このアプローチは、Clojureのデザインをユニークな方法で活用した、非常に強力なテスト手法であることがわかりました。

マクロの使用

私たちのコードベースには約400のマクロが定義されていますが、そのうちの70%はソースコードの一部で、30%はテストコード専用です。関数が使えるのにマクロを使うな」という、マクロに関する一般的なアドバイスは、賢明な指針であることがわかりました。通常の関数ではできないことをするマクロが400個もあるということは、強力なマクロシステムを持たない一般的な言語でできることをはるかに超えた抽象化を行っていることを示しています。

私たちのマクロのうち約100個は、最初にリソースを開き、フォームが終了するときにリソースを確実にクリーンアップする、シンプルな「with」スタイルのマクロです。これらのマクロは、ファイルのライフサイクルの管理、ログレベルの管理、設定のスコープ、複雑なシステムのライフサイクルの管理などに使用しています。

約60個のマクロがカスタム言語の抽象化を定義しています。これらすべてにおいて、フォームの解釈はバニラClojureとは異なります。

マクロの多くはユーティリティーマクロで、例えば “letlocals “は変数の束縛と副作用をより簡単に混ぜ合わせることができます。テストコードでは以下のように多用しています。

(letlocals
  (bind a (mk-a-thing))
  (do-something! a)
  (bind b (mk-another-thing))
  (is (= (foo b) (bar a))))

このコードは以下のように展開されます

(let [a (mk-a-thing)
      _ (do-something! a)
      b (mk-another-thing)]
  (is (= (foo b) (bar a))))

残りのマクロは、私たちが構築したステートマシンDSLのような内部の抽象化と、他では取り除くことのできないコードの重複をマクロが取り除くような、さまざまな特異な実装の詳細が混在しています。

マクロは言語機能のひとつで、悪用されるとひどく混乱したコードになりますし、活用されると素晴らしくエレガントなコードになります。ソフトウェア開発では何でもそうですが、最終的に得られる結果は、それを使う人のスキルによって決まります。レッド・プラネット・ラボでは、ツールボックスにマクロが入っていないソフトウェア・システムの構築は考えられません。

決定論的シミュレーション

前回書いたように、システム全体を単一のスレッドで実行し、ランダムなシードから始まるイベントを実行するエンティティの順序をランダムにすることで、100%再現可能な分散システムテストを書くことができる能力があります。シミュレーションは、前述の依存性注入と再定義の技術を多用した、コードベースにまたがる主要な機能です。例えば。

  • 本番ではユニークなスレッドとなるシステムのあらゆる部分が、実行者サービスの観点からコード化されています。システムの特定の部分のエクゼキュータ・サービスを取得するために、「エクゼキュータ・サービス・ファクトリー」に要求します。本番環境では、これによって新しいスレッドが返されます。しかしシミュレーションでは、このコンポーネントをオーバーライドして、シングルスレッドでグローバルに管理されたソースからエクゼキュータサービスを提供しています。
  • 私たちのシステムの多くは時間に依存しているため(タイムアウトなど)、時間は実装から抽象化されています。時間に関心のあるシステムの一部は、「時間ソース」の依存関係を参照します。本番環境ではこれはシステムクロックですが、シミュレーションではこのコンポーネントは「シミュレートされた時間源」で上書きされ、シミュレーションテストで明示的に制御することができます。
  • プロミスは、非同期でノンブロッキングな動作を管理するために、コードベース全体でかなり使用されています。シミュレーションでは、with-redefsを使用して、シミュレーションを実行するのに便利な機能をプロミスに追加しています。

フロントエンド

私たちの製品は、ユーザーがクラスタ上で何を実行しているか、スケーリングなどのオペレーションの現在のステータス、アプリケーションで何が起こっているかを示すテレメトリーを確認できるUIを提供します。

フロントエンドは、ClojureScriptでコーディングされたWebベースのシングルページアプリです。ClojureScriptのエコシステムには、開発を効率的で楽しいものにする、成熟した設計のライブラリがたくさんあります。

これらのライブラリとその利点を見ていくと、それだけでブログの記事になってしまいますが、簡単に説明すると、データ指向の状態管理とイベント処理モデルが推論しやすく、検査しやすいことから、re-frameを使用しています。reititはフロントエンドのルーティングに使用しています。そのデータ指向のデザインにより、任意のデータを各ルートに関連付けることができ、ルートの変更時にre-frameイベントをディスパッチするようなきちんとしたことができる点が気に入っています。プロジェクトのコンパイルには shadow-cljs を使用しています。これは、JavaScript ライブラリの使用や外部関数の処理を劇的に簡素化できることが理由のひとつです。

時系列データの表示にはuPlotを使用しています。APIバックエンドにはJettyサーバーを使用し、バックエンドのルート定義にはCompojureを使用しています。

フロントエンドを他のコードベースと同じ言語で定義したことは、特にClojureとClojureScriptの間でデータを簡単に行き来できるようになったことが大きな収穫です。Clojureで強調されている不変的なスタイルは、バックエンドのコードと同様にフロントエンドのコードでも有益であり、それを一貫して活用できることは、私たちの生産性と製品の堅牢性に大きなメリットをもたらします。

ライブラリ

ここでは、Clojure、ClojureScript、Java、Javascriptのライブラリを混合して、私たちのコードベースで使用している外部ライブラリの多くを紹介します。

  • ASM: バイトコードの生成に使用
  • Compojure: Webサーバのルート定義に使用しています。
  • Component: ライフサイクルが明確なサブシステムの定義に使用される
  • Jetty:フロントエンドへのデータ提供に使用
  • Loom: グラフのデータ構造を表現するために使われる。
  • Netty: 非同期のネットワーク通信に使用
  • Nippy:シリアライズに使用しています。
  • Potemkin: “import-namespace”、”import-vars”、”def-map-type “などのいくつかのユーティリティを使用しています。
  • reit:フロントエンドのルーティングに使用
  • re-frame: ウェブコードの構築に使用しています。
  • RocksDB: 耐久性のあるインデックスを作成するために使用しています。
  • Schema: リッチなスキーマで型を定義するのに使われる
  • shadow-cljs: フロントエンドのコードをコンパイルするために使用
  • SnakeYAML: YAMLの解析に使われる
  • Thrift: 製品のCLIの一部に使用されています。
  • uPlot: フロントエンドで時系列グラフを表示するために使用される

まとめ

Clojureは私たちの製品を開発する上で素晴らしいものでした。他の言語では不可能な強力な抽象化を構築し、一切の儀式を排除し、強力なテスト技術を利用することができました。さらに、私たちのチームには、Clojureや関数型プログラミングの経験がない複数のメンバーがいますが、彼らはすぐにスピードアップすることができました。

私たちと一緒にソフトウェア開発の未来を切り開いていくことに興味のある方は、ぜひご応募ください。私たちは、コンパイラ、データベース、分散システムの可能性を押し広げる難しい問題に取り組んでいます。私たちのチームは完全に分散しており、世界のどこでも採用することができます。

www.DeepL.com/Translator(無料版)で翻訳しました。