每一个开发人员使用各自的代码风格编写接口访

作者: 前端  发布:2019-11-09

前言

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

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

  后端开发

  我在这个示例中,后端采用了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())));
}

基于NodeJS的前后端分离的思考与实践(三)轻量级的接口配置建模框架,nodejs建模

前言

使用Node做前后端分离的开发模式带来了一些性能及开发流程上的优势, 但同时也面临不少挑战。在淘宝复杂的业务及技术架构下,后端必须依赖Java搭建基础架构,同时提供相关业务接口供前端使用。Node在整个环境中最重要的工作之一就是代理这些业务接口,以方便前端(Node端和浏览器端)整合数据做页面渲染。如何做好代理工作,使得前后端开发分离之后,仍然可以在流程上无缝衔接,是我们需要考虑的问题。本文将就该问题做相关探讨,并提出解决方案。

图片 1

由于后端提供的接口方式可能多种多样,同时开发人员在编写Node端代码访问这些接口的方式也有可能多种多样。如果我们在接口访问方式及使用上不做统一架构处理,则会带来以下一些问题:

1. 每一个开发人员使用各自的代码风格编写接口访问代码,造成工程目录及编码风格混乱,维护相对困难。
2. 每一个开发人员编写自己的mock数据方式,开发完毕之后,需要手工修改代码移除mock。
3. 每一个开发人员为了实现接口的不同环境切换(日常,预发,线上),可能各自维护了一些配置文件。

  1. 数据接口调用方式无法被各个业务model非常方便地复用。
    5. 对于数据接口的描述约定散落在代码的各个角落,有可能跟后端人员约定的接口文档不一致。
    6. 整个项目分离开发之后,对于接口的联调或者测试回归成本依然很高,需要涉及到每一个接口提供者和使用者。
    于是我们希望有这样一个框架,通过该框架提供的机制去描述工程项目中依赖的所有外部接口,对他们进行统一管理,同时提供灵活的接口建模及调用方式,并且提供便捷的线上环境和生产环境切换方法,使前后端开发无缝结合。ModelProxy就是满足这样要求的轻量级框架,它是Midway Framework 核心构件之一,也可以单独使用。使用ModelProxy可以带来如下优点:

  2. 不同的开发者对于接口访问代码编写方式统一,含义清晰,降低维护难度。
    2. 框架内部采用工厂+单例模式,实现接口一次配置多次复用。并且开发者可以随意定制组装自己的业务Model(依赖注入)。

  3. 可以非常方便地实现线上,日常,预发环境的切换。
  4. 内置river-mock和mockjs等mock引擎,提供mock数据非常方便。
    5. 使用接口配置文件,对接口的依赖描述做统一的管理,避免散落在各个代码之中。
    6. 支持浏览器端共享Model,浏览器端可以使用它做前端数据渲染。整个代理过程对浏览器透明。
    7. 接口配置文件本身是结构化的描述文档,可以使用river工具集合,自动生成文档。也可使用它做相关自动化接口测试,使整个开发过程形成一个闭环。

ModelProxy工作原理图及相关开发过程图览

图片 2

在上图中,开发者首先需要将工程项目中所有依赖的后端接口描述,按照指定的json格式,写入interface.json配置文件。必要时,需要对每个接口编写一个规则文件,也即图中interface rules部分。该规则文件用于在开发阶段mock数据或者在联调阶段使用River工具集去验证接口。规则文件的内容取决于采用哪一种mock引擎(比如 mockjs, river-mock 等等)。配置完成之后,即可在代码中按照自己的需求创建自己的业务model。

下面是一个简单的例子:

【例一】

第一步 在工程目录中创建接口配置文件interface.json, 并在其中添加主搜接口json定义

复制代码 代码如下:

