Silverlight のローカライズ

Silverlight のロケール リソースを読み込むためのヒントと秘訣 (第 2 部)

Matthew Delisle

コード サンプルのダウンロード

このシリーズの第 1 部 (msdn.microsoft.com/magazine/gg650657) では、Windows Communication Foundation (WCF) サービス、シンプルなデータベース スキーマ、および UI にリソースの変化を通知するクライアント側のコードを使用して、Silverlight のリソースを読み込む方法について説明しました。そのソリューションでは、アプリケーションの初期化中に Web サービス呼び出しによって既定のリソースを読み込みました。今月は、John Brodeur と共同で、Web サービス呼び出しを行わずに既定のリソースを読み込む方法を紹介します。

ローカライズの標準プロセス

先月概要を説明したように、ローカライズの標準プロセスには、ロケール リソースを取得する方法がいくつかあります。よく使用するのは、デザイン時に .resx ファイルをアプリケーションに埋め込む方法です (図 1 参照)。

Resource Files Embedded in the Application

図 1 アプリケーションに埋め込まれたリソース ファイル

アプリケーションにリソースを埋め込む方法には、すべてのリソースがアプリケーションと共にダウンロードされてしまうという問題があります。Silverlight に用意されているネイティブのローカライズ方法では、なんらかの方法でアプリケーションに必ずリソースが埋め込まれます。

既定のリソース セットのみを埋め込み、その他のリソースは必要に応じて読み込む方が優れたソリューションです。必要に応じてリソースを読み込む処理は、さまざまは方法で実現できます。たとえば、このシリーズの第 1 部で紹介したように .xap ファイルや .resx ファイルを取得したり、Web サービスを使用して .resx XML 文字列を取得したりすることによって実現できます。しかし、既定のロケールはユーザーのプライマリ ロケールではない可能性があるとういう問題が残っています。既定のロケールと異なるロケールを使用するユーザーは、常にサーバーからリソースを取得する必要があります。

最も優れたソリューションは、現在のユーザーのロケール固有のリソースが埋め込まれた .xap ファイルを必要に応じて生成するソリューションです。このようなソリューションであれば、任意のユーザーの既定のリソースを読み込むために Web サービス呼び出しを行う必要がありません。Web サービス呼び出しは、実行時にロケールを変更する場合にのみ必要です。ここからは、このようなソリューションについて説明します。

ローカライズのカスタム プロセス

今回紹介するカスタム ローカライズ ソリューションは、クライアント コンポーネントとサーバー コンポーネントから成り立ち、第 1 部で作成した CustomLocalization プロジェクトを基盤に構築します。このプロセスを、まずは、Silverlight オブジェクトを含む .aspx ファイルから説明します。

HttpHandler のすべてのパラメーターは、Silverlight アプリケーションの URL を使用して渡す必要があります。ブラウザー カルチャを渡すために、URL パラメーターを追加し、次のように現在のスレッドのカルチャを指定します。

<param name="source" value="ClientBin/CustomLocalization.xap?c=
   <%= Thread.CurrentThread.CurrentCulture.Name %>&rs=ui"/>

また、Thread クラスを使用するために、System.Threading 名前空間の Import ステートメントも追加します。

<%@ Import Namespace="System.Threading" %>

さらに、取得対象のリソース セットを表す rs というパラメーターも追加しています。

.aspx ファイル内に必要なコードはこれだけです。ユーザーのロケールは HttpHandler に渡され、そのカルチャによって指定されるリソースが .xap ファイルに埋め込まれます。

ハンドラーを作成する

では、Web プロジェクトのルートに XapHandler というファイルを作成します。このクラスに IHttpHandler を実装し、再利用不可にします。メソッド間で CultureInfo、HttpContext、および ResourceSet の各オブジェクトを共有する 3 つのフィールドを追加します。ここまでのコードは次のようになります。

using System.Web;
namespace CustomLocalization.Web {
  public class XapHandler : IHttpHandler {
    private CultureInfo Culture;
    private HttpContext Context;
    private string ResourceSet;

    public bool IsReusable { get { return false; } }

    public void ProcessRequest(HttpContext context) {
      throw new System.NotImplementedException();
      } } }

ProcessRequest メソッドでは、カルチャとリソース セットを取得し、カルチャを検証して、ローカライズ済みの .xap ファイルを作成し、そのファイルをクライアントに送信します。パラメーターを取得するには、次のように Request オブジェクトの Params 配列からアクセスします。

string culture = context.Request.Params["c"];]
ResourceSet = context.Request.Params["rs"];

カルチャを検証するには、次のように CultureInfo オブジェクトの作成を試みます。コンストラクターが失敗したら、カルチャは無効であると見なします。

if (!string.IsNullOrEmpty(culture)) {
  try {
    Culture = new CultureInfo(culture);
  }
  catch (Exception ex) {
    // Throw an error
  } }

ここで、よく使用するいくつかの関数を保持する Utilities クラスを再利用のために作成することをお勧めします。まず、クライアントに応答を送信してから、応答オブジェクトを閉じる関数を記述します。これは、エラー メッセージの送信に有効です。コードは次のようになります。

public static void SendResponse(HttpContext context, int statusCode,  
  string message) {
  if (context == null) return;
  context.Response.StatusCode = statusCode;
  if(!string.IsNullOrEmpty(message)) {
    context.Response.StatusDescription = message;
  }
  context.Response.End();
}

この方法を使用して、無効なカルチャが指定されたときにエラー メッセージを送信します。

if (!string.IsNullOrEmpty(culture)) {
  try {
    Culture = new CultureInfo(culture);
  }
  catch (Exception ex) {
    // Throw an error
    Utilities.SendResponse(Context, 500,
    "The string " + culture + " is not recognized as a valid culture.");
     return;
  } }

カルチャを検証したら、次は、ローカライズ済みの .xap ファイルを作成し、ファイル パスを返します。

ローカライズ済みの XAP ファイルを作成する

ここで、魔法のようにあらゆる処理を行います。string 型のパラメーターを受け取る、CreateLocalizedXapFile というメソッドを作成します。このパラメーターは、埋め込みリソースを含まない、アプリケーションの .xap ファイルを格納するサーバー上の場所を指定します。リソースを含まない .xap ファイルがサーバー上にないと処理を続行できないため、次のようにしてエラーを発生させます。

string xapWithoutResources = Context.Server.MapPath(Context.Request.Path);
if (string.IsNullOrEmpty(xapWithoutResources) || !File.Exists(xapWithoutResources))
  Utilities.SendResponse(Context, 500, "The XAP file does not exist.");
  return;
}
else {
  string localizedXapFilePath = CreateLocalizedXapFile(xapWithoutResources);
}

CreateLocalizedXapFile メソッドについて説明する前に、Web サーバー上でのこのソリューションのディレクトリ構造を確認しましょう。ルート Web フォルダーに acme という Web アプリケーションがあるとします。acme フォルダー内には ClientBin ディレクトリがあり、通常はここに Silverlight アプリケーションが格納されます。リソースを含まない .xap ファイルはここに配置します。このディレクトリの下位にはロケール識別子 (en-US、es-MX、fr-FR など) にちなんで名前が付けられた他のディレクトリがあり、これらのディレクトリにはロケール固有の .xap ファイルを作成して格納します。ディレクトリ構造は、図 2 のようになります。

Directory Structure for Localized XAP Files

図 2 ローカライズ済み XAP ファイルのディレクトリ構造

では、CreateLocalizedXapFile メソッドを詳しく見ていきましょう。このメソッドには、2 つのメイン実行経路があります。1 つ目は、ローカライズ済み .xap ファイルが存在し、最新状態である場合の経路です。この場合の処理は簡単で、ローカライズ済み .xap ファイルのフルパスを返すだけです。2 つ目は、ローカライズ済み .xap ファイルが存在しないか、最新状態でない場合の経路です。ローカライズ済み .xap ファイルは、ファイル内に埋め込まれているプレーン .xap ファイルや .resx ファイルより古い場合に、最新状態ではないと見なされます。個々の .resx ファイルはローカライズ済み .xap ファイル外に格納されるため容易に変更できます。また、これらのファイルを使用して、ローカライズ済み .xap ファイルが最新状態であるかどうかが確認されます。ローカライズ済み .xap ファイルが最新状態でなければ、そのファイルにプレーン .xap ファイルを上書きし、上書きしたファイルにリソースを挿入します。図 3 に、コメントを追加した CreateLocalizedXapFile メソッドを示します。

図 3 CreateLocalizedXapFile メソッド

