開始你的api:NetApiStarter
此篇是寫給新手的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,當請求參數驗證不通過的時候,肯定是沒有數據返回了。
在業務邏輯層,我選擇以包裝類作為返回類型,因為有很多錯誤都會在業務邏輯層出現,我們的接口是需要這些錯誤信息的。
現在前后端分離大行其道,我們做后端的通常會返回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。
關於Form表單形式的請求,可以參見這位園友的文章:你從未知道如此強大的ASP.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調用處理。第三個方法是異步處理,可以對異步IO處理做一些優化,比如你提供的這個接口是調用的另一個網絡接口的情況。
關於這個問題,我在一篇文章中貼了一些代碼,其實只要是知道怎么回事之后,自己可以想怎么玩就怎么玩了,下面講的沒有涉及角色的權限。
根據以往經驗,我們可以把資源(也就是一個接口)的權限分為三個等級(標紅的第二點很重要,會大大簡化后台權限管理的工作):
1,公開可訪問
2,登錄用戶可訪問
3,有權限的登錄用戶可訪問
所以我們如此設計驗證的過濾器:
public class AuthFilterAttribute : ActionFilterAttribute { /// <summary> /// 匿名可訪問 /// </summary> public bool AllowAnonymous { get; set; } /// <summary> /// 登錄用戶就可以訪問 /// </summary> public bool OnlyLogin { get; set; } /// <summary> /// 使用的資源權限名,比如多個接口可以使用同一個資源的權限,默認是/ControllerName/ActionName /// </summary> public string PowerName { get; set; } public sealed override void OnActionExecuting(ActionExecutingContext filterContext) { //跨域時,客戶端會用OPTIONS請求來探測服務器 if (filterContext.HttpContext.Request.HttpMethod == "OPTIONS") { var origin = filterContext.HttpContext.Request.Headers["Origin"]; if (true) //可以維護一個允許跨域的域名集合,類判斷是否可以跨域 { filterContext.HttpContext.Response.Headers.Add("Access-Control-Allow-Origin", origin ?? "*"); } filterContext.Result = new EmptyResult(); return; } if (AllowAnonymous) return; var user = LoginUser.GetUser(); if (user == null) { filterContext.Result = new ApiResult { ResultData = new ResultObject { Code = -1, Msg = "未登錄" }, JsonRequestBehavior = JsonRequestBehavior.AllowGet }; return; } if (OnlyLogin) return; var url = PowerName; if (string.IsNullOrEmpty(url)) { url = "/" + filterContext.ActionDescriptor.ControllerDescriptor.ControllerName + "/" + filterContext.ActionDescriptor.ActionName; } var hasPower = true; //可以根據 user和url等信息判斷是否有權限 if (!hasPower) { filterContext.Result = new ApiResult { ResultData = new ResultObject { Code = -2, Msg = "無權限" }, JsonRequestBehavior = JsonRequestBehavior.AllowGet }; } } }
AllowAnonymous屬性和OnlyLogin屬性的功能已經說過了,匿名訪問就是公開的,一個系統總會需要這樣的接口,登錄可訪問一般針對安全性比較低,比如字典數據的獲取,只要登錄了,就可以訪問,在權限管理里也不用配置了。
PowerName的屬性是出於什么考慮呢?有些時候,兩個接口的權限級別是綁定在一起的,比如一個商品的添加和修改接口,可以設置成同一個資源權限,所以都可以設置成/product/edit,這樣我們在權限管理里,只要維護/product/edit,而不需要分別維護/product/add和/product/update了(例子可能不太恰當,因為很多時候添加和修改本來就是一個接口,但是這個情況的確存在,設置PowerName也是為了簡化后台的權限管理)。
對於跨域的情況,上面代碼也有注釋,客戶端會用OPTIONS動作來探測服務器,除了上述代碼,在web.config也需要配置一下:
<system.webServer> <httpProtocol> <customHeaders> <!--<add name="Access-Control-Allow-Origin" value="*" />--> <add name="Access-Control-Allow-Headers" value="Origin, X-Requested-With, Content-Type, Accept,apiToken" /> </customHeaders> </httpProtocol> </system.webServer>
配置中注釋掉的一行,我故意留着,就是因為要和代碼里有個對應的地方,在配置中只能配置為“*” 或特定域名,我們要更靈活,所以在程序里控制,可以允許一個域名集合。
LoginUser的邏輯和上面的連接里的代碼差不多,不再貼了,下載里也有,apiToken從cookie和http頭部都可以取得,這樣不管是同域名網頁,跨域,app都是可以調用接口的。
以前的模型生產器很多,現在使用T4模板的也不少,而且VS里自帶T4模板。但是我不太喜歡用T4(主要是沒有智能提示)。我感覺Razor引擎就挺好呀,完全可以用來生成模型。自己寫的一個ORM新加了兩個方法,來獲取數據庫表的元數據,目前支持MSSql和MySql,稍微寫點代碼就可以生成模型了,下面是cshtml的內容,截圖是為了展示代碼高亮效果,哈哈(完整代碼在最下方有下載)
所以有時候,自己動動手還是挺好的。其實所有web語言都可以生成,jsp,php,nodejs,和動態生成頁面返回給客戶端是一樣的,這個只不過是寫到文件里。
這里自然說的是API文檔,和上面那個生成模型不太一樣,雖說生成基本上都是:模板+數據=結果,但是這個生成在獲取數據的時候有點困難,先看效果圖:
api文檔自動生成的重要性想必大家都知道了,如果還是手動寫word或excel,工作量大不說,是很難保持一致性的。
1. asp.net webapi 自帶一個Help Page 有興趣可以了解。
2. Swagger 是個生成api的框架,很強大,也支持接口測試,但是.net下的swagger好像只能使用在webapi中,一般的mvc不行,有興趣的也可以了解。
下面主要說一下本輪子的實現。從一個類型得到一個該類型的對象圖,在不嚴謹的情況下,還是比容易實現的,主要用反射和遞歸就可以了。
上面截圖中的C#類:
public class GetProductRequest : IRequest { /// <summary> /// 商品編號 /// </summary> public int? ProductID { get; set; } public ResultObject Validate() { if (ProductID == null || ProductID.Value <= 0) { return new ResultObject { Code = 1, Msg = "商品編號有誤" }; } return null; } } public class GetProductResponse : IResponse { /// <summary> /// 編號 /// </summary> public int? ID { get; set; } /// <summary> /// 商品名稱 /// </summary> public string Name { get; set; } /// <summary> /// 顏色集合 /// </summary> public List<string> Colors { get; set; } public List<ProductTag> TagList { get; set; } } public class ProductTag { /// <summary> /// 標簽編號 /// </summary> public int ID { get; set; } /// <summary> /// 標簽名稱 /// </summary> public string TagName { get; set; } }
轉換成JSON字符串:
{ "data": { "id": 0, "name": "str", "colors": [ "str" ], "tagList": [ { "id": 0, "tagName": "str" } ] }, "code": 0, "msg": "str" }
這樣我們就顯示了對象的結構,但是如果加上注釋呢? 如何顯示成下面的結果呢?這也是本輪子的特色,還是以json的格式展示中文說明。
{ "data": { "id": "編號", "name": "商品名稱", "colors": [ "顏色集合" ], "tagList": [ { "id": "標簽編號", "tagName": "標簽名稱" } ] }, "code": "等於0表示成功", "msg": "code不為0時,返回錯誤消息" }
思考一下,一個什么樣的對象才能被序列化成上面顯示的JSON字符串呢?
沿着這個思路,我打算在生成對象圖的時候再生成一個對象B,對象B用字典表示,而且末端的值填充成為對象圖對應屬性的Summary。
比如 一個C#類:
public class A { /// <summary> /// 編號 /// </summary> public int ID { get; set; } /// <summary> /// 字符串列表 /// </summary> public List<string> StrList { get; set; } public List<Sub> SubList { get; set; } public class Sub { /// <summary> /// Sub名稱 /// </summary> public int SubName { get; set; } } }
在構建A的對象圖的同時會像執行如下代碼一樣構建另一個對象B:
Dictionary<string, object> dict = new Dictionary<string, object>(); dict.Add("ID", "編號"); dict.Add("StrList", new List<string> { "字符串列表" }); var subDict = new Dictionary<string, object>(); subDict.Add("SubName", "Sub名稱"); dict.Add("SubList", new List<Dictionary<string, object>> { subDict });
ObjectGenerator的代碼如下:
public class ObjectGenerator { public static string GetSummary(PropertyInfo prop, Dictionary<string, string> summaryDict) { if (summaryDict == null || summaryDict.Count == 0) return string.Empty; var objType = prop.DeclaringType; var propName = prop.Name; var key = "P:" + objType.Namespace + "." + GetPrettyName(objType) + objType.Name + "." + propName; if (summaryDict.ContainsKey(key)) { return summaryDict[key]; } else { return ""; } } private static string GetPrettyName(Type objType, string namespaceStr = "") { if (objType.DeclaringType != null) { return GetPrettyName(objType.DeclaringType, objType.DeclaringType.Name + "." + namespaceStr); } else { return namespaceStr; } } public static Tuple<object, object> GetObjectMapDict(Type type, PropertyInfo typeProp, Dictionary<string, string> summaryDict, HashSet<string> ignoreProps = null) { if (typeProp != null) { var p = typeProp; } // if (type.IsPrimitive || type == typeof(decimal)) { var v1 = Convert.ChangeType(0, type); var v2 = v1.ToString(); if (typeProp != null) { v2 = GetSummary(typeProp, summaryDict); } return new Tuple<object, object>(v1, v2); } else if (type == typeof(string)) { var v1 = "str"; var v2 = v1.ToString(); if (typeProp != null) { v2 = GetSummary(typeProp, summaryDict); } return new Tuple<object, object>(v1, v2); } else if (type == typeof(DateTime)) { var v1 = DateTime.Now; var v2 = v1.ToString("yyyy-MM-dd HH:mm:ss"); if (typeProp != null) { v2 = GetSummary(typeProp, summaryDict); } return new Tuple<object, object>(v1, v2); } else if (type.IsArray) { var eleType = type.GetElementType(); var arr = Array.CreateInstance(eleType, 1); var list = new List<object>(); var ele_tuple = GetObjectMapDict(eleType, typeProp, summaryDict, ignoreProps); arr.SetValue(ele_tuple.Item1, 0); list.Add(ele_tuple.Item2); return new Tuple<object, object>(arr, list); } else if (type.Name.Equals("List`1")) { var list = (IList)Activator.CreateInstance(type); var list1 = new List<object>(); var eleType = type.GetGenericArguments()[0]; var ele_tuple = GetObjectMapDict(eleType, typeProp, summaryDict, ignoreProps); list.Add(ele_tuple.Item1); list1.Add(ele_tuple.Item2); return new Tuple<object, object>(list, list1); } else if (type.Name.Equals("Dictionary`2")) { var dict = (IDictionary)Activator.CreateInstance(type); var dict1 = new Dictionary<string, object>(); var keyType = type.GetGenericArguments()[0]; var valueType = type.GetGenericArguments()[1]; var key = GetObjectMapDict(keyType, null, summaryDict, ignoreProps); var value = GetObjectMapDict(valueType, null, summaryDict, ignoreProps); dict.Add(key.Item1, value.Item1); dict1.Add(key.Item2.ToString(), value.Item2); return new Tuple<object, object>(dict, dict1); } else if (type.IsClass) { var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); try { var obj = Activator.CreateInstance(type); var dict = new Dictionary<string, object>(); foreach (var prop in props) { if (ignoreProps != null && ignoreProps.Contains(prop.Name)) { continue; } var pType = DealNullable(prop.PropertyType); var val = GetObjectMapDict(pType, prop, summaryDict, ignoreProps); dict.Add(prop.Name, val.Item2); var setter = prop.GetSetMethod(true); if (setter != null) { prop.SetValue(obj, val.Item1, null); } } return new Tuple<object, object>(obj, dict); } catch (Exception e) { return null; } } else { try { var obj = Activator.CreateInstance(type); return new Tuple<object, object>(obj, obj); } catch (Exception e) { return null; } } } private static Type DealNullable(Type type) { if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) { return type.GetGenericArguments()[0]; } return type; } }
這段代碼是很不完善的,但是目前夠用了,不夠用可以再改嘛,javascript數據類型本來也不多,接口定義當然也是越簡單越好了。可巧的是webapi的 help page里也有個同名同功的ObjectGenerator,它的實現是比較完善的,但是只返回了對象圖,我開始還打算要在它上面按照我的思路修改一下呢,嘗試之后就作罷了,改動太多了,而且對我來說,上面代碼夠用了。
上面的summaryDict可以從外部讀取注釋文件獲取,要讀取哪些項目的注釋都需要設置一下:
讀取的代碼也很簡單,因為我只關注屬性的注釋,所以我只讀取屬性的:
Dictionary<string, string> getSummaryDict() { var path = Server.MapPath("~/") + "bin\\"; var files = Directory.GetFiles(path, "*.xml"); Dictionary<string, string> msDict = new Dictionary<string, string>(); foreach (var file in files) { XmlDocument xmldoc = new XmlDocument(); xmldoc.Load(file); var memberNodes = xmldoc.SelectNodes("/doc/members/member"); foreach (XmlNode item in memberNodes) { var name = item.Attributes["name"].Value; if (name.StartsWith("P:")) //只取屬性 { var summaryNode = item.SelectSingleNode("summary"); if (summaryNode != null) { msDict[name] = summaryNode.InnerText.Trim(); } } } } return msDict; }
Demo並不完整,沒有真正讀取數據庫,有興趣的同學可以下載下來玩玩。(由於上傳大小有限,我把packages文件夾刪除了)
在此之前,寫過一篇 給新手的WebAPI實踐 ,獲得了很多新人的認可,那時還是基於.net mvc,文檔生成還是自己鬧洞大開寫出來的,經過這兩年的時間,netcore的發展已經勢不可擋,自己也在不斷的學習,公司的項目也轉向了netcore。大部分也都是前后分離的架構,后端api開發居多,從中整理了一些東西在這里分享給大家。
源碼地址:https://gitee.com/loogn/NetApiStarter,這是一個基於netcore mvc 3.0的模板項目,如果你使用的netcore 2.x,除了引用不通用外,代碼基本是可以復用的。下面介紹一下其中的功能。
登錄驗證
這里我默認使用了jwt登錄驗證,因為它足夠簡單和輕量,在netcore mvc中使用jwt驗證非常簡單,首先在startup.cs文件中配置服務並啟用:
ConfigureServices方法中:
var jwtSection = Configuration.GetSection("Jwt"); services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidAudience = jwtSection["Audience"], ValidIssuer = jwtSection["Issuer"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSection["SigningKey"])) }; });
Configure方法中,在UseRouting和UseEndpoints方法之前:
app.UseAuthorization();
上面我們使用到了jwt配置塊,對應appsettings.json文件中有這樣的配置:
{
"Jwt": { "SigningKey": "1234567812345678", "Issuer": "NetApiStarter", "Audience": "NetApiStarter" } }
我們再操作兩步來實現登錄驗證,
一、提供一個接口生成jwt,
二、在客戶端請求頭部加上Authorization: Bearer {jwt}
我先封裝了一個生成jwt的方法
public static class JwtHelper { public static string WriteToken(Dictionary<string, string> claimDict, DateTime exp) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(AppSettings.Instance.Jwt.SigningKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: AppSettings.Instance.Jwt.Issuer, audience: AppSettings.Instance.Jwt.Audience, claims: claimDict.Select(x => new Claim(x.Key, x.Value)), expires: exp, signingCredentials: creds); var jwt = new JwtSecurityTokenHandler().WriteToken(token); return jwt; } }
然后在登錄服務中調用
/// <summary> /// 登錄,獲取jwt /// </summary> /// <param name="request"></param> /// <returns></returns> public ResultObject<LoginResponse> Login(LoginRequest request) { var user = userDao.GetUser(request.Account, request.Password); if (user == null) { return new ResultObject<LoginResponse>("用戶名或密碼錯誤"); } var dict = new Dictionary<string, string>(); dict.Add("userid", user.Id.ToString()); var jwt = JwtHelper.WriteToken(dict, DateTime.Now.AddDays(7)); var response = new LoginResponse { Jwt = jwt }; return new ResultObject<LoginResponse>(response); }
在Controller和Action上添加[Authorize]和[AllowAnonymous]兩個特性就可以實現登錄驗證了。
請求響應
這里請求響應的設計依然沒有使用restful風格,一是感覺太麻煩,二是真的不太懂(實事求是),所以請求還是以POST方式投遞JSON數據,響應當然也是JSON數據這個沒啥異議的。
為啥使用POST+JSON呢,主要是簡單,大家都懂,而且規則統一、繁簡皆宜,比如什么參數都不需要,就傳{}
,根據ID查詢文章{articleId:23}
,或者復雜的查詢條件和表單提交{ name:'abc', addr:{provice:'HeNan', city:'ZhengZhou'},tags:['騎馬','射箭'] }
等等都可以優雅的傳遞。
這只是我個人的風格,netcore mvc是支持其他的方式的,選自己喜歡的就行了。
下面的內容還是按照POST+JSON來說。
首先提供請求基類:
/// <summary> /// 登錄用戶請求的基類 /// </summary> public class LoginedRequest { #region jwt相關用戶 private ClaimsPrincipal _claimsPrincipal { get; set; } public ClaimsPrincipal GetPrincipal() { return _claimsPrincipal; } public void SetPrincipal(ClaimsPrincipal user) { _claimsPrincipal = user; } public string GetClaimValue(string name) { return _claimsPrincipal?.FindFirst(name)?.Value; } #endregion #region 數據庫相關用戶 (如果有必要的話) //不用屬性是因為swagger中會顯示出來 private User _user; public User GetUser() { return _user; } public void SetUser(User user) { _user = user; } #endregion }
這個類中說白了就是兩個手寫屬性,一個ClaimsPrincipal用來保存從jwt解析出來的用戶,一個User用來保存數據庫中完整的用戶信息,為啥不直接使用屬性呢,上面注釋也提到了,不想在api文檔中顯示出來。這個用戶信息是在服務層使用的,而且User不是必須的,比如jwt中的信息夠服務層使用,不定義User也是可以的,總之這里的信息是為服務層邏輯服務的。
我們還可以定義其他的基類,比如經常用的分頁基類:
public class PagedRequest : LoginedRequest { public int PageIndex { get; set; } public int PageSize { get; set; } }
根據項目的實際情況還可以定義更多的基類來方便開發。
響應類使用統一的格式,這里直接提供json方便查看:
{
"result": { "jwt": "string" }, "success": true, "code": 0, "msg": "錯誤信息" }
result是具體的響應對象,如果success為false的話,result一般是null。
ActionFilter
mvc本身是一個擴展性極強的框架,層層有攔截,ActionFilter就是其中之一,IActionFilter接口有兩個方法,一個是OnActionExecuted,一個是OnActionExecuting,從命名也能看出,就是在Action的前后分別執行的方法。我們這里主要重寫OnActionExecuting方法來做兩件事:
一、將登陸信息賦值給請求對象
二、驗證請求對象
這里說的請求對象,其類型就是LoginedRequest或者LoginedRequest的子類,看代碼:
[AppService] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] public class MyActionFilterAttribute : ActionFilterAttribute { /// <summary> /// 是否驗證參數有效性 /// </summary> public bool ValidParams { get; set; } = true; public override void OnActionExecuting(ActionExecutingContext context) { //由於Filters是套娃模式,使用以下邏輯保證作用域的覆蓋 Action > Controller > Global if (context.Filters.OfType<MyActionFilterAttribute>().Last() != this) { return; } //默認只有一個參數 var firstParam = context.ActionArguments.FirstOrDefault().Value; if (firstParam != null && firstParam.GetType().IsClass) { //驗證參數合法性 if (ValidParams) { var validationResults = new List<ValidationResult>(); var validationFlag = Validator.TryValidateObject(firstParam, new ValidationContext(firstParam), validationResults, false); if (!validationFlag) { var ro = new ResultObject(validationResults.First().ErrorMessage); context.Result = new JsonResult(ro); return; } } } var requestParams = firstParam as LoginedRequest; if (requestParams != null) { //設置jwt用戶 requestParams.SetPrincipal(context.HttpContext.User); var userid = requestParams.GetClaimValue("userid"); //如果有必要,可以每次都獲取數據庫中的用戶 if (!string.IsNullOrEmpty(userid)) { var user = ((UserService)context.HttpContext.RequestServices.GetService(typeof(UserService))).SingleById(long.Parse(userid)); requestParams.SetUser(user); } } base.OnActionExecuting(context); } }
模型驗證這塊使用的是系統自帶的,從上面代碼也可以看出,如果請求對象定義為LoginedRequest及其子類,每次請求會填充ClaimsPrincipal,如果有必要,可以從數據庫中讀取User信息填充。
請求經過ActionFilter時,模型驗證不通過的,直接返回了驗證錯誤信息,通過之后到達Action和Service時,用戶信息已經可以直接使用了。
api文檔和日志
api文檔首選swagger了,aspnetcore 官方文檔也是使用的這個,我這里用的是Swashbuckle,首先安裝引用
Install-Package Swashbuckle.AspNetCore -Version 5.0.0-rc4
定義一個擴展類,方便把swagger注入容器中:
public static class SwaggerServiceExtensions { public static IServiceCollection AddSwagger(this IServiceCollection services) { //https://github.com/domaindrivendev/Swashbuckle.AspNetCore services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "My Api", Version = "v1" }); c.IgnoreObsoleteActions(); c.IgnoreObsoleteProperties(); c.DocumentFilter<SwaggerDocumentFilter>(); //自定義類型映射 c.MapType<byte>(() => new OpenApiSchema { Type = "byte", Example = new OpenApiByte(0) }); c.MapType<long>(() => new OpenApiSchema { Type = "long", Example = new OpenApiLong(0L) }); c.MapType<int>(() => new OpenApiSchema { Type = "integer", Example = new OpenApiInteger(0) }); c.MapType<DateTime>(() => new OpenApiSchema { Type = "DateTime", Example = new OpenApiDateTime(DateTimeOffset.Now) }); //xml注釋 foreach (var file in Directory.GetFiles(AppContext.BaseDirectory, "*.xml")) { c.IncludeXmlComments(file); } //Authorization的設置 c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { In = ParameterLocation.Header, Description = "請輸入驗證的jwt。示例:Bearer {jwt}", Name = "Authorization", Type = SecuritySchemeType.ApiKey, }); }); return services; } /// <summary> /// Swagger控制器描述文字 /// </summary> class SwaggerDocumentFilter : IDocumentFilter { public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) { swaggerDoc.Tags = new List<OpenApiTag> { new OpenApiTag{ Name="User", Description="用戶相關"}, new OpenApiTag{ Name="Common", Description="公共功能"}, }; } } }
主要是驗證部分,加上去之后就可以在文檔中使用jwt測試了
然后在startup.cs的ConfigureServices方法中services.AddSwagger();
Configure方法中:
if (env.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(options => { options.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); options.DocExpansion(DocExpansion.None); }); }
這里限制了只有在開發環境才顯示api文檔,如果是需要外部調用的話,可以不做這個限制。
日志組件使用Serilog。
首先也是安裝引用Install-Package Serilog
Install-Package Serilog.AspNetCore
Install-Package Serilog.Settings.Configuration
Install-Package Serilog.Sinks.RollingFile
然后在appsettings.json中添加配置
{
"Serilog": { "WriteTo": [ { "Name": "Console" }, { "Name": "RollingFile", "Args": { "pathFormat": "logs/{Date}.log" } } ], "Enrich": [ "FromLogContext" ], "MinimumLevel": { "Default": "Debug", "Override": { "Microsoft": "Warning", "System": "Warning" } } }, }
更多配置請查看https://github.com/serilog/serilog-settings-configuration
上述配置會在應用程序根目錄的logs文件夾下,每天生成一個命名類似20191129.log的日志文件
最后要修改一下Program.cs,代替默認的日志組件
public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseConfiguration(new ConfigurationBuilder().SetBasePath(Environment.CurrentDirectory).AddJsonFile("appsettings.json").Build()); webBuilder.UseStartup<Startup>(); webBuilder.UseSerilog((whbContext, configureLogger) => { configureLogger.ReadFrom.Configuration(whbContext.Configuration); }); });
文件分塊上傳
文件上傳就像登錄驗證一樣常用,哪個應用還不上傳個頭像啥的,所以我也打算整合到模板項目中,如果是單純的上傳也就沒必要說了,這里主要說的是一種大文件上傳的解決方法: 分塊上傳。
分塊上傳是需要客戶端配合的,客戶端把一個大文件分好塊,一小塊一小塊的上傳,上傳完成之后服務端按照順序合並到一起就是整個文件了。
所以我們先定義分塊上傳的參數:
string identifier : 文件標識,一個文件的唯一標識, int chunkNumber :當前塊所以,我是從1開始的 int chunkSize :每塊大小,客戶端設置的固定值,單位為byte,一般2M左右就可以了 long totalSize:文件總大小,單位為byte int totalChunks:總塊數
這些參數都好理解,在服務端驗證和合並文件時需要。
開始的時候我是這樣處理的,客戶端每上傳一塊,我會把這塊的內容寫到一個臨時文件中,使用identifier和chunkNumber來命名,這樣就知道是哪個文件的哪一塊了,當上傳完最后一塊之后,也就是chunkNumber==totalChunks的時候,我將所有的分塊小文件合並到目標文件,然后返回url。
這個邏輯是沒什么問題,只需要一個機制保證合並文件的時候所有塊都已上傳就可以了,為什么要這樣一個機制呢,主要是因為客戶端的上傳可能是多線程的,而且也不能完全保證http的響應順序和請求順序是一樣的,所以雖然上傳完最后一塊才會合並,但是還是需要一個機制判斷一下是否所有塊都上傳完畢,沒有上傳完還要等待一下(想一想怎么實現!)。
后來在實際上傳過程中發現最后一塊響應會比較慢,特別是文件很大的時候,這個也好理解,因為最后一塊上傳會合並文件,所以需要優化一下。
這里就使用到了隊列的概念了,我們可以把每次上傳的內容都放在隊列中,然后使用另一個線程從隊列中讀取並寫入目標文件。在這個場景中BlockingCollection
是最合適不過的了。
我們定義一個實體類,用於保存入列的數據:
public class UploadChunkItem { public byte[] Data { get; set; } public int ChunkNumber { get; set; } public int ChunkSize { get; set; } public string FilePath { get; set; } }
然后定義一個隊列寫入器
public class UploadChunkWriter { public static UploadChunkWriter Instance = new UploadChunkWriter(); private BlockingCollection<UploadChunkItem> _queue; private int _writeWorkerCount = 3; private Thread _writeThread; public UploadChunkWriter() { _queue = new BlockingCollection<UploadChunkItem>(500); _writeThread = new Thread(this.Write); } public void Write() { while (true) { //單線程寫入 //var item = _queue.Take(); //using (var fileStream = File.Open(item.FilePath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite)) //{ // fileStream.Position = (item.ChunkNumber - 1) * item.ChunkSize; // fileStream.Write(item.Data, 0, item.Data.Length); // item.Data = null; //} //多線程寫入 Task[] tasks = new Task[_writeWorkerCount]; for (int i = 0; i < _writeWorkerCount; i++) { var item = _queue.Take(); tasks[i] = Task.Run(() => { using (var fileStream = File.Open(item.FilePath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite)) { fileStream.Position = (item.ChunkNumber - 1) * item.ChunkSize; fileStream.Write(item.Data, 0, item.Data.Length); item.Data = null; } }); } Task.WaitAll(tasks); } } public void Add(UploadChunkItem item) { _queue.Add(item); } public void Start() { _writeThread.Start(); } }
主要是Write方法的邏輯,調用_queue.Take()方法從隊列中獲取一項,如果隊列中沒有數據,這個方法會堵塞當前線程,這也是我們所期望的,獲取到數據之后,打開目標文件(在上傳第一塊的時候會創建),根據ChunkNumber 和ChunkSize找到開始寫入的位置,然后把本塊數據寫入。
打開目標文件的時候使用了FileShare.ReadWrite,表示這個文件可以同時被多個線程讀取和寫入。
文件上傳方法也簡單:
/// <summary> /// 分片上傳 /// </summary> /// <param name="formFile"></param> /// <param name="chunkNumber"></param> /// <param name="chunkSize"></param> /// <param name="totalSize"></param> /// <param name="identifier"></param> /// <param name="totalChunks"></param> /// <returns></returns> public ResultObject<UploadFileResponse> ChunkUploadfile(IFormFile formFile, int chunkNumber, int chunkSize, long totalSize, string identifier, int totalChunks) { var appSetting = AppSettings.Instance; #region 驗證 if (formFile == null && formFile.Length == 0) { return new ResultObject<UploadFileResponse>("文件不能為空"); } if (formFile.Length > appSetting.Upload.LimitSize) { return new ResultObject<UploadFileResponse>("文件超過了最大限制"); } var ext = Path.GetExtension(formFile.FileName).ToLower(); if (!appSetting.Upload.AllowExts.Contains(ext)) { return new ResultObject<UploadFileResponse>("文件類型不允許"); } if (chunkNumber == 0 || chunkSize == 0 || totalSize == 0 || identifier.Length == 0 || totalChunks == 0) { return new ResultObject<UploadFileResponse>("參數錯誤0"); } if (chunkNumber > totalChunks) { return new ResultObject<UploadFileResponse>("參數錯誤1"); } if (totalSize > appSetting.Upload.TotalLimitSize) { return new ResultObject<UploadFileResponse>("參數錯誤2"); } if (chunkNumber < totalChunks && formFile.Length != chunkSize) { return new ResultObject<UploadFileResponse>("參數錯誤3"); } if (totalChunks == 1 && formFile.Length != totalSize) { return new ResultObject<UploadFileResponse>("參數錯誤4"); } #endregion //寫入邏輯 var now = DateTime.Now; var yy = now.ToString("yyyy"); var mm = now.ToString("MM"); var dd = now.ToString("dd"); var fileName = EncryptHelper.MD5Encrypt(identifier) + ext; var folder = Path.Combine(appSetting.Upload.UploadPath, yy, mm, dd); var filePath = Path.Combine(folder, fileName); //線程安全的創建文件 if (!File.Exists(filePath)) { lock (lockObj) { if (!File.Exists(filePath)) { if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); } File.Create(filePath).Dispose(); } } } var data = new byte[formFile.Length]; formFile.OpenReadStream().Read(data, 0, data.Length); UploadChunkWriter.Instance.Add(new UploadChunkItem { ChunkNumber = chunkNumber, ChunkSize = chunkSize, Data = data, FilePath = filePath }); if (chunkNumber == totalChunks) { //等等寫入完成 int i = 0; while (true) { if (i >= 20) { return new ResultObject<UploadFileResponse> { Success = false, Msg = $"上傳失敗,總大小:{totalSize},實際大小:{new FileInfo(filePath).Length}", Result = new UploadFileResponse { Url = "" } }; } if (new FileInfo(filePath).Length != totalSize) { Thread.Sleep(TimeSpan.FromMilliseconds(1000)); i++; } else { break; } } var fileUrl = $"{appSetting.RootUrl}{appSetting.Upload.RequestPath}/{yy}/{mm}/{dd}/{fileName}"; var response = new UploadFileResponse { Url = fileUrl }; return new ResultObject<UploadFileResponse>(response); } else { return new ResultObject<UploadFileResponse> { Success = true, Msg = "uploading...", Result = new UploadFileResponse { Url = "" } }; } }
撇開上面的參數驗證,主要邏輯也就是三個,一是創建目標文件,二是分塊數據加入隊列,三是最后一塊的時候要驗證文件的完整性(也就是所有的塊都上傳了,並都寫入到了目標文件)
創建目標文件需要保證線程安全,這里使用了雙重檢查加鎖機制,雙重檢查的優點是避免了不必要的加鎖情況。
完整性我只是驗證了文件的大小,這只是一種簡單的機制,一般是夠用了,別忘了我們的接口都是受jwt保護的,包括這里的上傳文件。如果要求更高的話,可以讓客戶端傳參整個文件的md5值,然后服務端驗證合並之后文件的md5是否和客戶端給的一致。
最后要開啟寫入線程,可以在Startup.cs的Configure方法中開啟:
UploadChunkWriter.Instance.Start();
經過這樣的整改,上傳速度溜溜的,最后一塊也不用長時間等待啦!
(項目中當然也包含了不分塊上傳)
其他功能
自從netcore提供了依賴注入,我也習慣了這種寫法,不過在構造函數中寫一堆注入實在是難看,而且既要聲明字段接收,又要寫參數賦值,挺麻煩的,於是乎自己寫了個小組件,已經用於手頭所有的項目,當然也包含在了NetApiStarter中,不僅解決了屬性和字段注入,同時也解決了實現多接口注入的問題,以及一個接口多個實現精准注入的問題,詳細說明可查看項目文檔Autowired.Core。
如果你聽過MediatR,那么這個功能不需要介紹了,項目中包含一個應用程序級別的事件發布和訂閱的功能,具體使用可查看文檔AppEventService。
如果你聽過AutoMapper,那么這個功能也不需要介紹了,項目中包含一個SimpleMapper,代碼不多功能還行,支持嵌套類、數組、IList<>、IDictionary<,>實體映射在多層數據傳輸的時候可謂是必不可少的功能,用法嘛就不說了,只有一個Map方法太簡單了
重中之重
如果你感覺這個項目對你、或者其他人(You or others,沒毛病)有稍許幫助,請給個Star好嗎!
NetApiStarter倉庫地址:https://gitee.com/loogn/NetApiStarter