![]() |
![]() |
![]()
| ![]() |
In Development Automation / COM
An Automation Server Object Model Design and Implementation
This article describes how to design an Automation server, and build it with Delphi. It also describes how to test the server by implementing it as a plug-in, and using script to put it though its paces. Let's begin with a review of the technology involved, and terminology used to describe it.
An application is an Automation server when it allows an external application (or scripting tool) to control it programmatically, via a COM interface. The Microsoft Office applications (Word, Excel, etc.) are good examples of Automation servers. For example, many applications use Word's mail merge capabilities from external applications to create letters, reports, and forms populated with data from the managing (client) application. Excel is similarly used by many enterprise applications as a calculation engine, e.g. a pre-defined spreadsheet is created and data is transferred to Excel by the client application (known as the Automation client) to perform complex calculations. Other examples include network charting applications that employ general purpose tools such as Visio or Micrografx Charter to diagram a network.
Scripting is a feature that allows "power users" to automate tasks in the application by writing a script using a language integrated with the application. Microsoft offers VBA (Visual Basic for Applications) as the scripting language for its Office suite. VBA is also now widely available in non-Microsoft products such as AutoCAD and Visio. Microsoft Internet Explorer allows you to use VBScript or JavaScript, Netscape Navigator offers JavaScript, and other tools offer more obscure scripting languages.
Plug-ins are externally compiled objects that "plug in" the application and add functionality via new menu commands, objects that can be manipulated, etc. Internet Explorer uses plug-ins to add new functionality. The Alexa Internet Explorer plug-in is a good example of a third-party tool that enhances the use of a mainstream application via a plug-in. Although Delphi doesn't use COM to implement plug-ins, the Open Tools API is a framework that allows you to add new objects (project and form experts) and plug-ins (add-ins and Help menu experts) to Delphi.
Why COM? There are some advantages to using COM as a way to create an application extension framework. COM defines a standard method to offer Automation. Any application that knows how to talk to an Automation server will be able to automate your application via COM interfaces.
Excellent scripting engines are available free from Microsoft in the form of JavaScript (JScript) and VBScript. These scripting engines require an interface into your application via COM interfaces and standard Automation objects. Many users are familiar with Visual Basic, and VBScript is an acceptable substitute. Many Internet warriors are familiar with JavaScript, and Microsoft's JScript is a well-debugged version of this Java look-alike scripting engine. Using ActiveScript, you can provide VBScript, JScript, or both. In fact, if you write the code to support one of these languages, you'll need to make minimal changes to support the other. Other scripting engines (e.g. Perl) are available that will also plug into an ActiveScript-enabled application.
Unlike DLLs that require extensive documentation, COM-based Automation objects are "self documenting" via type libraries. Though you should always provide documentation for an application extension framework, you won't need to create interface modules for every language you want to support, and you won't be limited to tools that can call DLLs. (Note: Delphi's Open Tools API was created with Delphi 1 and enhanced since that time.)
Although we often hear that Delphi uses Delphi to extend itself, Delphi allows experts and add-ins created with other tools to be installed. The Open Tools API, however, must provide special functions to allow C/C++ functions to call and manipulate Delphi functions, objects, and data structures. Even though COM plug-ins have more overhead than Delphi plug-ins, I have the feeling that Delphi would benefit from the cleaner approach to application extension via COM. (The Delphi debugger is implemented as a COM object of some sort, so Inprise developers are also aware of the benefits of COM for application extensibility.)
Planning the Extension Framework The most important issue to understand about providing an application extension framework is the functionality of your application you want to expose - not the things the plug-ins, scripts, or external applications will have to do. COM is an object-based architecture. Your application should expose a set of objects that can be manipulated by the "extending" agents. The term "object model" seems to be the standard way to refer to an application-specific extension API. Examples include COM (Component Object Model) and Internet Explorer's (and the W3C) DOM (Document Object Model), which is used to describe the objects that make an HTML page (and with MSIE 5 XML documents). Even IBM used SOM (System Object Model) in the past. (When the sample application that provides template-based authoring had to have an extension framework, I named it Authoring Templates Object Model [ATOM] with the purpose of writing a white paper in the future titled "Splitting the ATOM.")
The root object of the Word object model is named Application, an object that provides access to Word and provides sets of collections (arrays) for sub-objects used in the application. In Word's case, the Application object provides access to the documents edited by the application, and a Document object that can be obtained from the application provides access to paragraphs, images, links, and other collections. Similarly, Excel's Application object provides access to worksheet objects that in turn provides access to cells.
When you design your object model, try to understand the data structures and objects with which your application is built. In the sample application, the code is nothing more than a visual way to represent a hierarchical database of objects. It made sense to provide access to this database via a single object obtained from the Application object. This object is named Project in the sample application; it provides access to all the nodes in the hierarchy. If your application manages relational databases, a good idea would be to expose common tables as objects via the root of the hierarchy. If your application is used for the creation of some documents, it would make sense to expose "document objects" (these documents don't need to be called documents; they can be worksheets, charts, images, etc.) as objects.
Once you understand what objects and data structures you want to expose (these provide the "guts" of your application), it's time to think of the way you want extensions to be able to manipulate the user interface side of the application. This usually means ways to expose the menubar, commandbars, and other elements of the application. I will provide sample code that exposes and manipulates the menubar, a common and popular way to offer scripting and extensibility.
Implementing an Object Model If you've created your application using object-oriented techniques, it's easy to encapsulate your application's objects as Automation objects exposed in an object model. Assuming you use the convention of naming the object model root Application, it would be a good idea to hold a field in your application's main form that points to an instance of the Application Automation object's interface. This object can be instantiated in the OnCreate event of the main form. In the sample application, I created an Automation object named eAuthorApp (using Delphi's Automation object expert on the ActiveX page of the New Items dialog box). The main form holds a field using the following declaration in its private section:
FeAuthorApplication : IeAuthorApp;
and exposes it in the public section:
property eAuthorApplication: IeAuthorApp read FeAuthorApplication;
Finally, the OnFormCreate event handler includes the following code to instantiate the object:
try FeAuthorApplication := TeAuthorApplication.Create; except ShowMessage( 'eAuthor.eAuthorApplication could not be created'); end;
Providing access to the other objects in the object model is done via properties and methods of the Automation object. Because the sample application's database objects descend from a common ancestor, I created a virtual CreateAutomationObject method in the ancestor and created a default Automation object that applies to every object in the database. Some of the objects that descend from this common ancestor override CreateAutomationObject and return a pointer to an Automation object that provides more functionality than the default object. Your design will have to be based on the actual design of your objects and data structures.
My application's Application object provides an entry into the database object model by exposing a Project property defined of type IDispatch. (Remember that properties and methods defined in Automation objects must be defined using the Type Library editor and implemented in the implementation unit). Because the sample application has only one entry point into the database object model and because this entry point is commonly used, I want to cache it, to see that my implementation of TeAuthorApplication includes a private pointer to IDispatch:
ProjectObject : IDispatch;
The Get_Project method that implements the Project property access (it's a read-only property) is implemented as follows:
function TeAuthorApplication.Get_Project: IDispatch; begin if (not Assigned(ProjectObject)) then begin ProjectObject := eAuthorSite.HyperTextProject.CreateAutomationObject; IUnknown(ProjectObject)._AddRef; end; Result := ProjectObject; end;
As you can see, the main form holds a global HyperTextProject Delphi object and calls the CreateAutomationObject method to retrieve a pointer to its IDispatch interface. Notice the need to call _AddRef to ensure the object's reference count will keep it cached in memory. Obviously, I need to free it in the destructor:
destructor TeAuthorApplication.Destroy; begin if (Assigned(ProjectObject)) then IUnknown(ProjectObject)._Release; end;
Now let's examine the CreateAutomationObject for my object's ancestor:
function TObjectWithFields.CreateAutomationObject; var NewObj: TEditableObject; begin NewObj := TEditableObject.Create; NewObj.WrappedObject := Self; Result := NewObj; end;
Remember, TObjectWithFields is the ancestor object to all the objects in the hierarchical database managed by the sample application. EditableObject is the Automation object created to represent this object from the COM side of things.
The interesting thing to notice is that TEditableObject's implementation has a WrappedObject property that holds a TObjectWithFields. Using this technique, a COM object can always be created from a Delphi object calling its CreateAutomationObject, and the Delphi object (which is always in memory) can be referred from the COM object by referring to its WrappedObject property.
Notice that CreateAutomationObject doesn't cache the object in memory using the AddRef/Release method of reference counting. Because the Delphi objects are always "alive," we can always use them to create the COM objects. The COM objects are created and stay alive only for the duration they are needed by the external application/script/plug-in.
Because every object in the sample application descends from TObjectWithFields, I wanted every COM object that represents a descendent of this object to retain EditableObject's methods and properties. This is surprisingly easy to do when you create a new Automation object (e.g. AutoProject represents the THyperTextProject object referred to previously). I inherited the interface defined for this object in the type library from IEditableObject and inherited the implementation object (TAutoProject in this example) from TEditableObject (see Figure 1).
Debugging Your Object Model Before you continue with the design of a plug-in framework, you must ensure the object model you designed works as expected and provides access to functionality needed by extension "clients." When I was ready to test and debug my COM object model, I considered writing an Automation controller application with Delphi to test the server. Although this approach would work, I decided to test my object model by developing the scripting code and testing the object model from within the sample application. This made the concept of setting debug breakpoints and writing scripting code to test new objects easy and compile-free.
For my purposes, I added a Scripts menu option to the main menu of the main form. This option opens a script dialog box that includes two TMemo components: one used as an editor, and the other as a console to test the results of the script.
In the TActiveScriptSite implementation of my ActiveScript unit, I exposed the Application and Project objects using the code shown in Figure 2.
function TActiveScriptSite.GetItemInfo( ItemName: WideString; dwReturnMask: DWord out UnkItem: IUnknown; out TypeInfo: ITypeInfo): HResult; var ObjDispatch : IDispatch; begin { Does the engine want the Automation object's IUnknown pointer? } if (dwReturnMask = SCRIPTINFO_IUNKNOWN) and (ItemName = 'Application') then UnkItem := eAuthorSite.eAuthorApplication;
if (dwReturnMask = SCRIPTINFO_IUNKNOWN) and (ItemName = 'Project') then UnkItem := eAuthorSite.eAuthorApplication.Project;
{ Does the engine want the Automation object's type information? } if (dwReturnMask = SCRIPTINFO_ITYPEINFO) and (ItemName = 'Application') then begin ObjDispatch := eAuthorSite.eAuthorApplication; { Get a handle to our Automation object's type library. } ObjDispatch.GetTypeInfo(0,0,TypeInfo); end;
if (dwReturnMask = SCRIPTINFO_ITYPEINFO) and (ItemName = 'Project') then begin ObjDispatch := eAuthorSite.eAuthorApplication.Project; ObjDispatch.GetTypeInfo(0,0,TypeInfo); end;
Result := S_OK; end; Figure 2: Exposing the Application and Project objects.
Finally, I connected the console to the Application Automation object by adding Write, WriteLine, and ClearConsole methods. The Write implementation follows:
procedure TeAuthorApplication.Write( const AText: WideString); begin if (Assigned(eAuthorSite.Console)) then eAuthorSite.Console[eAuthorSite.Console.Count - 1] := eAuthorSite.Console[eAuthorSite.Console.Count - 1] + AText; end;
Console is a global TStrings property of the main form. When the Application object needs to write a line to the console, it simply adds it to the console.
Providing Menubar Access via COM Interfaces Now that we have an object model in place, it's time to think of ways to provide access to the user interface of the application. A common feature to expose is the menubar. The Application (COM) object of the example program exposes two functions: MenuExists, which determines if a menu path (e.g. File | Save) exists; and Menu, which returns an Automation object that represents a specific menu item based on a menu path provided.
I wrote the FindMenu function (see Figure 3) to find a menu item based on its path. The only interesting part of this function is stripping ampersand characters (used to determine menu hot-keys) from menu item captions.
function TeAuthorApplication.FindMenu;
function FixCaption(ACaption: string): string; var g : Integer; begin Result := ACaption; g := pos('&', Result); while (g > 0) do begin Result := copy(Result, 1, g - 1) + copy(Result, g + 1, length(Result) - g); g := pos('&', Result); end; end;
function FindByLevel(PartialPath: string; MenuRoot: TMenuItem): TMenuItem; var FirstLevel, RestOfPath : string; i, p : Integer; begin Result := nil; p := pos('|', PartialPath); if (p > 0) then begin FirstLevel := Copy(PartialPath, 1, p - 1); RestOfPath := Copy(PartialPath, p + 1, length(PartialPath) - p); end else begin FirstLevel := PartialPath; RestOfPath := ''; end;
i := 0; while (i < MenuRoot.Count) do begin if (FixCaption( MenuRoot.Items[i].Caption) = FirstLevel) then Result := MenuRoot.Items[i]; inc(i); end; if (Assigned(Result) and (RestOfPath <> '')) then Result := FindByLevel(RestOfPath, Result); end; { FindByLevel }
begin Result := FindByLevel(MenuPath, eAuthorSite.Menu.Items); end; { TeAuthorApplication.FindMenu } Figure 3: The FindMenu function to find a menu item based on its path.
I defined an Automation object named AutoMenuItem that has a WrappedMenu property (like the WrappedObject property of EditableObject objects discussed earlier). This COM object wraps TMenuItem and exposes its properties and methods (like Checked, Enabled, Caption, Hint, ShortCut, Execute, SubItem, etc.). In addition, AutoMenuItem has a SetHandler method required for plug-ins. We'll discuss this function later.
The sample application includes code that demonstrates how to expose the menu from an Application object and includes an implementation of a Menu Automation object. Once this mechanism is in place, it's easy to activate menu commands from a script or an automating client using code such as this JavaScript example:
Application.Menu("File|Save").Execute();
Menu access becomes more important once we need to discuss the issue of plug-ins.
Setting Plug-in Rules A plug-in is a separately compiled code module that can be "plugged" into the application at run time. Typically, the plug-in adds entries to the application's menubar. When the user chooses a menu item that activates the plug-in, the plug-in needs to take over execution. It will then usually converse with the application via the application's object model.
In the sample application, I defined two interfaces that must be implemented by a plug-in:
Figure 4 shows a sample plug-in that adds an entry to the end of the Help menu. When this menu item is selected it displays a dialog box. TAddInObject is defined as an Automation object and it implements the two interfaces mentioned above.
type TAddInObject = class(TAutoObject, IAddInObject, IeAuthorApplicationAddIn, IeAuthorMenuHandler) private eAuthorApp : IeAuthorApp; public // IeAuthorApplicationAddIn procedure RegisterMenus; safecall; procedure SetApplication( const App: IeAuthorApp); safecall; // IeAuthorMenuHandler procedure Execute(Tag: Integer); safecall; end; Figure 4: TAddInObject as Automation object.
SetApplication stores the object model root in the eAuthorApp variable:
procedure TAddInObject.SetApplication; begin eAuthorApp := App; end;
RegisterMenus uses the menu facilities of the Application object to add a new menu entry. Notice the use of the AutoMenuItem's SetHandler method (see Figure 5). We'll discuss this method in the next section.
procedure TAddInObject.RegisterMenus; var NewMenu : OleVariant; TheMenu : IAutoMenuItem; begin TheMenu := eAuthorApp.Menu('Help') as IAutoMenuItem; NewMenu := TheMenu.AddSubItem; NewMenu.Tag := 3; NewMenu.Caption := 'Add In!'; NewMenu.SetHandler(Self as IeAuthorMenuHandler); end; Figure 5: Adding a new menu entry.
Finally, Execute is simple:
procedure TAddInObject.Execute; begin ShowMessage('Add In Tag ' + intToStr(Tag)); end;
Notice the use of the Tag property to store information about a menu item. This allows a single plug-in to register multiple menu items, and differentiate between them in the Execute method.
Adding Plug-in Support To have the application recognize plug-ins, two issues need to be resolved: a method to register plug-ins with the application, and a way for the application to know what menu items are associated with which plug-ins and how to call them.
The first issue is easy to resolve. I prefer to use the Windows registry to store a list of references to all the plug-ins recognized by the application. The application also provides a plug-in manager dialog box, which offers a way for users to specify the names of all the COM Automation objects that the application should load as plug-ins when it starts.
Handling menu-to-plug-in association is a bit more complicated, and includes the use if a new menu item class (I named it TAutomatedMenu) derived from TMenuItem. The definition of this new class is remarkably simple:
type TAutomatedMenu = class(TMenuItem) private FHandler : IeAuthorMenuHandler; procedure HandleClick(Sender: TObject); public constructor Create(AOwner: TComponent); override; property Handler: IeAuthorMenuHandler read FHandler write FHandler; end;
As you can see, an automated menu includes a reference to an IeAuthorMenuHandler interface, and a pre-defined click event handler named HandleClick. As you may recall from the previous section, the AutoMenuItem wrapper around the menu item object has a SetHandler method that's used by a plug-in that creates a new menu item. As you can imagine, SetHandler sets the Handler property of an automated menu item to point to itself.
With this business handled, the rest of the class implementation is trivial. The Create constructor sets the OnClick event to the HandleClick event handler, which in turn uses the handler's Execute method:
constructor TAutomatedMenu.Create; begin inherited Create(AOwner); OnClick := HandleClick; end;
procedure TAutomatedMenu.HandleClick; begin if (Assigned(FHandler)) then FHandler.Execute(Tag); end;
Sample Application To demonstrate application extension via COM plug-ins, I wrote a simple text editor application (see Figure 6). The application uses a simple tabbed interface that allows you to edit multiple text documents. Think of it as a poor man's Notepad with extensibility. I decided to implement only a subset of the functionality one would normally implement in such an application, which is the minimum I needed to make the example useful.
The code that is available as part of this article includes the project editproj.dpr, the Delphi source for the sample application (available for download; see end of article for details). MainForm.pas is the definition and the code of the main form. PIMgr.pas is the code for the plug-in manager dialog box, which is used to "register" COM plug-ins with the sample application.
The application's object model consists of an Application object that provides access to the menu bar, and to the document being edited. A more complete example will allow access to documents not currently edited by the user and the ability to switch between documents, but for the purpose of this example, this is all I needed.
The COM Application object (implemented in AutoApp.pas) provides access to COM objects implemented in the AutoMenu.pas file, and to the COM document object implemented in the AutoDoc.pas. The Menu object allows you to set menu attributes (caption, short-cut, visibility, and - for newly created menus - code handler), and allows you to create new menu entries that will be inserted into the application's menu bar. The document object provides access to the selected text and cursor location, and allows us to insert new text lines into the document.
In the application's OnShow event the list of "registered" plug-ins is read from the registry, and these plug-ins are activated. The plug-ins in turn create new menu items that are added to the application's menubar. The user can now select the new menu entries and activate them.
The type library of the sample project also defines two interfaces, IEditMenuHandler and IEditPlugin, that must be implemented by plug-in COM objects. The application source also includes XtdMenu.pas that implements a TAutomatedMenu menu item descendant that can activate a plug-in via the IeditMenuHandler interface from a menu.
I wrote two simple plug-ins to show the capabilities of what we can do with COM objects compiled separately from our application. The first, defined in HelloWorld.dpr, defines an Automation object named HelloWorld.Plugin that implements the required interfaces (see HWPI.pas), and adds a Hello World menu item to the Help menu. As can be expected, the plug-in displays a dialog box with the universal message displayed for programmer education worldwide.
A more sophisticated plug-in is implemented in ProcRemark.dpr, which takes advantage of the application's object model and adds two menu items to the Edit menu. The Remark procedure adds a three-line remark with the selected text between lines of asterisks, which are located above the line of the currently selected text (see Figure 7). The Procedure Body menu item adds a begin..end procedure body beneath the selected text in the editor. A long time ago I wrote two functions like these in Brief's extension language (Brief being the text editor created by Underware Software and being sold for a while by Borland). It's amazing how much more productive I became when my Pascal sources were always prefixed and suffixed with the same style. The code for the function's implementation is provided in PRPI.pas. It's trivial, and, as such, left unexplained in this article.
Conclusion Today's applications require extensibility and programmability. The Microsoft COM foundation is an excellent way to provide scripting, Automation, and plug-in capabilities from your application. Creating an extensible application requires a well-thought-out object model that maps the application's objects and concepts as COM objects that can be accessed from extension modules. Plug-ins can be created by adding support for minimal extension interfaces within the applications. COM plug-ins can be created in different development tools, and doesn't require recompiling the application.
The projects referenced in this article are available for download.
Ron Loewy is a software developer for HyperAct, Inc. He is the lead developer of eAuthor Help, HyperAct's HTML Help authoring tool. For more information about HyperAct and eAuthor Help, contact HyperAct at (515) 987-2910 or visit http://www.hyperact.com/.
The code provided with the application includes the source and the compiled versions of the application and the plug-ins. You'll need to perform the following steps to use the plug-ins with the application:
|
![]() |
|