Building the web role for the Windows Azure Email Service application - 3 of 5.
This is the third tutorial in a series of five that show how to build and deploy the Windows Azure Email Service sample application. For information about the application and the tutorial series, see .
In this tutorial you'll learn:
- How to create a solution that contains a Cloud Service project with a web role and a worker role.
- How to work with Windows Azure tables, blobs, and queues in MVC 4 controllers and views.
- How to handle concurrency conflicts when you are working with Windows Azure tables.
- How to configure a web role or web project to use your Windows Azure Storage account.
Create solutionCreate the Visual Studio solution
You begin by creating a Visual Studio solution with a project for the web front-end and a project for one of the back-end Windows Azure worker roles. You'll add the second worker role later.
(If you want to run the web UI in a Windows Azure Web Site instead of a Windows Azure Cloud Service, see the section later in this tutorial for changes to these instructions.)
Create a cloud service project with a web role and a worker role
-
Start Visual Studio 2012 or Visual Studio 2012 for Web Express, with administrative privileges.
The Windows Azure compute emulator which enables you to test your cloud project locally requires administrative privileges.
-
From the File menu select New Project.
-
Expand C# and select Cloud under Installed Templates, and then select Windows Azure Cloud Service.
-
Name the application AzureEmailService and click OK.
-
In the New Windows Azure Cloud Service dialog box, select ASP.NET MVC 4 Web Role and click the arrow that points to the right.
-
In the column on the right, hover the pointer over MvcWebRole1, and then click the pencil icon to change the name of the web role.
-
Enter MvcWebRole as the new name, and then press Enter.
-
Follow the same procedure to add a Worker Role, name it WorkerRoleA, and then click OK.
-
In the New ASP.NET MVC 4 Project dialog box, select the Internet Application template.
-
In the View Engine drop-down list make sure that Razor is selected, and then click OK.
Set the page header, menu, and footer
In this section you update the headers, footers, and menu items that are shown on every page for the administrator web UI. The application will have three sets of administrator web pages: one for Mailing Lists, one for Subscribers to mailing lists, and one for Messages.
-
In Solution Explorer, expand the Views\Shared folder and open the _Layout.cshtml file.
-
In the <title> element, change "My ASP.NET MVC Application" to "Windows Azure Email Service".
-
In the <p> element with class "site-title", change "your logo here" to "Windows Azure Email Service", and change "Home" to "MailingList".
-
Delete the menu section:
-
Insert a new menu section where the old one was:
- @Html.ActionLink("Mailing Lists", "Index", "MailingList")
- @Html.ActionLink("Messages", "Index", "Message")
- @Html.ActionLink("Subscribers", "Index", "Subscriber")
-
In the <footer> element, change "My ASP.NET MVC Application" to "Windows Azure Email Service".
Run the application locally
-
Press CTRL+F5 to run the application.
The application home page appears in the default browser.
The application runs in the Windows Azure compute emulator. You can see the compute emulator icon in the Windows system tray:
![Compute emulator in system tray][mtas-compute-emulator-icon]
Configure TracingConfigure Tracing
To enable tracing data to be saved, open the WebRole.cs file and add the following ConfigureDiagnostics
method. Add code that calls the new method in the OnStart
method.
privatevoidConfigureDiagnostics(){ DiagnosticMonitorConfiguration config =DiagnosticMonitor.GetDefaultInitialConfiguration(); config.Logs.BufferQuotaInMB=500; config.Logs.ScheduledTransferLogLevelFilter=LogLevel.Verbose; config.Logs.ScheduledTransferPeriod=TimeSpan.FromMinutes(1d);DiagnosticMonitor.Start("Microsoft.WindowsAzure.Plugins.Diagnostics.ConnectionString", config);}publicoverrideboolOnStart(){ ConfigureDiagnostics();returnbase.OnStart();}
The ConfigureDiagnostics
method is explained in .
RestartsAdd code to efficiently handle restarts.
Windows Azure Cloud Service applications are restarted approximately twice per month for operating system updates. (For more information on OS updates, see .) When a web application is going to be shut down, an OnStop
event is raised. The web role boiler plate created by Visual Studio does not override the OnStop
method, so the application will have only a few seconds to finish processing HTTP requests before it is shut down. You can add code to override the OnStop
method in order to ensure that shutdowns are handled gracefully.
To handle shutdowns and restarts, open the WebRole.cs file and add the following OnStop
method override.
publicoverridevoidOnStop(){ Trace.TraceInformation("OnStop called from WebRole");var rcCounter =newPerformanceCounter("ASP.NET","Requests Current","");while(rcCounter.NextValue()>0){ Trace.TraceInformation("ASP.NET Requests Current = "+ rcCounter.NextValue().ToString());System.Threading.Thread.Sleep(1000);}}
This code requires an additional using
statement:
usingSystem.Diagnostics;
The OnStop
method has up to 5 minutes to exit before the application is shut down. You could add a sleep call for 5 minutes to the OnStop
method to give your application the maximum amount of time to process the current requests, but if your application is scaled correctly, it should be able to process the remaining requests in much less than 5 minutes. It is best to stop as quickly as possible, so that the application can restart as quickly as possible and continue processing requests.
Once a role is taken off-line by Windows Azure, the load balancer stops sending requests to the role instance, and after that the OnStop
method is called. If you don't have another instance of your role, no requests will be processed until your role completes shutting down and is restarted (which typically takes several minutes). That is one reason why the Windows Azure service level agreement requires you to have at least two instances of each role in order to take advantage of the up-time guarantee.
In the code shown for the OnStop
method, an ASP.NET performance counter is created for Requests Current
. The Requests Current
counter value contains the current number of requests, including those that are queued, currently executing, or waiting to be written to the client. The Requests Current
value is checked every second, and once it falls to zero, the OnStop
method returns. Once OnStop
returns, the role shuts down.
Trace data is not saved when called from the OnStop
method without performing an . You can view the OnStop
trace information in real time with the utility from a remote desktop connection.
Update Storage Client LibraryUpdate the Storage Client Library NuGet Package
The API framework that you use to work with Windows Azure Storage tables, queues, and blobs is the Storage Client Library (SCL). This API is included in a NuGet package in the Cloud Service project template. However, as of the date this tutorial is being written, the project templates include the 1.7 version of SCL, not the current 2.0 version. Therefore, before you begin writing code you'll update the NuGet package.
-
In the Visual Studio Tools menu, hover over Library Package Manager, and then click Manage NuGet Packages for Solution.
-
In the left pane of the Manage NuGet Packages dialog box, select Updates, then scroll down to the Windows Azure Storage package and click Update.
-
In the Select Projects dialog box, make sure both projects are selected, and then click OK.
-
Accept the license terms to complete installation of the package, and then close the Manage NuGet Packages dialog box.
-
In WorkerRoleA.cs in the WorkerRoleA project, delete the following
using
statement because it is no longer needed:usingMicrosoft.WindowsAzure.StorageClient;
The 1.7 version of the SCL includes a LINQ provider that simplifies coding for table queries. As of the date this tutorial is being written, the 2.0 Table Service Layer (TSL) does not yet have a LINQ provider. If you want to use LINQ, you still have access to the SCL 1.7 LINQ provider in the namespace. The 2.0 TSL was designed to improve performance, and the 1.7 LINQ provider does not benefit from all of these improvements. The sample application uses the 2.0 TSL, so it does not use LINQ for queries. For more information about SCL and TSL 2.0, see the resources at the end of .
Add SCL 1.7 referenceAdd a reference to an SCL 1.7 assembly
Version 2.0 of the Storage Client Library (SCL) 2.0 does not have everything needed for diagnostics, so you have to add a reference to a 1.7 assembly.
-
Right-click the MvcWebRole project, and choose Add Reference.
-
Click the Browse... button at the bottom of the dialog box.
-
Navigate to the following folder:
C:\Program Files\Microsoft SDKs\Windows Azure\.NET SDK\2012-10\ref
-
Select Microsoft.WindowsAzure.StorageClient.dll, and then click Add.
-
In the Reference Manager dialog box, click OK.
-
Repeat the process for the WorkerRoleA project.
App_Start CodeAdd code to create tables, queue, and blob container in the Application_Start method
The web application will use the MailingList
table, the Message
table, the azuremailsubscribequeue
queue, and the azuremailblobcontainer
blob container. You could create these manually by using a tool such as Azure Storage Explorer, but then you would have to do that manually every time you started to use the application with a new storage account. In this section you'll add code that runs when the application starts, checks if the required tables, queues, and blob containers exist, and creates them if they don't.
You could add this one-time startup code to the OnStart
method in the WebRole.cs file, or to the Global.asax file. For this tutorial you'll initialize Windows Azure Storage in the Global.asax file since that works with Windows Azure Web Sites as well as Windows Azure Cloud Service web roles.
-
In Solution Explorer, expand Global.asax and then open Global.asax.cs.
-
Add a new
CreateTablesQueuesBlobContainers
method after theApplication_Start
method, and then call the new method from theApplication_Start
method, as shown in the following example:protectedvoidApplication_Start(){ AreaRegistration.RegisterAllAreas();WebApiConfig.Register(GlobalConfiguration.Configuration);FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);RouteConfig.RegisterRoutes(RouteTable.Routes);BundleConfig.RegisterBundles(BundleTable.Bundles);AuthConfig.RegisterAuth();// Verify that all of the tables, queues, and blob containers used in this application// exist, and create any that don't already exist.CreateTablesQueuesBlobContainers();}privatestaticvoidCreateTablesQueuesBlobContainers(){ var storageAccount =CloudStorageAccount.Parse(RoleEnvironment.GetConfigurationSettingValue("StorageConnectionString"));// If this is running in a Windows Azure Web Site (not a Cloud Service) use the Web.config file:// var storageAccount = CloudStorageAccount.Parse(ConfigurationManager.ConnectionStrings["StorageConnectionString"].ConnectionString);var tableClient = storageAccount.CreateCloudTableClient();var mailingListTable = tableClient.GetTableReference("MailingList"); mailingListTable.CreateIfNotExists();var messageTable = tableClient.GetTableReference("Message"); messageTable.CreateIfNotExists();var blobClient = storageAccount.CreateCloudBlobClient();var blobContainer = blobClient.GetContainerReference("azuremailblobcontainer"); blobContainer.CreateIfNotExists();var queueClient = storageAccount.CreateCloudQueueClient();var subscribeQueue = queueClient.GetQueueReference("azuremailsubscribequeue"); subscribeQueue.CreateIfNotExists();}
-
Right click on the blue squiggly line under
RoleEnvironment
, select Resolve then select using Microsoft.WindowsAzure.ServiceRuntime. -
Right click the blue squiggly line under
CloudStorageAccount
, select Resolve, and then select using Microsoft.WindowsAzure.Storage. -
Alternatively, you can manually add the following using statements:
usingMicrosoft.WindowsAzure.ServiceRuntime;usingMicrosoft.WindowsAzure.Storage;
-
Build the application, which saves the file and verifies that you don't have any compile errors.
In the following sections you build the components of the web application, and you can test them with development storage or your storage account without having to manually create tables, queues, or blob container first.
Mailing ListCreate and test the Mailing List controller and views
The Mailing List web UI is used by administrators to create, edit and display mailing lists, such as "Contoso University History Department announcements" and "Fabrikam Engineering job postings".
Add the MailingList entity class to the Models folder
The MailingList
entity class is used for the rows in the MailingList
table that contain information about the list, such as its description and the "From" email address for emails sent to the list.
-
In Solution Explorer, right-click the
Models
folder in the MVC project, and choose Add Existing Item. -
Navigate to the folder where you downloaded the sample application, select the MailingList.cs file in the
Models
folder, and click Add. -
Open MailingList.cs and examine the code.
publicclassMailingList:TableEntity{ publicMailingList(){ this.RowKey="mailinglist";}[Required][RegularExpression(@"[\w]+",ErrorMessage=@"Only alphanumeric characters and underscore (_) are allowed.")][Display(Name="List Name")]publicstringListName{ get{ returnthis.PartitionKey;}set{ this.PartitionKey= value;}}[Required][Display(Name="'From' Email Address")]publicstringFromEmailAddress{ get;set;}publicstringDescription{ get;set;}}
The Windows Azure Storage TSL 2.0 API requires that the entity classes that you use for table operations derive from . This class defines
PartitionKey
,RowKey
,TimeStamp
, andETag
fields. TheTimeStamp
andETag
properties are used by the system. You'll see how theETag
property is used for concurrency handling later in the tutorial.(There is also a class for use when you want to work with table rows as Dictionary collections of key value pairs instead of by using predefined model classes. For more information, see .)
The
mailinglist
table partition key is the list name. In this entity class the partition key value can be accessed either by using thePartitionKey
property (defined in theTableEntity
class) or theListName
property (defined in theMailingList
class). TheListName
property usesPartitionKey
as its backing variable. Defining theListName
property enables you to use a more descriptive variable name in code and makes it easier to program the web UI, since formatting and validation DataAnnotations attributes can be added to theListName
property, but they can't be added directly to thePartitionKey
property.The
RegularExpression
attribute on theListName
property causes MVC to validate user input to ensure that the list name value entered only contains alphanumeric characters or underscores. This restriction was implemented in order to keep list names simple so that they can easily be used in query strings in URLs.Note: If you wanted the list name format to be less restrictive, you could allow other characters and URL-encode list names when they are used in query strings. However, certain characters are not allowed in Windows Azure Table partition keys or row keys, and you would have to exclude at least those characters. For information about characters that are not allowed or cause problems in the partition key or row key fields, see and .
The
MailingList
class defines a default constructor that setsRowKey
to the hard-coded string "mailinglist", because all of the mailing list rows in this table have that value as their row key. (For an explanation of the table structure, see the .) Any constant value could have been chosen for this purpose, as long as it could never be the same as an email address, which is the row key for the subscriber rows in this table.The list name and the "from" email address must always be entered when a new
MailingList
entity is created, so they haveRequired
attributes.The
Display
attributes specify the default caption to be used for a field in the MVC UI.
Add the MailingList MVC controller
-
In Solution Explorer, right-click the Controllers folder in the MVC project, and choose Add Existing Item.
-
Navigate to the folder where you downloaded the sample application, select the MailingListController.cs file in the
Controllers
folder, and click Add. -
Open MailingListController.cs and examine the code.
The default constructor creates a
CloudTable
object to use for working with themailinglist
table.publicclassMailingListController:Controller{ privateCloudTable mailingListTable;publicMailingListController(){ var storageAccount =Microsoft.WindowsAzure.Storage.CloudStorageAccount.Parse(RoleEnvironment.GetConfigurationSettingValue("StorageConnectionString"));// If this is running in a Windows Azure Web Site (not a Cloud Service) use the Web.config file:// var storageAccount = Microsoft.WindowsAzure.Storage.CloudStorageAccount.Parse(ConfigurationManager.ConnectionStrings["StorageConnectionString"].ConnectionString);var tableClient = storageAccount.CreateCloudTableClient(); mailingListTable = tableClient.GetTableReference("mailinglist");}
The code gets the credentials for your Windows Azure Storage account from the Cloud Service project settings file in order to make a connection to the storage account. (You'll configure those settings later in this tutorial, before you test the controller.) If you are going to run the MVC project in a Windows Azure Web Site, you can get the connection string from the Web.config file instead.
Next is a
FindRow
method that is called whenever the controller needs to look up a specific mailing list entry of theMailingList
table, for example to edit a mailing list entry. The code retrieves a singleMailingList
entity by using the partition key and row key values passed in to it. The rows that this controller edits are the ones that have "MailingList" as the row key, so "MailingList" could have been hard-coded for the row key, but specifying both partition key and row key is a pattern used for theFindRow
methods in all of the controllers.privateMailingListFindRow(string partitionKey,string rowKey){ var retrieveOperation =TableOperation.Retrieve
(partitionKey, rowKey);var retrievedResult = mailingListTable.Execute(retrieveOperation);var mailingList = retrievedResult.ResultasMailingList;if(mailingList ==null){ thrownewException("No mailing list found for: "+ partitionKey);}return mailingList;} It's instructive to compare the
FindRow
method in theMailingList
controller, which returns a mailing list row, with theFindRow
method in theSubscriber
controller, which returns a subscriber row from the samemailinglist
table.privateSubscriberFindRow(string partitionKey,string rowKey){ var retrieveOperation =TableOperation.Retrieve
(partitionKey, rowKey);var retrievedResult = mailingListTable.Execute(retrieveOperation);var subscriber = retrievedResult.ResultasSubscriber;if(subscriber ==null){ thrownewException("No subscriber found for: "+ partitionKey +", "+ rowKey);}return subscriber;} The only difference in the two queries is the model type that they pass to the method. The model type specifies the schema (the properties) of the row or rows that you expect the query to return. A single table may have different schemas in different rows. Typically you specify the same model type when reading a row that was used to create the row.
The Index page displays all of the mailing list rows, so the query in the
Index
method returns allMailingList
entities that have "mailinglist" as the row key (the other rows in the table have email address as the row key, and they contain subscriber information).var query =newTableQuery
().Where(TableQuery.GenerateFilterCondition("RowKey",QueryComparisons.Equal,"mailinglist")); lists = mailingListTable.ExecuteQuery(query, reqOptions).ToList(); The
Index
method surrounds this query with code that is designed to handle timeout conditions.publicActionResultIndex(){ TableRequestOptions reqOptions =newTableRequestOptions(){ MaximumExecutionTime=TimeSpan.FromSeconds(1.5),RetryPolicy=newLinearRetry(TimeSpan.FromSeconds(3),3)};List
lists;try{ var query =newTableQuery ().Where(TableQuery.GenerateFilterCondition("RowKey",QueryComparisons.Equal,"mailinglist")); lists = mailingListTable.ExecuteQuery(query, reqOptions).ToList();}catch(StorageException se){ ViewBag.errorMessage ="Timeout error, try again. ";Trace.TraceError(se.Message);returnView("Error");}returnView(lists);} If you don't specify timeout parameters, the API automatically retries three times with exponentially increasing timeout limits. For a web interface with a user waiting for a page to appear, this could result in unacceptably long wait times. Therefore, this code specifies linear retries (so the timeout limit doesn't increase each time) and a timeout limit that is reasonable for the user to wait.
When the user clicks the Create button on the Create page, the MVC model binder creates a
MailingList
entity from input entered in the view, and theHttpPost Create
method adds the entity to the table.[HttpPost][ValidateAntiForgeryToken]publicActionResultCreate(MailingList mailingList){ if(ModelState.IsValid){ var insertOperation =TableOperation.Insert(mailingList); mailingListTable.Execute(insertOperation);returnRedirectToAction("Index");}returnView(mailingList);}
For the Edit page, the
HttpGet Edit
method looks up the row, and theHttpPost
method updates the row.[HttpPost][ValidateAntiForgeryToken]publicActionResultEdit(string partitionKey,string rowKey,MailingList editedMailingList){ if(ModelState.IsValid){ var mailingList =newMailingList();UpdateModel(mailingList);try{ var replaceOperation =TableOperation.Replace(mailingList); mailingListTable.Execute(replaceOperation);returnRedirectToAction("Index");}catch(StorageException ex){ if(ex.RequestInformation.HttpStatusCode==412){ // Concurrency errorvar currentMailingList =FindRow(partitionKey, rowKey);if(currentMailingList.FromEmailAddress!= editedMailingList.FromEmailAddress){ ModelState.AddModelError("FromEmailAddress","Current value: "+ currentMailingList.FromEmailAddress);}if(currentMailingList.Description!= editedMailingList.Description){ ModelState.AddModelError("Description","Current value: "+ currentMailingList.Description);}ModelState.AddModelError(string.Empty,"The record you attempted to edit "+"was modified by another user after you got the original value. The "+"edit operation was canceled and the current values in the database "+"have been displayed. If you still want to edit this record, click "+"the Save button again. Otherwise click the Back to List hyperlink.");ModelState.SetModelValue("ETag",newValueProviderResult(currentMailingList.ETag, currentMailingList.ETag,null));}else{ throw;}}}returnView(editedMailingList);}
The try-catch block handles concurrency errors. A concurrency exception is raised if a user selects a mailing list for editing, then while the Edit page is displayed in the browser another user edits the same mailing list. When that happens, the code displays a warning message and indicates which fields were changed by the other user. The TSL API uses the
ETag
to check for concurrency conflicts. Every time a table row is updated, theETag
value is changed. When you get a row to edit, you save theETag
value, and when you execute an update or delete operation you pass in theETag
value that you saved. (TheEdit
view has a hidden field for the ETag value.) If the update operation finds that theETag
value on the record you are updating is different than theETag
value that you passed in to the update operation, it raises a concurrency exception. If you don't care about concurrency conflicts, you can set the ETag field to an asterisk ("*") in the entity that you pass in to the update operation, and conflicts are ignored.Note: The HTTP 412 error is not unique to concurrency errors. It can be raised for other errors by the SCL API.
For the Delete page, the
HttpGet Delete
method looks up the row in order to display its contents, and theHttpPost
method deletes theMailingList
row along with anySubscriber
rows that are associated with it in theMailingList
table.[HttpPost,ActionName("Delete")][ValidateAntiForgeryToken]publicActionResultDeleteConfirmed(string partitionKey){ // Delete all rows for this mailing list, that is, // Subscriber rows as well as MailingList rows.// Therefore, no need to specify row key.var query =newTableQuery
().Where(TableQuery.GenerateFilterCondition("PartitionKey",QueryComparisons.Equal, partitionKey));var listRows = mailingListTable.ExecuteQuery(query).ToList();var batchOperation =newTableBatchOperation();int itemsInBatch =0;foreach(MailingList listRow in listRows){ batchOperation.Delete(listRow); itemsInBatch++;if(itemsInBatch ==100){ mailingListTable.ExecuteBatch(batchOperation); itemsInBatch =0; batchOperation =newTableBatchOperation();}}if(itemsInBatch >0){ mailingListTable.ExecuteBatch(batchOperation);}returnRedirectToAction("Index");} In case a large number of subscribers need to be deleted, the code deletes the records in batches. The transaction cost of deleting one row is the same as deleting 100 rows in a batch. The maximum number of operations that you can perform in one batch is 100.
Although the loop processes both
MailingList
rows andSubscriber
rows, it reads them all into theMailingList
entity class because the only fields needed for theDelete
operation are thePartitionKey
,RowKey
, andETag
fields.
Add the MailingList MVC views
-
In Solution Explorer, create a new folder under the Views folder in the MVC project, and name it MailingList.
-
Right-click the new Views\MailingList folder, and choose Add Existing Item.
-
Navigate to the folder where you downloaded the sample application, select all four of the .cshtml files in the Views\MailingList folder, and click Add.
-
Open the Edit.cshtml file and examine the code.
@modelMvcWebRole.Models.MailingList@{ ViewBag.Title="Edit Mailing List";}
}EditMailingList
@using (Html.BeginForm()) { @Html.AntiForgeryToken() @Html.ValidationSummary(true) @Html.HiddenFor(model => model.ETag)@Html.ActionLink("Back to List","Index")@section Scripts { @Scripts.Render("~/bundles/jqueryval")}This code is typical for MVC views. Notice the hidden field that is included to preserve the
ETag
value which is used for handling concurrency conflicts. Notice also that theListName
field has aDisplayFor
helper instead of anEditorFor
helper. We didn't enable the Edit page to change the list name, because that would have required complex code in the controller: theHttpPost Edit
method would have had to delete the existing mailing list row and all associated subscriber rows, and re-insert them all with the new key value. In a production application you might decide that the additional complexity is worthwhile. As you'll see later, theSubscriber
controller does allow list name changes, since only one row at a time is affected.The Create.cshtml and Delete.cshtml code is similar to Edit.cshtml.
-
Open Index.cshtml and examine the code.
@modelIEnumerable
@{ ViewBag.Title="Mailing Lists";} MailingLists
@Html.ActionLink("Create New", "Create")
@Html.DisplayNameFor(model => model.ListName) @Html.DisplayNameFor(model => model.Description) @Html.DisplayNameFor(model => model.FromEmailAddress) @Html.DisplayFor(modelItem => item.ListName) @Html.DisplayFor(modelItem => item.Description) @Html.DisplayFor(modelItem => item.FromEmailAddress) @Html.ActionLink("Edit","Edit",new{ PartitionKey= item.PartitionKey,RowKey=item.RowKey})|@Html.ActionLink("Delete","Delete",new{ PartitionKey= item.PartitionKey,RowKey=item.RowKey}) This code is also typical for MVC views. The Edit and Delete hyperlinks specify partition key and row key query string parameters in order to identify a specific row. For
MailingList
entities only the partition key is actually needed since row key is always "MailingList", but both are kept so that the MVC view code is consistent across all controllers and views.
Make MailingList the default controller
-
Open Route.config.cs in the App_Start folder.
-
In the line that specifies defaults, change the default controller from "Home" to "MailingList".
routes.MapRoute( name:"Default", url:"{controller}/{action}/{id}", defaults:new{ controller ="MailingList", action ="Index", id =UrlParameter.Optional}
Configure storageConfigure the web role to use your test Windows Azure Storage account
You are going to enter settings for your test storage account, which you will use while running the project locally. To add a new setting you have to add it for both cloud and local, but you can change the cloud value later. You'll add the same settings for worker role A later.
(If you want to run the web UI in a Windows Azure Web Site instead of a Windows Azure Cloud Service, see the section later in this tutorial for changes to these instructions.)
-
In Solution Explorer, right-click MvcWebRole under Roles in the AzureEmailService cloud project, and then choose Properties.
-
Make sure that All Configurations is selected in the Service Configuration drop-down list.
-
Select the Settings tab and then click Add Setting.
-
Enter "StorageConnectionString" in the Name column.
-
Select Connection String in the Type drop-down list.
-
Click the ellipsis (...) button at the right end of the line to open the Storage Account Connection String dialog box.
-
In the Create Storage Connection String dialog, click the Your subscription radio button, and then click the Download Publish Settings link.
Note: If you configured storage settings for tutorial 2 and you're doing this tutorial on the same machine, you don't have to download the settings again, you just have to click Your subscription and then choose the correct Subscription and Account Name.
When you click the Download Publish Settings link, Visual Studio launches a new instance of your default browser with the URL for the Windows Azure Management Portal download publish settings page. If you are not logged into the portal, you are prompted to log in. Once you are logged in your browser prompts you to save the publish settings. Make a note of where you save the settings.
-
In the Create Storage Connection String dialog, click Import, and then navigate to the publish settings file that you saved in the previous step.
-
Select the subscription and storage account that you wish to use, and then click OK.
-
Follow the same procedure that you used for the
StorageConnectionString
connection string to set theMicrosoft.WindowsAzure.Plugins.Diagnostics.ConnectionString
connection string.You don't have to download the publish settings file again. When you click the ellipsis for the
Microsoft.WindowsAzure.Plugins.Diagnostics.ConnectionString
connection string, you'll find that the Create Storage Connection String dialog box remembers your subscription information. When you click the Your subscription radio button, all you have to do is select the same Subscription and Account Name that you selected earlier, and then click OK. -
Follow the same procedure that you used for the two connection strings for the MvcWebRole role to set the connection strings for the WorkerRoleA role.
When you added a new setting with the Add Settings button, the new setting was added to the XML in the ServiceDefinition.csdf file and in each of the two .cscfg configuration files. The following XML is added by Visual Studio to the ServiceDefinition.csdf file.
The following XML is added to each .cscfg configuration file.
You can manually add settings to the ServiceDefinition.csdf file and the two .cscfg configuration files, but using the properties editor has the following advantages for connection strings:
- You only add the new setting in one place, and the correct setting XML is added to all three files.
-
The correct XML is generated for the three settings files. The ServiceDefinition.csdf file defines settings that must be in each .cscfg configuration file. If the ServiceDefinition.csdf file and the two .cscfg configuration files settings are inconsistent, you can get the following error message from Visual Studio: "The current service model is out of sync. Make sure both the service configuration and definition files are valid."
If you get this error, the properties editor will not work until you resolve the inconsistency problem.
Test the application
-
Run the project by pressing CTRL+F5.
-
Use the Create function to add some mailing lists, and try the Edit and Delete functions to make sure they work.
SubscriberCreate and test the Subscriber controller and views
The Subscriber web UI is used by administrators to add new subscribers to a mailing list, and to edit, display, and delete existing subscribers.
Add the Subscriber entity class to the Models folder
The Subscriber
entity class is used for the rows in the MailingList
table that contain information about subscribers to a list. These rows contain information such as the person's email address and whether the address is verified.
-
In Solution Explorer, right-click the Models folder in the MVC project, and choose Add Existing Item.
-
Navigate to the folder where you downloaded the sample application, select the Subscriber.cs file in the Models folder, and click Add.
-
Open Subscriber.cs and examine the code.
publicclassSubscriber:TableEntity{ [Required]publicstringListName{ get{ returnthis.PartitionKey;}set{ this.PartitionKey= value;}}[Required][Display(Name="Email Address")]publicstringEmailAddress{ get{ returnthis.RowKey;}set{ this.RowKey= value;}}publicstringSubscriberGUID{ get;set;}publicbool?Verified{ get;set;}}
Like the
MailingList
entity class, theSubscriber
entity class is used to read and write rows in themailinglist
table.Subscriber
rows use the email address instead of the constant "mailinglist" for the row key. (For an explanation of the table structure, see the .) Therefore anEmailAddress
property is defined that uses theRowKey
property as its backing field, the same way thatListName
usesPartitionKey
as its backing field. As explained earlier, this enables you to put formatting and validation DataAnnotations attributes on the properties.The
SubscriberGUID
value is generated when aSubscriber
entity is created. It is used in subscribe and unsubscribe links to help ensure that only authorized persons can subscribe or unsubscribe email addresses.When a row is initially created for a new subscriber, the
Verified
value isfalse
. TheVerified
value changes totrue
only after the new subscriber clicks the Confirm hyperlink in the welcome email. If a message is sent to a list while a subscriber hasVerified
=false
, no email is sent to that subscriber.The
Verified
property in theSubscriber
entity is defined as nullable. When you specify that a query should returnSubscriber
entities, it is possible that some of the retrieved rows might not have aVerified
property. Therefore theSubscriber
entity defines itsVerified
property as nullable so that it can more accurately reflect the actual content of a row if table rows that don't have a Verified property are returned by a query. You might be accustomed to working with SQL Server tables, in which every row of a table has the same schema. In a Windows Azure Storage table, each row is just a collection of properties, and each row can have a different set of properties. For example, in the Windows Azure Email Service sample application, rows that have "MailingList" as the row key don't have aVerified
property. If a query returns a table row that doesn't have aVerified
property, when theSubscriber
entity class is instantiated, theVerified
property in the entity object will be null. If the property were not nullable, you would get the same value offalse
for rows that haveVerified
=false
and for rows that don't have aVerified
property at all. Therefore, a best practice for working with Windows Azure Tables is to make each property of an entity class nullable in order to accurately read rows that were created by using different entity classes or different versions of the current entity class.
Add the Subscriber MVC controller
-
In Solution Explorer, right-click the Controllers folder in the MVC project, and choose Add Existing Item.
-
Navigate to the folder where you downloaded the sample application, select the SubscriberController.cs file in the Controllers folder, and click Add. (Make sure that you get Subscriber.cs and not Subscribe.cs; you'll add Subscribe.cs later.)
-
Open SubscriberController.cs and examine the code.
Most of the code in this controller is similar to what you saw in the
MailingList
controller. Even the table name is the same because subscriber information is kept in theMailingList
table. After theFindRow
method you see aGetListNames
method. This method gets the data for a drop-down list on the Create and Edit pages, from which you can select the mailing list to subscribe an email address to.privateList
GetListNames(){ var query =(newTableQuery ().Where(TableQuery.GenerateFilterCondition("RowKey",QueryComparisons.Equal,"mailinglist")));var lists = mailingListTable.ExecuteQuery(query).ToList();return lists;} This is the same query you saw in the
MailingList
controller. For the drop-down list you want rows that have information about mailing lists, so you select only those that have RowKey = "mailinglist".For the method that retrieves data for the Index page, you want rows that have subscriber information, so you select all rows that do not have RowKey = "MailingList".
publicActionResultIndex(){ var query =(newTableQuery
().Where(TableQuery.GenerateFilterCondition("RowKey",QueryComparisons.NotEqual,"mailinglist")));var subscribers = mailingListTable.ExecuteQuery(query).ToList();returnView(subscribers);} Notice that the query specifies that data will be read into
Subscriber
objects (by specifying<Subscriber>
) but the data will be read from themailinglist
table.Note: The number of subscribers could grow to be too large to handle this way in a single query. In a future release of the tutorial we hope to implement paging functionality and show how to handle continuation tokens. You need to handle continuation tokens when you execute queries that would return more than 1,000 rows: Windows Azure returns 1,000 rows and a continuation token that you use to execute another query that starts where the previous one left off. (Azure Storage Explorer does not handle continuation tokens; therefore its queries will not return more than 1,000 rows.) For more information about large result sets and continuation tokens, see and .
In the
HttpGet Create
method, you set up data for the drop-down list; and in theHttpPost
method, you set default values before saving the new entity.publicActionResultCreate(){ var lists =GetListNames();ViewBag.ListName=newSelectList(lists,"ListName","Description");var model =newSubscriber(){ Verified=false};returnView(model);}[HttpPost][ValidateAntiForgeryToken]publicActionResultCreate(Subscriber subscriber){ if(ModelState.IsValid){ subscriber.SubscriberGUID=Guid.NewGuid().ToString();if(subscriber.Verified.HasValue==false){ subscriber.Verified=false;}var insertOperation =TableOperation.Insert(subscriber); mailingListTable.Execute(insertOperation);returnRedirectToAction("Index");}var lists =GetListNames();ViewBag.ListName=newSelectList(lists,"ListName","Description", subscriber.ListName);returnView(subscriber);}
The
HttpPost Edit
page is more complex than what you saw in theMailingList
controller because theSubscriber
page enables you to change the list name or email address, both of which are key fields. If the user changes one of these fields, you have to delete the existing record and add a new one instead of updating the existing record. The following code shows the part of the edit method that handles the different procedures for key versus non-key changes:if(ModelState.IsValid){ try{ UpdateModel(editedSubscriber,string.Empty,null, excludeProperties);if(editedSubscriber.PartitionKey== partitionKey && editedSubscriber.RowKey== rowKey){ //Keys didn't change -- Update the rowvar replaceOperation =TableOperation.Replace(editedSubscriber); mailingListTable.Execute(replaceOperation);}else{ // Keys changed, delete the old record and insert the new one.if(editedSubscriber.PartitionKey!= partitionKey){ // PartitionKey changed, can't do delete/insert in a batch.var deleteOperation =TableOperation.Delete(newSubscriber{ PartitionKey= partitionKey,RowKey= rowKey,ETag= editedSubscriber.ETag}); mailingListTable.Execute(deleteOperation);var insertOperation =TableOperation.Insert(editedSubscriber); mailingListTable.Execute(insertOperation);}else{ // RowKey changed, do delete/insert in a batch.var batchOperation =newTableBatchOperation(); batchOperation.Delete(newSubscriber{ PartitionKey= partitionKey,RowKey= rowKey,ETag= editedSubscriber.ETag}); batchOperation.Insert(editedSubscriber); mailingListTable.ExecuteBatch(batchOperation);}}returnRedirectToAction("Index");
The parameters that the MVC model binder passes to the
Edit
method include the original list name and email address values (in thepartitionKey
androwKey
parameters) and the values entered by the user (in thelistName
andemailAddress
parameters):publicActionResultEdit(string partitionKey,string rowKey,string listName,string emailAddress)
The parameters passed to the
UpdateModel
method excludePartitionKey
andRowKey
properties from model binding:var excludeProperties =newstring[]{ "PartitionKey","RowKey"};
The reason for this is that the
ListName
andEmailAddress
properties usePartitionKey
andRowKey
as their backing properties, and the user might have changed one of these values. When the model binder updates the model by setting theListName
property, thePartitionKey
property is automatically updated. If the model binder were to update thePartitionKey
property with that property's original value after updating theListName
property, it would overwrite the new value that was set by theListName
property. TheEmailAddress
property automatically updates theRowKey
property in the same way.After updating the
editedSubscriber
model object, the code then determines whether the partition key or row key was changed. If either key value changed, the existing subscriber row has to be deleted and a new one inserted. If only the row key changed, the deletion and insertion can be done in an atomic batch transaction.Notice that the code creates a new entity to pass in to the
Delete
operation:// RowKey changed, do delete/insert in a batch.var batchOperation =newTableBatchOperation(); batchOperation.Delete(newSubscriber{ PartitionKey= partitionKey,RowKey= rowKey,ETag= editedSubscriber.ETag}); batchOperation.Insert(editedSubscriber); mailingListTable.ExecuteBatch(batchOperation);
Entities that you pass in to operations in a batch must be distinct entities. For example, you can't create a
Subscriber
entity, pass it in to aDelete
operation, then change a value in the sameSubscriber
entity and pass it in to anInsert
operation. If you did that, the state of the entity after the property change would be in effect for both the Delete and the Insert operation.Note: Operations in a batch must all be on the same partition. Because a change to the list name changes the partition key, it can't be done in a transaction.
Add the Subscriber MVC views
-
In Solution Explorer, create a new folder under the Views folder in the MVC project, and name it Subscriber.
-
Right-click the new Views\Subscriber folder, and choose Add Existing Item.
-
Navigate to the folder where you downloaded the sample application, select all five of the .cshtml files in the Views\Subscriber folder, and click Add.
-
Open the Edit.cshtml file and examine the code.
@modelMvcWebRole.Models.Subscriber@{ ViewBag.Title="Edit Subscriber";}
}EditSubscriber
@using (Html.BeginForm()) { @Html.AntiForgeryToken() @Html.ValidationSummary(true) @Html.HiddenFor(model => model.SubscriberGUID) @Html.HiddenFor(model => model.ETag)@Html.ActionLink("Back to List","Index")@section Scripts { @Scripts.Render("~/bundles/jqueryval")}This code is similar to what you saw earlier for the
MailingList
Edit view. TheSubscriberGUID
value is not shown, so the value is not automatically provided in a form field for theHttpPost
controller method. Therefore, a hidden field is included in order to preserve this value.The other views contain code that is similar to what you already saw for the
MailingList
controller.
Test the application
-
Run the project by pressing CTRL+F5, and then click Subscribers.
-
Use the Create function to add some mailing lists, and try the Edit and Delete functions to make sure they work.
MessageCreate and test the Message controller and views
The Message web UI is used by administrators to create, edit, and display information about messages that are scheduled to be sent to mailing lists.
Add the Message entity class to the Models folder
The Message
entity class is used for the rows in the Message
table that contain information about a message that is scheduled to be sent to a list. These rows include information such as the subject line, the list to send a message to, and the scheduled date to send it.
-
In Solution Explorer, right-click the Models folder in the MVC project, and choose Add Existing Item.
-
Navigate to the folder where you downloaded the sample application, select the Message.cs file in the Models folder, and click Add.
-
Open Message.cs and examine the code.
publicclassMessage:TableEntity{ privateDateTime? _scheduledDate;privatelong _messageRef;publicMessage(){ this.MessageRef=DateTime.Now.Ticks;this.Status="Pending";}[Required][Display(Name="Scheduled Date")]// DataType.Date shows Date only (not time) and allows easy hook-up of jQuery DatePicker[DataType(DataType.Date)]publicDateTime?ScheduledDate{ get{ return _scheduledDate;}set{ _scheduledDate = value;this.PartitionKey= value.Value.ToString("yyyy-MM-dd");}}publiclongMessageRef{ get{ return _messageRef;}set{ _messageRef = value;this.RowKey="message"+ value.ToString();}}[Required][Display(Name="List Name")]publicstringListName{ get;set;}[Required][Display(Name="Subject Line")]publicstringSubjectLine{ get;set;}// Pending, Queuing, Processing, CompletepublicstringStatus{ get;set;}}
The
Message
class defines a default constructor that sets theMessageRef
property to a unique value for the message. Since this value is part of the row key, the setter for theMessageRef
property automatically sets theRowKey
property also. TheMessageRef
property setter concatenates the "message" literal and theMessageRef
value and puts that in theRowKey
property.The
MessageRef
value is created by getting theTicks
value fromDateTime.Now
. This ensures that by default when displaying messages in the web UI they will be displayed in the order in which they were created for a given scheduled date (ScheduledDate
is the partition key). You could use a GUID to make message rows unique, but then the default retrieval order would be random.The default constructor also sets default status of Pending for new
message
rows.For more information about the
Message
table structure, see the .
Add the Message MVC controller
-
In Solution Explorer, right-click the Controllers folder in the MVC project, and choose Add Existing Item.
-
Navigate to the folder where you downloaded the sample application, select the MessageController.cs file in the Controllers folder, and click Add.
-
Open MessageController.cs and examine the code.
Most of the code in this controller is similar to what you saw in the
Subscriber
controller. What is new here is code for working with blobs. For each message, the HTML and plain text content of the email is uploaded in the form of .htm and .txt files and stored in blobs.Blobs are stored in blob containers. The Windows Azure Email Service application stores all of its blobs in a single blob container named "azuremailblobcontainer", and code in the controller constructor gets a reference to this blob container:
publicclassMessageController:Controller{ privateTableServiceContext serviceContext;privatestaticCloudBlobContainer blobContainer;publicMessageController(){ var storageAccount =CloudStorageAccount.Parse(RoleEnvironment.GetConfigurationSettingValue("StorageConnectionString"));// If this is running in a Windows Azure Web Site (not a Cloud Service) use the Web.config file:// var storageAccount = CloudStorageAccount.Parse(ConfigurationManager.ConnectionStrings["StorageConnectionString"].ConnectionString);// Get context object for working with tables and a reference to the blob container.var tableClient = storageAccount.CreateCloudTableClient();serviceContext = tableClient.GetTableServiceContext();var blobClient = storageAccount.CreateCloudBlobClient(); blobContainer = blobClient.GetContainerReference("azuremailblobcontainer");}
For each file that a user selects to upload, the MVC view provides an
HttpPostedFile
object that contains information about the file. When the user creates a new message, theHttpPostedFile
object is used to save the file to a blob. When the user edits a message, the user can choose to upload a replacement file or leave the blob unchanged.The controller includes a method that the
HttpPost Create
andHttpPost Edit
methods call to save a blob:privatevoidSaveBlob(string blobName,HttpPostedFileBase httpPostedFile){ // Retrieve reference to a blob. CloudBlockBlob blob = blobContainer.GetBlockBlobReference(blobName);// Create the blob or overwrite the existing blob by uploading a local file.using(var fileStream = httpPostedFile.InputStream){ blob.UploadFromStream(fileStream);}}
The
HttpPost Create
method saves the two blobs and then adds theMessage
table row. Blobs are named by concatenating theMessageRef
value with the file name extension ".htm" or ".txt".[HttpPost][ValidateAntiForgeryToken]publicActionResultCreate(Message message,HttpPostedFileBase file,HttpPostedFileBase txtFile){ if(file ==null){ ModelState.AddModelError(string.Empty,"Please provide an HTML file path");}if(txtFile ==null){ ModelState.AddModelError(string.Empty,"Please provide a Text file path");}if(ModelState.IsValid){ SaveBlob(message.MessageRef+".htm", file);SaveBlob(message.MessageRef+".txt", txtFile);var insertOperation =TableOperation.Insert(message); messageTable.Execute(insertOperation);returnRedirectToAction("Index");}var lists =GetListNames();ViewBag.ListName=newSelectList(lists,"ListName","Description");returnView(message);}
The
HttpGet Edit
method validates that the retrieved message is inPending
status so that the user can't change a message once worker role B has begun processing it. Similar code is in theHttpPost Edit
method and theDelete
andDeleteConfirmed
methods.publicActionResultEdit(string partitionKey,string rowKey){ var message =FindRow(partitionKey, rowKey);if(message.Status!="Pending"){ thrownewException("Message can't be edited because it isn't in Pending status.");}var lists =GetListNames();ViewBag.ListName=newSelectList(lists,"ListName","Description", message.ListName);returnView(message);}
In the
HttpPost Edit
method, the code saves a new blob only if the user chose to upload a new file. The following code omits the concurrency handling part of the method, which is the same as what you saw earlier for theMailingList
controller.[HttpPost][ValidateAntiForgeryToken]publicActionResultEdit(string partitionKey,string rowKey,Message editedMsg,DateTime scheduledDate,HttpPostedFileBase httpFile,HttpPostedFileBase txtFile){ if(ModelState.IsValid){ var excludePropLst =newList
(); excludePropLst.Add("PartitionKey"); excludePropLst.Add("RowKey");if(httpFile ==null){ // They didn't enter a path or navigate to a file, so don't update the file. excludePropLst.Add("HtmlPath");}else{ // They DID enter a path or navigate to a file, assume it's changed.SaveBlob(editedMsg.MessageRef+".htm", httpFile);}if(txtFile ==null){ excludePropLst.Add("TextPath");}else{ SaveBlob(editedMsg.MessageRef+".txt", txtFile);}string[] excludeProperties = excludePropLst.ToArray();try{ UpdateModel(editedMsg,string.Empty,null, excludeProperties);if(editedMsg.PartitionKey== partitionKey){ // Keys didn't change -- update the row.var replaceOperation =TableOperation.Replace(editedMsg); messageTable.Execute(replaceOperation);}else{ // Partition key changed -- delete and insert the row.// (Partition key has scheduled date which may be changed;// row key has MessageRef which does not change.)var deleteOperation =TableOperation.Delete(newMessage{ PartitionKey= partitionKey,RowKey= rowKey,ETag= editedMsg.ETag}); messageTable.Execute(deleteOperation);var insertOperation =TableOperation.Insert(editedMsg); messageTable.Execute(insertOperation);}returnRedirectToAction("Index");} If the scheduled date is changed, the partition key is changed, and a row has to be deleted and inserted. This can't be done in a transaction because it affects more than one partition.
The
HttpPost Delete
method deletes the blobs when it deletes the row in the table:[HttpPost,ActionName("Delete")]publicActionResultDeleteConfirmed(String partitionKey,string rowKey){ // Get the row again to make sure it's still in Pending status.var message =FindRow(partitionKey, rowKey);if(message.Status!="Pending"){ thrownewException("Message can't be deleted because it isn't in Pending status.");}DeleteBlob(message.MessageRef+".htm");DeleteBlob(message.MessageRef+".txt");var deleteOperation =TableOperation.Delete(message);messageTable.Execute(deleteOperation);returnRedirectToAction("Index");}privatevoidDeleteBlob(string blobName){ var blob = blobContainer.GetBlockBlobReference(blobName); blob.Delete();}
Add the Message MVC views
-
In Solution Explorer, create a new folder under the Views folder in the MVC project, and name it
Message
. -
Right-click the new Views\Message folder, and choose Add Existing Item.
-
Navigate to the folder where you downloaded the sample application, select all five of the .cshtml files in the Views\Message folder, and click Add.
-
Open the Edit.cshtml file and examine the code.
@modelMvcWebRole.Models.Message@{ ViewBag.Title="Edit Message";}
}EditMessage
@using (Html.BeginForm("Edit", "Message", FormMethod.Post, new { enctype = "multipart/form-data" })){ @Html.AntiForgeryToken() @Html.ValidationSummary(true) @Html.HiddenFor(model => model.ETag)@Html.ActionLink("Back to List", "Index")@section Scripts { @Scripts.Render("~/bundles/jqueryval")}The
HttpPost Edit
method needs the partition key and row key, so the code provides these in hidden fields. The hidden fields were not needed in theSubscriber
controller because (a) theListName
andEmailAddress
properties in theSubscriber
model update thePartitionKey
andRowKey
properties, and (b) theListName
andEmailAddress
properties were included withEditorFor
helpers in the Edit view. When the MVC model binder for theSubscriber
model updates theListName
property, thePartitionKey
property is automatically updated, and when the MVC model binder updates theEmailAddress
property in theSubscriber
model, theRowKey
property is automatically updated. In theMessage
model, the fields that map to partition key and row key are not editable fields, so they don't get set that way.A hidden field is also included for the
MessageRef
property. This is the same value as the partition key, but it is included in order to enable better code clarity in theHttpPost Edit
method. Including theMessageRef
hidden field enables the code in theHttpPost Edit
method to refer to theMessageRef
value by that name when it constructs file names for the blobs. -
Open the Index.cshtml file and examine the code.
@modelIEnumerable
@{ ViewBag.Title="Messages";} Messages
@Html.ActionLink("Create New", "Create")
@Html.DisplayNameFor(model => model.ListName) @Html.DisplayNameFor(model => model.SubjectLine) @Html.DisplayNameFor(model => model.ScheduledDate) @Html.DisplayNameFor(model => model.Status) @Html.DisplayFor(modelItem => item.ListName) @Html.DisplayFor(modelItem => item.SubjectLine) @Html.DisplayFor(modelItem => item.ScheduledDate) @item.Status @if(item.Status=="Pending"){ @Html.ActionLink("Edit","Edit",new{ PartitionKey= item.PartitionKey,RowKey= item.RowKey})@:|@Html.ActionLink("Delete","Delete",new{ PartitionKey= item.PartitionKey,RowKey= item.RowKey})@:|}@Html.ActionLink("Details","Details",new{ PartitionKey= item.PartitionKey,RowKey= item.RowKey}) A difference here from the other Index views is that the Edit and Delete links are shown only for messages that are in
Pending
status:@if(item.Status=="Pending"){ @Html.ActionLink("Edit","Edit",new{ PartitionKey= item.PartitionKey,RowKey= item.RowKey})@:|@Html.ActionLink("Delete","Delete",new{ PartitionKey= item.PartitionKey,RowKey= item.RowKey})@:|}
This helps prevent the user from making changes to a message after worker role A has begun to process it.
The other views contain code that is similar to the Edit view or the other views you saw for the other controllers.
Test the application
-
Run the project by pressing CTRL+F5, then click Messages.
-
Use the Create function to add some mailing lists, and try the Edit and Delete functions to make sure they work.
UnsubscribeCreate and test the Unsubscribe controller and view
Next, you'll implement the UI for the unsubscribe process.
Note: This tutorial only builds the controller for the unsubscribe process, not the subscribe process. As was explained in , the UI and service method for the subscription process have been left out until we implement appropriate security for the service method. Until then, you can use the Subscriber administrator pages to subscribe email addresses to lists.
Add the Unsubscribe view model to the Models folder
The UnsubscribeVM
view model is used to pass data between the Unsubscribe
controller and its view.
-
In Solution Explorer, right-click the
Models
folder in the MVC project, and choose Add Existing Item. -
Navigate to the folder where you downloaded the sample application, select the
UnsubscribeVM.cs
file in the Models folder, and click Add. -
Open
UnsubscribeVM.cs
and examine the code.publicclassUnsubscribeVM{ publicstringEmailAddress{ get;set;}publicstringListName{ get;set;}publicstringListDescription{ get;set;}publicstringSubscriberGUID{ get;set;}publicbool?Confirmed{ get;set;}}
Unsubscribe links contain the
SubscriberGUID
. That value is used to get the email address, list name, and list description from theMailingList
table. The view displays the email address and the description of the list that is to be unsubscribed from, and it displays a Confirm button that the user must click to complete the unsubscription process.
Add the Unsubscribe controller
-
In Solution Explorer, right-click the
Controllers
folder in the MVC project, and choose Add Existing Item. -
Navigate to the folder where you downloaded the sample application, select the UnsubscribeController.cs file in the Controllers folder, and click Add.
-
Open UnsubscribeController.cs and examine the code.
This controller has an
HttpGet Index
method that displays the initial unsubscribe page, and anHttpPost Index
method that processes the Confirm or Cancel button.The
HttpGet Index
method uses the GUID and list name in the query string to get theMailingList
table row for the subscriber. Then it puts all the information needed by the view into the view model and displays the Unsubscribe page. It sets theConfirmed
property to null in order to tell the view to display the initial version of the Unsubscribe page.publicActionResultIndex(string id,string listName){ if(string.IsNullOrEmpty(id)==true||string.IsNullOrEmpty(listName)){ ViewBag.errorMessage ="Empty subscriber ID or list name.";returnView("Error");}string filter =TableQuery.CombineFilters(TableQuery.GenerateFilterCondition("PartitionKey",QueryComparisons.Equal, listName),TableOperators.And,TableQuery.GenerateFilterCondition("SubscriberGUID",QueryComparisons.Equal, id));var query =newTableQuery
().Where(filter);var subscriber = mailingListTable.ExecuteQuery(query).ToList().Single();if(subscriber ==null){ ViewBag.Message="You are already unsubscribed";returnView("Message");}var unsubscribeVM =newUnsubscribeVM(); unsubscribeVM.EmailAddress=MaskEmail(subscriber.EmailAddress); unsubscribeVM.ListDescription=FindRow(subscriber.ListName,"mailinglist").Description; unsubscribeVM.SubscriberGUID= id; unsubscribeVM.Confirmed=null;returnView(unsubscribeVM);} Note: The SubscriberGUID is not in the partition key or row key, so the performance of this query will degrade as partition size (the number of email addresses in a mailing list) increases. For more information about alternatives to make this query more scalable, see .
The
HttpPost Index
method again uses the GUID and list name to get the subscriber information and populates the view model properties. Then, if the Confirm button was clicked, it deletes the subscriber row in theMailingList
table. If the Confirm button was pressed it also sets theConfirm
property totrue
, otherwise it sets theConfirm
property tofalse
. The value of theConfirm
property is what tells the view to display the confirmed or canceled version of the Unsubscribe page.[HttpPost][ValidateAntiForgeryToken]publicActionResultIndex(string subscriberGUID,string listName,string action){ string filter =TableQuery.CombineFilters(TableQuery.GenerateFilterCondition("PartitionKey",QueryComparisons.Equal, listName),TableOperators.And,TableQuery.GenerateFilterCondition("SubscriberGUID",QueryComparisons.Equal, subscriberGUID));var query =newTableQuery
().Where(filter);var subscriber = mailingListTable.ExecuteQuery(query).ToList().Single();var unsubscribeVM =newUnsubscribeVM();unsubscribeVM.EmailAddress=MaskEmail(subscriber.EmailAddress);unsubscribeVM.ListDescription=FindRow(subscriber.ListName,"mailinglist").Description;unsubscribeVM.SubscriberGUID= subscriberGUID;unsubscribeVM.Confirmed=false;if(action =="Confirm"){ unsubscribeVM.Confirmed=true;var deleteOperation =TableOperation.Delete(subscriber); mailingListTable.Execute(deleteOperation);}returnView(unsubscribeVM);}
Create the MVC views
-
In Solution Explorer, create a new folder under the Views folder in the MVC project, and name it Unsubscribe.
-
Right-click the new Views\Unsubscribe folder, and choose Add Existing Item.
-
Navigate to the folder where you downloaded the sample application, select the Index.cshtml file in the Views\Unsubscribe folder, and click Add.
-
Open the Index.cshtml file and examine the code.
@modelMvcWebRole.Models.UnsubscribeVM@{ ViewBag.Title="Unsubscribe";Layout=null;}
}@sectionScripts{ @Scripts.Render("~/bundles/jqueryval")}EmailListSubscriptionService
@using (Html.BeginForm()) { @Html.AntiForgeryToken() @Html.ValidationSummary(true)The
Layout = null
line specifies that the _Layout.cshtml file should not be used to display this page. The Unsubscribe page displays a very simple UI without the headers and footers that are used for the administrator pages.In the body of the page, the
Confirmed
property determines what will be displayed on the page: Confirm and Cancel buttons if the property is null, unsubscribe-confirmed message if the property is true, unsubscribe-canceled message if the property is false.
Test the application
-
Run the project by pressing CTRL-F5, and then click Subscribers.
-
Click Create and create a new subscriber for any mailing list that you created when you were testing earlier.
Leave the browser window open on the SubscribersIndex page.
-
Open Azure Storage Explorer, and then select your test storage account.
-
Click Tables under Storage Type, select the MailingList table, and then click Query.
-
Double-click the subscriber row that you added.
-
In the Edit Entity dialog box, select and copy the
SubscriberGUID
value. -
Switch back to your browser window. In the address bar of the browser, change "Subscriber" in the URL to "unsubscribe?ID=[guidvalue]&listName=[listname]" where [guidvalue] is the GUID that you copied from Azure Storage Explorer, and [listname] is the name of the mailing list. For example:
http://127.0.0.1/unsubscribe?ID=b7860242-7c2f-48fb-9d27-d18908ddc9aa&listName=contoso1
The version of the Unsubscribe page that asks for confirmation is displayed:
-
Click Confirm and you see confirmation that the email address has been unsubscribed.
-
Go back to the SubscribersIndex page to verify that the subscriber row is no longer there.
Alternative Architecture(Optional) Build the Alternative Architecture
The following changes to the instructions apply if you want to build the alternative architecture -- that is, running the web UI in a Windows Azure Web Site instead of a Windows Azure Cloud Service web role.
-
When you create the solution, create the ASP.NET MVC 4 Web Application project first, and then add to the solution a Windows Azure Cloud Service project with a worker role.
-
Store the Windows Azure Storage connection string in the Web.config file instead of the cloud service settings file. (This only works for Windows Azure Web Sites. If you try to use the Web.config file for the storage connection string in a Windows Azure Cloud Service web role, you'll get an HTTP 500 error.)
Add a new connection string named
StorageConnectionString
to the Web.config file, as shown in the following example:Get the values for the connection string from the : select the Storage tab and your storage account, and then click Manage keys at the bottom of the page.
-
Wherever you see
RoleEnvironment.GetConfigurationSettingValue("StorageConnectionString")
in the code, replace it withConfigurationManager.ConnectionStrings["StorageConnectionString"].ConnectionString
.
Next stepsNext steps
As explained in , we are not showing how to build the subscribe process in detail in this tutorial until we implement a shared secret to secure the ASP.NET Web API service method. However, the IP restriction also protects the service method and you can add the subscribe functionality by copying the following files from the downloaded project.
For the ASP.NET Web API service method:
- Controllers\SubscribeAPI.cs
For the web page that subscribers get when they click on the Confirm link in the email that is generated by the service method:
- Models\SubscribeVM.cs
- Controllers\SubscribeController.cs
- Views\Subscribe\Index.cshtml
In the you'll configure and program worker role A, the worker role that schedules emails.
For links to additional resources for working with Windows Azure Storage tables, queues, and blobs, see the end of .