Nix lnaugage basicsを読んだメモ

最近、パッケージ管理やOSの設定を宣言的にやりたいという気持ちが湧いてきてNixに入門しドキュメントを読んでいたが、やはりサンプルコードに何が書いてあるのかわからないとあまりモチベーションがわかない。 まだNixに登場する概念をほとんどカバーできていないが、先にNix言語から勉強することにした。 多くのドキュメントでNix language basicsが勧められていたのでこれを読んでみることにした。

introduction

Nix言語は、既存のファイルの内容が新しいファイルを導出するのにどのように使われるかを簡単に記述できるよう設計されている。

Nix言語で書かれたファイルは非常に難解に見えるが、それはNix言語はソフトウェアの構築という複雑な問題を解決するのに使われているためであり、Nix言語自体には自在に組み合わせられるわずかなコンセプトが存在するのみだ。 Nix言語のファイルに見られる複雑さはNix言語から来るのではなく、その用途から来るものである。

How to run the examples?

Nix言語のコードの一部分をNix式(Nix expression)と呼ぶ Nix式を評価すると得られる値をNix値(Nix value)と呼ぶ .nixファイルの中身はNix式である

Nix式を評価するというのは、Nix式を言語のルールに則ってNix値に変化させることを言う。

Interactive evaluation

REPLはnix replで起動できる。 Nix言語は遅延評価を採用しているが、このREPLもデフォルトでは必要になるまで式を評価しない。 式を最後まで評価して得られる値が見たい場合、:p expressionとすればよい。

nix-repl> { a.b.c = 1; }
{
  a = { ... };
}

nix-repl> :p { a.b.c = 1; }
{
  a = {
    b = { c = 1; };
  };
}

:qでREPLを終了する。

Evaluating Nix files

.nixファイルに書き込んだ式を評価するにはnix-instantiate --eval example.nixとする。 nix-instantiateは本来はNix式からstore derivationsと呼ばれるものを生成するコマンド。 store derivationsは簡単に言うと、引数などが厳密に指定されたビルド手順のレシピのようだが、今回はNix言語の理解に集中したいため後回しにする。

--evalオプションをつけるとただファイルの中のNix式を評価して得られるNix値を標準出力に出すだけで、store derivationsのinstantiation(これも現時点では不明の概念)まではやらない。

nix-instantiateにファイル名を渡さない場合はdefault.nixというファイルを読み込もうとする。

nix-instantiateも必要になるまで式を評価してくれないので、完全に評価されたNix値を見たい場合は--strictオプションも付ける。

$ echo "{ a.b.c = 1; }" > file.nix
$ nix-instantiate --eval file.nix
{ a = <CODE>; }
$ nix-instantiate --eval --strict file.nix
{ a = { b = { c = 1; }; }; }

Names and values

Nix言語における値にはプリミティブなデータ型、リスト、attribute sets、関数が存在する。

attribute setsは名前と値のペアを集めたもので、JSONと似ている。 ただし、JSONと違って関数を値として持つことができる。 たとえば、以下はプリミティブ型、リスト、attribute setsを値として持つattribute sets。

{
  string = "hello";
  integer = 1;
  float = 3.141;
  bool = true;
  null = null;
  list = [ 1 "two" false ];
  attribute-set = {
    a = "hello";
    b = 2;
    c = 2.718;
    d = false;
  };
}

name = value;という形で値に名前が割り当てられる。 他の言語で言うところの変数の束縛だと思われるが、ドキュメントではVariableという言葉が登場せずNameと書いてある。

また、見てわかるとおりリストの要素はスペース区切り。

Recursive attribute set rec { ... }

先頭にrecを付けて宣言されたattribute setsの中では、式の中でそれまでに値に付けた名前を使うことができる。

rec {
  one = 1;
  two = one + 1;
  three = two + 1;
}
nix-repl> :p rec { one = 1; two = one + 1; three = two + 1; }
{
  one = 1;
  three = 3;
  two = 2;
}

attribute setの要素は好きな順に宣言して問題なく、評価時に順序が決まる。

わざわざrecを付けないとこうできないようにした理由が何かあってこの先明らかになるのだろうか。 気になるところ。

let ... in ...

letとinの間で値に名前を付け、inの後にその名前を使って組み立てた式を評価した値が返される。

let
  a = 1;
in
  a + a
nix-repl> let a = 1; in a + a
2

Haskellのlet式と同じっぽい...と思ったら値に名前を付ける順番は好きにやっていいらしい。

nix-repl> let b = a + 1; a = 1; in a + b
3

let式と再帰的なattribute setsはどのような順番で値に名前を割り当ててもよく、他の名前を=の右側の式に使ってもよいという点で似ている。 一方で再帰的なattribute setsはattribute setとして評価されるが、let ... in ...からは(attribute setも含めて)どんな値でも返せる。

nix-repl> let a = c + 1; b = a + 1; c = b + 1; in a + b + c
error: infinite recursion encountered
       at «string»:1:20:
            1| let a = c + 1; b = a + 1; c = b + 1; in a + b + c
             |                    ^

循環参照みたいなことをやってみたら当然怒られた。

let式の中で付けた名前にアクセスできるのはそのlet式の中のみ。

with ...; ...

1つの式の中で特定のattribute setの要素に繰り返しアクセスする際に使える構文。

let
  a = {
    x = 1;
    y = 2;
    z = 3
  };
in
with a; [ x y z ]
[ 1 2 3 ]

これは

let
  a = {
    x = 1;
    y = 2;
    z = 3;
  }
in [ a.x a.y a.z ]

と同値。

複数withを並べれば複数のattribute setの中の属性を同時に参照することもできるらしい。

let
  a = {
    x = 1;
    y = 2;
    z = 3;
  };
  b = {
    v = 4;
    w = 5;
  };
in with a; with b; [x y z v w]
[ 1 2 3 4 5 ]

attribute setの中の名前が被っているときは後にwithで指定した方の値が優先される?

let
  a = {
    x = 1;
    y = 2;
    z = 3;
  };
  b = {
    x = 4;
    y = 5;
  };
in with a; with b; [ x y z ]
[ 4 5 3 ]

便利だと思う反面使いすぎると保守性が悪化しそうな気がする。

inherit ...

JavaScriptの{ x, y }のように、既に名前がついてる値をattribute setに加えるためのキーワード。

let
  x = 1;
  y = 2;
in
{
  inherit x y;
}

これは

let
  x = 1;
  y = 2;
in {
  x = 1;
  y = 2;
}

と全く同じ。

withとは違ってinheritで指定した名前と同じ名前をもう一度束縛しようとするとエラーになる。

let
  x = 1;
  y = 2;
in { inherit x y; x = 3; }

これはwithと違ってこんなことを許容しても役にたつケースがおよそ存在しないからinheritの機能としてエラーにされているのか、あるいはx = 1; y = 2; x = 3;と展開されて、1つのattribute setの中で同じ名前は2度使えないというルールによってエラーになっているのだろうか?

withと同じく、既存のattribute setから特定の名前だけを取り出して新しいattribute setに加えることもできる。

let
  a = {
    x = 1;
    y = 2;
    z = 3;
  };
in {
  inherit (a) x y;
}
{ x = 1; y = 2; }

2つ以上のattribute setをinheritで同じ名前の値を取り出そうとするとやはりエラーになる。

let
  a = {
    x = 1;
    y = 2;
  };
  b = {
    x = 1;
    z = 3;
  };
in {
  inherit (a) x y;
  inherit (b) z;
}

String interpolation ${ ... }

大体Scalaと同じらしい。 恐らくScalaのようにカスタムした補完子は作れないのか先頭にsを付ける必要が無いのと、名前1つから成る式を埋め込む時でも{}が必須になったくらいか。

let
  name = "Nix";
in
"hello ${name}
"hello Nix"

当然、string値や文字列に変換できる型の値しか埋め込めないのだが、なんとinteger型すら文字列に変換できる型として扱われていないらしい。 一体string以外にどの型が埋め込めるんだろう。

文字列補完子のネストもできるが可読性は悪そう。

ちなみに、Nix言語の用途の都合上、Nix言語のコードの中にインラインでシェルスクリプト書いた場合にそのスクリプトの中で$varのように"$"が文字列に登場することは普通にあるようだ。 名前1つから成る式を埋め込む場合も{}が必須なのはそういう事情もあるのかもしれない。

File system paths

stringとは別にパスを表記する型があるらしい。

絶対パスを記述する方法と相対パスを記述する方法があり、/から始めると絶対パスを記述できる。

nix-repl> /foo/bar
/foo/bar

/以外から始めると相対パスとして評価される。

相対パスは必ず/を含まなければならないというルールがある。 .はそこまでのパスのディレクトリを指し、例としてfoo/.と書くと(仮にそう書かれたファイルが/current/directoryというパスに置いてある場合は)/current/directory/fooというパスになる。 これらの2つのルールからNix言語ではファイルが置かれているディレクトリを指す際に./.という記述を使う。

..で親のディレクトリを指せる。

また、パスは文字列補完で埋め込むことができる。

Lookup paths

このパートは書いてある内容があまり理解できなかったので、一度飛ばし、後でこのLookup pathsが再登場した時に戻ってきた。 それでも分からないのでNix Reference Manualを読みにいったがそれでも理解しきるに至らなかったので、わかったところまでを書いておく。

Lookup pathsとは<nixpkgs>や<nixpkgs/lib>のような<>で囲まれたパスであり、<nixpkgs>を脱糖するとbuiltins.findFile builtins.nixPath "nixpkgs"になる。 builtinsというのは、これも後に出てくるのだがNix言語のインタプリタに実装されたライブラリで、コード中で参照すると様々な関数を要素として持つattribute setになっている。

nix-repl>  builtins
{
  abort = «primop abort»;
  add = «primop add»;
  addDrvOutputDependencies = «primop addDrvOutputDependencies»;
  addErrorContext = «primop addErrorContext»;
  all = «primop all»;
  any = «primop any»;
  appendContext = «primop appendContext»;
  # ... (とても要素数が多いので省略)
}

builtins.findFileはそんな関数の内の1つだ。 builtins.findFile builtins.nixPath "nixpkgs"という式ではbuiltins.nixPathと"nixpkgs"という2つの引数が与えられている。

builtins.findFileの挙動を説明するにあたってbuiltins.nixPathが何なのかを先に知っておいた方がわかりやすいのでこちらを先に見る。 私の環境ではこのようなattribute setになっている。

nix-repl> :p builtins.nixPath
[
  {
    path = "/home/pkino/.nix-defexpr/channels";
    prefix = "";
  }
  {
    path = "flake:nixpkgs";
    prefix = "nixpkgs";
  }
]

このようにbuiltins.findFileの第1引数にはpathとprefixという属性を持つattribute setのリストが渡される。 builtins.findFileはこの中でprefixが第2引数にマッチするものを見つけると、そのattribute setのpath属性を返す。 なので、builtins.findFile builtins.nixPath "nixpkgs"とすると"flake:nixpkgs"が返ってくる。

しかし実際にREPLで<nixpkgs>を評価させると以下のように展開される。

nix-repl> <nixpkgs>
/nix/store/g9bw874w67bfsvg93szwnxxjxp4lphby-source

どうもattributeのpath属性がパッケージ名だと、そのパッケージをダウンロードしてそのディレクトリの絶対パスを返しているようなのだが、このあたりがリファレンスマニュアルでは詳しくは説明されておらず、後は実際にbuiltins.findFileのコードを読みに行くしかなさそうだが現状でそこまでのモチベーションが無いので先に進むことにした。

なお、builtins.findFile builtins.nixPath "nixpkgs/lib/network"のように第2引数に/を含む文字列を渡すと最初の/までの文字列がprefix属性とのマッチングに使われ、返り値はpathの末尾に残りのパスをくっつけたものになる。

nix-repl> builtins.findFile builtins.nixPath "nixpkgs/lib/network"
/nix/store/g9bw874w67bfsvg93szwnxxjxp4lphby-source/lib/network

"nixpkgs"がマッチングに使われ、返り値の最後に"/lib/network"が追加されているのがわかるだろう。

ここまで長く説明したが、どうやらLookup pathsは再現性がなく不純であるため(使われてしまってはいるのだが)あまり使わない方がいいらしい。

Indented strings

''で文字列を囲むと複数行の文字列が書ける。

nix-repl> ''
          foo
          bar
          baz
          ''
"foo\nbar\nbaz\n"

一番スペースが少ない行がインデントの基準にされる。

nix-repl> ''
             foo
              bar
               baz
          ''
"foo\n bar\n  baz\n"

nix-repl> ''
             foo
            bar
          baz
          ''
"   foo\n  bar\nbaz\n"

Functions

関数はarg: expressionという形で表記される。 実際のドキュメントでは次のパートで説明されているが、関数の適用はHaskellと同様functionName argという形で行う。

nix-repl> f = x: x + 1

nix-repl> f 1
2

Nixでは1引数関数のみが存在するが、attribute setを引数にとることで複数の引数を扱える。

nix-repl> f = { x, y }: x + y

nix-repl> a = { x = 1; y = 2; }

nix-repl> f a
3

なお、この関数を余分な名前を含むattribute setに対し適用しようとするとエラーになる。

nix-repl> a = { x = 1; y = 2; z = 3; }

nix-repl> f a
error:
       … from call site
         at «string»:1:1:
            1| f a
             | ^

       error: function 'anonymous lambda' called with unexpected argument 'z'
       at «string»:1:2:
            1|  { x, y }: x + y
             |  ^
       Did you mean one of x or y?

余分な名前を含むattribute setを受け入れるには、以下のようにする。

nix-repl> f = { x, y, ... }: x + y

nix-repl> a = { x = 1; y = 2; z = 3; }

nix-repl> f a
3

HaskellやScalaのパターンマッチにおけるasパターンのように、引数に受け取るattribute set自体に名前を付けることもできる。

nix-repl> f = args@{ x, y, ... }: x + y + args.z

nix-repl> a = { x = 1; y = 2; z = 3; }

nix-repl> f a
6

どの引数が求められているのかわかりづらく保守性が悪化しそうだが、パッケージの設定のために非常に多くのパラメータを受け取ることになったらこうせざるを得ないんだろうなという気がする。 後置でもよい。

nix-repl> f = { x, y, ... }@args: x + y + args.z

デフォルト引数も使える。

nix-repl> f = { x, y ? 0 }: x + y

nix-repl> a = { x = 1; }

nix-repl> f a
1

匿名の関数はlambdaと呼ばれる。

Calling functions

以下のように、関数の適用を正しく行うためにかっこで囲まなければならないケースもある。

nix-repl> let
            f = x: x + 1;
            a = 1;
          in [ (f a) ]
[
  2
]

nix-repl> let
            f = x: x + 1;
            a = 1;
          in [ f a ]
[
  «lambda f @ «string»:2:7»
  1
]

このケースではリストの要素はスペース区切りで記述されるため、上はf aの返り値を要素に持つリストが作成されるが、下はfとaを要素に持つリストになってしまう。

Multiple arguments

関数の返り値を関数にすることによりカリー化もできる。

nix-repl> f = x: y: x + y

nix-repl> f 1 2
3

nix-repl> f2 = f 1

nix-repl> f2 2
3

x: y: x + yはx: (y: x + y)と評価されていることがわかる。

Function libraries

Nix言語で広く使われるライブラリとしてbuiltinsとpkgs.libが挙げられる。

builtinsはその名の通りNix言語のインタプリタにC++で実装されている関数で、ここにリファレンスマニュアルがある。

pkgs.libはnixpkgsリポジトリにlibと呼ばれるattribute setがあり、便利な関数を提供している。 こちらはNix言語で実装されている。 恐らくこれのことだと思われる。

特定のNixファイルに定義されている関数を呼び出したいなら、まずそのnixファイルに書かれた式を評価し値を得る必要がある。 たとえば、./test/lib.nixに以下の内容を書き込む。

{
  a = 1;
}

importというキーワードはパス型の値を受け取る関数のようになっており(あるいは本当に関数なのかもしれない)、importにnixファイルのパスを渡すことでそのファイルに書かれた式を評価できる。

nix-repl> import ./test/lib.nix
{ a = 1; }

他のnixファイルが返すattribute setの要素である値を扱いたいなら、以下のようにimportが返したattribute setの要素をそのまま参照するか、importが返したattribute setに名前を付けることになる。 大抵は後者でやることになると思われる。

nix-repl> (import ./test/lib.nix).a
1

nix-repl> test-lib = import ./test/lib.nix

nix-repl> test-lib.a
1

また、importにディレクトリの名前を渡すと、そのディレクトリにあるdefault.nixという名前のファイルが探される。

./test/default.nixというファイルに以下の内容を書き込む。

{
  add = x: y: x + y;
}

以下はこのファイルをディレクトリを指定し利用している例。

nix-repl> test = import ./test

nix-repl> test.add 1 2
3

なお<nixpkgs>を評価して得られるディレクトリのdefault.nixが返す値は関数になっており、慣例的に空のattribute setを渡して利用されている。

nix-repl> pkgs = import <nixpkgs> {}

nix-repl> pkgs.lib.strings.toUpper "foo"
"FOO"

pkgsやpkgs.libは慣例として関数の引数として渡されることもよくある。

{ pkgs, lib, ... }:
# ...

この場合は呼び出し側でnixpkgsをimportし渡す、依存性注入のような操作を行うことになる。

Impurities

Nix言語では、ビルドの中で特定のファイルを参照することがある。 このようなファイルをビルド時の入力として渡す方法は、ファイルパスを記述するか、Fetcherと呼ばれる専用の関数を使うかのどちらかになる。

Paths

「ファイルパスを記述する」方の方法。 ファイルシステムパスが文字列補完子の中で使われると、そのファイルはNix storeというNixがパッケージ管理に使っているディレクトリにコピーされ、そのパスが返される。

nix-repl> "${./test/lib.nix}"
"/nix/store/41yix9s3lf3vsrmxk59n6j0i1c63ymj9-lib.nix"

文字列補完子に埋め込んだ値は文字列として表現できるものに評価されなければならない。 ファイルシステムパスの場合は、対応するNixストアのファイルのパスとして評価される。 Nixストアのファイルパスは/nix/store/<hash>-<name>という形式になっており、<hash>の部分にはファイル内容のハッシュが、<name>には元のファイル名が入る。 Nix言語自体からは脱線するが、Nixはビルドに使うファイルの中身をハッシュとして記録することで、ビルドの再現性を担保しているようだ。

ディレクトリのパスを埋め込んだ場合も、副作用としてディレクトリ全体がNix storeにコピーされ、そのディレクトリのNix storeパスが返ってくる。

Fetchers

ビルド時の入力に使われるファイルはファイルシステムからだけでなく、ネットワークから取得してもよい。 そのための関数がbuiltinsパッケージに用意されている。

  • builtins.fetchurl
  • builtins.fetchTarball
  • builtins.fetchGit
  • builtins.fetchClosure

これらの関数により取得されたファイルはやはりNix storeに保存され、そのパスが関数の返り値として返される。

Derivations

(先に書いたことの繰り返しになるが、Derivationとはパッケージのビルド手順のレシピ)

Nix言語はDerivationsを記述するのに使われ、 NixはDerivationsから成果物を生成する。 ビルドされた成果物は他のDerivationsの入力に使われることもある。

感想

実はNix language basicsを読んでいる間にだんだん手を動かしたくなってきてしまい、VirtualBoxにNixOSをインストールしてちょっとした設定をいじるところから始めていた。 まだ記述している内容により何が起こるのかまでは理解できていないが、どういう構文が使われて、Nix式がどういう構成になっているのかくらいはわかるようになって楽しくなってきた。 ただ、まだNixに登場する概念を全然理解できていないので、次はNixOS & Flakes Bookを読んでみようと思う。 NixOSは難易度が高いと聞いており常用していくかは未定だが、パッケージの管理をNixに移行するところまではやってみたい。

「関数型ドメインモデリング」を読んだ

業務で複雑なドメインを持つソフトウェアをScalaで書いているので以前からとても読みたかったのだが、英語とは遠い昔に仲違いしてしまい読むことができなかった。 この度和訳されたことに感謝したい。

関数型ドメインモデリング - アスキードワンゴ

全体を通した感想

正直言って、この本に書いてある内容を全て実践するのは難しいと感じた。 まず、最初に出てくるドメインエキスパートを交えたイベントストーミングの実施が一番の壁というチームも多いのではないだろうか。 しかし、それでもなおこの本により新しく知った考え方は多く、読む価値があったと思う。

