Refactor common functionality out of WalkerManager and into JVMUtils and PathUtils. Add support for loading walkers from a jar.
git-svn-id: file:///humgen/gsa-scr1/gsa-engineering/svn_contents/trunk@229 348d0f76-0448-11de-a6fe-93d51630548a
This commit is contained in:
parent
8be02f6a34
commit
e812cfbf55
|
|
@ -5,24 +5,18 @@ import net.sf.functionalj.reflect.JdkStdReflect;
|
|||
import net.sf.functionalj.FunctionN;
|
||||
import net.sf.functionalj.Functions;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.io.File;
|
||||
import java.io.FilenameFilter;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarInputStream;
|
||||
|
||||
import org.broadinstitute.sting.gatk.walkers.Walker;
|
||||
import org.broadinstitute.sting.gatk.walkers.WalkerName;
|
||||
import org.broadinstitute.sting.utils.cmdLine.Argument;
|
||||
import org.broadinstitute.sting.utils.JVMUtils;
|
||||
import org.broadinstitute.sting.utils.PathUtils;
|
||||
import org.apache.log4j.Logger;
|
||||
|
||||
/**
|
||||
|
|
@ -46,7 +40,7 @@ public class WalkerManager {
|
|||
List<Class> walkerCandidates = new ArrayList<Class>();
|
||||
|
||||
// Load all classes that live in this jar.
|
||||
final File location = getThisLocation();
|
||||
final File location = JVMUtils.getLocationFor( getClass() );
|
||||
walkerCandidates.addAll(loadClassesFromLocation(location));
|
||||
|
||||
// Load all classes that live in the extension path.
|
||||
|
|
@ -56,8 +50,13 @@ public class WalkerManager {
|
|||
|
||||
File extensionPath = new File(pluginDirectory);
|
||||
if (extensionPath.exists()) {
|
||||
List<String> filesInPath = findFilesInPath(extensionPath, "", "class", false);
|
||||
walkerCandidates.addAll(loadExternalClasses(extensionPath, filesInPath));
|
||||
List<String> classFilesInPath = PathUtils.findFilesInPath(extensionPath, "", "class", false);
|
||||
walkerCandidates.addAll(JVMUtils.loadExternalClasses(extensionPath, classFilesInPath));
|
||||
List<String> jarsInPath = PathUtils.findFilesInPath(extensionPath, "", "jar", false);
|
||||
for( String jarFileName: jarsInPath ) {
|
||||
File jarFile = new File( extensionPath, jarFileName );
|
||||
walkerCandidates.addAll(JVMUtils.loadExternalClassesFromJar(jarFile) );
|
||||
}
|
||||
}
|
||||
|
||||
walkerCandidates = filterWalkers(walkerCandidates);
|
||||
|
|
@ -100,22 +99,6 @@ public class WalkerManager {
|
|||
return walkers.get(walkerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which jar file contains the WalkerManager class.
|
||||
*
|
||||
* @return Jar file containing the WalkerManager class.
|
||||
*/
|
||||
private File getThisLocation() throws IOException {
|
||||
try {
|
||||
java.net.URI locationURI = getClass().getProtectionDomain().getCodeSource().getLocation().toURI();
|
||||
return new File(locationURI);
|
||||
}
|
||||
catch (java.net.URISyntaxException ex) {
|
||||
// a URISyntaxException here must be an IO error; wrap as such.
|
||||
throw new IOException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load classes internal to the classpath from an arbitrary location.
|
||||
*
|
||||
|
|
@ -126,175 +109,10 @@ public class WalkerManager {
|
|||
private List<Class> loadClassesFromLocation(File location)
|
||||
throws IOException {
|
||||
if (location.getAbsolutePath().endsWith(".jar"))
|
||||
return loadClassesFromJar(location);
|
||||
return JVMUtils.loadInternalClassesFromJar(location);
|
||||
else {
|
||||
List<String> classFileNames = findFilesInPath(location, "", "class", true);
|
||||
return loadInternalClasses(classFileNames);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads concrete classes from a jar which are both in the same package or 'sub-package' of baseClass,
|
||||
* and which extend from baseClass.
|
||||
*
|
||||
* @param jarFile The jar file to search.
|
||||
* @return A list of classes derived from baseClass.
|
||||
*/
|
||||
private List<Class> loadClassesFromJar(final File jarFile)
|
||||
throws IOException {
|
||||
List<Class> subclasses = new ArrayList<Class>();
|
||||
|
||||
JarInputStream jarInputStream = new JarInputStream(new FileInputStream(jarFile));
|
||||
|
||||
try {
|
||||
JarEntry jarEntry = jarInputStream.getNextJarEntry();
|
||||
|
||||
while (jarEntry != null) {
|
||||
String jarEntryName = jarEntry.getName();
|
||||
if (jarEntryName.endsWith(".class")) {
|
||||
String className = fileNameToClassName(jarEntryName);
|
||||
subclasses.add(Class.forName(className));
|
||||
}
|
||||
jarEntry = jarInputStream.getNextJarEntry();
|
||||
}
|
||||
}
|
||||
catch (ClassNotFoundException ex) {
|
||||
// A ClassNotFoundException here must be an IO error; wrap as such.
|
||||
throw new IOException(ex);
|
||||
}
|
||||
finally {
|
||||
jarInputStream.close();
|
||||
}
|
||||
|
||||
return subclasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a list of classes currently on the classpath.
|
||||
*
|
||||
* @param classFileNames List of files representing classes.
|
||||
* @return class objects.
|
||||
* @throws IOException Unable to open any of the found classes.
|
||||
*/
|
||||
private List<Class> loadInternalClasses(List<String> classFileNames)
|
||||
throws IOException {
|
||||
List<Class> internalClasses = new ArrayList<Class>();
|
||||
|
||||
for (String classFileName : classFileNames) {
|
||||
String className = fileNameToClassName(classFileName);
|
||||
try {
|
||||
internalClasses.add(Class.forName(className));
|
||||
}
|
||||
catch (ClassNotFoundException ex) {
|
||||
// A ClassNotFoundException here must be an IO error; wrap as such.
|
||||
throw new IOException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
return internalClasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load loose classes, external to the classloader, from the specified directory.
|
||||
*
|
||||
* @param path source path from which to load classes.
|
||||
* @return A list of all loose classes contained in the path directory.
|
||||
*/
|
||||
private List<Class> loadExternalClasses(final File path, List<String> classFileNames)
|
||||
throws IOException {
|
||||
List<Class> subclasses = new ArrayList<Class>();
|
||||
|
||||
URL pathURL = path.toURI().toURL();
|
||||
|
||||
ClassLoader cl = new URLClassLoader(new URL[]{pathURL});
|
||||
|
||||
List<String> filesInPath = findFilesInPath(path, "", "class", false);
|
||||
for (String file : filesInPath) {
|
||||
String className = fileNameToClassName(file);
|
||||
try {
|
||||
subclasses.add(cl.loadClass(className));
|
||||
}
|
||||
catch (ClassNotFoundException ex) {
|
||||
// Class not found from a list of classes just looked up is an IO error. Wrap and throw.
|
||||
throw new IOException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
return subclasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the files in the given directory matching the given extension.
|
||||
*
|
||||
* @param basePath Path to search.
|
||||
* @param relativePrefix What directory should the given files be presented relative to?
|
||||
* @param extension Extension for which to search.
|
||||
* @param recursive Search recursively. Beware of symlinks!
|
||||
* @return A list of files matching the specified criteria.
|
||||
* TODO: Move to a utils class.
|
||||
* TODO: Test recursive traversal in the presence of a symlink.
|
||||
*/
|
||||
private List<String> findFilesInPath(final File basePath, final String relativePrefix, final String extension, boolean recursive) {
|
||||
List<String> filesInPath = new ArrayList<String>();
|
||||
|
||||
File[] contents = basePath.listFiles(new OrFilenameFilter(new DirectoryFilter(), new ExtensionFilter(extension)));
|
||||
for (File content : contents) {
|
||||
String relativeFileName = relativePrefix.trim().length() != 0 ?
|
||||
relativePrefix + File.separator + content.getName() :
|
||||
content.getName();
|
||||
if (relativeFileName.endsWith(extension))
|
||||
filesInPath.add(relativeFileName);
|
||||
else if (content.isDirectory() && recursive)
|
||||
filesInPath.addAll(findFilesInPath(content, relativeFileName, extension, recursive));
|
||||
}
|
||||
|
||||
return filesInPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a filename of the form a/b/c.class to a.b.c. Makes no assurances about whether the
|
||||
* class is valid on any classloader.
|
||||
*
|
||||
* @param fileName Filename to convert.
|
||||
* @return classname represented by that file.
|
||||
* TODO: Move to a utils class.
|
||||
*/
|
||||
private String fileNameToClassName(String fileName) {
|
||||
return fileName.substring(0, fileName.lastIndexOf(".class")).replace('/', '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* The following are general-purpose file selection filters.
|
||||
* TODO: Move to a utils class.
|
||||
*/
|
||||
private class ExtensionFilter implements FilenameFilter {
|
||||
private String extensionName = null;
|
||||
|
||||
public ExtensionFilter(String extensionName) {
|
||||
this.extensionName = extensionName;
|
||||
}
|
||||
|
||||
public boolean accept(File f, String s) {
|
||||
return s.endsWith("." + extensionName);
|
||||
}
|
||||
}
|
||||
|
||||
private class DirectoryFilter implements FilenameFilter {
|
||||
public boolean accept(File f, String s) {
|
||||
return new File(f, s).isDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
private class OrFilenameFilter implements FilenameFilter {
|
||||
private FilenameFilter lhs = null, rhs = null;
|
||||
|
||||
public OrFilenameFilter(FilenameFilter lhs, FilenameFilter rhs) {
|
||||
this.lhs = lhs;
|
||||
this.rhs = rhs;
|
||||
}
|
||||
|
||||
public boolean accept(File f, String s) {
|
||||
return lhs.accept(f, s) || rhs.accept(f, s);
|
||||
List<String> classFileNames = PathUtils.findFilesInPath(location, "", "class", true);
|
||||
return JVMUtils.loadInternalClasses(classFileNames);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -306,27 +124,10 @@ public class WalkerManager {
|
|||
*/
|
||||
private List<Class> filterWalkers(List<Class> classes) {
|
||||
StdReflect reflect = new JdkStdReflect();
|
||||
FunctionN<Boolean> filterFunc = reflect.instanceFunction(new ClassFilter(Walker.class), "filter", Class.class);
|
||||
FunctionN<Boolean> filterFunc = reflect.instanceFunction(new JVMUtils.ClassFilter(Walker.class), "filter", Class.class);
|
||||
return Functions.findAll(filterFunc.f1(), classes);
|
||||
}
|
||||
|
||||
/**
|
||||
* A functor returning true for classes which extend from baseClass.
|
||||
*/
|
||||
private class ClassFilter {
|
||||
private Class baseClass;
|
||||
|
||||
public ClassFilter(Class baseClass) {
|
||||
this.baseClass = baseClass;
|
||||
}
|
||||
|
||||
public Boolean filter(Class clazz) {
|
||||
return baseClass.isAssignableFrom(clazz) &&
|
||||
!Modifier.isAbstract(clazz.getModifiers()) &&
|
||||
!Modifier.isInterface(clazz.getModifiers());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the list of walker classes. Add them to the walker hashmap.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
package org.broadinstitute.sting.utils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.FileInputStream;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.jar.JarInputStream;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.lang.reflect.Modifier;
|
||||
|
||||
/**
|
||||
* Created by IntelliJ IDEA.
|
||||
* User: hanna
|
||||
* Date: Mar 30, 2009
|
||||
* Time: 5:38:05 PM
|
||||
*
|
||||
* A set of static utility methods for determining information about this runtime environment.
|
||||
* Introspects classes, loads jars, etc.
|
||||
*/
|
||||
public class JVMUtils {
|
||||
/**
|
||||
* Constructor access disallowed...static utility methods only!
|
||||
*/
|
||||
private JVMUtils() { }
|
||||
|
||||
/**
|
||||
* Determines which location contains the specified class.
|
||||
*
|
||||
* @return Location (either jar file or directory) of path containing class.
|
||||
*/
|
||||
public static File getLocationFor( Class clazz ) throws IOException {
|
||||
try {
|
||||
java.net.URI locationURI = clazz.getProtectionDomain().getCodeSource().getLocation().toURI();
|
||||
return new File(locationURI);
|
||||
}
|
||||
catch (java.net.URISyntaxException ex) {
|
||||
// a URISyntaxException here must be an IO error; wrap as such.
|
||||
throw new IOException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads concrete classes from a jar which are both in the same package or 'sub-package' of baseClass,
|
||||
* and which extend from baseClass. Loaded classes must already be on the classpath.
|
||||
*
|
||||
* @param jarFile The jar file to search.
|
||||
* @return A list of classes derived from baseClass.
|
||||
*/
|
||||
public static List<Class> loadInternalClassesFromJar(final File jarFile)
|
||||
throws IOException {
|
||||
return loadClassesFromJar( jarFile, new InternalLoadingStrategy() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads concrete classes from a jar which are both in the same package or 'sub-package' of baseClass,
|
||||
* and which extend from baseClass. Loaded classes can be outside of the current classpath.
|
||||
*
|
||||
* @param jarFile The jar file to search.
|
||||
* @return A list of classes derived from baseClass.
|
||||
*/
|
||||
public static List<Class> loadExternalClassesFromJar(final File jarFile)
|
||||
throws IOException {
|
||||
return loadClassesFromJar( jarFile, new ExternalLoadingStrategy(jarFile) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a list of classes currently on the classpath.
|
||||
*
|
||||
* @param classFileNames List of files representing classes.
|
||||
* @return class objects.
|
||||
* @throws IOException Unable to open any of the found classes.
|
||||
*/
|
||||
public static List<Class> loadInternalClasses(List<String> classFileNames)
|
||||
throws IOException {
|
||||
return loadClasses( classFileNames, new InternalLoadingStrategy() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Load loose classes, external to the classloader, from the specified directory.
|
||||
*
|
||||
* @param path source path from which to load classes.
|
||||
* @return A list of all loose classes contained in the path directory.
|
||||
*/
|
||||
public static List<Class> loadExternalClasses(final File path, List<String> classFileNames)
|
||||
throws IOException {
|
||||
return loadClasses( classFileNames, new ExternalLoadingStrategy( path ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a filename of the form a/b/c.class to a.b.c. Makes no assurances about whether the
|
||||
* class is valid on any classloader.
|
||||
*
|
||||
* @param fileName Filename to convert.
|
||||
* @return classname represented by that file.
|
||||
*/
|
||||
public static String fileNameToClassName(String fileName) {
|
||||
return fileName.substring(0, fileName.lastIndexOf(".class")).replace('/', '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* A functor returning true for classes which extend from baseClass.
|
||||
*/
|
||||
public static class ClassFilter {
|
||||
private Class baseClass;
|
||||
|
||||
public ClassFilter(Class baseClass) {
|
||||
this.baseClass = baseClass;
|
||||
}
|
||||
|
||||
public Boolean filter(Class clazz) {
|
||||
return baseClass.isAssignableFrom(clazz) &&
|
||||
!Modifier.isAbstract(clazz.getModifiers()) &&
|
||||
!Modifier.isInterface(clazz.getModifiers());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a list of classes from the given jar, using the provided loading strategy.
|
||||
* @param jarFile Jar file from which to load.
|
||||
* @param loader Dictates how these classes should be loaded.
|
||||
* @return A list of loaded classes.
|
||||
* @throws IOException In case there's an IO error trying to load the jar.
|
||||
*/
|
||||
private static List<Class> loadClassesFromJar( final File jarFile, final LoadingStrategy loader )
|
||||
throws IOException {
|
||||
List<Class> classes = new ArrayList<Class>();
|
||||
|
||||
JarInputStream jarInputStream = new JarInputStream(new FileInputStream(jarFile));
|
||||
|
||||
try {
|
||||
JarEntry jarEntry = jarInputStream.getNextJarEntry();
|
||||
|
||||
while (jarEntry != null) {
|
||||
String jarEntryName = jarEntry.getName();
|
||||
if (jarEntryName.endsWith(".class")) {
|
||||
String className = fileNameToClassName(jarEntryName);
|
||||
classes.add( loader.load( className ) );
|
||||
}
|
||||
jarEntry = jarInputStream.getNextJarEntry();
|
||||
}
|
||||
}
|
||||
catch (ClassNotFoundException ex) {
|
||||
// A ClassNotFoundException here must be an IO error; wrap as such.
|
||||
throw new IOException(ex);
|
||||
}
|
||||
finally {
|
||||
jarInputStream.close();
|
||||
}
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a list of classes, using the provided loading strategy.
|
||||
* @param classFileNames Which class files to load.
|
||||
* @param loader Dictates how these classes should be loaded.
|
||||
* @return A list of loaded classes.
|
||||
* @throws IOException In case there's an IO error trying to load the jar.
|
||||
*/
|
||||
private static List<Class> loadClasses( List<String> classFileNames, LoadingStrategy loader )
|
||||
throws IOException {
|
||||
List<Class> classes = new ArrayList<Class>();
|
||||
|
||||
for (String classFileName : classFileNames) {
|
||||
String className = fileNameToClassName(classFileName);
|
||||
try {
|
||||
classes.add( loader.load( className ) );
|
||||
}
|
||||
catch (ClassNotFoundException ex) {
|
||||
// A ClassNotFoundException here must be an IO error; wrap as such.
|
||||
throw new IOException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* What mechanism should we use for loading a list of classes?
|
||||
*/
|
||||
private static interface LoadingStrategy {
|
||||
Class load( String className ) throws ClassNotFoundException;
|
||||
}
|
||||
|
||||
/**
|
||||
* An internal loading strategy, for loading classes already on the classpath.
|
||||
*/
|
||||
private static class InternalLoadingStrategy implements LoadingStrategy {
|
||||
public Class load( String className )
|
||||
throws ClassNotFoundException {
|
||||
return Class.forName( className );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An external loading strategy, for loading classes not necessarily already
|
||||
* on the classpath.
|
||||
*/
|
||||
private static class ExternalLoadingStrategy implements LoadingStrategy {
|
||||
private final ClassLoader classLoader;
|
||||
|
||||
public ExternalLoadingStrategy( final File jarFile ) throws IOException {
|
||||
URL pathURL = jarFile.toURI().toURL();
|
||||
classLoader = new URLClassLoader(new URL[]{pathURL});
|
||||
}
|
||||
|
||||
public Class load( String className ) throws ClassNotFoundException {
|
||||
return classLoader.loadClass(className);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package org.broadinstitute.sting.utils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.io.File;
|
||||
import java.io.FilenameFilter;
|
||||
|
||||
/**
|
||||
* Created by IntelliJ IDEA.
|
||||
* User: hanna
|
||||
* Date: Mar 30, 2009
|
||||
* Time: 5:43:39 PM
|
||||
* To change this template use File | Settings | File Templates.
|
||||
*
|
||||
* A set of static utility methods for common operations on paths.
|
||||
*/
|
||||
public class PathUtils {
|
||||
/**
|
||||
* Constructor access disallowed...static utility methods only!
|
||||
*/
|
||||
private PathUtils() { }
|
||||
|
||||
/**
|
||||
* Find the files in the given directory matching the given extension.
|
||||
*
|
||||
* @param basePath Path to search.
|
||||
* @param relativePrefix What directory should the given files be presented relative to?
|
||||
* @param extension Extension for which to search.
|
||||
* @param recursive Search recursively. Beware of symlinks!
|
||||
* @return A list of files matching the specified criteria.
|
||||
* TODO: Test recursive traversal in the presence of a symlink.
|
||||
*/
|
||||
public static List<String> findFilesInPath(final File basePath, final String relativePrefix, final String extension, boolean recursive) {
|
||||
List<String> filesInPath = new ArrayList<String>();
|
||||
|
||||
FilenameFilter filter = new OrFilenameFilter(new DirectoryFilter(),
|
||||
new ExtensionFilter(extension));
|
||||
File[] contents = basePath.listFiles( filter );
|
||||
for (File content : contents) {
|
||||
String relativeFileName = relativePrefix.trim().length() != 0 ?
|
||||
relativePrefix + File.separator + content.getName() :
|
||||
content.getName();
|
||||
if (relativeFileName.endsWith(extension))
|
||||
filesInPath.add(relativeFileName);
|
||||
else if (content.isDirectory() && recursive)
|
||||
filesInPath.addAll(findFilesInPath(content, relativeFileName, extension, recursive));
|
||||
}
|
||||
|
||||
return filesInPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter files by extension.
|
||||
*/
|
||||
public static class ExtensionFilter implements FilenameFilter {
|
||||
private String extensionName = null;
|
||||
|
||||
public ExtensionFilter(String extensionName) {
|
||||
this.extensionName = extensionName;
|
||||
}
|
||||
|
||||
public boolean accept(File f, String s) {
|
||||
return s.endsWith("." + extensionName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter directories from list of files.
|
||||
*/
|
||||
public static class DirectoryFilter implements FilenameFilter {
|
||||
public boolean accept(File f, String s) {
|
||||
return new File(f, s).isDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join two FilenameFilters together in a logical 'or' operation.
|
||||
*/
|
||||
public static class OrFilenameFilter implements FilenameFilter {
|
||||
private FilenameFilter lhs = null, rhs = null;
|
||||
|
||||
public OrFilenameFilter(FilenameFilter lhs, FilenameFilter rhs) {
|
||||
this.lhs = lhs;
|
||||
this.rhs = rhs;
|
||||
}
|
||||
|
||||
public boolean accept(File f, String s) {
|
||||
return lhs.accept(f, s) || rhs.accept(f, s);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue