24 days of Perl code from RJBS! Feed

When in doubt, add more globals.

Global::Context - 2010-12-17

EXPERIMENTAL CODE AHEAD

Last week, I wrote about the highly experimental DNS::Oterica. This week, I'll be talking about the dangerously experimental Global::Context.

The Law of Demeter Sucks

Actually, I'm a pretty firm believer in at least a modified form of the Law of Demeter in designing systems, but sometimes you really need a global. Sometimes the only alternative to a global is inversion of control, a fairly complex pattern to implement. That doesn't mean that globals are always a better choice than IOC, just that both are choices. (Like many other often-lousy program choices, global variables have been more maligned than they deserve. They deserve to be disliked, but maybe not quite demonized.)

Years ago, we wanted to run all code inside of dynamic scopes that would provide "context" for the call. We called the notional project Class::Contexual. Context would tell us things like what user had initiated the request, from what IP, and what URL had been hit. This wouldn't just be part of our web framework, though. Scripts run by the customer service staff would provide the same kind of context.

Since we'd always have the same kind of context, we could always check permissions by using a single authorization system built into the user class. When logging, we'd always know where requests came from. The important thing was that we'd always have a context, and the context would be the same type of object everywhere. Our notional API looked something like:


1: 
2: 
3: 
4: 
5: 
6: 
7: 

 

sub do_stuff {
  my ($self, @args) = @_;

  X::Permission->throw unless $self->context->user->can_do_stuff;

  $self->with_more_context({ action => 'doing stuff' })->do_else;
}

 

Then, do_else would be called on some clone of $self that differed only in context.

The whole thing got really messy really quick, and eventually we abandoned the idea and just stuck to looking at the web request.

Years later, the idea kept coming up here and there as a solution to a number of different problems. We finally said, "You know what? This is just what global variables are for." After all, the context is describing the global execution context. It's only for code that we have written and run internally, and isn't going to be consumed by general publicly distributed code.

We even made sure that the global context system could be re-used by multiple sets of libraries in a single process by making the specific global variable used pluggable. That is, two different systems could use the global context system without clobbering each others context. With that much safety ensured, the next question was just what we needed in our context.

The Content of The Context

We decided that for any given action, we'd only care about a few things:

  1. a handle for performing authorization checks; the auth Token

  2. a record of where the request came from (via email? the web? what IP?); the request's Terminal

  3. a simple stack of how the request came in and got routed; the Stack

Auth token could come in all forms. We might get a web request from a user with a logged-in session identified in his cookie. We might get an OAuth signed request with a token associated with a user and a subset of that user's rights. The token might even just represent "being run by employee rjbs who has superuser privileges." What we do know is that it isn't going to change one we get it. Every request has exactly one valid token, and once we have it, we don't change it. Tokens are associated with users.

The terminal, too, is pretty straightforward. We can tell, looking at the web request or the console program being run, where the request came from. Once we know that, it never needs to change.

Both the token and the terminal can be represented by URIs, although we might want to use private URI schemas for them: myapp://token/websession/1234

The stack isn't very complicated, either. It's an array of frames, and each frame doesn't need much more than a name. Imagine that user on the web has uploaded a list of contacts to add to his address book. We might have the following context, when we get to the underlying code that's going to do the import:


1: 
2: 
3: 
4: 
5: 
6: 
7: 
8: 
9: 

 

token:
  uri : myapp://token/websession/1234
  user: MyApp::User object
terminal:
  uri : ip://1.2.3.4
stack:
  - account setup wizard
  - address book manager
  - contact importer

 

The stack can add and drop frames as the program runs and execution enters and exits subroutines. We don't tie the context's stack to the program's stack one-to-one, because that would make refactoring the code much more complicated. Instead, we can just add frames dynamically, and Perl will pop them off when we leave that part of the program.

We get that benefit by making the context object a real old-fashioned global variable that can be localized with local.

Putting Things into Context

A simple program that uses Global::Context might look something like this:


1: 
2: 
3: 
4: 
5: 
6: 

 

use Global::Context -all, '$Context';

ctx_init({
  terminal => 'ip://1.2.3.4',
  auth_token => 'myapp://token/websession/123',
});

 

This imports a $Context global and initializes it by calling ctx_init. If some other part of the program has already initialized the context, an exception is thrown -- once we've set up the starting context, we're only supposed to replace it locally. For example, if our first action is to decide to route down into the setup wizard, we might write this:


1: 
2: 
3: 
4: 
5: 
6: 
7: 
8: 

 

# our request is routed to the setup wizard:
if ( ... ) {
  local $Context = ctx_push("account setup wizard");

  $wizard->process_request( $r );
}

$Logger->log("program complete");

 

That's it! We've pushed a frame onto the stack. $Context is now a new object, identical to what it was before, safe for the extra stack frame. We can keep pushing onto the stack as we descend through our program -- or we can leave it untouched. As we go back up the frame, the previous value is restored. For example, by the time we're logging "program complete," above, the "account setup wizard" stack frame is gone.

This works because we've made sure that every part of the context is immutable. Nothing that we change at a deeper scope will be changed and then lost when we exit that scope. The only allowable change is the addition of stack frames.

Marrying the context to logging make it easy for any part of the program to mention, in its, logs, why it's doing something, without changing its API at all to allow caller info to be passed in. That is, we can take some deep, crufty old routine and make it tell us exactly why it's doing something dangerous:


1: 
2: 
3: 
4: 
5: 
6: 
7: 
8: 
9: 
10: 
11: 
12: 

 

sub __randomize_user_password {
  my ($self, $enc, $str, $dig, $_uk1, $uk_3, $uk_2, @rest) = @_;

# log exactly why we're going to do this:
$Logger->log([
    "randomizing user password for %s because of %s",
    $self->user_id,
    $Context->as_json,
  ]);

  $self->password = re_enc($uk1, ...);
}

 

Specialized Context

Token, terminal, and stack behavior are all implemented as roles. Because it's pretty likely that different users of a system like Global::Context would want to allow different kinds of token, users, and so on, it's easy to subclass any part of the system with more specific restrictions.

If the only kind of terminal you want to support is a web browser, you can write a single Terminal class and require that the context object's terminal attribute be a member of it.

The Future

The future of this totally untested software (and idea) is, well, to be tested. It's a design we've been thinking about for quite some time, but it needs to see some real combat. After that, it will probably either fail and never be seen again, or it will stick and simplify large amounts of our code.

See Also