自己動手實現springboot配置(非)中心
好久沒寫部落格了,這段時間主要是各種充電,因為前面寫的一些東西,可能大家不太感興趣或者是嫌棄沒啥技術含量,所以這次特意下了一番功夫。這篇部落格其實我花了週末整整兩天寫好了第一個版本,已經開源出去了,同樣是像以前那樣用來拋磚引玉。下面進入正題!
當我們想在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.env.Environment; import org.springframework.core.type.AnnotationMetadata; import org.springframework.core.type.MethodMetadata; 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 COLON = ":"; private final static String SET_PREFIX = "set"; private final static String VALUE_REGEX = "\\$\\{(.*)}"; private static ApplicationContext applicationContext; private static Environment environment; private static String filePath; public static ApplicationContext getApplicationContext() { return applicationContext; } @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修飾的方法產生的物件中,這裡暫時不需要處理,僅僅@Value註解不需要處理@Bean * 修飾的方法 * TODO */ } 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.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Map; /** * 封裝的執行器,主要負責真正修改屬性值 * @author rongdi * @date 2019-09-21 10:10:01 */ public class SpringAnnotatedRefreshScopeBeanInvoker { private Logger logger = LoggerFactory.getLogger(SpringAnnotatedRefreshScopeBeanInvoker.class); 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) { /** * 先根據beanName再根據beanType獲取spring容器中的物件 */ Object bean = defaultListableBeanFactory.getBean(beanName); if(bean == null) { bean = defaultListableBeanFactory.getBean(clazz); } /** * 獲取所有可用的屬性 */ Field[] fields = clazz.getDeclaredFields(); for(Field field : fields) { /** * 如果屬性被@Value修飾 */ Value valueAnn = field.getAnnotation(Value.class); if (valueAnn == null) { continue; } String key = valueAnn.value(); if (key == null) { continue; } /** * 提取@Value("${xx.yy:dd}")中的key:xx.yy */ key = key.replaceAll(VALUE_REGEX,"$1"); key = key.split(COLON)[0]; /** * 如果屬性map中包含@Value註解中的key,強行使用反射修改裡面的值 */ if (props.containsKey(key)) { field.setAccessible(true); try { field.set(bean, props.get(key)); } catch (Exception e) { logger.info("set field error",e); } } } } }
如上程式碼,就是一些簡單的反射呼叫,註釋都寫在程式碼裡了。
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,其實這個實現還有兩個問題需要解決
1.自詡配置非中心,雖然確實跟隨自己的業務微服務啟動了,但是配置在本地檔案中也就意味著一個微服務要改一次,雖然可以從網路中讀取配置但是沒有實現網路配置檔案的監聽器了。這個其實很好實現上文有提到思路
2.只支援使用@Value的配置更新,不支援springboot中@ConfigrationProperties之類的配置初始化注入和更新,下面先說下實現思路:這個註解可以修飾在方法和類上,如果修飾類可以在反射修改屬性之前解析到這個註解的prefix字首值,然後不管屬性是否被@Value修飾首先在key上拼接一個字首然後從待更新的屬性map中過去值,使用反射改變對應屬性值;如果修飾Bean方法則可以直接從spring工廠拿到改物件生成的例項,然後配合字首使用反射更新配置檔案有的所有屬性值。其實確實都很簡單,關鍵在於思路。本來我這個應該是可以都實現出來的,但是由於剛開始寫的時候以為spring5的db結構和pringboot一樣,導致我寫完更新那裡發現springboot直接報錯,原因在於獲取使用@Configration註解修飾的類被生成了一個完全不一樣的代理,那些屬性都被刪了,反射拿不到原始的屬性資訊,在這使用斷點找了bd裡的結構對比浪費了大概一天的時間。就算覺得自己大致瞭解spring的流程遇到細節還是會很蛋疼。
最後再說說個人見解,嫌羅嗦的可以直接忽略,哈哈!
-----------------------------------------------------------------------------------------------------------------這是裡無聊的分割線------------------------------------------------------------------------------------------
我覺得開源的目的是能讓大部分人看懂,不要去玩一些花樣然後跟別人說看這個程式碼需要基礎,然後導致大部分開發人員甚至到了10年以上還停留在業務程式碼層面。不是人家不想學實在是你那玩意要看懂很費勁啊。外國開源的專案晦澀難懂也就算了,可能外國人腦回路不一樣,人家同類可以懂。一個國內開源的專案讓國內程式設計師都很難看懂,你怕不是為了別人能看懂吧,只是為了吹噓下你牛b的功能吧。其實一個基礎好點的程式設計師你給他時間啥框架寫不出來呢(你瞭解一下反射,aop,動態代理,spi等這些基礎的東西,還不會模仿市面上一些流行的框架可以跟我溝通)。個人和團隊的開源的東西區別就在細節和效能上,當然這兩個東西用在一樣的場景上可能都完全不需要考慮,屁大點專案需要細節嗎實現功能就行了,夠用就行了,需要效能嗎,才百十來人使用你跟我扯這些沒用的。照成已經有很多成熟方案後我還是在重複照輪子的根本原因是,別人提供的輪子自己根本掌控不了,可能連看懂都很費勁,我就屁大點需求,你讓我花十天半月去研究嗎,不研究遇到坑你能實時幫我解決嗎。程式中玩再多花,把方法呼叫寫的足夠深,真正編譯後還不是可能被虛擬機器直接優化到一個方法裡,你有什麼可以牛的呢。一邊標榜程式是給人看的,我想問你開源的程式碼真的有人看懂嗎。並不是人家能力不夠主要是因為每個人的思維就不一樣,不能用自己認為的簡單去套用到別人身上。有些人或組織寫程式碼呼叫層次故意搞得很深,美其名曰為了擴充套件性,請問你還要擴充套件啥東西,啥時候要擴充套件,剛開始有必要搞得那麼複雜嗎?還是說只是為了讓人看不懂,裝下b。所以誠心開源的,還是註釋寫清楚好,我人微言輕,是個不知名的還在貧困線掙扎的小碼農,如果冒犯了各位大神請不要見怪,純粹當我發神經就好了!