The first file, index.php (Listing 1), provides a redirect. If a user attempts to access the root directory, we'll redirect to chat.php that will, in turn, redirect the page to login.php if they're not already logged in.
Listing 1: index.php
<?php header("Location: chat.php"); exit; ?>
Listing 2 shows the header file which sets the stylesheet, the page title and sets up the HTML markup.
Listing 2: header.php
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> <meta http-equiv="content-type" content="text/html; charset=utf-8" /> <title><?php echo ($title); ?></title> <link rel="stylesheet" href="css/chat.css" type="text/css" media="screen" /> </head> <body> <div id="frame"> <div id="header"> <h1><?php echo ($APP_NAME); ?></h1> </div>
Listing 3 shows footer.php, which displays login status and closes off the markup.
Listing 3: footer.php
<div id="footer"> <?php if(empty($_SESSION{"userid"})){ echo("Not logged in.<br>"); ?> <a href="login.php">Login here</a> <?php }else{ echo("Logged in as user <i>" . $_SESSION{"userid"} . "</i>"); echo("<br>"); echo("<a href='logout.php'>Logout</a>"); }?> </div> </div> </body> </html>
Like pieces of bread around a sandwich, the header and footer are boring but mandatory items included at the top and bottom of every page output by our application. Like sandwiches, all the interesting stuff happens between the bread.
include.php contains the meat of our application, and you'll see
it included first at the top of every PHP page, even before the
header.php file. It starts the PHP session and contains a variety
of constants used elsewhere in the application and the
User, Comment and
MemcachedSingleton classes that do the bulk of
the work in this application.
The first two clauses of include.php (Listing 4) set the error output and warnings to maximum and direct their output to the web server. While this is something we would turn off on a production web server, it's helpful to see all warnings and errors while in development. The global constants are accessible by other pages and classes called after this file has been included.
Listing 4: include.php constants and settings
error_reporting(E_ALL); ini_set("display_errors", 1); //start our session session_start(); //Global constants $APP_NAME="PHP Membase WebChat"; define('APP_PREFIX',"chat"); define('KEY_DELIM',"::"); define('MEMBASE_SERVER',"localhost"); define('MEMBASE_PORT',11211);
The $APP_NAME variable is output by the header
at the top of every page.
The constants that are set using the define
function are used by the classes defined later in the file.
APP_PREFIX (chat) is added to the front of
every key avoid potential key name collisions with other
applications using the same bucket. KEY_DELIM
(set to two colons "::")is used to delimit the various portions of
the keys; so, for example, the key for comment number 99 will be
the concatenation of APP_PREFIX, KEY_DELIM, the
key type (in this case comment), the KEY_DELIM
again and the comment number:
chat::comment::99.
MEMBASE_SERVER and
MEMBASE_PORT indicate the location of the
Membase server and bucket we'll be connecting to. You can change
these to match the address and port of your own server.
The next part of the include.php file contains the three classes
that do most of the work: MemcachedSingleton,
User and Comment.
The MemcachedSingleton class (Listing 5) is a
wrapper for the Memcached library class. Both
the User and Comment class
(which we'll see next) require a Memcached library connection to
the Membase server.
Listing 5: include.php MemcachedSingleton class
/** * Singleton Memcached class * This keeps a single global copy of the Memcached * Membase connection. */ class MemcachedSingleton{ private static $instance; private static $mc_obj; /** * Construct the object */ private function __construct() { } /** * Initialize this class after construction */ private function initialize(){ self::$mc_obj = new Memcached(); self::$mc_obj->setOption(Memcached::OPT_COMPRESSION,false); self::$mc_obj->addServer(MEMBASE_SERVER, MEMBASE_PORT); } /** * Return the singleton instance, constructing and * and initializing it if it doesn't already exist */ public static function getInstance() { if(!self::$instance) { self::$instance = new self(); self::$instance->initialize(); } return self::$instance; } /** * Return the Memcached object held by the singleton */ public static function getMemcached(){ return(self::$mc_obj); } }
Rather than have more than one Memcached
library instance created, we use
MemcachedSingleton to ensure we initialize and
use only one copy per invocation of the application. When the
static method getInstance() is called, it
creates a MemcachedSingleton instance and
initializes a Memcached instance, which may be
retrieved using the getMemcached() method. If a
Memcached connection is required, the call:
Memcached::getInstance()->getMemcached()
will provide a reference with the appropriately initialized
Memcached server information and avoid recreating one. Though this
is overkill for the current implementation, if it were to get
significantly more complex (as applications tend to), it avoids
the issue of multiple Memcached library instances being
instantiated by different classes.
The Memcached library has limited functionality for connecting to servers; it does not directly support vBuckets or SASL authentication. All that can be done is to set multiple servers with associated weights (connection priorities) by specifying hostname, port and weight. If we've set up Moxie servers (as is done by the Membase installer) then that can be configured to automatically proxy for multiple Membase instances.
The key method of the MemcachedSingleton class
is initialize(), which configures the
connection to the Memcached server. In initialize()we turn off
compression using:
setOption(Memcached::OPT_COMPRESSION,false) as
the Memcached append method is only supported
if the Membase records are not compressed.
We only add one server to the connection for this application,
however we could add as many as we wish using either the
addServer or addServers
Memcached methods. If multiple servers are added, we can assign a
weight to each server to decide how often it is selected to store
a key. Care should be taken however as it is possible to re-add
the same server multiple times, and each added server will be
treated as different regardless of whether they are. Memcached
does not currently support removing servers. If it is necessary to
change the list of servers connected, the Memcached object must be
re-instantiated to do so.
The User class (Listing 6) handles user related actions; registration, login and logout.
Listing 6: include.php User class
/** * User class * This handles user interactions with Membase through * the Memcached interface. */ class User { private $last_error_string; /** * Create a user account based on provided userid * and password. * @param string $userid * @param string $password * @return boolean */ public function createUserAccount($userid, $password) { $error = ""; if(!preg_match("/^\w{4,10}$/", $userid)) { $error .= "Illegal userid '<i>$userid</i>'<br>"; } if(!preg_match("/^.{4,10}/", $password)) { $error .= "Password must have between 4 and 10 characters"; } if($error != "") { $this -> last_error_string = $error; return false; } $mc_obj = MemcachedSingleton::getInstance() -> getMemcached(); //check to see if the userid already exists $userid_key = APP_PREFIX . KEY_DELIM . "user" . KEY_DELIM . $userid; $passwordHash = sha1($password); if($mc_obj -> add($userid_key, $passwordHash)) { //now that we've added the userid key we'll add it to the userlist $userlist_key = APP_PREFIX . KEY_DELIM . "userlist"; if(!$mc_obj -> add($userlist_key, $userid)) { $mc_obj -> append($userlist_key, KEY_DELIM . $userid); } return true; } else { $result_code = $mc_obj -> getResultCode(); if($result_code == Memcached::RES_NOTSTORED) { $this -> last_error_string .= "User id '<i>$userid</i>' exists, please choose another."; } else { $this -> last_error_string .= "Error, please contact administrator:" . $mc_obj -> getResultMessage(); } return false; } } /** * Attempt to login the user * @param string $userid * @param string $password * @return boolean */ public function loginUser($userid, $password) { $mc_obj = MemcachedSingleton::getInstance() -> getMemcached(); //check to sees if userid exists with the same password hashed $userid_key = APP_PREFIX . KEY_DELIM . "user" . KEY_DELIM . $userid; $submitted_passwordHash = sha1($password); $db_passwordHash = $mc_obj -> get($userid_key); if($db_passwordHash == false) { return (false); } //do we match the password? if($db_passwordHash == $submitted_passwordHash) { $_SESSION{"userid"} = $userid; return true; } else { return false; } } /** * Log the user out */ public function logoutUser() { session_unset(); session_destroy(); } /** * Get the error string from the last action * @return string */ public function getLastErrorString() { return $this -> last_error_string; } }
The createUser method is called from the
register.php file, which we'll see later. It checks that the
userid and password are of the correct format (userid 4-10
alphanumeric or underscore characters, password 4-10 characters),
then attempts to add the new user to the database. The key is of
the format: Chat::user::$userid, and the value
is the sha1 hash of the password. If it fails (the method returns
false), we set a private class error variable
$last_error_string, which can be retrieved
using the getLastErrorString method. There are
many possible failure modes for an add (see the Memcached
documentation; in this case we're only dealing with one
explicitly, where the user id is already taken. In that case the
error code is Memcached::RES_NOTSTORED. All
other error cases are converted to text using the
getResultMessage() method for text display to
the user.
If the user id addition is successful then we add it to the user
list (stored in the chat::userlist key) with
the append method. This will result in a value
for the userlist of the form
bjones::fdavis::jsmith enumerating all user ids
that have successfully created accounts. Though we don't currently
use this elsewhere in the application, if we were to create a user
management administration view, we'd need some way to find the
full list of user ids. This list is where we would get that
information.
The loginUser method of the User class is
called from the login.php page. It retrieves the user id record
(if it exists) from Membase, and then compares the retrieved sha1
hash with the hash of the provided password. If they match, the
user is logged in by setting the userid value in the session
variable.
The logout method is particularly simple; it
destroys the PHP session for the current user.
The Comments class (Listing 7) manages adding,
listing and deleting comments.
Listing 7: include.php Comment
/** * Comments class for managing comments in Membase * through the Memcached library */ class Comments { /** * Return the last n comments * @param integer $count * @return Array */ public function getLastComments($count) { $mc_obj = MemcachedSingleton::getInstance() -> getMemcached(); $comment_count_key = APP_PREFIX . KEY_DELIM . "chatcount"; $script_comment_count = $mc_obj -> get($comment_count_key); $comment_key_list = array(); for($i = $script_comment_count; $i > 0 && $i > ($script_comment_count - $count); $i--) { array_push($comment_key_list, "COMMENT#" . $i); } $null = null; return ($mc_obj -> getMulti($comment_key_list, $null, Memcached::GET_PRESERVE_ORDER)); } /** * Add a comment to the comment list * @param string */ public function addComment($comment) { $mc_obj = MemcachedSingleton::getInstance() -> getMemcached(); if($comment != "") { $comment_count_key = APP_PREFIX . KEY_DELIM . "chatcount"; $comment_count = $mc_obj -> get($comment_count_key); if($mc_obj -> getResultCode() == Memcached::RES_NOTFOUND) { $mc_obj -> add($comment_count_key, 0); } $script_comment_count = $mc_obj -> increment($comment_count_key); $mc_obj -> add("COMMENT#" . $script_comment_count $_SESSION{"userid"} . KEY_DELIM . $_POST["comment"]); } } /** * delete a comment from the list by number * @param integer $number */ public function deleteComment($number) { $mc_obj = MemcachedSingleton::getInstance() -> getMemcached(); $comment_key = "COMMENT#" . $number; $result = $mc_obj -> get($comment_key); $elements = explode(KEY_DELIM, $result); $userid = array_shift($elements); //make sure user who created is deleting if($userid == $_SESSION{"userid"}) { $result = $mc_obj -> delete($comment_key); } } } ?>
The addComment method takes the provided
comment and adds it to the Membase database. The current comment
number is stored in the $comment_count_key key
(chat::chatcount). To add the comment to the
database, the comment count key is incremented and we take the
resultant number as our next comment number. Membase guarantees
that the increment and retrieval are atomic, so we can be sure
that we won't collide with comment key number generated by another
instance of the web application. The key for the comment is
created, of the form chat::COMMENT#9. The
corresponding comment value is of the form userid::comment, so
that the comment is prepended by the identity of the submitter. We
could serialize a data structure into the value if desired;
however we've chosen to use text here for the sake of simplicity
and clarity.
The deleteComment method takes a comment
number as an argument and deletes that comment if the same user
who submitted the comment requested the action.
The getLastComments method returns the
specified number of comments in an array, counting backwards from
the most current comment. The current comment count is retrieved
to determine the most recent comment number. From this, an Array
of keys is generated counting backwards from that comment, and the
Memcached getMulti method is used to retrieve them all
simultaneously, which is more efficient than looping and
retrieving each individually. The results are returned in an Array
with the key being the request key and the value being the value
retrieved from Membase. If a key doesn't exist because that
comment has been deleted, the value returned is empty.
The next few listings describe pages that provide views of the application; login, logout, register and chat.
The login view (Figure 2) is a userid and password form.
The login code (login.php, Listing 8) passes the userid and password fields to the createUser method of the User class we saw previously in include/include.inc.
Listing 8: login.php8
<?php include_once ("include/include.php"); $loginfailed = ""; if(!empty($_POST{"userid"})) { $user = new User(); if(!$user -> loginUser($_POST{"userid"}, $_POST{"password"})) { $loginfailed = "Login attempt failed"; } } if(!empty($_SESSION{"userid"})) { header("Location: chat.php"); exit ; } $title = "Login"; include_once ("include/header.php"); ?> <div id="content"> <h2>Login to MemChat</h2> <?php echo $loginfailed ?><br> <form method="post" action="login.php" id="loginform" style="width:40%"> <fieldset> <label for="userid"> User id: </label> <input type="text" name="userid" length=20> <label for="password"> Password: </label> <input type="password" name="password" id="password" length=20> <input type="submit" name="login" id="registration"> </fieldset> </form> Register <a href="register.php">here</a> if you don't have an account. </div> <?php include_once ("include/footer.php"); ?>
If the user is already logged in (i.e., there is a value for
$_SESSION{"userid"}), they are immediately
redirected to the chat.php file. If there is an error on login (in
which case the the loginUser method returns
false) the login page is shown again with a login error.
The logout screen (Figure 3) just indicates the logged out status of the user.
The logout code (logout.php, Listing 9) calls the
User class logoutUser
method, which we've seen already, then clears the current user PHP
session.
Listing 9: logout.php
<?php include_once("include/include.php"); $userid=$_SESSION{"userid"}; $title="Logout"; include_once("include/header.php"); $user = new User(); $user->logoutUser(); echo("$userid logged out"); include_once("include/footer.php"); ?>
The register screen (Figure 4), is similar to the login page and provides a userid and password form.
The register code (register.php, Listing 10) is similar to login; it takes a userid and password and calls the User class createUserAccount method; if an error is returned (for example the userid already exists) then the error is displayed.
Listing 10: register.php
<?php include_once("include/include.php"); $title="Register User"; include_once("include/header.php"); $result = false; //opening the register page logs you out $user=new User(); $user->logoutUser(); if(!empty($_POST{"userid"})){ $userid=$_POST{"userid"}; $result = $user->createUserAccount($_POST{"userid"},$_POST{"password"}); if($result == true){ echo "<div id='error'>" . "User id '<i>$userid</i>' successfully created. <br> " . "<a href='login.php'>Login here</a>" . "</div>"; }else{ echo "<div id='error'>" . $user->getLastErrorString() . "</div>"; } } if($result != true){ ?> <div id="content"> <h2>Register to use WebChat</h2> <form method="post" action="register.php" id="registrationform" style="width:40%"> <fieldset> <label for="userid"> User id: </label> <input type="text" name="userid" id="userid"> <label for="password"> Password: </label> <input type="password" name="password" id="password"> <input type="submit" name="registration" id="registration"> </fieldset> </form> <small> <i> User id must have between 4 and 10 characters. <br> Only letters, numbers and underscore characters are permitted </i> </small> </div> <?php } include_once("include/footer.php"); ?>
The chat view (Figure 5) displays the user chat form (a text field and a button), and the last ten submitted comments. The delete buttons in the "Action" allow a user to delete their own comments.
The chat view (chat.php, Listing 11) has the most user interaction and behavioral complexity, however the bulk of this behavior is implemented in the Comments class; the chat.php code is relatively simple.
Listing 11. chat.php
<?php include_once ("include/include.php"); if(empty($_SESSION{"userid"})) { header("Location: login.php"); } $title = "Chat"; include_once ("include/header.php"); ?> <form method="post" action="chat.php" id="chat"> <div> <input type="text" name="comment" id="comment" maxlength="50" size="50"/> <input type="submit" name="submitcomment" id="submitcomment" value="Submit comment" /> </div> </form> <br> <?php $comments = new Comments(); if(!empty($_POST{"comment"})) { $comments -> addComment($_POST{"comment"}); } if(!empty($_POST{"delete"})) { $comments -> deleteComment($_POST{"commentnum"}); } $comment_list = $comments -> getLastComments(10); $keys = array_keys($comment_list); ?> <table> <tr> <th width="10%" align="center">Comment #</th> <th>Action</th> <th>User ID</th> <th width="60%">Comment</th> </tr> <?php foreach($keys as $key) { echo "<tr>\n"; $result = explode(KEY_DELIM, $comment_list{$key}); $userid = array_shift($result); $message = implode(KEY_DELIM, $result); $commentnum = array_pop(explode("#", $key)); $actionlink = ""; if(empty($userid)) { $userid = "?"; $message = "<i>deleted</i>"; } if($_SESSION{"userid"} == $userid) { $actionlink = "<form method='post' action='chat.php' id='message" . $commentnum . "'>"; $actionlink .= "<input type='hidden' value='$commentnum' name='commentnum'>"; $actionlink .= '<input type="submit" value="delete" name="delete" style="background: none; border: none; font-style: italic;" >'; $actionlink .= "</form>"; } echo("<td>$commentnum</td><td>$actionlink</td><td> $userid</td><td>$message</td>\n"); echo "</tr>\n"; } ?> </table> <?php include_once ("include/footer.php"); ?>
If a non-logged in user accesses the chat view, they are
immediately routed to the login page. If they are logged in, a
chat form is displayed with the last 10 comments shown in
decreasing order (i.e. increasing in age as they go down the
page). If the currently logged in user created a given comment, a
button to delete it is shown in the action column (Figure 5) for
that comment. The delete button looks like a link, but it is
actually a borderless button on a form that does a POST submission
to the chat.php page. Comment class methods
handle both comment submission and comment deletion.