Creating reusable actions with Moose::Role - an example

overview

Have you ever implemented an action, which operates on one database entry, and later realized that it would be useful to apply this action to several entries at once?

Since Catalyst applications are, in general, very modular and easy to extend, it is not a big problem to create some kind of wrapper-action, which prepares the appropriate data and forwards to your action. There is nothing wrong about solving the problem that way, but you have to repeat this for every action (that shoud be applied to several database entries at once). You will soon realize that most of your wrapper-actions are more or less identical, and writing the same code again and again can be a big pain in the CurseWord.

An other solution would be to adjust your action, but what if you still need that old action (which only operates on one single database entry)? In that case, you have to do a lot of parameter checking to figure out whether the current request is a single-entry-request or a multi-entry-request. In the worst case, this procedure does not work as you expected and you have to spend a lot of time debugging your code, which is a even bigger pain in the ... You know what I mean!

This Article describes how to create a generic multi-action which can easily be used as a wrapper for any action, with minimal changes to your code. The generic multi-action will be moved from the controller to an extra Moose::Role. Since Roles can easily be applied to any Moose object, the multi-action can be reused in any controller, with minimal effort.

Chapter 5 from the Catalyst Tutorial is used as example application (which is some kind of book database, with the ability to delete single books by calling the delete-action with the books id as parameter). The code provided in this article will add the ability to select several books from the list, and delete all of them at once.

Preparation

This Article is based on the example code provided by the Catalyst tutorial, chapter 5. The code can be checked out by running:

 [nomos30] ~/src % svn co http://dev.catalyst.perl.org/repos/Catalyst/trunk/examples/Tutorial/MyApp_Chapter5

Make sure you have all dependencies installed by running:

 [nomos30] ~/src % cd MyApp_Chapter5/MyApp
 [nomos30] ~/src/MyApp_Chapter5/MyApp % perl MakeFile.pl
 [nomos30] ~/src/MyApp_Chapter5/MyApp % make

After that, you can start the application by running:

 [nomos30] ~/src/MyApp_Chapter5/MyApp % script/myapp_server.pl

You can use the following credentials to log in to the example application:

* username: test01
* password: mypass

Dependencies

Tasks

It is assumed that the relevant controller provides a list method, which uses Template::Toolkit to display the content.

the View

The books listing should provide:

* checkboxes to select books from the list
* a 'delete selected' button

the Controller

The generic multi-action should:

* find out which action to call
* find out which database entries have been selected
* call the method with the correct parameters
* create a status report

Implementation

updating the controller

At this point, all modification to the controller is done in lib/MyApp/Controller/Books.pm

To make the code more readable, we will create one action for each of the four Controller tasks mentioned above.

The following new actions will be created:

* get_multiaction: Private

This method will search the requests parameters for possible action names.

* arguments: none
* returns: $multiaction OR undef
* conventions

The name of the actions provided in the request parameters. The parameters name starts with "multi_", followed by the actions name. It is important to stick to this convention when udating the view.

Add the following code to lib/MyApp/Controller/Books.pm:

 sub get_multiaction :Private {
 	my ($self, $c) = @_;
 	foreach (keys %{$c->req->params}){
 		# search parameters for possible action names
 		if ($_ =~ /^multi_(\w+)$/){
 			# check whether the parameter is an action 
 			# of the current controller
 			if( $self->action_for($1)){
				# return the actions name
 				return $1;
 			}
 		}
 	}
 	return undef;
 }

* get_args: Private

This method searches the request parameters for possible Arguments to the requested action. It returns a reference to a list, containting CatupreArgs and Args for each selected database-entry, or undef if no entries where selected.

* arguments: $multiaction

The name of the currently requested action

* returns: \[ { captures => [$captureargs], args => [$arguments] } ,...] OR undef
* conventions

The request parameters should contain:

* one list called "selected"

containing ids of all selected database entries

* one list called "arguments_$i" per selected entry, with $i being the entries id

containing the arguments that should be passed to the requested action, for each selected database entry

* one list called "captures_$i" per selected entry, with $i being the entries id

containing the "capture arguments" that should be passed to the requested action, for each selected database entry

The result is returned as a ArrayRef. Each element in this array contains the arguments for one selected entry, stored in a hash.

Add the following code to lib/MyApp/Controller/Books.pm:

 sub get_args :Private {
 	my ($self, $c, $multiaction) = @_;
	# retrieving selecte entries
	my @selected = $c->req->param('selected');
	my @result;
	# looping over captures and args
	foreach (@selected){
		# retrieving catures and args
		my $captures = [$c->req->param("captures_$_")];
		my $args = [$c->req->param("arguments_$_")];
		# storing captures and args in result
		push @result, {captures => $captures, args => $args};

	}
	return (@result && @result > 0) ? \@result : undef;
 } 

* create_status_msg :Private

This method should create a status message for the current multiaction-request.

* parameters:

$action

A String, containing the name of the currently requested multiaction

$successfull

An ArrayRef, containting captures and args for all successfully executed multiactions

* returns: a String
* conventions:

It is assumed that the $successful - parameter has the same form as the ArrayRef returned by "get_args"

Add the following code to lib/MyApp/Controller/Books.pm:

 sub create_status_msg :Private{
 	my ($self, $c, $action, $successful) = @_;
 	my $msg = join "<br/>", map{
		"CaptureArgs: " . 
			join ", ", @{$_->{'captures'}} . 
		" - Args: ". 
			join ", ", @{$_->{'args'}}
		} @$successful;
 	return "sucessfully executed $action for: <br/> $msg";
 }

* multiaction :Chained('base') :PathPart('multiaction') :Args(0)

This method will put it all together: It calls "get_multiaction" and "get_args", performs the actual method-call, creates a status report and forwards to "list", when all work is finished.

* parameters: none
* returns: nothing

Add the following code to lib/MyApp/Controller/Books.pm:

 sub multiaction :Chained('base') :PathPart('multiaction') :Args(0) {

 	my ($self, $c) = @_;
 	# make sure that the current request is a "POST" request
 	if( $c->req->method eq 'POST'){
 		# try to find the requested multiaction
 		my $multiaction = $c->forward('get_multiaction');
 		if($multiaction){
 			# try to find the requested parameters
 			my $selected = $c->forward('get_args', $multiaction);
 			if($selected){
 				my $successful;
				# loop over all parameters
 				foreach(@$selected){
 					my $captures = $_->{captures};
 					my $args = $_->{args};
					# call the method
 					$c->visit($self, $multiaction, $captures, $args);
					# store the status information
 					push @$successful, $_;
 				}
				 # create a status mesage
 				my $status_msg = $c->forward('create_status_msg',[$multiaction, $successful]);
 				$c->flash(
 					status_msg => $status_msg,
 					) if $successful;
 			}
 			else{
 				$c->flash(error_msg => "MULTIACTION CANCELED: no entries selected");
 			}
 		}
 		else{
 			$c->flash(error_msg => "MULTIACTION CANCELED: unknown mutliaction");
 		}
 	}
 	else{
 		$c->flash(error_msg => "MULTIACTION CANCELED: not a POST request");
 	}
 	$c->response->redirect($c->uri_for($self->action_for('list')));
 }




updating the view

To account for the tasks described above, edit root/src/books/list.tt2, and

* add just before the <table> tag:
 <form method="POST" action="[% c.uri_for(c.controller.action_for('multiaction')) %]" />

* add just after the </table> tag:
 </form>

* edit the line containing the table header tags, and add a submit button for the delete action. after this, the section should look like this:


 <tr>
 <th>Title</th><th>Rating</th><th>Author(s)</th><th>Links</th>
 <th><input type="submit" name="multi_delete", value="delete selected"/></th>
 </tr>

* add a checkbox for each displayed database-entry. Add, just before the </tr> tag inside the FOEARCH-loop:
 <td> <input type="checkbox" name="selected" value="[% book.id %]"/> </td>
 <input type="hidden" name="captures_[% book.id %]" value="[% book.id %]"/>
 <input type="hidden" name="arguments_[% book.id %]" value="[% book.id %]"/>

after this, your list.tt2 should look like this:

 [% META title = 'Book List' -%]

 <form method="POST" action="[% c.uri_for(c.controller.action_for('multiaction')) %]" />
 <table>
 <tr>
 <th>Title</th><th>Rating</th><th>Author(s)</th><th>Links</th>
 <th><input type="submit" name="multi_delete", value="delete selected"/></th>
 </tr>
 [% # Display each book in a table row %]
 [% FOREACH book IN books -%]
   <tr>
     <td>[% book.title %]</td>
     <td>[% book.rating %]</td>
     <td>
       [% # Print count and author list using Result Class methods -%]
       ([% book.author_count | html %]) [% book.author_list | html %]
     </td>
     <td>
       [% # Add a link to delete a book %]
       <a href="[% c.uri_for(c.controller.action_for('delete'), [book.id]) %]">Delete</a>
     </td>
     <td> <input type="checkbox" name="selected" value="[% book.id %]"/> </td>
     <input type="hidden" name="captures_[% book.id %]" value="[% book.id %]"/>
     <input type="hidden" name="arguments_[% book.id %]" value="[% book.id %]"/>
   </tr>
 [% END -%]
 </table>
 </form>
 <p>
   <a href="[% c.uri_for('/login') %]">Login</a>
   <a href="[% c.uri_for(c.controller.action_for('form_create')) %]">Create</a>
 </p>

Test the multiaction:

Start the test application, and point your browser to localhost:3000/books/list

You should be able to select several books and delete them by clicking the "delete selected" button

Increasing reusability

At this point, we created a generic multi-action-wrapper in our Books-controller. The next part of this article shows how to increase reusability by moving the code from the controller to an Moose::Role.

Creating the role

* Create the file lib/MyApp/MultiAction.pm and add the following Content:


 package MyApp::MultiAction;

 use MooseX::MethodAttributes::Role;
 use namespace::autoclean;




 1;

MooseX::MethodAttributes::Role is a extension to Moose::Role, which adds the ability to define method attributes (like :Private, :Chained ...) in Roles.

* Move the code

Remove all new created code from lib/MyApp/Controller/Books.pm and paste it to lib/MyApp/MultiAction.pm, after "use namespace::autoclean", but before "1;"

* Adapt the code

* Change the methodattribute from multiaction to ":Action"

After that the first line of your multiaction method should look like this:

 sub multiaction :Action {   

* Add requirements

Because our Role itself is not a Catalyst Controller, we have to make sure that the required methods - list and action_for - are present. Add

 requires qw/list action_for/;

anywhere between "use namespace::autoclean" and "1;". After that, our Role can only be applied to Objects which provide this two methods.

Using the role

At this point, all new functionality has moved from our controller to a Moose::Role. The only thing left is to apply the role to our controller:

Open lib/MyApp/Controller/Books.pm again and

* Make the controller to use the role

by adding

 with 'MyApp::MultiAction';

after the BEGIN section at the top of the file.

Note: the with statement MUST NOT be included in the BEGIN section, because this would make the perl interpreter to apply the role BEFORE the list method has been compiled, which would result in a compile time error.

* Activate the generic multiaction in the controller

by adding

 __PACKAGE__->config(
 	action=> {      
 		multiaction => {Chained => 'base', PathPart => 'multiaction', Args => 0}, 
 	},
 );

just before

  __PACKAGE__->meta->make_immutable;

at the end of the file.

Test the Role

Start the test-application, and point your browser to localhost:3000/books/list

You should be able to select several books and delete them by clicking the "delete selected" button, just as before.

Adapt the behaviour of "multiaction" for a single controller

In addition to a better code structure, implenting a helper-action for every identified task has another big benefit: You can change the behaviour of every task, by just overriding the corresponding method in your controller.

EXAMPLE: The status-report created by our Role is very generic - and very ugly. We can change the report for our deleted Books by overiding the "create_status_msg" method in lib/MyApp/Controller/Books.pm.

Open the file and add the following code:



 around create_status_msg => sub{
 	my ($orig,$self, $c, $action, $successful) = @_;
 	if( $action eq 'delete'){
 		my $msg = join "<br/>", map{ join ", ", @{$_->{'captures'}}} @$successful;
 		return "sucessfully deleted all books with the following ids: <br/> $msg";
        }
        else{
       		return $self->$orig($c,$action,$successful);
	}

 };

Note: Methods implemented in Roles can not be overridden with the "override" and "augment" pragmas provided by Moose. (This is, because these methods are not inherited from a parent class. They are implemented in the calling class itself.) If you don't need the original method at all, you can just redefine the complete method in your controller. If you only want to change the behaviour under certain conditions, and otherwise stick to the original behaviour, you can use Mooses "before", "after" and "around" functionality. In the above example, the status message is only changed for "delete" actions. All other multiactions will produce the old, generic messages. See Moose::Manual::MethodModifiers for details.

Reusing the multiaction

* using the role in a different controller

To use the role in another controller, just apply the role to it, and activate the multiaction, as described above. Don't forget to update your list-template aswell.

* adding more multiactions

If you want to use the multiaction-feature with another action, implement you action in your controller, and add a corresponding submit-button to your list template.

Notes:

When implementing the multiaction as described above, most of your code is generic and reusable, but the templates used in this example are very simple. As long as your actions expect the entries ids as arguments or captureargs, everything is fine. If your actions need more complex parameters, you have to improve your templates. If different actions need very different sets of parameters, you have to adapt your get_args-method. The name of the requested action is passed as a parameter to get_args. Use it to find out what arguments you have to provide.

Conclusion

It is possible to apply an action to several datasets at once, by creating a generic wrapper-method which calculates the correct parameters and calls the requested action.

Moving actions from the Controller to a Moose::Role is one possibility to make you code reuseable for any controller. One benefit of using roles is, that they can easily be applied to any Moose object, as long as this object fulfills all of the roles requirements. The Objects do not even have to be Catalyst Controllers. If you want to use your code in a Catalyst-independent application, you can do so. All you have to care about is, that you code passes the correct parameters to the roles methods.

Splitting an action into several (more or less) atomic methods makes your code more readable and adds the possibility to finetune your actions behaviour on a per-controller basis.

The lack of some missing method modifiers in Roles can be bypassed with standart Moose functionality, and without black magic.

Author

Lukas Thiemeier <lukas@thiemeier.net>

http://public.thiemeier.net