Monday, 08 September 2008
PicoSearch

Three-Tier Architecture

This article appeared in the December 1996 edition of EXE, and was a special report on the development of n-tier applications using a Microsoft tool. The primary focus of this article specifically relates to Visual Basic 4, the first version of the product to support this new technology. The code listings are deliberately simplistic (rather than production quality), although the downloadable sample code is a bit more robust. The code is designed to run against RDO v1.

Download sample code


Client/Server development has traditionally been a two–tiered affair. That is, a client application directly interacts with a data source and makes its own decisions as to how the data should be manipulated. This approach has several drawbacks, the most significant being that an entire data set often needs to be pulled across the network to the client machine (which will be low spec in many cases), only to be discarded once a result has been derived. Under these circumstances, result sets can get paged out to disk and the application performance will suffer as a result. Another unfortunate by–product of this architectural approach is that the code is very specific to the task for which it was written. If a similar application is required by the same company which uses the same underlying data sources then the same logic must be recreated, either from scratch or by copying the code from the earlier application and then editing it as appropriate. This is wasteful for the company, and also might result in a duplication of any bugs which may exist in the original code. Worse still, any general changes which need to be made to the code (e.g. to reflect a change in the database structure) now needs doing (and testing) twice.

The three-tier model

Visual Basic 4 introduced the first implementation of a three–tier architectural model from Microsoft. This approach breaks down a system design into three components, namely Data Services, Business Services and User Services. The actual implementation of a three-tier system can vary architecturally, but the logical tiers are described as follows:

Data Services

The Data Services component is the actual source of the data, and may include such items as stored procedures and triggers. Typically, stored procedures are the means to manipulate the data at the physical level, and triggers can ensure that the referential integrity of the database is preserved. In short, it is only concerned with the mechanics of data access.

Business Services

Business Services is a layer that acts as a wrapper for the physical database. It is the component which actually causes the data manipulation/processing. It also validates its actions against business rules. For example, in a stock control system a user might be prevented from reordering a specific item until a certain minimum stock level is reached. In this case the Reorder method might be called but it would return an error instead of initiating the action.

User Services

The User Services layer is typically the program run by the end user – this layer is also known as the presentation layer (because that’s all it should do). This component interfaces only to the Business Services layer and all of its requests for data are satisfied from that level. At no time does the User Services component directly communicate with the Data Services component.

This approach to client/server design is described in the product documentation as the Services Model. The philosophy behind this is that the implementation of each layer is independent from the other 2 layers as long as the interfaces that exist are clearly and rigidly defined up front. The interface definition is called a contract. This tiered object architecture provides a level of abstraction and truly promotes ‘data hiding’ – both facets of the object oriented paradigm.

The means by which this is achieved is Automation. Previously called OLE Automation, it is the term given by Microsoft to the mechanism whereby separate object components can communicate with each other. This communication can also take place between object components that exist on different physical machines, in which case it is called Remote Automation. These object components, using the definitions just given, are normally referred to as Automation Objects.

Creating the server

Using the above definition of the three tier model, the use of the term ‘server’ can become a little ambiguous. To clarify this point, I am referring here to the Business Services component.

It is first necessary to identify what the purpose of the application will be, and to then specify these requirements as business objects. These business objects can then be mapped to software objects, and a logical hierarchy built for their access. Figure 1 shows the current design best practice for an object hierarchy. It shows a top level interface called Application, which is effectively a definition of the entire object set for that server application. The Application object contains a collection of other objects. In the example shown, an object exists (Customers) which is itself a collection of single Customer objects.

Figure 1: Generic object hierarchy

One of the design goals for a Business Services component should be reuse. Therefore when grouping together areas of functionality, decide whether some areas are so unrelated from each other as to warrant placing them in different servers altogether. This granularity is good practice because it lends itself towards greater reuse within the business community. With careful planning at a high level, a corporate environment can theoretically make a rich set of business objects available for reuse by its development community. Microsoft provide the Component Manager application with the Enterprise edition of Visual Basic 4 to assist in this process. Tools such as the Microsoft BackOffice Systems Management Server (SMS for short) can ensure that these objects are always up to date throughout the enterprise