{
    "title": "pad淘宝项目数据接口集合定义",
    "version": "1.0.0",
    "engine": "mockjs",
    "rulebase": "./interfaceRules/",
    "status": "online",
    "interfaces": [ {
        "name": "主搜索接口",
        "id": "Search.getItems",
        "urls": {
            "online": ""
        }
    } ]
}

第二步 在代码中创建并使用model

复制代码 代码如下:

// 引入模块
var ModelProxy = require( 'modelproxy' );

// 全局初始化引入接口配置文件  (注意:初始化工作有且只有一次)
ModelProxy.init( './interface.json' );

// 创建model 更多创建模式请参后文
var searchModel = new ModelProxy( {
    searchItems: 'Search.getItems'  // 自定义方法名: 配置文件中的定义的接口ID
} );

// 使用model, 注意: 调用方法所需要的参数即为实际接口所需要的参数。
searchModel.searchItems( { q: 'iphone6' } )
    // !注意 必须调用 done 方法指定回调函数,来取得上面异步调用searchItems获得的数据!
    .done( function( data ) {
        console.log( data );
    } )
    .error( function( err ) {
        console.log( err );
    } );

ModelProxy的功能丰富性在于它支持各种形式的profile以创建需要业务model:

使用接口ID创建>生成的对象会取ID最后'.'号后面的单词作为方法名

复制代码 代码如下:

ModelProxy.create( 'Search.getItem' );

使用键值JSON对象>自定义方法名: 接口ID

复制代码 代码如下:

ModelProxy.create( {
    getName: 'Session.getUserName',
    getMyCarts: 'Cart.getCarts'
} );

使用数组形式>取最后 . 号后面的单词作为方法名
下例中生成的方法调用名依次为: Cart_getItem, getItem, suggest, getName

复制代码 代码如下:

ModelProxy.create( [ 'Cart.getItem', 'Search.getItem', 'Search.suggest', 'Session.User.getName' ] );

前缀形式>所有满足前缀的接口ID会被引入对象,并取其后半部分作为方法名

复制代码 代码如下:

ModelProxy.create( 'Search.*' );

同时,使用这些Model,你可以很轻易地实现合并请求或者依赖请求,并做相关模板渲染

【例二】 合并请求

复制代码 代码如下:

var model = new ModelProxy( 'Search.*' );

// 合并请求 (下面调用的model方法除done之外,皆为配置接口id时指定)
model.suggest( { q: '女' } )
    .list( { keyword: 'iphone6' } )
    .getNav( { key: '流行服装' } )
    .done( function( data1, data2, data3 ) {
        // 参数顺序与方法调用顺序一致
        console.log( data1, data2, data3 );
    } );

【例三】 依赖请求

复制代码 代码如下:

var model = new ModelProxy( {
    getUser: 'Session.getUser',
    getMyOrderList: 'Order.getOrder'
} );
// 先获得用户id,然后再根据id号获得订单列表
model.getUser( { sid: 'fdkaldjfgsakls0322yf8' } )
    .done( function( data ) {
        var uid = data.uid;
        // 二次数据请求依赖第一次取得的id号
        this.getMyOrderList( { id: uid } )
            .done( function( data ) {
                console.log( data );
            } );
    } );

此外ModelProxy不仅在Node端可以使用,也可以在浏览器端使用。只需要在页面中引入官方包提供的modelproxy-client.js即可。
【例四】浏览器端使用ModelProxy

复制代码 代码如下:

<!-- 引入modelproxy模块,该模块本身是由KISSY封装的标准模块-->
<script src="modelproxy-client.js" ></script>
<script type="text/javascript">
    KISSY.use( "modelproxy", function( S, ModelProxy ) {
        // !配置基础路径,该路径与第二步中配置的拦截路径一致!
        // 且全局配置有且只有一次!
        ModelProxy.configBase( '/model/' );

        // 创建model
        var searchModel = ModelProxy.create( 'Search.*' );
        searchModel
            .list( { q: 'ihpone6' } )
            .list( { q: '冲锋衣' } )
            .suggest( { q: 'i' } )
            .getNav( { q: '滑板' } )
            .done( function( data1, data2, data3, data4 ) {
                console.log( {
                    "list_ihpone6": data1,
                    "list_冲锋衣": data2,
                    "suggest_i": data3,
                    "getNav_滑板": data4
                } );
            } );
    } );
