Showing posts with label Обмен опытом. Show all posts
Showing posts with label Обмен опытом. Show all posts

Tuesday, September 14, 2010

Add Interactivity to ASP.NET ReportViewer

In one of our projects I had a task to add interactivity to charts located on (MS Report Server) reports that were viewed in ASP.NET web application using ReportViewer control.

Charts I worked with contained date series and user should had have the ability to add notes to data in series by clicking on that data on chart.

In the final solution I used jQuery to find images (charts) in rendered report HTML that had special value encoded in image's alt tag. Client javascript (which was on the same page as ReportViewer control) made AJAX requests to server to calibrate chart axes. Then calibration result was used on client side to build HTML MAP with AREAs to which jQuery onclick handlers were attached.

Here's how working example looked in browser:



Client side implementation was written in pure jQuery with help of jQuery qTip plugin and takes around 450 lines of code. Nothing interesting.

The most interesting part in this approach was getting chart image from generated report on server side.

To get image on server side I used the same mechanism that ReportViewer web control uses. I must to say that I couldn't do this without Red Gate's .Net Reflector (I used free version, it was enough here). This is really helpful tool that makes (nearly all) .Net libraries open sourced. I highly recommend it.

Browser obtains chart image by URL, so I had to pass that URL to server side to grab that image from there.

            function calibrate($image) {
$.ajax({
type: "POST",
contentType: "application/json; charset=utf-8",
url: options.calibrateAxesUrl,
data: "{imageUrl: '" + $image.attr("src") + "'}",
dataType: "json",
success: function(msg) {
calibrationResult = msg.d;

// process calibration result...
}
});
}


As you see I used image's src tag to get image URL and invoked web method CalibrateAxes (which is a public static method with [WebMethod] annotation) of my ASP.NET page (the full URL looks like '<%= ResolveClientUrl("~/Reporting/ReportViewer.aspx") + "/CalibrateAxes" %>').

CalibrateAxes method gets chart image as bitmap and does some bitmap analysis to form the result:


        [WebMethod]
public static CalibrationResult CalibrateAxes(string imageUrl)
{
byte[] image = RenderImage(imageUrl);

using (var stream = new MemoryStream(image))
using (var bmp = new Bitmap(stream))
{
var result = new CalibrationResult();

// analyze bitmap...

return result;
}
}

private static byte[] RenderImage(string imageUrl)
{
var imageUri = new Uri(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Authority) + imageUrl);

var parameters = HttpUtility.ParseQueryString(imageUri.Query);

var reportSession = parameters["ReportSession"];
var controlID = parameters["ControlID"];
var culture = parameters["Culture"];
var uiCulture = parameters["UICulture"];
var reportStack = parameters["ReportStack"];
var streamID = parameters["StreamID"];

var rs = new ReportExecutionService
{
Url = ConfigurationManager.AppSettings["ReportExecutionServiceUrl"],
Credentials = new NetworkCredential(
ConfigurationManager.AppSettings["ReportServerUserName"],
ConfigurationManager.AppSettings["ReportServerUserPassword"],
ConfigurationManager.AppSettings["ReportServerUserDomain"]),
ExecutionHeaderValue = new ExecutionHeader {ExecutionID = reportSession}
};

string deviceInfo = GetDeviceInfo(reportSession, controlID, culture, uiCulture, reportStack);

string encoding;
string mimetype;

return rs.RenderStream("HTML4.0", streamID, deviceInfo, out encoding, out mimetype);
}

private static string GetDeviceInfo(string reportSession, string controlID, string culture, string uiCulture, string reportStack)
{
var writer = new StringWriter();
var xmlWriter = new XmlTextWriter(writer);
xmlWriter.WriteStartElement("DeviceInfo");
var url = CreateUrl(reportSession, controlID, culture, uiCulture, reportStack);
xmlWriter.WriteElementString("StreamRoot", url);
xmlWriter.WriteEndElement();
return writer.ToString();
}

