001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2017-2022 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.util; 020 021import java.io.IOException; 022import java.util.HashMap; 023import java.util.Map; 024import java.util.Optional; 025import java.util.prefs.BackingStoreException; 026import java.util.prefs.Preferences; 027import org.jgrapes.core.Channel; 028import org.jgrapes.core.EventPipeline; 029import org.jgrapes.core.annotation.Handler; 030import org.jgrapes.core.events.Start; 031import org.jgrapes.util.events.ConfigurationUpdate; 032import org.jgrapes.util.events.InitialPreferences; 033 034/** 035 * This component provides a store for an application's configuration 036 * backed by the Java {@link Preferences}. Preferences 037 * are maps of key value pairs that are associated with a path. A common 038 * base path is passed to the component on creation. The application's 039 * configuration information is stored using paths relative to that 040 * base path. 041 * 042 * The component reads the initial values from the Java {@link Preferences} 043 * tree denoted by the base path. During application bootstrap, it 044 * intercepts the {@link Start} event using a handler with priority 045 * 999999. When receiving this event, it fires all known preferences 046 * values on the channels of the start event as a 047 * {@link InitialPreferences} event, using a new {@link EventPipeline} 048 * and waiting for its completion. Then, allows the intercepted 049 * {@link Start} event to continue. 050 * 051 * Components that depend on configuration values define handlers 052 * for {@link ConfigurationUpdate} events and adapt themselves to the values 053 * received. Note that due to the intercepted {@link Start} event, the initial 054 * preferences values are received before the {@link Start} event, so 055 * components' configurations can be rearranged before they actually 056 * start doing something. 057 * 058 * Besides initially publishing the stored preferences values, 059 * the component also listens for {@link ConfigurationUpdate} events 060 * on its channel and updates the preferences store (may be suppressed). 061 */ 062@SuppressWarnings("PMD.DataflowAnomalyAnalysis") 063public class PreferencesStore extends ConfigurationStore { 064 065 private Preferences preferences; 066 067 /** 068 * Creates a new component with its channel set to the given 069 * channel and a base path derived from the given class. 070 * 071 * @param componentChannel the channel 072 * @param appClass the application class; the base path 073 * is formed by replacing each dot in the class's package's full 074 * name with a slash, prepending a slash, and appending 075 * "`/PreferencesStore`". 076 */ 077 public PreferencesStore(Channel componentChannel, Class<?> appClass) { 078 this(componentChannel, appClass, true); 079 } 080 081 /** 082 * Allows the creation of a "read-only" store. 083 * 084 * @param componentChannel the channel 085 * @param appClass the application class; the base path 086 * is formed by replacing each dot in the class's package's full 087 * name with a slash, prepending a slash, and appending 088 * "`/PreferencesStore`". 089 * @param update whether to update the store when 090 * {@link ConfigurationUpdate} events are received 091 * 092 * @see #PreferencesStore(Channel, Class) 093 */ 094 public PreferencesStore( 095 Channel componentChannel, Class<?> appClass, boolean update) { 096 super(componentChannel); 097 if (update) { 098 Handler.Evaluator.add(this, "onConfigurationUpdate", 099 channel().defaultCriterion()); 100 } 101 preferences = Preferences.userNodeForPackage(appClass) 102 .node("PreferencesStore"); 103 } 104 105 /** 106 * Intercepts the {@link Start} event and fires a 107 * {@link ConfigurationUpdate} event. 108 * 109 * @param event the event 110 * @throws BackingStoreException the backing store exception 111 * @throws InterruptedException the interrupted exception 112 */ 113 @SuppressWarnings("PMD.DataflowAnomalyAnalysis") 114 @Handler(priority = 999_999, channels = Channel.class) 115 public void onStart(Start event) 116 throws BackingStoreException, InterruptedException { 117 InitialPreferences updEvt 118 = new InitialPreferences(preferences.parent().absolutePath()); 119 addPrefs(updEvt, preferences.absolutePath(), preferences); 120 newEventPipeline().fire(updEvt, event.channels()).get(); 121 } 122 123 @SuppressWarnings("PMD.DataflowAnomalyAnalysis") 124 private void addPrefs( 125 InitialPreferences updEvt, String rootPath, Preferences node) 126 throws BackingStoreException { 127 String nodePath = node.absolutePath(); 128 String relPath = "/" + nodePath.substring(Math.min( 129 rootPath.length() + 1, nodePath.length())); 130 for (String key : node.keys()) { 131 updEvt.add(relPath, key, node.get(key, null)); 132 } 133 for (String child : node.childrenNames()) { 134 addPrefs(updEvt, rootPath, node.node(child)); 135 } 136 } 137 138 /** 139 * Merges and saves configuration updates. 140 * 141 * @param event the event 142 * @throws IOException Signals that an I/O exception has occurred. 143 */ 144 @Handler(dynamic = true) 145 @SuppressWarnings("PMD.AvoidReassigningLoopVariables") 146 public void onConfigurationUpdate(ConfigurationUpdate event) 147 throws BackingStoreException { 148 if (event instanceof InitialPreferences) { 149 return; 150 } 151 for (String path : event.paths()) { 152 Optional<Map<String, String>> prefs = event.values(path); 153 path = path.substring(1); // Remove leading slash 154 if (!prefs.isPresent()) { 155 preferences.node(path).removeNode(); 156 continue; 157 } 158 for (Map.Entry<String, String> e : prefs.get().entrySet()) { 159 preferences.node(path).put(e.getKey(), e.getValue()); 160 } 161 } 162 preferences.flush(); 163 } 164 165 @Override 166 public Optional<Map<String, String>> values(String path) { 167 if (!path.startsWith("/")) { 168 throw new IllegalArgumentException("Path must start with \"/\"."); 169 } 170 try { 171 var relPath = path.substring(1); 172 if (!preferences.nodeExists(relPath)) { 173 return Optional.empty(); 174 } 175 var node = preferences.node(relPath); 176 var result = new HashMap<String, String>(); 177 for (String key : node.keys()) { 178 result.put(key, node.get(key, null)); 179 } 180 return Optional.of(result); 181 } catch (BackingStoreException e) { 182 throw new IllegalStateException(e); 183 } 184 } 185}