本文内容
- 字符串
- 处理日期和时间
- 数字值
- 使用特定于区域性的设置
全球化涉及到设计和开发世界通用的应用,这些应用支持本地化界面和区域数据,供位于多个区域性的用户使用。 在设计阶段开始之前,应确定应用将支持哪些区域性。 虽然应用以单一区域性或区域作为默认目标,但可设计和编写应用,使其能够轻松地供其他区域性或区域的用户使用。
作为开发人员,我们对由区域性组成的用户界面和数据都做出过假设。 例如,对于说英语的美国开发人员来说,将日期和时间数据序列化为采用 MM/dd/yyyy hh:mm:ss
格式的字符串似乎是相当合理的。 但是,如果在处于不同区域性的系统上将该字符串进行反序列化,则可能会引发 FormatException 异常或生成不准确的数据。 全球化使我们能够识别这些特定于区域性的假设,并确保它们不会影响到应用的设计和编码。
本文将讨论在全球化应用中处理字符串、日期和时间值以及数值时,应考虑的一些主要问题和遵从的最佳做法。
1、字符串
处理字符和字符串的是全球化的重点,因为每个区域性或区域可能使用不同的字符和字符集,且排序方式也不同。 本节提供在全球化应用中使用字符串的一些建议。
1.1 在内部使用 Unicode
默认情况下,.NET 使用 Unicode 字符串。 一个 Unicode 字符串由零个、一个或多个 Char 对象组成,其中每个对象表示一个 UTF-16 代码单元。 对于每个字符集中的几乎每个字符来说,都有一个在全球范围内使用的 Unicode 表达式。
许多应用程序和操作系统(包括 Windows 操作系统)也可以使用代码页来表示字符集。 代码页通常包含从 0x00 到 0x7F 的标准 ASCII 值,并将其他字符映射到从 0x80 到 0xFF 的剩余值。 从 0x80 到 0xFF 的值的解释取决于具体的代码页。 因此,如有可能,应避免在全球化应用中使用代码页。
以下示例阐释了当系统上的默认代码页与保存数据的代码页不同时,解释代码页数据的危险。 (若要模拟此场景,示例应明确指定不同的代码页。)首先,示例定义了一个由希腊字母表的大写字符组成的数组。 然后使用代码页 737(也称为 MS-DOS 希腊语)将其编码成一个字节数组,并保存到文件。 如果检索该文件并使用代码页 737 对其字节数组进行解码,则会还原原始字符。 但是,如果检索该文件并使用代码页 1252(或按拉丁字母表来表示字符的 Windows-1252)对其字节数组进行解码,原始数据则会丢失。
using System;
using System.IO;
using System.Text;
public class Example
{
public static void CodePages()
{
// Represent Greek uppercase characters in code page 737.
char[] greekChars =
{
'Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ', 'Η', 'Θ',
'Ι', 'Κ', 'Λ', 'Μ', 'Ν', 'Ξ', 'Ο', 'Π',
'Ρ', 'Σ', 'Τ', 'Υ', 'Φ', 'Χ', 'Ψ', 'Ω'
};
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Encoding cp737 = Encoding.GetEncoding(737);
int nBytes = cp737.GetByteCount(greekChars);
byte[] bytes737 = new byte[nBytes];
bytes737 = cp737.GetBytes(greekChars);
// Write the bytes to a file.
FileStream fs = new FileStream(@".\\CodePageBytes.dat", FileMode.Create);
fs.Write(bytes737, 0, bytes737.Length);
fs.Close();
// Retrieve the byte data from the file.
fs = new FileStream(@".\\CodePageBytes.dat", FileMode.Open);
byte[] bytes1 = new byte[fs.Length];
fs.Read(bytes1, 0, (int)fs.Length);
fs.Close();
// Restore the data on a system whose code page is 737.
string data = cp737.GetString(bytes1);
Console.WriteLine(data);
Console.WriteLine();
// Restore the data on a system whose code page is 1252.
Encoding cp1252 = Encoding.GetEncoding(1252);
data = cp1252.GetString(bytes1);
Console.WriteLine(data);
}
}
// The example displays the following output:
// ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ
// €‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’""•–—
使用 Unicode 可确保相同的代码单元始终能映射到相同的字符,并且相同的字符始终能映射到相同的字节数组。
1.2 使用资源文件
即使在开发以单一区域性或区域为目标的应用时,也应使用资源文件存储显示在用户界面中的字符串和其他资源。 切勿将它们直接添加到代码中。 使用资源文件有许多优点:
- 所有字符串都在一个位置。 不必搜索整个源代码即可识别要为特定的语言或区域性修改的字符串。
- 不需要重复字符串。 不使用资源文件的开发人员常常在多个源代码文件中定义同一字符串。 此类重复增加了在修改字符串时忽略一个或多个实例的可能性。
- 可以将非字符串资源(如图像或二进制数据)包含在资源文件中以便于检索,而不将它们存储在单独的独立文件中。
对于创建本地化应用来说,使用资源文件具有独特优势。 在附属程序集中部署资源时,公共语言运行时会基于由 CultureInfo.CurrentUICulture 属性定义的用户当前 UI 区域性来自动选择适合区域性的资源。 只要提供了相应的区域性特定资源并正确示例化了 ResourceManager 对象或使用了强类型的资源类,运行时就会负责检索适合的资源。
若要详细了解如何创建资源文件,请参阅创建资源文件。 若要了解如何创建和部署附属程序集,请参阅创建附属程序集以及打包和部署资源。
1.3 搜索和比较字符串
应尽可能地将字符串按整个字符串处理,而不是按一系列单个字符进行处理。 尤其重要的一点是在排序或搜索子字符串时,要防止出现与分析组合字符相关的问题。
提示
可使用 StringInfo 类与文本元素配合使用,而不使用字符串中的单个字符。
在字符串搜索和比较中,常见的错误是将字符串作为字符的集合,其中每个字符由 Char 对象表示。 实际上,单个字符串可能由一个、两个或多个 Char 对象组成。 此类字符在一些字符串中出现得最频繁,这些字符串位于其字母表是由 Unicode 基本拉丁字符范围(从 U+0021 到 U+007E)以外的字符所组成的区域性中。 以下示例尝试在字符串中查找 LATIN CAPITAL LETTER A WITH GRAVE 字符 (U+00C0) 的索引。 但是,此字符有两种表示方法:单个代码单元 (U+00C0) 或复合字符(两个代码单元:U+0041 和 U+0300)。 在这种情况下,字符在字符串示例中用两个 Char 对象(U+0041 和 U+0300)表示。 示例代码调用 String.IndexOf(Char) 和 String.IndexOf(String) 重载以查找此字符在字符串实例中的位置,但返回了不同的结果 。 第一个方法调用拥有 Char 参数,它执行的是序号比较,因此无法找到匹配项。 第二个调用拥有 String 参数,它执行的是区分区域性的比较,因此找到了匹配项。
using System;
using System.Globalization;
using System.Threading;
public class Example17
{
public static void Main17()
{
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("pl-PL");
string composite = "\u0041\u0300";
Console.WriteLine("Comparing using Char: {0}", composite.IndexOf('\u00C0'));
Console.WriteLine("Comparing using String: {0}", composite.IndexOf("\u00C0"));
}
}
// The example displays the following output:
// Comparing using Char: -1
// Comparing using String: 0
可以通过调用包含 StringComparison 参数(如 String.IndexOf(String, StringComparison) 或 String.LastIndexOf(String, StringComparison) 方法)的重载来避免此示例中出现的一些多义性(对方法的两个相似重载的调用返回了不同的结果)。
但是,搜索并不总是区分区域性的。 如果搜索的目的在于做出安全性决策或是允许或禁止访问某些资源,应进行序号比较,此主题将在下一节中讨论。
1.4 测试字符串的相等性
如果要测试两个字符串是否相等,而不是确定如何按排序顺序进行比较,则使用 String.Equals 方法,而不是字符串比较方法,如 String.Compare 或 CompareInfo.Compare。
通常比较相等性用于条件性地访问某些资源。 例如,可能需要比较相等性以验证密码或确认文件是否存在。 此类非语义比较应始终为序号比较,而不是区分区域性的比较。 一般情况下,应使用值为 StringComparison.Ordinal 的字符串(如密码)和值为 StringComparison.OrdinalIgnoreCase 的字符串(如文件名或 URI)来调用实例 String.Equals(String, StringComparison) 方法或静态的 String.Equals(String, String, StringComparison) 方法。
相等性的比较有时会涉及到搜索或子字符串比较,而不是对 String.Equals 方法的调用。 在某些情况下,可以使用子字符串搜索以确定子字符串是否与另一字符串相等。 如果比较的目的是非语义的,那么搜索也应该为序号搜索,而不区分区域性。
以下示例阐释了对非语义数据进行区分区域性搜索的危险。 AccessesFileSystem
方法旨在禁止文件系统访问以子字符串“FILE”开头的 URI。 为此,它对以字符串“FILE”开头的 URI 执行区分区域性、不区分大小写的比较。 由于访问文件系统的 URI 可以“FILE:”或“file:”开头,因此隐式假设“i”(U+0069) 始终为小写且等效于“I”(U+0049)。 但是,在土耳其语和阿塞拜疆语中,“i”的大写为“İ”(U+0130)。 由于存在此差异,因此区分区域性的比较在应禁止的情况下仍允许进行文件系统访问。
using System;
using System.Globalization;
using System.Threading;
public class Example10
{
public static void Main10()
{
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("tr-TR");
string uri = @"file:\\c:\users\username\Documents\bio.txt";
if (!AccessesFileSystem(uri))
// Permit access to resource specified by URI
Console.WriteLine("Access is allowed.");
else
// Prohibit access.
Console.WriteLine("Access is not allowed.");
}
private static bool AccessesFileSystem(string uri)
{
return uri.StartsWith("FILE", true, CultureInfo.CurrentCulture);
}
}
// The example displays the following output:
// Access is allowed.
因此,可执行忽视大小写的序号比较来避免出现此问题,如下例所示。
using System;
using System.Globalization;
using System.Threading;
public class Example11
{
public static void Main11()
{
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("tr-TR");
string uri = @"file:\\c:\users\username\Documents\bio.txt";
if (!AccessesFileSystem(uri))
// Permit access to resource specified by URI
Console.WriteLine("Access is allowed.");
else
// Prohibit access.
Console.WriteLine("Access is not allowed.");
}
private static bool AccessesFileSystem(string uri)
{
return uri.StartsWith("FILE", StringComparison.OrdinalIgnoreCase);
}
}
// The example displays the following output:
// Access is not allowed.
1.5 顺序和排序字符串
通常,要在用户界面中显示的已排列字符串应根据区域性进行排序。 大多数情况下,在调用排序字符串的方法(如 Array.Sort 和 List<T>.Sort)时,此类字符串比较是由 .NET 隐式处理的。 默认情况下,使用当前区域性的排序约定对字符串进行排序。 以下示例阐释了使用英语(美国)区域性和瑞典语(瑞典)区域性的约定对一组字符串进行排序时的差异。
using System;
using System.Globalization;
using System.Threading;
public class Example18
{
public static void Main18()
{
string[] values = { "able", "ångström", "apple", "Æble",
"Windows", "Visual Studio" };
// Change thread to en-US.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US");
// Sort the array and copy it to a new array to preserve the order.
Array.Sort(values);
string[] enValues = (String[])values.Clone();
// Change culture to Swedish (Sweden).
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("sv-SE");
Array.Sort(values);
string[] svValues = (String[])values.Clone();
// Compare the sorted arrays.
Console.WriteLine("{0,-8} {1,-15} {2,-15}\n", "Position", "en-US", "sv-SE");
for (int ctr = 0; ctr <= values.GetUpperBound(0); ctr++)
Console.WriteLine("{0,-8} {1,-15} {2,-15}", ctr, enValues[ctr], svValues[ctr]);
}
}
// The example displays the following output:
// Position en-US sv-SE
//
// 0 able able
// 1 Æble Æble
// 2 ångström apple
// 3 apple Windows
// 4 Visual Studio Visual Studio
// 5 Windows ångström
区分区域性的字符串比较是由 CompareInfo 对象定义的,该对象由每个区域性的 CultureInfo.CompareInfo 属性返回。 使用 String.Compare 方法重载的区分区域性的字符串比较也使用 CompareInfo 对象。
.NET 使用表格对字符串数据进行区分区域性的排序。 这些表格的内容包含了数据的排序权重和字符串标准化,而这些内容是通过 .NET 的特定版本实现的 Unicode 标准版确定的。 下表列出了通过 .NET 指定版本所实现的 Unicode 版本。 受支持的 Unicode 版本列表仅适用于字符比较和排序;不适用于按类别分类的 Unicode 字符。 有关详细信息,请参阅 String 文章中“字符串和 Unicode 标准”部分。
.NET Framework 版本 | 操作系统 | Unicode 版本 |
---|---|---|
.NET Framework 2.0 | 所有操作系统 | Unicode 4.1 |
.NET Framework 3.0 | 所有操作系统 | Unicode 4.1 |
.NET Framework 3.5 | 所有操作系统 | Unicode 4.1 |
.NET Framework 4 | 所有操作系统 | Unicode 5.0 |
Windows 7 上的 .NET Framework 4.5 及更高版本 | Unicode 5.0 | |
Windows 8 和更高版本的操作系统上的 .NET Framework 4.5 及更高版本 | Unicode 6.3.0 | |
.NET Core 和 .NET 5+ | 取决于基础操作系统支持的 Unicode 标准版本。 |
从 .NET Framework 4.5 起以及在 .NET Core 和 .NET 5+ 的所有版本中,字符串比较和排序取决于操作系统。 在 Windows 7 上运行的 .NET Framework 4.5 及更高版本从其自身实现 Unicode 5.0 的表中检索数据。 在 Windows 8 及更高版本上运行的 .NET Framework 4.5 及更高版本从实现 Unicode 6.3 的操作系统表中检索数据。 在 .NET Core 和 .NET 5+ 上,受支持的 Unicode 版本取决于基础操作系统。 如果对区分区域性的已排序数据进行序列化,可使用 SortVersion 类来确定何时需要对序列化数据进行排序,使其与 .NET 和操作系统的排序顺序保持一致。 有关示例,请参阅 SortVersion 类主题。
如果应用对字符串数据执行大量特定于区域性的排序,则可使用 SortKey 类来比较字符串。 排序关键字反映了特定字符串的特定于区域性的排序权重,包括字母顺序、大小写和音调符号权重。 由于使用排序关键字的比较为二进制,因此与显示或隐式使用 CompareInfo 对象的比较相比,这类比较速度更快。 可通过将字符串传递给 CompareInfo.GetSortKey 方法为特定字符串创建区分区域性的排序关键字。
以下示例与前一个示例类似。 但是此示例没有调用 Array.Sort(Array) 方法(其隐式调用了 CompareInfo.Compare 方法),而是定义了对排序关键字进行比较的 System.Collections.Generic.IComparer<T> 实现,它会进行实例化并传递到 Array.Sort<T>(T[], IComparer<T>) 方法。
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
public class SortKeyComparer : IComparer<String>
{
public int Compare(string? str1, string? str2)
{
return (str1, str2) switch
{
(null, null) => 0,
(null, _) => -1,
(_, null) => 1,
(var s1, var s2) => SortKey.Compare(
CultureInfo.CurrentCulture.CompareInfo.GetSortKey(s1),
CultureInfo.CurrentCulture.CompareInfo.GetSortKey(s1))
};
}
}
public class Example19
{
public static void Main19()
{
string[] values = { "able", "ångström", "apple", "Æble",
"Windows", "Visual Studio" };
SortKeyComparer comparer = new SortKeyComparer();
// Change thread to en-US.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US");
// Sort the array and copy it to a new array to preserve the order.
Array.Sort(values, comparer);
string[] enValues = (String[])values.Clone();
// Change culture to Swedish (Sweden).
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("sv-SE");
Array.Sort(values, comparer);
string[] svValues = (String[])values.Clone();
// Compare the sorted arrays.
Console.WriteLine("{0,-8} {1,-15} {2,-15}\n", "Position", "en-US", "sv-SE");
for (int ctr = 0; ctr <= values.GetUpperBound(0); ctr++)
Console.WriteLine("{0,-8} {1,-15} {2,-15}", ctr, enValues[ctr], svValues[ctr]);
}
}
// The example displays the following output:
// Position en-US sv-SE
//
// 0 able able
// 1 Æble Æble
// 2 ångström apple
// 3 apple Windows
// 4 Visual Studio Visual Studio
// 5 Windows ångström
1.6 避免字符串串联
如有可能,请避免使用在运行时从串联词组中生成的复合字符串。 复合字符串难以本地化,因为它们往往以应用的原始语言假设语法顺序,而此顺序并不适用于其他本地化语言。
2、处理日期和时间
如何处理日期和时间值取决于它们是要显示在用户界面中还是保留。 本节将讨论这两种用法。 同时也将讨论在处理日期和时间时应如何处理时区差异和算术运算。
2.1 显示日期和时间
通常,如果日期和时间要显示在用户界面中,应使用用户区域性的格式约定,此约定是由 CultureInfo.CurrentCulture 属性以及 CultureInfo.CurrentCulture.DateTimeFormat
属性返回的 DateTimeFormatInfo 对象定义的。 在使用以下 3 种方法之一设置日期的格式时会自动使用当前区域性的格式约定:
-
无参数的 DateTime.ToString() 方法
-
DateTime.ToString(String) 方法,其中包含一个格式字符串
-
无参数的 DateTimeOffset.ToString() 方法
-
DateTimeOffset.ToString(String),其中包含一个格式字符串
-
复合格式功能(与日期配合使用时)
以下示例显示了两次 2012 年 10 月 11 日的日出和日落数据。 它首先将当前区域性设置为克罗地亚语(克罗地亚),然后是英语(英国)。 在每个用例中,日期和时间以适合当地区域性的格式显示。
using System;
using System.Globalization;
using System.Threading;
public class Example3
{
static DateTime[] dates = { new DateTime(2012, 10, 11, 7, 06, 0),
new DateTime(2012, 10, 11, 18, 19, 0) };
public static void Main3()
{
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("hr-HR");
ShowDayInfo();
Console.WriteLine();
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-GB");
ShowDayInfo();
}
private static void ShowDayInfo()
{
Console.WriteLine("Date: {0:D}", dates[0]);
Console.WriteLine(" Sunrise: {0:T}", dates[0]);
Console.WriteLine(" Sunset: {0:T}", dates[1]);
}
}
// The example displays the following output:
// Date: 11. listopada 2012.
// Sunrise: 7:06:00
// Sunset: 18:19:00
//
// Date: 11 October 2012
// Sunrise: 07:06:00
// Sunset: 18:19:00
2.2 存留日期和时间
切勿将日期和时间数据保留为随区域性而异的格式。 这是常见的编程错误,会导致数据损坏或运行时异常。 以下示例使用英语(美国)区域性的格式约定将两个日期(2013 年 1 月 9 日和 2013 年 8 月 18 日)序列化为字符串。 在使用英语(美国)区域性的约定检索和分析该数据时,它会成功还原。 但在使用英语(英国)区域性的约定进行检索和分析时,第一个日期被错误地解释为 9 月 1 日,并且无法分析第二个日期,因为公历中没有第 18 个月。
using System;
using System.IO;
using System.Globalization;
using System.Threading;
public class Example4
{
public static void Main4()
{
// Persist two dates as strings.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US");
DateTime[] dates = { new DateTime(2013, 1, 9),
new DateTime(2013, 8, 18) };
StreamWriter sw = new StreamWriter("dateData.dat");
sw.Write("{0:d}|{1:d}", dates[0], dates[1]);
sw.Close();
// Read the persisted data.
StreamReader sr = new StreamReader("dateData.dat");
string dateData = sr.ReadToEnd();
sr.Close();
string[] dateStrings = dateData.Split('|');
// Restore and display the data using the conventions of the en-US culture.
Console.WriteLine("Current Culture: {0}",
Thread.CurrentThread.CurrentCulture.DisplayName);
foreach (var dateStr in dateStrings)
{
DateTime restoredDate;
if (DateTime.TryParse(dateStr, out restoredDate))
Console.WriteLine("The date is {0:D}", restoredDate);
else
Console.WriteLine("ERROR: Unable to parse {0}", dateStr);
}
Console.WriteLine();
// Restore and display the data using the conventions of the en-GB culture.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-GB");
Console.WriteLine("Current Culture: {0}",
Thread.CurrentThread.CurrentCulture.DisplayName);
foreach (var dateStr in dateStrings)
{
DateTime restoredDate;
if (DateTime.TryParse(dateStr, out restoredDate))
Console.WriteLine("The date is {0:D}", restoredDate);
else
Console.WriteLine("ERROR: Unable to parse {0}", dateStr);
}
}
}
// The example displays the following output:
// Current Culture: English (United States)
// The date is Wednesday, January 09, 2013
// The date is Sunday, August 18, 2013
//
// Current Culture: English (United Kingdom)
// The date is 01 September 2013
// ERROR: Unable to parse 8/18/2013
可通过以下 3 种方法之一来避免此问题:
- 以二进制格式对日期和时间进行序列化,而不是序列化为字符串。
- 不考虑用户的区域性,使用同一自定义格式字符串保存和分析日期和时间的字符串表示形式。
- 使用固定区域性的格式约定保存字符串。
以下示例演示了第 3 种方法。 它使用静态 CultureInfo.InvariantCulture 属性返回的固定区域性的格式约定。
using System;
using System.IO;
using System.Globalization;
using System.Threading;
public class Example5
{
public static void Main5()
{
// Persist two dates as strings.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US");
DateTime[] dates = { new DateTime(2013, 1, 9),
new DateTime(2013, 8, 18) };
StreamWriter sw = new StreamWriter("dateData.dat");
sw.Write(String.Format(CultureInfo.InvariantCulture,
"{0:d}|{1:d}", dates[0], dates[1]));
sw.Close();
// Read the persisted data.
StreamReader sr = new StreamReader("dateData.dat");
string dateData = sr.ReadToEnd();
sr.Close();
string[] dateStrings = dateData.Split('|');
// Restore and display the data using the conventions of the en-US culture.
Console.WriteLine("Current Culture: {0}",
Thread.CurrentThread.CurrentCulture.DisplayName);
foreach (var dateStr in dateStrings)
{
DateTime restoredDate;
if (DateTime.TryParse(dateStr, CultureInfo.InvariantCulture,
DateTimeStyles.None, out restoredDate))
Console.WriteLine("The date is {0:D}", restoredDate);
else
Console.WriteLine("ERROR: Unable to parse {0}", dateStr);
}
Console.WriteLine();
// Restore and display the data using the conventions of the en-GB culture.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-GB");
Console.WriteLine("Current Culture: {0}",
Thread.CurrentThread.CurrentCulture.DisplayName);
foreach (var dateStr in dateStrings)
{
DateTime restoredDate;
if (DateTime.TryParse(dateStr, CultureInfo.InvariantCulture,
DateTimeStyles.None, out restoredDate))
Console.WriteLine("The date is {0:D}", restoredDate);
else
Console.WriteLine("ERROR: Unable to parse {0}", dateStr);
}
}
}
// The example displays the following output:
// Current Culture: English (United States)
// The date is Wednesday, January 09, 2013
// The date is Sunday, August 18, 2013
//
// Current Culture: English (United Kingdom)
// The date is 09 January 2013
// The date is 18 August 2013
2.3 序列化和时区感知
一个日期和时间值可能有多个解释,从常规时间(“商店于 2013 年 1 月 2 日上午 9 点开门。”)到某个特定时刻(“出生日期:2013 年 1 月 2 日上午 6:32:00。”)。 当时间值表示某个特定时刻并且将它从序列化的值中还原时,无论用户处于哪个地理位置或时区,都应确保它表示的是同一时刻。
以下示例阐释了此问题。 它以三种标准格式将单个本地日期和时间值保存为字符串:
- “G”表示常规日期(长时间)。
- “s”表示可排序日期/时间。
- “o”表示往返日期/时间。
using System;
using System.IO;
public class Example6
{
public static void Main6()
{
DateTime dateOriginal = new DateTime(2023, 3, 30, 18, 0, 0);
dateOriginal = DateTime.SpecifyKind(dateOriginal, DateTimeKind.Local);
// Serialize a date.
if (!File.Exists("DateInfo.dat"))
{
StreamWriter sw = new StreamWriter("DateInfo.dat");
sw.Write("{0:G}|{0:s}|{0:o}", dateOriginal);
sw.Close();
Console.WriteLine("Serialized dates to DateInfo.dat");
}
Console.WriteLine();
// Restore the date from string values.
StreamReader sr = new StreamReader("DateInfo.dat");
string datesToSplit = sr.ReadToEnd();
string[] dateStrings = datesToSplit.Split('|');
foreach (var dateStr in dateStrings)
{
DateTime newDate = DateTime.Parse(dateStr);
Console.WriteLine("'{0}' --> {1} {2}",
dateStr, newDate, newDate.Kind);
}
}
}
当还原数据的系统与对其进行序列化的系统位于同一时区时,反序列化的日期和时间值能准确地反映原始值,输出如下:
'3/30/2013 6:00:00 PM' --> 3/30/2013 6:00:00 PM Unspecified
'2013-03-30T18:00:00' --> 3/30/2013 6:00:00 PM Unspecified
'2013-03-30T18:00:00.0000000-07:00' --> 3/30/2013 6:00:00 PM Local
但是,如果在处于其他时区的系统上还原数据,仅格式为“o”(往返)标准格式字符串的日期和时间值会保留时区信息,因此它会表示同一时刻。 当在处于罗马标准时区的系统上还原日期和时间数据时,输出如下:
'3/30/2023 6:00:00 PM' --> 3/30/2023 6:00:00 PM Unspecified
'2023-03-30T18:00:00' --> 3/30/2023 6:00:00 PM Unspecified
'2023-03-30T18:00:00.0000000-07:00' --> 3/31/2023 3:00:00 AM Local
若要准确反映表示单个时刻的日期和时间值,而无需考虑对数据进行反序列化的系统所在时区,则可以执行以下任一操作:
- 使用“o”(往返)标准格式字符串将值保存为字符串。 然后在目标系统上进行反序列化。
- 将其转换为 UTC,并使用“r”(RFC1123) 标准格式字符串将其保存为一个字符串。 然后在目标系统上进行反序列化,并将其转换为本地时间。
- 将其转换为 UTC,并使用“u”(通用可排序)标准格式字符串将其保存为一个字符串。 然后在目标系统上进行反序列化,并将其转换为本地时间。
以下示例演示了每种方法:
using System;
using System.IO;
public class Example9
{
public static void Main9()
{
// Serialize a date.
DateTime dateOriginal = new DateTime(2023, 3, 30, 18, 0, 0);
dateOriginal = DateTime.SpecifyKind(dateOriginal, DateTimeKind.Local);
// Serialize the date in string form.
if (!File.Exists("DateInfo2.dat"))
{
StreamWriter sw = new StreamWriter("DateInfo2.dat");
sw.Write("{0:o}|{1:r}|{1:u}", dateOriginal,
dateOriginal.ToUniversalTime());
sw.Close();
}
// Restore the date from string values.
StreamReader sr = new StreamReader("DateInfo2.dat");
string datesToSplit = sr.ReadToEnd();
string[] dateStrings = datesToSplit.Split('|');
for (int ctr = 0; ctr < dateStrings.Length; ctr++)
{
DateTime newDate = DateTime.Parse(dateStrings[ctr]);
if (ctr == 1)
{
Console.WriteLine($"'{dateStrings[ctr]}' --> {newDate} {newDate.Kind}");
}
else
{
DateTime newLocalDate = newDate.ToLocalTime();
Console.WriteLine($"'{dateStrings[ctr]}' --> {newLocalDate} {newLocalDate.Kind}");
}
}
}
}
当在位于太平洋标准时区的系统上和在位于罗马标准时区的系统上对数据进行序列化时,该示例将显示以下输出:
'2023-03-30T18:00:00.0000000-07:00' --> 3/31/2023 3:00:00 AM Local
'Sun, 31 Mar 2023 01:00:00 GMT' --> 3/31/2023 3:00:00 AM Local
'2023-03-31 01:00:00Z' --> 3/31/2023 3:00:00 AM Local
有关详细信息,请参阅转换时区时间。
2.4 执行日期和时间算法
DateTime 和 DateTimeOffset 类型都支持算术运算。 可以计算两个日期值之差,或者将日期值与特定的时间间隔相加或相减。 但是,对日期和时间值进行的算术运算时不考虑时区和时区调整规则。 因此,计算表示时刻的日期和时间值可能会返回错误结果。
例如,从太平洋标准时到太平洋夏令时的转换发生在 3 月的第二个星期日,即 2013 年 3 月 10 日。 如下例所示,如果在位于太平洋时区的系统上计算 2013 年 3 月 9 日上午 10 点 30 分之后 48 小时的日期和时间,则不会考虑时间间隔调整,因此结果为 2013 年 3 月 11 日上午 10 点 30 分。
using System;
public class Example7
{
public static void Main7()
{
DateTime date1 = DateTime.SpecifyKind(new DateTime(2013, 3, 9, 10, 30, 0),
DateTimeKind.Local);
TimeSpan interval = new TimeSpan(48, 0, 0);
DateTime date2 = date1 + interval;
Console.WriteLine("{0:g} + {1:N1} hours = {2:g}",
date1, interval.TotalHours, date2);
}
}
// The example displays the following output:
// 3/9/2013 10:30 AM + 48.0 hours = 3/11/2013 10:30 AM
若要确保日期和时间值的算术运算生成精准的结果,请执行以下步骤:
- 将源时区中的时间转换为 UTC。
- 执行算术运算。
- 如果结果为日期和时间值,则将它从 UTC 转换成源时区中的时间。
以下示例与前一个示例类似,不同的是,它按照这 3 个步骤在 2013 年 3 月 9 日上午 10 点 30 分的基础上恰当添加了 48 个小时。
using System;
public class Example8
{
public static void Main8()
{
TimeZoneInfo pst = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");
DateTime date1 = DateTime.SpecifyKind(new DateTime(2013, 3, 9, 10, 30, 0),
DateTimeKind.Local);
DateTime utc1 = date1.ToUniversalTime();
TimeSpan interval = new TimeSpan(48, 0, 0);
DateTime utc2 = utc1 + interval;
DateTime date2 = TimeZoneInfo.ConvertTimeFromUtc(utc2, pst);
Console.WriteLine("{0:g} + {1:N1} hours = {2:g}",
date1, interval.TotalHours, date2);
}
}
// The example displays the following output:
// 3/9/2013 10:30 AM + 48.0 hours = 3/11/2013 11:30 AM
有关详细信息,请参阅执行日期和时间算术运算。
2.5 对日期元素使用区分区域性的名称
应用可能需要显示月份的名称或星期几。 为此,常使用以下代码。
using System;
public class Example12
{
public static void Main12()
{
DateTime midYear = new DateTime(2013, 7, 1);
Console.WriteLine("{0:d} is a {1}.", midYear, GetDayName(midYear));
}
private static string GetDayName(DateTime date)
{
return date.DayOfWeek.ToString("G");
}
}
// The example displays the following output:
// 7/1/2013 is a Monday.
但是,此代码始终以英语返回一周中某天的名称。 提取月份名称的代码通常更加固定。 它常常采用特定语言的月份名称来假设十二月历。
使用自定义日期和时间格式字符串或 DateTimeFormatInfo 对象的属性,可以轻松提取字符串,以反映用户区域性中的星期几或月份名称,如下面的示例所示。 它将当前区域性更改为法语(法国),并为 2013 年 7 月 1 日显示一周中某天的名称和月份的名称。
using System;
using System.Globalization;
public class Example13
{
public static void Main13()
{
// Set the current culture to French (France).
CultureInfo.CurrentCulture = CultureInfo.CreateSpecificCulture("fr-FR");
DateTime midYear = new DateTime(2013, 7, 1);
Console.WriteLine("{0:d} is a {1}.", midYear, DateUtilities.GetDayName(midYear));
Console.WriteLine("{0:d} is a {1}.", midYear, DateUtilities.GetDayName((int)midYear.DayOfWeek));
Console.WriteLine("{0:d} is in {1}.", midYear, DateUtilities.GetMonthName(midYear));
Console.WriteLine("{0:d} is in {1}.", midYear, DateUtilities.GetMonthName(midYear.Month));
}
}
public static class DateUtilities
{
public static string GetDayName(int dayOfWeek)
{
if (dayOfWeek < 0 | dayOfWeek > DateTimeFormatInfo.CurrentInfo.DayNames.Length)
return String.Empty;
else
return DateTimeFormatInfo.CurrentInfo.DayNames[dayOfWeek];
}
public static string GetDayName(DateTime date)
{
return date.ToString("dddd");
}
public static string GetMonthName(int month)
{
if (month < 1 | month > DateTimeFormatInfo.CurrentInfo.MonthNames.Length - 1)
return String.Empty;
else
return DateTimeFormatInfo.CurrentInfo.MonthNames[month - 1];
}
public static string GetMonthName(DateTime date)
{
return date.ToString("MMMM");
}
}
// The example displays the following output:
// 01/07/2013 is a lundi.
// 01/07/2013 is a lundi.
// 01/07/2013 is in juillet.
// 01/07/2013 is in juillet.
3、数字值
数字的处理方式取决于它们是显示在用户界面中还是保留。 本节将讨论这两种用法。
在分析和设置格式时,.NET 仅将 0 到 9(U+0030 到 U+0039)的基本拉丁字符识别为数字。
3.1 显示数字值
通常,如果数字显示在用户界面中,应使用用户区域性的格式约定,此约定由 CultureInfo.CurrentCulture 属性以及 CultureInfo.CurrentCulture.NumberFormat
属性返回的 NumberFormatInfo 对象定义。 在使用以下方法设置日期的格式时会自动使用当前区域性的格式设置约定:
- 使用任何数值类型的无参数
ToString
方法。 - 使用任何数值类型的
ToString(String)
方法,其中将格式字符串作为参数。 - 使用带数值的复合格式设置。
以下示例显示法国巴黎每月的平均气温。 在显示数据之前,它首先将当前区域性设置为法语(法国),然后再设置为英语(美国)。 在每个用例中,月份名称和气温以适合当地区域性的格式显示。 请注意,两个区域性使用不同的小数分隔符以分隔气温值。 另请注意,该示例使用“MMMM”自定义日期和时间格式字符串以显示完整的月份名称,并且它通过确定 DateTimeFormatInfo.MonthNames 数组中最长月份名称的长度为结果字符串中的月份名称分配了足够的空间。
using System;
using System.Globalization;
using System.Threading;
public class Example14
{
public static void Main14()
{
DateTime dateForMonth = new DateTime(2013, 1, 1);
double[] temperatures = { 3.4, 3.5, 7.6, 10.4, 14.5, 17.2,
19.9, 18.2, 15.9, 11.3, 6.9, 5.3 };
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("fr-FR");
Console.WriteLine("Current Culture: {0}", CultureInfo.CurrentCulture.DisplayName);
// Build the format string dynamically so we allocate enough space for the month name.
string fmtString = "{0,-" + GetLongestMonthNameLength().ToString() + ":MMMM} {1,4}";
for (int ctr = 0; ctr < temperatures.Length; ctr++)
Console.WriteLine(fmtString,
dateForMonth.AddMonths(ctr),
temperatures[ctr]);
Console.WriteLine();
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US");
Console.WriteLine("Current Culture: {0}", CultureInfo.CurrentCulture.DisplayName);
fmtString = "{0,-" + GetLongestMonthNameLength().ToString() + ":MMMM} {1,4}";
for (int ctr = 0; ctr < temperatures.Length; ctr++)
Console.WriteLine(fmtString,
dateForMonth.AddMonths(ctr),
temperatures[ctr]);
}
private static int GetLongestMonthNameLength()
{
int length = 0;
foreach (var nameOfMonth in DateTimeFormatInfo.CurrentInfo.MonthNames)
if (nameOfMonth.Length > length) length = nameOfMonth.Length;
return length;
}
}
// The example displays the following output:
// Current Culture: French (France)
// janvier 3,4
// février 3,5
// mars 7,6
// avril 10,4
// mai 14,5
// juin 17,2
// juillet 19,9
// août 18,2
// septembre 15,9
// octobre 11,3
// novembre 6,9
// décembre 5,3
//
// Current Culture: English (United States)
// January 3.4
// February 3.5
// March 7.6
// April 10.4
// May 14.5
// June 17.2
// July 19.9
// August 18.2
// September 15.9
// October 11.3
// November 6.9
// December 5.3
3.2 存留数字值
切勿将数值数据保留为特定于区域性的格式。 这是常见的编程错误,会导致数据损坏或运行时异常。 以下示例随机生成了十个浮点数,然后使用英语(美国)区域性的格式约定将它们序列化为字符串。 在使用英语(美国)区域性的约定检索和分析该数据时,它会成功还原。 但是,当使用法语(法国)区域性的约定进行检索和分析时,无法分析任何数字,因为区域性使用了不同的小数分隔符。
using System;
using System.Globalization;
using System.IO;
using System.Threading;
public class Example15
{
public static void Main15()
{
// Create ten random doubles.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US");
double[] numbers = GetRandomNumbers(10);
DisplayRandomNumbers(numbers);
// Persist the numbers as strings.
StreamWriter sw = new StreamWriter("randoms.dat");
for (int ctr = 0; ctr < numbers.Length; ctr++)
sw.Write("{0:R}{1}", numbers[ctr], ctr < numbers.Length - 1 ? "|" : "");
sw.Close();
// Read the persisted data.
StreamReader sr = new StreamReader("randoms.dat");
string numericData = sr.ReadToEnd();
sr.Close();
string[] numberStrings = numericData.Split('|');
// Restore and display the data using the conventions of the en-US culture.
Console.WriteLine("Current Culture: {0}",
Thread.CurrentThread.CurrentCulture.DisplayName);
foreach (var numberStr in numberStrings)
{
double restoredNumber;
if (Double.TryParse(numberStr, out restoredNumber))
Console.WriteLine(restoredNumber.ToString("R"));
else
Console.WriteLine("ERROR: Unable to parse '{0}'", numberStr);
}
Console.WriteLine();
// Restore and display the data using the conventions of the fr-FR culture.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("fr-FR");
Console.WriteLine("Current Culture: {0}",
Thread.CurrentThread.CurrentCulture.DisplayName);
foreach (var numberStr in numberStrings)
{
double restoredNumber;
if (Double.TryParse(numberStr, out restoredNumber))
Console.WriteLine(restoredNumber.ToString("R"));
else
Console.WriteLine("ERROR: Unable to parse '{0}'", numberStr);
}
}
private static double[] GetRandomNumbers(int n)
{
Random rnd = new Random();
double[] numbers = new double[n];
for (int ctr = 0; ctr < n; ctr++)
numbers[ctr] = rnd.NextDouble() * 1000;
return numbers;
}
private static void DisplayRandomNumbers(double[] numbers)
{
for (int ctr = 0; ctr < numbers.Length; ctr++)
Console.WriteLine(numbers[ctr].ToString("R"));
Console.WriteLine();
}
}
// The example displays output like the following:
// 487.0313743534644
// 674.12000879371533
// 498.72077885024288
// 42.3034229512808
// 970.57311049223563
// 531.33717716268131
// 587.82905693530529
// 562.25210175023039
// 600.7711019370571
// 299.46113717717174
//
// Current Culture: English (United States)
// 487.0313743534644
// 674.12000879371533
// 498.72077885024288
// 42.3034229512808
// 970.57311049223563
// 531.33717716268131
// 587.82905693530529
// 562.25210175023039
// 600.7711019370571
// 299.46113717717174
//
// Current Culture: French (France)
// ERROR: Unable to parse '487.0313743534644'
// ERROR: Unable to parse '674.12000879371533'
// ERROR: Unable to parse '498.72077885024288'
// ERROR: Unable to parse '42.3034229512808'
// ERROR: Unable to parse '970.57311049223563'
// ERROR: Unable to parse '531.33717716268131'
// ERROR: Unable to parse '587.82905693530529'
// ERROR: Unable to parse '562.25210175023039'
// ERROR: Unable to parse '600.7711019370571'
// ERROR: Unable to parse '299.46113717717174'
若要避免此问题,可使用以下方法之一:
- 不考虑用户的区域性,使用同一自定义格式字符串保存和分析数字的字符串表示形式。
- 使用固定区域性的格式约定将数字保存为字符串,此约定是由 CultureInfo.InvariantCulture 属性返回的。
货币值的序列化是一种特殊情况。 由于货币值取决于表示它的货币单位,因此将它视为独立的数值没有什么意义。 但是如果将货币值保存为包含货币符号的格式化字符串,则无法在其默认区域性使用不同货币符号的系统上对其进行反序列化,如下例所示。
using System;
using System.Globalization;
using System.IO;
using System.Threading;
public class Example1
{
public static void Main1()
{
// Display the currency value.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US");
Decimal value = 16039.47m;
Console.WriteLine("Current Culture: {0}", CultureInfo.CurrentCulture.DisplayName);
Console.WriteLine("Currency Value: {0:C2}", value);
// Persist the currency value as a string.
StreamWriter sw = new StreamWriter("currency.dat");
sw.Write(value.ToString("C2"));
sw.Close();
// Read the persisted data using the current culture.
StreamReader sr = new StreamReader("currency.dat");
string currencyData = sr.ReadToEnd();
sr.Close();
// Restore and display the data using the conventions of the current culture.
Decimal restoredValue;
if (Decimal.TryParse(currencyData, out restoredValue))
Console.WriteLine(restoredValue.ToString("C2"));
else
Console.WriteLine("ERROR: Unable to parse '{0}'", currencyData);
Console.WriteLine();
// Restore and display the data using the conventions of the en-GB culture.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-GB");
Console.WriteLine("Current Culture: {0}",
Thread.CurrentThread.CurrentCulture.DisplayName);
if (Decimal.TryParse(currencyData, NumberStyles.Currency, null, out restoredValue))
Console.WriteLine(restoredValue.ToString("C2"));
else
Console.WriteLine("ERROR: Unable to parse '{0}'", currencyData);
Console.WriteLine();
}
}
// The example displays output like the following:
// Current Culture: English (United States)
// Currency Value: $16,039.47
// ERROR: Unable to parse '$16,039.47'
//
// Current Culture: English (United Kingdom)
// ERROR: Unable to parse '$16,039.47'
相反,应将数值和一些区域性信息一起序列化,如区域性的名称,这样数值和其货币符号才可在当前区域性中独立地进行反序列化。 以下示例通过定义带有两个参数(Decimal 值和值所属的区域性的名称)的 CurrencyValue
结构来实现这一点。
using System;
using System.Globalization;
using System.Text.Json;
using System.Threading;
public class Example2
{
public static void Main2()
{
// Display the currency value.
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US");
Decimal value = 16039.47m;
Console.WriteLine($"Current Culture: {CultureInfo.CurrentCulture.DisplayName}");
Console.WriteLine($"Currency Value: {value:C2}");
// Serialize the currency data.
CurrencyValue data = new()
{
Amount = value,
CultureName = CultureInfo.CurrentCulture.Name
};
string serialized = JsonSerializer.Serialize(data);
Console.WriteLine();
// Change the current culture.
CultureInfo.CurrentCulture = CultureInfo.CreateSpecificCulture("en-GB");
Console.WriteLine($"Current Culture: {CultureInfo.CurrentCulture.DisplayName}");
// Deserialize the data.
CurrencyValue restoredData = JsonSerializer.Deserialize<CurrencyValue>(serialized);
// Display the round-tripped value.
CultureInfo culture = CultureInfo.CreateSpecificCulture(restoredData.CultureName);
Console.WriteLine($"Currency Value: {restoredData.Amount.ToString("C2", culture)}");
}
}
internal struct CurrencyValue
{
public decimal Amount { get; set; }
public string CultureName { get; set; }
}
// The example displays the following output:
// Current Culture: English (United States)
// Currency Value: $16,039.47
//
// Current Culture: English (United Kingdom)
// Currency Value: $16,039.47
4、使用特定于区域性的设置
在 .NET 中,CultureInfo 类表示特定的区域性或区域。 其中一些属性返回提供有关某些区域性方面的特定信息的对象:
-
CultureInfo.CompareInfo 属性返回 CompareInfo 对象,该对象包含有关如何比较区域性和排列字符串的信息。
-
CultureInfo.DateTimeFormat 属性返回 DateTimeFormatInfo 对象,该对象提供用于设置日期和时间数据格式的区域性特定信息。
-
CultureInfo.NumberFormat 属性返回 NumberFormatInfo 对象,该对象提供用于设置数值数据格式的区域性特定信息。
-
CultureInfo.TextInfo 属性返回 TextInfo 对象,该对象提供有关区域性写入系统的信息。
一般情况下,不要对特定的 CultureInfo 属性及其相关对象的值作出任何假设。 相反,应将区域性特定的数据视为可更改的,原因如下:
-
当数据损坏、有更好的数据可用或区域性特定的约定更改时,各个属性值是可更改且可修订。
-
各个属性值在各个 .NET 版本或操作系统版本中可能会有所不同。
-
.NET 支持替换区域性。 由此可定义补充现有标准区域性或完全替换现有标准区域性的新的自定义区域性。
-
在 Windows 系统上,用户可使用“控制面板”中的“区域和语言”应用,自定义区域性专用设置。 在实例化 CultureInfo 对象时,可调用 CultureInfo(String, Boolean) 构造函数来确定它是否反射这些用户自定义。 通常,对最终用户应用而言,你应考虑用户首选项,以用户期望的格式呈现数据。