欢迎光临
我们一直在努力

【DIY系列:Java虚拟机】第07篇:Entry 接口设计——类路径的“积木块“

上一篇【第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)

这是路径分隔符,跨平台的关键:

操作系统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 接口:只有两个方法——readClass(className) ([]byte, Entry, error) 负责读取,String() 负责字符串表示
  • 参数格式:class 名用斜线分隔、带 .class 后缀(java/lang/Object.class),这是 JVM 内部统一表示法
  • newEntry 工厂:按"含分隔符 → 含 * → jar/zip 后缀 → 兜底目录"的优先级链创建四种实现
  • 四种实现:DirEntry(目录)、ZipEntry(jar/zip)、CompositeEntry(多路径组合)、WildcardEntry(通配符)
  • Go 的隐式接口:实现方法即实现接口,无需 implements,后续解析常量池时会大量受益
  • 下一篇,我们实现最简单也最基础的两个 Entry:DirEntry 和 ZipEntry。它们负责真正把 class 文件从磁盘(或压缩包)里读出来。


    上一篇【第06篇】类路径(classpath)是什么鬼——JVM 怎么找到你的 class 文件 下一篇【第08篇】DirEntry 与 ZipEntry——目录和 JAR 包的读取实现


    赞(0)
    未经允许不得转载:171主机测评 » 【DIY系列:Java虚拟机】第07篇:Entry 接口设计——类路径的“积木块“
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址