robotlegs / robotlegs-framework

An ActionScript 3 application framework for Flash and Flex
https://robotlegs.tenderapp.com/
MIT License
966 stars 261 forks source link

Thoughts on the architecture #114

Closed creynders closed 11 years ago

creynders commented 11 years ago

I've been working on 2 extensions, swapping between them whenever I got stuck, which happened a lot. Partly due to my vague understanding of how everything works, but in a few cases also because I think some things are missing in the architecture.

For instance, if you take a look at commands, there doesn't seem to be a consistent way how commands are executed. Every extension (eventCommandmap and messageCommandmap) is responsible for executing them themselves. There's a lot of overlap (verifying it's a command, handling guards, hooks, mapping and unmapping the command) I understand why though, since the eventCommandMap needs to do other "stuff" in between those phases compared to the messageCommandMap. However, I think it would be a MAJOR improvement if there was some kind of CommandExecutor which you could reuse when writing an extension and decorate/extend/hook into its execution sequence. Or for instance if the CommandCenterExtension would provide a centralized execute method just as the commandmap in RL1 did. It wouldn't even have to be exposed in the API, but it would be nice to have something like it.

Speaking of commands in general: is it necessary to enforce the implementation of an execute method? Might a command not just as well be a class that implements a PostConstruct tagged method? I'd drop the whole describeType(mapping.commandClass).factory.method.(@name == "execute").length() thing (it's pretty slow too) and make the calling of the execute method only happen if the class implements a ICommand interface. The whole seems more flexible that way.

