自己动手实现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.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。所以诚心开源的,还是注释写清楚好,我人微言轻,是个不知名的还在贫困线挣扎的小码农,如果冒犯了各位大神请不要见怪,纯粹当我发神经就好了!