diff --git a/pkg/saga/statemachine/statelang/parser/statemachine_config_parser.go b/pkg/saga/statemachine/statelang/parser/statemachine_config_parser.go new file mode 100644 index 000000000..1de776390 --- /dev/null +++ b/pkg/saga/statemachine/statelang/parser/statemachine_config_parser.go @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package parser + +import ( + "bytes" + "encoding/json" + "fmt" + "gopkg.in/yaml.v3" + "io" + "os" + "path/filepath" +) + +// ConfigParser is a general configuration parser interface, used to agree on the implementation of different types of parsers +type ConfigParser interface { + Parse(configContent []byte) (*StateMachineObject, error) +} + +type StateMachineConfigParser struct{} + +func NewStateMachineConfigParser() *StateMachineConfigParser { + return &StateMachineConfigParser{} +} + +func (p *StateMachineConfigParser) checkConfigFile(configFilePath string) error { + _, err := os.Stat(configFilePath) + if os.IsNotExist(err) { + return fmt.Errorf("config file %s does not exist: %w", configFilePath, err) + } + if err != nil { + return fmt.Errorf("failed to access config file %s: %w", configFilePath, err) + } + return nil +} + +func (p *StateMachineConfigParser) readFile(configFilePath string) ([]byte, error) { + file, _ := os.Open(configFilePath) + defer func(file *os.File) { + _ = file.Close() + }(file) + + var buf bytes.Buffer + _, err := io.Copy(&buf, file) + if err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", configFilePath, err) + } + + return buf.Bytes(), nil +} + +func (p *StateMachineConfigParser) getParser(configFilePath string) (ConfigParser, error) { + fileExt := filepath.Ext(configFilePath) + // check the file extension, compatible with some illegal but possible situations + switch fileExt { + case ".json", ".JSON": + return NewJSONConfigParser(), nil + case ".yaml", ".yml", ".YAML", ".YML": + return NewYAMLConfigParser(), nil + default: + return nil, fmt.Errorf("unsupported config file format: %s", fileExt) + } +} + +func (p *StateMachineConfigParser) Parse(configFilePath string) (*StateMachineObject, error) { + if err := p.checkConfigFile(configFilePath); err != nil { + return nil, err + } + + configContent, err := p.readFile(configFilePath) + if err != nil { + return nil, err + } + + parser, err := p.getParser(configFilePath) + if err != nil { + return nil, err + } + + return parser.Parse(configContent) +} + +type JSONConfigParser struct{} + +func NewJSONConfigParser() *JSONConfigParser { + return &JSONConfigParser{} +} + +func (p *JSONConfigParser) Parse(configContent []byte) (*StateMachineObject, error) { + if configContent == nil || len(configContent) == 0 { + return nil, fmt.Errorf("empty JSON config content") + } + + var stateMachineObject StateMachineObject + if err := json.Unmarshal(configContent, &stateMachineObject); err != nil { + return nil, fmt.Errorf("failed to parse JSON config content: %w", err) + } + + return &stateMachineObject, nil +} + +type YAMLConfigParser struct{} + +func NewYAMLConfigParser() *YAMLConfigParser { + return &YAMLConfigParser{} +} + +func (p *YAMLConfigParser) Parse(configContent []byte) (*StateMachineObject, error) { + if configContent == nil || len(configContent) == 0 { + return nil, fmt.Errorf("empty YAML config content") + } + + var stateMachineObject StateMachineObject + if err := yaml.Unmarshal(configContent, &stateMachineObject); err != nil { + return nil, fmt.Errorf("failed to parse YAML config content: %w", err) + } + + return &stateMachineObject, nil +} + +type StateMachineObject struct { + Name string `json:"Name" yaml:"Name"` + Comment string `json:"Comment" yaml:"Comment"` + Version string `json:"Version" yaml:"Version"` + StartState string `json:"StartState" yaml:"StartState"` + RecoverStrategy string `json:"RecoverStrategy" yaml:"RecoverStrategy"` + Persist bool `json:"IsPersist" yaml:"IsPersist"` + RetryPersistModeUpdate bool `json:"IsRetryPersistModeUpdate" yaml:"IsRetryPersistModeUpdate"` + CompensatePersistModeUpdate bool `json:"IsCompensatePersistModeUpdate" yaml:"IsCompensatePersistModeUpdate"` + Type string `json:"Type" yaml:"Type"` + States map[string]interface{} `json:"States" yaml:"States"` +} diff --git a/pkg/saga/statemachine/statelang/parser/statemachine_config_parser_test.go b/pkg/saga/statemachine/statelang/parser/statemachine_config_parser_test.go new file mode 100644 index 000000000..79563a831 --- /dev/null +++ b/pkg/saga/statemachine/statelang/parser/statemachine_config_parser_test.go @@ -0,0 +1,860 @@ +package parser + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestStateMachineConfigParser_Parse(t *testing.T) { + parser := NewStateMachineConfigParser() + + tests := []struct { + name string + configFilePath string + expectedStateMachineObject *StateMachineObject + }{ + { + name: "JSON Simple 1", + configFilePath: "../../../../../testdata/saga/statelang/simple_statelang_with_choice.json", + expectedStateMachineObject: GetStateMachineObject1("json"), + }, + { + name: "JSON Simple 2", + configFilePath: "../../../../../testdata/saga/statelang/simple_statemachine.json", + expectedStateMachineObject: GetStateMachineObject2("json"), + }, + { + name: "JSON Simple 3", + configFilePath: "../../../../../testdata/saga/statelang/state_machine_new_designer.json", + expectedStateMachineObject: GetStateMachineObject3("json"), + }, + { + name: "YAML Simple 1", + configFilePath: "../../../../../testdata/saga/statelang/simple_statelang_with_choice.yaml", + expectedStateMachineObject: GetStateMachineObject1("yaml"), + }, + { + name: "YAML Simple 2", + configFilePath: "../../../../../testdata/saga/statelang/simple_statemachine.yaml", + expectedStateMachineObject: GetStateMachineObject2("yaml"), + }, + { + name: "YAML Simple 3", + configFilePath: "../../../../../testdata/saga/statelang/state_machine_new_designer.yaml", + expectedStateMachineObject: GetStateMachineObject3("yaml"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + object, err := parser.Parse(tt.configFilePath) + if err != nil { + t.Error("parse fail: " + err.Error()) + } + assert.Equal(t, tt.expectedStateMachineObject, object) + }) + } +} + +func GetStateMachineObject1(format string) *StateMachineObject { + switch format { + case "json": + case "yaml": + } + + return &StateMachineObject{ + Name: "simpleChoiceTestStateMachine", + Comment: "带条件分支的测试状态机定义", + StartState: "FirstState", + Version: "0.0.1", + States: map[string]interface{}{ + "FirstState": map[string]interface{}{ + "Type": "ServiceTask", + "ServiceName": "demoService", + "ServiceMethod": "foo", + "Next": "ChoiceState", + }, + "ChoiceState": map[string]interface{}{ + "Type": "Choice", + "Choices": []interface{}{ + map[string]interface{}{ + "Expression": "[a] == 1", + "Next": "SecondState", + }, + map[string]interface{}{ + "Expression": "[a] == 2", + "Next": "ThirdState", + }, + }, + "Default": "SecondState", + }, + "SecondState": map[string]interface{}{ + "Type": "ServiceTask", + "ServiceName": "demoService", + "ServiceMethod": "bar", + }, + "ThirdState": map[string]interface{}{ + "Type": "ServiceTask", + "ServiceName": "demoService", + "ServiceMethod": "foo", + }, + }, + } +} + +func GetStateMachineObject2(format string) *StateMachineObject { + var retryMap map[string]interface{} + + switch format { + case "json": + retryMap = map[string]interface{}{ + "Exceptions": []interface{}{ + "java.lang.Exception", + }, + "IntervalSeconds": float64(2), + "MaxAttempts": float64(3), + "BackoffRate": 1.5, + } + case "yaml": + retryMap = map[string]interface{}{ + "Exceptions": []interface{}{ + "java.lang.Exception", + }, + "IntervalSeconds": 2, + "MaxAttempts": 3, + "BackoffRate": 1.5, + } + } + + return &StateMachineObject{ + Name: "simpleTestStateMachine", + Comment: "测试状态机定义", + StartState: "FirstState", + Version: "0.0.1", + States: map[string]interface{}{ + "FirstState": map[string]interface{}{ + "Type": "ServiceTask", + "ServiceName": "is.seata.saga.DemoService", + "ServiceMethod": "foo", + "IsPersist": false, + "Next": "ScriptState", + }, + "ScriptState": map[string]interface{}{ + "Type": "ScriptTask", + "ScriptType": "groovy", + "ScriptContent": "return 'hello ' + inputA", + "Input": []interface{}{ + map[string]interface{}{ + "inputA": "$.data1", + }, + }, + "Output": map[string]interface{}{ + "scriptStateResult": "$.#root", + }, + "Next": "ChoiceState", + }, + "ChoiceState": map[string]interface{}{ + "Type": "Choice", + "Choices": []interface{}{ + map[string]interface{}{ + "Expression": "foo == 1", + "Next": "FirstMatchState", + }, + map[string]interface{}{ + "Expression": "foo == 2", + "Next": "SecondMatchState", + }, + }, + "Default": "FailState", + }, + "FirstMatchState": map[string]interface{}{ + "Type": "ServiceTask", + "ServiceName": "is.seata.saga.DemoService", + "ServiceMethod": "bar", + "CompensateState": "CompensateFirst", + "Status": map[string]interface{}{ + "return.code == 'S'": "SU", + "return.code == 'F'": "FA", + "$exception{java.lang.Throwable}": "UN", + }, + "Input": []interface{}{ + map[string]interface{}{ + "inputA1": "$.data1", + "inputA2": map[string]interface{}{ + "a": "$.data2.a", + }, + }, + map[string]interface{}{ + "inputB": "$.header", + }, + }, + "Output": map[string]interface{}{ + "firstMatchStateResult": "$.#root", + }, + "Retry": []interface{}{ + retryMap, + }, + "Catch": []interface{}{ + map[string]interface{}{ + "Exceptions": []interface{}{ + "java.lang.Exception", + }, + "Next": "CompensationTrigger", + }, + }, + "Next": "SuccessState", + }, + "CompensateFirst": map[string]interface{}{ + "Type": "ServiceTask", + "ServiceName": "is.seata.saga.DemoService", + "ServiceMethod": "compensateBar", + "IsForCompensation": true, + "IsForUpdate": true, + "Input": []interface{}{ + map[string]interface{}{ + "input": "$.data", + }, + }, + "Output": map[string]interface{}{ + "firstMatchStateResult": "$.#root", + }, + "Status": map[string]interface{}{ + "return.code == 'S'": "SU", + "return.code == 'F'": "FA", + "$exception{java.lang.Throwable}": "UN", + }, + }, + "CompensationTrigger": map[string]interface{}{ + "Type": "CompensationTrigger", + "Next": "CompensateEndState", + }, + "CompensateEndState": map[string]interface{}{ + "Type": "Fail", + "ErrorCode": "StateCompensated", + "Message": "State Compensated!", + }, + "SecondMatchState": map[string]interface{}{ + "Type": "SubStateMachine", + "StateMachineName": "simpleTestSubStateMachine", + "Input": []interface{}{ + map[string]interface{}{ + "input": "$.data", + }, + map[string]interface{}{ + "header": "$.header", + }, + }, + "Output": map[string]interface{}{ + "firstMatchStateResult": "$.#root", + }, + "Next": "SuccessState", + }, + "FailState": map[string]interface{}{ + "Type": "Fail", + "ErrorCode": "DefaultStateError", + "Message": "No Matches!", + }, + "SuccessState": map[string]interface{}{ + "Type": "Succeed", + }, + }, + } +} + +func GetStateMachineObject3(format string) *StateMachineObject { + var ( + boundsMap1 map[string]interface{} + boundsMap2 map[string]interface{} + boundsMap3 map[string]interface{} + boundsMap4 map[string]interface{} + boundsMap5 map[string]interface{} + boundsMap6 map[string]interface{} + boundsMap7 map[string]interface{} + boundsMap8 map[string]interface{} + boundsMap9 map[string]interface{} + + waypoints1 []interface{} + waypoints2 []interface{} + waypoints3 []interface{} + waypoints4 []interface{} + waypoints5 []interface{} + waypoints6 []interface{} + waypoints7 []interface{} + ) + + switch format { + case "json": + boundsMap1 = map[string]interface{}{ + "x": float64(300), + "y": float64(178), + "width": float64(100), + "height": float64(80), + } + boundsMap2 = map[string]interface{}{ + "x": float64(455), + "y": float64(193), + "width": float64(50), + "height": float64(50), + } + boundsMap3 = map[string]interface{}{ + "x": float64(300), + "y": float64(310), + "width": float64(100), + "height": float64(80), + } + boundsMap4 = map[string]interface{}{ + "x": float64(550), + "y": float64(178), + "width": float64(100), + "height": float64(80), + } + boundsMap5 = map[string]interface{}{ + "x": float64(550), + "y": float64(310), + "width": float64(100), + "height": float64(80), + } + boundsMap6 = map[string]interface{}{ + "x": float64(632), + "y": float64(372), + "width": float64(36), + "height": float64(36), + } + boundsMap7 = map[string]interface{}{ + "x": float64(722), + "y": float64(200), + "width": float64(36), + "height": float64(36), + } + boundsMap8 = map[string]interface{}{ + "x": float64(722), + "y": float64(372), + "width": float64(36), + "height": float64(36), + } + boundsMap9 = map[string]interface{}{ + "x": float64(812), + "y": float64(372), + "width": float64(36), + "height": float64(36), + } + + waypoints1 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(400), + "y": float64(218), + }, + "x": float64(400), + "y": float64(218), + }, + map[string]interface{}{"x": float64(435), "y": float64(218)}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(455), + "y": float64(218), + }, + "x": float64(455), + "y": float64(218), + }, + } + waypoints2 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(505), + "y": float64(218), + }, + "x": float64(505), + "y": float64(218), + }, + map[string]interface{}{"x": float64(530), "y": float64(218)}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(550), + "y": float64(218), + }, + "x": float64(550), + "y": float64(218), + }, + } + waypoints3 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(480), + "y": float64(243), + }, + "x": float64(480), + "y": float64(243), + }, + map[string]interface{}{"x": float64(600), "y": float64(290)}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(600), + "y": float64(310), + }, + "x": float64(600), + "y": float64(310), + }, + } + waypoints4 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(650), + "y": float64(218), + }, + "x": float64(650), + "y": float64(218), + }, + map[string]interface{}{"x": float64(702), "y": float64(218)}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(722), + "y": float64(218), + }, + "x": float64(722), + "y": float64(218), + }, + } + waypoints5 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(668), + "y": float64(390), + }, + "x": float64(668), + "y": float64(390), + }, + map[string]interface{}{"x": float64(702), "y": float64(390)}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(722), + "y": float64(390), + }, + "x": float64(722), + "y": float64(390), + }, + } + waypoints6 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(600), + "y": float64(310), + }, + "x": float64(600), + "y": float64(310), + }, + map[string]interface{}{"x": float64(740), "y": float64(256)}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(740), + "y": float64(236), + }, + "x": float64(740), + "y": float64(236), + }, + } + waypoints7 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(758), + "y": float64(390), + }, + "x": float64(758), + "y": float64(390), + }, + map[string]interface{}{"x": float64(792), "y": float64(390)}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": float64(812), + "y": float64(390), + }, + "x": float64(812), + "y": float64(390), + }, + } + + case "yaml": + boundsMap1 = map[string]interface{}{ + "x": 300, + "y": 178, + "width": 100, + "height": 80, + } + boundsMap2 = map[string]interface{}{ + "x": 455, + "y": 193, + "width": 50, + "height": 50, + } + boundsMap3 = map[string]interface{}{ + "x": 300, + "y": 310, + "width": 100, + "height": 80, + } + boundsMap4 = map[string]interface{}{ + "x": 550, + "y": 178, + "width": 100, + "height": 80, + } + boundsMap5 = map[string]interface{}{ + "x": 550, + "y": 310, + "width": 100, + "height": 80, + } + boundsMap6 = map[string]interface{}{ + "x": 632, + "y": 372, + "width": 36, + "height": 36, + } + boundsMap7 = map[string]interface{}{ + "x": 722, + "y": 200, + "width": 36, + "height": 36, + } + boundsMap8 = map[string]interface{}{ + "x": 722, + "y": 372, + "width": 36, + "height": 36, + } + boundsMap9 = map[string]interface{}{ + "x": 812, + "y": 372, + "width": 36, + "height": 36, + } + + waypoints1 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 400, + "y": 218, + }, + "x": 400, + "y": 218, + }, + map[string]interface{}{"x": 435, "y": 218}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 455, + "y": 218, + }, + "x": 455, + "y": 218, + }, + } + waypoints2 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 505, + "y": 218, + }, + "x": 505, + "y": 218, + }, + map[string]interface{}{"x": 530, "y": 218}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 550, + "y": 218, + }, + "x": 550, + "y": 218, + }, + } + waypoints3 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 480, + "y": 243, + }, + "x": 480, + "y": 243, + }, + map[string]interface{}{"x": 600, "y": 290}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 600, + "y": 310, + }, + "x": 600, + "y": 310, + }, + } + waypoints4 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 650, + "y": 218, + }, + "x": 650, + "y": 218, + }, + map[string]interface{}{"x": 702, "y": 218}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 722, + "y": 218, + }, + "x": 722, + "y": 218, + }, + } + waypoints5 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 668, + "y": 390, + }, + "x": 668, + "y": 390, + }, + map[string]interface{}{"x": 702, "y": 390}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 722, + "y": 390, + }, + "x": 722, + "y": 390, + }, + } + waypoints6 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 600, + "y": 310, + }, + "x": 600, + "y": 310, + }, + map[string]interface{}{"x": 740, "y": 256}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 740, + "y": 236, + }, + "x": 740, + "y": 236, + }, + } + waypoints7 = []interface{}{ + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 758, + "y": 390, + }, + "x": 758, + "y": 390, + }, + map[string]interface{}{"x": 792, "y": 390}, + map[string]interface{}{ + "original": map[string]interface{}{ + "x": 812, + "y": 390, + }, + "x": 812, + "y": 390, + }, + } + } + + return &StateMachineObject{ + Name: "StateMachineNewDesigner", + Comment: "This state machine is modeled by designer tools.", + Version: "0.0.1", + StartState: "ServiceTask-a9h2o51", + RecoverStrategy: "", + Persist: false, + RetryPersistModeUpdate: false, + CompensatePersistModeUpdate: false, + Type: "", + States: map[string]interface{}{ + "ServiceTask-a9h2o51": map[string]interface{}{ + "style": map[string]interface{}{ + "bounds": boundsMap1, + }, + "Name": "ServiceTask-a9h2o51", + "IsForCompensation": false, + "Input": []interface{}{map[string]interface{}{}}, + "Output": map[string]interface{}{}, + "Status": map[string]interface{}{}, + "Retry": []interface{}{}, + "ServiceName": "", + "ServiceMethod": "", + "Type": "ServiceTask", + "Next": "Choice-4ajl8nt", + "edge": map[string]interface{}{ + "Choice-4ajl8nt": map[string]interface{}{ + "style": map[string]interface{}{ + "waypoints": waypoints1, + "source": "ServiceTask-a9h2o51", + "target": "Choice-4ajl8nt", + }, + "Type": "Transition", + }, + }, + "CompensateState": "CompensateFirstState", + }, + "Choice-4ajl8nt": map[string]interface{}{ + "style": map[string]interface{}{ + "bounds": boundsMap2, + }, + "Name": "Choice-4ajl8nt", + "Type": "Choice", + "Choices": []interface{}{ + map[string]interface{}{ + "Expression": "", + "Next": "SubStateMachine-cauj9uy", + }, + map[string]interface{}{ + "Expression": "", + "Next": "ServiceTask-vdij28l", + }, + }, + "Default": "SubStateMachine-cauj9uy", + "edge": map[string]interface{}{ + "SubStateMachine-cauj9uy": map[string]interface{}{ + "style": map[string]interface{}{ + "waypoints": waypoints2, + "source": "Choice-4ajl8nt", + "target": "SubStateMachine-cauj9uy", + }, + "Type": "ChoiceEntry", + }, + "ServiceTask-vdij28l": map[string]interface{}{ + "style": map[string]interface{}{ + "waypoints": waypoints3, + "source": "Choice-4ajl8nt", + "target": "ServiceTask-vdij28l", + }, + "Type": "ChoiceEntry", + }, + }, + }, + "CompensateFirstState": map[string]interface{}{ + "style": map[string]interface{}{ + "bounds": boundsMap3, + }, + "Name": "CompensateFirstState", + "IsForCompensation": true, + "Input": []interface{}{map[string]interface{}{}}, + "Output": map[string]interface{}{}, + "Status": map[string]interface{}{}, + "Retry": []interface{}{}, + "ServiceName": "", + "ServiceMethod": "", + "Type": "ServiceTask", + }, + "SubStateMachine-cauj9uy": map[string]interface{}{ + "style": map[string]interface{}{ + "bounds": boundsMap4, + }, + "Name": "SubStateMachine-cauj9uy", + "IsForCompensation": false, + "Input": []interface{}{map[string]interface{}{}}, + "Output": map[string]interface{}{}, + "Status": map[string]interface{}{}, + "Retry": []interface{}{}, + "StateMachineName": "", + "Type": "SubStateMachine", + "Next": "Succeed-5x3z98u", + "edge": map[string]interface{}{ + "Succeed-5x3z98u": map[string]interface{}{ + "style": map[string]interface{}{ + "waypoints": waypoints4, + "source": "SubStateMachine-cauj9uy", + "target": "Succeed-5x3z98u", + }, + "Type": "Transition", + }, + }, + }, + "ServiceTask-vdij28l": map[string]interface{}{ + "style": map[string]interface{}{ + "bounds": boundsMap5, + }, + "Name": "ServiceTask-vdij28l", + "IsForCompensation": false, + "Input": []interface{}{map[string]interface{}{}}, + "Output": map[string]interface{}{}, + "Status": map[string]interface{}{}, + "Retry": []interface{}{}, + "ServiceName": "", + "ServiceMethod": "", + "Catch": []interface{}{ + map[string]interface{}{ + "Exceptions": []interface{}{}, + "Next": "CompensationTrigger-uldp2ou", + }, + }, + "Type": "ServiceTask", + "catch": map[string]interface{}{ + "style": map[string]interface{}{ + "bounds": boundsMap6, + }, + "edge": map[string]interface{}{ + "CompensationTrigger-uldp2ou": map[string]interface{}{ + "style": map[string]interface{}{ + "waypoints": waypoints5, + "source": "ServiceTask-vdij28l", + "target": "CompensationTrigger-uldp2ou", + }, + "Type": "ExceptionMatch", + }, + }, + }, + "Next": "Succeed-5x3z98u", + "edge": map[string]interface{}{ + "Succeed-5x3z98u": map[string]interface{}{ + "style": map[string]interface{}{ + "waypoints": waypoints6, + "source": "ServiceTask-vdij28l", + "target": "Succeed-5x3z98u", + }, + "Type": "Transition", + }, + }, + }, + "Succeed-5x3z98u": map[string]interface{}{ + "style": map[string]interface{}{ + "bounds": boundsMap7, + }, + "Name": "Succeed-5x3z98u", + "Type": "Succeed", + }, + "CompensationTrigger-uldp2ou": map[string]interface{}{ + "style": map[string]interface{}{ + "bounds": boundsMap8, + }, + "Name": "CompensationTrigger-uldp2ou", + "Type": "CompensationTrigger", + "Next": "Fail-9roxcv5", + "edge": map[string]interface{}{ + "Fail-9roxcv5": map[string]interface{}{ + "style": map[string]interface{}{ + "waypoints": waypoints7, + "source": "CompensationTrigger-uldp2ou", + "target": "Fail-9roxcv5", + }, + "Type": "Transition", + }, + }, + }, + "Fail-9roxcv5": map[string]interface{}{ + "style": map[string]interface{}{ + "bounds": boundsMap9, + }, + "Name": "Fail-9roxcv5", + "ErrorCode": "", + "Message": "", + "Type": "Fail", + }, + }, + } +} diff --git a/pkg/saga/statemachine/statelang/parser/statemachine_json_parser.go b/pkg/saga/statemachine/statelang/parser/statemachine_json_parser.go index 4bcfdcbc6..9c9bcc5c8 100644 --- a/pkg/saga/statemachine/statelang/parser/statemachine_json_parser.go +++ b/pkg/saga/statemachine/statelang/parser/statemachine_json_parser.go @@ -1,7 +1,6 @@ package parser import ( - "encoding/json" "github.com/pkg/errors" "github.com/seata/seata-go/pkg/saga/statemachine/constant" "github.com/seata/seata-go/pkg/saga/statemachine/statelang" @@ -22,10 +21,8 @@ func (stateMachineParser JSONStateMachineParser) GetType() string { return "JSON" } -func (stateMachineParser JSONStateMachineParser) Parse(content string) (statelang.StateMachine, error) { - var stateMachineJsonObject StateMachineJsonObject - - err := json.Unmarshal([]byte(content), &stateMachineJsonObject) +func (stateMachineParser JSONStateMachineParser) Parse(configFilePath string) (statelang.StateMachine, error) { + stateMachineJsonObject, err := NewStateMachineConfigParser().Parse(configFilePath) if err != nil { return nil, err } @@ -115,16 +112,3 @@ func (stateMachineParser JSONStateMachineParser) isTaskState(stateType string) b } return false } - -type StateMachineJsonObject struct { - Name string `json:"Name"` - Comment string `json:"Comment"` - Version string `json:"Version"` - StartState string `json:"StartState"` - RecoverStrategy string `json:"RecoverStrategy"` - Persist bool `json:"IsPersist"` - RetryPersistModeUpdate bool `json:"IsRetryPersistModeUpdate"` - CompensatePersistModeUpdate bool `json:"IsCompensatePersistModeUpdate"` - Type string `json:"Type"` - States map[string]interface{} `json:"States"` -} diff --git a/pkg/saga/statemachine/statelang/parser/statemachine_json_parser_test.go b/pkg/saga/statemachine/statelang/parser/statemachine_json_parser_test.go index 0028309ab..f2736da8d 100644 --- a/pkg/saga/statemachine/statelang/parser/statemachine_json_parser_test.go +++ b/pkg/saga/statemachine/statelang/parser/statemachine_json_parser_test.go @@ -1,45 +1,86 @@ package parser import ( - "os" "testing" ) func TestParseChoice(t *testing.T) { - filePath := "../../../../../testdata/saga/statelang/simple_statelang_with_choice.json" - fileContent, err := os.ReadFile(filePath) - if err != nil { - t.Error("parse fail: " + err.Error()) - return + parser := NewJSONStateMachineParser() + + tests := []struct { + name string + configFilePath string + }{ + { + name: "JSON Simple: StateLang With Choice", + configFilePath: "../../../../../testdata/saga/statelang/simple_statelang_with_choice.json", + }, + { + name: "YAML Simple: StateLang With Choice", + configFilePath: "../../../../../testdata/saga/statelang/simple_statelang_with_choice.yaml", + }, } - _, err = NewJSONStateMachineParser().Parse(string(fileContent)) - if err != nil { - t.Error("parse fail: " + err.Error()) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parser.Parse(tt.configFilePath) + if err != nil { + t.Error("parse fail: " + err.Error()) + } + }) } } func TestParseServiceTaskForSimpleStateMachine(t *testing.T) { - filePath := "../../../../../testdata/saga/statelang/simple_statemachine.json" - fileContent, err := os.ReadFile(filePath) - if err != nil { - t.Error("parse fail: " + err.Error()) - return + parser := NewJSONStateMachineParser() + + tests := []struct { + name string + configFilePath string + }{ + { + name: "JSON Simple: StateMachine", + configFilePath: "../../../../../testdata/saga/statelang/simple_statemachine.json", + }, + { + name: "YAML Simple: StateMachine", + configFilePath: "../../../../../testdata/saga/statelang/simple_statemachine.yaml", + }, } - _, err = NewJSONStateMachineParser().Parse(string(fileContent)) - if err != nil { - t.Error("parse fail: " + err.Error()) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parser.Parse(tt.configFilePath) + if err != nil { + t.Error("parse fail: " + err.Error()) + } + }) } } func TestParseServiceTaskForNewDesigner(t *testing.T) { - filePath := "../../../../../testdata/saga/statelang/state_machine_new_designer.json" - fileContent, err := os.ReadFile(filePath) - if err != nil { - t.Error("parse fail: " + err.Error()) - return + parser := NewJSONStateMachineParser() + + tests := []struct { + name string + configFilePath string + }{ + { + name: "JSON Simple: StateMachine New Designer", + configFilePath: "../../../../../testdata/saga/statelang/state_machine_new_designer.json", + }, + { + name: "YAML Simple: StateMachine New Designer", + configFilePath: "../../../../../testdata/saga/statelang/state_machine_new_designer.yaml", + }, } - _, err = NewJSONStateMachineParser().Parse(string(fileContent)) - if err != nil { - t.Error("parse fail: " + err.Error()) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parser.Parse(tt.configFilePath) + if err != nil { + t.Error("parse fail: " + err.Error()) + } + }) } } diff --git a/pkg/saga/statemachine/statelang/parser/statemachine_parser.go b/pkg/saga/statemachine/statelang/parser/statemachine_parser.go index 690ad278f..892c8f484 100644 --- a/pkg/saga/statemachine/statelang/parser/statemachine_parser.go +++ b/pkg/saga/statemachine/statelang/parser/statemachine_parser.go @@ -148,18 +148,23 @@ func (b BaseStateParser) GetIntOrDefault(stateName string, stateMap map[string]i return defaultValue, nil } - // just use float64 to convert, json reader will read all number as float64 - valueAsFloat64, ok := value.(float64) - if !ok { + // use float64 conversion when the configuration file is json, and use int conversion when the configuration file is yaml + valueAsFloat64, okToFloat64 := value.(float64) + valueAsInt, okToInt := value.(int) + if !okToFloat64 && !okToInt { return defaultValue, errors.New("State [" + stateName + "] " + key + " illegal, required int") } - floatStr := strconv.FormatFloat(valueAsFloat64, 'f', -1, 64) - if strings.Contains(floatStr, ".") { - return defaultValue, errors.New("State [" + stateName + "] " + key + " illegal, required int") + if okToFloat64 { + floatStr := strconv.FormatFloat(valueAsFloat64, 'f', -1, 64) + if strings.Contains(floatStr, ".") { + return defaultValue, errors.New("State [" + stateName + "] " + key + " illegal, required int") + } + + return int(valueAsFloat64), nil } - return int(valueAsFloat64), nil + return valueAsInt, nil } func (b BaseStateParser) GetFloat64OrDefault(stateName string, stateMap map[string]interface{}, key string, defaultValue float64) (float64, error) { @@ -169,11 +174,17 @@ func (b BaseStateParser) GetFloat64OrDefault(stateName string, stateMap map[stri return defaultValue, nil } - valueAsFloat64, ok := value.(float64) - if !ok { + // use float64 conversion when the configuration file is json, and use int conversion when the configuration file is yaml + valueAsFloat64, okToFloat64 := value.(float64) + valueAsInt, okToInt := value.(int) + if !okToFloat64 && !okToInt { return defaultValue, errors.New("State [" + stateName + "] " + key + " illegal, required float64") } - return valueAsFloat64, nil + + if okToFloat64 { + return valueAsFloat64, nil + } + return float64(valueAsInt), nil } type StateParserFactory interface { diff --git a/pkg/saga/statemachine/statelang/parser/task_state_json_parser.go b/pkg/saga/statemachine/statelang/parser/task_state_json_parser.go index 8dcfff5c5..ca5766111 100644 --- a/pkg/saga/statemachine/statelang/parser/task_state_json_parser.go +++ b/pkg/saga/statemachine/statelang/parser/task_state_json_parser.go @@ -242,7 +242,7 @@ func (s ServiceTaskStateParser) Parse(stateName string, stateMap map[string]inte return nil, err } - serviceName, err := s.GetString(stateName, stateMap, "serviceName") + serviceName, err := s.GetString(stateName, stateMap, "ServiceName") if err != nil { return nil, err } diff --git a/testdata/saga/statelang/simple_statelang_with_choice.yaml b/testdata/saga/statelang/simple_statelang_with_choice.yaml new file mode 100644 index 000000000..32fa403fa --- /dev/null +++ b/testdata/saga/statelang/simple_statelang_with_choice.yaml @@ -0,0 +1,26 @@ +Name: simpleChoiceTestStateMachine +Comment: 带条件分支的测试状态机定义 +StartState: FirstState +Version: 0.0.1 +States: + FirstState: + Type: ServiceTask + ServiceName: demoService + ServiceMethod: foo + Next: ChoiceState + ChoiceState: + Type: Choice + Choices: + - Expression: "[a] == 1" + Next: SecondState + - Expression: "[a] == 2" + Next: ThirdState + Default: SecondState + SecondState: + Type: ServiceTask + ServiceName: demoService + ServiceMethod: bar + ThirdState: + Type: ServiceTask + ServiceName: demoService + ServiceMethod: foo \ No newline at end of file diff --git a/testdata/saga/statelang/simple_statemachine.yaml b/testdata/saga/statelang/simple_statemachine.yaml new file mode 100644 index 000000000..a5da6c03d --- /dev/null +++ b/testdata/saga/statelang/simple_statemachine.yaml @@ -0,0 +1,90 @@ +Name: simpleTestStateMachine +Comment: 测试状态机定义 +StartState: FirstState +Version: 0.0.1 +States: + FirstState: + Type: ServiceTask + ServiceName: is.seata.saga.DemoService + ServiceMethod: foo + IsPersist: false + Next: ScriptState + ScriptState: + Type: ScriptTask + ScriptType: groovy + ScriptContent: return 'hello ' + inputA + Input: + - inputA: $.data1 + Output: + scriptStateResult: '$.#root' + Next: ChoiceState + ChoiceState: + Type: Choice + Choices: + - Expression: foo == 1 + Next: FirstMatchState + - Expression: foo == 2 + Next: SecondMatchState + Default: FailState + FirstMatchState: + Type: ServiceTask + ServiceName: is.seata.saga.DemoService + ServiceMethod: bar + CompensateState: CompensateFirst + Status: + return.code == 'S': SU + return.code == 'F': FA + '$exception{java.lang.Throwable}': UN + Input: + - inputA1: $.data1 + inputA2: + a: $.data2.a + - inputB: $.header + Output: + firstMatchStateResult: '$.#root' + Retry: + - Exceptions: [java.lang.Exception] + IntervalSeconds: 2 + MaxAttempts: 3 + BackoffRate: 1.5 + Catch: + - Exceptions: + - java.lang.Exception + Next: CompensationTrigger + Next: SuccessState + CompensateFirst: + Type: ServiceTask + ServiceName: is.seata.saga.DemoService + ServiceMethod: compensateBar + IsForCompensation: true + IsForUpdate: true + Input: + - input: $.data + Output: + firstMatchStateResult: '$.#root' + Status: + return.code == 'S': SU + return.code == 'F': FA + '$exception{java.lang.Throwable}': UN + CompensationTrigger: + Type: CompensationTrigger + Next: CompensateEndState + CompensateEndState: + Type: Fail + ErrorCode: StateCompensated + Message: State Compensated! + SecondMatchState: + Type: SubStateMachine + StateMachineName: simpleTestSubStateMachine + Input: + - input: $.data + - header: $.header + Output: + firstMatchStateResult: '$.#root' + Next: SuccessState + FailState: + Type: Fail + ErrorCode: DefaultStateError + Message: No Matches! + SuccessState: + Type: Succeed diff --git a/testdata/saga/statelang/state_machine_new_designer.yaml b/testdata/saga/statelang/state_machine_new_designer.yaml new file mode 100644 index 000000000..d646ab01d --- /dev/null +++ b/testdata/saga/statelang/state_machine_new_designer.yaml @@ -0,0 +1,286 @@ +Name: StateMachineNewDesigner +Comment: This state machine is modeled by designer tools. +Version: 0.0.1 +style: + bounds: + x: 200 + y: 200 + width: 36 + height: 36 +States: + ServiceTask-a9h2o51: + style: + bounds: + x: 300 + y: 178 + width: 100 + height: 80 + Name: ServiceTask-a9h2o51 + IsForCompensation: false + Input: + - {} + Output: {} + Status: {} + Retry: [] + ServiceName: "" + ServiceMethod: "" + Type: ServiceTask + Next: Choice-4ajl8nt + edge: + Choice-4ajl8nt: + style: + waypoints: + - original: + x: 400 + y: 218 + x: 400 + y: 218 + - x: 435 + y: 218 + - original: + x: 455 + y: 218 + x: 455 + y: 218 + source: ServiceTask-a9h2o51 + target: Choice-4ajl8nt + Type: Transition + CompensateState: CompensateFirstState + Choice-4ajl8nt: + style: + bounds: + x: 455 + y: 193 + width: 50 + height: 50 + Name: Choice-4ajl8nt + Type: Choice + Choices: + - Expression: "" + Next: SubStateMachine-cauj9uy + - Expression: "" + Next: ServiceTask-vdij28l + Default: SubStateMachine-cauj9uy + edge: + SubStateMachine-cauj9uy: + style: + waypoints: + - original: + x: 505 + y: 218 + x: 505 + y: 218 + - x: 530 + y: 218 + - original: + x: 550 + y: 218 + x: 550 + y: 218 + source: Choice-4ajl8nt + target: SubStateMachine-cauj9uy + Type: ChoiceEntry + ServiceTask-vdij28l: + style: + waypoints: + - original: + x: 480 + y: 243 + x: 480 + y: 243 + - x: 600 + y: 290 + - original: + x: 600 + y: 310 + x: 600 + y: 310 + source: Choice-4ajl8nt + target: ServiceTask-vdij28l + Type: ChoiceEntry + CompensateFirstState: + style: + bounds: + x: 300 + y: 310 + width: 100 + height: 80 + Name: CompensateFirstState + IsForCompensation: true + Input: + - {} + Output: {} + Status: {} + Retry: [] + ServiceName: "" + ServiceMethod: "" + Type: ServiceTask + SubStateMachine-cauj9uy: + style: + bounds: + x: 550 + y: 178 + width: 100 + height: 80 + Name: SubStateMachine-cauj9uy + IsForCompensation: false + Input: + - {} + Output: {} + Status: {} + Retry: [] + StateMachineName: "" + Type: SubStateMachine + Next: Succeed-5x3z98u + edge: + Succeed-5x3z98u: + style: + waypoints: + - original: + x: 650 + y: 218 + x: 650 + y: 218 + - x: 702 + y: 218 + - original: + x: 722 + y: 218 + x: 722 + y: 218 + source: SubStateMachine-cauj9uy + target: Succeed-5x3z98u + Type: Transition + ServiceTask-vdij28l: + style: + bounds: + x: 550 + y: 310 + width: 100 + height: 80 + Name: ServiceTask-vdij28l + IsForCompensation: false + Input: + - {} + Output: {} + Status: {} + Retry: [] + ServiceName: "" + ServiceMethod: "" + Catch: + - Exceptions: [] + Next: CompensationTrigger-uldp2ou + Type: ServiceTask + catch: + style: + bounds: + x: 632 + y: 372 + width: 36 + height: 36 + edge: + CompensationTrigger-uldp2ou: + style: + waypoints: + - original: + x: 668 + y: 390 + x: 668 + y: 390 + - x: 702 + y: 390 + - original: + x: 722 + y: 390 + x: 722 + y: 390 + source: ServiceTask-vdij28l + target: CompensationTrigger-uldp2ou + Type: ExceptionMatch + Next: Succeed-5x3z98u + edge: + Succeed-5x3z98u: + style: + waypoints: + - original: + x: 600 + y: 310 + x: 600 + y: 310 + - x: 740 + y: 256 + - original: + x: 740 + y: 236 + x: 740 + y: 236 + source: ServiceTask-vdij28l + target: Succeed-5x3z98u + Type: Transition + Succeed-5x3z98u: + style: + bounds: + x: 722 + y: 200 + width: 36 + height: 36 + Name: Succeed-5x3z98u + Type: Succeed + CompensationTrigger-uldp2ou: + style: + bounds: + x: 722 + y: 372 + width: 36 + height: 36 + Name: CompensationTrigger-uldp2ou + Type: CompensationTrigger + Next: Fail-9roxcv5 + edge: + Fail-9roxcv5: + style: + waypoints: + - original: + x: 758 + y: 390 + x: 758 + y: 390 + - x: 792 + y: 390 + - original: + x: 812 + y: 390 + x: 812 + y: 390 + source: CompensationTrigger-uldp2ou + target: Fail-9roxcv5 + Type: Transition + Fail-9roxcv5: + style: + bounds: + x: 812 + y: 372 + width: 36 + height: 36 + Name: Fail-9roxcv5 + ErrorCode: "" + Message: "" + Type: Fail +StartState: ServiceTask-a9h2o51 +edge: + style: + waypoints: + - original: + x: 236 + y: 218 + x: 236 + y: 218 + - x: 280 + y: 218 + - original: + x: 300 + y: 218 + x: 300 + y: 218 + target: ServiceTask-a9h2o51 + Type: Transition \ No newline at end of file