private string CreateLocalizedXapFile(string filePath) {
  FileInfo plainXap = new FileInfo(filePath);
  string localizedXapFilePath = plainXap.FullName;

  try {
    // Get the localized XAP file
    FileInfo localizedXap = new FileInfo(plainXap.DirectoryName + 
      "\\" + Culture.Name + "\\" + plainXap.Name);
                
    // Get the RESX file for the locale
    FileInfo resxFile = new FileInfo(GetResourceFilePath(
      Context, ResourceSet, Culture.Name));

    // Check to see if the file already exists and is up to date
    if (!localizedXap.Exists || (localizedXap.LastWriteTime < 
      plainXap.LastWriteTime) || 
      (localizedXap.LastWriteTime < resxFile.LastWriteTime)) {
    if (!Directory.Exists(localizedXap.DirectoryName))  {
      Directory.CreateDirectory(localizedXap.DirectoryName);
     }
                    
     // Copy the XAP without resources
     localizedXap = plainXap.CopyTo(localizedXap.FullName, true);

     // Inject the resources into the plain XAP, turning it into a localized XAP
     if (!InjectResourceIntoXAP(localizedXap, resxFile)) {
       localizedXap.Delete();
  } }                
     if (File.Exists(localizedXap.FullName)) {
       localizedXapFilePath = localizedXap.FullName;
     } }
  catch (Exception ex) {
    // If any error occurs, throw back the error message
    if (!File.Exists(localizedXapFilePath)) {
      Utilities.SendResponse(Context, 500, ex.Message);
    } }
  return localizedXapFilePath;
}

図 4 に GetResourceFilePath メソッドを示します。このメソッドに渡すパラメーターは、コンテキスト、リソース セット、およびカルチャです。リソース ファイルを表す文字列を作成し、リソース ファイルが存在するかどうかを確認して、存在する場合はファイル パスを返します。

図 4 GetResourceFilePath メソッド

private static string GetResourceFilePath(
  HttpContext context, string resourceSet, string culture) {
  if (context == null) return null;
  if (string.IsNullOrEmpty(culture)) return null;

  string resxFilePath = resourceSet + "." + culture + ".resx";
string folderPath = context.Server.MapPath(ResourceBasePath);
FileInfo resxFile = new FileInfo(folderPath + resxFilePath);

if (!resxFile.Exists) {
  Utilities.SendResponse(context, 500, "The resx file does not exist 
    for the locale " + culture);
}
return resxFile.FullName;
}

XAP ファイルにリソースを挿入する

では、InjectResourceIntoXAP メソッドを見ていきましょう。ほとんどの Silverlight 開発者がご存知のように、.xap ファイルは zip ファイルの拡張子を変えたものです。.xap ファイルは、適切なファイルをまとめて圧縮し、圧縮後のファイルに .xap 拡張子を割り当てるだけで作成されています。今回のシナリオでは、既存の .zip ファイル (リソースを含まない .xap ファイル) を取得し、そのファイルに適切なカルチャの .resx ファイルを追加する必要があります。ここでは、圧縮機能を支援するために、dotnetzip.codeplex.com (英語) にある DotNetZip ライブラリを使用します。初めは System.IO.ZipPackage 名前空間を使用して外部ライブラリを使わずに圧縮しようとしましたが、結果として生成される .xap ファイルに互換性に関する問題が発生しました。この処理は System.IO.ZipPackage 名前空間のみを使用して実行できますが、DotNetZip ライブラリを使用する方がはるかに簡単です。

圧縮機能を支援するために作成したユーティリティ メソッドは、次のとおりです。

public static void AddFileToZip(string zipFile, string fileToAdd, 
  string directoryPathInZip) {
  if (string.IsNullOrEmpty(zipFile) || string.IsNullOrEmpty(fileToAdd)) return;

  using (ZipFile zip = ZipFile.Read(zipFile)) {
    zip.AddFile(fileToAdd, directoryPathInZip);
    zip.Save();
  } }

InjectResourceIntoXAP メソッドには、エラー処理と共に AddFileToZip メソッドへの呼び出しをラップしているだけです。

private bool InjectResourceIntoXAP(FileInfo localizedXapFile, 
  FileInfo localizedResxFile) {
  if (localizedXapFile.Exists && localizedResxFile.Exists) {
    try {
      Utilities.AddFileToZip(localizedXapFile.FullName, 
        localizedResxFile.FullName, string.Empty);
      return true;
    }
    catch { return false; }
  }
  return false;
}

当初、ソリューションの最も複雑な部分の 1 つになるだろうと考えていた部分は、結果的に最も単純な部分になりました。動的に作成する .xap ファイルの他のあらゆる用途に流用してください。

クライアントにファイルを送信する

ここで初めに戻り、ProcessRequest メソッドを完成します。これまで、CreateLocalizedXapFile メソッドを呼び出すコードを追加し、.xap ファイルのパスを返しましたが、そのファイルでは一切処理を行っていません。クライアントへのファイルの送信を支援するために、別のユーティリティ メソッドを作成します。TransmitFile というメソッドは、ヘッダー、コンテンツ タイプ、およびファイルのキャッシュの有効期限を設定し、HttpResponse クラスの TransmitFile メソッドを使用してバッファー処理を行わずにクライアントに直接ファイルを送信します。図 5 にそのコードを示します。

図 5 TransmitFile メソッド

public static void TransmitFile(HttpContext context, string filePath, 
  string contentType, bool deleteFile) {
  if (context == null) return;
  if (string.IsNullOrEmpty(filePath)) return;

  FileInfo file = new FileInfo(filePath);
   try {
     if (file.Exists) {
       context.Response.AppendHeader("Content-Length", file.Length.ToString());
       context.Response.ContentType = contentType;
       if (!context.IsDebuggingEnabled) {                      
         context.Response.Cache.SetCacheability(HttpCacheability.Public);
         context.Response.ExpiresAbsolute = DateTime.UtcNow.AddDays(1);
         context.Response.Cache.SetLastModified(DateTime.UtcNow); 
       }

       context.Response.TransmitFile(file.FullName);
     if (context.Response.IsClientConnected) {
       context.Response.Flush();
     }  }
     else {
       Utilities.SendResponse(context, 404, "File Not Found (" + filePath + ")."); }
     }
     finally {
       if (deleteFile && file.Exists) { file.Delete(); }
     } }

ProcessRequest メソッドでは、TransmitFile メソッドを呼び出します。TransmitFile メソッドには、コンテキストとローカライズ済みの .xap ファイルのパスを指定し、送信完了後にファイルを削除しない (キャッシュする) ように指定します。

Utilities.TransmitFile(context, localizedXapFilePath, "application/x-silverlight-app", false);

機能させる

これで、機能する .xap ハンドラーが準備できたので、Web アプリケーションと連携させる必要があります。web.config の httpHandlers セクションにこのハンドラーを追加します。ハンドラーのパスは、拡張子の前にアスタリスクを挿入した .xap ファイルのファイル パスです。これにより、その .xap ファイルに対するすべての要求が、パラメーターに関係なく、ハンドラーにルーティングされます。system.web 構成セクションは Cassini および IIS 6 で使用され、system.webServer 構成セクションは IIS 7 で使用されます。

<system.web>
    <httpHandlers>
      <add verb="GET" path="ClientBin/CustomLocalization*.xap" 
        type="CustomLocalization.Web.XapHandler, CustomLocalization.Web"/>
    </httpHandlers>
  </system.web>
<system.webServer>
    <handlers>
      <add name="XapHandler" verb="GET" path=
        "ClientBin/CustomLocalization*.xap" 
        type="CustomLocalization.Web.XapHandler, CustomLocalization.Web"/>
    </handlers>
  </system.webServer>

これで、サーバーにある各ロケールのフォルダーにリソース ファイルを移動することで、ソリューションが機能するようになります。.resx ファイルを更新するたびに、ローカライズ済み .xap ファイルが最新状態でなくなり、必要に応じて再生成されます。そのため、Web サービス呼び出しを一度も行わずに、任意の言語のリソースを含む Silverlight アプリケーションを展開できるソリューションになります。では、もう 1 歩踏み込んでみましょう。ロケール情報の本来の情報源は .resx ファイルではありません。本来の情報源はデータベースで、.resx ファイルは二次的なものにすぎません。.resx ファイルを操作する必要がないのが理想的なソリューションです。リソースが追加または更新されたときは、データベースだけが変更されます。現在のところ、データベースを変更したときに .resx ファイルを更新する必要があります。これは、半自動のツールを使用しても面倒な作業です。ここからは、この作業の自動化について見ていきます。

カスタム リソース プロバイダーを使用する

カスタム リソース プロバイダーの作成は複雑な作業なのでここでは詳しく取り上げません。同様の実装に関する説明については、Rick Strahl 氏が執筆した記事 (bit.ly/ltVajU、英語) を参照してください。今回は、この記事で紹介されているリソース プロバイダー ソリューションのサブセットを使用します。メイン メソッドの GenerateResXFileNormalizedForCulture は、特定のカルチャ文字列の完全なリソース セットをデータベースにクエリします。カルチャのリソース セットの作成時に、インバリアント カルチャ、ニュートラル (または言語) カルチャ、特定のカルチャ リソースの順に照合して、各キーの標準の .NET リソース マネージャー階層を管理します。

たとえば、en-us カルチャに対する要求は、ui.resx、ui.en.resx、および ui.en-us.resx というファイルの組み合わせになります。

埋め込みリソースを使用する

第 1 部では、ソリューションから Web サービスを呼び出してすべてのリソースを取得し、Web サービスを使用できない場合は、Web ディレクトリに格納された、既定のリソース文字列を含むファイルを使用しました。今回は、どちらの手順も必要なくなります。既定のリソース文字列を含むファイルを削除し、そのファイルを参照するアプリケーション設定を取り除きます。次の手順は、アプリケーションの初期化時に埋め込みリソースを読み込むように、SmartResourceManager を変更します。ChangeCulture は、ソリューションに埋め込みリソースを統合するための重要なメソッドです。現在、このメソッドは次のようになっています。

public void ChangeCulture(CultureInfo culture) {
  if (!ResourceSets.ContainsKey(culture.Name)) {
    localeClient.GetResourcesAsync(culture.Name, culture);
  }
  else {
    ResourceSet = ResourceSets[culture.Name];
    Thread.CurrentThread.CurrentCulture = 
      Thread.CurrentThread.CurrentUICulture = culture;
  } }

すぐに GetResourcesAsync 操作を呼び出すのではなく、埋め込みリソース ファイルからリソースの読み込みを試みます。読み込みに失敗した場合は、Web サービスを呼び出します。埋め込みリソースの読み込みに成功した場合は、アクティブ リソース セットを更新します。コードは次のようになります。

if (!ResourceSets.ContainsKey(culture.Name)) {
  if (!LoadEmbeddedResource(culture)) {
    localeClient.GetResourcesAsync(culture.Name, culture);    
  } 
else {
  ResourceSet = ResourceSets[culture.Name];
  Thread.CurrentThread.CurrentCulture = 
    Thread.CurrentThread.CurrentUICulture = culture;
} }

LoadEmbeddedResource メソッドでは、resourceSet.culture.resx という形式でアプリケーション内のファイルを検索します。ファイルを検出したら、そのファイルを XmlDocument として読み込み、ディクショナリ解析を行って、ResourceSets ディクショナリに追加します。このコードは、図 6 のようになります。

図 6 LoadEmbeddedResource メソッド

private bool LoadEmbeddedResource(CultureInfo culture) {
  bool loaded = false;
  try {
    string resxFile = "ui." + culture.Name + ".resx";
    using (XmlReader xmlReader = XmlReader.Create(resxFile)) {
      var rs = ResxToDictionary(xmlReader);
      SetCulture(culture, rs);
      loaded = true;
   } }
   catch (Exception) {
     loaded = false;
  }
return loaded;
}

SetCulture メソッドは、エントリが存在する場合はリソース セットを更新し、存在しない場合はエントリを追加します。

まとめ

今月は、.xap ファイルと .resx ファイルを管理するサーバー側コンポーネントを統合し、第 1 部のソリューションを仕上げました。このソリューションでは、既定のリソースを取得するために Web サービス呼び出しを行う必要はありません。アプリケーションに既定のリソースをパッケージ化するという考え方は、ユーザーが求めるリソースをいくつでも含めるように拡張できます。

このソリューションによって、リソース文字列に必要な管理が減少します。必要に応じてデータベースから .resx ファイルを生成することは、.resx ファイルにの管理がほとんど必要ないことを意味します。Rick Strahl 氏は、データベースからロケール リソースを読み取り、それらを変更して、.resx ファイルを作成するために使用できる、便利なローカライズ ツールをコーディングしました。このツールは、bit.ly/kfjtI2 (英語) からダウンロードできます。

このソリューションにフックする場所は多数あるため、ソリューションをカスタマイズして目的の作業をほぼすべて行うことができます。コーディングを楽しんでください。

Matthew Delisle は、Schneider Electric 社に勤務しており、節電によって節約できる最先端の Silverlight アプリケーションの開発に取り組んでいます。彼のブログは、mattdelisle.net (英語) からアクセスできます。

John Brodeur は、Schneider Electric 社のソフトウェア アーキテクトで、Web 開発とアプリケーション デザインの経験が豊富です。

この記事のレビューに協力してくれた技術スタッフの Shawn Wildermuth に心より感謝いたします。