Fitch & Mather Sample: Web Application Components

Scott Stanfield
Vertigo Software, Inc.

September 1998

Summary: Discusses the four server components used by the FMCorp Web application to handle business logic. (36 printed pages) Includes:

Introduction
Overview of Server Components
Use Cases
Visual Modeler Diagram
Component Design
Implementation Language
Generic Code
Login Methods
Upload Methods
Approve/Decline Methods
Development Tips and Tricks
Conclusion
About the Author
More Information

Introduction

The FMCorp Web application uses four middle-tier components to handle business logic. The FMCorp architecture document describes the components' role in a Microsoft® Windows® Distributed interNet Applications Architecture (Windows DNA) Web application.

Overview of Server Components

The four components are described in Table 1. The component column lists the component ProgID that is used by the Web pages and test application. The third column states whether a component must be part of a transaction. In general, the components that make modifications to the database require a transaction.

Table 1. FMCorp Server Components

Component Comments Transaction support
FMCorp.ExpenseReports High-level routines to parse and approve/decline expense reports. Required
FMCorp.ExpenseWriteDB Three methods to add a new expense report and its items, and to change the status of an expense report. Required
FMCorp.ExpenseReadDB Read-only routines to login, get the UserID from an e-mail address, and return expense report details. Not supported
FMCorp.Mail Sends simple mail messages using the SMTP service. Not supported

Use Cases

Before the first line of code was written, the developers asked themselves, "What should these components do?" We came up with the following feature list:

  1. Given an XML file path, parse the file and add the expense report to the database. This operation has the most points of failure so it needs to be robust.

  2. Provide methods to help login and validate a user's credentials.

  3. Provide a mechanism to approve or decline expense reports.

  4. Send e-mail messages when appropriate.

The middle-tier components expose functionality to the Web pages. So it is appropriate to infer what the Web pages will need. We learned that the methods we needed to support fell into three categories:

We later determined that there are four classes of Web pages, three of which use the middle-tier components directly:

Visual Modeler Diagram

Based on the preceding scenarios, we generated a diagram using the Visual Modeler tool, as shown in Figure 1.

Figure 1. Visual Modeler diagram

Component Design

We isolated all the read-only methods in a single component called FMCorp.ExpenseReadDB. These methods do not require transactional support from Microsoft Transaction Server (MTS). Their lifetime is, however, managed by MTS.

The methods that execute stored procedures that change the database were placed in FMCorp.ExpenseWriteDB. This component, along with a higher-level wrapper component, FMCorp.ExpenseReports, do require transactions. The final component, FMCorp.Mail, simplifies the process of sending e-mail.

We specifically did not burden these components by adding support for every read-only database access. Instead, we reasoned that there would be no additional overhead for ASP pages to make these requests directly to the database. Because they didn't need transaction support, placing them in Microsoft Visual Basic® components would only complicate the entire architecture.

For that reason, you will find some stored procedures are used only by the components, while others are used strictly by the Web pages. The ones used by the Web pages typically take one or two parameters (UserID or ExpenseReportID) and return a select statement that spans several tables.

Implementation Language

The four components were written in the Visual Basic 6.0 development system. They could have been written in any language that supports the Component Object Model (COM). In fact, a few of the components were originally written in the Microsoft Visual C++® development system and Microsoft Active Template Library (ATL).

While there are a few peculiar aspects in building Visual Basic 6.0 COM components, they were much easier to build than their ATL counterparts. The ATL code was about twice as long, and more difficult to write, than the Visual Basic version.

Generic Code

Every interface method that talks to ADO uses the same set of generic code. There is more work to do if we want the component to be part of a transaction. The generic code will be explained in two sections: Login() and ApproveReport().

Instead of explaining each component separately, I'll explain each method in the three main contexts:

Login Methods

When a user first visits the FMCorp site, they are prompted for their e-mail address and password. If the user has a valid Microsoft Windows NT® account, they can choose to let the browser authenticate their network credentials automatically.

Both login methods require their own middle-tier implementation that calls stored procedures on the database. Let's start with the first method, ExpenseReadDB.Login().

ExpenseReadDB.Login()

Login() tests the validity of an e-mail and password combination by using the sp_Login stored procedure. It is used by the LoginAction.asp Web page.

Every public method in all the components takes a data source name (DSN) as the first parameter. The DSN is simply a string that contains enough information to connect to a database. It includes the database server name, user name, password, default database, database driver, and so on. The DSN we use for all connections in FMCorp is defined in one place, the global.asa file in the Web project. The following code shows the DSN:

<SCRIPT LANGUAGE=VBScript RUNAT=Server>
Sub Application_OnStart
   '==Visual InterDev Generated - startspan==
   '--Project Data Connection
   
   ...

      Application("FMCorp_ConnectionString") = "DRIVER=SQL Server;SERVER=ahi;UID=sa;
APP=Microsoft Development Environment;WSID=AHI;DATABASE=FMCorp;User Id=sa;PASSWORD=;"

   ...

End Sub

We don't type this string in by hand. If you read the FMCorp Setup documentation, you'll see that we start the Web project with an empty global.asa file. You use the database connection dialog box in the Microsoft Visual Studio® development system to create the DSN.

The e-mail address and password are the next two parameters passed by the Web page. A simple HTML form gathers these two text strings. The final three parameters (UserID, FullName, and ManageCount) are outgoing parameters. These values are filled in if the e-mail and password combinations check out.

Like every method that deals with a database, an ADO Command object is created, called cmd. The With cmd Visual Basic operator allows us to leave out the word "cmd," which would normally be in front of every Command method.

