寫給新手的WebAPI實踐
此篇是寫給新手的Demo,用於參考和借鑑,用於發散思路。老鳥可以忽略了。
自己經常有這種情況,遇到一個新東西或難題,在瞭解和解決之前總是說“等搞定了一定要寫篇文章記錄下來”,但是當掌握了之後,就感覺好簡單呀不值得寫下來了。其實這篇也一樣,決定寫下來是想在春節前最後再幹一件正經事兒!
目錄:
一、請求響應的設計
RESTFul風格響亮很久了,但是我沒用過,以後也不打算用。當系統稍微複雜時,為了符合RESTFul要吃力地建立一些不直觀的名詞,這不是我的風格。所以此文設計的不是RESTFul風格,是最常用的POST和GET請求。
請求部分就是呼叫API的引數,抽象出一個介面如下:
public interface IRequest { ResultObject Validate(); }
這裡面只定義了一個Validate()方法,用於驗證請求引數的有效性,返回值是響應裡的東西,下面會講到。
對於請求物件,傳遞到業務邏輯層,甚至是資料訪問層都可以,因為它本身就是用來傳輸資料的,俗話叫DTO(Data Transfer Object),不過定義多層傳輸物件,然後複製來複制去也是可以的~。但是有時候業務處理會需要當前登入人的資訊,而這個資訊我並不希望直接從介面層向下傳遞,所以這裡我再抽象一個UserRequestBase,用於附加登入人相關資訊:
public abstract class UserRequestBase : IRequest { public int ApiUserID { get; set; } public string ApiUserName { get; set; } // ......可以新增其他要專遞的登入使用者相關的資訊 public abstract ResultObject Validate(); }
ApiUserID和ApiUserName這樣的欄位是不需要客戶端傳遞的,我們會根據登入人資訊自動填充。
根據實際中的經驗,我們往往會做分頁查詢,會用到頁碼和每頁條數,所為我們再定義個PageRequestBase:
public abstract class PageRequestBase : UserRequestBase { public int PageIndex { get; set; } public int PageSize { get; set; } }
因為.net只能繼承單個父類,而且有些分頁查詢可能需要使用者資訊,所以我們選擇繼承UserRequestBase。
當然,還可以根據自己的實際情況抽象出更多的公用類,在這不一一列舉。
響應的設計分為兩部分,第一個是實際響應部分,第二個會把響應包裝一下,加上code和msg,用於表示呼叫狀態和錯誤資訊(好老的方法了,大家都懂)。
響應介面IResponse裡什麼也沒有,就是一個標記介面,不過我們也可以抽象出來兩個常用的公用類用於響應列表和分頁資料:
public class ListResponseBase<T> : IResponse { public List<T> List { get; set; } } public class PageResponseBase<T>: ListResponseBase<T> { /// <summary> /// 頁碼數 /// </summary> public int PageIndex { get; set; } /// <summary> /// 總條數 /// </summary> public long TotalCount { get; set; } /// <summary> /// 每頁條數 /// </summary> public int PageSize { get; set; } /// <summary> /// 總頁數 /// </summary> public long PageCount { get; set; } }
包裝響應的時候,有兩種情況,第一種是操作類介面,比如新增商品,這些介面是不用響應物件的,只要返回是否成功就行了,第二種查詢類,這個時候必須要返回一些具體的東西了,所以響應包裝設計成兩個類:
public class ResultObject { /// <summary> /// 等於0表示成功 /// </summary> public int Code { get; set; } /// <summary> /// code不為0時,返回錯誤訊息 /// </summary> public string Msg { get; set; } } public class ResultObject<TResponse> : ResultObject where TResponse : IResponse { public ResultObject() { } public ResultObject(TResponse data) { Data = data; } /// <summary> /// 返回的資料 /// </summary> public TResponse Data { get; set; } }
IRequest介面的Validate()方法返回值就是第一個ResultObject,當請求引數驗證不通過的時候,肯定是沒有資料返回了。
在業務邏輯層,我選擇以包裝類作為返回型別,因為有很多錯誤都會在業務邏輯層出現,我們的介面是需要這些錯誤資訊的。
二、請求的Content-Type和模型繫結
現在前後端分離大行其道,我們做後端的通常會返回JSON格式給前端,響應的Content-Type為application/json,前端通過一些框架可以直接作為js物件使用。但是前端請求後端的時候還有很多是以form表單形式,也就是請求的Content-Type為:application/x-www-form-urlencoded,請求體為id=23&name=loogn這樣的字串,如果資料格式複雜了,前端不好傳,後端解析起來也麻煩。還有的直接用一個固定引數傳遞json字串,比如json={id:23,name:'loogn'},後端用form[‘json’]取出來後再反序列化。這些方法都可以,但是不夠好,最好的方法是前端也直接傳json,幸好現在很多web伺服器都是支援請求的Content-Type為application/json的,這個時候請求的引數會以有效負荷(Payload)的形式傳遞過去,比如用jQuery的ajax來請求:
$.ajax({ type: "POST", url: "/product/editProduct", contentType: "application/json; charset=utf-8", data: JSON.stringify({id:1,name:"name1"}), success: function (result) { console.log(result); } })
除了contentType,還要注意使用了JSON.stringify把物件轉換成了字串。其實ajax使用的XmlHttpRequest物件只能處理字串(json字串呀,xml字串呀,text純文字呀,base64呀)。這些資料到了後端之後,從請求流裡讀出來就是json形式的字串了,可直接反序列化成後端物件。
然而這些考慮,.net mvc框架已經幫我們做好了,這都要歸功於DefaultModelBinder。
我這裡想說的是,DefaultModelBinder足夠智慧,並不需要我們自己做什麼,它會根據請求的contentType的不同,用不同的方式解析請求,然後繫結到物件,遇到contentType為application/json時,就直接反序列化得到物件,遇到application/x-www-form-urlencoded就用form表單的形式繫結物件,唯一要注意的就是前端同學,不要把請求的contentType和請求的實際內容搞錯就行了。你告訴我你送過來一隻貓,而實際上是一隻狗,我以對待貓的方式對待狗當然就有被咬一口的危險了(肯定會報錯)。
三、自定義ApiResult和ApiControllerBase
因為我不需要RESTFul風格,也不需要根據客戶端的意願返回json或xml,所以我選擇AsyncController作為控制器的基類。AsyncController是直接繼承Controller的,而且支援非同步處理,具體Controller和ApiController的區別,想了解的同學可以看這篇文章difference-between-apicontroller-and-controller-in-asp-net-mvc ,或者直接閱讀原始碼。
Controller裡的Action需要返回一個ActionResult物件,結合上面的響應包裝物件ResultObject,我決定自定義一個ApiResult作為Action的返回值,同時在這裡處理jsonp呼叫、跨域呼叫、序列化的小駝峰命名和時間格式問題。
/// <summary> /// api返回結果,控制jsonp、跨域、小駝峰命名和時間格式問題 /// </summary> public class ApiResult : ActionResult { /// <summary> /// 返回資料 /// </summary> public ResultObject ResultData { get; set; } /// <summary> /// 返回資料編碼,預設utf8 /// </summary> public Encoding ContentEncoding { get; set; } /// <summary> /// 是否接受Get請求,預設允許 /// </summary> public JsonRequestBehavior JsonRequestBehavior { get; set; } /// <summary> /// 是否允許跨域請求 /// </summary> public bool AllowCrossDomain { get; set; } /// <summary> /// jsonp回撥引數名 /// </summary> public string JsonpCallbackName = "callback"; public ApiResult() : this(null) { } public ApiResult(ResultObject resultData) { this.ResultData = resultData; ContentEncoding = Encoding.UTF8; JsonRequestBehavior = JsonRequestBehavior.AllowGet; AllowCrossDomain = true; } public override void ExecuteResult(ControllerContext context) { var response = context.HttpContext.Response; var request = context.HttpContext.Request; response.ContentEncoding = ContentEncoding; response.ContentType = "text/plain"; if (ResultData != null) { string buffer; if ((JsonRequestBehavior == JsonRequestBehavior.DenyGet) && string.Equals(context.HttpContext.Request.HttpMethod, "GET")) { buffer = "該介面不允許Get請求"; } else { var jsonpCallback = request[JsonpCallbackName]; if (string.IsNullOrWhiteSpace(jsonpCallback)) { //如果可以跨域,寫入響應頭 if (AllowCrossDomain) { WriteAllowAccessOrigin(context); } response.ContentType = "application/json"; buffer = JsonConvert.SerializeObject(ResultData, JsonSetting.Settings); } else { //jsonp if (AllowCrossDomain) //這個判斷可能非必須 { response.ContentType = "text/javascript"; buffer = string.Format("{0}({1});", jsonpCallback, JsonConvert.SerializeObject(ResultData, JsonSetting.Settings)); } else { buffer = "該介面不允許跨域請求"; } } } try { response.Write(buffer); } catch (Exception exp) { response.Write(exp.Message); } } else { response.Write("ApiResult.Data為null"); } response.End(); } /// <summary> /// 寫入跨域請求頭 /// </summary> /// <param name="context"></param> private void WriteAllowAccessOrigin(ControllerContext context) { var origin = context.HttpContext.Request.Headers["Origin"]; if (true) //可以維護一個允許跨域的域名集合,類判斷是否可以跨域 { context.HttpContext.Response.Headers.Add("Access-Control-Allow-Origin", origin ?? "*"); } } }
裡面都是一些常規的邏輯,不做說明了,其中的JsonSetting就是設定序列化的小駝峰和日期格式的:
public class JsonSetting { public static JsonSerializerSettings Settings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver(), DateFormatString = "yyyy-MM-dd HH:mm:ss", }; }
這個時候有個問題,如果一個時間欄位需要"yyyy-MM-dd"這種格式怎麼辦呢?這個時候要定義一個JsonConverter的子類,來實現自定義日期格式:
/// <summary> /// 日期格式化器 /// </summary> public class CustomDateConverter : DateTimeConverterBase { private IsoDateTimeConverter dtConverter = new IsoDateTimeConverter { }; public CustomDateConverter(string format) { dtConverter.DateTimeFormat = format; } public CustomDateConverter() : this("yyyy-MM-dd") { } public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { return dtConverter.ReadJson(reader, objectType, existingValue, serializer); } public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { dtConverter.WriteJson(writer, value, serializer); } }
在需要的響應屬性上加上 [JsonConverter(typeof(CustomDateConverter))] 或 [JsonConverter(typeof(CustomDateConverter),"yyyy年MM月dd日")] 即可。
ApiResult定義好了,再定義一個控制器基類,目的是便於處理ApiResult:
/// <summary> /// API控制器基類 /// </summary> public class ApiControllerBase : AsyncController { public ApiResult Api<TRequest>(TRequest request, Func<TRequest, ResultObject> handle) { try { var requestBase = request as IRequest; if (requestBase != null) { //處理需要登入使用者的請求 var userRequest = request as UserRequestBase; if (userRequest != null) { var loginUser = LoginUser.GetUser(); if (loginUser != null) { userRequest.ApiUserID = loginUser.UserID; userRequest.ApiUserName = loginUser.UserName; } } var validResult = requestBase.Validate(); if (validResult != null) { return new ApiResult(validResult); } } var result = handle(request); //處理請求 return new ApiResult(result); } catch (Exception exp) { //異常日誌: return new ApiResult { ResultData = new ResultObject { Code = 1, Msg = "系統異常:" + exp.Message } }; } } public ApiResult Api(Func<ResultObject> handle) { try { var result = handle();//處理請求 return new ApiResult(result); } catch (Exception exp) { //異常日誌 return new ApiResult { ResultData = new ResultObject { Code = 1, Msg = "系統異常:" + exp.Message } }; } } /// <summary> /// 非同步api /// </summary> /// <typeparam name="TRequest"></typeparam> /// <param name="request"></param> /// <param name="handle"></param> /// <returns></returns> public Task<ApiResult> ApiAsync<TRequest, TResponse>(TRequest request, Func<TRequest, Task<TResponse>> handle) where TResponse : ResultObject { return handle(request).ContinueWith(x => { return Api(() => x.Result); }); } }
最常用的應該就是第一個Api<TRequest>方法,裡面處理了請求引數的驗證,把使用者資訊賦給需要的請求物件,異常記錄等。第二個方法是對沒有請求引數的api呼叫處理。第三個方法是非同步處理,