Skip to content

Commit ad8ac6a

Browse files
committed
feature #2211 [TwigComponent] Cache component properties metadata (smnandre)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [TwigComponent] Cache component properties metadata Currently if we have 50 times the same component in a page, 50 times we use reflection to analyse component class properties and methods. This PR centralize this task in a dedicated (internal) service and add a cachewarmer to pre-compute metadata during app build. Significant performance gains here too (i won't do charts for every PR but be sure i'm gonna make some before/after once i'm "done" with all this.... in Vienna 👼 ) Commits ------- 1cc7362 [TwigComponent] Cache component properties metadata
2 parents bf3a0f0 + 1cc7362 commit ad8ac6a

File tree

6 files changed

+258
-63
lines changed

6 files changed

+258
-63
lines changed

src/TwigComponent/config/cache.php

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\DependencyInjection\Loader\Configurator;
13+
14+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
15+
16+
return static function (ContainerConfigurator $container): void {
17+
$container->services()
18+
->set('cache.ux.twig_component')
19+
->parent('cache.system')
20+
->private()
21+
->tag('cache.pool')
22+
;
23+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\CacheWarmer;
13+
14+
use Psr\Container\ContainerInterface;
15+
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
16+
use Symfony\Contracts\Service\ServiceSubscriberInterface;
17+
use Symfony\UX\TwigComponent\ComponentProperties;
18+
19+
/**
20+
* Warm the TwigComponent metadata caches.
21+
*
22+
* @author Simon André <[email protected]>
23+
*
24+
* @internal
25+
*/
26+
final class TwigComponentCacheWarmer implements CacheWarmerInterface, ServiceSubscriberInterface
27+
{
28+
/**
29+
* As this cache warmer is optional, dependencies should be lazy-loaded, that's why a container should be injected.
30+
*/
31+
public function __construct(
32+
private readonly ContainerInterface $container,
33+
) {
34+
}
35+
36+
public static function getSubscribedServices(): array
37+
{
38+
return [
39+
'ux.twig_component.component_properties' => ComponentProperties::class,
40+
];
41+
}
42+
43+
public function warmUp(string $cacheDir, ?string $buildDir = null): array
44+
{
45+
$properties = $this->container->get('ux.twig_component.component_properties');
46+
$properties->warmup();
47+
48+
return [];
49+
}
50+
51+
public function isOptional(): bool
52+
{
53+
return true;
54+
}
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent;
13+
14+
use Symfony\Component\Cache\Adapter\AdapterInterface;
15+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
16+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
17+
18+
/**
19+
* @author Simon André <[email protected]>
20+
*
21+
* @internal
22+
*/
23+
final class ComponentProperties
24+
{
25+
private const CACHE_KEY = 'ux.twig_component.component_properties';
26+
27+
/**
28+
* @var array<class-string, array{
29+
* properties: array<class-string, array{string, array{string, string, bool}, bool}>,
30+
* methods: array<class-string, array{string, array{string, bool}}>,
31+
* }|null>
32+
*/
33+
private array $classMetadata;
34+
35+
public function __construct(
36+
private readonly PropertyAccessorInterface $propertyAccessor,
37+
?array $classMetadata = [],
38+
private readonly ?AdapterInterface $cache = null,
39+
) {
40+
$cacheItem = $this->cache?->getItem(self::CACHE_KEY);
41+
42+
$this->classMetadata = $cacheItem?->isHit() ? [...$cacheItem->get(), ...$classMetadata] : $classMetadata;
43+
}
44+
45+
/**
46+
* @return array<string, mixed>
47+
*/
48+
public function getProperties(object $component, bool $publicProps = false): array
49+
{
50+
return iterator_to_array($this->extractProperties($component, $publicProps));
51+
}
52+
53+
public function warmup(): void
54+
{
55+
if (!$this->cache) {
56+
return;
57+
}
58+
59+
foreach ($this->classMetadata as $class => $metadata) {
60+
if (null === $metadata) {
61+
$this->classMetadata[$class] = $this->loadClassMetadata($class);
62+
}
63+
}
64+
65+
$this->cache->save($this->cache->getItem(self::CACHE_KEY)->set($this->classMetadata));
66+
}
67+
68+
/**
69+
* @return \Generator<string, mixed>
70+
*/
71+
private function extractProperties(object $component, bool $publicProps): \Generator
72+
{
73+
yield from $publicProps ? get_object_vars($component) : [];
74+
75+
$metadata = $this->classMetadata[$component::class] ??= $this->loadClassMetadata($component::class);
76+
77+
foreach ($metadata['properties'] as $propertyName => $property) {
78+
$value = $property['getter'] ? $component->{$property['getter']}() : $this->propertyAccessor->getValue($component, $propertyName);
79+
if ($property['destruct'] ?? false) {
80+
yield from $value;
81+
} else {
82+
yield $property['name'] => $value;
83+
}
84+
}
85+
86+
foreach ($metadata['methods'] as $methodName => $method) {
87+
if ($method['destruct'] ?? false) {
88+
yield from $component->{$methodName}();
89+
} else {
90+
yield $method['name'] => $component->{$methodName}();
91+
}
92+
}
93+
}
94+
95+
/**
96+
* @param class-string $class
97+
*
98+
* @return array{
99+
* properties: array<string, array{
100+
* name?: string,
101+
* getter?: string,
102+
* destruct?: bool
103+
* }>,
104+
* methods: array<string, array{
105+
* name?: string,
106+
* destruct?: bool
107+
* }>,
108+
* }
109+
*/
110+
private function loadClassMetadata(string $class): array
111+
{
112+
$refClass = new \ReflectionClass($class);
113+
114+
$properties = [];
115+
foreach ($refClass->getProperties() as $property) {
116+
if (!$attributes = $property->getAttributes(ExposeInTemplate::class)) {
117+
continue;
118+
}
119+
$attribute = $attributes[0]->newInstance();
120+
$properties[$property->name] = [
121+
'name' => $attribute->name ?? $property->name,
122+
'getter' => $attribute->getter ? rtrim($attribute->getter, '()') : null,
123+
];
124+
if ($attribute->destruct) {
125+
unset($properties[$property->name]['name']);
126+
$properties[$property->name]['destruct'] = true;
127+
}
128+
}
129+
130+
$methods = [];
131+
foreach ($refClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
132+
if (!$attributes = $method->getAttributes(ExposeInTemplate::class)) {
133+
continue;
134+
}
135+
if ($method->getNumberOfRequiredParameters()) {
136+
throw new \LogicException(\sprintf('Cannot use "%s" on methods with required parameters (%s::%s).', ExposeInTemplate::class, $class, $method->name));
137+
}
138+
$attribute = $attributes[0]->newInstance();
139+
$name = $attribute->name ?? (str_starts_with($method->name, 'get') ? lcfirst(substr($method->name, 3)) : $method->name);
140+
$methods[$method->name] = $attribute->destruct ? ['destruct' => true] : ['name' => $name];
141+
}
142+
143+
return [
144+
'properties' => $properties,
145+
'methods' => $methods,
146+
];
147+
}
148+
}

src/TwigComponent/src/ComponentRenderer.php

+5-58
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@
1111

1212
namespace Symfony\UX\TwigComponent;
1313

14-
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1514
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
16-
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
1715
use Symfony\UX\TwigComponent\Event\PostRenderEvent;
1816
use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
1917
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
@@ -30,7 +28,7 @@ public function __construct(
3028
private Environment $twig,
3129
private EventDispatcherInterface $dispatcher,
3230
private ComponentFactory $factory,
33-
private PropertyAccessorInterface $propertyAccessor,
31+
private ComponentProperties $componentProperties,
3432
private ComponentStack $componentStack,
3533
) {
3634
}
@@ -107,9 +105,11 @@ private function preRender(MountedComponent $mounted, array $context = []): PreR
107105
{
108106
$component = $mounted->getComponent();
109107
$metadata = $this->factory->metadataFor($mounted->getName());
110-
$isAnonymous = $mounted->getComponent() instanceof AnonymousComponent;
111108

112-
$classProps = $isAnonymous ? [] : iterator_to_array($this->exposedVariables($component, $metadata->isPublicPropsExposed()));
109+
$classProps = [];
110+
if (!$metadata->isAnonymous()) {
111+
$classProps = $this->componentProperties->getProperties($component, $metadata->isPublicPropsExposed());
112+
}
113113

114114
// expose public properties and properties marked with ExposeInTemplate attribute
115115
$props = [...$mounted->getInputProps(), ...$classProps];
@@ -137,57 +137,4 @@ private function preRender(MountedComponent $mounted, array $context = []): PreR
137137

138138
return $event;
139139
}
140-
141-
private function exposedVariables(object $component, bool $exposePublicProps): \Iterator
142-
{
143-
if ($exposePublicProps) {
144-
yield from get_object_vars($component);
145-
}
146-
147-
$class = new \ReflectionClass($component);
148-
149-
foreach ($class->getProperties() as $property) {
150-
if (!$attribute = $property->getAttributes(ExposeInTemplate::class)[0] ?? null) {
151-
continue;
152-
}
153-
154-
$attribute = $attribute->newInstance();
155-
156-
/** @var ExposeInTemplate $attribute */
157-
$value = $attribute->getter ? $component->{rtrim($attribute->getter, '()')}() : $this->propertyAccessor->getValue($component, $property->name);
158-
159-
if ($attribute->destruct) {
160-
foreach ($value as $key => $destructedValue) {
161-
yield $key => $destructedValue;
162-
}
163-
}
164-
165-
yield $attribute->name ?? $property->name => $value;
166-
}
167-
168-
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
169-
if (!$attribute = $method->getAttributes(ExposeInTemplate::class)[0] ?? null) {
170-
continue;
171-
}
172-
173-
$attribute = $attribute->newInstance();
174-
175-
/** @var ExposeInTemplate $attribute */
176-
$name = $attribute->name ?? (str_starts_with($method->name, 'get') ? lcfirst(substr($method->name, 3)) : $method->name);
177-
178-
if ($method->getNumberOfRequiredParameters()) {
179-
throw new \LogicException(\sprintf('Cannot use "%s" on methods with required parameters (%s::%s).', ExposeInTemplate::class, $component::class, $method->name));
180-
}
181-
182-
if ($attribute->destruct) {
183-
foreach ($component->{$method->name}() as $prop => $value) {
184-
yield $prop => $value;
185-
}
186-
187-
return;
188-
}
189-
190-
yield $name => $component->{$method->name}();
191-
}
192-
}
193140
}

src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php

+3
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ public function process(ContainerBuilder $container): void
8080
$factoryDefinition->setArgument(4, $componentConfig);
8181
$factoryDefinition->setArgument(5, $componentClassMap);
8282

83+
$componentPropertiesDefinition = $container->findDefinition('ux.twig_component.component_properties');
84+
$componentPropertiesDefinition->setArgument(1, array_fill_keys(array_keys($componentClassMap), null));
85+
8386
$debugCommandDefinition = $container->findDefinition('ux.twig_component.command.debug');
8487
$debugCommandDefinition->setArgument(3, $componentClassMap);
8588
}

0 commit comments

Comments
 (0)