近頃やっているコンポーネント構成について

最近のPerlでのWebアプリケーション開発

わいとんです。このエントリはPerl Advent Calendar 2022の20日目のエントリです。その割にはまあまあボリュームのある内容となっています。
ちなみに昨日のエントリは@hirataraさんによる「joinクイズ」でした 。私も一見しただけでは答えがわかりませんでした。かなり難しいです!

さて、実は最近またひっそりとPerlで開発をしていまして、主に開発最初期の設計の任を預かることが結構あります。

「新規でPerlを使うのはどうなんだ」等のご意見があろうかと思いますが、これについては、その時置かれている状況ごとによって最適解は異なる、という意見を述べるに留めておきます。

歴史とMVC

Perlでは長らくMVCという設計アプローチが採用されてきました。これがなかなか優れた設計であるがゆえに、ここまで長きに亘ってMVCが利用され続けてきたのです。

MVCは優れている。ただし、人間の怠惰の面倒までは見てくれない。

個人的にMVCの利点としては以下のようなものがあると認識しております。

  1. ViewとModelが分離することにより、Viewからロジックを引きはがすことに成功している。
  2. ControllerとModelが分離することにより、複雑な実装をModelに引きはがすことでControllerをシンプルに保つことに成功している。
  3. Modelについてテストを記述することで、動作仕様の担保に成功している。

ところが、人間という生き物は怠惰でありますから、上記のようなMVCの利点を以下のようなふるまいでムダにしてしまいがちです。

  1. ViewとModelが分離しているにもかかわらず、マクロ等を駆使してViewにロジックを記述してしまいがちである。
  2. ControllerとModelが分離しているにもかかわらず、Controllerにロジックを記述してしまいがちである。
  3. ひとつのControllerに役割を持たせすぎてしまいがちである。
  4. ひとつのModelに役割をもたせすぎてしまいがちである。
  5. Modelのテストを記述せずに済ませてしまいがちであり、動作仕様の担保をないがしろにしがちである。

MVCにおいては、人間が勤勉であればあるほどModelが肥える

ところで、もし仮に勤勉な人間ばかりで開発陣が構成されていたら、MVCではどのようになるでしょうか。改めて先ほどのMVCの利点を列挙してみます。

  1. ViewとModelが分離することにより、Viewからロジックを引きはがすことに成功している。
  2. ControllerとModelが分離することにより、複雑な実装をModelに引きはがすことでControllerをシンプルに保つことに成功している。
  3. Modelについてテストを記述することで、動作仕様の担保に成功している。

これを見る限り、1ではロジックがViewから引きはがされ、2ではControllerからロジックが引きはがされています。宙に浮いたロジックが行きつく先はModelとなるわけですが、当然ViewやControllerからロジックが集まってきたということは、その物量はなかなか結構な量となるはずです。

つまり、MVCでは開発者がまじめにMVCを運用・最適化すると、必然的にModelが肥大化するさだめにあるのです。

バックエンドアプリケーションを取り巻く昨今の事情

ここ数年の情勢の変化で、バックエンドアプリケーションに大きな影響を与えたものとして「サーバサイドレンダリング」を求められなくなった、というものが挙げられます。

これはいくつかの要因が重なりあった結果なのですが、主な要因は以下のようなものだと認識しています。

  1. Webフロントエンドにおける各種フレームワークの成熟(代表的なものにNext.jsやReactなど)
  2. ガラケーの終焉

Webフロントエンドにおける各種フレームワークの成熟

言うまでもなく、この7~8年間においてもっとも進化したソフトウェア分野のひとつとして、Webフロントエンドフレームワークが挙げられます。

今では当たり前のようにAPIに対してリクエストを送り、JSONレスポンスを受け取って、表示に必要な処理をこなしてくれるWebフロントエンド。ReactやVue.js、Next.jsの登場とそれらの相互作用によるすばやい進化によって、現在のような賢いWebフロントエンドが実現できるようになったと言えるでしょう。

また、これらのフレームワークとは別に、Webフロントエンドはサーバサイドレンダリングやサーバサイドジェネレーターの機能をも取り込むという進化を遂げました。