private static string CreateUrl(string reportSession,
string controlID,
string culture,
string uiCulture,
string reportStack)
{
var uriBuilder = new UriBuilder(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Authority));
var applicationPath = HttpContext.Current.Request.ApplicationPath;
if (!applicationPath.EndsWith("/", true, CultureInfo.InvariantCulture))
{
applicationPath = applicationPath + "/";
}
applicationPath = applicationPath + "Reserved.ReportViewerWebControl.axd";
applicationPath = HttpContext.Current.Response.ApplyAppPathModifier(applicationPath);
uriBuilder.Path = applicationPath;

var builder = new StringBuilder();
builder.AppendFormat("{0}={1}", "ReportSession", reportSession);
builder.AppendFormat("&{0}={1}", "ControlID", HttpUtility.UrlEncode(controlID));
builder.AppendFormat("&{0}={1}", "Culture", culture);
builder.AppendFormat("&{0}={1}", "UICulture", uiCulture);
builder.AppendFormat("&{0}={1}", "ReportStack", reportStack);
builder.Append("&OpType=ReportImage&StreamID=");
uriBuilder.Query = builder.ToString();

return uriBuilder.Uri.PathAndQuery;
}


This code of getting rendered image from report turned to be very generic, so you may also use it in your web applications.

Monday, September 13, 2010

How To Determine Client TimeZone In A Web Application

In web applications when client and server located in different timezones we need a way to determine client timezone to display date/time sensitive information. This is almost always true for Google Appengine, where default server time zone is UTC.

There are several ways to determine client timezone.

One of them is resolving client IP address to location:

  1. Get client IP

  2. Get client location (latitude, longitude) by the IP-address

  3. Get information about timezone by the location coordinates


Every web framework provides API to get client IP. For instance, in java there is a method ServletRequest.getRemoteAddr() for this purpose.

To resolve IP and location information you can use one of the numerous web services available online.

For instance, to resolve IP to location Ping Service uses IP-whois.net service.

Another service, Geonames.org provides web service API to get timezone information by latitude/longitude pair.

Here's an implementation of described approach in java:


    private TimeZone getTimeZoneByClientIP() {
        TimeZone timeZone = UTC_TIME_ZONE;
        
        try {
            String clientIP = globals.getHTTPServletRequest().getRemoteAddr();
            
            if (!Utils.isNullOrEmpty(clientIP)) {
                Location location = locationResolver.resolveLocation(clientIP);
                
                if (!location.isEmpty()) {
                    timeZone = timeZoneResolver.resolveTimeZone(location.getLatitude(), location.getLongitude());
                }
                
                if (timeZone == null) {
                    timeZone = UTC_TIME_ZONE;
                }
            }
            
            logger.debug("Resolved timeZoneId is {}", timeZone.getID());
        } catch (Exception e) {
            logger.error("Error resolving client timezone by ip " 
                    + globals.getHTTPServletRequest().getRemoteAddr(), e);
        }
        
        return timeZone;
    }


The disadvantages using this approach are:

  • Your code becomes dependent on third party online services that are not 100% reliable

  • Requesting third party services online will take time (up to several seconds) which may result in long response time

Note: according to Ping Service statistics IP-Whois.net availability is close to 100% with average response time ~270 ms, while Geonames.org availability is only around 80% with average response time ~1100 ms. Geonames.org low level availability is due to GAE hosting: Geonames.org restricts free access to its API to 3000 requests per IP per hour.

On the other hand you have really simple solution to implement that allows to determine client timezone at the very first client request so you can display all date/time sensitive data using client local time.

See also:
Update: GAE 1.6.5 introduces some request headers which already contains Lat/Lng pair for incoming request: https://developers.google.com/appengine/docs/java/runtime#Request_Headers

Thursday, August 26, 2010

GAE and Tapestry5 Exception Handling

Tapestry5 uses its own technique to process unhandled exceptions.
When unhandled exception occurs Tapestry5 redirects response to special error page which is responsible to display exception detail.

There is a standard error page in Tapestry5 that can be very helpful for developer if you configure your application to run in development mode. To do this you contribute SymbolConstants.PRODUCTION_MODE symbol with value "false" in your AppModule.java like this:

public static void contributeApplicationDefaults(
MappedConfiguration<String, String> configuration)
{
// ...
configuration.add(SymbolConstants.PRODUCTION_MODE, "false");
// ...
}

Standard error page provides you all necessary information to understand the cause of exception:


And here is how exception report looks like in production:


This is reasonable, because in production you usually don't want to display all this information to clients. But this is also not so user friendly, because it displays value of Throwable.getMessage().

Tapestry5 allows overriding standard error page with your own exception page so you can display more user friendly messages.

There's also another scenario when you don't want Tapestry5 to generate exception report, and let application server provide static HTML page with apologizes to client. This approach better suits for production, but in development mode its better to leave detailed error report as is.

To change the way Tapestry5 handles exceptions you should provide another implementation of RequestExceptionHandler. One way doing this is to decorate RequestExceptionHandler:

public RequestExceptionHandler decorateRequestExceptionHandler(
final Logger logger,
final Response response,
@Symbol(SymbolConstants.PRODUCTION_MODE)
boolean productionMode)
{
// Leave default implementation of RequestExceptionHandler in development mode
if (!productionMode) return null;

// Provide simple implementation that logs exception and returns
// HTTP error code which will be handled by application server
return new RequestExceptionHandler()
{
public void handleRequestException(Throwable exception) throws IOException
{
logger.error("Unexpected runtime exception", exception);

// Return HTTP error code 500
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, null);
}
};
}


Next, add this markup to web.xml:

<error-page>
<error-code>500</error-code>
<location>/500.html</location>
</error-page>


Now in case of any exceptions client will see contents of 500.html.



This approach have one more advantage for GAE. Generating exception reports consumes billable CPU cycles and takes request processing time.

Saving CPU cycles is good. And there is one note about request processing time. As you may know on GAE each request have to be processed in 30 seconds. If it doesn't, then runtime raises DeadlineExceededException and gives application few hundreds of milliseconds to fail gracefully. As practice shows, default T5 RequestExceptionHandler + error report generation usually takes longer.

One more note about GAE exception handling. Since version 1.3.6 GAE allows developers declare custom static error handlers for GAE specific errors: over_quota, dos_api_denial and timeout.
In case of first two errors GAE doesn't even pass requests to application code. Timeout errors appear as a result of application code execution and (I suppose) this static error handler may conflict with RequestExceptionHandler that overrides DeadlineExceededException with HTTP error code 500.

I also want to share my implementation of over_quota.html page. I noticed free quotas got reset every day near 11am-12am Moscow Summer Time (its around 7am-8am UTC time, not sure if it the same for another applications). I thought it would be good if I include how many time is it left for GAE enabled free quotas next time. And though over_quota.html is a static page it is possible to include a peace of javascript that calculates this time in client timezone. Here is it:

<html>
<head>
<title>Ping Service - Over Capacity</title>
</head>
<body>
<h1>
Over Capacity
</h1>

<p>
We apologize for the inconvenience.
</p>

<p>
Service is temporary unavailable until <span id="deadline">8:00 am UTC time.</span>

<script type="text/javascript">
var element = document.getElementById("deadline");
var now = new Date();
var deadline = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8);
var timezoneOffset = now.getTimezoneOffset() / 60;
deadline.setHours(deadline.getHours() - timezoneOffset);
if (deadline <= now) {
deadline.setDate(deadline.getDate() + 1);
}
element.innerHTML = deadline.toLocaleTimeString().replace(/:00$/, "")
+ " your time ("
+ Math.round((deadline - now) / 60 / 60 / 1000)
+ " hours left).";
</script>
</p>
</body>
</html>