</script>

同时,ModelProxy可以配合Midway另一核心组件Midway-XTPL一起使用,实现数据和模板以及相关渲染过程在浏览器端和服务器端的全共享。关于ModelProxy的详细教程及文档请移步

总结

ModelProxy以一种配置化的轻量级框架存在,提供友好的接口model组装及使用方式,同时很好的解决前后端开发模式分离中的接口使用规范问题。在整个项目开发过程中,接口始终只需要定义描述一次,前端开发人员即可引用,同时使用River工具自动生成文档,形成与后端开发人员的契约,并做相关自动化测试,极大地优化了整个软件工程开发过程。

【注】River 是阿里集团研发的前后端统一接口规范及相关工具集合的统称

前后端分离了,然后呢?

2015/07/09 · CSS, HTML5, JavaScript · 2 评论 · 前后端

原文出处: 邱俊涛的博客(@正反反长)   

  总结

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

前言 使用Node做前后端分离的开发模式带来了一些性能及...

前端开发

根据通常的做法,前后端分离之后,我们很容易mock一些数据来自己测试:

XHTML

[ { "id": 1, "url": "", "title": "Python中的 list comprehension 以及 generator", "publicDate": "2015年3月20日" }, { "id": 2, "url": "", "title": "使用inotify/fswatch构建自动监控脚本", "publicDate": "2015年2月1日" }, { "id": 3, "url": "", "title": "使用underscore.js构建前端应用", "publicDate": "2015年1月20日" } ]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
    {
        "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来测试前台:

JavaScript

$(function() { $.get('/mocks/feeds.json').then(function(feeds) { var feedList = new Backbone.Collection(extended); var feedListView = new FeedListView(feedList); $('.container').append(feedListView.render()); }); });

1
2
3
4
5
6
7
8
$(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的转换:

Shell

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

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

这样,当我们和实际的服务进行集成时,只需要连接到那个服务器就可以了。

注意,我们现在的核心是mocks/feeds.json这个文件。这个文件现在的角色就是一个契约,至少对于前端来说是这样的。紧接着,我们的应用需要渲染加星的功能,这就需要另外一个契约:找出当前用户加星过的所有条目,因此我们加入了一个新的契约:

XHTML

[ { "id": 3, "url": "", "title": "使用underscore.js构建前端应用", "publicDate": "2015年1月20日" } ]

1
2
3
4
5
6
7
8
[
    {
        "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中加入一个新的映射:

XHTML

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

1
2
3
4
get '/api/fav-feeds/:id' do
  content_type 'application/json'
  File.open('mocks/fav-feeds.json').read
end

通过这两个请求,我们会得到两个列表,然后根据这两个列表的交集来绘制出所有的星号的状态(有的是空心,有的是实心):

JavaScript

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

1
2
3
4
5
6
7
8
9
10
11
$.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());
});

剩下的一个问题是当点击红心时,我们需要发请求给后端,然后更新红心的状态:

JavaScript

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

1
2
3
4
5
6
7
8
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我们还是可以很容易的支持它:

JavaScript

post '/api/feeds/:id' do end

1
2
post '/api/feeds/:id' do
end

可以看到,在没有后端的情况下,我们一切都进展顺利 — 后端甚至还没有开始做,或者正在由一个进度比我们慢的团队在开发,不过无所谓,他们不会影响我们的。

不仅如此,当我们写完前端的代码之后,可以做一个End2End的测试。由于使用了mock数据,免去了数据库和网络的耗时,这个End2End的测试会运行的非常快,并且它确实起到了端到端的作用。这些测试在最后的集成时,还可以用来当UI测试来运行。所谓一举多得。

JavaScript

#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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#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

图片 3

关于如何编写这样的测试,可以参考之前写的这篇文章。

  一个例子

  我们以这个应用为示例,来说明如何在前后端分离之后,保证代码的质量,并降低集成的成本。这个应用场景很简单:所有人都可以看到一个条目列表,每个登陆用户都可以选择自己喜欢的条目,并为之加星。加星之后的条目会保存到用户自己的个人中心中。用户界面看起来是这样的:

图片 4

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

一个例子

我们以这个应用为示例,来说明如何在前后端分离之后,保证代码的质量,并降低集成的成本。这个应用场景很简单:所有人都可以看到一个条目列表,每个登陆用户都可以选择自己喜欢的条目,并为之加星。加星之后的条目会保存到用户自己的个人中心中。用户界面看起来是这样的:

图片 5

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

  一点背景

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

图片 6

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

  上述的场景还是比较理想,我们事实上在实际环境中会有非常复杂的场景,比如异构的网络,异构的操作系统等等:

图片 7

  在实际的场景中,后端可能还会更复杂,比如用C语言做数据采集,然后通过Java整合到一个数据仓库,然后该数据仓库又有一层Web Service,最后若干个这样的Web Service又被一个Ruby的聚合Service整合在一起返回给前端。在这样一个复杂的系统中,后台任意端点的失败都可能阻塞前端的开发流程,因此我们会采用mock的方式来解决这个问题:

图片 8

  这个mock服务器可以启动一个简单的HTTP服务器,然后将一些静态的内容serve出来,以供前端代码使用。这样的好处很多:

  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更加易读,所以就采用了它。

后端开发

我在这个示例中,后端采用了spring-boot作为示例,你应该可以很容易将类似的思路应用到Ruby或者其他语言上。

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

JavaScript

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@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之后,我们如何测试它呢?或者说,如何让契约变得实际可用呢?

spring-test提供了非常优美的DSL来编写测试,我们仅需要一点代码就可以将契约用起来,并实际的监督接口的修改:

Java

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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的单元测试了:

JavaScript

<a href='; 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()))); }

1
2
3
4
5
6
7
8
9
10
<a href='http://www.jobbole.com/members/madao'>@Test</a>
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文件:

JavaScript

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

1
2
3
4
5
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}的方式类似:

JavaScript

<a href='; 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()))); }

1
2
3
4
5
6
7
8
9
10
11
12
<a href='http://www.jobbole.com/members/madao'>@Test</a>
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测试,更平滑的集成,更安全的分离开发等等。

 

一点背景

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

图片 9

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

上述的场景还是比较理想,我们事实上在实际环境中会有非常复杂的场景,比如异构的网络,异构的操作系统等等:

图片 10

在实际的场景中,后端可能还会更复杂,比如用C语言做数据采集,然后通过Java整合到一个数据仓库,然后该数据仓库又有一层Web Service,最后若干个这样的Web Service又被一个Ruby的聚合Service整合在一起返回给前端。在这样一个复杂的系统中,后台任意端点的失败都可能阻塞前端的开发流程,因此我们会采用mock的方式来解决这个问题:

图片 11

这个mock服务器可以启动一个简单的HTTP服务器,然后将一些静态的内容serve出来,以供前端代码使用。这样的好处很多:

  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更加易读,所以就采用了它。

  前端开发

  根据通常的做法,前后端分离之后,我们很容易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

图片 12

  关于如何编写这样的测试,可以参考之前写的这篇文章。

代码

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

  1. bookmarks-frontend
  2. bookmarks-server

    1 赞 5 收藏 2 评论

图片 13

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

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

  代码

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

  1. bookmarks-frontend
  2. bookmarks-server

 

 

本文由9159.com发布于前端,转载请注明出处:每一个开发人员使用各自的代码风格编写接口访

关键词: