🍵

[Laravel] 10年付き合うことになるWebAPIをチームで作るときに考えたこと

に公開

概要

ある程度の機能があるWebアプリケーションを、何も考えずに素朴に何年もメンテナンスしていくと、いろいろ辛くなるケースがあるかと思います。

  • コードが複雑怪奇で読みづらく、追加開発や修正に時間がかかる
  • 仕様書と実装が一致せず、結局実装を読み解いて仕様を把握する羽目になる
  • プログラム言語やミドルウェアのバージョンアップが面倒くさすぎる

私の現場では、新しくリプレイスするWebAPIが最低でも10年間は面倒を見ることがほぼ確定していました。上記のような問題を10年間抱え続けるのは、開発者としても事業担当者としても避けたい状況です。

そこで「10年後も快適に開発できる状態」を目指して、対象とする事業やチーム特性を踏まえて、ディレクトリ構成などを考え直しました。この記事では、その取り組みの実例と、実際に運用してみての効果や反省点をまとめています。

事業とシステムの現状

事業の現状

  • 通販事業
    • 自社製造の飲料商品を販売するサービス
    • 仕入れ商品のヘルスケア商品を販売するサービス
  • 創業してから数十年の歴史
  • 自社製造のための工場や、出荷業務を行う倉庫を保有している
  • 注文の媒体としてECWeb・電話・FAX・ハガキ・Yahooや楽天などの外部モール
  • 課題
    • 注文を受けてから商品がエンドユーザーに届くまで最短4日かかる
    • 飲料商品サービスは売り上げが上がっているもものの、ヘルスケア商品サービスはまだローンチしてからまもなく、まだまだ新しい施策を打ちたい
    • 飲料の原料費が高騰している背景で、コストカットできる施策を打ちたい

システムの現状

  • インフラ
    • 基本オンプレミス
    • VMWareやHyper-Vなどで仮想化
    • CentOS7いまだに使ってる
  • システム
    • 協力会社様に開発していただいたERPを基幹システムと位置付け
    • ECWebの売上比率が大きい
    • 通販で使うWebシステムを内製化している
  • アプリケーション
    • 内製化しているWebアプリケーション
      • 9割5分はPHPで開発
      • ノンフレームワーク、CodeIgniter3、あるいはそれらの混在といった事例が多く見られます
        • 10年前のPHPコードが動き続けていて、古い構造を捨て切れない
        • フレームワークが中途半端に混在してるところがかなりバグを引き起こす
  • ERPとWebシステムの連携方式
    • オフラインのバッチ形式が多い (夜間バッチ)
    • オンライン連携もあり、ERPと各種アプリケーションを繋ぐために 基幹システムのリソースを操作するSOAPプロトコルのWebAPIアプリケーションがある
      • C#で作られている
      • SOAP WebAPIに現状バグがあったりするが、開発環境がそもそも作れなかったり、本番環境と開発環境でC#のコードが違うので手を入れづらい
      • ソースコードのバージョン管理が適切にされてない
  • 複雑度合いの指標
    • 基幹システム
      • メインフレームとかの時代があったりするらしい
      • 協力会社さんを変えてリプレイスした歴史もある
      • 10数年くらいのスパンでリプレイスとかマイグレーションしてる
    • DBのテーブル数
      • Webシステムだと250テーブルくらい
      • 基幹システムは数百テーブルくらい
  • 契約まわり
    • ERPのデータベースは当社の方で参照したり変更していい決まりになっている
      • 勝手に変更して障害が起きた時の契約とかは知らされてないが、開発が許可されている

現場チームの特性と課題

  • 現場で使う言語はPHPで統一されている
  • スーパーエンジニアとかプログラマは在籍してない
  • WebMVCの開発に慣れている
  • 数十年前のコードを読んで仕様を読み解く力とか根性は備えている
  • コードレビューの文化がない
  • 枯れた技術を好み、他のシステムでも同じ技術で運用したいモチベーションが高い
  • PHPコード書く時のエディタやそのconfigは統一されてなく、インデントがタブだったりスペースだったりする
  • 全員がスキル上昇志向を持っているわけでもないように見える (頑張っている人もいる)
  • コーディングとか
    • 明らかに継承使わないのにprotectedプロパティを使ってたりする
    • 「PHPのinterface」っていう話が伝わらない そんな機能あるんですか? っていう感触
    • SOLID原則は守ってないのと存在を知らない人もいる
    • DDDで実装しました!と言っているが、実際のところは画面駆動開発であり、見せかけDDDになっている実績がある
    • 7年くらい前にいた優秀なエンジニアが書いたテストコードを、CI/CDが通らずデプロイができないからという理由で、PHPUnit及びCI/CDの仕組みを抹消した事例がある
    • あらゆるデータを連想配列に詰める傾向がある
    • ある人が書いたコードを参考というか真似して書く傾向がある
      • 真似されるコードがぐちゃぐちゃでも流用されるので、初めに書かれているコードの品質によって未来が左右される
    • 現状のコードを読みづらいよね、っていう共通の認識はあるが、どう活動していけばいいかわからないように見える
    • リファクタリング施策とかやっている時間的余裕があんまりないので、初めからいい感じのコードを書かないと詰む
    • 技術的な用語の解像度や理解度がまちまちな上で、曖昧な発言が散見される
      • 「ドメイン云々」といった言葉の解釈がメンバー間で異なり、議論が深まらないことがある

今回私が主担当したWebAPI

  • SOAP WebAPIのリプレイス
    • REST WebAPIにしてほしいリクエストを受けた
    • インターネットからCALLされない社内WebAPI
    • 顧客関連のエンドポイントと在庫関連のエンドポイントを合わせて約8個
    • 現行SOAP APIが持っている機能は担保しつつ、現状わかっているバグを治してリリース
  • 2023年とかに現場の基幹システムをリプレイスしたばかり
    • 基幹システムが続く限りは、そのリソースを操作するWebAPIも続くはず
    • 基幹システムは10数年くらいのスパンでリプレイスとかマイグレーションしてる
    • 少なくとも10年は面倒を見る必要がある

上記の特性や課題を踏まえて、どういう設計をしたか?

インフラ

  • オンプレミスのリソースを引き続き活用
  • CentOS7はサポートが切れているので、新しいOSを選定
    • 色々検討した結果、AlmaLinuxに
      • 今回作るWebAPIだけではなく、社内的にCentOSを使っているところをAlmaLinuxへの切り替え構想と合わせて選定しました
        • OSだけ差し替えるアプローチも考えて、他のWebアプリケーションの構成をAlmaLinuxで差し替えて動かせるかまで確認
      • 選定自体はインフラに詳しい人にお願いした
  • 仮想マシンにコンテナを乗っける
    • PHPとかDBドライバのバージョンアップを、アプリ開発者がコンテナファイル書き換えて可能にしたかった
      • PHPUnitをちゃんと書くのを前提に
    • 仮想マシン with コンテナのオーバーヘッドも検証した
      • オーバーヘッドはあるものの、オンプレミスのサーバリソースがそこそこあったので目を瞑った
    • GitLabサーバでCI/CD楽に実現するためにもコンテナ使いたかった
    • コンテナ構成をこれまで使ってないので、システム全体の運用が統一できずに、障害時の切り分けが面倒になるリスクはありそう
      • 今の現場でコンテナ採用は、今振り返っても最適解だったかは疑問
      • 仮想マシンだけでやる方もメリットがあるはずで
      • コンテナの使い方わからないっていう現場の声とか相談もしばしば受ける

言語とかWebアプリケーションフレームワークの選び

  • 候補
    • CodeIgniter4
    • Laravel
    • Golang (Echo)
  • 今回作る非機能要件はそんなにシビアじゃない
    • 顧客情報取得WebAPIが0.1sec程度でいい合意を得ている
  • みんなPHP使っているのでCodeIgniter4かLaravel
  • CodeIgniter4を考えてたが...
    • CodeIgniter3とCodeIgniter4で書き方がまあまあ変わっていた
    • CodeIgniterにこだわる必要があんまりなさそう
    • Laravelはこれまでパフォーマンスの理由で避けていた経緯があったらしいが...
      • 最近のPHPはOPCacheやJITで頑張っている
        • 加えてComposerやLaravelで適切にキャッシュすれば、今回の非機能要件はクリアできそう
      • 主担当の私が個人開発で慣れてて引っ張れそう
      • 書籍とかもあるので学べる環境が整っている、というのを説明して採用した

バージョン管理とかデプロイメントとか

  • git commitするときに、PHPUnitとPHPStanとLaravel Pintを実行させる pre-commitスクリプトを配布
    • 不完全と機械的に判別できるコードはバージョン管理に入れないようにした
  • オンプレミスでセルフホストしているGitLabサーバを活用
    • GitLab Runnerを使って簡易CICDを構築

Laravelのディレクトリ構成をひとまず仮決めした