See also

Saturday, November 28, 2009

Best Practice: Implementing Telerik RadGrid with Linq(ToSQL)

Telerik RadGrid is a very powerful component that allows you to view, edit, order and filter data.

I'd like to share my experience using this component with linq to sql.

Using this component is very straightforward unless your entity contains references to other entities (in other words if your table have foreign keys to other tables, see DB diagram below). Very often such relations have a kind of reference data.



In this case you may meet the following difficulties:

  1. ability to display data field of referenced entity (except its ID, of course) in grid column;
  2. ability to sort and filter data set of your entities by columns which are references to other entities.


To understand the cause of these difficulties lets look at simple example.

Generated Linq to SQL model for the database schema above will look like the following:



Now, to display a list of Books in RadGrid you should implement databinding. To do this you usually subscribe to RadGrid's OnNeedDataSource event and invoke RadGrid.DataBind() in PageLoad event handler:


protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
RadGrid1.DataBind();
}
}

protected void RadGrid1_NeedDataSource(object source, GridNeedDataSourceEventArgs e)
{
if (!e.IsFromDetailTable)
{
if (e.RebindReason == GridRebindReason.InitialLoad
|| e.RebindReason == GridRebindReason.ExplicitRebind)
{
RadGrid1.VirtualItemCount = Book.GetBooksCount();
}

int skip = RadGrid1.MasterTableView.CurrentPageIndex * RadGrid1.MasterTableView.PageSize;
int take = RadGrid1.MasterTableView.PageSize;

RadGrid1.DataSource = Book.GetBooks(skip, take);
}
}


Here's our data access methods:


public static int GetBooksCount()
{
using (var ctx = new DataClasses1DataContext())
{
return GetBooksQuery(ctx).Count();
}
}

public static List<Book> GetBooks(int? skip, int? take)
{
using (var ctx = new DataClasses1DataContext())
{
var query = GetBooksQuery(ctx);

if (skip.HasValue)
{
query = query.Skip(skip.Value);
}
if (take.HasValue)
{
query = query.Take(take.Value);
}

return query.ToList();
}
}

private static IQueryable<Book> GetBooksQuery(DataClasses1DataContext ctx)
{
var query = (from book in ctx.Books
select book);

return query;
}


part of *.aspx file that holds RadGrid markup:


<form id="form1" runat="server">
<div>
<telerik:RadScriptManager ID="RadScriptManager1" runat="server"/>
<telerik:RadGrid ID="RadGrid1" runat="server"
OnNeedDataSource="RadGrid1_NeedDataSource"
AllowCustomPaging="True"
AllowPaging="True">
<PagerStyle AlwaysVisible="True" Mode="NextPrevAndNumeric" />
</telerik:RadGrid>
</div>
</form>


and the resulting grid:



Notice how we implemented pagind with just few lines of code.

By default RadGrid created columns and bound them to properties of our Book class (AutoGenerateColumns="True"). But we need to display Pubilsher's Name instead of PublisherID.

To do this we need to change AutoGenerateColumns to False and write RadGrid markup by hand.

Here is the markup:

<Columns>
<telerik:GridBoundColumn UniqueName="Title" HeaderText="Title"
DataField="Title" DataType="System.String" />
<telerik:GridBoundColumn UniqueName="Author" HeaderText="Author"
DataField="Author" DataType="System.String" />
<telerik:GridBoundColumn UniqueName="Publisher.Name" HeaderText="Publisher"
DataField="Publisher.Name" DataType="System.String" />
</Columns>

and code behind:

public partial class Book
{

public static int GetBooksCount()
{
using (var ctx = new DataClasses1DataContext())
{
return GetBooksQuery(ctx).Count();
}
}

public static List<Book> GetBooks(int? skip, int? take)
{
using (var ctx = new DataClasses1DataContext())
{

// Preload Book's Publisher field
var loadOptions = new DataLoadOptions();
loadOptions.LoadWith<Book>(b => b.Publisher);
ctx.LoadOptions = loadOptions;

var query = GetBooksQuery(ctx);

if (skip.HasValue)
{
query = query.Skip(skip.Value);
}
if (take.HasValue)
{
query = query.Take(take.Value);
}

return query.ToList();
}
}

private static IQueryable<Book> GetBooksQuery(DataClasses1DataContext ctx)
{
var query = (from book in ctx.Books
select book);

return query;
}
}

Here's what we got at this point:



Now we will add support for filter and sorting capabilities. To do this we should set RadGrid's AllowFilteringByColumn and AllowSorting to True and change our DAL methods to support query filtering and ordering.

RadGrid gives us very good support here, because it can generate linq string that contains part of linq query's where expression. To get this expression we wrote simple RadGridHelper class (see full source code in attachments below).

To use RadGrid expressions we need DynamicQueriable (CSharpSamples.zip\LinqSamples\DynamicQuery\DynamicQuery\Dynamic.cs) to mix dynamic linq expressions with static queries in DAL.

Finishing stroke is to make filtering case insensitive, in order to do this we should set CaseSensitive="False" in RadGrid's GroupingSettings.

Below are the resulting code linstings:

Resulting RadGrid markup:

<telerik:RadGrid ID="RadGrid1" runat="server"
OnNeedDataSource="RadGrid1_NeedDataSource"
AllowCustomPaging="True"
AllowPaging="True"
AllowFilteringByColumn="True"
AllowSorting="True"
AutoGenerateColumns="False" GridLines="None">
<HeaderContextMenu EnableTheming="True">
<CollapseAnimation Type="OutQuint" Duration="200"></CollapseAnimation>
</HeaderContextMenu>
<PagerStyle AlwaysVisible="True" Mode="NextPrevAndNumeric" />
<GroupingSettings CaseSensitive="False" />
<MasterTableView>
<ExpandCollapseColumn>
<HeaderStyle Width="20px"></HeaderStyle>
</ExpandCollapseColumn>
<Columns>
<telerik:GridBoundColumn UniqueName="Title" HeaderText="Title"
DataField="Title" DataType="System.String" SortExpression="Title" />
<telerik:GridBoundColumn UniqueName="Author" HeaderText="Author"
DataField="Author" DataType="System.String" SortExpression="Author" />
<telerik:GridBoundColumn UniqueName="PublisherName" HeaderText="Publisher"
DataField="Publisher.Name" DataType="System.String" SortExpression="Publisher.Name" />
</Columns>
</MasterTableView>
<FilterMenu EnableTheming="True">
<CollapseAnimation Type="OutQuint" Duration="200"></CollapseAnimation>
</FilterMenu>
</telerik:RadGrid>


Resulting code behind:


protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
RadGrid1.DataBind();
}
}

protected void RadGrid1_NeedDataSource(object source, GridNeedDataSourceEventArgs e)
{
if (!e.IsFromDetailTable)
{
string where = RadGridHelper.GetFilterExpression(RadGrid1.MasterTableView,
null);
string orderBy = RadGridHelper.GetOrderBy(RadGrid1.MasterTableView);

if (e.RebindReason == GridRebindReason.InitialLoad
|| e.RebindReason == GridRebindReason.ExplicitRebind)
{
RadGrid1.VirtualItemCount = Book.GetBooksCount(where);
}

int skip = RadGrid1.MasterTableView.CurrentPageIndex * RadGrid1.MasterTableView.PageSize;
int take = RadGrid1.MasterTableView.PageSize;

RadGrid1.DataSource = Book.GetBooks(where, orderBy, skip, take);
}
}


Final Book class:


