It’s quite common for Blue Prism developers to use MS Word VBO or similar to interact with Word instances in workflows. However, a problem arises when we want to extend the object with some custom functionality that utilizes Word COM object. The issue is that MS Word VBO doesn’t expose the Word instance and we can only use the handle. Due to that, Blue Prism developers often extend this object and add actions directly in it. This breaks many principles and is very nasty. Let’s see why and how to fix that.
Table of Contents
Why extending MS Word VBO is not the best idea
Nothing stops us from extending the object. However, this is not ideal because:
- IT administrators become scared of updating the object since it is hard to migrate the newly added actions
- We stop understanding what’s in the object. Apart from new actions, someone may have actually modified existing ones and broken the object. We can’t trust it anymore
- This breaks Open Closed principle – objects should be open for extension but closed for modification
- MS Word VBO is written in VB.NET. If we want to use C#, we can’t do that as we can’t mix languages inside an object
- It’s hard to share functionality between projects. Ideally, we’d like to be able to share our actions between projects and separate them from actions that we don’t want to share. This is much harder if we put these actions directly on the object
- Actions are much further from the business context. If we need a specific action for one of our processes, then it’s probably better to keep the action with other actions in the process-specific object
There are probably many more reasons to avoid that. Generally, apart from technical limitations (like being unable to use C#), this is just a matter of best practices and good design.
Why it’s hard to put actions outside of the object
First and foremost, Word instance is a COM object. Without going into details, it’s an object that is globally visible in the operating system and exposes many APIs. You can call this object from Blue Prism, Visual Basic for Applications, PowerShell, Visual Basic Script, C#, Java, and much more.
However, actions in Blue Prism objects cannot return any type they want. They can deal with numbers, texts, collections, and some other things. However, they can’t return an arbitrary object type. This means that the action cannot return a Word instance. To work that around, MS Word Object returns a handle
which is an integer (a number). So when we call MS Word.Create Instance
, then the method returns a number that identifies the word instance held behind the scenes. If we then call a method like MS Word.Open
, the MS Word Object must find the actual Word instance based on the number.
Technically, it works in the following way. When creating an instance, this is the code that is executed:
1 2 3 4 5 6 7 |
Dim word as Object = CreateObject("Word.Application") ' Create a GUID with which we can kill the instance later ' if we have to play hardball to get rid of it. word.Caption = System.Guid.NewGuid().ToString().ToUpper() handle = GetHandle(word) |
We create an instance of the Word object, and then we get the handle for it. This is GetHandle
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
' Gets the handle for a given instance ' ' If the instance is not yet held, then it is added to the ' map and a handle is assigned to it. It is also set as the ' 'current' instance, accessed with a handle of zero in the ' below methods. ' ' Either way, the handle which identifies the instance is returned ' ' @param Instance The instance for which a handle is required ' ' @return The handle of the instance Protected Function GetHandle(Instance As Object) As Integer If Instance Is Nothing Then Throw New ArgumentNullException("Tried to add an empty instance") End If ' Check if we already have this instance - if so, return it. If InstanceMap.ContainsKey(Instance) Then CurrentInstance = Instance Return InstanceMap(Instance) End If Dim key as Integer For key = 1 to Integer.MaxValue If Not HandleMap.ContainsKey(key) HandleMap.Add(key, Instance) InstanceMap.Add(Instance, key) Me.CurrentInstance = Instance Return key End If Next key Return 0 End Function |
We first check if the Instance
is empty. It is not, as we just created it. We then check if the instance is already in the InstanceMap
collection that holds all the Word instances we already created. At this point, it is not there. In that case, we simply iterate over all numbers from 1 going up, and then we find the first unused number. We add the instance to the HandleMap
with key equal to 1, and then we return 1 as the handle
that the user will use later on.
Let’s now say that you call Open
. This is what it does behind the scenes:
1 2 |
' Just ensure that the handle references a valid instance HandleMissing = (GetInstance(handle) is Nothing) |
This calls GetInstance
which looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
' Gets the instance corresponding to the given handle, setting ' the instance as the 'current' instance for future calls ' ' A value of 0 will provide the 'current' instance, which ' is set each time an instance is added or accessed. ' ' This will return Nothing if the given handle does not ' correspond to a registered instance, or if the current ' instance was closed and the reference has not been updated. ' ' @param Handle The handle representing the instance required, ' or zero to get the 'current' instance. Protected Function GetInstance(Handle As Integer) As Object Dim Instance As Object = Nothing If Handle = 0 Then If CurrentInstance Is Nothing Then ' Special case - getting the current instance when the ' instance is not set, try and get a current open instance. ' If none there, create a new one and assign a handle as if ' CreateInstance() had been called ' Try ' Instance = GetObject(,"Word.Application") ' Catch ex as Exception ' Not running ' Instance = Nothing ' End Try ' If Instance Is Nothing Then Create_Instance(Handle) ' Instance = CreateObject("Word.Application") ' Force the instance into the maps. ' GetHandle(Instance) ' CurrentInstance should now be set. ' If it's not, we have far bigger problems ' End If End If Return CurrentInstance End If If Not HandleMap.ContainsKey(Handle) Throw New ArgumentException("A valid attachment to the application cannot been detected. Please check that Blue Prism is attached to an instance of the application.") End If Instance = HandleMap(Handle) If Not Instance Is Nothing Then CurrentInstance = Instance End If Return Instance End Function |
We just check if the handle is in the HandleMap
and then store it in the CurrentInstance
. So this is how we get the Word instance back from the integer passed by the user.
To use the Word instance in some other object, we would need to access the Word instance. However, we can’t just return it from the action as Blue Prism doesn’t support returning any type. We could also try to get access to HandleMap
and then extract the instance. This is doable but far from straightforward. However, we can use some clever tricks to get access.
One of possible workarounds – truly global variable in Blue Prism
The idea is to create a global dictionary for sharing any data between any objects in Blue Prism process. The solution will work like this:
- We create a code block that creates a globally-accessible dictionary storing string-object pairs
- We add one action to MS Word VBO to put the Word COM instance in the dictionary based on the handle
- In some other object (like our custom MS Word Object), we extract the com instance from the globally-accessible dictionary and use it accordingly
Let’s see that in action.
First, let’s create an object CustomWordObject
.
We need these two external references added:
1 |
System.Windows.Forms.dll |
and
1 |
C:\Program Files (x86)\Microsoft Visual Studio\Shared\Visual Studio Tools for Office\PIA\Office15\Microsoft.Office.Interop.Word.dll |
You may need to adjust the path to the Word dll based on your Word version.
Now, let’s create an action named CreateGlobalDictionary
with the following content:
1 2 3 4 5 6 7 8 9 10 11 12 |
<process name="__selection__CustomWordObject" type="object" runmode="Exclusive"><stage stageid="58774a62-94b0-4409-b265-8a5bd53ef49b" name="Start" type="Start"><subsheetid>16965f9d-eb5e-44bc-bb24-fb2732756063</subsheetid><loginhibit /><display x="15" y="-105" /><onsuccess>1e2e0509-5b3b-4b0b-8d20-dcc5b5e91743</onsuccess></stage><stage stageid="ced18beb-abce-4eac-92d4-e627fa99a391" name="End" type="End"><subsheetid>16965f9d-eb5e-44bc-bb24-fb2732756063</subsheetid><loginhibit /><display x="15" y="90" /></stage><stage stageid="1e2e0509-5b3b-4b0b-8d20-dcc5b5e91743" name="Create global dictionary" type="Code"><subsheetid>16965f9d-eb5e-44bc-bb24-fb2732756063</subsheetid><loginhibit /><display x="15" y="-15" w="90" h="30" /><onsuccess>ced18beb-abce-4eac-92d4-e627fa99a391</onsuccess><code><![CDATA[try{ var name = new System.Reflection.AssemblyName("GlobalAssemblyForSharingData"); var assemblyBuilder = System.Reflection.Emit.AssemblyBuilder.DefineDynamicAssembly(name, System.Reflection.Emit.AssemblyBuilderAccess.Run); var moduleBuilder = assemblyBuilder.DefineDynamicModule(name.Name ?? "GlobalAssemblyForSharingDataModule"); var typeBuilder = moduleBuilder.DefineType("GlobalType", System.Reflection.TypeAttributes.Public); var fieldBuilder = typeBuilder.DefineField("GlobalDictionary", typeof(System.Collections.Generic.Dictionary<string, object>), System.Reflection.FieldAttributes.Public | System.Reflection.FieldAttributes.Static); Type t = typeBuilder.CreateType(); System.Reflection.FieldInfo fieldInfo = t.GetField("GlobalDictionary"); fieldInfo.SetValue(null, new System.Collections.Generic.Dictionary<string, object>()); }catch(Exception e){ System.Windows.Forms.MessageBox.Show(e.ToString()); }]]></code></stage></process> |
Specifically, there is a code block that does the following:
1 2 3 4 5 6 7 8 9 10 11 12 |
try{ var name = new System.Reflection.AssemblyName("GlobalAssemblyForSharingData"); var assemblyBuilder = System.Reflection.Emit.AssemblyBuilder.DefineDynamicAssembly(name, System.Reflection.Emit.AssemblyBuilderAccess.Run); var moduleBuilder = assemblyBuilder.DefineDynamicModule(name.Name ?? "GlobalAssemblyForSharingDataModule"); var typeBuilder = moduleBuilder.DefineType("GlobalType", System.Reflection.TypeAttributes.Public); var fieldBuilder = typeBuilder.DefineField("GlobalDictionary", typeof(System.Collections.Generic.Dictionary<string, object>), System.Reflection.FieldAttributes.Public | System.Reflection.FieldAttributes.Static); Type t = typeBuilder.CreateType(); System.Reflection.FieldInfo fieldInfo = t.GetField("GlobalDictionary"); fieldInfo.SetValue(null, new System.Collections.Generic.Dictionary<string, object>()); }catch(Exception e){ System.Windows.Forms.MessageBox.Show(e.ToString()); } |
We use some .NET magic to dynamically create an assembly named GlobalAssemblyForSharingData
, add a type named GlobalType
to it, add a global field named GlobalDictionary
, and initialize it accordingly.
Next, let’s add the action to MS Word VBO named ExportWord
with the following content:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<process name="__selection__MS Word" type="object" runmode="Background"><stage stageid="f44e379f-73b9-4e3a-8f3a-304011753dbc" name="Start" type="Start"><subsheetid>5600ecd3-0257-4793-8c9b-58fba32fc417</subsheetid><loginhibit /><display x="15" y="-105" /><inputs><input type="number" name="handle" stage="handle" /></inputs><onsuccess>064c4864-b486-4ff5-9e44-0d9b0e11c003</onsuccess></stage><stage stageid="2ef37518-b895-403e-93ee-76b9743c2cf0" name="End" type="End"><subsheetid>5600ecd3-0257-4793-8c9b-58fba32fc417</subsheetid><loginhibit /><display x="15" y="90" /><outputs><output type="text" name="word" stage="word" /></outputs></stage><stage stageid="064c4864-b486-4ff5-9e44-0d9b0e11c003" name="Export Word" type="Code"><subsheetid>5600ecd3-0257-4793-8c9b-58fba32fc417</subsheetid><loginhibit /><display x="15" y="-45" /><inputs><input type="number" name="handle" expr="[handle]" /></inputs><outputs><output type="text" name="word" stage="word" /></outputs><onsuccess>2ef37518-b895-403e-93ee-76b9743c2cf0</onsuccess><code><![CDATA[Dim identifier = Guid.NewGuid().ToString() Dim globalDictionary As System.Collections.Generic.Dictionary(Of String, Object) = Nothing For Each assembly In System.AppDomain.CurrentDomain.GetAssemblies() Try For Each type In assembly.GetTypes() If type.Name = "GlobalType" Then globalDictionary = CType(type.GetField("GlobalDictionary").GetValue(Nothing), System.Collections.Generic.Dictionary(Of String, Object)) End If Next Catch e As Exception End Try Next globalDictionary(identifier) = GetInstance(handle) word = identifier]]></code></stage><stage stageid="e0455cc2-086d-4c6a-a222-6d18dbe4d221" name="handle" type="Data"><subsheetid>5600ecd3-0257-4793-8c9b-58fba32fc417</subsheetid><display x="90" y="-105" /><datatype>number</datatype><initialvalue /><private /><alwaysinit /></stage><stage stageid="dfd065c2-23f1-4821-a3a9-109a62bbac87" name="word" type="Data"><subsheetid>5600ecd3-0257-4793-8c9b-58fba32fc417</subsheetid><display x="90" y="-45" /><datatype>text</datatype><initialvalue /><private /><alwaysinit /></stage></process> |
Let’s see the code in detail:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
Dim identifier = Guid.NewGuid().ToString() Dim globalDictionary As System.Collections.Generic.Dictionary(Of String, Object) = Nothing For Each assembly In System.AppDomain.CurrentDomain.GetAssemblies() Try For Each type In assembly.GetTypes() If type.Name = "GlobalType" Then globalDictionary = CType(type.GetField("GlobalDictionary").GetValue(Nothing), System.Collections.Generic.Dictionary(Of String, Object)) End If Next Catch e As Exception End Try Next globalDictionary(identifier) = GetInstance(handle) word = identifier |
We list all the assemblies, then we find all the types, then we find the type with the global dictionary. Next, we get the dictionary and put the COM object in it. Finally, we return the identifier.
With the regular actions on the MS Word Object, we need to identify the Word instance by using an integer handle
. For our custom actions, we will use a string identifier that serves the same purpose. Just like the regular actions accept handle and other parameters, we’ll accept the same with only a different identifier type.
Lastly, we create a new action in our custom object. Let’s say that we would like to show the instance of the Word application. This is the action:
1 2 3 4 5 6 7 8 9 10 11 12 |
<process name="__selection__CustomWordObject" type="object" runmode="Exclusive"><stage stageid="026cdc08-34e3-4474-a338-8b3b43d93076" name="Start" type="Start"><subsheetid>c76f8dc4-c63e-4820-b619-475abe9c3adc</subsheetid><loginhibit /><display x="15" y="-105" /><inputs><input type="text" name="identifier" stage="identifier" /></inputs><onsuccess>659b998f-463b-401a-b72f-e7f809dc5151</onsuccess></stage><stage stageid="5a5fb9ef-562a-457d-945f-432e8ac1610c" name="End" type="End"><subsheetid>c76f8dc4-c63e-4820-b619-475abe9c3adc</subsheetid><loginhibit /><display x="15" y="90" /></stage><stage stageid="25067651-11ab-463e-9610-9e3ae43a732b" name="identifier" type="Data"><subsheetid>c76f8dc4-c63e-4820-b619-475abe9c3adc</subsheetid><display x="90" y="-105" /><datatype>text</datatype><initialvalue /><private /><alwaysinit /></stage><stage stageid="659b998f-463b-401a-b72f-e7f809dc5151" name="Show Word" type="Code"><subsheetid>c76f8dc4-c63e-4820-b619-475abe9c3adc</subsheetid><loginhibit /><display x="15" y="-45" /><inputs><input type="text" name="identifier" expr="[identifier]" /></inputs><onsuccess>5a5fb9ef-562a-457d-945f-432e8ac1610c</onsuccess><code><![CDATA[System.Collections.Generic.Dictionary<string, object> globalDictionary = null; foreach(var assembly in System.AppDomain.CurrentDomain.GetAssemblies()){ try{ foreach(var type in assembly.GetTypes()){ if(type.Name == "GlobalType"){ globalDictionary = (System.Collections.Generic.Dictionary<string, object>)type.GetField("GlobalDictionary").GetValue(null); } } }catch(Exception e){ } } ((Microsoft.Office.Interop.Word.ApplicationClass)globalDictionary[identifier]).Visible = true;]]></code></stage></process> |
And here is the code specifically:
1 2 3 4 5 6 7 8 9 10 11 12 |
System.Collections.Generic.Dictionary<string, object> globalDictionary = null; foreach(var assembly in System.AppDomain.CurrentDomain.GetAssemblies()){ try{ foreach(var type in assembly.GetTypes()){ if(type.Name == "GlobalType"){ globalDictionary = (System.Collections.Generic.Dictionary<string, object>)type.GetField("GlobalDictionary").GetValue(null); ac } } }catch(Exception e){ } } ((Microsoft.Office.Interop.Word.ApplicationClass)globalDictionary[identifier]).Visible = true; |
You can see that it’s the same code for obtaining the dictionary as before. We list all the types, we then find the dictionary and extract the COM instance based on the identifier obtained when exporting the instance. We then cast the object to the known interface and then simply change the property to show the Word application.
This is how we would use it in the workflow:
1 |
<process name="__selection__Test - MS Word"><stage stageid="991ba627-4d4e-4035-b5bb-a91943c289b0" name="Start" type="Start"><display x="15" y="-150" /><onsuccess>24a17572-5a8c-4ad1-8119-36c634d3cc75</onsuccess></stage><stage stageid="480cfc9d-c4c1-4556-958f-17c471c330ef" name="End" type="End"><display x="15" y="240" /></stage><stage stageid="24a17572-5a8c-4ad1-8119-36c634d3cc75" name="MS Word::Create Instance" type="Action"><loginhibit onsuccess="true" /><display x="15" y="-60" w="120" h="30" /><outputs><output type="number" name="handle" friendlyname="handle" stage="handle" /></outputs><onsuccess>54e185fe-aa0e-4796-b4d9-ab85cf14aee2</onsuccess><resource object="MS Word" action="Create Instance" /></stage><stage stageid="25f56258-8ced-4ea2-8ade-b4bb515e1cd1" name="handle" type="Data"><display x="210" y="-60" /><datatype>number</datatype><initialvalue /><private /><alwaysinit /></stage><stage stageid="54e185fe-aa0e-4796-b4d9-ab85cf14aee2" name="MS Word::Open" type="Action"><loginhibit onsuccess="true" /><display x="15" y="0" w="90" h="30" /><inputs><input type="number" name="handle" friendlyname="handle" expr="" /><input type="text" name="File Name" friendlyname="File Name" expr=""C:\Users\user\Documents\Word.docx"" /></inputs><outputs><output type="text" name="Document Name" friendlyname="Document Name" stage="" /></outputs><onsuccess>8cfb4920-496f-4db5-8098-ee503aec2b89</onsuccess><resource object="MS Word" action="Open" /></stage><stage stageid="8cfb4920-496f-4db5-8098-ee503aec2b89" name="CustomWordObject:CreateGlobalDictionary" type="Action"><loginhibit onsuccess="true" /><display x="15" y="60" w="180" h="30" /><onsuccess>6c70b7da-9c20-461e-97e3-99b3617d2109</onsuccess><resource object="CustomWordObject" action="CreateGlobalDictionary" /></stage><stage stageid="6c70b7da-9c20-461e-97e3-99b3617d2109" name="MS Word::ExportWord" type="Action"><loginhibit onsuccess="true" /><display x="15" y="120" w="90" h="30" /><inputs><input type="number" name="handle" friendlyname="handle" expr="" /></inputs><outputs><output type="text" name="word" friendlyname="word" stage="word" /></outputs><onsuccess>16f8e48f-b508-4bad-b79c-7e38fa5f7cf8</onsuccess><resource object="MS Word" action="ExportWord" /></stage><stage stageid="61ba0748-5329-421d-9180-a260d53e2aee" name="word" type="Data"><display x="210" y="120" /><datatype>text</datatype><initialvalue /><private /><alwaysinit /></stage><stage stageid="16f8e48f-b508-4bad-b79c-7e38fa5f7cf8" name="CustomWordObject::ShowWord" type="Action"><loginhibit onsuccess="true" /><display x="15" y="180" w="150" h="30" /><inputs><input type="text" name="identifier" friendlyname="identifier" expr="[word]" /></inputs><onsuccess>480cfc9d-c4c1-4556-958f-17c471c330ef</onsuccess><resource object="CustomWordObject" action="ShowWord" /></stage></process> |
You can see that we first open the Word instance and open the file using the regular Word object. We then call CreateGlobalDictionary
and ExportWord
to export the instance. Finally, we call ShowWord
from our custom object. This way, you can add any action in your business object and not touch the Blue Prism’s stock object anymore.
Further improvements
It’s worth noting that this solution provides a truly global dictionary for sharing any data between any objects. Nothing stops us from sharing other things. Just don’t abuse the mechanism.
It’s also worth noticing that there is a big code duplication. We could put it in a NuGet package to reuse the code easily.