摘要:本文是架构之路的系列文章,已经到了第七篇,主要分享了MVC点滴。下一章将再讲CurrentUser,并由此引出我们的原则:如何在View、Controller、Service和ViewModel之间划分逻辑(或者责任)。
WebForm
随着ASP.NET MVC的兴起,WebForm已成昨日黄花,但我其实还很想为WebForm说几句。
没有经历过从ASP向ASP.NET转变的同学,是很难理解当WebForm出现时,程序猿世界的欢呼雀跃的。事实上,我也是在Razor出现之后,才勉勉强强的转向MVC,因为看见<% %>这个东西就怕。我曾经参加过一个升级ASP到ASP.NET的项目,ASP里面乱七八糟的代码看得我眼睛又酸又胀红通通的流泪,一辈子都记得! WebForm最后生成的html可能会臃肿难看,但其代码页面(.aspx)是相当清爽漂亮的。 既然我们都已经决定采用MVC了,WebForm的不足就不用再多说了。但我们应该努力的学习和借鉴它优秀的地方,这些也是在MVC的开发中会用到的:
- 呈现和页面逻辑相分离。WebForm里由于它的框架本来就显式的区分了aspx和aspx.cs,所以大多数时候我们不会担心这个事情。但MVC里面,我们很容易就在view里面利用ViewModel数据进行运算,模糊Controller和View之间的逻辑界限。这个问题我们将在CurrentUser的时候详细讲解。
- 良好的页面封装和重用。当我们发现页面又反复出现的、大同小异的“部件”时,我们肯定就会想到重用。这就是考验我们功力的时候。我先提一点我想到的:有时候我们宁愿重复不愿重用!这是我得出来的血泪教训。应该是在创业家园项目的评论页面部分,我曾经试图重用所有评论的PartialView,结果惨不忍睹,最后放弃重用,反而海阔天空。其实有一个更好的例子就是WebForm中的GridView和Repeater,从实践上看,反而是简单封装的Repeater更受欢迎,“大而全”的GridView却少有人用。所以封装和重用有一个度的问题。
RouteTest Route功能是MVC的一个重大突破,也是一个重要缺陷。由于没有良好的自动检查机制,在实际的开发过程中,非常容易出错!相信有过开发经验的同学都有体会,有时候老半天都报错:找不到View找不到Action,查来查去就一个拼写错误;有时候新增一条RouteConfig,一会儿其他同事叫起来了,“考!原来是你的设置把我的覆盖了。查了我一下午!” 把时间浪费在这些地方实在是可惜,所以我们解决这个问题的办法是使用单元测试,在PCTest的project中引入了RouteTest。每一次新增RouteConfig,跑一遍单元测试:自己的能过,也不影响别人的,就OK了。 这是单元测试在我们项目的UI层最成功的例子。照理说,MVC的最大的一个好处就是“可测试”,其他地方也应该广泛引入单元测试的,但本人偷懒,另外HttpContext的sealed限制也限制了单元测试的实施(MVC 5应该解决了这个问题),所以目前UI层的单元测试还没有展开。但估计这个工作迟早都得做,现在已经出现了一些手工测试繁琐费事易遗漏的问题了。 URL/View层级 MVC现目前的另一个问题是,View很难按多层级组织。比如,我可能需要的View是这样组织的:
注意Controller也有层级关系设置。我始终觉得这样会更清晰整洁,但如果MVC的框架不能这样进行“层级对应”。如果一定要这样把View分层组织起来,在Action中就必须写出View的全部路径,比如:
public class LogController : Controller { // // GET: /Account/Log/On public ActionResult On() { return View("~/Views/Account/Log/On.cshtml"); } } |
还得专门配置RouteConfig,这也太麻烦了一点。所以,我们就还是尽量按MVC的框架,从URL的设计开始,就尽量是/{Controller}/{action}/{route-parameter}的样式,View也同样,放在Contoller对应的文件夹下即可。 Partial/ChildAction/EditorTemplate 当我们需要重用某些“页面片段”时,我们就面临了以上这几种选择。切入的点有很多,我们就只结合我们项目,抽取其最鲜明最容易辨认的特点,直接讲述他们的使用场景: 首先是EditorTemplate。它的特点最明显,是和Post相关的。也就是,当一个“页面片段”的数据,还需要再Post回服务器的时候,我们就必须使用EditorTemplate;如果不使用EditorTemplate,就ViewModel的数据就无法传回(参考:任务管理系统代码中/Views/Task/EditorTemplates)。为什么呢?和MVC的ViewModel绑定机制有关,EditorTemplate中的html控件呈现时,会在其name上加上所属父Model的前缀,以便于MVC自动解析post数据并绑定到ViewModel。 如果“页面片段”不需要POST,只负责呈现即可,又该如何选择呢?我们的原则是:
- 如果“页面片段”不需要和服务器端交互,所需要的数据都能从父Model中获得,使用Partial;
- 否则,如果“页面片段”说需要的数据还需要从服务器获得,那就只能使用ChildAction了。
HtmlHelper 除了上述几种页面片段的重用,还有通过创建HtmlHelper的扩展方法,自定义一种“页面片段”的呈现方式。这种方式一般是PartialView的一种替代方式,我们通常把“很小很小”(比如一个链接、一个下拉列表等),用处“很多很多”(甚至于跨项目)的可重用html片段用HtmlHelper封装起来。可参考:
- 任务管理系统项目中的DocumentLink:封装一个总是使用doc.zyfei.net域名的html链接
- Global项目(还未上传源代码)中的EnumDropDownListFor:封装一个使用dropdownlist,该dropdownlist由enum填充,使用enum上的[Description]作为呈现文本
AJAX 观察我们的Action就可以发现,我们为Ajax提供的Action始终是返回的ActionResult,而不是使用“更先进”的WebApi机制(直接返回int等简单类型)。这主要是因为我们使用了SessionPerRequest机制(主要是为了提高性能),我们让一个Request请求只使用一个session(可先简单的理解为一个数据库连接),亦即:
- 当MVC获得一个Request,需要使用session时,Service生成一个session;
- 然后,在这个Request的整个请求过程中,使用的都将是这个已经生成的session(类似于“单例模式”);
- 当Request结束后,释放这个session,将所有改动同步到数据库
好了,这里我们的关键点就是什么时候算“Request结束”?我们更进一步的定义它为View呈现完毕的时候,所以利用了Filter机制,在OnResultExecuted()时同步数据库,代码如下:
public class SessionPerRequest : ActionFilterAttribute { public override void OnResultExecuted(ResultExecutedContext filterContext) { #if PROD FFLTask.SRV.ProdService.BaseService.EndSession(); #endif base.OnResultExecuted(filterContext); } } |
所以,即使Ajax调用,也必须经历一个“View呈现完毕”的过程,才能完成数据同步。
UIDevService切换 进行前台开发,不需要连接后台数据库的同学,只需要在MVC项目编译时,输入UIDEV即可(如果要真正的连接数据库,使用PROD),如下所示:
那么,这究竟是如何实现的呢? 总体上来说,我们借用了autofac这个类库,实现了所谓的“依赖倒置”
所以,在MVC的Controller中,我们只使用ServiceInterface而不管其具体实现,如下所示:
private IAuthroizationService _authService; public AuthController(IAuthroizationService authService) { _authService = authService; } |
最后,在Global.asax.cs中我们通过条件编译符if...else来确定究竟使用哪一种Service实现:ProdServiceModule,或者UIDevServicemodule。
void ResolveDependency() { var builder = new ContainerBuilder();
builder.RegisterControllers(Assembly.GetExecutingAssembly()); builder.RegisterFilterProvider();
#if PROD builder.RegisterModule(new ProdServiceModule()); #endif #if UIDEV builder.RegisterModule(new UIDevServicemodule()); #endif container = builder.Build(); DependencyResolver.SetResolver(new AutofacDependencyResolver(container)); } |
最后,不要忘了,新引入一个Service时,在ProdServiceModule.cs或者UIDevServicemodule.cs中添加:
builder.RegisterType<RegisterService>().As<IRegisterService>(); |
这一章就差不多了吧。下一章我们再讲CurrentUser,并由此引出我们的原则:如何在View、Controller、Service和ViewModel之间划分逻辑(或者责任)。
本文主要讲了CurrentUser,并由此引出我们的原则:如何在View、Controller、Service和ViewModel之间划分逻辑(或者责任)。
CurrentUser,也就是当前用户,这是我们系统中大量使用的一个概念。
确认当前用户
当然,我们利用的是cookie:用户的ID存放在cookie中,服务器端通过cookie中的Id,查找数据库,得到需要的用户信息。
那么,这里就有一个安全问题,如何防止cookie的伪造或篡改?我们采用了以下方法:
首先,cookie中除了存放用户Id,还存放了一个加密过后的验证码,其来源如下:
- 未加密的验证码在用户生成时由系统随机产生,并存储在数据库中,如:287653;
- 它会被使用MD5加密成我们看不懂的字符串,如:49b5f37dff119cf81fcb2b4e6077e17;
所以,当服务器端使用cookie中的用户Id时,会先检查加密过后的验证码是否有效。捏造的验证码是不会通过审核的。
还有一点需要说明的是,我们不考虑一个有效的cookie(连同验证码)被盗窃的情形。因为这就相当于你的电脑被别人使用了一样,我们确实无法判断使用你电脑的是不是你本人。 为什么没有使用session
可能有同学会想到,每次取cookie再查数据库,是不是会增加数据库负担,为什么不考虑session呢?两个方面的原因:
- session有定时清理机制。不管时间长短,session总有可能被清理掉的时候,这个时候不能让用户再重新登录啊!多麻烦,是不是?你可以if(session["userInfo"]== null),再通过cookie取数据再装到session里,但何苦呢?
- session难以同步更新,维护起来非常麻烦。比如当前用户发表一篇文章,积分增加了,你就得既改session又改数据库,这个同步过程是比较容易出问题的。
- 上面两个问题,NHiernate的cache已经做得很好了,不会增加数据库负担,这个以后会讲。
CurrentUser的ViewModel
CurrentUser最麻烦的一件事情是:很多页面是根据不同的当前用户,显示不同的内容的。以“任务编辑”页面为例,当前用户是该任务的发布人,发布栏可编辑;否则,发布栏仅仅是可读的。
所以,最初我们的方案很简单,也封装一个CurrentUserModel就可以了呀!
但后来我们发现:
- 需要判断的东西越来越多,比如还要判断当前用户是不是管理员、当前用户有没有验收权限、当前用户的上一次操作……把这些所有的信息都装到一个ViewModel里肯定是不合适的。怎么办呢?想到的自然就是拆分类,但CurrentUser还怎么拆分呢?
- 页面的判断逻辑也变得复杂起来,比如当前用户有没有某种权限得查他的申请历史和批准情况,并且还得看当前文章是那种类型及其作者的权限等。这些大 段大段的逻辑就写在View里面么?关键是有些数据是单个View取不到的,需要从其他地方(比如url parameter中)获取,这些都进一步的增加了复杂性。让我们不得不考虑,我们是不是应该把这些逻辑移到Controller中,然后直接将结果告诉 View,保持View的干净清爽?
在MVC架构中,Controller将Model传递给View,其实可能有两种情况:
- View直接呈现Model的数据,比如直接显示CurrentUser的用户名
- View还可以利用Model中的数据进行运算,然后予以呈现,比如比较CurrentUser和当前任务的承接人
我曾经计划禁止掉第2种情形,也就是说:在View里面不需要任何计算,只负责呈现。用代码表示就是:
@if (Model.CurrentUserIsAccepter) { //CurrentUserIsAccepter的值在controller中获取 } |
而不是之前的:
@if (Model.CurrentUser.Id == Model.Accepter.Id) {
} |
但我们最终放弃了,因为实现起来太臃肿了。我们可以想象,这样的话,我们首先就至少需要三个Is属性:
public class EditModel { public bool IsAccepter { get; set; } public bool IsOwner { get; set; } public bool IsPublisher { get; set; } } |
有点怪,但好像还可以接受,但后来情况发生了变化,我们还得考虑当前用户即是发布人又是承接人,或者即是承接人又是验收人,或者既是……又是……的情形:
public class EditModel { public bool IsAccepter { get; set; } public bool IsOwner { get; set; } public bool IsPublisher { get; set; }
public bool IsBothAccepterAndOwner { get; set; } public bool IsBothAccepterAndPublisher { get; set; } public bool IsBothPublisherAndOwner { get; set; } //...... }
|
这代码给人的感觉就是有病了。关键是,谁知道以后还来不来一个“是…和…但不是……”的逻辑呢?到时候又该怎么办呢?
//任务编辑页面(/Task/Edit/{taskId})是一个页面呈现逻辑比较复杂的典型例子, //我们前后大改了三次,才形成今天所使用的代码格局。 //我以前说我带的一个妹纸看着代码哭,哭的就是这里,呵呵 //有兴趣的同学可以研究一下。 |
所以,取巧是不行了,我们还是得面对这个问题:
如何划分Controller和View之间的逻辑/责任?
更直白一点的讲,哪些事该Controller做,哪些事该View做?这个问题真的超级虐心。我想来想去,只能说:“能Controller做的,尽量让Controller做”。我自己对这个问题都相当不满意,但实在是没有办法啦。
具体到CurrentUser的ViewModel,我们提出以下两个原则:
- 不包含需要和其他对象交互运算才能得到的数据,比如当前用户是不是当前任务的发布人,需要和“当前任务的发布人”做比较,就不能包含进来
- 只能是需要多个View共用的数据,才能放进来。比如用户名,很多View都需要,就放进来好了。
为什么需要明确这些原则
可能你耐着性子看了上面的分析,最后却只得到一个似是而非又蛋疼的原则,会忍不住的问,“为什么一定需要/讲解这些原则?让程序员根据实际情况,自由发挥,不行么?”
浅层次的原因是要保证代码的可读性。阅读别人的代码是一件非常累的事情。但如果所有的代码都像一个人写的,而且这个人的思路自始至终都是非常清晰的,这样,我们会稍稍轻松一点。代码不是文学作品,在绝大多数情况下,不能天马行空自由发挥!
我们很多开发人员都已经开始注意代码的规范,但大多数还停留在缩进、换行、命名之类的细节(当然,这些也很重要)上;而架构师应站在一个更全局的高度,来“规范”所有的开发行为。
所以,其实更深层次的原因是:所有的代码都必须规范化。既然要规范化,那么首先就要有规范!先可以不管好坏,但至少要有。那么怎么制定完善这个规范呢?我分享一下我的经验:
- 按规范文档,做入职培训,培训可以着重讲道理,强化开发人员代码规范化的思维;
- 所有代码都必须review。review要往“挑刺”的方向靠,所以不规范的代码其实是很容易被发现的;
- 开发人员不服review的结果,review的人员要拿出依据(规范文档)来;
- 规范文档中如果还没有相关的规定,立即补充,并照此执行,包括改正以前不合规范的代码
这样不断的迭代,基本上就能不断的提高代码的规范性,并得到一份不错的规范文档。
|