Laravel Mehrmandanten Anwendungen (Multi-Tenenacy)

 | 935 Wörter


Ahoi,

ich sah mich zuletzt vor der Herausforderung eine interne Firmenanwendung mehrmandanten-fähig zu gestalten. Voraus geht dem Ganzen, dass alle Mandanten sich, dasselbe Daten-Layout teilen aber jeder Mandant jeweils eine eigene Datenbank führt.

In diesem Fall ist der ganze Prozess relativ einfach umzusetzen. Es ist auch möglich, vollkommen unterschiedliche Datenquellen (Datenbanken, APIs etc) zu vereinheitlichen, aber vor dem Problem stand ich zum Glück nicht :)

Zuallererst ist uns also bewusst, dass wir praktisch immer mit denselben Tabellen und Abfragen arbeiten können, unabhängig davon in welchem Mandaten man sich bewegt. Es muss “lediglich” festgestellt werden, welcher Mandant gerade gefragt ist, um dann die korrekte Datenbank abzufragen. Aber wie?

Mein konkreter Fall bezieht sich auf eine Web-Anwendung, welche die Daten aus allen Mandaten zusammenführen soll, ohne das drei eigenständige Anwendungen entwickelt werden müssen.

Fangen wir also mit einem normalen Laravel Projekt an:

1
2
3
4
$ composer create-project laravel/laravel app
Creating a "laravel/laravel" project at "./app"
Installing laravel/laravel (v10.2.3)
...

Die Ordnerstruktur sieht wie folgt aus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
app
artisan
bootstrap
composer.json
composer.lock
config
database
package.json
phpunit.xml
public
README.md
resources
routes
storage
tests
vendor
vite.config.js

Damit wir die allgemeine Logik von tenant-spezifischer unterscheiden können, legen wir im Wurzelordner den Ordner " tenants" und “tenants/Models” an:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ mkdir -pv tenants/Models
mkdir: created directory 'tenants'
mkdir: created directory 'tenants/Models'
$ ls -lsa
...
routes
storage
tenants
tests
vendor
...

Wieso aber legen wir einfach nur den Ordner “Tenants” unter “app/Models” an?

Grundsätzlich funktioniert das natürlich aber es kann in diesem Modell zu unübersichtlich werden, wenn neben tenant-spezifischen Models auch Controller, Middlewares, Service Provider, Scopes usw. benötigt werden. Dann müsste nämlich ebenfalls unter “app/Providers”, “app/Http/Controllers”, “app/Http/Middlewares” der Ordner “Tenants” angelegt werden.

Ich bevorzuge daher einen einzigen Ordner, welcher durch seine Struktur den “app” Ordner von Laravel widerspiegelt. Das würde dann in etwa so aussehen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ cd tenants/ && tree .
tenants
├── Tenant1
│   ├── Http
│   │   ├── Controllers
│   │   └── Middlewares
│   ├── Models
│   │   └── Scopes
│   └── Providers
└── Tenant2
    ├── Http
    │   ├── Controllers
    │   └── Middlewares
    ├── Models
    │   └── Scopes
    └── Providers

Damit die gesamte Organisation des Codes einheitlich ist, geben wir uns selbst zwei Einschränkungen:

  • Eine allgemeingültige Implementierung ist IMMER eine abstrakte Klasse im Unterordner “app”

  • Eine tenant-spezifische Implementierung muss IMMER von einer allgemeingültigen erben, liegt im Unterordner ' tenants/TENANT_NAME/' und folgt der Ordnersturkur der allgemeingültigen Implementierung.

Diese Einschränkungen erlauben uns, den Code vom korrekten Tenant zur Laufzeit zu bestimmen.

Als Nächstes müssen wir den Autoloader mit den neuen “tenants” Ordner bekannt machen. Dazu bearbeiten wir die composer.json und fügen Folgendes im Block “autoload” ein:

1
2
3
4
5
6
7
 "autoload": {
"psr-4": {
...
"Tenants\\": "tenants/",
...
}
},

Kommen wir jetzt aber zu Code:

