WEB
Laravel : bases de données et performances
Par Aubin Puyoou, publié le 07/09/2021 à 21:49.
Interactions avec la base de données¶
Un des fondamentaux d'une application Web est sa façon d'interagir avec une base de données. La documentation présente en détail différentes manières configurer ses connexions.
Par défaut, Laravel manipule aisément MySQL/MariaDB, PostgreSQL, SQLite
et SQLServer. Le driver principal associé à nos connexions se situe dans le fichier
.env à la ligne DB_CONNECTION=mysql
que nous avons modifié précédemment.
Le détail des configurations de ces drivers est disponible dans le fichier config/database.php, et nous
permet entres-autres d'établir des connexions à plusieurs schémas en simultané ou d'ajouter par exemple un
driver MongoDB.
Etant donné que nous avons normalement déjà établi la connexion à notre BDD dans le chapitre précédent, nous aborderons ici :
- La création de tables à l'aide de migrations
- Les différentes manières d'interroger la base et l'ORM Eloquent, avec quelques bonnes pratiques
- Une façon de peupler celle-ci avec des classes Seeder et Factory
- En bonus, un test de performance des fonctionnalités proposées par le framework
Migrations¶
Nous avons précédemment instancié une migration que nous pouvons lister dans le dossier :
! ls database/migrations/
2014_10_12_000000_create_users_table.php 2014_10_12_100000_create_password_resets_table.php 2019_08_19_000000_create_failed_jobs_table.php 2021_06_01_122828_create_books_table.php
Nous pouvons remarquer l'existence de 3 autres migrations créé automatiquement lors de la génération du projet. Laravel met à disposition des schémas prédéfinis pour des fonctionnalités récurrentes dans les applications web.
Si nous analysons en détail la migration que nous avons créé dans le chapitre précédent, 2021_06_01_122828_create_books_table.php, nous avons :
! cat database/migrations/2021_06_01_122828_create_books_table.php
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; class CreateBooksTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('books', function (Blueprint $table) { $table->id(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('books'); } }
Notre classe possède 2 méthodes :
- up() : s'occupe de gérer l'ajout ou la création de nouvelles tables / colonnes, de modifier leur type etc...
- down() : doit nécéssairement effectuer l'effet contraire de la méthode up()
Dans notre exemple, notre méthode up() créé la table books, tandis que notre méthode down() la détruit. Ici nos deux fonctions manipulent la facade Schema pour générer la table. En regardant en détail cette classe, on peut apercevoir les différentes méthodes qui nous permettront de manipuler nos tables. S'y ajoutent aussi des méthodes permettant de définir les contraintes d'intégrité de chacune de nos tables. Cette facade sert de filtre, dans le cas où nous utiliserions plusieurs bases de données différentes, et nous permet de condenser les multiples syntaxes utilisées pour chacune d'entre-elles en une seule.
Il est également possible de directement utiliser celle appropriée à notre base de données. Une syntaxe alternative aurait pu être :
! cat database/migrations/2021_06_01_122828_create_books_table.php
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; class CreateBooksTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { DB::statement('CREATE TABLE books ( id bigint auto_increment, created_at datetime, updated_at datetime );'); } /** * Reverse the migrations. * * @return void */ public function down() { DB::statement('DROP TABLE books;'); } }
_Exercice : à l'aide du constructeur de schéma ou d'une requête SQL classique, préparer une migration permettant de générer et supprimer la table suivante (en laissant pour l'instant, le champ authorid nullable) :
Une fois notre migration prête, il ne nous reste plus qu'à la lancer.
# Pour appeler la méthode up()
! sail artisan migrate
Migrating: 2014_10_12_000000_create_users_table Migrated: 2014_10_12_000000_create_users_table (717.23ms) Migrating: 2014_10_12_100000_create_password_resets_table Migrated: 2014_10_12_100000_create_password_resets_table (1,589.78ms) Migrating: 2019_08_19_000000_create_failed_jobs_table Migrated: 2019_08_19_000000_create_failed_jobs_table (620.50ms) Migrating: 2021_06_01_122828_create_books_table Migrated: 2021_06_01_122828_create_books_table (459.63ms)
# Et pour faire machine arrière avec la méthode down()
! sail artisan migrate:rollback
Rolling back: 2021_06_01_122828_create_books_table Rolled back: 2021_06_01_122828_create_books_table (506.26ms) Rolling back: 2019_08_19_000000_create_failed_jobs_table Rolled back: 2019_08_19_000000_create_failed_jobs_table (568.24ms) Rolling back: 2014_10_12_100000_create_password_resets_table Rolled back: 2014_10_12_100000_create_password_resets_table (252.37ms) Rolling back: 2014_10_12_000000_create_users_table Rolled back: 2014_10_12_000000_create_users_table (244.11ms)
Lorsqu'une migration est lancée, la table migrations créée dans le chapitre précédent permet de marquer l'ensemble des actions qui ont déjà eu lieu.
!!! NB !!! : une migration altérant une colonne s'applique sur toutes les lignes de la table.¶
Mise à jour du modèle associé à notre table¶
De manière à conserver une cohérence entre notre couche de données et les modèles de notre application, il nous faut mettre à jour le modèle créé dans la première partie de la leçon.
! cat app/Models/Book.php
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Book extends Model { }
Par défaut, Laravel gère automatiquement les identifiants et les champs dates de création / mise à jour. Même si nous ne souhaitons par forcément nous servir d'Eloquent, il est préférables de conserver la syntaxe utilisée sur la documentation de manière à faciliter la relecture. Si jamais vous êtes amenés à utiliser des tables déjà instanciées, avec des noms de colonnes différents pour les identifiants / dates, il est également possible de les redéfinir sur le modèle.
Nous avons ajouté ici les champs title, author, edition, pages_count et price. Le champ author faisant référence à un biginteger d'une table que nous créerons plus tard, nous l'insèrerons au même moment.
Pas besoin ici de typer nos variables, étant donné qu'elles sont contenues dans un tableau
$fillable
.
Nous ne voyons pas ici nous champs created_at / updated_at, mais sont tout de mêmes manipulés lors d'insertion en base. Dans notre cas si une instance d'un Book tente d'être insérée en base mais que ces deux champs sont null, une erreur s'affichera.
! cat ../app/Models/Book.php
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Book extends Model { protected $fillable = ['title', 'edition', 'pages_count', 'price']; }
! sail artisan make:factory BookFactory
Factory created successfully.
Analysons le :
! cat database/factories/BookFactory.php
<?php namespace Database\Factories; use App\Models\Book; use Illuminate\Database\Eloquent\Factories\Factory; class BookFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = Book::class; /** * Define the model's default state. * * @return array */ public function definition() { return [ // ]; } }
Nous pouvons voir que cette classe se compose de deux items :
- un modèle, qui pointe ici vers le modèle Book
- une fonction definition(), qui nous servira à lister la manière dont devront être instancier les attributs d'un Book généré.
Notons tout de même que le nom de la classe saisi en ligne de commande a directement influencé le modèle sur lequel se basera ce nouvel objet.
L'objectif désormais est de définir tous les attributs par défaut listés dans le modèle.
! cat database/factories/BookFactory.php
<?php namespace Database\Factories; use App\Models\Book; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Factory; class BookFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = Book::class; /** * Define the model's default state. * * @return array */ public function definition() { return [ 'created_at' => Carbon::now()->toDateTimeString(), 'updated_at' => Carbon::now()->toDateTimeString(), 'title' => 'Titre', 'edition' => 'Edition', 'pages_count' => 500, 'price' => 30.00 ]; } }
Il est désormais possible d'instancier un objet avec ces valeurs par défaut. Il est également possible à ce niveau d'user de toutes sortes de bibliothèques pour générer des données aléatoires et cohérentes (Faker).
$book = Book::factory()->make()
=> App\Models\Book {#3411
created_at: "2021-06-01 23:06:53",
updated_at: "2021-06-01 23:06:53",
title: "Titre",
edition: "Edition",
pages_count: 500,
price: 30.0,
}
La commande à simplement eu pour but d'instancier l'objet ici. Il est possible de le sauvegarder en base avec un
$book->save();
=> true
Nous pouvons désormais donner des valeurs par défaut à nos futurs Books.
Seeders¶
Il peut parfois être utile de peupler une base de données, que ça soit pour simuler des tables avec de gros volumes ou bien simplement à des fins de tests.
Les Seeders sont les classes prévues à cet effet.
! sail artisan make:seeder BookSeeder
Seeder created successfully.
Comme d'habitude, ouvrons le capot :
! cat database/seeders/BookSeeder.php
<?php namespace Database\Seeders; use Illuminate\Database\Seeder; class BookSeeder extends Seeder { /** * Run the database seeds. * * @return void */ public function run() { // } }
C'est dans cette méthode run() que nous devrons placer la logique de création de nos lignes.
Comme pour les migrations, l'utilisation ou non d'Eloquent est possible en entrant notre requête directement
à l'aide d'un DB::statement()
.
! cat ../database/seeders/BookSeeder.php
<?php namespace Database\Seeders; use App\Models\Book; use Illuminate\Database\Seeder; class BookSeeder extends Seeder { /** * Run the database seeds. * * @return void */ public function run() { Book::factory(10)->create(); } }
! sail artisan db:seed --class=BookSeeder
Database seeding completed successfully.
mysql> SELECT COUNT(*) FROM books;
+----------+
| COUNT(*) |
+----------+
| 11 |
+----------+
Car nous avons fait un $book->save()
quelques lignes auparavant.
Etant donné que cette formation s'articule autour de l'écosystème Laravel, je conserverai les requêtes en utilisant l'ORM dans mes exemples. Il est cependant important de conserver la logique dans les classes dans lesquelles elles doivent être implémentées. J'essaierai de détailler en quoi l'utilisation ou non des méthodes de l'ORM peuvent être nuisibles dans les situations où elles le seront.
Maintenant à vous de jouer, en manipulant ces outils, commencez à créer une base de test et son jeu de données. Voici le schéma utilisé pour l'exemple :
Relations¶
Un des concepts clés d'Eloquent au travers de la classe Model est l'ajout de certaines méthodes permettant de gérer directement les relations entre nos différents objets, tout en récupérant les données stockées dans la base.
Exemple d'une relation BelongsTo() / HasMany()¶
En partant de notre modèle, un Book est associé à un Author. De manière à représenter cette relation côté objet, nos modèles doivent respectivement se composer des méthodes :
// Pour Book, qui est associé à un Author
public function author(){
return $this->belongsTo(Author::class, 'author_id');
}
// Pour Author, qui est associé à plusieurs Books
public function books(){
return $this->hasMany(Book::class);
}
permettant de récupérer l'auteur associé. Nous pouvons ainsi depuis n'importe quelle instance de Book ou Author récupérer la liste des champs associés par la relation.
$book->author()->first();
$author->books()->get();
Il est important de noter ici que la méthode d'association nous retourne une requête Eloquent et non pas directement le tableau associatif. Le chapitre suivant traite les problématiques de performances que peuvent impliquer une mauvaise utilisation de ces méthodes.
Autres méthodes¶
Il est possible de combiner ces méthodes de manière à représenter les relations de notre base de données. Ainsi pour générer des relations 0.. ----- 0.. de bases de données classique, il est possible de renseigner deux modèles possédant une fonction renvoyant un hasMany(). La liste des relations implémentables est disponible sur la documentation Laravel.
Tests de performances ORM Eloquent / Requêtes SQL classiques¶
Perte de performances associée à la méconnaissance de l'ORM¶
Il est important de bien connaître Eloquent avant de le manipuler, sans quoi son utilisation pourrait fortement endommager les performances du site web développé. Le détail des bonnes pratiques et méthodes disponibles au propos de la gestion des lignes est très bien décrit sur la documentation Laravel.
Un des cas typique de sa mauvaise utilisation est associé à son système de relations.
Imaginons que nous souhaitions un objet pour en récupérer son auteur, une des façons de faire pourrait être :
// Récupération du premier livre de la base
$book = Book::find(1)->first();
// Récupération de l'auteur associé
$authorName = $book->author()->get('name');
Lorsque cette dernière méthode est appelée, l'ORM envoie une instruction SQL préparée équivalente à :
SELECT * FROM authors WHERE authors.id in ?;
Il est possible assez vite de s'imaginer la résultat catastrophique que l'appel de cette fonction
entrainerait par exemple lors d'un listage de données dans un tableau par exemple.
Ici la bonne pratique serait de récupérer les auteurs en même temps que nos livres via la méthode with()
dans la requête intiiale.
$book = Book::find(1)->with('author')->first();
$authorName = $book->author->name;
Requêtes Eloquent / SQL et génération d'objets¶
Etant donné l'impact que peuvent avoir les requêtes SQL sur les performances d'un site Web, le choix d'utiliser ou non l'ORM Eloquent peut être crucial une fois notre application déployée. De manière à manipuler un nombre de lignes assez conséquent, j'ai décidé de présenter un test basé sur un type de requête classique se composant d'une jointure, et d'un critère de sélection en me basant sur le modèle précédent.
SELECT * FROM books join authors ON books.author = authors.id where title like '%a%'";
L'ORM nous retournant une série d'objets, nous testerons ici les performances :
- de récupération des lignes
- de la création des objets associés
Il est possible en Laravel d'effectuer des requêtes de 3 manières différentes :
- L'ORM Eloquent :
Book::with('author')->where('title', 'like', '%a%')->get();
- Certaines fonctions de la facade DB, nous fournissant un ensemble de fonctions permettant de générer des
requêtes SQL de manière à faire abstraction du type de base de données utilisée, en uniformisant les
syntaxes :
DB::table('books') ->join('authors', 'books.author', '=', 'authors.id') ->where('title', 'like', '%a%') ->get();
- Une requête SQL classique, toujours avec la facade, mais celle-ci envoyée directement au driver :
DB::select("SELECT * FROM books join authors ON books.author = authors.id where title like '%a%'");
La base de test présentée ici se compose de :
- 10000 Livres
- 1000 Auteurs
Chacun des tests détaillés ci-dessous sont disponibles sur Github, dans le fichier OrmBenchmarkDemoController.php. Pour les tester, lancer le navigateur sur la route http://localhost/books/XXX où XXX correspond au nom d'une fonction présente dans le Controller. Une fois que l'interface de debug apparaît, une case "Queries" est accessible dans la fenêtre "Debug" et permet d'accéder aux détails des fonctions SQL exécutées.
Eloquent¶
Dans ce cas précis, notre ORM nous retourne directement un ensemble d'objets Book avec leur Author respectif en environ 0.12 secondes pour 12582912 bytes d'utilisation de RAM. Il est intéressant de voir ici comment la jointure est générée :
select * from `books` where `title` like ?; -- en 112.28 ms
select * from `authors` where `authors`.`id` in (1, ..., 1000); -- en 10 ms
Plutot que de faire une jointure, notre requête ici va récupérer tous les ID des clés étrangères de la
première table pour ensuite récupérer toutes les lignes associées sur la seconde. Sur des tables composées
d'un grand nombre de clés étrangères, ce type de requête va forcément générer des ralentissements.
Eloquent va automatiquement gérer la génération d'objets, et en fonction des ID, les associer les uns entre
les autres. A savoir également qu'il est possible de mettre en cache nos objets via une méthode ->cache()
,
à appeler en fin de requête.
Facade de génération de requête.¶
Dans ce cas et le suivant, les objets sont générés après avoir effectué la requête. Le résultat ici est généré en 0.26 secondes pour 16777216 bytes d'utilisation de RAM. La requête envoyée à la base :
select * from `books`
inner join `authors` on `books`.`author_id` = `authors`.`id`
where `title` like ?; -- en 100 ms
Le temps d'exécution ici dépend en grande partie de celui que prend PHP à associer nos objets.
Requête SQL Classique¶
Les résultats ici sont générés en 0.19 secondes, pour 16777216 bytes de RAM utilisée." La requête étant identique à la précédente, nous restons aux alentours des 100 ms de temps d'éxécution.
Alors quoi choisir ?¶
Entre ces trois différentes méthodes, savoir si l'on souhaite manipuler un tableau associatif ou des objets peut déjà être un critère de sélection. Un autre serait de se baser sur la volumétrie de nos données et le nombre de clés étrangères de la table principale sur laquelle s'effectue la requête. Le moteur Eloquent a été conçu de manière à optimiser nos requêtes et les traitements objets, et propose un ensemble de méthodes qui peuvent parfois s'avérer indispensables dans notre développement. Sachant tout cela, il n'y a plus qu'à faire un choix.