前言前后端分离已经是业界所共识的一种开发/部署模式了。所谓的前后端分离,并不是传统行业中的按部门划分,一部分人纯做前端(HTML/CSS/JavaScript/Flex),另一部分人纯做后端,因为这种方式是不工作的:比如很多团队采取了后端的模板技术(JSP, FreeMarker, ERB等等),前端的开发和调试需要一个后台Web容器的支持,从而无法做到真正的分离(更不用提在部署的时候,由于动态内容和静态内容混在一起,当设计动态静态分流的时候,处理起来非常麻烦)。关于前后端开发的另一个讨论可以参考这里。 即使通过API来解耦前端和后端开发过程,前后端通过 一点背景一个典型的Web应用的布局看起来是这样的: 前后端都各自有自己的开发流程,构建工具,测试集合等等。前后端仅仅通过接口来编程,这个接口可能是JSON格式的RESTFul的接口,也可能是XML的,重点是后台只负责数据的提供和计算,而完全不处理展现。而前端则负责拿到数据,组织数据并展现的工作。这样结构清晰,关注点分离,前后端会变得相对独立并松耦合。 上述的场景还是比较理想,我们事实上在实际环境中会有非常复杂的场景,比如异构的网络,异构的操作系统等等: 在实际的场景中,后端可能还会更复杂,比如用C语言做数据采集,然后通过Java整合到一个数据仓库,然后该数据仓库又有一层Web Service,最后若干个这样的Web Service又被一个Ruby的聚合Service整合在一起返回给前端。在这样一个复杂的系统中,后台任意端点的失败都可能阻塞前端的开发流程,因此我们会采用mock的方式来解决这个问题: 这个
但是当集成依然是一个令人头疼的难题。我们往往在集成的时候才发现,本来协商的数据结构变了: 所以仅仅使用一个静态服务器,然后提供
简而言之,我们需要商定一些契约,并将这些契约作为可以被测试的中间格式。然后前后端都需要有测试来使用这些契约。一旦契约发生变化,则另一方的测试会失败,这样就会驱动双方协商,并降低集成时的浪费。 一个实际的场景是:前端发现已有的某个契约中,缺少了一个 而且实际的项目中,往往都是多个页面,多个API,多个版本,多个团队同时进行开发,这样的契约会降低非常多的调试时间,使得集成相对平滑。 在实践中,契约可以定义为一个JSON文件,或者一个XML的payload。只需要保证前后端共享同一个契约集合来做测试,那么集成工作就会从中受益。一个最简单的形式是:提供一些静态的
看到 一个例子 我们以这个应用为示例,来说明如何在前后端分离之后,保证代码的质量,并降低集成的成本。这个应用场景很简单:所有人都可以看到一个条目列表,每个登陆用户都可以选择自己喜欢的条目,并为之加星。加星之后的条目会保存到用户自己的 不过为了专注在我们的中心上,我去掉了诸如登陆,个人中心之类的页面,假设你是一个已登录用户,然后我们来看看如何编写测试。 前端开发 根据通常的做法,前后端分离之后,我们很容易 [ { "id": 1, "url": "http://abruzzi.github.com/2015/03/list-comprehension-in-python/", "title": "Python中的 list comprehension 以及 generator", "publicDate": "2015年3月20日" }, { "id": 2, "url": "http://abruzzi.github.com/2015/03/build-monitor-script-based-on-inotify/", "title": "使用inotify/fswatch构建自动监控脚本", "publicDate": "2015年2月1日" }, { "id": 3, "url": "http://abruzzi.github.com/2015/02/build-sample-application-by-using-underscore-and-jquery/", "title": "使用underscore.js构建前端应用", "publicDate": "2015年1月20日" } ] 然后,一个可能的方式是通过请求这个json来测试前台: $(function() { $.get('/mocks/feeds.json').then(function(feeds) { var feedList = new Backbone.Collection(extended); var feedListView = new FeedListView(feedList); $('.container').append(feedListView.render()); }); }); 这样当然是可以工作的,但是这里发送请求的 get '/api/feeds' do content_type 'application/json' File.open('mocks/feeds.json').read end 这样,当我们和实际的服务进行集成时,只需要连接到那个服务器就可以了。 注意,我们现在的核心是 [ { "id": 3, "url": "http://abruzzi.github.com/2015/02/build-sample-application-by-using-underscore-and-jquery/", "title": "使用underscore.js构建前端应用", "publicDate": "2015年1月20日" } ] 然后在 get '/api/fav-feeds/:id' do content_type 'application/json' File.open('mocks/fav-feeds.json').read end 通过这两个请求,我们会得到两个列表,然后根据这两个列表的交集来绘制出所有的星号的状态(有的是空心,有的是实心): $.when(feeds, favorite).then(function(feeds, favorite) { var ids = _.pluck(favorite[0], 'id'); var extended = _.map(feeds[0], function(feed) { return _.extend(feed, {status: _.includes(ids, feed.id)}); }); var feedList = new Backbone.Collection(extended); var feedListView = new FeedListView(feedList); $('.container').append(feedListView.render()); }); 剩下的一个问题是当点击红心时,我们需要发请求给后端,然后更新红心的状态: toggleFavorite: function(event) { event.preventDefault(); var that = this; $.post('/api/feeds/'+this.model.get('id')).done(function(){ var status = that.model.get('status'); that.model.set('status', !status); }); } 这里又多出来一个请求,不过使用Sinatra我们还是可以很容易的支持它: post '/api/feeds/:id' do end 可以看到,在没有后端的情况下,我们一切都进展顺利 — 后端甚至还没有开始做,或者正在由一个进度比我们慢的团队在开发,不过无所谓,他们不会影响我们的。 不仅如此,当我们写完前端的代码之后,可以做一个 #encoding: utf-8 require 'spec_helper' describe 'Feeds List Page' do let(:list_page) {FeedListPage.new} before do list_page.load end it 'user can see a banner and some feeds' do expect(list_page).to have_banner expect(list_page).to have_feeds end it 'user can see 3 feeds in the list' do expect(list_page.all_feeds).to have_feed_items count: 3 end it 'feed has some detail information' do first = list_page.all_feeds.feed_items.first expect(first.title).to eql("Python中的 list comprehension 以及 generator") end end 关于如何编写这样的测试,可以参考之前写的这篇文章。 后端开发 我在这个示例中,后端采用了 首先是请求的入口, @Controller @RequestMapping("/api") public class FeedsController { @Autowired private FeedsService feedsService; @Autowired private UserService userService; public void setFeedsService(FeedsService feedsService) { this.feedsService = feedsService; } public void setUserService(UserService userService) { this.userService = userService; } @RequestMapping(value="/feeds", method = RequestMethod.GET) @ResponseBody public Iterable<Feed> allFeeds() { return feedsService.allFeeds(); } @RequestMapping(value="/fav-feeds/{userId}", method = RequestMethod.GET) @ResponseBody public Iterable<Feed> favFeeds(@PathVariable("userId") Long userId) { return userService.favoriteFeeds(userId); } } 具体查询的细节我们就不做讨论了,感兴趣的可以在文章结尾处找到代码库的链接。那么有了这个Controller之后,我们如何测试它呢?或者说,如何让契约变得实际可用呢?
private MockMvc mockMvc; private FeedsService feedsService; private UserService userService; @Before public void setup() { feedsService = mock(FeedsService.class); userService = mock(UserService.class); FeedsController feedsController = new FeedsController(); feedsController.setFeedsService(feedsService); feedsController.setUserService(userService); mockMvc = standaloneSetup(feedsController).build(); } 建立了mockmvc之后,我们就可以编写Controller的单元测试了: @Test public void shouldResponseWithAllFeeds() throws Exception { when(feedsService.allFeeds()).thenReturn(Arrays.asList(prepareFeeds())); mockMvc.perform(get("/api/feeds")) .andExpect(status().isOk()) .andExpect(content().contentType("application/json;charset=UTF-8")) .andExpect(jsonPath("$", hasSize(3))) .andExpect(jsonPath("$[0].publishDate", is(notNullValue()))); } 当发送 注意此处的 private Feed[] prepareFeeds() throws IOException { URL resource = getClass().getResource("/mocks/feeds.json"); ObjectMapper mapper = new ObjectMapper(); return mapper.readValue(resource, Feed[].class); } 这样,当后端修改 对应的,测试 @Test public void shouldResponseWithUsersFavoriteFeeds() throws Exception { when(userService.favoriteFeeds(any(Long.class))) .thenReturn(Arrays.asList(prepareFavoriteFeeds())); mockMvc.perform(get("/api/fav-feeds/1")) .andExpect(status().isOk()) .andExpect(content().contentType("application/json;charset=UTF-8")) .andExpect(jsonPath("$", hasSize(1))) .andExpect(jsonPath("$[0].title", is("使用underscore.js构建前端应用"))) .andExpect(jsonPath("$[0].publishDate", is(notNullValue()))); } 总结 前后端分离是一件容易的事情,而且团队可能在短期可以看到很多好处,但是如果不认真处理集成的问题,分离反而可能会带来更长的集成时间。通过面向契约的方式来组织各自的测试,可以带来很多的好处:更快速的 代码前后端的代码我都放到了Gitbub上,感兴趣的可以clone下来自行研究:
|