🥤

Laravel & DockerでシンプルなMonoRepo環境を構築する

2022/01/11に公開

要約

  • LaravelでAPIサーバやバッチが複数あるシステムを構築するとMonolithでもMultiRepoでも開発がつらい。
  • Modelとmigrationsディレクトリをパッケージとして分離して使い回し、MonoRepo(一つのリポジトリ)で管理することでつらさを回避しよう。
  • 公式のパッケージ開発用ドキュメントを参考にMonoRepoで実用できるレベルに落とし込む。

Monolith, MultiRepoのつらみ

Monolith(一つのプロジェクト & 一つのリポジトリ)

Monolithと言われるとあまりピンとはこないかもしれませんが、composer create-projectしてLaravelプロジェクトを作成したデフォルトの構成と言われるとわかりやすいのではないでしょうか。

まず最初にチュートリアルで動かす構成でもありますし、小さなプロジェクトならこれで十分な構成です。
つまりは一つのディレクトリに全てのコードが含まれている状態です。

しかしこの構成の場合、プロジェクトの規模が大きく、ユーザー用と管理用のように明示的にAPIをエンドポイントで分離する場合に不都合が出てきます。

小規模なプロジェクトならばルーティングを//adminのように分割し一つのサーバーで動かせば済みますが、規模が大きくなると負荷分散やドメインを分けるためにそれぞれ別のサーバーとして分離させたくなります。

Monolithでこの問題を解決する場合、複数のサーバーへ同じコードをデプロイし、ユーザー用サーバーから管理用エンドポイントにアクセスできないように環境変数などを用いて片方のエンドポイントを無効化するなどの方法があげられます。

Monolith

しかしこの方法だとオペレート時に環境変数の設定ミスにより予期しない環境からアクセスできてしまう危険性があります。
また余分なコードやビルドが発生するためあまり効率が良くなかったり、規模の大きさと比例してコードが複雑化していくのもデメリットです。

MultiRepo(複数のプロジェクト & 複数のリポジトリ)

上記の問題を解決する方法としてMultiRepoという構成があります。

この構成ではユーザー用API、管理用APIとでLaravelプロジェクト自体を複数作成し、それぞれ別のリポジトリで管理します。
そうすることにより別々のサーバーへ独立してデプロイすることができるため、Monolithでのつらみを解消することができます。

しかしLaravelで開発するとなるとまた別の問題が出てきてしまいます。

通常LaravelではModelやmigrationがプロジェクトに依存しています。
そのため分割した全てのプロジェクトで全く同じModelのコードを書かなくてはならず、データベーススキーマに修正が入るたびに全てのリポジトリで更新が必要になります。

MultiRepo

リポジトリが2個程度ならまだなんとかなりますが、APIの増加やバッチ処理の追加などにより3個4個と増えていくと指数関数的につらみが増加していきます。[1]

またDDLを管理するmigrationファイルがLaravelプロジェクトに紐づく構造のため、ローカルでの開発やCIの実行時に一つのリポジトリで完結することができず、わざわざ別のデータベーススキーマが定義されているリポジトリを操作する必要があります。

Monolith, MultiRepoからMonoRepoへ

MonoRepoでは複数のプロジェクトを一つのリポジトリに集約します。

Modelやmigrationなどの共通部分をinfraパッケージに抜き出すことによりMultiRepoのつらみを解消しつつも、プロジェクト単位でCI/CDを構築することでMonolithの問題点も解消しています。

MonoRepo

ディレクトリ構造としては以下のようになります。

root/
├ user-api/    // Laravel Project
│ ├ app/
│ ├ bootstrap/
│ ├ ...
│ └ composer.json
├ admin-api/   // Laravel Project
│ ├ app/
│ ├ bootstrap/
│ ├ ...
│ └ composer.json
├ infra/           // Package
│ ├ src/
│ ├ database/
│ ├ ...
│ └ composer.json
├ docker/
│ ├ docker-compose.yml
│ ├ user-api/
│ │ └ Dockerfile
│ └ admin-api/
│   └ Dockerfile
└ README.md

このようなディレクトリ構造にすることで各プロジェクトで独立性を保ちながらも、Laravelの規約を崩さずにModelやmigrationなどを共通化することができます。

今回はこの一連の構造を構築する方法を解説していきます。

Laravel Projectの作成

まずは基本となるLaravelプロジェクトを作成します。
user-apiadmin-apiのどちらでも行う設定は同じので、今回はuser-apiのみに絞って説明していきます。

root/
├ user-api/    // Laravel Project
│ ├ app/
│ ├ bootstrap/
│ ├ ...
│ └ composer.json
└ README.md

Laravelプロジェクトはcomposerでもlaravel/installerでも好きな方法でセットアップしましょう。
ディレクトリの構造上、sailは使用できないので注意してください。

