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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.76/5 (22投票s)

2005 年 11 月 1 日

13分钟阅读

viewsIcon

231771

downloadIcon

3025

扩展 .NET 现有的国际化功能,以创建灵活强大的多语言网站。第三部分将不再关注基础知识,而是对我们已涵盖内容的改进。

目录

引言

一年多前,我写了一篇关于在 ASP.NET 中创建多语言 Web 应用程序的分为两部分的文章。第一篇文章重点介绍了一个自定义资源管理器,它解决了内置功能带来的许多问题,并提供了一组自定义服务器控件,使创建多语言网站变得轻松。第二篇文章介绍了一些问题,包括 URL 重写、数据模型设计和增强的自定义服务器控件。第三部分将不再关注基础知识,而是对我们已涵盖内容的改进。其中一些功能,特别是第一个功能,将极大地增加我们解决方案的复杂性。如果您不需要它,我建议您不要实现它——其他功能都不需要它。

尚未阅读本系列前两篇文章的读者将完全跟不上,请立即阅读!(第一部分, 第二部分)

数据库驱动

第一个也是最重要的更改将是修改 ResourceManager 以支持各种本地化内容源。从一开始就打算让 ResourceManager 支持 SQL Server,但最终选择了更简单的代码库。在我参与的项目中,XML 文件仍然是我的首选,但您的项目可能有充分的理由使用其他方法。

提供程序工厂模式

我们将使用提供程序工厂模式来使我们的 ResourceManager 与底层存储机制无关。此模式是许多 ASP.NET 2.0 功能的核心,因此您应该熟悉它。不深入太多细节,提供程序工厂模式通过使用抽象类来发布接口,并依赖其他类来实现功能。换句话说,我们仍然会有一个 ResourceManager 类,它将是一个外壳,还有一个 ResourceManagerXml 类和一个 ResourceManagerSql 类。该设计模式功能强大,因为它允许第三方创建自己的实现。任何人都可以创建一个名为 ResourceManagerAccess 的新类,该类继承自 ResourceManager 并实现与 Access 数据库协同工作所需的逻辑。您可以通过 web.config 配置使用哪个实现。我们将要介绍的代码应该能让您对该设计模式有很好的实际体验,但如果您有兴趣了解更多信息,请务必访问 MSDN

ResourceManager 和 ResourceManagerXml

第一步是将我们的 ResourceManager 转换为一个抽象类,该类定义了我们的提供程序(实现实际逻辑的类)必须实现的成员。

public abstract class ResourceManager
{
   protected abstract string RetrieveString(string key);
}

重用 GetString 这个名字会很好,但由于我们希望 ResourceManager 与前一个版本兼容,我们必须使用一个新名称。如果您不熟悉成员上的 abstract 关键字,它只是意味着任何继承自 ResourceManager 的类必须实现 RetrieveString 函数。还要注意,如果类中的任何成员是 abstract 的,那么类本身就必须被标记为 abstract。这意味着您无法创建该类的新实例。如果您创建了一个 ResourceManager 的实例,然后尝试调用未实现的 RetrieveString 函数,会发生什么?拥有一个无法创建其实例的类可能看起来是浪费字节,但让我们看看它实际上是如何成为面向对象设计的扎实应用。我们现在创建 ResourceManagerXml 类,它是 XML 感知的。

public class ResourceManagerXml: ResourceManager
{
   protected override string RetrieveString(string key)
   {
      NameValueCollection messages = GetResource();
      if (messages[key] == null)
      {
         messages[key] = string.Empty;
#if DEBUG
         throw new ApplicationException("Resource value not found for key: " + key);
#endif
      }
      return messages[key];
   }
}

如您所见,我们所做的就是创建了一个抽象层,并将 ResourceManager 上一个版本的 GetString 功能移到了这里。我们还把所有支持函数从 ResourceManager 移到了 ResourceManagerXml 类中,即私有函数 GetResourceLoadResource(此处未显示)。

由于 ResourceManagerXml 继承自 ResourceManager,我们可以将其转换为 ResourceManager,就像 string 可以转换为 object 一样。这就是我们将两个类联系在一起的地方。在 ResourceManager 类中,我们创建了一个名为 Instance 的属性。

