Java 通过命令编译源码并构建可执行 jar 包
一般情况下我们会使用构建工具帮助我生成 jar 包,但工具是建立在底层命令的基础之上的,只会用工具而不知道底层实现原理,通常会让我们心里没底。本文主要记录如何使用 Java 自带的命令(javac、java、jar)来实现项目的编译、运行和打包。
一、概述
在本文中,我们将讨论以下内容:
- 使用 javac 命令编译项目用到的所有 java 文件
- 使用 java 命令运行项目主函数
- 使用 jar 命令创建可执行 jar 包
- 使用 jar 命令解压 jar 包
- 使用 javac 命令编译项目中所有 java 文件,无论该文件是否被引用
二、项目结构
作为演示,我们项目的结构如下:
demo-project
├── src
│ └── org
│ └── example
│ ├── app
│ │ └── App.java
│ └── service
│ ├── ByeService.java
│ ├── HelloService.java
│ └── impl
│ └── HelloServiceImpl.java
└── target
├── classes # 保存生成的 class 文件
├── demo-project.jar # 保存生成的 jar 包
└── unpackaged # jar 包的解压目录
注意:我们的源代码中一共有 ByeService 和 HelloService 两个接口,但只提供了 HelloService 的实现(HelloServiceImpl)。这是有意为之,后面演示会进行说明。
三、源代码
org/example/app/App.java:
package org.example.app;
import org.example.service.HelloService;
import org.example.service.impl.HelloServiceImpl;
public class App {
public static void main(String[] args) {
HelloService h = new HelloServiceImpl();
h.hello();
}
}
org/example/service/ByService.java:
package org.example.service;
public interface ByeService {
void bye();
}
org/example/service/HelloService:
package org.example.service;
public interface HelloService {
void hello();
}
org/example/service/impl/HelloServiceImpl:
package org.example.service.impl;
import org.example.service.HelloService;
public class HelloServiceImpl implements HelloService {
@Override
public void hello() {
System.out.println("HelloServiceImpl -> HelloService.hello()");
}
}
注意:以上代码中我们没有引用 org.example.service.ByeService 这个类。
四、编译项目
首先确保我们创建了第一节中所描述的目录结构,主要是确保 target/classes 目录存在,因为我们会将该目录作为输出目录。
然后进入 src 目录,使用以下命令进行编译:
# 备注:
# 1) 当前所在目录为 src 目录
# 2) -d 用于指导输出目录
# 3) ../target/classes 目录必须存在才能编译成功
javac org/example/app/App.java -d ../target/classes
以上命令会编译 App.java 以及所有被 App.java 引用(import)的文件,由于 ByeService 没有被引用,所以 ByeService.java 文件没有被编译。
以下为编译之后的文件树形结构:
target
└─ classes
└─ org
└─ example
├─ app
│ └─ App.class
└─ service
├─ HelloService.class
└─ impl
└─ HelloServiceImpl.class
五、运行程序
编译完成后,进入 target/classes 目录,然后执行以下命令以运行程序:
# 指定入口程序的全类名
java org.example.app.App
输出结果为:
HelloServiceImpl -> HelloService.hello()
六、创建可执行 jar 包
使用 jar 命令,我们可以把生成的 class 文件打包成一个 jar 包。首先我们需要进入 target/classes 目录,然后输入以下命令即可进行打包:
# 备注:
# 1) c 代表创建归档文件(即表明:此操作为打包而不是解压)
# 2) v 代表打印日志信息
# 3) f 代表指定输出文件名
# 4) ../demo-project.jar 即目标文件名
# 5) * 表示打包当前路径下的所有文件
jar cvf ../demo-project.jar *
通过上面的命令创建的不是一个这里所说的可执行 jar 包,但我们可以通过以下命令运行 main 方法:
# 备注:
# 1) -cp 代表指定 classpath,此处指定为生成的 jar 包
# 2) org.example.app.App 表示主程序入口
java -cp demo-project.jar org.example.app.App
上面的方式虽然能够执行 jar 包,但步骤稍显麻烦,而且需要我们记住主程序的全类名,比如这里的 org.example.app.App 这一长串。
下面我们要介绍的打包方式可以在打包时记录主程序的全类名,这样在我们执行这个 jar 包的时候,就不用输入全类名了。首先进入 target/classes 目录,然后执行以下命令:
# 备注:
# 1) 此处添加了 e 选项
# 2) 此处指定了主程序全类名 org.example.app.App
jar cvfe ../demo-project.jar org.example.app.App *
打包完成后,进入 target 目录,使用以下命令即可运行 jar 包:
java -jar demo-project.jar
下一节我们将讨论如何解压 jar 包,并说明 jar 包是如何记录主程序全类名的。
七、解压 jar 包
jar 包其实也是一种压缩包格式,我们可以使用 jar 命令解压 jar 包。以上面生成的 demo-project.jar 为例,首先我们进入 target/unpackaged 目录,确保 target/demo-project.jar 文件存在,然后执行以下命令:
# 备注:
# 1) x 代表解压
# 2) v 表示打印日志
# 3) f 指定要解压的文件名
# 4) 当前目录为 unpackaged,解压结果会保存到当前目录
jar xvf ../demo-project.jar
解压完成后,unpackaged 的目录结构如下:
unpackaged
├── META-INF
│ └── MANIFEST.MF
└── org
└── example
├── app
│ └── App.class
└── service
├── HelloService.class
└── impl
└── HelloServiceImpl.class
可以看到,解压后的文件夹中除了我们的 class 文件外,还多了一个 META-INF 文件夹,该文件夹中的文件就是用来保存当前 jar 包的相关信息的。其中 META-INF/MANIFEST.MF 的文件内容如下:
Manifest-Version: 1.0
Created-By: 1.8.0_191 (Oracle Corporation)
Main-Class: org.example.app.App
可以看到,该文件使用 Main-Class 字段保存了主程序的全类名。
八、编译所有源文件
前面我们已经说过,由于我们的程序没有引用 ByeService.java 这个文件,所以我们在编译 App.java 的时候,编译器并不会去编译 ByeService.java 这个文件。但有些时候,我们需要编译源码目录下的所有文件,不管这些文件是否被引用。
要做到这一点,我们需手动告诉编译器我们需要编译哪些文件,比如下面这样:
# 备注:运行时确保我们处于 src 目录下
javac org/example/app/App.java org/example/service/ByeService.java -d ../target/classes
在上面的命令中,我们明确指示编译器要对 org/example/service/ByeService.java 这个文件进行编译。命令完成后,我们可以发现 org/example/service/ByeService.class 已经编译完成。
如果我们的项目很大,里面有无数的 .java 源文件,我们也不能清楚的记得哪些文件被引用了,而哪些文件没有被引用。这个时候,如果要求我们在编译时指定没有被引用的文件,就稍显麻烦了。所以最保险的办法是让编译器编译所有的源文件。也就是说,我们需要在 javac 命令中指定所有 .java 文件的路径。不过由于这个操作太反人类了,我们可以使用以下的 shell 脚本来帮我们完成:
#!/bin/bash
# 备注:
# 请将这个文件保存在项目根目录下(与 src 目录同级)
# 并命名为 build.sh
# 获取脚本文件所在目录
readonly __DIR__=$(cd $(dirname $0) && pwd)
# 源码目录
readonly SRC_DIR="${__DIR__}/src"
# 目标目录
readonly TARGET_DIR="${__DIR__}/target"
# class 文件目录
readonly CLASSES_DIR="${TARGET_DIR}/classes"
# 创建一个临时文件路径
readonly TEMP_FILE=$(mktemp)
# 打印提示信息
echo
echo " SRC_DIR: ${SRC_DIR}"
echo " TARGET_DIR: ${TARGET_DIR}"
echo "CLASSES_DIR: ${CLASSES_DIR}"
echo " TEMP_FILE: ${TEMP_FILE}"
echo
# 如果源码目录不存在,则退出程序
if [ ! -d "${SRC_DIR}" ]
then
echo "ERROR: ${SRC_DIR} is not a directory."
echo
exit 1
fi
# 查找所有 .java 文件,并将文件名保存至临时文件
# 1. 首先进入源码目录
cd $SRC_DIR
# 2. 找到所有以 .java 结尾的文件名,并判断该文件是不是普通文件(排除目录)
for filename in $(find . -name "*.java")
do
# 如果是普通文件,则将文件名保存至临时文件
if [ -f "${filename}" ]
then
echo $filename >> $TEMP_FILE
fi
done
# 将文件名列表保存至变量
readonly FILE_LIST=$(cat $TEMP_FILE)
# 如果文件列表为空,则退出程序
if [ "${FILE_LIST}" == "" ]
then
echo "ERROR: There is no java file in ${SRC_DIR} directory."
echo
exit 1
fi
# 开始编译
# 1. 首先删除原有文件
rm -rf $CLASSES_DIR
# 2. 创建一个空目录
mkdir -p $CLASSES_DIR
# 3. 编译(此处可以根据实际需要添加其他参数)
javac $FILE_LIST -d $CLASSES_DIR
# 打印已经编译的文件名列表
echo "Compiled file list:"
echo
cat $TEMP_FILE
echo
# 删除临时文件
rm -rf $TEMP_FILE
# 编译完成,退出程序
exit
将以上文件内容保存至项目根目录下,并命名为 build.sh,然后执行以下命令给该文件添加可执行权限:
chmod +x build.sh
然后执行 build.sh 文件即可编译所有源文件:
./build.sh
本文完。