Skip to content

Performance durch Caching

Mit wachsender Popularität können Performance-Probleme einhergehen. Einige typische Beispiele: fehlende Datenbankindizes oder tonnenweise SQL-Abfragen pro Seite. Mit einer leeren Datenbank wirst du wirst keine Probleme haben, aber bei mehr Traffic und wachsenden Datenmengen könnten irgendwann Performance-Probleme auftreten.

HTTP-Cache-Header hinzufügen

Die Verwendung von HTTP-Caching-Strategien ist eine großartige Möglichkeit, die Leistung für Endbenutzer*innen mit geringem Aufwand zu maximieren. Füge im Produktivsystem einen Reverse-Proxy-Cache hinzu, um das Caching zu ermöglichen, und verwende ein CDN, um eine noch bessere Leistung zu erzielen.

Lass uns die Homepage für eine Stunde cachen:

1
2
3
4
5
6
7
8
9
10
11
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -29,7 +29,7 @@ class ConferenceController extends AbstractController
     {
         return $this->render('conference/index.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
-        ]);
+        ])->setSharedMaxAge(3600);
     }

     #[Route('/conference/{slug}', name: 'conference')]

Die setSharedMaxAge()-Methode konfiguriert die Cache-Dauer für Reverse-Proxies. Verwende setMaxAge(), um den Browser-Cache zu steuern. Die Zeit wird in Sekunden angegeben (1 Stunde = 60 Minuten = 3600 Sekunden).

Das Cachen der Konferenzseite ist schwieriger, da sie dynamischer ist. Jeder kann jederzeit einen Kommentar hinzufügen, und niemand will eine Stunde warten, um ihn online zu sehen. Verwende in solchen Fällen die HTTP-Validation*-Strategie.

Den Symfony HTTP Cache Kernel aktivieren

Aktiviere den Symfony HTTP-Reverse-Proxy um die HTTP-Cache-Strategie zu testen, aber nur in der "development"-Environment (Für die "production" Environment werden wir eine robustere Lösung nutzen):

1
2
3
4
5
6
7
8
9
10
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -23,3 +23,7 @@ when@test:
         test: true
         session:
             storage_factory_id: session.storage.factory.mock_file
+
+when@dev:
+    framework:
+        http_cache: true

Der Symfony HTTP-Reverse-Proxy (über die HttpCache-Klasse) ist nicht nur ein vollwertiger HTTP-Reverse-Proxy, sondern fügt auch einige nützliche Debug-Informationen als HTTP-Header hinzu. Das hilft sehr bei der Validierung der von uns eingestellten Cache-Header.

Überprüfe es auf der Homepage:

1
$ curl -s -I -X GET https://127.0.0.1:8000/
1
2
3
4
5
6
7
8
9
10
11
HTTP/2 200
age: 0
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:11:57 GMT
x-content-digest: en63cef7045fe418859d73668c2703fb1324fcc0d35b21d95369a9ed1aca48e73e
x-debug-token: 9eb25a
x-debug-token-link: https://127.0.0.1:8000/_profiler/9eb25a
x-robots-tag: noindex
x-symfony-cache: GET /: miss, store
content-length: 50978

Für den allerersten Request teilt Dir der Cache-Server mit, dass es sich um einen miss handelt und dass er store ausführte, um die Response zu cachen. Überprüfe den cache-control-Header, um die konfigurierte Cache Strategie zu sehen.

Für nachfolgende Requests ist die Response im Cache (age wurde ebenfalls aktualisiert):

1
2
3
4
5
6
7
8
9
10
11
HTTP/2 200
age: 143
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:11:57 GMT
x-content-digest: en63cef7045fe418859d73668c2703fb1324fcc0d35b21d95369a9ed1aca48e73e
x-debug-token: 9eb25a
x-debug-token-link: https://127.0.0.1:8000/_profiler/9eb25a
x-robots-tag: noindex
x-symfony-cache: GET /: fresh
content-length: 50978

SQL-Requests mit ESI vermeiden

Der TwigEventSubscriber-Listener injiziert eine globale Variable mit allen Konferenzobjekten in Twig. Dies geschieht für jede einzelne Seite der Webseite. Das ist wahrscheinlich ein großer Optimierungspunkt.

