"There are two hard things in computer science: cache invalidation, naming things, and off-by-one errors" -- http://martinfowler.com/bliki/TwoHardThings.html
We will not create computer science today, we will just use one of the fruits of computer science. Today I will show you how to use NHibernate and its built-in caching mechanism and make it compatible with localization.
I've tried creating a seamless multilingual app in NHibernate before, it works even on brownfield projects. And I've tried a cache-enabled NHibernate app with Redis, it just works, it's simply amazing. I tried mixing the two, it has a problem though, localized fields must be mapped to their own classes, otherwise they can't be switched to another language when the master entity is cached already.
This post will show you how to make a multilingual app on NHibernate without compromising the entity and query cacheablity.
First, let's start with the domain model.
public class Product { public virtual int ProductId { get; set; } public virtual int YearIntroduced { get; set; } } [Serializable] public class ProductLanguageCompositeKey { public virtual int ProductId { get; set; } public virtual string LanguageCode { get; set; } public override bool Equals(object obj) { if (obj == null) return false; var t = obj as ProductLanguageCompositeKey; if (t == null) return false; if (ProductId == t.ProductId && LanguageCode == t.LanguageCode) return true; return false; } public override int GetHashCode() { return (ProductId + "|" + LanguageCode).GetHashCode(); } } public class ProductLanguage { public virtual ProductLanguageCompositeKey ProductLanguageCompositeKey { get; set; } public ProductLanguage() { this.ProductLanguageCompositeKey = new ProductLanguageCompositeKey(); } public virtual string ProductName { get; set; } public virtual string ProductDescription { get; set; } // A guide for the user, so he/she could know the source language of the untranslated string came from public virtual string ActualLanguageCode { get; set; } }
For detailed explanation on Serializable on the composite key and the rationale for extracting the composite key of the product localization to its own class. Read on: http://devlicio.us/blogs/anne_epstein/archive/2009/11/20/nhibernate-and-composite-keys.aspx
To get the multi-lingual "table":
create function dbo.get_product_i18n(@language_code nvarchar(6)) returns table -- (product_id int, language_code nvarchar(6), product_name nvarchar(1000), product_description nvarchar(1000)) as return with a as ( select the_rank = row_number() over(partition by product_id order by case language_code when @language_code then 1 when 'en' then 2 else 3 end) ,* ,actual_language_code = language_code from product_i18n ) select -- composite key for ORM: a.product_id, language_code = @language_code -- ...composite key , a.product_name, a.product_description , a.actual_language_code from a where the_rank = 1; GO
That function will return the entity's localized fields(product_name and product_description in our example), if there's no matched found just return the English version of it, and if there's no English just return any localization that matches the entity.
Data Source:
product_id year_introduced ----------- --------------- 1 2016 2 2007 3 1964 4 1994 (4 row(s) affected) product_id language_code product_name product_description ----------- ------------- ------------------ ------------------------------- 1 en Apple I First Personal Computer 1 zh Pingguo Xian Xian Dian Nao 2 en iPhone First Truly Smartphone 3 ph Sarao World's Top Jeepney Brand 4 zh Anta China's Top Shoe Brand (5 row(s) affected)
Sample output of get_product_i18n:
select * from dbo.get_product_i18n('zh'); product_id language_code product_name product_description actual_language_code ----------- ------------- ------------------ ---------------------------- -------------------- 1 zh Pingguo Xian Xian Dian Nao zh 2 zh iPhone First Truly Smartphone en 3 zh Sarao World's Top Jeepney Brand ph 4 zh Anta China's Top Shoe Brand zh (4 row(s) affected)
There's a zh translation for First Personal Computer, hence the tvf returns the localized version of First Personal Computer, i.e., Pingguo Xian
There's no zh translation for First Truly Smartphone, but there's an English(fallback language) version of it, hence the tvf returns the English version.
There's no zh translation for World's Top Jeepney Brand, and there's no English version of it, hence the tvf returns the native version, i.e., Sarao
There's a zh localization for China's Top Shoe Brand, hence the get_product_i18n will just return that.
On ProductLanguage mapping, you'll notice that we use merge, this will add the product + language pair if it doesn't exist yet, update if it already exist. If we look at the sample output of the query above, it also return a row for iPhone even if the zh language don't have a translation for it yet, the merge command will be able to INSERT the translation for iPhone if the zh user decided to change the product name and product description to something else. Then if the entity already exist on database, use the UPDATE command instead.
SqlInsert and SqlUpdate don't have a named parameter capability yet, the order of the parameters (denoted by the question mark) is simple, the fields just follows the exact order of its corresponding properties on the class. With minor caveat, primary key(s) are on the last part of the database command. Hence this is the order of the parameter: product_name, product_description, actual_language_code, pk_product_id, pk_language_code. pk_product_id and pk_language_code being the composite keys.
using NHibernate.Mapping.ByCode.Conformist; using NHibernate.Mapping.ByCode; using LocalizationWithCaching.Models; namespace LocalizationWithCaching.ModelMappings { public class ProductLanguageMapping : ClassMapping<ProductLanguage> { string save = "merge product_i18n as dst using( values(?,?,?, ?,?) ) as src(product_name, product_description, actual_language_code, pk_product_id, pk_language_code) on src.pk_product_id = dst.product_id and src.pk_language_code = dst.language_code when matched then update set dst.product_name = src.product_name, dst.product_description = src.product_description when not matched then insert (product_id, language_code, product_name, product_description) values (src.pk_product_id, src.pk_language_code, src.product_name, src.product_description);"; public ProductLanguageMapping() { // When the query from this mapping is run on different languages, they will have their isolated copy of query caching. // That behavior comes from NHibernate filters. Table("dbo.get_product_i18n(:lf.LanguageCode)"); // lf is an NHibernate filter // Hence the following behavior: // TestQueryCache("en"); // database hit // TestQueryCache("zh"); // database hit // TestQueryCache("en"); // cached query hit // TestQueryCache("zh"); // cached query hit // TestQueryCache("ca"); // database hit // If we don't use NHibernate filters(e.g. using CONTEXT_INFO technique instead), identical queries run from different languages will get the same query cache. // Thus this mapping: // Table("dbo.get_product_i18n(convert(nvarchar, substring(context_info(), 5, convert(int, substring(context_info(), 1, 4)) ) ))"); // Will have this behavior: // TestQueryCache("en"); // database hit // TestQueryCache("zh"); // cached query hit // TestQueryCache("en"); // cached query hit // TestQueryCache("zh"); // cached query hit // TestQueryCache("ca"); // cached query hit // Need to be turned on, so N+1 won't happen // http://stackoverflow.com/questions/8761249/how-do-i-make-nhibernate-cache-fetched-child-collections Cache(x => x.Usage(CacheUsage.ReadWrite)); ComponentAsId(key => key.ProductLanguageCompositeKey, m => { m.Property(x => x.ProductId, c => c.Column("product_id")); m.Property(x => x.LanguageCode, c => c.Column("language_code")); }); SqlInsert(save); SqlUpdate(save); Property(x => x.ProductName, c => c.Column("product_name")); Property(x => x.ProductDescription, c => c.Column("product_description")); Property(x => x.ActualLanguageCode, c => c.Column("actual_language_code")); } } }
The product mapping:
using NHibernate.Mapping.ByCode.Conformist; using NHibernate.Mapping.ByCode; using LocalizationWithCaching.Models; namespace LocalizationWithCaching.ModelMappings { public class ProductMapping : ClassMapping<Product> { public ProductMapping() { Table("product"); Id(x => x.ProductId, c => { c.Column("product_id"); c.Generator(Generators.Identity); }); // Need to be turned on, so N+1 won't happen // http://stackoverflow.com/questions/8761249/how-do-i-make-nhibernate-cache-fetched-child-collections Cache(x => x.Usage(CacheUsage.ReadWrite)); Property(x => x.YearIntroduced, c => c.Column("year_introduced")); } } }
Now on the interesting part, when mapping a table-valued function...
create function dbo.tvf_get_product_sold() returns table as return select p.product_id, ordered_count = coalesce(sum(o.qty), 0) from dbo.product p left join dbo.ordered_product o on p.product_id = o.product_id group by p.product_id go namespace LocalizationWithCaching.Models { public class GetProductSold { public virtual int ProductId { get; set; } public virtual int Sold { get; set; } } }
...we must have a mechanism to invalidate the query cache whenever there's a change on ordered_product. NHibernate just have that, just specify Synchronize(new[] { "ordered_product" }); on GetProductSold mapping:
public class GetProductSoldMapping : ClassMapping<GetProductSold> { public GetProductSoldMapping() { Table("dbo.tvf_get_product_sold()"); Cache(x => x.Usage(CacheUsage.ReadOnly)); Synchronize(new[] { "ordered_product" }); Id(x => x.ProductId, c => c.Column("product_id")); Property(x => x.Sold, c => c.Column("ordered_count")); } }
Thus we will get this behavior when we specify synchronize:
TestTvfGetProductSoldQueryCache("en"); // database hit TestTvfGetProductSoldQueryCache("en"); // cached query hit UpdateOrderedProduct(orderedProductId: 1, languageCode: "en"); // database hit on entity get. database hit on update. refresh entity cache. invalidates GetProductSold query cache TestOrderedProductEntityCache(orderedProductId: 1, languageCode: "en"); // cached entity hit on entity get TestTvfGetProductSoldQueryCache("en"); // was invalidated on line 3. database hit TestTvfGetProductSoldQueryCache("en"); // cached query hit private void TestTvfGetProductSoldQueryCache(string languageCode) { using (var session = Mapper.TheMapper.GetSessionFactory().OpenSession()) using (var tx = session.BeginTransaction().SetLanguage(session, languageCode)) { var x = (from q in from ps in session.Query<GetProductSold>() join pl in session.Query<ProductLanguage>() on ps.ProductId equals pl.ProductLanguageCompositeKey.ProductId select new { ps, pl } where q.pl.ProductLanguageCompositeKey.LanguageCode == languageCode select q).Cacheable(); // Rationale for Cacheable at the end: // http://www.ienablemuch.com/2013/06/nhibernate-query-caching.html var l = x.ToList(); } } private void UpdateOrderedProduct(int orderedProductId, string languageCode) { using (var session = Mapper.TheMapper.GetSessionFactory().OpenSession()) using (var tx = session.BeginTransaction().SetLanguage(session, languageCode)) { var x = session.Get<OrderedProduct>(orderedProductId); x.Quantity = x.Quantity + 1; session.Save(x); tx.Commit(); } } private void TestOrderedProductEntityCache(int orderedProductId, string languageCode) { using (var session = Mapper.TheMapper.GetSessionFactory().OpenSession()) using (var tx = session.BeginTransaction().SetLanguage(session, languageCode)) { var x = session.Get<OrderedProduct>(orderedProductId); } }
With the right modelling, multilingual with caching can be easily achieved on NHibernate.
Here's the detailed behavior of NHibernate caching:
TestProductAndLanguageQueryCache("en"); // database hit TestProductAndLanguageQueryCache("zh"); // database hit TestProductAndLanguageQueryCache("en"); // cached query hit TestProductAndLanguageQueryCache("zh"); // cached query hit TestProductAndLanguageQueryCache("ca"); // database hit TestTvfGetOrderInfoQueryCache("en"); // database hit TestTvfGetOrderInfoQueryCache("en"); // cached query hit TestTvfGetOrderInfoQueryCache("zh"); // database hit TestTvfGetOrderInfoQueryCache("zh"); // cached query hit TestTvfGetOrderInfoQueryCache("en"); // cached query hit UpdateProduct(productId: 1, languageCode: "en"); // cached entity hit on entity get. database hit on update. refresh entity cache. Invalidates GetOrderInfo query cache TestTvfGetOrderInfoQueryCache("en"); // database hit TestTvfGetOrderInfoQueryCache("en"); // cached query hit UpdateProductLanguage(productId: 1, languageCode: "zh"); // cached entity hit on entity get. database hit on update. refresh entity cache. Invalidates GetOrderInfo query cache TestTvfGetOrderInfoQueryCache("en"); // database hit. even we only touch the Chinese language above TestTvfGetOrderInfoQueryCache("en"); // cached query hit TestTvfGetProductSoldQueryCache("en"); // database hit UpdateProduct(productId: 1, languageCode: "en"); // cached entity hit on entity get. database hit on update. refresh entity cache. does not invalidates GetProductSold query cache TestTvfGetProductSoldQueryCache("en"); // was not invalidated. cached query hit. GetProductSold query cache is Synchronized with ordered_product only TestTvfGetProductSoldQueryCache("en"); // cached query hit UpdateProductLanguage(productId: 1, languageCode: "zh"); // cached hit on entity get. database hit on update. refresh entity cache. invalidates GetProductSold query cache as it joins on ProductLanguage entity TestTvfGetProductSoldQueryCache("en"); // cached query was invalidated. database hit TestTvfGetProductSoldQueryCache("en"); // cached query hit TestTvfGetProductSoldQueryCache("en"); // cached query hit UpdateOrderedProduct(orderedProductId: 1, languageCode: "en"); // database hit on entity get. database hit on update. refresh entity cache. invalidates GetProductSold query cache TestOrderedProductEntityCache(orderedProductId: 1, languageCode: "en"); // cached entity hit on entity get TestTvfGetProductSoldQueryCache("en"); // database hit TestTvfGetProductSoldQueryCache("en"); // cached query hit UpdateOrderedProduct(orderedProductId: 1, languageCode: "zh"); // cached entity hit on entity get. database hit on update. refresh entity cache. invalidates GetProductSold query cache TestTvfGetProductSoldQueryCache("en"); // database hit. even we only touch the Chinese language above TestTvfGetProductSoldQueryCache("en"); // cached query hit UpdateOrderedProduct(orderedProductId: 1, languageCode: "en"); // cached entity hit on entity get. database hit on update. refresh entity cache TestOrderedProductEntityCache(orderedProductId: 1, languageCode: "en"); // cached entity hit on entity get TestProductEntityCache(productId: 1,languageCode: "en"); // cached entity hit TestProductLanguageEntityCache(productId: 1, languageCode: "en"); // cached entity hit TestProductEntityCache(productId: 2, languageCode: "zh"); // cached entity hit TestProductLanguageEntityCache(productId: 2, languageCode: "en"); // cached entity hit UpdateProduct(productId: 1, languageCode: "en"); // cached entity hit on entity get. database hit on update. refresh entity cache TestProductEntityCache(productId: 1, languageCode: "en"); // cached entity hit UpdateProduct(productId: 1, languageCode: "en"); // cached entity hit on entity get. database hit. refresh entity cache. invalidates cached query TestProductAndLanguageQueryCache("en"); // cached query was invalidated. database hit TestProductAndLanguageQueryCache("en"); // cached query hit TestProductQueryCache("en"); // cached query was invalidated. database hit TestProductQueryCache("ca"); // cached query was invalidated. database hit TestProductQueryCache("en"); // cached query hit TestProductQueryCache("ca"); // cached query hit UpdateProduct(productId: 1, languageCode: "en"); // cached entity hit on entity get. database hit on update. refresh entity cache. invalidates cached query TestProductEntityCache(productId: 1, languageCode: "en"); // cached entity hit TestProductAndLanguageQueryCache("en"); // cached query was invalidated. database hit TestProductAndLanguageQueryCache("ca"); // database hit TestProductLanguageEntityCache(productId: 1, languageCode: "ca"); // cached entity hit TestProductLanguageEntityCache(productId: 1, languageCode: "es"); // database hit TestProductLanguageEntityCache(productId: 1, languageCode: "es"); // cached entity hit // cached entity hit on entity get. database hit on update. entity cache is refreshed. invalidates *ALL* language version of ProductLanguage query cache UpdateProductLanguage(productId: 1, languageCode: "es"); TestProductAndLanguageQueryCache("zh"); // was invalidated. database hit TestProductAndLanguageQueryCache("es"); // was invalidated. database hit TestProductAndLanguageQueryCache("es"); // cached query hit TestProductAndLanguageQueryCache("en"); // was invalidated. database hit TestProductAndLanguageQueryCache("zh"); // cached query hit TestProductAndLanguageQueryCache("en"); // cached query hit TestProductLanguageEntityCache(productId: 1, languageCode: "es"); // cached entity hit
Full code: https://github.com/MichaelBuen/DemoLocalizationWithCaching/tree/optimized