用 Go 手搓一个 Java 构建工具:当 IDE 不在身边时的自救指南

32 阅读6分钟

"工欲善其事,必先利其器" ——但有时候,你连工具箱都丢了。

最近,我遇到了一个略显尴尬的场景:需要紧急修改一段老 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

它会自动完成以下五步:

  1. 扫描源码目录,生成 sources.txt(供 javac @sources.txt 使用)
  2. 编译所有 Java 文件 到 bin/ 目录,自动包含 lib_dir 下的所有 .jar 依赖
  3. 复制依赖库 到 <output_jar>_lib/(如 Test.jar_lib/)
  4. 生成符合规范的 MANIFEST.MF,包含 Main-Class 和自动换行的 Class-Path
  5. 打包成可执行 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 构建工具,看似"跨界",实则是对两种语言生态的融会贯通。它不仅是应急方案,更是一次对"构建系统"本质的致敬。

真正的自由,不是拥有最强大的工具,而是在任何环境下都能创造工具。