Du wirst nicht jeden Tag neue Konferenzen hinzufügen, sodass der Code immer wieder genau die gleichen Daten aus der Datenbank abfragt.

Wir möchten vielleicht die Konferenznamen und Slugs mit dem Symfony Cache zwischenspeichern. Ich verlasse mich jedoch, wann immer es möglich ist, auf die HTTP-Caching-Infrastruktur.

Wenn Du ein Fragment einer Seite zwischenspeichern möchtest, verschiebe es aus dem aktuellen HTTP-Requests, indem Du einen Sub-Request erstellst. ESI ist die perfekte Ergänzung zu diesem Anwendungsfall. Ein ESI ist eine Möglichkeit, das Ergebnis einer HTTP-Anfrage in eine andere einzubetten.

Erstelle einen Controller, der nur das HTML-Fragment zurückgibt, welches die Konferenzen anzeigt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,6 +33,14 @@ class ConferenceController extends AbstractController
         ])->setSharedMaxAge(3600);
     }

+    #[Route('/conference_header', name: 'conference_header')]
+    public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
+    {
+        return $this->render('conference/header.html.twig', [
+            'conferences' => $conferenceRepository->findAll(),
+        ]);
+    }
+
     #[Route('/conference/{slug}', name: 'conference')]
     public function show(
         Request $request,

Erstelle das entsprechende Template:

templates/conference/header.html.twig
1
2
3
4
5
<ul>
    {% for conference in conferences %}
        <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
    {% endfor %}
</ul>

Rufe /conference_header im Browser auf, um zu überprüfen, ob alles in Ordnung ist.

Zeit, den Trick zu enthüllen! Aktualisiere das Twig-Template, um den soeben erstellten Controller aufzurufen:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -16,11 +16,7 @@
     <body>
         <header>
             <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
-            <ul>
-            {% for conference in conferences %}
-                <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
-            {% endfor %}
-            </ul>
+            {{ render(path('conference_header')) }}
             <hr />
         </header>
         {% block body %}{% endblock %}

Und voilà. Aktualisiere die Seite und sie zeigt immer noch das Gleiche an.

Tip

Verwende das Symfony Profiler-Panel "Request / Response", um mehr über den Main Request und seine Sub-Requests zu erfahren.

Nun werden bei jedem Aufruf einer Seite im Browser zwei HTTP-Requests ausgeführt, einer für den Header und einer für die Hauptseite. Du hast die Performance verschlechtert. Herzlichen Glückwunsch!

Der Konferenz Header HTTP-Request wird derzeit intern von Symfony durchgeführt, so dass kein HTTP-Round-trip erforderlich ist. Dies bedeutet auch, dass es keine Möglichkeit gibt, von HTTP-Cache-Headern zu profitieren.

Konvertiere den Request in einen "echten" HTTP-Request mit Hilfe von ESI.

Aktiviere zunächst den ESI-Support:

1
2
3
4
5
6
7
8
9
10
11
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -13,7 +13,7 @@ framework:
         cookie_secure: auto
         cookie_samesite: lax

-    #esi: true
+    esi: true
     #fragments: true
     php_errors:
         log: true

Verwende render_esi anstelle von render:

1
2
3
4
5
6
7
8
9
10
11
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -14,7 +14,7 @@
     <body>
         <header>
             <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
-            {{ render(path('conference_header')) }}
+            {{ render_esi(path('conference_header')) }}
             <hr />
         </header>
         {% block body %}{% endblock %}

Wenn Symfony einen Reverse-Proxy erkennt, der mit ESIs umgehen kann, aktiviert es den ESI-Support automatisch (wenn nicht, greift es auf render zurück, um den Sub-Request synchron auszuführen).

Da der Symfony Reverse-Proxy ESIs unterstützt, sollten wir seine Logs überprüfen (zuerst den Cache entfernen – siehe "Purging" unten):

1
$ curl -s -I -X GET https://127.0.0.1:8000/
1
2
3
4
5
6
7
8
9
10
11
12
HTTP/2 200
age: 0
cache-control: must-revalidate, no-cache, private
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:20:05 GMT
expires: Mon, 28 Oct 2019 08:20:05 GMT
x-content-digest: en4dd846a34dcd757eb9fd277f43220effd28c00e4117bed41af7f85700eb07f2c
x-debug-token: 719a83
x-debug-token-link: https://127.0.0.1:8000/_profiler/719a83
x-robots-tag: noindex
x-symfony-cache: GET /: miss, store; GET /conference_header: miss
content-length: 50978

Aktualisiere die Seite einige Male: Die /-Response wird zwischengespeichert und die /conference_header-Response nicht. Wir haben etwas Großartiges erreicht: Wir haben die ganze Seite im Cache, aber ein Teil ist immer noch dynamisch.

Das ist aber nicht das, was wir wollen. Cache die Header-Seite für eine Stunde, unabhängig von allem anderen:

1
2
3
4
5
6
7
8
9
10
11
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -37,7 +37,7 @@ class ConferenceController extends AbstractController
     {
         return $this->render('conference/header.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
-        ]);
+        ])->setSharedMaxAge(3600);
     }

     #[Route('/conference/{slug}', name: 'conference')]

Der Cache ist nun für beide Requests aktiviert:

1
$ curl -s -I -X GET https://127.0.0.1:8000/
1
2
3
4
5
6
7
8
9
10
11
HTTP/2 200
age: 613
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 07:31:24 GMT
x-content-digest: en15216b0803c7851d3d07071473c9f6a3a3360c6a83ccb0e550b35d5bc484bbd2
x-debug-token: cfb0e9
x-debug-token-link: https://127.0.0.1:8000/_profiler/cfb0e9
x-robots-tag: noindex
x-symfony-cache: GET /: fresh; GET /conference_header: fresh
content-length: 50978

Der x-symfony-cache-Header enthält zwei Elemente: den Main Request / und einen Sub-Request (den conference_header ESI). Beide befinden sich im Cache (fresh).

Die Cache-Strategie kann sich von der Hauptseite und ihren ESIs unterscheiden. Wenn wir eine "Über"-Seite haben, möchten wir sie vielleicht für eine Woche im Cache speichern und trotzdem den Header jede Stunde aktualisieren lassen.

Entferne den Listener, da wir ihn nicht mehr benötigen:

1
$ rm src/EventSubscriber/TwigEventSubscriber.php

Den HTTP-Cache zum Testen leeren

Das Testen der Website in einem Browser oder durch automatisierte Tests wird mit einer Caching-Ebene etwas schwieriger.

Du kannst den gesamten HTTP-Cache manuell entfernen, indem Du das var/cache/dev/http_cache/-Verzeichnis entfernst:

1
$ rm -rf var/cache/dev/http_cache/

Diese Strategie funktioniert nicht gut, wenn Du nur einige URLs invalidieren willst oder wenn Du die Cache-Invalidierung in Deine Funktionalen Tests integrieren willst. Lass uns einen kleinen HTTP-Endpunkt nur für Administrator*innen hinzufügen, um einige URLs zu invalidieren:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
--- a/config/packages/security.yaml
+++ b/config/packages/security.yaml
@@ -20,6 +20,8 @@ security:
                 login_path: app_login
                 check_path: app_login
                 enable_csrf: true
+            http_basic: { realm: Admin Area }
+            entry_point: form_login
             logout:
                 path: app_logout
                 # where to redirect after logout
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -8,6 +8,8 @@ use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
+use Symfony\Component\HttpKernel\KernelInterface;
 use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Routing\Attribute\Route;
 use Symfony\Component\Workflow\WorkflowInterface;
@@ -47,4 +49,16 @@ class AdminController extends AbstractController
             'comment' => $comment,
         ]));
     }
+
+    #[Route('/admin/http-cache/{uri<.*>}', methods: ['PURGE'])]
+    public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri, StoreInterface $store): Response
+    {
+        if ('prod' === $kernel->getEnvironment()) {
+            return new Response('KO', 400);
+        }
+
+        $store->purge($request->getSchemeAndHttpHost().'/'.$uri);
+
+        return new Response('Done');
+    }
 }

Der neue Controller wurde auf die PURGE-HTTP-Methode beschränkt. Diese Methode ist nicht im HTTP-Standard enthalten, wird aber häufig verwendet, um Caches zu invalidieren.

Standardmäßig können Routenparameter kein /-Zeichen enthalten, da es URL-Segmente trennt. Du kannst diese Einschränkung für den letzten Routenparameter, hier beispielsweise uri, überschreiben, indem Du für ihn ein eigenes Requirement-Pattern (.*) festlegst.

