ifandelse / machina.js

js ex machina - finite state machines in JavaScript
http://machina-js.org/
Other
1.93k stars 147 forks source link

Feature request: Add arguments to transition method #78

Closed murillo128 closed 9 years ago

murillo128 commented 9 years ago

Hi,

Could it be possible to add arguments to the transition method so they are passed to the state _onEnter method call?

Best regards Sergio

ifandelse commented 9 years ago

@murillo128 Apologies for taking four days to respond! This is actually a question that's come up before, and is something I don't currently plan to add support for. However, I believe machina may already support what you need to do.

First, here's my reasoning behind why I'd prefer to not add support for transition args: If a conditional action is necessary inside the _onEnter of a new state, then what you really have is FSM input. Having the _onEnter double as an input handler for one or more implied inputs can become problematic, as you may end up with duplicate behavior (in an explicitly named input handler and in the _onEnter), and it couples input and transition without modeling the input as such. Consider this somewhat contrived example... here we have an FSM managing the sending of messages when a particular connection is online or offline-idle. If it's offline-idle, it transitions to online, passes the msg as a transition arg and sends the message:

// pretending, for a moment, that _onEnter supports arguments
states: {
  online: {
     _onEnter: function( transitionArgs ) {
      this.someProviderApi.connect().then( function() {
        this.someProviderApi.sendMessage( transitionArgs );
      }.bind( this ) );
    }
  },
  "offline-idle": {
    sendMessage: function( msg ) {
      this.transition( "online", msg );
    }
  }
  // other states, etc.
}

Note that what the offline-idle state handles as an explicitly named input (sendMessage) is treated as implied input in the online state's _onEnter. In reality, we'd have to check to see if transitionArgs were passed, and not just assume it always contains a message. Our online state also likely needs its own sendMessage handler as well, so it would have duplicate logic. In addition, this will quickly break down as new kinds of input need to be handled beyond sendMessage (what if we needed to add more specific message types like sendAcknowledgement, sendReply, sendComplete etc?). Our _onEnter in the online state would grow significantly not only to cover what kind of input needs to be handled, but the last state may be important as well (since the previous state may not always be offline-idle).

I have yet to run into a use-case where the need for transition args didn't involve either the need to trigger an input handler in the new state or read some state from the FSM. If your scenario involves the need to trigger input, you can use the deferUntilTransition or the deferAndTransition method (deferAndTransition is available in machina v1 or later):

states: {
  online: {
    sendMessage: function( msg ) {
       this.someProviderApi.sendMessage( msg );
    }
  },
  "offline-idle": {
    sendMessage: function( msg ) {
      /* this is the same as calling 
          this.transition( "online" );
          this.handle( "sendMessage", msg );
      */
      this.deferAndTransition( "online" );
    }
  }
  // other states, etc.
}

(See the wiki for API documentation on the differences between deferUntilTransition and deferAndTransition, etc.)

The above approach maintains an explicit/intentional nature about the input your FSM handles.

If your scenario doesn't involve the need to trigger input, but instead you need to store some data while in one state, and read it in another, then you can simply store it on the FSM instance itself if you're using a machina.Fsm or on the client if you are using a machina.behavioralFsm instance.

I hope this helps! If I've completely misunderstood the need, feel free to reply and better educate me as to what your use-case is. Thanks!

murillo128 commented 9 years ago

thanxs for your answer. Very much appreciated.

Yes, I was thinking in doing a transition && handle and move the functionality to the input handler, and also agree with you that passing an argument to the _onEnter is not an elegant FSM design.

However, I still fill that I feel missing (or I have failed to find in the docs and source code) to be able to keep an internal attributes of the state. Currently if I have to store a counter or set an attibute for calling my API I need to set it at FSM level and it get's quite polluted after a while.

In that sense, I would rename my feature request, instead of been able to pass an argument to the _onEnter, I think it would be able to have an "internal state" or attributes within each state, and be able to set the from the FSM while transitioning.

For example, it would be something like:

this.transition('display-item',{id: 1});

So the id would be and state attribute I could access from all input handlers and _onEnter/_onExit without having to store it globally on the FSM.

ifandelse commented 9 years ago

@murillo128 I definitely understand what you're asking - unfortunately it's not something that I would want to add to machina's core lib. Here's why: at the foundation, every machina FSM is a BehavioralFsm - which is machina's way of describing just the states/behavior of a machine, but not the long-lived instance on which the machine is acting. BehavioralFsms actually receive a client argument as the first call to every method, including _onEnter, _onExit, etc. (the point of behavioral FSMs is to allow one FSM act on multiple clients, so no need to store transient state on the FSM itself). the machina.Fsm constructor actually extends the BehavioralFsm, but presents itself as the client, saving the trouble of having to pass an explicit client param to each method/handler. Here's an example of the same FSM behavior, one as a BehavioralFsm, the other as a plain Fsm:

var plainFsm = new machina.Fsm({
    initialize: function() {
        this.history = [];
    },
    initialState: "on",
    states: {
        "on": {
            _onEnter: function() {
                this.history.push("turning on");
            },
            "turn.on": function() {
                this.history.push("already on!");
            },
            "turn.off": "off"
        },
        "off": {
            _onEnter: function() {
                this.history.push("turning off");
            },
            "turn.on": "on",
            "turn.off": function() {
                this.history.push("already off!");
            }
        }
    },
    turnOn: function() {
        this.handle("turn.on");
    },
    turnOff: function() {
        this.handle("turn.off");
    }
});

var behavioralFsm = new machina.BehavioralFsm({
    initialState: "on",
    states: {
        "on": {
            _onEnter: function(client) {
                client.history.push("turning on");
            },
            "turn.on": function(client) {
                client.history.push("already on!");
            },
            "turn.off": "off"
        },
        "off": {
            _onEnter: function(client) {
                client.history.push("turning off");
            },
            "turn.on": "on",
            "turn.off": function(client) {
                client.history.push("already off!");
            }
        }
    },
    turnOn: function(client) {
        this.handle(client, "turn.on");
    },
    turnOff: function(client) {
        this.handle(client, "turn.off");
    }
});

var client = {
    history: []
};

plainFsm.turnOn();
plainFsm.turnOff();
plainFsm.turnOff();
plainFsm.turnOn();
plainFsm.turnOn();

behavioralFsm.turnOn(client);
behavioralFsm.turnOff(client);
behavioralFsm.turnOff(client);
behavioralFsm.turnOn(client);
behavioralFsm.turnOn(client);

console.log(plainFsm.history);
console.log(client.history);
});

It sounds like using a behavioral FSM may work well for what you want to do.

murillo128 commented 9 years ago

thanxs @ifandelse for the detailed answer. I appreciate your time and why you don't want to implement the change.

Anyway, here are the changes I am working on, just in case you find it interesting (untested yet): https://github.com/ifandelse/machina.js/pull/82