composer create-project laravel/laravel user-api

Dockerの構築

次はdockerディレクトリを作成し、Dockerの実行環境を構築します。

root/
├ user-api/    // Laravel Project
│ ├ app/
│ ├ bootstrap/
│ ├ ...
│ └ composer.json
├ docker/
│ ├ docker-compose.yml
│ └ user-api/
│   └ Dockerfile
└ README.md

必要最低限のシンプルな環境です。

docker/user-api/Dockerfile
FROM php:8.1-apache

COPY --from=composer:2.2.1 /usr/bin/composer /usr/bin/composer
RUN apt-get update && apt-get install -y \
    git \
    && docker-php-ext-install pdo_mysql

RUN sed -i 's!/var/www/html!/var/www/app/public!g' /etc/apache2/sites-available/000-default.conf
docker/docker-compose.yml
version: "3.7"

services:
  user-api:
    build:
      context: .
      dockerfile: ./user-api/Dockerfile
    volumes:
      - ../user-api:/var/www/html
    environment:
      - APP_ENV=local
    ports:
      - 8080:80
    depends_on:
      - db

  db:
    image: mysql:5.7
    environment:
      - MYSQL_ALLOW_EMPTY_PASSWORD=yes
      - MYSQL_DATABASE=my_db
      - TZ=Asia/Tokyo
    ports:
      - 3306:3306

最後にdockerコンテナを立ち上げておきましょう。

cd docker
docker compose up -d

Packageの作成

Laravelの拡張パッケージは公式が専用のドキュメントを用意してくれているので、こちらを参考に作成していきます。

https://laravelpackage.com/

composer.jsonのセットアップ

こちらのページを参考にcomposerのパッケージをセットアップしていきます。
基本的にはデフォルトの設定のままで大丈夫ですが、パッケージの名前はpackages/infraのようなディレクトリと一致するようなわかりやすい名前にしましょう。

mkdir infra
cd infra
composer init

composer initを実行すると以下のようなcomposer.jsonが作成されます。

infra/composer.json
{
  "name": "packages/infra",
  "description": "A demo package",
  "type": "library",
  "license": "MIT",
  "authors": [
    {
      "name": "John Doe",
      "email": "john@doe.com"
    }
  ],
  "require": {}
}

また.gitignoreを作成しておくと後々便利です。

infra/.gitignore
/vendor
/composer.lock
/.phpunit.result.cache

必要なライブラリの追加

パッケージで必要なライブラリをcomposer requireで入れます。
テストをしないのであればrequire-devは不要です。

  • require
    • php
    • illuminate/contracts
    • illuminate/database
    • illuminate/support
  • require-dev
    • fakerphp/faker
    • mockery/mockery
    • orchestra/testbench
    • phpunit/phpunit

autoloadの設定

パッケージとそれぞれのディレクトリを紐付けます。

infra/composer.json
// infra/
mkdir src
mkdir tests
infra/composer.json
{
  "name": "packages/infra",
  "description": "A demo package",
...
  "require-dev": {
      "fakerphp/faker": "^1.9.1",
      "mockery/mockery": "^1.4.4",
      "orchestra/testbench": "^6.23",
      "phpunit/phpunit": "^9.5.8"
  },
+ "autoload": {
+       "psr-4": {
+           "Sample\\Infra\\": "src/",
+       }
+   },
+   "autoload-dev": {
+       "psr-4": {
+           "Sample\\Infra\\Tests\\": "tests/"
+       }
+   }
}

Laravel ProjectにPackageを読み込ませる

最低限のパッケージは構成し終えたので一旦user-apiにパッケージを読み込ませてみましょう。

まずはuser-apiからパッケージを参照できるようにdocker-compose.ymluser-apicomposer.ymlを編集します。

docker/docker-compose.yml
version: "3.7"

services:
  user-api:
    build:
      context: .
      dockerfile: ./user-api/Dockerfile
    volumes:
      - ../user-api:/var/www/html
+     - ../infra:/var/www/html/packages
    environment:
      - APP_ENV=local
    ports:
      - 8080:80
    depends_on:
      - db
...
user-api/composer.json
{
  "scripts": { ... },

+ "repositories": [
+   {
+     "type": "path",
+     "url": "packages/infra"
+   }
+ ]
}

最後にdockerコンテナからinfraパッケージを追加します。

cd docker
docker compose exec user-api bash
...
composer require packages/infra

ここまでのディレクトリ構成

最終的に以下のようになっていれば問題ありません。

 root/
 ├ user-api/    // Laravel Project
+├ infra/           // Package
+│ ├ src/
+│ ├ tests/
+│ ├ .gitignore
+│ └ composer.json
 ├ docker/
 └ README.md

