パッケージ設計をすべきか否か
たまたま会社で世間話をしているときに、
「Unityのパッケージってどうやって設計すべきですか」
という相談を受けました。知らんがな。私はUnityエンジニアじゃないし。と、思ったんですが、昔、自分もパッケージ設計やディレクトリ設計にこだわっていました。しかし、今となっては、ほぼほぼこだわらなくなりました。そして、その時に、
「質問が的を外している気がする」
ということをとっさに思いました。それは、なぜなのか?どうしてなのか?ということを少し説明したいと思いました。
インクリメンタルなコンポーネント設計
インクリメンタルなコンポーネント設計をしてみます。ここでは、簡単な例として、REST APIでユーザーを登録するAPIを考えます。pythonのflaskで書いてみると以下のようなコード(main1.py)になります。今回、ユーザー名として、6文字以上30文字以下の英数字しか使えないとしています。
from flask import Flask,request,jsonify
import uuid
import re
app = Flask(__name__)
users = dict()
@app.post("/users")
def create_user():
user_id = str(uuid.uuid4())
name = request.json["name"]
if re.match(r'^[a-zA-Z0-9]{6,30}$', name)==None:
return jsonify({"message": "name is invalid."}), 400
global users
user = {"id": user_id, "name": name}
users[user_id] = user
return jsonify(user)
特に難しいことをしておらず、requestのname要素から、値を取り出し、それをvalidateして、userへ登録するシンプルな作りです。
このコードですが、以下の様にvalidate_nameを関数として切り出して実装する方法もあります。これは原則に厳格に沿うのであれば実装してはいけません。(main1-1.py)
from flask import Flask,request,jsonify
import uuid
import re
app = Flask(__name__)
users = dict()
@app.post("/users")
def create_user():
user_id = str(uuid.uuid4())
name = request.json["name"]
if validate_name(name):
return jsonify({"message": "name is invalid."}), 400
global users
user = {"id": user_id, "name": name}
users[user_id] = user
return jsonify(user)
def validate_name(text):
return re.match(r'^[a-zA-Z0-9]{6,30}$', text)==None
どのような原則かというとYAGNIの原則に違反するからです。
"You ain't gonna need it"[1]、縮めて YAGNI とは、機能は実際に必要となるまでは追加しないのがよいとする、エクストリーム・プログラミングにおける原則である。
今、欲しいのはPOST /userでユーザーを登録する機能です。そのため、validate_nameは直接的には必要のない機能となります。反論はあると思いますが、一旦この視点を頭に入れてみてください。
次に、ユーザーの情報を変更するAPIを追加することを試みます。そうすると、以下のようなコードになります。(main2.py)
from flask import Flask,request,jsonify
import uuid
import re
app = Flask(__name__)
users = dict()
@app.post("/users")
def create_user():
user_id = str(uuid.uuid4())
name = request.json["name"]
if re.match(r'^[a-zA-Z0-9]{6,30}$', name)==None:
return jsonify({"message": "name is invalid."}), 400
global users
user = {"id": user_id, "name": name}
users[user_id] = user
return jsonify(user)
@app.put("/users/<user_id>")
def update_user(user_id):
name = request.json["name"]
if re.match(r'^[a-zA-Z0-9]{6,30}$', name)==None:
return jsonify({"message": "name is invalid."}), 400
global users
user = {"id": user_id, "name": name}
users[user_id] = user
return jsonify(user)
ここでDRY原則に違反していることに気づきます。
Don't repeat yourself(DRY)は、特にコンピューティングの領域で、重複を防ぐ考え方である。この哲学は、情報の重複は変更の困難さを増大し透明性を減少させ、不一致を生じる可能性につながるため、重複するべきでないことを強調する。
create_userとupdate_userで明らかにnameのバリデーションロジックが重複しています。これをリファクタリングして共通化しましょう。(main2-1.py)
from flask import Flask,request,jsonify
import uuid
import re
app = Flask(__name__)
users = dict()
@app.post("/users")
def create_user():
user_id = str(uuid.uuid4())
name = request.json["name"]
if validate_name(name):
return jsonify({"message": "name is invalid."}), 400
global users
user = {"id": user_id, "name": name}
users[user_id] = user
return jsonify(user)
@app.put("/users/<user_id>")
def update_user(user_id):
name = request.json["name"]
if validate_name(name):
return jsonify({"message": "name is invalid."}), 400
global users
user = {"id": user_id, "name": name}
users[user_id] = user
return jsonify(user)
def validate_name(text):
return re.match(r'^[a-zA-Z0-9]{6,30}$', text)==None
ここで私が言いたかったのは、 "必要であること" です。
まず初めに、main1.pyを作ったときは、ソフトウェアの要件は、
- POST /userでユーザーの情報を登録する機能を提供する
- ユーザー名は、6文字以上30文字以下の英数字
というものしかありませんでした。この時、ユーザー名のバリデーションロジックは1か所しかなく、そのロジックを切り出すことは、再利用可能な状況にする合理性がありませんでした。そのため、YAGNIの原則から考えると、main1-1.pyは不適切だと言えました。
しかし、要件が変わり、
- POST /userでユーザーの情報を登録する機能を提供する
- PUT /user/でユーザーの情報を更新する機能を提供する
- ユーザー名は、6文字以上30文字以下の英数字
となったとき、PUT /userにもユーザー名のバリデーションの機能を提供する必要が生まれました。この時、main2.pyの様にコードを書くとユーザー名のバリデーションの機能に重複が発生してしまいました。これは、DRYの原則に違反するため、validate_nameとして、バリデーションの機能を関数として分割する必要が生まれました。分割する必要が生まれたので、YAGNI原則に違反しないため、main2-1.pyは合理的なコードであるといえます。
事前のコンポーネント設計
ここでは、前節の仕様が明らかになっているとします
- POST /userでユーザーの情報を登録する機能を提供する
- PUT /user/でユーザーの情報を更新する機能を提供する
- ユーザー名は、6文字以上30文字以下の英数字
そうした場合、これらの要件を睨んで、おそらく、ユーザー情報の登録の要件とユーザー情報の更新の要件で、「ユーザー名のバリデーションのロジックを共通で使いそうだ」という予測を立てます。
それらの予測のもとに、
- create_userというPOST /userの処理を書く関数
- update_userというPUT /user/の処理を書く関数
- validate_nameというユーザー名をバリデーションする関数
という3つの関数が必要だ。と設計し、それぞれを実装していく運びになります。
インクリメンタルなコンポーネント設計と事前のコンポーネント設計のメリット・デメリット
設計方針 | メリット | デメリット |
---|---|---|
インクリメンタルなコンポーネント設計 | 設計がシンプルになる,随時リリースしやすい | テストが必須,開発が並列化しにくい |
事前のコンポーネント設計 | 開発が並列化しやすい,随時リリースしにくい | コードの重複が発生しうる,設計に失敗したときの手戻りが大きい |
ここで、"開発の並列化"について書きます。事前の設計の場合、create_userやupdate_userがvalidate_nameに依存していることが分かります。そのため、step1でvalidate_nameの実装が完了すると、step2として2人の開発者がcreate_userとupdate_userの実装が可能になります。このため、事前のクラス設計の場合、開発者が複数人いると、作業が並列化可能であるためスケールしやすい形になります。そのため、開発が並列化しやすいと言えます。
一方で、インクリメンタルな設計の場合、step1でcreate_userを作った後、step2でupdate_userを作ります。そして、コードの重複に気づき、step3でリファクタリングを行い、validate_userを関数化します。このように随時、設計を行うアプローチの場合、既存部分との重複を見ながらコードを改善するため、機能を実装する人数がスケールしにくくなってしまいます。そのため、開発者が2人いても1人しか作業できない状態が発生しうるので、並列化効率が悪い。と言えます。
これは、リリースとトレードオフの関係にあります。インクリメンタルな設計の場合、機能を1つずつ作り、それを順次、リリースしてユーザーの反応を見る。ということが可能になります。一方で、事前の設計の場合、複数のコンポーネントを要するため、そこが分業していると結合という作業が必要になり、リリースが遅延する可能性があります。
インクリメンタルなクラス設計は、作り方を注意深く行えば、コードの行数が最小限でシンプルな実装で済みます。しかし、この"作り方を注意深く行う"というワードは曲者です。そもそも、リファクタリングを行う。ということは、事前にテストを書いておき、それで動作が変わらないように保証して行うのが鉄則です。しかし、開発者自身が認知しやすいコードに書き換えることをリファクタリングと呼び、テストを書かずにコードを書き換える行為が横行しているために、バグを作りこんだり、デグレが起こったりする現状があります。また、コードを改善するということは、コードが重複している。といった"コードの悪い部分に気づく"というスキルも必要になり、そのスキルを高めるのも難しい。という課題もあります。
一方で、事前のクラス設計は、要件が大きくなると、最初のクラス設計を行うのが大変になります。また、要件を大きくすればするほど、人間が把握できなくなってくるので、クラス設計の境界を間違うようになり、最終的に機能が実現できないクラス設計をしてしまうことがあります。また、クラス設計の境界や関数のインターフェースを間違えて設計してしまうと、ほぼ内容は一緒だが、微妙に処理内容の違う関数群が発生したりします。
どちらを選択すべきか?
事前のコンポーネント設計をする場合は、どういった場合か?
- 要件について詳しいとき
- 採用する技術スタックについて詳しいとき
- 開発者が多いとき
例えば、自分が長年携わってきた既存のシステムであれば、要件について詳しいでしょう。そのため、ある程度、詳しくクラスレベルで分割することは可能であると考えられます。一方で、作ろうとしているシステムが自分が今まで使ったことの内容な技術スタックで、思想レベルで違うようなものであれば、そもそもクラス設計をすること自体が難しいでしょう。例えば、jqueryでしかサイトを使ったことがない人は、Reactでのコンポーネント設計は難しいでしょう。また、短納期であったり、開発者が多いときは、並列化効率はどうしても上げざるを得ないので、事前にクラス設計をする必要はあるでしょう。
インクリメンタルなコンポーネント設計をする場合は、ほぼ裏返しになりますが
- 小さくリリースすることが重要視されるとき
- 採用する技術スタックについて詳しくない時
- 開発者が少ないとき
となります。言ってることは全て同じかもしれません。採用する技術スタックについて詳しくない時は、リスク最小で動きたくなります。そうすると、影響が少ないように小さくリリースしたくなります。また、そういったチャレンジングな案件は開発者が少なく、小さく回す必要がある。ということが実情となります。
問いへの回答
「Unityのパッケージってどうやって設計すべきですか」
「質問が的を外している気がする」
という答えは何か。というと、まず本来的に何を作るか分かっていない(要件を知らない)のに、パッケージ設計をすることは出来ないです。ある種の残酷さがあるのですが、本当にそうとしか言えないんですよね。ただ、私はREST APIの文脈だと、Controller,Application,Repositoryというレイヤードアーキテクチャを作ることが多いです。というか、そういう風にコンポーネントが"割れてくる"というのが感覚としてあって、意図的にそのようなモジュール分割をしているつもりはないんですよね。あとやはりパッケージ設計に正解は無くて、時々で使いやすいようにインクリメンタルにしていくのが、綺麗なパッケージ構成になると感じています。したがって、万物不変に使えるようなパッケージ構成というのは、おそらく無いです。"細かく分ける"とすると、"分ける基準"が必要になってきます。そこが曖昧で新規開発したクラスが分別出来ないのであれば、やっぱり意味のないパッケージ構成になります。また、逆にあまりにも細かすぎると作業者が運用しきれなくなる。という問題が出てきます。一方で、荒くしすぎると、それはそれで何のためにパッケージ分割しているのか、意味が分からなくなってきます。こういうことを思うと、やはり自然にパッケージが割れてくるのを待つのが一番ベストな選択肢になってきます。
ただUnityにはUnityの辛さがあり、テストが書きにくい。という肌感覚はあります。また、これからUnityの新規開発するぞ!となると、プロトタイプの側面が強くなってきます。プロトタイプとなると、そもそも完成するか分からない。というレベルのもので、技術スタックが大幅に変わったり、UXや表現により大幅に構造が変わったりします。そこに耐えうるパッケージ構成はそもそも無理じゃないか。と思う面もあります。では、全然無理か。何も私たちは出来ないのか。というと、最悪ではない。ぐらいの選択肢はあります。
「徹底的に構造をパクる」
というのが一番楽です。世の中には、たくさんのOSSがあり、長く作られているものもあります。そういったプロダクトのディレクトリ構造やパッケージ構成を調査して見るのは1つ勉強になります。長く作られているものは、色々なしがらみの中、何とか生き残ってきたものです。そこには、理想論と現実の境目のギリギリのせめぎあいによって得られた構造というものがあります。そういうものを見ると、なるほどなぁ。と思うことは、あります。ただし、3,4個見る必要はあります。1つだけだと、あるドメインに特化したもので、汎用性が無いため、何個か見たうえで、自分の中で抽象化する必要があります。そのうえで、自分が作りたいものに対して、一番近いプロダクトは何か?を考えたうえで、その構造をパクるのは、それほど悪い結果ではないです。正直、バカの考え休みに似たり。みたいなことがあります。自分のプロダクトの設計に悩んでいるような程度の人間が、一生懸命考えたところで、最高の設計が出ることは無かったりします。そうした場合、とりあえず世の中の良い設計をパクってやってみる。というのが、実はそこそこ良かったりします。最後に言っておきますが、プロトタイプ以外で、長期にわたって保守するプロダクトにおいては、テストで回帰して、リファクタリングを行い、インクリメンタルな設計をしていくことは、現代においてほぼ正解だと思います。
まとめ
この記事は、ここ5年間ぐらいのアーキテクト観の見直しでもあり、自戒でもあり、指針でもあるな。と思います。
昔、そこそこ伸びた記事に「リファクタリングして学ぶTypeScriptでクリーンアーキテクチャ」という記事があります。これは、私自身がクリーンアーキテクチャーにご執心だった時代に書いた記事です。この記事を書いて、再度見てみるとあんまり意味なかったなぁと思います。
そのほかにも、「バリデーションの実装はどこにすべきか?」という記事も書いています。こいつも今見ると、ほとんど意味ないなぁという感想になってます。
どうしても"絶対的な答え"って欲しくなるんですよね。特にバリデーションの記事に顕著ですが、とりあえず脳死でValueObject作っとけ。っていうのが透けて見えますね。
今、私の意見だと、「テスト駆動をして、テストができるようにコンポーネントを設計する」というのが、しっくりくる答えだと思います。テスト駆動をするうちに徐々にコンポーネントが分割され、責務が研ぎ澄まされていくのが一番綺麗だと思います。(その意味だと、実は例題は間違っています。テストが無いので。)
では、一切クリーンアーキテクチャが無意味か。というと、そんなことは無くて、クリーンアーキテクチャを選ぶメリットというのもあります。それが、Unityの説明で使ったロジックで、とりあえずいろんな人によって精査されたアーキテクチャなので、そこまで失敗ではないだろう。という話です。もう1つは教育コストの低減です。これも悩ましいのですが、例えば、世の中にReactがなく、実は自分のいる会社でReactが実装され、それを使う。となるとどう思われるでしょうか?あれはfacebookという大きな会社だから出来た所業であって、小さな会社が自分たちの独自のアーキテクチャを持って、しかもそれがアクの強いものであったらいかがでしょうか?おそらく作った本人は良いんですが、後に入ってきた人の教育コストが高すぎて、ライブラリの保守もプロダクトも持たない可能性があります。おそらく、一度は経験があるのではないでしょうか?社内の独自フレームワークに泣いた経験が・・・そういったときに、世間にあるドキュメントや名著のあるアーキテクチャ、ライブラリの方が、自社で独自の教育に対する投資を行わずに済む。というので、ベターな選択肢にもなりえます。このように、世間一般で知られているソリューションもチーム全体でみると、悪くない。という可能性もあります。
組織によってプロダクトの境界を決めてしまうコンウェイの法則のように、パッケージの境界は人間の認知が決めるものだと思います。その認知が、個人なのかチームなのか。それによっても境界が異なるんだろうな。と思います。