CLOVER🍀

That was when it all began.

Javaで特定のパッケージ配下のクラスを検索する

1度やってみたかったんですよね、これ。DIコンテナとかで、よく特定のパッケージ配下のクラスを検索するような機能がありますが、これを自分で書いてみようと思います。

検索対象のクラスが配置されているパターンとしては、

  1. ディレクトリ配下に.classファイルが配置してある
  2. JARファイル内にパッケージングしてある

というのがよく考えられると思います。今回は、とりあえず上記2つを対象に考えます。

まず、検索対象のスケープゴートとして、以下のようなディレクトリ構成のクラスを用意しました。
ソースコード

root/RootPackageClass1.java
root/RootPackageClass2.java
root/sub/SubPackageClass1.java
root/sub/SubPackageClass2.java

クラスファイル

root/RootPackageClass1.class
root/RootPackageClass2.class
root/sub/SubPackageClass1.class
root/sub/SubPackageClass1$InnerClass1.class
root/sub/SubPackageClass2.class

クラスファイルをパッケージングしたJARファイル

$ jar -tvf classes.jar 
     0 Sun Mar 11 18:54:34 JST 2012 META-INF/
    71 Sun Mar 11 18:54:34 JST 2012 META-INF/MANIFEST.MF
     0 Sun Mar 11 17:50:42 JST 2012 root/
     0 Sun Mar 11 18:54:12 JST 2012 root/sub/
   303 Sun Mar 11 18:54:12 JST 2012 root/sub/SubPackageClass1$InnerClass1.class
   307 Sun Mar 11 18:54:12 JST 2012 root/sub/SubPackageClass1.class
   215 Sun Mar 11 18:54:12 JST 2012 root/sub/SubPackageClass2.class
   213 Sun Mar 11 18:54:02 JST 2012 root/RootPackageClass1.class
   213 Sun Mar 11 18:54:02 JST 2012 root/RootPackageClass2.class

クラスの検索がしたいだけなので、中身はどれも空っぽのクラスです。

package root;

public class RootPackageClass1 {
}

------------------------------------------------------

package root;

public class RootPackageClass2 {
}

------------------------------------------------------

package root.sub;

public class SubPackageClass1 {
    public static class InnerClass1 {
    }
}

------------------------------------------------------

package root.sub;

public class SubPackageClass2 {
}

しれっと、インナークラスが混じっています。

これにクラスパスを通して実行する、こういうクラスを書いてみます。
*import文は省略しています

public class ClassFinder {
    private ClassLoader classLoader;

    public static void main(String[] args) throws Exception {
        ClassFinder classFinder = new ClassFinder();
        classFinder.printClasses(args[0]);
    }

    public ClassFinder() {
        classLoader = Thread.currentThread().getContextClassLoader();
    }

    public ClassFinder(ClassLoader classLoader) {
        this.classLoader = classLoader;
    }

    public void printClasses(String rootPackageName) throws Exception {
        String resourceName = rootPackageName.replace('.', '/');
        URL url = classLoader.getResource(resourceName);
        System.out.println("URL = " + url);
        System.out.println("URLConnection = " + url.openConnection());
    }
}

ポイントは、パッケージ名を「.」つなぎから「/」つなぎに変換していることと、

        String resourceName = rootPackageName.replace('.', '/');

ClassLoader#getResourceメソッドを使ってURLを取得していることです。

        URL url = classLoader.getResource(resourceName);

最後に、取得したURLを表示してみて、どういう結果となるか確認してみます。

上記サンプルは、第1引数にパッケージ名を受け取ることを想定して書いています。とりあえず、実行してみましょう。対象は「root」パッケージとします。

まずは、クラスファイルを特定のディレクトリに展開している場合。

$ java -cp ../java/classes:. ClassFinder root
URL = file:/xxxxx/java/classes/root
URLConnection = sun.net.www.protocol.file.FileURLConnection:file:/xxxxx/java/classes/root

続いて、JARファイルにパッケージングしている場合。

$ java -cp ../java/classes.jar:. ClassFinder root
URL = jar:file:/xxxxx/java/classes.jar!/root
URLConnection = sun.net.www.protocol.jar.JarURLConnection:jar:file:/xxxxx/java/classes.jar!/root

見ての通り、結果が異なります。対象のクラスがファイルシステムからロードされた場合はFileURLConnectionが、JARファイルからロードされた場合はJarURLConnectionが返ってきています。この結果、クラスパスの通し方によって、クラスの探索方法が異なることになります。

では、最初のサンプルを、目的の特定のパッケージ配下のクラスを検索するように書き直してみます。

プロトコルがfileなのかjarなのかで動作を変えることになるので、そのように条件分岐します。

        String protocol = url.getProtocol();
        if ("file".equals(protocol)) {
            // fileの場合
        } else if ("jar".equals(protocol)) {
            // JARファイルからロードした場合
        }

