65.9K
CodeProject 正在变化。 阅读更多。
Home

创建多语言网站 -第二部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (59投票s)

2004年8月25日

CPOL

14分钟阅读

viewsIcon

524957

downloadIcon

6014

创建多语言网站 -第二部分。

目录

引言

第一部分中,我们简要回顾了.NET中的本地化是如何实现的。然后,我们通过构建自己的ResourceManager类以及扩展一些服务器控件使其支持本地化来扩展了该功能。

在本第二部分中,我们将更深入地探讨创建多语言Web应用程序的架构。我们将首先使用URL重写来维护用户所在的文化(而不是我们之前使用的更简单的查询字符串方法),然后讨论数据库设计和集成。最后,我们将讨论更高级的问题和可能的解决方案。

URL重写

在我们之前的示例中,我们仅使用QueryString来确定用户的语言选择。虽然这对于展示本地化来说很棒,但在实际应用程序中肯定行不通 - 主要是因为它会在多个页面上造成维护噩梦。我过去使用的一种替代方法是为支持的每种文化使用不同的域名或子域名。虽然这有无需维护的优点,但并非每个人都有权访问多个域名或子域名。另一种替代方法是将文化存储在客户端的cookie中。当然,这具有任何依赖cookie的解决方案的所有优点和缺点。最终,我坚信使用URL重写是最佳选择。

URL重写基础

如果您不熟悉URL重写,很快就会想知道没有它您将如何生活。使用URL重写有很多原因,但最好的是使您的URL更加友好和不复杂。基本上,URL重写允许您创建一个指向不存在的页面的链接,捕获请求,从URL中提取信息,然后将请求发送到正确的页面。没有重定向,所以没有性能损失。一个简单的例子是,如果您有一个会员系统,每个会员都有自己的个人资料页面。通常,该页面将通过以下方式访问:

http://www.domain.com/userDetails.aspx?UserId=93923

使用URL重写,您可以支持一个更漂亮的URL,例如:

http://www.domain.com/johnDoe/details.aspx

即使johnDoe/details.aspx实际上不存在,您也会捕获URL,解析地址,查找用户名johnDoe的用户ID,并将URL重写为userDetails.aspx?UserID=93923。虽然最终效果相同,但URL更加个性化,干净且易于记忆。

URL重写文化

我们希望将文化的名称嵌入URL中,提取它以加载文化,并将URL重写,就好像文化从未存在过一样。例如:

http://www.domain.com/en-CA/login.aspx  --> en-CA culture --> 
        http://www.domain.com/login.aspx
http://www.domain.com/fr-CA/login.aspx  --> fr-CA culture -->
   http://www.domain.com/login.aspx
http://www.domain.com/en-CA/users/admin.aspx?id=3  --> 
  en-CA culture --> http://www.domain.com/users/admin.aspx?id=3
http://www.domain.com/fr-CA/users/admin.aspx?id=3  --> 
  fr-CA culture --> http://www.domain.com/users/admin.aspx?id=3
http://www.domain.com/virtualDir/en-CA/users/admin.aspx?id=3  --> 
  en-CA culture --> http://www.domain.com/virtualDir/users/admin.aspx?id=3
http://www.domain.com/virtualDir/fr-CA/users/admin.aspx?id=3  --> 
  fr-CA culture --> http://www.domain.com/virtualDir/users/admin.aspx?id=3

现在,我们将替换之前放在Global.AsaxApplication_BeginRequest方法中的代码,以使用URL重写。同时,我们将把代码移出Global.asax并放入自定义的HTTPModules中。HTTPModules基本上是更便携的Global.asax

   1:     public class LocalizationHttpModule : IHttpModule {
   2:        public void Init(HttpApplication context) {
   3:           context.BeginRequest += new EventHandler(context_BeginRequest);
   4:        }
   5:        public void Dispose() {}
   6:        private void context_BeginRequest(object sender, EventArgs e) {
   7:           HttpRequest request = ((HttpApplication) sender).Request;
   8:           HttpContext context = ((HttpApplication)sender).Context;
   9:           string applicationPath = request.ApplicationPath;
  10:           if(applicationPath == "/"){
  11:              applicationPath = string.Empty;
  12:           }
  13:           string requestPath = request.Url.AbsolutePath.Substring(
                        applicationPath.Length);
  14:           LoadCulture(ref requestPath);        
  15:           context.RewritePath(applicationPath + requestPath);
  16:        }
  17:        private void LoadCulture(ref string path) {
  18:           string[] pathParts = path.Trim('/').Split('/');
  19:           string defaultCulture = 
        LocalizationConfiguration.GetConfig().DefaultCultureName;
  20:           if(pathParts.Length > 0 && pathParts[0].Length > 0) {
  21:              try {
  22:                 Thread.CurrentThread.CurrentCulture = 
                               new CultureInfo(pathParts[0]);
  23:                 path = path.Remove(0, pathParts[0].Length + 1);
  24:              }catch (Exception ex) {
  25:                 if(!(ex is ArgumentNullException) && 
                        !(ex is ArgumentException)) {
  26:                    throw;
  27:                 }               
  28:                 Thread.CurrentThread.CurrentCulture = 
                           new CultureInfo(defaultCulture);
  29:              }
  30:           }else {
  31:              Thread.CurrentThread.CurrentCulture = 
                               new CultureInfo(defaultCulture);
  32:           }
  33:           Thread.CurrentThread.CurrentUICulture = 
                      Thread.CurrentThread.CurrentCulture;
  34:        }
  35:     }

Init函数是从IHttpModule接口继承的,它允许我们挂接到许多ASP.NET事件。我们唯一感兴趣的是BeginRequest **[第3行]**。一旦我们挂接到BeginRequest事件,每次向.aspx页面发出新的HTTP请求时,context_BeginRequest方法 **[第6行]** 就会触发 - 就像之前一样,这是设置线程文化的理想位置。context_BeginRequest获取请求的路径并从中删除应用程序路径 **[第13行]**。然后调用LoadCulture **[第14行]**。通过从请求路径中删除应用程序路径,文化名称应该在我们路径的第一个段中。LoadCulture尝试从该第一段创建文化 **[第22行]**,如果成功,它会从路径中删除文化 **[第23行]**。如果失败,则加载默认文化 **[第28行]**。最后,context_BeginRequest将URL重写到LoadCulture修改后的路径 **[第15行]**。

一旦这个HttpModule通过web.config加载,它就可以作为核心本地化引擎使用,无需任何额外工作。

数据库设计

我想通过数据库设计来解决的主要问题是保持多语言应用程序 properly normalized。我见过太多次由于本地化而导致的非规范化数据库的相同模式。

示例应用程序要求

为了更好地理解,我们将看一个非常简单的例子:一个用于销售商品的数据库(嗯,其中一小部分)。基本要求是:

  1. 每件商品都有一个单一的类别,该类别从查找值中选择
  2. 每件商品都有名称、描述、卖家ID和价格,并且
  3. 应用程序必须支持英语和法语

糟糕的设计

想出一个糟糕的设计确实不应该花太长时间,这是我几秒钟内想到的模式:

如您所见,每件商品都有一个Seller Id,该ID链接到一个虚构的User表,它有EnglishNameFrenchName列,以及EnglishDescriptionFrenchDescription列以及price。它还有一个CategoryId,链接到Category表。Category表有EnglishFrench name以及EnglishFrench description。此模式可以正常工作,但存在一些严重问题:

  • 该模式违反了第一范式,它有重复的列。
  • 该模式给开发人员增加了额外的负担。开发人员需要知道他想要名为"EnglishDescription"的列,而不是仅仅知道他想要描述。
  • 许多数据库引擎都有最大行大小(例如,SQL Server的任何版本,除了Yukon,它仍处于测试阶段)。使用上述模式,我们将很快遇到该限制。
  • 即使需求明确指出只需要支持EnglishFrench,需求也会改变,并且此模式一点也不灵活。如果您有200个这样的表,则需要修改每个表并添加相应的列。这将使上述三点变得更糟。

好的设计

创建一个干净灵活的模式就像正确地规范化表一样简单 - 即删除重复的列。以下是改进后的模式:

诀窍是识别特定于文化的字段,这很容易,因为它们以"EnglishXXXX"或"FrenchXXXX"开头,这些字段被提取到_Locale表中(我起的名称),并垂直分区(行),而不是水平分区(列)。为了实现垂直分区,我们引入了一个新的Culture表。我意识到单字段的Category表不太好。不幸的是,ItemCategoryId不能直接连接到Category_Locale表,因为它有一个联合主键。尽管如此,非特定于文化的事物很可能会进入Category表,例如"Enabled"和"SortOrder"列。

如果您难以理解此模型,让我们用示例数据填充表:

文化