結果、バックエンドアプリケーションはもはやAPIとしての役割だけが期待されることとなり、現在に至ります。

ガラケーの終焉

ガラケー時代では、Webアプリケーションと言えばPerlかPHP、あるいは後期ではRubyで開発するのが相場でした。

多くのガラケーではJavaScriptは動作せず、動作したとしてもあまり実用的とは言えない程度のものでした。デザインの面でもできることは限られており、一度に表示できるページ容量には厳しい上限が課されていました。

このようなこともあり、フロントエンドとバックエンドの分業はあまり起こらず、むしろバックエンドエンジニアがフロントエンドも兼任するという風景が日常的でした。そしてMVCが採用され、今日まで概ねなんとなくうまく行っていたというわけです。

ところが、DoCoMoがiModeのサービス終了を発表した(実際の終了は令和8年3月末)こともあり、ガラケーコンテンツとしてのWebアプリケーションは軒並み終了を余儀なくされました。この時にバックエンドアプリケーションは、サーバサイドレンダリングを行う大きな理由を失いました。

MVCからレイヤードアーキテクチャーへ

さて、サーバサイドレンダリングという大役を失ったバックエンドアプリケーションですが、まだまだ役割は盛りだくさんです。

  • DBやデータストアとのやりとり
  • ユーザーデータの管理
  • データアクセスに際しての認証・認可
  • ビジネスロジックの実装・実行 などなど…

MVCでいうなら、Viewが空っぽでModelが山盛り、という状態でしょう。あれもModel、これもModel…

さすがに何もかもをModelで済ませていては解像度が低すぎます。また、Controllerについてもどのようなリクエストを期待し、どのようなレスポンスを返すのかが自明ではありません。

  • Model内で副作用の有無が混ざっている
  • Model内で多重継承が目立つ
  • Controller側で期待するリクエストおよびレスポンスが不明確

従来のPerlによるMVCではこのあたりに課題が生じがちでした。そこで私はこの半年ほど、レイヤードアーキテクチャーと関数型の要素を取り入れたアプローチでPerlのアプリケーションを設計・開発しています。

Mojolicious + レイヤードアーキテクチャー的アプローチ

まず、下地となるWebアプリケーションフレームワークにMojoliciousを選びました。充分に枯れていながら依存モジュールが少なく、利用方法がMVCにこだわらない点が素晴らしいです。メンテナンスも熱心に継続されているため、安心感があります。

次に、実際のレイヤードアーキテクチャとは異なりますが、それっぽいコンポーネント設計として以下のような層を設けます。

  • Router : リクエストを適切なロジックに処理させる層。
  • Handler : メッセージデータを受け付けて、レスポンスを返す層。
  • Service : Handlerから呼び出され、副作用を伴うビジネスロジックを抱える層。
  • Domain : HandlerおよびServiceから呼び出され、副作用を伴わないビジネスロジックおよび定数群を抱える層。
  • Message : リクエストおよびレスポンスをメッセージデータ構造に当て込む層。
  • Repository : Serviceから呼び出され、外部データの取得・保存を行う層。
  • Infra : ServiceおよびRepositoryから呼び出され、外部システムとの窓口となる仕組みを抱える層。

図解すると、以下のようになります。

わいとん式PerlレイヤードアーキテクチャR4の図

いったん、「わいとん式Perlレイヤードアーキテクチャ(R4)」と銘打ちました。R4は令和4年の意味です。略称を 「YPLA-R4」 とでもしておきましょうか。

ディレクトリ構成と各パッケージの命名規則

ディレクトリ構成は概ね以下のようなものとなります。