Then, I really miss factories or some kind of way to define which classes are created by default. As I already commented in the commandCenter extension it seems like a waste that the only thing preventing the reuse of the CommandMapper class is the fact that it creates concrete CommandMapping instances. The same applies to the ScopedEventDispatcherExtension. It seems a pity that EventDispatcher instances are created by default w/o any option to define another implementation of IEventDispatcher. (Obviously it's pretty easy to copy/paste the code from ScopedEventDispatcherExtension and create your own custom extension, but it also violates DRY)

I have a feeling RL2 is way superior to RL1 when it comes to "normal" usage, but in my (granted limited) experience when creating extensions it seems you need to overcome a lot more hurdles than before.

darscan commented 11 years ago

Nice. Damn, I should have mentioned that I'd pushed some commits to our shared branch. I'm going to rebase your branch before merging, but that does mean that you might have to abandon your current WIP branch and make a new one (if my thinking is correct).

darscan commented 11 years ago

I still need some time to figure out how I feel about Extract and Execute. BTW, have you looked at Parsley commands at all?

http://www.spicefactory.org/parsley/docs/3.0/manual/managedcommands.php

It has many of the same concepts. If you look at 7.2.1 you can see that the mapping itself can be inferred from the signature of the execute method. Which means that all you need to do from the outside is register the command.

That page leaves out the MessageHandler stuff which covers splitting properties out of any message, and applies to commands as well. For example:

[MessageHandler(type="com.bookstore.events.LoginMessage",messageProperties="user,role"]
public function handleLogin (user:User, role:String) : void {

http://www.spicefactory.org/parsley/docs/3.0/manual/messaging.php

Anyhow, I think my concern is that if we move away from a lightweight trigger->execute mechanism, then we need to rethink how commands (and messages) work entirely. Just thinking aloud here.

creynders commented 11 years ago

then we need to rethink how commands (and messages) work entirely

Yeah, I understand your concern. But, to me at least, reconceptualizing commands and messages is not an option. Not only time-wise, but also because I think RL already has the purest command/message concept of all the frameworks.

The Parsley-way is definitely not for me. It does things in a way I hate, by convention, assumption and distirbuting wiring configuration. When I'm reading Parsley examples I'm constantly going "WTF, where does that come from?" and "where do I find that wiring?" It's what won me over to RL literally after a few minutes glancing through the examples and the tutorials: it's explicit. You want that to be injected into this then you better map it that. No magic, no assumptions made by the framework; you, the developer, write it down.

This is something I feel very strongly about.

Obviously you can get more explicit with Parsley, but where it's done and how is terrible, IMO. The receiver of a message should NOT, in any possible way, care what message it's dealing with (= also one of my main reasons to start on the PayloadEventsCommandMap) it should only care about the data it receives.

[MessageHandler(type="com.bookstore.events.LoginMessage",messageProperties="user,role"]
public function handleLogin (user:User, role:String) : void

This makes me weep. You hardwire the command to the message type (as a String [!], that's just awful for people like myself who are constantly renaming things). Also, this means that if someone dumps his project in my lap and I need to find out where that particular message is dealt with I'll be text-searching my classes. Awful, awful, awful.

On the other hand, this:

    public function execute (msg: GetUserProfileMessage): AsyncToken {
        return service.getUserProfile(msg.userId);
    }

makes me weep too. The auto-wiring thing I mean. Again, you'll be searching through tons of classes to find out where your GetUserProfileMessage is dealt with. And again you're hardwiring the message type to your command.

The main difference between my approach and the parsley way is that they distribute wiring configuration, while I distribute mapping configuration. IMO, the latter is ok.

Let's take the Parsley example:

//LoginService.as
[Execute]
public function login(user:User, role:String) : void
{
    //do login
}
//LoginEvent.as
[Extract]
public var user:User;

[Extract]
public var role : String;
//LoginConfig.as
eventCommandMap.map(LoginEvent.LOGIN, LoginEvent)
    .toCommand(LoginService);

Benefits:

  1. the service class does not know it's being executed as a command
  2. it doesn't know what event triggers it, or if it's even triggered by an event, it could be called directly. In fact you can mix and match if you want.
  3. the event class has no clue what happens when it's broadcast
  4. I as a developer only need to look at LoginConfig to know what's happening:

    • since LoginService is a service and not a command I know it will either implement an execute method or have an execute-tagged method, which at this moment is all the same to me.
    • it's being triggered by the LoginEvent.

    I don't have to go searching anywhere since the wiring configuration still is where it should be: in a config file.

This is WAY more flexible than the Parsley-shizzle. If I've implemented the above and suddenly

  1. I need to add some kind of service configuration step for LoginService: I create a command, remap the event in the config file, et voila.
  2. I need an ordered sequence of commands to happen when the LoginEvent is dispatched: I create the sequencing command, remap the event in the config file, and use the directCommandMap in the sequencing command to map the service as the last step in line.
  3. I need to map the service to a different event: I remap it in the config file.

In none of the above cases do I need to touch the event nor the service class.

if we move away from a lightweight trigger->execute mechanism

That's just it, we don't. We just provide an alternative way of configuring the mapping. It still is lightweight, both code-wise, but also during run-time.

Phew, this went on for ever.

When writing this I suddenly realized there's one more thing to do: allow command classes to be mapped as interfaces, instead of concrete classes.

injector.map(ILoginService).toType(LoginService);

eventCommandMap.map(LoginEvent.LOGIN, LoginEvent)
    .toCommand(ILoginService);

Which means that you need to execute-tag the method in the interface and not the concrete class. And this only makes sense IMO.

I know it's a lot to accept (and to grok maybe) but remember this is all additional functionality, as a dev you don't need to use any of this. Code-wise it's a small addition and in processing time there's little overhead, but the added leeway is huge, while still conforming to all the characteristics of what makes RL such a great framework: you're forced to encapsulate, decouple etc

creynders commented 11 years ago

And don't get me wrong, I fully understand and have no hard time at all to accept if you deem this too much or think we're getting off-course. The concept of rigorously killing your darlings is something I adhere to fully.

creynders commented 11 years ago

now you can map commands to interfaces

I kept it in a separate feature-branch branched from my (new) WIP-branch, since even though code-wise very little changes, it could have major consequences.

Just to be clear, this has nothing to do with the execute and extract-tags. This is more of a following through of the fact that we allow to configure the execute-method of commands. Since that opens up the possibility of using other classes than command classes, we should allow for command polymorphism IMO. But there's plenty of pitfalls. I had to move the creation of the childinjector from the triggers to the executor. On one hand this makes sense, since ALL command maps provide their executors with a child injector, but on the other hand this means a new injector is created for each trigger [Edit: silly me, this already was the case]. This was necessary, since the command instance is mapped in the injector and we can't risk this to overwrite the command injector mapping system-wide. Leaving it to the extension devs would be really dangerous.

I totally understand if you think this is not acceptable. And I'm going to back down for a while now until you've had enough time to review the previous changes and suggestions. I can imagine it's becoming a bit overwhelming. I'd hate it if an idea got shot down simply because the plethora of changes and ideas is just too much to get an overview of the impact.

darscan commented 11 years ago

Have no fear, I wasn't suggesting that we actually rework messaging and commands at this point!

Also, I get where you're coming from, it's just that most people have knee-jerk reactions to surface level aspects of Parsley, like string based mappings, which puts them off digging deeper into the framework. And that's a pity. At the same time, I'm not trying to make another Parsely, and I understand why people like RL.

But there are a lot of good concepts, and powerful approaches that people miss out on because they get stuck on small details. For each Metatag or string-based convenience there is a corresponding fluent API for configuring the same thing through code (which is what the tags end up invoking anyway). All the points you mention above are possible in Parsley. Aaaanyway, getting back to our framework... :)

I think I'm cool with the [Execute] stuff (still need to take a proper look, but seems good from one or two quick reviews).

How do you feel about [Expose] instead of [Extract]? There's something about the word extract in this context that doesn't sit well with me.

darscan commented 11 years ago

I had to move the creation of the childinjector from the triggers to the executor.

That's totally cool, and makes sense regardless of the interface mapping stuff.

But there's plenty of pitfalls.

Lemme know what they are!

creynders commented 11 years ago

most people have knee-jerk reactions to surface level aspects of Parsley

Knee-jerk reactions? Me? Never! :)

. For each Metatag or string-based convenience there is a corresponding fluent API for configuring the same thing through code

Ok, gotcha. I got sidetracked by the examples you posted. I'll dig into the fluent API for a change.

How do you feel about [Expose] instead of [Extract]

A lot better phonetically, but not sure semantically. 'Expose' sounds like you're modifying the access control attribute. I took 'extract' as a somewhat antonym of 'inject' and it describes what should be done to/with the member by the framework, just as 'inject' does, not what the developer is doing with it; in that sense 'expose' is a worse fit, though I like the sound of it more. But maybe I'm splitting hairs, i.e. I'm OK with it, it's better than 'extract'.

Maybe what's not sitting right with 'extract' is that it sounds as if the value is taken away from the instance.

Lemme know what they are!

Yeah, I should've written 'potential pitfalls', I need to think it through, that we're not overlooking something fundamental with how future extensions with slightly different requirements might come into trouble because of this.

creynders commented 11 years ago

If we don't keep to the describes-what-the-framework-does-with-it, maybe simply [Payload] would be a better fit? I've been thinking about how to explain the changes to someone coming from RL1 and I can imagine some confusion arising from the different terms/concepts. For instance I think one of the most read new FAQ entries will be:

I tagged this method with execute yet it won't receive my Inject-tagged values as parameters.

Maybe if we use [Payload] we can more clearly delineate the difference between payloads and values mapped to the injector?

creynders commented 11 years ago

Lemme know what they are!

Apparently none, checked the SCM etc. Just pushed it to the WIP

creynders commented 11 years ago

So, I was looking through @dotdotcommadot his example and reading this and think I've come up with a better solution for intermodular communication, something like this:

We create a ModuleEventMap which gets injected with the local event dispatcher, it exposes methods to map events in which we can define the direction toChildren, toParent, toAll. It also provides a createChildMap method and parentMap accesor which are used to setup the hierarchy, just as with injectors. In other words it creates a channel between modules, that plugs into the existing scoped event dispatchers.

Another option would be to allow communication direction configuration of the ModuleEventMap itself: multiplex, simplex facing the children, simplex facing the parent.

If you don't have the headspace for it now, no problem, I just jot it down here, so that I don't forget what my idea was, in order to sketch it out, once we finish with the command maps.

darscan commented 11 years ago

Coolio, there's an issue for this here: https://github.com/robotlegs/robotlegs-framework/issues/118

darscan commented 11 years ago

I think this might be a good time to merge our shared branch into master, cut another beta, and open a new issue for "Payload/Extract".

creynders commented 11 years ago

I think this might be a good time to merge our shared branch into master, cut another beta

Cool. Maybe it's a good time to use the new SwiftSuspenders version too? I ran into the issue fixed by this commit a lot a while back. Or has that been taken care of already? I must confess I'm having a hard time figuring out from which commit our swiftsuspenders swc has been compiled.

open a new issue for "Payload/Extract".

Will do. I'll have a first try at integrating it into the core, doesn't matter whether the tag will be renamed later on, I'll go with Payload for the time being.

creynders commented 11 years ago

Pushed the stress test for the command executor.

  1. compare execution of 50000 command stubs before and after commit creynders/robotlegs-framework@9c6c02ef7cafefa4b78695630ce82a273d91ede1

    before: 458, 440, 443, 457
    after: 749, 762, 807, 758

    As you can see, it does have an impact. Relativizing note: it's 50.000 executions, that's a LOT.

  2. map the ICommand interface to the command stub to the injector, 50000 iterations

    result : 836, 817, 820, 829 

    Conclusion: differs very little with the after of the previous test, i.e. retrieving a command class mapped to an interface doesn't put a lot of extra strain

  3. map the ICommand interface to the command stub to the root of an injector-chain of 11 children, 50000 iterations (11= 10 in test + 1 in executor)

    result: 1678, 1980, 2071, 1650

    Conclusion: serious impact, i.e. the length of the chain strains execution.

Overall: even though execution speed is clearly affected, I do think it's all well in between reasonable boundaries. I think we can conclude from 1 and 2 that probably the biggest impact is caused by the move of the child injector creation to the executor. Having looked at all command map extensions, I'd say the typical chain length is 2 to 3, if you want I can produce numbers for that length as well.

darscan commented 11 years ago

I think we've covered everything here? Going to close this. We can open fresh issues for any bits we missed.