CultureId 名称 DisplayName
1 en-CA English
2 fr-CA Français

项目

ItemId CategoryId SellerId 价格
20 2 54 20.00
23 1 54 25.00
24 1 34 2.00
25 3 543 2000.00

Item_Locale

ItemId CultureId 名称 描述
20 1 ASP.NET v 2.0 初探 购买第一本关于下一版本ASP.Net的书
20 2 Le premier livre sur la prochain version de ASP.NET Achetez ce livre incroyable pour devenir un expert sur la technologie de demain
23 1 Duct table 50m of premium quality duct tap
23 2 bande de conduit 50m de bande de conduit d'haute qualité

类别

CategoryId
1
2

Category_Locale

CategoryId CultureId 名称 描述
1 1 书籍 All types of reading material should be placed here
1 2 Livre Tous les types de matériel de lecture devraient être placés ici
2 1 Tapes Category for all tapes and other adhesive products
2 2 Adhésifs Catégorie pour tous bandes et d'autres produits adhésifs

如您所见,所有非特定于文化的信息都保存在主表中(Item, Category)。所有特定于文化的信息都保存在_Locale表中(Item_Locale, Category_Locale)。_Locale表为每种支持的文化都有一行。现在模式已规范化,我们已帮助避免了行大小限制,并且添加文化只需向Culture表添加行即可。您可能会认为此模式使查询数据库更加复杂,在下一节中,我们将看到它有多么简单。

查询本地化数据库

信不信由你,使用本地化模式查询数据库要容易得多。让我们看一个例子。假设我们要获取由特定用户(@SellerId)销售的所有商品,并根据您网站访问者的语言(@CultureName)进行本地化。在糟糕的设计中,我们将不得不编写:

   1:  IF @CultureName = 'en-CA' BEGIN
   2:   
   3:     SELECT I.ItemId, I.EnglishName, I.EnglishDescription, I.Price, 
                   C.EnglishName, C.EnglishDescription
   4:        FROM Item I
   5:           INNER JOIN Category C ON I.CategoryId = C.CategoryId
   6:        WHERE I.SellerId = @SellerID
   7:   
   8:  END ELSE BEGIN
   9:   
  10:     SELECT I.ItemId, I.FrenchName, I.FrenchDescription, I.Price, 
                 C.FrenchName, C.FrenchDescription
  11:        FROM Item I
  12:           INNER JOIN Category C ON I.CategoryId = C.CategoryId
  13:        WHERE I.SellerId = @SellerID
  14:   
  15:  END

如果您一直跟进,您会很快意识到这根本不灵活,而且维护起来会很麻烦。您可以使用动态SQL,但您将用一个麻烦来交换另一个麻烦。

使用新的数据库模式,我们将能够编写一个存储过程,该过程在添加新语言时无需修改,它将更具可读性且维护更少。看看这个:

   1:  DECLARE @CultureId INT
   2:  SELECT @CultureId = CultureId
   3:     FROM Culture WHERE CultureName = @CultureName
   4:   
   5:   
   6:  SELECT I.ItemId, IL.[Name], IL.[Description],
              I.Price, CL.[Name], CL.[Description]
   7:     FROM Item I
   8:        INNER JOIN Item_Locale IL ON I.ItemId = 
                 IL.ItemID AND IL.CultureId = @CultureIdD
   9:        INNER JOIN Category C ON I.CategoryId = 
     C.CategoryId  //could be removed since we aren't actually using it
  10:        INNER JOIN Category_Locale CL ON C.CategoryID 
                 = CL.CategoryId AND CL.CultureId = @cultureId
  11:     WHERE I.SellerId = @SellerID

希望您能看到这带来的巨大优势。它的性能可能稍差一些,但无论您支持多少种语言,都有一个单一的查询,这使得它非常灵活且易于维护。我保留了与类别表的连接 **[第9行]**,因为如我之前所说,我们肯定可以在Category中添加一些列,使其具有价值。此外,从@CultureName获取@CultureId **[第2-3行]** 是用户定义函数的绝佳选择,因为几乎每个sproc都会这样做。除此之外,主要区别在于我们正在将_Locale表 **[第8行和10行]** 连接到它们的父级,并针对指定的@CultureId

总结

关于数据库设计讨论,只有两件事需要补充。首先,如果您错过了,您需要传递到存储过程的@CultureName参数实际上是.NET的CultureInfo.Name。这使得事情非常容易,因为用户的当前文化可以在System.Threading.Thread.CurrentThread.CurrentCulture.Name中找到,或者从我们在第一部分的代码示例中,可以通过ResourceManagerResourceManager.CurrentCultureName的形式访问。

