[toc]
前言
本文來自拉勾網(wǎng)課程整理
首先請你想一想:如果沒有一套靈活的可擴(kuò)展的系統(tǒng)架構(gòu)惨撇,結(jié)果會怎樣伊脓?
這方面我深有感觸,在我們的App
沒有良好的系統(tǒng)架構(gòu)之前魁衙,每一個微小的改動都需要“大動干戈”
报腔。具體來說,由于強(qiáng)耦合性剖淀,每次改動我們都需要和各個業(yè)務(wù)部門商討詳細(xì)的技術(shù)方案纯蛾;功能開發(fā)完畢后,又要協(xié)調(diào)各個部門進(jìn)行功能回歸測試纵隔。整個過程下來翻诉,不僅耗費(fèi)太多精力和時間炮姨,還容易在跨部門、跨團(tuán)隊溝通之間生出許多事來碰煌。
而一套良好的系統(tǒng)架構(gòu)舒岸,不僅僅是一款App
的基石,也是整套代碼庫的規(guī)范芦圾。有了良好的系統(tǒng)架構(gòu)吁津,業(yè)務(wù)功能開發(fā)者就能做到有據(jù)可依,團(tuán)隊之間的溝通變成十分順暢堕扶;各個功能團(tuán)隊之間也能并行開發(fā)碍脏,保證彼此快速迭代,提高效率稍算。
因此典尾,我們在推動工程化實踐的同時也需要不斷優(yōu)化系統(tǒng)架構(gòu)。在2017
年糊探,我和公司同事就設(shè)計與實現(xiàn)了一套基于原生技術(shù)的跨平臺系統(tǒng)架構(gòu)钾埂,能讓所有開發(fā)者同時在iOS
和 Android
平臺上工作。
如今這套架構(gòu)經(jīng)過不斷改進(jìn)科平,依然在使用褥紫。我們現(xiàn)在開發(fā)的 Moments App
,它所用的跨平臺系統(tǒng)架構(gòu)瞪慧,正是我吸取了當(dāng)初的經(jīng)驗與教訓(xùn)髓考,使用 BFF
和 MVVM
重新架構(gòu)與實現(xiàn)的。
這一章弃酌,我們主要先聊聊如何使用 BFF
(backend for frontend
氨菇,服務(wù)于前端的后端)來設(shè)計跨平臺的系統(tǒng)架構(gòu),以提高可重用性妓湘,進(jìn)而提升開發(fā)效率查蓉。MVVM
的設(shè)計與實現(xiàn),我會在后面幾章詳細(xì)介紹榜贴。
為什么使用 BFF 豌研?
我們的 Moments App
是一款類朋友圈的App
,隨著功能的不斷完善唬党,目前幾乎所有 App
的數(shù)據(jù)源都由多個微服務(wù)所支持鹃共。在 Moments App
中,后臺微服務(wù)包括:
- 用于用戶管理與鑒權(quán)的用戶服務(wù)
- 用于記錄朋友關(guān)系的朋友關(guān)系服務(wù)
- 用于拉黑管理的黑名單服務(wù)
- 用于記錄每條朋友圈信息的信息服務(wù)
- 用于頭像管理的頭像服務(wù)
- 用于點(diǎn)贊管理的點(diǎn)贊服務(wù)等等初嘹。
當(dāng)我們需要呈現(xiàn)朋友圈界面時及汉,App
需要給各個微服務(wù)發(fā)送請求,然后把返回的信息整理屯烦,合并和轉(zhuǎn)換成我們所需要的信息進(jìn)行呈現(xiàn)坷随。
這些網(wǎng)絡(luò)請求的順序和邏輯非常復(fù)雜房铭。有些請求需要串行處理,例如只有完成了
用戶服務(wù)的請求以后温眉,才能繼續(xù)其他請求缸匪;而有些請求卻可以并行發(fā)送,比如在得到信
息服務(wù)的返回結(jié)果以后类溢,可以同時
向頭像服務(wù)和點(diǎn)贊服務(wù)發(fā)送請求凌蔬。
接著,在得到了所有結(jié)果以后闯冷,Ap
p 需要整理和合并數(shù)據(jù)的邏輯也非常復(fù)雜
砂心,如果請求返回結(jié)果的順序不一致,往往會導(dǎo)致程序出錯蛇耀。于是辩诞,為了解決這一系列的問題,我們引入了 BFF
服務(wù)纺涤。
BFF
是一個服務(wù)于不同前端的后臺服務(wù)译暂,所有的前端(比如 iOS
, Android
和Web
) 都依賴它撩炊。而且 BFF
是一個整合服務(wù)外永,它負(fù)責(zé)把前端的請求統(tǒng)一分發(fā)到各個具體的微服務(wù)上
,然后把返回數(shù)據(jù)整合在一起
統(tǒng)一返回給前端拧咳。
可以說伯顶,有了 BFF
,我們的 App
就不再需要往多個微服務(wù)發(fā)送請求呛踊,也不再需要處理復(fù)雜的并發(fā)請求砾淌,這樣就有效減低了復(fù)雜度,避免競態(tài)條件等非預(yù)期情況發(fā)生谭网。除此以外, 使用BFF
還有以下好處赃春。
首先愉择,App
僅需依賴一個 BFF
微服務(wù),就能有效地管理 App
對微服務(wù)的依賴织中。眾所周知锥涕,當(dāng) App
版本發(fā)布以后,我們沒有辦法強(qiáng)迫用戶更新他們設(shè)備上的 App
狭吼,如果我們需要變動某個微服務(wù)的地址层坠,原有的 App
將無法訪問新的微服務(wù)地址,但是有了 BFF
以后刁笙,我們可以通過 BFF
統(tǒng)一路由到新的微服務(wù)去破花。
第二谦趣,不同的微服務(wù)可能提供不一樣
的數(shù)據(jù)傳輸方式,例如有的提供 RESI API
座每,有的提供 gRPC
前鹅,而有的提供 GraphQL
。在沒有 BFF
的情況下峭梳,App
端必須實現(xiàn)各個技術(shù)棧來訪問各個微服務(wù)舰绘。一旦有了 BFF
以后,App
只需要支持一種傳輸方式葱椭,極大減輕移動端開發(fā)和維護(hù)成本捂寿。
第三,由于 BFF
統(tǒng)一處理所有的數(shù)據(jù)孵运,iOS
和 Android
兩端都可以得到由 BFF
清理并轉(zhuǎn)換好的數(shù)據(jù)者蠕,無須在各端重復(fù)開發(fā)一樣的數(shù)據(jù)處理代碼。這極大減少了工作量掐松,讓我們可以把重心放在提高用戶體驗
上踱侣。
第四,BFF
在提升整套系統(tǒng)安全性的同時大磺,提高整體性能抡句。
具體來說,因為我們的 App
是通過公網(wǎng)連接到后臺微服務(wù)的杠愧,所有微服務(wù)都需要公開給所有外部系統(tǒng)進(jìn)行訪問待榔。這就會面臨隱私信息暴露等安全問題,比如用戶會通過 App
獲得本來不應(yīng)該公開的黑名單信息流济。
但我們引入 BFF
以后锐锣,可以為微服務(wù)配置安全規(guī)則(如 AWS
上的 Security Group
)只允許 BFF
能訪問,例如上述的黑名單管理服務(wù)绳瘟,就可以設(shè)置除了 BFF
以外不允許任何其他外部系統(tǒng)(包括我們的 App
)直接訪問雕憔,從而有效保證了隱私信息與公網(wǎng)的隔離。
與此同時糖声, BFF
還可以同步訪問多個不同的數(shù)據(jù)源斤彼,統(tǒng)一管理數(shù)據(jù)緩存,這無疑能有效提升整套系統(tǒng)的性能蘸泻。
BFF 的技術(shù)選型——GraphQL
既然 BFF
那么好用琉苇,那應(yīng)該怎樣實現(xiàn)一個 BFF
服務(wù)呢?我經(jīng)過多個項目的實踐總結(jié)發(fā)現(xiàn)悦施,GraphQL
是目前實現(xiàn) BFF
架構(gòu)的最優(yōu)方案并扇。
什么是 GraphQL?
具體來說抡诞,和 REST API
穷蛹,gRPC
以及 SOAP
相比土陪, GraphQL
架構(gòu)有以下幾大優(yōu)點(diǎn)。
-
GraphQL
允許客戶端按自身的需要通過Query
來請求不同數(shù)據(jù)集俩莽,而不像REST API
和gRPC
那樣每次都是返回全部數(shù)據(jù)旺坠,這樣能有效減輕網(wǎng)絡(luò)負(fù)載。 -
GraphQL
能減輕為各客戶端開發(fā)單獨(dú)Endpoint
的工作量扮超。比如當(dāng)我們開發(fā)App Clip
的時候取刃,App Clip
可以在Query
中以指定子數(shù)據(jù)集的方式來使用和主App
相同的Query
,而無須重新開發(fā)新Endpoint
出刷。 -
GraphQL
服務(wù)能根據(jù)客戶端的Query
來按需請求數(shù)據(jù)源璧疗,避免無必要的數(shù)據(jù)請求,減輕服務(wù)端的負(fù)載馁龟。
下面我們以一個例子來看看GraphQL
是怎樣處理不同的 Query
的崩侠。
假設(shè)我們要開發(fā)一個顯示某大 V
朋友圈的 App Clip
,當(dāng)用戶使用 App Clip
時不需要鑒權(quán)坷檩,不必
查看黑名單却音,就直接可以看到該大 V
的朋友圈信息,那么我們在訪問GraphQL
的流程會就簡化了(如下圖所示)矢炼。
和我們的主App
請求相比系瓢,App Clip
不需要顯示點(diǎn)贊信息,返回的結(jié)果就可以精簡了句灌。而且由于不需要進(jìn)行鑒權(quán)夷陋,也不需要查詢朋友關(guān)系、黑名單和點(diǎn)贊等信息胰锌,BFF
也無須向這些微服務(wù)發(fā)起請求骗绕,從而有效減輕了 BFF
服務(wù)的負(fù)載。
另外一方面资昧,和 REST API
相比酬土,GraphQL
的數(shù)據(jù)交換都由 Schema
統(tǒng)一管理,能有效減少由于數(shù)據(jù)類型和可空類型不匹配所導(dǎo)致的問題榛搔。
除此之外诺凡,GraphQL
還能減輕版本管理的工作量。因為 GraphQL
能支持返回不同數(shù)據(jù)集践惑,從而無須像 REST API
那樣為每個新功能不斷地更新 Endpoint
的版本號。
如何使用 GraphQL 實現(xiàn) BFF
既然我們確定了 GraphQL
嘶卧,那需要選擇一個服務(wù)框架來幫我們實現(xiàn)尔觉。具體怎么實現(xiàn)呢?為了方便演示芥吟,我選擇了 Apollo Serve
侦铜。
Apollo Serve
是基于 Node.js
的 GraphQL
服務(wù)器专甩,目前非常流行。使用它钉稍,可以很方便地結(jié)合 Express
等 Web
服務(wù)涤躲,而且還可以部署到亞馬遜Lambda
,微軟 Azure Functions
等 Serverless
服務(wù)上贡未。
再加上 Apollo Serve
在我們公司的生產(chǎn)環(huán)境上使用多年种樱,一直穩(wěn)定地支撐著 App
正常運(yùn)行,因為比較熟悉俊卤,所以我就選了它嫩挤。
下面一起看看具體怎么做。
第一步消恍,使用 GraphQL
岂昭,我們先要為前后端傳遞的數(shù)據(jù)定義 schema。 在這里我寫了 Moment
類型的部分 Schema
定義狠怨。比如在 Moment
類型里约啊,我定義了 id,type佣赖,title
和 user details
等屬性恰矩,其中 user details
屬性的類型是 User Details
,它定義了 name
和 avatar
等屬性茵汰。其的代碼示例如下所示枢里。
enum MomentType {
URL
PHOTOS
}
type Moment {
id: ID!
userDetails: UserDetails!
type: MomentType!
title: String # nullable
photos: [String!]! # non-nullable but can be empty
}
type UserDetails {
id: ID!
name: String!
avatar: String!
backgroundImage: String!
}
如果你想要查看完整定義,可以點(diǎn)擊倉庫中查看蹂午。
GraphQL
支持枚舉類型栏豺,比如上面的MomentType
就是一個枚舉類型,它只有兩個值URL
和PHOTOS
豆胸,在數(shù)據(jù)傳輸過程中奥洼,它們是通過字符串傳送給前端的。
Moment
是一個類型定義晚胡,在 Swift
中可以對應(yīng)成struct
灵奖,而在 Kotlin
中則對應(yīng)為data class
。這個類型有id估盘、userDetails
等屬性瓷患。這些屬性可以是基礎(chǔ)數(shù)據(jù)類型,如String遣妥、ID擅编、Int
等;也可以是自定義類型,如自定義的UserDetails
爱态。
當(dāng)數(shù)據(jù)類型后面有!
時谭贪,表示該屬性不能為null
。這其中需要注意一點(diǎn)锦担,那就是!在數(shù)組定義里面的使用俭识。比如photos: [String!]!
,表示該數(shù)組不能為null
洞渔,而且不能存放值為null
的數(shù)據(jù)套媚。而photos: [String!]
則表示photos
數(shù)組自身可能為null
,但還是不能存放值為null
的數(shù)據(jù) 痘煤。再來看photos: [String]!
凑阶,這表示photos
數(shù)組自己不可以為null
, 但是可以放值為null
的數(shù)據(jù)。
第二步衷快,有了 Schema 的定義以后宙橱,接下來我們可以定義 Query 和 Mutation,以便為 App 提供查詢和更新的接口蘸拔。
type Query {
getMomentsDetailsByUserID(userID: ID!): MomentsDetails!
}
這表示該 GraphQL
服務(wù)提供一個名叫getMomentsDetailsByUserID
的 Query
师郑,該Query
接受userID
作為入口參數(shù),并返回MomentsDetails
调窍。
一般 Query
只能用于查詢宝冕,如果要更新,則需要使用Mutation
邓萨,下面是一個 Mutation
的定義
type Mutation {
updateMomentLike(momentID: ID!, userID: ID!, isLiked: Boolean!): MomentsDetails!
}
其實 Mutation
是一個會更新狀態(tài)的Query
地梨,因為在更新后還是可以返回數(shù)據(jù)的。例如上例中updateMomentLike
接受了momentID
缔恳、userID
和isLiked
作為入口參數(shù)宝剖,在更新狀態(tài)后也可以返回MomentsDetails
。
第三步歉甚,有了以上的定義以后万细,我們可以借助 resolver 來查詢或者更新數(shù)據(jù)。
const resolvers = {
Query: {
getMomentsDetailsByUserID: (_, {userID}) => momentsDetails,
},
Mutation: {
updateMomentLike: (_, {momentID, userID, isLiked}) => {
for (const i in momentsDetails.moments) {
if (momentsDetails.moments[i].id === momentID) {
if (momentsDetails.moments[i].isLiked === isLiked) {
break
}
momentsDetails.moments[i].isLiked = isLiked;
if (isLiked) {
const likedUserDetails = getUserDetailsByID(userID)
momentsDetails.moments[i].likes.push(likedUserDetails);
} else {
// remove the item for that user
momentsDetails.moments[i].likes = momentsDetails.moments[i].likes.filter((item) => item.id !== userID);
}
break;
}
}
return momentsDetails;
}
}
};
resolvers
的大致邏輯是纸泄,在 get Moments Details By User ID
查詢里面赖钞,直接把momentsDetails
的數(shù)據(jù)返回。在 update moment like
更新里面聘裁,我們更新了momentsDetails 的 is Liked
屬性來表示用戶是否點(diǎn)贊雪营。在 Moments App
的 BFF
中,我們維護(hù)了一個內(nèi)存數(shù)據(jù)庫衡便,而在真實生產(chǎn)環(huán)境中卓缰,可以訪問 MySQL、MongoDB
來直接存儲數(shù)據(jù)砰诵,或者通過其他微服務(wù)來橋接數(shù)據(jù)庫的訪問征唬。
到此為止,我們就通過GraphQL
實現(xiàn)了一個 BFF
茁彭。 注意总寒,這只是一個例子,并不是每個 BFF
都必須通過 Apollo Server
以及 Node.js
來實現(xiàn)理肺。你可以根據(jù)所做團(tuán)隊成員的技能來挑選適合你們的技術(shù)棧摄闸。
比如,Kotlin
是一個不錯的選擇妹萨,因為大部分 Android
開發(fā)者都熟悉Kotlin
語言年枕,而且 Kotlin
還可以完美兼容JVM
。特別JVM
生態(tài)非常發(fā)達(dá)乎完,我們可以利用Kotlin
和基于JVM
的開源庫構(gòu)建穩(wěn)定的BFF
方案熏兄。
總結(jié)
這一章我介紹了如何使用 BFF
來設(shè)計跨平臺的系統(tǒng)架構(gòu),以及如何使用 GraphQL
實現(xiàn) BFF
树姨。雖然GraphQL
有眾多優(yōu)點(diǎn)摩桶,但并非十全十美,甚至可以說帽揪,世界上并沒有完美的技術(shù)硝清。所以,在使用 GraphQL
過程中转晰,我們需要注意以下兩點(diǎn)芦拿。
- 在定義
Schema
的過程中,需要前后臺開發(fā)者共同協(xié)商溝通查邢,特別要注意nullable
類型的處理蔗崎,如果前端定義有誤,很容易引起App
的崩潰侠坎。 -
GraphQL
通常使用HTTP POST
請求蚁趁,但有些CDN (content delivery network
,內(nèi)容分發(fā)網(wǎng)絡(luò))對POST
緩存支持不好实胸,當(dāng)我們把GraphQL
的請求換成GET
時他嫡,整個Query
會變成JSON-encoded
字符串并放在Query String
里面進(jìn)行發(fā)送。此時庐完,要特別注意該Query String
的長度不要超過CDN
所支持的長度限制(比如Akamai
支持最長的URL
是 8892 字節(jié))钢属,否則請求將會失敗。