ExtensionLoader
1、私有構造
ExtensionLoader的構造方法傳入一個Class,代表當前擴展點對應的SPI接口。
每個ExtensionLoader實例管理自己Class的擴展點,包括加載、獲取等等。
type:當前擴展點對應spi接口class;
objectFactory:擴展點工廠AdaptiveExtensionFactory,主要用于setter注入,后面再看。
2、單例
ExtensionLoader提供靜態方法,構造ExtensionLoader實例。
單例往往要針對一個范圍(scope)來說,比如Spring中所說的單例,往往是在一個BeanFactory中,而一個應用可以運行多個BeanFactory。又比如Class對象是單例,往往隱含的scope是同一ClassLoader。
ExtensionLoader在一個擴展點接口Class下只有一個實例,而每個擴展點實現實例在全局只有一個。
3、成員變量
ExtensionLoader的成員變量可以分為幾類
普通擴展點相關:
active擴展點相關:
adaptive擴展點相關:
wrapper擴展點相關:
4、加載擴展點Class
ExtensionLoader#getExtensionClasses:
當需要加載某個擴展點實現實例前,總會優先加載該擴展點所有實現Class,并緩存到cachedClasses中。
ExtensionLoader#loadExtensionClasses:加載所有擴展點實現類
ExtensionLoader#cacheDefaultExtensionName:加載SPI注解中的value屬性,作為默認擴展點名稱,默認擴展點只能存在一個。
ExtensionLoader會掃描classpath三個路徑下的擴展點配置文件:
- META-INF/dubbo/internal:dubbo框架自己用的
- META-INF/dubbo/:用戶擴展用的
- META-INF/services/:官方也沒建議這樣使用
ExtensionLoader#loadDirectory:
1)類加載器:優先線程類加載器,其次ExtensionLoader自己的類加載器;
2)掃描擴展點配置文件;
3)加載擴展類;
ExtensionLoader#loadResource:加載文件中每一行,key是擴展名,value是擴展實現類名。
ExtensionLoader#loadClass:最終將每個擴展實現類Class按照不同的方式,緩存到ExtensionLoader實例中。
普通擴展點
案例
對于一個擴展點MyExt:
@SPI
public interface MyExt {
String echo(URL url, String s);
}
MyExtImplA實現MyExt:
public class MyExtImplA implements MyExt {
@Override
public String echo(URL url, String s) {
return "ext1";
}
}
可以配置多個擴展點實現META-INF/dubbo/x.y.z.MyExt:
A=x.y.z.impl.MyExtImplA
B=x.y.z.impl.MyExtImplB
使用ExtensionLoader#getExtention獲取對應擴展點:
@Test
void testExtension() {
ExtensionLoader
這種用法,對于用戶來說,和beanFactory極為類似,當然實現并不同。
MyExt a = beanFactory.getBean("A", MyExt.class);
原理
ExtensionLoader#getExtention固然有單例緩存(cachedInstances),這個直接跳過。
ExtensionLoader#createExtension:創建擴展點實現
0)getExtensionClasses:確保所有擴展點class被加載
1)通過無參構造,實例化擴展點instance
2)injectExtention:對擴展點instance執行setter注入,暫時忽略
3)包裝類相關,暫時忽略
4)執行instance的初始化方法
initExtension:初始化
ExtensionLoader和Spring的創建bean流程相比,確實很像,比如:
1)Spring可以通過各種方式選擇bean的一個構造方法創建一個bean(AbstractAutowireCapableBeanFactory#createBeanInstance),而ExtensionLoader只能通過無參構造創建擴展點;
2)Spring可以通過多種方式進行依賴注入(AbstractAutowireCapableBeanFactory#populateBean),比如Aware接口/setter/注解等,而ExtensionLoader只能支持setter注入;
3)Spring可以通過多種方式進行初始化(AbstractAutowireCapableBeanFactory#initializeBean),比如PostConstruct注解/InitializingBean/initMethod等,而ExtensionLoader只支持InitializingBean(LifeCycle)這種方式;
包裝擴展點
案例
上面在ExtensionLoader#createExtension的第三步,可能會走包裝擴展點邏輯。
假設有個擴展點MyExt2:
@SPI
public interface MyExt2 {
String echo(URL url, String s);
}
有普通擴展點實現MyExt2ImplA:
public class MyExt2ImplA implements MyExt2 {
@Override
public String echo(URL url, String s) {
return "A";
}
}
除此以外,還有兩個實現MyExt2的擴展點的MyExtWrapperA和MyExtWrapperB, 特點在于他有MyExt2的單參數構造方法 。
public class MyExtWrapperA implements MyExt2 {
private final MyExt2 myExt2;
public MyExtWrapperA(MyExt2 myExt2) {
this.myExt2 = myExt2;
}
@Override
public String echo(URL url, String s) {
return "wrapA>>>" + myExt2.echo(url, s);
}
}
public class MyExtWrapperB implements MyExt2 {
private final MyExt2 myExt2;
public MyExtWrapperB(MyExt2 myExt2) {
this.myExt2 = myExt2;
}
@Override
public String echo(URL url, String s) {
return "wrapB>>>" + myExt2.echo(url, s);
}
}
然后編寫配置文件META-INF/x.y.z.myext2.MyExt2:
A=x.y.z.myext2.impl.MyExt2ImplA
wrapperA=x.y.z.myext2.impl.MyExtWrapperA
wrapperB=x.y.z.myext2.impl.MyExtWrapperB
測試驗證,echo方法輸出wrapB>>>wrapA>>>A。
@Test
void testWrapper() {
ExtensionLoader
但是包裝擴展點不能通過getExtension顯示獲取 ,比如:
// 包裝類無法通過name直接獲取
@Test
void testWrapper_IllegalStateException() {
ExtensionLoader
原理
包裝類之所以不暴露給用戶直接獲取,是因為包裝類提供類似aop的用途,對于用戶來說是透明的。
類加載階段
在類加載階段,isWrapperClass判斷一個擴展類是否是包裝類,如果是的話放入cachedWrapperClasses緩存。
對于包裝類,不會放入普通擴展點的緩存map,所以無法通過getExtension顯示獲取。
判斷是否是包裝類,取決于擴展點實現clazz是否有對應擴展點type的單參構造方法。
實例化階段
包裝類實例化,是通過ExtensionLoader.getExtension("A")獲取普通擴展點觸發的,而返回的會是一個包裝類。
即 如果一個擴展點存在包裝類,客戶端通過getExtension永遠無法獲取到原始擴展點實現 。
包裝類是硬編碼實現的:
1)本質上包裝的順序是無序的,取決于擴展點配置文件的掃描順序。(SpringAOP可以設置順序)
2)包裝類即使只關注擴展點的一個方法,也必須要實現擴展點的所有方法,擴展點新增方法如果沒有默認實現,需要修改所有包裝類。(SpringAOP如果用戶只關心其中一個方法,也可以實現,因為是動態代理)
3)性能較好。(無反射)
自適應擴展點
對于一個擴展點type,最多只有一個自適應擴展點實現。
可以通過用戶硬編碼實現,也可以通過dubbo自動生成,優先取用戶硬編碼實現的自適應擴展點。
硬編碼(Adaptive注解Class)
案例
假如有個水果擴展類,howMuch來統計交易上下文中該水果能賣多少錢。
@SPI
public interface Fruit {
int howMuch(String context);
}
有蘋果香蕉等實現,負責計算自己能賣多少錢。
public class Apple implements Fruit {
@Override
public int howMuch(String context) {
return context.contains("apple") ? 1 : 0;
}
}
public class Banana implements Fruit {
@Override
public int howMuch(String context) {
return context.contains("banana") ? 2 : 0;
}
}
這里引入一個AdaptiveFruit,在類上加了Adaptive注解,目的是統計上下文中所有水果能賣多少錢。
getSupportedExtensionInstances這個方法能加載所有擴展點,并依靠Prioritized接口實現排序,這個原理忽略,和Spring的Ordered差不多。
@Adaptive
public class AdaptiveFruit implements Fruit {
private final Set
測試方法如下,用戶購買蘋果和香蕉,共花費3元。
核心api是ExtensionLoader#getAdaptiveExtension獲取自適應擴展點實現。
@Test
void testAdaptiveFruit() {
ExtensionLoader
原理
在類加載階段,被Adaptive注解修飾的擴展點Class會被緩存到cachedAdaptiveClass。
注意,Adaptive注解類也不會作為普通擴展點暴露給用戶,即不能通過ExtensionLoader.getExtension通過擴展名直接獲取。
ExtensionLoader#getAdaptiveExtension獲取自適應擴展點。
實例化階段,無參構造反射創建Adaptive擴展點,并執行setter注入。
dubbo優先選取用戶實現的Adaptive擴展點實現,否則會動態生成Adaptive擴展點。
動態生成(Adaptive注解Method)
案例
假設現在有個秒殺水果擴展點SecKillFruit。
相較于剛才的Fruit擴展點, 區別在于入參改為了URL,且方法加了Adaptive注解 。
@SPI
public interface SecKillFruit {
@Adaptive
int howMuch(URL context);
}
蘋果秒殺0元,香蕉秒殺1元。
public class SecKillApple implements SecKillFruit {
@Override
public int howMuch(URL context) {
return 0;
}
}
public class SecKillBanana implements SecKillFruit {
@Override
public int howMuch(URL context) {
return 1;
}
}
擴展點配置文件META-INF/x.y.z.myext4.SecKillFruit:
apple=x.y.z.myext4.impl.SecKillApple
banana=x.y.z.myext4.impl.SecKillBanana
假設場景,每次只能秒殺一種水果,需要根據上下文不同,決定秒殺的是哪種水果,計算不同的價錢。
有下面的測試案例,關鍵點在于URL里增加了sec.kill.fruit=擴展點名,零編碼實現根據URL走不同策略。
sec.kill.fruit是SecKillFruit駝峰解析為小寫后用點分割得到。
@Test
void testAdaptiveFruit2() {
ExtensionLoader
也可以通過指定Adaptive注解的value,讓獲取擴展點名字的邏輯更加清晰。
比如取URL中的fruitType作為獲取擴展名的方式。
@SPI
public interface SecKillFruit {
@Adaptive("fruitType")
int howMuch(URL context);
}
原理
由于Dubbo內部就是用URL做全局上下文來用,你可以理解為字符串無所不能。
所以為了減少重復代碼,很多策略都通過動態生成自適應擴展來實現。
ExtensionLoader#createAdaptiveExtensionClass:如果沒有用戶Adaptive注解實現擴展點,走這里動態生成。
關鍵點在于AdaptiveClassCodeGenerator#generate如何生成java代碼。
擴展點接口必須有Adaptive注解方法,否則getAdaptiveExtension會異常。
關鍵在于generateMethodContent如何實現adaptive方法邏輯。
對于沒有Adaptive注解的方法,直接拋出異常。
對于Adaptive注解的方法,分為四步:
1)獲取URL:優先從參數列表里直接找URL,降級從一個有URL的getter方法的Class里獲取URL,否則異常;
2)決定擴展名:優先從Adaptive注解value屬性獲取,否則取擴展點類名去駝峰加點;
3)獲取擴展點:調用ExtensionLoader.getExtension;
4)委派給目標擴展實現:調用目標擴展的目標方法,傳入原始參數列表;
比如針對SecKillFruit,最終生成的代碼如下。
對于Dubbo來說,雖然擴展點不同,但是都用URL上下文,就可以少寫重復代碼。
public class SecKillFruit$Adaptive implements x.y.z.myext4.SecKillFruit {
// Adaptive注解方法,通過ContextHolder.getUrl獲取URL
public int howMuch2(x.y.z.myext4.ContextHolder arg0) {
if (arg0 == null)
throw new IllegalArgumentException("...");
if (arg0.getUrl() == null)
throw new IllegalArgumentException("...");
org.apache.dubbo.common.URL url = arg0.getUrl();
String extName = url.getParameter("fruitType");
if (extName == null)
throw new IllegalStateException("...");
x.y.z.myext4.SecKillFruit extension = (x.y.z.myext4.SecKillFruit)
ExtensionLoader
.getExtensionLoader(x.y.z.myext4.SecKillFruit.class)
.getExtension(extName);
return extension.howMuch2(arg0);
}
// Adaptive注解方法,直接從參數列表中獲取URL
public int howMuch(org.apache.dubbo.common.URL arg0) {
if (arg0 == null) throw new IllegalArgumentException("url == null");
org.apache.dubbo.common.URL url = arg0;
String extName = url.getParameter("fruitType");
if (extName == null)
throw new IllegalStateException("...");
x.y.z.myext4.SecKillFruit extension = (x.y.z.myext4.SecKillFruit)
ExtensionLoader
.getExtensionLoader(x.y.z.myext4.SecKillFruit.class)
.getExtension(extName);
return extension.howMuch(arg0);
}
// 沒有Adaptive注解的方法
public int howMuch() {
throw new UnsupportedOperationException("...");
}
}
Spring+jdk動態代理實現
上面原理分析不太好理解,這個事情也可以用Spring+jdk動態代理來實現。
其實這個需求和feign的FeignClient、mybatis的Mapper都比較像,寫完接口就相當于寫完實現。
針對同一個擴展點type設計一個 AdaptiveFactoryBean 。
public class AdaptiveFactoryBean implements FactoryBean
核心邏輯在InvocationHandler#invoke代理邏輯中,和AdaptiveClassCodeGenerator#generateMethodContent一樣。
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (!cachedMethod2Adaptive.containsKey(method)) {
throw new UnsupportedOperationException();
}
// 1. 獲取URL
int urlIdx = cachedMethod2URLIndex.get(method);
URL url = (URL) args[urlIdx];
// 2. 從url里獲取擴展點名
Adaptive adaptive = cachedMethod2Adaptive.get(method);
String extName = null;
for (String key : adaptive.value()) {
extName = url.getParameter(key);
if (extName != null) {
break;
}
}
if (extName == null) {
extName = defaultExtName;
}
if (extName == null) {
throw new IllegalStateException();
}
// 3. 獲取擴展點
Object extension =
ExtensionLoader.getExtensionLoader(type).getExtension(extName);
// 4. 委派給擴展點
return method.invoke(extension, args);
}
為了注入所有包含Adaptive注解方法的擴展點AdaptiveFactoryBean,提供一個批量注冊BeanDefinition的 AdaptiveBeanPostProcessor ,實現比較粗糙,主要為了說明問題。
public class AdaptiveBeanPostProcessor implements BeanDefinitionRegistryPostProcessor, EnvironmentAware {
private final String packageToScan;
private Environment environment;
public AdaptiveBeanPostProcessor(String packageToScan) {
this.packageToScan = packageToScan;
}
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false, this.environment) {
@Override
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
// 有Adaptive注解方法
return beanDefinition.getMetadata()
.hasAnnotatedMethods("org.apache.dubbo.common.extension.Adaptive");
}
};
scanner.addIncludeFilter(new AnnotationTypeFilter(SPI.class));
Set
測試驗證:
@Configuration
public class AdaptiveFactoryBeanTest {
@Bean
public AdaptiveBeanPostProcessor adaptiveBeanPostProcessor() {
return new AdaptiveBeanPostProcessor("x.y.z.myext4");
}
@Test
void test() {
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();
applicationContext.register(AdaptiveFactoryBeanTest.class);
applicationContext.refresh();
SecKillFruit secKillFruit = applicationContext.getBean("x.y.z.myext4.SecKillFruit$Adaptive_Spring", SecKillFruit.class);
URL url = new URL("myProtocol", "1.2.3.4", 1010, "path");
// 0元秒殺蘋果
url = url.addParameters("fruitType", "apple");
int money = secKillFruit.howMuch(url);
assertEquals(0, money);
// 1元秒殺香蕉
url = url.addParameters("fruitType", "banana");
money = secKillFruit.howMuch(url);
assertEquals(1, money);
// 無URL方法異常
assertThrows(UnsupportedOperationException.class, secKillFruit::howMuch);
}
}
是不是用spring+動態代理來說明,更加容易理解了。
依賴注入
無論是對于普通擴展點/包裝擴展點/自適應擴展點,所有的擴展點實例都會經過依賴注入。
案例
InjectExt是個擴展點,有實現InjectExtImplA,InjectExtImplA有一個Inner的setter方法。
public class InjectExtImplA implements InjectExt {
private Inner inner;
public void setInner(Inner inner) {
this.inner = inner;
}
@Override
public Inner getInner() {
return inner;
}
}
Inner是個擴展點,且能生成自適應擴展實現。
@SPI
public interface Inner {
@Adaptive
String echo(URL url);
}
Inner有InnerA實現。
public class InnerA implements Inner {
@Override
public String echo(URL url) {
return "A";
}
}
測試方法,InjectExtImplA 被自動注入了Inner的自適應實現 。
@Test
void testInject() {
ExtensionLoader
原理
ExtensionLoader#injectExtension依賴注入,循環每個setter方法,找到入參Class和屬性名。
通過ExtensionFactory搜索依賴,整個注入過程的異常都被捕獲。
ExtensionFactory也是SPI接口。
這里走硬編碼實現的 AdaptiveExtensionFactory ,循環每個ExtensionFactory擴展點,通過type和name找擴展點實現。
ExtensionFactory擴展點有兩個實現。
原生的 SpiExtensionFactory , 沒有利用setter的屬性name ,直接獲取type對應的自適應擴展點。
這也是為什么案例中,被注入的擴展點用了Adaptive。
Spring相關的SpringExtensionFactory支持從多個ioc容器中,通過getBean(setter屬性名,擴展點)獲取bean。
激活擴展點
背景
ExtensionLoader#getExtension可以獲取單個擴展點實現。
ExtensionLoader#getSupportedExtensionInstances可以獲取所有擴展點實現。
現在 需要根據條件,獲取一類擴展點實現,這就是所謂的激活擴展點 。
以Spring為例,如何利用Qualifier做到這點。
假設現在有個用戶接口,根據用戶類型和用戶等級有不同實現。
public interface User {
}
利用Qualifier注解,Category代表用戶類型,Level代表用戶等級。
@Qualifier
public @interface Category {
String value();
}
@Qualifier
public @interface Level {
int value();
}
針對User有四種實現,包括vip1級用戶、vip2級用戶、普通用戶、普通2級用戶。
@Component
@Category("vip")
@Level(1)
public static class VipUser implements User {
}
@Component
@Category("vip")
@Level(2)
public static class GoldenVipUser implements User {
}
@Component
@Category("normal")
public static class UserImpl implements User {
}
@Component
@Category("normal")
@Level(2)
public static class UserImpl2 implements User {
}
通過Qualifier,可以按照需求注入不同類型等級用戶集合,做業務邏輯。
@Configuration
@ComponentScan
public class QualifierTest {
@Autowired
@Category("vip")
private List
案例
和上面Spring的案例一樣,替換成ExtensionLoader實現,看起來語義差不多。
用戶等級作為分組,在URL參數上獲取用戶等級。
@Activate(group = {"vip"}, value = {"level:1"})
public class VipUser implements User {
}
@Activate(group = {"vip"}, value = {"level:2"}, order = 1000)
public class GoldenVipUser implements User {
}
@Activate(group = {"normal"}, order = 10)
public class UserImpl implements User {
}
@Activate(group = {"normal"}, value = {"level:2"}, order = 500)
public class UserImpl2 implements User {
}
測試方法如下,發現與Spring的Qualifier有相同也有不同。
比如通過group=vip和url不包含level去查詢:
1)UserImpl和UserImpl2查不到,因為group不滿足;
2)VipUser和GoldenVipUser查不到,因為url必須有level,且分別為1和2;
又比如通過group=null和level=2去查詢:
1)UserImpl沒有設置Activate注解value,代表對url沒有約束,且查詢條件group=null,代表匹配所有group,所以可以查到;
2)VipUser對url有約束,必須level=1,所以查不到;
3)GoldenVipUser和UserImpl2,都滿足level=2,且查詢條件group=null,代表匹配所有group,所以都能查到;
@Test
void testActivate() {
ExtensionLoader
原理
類加載階段,激活擴展點在普通擴展點分支邏輯中。
所以 激活擴展點只是篩選普通擴展點的方式 ,屬于普通擴展點的子集。
ExtensionLoader#getActivateExtension獲取激活擴展點的入參包含三部分:
1)查詢URL;2)查詢擴展點名稱集合;3)查詢分組
其中1和3用于Activate匹配,2用于直接從getExtension獲取擴展點加在Activate匹配的擴展點之后。
重點看isMatchGroup和isActive兩個方法。
isMatchGroup :如果查詢條件不包含group,則匹配,如果查詢條件包含group,注解中必須有group與其匹配。
isActive :匹配url
1)Activate沒有value約束,匹配
2)url匹配成功條件:如果注解value配置為k:v模式,要求url參數kv完全匹配;如果注解value配置為k模式,只需要url包含kv參數即可。其中k還支持后綴匹配。
比如@Activate(value = {"level"})只需要url中有level=xxx即可,
而@Activate(value = {"level:2"})需要url中level=2。
總結
本文分析了Dubbo2.7.6的SPI實現。
ExtensionLoader相較于java的spi能按需獲取擴展點,還有很多高級特性,與Spring的ioc和aop非常相似。
看似ExtensionLoader的功能都能通過Spring實現,但是Dubbo不想依賴Spring,所以造了套輪子。
題外話:非常夸張的是,Dubbo一個RPC框架,竟然有27w行代碼,而同樣是RPC框架的sofa-rpc5.9.0只有14w行。
除了很多com.alibaba的老包兼容代碼,輪子是真的多,早期版本連json庫都是自己實現的,現在是換成fastjson了。
普通擴展點
ExtensionLoader#getExtension(name),普通擴展點通過擴展名獲取。
@SPI
public interface MyExt {
String echo(URL url, String s);
}
創建普通擴展點分為四個步驟
1)無參構造
2)依賴注入
3)包裝
4)初始化
包裝擴展點
如果擴展點實現包含該擴展點的單參構造方法,被認為是包裝擴展點。
public class WrapperExt implements Ext {
public WrapperExt(Ext ext) {
}
}
包裝擴展點無法通過擴展名顯示獲取,而是在用戶獲取普通擴展點時,自動包裝普通擴展點,返回給用戶,整個過程是透明的。
自適應擴展點
ExtensionLoader#getAdaptiveExtension獲取自適應擴展點。
每個擴展點最多只有一個自適應擴展點。
自適應擴展點分為兩類:硬編碼、動態生成。
硬編碼自適應擴展點,在擴展點實現class上標注Adaptive注解,優先級高于動態生成自適應擴展點。
@Adaptive
public class AdaptiveFruit implements Fruit {
}
動態生成自適應擴展點。
出現的背景是,dubbo中有許多依賴URL上下文選擇不同擴展點策略的場景,如果通過硬編碼實現,會有很多重復代碼。
動態生成自適應擴展點,針對@Adaptive注解方法且方法參數有URL的擴展點,采用javassist字節碼技術,動態生成策略實現。
@SPI
public interface SecKillFruit {
@Adaptive("fruitType")
int howMuch(URL context);
}
激活擴展點
激活擴展點屬于普通擴展點的子集。
激活擴展點利用Activate注解,根據條件匹配一類擴展點實現 。
@Activate(group = {"vip"}, value = {"level:2"}, order = 1000)
public class GoldenVipUser implements User {
}
ExtensionLoader#getActivateExtension:通過group和URL查詢一類擴展點實現。
@Test
void testActivate() {
ExtensionLoader
依賴注入
無論是普通/包裝/自適應擴展點,在暴露給用戶使用前,都會進行setter依賴注入。
依賴注入對象可來源于兩部分:
1)SpiExtensionFactory根據type獲取自適應擴展點
2)SpringExtensionFactory根據setter屬性+type從ioc容器獲取擴展點
-
ECHO
+關注
關注
1文章
73瀏覽量
27225 -
RPC
+關注
關注
0文章
111瀏覽量
11577 -
URL
+關注
關注
0文章
139瀏覽量
15479 -
SPI接口
+關注
關注
0文章
259瀏覽量
34551
發布評論請先 登錄
相關推薦
怎么閱讀Spring源碼
獨家專訪阿里高級技術專家北緯:Dubbo開源重啟半年來的快意江湖
聊聊Dubbo - Dubbo可擴展機制實戰
聊聊Dubbo - Dubbo可擴展機制源碼解析
Dubbo開源現狀與未來規劃
Dubbo Cloud Native 之路的實踐與思考
Dubbo源代碼實現服務調用的動態代理和負載均衡
服務化改造實踐(一)| Dubbo + ZooKeeper
![服務化改造實踐(<b class='flag-5'>一</b>)| <b class='flag-5'>Dubbo</b> + ZooKeeper](https://file.elecfans.com/web1/M00/61/59/pIYBAFuDuCaANVyLAAEeMg_F3gA954.png)
服務化改造實踐(一)| Dubbo + ZooKeeper
![服務化改造實踐(<b class='flag-5'>一</b>)| <b class='flag-5'>Dubbo</b> + ZooKeeper](https://file.elecfans.com/web1/M00/61/5C/pIYBAFuDw4qAFXuaAAEeMg_F3gA989.png)
STM32入門:軟件 SPI 源碼分享
![STM32入門:軟件 <b class='flag-5'>SPI</b> <b class='flag-5'>源碼</b>分享](https://file.elecfans.com/web1/M00/D9/4E/pIYBAF_1ac2Ac0EEAABDkS1IP1s689.png)
評論