序言
背景概述
公司目前 Java 項目提供服務都是基於 Dubbo 框架的,而且 Dubbo 框架已經成為大部分國內互聯網公司選擇的一個基礎組件。
在日常項目協作過程中,其實會碰到服務不穩定、不滿足需求場景等情況,很多開發都會通過在本地使用 Mocktio 等單測工具作為自測輔助。那麼,在聯調、測試等協作過程中怎麼處理?
其實,Dubbo 開發者估計也是遇到了這樣的問題,所以提供了一個提供泛化服務註冊的入口。但是在服務發現的時候有個弊端,就說通過服務發現去請求這個 Mock 服務的話,在註冊中心必須只有一個服務有效,否則消費者會請求到其他非Mock服務上去。
為了解決這個問題,Dubbo 開發者又提供了泛化調用的入口。既支持通過註冊中心發現服務,又支持通過 IP+PORT 去直接調用服務,這樣就能保證消費者調用的是 Mock 出來的服務了。
以上泛化服務註冊和泛化服務調用結合起來,看似已經是一個閉環,可以解決 Dubbo 服務的 Mock 問題。但是,結合日常工作使用時,會出現一些麻煩的問題:
- 服務提供方使用公用的註冊中心,消費方無法準確調用
- 消費者不可能更改代碼,去直連 Mock 服務
- 使用私有註冊中心能解決以上問題,但是 Mock 最小緯度為 Method,一個 Service 中被 Mock 的 Method 會正常處理,沒有被 Mock 的 Method 會異常,導致服務方需要 Mock Service 的全部方法
在解決以上麻煩的前提下,為了能快速註冊一個需要的 Dubbo 服務,提高項目協作過程中的工作效率,開展了 Mock 工廠的設計與實現。
功能概述
- Mock Dubbo 服務
- 單個服務器,支持部署多個相同和不同的 Service
- 動態上、下線服務
- 非 Mock 的 Method 透傳到基礎服務
一、方案探索
1.1 基於 Service Chain 選擇 Mock 服務的實現方式
1.1.1 Service Chain 簡單介紹
在業務發起的源頭添加 Service Chain 標識,這些標識會在接下來的跨應用遠程調用中一直透傳並且基於這些標識進行路由,這樣我們只需要把涉及到需求變更的應用的實例單獨部署,並添加到 Service Chain 的數據結構定義裡面,就可以虛擬出一個邏輯鏈路,該鏈路從邏輯上與其他鏈路是完全隔離的,並且可以共享那些不需要進行需求變更的應用實例。根據當前調用的透傳標識以及 Service Chain 的基礎元數據進行路由,路由原則如下:- 當前調用包含 Service Chain 標識,則路由到歸屬於該 Service Chain 的任意服務節點,如果沒有歸屬於該
- Service Chain 的服務節點,則排除掉所有隸屬於 Service Chain 的服務節點之後路由到任意服務節點
- 當前調用沒有包含 Service Chain 標識,則排除掉所有隸屬於 Service Chain 的服務節點之後路由到任意服務節點
- 當前調用包含 Service Chain 標識,並且當前應用也屬於某個 Service Chain 時,如果兩者不等則拋出路由異常
以 Dubbo 框架為例,給出了一個 Service Chain 實現架構圖(下圖來自有贊架構團隊)
1.1.2 Mock 服務實現設計方案
方案一、基於 GenericService 生成需要 Mock 接口的泛化實現,並註冊到 ETCD 上(主要實現思路如下圖所示)。
方案二、使用 Javassist,生成需要mock接口的Proxy實現,並註冊到 ETCD 上(主要實現思路如下圖所示)。
1.1.3 設計方案比較
方案一優點:實現簡單,能滿足mock需求
- 繼承 GenericService,只要實現一個$invoke( String methodName, String[]parameterTypes, Object[]objects ),可以根據具體請求參數做出自定義返回信息。
- 接口信息只要知道接口名、protocol 即可。
- 即使該服務已經存在,也能因為 generic 字段,讓消費者優先消費該 mock service。
缺點:與公司的服務發現機制衝突
由於有贊服務背景,在使用 Haunt 服務發現時,是會同時返回正常服務和帶有 Service Chain 標記的泛化服務,所以必然存在兩種類型的服務。導致帶有 Service Chain 標記的消費者在正常請求泛化服務時報 no available invoke。
例:註冊了 2個 HelloService:
- 正常的 :generic=false&interface=com.alia.api.HelloService&methods=doNothing,say,age
- 泛化的:generic=true&interface=com.alia.api.HelloService&methods=*
在服務發現的時候,RegistryDirectory 中有個 map,保存了所有 Service 的註冊信息。也就是說, method=* 和正常 method=doNothing,say,age 被保存在了一起。
客戶端請求服務的時候,優先匹配到正常的服務的 method,而不會去調用泛化服務。
導致結果:訪問時,會跳過 genericFilter,報 no available invoke。
方案二優點:Proxy 實現,自動生成一個正常的 Dubbo 接口實現
1.Javassist 有現成的方法生成接口實現字節碼,大大簡化了對用戶代碼依賴。例如:
- 返回 String、Json 等,對單 method 的 mock 實現,都無需用戶上傳實現類。
- 透傳時統一由平臺控制,不配置 mock 的方法默認就會進行透傳,而且保留 Service Chain 標記。
2.Mock 服務註冊 method 信息完整。
3.生成接口 Proxy 對象時,嚴格按照接口定義進行生成,返回數據類型有保障。
缺點:
- 無優先消費選擇功能。
- 字節碼後臺生成,不利於排查生成的 Proxy 中存在問題。
1.1.4 選擇結果
由於做為平臺,不僅僅需要滿足 mock 需求,還需要減少用戶操作,以及支持現有公司服務架構體系,所以選擇設計方案二。
1.2 基於動態代理結合 ServiceConfig 實現動態上、下線服務
1.2.1 Dubbo 暴露服務的過程介紹
上圖(來自 dubbo 開發者文檔)暴露服務時序圖: 首先 ServiceConfig 類拿到對外提供服務的實際類 ref(如:StudentInfoServiceImpl),然後通過 ProxyFactory 類的 getInvoker 方法使用 ref 生成一個 AbstractProxyInvoker 實例。到這一步就完成具體服務到 Invoker 的轉化。接下來就是 Invoker 轉換到 Exporter 的過程,Exporter 會通過轉化為 URL 的方式暴露服務。 從 dubbo 源碼來看,dubbo 通過 Spring 框架提供的 Schema 可擴展機制,擴展了自己的配置支持。dubbo-container 通過封裝 Spring 容器,來啟動了 Spring 上下文,此時它會去解析 Spring 的 bean 配置文件(Spring 的 xml 配置文件),當解析 dubbo:service 標籤時,會用 dubbo 自定義 BeanDefinitionParser 進行解析。dubbo 的 BeanDefinitonParser 實現為 DubboBeanDefinitionParser。
Spring.handlers 文件:http://code.alibabatech.com/schema/dubbo=com.alibaba.dubbo.config.spring.schema.DubboNamespaceHandler
public
class
DubboNamespaceHandler
extends
NamespaceHandlerSupport
{
public
DubboNamespaceHandler
() {
}
public
void
init() {
this
.registerBeanDefinitionParser(
"application"
,
new
DubboBeanDefinitionParser
(
ApplicationConfig
.
class
,
true
));
this
.registerBeanDefinitionParser(
"module"
,
new
DubboBeanDefinitionParser
(
ModuleConfig
.
class
,
true
));
this
.registerBeanDefinitionParser(
"registry"
,
new
DubboBeanDefinitionParser
(
RegistryConfig
.
class
,
true
));
this
.registerBeanDefinitionParser(
"monitor"
,
new
DubboBeanDefinitionParser
(
MonitorConfig
.
class
,
true
));
this
.registerBeanDefinitionParser(
"provider"
,
new
DubboBeanDefinitionParser
(
ProviderConfig
.
class
,
true
));
this
.registerBeanDefinitionParser(
"consumer"
,
new
DubboBeanDefinitionParser
(
ConsumerConfig
.
class
,
true
));
this
.registerBeanDefinitionParser(
"protocol"
,
new
DubboBeanDefinitionParser
(
ProtocolConfig
.
class
,
true
));
this
.registerBeanDefinitionParser(
"service"
,
new
DubboBeanDefinitionParser
(
ServiceBean
.
class
,
true
));
this
.registerBeanDefinitionParser(
"reference"
,
new
DubboBeanDefinitionParser
(
ReferenceBean
.
class
,
false
));
this
.registerBeanDefinitionParser(
"annotation"
,
new
DubboBeanDefinitionParser
(
AnnotationBean
.
class
,
true
));
}
static
{
Version
.checkDuplicate(
DubboNamespaceHandler
.
class
);
}
}
DubboBeanDefinitionParser
會將配置標籤進行解析,並生成對應的
Javabean
,最終註冊到
Spring
Ioc
容器中。 對
ServiceBean
進行註冊時,其
implements
InitializingBean
接口,當 bean 完成註冊後,會調用 afterPropertiesSet() 方法,該方法中調用
export
() 完成服務的註冊。在
ServiceConfig
中的 doExport() 方法中,會對服務的各個參數進行校驗。
if
(
this
.
ref
instanceof
GenericService
) {
this
.interfaceClass =
GenericService
.
class
;
this
.
generic
=
true
;
}
else
{
try
{
this
.interfaceClass =
Class
.forName(
this
.interfaceName,
true
,
Thread
.currentThread().getContextClassLoader());
}
catch
(
ClassNotFoundException
var5) {
throw
new
IllegalStateException
(var5.getMessage(), var5);
}
this
.checkInterfaceAndMethods(
this
.interfaceClass,
this
.methods);
this
.checkRef();
this
.
generic
=
false
;
}
註冊過程中會進行判斷該實現類的類型。其中如果實現了 GenericService 接口,那麼會在暴露服務信息時,將 generic 設置為 true,暴露方法就為*。如果不是,就會按正常服務進行添加服務的方法。此處就是我們可以實現 Mock 的切入點,使用 Javassist 根據自定義的 Mock 信息,寫一個實現類的 class 文件並生成一個實例注入到 ServiceConfig 中。生成 class 實例如下所示,與一個正常的實現類完全一致,以及註冊的服務跟正常服務也完全一致。
package
123.com
.youzan.api;
import
com.youzan.api.
StudentInfoService
;
import
com.youzan.pojo.
Pojo
;
import
com.youzan.test.mocker.
internal
.common.reference.
ServiceReference
;
public
class
StudentInfoServiceImpl
implements
StudentInfoService
{
private
Pojo
getNoValue0;
private
Pojo
getNoValue1;
private
ServiceReference
service;
public
void
setgetNoValue0(
Pojo
var1) {
this
.getNoValue0 = var1;
}
public
void
setgetNoValue1(
Pojo
var1) {
this
.getNoValue1 = var1;
}
public
Pojo
getNo(
int
var1) {
return
var1 ==
1
?
this
.getNoValue0 :
this
.getNoValue1;
}
public
void
setService(
ServiceReference
var1) {
this
.service = var1;
}
public
double
say() {
return
(
Double
)
this
.service.reference(
"say"
,
""
, (
Object
[])
null
);
}
public
void
findInfo(
String
var1,
long
var2) {
this
.service.reference(
"findInfo"
,
"java.lang.String,long"
,
new
Object
[]{var1,
new
Long
(var2)});
}
public
StudentInfoServiceImpl
() {}
}
使用 ServiceConfig 將自定義的實現類注入,並完成註冊,實現如下:
void
registry(
Object
T,
String
sc) {
service.setFilter(
"request"
)
service.setRef(T)
service.setParameters(
new
HashMap
<
String
,
String
>())
service.getParameters().put(
Constants
.SERVICE_CONFIG_PARAMETER_SERVICE_CHAIN_NAME, sc)
service.
export
()
if
(service.isExported()) {
log.warn
"發佈成功 : ${sc}-${service.interface}"
}
else
{
log.error
"發佈失敗 : ${sc}-${service.interface}"
}
}
通過 service.setRef(genericService)完成實現類的注入,最終通過 service.export()完成服務註冊。ref 的值已經被塞進來,並附帶 ServiceChain 標記保存至 service 的 paramters 中。具體服務到 Invoker 的轉化以及 Invoker 轉換到 Exporter,Exporter 到 URL 的轉換都會附帶上 ServiceChain 標記註冊到註冊中心。
1.2.2 生成實現類設計方案
方案一、 支持指定 String(或 Json) 對單個 method 進行 mock。
功能介紹:根據入參 String or Json,生成代理對象。由 methodName 和 methodParams 獲取唯一 method 定義。(指支持單個方法mock)。消費者請求到Mock服務的對應Mock Method時,Mock服務將保存的數據轉成對應的返回類型,並返回。
方案二、 支持指定 String(或 Json) 對多個 method生成 mock。
功能介紹:根據入參 String or Json,生成代理對象。method 對應的 mock 數據由 methodMockMap 指定,由 methodName 獲取唯一 method 定義,所以被 mock 接口不能有重載方法(只支持多個不同方法 mock)。消費者請求到 Mock 服務的對應 mock method 時,Mock 服務將保存的數據轉成對應的返回類型,並返回。
方案三、 在使用 實現類(Impl) 的情況下,支持傳入一個指定的 method 進行 mock。
功能介紹:根據入參的實現類,生成代理對象。由 methodName 和 methodParams 獲取唯一 method 定義。(支持 mock 一個方法)。消費者請求到 Mock 服務的對應 mock method 時,Mock 服務調用該實現類的對應方法,並返回。
方案四、 在使用 實現類(Impl) 的情況下,支持傳入多個 method 進行 mock。
功能介紹:根據入參的實現類,生成代理對象。由 methodName 獲取唯一 method 定義,所以被 mock 接口不能有重載方法(只支持一個實現類 mock 多個方法)。消費者請求到 Mock 服務的對應 mock method 時,Mock 服務調用該實現類的對應方法,並返回。
方案五、 使用 Custom Reference 對多個 method 進行 mock。
功能介紹:根據入參 ServiceReference,生成代理對象。method 對應的自定義 ServiceReference 由 methodMockMap 指定,由 methodName 獲取唯一method定義,所以被 mock 接口不能有重載方法(只支持多個不同方法 mock)。消費者請求到 Mock 服務的對應 mock method 時,Mock 服務會主動請求自定義的 Dubbo 服務。
1.2.3 設計方案選擇
以上五種方案,其實就是整個 Mock 工廠實現的一個迭代過程。在每個方案的嘗試中,發現各自的弊端然後出現了下一種方案。目前,在結合各種使用場景後,選擇了方案二、方案五。
方案三、方案四被排除的主要原因:Dubbo 對已經發布的 Service 保存了實現類的 ClassLoader,相同 className 的類一旦註冊成功後,會將實現類的 ClassLoader 保存到內存中,很難被刪除。所以想要使用這兩種方案的話,需要頻繁變更實現類的 className,大大降低了一個工具的易用性。改用自定義 Dubbo 服務(方案五),替代自定義實現類,但是需要使用者自己起一個 Dubbo 服務,並告知 IP+PORT。
方案一其實是方案二的補集,能支持 Service 重載方法的 Mock。由於在使用時,需要傳入具體 Method 的簽名信息,增加了用戶操作成本。由於公司內部保證一個 Service 不可能有重載方法,且為了提高使用效率,不開放該方案。後期如果出現這樣的有重載方法的情況,再進行開放。
1.2.4 遇到的坑
基礎數據類型需要特殊處理
使用 Javassist 根據接口 class 寫一個實現類的 class 文件,遇到最讓人頭疼的就是方法簽名和返回值。如果方法的簽名和返回值為基礎數據類型時,那在傳參和返回時需要做特殊處理。平臺中本人使用了最笨的枚舉處理方法,如果有使用 Javassist 的高手,有好的建議麻煩不吝賜教。代碼如下:
/** 參數存在基本數據類型時,默認使用基本數據類型
* 基本類型包含:
* 實數:double、float
* 整數:byte、short、int、long
* 字符:char
* 布爾值:boolean
* */
private
static
CtClass
getParamType(
ClassPool
classPool,
String
paramType) {
switch
(paramType) {
case
"char"
:
return
CtClass
.charType
case
"byte"
:
return
CtClass
.byteType
case
"short"
:
return
CtClass
.shortType
case
"int"
:
return
CtClass
.intType
case
"long"
:
return
CtClass
.longType
case
"float"
:
return
CtClass
.floatType
case
"double"
:
return
CtClass
.doubleType
case
"boolean"
:
return
CtClass
.booleanType
default
:
return
classPool.
get
(paramType)
}
}
1.3 非 Mock 的 Method 透傳到基礎服務
1.3.1 Dubbo 服務消費的過程介紹
在消費端:Spring 解析 dubbo:reference 時,Dubbo 首先使用 com.alibaba.dubbo.config.spring.schema.NamespaceHandler 註冊解析器,當 Spring 解析 xml 配置文件時就會調用這些解析器生成對應的 BeanDefinition 交給 Spring 管理。Spring 在初始化 IOC 容器時會利用這裡註冊的 BeanDefinitionParser 的 parse 方法獲取對應的 ReferenceBean 的 BeanDefinition 實例,由於 ReferenceBean 實現了 InitializingBean 接口,在設置了 Bean 的所有屬性後會調用 afterPropertiesSet 方法。afterPropertiesSet 方法中的 getObject 會調用父類 ReferenceConfig 的 init 方法完成組裝。ReferenceConfig 類的 init 方法調用 Protocol 的 refer 方法生成 Invoker 實例,這是服務消費的關鍵。接下來把 Invoker 轉換為客戶端需要的接口(如:StudentInfoService)。由 ReferenceConfig 切入,通過 API 方式使用 Dubbo 的泛化調用,代碼如下:
Object
reference(
String
s,
String
paramStr,
Object
[] objects) {
if
(
StringUtils
.isEmpty(serviceInfoDO.interfaceName) || serviceInfoDO.interfaceName.length() <=
0
) {
throw
new
NullPointerException
(
"The 'interfaceName' should not be ${serviceInfoDO.interfaceName}, please make sure you have the correct 'interfaceName' passed in"
)
}
// set interface name
referenceConfig.setInterface(serviceInfoDO.interfaceName)
referenceConfig.setApplication(serviceInfoDO.applicationConfig)
// set version
if
(serviceInfoDO.version !=
null
&& serviceInfoDO.version !=
""
&& serviceInfoDO.version.length() >
0
) {
referenceConfig.setVersion(serviceInfoDO.version)
}
if
(
StringUtils
.isEmpty(serviceInfoDO.refUrl) || serviceInfoDO.refUrl.length() <=
0
) {
throw
new
NullPointerException
(
"The 'refUrl' should not be ${serviceInfoDO.refUrl} , please make sure you have the correct 'refUrl' passed in"
)
}
//set refUrl
referenceConfig.setUrl(serviceInfoDO.refUrl)
reference.setGeneric(
true
)
// 聲明為泛化接口
//使用com.alibaba.dubbo.rpc.service.GenericService可以代替所有接口引用
GenericService
genericService = reference.
get
()
String
[] strs =
null
if
(paramStr !=
""
){
strs = paramStr.split(
","
)
}
Object
result = genericService.$invoke(s, strs, objects)
// 返回值類型不定,需要做特殊處理
if
(result.getClass().isAssignableFrom(
HashMap
.
class
)) {
Class
dtoClass =
Class
.forName(result.
get
(
"class"
))
result.remove(
"class"
)
String
resultJson = JSON.toJSONString(result)
return
JSON.parseObject(resultJson, dtoClass)
}
return
result
}
如上代碼所示,具體業務 DTO 類型,泛化調用結果非僅結果數據,還包含 DTO 的 class 信息,需要特殊處理結果,取出需要的結果進行返回。
1.3.2 記錄dubbo服務請求設計方案
方案一、捕獲請求信息
服務提供方和服務消費方調用過程攔截,Dubbo 本身的大多功能均基於此擴展點實現,每次遠程方法執行,該攔截都會被執行。Provider 提供的調用鏈,具體的調用鏈代碼是在 ProtocolFilterWrapper 的 buildInvokerChain 完成的,具體是將註解中含有 group=provider 的 Filter 實現,按照 order 排序,最後的調用順序是 EchoFilter->ClassLoaderFilter->GenericFilter->ContextFilter->ExceptionFilter->TimeoutFilter->MonitorFilter->TraceFilter。 其中:EchoFilter 的作用是判斷是否是回聲測試請求,是的話直接返回內容。回聲測試用於檢測服務是否可用,回聲測試按照正常請求流程執行,能夠測試整個調用是否通暢,可用於監控。ClassLoaderFilter 則只是在主功能上添加了功能,更改當前線程的 ClassLoader。
在 ServiceConfig 繼承 AbstractInterfaceConfig,中有 filter 屬性。以此為切入點,給每個 Mock 服務添加 filter,記錄每次 dubbo 服務請求信息(接口、方法、入參、返回、響應時長)。
方案二、記錄請求信息
將請求信息保存在內存中,一個接口的每個被 Mock 的方法保存近 10次 記錄信息。使用二級緩存保存,緩存代碼如下:
@Singleton
(lazy =
true
)
class
CacheUtil
{
private
static
final
Object
PRESENT =
new
Object
()
private
int
maxInterfaceSize =
10000
// 最大接口緩存數量
private
int
maxRequestSize =
10
// 最大請求緩存數量
private
Cache
<
String
,
Cache
<
RequestDO
,
Object
>> caches =
CacheBuilder
.newBuilder()
.maximumSize(maxInterfaceSize)
.expireAfterAccess(
7
,
TimeUnit
.DAYS)
// 7天未被請求的接口,緩存回收
.build()
}
如上代碼所示,二級緩存中的一個 Object 是被浪費的內存空間,但是由於想不到其他更好的方案,所以暫時保留該設計。
1.3.3 遇到的坑
泛化調用時參數對象轉換
使用 ReferenceConfig 進行服務直接調用,繞過了對一個接口方法簽名的校驗,所以在進行泛化調用時,最大的問題就是 Object[] 內的參數類型了。每次當遇到數據類型問題時,本人只會用最笨的辦法,枚舉解決。代碼如下:
/** 參數存在基本數據類型時,默認使用基本數據類型
* 基本類型包含:
* 實數:double、float
* 整數:byte、short、int、long
* 字符:char
* 布爾值:boolean
* */
private
Object
getInstance(
String
paramType,
String
value) {
switch
(paramType) {
case
"java.lang.String"
:
return
value
case
"byte"
:
case
"java.lang.Byte"
:
return
Byte
.parseByte(value)
case
"short"
:
return
Short
.parseShort(value)
case
"int"
:
case
"java.lang.Integer"
:
return
Integer
.parseInt(value)
case
"long"
:
case
"java.lang.Long"
:
return
Long
.parseLong(value)
case
"float"
:
case
"java.lang.Float"
:
return
Float
.parseFloat(value)
case
"double"
:
case
"java.lang.Double"
:
return
Double
.parseDouble(value)
case
"boolean"
:
case
"java.lang.Boolean"
:
return
Boolean
.parseBoolean(value)
default
:
JSONObject
jsonObject = JSON.parseObject(value)
// 轉成JSONObject
return
jsonObject
}
}
如以上代碼所示,是將傳入參數轉成對應的包裝類型。當接口的簽名如果為 int,那麼入參對象是 Integer 也是可以的。因為$invoke(StringmethodName,String[]paramsTypes,Object[]objects),是由 paramsTypes 檢查方法簽名,然後再將 objects 傳入具體服務中進行調用。
ReferenceConfig 初始化優先設置 initialize 為 true
使用泛化調用發起遠程 Dubbo 服務請求,在發起 invoke 前,有GenericServicegenericService=referenceConfig.get()操作。當 Dubbo 服務沒有起來,此時首次發起調用後,進行 ref 初始化操作。ReferenceConfig 初始化 ref 代碼如下:
private
void
init() {
if
(initialized) {
return
;
}
initialized =
true
;
if
(interfaceName ==
null
|| interfaceName.length() ==
0
) {
throw
new
IllegalStateException
(
"interface not allow null!"
);
}
// 獲取消費者全局配置
checkDefault();
appendProperties(
this
);
if
(getGeneric() ==
null
&& getConsumer() !=
null
) {
setGeneric(getConsumer().getGeneric());
}
...
}
結果導致:由於第一次初始化的時候,先把 initialize 設置為 true,但是後面未獲取到有效的 genericService,導致後面即使 Dubbo 服務起來後,也會泛化調用失敗。
解決方案:泛化調用就是使用 genericService 執行 invoke 調用,所以每次請求都使用一個新的 ReferenceConfig,當初始化進行 get() 操作時報異常或返回為 null 時,不保存;直到初始化進行 get() 操作時獲取到有效的 genericService 時,將該 genericService 保存起來。實現代碼如下:
synchronized
(hasInit) {
if
(!hasInit) {
ReferenceConfig
referenceConfig =
new
ReferenceConfig
();
// set interface name
referenceConfig.setInterface(serviceInfoDO.interfaceName)
referenceConfig.setApplication(serviceInfoDO.applicationConfig)
// set version
if
(serviceInfoDO.version !=
null
&& serviceInfoDO.version !=
""
&& serviceInfoDO.version.length() >
0
) {
referenceConfig.setVersion(serviceInfoDO.version)
}
if
(
StringUtils
.isEmpty(serviceInfoDO.refUrl) || serviceInfoDO.refUrl.length() <=
0
) {
throw
new
NullPointerException
(
"The 'refUrl' should not be ${serviceInfoDO.refUrl} , please make sure you have the correct 'refUrl' passed in"
)
}
referenceConfig.setUrl(serviceInfoDO.refUrl)
referenceConfig.setGeneric(
true
)
// 聲明為泛化接口
genericService = referenceConfig.
get
()
if
(
null
!= genericService) {
hasInit =
true
}
}
}
1.4 單個服務器,支持部署多個相同和不同的Service
根據需求,需要解決兩個問題:1.服務器運行過程中,外部API的Jar包加載問題;2.註冊多個相同接口服務時,名稱相同的問題。
1.4.1 動態外部Jar包加載的設計方案
方案一、為外部 Jar 包生成單獨的 URLClassLoader,然後在泛化註冊時使用保存的 ClassLoader,在回調時進行切換 currentThread 的 ClassLoader,進行相同 API 接口不同版本的 Mock。
不可用原因:
JavassistProxyFactory 中finalWrapperwrapper=Wrapper.getWrapper(proxy.getClass().getName().indexOf('$')<0?proxy.getClass():type);wapper 獲取的時候,使用的 makeWrapper 中默認使用的是 ClassHelper.getClassLoader(c);導致一直會使用 AppClassLoader。API 信息會保存在一個 WapperMap 中,當消費者請求過來的時候,會優先取這個 Map 找對應的 API 信息。
導致結果:
- 1.由於使用泛化註冊,所以 class 不在 AppClassLoader 中。設置了 currentThread 的 ClassLoader 不生效。
- 2.由於 dubbo 保存 API 信息只有一個 Map,所以導致發佈的服務的 API 也只能有一套。
解決方案:
- 使用自定義 ClassLoader 進行加載外部 Jar 包中的 API 信息。
- 一臺 Mock 終端存一套 API 信息,更新 API 時需要重啟服務器。
方案二、在程序啟動時,使用自定義 TestPlatformClassLoader。還是給每個 Jar 包生成對應的 ApiClassLoader,由 TestPlatformClassLoader 統一管理。
不可用原因:
在 Mock 終端部署時,使用 -Djava.system.class.loader設置 ClassLoader 時,JVM 啟動參數不可用。因為,TestPlatformClassLoader 不存在於當前 JVM 中,而是在工程代碼中。詳細參數如下: -Djava.system.class.loader= com.youzan.test.mocker.internal.classloader.TestPlatformClassLoader
解決方案:(由架構師汪興提供)
- 使用自定義 Runnable(),保存程序啟動需要的 ClassLoader、啟動參數、mainClass 信息。
- 在程序啟動時,新起一個 Thread,傳入自定義 Runnable(),然後將該線程啟動。
方案三、使用自定義容器啟動服務
應用啟動流程,如下圖所示(下圖來自有贊架構團隊)
Java 的類加載遵循雙親委派的設計模式,從 AppClassLoader 開始自底向上尋找,並自頂向下加載,所以在沒有自定義 ClassLoader 時,應用的啟動是通過 AppClassLoader 去加載 Main 啟動類去運行。
自定義 ClassLoader 後,系統 ClassLoader 將被設置成容器自定義的 ClassLoader,自定義 ClassLoader 重新去加載 Main 啟動類運行,此時後續所有的類加載都會先去自定義的 ClassLoader 裡查找。
難點:應用默認系統類加載器是 AppClassLoader,在 New 對象時不會經過自定義的 ClassLoader。
巧妙之處:Main 函數啟動時,AppClassLoader 加載 Main 和容器,容器獲取到 Main class,用自定義 ClassLoader 重新加載Main,設置系統類加載器為自定義類加載器,此時 New 對象都會經過自定義的 ClassLoader。
1.4.2 設計方案選擇
以上三個方案,其實是實踐過程中的一個迭代。最終結果:
- 方案一、保留為外部Jar包生成單獨的 URLClassLoader。
- 方案二、保留自定義 TestPlatformClassLoader,使用 TestPlatformClassLoader 保存每個 Jar 包中 API 與其 ClassLoader 的對應關係。
- 方案三、採用自定義容器啟動,新起一個線程,並設置其 concurrentThreadClassLoader 為 TestPlatformClassLoader,用該線程啟動 Main.class。
1.4.3 遇到的坑
使用 Javassist 生成的 Class 名稱相同
使用 Javassist 生成的 Class,每個 Class 有單獨的 ClassName 以 Service Chain + className 組成。在重新生成相同名字的 class 時,即使使用 newClassPool()也不能完全隔離。因為生成 Class 的時候 Class>clazz=ctClass.toClass()默認使用的是同一個 ClassLoader,所以會報“attempted duplicate class definition for name:**”。
解決方案:基於 ClassName 不是隨機生成的,所以只能基於之前的 ClassLoader 生成一個新的 SecureClassLoader(ClassLoader parent) 加載新的 class,舊的 ClassLoader 靠 Java 自動 GC。代碼如下: Class>clazz=ctClass.toClass(newSecureClassLoader(clz.classLoader))
PS:該方案目前沒有做過壓測,不知道會不會導致內存溢出。
二、方案實現
2.1 Mock 工廠整體設計架構
2.2 Mocker 容器設計圖
2.3 二方包管理時序圖
2.4 Mocker 容器服務註冊時序圖
三、支持場景
3.1 元素及名詞解釋
上圖所示為基本元素組成,相關名詞解釋如下:
- 消費者:調用方發起 DubboRequest
- Base 服務:不帶 Service Chain 標識的正常服務
- Mock 服務:通過 Mock 工廠生成的 dubbo 服務
- ETCD:註冊中心,此處同時註冊著 Base 服務和 Mock 服務
- 默認服務透傳:對接口中不需要 Mock 的方法,直接泛化調用 Base 服務
- 自定義服務(CF):用戶自己起一個泛化 dubbo 服務(PS:不需要註冊到註冊中心,也不需要 Service Chain 標識)
3.2 支持場景簡述
場景1:不帶 Service Chain 請求(不使用 Mock 服務時)
消費者從註冊中心獲取到 Base 環境服務的 IP+PORT,直接請求 Base 環境的服務。
場景2、帶 Service Chain 請求、Mock 服務採用 JSON 返回實現
消費者從註冊中心獲取到兩個地址:1.Base 環境服務的 IP+PORT;2.帶 Service Chain 標記服務(Mock服務)的 IP+PORT。根據 Service Chain 調用路由,去請求 Mock 服務中的該方法,並返回 Mock 數據。
場景3、帶 Service Chain 請求、Mock 服務沒有該方法實現
消費者從註冊中心獲取到兩個地址:1.Base 環境服務的 IP+PORT;2.帶 Service Chain 標記服務(Mock 服務)的 IP+PORT。根據 Service Chain 調用路由,去請求 Mock 服務。由於 Mock 服務中該方法是默認服務透傳,所以由 Mock 服務直接泛化調用 Base 服務,並返回數據。
場景4、帶 Service Chain 請求頭、Mock 服務採用自定義服務(CR)實現
消費者從註冊中心獲取到兩個地址:1.Base 環境服務的 IP+PORT;2.帶 Service Chain 標記服務(Mock 服務)的 IP+PORT。根據 Service Chain 調用路由,去請求Mock服務。由於 Mock 服務中該方法是自定義服務(CF),所以由 Mock 服務調用用戶的 dubbo 服務,並返回數據。
場景5、帶 Service Chain 請求頭、Mock 服務沒有該方法實現、該方法又調用帶 Service Chain 的 InterfaceB 的方法
消費者調用 InterfaceA 的 Method3 時,從註冊中心獲取到兩個地址:1.Base 環境服務的 IP+PORT;2.帶 Service Chain 標記服務(Mock 服務)的 IP+PORT。根據 Service Chain 調用路由,去請求 InterfaceA 的 Mock 服務。由於 Mock 服務中該方法是默認服務透傳,所以由 Mock 服務直接泛化調用 InterfaceA 的 Base 服務的Method3。
但是,由於 InterfaceA 的 Method3 是調用 InterfaceB 的 Method2,從註冊中心獲取到兩個地址:1.Base 環境服務的 IP+PORT;2.帶 Service Chain 標記服務(Mock 服務)的 IP+PORT。由於 Service Chain 標識在整個請求鏈路中是一直被保留的,所以根據Service Chain調用路由,最終請求到 InterfaceB 的 Mock 服務,並返回數據。
場景6、帶 Service Chain 請求頭、Mock已經存在的 Service Chain 服務
由於不能同時存在兩個相同的 Service Chain 服務,所以需要降原先的 Service Chain 服務進行只訂閱、不註冊的操作。然後將Mock服務的透傳地址,配置為原 Service Chain 服務(即訂閱)。
消費者在進行請求時,只會從 ETCD 發現 Mock 服務,其他同場景2、3、4、5。
四、結束語
Mock平臺實踐過程中,遇到很多的難題.
閱讀更多 JAVA柯尼塞克丶 的文章