Persistent user sessions in WebSnap

By: Nick Hodges

Abstract: This article shows how easy it is to maintain a user's session information between sessions and logins. By Nick Hodges..

In my last article, I showed how easy it is to maintain a user's state information in a WebSnap application using the session object. You likely noticed, however, that since session information is stored in memory, when the user logs out or the session expires, that session information is lost. You very likely won't want to ask users to fill out their preferences every time they visit your site, so you'll want to retain those preferences so they are there when he comes back. Web sites that greet me by name make me smile and feel all cozy inside -- I'm sure you feel the same way. So let's make sure your Web site does that as well.

The code for this article can be found on Code Central. Much of the credit for that code goes to Jim Tierney, WebSnap's architect, who helped me write the two helper functions you'll see in a minute. Well, actually, he pretty much wrote them, and I just made them a touch more generic.

As you have no doubt come to expect, it won't take a lot of work to add persistent session information to a WebSnap app. However, you will have to (or get to; it's all a matter of perspective, I suppose) write a little code. There are many ways to implement this feature, and while I'd love to show you all of them, in this article I'm focusing on just one of them. We'll look at how you can store user information in an InterBase database using the standard components that appear on the WebSnap tab on the component palette.

While WebSnap is powerful, it is not bug-free. There is a change that you need to make to WebCntnrs.pas in order to get session streaming to work properly. Change the TAbstractNamedVariants.ReadData method so it looks like this:

procedure TAbstractNamedVariants.ReadData(Reader: TReader);
var
  V: Variant;
  S: string;
begin
  Reader.ReadListBegin;
  BeginUpdate;
  try
    Clear;
    while not Reader.EndOfList do
    begin
       s := Reader.ReadString;
       V := Reader.ReadVariant;
       Add(S, V);
    end;
  finally
    EndUpdate;
  end;
  Reader.ReadListEnd;
end;

This fix was passed on to me from Jim Tierney, and it will be included in any future update to Delphi 6. Once you make the change, you can add the new file to the project or include it's path in the projects library to make sure that the new code is compiled into your project.

We'll build on the application that we used in my previous article on sessions management. Make a copy of the application in a new directory, and save it as PersistDemo.dpr. The InterBase table will be quite easy to build, as the TSessionsService component provides a means for persisting session information to a stream. Therefore, we'll need a table with only two fields -- a varchar field to hold the username and a Blob field to hold the session name. That way, we can use the TBlobStream class to write all the session information out to the table. The cool part is that you can change and add to the session fields all you want as your site expands, and this scheme will work no matter how much session information is stored. Session values are variants, and therefore they can be written to and read from streams. Right now we are maintaining only three pieces of information about each user, but if you decide later to store more information than that (such as something dreadfully important like the name of the user's dog), you won't need to change anything in the database since it is all stored as a blob.

The InterBase table is defined as follows:

/* Table: USERINFO, Owner: SYSDBA */

CREATE TABLE "USERINFO" 
(
  "USERNAME"	VARCHAR(20) NOT NULL,
  "SESSIONINFO"	BLOB SUB_TYPE 0 SEGMENT SIZE 80,
  CONSTRAINT "USERINFOPRIMARYKEY1" PRIMARY KEY ("USERNAME")
);

I've included a database file for your use along with the code for this article.

TSessionsService implements the ISessionsServicePersist interface, which is declared as follows:

  ISessionsServicePersist = interface ['{6FFA990F-183E-468E-8246-2E1093CEA13D}']
    procedure LoadFromFile(const Filename: string);
    procedure SaveToFile(const Filename: string);
    procedure LoadFromStream(S: TStream);
    procedure SaveToStream(S: TStream);
  end;

This interface allows you to read and write session information to a file or a stream. Thus, at any time you can save your session information, confident that it will be available the next time the user logs in. As it stands right now, the interface saves all the data in the session object for all the sessions, not just the current one. So we are going to write helper functions that will use the interface to create a temporary session, copy the current user's session into it, and save that information into a stream. The helper functions look like this:

// Write a session to a stream
procedure SaveSession(AID: TSessionID; aStream: TStream);
var
  TempSessions: TSessions;
  TempItem: TSessionItem;
begin
  TempSessions := TSessions.Create;
  try
    TempItem := TSessionItem.Create(TempSessions);
    if Sessions.GetSession(AID, TempItem) then
      TempSessions.SaveToStream(aStream)
    else
      Assert(False, 'Session not found');
  finally
    TempSessions.Free;
  end;
end;

// Update or Add all name/value pairs from the saved session to a new or existing session
procedure RestoreSession(AID: TSessionID;  aStream: TStream);
var
  TempSessions: TSessions;
  Item: TSessionItem;
  I: Integer;
begin
  if aStream.Size <> 0 then // if there is no data there, don't do anything
  begin
    TempSessions := TSessions.Create;
    try
      TempSessions.LoadFromStream(aStream);
      if AID = '' then
        // Create a new session
        AID := SessColn.Sessions.StartSession
      else
        Assert(Sessions.SessionExists(AID), 'Could not find session ' + AID);
      Item := TempSessions.Items[0] as TSessionItem;
      for I := 0 to Item.Items.Count - 1 do
        Sessions.SetItemValue(AID, Item.Items.Names[I], Item.Items.Variants[I]);
    finally
      TempSessions.Free;
    end;
  end;
end;

I put this code into a unit called WebSnapUtils that is part of the code you can download for this article. If you don't want to type all the above in, you can just use that unit as needed.

The code for the helper functions is pretty straightforward. Note, however, that both procedures create temporary sessions to hold the data as it comes in and goes out. This ensures that only the values for the current session, rather than all the values for all the existing sessions, are written to the stream. Since only one set of values is written the proper set of values will be loaded the next time the user logs in.

Note that the code doesn't care how many session variables you have, what types they are, or how big they are, or whether they smell bad. Any and all session information will be stored and loaded by these procedures. This means that your session information will get stored, and no matter how much you change or add to the amount of information you track for each user, all of this code will still work.

Of course, the key thing is to save and load this information at the right time. This would, naturally, be when the user logs in, when he enters new or updated session information, and when his session ends. If you cover all three of these events, your user should always see current session information between logins. Being greeted by name gives users a warm fuzzy, but being forgotten might make some users break down in tears -- and we can't have that now, can we?

The first thing that you want to do is to load information about a user when the user logs in. Naturally, WebSnap has an event that occurs at that very important moment -- the TLoginFormAdapter.OnLogin event. Open your demo project, navigate to the TLoginFormAdapter on the Login page, and create an event handler for the OnLogin event. Put wdmSession in the uses clause (we'll create that unit in a minute), and add code so it looks like this:

procedure TLogin.LoginFormAdapter1Login(Sender: TObject; UserID: Variant);
begin
  // Get session information for the UserID
  SessionDatamodule.GetSessionInformation(UserID);
end;

As you can see, the event passes you the UserID of the person who just logged in. We can then use this information to do a lookup on the record in the database with that username and get the session information out of the Blob field for that record. That's what the call to SessionDataModule.GetSessionInformation does. We'll take a look at how that call works...but first, we have to set things up to talk to the database.

WebSnap applications, like everyday client-server apps, should put their data handling controls into datamodules. But because of the unusual environment where WebSnap applications run -- namely inside the context of a multi-threaded Web server -- they cannot use normal datamodules. But you don't need to fret. WebSnap gives us the TWebDatamodule, which is specifically designed to be used in Web applications. You can create a new one by pressing the New WebSnap Data Module button on your Internet toolbar in the IDE, or by selecting File | New | Other... | WebSnap | WebSnap Data Module from the IDE's menu. Either way, you'll end up with a very normal looking TWebDatamodule. Rename the Web datamodule to SessionDatamodule and save the new file as wdmSession.pas.

Naturally, we'll need to add some data access components. Since we are running against an InterBase database, let's use the InterBase Express components written by my fellow TeamB member Jeff Overcash. They are found on the InterBase tab of the Component Palette. Drop a TIBTransaction, a TIBDatabase, a TIBQuery, and a TUpdateSQL on the WebDatamodule. Set their properties as follows:

  1. Point the IBDatabase1.DatabaseName property to your GDB file.
  2. Set the IBTransaction1.DefaultDatabase property to IBDatabase1.
  3. Set the IBDatabase.LoginPrompt property to False.
  4. Double click on IBDatabase1 and set the Username and Password values to the correct values for your server.
  5. Set the IBQuery1.Database property to IBDatabase1.
  6. Set the IBQuery1.SQL property to SELECT * FROM USERINFO WHERE USERNAME = :aUserName.
  7. Set the IBQuery1.Transaction property to IBTransaction1.
  8. Double-click on IBQuery1, then right click on the Field Editor and select "Add all Fields."
  9. Set the IBQuery1.UpdateObject to UpdateSQL1.
  10. Double-click on the UpdateSQL1 component. Press the Select Primary Key button, then the Generate SQL button. Then press Ok.
  11. Rename IBQuery1 to UserInfoQuery, just so the name is a little clearer.
Once you do all of that, the database components should be ready to handle the data processing we are about to do.

The SessionDatamodule and associated database file turn out to be pretty useful. You can use them in any WebSnap application. Just create a new, empty database, and add the wdmSession unit to the project. You'll be able to save session information for your new application just like this one. The unit is not specific to any session implementation or set of session values.

Okay, where were we?

Oh yeah, we were just about to do the work of saving and loading all of this session data quickly and easily. Here we go.

Above, you wrote some code that called SessionDatamodule.GetSessionInformation. Add that method to the Web datamodule, and make it look like this:

procedure TSessionDatamodule.GetSessionInformation(aUserName: string);
var
  BlobStream: TStream;
begin
  if aUserName <> '' then
  begin
    SessionDatamodule.UserInfoQuery.Close;
    SessionDatamodule.UserInfoQuery.ParamByName('aUserName').AsString := aUserName;
    SessionDatamodule.UserInfoQuery.Open;
    if SessionDatamodule.UserInfoQuery.RecordCount > 0 then
    begin
      BlobStream := UserInfoQuery.CreateBlobStream(UserInfoQuerySESSIONINFO, bmRead);
      try
        RestoreSession(Session.SessionID, BlobStream);
      finally
        BlobStream.Free;
      end;
    end;
  end;
end;

This code should be pretty easy to follow. Right? It merely sets up the parameter for the query, then opens it. If the query finds a record (it should only find one, because the UserName column is unique), then it creates a BlobStream for the SESSIONINFO field and restores the session data from that field. Pretty simple. For you, anyway. In the background, WebSnap is doing a lot of work, managing and storing session information and maintaining it all in Variants. Since it's in the OnLogin event, all the stored session information will be reloaded every time a user logs in.

But what about when the data changes?

No problem. We can easily save the data when the user enters it. Go to the GetPrefs page in the wmGetPrefs unit, put wdmSession in the uses clause, and add the following to the very end of the SubmitPrefsActionExecute procedure. The procedure should look like this:

procedure TGetPrefs.SubmitPrefsActionExecute(Sender: TObject;
  Params: TStrings);
var
  Value: IActionFieldValue;
  i: integer;
  SL: TStringList;
begin

  ...

  // Save this new session information to the database.
  SessionDatamodule.SaveSessionInformation(WebContext.EndUser.DisplayName);
end;
procedure TSessionDatamodule.SaveSessionInformation(aUserName: string);
var
  BlobStream: TStream;
begin
  if aUserName <> '' then
  begin
    with SessionDatamodule do
    begin
      UserInfoQuery.Close;
      UserInfoQuery.ParamByName('aUserName').AsString := aUserName;
      UserInfoQuery.Open;
      if UserInfoQuery.RecordCount > 0 then
      begin
        UserInfoQuery.Edit;
      end else
      begin
        // There is no entry yet for this user, so create one
        UserInfoQuery.Insert;
        UserInfoQueryUSERNAME.AsString := aUserName;
      end;
      BlobStream := UserInfoQuery.CreateBlobStream(UserInfoQuerySESSIONINFO, bmWrite);
      try
        SaveSession(Session.SessionID, BlobStream);
      finally
        BlobStream.Free;
      end;
      UserInfoQuery.Post;
      IBTransaction1.Commit;
    end;
  end;
end;

This code isn't all that different form the code that loads the session information. It merely reverses the process and creates a new entry in the database if the user doesn't yet exist.

There's one other place that we need to add some code. The code we've written so far saves user preference data right after a user updates it, but your application may change that data as the user runs it, so you'll want to save the data one other time -- when the session ends, either as a result of the user logging out or when the session expires. The SessionServices.OnEndSession event fires on both occasions, so it is perfect. Go to the Home page of the application, create an event handler for the event, and make it look like this:

procedure THome.SessionsServiceEndSession(ASender: TObject;
  ASession: TAbstractWebSession; AReason: TEndSessionReason);
begin
  SessionDatamodule.SaveSessionInformation(EndUserSessionAdapter.UserID);
end;

Note that you'll have to add the wdmSession unit to the uses clause of the wmHome unit as well.

One last thing to do, just to help illustrate that this app is really working. Go to the Home page and add another user to the WebUserList component. This way, you can be sure that multiple sets of preferences are being stored.

Ready to have some fun? Compile and run the application, and leave it running so the session information can remain in memory while the app runs. Use the Web App Debugger to navigate to the PersistDemo application. Log in, enter some preference information, and then log out. Log back in, and go to the Preferences display page -- you should see all the session data that you entered restored to its original glory. Log out and repeat the process for the new user you added. Go back and forth between users, change the session information...it all just works. What a thrill, huh? Man, is that ever cool.

Nick Hodges is TopDog at HardThink Inc., a consulting shop specializing in Delphi development. He is a TeamB member and hasn't had a drop of coffee in his whole life. He likes to hang with his family and enjoy their new home in St. Paul, MN.



Server Response from: ETNASC02