FILEプロトコルの場合は、URLからファイルパスが取得できるので、ここからFileクラスのインスタンスを生成します。

new File(url.getFile());

あとは、このディレクトリを基点に配下のクラスを検索していくコードを書きます。

    private List<Class<?>> findClassesWithFile(String packageName, File dir) throws Exception {
        List<Class<?>> classes = new ArrayList<Class<?>>();

        for (String path : dir.list()) {
            File entry = new File(dir, path);
            if (entry.isFile() && isClassFile(entry.getName())) {
                classes.add(classLoader.loadClass(packageName + "." + fileNameToClassName(entry.getName())));
            } else if (entry.isDirectory()) {
                classes.addAll(findClassesWithFile(packageName + "." + entry.getName(), entry));
            }
        }

        return classes;
    }

メソッドisClassFileは、拡張子が「.class」かどうかを判定し、fileNameToClassNameは拡張子「.class」を除去するだけのメソッドです。サブディレクトリがある場合を考えて、「.」を足して再帰呼び出ししています。このロード方法の場合、すでに特定のパッケージの配下を走査していることになるので、単純にクラスを検索して足していくだけですね。

続いてJARファイルの場合。JARファイルの場合は、URL#openConnectionメソッドを呼び出してJarConnectionを取得します。

        JarURLConnection jarUrlConnection = (JarURLConnection)jarFileUrl.openConnection();

取得したJarURLConnectionからは、JarFileが取得できます。

            jarFile = jarUrlConnection.getJarFile();

あとは、JarFile内のエントリをひとつひとつ確認していくことになります。特定のパッケージの配下ということで、String#startsWithで当てることになるのですが、この時JARファイル内のエントリの名前は「.」ではなく「/」となっているので注意が必要です。

            String packageNameAsResourceName = packageNameToResourceName(rootPackageName);

            while (jarEnum.hasMoreElements()) {
                JarEntry jarEntry = jarEnum.nextElement();
                if (jarEntry.getName().startsWith(packageNameAsResourceName) && isClassFile(jarEntry.getName())) {
                    classes.add(classLoader.loadClass(resourceNameToClassName(jarEntry.getName())));
                }
            }

今回は、引数のパッケージ名の「.」をあらかじめ「/」に変換してString#startsWithで当てるようにしています。なお、この場合も拡張子の「.class」はクラス取得時には不要なので取り除きます。

JarFileは、一応閉じておきます。

        } finally {
            if (jarFile != null) {
                jarFile.close();
            }
        }

JARファイルからの検索処理のコード片は、上記を合わせてこんな感じになりました。

    private List<Class<?>> findClassesWithJarFile(String rootPackageName, URL jarFileUrl) throws Exception {
        List<Class<?>> classes = new ArrayList<Class<?>>();

        JarURLConnection jarUrlConnection = (JarURLConnection)jarFileUrl.openConnection();
        JarFile jarFile = null;

        try {
            jarFile = jarUrlConnection.getJarFile();
            Enumeration<JarEntry> jarEnum = jarFile.entries();

            String packageNameAsResourceName = packageNameToResourceName(rootPackageName);

            while (jarEnum.hasMoreElements()) {
                JarEntry jarEntry = jarEnum.nextElement();
                if (jarEntry.getName().startsWith(packageNameAsResourceName) && isClassFile(jarEntry.getName())) {
                    classes.add(classLoader.loadClass(resourceNameToClassName(jarEntry.getName())));
                }
            }
        } finally {
            if (jarFile != null) {
                jarFile.close();
            }
        }

        return classes;
    }

では、実行してみます。
まずは展開されたクラスファイルに対して走査する場合。

$ java -cp ../java/classes:. ClassFinder root
class root.sub.SubPackageClass1$InnerClass1
class root.sub.SubPackageClass1
class root.sub.SubPackageClass2
class root.RootPackageClass1
class root.RootPackageClass2

続いて、JARファイル内のクラスを走査する場合。

$ java -cp ../java/classes.jar:. ClassFinder root
class root.sub.SubPackageClass1$InnerClass1
class root.sub.SubPackageClass1
class root.sub.SubPackageClass2
class root.RootPackageClass1
class root.RootPackageClass2

ちゃんとインナークラスも取得できています。

もうひとつパッケージを落としても、実行可能です。

$ java -cp ../java/classes.jar:. ClassFinder root.sub
class root.sub.SubPackageClass1$InnerClass1
class root.sub.SubPackageClass1
class root.sub.SubPackageClass2

Seasar2のコードとかでは、その他Zipファイルからクラスをロードした場合とかWARファイルからクラスをロードした場合とかを考慮しているようです。とはいえ、基本はこんなんできっと大丈夫…でしょう。

