开始你的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