第二个问题是,我们在第一部分中使用的ResourceManager使用了XML文件,但是使用与上述类似的模式(带有Culture表),可以轻松地将其修改为使用数据库。这个练习留给您。

高级考虑

在最后一部分,我想指出并解决一些高级问题。

占位符基础

第一个问题是由Frank Froese在我Code Project上发布的第一部分中提出的。这是所有多语言应用程序开发者都必须面对的问题,我感谢他提醒了我。问题在于,您经常需要使用一个或多个占位符的句子。例如,假设您想说类似"{CurrentUserName}的主页"之类的话,您可能会尝试这样做:

   1:  <asp:literal id="user" runat="server" />
   2:  <Localized:LocalizedLiteral id="passwordLabel" 
          runat="server" Key="password" Colon="True" />

并在您的代码隐藏中设置"user"字面量。然而,这不仅在绑定时变得乏味,而且在许多语言中根本行不通,因为它们的语法不同。例如,在法语中,您会希望它是"Page d'acceuil de {CurrentUserName}"。在法语示例中,用户名出现在句子之后,而我们上面的代码根本行不通。当需要其他占位符时,问题只会变得更糟。

我们想要做的是在XML文件值中使用占位符,并在运行时用实际值替换它们。虽然您可以使用数字占位符,例如{0},但我发现使用命名占位符,例如{Name},可以传达更多的含义,并且对于试图理解上下文的翻译人员来说非常有帮助。

让我们看一个基本示例:

   1:  using System.Collections.Specialized;
   2:  using System.Web.UI;
   3:  using System.Web.UI.WebControls;
   4:   
   5:  namespace Localization {
   6:     public class LocalizedLiteral : Literal, ILocalized {
   7:        #region fields and properties
   8:        private string key;
   9:        private bool colon = false;
  10:        private NameValueCollection replacements;
  11:   
  12:        public NameValueCollection Replacements {
  13:           get {
  14:              if (replacements == null){
  15:                 replacements = new NameValueCollection();
  16:              }
  17:              return replacements;
  18:           }
  19:           set { replacements = value; }
  20:        }
  21:   
  22:        public bool Colon {
  23:           get { return colon; }
  24:           set { colon = value; }
  25:        }
  26:   
  27:        public string Key {
  28:           get { return key; }
  29:           set { key = value; }
  30:        }
  31:        #endregion
  32:   
  33:   
  34:        protected override void Render(HtmlTextWriter writer) {
  35:           base.Text = ResourceManager.GetString(key);
  36:           if (colon){
  37:              base.Text += ResourceManager.Colon;
  38:           }
  39:           if (replacements != null){
  40:              foreach (string placeholder in replacements.Keys) {
  41:                 string value = replacements[placeholder];
  42:                 base.Text = base.Text.Replace("{" 
               + placeholder + "}", value);
  43:              }
  44:           }
  45:           base.Render(writer);
  46:        }
  47:     }
  48:  }

基本上,我们添加了一个替换NameValueCollection **[第10、12-20行]**(NameValueCollectionHashtable相同,但它专门用于具有string键和string值,而不是对象)。在我们的Render方法 **[第34行]** 中,我们遍历新字段 **[第40-44行]** 并用指定值替换键。(顺便说一句,如果您喜欢这种方法,可以考虑使用System.Text.StringBuilder对象来提高性能)。

我们可以在页面的代码隐藏中使用Replacement集合,如下所示:

   1:           usernameLabel.Replacements.Add("Name", CurrentUser.UserName);
   2:           usernameLabel.Replacements.Add("Email", CurrentUser.Email);

高级占位符支持

为了获得对占位符的真正支持,我们确实需要使页面开发人员无需编写任何代码即可设置值。在数据绑定时尤其如此。

   1:  <Localized:LocalizedLiteral id="Literal1" key="LoginAudit" runat="server">
   2:        <Localized:Parameter runat="Server" Key="Username" 
              value='<%# DataBinder.Eval(Container.DataItem, "UserName")%>' />
   3:        <Localized:Parameter runat="Server" Key="Name" 
              value='<%# DataBinder.Eval(Container.DataItem, "Name")%>'/>
   4:        <Localized:Parameter runat="Server" key="Date" 
              value='<%# DataBinder.Eval(Container.DataItem, "lastLogin")%>' />         
   5:  </Localized:LocalizedLiteral>

添加这个非常重要的功能是一个相对简单的过程。首先,我们将创建新的Parameter控件:

   1:  using System.Web.UI;
   2:   
   3:  namespace Localization {
   4:     public class Parameter: Control {
   5:        #region Fields and Properties
   6:        private string key;
   7:        private string value;
   8:   
   9:        public string Key {
  10:           get { return key; }
  11:           set { key = value; }
  12:        }
  13:   
  14:        public string Value {
  15:           get { return this.value; }
  16:           set { this.value = value; }
  17:        }
  18:        #endregion
  19:   
  20:   
  21:        public Parameter() {}
  22:        public Parameter(string key, string value) {
  23:           this.key = key;
  24:           this.value = value;
  25:        }
  26:     }
  27:  }

它基本上是一个具有两个属性的控件:键 **[第9-12行]** 和值 **[第14-17行]**。

现在只需最后一步就可以将此新控件用作其他Localized控件的子控件(好吧,实际上,您现在就可以使用它,但它什么也做不了)。这是一个新的render方法,后面是说明:

   1:        protected override void Render(HtmlTextWriter writer) {
   2:           string value = ResourceManager.GetString(key);
   3:           if (colon){
   4:              value += ResourceManager.Colon;
   5:           }
   6:           for (int i = 0; i < Controls.Count; i++){
   7:              Parameter parameter = Controls[i] as Parameter;            
   8:              if(parameter != null){
   9:                 string k = parameter.Key;
  10:                 string v = parameter.Value;
  11:                 value = value.Replace('{' + k.ToUpper() + '}', v);
  12:              }
  13:           }
  14:           base.Text = value;
  15:           base.Render(writer);
  16:        }

基本上,在每个控件的渲染中,您都需要遍历其Control集合 **[第6-13行]**(所有子控件的集合)。如果子控件是Localized.Parameter **[第7、8行]**,您需要替换 **[第11行]** 任何与参数的Key **[第9行]** 相同的占位符,并使用参数的值 **[第10行]**。

在本文随附的代码中,此循环功能已提取到一个帮助类LocalizedUtility中,以便不会为每个控件重复。另外,请注意,您仍然可以在代码隐藏中设置参数:

   1:  myLiteral.Controls.Add(new Parameter("url", 
        "BindingSample.aspx"));

仍然有效。

我想提及我在此过程中遇到的一些注意事项。首先,Literal控件,LocalizedLiteral从中继承,不允许您拥有子控件。因此,我不得不将其更改为继承自Label控件。我还注意到,如果您对基类进行任何操作,控件集合就会被清空。例如,如果在Render方法的开头,您执行base.Text = "";Control的集合将切换到0。我确定这有充分的理由,但这确实让我觉得奇怪。

ASP.NET 2.0

最后要谈的是下一版本的ASP.NET将如何改变您的多语言应用程序开发方式。不幸的是,我还没有花很多时间研究到目前为止可用的alpha和beta包。希望在不久的将来,我将能够写一篇后续文章。我知道的很有希望。看起来他们确实加强了内置支持 - 特别是添加了利用资源的新方法(而不是目前可用的单一ResourceManager方式)。Fredrik Normén在他的这篇博客文章中非常出色地为我们提供了一些初步细节。

下载

此下载与第一部分中的非常相似。需要注意的地方是Global.Asax的移除以及LocalizationHttpModule的引入(请查看web.config中的HttpModule部分,了解它是如何挂接的)。我真的认为这是存储用户文化的好方法。另外,请看看Localization.Parameter是如何工作的,并尝试使用它,我希望您会发现它能满足您的需求。最后,我在示例中没有包含任何数据库代码。我希望上面的截图和代码已经足够。废话不多说,下载!

结论

希望本教程中的一些内容会有所帮助。目标是使内容尽可能易于复制粘贴,同时提供灵活的代码和一些通用准则。可以进行许多增强,例如为设计器中的ILocalized控件提供智能感知支持,将ResourceManager扩展为更像提供者模型,为资源提供管理功能等等。微软通过.NET赋予我们的力量使所有这些成为可能。这真是一次愉快的经历!

历史

  • 2004年8月26日:初版

许可证

本文没有明确的许可证,但可能包含文章文本或下载文件本身的用法条款。如有疑问,请通过下方的讨论区联系作者。作者可能使用的许可证列表可以在此处找到。

© . All rights reserved.