Im WordPress-Ökosystem ist das Freemium-Modell eine gängige Methode, um kommerzielle Plugins zu bewerben und zu monetarisieren. Bei diesem Ansatz wird eine Basisversion des Plugins kostenlos veröffentlicht – in der Regel über das WordPress-Plugin-Verzeichnis – underweiterte Funktionen über eine PRO-Version oder Add-ons angeboten, die in der Regel auf der Website des Plugins verkauft werden.
Es gibt drei verschiedene Möglichkeiten, kommerzielle Funktionen in ein Freemium-Modell zu integrieren:
- Binde die kommerziellen Funktionen in das kostenlose Plugin ein und aktiviere sie nur, wenn die kommerzielle Version auf der Website installiert ist oder ein kommerzieller Lizenzschlüssel bereitgestellt wird.
- Erstelle die kostenlose und die PRO-Version als unabhängige Plugins, wobei die PRO-Version die kostenlose Version ersetzen soll, um sicherzustellen, dass immer nur eine Version installiert ist.
- Installiere die PRO-Version neben dem kostenlosen Plugin und erweitere dessen Funktionen. Dazu müssen beide Versionen vorhanden sein.
Der erste Ansatz ist jedoch unvereinbar mit den Richtlinien für Plugins, die über das WordPress-Plugin-Verzeichnis vertrieben werden, da diese Regeln die Aufnahme von Funktionen verbieten, die eingeschränkt oder gesperrt sind, bis eine Zahlung oder ein Upgrade erfolgt ist.
Damit bleiben uns die letzten beiden Optionen, die Vor- und Nachteile bieten. In den folgenden Abschnitten wird erklärt, warum die letzte Strategie, „PRO zusätzlich zu kostenlos“, unsere beste Wahl ist.
Die zweite Option, „PRO als Ersatz für kostenlos“, hat ihre Mängel und ist nicht zu empfehlen.
Anschließend gehen wir näher auf die Strategie „PRO zusätzlich zu kostenlos“ ein und zeigen auf, warum sie die beste Wahl ist.
Vorteile der Strategie „PRO als Ersatz für kostenlos“
Die Strategie „PRO als Ersatz für kostenlos“ ist relativ einfach umzusetzen, da die Entwickler/innen eine einzige Codebasis für beide Plugins (Free und PRO) verwenden und daraus zwei Ausgaben erstellen können, wobei die Free-Version (oder „Standard“-Version) einfach eine Teilmenge des Codes enthält und die PRO-Version den gesamten Code umfasst.
Zum Beispiel könnte die Codebasis des Projekts in die Verzeichnisse standard/
und pro/
aufgeteilt werden. Das Plugin würde immer den Standardcode laden, während der PRO-Code abhängig vom Vorhandensein des jeweiligen Verzeichnisses geladen wird:
// Main plugin file: myplugin.php
// Always load the standard plugin's code
require_once __DIR__ . '/standard/load.php';
// Load the PRO plugin's code only if the folder exists
$proFolder = __DIR__ . '/pro';
if (file_exists($proFolder)) {
require_once $proFolder . '/load.php';
}
Wenn wir das Plugin dann mit einem Continuous Integration Tool erstellen, können wir die beiden Assets myplugin-standard.zip
und myplugin-pro.zip
aus demselben Quellcode erzeugen.
Wenn das Projekt auf GitHub gehostet wird und die Assets über GitHub Actions erstellt werden, funktioniert der folgende Workflow:
name: Generate the standard and PRO plugins
on:
release:
types: [published]
jobs:
process:
name: Generate plugins
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install zip
uses: montudor/[email protected]
- name: Create the standard plugin .zip file (excluding all PRO code)
run: zip -X -r myplugin-standard.zip . -x **/src/\pro/\*
- name: Create the PRO plugin .zip file
run: zip -X -r myplugin-pro.zip . -x myplugin-standard.zip
- name: Upload both plugins to the release page
uses: softprops/action-gh-release@v1
with:
files: |
myplugin-standard.zip
myplugin-pro.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Probleme mit der Strategie „PRO als Ersatz für kostenlos“
Die Strategie „PRO als Ersatz für kostenlose Plugin“ verlangt, dass das kostenlose Plugin durch die PRO-Version ersetzt wird. Wenn das kostenlose Plugin über das WordPress-Plugin-Verzeichnis verbreitet wird, sinkt die Anzahl der „aktiven Installationen“ (da nur das kostenlose Plugin, nicht aber die PRO-Version erfasst wird), was den Eindruck erweckt, dass das Plugin weniger beliebt ist als es tatsächlich ist.
Dies würde den Zweck des WordPress-Plugin-Verzeichnisses zunichte machen: Als Plugin-Entdeckungskanal, über den Nutzer/innen unser Plugin kennenlernen, herunterladen und installieren können. (Nach der Installation kann das kostenlose Plugin dann zum Upgrade auf die PRO-Version einladen).
Wenn die Anzahl der aktiven Installationen nicht hoch ist, kann es sein, dass Nutzer/innen nicht überzeugt werden können, unser Plugin zu installieren. Ein Beispiel dafür, wie es schief gehen kann, ist das Plugin „Newsletter Glue“, das aus dem WordPress-Plugin-Verzeichnis entfernt wurde, weil die niedrige Aktivierungszahl die Erfolgsaussichten des Plugins beeinträchtigte.
Da die Strategie „PRO als Ersatz für kostenlos“ nicht praktikabel ist, bleibt uns nur eine Möglichkeit: die Strategie „PRO zusätzlich zu kostenlos“.
Schauen wir uns die Vor- und Nachteile dieser Strategie an.
Das Konzept der „PRO zusätzlich zu kostenlos“-Strategie
Die Idee ist, dass das kostenlose Plugin auf der Website installiert wird und seine Funktionalität durch die Installation von zusätzlichen Plugins oder Addons erweitert werden kann. Dies kann über ein einzelnes PRO-Plugin oder über eine Sammlung von PRO-Erweiterungen oder Addons geschehen, wobei jede von ihnen eine bestimmte Funktionalität bietet.
In diesem Fall ist es für das kostenlose Plugin unerheblich, welche anderen Plugins auf der Website installiert sind. Es stellt lediglich zusätzliche Funktionen zur Verfügung. Dieses Modell ist vielseitig und ermöglicht Erweiterungen sowohl durch die ursprünglichen Entwickler als auch durch Drittanbieter.
Bitte beachte, dass es keine Rolle spielt, ob die PRO-Erweiterungen von uns (d. h. von denselben Entwicklern, die auch das Standard-Plugin erstellen) oder von jemand anderem entwickelt werden: Der Code für beide Varianten ist derselbe. Daher ist es eine gute Idee, eine Grundlage zu schaffen, die die Erweiterungsmöglichkeiten für das Plugin nicht einschränkt. Das ermöglicht es Entwicklern von Drittanbietern, unser Plugin auf eine Weise zu erweitern, an die wir nicht gedacht haben.
Design-Ansätze: Hooks und Service-Container
Es gibt zwei Hauptansätze, um PHP-Code erweiterbar zu machen:
- Über die WordPress-Action- und Filter-Hooks
- Über einen Service-Container
Der erste Ansatz ist unter WordPress-Entwicklern am weitesten verbreitet, während der zweite Ansatz von der breiteren PHP-Gemeinde bevorzugt wird.
Sehen wir uns Beispiele für beide an.
Code über Action- und Filter-Hooks erweiterbar machen
WordPress bietet Hooks (Filter und Aktionen) als Mechanismus zur Verhaltensänderung. Filter-Hooks werden verwendet, um Werte außer Kraft zu setzen, und Action-Hooks, um benutzerdefinierte Funktionen auszuführen.
Unser Haupt-Plugin kann dann in seiner gesamten Codebasis mit Hooks „übersät“ werden, damit Entwickler sein Verhalten ändern können.
Ein gutes Beispiel dafür ist WooCommerce, das ein riesiges Ökosystem von Add-ons umfasst, von denen die meisten von Drittanbietern stammen. Dies ist dank der zahlreichen Hooks möglich, die dieses Plugin bietet.
Die Entwickler von WooCommerce haben absichtlich Hooks hinzugefügt, obwohl sie sie selbst nicht brauchen. Jemand anderes soll sie nutzen. Beachte die große Anzahl von „Vorher“- und „Nachher“-Action-Hooks:
woocommerce_after_account_downloads
woocommerce_after_account_navigation
woocommerce_after_account_orders
woocommerce_after_account_payment_methods
woocommerce_after_available_downloads
woocommerce_after_cart
woocommerce_after_cart_contents
woocommerce_after_cart_item_name
woocommerce_after_cart_table
woocommerce_after_cart_totals
- …
woocommerce_before_account_downloads
woocommerce_before_account_navigation
woocommerce_before_account_orders
woocommerce_before_account_orders_pagination
woocommerce_before_account_payment_methods
woocommerce_before_available_downloads
woocommerce_before_cart
woocommerce_before_cart_collaterals
woocommerce_before_cart_contents
woocommerce_before_cart_table
woocommerce_before_cart_totals
- …
Zum Beispiel enthält die Datei downloads.php
enthält mehrere Aktionen, um zusätzliche Funktionen einzubauen, und die Shop-URL kann über einen Filter überschrieben werden:
<?php
$downloads = WC()->customer->get_downloadable_products();
$has_downloads = (bool) $downloads;
do_action( 'woocommerce_before_account_downloads', $has_downloads ); ?>
<?php if ( $has_downloads ) : ?>
<?php do_action( 'woocommerce_before_available_downloads' ); ?>
<?php do_action( 'woocommerce_available_downloads', $downloads ); ?>
<?php do_action( 'woocommerce_after_available_downloads' ); ?>
<?php else : ?>
<?php
$wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : '';
wc_print_notice( esc_html__( 'No downloads available yet.', 'woocommerce' ) . ' <a class="button wc-forward' . esc_attr( $wp_button_class ) . '" href="' . esc_url( apply_filters( 'woocommerce_return_to_shop_redirect', wc_get_page_permalink( 'shop' ) ) ) . '">' . esc_html__( 'Browse products', 'woocommerce' ) . '</a>', 'notice' );
?>
<?php endif; ?>
<?php do_action( 'woocommerce_after_account_downloads', $has_downloads ); ?>
Code über Service-Container erweiterbar machen
Ein Service-Container ist ein PHP-Objekt, das uns hilft, die Instanziierung aller Klassen im Projekt zu verwalten, und das üblicherweise als Teil einer „Dependency Injection“-Bibliothek angeboten wird.
Dependency Injection ist eine Strategie, die es ermöglicht, alle Teile der Anwendung dezentral zusammenzukleben: PHP-Klassen werden über die Konfiguration in die Anwendung injiziert, und die Anwendung ruft Instanzen dieser PHP-Klassen über den Service-Container ab.
Es gibt eine Vielzahl von Bibliotheken für Dependency Injection. Die folgenden sind beliebt und austauschbar, da sie alle die PSR-11 (PHP-Standardempfehlung) erfüllen, die Container für Dependency Injection beschreibt:
Laravel enthält auch einen Service-Container, der bereits in die Anwendung integriert ist.
Durch Dependency Injection muss das kostenlose Plugin nicht im Voraus wissen, welche PHP-Klassen zur Laufzeit vorhanden sind: Es fordert einfach Instanzen aller Klassen beim Service-Container an. Viele PHP-Klassen werden vom kostenlosen Plugin selbst bereitgestellt, um seine Funktionen zu erfüllen, andere werden von den Addons bereitgestellt, die auf der Website installiert sind, um die Funktionen zu erweitern.
Ein gutes Beispiel für die Verwendung eines Service-Containers ist Gato GraphQL, das auf die DependencyInjection-Bibliothek von Symfony zurückgreift.
So wird der Service-Container instanziiert:
<?php
declare(strict_types=1);
namespace GatoGraphQL\Container;
use Symfony\Component\Config\ConfigCache;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Dumper\PhpDumper;
trait ContainerBuilderFactoryTrait
{
protected ContainerInterface $instance;
protected bool $cacheContainerConfiguration;
protected bool $cached;
protected string $cacheFile;
/**
* Initialize the Container Builder.
* If the directory is not provided, store the
* cache in a system temp dir
*/
public function init(
bool $cacheContainerConfiguration,
string $namespace,
string $directory
): void {
$this->cacheContainerConfiguration = $cacheContainerConfiguration;
if ($this->cacheContainerConfiguration) {
if (!$directory) {
$directory = sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'container-cache';
}
$directory .= \DIRECTORY_SEPARATOR . $namespace;
if (!is_dir($directory)) {
@mkdir($directory, 0777, true);
}
// Store the cache under this file
$this->cacheFile = $directory . 'container.php';
$containerConfigCache = new ConfigCache($this->cacheFile, false);
$this->cached = $containerConfigCache->isFresh();
} else {
$this->cached = false;
}
// If not cached, then create the new instance
if (!$this->cached) {
$this->instance = new ContainerBuilder();
} else {
require_once $this->cacheFile;
/** @var class-string<ContainerBuilder> */
$containerFullyQuantifiedClass = "\\GatoGraphQL\\ServiceContainer";
$this->instance = new $containerFullyQuantifiedClass();
}
}
public function getInstance(): ContainerInterface
{
return $this->instance;
}
/**
* If the container is not cached, then compile it and cache it
*
* @param CompilerPassInterface[] $compilerPasses Compiler Pass objects to register on the container
*/
public function maybeCompileAndCacheContainer(
array $compilerPasses = []
): void {
/**
* Compile Symfony's DependencyInjection Container Builder.
*
* After compiling, cache it in disk for performance.
*
* This happens only the first time the site is accessed
* on the current server.
*/
if ($this->cached) {
return;
}
/** @var ContainerBuilder */
$containerBuilder = $this->getInstance();
foreach ($compilerPasses as $compilerPass) {
$containerBuilder->addCompilerPass($compilerPass);
}
// Compile the container.
$containerBuilder->compile();
// Cache the container
if (!$this->cacheContainerConfiguration) {
return;
}
// Create the folder if it doesn't exist, and check it was successful
$dir = dirname($this->cacheFile);
$folderExists = file_exists($dir);
if (!$folderExists) {
$folderExists = @mkdir($dir, 0777, true);
if (!$folderExists) {
return;
}
}
// Save the container to disk
$dumper = new PhpDumper($containerBuilder);
file_put_contents(
$this->cacheFile,
$dumper->dump(
[
'class' => 'ServiceContainer',
'namespace' => 'GatoGraphQL',
]
)
);
// Change the permissions so it can be modified by external processes
chmod($this->cacheFile, 0777);
}
}
Bitte beachte, dass der Service-Container (erreichbar unter PHP-Objekt mit der Klasse GatoGraphQLServiceContainer
) bei der ersten Ausführung des Plugins erzeugt und dann auf der Festplatte zwischengespeichert wird (als Datei container.php
in einem System-Temp-Ordner). Das liegt daran, dass die Erstellung des Service-Containers ein kostspieliger Prozess ist, der unter Umständen mehrere Sekunden dauern kann.
Anschließend legen sowohl das Haupt-Plugin als auch alle seine Erweiterungen über eine Konfigurationsdatei fest, welche Dienste in den Container integriert werden sollen:
services:
_defaults:
public: true
autowire: true
autoconfigure: true
GatoGraphQL\GatoGraphQL\Registries\ModuleTypeRegistryInterface:
class: \GatoGraphQL\GatoGraphQL\Registries\ModuleTypeRegistry
GatoGraphQL\GatoGraphQL\Log\LoggerInterface:
class: \GatoGraphQL\GatoGraphQL\Log\Logger
GatoGraphQL\GatoGraphQL\Services\:
resource: ../src/Services/*
GatoGraphQL\GatoGraphQL\State\:
resource: '../src/State/*'
Wir können Objekte für bestimmte Klassen instanziieren (z. B. GatoGraphQL\GatoGraphQL\Log\Logger
, auf die über die Vertragsschnittstelle GatoGraphQL\GatoGraphQL\Log\LoggerInterface
zugegriffen wird), und wir können auch angeben, dass alle Klassen unter einem bestimmten Verzeichnis instanziiert werden sollen (z. B. alle Dienste unter ../src/Services
).
Schließlich injizieren wir die Konfiguration in den Dienstcontainer:
<?php
declare(strict_types=1);
namespace PoP\Root\Module;
use PoP\Root\App;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
trait InitializeContainerServicesInModuleTrait
{
// Initialize the services defined in the YAML configuration file.
public function initServices(
string $dir,
string $serviceContainerConfigFileName
): void {
// First check if the container has been cached. If so, do nothing
if (App::getContainerBuilderFactory()->isCached()) {
return;
}
// Initialize the ContainerBuilder with this module's service implementations
/** @var ContainerBuilder */
$containerBuilder = App::getContainer();
$loader = new YamlFileLoader($containerBuilder, new FileLocator($dir));
$loader->load($serviceContainerConfigFileName);
}
}
Die in den Container injizierten Dienste können so konfiguriert werden, dass sie immer oder nur bei Bedarf initialisiert werden (Lazy Mode).
Um zum Beispiel einen benutzerdefinierten Beitragstyp zu repräsentieren, hat das Plugin die Klasse AbstractCustomPostType
, deren Methode initialize
die Logik zur Initialisierung gemäß WordPress ausführt:
<?php
declare(strict_types=1);
namespace GatoGraphQL\GatoGraphQL\Services\CustomPostTypes;
use GatoGraphQL\GatoGraphQL\Services\Taxonomies\TaxonomyInterface;
use PoP\Root\Services\AbstractAutomaticallyInstantiatedService;
abstract class AbstractCustomPostType extends AbstractAutomaticallyInstantiatedService implements CustomPostTypeInterface
{
public function initialize(): void
{
\add_action(
'init',
$this->initCustomPostType(...)
);
}
/**
* Register the post type
*/
public function initCustomPostType(): void
{
\register_post_type($this->getCustomPostType(), $this->getCustomPostTypeArgs());
}
abstract public function getCustomPostType(): string;
/**
* Arguments for registering the post type
*
* @return array<string,mixed>
*/
protected function getCustomPostTypeArgs(): array
{
/** @var array<string,mixed> */
$postTypeArgs = [
'public' => $this->isPublic(),
'publicly_queryable' => $this->isPubliclyQueryable(),
'label' => $this->getCustomPostTypeName(),
'labels' => $this->getCustomPostTypeLabels($this->getCustomPostTypeName(), $this->getCustomPostTypePluralNames(true), $this->getCustomPostTypePluralNames(false)),
'capability_type' => 'post',
'hierarchical' => $this->isAPIHierarchyModuleEnabled() && $this->isHierarchical(),
'exclude_from_search' => true,
'show_in_admin_bar' => $this->showInAdminBar(),
'show_in_nav_menus' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_rest' => true,
];
return $postTypeArgs;
}
/**
* Labels for registering the post type
*
* @param string $name_uc Singular name uppercase
* @param string $names_uc Plural name uppercase
* @param string $names_lc Plural name lowercase
* @return array<string,string>
*/
protected function getCustomPostTypeLabels(string $name_uc, string $names_uc, string $names_lc): array
{
return array(
'name' => $names_uc,
'singular_name' => $name_uc,
'add_new' => sprintf(\__('Add New %s', 'gatographql'), $name_uc),
'add_new_item' => sprintf(\__('Add New %s', 'gatographql'), $name_uc),
'edit_item' => sprintf(\__('Edit %s', 'gatographql'), $name_uc),
'new_item' => sprintf(\__('New %s', 'gatographql'), $name_uc),
'all_items' => $names_uc,//sprintf(\__('All %s', 'gatographql'), $names_uc),
'view_item' => sprintf(\__('View %s', 'gatographql'), $name_uc),
'search_items' => sprintf(\__('Search %s', 'gatographql'), $names_uc),
'not_found' => sprintf(\__('No %s found', 'gatographql'), $names_lc),
'not_found_in_trash' => sprintf(\__('No %s found in Trash', 'gatographql'), $names_lc),
'parent_item_colon' => sprintf(\__('Parent %s:', 'gatographql'), $name_uc),
);
}
}
Dann ist die Klasse GraphQLCustomEndpointCustomPostType.php
eine Implementierung eines benutzerdefinierten Beitragstyps. Nachdem sie als Dienst in den Container injiziert wurde, wird sie instanziiert und in WordPress registriert:
<?php
declare(strict_types=1);
namespace GatoGraphQL\GatoGraphQL\Services\CustomPostTypes;
class GraphQLCustomEndpointCustomPostType extends AbstractCustomPostType
{
public function getCustomPostType(): string
{
return 'graphql-endpoint';
}
protected function getCustomPostTypeName(): string
{
return \__('GraphQL custom endpoint', 'gatographql');
}
}
Diese Klasse ist im kostenlosen Plugin enthalten und andere benutzerdefinierte Post-Type-Klassen, die ebenfalls auf AbstractCustomPostType
basieren, werden von PRO-Erweiterungen bereitgestellt.
Vergleich von Hooks und Service-Containern
Vergleichen wir die beiden Designansätze.
Der Vorteil von Action- und Filter-Hooks ist, dass sie die einfachere Methode sind, da ihre Funktionalität Teil des WordPress-Kerns ist. Und jeder Entwickler, der mit WordPress arbeitet, weiß bereits, wie man mit Hooks umgeht, sodass die Lernkurve niedrig ist.
Allerdings ist die Logik an einen Hook-Namen gebunden, der eine Zeichenkette ist, und kann daher zu Fehlern führen: Wenn der Hook-Name geändert wird, bricht die Logik der Erweiterung. Der Entwickler bemerkt das Problem jedoch möglicherweise nicht, weil der PHP-Code weiterhin kompiliert wird.
Daher werden veraltete Hooks oft sehr lange in der Codebasis aufbewahrt, vielleicht sogar für immer. Im Projekt sammelt sich dann veralteter Code an, der nicht entfernt werden kann, aus Angst, die Erweiterungen zu zerstören.
Zurück zu WooCommerce: Diese Situation wird in der Datei dashboard.php
belegt (beachte, dass die veralteten Hooks seit der Version 2.6
beibehalten werden, während die aktuellste Version 8.5
ist):
<?php
/**
* My Account dashboard.
*
* @since 2.6.0
*/
do_action( 'woocommerce_account_dashboard' );
/**
* Deprecated woocommerce_before_my_account action.
*
* @deprecated 2.6.0
*/
do_action( 'woocommerce_before_my_account' );
/**
* Deprecated woocommerce_after_my_account action.
*
* @deprecated 2.6.0
*/
do_action( 'woocommerce_after_my_account' );
Die Verwendung eines Service-Containers hat den Nachteil, dass er eine externe Bibliothek benötigt, was die Komplexität weiter erhöht. Außerdem muss diese Bibliothek gescannt werden (mit PHP-Scoper oder Strauss), da zu befürchten ist, dass eine andere Version derselben Bibliothek von einem anderen Plugin auf derselben Website installiert wird, was zu Konflikten führen könnte.
Die Verwendung eines Service-Containers ist zweifelsohne schwieriger zu implementieren und erfordert mehr Entwicklungszeit.
Der Vorteil ist, dass Service-Container mit PHP-Klassen arbeiten, ohne dass die Logik an einen String gekoppelt werden muss. Das führt dazu, dass im Projekt mehr PHP-Best-Practices verwendet werden, was langfristig zu einer leichter zu wartenden Codebasis führt.
Zusammenfassung
Wenn du ein Plugin für WordPress erstellst, ist es eine gute Idee, dass es Erweiterungen unterstützt, damit wir (die Ersteller des Plugins) kommerzielle Funktionen anbieten können und auch jeder andere zusätzliche Funktionen hinzufügen kann und so hoffentlich ein Ökosystem rund um das Plugin entsteht.
In diesem Artikel haben wir untersucht, welche Überlegungen es bezüglich der Architektur des PHP-Projekts gibt, um das Plugin erweiterbar zu machen. Wir haben gelernt, dass wir zwischen zwei Designansätzen wählen können: der Verwendung von Hooks oder einem Service-Container. Wir haben beide Ansätze miteinander verglichen und dabei die Vorzüge und Schwächen beider Ansätze herausgearbeitet.
Hast du vor, dein WordPress-Plugin erweiterbar zu machen? Lass es uns in den Kommentaren wissen.
Schreibe einen Kommentar