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 java.util.stream.Collectors; 028import org.jgrapes.core.Channel; 029import org.jgrapes.core.EventPipeline; 030import org.jgrapes.core.annotation.Handler; 031import org.jgrapes.core.events.Start; 032import org.jgrapes.util.events.ConfigurationUpdate; 033import org.jgrapes.util.events.InitialPreferences; 034 035/** 036 * This component provides a store for an application's configuration 037 * backed by the Java {@link Preferences}. Preferences 038 * are maps of key value pairs that are associated with a path. A common 039 * base path is passed to the component on creation. The application's 040 * configuration information is stored using paths relative to that 041 * base path. 042 * 043 * The component reads the initial values from the Java {@link Preferences} 044 * tree denoted by the base path. During application bootstrap, it 045 * intercepts the {@link Start} event using a handler with priority 046 * 999999. When receiving this event, it fires all known preferences 047 * values on the channels of the start event as a 048 * {@link InitialPreferences} event, using a new {@link EventPipeline} 049 * and waiting for its completion. Then, allows the intercepted 050 * {@link Start} event to continue. 051 * 052 * Components that depend on configuration values define handlers 053 * for {@link ConfigurationUpdate} events and adapt themselves to the values 054 * received. Note that due to the intercepted {@link Start} event, the initial 055 * preferences values are received before the {@link Start} event, so 056 * components' configurations can be rearranged before they actually 057 * start doing something. 058 * 059 * Besides initially publishing the stored preferences values, 060 * the component also listens for {@link ConfigurationUpdate} events 061 * on its channel and updates the preferences store (may be suppressed). 062 */ 063@SuppressWarnings("PMD.DataflowAnomalyAnalysis") 064public class PreferencesStore extends ConfigurationStore { 065 066 private Preferences preferences; 067 068 /** 069 * Creates a new component with its channel set to the given 070 * channel and a base path derived from the given class. 071 * 072 * @param componentChannel the channel 073 * @param appClass the application class; the base path 074 * is formed by replacing each dot in the class's package's full 075 * name with a slash, prepending a slash, and appending 076 * "`/PreferencesStore`". 077 */ 078 public PreferencesStore(Channel componentChannel, Class<?> appClass) { 079 this(componentChannel, appClass, true); 080 } 081 082 /** 083 * Allows the creation of a "read-only" store. 084 * 085 * @param componentChannel the channel 086 * @param appClass the application class; the base path 087 * is formed by replacing each dot in the class's package's full 088 * name with a slash, prepending a slash, and appending 089 * "`/PreferencesStore`". 090 * @param update whether to update the store when 091 * {@link ConfigurationUpdate} events are received 092 * 093 * @see #PreferencesStore(Channel, Class) 094 */ 095 public PreferencesStore( 096 Channel componentChannel, Class<?> appClass, boolean update) { 097 super(componentChannel); 098 if (update) { 099 Handler.Evaluator.add(this, "onConfigurationUpdate", 100 channel().defaultCriterion()); 101 } 102 preferences = Preferences.userNodeForPackage(appClass) 103 .node("PreferencesStore"); 104 } 105 106 /** 107 * Intercepts the {@link Start} event and fires a 108 * {@link ConfigurationUpdate} event. 109 * 110 * @param event the event 111 * @throws BackingStoreException the backing store exception 112 * @throws InterruptedException the interrupted exception 113 */ 114 @SuppressWarnings("PMD.DataflowAnomalyAnalysis") 115 @Handler(priority = 999_999, channels = Channel.class) 116 public void onStart(Start event) 117 throws BackingStoreException, InterruptedException { 118 InitialPreferences updEvt 119 = new InitialPreferences(preferences.parent().absolutePath()); 120 addPrefs(updEvt, preferences.absolutePath(), preferences); 121 newEventPipeline().fire(updEvt, event.channels()).get(); 122 } 123 124 @SuppressWarnings("PMD.DataflowAnomalyAnalysis") 125 private void addPrefs( 126 InitialPreferences updEvt, String rootPath, Preferences node) 127 throws BackingStoreException { 128 String nodePath = node.absolutePath(); 129 String relPath = "/" + nodePath.substring(Math.min( 130 rootPath.length() + 1, nodePath.length())); 131 var props = new HashMap<String, String>(); 132 for (String key : node.keys()) { 133 props.put(key, node.get(key, null)); 134 } 135 updEvt.set(relPath, ConfigurationStore.structure(props)); 136 for (String child : node.childrenNames()) { 137 addPrefs(updEvt, rootPath, node.node(child)); 138 } 139 } 140 141 /** 142 * Merges and saves configuration updates. 143 * 144 * @param event the event 145 * @throws IOException Signals that an I/O exception has occurred. 146 */ 147 @Handler(dynamic = true) 148 @SuppressWarnings("PMD.AvoidReassigningLoopVariables") 149 public void onConfigurationUpdate(ConfigurationUpdate event) 150 throws BackingStoreException { 151 if (event instanceof InitialPreferences) { 152 return; 153 } 154 for (String path : event.paths()) { 155 Optional<Map<String, String>> prefs = event.values(path); 156 path = path.substring(1); // Remove leading slash 157 if (!prefs.isPresent()) { 158 preferences.node(path).removeNode(); 159 continue; 160 } 161 for (Map.Entry<String, String> e : prefs.get().entrySet()) { 162 preferences.node(path).put(e.getKey(), e.getValue()); 163 } 164 } 165 preferences.flush(); 166 } 167 168 @Override 169 public Optional<Map<String, String>> values(String path) { 170 return nodeValues(path).map(m -> m.entrySet().stream()).map( 171 s -> s.collect(Collectors.toMap(e -> e.getKey().replace("\"", ""), 172 Map.Entry::getValue))); 173 } 174 175 @Override 176 public Optional<Map<String, Object>> structured(String path) { 177 return nodeValues(path).map(ConfigurationStore::structure); 178 } 179 180 private Optional<Map<String, String>> nodeValues(String path) { 181 if (!path.startsWith("/")) { 182 throw new IllegalArgumentException("Path must start with \"/\"."); 183 } 184 try { 185 var relPath = path.substring(1); 186 if (!preferences.nodeExists(relPath)) { 187 return Optional.empty(); 188 } 189 var node = preferences.node(relPath); 190 var result = new HashMap<String, String>(); 191 for (String key : node.keys()) { 192 result.put(key, node.get(key, null)); 193 } 194 return Optional.of(result); 195 } catch (BackingStoreException e) { 196 throw new IllegalStateException(e); 197 } 198 } 199 200}