The first three lines in the With block set up the database connection and the stored procedure name. The Refresh method makes a round-trip to the server to discover the names of each stored procedure parameter, along with its data type. There is an alternate way to code this that avoids a round-trip. The round-trip version is easier to read, but it does require more work on the database server. An alternate way to set up the Email parameter, for instance, would look like this:

cmd.Parameters.Append .CreateParameter("@email", adVarChar, adParamInput, 255, email)

You have to know ahead of time the type and size of each parameter. This method also requires you to specify the outgoing parameters, even though you don't use them until after cmd.Execute.

After setting up the parameters, the stored procedure is executed with cmd.Execute. The three return values (UserID, FullName, and ManageCount) are passed back to the caller through the ByRef method parameters.

If the UserID is not 0, the stored procedure call was successful. The FullName will be a concatenated string consisting of the first and last name. The ManageCount is simply a number that indicates how many employees are managed by this particular user.

If there are no run-time errors, the cmd object is explicitly freed and the function exits. If there are run-time errors, the code after Login_Error will be executed. The return value here is set to false, the error is bubbled up using Err.Raise, and control returns to the normal function exit procedure.

You will see that almost all components use a similar structure:

ExpenseReadDB.Login() source code

Public Function Login(ByVal dsn As Variant, ByVal Email As Variant, _
      ByVal Password As Variant, ByRef UserID As Variant, _
      ByRef FullName As Variant, ByRef ManageCount As Variant) As Boolean
    
    On Error GoTo Login_Error
    
    Dim cmd As New ADODB.Command
    Dim ret As Variant
    
    With cmd
        .ActiveConnection = dsn
        .CommandType = ADODB.adCmdStoredProc
        .CommandText = "sp_Login"
        .Parameters.Refresh
        .Parameters("@Email").Value = Email
        .Parameters("@Password").Value = Password
        .Execute
        
        UserID = .Parameters("@UserID")
        FullName = .Parameters("@FullName")
        ManageCount = .Parameters("@ManageCount")
    End With
   
    ' sp_Login indicates failure by setting @UserID = 0
    If UserID = 0 Then
        Login = False
    Else
        Login = True
    End If
    
Login_Exit:
    If Not cmd Is Nothing Then
        Set cmd = Nothing
    End If
    Exit Function

Login_Error:
    Login = False
    Err.Raise Err.Number, "FMCorp.ExpenseReadDB.Login", Err.Description
    Resume Login_Exit
 
End Function

ExpenseReadDB.Login() example

LoginAction.asp uses the following method as shown in the following Visual Basic Scripting Edition (VBScript) code:

<%@ LANGUAGE=VBScript %>
<%
Response.Buffer = true
Response.Clear 
Session("UID")= ""
email = trim(Request("login"))

If email <> "" Then
   set login = Server.CreateObject("FMCorp.ExpenseReadDB")
   
   dim UserID, ManageCount, FullName
   
   if login.Login(Application("FMCorp_ConnectionString"), email, 
         trim(Request("password")), UserID, FullName, ManageCount) then
      Session("UID") = UserID
      Session("name") = FullName
      
      if ManageCount > 0 then
         Session("Manager") = true
      end if
   else
      Response.Redirect("login.asp")
   end if
   set login = nothing
   
   Response.Redirect ("home.asp?refresh=1")
else
   Response.Redirect ("login.asp")
end if
%>

ExpenseReadDB.NTLogin()

Login() tests the validity of an email and password combination by using the sp_Login stored procedure. It is used by the LoginAction.asp Web page.

The NTLogin() method is slightly different from Login(). It only needs one parameter in addition to the DSN, the Windows NT authenticated login string. If you are running this Web site through Microsoft Internet Explorer and your browser attempts a connection to a Web page that specifically disallows anonymous access, the browser will negotiate your Windows NT credentials with the Web server.

By the time control gets to the LoginNT.asp Web page, the Web server has already received the Windows NT login information. The string looks like "domain\user." We simply take this string and pass it to ExpenseReadDB.NTLogin() to see if it corresponds to a particular UserID. This method returns the same parameters as the preceding Login().

ExpenseReadDB.NTLogin() source code

Public Function NTLogin(ByVal dsn As Variant, ByVal Login As Variant, _
      ByRef UserID As Variant, ByRef FullName As Variant, _
      ByRef ManageCount As Variant) As Boolean
    
    On Error GoTo NTLogin_Error
    
    Dim cmd As New ADODB.Command
    Dim ret As Variant
    
    With cmd
        .ActiveConnection = dsn
        .CommandType = ADODB.adCmdStoredProc
        .CommandText = "sp_NTLogin"
        .Parameters.Refresh
              
        .Parameters("@NTLogin").Value = Login
       
        .Execute
        
        UserID = .Parameters("@UserID")
        FullName = .Parameters("@FullName")
        ManageCount = .Parameters("@ManageCount")
    End With
    
    If UserID = 0 Then
        NTLogin = False
    Else
        NTLogin = True
    End If
    
NTLogin_Exit:
    If Not cmd Is Nothing Then
        Set cmd = Nothing
    End If
    Exit Function

NTLogin_Error:
    NTLogin = False
    Err.Raise Err.Number, "FMCorp.ExpenseReadDB.NTLogin", Err.Description
    Resume NTLogin_Exit
 
End Function

Upload Methods

