Tuesday, June 16, 2009

Updatable ASP.NET ResX Resource Provider – yes, it’s possible!

Update (November 4, 2009): I’ve been getting a lot of emails over this one; if you’re running into any issues implementing this, however slight, please post here and I will answer the questions for everyone. Also, note that, with a few minor changes, you can use CompilationMode to introduce content/layout changes directly on *.aspx/*.ascx/*.master surfaces at run-time as well.

First and foremost, I want to thank Rick Strahl for his efforts; his work served as the initial inspiration for us to even consider this approach [1]. Next, I want to highlight that our motivations for deviating from the standard (resource) providers differ somewhat: in our case, we had a very high-volume application that was already built-out with the default *.resx localization scheme, and we were more or less content with it, except for one drawback. Our motivation was solely driven by the need to push updates to production without: a) restarting the application and jeopardizing in-process session, cache data; and b) introducing any sort of performance degradation, however slight.

Editing Resources at Runtime

Think of this as a make-shift CMS, if you will, that enables you to update localized content in your ASP.NET applications without full-blown code promotions (and the attendant scheduling considerations).

Beyond the ability to update values in production, I’m not convinced that a custom authoring UI justifies some of the risks introduced in the DB-based solutions; certainly, some of the value-added features are nice-to-haves, but the focus is on remaining feature-compatible with the built-in provider (e.g. support for Visual Studio’s 'Generate Local Resources', Explicit and Implicit Resource Expressions, etc.) while providing run-time updatability.

That’s really all that we’re after and the simplicity (foot-print) of the solution should reflect it.

In this vein, we set out to deliver a scheme that allows you to preserve your *.resx files with the added ability to push updates to production app pools; in doing so, we needed an understanding of:

  1. Why web resources compiled from *.resx to *.resources in the first place?
    • Why would a web applications store binary in these files – images, audio, text-files, etc?
  2. How do we by-pass this to apply localization directly from cached *.resx files (with the necessary disk-based cache dependencies)?
  3. How do we stop the automatic assembly generation that eventually kills the app pool?
  4. How do we stop the FCNs for .NET special directories but continue to maintain FCNs for CacheDependencies?

We begin by first registering our custom provider in web.config:

<globalization resourceProviderFactoryType="Sample.UpdatableResXResourceProviderFactory, Sample" />

This, along with the reference assembly, is all that’s required to introduce this to an existing application.

[Sections below assume familiarity with File Change Notifications (FCNs) in ASP.NET.]

Next, we spent some time looking at how one would disable FCNs to allow *.resx files to be pushed to their default paths (for both local and global resources) without triggering assembly generation. A few queries proved that this is indeed possible with some crude reflection; the following hack, for instance, allows you to kill all FCNs across the application (once placed in Application_Start):

Untitled

PropertyInfo p = typeof(System.Web.HttpRuntime).GetProperty("FileChangesMonitor", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); object o = p.GetValue(null, null); FieldInfo f = o.GetType().GetField("_FCNMode", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.IgnoreCase); f.SetValue(o, 1);

Unfortunately, this also has the nasty side-effect of killing the very FCNs that your Cache Dependencies rely on to reload file contents. There is a slight variation that’s successful in preserving the Cache Dependency FCNs but disabling virtually everything else:

PropertyInfo p = typeof(System.Web.HttpRuntime).GetProperty("FileChangesMonitor", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); object o = p.GetValue(null, null); FieldInfo f = o.GetType().GetField("_dirMonSpecialDirs", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.IgnoreCase); f.SetValue(o, null);

More on the need to unload the AppDomain [2], [3].

We were ultimately unhappy with this solution for several reasons and sought more simplicity. (We also had issues updating global resource files in their native paths on precompiled builds.) Recognizing that App_Data is one of a few folders under the web root that’s shielded from FCNs, we decided to move our *.resx output (preserving their relative paths) to App_Data\Resources. Under this new location, we don’t need reflection to interfere with the FileChangeMonitor and can continue to push intact *.resx files to the web root without generating FCNs or triggering assembly builds.

Ultimately, it’s really no different than pushing out any *.xml updates to App_Data except in this case the intact *.resx files that you’ve already developed serve as the payload.

Again, a key principle here was to minimize foot-print. The gist of it really boils down to a few lines of code, with the necessary dressing to plug it in. The abstract UpdatableResXResourceProvider contains all of the logic for both providers - the GlobalResXResourceProvider and the LocalResXResourceProvider simply provide path details for their respective locations:

image

The implementation boils down to the GetResourceCache method that leverages the framework’s own ResXResourceReader to read the original *.resx, storing the result it in the runtime cache with a new CacheDependency:

image

Download: UpdatableResXResourceProviderFactory.zip

Special Instructions:

  • Update the DefaultStore = @"App_Data/Resources/" parameter as a first-step; to use the default location, you need a post-build process that propagates *.resx files to this location (preserving their relative depth). To test with resources in their current location, you can use DefaultStore = “”;
  • Note that a reference to System.Windows.Forms is required for referencing ResXResourceReader.

References:

[1] – Updated Westwind.Globalization Data Driven Resource Provider for ASP.NET
http://www.west-wind.com/weblog/posts/695968.aspx

[2] – Exactly which files and directions are monitored http://blogs.msdn.com/tmarq/archive/2007/11/02/asp-net-file-change-notifications-exactly-which-files-and-directories-are-monitored.aspx

[3] – On the need to throttle updates, even if down by a scheduled task after hours: http://connect.microsoft.com/VisualStudio/feedback/ViewFeedback.aspx?FeedbackID=366809

“This is a limitation of handling file change notifications from the operating system. If too many files change, the internal change buffers overflow and ASP.NET has to assume that the application needs to be restarted. Unfortunately, there is no workaround other than to throttle the rate at which changed files are propagated to a live web application.”

[4] – Stop Monitoring on Designated Folders
http://www.velocityreviews.com/forums/t533767-workaround-for-fcndirectory-delete-bug.html

[5] – Error Debugging Project (KB 810886)
http://forums.asp.net/p/939042/1118849.aspx

[6] – ASP.NET 2.0: Custom Resource Provider Using Sql Database (Support for Oracle)
http://asp-net-whidbey.blogspot.com/2006/03/aspnet-20-custom-resource-provider.html

27 comments:

Scott Hanselman said...

Nicely done!

franklin said...

Thanks for this!

1) ContainsIgnoreCase() seems to be incorrect. It returns false if I call the following:
virtualPath.ContainsIgnoreCase("/Controls/")
...when virtualPath == "/controls/mycontrol.ascx"
Should this be implemented as:
return input.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0; //instead of > 0

2) Regarding this code:
foreach (DictionaryEntry entry in GetResourceCache(culture))
  if (resourceKey.Equals(entry.Key as string, StringComparison.OrdinalIgnoreCase))
   return entry.Value;
Why not retrieve the value from GetResourceCache() as an indexed property?
Wouldn't this be more efficient that looping over all DictionaryEntry values for each lookup?

Nariman Haghighi said...

@Scott: Thanks!

@Franklin: Thanks for pointing that out - I've uploaded a fix for the ContainsIgnoreCase. RE: GetResourceCache(), I don't see an indexed property exposed; if you find that you can enumerate it more efficiently, please post your solution here.

franklin said...

@Nariman
RE: indexed property...

When I check sample implementations of IResourceProvider.GetObject() on the web, many of them are internally accessing an IDictionary object.
The resource is retrieved from the IDictionary using a key value.

Some examples:
http://www.west-wind.com/presentations/wwdbResourceProvider/
http://www.codeproject.com/KB/aspnet/customsqlserverprovider.aspx
http://msdn.microsoft.com/en-us/library/aa905797.aspx

Both the above implementations of GetObject() internally call a method GetResourceCache() which returns an IDictionary.

On the other hand, your code for GetObject() is different - it instead retrieves the an IResourceReader object and loops over the enumerator to find the matching key.
Would it be better to implement with an IDictionary object?

franklin said...

Adding to my previous comment...

With your current implementation, the code iterates over each item in the ResxResourceReader until it finds a match.
For large resource files, this could impact performance.

Could you store an IDictionary object in HttpRuntime cache rather than the ResxResourceReader?

That way, the resource lookups could be done in constant time.

Prashant Atal said...

Can you please provide me some more info on how can i implement in one of my ASP.NET apps with some simple steps. I read the complete post but did not under how can it be simply implemented.

Regards
Prashant Atal

franklin said...

@Prashant
Download UpdatableResXResourceProviderFactory.cs file, save in your App_Code folder.

Add to web.config under system.web section:
<globalization resourceProviderFactoryType=
"CustomLocalizationProviders.UpdatableResXResourceProviderFactory,__code"/>

Update DefaultStore variable as described in article.

Move your .resx files to a subdirectory of the DefaultStore path, for example, /App_Data/Resources/App_GlobalResources

Nariman Haghighi said...

@Franklin: Yes, of course. You're right. I haven't looked at this code in a while (knee deep in something else at the moment). Resources can be enumerated by traversing the IDictionaryEnumerator returned by the GetEnumerator method, which is all that the default implementation does. But if you look at the implementation of BasicResXResourceReader in the sample above, it's trivial to put together an IResourceReader that exposes IDictionary capabilities for constant lookup. You would have to cast the result of GetResourceCache as the specialized type to gain access to the IDictionary. I will try to update the code sample this week.

franklin said...

@Nariman

In your code, GetResourceCache() returns an IResourceReader.

ResXResourceReader cannot be cast to IDictionary.
How can this be converted?

The BasicResXResourceReader implementation takes an IDictionary and exposes it as an IDictionaryEnumerator, not the other way around.

franklin said...

@Nariman

Any update on this? Have you used this successfully in production?

I'm looking to implement a similar model to get make resx files updateable.

Nariman Haghighi said...

@Franklin:

Yes, it was deployed to a very high volume site when the article was first posted, without issue; though the largest resource file we have is approx. 40 keys.

Why don't you give it a try and remove the web.config entry if you notice any issues?

Will try to update the code sample this weekend...

Nariman Haghighi said...

@Franklin:

The code sample has been upgraded for constant lookups - hope this helps.

franklin said...

@Nariman

Thanks very much for the update. We are trying this out.

Prashant Atal said...

Thanks a lot i have been able to move forward.

I have created this dummy site that you can find it here http://dev.apoyar.net/atalapps/testwebsite.rar

As soon as i uncomment the globalization tag in the web.config my application does not compile and if it commented then then it builds fine.

Prashant Atal said...
This comment has been removed by the author.
Prashant Atal said...

I was able to get it to work and it works fantastic all i had to do was change this line in the web.config.

<globalization resourceProviderFactoryType="Sample.UpdatableResXResourceProviderFactory, Sample" />

to

<globalization resourceProviderFactoryType="Sample.UpdatableResXResourceProviderFactory" />

Martin S said...

This is very good.

I was wondering about an expression builder for this and wondering if it is possible?

I have tried but always get Pbject reference not set to an instance of an object at compile time?

Nelson Cheng said...

Awesome work!

I've spent a bit of time implementing a solution based on this for one of my projects and it works great... except I'm having the same problem as Martin S.

When loaded up in IIS the site works, loading in Resources from the correct file locations and storing them in the Cache, but when I'm compiling my solution in Visual Studio I get "Object Reference not set to an instance of the object" on all my <%@ Resource:ResourceKey %> references, I'm assuming because the compiler doesn't know to look inside App_Data/Resources, rather than in App_GlobalResources or App_LocalResources.

Is there any way to tell the compiler to look in different locations for the Resource files?

Kai said...

Hi,

i'm try to use your ResourceProviderFactory within an MVC3 Project using the EntityFramework. Everything works fine so far as long as i'm using '@HttpContext.GetGlobalResourceObject("AppResources", "TextElementTextValue")' , but when i try to use the DataAnnotation Feature for Display and Validation i get an error saying: "Could not find any resources appropriate for the specified culture or the neutral culture. Make sure "DBProject.Resources.AppResources.resources" was correctly embedded or linked into assembly "DBProject" at compile time, or that all the satellite assemblies required are loadable and fully signed."

Currently i use the following implementation in my Metadata class:

[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 3)]
[Display(ResourceType = typeof(AppResources), Name = "TextElementTextValue")]
public string TextValue { get; set; }

and the following Code within the view:

<div class="editor-label">
@Html.LabelFor(model => model.TextValue)
</div>

It seems like using this way does not even trigger the UpdateableResxProviderFactory.


Any Ideas how i could solve this problem?

Sener said...

Does anyone know what Nelson is talking about the solution of the problem?

I have same problem and stuck.. any Ideas?

thanks

sathish kumar said...

Good article. Can u tell wtr it is posible for using resource in client side validation with output cache enabled.

Paraschiv Andrei said...

I tried this a lot this just doesn't work for me. I even tried using the sample application created by mister Prashant Atal with the modifications specified in his later comment but I couldn't get it to work. I load up the app and I still get the old text instead of the new one. In this case the text "ACCOUNT" instead of "New ACCOUNT Woooow It Works". Can someone please tell me what I am doing wrong?

Paraschiv Andrei said...

In fact I cannot even get the new provider code to trigger :( again using the application provided above

Paraschiv Andrei said...

I uploaded the exact code that doesn't work for me herehere

Nathan @Questor said...

Hi I would love to use this code but the download doesn't seem to work.

Just thought I'd let you know.
So it can be fixed. :).

Nathan @Questor said...

I found a link to it here

https://sites.google.com/a/behtarinc.com/public/labs

it seems http://www.behtarinc.com/
is down.

Nagasree said...

I am getting these errors "The name 'Resources' does not exist in the current context" & "The resource object with key 'File' was not found." when I compile.

Could you please help me with this?