Sunday, June 30, 2013

Multilingual + Caching on NHibernate: Made Compatible

"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

No comments:

Post a Comment