前一篇博客我已经把各个实体分析了一遍,从分析中可以看到,这个公司是做本地采购,生产,然后通过网站和门店进行国际销售的。所以这里会涉及到一些国际化的问题。接下来就来分析一下有哪些国际化需要注意的问题和数据库模型中的解决方案。
语言
AdventureWorks数据模型中,只有对ProductDescription进行了多语言设置。关于多语言的建模,我曾经写了一篇文章,详细介绍了多语言建模的几种方法,可以参考:http://www.cnblogs.com/studyzy/archive/2013/04/03/2998322.html
回过头来看看AdventureWorks是怎么处理多语言的。
他在ProductDescription的Description字段中用各种语言维护了一些描述信息,但是却并没有任何一个字段说明这一行维护的是什么语言。反而时建立了ProductDescription和Culture的多对多关系。这是一个很奇怪的设计,Culture和ProductDescription应该是一对多的关系,一种语言会维护很多句描述信息,而具体的一条描述信息,在写入Description的时候就应该已经确定了唯一的一种语言。比如我随便找了一行描述“充电式双光束车灯。”这行描述会对应英语?法语?会是对应多种语言吗?而实际的查询也证明了我这个观点:
SELECT [ProductDescriptionID] ,count([CultureID]) as CultureCount FROM [Production].[ProductModelProductDescriptionCulture] group by ProductDescriptionID having count(CultureID)>1
返回0行,不会有哪个ProductDescription会去对应多个Culture。
那正确的模型应该是什么样的呢?简单的改法是:Culture和ProductDescription是一对多关系,ProductModel和ProductDescription是多对多关系,如下图所示:
这种模型可以使得每一行的Description在定义输入的时候就指定了输入的到底是什么语言。但是这个模型有一个缺点,就是同一个ProductModel,在不同的语言情况下可能对应的描述不一致。比如有产品样品A,他关联的中文描述是:
- 描述1
- 描述2
而他关联的英文描述是:
- Description 2
- Description 3
- Description 4
所以这个模型引用的多语言描述可能是混乱的。那么我们可以进一步改进模型。需要增加一个表ProductStandardDescription,这个表中维护了最常用的语言的描述,比如里面维护了英文描述。然后ProductStandardDescription和Culture形成多对多关系,把除了英文外的其他语言的描述信息翻译好维护进去。最后ProductStandardDescription和ProductModel也是一个多对多关系。
这样的好处是可以避免前面提到的不同语言关联的描述不一致的问题,而且以英语为标准描述,可以很容易找到对应的其他语言是否存在,不存在的话就使用默认语言英语。
格式
格式是一个在应用程序中需要注意的问题,主要是对日期和数字的显示格式处理。在数据库建模中,为了避免格式问题,不要用字符串类型去存储日期时间和数字。如果知道是日期类型的那么就使用Date类型,如果是要包含日期和时间的,那么就用datetime类型,只需要记录时间就用time类型。而对于数字或者是金额,也一定要使用对应的数字类型int decimal和金额类型money。
如果使用varchar来存储日期会出现什么问题呢?美国用户在输入日期时使用的格式是MM/dd/yy,而中国用户习惯的输入格式是yyyy-MM-dd或者yyyy/MM/dd,到英国又不一样,而这些格式存储到了数据库中,那么将无法进行日期大小的比较,而且在展示的时候也按原来用户输入的格式再展示给另外一个国家的用户,那么很可能引起误解。
时间类型也有类似的问题,美国用户喜欢使用AM PM来表示上午下午,而中国用户使用24小时制,按字符串存储到数据库中也会存在无法正确排序和比较的问题。
数字的字符串问题在于有的使用逗号作为千分符,而有的国家是使用顿号作为千分符,有的用户又不使用千分符。
金额的话有的输入前面会带货币符号,有的又不带。
回过头来看AdventureWorks数据库模型,他对数据类型的使用都很准确,不存在乱用varchar类型的问题。
币种和汇率
如果在某个表单中涉及到多个币种问题,那么必须将币种属性添加到表单中。比如SalesOrderHeader。而对于采购订单PurchaseOrder中,由于都是本国采购,全部采用美元结算,所以在PurchaseOrder中没有币种字段。这里需要注意的是,并不是只要某个表单中只采用一种币种,就不需要记录币种信息了,表单的币种如果与财务核算的币种不一致,那么在财务做账时就得进行币种转换,这里由于采购的币种和财务核算的币种都是美元,所以才不需要记录币种。
我们在系统中记录币种信息主要是为了实现币种转换,而币种转换的关键就是汇率,而汇率是一个很复杂的东西,因为汇率是随着时间不断变化的。在系统进行汇率转换时应该取哪个汇率呢?两个币种的汇率存在买入汇率、卖出汇率、中间汇率等,基本介绍可以参考百度文档:http://wenku.baidu.com/link?url=MP0nC_0sIGEIlAfbr-rWSSKcE_bmqQrNclr80WHDfc4kAFZY6S9dskMt5PzPDzkm88iseIhGDhAz9SZEnoQVvtAIRVo13p1kFpTGnCEUVqe
汇率的时间取值可以是日汇率,月底中心汇率,月平均汇率,年度预算汇率等,一般系统都是以“日”为单位,每日记录一次汇率信息,使用中间汇率进行计算的较多。从以上的分析,我们可以建立汇率转换表如下:
接下来在记录币种信息时有以下几种建模方法:
1. 完全范式化,只记录交易币种
范式化后汇率表我们可以变成多个表,而在表单中只记录交易币种,那是因为我们可能有多种核算方法,比如同一个系统,欧洲区希望以欧元为币种看到报表,总公司希望看到美元为币种的报表,那么转换后的币种是不确定的,所以不需要记录转换后币种,我们只需要在表单中记录原币种和日期,剩下的就由系统计算得到。
这样做后系统是最灵活的,可以应对各种情况,交易时的真实币种为LocalCurrency,可以对应CurrencyRate中的FromCurrency,OrderDate就是CurrencyRateDate,而核算币种StatisticsCurrency(本位币)由系统输入,可以对应CurrencyRate中的ToCurrency,最后再由系统带人CurrencyRateType即可知道本位币的金额。但是由于范式化,也是最慢的,因为在出报表时需要进行多个表的Join,才能得到结果。
2.固定本位币,将汇率类型以列展示
毕竟对于大多数公司来说,核算时永远是用一种固定的本位币(比如美元)这也是AdventureWorks所采取的方式,那么我们可以将系统模型简化,也就是说CurrencyRate表中没有CurrencyRateType栏位,如果要记录日平均汇率进记入“AverageRate”字段,如果要记录当天的最后中间汇率,那么就记入“EndOfDayRate”中,如果我们还想记录更多类型的汇率值,只需要在这个表中增加栏位即可。我们再来看看AdventureWorks系统的模型:
SalesOrder在录入时就已知了LocalCurrency和CurrencyRateDate(一般也就是OrderDate),而我们的本位币是固定的USD,所以基于这3个字段就唯一的确定了CurrencyRate中的一条记录。所以在SalesOrder中并没有记录LocalCurrency,而是引用CurrencyRateID即可。这种模型虽然不会像上一种方案那么灵活,但是由于只需要Join一张表,所以查询效率比上面会快很多。
3. 固定本位币,固定汇率类型,反范式化
如果我们公司不仅本位币是唯一的,而且采用的汇率类型也是固定的(只使用每人中间汇率),那么我们的模型可以出于效率的考虑,进一步反范式化:
每个在记录金额的字段,都加上对应的货币字段,同时也加上按当时的汇率换算成本位币后的金额。
这样做的特点是在后期做各种财务统计时不需要在进行表的链接也不需要进行汇率换算,在录入数据时都已经计算好,所以统计速度会很快。当然缺点也是显而易见的,一方面时反范式化后带来的缺点,另一方面是可能在录入数据时并不知道当时的汇率,所以本位币金额就无法计算,只有等公布了当天的汇率后再去补上本位币金额,这是相当麻烦的。还有一个缺点是本位币金额在计算时取的是中间汇率,如果哪天我们希望再以月平均汇率来统计本位币金额,那么还是得重新计算。
时间
时间问题主要是时区的问题,是个比较头疼的问题,在中国可能意识不到这个问题,因为整个中国都是实行+8区的统一时区,而像俄罗斯、美国等国家,他们从东部到西部都是实行不同的时区,而我们的IT系统如果是国际化的,那么就更会遇到时区问题。
时区
时区问题分为录入和显示两个方向。比如一个中国的客户,在2015-5-2早上9:00在系统中下了一个单,那么我们系统记录的是什么时间呢?直接计入2015-5-2 9:00:00吗?但是对于伦敦的管理员来说,他看到这个时间就会很奇怪,因为他们现在才2015-5-2 1:00:00啊,怎么会有未来的单子。所以我们必须将时区作为一个日期的部分,考虑到数据库的模型中。
关于时区,我们可以有两种解决方案:
1.直接记录时区到数据库
为了解决时区问题,SQL Server数据库专门提供了一个数据类型DateTimeOffset,以及相关的函数,用于处理带时区的日期时间。比如我们前面说到的订单日期,我们就可以将其数据类型改为datetimeoffset。而获取时间和时区,有三种数据源:
- 客户端时间
- 应用服务器时间
- 数据库服务器时间
如果使用用户输入的时间值,那么就必须在读取用户输入的值的同时,也得读到用户电脑所设置的时区(这个无论是BS才是CS应用都很容易读取到),然后把时间和时区两个值传到后台数据库,最终以DatetimeOffset的格式存储到数据库中。如果是只需要取当前时间,那么就可以取应用服务器或者数据库服务器的时间和时区。数据库函数为:SYSDATETIMEOFFSET ( )
如果我们使用的数据库没有DateTimeOffSet这种数据类型,那么我也可以将来DateTime和Timezone两个字段来存储时间和时区。
2.转换为UTC时间存到数据库
前面说到我们可以建立Timezone字段来存储时区信息,但是这样做最大的缺点就是不方便数据库中的排序。而且新建的字段也会额外占用存储空间,导致性能降低。除了使用专门为时区而设计的DateTimeOffset数据类型外,我们还可以在应用程序中做时区转换,把所有时间都转换为UTC时间,然后在数据库中就存储UTC时间。
如果是用户从客户端输入或者是应用程序服务器时间,那么只需要在代码中调用系统函数做个简单的转换即可,如果是取数据库当前UTC时间,也有GETUTCDATE()函数可以用。
上面说到的都是录入,而在显示上面,应用程序也只需要读取客户端的时区,然后将数据库中的时间以新时区展示即可。
夏令时
夏令时问题也是一个在中国意识不到的问题,因为中国现在不实行夏令时制(以前实行过几年,后来取消了)。夏令时问题和时区问题独立出来,那是因为我们的系统可能并不是一个国际化的系统,只在一个时区使用,但是这个国家实行了夏令时制,那么我们就需要考虑夏令时给系统带来的影响。
如果我们的系统记录数据库服务器的时间为订单创建的时间,那么在凌晨1:59创建了一个订单,然后一分钟后由于夏令时时间调整,接下来下的订单就变成了1:00创建的了,这个时候如果我们按创建订单的时间进行排序就会有问题,明明先创建的订单,结果却排在了后创建订单的后面。
解决办法很简单,也是就跟时区问题一样,我们可以把时间转换为UTC时间再存储。
度量衡
度量衡问题是在国际化过程中遇到的最麻烦的问题,因为度量衡的东西太多(长度、面积、体积、温度重量……)而其相互之间的换算还不一样(华氏度和摄氏度有个换算公式,磅和千克又有一个公式,米和英里、英尺、英尺又是不同的公式)这些足够让人抓狂。所以现在大部分系统都回避这个问题,只使用系统录入的时候输入的值和选择的单位。也就是说如果用户在系统中录入一件商品重10磅,那么所有用户看到的都是10磅,对于中国用户来说,根本不知道10磅是啥概念,只有自己去百度磅和斤怎么换算,然后自己拿个计算器算一下到底有几斤重。
AdventureWorks的模型中,只是使用UnitMeasure表存储了系统中用到的度量衡的单位名称,并没有涉及到度量衡的转换问题。假如我们要啃度量衡这块硬骨头,那我们的数据库模型该怎么设计呢?
首先,我们在数据库尽量要以国际标准的度量衡单位为准,存储数值(温度就用摄氏度做标准单位就行了,没必要用开做单位)。然后建立度量衡表,里面设置了以下栏位:单位名称,单位符号,换算方法(乘以倍率,套用换算公式,查询换算表)与标准单位的换算倍率,与标准单位的换算公式/函数(有些单位的换算不是简单的乘以一个倍数就能搞定的,摄氏度和华氏度就是个特例)。换算表表名(比如鞋子尺码的换算,我们可以通过查表获得)具体单位换算规则我们可以参考:http://baike.baidu.com/view/43851.htm
因为很少有系统在国际化中涉及到度量衡换算问题,所以我接下来举一个具体的例子,说明我这个模型的可行性。
我们以一个重量,一个温度和一个鞋码为例子。
先按模型创建表:
create table StandardUnit ( StandardUnitCode varchar(10) not null, StandardUnitName nvarchar(10) not null, constraint PK_STANDARDUNIT primary key (StandardUnitCode) ) go create table Test ( TestId int identity not null, StandardUnitCode varchar(10) not null, TestValue decimal(18,2) not null, constraint PK_TEST primary key (TestId) ) go alter table Test add constraint FK_TEST_RELATIONS_STANDARD foreign key (StandardUnitCode) references StandardUnit (StandardUnitCode) go create table UnitMeasure ( UnitId int identity not null, StandardUnitCode varchar(10) not null, UnitName nvarchar(10) not null, UnitCode varchar(10) not null, ConvertType varchar(50) not null, ConvertRate double precision null, ConvertFunction varchar(50) null, ConvertTable varchar(50) null, constraint PK_UNITMEASURE primary key (UnitId) ) go alter table UnitMeasure add constraint FK_UNITMEAS_RELATIONS_STANDARD foreign key (StandardUnitCode) references StandardUnit (StandardUnitCode) go create table ShoeSize ( StandardValue decimal(10,2) not null, ToCode varchar(10) not null, ToValue decimal(10,2) not null, constraint PK_SHOESIZE primary key (StandardValue, ToCode) )
go
接下来我们初始化一些数据:
insert into StandardUnit values('Kg',N'千克'),('C',N'摄氏度'),('OS',N'欧码'); insert into UnitMeasure values('C',N'华氏度','F','Function',null,'dbo.ConvertC2F',null); insert into UnitMeasure values('OS',N'美码','US','Table',null,null,'dbo.ShoeSize'); insert into Test values('Kg',0.5),('C',100),('OS',43); insert into ShoeSize values(41,'US',8.5),(42,'US',9),(43,'US',9.5);
这里面涉及到一个温度转换函数,我们需要创建数据库函数dbo.ConvertC2F:
create function dbo.ConvertC2F ( @c decimal(10,2) ) returns decimal(10,2) as begin return @c*1.8+32; end
好了,一切准备就绪,现在写一个SQL,把所有Test中的值,转换为磅,华氏度,美码显示出来,那么我们的SQL写为:
select t.TestId,um.UnitName,t.TestValue*ConvertRate as NewValue from Test t inner join UnitMeasure um on t.StandardUnitCode=um.StandardUnitCode where um.ConvertType='Rate' union all select t.TestId,um.UnitName,dbo.ConvertC2F(t.TestValue) as NewValue from Test t inner join UnitMeasure um on t.StandardUnitCode=um.StandardUnitCode where um.ConvertType='Function' union all select t.TestId,um.UnitName,ss.ToValue as NewValue from Test t inner join UnitMeasure um on t.StandardUnitCode=um.StandardUnitCode inner join dbo.ShoeSize ss on t.TestValue=ss.StandardValue and ss.ToCode=um.UnitCode
where um.ConvertType='Table'
这里由于Function和Table是动态配置的,所以这个SQL是程序先读取了UnitMeasure中的值,然后动态生成的。如果不依靠程序动态生成SQL,我们可以修改模型,去掉ConvertFunction和ConvertTable两个字段,写死一个固定的函数和查找表,毕竟需要用到转换函数的,我目前也就找到了温度,其他基本上都是乘以一个系数就搞定。下面我们就来看看改进后的模型:
对应的SQL为:
create table StandardUnit (
StandardUnitCode varchar(10) not null, StandardUnitName nvarchar(10) not null, constraint PK_STANDARDUNIT primary key (StandardUnitCode) ) go create table Test ( TestId int identity not null, StandardUnitCode varchar(10) not null, TestValue decimal(18,2) not null, constraint PK_TEST primary key (TestId) ) go create table UnitMeasure ( UnitCode varchar(10) not null, StandardUnitCode varchar(10) not null, UnitName nvarchar(10) null, ConvertType varchar(50) null, ConvertRate double precision null, constraint PK_UNITMEASURE primary key (UnitCode) ) go create table UnitValueMapping ( StandardUnitCode varchar(10) not null, StandardValue decimal(18,2) not null, UnitCode varchar(10) not null, ToValue decimal(18,2) null, constraint PK_UNITVALUEMAPPING primary key (StandardUnitCode, UnitCode, StandardValue) ) go alter table Test add constraint FK_TEST_RELATIONS_STANDARD foreign key (StandardUnitCode) references StandardUnit (StandardUnitCode) go alter table UnitMeasure add constraint FK_UNITMEAS_RELATIONS_STANDARD foreign key (StandardUnitCode) references StandardUnit (StandardUnitCode) go alter table UnitValueMapping add constraint FK_UNITVALU_RELATIONS_STANDARD foreign key (StandardUnitCode) references StandardUnit (StandardUnitCode) go alter table UnitValueMapping add constraint FK_UNITVALU_RELATIONS_UNITMEAS foreign key (UnitCode) references UnitMeasure (UnitCode) go
初始化的数据也差不多,这里就不需要再写初始化脚本了,我们来看看转换语句:
select t.TestId,um.UnitName,t.TestValue*ConvertRate as NewValue from Test t inner join UnitMeasure um on t.StandardUnitCode=um.StandardUnitCode where um.ConvertType='Rate' union all select t.TestId,um.UnitName,t.TestValue*1.8+32 as NewValue from Test t inner join UnitMeasure um on t.StandardUnitCode=um.StandardUnitCode where um.ConvertType='Function' union all select t.TestId,um.UnitName,m.ToValue as NewValue from Test t inner join UnitMeasure um on t.StandardUnitCode=um.StandardUnitCode inner join dbo.UnitValueMapping m on t.TestValue=m.StandardValue and m.UnitCode=um.UnitCode and m.StandardUnitCode=t.StandardUnitCode
where um.ConvertType='Table'
【其实鞋码转换问题不能算是度量衡问题,只是为了说明这个模型的扩展性,增加个查找表的转换模式,所以举了这个例子。】