Inhalt
- 1. Was ist das Object Pool Pattern?
- 2. Wann ist Pooling sinnvoll?
- 3. PHP 8.4 Implementierung
- 4. Magento: DB-Connection-Pool (ResourceConnection)
- 5. HTTP-Client-Pool in Magento
- 6. Eigener Pool: PDF-Generator-Beispiel
- 7. PHP-Request-Scope: Warum klassisches Pooling begrenzt ist
- 8. Pool vs. Singleton vs. Factory
- 9. Object Pools testen
- 10. Fazit: Wann Object Pool in Magento einsetzen?
Das Object Pool Pattern ist eines der oft übersehenen Creational Patterns aus dem GoF-Katalog. Die Idee ist simpel: Statt ein teures Objekt jedes Mal neu zu erstellen und zu zerstören, legst du einen Vorrat (Pool) solcher Objekte an und leihst sie bei Bedarf aus — und gibst sie nach der Nutzung zurück. In Sprachen wie Java oder C# ist das unverzichtbar für Thread-Pools und Connection-Pools. In PHP-per-Request-Architektur verändert sich der Kontext — aber das Pattern bleibt hochrelevant, wie Magento selbst beweist.
1. Was ist das Object Pool Pattern?
Das GoF-Object-Pool-Pattern verwaltet einen Satz vorinitialisierter Objekte, die teuer in der Erstellung sind. Der Client fragt den Pool nach einem Objekt, nutzt es, und gibt es zurück — anstatt es zu vernichten.
+------------------+ acquire() +------------------+
| Object Pool | -----------------------> | Pooled Object |
| | <----------------------- | (in use) |
| [obj1] [obj2] | release() +------------------+
| [obj3] [obj4] |
+------------------+
|
| creates new if empty
v
+------------------+
| Object Factory |
+------------------+
Die drei Kernoperationen:
- acquire() — Ein freies Objekt aus dem Pool holen (oder neu erstellen, falls Pool leer)
- release() — Objekt zurück in den Pool legen (auf Initialzustand zurücksetzen)
- create() — Neue Instanz erzeugen, wenn Pool erschöpft ist (optional: max-size begrenzen)
2. Wann ist Pooling sinnvoll?
Nicht jedes Objekt profitiert von Pooling. Die Faustregeln:
| Szenario | Erstellungskosten | Pool sinnvoll? |
|---|---|---|
| Datenbank-Connection | TCP-Handshake, Auth: ~50–200ms | ✓ Ja |
| HTTP-Client (cURL) | SSL-Handshake: ~30–100ms | ✓ Ja (keep-alive) |
| PDF-Generator (Puppeteer/wkhtmltopdf) | Browser-Start: ~500ms–2s | ✓ Ja |
| Elasticsearch-Client | Connection + Auth: ~20–80ms | ✓ Ja |
| Einfaches DataObject | <1ms | ✗ Nein (Overhead überwiegt) |
| ViewModel mit DI | <1ms (Shared) | ✗ Nein (Singleton reicht) |
3. PHP 8.4 Implementierung
Eine generische Pool-Implementierung mit PHP 8.4 Constructor Property Promotion und Typed Properties:
<?php
declare(strict_types=1);
namespace Mironsoft\Core\Pool;
use SplQueue;
use Throwable;
/**
* Generic object pool for expensive-to-create resources.
*
* @template T of object
*/
final class ObjectPool
{
/** @var SplQueue<T> */
private readonly SplQueue $available;
private int $currentSize = 0;
/**
* @param callable(): T $factory Creates a new instance when pool is empty
* @param callable(T): void $reset Resets object state before returning to pool
* @param int $maxSize Maximum pool size (0 = unlimited)
*/
public function __construct(
private readonly \Closure $factory,
private readonly \Closure $reset,
private readonly int $maxSize = 10,
) {
$this->available = new SplQueue();
}
/**
* Acquires an object from the pool, creating one if necessary.
*
* @return T
*/
public function acquire(): object
{
if (!$this->available->isEmpty()) {
return $this->available->dequeue();
}
$this->currentSize++;
return ($this->factory)();
}
/**
* Returns an object to the pool after use.
*
* @param T $object
*/
public function release(object $object): void
{
if ($this->maxSize > 0 && $this->available->count() >= $this->maxSize) {
// Pool is full — let the object be garbage collected
$this->currentSize--;
return;
}
($this->reset)($object);
$this->available->enqueue($object);
}
/**
* Returns current pool statistics.
*
* @return array{available: int, total: int}
*/
public function stats(): array
{
return [
'available' => $this->available->count(),
'total' => $this->currentSize,
];
}
}
Verwendung mit einem konkreten Ressourcentyp:
<?php
// Build a pool of cURL handles (example of pool usage)
$curlPool = new ObjectPool(
factory: static function (): \CurlHandle {
$handle = curl_init();
curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
curl_setopt($handle, CURLOPT_TIMEOUT, 10);
return $handle;
},
reset: static function (\CurlHandle $handle): void {
curl_reset($handle);
curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
curl_setopt($handle, CURLOPT_TIMEOUT, 10);
},
maxSize: 5,
);
// Use the pool
$handle = $curlPool->acquire();
curl_setopt($handle, CURLOPT_URL, 'https://api.example.com/data');
$response = curl_exec($handle);
$curlPool->release($handle); // Returns to pool instead of destroying
4. Magento: DB-Connection-Pool (ResourceConnection)
Magento implementiert intern ein Connection-Pool-Konzept über Magento\Framework\App\ResourceConnection. Statt bei jedem Datenbankzugriff eine neue TCP-Verbindung aufzubauen, werden Verbindungen gecacht und wiederverwendet.
<?php
declare(strict_types=1);
namespace Mironsoft\Example\Model;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\DB\Adapter\AdapterInterface;
/**
* Demonstrates how Magento pools database connections internally.
*/
final class ProductRepository
{
public function __construct(
private readonly ResourceConnection $resourceConnection,
) {}
/**
* Magento's getConnection() returns the SAME AdapterInterface
* instance every time within one request — this IS the pool.
*/
public function getActiveProductCount(): int
{
// First call: opens TCP connection to MySQL, creates Adapter
$connection = $this->resourceConnection->getConnection();
// Second call anywhere in the request: returns SAME instance
// No new TCP handshake, no re-authentication
$connection2 = $this->resourceConnection->getConnection();
// $connection === $connection2 → true (same object)
$table = $this->resourceConnection->getTableName('catalog_product_entity');
return (int) $connection->fetchOne(
$connection->select()
->from($table, ['COUNT(*)'])
->where('status = ?', 1)
);
}
/**
* Named connections: separate pool entries for read/write splitting.
*/
public function getWithReadReplica(): AdapterInterface
{
// Magento supports named connections (read replica)
// Each name maps to a separate pooled connection
return $this->resourceConnection->getConnection('read');
}
}
Die interne Pooling-Logik in ResourceConnection::getConnection():
<?php
// Simplified from vendor/magento/framework/App/ResourceConnection.php
// The actual implementation uses _connections[] array as the pool
class ResourceConnection
{
/** @var AdapterInterface[] Pool indexed by connection name */
private array $connections = [];
public function getConnection(string $resourceName = self::DEFAULT_CONNECTION): AdapterInterface
{
$connectionName = $this->getConnectionName($resourceName);
// Pool lookup: return existing connection if available
if (isset($this->connections[$connectionName])) {
return $this->connections[$connectionName]; // ← Pool hit
}
// Pool miss: create new connection and store in pool
$this->connections[$connectionName] = $this->connectionFactory->create(
$this->deploymentConfig->get("db/connection/{$connectionName}")
);
return $this->connections[$connectionName];
}
}
Das ist klassisches Object Pool Pattern — nur ohne explizites release(), da PHP am Request-Ende den Speicher freigibt.
5. HTTP-Client-Pool in Magento
Beim Ansprechen externer APIs (Payment-Gateways, ERP-Systeme) lohnt sich ein cURL-Pool, um SSL-Handshakes zu vermeiden:
<?php
declare(strict_types=1);
namespace Mironsoft\Integration\Http;
/**
* Pools cURL handles to reuse SSL sessions within a single request.
*/
final class CurlHandlePool
{
/** @var \CurlHandle[] */
private array $available = [];
/** @var \CurlHandle[] */
private array $inUse = [];
private const MAX_POOL_SIZE = 8;
/**
* Acquires a cURL handle, reusing an existing one if possible.
*/
public function acquire(): \CurlHandle
{
if (!empty($this->available)) {
$handle = array_pop($this->available);
$this->inUse[spl_object_id($handle)] = $handle;
return $handle;
}
$handle = $this->createHandle();
$this->inUse[spl_object_id($handle)] = $handle;
return $handle;
}
/**
* Returns a handle to the pool and resets its state.
*/
public function release(\CurlHandle $handle): void
{
$id = spl_object_id($handle);
unset($this->inUse[$id]);
if (count($this->available) < self::MAX_POOL_SIZE) {
curl_reset($handle);
curl_setopt_array($handle, $this->defaultOptions());
$this->available[] = $handle;
} else {
curl_close($handle);
}
}
private function createHandle(): \CurlHandle
{
$handle = curl_init();
curl_setopt_array($handle, $this->defaultOptions());
return $handle;
}
/** @return array<int, mixed> */
private function defaultOptions(): array
{
return [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0,
CURLOPT_TCP_KEEPALIVE => 1,
];
}
}
Di.xml-Registrierung als Shared-Objekt (Pool muss Singleton sein):
<!-- app/code/Mironsoft/Integration/etc/di.xml -->
<config>
<type name="Mironsoft\Integration\Http\CurlHandlePool">
<!-- shared="true" is the default — explicitly stating it for clarity -->
<!-- The pool MUST be a singleton so all callers share the same pool -->
<arguments>
<!-- Max pool size configurable via di.xml -->
</arguments>
</type>
</config>
6. Eigener Pool: PDF-Generator-Beispiel
Ein realistisches Magento-Szenario: PDF-Generierung über einen externen Prozess (z.B. headless Chrome via Puppeteer). Der Prozessstart kostet ~1–2 Sekunden — mit Pooling nur einmal pro Request-Lebenszyklus.
<?php
declare(strict_types=1);
namespace Mironsoft\Pdf\Pool;
use Psr\Log\LoggerInterface;
/**
* Manages a pool of external PDF renderer processes.
*/
final class PdfRendererPool
{
/** @var PdfRenderer[] */
private array $available = [];
private int $created = 0;
public function __construct(
private readonly PdfRendererFactory $rendererFactory,
private readonly LoggerInterface $logger,
private readonly int $maxPoolSize = 3,
private readonly int $maxIdleTime = 300, // seconds
) {}
/**
* Acquires a PDF renderer, starting a new process if necessary.
*/
public function acquire(): PdfRenderer
{
// Remove stale renderers first
$this->evictStale();
if (!empty($this->available)) {
return array_pop($this->available);
}
if ($this->created >= $this->maxPoolSize) {
// Block and wait — or throw if strict mode
throw new \RuntimeException(
"PDF renderer pool exhausted (max: {$this->maxPoolSize})"
);
}
$this->logger->info('Starting new PDF renderer process');
$renderer = $this->rendererFactory->create();
$this->created++;
return $renderer;
}
/**
* Returns a renderer to the pool after use.
*/
public function release(PdfRenderer $renderer): void
{
if (!$renderer->isHealthy()) {
$renderer->terminate();
$this->created--;
return;
}
$renderer->markLastUsed(time());
$this->available[] = $renderer;
}
/**
* Removes renderers that have been idle too long.
*/
private function evictStale(): void
{
$now = time();
$this->available = array_filter(
$this->available,
function (PdfRenderer $r) use ($now): bool {
if ($now - $r->lastUsed() > $this->maxIdleTime) {
$r->terminate();
$this->created--;
return false;
}
return true;
}
);
}
}
Der Service, der den Pool nutzt:
<?php
declare(strict_types=1);
namespace Mironsoft\Pdf\Service;
use Mironsoft\Pdf\Pool\PdfRendererPool;
use Magento\Sales\Api\Data\OrderInterface;
/**
* Generates order PDF documents using a pooled renderer.
*/
final class OrderPdfService
{
public function __construct(
private readonly PdfRendererPool $rendererPool,
) {}
/**
* Generates a PDF for the given order.
*/
public function generateOrderPdf(OrderInterface $order): string
{
$renderer = $this->rendererPool->acquire();
try {
$html = $this->buildOrderHtml($order);
return $renderer->render($html, ['format' => 'A4']);
} finally {
// Always release back to pool, even on exception
$this->rendererPool->release($renderer);
}
}
private function buildOrderHtml(OrderInterface $order): string
{
return sprintf(
'<html><body><h1>Order #%s</h1><p>Total: %s</p></body></html>',
$order->getIncrementId(),
$order->getGrandTotal()
);
}
}
Das finally-Block ist entscheidend: Der Renderer muss immer zurück in den Pool, auch bei Exceptions — sonst leakt der Pool.
7. PHP-Request-Scope: Warum klassisches Pooling begrenzt ist
PHP ist per Default Share-Nothing-Architektur: Jeder Request startet einen neuen PHP-Prozess (oder FPM-Worker), der am Ende vollständig bereinigt wird. Das bedeutet:
PHP-Besonderheiten beim Object Pool
- Kein Cross-Request-Pooling: Ein Pool lebt nur innerhalb eines einzelnen Requests. Für dauerhaftes Pooling brauchst du einen externen Proxy (PgBouncer, ProxySQL, HAProxy).
- PHP-FPM Worker als impliziter Pool: FPM selbst pooled PHP-Worker-Prozesse. Persistent Connections (
pconnect) nutzen diesen Worker-Pool. - Swoole/RoadRunner: Mit Coroutine-Frameworks existiert der Prozess über mehrere Requests — dann ist Cross-Request-Pooling möglich und sinnvoll.
- Sinnvoll trotzdem: Innerhalb eines einzelnen langen Requests (Batch-Import, Queue-Consumer) mit vielen DB-Queries oder API-Calls lohnt sich intra-request Pooling sehr.
Für Magento-Queue-Consumer, die als Long-Running-Prozesse laufen, ist Object Pool besonders relevant:
<?php
declare(strict_types=1);
namespace Mironsoft\Queue\Consumer;
use Magento\Framework\MessageQueue\ConsumerInterface;
use Magento\Framework\MessageQueue\EnvelopeInterface;
use Mironsoft\Integration\Http\CurlHandlePool;
/**
* Long-running queue consumer that benefits from cURL handle pooling.
* This process may handle thousands of messages before being recycled.
*/
final class WebhookDispatcher implements ConsumerInterface
{
public function __construct(
private readonly CurlHandlePool $curlPool,
) {}
public function process(EnvelopeInterface $envelope): void
{
$handle = $this->curlPool->acquire();
try {
$message = json_decode($envelope->getBody(), true, 512, JSON_THROW_ON_ERROR);
curl_setopt($handle, CURLOPT_URL, $message['webhook_url']);
curl_setopt($handle, CURLOPT_POSTFIELDS, json_encode($message['payload']));
curl_exec($handle);
} finally {
$this->curlPool->release($handle);
}
}
}
8. Pool vs. Singleton vs. Factory
| Pattern | Instanzen | Zustand | Ideal für |
|---|---|---|---|
| Singleton | 1 (immer) | Geteilt (gefährlich) | Stateless Services, Konfiguration |
| Factory | N (jedes Mal neu) | Frisch, isoliert | Non-injectable, günstige Erstellung |
| Object Pool | M (begrenzt, wiederverwendet) | Reset zwischen Nutzungen | Teure Erstellung, häufige Nutzung |
| Prototype | N (via clone) | Kopiert vom Original | Komplexe Initialzustände, Templates |
Die Entscheidungsregel: Wenn das Erstellen eines Objekts messbar Zeit kostet (>10ms) und du es häufig brauchst, ist ein Pool sinnvoll. Wenn der Zustand nicht resettbar ist, verwende Factory. Wenn du nur eine Instanz brauchst und der Zustand stateless ist, verwende Singleton (shared di.xml).
9. Object Pools testen
Beim Testen von Pools gibt es drei kritische Szenarien:
<?php
declare(strict_types=1);
namespace Mironsoft\Core\Test\Unit\Pool;
use Mironsoft\Core\Pool\ObjectPool;
use PHPUnit\Framework\TestCase;
final class ObjectPoolTest extends TestCase
{
private int $createCount = 0;
private function buildPool(int $maxSize = 5): ObjectPool
{
return new ObjectPool(
factory: function (): \stdClass {
$this->createCount++;
$obj = new \stdClass();
$obj->id = $this->createCount;
$obj->value = null;
return $obj;
},
reset: static function (\stdClass $obj): void {
$obj->value = null; // Reset state before returning to pool
},
maxSize: $maxSize,
);
}
public function testAcquireCreatesNewObjectWhenPoolEmpty(): void
{
$pool = $this->buildPool();
$obj = $pool->acquire();
$this->assertSame(1, $this->createCount);
$this->assertInstanceOf(\stdClass::class, $obj);
}
public function testReleaseAndReacquireReusesObject(): void
{
$pool = $this->buildPool();
$obj1 = $pool->acquire();
$pool->release($obj1);
$obj2 = $pool->acquire();
// Only ONE object was ever created
$this->assertSame(1, $this->createCount);
// It's the SAME instance
$this->assertSame($obj1, $obj2);
}
public function testResetIsCalledOnRelease(): void
{
$pool = $this->buildPool();
$obj = $pool->acquire();
$obj->value = 'dirty-state';
$pool->release($obj);
$reacquired = $pool->acquire();
// State must be reset
$this->assertNull($reacquired->value);
}
public function testPoolDoesNotExceedMaxSize(): void
{
$pool = $this->buildPool(maxSize: 2);
$a = $pool->acquire();
$b = $pool->acquire();
$c = $pool->acquire(); // Creates 3rd
// Release all three
$pool->release($a);
$pool->release($b);
$pool->release($c); // Pool is full — $c gets discarded
$stats = $pool->stats();
$this->assertLessThanOrEqual(2, $stats['available']);
}
public function testConcurrentAcquireCreatesSeparateObjects(): void
{
$pool = $this->buildPool();
$obj1 = $pool->acquire();
$obj2 = $pool->acquire(); // Pool empty — new object
$this->assertSame(2, $this->createCount);
$this->assertNotSame($obj1, $obj2);
}
}
10. Fazit: Wann Object Pool in Magento einsetzen?
Das Object Pool Pattern ist in Magento an den richtigen Stellen bereits eingesetzt (DB-Connections, HTTP-Clients) und du solltest es gezielt für eigene Anforderungen verwenden:
✓ Pool nutzen wenn...
- Erstellung kostet >10ms (Connection, Prozess)
- Objekt innerhalb eines Requests mehrfach gebraucht
- Zustand nach Nutzung resettbar ist
- Long-running Queue-Consumer (Magento MQ)
- Externe API-Verbindungen mit SSL-Overhead
✗ Pool vermeiden wenn...
- Erstellung günstig ist (<1ms)
- Objekte mutable und nicht resetbar sind
- Nur eine Instanz nötig (dann: Singleton/Shared)
- Standard-HTTP-Request mit 1-2 DB-Queries
- Zustand zwischen Nutzungen kritisch unterschiedlich
Magento selbst zeigt den richtigen Weg: ResourceConnection pooled DB-Connections transparent, ohne dass der Entwickler sich darum kümmern muss. Das ist gutes Object Pool Design — einfach zu nutzen, schwer zu missbrauchen.
Zusammenfassung
Object Pool in der Praxis
Connection-Pools analysieren, HTTP-Clients optimieren oder Queue-Consumer beschleunigen.