Apache Mina 文档翻译 - 第十四章 - 状态机

第十四章 - 状态机

如果你在用MINA开发复杂的网络应用程序,有时候需要用来状态模式来应对问题的复杂性。在自己实现状态模式之前你可以先看看mina状态机,利用这个功能可以方便的实现状态模式。

一个简单的例子

我们来演示一下怎么用mina状态机实现一下简单的例子。下面这个图展示了一个标准的磁带机的状态迁移。 椭圆表示状态,箭头表示状态迁移。每一个状态迁移都付了一个标签,表示事件,这些事件触发状态迁移。

Apache Mina 文档翻译 - 第十四章 - 状态机


磁带机的初始状态是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标注里可以指定一个迁移定义列表。

关于@Transition的参数:

    如果没有指定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实例。

@Transition标注和Transition有什么不同呢?

@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接口创建状态机。

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

下面我们就看一下当一个代理类里的方法被调用时发生了什么。

查找StateContext对象

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对象的引用。


状态机调用

当Event对象创建完以后,代理类会调用StateMachine.handle(Event)。StateMachine.handle(Event)方法会遍历当前状态的所有可用迁移,查找可以接受当前Event对象的Transition对象。当找到对应的Transition对象。把所有找到的Transition对象按照weight排序(通过 @Transition标注来指定)。

执行Transition

最后一步是调用匹配的Transition对象的execute(Event)方法。当Transition被调用,StateMachine会更新当前状态到Transition定义的终止状态。Transition是一个接口,每次使用@Transition标注时都有一个MethodTransition对象被创建。

MethodTransition

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.

你可能感兴趣的:(apache)