├── .gitignore
├── CHANGELOG.md
├── src
├── Exceptions
│ ├── StateNotFoundException.php
│ ├── LogicException.php
│ ├── SetStateFailedException.php
│ └── TransitionNotFoundException.php
├── Support
│ ├── StateEvents.php
│ └── Str.php
├── Contracts
│ └── StatefulInterface.php
├── Factory.php
├── Event
│ └── Event.php
├── State.php
├── Transition.php
├── Machine.php
└── Blueprint.php
├── .travis.yml
├── tests
├── Fixture
│ ├── AlphaBlueprint.php
│ ├── BetaBlueprint.php
│ ├── AlphaStateful.php
│ └── BetaStateful.php
├── StrTest.php
├── FactoryTest.php
├── StateTest.php
├── TransitionTest.php
├── BlueprintTest.php
└── MachineTest.php
├── phpunit.xml
├── composer.json
├── .scrutinizer.yml
├── .php_cs
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | composer.phar
2 | vendor
3 | .idea
4 | composer.lock
5 | example
6 | .php_cs.cache
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # ChangeLog for Heshen
2 |
3 | ## [1.0.2] - 2018-04-08
4 | ### Changed
5 | - transition 允许添加多个 from state.
6 |
7 | ## [1.0.3] - 2018-07-18
8 | ### Changed
9 | - 当设置状态后获取状态为未切换时抛出异常
--------------------------------------------------------------------------------
/src/Exceptions/StateNotFoundException.php:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 | tests
8 |
9 |
10 |
11 |
12 | src
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/Support/Str.php:
--------------------------------------------------------------------------------
1 | assertSame('prePost', Str::studly('pre_post'));
17 | $this->assertSame('prePost', Str::studly('pre-post'));
18 | $this->assertSame('prePost', Str::studly('pre post'));
19 | $this->assertSame('prePost', Str::studly('prePost'));
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.scrutinizer.yml:
--------------------------------------------------------------------------------
1 | filter:
2 | paths:
3 | - 'src/*'
4 | excluded_paths:
5 | - 'tests/*'
6 | - 'example/*'
7 | dependency_paths:
8 | - 'vendor/'
9 |
10 | checks:
11 | php:
12 | remove_extra_empty_lines: true
13 | remove_php_closing_tag: true
14 | remove_trailing_whitespace: true
15 | fix_use_statements:
16 | remove_unused: true
17 | preserve_multiple: false
18 | preserve_blanklines: true
19 | order_alphabetically: true
20 | fix_php_opening_tag: true
21 | fix_linefeed: true
22 | fix_line_ending: true
23 | fix_identation_4spaces: true
24 | fix_doc_comments: true
25 |
26 | tools:
27 | external_code_coverage:
28 | timeout: 600
29 | runs: 1
--------------------------------------------------------------------------------
/.php_cs:
--------------------------------------------------------------------------------
1 | setRiskyAllowed(true)
10 | ->setRules([
11 | '@Symfony' => true,
12 | 'no_unused_imports' => true,
13 | 'no_singleline_whitespace_before_semicolons' => true,
14 | 'no_empty_statement' => true,
15 | 'include' => true,
16 | 'no_leading_namespace_whitespace' => true,
17 | 'single_quote' => true,
18 | 'array_syntax' => [
19 | 'syntax' => 'short',
20 | ],
21 | 'ordered_imports' => [
22 | 'sort_algorithm' => 'length',
23 | ],
24 | 'concat_space' => [
25 | 'spacing' => 'one',
26 | ],
27 | ])
28 | ->setFinder(PhpCsFixer\Finder::create()->exclude('vendor')->in([
29 | __DIR__ . '/src',
30 | __DIR__ . '/tests',
31 | ]));
--------------------------------------------------------------------------------
/src/Factory.php:
--------------------------------------------------------------------------------
1 | loader = $loader;
32 | }
33 |
34 | /**
35 | * @param StatefulInterface $stateful
36 | *
37 | * @return Machine
38 | */
39 | public function make(StatefulInterface $stateful): Machine
40 | {
41 | $blueprint = $this->loader[get_class($stateful)];
42 |
43 | if (!array_key_exists($blueprint, $this->blueprints)) {
44 | $this->blueprints[$blueprint] = new $blueprint();
45 | }
46 |
47 | return new Machine($stateful, $this->blueprints[$blueprint]);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Event/Event.php:
--------------------------------------------------------------------------------
1 | stateful = $stateful;
34 | $this->parameters = $parameters;
35 | }
36 |
37 | /**
38 | * @return StatefulInterface
39 | */
40 | public function getStateful(): StatefulInterface
41 | {
42 | return $this->stateful;
43 | }
44 |
45 | /**
46 | * @return array
47 | */
48 | public function getParameters(): array
49 | {
50 | return $this->parameters;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/tests/FactoryTest.php:
--------------------------------------------------------------------------------
1 | AlphaBlueprint::class,
27 | BetaStateful::class => BetaBlueprint::class,
28 | ]);
29 |
30 | $this->assertSame(
31 | AlphaBlueprint::class,
32 | get_class($factory->make(new AlphaStateful())->getBlueprint())
33 | );
34 | $this->assertSame(
35 | BetaBlueprint::class,
36 | get_class($factory->make(new BetaStateful())->getBlueprint())
37 | );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/StateTest.php:
--------------------------------------------------------------------------------
1 | assertSame(true, $state->isInitial());
18 | $this->assertSame(false, $state->isNormal());
19 | $this->assertSame(false, $state->isFinal());
20 | }
21 |
22 | public function testIsNormal()
23 | {
24 | $state = new State('a', State::TYPE_NORMAL);
25 | $this->assertSame(false, $state->isInitial());
26 | $this->assertSame(true, $state->isNormal());
27 | $this->assertSame(false, $state->isFinal());
28 | }
29 |
30 | public function testIsFinal()
31 | {
32 | $state = new State('a', State::TYPE_FINAL);
33 | $this->assertSame(false, $state->isInitial());
34 | $this->assertSame(false, $state->isNormal());
35 | $this->assertSame(true, $state->isFinal());
36 | }
37 |
38 | public function testGetType()
39 | {
40 | $state = new State('a', State::TYPE_FINAL);
41 | $this->assertSame(State::TYPE_FINAL, $state->getType());
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/State.php:
--------------------------------------------------------------------------------
1 | name = $name;
37 | $this->type = $type;
38 | }
39 |
40 | /**
41 | * @return string
42 | */
43 | public function getName(): string
44 | {
45 | return $this->name;
46 | }
47 |
48 | /**
49 | * @return string
50 | */
51 | public function getType(): string
52 | {
53 | return $this->type;
54 | }
55 |
56 | /**
57 | * @return bool
58 | */
59 | public function isInitial(): bool
60 | {
61 | return self::TYPE_INITIAL === $this->type;
62 | }
63 |
64 | /**
65 | * @return bool
66 | */
67 | public function isNormal(): bool
68 | {
69 | return self::TYPE_NORMAL === $this->type;
70 | }
71 |
72 | /**
73 | * @return bool
74 | */
75 | public function isFinal(): bool
76 | {
77 | return self::TYPE_FINAL === $this->type;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/Transition.php:
--------------------------------------------------------------------------------
1 | name = $name;
45 | $this->fromStates = !is_array($from) ? [$from] : $from;
46 | $this->toState = $to;
47 | $this->checker = $checker;
48 | }
49 |
50 | /**
51 | * @return State[]
52 | */
53 | public function getFromStates(): array
54 | {
55 | return $this->fromStates;
56 | }
57 |
58 | /**
59 | * @return State
60 | */
61 | public function getToState(): State
62 | {
63 | return $this->toState;
64 | }
65 |
66 | /**
67 | * @param StatefulInterface $stateful
68 | * @param array $parameters
69 | *
70 | * @return bool
71 | */
72 | public function can(StatefulInterface $stateful, array $parameters = []): bool
73 | {
74 | foreach ($this->fromStates as $state) {
75 | if ($state->getName() === $stateful->getState()) {
76 | if (!is_null($this->checker) && !(bool) call_user_func($this->checker, $stateful, $parameters)) {
77 | return false;
78 | }
79 |
80 | return true;
81 | }
82 | }
83 |
84 | return false;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/tests/TransitionTest.php:
--------------------------------------------------------------------------------
1 | assertSame('a', $transition->getFromStates()[0]->getName());
25 | $this->assertSame(true, $transition->getFromStates()[0]->isInitial());
26 | $this->assertSame('b', $transition->getToState()->getName());
27 | $this->assertSame(true, $transition->getToState()->isNormal());
28 | }
29 |
30 | public function testCan()
31 | {
32 | $transition = new Transition(
33 | 'one',
34 | new State('a', State::TYPE_INITIAL),
35 | new State('b', State::TYPE_NORMAL)
36 | );
37 |
38 | $stateful = new class() implements StatefulInterface {
39 | protected $state = 'a';
40 |
41 | public function getState(): string
42 | {
43 | return $this->state;
44 | }
45 |
46 | public function setState(string $state): void
47 | {
48 | $this->state = $state;
49 | }
50 | };
51 |
52 | $this->assertSame(true, $transition->can($stateful));
53 | $stateful->setState('b');
54 | $this->assertSame(false, $transition->can($stateful));
55 | $stateful->setState('a');
56 |
57 | $transition = new Transition(
58 | 'one',
59 | new State('a', State::TYPE_INITIAL),
60 | new State('b', State::TYPE_NORMAL),
61 | function (StatefulInterface $stateful, array $parameters) {
62 | if (!isset($parameters['random'])) {
63 | return false;
64 | }
65 |
66 | return $parameters['random'] > 5;
67 | }
68 | );
69 |
70 | $this->assertSame(false, $transition->can($stateful));
71 | $this->assertSame(false, $transition->can($stateful, [
72 | 'random' => 4,
73 | ]));
74 | $this->assertSame(true, $transition->can($stateful, [
75 | 'random' => 6,
76 | ]));
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/Machine.php:
--------------------------------------------------------------------------------
1 | stateful = $stateful;
37 | $this->blueprint = $blueprint;
38 | }
39 |
40 | /**
41 | * @return string
42 | */
43 | public function getCurrentState(): string
44 | {
45 | return $this->stateful->getState();
46 | }
47 |
48 | /**
49 | * @param $transitionName
50 | * @param array $parameters
51 | *
52 | * @return bool
53 | */
54 | public function can($transitionName, array $parameters = []): bool
55 | {
56 | return $this
57 | ->blueprint
58 | ->getTransition($transitionName)
59 | ->can($this->stateful, $parameters);
60 | }
61 |
62 | /**
63 | * @param $transitionName
64 | * @param array $parameters
65 | */
66 | public function apply($transitionName, array $parameters = []): void
67 | {
68 | if (!$this->can($transitionName, $parameters)) {
69 | throw new LogicException(sprintf(
70 | 'The "%s" transition can not be applied to the "%s" state of object "%s"',
71 | $transitionName,
72 | $this->stateful->getState(),
73 | get_class($this->stateful)
74 | ));
75 | }
76 |
77 | $this->dispatchEvent(StateEvents::PRE_TRANSITION . $transitionName, $parameters);
78 |
79 | $transition = $this->blueprint->getTransition($transitionName);
80 |
81 | $this->stateful->setState($transition->getToState()->getName());
82 |
83 | if ($this->stateful->getState() !== $transition->getToState()->getName()) {
84 | throw new SetStateFailedException(sprintf(
85 | 'Failed to set the "%s" state for object "%s"',
86 | $transition->getToState()->getName(),
87 | get_class($this->stateful)
88 | ));
89 | }
90 |
91 | $this->dispatchEvent(StateEvents::POST_TRANSITION . $transitionName, $parameters);
92 | }
93 |
94 | /**
95 | * @return Blueprint
96 | */
97 | public function getBlueprint(): Blueprint
98 | {
99 | return $this->blueprint;
100 | }
101 |
102 | /**
103 | * @param $event
104 | * @param array $parameters
105 | */
106 | protected function dispatchEvent($event, array $parameters = []): void
107 | {
108 | $this->blueprint->getDispatcher()->dispatch($event, new Event($this->stateful, $parameters));
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/tests/BlueprintTest.php:
--------------------------------------------------------------------------------
1 | blueprint = new class() extends Blueprint {
28 | protected function configure(): void
29 | {
30 | $this->addState('a', State::TYPE_INITIAL);
31 | $this->addState('b', State::TYPE_NORMAL);
32 | $this->addState('c', State::TYPE_FINAL);
33 | $this->addState('d', State::TYPE_FINAL);
34 |
35 | $this->addTransition('one', 'a', 'b');
36 | $this->addTransition('two', 'b', 'c');
37 | $this->addTransition('three', 'c', 'd');
38 | }
39 |
40 | protected function preOne(StatefulInterface $stateful, array $parameters)
41 | {
42 | }
43 |
44 | protected function postOne(StatefulInterface $stateful, array $parameters)
45 | {
46 | }
47 | };
48 | }
49 |
50 | public function testGetTransitionAndGetState()
51 | {
52 | $transition = $this->blueprint->getTransition('one');
53 | $this->assertSame(
54 | $this->blueprint->getState('a'),
55 | $transition->getFromStates()[0]
56 | );
57 | $this->assertSame(
58 | $this->blueprint->getState('b'),
59 | $transition->getToState()
60 | );
61 | }
62 |
63 | public function testGetDispatcher()
64 | {
65 | $this->assertSame(
66 | true,
67 | $this->blueprint->getDispatcher()->hasListeners(StateEvents::PRE_TRANSITION . 'one')
68 | );
69 | $this->assertSame(
70 | true,
71 | $this->blueprint->getDispatcher()->hasListeners(StateEvents::POST_TRANSITION . 'one')
72 | );
73 | $this->assertSame(
74 | false,
75 | $this->blueprint->getDispatcher()->hasListeners(StateEvents::PRE_TRANSITION . 'two')
76 | );
77 | $this->assertSame(
78 | false,
79 | $this->blueprint->getDispatcher()->hasListeners(StateEvents::POST_TRANSITION . 'two')
80 | );
81 | }
82 |
83 | public function testDeclareBlueprintWithoutConfigure()
84 | {
85 | $this->expectException(LogicException::class);
86 | $this->expectExceptionMessage('you must overwrite the configure method in the concrete blueprint class');
87 |
88 | new class() extends Blueprint {
89 | };
90 | }
91 |
92 | public function testGetNotExistState()
93 | {
94 | $this->expectException(StateNotFoundException::class);
95 |
96 | $this->blueprint->getState('hello');
97 | }
98 |
99 | public function testGetNotExistTransition()
100 | {
101 | $this->expectException(TransitionNotFoundException::class);
102 |
103 | $this->blueprint->getTransition('world');
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Heshen
2 |
3 | Finite-state Machine In PHP
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | ### Features
15 | - 基于 Stateful 对象绑定 Graph
16 | - Transition 事件监听
17 | - 便捷的 Transition Checker
18 | - 以上都在瞎扯淡
19 |
20 | 
21 |
22 | ### Documentation
23 |
24 | none
25 |
26 | ### Usage
27 |
28 | 先定义 Stateful 对象
29 |
30 | ```php
31 | state;
41 | }
42 |
43 | public function setState(string $state): void
44 | {
45 | echo "\nsetting\n";
46 | $this->state = $state;
47 | }
48 | }
49 |
50 | ```
51 |
52 | 然后定义一个 Blueprint 来配置 Transition 及 State
53 | ```php
54 | addState('a', State::TYPE_INITIAL);
64 | $this->addState('b', State::TYPE_NORMAL);
65 | $this->addState('c', State::TYPE_NORMAL);
66 | $this->addState('d', State::TYPE_FINAL);
67 |
68 | $this->addTransition('one', 'a', 'b');
69 | $this->addTransition('two', 'b', 'c', function (StatefulInterface $stateful, array $parameters) {
70 | return ($parameters['number'] ?? 0) > 5;
71 | });
72 | }
73 |
74 | protected function preOne(StatefulInterface $stateful, array $parameters = [])
75 | {
76 | echo "before apply transition 'one'\n";
77 | }
78 |
79 | protected function postOne(StatefulInterface $stateful, array $parameters = [])
80 | {
81 | echo "after apply transition 'one'\n";
82 | }
83 | }
84 | ```
85 |
86 | 开始使用!
87 | ```php
88 | can('one')); // output: bool(true)
95 | var_dump($machine->can('two')); // output: bool(false)
96 |
97 | $machine->apply('one');
98 | /*
99 | * output:
100 | * before apply transition 'one'
101 | * after apply transition 'one'
102 | */
103 |
104 | var_dump($machine->can('two', ['number' => 1])); // output: bool(false)
105 | var_dump($machine->can('two', ['number' => 6])); // output: bool(true)
106 |
107 | ```
108 |
109 | 通过 Factory 获取 Machine
110 | ```php
111 | Graph::class,
117 | ]);
118 |
119 | $document = new Document;
120 |
121 | $machine = $factory->make($document);
122 |
123 | var_dump($machine->can('one')); // output: bool(true)
124 | ```
125 |
126 | ### Tips
127 | 在实现 `StatefulInterface::setState()` 时, 如有需要, 应当使用锁避免冲突.
--------------------------------------------------------------------------------
/tests/MachineTest.php:
--------------------------------------------------------------------------------
1 | state;
36 | }
37 |
38 | public function setState(string $state): void
39 | {
40 | if ('z' !== $state) {
41 | $this->state = $state;
42 | }
43 | }
44 |
45 | public function addDemo($number)
46 | {
47 | $this->demo += $number;
48 | }
49 |
50 | public function getDemo()
51 | {
52 | return $this->demo;
53 | }
54 | };
55 |
56 | $blueprint = new class() extends Blueprint {
57 | protected function configure(): void
58 | {
59 | $this->addState('a', State::TYPE_INITIAL);
60 | $this->addState('b', State::TYPE_NORMAL);
61 | $this->addState('c', State::TYPE_FINAL);
62 | $this->addState('d', State::TYPE_FINAL);
63 | $this->addState('z', State::TYPE_FINAL);
64 |
65 | $this->addTransition('one', 'a', 'b');
66 | $this->addTransition('two', 'b', 'c');
67 | $this->addTransition('three', 'c', 'd');
68 |
69 | $this->addTransition('four', 'a', 'z');
70 | }
71 |
72 | protected function preOne(StatefulInterface $stateful, array $parameters)
73 | {
74 | $stateful->addDemo(1);
75 | }
76 |
77 | protected function postOne(StatefulInterface $stateful, array $parameters)
78 | {
79 | $stateful->addDemo(2);
80 | }
81 | };
82 |
83 | $this->machine = new Machine($stateful, $blueprint);
84 | $this->object = $stateful;
85 | }
86 |
87 | public function testGetCurrentState()
88 | {
89 | $this->assertSame('a', $this->machine->getCurrentState());
90 | }
91 |
92 | public function testCan()
93 | {
94 | $this->assertSame(true, $this->machine->can('one'));
95 | $this->assertSame(false, $this->machine->can('two'));
96 | $this->assertSame(false, $this->machine->can('three'));
97 | }
98 |
99 | public function testApply()
100 | {
101 | $this->machine->apply('one');
102 | $this->assertSame('b', $this->machine->getCurrentState());
103 | $this->assertSame(false, $this->machine->can('one'));
104 | $this->assertSame(true, $this->machine->can('two'));
105 | $this->assertSame(false, $this->machine->can('three'));
106 |
107 | $this->assertSame(4, $this->object->getDemo());
108 | }
109 |
110 | public function testApplyWrongTransition()
111 | {
112 | $this->expectException(LogicException::class);
113 | $this->machine->apply('two');
114 | }
115 |
116 | public function testSaveStateFail()
117 | {
118 | $this->expectException(SetStateFailedException::class);
119 | $this->machine->apply('four');
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/Blueprint.php:
--------------------------------------------------------------------------------
1 | dispatcher = new EventDispatcher();
42 |
43 | $this->configure();
44 | }
45 |
46 | /**
47 | * @param $name
48 | *
49 | * @return Transition
50 | */
51 | public function getTransition(string $name): Transition
52 | {
53 | if (array_key_exists($name, $this->transitions)) {
54 | return $this->transitions[$name];
55 | }
56 |
57 | throw new TransitionNotFoundException($name);
58 | }
59 |
60 | /**
61 | * @param $name
62 | *
63 | * @return State
64 | */
65 | public function getState(string $name): State
66 | {
67 | if (array_key_exists($name, $this->states)) {
68 | return $this->states[$name];
69 | }
70 |
71 | throw new StateNotFoundException($name);
72 | }
73 |
74 | /**
75 | * @return EventDispatcher
76 | */
77 | public function getDispatcher(): EventDispatcher
78 | {
79 | return $this->dispatcher;
80 | }
81 |
82 | /**
83 | * @param string $name
84 | * @param string $type
85 | *
86 | * @return Blueprint
87 | */
88 | protected function addState(string $name, string $type): self
89 | {
90 | $this->states[$name] = new State($name, $type);
91 |
92 | return $this;
93 | }
94 |
95 | /**
96 | * @param string $name
97 | * @param string|array $from
98 | * @param string $to
99 | * @param null $checker
100 | *
101 | * @return $this
102 | */
103 | protected function addTransition(string $name, $from, string $to, $checker = null): self
104 | {
105 | $from = (array) $from;
106 | $fromStates = array_map(function ($state) {
107 | return $this->getState($state);
108 | }, $from);
109 | $this->transitions[$name] = new Transition(
110 | $name,
111 | $fromStates,
112 | $this->getState($to),
113 | $checker
114 | );
115 |
116 | $preMethod = Str::studly("pre{$name}");
117 | $postMethod = Str::studly("post{$name}");
118 |
119 | if (method_exists($this, $preMethod)) {
120 | $this->dispatcher->addListener(
121 | StateEvents::PRE_TRANSITION . $name,
122 | $this->eventListener($preMethod)
123 | );
124 | }
125 |
126 | if (method_exists($this, $postMethod)) {
127 | $this->dispatcher->addListener(
128 | StateEvents::POST_TRANSITION . $name,
129 | $this->eventListener($postMethod)
130 | );
131 | }
132 |
133 | return $this;
134 | }
135 |
136 | /**
137 | * @param $method
138 | *
139 | * @return Closure
140 | */
141 | protected function eventListener($method): Closure
142 | {
143 | return function (Event $event) use ($method) {
144 | return call_user_func([$this, $method], $event->getStateful(), $event->getParameters());
145 | };
146 | }
147 |
148 | protected function configure(): void
149 | {
150 | throw new LogicException('you must overwrite the configure method in the concrete blueprint class');
151 | }
152 | }
153 |
--------------------------------------------------------------------------------