|
|
|
ON
TARGET s o
f t w a r e www.targetsoft.com |
C# 2008 / ASP.NET 3.5
Data
Service to Silverlight
Revision 1.0
Copyright 2008
On Target Software All Rights
Reserved
Table
of Contents
As a
convenience, create a web.changes.config file
Add a
ListBox and a Button to supply dummy data
For this cookbook, I'll build a simple website implementing a data service and a Silverlight client, using a simple database.
The instructions are straightforward and there seems to be no reason to be concerned, much like driving a simple mountain road on a summer's day. A few turns here and there, no big deal. You hardly notice the lack of guardrails.
Now turn off your lights and drive the same road on a moonless night and it's a whole different story. I can't add any guardrails, but I hope to shed some light on the road ahead.
To that end, I've gone out of my way to cause problems, see what error messages were triggered, and seek out the solutions on the web.
I'm generally following the instructions given here
and here
with annotations that cover my situation and that resolve problems I've
encountered.
Before starting, it helps to know what you'll call the
different projects, namespaces and directories, so the names make sense for
their purpose and don't overlap where they would conflict. Because my domain name is targetsoft and I
want to write a Silverlight application named MetroSilver, these are my
choices:
Solution Targetsoft
Web application Targetsoft
Virtual directory Targetsoft
Namespace Targetsoft
Data service MetroData
Data service model MetroModel (Metro is short for metronome, for my timekeeper application)
Data service entities MetroEntities (this is the type in the Data Service)
Silverlight app MetroSilver
Before starting, I defined two of the tables that I'll have
in my SQL Server 2008 Express database.
These are MUser and MEvent. The
application will be a simple time-tracking utility where users sign on to
indicate the hours they’ve spent on different projects. As a test, I'll be displaying the list of
users, so I've entered four users in the database.
Create a new ASP.NET web application, with a new solution in a new directory. I'm adding this to SourceSafe as I'm a strong believer in using source control for any project with any longevity.
Since I'll be debugging with the live website on my real domain, I made a note in the default.aspx page that the website will be unavailable during experimentation. I also added four buttons which I'll use to test the service, the client, and local and remote access to the database. The buttons, for now, have no associated actions.
· Test under debugger – succeeds.
Publish to precompiled directory.
· Create a new folder called Precompiled that is a sister to the Targetsoft solution folder.
· Publish to that folder with these options:
· delete existing files prior to publish
· copy only files needed to run
· include files from App_Data (currently, there are none)
· Create a new virtual directory on the development machine, named Targetsoft. Did not change any of the default configurations.
· Test precompiled application – succeeds.
Publish to Infosaic shared host.
· Delete existing data related to existing Targetsoft website.
· Copy precompiled files to infosaic and test – succeeds.
To upload the data, I used the free copy of Core FTP.
You'll need a copy of the Database Publishing Wizard to produce a script capable of uploading the database to Infosaic. Version 1.1 is fine for the older versions of SQL Server and it's easy to find and download on the web. If you have SQL Server 2008, you'll need version 1.2, which is nowhere to be found on the web. It is, however, installed with Visual Studio 2008, but it's hidden. Sometimes it's available as right-click after you set up an ERD, but that didn't work for me.
Look in the Visual Studio install directory, under
…\90\Tools\Publishing, for the file " SqlPubWiz.exe".
You'll need to create and then edit the script. The tool creates a unicode file. For my host (www.infosaic.com) I needed to save it as an
ANSI file, which meant opening it in Notepad and using the save as…
option. Then I could import it to the
database, creating the schema and populating it with data. This could get tedious with large databases
but it has the advantage of being compatible across versions.
There's no point creating a data service if you don't have simple access to the database itself. So I'll begin by testing that with an ADO.NET SQLConnection.
This test primarily verifies the connection strings. This website goes into all sorts of useful detail on the topic; well worth reading. When you believe they're correct, put the connection strings in web.config like this:
<connectionStrings>
<add name="TargetsoftLocal"
connectionString="Data Source=yourMachineName\SQLEXPRESS;Initial
Catalog=yourDatabase;
User
ID=yourUserID;Password=yourPassword;MultipleActiveResultSets=True"/>
<add name="TargetsoftRemote"
connectionString="Data
Source=hostIPAddress,hostPortNumber;
Network Library=DBMSSOCN;Database= yourDatabase;
User ID= yourUserID;Password= yourPassword;MultipleActiveResultSets=True
"/>
</connectionStrings>
The entry "Network
Library=DBMSSOCN" tells SQL Server to use TCP/IP for the connection.
The following functions read a database and echo the contents to a text label on the web page. It's obviously specific to the targetsoft schema, you can modify it as you need.
If an exception occurs, the BuildExceptionMessage will dive through as many as ten nested exceptions, building a message with all of them included. This is helpful when you get an exception but the third inner exception records the actual problem.
Call ReadDB with the name of the
connection string (in my case, TargetsoftLocal or TargetsoftRemote) from inside
the click handlers for the buttons on the web page.
void ReadDB(string cnName)
{
try
{
SqlConnection cn = new SqlConnection();
string cnStr = ConfigurationManager.ConnectionStrings[cnName].ConnectionString;
Display("Connection
string = \"" + cnStr + "\"");
cn.ConnectionString = cnStr;
cn.Open();
string query = "Select * From MUser";
SqlCommand
sqlCommand = new SqlCommand(query,
cn);
SqlDataReader
reader;
reader = sqlCommand.ExecuteReader(CommandBehavior.CloseConnection);
while (reader.Read())
{
string message =
reader["UserName"].ToString() + " ";
message += reader["FirstName"].ToString()
+ " ";
message += reader["LastName"].ToString();
Display(message);
}
reader.Close();
Display("End of Test");
}
catch (Exception ex)
{
Display(BuildExceptionMessage("Exception
in ReadDB.", ex));
throw ex;
}
}
void Display(string message)
{
MessageLabel.Text += message + " ";
}
string BuildExceptionMessage(string message, Exception
ex)
{
System.Text.StringBuilder
sb = new System.Text.StringBuilder(message,
4096);
sb.Append("\n");
int i = 1;
sb.AppendFormat("Exception
level {0}: {1}", i++, ex.Message);
for (; i < 11
&& ex.InnerException != null; ++i)
{
ex = ex.InnerException;
sb.Append("-------------------\n");
sb.AppendFormat("Exception
level {0}: {1}", i, ex.Message);
}
return sb.ToString();
}
· Test local database under debugger – succeeds
· Publish and test local database – succeeds
You can test remotely from your own machine, indeed, you can test access to the Infosaic-hosted database from inside the debugger since the hosted database is available from an IP address. So I tested from the locally published website and that worked. You'll find the data service is not nearly this transparent.
Now we need to run the acid test. Upload the web app to the infosaic host and test the hosted database. This is how the data service will access the database.
For basic web site creation and testing, I haven't given any description of what can go wrong, since this tends to be well-covered elsewhere. From this point forward, I'll be covering the kinds of errors that can occur and what to do about them.
The first error you see might look like this:
Server Error in '/' Application.
Description: An application error occurred on the server. The current custom error settings for this application prevent the details of the application error from being viewed remotely (for security reasons). It could, however, be viewed by browsers running on the local server machine.
Details: To enable the details of this specific error message to be viewable on remote machines, please create a <customErrors> tag within a "web.config" configuration file located in the root directory of the current web application. This <customErrors> tag should then have its "mode" attribute set to "Off".
As the error suggests in further detail (which you'll see if you get the error), you need to insert this line in your web.config file:
<customErrors mode="Off"/>
This happens if you're trying to get a SQL Server 2008 database working with an ODBC connection string. See here for detailed instructions and look at the sample connection strings I listed above.
You might get this message on your web page:
A network-related or instance-specific error occurred while establishing
a connection to SQL Server. The server was not found or was not accessible.
Verify that the instance name is correct and that SQL Server is configured to
allow remote connections. (provider: Named Pipes Provider, error: 40 - Could
not open a connection to SQL Server)
In this case, your connection string is syntactically correct, but the software could not connect to SQL Server. It helps to remember that SQL Server needs to establish some kind if interprocess communication between your code and its driver. It typically does this through Named Pipes but can also use other methods, like TCP/IP. The Data Source in the connection string tells how to find that driver.
If it can't find the driver, it gives this error, which generally means the Data Source is wrong. If you gave an IP address, you may need a port number.
Please refer to these MSDN notes. We are just now starting on step 2.
· Follow step 2 in the MSDN notes to add a new ADO.NET Entity Data Model. I called my data model MetroModel.
· When asked, choose Generate from database. I chose my Targetsoft database using Windows authentication. I asked for the connection settings to be saved as "MetroEntities." Choose Tables but not Views or Stored Procedures. I gave the model namespace as Targetsoft, for my own convenience so I don't have extra namespaces floating around unnecessarily.
· After Entity Designer opens, get the properties for the entities and fiddle with the names until you have, e.g. MUserSet and MUser; MEventSet and MEvent. The MSDN notes describe this in detail.
· If you now look at Web.config, you'll find a connection string with parameters that will work only for your local database. You'll need to modify this string every time you upload to your shared host. For now, leave it alone, we'll deal with it later.
· Once again, follow the MSDN notes and add a new Data Service, which I called MetroData.
· Set the type of the DataService in the TestService class declaration, which in my case is MetroEntities.
· In the InitializeService function, insert these three lines:
config.SetEntitySetAccessRule("*", EntitySetRights.AllRead);
config.SetServiceOperationAccessRule("*", ServiceOperationRights.All);
config.UseVerboseErrors = true;
· Run locally under the debugger and navigate to your XXX.svc page. You should see something like this:
<service xml:base=" http://localhost:4704/MetroData.svc/">
<workspace>
<atom:title>Default</atom:title>
<collection href="MEventSet">
<atom:title>MEventSet</atom:title>
</collection>
<collection href="MUserSet">
<atom:title>MUserSet</atom:title>
</collection>
</workspace>
</service>
That's an XML list of the tables in the schema that Visual Studio imported from the database. Note that you don't need a live database connection to see this XML list. You only needed the connection at the time you created the entity data model. You will, of course, need a live connection to get to the database itself when it's time to use the service to read and update data.
· Publish and test locally
· Copy to shared host and test there as well
You may see this error message:
This collection already contains an address with scheme http. There can be at most one address per scheme
in this collection.
Parameter name: item
This is a well-documented issue. You need only change web.config to have:
<system.serviceModel>
<serviceHostingEnvironment aspNetCompatibilityEnabled="true">
<baseAddressPrefixFilters>
<add prefix="http://www.your-domain-name.com"/>
</baseAddressPrefixFilters>
</serviceHostingEnvironment>
</system.serviceModel>
You may see other, more complicated solutions involving
overriding the service host factory. As
far as I can tell, they are not necessary and the above solution will suffice.
You might get this error running locally or on the shared host:
IIS specified
authentication schemes 'Basic, Anonymous', but the binding only supports
specification of exactly one authentication scheme.
The solution is to go into the IIS management window, open the properties for the virtual directory, go to Directory Security, edit the access and authentication control, and uncheck everything but anonymous access. If it works locally but you get that message on the shared host, you'll have to ask the administrators of the shared host to make the same change.
Note: you'll have to restart the WWW publishing service for
the change to take effect.
If you get a 404 error attempting to access your XXX.svc page, you need to check if .svc is a mapped extension.
Go to the IIS management window, open the properties for the
virtual directory, and edit the Application Configuration. Check that .svc is mapped to
aspnet_isapi.dll. If it isn't, add the
mapping. Once again, if it works locally
but gives a 404 on your shared host, you'll have to ask the administrators of
the shared host to make the same change.
Of course a 404 could be anything, so you should carefully debug to make
sure you're not crying wolf.
You may have to restart the WWW publishing service for the change to take effect.
When I test locally, I use a different connection string for the data service. I also cannot have the baseAddressPrefixFilters. So each time I copy the published web application to the shared host, I have to edit the web.config file.
To save time and avoid errors, I've created a small file that has the differences between the local configuration and the hosted configuration.
We have now moved on to these MSDN
notes.
Right-click on the solution, choose Add new project, and choose Silverlight application. I named my new project MetroSilver. On the next screen, I chose to link it to the existing web site. I'm adding a test page, but not making it the start page since I'll later integrate Silverlight into a normal ASP.NET website. I will enable Silverlight debugging.
In the default.aspx of the web app, I put links to the testservice.svc and the silverlight test page (clienttest.aspx).
Then in page.xaml I added a simple listbox and a button, each in its own row in the grid. Here's the code that goes inside the grid:
<Grid.RowDefinitions>
<RowDefinition MaxHeight="300" />
<RowDefinition MaxHeight="100" />
</Grid.RowDefinitions>
<ListBox x:Name="dataSvcListBox"
HorizontalAlignment="Stretch"
Grid.RowSpan="1"
Grid.Row="0" VerticalAlignment="Stretch"
ItemsSource="{Binding Mode=OneWay}" >
</ListBox>
<Button x:Name="GetDataButton"
Grid.Row="1" Height="20"
Width="100" Content="Get
Data" Click="GetDataButton_Click"></Button>
Then you need to add this
function to page.xaml.cs:
private void GetDataButton_Click(object
sender, RoutedEventArgs e)
{
List<int> iList = new List<int> {
0, 1, 2, 3, 4, 5, 6 };
dataSvcListBox.ItemsSource = iList;
}
I published and tested locally and on the Infosaic host. Pressing the button populates the list. The new code works everywhere and none of the old code is broken. At this point, there don't seem to be as many opportunities for difficult-to-trace errors. Although…
It's worth noting that if you copied and pasted the XAML without inserting the click event handler, you wouldn't get a compiler error like you'd expect, since the XAML is compiled dynamically. Instead you'll get a XAML parsing error with the misleading name of AG_E_PARSER_BAD_PROPERTY_VALUE.
So naturally, you'll think something is wrong with the XAML
syntax, when it's simply missing the code-behind event handler.
Right click on the Silverlight project in Solution Explorer to Add a Service Reference. Enter the address of the published service page (e.g. http://localhost/targetsoft/metrodata.svc) and click Go.
At this point, the data service doesn't have to be running, but it has to be capable of running (so Visual Studio can run it on your behalf). If for some reason, the services won't appear properly in the Visual Studio dialog, make sure you can run it outside Visual Studio in a browser on localhost.
Once the service runs it should list a tree of services. You need to specify a namespace which will be nested within the client namespace.
In page.xaml.cs add:
using
System.Data.Services.Client;
We'll redefine the listbox we added previously to page.xaml so it will bind to the fields in the MUser table in MetroEntities. The table definitions for MetroEntities were picked up by the client when we added the service reference. That's why the service had to be ready to run.
Here's the new listbox, bound to UserName, FirstName, and LastName:
<ListBox x:Name="dataSvcListBox"
HorizontalAlignment="Stretch"
Grid.RowSpan="1"
Grid.Row="0" VerticalAlignment="Stretch"
ItemsSource="{Binding Mode=OneWay}" >
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel x:Name="DisplayListData"
Orientation="Horizontal"
VerticalAlignment="Bottom"
Margin="5"
>
<TextBlock x:Name="UserName"
Text="{Binding UserName}"
Margin="5,0,0,0"
VerticalAlignment="Bottom"
HorizontalAlignment="Left"
FontSize="12">
</TextBlock>
<TextBlock x:Name="FirstName"
Text="{Binding FirstName}"
Margin="5,0,0,0"
VerticalAlignment="Bottom"
HorizontalAlignment="Left"
FontSize="12">
</TextBlock>
<TextBlock x:Name="LastName"
Text="{Binding LastName}"
Margin="5,0,0,0"
VerticalAlignment="Bottom"
HorizontalAlignment="Left"
FontSize="12">
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
The new code is contained by the ItemTemplate.
In this example, I'll do all the work inside page.xaml/.cs. In a real app, we'd have dedicated classes for the data instead of mixing them into the one UI class.
First, add a using for the client's data service. In my case, it's:
using MetroSilver.targetsoft;
This was the auto-generated class created when the service reference was added.
Now we'll replace the dummy code in GetDataButton_Click with live data retrieval code. Since the retrieval takes place asynchronously, we need a function to request the data and a callback function to receive it. Like this:
private void GetDataButton_Click(object
sender, RoutedEventArgs e)
{
try
{
DataServiceContext
svcCtx = new DataServiceContext(
new Uri("MetroData.svc",
UriKind.Relative));
svcCtx.BeginExecute<MUser>(
new Uri("MUserSet",
UriKind.Relative),
loadUserCallback, svcCtx);
}
catch (Exception ex)
{
throw ex;
}
}
private void loadUserCallback(IAsyncResult
asyncResult)
{
try
{
DataServiceContext
ctx = asyncResult.AsyncState as DataServiceContext;
IEnumerable<MUser> source = ctx.EndExecute<MUser>(asyncResult);
dataSvcListBox.DataContext = source;
}
catch (Exception ex)
{
throw ex;
}
}
Though it did work for me in this case, (mainly because I had to slog through a bunch of errors earlier), I deliberately deleted the port # in the IP address of the Data Source, simulating the most likely error – a bad connection string. If this happens, you won't get an error screen with an explanatory message. You'll get no response at all.
To debug locally, pretending I didn't know the cause, I set a breakpoint and walked through the above code to find out it threw an exception in loadUserCallback. The exception is:
An error occurred while processing this request.
Well, we knew that. Checking the inner exception (which is a bit tedious in the debugger, but not too bad), I got:
An error occurred while saving changes. See the inner exception for details.
Again, not real helpful. I dug deeper still, which is now getting tedious, and finally got to the meat:
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
… blah blah blah ..
<message>The underlying provider failed on Open.</message>
… and farther down…
<message>A
network-related or instance-specific error occurred while establishing a
connection to SQL Server. The server was not found or was not accessible.
Verify that the instance name is correct and that SQL Server is configured to
allow remote connections. (provider: TCP Provider, error: 0 - A connection
attempt failed because the connected party did not properly respond after a
period of time, or established connection failed because connected host has
failed to respond.)</message>
It couldn't connect because it couldn't find the server identified in the connection string. Either the database is not running or the string is wrong (which it was).
The lesson here is if we can't connect to the database, Silverlight won't blast it onto a web page in front of us. That's good for the user, no ugly error screens, but how do we debug outside the debugger?
I'm sure there are better ways than what I'm about to tell you, but the following works for me.
I've attached the log file software as a separate download (it's a single .cs file) and it includes a function like the one used during database testing, to unwind exception messages. It writes a small circular file that wraps around on itself when it reaches its allocation limit. The file appears on the client in the local settings directory.
Here's another error you might see:
[ServiceActivationException]:
The service '/DataService/DataModel.svc' cannot be activated due to an
exception during compilation. The exception message is: Index was out of
range. Must be non-negative and less than the size of the collection.
The cause was simple enough –
the service wasn't working because I'd forgotten to reset the web.config for
local operation (it was set for operation on the shared host). This is the kind of misleading message that
can fool you.
Anyway, at this point I have the code working under the debugger, locally without the debugger, and on the shared host at Infosaic.
It's time to move onto updating records.