アドベンチャーアドベントカレンダー2022の1本目の記事です。
誰
株式会社アドベンチャー skyticket品質保証担当です。元サーバーサイドエンジニアで現在はQAマネージャーみたいな仕事をしています。品質保証ちょっとできる人ぜひ弊社へ。
背景
skyticketのホテル予約サービスはアーキテクチャが腐っています。いかなる標準にも基づかないオレオレアーキテクチャで、作った人はもう辞めました。今となってはどこに何をどう実装するのが正解なのか誰にも理解できません。最悪です。
最悪なのでどうにかしなければなりません。リファクタリング?いやいや実運用中のシステムのアーキテクチャレベルからのリファクタリングって要するに作り直しですよね。エンジニアリソースが無限に確保できるならもちろん作り直したいですが現実はそんなに甘くはありません。夢を語るのは結構な話ですが我々はまず現実と闘わなくてはなりません。
そこで、全体を一気に作り直せないなら部分部分を切り出してマイクロサービス化していけばいいじゃない、という話になります。なりました。しました。
マイクロサービス化の動機が腐ったアーキテクチャをどうにかしたいというところにあるので、当然新たに作るマイクロサービスの中身は徹底的にきれいにしようということになります。今風のいけてるアーキテクチャって何だろう。そうだよく見かける玉葱みたいなあの絵だ。クリーンアーキテクチャだ。じゃあせっかくだしドメイン駆動設計をちゃんとやろう。よしこれでやれてる系、やれてる系ですよ!
言語とフレームワークはPHPとLaravelで。
どうしてここでPHPとLaravelなのか
はい。言いたいことはわかります。もう平成も終わったというのになんで新規開発でPHPなんか使うんだと。GoとかRustとかElixirとかもっとやれてる系の言語いろいろあるじゃないかと。DDDやクリーンアーキテクチャやるなら尚更PHPは不向きなんじゃないかと。
まずは自分の立場を明確にしておくと、僕はPHPが心の底から嫌いです。アドベンチャーに入社するまで20年弱のエンジニア人生を、徹底してPHPを避けて通ってきました。PHPはVBと並ぶ欠陥言語だと今でも思っています。COBOLの方がはるかに洗練されている。
なので、個人で何か開発するのであれば絶対にPHPを選ぶことはないでしょう。ですがこれは仕事であり、skyticketはPHPでできています。社内にいるサーバーサイドエンジニアは基本的に全員PHPの人です。ここで例えばRustで作るとか言い出したら何が起きるのか。技術スタックの分断が生まれます。社内がPHPエンジニアとRustエンジニア(と両方できるスーパーエンジニア)に分断されるわけです。技術スタックの分断は機動的な人員の再配置を阻害します。僕はこれは開発組織にとって大きなマイナス要因であると考えています。アドベンチャーのような小さな会社においては特に。
それから、採用も考えなくてはなりません。システムの一部をRustにしたところで、保守フェーズに入るとエンジニアにフルタイムでRustの仕事を与えられるほどの仕事量は発生せず、従ってPHPも書いてもらう話になります。ならPHPとRust両方できるエンジニアを採ればいいじゃない、というのは現実が見えていない富豪的思想です。複数の言語をそれなりにハイレベルで扱える人というのは、採用の仕事をしたことがない人が考えている以上に貴重です。その複数の言語がこちらの期待する組合せであるエンジニアは更にレアリティが上がります。口開けて待ってればエンジニアが飛び込んでくるような大手企業や有名テック企業であれば、それでも出るまで回せの精神で戦えるでしょう。弊社、まずPHPエンジニアの採用すら困難を極めています。つらい。
これが開発して納品して瑕疵担保期間を逃げ切れば終わりの受託開発なら事情はまた少し違うでしょう。プロジェクト期間の間だけ必要な人員を手当てできればどうにかなるからです。足りない人は外注すれば調達できます。しかし我々は自社のプロダクトを開発している以上、システムのライフサイクルの終わりまで面倒を見ることを念頭に置かなくてはなりません。新規開発なんてほんの一瞬で、その後に長い長い保守が待っているわけです。その長い保守期間の間、必要な人員をキープし続けなくてはなりません。それを外注に依存するのは望ましくないと考えています。
結局のところ、ソフトウェア開発は人に依存するのです。社内に人はいないし外からも採れないのだから今いる人間で戦える技術を選ぶのが最適解なのです。
さて、ここまでRustを例に技術選定の話をしましたが、実は社内にすでに失敗例があります。一時期、社内でGoが流行ったことがありました。主にツールやサーバーレス関数で採用されたほか、Webシステムも1つGoで開発されました。流行りが去った現在、メンテナンスできる人がほとんど社内におらず持て余されています。僕は完全なる技術選定の誤りだったと考えています。趣味ならともかく、仕事で使う技術を流行り廃りや好き嫌いで選ぶべきではないのです。だから僕はマイクロサービス開発に心の底から嫌悪しているPHPを選びました。
非同期処理や多重並列処理などPHPが苦手とする処理を行うなら、その時にその用途に適した言語を検討すればよいでしょう。その必要性がないのであれば、リソース確保が容易で社内にナレッジが蓄積されている言語を選ぶべきです。
フレームワークも同じ話です。社内で一番実績のあるPHPフレームワークは何かと言ったらLaravelです。一部CakePHPもありますが、それもメンテナンスできる人が少なく困っています。Symfonyを生で使うような根性はありません。
自由に作っていいならフロントエンドもサーバーサイドも全部TypeScriptで書きたい。けどそういうのは個人開発でやってます。会社はエンジニア個人のエゴを通す場ではない。
構造
ざっくりした構成はこんな感じです。
/
├── app
│ ├── Domain
│ │ ├── Models <<entity (of clean architecture)>>
│ │ │ ├── Property
│ │ │ │ ├── Property.php <<aggregation root>>
│ │ │ │ ├── ValueObjects <<value object>>
│ │ │ │ │ ├── PropertyId.php
│ │ │ │ │ └── ・・・
│ │ │ │ ├── Services <<domain service>>
│ │ │ │ │ └── PropertyService.php
│ │ │ │ └── DataAccess <<data access interface>>
│ │ │ │ ├── IEpsSearchApiAdapter.php
│ │ │ │ ├── IFilterAmenityRepositoryRepository.php
│ │ │ │ └── IPropertyCategoryRepository.php
│ │ │ └── Booking
│ │ │ ├── Booking.php <<aggregation root>>
│ │ │ ├── Entities <<entity (of DDD)>>
│ │ │ │ ├── Booking.php
│ │ │ │ └── ・・・
│ │ │ ├── ValueObjects <<value object>>
│ │ │ │ ├── BookingId.php
│ │ │ │ └── ・・・
│ │ │ ├── Services <<domain service>>
│ │ │ │ └── BookingService.php
│ │ │ ├── Exceptions
│ │ │ │ ├── CancellableRoomNotFoundException.php
│ │ │ │ └── ・・・
│ │ │ └── DataAccess <<data access interface>>
│ │ │ └── IEpsBookingApiAdapter.php
│ │ └── Share
│ │ ├── Entities <<entity (of DDD)>>
│ │ │ └── Entity.php
│ │ ├── ValueObjects <<value object>>
│ │ │ ├── IntegerIdentifier.php
│ │ │ ├── Coordinate.php
│ │ │ └── ・・・
│ │ └── Exceptions
│ │ └── DomainException.php
│ ├── Exceptions
│ │ └── Handler.php
│ ├── Http
│ │ ├── Controllers <<controller>>
│ │ │ ├── ListSearch
│ │ │ │ └── GetHotelListController.php
│ │ │ ├── BookingCreate
│ │ │ │ └── CreateBookingController.php
│ │ │ └── ・・・
│ │ ├── Requests <<input data>>
│ │ │ ├── Search
│ │ │ │ ├── GetHotelSearchRequest.php
│ │ │ │ └── GetHotelRescheduleRequest.php
│ │ │ ├── BookingCreate
│ │ │ │ └── CreateBookingRequest.php
│ │ │ └── ・・・
│ │ └── Resources <<output data>>
│ │ ├── ListSearch
│ │ │ └── GetHotelListResource.php
│ │ ├── BookingCreate
│ │ │ └── CreateBookingResource.php
│ │ └── ・・・
│ ├── Infrastructure
│ │ ├── Repositories <<data access>>
│ │ │ ├── AmenityWholesalerRepository.php
│ │ │ └── PropertyCategoryRepository.php
│ │ └── Adapters <<data access>>
│ │ ├── Rapid30EpsApiAdapter.php
│ │ └── Rapid30EpsApiTranslator.php
│ ├── Providers
│ │ ├── AppServiceProvider.php
│ │ └── ・・・
│ ├── UseCases
│ │ ├── Search
│ │ │ └── Get
│ │ │ ├── IGetHotelSearchUseCase.php <<input boundary>>
│ │ │ └── GetHotelSearchInteractor.php <<use case interactor>>
│ │ ├── BookingCreate
│ │ │ └── Post
│ │ │ ├── ICreateBookingUseCase.php <<input boundary>>
│ │ │ └── CreateBookingInteractor.php <<use case interactor>>
│ │ └── ・・・
│ └── Utils
│ └── Encrypt.php
├── docs
│ └── openapi.yaml
└── tests
├── Feature
│ └── ...
└── Unit
└── ...
クリーンアーキテクチャのこの図と突き合わせて見てもらえばだいたいわかるかと思います。
DDDとクリーンアーキテクチャをやっていてたまに混乱が見受けられるのがエンティティの定義です。クリーンアーキテクチャの同心円図だと中心に位置するやつですね。クリーンアーキテクチャにおけるエンティティは、DDDにおける集約、エンティティ、値オブジェクト、ドメインサービスを包含したものと理解しています。上記のディレクトリ構造においては、どちらの文脈でのエンティティであるかを明示しています。
ちょっと工夫した点はapp/Domain
の下にShare
を置いたことです。システムでは通常複数のドメインモデルを扱いますが、モデルを設計していくとわりと汎用性の高い値オブジェクト、複数のモデルに共通して登場する値オブジェクトが結構出てきます。例えば緯度経度とか、通貨付きの金額みたいなやつらです。コードの重複は邪悪という信念のもとにそういうやつらをShare
に括り出していきました。結果、各モデルにはそのモデル固有のエンティティや値オブジェクトだけが残る形になり、コード量も減り理解もしやすくなったと考えています。
もちろんこの構造が100%正解だとは全く考えていなくて、改善したいところもあります。例えば、現状Domain
がapp
の下、つまりLaravelのプロジェクト内に置かれていますが、これは望ましくないのではないかと考えています。ドメインがフレームワークに依存している状態と言えると思うので、DDDの原理原則論に照らして望ましくないというのもありますが、より具体的に困るケースとしてフレームワークを変更するケースがあります。現在はLaravelですが、将来もっとやれてる系のフレームワークが登場して、それに切り換えたいと思ったときに現在の構成はそれを阻害するでしょう。ドメインは独立したディレクトリに置かれ、フレームワークが変わっても影響を受けないようにするのがよいと考えています。
連想配列との死闘
PHPに限らずLLでは巨大な連想配列(あるいはそれに相当するもの)をこねくり回しながら持って回って処理をしているのをよく見かけると思います。ホテルの既存コードも例に漏れずそうです。ひどいと8重くらいにネストしています。これには大きく2つの問題があります。1つはデータの構造も内容も一切保証がされないこと、もう1つは定義が存在しないためコードの理解性を低下させることです。どちらも品質や生産性の悪化に直結する深刻な問題です。
巨大な連想配列はJavaやC#やTypeScriptなど静的型付けの言語を長くやっていた立場からすると頭の痛くなる実装ですが、PHPやPerlやJavaScriptなどでは当たり前のように見かける実装です。よく見かけるからといって許容できるわけではありません。上記のような問題点がある以上、対処しなくてはなりません。
マイクロサービス開発ではコーディング規約でネストした連想配列の使用を原則禁止しました。これまで連想配列として扱っていた類のデータは全てクラスを定義しそのインスタンスとすることで、データ構造や値の保証とコードの理解性を両立させました。もちろん1つのクラスに全部を詰め込むのではなく、階層化して細分化していきます。結果としてエンティティや値オブジェクトの数は増えますが、別に数が増えたところで何ら問題はありません。
こうして連想配列を追放することで、堅牢性と理解性だけでなくテスト容易性も得ることができました。PHPの連想配列は過去の負債であり忌むべき言語機能です。積極的に滅ぼしていきましょう。
テスト
テストを書け。
テストがないとリファクタリングもパッケージアップデートもミドルウェアアップデートも危なくてできない。
つべこべ言わずにテストを書け。
開発者にこう言い続けた結果、それなりのカバレッジのテストができました。カバレッジの管理はCodecovを使っています。
Unitテストコードは/tests/Unit
下に、/app
と同じディレクトリ・ファイル構成で配置しています。こうしてプロダクトコードとテストコードを1:1で対比しやすくすることで、テスト抜けが起こりにくいようにしています。コードレビューで新規ファイルに対して対応するテストがあるかをチェックすればよいわけです。
Featureテストで正常系の多くは補えることから、Unitテストは異常系・境限界系をメインで書くなど工数に配慮した工夫も行っています。カバレッジをちゃんと計測して管理していれば、テストコードも漏れなくダブりなく書くことができます。
テストがあるのでパッケージバージョンやミドルウェアバージョンも安心して上げられます。Composerで入れるパッケージについてはDependabotを導入しており、常に最新のパッケージに追随し続けるようにしています。また、k8s運用なのでコンテナです。コンテナのベースイメージもどんどんバージョンを上げていけます。今はPHP8.1ですが、8.2が出れば8.2に上げます。9が出れば9に上げます。動作は自動テストが担保してくれます。failしたところだけ直せばいいのです。
欲を言えばTDDをやりたかったのですが、プロジェクトのメンバーにテストコードを書き慣れた人がほとんどおらず、その状態だと難しいと判断して断念しました。ただ、今回のような取り組みを続けて開発者がテストコードの記述に習熟してくると、TDDも可能になってくると考えています。やっぱりテスト→コード→リファクタリングのサイクルを高速で回していきたいよね、というのがあって、それができればより高品質なプロダクトをより早いペースで開発できるようになるはずです。
CI
CIはそれなりにガチガチに固めている部類だと思います。GitHub Actionsを使用していますが、作業ブランチをdevelopにマージするには以下をすべてクリアする必要があります。
- テストを全件passすること
- テストカバレッジが90%以上であること
- Larastanによる静的解析で指摘がないこと
- Deptracによる静的解析で指摘がないこと
- コードレビューで2人以上のApproveを得ること
このくらいはやらないと、最初は高品質のものができても徐々に腐っていきます。システムがそのライフサイクルを終えるまで、品質保証の手を緩めないことが重要です。上記のうちコードレビュー以外は自動化されているので、一度仕組みを作ってしまえば後は手間はかかりません。
CIを固める上でのポイントは実行時間だと考えています。いくら品質を担保するためであっても、コードをpushしてからCIで30分も1時間も待たされるようでは当然開発速度に影響が出ます。今回紹介したマイクロサービスではCIは平均3分くらいなので問題ありませんが、長時間かかるようであれば例えばテストを並列実行するような方法が考えられるでしょう。GitHub Actionsのランナーは2コアなので2並列にできれば高速化が期待できます。また、基本的なことですがLaravelであればMockeryを使ったモック化は必須です。DBやAPIなどへのアクセスは、本当に必要な局面以外はモック化することでテストにかかる時間は大きく短縮できます。
まとめ
こうした取り組みをやってみた結果、見えたこととしてはこのアプローチは人を選ぶということです。今回プロジェクトのメンバーはほとんどの人がDDDやクリーンアーキテクチャの事前知識がなく、僕は知識はありましたが開発者ではないので、実質的にスタートラインは皆同じだったと思います。にもかかわらず、プロジェクトを進めていくと設計思想を理解してバリバリコードを書ける人とそうでない人に二分されました。単純に経験やスキルの高低で分かれているわけでもなさそうで、結局は合う合わないという話になるのかな、と思っています。このあたりは、社内に様々なプロジェクトがある中で、いかに個人個人の適性を見抜いて適材適所な人員配置をするか、という組織的課題につながってきます。
アドベントカレンダー初日から長文を書いて後に続く人たちに圧をかけてみました。QAからは以上です。