ScalaやHaskellなど静的型付けの関数型言語をかじってみたがこれをどう実際のビジネスに関わるアプリケーションの開発に適用していくのかイメージが掴めないという人には特に勧められる。 F#でサンプルコードが書かれているが、静的型付けの関数型言語に触れたことがあれば読むのはそう難しくない。 少し難点があるとすればADTに関する説明は経験者からすると少し冗長に感じたが、それでもADTでドメインモデリングを行う際の手順や考えるべきことが書かれているので無駄ではなかった。 また、関数型プログラミングの作法について随所で説明されているのもよかった。

それらの経験が無い場合、ドメインモデリングの核を担う代数的データ型(Algebraic Data Type)についてまず理解する必要はあるものの、ADTの説明はかなり丁寧に行われているので十分読めるだろう。 モナドなどの概念はたしかに出てくることは出てくるのだが、この本では「実は今まで使っていたものの背景にモナドがある。アプリカティブというものもありこちらも便利なことができる。」というようなことが1ページちょっと解説されるに過ぎない。 あっさりしすぎに思えるかもしれないが、その後モナドという言葉はまたぱったりと出てこなくなり、モナドが何なのかろくすっぽ理解できなかったとしても1全く問題なく読み勧められるようになっており、むしろモナドにより理解が妨げられないよう配慮されていると感じた。 モナドやアプリカティブのことが気になるのであれば、その後CatsやHaskellにでも入門してみればいい。

1部の感想

関数型言語がどうとかではなく、DDDの戦略的設計を解説する和書として貴重ではないかと感じた。 まだDDDの入門書自体そこまで見ない印象だが2、私が今まで読んだものは最初にDDDはドメインエキスパートと話すことから始まるといったことを少し説明し、その後は大部分をエンティティや集約やリポジトリなどの戦術的設計の解説に費やすものが多かった。

この本の1部はドメインエキスパートとのイベントストーミングによりビジネス上で発生するイベントやワークフローを理解しドメインエキスパートが理解できる形で文書化しようという話から始まる。 エンティティや集約といった話は全くといっていいほど出てこない。 それどころかコードすらほとんど出てこず、イベントストーミングによりわかったビジネスルールをイベントとワークフローを中心に捉え、ドメインエキスパートが読める形で文書化する流れの例に大部分を割いている。 戦術的設計に関して今までイメージが湧かなかったのだが、このパートを読んだことで少しだけ理解が進んだ気がする。

ただ1つ気になった点として、DBのテーブルやクラスに落とし込む前提でドメインを捉えようとしてはいけないということをこの本は説くのだが、イベントストーミングによってできあがっていく文書はScalaやHaskellをかじった者が見ればADTをほぼそのまま文書化したようなものだとわかる。 それこそ2部でドメインモデリングに入る際に、ここはこうするだろうなとある程度わかってしまうくらいまんまADTなのだ。 テーブルやクラスに落とし込むことを前提としてはいけないのであれば、ADTとほぼ全く変わらない文書に落とし込むのはどうして問題が無いのか、説明が欲しかった。 別にまんまADTであることが悪いと言いたいわけではない。 徒にプログラミングの概念から離れれば離れるだけよいというものではないし、むしろ離れすぎるとプログラムに落とし込むのが大変だと思う。 ただそれでも「ADTはテーブル定義やクラスよりもパワフルでドメインを表現する力が十分ある」とか「ADTはドメインエキスパートが理解できるくらいシンプルだ」とか、テーブルやクラスがダメでADTがいい理由を主張してほしかった。

一応2部で以下のような記述があるので、「ADTはドメインエキスパートが理解できるくらいシンプルだ」と筆者は考えているのかもしれない。

あなたが開発者ではない人だと想像してみてください。このコードをドキュメントとして理解するためには、何を学ばなければならないでしょうか。単純型(単一ケースの共用体)、AND型(中かっこつきのレコード)、OR型(縦棒つきの選択肢)、「プロセス」(入力、出力、矢印)の構文を理解する必要がありますが、それ以上のことはありません。しかも、C#やJavaのような従来のプログラミング言語よりは間違いなく読みやすくなります。

『関数型ドメインモデリング ドメイン駆動設計とF#でソフトウェアの複雑さに立ち向かおう』, Scott Wlaschin, 株式会社ドワンゴ, 2024-06-28, 猪股健太郎 訳, p.97

少し気になる点はあったものの

  • データではなくイベントやワークフローに着目する
  • イベントから必要なデータをコマンドとして取り出してワークフローに渡し、ワークフローはまた新たなイベントを返す
  • イベントは境界付けられたコンテキストを出る時にDTOに変換された後にシリアライズされ、別の境界付けられたコンテキストはDTOを通してデシリアライズを行ってイベントを取り出すことで通信が行われる

といった戦略的設計の基礎がわかった。

2部の感想

この部では1部で作った文書からF#のADTにドメインモデルをおこしていくという流れが説明される。 ADTに関する解説は読み飛ばし気味だったが、それでも今まで意識していなかったことに気付かされた。

個人的に特に重要だと思ったのが、プリミティブ型をラップする単純型のパワフルさだ。 Scalaならopaque types、Haskellならnewtypeで定義するものだが、当初は「互換性の無い値を取り違えずにすむ」くらいの認識だった。 しかし、単純型を定義しそれをパーツとしてモデリングを行っていくことで、防御的プログラミングを行う機会がぐっと減ることが単純型の真価だということに気付かされた。

たとえば、以下のように商品の注文を表す型が定義されているとする。 DBやHTTPリクエストのパラメータなど外部からやってきた価格を表す値は必ずこのコンストラクタを経由することでバリデーションが行われる。 説明の簡略化のため雑に例外を投げてしまっているが、実際はEitherなりを返すコンストラクタが定義されるものと考えてほしい。

case class Order(name: String, price: Int) {
  require(price > 0)
}

通常、商品の注文は在庫の確認などのドメインのルールに則したバリデーションが必要であり、問題ないと判定された注文の型も欲しいので以下のように定義する。

case class ValidatedOrder(name: String, price: Int) {
  require(price > 0)
}

OrderをValidatedOrderに変換することを考えると、既にOrderでpriceが0より大きいことは検証されているので、バリデーションを両方に書くのは冗長ではないだろうか? ここに単純型を導入すると以下のようになる。

opaque type ProductName = String
// ProductNameのコンストラクタは説明の本筋でないため省略

opaque type Price = Int

object Price {
  def apply(i: Int): Either[String, Price] =
    Either.cond(i > 0, i, "Price must be positive.")
}

case class Order(name: ProductName, price: Price)
case class ValidatedOrder(name: ProductName, price: Price)

この場合、コンテキストの外からやってきた値はまず一度Priceのコンストラクタを通してしまえば、その後はコンテキストを出るまではこの値を有効な値として扱うことができる。 OrderからValidatedOrderに詰め替える時も既に検証が済んでいるPrice値をそのまま渡すので、それぞれの型で独自に価格として有効な値を検証する必要がなく、コードがすっきりする。 プリミティブ型をラップする単純型を用意することで、同じ型の値を取り違えるリスクを排除できるばかりか、このように防御的プログラミングを避けられるメリットまであるのだ。

この他にも、ワークフローが何らかの外部からの追加の入力を必要とするときは、外部からの追加の入力を使った計算を関数としてワークフローに渡すことでワークフローを純粋に保つなど、新しい考えを得られた。

ワークフローを関数型として定義することについては、取り入れてみたいとは思ったものの、Scalaだと関数として定義するのか状態の無いオブジェクトが持つメソッドとして定義すべきなのか少し悩んだ。 特に、DIコンテナを使っているのであれば、メソッドとして定義しておく方がよくあるやり方を外れず無難ではないかという気もする。

3部の感想

2部で型を定義したワークフローに、合成可能な小さな関数を組み立てるという関数型の手法で実装を用意していく部だった。 この部でモナドが登場するのだが、全体を通した感想に書いたようにここでモナドを理解できなくともこの本の残りの部分を読み進める上で全く問題がないので安心だ。 失敗する可能性があることを表すEitherやResultと呼ばれる型の値を返す関数をどんどんつなげていく際にbindやflatMapと呼ばれる操作が必要になるという具体的な話から始まり、ワークフローを実装するために小さな関数を合成していくための他のいくつかの道具となる高階関数について説明されたあと、実は今まで使ってきたResultのようなデータ構造の内、bindなどの操作が特定の規則を満たして提供されているのがモナドだと明かされ、その後モナドは説明からはぱったりと姿を消す。 これでモナドについていきなり理解できる人は少ないと思うが、かえってこの本を最後まで読むことを妨げず、気になる人は関数型言語に入門するという選択肢を与える良い塩梅ではないかと感じた。

個人的には、これまでモデリングに登場してきた直和型をどうやってシリアライズ/デシリアライズして複数のコンテキスト間でやりとりするのか、永続化する場合どういう選択肢がありどういうメリット/デメリットがあるのかといったことを解説する11章や12章が参考になった。 これまでモノリシックなアプリケーションを作ることが多く、コンテキスト間のやりとりを意識する機会が少なかったが、そのようなアーキテクチャのアプリケーションの開発に携わる際に改めて読み返したい。

余談

関数型言語とDDDによるソフトウェア開発というとFunctional and Reactive Domain Modelingという本もあり、こちらは今回のDomain Modeling Made Functionalよりもより進んだ内容になっているらしい。 こちらもいつか読みたい。


  1. この解説量で理解できたらなかなかセンスがあると思う。
  2. ありがたいことにこの本が発売された時期はDDD関連の本の発売が集中しており、[入門]ドメイン駆動設計やドメイン駆動設計をはじめようが後に続いた。

ScalaMatsuri 2024 感想

昨年に引き続きScala Matsuri 2024に参加してきた。 色々あって体調を崩してしまっており長時間の外出に少々不安はあったが、それを押し切って来る価値はあったし、幸い何も起こらずに済んだのでよかった1。

オフライン会場ではレシーバとイヤホンが借りられ、それを使うと同時通訳で発表を聞けたのだが、特にリスニングがさっぱりな私としてはとても体験がよかった。 通訳者の方はもちろん、レシーバをアルコールティッシュで拭いて衛生を確保されていたスタッフの方も大変だっただろうから感謝したい。

特に印象に残ったセッションのうち、ある程度消化できたものについて忘れない内に書いていく。

Ironライブラリで守られた型安全性 (Raphaël Lemaitreさん)

スライド: https://scalamatsuri.rlemaitre.com

篩型と呼ばれる、型に述語を記述することで受け付ける値を制限する仕組みを提供するライブラリを紹介するセッション。 たとえば、「Int型の内、正の値で42よりも小さいもの」を表す型はInt :| (Positive & Less[42])と記述する。 Int値をInt :| (Positive & Less[42])型の変数に格納したいときはintVal.refineEither[Int :| (Positive & Less[42])]のようにすると、条件に合致しない値はLeft値を返して弾いてくれる。 モデルを定義する際に使えば、かなり可読性がよくなるし、例外に頼らないエラーハンドリングに役立つだろう。

篩型自体もぜひプロジェクトに取り入れてみたいと思うものだが、私が真に驚かされたのは篩型が違和感なく表現できてしまうScala 3の型システムについてだ。 私は一見「そこまでやる必要があるのか?」とすら思ってしまうほど表現力の高いScalaの型システムとそれがもたらす堅牢性が大好きだ。 Scala 3でも、TypeScriptでおなじみのUnion Type, Intersection Type, Literal Typeなどが取り入れられた。 初めて聞いたときは「どこまで型の表現力を高めるつもりなんだ!?」と面白半分だったのだが、今こうして篩型を見るとLiteral Type, Intersection Type、そして元々あるHigher Kinded Typeの組み合わせで、特別な仕組みを導入するでもなく実に自然な形で篩型が表現されている。 スライドの最後に「Scala 3の型システムは信じられないほど強力だ」とあるが、全くもって同意しか無い。

私が業務で携わっているプロジェクトではまだScala 2を使っているが、頑張ってScala 3に上げたいと思った。 また、Scala本体についてもコップ本第4版を読んだきりでScala 3についてはあまりキャッチアップできていなかったのだが、急いでScala 3 Bookを読まなければという気持ちになった。 早速明日から読んでいくことにする。

あとopaqueをずっと「オパキュー」と読んでいたのだが、「オペーク」が正しいということがわかり、一つ上のScalaプログラマに成長した。

Scalaの開発者ツールエコシステム(Tomasz Godzikさん)

スライド: https://slides.com/tomekgodzik/scala-ecosystem

Metalsくらいまでは知っていたが、Scala Stewardを皮切りにどんどん知らない便利ツールが出てきた。 特にMdocはScalaでもdoctestが使えるようなので取り入れたい。 となるとやはり入出力を切り離して短いコードでテストできるメソッドを書いてやる必要があるんだよなぁ。

Scala Toolkitも公式ドキュメントを見ると、Scala-CLIでちょっとスクレイピングしたいときなどに、Pythonのrequestsのような感覚で使えたりしてよさそうだ。

Scalafmt, Scalafix, Wartremoverの使い分けが未だにピンと来ていないのでどうせなら質問しておけばよかったなとセッションが終わって1分くらいした頃に思ったが後のMatsuriだった。 誰か教えてほしい。

(2024-06-10T20:41追記)

などとTwitterでもぼやいていたら、吉田さんが懇切丁寧に教えてくださった。本当にありがとうございます。

(追記ここまで)

あとCouriserの読み方がずっとわからず「クーシエル」のような読み方をしていたのだが、「クルシェ」が正しいということがわかり、一つ上のScalaプログラマに成長した。

Property-based testing: テストライブラリ活用方法(Magda Stozekさん)

スライド: https://slides.com/magdastozek/property-based-testing-scala-20-min

Property-Based Testingについてはコップ本を読んでいたときやHaskellをかじったときに少しだけ目にしたが、実際にどういう使い方をするのか、使う上でどういう課題があるのかはさっぱり知らなかったので、TwitterのTLも含め

  • エッジケースになるようなトリッキーなデータでテストを行ってくれる
  • ジェネレータが誤ったテストデータを作ってしまわないよう注意し、プロパティベースのテストが落ちたときはメソッドにバグがあると確信できる状況にしておく
    • Ironライブラリで篩型を導入しておくとこれがやりやすい
  • テストを書く前にエッジケースは何かを考えなければならず、TDDに使うには向いていない
    • まずは通常のExample-BasedなテストでTDDし、後でProperty-Basedなテストを書くべき
  • 重要なコンポーネントのみProperty-Basedなテストを採用すべき
  • CIではやはりそのランダム性が問題になることがあり、CIでは回さないようにしたり、seedを固定するなどのやり方もある

といった実用を通した知見が聞け、面白かった。

Twitterで流れていたこの辺もあとでチェックしたい。

また、もはやScalaとは全く関係ないのだが、Property-Based Testingといえばラムダノートからも本が出ていた。 Property-Based Testingの本、それもErlang/Elixirで書かれているとなれば需要はかなり限られてしまうはずだが、それでもProperty-Based Testingに関する和書がほとんど無い状況で和訳し出版してくれた気概にシビれたので、こちらもちゃんと買って目を通したい。

いつ継承を使い、いつ使わないのか(がくぞさん)

スライド: https://gakuzzzz.github.io/slides/when_to_use_subtyping_when_not_to_use/#1

Scalaでパターンの多い分岐を書く方法としては直和型を使う方法と、OOP由来のサブタイピングを使ったポリモフィズムを使う方法の2つがあるが、どんなときにどちらを使うのかというのがこのタイトルの表したいことのようだった。 分岐を扱う方法として直和型とポリモフィズムの2つがあることはGood Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考でもたしか触れられていたのだが具体的な使い分けの基準までは触れられていなかったので、気になっていたセッションの1つだった。

代数的データ型がどう代数的なのかという話がされていて、とても説明が丁寧な印象だった。

特に

  • ポリモフィズムでは分岐パターンの追加(つまりサブクラスの追加)は既存のコードを変更することなく行えるが、インターフェースに新たな操作を追加した場合はすべてのサブクラスに追加の実装をしなければならない
    • また、ポリモフィズムでは抽象のみを公開し具象クラスをパッケージ内に隠蔽すると、具象クラス毎に分岐する処理をそのデータ構造が定義されているパッケージの外で定義できないため、レイヤードアーキテクチャを採用した場合にDBに保存する際などどうしても具象クラス毎に分岐しなければならない場面で分岐がそもそも書けずミスマッチがある
  • 直和型を使う場合は新たな操作を追加するのは既存のコードの変更することなく行えるが、分岐パターンの追加(ケースの追加)をする場合はその型を使っている箇所すべてを変更しなければならない

という比較がとてもすっきりした。 このように両者のメリット/デメリットを言語化してもらえると、自分で考える軸が得られるのでとてもありがたい。

余談だが、15:20〜16:00にかけA会場/B会場ともに興味深いセッションが重なっており、どう動くかとても悩まされた。 Oxに関するセッションは後でスライドを確認する予定だが、参照透過性に関するディスカッションについてはこれから面白くなるというタイミングでB会場を離れなければならなかったので、少し心残りになってしまった。

まとめ

今回はある程度消化できたセッションについてのみ書いたが、他のセッションも面白かった。 今の自分ではまだ理解が難しいものもあり、また来週末にゆっくり時間をかけて消化したい。

Ironのセッションを聞いているうちに、やはりScalaの型システムの表現力は素晴らしい!という気持ちになり、とにかくScala 3に対するモチベーション、またScala全体に対するモチベーションがグッと上がった。 Scalaは知りたくなることが無限に湧いてきて、ずっと味のするガムのようで良い。

登壇者, スタッフ, 通訳者, スポンサー, その他開催に関わったすべての方々に感謝したい。 ありがとうございました。


  1. 詳しい話は割愛するが最悪の場合でも誰かに感染したり救急搬送されるなど他の参加者やスタッフの方に迷惑をかける類の体調不良ではなく、参加して問題ないだろうと判断した。

Play Frameworkとpekko-quartz-schedulerで動かしているTyped ActorにDIする

現在私が業務で携わっているプロジェクトでは、バッチ処理の定期実行にpekko-quartz-schedulerというライブラリを使っている。 簡単に言うと、Actorに対し決められたタイミングでメッセージを送ることでバッチ処理を定期実行させるためのライブラリだ。 バッチ処理を行うActorの間ではあまり複雑なメッセージのやりとりが行われることもないためTyped Actorに移行するメリットより学習コストが大きいと判断されたためか、プロジェクトではこれまでバッチ処理のためのActorを書くときにずっとClassic Actorで書いていた。

しかし、Typed Actorがstableになってからもう5年くらい経っておりいい加減移行すべきではないかと思った。 ただ、Play Frameworkが標準で採用しているDIコンテナであるGoogle GuiceとTyped Actorを組み合わせて使う場合の情報があまり見つからず、少し調べるのに時間がかかった。 今回はPlay Framework, Google Guiceを利用している前提でpekko-quartz-schedulerでClassic ActorからTyped Actorに移行する際の勘所を書いていく。 Typed Actor自体については説明すると長くなってしまうため、各自AkkaやApache Pekkoのドキュメントを確認してほしい。

なお、今回はpekko-quartz-schedulerを使う前提で書いているが、akka-quartz-schedulerを使う場合もおそらく設定のキー名やimportをpekkoからakkaに変更するくらいの違いしか無いと思われる。

サンプルコードはここに置いている。

QuartzSchedulerTypedExtensionを使う

Classic Actorに定期的にメッセージを送るためにはQuartzSchedulerExtensionのインスタンスが必要になるが、これをQuartzSchedulerTypedExtensionに変更する必要がある。 私のプロジェクトでは以下のようなクラスを定義し、Actorに送るメッセージのスケジューリングを送るクラスに対しSchedulerSettingを注入している。

import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.actor.typed.scaladsl.adapter.ClassicActorSystemOps
import org.apache.pekko.extension.quartz.QuartzSchedulerTypedExtension

import javax.inject.Inject

class SchedulerSetting @Inject()(actorSystem: ActorSystem) {
  val scheduler: QuartzSchedulerTypedExtension =
    QuartzSchedulerTypedExtension(actorSystem.toTyped)
}

なお、QuartzSchedulerTypedExtensionはQuartzSchedulerExtensionを継承しておりClassic Actorからも同じインターフェースで利用できる。 なので、仮に私のプロジェクトのようにQuartzSchedulerExtensionのインスタンスが既に多くのClassic Actorに関する設定を行うクラスから参照されてしまっていたとしても、それらのClassic Actorや設定を行うクラスを作り直す必要は一切なく、新しく作られるTyped Actorと共存することができる。

親ActorへのDI

まず、Actorを以下のように定義する。

package scheduler

import com.google.inject.{AbstractModule, Provides}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import play.api.libs.concurrent.{ActorModule, PekkoGuiceSupport}

import javax.inject.Inject

object SampleActor extends ActorModule {
  sealed trait SampleActorMessage
  case object Hello extends SampleActorMessage

  override type Message = SampleActorMessage

  @Provides
  def create(sampleService: SampleService): Behavior[SampleActorMessage] =
    Behaviors.receiveMessage { case Hello =>
      println("SampleActor received Hello.")
      sampleService.exec()
      Behaviors.same
    }
}

class SampleService @Inject() () {
  def exec(): Unit = println("SampleService was executed.")
}

Typed ActorではActorはclassではなくBehaviorを返すメソッドをSingleton Objectに定義することでふるまいを定義するようになったため、DIしようにもどう注入すればいいのか謎だったがProvides Methodを使うようだった。 これで、Behavior[SampleActorMessage]のインスタンスをGuiceで生成しようとすると、SampleActor.create()が呼び出されるようになる。 Provides Methodの引数がGuiceにより生成できるオブジェクトだけであれば、依存関係の解決も勝手にやってくれる。

SampleActorでoverrideしているMessageという型エイリアスについてはこのActorをバインディングする際に関わってくるため、後で説明する。

次に、このActorに定期的にメッセージを送るためのスケジュールを行うためのクラスを見てみよう。

package scheduler

import com.google.inject.{AbstractModule, Provides}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import play.api.libs.concurrent.{ActorModule, PekkoGuiceSupport}

import javax.inject.Inject

class SampleActorScheduler @Inject() (
    schedulerSetting: SchedulerSetting,
    sampleActor: ActorRef[SampleActor.SampleActorMessage]
) {
  schedulerSetting.scheduler.scheduleTyped(
    "Every5Seconds",
    sampleActor,
    SampleActor.Hello
  )
}

class SampleActorModule extends AbstractModule with PekkoGuiceSupport {
  override def configure(): Unit = {
    bindTypedActor(SampleActor, "SampleActor")
    bind(classOf[SampleActorScheduler]).asEagerSingleton()
  }
}

Classic ActorへのメッセージのスケジュールではQuartzSchedulerExtension.schedule()を呼び出していたが、Typed ACtorの場合はQuartzSchedulerTypedExtension.scheduleTyped()を使う。 SampleActorSchedulerにActorRefを注入しているやり方に注目してほしいのだが、Classic ActorのActorRefを注入する際は名前付きバインディングを使っていたのが、そうではなくなっている。 Classic ActorではbindActor()によりActorRefを生成し名前付きバインディングを行なっていたが、Typed ActorのActorRefを生成しバインディングするbindTypedActor()は名前付きバインディングを提供しないのだ。 第2引数に渡した文字列は、ただ生成したActorの名前に使われるだけである。

bindTypedActor()は第1引数に受け取ったオブジェクトからActorRef[Message]型の値を返すProvides Methodを探し、そのメソッドに必要な依存関係を引数に渡してBehavior[Message]を生成し、Actorを生成してくれる。 このMessage型は何かといえば、Actorに関する説明で後回しにしていた型エイリアスであり、今回はSampleActorMessageの型エイリアスになる。 つまり、今回はSampleActorの中からBehavior[SampleActorMessage]型の値を返すProvides Methodを探してActorを生成することになる。

最後に、ここはClassic Actorと何も変更点が無いのだが、プロジェクトによってやり方が違うだろうからapplication.confの中身も一応載せて解説しておく。

pekko {
  quartz {
    schedules {
      Every5Seconds {
        description = "5秒ごとに実行"
        expression = "*/5 * * ? * *"
      }
    }
  }
}

play.modules.enabled += "scheduler.SampleActorModule"

SampleActorModuleを有効化することで、アプリケーションの起動時にActorの生成とバインディング, SampleActorSchedulerの初期化処理が走り、その中でActorに5秒ごとにHelloが送られるようスケジュールしているので、sbt runを実行すると5秒ごとに以下のようなログが流れるだろう。

SampleActor received Hello.
SampleService was executed.
SampleActor received Hello.
SampleService was executed.
SampleActor received Hello.
SampleService was executed.

...

状態を持つActorへのDI

ここまではakka-quartz-schedulerやPlay Frameworkのドキュメントを調べればわかるのだが、ここから先の話はどこにもベストプラクティスが書いていなかったので、あくまで私はこうやっているという話になる。

次にClassic Actorで以下のように記述されていたActorへDIを行うことを考える。

package scheduler

import com.google.inject.AbstractModule
import org.apache.pekko.actor.{Actor, ActorRef}
import play.api.libs.concurrent.PekkoGuiceSupport

import java.time.LocalDateTime
import javax.inject.{Inject, Named}
import scala.collection.mutable

class StatefulClassicActor @Inject() (sampleService: SampleService)
    extends Actor {
  private var i: Int = 1
  private val messageHistory
      : mutable.Buffer[(StatefulClassicActor.Message, LocalDateTime)] =
    mutable.Buffer.empty

  override def receive: Receive = { case StatefulClassicActor.Hello =>
    println(i)
    println(messageHistory.mkString(",\n"))
    sampleService.exec()

    i += 1
    messageHistory += ((StatefulClassicActor.Hello, LocalDateTime.now()))
  }
}

object StatefulClassicActor {
  sealed trait Message
  case object Hello extends Message
}

class StatefulClassicActorScheduler @Inject() (
    schedulerSetting: SchedulerSetting,
    @Named("StatefulClassicActor") statefulClassicActor: ActorRef
) {
  schedulerSetting.scheduler.schedule(
    "Every5Seconds",
    statefulClassicActor,
    StatefulClassicActor.Hello
  )
}

class StatefulClassicActorModule extends AbstractModule with PekkoGuiceSupport {
  override def configure(): Unit = {
    bindActor[StatefulClassicActor]("StatefulClassicActor")
    bind(classOf[StatefulClassicActorScheduler]).asEagerSingleton()
  }
}

これも、application.confでStatefulClassicActorModuleを有効化してsbt runすれば以下のようなログが得られるだろう。

1

SampleService was executed.
2
(Hello,2024-06-05T20:29:40.021417)
SampleService was executed.
3
(Hello,2024-06-05T20:29:40.021417),
(Hello,2024-06-05T20:29:45.007236)
SampleService was executed.

DIをする必要があり、尚且つ状態を持っているというのが今回のミソだ。 Typed ActorではClassic Actorがインスタンス変数として持っていた内部状態をBehaviorを生成する関数の引数として持ち回ることでmutableなオブジェクトやvarを使わずとも状態を持つことができるようになっている。 しかし、ProvidesMethodはGuiceが生成できる型の値以外は引数にとれないため、IntやSeq[(StatefulClassicActor.Message, LocalDateTime)]といった型の状態を管理しなければならないとなると、そのままでは困ってしまう。

このような場合、まずGuiceが生成できる型の値だけを引数にとるメソッドを定義し、そのメソッドの中で状態を持ち回るヘルパー関数を定義し、最後にヘルパー関数に初期状態を渡して呼び出してやるとよい。

package scheduler

import com.google.inject.{AbstractModule, Provides}
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import play.api.libs.concurrent.{ActorModule, PekkoGuiceSupport}

import java.time.LocalDateTime
import javax.inject.Inject

object StatefulTypedActor extends ActorModule {
  sealed trait StatefulTypedActorMessage
  case object Hello extends StatefulTypedActorMessage

  override type Message = StatefulTypedActorMessage

  @Provides
  def create(
      sampleService: SampleService
  ): Behavior[StatefulTypedActorMessage] = {
    def loop(
        i: Int,
        messageHistory: Seq[(StatefulTypedActorMessage, LocalDateTime)]
    ): Behavior[StatefulTypedActorMessage] =
      Behaviors.receiveMessage { case m @ Hello =>
        println(i)
        println(messageHistory.mkString(",\n"))
        sampleService.exec()
        loop(i + 1, messageHistory :+ (m, LocalDateTime.now()))
      }

    loop(1, Vector())
  }
}

class StatefulTypedActorScheduler @Inject() (
    schedulerSetting: SchedulerSetting,
    statefulTypedActor: ActorRef[StatefulTypedActor.StatefulTypedActorMessage]
) {
  schedulerSetting.scheduler.scheduleTyped(
    "Every5Seconds",
    statefulTypedActor,
    StatefulTypedActor.Hello
  )
}

class StatefulTypedActorModule extends AbstractModule with PekkoGuiceSupport {
  override def configure(): Unit = {
    bindTypedActor(StatefulTypedActor, "StatefulTypedActor")
    bind(classOf[StatefulTypedActorScheduler]).asEagerSingleton()
  }
}

特定のActorの子ActorにDIする

ここまではActorの親子関係について考えず、子を持たないActorにDIすることを考えてきた。 しかし、当然特定のActorから生成されるActorに依存性を注入したいこともあるだろう。 この場合、親Actorと同じようにbindTypedActor()により依存性を注入させると、ある問題が発生する。

package scheduler

import com.google.inject.{AbstractModule, Provides}
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import play.api.libs.concurrent.{ActorModule, PekkoGuiceSupport}

import javax.inject.Inject

object WrongParentActor extends ActorModule {
  sealed trait WrongParentActorMessage
  case object Hello extends WrongParentActorMessage

  override type Message = WrongParentActorMessage

  @Provides
  def create(
      child: ActorRef[WrongChildActor.WrongChildActorMessage]
  ): Behavior[WrongParentActorMessage] =
    Behaviors.setup { ctx =>
      Behaviors.receiveMessage { case Hello =>
        println(ctx.self.path)
        child ! WrongChildActor.Hello
        Behaviors.same
      }
    }
}

object WrongChildActor extends ActorModule {
  sealed trait WrongChildActorMessage
  case object Hello extends WrongChildActorMessage

  override type Message = WrongChildActorMessage

  @Provides
  def create(sampleService: SampleService): Behavior[WrongChildActorMessage] = {
    Behaviors.setup { ctx =>
      Behaviors.receiveMessage { case Hello =>
        println(ctx.self.path)
        sampleService.exec()
        Behaviors.same
      }
    }
  }
}

class WrongActorScheduler @Inject() (
    schedulerSetting: SchedulerSetting,
    wrongParentActor: ActorRef[WrongParentActor.WrongParentActorMessage]
) {
  schedulerSetting.scheduler.scheduleTyped(
    "Every5Seconds",
    wrongParentActor,
    WrongParentActor.Hello
  )
}

class WrongActorModule extends AbstractModule with PekkoGuiceSupport {
  override def configure(): Unit = {
    bindTypedActor(WrongParentActor, "WrongParentActor")
    bindTypedActor(WrongChildActor, "WrongChildActor")
    bind(classOf[WrongActorScheduler]).asEagerSingleton()
  }
}

このコードでは親のActorと子のActorにパスを出力させているが、以下のようにWrongParentActorとWrongChildActorが親子関係になっていないことがわかる。 特定のActor同士を親子関係にしたいのであれば、子ActorはGuiceにより生成するのではなく、親Actorが自ら生成しなければならない。 第一、この方法ではActorを動的に生成できないし、一度親からstopさせてしまったらもう一度生成できない。

pekko://application/user/WrongParentActor
pekko://application/user/WrongChildActor
SampleService was executed.
pekko://application/user/WrongParentActor
pekko://application/user/WrongChildActor
SampleService was executed.
pekko://application/user/WrongParentActor
pekko://application/user/WrongChildActor
SampleService was executed.

愚直にやるならば、次のように親Actorに子Actorが必要とする依存関係を注入してしまい、親Actorから子Actorにバケツリレーするという方法があるだろう。

package scheduler

import com.google.inject.{AbstractModule, Provides}
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import play.api.libs.concurrent.{ActorModule, PekkoGuiceSupport}

import java.util.UUID
import javax.inject.Inject

object ParentActor extends ActorModule {
  sealed trait ParentActorMessage
  case object Hello extends ParentActorMessage

  override type Message = ParentActorMessage

  @Provides
  def create(sampleService: SampleService): Behavior[ParentActorMessage] =
    Behaviors.setup { ctx =>
      Behaviors.receiveMessage { case Hello =>
        println(ctx.self.path)
        // 同時に同じ名前で複数のActorを生成できないため、UUIDを名前の一部とする
        val child = ctx.spawn(
          ChildActor.create(sampleService),
          s"ChildActor${UUID.randomUUID()}"
        )
        child ! ChildActor.Hello
        Behaviors.same
      }
    }
}

object ChildActor {
  sealed trait Message
  case object Hello extends Message

  def create(sampleService: SampleService): Behavior[Message] =
    Behaviors.setup { ctx =>
      Behaviors.receiveMessage { case Hello =>
        println(ctx.self.path)
        sampleService.exec()
        Behaviors.same
      }
    }
}

class ParentActorScheduler @Inject() (
    schedulerSetting: SchedulerSetting,
    parentActor: ActorRef[ParentActor.ParentActorMessage]
) {
  schedulerSetting.scheduler.scheduleTyped(
    "Every5Seconds",
    parentActor,
    ParentActor.Hello
  )
}

class ParentActorModule extends AbstractModule with PekkoGuiceSupport {
  override def configure(): Unit = {
    bindTypedActor(ParentActor, "ParentActor")
    bind(classOf[ParentActorScheduler]).asEagerSingleton()
  }
}

実際、動かすと以下のようにChildActorがParentActorの子になっている。 なお、本来はちゃんとChildActorはstopしないとメモリ上に残り続けてしまうが、今回は説明の簡略化のため無視している。

pekko://application/user/ParentActor
pekko://application/user/ParentActor/ChildActorf5d2e045-f2cd-4fa6-bed7-386b48b3c536
SampleService was executed.
pekko://application/user/ParentActor
pekko://application/user/ParentActor/ChildActor95d59545-c9a5-4e2f-b655-3e5594549d24
SampleService was executed.
pekko://application/user/ParentActor
pekko://application/user/ParentActor/ChildActor1c83857f-9b9d-440c-9750-fbebfe1a04e9
SampleService was executed.

ただ、見ていてわかるかもしれないが、これは筋のいい方法ではない。 親Actorが自分で使っている依存関係と子Actorに渡したいだけの依存関係の区別がぱっと見でつかないし、もし子Actorが追加で依存関係を必要としたら親Actorまで修正を入れなければならない。 親子関係が更にネストした状態でそんなことはやりたくないだろう。

これに対するベストプラクティスは探したのだが見つからなかったので、私は以下のように子ActorのBehaviorを生成するメソッドを呼び出すだけのヘルパークラスを用意することで解決している。

package scheduler

import com.google.inject.{AbstractModule, Provides}
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import play.api.libs.concurrent.{ActorModule, PekkoGuiceSupport}

import java.util.UUID
import javax.inject.Inject

object ImprovedParentActor extends ActorModule {
  sealed trait ImprovedParentActorMessage
  case object Hello extends ImprovedParentActorMessage

  override type Message = ImprovedParentActorMessage

  @Provides
  def create(
      behaviorGenerator: BehaviorGenerator
  ): Behavior[ImprovedParentActorMessage] = Behaviors.setup { ctx =>
    Behaviors.receiveMessage { case Hello =>
      println(ctx.self.path)
      val child = ctx.spawn(
        behaviorGenerator.create(),
        s"ImprovedChildActor${UUID.randomUUID()}"
      )
      child ! ImprovedChildActor.Hello
      Behaviors.same
    }
  }
}

object ImprovedChildActor {
  sealed trait Message
  case object Hello extends Message

  def create(sampleService: SampleService): Behavior[Message] =
    Behaviors.setup { ctx =>
      Behaviors.receiveMessage { case Hello =>
        println(ctx.self.path)
        sampleService.exec()
        Behaviors.same
      }
    }
}

class BehaviorGenerator @Inject() (sampleService: SampleService) {
  def create(): Behavior[ImprovedChildActor.Message] =
    ImprovedChildActor.create(sampleService)
}

class ImprovedParentActorScheduler @Inject() (
    schedulerSetting: SchedulerSetting,
    improvedParentActor: ActorRef[
      ImprovedParentActor.ImprovedParentActorMessage
    ]
) {
  schedulerSetting.scheduler.scheduleTyped(
    "Every5Seconds",
    improvedParentActor,
    ImprovedParentActor.Hello
  )
}

class ImprovedParentActorModule extends AbstractModule with PekkoGuiceSupport {
  override def configure(): Unit = {
    bindTypedActor(ImprovedParentActor, "ImprovedParentActor")
    bind(classOf[ImprovedParentActorScheduler]).asEagerSingleton()
  }
}
pekko://application/user/ImprovedParentActor
pekko://application/user/ImprovedParentActor/ImprovedChildActor4dc53dcd-0411-44f5-9f2f-9c08b66cbd87
SampleService was executed.
pekko://application/user/ImprovedParentActor
pekko://application/user/ImprovedParentActor/ImprovedChildActor33053a72-2db4-4e16-ac7f-56157ce18c5f
SampleService was executed.
pekko://application/user/ImprovedParentActor
pekko://application/user/ImprovedParentActor/ImprovedChildActorcb8dfc8a-5c06-4e2e-9d03-78d3f9963f58
SampleService was executed.

このようにすれば、もしImprovedChildActorが新たな依存関係を必要としたとしても修正が必要なのはImprovedChildActorとBehaviorGeneratorのみだ。 更に子孫のActorが登場したとしても、同様に孫用のBehaviorGeneratorにあたるクラスを用意し、そのクラスをBehaviorGeneratorにDIしてやれば対応できる。

ただ、くどいようだが私が見落としているだけでベストプラクティスがどこかに書かれているかもしれないので、見つけたら教えてほしい。

Guiceが生成するインスタンスをMockitoでspyする

Play FrameworkではGoogle Guiceが標準で採用されている。 統合テストを書いているとGuiceが生成するインスタンスをspyに差し替えたくなる時がごく稀にある。 この場合、Injector.getInstance()が返したインスタンスをspyにするのではなく、Injector.getInstance()のインスタンスを生成する処理の中でspyにしてやらないと、テスト対象の依存関係の中にspyが組み込まれない。

Injectorがインスタンスを生成する処理をカスタマイズしたい場合、インスタンスの生成手順をメソッドで定義する@Provides Methodsや、インスタンスを生成するメソッドを持つクラスを定義するProviderが使える。 @Provides Methodsを使った場合は本番用のコードの中にテストのためのコードを追加しなければならなくなるため、今回の目的ならテストコードを書くディレクトリでProviderを定義してやる方がいいだろう。

package com.pkinop.example

import com.google.inject.Injector
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.{doAnswer, spy, times, verify}
import org.mockito.invocation.InvocationOnMock
import org.mockito.stubbing.Answer
import org.scalatest.TestData
import org.scalatestplus.play.PlaySpec
import org.scalatestplus.play.guice.GuiceOneAppPerTest
import play.api.Application
import play.api.inject.bind
import play.api.inject.guice.GuiceApplicationBuilder

import javax.inject.{Inject, Provider, Singleton}

trait Reverser {
  def reverse(x: Int): Int
}

class ReverserImpl @Inject()() extends Reverser {
  def reverse(x: Int): Int = -x
}

class ReverserProvider @Inject()(injector: Injector)
  extends Provider[Reverser] {
  override def get(): Reverser = spy(
    injector.getInstance(classOf[ReverserImpl])
  )
}

class ReverserClient @Inject()(reverser: Reverser) {
  def reverse(x: Int): Int = reverser.reverse(x)
}

class ReverserSpec
  extends PlaySpec
  with GuiceOneAppPerTest {
  override def newAppForTest(testData: TestData): Application =
    GuiceApplicationBuilder().overrides(
      // SingletonにしておかないとReverserを要求する場所ごとに別のspyが生成されてしまい
      // 動作の変更や呼び出しの検証を行うspyが依存関係の解決に使われなくなってしまう
      bind[Reverser]
        .toProvider(classOf[ReverserProvider])
        .in[Singleton]
    ).build()

  "呼び出しをテストする" in {
    val reverserClient = app.injector.instanceOf[ReverserClient]

    val result = reverserClient.reverse(1)

    result mustBe -1
    val reverser = app.injector.instanceOf[Reverser]
    verify(reverser, times(1))
      .reverse(1)
  }

  "実装を変更してみる" in {
    val reverser = app.injector.instanceOf[Reverser]
    doAnswer(new Answer[Int] {
      override def answer(invocation: InvocationOnMock): Int = {
        val original = invocation.callRealMethod().asInstanceOf[Int]
        original * 10
      }
    }).when(reverser).reverse(any)
    val reverserClient = app.injector.instanceOf[ReverserClient]

    val result = reverserClient.reverse(1)

    result mustBe -10
  }
}

