/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
*/
/*
* Created on Oct 19, 2004
*/
package org.apache.jmeter.services;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import org.apache.commons.collections.ArrayStack;
import org.apache.jmeter.gui.JMeterFileFilter;
import org.apache.jmeter.save.CSVSaveService;
import org.apache.jmeter.util.JMeterUtils;
import org.apache.jorphan.logging.LoggingManager;
import org.apache.jorphan.util.JOrphanUtils;
import org.apache.log.Logger;
/**
*
* The point of this class is to provide thread-safe access to files, and to
* provide some simplifying assumptions about where to find files and how to
* name them. For instance, putting supporting files in the same directory as
* the saved test plan file allows users to refer to the file with just it's
* name - this FileServer class will find the file without a problem.
* Eventually, I want all in-test file access to be done through here, with the
* goal of packaging up entire test plans as a directory structure that can be
* sent via rmi to remote servers (currently, one must make sure the remote
* server has all support files in a relative-same location) and to package up
* test plans to execute on unknown boxes that only have Java installed.
*/
public class FileServer {
private static final Logger log = LoggingManager.getLoggerForClass();
/**
* The default base used for resolving relative files, i.e.<br/>
* {@code System.getProperty("user.dir")}
*/
private static final String DEFAULT_BASE = System.getProperty("user.dir");// $NON-NLS-1$
/** Default base prefix: {@value} */
private static final String BASE_PREFIX_DEFAULT = "~/"; // $NON-NLS-1$
private static final String BASE_PREFIX =
JMeterUtils.getPropDefault("jmeter.save.saveservice.base_prefix", // $NON-NLS-1$
BASE_PREFIX_DEFAULT);
//@GuardedBy("this")
private File base;
//@GuardedBy("this") NOTE this also guards against possible window in checkForOpenFiles()
private final Map<String, FileEntry> files = new HashMap<String, FileEntry>();
private static final FileServer server = new FileServer();
private final Random random = new Random();
// volatile needed to ensure safe publication
private volatile String scriptName;
// Cannot be instantiated
private FileServer() {
base = new File(DEFAULT_BASE);
log.info("Default base='"+DEFAULT_BASE+"'");
}
/**
* @return the singleton instance of the server.
*/
public static FileServer getFileServer() {
return server;
}
/**
* Resets the current base to {@link #DEFAULT_BASE}.
*/
public synchronized void resetBase() {
checkForOpenFiles();
base = new File(DEFAULT_BASE);
log.info("Reset base to'"+base+"'");
}
/**
* Sets the current base directory for relative file names from the provided path.
* If the path does not refer to an existing directory, then its parent is used.
* Normally the provided path is a file, so using the parent directory is appropriate.
*
* @param basedir the path to set, or {@code null} if the GUI is being cleared
* @throws IllegalStateException if files are still open
*/
public synchronized void setBasedir(String basedir) {
checkForOpenFiles(); // TODO should this be called if basedir == null?
if (basedir != null) {
File newBase = new File(basedir);
if (!newBase.isDirectory()) {
newBase = newBase.getParentFile();
}
base = newBase;
log.info("Set new base='"+base+"'");
}
}
/**
* Sets the current base directory for relative file names from the provided script file.
* The parameter is assumed to be the path to a JMX file, so the base directory is derived
* from its parent.
*
* @param scriptPath the path of the script file; must be not be {@code null}
* @throws IllegalStateException if files are still open
* @throws IllegalArgumentException if scriptPath parameter is null
*/
public synchronized void setBaseForScript(File scriptPath) {
if (scriptPath == null){
throw new IllegalArgumentException("scriptPath must not be null");
}
setScriptName(scriptPath.getName());
// getParentFile() may not work on relative paths
setBase(scriptPath.getAbsoluteFile().getParentFile());
}
/**
* Sets the current base directory for relative file names.
*
* @param jmxBase the path of the script file base directory, cannot be null
* @throws IllegalStateException if files are still open
* @throws IllegalArgumentException if {@code basepath} is null
*/
public synchronized void setBase(File jmxBase) {
if (jmxBase == null) {
throw new IllegalArgumentException("jmxBase must not be null");
}
checkForOpenFiles();
base = jmxBase;
log.info("Set new base='"+base+"'");
}
/**
* Check if there are entries in use.
* <p>
* Caller must ensure that access to the files map is single-threaded as
* there is a window between checking the files Map and clearing it.
*
* @throws IllegalStateException if there are any entries still in use
*/
private void checkForOpenFiles() throws IllegalStateException {
if (filesOpen()) { // checks for entries in use
throw new IllegalStateException("Files are still open, cannot change base directory");
}
files.clear(); // tidy up any unused entries
}
public synchronized String getBaseDir() {
return base.getAbsolutePath();
}
public static String getDefaultBase(){
return DEFAULT_BASE;
}
/**
* Calculates the relative path from {@link #DEFAULT_BASE} to the current base,
* which must be the same as or a child of the default.
*
* @return the relative path, or {@code "."} if the path cannot be determined
*/
public synchronized File getBaseDirRelative() {
// Must first convert to absolute path names to ensure parents are available
File parent = new File(DEFAULT_BASE).getAbsoluteFile();
File f = base.getAbsoluteFile();
ArrayStack l = new ArrayStack();
while (f != null) {
if (f.equals(parent)){
if (l.isEmpty()){
break;
}
File rel = new File((String) l.pop());
while(!l.isEmpty()) {
rel = new File(rel, (String) l.pop());
}
return rel;
}
l.push(f.getName());
f = f.getParentFile();
}
return new File(".");
}
/**
* Creates an association between a filename and a File inputOutputObject,
* and stores it for later use - unless it is already stored.
*
* @param filename - relative (to base) or absolute file name (must not be null)
*/
public void reserveFile(String filename) {
reserveFile(filename,null);
}
/**
* Creates an association between a filename and a File inputOutputObject,
* and stores it for later use - unless it is already stored.
*
* @param filename - relative (to base) or absolute file name (must not be null)
* @param charsetName - the character set encoding to use for the file (may be null)
*/
public void reserveFile(String filename, String charsetName) {
reserveFile(filename, charsetName, filename, false);
}
/**
* Creates an association between a filename and a File inputOutputObject,
* and stores it for later use - unless it is already stored.
*
* @param filename - relative (to base) or absolute file name (must not be null)
* @param charsetName - the character set encoding to use for the file (may be null)
* @param alias - the name to be used to access the object (must not be null)
*/
public void reserveFile(String filename, String charsetName, String alias) {
reserveFile(filename, charsetName, alias, false);
}
/**
* Creates an association between a filename and a File inputOutputObject,
* and stores it for later use - unless it is already stored.
*
* @param filename - relative (to base) or absolute file name (must not be null)
* @param charsetName - the character set encoding to use for the file (may be null)
* @param alias - the name to be used to access the object (must not be null)
* @param hasHeader true if the file has a header line describing the contents
* @return the header line; may be null
*/
public synchronized String reserveFile(String filename, String charsetName, String alias, boolean hasHeader) {
if (filename == null){
throw new IllegalArgumentException("Filename must not be null");
}
if (alias == null){
throw new IllegalArgumentException("Alias must not be null");
}
FileEntry fileEntry = files.get(alias);
if (fileEntry == null) {
File f = new File(filename);
fileEntry =
new FileEntry(f.isAbsolute() ? f : new File(base, filename),null,charsetName);
if (filename.equals(alias)){
log.info("Stored: "+filename);
} else {
log.info("Stored: "+filename+" Alias: "+alias);
}
files.put(alias, fileEntry);
if (hasHeader){
try {
fileEntry.headerLine=readLine(alias, false);
} catch (IOException e) {
throw new IllegalArgumentException("Could not read file header line",e);
}
}
}
return fileEntry.headerLine;
}
/**
* Get the next line of the named file, recycle by default.
*
* @param filename the filename or alias that was used to reserve the file
* @return String containing the next line in the file
* @throws IOException
*/
public String readLine(String filename) throws IOException {
return readLine(filename, true);
}
/**
* Get the next line of the named file, first line is name to false
*
* @param filename the filename or alias that was used to reserve the file
* @param recycle - should file be restarted at EOF?
* @return String containing the next line in the file (null if EOF reached and not recycle)
* @throws IOException
*/
public String readLine(String filename, boolean recycle) throws IOException {
return readLine(filename, recycle, false);
}
/**
* Get the next line of the named file.
*
* @param filename the filename or alias that was used to reserve the file
* @param recycle - should file be restarted at EOF?
* @param firstLineIsNames - 1st line is fields names
* @return String containing the next line in the file (null if EOF reached and not recycle)
* @throws IOException
*/
public synchronized String readLine(String filename, boolean recycle,
boolean firstLineIsNames) throws IOException {
FileEntry fileEntry = files.get(filename);
if (fileEntry != null) {
if (fileEntry.inputOutputObject == null) {
fileEntry.inputOutputObject = createBufferedReader(fileEntry);
} else if (!(fileEntry.inputOutputObject instanceof Reader)) {
throw new IOException("File " + filename + " already in use");
}
BufferedReader reader = (BufferedReader) fileEntry.inputOutputObject;
String line = reader.readLine();
if (line == null && recycle) {
reader.close();
reader = createBufferedReader(fileEntry);
fileEntry.inputOutputObject = reader;
if (firstLineIsNames) {
// read first line and forget
reader.readLine();
}
line = reader.readLine();
}
if (log.isDebugEnabled()) { log.debug("Read:"+line); }
return line;
}
throw new IOException("File never reserved: "+filename);
}
/**
*
* @param alias the file name or alias
* @param recycle whether the file should be re-started on EOF
* @param firstLineIsNames whether the file contains a file header
* @param delim the delimiter to use for parsing
* @return the parsed line, will be empty if the file is at EOF
*/
public synchronized String[] getParsedLine(String alias, boolean recycle, boolean firstLineIsNames, char delim) throws IOException {
BufferedReader reader = getReader(alias, recycle, firstLineIsNames);
return CSVSaveService.csvReadFile(reader, delim);
}
private BufferedReader getReader(String alias, boolean recycle, boolean firstLineIsNames) throws IOException {
FileEntry fileEntry = files.get(alias);
if (fileEntry != null) {
BufferedReader reader;
if (fileEntry.inputOutputObject == null) {
reader = createBufferedReader(fileEntry);
fileEntry.inputOutputObject = reader;
if (firstLineIsNames) {
// read first line and forget
reader.readLine();
}
} else if (!(fileEntry.inputOutputObject instanceof Reader)) {
throw new IOException("File " + alias + " already in use");
} else {
reader = (BufferedReader) fileEntry.inputOutputObject;
if (recycle) { // need to check if we are at EOF already
reader.mark(1);
int peek = reader.read();
if (peek == -1) { // already at EOF
reader.close();
reader = createBufferedReader(fileEntry);
fileEntry.inputOutputObject = reader;
if (firstLineIsNames) {
// read first line and forget
reader.readLine();
}
} else { // OK, we still have some data, restore it
reader.reset();
}
}
}
return reader;
} else {
throw new IOException("File never reserved: "+alias);
}
}
private BufferedReader createBufferedReader(FileEntry fileEntry) throws IOException {
FileInputStream fis = new FileInputStream(fileEntry.file);
InputStreamReader isr = null;
// If file encoding is specified, read using that encoding, otherwise use default platform encoding
String charsetName = fileEntry.charSetEncoding;
if(!JOrphanUtils.isBlank(charsetName)) {
isr = new InputStreamReader(fis, charsetName);
} else {
isr = new InputStreamReader(fis);
}
return new BufferedReader(isr);
}
public synchronized void write(String filename, String value) throws IOException {
FileEntry fileEntry = files.get(filename);
if (fileEntry != null) {
if (fileEntry.inputOutputObject == null) {
fileEntry.inputOutputObject = createBufferedWriter(fileEntry);
} else if (!(fileEntry.inputOutputObject instanceof Writer)) {
throw new IOException("File " + filename + " already in use");
}
BufferedWriter writer = (BufferedWriter) fileEntry.inputOutputObject;
if (log.isDebugEnabled()) { log.debug("Write:"+value); }
writer.write(value);
} else {
throw new IOException("File never reserved: "+filename);
}
}
private BufferedWriter createBufferedWriter(FileEntry fileEntry) throws IOException {
FileOutputStream fos = new FileOutputStream(fileEntry.file);
OutputStreamWriter osw = null;
// If file encoding is specified, write using that encoding, otherwise use default platform encoding
String charsetName = fileEntry.charSetEncoding;
if(!JOrphanUtils.isBlank(charsetName)) {
osw = new OutputStreamWriter(fos, charsetName);
} else {
osw = new OutputStreamWriter(fos);
}
return new BufferedWriter(osw);
}
public synchronized void closeFiles() throws IOException {
for (Map.Entry<String, FileEntry> me : files.entrySet()) {
closeFile(me.getKey(),me.getValue() );
}
files.clear();
}
/**
* @param name
* @throws IOException
*/
public synchronized void closeFile(String name) throws IOException {
FileEntry fileEntry = files.get(name);
closeFile(name, fileEntry);
}
private void closeFile(String name, FileEntry fileEntry) throws IOException {
if (fileEntry != null && fileEntry.inputOutputObject != null) {
log.info("Close: "+name);
fileEntry.inputOutputObject.close();
fileEntry.inputOutputObject = null;
}
}
boolean filesOpen() { // package access for test code only
for (FileEntry fileEntry : files.values()) {
if (fileEntry.inputOutputObject != null) {
return true;
}
}
return false;
}
/**
* Method will get a random file in a base directory
* TODO hey, not sure this method belongs here. FileServer is for threadsafe
* File access relative to current test's base directory.
*
* @param basedir
* @return a random File from the basedir that matches one of the extensions
*/
public File getRandomFile(String basedir, String[] extensions) {
File input = null;
if (basedir != null) {
File src = new File(basedir);
if (src.isDirectory() && src.list() != null) {
File[] lfiles = src.listFiles(new JMeterFileFilter(extensions));
int count = lfiles.length;
input = lfiles[random.nextInt(count)];
}
}
return input;
}
private static class FileEntry{
private String headerLine;
private final File file;
private Closeable inputOutputObject;
private final String charSetEncoding;
FileEntry(File f, Closeable o, String e){
file=f;
inputOutputObject=o;
charSetEncoding=e;
}
}
/**
* Resolve a file name that may be relative to the base directory.
* If the name begins with the value of the JMeter property
* "jmeter.save.saveservice.base_prefix"
* - default "~/" - then the name is assumed to be relative to the basename.
*
* @param relativeName
* @return the updated file
*/
public static String resolveBaseRelativeName(String relativeName) {
if (relativeName.startsWith(BASE_PREFIX)){
String newName = relativeName.substring(BASE_PREFIX.length());
return new File(getFileServer().getBaseDir(),newName).getAbsolutePath();
}
return relativeName;
}
/**
* @return JMX Script name
* @since 2.6
*/
public String getScriptName() {
return scriptName;
}
/**
* @param scriptName Script name
* @since 2.6
*/
public void setScriptName(String scriptName) {
this.scriptName = scriptName;
}
}