public abstract class ResourceManager
{
   internal static ResourceManager Instance
   {
       get { return new ResourceManagerXml(); }
   }

   public static string GetString(string key)
   {
      return Instance.RetrieveKey(key);
   }

   protected abstract string RetrieveString(string key);
}

如您所期望的那样,GetString 现在通过 Instance 方法调用 ResourceManagerXml 类的 RetrieveString。如果我们现在创建一个 ResourceManagerSql 并让 Instance 属性返回它的一个实例,那么对 RetrieveString 的调用将由 SQL 实现来处理。为了真正有用,我们的 ResourceManager 的实现稍微复杂一些。它根据 web.config 中的值动态创建子类。这意味着如果您想从 XML 切换到 SQL Server,您无需更改类并重新编译,只需更改配置文件中的值即可。这是相关代码的实际实现。

public abstract class ResourceManager
{
   private static ResourceManager instance = null;
      
   static ResourceManager()
   {
      Provider provider = LocalizationConfiguration.GetConfig().Provider;
      Type type = Type.GetType(provider.Type);
      if (type == null)
      {
         throw new ApplicationException(string.Format("Couldn't" + 
                               " load type: {0}", provider.Type));
      }
      object[] arguments = new object[] {provider.Parameters};
      instance = (ResourceManager) Activator.CreateInstance(type, arguments);
   }

   internal static ResourceManager Instance
   {
      get { return instance; }
   }
}

static 构造函数(它自动是私有的,并由 .NET 保证在首次调用 ResourceManager 的任何成员时仅调用一次)从配置中获取一个提供程序,尝试获取类型并实例化对象。Type.GetType 方法可以接受 Namespace.ClassName, AssemblyName 格式的字符串,并创建一个 Type 对象,然后可以使用 Activator.CreateInstance 来实例化它。这种动态调用的类型存在性能损失,因此我们将实例存储在一个私有静态变量中,该变量将在应用程序的整个生命周期中使用(换句话说,昂贵的代码只执行一次)。我们将不详细介绍为支持提供程序而对 LocalizationConfiguration 所做的更改(可下载的代码文档很详细),但它基本上支持我们 web.config 中的以下类型数据:

<Localization 
   ...
   providerName="XmlLocalizationProvider"
 >
   <Provider> 
      <add 
         name="XmlLocalizationProvider"
         type="Localization.ResourceManagerXml, Localization"
         languageFilePath="c:\inetpub\wwwroot\localizedSample\Language"
      />
      <add 
         name="SqlLocalizationProvider"
         type="Localization.ResourceManagerSql, Localization"
         connectionString="Data Source=(local);Initial 
                           Catalog=DATABASE;User Id=sa;Password=PASSWORD;"
      />
   </Provider>
</Localization>

支持多个提供程序,但只加载由 providerName 指定的提供程序。每个提供程序的 nametype 属性用于加载实际实例,所有其他属性都作为 NameValueCollection 传递给提供程序的构造函数。下次看 ResourceManagerSql 时,我们将看到一个示例。

ResourceManagerSql

现在已经具备了使用任何技术存储本地化内容的框架。创建一个与 SQL Server 协同工作的框架只需要三个步骤:创建数据库模型、ResourceManagerSql 类和必要的存储过程。我们将使用的模型与本系列第二部分中讨论的内容规范化方法类似。也可以采用更简单、不那么规范化的模型。此外,我们指定了 1024 个字符的值,但我们可以选择最多 4196 个 NVARCHAR 或甚至一个 Text 字段。甚至还可以使用 VARCHARText 列,并从中拉取(不是最优雅的设计,但可能是必要且实用的)。

Data Model

接下来我们创建 ResourceManagerSql 类。这个类与 XML 类非常相似,只是 LoadResources 方法通过 System.Data 类而不是 XML 文件与 SQL Server 进行交互。我们从构造函数开始,正如我们在上一节所看到的,它被动态调用并传递了一个 NameValueCollection

private string connectionString;
private int cacheDuration;

public ResourceManagerSql(NameValueCollection parameters)
{
   if (parameters == null || parameters["connectionString"] == null)
   {
      throw new ApplicationException("ResourceManagerSql" + 
            " requires connectionString attribute in configuraiton.");
   }
   connectionString = parameters["connectionString"];

   //load the optional cacheDuration parameter, 

   //else we'll cache for 30 minutes

   if (parameters["cacheDuration"] != null)
   {
      cacheDuration = Convert.ToInt32(parameters["cacheDuration"]);
    }
    else
    {
       cacheDuration = 30;
    }
}

这里没有发生什么魔法,读取必需的 connectionString 参数(如果没有则抛出异常),并读取可选的 cacheDuration 参数,或者使用默认值。

由于我们的类继承自 ResourceManager,它必须实现 RetrieveString 方法。此方法与 XML 等效方法相同。

protected override string RetrieveString(string key)
{
   NameValueCollection messages = GetResources();
   if (messages[key] == null)
   {
      messages[key] = string.Empty;
#if DEBUG
      throw new ApplicationException("Resource value" + 
                         " not found for key: " + key);
#endif
   }
   return messages[key];
}

真正的区别发生在 GetResourcesLoadResources 方法中。

private NameValueCollection GetResources()
{
   string currentCulture = ResourceManager.CurrentCultureName;
   string defaultCulture = LocalizationConfiguration.GetConfig().DefaultCultureName;
   string cacheKey = "SQLLocalization:" + defaultCulture + ':' + currentCulture;
   NameValueCollection resources = (NameValueCollection) HttpRuntime.Cache[cacheKey];
   if (resources == null)
   {
      resources = LoadResources(defaultCulture, currentCulture);
      HttpRuntime.Cache.Insert(cacheKey, resources, null, 
                  DateTime.Now.AddMinutes(cacheDuration), 
                              Cache.NoSlidingExpiration);
   }
   return resources;
}

private NameValueCollection LoadResources(string defaultCulture, 
                                          string currentCulture)
{
   SqlConnection connection = null;
   SqlCommand command = null;
   SqlDataReader reader = null;
   NameValueCollection resources = new NameValueCollection();
   try
   {
      connection = new SqlConnection(connectionString);
      command = new SqlCommand("LoadResources", connection);
      command.CommandType = CommandType.StoredProcedure;
      command.Parameters.Add("@DefaultCulture", 
              SqlDbType.Char,5).Value = defaultCulture;
      command.Parameters.Add("@CurrentCulture", 
              SqlDbType.Char,5).Value = currentCulture;
      connection.Open();
      reader = command.ExecuteReader(CommandBehavior.SingleResult);
      int nameOrdinal = reader.GetOrdinal("Name");
      int valueOrdinal = reader.GetOrdinal("Value");
      while(reader.Read())
      {
         resources.Add(reader.GetString(nameOrdinal), 
                     reader.GetString(valueOrdinal));
      }
   }
   finally
   {
      if (connection != null)
      {
         connection.Dispose();
      }
      if (command != null)
      {
         command.Dispose();
      }
      if (reader != null && !reader.IsClosed)
      {
         reader.Close();
      }
   }
   return resources;
}

这应该与您以前编写过的任何 SQL 代码相似。这种方法与 XML 方法的主要区别在于,我们将回退逻辑推给了存储过程。这有助于减少数据库调用。此外,由于我们无法为缓存添加 FileDependency,因此我们设置了一个绝对过期时间,可以通过 web.config 进行更改。

最后,剩下的就是 LoadResources 存储过程。

