Headless Blog mit dem Laravel Framework

Aug 23, 2019 | Academy Day, Headless Blog, Wissen

Einleitung

Laravel ist ein sehr weitver­brei­tetes PHP-Framework und gehört zur den Kernwerk­zeugen bei uns im WTL.

Im Zuge unseres Academy-Days habe ich Laravel einge­setzt, um einen HeadlessBlog zu imple­men­tieren. Dabei habe ich diese Imple­men­tierung als Referenz­projekt erstellt, um den Tag bestmöglich vorbe­reiten zu können.

Als vorbe­reitete Rahmen­be­dingung gab es eine spezi­fi­zierte API-Struktur und dazuge­hörige API-Tests.

Das überge­ordnete Ziel unseres Projektes war festzu­stellen:

  • Ist der HeadlessBlog innerhalb eines Tages umsetzbar?
  • Genügt die Definition der API, um den Blog umsetzen zu können?
  • Sind die API-Test ausrei­chend gut definiert, um sicher­stellen zu können, dass die Projekte vergleichbar sind?

Da Laravel zu meinen Standard-Tools gehört, war es für mich persönlich besonders inter­essant folgende Aspekte zu betrachten:

 

  • Wie schnell kann ich das Projekt umsetzen?
  • Komme ich ohne Schwie­rig­keiten durch?
  • Wie schneidet Laravel gegenüber den anderen Projekten ab?

Stack und Setup

Zur Anwendung kam bei mir der “quasi”-Standard Techno­logie-Stack mit:

Datenbank

Die Datenbank beinhaltet drei Tabellen, die folgen­der­maßen mitein­ander verknüpft sind:

Ein User kann der Author von einem oder mehreren Posts und/oder Kommen­taren sein. Ein Post kann keinen oder mehrere Kommentare haben.

NGiNX

Der Nginx beinhaltet einen vhost, welcher auf Port 80 läuft.

Laravel

Das Setup von Laravel konnte mit der Standard­aus­prägung der composer.json vom Framework erfolgen, da im Zuge dieses Projektes keine Beson­deren Biblio­theken benötigt wurden.

Die Instal­lation erfolgte dann via:

composer install

Docker

Das lokale Setup erfolgt mit Docker, welches gleich­zeitig auch die Vorgabe im Rahmen des Acade­myDays darstellte. Es gibt jeweils einen Container für PHP, Nginx und MySQL, welche via docker-compose organi­siert und ausge­führt werden.

Gestartet wird die Umgebung via:

docker-compose -p hblog up --build

Datenbank Migra­tionen

Im Vorfeld der Imple­men­tierung habe ich zunächst Migra­tionen erstellt. Im Anschluss wurde von mir die Business­Logik program­miert und mit der API-Definition abgeglichen. Migra­tionen werden in Laravel über eine Daten­bank­ta­belle migra­tions organi­siert. Sollte diese Tabelle beim Ausführen der Migra­tionen nicht existieren, wird diese automa­tisch angelegt. Das Erstellen der Migra­tionen erfolgt bei Laravel über ein Konso­len­kom­mando:

php artisan make:migration AddTablePost

Es wird eine Datei im Migra­tionen-Ordner erstellt, die ich dann wie abgebildet angepasst habe. Mein Beispiel zeigt die Migration für die Tabelle posts mit den Spalten id, message, author_id, updated_at und created_at. Zusätzlich wird ein ForeignKey auf die users-Tabelle erzeugt. Im Falle eines Rollbacks wird die Tabelle posts gelöscht.

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

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

            $table->foreign('author_id')
                ->references('id')->on('users')->onDelete('cascade');
        });
    }

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

Das Rollback einer Migration erfolgt über:

php artisan migration rollback

Imple­men­tierung

Die Business­logik besteht aus Anlegen, Bearbeiten, Lesen und Löschen von Benutzern, Posts und Kommen­taren. Dazu soll es eine rudimentäre Benutzer-Authen­tif­zierung geben, welche in diesem Fall aus einem Token besteht, der mit dem Request mitge­schickt wird.

Routing

Es wurden folgende Routen angelegt:

Die Routen werden im Ordner routes in dem Fall in der Datei api.php erstellt. Dabei wird die Route definiert und auf die entspre­chende Action im Controller verlinkt. Das folgende Beispiel zeigt die Definition der Routen für die Kommentare:

Route::group(
            [
                'prefix' => '{id}/comments'
            ],
            function($id) {
                Route::get('/', [
                    'uses' => 'CommentController@index',
                ]);

                Route::post('/',[
                    'middleware' => 'auth:api',
                    'uses' => 'CommentController@add',
                ]);

                Route::put('/{commentId}',[
                    'middleware' => 'auth:api',
                    'uses' => 'CommentController@edit',
                ]);

                Route::get('/{commentId}', [
                    'uses' => 'CommentController@get',
                ]);

                Route::delete('/{commentId}', [
                    'middleware' => 'auth:api',
                    'uses' => 'CommentController@delete',
                ]);
            }
        );
Die hier auf gelistete Middleware auth:api ist für die Authent­fi­zierung zuständig.

Controller

Im folgendem Beispiel wird die Logik für das Anlegen eines Blog-Posts gezeigt. Dabei werden alle relevanten Daten, wie message und user aus dem Request gelesen. (Anmerkung: In einer perfekten Welt wäre dieser Code hier niemals im Controller selber gelandet, aber im Zuge dieses Referenz­pro­jektes erschien mir das ausrei­chend.)

    /**
     * @param Request $request
     *
     * @return \App\Http\Resources\Post
     */
    public function add(AddPostRequest $request)
    {
        $post = new Post($request->all());
        $post->author()->associate($request->user());
        $post->save();
        return new \App\Http\Resources\Post($post);
    }

Als erstes wird ein neuer Post mit den Daten des Requests angelegt. Im nächsten Schritt wird der angemeldete User (also der User dessen Token mitge­geben wurde) als Author des Posts gesetzt und anschließend wird der Post gespei­chert.

Validierung

Um sicher­zu­stellen, dass die Daten im Request im richtigen Format vorliegen, wird in meiner Imple­men­tierung die Laravel-Formre­quest-Validierung verwendet. Dabei wird ein FormRe­quest erstellt, der bestimmte Validie­rungs­regeln enthält, welche direkt bei der Erstellung des Requests ausge­wertet werden. Im Falle eines Fehlers gibt die API einen 422-Response mit den entspre­chenden Fehler­mel­dungen zurück.

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class AddPostRequest extends FormRequest
{
    public function rules ()
    {
        return [
            'message' => 'required',
        ];
    }
}

Im folgenden Beispiel wird ein beispiel­hafter Response einer fehlge­schla­genen Validierung darge­stellt.

{
    "message": [
        "The message field is required."
    ]
}

Response

Der Response der Action wird mit Hilfe der Laravel Resources erstellt. Laravel Resources erlauben es Models anhand definierter Regeln in eine JSON-Struktur umzuwandeln, welche dann als Response von der API geliefert wird. Der folgende Code-Ausschnitt zeigt die Resource für einen Post:

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

/**
 * Class Post
 *
 * @package App\Http\Resources
 */
class Post extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'message' => $this->message,
            'author' => [
                'id' => $this->author->id,
                'name' => $this->author->name,
                'email' => $this->author->email,
            ],
            'comments' => (new CommentCollection($this->comments))->collection,
        ];
    }
}

In dieser Resource wird festgelegt, dass ich die Felder id, message, author und comments eines Posts haben möchte. Vom Author benötige ich nur die Felder id, name und email. Die Struktur der Kommentare werden in einer eigenen Resource festgelegt. Der resul­tie­rende JSON-Response sieht wie folgt aus:

{
    "id": 23,
    "message": "myHardCodedValue",
    "author": {
        "id": 34,
        "name": "myHardCodedValue",
        "email": "6hA0VfJmTB1Uvx4JeIC6@foo.com"
    },
    "comments": [
        {
            "id": 13,
            "message": "Testkommentar",
            "post_id": 23,
            "author": {
                "id": 34,
                "name": "myHardCodedValue",
                "email": "6hA0VfJmTB1Uvx4JeIC6@foo.com"
            }
        }
    ]
}

Wie definiert erhalten wir als Response einen Post, mit Author und einer Liste an Kommen­taren.

Ausführung

Ich habe den Blog soweit möglich mit Postman getestet. Dazu habe ich natürlich einige manuelle Requests geschickt und ausge­wertet, als auch eine Testsuite erstellt und ausge­führt. Das war gleich­zeitig die erste Version unserer API-Tests. In einer weiteren Iteration, haben wir Chakram als API-Test-Tool einge­führt. Zusätzlich habe ich ein paar Perfor­mance-Tests mit artillery.io und gatling.io ausge­führt.

