管理 Java 类路径(UNIX 和 Mac OS X)

在 UNIX 和 Mac OS X 中管理类路径的技巧

类路径是 Java™ 平台中最复杂也最令人头痛的部分之一,但熟练掌握类路径对成为一名专业 Java 程序员来说却又十分关键。在本文中,Elliotte Rusty Harold 为您阐述了类路径和源路径的复杂性,并向您展示了如何在 UNIX 和 Mac OS X 中熟练掌握它们。如果您使用的是 Windows,请参阅本文的 姊妹篇

Elliotte Harold, 副教授, Polytechnic 大学

Elliot Rusty Harold 的照片Elliotte Rusty Harold 来自新奥尔良,现在他还定期回老家喝一碗美味的秋葵汤。不过目前,他和妻子 Beth 定居在纽约临近布鲁克林的 Prospect Heights,同住的还有他的猫咪 Charm(取自夸克)和 Marjorie(取自他岳母的名字)。他是 Polytechnic 大学计算机科学的副教授,他在该校讲授 Java 和面向对象编程。他的 Web 站点 Cafe au Lait 已经成为 Internet 上最流行的独立 Java 站点之一,它的姊妹站点 Cafe con Leche 已经成为最流行的 XML 站点之一。 他最近编著的一本书是 Java I/O, 2nd edition。他目前在从事处理 XML 的 XOM API、Jaxen XPath 引擎和 Jester 测试覆盖率工具的开发工作。



2007 年 1 月 04 日

类路径可以连接 Java 运行库和文件系统。它定义编译器和解释器应该在何处查找要加载的 .class 文件。它的基本思想是:文件系统的层次结构反映了 Java 包的层次结构,而类路径则定义了文件系统中的哪个目录可以作为 Java 包层次结构的根。

遗憾的是,通常文件系统非常复杂并依赖于平台,而且和 Java 包也不能很好地匹配。这样一来,不论是新用户还是资深 Java 程序员都深感类路径的棘手。没错,它的确不是 Java 平台好的一面,它让您到了下班的时候还在忙于调试一个顽固的小问题。

当然采用 Eclipse 这样的优秀 IDE 可以减少管理类路径的一些困难,但只能说是一些,而且前提还必须是一切都正常(但这不大可能,因为总会有一些意外出现)。因此,每个 Java 程序员都必须要全面了解类路径,惟有如此,才有希望调试类路径中所出现的问题。

在本文中,我给出了您所需要了解的有关 UNIX、Linux 和 Mac OS X 中的 Java 类路径(以及相关源路径)的全部内容。本文的 姊妹篇 则展示了 Windows 上的类似技术。文中列出的步骤可以作为指南,并能解决出现的大多数问题。

包结构

要掌握类路径,首先应从其源代码入手。每个类都属于一个包,而此包必须 遵守标准的命名约定。简单地说,包的名称要由颠倒的两级域名开始,比如 com.exampleedu.poly,之后是至少一个或多个单词用于描述包的内容。比方说,假设有一个域名为 elharo.com,如果要创建一个 Fraction 类,可以将其放入如下包中:

  • com.elharo.math
  • com.elharo.numbers
  • com.elharo.math.algebra.fields

在颠倒的域名之后,需要使用单一单词的子包名。不要使用缩写形式,并要保证拼写正确。如果需要,可以使用拼写检查器。大部分与类路径相关的问题都是由在源代码中使用某个单词而在文件系统中使用的却是与之稍有不同的拼写或缩写而引起的。所以最好的做法就是总是使用拼写正确且没有缩写的名称。

整个包名称应该是小写的,即使该名称是在别处常采取大写形式的一些惯用名称和缩写词。Windows 通常不区分文件名中的大小写,但 Java 和一些 UNIX 文件系统却区分。如果需要在不同的系统间移动文件,大小写问题肯定会带来一些麻烦。包名称必须要全部由 ASCII 字符组成。一些编译器也接受用 Hebrew、Cyrillic、Greek 或其他脚本编写的包名称,但大多数文件系统并不接受;您稍后就会看到,这样的包名称必须担负充当目录名这样的双重任务。Java 包和类名是 Unicode,但很多文件系统(包括 FAT)却不能识别 Unicode。遗憾的是,FAT 系统非常之多。如果只简单地用不同的默认编码将文件复制到系统将会使编译器和解释器无法找到正确的类。

