├── .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 | Code Coverage 8 | StyleCI 9 | 10 | 11 | 12 |

13 | 14 | ### Features 15 | - 基于 Stateful 对象绑定 Graph 16 | - Transition 事件监听 17 | - 便捷的 Transition Checker 18 | - 以上都在瞎扯淡 19 | 20 | ![](http://oupjptv0d.bkt.gdipper.com//heshen/fsm.png) 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 | --------------------------------------------------------------------------------