從AdventureWorks學習資料庫建模——國際化

深藍發表於2015-07-28

前一篇部落格我已經把各個實體分析了一遍,從分析中可以看到,這個公司是做本地採購,生產,然後通過網站和門店進行國際銷售的。所以這裡會涉及到一些國際化的問題。接下來就來分析一下有哪些國際化需要注意的問題和資料庫模型中的解決方案。

語言

AdventureWorks資料模型中,只有對ProductDescription進行了多語言設定。關於多語言的建模,我曾經寫了一篇文章,詳細介紹了多語言建模的幾種方法,可以參考:http://www.cnblogs.com/studyzy/archive/2013/04/03/2998322.html

回過頭來看看AdventureWorks是怎麼處理多語言的。

 NewImage

他在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是多對多關係,如下圖所示:

NewImage

這種模型可以使得每一行的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

匯率的時間取值可以是日匯率,月底中心匯率,月平均匯率,年度預算匯率等,一般系統都是以“日”為單位,每日記錄一次匯率資訊,使用中間匯率進行計算的較多。從以上的分析,我們可以建立匯率轉換表如下:

NewImage

接下來在記錄幣種資訊時有以下幾種建模方法:

1. 完全正規化化,只記錄交易幣種

正規化化後匯率表我們可以變成多個表,而在表單中只記錄交易幣種,那是因為我們可能有多種核算方法,比如同一個系統,歐洲區希望以歐元為幣種看到報表,總公司希望看到美元為幣種的報表,那麼轉換後的幣種是不確定的,所以不需要記錄轉換後幣種,我們只需要在表單中記錄原幣種和日期,剩下的就由系統計算得到。

NewImage

這樣做後系統是最靈活的,可以應對各種情況,交易時的真實幣種為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. 固定本位幣,固定匯率型別,反正規化化

如果我們公司不僅本位幣是唯一的,而且採用的匯率型別也是固定的(只使用每人中間匯率),那麼我們的模型可以出於效率的考慮,進一步反正規化化:

每個在記錄金額的欄位,都加上對應的貨幣欄位,同時也加上按當時的匯率換算成本位幣後的金額。

NewImage

這樣做的特點是在後期做各種財務統計時不需要在進行表的連結也不需要進行匯率換算,在錄入資料時都已經計算好,所以統計速度會很快。當然缺點也是顯而易見的,一方面時反正規化化後帶來的缺點,另一方面是可能在錄入資料時並不知道當時的匯率,所以本位幣金額就無法計算,只有等公佈了當天的匯率後再去補上本位幣金額,這是相當麻煩的。還有一個缺點是本位幣金額在計算時取的是中間匯率,如果哪天我們希望再以月平均匯率來統計本位幣金額,那麼還是得重新計算。

時間

時間問題主要是時區的問題,是個比較頭疼的問題,在中國可能意識不到這個問題,因為整個中國都是實行+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

因為很少有系統在國際化中涉及到度量衡換算問題,所以我接下來舉一個具體的例子,說明我這個模型的可行性。

我們以一個重量,一個溫度和一個鞋碼為例子。

NewImage

先按模型建立表:

create table StandardUnit (
StandardUnitCode varchar(10not null,
StandardUnitName nvarchar(10not null,
constraint PK_STANDARDUNIT primary key (StandardUnitCode)
)
go
create table Test (
TestId int identity not null,
StandardUnitCode varchar(10not null,
TestValue decimal(18,2not 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(10not null,
UnitName nvarchar(10not null,
UnitCode varchar(10not null,
ConvertType varchar(50not null,
ConvertRate double precision null,
ConvertFunction varchar(50null,
ConvertTable varchar(50null,
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,2not null,
ToCode varchar(10not null,
ToValue decimal(10,2not 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兩個欄位,寫死一個固定的函式和查詢表,畢竟需要用到轉換函式的,我目前也就找到了溫度,其他基本上都是乘以一個係數就搞定。下面我們就來看看改進後的模型:

NewImage

對應的SQL為:

 create table StandardUnit (

StandardUnitCode varchar(10not null,
StandardUnitName nvarchar(10not null,
constraint PK_STANDARDUNIT primary key (StandardUnitCode)
)
go
create table Test (
TestId int identity not null,
StandardUnitCode varchar(10not null,
TestValue decimal(18,2not null,
constraint PK_TEST primary key (TestId)
)
go
create table UnitMeasure (
UnitCode varchar(10not null,
StandardUnitCode varchar(10not null,
UnitName nvarchar(10null,
ConvertType varchar(50null,
ConvertRate double precision null,
constraint PK_UNITMEASURE primary key (UnitCode)
)
go
create table UnitValueMapping (
StandardUnitCode varchar(10not null,
StandardValue decimal(18,2not null,
UnitCode varchar(10not null,
ToValue decimal(18,2null,
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' 

【其實鞋碼轉換問題不能算是度量衡問題,只是為了說明這個模型的擴充套件性,增加個查詢表的轉換模式,所以舉了這個例子。】

相關文章