将UI和领域模型隔离开来 (一)
Nov 30, 2010
有将近一个月没有更新博客了,这阵子确实比较忙,必须淡定,克己轩. 不过这期间我有在Evernote中记录了一些零星的想法和感触,今天刚好有时间,于是拿出来仔细瞧了瞧.其中有一篇是关于我在TWU即将结束的时候记下来的日记,记得当时有一个故事,需要将浏览器端的javascript验证逻辑移植到服务器端.在完成这个故事的过程中给我留下了很深的印象,遇到了很多有意思的问题,我觉得我有必要写出来,否则时间过久了就会忘了.TWU这个项目比较有意思,它历经了几届TWU学员的蹂躏,很好的完成了试验品这个角色赋予的任务,你可以猜到当我们拿到这个项目时,并不是推到原有的而从新开始开发,而是在既有的代码库上进行开发.
我会选取我在完成这个故事中的一个情景入手,这个情景是用户可以跳到"创建新故事"页面,然后可以输入title(文本框,必须填写),city(文本框,必须填写),content(文本框,必须填写),month(下拉框,非必须选择)和year(文本框,必须填写)这几个选项,然后单击"Publish"按钮进行发布,发布之后会跳到 显示故事的页面,它会显示刚刚创建的故事的一些信息. 其中值得注意的是 用户在"创建新故事" 页面的表格中,如果用户跳过month字段,因为它是非必须的,那么在显示故事的页面的"Event Date: "后面只会显示年份,而没有月份.
显示故事的页面:
现有实现
首先用户填写表格,并单击"Publish",javascript会拦截请求,进行验证,这里我们只关心年和月的验证:
function validTWYear(year,month) { if(year == "") return false;//for year empty if(!isNumeric(year)) return false; var date = new Date(); var curr_date = date.getDate(); var curr_month = date.getMonth()+1; var curr_year = date.getFullYear(); if(year>curr_year) return false; if(year<1954) return false; if(year==1954 && month<5) return false; return true; } function validTWMonth(month,year) { var date = new Date(); var curr_date = date.getDate(); var curr_month = date.getMonth()+1; var curr_year = date.getFullYear(); if(year==curr_year) if(month>curr_month && month!=-1) return false; if(year==1954 && month<5) return false; return true; }这段代码的意思是对于年字段的值而言,它不能为空,不能包含非数字的字符,不能比今年还大且不能早于1954年,如果是年份是1954,那么对应的月份应该早于5月份. 对于月字段的值而言,如果输入年份是今年,那么输入的月份的值不能大于当前的月份. 这段代码它没有任何测试案例可以寻找,另外代码的抽象程度太低,读起来很费劲,为后来的将这段逻辑移植到服务器端带来很大的不便. 关于javascript测试:Make JavaScript tests part of your build: QUnit & Rhino 如果验证都有通过,下一步就是在Controller层处理请求,这个方法是
@RequestMapping(value = "/story/new", method = RequestMethod.POST) public ModelAndView create(@Valid Story story, BindingResult bindingResult, @CurrentUser User) { ModelAndView modelAndView = new ModelAndView("new_story", "story", story); if(bindingResult.hasErrors()) { return modelAndView; } if (storyService.save(story, currentUser,request.getContextPath())){ return new ModelAndView(String.format("redirect:/story/%s", story.getId())); } return new ModelAndView("redirect:/error.jsp"); }这段还是很清楚的,做完数据绑定之后,调用Spring内嵌支持的Validator对Story对象上所有标记Annotation的字段进行验证,并将结果保持到bindResult中,如果出现错误返回到原页面,并将错误信息一并带回. 这个过程是发生在数据绑定之后的,所以你可以猜到Story对象中一定有month和year字段. 这里必须提到Hibernate Validation Annotation , 好处在于它使得模型能够拥有自己 的判断逻辑,而不需要跟其他比如UI层发生紧密的耦合,提高可重用性的同时保持自己的纯洁.但是现在Story里面的判断逻辑都是非常简单的,比如像是
@Column(name = "story_date", nullable = false) public Date getStoryDate() { return storyDate; }然后便是storyService存储故事的代码
@Transactional public Boolean save(Story story, User currentUser,String contextPath) { story.generateStoryDate(); if (!story.valid()) { return false; } story.setUnformattedContent(HTMLTagsRemover.removeTagsIn(story.getContent())); story.generateStoryDate(); inlineTagProcessor.updateTags(story, contextPath); story.setLastModifiedBy(currentUser); story.setAuthor(currentUser); return storyRepository.save(story); }首先,它调用story.generateStoryDate(). 这个方法是能从它的名字推出,因为story里此时记录的时间不是storyDate而是month和year,所以它需要将其转换成一个storyDate.
private Date storyDate; private String month; private String year; private Date convertToDateObject(String month, String year) { DateTime date = month.equals("-1") ? new DateTime(Integer.parseInt(year),DateTimeConstants.DECEMBER, 31 ,0,0,0,0) : new DateTime(Integer.parseInt(year),Integer.parseInt(month), 1, 0,0,0,0); return date.toDate(); } public void generateStoryDate() { setStoryDate(convertToDateObject(this.month, this.year)); }忘了交代,如果用户在"创建新故事"的表格中没有选取month字段,那么默认会给一个值: -1. 所以为什么在convertToDateObject会是这个样子了? 因为显示这个故事的时候,需要知道是否用户当初有没有填入month,如果没有,只显示年份;如果填了,那么显示年月.这两种情况都没有跟天有什么关系, 所以此时的这段代码作者便是这样处理的: 如果用户没有在表格中填month字段,那么此时在Story对象中month的值就是-1,那就设置它的DayOfMonth是31号;如果用户有填了,那么将其DayOfMonth设置为1号. 所以你可以思考那他在显示此故事的逻辑是怎样处理的了?
Event date: ${story_view_model.dateOccurred}
在StoryViewModel中
public EscapedString getDateOccurred() { if (story.getMonth().equals("0")) return escape(story.getYear()); else return escape(new DateTime(story.getStoryDate()).monthOfYear().getAsShortText() + " " + story.getYear()); }这段逻辑也很简单,如果getMonth()是"0",则只返回年份;如果不是则讲二则组合起来返回. 那这个"0"是从何而来了, 准备好别吐血,且看在Story中getMonth()的代码:
@Transient public String getMonth() { if(storyDate == null) return ""; if(new DateTime(storyDate).getDayOfMonth() == 31) { return "0"; } else return String.valueOf(new DateTime(storyDate).getMonthOfYear()); }如果你将整个联合起来一想,就知道写此代码之人是如何思考的拉,如果在显示故事的时候知道是应该显示年份了还是年月一起来, 他很聪明,想到这个东西跟天 没有关系,于是用天作为一个mark,同时即使在model层知道了天是否正确,那么如何让UI知道了,于是有了"0". 两个MAGIC NUMBER ,好歹你也封装下如此底层的实现细节,花了不少时间在揣测它想表达意思,后来发现是用"0"和31来串联整个流程,把我可是看吐血了. 别急还有,在Service层,调用完generateStoryDate()之后,紧接着的是:
if (!story.valid()) { return false; }在Story中代码是:
public boolean valid() { boolean validator=checkValidYearAndTextFields(); boolean monthInFuture=checkMonthInFuture(); return (validator && !monthInFuture); } private boolean checkValidYearAndTextFields() { Validator validator = Validation.buildDefaultValidatorFactory() .getValidator(); Set> constraintViolations = validator .validate(this); return constraintViolations.isEmpty(); } public boolean checkMonthInFuture() { if (Integer.parseInt(getYear()) == currentYear()) { DateTime today = new DateTime(); if(getMonth().equals("0")) return false; return Integer.parseInt(getMonth()) > today.monthOfYear().get(); } return false; }看起来,貌似抽象程度还可以,但是如果看checkValidYearAndTextFields()方法中,居然要一次调用Validator来验证,不知道为啥,因为CONTROLLER层已经有使用Spring内嵌提供的validator验证过了,这里再来一遍,实在是不解. 再看看checkMonthInFuture(),要检查了一遍如果输入年份是现在的年份,是否月份超出当前的月份. 于是你要可以看到神奇的数字"0",而且没有测试.