Results 1 to 16 of 16

Thread: An Azure learning app - "Task Bazaar"

  1. #1

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    An Azure learning app - "Task Bazaar"

    Hi

    My new years resolution is to become Azure proficient and, because I believe the best way to learn is to do, I have set myself a task of creating an Azure hosted application. This is not a financially lucrative idea but rather a project that has elements of all the things I think I need to learn or practice.

    I have bought myself an MSDN license and this comes with $150/month Azure credit that could be used. I also have the source code up on VisualStudio.com under the project name "TaskBazaar".

    Currently I am putting together an underlying architecture (based on CQRS) and once I have that done will start on the UI side - will probably need a lot of guidance on that as I have not done much web stuff in earnest.

    If anyone is interested in contributing or even getting read-only access for the source code let me know. If sufficient interest I will set up a project communications thread and post all the details there.

    Overview
    ========

    The TaskBazaar is an application that allows companies and organisations to set up internal task pools whereby anyone who has a need can set up a task and have that task undertaken by any available fellow employees or organisation members who have the required skills.

    The completion of tasks is recorded and this, in turn, allows the company or organisation to build a searchable and verifiable database of employees and skillsets.
    Anyone seeking a particular skill can look up tasks performed that required that skill and get referral information from the task owner as to which employees demonstrated aptitudes in these tasks.

  2. #2

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    First code for the CQRS pattern is to define an interface for a generic query definition and a generic command definition. These will be implemented by specific concrete classes for each distinct type of query or command the application can support.

    Code:
    ''' <summary>
    ''' Interface to be implemented by all query definitions 
    ''' </summary>
    ''' <remarks>
    ''' This allows for a separation of concerns between the definition of the query and any parameters it requires,
    ''' the validation(s) of that query and the execution of the query
    ''' </remarks>
    Public Interface IQueryDefinition(Of TResult)
    
        ''' <summary>
        ''' Unique identifier of this query instance
        ''' </summary>
        ''' <remarks>
        ''' This allows queries to be queued and the response to a given query definition to be identified
        ''' </remarks>
        ReadOnly Property InstanceIdentifier As Guid
    
        ''' <summary>
        ''' The unique name of the query being requested
        ''' </summary>
        ReadOnly Property QueryName As String
    
        ''' <summary>
        ''' Add a paremeter to this query
        ''' </summary>
        ''' <param name="parameter">
        ''' The parameter to add to the query
        ''' </param>
        ''' <remarks>
        ''' This will throw an argumwent exception if this query already has a parameter with teh same name and index.  Use TryAddParameter to avoid this exception
        ''' </remarks>
        Sub AddParameter(ByVal parameter As QueryParameter)
    
        ''' <summary>
        ''' Add a paremeter to this query
        ''' </summary>
        ''' <param name="parameter">
        ''' The parameter to add to the query
        ''' </param>
        ''' <remarks>
        ''' This will return true if the parameter was successfully added
        ''' </remarks>
        Function TryAddParameter(ByVal parameter As QueryParameter) As Boolean
    
        ''' <summary>
        ''' True if this query has the parameter defined for it
        ''' </summary>
        ''' <param name="parameterName">
        ''' The name of the parameter to look for
        ''' </param>
        ''' <param name="parameterIndex">
        ''' The zero-based index of the parameter
        ''' </param>
        Function ParameterExists(ByVal parameterName As String, ByVal parameterIndex As Integer) As Boolean
    
        ''' <summary>
        ''' Get the parameter value for the named parameter
        ''' </summary>
        ''' <param name="parameterName">
        ''' The name of the parameter to look for
        ''' </param>
        ''' <param name="parameterIndex">
        ''' The zero-based index of the parameter
        ''' </param>
        ''' <remarks>
        ''' This will throw an error if the parameter does not exists.  Use TryGetParameter instead if that is not desired
        ''' </remarks>
        Function GetParameter(ByVal parameterName As String, ByVal parameterIndex As Integer) As Object
    
        ''' <summary>
        ''' Get the parameter value for the named parameter
        ''' </summary>
        ''' <param name="parameterName">
        ''' The name of the parameter to look for
        ''' </param>
        ''' <param name="parameterIndex">
        ''' The zero-based index of the parameter
        ''' </param>
        ''' <param name="value">
        ''' The variable to hold the parameter value if found
        ''' </param>
        ''' <remarks>
        ''' This will return true if the parameter was retrieved
        ''' </remarks>
        Function TryGetParameter(ByVal parameterName As String, ByVal parameterIndex As Integer, ByRef value As Object) As Boolean
    
    End Interface
    Where the query parameter is defined thus:-
    Code:
    Imports System.Runtime.Serialization
    
    ''' <summary>
    ''' A single parameter that is used to restrict the results returned for a given query definition
    ''' </summary>
    ''' <remarks>
    ''' This is an immutable class to allow for safe parallel/asynchronous processing
    ''' </remarks>
    <DataContract>
    Public NotInheritable Class QueryParameter
    
        <DataMember(Name:="ParameterName")>
        ReadOnly m_name As String
        <DataMember(Name:="ParameterIndex")>
        ReadOnly m_index As Integer
        <DataMember(Name:="ParameterValue")>
        ReadOnly m_value As Object
    
        ''' <summary>
        ''' The name of the parameter
        ''' </summary>
        ''' <remarks>
        ''' This should be unique in any given query definition, unless there are multuiple indexed properties with the same name
        ''' </remarks>
        Public ReadOnly Property Name
            Get
                Return m_name
            End Get
        End Property
    
        ''' <summary>
        ''' The index (zero based) of the parameter
        ''' </summary>
        ''' <remarks>
        ''' For a non-indexed parameter, this wuill always be zero
        ''' </remarks>
        Public ReadOnly Property Index As Integer
            Get
                Return m_index
            End Get
        End Property
    
        ''' <summary>
        ''' The value of the parameter
        ''' </summary>
        Public ReadOnly Property Value As Object
            Get
                Return m_value
            End Get
        End Property
    
        ''' <summary>
        ''' Creatre a new parameter instance with the given properties
        ''' </summary>
        ''' <param name="nameInit">
        ''' The name of the parameter
        ''' </param>
        ''' <param name="indexInit">
        ''' The zero-based index of the parameter
        ''' </param>
        ''' <param name="valInit">
        ''' The starting value of the parameter - this can be Nothing (null) to indicate that the parameter is not set
        ''' </param>
        ''' <remarks></remarks>
        Public Sub New(ByVal nameInit As String, ByVal indexInit As Integer, ByVal valInit As Object)
            m_name = nameInit
            m_index = indexInit
            m_value = valInit
        End Sub
    
        ''' <summary>
        ''' Create a new parameter for the given properties
        ''' </summary>
        ''' <param name="name">
        ''' The name of the parameter
        ''' </param>
        ''' <param name="index">
        ''' The zero-based index of the parameter
        ''' </param>
        ''' <param name="value">
        ''' The value to use for this parameter
        ''' </param>
        Public Shared Function Create(ByVal name As String, ByVal index As Integer, ByVal value As Object) As QueryParameter
            Return New QueryParameter(name, index, value)
        End Function
    
        Public Shared Function GetParameterKey(ByVal parameter As QueryParameter) As String
            Return GetParameterKey(parameter.Name, parameter.Index)
        End Function
    
        Public Shared Function GetParameterKey(ByVal parameterName As String, ByVal parameterIndex As Integer) As String
            Return parameterName & " [" & parameterIndex.ToString() & "]"
        End Function
    
    End Class
    The first design question: does it make sense to turn QueryParameter into a generic class rather than have value as object?

  3. #3

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    The demo application will use three different Azure storage methods/concepts:-
    System wide data will be stored in an SQL Azure database
    Client / Bazaar / Task structured data will be held in Table storage
    Task related documents and unstructured data will be held in Blob storage
    (and - to complete the coverage of Azure concepts, the command side of the CQRS architecture will use Queue based storage)
    Last edited by Merrion; Jan 14th, 2014 at 09:30 AM.

  4. #4

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    It is just a shell at the moment but the putative web front end for the application is at taskbazaar.azurewebsites.net...

  5. #5

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    Commands are to be passed to the command handlers (worker processes) using an Azure queue. However, as the queue storage restricts the message size (and has other restrictions) what I am thinking is a two-step process whereby the command parameters are written to a WATS (Table storage) and the key to that store is then passed in the message queue. This allows command parameters to be quite large - for example if the command is something like "process this file" then the file can be a parameter.

    The command parameter class is defined thus:
    Code:
    Imports System.Runtime.Serialization
    Imports System.Text
    
    ''' <summary>
    ''' A single parameter that is used to restrict the results returned for a given query definition
    ''' </summary>
    ''' <remarks>
    ''' This is an immutable class to allow for safe parallel/asynchronous processing
    ''' </remarks>
    <DataContract>
    Public NotInheritable Class CommandParameter
    
        <DataMember(Name:="ParameterName")>
        ReadOnly m_name As String
        <DataMember(Name:="ParameterIndex")>
        ReadOnly m_index As Integer
        <DataMember(Name:="ParameterValue")>
        ReadOnly m_value As Object
    
        ''' <summary>
        ''' The name of the parameter
        ''' </summary>
        ''' <remarks>
        ''' This should be unique in any given query definition, unless there are multuiple indexed properties with the same name
        ''' </remarks>
        Public ReadOnly Property Name As String
            Get
                Return m_name
            End Get
        End Property
    
        ''' <summary>
        ''' The index (zero based) of the parameter
        ''' </summary>
        ''' <remarks>
        ''' For a non-indexed parameter, this wuill always be zero
        ''' </remarks>
        Public ReadOnly Property Index As Integer
            Get
                Return m_index
            End Get
        End Property
    
        ''' <summary>
        ''' The value of the parameter
        ''' </summary>
        Public ReadOnly Property Value As Object
            Get
                Return m_value
            End Get
        End Property
    
        ''' <summary>
        ''' Creatre a new command parameter instance with the given properties
        ''' </summary>
        ''' <param name="nameInit">
        ''' The name of the parameter
        ''' </param>
        ''' <param name="indexInit">
        ''' The zero-based index of the parameter
        ''' </param>
        ''' <param name="valInit">
        ''' The starting value of the parameter - this can be Nothing (null) to indicate that the parameter is not set
        ''' </param>
        ''' <remarks></remarks>
        Public Sub New(ByVal nameInit As String, ByVal indexInit As Integer, ByVal valInit As Object)
            m_name = nameInit
            m_index = indexInit
            m_value = valInit
        End Sub
    
    
        ''' <summary>
        ''' Turn the parameter into a string describing it
        ''' </summary>
        ''' <remarks>
        ''' This will be an XMl node with the parameter value also as XML
        ''' </remarks>
        Public Overrides Function ToString() As String
    
            Dim serialiser As New System.Runtime.Serialization.DataContractSerializer(GetType(CommandParameter))
            If (serialiser IsNot Nothing) Then
                Dim serialXML As StringBuilder = New StringBuilder()
                Using xmlWrite As System.Xml.XmlWriter = System.Xml.XmlWriter.Create(serialXML)
                    serialiser.WriteObject(xmlWrite, Me)
                    xmlWrite.Flush()
                End Using
                Return serialXML.ToString()
            Else
                Return ""
            End If
    
        End Function
    
        ''' <summary>
        ''' Create a new parameter for the given properties
        ''' </summary>
        ''' <param name="name">
        ''' The name of the parameter
        ''' </param>
        ''' <param name="index">
        ''' The zero-based index of the parameter
        ''' </param>
        ''' <param name="value">
        ''' The value to use for this parameter
        ''' </param>
        Public Shared Function Create(ByVal name As String, ByVal index As Integer, ByVal value As Object) As CommandParameter
            Return New CommandParameter(name, index, value)
        End Function
    
        Public Shared Function Create(ByVal xml As String) As CommandParameter
            If (String.IsNullOrWhiteSpace(xml)) Then
                Throw New ArgumentException("XML does not hold valid command parameter", "xml")
            Else
                Dim serialiser As New System.Runtime.Serialization.DataContractSerializer(GetType(CommandParameter))
                If (serialiser IsNot Nothing) Then
                    Using xmlRead As System.Xml.XmlReader = System.Xml.XmlReader.Create(New System.IO.StringReader(xml))
                        Return CType(serialiser.ReadObject(xmlRead), CommandParameter)
                    End Using
                Else
                    Throw New InvalidOperationException("Unable to create serialiser for CommandParameter")
                End If
    
            End If
        End Function
    
        Public Shared Function GetParameterKey(ByVal parameter As CommandParameter) As String
            Return GetParameterKey(parameter.Name, parameter.Index)
        End Function
    
        Public Shared Function GetParameterKey(ByVal parameterName As String, ByVal parameterIndex As Integer) As String
            Return parameterName & " [" & parameterIndex.ToString() & "]"
        End Function
    
    End Class
    and the class to allow persisting these ICommandDefinition classes with all their parameters to a windows azure table storage uses a class derived from ITableEntity thus:-
    Code:
        ''' <summary>
        ''' A class for storing a command and its parameters in an azure table store
        ''' </summary>
        ''' <remarks>
        ''' The command type and the instance identifier are the partition and row keys to allow any 
        ''' command handler easily to find its command parameter data
        ''' </remarks>
        Public Class CommandTableEntity
            Implements ITableEntity
    
            Private m_parameters As Dictionary(Of String, CommandParameter) = New Dictionary(Of String, CommandParameter)
    
            ''' <summary>
            ''' Gets or sets the entity's current Entity Tag. 
            ''' </summary>
            ''' <remarks>
            ''' Set this value to '*' in order to blindly overwrite an entity as part of an update operation. 
            ''' </remarks>
            Public Property ETag As String Implements ITableEntity.ETag
    
            ''' <summary>
            ''' The property that defines what table partition this command will be stored in
            ''' </summary>
            ''' <remarks>
            ''' In this implementation this is the command class name
            ''' </remarks>
            Public Property PartitionKey As String Implements ITableEntity.PartitionKey
    
            ''' <summary>
            ''' The property that defines what row this command record will be stored in
            ''' </summary>
            Public Property RowKey As String Implements ITableEntity.RowKey
    
            Public Property Timestamp As DateTimeOffset Implements ITableEntity.Timestamp
    
            ''' <summary>
            ''' Has this command been marked as processed
            ''' </summary>
            Public Property Processed As Boolean
    
            ''' <summary>
            ''' Has this command been marked as in error
            ''' </summary>
            Public Property InError As Boolean
    
            ''' <summary>
            ''' What handler processed this command
            ''' </summary>
            Public Property ProcessedBy As String
    
    #Region "Public constructors"
            ''' <summary>
            ''' Parameter-less constructor for serialisation
            ''' </summary>
            ''' <remarks>
            ''' This is mandatory for WATS to persist the object
            ''' </remarks>
            Public Sub New()
    
            End Sub
    
            ''' <summary>
            ''' Create a new record with in the commands table 
            ''' </summary>
            ''' <param name="commandType">
            ''' The type of command to process
            ''' </param>
            ''' <param name="instanceIdentifier">
            ''' Unique identifier of the command to process
            ''' </param>
            ''' <remarks>
            ''' This can be used if a command has no parameters
            ''' </remarks>
            Public Sub New(ByVal commandType As String, ByVal instanceIdentifier As Guid)
                Me.PartitionKey = commandType
                Me.RowKey = instanceIdentifier.ToString
            End Sub
    
    
            Public Sub New(ByVal command As ICommandDefinition)
                Me.New(command.GetType().Name, command.InstanceIdentifier)
                'save the parameters too...
                If (m_parameters Is Nothing) Then
                    m_parameters = New Dictionary(Of String, CommandParameter)()
                End If
                For Each param As CommandParameter In command.GetParameters()
                    m_parameters.Add(CommandParameter.GetParameterKey(param), param)
                Next
            End Sub
    
    #End Region
    
            Public Sub ReadEntity(properties As IDictionary(Of String, EntityProperty), operationContext As OperationContext) Implements ITableEntity.ReadEntity
    
                For Each propertyPair In properties
                    If (propertyPair.Key.Contains("[")) AndAlso (propertyPair.Key.Contains("]")) Then
                        'This is a property in the form name[index]...
                        Dim parameterName As String = CommandParameter.GetParameterName(propertyPair.Key)
                        Dim parameterIndex As Integer = CommandParameter.GetParameterIndex(propertyPair.Key)
                        'Get the command payload from the string
                        Dim parameterPayload As String = propertyPair.Value.StringValue
                        If (Not String.IsNullOrWhiteSpace(parameterPayload)) Then
                            m_parameters.Add(propertyPair.Key, CommandParameter.Create(parameterPayload))
                        Else
                            '\\ Add a parameter that has no payload/value
                            m_parameters.Add(propertyPair.Key, CommandParameter.Create(parameterName, parameterIndex, Nothing))
                        End If
                    Else
                        'Named property only...do not persist "ETag", "Timestamp", "Rowkey" or "PartitionKey" 
    
                        ' Additional properties for a command 
                        If propertyPair.Key.Equals("Processed", StringComparison.OrdinalIgnoreCase) Then
                            If (propertyPair.Value.BooleanValue.HasValue) Then
                                Me.Processed = propertyPair.Value.BooleanValue.Value
                            End If
                        End If
    
                        If propertyPair.Key.Equals("InError", StringComparison.OrdinalIgnoreCase) Then
                            If (propertyPair.Value.BooleanValue.HasValue) Then
                                Me.InError = propertyPair.Value.BooleanValue.Value
                            End If
                        End If
    
                        If propertyPair.Key.Equals("ProcessedBy", StringComparison.OrdinalIgnoreCase) Then
                            Me.ProcessedBy = propertyPair.Value.StringValue
                        End If
    
                    End If
                Next
    
            End Sub
    
            Public Function WriteEntity(operationContext As OperationContext) As IDictionary(Of String, EntityProperty) Implements ITableEntity.WriteEntity
    
                Dim properties As New Dictionary(Of String, EntityProperty)
                'Skip the ITableEntity properties "ETag", "Timestamp", "Rowkey" or "PartitionKey" 
    
                'Add all the command parameters...
                For Each param As CommandParameter In m_parameters.Values
                    properties.Add(CommandParameter.GetParameterKey(param), New EntityProperty(param.ToString))
                Next
    
                'Add the other properties
                properties.Add("Processed", New EntityProperty(Me.Processed))
                properties.Add("InError", New EntityProperty(Me.InError))
                properties.Add("ProcessedBy", New EntityProperty(Me.ProcessedBy))
    
                Return properties
    
            End Function
        End Class
    Last edited by Merrion; Jan 15th, 2014 at 10:58 AM. Reason: adding command parameter class definition....

  6. #6

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    Name:  cqrs-command.jpg
Views: 361
Size:  22.8 KB

    The dispatcher that puts these commands on the table and then sends a message on the queue is:-
    Code:
       ''' <summary>
        ''' Command dispatcher that put scommands on an Azure queue
        ''' </summary>
        ''' <remarks>
        ''' For Azure:-
        ''' 1. A queue name must start with a letter or number, and can only contain letters, numbers, and the dash (-) character. 
        ''' 2. The first and last letters in the queue name must be alphanumeric. The dash (-) character cannot be the first or last character. Consecutive dash characters are not permitted in the queue name.
        ''' 3. All letters in a queue name must be lowercase.
        ''' 4. A queue name must be from 3 through 63 characters long.
        ''' </remarks>
        Public Class AzureQueueBackedCommandDispacther
            Implements ICommandDispatcher
    
    
            'The prefix for the queue name that contains commands
            Public Const COMMAND_QUEUE_PREFIX = "cmd-"
            Public Const CLIENT_QUEUE_PREFIX = "cli-"
    
            Public Const COMMAND_TABLE_PREFIX = "cmd"
            Public Const CLIENT_TABLE_PREFIX = "cli"
    
            Dim applicationStorageAccount As CloudStorageAccount
            Dim applicationQueueClient As CloudQueueClient
            Dim applicationTableClient As CloudTableClient
    
            ''' <summary>
            ''' Gets the name of the appropriate queue to put this command on
            ''' </summary>
            ''' <param name="command">
            ''' The command definition to dispatch
            ''' </param>
            ''' <returns>
            ''' e.g. "cmd-global" or "cmd-cli-23"
            ''' </returns>
            ''' <remarks>
            ''' At present there are only distinct command queues per client.  If this application is a higher usage type then 
            ''' queues at a lower level should be considered
            ''' </remarks>
            Public Function GetQueueReference(command As ICommandDefinition) As String Implements ICommandDispatcher.GetQueueReference
    
                Dim clientCommand As IClientSpecificCommand = Nothing
                If (GetType(IClientSpecificCommand).IsAssignableFrom(command.GetType())) Then
                    clientCommand = CType(command, IClientSpecificCommand)
                End If
    
                If (clientCommand IsNot Nothing) Then
                    Return COMMAND_QUEUE_PREFIX & CLIENT_QUEUE_PREFIX & clientCommand.ClientIdentifier.ToString()
                End If
    
                'If no specific command queue level exists, use the global commands queue
                Return COMMAND_QUEUE_PREFIX & "global"
    
            End Function
    
            ''' <summary>
            ''' Gets the name of the appropriate queue to put this command on
            ''' </summary>
            ''' <param name="command">
            ''' The command definition to dispatch
            ''' </param>
            ''' <returns>
            ''' e.g. "cmdglobal" or "cmdcli123"
            ''' </returns>
            ''' <remarks>
            ''' At present there are only distinct command queues per client.  If this application is a higher usage type then 
            ''' queues at a lower level should be considered
            ''' </remarks>
            Public Function GetTablename(command As ICommandDefinition) As String
    
                Dim clientCommand As IClientSpecificCommand = Nothing
                If (GetType(IClientSpecificCommand).IsAssignableFrom(command.GetType())) Then
                    clientCommand = CType(command, IClientSpecificCommand)
                End If
    
                If (clientCommand IsNot Nothing) Then
                    Return COMMAND_TABLE_PREFIX & CLIENT_TABLE_PREFIX & clientCommand.ClientIdentifier.ToString()
                End If
    
                'If no specific command queue level exists, use the global commands queue
                Return COMMAND_TABLE_PREFIX & "global"
    
            End Function
    
            Public Sub Send(command As ICommandDefinition) Implements ICommandDispatcher.Send
    
                ' 1) Save the command data in a commands table for the handler to use
                If (applicationTableClient IsNot Nothing) Then
                    Dim commandTable As CloudTable = applicationTableClient.GetTableReference(GetTablename(command))
                    'Table may not exists yet, so create it if it doesn't
                    commandTable.CreateIfNotExists()
                    Dim cmdRecord As New CommandTableEntity(command)
                    '\\ Insert or update the record
                    Dim insertOperation As TableOperation = TableOperation.InsertOrReplace(cmdRecord)
                    Dim insertResult = commandTable.Execute(insertOperation)
                End If
    
                ' 2) Queue the command to execute
                If (applicationQueueClient IsNot Nothing) Then
                    Dim queue As CloudQueue = applicationQueueClient.GetQueueReference(GetQueueReference(command))
                    ' Queue may not yet exist so create it is it doesn't
                    queue.CreateIfNotExists()
                    Dim msg As CloudQueueMessage = New CloudQueueMessage(command.GetType.Name & "::" & command.InstanceIdentifier.ToString())
                    If (msg IsNot Nothing) Then
                        queue.AddMessage(msg)
                    End If
                End If
    
            End Sub
    
            Public Sub Send(commands As IEnumerable(Of ICommandDefinition)) Implements ICommandDispatcher.Send
    
                If (applicationQueueClient IsNot Nothing) AndAlso (applicationTableClient IsNot Nothing) Then
                    For Each Command As ICommandDefinition In commands
                        Send(Command)
                    Next
                End If
    
            End Sub
    
            Public Sub New(ByVal connectionString As String)
                'Hook up the clound storage account
                If (CloudStorageAccount.TryParse(connectionString, applicationStorageAccount)) Then
                    If (applicationStorageAccount IsNot Nothing) Then
                        applicationQueueClient = applicationStorageAccount.CreateCloudQueueClient()
                        applicationTableClient = applicationStorageAccount.CreateCloudTableClient()
                    End If
                End If
            End Sub
    
        End Class
    And the record that inherits ITableEntity to allow this to be written to/from table storage is thus:

    Code:
        ''' <summary>
        ''' A class for storing a command and its parameters in an azure table store
        ''' </summary>
        ''' <remarks>
        ''' The command type and the instance identifier are the partition and row keys to allow any 
        ''' command handler easily to find its command parameter data
        ''' </remarks>
        Public Class CommandTableEntity
            Implements ITableEntity
    
            Private m_parameters As Dictionary(Of String, CommandParameter) = New Dictionary(Of String, CommandParameter)
    
            ''' <summary>
            ''' Gets or sets the entity's current Entity Tag. 
            ''' </summary>
            ''' <remarks>
            ''' Set this value to '*' in order to blindly overwrite an entity as part of an update operation. 
            ''' </remarks>
            Public Property ETag As String Implements ITableEntity.ETag
    
            ''' <summary>
            ''' The property that defines what table partition this command will be stored in
            ''' </summary>
            ''' <remarks>
            ''' In this implementation this is the command class name
            ''' </remarks>
            Public Property PartitionKey As String Implements ITableEntity.PartitionKey
    
            ''' <summary>
            ''' The property that defines what row this command record will be stored in
            ''' </summary>
            Public Property RowKey As String Implements ITableEntity.RowKey
    
            Public Property Timestamp As DateTimeOffset Implements ITableEntity.Timestamp
    
            ''' <summary>
            ''' Has this command been marked as processed
            ''' </summary>
            Public Property Processed As Boolean
    
            ''' <summary>
            ''' Has this command been marked as in error
            ''' </summary>
            Public Property InError As Boolean
    
            ''' <summary>
            ''' What handler processed this command
            ''' </summary>
            Public Property ProcessedBy As String
    
    #Region "Public constructors"
            ''' <summary>
            ''' Parameter-less constructor for serialisation
            ''' </summary>
            ''' <remarks>
            ''' This is mandatory for WATS to persist the object
            ''' </remarks>
            Public Sub New()
    
            End Sub
    
            ''' <summary>
            ''' Create a new record with in the commands table 
            ''' </summary>
            ''' <param name="commandType">
            ''' The type of command to process
            ''' </param>
            ''' <param name="instanceIdentifier">
            ''' Unique identifier of the command to process
            ''' </param>
            ''' <remarks>
            ''' This can be used if a command has no parameters
            ''' </remarks>
            Public Sub New(ByVal commandType As String, ByVal instanceIdentifier As Guid)
                Me.PartitionKey = commandType
                Me.RowKey = instanceIdentifier.ToString
            End Sub
    
    
            Public Sub New(ByVal command As ICommandDefinition)
                Me.New(command.GetType().Name, command.InstanceIdentifier)
                'save the parameters too...
                If (m_parameters Is Nothing) Then
                    m_parameters = New Dictionary(Of String, CommandParameter)()
                End If
                For Each param As CommandParameter In command.GetParameters()
                    m_parameters.Add(CommandParameter.GetParameterKey(param), param)
                Next
            End Sub
    
    #End Region
    
            Public Sub ReadEntity(properties As IDictionary(Of String, EntityProperty), operationContext As OperationContext) Implements ITableEntity.ReadEntity
    
                For Each propertyPair In properties
                    If (propertyPair.Key.Contains("__")) Then
                        'This is a property in the form name[index]...
                        Dim fixedPropertyName As String = propertyPair.Key.Replace("__", "[").Trim() & "]"
                        Dim parameterName As String = CommandParameter.GetParameterName(fixedPropertyName)
                        Dim parameterIndex As Integer = CommandParameter.GetParameterIndex(fixedPropertyName)
                        'Get the command payload from the string
                        Dim parameterPayload As String = propertyPair.Value.StringValue
                        If (Not String.IsNullOrWhiteSpace(parameterPayload)) Then
                            m_parameters.Add(propertyPair.Key, CommandParameter.Create(parameterPayload))
                        Else
                            '\\ Add a parameter that has no payload/value
                            m_parameters.Add(propertyPair.Key, CommandParameter.Create(parameterName, parameterIndex))
                        End If
                    Else
                        'Named property only...do not persist "ETag", "Timestamp", "Rowkey" or "PartitionKey" 
    
                        ' Additional properties for a command 
                        If propertyPair.Key.Equals("Processed", StringComparison.OrdinalIgnoreCase) Then
                            If (propertyPair.Value.BooleanValue.HasValue) Then
                                Me.Processed = propertyPair.Value.BooleanValue.Value
                            End If
                        End If
    
                        If propertyPair.Key.Equals("InError", StringComparison.OrdinalIgnoreCase) Then
                            If (propertyPair.Value.BooleanValue.HasValue) Then
                                Me.InError = propertyPair.Value.BooleanValue.Value
                            End If
                        End If
    
                        If propertyPair.Key.Equals("ProcessedBy", StringComparison.OrdinalIgnoreCase) Then
                            Me.ProcessedBy = propertyPair.Value.StringValue
                        End If
    
                    End If
                Next
    
            End Sub
    
            Public Function WriteEntity(operationContext As OperationContext) As IDictionary(Of String, EntityProperty) Implements ITableEntity.WriteEntity
    
                Dim properties As New Dictionary(Of String, EntityProperty)
                'Skip the ITableEntity properties "ETag", "Timestamp", "Rowkey" or "PartitionKey" 
    
                'Add all the command parameters...
                For Each param As CommandParameter In m_parameters.Values
                    Dim fixedPropertyName As String = CommandParameter.GetParameterKey(param).Replace("[", "__").Replace("]", "")
                    properties.Add(fixedPropertyName, New EntityProperty(param.ToString))
                Next
    
                'Add the other properties
                properties.Add("Processed", New EntityProperty(Me.Processed))
                properties.Add("InError", New EntityProperty(Me.InError))
                properties.Add("ProcessedBy", New EntityProperty(Me.ProcessedBy))
    
                Return properties
    
            End Function
        End Class
    Last edited by Merrion; Jan 20th, 2014 at 05:30 AM. Reason: fixed queue naming code and table naming

  7. #7

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    Tips I have learnt so far:-
    1) When storing data in windows azure table storage, put extra care into deciding what the partition key property is - I started out with too high level an object (client) which meant my tables only had a small number of partitions which makes data access slower.... combining client # and bazaar # proved better in most cases.
    2) It costs a (very small) amount of money to poll a windows storage queue even if it is empty therefore a way of throttling the polling rate up or down depending on demand is a very good idea
    3) Use a tool like Azure storage explorer to see what is being saved in your blobs/queues/tables
    4) Even though you can use the same name for databases and storage accounts it is best not to as you then can't link them to your website as a linked resource
    Last edited by Merrion; Jan 20th, 2014 at 09:59 AM.

  8. #8
    PowerPoster dilettante's Avatar
    Join Date
    Feb 2006
    Posts
    24,487

    Re: An Azure learning app - "Task Bazaar"

    Have you looked at push notification instead of polling?

    Push notifications are meant to do three things: (1.) save on cloud costs, (2.) save on network costs, and (3.) save on CPU cycles in the client device.

    Sadly, Microsoft has never implemented such a thing for Windows. You do have it in WinPhone (I think) and WinRT (if you count that) but not generally in Windows that I can find.


    Finding out anything about Azure seems like pulling teeth. As far as I can tell Azure Push does support WinPhone, WinRT, iOS, and Android. The last two relay to the vendors' native platform push services acting as a proxy. But for a real Windows desktop application Azure appears to leave you standing, hat in hand.


    On the other hand Amazon SNS appears to offer a far wider range of options (though it currently does not support the minority WinPhone and WinRT platforms). One of the useful options in SNS is "push to HTTP/HTTPS" which could be used in a Windows desktop application. The downside here is that you have to be able to open a port through all firewalls between you and the Amazon servers so SNS can connect to your application.

    The mobile platforms seem to use a situation where a local service on the device sits between apps and the push service in the cloud. When the device starts, or gets network connectivity, it opens a persistent TCP port to the push service. Since this is an outbound connection the firewall issues are smaller.


    Too bad we don't have a discussion area for these sorts of topics. General Developer Discussions seems to be given over to VB.Net questions lately and any thread there not based on Win9x-level technologies seems to get trashed by the regular trolls from the peanut gallery.

  9. #9

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    I expect my command handler itself to be implemented as a worker role (or many) on azure anyway so don't anticipate problems on the firewall side. I have put together two distinct interfaces - one for pull notifications and one for push notifications so if I (more likely you/we) discover how to do this I can swap it out pretty quickly.

    Since I'm in learning mode I'm going to take a look at the Azure notifications system but doubt that there is much help there.

    On the other point, maybe we should petition for an "Azure" section ... although we need to know there are enough people interested to prevent it becoming a ghost (like architecture is).

  10. #10
    PowerPoster dilettante's Avatar
    Join Date
    Feb 2006
    Posts
    24,487

    Re: An Azure learning app - "Task Bazaar"

    You might find sympathy for an Azure section with the top-tier moderators here but I agree it might end up as a ghost town like others. "If you build it they will come" has been tried and failed before.

  11. #11

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    Going to see if I can add a "get latest code" function to the app itself so anyone wants the code can get it and just post any questions as arise into the general VB.NET area...

    Also - if anyone is interested, there's a deep-dive going on Micrsofot Virtual Academy next week that is probably worth a look.
    Last edited by Merrion; Jan 20th, 2014 at 05:02 PM.

  12. #12

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    Another thing that goes hand-in-hand with the CQRS architecture is Event Sourcing (See this article)

    To facilitate this I have a class that saves events to an azure table using the PartitionKey as the aggregate identifier (like the record unique identifier in relational systems) and the RowKey as the version number (the event order):

    vb.net Code:
    1. Imports Microsoft.WindowsAzure.Storage
    2. Imports Microsoft.WindowsAzure.Storage.Auth
    3. Imports Microsoft.WindowsAzure.Storage.Table
    4.  
    5. Imports System.Text.RegularExpressions
    6.  
    7. Public Class AzureEventEntity
    8.  
    9.     Private Const VERSION_FORMAT As String = "0000000000000000000"
    10.  
    11.     Private Const PROPERTYNAME_EVENTTYPE As String = "EventType"
    12.     Private Const PROPERTYNAME_SEQUENCE As String = "SequenceNumber"
    13.  
    14.     ''' <summary>
    15.     ''' Turn the version into a consistently ordered string
    16.     ''' </summary>
    17.     ''' <param name="version">
    18.     ''' The incremental number that represents the row version
    19.     ''' </param>
    20.     ''' <returns>
    21.     ''' The version number formatted with leading zeros so the string order matches
    22.     ''' the number order
    23.     ''' </returns>
    24.     ''' <remarks>
    25.     ''' If version is less than 0, assume it to be zero.  
    26.     ''' </remarks>
    27.     Public Shared Function VersionToRowkey(ByVal version As Long) As String
    28.  
    29.         If (version <= 0) Then
    30.             Return VERSION_FORMAT
    31.         Else
    32.             Return version.ToString(VERSION_FORMAT)
    33.         End If
    34.  
    35.     End Function
    36.  
    37.     ''' <summary>
    38.     ''' Turn the row key into a version number
    39.     ''' </summary>
    40.     ''' <param name="rowkey">
    41.     ''' The row unique identifier
    42.     ''' </param>
    43.     ''' <returns>
    44.     ''' The version number formatted with leading zeros so the string order matches
    45.     ''' the number order
    46.     ''' </returns>
    47.     ''' <remarks>
    48.     ''' If version is less than 0, assume it to be zero.  
    49.     ''' </remarks>
    50.     Public Shared Function RowKeyToVersion(ByVal rowkey As String) As Long
    51.  
    52.         If (String.IsNullOrWhiteSpace(rowkey)) Then
    53.             Return 0
    54.         Else
    55.             Dim ret As Long
    56.             If (Long.TryParse(rowkey, ret)) Then
    57.                 Return ret
    58.             Else
    59.                 Return 0
    60.             End If
    61.         End If
    62.  
    63.     End Function
    64.  
    65.     Public Shared Function MakeDynamicTableEntity(ByVal eventToSave As IEventContext) As DynamicTableEntity
    66.  
    67.         Dim ret As New DynamicTableEntity
    68.  
    69.         ret.PartitionKey = eventToSave.GetAggregateIdentifier()
    70.         ret.RowKey = VersionToRowkey(eventToSave.Version)
    71.  
    72.         'Add the event type - currently this is the
    73.         ret.Properties.Add(PROPERTYNAME_EVENTTYPE, New EntityProperty(eventToSave.EventInstance.GetType().Name))
    74.  
    75.         'Add the context
    76.         If (eventToSave.SequenceNumber <= 0) Then
    77.             'Default sequence number is the current UTC date
    78.             ret.Properties.Add(PROPERTYNAME_SEQUENCE, New EntityProperty(DateTime.UtcNow.Ticks))
    79.         Else
    80.             ret.Properties.Add(PROPERTYNAME_SEQUENCE, New EntityProperty(eventToSave.SequenceNumber))
    81.         End If
    82.  
    83.         If (Not String.IsNullOrWhiteSpace(eventToSave.Commentary)) Then
    84.             ret.Properties.Add("Commentary", New EntityProperty(eventToSave.Commentary))
    85.         End If
    86.  
    87.         If (Not String.IsNullOrWhiteSpace(eventToSave.Who)) Then
    88.             ret.Properties.Add("Who", New EntityProperty(eventToSave.Who))
    89.         End If
    90.  
    91.         If (Not String.IsNullOrWhiteSpace(eventToSave.Source)) Then
    92.             ret.Properties.Add("Source", New EntityProperty(eventToSave.Source))
    93.         End If
    94.  
    95.         'Now add in the different properties of the payload
    96.         For Each pi As System.Reflection.PropertyInfo In eventToSave.EventInstance.GetType().GetProperties()
    97.             If (pi.CanRead) Then
    98.                 ret.Properties.Add(pi.Name, MakeEntityProperty(pi, eventToSave.EventInstance))
    99.             End If
    100.         Next pi
    101.  
    102.         Return ret
    103.  
    104.     End Function
    105.  
    106.  
    107.     Public Shared Function MakeEventFromTableEntity(ByVal tableEntityRow As DynamicTableEntity) As IEventContext
    108.  
    109.         'get the event type
    110.  
    111.         'get the context parts
    112.         If (tableEntityRow.Properties.ContainsKey(PROPERTYNAME_SEQUENCE)) Then
    113.  
    114.         End If
    115.  
    116.     End Function
    117.  
    118.  
    119.     Private Const TABLE_ILLEGAL_CHAR_REGEX As String = "[\W_]"
    120.  
    121.     Public Shared Function MakeValidTableName(strIn As String) As String
    122.         ' Replace invalid characters with empty strings.
    123.         Try
    124.             Return Regex.Replace(strIn, TABLE_ILLEGAL_CHAR_REGEX, "").ToLower()
    125.             ' If we timeout when replacing invalid characters,  
    126.             ' we should return String.Empty.
    127.         Catch e As RegexMatchTimeoutException
    128.             Return String.Empty
    129.         End Try
    130.     End Function
    131.  
    132. End Class

  13. #13

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    and I use another table "EventTracker" to store the current max sequence number of each aggregate - it turns out that this is faster and more parallelizable than querying in the event table itself (another RDBMS hangup to leave behind?)

    vb.net Code:
    1. Imports Microsoft.WindowsAzure.Storage
    2. Imports Microsoft.WindowsAzure.Storage.Auth
    3. Imports Microsoft.WindowsAzure.Storage.Table
    4.  
    5. Class AzureSequenceTrackerEntity
    6.     Inherits TableEntity
    7.  
    8.     ''' <summary>
    9.     ''' The sequence # currently heading this given event history
    10.     ''' </summary>
    11.     Public Property Sequence As Long
    12.  
    13.     Public Sub New()
    14.  
    15.     End Sub
    16.  
    17.     Public Sub New(ByVal eventTableName As String,
    18.                    ByVal aggregateidentifier As String)
    19.  
    20.         Me.PartitionKey = eventTableName
    21.         Me.RowKey = aggregateidentifier
    22.  
    23.     End Sub
    24.  
    25. End Class
    26.  
    27.  
    28. Public Class AzureSequenceTracker
    29.  
    30.     Dim applicationStorageAccount As CloudStorageAccount
    31.     Dim applicationTableClient As CloudTableClient
    32.     Dim eventTrackerTable As CloudTable
    33.  
    34.     Private Const TRACKER_TABLE_NAME As String = "EventTracker"
    35.  
    36.     Private Sub CreateTableIfNotExists()
    37.         If (applicationTableClient IsNot Nothing) Then
    38.             If (eventTrackerTable Is Nothing) Then
    39.                 eventTrackerTable = applicationTableClient.GetTableReference(TRACKER_TABLE_NAME)
    40.                 'if the table does not exist, create it
    41.                 eventTrackerTable.CreateIfNotExists()
    42.             End If
    43.         End If
    44.     End Sub
    45.  
    46.     Public Sub New(ByVal connectionString As String)
    47.         'Hook up the clound storage account
    48.         If (CloudStorageAccount.TryParse(connectionString, applicationStorageAccount)) Then
    49.             If (applicationStorageAccount IsNot Nothing) Then
    50.                 applicationTableClient = applicationStorageAccount.CreateCloudTableClient()
    51.                 CreateTableIfNotExists()
    52.             End If
    53.         End If
    54.     End Sub
    55.  
    56.     Public Function GetCurrentSequence(ByVal eventTableName As String, ByVal aggregateidentifier As String) As Long
    57.  
    58.         CreateTableIfNotExists()
    59.  
    60.         Dim query As TableOperation = TableOperation.Retrieve(eventTableName, aggregateidentifier)
    61.  
    62.  
    63.         Dim retreivedResult = eventTrackerTable.Execute(query)
    64.  
    65.         If (retreivedResult.Result IsNot Nothing) Then
    66.             Return DirectCast(retreivedResult.Result, DynamicTableEntity).Properties("Sequence").Int64Value
    67.         Else
    68.             Return 0L
    69.         End If
    70.     End Function
    71.  
    72.     Public Sub SetCurrentSequence(ByVal eventTableName As String, ByVal aggregateidentifier As String, ByVal currentSequence As Long)
    73.  
    74.         CreateTableIfNotExists()
    75.  
    76.         Dim newSeq As New AzureSequenceTrackerEntity(eventTableName, aggregateidentifier)
    77.         newSeq.Sequence = currentSequence
    78.  
    79.         Dim insertOperation As TableOperation = TableOperation.InsertOrReplace(newSeq)
    80.         eventTrackerTable.Execute(insertOperation)
    81.  
    82.     End Sub
    83.  
    84. End Class

  14. #14

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    The full source code thus far is up on the Azure storage area if anyone fancies a look?

  15. #15

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    Still more - the query handlers and the repositories they depend on are hooked up by Unity dependency injection:-

    vb.net Code:
    1. Imports Microsoft.Practices.Unity
    2. Imports TaskBazaar.Query
    3. Imports TaskBazaar.Repository
    4.  
    5. ''' <summary>
    6. ''' The concrete class that hooks up the query definition
    7. ''' with the class that is its handler
    8. ''' </summary>
    9. ''' <remarks>
    10. ''' This class performs the dependency injection so is
    11. ''' made "not inheritable" for sanity
    12. ''' </remarks>
    13. Public NotInheritable Class QueryHandler
    14.     Implements IDisposable
    15.  
    16.     Private Shared container As New Lazy(Of IUnityContainer)
    17.          (Function()
    18.               Dim container = New UnityContainer()
    19.                                 RegisterTypes(container)
    20.                                 Return container
    21.          End Function)
    22.  
    23.     ''' <summary>
    24.     ''' Register all of the types which implement the IQueryHandler interface
    25.     ''' or IRepositoryRead in the given unity container
    26.     ''' </summary>
    27.     ''' <param name="container">
    28.     ''' The unity container that will return concrete instances of these query handler classes
    29.     ''' </param>
    30.     ''' <remarks>
    31.     ''' The handlers are instantiated as per-thread singletons so that any data connections etc.
    32.     ''' they use are only
    33.     ''' disposed when the container is disposed but if required we can
    34.     ''' have multiple independent threads handling
    35.     ''' queries
    36.     ''' </remarks>
    37.   Public Shared Sub RegisterTypes(container As IUnityContainer)
    38.  
    39.     Dim interfaceType As Type = Nothing
    40.  
    41.     'Get the interface definition for the generic query handler interface
    42.     interfaceType = GetType(IQueryHandler(Of ,))
    43.  
    44.      container.RegisterTypes(AllClasses.FromAssembliesInBasePath(),
    45.                                 Function(i) WithName.Default(i),
    46.                                 Function(i) WithLifetime.PerThread(i))
    47.  
    48. 'and any repository handlers...
    49. ' we use the namespace to differentiate between back end technologies
    50.         interfaceType = GetType(IRepositoryRead(Of ,))
    51.  
    52.         container.RegisterTypes(AllClasses.FromAssembliesInBasePath().
    53.              Where(Function(z) z.Namespace.Contains("Azure")),
    54.                                 Function(i) WithName.Default(i),
    55.                                 Function(i) WithLifetime.PerThread(i))
    56.  
    57.     End Sub
    Last edited by Merrion; Apr 14th, 2014 at 07:53 AM. Reason: formatting...

  16. #16

    Thread Starter
    PowerPoster
    Join Date
    Jul 2002
    Location
    Dublin, Ireland
    Posts
    2,148

    Re: An Azure learning app - "Task Bazaar"

    ..and the events that are held in the EventStore all inherit from a base class:-

    VB Code:
    1. ''' <summary>
    2.     ''' Base class for all events - allows for a common synchronising property
    3.     ''' </summary>
    4.     Public MustInherit Class EventBase
    5.  
    6.         ''' <summary>
    7.         ''' The creation timestamp of this event
    8.         ''' </summary>
    9.         ''' <remarks>
    10.         ''' This allows event streams to be combined in a synchronised fashion for
    11.         ''' multi-aggregate snapshots
    12.         ''' </remarks>
    13.         Public Property SynchronisationStamp As Long
    14.  
    15.         Public Sub New()
    16.             'Default the synchronisation stamp to now
    17.             SynchronisationStamp = DateTime.UtcNow.Ticks
    18.         End Sub
    19.  
    20.         ''' <summary>
    21.         ''' Force derived classes to override ToString
    22.         ''' </summary>
    23.         Public MustOverride Overrides Function ToString() As String
    24.  
    25.     End Class

    In my application there is no need at all to synchronise below milliseconds but if there were then some more elaborate method would be needed.

    An example of an event that derives from this can then look like:
    VB Code:
    1. ''' <summary>
    2.         ''' A new client was created
    3.         ''' </summary>
    4.         ''' <remarks>
    5.         ''' This event has a client code and a client name
    6.         ''' </remarks>
    7.         Public Class CreatedEvent
    8.             Inherits EventBase
    9.             Implements IEvent(Of AggregateIdentifiers.ClientAggregateIdentity)
    10.  
    11.             ''' <summary>
    12.             ''' The unique identifier of the client that was created
    13.             ''' </summary>
    14.             <EventVersion(1)>
    15.             Public Property ClientCode As String
    16.  
    17.             ''' <summary>
    18.             ''' The display name of the client
    19.             ''' </summary>
    20.             <EventVersion(1)>
    21.             Public Property ClientName As String
    22.  
    23.             Public Overrides Function ToString() As String
    24.                 Return "Client was created - " & ClientName & " with the assigned code - " & ClientCode
    25.             End Function
    26.  
    27.         End Class

Tags for this Thread

Posting Permissions

  • You may not post new threads
  • You may not post replies
  • You may not post attachments
  • You may not edit your posts
  •  



Click Here to Expand Forum to Full Width