実際にReverserClientがReverserのインスタンスを要求すると、ReverserImplのspyが依存関係の解決に使われているのがわかるだろう。 ProviderもGuiceで生成でき、Providerの中でもGuiceによる依存関係の解決ができるというのがミソだ。

なおこの方法では具象クラスのインスタンスを要求している箇所でspyに差し替えることはできない。 ここでわざわざReverserとReverserImplを用意しているのもそれが理由だ。 仮にReverserというクラスを用意してそのまま実装を書くと、以下のようになる。

class Reverser @Inject()() extends Reverser {
  def reverse(x: Int): Int = -x
}

class ReverserProvider @Inject()(injector: Injector)
  extends Provider[Reverser] {
  override def get(): Reverser = spy(
    injector.getInstance(classOf[Reverser])
  )
}

すると、GuiceはReverserのインスタンスを生成するためにReverserProvider.get()を呼び出すが、その中ではGuiceを使ってReverserのインスタンスを生成しているため循環参照に陥ってしまう。

もっとも、spyに差し替えたくなるようなクラスはインターフェイスが用意されていることがほとんどだろうし、そうでなくてもインターフェイスを用意するだけであれば本番用のコードの中にテスト用の実装が入るわけではないから、テストのために本番コードを歪める行為の中ではだいぶマシな部類であり、この制限が問題になることはあまり無いだろう。

「単体テストの考え方/使い方」で得た知見をレガシーコードの多いプロジェクトで実践してみた感想

本書の感想だが、Railsでの開発で最初にRSpecの使い方を学んだきりテストについて全く学んでこなかった身としては、テストコードを書く際の指針、プラクティスがはっきりし、非常によい内容だった。 その上で、学んだ内容を実際にプロジェクトで実践しようとした際、レガシーコードまみれのプロジェクトであることが起因して色々と問題があった。

私が関わっているプロジェクトで単体テストを書けるコードはほぼ無い

私がこの本を読んで得た一番の学びの1つがこれだった。 単体テストの定義をおさらいすると

  • 「単体」と呼ばれる少量のコードを検証する
  • 実行時間が短い
  • 隔離された状態で実行される

の3つを満たすテストコードが単体テストとなる。 この内1つ目と3つ目の定義には解釈の余地があり、その解釈をめぐってデトロイト学派とロンドン学派という2つのスタイルが存在している。

この本の著者は古典学派であり、古典学派の立場では「単体」とはビジネス上の単体のふるまいを指し、「隔離」というのはそれぞれのテストケースが他のテストケースから隔離された状態で実行されるということを意味する。

ここで私が現在携わっているプロジェクトについて話をしなければならないのだが、以下のような残念な状態になっている。

  • アプリケーション外部への副作用を持つ依存関係が何個もあるクラスに複雑なロジックが書かれている
    • 酷いときにはControllerからRepositoryを呼び出してそのままロジックが始まったりする
  • domainというパッケージの中には、DBのテーブル構造を写しただけの何のメソッドも持たない構造体のようなものが入ってる
  • DBのテーブル1つに対しRepositoryが作られており、集約はほとんど活用されていない

この本では、それぞれのクラスを「依存関係が多い/少ない」, 「コードが複雑/複雑でない」という2つの軸で評価できるとしており、それぞれのクラスは以下のように名前が付けられている。

依存関係が少ない 依存関係が多い
コードが複雑 ドメインモデル/アルゴリズム 過度に複雑なコード
コードが複雑でない 取るに足らないコード コントローラ

この内、過度に複雑なコードはテストしないと危険だが、依存関係が多いために簡単にはテストができないという厄介なものだ。 このようなクラスはコードの複雑さが高いが依存関係をほとんど持たない「ドメインモデル/アルゴリズム」と、依存関係を多く持つがコードの複雑さがほとんどない「コントローラ」に分離する必要がある。 、依存関係が多くロジックも複雑なクラスはテストしないと危険だがテストが困難であるため、依存関係が多いが複雑なロジックを持たないクラスと、ロジックが複雑だがアプリケーション外部への副作用を持つ依存関係は持たないクラスに分ける必要があると説いている。 そして、依存関係が少なくテストしやすい「ドメインモデル/アルゴリズム」を単体テストで、依存関係が多いが複雑なロジックを持たない「コントローラ」は必要があれば統合テストによりカバーしていく。

ここで私のプロジェクトを振り返ってみると、「ドメインモデル/アルゴリズム」に書かれるべきだったビジネスロジックの大半が「コントローラ」に流れ出てしまい、「過度に複雑なコード」が大量に生まれてしまっている状態だ。 ビジネスロジックが漏れ出てしまったドメインモデルのなり損ないについては「取るに足らないコード」に分類できる。 このような状況では、実行時間が短く他のテストケースから隔離された状態で実行される単体テストは書きようがない。 リファクタリングの前に追加されるテストは統合テストにならざるを得ない。

レガシーコードまみれのプロジェクトではリファクタ耐性を意識して統合テストを追加していくしかない

ではこの本を読んだのは無駄だったのかと言えば、「単体テストの考え方/使い方」というタイトルに反して統合テストのプラクティスについても書いてあったので全く無駄ではなかった。 本書では、質のいい単体テストの性質として以下の4つを挙げている。

残念ながら、これら4つの要素の内最初の3つは互いにトレードオフの関係になってしまう。 実際に単体テストを作成する際はリファクタリング耐性を重要視しつつも、退行への保護と迅速なフィードバックへの間でバランスを取る必要がある。 統合テストは、これの内3, 4つ目の性質を犠牲にし、代わりにより強い対抗への保護, リファクタリング耐性を獲得したテストだと考えられる。 どうしても実行時間がかさむので迅速なフィードバックは得られなくなるし、セットアップなども長くなりがちなのでテストコードは単体テストより長くなり、その分保守性は低くなってしまう。

レガシーコードは必然的にテストで保護された後でリファクタリングを受けるので、退行への保護はもちろんリファクタリング耐性が極めて重要になってくる。 リファクタリング耐性を落とす要因として、モックすべきでないクラスのモック化が挙げられる。 リファクタリングされていく内に、もともとモックしていたクラスの呼び出しが行われなくなり、モックの呼び出しをテストしていたテストケースが落ちる。 このようなテストの失敗は、テストが落ちた原因の調査に時間をとられる分、コンパイルエラーになるようなテストの壊れ方よりだいぶ性質が悪い。

ではどのようなクラスをモックすべきかとこの本は説いているのかというと「アプリケーションの管理下にないプロセス外依存だけをモックせよ」と書いている。 順に説明していくと、まず「プロセス外依存」とは、DBや外部APIやファイルシステムなどプロセスに割り当てられたメモリの外の存在にアクセスすることを指す。 次に「アプリケーション管理下にない」とは、アプリケーションを経由する以外にアクセスする方法が用意されていることを意味する。 たとえばDBなら、そのアプリケーションからしか直接読み書きされないならアプリケーションの管理下にあるプロセス外依存だが、もしそのDBを他のアプリケーションも利用するのであれば、アプリケーションの管理下にないプロセス外依存となる。 外から利用されないアプリケーションの管理下にあるプロセス外依存は実装の詳細であると考えられ、実装の詳細をテストしてしまっているテストは壊れやすくなる。

これまで私は統合テストの実行時間削減のためにRepositoryをモックするか、そもそも前術の「DBのテーブル1つに対しRepositoryが作られており、集約はほとんど活用されていない」という事情があるためにRepositoryの設計自体がイケてなく作り直すことになるのだからDBのレコードを確認するテストを書くべきなのか悩んでいたが、この辺の話を読んで、統合テストは迅速なフィードバックなど考えずDBをそのまま使って、退行への保護とリタクタリング耐性に全振りすべきなのだと理解できた。

今後解決したい悩み

ただ、この本に書いてある内容は正常なテストピラミッドが構築されていることが前提になる。 テストの割合は単体テストが最も多く、統合テストはそれより少なく、最後にごくわずかなE2Eテストがあるべきというあれだ。 単体テストが書けずひたすら統合テストを追加しているのだから、当然歪なテストピラミッドになる。 するとテストの実行時間がどんどん膨らんでいく。

このままではCIでテストを回す際に困ってしまうというのもあるのだが、1回の実行に30秒くらいかかるテストスイートを頼りにリファクタリングを行うというのは実際やっていてあまり開発体験がよくない。 リファクタ後は統合テストをやめて単体テストできる部分は単体テストに切り替えていきたいが、なかなかそこまで手が回らないのが現状だ。 この点については今後プラクティスを探っていきたい。

「単体テストの考え方/使い方」第3部, 4部を読んで考えたこと

特に後半は細かいプラクティスの話が多くなり、どうしても人によって「そうかな?」と思わされる内容も多そうだった。 とはいえ大事な考え方も書かれているので一読の価値は間違いなくあると感じた。

前回 p-kino.hatenablog.com

repositoryのインターフェイスを用意するか

この本のテストでのDBの扱いに関しての主張は以下のようなものだった

  • 複雑なビジネスロジックを持つドメインモデルとプロセス外依存を扱うクラスの連携を指揮する「コントローラ」に分類されるコードに対して統合テストを書くべき
  • 統合テストはテストケースを同時実行できるようにしようとすると保守コストが大きくなってしまうため、単体テストと違いテストケースを隔離しなくともよく、実際のDBにアクセスしてもよい
  • テスト対象のアプリケーションを通してしかアクセスされないDBは「実装の詳細」だと考えられ、実装の詳細をモックしてしまうとテストは壊れやすくなりリファクタリングへの耐性を失うため、モックすべきでない
  • 統合テストは本番環境のアプリケーションと同じ処理を実行させることが大事なので、インメモリな実装に置き換えたrepositoryを代わりに使うこともすべきではない
  • 実装が1つしかなく、モックするわけでもないクラスはインターフェイスを用意すべきではない(YAGNI原則に違反するため)

特にインメモリでDBを模したrepositoryについては、私も「そのrepositoryの実装にミスがあったら破綻するのでは?」と比定寄りの考えを持っていた。 となると、たしかにusecase層がinfrastructure層に依存しないよう依存性逆転を行うという目的こそあるが、それも形骸化してしまいいよいよrepositoryのインターフェイスを作成する意味がよくわからなくなってくる。 ただ、私はそれでもusecase層からrepositoryを呼び出させることを意図しているのであればインターフェイスを用意すべきだし、そうでないなら用意しないべきではないかと思った。

