System pluginów w Koseven – architektura, lifecycle i rozszerzalność
Wstęp
Koseven, jako fork Kohany, zachował prostą, plikową architekturę, opartą na konwencjach PSR-0 i autoloadzie klas z katalogu classes/.
To sprawia, że framework idealnie nadaje się do budowy systemu pluginów, który:
- nie wymaga bazy danych
- nie korzysta z composera
- nie używa namespaces
- jest zgodny z filozofią frameworka
W tym artykule opisano praktyczną implementację systemu pluginów w Koseven, inspirowaną WordPressem, ale zaprojektowaną w sposób jawny, testowalny i bez „magii”.
Założenia architektoniczne
Projekt systemu pluginów opiera się na kilku kluczowych założeniach:
- Plugin ≠ moduł
- moduły rozszerzają core
- pluginy rozszerzają zachowanie aplikacji
- Brak bazy danych
- stan pluginów przechowywany w pliku PHP (
storage/plugins.php)
- stan pluginów przechowywany w pliku PHP (
- PSR-0 i klasy z podkreśleniem
- pełna kompatybilność z autoloadem Koseven
- Jawny lifecycle
activate()boot()deactivate()
- Rozdzielenie Eventów i Hooków
- Event → domena biznesowa
- Hook → rozszerzalność
Struktura katalogów
/application
/classes
Event.php
Hook.php
Plugin_Manager.php
Plugin_Interface.php
/storage
plugins.php
/plugins
/article_stats
plugin.php
/classes
Article/
Stats/
Plugin.php
Stan pluginów
Stan aktywności pluginów przechowywany jest w jednym pliku:
application/storage/plugins.php
<?php defined('SYSPATH') or die('No direct script access.');
return [
'article_stats' => true,
];
- klucz = slug pluginu
true= włączonyfalselub brak = wyłączony
Dzięki temu:
- system działa bez DB
- łatwo debugować
- ręczne usunięcie pluginu nie powoduje błędów
Metadane pluginu
Każdy plugin posiada plik opisowy:
plugins/article_stats/plugin.php
<?php defined('SYSPATH') or die('No direct script access.');
return [
'name' => 'Article Statistics',
'description' => 'Zlicza wyświetlenia artykułów',
'version' => '1.0.0',
'author' => 'ACME',
];
Plik ten:
- nie jest wykonywany
- służy wyłącznie do wyświetlania informacji w panelu admina
Interfejs pluginu
Każdy plugin musi implementować wspólny interfejs:
<?php defined('SYSPATH') or die('No direct script access.');
interface Plugin_Interface
{
public function activate();
public function boot();
public function deactivate();
}
Dzięki temu PluginManager ma gwarancję spójnego API.
Eventy – system zdarzeń
System eventów umożliwia pluginom:
- logowanie zdarzeń
- realizację systemów powiadomień
Klasa Event
<?php defined('SYSPATH') or die('No direct script access.');
class Event
{
protected static $listeners = [];
public static function listen($event, callable $callback)
{
static::$listeners[$event][] = $callback;
}
public static function dispatch($event, array $payload = [])
{
if (empty(static::$listeners[$event]))
{
return;
}
foreach (static::$listeners[$event] as $listener)
{
call_user_func_array($listener, $payload);
}
}
}
Hooki – system rozszerzeń
System hooków umożliwia pluginom:
- reagowanie na zdarzenia aplikacji (actions)
- modyfikowanie danych (filters)
Klasa Hook
<?php defined('SYSPATH') or die('No direct script access.');
class Hook
{
protected static $actions = [];
protected static $filters = [];
/* =======================
* ACTIONS
* ======================= */
public static function add_action($name, callable $callback)
{
static::$actions[$name][] = $callback;
}
public static function do_action($name, array $payload = [])
{
if (empty(static::$actions[$name]))
{
return;
}
foreach (static::$actions[$name] as $callback)
{
call_user_func_array($callback, $payload);
}
}
/* =======================
* FILTERS
* ======================= */
public static function add_filter($name, callable $callback)
{
static::$filters[$name][] = $callback;
}
public static function apply_filters($name, $value)
{
if (empty(static::$filters[$name]))
{
return $value;
}
foreach (static::$filters[$name] as $callback)
{
$value = call_user_func_array($callback, [$value]);
}
return $value;
}
}
Event vs Hook – rozdzielenie odpowiedzialności
| Event | Hook |
|---|---|
| domena biznesowa | rozszerzalność |
| informacyjny | ingerujący |
| brak zwrotki | pipeline wartości |
| deterministyczny | opcjonalny |
Przykład:
article.viewed→ Eventarticle.title→ Hook
PluginManager – serce systemu
PluginManager odpowiada za:
- ładowanie pluginów
- aktywację i dezaktywację
- mapowanie slug → klasa → plik
Mapowanie PSR-0
slug: article_stats
class: Article_Stats_Plugin
file: plugins/article_stats/classes/Article/Stats/Plugin.php
Kluczowy fragment _boot_plugin()
$base = implode('_', array_map('ucfirst', explode('_', $slug)));
$class = $base.'_Plugin';
$file = $classes_path.DIRECTORY_SEPARATOR
.str_replace('_', DIRECTORY_SEPARATOR, $base)
.DIRECTORY_SEPARATOR.'Plugin.php';
Cała klasa plugin managera
<?php defined('SYSPATH') or die('No direct script access.');
class Plugin_Manager
{
protected $_state_file;
protected $_enabled;
protected $_instances = [];
public function __construct()
{
$this->_state_file = APPPATH.'storage/plugins.php';
$this->_enabled = $this->_load_state();
}
public function load()
{
foreach ($this->_enabled as $slug => $enabled)
{
if ($enabled === true)
{
$this->_boot_plugin($slug);
}
}
}
public function activate($slug)
{
$plugins = $this->_load_state();
if ( ! empty($plugins[$slug]))
{
return;
}
$plugin = $this->_boot_plugin($slug);
$plugin->activate();
$plugins[$slug] = true;
$this->_save_state($plugins);
}
public function deactivate($slug)
{
$plugins = $this->_load_state();
if (empty($plugins[$slug]))
{
return;
}
$plugin = $this->_boot_plugin($slug);
$plugin->deactivate();
$plugins[$slug] = false;
$this->_save_state($plugins);
}
public function list_plugins()
{
$state = $this->_enabled;
$plugins = [];
$root = DOCROOT.'plugins'.DIRECTORY_SEPARATOR;
foreach (glob($root.'*', GLOB_ONLYDIR) as $dir)
{
$slug = basename($dir);
$info_file = $dir.DIRECTORY_SEPARATOR.'plugin.php';
if ( ! is_file($info_file))
{
continue;
}
$info = include $info_file;
$plugins[$slug] = [
'slug' => $slug,
'name' => Arr::get($info, 'name', $slug),
'description' => Arr::get($info, 'description', ''),
'version' => Arr::get($info, 'version', ''),
'author' => Arr::get($info, 'author', ''),
'enabled' => ! empty($state[$slug]),
];
}
return $plugins;
}
protected function _boot_plugin($slug)
{
if (isset($this->_instances[$slug]))
{
return $this->_instances[$slug];
}
$plugin_root = DOCROOT.'plugins'.DIRECTORY_SEPARATOR.$slug;
$classes_path = $plugin_root.DIRECTORY_SEPARATOR.'classes';
if ( ! is_dir($classes_path))
{
throw new Kohana_Exception('Plugin :slug not found', [
':slug' => $slug,
]);
}
/**
* article_stats → Article_Stats
*/
$base_name = implode('_', array_map('ucfirst', explode('_', $slug)));
/**
* Article_Stats_Plugin
*/
$class = $base_name.'_Plugin';
/**
* PSR-0:
* Article_Stats_Plugin
* → Article/Stats/Plugin.php
*/
$file = $classes_path.DIRECTORY_SEPARATOR.
str_replace('_', DIRECTORY_SEPARATOR, $base_name).
DIRECTORY_SEPARATOR.'Plugin.php';
if ( ! is_file($file))
{
throw new Kohana_Exception(
'Plugin file not found: :file',
[':file' => $file]
);
}
require_once $file;
if ( ! class_exists($class))
{
throw new Kohana_Exception(
'Plugin class :class not found',
[':class' => $class]
);
}
$plugin = new $class;
if ( ! $plugin instanceof Plugin_Interface)
{
throw new Kohana_Exception(
'Plugin :class must implement Plugin_Interface',
[':class' => $class]
);
}
$plugin->boot();
$this->_instances[$slug] = $plugin;
return $this->_instances[$slug];
}
protected function _load_state()
{
if ( ! is_file($this->_state_file))
{
return [];
}
return include $this->_state_file;
}
protected function _save_state(array $plugins)
{
$export = "<?php defined('SYSPATH') or die('No direct script access.');\n\nreturn ".var_export($plugins, true).";\n";
file_put_contents($this->_state_file, $export, LOCK_EX);
}
}
Lifecycle pluginu
install
↓
activate()
↓
boot()
↓
runtime
↓
deactivate()
Przykład pluginu
<?php defined('SYSPATH') or die('No direct script access.');
class Article_Stats_Plugin implements Plugin_Interface
{
public function boot()
{
Hook::add_action('article.viewed', [$this, 'on_view']);
}
public function activate()
{
Kohana::$log->add(Log::INFO, 'Plugin activated');
}
public function deactivate()
{
Kohana::$log->add(Log::INFO, 'Plugin deactivated');
}
public function on_view($article)
{
$article->views++;
$article->save();
}
}
Zastosowanie w kontrolerze
Event::dispatch('article.viewed', [$article]);
$title = Hook::apply_filters('article.title', [$article->title]);
Kontroler:
- nie zna pluginów
- nie zawiera ifów
- jest testowalny
Zalety podejścia
Możliwe rozszerzenia
- priorytety hooków
remove_action / remove_filter- zależności pluginów
- CLI (
plugin enable article_stats) - upload ZIP
- uninstall pluginu
- cache mapy pluginów
Podsumowanie
Przedstawiony system pluginów:
- wykorzystuje naturalne mechanizmy Koseven
- zachowuje prostotę Kohany
- daje elastyczność znaną z WordPressa
- pozostaje czytelny i przewidywalny
To solidna baza pod rozszerzalną aplikację bez narzutów typowych dla ciężkich frameworków.