/**
* Copyright (c) 2014-2017 by the respective copyright holders.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*/
package org.eclipse.smarthome.storage.json;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.smarthome.core.storage.Storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
/**
* The JsonStorage is concrete implementation of the {@link Storage} interface.
* It stores the key-value pairs in files. This Storage serializes and deserializes
* the given values using JSON (generated by {@code Gson}).
* A deferred write mechanism of WRITE_DELAY milliseconds is used to improve performance.
* The service keeps backups in a /backup folder, and maintains a maximum of MAX_FILES
* at any time
*
* @author Chris Jackson - Initial Contribution
* @author Stefan Triller - Removed dependency to internal GSon packages
*/
public class JsonStorage<T> implements Storage<T> {
private final Logger logger = LoggerFactory.getLogger(JsonStorage.class);
private final int maxBackupFiles;
private final int writeDelay;
private final int maxDeferredPeriod;
private final String CLASS = "class";
private final String VALUE = "value";
private final String BACKUP_EXTENSION = "backup";
private final String SEPARATOR = "--";
private Timer commitTimer = null;
private TimerTask commitTimerTask = null;
private long deferredSince = 0;
private File file;
private ClassLoader classLoader;
private final Map<String, Map<String, Object>> map = new ConcurrentHashMap<String, Map<String, Object>>();
private transient Gson mapper;
public JsonStorage(File file, ClassLoader classLoader, int maxBackupFiles, int writeDelay, int maxDeferredPeriod) {
this.file = file;
this.classLoader = classLoader;
this.maxBackupFiles = maxBackupFiles;
this.writeDelay = writeDelay;
this.maxDeferredPeriod = maxDeferredPeriod;
this.mapper = new GsonBuilder().registerTypeAdapter(Map.class, new StringObjectMapDeserializer()).setPrettyPrinting()
.create();
commitTimer = new Timer();
Map<String, Map<String, Object>> inputMap = null;
if (file.exists()) {
// Read the file
inputMap = readDatabase(file);
}
// If there was an error reading the file, then try one of the backup files
if (inputMap == null) {
if (file.exists()) {
logger.info("Json storage file at '{}' seems to be corrupt - checking for a backup.",
file.getAbsolutePath());
} else {
logger.debug("Json storage file at '{}' does not exist - checking for a backup.",
file.getAbsolutePath());
}
for (int cnt = 1; cnt <= maxBackupFiles; cnt++) {
File backupFile = getBackupFile(cnt);
if (backupFile == null) {
break;
}
inputMap = readDatabase(backupFile);
if (inputMap != null) {
logger.info("Json storage file at '{}' is used (backup {}).", backupFile.getAbsolutePath(), cnt);
break;
}
}
}
// If we've read data from a file, then add it to the map
if (inputMap != null) {
map.putAll(inputMap);
logger.debug("Opened Json storage file at '{}'.", file.getAbsolutePath());
}
}
/**
* {@inheritDoc}
*/
@Override
public T put(String key, T value) {
Map<String, Object> val = new LinkedHashMap<String, Object>();
val.put(CLASS, value.getClass().getName());
val.put(VALUE, value);
Map<String, Object> previousValue = map.get(key);
map.put(key, val);
deferredCommit();
if (previousValue == null) {
return null;
}
return deserialize(previousValue);
}
/**
* {@inheritDoc}
*/
@Override
public T remove(String key) {
Map<String, Object> removedElement = map.remove(key);
deferredCommit();
return deserialize(removedElement);
}
/**
* {@inheritDoc}
*/
@Override
public T get(String key) {
Map<String, Object> value = map.get(key);
if (value == null) {
return null;
}
return deserialize(value);
}
/**
* {@inheritDoc}
*/
@Override
public Collection<String> getKeys() {
return map.keySet();
}
/**
* {@inheritDoc}
*/
@Override
public Collection<T> getValues() {
Collection<T> values = new ArrayList<T>();
for (String key : getKeys()) {
values.add(get(key));
}
return values;
}
/**
* Deserializes and instantiates an object of type {@code T} out of the
* given JSON String. A special classloader (other than the one of the
* Json bundle) is used in order to load the classes in the context of
* the calling bundle.
*/
@SuppressWarnings("unchecked")
private T deserialize(Map<String, Object> jsonValue) {
if (jsonValue == null) {
// nothing to deserialize
return null;
}
T value = null;
try {
// load required class within the given bundle context
Class<T> loadedValueType = null;
if (classLoader == null) {
loadedValueType = (Class<T>) Class.forName((String) jsonValue.get(CLASS));
} else {
loadedValueType = (Class<T>) classLoader.loadClass((String) jsonValue.get(CLASS));
}
String jsonString = mapper.toJson(jsonValue.get("value"));
value = mapper.fromJson(jsonString, loadedValueType);
logger.trace("deserialized value '{}' from Json", value);
} catch (Exception e) {
logger.error("Couldn't deserialize value '{}'. Root cause is: {}", jsonValue, e.getMessage());
}
return value;
}
@SuppressWarnings("unchecked")
private Map<String, Map<String, Object>> readDatabase(File inputFile) {
try {
final Map<String, Map<String, Object>> inputMap = new ConcurrentHashMap<String, Map<String, Object>>();
FileReader reader = new FileReader(inputFile);
Map<String, Map<String, Object>> type = new HashMap<String, Map<String, Object>>();
Map<String, Map<String, Object>> loadedMap = mapper.fromJson(reader, type.getClass());
if (loadedMap != null && loadedMap.size() != 0) {
map.putAll(loadedMap);
}
return inputMap;
} catch (JsonSyntaxException | JsonIOException | FileNotFoundException e) {
logger.error("Error reading JsonDB from {}. Cause {}.", inputFile.getPath(), e.getMessage());
return null;
}
}
private File getBackupFile(int age) {
// Delete old backups
List<Long> fileTimes = new ArrayList<Long>();
File folder = new File(file.getParent() + File.separator + BACKUP_EXTENSION);
File[] files = folder.listFiles();
// Get an array of file times from the filename
int count = files.length;
for (int i = 0; i < count; i++) {
if (files[i].isFile()) {
String[] parts = files[i].getName().split(SEPARATOR);
if (parts.length != 2 || !parts[1].equals(file.getName())) {
continue;
}
long time = Long.parseLong(parts[0]);
fileTimes.add(time);
}
}
// Sort
Collections.sort(fileTimes);
if (fileTimes.size() < age) {
return null;
}
return new File(file.getParent() + File.separator + BACKUP_EXTENSION,
fileTimes.get(fileTimes.size() - age) + SEPARATOR + file.getName());
}
/**
* Write out any outstanding data
*/
public void commitDatabase() {
String s = mapper.toJson(map);
synchronized (map) {
// Rename the file for backup
File rename = new File(file.getParent() + File.separator + BACKUP_EXTENSION,
System.currentTimeMillis() + SEPARATOR + file.getName());
file.renameTo(rename);
try (FileOutputStream outputStream = new FileOutputStream(file, false);) {
outputStream.write(s.getBytes());
} catch (Exception e) {
logger.error("Error writing JsonDB to {}. Cause {}.", file.getPath(), e.getMessage());
}
deferredSince = 0;
}
}
private class CommitTimerTask extends TimerTask {
@Override
public void run() {
// Save the database
commitDatabase();
// Delete old backups
List<Long> fileTimes = new ArrayList<Long>();
File folder = new File(file.getParent() + File.separator + BACKUP_EXTENSION);
if (!folder.isDirectory()) {
return;
}
File[] files = folder.listFiles();
// Get an array of file times from the filename
int count = files.length;
for (int i = 0; i < count; i++) {
if (files[i].isFile()) {
String[] parts = files[i].getName().split(SEPARATOR);
if (parts.length != 2 || !parts[1].equals(file.getName())) {
continue;
}
long time = Long.parseLong(parts[0]);
fileTimes.add(time);
}
}
// Sort, and delete the oldest
Collections.sort(fileTimes);
if (fileTimes.size() > maxBackupFiles) {
for (int counter = 0; counter < fileTimes.size() - maxBackupFiles; counter++) {
File deleter = new File(file.getParent() + File.separator + BACKUP_EXTENSION,
fileTimes.get(counter) + SEPARATOR + file.getName());
deleter.delete();
}
}
}
}
public synchronized void deferredCommit() {
// Handle a maximum time for deferring the commit.
// This stops a pathological loop preventing saving
if (deferredSince != 0 && deferredSince < System.nanoTime() - maxDeferredPeriod) {
commitDatabase();
}
if (deferredSince == 0) {
deferredSince = System.nanoTime();
}
// Stop any existing timer
if (commitTimerTask != null) {
commitTimerTask.cancel();
commitTimerTask = null;
}
// Create the timer task
commitTimerTask = new CommitTimerTask();
// Start the timer
commitTimer.schedule(commitTimerTask, writeDelay);
}
}