用完即弃的代码

如果您在编写一个只使用一次就丢掉的类 —— 比如测试一个 API 的类 —— 则无需将它放到包中。但需要多次使用的类必须要放到包中。

不要试图在包名称方面节约成本。长远来看,这只会有百害而无一利。如果需要域名就买一个。如果名称太长就买个短些的(我曾经买到了 xom.nu 这样一个域名,因而我的包前缀就只有 6 个字符)。不要将类放到默认包中(默认包是指如果未在类中包含一条包语句时系统默认给出的包)。如果包访问不利于对象间的通信,就需要向类中添加更多的公共方法。需要多次使用的类必须要放到包中。

目录结构

下一步要做的是组织源文件来匹配包结构。在某处创建一个干净的空白目录。本文中,我将其命名为 project。在这个目录里,再创建两个目录:bin 和 src。(有些人更喜欢将其分别命名为 build 和 source。)

接下来,在 src 目录,建一个与包层次结构相匹配的层次结构。例如,如果给定类名为 com.elharo.math.Fraction,我会将 com 目录 放到 src 目录中,然后在 com 目录中创建一个 elharo 目录,再在 elharo 目录内放一个 math 目录,最后在 math 目录内放上 Fraction.java,如图 1 所示:

图 1. 目录结构符合包结构
目录结构符合包结构

要点:不要在 src 目录中放置除源代码之外的任何内容。通常这里放入的文件都是 .java 文件。在有些情况下,也可放置 .html 文件(用于 JavaDoc)或其他类型的源代码。然而,决不能在此结构内放置 .class 文件或任何其他编译并生成的工件。这样做只会带来麻烦。遗憾的是,如果不够谨慎,javac 编译器就会 “明知故犯”。在下一节,将介绍如何修复这一问题。


编译

编译 Java 代码需要一些技巧,原因是必须要跟踪如下几方面相关但又有所不同的内容:

  • 正在编译的目标文件。
  • 编译器在其中寻找目标文件导入 .java 文件的那个目录。
  • 编译器在其中寻找目标文件导入 .class 文件的那个目录。
  • 编译器在其中放置编译输出的目录。

默认地,javac 编译器将上述目录都认为是当前目录,而这并不是您所希望的。因此,需要在编译时显式地指定这些元素。

要编译的文件

指定的第一个要编译的文件是 .java 文件,以从当前目录到该文件的整个路径的形式给出。比如,假设当前所在目录是 图 1 所示的 project 目录。该目录包含 src 目录。此 src 目录包含 com 目录,而 com 目录又包含 example 目录,example 目录下是 Fraction.java 文件。如下命令行对它进行编译:

$ javac src/com/elharo/math/Fraction.java

如果路径不正确,就会给出这样的错误消息:

error: cannot read: src/com/example/mtah/Fraction.java

如果出现这样的错误消息,就需要检查路径的各个部分,确保它们拼写正确。然后再通过一个与下面类似的 ls 检查该文件是否处于它应该出现的位置:

$ ls src/com/example/math
ls: src/com/example/math: No such file or directory

出现问题的原因通常是因为路径拼写错误,但也可能是由于当前的目录不对。在本例中,需要检查当前的工作目录是不是 project 目录。pwd 命令在这里非常有用。例如,以下命令将告诉我,我实际上处于 project/src 而不是 project 目录中:

$ pwd
/Users/elharo/documents/articles/classpath/project/src

在编译之前,我需要执行 cd ..

输出到哪里

假设没有出现任何语法错误,javac 将编译后的 .class 文件放到与之对应的.java 文件所在的相同目录内。这并不是您所想要的结果。将 .class 和 .java 文件混在一起常常会使清理编译后的文件十分困难,因为很可能会意外删除本应保留的 .java 文件。这常会使清理构建十分困难,而且还会导致版本问题。发布一个二进制时,只对编译后的 .class 文件进行归档也会十分困难。因此,需要告知编译器将编译后的输出放到一个完全不同的目录内。-d 开关用来指定输出目录(通常称为 bin、build 或 class):

$ javac -d bin src/com/elharo/math/Fraction.java

现在输出如图 2.所示,注意 javac 已建立了完整的目录层次结构 com/elharo/math。不需要再手动建立。

