2017年11月16日 星期四

【MVC】多語系


網站如果希望能提供多語系版本,且網址規則如下該如何實作?

http://abcdefg.com/index             【預設繁體中文】
http://abcdefg.com/zh-TW/index 【繁體中文】
http://abcdefg.com/zh-CN/index  【簡體中文】
http://abcdefg.com/en-US/index  【英文】

先從Action開始,挖個Route參數來抓目前使用者希望的語系為何,並且設定Culture
(因為重點是多語系範例,所以就不考慮大小寫判斷那些,還請暫時忽略)
[Route("~/index")]
[Route("~/{culture}/index")]
public ActionResult Index(string culture)
{
    switch (culture)
    {
        case "zh-CN":
            break;
        case "en-US":
            break;
        default:
            culture = "zh-TW";
            break;
    }

    //設定多語系
    CultureInfo ci = new CultureInfo(culture);
    Thread.CurrentThread.CurrentCulture = ci;
    Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(ci.Name);
    return View();
}



接著新增語系檔資料夾App_GlobalResource
專案右鍵  >  加入  > 加入ASP.NET資料夾 > App_GlobalResource


新增對應語系的設定檔App_GlobalResources右鍵  >  加入  > 資源檔



這邊的資源檔名稱是固定的,不能任意更改


在各個設定檔設定簡單文字,用來判別是否有正確切換語系






View上面就簡單做,只顯示出對應語系的CultureNow



測試



 好的做到這邊看起來沒什麼問題,但接下來的問題是,是否後續開發都要在每個Action加上這個RouteAttribute


[Route("~/{culture}/index")]
[Route("~/{culture}/home")]
[Route("~/{culture}/user")]
......

且設定語系的段落勢必也要寫成ActionFilterAttribute,這樣其實增加了開發的困難之外,只要有人忘記加上,那新的頁面就會少了多語系功能。

工程師的美德就是懶,是否有辦法只做一次就讓全站Route都自動設定好呢? 這時候就要用到DefaultDirectRouteProvider 來解決這這個問題了
DefaultDirectProvider能幫我們做什麼?

它能幫我們在註冊全站Route Template的時候做一些邏輯的加工,在這邊的案例應用上,
我們希望在註冊全站的Route的時候都自動幫我們在Template最前面加上{culture}
來達到做一次多語系設定即可

讓我來實作看看,先新增一個CultureRouteProvider
/// <summary>
    /// 多語系Route Provider
    /// </summary>
    /// <seealso cref="System.Web.Mvc.Routing.DefaultDirectRouteProvider" />
    public class CultureRouteProvider: DefaultDirectRouteProvider
    {
        /// <summary>
        /// 取得所指定之動作描述元的一組路由 Factory。
        /// </summary>
        /// <param name="actionDescriptor">動作描述元。</param>
        /// <returns>
        /// 一組路由 Factory。
        /// </returns>
        protected override IReadOnlyList<IDirectRouteFactory> GetActionRouteFactories(ActionDescriptor actionDescriptor)
        {
            IReadOnlyList<IDirectRouteFactory> actionRouteFactories = base.GetActionRouteFactories(actionDescriptor);

            List<IDirectRouteFactory> actionDirectRouteFactories = new List<IDirectRouteFactory>();

            foreach (IDirectRouteFactory routeFactory in actionRouteFactories)
            {
                RouteAttribute routeAttr = routeFactory as RouteAttribute;
                if (routeAttr != null && !string.IsNullOrEmpty(routeAttr.Template))
                {
                    //每個Route Template原本的樣子
                    //已剛剛的Action為例,就是 "~/index"
                    var template = $"{routeAttr.Template}";

                    var routeAttribute = new RouteAttribute(template)
                    {
                        Order = routeAttr.Order,
                        Name = routeAttr.Name
                    };
                    actionDirectRouteFactories.Add(routeAttribute);

                    //替每組Action都多加上一組{culture}/RouterTemplateLanguage的多語系RouteMap
                    //EX: "~/index" 多一組 "~/{culture}/index"
                    var includeLangTemplate = routeAttr.Template.Replace("~/", string.Format(@"~/{{culture:regex(^(zh\-tw|en\-us|zh\-cn)$)}}/"));
                    
                    //註冊這組多語系的Route Template
                    var includeLangRouteAttribute = new RouteAttribute(includeLangTemplate);
                    includeLangRouteAttribute.Order = routeAttr.Order + 1;
                    includeLangRouteAttribute.Name = routeAttr.Name;

                    actionDirectRouteFactories.Add(includeLangRouteAttribute);
                }
            }

            return actionDirectRouteFactories;
        }
    }

這邊需要特別注意一下,我在culture後面加上了RouteConstraint的正規表示法限制,目的是讓只有zh-tw ,  en-us , zh-cn才會落入這個模板的範圍,如果沒有這段限制,就會變成萬用Route,有點像是{Controller}/{action}/{id}那般,所有網址都會跑到這邊,造成網址大亂!!!

接著將這組CultureRouteProvider在RouteConfig註冊使用
public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        //註冊CultureRouteProvider
        var constraintsResolver = new DefaultInlineConstraintResolver();
        RouteTable.Routes.MapMvcAttributeRoutes(new CultureRouteProvider());
    }
}

然後將原本Action那組多語系RouteAttribute拿掉試試看
//拿掉多語系RouteAttribute
[Route("~/index")]
public ActionResult Index(string culture)
{
    switch (culture)
    {
        case "zh-CN":
            break;
        case "en-US":
            break;
        default:
            culture = "zh-TW";
            break;
    }

    //設定多語系
    CultureInfo ci = new CultureInfo(culture);
    Thread.CurrentThread.CurrentCulture = ci;
    Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(ci.Name);
    return View();
}

會發現多語系的功能依然存在,所以CultureRouteProvider有正確運作,自動的幫我們加上了多語系的Route Template。

接著是處理設定語系的段落,不應該讓設定語系的判斷落在每個Action裡面,所以將它獨立拉出來到自定義的CultureFilter中
public class CultureFilter : IAuthorizationFilter
{
    public List<string> AllowCultures = new List<string>
    {
        "zh-cn","zh-tw","en-us"
    };

    public void OnAuthorization(AuthorizationContext filterContext)
    {
        string culture = string.Empty;
        if (filterContext.RequestContext.HttpContext.Request.Url.Segments.Count() & gt; 1)
        {
            culture = filterContext.RequestContext.HttpContext.Request.Url.Segments[1].Replace("/", string.Empty);
        }

        if (string.IsNullOrWhiteSpace(culture) ||
            !AllowCultures.Any(x = > x.ToLower() == culture.ToLower()))
            {
            culture = "zh-tw";
        }


        //設定多語系
        CultureInfo ci = new CultureInfo(culture);
        Thread.CurrentThread.CurrentCulture = ci;
        Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(ci.Name);
    }
}

在FilterConfig註冊CultureFilter
public class FilterConfig
{
    public static void RegisterGlobalFilters(GlobalFilterCollection filters)
    {
        filters.Add(new HandleErrorAttribute());
        //多國語系
        filters.Add(new CultureFilter());
    }
}

將原本的Action所有判斷拿掉會發現多語系的功能還是一切正常
[Route("~/index")]
public ActionResult Index()
{
    return View();
}

這樣就差不多大功告成,其實要優化的地方還很多,例如大小寫判斷,多語系應該拉成Enum方便擴充,Route Constraint應該跟隨著多語系的Enum去自動產生....等,因為這是獨立拉出來Demo的程式,就不搞得像Production Code一樣複雜了,知道自己注意一下就好XD

2017年11月15日 星期三

【MVC】Router Constraint


當今天有個網址的需求是 http://abcdefg.com/【使用者暱稱】,使用者暱稱帶到誰的就會到個人頁網址,EX : http://abcdefg.com/toyo 就連到Toyo個人頁面,http://abcdefg.com/steven就連到Steven個人頁,那我們Router可以寫成
[Route("~/toyo")]
[Route("~/steven")]
public ActionResult Content()
{
    return View();
}

這樣寫的確兩個網址都能連到了,但卻抓不到UserName所以不知道怎麼顯示個人頁,調整一下
[Route("~/{UserName}")]
public ActionResult Content(string userName)
{
   ViewBag.Name = userName;
   return View();
}

下個問題來了,Tom明明不是這邊的用戶,卻也會導到這個Action,導致後端抓不到對應資料顯示錯誤,能不能只有abcdefg.com/Toyo 跟 abcdefg.com/Steven 的時候才導來這,其他什麼阿貓阿狗,甚至是常用的Index、Home、Menu之類的不會跑錯。

這時候RouteConstraint就派上用場了
public class UserNameConstraint : IRouteConstraint
{
    public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
    {
        if (values.ContainsKey(parameterName))
        {
            var UserName = values[parameterName] as string;

            return UserName.ToLower() == "toyo" ||
                   UserName.ToLower() == "steven";
        }

        return false;
    }
}

接著在RouteConfig註冊這組Constraint,讓RouteAttribute可以使用
public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        //註冊Router ConStraint
        var constraintsResolver = new DefaultInlineConstraintResolver();
        constraintsResolver.ConstraintMap.Add("MustUserName", typeof(UserNameConstraint));
        routes.MapMvcAttributeRoutes(constraintsResolver);
    }
}
將RouteAttribute的UserName加上這個限制
[Route("~/{UserName:MustUserName}")]
public ActionResult MyContent(string userName)
{
    ViewBag.Name = userName;
    return View();
}
這樣只要UserName不是帶Toyo或是Steven的就都不會導到這個Action了。

其實RouteConstraint官方已經提供一下基礎的限制可以使用,例如一定要是Int,字串長度之類的方便用法,而且更重要的是在這案例之中,使用者名稱我們是寫死的,只要把那段改成抓外部來源,例如資料庫之類的,這樣就能後台使用者有新增時,Route的限制就自動更新了


延伸閱讀:
Attribute Routing in ASP.NET MVC 5
DemoShop : ASP.NET MVC Route 自訂限制條件(constraints)的技巧

2017年11月14日 星期二

【Tools】修復Html缺少Close Tag問題 - HtmlAgilityPack


開放後台給非工程人員編輯Html,往往都要擔負一些Html Tag錯誤整導致破版的風險。這次就碰到上線前發現部分資料區塊缺少了Close Tag,導致只要讀到那些資料的頁面都破版,但上千筆資料請人一筆一筆去檢查又太不切實際。所以就研究了一下是否有套件可以處理這類的問題,結果就發現了一個強大的套件 : HtmlAgilityPack

該套件主要功能是拿來解析網頁,似乎更多人是拿來製作爬蟲工具,但他同時也很貼心的提供API來分析Html Tag是否正確


首先先從Nuget載入該套件

然後用以下程式來進行Html修復
//商品的尺寸報表Html有錯誤
var ps = this.Products.Where(x = > x.SizeReport != null);
foreach (var p in ps)
{
 HtmlDocument doc = new HtmlDocument();
 //fix when nesting errors are detected
 doc.OptionFixNestedTags = true;

 //將Html Editor編輯的東西丟進去
 doc.LoadHtml(p.SizeReport.ToString());

 //將修復後的Html結果存到MemoryStream
 MemoryStream stream = new MemoryStream();
 doc.Save(stream);
 try
 {
  using (StreamReader reader = new StreamReader(stream, Encoding.Default))
  {
   stream.Position = 0;
   p.SizeReport = reader.ReadToEnd(); //儲存回DB
   this.SubmitChanges();
  }

 }
 catch (Exception ex)
 {

 }
}

2017年11月8日 星期三

【Azure】如何跨DB讀取資料


忙了一大段時間,突然發現好長時間沒更新部落格了,這段時間專案實在太忙了,慢慢的再把這些時間學到的東西慢慢筆記下來,今天就先從Azure開始吧!!

以前如果有跨DB存取或Join Table,通常都會請DBA開Link DB的方式來處理,但在Azure SQL上面就沒有這個選項了
跨DB讀取資料

但它推出了一個語法來達成這樣子的需求 External Data Source,假設我在A資料庫,想要Join B資料庫PromoEmail Table取得行銷案的Email資料來做交叉分析,這時候我們可以這樣寫
CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'This Account Password'; 

CREATE DATABASE SCOPED CREDENTIAL AzureStorageCredential 
WITH IDENTITY = 'This DataBase Login Account', 
SECRET = 'This Account Password';

CREATE EXTERNAL DATA SOURCE RefPromoTableDataBase
WITH  
(  
    TYPE=RDBMS,  
    LOCATION ='Your Azure SQL Database Location',  
    DATABASE_NAME = 'B',  --外連B資料庫
    CREDENTIAL = AzureStorageCredential
);  

--建立一個External Table
--這邊Schema就取決於你要從B Database的PromoEmail Table取得什麼欄位
--欄位名稱跟資料型態要相同
CREATE EXTERNAL TABLE [dbo].[PromoEmail](  
    [Email] [varchar] (300) Not NULL  
)  
WITH  
(  
    DATA_SOURCE = RefPromoTableDataBase
);    

--在A資料庫Join PromoEmail Table
--看看行銷案轉換成訂單的績效
SELECT email
FROM [Order] o
Join PromoEmail pe on o.UserEmail = pe.Email

當然最後做完後,要記得把這些資源都釋放掉

DROP EXTERNAL TABLE PromoEmail
DROP EXTERNAL DATA SOURCE RefPromoTableDataBase
DROP DATABASE SCOPED CREDENTIAL AzureStorageCredential  
DROP MASTER KEY  


參考資料
Cross Database Queries In Azure SQL
SETTING UP CROSS DATABASE QUERIES IN AZURE SQL DATABASE