先後端分離了,而後呢?(轉)

 

 前言

  先後端分離已是業界所共識的一種開發/部署模式了。所謂的先後端分離,並非傳統行業中的按部門劃分,一部分人純作前端(HTML/CSS/JavaScript/Flex),另外一部分人純作後端,由於這種方式是不工做的:好比不少團隊採起了後端的模板技術(JSP, FreeMarker, ERB等等),前端的開發和調試須要一個後臺Web容器的支持,從而沒法作到真正的分離(更不用提在部署的時候,因爲動態內容和靜態內容混在一塊兒,當設計動態靜態分流的時候,處理起來很是麻煩)。關於先後端開發的另外一個討論能夠參考這裏html

  即便經過API來解耦前端和後端開發過程,先後端經過RESTFul的接口來通訊,前端的靜態內容和後端的動態計算分別開發,分別部署,集成仍然是一個繞不開的問題 — 前端/後端的應用均可以獨立的運行,可是集成起來卻不工做。咱們須要花費大量的精力來調試,直到上線前仍然沒有人有信心全部的接口都是工做的。前端

  一點背景

  一個典型的Web應用的佈局看起來是這樣的:python

typical web application

  先後端都各自有本身的開發流程,構建工具,測試集合等等。先後端僅僅經過接口來編程,這個接口多是JSON格式的RESTFul的接口,也多是XML的,重點是後臺只負責數據的提供和計算,而徹底不處理展示。而前端則負責拿到數據,組織數據並展示的工做。這樣結構清晰,關注點分離,先後端會變得相對獨立並鬆耦合。jquery

  上述的場景仍是比較理想,咱們事實上在實際環境中會有很是複雜的場景,好比異構的網絡,異構的操做系統等等:git

real word application

  在實際的場景中,後端可能還會更復雜,好比用C語言作數據採集,而後經過Java整合到一個數據倉庫,而後該數據倉庫又有一層Web Service,最後若干個這樣的Web Service又被一個Ruby的聚合Service整合在一塊兒返回給前端。在這樣一個複雜的系統中,後臺任意端點的失敗均可能阻塞前端的開發流程,所以咱們會採用mock的方式來解決這個問題:github

mock application

  這個mock服務器能夠啓動一個簡單的HTTP服務器,而後將一些靜態的內容serve出來,以供前端代碼使用。這樣的好處不少:web

  1. 先後端開發相對獨立
  2. 後端的進度不會影響前端開發
  3. 啓動速度更快
  4. 先後端均可以使用本身熟悉的技術棧(讓前端的學maven,讓後端的用gulp都會很不順手)

  可是當集成依然是一個使人頭疼的難題。咱們每每在集成的時候才發現,原本協商的數據結構變了:deliveryAddress字段原本是一個字符串,如今變成數組了(業務發生了變動,系統如今能夠支持多個快遞地址);price字段變成字符串,協商的時候是number;用戶郵箱地址多了一個層級等等。這些變更在所不免,並且時有發生,這會花費大量的調試時間和集成時間,更別提修改以後的迴歸測試了。spring

  因此僅僅使用一個靜態服務器,而後提供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/

相關文章
相關標籤/搜索