【DIY系列:Java虚拟机】第08篇:DirEntry 与 ZipEntry——目录和 JAR 包的读取实现
2026/9/9 4:36:31 网站建设 项目流程

上一篇【第07篇】Entry 接口设计——类路径的"积木块"
下一篇【第09篇】CompositeEntry 与 WildcardEntry——组合与通配符的魔法


摘要

上一篇设计好了 Entry 接口,这一篇实现两个"叶子节点":DirEntry(读目录)和ZipEntry(读 JAR/ZIP 包)。

这两个是最基础的实现——真正的"干活的"。DirEntry 简单到只有几行代码,ZipEntry 稍微复杂些,要从压缩包里把 class 文件掏出来。本文会带你掌握 Go 标准库的几个关键用法:filepath.Abs(转绝对路径)、filepath.Join(跨平台路径拼接)、ioutil.ReadFile(读文件)、archive/zip(解压)、以及 Go 特有的defer资源管理机制。

写完这篇,我们的 JVM 就能从磁盘目录和 rt.jar 里读取 class 文件了。


一、DirEntry:最简单的一种

DirEntry 表示目录形式的类路径,比如-cp D:\classes或默认的.(当前目录)。

结构体设计

// ch02/classpath/entry_dir.gopackageclasspathimport("io/ioutil""path/filepath")// DirEntry 表示目录形式的类路径typeDirEntrystruct{absDirstring// 目录的绝对路径}

只有一个字段absDir,存放目录的绝对路径

重点:为什么必须存绝对路径?因为 JVM 运行时的当前工作目录可能和你想象的不一样(比如从 IDE 启动时、从脚本启动时)。存绝对路径可以避免相对路径带来的歧义——不管程序从哪儿启动,都能找到正确的目录。

构造函数

Go 没有专门的构造函数语法,本书统一约定:用new开头的函数来创建结构体实例。

// newDirEntry 创建 DirEntry 实例funcnewDirEntry(pathstring)*DirEntry{absDir,err:=filepath.Abs(path)// 转换成绝对路径iferr!=nil{panic(err)// 转换失败直接终止程序}return&DirEntry{absDir}}

filepath.Abs()的作用:

输入(相对路径)当前工作目录输出(绝对路径)
.D:\projects\jvmgoD:\projects\jvmgo
classesD:\projects\jvmgoD:\projects\jvmgo\classes
../libD:\projects\jvmgoD:\projects\lib
D:\classes(已是绝对)任意D:\classes(原样返回)

如果转换失败(极少见,通常是权限问题),直接panic终止程序——类路径配置错了,后面的工作都没意义,快速失败比带着错误继续跑要好。

readClass():核心方法