一応、今回作成したソース全体を載せておきます。

import java.io.File;
import java.net.JarURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

public class ClassFinder {
    private ClassLoader classLoader;

    public static void main(String[] args) throws Exception {
        ClassFinder classFinder = new ClassFinder();
        // classFinder.printClasses(args[0]);

        for (Class<?> clazz : classFinder.findClasses(args[0])) {
            System.out.println(clazz);
        }
    }

    public ClassFinder() {
        classLoader = Thread.currentThread().getContextClassLoader();
    }

    public ClassFinder(ClassLoader classLoader) {
        this.classLoader = classLoader;
    }

    public void printClasses(String rootPackageName) throws Exception {
        String resourceName = rootPackageName.replace('.', '/');
        URL url = classLoader.getResource(resourceName);
        System.out.println("URL = " + url);
        System.out.println("URLConnection = " + url.openConnection());
    }

    private String fileNameToClassName(String name) {
        return name.substring(0, name.length() - ".class".length());
    }

    private String resourceNameToClassName(String resourceName) {
        return fileNameToClassName(resourceName).replace('/', '.');
    }

    private boolean isClassFile(String fileName) {
        return fileName.endsWith(".class");
    }

    private String packageNameToResourceName(String packageName) {
        return packageName.replace('.', '/');
    }

    public List<Class<?>> findClasses(String rootPackageName) throws Exception {
        String resourceName = packageNameToResourceName(rootPackageName);
        URL url = classLoader.getResource(resourceName);

        if (url == null) {
            return new ArrayList<Class<?>>();
        }

        String protocol = url.getProtocol();
        if ("file".equals(protocol)) {
            return findClassesWithFile(rootPackageName, new File(url.getFile()));
        } else if ("jar".equals(protocol)) {
            return findClassesWithJarFile(rootPackageName, url);
        }

        throw new IllegalArgumentException("Unsupported Class Load Protodol[" + protocol + "]");
    }

    private List<Class<?>> findClassesWithFile(String packageName, File dir) throws Exception {
        List<Class<?>> classes = new ArrayList<Class<?>>();

        for (String path : dir.list()) {
            File entry = new File(dir, path);
            if (entry.isFile() && isClassFile(entry.getName())) {
                classes.add(classLoader.loadClass(packageName + "." + fileNameToClassName(entry.getName())));
            } else if (entry.isDirectory()) {
                classes.addAll(findClassesWithFile(packageName + "." + entry.getName(), entry));
            }
        }

        return classes;
    }

    private List<Class<?>> findClassesWithJarFile(String rootPackageName, URL jarFileUrl) throws Exception {
        List<Class<?>> classes = new ArrayList<Class<?>>();

        JarURLConnection jarUrlConnection = (JarURLConnection)jarFileUrl.openConnection();
        JarFile jarFile = null;

        try {
            jarFile = jarUrlConnection.getJarFile();
            Enumeration<JarEntry> jarEnum = jarFile.entries();

            String packageNameAsResourceName = packageNameToResourceName(rootPackageName);

            while (jarEnum.hasMoreElements()) {
                JarEntry jarEntry = jarEnum.nextElement();
                if (jarEntry.getName().startsWith(packageNameAsResourceName) && isClassFile(jarEntry.getName())) {
                    classes.add(classLoader.loadClass(resourceNameToClassName(jarEntry.getName())));
                }
            }
        } finally {
            if (jarFile != null) {
                jarFile.close();
            }
        }

        return classes;
    }
}

Scalaで特定のパッケージ配下のクラスを検索する

先の日記は、同様の趣旨のプログラムをJavaで書いたのですが、今度はScalaで書いてみました。

パッケージの取得と配下のクラスファイルの走査方法自体は前回で書いているので、今回は割愛。とりあえず、結果のソースを載せてみます。

なお、動作的にScala版がJava版と異なるところは、サポートしていないプロトコルやファイルフォーマットからクラスがロードされていた場合、例外ではなくNilを返却するところですかね。

import scala.collection.JavaConverters._
import scala.collection.mutable.ListBuffer

import java.io.File
import java.net.{JarURLConnection, URL}
import java.util.jar.{JarEntry, JarFile}

object ClassFinder {
  def main(args: Array[String]): Unit =
    new ClassFinder().findClasses(args(0)).foreach(println)
}

class ClassFinder(private val classLoader: ClassLoader) {
  def this() = this(Thread.currentThread.getContextClassLoader)

  private def pathToClassName(path: String): String = path.substring(0, path.size - ".class".size)

  private def isClassFile(entry: JarEntry): Boolean = isClassFile(entry.getName)
  private def isClassFile(file: File): Boolean = file.isFile && isClassFile(file.getName)
  private def isClassFile(filePath: String): Boolean = filePath.endsWith(".class")