public partial class Book
{

public static int GetBooksCount(string where)
{
using (var ctx = new DataClasses1DataContext())
{
return GetBooksQuery(ctx, where, null).Count();
}
}

public static List<Book> GetBooks(string where, string orderBy, int? skip, int? take)
{
using (var ctx = new DataClasses1DataContext())
{
var query = GetBooksQuery(ctx, where, orderBy);

if (skip.HasValue)
{
query = query.Skip(skip.Value);
}
if (take.HasValue)
{
query = query.Take(take.Value);
}

return query.ToList();
}
}

private static IQueryable<BookWrapper> GetBooksQuery(DataClasses1DataContext ctx, string where, string orderBy)
{
var query = (from book in ctx.Books
select book);

if (!string.IsNullOrEmpty(where))
{
query = query.Where(where);
}
if (!string.IsNullOrEmpty(orderBy))
{
query = query.OrderBy(orderBy);
}

return query;
}
}


Resulting RadGrid in action:



Attachments


radgridsample-src.zip Full project sources + DB (142 KB)

Thursday, July 16, 2009

Host WCF service in Windows service

Hosting WCF service in windows service may cause "Access is denied" error:


Service cannot be started. System.ServiceModel.AddressAccessDeniedException: HTTP could not register URL http://+:8888/docstore/. Your process does not have access rights to this namespace (see http://go.microsoft.com/fwlink/?LinkId=70353 for details). ---> System.Net.HttpListenerException: Access is denied
at System.Net.HttpListener.AddAll()
at System.Net.HttpListener.Start()
at System.ServiceModel.Channels.SharedHttpTransportManager.OnOpen()
--- End of inner exception stack trace ---
at System.ServiceModel.Channels.SharedHttpTransportManager.OnOpen()
at System.ServiceModel.Channels.TransportManager.Open(TransportChannelListener channelListener)
at System.ServiceModel.Channels.TransportManagerContainer.Open(SelectTransportManagersCallback selectTransportManagerCallback)
at System.ServiceModel.Channels.TransportChannelListener.OnOpen(TimeSpan timeout)
at System.ServiceModel.Channels.HttpChannelListener.OnOpen(TimeSpan timeout)
at System.ServiceModel.Channels.CommunicationObject.Open(TimeSpan t...

For more information, see Help and Support Center at http://go.microsoft.com/fwlink/events.asp.


MSDN briefs the light on how to configure such service.

For short, on Windows 2003 you have to execute the following command:

httpcfg set urlacl /u {http://URL:Port/ | https://URL:Port/} /aACL


I had several problems with this command:


  1. No httpcfg installed. It shipped with Win2003 server, but may not be installed by default. This link explains how you can obtain it if you're Win2003/XP user.

  2. Value for the /u argument. In my case I just copied the URL I got in stack trace above: http://+:8888/docstore/.

  3. Value for the /a argument. It takes a string that contains an Access Control List (ACL) in the form of a Security Descriptor Definition Language (SDDL) string.



If you look at SDDL string for first time you can harm your brain, here is an example from MSDN:


"O:DAG:DAD:(A;;RPWPCCDCLCRCWOWDSDSW;;;SY)
(A;;RPWPCCDCLCRCWOWDSDSW;;;DA)
(OA;;CCDC;bf967aba-0de6-11d0-a285-00aa003049e2;;AO)
(OA;;CCDC;bf967a9c-0de6-11d0-a285-00aa003049e2;;AO)
(OA;;CCDC;6da8a4ff-0e52-11d0-a286-00aa003049e2;;AO)
(OA;;CCDC;bf967aa8-0de6-11d0-a285-00aa003049e2;;PO)
(A;;RPLCRC;;;AU)S:(AU;SAFA;WDWOSDWPCCDCSW;;;WD)"


I've found a tool by Dominick Baier (HttpCfg ACL Helper) that can help you to build an ACL string. You can found its description and link to sources on Dominick's blog.

If you don't have VS at hand you can download HttpCfg ACL Helper binary here (3 KB).

When you run it it will prompt to add user account under which you will run the windows service and in the output it will build the command for you (thank you Dominick!):