.
├── Makefile
├── app.pl
└── lib
    └── MyProj
        ├── Config.pm
        ├── Domain
        │   ├── CouponDomain.pm
        │   ├── UserCouponDomain.pm
        │   └── UserDomain.pm
        ├── Handler
        │   ├── UserAccountHandler.pm
        │   └── UserCouponHandler.pm
        ├── Infra
        │   ├── Database.pm
        │   ├── HTTPClient.pm
        │   └── Payment.pm
        ├── Message
        │   ├── Request
        │   │   ├── BuyCouponRequest.pm
        │   │   ├── CreateUserAccountRequest.pm
        │   │   ├── CreateUserCouponRequest.pm
        │   │   ├── UpdateUserAccountRequest.pm
        │   │   └── UseUserCouponRequest.pm
        │   └── Response
        │       ├── ErrorResponse.pm
        │       ├── UserAccountResponse.pm
        │       ├── UserCouponListResponse.pm
        │       ├── UserCouponResponse.pm
        │       └── UserListResponse.pm
        ├── Repository
        │   ├── CouponRepository.pm
        │   ├── UserCouponRepository.pm
        │   └── UserRepository.pm
        ├── Router
        │   └── Root.pm
        └── Service
            ├── CouponService.pm
            ├── MyExternalService.pm
            ├── PaymentService.pm
            └── UserAccountService.pm

特徴的なのが、基本的にパッケージ名が自己言及的な名称となっている点でしょう。 MyProj::Service::UserService のように、一目でどの層に属するのかがわかるような命名にするべきです。

Handlerのコード例

すでに見ての通り、このコンポーネント設計ではパッケージ名が結構長いものとなります。

たとえば MyProj::Service::UserServiceMyProj::Service::CouponService などというのはまずまず呼ばれそうなクラスですが、これを毎回打ち込むというのはやっていられないでしょう。

そんな時に便利なのが aliased モジュールです。これをつかうと、たとえば MyProj::Handler::UserCouponHandler で以下のように書くことができます。

package MyProj::Handler::UserCouponHandler
use strictures 2;
use experimental qw(try);
use Mojo::Base 'Mojolicious::Controller', -signatures;
use Types::Common -types;
use Function::Parameters;

use aliased 'MyProj::Service::UserService';
use aliased 'MyProj::Service::CouponService';
use aliased 'MyProj::Service::UserCouponService';
use aliased 'MyProj::Service::PaymentService';

use aliased 'MyProj::Message::Request::BuyCouponRequest';
use aliased 'MyProj::Message::Response::ErrorResponse';
use aliased 'MyProj::Message::Response::UserCouponResponse';

