Fat Controllerをコードで防ぐ方法はないか?

Intent-Responseパターンの提案

Takashi Iwamoto
VELTRA Engineering
8 min readApr 6, 2020

--

昨秋に受けた人間ドックの結果、メタボ予防の指導対象となったイワモトです。半年間の指導が先日終わりました。

人間のファットも問題ですが、Webアプリにおけるコントローラのファットも問題です。保守性が下がり、エンバグの危険性が高まるためです。書籍『Rails AntiPatterns』でも、Railsのアンチパターンのひとつとして「Fat Controller」が挙げられています。

Fat Controllerの予防には「ビジネスロジックやプレゼンテーションロジックをコントローラに書かない」ルールを守るのが効果的です。前者はモデル、後者はビューの責務です。このルールを守れば、コントローラをスリムに保てます。

ただ、ルールを守るといっても、なかなか大変です。プロジェクトに携わるすべてのプログラマにルールを守らせるには、相応のコストがかかるでしょう。ルールの周知、ルール違反の検知・修正といったコストです。

何か、頑張らなくてもルールが守られる実装パターンは考えられないものでしょうか? もし、そのようなパターンで実装されたフレームワークなりミドルウェアなりが使えたら、コストをかけずにFat Controllerが予防できます。

Intent-Responseパターン

そこで思いついたのが「Intent-Responseパターン」です。以下、詳しく説明します。

Intent

  • ビジネスロジックを担当
  • コントローラのアクションから即座に呼び出される
  • HTTPリクエストを受け、HTTPステータスコードとビジネスロジックの実行結果(outcome)を返す

Response

  • プレゼンテーションロジックを担当
  • Intentが返したHTTPステータスコードをもとに、コントローラから呼び出される
  • HTTPリクエストとoutcomeを受け、HTTPヘッダとレスポンスボディを返す

コントローラ

  • IntentとResponseの呼び出しを担当
  • HTTPリクエストを受け、呼び出すべきIntentを決めて呼び出す
  • Intentから返されたHTTPステータスコードをもとに、呼び出すべきResponseを決めて呼び出す

Intent-Responseパターンの実装例

言葉だけでは分かりづらいので、実装例として、Flaskの拡張機能を作ってみました。flask-skinnyです。

インポートすると、Flaskアプリを下記のように実装できます。

from flask import Flask
from flask_skinny import skinny
from random import randint
import json


def intent(request):
if randint(0, 1) == 0:
status_code = 200
outcome = "OK"
else:
status_code = 403
outcome = "Forbidden"
return status_code, outcome


def response(request, outcome):
headers = {"content-type": "application/json"}
body = json.dumps({"message": outcome}) + "\n"
return headers, body



app = Flask(__name__)


@app.route("/", methods=["GET"])
@skinny.responses({200: response, 403: response})
@skinny.intent(intent)
def index():
pass

ご覧の通り、コントローラのアクションには「pass」しか書かれていません。究極のSkinny Controllerといえます。pass以外のコードが書かれていたらルール違反であり、その検知や修正は簡単です。

アクションには @skinny.intent デコレータが指定されています。デコレータは自動的に intent 関数を呼び出します。

intent 関数は、50%の確率で 200, "OK" もしくは 403, "Forbidden" を返します。

次のデコレータは @skinny.responses です。自動的に response 関数を呼び出します。今回は説明を簡単にするため、200 でも 403 でも response 関数を呼び出すようにしました。

response 関数は、Intentの実行結果(outcome)をJSON文字列に変換し、HTTPヘッダとともに返します。それらの戻り値がアクションの戻り値となります。

上記アプリを起動し、アクセスすると、下記のような結果が得られます。

$ curl -v http://localhost:3000/
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.58.0
> Accept: */*
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< content-type: application/json
< Content-Length: 18
< Server: Werkzeug/1.0.0 Python/3.8.2
< Date: Mon, 30 Mar 2020 08:15:12 GMT
<
{"message": "OK"}
* Closing connection 0

想定される質問

Intent-Responseパターンのメリットは何ですか?

以下のメリットがあります。

  • Skinny Controllerが実現できる
  • プログラマがIntentとResponseの実装に集中できる
  • フレームワークへの依存度が下がる

なぜIntentをModel、ResponseをViewと呼ばないのですか?

そう呼んでもよいのですが、既存のMVCフレームワークとの違いを明確にしたく、別の名前を選びました。

Fat Controllerは予防できても、Fat IntentとFat Responseを生むのではないですか?

それぞれの責務が守られていれば問題ないと思いますが、コード量が増えて読みづらければ分割すべきでしょう。

Intent-ResponseパターンはRailsのようなフルスタックフレームワークでも使えますか?

使えると思います。が、そうすべきかどうか、実際のところは実装してみないと分かりません。

IntentでHTTPステータスコードを返すのは適切なのでしょうか?

Intentの実装者にとって分かりやすいだろうと判断して採用しました。結果コードのようにResponseの選択に使える値であれば、何でもよいとは思います。

なぜResponseにrequestを渡すのですか?

プレゼンテーションロジックにも、HTTPリクエストの内容が必要となる処理がありえるからです。たとえば表示言語は、ビジネスロジックでは不要でも、プレゼンテーションロジックでは必要になるでしょう。

Response内でHTTPステータスコードを変えたいのですが?

はい、その必要があるなら、Responseのインターフェイスを変えても構いません。

def response(status_code, request, outcome):
headers = {"content-type": "application/json"}
body = json.dumps({"message": outcome}) + "\n"
return status_code, headers, body

flask-skinnyについて、Intentが受け取るrequestは何ですか?

flask.requestにしました。Flaskへの依存をなくすのが最優先なら、WSGIのenvironを渡すべきです。が、flask-skinnyはFlaskの拡張機能なので、その必要はないと判断しました。

おわりに

この記事では、Fat Controllerの予防策として、Intent-Responseパターンを提案しました。また、実装例のflask-skinnyもご紹介しました。

ただし、このパターンで書いたWebアプリを実運用しているわけではないので、考慮不足があるかもしれません。お気づきの点がありましたら、コメントいただければ幸いです。

--

--

Takashi Iwamoto
VELTRA Engineering

ENECHANGE株式会社VPoT兼CTO室マネージャー。AWS Community Builder (Cloud Operations)。前職はAWS Japan技術サポート。社内外を問わず開発者体験の向上に取り組んでいます。