001/* 002 * Copyright 2002-2018 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.beans.factory.config; 018 019import java.io.IOException; 020import java.io.Reader; 021import java.util.AbstractMap; 022import java.util.Arrays; 023import java.util.Collection; 024import java.util.Collections; 025import java.util.LinkedHashMap; 026import java.util.List; 027import java.util.Map; 028import java.util.Properties; 029import java.util.Set; 030 031import org.apache.commons.logging.Log; 032import org.apache.commons.logging.LogFactory; 033import org.yaml.snakeyaml.Yaml; 034import org.yaml.snakeyaml.constructor.Constructor; 035import org.yaml.snakeyaml.nodes.MappingNode; 036import org.yaml.snakeyaml.parser.ParserException; 037import org.yaml.snakeyaml.reader.UnicodeReader; 038 039import org.springframework.core.CollectionFactory; 040import org.springframework.core.io.Resource; 041import org.springframework.util.Assert; 042import org.springframework.util.StringUtils; 043 044/** 045 * Base class for YAML factories. 046 * 047 * @author Dave Syer 048 * @author Juergen Hoeller 049 * @since 4.1 050 */ 051public abstract class YamlProcessor { 052 053 private final Log logger = LogFactory.getLog(getClass()); 054 055 private ResolutionMethod resolutionMethod = ResolutionMethod.OVERRIDE; 056 057 private Resource[] resources = new Resource[0]; 058 059 private List<DocumentMatcher> documentMatchers = Collections.emptyList(); 060 061 private boolean matchDefault = true; 062 063 064 /** 065 * A map of document matchers allowing callers to selectively use only 066 * some of the documents in a YAML resource. In YAML documents are 067 * separated by <code>---<code> lines, and each document is converted 068 * to properties before the match is made. E.g. 069 * <pre class="code"> 070 * environment: dev 071 * url: https://dev.bar.com 072 * name: Developer Setup 073 * --- 074 * environment: prod 075 * url:https://foo.bar.com 076 * name: My Cool App 077 * </pre> 078 * when mapped with 079 * <pre class="code"> 080 * setDocumentMatchers(properties -> 081 * ("prod".equals(properties.getProperty("environment")) ? MatchStatus.FOUND : MatchStatus.NOT_FOUND)); 082 * </pre> 083 * would end up as 084 * <pre class="code"> 085 * environment=prod 086 * url=https://foo.bar.com 087 * name=My Cool App 088 * </pre> 089 */ 090 public void setDocumentMatchers(DocumentMatcher... matchers) { 091 this.documentMatchers = Arrays.asList(matchers); 092 } 093 094 /** 095 * Flag indicating that a document for which all the 096 * {@link #setDocumentMatchers(DocumentMatcher...) document matchers} abstain will 097 * nevertheless match. Default is {@code true}. 098 */ 099 public void setMatchDefault(boolean matchDefault) { 100 this.matchDefault = matchDefault; 101 } 102 103 /** 104 * Method to use for resolving resources. Each resource will be converted to a Map, 105 * so this property is used to decide which map entries to keep in the final output 106 * from this factory. Default is {@link ResolutionMethod#OVERRIDE}. 107 */ 108 public void setResolutionMethod(ResolutionMethod resolutionMethod) { 109 Assert.notNull(resolutionMethod, "ResolutionMethod must not be null"); 110 this.resolutionMethod = resolutionMethod; 111 } 112 113 /** 114 * Set locations of YAML {@link Resource resources} to be loaded. 115 * @see ResolutionMethod 116 */ 117 public void setResources(Resource... resources) { 118 this.resources = resources; 119 } 120 121 122 /** 123 * Provide an opportunity for subclasses to process the Yaml parsed from the supplied 124 * resources. Each resource is parsed in turn and the documents inside checked against 125 * the {@link #setDocumentMatchers(DocumentMatcher...) matchers}. If a document 126 * matches it is passed into the callback, along with its representation as Properties. 127 * Depending on the {@link #setResolutionMethod(ResolutionMethod)} not all of the 128 * documents will be parsed. 129 * @param callback a callback to delegate to once matching documents are found 130 * @see #createYaml() 131 */ 132 protected void process(MatchCallback callback) { 133 Yaml yaml = createYaml(); 134 for (Resource resource : this.resources) { 135 boolean found = process(callback, yaml, resource); 136 if (this.resolutionMethod == ResolutionMethod.FIRST_FOUND && found) { 137 return; 138 } 139 } 140 } 141 142 /** 143 * Create the {@link Yaml} instance to use. 144 */ 145 protected Yaml createYaml() { 146 return new Yaml(new StrictMapAppenderConstructor()); 147 } 148 149 private boolean process(MatchCallback callback, Yaml yaml, Resource resource) { 150 int count = 0; 151 try { 152 if (logger.isDebugEnabled()) { 153 logger.debug("Loading from YAML: " + resource); 154 } 155 Reader reader = new UnicodeReader(resource.getInputStream()); 156 try { 157 for (Object object : yaml.loadAll(reader)) { 158 if (object != null && process(asMap(object), callback)) { 159 count++; 160 if (this.resolutionMethod == ResolutionMethod.FIRST_FOUND) { 161 break; 162 } 163 } 164 } 165 if (logger.isDebugEnabled()) { 166 logger.debug("Loaded " + count + " document" + (count > 1 ? "s" : "") + 167 " from YAML resource: " + resource); 168 } 169 } 170 finally { 171 reader.close(); 172 } 173 } 174 catch (IOException ex) { 175 handleProcessError(resource, ex); 176 } 177 return (count > 0); 178 } 179 180 private void handleProcessError(Resource resource, IOException ex) { 181 if (this.resolutionMethod != ResolutionMethod.FIRST_FOUND && 182 this.resolutionMethod != ResolutionMethod.OVERRIDE_AND_IGNORE) { 183 throw new IllegalStateException(ex); 184 } 185 if (logger.isWarnEnabled()) { 186 logger.warn("Could not load map from " + resource + ": " + ex.getMessage()); 187 } 188 } 189 190 @SuppressWarnings("unchecked") 191 private Map<String, Object> asMap(Object object) { 192 // YAML can have numbers as keys 193 Map<String, Object> result = new LinkedHashMap<String, Object>(); 194 if (!(object instanceof Map)) { 195 // A document can be a text literal 196 result.put("document", object); 197 return result; 198 } 199 200 Map<Object, Object> map = (Map<Object, Object>) object; 201 for (Map.Entry<Object, Object> entry : map.entrySet()) { 202 Object value = entry.getValue(); 203 if (value instanceof Map) { 204 value = asMap(value); 205 } 206 Object key = entry.getKey(); 207 if (key instanceof CharSequence) { 208 result.put(key.toString(), value); 209 } 210 else { 211 // It has to be a map key in this case 212 result.put("[" + key.toString() + "]", value); 213 } 214 } 215 return result; 216 } 217 218 private boolean process(Map<String, Object> map, MatchCallback callback) { 219 Properties properties = CollectionFactory.createStringAdaptingProperties(); 220 properties.putAll(getFlattenedMap(map)); 221 222 if (this.documentMatchers.isEmpty()) { 223 if (logger.isDebugEnabled()) { 224 logger.debug("Merging document (no matchers set): " + map); 225 } 226 callback.process(properties, map); 227 return true; 228 } 229 230 MatchStatus result = MatchStatus.ABSTAIN; 231 for (DocumentMatcher matcher : this.documentMatchers) { 232 MatchStatus match = matcher.matches(properties); 233 result = MatchStatus.getMostSpecific(match, result); 234 if (match == MatchStatus.FOUND) { 235 if (logger.isDebugEnabled()) { 236 logger.debug("Matched document with document matcher: " + properties); 237 } 238 callback.process(properties, map); 239 return true; 240 } 241 } 242 243 if (result == MatchStatus.ABSTAIN && this.matchDefault) { 244 if (logger.isDebugEnabled()) { 245 logger.debug("Matched document with default matcher: " + map); 246 } 247 callback.process(properties, map); 248 return true; 249 } 250 251 if (logger.isDebugEnabled()) { 252 logger.debug("Unmatched document: " + map); 253 } 254 return false; 255 } 256 257 /** 258 * Return a flattened version of the given map, recursively following any nested Map 259 * or Collection values. Entries from the resulting map retain the same order as the 260 * source. When called with the Map from a {@link MatchCallback} the result will 261 * contain the same values as the {@link MatchCallback} Properties. 262 * @param source the source map 263 * @return a flattened map 264 * @since 4.1.3 265 */ 266 protected final Map<String, Object> getFlattenedMap(Map<String, Object> source) { 267 Map<String, Object> result = new LinkedHashMap<String, Object>(); 268 buildFlattenedMap(result, source, null); 269 return result; 270 } 271 272 private void buildFlattenedMap(Map<String, Object> result, Map<String, Object> source, String path) { 273 for (Map.Entry<String, Object> entry : source.entrySet()) { 274 String key = entry.getKey(); 275 if (StringUtils.hasText(path)) { 276 if (key.startsWith("[")) { 277 key = path + key; 278 } 279 else { 280 key = path + '.' + key; 281 } 282 } 283 Object value = entry.getValue(); 284 if (value instanceof String) { 285 result.put(key, value); 286 } 287 else if (value instanceof Map) { 288 // Need a compound key 289 @SuppressWarnings("unchecked") 290 Map<String, Object> map = (Map<String, Object>) value; 291 buildFlattenedMap(result, map, key); 292 } 293 else if (value instanceof Collection) { 294 // Need a compound key 295 @SuppressWarnings("unchecked") 296 Collection<Object> collection = (Collection<Object>) value; 297 int count = 0; 298 for (Object object : collection) { 299 buildFlattenedMap(result, 300 Collections.singletonMap("[" + (count++) + "]", object), key); 301 } 302 } 303 else { 304 result.put(key, (value != null ? value : "")); 305 } 306 } 307 } 308 309 310 /** 311 * Callback interface used to process the YAML parsing results. 312 */ 313 public interface MatchCallback { 314 315 /** 316 * Process the given representation of the parsing results. 317 * @param properties the properties to process (as a flattened 318 * representation with indexed keys in case of a collection or map) 319 * @param map the result map (preserving the original value structure 320 * in the YAML document) 321 */ 322 void process(Properties properties, Map<String, Object> map); 323 } 324 325 326 /** 327 * Strategy interface used to test if properties match. 328 */ 329 public interface DocumentMatcher { 330 331 /** 332 * Test if the given properties match. 333 * @param properties the properties to test 334 * @return the status of the match 335 */ 336 MatchStatus matches(Properties properties); 337 } 338 339 340 /** 341 * Status returned from {@link DocumentMatcher#matches(java.util.Properties)} 342 */ 343 public enum MatchStatus { 344 345 /** 346 * A match was found. 347 */ 348 FOUND, 349 350 /** 351 * No match was found. 352 */ 353 NOT_FOUND, 354 355 /** 356 * The matcher should not be considered. 357 */ 358 ABSTAIN; 359 360 /** 361 * Compare two {@link MatchStatus} items, returning the most specific status. 362 */ 363 public static MatchStatus getMostSpecific(MatchStatus a, MatchStatus b) { 364 return (a.ordinal() < b.ordinal() ? a : b); 365 } 366 } 367 368 369 /** 370 * Method to use for resolving resources. 371 */ 372 public enum ResolutionMethod { 373 374 /** 375 * Replace values from earlier in the list. 376 */ 377 OVERRIDE, 378 379 /** 380 * Replace values from earlier in the list, ignoring any failures. 381 */ 382 OVERRIDE_AND_IGNORE, 383 384 /** 385 * Take the first resource in the list that exists and use just that. 386 */ 387 FIRST_FOUND 388 } 389 390 391 /** 392 * A specialized {@link Constructor} that checks for duplicate keys. 393 */ 394 protected static class StrictMapAppenderConstructor extends Constructor { 395 396 // Declared as public for use in subclasses 397 public StrictMapAppenderConstructor() { 398 super(); 399 } 400 401 @Override 402 protected Map<Object, Object> constructMapping(MappingNode node) { 403 try { 404 return super.constructMapping(node); 405 } 406 catch (IllegalStateException ex) { 407 throw new ParserException("while parsing MappingNode", 408 node.getStartMark(), ex.getMessage(), node.getEndMark()); 409 } 410 } 411 412 @Override 413 protected Map<Object, Object> createDefaultMap() { 414 final Map<Object, Object> delegate = super.createDefaultMap(); 415 return new AbstractMap<Object, Object>() { 416 @Override 417 public Object put(Object key, Object value) { 418 if (delegate.containsKey(key)) { 419 throw new IllegalStateException("Duplicate key: " + key); 420 } 421 return delegate.put(key, value); 422 } 423 @Override 424 public Set<Entry<Object, Object>> entrySet() { 425 return delegate.entrySet(); 426 } 427 }; 428 } 429 } 430 431}