0%

接到一個任務,要把全國的公車資料座標訊息爬回來,並且做整理歸納。研究了一下交通部的公共運輸整合資訊平台的內容,資訊相當完整,API也寫得很彈性。

唯獨公車站牌的部分,是以公車路線為出發點思考,每條路線雖然會經過同一個站牌,站牌的ID都會視為不同,不同ID的站牌,經緯度可能相同。

例如 :
富陽街口的公車站牌資料

[![](https://1.bp.blogspot.com/-rsJGRd8q0Yk/V6LuwauETRI/AAAAAAAAH2I/MXCywQPawicA3nbmx9j-D6608XWt5RxXQCLcB/s640/13765731_1287175481295263_7952967575366659415_o.jpg)](https://1.bp.blogspot.com/-rsJGRd8q0Yk/V6LuwauETRI/AAAAAAAAH2I/MXCywQPawicA3nbmx9j-D6608XWt5RxXQCLcB/s1600/13765731_1287175481295263_7952967575366659415_o.jpg)
富陽街口去程回程的公車,透過GoogleMap算大概有8支站牌,但透過API去撈會發現有17支ID皆為不同的資料。

任務目標 :
將所有站牌資料爬回來,相同的站名距離20公尺內的站牌要能自動聚合在一起,成為一支站牌。

原本思考是全部爬回來後用站名做整合,但發現一個問題是宜蘭與桃園都有相同站名的站牌”吊橋頭”,而距離相差十萬八千里,所以站名不可行。會加上20公尺的條件是因為像是台北火車站他的腹地很大,公車站牌可能很多相距很遠, 或是有些捷運站出口在兩條路口的轉角都有公車站牌,且站名相同,如果單純把這些點整合在一起放回地圖上,會發現公車站牌少了許多,失真。

歸納以上的條件,我們會有一大批公車站牌的資料(118728筆資料),如果把每個站牌用同名稱的逐筆下去比對各自的距離,顯然掃到天荒地老也跑不完,顯然浪費效能且不切實際。

看了一篇文章後得到非常大的啟發,該方法是透過四元樹演算法將大批座標快速塞選出範圍內的點,並且做出聚合。
參考  :  How To Efficiently Display Large Amounts of Data on iOS Maps
四元樹演算法 : Wiki

節錄Wiki對四元樹的解說:「假如四元樹區塊被用來表達一組點資料(諸如一組城市的經緯度),區塊就進行次分割直到每個葉節點包含最多一個單點。

用圖片來表示就像這樣

[![](https://4.bp.blogspot.com/-waUqxqrmcf8/V6L7oy8YO-I/AAAAAAAAH2Y/kMcCy8J_kGw45_XPYsvcsqONAuQa767qgCLcB/s320/quadTreeInsert.gif)](https://4.bp.blogspot.com/-waUqxqrmcf8/V6L7oy8YO-I/AAAAAAAAH2Y/kMcCy8J_kGw45_XPYsvcsqONAuQa767qgCLcB/s1600/quadTreeInsert.gif)
**Source  :  https://robots.thoughtbot.com/how-to-handle-large-amounts-of-data-on-maps**
四元樹包含幾個概念 1.每個節點最多只能包含一個座標 2.當節點的座標滿了,會分裂出四個象限

根據以上概念寫出以下Class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/// <summary>
/// 四元樹節點
/// </summary>
public class QuadTreeNode
{
//四個象限
public QuadTreeNode NorthWest { get; set; }
public QuadTreeNode NorthEast { get; set; }
public QuadTreeNode SouthWest { get; set; }
public QuadTreeNode SouthEast { get; set; }

/// <summary>
/// 邊界
/// </summary>
public BoundingBoxDTO BoundingBox { get; set; }

/// <summary>
/// 節點數量
/// </summary>
public int BucketCapacity { get; set; }

/// <summary>
/// 座標資料
/// </summary>
public List<Data> Points { get; set; }

public QuadTreeNode(BoundingBoxDTO boundingBox , int capacity)
{
BoundingBox = boundingBox;
BucketCapacity = capacity;
Points = new List<BusStopAPIResult>();
}
}

那座標插入四元樹節點時如何運作?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public bool Insert(QuadTreeNode node, Data point)
{
// 確定這個座標符合四元樹的框框範圍
if (!CheckBoundingBoxContainData(node.BoundingBox, point))
{
return false;
}

// 如果這個座標符合框框範圍,檢查這個節點是否已經超過座標存放上限
// 如果沒有,那就座標放入回傳True
if ((node.Points.Count +1) <= node.BucketCapacity)
{
node.Points.Add(data);
return true;
}

// 會走到這一步,表示這個座標在這個四元樹的框框範圍內
// 但是能存放的座標已經滿了,判斷這個四元樹節點是否有四個象限
// 如果沒有,進行分裂
if (node.NorthWest == null)
{
Subdivide(node);
}

// 將座標丟到新分裂出來的四個四元樹象限,符合座標坐落的位置,存放好回傳True
if (Insert(node.NorthWest, data)) return true;
if (Insert(node.NorthEast, data)) return true;
if (Insert(node.SouthWest, data)) return true;
if (Insert(node.SouthEast, data)) return true;

return false;
}

四元樹分裂的程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/// <summary>
/// 四元樹分裂
/// </summary>
/// <param name="node">四元樹節點</param>
void Subdivide(QuadTreeNode node)
{
var box = node.BoundingBox;

//找出要分裂的四元樹中心點
double LatMid = (box.LeftTop.Latitude + box.RightBottom.Latitude) / 2.0;
double LngMid = (box.LeftTop.Longitude + box.RightBottom.Longitude) / 2.0;

//算出西北象限的左上右下座標點
var NorthWest = new BoundingBoxDTO
{
LeftTop = new CoordinateDTO
{
Latitude = box.LeftTop.Latitude,
Longitude = box.LeftTop.Longitude
},
RightBottom = new CoordinateDTO
{
Latitude = LatMid,
Longitude = LngMid
}
};
node.NorthWest = new QuadTreeNode(NorthWest, node.BucketCapacity);

//算出東北象限的左上右下座標點
var NorthEast = new BoundingBoxDTO
{
LeftTop = new CoordinateDTO
{
Latitude = box.LeftTop.Latitude,
Longitude = LngMid
},
RightBottom = new CoordinateDTO
{
Latitude = LatMid,
Longitude = box.RightBottom.Longitude
}
};
node.NorthEast = new QuadTreeNode(NorthEast, node.BucketCapacity);

//算出西南象限的左上右下座標點
var SouthWest = new BoundingBoxDTO
{
LeftTop = new CoordinateDTO
{
Latitude = LatMid,
Longitude = box.LeftTop.Longitude
},
RightBottom = new CoordinateDTO
{
Latitude = box.RightBottom.Latitude,
Longitude = LngMid
}
};
node.SouthWest = new QuadTreeNode(SouthWest, node.BucketCapacity);

//算出東南象限的左上右下座標點
var SouthEast = new BoundingBoxDTO
{
LeftTop = new CoordinateDTO
{
Latitude = LatMid,
Longitude = LngMid
},
RightBottom = new CoordinateDTO
{
Latitude = box.RightBottom.Latitude,
Longitude = box.RightBottom.Longitude
}
};
node.SouthEast = new QuadTreeNode(SouthEast, node.BucketCapacity);

}

看到這邊可能會有個疑問,那就是雖然知道四元樹就像是遞迴的概念,將地圖分裂成很多塊,每個大區塊可能又被分裂成若干的小區塊,一層一層的往下長

[![](https://1.bp.blogspot.com/-m_rkkVB1hxw/V6MCmq-X0gI/AAAAAAAAH2o/FHOE9VcWAvkLDp8BWJvjYnWvP9-NlbGfgCLcB/s320/500px-Point_quadtree.svg.png)](https://1.bp.blogspot.com/-m_rkkVB1hxw/V6MCmq-X0gI/AAAAAAAAH2o/FHOE9VcWAvkLDp8BWJvjYnWvP9-NlbGfgCLcB/s1600/500px-Point_quadtree.svg.png)
**Source  :  https://zh.wikipedia.org/wiki/%E5%9B%9B%E5%8F%89%E6%A0%91**
但為何會讓搜尋速度加快呢? 原因是他從上層開始看是否有重疊到要搜尋的區塊,如果有就往該節點的四象限遞迴繼續往下找重疊,如果在大區塊的節點就已經沒有包含重疊區域,那他無限往下延伸的子節點也就不會被搜尋了。引用原作者的圖片就一目了然
[![](https://3.bp.blogspot.com/-FXlbFONjFzI/V6MEINzRBHI/AAAAAAAAH20/gXhavItzQ_o3GgeyMPRgBGAxhJ9U6MuVwCLcB/s400/quadTreeQuery.gif)](https://3.bp.blogspot.com/-FXlbFONjFzI/V6MEINzRBHI/AAAAAAAAH20/gXhavItzQ_o3GgeyMPRgBGAxhJ9U6MuVwCLcB/s1600/quadTreeQuery.gif)
**Source  :  https://robots.thoughtbot.com/how-to-handle-large-amounts-of-data-on-maps**

搜尋程式碼如下
如何透過座標判斷地理位置有無交集,請參考上一篇文章: 【地理位置】透過座標框出地理位置,與判斷地理位置是否重疊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void SearchDataInRange(QuadTreeNode node, BoundingBoxDTO range, ref List<Data> datas)
{
// 確認要搜尋的區塊是否與四元樹節點所涵蓋的區塊有重疊到,如果沒有就直接跳掉
if (!CheckIntersectionBoundingBox(node.BoundingBox, range))
{
return;
}

//如果有,將這個區塊的節點拿出來比對是否在要搜尋的範圍中
//因為雖然範圍有重疊到,但不代表裡面的所有節點都落在重疊的範圍
//如果落在重疊的範圍,儲存起來,之後回傳
foreach (var item in node.Points)
{
// Gather points contained in range
if (CheckBoundingBoxContainData(range, item))
{
datas.Add(item);
}
}

//確認這個節點是否有分裂過
if (node.NorthWest == null)
{
return;
}

//如果有分裂成四個象限,繼續遞迴往下找
SearchDataInRange(node.NorthWest, range, ref datas);
SearchDataInRange(node.NorthEast, range, ref datas);
SearchDataInRange(node.SouthWest, range, ref datas);
SearchDataInRange(node.SouthEast, range, ref datas);
}

說了這麼多,我們如何將上述的四元樹理論運用在我們的目標上呢?

首先我們把一堆的公車站牌資料抓回來後,依據名稱先進行第一次分類,把每個名稱相同的依據上面的方式做成四元樹節點,但這邊有個前面提到的問題,如果是同個站名但其實是不同的站點呢?

這邊我用的方法是將同名稱的公車站牌,找出兩個離最遠的站牌算距離,如果超過1公里合理懷疑這是兩個不同的站點,只是碰巧相同名稱。台灣應該沒有差了一公里遠但是是同一個站的吧……(有的話請跟我說XD)。

將這群同站名但其實不同地方的站牌,依據一公里為單位分組,這樣就能有效地將這類同名的站牌做更細部的歸類。

計算兩點座標距離的程式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private const double EARTH_RADIUS = 6378.137; //地球半?

/// <summary>
/// 計算距離(返回公尺)
/// </summary>
/// <param name="lat1">點1經</param>
/// <param name="lng1">點1緯</param>
/// <param name="lat2">點2經</param>
/// <param name="lng2">點2緯</param>
/// <returns></returns>
public static double GetDistance(double lat1, double lng1, double lat2, double lng2)
{
double radLat1 = rad(lat1);
double radLat2 = rad(lat2);
double a = radLat1 - radLat2;
double b = rad(lng1) - rad(lng2);
double s = 2 * Math.Asin(Math.Sqrt(Math.Pow(Math.Sin(a / 2), 2) +
Math.Cos(radLat1) * Math.Cos(radLat2) * Math.Pow(Math.Sin(b / 2), 2)));
s = s * EARTH_RADIUS;
s = Math.Round(s * 10000) / 10;
return s;
}

private static double rad(double d)
{
return d * Math.PI / 180.0;
}

上面的問題皆排除並分類後,依據同名的站牌,算出這群站牌座標的中心點,並用離中心最遠的座標距離做為範圍,依照每小格20公尺的矩形進行四元樹搜尋,在同一格內的整合成一個站牌,記錄下來

概念圖

[![](https://2.bp.blogspot.com/-C9CqLPtlx7Q/V6MKsEK1vTI/AAAAAAAAH3E/KDH_KTeXYQQnoFWCQdLL7mvIihxNMh7KgCLcB/s640/1.png)](https://2.bp.blogspot.com/-C9CqLPtlx7Q/V6MKsEK1vTI/AAAAAAAAH3E/KDH_KTeXYQQnoFWCQdLL7mvIihxNMh7KgCLcB/s1600/1.png)
1.這些點只是亂標的,但假設真的有個同名站牌坐落在這些位置,找到中心點後,藍色線為中心點與最遠座標的直線距離,依據這個距離做為矩形二分之一寬。

2.有了距離後就能求出左上跟右下角的點,透過DbGeography這個來取得地理面積的物件

3.從左上開始,橘色的每小格為長寬各20公尺的矩形,透過四元樹下去搜尋,被同一格橘色小框框到的座標收納成同一個點,記錄下來

經過實測,如果只是雙北市的公車站點,只要4分鐘就能全部歸類掃描完畢,全國的話大概4個小時能算完,以上是這次任務的小筆記

測試環境 :
CPU  :  I7  4核 (實際在跑的時候控制大概在3核左右)
RAM : 16G

如果取得西北東南的兩點座標透過DbGeography這個類別能夠框出地理面積,程式碼如下

1
2
3
4
5
6
DbGeography.FromText(string.Format("POLYGON(({0} {1}, {0} {2}, {3} {2}, {3} {1}, {0} {1}))",
西北座標點的經度,
西北座標點的緯度,
東南座標點的緯度,
東南座標點的經度), 4326);

這樣能取回DbGeography的地理面積的類別,透過這個類別的Intersects這個Method能判斷兩個面積,或是座標點是否有交集。參考MSDN

範例 : 用Google地圖來取得兩個座標點,框出地理位置
**
**座標點1 : 25.029768, 121.546829

[![](https://1.bp.blogspot.com/-2gYLmcT7YnE/V6Lj8q2dPzI/AAAAAAAAH1I/paibCBIx16cafBaBrvhNCJkrFTcVrYS0gCLcB/s400/1.png)](https://1.bp.blogspot.com/-2gYLmcT7YnE/V6Lj8q2dPzI/AAAAAAAAH1I/paibCBIx16cafBaBrvhNCJkrFTcVrYS0gCLcB/s1600/1.png)

座標點2 : 25.028854, 121.548417

[![](https://2.bp.blogspot.com/-U3tLa6GFY3U/V6LkMN-HODI/AAAAAAAAH1M/9FhZt1GbnlQPi29X-gO8YKwhuiZbLUBvACLcB/s400/1.png)](https://2.bp.blogspot.com/-U3tLa6GFY3U/V6LkMN-HODI/AAAAAAAAH1M/9FhZt1GbnlQPi29X-gO8YKwhuiZbLUBvACLcB/s1600/1.png)

所以透過剛剛的程式我們可以框出這塊地理面積

[![](https://2.bp.blogspot.com/-vt2mdO_TXx0/V6LlFRP_RrI/AAAAAAAAH1U/iyilo98OOwsCNtTPvlcvN9Hs-LAiD0uAwCLcB/s320/1.png)](https://2.bp.blogspot.com/-vt2mdO_TXx0/V6LlFRP_RrI/AAAAAAAAH1U/iyilo98OOwsCNtTPvlcvN9Hs-LAiD0uAwCLcB/s1600/1.png)

依樣畫葫蘆,在選兩個點去框出下個面積
座標點1 : 25.029541, 121.547474
**座標點2 : **25.028199, 121.549636

[![](https://1.bp.blogspot.com/-uv7-0y_8x2I/V6LlwFgayyI/AAAAAAAAH1k/4lmNDDpGBmMhF0xtmFJjIuAtlQKfqw_mwCLcB/s320/1.png)](https://1.bp.blogspot.com/-uv7-0y_8x2I/V6LlwFgayyI/AAAAAAAAH1k/4lmNDDpGBmMhF0xtmFJjIuAtlQKfqw_mwCLcB/s1600/1.png)

從兩張圖可以看到在四維路那邊有重疊的部分,用程式碼來跑跑看

[![](https://1.bp.blogspot.com/-1wdcHZ4VBEU/V6LmrE_EI7I/AAAAAAAAH1s/4_wwJ8SwkEw-neIcwpAeeaoTfrcJ544JgCLcB/s640/1.png)](https://1.bp.blogspot.com/-1wdcHZ4VBEU/V6LmrE_EI7I/AAAAAAAAH1s/4_wwJ8SwkEw-neIcwpAeeaoTfrcJ544JgCLcB/s1600/1.png)

為了驗證不重疊會回傳False,我們將Area2的西北座標往右下角移動,讓面積縮小不會跟Area1重疊,再次實驗看看
Area2 
座標點1 : 25.029541, 121.547474 >> 25.029123, 121.548772  (改到敦化南路二段上)
**座標點2 : **25.028199, 121.549636

[![](https://4.bp.blogspot.com/-EYzYtuHuPs0/V6Lnj8MSgYI/AAAAAAAAH14/cDU5K1uKDQUlQbYYNdFEt_h_sJySf0ZmACLcB/s1600/1.png)](https://4.bp.blogspot.com/-EYzYtuHuPs0/V6Lnj8MSgYI/AAAAAAAAH14/cDU5K1uKDQUlQbYYNdFEt_h_sJySf0ZmACLcB/s1600/1.png)

補充:
上面介紹的是兩點框出面積,如果要判斷一個座標點有無落在一個面積裡面,用法也相同,只是建立DbGeography座標點的方法略有不同,如下

1
2
var point = DbGeography.PointFromText(string.Format("POINT({0} {1})", 經度, 緯度), 4326);

一樣帶到Intersects就可以得到答案了!! 以上

曾上一篇,開始在我們的Production專案寫一段跟DB要資料的程式吧。(註 : 這邊不考慮分層與物件導向問題,一切專注在單元測試上)

首先先在專案中加入EntityFramework,並把Northwind的Employees Table加進來

[![](https://4.bp.blogspot.com/-rgwWYtv-ueQ/V5rwi6Q1TII/AAAAAAAAHyQ/uRS4J_82bxsDX5wZNtqjqoA8hjVtRUjcQCLcB/s640/1.png)](https://4.bp.blogspot.com/-rgwWYtv-ueQ/V5rwi6Q1TII/AAAAAAAAHyQ/uRS4J_82bxsDX5wZNtqjqoA8hjVtRUjcQCLcB/s1600/1.png)
[![](https://2.bp.blogspot.com/-yQ-t7YWi-qI/V5rwi006AII/AAAAAAAAHyY/1ftZ1r6NWgQVEmiKy4uhK84XBGa4le3TQCLcB/s1600/2.png)](https://2.bp.blogspot.com/-yQ-t7YWi-qI/V5rwi006AII/AAAAAAAAHyY/1ftZ1r6NWgQVEmiKy4uhK84XBGa4le3TQCLcB/s1600/2.png)
[![](https://2.bp.blogspot.com/-eUqI2lewHEY/V5rwi_fRLXI/AAAAAAAAHyU/D-s4l_BrxWE2XOmsvflt-8s-Uo2arn95gCLcB/s640/3.png)](https://2.bp.blogspot.com/-eUqI2lewHEY/V5rwi_fRLXI/AAAAAAAAHyU/D-s4l_BrxWE2XOmsvflt-8s-Uo2arn95gCLcB/s1600/3.png)
[![](https://2.bp.blogspot.com/-dbz2xrn6Veo/V5rwjJrehdI/AAAAAAAAHyc/zTVkWvQnVoMUaEVjPiL9TwqtZhTwYxeyQCLcB/s1600/4.png)](https://2.bp.blogspot.com/-dbz2xrn6Veo/V5rwjJrehdI/AAAAAAAAHyc/zTVkWvQnVoMUaEVjPiL9TwqtZhTwYxeyQCLcB/s1600/4.png)
[![](https://3.bp.blogspot.com/-gY9k8ego5EE/V5rwjPDRHwI/AAAAAAAAHyg/7ICoznJNU74dBS7VPVp56-He_BOoF6HxwCLcB/s1600/5.png)](https://3.bp.blogspot.com/-gY9k8ego5EE/V5rwjPDRHwI/AAAAAAAAHyg/7ICoznJNU74dBS7VPVp56-He_BOoF6HxwCLcB/s1600/5.png)
[![](https://2.bp.blogspot.com/-4HqmVn15BgY/V5rw-Q4Ow3I/AAAAAAAAHyk/kQKue8geuyUfymRmy-mPAhsTIKt_-iPSwCLcB/s1600/6.png)](https://2.bp.blogspot.com/-4HqmVn15BgY/V5rw-Q4Ow3I/AAAAAAAAHyk/kQKue8geuyUfymRmy-mPAhsTIKt_-iPSwCLcB/s1600/6.png)
[![](https://3.bp.blogspot.com/-xa7G6erk2fM/V5rxkJIbLSI/AAAAAAAAHys/sTsfBXgEjCAPXHa1C4YXqdZ4VZLEHeSIgCLcB/s1600/1.png)](https://3.bp.blogspot.com/-xa7G6erk2fM/V5rxkJIbLSI/AAAAAAAAHys/sTsfBXgEjCAPXHa1C4YXqdZ4VZLEHeSIgCLcB/s1600/1.png)
接著寫一段程式,讓我們帶入ID能順利取回該筆員工資料
[![](https://2.bp.blogspot.com/-iG0Vl0uUc0I/V5ryyt4eQoI/AAAAAAAAHy8/DsdR1LF6JycomPbTS1zop1NvFhb1C8olwCLcB/s1600/1.png)](https://2.bp.blogspot.com/-iG0Vl0uUc0I/V5ryyt4eQoI/AAAAAAAAHy8/DsdR1LF6JycomPbTS1zop1NvFhb1C8olwCLcB/s1600/1.png)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class EmployeesRepository :IDisposable
{
NorthwindEntities DBContext;
bool disposedValue;
public EmployeesRepository()
{
DBContext = new NorthwindEntities();
disposedValue = false;
}

public Employees Get(int id)
{
return DBContext.Employees.FirstOrDefault(x => x.EmployeeID == id);
}

protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (DBContext != null)
DBContext.Dispose();

disposedValue = true;
}
}

public void Dispose()
{
Dispose(disposedValue);
GC.SuppressFinalize(this);
}
}

改寫HomeController的Index Action,依據帶入的ID取回員工,並顯示於葉面上

[![](https://3.bp.blogspot.com/-nqGw1xVRRX0/V5r20E6fPAI/AAAAAAAAHzc/qRcvr7Lr_Os9IrcMr_1XqYnNJYTxKfVsgCLcB/s640/1.png)](https://3.bp.blogspot.com/-nqGw1xVRRX0/V5r20E6fPAI/AAAAAAAAHzc/qRcvr7Lr_Os9IrcMr_1XqYnNJYTxKfVsgCLcB/s1600/1.png)
View的部分(一切從簡 XDD)
[![](https://4.bp.blogspot.com/-Z3oYtUvXjqo/V5r3L10zYiI/AAAAAAAAHzg/RH5ZJQSpyHk5X8NVMhOpk7JA0rJT95nkQCLcB/s640/1.png)](https://4.bp.blogspot.com/-Z3oYtUvXjqo/V5r3L10zYiI/AAAAAAAAHzg/RH5ZJQSpyHk5X8NVMhOpk7JA0rJT95nkQCLcB/s1600/1.png)
來執行看看這段Code有沒有用!!!
[![](https://1.bp.blogspot.com/-6bOfbEOFZ_M/V5r3beLjkWI/AAAAAAAAHzo/Q1YmS32wgeMfNPkUeJqFVVQDjRRmu-iKQCLcB/s640/1.png)](https://1.bp.blogspot.com/-6bOfbEOFZ_M/V5r3beLjkWI/AAAAAAAAHzo/Q1YmS32wgeMfNPkUeJqFVVQDjRRmu-iKQCLcB/s1600/1.png)
[![](https://3.bp.blogspot.com/-NfsCSeUstAo/V5r3ooighWI/AAAAAAAAHzs/R94VplAsQn8XeC3QaeL9euzBNLf8NvPjACLcB/s1600/1.png)](https://3.bp.blogspot.com/-NfsCSeUstAo/V5r3ooighWI/AAAAAAAAHzs/R94VplAsQn8XeC3QaeL9euzBNLf8NvPjACLcB/s1600/1.png)

所以這段Code的確可以正常運作,確定之後我們來寫單元測試驗證這件事情。

首先在單元測試專案建立EmployeesRepositoryTests

[![](https://3.bp.blogspot.com/-LVviiAdNxxs/V5r4xF2a5NI/AAAAAAAAHz8/8H5uWifglgYlr2hqA9WDZ5aageApPJrYACLcB/s1600/1.png)](https://3.bp.blogspot.com/-LVviiAdNxxs/V5r4xF2a5NI/AAAAAAAAHz8/8H5uWifglgYlr2hqA9WDZ5aageApPJrYACLcB/s1600/1.png)
上一篇有提到,這邊做的單元測試是每個測試前將準備好的CSV資料匯入LocalDB,做完測試後把資料全部砍掉,所以我們得先寫一段TestInitial讓每次測試之前先跑匯入資料的部分,先在單元測試專把CSVHelper安裝起來。
[![](https://2.bp.blogspot.com/--r9VuX4WFbQ/V5r5fTcTc_I/AAAAAAAAH0E/to6r2WGGomsVjlG3sQhFFworO3dAasVqgCLcB/s640/1.png)](https://2.bp.blogspot.com/--r9VuX4WFbQ/V5r5fTcTc_I/AAAAAAAAH0E/to6r2WGGomsVjlG3sQhFFworO3dAasVqgCLcB/s1600/1.png)

接著寫程式把資料從CSV讀出來,並寫入LocalDB,但首先先將測試專案安裝EntityFramwork套件

[![](https://4.bp.blogspot.com/-CbTTIt0AEHI/V5scdqAt-FI/AAAAAAAAH0U/ZiSP3YNeYMwuoUwkZ8dC6iTIq7EMZ8mkACLcB/s640/1.png)](https://4.bp.blogspot.com/-CbTTIt0AEHI/V5scdqAt-FI/AAAAAAAAH0U/ZiSP3YNeYMwuoUwkZ8dC6iTIq7EMZ8mkACLcB/s1600/1.png)

然後記得把連線字串加到單元測試的專案之中

1
2
3
4
<connectionStrings>
<add name="NorthwindEntities" connectionString="metadata=res://*/Models.Northwind.csdl|res://*/Models.Northwind.ssdl|res://*/Models.Northwind.msl;provider=System.Data.SqlClient;provider connection string=&quot;data source=(LocalDB)\MSSQLLocalDB;attachdbfilename=|DataDirectory|\TestDB.mdf;integrated security=True;MultipleActiveResultSets=True;App=EntityFramework&quot;" providerName="System.Data.EntityClient" />
</connectionStrings>

寫以下程式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WebApplication4.Models;
using System.IO;
using CsvHelper;
using System.Linq;
using System.Reflection;
using System.Linq;
namespace WebApplication4.Tests
{
[TestClass]
public class EmployeesRepositoryTests
{

[TestInitialize]
public void Initial()
{
//讀取檔案
using (StreamReader reader = new StreamReader(
string.Concat(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"\CSVs\Employees.csv"),
new UTF8Encoding()))
using (var csvReader = new CsvReader(reader))
{
csvReader.Configuration.WillThrowOnMissingField = false;
var Employees = csvReader.GetRecords<Employees>().ToList();

//將資料寫入DB
var DBContext = new NorthwindEntities();
DBContext.Employees.AddRange(Employees);
DBContext.SaveChanges();
}
}

[TestMethod]
public void Get_帶入ID_應取回該ID的Employee()
{
//arrange
var ID = 0;
var Sut = new EmployeesRepository();

var Expected = "Nancy";
//act
var actual = Sut.Get(ID);

//assert
Assert.AreEqual(Expected, actual.FirstName);
}
}
}

執行看看會發現爆掉了!!!!!! 果然事情不是憨人我想的那麼簡單

[![](https://1.bp.blogspot.com/--GWQvH-BzPo/V5srR-gwv8I/AAAAAAAAH0k/P2uj3DhlLwI1Q5ZTEPa0_E7CdwyQvMNngCLcB/s640/1.png)](https://1.bp.blogspot.com/--GWQvH-BzPo/V5srR-gwv8I/AAAAAAAAH0k/P2uj3DhlLwI1Q5ZTEPa0_E7CdwyQvMNngCLcB/s1600/1.png)

原因出在於Employee這個Table的ID欄位為流水自動編號,透過EF是無法寫入的,這時候只好透過Dapper下指令解決了,先安裝Dapper

[![](https://3.bp.blogspot.com/-pST5pb51uh0/V5srvCR_TVI/AAAAAAAAH0o/rH5mdCRr574aqTB8_sQIQ6S2clZ4bcU1QCLcB/s640/1.png)](https://3.bp.blogspot.com/-pST5pb51uh0/V5srvCR_TVI/AAAAAAAAH0o/rH5mdCRr574aqTB8_sQIQ6S2clZ4bcU1QCLcB/s1600/1.png)

將剛剛從CSVHelper的資料用Dapper塞進去,而且要將Identity Insert打開,先補上要給Dapper用的連線字串

1
2
<add name="NorthwindString" connectionString="Data Source=(LocalDB)\MSSQLLocalDB;attachdbfilename=|DataDirectory|\TestDB.mdf;Persist Security Info=True;" providerName="System.Data.SqlClient" />

把程式改成如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WebApplication4.Models;
using System.IO;
using CsvHelper;
using System.Linq;
using System.Reflection;
using System.Data.SqlClient;
using Dapper;
namespace WebApplication4.Tests
{
[TestClass]
public class EmployeesRepositoryTests
{

[TestInitialize]
public void Initial()
{
//讀取檔案
using (StreamReader reader = new StreamReader(
string.Concat(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"\CSVs\Employees.csv"),
new UTF8Encoding()))
using (var csvReader = new CsvReader(reader))
{
csvReader.Configuration.WillThrowOnMissingField = false;
var Employees = csvReader.GetRecords<Employees>().ToList();

//將資料寫入DB
//var DBContext = new NorthwindEntities();
//DBContext.Employees.AddRange(Employees);
//DBContext.SaveChanges();
using (var cn = new SqlConnection(System.Configuration.ConfigurationManager.ConnectionStrings["NorthwindString"].ConnectionString))
{
cn.Execute(@"SET IDENTITY_INSERT Employees ON
INSERT INTO Employees (EmployeeID,LastName,FirstName,Title,TitleOfCourtesy,BirthDate,HireDate,Address,City,Region,PostalCode,Country,HomePhone,Extension,Notes,ReportsTo,PhotoPath)
VALUES (@EmployeeID,@LastName,@FirstName,@Title,@TitleOfCourtesy,@BirthDate,@HireDate,@Address,@City,@Region,@PostalCode,@Country,@HomePhone,@Extension,@Notes,@ReportsTo,@PhotoPath)
SET IDENTITY_INSERT Employees OFF",
Employees);
}

}
}

[TestMethod]
public void Get_帶入ID_應取回該ID的Employee()
{
//arrange
var ID = 1;
var Sut = new EmployeesRepository();

var Expected = "Nancy";
//act
var actual = Sut.Get(ID);

//assert
Assert.AreEqual(Expected, actual.FirstName);
}
}
}

執行看看,就會發現終於成功了…….

[![](https://1.bp.blogspot.com/-XvPJF-Zf0u8/V5su65wLx5I/AAAAAAAAH04/D4u0LHDen-EHvjOweb7yfV2r9aLUehYmACLcB/s320/1.png)](https://1.bp.blogspot.com/-XvPJF-Zf0u8/V5su65wLx5I/AAAAAAAAH04/D4u0LHDen-EHvjOweb7yfV2r9aLUehYmACLcB/s1600/1.png)
最後別忘了前面提到的,每個單元測試應該都是獨立的,所以既然每次都會寫資料進去,當然每次測試完也都要把資料砍掉啦
所以在單元測試的最後補上這個Method,讓他每個單元測試執行完畢後都會清掉資料
```csharp [TestCleanup] public void CleanUp() { using (var cn = new SqlConnection(System.Configuration.ConfigurationManager.ConnectionStrings["NorthwindString"].ConnectionString)) { cn.Execute(@"Delete Employees"); } }

```

這樣就大功告成了!!!!

大部分的專案都會需要跟資料庫溝通,存取資料。雖然說開發的時候往往都有測試資料庫,但測試機料庫也可能因為很多人都在開發存取,導致資料可能會時常受到異動。

單元測試的一大重點就是不管任何人、時、地,都要能測試成功,如果今天用來測試的資料庫可能面臨資料在不確定何時何人會異動的情境下,這可能會讓我們的單元測試雖然邏輯正確,但最後驗證失敗。例如:原本預期A會員權限有效,但因為測試資料庫別人也在使用開發,所以被改成失效,這時候你如果去跑單元測試就會亮起紅燈失敗,直到你去查了後才發現原來是源頭資料被改動了。為了解決這樣的問題,我們最好能創造一個資料庫只給單元測試使用,且能跟著專案走的(不然別人從Git把專案抓下來,沒有資料庫的情況下單元測試全掛掉…..這該如何是好),所以LocalDB就是一個很好的選擇。

第一篇就紀錄一下我如何建立LocalDB、並準備測試資料,這邊大部分都參考
MRKT大師 : 測試專案使用 LocalDB - 使用 Entity Framework 的情境 
MRKT大師 : Dapper - 使用 LINQPad 快速產生相對映 SQL Command 查詢結果的類別
,以及一些公司同事提供的方便小工具,非自己原創XD


第一步、建立專案來擺放LocalDB

建立一般的Library Project

[![](https://4.bp.blogspot.com/-QN2Lgo0WWLM/V5nWU8uV4rI/AAAAAAAAHwg/AjshLUxypfUm3whgktw12ZXq7ApXm3FGgCLcB/s640/1.png)](https://4.bp.blogspot.com/-QN2Lgo0WWLM/V5nWU8uV4rI/AAAAAAAAHwg/AjshLUxypfUm3whgktw12ZXq7ApXm3FGgCLcB/s1600/1.png)

接下在專案底下新增LocalDB

[![](https://2.bp.blogspot.com/-8T2Ln5dZrpo/V5nWwfdpzFI/AAAAAAAAHwk/vk4Ujt5NUeMltYoBoSBlZnMSmv-HD27twCLcB/s640/1.png)](https://2.bp.blogspot.com/-8T2Ln5dZrpo/V5nWwfdpzFI/AAAAAAAAHwk/vk4Ujt5NUeMltYoBoSBlZnMSmv-HD27twCLcB/s1600/1.png)
[![](https://4.bp.blogspot.com/-sfnO0MbA31s/V5rEVI9uucI/AAAAAAAAHw4/dHkcoCnpGTc_4O7I_BY0NKphzT2uP03vACLcB/s1600/1.png)](https://4.bp.blogspot.com/-sfnO0MbA31s/V5rEVI9uucI/AAAAAAAAHw4/dHkcoCnpGTc_4O7I_BY0NKphzT2uP03vACLcB/s1600/1.png)

第二步、幫LocalDB建立Schema (已NorthWind資料庫為例)

基本上SSMS操作差不多,在資料表右鍵 > 新增查詢,然後把Create Employees Table的Script貼過去執行
[![](https://2.bp.blogspot.com/-5ji6J1LhAWQ/V5rJYfKcExI/AAAAAAAAHxI/IFxpZPmJ5skP5CFyB11VLs-iKe0DLSnLACLcB/s1600/1.png)](https://2.bp.blogspot.com/-5ji6J1LhAWQ/V5rJYfKcExI/AAAAAAAAHxI/IFxpZPmJ5skP5CFyB11VLs-iKe0DLSnLACLcB/s1600/1.png)
[![](https://3.bp.blogspot.com/-nqLVzU-QAqQ/V5rJolPPJII/AAAAAAAAHxM/Z99R12JALYgIIAq12h6mZGB4JdVBQl0HQCLcB/s640/1.png)](https://3.bp.blogspot.com/-nqLVzU-QAqQ/V5rJolPPJII/AAAAAAAAHxM/Z99R12JALYgIIAq12h6mZGB4JdVBQl0HQCLcB/s1600/1.png)

第三步、準備測試資料

因為每個測試之間應該都是獨立的,為了避免互相干擾,所以資料通常不會預先倒到LocalDB裡面去,而是在執行每次測試時,動態的將準備好的資料寫進去,每個測試完畢後砍掉資料,這樣不停的重複著。
而一個Table的資料可能會很多很多,如果用手寫成SQL Script應該寫完專案DeadLine也過了,
所以這邊打算將資料匯出到CSV檔案中,然後透過CSVHelper讀出來倒進DB,做法如下:
已下搭配LinqPad使用
```csharp void Main() { // 這邊修改為你要執行的 SQL Command var sqlCommand = @"select top 1 * from Employees"; // 第一個參數填入 SQL Command, 第二個參數輸入要產生的 Class 名稱 this.Connection.DumpClass(sql: sqlCommand.ToString(), className: "Employees").Dump(); } public static class LINQPadExtensions { private static readonly Dictionary TypeAliases = new Dictionary { { typeof(int), "int" }, { typeof(short), "short" }, { typeof(byte), "byte" }, { typeof(byte[]), "byte[]" }, { typeof(long), "long" }, { typeof(double), "double" }, { typeof(decimal), "decimal" }, { typeof(float), "float" }, { typeof(bool), "bool" }, { typeof(string), "string" } }; private static readonly HashSet NullableTypes = new HashSet { typeof(int), typeof(short), typeof(long), typeof(double), typeof(decimal), typeof(float), typeof(bool), typeof(DateTime) }; public static string DumpClass(this IDbConnection connection, string sql, string className = "Info") { if (connection.State != ConnectionState.Open) { connection.Open(); } var cmd = connection.CreateCommand(); cmd.CommandText = sql; var reader = cmd.ExecuteReader(); var builder = new StringBuilder(); do { if (reader.FieldCount <= 1) continue; builder.AppendFormat("public class {0}{1}", className, Environment.NewLine); builder.AppendLine("{"); var schema = reader.GetSchemaTable(); foreach (DataRow row in schema.Rows) { var type = (Type)row["DataType"]; var name = TypeAliases.ContainsKey(type) ? TypeAliases[type] : type.Name; var isNullable = (bool)row["AllowDBNull"] && NullableTypes.Contains(type); var collumnName = (string)row["ColumnName"]; builder.AppendLine(string.Format("\tpublic {0}{1} {2} {{ get; set; }}", name, isNullable ? "?" : string.Empty, collumnName)); builder.AppendLine(); } builder.AppendLine("}"); builder.AppendLine(); } while (reader.NextResult()); return builder.ToString(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72


這支程式的目的是先做出符合Employees對應的Class,方便支後匯出跟匯入資料,執行後應該會看到產出的Class文字檔
<div class="separator" style="clear: both; text-align: center;">[![](https://4.bp.blogspot.com/-lnj9t366iQ4/V5rM--fHASI/AAAAAAAAHxc/fb_na4MSXXgUZTpXFxaxy65YfjGEWs5lgCLcB/s640/1.png)](https://4.bp.blogspot.com/-lnj9t366iQ4/V5rM--fHASI/AAAAAAAAHxc/fb_na4MSXXgUZTpXFxaxy65YfjGEWs5lgCLcB/s1600/1.png)</div>

接著用第一支程式產出的Class貼到第二支程式底下

```csharp
void Main()
{
//修改匯出CSV資料存放的位置
using (var sw = new StreamWriter(@"D:\Employees.csv"))
using (var writer = new CsvWriter(sw))
{
var result = new List<Employees>();
var connectionString = this.Connection.ConnectionString;

using (var conn = new SqlConnection(connectionString))
{
conn.Open();

var sqlCommand = new StringBuilder();
//要匯出哪些資料的SQL SCript
sqlCommand.AppendLine(@"select top 10 * from Employees");
result = conn.Query<Employees>(sqlCommand.ToString())
.ToList();
}
writer.WriteRecords(result);
}
}
//將第一支程式產出的Class貼在這邊!!!

public class Employees
{
public int EmployeeID { get; set; }

public string LastName { get; set; }

public string FirstName { get; set; }

public string Title { get; set; }

public string TitleOfCourtesy { get; set; }

public DateTime? BirthDate { get; set; }

public DateTime? HireDate { get; set; }

public string Address { get; set; }

public string City { get; set; }

public string Region { get; set; }

public string PostalCode { get; set; }

public string Country { get; set; }

public string HomePhone { get; set; }

public string Extension { get; set; }

public byte[] Photo { get; set; }

public string Notes { get; set; }

public int? ReportsTo { get; set; }

public string PhotoPath { get; set; }

}

執行之後,依照你寫的路徑去找到產出的CSV檔,並把他貼到測試專案之中

[![](https://3.bp.blogspot.com/-UxqEWgpQ1Z8/V5rQF-UsS7I/AAAAAAAAHxo/UM-O8j-1vqg4RoZOaC266QtsGuVB742IACLcB/s1600/1.png)](https://3.bp.blogspot.com/-UxqEWgpQ1Z8/V5rQF-UsS7I/AAAAAAAAHxo/UM-O8j-1vqg4RoZOaC266QtsGuVB742IACLcB/s1600/1.png)
將檔案屬性改成如下
[![](https://1.bp.blogspot.com/-5JmcyzIAZrY/V5rQUsVtfsI/AAAAAAAAHxs/Hzn8l6q895IZQ-qdmNZJjzopfvReaDfSwCLcB/s320/1.png)](https://1.bp.blogspot.com/-5JmcyzIAZrY/V5rQUsVtfsI/AAAAAAAAHxs/Hzn8l6q895IZQ-qdmNZJjzopfvReaDfSwCLcB/s1600/1.png)
將測試專案參考放LocalDB的Resource專案,基本上基礎的設置就大功告成了
[![](https://2.bp.blogspot.com/-J3w_UzNgjqI/V5rQv3jUagI/AAAAAAAAHxw/2ZLjd4ZnFlwKx1k4SWnmR42iD4t2NIgzgCLcB/s320/1.png)](https://2.bp.blogspot.com/-J3w_UzNgjqI/V5rQv3jUagI/AAAAAAAAHxw/2ZLjd4ZnFlwKx1k4SWnmR42iD4t2NIgzgCLcB/s1600/1.png)
[![](https://2.bp.blogspot.com/-W22EdOO54UQ/V5rQ4alyKlI/AAAAAAAAHx0/0OsWQ0t7roQiwlxBcctsrV8Nnwkb9J1JgCLcB/s640/1.png)](https://2.bp.blogspot.com/-W22EdOO54UQ/V5rQ4alyKlI/AAAAAAAAHx0/0OsWQ0t7roQiwlxBcctsrV8Nnwkb9J1JgCLcB/s1600/1.png)
[![](https://3.bp.blogspot.com/-0GrZTpNVuYA/V5rRUwzF5EI/AAAAAAAAHx8/EU3rhcWLSFo0HH0UsxyYuEI3czy_nJKWwCLcB/s320/1.png)](https://3.bp.blogspot.com/-0GrZTpNVuYA/V5rRUwzF5EI/AAAAAAAAHx8/EU3rhcWLSFo0HH0UsxyYuEI3czy_nJKWwCLcB/s1600/1.png)
下一篇接著寫Production Code,並針對那段Code去做測試

一般開發的時候通常會有測試機、正式機、或是Release前的機器…等,而在發佈到每台機器都要去做調整Config確實是很惱人的事情,感覺一個分神可能就會把測試機的連線貼到正式機之類的,這可能就是一場大災難。


還好Visual Studio很貼心的為WebConfig分出了Release版本跟Debug版本,並依據發佈時你所選擇的組態檔,去做對應的修改。(註:如果需要更多版本,請到建置 > 組態管理員去新增)。

[![](https://1.bp.blogspot.com/-bTABzkFPg84/V3yBI-o1a3I/AAAAAAAAHwE/Acz2x_EWkJMhQ6HTDpAVi3SuhLeuPiVCACLcB/s320/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://1.bp.blogspot.com/-bTABzkFPg84/V3yBI-o1a3I/AAAAAAAAHwE/Acz2x_EWkJMhQ6HTDpAVi3SuhLeuPiVCACLcB/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)
Release版本跟Debug版本
之前有寫篇文章[【擴充WebConfig】](http://toyo0103.blogspot.tw/2013/09/webconfigwebconfig.html)有提到如何在專案中加掛Config檔案,讓設定檔能做簡單的歸類整理。 原本以為Visual Studio應該會有相關的功能,可以對這類自訂的Config分割出對應版本,結果東找西找都找不到方法去做對應,原來VS似乎沒有提供這個功能(?) 還好在網路上找到了解決的套件跟方法 [How to add config transformations for a custom config file in Visual Studio?](http://stackoverflow.com/questions/34735132/how-to-add-config-transformations-for-a-custom-config-file-in-visual-studio)

簡單說就是先去下載VS擴充套件(註:目前似乎只支援到VS 2013)
SlowCheetah - XML Transforms

安裝完後,用VS 2013開啟專案,並且對著想要分割版本的Config檔案按右鍵,選擇Add Transfom即可

[![](https://4.bp.blogspot.com/-rH4cnE-93Gc/V3yDHDPxl2I/AAAAAAAAHwQ/RtNpbTiBOKYsVad-Vjb_McHswPnPJy_pwCLcB/s320/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)](https://4.bp.blogspot.com/-rH4cnE-93Gc/V3yDHDPxl2I/AAAAAAAAHwQ/RtNpbTiBOKYsVad-Vjb_McHswPnPJy_pwCLcB/s1600/%25E6%259C%25AA%25E5%2591%25BD%25E5%2590%258D.png)

搞定!!!! 之後再來寫篇如何透過Release Config檔在發佈時置換屬性值好了~

【SQL】Group by and Top 1
之前有寫一篇是如何將資料歸類完後,取得各個分類第一筆的文章,歲月如梭竟然已經是2013年的文章了。

最近碰到相同的問題,卻需要用Linq來解決,研究了一下後來把他順便筆記下來,狀況全部呈第一篇文章的資料結構

1
2
3
4
5
var WhatAreYouDoing = from t in this.timeline
group t by new {t.location, t.who }
into Group
select Group.OrderByDescending(x=>x.time).Take(1);

這樣就能達到一樣的效果了(騙文章數來著?)

有時候SQL搜尋出來的資料是直式的,但偏偏PM們常常想看的卻是橫式報表,狀況模擬如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
DECLARE @Report TABLE (
CompanyName varchar(8),
[Year] int,
[Month] int,
Revenue int)

insert @Report values('A公司',2016,1,100)
insert @Report values('A公司',2016,2,100)
insert @Report values('A公司',2016,3,100)

insert @Report values('B公司',2016,1,200)
insert @Report values('B公司',2016,2,200)
insert @Report values('B公司',2016,3,200)

insert @Report values('總公司',2016,1,300)
insert @Report values('總公司',2016,2,300)
insert @Report values('總公司',2016,3,300)

select * from @Report

[![](https://3.bp.blogspot.com/-qirx6weBsrU/Vt4qvqqJUMI/AAAAAAAAHus/HnoX8Epl2xY/s320/1.png)](https://3.bp.blogspot.com/-qirx6weBsrU/Vt4qvqqJUMI/AAAAAAAAHus/HnoX8Epl2xY/s1600/1.png)
這是資料在資料庫裡面的記錄方式
[![](https://4.bp.blogspot.com/-u18YG_Pkcro/Vt4rc9-XmnI/AAAAAAAAHuw/R12QCiukmio/s1600/1.png)](https://4.bp.blogspot.com/-u18YG_Pkcro/Vt4rc9-XmnI/AAAAAAAAHuw/R12QCiukmio/s1600/1.png)
PM想看到的

還好MS SQL 2005之後提供PIVOT來解決這種問題,實際SQL如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
select 
[date] as '年/月份',
[A公司] as 'A公司' ,[B公司] as 'B公司' ,([A公司] + [B公司] + [總公司]) as '總公司'
from(
select
CompanyName,
cast([Year] as char(4)) + '/' + cast([Month] as char(2)) as [date],
Revenue
from @Report
group by CompanyName,[Year],[Month],Revenue
) as TempReportTable
PIVOT
(
sum(Revenue)
for CompanyName in ([A公司] ,[B公司] ,[總公司])
)as PivotTable

[![](https://3.bp.blogspot.com/-iPNlxbN3F60/Vt4vAwWbJbI/AAAAAAAAHu8/UEDA-UufY6M/s320/1.png)](https://3.bp.blogspot.com/-iPNlxbN3F60/Vt4vAwWbJbI/AAAAAAAAHu8/UEDA-UufY6M/s1600/1.png)
算出結果

  1. 方法命名  :  受測的方法名稱Test_動作_預期結果

    這樣可以看標題名稱,馬上就能大略的明白此單元測試的受測者與目標是什麼,對於之後如果改版發生單元錯誤時,也才能明白該如何修復

    [![](https://3.bp.blogspot.com/-CaS4BNBA6a4/VtzcFkfagkI/AAAAAAAAHuQ/2mAf5riuHfk/s1600/1.png)](https://3.bp.blogspot.com/-CaS4BNBA6a4/VtzcFkfagkI/AAAAAAAAHuQ/2mAf5riuHfk/s1600/1.png)
    錯誤寫法 
     哪天因為改版造成這個單元測試錯誤了,要修復時非得看完全部的Code才能明白何謂"正確的狀況",對於維護來說是不利的
    [![](https://2.bp.blogspot.com/-B7ro_7k5cAo/VtzauPAEglI/AAAAAAAAHuE/KnntqnWuvpQ/s640/1.png)](https://2.bp.blogspot.com/-B7ro_7k5cAo/VtzauPAEglI/AAAAAAAAHuE/KnntqnWuvpQ/s1600/1.png)
    建議寫法
  2. 共用的參數應該有個父類別來管理
    有時候我們會為了測試某些情境,對於public static的東西進行設定,但也因為這是public static的變數,為了避免與其它單元測試交互影響,所以我們會在TestCleanUp的方法中做清除的動作

    [![](https://1.bp.blogspot.com/-t9BldDR6eJU/VtzepBta3lI/AAAAAAAAHug/Uv5g94SdikU/s640/1.png)](https://1.bp.blogspot.com/-t9BldDR6eJU/VtzepBta3lI/AAAAAAAAHug/Uv5g94SdikU/s1600/1.png)

    但這種寫法如果散落在各個單元測試的Class之中,對於維護也是一大挑戰,所以建議建立一個TestBase之類的父類別,讓所有單元測試的類別繼承它,並統一管理。
    總結,單元測試是為了讓程式更有品質,且在一些保護的基礎上進行改版,但如果單元測試的撰寫沒有一些有效的管理,對於後續的維護成本可能會比沒單元測試來的更高更難維護,如果單元測試寫的不清不楚,誰會知道這個測試到底要保護什麼呢?

將一個本來沒有單元測試的專案,改成能測試的狀態,通常第一個遇到的問題就是耦合太深,導致無法切開來模擬外部對象,大幅度改動又可能牽扯很多Method與Class,在沒有測試保護下,怕改到壞掉而不自知。

以下記錄第一種重構方式,讓測試能在安全的重構之下進行
範例Code:
原本的Legacy Code 
```csharp public class UnitTestSampleBase { public UnitTestSampleBase() { }
    public int WantUnitTestMethod()
    {
        //直接引用,導致無法隔離測試
        ThirdPartyObject tpObj = new ThirdPartyObject();

        int value =  tpObj.GetValue();

        return value + 1;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25


1. 分離耦合的部分

```csharp
public class UnitTestSampleBase
{
public UnitTestSampleBase() { }

protected virtual int GetValue()
{
//開一個virtual的Method,將外部引用隔離出來
ThirdPartyObject tpobj = new ThirdPartyObject();
return tpobj.GetValue();
}

public int WantUnitTestMethod()
{
//避免直接引用
int value = GetValue();

return value + 1;
}
}

  1. 做一個Fake的受測對象,並繼承受測得目標,並改寫取得外部資源的結果的Method,讓它回傳我們寫死的固定值 ```csharp
    public class UnitTestSampleFake : UnitTestSampleBase
    {
     protected override int GetValue()
     {
         return 1;
     }
    
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19


3. 進行測試 ```csharp
[TestClass]
public class UnitTestSampleFakeTest
{
[TestMethod]
public void WantUnitTestMethod_Call_ShouldRetuen2()
{
//arrange
var sut = new UnitTestSampleFake();
//act
var actual = sut.WantUnitTestMethod();
//assert
//1+1 = 2
Assert.AreEqual(actual, 2);
}
}

  1. 測試通過

這樣對於外部使用你的Mthod的人來說,他Code都不用改(因為你還是保有原本的Method
WantUnitTestMethod,且不需要額外的外部注入或參數注入),但你的Code已經做了初步的分離跟測試,之後就可以慢慢在這個測試保護的基礎之上,繼續後續的開發了!!

Regular Expression(後面簡稱RE)真的是一個非常難以閱讀的語言,從大學時期初次碰到,到現在當工程師已經5年多,常常碰到都還是要翻找書籍和嘗試很久才能寫出自己想要的東西,這邊就做點簡單的筆記避免之後又忘記了。

範例:
如果今天我有一段Log如下

=================================================
2016-02-25 00:05:50.9017 | INFO | TID:9 |

Request:
Method: GET, RequestUri: ‘http://regularexpression.com.tw/v1/1456329948291/500‘, Version: 1.1, Content: System.Web.Http.WebHost.HttpControllerHandler+LazyStreamContent, Headers:
{
  Connection: keep-alive
  Accept-Encoding: gzip
  Host: regularexpression.com.tw
  X-YC-IM-Device-OS: 2
  X-YC-IM-Token: Token
  X-Forwarded-For: xx.xxx.xxx.xxx
  X-Forwarded-Port: 443
  X-Forwarded-Proto: https
  Process-Start-Time: Time: 2016-02-25 00:05:50.183
  Process–End–Time: Time: 2016-02-25 00:05:50.901
  Process-Time-Count: Ticks: 7141131, 714 ms
  Content-Length: 0
}
=================================================

我想節錄出紅字的部分,我自己解法如下

  1. 首先因為RE具有將斷行視為一個完整句子的特性,所以要跨行搜尋變的很困難,所以我先會將斷行符號換成自己定義的連結符號,好讓整段Log可以變成一個連貫的字串 ```csharp
    content.Replace(Environment.NewLine,”<^^>”);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32

    2. 接著RE Pattern如下
    **{% note info %}
    /Method:\s(?<mehtod>[^,]*),\sRequestUri:\s'(?<uri>[^']*)',.*?X-YC-IM-Token:\s(?<token>[^<]*)<\^\^>.*?Process-Time-Count:\sTicks:\s[0-9]+,\s(?<processTime>[^<]*)<\^\^>/
    {% endnote %}
    **

    **@解析**Method:\s(?<mehtod>**<span style="color: red;">[^,]</span>***),**
    **\s為空白字元,所以等於從Method: 開始到下一個不是**<span style="color: red;">逗號</span>**的字元抓出來儲存到method這個參數之中

    @**解析**\sRequestUri:\s'(?<uri>**<span style="color: red;">[^']</span>***)',
    從 RequestUri: '開始到下一個不是&nbsp;<span style="color: red;">'&nbsp;</span>的字元抓出來儲存到uri這個參數之中

    @**解析.*?X-YC-IM-Token:\s(?<token><span style="color: red;">[^<]</span>*)<\^\^>**
    之後接著N個字元直到出現**X-YC-IM-Token: **將**&nbsp;之後抓取N個字元直到出現****<span style="color: red;"><</span>為止,**儲存到token。這邊特別提一下這段最前面的.*?,如果我們的Log只有這麼一段沒有太大問題,如果我們的Log是重複上面的格式出現很多串,如果單純只下**.*X-YC-IM-Token也就是少了**<span style="color: red;">問號</span>,那他就會一直抓且跨很多段Log直到最後一個**X-YC-IM-Token**出現為止。

    ?號有與沒有差別在於貪婪搜尋,有問號表示非貪婪模式,搜尋到第一個符合的即停
    3. 後面的符號都出現過也一直重複,所以就不另外說明,在.NET中要取出剛剛用RE儲存的參數寫法如下
    ```csharp
    content = content.Replace(Environment.NewLine,"<^^>");
    string pattern = @"Method:\s(?<mehtod>[^,]*),\sRequestUri:\s'(?<uri>[^']*)',.*?X-YC-IM-Token:\s(?<token>[^<]*)<\^\^>.*?Process-Time-Count:\sTicks:\s[0-9]+,\s(?<processTime>[^<]*)<\^\^>";

    Regex reg = new Regex(pattern, RegexOptions.IgnoreCase);

    MatchCollection matches = reg.Matches(content);
    foreach (Match match in matches)
    {
    Console.WriteLine(match.Groups["mehtod"].Value);
    Console.WriteLine(match.Groups["uri"].Value);
    Console.WriteLine(match.Groups["token"].Value);
    Console.WriteLine(match.Groups["processTime"].Value);}

以下附錄來源 : 就是愛程式 - 正規表示式 Regular Expression

正規表示式說明及範例比對不成立之字串
/a/含字母 “a” 的字串,例如 “ab”, “bac”, “cba”“xyz”
/a./含字母 “a” 以及其後任一個字元的字串,例如 “ab”, “bac”(若要比對.,請使用 \.)“a”, “ba”
/^xy/以 “xy” 開始的字串,例如 “xyz”, “xyab”(若要比對 ^,請使用 \^)“axy”, “bxy”
/xy$/以 “xy” 結尾的字串,例如 “axy”, “abxy”以 “xy” 結尾的字串,例如 “axy”, “abxy” (若要比對 $,請使用 \$)“xya”, “xyb”
[13579]包含 “1” 或 “3” 或 “5” 或 “7” 或 “9” 的字串,例如:”a3b”, “1xy”“y2k”
[0-9]含數字之字串不含數字之字串
[a-z0-9]含數字或小寫字母之字串不含數字及小寫字母之字串
[a-zA-Z0-9]含數字或字母之字串不含數字及字母之字串
b[aeiou]t“bat”, “bet”, “bit”, “bot”, “but”“bxt”, “bzt”
[^0-9]不含數字之字串(若要比對 ^,請使用 \^)含數字之字串
[^aeiouAEIOU]不含母音之字串(若要比對 ^,請使用 \^)含母音之字串
[^\^]不含 “^” 之字串,例如 “xyz”, “abc”“xy^”, “a^bc”
.
正規表示式的特定字元說明等效的正規表示式
\d數字[0-9]
\D非數字[^0-9]
\w數字、字母、底線[a-zA-Z0-9_]
\W非 \w[^a-zA-Z0-9_]
\s空白字元[ \r\t\n\f]
\S非空白字元[^ \r\t\n\f]
.
正規表示式說明
/a?/零或一個 a(若要比對? 字元,請使用 \?)
/a+/一或多個 a(若要比對+ 字元,請使用 \+)
/a*/零或多個 a(若要比對* 字元,請使用 \*)
/a{4}/四個 a
/a{5,10}/五至十個 a
/a{5,}/至少五個 a
/a{,3}/至多三個 a
/a.{5}b/a 和 b中間夾五個(非換行)字元
.
字元說明簡單範例
\避開特殊字元/A\*/ 可用於比對 “A*”,其中 * 是一個特殊字元,為避開其特殊意義,所以必須加上 “\”
^比對輸入列的啟始位置/^A/ 可比對 “Abcd” 中的 “A”,但不可比對 “aAb”
$比對輸入列的結束位置/A$/ 可比對 “bcdA” 中的 “A”,但不可比對 “aAb”
*比對前一個字元零次或更多次/bo*/ 可比對 “Good boook” 中的 “booo”,亦可比對 “Good bk” 中的 “b”
+比對前一個字元一次或更多次,等效於 {1,}/a+/ 可比對 “caaandy” 中的 “aaa”,但不可比對 “cndy”
?比對前一個字元零次或一次/e?l/ 可比對 “angel” 中的 “el”,也可以比對 “angle” 中的 “l”
.比對任何一個字元(但換行符號不算)/.n/ 可比對 “nay, an apple is on the tree” 中的 “an” 和 “on”,但不可比對 “nay”
(x)比對 x 並將符合的部分存入一個變數/(a*) and (b*)/ 可比對 “aaa and bb” 中的 “aaa” 和 “bb”,並將這兩個比對得到的字串設定至變數 RegExp.$1 和 RegExp.$2。
xy比對 x 或 y/a*b*/g 可比對 “aaa and bb” 中的 “aaa” 和 “bb”
{n}比對前一個字元 n 次,n 為一個正整數/a{3}/ 可比對 “lllaaalaa” 其中的 “aaa”,但不可比對 “aa”
{n,}比對前一個字元至少 n 次,n 為一個正整數/a{3,}/ 可比對 “aa aaa aaaa” 其中的 “aaa” 及 “aaaa”,但不可比對 “aa”
{n,m}比對前一個字元至少 n 次,至多 m 次,m、n 均為正整數/a{3,4}/ 可比對 “aa aaa aaaa aaaaa” 其中的 “aaa” 及 “aaaa”,但不可比對 “aa” 及 “aaaaa”
[xyz]比對中括弧內的任一個字元/[ecm]/ 可比對 “welcome” 中的 “e” 或 “c” 或 “m”
[^xyz]比對不在中括弧內出現的任一個字元/[^ecm]/ 可比對 “welcome” 中的 “w”、”l”、”o”,可見出其與 [xyz] 功能相反。(同時請注意 /^/ 與 [^] 之間功能的不同。)
[\b]比對退位字元(Backspace character)可以比對一個 backspace ,也請注意 [\b] 與 \b 之間的差別
\b比對英文字的邊界,例如空格例如 /\bn\w/ 可以比對 “noonday” 中的 ‘no’ ; /\wy\b/ 可比對 “possibly yesterday.” 中的 ‘ly’
\B比對非「英文字的邊界」例如, /\w\Bn/ 可以比對 “noonday” 中的 ‘on’ , 另外 /y\B\w/ 可以比對 “possibly yesterday.” 中的 ‘ye’
\cX比對控制字元(Control character),其中 X 是一個控制字元/\cM/ 可以比對 一個字串中的 control-M
\d比對任一個數字,等效於 [0-9]/[\d]/ 可比對 由 “0” 至 “9” 的任一數字 但其餘如字母等就不可比對
\D比對任一個非數字,等效於 [^0-9]/[\D]/ 可比對 “w” “a”… 但不可比對如 “7” “1” 等數字
\f比對 form-feed若是在文字中有發生 “換頁” 的行為 則可以比對成功
\n比對換行符號若是在文字中有發生 “換行” 的行為 則可以比對成功
\r比對 carriage return
\s比對任一個空白字元(White space character),等效於 [ \f\n\r\t\v]/\s\w*/ 可比對 “A b” 中的 “b”
\S比對任一個非空白字元,等效於 [^ \f\n\r\t\v]/\S/\w* 可比對 “A b” 中的 “A”
\t比對定位字元(Tab)
\v比對垂直定位字元(Vertical tab)
\w比對數字字母字元(Alphanumerical characters)或底線字母(”_”),等效於 [A-Za-z0-9_]/\w/ 可比對 “.A _!9” 中的 “A”、”_”、”9〃。
\W比對非「數字字母字元或底線字母」,等效於 [^A-Za-z0-9_]/\W/ 可比對 “.A _!9” 中的 “.”、” “、”!”,可見其功能與 /\w/ 恰好相反。
\o_octal_比對八進位,其中_octal是八進位數目_/\oocetal123/ 可比對 與 八進位的ASCII中 “123” 所相對應的字元值。
\x_hex_比對十六進位,其中_hex是十六進位數目_/\xhex38/ 可比對 與 16進位的ASCII中 “38” 所相對應的字元。

好用的Regular expression測試網站 :  http://www.rubular.com/
延伸閱讀 : 貪婪與非貪婪效能問題