上一篇【第06篇】类路径(classpath)是什么鬼——JVM 怎么找到你的 class 文件 下一篇【第08篇】DirEntry 与 ZipEntry——目录和 JAR 包的读取实现
摘要
类路径看起来是一坨复杂的东西——可能是目录、可能是 jar 包、可能是 lib/* 这种通配符、还可能是用分号串起来的一长串。
怎么把这么乱的东西统一抽象?答案是组合模式(Composite Pattern):把类路径想象成一堆可以嵌套的"积木块",不管它是单个目录还是一长串路径组合,对外都暴露同一个接口。
本文设计并实现这个接口——Go 语言里的 Entry 接口。你会看到:接口的两个方法如何设计、newEntry() 工厂函数如何根据路径特征"智能"创建对应实现、四种 Entry 实现各自的职责,以及 Go 接口"隐式实现"这个特性在这里用得有多爽。
一、组合模式:把类路径想象成"俄罗斯套娃"
先理解设计思路。回到上一篇文章的结论:类路径可以是这些形式:
# 形式 1:单个目录
-cp D:\\classes
# 形式 2:单个 jar 包
-cp lib\\a.jar
# 形式 3:通配符(目录下所有 jar)
-cp lib\\*
# 形式 4:多个路径用分隔符串起来(每个又可以是上面任意一种!)
-cp "D:\\classes;lib\\a.jar;lib\\*;lib\\b.zip"
看出规律了吗?形式 4 是前三种的"组合"——它里面每一项又可以是目录、jar、或通配符。
这就是典型的树形结构:
【类路径的树形结构(组合模式)】
类路径: "D:\\classes;lib\\*;lib\\b.zip"
│
▼
┌─────────────────────────────────────────┐
│ CompositeEntry (组合节点) │
│ 负责:把路径按分隔符切开,逐个委派 │
└───┬──────────────┬──────────────────┬───┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────────┐ ┌─────────┐
│DirEntry │ │WildcardEntry │ │ZipEntry │
│(叶子节点)│ │ (组合节点) │ │(叶子节点)│
│ │ └───┬──────┬───┘ │ lib\\ │
│D:\\classes│ │ │ │ b.zip │
└─────────┘ ▼ ▼ └─────────┘
┌───────┐ ┌───────┐
│ZipEntry│ │ZipEntry│
│a.jar │ │c.jar │
└───────┘ └───────┘
组合模式的精髓:不管是"组合节点"(CompositeEntry、WildcardEntry)还是"叶子节点"(DirEntry、ZipEntry),对外都实现同一个接口。调用方不需要关心自己拿到的是单个目录还是一长串组合,统一调用 readClass() 就行。
【组合模式的好处】
客户端代码
│
│ entry.readClass("java/lang/Object.class")
│
▼
┌───────────────────────────────┐
│ Entry 接口 │ ← 客户端只认识接口
└───────────────────────────────┘
▲ ▲ ▲
│ │ │
DirEntry CompositeEntry ZipEntry ← 具体实现对客户端透明
❌ 不用组合模式:
if 是目录 { … } else if 是jar { … } else if 是组合 { 递归判断… }
客户端代码里塞满 if-else,加一种新类型就得改所有地方
✅ 用组合模式:
entry.readClass(name) // 一行搞定,管你是什么类型
重点:组合模式让"单个对象"和"组合对象"具有一致的接口。这是设计可扩展系统的关键手法——以后要加一种新类型(比如从网络 URL 加载类),只需要新增一个 Entry 实现,现有代码一行都不用改。
二、Entry 接口的两个方法
接口设计得极其精简——只有两个方法:
// ch02/classpath/entry.go
package classpath
import "os"
import "strings"
const pathListSeparator = string(os.PathListSeparator)
// Entry 接口:表示类路径中的一项
type Entry interface {
// readClass 寻找并读取 class 文件
readClass(className string) ([]byte, Entry, error)
// String 返回该项的字符串表示(相当于 Java 的 toString)
String() string
}
方法 1:readClass()
readClass(className string) ([]byte, Entry, error)
逐个拆解:
| 参数 className | class 文件的相对路径,用斜线 / 分隔,带 .class 后缀 |
| 返回值 1 []byte | 读到的 class 文件字节数据 |
| 返回值 2 Entry | 最终定位到该文件的 Entry(便于调试:知道这个类是从哪个 jar/目录来的) |
| 返回值 3 error | 错误信息。找不到就返回 error |
参数格式举例:
要加载的类 传给 readClass 的参数
─────────────────────────────────────────────────
java.lang.Object → java/lang/Object.class
com.example.Hello → com/example/Hello.class
HelloWorld → HelloWorld.class
java.lang.String[] → java/lang/String[].class (数组类,第8章处理)
重点:为什么参数用斜线 / 而不是反斜杠 \\?因为这是 JVM 内部的统一表示法,跟操作系统无关。这样在 Windows 上也能统一处理,避免了平台差异。转换工作(/ → \\)由各个 Entry 实现内部去适配。
为什么要返回第二个 Entry?
这个设计很巧妙。想象一下调试场景:
data, entry, err := cp.ReadClass("java/lang/Object")
fmt.Printf("java.lang.Object 加载自:%v\\n", entry)
// 输出:java.lang.Object 加载自:C:\\Java\\jdk1.8.0_202\\jre\\lib\\rt.jar
一眼就能看出这个类是从 rt.jar 还是从你的 classes 目录加载的。排查"类加载冲突"(同一个类被不同 jar 里的版本覆盖)时,这个信息非常有用。
方法 2:String()
String() string
相当于 Java 的 toString()。Go 里只要实现了 String() string 方法,用 %v、%s 格式化输出时就会自动调用它。
fmt.Printf("%v\\n", entry) // 自动调用 entry.String()
fmt.Println(entry) // 同样自动调用
pathListSeparator 常量
const pathListSeparator = string(os.PathListSeparator)
这是路径分隔符,跨平台的关键:
| Windows | ';' | ; |
| Linux / macOS | ':' | : |
用 os.PathListSeparator 而不是硬编码 ";",代码就能自动适配不同系统。
三、newEntry() 工厂函数
有了接口,还需要一个"工厂"来根据路径字符串创建对应的实现。这就是 newEntry():
// newEntry 根据路径特征创建对应的 Entry 实现
func newEntry(path string) Entry {
// 1. 包含路径分隔符 → 组合入口
if strings.Contains(path, pathListSeparator) {
return newCompositeEntry(path)
}
// 2. 以 * 结尾 → 通配符入口
if strings.HasSuffix(path, "*") {
return newWildcardEntry(path)
}
// 3. 以 .jar/.JAR/.zip/.ZIP 结尾 → ZIP 入口
if strings.HasSuffix(path, ".jar") || strings.HasSuffix(path, ".JAR") ||
strings.HasSuffix(path, ".zip") || strings.HasSuffix(path, ".ZIP") {
return newZipEntry(path)
}
// 4. 其他情况 → 目录入口
return newDirEntry(path)
}
判断逻辑是个优先级递减的决策链:
【newEntry() 决策流程】
输入路径 path
│
▼
┌─────────────────────────────┐
│ 包含分隔符 ( ; 或 : )? │──是──► newCompositeEntry
└──────────┬──────────────────┘
│ 否
▼
┌─────────────────────────────┐
│ 以 "*" 结尾? │──是──► newWildcardEntry
└──────────┬──────────────────┘
│ 否
▼
┌─────────────────────────────┐
│ 以 .jar/.JAR/.zip/.ZIP 结尾?│──是──► newZipEntry
└──────────┬──────────────────┘
│ 否
▼
newDirEntry (兜底:当成目录处理)
为什么要先判断分隔符? 因为 -cp "a.jar;b.jar" 这种组合路径,虽然包含 .jar,但它整体是个组合,必须先拆开。顺序反了就会把整串当成一个 jar 文件名。
为什么目录是兜底选项? 因为目录路径没有明显的特征(既没有 * 也没有 .jar 后缀)。任何"看起来不像其他的"路径,就当目录处理——这和现实中的直觉一致。
举几个例子
| D:\\classes | 无分隔符、无 *、无 jar 后缀 | DirEntry |
| lib\\a.jar | 无分隔符、无 *、有 .jar | ZipEntry |
| lib\\* | 无分隔符、有 * | WildcardEntry |
| classes;lib\\a.jar | 有分隔符 | CompositeEntry |
| . | 都不满足 | DirEntry(当前目录) |
四、四种 Entry 实现的职责
Entry 接口有四个实现,各司其职:
【四种 Entry 实现】
┌────────────────────────────────────────────────────────────┐
│ Entry 接口 │
└────────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
│ │ │ │
┌─────────┐ ┌────────────┐ ┌──────────────┐ ┌────────────┐
│DirEntry │ │ ZipEntry │ │CompositeEntry│ │WildcardEntry│
├─────────┤ ├────────────┤ ├──────────────┤ ├────────────┤
│目录形式 │ │JAR/ZIP文件 │ │多路径组合 │ │通配符 lib/* │
│ │ │ │ │ │ │ │
│字段: │ │字段: │ │字段: │ │字段: │
│absDir │ │absPath │ │entrys []Entry│ │(内部复用 │
│ │ │ │ │ │ │Composite) │
├─────────┤ ├────────────┤ ├──────────────┤ ├────────────┤
│实现: │ │实现: │ │实现: │ │实现: │
│拼接路径 │ │打开zip │ │遍历子Entry │ │扫描目录 │
│读文件 │ │查找文件 │ │逐个委派 │ │找所有jar │
│ │ │解压读取 │ │第一个成功即用│ │再组合 │
└─────────┘ └────────────┘ └──────────────┘ └────────────┘
叶子节点 叶子节点 组合节点 组合节点
| DirEntry | D:\\classes、. | 拼接绝对路径,直接 os.ReadFile | 叶子 |
| ZipEntry | lib\\a.jar、x.zip | 打开 zip 文件,在其中查找并解压 | 叶子 |
| CompositeEntry | a;b;c | 按分隔符拆分,逐个委派,取第一个成功 | 组合 |
| WildcardEntry | lib\\* | 扫描目录下所有 .jar,构造成 CompositeEntry | 组合 |
后面三篇文章会逐个实现它们。这里先记住整体分工。
五、Go 接口的"隐式实现"有多爽
最后聊个 Go 语言的设计亮点:接口是隐式实现的。
在 Java 里,实现一个接口必须写 implements:
// Java:必须显式声明
public class DirEntry implements Entry {
@Override
public byte[] readClass(String className) { ... }
}
在 Go 里,不需要任何声明——只要结构体实现了接口的所有方法,它就自动是那个接口的实例:
// Go:不需要 implements,实现了方法就算
type DirEntry struct {
absDir string
}
func (self *DirEntry) readClass(className string) ([]byte, Entry, error) {
// …
}
func (self *DirEntry) String() string {
return self.absDir
}
// 上面两个方法一写,*DirEntry 就自动实现了 Entry 接口!
var e Entry = &DirEntry{absDir: "D:\\\\classes"} // ✅ 编译通过
重点:这就是所谓的 duck typing(鸭子类型)——“如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子”。Go 编译器在编译期检查方法集,不需要运行时的 instanceof。
这个特性在解析 class 文件常量池时会发挥到极致——14 种常量各写一个结构体,各自实现 readInfo() 方法,然后统一放进 []ConstantInfo 里管理,完全不需要继承体系。
完整代码:entry.go
把前面的内容整合起来,ch02/classpath/entry.go 的完整实现:
package classpath
import (
"os"
"strings"
)
// pathListSeparator 存放路径分隔符
// Windows 下是 ';',Linux/Mac 下是 ':'
const pathListSeparator = string(os.PathListSeparator)
// Entry 表示类路径中的一项
type Entry interface {
// readClass 寻找并读取 class 文件
// 参数 className 是相对路径,用斜线分隔,带 .class 后缀
// 返回值:字节数据、找到该文件的 Entry、错误信息
readClass(className string) ([]byte, Entry, error)
// String 返回该项的字符串表示
String() string
}
// newEntry 根据路径创建不同类型的 Entry 实例
func newEntry(path string) Entry {
// 包含分隔符 → 组合入口
if strings.Contains(path, pathListSeparator) {
return newCompositeEntry(path)
}
// 以 * 结尾 → 通配符入口
if strings.HasSuffix(path, "*") {
return newWildcardEntry(path)
}
// jar 或 zip 文件 → ZIP 入口(注意大小写)
if strings.HasSuffix(path, ".jar") || strings.HasSuffix(path, ".JAR") ||
strings.HasSuffix(path, ".zip") || strings.HasSuffix(path, ".ZIP") {
return newZipEntry(path)
}
// 其他情况 → 目录入口(兜底)
return newDirEntry(path)
}
注意 readClass 和 newEntry 都是小写开头——在 Go 里这表示它们只在 classpath 包内可见。对外只暴露大写的 Entry 接口(以及后面会实现的 Classpath)。这是 Go 的封装惯例:最小化暴露的 API 表面。
本篇小结
本文完成了类路径的抽象设计:
下一篇,我们实现最简单也最基础的两个 Entry:DirEntry 和 ZipEntry。它们负责真正把 class 文件从磁盘(或压缩包)里读出来。
上一篇【第06篇】类路径(classpath)是什么鬼——JVM 怎么找到你的 class 文件 下一篇【第08篇】DirEntry 与 ZipEntry——目录和 JAR 包的读取实现