CREATE PROCEDURE LoadResources
(
  @DefaultCulture CHAR(5),
  @CurrentCulture CHAR(5)
)
AS
SET NOCOUNT ON

  SELECT R.[Name], RL.[Value]
   FROM Resources R 
      INNER JOIN ResourcesLocale RL ON R.Id = RL.ResourceId
      INNER JOIN Culture C ON RL.CultureId = C.CultureId
   WHERE C.Culture = @CurrentCulture
   
   UNION ALL

  SELECT R.[Name], RL.[Value]
   FROM Resources R 
      INNER JOIN ResourcesLocale RL ON R.Id = RL.ResourceId
      INNER JOIN Culture C ON RL.CultureId = C.CultureId
   WHERE C.Culture = @DefaultCulture
    AND R.[Name] NOT IN (
        SELECT [Name] FROM Resources R2 
          INNER JOIN ResourcesLocale RL2 ON R2.Id = RL2.ResourceId
          INNER JOIN Culture C2 ON RL2.CultureId = C2.CultureId
       WHERE C2.Culture = @CurrentCulture
    )
SET NOCOUNT OFF

该存储过程的复杂性比看起来要高。但是,我们的数据模型得到了很好的规范化,这意味着我们需要多个 JOIN,并且我们决定将回退逻辑推给存储过程。

最终考虑

XML 和 SQL 实现之间有一些共享代码。这些功能可以推送到抽象 ResourceManager 中。例如,RetrieveString 方法(在两种情况下都相同)可以放在 ResourceManager 中,而 GetResource 方法可以是 abstract 的。同样,缓存可以在 ResourceManager 中实现,而不是在每个实现中。但是,您永远无法知道特定提供程序将如何实现,我也不想做出一个会使提供程序难以开发的假设。例如,仅仅因为 SQL 和 XML 提供程序使用了 NameValueCollection,并不意味着 Oracle 提供程序也会。

还有一点需要注意,ResourceManager 的接口定义良好,但内部实现是完全私有的。这意味着您可以随心所欲地进行更改和调整,而不必担心破坏现有代码。提供程序模型本身就提倡良好的编程实践,您应该在适当的情况下尝试在自己的代码中模仿这些实践。

图像支持

我们要添加的下一个功能是对图像的本地化支持。这是本地化一组内容的一个有用演示。图像通常有三个需要本地化的值:heightwidthalt 标签。高度和宽度可能没有意义,但包含文字的图像的长度通常会因文化而异。过去,我看到过的解决方案是重用 GetString 方法和自定义命名约定。例如,我经常看到:

