先后端分离了,而后呢?(转)

 

 前言html

  先后端分离已是业界所共识的一种开发/部署模式了。所谓的先后端分离,并非传统行业中的按部门划分,一部分人纯作前端(HTML/CSS/JavaScript/Flex),另外一部分人纯作后端,由于这种方式是不工做的:好比不少团队采起了后端的模板技术(JSP, FreeMarker, ERB等等),前端的开发和调试须要一个后台Web容器的支持,从而没法作到真正的分离(更不用提在部署的时候,因为动态内容和静态内容混在一块儿,当设计动态静态分流的时候,处理起来很是麻烦)。关于先后端开发的另外一个讨论能够参考这里前端

  即便经过API来解耦前端和后端开发过程,先后端经过RESTFul的接口来通讯,前端的静态内容和后端的动态计算分别开发,分别部署,集成仍然是一个绕不开的问题 — 前端/后端的应用均可以独立的运行,可是集成起来却不工做。咱们须要花费大量的精力来调试,直到上线前仍然没有人有信心全部的接口都是工做的。python

  一点背景

  一个典型的Web应用的布局看起来是这样的:jquery

typical web application

  先后端都各自有本身的开发流程,构建工具,测试集合等等。先后端仅仅经过接口来编程,这个接口多是JSON格式的RESTFul的接口,也多是XML的,重点是后台只负责数据的提供和计算,而彻底不处理展示。而前端则负责拿到数据,组织数据并展示的工做。这样结构清晰,关注点分离,先后端会变得相对独立并松耦合。git

  上述的场景仍是比较理想,咱们事实上在实际环境中会有很是复杂的场景,好比异构的网络,异构的操做系统等等:github

real word application

  在实际的场景中,后端可能还会更复杂,好比用C语言作数据采集,而后经过Java整合到一个数据仓库,而后该数据仓库又有一层Web Service,最后若干个这样的Web Service又被一个Ruby的聚合Service整合在一块儿返回给前端。在这样一个复杂的系统中,后台任意端点的失败均可能阻塞前端的开发流程,所以咱们会采用mock的方式来解决这个问题:web

mock application

  这个mock服务器能够启动一个简单的HTTP服务器,而后将一些静态的内容serve出来,以供前端代码使用。这样的好处不少:spring

  1. 先后端开发相对独立
  2. 后端的进度不会影响前端开发
  3. 启动速度更快
  4. 先后端均可以使用本身熟悉的技术栈(让前端的学maven,让后端的用gulp都会很不顺手)

  可是当集成依然是一个使人头疼的难题。咱们每每在集成的时候才发现,原本协商的数据结构变了:deliveryAddress字段原本是一个字符串,如今变成数组了(业务发生了变动,系统如今能够支持多个快递地址);price字段变成字符串,协商的时候是number;用户邮箱地址多了一个层级等等。这些变更在所不免,并且时有发生,这会花费大量的调试时间和集成时间,更别提修改以后的回归测试了。数据库

  因此仅仅使用一个静态服务器,而后提供mock数据是远远不够的。咱们须要的mock应该还能作到:编程

  1. 前端依赖指定格式的mock数据来进行UI开发
  2. 前端的开发和测试都基于这些mock数据
  3. 后端产生指定格式的mock数据
  4. 后端须要测试来确保生成的mock数据正是前端须要的

  简而言之,咱们须要商定一些契约,并将这些契约做为能够被测试的中间格式。而后先后端都须要有测试来使用这些契约。一旦契约发生变化,则另外一方的测试会失败,这样就会驱动双方协商,并下降集成时的浪费。

  一个实际的场景是:前端发现已有的某个契约中,缺乏了一个address的字段,因而就在契约中添加了该字段。而后在UI上将这个字段正确的展示了(固然还设置了字体,字号,颜色等等)。可是后台生成该契约的服务并无感知到这一变化,当运行生成契约部分测试(后台)时,测试会失败了 — 由于它并无生成这个字段。因而后端工程师就找前端来商量,了解业务逻辑以后,他会修改代码,并保证测试经过。这样,当集成的时候,就不会出现UI上少了一个字段,可是谁也不知道是前端问题,后端问题,仍是数据库问题等。

  并且实际的项目中,每每都是多个页面,多个API,多个版本,多个团队同时进行开发,这样的契约会下降很是多的调试时间,使得集成相对平滑。

  在实践中,契约能够定义为一个JSON文件,或者一个XML的payload。只须要保证先后端共享同一个契约集合来作测试,那么集成工做就会从中受益。一个最简单的形式是:提供一些静态的mock文件,而前端全部发日后台的请求都被某种机制拦截,并转换成对该静态资源的请求。

  1. moco,基于Java
  2. wiremock,基于Java
  3. sinatra,基于Ruby

  看到sinatra被列在这里,可能熟悉Ruby的人会反对:它但是一个后端全功能的的程序库啊。之因此列它在这里,是由于sinatra提供了一套简洁优美的DSL,这个DSL很是契合Web语言,我找不到更漂亮的方式来使得这个mock server更加易读,因此就采用了它。

  一个例子

  咱们以这个应用为示例,来讲明如何在先后端分离以后,保证代码的质量,并下降集成的成本。这个应用场景很简单:全部人均可以看到一个条目列表,每一个登录用户均可以选择本身喜欢的条目,并为之加星。加星以后的条目会保存到用户本身的我的中心中。用户界面看起来是这样的:

bookmarks

  不过为了专一在咱们的中心上,我去掉了诸如登录,我的中心之类的页面,假设你是一个已登陆用户,而后咱们来看看如何编写测试。

  前端开发

  根据一般的作法,先后端分离以后,咱们很容易mock一些数据来本身测试:

[
    {
        "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()); }); });

  这样固然是能够工做的,可是这里发送请求的url并非最终的,当集成的时候咱们又须要修改成真实的url。一个简单的作法是使用Sinatra来作一次url的转换:

get '/api/feeds' do content_type 'application/json' File.open('mocks/feeds.json').read end

  这样,当咱们和实际的服务进行集成时,只须要链接到那个服务器就能够了。

  注意,咱们如今的核心是mocks/feeds.json这个文件。这个文件如今的角色就是一个契约,至少对于前端来讲是这样的。紧接着,咱们的应用须要渲染加星的功能,这就须要另一个契约:找出当前用户加星过的全部条目,所以咱们加入了一个新的契约:

[
    {
        "id": 3, "url": "http://abruzzi.github.com/2015/02/build-sample-application-by-using-underscore-and-jquery/", "title": "使用underscore.js构建前端应用", "publicDate": "2015年1月20日" } ]

  而后在sinatra中加入一个新的映射:

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

  能够看到,在没有后端的状况下,咱们一切都进展顺利 — 后端甚至尚未开始作,或者正在由一个进度比咱们慢的团队在开发,不过无所谓,他们不会影响咱们的。

  不只如此,当咱们写完前端的代码以后,能够作一个End2End的测试。因为使用了mock数据,免去了数据库和网络的耗时,这个End2End的测试会运行的很是快,而且它确实起到了端到端的做用。这些测试在最后的集成时,还能够用来当UI测试来运行。所谓一举多得。

#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

end 2 end

  关于如何编写这样的测试,能够参考以前写的这篇文章

  后端开发

  我在这个示例中,后端采用了spring-boot做为示例,你应该能够很容易将相似的思路应用到Ruby或者其余语言上。

  首先是请求的入口,FeedsController会负责解析请求路径,查数据库,最后返回JSON格式的数据。

@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以后,咱们如何测试它呢?或者说,如何让契约变得实际可用呢?

  sprint-test提供了很是优美的DSL来编写测试,咱们仅须要一点代码就能够将契约用起来,并实际的监督接口的修改:

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()))); }

  当发送GET请求到/api/feeds上以后,咱们指望返回状态是200,而后内容是application/json。而后咱们预期返回的结果是一个长度为3的数组,而后数组中的第一个元素的publishDate字段不为空。

  注意此处的prepareFeeds方法,事实上它会去加载mocks/feeds.json文件 — 也就是前端用来测试的mock文件:

private Feed[] prepareFeeds() throws IOException {
    URL resource = getClass().getResource("/mocks/feeds.json"); ObjectMapper mapper = new ObjectMapper(); return mapper.readValue(resource, Feed[].class); }

  这样,当后端修改Feed定义(添加/删除/修改字段),或者修改了mock数据等,都会致使测试失败;而前端修改mock以后,也会致使测试失败 — 不要害怕失败 — 这样的失败会促进一次协商,并驱动出最终的service的契约。

  对应的,测试/api/fav-feeds/{userId}的方式相似:

@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()))); }

  总结

  先后端分离是一件容易的事情,并且团队可能在短时间能够看到不少好处,可是若是不认真处理集成的问题,分离反而可能会带来更长的集成时间。经过面向契约的方式来组织各自的测试,能够带来不少的好处:更快速的End2End测试,更平滑的集成,更安全的分离开发等等。

  代码

  先后端的代码我都放到了Gitbub上,感兴趣的能够clone下来自行研究:

  1. bookmarks-frontend
  2. bookmarks-server

 

http://kb.cnblogs.com/page/524041/

相关文章
相关标签/搜索