在 ASP.NET Web API 2 基礎下,建立整合完備的應用程式專案範本。

  • 整合套件或自行開發來提供 RESTFul API 最佳實踐 中的建議功能。
  • 配合 開發規範 提供一致有效率的開發方式及程式品質。

這篇提供一些自行開發的程式範例,以達到對 RESTFul 最佳實踐的支援。

大標

內文
在 ASP.NET Web API 2 基礎下,建立整合完備的應用程式專案範本。

  • 整合套件或自行開發來提供 RESTFul API 最佳實踐 中的建議功能。
  • 配合 開發規範 提供一致有效率的開發方式及程式品質。

這篇提供一些自行開發的程式範例,以達到對 RESTFul 最佳實踐的支援。

版本資訊

視需求在 Route 中加入主版本號。

以 Owin Middleware 實作 Versioning Header

版本資訊,可用自動取得的 Assembly File Version 來表示,並保存起來,避免一直重覆讀取。進階上,可以配合參數,傳入指定的版本資訊或類型,提供更彈性的功能。

確認在 Web API 中啟用 Owin,並加入啟動類別。

類別 VersioningMiddleware

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
/// <summary>
/// 於 Response Header 中自動以 Assembly file version 提供 Api Version
/// </summary>
public class VersioningMiddleware
{
// 簡單保存在 static 中
private static string ApiVersion;
//
private const string HeaderName = "Api-Version";
//
private AppFunc next;

/// <param name="next"></param>
public VersioningMiddleware(AppFunc next)
{
this.next = next;
}

/// <param name="env"></param>
/// <returns></returns>
public async Task Invoke(IDictionary<string, object> env)
{
var ctx = new OwinContext(env);

var apiVersion = GetVersion();

ctx.Response.Headers.Add(HeaderName, new string[] { apiVersion });

await this.next(env);
}

private string GetVersion()
{
if(ApiVersion == null)
{
ApiVersion = GetVersionFromAssembly();
}

return ApiVersion;
}

private string GetVersionFromAssembly()
{
var fileVersion = FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location);
// Major.Minor.Build
return string.Format("{0}.{1}.{2}", fileVersion.ProductMajorPart, fileVersion.ProductMinorPart, fileVersion.ProductBuildPart);
}
}

注意,取到的是哪一個 Assembly 會影響拿到的版本內容。Assembly.GetExecutingAssembly() 是取 Middleware 實作的這個 Assembly。如果 Middleware 實作在另外的專案中,完整範例 中有提供自動找到繼承 ApiController 的 Assembly 方法。

類別 VersioningMiddlewareExtensions

1
2
3
4
5
6
7
8
9
10
11
/// <summary>
/// 於 Response Header 中自動以 Assembly file version 提供 Api Version
/// </summary>
public static class VersioningMiddlewareExtensions
{
/// <param name="app"></param>
public static void UseVersioningMiddleware(this IAppBuilder app)
{
app.Use<VersioningMiddleware>();
}
}

在啟動類別中啟用

1
app.UseVersioningMiddleware();

套用 snake_case

網址參數轉換

自行實作 ActionSelector,原理就是對 QueryString 做轉換和重寫。

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
public class SnakeCaseActionSelector : ApiControllerActionSelector
{
public override HttpActionDescriptor SelectAction(HttpControllerContext controllerContext)
{
var newUri = CreateNewUri(
controllerContext.Request.RequestUri,
controllerContext.Request.GetQueryNameValuePairs());

controllerContext.Request.RequestUri = newUri;

return base.SelectAction(controllerContext);
}

private Uri CreateNewUri(Uri requestUri, IEnumerable<KeyValuePair<string, string>> queryPairs)
{
var currentQuery = requestUri.Query;

if (!currentQuery.Any())
{
return requestUri;
}

var newQuery = ConvertQueryToCamelCase(queryPairs);
return new Uri(requestUri.ToString().Replace(currentQuery, newQuery));
}

private static string ConvertQueryToCamelCase(IEnumerable<KeyValuePair<string, string>> queryPairs)
{
queryPairs = queryPairs
.Select(x => new KeyValuePair<string, string>(x.Key.ToCamelCase(), x.Value));

return "?" + queryPairs
.Select(x => String.Format("{0}={1}", x.Key, x.Value))
.Aggregate((x, y) => x + "&" + y);
}

private static string ToCamelCase(string source)
{
var parts = source
.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries);

return parts
.First().ToLower() +
string.Join("", parts.Skip(1).Select(ToCapital));
}

private static string ToCapital(string source)
{
return string.Format("{0}{1}", char.ToUpper(source[0]), source.Substring(1).ToLower());
}
}

最後加入 WebApiConfig

1
config.Services.Replace(typeof(IHttpActionSelector), new SnakeCaseActionSelector());

分頁

OData 有支援分頁

http://www.c-sharpcorner.com/article/paging-with-odata-and-Asp-Net-web-api/

分頁資訊: 提供頁碼及連結 Uri

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
79
80
81
82
83
84
85
86
public class PageDataBuilder
{
public Uri FirstPage { get; private set; }
public Uri LastPage { get; private set; }
public Uri NextPage { get; private set; }
public Uri PreviousPage { get; private set; }
public bool HasLink { get; set; }
//
public int PageNo { get; private set; }
public int PageSize { get; private set; }
public int TotalRecordCount { get; private set; }
public int PageCount { get; private set; }

/// <summary>
/// constructor
/// </summary>
/// <param name="pageNo">需求的頁碼</param>
/// <param name="pageSize">每頁的筆數</param>
/// <param name="totalRecordCount">資料總筆數</param>
/// <param name="requestPath">服務路徑 ex:Request.RequestUri.GetLeftPart(UriPartial.Path), 沒傳就不產生 Link</param>
/// <param name="routeValues">Link 的 Url 參數, 匿名物件</param>
public PageDataBuilder(int pageNo,
int pageSize,
int totalRecordCount,
string requestPath = null,
object routeValues = null)
{
// Determine total number of pages
var pageCount = totalRecordCount > 0
? (int)Math.Ceiling(totalRecordCount / (double)pageSize)
: 0;

PageNo = pageNo;
PageSize = pageSize;
PageCount = pageCount;
TotalRecordCount = totalRecordCount;

// create page links,有 requestPath 才產生
if (!string.IsNullOrEmpty(requestPath))
{
HasLink = true;

FirstPage = BuildUri(requestPath, new HttpRouteValueDictionary(routeValues)
{
{"pageNo", 1},
{"pageSize", pageSize}
});

LastPage = BuildUri(requestPath, new HttpRouteValueDictionary(routeValues)
{
{"pageNo", pageCount},
{"pageSize", pageSize}
});

if (pageNo > 1)
{
PreviousPage = BuildUri(requestPath, new HttpRouteValueDictionary(routeValues)
{
{"pageNo", pageNo - 1},
{"pageSize", pageSize}
});
}

if (pageNo < pageCount)
{
NextPage = BuildUri(requestPath, new HttpRouteValueDictionary(routeValues)
{
{"pageNo", pageNo + 1},
{"pageSize", pageSize}
});
}
}
}

private Uri BuildUri(string path, HttpRouteValueDictionary routeValues)
{
return new Uri(path + "?" + routeValues
.Select(x => string.Format("{0}={1}", ToSnakeCase(x.Key), x.Value))
.Aggregate((x, y) => x + "&" + y));
}

private string ToSnakeCase(string camelCase)
{
return string.Concat(camelCase.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + x.ToString() : x.ToString())).ToLower();
}
}

