歡迎光臨
每天分享高質量文章

前後端分離了,然後呢?

作者:邱俊濤(@正反反長)

網址:http://icodeit.org/2015/06/whats-next-after-separate-frontend-and-backend/


前言

前後端分離已經是業界所共識的一種開發/部署樣式了。所謂的前後端分離,並不是傳統行業中的按部門劃分,一部分人純做前端(HTML/CSS/JavaScript/Flex),另一部分人純做後端,因為這種方式是不工作的:比如很多團隊採取了後端的模板技術(JSP, FreeMarker, ERB等等),前端的開發和除錯需要一個後臺Web容器的支援,從而無法做到真正的分離(更不用提在部署的時候,由於動態內容和靜態內容混在一起,當設計動態靜態分流的時候,處理起來非常麻煩)。關於前後端開發的另一個討論可以參考這裡。

即使透過API來解耦前端和後端開發過程,前後端透過RESTFul的介面來通訊,前端的靜態內容和後端的動態計算分別開發,分別部署,整合仍然是一個繞不開的問題 — 前端/後端的應用都可以獨立的執行,但是整合起來卻不工作。我們需要花費大量的精力來除錯,直到上線前仍然沒有人有信心所有的介面都是工作的。

一點背景

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

前後端都各自有自己的開發流程,構建工具,測試集合等等。前後端僅僅透過介面來程式設計,這個介面可能是JSON格式的RESTFul的介面,也可能是XML的,重點是後臺只負責資料的提供和計算,而完全不處理展現。而前端則負責拿到資料,組織資料並展現的工作。這樣結構清晰,關註點分離,前後端會變得相對獨立並松耦合。

上述的場景還是比較理想,我們事實上在實際環境中會有非常複雜的場景,比如異構的網路,異構的作業系統等等:

在實際的場景中,後端可能還會更複雜,比如用C語言做資料採集,然後透過Java整合到一個資料倉庫,然後該資料倉庫又有一層Web Service,最後若干個這樣的Web Service又被一個Ruby的聚合Service整合在一起傳回給前端。在這樣一個複雜的系統中,後臺任意端點的失敗都可能阻塞前端的開發流程,因此我們會採用mock的方式來解決這個問題:

這個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

關於如何編寫這樣的測試,可以參考之前寫的這篇文章。

後端開發

我在這個示例中,後端採用了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 allFeeds() {

return feedsService.allFeeds();

}

@RequestMapping(value=”/fav-feeds/{userId}”, method = RequestMethod.GET)

@ResponseBody

public Iterable favFeeds(@PathVariable(“userId”) Long userId) {

return userService.favoriteFeeds(userId);

}

}

具體查詢的細節我們就不做討論了,感興趣的可以在文章結尾處找到程式碼庫的連結。那麼有了這個Controller之後,我們如何測試它呢?或者說,如何讓契約變得實際可用呢?

spring-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:https://github.com/abruzzi/bookmarks-frontend
  2. bookmarks-server:https://github.com/abruzzi/bookmarks-server

贊(0)

分享創造快樂