好久沒寫博客了,這段時間主要是各種充電,因為前面寫的一些東西,可能大家不太感興趣或者是嫌棄沒啥技術含量,所以這次特意下了一番功夫。這篇博客其實我花了周末整整兩天寫好了第一個版本,已經開源出去了,同樣是像以前那樣用來拋磚引玉。下面進入正題!
當我們想在springboot實現一個配置集中管理,自動更新就會遇到如下尷尬的場景:
1. 啥?我就存個配置還要安裝個配置中心服務,配置中心服務掛了咋辦,你給我重啟嗎?
2. 啥?配置中心也要高可用,還要部署多個避免單點故障,服務器資源不要錢嗎,我分分鍾能有個小目標嗎?
3. 啥?你跟我說我存個配置還要有個單獨的地方存儲,什么git,阿波羅,git還用過,阿波羅?我是要登月嗎?
4. 啥?我實現一個在線更新配置還要依賴actuator模塊,這是個什么東西
5. 啥?我還要依賴消息隊列,表示沒用過
6. 啥?還要引入springcloud bus,啥子鬼東西,壓根不知道你說啥
我想大多人遇到上面那些場景,都會對配置中心望而卻步吧,實在是太麻煩了。我就想實現一個可以自動更新配置的功能就要安裝一個單獨的服務,還要考慮單獨服務都應該考慮的各種問題,負載均衡,高可用,唉!這東西不是人能用的,已經在用的哥們姐們,你們都是神!很反感一想到微服務就要部署一大堆依賴服務,什么注冊中心,服務網關,消息隊列我也就忍了,你一個放配置的也要來個配置中心,還要部署多個來個高可用,你丫的不要跟我說部屬一個單點就行了,你牛,你永遠不會掛!所以沒足夠服務器不要想着玩太多花,每個java服務就要用一個單獨的虛擬機加載全套的jar包(這里說的是用的最多的jdk8,據說后面版本可以做到公用一部分公共的jar),這都要資源。投機取巧都是我這種懶得學習這些新技術新花樣的人想出來的。下面開始我們自己實現一個可以很方便的嵌入到自己的springboot項目中而不需要引入新服務的功能。
想到要實現一個外部公共地方存放配置,首先可以想到把配置存在本地磁盤或者網絡,我們先以本地磁盤為例進行今天的分享。要實現一個在運行時隨時修改配置的功能需要解決如下問題:
1. 怎么讓服務啟動就讀取自己需要讓他讀取的配置文件(本地磁盤的,網絡的,數據庫里的配置)
2. 怎么隨時修改如上的配置文件,並且同時刷新spring容器中的配置(熱更新配置)
3. 怎么把功能集成到自己的springboot項目中
要實現第一點很簡單,如果是本地文件系統,java nio有一個文件監聽的功能,可以監聽一個指定的文件夾,文件夾里的文件修改都會已事件的方式發出通知,按照指定方式實現即可。要實現第二點就有點困難了,首先要有一個共識,spring中的bean都會在啟動階段被封裝成BeanDefinition對象放在map中,這些BeanDefinition對象可以類比java里每個類都會有一個Class對象模板,后續生成的對象都是以Class對象為模板生成的。spring中國同樣也是以BeanDefinition為模板生成對象的,所以基本要用到的所有信息在BeanDefinition都能找到。由於我們項目中絕大多數被spring管理的對象都是單例的,沒人會惡心到把配置類那些都搞成多例的吧!既然是單例我們只要從spring容器中找到,再通過反射強行修改里面的@Value修飾的屬性不就行了,如果你們以為就這么簡單,那就沒有今天這篇博客了。如下:
private void updateValue(Map<String,Object> props) { Map<String,Object> classMap = applicationContext.getBeansWithAnnotation(RefreshScope.class); if(classMap == null || classMap.isEmpty()) { return; } classMap.forEach((beanName,bean) -> { /** * 這是一個坑爹的東西,這里保存一下spring生成的代理類的字節碼由於有些@Value可能在@Configuration修飾的配置類下, * 被這個注解修飾的配置類里面的屬性在代理類會消失,只留下對應的getXX和setXX方法,導致下面不能直接通過反射直接 * 修改屬性的值,只能通過反射調用對應setXX方法修改屬性的值 */ // saveProxyClass(bean); Class<?> clazz = bean.getClass(); /** * 獲取所有可用的屬性 */ Field[] fields = clazz.getDeclaredFields(); /** * 使用反射直接根據屬性修改屬性值 */ setValue(bean,fields,props); }); } private void setValue(Object bean,Field[] fields,Map<String,Object> props) { for(Field field : fields) { Value valueAnn = field.getAnnotation(Value.class); if (valueAnn == null) { continue; } String key = valueAnn.value(); if (key == null) { continue; } key = key.replaceAll(VALUE_REGEX,"$1"); key = key.split(COLON)[0]; if (props.containsKey(key)) { field.setAccessible(true); try { field.set(bean, props.get(key)); } catch (Exception e) { e.printStackTrace(); } } } } /** * 只為測試導出代理對象然后反編譯 * @param bean */ private void saveProxyClass(Object bean) { byte[] bytes = ProxyGenerator.generateProxyClass("T", new Class[]{bean.getClass()}); try { Files.write(Paths.get("F:\\fail2\\","T.class"),bytes, StandardOpenOption.CREATE); } catch (IOException e) { e.printStackTrace(); } }
如上代碼,完全使用反射直接強行修改屬性值,確實可以解決一部分屬性修改的問題,但是還有一部分被@Configuration修飾的類就做不到了,因為spring中使用cglib對修飾這個注解的類做了代理,其實可以理解成生成了另外一個完全不一樣的類,類里那些被@Value修飾的屬性都被去掉了,就留下一堆setXX方法,鬼知道那些方法要使用那些key去注入配置。如果有人說使用上面代碼將就下把需要修改的配置不放在被@Configuration修飾的類下就好了。如果有這么low,我就不用寫這篇博客了,要實現就實現一個五臟俱全的功能,不能讓使用者遷就你的不足。這里提一下上面的@RefreshScope注解,這個注解並不是springboot中的配置服務那個注解,是自己定義的一個同名注解,因為用它那個要引入配置服務的依賴,為了一個注解引入一個依賴不值得。下面是實現配置更新的核心類:
package com.rdpaas.easyconfig.context; import com.rdpaas.easyconfig.ann.RefreshScope; import com.rdpaas.easyconfig.observer.ObserverType; import com.rdpaas.easyconfig.observer.Observers; import com.rdpaas.easyconfig.utils.PropUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.core.type.AnnotationMetadata; import org.springframework.core.type.MethodMetadata; import org.springframework.stereotype.Component; import java.io.File; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.HashMap; import java.util.Iterator; import java.util.Map; /** * 自定義的springboot上下文類 * @author rongdi * @date 2019-09-21 10:30:01 */public class SpringBootContext implements ApplicationContextAware { private Logger logger = LoggerFactory.getLogger(SpringBootContext.class); private final static String REFRESH_SCOPE_ANNOTATION_NAME = "com.rdpaas.easyconfig.ann.RefreshScope"; private final static Map<Class<?>, SpringAnnotatedRefreshScopeBeanInvoker> refreshScopeBeanInvokorMap = new HashMap<>(); private final static String VALUE_REGEX = "\\$\\{(.*)}"; private static ApplicationContext applicationContext; private static String filePath; @Override public void setApplicationContext(ApplicationContext ac) throws BeansException { applicationContext = ac; try { /** * 初始化准備好哪些類需要更新配置,放入map */ init(); /** * 如果有配置文件中配置了文件路徑,並且是本地文件,則開啟對應位置的文件監聽 */ if(filePath != null && !PropUtil.isWebProp(filePath)) { File file = new File(filePath); String dir = filePath; /** * 誰讓java就是nb,只能監聽目錄 */ if(!file.isDirectory()) { dir = file.getParent(); } /** * 開啟監聽 */ Observers.startWatch(ObserverType.LOCAL_FILE, this, dir); } } catch (Exception e) { logger.error("init refresh bean error",e); } } /** * 刷新spring中被@RefreshScope修飾的類或者方法中涉及到配置的改變,注意該類可能被@Component修飾,也有可能被@Configuration修飾 * 1.類中被@Value修飾的成員變量需要重新修改更新后的值( * 2.類中使用@Bean修飾的方法,如果該方法需要的參數中有其他被@RefreshScope修飾的類的對象,這個方法生成的類也會一同改變 * 3.類中使用@Bean修飾的方法循環依賴相互對象會報錯,因為這種情況是屬於構造方法層面的循環依賴,spring里也會報錯, * 所以我們也不需要考慮循環依賴 */ private void init() throws ClassNotFoundException { /** * 將applicationContext轉換為ConfigurableApplicationContext */ ConfigurableApplicationContext configurableApplicationContext = (ConfigurableApplicationContext) applicationContext; /** * 獲取bean工廠並轉換為DefaultListableBeanFactory */ DefaultListableBeanFactory defaultListableBeanFactory = (DefaultListableBeanFactory) configurableApplicationContext.getBeanFactory(); /** * 獲取工廠里的所有beanDefinition,BeanDefinition作為spring管理的對象的創建模板,可以類比java中的Class對象, */ String[] beanDefinitionNames = applicationContext.getBeanDefinitionNames(); for(String beanName : beanDefinitionNames) { BeanDefinition bd = defaultListableBeanFactory.getBeanDefinition(beanName); /** * 使用注解加載到spring中的對象都屬於AnnotatedBeanDefinition,畢竟要實現刷新配置也要使用@RefreshScope * 沒有人喪心病狂的使用xml申明一個bean並且在類中加一個@RefreshScope吧,這里就不考慮非注解方式加載的情況了 */ if(bd instanceof AnnotatedBeanDefinition) { /** * 得到工廠方法的元信息,使用@Bean修飾的方法放入beanDefinitionMap的beanDefinition對象這個值都不會為空 */ MethodMetadata factoryMethodMeta = ((AnnotatedBeanDefinition) bd).getFactoryMethodMetadata(); /** * 如果不為空,則該對象是使用@Bean在方法上修飾產生的 */ if(factoryMethodMeta != null) { /** * 如果該方法沒有被@RefreshScope注解修飾,則跳過 */ if(!factoryMethodMeta.isAnnotated(REFRESH_SCOPE_ANNOTATION_NAME)) { continue; } /** * 拿到未被代理的Class對象,如果@Bean修飾的方法在@Configuration修飾的類中,會由於存在cglib代理的關系 * 拿不到原始的Method對象 */ Class<?> clazz = Class.forName(factoryMethodMeta.getDeclaringClassName()); Method[] methods = clazz.getDeclaredMethods(); /** * 循環從class對象中拿到的所有方法對象,找到當前方法並且被@RefreshScope修飾的方法構造invoker對象 * 放入執行器map中,為后續處理@ConfigurationProperties做准備 */ for(Method m : methods) { if(factoryMethodMeta.getMethodName().equals(m.getName()) && m.isAnnotationPresent(RefreshScope.class)) { refreshScopeBeanInvokorMap.put(Class.forName(factoryMethodMeta.getReturnTypeName()), new SpringAnnotatedRefreshScopeBeanInvoker(true, defaultListableBeanFactory, beanName, (AnnotatedBeanDefinition)bd, clazz,m)); } } } else { /** * 這里顯然是正常的非@Bean注解產生的bd對象了,拿到元信息判斷是否被@RefreshScope修飾,這里可不能用 * bd.getClassName這個拿到的是代理對象,里面自己定義的屬性已經被去掉了,更加不可能拿到被@Value修飾 * 的屬性了 */ AnnotationMetadata at = ((AnnotatedBeanDefinition) bd).getMetadata(); if(at.isAnnotated(REFRESH_SCOPE_ANNOTATION_NAME)) { Class<?> clazz = Class.forName(at.getClassName()); /** * 先放入執行器map,后續循環處理,其實為啥要做 */ refreshScopeBeanInvokorMap.put(clazz, new SpringAnnotatedRefreshScopeBeanInvoker(false, defaultListableBeanFactory, beanName, (AnnotatedBeanDefinition)bd, clazz,null)); } } } } } /** * 根據傳入屬性刷新spring容器中的配置 * @param props */ public void refreshConfig(Map<String,Object> props) throws InvocationTargetException, IllegalAccessException { if(props.isEmpty() || refreshScopeBeanInvokorMap.isEmpty()) { return; } /** * 循環遍歷要刷新的執行器map,這里為啥沒用foreach就是因為沒法向外拋異常,很讓人煩躁 */ for(Iterator<Map.Entry<Class<?>, SpringAnnotatedRefreshScopeBeanInvoker>> iter = refreshScopeBeanInvokorMap.entrySet().iterator(); iter.hasNext();) { Map.Entry<Class<?>, SpringAnnotatedRefreshScopeBeanInvoker> entry = iter.next(); SpringAnnotatedRefreshScopeBeanInvoker invoker = entry.getValue(); boolean isMethod = invoker.isMethod(); /** * 判斷執行器是不是代表的一個@Bean修飾的方法 */ if(isMethod) { /** * 使用執行器將屬性刷新到@Bean修飾的方法產生的對象中,使其支持@ConfigurationProperties注解 */ invoker.refreshPropsIntoBean(props); } else { /** * 使用執行器將屬性刷新到對象中 */ invoker.refreshPropsIntoField(props); } } } public static void setFilePath(String filePath) { SpringBootContext.filePath = filePath; } }
如上代碼中,寫了很詳細的注釋,主要思路就是實現ApplicationContextAware接口讓springboot初始化的時候給我注入一個applicationContext,進而可以遍歷所有的BeanDefinition。先在獲得了applicationContext的時候找到被@RefreshScope修飾的類或者方法塊放入全局的map中。然后在配置修改的監聽收到事件后觸發刷新配置,刷新配置的過程就是使用反射強行修改實例的值,由於spring管理的對象基本都是單例的,假設spring容器中有兩個對象A和B,其中B引用了A,那么修改A的屬性,那么引用A的B對象同時也會跟着修改,因為B里引用的A已經變了,但是引用地址沒變,再次調用A的方法實際上是調用了改變后的A的方法。寫程序的過程實際上是運用分治法將一個大任務拆成多個小任務分別委派給多個類處理,最后匯總返回。每個類都是對調用方透明的封裝體,各自的修改后的效果也最終會反應到調用方上來。回到正題,核心類中用到的封裝好的執行器類如下
package com.rdpaas.easyconfig.context; import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Map; /** * 封裝的執行器,主要負責真正修改屬性值 * @author rongdi * @date 2019-09-21 10:10:01 */ public class SpringAnnotatedRefreshScopeBeanInvoker { private final static String SET_PREFIX = "set"; private final static String VALUE_REGEX = "\\$\\{(.*)}"; private final static String COLON = ":"; private DefaultListableBeanFactory defaultListableBeanFactory; private boolean isMethod = false; private String beanName; private AnnotatedBeanDefinition abd; private Class<?> clazz; private Method method; public SpringAnnotatedRefreshScopeBeanInvoker(boolean isMethod, DefaultListableBeanFactory defaultListableBeanFactory, String beanName, AnnotatedBeanDefinition abd, Class<?> clazz, Method method) { this.abd = abd; this.isMethod = isMethod; this.defaultListableBeanFactory = defaultListableBeanFactory; this.beanName = beanName; this.clazz = clazz; this.method = method; } public boolean isMethod() { return isMethod; } /** * 把屬性值刷新到屬性中 * @param props */ public void refreshPropsIntoField(Map<String,Object> props) throws IllegalAccessException { /** * 先根據beanName再根據beanType獲取spring容器中的對象 */ Object bean = defaultListableBeanFactory.getBean(beanName); if(bean == null) { bean = defaultListableBeanFactory.getBean(clazz); } /** * 獲取類上可能被修飾的注解 */ ConfigurationProperties cp = clazz.getAnnotation(ConfigurationProperties.class); String prefix = ""; if(cp != null && cp.prefix() != null && !"".equals(cp.prefix().trim())) { prefix = cp.prefix() + "."; } /** * 獲取所有可用的屬性 */ Field[] fields = clazz.getDeclaredFields(); for(Field field : fields) { /** * 如果屬性被@Value修飾 */ Value valueAnn = field.getAnnotation(Value.class); if (valueAnn == null && "".equals(prefix)) { continue; } String key = ""; /** * 如果沒有@Value注解則直接根據前綴拼接屬性名作為key,否則以前綴拼接@Value里的key */ if(valueAnn == null) { key = prefix + field.getName(); } else { key = valueAnn.value(); /** * 提取@Value("${xx.yy:dd}")中的key:xx.yy */ key = key.replaceAll(VALUE_REGEX,"$1"); /** * 如果前綴不為空,拼接上前綴 */ key = prefix + key.split(COLON)[0]; } /** * 如果屬性map中包含@Value注解中的key,強行使用反射修改里面的值, * 嚴格意義來說應該是使用對應setXX方法修改屬性值,這里圖方便直接使用屬性修改了 */ if (props.containsKey(key)) { field.setAccessible(true); field.set(bean, props.get(key)); } } } /** * 把屬性值刷新到Bean方法返回的對象中 * @param props */ public void refreshPropsIntoBean(Map<String,Object> props) throws InvocationTargetException, IllegalAccessException { if(!method.isAnnotationPresent(ConfigurationProperties.class)) { return; } /** * 獲取方法上可能被修飾的注解 */ ConfigurationProperties cp = method.getAnnotation(ConfigurationProperties.class); /** * 獲取到注解上的前綴信息並且拼上 */ String prefix = cp.prefix() + "."; /** * 獲取@Bean方法的返回值類型 */ Class<?> returnClazz = method.getReturnType(); /** * 先根據beanName再根據返回的beanType獲取spring容器中的對象 */ Object bean = defaultListableBeanFactory.getBean(beanName); if(bean == null) { bean = defaultListableBeanFactory.getBean(returnClazz); } /** * 循環返回類型里的所有setXX方法,調用對應的方法修改返回對象里的屬性值 */ Method[] methods = returnClazz.getDeclaredMethods(); for(Method m : methods) { /** * 根據set方法獲取對應的屬性名稱 */ String name = getNameBySetMethod(m); if(name == null) { continue; } String key = prefix + name; if (props.containsKey(key)) { m.invoke(bean,props.get(key)); } } } /** * 根據set方法獲取對應的屬性名稱 */ private String getNameBySetMethod(Method setMethod) { String setMethodName = setMethod.getName(); /** * 如果方法名為空 * 如果方法不是以set開頭 * 如果方法名長度小於4 * 如果set后第一個字母不是大寫 * 這些通通不是setXX方法 */ if(setMethodName == null || !setMethodName.startsWith(SET_PREFIX) || setMethodName.length() < 4 || !Character.isUpperCase(setMethodName.charAt(3))) { return null; } /** * 獲取把名稱第一位大寫變成小寫的屬性名 */ String tempName = setMethodName.substring(3); return tempName.substring(0,1).toLowerCase() + tempName.substring(1); } }
如上代碼,就是一些簡單的反射調用,注釋都寫在代碼里了。
package com.rdpaas.easyconfig.ann; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 自定義的修飾可以被刷新的注解,模仿springcloud的同名注解 * @author rongdi * @date 2019-09-21 10:00:01 */ @Target({ElementType.TYPE,ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface RefreshScope { }
下面就是一個工具類
package com.rdpaas.easyconfig.utils; import java.util.HashMap; import java.util.Map; import java.util.Properties; /** * 屬性工具類 * @author rongdi * @date 2019-09-21 16:30:07 */ public class PropUtil { public static boolean isWebProp(String filePath) { return filePath.startsWith("http:") || filePath.startsWith("https:"); } public static Map<String,Object> prop2Map(Properties prop) { Map<String,Object> props = new HashMap<>(); prop.forEach((key,value) -> { props.put(String.valueOf(key),value); }); return props; } }
然后說說,怎么在springboot啟動的時候加載自己定義的配置文件,這里可以參考springboot啟動類SpringApplication的源碼找到端倪,如下現在resources目錄下新建一個META-INF文件夾,然后在文件夾新建一個spring.factories文件內容如下:
org.springframework.boot.env.EnvironmentPostProcessor=com.rdpaas.easyconfig.boot.InitSettingsEnvironmentPostProcessor
配置類中實現類如下
package com.rdpaas.easyconfig.boot; import com.rdpaas.easyconfig.context.SpringBootContext; import com.rdpaas.easyconfig.utils.PropUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.env.EnvironmentPostProcessor; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.MutablePropertySources; import org.springframework.core.env.PropertiesPropertySource; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.core.io.support.PropertiesLoaderUtils; import java.io.File; import java.io.IOException; import java.util.Properties; /** * 使用環境的后置處理器,將自己的配置放在優先級最高的最前面,這里其實是仿照springboot中 * SpringApplication構造方法 ->setInitializers()->getSpringFactoriesInstances()->loadFactoryNames()-> * loadSpringFactories(@Nullable ClassLoader classLoader)斷點到里面可以發現這里會加載各個jar包 * FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories"文件,所以這里把這個類配置本模塊的同樣 * 位置,內容為org.springframework.boot.env.EnvironmentPostProcessor=com.rdpaas.easyconfig.boot.InitSettingsEnvironmentPostProcessor * 這里其實java的spi的方式,springboot中大量使用這種花樣 * @author rongdi * @date 2019-09-21 11:00:01 */ public class InitSettingsEnvironmentPostProcessor implements EnvironmentPostProcessor { private Logger logger = LoggerFactory.getLogger(InitSettingsEnvironmentPostProcessor.class); private final static String FILE_KEY = "easyconfig.config.file"; private final static String FILE_PATH_KEY = "easyconfig.config.path"; @Override public void postProcessEnvironment(ConfigurableEnvironment configurableEnvironment, SpringApplication application) { /** * 得到當前環境的所有配置 */ MutablePropertySources propertySources = configurableEnvironment.getPropertySources(); try { /** * 拿到bootstrap.properties文件,並讀取 */ File resourceFile = new File(InitSettingsEnvironmentPostProcessor.class.getResource("/bootstrap.properties").getFile()); FileSystemResource resource = new FileSystemResource(resourceFile); Properties prop = PropertiesLoaderUtils.loadProperties(resource); /** * 找到配置文件中的FILE_KEY配置,這個配置表示你想把配置文件放在哪個目錄下 */ String filePath = prop.getProperty(FILE_KEY); /** * 判斷文件資源是網絡或者本地文件系統,比如從配置中心獲取的就是網絡的配置信息 */ boolean isWeb = PropUtil.isWebProp(filePath); /** * 根據資源類型,網絡或者本地文件系統初始化好配置信息,其實springcloud中配置服務就是可以 * 直接通過一個url獲取到屬性,這個url地址也可以放在這里,spring就是好東西,UrlResource這種工具 * 也有提供,也免了自己寫的麻煩了 */ Properties config = new Properties(); Resource configRes = null; if(isWeb) { configRes = new UrlResource(filePath); } else { configRes = new FileSystemResource(filePath); } try { /** * 將資源填充到config中 */ PropertiesLoaderUtils.fillProperties(config, configRes); /** * 將自己配置的資源加入到資源列表的最前面,使其具有最高優先級 */ propertySources.addFirst(new PropertiesPropertySource("Config", config)); } catch (IOException e) { logger.error("load config error",e); } /** * 將讀出來的filePath設置到環境類中,暫時只搞一個文件,要搞多個文件也很簡單 */ SpringBootContext.setFilePath(filePath); } catch (Exception e) { logger.info("load easyconfig bootstrap.properties error",e); } } }
如上是實現springboot啟動的時候先根據本實現依賴的唯一配置文件bootstrap.properties,在里面指定好使用哪個文件作為服務的配置文件,思路和解釋都直接寫在上面代碼里了,這里就不再說了,下面再看看文件監聽怎么實現:
package com.rdpaas.easyconfig.observer; import com.rdpaas.easyconfig.context.SpringBootContext; import java.io.IOException; import java.util.concurrent.ExecutorService; /** * 觀察者基類 * @author rongdi * @date 2019-09-21 14:30:01 */ public abstract class Observer { protected volatile boolean isRun = false; public abstract void startWatch(ExecutorService executorService, SpringBootContext context, String target) throws IOException; public void stopWatch() throws IOException { isRun = false; } public abstract void onChanged(SpringBootContext context, Object... data); }
package com.rdpaas.easyconfig.observer; import com.rdpaas.easyconfig.context.SpringBootContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.support.PropertiesLoaderUtils; import java.io.File; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.nio.file.FileSystems; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.concurrent.ExecutorService; /** * 本地文件目錄監聽器 */ public class LocalFileObserver extends Observer { private Logger logger = LoggerFactory.getLogger(LocalFileObserver.class); @Override public void startWatch(ExecutorService executorService, SpringBootContext context, String filePath) throws IOException { isRun = true; /** * 設置需要監聽的文件目錄(只能監聽目錄) */ WatchService watchService = FileSystems.getDefault().newWatchService(); Path p = Paths.get(filePath); /** * 注冊監聽事件,修改,創建,刪除 */ p.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_CREATE); executorService.execute(() -> { try { while(isRun){ /** * 拿出一個輪詢所有event,如果有事件觸發watchKey.pollEvents();這里就有返回 * 其實這里類似於nio中的Selector的輪詢,都是屬於非阻塞輪詢 */ WatchKey watchKey = watchService.take(); List<WatchEvent<?>> watchEvents = watchKey.pollEvents(); for(WatchEvent<?> event : watchEvents){ /** * 拼接一個文件全路徑執行onChanged方法刷新配置 */ String fileName = filePath + File.separator +event.context(); logger.info("start update config event,fileName:{}",fileName); onChanged(context,fileName); } watchKey.reset(); } } catch (InterruptedException e) { e.printStackTrace(); } }); } @Override public void onChanged(SpringBootContext context, Object... data) { /** * 取出傳遞過來的參數構造本地資源文件 */ File resourceFile = new File(String.valueOf(data[0])); FileSystemResource resource = new FileSystemResource(resourceFile); try { /** * 使用spring工具類加載資源,spring真是個好東西,你能想到的基本都有了 */ Properties prop = PropertiesLoaderUtils.loadProperties(resource); Map<String,Object> props = new HashMap<>(); prop.forEach((key,value) -> { props.put(String.valueOf(key),value); }); /** * 調用SpringBootContext刷新配置 */ context.refreshConfig(props); } catch(InvocationTargetException | IllegalAccessException e1){ logger.error("refresh config error",e1); }catch (Exception e) { logger.error("load config error",e); } } }
package com.rdpaas.easyconfig.observer; import com.rdpaas.easyconfig.context.SpringBootContext; import java.io.IOException; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; /** * 觀察者工具類 * @author rongdi * @date 2019-09-21 15:30:09 */ public class Observers { private final static ExecutorService executorService = new ThreadPoolExecutor(1,1,0, TimeUnit.MILLISECONDS,new ArrayBlockingQueue<>(10)); private static Observer currentObserver; /** * 啟動觀察者 * @param type * @param context * @param target * @throws IOException */ public static void startWatch(ObserverType type, SpringBootContext context,String target) throws IOException { if(type == ObserverType.LOCAL_FILE) { currentObserver = new LocalFileObserver(); currentObserver.startWatch(executorService,context,target); } } /** * 關閉觀察者 * @param type * @throws IOException */ public static void stopWatch(ObserverType type) throws IOException { if(type == ObserverType.LOCAL_FILE) { if(currentObserver != null) { currentObserver.stopWatch(); } } } }
package com.rdpaas.easyconfig.observer; /** * 觀察者類型,如觀察本地文件,網絡文件,數據庫數據等 * @author rongdi * @date 2019-09-21 16:30:01 */ public enum ObserverType { LOCAL_FILE, DATEBASE; }
如上使用了nio中的文件監聽來監聽文件夾中的文件變化,如果變化調用自己提供的onChanged方法修改spring容器中的配置。初始化的時候實際上已經實現了從本地文件系統和其他注冊中心中讀取網絡配置,用過配置中心的應該知道配置中心提供的配置就是可以直接用瀏覽器通過http連接直接訪問到。只要在bootstrap.properties配置好如下配置就好了
#這里配置使用的本地或網絡配置文件,可以使用配置服務提供的http地址 easyconfig.config.file:E:/test/config/11.txt
但是上面的監聽第一個版本只是實現了本地文件系統的監聽,如果要實現網絡文件或者數據庫的監聽,是需要開一個定時器輪詢就好了,也是很方便實現,后續有空這些會補上,感興趣的也可以自己實現,應該不難。其實說到這里只剩下最后一步,怎么讓客戶代碼方便的把這個功能接入到自己的springboot項目中了,這里使用類似springboot的@EnableXX完成接入,這花樣都被springboot玩爛了。。。
package com.rdpaas.easyconfig.ann; import com.rdpaas.easyconfig.boot.EasyConfigSelector; import org.springframework.context.annotation.Import; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 開啟easyconfig的注解,其實springboot里各種開啟接口,只是使用spring * 的importSelector玩了一個花樣,所以這里是關鍵@Import(EasyConfigSelector.class) * 具體可以看spring5中org.springframework.context.annotation.ConfigurationClassParser#processImports(org.springframework.context.annotation.ConfigurationClass, org.springframework.context.annotation.ConfigurationClassParser.SourceClass, java.util.Collection, boolean) * 其實有很多方式實現@EnableXX比如先給SpringBootContext類使用@Component修飾然后使用如下注釋部分注解 * @ComponentScan("com.rdpaas") 或者 @Import({SpringBootContext.class}) 強行掃描你需要掃描的類並加載 * spring的魅力在於擴展很靈活,只有你想不到沒有他做不到,呵呵 * @author rongdi * @date 2019-09-22 8:01:09 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Import(EasyConfigSelector.class) //@ComponentScan("com.rdpaas") //@Import({SpringBootContext.class}) public @interface EnableEasyConfig { }
擴展方法很多,請多看代碼里的注釋,博客不咋會排版,還是把東西寫在注釋里方便點,哈哈!
import com.rdpaas.easyconfig.context.SpringBootContext; import org.springframework.context.annotation.ImportSelector; import org.springframework.core.type.AnnotationMetadata; /** * 這里就是配合@EnableEasyConfig使用的目的。在注解@EnableEasyConfig上使用@Import(EasyConfigSelector.class) * 來讓spring在檢測到有這個注解時,加載下面selectImports方法里提供的數組里代表的類,其實就是為了避免需要在 * SpringBootContext類顯示使用@Component注解,畢竟萬一有人不用這東西或者是別人項目中壓根就不配置掃碼你的 * com.rdpaas包那也會出現SpringBootContext類無法正常被掃描導致無法正常進行工作。簡單來說自己提供的依賴包應該 * 盡量直接使用@Component注解讓spring管理(鬼知道還要去掃描你的包名呢),需要讓用戶自己選擇是否需要被spring管理 * @author rongdi * @date 2019-09-22 8:05:14 */ public class EasyConfigSelector implements ImportSelector{ @Override public String[] selectImports(AnnotationMetadata importingClassMetadata) { return new String[]{SpringBootContext.class.getName()}; } }
代碼中有詳細的注釋,至此第一個版本的功能已全部實現,如果感興趣可以直接copy到自己項目中使用就行了,隨着自己的spring boot微服務一起啟動也不需要單獨部署什么配置中心啥的。至於如果不用pringboot直接使用spring的朋友,其實我之前以為springboot和spring的BeanDefinition結構應該是一樣的,我最開始也是直接使用spring啟動來做的,結果發現白費了,bd的結構區別還是很大的,具體表現在被@Configuration修飾的類產生的bd獲取非代理類名的方式不一樣,感興趣可以留言我可以貼出最開始處理spring的代碼,本篇主要是講的是springboot的集成。本篇博客的具體使用樣例在github中有,這里就不浪費篇幅了,詳細代碼可直接查看github鏈接:https://github.com/rongdi/easy-config,其實這個實現還有一個問題需要解決
自詡配置非中心,雖然確實跟隨自己的業務微服務啟動了,但是配置在本地文件中也就意味着一個微服務要改一次,雖然可以從網絡中讀取配置但是沒有實現網絡配置文件的監聽器了。這個其實很好實現上文有提到思路
最后再說說個人見解,嫌羅嗦的可以直接忽略,哈哈!
-----------------------------------------------------------------------------------------------------------------這是里無聊的分割線------------------------------------------------------------------------------------------
我覺得開源的目的是能讓大部分人看懂,不要去玩一些花樣然后跟別人說看這個代碼需要基礎,然后導致大部分開發人員甚至到了10年以上還停留在業務代碼層面。不是人家不想學實在是你那玩意要看懂很費勁啊。外國開源的項目晦澀難懂也就算了,可能外國人腦回路不一樣,人家同類可以懂。一個國內開源的項目讓國內程序員都很難看懂,你怕不是為了別人能看懂吧,只是為了吹噓下你牛b的功能吧。其實一個基礎好點的程序員你給他時間啥框架寫不出來呢(你了解一下反射,aop,動態代理,spi等這些基礎的東西,還不會模仿市面上一些流行的框架可以跟我溝通)。個人和團隊的開源的東西區別就在細節和性能上,當然這兩個東西用在一樣的場景上可能都完全不需要考慮,屁大點項目需要細節嗎實現功能就行了,夠用就行了,需要性能嗎,才百十來人使用你跟我扯這些沒用的。照成已經有很多成熟方案后我還是在重復照輪子的根本原因是,別人提供的輪子自己根本掌控不了,可能連看懂都很費勁,我就屁大點需求,你讓我花十天半月去研究嗎,不研究遇到坑你能實時幫我解決嗎。程序中玩再多花,把方法調用寫的足夠深,真正編譯后還不是可能被虛擬機直接優化到一個方法里,你有什么可以牛的呢。一邊標榜程序是給人看的,我想問你開源的代碼真的有人看懂嗎。並不是人家能力不夠主要是因為每個人的思維就不一樣,不能用自己認為的簡單去套用到別人身上。有些人或組織寫代碼調用層次故意搞得很深,美其名曰為了擴展性,請問你還要擴展啥東西,啥時候要擴展,剛開始有必要搞得那么復雜嗎?還是說只是為了讓人看不懂,裝下b。所以誠心開源的,還是注釋寫清楚好,我人微言輕,是個不知名的還在貧困線掙扎的小碼農,如果冒犯了各位大神請不要見怪,純粹當我發神經就好了!