img.Alt = ResourceManager.GetString("Image_Welcome_Alt");
img.Width = Convert.ToInt32(ResourceManager.GetString("Image_welcome_Width));
img.Height = Convert.ToInt32(ResourceManager.GetString("Image_Welcome_Height"));

这个解决方案既不优雅又容易出错。相反,我们将构建一个 GetImage 方法,该方法返回一个 LocalizedImageDataLocalizedImageData 是一个简单的类,包含我们所有本地化的属性。

public class LocalizedImageData
{
   private int width;
   private int height;
   private string alt;

   public int Width
   {
     get { return width; }
     set { width = value; }
   }
   public int Height
   {
     get { return height; }
     set { height = value; }
   }
   public string Alt
   {
     get { return alt; }
     set { alt = value; }
   }

   public LocalizedImageData()
   {
   }
   public LocalizedImageData(int width, 
                int height, string alt)
   {
      this.width = width;
      this.height = height;
      this.alt = alt;
   }
}

现在我们的 GetImage 函数有东西可返回了——一个包含所有本地化内容的类。我们在 ResourceManager 中实现 GetImage 函数。与我们在本文第一部分介绍的最新 GetString 一样,它将依赖于一个抽象的 RetrieveImage 函数,每个提供程序都必须实现该函数。我们在本文中只介绍 XML 实现,但可下载的代码也在 SQL 类中实现了它。

首先,我们在 ResourceManager 中创建 GetImage 函数。

public static LocalizedImageData GetImage(string key)
{
   return Instance.RetrieveImage(key);
}

接下来,我们创建抽象的 RetrieveImage 函数。

protected abstract LocalizedImageData RetrieveImage(string key);

最后,我们在 ResourceManagerXml 类中实现 RetrieveImage

protected override LocalizedImageData RetrieveImage(string key)
{
   Hashtable imageData = GetImages();
   if (imageData[key] == null)
   {
      imageData[key] = new LocalizedImageData(0,0,string.Empty);
#if DEBUG
      throw new ApplicationException("Resource value not found for key: " + key);
#endif
   }
   return (LocalizedImageData) imageData[key];
}

我们快速浏览了这段代码,因为它与 GetString 方法几乎相同。然而,它调用 GetImages 而不是 LoadResources。这就是代码开始变得有些不同之处。我们可以使用同一个 XML 文件存储一种新型数据,但我们决定使用不同的文件可能有助于保持代码整洁。主要区别发生在 XML 文件的解析中。

private void LoadImage(Hashtable resource, string culture, string cacheKey)
{
   string file = string.Format("{0}\\{1}\\Images.xml", fileName, culture);
   XmlDocument xml = new XmlDocument();
   xml.Load(file);
   foreach (XmlNode n in xml.SelectSingleNode("Images"))
   {
      if (n.NodeType != XmlNodeType.Comment)
      {
         LocalizedImageData data = new LocalizedImageData();
         data.Alt = n.InnerText;
         data.Height = Convert.ToInt32(n.Attributes["height"].Value);
         data.Width = Convert.ToInt32(n.Attributes["width"].Value);
         resource[n.Attributes["name"].Value] = data;
      }
   }
   HttpRuntime.Cache.Insert(cacheKey, resource, 
               new CacheDependency(file), 
               DateTime.MaxValue, TimeSpan.Zero);
}

由于 XML 结构稍微复杂一些,LoadImages 函数需要做更多的工作,但实际上,这一切都很简单。我们使用的 XML 文件看起来像这样:

<Images>
   <item name="Canada" width="10" height="10">Canada!</item>
   ...
   ...
</Images>

最后,最后一步是创建我们的本地化控件。

public class LocalizedHtmlImage : HtmlImage, ILocalized
{
   private const string imageUrlFormat = "{0}/{1}/{2}";
   private string key;
   private bool colon = false;
   public bool Colon
   {
      get { return colon; }
      set { colon = value; }
   }
   public string Key
   {
      get { return key; }
      set { key = value; }
   }

   protected override void Render(HtmlTextWriter writer)
   {
      LocalizedImageData data = ResourceManager.GetImage(key);
      if (data != null)
      {
         base.Src = string.Format(imageUrlFormat, 
                    LocalizationConfiguration.GetConfig().ImagePath, 
                    ResourceManager.CurrentCultureName, base.Src);
         base.Width = data.Width;
         base.Height = data.Height;
         base.Alt = data.Alt;
      }
      if (colon)
      {
         base.Alt += ResourceManager.Colon;
      }
      base.Render(writer);
   }
}

与所有本地化控件一样,我们的类实现了 ILocalized 接口,该接口定义了 KeyColon 两个属性。然而,在 Render 方法中调用 GetString 而不是 GetImage,并根据返回的 LocalizedImageData 类设置相应的图像值。最后,请注意图像的 src 也是本地化的。基本上,如果您指定 src="Welcome.gif",它将被转换为 src="/images/en-CA/welcome.gif",假设您的 web.config"/images/" 指定为 ImagePath,并将 en-Ca 指定为当前区域性。

我们相当快速地完成了图像本地化的练习。这在很大程度上是由于它与现有代码的相似性。几乎所有其他本地化数据组都可以用同样的方式处理。例如,您可以使用相同的方法本地化电子邮件(主题和正文)。

JavaScript 本地化

我们已经很好地提供了用户丰富多语言体验所需的所有必要工具。我们的 UI 中仍有一个方面需要一些本地化功能:JavaScript。随着 AJAX 的普及,JavaScript 的作用只会越来越大。即使是最常见的 JavaScript 验证也需要本地化。我们需要在客户端直接构建一些功能。

我们的设计将尝试在 JavaScript 中模拟我们已经在服务器端构建的内容。理想情况下,我们希望能够在 JavaScript 中执行 ResourceManager.GetString(XYZ); 并获得本地化值。一种解决方案是使用 AJAX,但这对于许多应用程序来说可能过于耗时。相反,我们将创建几个实用函数,将本地化内容转储到 JavaScript 数组中。我们将把数组包装在一个公开 GetString 方法的 JavaScript 对象中。由于 JavaScript 的一些独特功能,代码非常紧凑。缺点是整个本地化内容将不可用,而是我们必须在 Page_Load 期间指定哪些值可用。首先,我们来看 JavaScript 方法。

var ResourceManager = new RM();
function RM()
{
  this.list = new Array();
};
RM.prototype.AddString = function(key, value)
{
  this.list[key] = value;
};
RM.prototype.GetString = function(key)
{
  var result = this.list[key];  
  for (var i = 1; i < arguments.length; ++i)
  {
    result = result.replace("{" + (i-1) + "}", arguments[i]);
  }
  return result;
};

如果您不熟悉 JavaScript 对象,上面的代码可能看起来有点奇怪。基本上,我们创建了一个 RM 类(客户端)的新实例,该类只包含一个数组。接下来,我们创建两个成员:AddStringGetString。您可能不知道,JavaScript 数组不需要使用整数索引。相反,它们可以是关联的(类似于 Hashtable)。这显然是我们客户端 ResourceManager 工作方式的基础。如果我们根据键将值添加到数组,我们可以通过相同的键轻松检索该值。上面的 GetString 函数还支持占位符(这是我们在第 2 部分辛勤工作的成果)。JavaScript 允许将动态数量的参数传递给函数。GetString() 假设第一个参数是要获取的资源的键,所有后续参数都是占位符值。例如,要使用“InvalidEmail”资源“{0} 不是有效的电子邮件”,我们将执行:

ResourceManager.GetString("InvalidEmail", email.value);

我们将使用一个小的服务器端实用函数将本地化内容转储到客户端数组中。

public static void RegisterLocaleResource(string[] keys)
{
   if (keys == null || keys.Length == 0)
   {
      return;
   }
   Page page = HttpContext.Current.Handler as Page;
   if (page == null)
   {
      throw new InvalidOperationException("RegisterResourceManager" + 
                               " must be called from within a page");
   }
   StringBuilder sb = new StringBuilder("<script language="\""JavaScript\">");
   sb.Append(Environment.NewLine);
   foreach (string key in keys)
   {
      sb.Append("ResourceManager.AddString('");
      sb.Append(PrepareStringForJavaScript(key));
      sb.Append("', '");
      sb.Append(PrepareStringForJavaScript(ResourceManager.GetString(key)));
      sb.Append("');");
      sb.Append(Environment.NewLine);
   }
   sb.Append("</script>");
   page.RegisterStartupScript("RM:" + string.Join(":", keys), sb.ToString());
}

该函数使用一个或多个键进行调用。但是,它不会返回本地化值,而是将值添加到客户端 ResourceManager。换句话说,在使用 InvalidEmail 资源之前,我们需要调用 ResourceManager.RegisterLocaleResource("InvalidEmail");。同样,如果您愿意,可以传递多个值,例如 ResourceManager.RegisterLocaleResource("InvalidEmail", "InvalidUsername", "Success");。您也可以多次调用 RegisterLocaleResource。如果您使用的控件需要特定的本地化内容,这非常理想。

LocalizedNoParametersLiteral 困扰着我!

到目前为止,我们所做的一切都不应该破坏现有代码。然而,在第 2 部分,我们创建了一个 LocalizedNoParametersLiteral 服务器控件。这是一个错误——说实话,我不知道当时在想什么。可下载的示例将 LocalizedNoParametersLiteral 重命名为 LocalizedLiteral,而 LocalizedLiteral 现在是 LocalizedLabel。如果需要,您可以将其重命名为旧名称以避免代码中断,但这是我这次必须修复的一个问题。

结论

在本部分中,我们介绍了三个主要增强功能:

  • 提供程序模型和 SQL 功能,
  • 内置图像支持,以及
  • JavaScript 功能。

除了更改 LocalizedNoParametersLiteralLocalizdLiteral 的名称外,可下载的示例还有一些其他小的代码更改。这些更改对您现有的代码不应有任何影响,因为它们仅仅是小幅改进。

除了成为一个真正有用的库之外,我希望“创建多语言网站”系列使用了强大的设计实践,您可以在自己的代码中加以利用。

© . All rights reserved.