图 2. 并行源和编译后的层次结构
并行源和编译后的层次结构

源路径

源路径 就是 Java 在其中寻找源文件的那个目录。具体到本例,就是 src 目录。该目录必须包含源文件的层次结构,这些源文件可以被放到它们自己的目录中。因此它不是 com 目录 也不是 src/com/elharo/math 目录。

很多项目都使用不止一个类和包。它们通过导入语句和完整的包限定类名连接起来。例如,假设您在 com.elharo.gui 包里面创建一个新的 MainFrame 类 如清单 1 所示:

清单 1. 一个包中的类可以导入另一个包中的类
package com.elharo.gui;

import com.elharo.math.*;

public class MainFrame {

  public static void main(String[] args) {
    Fraction f = new Fraction();
    // ...
  }

}

该类使用的是与 MainFrame 类所在的包不同的包中的 com.elharo.math.Fraction 类。源设置现在应该如图 3 所示(我将编译后的输出从之前的步骤中删除了。但这没有关系,因为我总是能重新编译它)。

图 3. 几个包的源结构
几个包的源结构

现在来看一下试着像以前一样编译 MainFrame.java 会出现什么情况。

清单 2. 编译 MainFrame.java
$ javac -d bin src/com/elharo/gui/MainFrame.java
src/com/elharo/gui/MainFrame.java:3: package com.elharo.math does not exist
import com.elharo.math.*;
^
src/com/elharo/gui/MainFrame.java:7: cannot find symbol
symbol  : class Fraction
location: class com.elharo.gui.MainFrame
  private Fraction f = new Fraction();
          ^
src/com/elharo/gui/MainFrame.java:7: cannot find symbol
symbol  : class Fraction
location: class com.elharo.gui.MainFrame
  private Fraction f = new Fraction();
                           ^
3 errors

出现清单 2 中的错误的原因是,虽然 javac 知道到何处可以找到 MainFrame.java,但它却并不知道到何处可以找到 Fraction.java(您可能觉得它应该具备足够的智能来识别匹配的层次结构,但事实并非如此)。为了给它提供一些线索,必须指定源路径。用源路径指定编译器应该到哪些目录查找源文件的层次结构。在清单 2 中,源路径是 src。所以我使用了 -sourcepath 选项,如下所示:

$ javac -d bin -sourcepath src src/com/elharo/gui/MainFrame.java

现在再编译程序,就不会出现错误,并会产生如图 5 所示的输出。请注意 javac 也编译了文件 Fraction.java,Fraction.java 被当前编译的文件引用。

图 4. 多类输出
多类输出

在源路径中编译多个目录

在源路径中可以有多个目录,使用冒号分隔各目录,但通常没有必要这么做。例如,若我想包括本地的 src 目录和用来存放另一个项目的源代码的 /Users/elharo/Projects/XOM/src 目录,我可以这样进行编译:

$ javac -d bin -sourcepath src:/Users/elharo/Projects/XOM/src
  src/com/elharo/gui/MainFrame.java

该命令并不编译在这两个层次结构中所找到的每个文件。它只编译由单个的 .java 文件直接或间接引用的文件,而此 .java 文件必须被编译。

更常见的情况是,为 .java 文件用一个单一的源目录,为类或放置了预编译的第三方库的 JAR 归档文件用多个目录。而这正是类路径的作用所在。

设置类路径

在大中型项目中,每次都要对每个文件进行重编译会非常耗时。为减少这种编译负担,可以在不同的 bin 目录分别编译和存储相同项目的独立部分。这些目录被添加到类路径。

将类添加到类路径有几种方法可选。但您只能使用 -classpath 命令行开关。例如,假设我想从另一个之前已经编译到目录 /Users/elharo/classes 的工程导入文件,那么我会向命令行添加 -classpath /Users/elharo/classes,如下所示:

$ javac -d bin -sourcepath src -classpath /Users/elharo/classes
  src/com/elharo/gui/MainFrame.java

现在假设需要添加两个目录,/Users/elharo/project1/classes 和 /Users/elharo/project2/classes。那么我将包含它们并使用冒号将它们分隔开,如下所示:

$ javac -d bin -sourcepath src
  -classpath /Users/elharo/project1/classes:/Users/elharo/project2/classes
  src/com/elharo/gui/MainFrame.java

顶级目录