Um die Funktionsweise zu demonstrieren, benötigen wir 3 Models. Eins als allgemeine Implementierung und eins für jeden Tenant. Zur Vereinfachung nutzen wir das “User” Model, welches Laravel mitliefert.

1
2
3
4
5
6
7
// tenants/Tenant1/Models/User.php

use App\Models\User as BaseModel;

class User extends BaseModel {
    protected $connection = 'tenant1';
}
1
2
3
4
5
6
7
// tenants/Tenant2/Models/User.php

use App\Models\User as BaseModel;

class User extends BaseModel {
    protected $connection = 'tenant2';
}

Die Model jedes Tenants erben das “User” Model und überschreiben lediglich die Datenbank-Verbindung. Diese wären in der Praxis noch in der “config/database.php” anzulegen.

Im Anschluss müssen wir einmal den Autoloader ausführen, um die neuen Klassen zu finden:

1
2
3
4
5
6
$ composer dumpautoload
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi

Generated optimized autoload files containing 5807 classes

Und wenn in der web.php Folgendes einfügen, können wir uns ein ersten Bild von der aktuellen Situation machen:

1
2
3
4
5
// web.php

Route::get('/', function () {
    dd(resolve(\App\Models\User::class), resolve(\Tenants\Tenant1\Models\User::class), resolve(\Tenants\Tenant2\Models\User::class));
});

Ausgabe:

App\Models\User {#286 ▼ // routes/web.php:17 #connection: null … }

Tenants\Tenant1\Models\User {#287 ▼ // routes/web.php:17 #connection: “tenant1” … }

Tenants\Tenant2\Models\User {#288 ▼ // routes/web.php:17 #connection: “tenant2” … }

Wie zu erwarten, bekommen wir genau das, wo nach wir gefragt haben.
Aber wieso nutzen wir die resolve Funktion?

Laravels resolve Funktion erlaubt es uns, den Autoloader zu manipulieren und anhand von Laufzeitvariablen zu bestimmen, welcher Tenant zum aktuellen Zeitpunkt gefordert wird.

In unserem Fall können wir aber keine Interfaces einsetzen, da einiges in der Konfiguration und Authentifizierungs-Logik von der “User” Klasse abhängt. Damit wir das umgehen, nutzen wir Laravels Binding und binden das User Model zur Laufzeit auf einen Tenant:

1
2
3
4
5
6
7
// web.php

app()->bind(\App\Models\User::class, \Tenants\Tenant1\Models\User::class);

Route::get('/', function () {
    dd(resolve(\App\Models\User::class), resolve(\Tenants\Tenant1\Models\User::class), resolve(\Tenants\Tenant2\Models\User::class));
});

Wir sagen Laravel also, wenn nach der Klasse “\App\Models\User” gefragt wird, erzeuge ein Objekt der Klasse " \Tenants\Tenant1\Models\User" und gebe es zurück.

Ausgabe:

App\Models\User {#286 ▼ // routes/web.php:17 #connection: “tenant1” … }

Tenants\Tenant1\Models\User {#287 ▼ // routes/web.php:17 #connection: “tenant1” … }

Tenants\Tenant2\Models\User {#288 ▼ // routes/web.php:17 #connection: “tenant2” … }

Für eine komplexere Logik kann man auch eine Funktion übergeben:

1
2
3
4
5
6
7
// web.php

app()->bind(\App\Models\User::class, function() {
    $flag = false;

    return $flag ? new \Tenants\Tenant1\Models\User : new \Tenants\Tenant2\Models\User;
});

Ausgabe:

App\Models\User {#286 ▼ // routes/web.php:17 #connection: “tenant2” … }

Tenants\Tenant1\Models\User {#287 ▼ // routes/web.php:17 #connection: “tenant1” … }

Tenants\Tenant2\Models\User {#288 ▼ // routes/web.php:17 #connection: “tenant2” … }

Wunderbar! Wir können nun die Logik also an unsere Bedürfnisse anpassen und müssen im Anschluss nur noch alle Aufrufe der Klasse “App\Models\User” mit der resolve Funktion wrappen :)

#beispiel #datenbank #dependecy injection #inversion of control #laravel #mehrmandanten #multi-tenancy #pattern #php #programmierung #tenant