Blogging on App Engine, part 1: Static serving

This is the first in a series of articles on writing a blogging system on App Engine. An overview of what we're building is here.

As promised, today we'll be covering the static serving component of our blog-to-be. First, though, is the naming issue. There were a lot of good names proposed by readers. Unfortunately, pretty much every one of them is taken on appspot.com. In the end, I settled on a name suggested to me out-of-band: 'bloggart'. My wife, who loves to draw, has kindly promised to draw me a picture of a boastful looking monster to act as a mascot.

Now down to business. Create a new app called 'bloggart-demo' (or whatever you wish, really), and put the following in its app.yaml file:

application: bloggart-demo
version: live
runtime: python
api_version: 1

handlers:
- url: /remote_api
  script: $PYTHON_LIB/google/appengine/ext/remote_api/handler.py
  login: admin

- url: /.*
  script: static.py

Note that we're including remote_api right away. Without any sort of admin interface at this stage, it's going to be our only way of creating some initial content to test things out with. As discussed in the introductory post, we're separating out the 'static serving' code from the rest of the blog, so everything else gets sent to static.py, which you should create now. Obviously we'll need a datastore model to put all our content in, so put the following in your static.py file:

class StaticContent(db.Model):
  body = db.BlobProperty()
  content_type = db.StringProperty(required=True)
  last_modified = db.DateTimeProperty(required=True, auto_now=True)
  etag = aetycoon.DerivedProperty(lambda x: hashlib.sha1(x.body).hexdigest())

Other than the 'body' property, which is fairly self-evident, we have a mandatory 'content_type' property, so we can serve up multiple types of content, a last_modified property so we can handle caching and conditional responses properly, and an automatically generated etag property, which is also used to permit conditional responses. Note that we're using the aetycoon library, discussed in a previous article, to automatically generate the value of the etag whenever it's modified.

Since this module is going to be used as a library by other parts of the system - in addition to serving content directly - we should define a public interface so that we don't need to change the rest of our code if we modify how content is stored. Add the following functions to our module:

def get(path):
  return StaticContent.get_by_key_name(path)

def set(path, body, content_type, **kwargs):
  content = StaticContent(
      key_name=path,
      body=body,
      content_type=content_type,
      **kwargs)
  content.put()
  return content

As you can see, these are fairly straightforward right now. get() is simply a wrapper for StaticContent.get_by_key_name, while set() constructs a new StaticContent and stores it to the datastore. It would be fairly straightforward to implement the model memcaching pattern here; we haven't done so both for simplicity, and because the performance gain of memcaching vs a datastore get for a single entity is fairly small. Nevertheless, there is some gain both in performance and robustness - so this would be a good future enhancement.

Now we're ready to define a RequestHandler class that will handle requests for static content:

class StaticContentHandler(webapp.RequestHandler):
  def output_content(self, content, serve=True):
    self.response.headers['Content-Type'] = content.content_type
    last_modified = content.last_modified.strftime(HTTP_DATE_FMT)
    self.response.headers['Last-Modified'] = last_modified
    self.response.headers['ETag'] = '"%s"' % (content.etag,)
    if serve:
      self.response.out.write(content.body)
    else:
      self.response.set_status(304)
  
  def get(self, path):
    content = get(path)
    if not content:
      self.error(404)
      return

    serve = True
    if 'If-Modified-Since' in self.request.headers:
      last_seen = datetime.datetime.strptime(
          self.request.headers['If-Modified-Since'],
          HTTP_DATE_FMT)
      if last_seen >= content.last_modified.replace(microsecond=0):
        serve = False
    if 'If-None-Match' in self.request.headers:
      etags = [x.strip('" ')
               for x in self.request.headers['If-None-Match'].split(',')]
      if content.etag in etags:
        serve = False
    self.output_content(content, serve)

There's a fair bit to absorb here, so let's step through it:

output_content() is a fairly straightforward method. It sets the appropriate HTTP headers, based on the content type, last modified date, and etag of the StaticContent entity it's passed. Then, it either returns a standard response with the content of the page, or a '304 not modified', if the 'serve' parameter was False.

HTTP supports 'conditional responses'. This is when the client supplies some information to the server about what conditions it wants to get a response under, allowing the server to skip sending the entire content of the page if the client doesn't actually need it. The body of the get() method is mostly concerned with determining if we're handling a conditional request. If the 'If-Modified-Since' header is set, we parse the supplied timestamp, and determine if it's older than the current timestamp for our content; if it's not, we clear the 'serve' flag. Note that we have to truncate the last_modified timestamp, since it may have a milliseconds value, while the passed in timestamp will not. If the 'If-None-Match' header is set, we parse out a list of ETag values, and compare it to the current ETag; if any of them match, we clear the 'serve' flag. Finally, we call output_content to actually return the response.

For brevity, we've left out the definition of the WSGIApplication, as well as the imports. For the full code, see the repository.

Now that we have our static serving code written, we can upload it to App Engine, and use remote_api to poke some sample content in. Start up remote_api_shell.py (it's available in the root directory of the Python SDK) and try the following:

PYTHONPATH=. remote_api_shell.py -s localhost:8080 bloggart-demo
...
bloggart-demo> import static
bloggart-demo> static.set('/', 'Hello, world!', 'text/plain')


Fetch the root page of your app, and you should see "Hello, world!" displayed.

You can see the app so far (such as it is) at http://bloggart-demo.appspot.com/, and you can view the source on github. Both will be kept up to date as the series progresses.

In the next post, we'll create the beginnings of an Admin interface that can handle post submissions.

Comments

blog comments powered by Disqus