Erkennt­nisse und Verbes­se­rungen

Vorallem im Zuge der Chakram-Tests und der Perfor­mance-Tests ist mir aufge­fallen, dass meine JavaScript-Kollegen deutlich schneller unterwegs waren. Ich habe dann debugged und an den Einstel­lungen des PHP-FPM und NGINX herum­ge­stellt. Leider ohne größere Erfolge. Als ich einen Profiler dazuge­nommen habe, ist mir aufge­fallen, dass ich unglaublich viele Daten­bank­ab­fragen für meine 10 Blogposts durch­ge­führt habe. Ich konnte dann tatsächlich Lazy-Loading als den bösen Buben identi­fi­zieren und habe auf Eager-Loading umgestellt. Das folgende Beispiel zeigt meine Umstellung.

// Lazy

	public function index()
    {
        return new PostCollection(Post::all());
    }

// Eager

	public function index()
    {
        return new PostCollection(Post::with(['author','comments', 'comments.author'])->get());
    }

Gerade der Request auf alle Posts war davon sehr stark betroffen. Da die Laravel Resources für jeden Post den Author und die Kommentare einzeln nachge­laden haben hatte zeitweise 450 Abfragen beim Abfragen der Post-Liste (je nachdem wieviele Kommentare ich an die Posts angehängt hatte).

Kurz die Erklärung der Abfragen für 10 Posts mit je 5 Kommen­taren:

Gesamt 71 Gesamt 4
Lazy Loading Eager Loading
Abfrage Anzahl Abfragen Abfrage Anzahl Abfragen
Hole alle Posts 1 Hole alle Posts 1
Hole für jeden Post den Author 10 Hole alle Authoren zu allen Posts 1
Hole für jeden Post die Kommentare 10 Hole alle Kommentare zu allen Posts 1
Hole für jeden Kommentar den Author (5 Kommentare mal 10 Posts) 50 Hole alle Authoren zu allen Kommen­taren 1

Heißt das, dass wir lieber kein Lazy-Loading nutzen sollten? Nein!

In diesem Fall hat mir die Umstellung auf Eager-Loading zu einen Perfor­mance-Schub verholfen und mir damit vermutlich auch einige fiese Kommentare meiner lieben Kollegen erspart. 🙂 Insgesamt sollten wir aber von Fall zu Fall entscheiden, welches Vorgehen für uns das richtige ist.

Zusam­men­fassung

Ich hatte eingangs ein paar Fragen gestellt, die ich mir mit diesem Projekt beant­worten wollte.

  • Wie schnell kann ich das Projekt umsetzen?
    • Der HeadlessBlog war nach gut 4h einsatz­bereit, hat aber perfor­mance­tech­nisch noch Luft nach oben gehabt.
  • Komme ich ohne Schwie­rig­keiten durch?
    • Da hier keine größeren Beson­der­heiten auftraten, kam ich auch ganz ordentlich durch. Einzig die Perfor­mance hat mich ein wenig ins Schwimmen gebracht.
  • Wie schneidet Laravel gegenüber den anderen Projekten ab?
    • Nach meinen Fixes, konnte ich mit allen „Konkurrenz-Projekten“ mithalten.
  • Ist der HeadlessBlog innerhalb eines Tages umsetzbar?
    • Der Blog ist innerhalb eines Tages umsetzbar, wenn man die Sprache und das Framework kennt. Wenn da noch eine Einar­beitung erfor­derlich ist, dann wird die Zeit eher knapp.
  • Genügt die Definition der API, um den Blog umsetzen zu können?
    • Es gab noch ein paar unklare Punkte in der Definition die wir während des Projektes gemeinsam gefixed haben.
  • Sind die API-Test ausrei­chend gut definiert, um sicher­stellen zu können, dass die Projekte vergleichbar sind?
    • Die API-Tests waren letztlich unser ausschlag­ge­bendes Kriterium, ob der HeadlessBlog „korrekt“ umgesetzt wurde. Auch hier haben wir im Laufe des Tages ein paar Anpas­sungen vorge­nommen.

Die Umsetzung diesen kleinen Projektes hat mir und uns als Team gezeigt, dass wir mit Laravel schnell und ordentlich ans Ziel kommen können. Damit kann ich sagen, dass Laravel beim WTL zurecht zu den Basic-Tools in der Webent­wicklung gehört.