ModelとMigrationsのセットアップ

ここからがメインの部分になります。
こちらも公式のドキュメントを参考に作成していきます。

https://laravelpackage.com/08-models-and-migrations.html#models

Model

Modelは標準のLaravelでのModelと同じように作成することができます。
src/Modelsディレクトリを作成し、Illuminate\Database\Eloquent\Modelを継承するだけでOKです。

infra/src/Models/Post.php
<?php

namespace Sample\Infra\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
  use HasFactory;

  protected $guarded = [];
}

namespaceにはcomposer.jsonautoloadで設定した値を指定し、メインのLaravelプロジェクトでもそれを使用して参照することができます。

user-api/app/Http/Controllers/PostControllre.php
<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use Sample\Infra\Models\Post;

class PostController extends Controller
{
    public function find(int $postId): Post
    {
        return Post::find($postId);
    }
}

Migrations

Migrationsに関しても標準のLaravelと作成方法は変わりません。

Migrationファイルの作成

新しくdatabase/migrationsディレクトリとマイグレーションファイルを作成しましょう。

 root/
 ├ user-api/    // Laravel Project
 ├ infra/           // Package
 │ ├ src/
 │ ├ tests/
+│ ├ database/
+│ │ └ migrations/
+│ │   └ 2018_08_08_100000_create_posts_table.php
 │ ├ .gitignore
 │ └ composer.json
 ├ docker/
 └ README.md
infra/database/migrations/2018_08_08_100000_create_posts_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreatePostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('posts');
    }
}

ただしphp artisan make:migrationコマンドを使用するとLaravelプロジェクトの方にファイルができてしまうので、コマンドで作成したものを移動させるか自分で一から書きましょう。
その際マイグレーションファイルの命名規則に注意してください。

Migrationの実行

Modelとは異なりマイグレーションファイルを作成しただけではuser-apiプロジェクトの方でphp artisan migrateを実行してもマイグレーションされません。

なのでPackageにServiceProviderを作成してphp artisan migrateの対象ファイルとして読み込ませます。

まずはServiceProviderはこちらのページを参考にしてinfra/srcディレクトリ下に作成しcomposer.jsonに追記します。

そしてloadMigrationsFromヘルパを使用することでphp artisan migrateinfra/database/migrations下にある全てのマイグレーションファイルが実行されるようになります(ドキュメント

infra/src/InfraServiceProvider.php
<?php

namespace Sample\Infra;

use Illuminate\Support\ServiceProvider;

class InfraServiceProvider extends ServiceProvider
{
  public function register()
  {
    $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
  }

  public function boot()
  {
    //
  }
}
infra/composer.json
{
  ...,
  "autoload": { ... },

+ "extra": {
+     "laravel": {
+         "providers": [
+             "Sample\\Infra\\InfraServiceProvider"
+         ]
+     }
+ }
}

Factory

FactoryやSeederもパッケージ側で設定することが可能です。
infra下にdatabase/factoriesディレクトリを作成し、そこに標準のLaravelと同様のファクトリクラスを作成します。

infra/database/factories/PostFactory.php
<?php

namespace Sample\Infra\Database\Factories;

use Sample\Infra\Models\Post;
use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    protected $model = Post::class;

    public function definition()
    {
        return [
            //
        ];
    }
}

次にcomposer.jsonでファクトリへのnamespaceを設定します。

infra/composer.json
{
  ...,
  "autoload": {
    "psr-4": {
      "Sample\\Infra\\": "src",
+     "Sample\\Infra\\Database\\Factories\\": "database/factories"
    }
  },
  ...
}

設定した後にcomposer dump-autoloadするのを忘れないようにしましょう。

最後にModel側でファクトリの設定を上書きすれば完了です。

infra/src/Models/Post.php
class Post extends Model
{
    ...
+   protected static function newFactory()
+   {
+       return \Sample\Infra\Database\Factories\PostFactory::new();
+   }
}

テスト用の設定

Laravelプロジェクト側でテストを行う場合、Modelが読み込むことができ、php artisan migrateでパッケージ側のマイグレーションが実行されるため、特に設定など気にする必要なくRefreshDatabaseトレイトなどを使用してコードを書くことができます。

しかしパッケージ内でデータベースに接続するテストを書く場合は一工夫必要となります。

PHPUnitのセットアップ

パッケージ内でテストを行う場合、PHPUnitの設定を追加する必要があります。
こちらもドキュメントに記載されているので順に追っていきましょう。

ますはphpunitをパッケージに追加し、 phpunit.xmlinfraディレクトリ下に作成します。
APP_KEYはこのままでも問題ありませんが、気になるようなら任意の値に変更してください。

cd infra
composer require --dev phpunit/phpunit
infra/phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  bootstrap="vendor/autoload.php"
  backupGlobals="false"
  backupStaticAttributes="false"
  colors="true"
  verbose="true"
  convertErrorsToExceptions="true"
  convertNoticesToExceptions="true"
  convertWarningsToExceptions="true"
  processIsolation="false"
  stopOnFailure="false"
  xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
>
  <coverage>
    <include>
      <directory suffix=".php">src/</directory>
    </include>
  </coverage>
  <testsuites>
    <testsuite name="Unit">
      <directory suffix="Test.php">./tests/Unit</directory>
    </testsuite>
    <testsuite name="Feature">
      <directory suffix="Test.php">./tests/Feature</directory>
    </testsuite>
  </testsuites>
  <php>
    <env name="DB_CONNECTION" value="testing"/>
    <env name="APP_KEY" value="base64:2fl+Ktvkfl+Fuz4Qp/A75G2RTiWVA/ZoKZvp6fiiM10="/>
  </php>
</phpunit>

次にinfra/testsの下にFeatureディレクトリとUnitディレクトリ、TestCase.phpを作成します。

 root/
 ├ user-api/    // Laravel Project
 ├ infra/           // Package
 │ ├ src/
 │ ├ tests/
+│  │  ├ Feature/
+│  │  ├ Unit/
+│  │  └ TestCase.php
  │ ├ database/
 │ ├ .gitignore
 │ └ composer.json
 ├ docker/
 └ README.md
infra/tests/TestCase.php
<?php

namespace Sample\Infra\Tests;

use Sample\Infra\InfraServiceProvider;

class TestCase extends \Orchestra\Testbench\TestCase
{
  public function setUp(): void
  {
    parent::setUp();
  }

  protected function getPackageProviders($app)
  {
    return [
      InfraServiceProvider::class,
    ];
  }

  protected function getEnvironmentSetUp($app)
  {
    // perform environment setup
  }
}

データベースへの接続

今回は最初に定義したDockerコンテナのMySQLへアクセスします。
TestCase.phpgetEnvironmentSetUpメソッドに接続情報を記載するだけでテスト時に該当のデータベースへアクセスできるようになります。

MySQL以外の接続情報はLaravelプロジェクトのconfig/database.phpにあるものを参考にしてください。

infra/tests/TestCase.php
  protected function getEnvironmentSetUp($app)
  {
    $app['config']->set('database.default', 'testbench');
    $app['config']->set('database.connections.testbench', [
        'driver' => 'mysql',
        'url' => env('DATABASE_URL'),
        'host' => env('DB_HOST', '127.0.0.1'),
        'port' => env('DB_PORT', '3306'),
        'database' => env('DB_DATABASE', 'forge'),
        'username' => env('DB_USERNAME', 'forge'),
        'password' => env('DB_PASSWORD', ''),
        'unix_socket' => env('DB_SOCKET', ''),
        'charset' => 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
        'prefix' => '',
        'prefix_indexes' => true,
        'strict' => true,
        'engine' => null,
    ]);
  }
infra/phpunit.xml
    <php>
        <env name="DB_CONNECTION" value="testbench"/>
        <env name="APP_KEY" value="base64:2fl+Ktvkfl+Fuz4Qp/A75G2RTiWVA/ZoKZvp6fiiM10="/>
        <env name="DB_HOST" value="db"/>
        <env name="DB_PORT" value="3306"/>
        <env name="DB_DATABASE" value="テスト用データベース名"/>
        <env name="DB_USERNAME" value="root"/>
    </php>

まとめ

  • LaravelでAPIサーバやバッチが複数あるシステムを構築するとMonolithでもMultiRepoでも開発がつらい。
  • Modelとmigrationsディレクトリをパッケージとして分離して使い回し、MonoRepo(一つのリポジトリ)で管理することでつらさを回避しよう。
  • 公式のパッケージ開発用ドキュメントを参考にMonoRepoで実用できるレベルに落とし込む。

環境構築するだけの内容としてはボリュームが多くなってしまいましたが、コード量自体はそこまで多くなく、リポジトリの数が3,4個以上になるようなプロジェクトでは十分ペイできる内容だと思います。

また最初こそ若干戸惑うかもしれませんがLaravelの規約からはほとんど外れていませんし、公式のパッケージ開発方法をベースにしている[2]ので学習コストもほぼ0に近いのでおすすめです。

参考文献

脚注
  1. このレベルになるとデータベースへカラムを一個追加するだけでも相当めんどうな作業になるため、誰も触りたくなくなる悲しいプロダクトが誕生します。 ↩︎

  2. 実際この記事の後半は公式ドキュメントの内容をほぼ転記しているレベルでした。 ↩︎

Discussion