"工欲善其事,必先利其器" ——但有时候,你连工具箱都丢了。
最近,我遇到了一个略显尴尬的场景:需要紧急修改一段老 Java 项目代码,但手头的电脑只装了 JDK,没有安装任何 Java IDE(比如 IntelliJ IDEA 或 Eclipse)。更糟的是,这个项目没有现成的构建脚本(Maven/Gradle),甚至连一个简单的 build.xml 都没有。
怎么办?难道要花半小时下载安装 IDE?还是手动敲 javac 和 jar 命令?
不!我决定用 Go 写一个轻量级的构建工具——既解决燃眉之急,也顺便重温一下 Java 编译和打包的底层知识。
于是,就有了这个不到 200 行的 Go 程序:一个能自动编译 .java 源码、处理依赖库、生成可执行 JAR 的命令行工具。
🛠️ 工具功能一览
这个工具名为 buildjar,用法极其简单:
buildjar <src_dir> <lib_dir> <main_class> <output_jar>
例如:
buildjar src _lib Test Test.jar
它会自动完成以下五步:
- 扫描源码目录,生成 sources.txt(供 javac @sources.txt 使用)
- 编译所有 Java 文件 到 bin/ 目录,自动包含 lib_dir 下的所有 .jar 依赖
- 复制依赖库 到 <output_jar>_lib/(如 Test.jar_lib/)
- 生成符合规范的 MANIFEST.MF,包含 Main-Class 和自动换行的 Class-Path
- 打包成可执行 JAR,并提示用户运行时需保持 JAR 与依赖库同目录
整个过程全自动、零配置,专为"裸机环境"设计。
🔍 技术细节剖析
1. 为什么用 sources.txt?
Java 编译器 javac 支持通过 @filename 读取文件列表,避免命令行参数过长(尤其在 Windows 上有 8191 字符限制)。
我们递归遍历 src_dir,把所有 .java 文件路径写入 sources.txt,并用 GBK 编码保存——因为某些老项目源码文件名或路径含中文,而 Windows 的 javac 默认用系统编码(通常是 GBK),UTF-8 反而会报错。
gbkWriter := transform.NewWriter(file, simplifiedchinese.GBK.NewEncoder())
💡 小知识:javac 在 Windows 上对路径编码非常敏感,这是很多"中文路径编译失败"的根源。
2. 依赖管理:复制而非嵌入
为了保持简单和兼容性,没有把依赖 JAR 打进主 JAR(即不使用 jar -uf 合并),而是采用经典的"JAR + 同级 lib 目录"模式。
- 主 JAR 的 MANIFEST.MF 中指定 Class-Path: . Test.jar_lib/a.jar Test.jar_lib/b.jar ...
- 运行时只需确保 Test.jar 和 Test.jar_lib/ 在同一目录即可
这种方式兼容性极佳,连 Java 1.2 都能跑,且避免了"JAR-in-JAR"加载类的复杂问题。
3. MANIFEST.MF 的自动换行规则
Java 的 MANIFEST.MF 有严格格式要求:
- 每行最多 72 个字符(包括换行前的空格)
- 续行必须以单个空格开头
我们的 writeWrappedLine 函数精准实现了这一规则:
// 第一行:0 ~ 71 字符
// 后续行:以空格开头,再接最多 71 个字符
例如:
Class-Path: . very-long-lib1.jar very-long-lib2.jar very-long-lib3.jar ve
ry-long-lib4.jar
⚠️ 重要提醒:如果违反此规则,java -jar 会直接报错:Invalid or corrupt jarfile。
4. 跨平台路径处理
虽然工具主要面向 Windows(因 GBK 编码需求),但我们也做了兼容:
- Class-Path 中统一使用 正斜杠 /(Java 规范要求,即使 Windows 也认)
- filepath.Join 自动适配系统路径分隔符,但写入 MANIFEST 时强制替换为 /
relPath = strings.ReplaceAll(relPath, "\\", "/")
🧪 使用示例
假设项目结构如下:
my-project/
├── src/
│ └── Test.java
├── _lib/
│ ├── gson-2.8.9.jar
│ └── commons-lang3-3.12.0.jar
└── buildjar.exe # 我们的 Go 工具
执行:
buildjar src _lib Test Test.jar
输出:
✅ 生成 sources.txt 完成
✅ 编译完成
✅ 复制依赖库到 Test.jar_lib
✅ 生成 MANIFEST.MF 完成
🎉 成功生成可执行 JAR: Test.jar
📌 请确保运行时 Test.jar 与 Test.jar_lib 在同一目录下
最终目录:
my-project/
├── Test.jar
├── Test.jar_lib/
│ ├── gson-2.8.9.jar
│ └── commons-lang3-3.12.0.jar
└── ...
运行:
java -jar Test.jar
完美!
💡 为什么不用 Maven/Gradle?
- 环境限制:目标机器可能无网络、无法安装构建工具
- 项目太老:有些遗留系统连 Ant 都没用,纯手工编译
- 快速验证:临时修改,不想引入复杂构建体系
这个工具的哲学是:最小依赖,最大兼容,开箱即用。
📦 源码与扩展
完整源码:
package main
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
func main() {
if len(os.Args) != 5 {
fmt.Println("Usage: buildjar <src_dir> <lib_dir> <main_class> <output_jar>")
fmt.Println("Example: buildjar src _lib Test Test.jar")
os.Exit(1)
}
srcDir := os.Args[1]
libDir := os.Args[2]
mainClass := os.Args[3]
outputJar := os.Args[4]
// Step 0: 创建/清空 bin 目录
os.RemoveAll("bin")
os.MkdirAll("bin", 0755)
// Step 1: 生成 sources.txt
sourcesFile := "sources.txt"
err := generateSourcesList(srcDir, sourcesFile)
if err != nil {
fmt.Printf("❌ 生成 sources.txt 失败: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ 生成 sources.txt 完成")
// Step 2: 编译 Java 文件
err = compileJava(sourcesFile, libDir)
if err != nil {
fmt.Printf("❌ 编译失败: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ 编译完成")
// Step 3: 复制 _lib 到 outputJar_lib
libTargetDir := outputJar[:len(outputJar)-4] + "_lib"
err = copyDir(libDir, libTargetDir)
if err != nil {
fmt.Printf("❌ 复制 %s → %s 失败: %v\n", libDir, libTargetDir, err)
os.Exit(1)
}
fmt.Printf("✅ 复制依赖库到 %s\n", libTargetDir)
// Step 4: 生成 MANIFEST.MF(使用相对路径)
err = generateManifest(libTargetDir, mainClass)
if err != nil {
fmt.Printf("❌ 生成 MANIFEST.MF 失败: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ 生成 MANIFEST.MF 完成")
// Step 5: 打包 JAR
err = createJar(outputJar)
if err != nil {
fmt.Printf("❌ 打包 JAR 失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("🎉 成功生成可执行 JAR: %s\n", outputJar)
fmt.Printf("📌 请确保运行时 %s 与 %s 在同一目录下\n", outputJar, libTargetDir)
}
// 生成 sources.txt,递归列出所有 .java 文件
func generateSourcesList(srcDir, outputFile string) error {
// 创建文件
file, err := os.Create(outputFile)
if err != nil {
return err
}
defer file.Close()
// 创建 GBK 编码的 Writer
gbkWriter := transform.NewWriter(file, simplifiedchinese.GBK.NewEncoder())
writer := bufio.NewWriter(gbkWriter)
defer writer.Flush()
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".java") {
_, err := writer.WriteString(path + "\r\n") // Windows 换行用 \r\n 更稳妥
return err
}
return nil
})
}
// 编译 Java 文件
func compileJava(sourcesFile, libDir string) error {
// 构建 classpath: 当前目录 + 所有 lib/*.jar
classpath := "."
jars, err := filepath.Glob(filepath.Join(libDir, "*.jar"))
if err != nil {
return err
}
for _, jar := range jars {
classpath += string(os.PathListSeparator) + jar
}
cmd := exec.Command("javac", "-d", "bin", "-cp", classpath, "@"+sourcesFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// 生成 MANIFEST.MF 文件,自动换行,每行最多72字符(含空格)
func generateManifest(libDir, mainClass string) error {
manifestFile := "MANIFEST.MF"
file, err := os.Create(manifestFile)
if err != nil {
return err
}
defer file.Close()
writer := bufio.NewWriter(file)
defer writer.Flush()
// 写入 Manifest-Version 和 Main-Class
fmt.Fprintln(writer, "Manifest-Version: 1.0")
fmt.Fprintf(writer, "Main-Class: %s\n", mainClass)
// 收集所有 jar 文件名(使用相对于 JAR 文件的路径)
jars, err := filepath.Glob(filepath.Join(libDir, "*.jar"))
if err != nil {
return err
}
// 构建 Class-Path 行(使用相对路径)
var classPath string = "Class-Path: ."
for _, jar := range jars {
// 获取 jar 文件名,拼接为:outputJar_lib/filename.jar
relPath := filepath.Join(filepath.Base(libDir), filepath.Base(jar))
// 统一使用正斜杠(兼容所有系统)
relPath = strings.ReplaceAll(relPath, "\\", "/")
classPath += " " + relPath
}
// 按 72 字符限制写入,支持多行
writeWrappedLine(writer, classPath, 72)
// 最后必须有一个空行
fmt.Fprintln(writer, "")
return writer.Flush()
}
// 写入带自动换行的行(用于 MANIFEST.MF 的 Class-Path)
func writeWrappedLine(writer *bufio.Writer, line string, maxLen int) {
if len(line) <= maxLen {
fmt.Fprintln(writer, line)
return
}
// 写第一行(0 ~ maxLen-1)
fmt.Fprintln(writer, line[:maxLen])
// 剩余部分,每行 maxLen-1 个字符(因为开头要加一个空格,占1位)
rest := line[maxLen:]
for len(rest) > 0 {
if len(rest) <= maxLen-1 {
fmt.Fprintln(writer, " "+rest)
break
}
fmt.Fprintln(writer, " "+rest[:maxLen-1])
rest = rest[maxLen-1:]
}
}
// 打包 JAR
func createJar(outputJar string) error {
cmd := exec.Command("jar", "cfm", outputJar, "MANIFEST.MF", "-C", "bin", ".")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// 递归复制目录
func copyDir(src, dst string) error {
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
err = os.MkdirAll(dst, srcInfo.Mode())
if err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
err = copyDir(srcPath, dstPath)
if err != nil {
return err
}
} else {
err = copyFile(srcPath, dstPath)
if err != nil {
return err
}
}
}
return nil
}
// 复制单个文件
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
你还可以扩展一下:
- 编译成 Windows/Linux/macOS 二进制,随身携带
- 添加 -v 参数支持 verbose 模式
- 支持指定 JDK 路径(当前依赖系统 PATH 中的 javac/jar)
- 增加清理缓存、增量编译等功能
✨ 结语:程序员的“瑞士军刀”精神
在这个 IDE 和框架高度自动化的时代,我们很容易忘记底层发生了什么。但当环境受限、工具缺失时,理解编译、链接、打包的本质,才能真正掌控代码。
用 Go 写一个 Java 构建工具,看似"跨界",实则是对两种语言生态的融会贯通。它不仅是应急方案,更是一次对"构建系统"本质的致敬。
真正的自由,不是拥有最强大的工具,而是在任何环境下都能创造工具。