### POST /coupon/buy
fun buy_coupon((InstanceOf ['Mojolicious::Controller']) $c) {

    ## aliasedによって MyProj::Message::Request::BuyCouponRequest は 
    ## BuyCouponRequest とだけ書けばOKとなる。
    my $req = undef;
    try {
        $req = BuyCouponRequest->new_by($c);
    catch ($err) {
        return ErrorResponse->bad_request($c, $err);
    }

    my $user_id   = $req->user_id;
    my $coupon_id = $req->coupon_id;
    
    my $user   = UserService->get($user_id) or 
        return ErrorResponse->bad_request($c, "no such user_id");

    my $coupon = CouponService->get($coupon_id) or
        return ErrorResponse->bad_request($c, "no such coupon_id");

    my $err    = PaymentService->buy_coupon($user, $coupon);
    if ($err) {
        return ErrorResponse->internal_server_error($c, $err->message) if
            $err->code >= 500;
        return ErrorResponse->conflict($c, $err->message);
    }

    my $user_coupon = UserCouponService->create($user, $coupon);
    
    return UserCouponResponse->new_by($user_coupon)->ok($c);
}

...

ただし aliased を導入することによってIDEの支援を受けづらい状態になりますので、留意してください。

Messageのコード例

Message層はRequestとResponseに分けられます。それぞれのコード例を示します。

Message層はすべて Moo ベースのパッケージとして定義されます。

まずはRequestの例として BuyCouponRequest です。

package MyProj::Message::Request::BuyCouponRequest;
use Moo;
use Types::Common -types;
use Function::Parameters;
use namespace::autoclean;

has user_id => (
    is            => 'ro',
    isa           => NonEmptyStr,
    required      => 1,
    documentation => '会員ID',
);

has coupon_id => (
    is            => 'ro',
    isa           => NonEmptyStr,
    required      => 1,
    documentation => 'クーポンID',
);

fun new_by($class, (InstanceOf ['Mojolicious::Controller']) $c) {
    return $class->new(
        user_id    => $c->req->json->{'user_id'},
        coupon_id  => $c->req->json->{'coupon_id'},
    );
}

1;

new_by($c) を定義することで、リクエストを解釈するロジックを統一できます。

次にResponseの例として UserCouponResponse です。

package MyProj::Message::Response::UserCouponResponse;
use Moo;
with 'Moo::Role::ToJSON';
use Types::Common -types;
use Function::Parameters;
use namespace::autoclean;

has user_id => (
    is            => 'ro',
    isa           => NonEmptyStr,
    required      => 1,
    documentation => '会員ID',
);

has coupon_id => (
    is            => 'ro',
    isa           => NonEmptyStr,
    required      => 1,
    documentation => 'クーポンID',
);

has used_at => (
    is            => 'ro',
    isa           => DateTime | Undef,
    required      => 0,
    documentation => '使用日時(null=未使用)',
); 

has created_at => (
    is            => 'ro',
    isa           => DateTime,
    required      => 1,
    documentation => '登録日時(=購入日時)',
);

fun new_by($class, (InstanceOf ['MyProj::Repository::UserCoupon'] $user_coupon)) {
    return $class->new(
        user_id    => $user_coupon->user_id,
        coupon_id  => $user_coupon->coupon_id,
        used_at    => $user_coupon->used_at,
        created_at => $user_coupon->created_at,
    );
}

fun ok($self, (InstanceOf ['Mojolicious::Controller']) $c) {
    return $c->render(
        json   => $self->TO_JSON, 
        status => 200,
    );
}

Responseでも new_by($user_coupon) を定義していますが、レスポンスに必要な値を一気に詰め込むためのクラスメソッドとして定義してあります。

また、Mojoliciousのレスポンスとして返せるように ok($c) をインスタンスメソッドとして定義してあります。

…ちょっとそろそろ書いていて息切れしてきました。全部説明すると分量がだいぶ多くなりますので、ひとまずこのあたりにしておきましょう。

登場した各CPANモジュールについては、以下のリンクからどういうものか見てもらうとよろしいです。

YPLA-R4の設計哲学

ここから先は、このコンポーネント設計の哲学についてちょっとだけ説明します。

関数型のエッセンスを取り入れ、OOPをやりすぎない

ここまで読まれた方の中には「OOPで書けばこんなにコードが冗長になる必要はないのに」と感じた人もいるかもしれません。

基本的にはOOPでもいいんですが、層を切って役割を分けることに意味があると思っています。

その方向性で色々試行錯誤した結果、OOP依存度を下げ名前空間を有効活用することで、パッケージごとの単純性を保つことができそうだという結論に至った次第です。

層ごとの依存先ルールを破らない

たとえばService層ですと、依存してよい先はDomainとRepository、それにInfraという制約があります。また、同一層内での依存は基本的にしないという取り決めでやっていくのがよろしいでしょう。

これは本来のレイヤードアーキテクチャーにおいても、層どうしの依存方向を単一方向にするきまりがあり、そこに倣っています。そうすることで、変更容易性・メンテナンス性を高めていくという狙いがあります。

リクエストとレスポンスの型を定義し、APIとしての明確性を重視する

APIを定義するうえで、OpenAPIのようなフォーマットによる定義書を書く必要性が生じることでしょう。

そうした時に、リクエストとレスポンスの型をMessage層にて定義してあるので、SchemaとしてOpenAPIに記述することもやりやすいかと思います。

将来的にはMoo basedなパッケージからOpenAPIのSchemaを出力するような仕組みなんかも用意したいですね。

ドメインロジックと副作用を徹底的に切り離す

最大のポイントがここで、業務ドメインに関するロジックから副作用を徹底的に取り除き、業務ドメインロジックそのものを副作用のない関数として定義する、という点です。

とにかく業務ロジックにバグを作りたくないので、徹底的に副作用を削り、テストを分厚くするべきです。

そのためにDomain層を設けており、Domainに対するテストがしっかりしていれば(カバレッジで90%以上)、致命的な不具合は回避できるでしょう。

さいごに

だいぶ長いエントリとなってしまったこともあり、最後の方は疲れの片鱗が垣間見えるような内容となってしまいました。

そういえば、この内容と非常に近い話題をこの前の吉祥寺.pm #31@kurotyann9696さんが発表していましたので、ぜひスライドを見ていただくのがよろしいかと思います。