Appearance
github.com/polyspec/fsm/packages/go
fsm의 go 소비자(docs/plans/fsm-v1.md, C-2)다. 모듈은 비공개이며 test bed이고 공개하지 않는다. fsm 정의로부터 생성한 go 코드를 어떻게 쓰는지 보여주고, 그 코드를 conformance vector로 검사한다. src/의 생성 코드는 표준 라이브러리만 import하며, orm 패키지는 영속화 예제에만 필요하다.
프로젝트에 생성
module example.com/app처럼 go.mod가 있는 프로젝트의 module 안에서 생성기를 실행한다. --out은 그 module의 src 디렉터리다. 각 기계는 공유 Error 타입을 위해 <module>/src/fsm을 import하기 때문이다.
sh
cd /path/to/app
fsm-gen gen --project /path/to/order-machines --kind go --go-module example.com/app --out ./srctext
[fsm-gen] gen: ok생성된 파일은 손으로 고치지 않는다. 정의를 바꿀 때마다 명령을 다시 실행한다.
vector는 fsm-gen vectors --kind go로 쓴다. 이 명령도 package의 module 경로를 받는다(gen과 같이 --go-module이 필요하다):
sh
fsm-gen vectors --project /path/to/order-machines --kind go --go-module example.com/app --out ./tests/vectors_test.gotext
[fsm-gen] vectors: ok파일 tests/vectors_test.go는 package tests에 속한다. 기계마다 key 표, case마다 vectorCase literal 하나, 그리고 case의 key를 기계의 상수에 대응시키는 adapter 하나를 담는다. 이 파일의 import는 <module>/src/<machine> machine package뿐이며 사용자 module 경로로 이름이 붙으므로, 파일은 사용자 module에서 그대로 빌드된다. persistence 예제는 test bed의 것이다: decide는 Decide가 이름 붙이던 함수 타입을 반환하므로 파일에 examples import가 필요 없다(plan C-2, S-26-1-1).
생성 파일
| 파일 | 내용 |
|---|---|
src/fsm/fsm.go | package fsm: Error 타입과 오류 code 상수 CodeConflict, CodeInvalidTransition, CodeUnknownCode, CodeReservedCode(값은 E_CONFLICT 등) |
src/order/order.go | 기계마다 하나인 package order: 타입 State(int32, code), Event(int32, 1부터의 위치)와 Step, 상수 StateDraft…와 EventNext…, 기계의 함수 |
각 파일은 // Code generated by fsm-gen from <machine>; DO NOT EDIT.로 시작하므로 fsm-gen gen이 자신의 파일과 구별한다.
API
예제 기계의 package order는 다음 함수를 가진다(src/order/order.go).
| 함수 | 결과 |
|---|---|
Initial() State | 초기 상태 |
Code(state State) (int32, error) | 상태의 code |
Key(state State) (string, error) | 상태의 key |
FromCode(value int64) (State, error) | code의 상태. 인자는 int64이므로 database에서 읽은 값을 그대로 넘긴다 |
IsFinal(state State) (bool, error) | 상태가 최종 상태인지 |
EventsFrom(state State) ([]Event, error) | 상태에서 edge가 있는 event, 선언 순서 |
Target(expected State, event Event) (State, error) | event가 이르는 상태. edge가 없으면 E_INVALID_TRANSITION |
Apply(expected State, event Event, actual State) (Step, error) | 한 번 시도의 결과(루트 README의 전이 절 참고) |
Step은 To State와 Changed bool 필드를 가진다. 각 함수는 결과와 error를 돌려준다. error는 nil이거나 *fsm.Error다.
영속화 예제 packages/go/examples/persist.go는 transaction 안에서 Apply를 완전하게 쓰는 예이며, 따라 할 패턴이다(영속화 절). database를 거치지 않는 순수 호출은 다음과 같다:
go
step, err := order.Apply(order.StateDraft, order.EventNext, order.StateDraft)
// step == order.Step{To: order.StatePaid, Changed: true}, err == nil이 snippet은 이 checkout의 생성 파일을 쓴 임시 module에서 실행했다. 출력은 다음과 같다:
text
{2 true} <nil>
{2 false} <nil>
E_CONFLICT 1 3 1세 줄은 각각 Apply(draft, next, draft), Apply(draft, next, paid)(이미 도달한 상태: Unchanged), Apply(draft, next, shipped)(충돌. *fsm.Error의 Code, Expected, Actual, Event 필드를 출력했다)이다.
오류
모든 오류는 error이며, 실제 값은 *fsm.Error다(src/fsm/fsm.go).
| 필드 | 타입 | 뜻 |
|---|---|---|
Code | string | E_CONFLICT, E_INVALID_TRANSITION, E_UNKNOWN_CODE 또는 E_RESERVED_CODE |
Message | string | 기계 이름을 담은 메시지. Error()가 돌려준다 |
Expected | int32 | 기대한 상태 code. 없으면 0 |
Actual | int32 | 저장된 상태 code. 없으면 0 |
Event | int32 | event 위치(1부터). 없으면 0 |
필드는 errors.As로 읽는다. Code 값은 fsm의 상수다: fsm.CodeConflict 등. 0인 필드는 설정되지 않은 값이다. 상태 code는 최소 1이고 event 위치는 1부터 시작하기 때문이다.
필드가 어떤 code에서 설정되는지는 명세 B-6(docs/spec/fsm.md)의 규칙이며, tests/vectors_test.go의 vector가 이를 단언한다:
| Code | Expected | Actual | Event |
|---|---|---|---|
E_CONFLICT | 설정됨 | 설정됨 | 설정됨 |
E_INVALID_TRANSITION | 설정됨 | 0(설정되지 않음) | 설정됨 |
E_UNKNOWN_CODE, E_RESERVED_CODE | 0 | 0 | 0 |
메시지는 기계 이름을 담으며, E_UNKNOWN_CODE나 E_RESERVED_CODE의 값은 메시지 안에 있다(order: 99 is not a state of order). Go에는 null이 없으므로 0 값이 설정되지 않은 필드다.
영속화
packages/go/examples/persist.go는 SQLite, PostgreSQL, MySQL과 polyspec orm 위에서 docs/spec/fsm.md, B-4의 패턴이다. 저장된 상태는 row의 code다. Transition은 한 transaction 안에서 시도 한 번을 실행한다. row를 잠금 아래 읽고, 저장된 code를 decide에 넘기며, decide가 변경을 알릴 때만 row를 쓴다. Decide는 func(stored int64) (to int32, changed bool, err error)이므로 이 파일은 모든 기계에 쓰인다. 기계의 decide는 관찰한 상태와 event에 대한 Apply를 저장된 code의 FromCode에 적용하는 것이다. 기계마다의 decide는 tests/vectors_test.go에 쓰인다. order 기계의 것은 다음과 같다:
go
// packages/go/tests/vectors_test.go, orderMachine.decide
func (m orderMachine) decide(expected, event string) func(stored int64) (int32, bool, error) {
return func(stored int64) (int32, bool, error) {
from, err := lookup(orderStates, "state", expected)
if err != nil {
return 0, false, err
}
value, err := lookup(orderEvents, "event", event)
if err != nil {
return 0, false, err
}
observed, err := order.FromCode(stored)
if err != nil {
return 0, false, err
}
step, err := order.Apply(from, value, observed)
if err != nil {
return 0, false, err
}
code, err := order.Code(step.To)
return code, step.Changed, err
}
}영속화 예제의 transaction 본체는 이 함수다:
go
// packages/go/examples/persist.go, Transition
// Transition is one attempt on the row id: the row is read under its lock, decide runs on the stored code, and the row is updated
// only when the stored state changed. It returns the code of the target and whether the stored state changed. An error of decide
// (E_CONFLICT, E_INVALID_TRANSITION or E_UNKNOWN_CODE) is returned, and the transaction rolls back, so the stored state stays as it
// was.
func Transition(db *orm.DB, id int64, decide Decide) (int32, bool, error) {
var to int32
var changed bool
err := db.Transaction(func() error {
row, err := model.Item().Connect(db).ForUpdate().GetById(id)
if err != nil {
return err
}
to, changed, err = decide(int64(row.GetStatus()))
if err != nil {
return err
}
if !changed {
return nil
}
_, err = row.SetStatus(to).Update()
return err
})
if err != nil {
return 0, false, err
}
return to, changed, nil
}decide의 오류(Apply의 오류)는 callback에서 돌아오므로 transaction이 되돌아가고 저장된 상태는 그대로 남는다. 같은 expected로 다시 시도하면 changed가 false가 되어 아무것도 쓰지 않는다. 모델 examples/model은 go tool orm-gen이 contracts/schema/persist.dbs로부터 생성한다. 같은 파일의 Open, Seed, StatusOf는 table을 만들고 row를 읽는다. 위 발췌와 파일은 test bed의 model package github.com/polyspec/fsm/packages/go/examples/model을 쓴다. examples/persist.go를 복사해 쓰는 소비자는 자기 module의 model package(자기 schema로부터 orm-gen이 생성한)를 같은 자리에서 import한다.
테스트
checkout에서 실행한다(module은 한 번 온라인으로 받아야 한다):
sh
make -C packages/go install # go mod download
make -C packages/go test # GOPROXY=off, the conformance tests and the persistence example on SQLite
make test-go # the same from the root
make go-persist-test-postgres # the persist cases on PostgreSQL (FSM_TEST_PG_DSN; a missing DSN exits 2)
make go-persist-test-mysql # the persist cases on MySQL (FSM_TEST_MYSQL_DSN)실행 결과는 89개 case 중 89개 통과다. tests/conformance_test.go는 tests/vectors_test.go의 vector case마다 하나의 subtest를 case 이름 <feature>__<id>로 실행한다. 루트의 make gen-check는 생성 파일이 fsm-gen이 지금 쓰는 것과 같은지 검사한다. go release는 packages/go/go.mod의 go 줄(go 1.27.2, kit의 Go pin)이다.