ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

【DIY系列:Java虚拟机】第08篇:DirEntry 与 ZipEntry——目录和 JAR 包的读取实现

【DIY系列:Java虚拟机】第08篇:DirEntry 与 ZipEntry——目录和 JAR 包的读取实现 上一篇【第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/ioutilpath/filepath)// DirEntry 表示目录形式的类路径typeDirEntrystruct{absDirstring// 目录的绝对路径}只有一个字段absDir存放目录的绝对路径。重点为什么必须存绝对路径因为 JVM 运行时的当前工作目录可能和你想象的不一样比如从 IDE 启动时、从脚本启动时。存绝对路径可以避免相对路径带来的歧义——不管程序从哪儿启动都能找到正确的目录。构造函数Go 没有专门的构造函数语法本书统一约定用new开头的函数来创建结构体实例。// newDirEntry 创建 DirEntry 实例funcnewDirEntry(pathstring)*DirEntry{absDir,err:filepath.Abs(path)// 转换成绝对路径iferr!nil{panic(err)// 转换失败直接终止程序}returnDirEntry{absDir}}filepath.Abs()的作用输入相对路径当前工作目录输出绝对路径.D:\projects\jvmgoD:\projects\jvmgoclassesD:\projects\jvmgoD:\projects\jvmgo\classes../libD:\projects\jvmgoD:\projects\libD:\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}三步走拼路径filepath.Join(absDir, className)读文件ioutil.ReadFile(fileName)返回结果数据 self error为什么要用filepath.Join而不是字符串拼接// ❌ 不推荐手动拼接跨平台会出问题fileName:self.absDir/className// Linux OKWindows 也凑合fileName:self.absDir\\className// Windows OKLinux 挂了// ✅ 推荐filepath.Join 自动处理平台差异fileName:filepath.Join(self.absDir,className)filepath.Join会自动使用当前系统的路径分隔符Windows\Linux/Mac/还能处理多余的分隔符和./..调用结果Linux/Mac结果WindowsJoin(D:\\classes, java/lang/Object.class)—D:\classes\java\lang\Object.classJoin(/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/ioutilpath/filepath)typeDirEntrystruct{absDirstring}funcnewDirEntry(pathstring)*DirEntry{absDir,err:filepath.Abs(path)iferr!nil{panic(err)}returnDirEntry{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从压缩包里掏出 classZipEntry 表示JAR 或 ZIP 文件形式的类路径比如-cp lib\a.jar或者 JDK 的rt.jar。比 DirEntry 复杂一点因为要多做一步解压。结构体与构造// ch02/classpath/entry_zip.gopackageclasspathimport(archive/ziperrorsio/ioutilpath/filepath)// ZipEntry 表示 ZIP/JAR 文件形式的类路径typeZipEntrystruct{absPathstring// ZIP/JAR 文件的绝对路径}funcnewZipEntry(pathstring)*ZipEntry{absPath,err:filepath.Abs(path)iferr!nil{panic(err)}returnZipEntry{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.NameclassName{// 找到了打开这个文件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 标准库用到的三个核心 APIAPI作用对应到现实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。deferGo 的资源管理利器代码里出现了两次deferr,err:zip.OpenReader(self.absPath)iferr!nil{...}deferr.Close()// ← 注册函数返回前执行 r.Close()defer的作用是把语句推迟到当前函数返回前执行。不管函数是正常 return、还是中途 panic注册的 defer 语句都会执行。这解决了 C/C/Java 里经典的资源泄漏问题// Java得写 try-finally 或 try-with-resourcesZipFilezipFilenull;try{zipFilenewZipFile(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}returnZipEntry{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.jar、rt.jar字段absDir stringabsPath string查找方式直接拼路径读文件打开压缩包遍历文件列表时间复杂度O(1)文件系统直接定位O(n)遍历条目n 为包内文件数依赖标准库path/filepath、ioutilarchive/zip、path/filepath、errors资源管理无需ReadFile 自动关需要defer关闭两处本篇小结本篇实现了 Entry 接口的两个叶子节点DirEntry目录形式字段absDir存绝对路径用filepath.Abs()转换readClass()用filepath.Join()拼路径跨平台ioutil.ReadFile()读文件简洁到只有三行核心代码ZipEntryJAR/ZIP 形式字段absPath存压缩包绝对路径readClass()用zip.OpenReader打开包遍历r.File找匹配项f.Open()ioutil.ReadAll()读内容用defer确保资源关闭Go 的优雅设计ZIP 内部文件名用/分隔可以直接和 className 比对性能可优化用 Map 缓存 保持句柄打开entry_zip2.go学到的 Go 技巧filepath.Abs/Join跨平台路径处理archive/zip标准库解压缩defer函数返回前必定执行的资源管理下一篇实现两个组合节点CompositeEntry处理分号/冒号分隔的多路径和WildcardEntry处理lib/*通配符。其中 CompositeEntry 的类型定义有个 Go 语言的小巧思值得一看。上一篇【第07篇】Entry 接口设计——类路径的积木块下一篇【第09篇】CompositeEntry 与 WildcardEntry——组合与通配符的魔法
返回列表