注:
本文首發(fā)于“愛奇藝技術(shù)產(chǎn)品團隊”公眾號。作為本文的作者,我將此文在自己的簡書上再次發(fā)表,希望更多人閱讀,并歡迎提出問題和意見。
本文介紹的設(shè)計并非最佳,而是考慮到歷史問題后的折中方案,歡迎大家提出自己的設(shè)計。
一、前言
互聯(lián)網(wǎng)是一個強調(diào)速度的行業(yè),各公司都希望自己的開發(fā)人員能盡可能快地完成需求開發(fā)。但隨著系統(tǒng)和業(yè)務(wù)復(fù)雜度的增加,單靠程序員加班也越來越難以滿足產(chǎn)品提出的業(yè)務(wù)需求,而且單純強調(diào)速度也會留下越來越多的技術(shù)債。那如何設(shè)計開發(fā)業(yè)務(wù)系統(tǒng)才能滿足速度和質(zhì)量的雙重要求呢?
解決這一問題的一個重點是系統(tǒng)的架構(gòu)設(shè)計是否具有良好的擴展性,是否能夠使后來的業(yè)務(wù)開發(fā)能夠輕松而又高質(zhì)量地完成。接下來我將從理論、實踐等幾個方面,介紹我們會員團隊是如何解決這一問題的。
二、微內(nèi)核模式
業(yè)務(wù)系統(tǒng)同其它軟件系統(tǒng)其實并無本質(zhì)的區(qū)別,所以在如何打造高擴展性的業(yè)務(wù)系統(tǒng)這個問題上,可以從優(yōu)秀的同行身上得到借鑒。其中一個很重要的借鑒是微內(nèi)核模式。
很多開源項目,如 Linux、Spring Framework 等,都有一個小而精的內(nèi)核。比如 Linux 的 Kernel、Spring Framework 的 ApplicationContext。這些軟件系統(tǒng)(或框架)在這個核心基礎(chǔ)之上,通過組件(插件)的形式實現(xiàn)更加豐富的功能。這樣的設(shè)計滿足了高內(nèi)聚低耦合、開閉等設(shè)計原則,這就是微內(nèi)核模式的重要意義。
那微內(nèi)核模式是什么呢?這個問題并沒有完整統(tǒng)一的答案。對于一個業(yè)務(wù)系統(tǒng)的微內(nèi)核,我理解它應(yīng)當定義出基本業(yè)務(wù)流程,僅包含基本的業(yè)務(wù)功能實現(xiàn)(最好基本功能也由插件的形式實現(xiàn),而業(yè)務(wù)流程最好也是可定制的)。并且,為業(yè)務(wù)流程上的每一步定義出組件定義。不同場景通過提供不同的組件接口實現(xiàn)來完成。
所以,歸納來說,業(yè)務(wù)系統(tǒng)的微內(nèi)核應(yīng)當做如下的事情:
- 流程定義
- 組件定義
- 組件調(diào)用
這樣,系統(tǒng)即可以實現(xiàn)基本的功能,又可以通過組件的靈活實現(xiàn)和組合滿足不同業(yè)務(wù)場景下的需求。
舉個例子
舉例來說,在電商交易的結(jié)算頁服務(wù)中,需要記載各種信息,包括購物車、收貨人等等信息。這些操作可以抽象為如下的接口:
interface ReceiverLoader {
// 根據(jù) userId 加載收貨人信息
Receiver load(Long userId);
}
interface InventoryChecker {
// 根據(jù)商品 SKU 查詢庫存數(shù)據(jù)
InventorySummary check(List<String> skus);
}
interface DeliveryLoader {
// 返回用戶和商品類型(實體、虛擬等)所對應(yīng)的可用的遞送方式
DeliverySummary load(Long userId, CommodityType commodityType);
}
interface PayTypeLoader {
// 根據(jù) userId 返回改用所能使用的支付方式
List<PayType> load(Long userId);
}
對于不同的電商業(yè)務(wù),上述功能會有不同的業(yè)務(wù)需求:
例如,實現(xiàn)收貨人信息加載功能時,如果是對應(yīng)實物購買,需要返回收貨人真實地址;如果是虛擬物品,則不需要真實地址。實現(xiàn)庫存查詢功能時,如果是自營的實體物品,需要查詢自己的庫存系統(tǒng);如果是商旅產(chǎn)品,則需要查詢對應(yīng)的航空公司、酒店等;如果是一些虛擬商品,則可能不需要查詢庫存,因為虛擬物品可能是無上限的。
通過為上述接口提供不同的實現(xiàn)類,再組合搭配,就可以靈活實現(xiàn)不同的場景。
三、組件的組合
上一節(jié)并沒有介紹微內(nèi)核如何將不同的組件組合在一起,如何調(diào)用,以實現(xiàn)不同場景下的需求。本節(jié)就來介紹這方面的內(nèi)容。
簡單來說,組件的組合方式分為兩類:
- 靜態(tài)組合
- 動態(tài)組合
靜態(tài)組合
首先介紹靜態(tài)組合方式。
所謂靜態(tài)組合,就是軟件在編譯部署之后,組件之間的關(guān)系不再發(fā)生變化的一種組合方式。
靜態(tài)組合的優(yōu)點是實現(xiàn)起來相對簡單,配置也比較簡單。缺點是只能用于一些變化不難么復(fù)雜的業(yè)務(wù)場景。當業(yè)務(wù)場景變的復(fù)雜多變,需要根據(jù)參數(shù)的不同靈活調(diào)用不同組件時,靜態(tài)配置就無法滿足需求了。
對靜態(tài)組合的一個簡單解釋可以通過下面這段 Spring XML 配置來呈現(xiàn):
<!-- 普通商品結(jié)算服務(wù) -->
<bean id="checkoutService" class="com.company.team.CheckoutServiceImpl">
<property name="receiverLoader" value="defaultReceiverLoader" />
<property name="inventoryChecker" value="defaultInventoryChecker" />
<property name="payTypeLoader" value="defaultPayTypeLoader" />
<!-- more properties -->
</bean>
<!-- 虛擬商品結(jié)算服務(wù) -->
<bean id="virtualCheckoutService" class="com.company.team.CheckoutServiceImpl">
<property name="receiverLoader" value="virtualReceiverLoader" />
<property name="inventoryChecker" value="virtualInventoryChecker" />
<property name="payTypeLoader" value="defaultPayTypeLoader" />
<!-- more properties -->
</bean>
上面例子中,兩個 bean 是用來實現(xiàn)結(jié)算服務(wù) (CheckoutService) 的,這兩個 bean 使用了同一個實現(xiàn)類 CheckoutServiceImpl。這個類可以看作是結(jié)算服務(wù)的內(nèi)核。它通過與不同的組件(ReceiverLoader、InventoryChecker、PayTypeLoader 等)組合,實現(xiàn)不同場景下的需求。
這樣的設(shè)計方式反映出了一個面向?qū)ο笤O(shè)計原則 —— 組合優(yōu)于繼承
但靜態(tài)組合因為在編譯部署之后不再變化,無法適應(yīng)更加復(fù)雜的業(yè)務(wù)場景,這是靜態(tài)組合的不足。
動態(tài)組合
上面講到了靜態(tài)組合的不足,所以我們需要另一種更加靈活的組合方式。這種組合方式我們可以稱為動態(tài)組合。
如何實現(xiàn)呢?
顯然,在直接使用條件語句調(diào)度組件是不合適的,原因在于它不滿足開閉原則,無法滿足我們對擴展性的要求。
要實現(xiàn)易于擴展的動態(tài)組合設(shè)計,可以使用一些設(shè)計模式:例如表驅(qū)動模式、職責(zé)鏈模式等等。具體如何使用這些設(shè)計模式實現(xiàn)動態(tài)組合這里就不介紹。接下來看看這么實現(xiàn)之后,開發(fā)人員如何去實現(xiàn)新的組件以實現(xiàn)新的業(yè)務(wù)場景:
class NewComponent implements Component {
@Autowired
private ComponentRegistry registry;
public boolean canProcess(Request request) {
return request.getParam1().equals("abc") && request.getParam2().equals("def");
}
@PostConstruct
public void init() {
register();
}
public void register() {
registry.register(Component.class, this);
}
}
上述形式的組件定義再結(jié)合職責(zé)鏈模式,就能夠?qū)崿F(xiàn)組件的動態(tài)組合和調(diào)度。但是,這樣的形式并不是很理想。原因在于:
- 組件的調(diào)度規(guī)則配置既不直觀也不方便
- 組件劃分的粒度無法得到統(tǒng)一
這樣的問題,放在比較復(fù)雜的系統(tǒng)里,就會導(dǎo)致系統(tǒng)難以理解和維護的問題。
那如何實現(xiàn)一個直觀方便,易于維護,能夠統(tǒng)一組件劃分粒度的動態(tài)組合方式呢?接下來講結(jié)合實踐給予介紹。
四、實踐
接下來介紹一下愛奇藝會員交易團隊在構(gòu)建易于擴展的業(yè)務(wù)系統(tǒng)方面的實踐。
先簡單介紹一下會員交易系統(tǒng)的業(yè)務(wù)。會員交易系統(tǒng)是整個會員系統(tǒng)中重要組成部分,承載了絕大部分的 VIP 會員收入,功能包括:
- VIP 商品的展示和售賣
- 影片商品的售賣
- VIP 和影片訂單的查詢
- 自動續(xù)費的開通、執(zhí)行和管理
- 相關(guān)后臺業(yè)務(wù)
同很多實體和商旅類的交易服務(wù)相比,會員交易在庫存、發(fā)貨、物流等方面的需求被簡化了,但另一些需求卻變得復(fù)雜了,例如:
- 需要支持更加豐富多樣的支付方式和客戶端平臺
- 需要支持更加豐富多樣的優(yōu)惠促銷活動
- 需要支持多種形式的自動續(xù)費
- 需要支持各級別會員的升級購買
這些復(fù)雜的需求再加上緊張的業(yè)務(wù)排期,使得代碼越來越復(fù)雜和難以維護擴展。靜態(tài)代碼檢查發(fā)現(xiàn),代碼的復(fù)雜度和代碼行數(shù)之比接近1比3,代碼重復(fù)率達到了20%,而實際情況則更為嚴重
這些問題導(dǎo)致了新需求實現(xiàn)緩慢、技術(shù)優(yōu)化長期難以推進等問題。
為了解決這些問題,我們重新設(shè)計了訂單支付服務(wù)
首先我們重新設(shè)計了訂單支付服務(wù)的架構(gòu)。將訂單支付的核心流程交由核心模塊實現(xiàn),各個場景下的特殊邏輯由支付處理類或配置文件的方式定制實現(xiàn):