在 Response 中設定分頁相關 Header

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
79
public class PagingHeader
{
private const string PageNoHeaderName = "X-Paging-PageNo";
private const string PageSizeHeaderName = "X-Paging-PageSize";
private const string PageCountHeaderName = "X-Paging-PageCount";
private const string TotalRecordCountName = "X-Paging-TotalRecordCount";
private const string LinkHeaderName = "Link";
private const string LinkHeaderTemplate = "<{0}>; rel=\"{1}\"";
//
private HttpResponseMessage response;

public PagingHeader(HttpResponseMessage responseMessage)
{
this.response = responseMessage;
}

public PagingHeader AddPageNo(int pageNo)
{
response.Headers.Add(PageNoHeaderName, pageNo.ToString());

return this;
}

public PagingHeader AddPageSize(int pageSize)
{
response.Headers.Add(PageSizeHeaderName, pageSize.ToString());

return this;
}

public PagingHeader AddPageCount(int pageCount)
{
response.Headers.Add(PageCountHeaderName, pageCount.ToString());

return this;
}

public PagingHeader AddTotalRecordCount(int totalCount)
{
response.Headers.Add(TotalRecordCountName, totalCount.ToString());

return this;
}

public PagingHeader AddLink(PageDataBuilder dataBuilder)
{
if(!dataBuilder.HasLink)
{
return this;
}

List<string> links = new List<string>();

if (dataBuilder.FirstPage != null)
{
links.Add(string.Format(LinkHeaderTemplate, dataBuilder.FirstPage, "first"));
}

if (dataBuilder.PreviousPage != null)
{
links.Add(string.Format(LinkHeaderTemplate, dataBuilder.PreviousPage, "previous"));
}

if (dataBuilder.NextPage != null)
{
links.Add(string.Format(LinkHeaderTemplate, dataBuilder.NextPage, "next"));
}

if (dataBuilder.LastPage != null)
{
links.Add(string.Format(LinkHeaderTemplate, dataBuilder.LastPage, "last"));
}

// Set the page link header
response.Headers.Add(LinkHeaderName, string.Join(", ", links));

return this;
}
}

產生有分頁資訊的 Result

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
public class PagedResult<T>
{
private IEnumerable<T> pagedRows;
private PageDataBuilder dataBuilder;

/// <summary>
/// 用 totalRows 取得總筆數,做分頁後再附加分頁資訊
/// </summary>
/// <param name="totalRows">全部的資料列</param>
/// <param name="pageNo">需求的頁碼</param>
/// <param name="pageSize">每頁的筆數</param>
/// <param name="requestPath">服務路徑 ex:Request.RequestUri.GetLeftPart(UriPartial.Path), 沒傳就不產生 Link</param>
/// <param name="routeValues">Link 的 Url 參數, 匿名物件</param>
public PagedResult(IEnumerable<T> totalRows,
int pageNo,
int pageSize,
string requestPath = null,
object routeValues = null)
{
this.dataBuilder = new PageDataBuilder(pageNo, pageSize, totalRows.Count(), requestPath, routeValues);
//
var skipAmount = pageSize * (pageNo - 1);
this.pagedRows = totalRows.Skip(skipAmount).Take(pageSize);
}

/// <summary>
/// 使用者已分好頁,只附加分頁資訊
/// </summary>
/// <param name="requestPath">服務路徑 ex:Request.RequestUri.GetLeftPart(UriPartial.Path)</param>
/// <param name="routeValues">額外的 Url 參數</param>
/// <param name="pageRows">要回傳的單頁資料</param>
/// <param name="pageNo">需求的頁碼</param>
/// <param name="pageSize">每頁的筆數</param>
/// <param name="totalRecordCount">資料總筆數</param>
/// <param name="requestPath">服務路徑 ex:Request.RequestUri.GetLeftPart(UriPartial.Path), 沒傳就不產生 Link</param>
/// <param name="routeValues">Link 的 Url 參數, 匿名物件</param>
public PagedResult(IEnumerable<T> pageRows,
int pageNo,
int pageSize,
int totalRecordCount,
string requestPath = null,
object routeValues = null)
{
this.dataBuilder = new PageDataBuilder(pageNo, pageSize, totalRecordCount, requestPath, routeValues);
this.pagedRows = pageRows;
}

public ResultObject<T> CreateResult()
{
var result = new ResultObject<T>
{
Data = pagedRows,
Paging = new Paging
{
PageNo = dataBuilder.PageNo,
PageCount = dataBuilder.PageCount,
PageSize = dataBuilder.PageSize,
TotalRecordCount = dataBuilder.TotalRecordCount
}
};

if(dataBuilder.HasLink)
{
result.Paging.FirstPage = dataBuilder.FirstPage;
result.Paging.LastPage = dataBuilder.LastPage;
result.Paging.PreviousPage = dataBuilder.PreviousPage;
result.Paging.NextPage = dataBuilder.NextPage;
}

return result;
}

/// <summary>
/// 單取得分頁資料
/// </summary>
/// <returns></returns>
public PageDataBuilder GetPageDataBuilder()
{
return dataBuilder;
}
}

public class ResultObject<T>
{
public ResultObject()
{
Paging = new Paging();
}

public IEnumerable<T> Data { get; set; }
//
public Paging Paging { get; set; }
}

public class Paging
{
public int PageNo { get; set; }
public int PageSize { get; set; }
public int PageCount { get; set; }
public int TotalRecordCount { get; set; }
public Uri FirstPage { get; set; }
public Uri LastPage { get; set; }
public Uri PreviousPage { get; set; }
public Uri NextPage { get; set; }
}

用法範例:

產生分頁 header (X-Paging-*)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 建立不產生連結的分頁資訊
var dataBuilder = new PageDataBuilder(1, 10, 1000);

// 建立 HttpResponseMessage
var responseMessage = Request.CreateResponse(HttpStatusCode.OK);

// 加入分頁資訊 header 到 responseMessage
new PagingHeader(responseMessage)
.AddPageNo(dataBuilder.PageNo)
.AddPageSize(dataBuilder.PageSize)
.AddPageCount(dataBuilder.PageCount)
.AddTotalRecordCount(dataBuilder.TotalRecordCount);

return ResponseMessage(responseMessage);

產生分頁 header 及 Link header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 建立產生連結的分頁資訊 (有 requestPath 就會產生 Link)
var dataBuilder = new PageDataBuilder(1, 10, 1000, requestPath);

// 建立 HttpResponseMessage
var responseMessage = Request.CreateResponse(HttpStatusCode.OK);

// 加入分頁資訊 header 到 responseMessage
new PagingHeader(responseMessage)
.AddPageNo(dataBuilder.PageNo)
.AddPageSize(dataBuilder.PageSize)
.AddPageCount(dataBuilder.PageCount)
.AddTotalRecordCount(dataBuilder.TotalRecordCount)
.AddLink(dataBuilder);

return ResponseMessage(responseMessage);

產生分頁結果

1
2
3
4
5
6
7
8
// 建立分頁結果
var pagedResult = new PagedResult<Member>(members, pageNo, pageSize, requestPath);
// 有需要,可取得分頁資訊,做為輸出 header 用
var dataBuilder = pagedResult.GetPageDataBuilder();
// 在 Response 中加入 Result
var responseMessage = Request.CreateResponse(HttpStatusCode.OK, pagedResult.CreateResult());

return ResponseMessage(responseMessage);

輸出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"data": [
{
"num": 1,
"name": "MEM1"
},
{
"num": 2,
"name": "MEM2"
}
],
"paging": {
"page_no": 1,
"page_size": 2,
"page_count": 3,
"total_record_count": 5,
"first_page": "http://localhost:64568/api/members?page_no=1&page_size=2",
"last_page": "http://localhost:64568/api/members?page_no=3&page_size=2",
"next_page": "http://localhost:64568/api/members?page_no=2&page_size=2"
}
}

完整內容,可參考 GitHub 上的專案

系列文章