Appearance
polyspec/fsm-php
fsm의 PHP 소비자(docs/plans/fsm-v1.md, C-2)다. 패키지는 비공개이며 test bed이고 공개하지 않는다. fsm 정의로부터 생성한 PHP 코드를 어떻게 쓰는지 보여주고, 그 코드를 conformance vector로 검사한다. test bench는 namespace Polyspec\FsmTest를 생성한다. 프로젝트는 --php-namespace로 자신의 namespace를 고른다. 기계마다 하위 namespace <ns>\<Pascal machine key>가 생긴다. 예: Polyspec\FsmTest\Order.
프로젝트에 생성
코드의 namespace로 패키지의 소스 디렉터리에 쓴다. composer.json은 그 디렉터리를 PSR-4로 autoload하고, loader src/functions.php를 autoload.files로 읽어야 한다. loader는 모든 기계의 functions.php를 require하므로 files의 한 항목이 모든 기계의 함수를 읽는다:
sh
fsm-gen gen --project /path/to/order-machines --kind php --php-namespace 'Acme\Fsm' --out ./srctext
[fsm-gen] gen: okpackages/php/composer.json의 autoload block은 다음과 같다:
json
{
"autoload": {
"psr-4": {
"Polyspec\\FsmTest\\": "src/"
},
"files": [
"src/functions.php"
]
}
}test bed의 namespace는 Polyspec\FsmTest다. 이 README의 namespace(Acme\Fsm)에서는 composer.json에 "psr-4": { "Acme\\Fsm\\": "src/" }를 쓰고 files 항목은 같은 src/functions.php로 둔다. composer.json을 쓴 뒤 composer dump-autoload를 한 번 실행한다. 생성된 파일은 새 dump를 필요로 하지 않는다.
정의를 바꿀 때마다 명령을 다시 실행한다. vector는 fsm-gen vectors --kind php --php-namespace 'Acme\Fsm' --out <file>로 자신의 test 파일에 쓴다(test bed는 tests/ConformanceTest.php에 쓴다).
생성 파일
| 파일 | 내용 |
|---|---|
src/Fsm/Code.php | enum Code(문자열 기반): Conflict = 'E_CONFLICT', InvalidTransition, UnknownCode, ReservedCode |
src/Fsm/Error.php | 공유 namespace의 예외 Error |
src/Order/State.php, src/Order/Event.php | 기반 enum State: int(값이 code)와 Event: int(위치, 1부터) |
src/Order/Step.php | readonly 속성 to와 changed를 가진 final class Step |
src/Order/functions.php | 기계의 함수, namespace <ns>\Order 안. loader가 require한다 |
src/functions.php | loader: 모든 기계의 functions.php(src/InReview/functions.php, src/Order/functions.php 등)를 require한다. composer.json은 이 파일을 autoload.files에 적는다 |
각 파일은 // Code generated by fsm-gen from <machine>; DO NOT EDIT.로 시작하며 strict_types를 선언한다.
API
namespace Polyspec\FsmTest\Order의 함수(src/Order/functions.php)는 enum case를 받는다:
| 함수 | 결과 |
|---|---|
initial(): State | 초기 상태 |
code(State $state): int | 상태의 code |
key(State $state): string | 상태의 key, 예: draft |
fromCode(mixed $value): State | code의 상태. 정수가 아니거나 상태가 아닌 값은 E_UNKNOWN_CODE를 던진다 |
isFinal(State $state): bool | 상태가 최종 상태인지 |
eventsFrom(State $state): array | 상태에서 edge가 있는 event, 선언 순서 |
target(State $expected, Event $event): State | event가 이르는 상태. edge가 없으면 E_INVALID_TRANSITION을 던진다 |
apply(State $expected, Event $event, State $actual): Step | 한 번 시도의 결과(루트 README의 전이 절 참고) |
영속화 예제 examples/persist.php는 transaction 안에서 apply를 완전하게 쓰는 예이며, 따라 할 패턴이다(영속화 절). database를 거치지 않는 순수 호출은 다음과 같다:
php
<?php
use Acme\Fsm\Order\State;
use Acme\Fsm\Order\Event;
use Acme\Fsm\Order;
use Acme\Fsm\Fsm\Error;
$step = Order\apply(State::Draft, Event::Next, State::Draft);
echo $step->to->name, ' ', var_export($step->changed, true), "\n";
try {
Order\apply(State::Draft, Event::Next, State::Shipped);
} catch (Error $error) {
echo $error->getCode(), ' ', $error->expected, ' ', $error->actual, ' ', $error->event, "\n";
}이 program은 composer dump-autoload를 거친 임시 프로젝트와 이 checkout의 생성 파일로 실행했다. 출력은 다음과 같다:
text
Paid true
E_CONFLICT 1 3 1오류
함수는 Polyspec\FsmTest\Fsm\Error(여기서는 Acme\Fsm\Fsm\Error)를 던진다. \Exception의 하위 class다:
| 멤버 | 값 |
|---|---|
getCode() | code 문자열: E_CONFLICT, E_INVALID_TRANSITION, E_UNKNOWN_CODE 또는 E_RESERVED_CODE. PHP가 Exception의 protected $code를 정수로 두므로 enum Code의 값을 쓴다 |
getMessage() | 기계 이름을 담은 메시지 |
expected | 기대한 상태 code(?int). 없으면 null |
actual | 저장된 상태 code(?int). 없으면 null |
event | event 위치(?int, 1부터). 없으면 null |
Error를 잡아 getCode()와 세 속성을 읽는다.
속성이 어떤 code에서 설정되는지는 명세 B-6(docs/spec/fsm.md)의 규칙이며, tests/ConformanceTest.php의 vector가 이를 단언한다:
| Code | expected | actual | event |
|---|---|---|---|
E_CONFLICT | 설정됨 | 설정됨 | 설정됨 |
E_INVALID_TRANSITION | 설정됨 | null | 설정됨 |
E_UNKNOWN_CODE, E_RESERVED_CODE | null | null | null |
영속화
examples/persist.php는 SQLite, PostgreSQL, MySQL과 polyspec orm 위에서 docs/spec/fsm.md, B-4의 패턴이다. 저장된 상태는 row의 code다. 전이는 한 transaction 안에서 실행한다. row를 잠금(forUpdate()) 아래 읽고, event를 호출자가 관찰한 상태에 적용하며, step이 changed일 때만 row를 갱신한다:
php
// packages/php/examples/persist.php, transition
function transition(Db $db, string $module, int $id, \UnitEnum $expected, \UnitEnum $event): object
{
return $db->transaction(static function () use ($db, $module, $id, $expected, $event): object {
$row = (new Item())->connect($db)->id($id)->forUpdate()->get();
$apply = $module . '\apply';
$fromCode = $module . '\fromCode';
$code = $module . '\code';
$step = $apply($expected, $event, $fromCode($row->getStatus()));
if ($step->changed) {
$row->setStatus($code($step->to))->update();
}
return $step;
});
}$module은 기계의 namespace(예: Polyspec\FsmTest\Order)이므로, 같은 함수가 모든 기계에 쓰인다. apply의 오류는 transaction 안에서 던져져 transaction을 되돌린다. 같은 $expected로 다시 시도하면 변화 없는 step이 되어 아무것도 쓰지 않는다. 같은 파일의 open, seed, statusOf는 table을 만들고 row를 읽는다. 모델 Item은 orm-gen이 contracts/schema/persist.dbs로부터 examples/model/에 생성하며, database를 열 때 examples/model/bootstrap.php를 읽는다.
테스트
checkout에서 실행한다:
sh
make -C packages/php install # composer install: the pinned orm release and PHPUnit 13.4.2 (composer.lock)
make -C packages/php test # PHPUnit through kit's run-tests
make test-php # the same from the root
make php-persist-test-postgres # the persist cases on PostgreSQL (FSM_TEST_PG_DSN; a missing DSN exits 2)
make php-persist-test-mysql # the persist cases on MySQL (FSM_TEST_MYSQL_DSN)실행 결과는 89개 case 중 89개 통과다. fsm-gen vectors --kind php가 생성하는 tests/ConformanceTest.php는 PHPUnit test testConformance 하나를 vector case마다 이름이 <feature>__<id>인 data set으로 실행하며, 각 data set은 손으로 쓴 tests/ConformanceCase.php의 검사를 실행한다. 영속화 case는 SQLite에서 examples/persist.php를 실행하고, make php-persist-test-postgres와 make php-persist-test-mysql은 PostgreSQL과 MySQL에서 실행한다. 루트의 make gen-check는 생성 파일이 fsm-gen이 지금 쓰는 것과 같은지 검사한다. checkout의 PHP 릴리스는 8.5(config/toolchain.json)이며, composer.json의 config.platform.php는 composerPlatforms(config/dependency-policy.json)의 정밀도로 쓴 kit의 PHP pin 8.5.0이고, require.php는 >=8.5다.