领域模型与状态机

日常编程中你使用过状态机吗?也就是状态模式?首先问题是为什么要使用状态机,解答这个问题必须回答如何对抗软件的复杂性?软件的复杂性是因为一份代码做两件事引起的,很多领域模型中都包含一个半残的隐晦的状态机,如果不将状态管理从领域模型中分离出来,相当于让领域模型做两件事,一件事代表实体属性,一件事表达实体当前状态。

大多数领域模型随着时间的推移会有各种不同状态,,围绕这些状态之间的转换方式会有很多业务逻辑代码,如果这些逻辑代码不使用状态模式进行归类统一,会以各种难以理解和难以修改的方式存在各个地方。

通过识别领域模型何时首先被表达为状态机 - 或者识别何时需要将领域模型重构为状态机 - 我们就能保持模型的可理解性和可行性。我们可以驯服他们的复杂性。

该文详细使用了Javascript说明没有使用状态机和使用了状态机在代码质量上的提高。

文中以银行账户为例,说明状态机是响应事件在状态之间的转换,比如银行账户一开始是open状态:
1.在open状态时,银行账户可以响应close事件,并将状态切换到closed状态;
2.当在closed状态时,银行账户会对reopen事件响应,并切换到open状态;
3.当在open状态时,银行账户会响应扣款和存款的事件。

这个切换规则排除了一些改变状态的事件,比如在closed状态时,银行账户就不能响应扣款和存款事件。

对于这段规则,我们如果不挖掘总结业务需求背后的状态模式,而是直接根据功能要求进行设计编码,就会总结出存款deposit 扣款withdraw 放开 关闭等动作,并根据这些动作实现代码如下(通常是服务的接口实现):



let account = {
state: 'open',
balance: 0,

deposit (amount) {
if (this.state === 'open' || this.state === 'held') {
this.balance = this.balance + amount;
} else {
throw 'invalid event';
}
},

withdraw (amount) {
if (this.state === 'open') {
this.balance = this.balance - amount;
} else {
throw 'invalid event';
}
},

placeHold () {
if (this.state === 'open') {
this.state = 'held';
} else {
throw 'invalid event';
}
},

removeHold () {
if (this.state === 'held') {
this.state = 'open';
} else {
throw 'invalid event';
}
},

close () {
if (this.state === 'open' || this.state === 'held') {
if (balance > 0) {
// ...transfer balance to suspension account
}
this.state = 'closed';
} else {
throw 'invalid event';
}
},

reopen () {
if (this.state === 'closed') {
// ...restore balance if applicable
this.state = 'open';
} else {
throw 'invalid event';
}
}
}

如果有新的动作导致新的状态,那么我们就会增加新的方法行为,这样增加下去代码会变得复杂,混沌。

还有一种办法,就是建立一个转换表,把这些动作转换罗列出来:


| open | held |closed
open | deposit,withdraw | place-hold |close
held | remove-hold | deposit |close
closed| reopen

转换表清楚地显示了哪些事件由哪个状态处理,以及它们之间的转换。我们可以将这个想法带到我们的可执行代码中:


const STATES = Symbol("states");
const STARTING_STATE = Symbol(
"starting-state");

const Account = {
balance: 0,
STARTING_STATE: 'open',
STATES: {
open: {
open: {
deposit (amount) { this.balance = this.balance + amount; },
withdraw (amount) { this.balance = this.balance - amount; },
},
held: {
placeHold () {}
},
closed: {
close () {
if (balance > 0) {
// ...transfer balance to suspension account
}
}
}
},

held: {
open: {
removeHold () {}
},
held: {
deposit (amount) { this.balance = this.balance + amount; }
},
closed: {
close () {
if (balance > 0) {
// ...transfer balance to suspension account
}
}
}
},

closed: {
open: {
reopen () {
// ...restore balance if applicable
}
}
}
}
};

这种方式其实也是不可行,扩展性很差,造成代码非常复杂。

如果我们能够发现这个业务服务背后存在对一个统一的状态机(领域模型)进行操作,这些动作只不过是围绕领域模型状态机的行为而已,突出状态,使用状态模式实现,状态模式就是用一个个状态对象替代状态值,比如open状态使用Open对象表达:


const STATE = Symbol("state");
const STATES = Symbol(
"states");

const open = {
deposit (amount) { this.balance = this.balance + amount; },
withdraw (amount) { this.balance = this.balance - amount; },
placeHold () {
this[STATE] = this[STATES].held;
},
close () {
if (balance > 0) {
// ...transfer balance to suspension account
}
this[STATE] = this[STATES].closed;
}
};

const held = {
removeHold () {
this[STATE] = this[STATES].open;
},
deposit (amount) { this.balance = this.balance + amount; },
close () {
if (balance > 0) {
// ...transfer balance to suspension account
}
this[STATE] = this[STATES].closed;
}
};

const closed = {
reopen () {
// ...restore balance if applicable
this[STATE] = this[STATES].open;
}
};

上面三个状态对象 open held和closed,每个状态对象里封装了适应他们的动作行为,以及切换规则。

领域模型中封装了业务实体的属性和状态,这已经成为一种通用的设计规则。

当然,反对意见也是有的,他们认为传统的编程语言和命令式思维不适合编写状态机,所以它们没有被广泛使用,这并不令人惊讶。在为什么开发者从来不用状态机中,认为状态机导致复杂,没有什么用处,很多人认为将数据封装在对象中,而无法让数据成为第一公民,同时,状态机让组件复用变得困难,一些人推荐使用ECS系统(实体-组件-系统),让多变的行为和状态成为一个个组件,实体只是标识和具体一些属性,实体与不同组件组合实现不同的系统功能,组合超越继承。
更多讨论见:
Hackernews讨论1
Hackernews讨论2