Java 通用配置版本配置实现

Posted isea533

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 通用配置版本配置实现相关的知识,希望对你有一定的参考价值。

Java 通用配置
(一)设计
(二)JVM和环境变量实现
(三)用户配置实现
(四)版本配置实现

本系列参考实现:

https://gitee.com/mybatis-mapper/config
https://github.com/mybatis-mapper/config

版本配置设计

版本配置是提供给模块开发者使用的,模块的使用者可以使用默认的最新版本配置或者通过参数指定要使用的版本配置。

版本配置文件规范

模块开发者一般会把配置和代码一起打包,配置文件通常都在 jar 包内,所以需要从 jar 包读取资源,在读取资源时有很多情况需要考虑,如果允许配置文件出现在任意的 jar 包中,就需要对所有 jar 包中的资源进行扫描,就和 Spring 中配置的扫描包一样,指定范围越小,扫描处理越快。

综合考虑性能和功能,这里的版本配置中指定了如下的要求:

  1. 配置文件和版本配置子类:
    1. 在同一个 jar 包文件中
    2. 在同一个包名中
  2. 版本配置文件使用相同的前缀,后面带上版本号,版本号为两位,示例如下:
    1. mybatis-mapper-v1.0.properties
    2. mybatis-mapper-v1.1.properties
    3. mybatis-mapper-v2.0.properties
    4. mybatis-mapper-v2.5.properties

给自己用的工具能实现需求即可,不用为了灵活性搞的太复杂。

版本配置选择逻辑

有了上述规则后,就需要确定该使用哪个版本的配置文件。

假设项目刚开始 1.0 版本,此时组件有一些默认值设置。后期增加了新的配置,可以直接在 1.0 版本中新增,也可以创建一个和代码版本号对应的新配置文件进行维护。前一种方式操作简单,后一种方式的配置也是文档,可以清晰的展示出当前版本可以配置的参数。

当项目有不兼容的版本改动时,一定要创建新的配置文件,假设发展到2.5版本时,存在下面几个版本配置:

  1. mybatis-mapper-v1.0.properties
  2. mybatis-mapper-v1.1.properties
  3. mybatis-mapper-v2.0.properties
  4. mybatis-mapper-v2.5.properties

某个用户从 1.0 升级到 2.5 时,发现有个默认 true 的配置变成了 false,此时如果组件支持用户配置,可以直接在里面配置为 true,如果有版本配置,就可以指定要使用的版本配置,此时指定 mapper.version=v1.0,就会让 1.0 的配置优先级更高,如果想用 2.0 以前的配置,配置为 mapper.version=v1.9时会使用 1.1 的配置,当指定的版本号在两个版本区间时,向下选择低版本。

选择之外的其他版本,按照版本从高到低的优先级进行获取,这种方式可以保证新版本的代码在运行时不至于找不到新的配置参数。

代码实现

当前版本配置的抽象类中,提供了下面两个抽象方法:

/**
 * 获取配置文件名前缀
 */
protected abstract String getConfigName();

/**
 * 获取版本号对应的 key
 */
protected abstract String getVersionKey();

子类只需要实现这两个方法:

  • 第一个方法是配置文件的前缀,例如 mybatis-mappermybatis-provider
  • 第二个方法是返回的配置名,用户可以通过这个配置名指定自己想要使用的版本,配置名示例如:mapper.version, provider.version,用户可以在前面介绍的 JVM、环境变量、用户配置文件中指定这里的版本号,例如 mapper.version=1.0provider.version=v1.1

子类本身很好实现,除此之外还需要在子类相同的包名下面提供对应的版本配置文件,例如:

  • mybatis-mapper-v1.0.properties
  • mybatis-mapper-v1.1.properties
  • mybatis-mapper-v1.2.properties
  • mybatis-mapper-v2.0.properties

为了方便比较版本号,提供了一个 ConfigVersion 内部类:

public static class ConfigVersion implements Comparable<ConfigVersion> 
  private final int x;
  private final int y;
  private final String fileName;

  public ConfigVersion(String version) 
    this(version, null);
  

  public ConfigVersion(String version, String fileName) 
    this.fileName = fileName;
    if (version.startsWith("v")) 
      version = version.substring(1);
    
    String[] strings = version.split("\\\\.");
    this.x = Integer.parseInt(strings[0]);
    this.y = Integer.parseInt(strings[1]);
  

  public String getFileName() 
    return fileName;
  

  @Override
  public int compareTo(ConfigVersion o) 
    if (this.x == o.x) 
      return this.y - o.y;
    
    return this.x - o.x;
  

这里的版本规则就是可以 v 开头,版本号为两位,合法的版本号如:v1.0, 2.0, 2.1,支持两位主要考虑到如果有三位版本号,第三位一般是修复bug,很少会涉及配置的改动。

为了更好的讲解代码,下面会按照下图的执行逻辑逐段介绍:

首先从第一个方法 getStr(String key) 开始:

@Override
public String getStr(String key) 
  if (skipKey(key)) 
    return null;
  
  if (this.properties == null) 
    synchronized (this) 
      if (this.properties == null) 
        this.init();
      
    
  
  return properties.getProperty(key);

这里是获取配置值的入口,进入方法后,首先经过 skipKey(key) 判断:

/**
 * 跳过读取指定的 key
 *
 * @param key 属性
 */
protected boolean skipKey(String key) 
  return getVersionKey().equals(key);

注意看图中的 ,在后续初始化过程中,会通过 ConfigHelper.getStr(getVersionKey()) 读取用户指定的版本,当前配置文件也是 ConfigHelper.CONFIGS 中的一环,如果这里不做处理就会导致递归死循环产生。

接下来就是双重加锁方式的单例初始化,为什么要在 getStr 中进行初始化,为什么不放到构造方法中?

还是和后续的 ConfigHelper.getStr(getVersionKey()) 有关,由于初始化会读取配置,如果配置还没有实例化,就会产生**“鸡生蛋、蛋生鸡”**的问题,所以在构造方法中没有任何逻辑,单纯创建了一个类,只有真正调用时,才通过加锁的方式初始化,此时所有实例都已经存在,逻辑就能正常走下去。

/**
 * 初始化
 */
protected void init() 
  Properties props = buildVersionProperties();
  if (props != null) 
    this.properties = props;
   else 
    this.properties = new Properties();
  

初始化中就是调用 buildVersionProperties() 方法进行创建:

protected Properties buildVersionProperties() 
  String version = ConfigHelper.getStr(getVersionKey());
  // 读取资源
  URL resource = getClass().getResource("");
  if (resource == null) 
    return null;
  
  if (resource.getProtocol().equals("file")) 
    if (resource.getPath().endsWith(".jar")) 
      try 
        JarFile jarFile = new JarFile(resource.getPath());
        return chooseFromJarFile(jarFile, version);
       catch (IOException e) 
        throw new RuntimeException(e);
      
     else 
      try 
        File file = new File(resource.toURI());
        return chooseFromFile(file, version);
       catch (Exception e) 
        throw new RuntimeException(e);
      
    
   else if (resource.getProtocol().equals("jar")) 
    try 
      JarFile jarFile = ((JarURLConnection) resource.openConnection()).getJarFile();
      return chooseFromJarFile(jarFile, version);
     catch (IOException e) 
      throw new RuntimeException(e);
    
  
  return null;

方法的第一行代码 ConfigHelper.getStr(getVersionKey()) 就是很关键的一个点,在配置文件初始化过程中去获取某个配置的值,此时方法会经过 JVM 实现、环境变量实现、用户配置实现,然后进入到当前的版本配置实现中,如果不加控制,就会在继续进入到 init 方法(synchronized是可重入锁,不会在此产生死锁),然后再次调用 ConfigHelper.getStr(getVersionKey()) 形成一个递归死循环。

破局的关键就是前面的 skipKey,当前类发现找自己要 getVersionKey() 的值时,自己作为当事人可以直接给出答案,也可以不给答案,然后再自己的后续逻辑中解决没有配置的情况。这里直接不给答案,返回 null,然后在 init 后续逻辑中处理,没有指定版本号时,默认使用最新的配置文件。

这个方法的下一行代码 URL resource = getClass().getResource(""),读取的就是当前类所在包的路径,这种读取方式就要求版本配置文件必须和配置类在相同模块的相同包下面,虽然这个限制了灵活性,但是可以避免在运行时扫描所有类路径下的所有文件,可以有效的提升性能。

这个方法在不同场景下运行时,获取的值不同,下面分几种情况介绍。

在源码中(config项目)中执行时

此时读取的 target 下面编译的代码,因此会是文件路径,例如:

file:/Users/xxx/mybatis-config/target/test-classes/io/mybatis/config/custom/

这种情况下代码就会执行到 chooseFromFile(file, version); 代码中:

private Properties chooseFromFile(File file, String version) throws IOException 
  String configName = getConfigName();
  File[] files = file.listFiles();
  if (files == null || files.length == 0) 
    return null;
  
  Map<String, File> fileMap = new HashMap<>();
  for (File f : files) 
    if (f.getName().startsWith(configName)) 
      fileMap.put(f.getName(), f);
    
  
  List<ConfigVersion> versions = sortVersions(new ArrayList<>(fileMap.keySet()));
  ConfigVersion chooseVersion = chooseVersion(versions, version);
  return build(versions, chooseVersion, configVersion -> 
    try 
      return new FileInputStream(fileMap.get(configVersion.getFileName()));
     catch (FileNotFoundException e) 
      return null;
    
  );

这里就可以在当前包路径中直接 file.listFiles() 获取目录下面所有的文件,找到所有符合条件的文件后,根据文件创建排序后的 List<ConfigVersion> versions,后续逻辑在后面继续说。

在作为依赖 jar 包中执行时

当 mybatis-provider 作为依赖在 IDE 中(IDE不含mybatis-mapper源码)运行时,看到的路径如下:

jar:file:/Users/xxx/.m2/repository/io/mybatis/mybatis-provider/2.0.0/mybatis-provider-2.0.0.jar!/io/mybatis/provider/config/

后续处理和 Spring Boot 一样,放在下面一起分析。

没有测试出 file:/…/xxx.jar 的情况

在 Spring Boot 可执行 Jar 包中执行时

打包后的 Spring Boot 通过 java -jar 运行时,输出的路径如下:

jar:file:/Users/xxx/mybatis-mapper-springboot/target/mybatis-mapper-example-springboot-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/mybatis-provider-2.0.0.jar!/io/mybatis/provider/config/

和前一种情况相比,这里是在 fat jar 中的一个 jar 包,多套了一层 jar。此时通过下面的方法获取 Jar 文件:

JarFile jarFile = ((JarURLConnection) resource.openConnection()).getJarFile()

得到 Jar 文件后,就是调用 chooseFromJarFile(jarFile, version) 获取配置文件:

private Properties chooseFromJarFile(JarFile jarFile, String version) throws IOException 
  String configName = getConfigName();
  String configPath = getConfigPath();
  Enumeration<JarEntry> entries = jarFile.entries();
  Map<String, JarEntry> entryMap = new HashMap<>();
  while (entries.hasMoreElements()) 
    JarEntry entry = entries.nextElement();
    String name = entry.getName();
    if (name.startsWith(configPath)) 
      name = name.substring(configPath.length());
      if (name.startsWith(configName)) 
        entryMap.put(name, entry);
      
    
  
  List<ConfigVersion> versions = sortVersions(new ArrayList<>(entryMap.keySet()));
  ConfigVersion chooseVersion = chooseVersion(versions, version);
  return build(versions, chooseVersion, configVersion -> 
    try 
      return jarFile.getInputStream(entryMap.get(chooseVersion.getFileName()));
     catch (IOException e) 
      return null;
    
  );

到了 sortVersions 方法后续就没太大差别了:

private List<ConfigVersion> sortVersions(Collection<String> fileNames) 
  if(fileNames == null || fileNames.isEmpty()) 
    return null;
  
  Pattern pattern = Pattern.compile(getConfigName() + "-(v\\\\d+\\\\.\\\\d+)\\\\" + FILE_TYPE);
  return fileNames.stream().map(fileName -> 
    Matcher matcher = pattern.matcher(fileName);
    if (matcher.find()) 
      return new ConfigVersion(matcher.group(1), fileName);
    
    return null;
  ).filter(Objects::nonNull).sorted().collect(Collectors.toList());

从文件名提取版本号,转换为有序的 ConfigVersion 集合。

然后就是根据提供的版本选择要使用的版本:

private ConfigVersion chooseVersion(List<ConfigVersion> versions, String version) 
  if (versions == null || versions.isEmpty()) 
    return null;
  
  //没有指定版本时使用最新版本
  if (version == null || version.isEmpty()) 
    return versions.get(versions.size() - 1);
  
  ConfigVersion configVersion = new ConfigVersion(version);
  //从最高版本进行比较,选择的版本高于或等于配置版本时,就选择该版本
  for (int i = versions.size() - 1; i >= 0; i--) 
    if (configVersion.compareTo(versions.get(i)) >= 0) 
      return versions.get(i);
    
  
  //选择的版本不高于所有版本时,使用最小版本
  return versions.get(0);

选择出要优先使用的版本后,后续就是根据下面规则创建配置:

对应的就是下面的方法:

private Properties build(List<ConfigVersion> versions, ConfigVersion chooseVersion, Function<ConfigVersion, InputStream> toInputStream) throws IOException 
  if (chooseVersion == null) 
    return null;
  
  InputStream is;
  Properties prop = null;
  for (ConfigVersion configVersion : versions) 
    if(configVersion != chooseVersion) 
      prop = new Properties(prop);
      is = toInputStream.apply(configVersion);
      if(is != null) 
        prop.load(is);
        is.close();
      
    
  
  prop = new Properties(prop);
  is = toInputStream.apply(chooseVersion);
  if(is != null) 
    prop.load(is);
    is.close();
  
  return prop;

按照版本递增的顺序依次构建 Properties,小版本最为高版本的默认值,在 Properties 中,当前配置不存在时,会从传入的低版本查找:

public String getProperty(String key) 
  Object oval = map.get(key);
  String sval = (oval instanceof String) ? (String)oval : null;
  Properties defaults;
  return ((sval == null) && ((defaults = this.defaults) != null)) ? defaults.getProperty(key) : sval;

最后再用选择的版本作为优先级最高的配置进行初始化,到此就完成了整个配置初始化。

到这里所有基本的功能都实现了,一个可扩展的 Java 通用配置就成型了,其他组件可以简单的扩展用户配置和版本配置实现配置信息的管理。

但是在目前 Spring Boot 流行的今天,我们大多数配置文件都在 application.propertiesapplication.yaml 中,能否把自己的配置文件也放到 Spring 中一起管理呢?能否支持 Spring Boot 的命令行参数?支持 Spring Boot 的完整的外部化配置规则?

当然可以,而且实现起来也很简单,在下一篇中一起来看如何集成 Spring 配置。

以上是关于Java 通用配置版本配置实现的主要内容,如果未能解决你的问题,请参考以下文章