Writing AJAX-style web applications can be very tedious. If you're using XML as your transport layer, you have to parse the XML before you can work with it. It's a bit easier if you're using JSON, but once you have parsed the data, the data still needs to be turned into HTML markup that matches the current markup on the page. Finally, the newly created markup needs to be inserted into the correct place in the DOM, and any event handlers need to be attached to the appropriate newly-inserted markup.
So there's the parsing, the markup assembly, the DOM insertion, and finally the event handler attachment. Most of the time, people tend to write custom code for each element that needs asynchronous updating. There are several drawbacks with this scenario, but the most frustrating part is probably that the presentation logic is implemented twice--once in a templating language on the server which is designed specifically for outputting markup, and again on the client with inline Javascript. This leads to problems both in the agility and in the maintainability of this type of application.
With flojax, this can all be accomplished with one generalized implementation. The same server-side logic that generates the data for the first synchronous request can be used to respond to subsequent asynchronous requests, and unobtrusive attributes specify what to do for the rest.
The Basics
The first component for creating an application using the flojax strategy is to break up the content that you would like to reload asynchronously into smaller fragments. As a basic example of this, let's examine the case where there is a panel of buttons that you would like to turn into asynchronous requests instead of full page reloads.
The rendered markup for a fragment of buttons could look something like this:
<div class="buttons">
<a href="/vote/up/item1/">Vote up</a>
<a href="/vote/down/item1/">Vote down</a>
<a href="/favorite/item1/">Add to your favorites</a>
</div>
In a templating language, the logic might look something like this:
<div class="buttons">
{% if voted %}
<a href="/vote/clear/{{ item.id }}/">Clear your vote</a>
{% else %}
<a href="/vote/up/{{ item.id }}/">Vote up</a>
<a href="/vote/down/{{ item.id }}/">Vote down</a>
{% endif %}
{% if favorited %}
<a href="/favorite/{{ item.id }}/">Add to your favorites</a>
{% else %}
<a href="/unfavorite/{{ item.id }}/">Remove from your favorites</a>
{% endif %}
</div>
(Typically you wouldn't use anchors to do operations that can change state on the server, so you can imagine this would be accomplished using forms. However, for demonstration and clarity purposes I'm going to leave these as links.)
Now that we have written a fragment, we can start using it in our larger templates by way of an include, which might look something like this:
...
<p>If you like this item, consider favoriting or voting on it:</p>
{% include "fragments/buttons.html" %}
...
To change this from being standard links to being asynchronously updated, we just need to annotate a small amount of data onto the relevant links in the fragment.
<div class="buttons">
{% if voted %}
<a href="/vote/clear/{{ item.id }}/" class="flojax" rel="buttons">Clear your vote</a>
{% else %}
<a href="/vote/up/{{ item.id }}/" class="flojax" rel="buttons">Vote up</a>
<a href="/vote/down/{{ item.id }}/" class="flojax" rel="buttons">Vote down</a>
{% endif %}
{% if favorited %}
<a href="/favorite/{{ item.id }}/" class="flojax" rel="buttons">Add to your favorites</a>
{% else %}
<a href="/unfavorite/{{ item.id }}/" class="flojax" rel="buttons">Remove from your favorites</a>
{% endif %}
</div>
That's it! At this point, all of the click events that happen on these links will be changed into POST requests, and the response from the server will be inserted into the DOM in place of this div with the class of "buttons". If you didn't catch it, all that was done was to add the "flojax" class onto each of the links, and add a rel attribute that refers to the class of the parent node in the DOM to be replaced--in this case, "buttons".
Of course, there needs to be a server side component to this strategy, so that instead of rendering the whole page, the server just renders the fragment. Most modern Javascript frameworks add a header to the request to let the server know that the request was made asynchronously from Javascript. Here's how the code on the server to handle the flojax-style request might look (in a kind of non-web-framework-specific Python code):
def vote(request, direction, item_id):
item = get_item(item_id)
if direction == 'clear':
clear_vote(request.user, item)
elif direction == 'up':
vote_up(request.user, item)
elif direction == 'down':
vote_down(request.user, item)
context = {'voted': direction != 'clear', 'item': item}
if request.is_ajax():
return render_to_response('fragments/buttons.html', context)
# ... the non-ajax implementation details go here
return render_to_response('items/item_detail.html', context)
There are several advantages to writing your request handlers in this way. First, note that we were able to totally reuse the same templating logic from before--we just render out the fragment instead of including it in a larger template. Second, we have provided a graceful degradation path where users without javascript are able to interact with the site as well, albeit with a worse user experience.
That's really all there is to writing web applications using the flojax strategy.
Implementation Details
I don't believe that the Javascript code for this method can be easily reused, because each web application tends to have a different way of showing errors and other such things to the user. In this post, I'm going to provide a reference implementation (using jQuery) that can be used as a starting point for writing your own versions. The bulk of the work is done in a function that is called on every page load, called flojax_init.
function flojax_clicked() {
var link = $(this);
var parent = link.parents('.' + link.attr('rel'));
function successCallback(data, textStatus) {
parent.replaceWith(data);
flojax_init();
}
function errorCallback(request, textStatus, errorThrown) {
alert('There was an error in performing the requested operation');
}
$.ajax({
'url': link.attr('href'),
'type': 'POST',
'data': '',
'success': successCallback,
'error': errorCallback
});
return false;
}
function flojax_init() {
$('a.flojax').live('click', flojax_clicked);
}
There's really not a lot of code there. It POSTS to the given URL and replaces the specified parent class with the content of the response, and then re-initializes the flojax handler. The re-initialization could even be done in a smarter way, as well, by targeting only the newly inserted content. Also, you might imagine that an alert message probably wouldn't be such a great user experience, so you could integrate error messages into some sort of Javascript messaging or growl-style system.
Extending Flojax
Often times you'll want to do other things on the page when the asynchronous request happens. For our example, maybe there is some kind of vote counter that needs to be updated or some other messages that need to be displayed.
In these cases, I have found that using hidden input elements in the fragments can be useful for transferring that information from the server to the client. As long as the value in the hidden elements adheres to some predefined structure that your client knows about (it could even be something like JSON if you need to go that route).
If what you want can't be done by extending the fragments in this way, then flojax isn't the right strategy for that particular feature.
Limitations
This technique cannot solve all of the world's problems. It can't even solve all of the problems involved in writing an AJAX-style web application. It can, however, handle a fair amount of simple cases where all you want to do is quickly set up a way for a user's action to replace content on a page.
Some specific examples of things that flojax can't help with are if a user action can possibly update many items on a page, or if something needs to happen without a user clicking on a link. In these situations, you are better off coding a custom solution instead of trying to shoehorn it into the flojax workflow.
Conclusion
Writing AJAX-style web applications is usually tedious, but using the techniques that I've described, a large majority of the tedious work can be reduced. By using the same template code for rendering the page initially as with subsequent asynchronous requests, you ensure that code is not duplicated. By rendering HTML fragments, the client doesn't have to go through the effort of parsing the output and converting the result into correct DOM objects. Finally, by using a few unobtrusive conventions (like the rel attribute and the flojax class), the Javascript code that a web application developer writes is able to be reused again and again.
I don't believe that any of the details that I'm describing are new. In fact, people have been doing most of these things for years. What I think may in fact be new is the generalization of the sum of these techniques in this way. It's still very much a work in progress, though. As I use flojax more and more, I hope to find not only places where it can be extended to cover more use cases, but also its limitations and places where it makes more sense to use another approach.
What do you think about this technique? Are you using any techniques like this for your web applications? If so, how do they differ from what I've described?
Recently I've been spending some quality time trying to decrease page load times and decrease the number of database accesses on a site I'm working on. As you would probably suspect, that means dealing with caching. One common thing that I need to do, however, is invalidate a large group of cache keys when some action takes place. I've devised a pattern for doing this, and while I'm sure it's not novel, I haven't seen any recent write-ups of this technique. The base idea is that we're going to add another thin cache layer, and use the value from that first layer in the key to the second layer.
First, let me give a concrete example of the problem that I'm trying to solve. I'm going to use Django/Python from here on in, but you could substitute anything else, as this pattern should work across other frameworks and even other languages.
import datetime
from django.db import models
class Favorite(models.Model):
user = models.ForeignKey(User)
item = models.ForeignKey(Item)
date_added = models.DateTimeField(default=datetime.datetime.now)
def __unicode__(self):
return u'%s has favorited %s' % (self.user, self.item)
Given this model, now let's say that we have a function that gets the Favorite instances for a given user, which might look like this:
def get_favorites(user, start=None, end=None):
faves = Favorite.objects.filter(user=user)
return list(faves[start:end])
There's not much here yet--we're simply filtering to only include the Favorite instances for the given user, slicing it based on the given start and end numbers, and forcing evaluation before returning a list. Now let's start thinking about how we will cache this. We'll start by just implementing a naive cache strategy, which in this case simply means that the cache is never invalidated:
from django.core.cache import cache
def get_favorites(user, start=None, end=None):
key = 'get_favorites-%s-%s-%s' % (user.id, start, end)
faves = cache.get(key)
if faves is not None:
return faves
faves = Favorite.objects.filter(user=user)[start:end]
cache.set(key, list(faves), 86400 * 7)
return faves
Now we come to the hard part: how do we invalidate those cache keys? It's especially tricky because we don't know exactly what keys have been created. What combinations of start/end have been given? We could invalidate all combinations of start/end up to some number, but that's horribly inefficient and wasteful. So what do we do? My solution is to introduce another layer. Let me explain with code:
import uuid
from django.core.cache import cache
def favorite_list_hash(user):
key = 'favorite-list-hash-%s' % (user.id,)
cached_key_hash = cache.get(key)
if cached_key_hash:
key_hash = cached_key_hash
else:
key_hash = str(uuid.uuid4())
cache.set(key, key_hash, 86400 * 7)
return (key_hash, not cached_key_hash)
Essentially what this gives us is a temporary unique identifier for each user, that's either stored in cache or generated and stuffed into the cache. How does this help? We can use this identifier in the keys to the get_favorites function:
from django.core.cache import cache
def get_favorites(user, start=None, end=None):
key_hash, created = favorite_list_hash(user)
key = 'get_favorites-%s-%s-%s-%s' % (user.id, start, end, key_hash)
if not created:
faves = cache.get(key)
if faves is not None:
return faves
faves = Favorite.objects.filter(user=user)[start:end]
cache.set(key, list(faves), 86400 * 7)
return faves
As you can see, the first thing we do is grab that hash for the user, then we use it as the last part of the key for the function. The whole if not created thing is just an optimization that helps to avoid cache fetches when we know they will fail. Here's the great thing now: invalidating all of the different cached versions of get_favorite for a given user is a single function call:
from django.core.cache import cache
def clear_favorite_cache(user):
cache.delete('favorite-list-hash-%s' % (user.id,))
By deleting that single key, the next time get_favorites is called, it will call favorite_list_hash which will result in a cache miss, which will mean it will generate a new unique identifier and stuff it in cache, meaning that all of the keys for get_favorites are instantly different. I think that this is a powerful pattern that allows for coarser-grained caching without really sacrificing much of anything.
There is one aspect of this technique that some people will not like: it leaves old cache keys around taking up memory. I don't consider this a problem because memory is cheap these days and Memcached is generally smart about evicting the least recently used data.
I'm interested though, since I don't see people posting much about nontrivial cache key generation and invalidation. How are you doing this type of thing? Are most people just doing naive caching and calling that good enough?
Lately I've really fallen in love with writing utilities whose interface is simply HTTP. By making it accessible via HTTP, it's really easy to write clients that talk to the utility and, if the need arises, there are lots of tools that already exist for doing things with HTTP, like load balancing and caching, etc.
While it would be easy to use a framework to build these utilities, lately I've been choosing not to do so. Web frameworks like Django and Pylons are great when you need to build a fully-featured web application that will be accessible by people. When it will only be computers talking to the service, however, a lot of the machinery provided by frameworks is unneeded and will only slow your utility down. Instead of using a framework, we're going to write a pure WSGI application.
An Example: Music Discovery Website
This has all been very abstract, so let's take an example: Suppose you run a music discovery website that lets you play songs online. Next to each song, you simply want to display how many times the song has been played.
One solution to that problem could be to have a play_count column on the table where the song metadata is stored. Every time someone plays the song, you could issue an UPDATE on the row and increase the play_count by one. This solution will work while your site is small, but as more and more people begin using the application, the number of writes to your database is going to kill its performance.
A much more robust and scalable solution is to append a new line to a text log file every time a song is played, and have a process run regularly to scoop up all of the log files and update those play_count fields in the database.
However, even if you have that regular process run once every hour, there's still too great a lag time between when a user takes an action and when they see the results of that action. This is where our WSGI utility comes into play. It can serve as a realtime play counter to count the plays in between the time when the logs are analyzed and the play_count columns updated.
Song Play Counter
We can design the interface for our WSGI song play counter utility any way that we like, but I'm going to try to keep it as RESTful as I can. The interface will look like this:
- GET /song/SONGID will return the current play count of the given song
- POST /song/SONGID will increment the play count of the given song by one, and return its new value
- GET / will return a mapping of all songs registered to their respective play counts
- DELETE / will clear the whole mapping
So let's get started. First, I always like to start with a very basic skeleton:
def application(environ, start_response):
start_response('200 OK', [('content-type', 'text/plain')])
return ('Hello world!',)
This does what you would imagine, returns Hello world! to each and every request that it receives. Not very useful, so let's make it more interesting:
from collections import defaultdict
counts = defaultdict(int)
def application(environ, start_response):
global counts
path = environ['PATH_INFO']
method = environ['REQUEST_METHOD']
if path.startswith('/song/'):
song_id = path[6:]
if method == 'GET':
start_response('200 OK', [('content-type', 'text/plain')])
return (str(counts[song_id]),)
elif method == 'POST':
counts[song_id] += 1
start_response('200 OK', [('content-type', 'text/plain')])
return (str(counts[song_id]),)
else:
start_response('405 METHOD NOT ALLOWED', [('content-type', 'text/plain')])
return ('Method Not Allowed',)
start_response('404 NOT FOUND', [('content-type', 'text/plain')])
return ('Not Found',)
We've now added the data structure that we're using to keep track of the counts, which in this case is a defaultdict(int). We're also now looking at the request path and method, as well. If it's a GET starting with /song/, we look up the count and return it, and if it's a POST starting with /song/, we increment it by one before returning it. Also, we're doing the proper thing if we detect a method that's not allowed: we're returning HTTP error code 405.
Now let's add the final bit of functionality:
from collections import defaultdict
counts = defaultdict(int)
def application(environ, start_response):
# ... start of app
if path.startswith('/song/'):
# ... song-specific logic
elif path == '/':
if method == 'GET':
res = ','.join(['%s=%s' % (k, v) for k, v in counts.iteritems()])
start_response('200 OK', [('content-type', 'text/plain')])
return (res,)
elif method == 'DELETE':
counts = defaultdict(int)
start_response('200 OK', [('content-type', 'text/plain')])
return ('OK',)
else:
start_response('405 METHOD NOT ALLOWED', [('content-type', 'text/plain')])
return ('Method Not Allowed',)
# ... rest of app
We've done basically the same thing here as we did with the previous example: we are looking at the request path and method and doing the appropriate action. There really is nothing very tricky going on here. We're inventing our own format for the case where we return the counts for all songs, but it's nothing that will be hard to parse.
NOTE: Generally you would want to use some sort of threading lock primitive before accessing a global dictionary like this. I will be using Spawning to run this WSGI application, with a threadpool size of 0 to use cooperative coroutines instead of standard threads, so I am able to get away without locks for this application. To install Spawning for yourself, just type:
sudo easy_install Spawning
Running the Utility
Let's just take a quick look at how this utility works, from the command line:
$ spawn -t 0 -p 8000 counter.application
...and in another window:
$ curl http://127.0.0.1:8000/song/1
0
$ curl -X POST http://127.0.0.1:8000/song/1
1
$ curl http://127.0.0.1:8000/song/1
1
$ curl -X POST http://127.0.0.1:8000/song/5
1
$ curl -X POST http://127.0.0.1:8000/song/5
2
$ curl http://127.0.0.1:8000/
1=1,5=2
$ curl -X DELETE http://127.0.0.1:8000/
OK
As you can see, it seems to be working correctly. The play counter is behaving as expected.
Writing a Client to Talk to our Utility
Now that we have our WSGI utility written to keep track of the counts on our songs, we should write a client library to communicate with this server.
import httplib
class CountClient(object):
def __init__(self, servers=['127.0.0.1:8000']):
self.servers = servers
def _get_server(self, song_id):
return self.servers[song_id % len(self.servers)]
def _song_request(self, song_id, method):
conn = httplib.HTTPConnection(self._get_server(song_id))
conn.request(method, '/song/%s' % (song_id,))
resp = conn.getresponse()
play_count = int(resp.read())
conn.close()
return play_count
def get_play_count(self, song_id):
return self._song_request(song_id, 'GET')
def increment_play_count(self, song_id):
return self._song_request(song_id, 'POST')
def get_all_play_counts(self):
dct = {}
for server in self.servers:
conn = httplib.HTTPConnection(server)
conn.request('GET', '/')
counts = conn.getresponse().read()
conn.close()
if not counts:
continue
dct.update(dict([map(int, pair.split('=')) for pair in counts.split(',')]))
return dct
def reset_all_play_counts(self):
status = True
for server in self.servers:
conn = httplib.HTTPConnection(server)
conn.request('DELETE', '/')
resp = conn.getresponse().read()
if resp != 'OK':
status = False
conn.close()
return status
What we have here is a simple class that converts Python method calls to the RESTful HTTP equivalents that we have written for our WSGI utility. The best part about this setup, though, is that it uses a hash based on the song_id to determine which server to connect to. If you only ever do per-song operations, this setup is quite literally infinitely scalable. You could have thousands of servers keeping track of song counts, none of them knowing about each other. Since the decision about which server to talk to happens on the client side, there needs to be no communication between the servers whatsoever.
However, if you start to use the get_all_play_counts and reset_all_play_counts, then eventually after many many servers are added it will start to get slower.
Let's explore this client:
>>> from countclient import CountClient
>>> c = CountClient()
>>> c.get_play_count(1)
0
>>> c.increment_play_count(1)
1
>>> c.increment_play_count(1)
2
>>> c.get_play_count(1)
2
>>> c.increment_play_count(5)
1
>>> c.get_all_play_counts()
{1: 2, 5: 1}
>>> c.reset_all_play_counts()
True
>>> c.get_all_play_counts()
{}
Benchmarks!
I'm not a benchmarking nut in any way, shape, or form these days. However, in Python it's quite tough to beat pure-WSGI applications for raw speed. Using my MacBook Pro with a 2.5GHz Intel Core 2 Duo and 2 GB 667 MHz DDR2 SDRAM I got these results from ApacheBench:
e:Desktop ericflo$ ab -n 10000 http://127.0.0.1:8000/song/1
...
Concurrency Level: 1
Time taken for tests: 7.792 seconds
Complete requests: 10000
Failed requests: 0
Write errors: 0
Total transferred: 1020000 bytes
HTML transferred: 10000 bytes
Requests per second: 1283.31 [#/sec] (mean)
Time per request: 0.779 [ms] (mean)
Time per request: 0.779 [ms] (mean, across all concurrent requests)
Transfer rate: 127.83 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.1 0 2
Processing: 0 1 0.8 1 43
Waiting: 0 1 0.5 0 43
Total: 1 1 0.8 1 43
Take these results with a huge grain of salt, but suffice it to say, it's fast. It would probably be even faster using mod_wsgi instead of Spawning.
Drawing Conclusions From This Exercise
I don't want to misconstrue my standpoint on this: frameworks definitely have their place. There's no way you would want to write an entire user-facing application with pure WSGI unless you were using lots of middleware and stuff and at some point you're just recreating Pylons. But when you're writing a HTTP utility like we did here, then I think that pure-WSGI is the way to go.
I'd like to touch on one more nice side effect of using pure-WSGI: You can run it in any application server that supports WSGI. That means Google App Engine, Apache, Spawning, CherryPy, and many other containers. It can easily be served by pure python so even on very restrictive shared hosting it's possible to run your utility.
What do you think of pure-WSGI utilities? Are you using them in your app? I'd love to hear about it--leave me a comment and tell me your thoughts on this subject.
Caching is easy to screw up. Usually it's a manual process which is error-prone and tedious. It's actually quite easy to cache, but knowing when to invalidate which caches becomes a lot harder. There is a subset of caching the caching problem that, with Django, can be done quite easily. The underlying idea is that every Django model has a primary key, which makes for an excellent key to a cache. Using this basic idea, we can cover a fairly large use case for caching, automatically, in a much more deterministic way. Let's begin.
First, we need to decide upon a setting for how long each individual item should be saved in the cache. I'm going to call that SIMPLE_CACHE_SECONDS and grab it like so:
from django.conf import settings
SIMPLE_CACHE_SECONDS = getattr(settings, 'SIMPLE_CACHE_SECONDS', 2592000)
The next thing we need to do is be able to generate a cache key from an instance of a model. Thanks to Django's _meta information, we can get the app label and model name, plus the primary key, and we're all set.
def key_from_instance(instance):
opts = instance._meta
return '%s.%s:%s' % (opts.app_label, opts.module_name, instance.pk)
So now let's start setting the cache! My preferred way to do it is via a signal, but you could do it in a less generic way by overriding save on a model. My signal looks like this:
from django.core.cache import cache
from django.db.models.signals import post_save
def post_save_cache(sender, instance, **kwargs):
cache.set(key_from_instance(instance), instance, SIMPLE_CACHE_SECONDS)
post_save.connect(post_save_cache)
Now that we're putting items in the cache, we should probably delete them from the cache when the model instance is deleted:
from django.db.models.signals import pre_delete
def pre_delete_uncache(sender, instance, **kwargs):
cache.delete(key_from_instance(instance))
pre_delete.connect(pre_delete_uncache)
This is all good and well, but right now we don't really have a way to get at that information. Cache is pretty useless if we never use it! Our interface to the database is through the model's QuerySet, so let's make sure that our QuerySet is making good use of our newly-populated cache. To do so, we'll subclass QuerySet:
from django.db.models.query import QuerySet
class SimpleCacheQuerySet(QuerySet):
def filter(self, *args, **kwargs):
pk = None
for val in ('pk', 'pk__exact', 'id', 'id__exact'):
if val in kwargs:
pk = kwargs[val]
break
if pk is not None:
opts = self.model._meta
key = '%s.%s:%s' % (opts.app_label, opts.module_name, pk)
obj = cache.get(key)
if obj is not None:
self._result_cache = [obj]
return super(SimpleCacheQuerySet, self).filter(*args, **kwargs)
The only method that we really need to overwrite is filter, since get and get_or_create both just rely on filter anyway. The first for loop in the filter method just checks to see if there is a query by id or pk, and if so, then we construct a key and try to fetch it from the cache. If we found the item in the cache, then we place it into Django's internal result cache. At that point we're as good as done. Then we just let Django do the rest!
This SimpleCacheQuerySet won't be used all on its own though, we need to actually force a model to use it. How do we do that? We create a manager:
from django.db import models
class SimpleCacheManager(models.Manager):
def get_query_set(self):
return SimpleCacheQuerySet(self.model)
Now that we have this transparent caching library set up, we can go around to all of our models and import it and attach it as needed. Here's how that might look:
from django.db import models
from django_simplecache import SimpleCacheManager
class BlogPost(models.Model):
title = models.TextField()
body = models.TextField()
objects = SimpleCacheManager()
That's it! Just by attaching this manager to our model we're getting all the benefits of per-object caching right away. Of course, this isn't comprehensive. It does hit the vast majority of use cases, though. If you were to use this for a real site, however, then you wouldn't be able to use update method. It's a little bit trickier since there's no post_update signal, but it's nowhere near impossible. Let's just say that, for now, it's being left unimplemented as an exercise for the reader. in_bulk would be actually quite fun to implement, too, because you could get all of the results possible from cache, and all the rest could be gotten from the database, then merge those two dictionaries before returning.
I think this would be a really good reusable Django application. Essentially, we've grown a library from the ground up that really isn't all that much code. I think it took me 20 minutes to write the actual code, but with some serious polish and love, this library could evolve into something that I think many reusable apps would use to great benefit. What do you think? What should a good, simple, Django caching library have?
I love screencasts. I love them at least enough to create 16 of them this year. That's why when James Tauber announced that the Pinax Project would be having a screencast competition, it made me very very happy!
Pinax is already a great aide for those who want to build a website with a great deal of functionality in a short period of time. If you haven't checked it out yet, I really hope that you do. It tries to take the best of breed Django applications and glues them together with minimal configurable code and a set of common templates and conventions.
One area that Pinax could be better about, however, is documentation. We're working on it, but it still has a bit to go. That's one of the reasons why this contest is so great. By entering a screencast into the contest, you are really helping the Pinax community out a lot.
The best part about this contest, however, is that there are prizes! One prize, $100 at Amazon, will go to the very best screencast. A second prize, $40 at Amazon, will go to the second best screencast. As of this writing, there are no entries. So what are you waiting for? Head on over to the contest details page and enter your screencast today!
All Content

