Skip to content

github.com/polyspec/fsm/packages/go ​

English

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 ./src
text
[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.go
text
[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.gopackage 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).

필드타입뜻
CodestringE_CONFLICT, E_INVALID_TRANSITION, E_UNKNOWN_CODE 또는 E_RESERVED_CODE
Messagestring기계 이름을 담은 메시지. Error()가 돌려준다
Expectedint32기대한 상태 code. 없으면 0
Actualint32저장된 상태 code. 없으면 0
Eventint32event 위치(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가 이를 단언한다:

CodeExpectedActualEvent
E_CONFLICT설정됨설정됨설정됨
E_INVALID_TRANSITION설정됨0(설정되지 않음)설정됨
E_UNKNOWN_CODE, E_RESERVED_CODE000

메시지는 기계 이름을 담으며, 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)이다.