The primary job of the whole system is to allow employees to submit expense reports. They do this by filling in a Microsoft Excel template on their local computer. When they save the XLS file, a special macro in the template saves the file as an XML document. Later, when the employee uploads the XML file to the server, the following methods and components parse the XML file and add it to the database.

ExpenseReports.SubmitXMLFile()

SubmitXMLFile() is probably the longest and most complicated method. It is responsible for adding an expense report to the database. Some of the details are delegated to helper methods in other components, which will be described later.

Note   By the time SubmitXMLFile is called, the posting acceptor has already transferred the XML file from the client's computer to the post folder on the server.

SubmitXMLFile can be summarized with the following pseudo-code:

Add some error checking, transaction server methods, and variable declarations, and you'd have this code:

Public Function SubmitXMLFile(ByVal dsn As Variant, ByVal XMLFilePath As Variant, _
        ByRef ExpenseReportID As Variant, ByRef StatusCode As Variant) As Boolean
    
    On Error GoTo SubmitXMLFile_Error
    
    Dim context As ObjectContext
    Set context = GetObjectContext
    
    Dim scode As Variant
    
    ' Get the description, email and XMLDocument object from the XML File
    Dim xml As IXMLDocument
    Dim Email As String
    Dim Description As String
    If InitialParse(XMLFilePath, xml, Email, Description) = False Then
        scode = "Unable to parse XML file '" & XMLFilePath & "'"
        GoTo SubmitXMLFile_Error
    End If
    
    ' Create the two database components
    Dim readdb As FMCorp.ExpenseReadDB
    Dim writedb As FMCorp.ExpenseWriteDB
    
    If Not (context Is Nothing) Then
        Set readdb = context.CreateInstance("FMCorp.ExpenseReadDB")
        Set writedb = context.CreateInstance("FMCorp.ExpenseWriteDB")
    Else
        Set readdb = CreateObject("FMCorp.ExpenseReadDB")
        Set writedb = CreateObject("FMCorp.ExpenseWriteDB")
    End If
    
    ' Does this email correspond to a valid UserID?
    Dim UserID As Long
    UserID = readdb.GetUserID(dsn, Email)
    If UserID = 0 Then
        scode = "Unable to find a user with email '" & Email & "'"
        GoTo SubmitXMLFile_Error
    End If
    
    ' Create a new expense report
    Dim erid As Long
    If writedb.NewExpenseReport(dsn, UserID, Description, erid, scode) = True Then
        Dim root As IXMLElement
        Dim item As IXMLElement
        Dim icat As String
        Dim idesc As String
        Dim idate As String
        Dim icost As String
        Dim eiid As Long
                
        Set root = xml.root
        
        For Each item In root.children.item("ITEMS").children
            icat = item.children.item("TYPE").Text
            idesc = item.children.item("DESCRIPTION").Text
            idate = item.children.item("DATE").Text
            icost = item.children.item("COST").Text
            
            If writedb.AddExpenseItem(dsn, erid, idesc, idate, icost, _
                  icat, eiid, scode) = False Then
                GoTo SubmitXMLFile_Error
            End If
        Next
    Else
        GoTo SubmitXMLFile_Error
    End If
    
    SubmitXMLFile = True
    ExpenseReportID = erid
    If Not (context Is Nothing) Then
        context.SetComplete
    End If
    
SubmitXMLFile_Exit:
    If Not (xml Is Nothing) Then
        Set xml = Nothing
    End If
    If Not (context Is Nothing) Then
        Set context = Nothing
    End If
    If Not (readdb Is Nothing) Then
        Set readdb = Nothing
    End If
    If Not (writedb Is Nothing) Then
        Set writedb = Nothing
    End If
    StatusCode = scode
    Exit Function
    
SubmitXMLFile_Error:
    ' check cmd object ActiveConnection for NativeErrors
    
    SubmitXMLFile = False
    If Not (context Is Nothing) Then
        context.SetAbort
    End If
    Resume SubmitXMLFile_Exit
    
End Function

Error handling

You might notice the liberal use of GoTos in this method and just about every other interface we expose. This programming style is mostly due to the error handling syntax of the Visual Basic "On Error" construct. At the top of the SubmitXMLFile, we tell the Visual Basic run time to jump to the SubmitXMLFile_Error marker if anything strange happens. This could occur if one of the components couldn't be created, or one of the components raised an error or aborted the transaction.

The Error block sets the return value to false. Next, if the component is part of a transaction, it calls SetAbort to signal MTS to abort the entire transaction. The Resume statement jumps back to the normal exit point, SubmitXMLFile_Exit.

The Exit block releases any components that may have been created by setting them to "nothing." Finally, error messages in code are returned to the caller through the last parameter, StatusCode.

Once again, this pattern of Error blocks and Exit blocks will be repeated in almost all methods.

Implementing the pseudo-code

Now that you understand the Exit and Error blocks, the rest of the code is straightforward.

The first block enlists the InitialParse helper method to examine the version tag in the XML file. If the XML file passes this initial step, the Email and Description will be extracted. If the XML parser detects this is an invalid file, InitialParse will return false.

The rest of the code uses two FMCorp components. These components are instantiated differently if SubmitXMLFile is part of a transaction. If so, readdb and writedb are created within the current object context. This allows ExpenseWriteDB to inherit the current object context. Otherwise, the objects are created using the standard CreateObject call.

This two-way creation makes it easy to debug the components with the Visual Basic test program without needing the Windows NT 4.0 Service Pack 4 that allows MTS component debugging.

The next block of code ensures that the e-mail address in the XML file matches a real user in the user table. A return value of 0 indicates no match was found.