然后梳理核心流程

首先,在第4步,訂單支付服務(wù)會根據(jù)支付處理器上的注解配置或支付處理配置文件的定義,選擇出合適的支付處理類。具體的配置方式如下:
注解形式的配置
@PayRequestMapping(
payTypes = {ALIPAY, ALIPAY_V3},
platformTags = {PlatformTag.H5}
)
@Component
class AliPayH5OrderPayHandler implements OrderCustomizer, GatewayPayRequestCustomizer {
@Override
public void customize(Order order, PayContext payContext) {
...
}
@Override
public void customize(GatewayPayRequest gatewayPayRequest, PayContext payContext) {
...
}
}
文件形式的配置
{
"handlers": [
{
"name": "WeChatOrderContract",
"mapping": {
"payTypes": [
379
],
"platformTags": [
"h5"
]
},
"gatewayPayRequest": {
"extendParameters": {
"isFirstSign": "yes"
}
},
"resultType": "DIRECT_AND_JSON"
}
]
}
注解形式的配置常用于需要代碼實現(xiàn)動態(tài)邏輯的場景,而對于比較簡單的場景,僅需通過配置文件即可完成適配。
在找到合適的支付處理類之后,會根據(jù)其代碼實現(xiàn)(注解形式)或配置文件的內(nèi)容(配置形式)進行自定義操作。在上面流程圖的第6、9步都會進行相應(yīng)的自定義操作。
在進行上述改進之后,大部分簡單的訂單支付服務(wù)的業(yè)務(wù)實現(xiàn)就只需增加新的配置即可。而比較復(fù)雜的場景,因為擴展組件的明確定義和聲明式的組件路由,因此開發(fā)效率也得到的極大的提升,代碼復(fù)雜度大大降低。
五、Navi 項目
由來
在重新設(shè)計訂單支付服務(wù)之后,我們發(fā)現(xiàn)其它的一些服務(wù)也需要類似的設(shè)計:即通過聲明式的規(guī)則定義,動態(tài)地選擇合適的組件。
那如何讓其它項目也能輕松的擁有這樣的能力呢?于是便有了設(shè)計開發(fā)一個通用框架實現(xiàn)這樣功能的想法。這就是 Navi 項目的由來。
項目地址:https://github.com/yanglifan/navi
Navi 項目能做什么呢?簡單來說,Navi 能夠通過簡單、靈活、豐富的聲明式配置,實現(xiàn)動態(tài)的組件選擇功能。
接下來我將舉幾個栗子。
簡單示例
interface OrderCreateHandler { // 1
void handle(Order order, OrderCreateRequest request);
}
@EqualMatcher(property = "clientType", value = "android") // 2
@VersionMatcher(range = "[1.0.0,2.0.0)") // 2
@Component
class AndroidV1Handler implments OrderCreateHandler { // 2
void handle(Order order, OrderCreateRequest request) {
}
}
@EqualMatcher(property = "clientType", value = "android") // 2
@VersionMatcher(range = "[2.0.0,3.0.0)") // 2
@Component
class AndroidV2Handler implments OrderCreateHandler { // 2
void handle(Order order, OrderCreateRequest request) {
}
}
public class OrderService {
public OrderCreateResult createOrder(OrderCreateRequest req) {
// 略
OrderCreateHandler handler = selector.select(req, OrderCreateHandler.class); // 3
if (handler != null) {
handler.handle(order, req);
}
// 略
}
}
上面這段示例中,OrderCreateHandler 是一個業(yè)務(wù)組件的接口定義。它有兩個實現(xiàn)類:AndroidV1Handler 和 AndroidV2Handler,分別用來實現(xiàn)安卓客戶端 1.0 和 2.0 下不同的需求。即當 1.0 版本的 Android APP 調(diào)用時,選擇 AndroidV1Handler,2.0 版本的 Android APP 調(diào)用時,選擇 AndroidV2Handler。
在 Navi 中,組件選擇的過程通過執(zhí)行如下語句實現(xiàn):
OrderCreateHandler handler = selector.select(req, OrderCreateHandler.class);
selector 的類型為 Selector,意為組件選擇器,簡稱選擇器。在 Spring 應(yīng)用中推薦使用其實現(xiàn)類 SpringBasedSelector。
組件選擇參數(shù):select 方法的第一個入?yún)?req 是組件選擇參數(shù),組件選擇器會根據(jù)其中的數(shù)據(jù)與組件上定義的匹配規(guī)則進行比較,從而選擇出滿足條件的組件。
匹配器注解
要想組件按照期望被選擇出來,就需要用到匹配器(Matcher)注解。以上面的代碼為例進行說明
-
@EqualMatcher(property = "clientType", value = "android")
這個注解的意思是組件選擇參數(shù)(后續(xù)說明)中的clientType的值等于 android。此時,這條匹配條件就是命中,否則就會拒絕。 -
@VersionMatcher(range = "[2.0.0,3.0.0)")
這個注解的意思是組件選擇參數(shù)中的version字段的值表示一個版本(格式必須為 major.minor.patch 格式,每一段必須為數(shù)字類型)。而這個版本符合range所定義的范圍之內(nèi)。方括號表示閉區(qū)間,小括號表示開區(qū)間。這個與數(shù)學(xué)中的定義相同。
Navi 自建的匹配器除了上面兩個外,目前還有:
-
ContainMatcher組件選擇參數(shù)對應(yīng)字段的值包含期望的值(字符串 contains 操作) -
IntersectMatcher組件選擇參數(shù)對應(yīng)字段的值(集合類型或用分割符分割的字符串類型)與期望的值有交集
注:匹配器之間的關(guān)系都是「與」
組合匹配器
當需要在組件上同時使用多個匹配器的時候,可能會導(dǎo)致在同一個業(yè)務(wù)功能上,組件選擇的維度不統(tǒng)一,從而對功能的理解、維護造成困難。為了解決這個問題,達到簡化和統(tǒng)一匹配器使用的目的,Navi 引入了自定義匹配器的功能。
下面是一個例子:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@IntersectMatcher(property = "platform.tags")
@VersionMatcher(property = "appVersion")
@CompositeMatcherType
public @interface StoreViewHandler {
String ALL_PLATFORMS = "*";
@AliasFor(annotationFor = EqualMatcher.class, attributeFor = "value")
String[] platform() default ALL_PLATFORMS;
@AliasFor(annotationFor = VersionMatcher.class, attributeFor = "range")
String clientVersionRange() default "";
}
@StoreViewHandler(platform = "h5", clientVersionRange = "[1.0.0,2.0.0]")
class Component {
}
通過自定義一個注解,并在其上配置 Navi 內(nèi)置的匹配器注解,你就能得到一個新的匹配器注解。
注:匹配器注解不能無限制疊加,只能有一層的復(fù)合關(guān)系。
后續(xù)計劃
目前 Navi 只發(fā)布了兩個版本,后續(xù)還有更多功能:
- 函數(shù)式的配置
注解方式的配置簡單易用,但存在不能覆蓋的場景,所以計劃提供函數(shù)式的配置方式 - 透明化的組件選擇
不再通過Selector顯式選擇組件,而是為組件生成代理,通過代理選擇實際的組件 - 組件多選
目前選擇器只會返回單個組件,后續(xù)會支持選擇多個組件,根據(jù)優(yōu)先級依次調(diào)用的功能
六、總結(jié)
相比高可用、高并發(fā)等話題,擴展性是相對冷門的。但系統(tǒng)的擴展性與技術(shù)人員的日常工作關(guān)系更為緊密,對企業(yè)業(yè)務(wù)發(fā)展速度也有顯著的影響。
但如何設(shè)計易于擴展的系統(tǒng)并不像設(shè)計高可用、高并發(fā)系統(tǒng)那樣有著很多可供使用的技術(shù)、可供參考的設(shè)計模式。因為沒有哪兩家公司的業(yè)務(wù)是完全相同的,而業(yè)務(wù)上的差異就會帶來系統(tǒng)設(shè)計上的差異。所以,從這個角度講,是否能設(shè)計出易于擴展的系統(tǒng)更加能反映程序員的技術(shù)水平和設(shè)計能力。
任何公司的發(fā)展都是由小變大的,在公司發(fā)展初期,并不需要過分考慮系統(tǒng)擴展性的問題。但在公司不斷發(fā)展,業(yè)務(wù)不斷復(fù)雜的過程中,其背后的業(yè)務(wù)系統(tǒng)的可擴展性的重要性就會不斷提高。當發(fā)展到一定規(guī)模時,新提出的業(yè)務(wù)需求如果還是需要重復(fù)建設(shè)的話,那其所需的成本將會變得難以接受。
所以,有不少公司在推進業(yè)務(wù)平臺的建設(shè),通過提供完善的、可靈活定制的業(yè)務(wù)能力,以支持新業(yè)務(wù)的快速上線。而這些業(yè)務(wù)平臺的建設(shè),其實就反映出對系統(tǒng)擴展性不斷提高的要求。
本篇文章介紹了一些系統(tǒng)擴展性方面相關(guān)的理論與實踐。但實際情況其實遠比文中介紹的更加復(fù)雜,所以在系統(tǒng)擴展性方面,還有更多值得思考和討論的內(nèi)容。