app/
├── Http/
    ├── Controllers/
    ├── Requests/
    ├── Resources/
├── Models/
├── Rules/ 
├── Enums/
└── UseCases/
    ├── Customer/
    └── Order/
        ├── OrderStoreAction.php
        ├── OrderStoreInput.php
        ├── OrderStoreOutput.php
        ├── StockAllocationAction.php
        ├── StockAllocationInput.php
        └── StockAllocationOutput.php
  • UseCaseクラスの引数はxxxInputクラスを指定するようにしました
    • xxxActionの引数にはxxxInput、返り値にxxxOutputというクラスを指定するようにした
      • 書籍「ドメイン駆動設計入門: ボトムアップでわかる!ドメイン駆動設計の基本」にあるコマンドオブジェクトパターンの項で、シグネチャをあまり変えなくていいというコンセプトを部分的に採用してます
    • xxxInputxxxOutput、私の現場ではいろんなものを連想配列に詰める慣例みたいなのがあったので、それはやめて欲しいというリクエストを出しています
      • IDEとかエディタで特別なプラグイン入れないと連想配列のキーの補完が出ない
      • 定義ジャンプとかができないから、という理由で
      • stringとかintとかをIN/OUTにできるのであれば、それをタイプヒンティングしてもらうようにしてます
// UseCaseの入力
final readonly class StockAllocationInput {
    public function __construct(
        public string $productId,
        public string $quantity
    )
}

// UseCaseの出力
final readonly class StockAllocationOutput {
    public function __construct(
        public string $productId,
        public string $current
    )
}

// タイプヒンティングを積極的にする
final class StockAllocationAction {
    public function __invoke(StockAllocationInput $input): StockAllocationOutput {}
}

より良いコードを書くためのガイドラインを書いてチームに配った

  • 何を持って「良い設計」「良いコード」とするか?
    • 世間一般の経験則とか原則に向かってコードを書いていくアプローチ
    • 現場のメンバーが共通の理解ができるコードを書いていくアプローチ
  • DDDとかクリーンアーキテクチャ?
    • 感覚論ですが、まだオーバースペックな気がします
      • エンドポイント50くらいに相当するWebAppをMVCで作ってきたしなんとかなりそう
      • 既存のWebMVCAppより規模大きくなるのが自明になったらでいいような気がした
    • 今のメンバーはまだスキルが足らない
      • ドメイン、っていう言葉の使い方がメンバーによってチグハグ
      • 画面駆動開発になった実績もある
  • 「共通化」に対しての考え方
    • 「Common」とか「Manager」とかの名前があるクラスが作られがち
      • 禁止にした
    • 横断的関心事については共通化OK
    • あるパッケージ内での共通化はOK
    • 業務領域を跨いだ共通化はNG (必然的にそうなるはずで)
      • 例えば、「商品を取得する」処理は、注文の文脈と、在庫管理の文脈で違う
      • 注文の文脈では、商品名や価格が大事だったり
      • 在庫管理の文脈では、在庫数とか品名構成だったり

PHPUnitの方針

WebAPI リプレイス後に発生した事業などの変化

  • 仕入れ商品を販売するECサイトをECモール化
    • 出品者様から商品を預かり自社倉庫に保管して出荷する
      • 倉庫保管手数料を頂くモデルができた
      • ある商品がある日に何個あって、倉庫の体積をどれくらい占有していたかのニーズがあった
      • 今回のWebAPIで作れそう
  • コールセンターの運用負荷軽減を減らしたいニーズ
    • AIボイスbotとか
      • 今回作ったWebAPIをインターネットからCALLしたい
      • bot用のエンドポイントが欲しい
  • 受注リードタイムの課題解決
    • Webシステムで受けた注文データが、オフラインバッチで1日1回ERPに連携している
    • システム上の都合で、エンドユーザーへ商品が届くのが1日遅くなっていた
    • オンラインで取り込みたい
      • このWebAPIで受注endpointを生やせば実現できそう
      • 倉庫側のピッキングとか伝票印刷の業務調整別途必要

上記のニーズに伴って、エンドポイントも10個から30個くらいに増えそう、とのことで開発メンバーが2人から4人に増加しました。
その変化や、今後も開発が続きそうなところを踏まえて、ディレクトリ構造を再考しました。

ディレクトリ構成の再考と変更

  • UseCaseを細分化したいニーズがあった
    • 複数のUseCaseで、「在庫引き当て」という処理を使いまわしたかった
      • 受注の文脈でも、仮の在庫引き当てと言う文脈でも、全く同じ処理にする必要があった
    • ニーズに応じるためにInternalというディレクトリを切った
  • 後でオニオンアーキテクチャに引っ越せる余地を作っておく
    • Packagesというディレクトリを上位に配置しておき
      • 後でDomainやInfrastructureを生やせる余地を作る
    • UseCase切っておいて、UseCaseのPHPUnitがしっかりかけていれば、機能が担保されているかテストを実行しながらコード構造を変えられそう
app/
├── Http/
    ├── Controllers/
    ├── Requests/
    ├── Resources/
├── Models/
├── Rules/ 
├── Enums/
└── Packages/
    ├── Customer/
    └── Order/
        ├── UseCase/
        │   ├── OrderStoreAction.php
        │   ├── OrderStoreInput.php
        │   ├── OrderStoreOutput.php
        │   ├── StockAllocationAction.php
        │   ├── StockAllocationInput.php
        │   └── StockAllocationOutput.php
        └── Internal/
            ├── xxxValidator.php
            ├── xxxAlocator.php
            ├── xxxMapper.php
            ├── xxxValueObject.php
            ├── xxxFactory.php
            └── xxxInterface.php
  • Internalをどう作ればいい?という質問があった
    • 作る機能によって適用できるパターンが違うんじゃないか説がありそうだったので、どう書けばいいか明確な答えが出せなかった
      • 現状はあんまりルールを決めないでおき、ある程度緩く作っていいと伝えてます
        • ルールだらけなのも、本当にケアして欲しいルールが薄くなったり、守るの面倒になって結局守られない、みたいな感じになりそうな気もする
    • これはやめてほしい、のようなものは出せそうだったので、ドキュメントに非推奨パターンをまとめることに

非推奨パターン

  • 結局Internalに責任転嫁するだけになって、Internalどう書けばいいかでみんな困る
  • こう書けばいいは示せないが、こう書いてほしくないは過去の実績から示せそうだ

PHPUnitを抹消する、全く書かない

  • 問題点
    • テストコードの書きづらさを感じている時にはその設計がよろしくない可能性があると言われている
      • 全く書かないと、その不吉さに気づきづらいかもしれない
    • 他の人が別の人のコードに手を入れづらい
    • 今回の構成でサーバミドルウェアとかのバージョン上げやすくするためにコンテナ採用しているが、それはPHPUnitが敷いてある前提で
    • OpenAPIの仕様書と実装が乖離して、OpenAPIの仕様書が使い物にいつかならなくなるのでは?

連想配列を関数の引数や返り値にする、タイプヒンティングがない

  • 問題点
    • IDEやエディタでとにかくコードが追いづらい
      • 連想配列のkeyが補完きくプラグインとかあればいいが...
      • 現場の人によって好みのエディタがあるので統一は難しい
// $arrayに色々詰まっている
function some($array) {
    ...
}

// 返り値には何が詰まっているか?
// 結局関数の実装を読まないといけない?
$result = some($data);

// 以下のようにしてほしい
function some(string $a, int $b) : DateTime

// シグネチャをあんまり変えたくなければ独自の型を作る
function some(xxxInputDto $data) : xxxOutputDto

「Commonクラス」「Managerクラス」

  • 問題点
    • SOLID原則の単一責任に反する可能性がしばしばある
    • どういうスコープで共通なのかよく分からない
    • 呼び出し元によって挙動が変わるの、それは共通化なのか...?
// こういうコードが過去の実績としてある
function common(): int {
    if ($_SERVER['REQUEST_URI'] === 'order...') {
        return 1;
    } else if ($_SERVER['REQUEST_URI'] === 'customer...') {
        return 2;
    } else {
        return 3;
    }
}

コードレギュレーションのチェックの自動化の検討・予定

  • 個人開発でちょっと触ってたUnityC#のAssembly Definitionからインスパイア
    • このクラスからあのクラスへの依存関係とかを持つかどうか
  • LaravelPHPではデフォルトでは素朴にnamespaceを指定すればどのクラスも活用できるが..
    • PurePHPの層からはLaravelFWに依存させたくないようなコーディングレギュレーションを設けるなら、静的解析ツールとかで自動チェックしたい
  • まだやり方はこれから調査するけど、PHPStanとかで行けるなら行きたい

↑例えばの例だけど、UseCase/xxxActionからLaravel Requestクラスへの参照を検知したい

Internalディレクトリを切るのは正しい判断だったのか?

final class OrderStoreAction {
    public function __invoke(OrderStoreInput $input, OrderValidator $validator, OrderMapper $mapper, StockAllocator $allocator): OrderStoreOutput {
        // こう言うふうに書きたいらしい
        $validator->validate($input);
        $mapData = $mapper->map($input);
        $result = $processor->process($mapData);
        ...

        // 在庫引き当ては部品的に使いたいらしい
        // OrderStoreAction以外でも使いたい
        $allocator->allocate($input);
    }
}
  • UseCaseは、利用者から見た時の機能実現を責務としており、その観点&ローンチ時に書いたコードで言うと単一責任の原則には違反していないはず
  • privateメソッドに抽出するのでもいいのでは?
  • 複数のUseCaseで、処理を使いまわしたい場合に冗長に書いてしまう道もある
  • InternalはLaravel界隈でもあんまり見かけないような気がしているので、結構オリジナルルールになりそう
    • UseCaseとInternalの境界線は?
    • オリジナルルールを守ったり作っていくのは、私は天才ではないので茨の道なはず

まだ残ってしまっている課題

  • テストコードを他メンバーに如何にして書いてもらうか
    • PHPUnitがいらないと思っているメンバーがいるはず (そうでなければ過去にテストコード抹消なんかしないはずで)
    • どうやって書くかは示せるが、どうして書くの?を如何に理解してもらうか?
      • 口で説明するより体感してもらうのが早いし納得感があると考えているが...
      • どうやったらそれを体感してもらえるか?みたいなところは未だに困っている
  • コードレビュー
    • 職場の文化として、マージリクエストを自身で承認してマージ実行してしまっている現状がある
    • マージリクエストを他人の承認制にするのがベターなのかはまだ模索中
      • レビュー文化を一時的に取り入れてみて、他の人の感想とかを受けた結果を反映したい
  • 言葉の使い方やコミュニケーション (私含めて)
    • すごく雑に「ドメイン云々~~~」って言ってしまっているような気がする
    • 話す人と聞く人で解像度が違うのをなんとかしたいが...
      • 自分でできそうなところだと「どういうニュアンスですか?」って問いかけて解像度を確かめるくらいか?
  • 認証・認可・権限まわり
    • 特にインターネットからCALLしたい要件にまだ対応してない
    • 特定のクライアントのみ許可すれば今のところいいので、ネットワークレイヤでIPアドレス制限 + アプリケーションでJWTシグネチャ検証 とか?
    • 最小権限の考え方に則って、インターネットへはすべてのAPIは公開せず、限定したAPIだけ公開したい

感想とか反省とか

色々取り組みをしてみたものの、結局 「私の現場で10年続くアプリケーションをどうソフトウェア設計すれば」がよく分からずに、うまくできているのかの評価ができませんでした。
書籍とかで学んだ一般的な原則を、他の人に納得してもらうレベルで説明するのもなかなか難しく、そもそも自分がちゃんと理解できていないところも多々ありました。
唯一上手くできたのは、現場の過去の実績 (コードやインフラ構成とか)を振り返ってみて、辛いところはアンチパターンとして定義したところでしょうか。私の現場ではコード書く時に連想配列に何も考えずに詰めまくるケースが多く、IDEとかでとにかくコードが追いづらい問題があったので、タイプヒンティングを強く要求しました。

皆さんの現場ではどういう取り組みをされていますでしょうか。
もしよろしければ、お気軽にコメントください。

細かい振り返り
  • SOLID原則とかの、ある種標準的な考えに則ったり、Laravel Wayに乗っかることで結構楽はできた感じがする
    • とはいえ、使用しているフレームワークとか、チームメンバーのスタイル・文化で原則が適用しづらかったりもした
    • DDDがオーバースペックとかがいまだに感覚値なので、定量的な判断とかが分からなかった
  • この構成で10年後まで回っているかはわからない気がする
    • だからこそソフトな特性が必要な気がする
    • 後でオニオンアーキテクチャに引っ越せるように、ディレクトリ構造とかは寄せたし、PHPUnitを書くようにもした
  • このリファクタリング的な活動の経済効果ってどれくらいなんだろうかと疑問に感じた
    • 開発者的にはやりたいんだけど...
    • 経営層にもちゃんと必要性を説明するにはどうすればいいか?
  • SOAPで実現していた機能をそのまま担保するためには、いったんPHPの世界に同じ構造でコピぺする必要がありそうだ
    • コピぺしっぱなしで改良を疎かにするケースがあったのでなんとかしたい

Discussion