最近DDDや値オブジェクトやドメインオブジェクトの定義が一部界隈で話題です。kumagi sanとかとじゅんさんの間で熱い議論が何日にも渡って繰り広げられています。
kumagiさん眠たいんですが…。続きは明日でもいいですか。
— かとじゅん (@j5ik2o) 2022年5月19日
大変ですね!
ぼくも違うチャネルでkumagi sanに色々理解をぶつけてみたものの全然違うようで色々と教えてもらったりしました。
しかしながら、これ実際どこでどう使うのかなと。大先生がこんな事を言ってました。
言葉遊びしてるんじゃねえんだぞ、動くものを作れ
— Yoshi Yamaguchi (@ymotongpoo) 2022年5月19日
というわけで、最近書いたコードを題材にちょっとリファクタリングしながら考えてみたいなと思います。
ちなみに私、学生時代に2000万MAUくらいのWebサービスを作って以来、あまりまともなプロダクションコードは書いていません。ので、多分に我流ですがご笑納ください。みんな動くコードでDDDしようぜ!
- 何を作ったのか:スキャンしたPDFをマージするスクリプト
- リファクタリング前のできたコード
- DDDの4層モデルのおさらい
- どうやってリファクタリングするか
- リファクタリング結果
- ユーザインターフェース層の分離
- アプリケーション層をスッキリさせる
- ドメイン層
- インフラストラクチャー層
- 抽象化のために冗長にはなったけど見通し良くなったよね?
- 結局のところドメインオブジェクトとか値オブジェクトとかって完璧に作り切るべきなのか
何を作ったのか:スキャンしたPDFをマージするスクリプト
先日、A3のコピーが取りたくなって家庭におけるコンパクトな複合機を買いました。
はい。凄くコンパクトですね!しかし、残念ながら片面スキャンしかできないのでした。
片面スキャンしかできないスキャナで本をスキャンすると偶数ページと奇数ページのPDFができてしまいます。ので、これを1ページずつ交互にマージしないといけないんですね。妻がもうスキャン予定の本を裁断してしまってます。これはやばい。
なんとかしなければ。
リファクタリング前のできたコード
あまり人に見せる予定がなかったので若干あれな感じですが、これをDDD的にどうなんよっていうのを見ながらリファクタリングしていこうと思います。
giste8dd67f38f65c872b265040cf648c3d0
DDDの4層モデルのおさらい
Evansのドメイン駆動開発では4層に分類しています。
- ユーザインタフェース
ユーザに情報を表示して、ユーザのコマンドを解釈する責務を負う。外部アクタは人間のユーザではなく、別のコンピュータシステムのこともある。
- アプリケーション層
ソフトウェアが行うことになっている仕事を定義し、表現力豊かなドメインオブジェクトが問題を解決するように導く。
- ドメイン層
ビジネスの概念と、ビジネスが置かれた状況に関する情報、およびビジネスルールを表す責務を負う - インフラストラクチャ層
上位のレイヤを支える一般的な技術的機能を提供する。
ドメイン駆動開発というのは僕の雑な理解によると、このドメイン層こそがビジネスに重要なロジックを担っており、ここを分離して正しくモデル化できれば保守性の高いコードになるということなのだと思います。
概ね同意できるのではないかと思います。結局の所ほとんどのシステムはどこからどこにデータを移動して移動するときにどういった加工をするのかという問題に集約できます。それが、ユーザ相手だとユーザインターフェース、永続化したデータだとインフラストラクチャ層からデータがくるということです。このドメイン層こそがシステムの本質であるというのは間違いないでしょう。
どうやってリファクタリングするか
まずざざっと4層に分けてみたいなと思います。先に挙げたコードのうち下記がおおむねUI層と言えるでしょう。
gist42e9e40da401f97c91ddd0c081c71030
argparseに馴染みがない人もいると思うので軽く解説すると、terminalで実行したときの引数をいい感じにパースして変数に詰め込んでくれて、軽い型チェックや必須チェックをしてくれて、usageのhelpも自動生成してくれるすぐれものです。
また、アプリケーション層はどこかというと多分この辺です。
gist20a8b1141d62a1a59ed82def49600d47
ドメインっぽいものやインフラストラクチャに該当しそうなものがまんべんなく混ざってますね・・・これをリファクタリングして見やすくしてみましょう。
ここまでの理解をベースにザザザっと書き換えてみましょう。
リファクタリング結果
リファクタリングした結果を最初に示してそこから解説をしたいと思います。もともと48行だったのに100行超えてます・・・抽象化するとこうなるのは仕方がないですね。
gistbd833002df86c6a8d015882ee2cefd16
ユーザインターフェース層の分離
まずはパラメーターのパース部分をユーザーインターフェース層として分離しました。ほぼコピペしただけなので特に議論はないと思います。
gist5b3cb3567f0418e62a8d8d07080c2820
アプリケーション層をスッキリさせる
ビジネスロジックをモデルに逃したのでアプリケーションはものすごくスッキリしました。beforeは28行あったのにAfterは17行になりましたし、一見してなにをしているのかぱっと分かるようになりました。
どのへんがポイントかというと難しいんですが、アプリケーション層から直接生のデータを触ることをやめてモデル経由で操作を行うようにしたことで、アプリケーション側から見たときに抽象度の高いメソッドだけを扱えるようになったので可読性が格段に上がったのがポイントかと思います。
gisteb93d4ee840059d4ab6b615e1a4e35af
ドメイン層
ビジネスロジックを分離してドメイン型にしたのでこの部分がものすごく長くなっています。つくったドメイン型は3つです。
- PDFParameter
最近話題の値オブジェクト。PDFのファイル名とPDFが逆順かどうかのパラメータを持ちます。スキャンの仕方によってPDFのページ順が逆になることがあるため逆順フラグを持っています。 - PDF
PDFの実態をハンドリングするクラス。PDFParameterを受けてPDFを読み込んだり現在のページを状態として持っています。 - PDFMerger
PDFをマージするクラス。PDFをappendして最後にwriteでファイルを書き出します。
一つずつ見ていきましょう。まずはPDFParameterからです。
gist30123b3171c967764a1bca99b118bded
これは非常にシンプルですね。PDFのファイル名とページ順が逆順かどうかのフラグを持っています。これがいわゆる値オブジェクトです。パラメーターはreadonlyで変更されないようにしています。
値オブジェクトならequalsとか実装しろとか言われそうですが、今回のユースケースでそれは必要ないかと思います。YAGNI原則バンザイ。
つぎにPDFクラスです。これはちょっとだけ難解かもしれません。
gist04e762898c71c0d07ec2226e31b96be1
コンストラクタにPdfParameterをうけとります。この時点でファイルを読み込むべきなのではないかという話もありそうですが、個人的にはコンストラクタの中で例外が起きるのはあまり行儀が良いような気がしていないので僕はload()に分けます。コンストラクタはオブジェクトの初期化をすることは期待できますが、ファイルの読み込みという副作用をそこに盛り込む事が果たしていいのかは疑問です。
あり得る妥協点としてはfactoryメソッドを作って明示的にloadすることかなとも思いますが、そこは好みなのかなという気もします。factoryパターンのほうがload()も隠蔽できるのでより良いという考え方はあるかもしれません。
スキャンしたPDFは逆順になっている可能性があるという話を先程書きました。PDFクラスのユーザからすると逆順かどうかや残りページの確認などはできるだけ隠蔽されていたほうが使いやすいでしょう。なので、ページ送りのnext()関数を用意してこれを隠蔽します。するとユーザはnext()を単純に呼ぶだけでいいのですごく楽です。
最後にPdfMergerクラスです。これはそんなに難しくはないですね。
gist8cc3690bd016e1c66832247e6c8e9481
PdfMegerは出力先のファイル名を受け取って初期化され、appendにPDFインスタンスを与えることでそのページを追加していきます。writeで最後ファイルに書き出す感じです。
インフラストラクチャー層
今回はそんなに複雑なことしないと思ったので、open()とPyPDF2のwriteをそのまま使ってしまいました。もし今後拡張したりするのであれば依存性注入(DI)するなどしてこの入出力部分も汎化すると良いのかなと思いますが、これもYAGNI原則的に必要になるまではおいておけばよいのではないかと思います。
抽象化のために冗長にはなったけど見通し良くなったよね?
特に顕著なのはアプリケーション層でしょうか。ほぼ読めばわかるレベルに抽象化されているので何をしているかは一目瞭然です。
また、PDFのファイルの実態の取扱についてもPdfクラスに全面的に隠蔽したので、コードの凝集性(?)は格段に高まっています。beforeで冗長だったopenや条件分岐も一箇所になっているのでDRY原則的にも大変望ましいのではないでしょうか。
今回はサボってテスト書いてないんですが、テストも書きやすくなってるんじゃないかなと思います。beforeは機能ごとに分割されてないので全然testability担保できてなかったですよね。
ドメイン駆動開発の本をかってから3日目くらいなんですが、良いコードが書けるようになった気がしますね!
結局のところドメインオブジェクトとか値オブジェクトとかって完璧に作り切るべきなのか
個人的にはどーでもいいかなと(笑)。ただ、ぼく本業のエンジニアかというと怪しいのであくまで一意見としてという感じですが・・・
たとえば、今回作った値オブジェクトPdfPerameterにはequalsないんですが、使わないのに作るかというと僕は否定的です。比較するユースケースはないわけで必要になるまではなくてもいいかなと。
なにかの本によるとコンストラクタで値のチェックをしたほうが良いという話もあるようですが、それもなかなか悩ましいです。ファイル名には最長の長さが現実的には存在するわけですが、十分に長いのでそれをチェックすべきかどうかはよくわかりません。
そもそもpythonで書いてて思いましたが、今回型のアノテーションを使わなかったので型のありがたみが全く活用できていません。そこでいうと僕はそもそも技術選択で負けているような気もします。
しかし、それができなかったからと言ってpythonを選択しないのでしょうか。みんながみんな行儀よくコーディングできないから型のちからを借りたほうがベターは理解できますが、それができないものはすべて排除すべきなのでしょうか。それはよくわかりません。
今回、実際DDDらしきものを意識してコーディングしてみて、実際にコードの見通しは良くなったかなぁとは感じました。なのでこれを学ぶことはきっと重要なのかと思います。そのうえで思うのは、いろいろな道具に柔軟にそれを適用して応用していく力ということなんじゃないかなぁと思いました。
また、今回DDDは意識してみたものの、特段新しい斬新なテクニックがあったかというと特になかったような気はします。たぶん例がシンプルすぎたのだとは思いますが、「これぞDDDが特別に効果を発揮する素晴らしい例なのだ!」とかあったら是非動く例で見てみたいですね!