The object model that we are putting together therefore has three classes. We can call the class at the top of the hierarchy ‘Application’. Microsoft tend to use this convention a fair bit, but it’s really a matter of personal choice. The Application class will be the only object that can be accessed directly. All other classes must be accessed via the Application class. In order to allow an external process to create an instance of the Application class, it is necessary to set the Instancing property (in the properties dialog for the class module) to either Creatable SingleUse or Creatable MultiUse. See the separate panel for an explanation of the differences between the two. The other two classes are more function specific, and we will call them Customers and Customer.

To expose each interface from the Application object requires that it be declared as Public, but with the Instancing properties set to Not Creatable. Thus the Application class would expose an instance of the Customers class in the declarations section as follows (note that I have deliberately avoided adopting any naming conventions for readability):

Public Customers As New Customers

Strictly speaking, no more code is required for the Application class. However, you may like to expose other methods or properties such as the version number of the server. It’s also worth mentioning that the use of the name Application for the top level class might not always be suitable, depending on the scope of the server. If you are defining a set of Business Services servers for an enterprise implementation then a more unique set of names would be appropriate.

The Customers class is designed to mimic (where appropriate) the behaviour of the predefined Collection class. This is a Visual Basic class which is capable of storing other objects, and provides certain methods and properties for accessing them. A simplified code listing for the Customers class appears below.

‘custmers.cls
‘============

Option Explicit
Private coll_Customers As New Collection

Public Sub Add(str_FirstName As String, str_Surname As String, _
  str_Address1 As String, str_Address2 As String, str_Address3 As String, _
  str_Address4 As String, str_PostCode As String, str_TelNo As String)

  ' Create a new Customer object
  Dim obj_New As New Customer

  ' Populate the new object with data
  With obj_New
    .FirstName = str_FirstName
    .Surname = str_Surname
    .Address1 = str_Address1
    .Address2 = str_Address2
    .Address3 = str_Address3
    .Address4 = str_Address4
    .Postcode = str_PostCode
    .TelNo = str_TelNo

    ' Now add this object to the coll_Customers collection
    ' (Use the surname as the key field in this simple example)
    coll_Customers.Add Item:=obj_New, Key:=str_Surname
  End With
End Sub

Public Property Get Item(var_Key As Variant) As Customer
  ‘Returns a specific Customer object
  Set Item = coll_Customers.Item(var_Key)
End Property

Public Sub Remove(var_Key As Variant)
  ‘ Remove object from the collection using var_Key key
  coll_Customers.Remove var_Key
End Sub

Public Property Get Count() As Integer
  ‘Return the Count property from the collection
  Count = coll_Customers.Count
End Property

The Customer class is little more than a data container which either receives or returns the data by using the new Let/Get Property statements. The code for this class is shown below. Note that the actual data members are declared as Private. This is where the strength of the class model can be found: by using the Let/Get property statements, any modifications to the data can be validated or refined before they are actually applied. They can also be rejected if necessary by not updating the internal data members, which illustrates the business–oriented validation aspect. Thus the ‘real’ data is hidden.

‘custmer.cls
‘============

Option Explicit
Private mlng_ID As Long
Private mstr_FirstName As String
Private mstr_Surname As String
Private mstr_Address1 As String
Private mstr_Address2 As String
Private mstr_Address3 As String
Private mstr_Address4 As String
Private mstr_Postcode As String
Private mstr_TelNo As String

Public Property Get ID() As Long
  ID = mlng_ID
End Property

Public Property Let ID(lng_NewValue As Long)
  mlng_ID = lng_NewValue
End Property

Public Property Get Surname() As String
  Surname = mstr_Surname
End Property

Public Property Let Surname(str_NewValue As String)
  mstr_Surname = str_NewValue
End Property

Public Property Get FirstName() As String
  FirstName = mstr_FirstName
End Property

Public Property Let FirstName(str_NewValue As String)
  mstr_FirstName = str_NewValue
End Property

Public Property Get Address1() As String
  Address1 = mstr_Address1
End Property

Public Property Let Address1(str_NewValue As String)
  mstr_Address1 = str_NewValue
End Property

‘ NB Address2, 3 and 4 as per Address1...

Public Property Get Postcode() As String
  Postcode = mstr_Postcode
End Property

Public Property Let Postcode(str_NewValue As String)
  mstr_Postcode = str_NewValue
End Property

Public Property Get TelNo() As String
  TelNo = mstr_TelNo
End Property

Public Property Let TelNo(str_NewValue As String)
  mstr_TelNo = str_NewValue
End Property

Running the server

Taking the above as a real project, we now need to make a few changes in order to get it to run as a server. When a new Visual Basic project is created, a blank Form1 is always created with it. This can probably be deleted because the server is unlikely to need to interact with a local user. However, you will want to add a code module for global variable declarations and global utility code. Note that it is more usual in an OLE server for the application start point to be set as Sub Main, rather than having a startup form. In our basic server model, no actual code is required, but the sub main still needs to exist. In a real–life server though this would be the logical point for specific initialisation code such as loading registry entries (e.g. the location of the central error repository).

The application start point is set in the Project page of the Tools, Options dialog, as shown in Figure 2.

Figure 2: Project page of Tools, Options dialog

This dialog also contains a few other settings of note:

  • Project Name: The string that is set in the Project Name field is used to identify (in the registry) the classes that are defined as Public. This is the Type Library name, and is the text string that is associated with the globally unique id (GUID).

  • Application Description: This is the text that will be displayed in the Tools:References dialog to identify the server (Figure 3).

  • Start Mode: The normal behaviour for an OLE server is to terminate itself if there are no references to it. Setting the start mode to OLE Server overrides this automatic termination and keeps the server open. This is necessary because the entries in the registry for applications that are running from the development environment only exist while the server is actually in run mode. If the server were to start up and then immediately close down again because there were no references to it then the client application would not find the server’s entries in the registry and would not be able to connect to them.

Figure 3: Tools, References dialog

Once these are set, it is just a matter of selecting the Run command and, correct syntax permitting, the server should be running.

Communication between the layers

Any software component built by Visual Basic which uses automation will automatically include an additional software component, either a proxy or a stub (or conceivably it could contain both). The proxy is associated with the client, and the stub with the server (Figure 4). It is the role of these agents to co–ordinate communications between automation objects (the communication is performed via the Remote Procedure Call (RPC) service), and to marshal parameter data to and from the process address spaces.

Figure 4: Proxy/stub communication on a single machine

If these processes reside on different machines then an additional program (called the Automation Manager) needs to be running on the same machine as the server component. This program handles the additional processing necessary to ferry RPC calls across the network (Figure 5). Only one instance of this program ever needs to run on the same machine because it acts as a central clearing house for all such RPC transactions, and it will spawn a new thread of execution for each RPC link that it is supporting.

Figure 5: Proxy/stub communication over the network

The automation mechanism as implemented in Visual Basic 4 makes use of it’s own mechanism for handling RPC calls across the network. Version 5 is bound to offer a DCOM implementation as a result of this technology now being available in Windows NT 4.

Error handling

Visual Basic, by nature, ideally needs an error handler to be installed in each Sub or Function that contains code. This may appear to increase the coding workload, but this process can be helped by using tools such as AutoCoder which saves a lot of repetitive typing by adding code templates to each Sub/Function. These templates can be configured to insert company standard error handler boilerplates which, for each new piece of code, can be modified to trap specific errors which could conceivably arise at run–time. For example, a routine which opens files would always need to trap error 53 – ‘File not found’.

For the server application it is unlikely that you would ever wish to display an error in a message box. In many cases server applications will be running on a dedicated box in the computer room and so a popup error message such as ‘Type mismatch’ might not be seen until a user complains that the application is not responding. Therefore, the error handler should either deal with the error locally (if it can) or use the Err.Raise syntax to pass the error up to the next item in the call chain, which is probably the calling application. If it isn’t, then each layer should fail gracefully and pass the error back up the chain until the top layer is reached.

It is not possible to predict every error that can occur, so allowance must be made for this by defining some sort of catch–all error code. Because a catch–all error trap can only return a general error value back to the calling procedure, it is a good idea to implement some form of server–side error manager which writes errors down to a central repository. The client application can then report to the user that an unforeseen error occurred in the server and that the helpline should investigate it. This process can be of most use if you also allow the client application to report a serial number to the user, which can relate to the primary id of the error record. For database applications, it is better to have this central error repository as an Access database or even a flat file, because the error might have been a problem reported by the Data Services component (e.g. a communication link failure). Therefore, error handling should not be reliant on other components.

Creating the client application

The client application is the representation of the User Services component (presentation layer). It’s role is to interact with the Business Services component and display the results to the end user for viewing or manipulation. Therefore it needs to obtain a reference to the Application class and, through this reference (or other references derived from it), interact with it.

The reference can be obtained via the Set As New <class> statement. For example,

Dim myServer As CustomerServer.Application
Set myServer = New CustomerServer.Application

The object reference is duly used, (for example):

Debug.Print "Connected to CustomerServer.Application version " & myServer.VersionNumber

After some usage, the reference is then deallocated:

Set myServer = Nothing

It is necessary, however, to tell the client application where this CustomerServer object exists. To do this open the Tools:References dialog (see Figure 3) and select the object entry which corresponds to the Application Description that you gave to the server (Figure 2). In this case, the entry ‘.EXE magazine sample server’ is the correct reference so the check box is clicked. For the server, it is worth using the priority button to raise the entry to near the top of the list (Visual Basic forces two of its own entries to the very top). This mechanism sets the order of the lookup of the server name, and if you are using names that conflict with existing objects from other sources then the server which appears highest in the list will get the connection.

The customer server illustrates the key point of the Client/Server issue, but is limited by its lack of support for persistent data. It is possible to modify the existing Customers class without changing any of the interfaces, and the Customer class can be modified by the addition of one entirely new module. Replacement code for the affected methods is shown below. Note that the code in these listings is somewhat simplified and includes no error handling. The sample code for this article (see note at end of the article) is more comprehensive. I would also like to make a point at this stage that the sample code uses stored procedures on the database, whereas I could have used pure RDO calls which have made for easier coding. The reason for this approach is to show processing being performed at the Data Services layer, which in turn emphasises the three-tier approach.

‘ Custmers.cls
‘=============

Public Sub Add(str_FirstName As String, str_Surname As String, str_Address1 As String, str_Address2 As String, str_Address3 As String, str_Address4 As String, str_PostCode As String, str_TelNo As String)

  ‘ Adds a new record to the database
  Dim obj_New As New Customer ' Create a new CCustomer object
  Dim str_sql As String

  ' Send the data down to SQL Server
  str_sql = "execute sp_InsCustomer '" & str_FirstName & "', '" & str_Surname
  str_sql = str_sql & "', '" & str_Address1 & "', '" & str_Address2
  str_sql = str_sql & "', '" & str_Address3 & "', '" & str_Address4
  str_sql = str_sql & "', '" & str_PostCode & "', '" & str_TelNo & "'"

  Dim rdo_ps As rdoPreparedStatement

  ‘ Prepare the SQL string for processing
  Set rdo_ps = grdo_Conn.CreatePreparedStatement("", str_sql)
  rdo_ps.Execute ‘ Run it
  Set rdo_ps = Nothing ‘ Dereference object

  ' Rebuild the object hierarchy. This must be done as a fresh
  ' read because we have no way of telling what the new CustomerId
  ' value will be (allocated automatically by SQL Server)

  SetupCollection

End Sub

Public Sub Remove(var_Key As Variant)
  ‘ Deletes a record
  Dim rdo_ps As rdoPreparedStatement
  Dim str_sql As String

  Set rdo_ps = grdo_Conn.CreatePreparedStatement("", "execute sp_DelCustomer " & Trim(CStr(var_Key)))
  rdo_ps.Execute
  Set rdo_ps = Nothing

  ' If the error handler hasn't triggered by this stage then
  ' it is safe to go ahead and remove the record from the local
  ' collection object
  coll_Customers.Remove CStr(var_Key)

End Sub

Private Sub SetupCollection()
  ‘ Reads all records from the database, writes each record
  ‘ to a new Customer object and then adds this object to
  ‘ the coll_Customers collection

  Dim lng_DelCount As Long
  Dim rdo_ps As rdoPreparedStatement
  Dim rdo_rs As rdoResultset

  ' First, empty out the coll_Customers collection
  For lng_DelCount = 1 To coll_Customers.Count
    coll_Customers.Remove 1
  Next lng_DelCount

  ' Execute the stored procedure which gets all of the
  ' customer records from the database

  Set rdo_ps = grdo_Conn.CreatePreparedStatement("", "{call sp_GetAllCustomers}")

  ' Open the resultant record set
  Set rdo_rs = rdo_ps.OpenResultset(rdOpenKeyset)

  ' For each record in the result set...
  While Not rdo_rs.EOF
    ' Create a new Customer object
    Dim obj_Local As New Customer

    ' Add each field of the current record to the
    ' new Customer object

    With obj_Local
      .ID = CLng(rdo_rs(SQL_CUSTOMER_ID))
      .FirstName = rdo_rs(SQL_CUSTOMER_FIRSTNAME)
      .Surname = rdo_rs(SQL_CUSTOMER_SURNAME)
      .Address1 = rdo_rs(SQL_CUSTOMER_ADDRESS1)
      .Address2 = rdo_rs(SQL_CUSTOMER_ADDRESS2)
      .Address3 = rdo_rs(SQL_CUSTOMER_ADDRESS3)
      .Address4 = rdo_rs(SQL_CUSTOMER_ADDRESS4)
      .Postcode = rdo_rs(SQL_CUSTOMER_POSTCODE)
      .TelNo = rdo_rs(SQL_CUSTOMER_TELNO)
    End With

    ' Add the new (populated) object to the coll_Customers collection
    coll_Customers.Add Item:=obj_Local, Key:=CStr(rdo_rs(SQL_CUSTOMER_ID))

    ' Release our reference to the local object
    Set obj_Local = Nothing

    ' Move to the next record in the result set
    rdo_rs.MoveNext

  ' And loop around again...
  Wend

  ‘ Dereference the connections
  Set rdo_rs = Nothing
  Set rdo_ps = Nothing

End Sub

‘Custmer.cls
‘===========

Public Sub Update()
  ‘ Updates the database with the modified values
  ‘ (which are defined in the declarations section)

  Dim rdo_ps As rdoPreparedStatement
  Dim str_sql As String

  ' Build up a SQL-style string which uses the sp_UpdCustomer stored procedure
  str_sql = "execute sp_UpdCustomer " & CStr(mlng_ID) & ", '" & mstr_FirstName
  str_sql = str_sql & "', '" & mstr_Surname & "', '" & mstr_Address1 & "', '"
  str_sql = str_sql & mstr_Address2 & "', '" & mstr_Address3 & "', '"
  str_sql = str_sql & mstr_Address4 & "', '" & mstr_Postcode
  str_sql = str_sql & "', '" & mstr_TelNo & "'"

  ' Set up the SQL statement for processing via RDO
  Set rdo_ps = grdo_Conn.CreatePreparedStatement("", str_sql)

  ' Run the SQL command
  rdo_ps.Execute

  ' Release the RDO interface
  Set rdo_ps = Nothing

End Sub

Navigating the dots

While the Remote Automation architecture allows a tidy way of developing three–tier applications, performance can be a problem if there is repetitive accessing of interfaces (i.e. the Object.Property syntax). For example, if the customers example contained 50 records then it would be a reasonable assumption that the new For Each...Next language construct would be the correct course of action. This could be coded as:

Dim myCust As Customer

‘Loop through object set and add text to list box
For Each myCust in myCustomers
  myListBox.AddItem myCust.FirstName & " " & myCust.Surname
Next

In fact, if this code was run as a client talking to a remote server then it could take several seconds to complete the execution. Now imagine a form which had several such list boxes. The user would have time to go and get a coffee, and your reputation would suffer (although you would never ship it in this state of course). So why is this method so slow? The reason is that there is a constant overhead for the preparation of a single RPC transaction between the proxy and the stub. Single events go unnoticed but when this is magnified by a factor of 50 (in this case) then the effect becomes more pronounced. Visual Basic 5 rumour pages on the Internet suggest that Microsoft have worked hard in this area and have decreased the overhead quite substantially. However a workaround needs to be used for the time being.

One way to overcome this problem is to build generic array passing mechanisms into the server. If an array is created and populated by the client application, and then passed by reference to the server, it can be populated with data. The array is then read by the client application, and only a single call was made to the server, rather than the 50 needed earlier. A simple example of this array handling technique is shown below. It could of course be extended to accept a variable number of parameters (using the ParamArray syntax) to define specific fields to return. It would also be possible to include range parameters to restrict the amount of data being sent back into manageable subsets.

>>Client process (relevant lines):

Dim myArray() As String ‘Declare the array
ReDim myArray(myCustomers.Count, 8) ‘Size it
myCustomers.ItemList myArray ‘..and populate it

>>Server process:

Public Sub ItemList(ByRef arr_Data() As String)

Dim int_CurrentRow As Integer
Dim myCust As Customer

int_CurrentRow = 0

‘Loop through each object in the collection
‘and add each field to the array
For Each myCust In coll_Customers
  arr_Data(int_CurrentRow, 0) = myCust.FirstName
  arr_Data(int_CurrentRow, 1) = myCust.Surname
  arr_Data(int_CurrentRow, 2) = myCust.Address1
  arr_Data(int_CurrentRow, 3) = myCust.Address2
  arr_Data(int_CurrentRow, 4) = myCust.Address3
  arr_Data(int_CurrentRow, 5) = myCust.Address4
  arr_Data(int_CurrentRow, 6) = myCust.Postcode
  arr_Data(int_CurrentRow, 7) = myCust.TelNo
   int_CurrentRow = int_CurrentRow + 1
Next myCust

Other performance issues

There is a certain amount of tuning which can performed in order to optimise the three–tier configuration. The network infrastructure obviously plays a key role in performance. The choice of data access model depends upon the scope of the system. If everything is intended to reside on a single machine and the database resides locally (e.g. an Access database), then DAO would be the ideal choice. RDO would be the more normal choice for server based data services (e.g. SQL Server) because it is designed to support the object model, although for the brave of heart the ODBC API is slightly faster and gives a greater degree of control over what you are doing. Beware though that you can get into difficulty more easily (and more substantially). The other Microsoft data access mechanism, the VBSQL.OCX control, has apparently had it’s day and is no longer being developed.

The amount of processing required of the server component and the actual performance of the CPU should be weighed against the throughput capability of the network infrastructure, because it may be beneficial to either have the business objects on an entirely separate machine from the database server, or to have them reside on the same machine.

The server component can be declared as Creatable SingleUse or Creatable MultiUse.

Finally, it is worth considering a pool manager application to reduce the startup time of objects. For example, if an object is declared as Creatable SingleUse (one process per client application) and it needs to initialise itself with data from the data services layer, then a new connection by a client could have a noticeable delay while the new object starts itself up. A pool manager is designed to maintain several extra running objects, and thereby overcome the startup delay. Visual Basic provides a sample pool manager as a springboard.

Raw performance increases are likely to be one of the features of the forthcoming Visual Basic 5. The fact that Visual Basic now has more substantial competition in the form of Delphi should have given the software giant the motivation they needed to speed things up. I hope.

Copyright ©2002 Jon Perkins I, Jon Michael Perkins, hereby assert and give notice of my right under section 77 of the Copyright, Designs, and Patents Act 1988 to be identified as the author of the foregoing article.