Skip to content

Commit ac9f262

Browse files
committed
[Workflow] List place or transition listeners in profiler
1 parent ec4c496 commit ac9f262

File tree

3 files changed

+219
-0
lines changed

3 files changed

+219
-0
lines changed

DataCollector/WorkflowDataCollector.php

+125
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Symfony\Component\Workflow\DataCollector;
1313

14+
use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter;
15+
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
1416
use Symfony\Component\HttpFoundation\Request;
1517
use Symfony\Component\HttpFoundation\Response;
1618
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
@@ -19,8 +21,12 @@
1921
use Symfony\Component\VarDumper\Cloner\Stub;
2022
use Symfony\Component\Workflow\Debug\TraceableWorkflow;
2123
use Symfony\Component\Workflow\Dumper\MermaidDumper;
24+
use Symfony\Component\Workflow\EventListener\GuardExpression;
25+
use Symfony\Component\Workflow\EventListener\GuardListener;
2226
use Symfony\Component\Workflow\Marking;
27+
use Symfony\Component\Workflow\Transition;
2328
use Symfony\Component\Workflow\TransitionBlocker;
29+
use Symfony\Component\Workflow\WorkflowInterface;
2430

2531
/**
2632
* @author Grégoire Pineau <lyrixx@lyrixx.info>
@@ -29,6 +35,8 @@ final class WorkflowDataCollector extends DataCollector implements LateDataColle
2935
{
3036
public function __construct(
3137
private readonly iterable $workflows,
38+
private readonly EventDispatcherInterface $eventDispatcher,
39+
private readonly FileLinkFormatter $fileLinkFormatter,
3240
) {
3341
}
3442

@@ -50,6 +58,7 @@ public function lateCollect(): void
5058
$this->data['workflows'][$workflow->getName()] = [
5159
'dump' => $dumper->dump($workflow->getDefinition()),
5260
'calls' => $calls,
61+
'listeners' => $this->getEventListeners($workflow),
5362
];
5463
}
5564
}
@@ -102,4 +111,120 @@ protected function getCasters(): array
102111

103112
return $casters;
104113
}
114+
115+
public function hash(string $string): string
116+
{
117+
return hash('xxh128', $string);
118+
}
119+
120+
private function getEventListeners(WorkflowInterface $workflow): array
121+
{
122+
$listeners = [];
123+
$placeId = 0;
124+
foreach ($workflow->getDefinition()->getPlaces() as $place) {
125+
$eventNames = [];
126+
$subEventNames = [
127+
'leave',
128+
'enter',
129+
'entered',
130+
];
131+
foreach ($subEventNames as $subEventName) {
132+
$eventNames[] = sprintf('workflow.%s', $subEventName);
133+
$eventNames[] = sprintf('workflow.%s.%s', $workflow->getName(), $subEventName);
134+
$eventNames[] = sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $place);
135+
}
136+
foreach ($eventNames as $eventName) {
137+
foreach ($this->eventDispatcher->getListeners($eventName) as $listener) {
138+
$listeners["place{$placeId}"][$eventName][] = $this->summarizeListener($listener);
139+
}
140+
}
141+
142+
++$placeId;
143+
}
144+
145+
foreach ($workflow->getDefinition()->getTransitions() as $transitionId => $transition) {
146+
$eventNames = [];
147+
$subEventNames = [
148+
'guard',
149+
'transition',
150+
'completed',
151+
'announce',
152+
];
153+
foreach ($subEventNames as $subEventName) {
154+
$eventNames[] = sprintf('workflow.%s', $subEventName);
155+
$eventNames[] = sprintf('workflow.%s.%s', $workflow->getName(), $subEventName);
156+
$eventNames[] = sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $transition->getName());
157+
}
158+
foreach ($eventNames as $eventName) {
159+
foreach ($this->eventDispatcher->getListeners($eventName) as $listener) {
160+
$listeners["transition{$transitionId}"][$eventName][] = $this->summarizeListener($listener, $eventName, $transition);
161+
}
162+
}
163+
}
164+
165+
return $listeners;
166+
}
167+
168+
private function summarizeListener(callable $callable, string $eventName = null, Transition $transition = null): array
169+
{
170+
$extra = [];
171+
172+
if ($callable instanceof \Closure) {
173+
$r = new \ReflectionFunction($callable);
174+
if (str_contains($r->name, '{closure}')) {
175+
$title = (string) $r;
176+
} elseif ($class = \PHP_VERSION_ID >= 80111 ? $r->getClosureCalledClass() : $r->getClosureScopeClass()) {
177+
$title = $class->name.'::'.$r->name.'()';
178+
} else {
179+
$title = $r->name;
180+
}
181+
} elseif (\is_string($callable)) {
182+
$title = $callable.'()';
183+
$r = new \ReflectionFunction($callable);
184+
} elseif (\is_object($callable) && method_exists($callable, '__invoke')) {
185+
$r = new \ReflectionMethod($callable, '__invoke');
186+
$title = $callable::class.'::__invoke()';
187+
} elseif (\is_array($callable)) {
188+
if ($callable[0] instanceof GuardListener) {
189+
if (null === $eventName || null === $transition) {
190+
throw new \LogicException('Missing event name or transition.');
191+
}
192+
$extra['guardExpressions'] = $this->extractGuardExpressions($callable[0], $eventName, $transition);
193+
}
194+
$r = new \ReflectionMethod($callable[0], $callable[1]);
195+
$title = (\is_string($callable[0]) ? $callable[0] : \get_class($callable[0])).'::'.$callable[1].'()';
196+
} else {
197+
throw new \RuntimeException('Unknown callable type.');
198+
}
199+
200+
$file = null;
201+
if ($r->isUserDefined()) {
202+
$file = $this->fileLinkFormatter->format($r->getFileName(), $r->getStartLine());
203+
}
204+
205+
return [
206+
'title' => $title,
207+
'file' => $file,
208+
...$extra,
209+
];
210+
}
211+
212+
private function extractGuardExpressions(GuardListener $listener, string $eventName, Transition $transition): array
213+
{
214+
$configuration = (new \ReflectionProperty(GuardListener::class, 'configuration'))->getValue($listener);
215+
216+
$expressions = [];
217+
foreach ($configuration[$eventName] as $guard) {
218+
if ($guard instanceof GuardExpression) {
219+
if ($guard->getTransition() !== $transition) {
220+
continue;
221+
}
222+
$expressions[] = $guard->getExpression();
223+
} else {
224+
$expressions[] = $guard;
225+
}
226+
}
227+
228+
return $expressions;
229+
}
105230
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Component\Workflow\Tests\DataCollector;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter;
16+
use Symfony\Component\EventDispatcher\EventDispatcher;
17+
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
18+
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
19+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
20+
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
21+
use Symfony\Component\Validator\Validator\ValidatorInterface;
22+
use Symfony\Component\Workflow\DataCollector\WorkflowDataCollector;
23+
use Symfony\Component\Workflow\EventListener\ExpressionLanguage;
24+
use Symfony\Component\Workflow\EventListener\GuardListener;
25+
use Symfony\Component\Workflow\Tests\WorkflowBuilderTrait;
26+
use Symfony\Component\Workflow\Workflow;
27+
28+
class WorkflowDataCollectorTest extends TestCase
29+
{
30+
use WorkflowBuilderTrait;
31+
32+
public function test()
33+
{
34+
$workflow1 = new Workflow($this->createComplexWorkflowDefinition(), name: 'workflow1');
35+
$workflow2 = new Workflow($this->createSimpleWorkflowDefinition(), name: 'workflow2');
36+
$dispatcher = new EventDispatcher();
37+
$dispatcher->addListener('workflow.workflow2.leave.a', fn () => true);
38+
$dispatcher->addListener('workflow.workflow2.leave.a', [self::class, 'noop']);
39+
$dispatcher->addListener('workflow.workflow2.leave.a', [$this, 'noop']);
40+
$dispatcher->addListener('workflow.workflow2.leave.a', $this->noop(...));
41+
$dispatcher->addListener('workflow.workflow2.leave.a', 'var_dump');
42+
$guardListener = new GuardListener(
43+
['workflow.workflow2.guard.t1' => ['my_expression']],
44+
$this->createMock(ExpressionLanguage::class),
45+
$this->createMock(TokenStorageInterface::class),
46+
$this->createMock(AuthorizationCheckerInterface::class),
47+
$this->createMock(AuthenticationTrustResolverInterface::class),
48+
$this->createMock(RoleHierarchyInterface::class),
49+
$this->createMock(ValidatorInterface::class)
50+
);
51+
$dispatcher->addListener('workflow.workflow2.guard.t1', [$guardListener, 'onTransition']);
52+
53+
$collector = new WorkflowDataCollector(
54+
[$workflow1, $workflow2],
55+
$dispatcher,
56+
new FileLinkFormatter(),
57+
);
58+
59+
$collector->lateCollect();
60+
61+
$data = $collector->getWorkflows();
62+
63+
$this->assertArrayHasKey('workflow1', $data);
64+
$this->assertArrayHasKey('dump', $data['workflow1']);
65+
$this->assertStringStartsWith("graph LR\n", $data['workflow1']['dump']);
66+
$this->assertArrayHasKey('listeners', $data['workflow1']);
67+
68+
$this->assertSame([], $data['workflow1']['listeners']);
69+
$this->assertArrayHasKey('workflow2', $data);
70+
$this->assertArrayHasKey('dump', $data['workflow2']);
71+
$this->assertStringStartsWith("graph LR\n", $data['workflow1']['dump']);
72+
$this->assertArrayHasKey('listeners', $data['workflow2']);
73+
$listeners = $data['workflow2']['listeners'];
74+
$this->assertArrayHasKey('place0', $listeners);
75+
$this->assertArrayHasKey('workflow.workflow2.leave.a', $listeners['place0']);
76+
$descriptions = $listeners['place0']['workflow.workflow2.leave.a'];
77+
$this->assertCount(5, $descriptions);
78+
$this->assertStringContainsString('Closure', $descriptions[0]['title']);
79+
$this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[1]['title']);
80+
$this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[2]['title']);
81+
$this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[3]['title']);
82+
$this->assertSame('var_dump()', $descriptions[4]['title']);
83+
$this->assertArrayHasKey('transition0', $listeners);
84+
$this->assertArrayHasKey('workflow.workflow2.guard.t1', $listeners['transition0']);
85+
$this->assertSame('Symfony\Component\Workflow\EventListener\GuardListener::onTransition()', $listeners['transition0']['workflow.workflow2.guard.t1'][0]['title']);
86+
$this->assertSame(['my_expression'], $listeners['transition0']['workflow.workflow2.guard.t1'][0]['guardExpressions']);
87+
}
88+
89+
public static function noop()
90+
{
91+
}
92+
}

composer.json

+2
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@
2626
"require-dev": {
2727
"psr/log": "^1|^2|^3",
2828
"symfony/dependency-injection": "^5.4|^6.0|^7.0",
29+
"symfony/error-handler": "^6.4|^7.0",
2930
"symfony/event-dispatcher": "^5.4|^6.0|^7.0",
3031
"symfony/expression-language": "^5.4|^6.0|^7.0",
32+
"symfony/http-kernel": "^6.4|^7.0",
3133
"symfony/security-core": "^5.4|^6.0|^7.0",
3234
"symfony/validator": "^5.4|^6.0|^7.0"
3335
},

0 commit comments

Comments
 (0)