Приглашаем посетить
Романтизм (19v-euro-lit.niv.ru)

Cookie-Based Caching

Previous
Table of Contents
Next

Cookie-Based Caching

In addition to traditional server-side data caching, you can cache application data on the client side by using cookies as the storage mechanism. This technique works well if you need to cache relatively small amounts of data on a per-user basis. If you have a large number of users, caching even a small amount of data per user on the server side can consume large amounts of space.

A typical implementation might use a cookie to track the identity of a user and then fetch the user's profile information on every page. Instead, you can use a cookie to store not only the user's identity but his or her profile information as well.

For example, on a personalized portal home page, a user might have three customizable areas in the navigation bar. Interest areas might be

  • RSS feeds from another site

  • Local weather

  • Sports scores

  • News by location and category

You could use the following code to store the user's navigation preferences in the table user_navigation and access them through the get_interests and set_interest methods:

<?php
require 'DB.inc';
class User {
  public $name;
  public $id;
  public function _ _construct($id) {
    $this->id = $id;
    $dbh = new DB_Mysql_Test;
    $cur = $dbh->prepare("SELECT
                            name
                          FROM
                            users u
                          WHERE
                            userid = :1");
    $row = $cur->execute($id)->fetch_assoc();
    $this->name = $row['name'];
  }
  public function get_interests() {
    $dbh = new DB_Mysql_Test();
    $cur = $dbh->prepare("SELECT
                            interest,
                            position
                          FROM
                            user_navigation
                          WHERE
                            userid = :1");
  $cur->execute($this->userid);
  $rows = $cur->fetchall_assoc();
  $ret = array();
  foreach($rows as $row) {
    $ret[$row['position']] = $row['interest'];
  }
  return $ret;
}
public function set_interest($interest, $position) {
  $dbh = new DB_Mysql_Test;
      $stmtcur = $dbh->prepare("REPLACE INTO
                                         user_navigation
                                       SET
                                         interest = :1
                                         position = :2
                                       WHERE
                                         userid = :3");
      $stmt->execute($interest, $position, $this->userid);
  }
}
?>

The interest field in user-navigation contains a keyword like sports-football or news-global that specifies what the interest is. You also need a generate_navigation_element() function that takes a keyword and generates the content for it.

For example, for the keyword news-global, the function makes access to a locally cached copy of a global news feed. The important part is that it outputs a complete HTML fragment that you can blindly include in the navigation bar.

With the tools you've created, the personalized navigation bar code looks like this:

<?php
$userid = $_COOKIE['MEMBERID'];
$user = new User($userid);
if(!$user->name) {
  header("Location: /login.php");
}
$navigation = $user->get_interests();
?>
<table>
  <tr>
    <td>
      <table>
        <tr><td>
        <?= $user->name ?>'s Home
        <tr><td>
        <!-- navigation postion 1 -->
        <?= generate_navigation_element($navigation[1]) ?>
        </td></tr>
        <tr><td>
        <!-- navigation postion 2 -->
        <?= generate_navigation($navigation[2]) ?>
        </td></tr>
        <tr><td>
        <!-- navigation postion 3 -->
        <?= generate_navigation($navigation[3]) ?>
        </td></tr>
      </table>
    </td>
    <td>
      <!-- page body (static content identical for all users) -->
    </td>
  </tr>
</table>

When the user enters the page, his or her user ID is used to look up his or her record in the users table. If the user does not exist, the request is redirected to the login page, using a Location: HTTP header redirect. Otherwise, the user's navigation bar preferences are accessed with the get_interests() method, and the page is generated.

This code requires at least two database calls per access. Retrieving the user's name from his or her ID is a single call in the constructor, and getting the navigation interests is a database call; you do not know what generate_navigation_element() does internally, but hopefully it employs caching as well. For many portal sites, the navigation bar is carried through to multiple pages and is one of the most frequently generated pieces of content on the site. Even an inexpensive, highly optimized query can become a bottleneck if it is accessed frequently enough. Ideally, you would like to completely avoid these database lookups.

You can achieve this by storing not just the user's name, but also the user's interest profile, in the user's cookie. Here is a very simple wrapper for this sort of cookie access:

class Cookie_UserInfo {
  public $name;
  public $userid;
  public $interests;
  public function _ _construct($user = false) {
    if($user) {
      $this->name = $user->name;
      $this->interests = $user->interests();
    }
    else {
      if(array_key_exists("USERINFO", $_COOKIE)) {
        list($this->name, $this->userid, $this->interests) =
          unserialize($_cookie['USERINFO']);
      }
      else {
        throw new AuthException("no cookie");
      }
    }
  }
  public function send() {
    $cookiestr = serialize(array($this->name,
                               $this->userid,
                               $this->interests));
    set_cookie("USERINFO", $cookiestr);
  }
}
class AuthException {
  public $message;
  public function _ _construct($message = false) {
    if($message) {
      $this->message = $message;
    }
  }
}

You do two new things in this code. First, you have an infrastructure for storing multiple pieces of data in the cookie. Here you are simply doing it with the name, ID, and interests array; but because you are using serialize, $interests could actually be an arbitrarily complex variable. Second, you have added code to throw an exception if the user does not have a cookie. This is cleaner than checking the existence of attributes (as you did earlier) and is useful if you are performing multiple checks. (You'll learn more on this in Chapter 13, "User Authentication and Session Security.")

To use this class, you use the following on the page where a user can modify his or her interests:

$user = new User($name);
$user->set_interest('news-global', 1);
$cookie = new Cookie_UserInfo($user);
$cookie->send();

Here you use the set_interest method to set a user's first navigation element to global news. This method records the preference change in the database. Then you create a Cookie_UserInfo object. When you pass a User object into the constructor, the Cookie_UserInfo object's attributes are copied in from the User object. Then you call send(), which serializes the attributes (including not just userid, but the user's name and the interest array as well) and sets that as the USERINFO cookie in the user's browser.

Now the home page looks like this:

try {
$usercookie = new Cookie_UserInfo();
}
catch (AuthException $e) {
  header("Location /login.php");
}
$navigation = $usercookie->interests;
?>
<table>
  <tr>
    <td>
      <table>
        <tr><td>
        <?= $usercookie->name ?>
        </td></tr>
        <?php for ($i=1; $i<=3; $i++) { ?>
        <tr><td>
        <!-- navigation position 1 -->
        <?= generate_navigation($navigation[$i]) ?>
        </td></tr>
        <?php } ?>
      </table>
    </td>
    <td>
      <!-- page body (static content identical for all users) -->
    </td>
  </tr>
</table>

Cache Size Maintenance

The beauty of client-side caching of data is that it is horizontally scalable. Because the data is held on the client browser, there are no concerns when demands for cache storage increase. The two major concerns with placing user data in a cookie are increased bandwidth because of large cookie sizes and the security concerns related to placing sensitive user data in cookies.

The bandwidth concerns are quite valid. A client browser will always attach all cookies appropriate for a given domain whenever it makes a request. Sticking a kilobyte of data in a cookie can have a significant impact on bandwidth consumption. I view this largely as an issue of self-control. All caches have their costs. Server-side caching largely consumes storage and maintenance effort. Client-side caching consumes bandwidth. If you use cookies for a cache, you need to make sure the data you cache is relatively small.

Byte Nazis

Some people take this approach to an extreme and attempt to cut their cookie sizes down as small as possible. This is all well and good, but keep in mind that if you are serving 30KB pages (relatively small) and have even a 1KB cookie (which is very large), a 1.5% reduction in your HTML size will have the same effect on bandwidth as a 10% reduction on the cookie size.

This just means that you should keep everything in perspective. Often, it is easier to extract bandwidth savings by trimming HTML than by attacking relatively small portions of overall bandwidth usage.


Cache Concurrency and Coherency

The major gotcha in using cookies as a caching solution is keeping the data current if a user switches browsers. If a user uses a single browser, you can code the application so that any time the user updates the information served by the cache, his or her cookie is updated with the new data.

When a user uses multiple browsers (for example, one at home and one at work), any changes made via Browser A will be hidden when the page is viewed from Browser B, if that browser has its own cache. On the surface, it seems like you could just track what browser a user is using or the IP address the user is coming from and invalidate the cache any time the user switches. There are two problems with that:

  • Having to look up the user's information in the database to perform this comparison is exactly the work you are trying to avoid.

  • It just doesn't work. The proxy servers that large ISPs (for example, AOL, MSN) employ obscure both the USER_AGENT string sent from the client's browser and the IP address the user is making the request from. What's worse, the apparent browser type and IP address often change in midsession between requests. This means that it is impossible to use either of these pieces of information to authenticate the user.

What you can do, however, is time-out user state cookies based on reasonable user usage patterns. If you assume that a user will take at least 15 minutes to switch computers, you can add a timestamp to the cookie and reissue it if the cookie becomes stale.


Previous
Table of Contents
Next