web-dev-qa-db-fra.com

Comment recharger à chaud les propriétés dans Java EE et Spring Boot?

De nombreuses solutions internes viennent à l’esprit. C'est comme avoir les propriétés dans une base de données et les interroger toutes les N secondes. Vérifiez également la modification de l'horodatage pour un fichier .properties et rechargez-le. 

Mais je cherchais dans les normes Java EE et les documents de démarrage de printemps et je n'arrive pas à trouver le meilleur moyen de le faire.

J'ai besoin que mon application lise un fichier de propriétés (ou des variables d'environnement ou des paramètres de base de données), pour pouvoir ensuite les relire. Quelle est la meilleure pratique utilisée en production?

Une réponse correcte résoudra au moins un scénario (Spring Boot ou Java EE) et fournira un indice conceptuel sur la manière de le faire fonctionner sur l'autre.

5
David Hofmann

Après des recherches plus poussées, les propriétés de rechargement doivent être soigneusement considérées . Au printemps, par exemple, nous pouvons recharger les valeurs "actuelles" des propriétés sans trop de problèmes. Mais. Une attention particulière doit être prise lors de l'initialisation des ressources au moment de l'initialisation du contexte en fonction des valeurs présentes dans le fichier application.properties (sources de données, pools de connexion, files d'attente, etc.). 

REMARQUE

Les classes abstraites utilisées pour Spring et Java EE ne constituent pas le meilleur exemple de code vierge. Mais il est facile à utiliser et répond aux exigences initiales de base:

  • Aucune utilisation de bibliothèques externes autres que les classes Java 8.
  • Un seul fichier pour résoudre le problème (~ 160 lignes pour la version Java EE).
  • Utilisation du fichier codé UTF-8 Java Properties standard disponible dans le système de fichiers.
  • Soutenir les propriétés chiffrées.

Pour bottes de printemps

Ce code facilite le rechargement à chaud du fichier application.properties sans l'utilisation d'un serveur Spring Cloud Config (qui peut être excessif dans certains cas d'utilisation).

Cette classe abstraite que vous pouvez simplement copier-coller (SO goodies: D) C’est un code dérivé de cette SO réponse

// imports from Java/spring/lombok
public abstract class ReloadableProperties {

  @Autowired
  protected StandardEnvironment environment;
  private long lastModTime = 0L;
  private Path configPath = null;
  private PropertySource<?> appConfigPropertySource = null;

  @PostConstruct
  private void stopIfProblemsCreatingContext() {
    System.out.println("reloading");
    MutablePropertySources propertySources = environment.getPropertySources();
    Optional<PropertySource<?>> appConfigPsOp =
        StreamSupport.stream(propertySources.spliterator(), false)
            .filter(ps -> ps.getName().matches("^.*applicationConfig.*file:.*$"))
            .findFirst();
    if (!appConfigPsOp.isPresent())  {
      // this will stop context initialization 
      // (i.e. kill the spring boot program before it initializes)
      throw new RuntimeException("Unable to find property Source as file");
    }
    appConfigPropertySource = appConfigPsOp.get();

    String filename = appConfigPropertySource.getName();
    filename = filename
        .replace("applicationConfig: [file:", "")
        .replaceAll("\\]$", "");

    configPath = Paths.get(filename);

  }

  @Scheduled(fixedRate=2000)
  private void reload() throws IOException {
      System.out.println("reloading...");
      long currentModTs = Files.getLastModifiedTime(configPath).toMillis();
      if (currentModTs > lastModTime) {
        lastModTime = currentModTs;
        Properties properties = new Properties();
        @Cleanup InputStream inputStream = Files.newInputStream(configPath);
        properties.load(inputStream);
        environment.getPropertySources()
            .replace(
                appConfigPropertySource.getName(),
                new PropertiesPropertySource(
                    appConfigPropertySource.getName(),
                    properties
                )
            );
        System.out.println("Reloaded.");
        propertiesReloaded();
      }
    }

    protected abstract void propertiesReloaded();
}

Ensuite, vous créez une classe de bean permettant de récupérer les valeurs de propriété de applicatoin.properties qui utilise la classe abstraite.

@Component
public class AppProperties extends ReloadableProperties {

    public String dynamicProperty() {
        return environment.getProperty("dynamic.prop");
    }
    public String anotherDynamicProperty() {
        return environment.getProperty("another.dynamic.prop");    
    }
    @Override
    protected void propertiesReloaded() {
        // do something after a change in property values was done
    }
}

Assurez-vous d’ajouter @EnableScheduling à votre @SpringBootApplication 

@SpringBootApplication
@EnableScheduling
public class MainApp  {
   public static void main(String[] args) {
      SpringApplication.run(MainApp.class, args);
   }
}

Vous pouvez maintenant auto-câbler le bean AppProperties en tout lieu où vous en avez besoin. Assurez-vous simplement que always appelle les méthodes qu'il contient au lieu de sauvegarder sa valeur dans une variable. Et assurez-vous de reconfigurer toute ressource ou bean initialisé avec des valeurs de propriété potentiellement différentes.

Pour l'instant, je n'ai testé cela qu'avec un fichier ./config/application.properties externe et trouvé par défaut.

Pour Java EE 

J'ai créé une classe abstraite Java SE commune pour effectuer le travail.

Vous pouvez copier et coller ceci:

// imports from Java.* and javax.crypto.*
public abstract class ReloadableProperties {

  private volatile Properties properties = null;
  private volatile String propertiesPassword = null;
  private volatile long lastModTimeOfFile = 0L;
  private volatile long lastTimeChecked = 0L;
  private volatile Path propertyFileAddress;

  abstract protected void propertiesUpdated();

  public class DynProp {
    private final String propertyName;
    public DynProp(String propertyName) {
      this.propertyName = propertyName;
    }
    public String val() {
      try {
        return ReloadableProperties.this.getString(propertyName);
      } catch (Exception e) {
        e.printStackTrace();
        throw new RuntimeException(e);
      }
    }
  }

  protected void init(Path path) {
    this.propertyFileAddress = path;
    initOrReloadIfNeeded();
  }

  private synchronized void initOrReloadIfNeeded() {
    boolean firstTime = lastModTimeOfFile == 0L;
    long currentTs = System.currentTimeMillis();

    if ((lastTimeChecked + 3000) > currentTs)
      return;

    try {

      File fa = propertyFileAddress.toFile();
      long currModTime = fa.lastModified();
      if (currModTime > lastModTimeOfFile) {
        lastModTimeOfFile = currModTime;
        InputStreamReader isr = new InputStreamReader(new FileInputStream(fa), StandardCharsets.UTF_8);
        Properties prop = new Properties();
        prop.load(isr);
        properties = prop;
        isr.close();
        File passwordFiles = new File(fa.getAbsolutePath() + ".key");
        if (passwordFiles.exists()) {
          byte[] bytes = Files.readAllBytes(passwordFiles.toPath());
          propertiesPassword = new String(bytes,StandardCharsets.US_ASCII);
          propertiesPassword = propertiesPassword.trim();
          propertiesPassword = propertiesPassword.replaceAll("(\\r|\\n)", "");
        }
      }

      updateProperties();

      if (!firstTime)
        propertiesUpdated();

    } catch (Exception e) {
      e.printStackTrace();
    }
  }

  private void updateProperties() {
    List<DynProp> dynProps = Arrays.asList(this.getClass().getDeclaredFields())
        .stream()
        .filter(f -> f.getType().isAssignableFrom(DynProp.class))
        .map(f-> fromField(f))
        .collect(Collectors.toList());

    for (DynProp dp :dynProps) {
      if (!properties.containsKey(dp.propertyName)) {
        System.out.println("propertyName: "+ dp.propertyName + " does not exist in property file");
      }
    }

    for (Object key : properties.keySet()) {
      if (!dynProps.stream().anyMatch(dp->dp.propertyName.equals(key.toString()))) {
        System.out.println("property in file is not used in application: "+ key);
      }
    }

  }

  private DynProp fromField(Field f) {
    try {
      return (DynProp) f.get(this);
    } catch (IllegalAccessException e) {
      e.printStackTrace();
    }
    return null;
  }

  protected String getString(String param) throws Exception {
    initOrReloadIfNeeded();
    String value = properties.getProperty(param);
    if (value.startsWith("ENC(")) {
      String cipheredText = value
          .replace("ENC(", "")
          .replaceAll("\\)$", "");
      value =  decrypt(cipheredText, propertiesPassword);
    }
    return value;
  }

  public static String encrypt(String plainText, String key)
      throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, InvalidKeySpecException {
    SecureRandom secureRandom = new SecureRandom();
    byte[] keyBytes = key.getBytes(StandardCharsets.US_ASCII);
    SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
    KeySpec spec = new PBEKeySpec(key.toCharArray(), new byte[]{0,1,2,3,4,5,6,7}, 65536, 128);
    SecretKey tmp = factory.generateSecret(spec);
    SecretKey secretKey = new SecretKeySpec(tmp.getEncoded(), "AES");
    byte[] iv = new byte[12];
    secureRandom.nextBytes(iv);
    final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    GCMParameterSpec parameterSpec = new GCMParameterSpec(128, iv); //128 bit auth tag length
    cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
    byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
    ByteBuffer byteBuffer = ByteBuffer.allocate(4 + iv.length + cipherText.length);
    byteBuffer.putInt(iv.length);
    byteBuffer.put(iv);
    byteBuffer.put(cipherText);
    byte[] cipherMessage = byteBuffer.array();
    String cyphertext = Base64.getEncoder().encodeToString(cipherMessage);
    return cyphertext;
  }
  public static String decrypt(String cypherText, String key)
      throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, InvalidKeySpecException {
    byte[] cipherMessage = Base64.getDecoder().decode(cypherText);
    ByteBuffer byteBuffer = ByteBuffer.wrap(cipherMessage);
    int ivLength = byteBuffer.getInt();
    if(ivLength < 12 || ivLength >= 16) { // check input parameter
      throw new IllegalArgumentException("invalid iv length");
    }
    byte[] iv = new byte[ivLength];
    byteBuffer.get(iv);
    byte[] cipherText = new byte[byteBuffer.remaining()];
    byteBuffer.get(cipherText);
    byte[] keyBytes = key.getBytes(StandardCharsets.US_ASCII);
    final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
    KeySpec spec = new PBEKeySpec(key.toCharArray(), new byte[]{0,1,2,3,4,5,6,7}, 65536, 128);
    SecretKey tmp = factory.generateSecret(spec);
    SecretKey secretKey = new SecretKeySpec(tmp.getEncoded(), "AES");
    cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, iv));
    byte[] plainText= cipher.doFinal(cipherText);
    String plain = new String(plainText, StandardCharsets.UTF_8);
    return plain;
  }
}

Ensuite, vous pouvez l'utiliser de cette façon:

public class AppProperties extends ReloadableProperties {

  public static final AppProperties INSTANCE; static {
    INSTANCE = new AppProperties();
    INSTANCE.init(Paths.get("application.properties"));
  }


  @Override
  protected void propertiesUpdated() {
    // run code every time a property is updated
  }

  public final DynProp wsUrl = new DynProp("ws.url");
  public final DynProp hiddenText = new DynProp("hidden.text");

}

Si vous souhaitez utiliser des propriétés codées, vous pouvez inclure sa valeur dans ENC () et un mot de passe pour le déchiffrement sera recherché dans le même chemin et nom du fichier de propriété avec une extension .key ajoutée. Dans cet exemple, le mot de passe sera recherché dans le fichier application.properties.key.

application.properties ->

ws.url=http://some webside
hidden.text=ENC(AAAADCzaasd9g61MI4l5sbCXrFNaQfQrgkxygNmFa3UuB9Y+YzRuBGYj+A==)

aplication.properties.key ->

password aca

Pour le chiffrement des valeurs de propriété de la solution Java EE, j'ai consulté l'excellent article de Patrick Favre-Bulle sur Encodage symétrique avec AES en Java et Android . Ensuite, cochez Cipher, mode bloc et remplissage dans cette question SO sur AES/GCM/NoPadding . Et enfin, j'ai fait en sorte que les bits AES soient dérivés d'un mot de passe de @erickson, une excellente réponse dans SO à propos de AES Password Based Encryption . En ce qui concerne le chiffrement des propriétés de valeur dans Spring, je pense qu'elles sont intégrées à Java Simplified Encryption

Que cela soit considéré comme une pratique exemplaire ou non peut être hors de portée. Cette réponse montre comment avoir des propriétés rechargeables dans Spring Boot et Java EE.

4
David Hofmann

Cette fonctionnalité peut être obtenue en utilisant un Spring Cloud Config Server et/ refresh scope client .

Serveur

Serveur (application Spring Boot) sert la configuration stockée, par exemple, dans un référentiel Git:

@SpringBootApplication
@EnableConfigServer
public class ConfigServer {
  public static void main(String[] args) {
    SpringApplication.run(ConfigServer.class, args);
  }
}

application.yml:

spring:
  cloud:
    config:
      server:
        git:
          uri: git-repository-url-which-stores-configuration.git

fichier de configuration configuration-client.properties (dans un référentiel Git):

configuration.value=Old

Client

Le client (application Spring Boot) lit la configuration à partir du serveur de configuration à l'aide de @RefreshScope annotation:

@Component
@RefreshScope
public class Foo {

    @Value("${configuration.value}")
    private String value;

    ....
}

bootstrap.yml:

spring:
  application:
    name: configuration-client
  cloud:
    config:
      uri: configuration-server-url

Quand il y a un changement de configuration dans le référentiel Git:

configuration.value=New

rechargez la variable de configuration en envoyant une demande POST au noeud final /refresh:

$ curl -X POST http://client-url/actuator/refresh

Vous avez maintenant la nouvelle valeur New.

De plus, la classe Foo peut transmettre la valeur au reste de l'application via RESTful API si elle est remplacée par RestController et a un endpont correspondant.

1
Boris