  private def resourceNameToClassName(resourceName: String): String =
    pathToClassName(resourceNameToPackageName(resourceName))
  private def resourceNameToPackageName(resourceName: String): String =
    resourceName.replace('/', '.')
  private def packageNameToResourceName(packageName: String): String =
    packageName.replace('.', '/')

  private val finderFunction: PartialFunction[URL, String => List[Class[_]]] =
    findClassesWithFile orElse
    findClassesWithJarFile orElse
    findClassesWithNone

  def findClasses(rootPackageName: String): List[Class[_]] = {
    val resourceName = packageNameToResourceName(rootPackageName)
    classLoader.getResource(resourceName) match {
      case null => Nil
      case url => finderFunction(url)(rootPackageName)
    }
  }

  def findClassesWithFile: PartialFunction[URL, String => List[Class[_]]] = {
    case url if url.getProtocol == "file" =>
      val classes = new ListBuffer[Class[_]]

      def findClassesWithFileInner(packageName: String, dir: File): List[Class[_]] = {
        dir.list.foreach { path =>
          new File(dir, path) match {
            case file if isClassFile(file) =>
              classes += classLoader.loadClass(packageName + "." + pathToClassName(file.getName))
            case directory if directory.isDirectory =>
              findClassesWithFileInner(packageName + "." + directory.getName, directory)
            case _ =>
          }
        }

        classes toList
      }

      findClassesWithFileInner(_: String, new File(url.getFile))
  }

  def findClassesWithJarFile: PartialFunction[URL, String => List[Class[_]]] = {
    case url if url.getProtocol == "jar" =>
      def manageJar[T](jarFile: JarFile)(body: JarFile => T): T = try {
        body(jarFile)
      } finally {
        jarFile.close()
      }

      def findClassesWithJarFileInner(packageName: String): List[Class[_]] =
        url openConnection match {
          case jarURLConnection: JarURLConnection =>
            manageJar(jarURLConnection.getJarFile) { jarFile =>
              jarFile.entries.asScala.toList.collect {
                case jarEntry if resourceNameToPackageName(jarEntry.getName).startsWith(packageName) &&
                                  isClassFile(jarEntry) =>
                                    classLoader.loadClass(resourceNameToClassName(jarEntry.getName))
              }
            }
        }

      findClassesWithJarFileInner
  }

  def findClassesWithNone: PartialFunction[URL, String => List[Class[_]]] = {
    case _ => packageName => Nil
  }
}

今回のテーマは、部分関数の関数合成です。クラスを検索するメソッドとして、3つ用意しました。
ファイルシステムから検索するメソッド

  def findClassesWithFile: PartialFunction[URL, String => List[Class[_]]]

JARファイルから検索するメソッド

  def findClassesWithJarFile: PartialFunction[URL, String => List[Class[_]]]

何もせず、Nilを返すメソッド

  def findClassesWithNone: PartialFunction[URL, String => List[Class[_]]] = {
    case _ => packageName => Nil
  }

これらのメソッドを、PartialFunction#orElseで合成します。

  private val finderFunction: PartialFunction[URL, String => List[Class[_]]] =
    findClassesWithFile orElse
    findClassesWithJarFile orElse
    findClassesWithNone

まあ、PartialFunctionに与えている型パラメータが

PartialFunction[URL, String => List[Class[_]]]

なので、PartialFunction#applyの結果、さらに関数が返ってくるところがちょっと注意ですね。こういうの、かえって読みにくくなるだけかなぁ…?

あとは、このフィールドに対してapplyメソッドを呼び出し、その結果返ってきた関数に対して検索処理を指示します。

  def findClasses(rootPackageName: String): List[Class[_]] = {
    val resourceName = packageNameToResourceName(rootPackageName)
    classLoader.getResource(resourceName) match {
      case null => Nil
      case url => finderFunction(url)(rootPackageName)
    }
  }

この部分ですね。

      case url => finderFunction(url)(rootPackageName)

ファイルシステムやJARファイル以外に増えても、メソッドを増やしてfinderFunctionに合成すれば大丈夫!最後にNilを返す関数を合成しているのは、ちょっと面倒くさがりだからですかね…。

最近はNettyの写経ばっかりしていたので、Scalaの関数型オブジェクトを使ったプログラミングが楽しいです♪

もちろん、Java版と同じように動作します。
展開されたクラスファイルに対して走査する場合。

$ scala -cp ../java/classes ClassFinder root.sub
class root.sub.SubPackageClass1$InnerClass1
class root.sub.SubPackageClass1
class root.sub.SubPackageClass2

続いて、JARファイル内のクラスを走査する場合。

$ scala -cp ../java/classes.jar ClassFinder root.sub
class root.sub.SubPackageClass1$InnerClass1
class root.sub.SubPackageClass1
class root.sub.SubPackageClass2