作者:邱俊濤(@正反反長)
網址: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出來,以供前端程式碼使用。這樣的好處很多:
- 前後端開發相對獨立
- 後端的進度不會影響前端開發
- 啟動速度更快
- 前後端都可以使用自己熟悉的技術棧(讓前端的學maven,讓後端的用gulp都會很不順手)
但是當整合依然是一個令人頭疼的難題。我們往往在整合的時候才發現,本來協商的資料結構變了:deliveryAddress欄位本來是一個字串,現在變成陣列了(業務發生了變更,系統現在可以支援多個快遞地址);price欄位變成字串,協商的時候是number;使用者郵箱地址多了一個層級等等。這些變動在所難免,而且時有發生,這會花費大量的除錯時間和整合時間,更別提修改之後的回歸測試了。
所以僅僅使用一個靜態伺服器,然後提供mock資料是遠遠不夠的。我們需要的mock應該還能做到:
- 前端依賴指定格式的mock資料來進行UI開發
- 前端的開發和測試都基於這些mock資料
- 後端產生指定格式的mock資料
- 後端需要測試來確保生成的mock資料正是前端需要的
簡而言之,我們需要商定一些契約,並將這些契約作為可以被測試的中間格式。然後前後端都需要有測試來使用這些契約。一旦契約發生變化,則另一方的測試會失敗,這樣就會驅動雙方協商,並降低整合時的浪費。
一個實際的場景是:前端發現已有的某個契約中,缺少了一個address的欄位,於是就在契約中添加了該欄位。然後在UI上將這個欄位正確的展現了(當然還設定了字型,字號,顏色等等)。但是後臺生成該契約的服務並沒有感知到這一變化,當執行生成契約部分測試(後臺)時,測試會失敗了 — 因為它並沒有生成這個欄位。於是後端工程師就找前端來商量,瞭解業務邏輯之後,他會修改程式碼,並保證測試透過。這樣,當整合的時候,就不會出現UI上少了一個欄位,但是誰也不知道是前端問題,後端問題,還是資料庫問題等。
而且實際的專案中,往往都是多個頁面,多個API,多個版本,多個團隊同時進行開發,這樣的契約會降低非常多的除錯時間,使得整合相對平滑。
在實踐中,契約可以定義為一個JSON檔案,或者一個XML的payload。只需要保證前後端共享同一個契約集合來做測試,那麼整合工作就會從中受益。一個最簡單的形式是:提供一些靜態的mock檔案,而前端所有發往後臺的請求都被某種機制攔截,並轉換成對該靜態資源的請求。
- moco,基於Java
- wiremock,基於Java
- 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的單元測試了:
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}的方式類似:
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下來自行研究:
- bookmarks-frontend:https://github.com/abruzzi/bookmarks-frontend
- bookmarks-server:https://github.com/abruzzi/bookmarks-server