Next, a new expense report record is created using the description from the XML file, the UserID returned from GetUserID and the current DSN. If successful, the IXMLDocument interface allows us to enumerate the individual expense items in a loop. Each item is added to the ExpenseItems table through AddExpenseItem.

Nested transactions with MTS

SubmitXMLFile is the fundamental reason why FMCorp is so successful. Why? A lot of things can go wrong in this method—and I'll show you why it doesn't matter:

Now we can't prevent these problems from occurring, but we can handle them gracefully. And the right way to handle these cases is to wrap everything in a transaction.

Consider the following case: Dolores uploads a valid XML file containing three expense report items from her trip to Las Vegas, Nevada (InitialParse). Her UserID is pulled out of the database using her e-mail address as a unique key (GetUserID). A new expense report is generated, say, with an ExpenseReportID of 42 (NewExpenseReport). The first expense item, a hotel room at the Marriott, is added to the database with 42 as the foreign key ExpenseItem.ExpenseReportID (AddExpenseItem).

Then, for example, we'll say that the next line item has a typo. It says she rented a Ford Mustang for $1500. Because she has a maximum expense report limit of $500, the tr_ExpenseItems_iu trigger rolls back the stored procedure that tried to add the offending rental car to ExpenseItems.

What do we do with ExpenseReportID 42 and the hotel room? Absolutely nothing. The ExpenseReports and ExpenseWriteDB components require a transaction. When the trigger rolled back the transaction, ExpenseWriteDB picked up on the fact that the query failed. It calls the MTS object context method SetAbort to signal to the other parties that all bets are off. Then ExpenseReports.SubmitXMLFile exits.

Notice that the component doesn't have to "clean up after itself." Microsoft SQL Server™ 6.5 uses Distributed Transaction Controller (DTC) to communicate with MTS. The Web page that initiates the call to SubmitXMLFile is tagged at the top to load all the components in one transaction. Because the components are registered and controlled by MTS, everything—from the Web page down to the trigger—functions as one transaction.

This example was fairly simple, but imagine if SubmitXMLFile wrote to your Accounts Payable database. The more components you add to the picture, the tougher it is to keep everything synchronized. With MTS, all you have to do is a bit of housekeeping with SetComplete and SetAbort to keep everyone notified of the transaction state. Everything else is automatic.

Now for something much simpler. How exactly is the new expense report created?

ExpenseWriteDB.NewExpenseReport()

NewExpenseReport is simply a middle-tier wrapper for the stored procedure sp_NewExpenseReport. It takes a DSN, the UserID, and a description for the new report, and returns the new ExpenseReportID. If there are any problems along the way, StatusCode will contain a string with an error message:

Public Function NewExpenseReport(ByVal dsn As Variant, ByVal UserID As Variant, _
      ByVal Description As Variant, ByRef ExpenseReportID As Variant, _
      ByRef StatusCode As Variant) As Boolean

    On Error GoTo NewExpenseReport_Error
    Dim helper As New CHelper
    helper.Init (dsn)
    
    With helper.cmd
        .CommandText = "sp_NewExpenseReport"
        .Parameters.Refresh
        
        .Parameters("@UserID").Value = UserID
        .Parameters("@Description").Value = Description
        .Execute
        
        ExpenseReportID = .Parameters("@ERID")
    End With
    
    NewExpenseReport = True
    helper.SetComplete
    
NewExpenseReport_Exit:
    helper.Cleanup
    Exit Function

NewExpenseReport_Error:
    NewExpenseReport = False
    StatusCode = helper.SetAbort()
    Resume NewExpenseReport_Exit
    
End Function 

NewExpenseReport uses a custom class called CHelper to initialize a database connection with the DSN. The name of the stored procedure is set in the ADODB.Command object.

CHelper

All three ExpenseWriteDB methods use a helper class, called CHelper, to assist with the housekeeping. Without CHelper, a lot of boilerplate code can get in the way of the parameter wrangling.

The source code for CHelper is small:

Public context As ObjectContext
Public cmd As ADODB.Command
Public scode As Variant

Public Function SetAbort() As String
    If cmd.ActiveConnection.Errors.Count > 0 Then
        Dim error As ADODB.error
        
        For Each error In cmd.ActiveConnection.Errors
            scode = "(" & error.NativeError & ") " & error.Description
        Next
    Else
        scode = Err.Description
    End If
    If Not (context Is Nothing) Then
        context.SetAbort
    End If
    
    SetAbort = scode
End Function

Public Sub SetComplete()
    If Not (context Is Nothing) Then
        context.SetComplete
    End If
End Sub

Public Sub Cleanup()
    If Not (cmd Is Nothing) Then
        Set cmd = Nothing
    End If
    If Not (context Is Nothing) Then
        Set context = Nothing
    End If
End Sub


Public Sub Init(ByVal dsn As String)
    Set context = GetObjectContext
    Set cmd = CreateObject("ADODB.Command")
    scode = ""
    cmd.ActiveConnection = dsn
    cmd.CommandType = ADODB.adCmdStoredProc
End Sub

An instance of CHelper manages the database connection, the MTS object context, and the status string. Its usage requires the Init function to be called before accessing the public member variables. SetComplete and SetAbort mimic the MTS commands with a little more error checking. If for some reason the object context can not be obtained (such as when the code is exercised with the Visual Basic test program), the code still works smoothly.

ExpenseWriteDB.AddExpenseItem()

AddExpenseItem inserts a new record into the ExpenseItem table. It needs the following parameters:

