前言html
先後端分離已是業界所共識的一種開發/部署模式了。所謂的先後端分離,並非傳統行業中的按部門劃分,一部分人純作前端(HTML/CSS/JavaScript/Flex),另外一部分人純作後端,由於這種方式是不工做的:好比不少團隊採起了後端的模板技術(JSP, FreeMarker, ERB等等),前端的開發和調試須要一個後臺Web容器的支持,從而沒法作到真正的分離(更不用提在部署的時候,因爲動態內容和靜態內容混在一塊兒,當設計動態靜態分流的時候,處理起來很是麻煩)。關於先後端開發的另外一個討論能夠參考這裏。前端
即便經過API來解耦前端和後端開發過程,先後端經過RESTFul
的接口來通訊,前端的靜態內容和後端的動態計算分別開發,分別部署,集成仍然是一個繞不開的問題 — 前端/後端的應用均可以獨立的運行,可是集成起來卻不工做。咱們須要花費大量的精力來調試,直到上線前仍然沒有人有信心全部的接口都是工做的。python
一個典型的Web應用的佈局看起來是這樣的:jquery
先後端都各自有本身的開發流程,構建工具,測試集合等等。先後端僅僅經過接口來編程,這個接口多是JSON格式的RESTFul的接口,也多是XML的,重點是後臺只負責數據的提供和計算,而徹底不處理展示。而前端則負責拿到數據,組織數據並展示的工做。這樣結構清晰,關注點分離,先後端會變得相對獨立並鬆耦合。git
上述的場景仍是比較理想,咱們事實上在實際環境中會有很是複雜的場景,好比異構的網絡,異構的操做系統等等:github
在實際的場景中,後端可能還會更復雜,好比用C語言作數據採集,而後經過Java整合到一個數據倉庫,而後該數據倉庫又有一層Web Service,最後若干個這樣的Web Service又被一個Ruby的聚合Service整合在一塊兒返回給前端。在這樣一個複雜的系統中,後臺任意端點的失敗均可能阻塞前端的開發流程,所以咱們會採用mock的方式來解決這個問題:web
這個mock
服務器能夠啓動一個簡單的HTTP服務器,而後將一些靜態的內容serve出來,以供前端代碼使用。這樣的好處不少:spring
可是當集成依然是一個使人頭疼的難題。咱們每每在集成的時候才發現,原本協商的數據結構變了:deliveryAddress
字段原本是一個字符串,如今變成數組了(業務發生了變動,系統如今能夠支持多個快遞地址);price
字段變成字符串,協商的時候是number
;用戶郵箱地址多了一個層級等等。這些變更在所不免,並且時有發生,這會花費大量的調試時間和集成時間,更別提修改以後的迴歸測試了。數據庫
因此僅僅使用一個靜態服務器,而後提供mock
數據是遠遠不夠的。咱們須要的mock
應該還能作到:編程
簡而言之,咱們須要商定一些契約,並將這些契約做爲能夠被測試的中間格式。而後先後端都須要有測試來使用這些契約。一旦契約發生變化,則另外一方的測試會失敗,這樣就會驅動雙方協商,並下降集成時的浪費。
一個實際的場景是:前端發現已有的某個契約中,缺乏了一個address
的字段,因而就在契約中添加了該字段。而後在UI上將這個字段正確的展示了(固然還設置了字體,字號,顏色等等)。可是後臺生成該契約的服務並無感知到這一變化,當運行生成契約部分測試(後臺)時,測試會失敗了 — 由於它並無生成這個字段。因而後端工程師就找前端來商量,瞭解業務邏輯以後,他會修改代碼,並保證測試經過。這樣,當集成的時候,就不會出現UI上少了一個字段,可是誰也不知道是前端問題,後端問題,仍是數據庫問題等。
並且實際的項目中,每每都是多個頁面,多個API,多個版本,多個團隊同時進行開發,這樣的契約會下降很是多的調試時間,使得集成相對平滑。
在實踐中,契約能夠定義爲一個JSON文件,或者一個XML的payload。只須要保證先後端共享同一個契約集合來作測試,那麼集成工做就會從中受益。一個最簡單的形式是:提供一些靜態的mock
文件,而前端全部發日後臺的請求都被某種機制攔截,並轉換成對該靜態資源的請求。
看到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
關於如何編寫這樣的測試,能夠參考以前寫的這篇文章。
我在這個示例中,後端採用了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下來自行研究: