ADAM MAGEE
Adam Magee is a software developer who specializes in
building enterprise applications. He has worked with many large companies in
the
Taking care of
business every day
Taking care of business every way
I've been taking care of business, it's all mine
Taking care of business and working overtime
Work out!
"Taking Care of Business"
by Bachmann Turner Overdrive
Business objects are big news. Everyone says the key to building distributed enterprise applications is business objects. Lots of 'em.
Business objects are cool and funky-we need business objects! You need business objects! Everyone needs business objects!
This chapter presents a design pattern for developing solid, scalable, robust business objects, designed for (and from) real-life distributed enterprise applications.
The architecture we propose at The Mandelbrot Set (International) Limited (TMS) for building distributed business object-based applications breaks down into various layers of functionality.
The data access layer (DAL) is the layer that talks to the data source. The interface of this layer should be the same regardless of the type of data source being accessed.
The business object layer is the actual layer that models the entities within your application-the real data, such as Employees, Projects, Departments, and so forth. In Unified Modeling Language (UML) parlance, these are called entity objects.
The action object layer represents the processes that occur on the business objects: these processes are determined by the application requirements. In UML, these action object layers are called control objects.
The client application layer, such as a Microsoft Visual Basic form, an ActiveX document, or an Active Server Page (ASP) on a Web server, is known as an interface object in UML.
This chapter concentrates on the middle business object layer and introduces the concepts of action objects, factory objects, and worker objects.
The most common question I get asked by Visual Basic developers is, "What type of DAL should I use? Data Access Objects (DAO), Open Database Connectivity (ODBC) API, ODBCDirect, Remote Data Objects (RDO), ActiveX Data Objects (ADO), VBSQL, vendor-specific library, smoke signals, semaphores, Morse code, underwater subsonic communications?" The answer is never easy. (Although in windier climates I would advise against smoke signals.)
What I do think is a good approach to data access is to create a simple abstract data interface for your applications. This way, you can change your data access method without affecting the other components in your application. Also, a simple data interface means that developers have a much lower learning curve and can become productive more quickly.
Remember that we are trying to create a design pattern for business object development in corporate database-centric applications. These applications typically either send or receive data or they request a particular action to be performed; as such, a simple, straightforward approach is required. I like simplicity-it leads to high-quality code.
So am I saying don't use
Not only is abstracting the data access methodology important, but abstracting the input and output from this component are important as well. The DAL should return the same data construct regardless of the database source.
But enough theoretical waffling-let's look at something real.
The DAL presented here is essentially the same DAL that TMS uses in enterprise applications for its clients. I'll describe the operation of the DAL in detail here, but I'll concentrate only on using a DAL, not on building one. Remember that we want to concentrate on business object development, so this is the data object that our business objects will interface with.
The DAL below consists of only six methods. All other aspects of database operation (for instance, cursor types, locking models, parameter information, and type translation) are encapsulated and managed by the DAL itself.
cDAL Interface
Member |
Description |
OpenRecordset |
Returns a recordset of data |
UpdateRecordset |
Updates a recordset of data |
ExecuteCommand |
Executes a command that doesn't involve a recordset |
BeginTransaction |
Starts a transaction |
CommitTransaction |
Commits a transaction |
RollbackTransaction |
Rolls back a transaction |
What's all this recordset malarkey? Well, this is (surprise, surprise) an abstracted custom recordset (cRecordset). We must investigate this recordset carefully before we look at the operation of the DAL itself.
The method TMS uses for sending and receiving data to and from the database is a custom recordset object (cRecordset). In this chapter, whenever I mention recordset, I am referring to this custom cRecordset implementation, rather than to the various DAO/RDO/ADO incarnations.
A recordset is returned from the DAL by calling the OpenRecordset method. This recordset is completely disconnected from the data source. Updating a recordset will have no effect on the central data source until you call the UpdateRecordset method on the DAL.
We could have easily
returned an array of data from our DAL, especially since
Array manipulation is horrible. In Visual Basic, you can resize only the last dimension of an array, so forget about adding columns easily. Also, arrays are not self-documenting. Retrieving information from an array means relying on such hideous devices as constants for field names and the associated constant maintenance, or the low-performance method of looping through indexes looking for fields.
Enabling varying rows and columns involves using a data structure known as a ragged array-essentially an array of arrays-which can be cumbersome and counterintuitive to develop against.
The advantage of using a custom recordset object is tha 15115r171p t we can present the data in a way that is familiar to most programmers, but we also get full control of what is happening inside the recordset. We can again simplify and customize its operation to support the rest of our components.
Notice the Serialize method, which allows us to move these objects easily across machine boundaries. More on this powerful method later. For the moment, let's look at the typical interface of a cRecordset.
cRecordset Interface
Member |
Description |
MoveFirst |
Moves to first record |
MoveNext |
Moves to next record |
MovePrevious |
Moves to previous record |
MoveLast |
Moves to last record |
Name |
Shows the name of the recordset |
Fields |
Returns a Field object |
Synchronize |
Refreshes the contents of the recordset |
RowStatus |
Shows whether this row has been created, updated, or deleted |
RowCount |
Shows the number of records in the recordset |
AbsolutePosition |
Shows the current position in the recordset |
Edit |
Copies the current row to a buffer for modification |
AddNew |
Creates an empty row in a buffer for modification |
Update |
Commits the modification in the buffer to the recordset |
Serialize |
Converts or sets the contents of the recordset to an array |
This table shows the details of the interface for the cField object, which is returned from the cRecordset object.
cField Interface
Member |
Description |
Name |
Name of the field |
Value |
Contents of the field |
Type |
Visual Basic data type |
Length |
Length of the field |
So how can we utilize this cRecordset with our DAL? Here's an example of retrieving a recordset from the DAL and displaying information:
Dim oDAL As New cDALPlease forgive me for being a bit naughty in using automatically instantiated object variables, but this has been done purely for code readability.
Notice that the details involved in setting up the data connection and finding the data source are all abstracted by the DAL. Internally, the DAL is determining where the Employees data lives and is retrieving the data and creating a custom recordset. In a single-system DAL, the location of the data could be assumed to be in a stored procedure on a Microsoft SQL Server; in a multidata source system, the DAL might be keying into a local database to determine the location and type of the Employees data source. The implementation is dependent upon the requirements of the particular environment.
Also, the operation in the DAL is stateless. After retrieving the recordset, the DAL can be closed down and the recordset can survive independently. This operation is critical when considering moving these components to a hosted environment, such as Microsoft Transaction Server (MTS).
Statelessness is important because it determines whether components will scale. The term scale means that the performance of this object will not degrade when usage of the object is increased. An example of scaling might be moving from two or three users of this object to two or three hundred. A stateless object essentially contains no module-level variables. Each method call is independent and does not rely on any previous operation, such as setting properties or other method calls. Because the object has no internal state to maintain, the same copy of the object can be reused for many clients. There is no need for each client to have a unique instance of the object, which also allows products such as Microsoft Transaction Server to provide high-performance caching of these stateless components.
Two of the primary reasons for employing a custom recordset are serialization and software locking. Because passing objects across machines causes a considerable performance penalty, we need a way of efficiently moving a recordset from one physical tier to another.
Serialization allows you to export the contents of your objects (such as the variables) as a primitive data type. What can you do with this primitive data type? Well, you can use it to re-create that object in another environment-maybe in another process, maybe in another machine. All you need is the class for the object you have serialized to support the repopulation of its internal variables from this primitive data type. The process of serialization has tremendous performance advantages in that we can completely transfer an object to another machine and then utilize the object natively in that environment without incurring the tremendous performance cost that is inherent in accessing objects across machine boundaries.
The cRecordset object stores its data and state internally in four arrays. The Serialize property supports exposing these arrays to and receiving them from the outside world, so transferring a recordset from one physical tier to another is simply a matter of using the Serialize property on the cRecordset. Here's an example:
Dim oRec As New cRecordsetNow we have a recordset that can live independently. It can even be passed to another machine and then updated by a DAL on that machine, if required.
We need to keep track of whether the data on the central data source has changed since we made our copy. This is the job of one of the arrays inside the cRecordset, known affectionately as the CRUD array.
The CRUD array indicates whether this row has been Created, Updated, Deleted, or just plain old Read. Also stored is a globally unique identifier (GUID) for this particular row. This unique identifier must be automatically updated when a row is modified on the data source. These two parameters are used by the DAL in the UpdateRecordset method to determine whether a row needs updating and whether this row has been modified by someone since the client received the recordset. This process is a form of software locking, although it could be internally implemented just as easily using timestamps (if a given data source supports them).
Updating a cRecordset object through the DAL occurs by way of the UpdateRecordset method. UpdateRecordset will scan through the internal arrays in the recordset and perform the required database operation. The unique row identifier is used to retrieve each row for modification, so if someone has updated a row while the recordset has been in operation this row will not be found and an error will be raised to alert the developer to this fact. The following is an example of updating a row in a recordset and persisting that change to the data source:
Dim oRec As New cRecordsetAfter a cRecordset object has been used by UpdateRecordset to successfully update the data source, the cRecordset object needs to have the same changes committed to itself, which is accomplished by means of the Synchronize method.
Using the Synchronize method will remove all Deleted rows from the recordset and will set any Updated or Created rows to Read status. This gives the developer using the cRecordset object control over committing changes and also means the recordset state can be maintained in the event of an UpdateRecordset failure. Here is an example of synchronizing a recordset after a row has been updated:
oDAL.UpdateRecordset oRecSimply supplying the name of a cRecordset object to OpenRecordset is usually not enough information, except maybe when retrieving entire sets of data, so we need a way of supplying parameters to a DAL cRecordset operation.
This is achieved by using a cParams object. The cParams object is simply a collection of cParam objects, which have a name and a value. The cParams object, like the cRecordset object, can also be serialized. This is useful if the parameters need to be maintained on another machine.
CParams Interface
Member |
Description |
Add |
Adds a new cParam object with a name and a value |
Item |
Returns a cParam object |
Count |
Returns the count of collected cParam objects |
Remove |
Removes a cParam object |
Serialize |
Converts or sets the content of cParams object into an array |
CParam Interface
Member |
Description |
Name |
Name of the parameter |
Value |
Variant value of the parameter |
Here is an example of retrieving a recordset with parameters:
Dim oDAL As New cDALWe can see now that only two well-defined objects are parameters to the DAL-cRecordset and cParams-and both of these objects support serialization, giving a consistent, distributed operation-aware interface.
A lot of database operations do not involve, or should not involve, cRecordset objects. For instance, checking and changing a password are two operations that do not require the overhead of instantiating and maintaining a cRecordset. This is where you use the ExecuteCommand method of the DAL. The ExecuteCommand method takes as parameters both the name of the command to perform and a cParams object.
Any output cParam objects generated by the command are automatically populated into the cParams object if they are not supplied by default. Here's an example of checking a password:
Dim oDAL As New cDALMost corporate data sources support transactions; our DAL must enable this functionality as well. This is relatively easy if you are using data libraries such as DAO or RDO, since it is a trivial task to simply map these transactions onto the underlying calls. If your data source does not support transactions, you might have to implement this functionality yourself. If so, may the force be with you. The three transaction commands are BeginTransaction, CommitTransaction, and RollbackTransaction.
The DAL is taking care of all the specifics for our transaction, leaving us to concentrate on the manipulation code. In the case of a transaction that must occur across business objects, we'll see later how these business objects will all support the same DAL. Here's an example of updating a field inside a transaction:
Dim oRec As New cRecordsetSo that's a look at how our abstracted data interface works. I hope you can see how the combination of cDAL, cRecordset, and cParams presents a consistent logical interface to any particular type of data source. There is comprehensive support for distributed operation in the sense that the DAL is completely stateless and that the input and output objects (cParams and cRecordset) both support serialization.
So what, really, is a business object and how is it implemented? Well, in the Visual Basic world, a business object is a public object that exposes business-specific attributes. The approach TMS takes toward business objects is to employ the action-factory-worker model. We'll come to the action objects later, but for now we'll concentrate on the factory-worker objects.
A quick note about terminology: in this design pattern there is no such thing as an atomic "business object" itself. The combination of the interaction between action, worker, and factory can be described as a logical business object.
The factory-worker model stipulates that for each particular type of business object an associated management class will exist. The purpose of the management class is to control the creation and population of data in the business objects. This management class is referred to as a factory class. The interface of every factory class is identical (except under exceptional circumstances).
Likewise, the worker class is the actual business object. Business objects cannot be instantiated without an associated factory class. In Visual Basic-speak we say that they are "Public Not Creatable"-the only way to gain access to a worker object is through the publicly instantiable/creatable factory class. So when we refer to a business object we are actually talking about the combination of the factory and worker objects, since each is dependent on the other.
Adding all this factory-worker code to your application isn't going to make it any faster. If your worker objects had 30 properties each and you wanted to create 1000 worker objects, the factory class would have to receive 1000 rows from the database and then populate each worker with the 30 fields. This would require 30,000 data operations! Needless to say, a substantial overhead.
What you need is a method of populating the worker objects in a high-performance fashion. The Shared Recordset Model solves this problem, which means that one cRecordset is retrieved from the DAL and each worker object is given a reference to a particular row in that recordset. This way, when a property is accessed on the worker object, the object retrieves the data from the recordset rather than from explicit internal variables, saving us the overhead of populating each worker object with its own data.
Populating each worker object involves instantiating only the object and then passing an object reference to the Shared Recordset and a row identifier, rather than setting all properties in the worker object individually. The worker object uses this object reference to the recordset to retrieve or write data from its particular row when a property of the business object is accessed. But to establish the real benefits of using the factory-worker approach, we need to discuss briefly how distributed clients interface with our business objects. This is covered in much greater detail in the sections, "Action Objects" and "Clients," later in this chapter.
To make a long story short, distributed clients send and receive recordsets only. Distributed clients have no direct interface to the business objects themselves. This is the role of action objects. Action objects act as the brokers between client applications and business objects. The recordset supports serialization, so the clients use this "serializable" recordset as a means of transferring data to and from the client tier to the business tier via the action object.
It's quite common for a client to request information that originates from a single business object. Say, for example, that the client requests all the information about an organization's employees. What the client application wants to receive is a recordset containing all the Employee Detail information from an action object. The EmployeeDetail recordset contains 1800 employees with 30 fields of data for each row.
Let's look at what's involved in transferring this information from the business objects to the client if we don't use the Shared Recordset implementation.
The client requests the EmployeeDetail recordset from the Employee Maintenance action object.
The Employee Maintenance action object creates an Employee factory object.
The Employee factory object obtains an Employee recordset from the DAL.
The Employee factory object creates an Employee worker object for each of the rows in the recordset.
The Employee factory object sets the corresponding property on the Employee worker object for each of the fields in that row of the recordset.
We now have a factory-worker object containing information for our 1800 employees. But the client needs all this information in a recordset, so the client follows these steps:
The Employee Maintenance action object creates a recordset.
The Employee Maintenance action object retrieves each Employee worker object from the Employee factory object and creates a row in the recordset.
Each property on that Employee worker object is copied into a field in the recordset.
This recordset is returned to the client and serialized on the client side.
The client releases the reference to the action object.
Basically, the business object gets a recordset, tears it apart, and then the action object re-creates exactly the same recordset we had in the first place. In this case, we had 1800 × 30 data items that were set and then retrieved, for a total of 108,000 data operations performed on the recordsets!
Let's look at the difference if we use the Shared Recordset Model.
The client requests an EmployeeDetail recordset from the Employee Maintenance action object.
The Employee Maintenance action object creates an Employee factory object.
The Employee factory object obtains an Employee recordset from the DAL.
The Employee factory object keeps a reference to the recordset.
NOTE
Notice that at this point the factory will not create any objects; rather, it will create the objects only the first time they are requested-in essence, it will create a Just In Time (JIT) object.
The Employee Maintenance action object obtains a reference to the recordset from the Employee factory object via the Recordset property.
This recordset is returned to the client and serialized on the client side.
The client releases the reference to the action object.
Total data operations on the recordset-zero. We can now return large sets of data from a business object in a high-performance fashion. But this leads to the question of why you should bother with the business objects at all. If all the client is doing is requesting a recordset from the DAL, why the heck doesn't it just use the DAL directly?
Well, to do so would completely ignore the compelling arguments for object-oriented programming. We want contained, self-documenting abstracted objects to represent our core business, and this scenario is only receiving data, not performing any methods on these objects.
Remember that this is a particular case when a client, on a separate physical tier, requests a set of data that directly correlates to a single business object. The data will often need to be aggregated from one or more business objects. So the action object, which exists on the same physical tier as the business object, will have full access to the direct worker object properties to perform this data packaging role.
A client will often request that a complex set of business logic be performed. The action object will perform this logic by dealing directly with the worker objects and the explicit interfaces they provide. Thus, the action objects can fully exploit the power of using the objects directly.
Using the worker objects directly on the business tier means we are creating much more readable, self-documenting code. But because of the advantages of the Shared Recordset implementation, we are not creating a problem in terms of performance. If we need to shift massive amounts of data to the client, we can still do it and maintain a true business object approach at the same time-we have the best of both worlds.
Now that we have covered the general architecture of the action-factory-worker-recordset interaction, we can take a closer look at the code inside the factory and worker objects that makes all this interaction possible.
The interface for the factory object is as follows:
cFactory Interface
Member |
Description |
Create |
Initializes the business object with a DAL object |
Populate |
Creates worker objects according to any passed-in parameter object |
Item |
Returns a worker object |
Count |
Returns the number of worker objects contained in the factory |
Add |
Adds an existing worker object to the factory |
AddNew |
Returns a new empty worker object |
Persist |
Updates all changes in the worker objects to the database |
Remove |
Removes a worker object from the factory |
Recordset |
Returns the internal factory recordset |
Delete |
Deletes a worker object from the data source and the factory |
Parameters |
Returns the parameters that can be used to create worker objects |
Creating the factory is simple. The code below demonstrates the use of the Create method to instantiate a factory object. This code exists in the action objects (which we'll cover later). This sample action object uses the Employees business object to perform a business process.
Dim ocDAL As New cDALHold it-what's the DAL doing here? Isn't the purpose of business objects to remove the dependency on the DAL? Yes, it is. But something important is happening here, and it has everything to do with transactions. The scope and lifetime of the action object determines the scope and lifetime of our transaction for the business objects as well.
Say our action object needs to access four different factory objects and change data in each of them. Somehow each business object needs to be contained in the same transaction. This is achieved by having the action object instantiate the DAL, activating the transaction and passing that DAL to all four factory objects it creates. This way, all our worker object activities inside the action object can be safely contained in a transaction, if required. More on action objects and transactions later.
So what does the Create code look like? Too easy:
Public Sub Create(i_ocDAL As cDAL)Creating a factory object isn't really that exciting-the fun part is populating this factory with worker objects, or at least looking like we're doing so!
Dim ocDAL As New cDALHere a recordset has been defined in the DAL as Employees, which can take the parameter Department to retrieve all employees for a particular department. Good old cParams is used to send parameters to the factory object, just like it does with the DAL. What a chipper little class it is!
So there you have it-the factory object now contains all the worker objects ready for us to use. But how does this Populate method work?
Private oPicRecordset As cRecordsetThe important point here is that the Populate method is only retrieving the recordset-it is not creating the worker objects. Creating the worker objects is left for when the user accesses the worker objects via either the Item or Count method.
Some readers might argue that using cParams instead of explicit parameters detracts from the design. The downside of using cParams is that the parameters cannot be determined for this class at design time and do not contribute to the self-documenting properties of components. In a way I agree, but using explicit parameters also has its limitations.
The reason I tend to use cParams rather than explicit parameters in the factory object Populate method is that the interface to the factory class is inherently stable. With cParams all factory objects have the same interface, so if parameters for the underlying data source change (as we all know they do in the real world) the public interface of our components will not be affected, thereby limiting the dreaded Visual Basic nightmare of incompatible components.
Also of interest in the Populate method is that the cParams object is optional. A Populate method that happens without a set of cParams is determined to be the default Populate method and in most cases will retrieve all appropriate objects for that factory. This functionality is implemented in the DAL.
After we have populated the factory object, we can retrieve worker objects via the Item method as shown here:
Dim ocDAL As New cDALAt this point, the Item method will initiate the instantiation of worker objects. (Nothing like a bit of instantiation initiation.)
Public Property Get Item(i_vKey As Variant) As cwEmployeeSo what does PiCreateWorkerObjects do?
Private Sub PiCreateWorkerObjectsHere we can see the payback in performance for using the Shared Recordset Model. Initializing the worker object simply involves calling the Create method of the worker and passing in a reference to oPicRecordset and oPicDAL. The receiving worker object will store the current row reference and use this to retrieve its data.
But why is the DAL reference there? The DAL reference is needed so that a worker object has the ability to create a factory of its own. This is the way object model hierarchies are built up. (More on this later.)
The Item method is also the default method of the class, enabling us to use the coding-friendly syntax of
ocfEmployees("637").NameCouldn't be simpler:
MsgBox CStr(ofcEmployees.Count)Says it all, really.
Often you will have a factory object to which you would like to add pre-existing worker objects. You can achieve this by using the Add method. Sounds simple, but there are some subtle implications when using the Shared Recordset implementation. Here it is in action:
Dim ocDAL As New cDALYou'll run into a few interesting quirks when adding another object. First, since the worker object we're adding to our factory has its data stored in another factory somewhere, we need to create a new row in our factory's recordset and copy the data from the worker object into the new row. Then we need to set this new object's recordset reference from its old parent factory to the new parent factory, otherwise it would be living in one factory but referencing data in another-that would be very bad. To set the new reference, we must call the worker Create method to "bed" it into its new home.
Public Sub Add(i_ocwEmployee As cwEmployee)And there you have it-one worker object in a new factory.
You use the AddNew method when you want to request a new worker object from the factory. In the factory, this involves adding a row to the recordset and creating a new worker object that references this added row. One minor complication here: what if I don't have already have a recordset?
Suppose that I've created a factory object, but I haven't populated it. In this case, I don't have a recordset at all, so before I can create the new worker object I have to create the recordset. Now, when I get a recordset from the DAL, it comes back already containing the required fields. But if I don't have a recordset, I'm going to have to build one manually. This is a slightly laborious task because it means performing AddField operations for each property on the worker object. Another way to do this would be to retrieve an empty recordset from the DAL, either by requesting the required recordset with parameters that will definitely return an empty recordset, or by having an optional parameter on the OpenRecordset call. In our current implementation, however, we build empty recordsets inside the factory object itself.
But before we look at the process of creating a recordset manually, let's see the AddNew procedure in action:
Dim ocDAL As New cDALThis is how it is implemented:
Public Function AddNew() As cwEmployeeThis introduces an unavoidable maintenance problem, though. Changes to the worker object must now involve updating this code as well-not the most elegant solution, but it's worth keeping this in mind whenever changes to the worker objects are implemented.
So now we can retrieve worker objects from the database, we can add them to other factories, and we can create new ones-all well and good-but what about saving them back to the data source? This is the role of persistence. Basically, persistence involves sending the recordset back to the database to be updated. The DAL has a method that does exactly that-UpdateRecordset-and we can also supply and retrieve any parameters that might be appropriate for the update operation (although most of the time UpdateRecordset tends to happen without any reliance on parameters at all).
Dim ocDAL As New cDALWhat is this Persist method doing, then?
Public Sub Persist(Optional i_ocParams As cParams)Consider it persisted.
What about removing worker objects from factories? Well, you have two options-sack 'em or whack 'em!
A worker object can be removed from the factory, which has no effect on the underlying data source. Maybe the factory is acting as a temporary collection for worker objects while they wait for an operation to be performed on them. For example, a collection of Employee objects needs to have the IncreaseSalary method called (yeah, I know what you're thinking-that would be a pretty small collection). For one reason or another, you need to remove an Employee worker object from this factory (maybe the worker object had the SpendAllDayAtWorkSurfingTheWeb property set to True), so you would call the Remove method. This method just removes the worker object from this factory with no effect on the underlying data. This is the sack 'em approach.
You use the other method when you want to permanently delete an object from the underlying data source as well as from the factory. This involves calling the Delete method on the factory and is known as the whack 'em approach. Calling Delete on the factory will completely remove the worker object and mark its row in the database for deletion the next time a Persist is executed.
This is an important point worth repeating-if you delete an object from a factory, it is not automatically deleted from the data source. So if you want to be sure that your worker objects data is deleted promptly and permanently, make sure that you call the Persist method! Some of you might ask, "Well, why not call the Persist directly from within the Delete method?" You wouldn't do this because of performance. If you wanted to delete 1000 objects, say, you wouldn't want a database update operation to be called for each one-you would want it to be called only at the end when all objects have been logically deleted.
Dim ocDAL As New cDALOne important point to note here is that worker objects cannot commit suicide! Only the factory object has the power to delete or remove a worker object.
Public Sub Remove(i_vKey As Variant)As discussed earlier, sometimes it is more efficient to deal with the internal factory recordset directly rather than with the factory object. This is primarily true when dealing with distributed clients that do not have direct access to the worker objects themselves. In this case, the factory object exports this recordset through the Recordset property.
The Recordset property can also be used to regenerate a business object. Imagine a distributed client that has accessed an EmployeeDetails method on an action object and has received the corresponding recordset. The distributed client then shuts the action object down (because, as we will soon see, action objects are designed to be stateless). This recordset is then modified and sent back to the action object. The action object needs to perform some operations on the business objects that are currently represented by the recordset.
The action object can create an empty factory object and assign the recordset sent back from the client to this factory. Calling the Populate method will now result in a set of worker objects being regenerated from this recordset! Or, if the data has just been sent back to the database, the action object could call Persist without performing the Populate method at all, again maximizing performance when the client is modifying simple sets of data.
Take particular care when using the Recordset property with distributed clients, though. It's important to ensure that other clients don't modify the underlying business object after the business object has been serialized as a recordset. In such a case, you'll end up with two different recordsets-the recordset on the client and the recordset inside the business object. This situation can easily be avoided by ensuring that the action objects remain as stateless as possible. In practice, this means closing down the business object immediately after the recordset has been retrieved, thereby minimizing the chance of the business object changing while a copy of the recordset exists on the client.
Dim ocfEmployees As New cfEmployeesBe aware that the above code is not recommended, except when you need to return sets of data to distributed clients. Data manipulation that is performed on the same tier as the factory objects should always be done by direct manipulation of the worker objects.
Because we don't use explicit procedure parameters in the Populate method of the factory class, it can be useful to be able to determine what these parameters are. The read-only Parameters property returns a cParams object populated with the valid parameter names for this factory.
The Parameters property is useful when designing tools that interact with business objects-such as the business object browser that we'll look at later on-since the parameters for a factory can be determined at run time. This determination allows us to automatically instantiate factory objects.
Dim ocfEmployees As New cfEmployeesSo far, we've concentrated mainly on the factory objects; now it's time to examine in greater detail the construction of the worker objects.
Factory objects all have the same interface. Worker objects all have unique interfaces. The interface of our sample Employee worker object is shown below.
cWorker Employee Interface
Member |
Description |
ID |
Unique string identifier for the worker object |
Name |
Employee name |
Salary |
Employee gross salary |
Department |
Department the employee works in |
Create |
Creates a new worker object |
As we saw in the discussion of the factory object, creating a worker object involves passing the worker object a reference to the shared recordset and a reference to the factory's DAL object. This is what the worker does with these parameters:
Private oPicRecordset As cRecordSetWhy is this procedure so friendly? (That is, why is it declared as Friend and not as Public?) Well, remember that these worker objects are "Public Not Creatable" because we want them instantiated only by the factory object. Because the factory and workers always live in the same component, the Friend designation gives the factory object exclusive access to the Create method. Notice also that the worker objects store the row reference in the module-level variable lPiRowIndex.
In this design pattern, an ID is required for all worker objects. This ID, or string, is used to index the worker object into the factory collection. This ID could be manually determined by each individual factory, but I like having the ID as a property on the object-it makes automating identification of individual worker objects inside each factory a lot easier.
In most cases, the ID is the corresponding database ID, but what about when a worker object is created based on a table with a multiple field primary key? In this case, the ID would return a concatenated string of these fields, even though they would exist as explicit properties in their own right.
Here is the internal worker code for the ID property-the property responsible for setting and returning the worker object ID. Note that this code is identical for every other Property Let/Get pair in the worker object.
The most important point here is the PiSetAbsoluteRowPosition call. This call is required to point the worker object to the correct row in the shared recordset. The recordset current record at this point is undefined-it could be anywhere. The call to PiSetAbsoluteRowPosition is required to make sure that the worker object is retrieving the correct row from the recordset.
Private Sub PiSetAbsoluteRowPosition()Likewise, this call to PiSetAbsoluteRowPosition needs to happen in the Property Let. The PiSetPropertyValue procedure merely edits the appropriate row in the recordset.
Private Sub PiSetPropertyValue(i_sFieldName As String, _At the moment, all we've concentrated on are the properties of worker objects. What about methods?
An Employee worker object might have methods such as AssignNewProject. How do you implement these methods? Well, there are no special requirements here-implement the customer business methods as you see fit. Just remember that the data is in the shared recordset and that you should call PiSetAbsoluteRowPosition before you reference any internal data.
Factory objects returning workers is all well and good, but what happens when we want to create relationships between our business objects? For example, an Employee object might be related to the Roles object, which is the current assignment this employee has. In this case, the Employee worker object will return a reference to the Roles factory object. The Employee object will be responsible for creating the factory object and will supply any parameters required for its instantiation. This is great because it means we need only to supply parameters to the first factory object we create. Subsequent instantiations are managed by the worker objects themselves.
Dim ocDAL As New cDALHere the Roles property on the Employee worker object returns the ocfRoles factory object.
Public Property Get Roles() As cfRolesAccessing child factory objects this way is termed navigated instantiation, and you should bear in mind this important performance consideration. If I wanted to loop through each Employee and display the individual Roles for each Employee, one data access would retrieve all the employees via the DAL and another data access would retrieve each set of Roles per employee. If I had 1800 employees, there would be 1801 data access operations-one operation for the Employees and 1800 operations to obtain the Roles for each employee. This performance would be suboptimal.
In this case, it would be better to perform direct instantiation, which means you'd create the Roles for all employees in one call and then manually match the Roles to the appropriate employee. The Roles object would return the EmployeeID, which we would then use to key into the Employee factory object to obtain information about the Employee for this particular Roles object. The golden rule here is that navigated instantiation works well when the number of data access operations will be minimal; if you need performance, direct instantiation is the preferred method.
Dim ocfRoles As New cfRolesAn interesting scenario occurs when a worker object has two different properties that return the same type of factory object. For example, a worker could have a CurrentRoles property and a PreviousRoles property. The difference is that these properties supply different parameters to the underlying factory object Populate procedure.
It's useful to be able to query a worker object to determine what factory objects it supports as children. Therefore, a worker object contains the read-only property Factories, which enables the code that dynamically determines the child factory objects of a worker and can automatically instantiate them. This is useful for utilities that manipulate business objects.
The Factories property returns a cParams object containing the names of the properties that return factories and the names of those factory objects that they return. Visual Basic can then use the CallByName function to directly instantiate the factories of children objects, if required.
The Factories property is hidden on the interface of worker objects because it does not form part of the business interface; rather, it's normally used by utility programs to aid in dynamically navigating the object model.
Dim ocfEmployees As New cfEmployeesAfter you've created the hierarchy of business objects by having worker objects return factory objects, you can dynamically interrogate this object model and represent it visually. A business object browser is a tremendously powerful tool for programmers to view both the structure and content of the business objects, because it allows the user to drag and drop business objects in the same fashion as the Microsoft Access Relationships editor.
Creating business objects can be a tedious task. If the data source you're modeling has 200 major entities (easily done for even a medium-size departmental database), that's a lot of business objects you'll have to build. Considering that the factory interface is the same for each business object and that the majority of the properties are derived directly from data fields, much of this process can be automated.
A business object wizard works by analyzing a data entity and then constructing an appropriate factory and worker class. This is not a completely automated process, however! Some code, such as worker objects returning factories, must still be coded manually. Also, any business methods on the worker object obviously have to be coded by hand, but using a business object wizard will save you a lot of time.
TMS uses a business object wizard to develop factory-worker classes based on a SQL Server database. This wizard is written as an add-in for Visual Basic and increases productivity tremendously by creating business objects based on the Shared Recordset implementation. If you need simple business objects, though, you can use the Data Object wizard in Visual Basic 6.0. The mapping of relational database fields to object properties is often referred to as the business object impedance mismatch.
Keep it simple-avoid circular relationships like the plague. Sometimes this is unavoidable, so make sure you keep a close eye on destroying references, and anticipate that you might have to use explicit destructors on the factory objects to achieve proper teardown. Teardown is the process of manually ensuring that business objects are set to Nothing rather than relying on automatic class dereferencing in Visual Basic. In practice, this means you call an explicit Destroy method to force the release of any internal object references.
Don't just blindly re-create the database structure as an object model; "denormalization" is OK in objects. For example, if you had an Employee table with many associated lookup tables for Department Name, Project Title, and so forth, you should denormalize these into the Employee object; otherwise, you'll be forever looking up Department factory objects to retrieve the Department Name for the employee or the current Project Title. The DAL should be responsible for resolving these foreign keys into the actual values and then transposing them again when update is required. Typically, this is done by a stored procedure in a SQL relational database.
This doesn't mean you won't need a Department factory class, which is still required for retrieving and maintaining the Department data. I'm saying that instead of your Employee class returning the DepartmentID, you should denormalize it so that it returns the Department Name.
MsgBox ocfEmployee.DepartmentNameis better than
MsgBox ocfDepartments(ocfEmployee.DepartmentID).NameBut at the end of the day, the level of denormalization required is up to you to decide. There are no hard and fast rules about denormalization-just try to achieve a manageable, usable number of business objects.
So there's the basic architecture for factory-worker model business objects with a Shared Recordset implementation. I feel that this approach provides a good balance between object purity and performance. The objects also return enough information about themselves to enable some great utilities to be written that assist in the development environment when creating and using business objects.
Now we turn to where the rubber meets the road-the point where the client applications interface with the business objects, which is through action objects.
Action objects represent the sequence of actions that an application performs. Action objects are responsible for manipulating business objects and are the containers for procedural business logic. Remember that the distributed clients should only send and receive cRecordset or cParams objects. This is a thin-client approach. The client should be displaying data, letting the user interact with the data, and then it should be returning this data to the action object.
Action objects live in the logical business tier (with factory and worker objects). Therefore, access to these action objects from the clients should be as stateless as possible. This means that if action objects live on a separate physical tier (as in a true three-tier system), performance is maximized by minimizing cross-machine references.
The other important task of action objects is to define transaction scope; that is, when an action object is created, all subsequent operations on that action object will be in a transaction. Physically, action objects live in a Visual Basic out-of-process component or in DLLs hosted by MTS. Worker and factory objects are contained in an in-process component that also could be hosted in MTS.
The structure of action objects comes from the requirements of the application. The requirements of the application logic should be determined from an object-based analysis, preferably a UML usage scenario. This is what I refer to as the Golden Triangle.
Here's an example: Imagine the archetypal Human Resource system in any organization. One of the most basic business requirements for an HR system is that it must be able to retrieve and update employee information and the employee's associated activities. These activities are a combination of the employee's current roles and projects and are known as the usage scenario.
The user interface needed to meet this requirement could be a Visual Basic form, but it could also be an Active Server Page-based Web page.
We can imagine that a Visual Basic form that implements this usage scenario would present two major pieces of information: EmployeeDetails and Activities. We can now determine the interface of the required action object.
GetEmployeeDetails
UpdateEmployeeDetails
GetCurrentActivities
UpdateCurrentActivities
Notice that these attributes of the action object are all stateless. That is, when you retrieve a recordset from GetEmployeeDetails, you then shut down the action object immediately, thereby minimizing the cross-layer communication cost. Users can then modify the resulting recordset and, when they are ready to send it back for updating, you create the action object again and call the UpdateEmployeeDetails method. The action object does not need to be held open while the recordset is being modified. Let's look at these calls in more detail:
Public Function GetEmployeeDetails(Optional i_sID As String) As cRecordsetLikewise, the UpdateEmployeeDetail call looks like this:
Public Sub UpdateEmployeeDetail(i_ocRecordset As cRecordset)The GetCurrentActivities call has a bit more work to do. It must create a new recordset because the usage scenario requires that both Roles and Projects come back as one set of data. So the GetCurrentActivities call would create a recordset with three fields-ActivityID, ActivityValue, and ActivityType (Roles or Projects) and then populate this recordset from the Roles business object and the Projects business object. This recordset would then be returned to the client.
The UpdateCurrentActivities call would have to do the reverse-unpack the recordset and then apply updates to the Roles and Projects table.
So if action objects are responsible for transactions, how do they maintain transactions? When an action object is initiated, it instantiates a cDAL object and begins a transaction. This cDAL object is passed to all business objects that the action object creates so that every business object in this action object has the same transaction.
Just before the action object is destroyed, it checks a module-level variable (bPiTransactionOK) in the class Terminate event to see whether the transaction should be committed. This module-level variable can be set by any of the procedures within the action object. Normally, if a transaction has to be rolled back, an exception is raised to the client and bPiTransactionOK is set to False so that the user can be informed that something very bad has happened. Checking this module-level variable in the Terminate event ensures that the action object is responsible for protecting the transaction, not the client.
Private bPiTransactionOK As BooleanSo now we've covered the role and design of action objects. In summary, a good analogy is that of a relational SQL database: factory-worker objects are represented by the tables and records, and action objects are represented by the stored procedures. The lifetime of the action object controls the lifetime of the implicit transaction contained within.
Action objects should be accessed in a stateless fashion-get what you want and then get the hell out of there! Stateless fashion is enabled by the supporting of serialization by cRecordset and cParams, which ensures that your applications can achieve good distributed performance.
Now for the final part of the puzzle. What is the best way for our client applications to use action objects? Basically, the client should open the appropriate action object, retrieve the required recordset, and then close down that action object immediately. When the data is ready to be returned for update, the action object will be instantiated again, the relevant Update method will be called, and then the action object will be closed down again. Here is an example of a client calling an action object to retrieve the list of current employees:
Dim ocaEmployeeMaintenance As caEmployeeMaintenanceThis code could be in a Visual Basic form, in an Active Server Page, or even in an ActiveX document-the important thing is that the client reference to the action object is as quick as possible. The recordset maintains the data locally until the time comes to the send the data back to the action object.
So there you have it, a workable approach to implementing high-performance distributed objects. With the right amount of planning and design, an awareness of distributed application issues, and the power of Visual Basic, building powerful, scalable software solutions is well within your reach.
So get out there and code those business objects!
|