func(self*DirEntry)readClass(classNamestring)([]byte,Entry,error){fileName:=filepath.Join(self.absDir,className)// 拼成完整路径data,err:=ioutil.ReadFile(fileName)// 读文件returndata,self,err}

三步走:

  1. 拼路径filepath.Join(absDir, className)
  2. 读文件ioutil.ReadFile(fileName)
  3. 返回结果:数据 + self + error

为什么要用filepath.Join而不是字符串拼接?

// ❌ 不推荐:手动拼接,跨平台会出问题fileName:=self.absDir+"/"+className// Linux OK,Windows 也凑合fileName:=self.absDir+"\\"+className// Windows OK,Linux 挂了// ✅ 推荐:filepath.Join 自动处理平台差异fileName:=filepath.Join(self.absDir,className)

filepath.Join会自动使用当前系统的路径分隔符(Windows\,Linux/Mac/),还能处理多余的分隔符和./..

调用结果(Linux/Mac)结果(Windows)
Join("D:\\classes", "java/lang/Object.class")D:\classes\java\lang\Object.class
Join("/usr/classes", "a/b.class")/usr/classes/a/b.class
Join("/usr/", "/a/")/usr/a

重点:className 里的斜线(如java/lang/Object.class)会被filepath.Join正确转换成当前系统的分隔符。这就是为什么 Entry 接口能统一用/表示路径。

一个有趣的细节ioutil.ReadFile在文件不存在时返回 error,我们直接把它返回给调用者。这样调用方(后面的 CompositeEntry)就能通过判断err == nil来决定是否继续查找下一个路径。

String() 方法

func(self*DirEntry)String()string{returnself.absDir}

简单到不用解释。实现了这个方法后,用fmt.Printf("%v", dirEntry)就会自动打印出目录路径。

DirEntry 完整代码

packageclasspathimport("io/ioutil""path/filepath")typeDirEntrystruct{absDirstring}funcnewDirEntry(pathstring)*DirEntry{absDir,err:=filepath.Abs(path)iferr!=nil{panic(err)}return&DirEntry{absDir}}func(self*DirEntry)readClass(classNamestring)([]byte,Entry,error){fileName:=filepath.Join(self.absDir,className)data,err:=ioutil.ReadFile(fileName)returndata,self,err}func(self*DirEntry)String()string{returnself.absDir}

提示:Go 1.16 之后,ioutil包被废弃,推荐用os.ReadFile代替(功能完全一样)。本书成书时用的是ioutil,读者可以按自己的 Go 版本选择。


二、ZipEntry:从压缩包里掏出 class

ZipEntry 表示JAR 或 ZIP 文件形式的类路径,比如-cp lib\a.jar或者 JDK 的rt.jar

比 DirEntry 复杂一点,因为要多做一步"解压"。

结构体与构造

// ch02/classpath/entry_zip.gopackageclasspathimport("archive/zip""errors""io/ioutil""path/filepath")// ZipEntry 表示 ZIP/JAR 文件形式的类路径typeZipEntrystruct{absPathstring// ZIP/JAR 文件的绝对路径}funcnewZipEntry(pathstring)*ZipEntry{absPath,err:=filepath.Abs(path)iferr!=nil{panic(err)}return&ZipEntry{absPath}}func(self*ZipEntry)String()string{returnself.absPath}

构造函数和 String() 跟 DirEntry 几乎一模一样,不多解释。

readClass():核心方法

func(self*ZipEntry)readClass(classNamestring)([]byte,Entry,error){// 1. 打开 ZIP 文件r,err:=zip.OpenReader(self.absPath)iferr!=nil{returnnil,nil,err}deferr.Close()// 函数返回前自动关闭// 2. 遍历压缩包里的文件,找目标 classfor_,f:=ranger.File{iff.Name==className{// 找到了,打开这个文件rc,err:=f.Open()iferr!=nil{returnnil,nil,err}deferrc.Close()// 同样自动关闭// 3. 读取内容data,err:=ioutil.ReadAll(rc)iferr!=nil{returnnil,nil,err}returndata,self,nil// 成功返回}}// 4. 遍历完都没找到returnnil,nil,errors.New("class not found: "+className)}

流程图:

【ZipEntry.readClass() 执行流程】 传入 className = "java/lang/Object.class" │ ▼ ┌─────────────────────────────┐ │ zip.OpenReader(absPath) │ 打开 JAR 文件 └──────────┬──────────────────┘ │ 失败 → 返回 error ▼ 成功,defer r.Close() ┌─────────────────────────────┐ │ 遍历 r.File 中的所有文件 │ │ ┌───────────────────────┐ │ │ │ f.Name == className ? │ │◄── 逐个比对文件名 │ └───────┬───────────────┘ │ │ 是 │ │ │ ▼ │ │ ┌───────────────────────┐ │ │ │ f.Open() 打开条目 │ │ │ │ ioutil.ReadAll(rc) │ │ 读内容 │ │ return data, self, nil│ │ ✅ 成功返回 │ └───────────────────────┘ │ └──────────┬──────────────────┘ │ 全部遍历完都没匹配 ▼ return nil, nil, errors.New("class not found: ...")

Go 的 archive/zip 标准库

用到的三个核心 API:

API作用对应到现实
zip.OpenReader(path)打开 ZIP 文件,返回*zip.ReadCloser打开压缩包
reader.File压缩包内所有文件的列表([]*zip.File查看文件清单
f.Open()打开某个具体文件,返回可读的io.ReadCloser打开压缩包里的某个文件

注意一个关键点:ZIP 内部的文件名一律用斜线/分隔,跟操作系统无关。所以我们可以直接拿classNamejava/lang/Object.class)去比对f.Name,不需要任何转换。

【JAR 包内部结构】 rt.jar ├── META-INF/ │ └── MANIFEST.MF ├── java/ │ ├── lang/ │ │ ├── Object.class ← f.Name = "java/lang/Object.class" │ │ ├── String.class ← f.Name = "java/lang/String.class" │ │ └── System.class │ ├── util/ │ │ ├── ArrayList.class │ │ └── HashMap.class │ └── io/ │ └── PrintStream.class └── ...

重点:JAR 本质上就是 ZIP —— 只是多了个META-INF/MANIFEST.MF清单文件,并约定了扩展名。所以处理 JAR 和 ZIP 的代码完全一样,这也是为什么 Entry 接口里 ZipEntry 同时处理.jar.zip

defer:Go 的资源管理利器

代码里出现了两次defer

r,err:=zip.OpenReader(self.absPath)iferr!=nil{...}deferr.Close()// ← 注册:函数返回前执行 r.Close()

defer的作用是:把语句推迟到当前函数返回前执行。不管函数是正常 return、还是中途 panic,注册的 defer 语句都会执行。

这解决了 C/C++/Java 里经典的资源泄漏问题:

// Java:得写 try-finally 或 try-with-resourcesZipFilezipFile=null;try{zipFile=newZipFile(path);// ... 操作}finally{if(zipFile!=null)zipFile.close();// 容易忘!}
// Go:一行 defer 搞定,绝不会忘r,_:=zip.OpenReader(path)deferr.Close()// ... 随便操作,函数返回时自动关闭

defer的三个特点:

特点说明
必定执行正常 return、panic 都会执行
后进先出多个 defer 按"栈"顺序执行(后注册的先执行)
参数立即求值defer f(x)中的x在注册时就算好了

踩坑提醒defer在函数级别生效,不是代码块级别。如果在循环里用 defer,它会在整个函数结束后才执行,可能导致资源积压。本书这里是单次调用,没问题。


三、ZipEntry 的性能问题与优化

书上专门提了一句:

readClass()方法每次都要打开和关闭 ZIP 文件,因此效率不是很高。

确实,每次读一个类都要:打开 jar(可能几十 MB)→ 遍历文件列表找 → 读取 → 关闭。加载 2 万个类就是 2 万次打开 jar,想想都慢。

优化思路

原书给了一个优化版本entry_zip2.go,核心思路是缓存

【ZipEntry 优化方案对比】 ❌ 朴素版(entry_zip.go) 每次 readClass: 打开 JAR → 遍历 → 查找 → 读取 → 关闭 时间复杂度:O(n) 每次都要遍历文件列表 rt.jar 有 2 万个条目,每次遍历 2 万次… ✅ 优化版(entry_zip2.go) 首次打开 JAR 时,建立 Map 缓存: map[className]*zip.File 之后每次 readClass: 直接从 Map 查(O(1))→ 打开 → 读取 JAR 文件句柄保持打开,不用反复开关

优化版的核心代码思路:

typeZipEntrystruct{absPathstringcachemap[string]*zip.File// 缓存:类名 → zip 文件条目reader*zip.ReadCloser// 保持打开的 JAR 句柄}funcnewZipEntry(pathstring)*ZipEntry{// 构造时一次性建立缓存r,_:=zip.OpenReader(absPath)cache:=make(map[string]*zip.File)for_,f:=ranger.File{cache[f.Name]=f}return&ZipEntry{absPath:absPath,cache:cache,reader:r}}func(self*ZipEntry)readClass(classNamestring)([]byte,Entry,error){f,ok:=self.cache[className]// O(1) 查找!if!ok{returnnil,nil,errors.New("class not found: "+className)}rc,err:=f.Open()// ... 读取}
方案首次后续查询内存占用实现复杂度
朴素版O(n) 遍历简单
缓存版慢(建立缓存)O(1) 查表高(缓存 + 常驻句柄)中等

建议:学习阶段用朴素版就够了,逻辑清晰。等整个 JVM 跑起来觉得慢了,再换成缓存版——过早优化是万恶之源,但这里的优化点很明确,改起来也就十几行。


四、两个实现的对比

对比项DirEntryZipEntry
对应路径目录(D:\classes.JAR/ZIP 文件(a.jarrt.jar
字段absDir stringabsPath string
查找方式直接拼路径读文件打开压缩包,遍历文件列表
时间复杂度O(1)(文件系统直接定位)O(n)(遍历条目,n 为包内文件数)
依赖标准库path/filepathioutilarchive/zippath/filepatherrors
资源管理无需(ReadFile 自动关)需要defer关闭两处

本篇小结

本篇实现了 Entry 接口的两个叶子节点:

  1. DirEntry(目录形式)

    • 字段absDir存绝对路径,用filepath.Abs()转换
    • readClass()filepath.Join()拼路径(跨平台),ioutil.ReadFile()读文件
    • 简洁到只有三行核心代码
  2. ZipEntry(JAR/ZIP 形式)

    • 字段absPath存压缩包绝对路径
    • readClass()zip.OpenReader打开包,遍历r.File找匹配项,f.Open()+ioutil.ReadAll()读内容
    • defer确保资源关闭(Go 的优雅设计)
    • ZIP 内部文件名用/分隔,可以直接和 className 比对
    • 性能可优化:用 Map 缓存 + 保持句柄打开(entry_zip2.go)
  3. 学到的 Go 技巧

    • filepath.Abs/Join:跨平台路径处理
    • archive/zip:标准库解压缩
    • defer:函数返回前必定执行的资源管理

下一篇实现两个"组合节点":CompositeEntry(处理分号/冒号分隔的多路径)和WildcardEntry(处理lib/*通配符)。其中 CompositeEntry 的类型定义有个 Go 语言的小巧思,值得一看。


上一篇【第07篇】Entry 接口设计——类路径的"积木块"
下一篇【第09篇】CompositeEntry 与 WildcardEntry——组合与通配符的魔法


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询