The method returns the new ExpenseItemID and a StatusCode in the two ByRef parameters. If there are any errors or the query fails, the function returns false.

If you compare this function to NewExpenseReport, they only differ in the name of the stored procedure and parameters. The error handling is identical:

Public Function AddExpenseItem(ByVal dsn As Variant, ByVal ExpenseReportID As Variant, _
      ByVal Description As Variant, ByVal ItemDate As Variant, _
      ByVal ItemCost As Variant, ByVal Category As Variant, _
      ByRef ExpenseItemID As Variant, ByRef StatusCode As Variant) As Boolean
    
    On Error GoTo AddExpenseItem_Error
    Dim helper As New CHelper
    helper.Init (dsn)
    
    With helper.cmd
        .CommandText = "sp_NewExpenseItem"
        .Parameters.Refresh
        .Parameters("@ERID").Value = ExpenseReportID
        .Parameters("@Description").Value = Description
        .Parameters("@Date").Value = CDate(ItemDate)
        .Parameters("@Amount").Value = CCur(ItemCost)
        .Parameters("@CategoryDescription").Value = Category
               
        .Execute
        ExpenseItemID = .Parameters("@ItemID")
    End With
    
    AddExpenseItem = True
    helper.SetComplete
    
AddExpenseItem_Exit:
    helper.Cleanup
    Exit Function
    
AddExpenseItem_Error:
    AddExpenseItem = False
    StatusCode = helper.SetAbort()
    Resume AddExpenseItem_Exit
    
End Function

Notice the item date and cost are cast to their respective data types. This is necessary because the Value property is expecting a correctly typed Variant.

ExpenseReadDB.GetUserID()

GetUserID retrieves a DSN and e-mail address and returns the corresponding UserID:

Public Function GetUserID(ByVal dsn As Variant, ByVal Email As Variant) As Long
    On Error GoTo GetUserID_Error
    
    Dim cmd As New ADODB.Command
    Dim ret As Variant
    
    With cmd
        .ActiveConnection = dsn
        .CommandType = ADODB.adCmdStoredProc
        .CommandText = "sp_GetUserID"
        .Parameters.Refresh
        .Parameters("@email").Value = Email
                
        .Execute
        ret = .Parameters("@UserID")
    End With
    
    If IsNull(ret) Then
        GetUserID = 0
    Else
        GetUserID = ret
    End If
    
GetUserID_Exit:
    If Not cmd Is Nothing Then
        Set cmd = Nothing
    End If
    Exit Function

GetUserID_Error:
    GetUserID = 0
    Err.Raise Err.Number, "FMCorp.ExpenseReadDB GetUserID", Err.Description
    Resume GetUserID_Exit
 
End Function

If no UserID is found, GetUserID returns a 0. The IsNull test right after the Execute method determines if the UserID parameter is NULL. In order to really understand this check and why it is necessary, we need to examine the stored procedure code:

Create Procedure sp_GetUserID
(
   @email varchar(255),
   @UserID int OUTPUT
)
As
   select @UserID=UserID
   from users
   where email = @email

What happens in the case where no corresponding User record can find an e-mail address? The output parameter, @UserID, will be NULL instead of 0.

Because this method lives in a nontransaction-enabled component, ExpenseReadDB, this method doesn't use the CHelper class.

Approve/Decline Methods

Only managers can approve or decline expense reports. It's up to the user interface to enforce this security restriction. After the expense report status changes, an e-mail message is sent to the employee as confirmation.

ExpenseReports.ApproveReport()

ApproveReport either approves or declines a given ExpenseReportID. It sends an e-mail message to the employee who submitted the expense report indicating what happened.

After setting up the four components used by this method (object context, ReadDB, WriteDB, and Mail), ApproveReport first verifies the ExpenseReportID. It extracts the UserID and Email address by calling GetExpenseReport, a helper method in ExpenseReadDB.

Next, ApproveExpenseReport does the work to approve or decline the report, depending on the parameter, Approve. If successful, ApproveReport uses the e-mail component ExpenseMail to send a confirmation message.

The standard Error and Exit blocks are at the bottom of the code. Because ApproveReport is in a transaction-enabled component, if any step along the way fails, the whole transaction is rolled back:

Public Function ApproveReport(ByVal dsn As Variant, ByVal ExpenseReportID As Variant, _
        ByVal Approve As Boolean, StatusCode As Variant) As Boolean
    
    On Error GoTo ApproveReport_Error
    
    ' Setup the 3 FMCorp objects and the MTS object context
    
    Dim context As ObjectContext
    Set context = GetObjectContext
      
    Dim readdb As FMCorp.ExpenseReadDB
    Dim writedb As FMCorp.ExpenseWriteDB
    Dim mail As FMCorp.ExpenseMail
    
    If Not (context Is Nothing) Then
        Set readdb = context.CreateInstance("FMCorp.ExpenseReadDB")
        Set writedb = context.CreateInstance("FMCorp.ExpenseWriteDB")
        Set mail = context.CreateInstance("FMCorp.ExpenseMail")
    Else
        Set readdb = CreateObject("FMCorp.ExpenseReadDB")
        Set writedb = CreateObject("FMCorp.ExpenseWriteDB")
        Set mail = CreateObject("FMCorp.ExpenseMail")
    End If
    
    ' Make sure this ERID is valid and retrieve the associated UserID and email
    Dim UserID As Integer
    Dim Email As String
    If readdb.GetExpenseReport(dsn, ExpenseReportID, UserID, Email) = False Then
        StatusCode = "Could not locate expense report ID " & ExpenseReportID
        GoTo ApproveReport_Error
    End If
    
    ' Approve or decline the expense report
    If writedb.ApproveExpenseReport(dsn, ExpenseReportID, _
         Approve, StatusCode) = False Then
        GoTo ApproveReport_Error
    End If
    
    ' Send out the appropriate notification
    Dim ret As Boolean
    Dim subject As String
    Dim body As String
    If Approve Then
        subject = "Expense Report " & ExpenseReportID & "approved."
        body = "Your expense report (" & ExpenseReportID & ") was approved."
    Else
        subject = "Expense Report " & ExpenseReportID & "declined."
        body = "Your expense report (" & ExpenseReportID & ") was declined."
    End If
    ret = mail.SendMail(Email, subject, body)
    
    ApproveReport = True
    If Not (context Is Nothing) Then
        context.SetComplete
    End If