请注意这里所说的顶级目录是指所有包含包的层次结构的顶级目录,包括 com/elharo/foo/bar 或 nu/xom/util 的层次结构。具有匹配包名称(com、elharo、math 等)的目录绝不会直接包括在源路径或类路径中。

当然,您也可以使用自己喜欢的各种相对路径的格式。比如,如果 project1 和 project2 是当前工作目录的同级目录(即它们有相同的父目录),那么我会这样引用它们:

$ javac -d bin -sourcepath src
  -classpath ../project1/classes:../project2/classes
  src/com/elharo/gui/MainFrame.java

到目前为止,我都一直假设程序完全独立并且没有使用任何单独的编译后的第三方库。如果需要使用第三方库,还必须将它们也添加到类路径。库通常是 JAR 文件的格式,比如 junit.jar 或 icu4j.jar。在本例中,需要向类路径添加的只是 JAR 文件本身,而不是包含 JAR 文件的目录(从实质上讲,JAR 文件可以充当包含编译后的 .class 文件的一种目录)。例如,如下命令会向类路径添加三项内容:/Users/elharo/classes 目录,当前工作目录里的 icu4j.jar 文件和 /Users/elharo/lib 下的 junit.jar 文件:

$ javac -d bin -sourcepath src
  -classpath /Users/elharo/classes:icu4j.jar:/Users/elharo/lib/junit.jar
  src/com/elharo/gui/MainFrame.java

JAR 文件仅用于 .class 文件和其类路径,不用于 .java 文件及其源路径。


运行程序

现在您已经成功地编译了程序,可以运行它了。运行与编译相似但更为简单一些。当运行程序时,只需指定两项内容:

  • 类路径。
  • 包含 main() 方法的类的完全限定包名。

无需指定源路径。

通常这里的类路径与编译程序所使用的类路径相同,只是多了一个放置编译后的输出的目录。例如,如果编译命令如下所示:

$ javac -d bin -sourcepath src
  -classpath /Users/elharo/classes:/Users/elharo/lib/junit.jar
  src/com/elharo/gui/MainFrame.java

并且 main() 方法在 com.elharo.gui.Mainframe.java 类内,就可以像这样运行此程序:

$ java
  -classpath /Users/elharo/classes:/Users/elharo/lib/junit.jar
   com.elharo.gui.MainFrame

请务必注意命令行的最后一项是类名。它不是一个文件名,也不是 .java 或 .class。该类必须能够在类路径的某处找到。


可能存在类的其他地方

我强烈建议您在编译和运行时总是显式地指定类路径。也可以将文件放到其他地方,以便它们可以被添加到类路径中,并被 javac 编译器和 java 解释器找到。这种做法会节省一些键入操作,但当(注意不是如果)您无意间将一个旧版本的类放到类路径中时,这却会耗费大量的调试时间。

在本节,将展示类常常隐匿其中的几个地点,这些类很可能会出乎意料地冒到类路径中并导致问题的出现。在不受您控制的机器上(比如服务器),这更为多见。

当前的工作目录

编译器总是将当前工作目录 (.) 添加到类路径,而不管您是否曾显式地要求这样做。您很容易忘记在和您所在的目录相同的目录中有和没有的内容。因此,请尽量避免将任何类或层次结构放入 project 或 home 目录。相反地,应该将 .java 文件和 .class 文件分别放入 src 目录和 bin 目录。

CLASSPATH

过一会,您就会发现向类路径手工添加 bin 目录和 JAR 归档文件太过繁琐。这时您可能会想要使用 CLASSPATH 环境变量。可以只向 CLASSPATH 环境变量添加一次目录和 JAR 归档文件,之后就不需要在每次运行 javac 或 java 时都要再键入这些路径。

请务必抵制这种诱惑。这样做,一旦加载了错误的类或错误版本的类,就会出问题。而且意外加载错误的类所带来的调试时间常常会百倍于省下的那点键入时间。要避免输入并自动处理类路径有更好的方法。

jre/lib/ext

jre/lib/ext 目录中的 JAR 归档文件会被添加到通过虚拟机运行的所有应用程序的类路径。这看起来很方便,实际上它与向 CLASSPATH 环境变量添加目录一样,存在长远的潜在问题。您迟早(通常很快)会在您想都想不到的地方加载类的一个错误版本的类并会为此付出大量的调试时间。

部署一个服务器端的应用程序时,问题就更为严峻。请确保部署到的服务器在其 jre/lib/ext 目录没有任何额外的 JAR。如果您不熟悉错误症状,也不知道该如何查找,那么由类路径中的错误版本的 JAV 归档文件所带来的问题可能会非常难于调试。为了避免这些问题的出现,一些框架甚至编写了自己的类加载器,用来绕过 Java 代码通常的类加载机制。

jre/lib/endorsed

jre/lib/endorsed 目录里的 JAR 文件 也被添加到了通过虚拟机运行的所有应用程序的类路径。不同的是,这里的文件被实际放入了 bootclasspath 而不是通常的类路径,并可以代替 JDK 附带的标准类。这种方式对于在 VM 更新 XML 解析器和修复 bug 尤其有用。

但是,如前所述,这种方法看起来十分方便,但实际上也存在长期的潜在问题,原因也一样。如果需要替换 JDK 类,可以在运行时使用 -Xbootclasspath/p 选项来避免意外地加载错误版本的类。

$ java -classpath /Users/elharo/classes
       -Xbootclasspath/p:xercesImpl.jar com.elharo.gui.MainFrame

自动管理类路径

在想要使用电动射钉枪之前要先熟练使用锤子,与此相似,在试图采用更强大的自动管理工具之前也要先能自如地手动管理这些类。如果您掌握了命令行工具集,就可以使用另外的工具来自动处理源路径和类路径所需的一些繁琐过程。这些工具大部分也需要您像本文所介绍的那样组织文件。

IDE

像 Eclipse 和 NetBeansMost 这样的许多开发环境都能协助类路径的自动管理。例如,当更改包的名称时,Eclipse 能相应地移动对应的 .java 文件,如图 5 所示:

图 5. 在 Eclipse 中快速修复类路径
移动 ‘Element.java’ 到 ‘nu.fox’ 包

请记住,这些 IDE 位于文件系统的顶部,必须正确设置,尤其是当需要与其他工具和其他 IDE 集成时就更应如此。这些工具最大的贡献是用 GUI 对话框、树视图和选项卡代替了命令行开关参数,但其基本的文件结构还是一样的。

Ant

Ant 是自动化构建过程的事实上的标准工具。与将目录放在 jre/lib/ext 或 CLASSPATH 环境变量的做法不同,Ant 真的可以让您创建单步的构建过程。但您仍然需要在 Ant build.xml 设置类路径并手动将源文件放到正确的目录。但至少现在您无需在每次编译都要重新进行指定。

Maven

Maven 在组织和自动化构建过程方面比 Ant 还要更进一步。Maven 提供一个合理的默认设置让您可以通过添加少许几行代码并将源文件放到 Maven 能够找到的位置即可构建简单的项目。您仍然需要调整文件系统和包的层次结构。Maven 在管理第三方库的依赖性方面也有上佳的表现,虽然它不如 Ant 那么易于定制。


结束语

不管类路径有多么棘手,您都可以通过一些简单的规则对它加以管制,尤其是要记住如下的一些原则:

  • 将类放到包中。
  • 严格遵守包和类的命名约定和大小写约定。
  • 确保包的层次结构与目录的层次结构匹配。
  • 总是对 javac 应用 -d 选项。
  • 不要在 jre/lib/ext 内放任何东西。
  • 不要在 jre/lib/endorsed 内放任何东西。
  • 不要将 .java 文件与 .class 文件放在同一个目录。
  • 不要将任何 .java 或 .class 文件放在当前的工作目录。

最后一点提示:很多耗时的类路径问题的起因大都是目录名拼写错误或从错误目录进行了编译。如果您不能找到问题的所在,可以问问周围的朋友或同事。以我的经验,自己发现自己的错误总是困难的,但这些错误在别人看来却显而易见。所以寻求他人的帮助也是一种切实有效的调试技巧。

类路径确实不是个简单的问题,但总会有相应的应对方法,所以它是完全可管理的。些许的谨慎加上对本文所介绍的命名约定、命令行参数和目录结构的注意,应该能够使您在问题最少的情况下编译和运行程序了。

参考资料

学习

获得产品和技术

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology, AIX and UNIX, Linux
ArticleID=186977
ArticleTitle=管理 Java 类路径(UNIX 和 Mac OS X)
publish-date=01042007