目录
概述
我之前写的一些关于ASP.net Web API的博客中,得到了一些朋友的反响,我一直也想整理下代码贴出来供大家参考,但后来发觉从整个项目工程中单独把一部分代码剥离出来还真是不容易,一转眼就把这个事情忘记了,最近终于下定决心弄一弄,于是才有了此文,本DEMO虽然不完美,但已经包括了我目前所掌握的全部的关于WEB API的相关技术,至于有哪些地方还需要改进的,我会在文章末尾一一指出,由于Web API的服务器端是没有界面的,这样不容易演示,所以我还提供了一个用WPF写的客户端,先一睹为快:
功能介绍
下面是这套程序所要演示的相关的技术或功能的介绍:
服务器端:
- 完整的,代码结构精良的(至少我这么认为)ASP.net Web API的服务器端
- 使用DataAnnotations进行Model验证
- 自定义Model验证
- 分层(分开UI层和业务逻辑层)
- 优异的日志记录方式
- 安全系数很高的身份验证(好吧,我这么写是为了用激将法来引出高手给我挑挑毛病)
- 敏感信息加密
- 纯Web API代码(去除了css/js及不需要的视图引擎)
关于身份验证的思路,可以参考《如何实现RESTful Web API的身份验证》
客户端:
- 自行设计的MVVM简易框架
- 大量的WPF实用技巧
- 使用DataAnnotations进行客户端Model验证
- HttpClient的完整示例
其它:
- 自动对象映射(使用AutoMapper)
- 独立运行,零配置,用Visual Studio 2010打开即用
- 我已经尽量减少重复代码……(其实做得还是不太够)
- 二进制文件的上传和下载
程序结构
本程序主要目的是做一个文档齐全,功能比较全面和零配置的demo,所以不涉及到DBMS的使用,尽管真正使用的时候DBMS几乎是必须的,但这次我就用一个XML来代替DBMS了。
程序分为两个部分,一是服务器端,另一是客户端,而且是分开成两个不同的solution,这样做完全是为了方便调试。但这样带来的问题是会产生一些重复的东西,比如这三个库:CommLib,WebApiKit,WebApiContract,它们是公共库,但又分别存在于不同的solution中,我在实际工作中是用了SVN这种工具来避免它们“改了这个忘了那个”的,而这次我用了一个自己很久以前写的工具来让它们“同步”:
这工具也会在本文后面提供下载。
服务器端介绍
服务器端的文件结构如图:
BLL - 业务逻辑层
UserInfo_BLL.cs - 就是用户信息类,后缀“BLL”表示它属于业务逻辑层,我习惯这样区分各个层面不同的Model
UserManager.cs - 业务逻辑层的主类,提供各种“增删查改”的方法
CommLib - 公共库,包括DES加密类,MD5类,日志类,一些正则表达式,全局常量等等……
Server - ASP.net Web API的主工程
AutoMapperConfig.cs - 自动对象映射的配置类,比如将UserInfo_BLL直接转为UserInfo_API_Get,而不需要一个一个属性地赋值
WebApiConfig.cs - ASP.net Web API的路由配置类
AvatarsController.cs - 头像的获取、修改和删除
EntranceController.cs - 登录并获取自己的信息
PasswordController.cs - 修改自己的密码
UsersInfoController.cs - 获取单个用户信息、获取用户列表、修改用户信息、增加用户(未实现)和删除用户(未实现)
ModelValidationFilter.cs - 针对所有请求的全局Model验证过滤器
WebApiAuthFilter.cs - 针对绝大部分(不排除有些地方不需要身份验证)的Controller的身份验证器
WebApiExceptionFilter.cs - 全局异常处理器
WebApiRoleFilter.cs - 针对某些Action的角色权限过滤器,比如某些动作只能管理员来做
GuidSet.cs - 用于防止重发攻击的Guid集合帮助类
WebApiPrincipal.cs - 登录用户的身份类
GlobalServerData.cs - 里面包括一个静态的GuidSet
Managers.cs - 里面包括一个静态的UserManager
WebApiContract - 就是用这个库来跟客户端“磋商”的
WebApiKit - 客户端/服务器端都能用到的一些工具
客户端介绍
客户端的项目结构图:
Client - 客户端的主工程
PasswordHelper.cs - 密码控件的帮助类,用于将密码控件的密码文本绑定到View Model,WPF出自于安全的需要默认不提供这种绑定支持
UIVisibleConverter.cs - 一些WPF界面用的转换器,用于根据View Model的一些属性来控制界面元素的显示与隐藏
ChangePassword_VM.cs - 修改密码界面用的View Model,后缀“VM”就是View Model的意思。
Login_VM.cs - 登录界面用的View Model。
UserInfo_VM.cs - 主界面上显示/修改用户信息用的View Model。
ViewModelBase.cs - 所有的View Model的基类,实现了INotifyPropertyChanged接口、IDataErrorInfo接口和一些帮助方法
“契约”
契约(Contract)这个词其实来自于Web Service,但Web Service是一套很重量级的技术,我个人并不不喜欢它。其实契约简单地说,就是:Web API如何用?契约中应该包括:调用地址是什么,方法是什么,有那些内容,有什么验证。以UserInfo_API_Put为例:
public class UserInfo_API_Base { [Required(ErrorMessage = Verifier.ERRMSG_CANNOT_BE_NULL)] [RegularExpression(Verifier.REG_EXP_CHINESE_NAME, ErrorMessage = Verifier.ERRMSG_REG_EXP_CHINESE_NAME)] public string RealName { get; set; } //真实姓名 public float Height { get; set; } //身高 public DateTime Birthday { get; set; } //生日 } //修改用户信息(普通用户只能修改自己的信息) //PUT api/usersinfo/{username} public class UserInfo_API_Put : UserInfo_API_Base { [EnuValueValidator(RoleType.ADMINISTARTOR, RoleType.NORMAL)] public string Role { get; set; } //角色Administrator, Normal, 普通用户无法修改此字段 }
如要修改“guogangj”这个用户的信息,那就往“api/usersinfo/guogangj”这个uri地址put这么一个对象,其中RealName这个属性不得为空,还必须是2-10个中文字符,当然了,Height和Birthday也都不可为空,因为float型和DateTime型都是不可空的类型,Role属性则要执行一个自定义的验证,确保其值必须为“Administrator”或“Normal”。
这样的契约必须同时被服务器端和客户端所理解,所以做成了一个类库的形式,服务器端和客户端都引用这个类库,这样做的最大的问题就在于这个类库发生了变动的情况下,更新了一边却忘了另一边,我目前是用一些工具来尽量避免这种情况的发生的,比如SVN的Externals参数设置。对此,各位高人有什么更好的方法?希望能分享一下。
Web API设计规则
尽管在《对RESTful Web API的理解与设计思路》中,我已经提了一下Web API的“法则”,这里再老调重弹外加几句补充吧。
RESTFul的核心内容是“R”,也就是资源,我们把对资源的增删查改具体化为HTTP的四个动作:POST、DELETE、GET和PUT。现在有这么个问题:假如我的用户名是guogangj,我要获取我的信息,是“GET /api/myinfo”呢,还是“GET /api/usersinfo/guogangj”呢?从技术上来说都没问题,现在关键是要从“资源”的角度考虑,如果你认为“/api/myinfo”是一个资源,那就意味着每个用户对这个资源的GET会得到不同的结果,而对于“/api/usersinfo/guogangj”这样的资源,不管是谁,获取到的内容应该是一致的(如果有权限获取的话),从这个角度看,“/api/usersinfo/guogangj”这种方式更加RESTFul,这是我的理解,不一定正确,还有请高手的分析。
并行写入冲突与时间戳
在对资源进行PUT和DELETE动作的时候,需要对其进行并行写入冲突检查,因为写入的时候,资源可能已经被别人动过,这个检查通常是用一个“时间戳”来实现的,我使用的是DateTime类型的Ticks,这是一个long类型,足够反映出资源发生变动的时间了。例如我现在要对用户guogangj的信息进行修改:
PUT http://localhost.:57955/api/usersinfo/guogangj?UpdateTicks=635054404507843749
{"Role":"Administrator","RealName":"蒋国纲","Height":1.67,"Birthday":"1981-11-12T00:00:00"}
也许仔细的你注意到了,localhost后面貌似多了个“.”,这是为了让Fiddler能够捕捉到这个http包而加的。
我会在URI中带上UpdateTicks参数,服务器端的业务逻辑层在执行Update的时候,会判断这个时间戳和现在数据库里的时间戳是否一致,如果不一致,则抛出并行写入冲突的异常。
我把UpdateTicks放在URI中的理由是:这个UpdateTicks也可以算是资源的一部分。例如对于上面这个PUT动作,我的意图是:我要更新时间戳为“635054404507843749”的“/api/usersinfo/guogangj”这个资源,如果它的时间戳不是“635054404507843749”,那就不是我要更新的资源。
这是我的方法,另一种我能想出的办法是把时间戳放在HTTP头中,如:
PUT http://localhost.:57955/api/usersinfo/guogangj
UpdateTicks:635054404507843749
{"Role":"Administrator","RealName":"蒋国纲","Height":1.67,"Birthday":"1981-11-12T00:00:00"}
这样服务器端在处理的时候一样可以取出时间戳,只不过方法稍有些不同,那种更好呢?就我个人而言,是偏向于前者,这里也请高手指教一下。
身份验证详解
好吧,终于到重头戏了,那就是Web API的身份验证,为了使大家马上有个直接的了解,我用Fiddler截取一个包,看看我每次请求到底发了些什么?
PUT http://localhost.:57955/api/usersinfo/guogangj?UpdateTicks=635054404507843749 HTTP/1.1
Custom-Auth-Name: guogangj
Custom-Auth-Key: 58E595EC40A74FF4EEF0856D7E59018F6141E12EA3DB965F74B416A4DFDB5746E6DCFDEDBDF5DA0C524254763FEE207B1FA8EF6D948132DF45C9C89AA7BF3A7373C509687C03BDE5
Accept: application/json
Content-Type: application/json; charset=utf-8
Host: localhost.:57955
Content-Length: 94
Expect: 100-continue
{"Role":"Administrator","RealName":"蒋国纲","Height":1.67,"Birthday":"1981-11-12T00:00:00"}
这是一个完整的HTTP请求,在HTTP头中多了这么两个东西:“Custom-Auth-Name”和“Custom-Auth-Key”,Custom-Auth-Name不用说,一看就知道是User ID,表示发起人是谁,但如果他说自己是谁服务器就认为他是谁的话,那就没有任何安全可言了,所以还要Custom-Auth-Key(下面简称Key)这个东西来验证一番,这个Key是长长的一串东西,这是经过加密和转码后的文本,下面说说这个Key是怎么来的。
在WebApiKit这个库中有这么一个方法:WebApiClientHelper.MakePrincipleHeader,代码全在里面,不多,我一一解释:
private static void MakePrincipleHeader(HttpRequestMessage reqMsg, string strUri) { //即便是一模一样的请求内容,我也希望生成不同的key,所以每次都需要生成一个新的GUID,防止“重发”用的也是这个GUID,用这个GUID使得每次请求(不管URI和内容是否一样)都是唯一的,不可复制和重复的 Guid guid = Guid.NewGuid(); //获取有效的URI,如这个请求的这一长串的URI获取到的内容是“/api/usersinfo/guogangj” strUri = InternalHelper.GetEffectiveUri(strUri); //有效URI连上GUID,进行一次MD5加密,(用这种方法来获得长度一致但每次都截然不同的内容)再连上GUID,这个结果作为对称加密的明文 string strToEncrypt = Md5.MD5Encode(strUri + guid) + " " + guid; //明文密码执行两次MD5之后作为对称加密的密钥,加密前面产生的那一串“明文”,好吧,Key就这样生成了 string strTheAuthKey = Des.Encode(strToEncrypt, Md5.MD5TwiceEncode(Password)); //将结果加入到HTTP请求的Header中去 reqMsg.Headers.Add(Consts.HTTP_HEADER_AUTH_USER, UserName); reqMsg.Headers.Add(Consts.HTTP_HEADER_AUTH_KEY, strTheAuthKey); }
对称加密,没有密钥就无法还原,而密钥并没有在网络上传输,不可能被第三者通过截包等方式跟踪到,所以这个密文应该来说是无法破解的。服务器端拿到这个请求包之后,执行一个逆向操作:
public static bool VerifyAuthKey(string strAuthUser, string strAuthKey, string strRequestUri, string strPwdMd5TwiceSvr, ref Guid guidRequest) { try { //对称加密的解密,密钥为用户密码的二次MD5,服务器端知道的 string strUrlAndGuid = Des.Decode(strAuthKey, strPwdMd5TwiceSvr); //如果解密成功,用空格劈开成两段,一段是“有效URI连上GUID,进行一次MD5加密”,另一段就是GUID了 string[] arrUrlAndGuid = strUrlAndGuid.Split(new[] { ' ' }); if (arrUrlAndGuid.Count() != 2) return false; string strUrl = arrUrlAndGuid[0]; string strGuid = arrUrlAndGuid[1]; //将解密出来的这个GUID作为返回参数,以便将其加入一个全局的集合中来防止“重发”(“重发”会在另一处地方检查) guidRequest = Guid.Parse(strGuid); //再按照与客户端一致的办法生成“有效URI连上GUID,进行一次MD5加密”的结果,把这个结果与刚解密出来的结果比对,如果一致,身份验证通过 strRequestUri = InternalHelper.GetEffectiveUri(strRequestUri); if (string.Compare(Md5.MD5Encode(strRequestUri + guidRequest), strUrl, true) == 0) { return true; } } catch (Exception) //忽略这其中产生的任何异常,将它认为是验证不通过 { //Ignore any exception } return false; }
这种验证方法可以杜绝了“身份冒充””和“重发”,而且完全不依赖于第三方的库,方法十分简单,开发者能很轻易地对它进行进一步的强化,我认为对于大多数场合,够了。好吧,等待高人来指正。
Web API验证规则
验证始终是应用程序的一个关键的功能,如前面提到的身份验证其实也是一种验证,验证的目的是:确保正确的人做正确的事。
有些验证仅仅是一个简单的规则,比如中文名验证:不可为空,必须是2-10中文字符;有些验证则需要访问数据库才知道,比如:添加一个用户,不能和已有用户的ID重复;还有些综合型的验证,在本例子中也有体现:用户可以修改自己的信息,但只有管理员才能修改别人的信息。
验证究竟是放在UI层还是放在业务逻辑层呢?其实这不只是Web API才有的问题,所有的系统,在设计的时候都要考虑这样的问题。以前我在做系统的时候,认为层与层之间是互相不信任的,因此业务逻辑层要进行一套完整的验证,而UI层当然也要进行一套完整的验证,这样带来的后果是重复代码增加,看起来有些凌乱,后来我这么考虑:如果网站的UI层对用户提供的信息执行过了验证,为什么业务逻辑层还需要再执行一次?应该不需要了,因为UI层和业务逻辑层都放在服务器端,这是我们自己能够控制的,我们只需要针对客户端过来的数据做验证即可,于是我大刀阔斧地把业务逻辑层的验证代码削除掉了,程序果然看起来整洁了许多。
*注:在这个DEMO中,Server这个站点属于UI层,而BLL这个类库属于业务逻辑层
但有些跟数据相关的验证就不是那么容易放在UI层做,比如前面说的“添加一个用户,不能和已有的用户的ID重复”,这个就需要到数据库里面查查到底有没有这个用户ID先。
所以,一般来说,我的规则是这样:身份验证、输入验证和权限判断能放在UI层就放在UI层,UI层做不到(比如涉及到具体数据的验证),才放在业务逻辑层,UI层验证和业务逻辑层的验证最好不要重复。
客户端MVVM简介
本文的重点是Web API,但也顺便简单说说客户端的MVVM模型,MVVM即“Model - View - ViewModel”,ViewModel与View绑定,绑定在这里的意思就是:当View发生变化时,ViewModel要体现出来,反之,当ViewModel发生变化时,View也要体现出来。大概就是这样,具体开来还要分什么双向绑定和单向绑定。
View发生变化,ViewModel也要跟着变,这个看起来并不难,比如你在UserName的文本框里输入“zhangsan”,当你的输入焦点离开这个文本框时,程序会产生一个事件,它会去处理这个事件并把文本框的值赋到ViewModel去,这个“事件”不一定是失去焦点,还有可能是键入,也可能是手动触发。
而ViewModel变化,View也要跟着变化,这个如何实现呢?WPF提供了一个接口INotifyPropertyChanged,这个接口里只有一个叫“PropertyChanged”的event,ViewModel发生变化的时候,就通过触发这个event来通知View改变。我在客户端代码中提供了一个叫“ViewModelBase”的基类,就实现了这个接口,我的其它的ViewModel都从这个基类派生下来,在给它们的属性SetValue的时候,就会触发这个接口中的那个event,实现对View的通知。
网上关于MVVM的文章还是很多的,还有些相当重量级的框架,如Prism,要掌握这些东西就绝非一朝一夕之力了,但我相信万变不离其宗,原理就如我所说的那样。
另外关于WPF的一些技术,我就不在这里提了,毕竟这不是本文重点,大家可以参考一些别的资料。
Web.Config
这也许是你见过的最简单的Web.Config,因为我把不用的都去除了。
<?xml version="1.0" encoding="utf-8"?> <!-- For more information on how to configure your ASP.NET application, please visit http://go.microsoft.com/fwlink/?LinkId=169433 --> <configuration> <system.web> <compilation debug="true" targetFramework="4.0" /> <authentication mode="None" /> </system.web> </configuration>
没错,上面的内容就是全部的,唯一值得一提的是authentication的mode属性,我们由于使用的是自定义的身份验证方式,所以得把这个设为None,否则服务器端很可能会使用Windows身份验证机制。并且此程序已经在IIS下验证过,正常使用没什么问题。
本DEMO的一些问题
DEMO毕竟是DEMO,我在写的过程中也发现了一些问题,有些是因为条件的限制,有些则真是问题,所以必须列一下,以便大家在正式开发的时候注意避免:
- 时间戳使用UTC时间而不是本地时间是否更佳?(考虑到如果使用UTC时间的话得多一点转换,所以我在此DEMO中就不用了)
- 没有使用事务。事务功能通常是DBMS的功能,本DEMO没有使用DBMS,另外,一个好的系统还能做到文件的回滚,不只是DBMS,但这远超本DEMO的范畴了。
- usersinfo的POST和DELETE功能没做(偷懒)
- 客户端的网络通信均会阻塞UI线程,用户体验不佳。改进参考
- 客户端ViewModel的验证与API Contract的验证存在重复,请问高手这个重复如何消除?
- 身份验证需要调用业务逻辑层,没有在UI层做缓存,在正式的大型应用场合,没有缓存的话效率会很低的,但缓存的更新也是个很大的问题,我相信大型的网络应用在这方面都有一套严谨而复杂的规则。
- 密码在服务器端的保存格式固定为二次MD5,这样不利于将来对加密算法的改进。
- 客户端的输入验证做得不够好,例如在年龄里输入“abc”,虽然有出错提示(转换成数字失败),但居然也可以提交(提交的内容是之前的数字)
- 由于服务器端的一些全局数据是static的,因而可能存在线程安全的问题
相关下载