ApproveReport_Exit:
    If Not (mail Is Nothing) Then
        Set mail = Nothing
    End If
    If Not (context Is Nothing) Then
        Set context = Nothing
    End If
    If Not (readdb Is Nothing) Then
        Set readdb = Nothing
    End If
    If Not (writedb Is Nothing) Then
        Set writedb = Nothing
    End If
    Exit Function
    
ApproveReport_Error:
    If StatusCode = "" Then
        StatusCode = Err.Description
    End If
    
    ApproveReport = False
    If Not (context Is Nothing) Then
        context.SetAbort
    End If
    Resume ApproveReport_Exit

End Function

ExpenseMail.SendMail()

SendMail sends a simple e-mail message.

SendMail uses the Collaborative Data Objects (CDO) for Microsoft Windows NT Server version 1.2. This library is installed if you choose the SMTP Services when installing the Windows NT 4 Option Pack.

If you are trying to compile this component on a Windows NT Workstation, you won't have the CDO component. Remove the comment that defines the SMTP variable as an object and add a comment to the next line and compile. The DLL will compile the same, but you won't have the benefit of Microsoft IntelliSense® source code:

Public Function SendMail(ByVal toEmail As String, ByVal subject As String, _
      ByVal body As String, Optional _
      ByVal fromEmail As String = "admin@fmcorp.com") As Boolean
    
    On Error GoTo SendMail_Error
    
    ' The CDONTS.NewMail object can be found under Project | References
    ' Microsoft CDO for NTS 1.2 Library
    ' in c:\winnt\system32\cdonts.dll
    
    ' You can install this option on your server by running NT 4.0 Option Pack
    ' and choosing the SMTP service.

    ' If you don't have this library installed on your development machine
    ' Remove the comment from the next line and comment the following line.
    
    ' Dim smtp As Object 'really a CDONTS.NewMail object
    Dim smtp As CDONTS.NewMail
    
    Set smtp = CreateObject("CDONTS.NewMail")
    smtp.Send fromEmail, toEmail, subject, body, 1
    Set smtp = Nothing ' not allowed to reuse it for another message
    
    SendMail = True
    Exit Function
    
SendMail_Error:
    If Not smtp Is Nothing Then
        Set smtp = Nothing
    End If
    SendMail = False
    
End Function

Development Tips and Tricks

During the course of developing these components, we came up with a list of tips and tricks that you might find useful.

FMCorp.dll Is Always Locked

The first time you run the Web site and login, Windows NT Server's MTS or Internet Information Server (IIS) technologies will lock FMCorp.dll for an undetermined amount of time. If you're in the code-compile-test cycle, it can be frustrating.

If you can't compile the DLL, try restarting the Web server. I use this batch file to make restarting IIS easy from a command prompt:

@echo off
net stop iisadmin /y
net start w3svc
net start smtpsvc

The IntelliSense feature in Visual Basic or the Microsoft Visual InterDev™ Web development system might have the component open in order to examine its type library. If you suspect that might be the case, quit the applications and try to recompile FMCorp.dll in Visual Basic.

Theoretically, IIS shouldn't have a direct lock on the components because they should be registered and controlled by MTS. You can "shut down" the FMCorp package in MTS to force it to unload, as shown in Figure 2. Otherwise, it will unload FMCorp.dll after three minutes of idle time.

Figure 2. Shut down a package

We have noticed that the first time a method in FMCorp is used (usually one of the login methods), it takes up to 10 seconds to kick in. You can configure the package to stay in memory while idle, instead of being shut down after five minutes, as shown in Figure 3. This will ensure that the package is loaded and ready to go.

Figure 3. FMCorp package properties

Visual Basic Annoyances

Here are three suggestions that make Visual Basic easier to use:

  1. Turn off Auto Syntax Check. If Visual Basic can't grok your code, it'll just underline it in red (just like Word does with the word "grok").

  2. Turn on Require Variable Declaration. By default, Visual Basic will let you summon variable names out of thin air, causing problems if you misspell one. Figure 4 shows how to change these two options.

  3. Visual Studio installs a great looking monospaced font called Lucida Sans Typewriter. Use it at 8 point if you can. Its width is larger, but overall line height is smaller, allowing you to see more lines in a given window. Another good font is Monotype (www.microsoft.com/typography/fontpack/default.htm). Both fonts are a nice change from tired, old Courier New.

Figure 4. Changing Visual Basic options

Passing Parameters to Stored Procedures

ADO supports two methods for passing parameters to stored procedures: Parameters.Refresh and Parameters.Append.

FMCorp uses the Refresh method because it is easier to read and code, but it does have some performance problems.

Parameters.Refresh implements a run-time binding that makes building a parameter list easy. There is one downside for scalable, production-quality applications using this method to build parameter lists. Refresh causes ADO to make many round-trips to the database to determine parameter data types.

The NewExpenseReport method in ExpenseWriteDB uses the stored procedure sp_NewExpenseReport. The Refresh call makes seven separate queries—one call to sp_cursorfetch for every parameter, plus one more for sp_sproc_columns—before making the final call in Execute. You can examine the SQL trace results in Figure 5.

Figure 5. SQL trace for sp_NewExpenseReport

The Parameter.Refresh code block from NewExpenseReport is shown next. (Notice that you don't need to specify output parameters before Execute is called):

With helper.cmd
        .CommandText = "sp_NewExpenseReport"
        .Parameters.Refresh
        
        .Parameters("@UserID").Value = UserID
        .Parameters("@Description").Value = Description
        .Execute
        
        ExpenseReportID = .Parameters("@ERID")
    End With

Parameter.Append offers an alternative method for passing parameters to stored procedures. The following code could have been used in place of the With block from the preceding code:

With helper.cmd
        .CommandText = "sp_NewExpenseReport"
        
        .Parameters.Append .CreateParameter("@UserID", adInteger, _
            adParamInput, 8, UserID)
        .Parameters.Append .CreateParameter("@Description", adVarChar, _
            adParamInput, 255, Description)
        .Parameters.Append .CreateParameter("@ERID", adInteger, adParamOutput, _
            8, ExpenseReportID)
        .Execute
        
        ExpenseReportID = .Parameters("@ERID")
    End With

This code is more tightly bound to the in and out parameters of the stored procedure. However, it doesn't require the eight extra calls to SQL Server. We recommend using the first method for quick prototyping, and then migrating to the CreateParameter method.

Debugging Trick

Sometimes debugging middleware components requires old tricks. You can use the scripting components to record the status of a running component. We used the following code to debug a timing problem:

    Set fso = CreateObject("Scripting.FileSystemObject")
    Set debugfile = fso.CreateTextFile("c:\debugwrite.log")
    
    debugfile.writeline ("Starting " & Time)

' The CDONTS.NewMail object can be found under Project | References

    debugfile.writeline ("Sstopping " & Time)
    debugfile.Close

Visual Basic 6.0 coupled with Service Pack 4 for Windows NT Server allows a developer to debug components directly in Visual Basic. Only a beta version of this service pack was available during the development of FMCorp.

VB Test Application

Throughout the development of the FMCorp components, we kept a separate Visual Basic test program around to exercise the new methods. Whenever we needed to test a new FMCorp method, we dropped another button on the FMCorpTest form and wrote "driver" code to test the method. Figure 6 shows the final version of the application.

Figure 6. FMCorp test app

The easiest way to keep the test program handy was to create a Visual Basic project group. This group contained the FMCorp COM DLL project (FMCorp.vbp) and the FMCorpTest application (FMCorpTest.vbp). Figure 7 shows the Project Group window.

Figure 7. FMCorp project group

Granted, the test program didn't look as pretty as the final version in Figure 6. Most of the time we just dragged a random button onto the form, double-clicked to drop into source mode, and then dashed out a few lines of code.

For example, in order to test the new mail method ExpenseMail.SendMail, it only takes a few lines of Visual Basic code:

Private Sub SendMail_Click()
    Dim mail As New FMCorp.ExpenseMail
    Dim ret
    ret = mail.SendMail("scott@VertigoSoftware.com", "A Tesst Subject", _
      "Hello from SendMail.")
   
    List1.AddItem ("Sent mail using SendMail method. Return value=" & ret)
End Sub

You'll find it easier to test and track bugs using this method, rather than writing ASP VBScript code. Without Service Pack 4, though, you won't be able to test the object context functions.

The test program has a list box called List1. All driver methods dump the return value into this list box instead of the Visual Basic debug window. This allows you to run the application as a stand-alone executable and still track the results.

The test program came in handy in a number of cases. Usually, after setting up a new FMCorp test server, we'd run the compiled FMCorpTest program and click the Login button. Of course, with a hard-coded DSN, we'd sometimes have different versions of the application.

If you are testing FMCorp.dll with FMCorpTest and using the Web site at the same time, you might need to reregister FMCorp.dll with MTS. Visual Basic changes the registry settings when debugging the FMCorpTest program. You can verify this for yourself by locating FMCorp.dll in the registry and watching the changes when you press F5 to start debugging.

Reregistering FMCorp with MTS is easy. Using the MTS Explorer, simply select the four component icons in MTS and press DELETE. Then drag the FMCorp.dll back into the FMCorp package. If the package is missing, create a new one using the default settings.

Implementation Process

Most of the components were developed using a "spiral" model. We followed the five steps listed here for each area of functionality (login, submittal, approval, and so on). Once we satisfied our testing criteria, we repeated the steps for the next main area.

  1. Write the SQL query with ISQL or Visual InterDev.

  2. Convert the query to a stored procedure. Use output parameters where appropriate.

  3. Write the Visual Basic component method that wraps the query.

  4. Test the component using the Visual Basic test program. You could also test the components in Visual C++.

  5. Implement the Web page that calls the component.

Step 1

For example, we needed a login procedure that uses the e-mail address and password. We wrote a rough query in step 1 that looked like this:

select UserID, FirstName + ' ' + LastName
from Users
where Email = "alice@fmcorp.com"
and password = ""

Step 2

The first conversion to a stored procedure looked like this:

create Procedure sp_Login
(
   @Email varchar(50),
   @Password varchar(50),
   
   @FullName varchar(100) output,
   @UserID int output
)
As
   select @UserID = 0
   select @UserID = UserID, @FullName = FirstName + ' ' + LastName
   from Users
   where Email = @email
   and password = @Password
go

The first select initializes the output parameter @UserID to 0. If no matching records were found, @UserID would have been NULL, making the interpretation in Visual Basic a little harder to handle.

After testing the stored procedure, we realized there were a couple of deficiencies. First, the e-mail address had to match completely. You couldn't pass "alice" expecting to match "alice@fmcorp.com". We didn't want to force users to enter the full e-mail address each time. It was acceptable to match just enough to make the login unique.

Second, we needed to know if this user managed any employees. The easiest way to do this was to return a numeric count of the number of managed employees.

The final version of the stored procedure looks like this:

create Procedure sp_Login
(
   @Email varchar(50),
   @Password varchar(50),
   
   @FullName varchar(100) output,
   @UserID int output,
   @ManageCount int output
)
As
   select @UserID = 0
   select @UserID = UserID, @FullName = FirstName + ' ' + LastName
   from Users
   where Email like @email + '%'
   and password = @Password
      
   -- Exactly one unique user should be found, otherwise fail
   if @@rowcount <> 1
   begin
      select @UserID = 0
      return
   end
   
   select @ManageCount = count(*)
   from Users
   where ManagerID = @UserID

Step 3

We examined the Visual Basic code that calls this stored procedure. It is repeated here:

Public Function Login(ByVal dsn As Variant, ByVal Email As Variant, ByVal Password As Variant, ByRef UserID As Variant, ByRef FullName As Variant, ByRef ManageCount As Variant) As Boolean
    
    On Error GoTo Login_Error
    
    Dim cmd As New ADODB.Command
    Dim ret As Variant
    
    With cmd
        .ActiveConnection = dsn
        .CommandType = ADODB.adCmdStoredProc
        .CommandText = "sp_Login"
        .Parameters.Refresh
        .Parameters("@Email").Value = Email
        .Parameters("@Password").Value = Password
        .Execute
        
        UserID = .Parameters("@UserID")
        FullName = .Parameters("@FullName")
        ManageCount = .Parameters("@ManageCount")
    End With
   
    ' sp_Login indicates failure by setting @UserID = 0
    If UserID = 0 Then
        Login = False
    Else
        Login = True
    End If
    
Login_Exit:
    If Not cmd Is Nothing Then
        Set cmd = Nothing
    End If
    Exit Function

Login_Error:
    Login = False
    Err.Raise Err.Number, "FMCorp.ExpenseReadDB.Login", Err.Description
    Resume Login_Exit
 
End Function

Step 4

The login code tests both Login and NTLogin. The source code looks like this:

Private Sub TestLogin_Click()
    Dim l As New FMCorp.ExpenseReadDB
    
    Dim UserID As Variant
    Dim ManageCount As Variant
    Dim FullName As Variant
    
    On Error GoTo LoginError
    
    If l.Login(g_dsn, "alice", "", UserID, FullName, ManageCount) Then
        List1.AddItem ("Login successful for alice with UserID=" & UserID)
    Else
        List1.AddItem ("Login failed: " & Err.Description)
    End If
    
    ' Try an NT Login now
    If l.NTLogin(g_dsn, "fmcorp\alice", UserID, FullName, ManageCount) Then
        List1.AddItem ("NTLogin successful.")
    Else
        List1.AddItem ("Login failed: " & Err.Description)
    End If
    
LoginExit:
    Set l = Nothing
    Exit Sub
    
LoginError:
    List1.AddItem "Error occurred while adding expense report:"
    List1.AddItem Err.Description
    Resume LoginExit
    
End Sub

The return values are posted to the list box embedded in the form. This code is also using the On Error blocks. These will catch errors raised by the components.

Step 5

Two Web pages are needed to test this method. The first is a simple form that captures the e-mail name and password. The form's action posts the information to the "action" page that actually performs the login. The LoginAction.asp page looks like this:

<%@ LANGUAGE=VBScript %>
<%
Response.Buffer = true
Response.Clear 
Session("UID")= ""
email = trim(Request("login"))

If email <> "" Then
   set login = Server.CreateObject("FMCorp.ExpenseReadDB")
   
   dim UserID, ManageCount, FullName
   
   if login.Login(Application("FMCorp_ConnectionString"), email,_ 
         trim(Request("password")), UserID, FullName, ManageCount) then
      Session("UID") = UserID
      Session("name") = FullName
      
      if ManageCount > 0 then
         Session("Manager") = true
      end if
   else
      Response.Redirect("login.asp")
   end if
   set login = nothing
   
   Session("page") = "home.asp"
   Response.Redirect ("MainPage.asp")
else
   Response.Redirect ("login.asp")
end if
%>

During step 4, I usually run the SQL Trace utility to make sure I'm passing the correct arguments. If the method is transactional, I watch the transaction statistics window. Although Login is not a transaction method, I would still expect to see the ExpenseReadDB component object count go to one and quickly drop back to zero, as shown in Figure 8.

Figure 8. Activation during login

Conclusion

The major ideas presented in this article include:

About the Author

Scott Stanfield is the president of Vertigo Software, Inc. Vertigo Software is a San Francisco Bay Area-based consulting firm that specializes in the design and development of Windows DNA applications and components. He can be reached at scott@vertigosoftware.com or on the Web at http://www.vertigosoftware.com/.

For More Information