生成方法が複雑なドメインオブジェクトはその生成過程をfactoryに隠蔽するが、infrastructure層にfactoryだけあっても使う側がそれに気付けないと意味がない。 しかしDomain層でドメインモデルと同じパッケージにfactoryのインターフェイスが置いてあれば「これを使えばいいんだな」とわかる。 Scalaなら、repositoryをfactory以外から呼び出してほしくない場合はinfrastructure層のパッケージの外に公開しないこともできる。

このように、インターフェイスはusecase層に操作を公開するという役割もあり、テストにおいてモックすることが望ましくないとはいえ必要に応じて用意しておいた方がいいのではないかという気がした。

テストで本番と違う実装を使う

ここで思い出してほしいのが、テストでは本番環境とまったく同じ方法でテスト対象のコードとやり取りをしなくてはならない、ということです。つまり、テストだからと言って特別なことが許されるわけではないのです。

『単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.378

これは、テストのためにprivateメソッドを公開してしまうのは、実装の詳細とテストが結びついてしまい壊れやすいテストになってしまうためアンチパターンであるという文脈で書かれていたことだ。

privateメソッドの公開に限らずテストのために特別なことをやってしまわないよう気をつけたいが、必ずしもそういうわけには行かないのではないかと感じた。私は少し前にこのルールを破らなければならないことがあった。

つい最近、顧客の要望でakka-quartz-schedulerを使ってバッチ処理を実装する機会があった。 その際のバッチ処理の内容は以下のようなものだった。

class SampleBatchProcessService(
  repository: CustomerInfoRepository
) {
  def process(customerId: Long): Unit =
    repository
      .find(customerId)
      .foreach { config =>
        // DBの変更を含む色々な処理
      }
}

class CustomerInfoRepository {
  def find(customerId: Long): Option[CustomerInfo]
}

case class CustomerInfo(foo: Long, bar: String)

このSampleBatchProcessServiceがActorから呼び出されていた。 ここで、顧客から「バッチ処理をしばらく止めたくなることがあるかもしれない」と言われたので、バッチ処理が有効になっているかをTINYINTで持つbatch_process_enabledというカラムをCustomerInfoを構成するためのテーブルの一部に追加し、それが1でない場合はCustomerInfoRepository.find()で見つからないようにすることでバッチ処理を止められるようにした。

このバッチ処理だが、結構な高頻度で実行されており、バッチ処理が実行されている間にbatch_process_enabledの値を変更しなければならないことも十分考えられた。 その場合、途中で処理を止めてしまうとDBの中身がおかしな状態になってしまう可能性が無いとは言えないため、既に走っているバッチ処理については最後まで通常どおり実行し、次回以降の実行はbatch_process_enabledが1に戻るまで行わないという動作になっていてほしい。

これは統合テストで見ておくシナリオだと思ったのだが、テストを書くにあたって1つ問題があった。 実際の手順を再現するのであればバッチ処理を行うActorにメッセージを送った直後にbatch_process_enabledの値を0にし、Actorから完了を伝えるメッセージが返ってきたらDBがバッチ処理を行なったあとの状態として正しい内容になっているかを確認すればよいのだが、テストコードではActorの処理がすぐ終わってしまい、なおかつバッチ処理の実行中にbatch_process_enabledが変わろうが変わるまいが期待するバッチ処理実行後のDBの状態は変わらないため、本当に

  • Actorがメッセージを受け取り、SampleBatchProcessServiceが呼び出され、CustomerInfoRepositoryからCustomerInfoが見つかる
  • batch_process_enabledが0に変更される
  • SampleBatchProcessServiceの処理が最後まで実行される

という順番で処理されているかわからなかった。なので、以下のようにspyを使ってCustomerInfoRepositoryのふるまいを変更して注入した。

val spyRepository = spy(new CustomerInfoRepository())
doAnswer(new Answer[AnyRef] {
  override def answer(invocation: InvocationOnMock): AnyRef = {
    val info = invocation.callRealMethod()

    // batch_process_enabledを0にするコード

    info
  }
}).when(spyRepository).find(any)

一応invocation.callRealMethod()で実際の実装が使われるが、完全に同じ実装というわけには行かなかった。 そもそもこの本の主張からすれば、テスト対象のプロジェクト以外からアクセスされることが全くないDBは管理下にある依存なので、モックすること自体が望ましくない。

ただ、一方で「バッチ処理の実行中にバッチ処理が行われないよう切り替える」というのはいかにもそのうち1回は行われそうな操作であり、しかもそんなことが行われる場合は既に何か他の問題が起きていることも考えられる。 そのような状況で更にデータを壊すようなバグを引き起こしてしまうというのはなるべく避けたく、ぜひとも統合テストでカバーしておきたいシナリオの1つだった。

繰り返しになるが、たしかに統合テストはビジネス上のシナリオで想定される操作をテストでも再現する必要がある。ただし、このようなビジネス上必ず検証しておく必要があるシナリオがあるが検証が難しい場合は、なるべく実際の操作から離れないようにしつつもある程度セオリーを破る必要もあるのではないかという気がした。

統合テストとpackage privateどちらをとるか

第4章で見たように、退行に対する保護はテスト中に実行されるコードの量によって変わります。そのため、管理下にない依存とのコミュニケーションの流れの中で、モックに置き換えられるコンポーネントをアプリケーションの境界に近いものにすれば、テストを実施する際に経由されるクラスの数が増え、より強力な退行に対する保護を得られるようになります。

『単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.313-314

とても大事な、原則と言ってもよさそうなルールだと感じたのだが、このルールに従うと1つだけ残念なことが起きた。

そもそもこの話が出てきたのは以下のようなクラスがあった場合どちらをモックすべきかという文脈だった。

class SlackWebApiClient(client: WSClient) {
  val WebApiBaseUri = new URI("https://slack.com/api")

  def postMessage(
    authorizationToken: String,
    channelName: String,
    message: String,
  )(implicit ec: ExecutionContext): Future[Unit] =
    client
      .url(WebApiBaseUri.resolve("chat.postMessage").toString)
      .withHttpHeaders(
        "Authorization" -> s"Bearer $authorizationToken",
        "Content-Type" -> "application/json",
      )
      .post(
        Json.obj(
          "channel" -> channelName,
          "text" -> message,
        )
      )
      .map { response =>
        // SlackWebAPIはレスポンスのJSONのokプロパティで成否を判定する
        Try((response.json \ "ok").as[Boolean]) match {
          case Success(true) =>
            ()
          case Success(false) =>
            throw new Exception("SlackWebAPIの実行に失敗しました。")
          case Failure(_) =>
            throw new Exception(
              "SlackWebAPIから想定していない形式のレスポンスを受け取りました。"
            )
        }
      }
}

case class BatchResult(taskName: String, customerName: String)

/**
 * 顧客に関する何らかのバッチ処理の結果を顧客のSlackチャンネルに
 * 通知する用途に特化したSlackWebApiClientのラッパー
 */
class BatchResultNotifier(client: SlackWebApiClient) {
  val AuthorizationToken = "foo"
  val Channel = "notification"

  def notifyComplete(result: BatchResult): Future[Unit] =
    client.postMessage(
      AuthorizationToken,
      Channel,
      s"${result.customerName} 様: ${result.taskName}が完了しました。"
    )
}

エラーハンドリングがガバガバだったり、認証用トークンやら通知先のチャンネル名やらがハードコーディングしてあるのはサンプルコードなので見逃してほしい。 この時、BatchResultNotifierを使うコードの統合テストではBatchResultNotifierをモックするのではなく、最終的に顧客のSlackに投稿を行うSlackWebApiClientをモックすべきだというのが本書の主張だ。

なお、実際に最終的にSlackへの投稿を行なっているのはWSClientじゃないかと思うかもしれないが、本書ではサードパーティ製のライブラリについては直接モックするのではなくアダプタとなるクラスを作成し、それをモックすることを推奨している。 実際、WSClientはBuilderパターンを採用しており、url()やwithHttpHeaders()などのメソッドが全て新しいWSClientのインスタンスを返すようになっているため、モックしようとすると面倒なことこの上ない。

たしかに、SlackWebApiClientのメソッドに渡される引数を確認しておけば、SlackWebApiClientに変更が入りでもしない限りは顧客に最終的に送られるメッセージの内容が変わってしまう可能性は低く、強力な対抗への保護が手に入る。

ただ、気にする人もそこまでいないのかもしれないがこのルールはScalaの機能と少し折り合いが悪い点がある。 Scalaにはprivate[foo] class Bar {}というように書くことでfooパッケージの中でのみBarを公開するということができる。 私はこのようなアクセス制御はコード補完のサジェスト汚染を回避する意味合いでもなるべくちゃんとやっておきたいと考えている。 上のコードで言えば、SlackWebApiClientを全体に公開してしまうと、認証用のトークンをどう取得するかといったビジネス上あまり重要でない処理がinfrastructure層の外で書かれてしまう可能性がある。 なので、当然SlackWebApiClientはinfrastructure層に該当するパッケージの中でのみ公開し、外部からはBatchResultNotifierのような用途ごとのラッパーを作らせてそちらを使わせたかった。

すると何が起こるか。 統合テストが書かれるパッケージからはSlackWebApiClientが参照できなくなるのでモックできなくなるのである。 SlackWebApiClientのインターフェイスを用意しそちらを公開すればよいのではないかと思うかもしれないが、PlayFrameworkを使った開発でGuiceを使っている場合はインターフェイスさえあれば実質SlackWebApiClientの機能が呼び出せてしまうので何の解決にもならない。 結局、品質の高いテストを書くことを優先し、SlackWebApiClientはpublicにしたが、何かうまいやり方は無いかとモヤモヤしてしまった。

感想

「単体テストの考え方/使い方」というタイトルに反し、優れた統合テストを書くための指針についても書いてくれているありがたいパートだった。 特にモックについてはこれまで「テストが速く終わる方がいいんだ!」と無闇やたらにRepositoryをモックにしていたと思えば、でも今のRepositoryの設計がイケていないのでいつか作り直す時のためにモックではなくDBを使いたいと考え始めたり、使い方に明確な指針を持てていなかったのでとても参考になった。

10章は、DBを使った統合テストを行うためにはスキーマをVCSで管理できるようにしておくだとか、開発者ごとに個別のDBインスタンスを用意するだとか、マイグレーションツールを使っているのであれば自ずと達成されているであろう内容も多かったが、むしろRDB以外を使った統合テストを書かなければならない時にこういったことを考える必要があるのかもしれないと思った。