Apache Mina 文檔翻譯 - 第十四章 - 狀態機

[size=xx-large]第十四章 - 狀態機[/size]

如果你在用MINA開發複雜的網絡應用程序,有時候需要用來狀態模式來應對問題的複雜性。在自己實現狀態模式之前你可以先看看mina狀態機,利用這個功能可以方便的實現狀態模式。

[size=large]一個簡單的例子[/size]

我們來演示一下怎麼用mina狀態機實現一下簡單的例子。下面這個圖展示了一個標準的磁帶機的狀態遷移。 橢圓表示狀態,箭頭表示狀態遷移。每一個狀態遷移都付了一個標籤,表示事件,這些事件觸發狀態遷移。

[img]http://dl2.iteye.com/upload/attachment/0085/5630/737f7846-c7cf-3995-b9d7-6e570cfad4d4.png[/img]


磁帶機的初始狀態是Empty狀態。當一個磁帶被插進來,觸發load事件,磁帶機進入Loaded狀態。在Loaded狀態裏觸發eject事件,狀態回到Empty,如果觸發的是play事件,進入Playing狀態。以此類推。。。 剩下的狀態遷移是很容易理解的。

現在我們寫一些代碼。系統外部(跟磁帶機交互的其他部分)只能看到TapeDeck接口:


public interface TapeDeck {
void load(String nameOfTape);
void eject();
void start();
void pause();
void stop();
}


接下來我們寫一個狀態機裏狀態遷移時真正被執行的代碼。首先我們要定義狀態。我們把狀態定義爲一些字符串常量表用@State標註:

public class TapeDeckHandler {
@State public static final String EMPTY = "Empty";
@State public static final String LOADED = "Loaded";
@State public static final String PLAYING = "Playing";
@State public static final String PAUSED = "Paused";
}


現在我們有了狀態定義,就可以開始編寫每個狀態遷移對應的代碼。在TapeDeckHandler中的每一個方法對以一個狀態遷移。每個狀態遷移方法都被標註爲@Transition,裏面定義了開始狀態(in),事件ID(on), 結束狀態(next):

public class TapeDeckHandler {
@State public static final String EMPTY = "Empty";
@State public static final String LOADED = "Loaded";
@State public static final String PLAYING = "Playing";
@State public static final String PAUSED = "Paused";

@Transition(on = "load", in = EMPTY, next = LOADED)
public void loadTape(String nameOfTape) {
System.out.println("Tape '" + nameOfTape + "' loaded");
}

@Transitions({
@Transition(on = "play", in = LOADED, next = PLAYING),
@Transition(on = "play", in = PAUSED, next = PLAYING)
})
public void playTape() {
System.out.println("Playing tape");
}

@Transition(on = "pause", in = PLAYING, next = PAUSED)
public void pauseTape() {
System.out.println("Tape paused");
}

@Transition(on = "stop", in = PLAYING, next = LOADED)
public void stopTape() {
System.out.println("Tape stopped");
}

@Transition(on = "eject", in = LOADED, next = EMPTY)
public void ejectTape() {
System.out.println("Tape ejected");
}
}


注意TapeDeckHandler並沒有實現TapeDeck接口,這是估計而爲之。

現在我們來分析一下代碼。首先是標註爲@Transition的loadTape代碼:

@Transition(on = "load", in = EMPTY, next = LOADED)
public void loadTape(String nameOfTape
) {

這段代碼的意思是,當磁帶機處於EMPTY狀態時,並且load事件被觸發則loadTape方法會被調用,並且磁帶機的狀態遷移到LOADED狀態。 在pauseTape,stopTape和ejectTape方法上的@Transition都和loadTape差不多,就不多解釋了。 playType方法看上去不太一樣。上面的圖中我們可以看到在LOADED或PAUSED狀態上觸發play事件都可以播放磁帶。如果在多個狀態遷移調用同一個方法可以如下使用@Transition標籤:

@Transitions({
@Transition(on = "play", in = LOADED, next = PLAYING),
@Transition(on = "play", in = PAUSED, next = PLAYING)
})
public void playTape() {


@Transitions標註裏可以指定一個遷移定義列表。

[b]關於@Transition的參數:[/b]

如果沒有指定on參數,默認是"*",意思是所有的狀態都符合條件。
如果沒有指定next參數,默認是遷移到"self",也就是保持當前狀態的意思。如果想要創建一個自我循環的狀態遷移,只需要指不定next就可以。
weight參數指定按什麼順序查找遷移, MINA會按weight參數升序查找遷移,默認值是0。

現在最後是從這個被標註的類裏創建一個StateMachine對象,並且用它作爲實現TapeDeck接口的代理對象。

public static void main(String[] args) {
TapeDeckHandler handler = new TapeDeckHandler();
StateMachine sm = StateMachineFactory.getInstance(Transition.class).create(TapeDeckHandler.EMPTY, handler);
TapeDeck deck = new StateMachineProxyBuilder().create(TapeDeck.class, sm);

deck.load("The Knife - Silent Shout");
deck.play();
deck.pause();
deck.play();
deck.stop();
deck.eject();
}



TapeDeckHandler handler = new TapeDeckHandler();
StateMachine sm = StateMachineFactory.getInstance(Transition.class).create(TapeDeckHandler.EMPTY, handler);


從TapeDeckHandler的實例創建一個StateMachine實例。在調用StateMachineFactory.getInstance(...) 方法時傳遞一個Transition.class作爲參數可以告訴工廠類,我們使用@Transition來創建狀態機,同時我們指定EMPTY作爲初始狀態。狀態機本質上是一個有向圖。狀態對象對應圖中的節點,遷移對應圖中的邊。TapeDeckHandler的每一個@Transition標註都對應一個Transition實例。
[b]
@Transition標註和Transition有什麼不同呢?[/b]
@Transition標註是標識狀態間遷移的方法用的。在後面MINA狀態機會爲每一個被@Transition標註的方法創建一個MethodTransition類。MethodTransition實現了Transition接口。在MINA狀態機框架中,你不需要直接使用Transition和MethodTransition類。

TapeDeck實例是通過StateMachineProxyBuilder創建的:

TapeDeck deck = new StateMachineProxyBuilder().create(TapeDeck.class, sm);


需要向StateMachineProxyBuilder.create()方法傳遞一個要代理類需要實現的接口和一個StateMachine實例。這個StateMachine實例會接收到由代理類的方法調用時產生的事件。

當上面的代碼執行時,會又如下的輸出:

Tape 'The Knife - Silent Shout' loaded
Playing tape
Tape paused
Playing tape
Tape stopped
Tape ejected


上面的狀態機和MINA有什麼關係呢?
你可能已經發現上面的例子沒有MINA相關的代碼。不要着急。後面我們會看到如何爲MINA的IoHandler接口創建狀態機。

上面的功能是如何工作的呢?

下面我們就看一下當一個代理類裏的方法被調用時發生了什麼。

[b]查找StateContext對象[/b]

StateContext對象非常重要,因爲它保持着當前狀態。當代理類裏的方法被調用時,它會要求StateContextLookup實例根據方法的參數返回一個StateContext的實例。一般StateContextLookup的實現裏會遍歷方法的參數,查找一個特殊類型的對象,用它來獲取StateContext對象。如果沒有找到關聯的StateContext對象,StateContextLookup會創建一個StateContext並且把它保存起來。

當代理MINA的IoHandler時我們使用一個IoSessoinStateContextLookup實例來查找參數中的IoSession。它會利用IoSession的屬性來爲每一個MINA的會話保存一個獨立的StateContext實例。這樣同一個狀態機的實例就可以爲所有的MINA的會話提供服務而不會干涉彼此。

在上面的例子中我們在使用StateMachineProxyBuilder來創建代理類的時候沒有指定StateContextLookup的實現。如果沒有指定默認使用SingletonStateContextLookup作爲實現。SingletonStateContextLookup會忽略傳給方法的參數,它總是返回相同的StateContext對象。很明顯這種方式在很多用戶併發的使用同一個狀態機時是沒有用的。例如使用IoHandler的環境。

把方法調用轉換爲Event對象。
在代理類上的所有方法調用都被轉換爲Event對象。每一個Event裏有一個id和0到多個參數。id對應着方法的名字事件的參數對應着方法的參數。調用方法deck.load("The Knife - Silent Shout") 對應的event對象就是{id = "load", arguments = ["The Knife - Silent Shout"]}。Event對象同時也包含一個StateContext對象的引用。


[b]狀態機調用[/b]

當Event對象創建完以後,代理類會調用StateMachine.handle(Event)。StateMachine.handle(Event)方法會遍歷當前狀態的所有可用遷移,查找可以接受當前Event對象的Transition對象。當找到對應的Transition對象。把所有找到的Transition對象按照weight排序(通過 @Transition標註來指定)。

[b]執行Transition[/b]

最後一步是調用匹配的Transition對象的execute(Event)方法。當Transition被調用,StateMachine會更新當前狀態到Transition定義的終止狀態。Transition是一個接口,每次使用@Transition標註時都有一個MethodTransition對象被創建。

[b]MethodTransition[/b]

MethodTransition對象非常重要,需要單獨說明一下。當事件id和@Transition裏的參數一致,並且標註的方法的參數和事件的參數一致,MethodTransition和對應的Event對象匹配。

所以,當Event類似於{id = "foo", arguments = [a, b, c]}時,方法

@Transition(on = "foo")
public void someMethod(One one, Two two, Three three) { ... }


當且僅當((a instanceof One && b instanceof Two && c instanceof Three) == true)的條件下和Event匹配。匹配以後,方法會被調用,參數就是Event裏的參數:

someMethod(a, b, c);


Integer, Double, Float等類型會匹配他們對應的原生類型int, double, float等。

上面的Event還可以和下面的方法匹配(事件參數是子集)

@Transition(on = "foo")
public void someMethod(Two two) { ... }


如果((a instanceof Two || b instanceof Two || c instanceof Two) == true). 在上面的例子中第一個匹配的事件參數會被綁定到two這個參數。

如果方法沒有參數,只要事件id一致就會匹配

@Transition(on = "foo")
public void someMethod() { ... }


更復雜的情況,如果第一個和第二個參數分別是Event類和StateContext也會匹配。也就是說:

@Transition(on = "foo")
public void someMethod(Event event, StateContext context, One one, Two two, Three three) { ... }
@Transition(on = "foo")
public void someMethod(Event event, One one, Two two, Three three) { ... }
@Transition(on = "foo")
public void someMethod(StateContext context, One one, Two two, Three three) { ... }


也會匹配Event {id = "foo", arguments = [a, b, c]} 如果滿足((a instanceof One && b instanceof Two && c instanceof Three) == true). 當前的Event對象會綁定到someMethod的第一個Event類型的參數event,當前的StateContext會綁定到someMethod的第二個context參數。

就像前面一樣事件參數的子集也會匹配。同時也可以直接使用StateContext的實現類。例如下面的:

@Transition(on = "foo")
public void someMethod(MyStateContext context, Two two) { ... }


方法參數的順序很重要。 如果方法想要訪問當先的Event類,必須把Event放到方法的第一個參數。StateContext可以是第一個參數(沒有Event參數)也可以是第二個參數(有Event參數)。事件參數也一定要按照順序匹配。MethodTransition在查找匹配時不會從排事件參數。

如果你已經走到這裏, 恭喜你! 我也認爲上面的解釋有點難於理解,下面的例子可能會讓你更明白一些:

假設Event對象{id = "messageReceived", arguments = [ArrayList a = [...], Integer b = 1024]}。則下面的方法會匹配:

// 所有的參數都直接匹配事件的參數
@Transition(on = "messageReceived")
public void messageReceived(ArrayList l, Integer i) { ... }

// 因爲((a instanceof List && b instanceof Number) == true)所以匹配
@Transition(on = "messageReceived")
public void messageReceived(List l, Number n) { ... }

// 因爲((b instanceof Number) == true)所以匹配
@Transition(on = "messageReceived")
public void messageReceived(Number n) { ... }

// 沒有參數的方法總是匹配
@Transition(on = "messageReceived")
public void messageReceived() { ... }

// 只需要當前的Event或StateContext的情況總是匹配
@Transition(on = "messageReceived")
public void messageReceived(StateContext context) { ... }

// 因爲((a instanceof Collection) == true)所以匹配
@Transition(on = "messageReceived")
public void messageReceived(Event event, Collection c) { ... }

下面的方法不匹:

// 參數順序不對
@Transition(on = "messageReceived")
public void messageReceived(Integer i, List l) { ... }

// ((a instanceof LinkedList) == false)
@Transition(on = "messageReceived")
public void messageReceived(LinkedList l, Number n) { ... }

// Event對象必須是第一個參數
@Transition(on = "messageReceived")
public void messageReceived(ArrayList l, Event event) { ... }

// 如果有Event,StateContext必須是第二個參數
@Transition(on = "messageReceived")
public void messageReceived(Event event, ArrayList l, StateContext context) { ... }

// Event對象必須在StateContext前面
@Transition(on = "messageReceived")
public void messageReceived(StateContext context, Event event) { ... }


狀態繼承

狀態實例可能有父狀態。如果StateMachine.handle(Event) 方法不能爲當前Event對象找到匹配的Transition,它會查找父狀態。如果仍然沒有匹配的會繼續查找父狀態的父狀態。

當你想爲所有的狀態加入一些共同的代碼又不想爲所有的狀態添加同一個@Transition時,這個功能很有用。下面是如何使用@State創建有層次的狀態:

@State    public static final String A = "A";
@State(A) public static final String B = "A->B";
@State(A) public static final String C = "A->C";
@State(B) public static final String D = "A->B->D";
@State(C) public static final String E = "A->C->E";


通過狀態繼承來處理錯誤

我們回過頭看一下TapeDeck的例子。如果我們在沒有磁帶插入的情況下調用deck.play() 會怎麼樣?我們試試:

public static void main(String[] args) {
...
deck.load("The Knife - Silent Shout");
deck.play();
deck.pause();
deck.play();
deck.stop();
deck.eject();
deck.play();
}

...
Tape stopped
Tape ejected
Exception in thread "main" o.a.m.sm.event.UnhandledEventException:
Unhandled event: org.apache.mina.statemachine.event.Event@15eb0a9[id=play,...]
at org.apache.mina.statemachine.StateMachine.handle(StateMachine.java:285)
at org.apache.mina.statemachine.StateMachine.processEvents(StateMachine.java:142)
...

哦~!我們得到了一個UnhandledEventException,因爲在EMPTY狀態下沒有play時間的遷移。我們可以爲所有的狀態添加一個沒有匹配情況下的遷移:

@Transitions({
@Transition(on = "*", in = EMPTY, weight = 100),
@Transition(on = "*", in = LOADED, weight = 100),
@Transition(on = "*", in = PLAYING, weight = 100),
@Transition(on = "*", in = PAUSED, weight = 100)
})
public void error(Event event) {
System.out.println("Cannot '" + event.getId() + "' at this time");
}


現在再來運行一下,我們不會得到異常,輸入如下:

...
Tape stopped
Tape ejected
Cannot 'play' at this time.

現在這種方法看上去工作的很好,對吧? 但是如果我們有30個狀態而不是4個會怎麼樣? 我們必須在error方法上添加30個@Transition的標註。這種做法不好。 現在我們用狀態繼承機制來實現:

public static class TapeDeckHandler {
@State public static final String ROOT = "Root";
@State(ROOT) public static final String EMPTY = "Empty";
@State(ROOT) public static final String LOADED = "Loaded";
@State(ROOT) public static final String PLAYING = "Playing";
@State(ROOT) public static final String PAUSED = "Paused";

...

@Transition(on = "*", in = ROOT)
public void error(Event event) {
System.out.println("Cannot '" + event.getId() + "' at this time");
}
}


上面的代碼得到的結果是一樣的,但是更容易維護。

在IoHandler中使用MINA狀態機

現在我們把磁帶機轉換爲一個TCP服務器,並擴展一些功能。服務器可以接受命令:load , play, stop等。響應可以是正+或負-。 協議是基於文本的,所有的命令和響應都是UTF-8字符串,並以CRLF (java裏是\r\n)結尾。 例如:

telnet localhost 12345
S: + Greetings from your tape deck!
C: list
S: + (1: "The Knife - Silent Shout", 2: "Kings of convenience - Riot on an empty street")
C: load 1
S: + "The Knife - Silent Shout" loaded
C: play
S: + Playing "The Knife - Silent Shout"
C: pause
S: + "The Knife - Silent Shout" paused
C: play
S: + Playing "The Knife - Silent Shout"
C: info
S: + Tape deck is playing. Current tape: "The Knife - Silent Shout"
C: eject
S: - Cannot eject while playing
C: stop
S: + "The Knife - Silent Shout" stopped
C: eject
S: + "The Knife - Silent Shout" ejected
C: quit
S: + Bye! Please come back!

TapeDeckServer的完成的代碼在mina-example模塊的org.apache.mina.example.tapedeck包裏,你可以在MINA的SVN庫裏找到。代碼使用MINA的ProtocolCodecFilter來轉換字節和命令對象。 每一個服務器可以識別的請求都對應一個Command。 我們在這裏就不說明codec的實現細節了。

現在,我們來看一下服務器是如何工作的。實現了狀態機的類是TapeDeckServer類。我們要做的第一件事就是狀態:

@State public static final String ROOT = "Root";
@State(ROOT) public static final String EMPTY = "Empty";
@State(ROOT) public static final String LOADED = "Loaded";
@State(ROOT) public static final String PLAYING = "Playing";
@State(ROOT) public static final String PAUSED = "Paused";


這裏沒有新的東西,處理事件的方法有了一些變化。我們看一下playTape方法:

@IoHandlerTransitions({
@IoHandlerTransition(on = MESSAGE_RECEIVED, in = LOADED, next = PLAYING),
@IoHandlerTransition(on = MESSAGE_RECEIVED, in = PAUSED, next = PLAYING)
})
public void playTape(TapeDeckContext context, IoSession session, PlayCommand cmd) {
session.write("+ Playing \"" + context.tapeName + "\"");
}


首先我們沒有使用前面的通用的@Transition和@Transitions標註,而是使用了MINA特定的@IoHandlerTransition 和 @IoHandlerTransitions標註。 通過MINA的IoHandler接口來創建狀態機時一般都使用這兩個標註,利用這兩個標註,可以使用Java的枚舉類型來定義狀態,而不是像前面一樣使用字符串。 IoFilter也有對應的Transition標註。


我們現在使用MESSAGE_RECEIVED代替了"play"作爲事件名(@IoHandlerTransition的on屬性)。這個常量定義在org.apache.mina.statemachine.event.IoHandlerEvents類裏,實際的值是"messageReceived",因爲它實際上是用來對用IoHandler的messageReceived() 方法的。Java5的靜態import機制可以讓我們直接使用常量,而不用引用類名。我們只需把下面的定義放在import段裏

import static org.apache.mina.statemachine.event.IoHandlerEvents.*;

另外一個變化的地方是我們使用自定義的StateContext實現TapeDeckContext。這個類可以保持當前磁帶的名字:

static class TapeDeckContext extends AbstractStateContext {
public String tapeName;
}


爲什麼不把磁帶名保存到IoSession?
我們可以把磁帶名保存到IoSession的屬性裏,但是推薦使用自定義的StateContext,因爲它是類型安全的。

最後一件需要注意的是playTape()方法,它的最後一個參數是PlayCommand。最後一個參數其實是IoHandler的messageReceived(IoSession session, Object message) 方法的message參數。這個意思是只有在接收到的字節可以裝換爲PlayCommand類時playTape()方法纔會被調用。

在磁帶機可以播放磁帶的之前,必須要加載磁帶。當接收到LoadCommand對象,磁帶機中指定編號的磁帶會被加載:


@IoHandlerTransition(on = MESSAGE_RECEIVED, in = EMPTY, next = LOADED)
public void loadTape(TapeDeckContext context, IoSession session, LoadCommand cmd) {
if (cmd.getTapeNumber() < 1 || cmd.getTapeNumber() > tapes.length) {
session.write("- Unknown tape number: " + cmd.getTapeNumber());
StateControl.breakAndGotoNext(EMPTY);
} else {
context.tapeName = tapes[cmd.getTapeNumber() - 1];
session.write("+ \"" + context.tapeName + "\" loaded");
}
}


這段代碼使用StateControl來遷移到下一個狀態。如果用戶指定一個錯誤的磁帶編號,我們不應該遷移狀態到LOADED,而應該繼續保持在EMPTY狀態:

StateControl.breakAndGotoNext(EMPTY);


後面會詳細介紹StateControl類。

connect方法在MINA調用IoHandler的sessionOpened()方法裏被調用:

@IoHandlerTransition(on = SESSION_OPENED, in = EMPTY)
public void connect(IoSession session) {
session.write("+ Greetings from your tape deck!");
}

這裏只是向客戶端寫了一句問候語,然後讓狀態機保持在EMPTY狀態。

pauseTape(), stopTape() 和 ejectTape()方法都和playTape()差不多,就不多說明了。 listTapes(), info() 和 quit() 方法都很簡單,也不說明了。請注意這三個方法是怎麼使用ROOT狀態的。這說明ist, info 和 quit命令可以在任何狀態上發生。

下面我們來看一下錯誤處理。error方法會在當前狀態下接收到非法命令是被調用:

@IoHandlerTransition(on = MESSAGE_RECEIVED, in = ROOT, weight = 10)
public void error(Event event, StateContext context, IoSession session, Command cmd) {
session.write("- Cannot " + cmd.getName() + " while "
+ context.getCurrentState().getId().toLowerCase());
}


error方法的weight比 listTapes(), info() 和 quit()的高,也就是說在執行上面的方法時不會調用error方法。error方法使用StateContext來得到當前狀態的id。在@State標註裏的字符串常量(EMPTY,LOADED等)會被MINA狀態機使用。

當ProtocolDecoder拋出CommandSyntaxException異常時commandSyntaxError()方法會被調用。它只是簡單的打印出客戶端發送過來的內容。

當commandSyntaxError()以外的其他的異常發生時,exceptionCaught()方法會被調用(它比commandSyntaxError()有更大的weight)。這個方法會立刻關閉會話。

最後一個@ IoHandlerTransition方法是unhandledEvent()。在其他的方法都沒有匹配的事件時,這個方法會被調用。我們需要這個方法是因爲我們沒有爲所有狀態上的所有事件都聲明瞭對應的方法。(例如我們沒有處理messageSent事件)沒有這個狀態機會在某些條件下拋出異常。

最後一段代碼是如何創建IoHandler代理類和main方法:

private static IoHandler createIoHandler() {
StateMachine sm = StateMachineFactory.getInstance(IoHandlerTransition.class).create(EMPTY, new TapeDeckServer());

return new StateMachineProxyBuilder().setStateContextLookup(
new IoSessionStateContextLookup(new StateContextFactory() {
public StateContext create() {
return new TapeDeckContext();
}
})).create(IoHandler.class, sm);
}

// This code will work with MINA 1.0/1.1:
public static void main(String[] args) throws Exception {
SocketAcceptor acceptor = new SocketAcceptor();
SocketAcceptorConfig config = new SocketAcceptorConfig();
config.setReuseAddress(true);
ProtocolCodecFilter pcf = new ProtocolCodecFilter(
new TextLineEncoder(), new CommandDecoder());
config.getFilterChain().addLast("codec", pcf);
acceptor.bind(new InetSocketAddress(12345), createIoHandler(), config);
}

// This code will work with MINA trunk:
public static void main(String[] args) throws Exception {
SocketAcceptor acceptor = new NioSocketAcceptor();
acceptor.setReuseAddress(true);
ProtocolCodecFilter pcf = new ProtocolCodecFilter(
new TextLineEncoder(), new CommandDecoder());
acceptor.getFilterChain().addLast("codec", pcf);
acceptor.setHandler(createIoHandler());
acceptor.setLocalAddress(new InetSocketAddress(PORT));
acceptor.bind();
}


createIoHandler()裏創建StateMachine的方法和我們之前的例子一樣,只是現在在調用StateMachineFactory.getInstance(…)時,使用的是IoHandlerTransition.class而不是Transition.class。這麼做是因爲我們在定義方法時確實使用的是@IoHandlerTransition標註。同時我們在這裏使用的IoSessionStateContextLookup和一個自定義的StateContextFactory。如果我們不使IoSessionStateContextLookup所有的會話共享同一個狀態機。

main方法創建了一個SocketAcceptor,並且添加了ProtocolCodecFilter來編碼和解碼命令。最後我們綁定了端口號12345.
發佈了15 篇原創文章 · 獲贊 2 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章