Skip to content

polyspec/fsm-php ​

English

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 ./src
text
[fsm-gen] gen: ok

packages/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.phpenum 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.phpreadonly 속성 to와 changed를 가진 final class Step
src/Order/functions.php기계의 함수, namespace <ns>\Order 안. loader가 require한다
src/functions.phploader: 모든 기계의 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): Statecode의 상태. 정수가 아니거나 상태가 아닌 값은 E_UNKNOWN_CODE를 던진다
isFinal(State $state): bool상태가 최종 상태인지
eventsFrom(State $state): array상태에서 edge가 있는 event, 선언 순서
target(State $expected, Event $event): Stateevent가 이르는 상태. 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
eventevent 위치(?int, 1부터). 없으면 null

Error를 잡아 getCode()와 세 속성을 읽는다.

속성이 어떤 code에서 설정되는지는 명세 B-6(docs/spec/fsm.md)의 규칙이며, tests/ConformanceTest.php의 vector가 이를 단언한다:

Codeexpectedactualevent
E_CONFLICT설정됨설정됨설정됨
E_INVALID_TRANSITION설정됨null설정됨
E_UNKNOWN_CODE, E_RESERVED_CODEnullnullnull

영속화 ​

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다.