Die Art und Weise, wie wir die HttpCache-Instanz erhalten, mag etwas seltsam wirken; wir verwenden eine anonyme Klasse, da der Zugriff auf die "echte" Klasse nicht möglich ist. Die HttpCache-Instanz umschließt den echten Kernel, welcher den Cache-Layer nicht kennt, was genau so sein sollte.

Invalidiere die Homepage und den Konferenz-Header mittels folgender cURL-Aufrufe:

1
2
$ curl -s -I -X PURGE -u admin:admin `symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL`admin/http-cache/
$ curl -s -I -X PURGE -u admin:admin `symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL`admin/http-cache/conference_header

Der symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL-Unterbefehl gibt die aktuelle URL des lokalen Webservers zurück.

Note

Der Controller hat keinen Routennamen, da er im Code nie referenziert werden wird.

Ähnliche Routen mit einem Präfix gruppieren

Die beiden Routen im Admin-Controller haben das gleiche /admin-Präfix. Anstatt es in allen Routen zu wiederholen, überarbeiten wir die Routen und konfigurieren das Präfix an der Klasse selbst:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -15,6 +15,7 @@ use Symfony\Component\Routing\Attribute\Route;
 use Symfony\Component\Workflow\WorkflowInterface;
 use Twig\Environment;

+#[Route('/admin')]
 class AdminController extends AbstractController
 {
     public function __construct(
@@ -24,7 +25,7 @@ class AdminController extends AbstractController
     ) {
     }

-    #[Route('/admin/comment/review/{id}', name: 'review_comment')]
+    #[Route('/comment/review/{id}', name: 'review_comment')]
     public function reviewComment(Request $request, Comment $comment, WorkflowInterface $commentStateMachine): Response
     {
         $accepted = !$request->query->get('reject');
@@ -50,7 +51,7 @@ class AdminController extends AbstractController
         ]));
     }

-    #[Route('/admin/http-cache/{uri<.*>}', methods: ['PURGE'])]
+    #[Route('/http-cache/{uri<.*>}', methods: ['PURGE'])]
     public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri, StoreInterface $store): Response
     {
         if ('prod' === $kernel->getEnvironment()) {

CPU/Speicher-intensive Operationen cachen

Wir haben keine CPU- oder speicherintensiven Algorithmen auf der Website. Um über lokale Caches zu sprechen, erstellen wir einen Befehl, der den aktuellen Schritt anzeigt, an dem wir gerade arbeiten (genauer gesagt, den Git-Tag-Namen, der an den aktuellen Git-Commit angehängt ist).

Die Symfony Process-Komponente ermöglicht es Dir, einen Befehl auszuführen und das Ergebnis zurückzubekommen (Standard- und Fehlerausgabe).

Implementiere den Befehl:

src/Command/StepInfoCommand.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;

#[AsCommand('app:step:info')]
class StepInfoCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
        $process->mustRun();
        $output->write($process->getOutput());

        return Command::SUCCESS;
    }
}

Note

Du hättest make:command nutzen können, um den Befehl zu erstellen:

1
$ symfony console make:command app:step:info

Was, wenn wir die Ausgabe für ein paar Minuten cachen wollen? Verwende den Symfony Cache.

Und umschließe den Code mit der Cache-Logik:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
--- a/src/Command/StepInfoCommand.php
+++ b/src/Command/StepInfoCommand.php
@@ -7,15 +7,27 @@ use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Process\Process;
+use Symfony\Contracts\Cache\CacheInterface;

 #[AsCommand('app:step:info')]
 class StepInfoCommand extends Command
 {
+    public function __construct(
+         private CacheInterface $cache,
+    ) {
+        parent::__construct();
+    }
+
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
-        $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
-        $process->mustRun();
-        $output->write($process->getOutput());
+        $step = $this->cache->get('app.current_step', function ($item) {
+            $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
+            $process->mustRun();
+            $item->expiresAfter(30);
+
+            return $process->getOutput();
+        });
+        $output->writeln($step);

         return Command::SUCCESS;
     }

Der Prozess wird nun nur noch aufgerufen, wenn sich das app.current_step-Element nicht im Cache befindet.

Profilerstellung und Performance-Vergleiche

Füge niemals blindlings Caching hinzu. Behalte im Hinterkopf, dass das Hinzufügen eines Caches mehr Komplexität bedeutet. Und da wir alle sehr schlecht darin sind, zu erraten, was schnell und was langsam ist, könntest Du in eine Situation geraten, in welcher der Cache Deine Anwendung langsamer macht.

Messe immer die Auswirkungen des Hinzufügens eines Cache mit einem Profiler-Tool wie Blackfire.

Gehe zum Schritt über "Performance", um mehr darüber zu erfahren, wie Du Blackfire verwenden kannst, um Deinen Code vor dem Deployment zu testen.

Einen Reverse Proxy-Cache auf dem Produktivsystem konfigurieren

Anstelle des Symfony-Reverse-Proxy, nutzen wir den mehr "robusten" Varnish-Reverse-Proxy auf dem Produktivsystem.

Füge Varnish zu den Platform.sh-Diensten hinzu:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
--- a/.platform/services.yaml
+++ b/.platform/services.yaml
@@ -4,3 +4,11 @@ database:
     disk: 1024


+varnish:
+    type: varnish:6.0
+    relationships:
+        application: 'app:http'
+    configuration:
+        vcl: !include
+            type: string
+            path: config.vcl

Verwende Varnish als Main-Entry-Point in den Routen:

1
2
3
4
5
6
--- a/.platform/routes.yaml
+++ b/.platform/routes.yaml
@@ -1,2 +1,2 @@
-"https://{all}/": { type: upstream, upstream: "app:http" }
+"https://{all}/": { type: upstream, upstream: "varnish:http", cache: { enabled: false } }
 "http://{all}/": { type: redirect, to: "https://{all}/" }

Erstelle zum Abschluss eine config.vcl-Datei zur Konfiguration von Varnish:

.platform/config.vcl
1
2
3
sub vcl_recv {
    set req.backend_hint = application.backend();
}

ESI-Unterstützung für Varnish aktivieren

Die ESI-Unterstützung für Varnish sollte für jeden Request explizit aktiviert werden. Um es universell zu machen verwendet Symfony die standardisierten Surrogate-Capability- und Surrogate-Control-Header, um die ESI-Unterstützung auszuhandeln:

.platform/config.vcl
1
2
3
4
5
6
7
8
9
10
11
sub vcl_recv {
    set req.backend_hint = application.backend();
    set req.http.Surrogate-Capability = "abc=ESI/1.0";
}

sub vcl_backend_response {
    if (beresp.http.Surrogate-Control ~ "ESI/1.0") {
        unset beresp.http.Surrogate-Control;
        set beresp.do_esi = true;
    }
}

Varnish Cache leeren

Die Invalidierung des Caches auf dem Produktivsystem sollte wahrscheinlich nie erforderlich sein, außer für Notfallzwecke und vielleicht auf einem Nicht-master-Branch. Wenn Du den Cache häufig leeren musst, bedeutet dies wahrscheinlich, dass die Caching-Strategie optimiert werden sollte (durch Senkung der TTL oder durch Verwendung einer Validierungsstrategie anstelle einer Ablaufstrategie).

Wie auch immer, lasse uns sehen, wie man Varnish für die Invalidierung des Caches konfiguriert:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
--- a/.platform/config.vcl
+++ b/.platform/config.vcl
@@ -1,6 +1,13 @@
 sub vcl_recv {
     set req.backend_hint = application.backend();
     set req.http.Surrogate-Capability = "abc=ESI/1.0";
+
+    if (req.method == "PURGE") {
+        if (req.http.x-purge-token != "PURGE_NOW") {
+            return(synth(405));
+        }
+        return (purge);
+    }
 }

 sub vcl_backend_response {

Im wirklichen Leben würdest du das wahrscheinlich auf bestimmte IPs einschränken, wie in der Varnish Dokumentation beschrieben.

Purge jetzt einige URLs:

1
2
$ curl -X PURGE -H 'x-purge-token: PURGE_NOW' `symfony cloud:env:url --pipe --primary`
$ curl -X PURGE -H 'x-purge-token: PURGE_NOW' `symfony cloud:env:url --pipe --primary`conference_header

Die URLs sehen etwas seltsam aus, da die von env:url zurückgegebenen URLs bereits mit / enden.

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.
TOC
    Version