前言 最近扒了下docker里rootfs的压缩包, 发现里面除了普通文件, 目录以外还有软链接(Symbolic Link)以及硬链接(Hard Link)。此外, 在go的tar库里面, 我还发现了linux设备的文件类型, 比如fifo(命名管道)和char(字符设备文件)。本文来谈谈如何压缩和解压这些奇怪的文件类型。
软链接 软链接又叫符号链接, 它只是文件或文件夹的路径字符串, 即使对应的文件或文件夹不存在也没关系, 下面是一些演示:
1 2 3 4 5 6 7 8 9 10 11 12 13 $ ln -s /not/exists ./mylink $ ls -al drwxr-xr-x 2 markity markity 4096 May 28 10:09 ./ drwx------ 19 markity markity 4096 May 28 09:32 ../ lrwxrwxrwx 1 markity markity 11 May 28 10:09 mylink -> /not/exists $ cd mylink cd : '/home/markity/Desktop/mylink' is a broken symbolic link to '/not/exists' $ cat mylink cat : mylink: No such file or directory$ ln -s /usr/lib ./mylink2 $ cd mylink2 $ pwd /home/markity/Desktop/mylink2
符号链接本身是一个文件, 但是里面的内容标识了它指向的一个路径, 在linux中打开文件或目录的接口中都可以指定是否“跟随(follow)”符号链接。如果我们选择不跟随符号链接, 那么open符号链接就会报错返回ELOOP。
先创建一个软链接:
1 2 3 4 5 6 7 8 $ cat myfile some content in this file $ link -s myfile mylink $ ls -al drwxr-xr-x 2 markity markity 4096 May 28 11:55 ./ drwxr-xr-x 4 markity markity 4096 May 28 11:53 ../ -rw-r--r-- 1 markity markity 0 May 28 11:55 myfile lrwxrwxrwx 1 markity markity 6 May 28 11:55 mylink -> myfile
用下面的方法可以判断一个文件并不是符号链接, 如果是符号链接则告知, 不是则打印出文件的所有内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 package mainimport ( "fmt" "io" "os" "syscall" ) func main () { file, err := os.OpenFile("mylink" , os.O_RDONLY|syscall.O_NOFOLLOW, 0 ) if err != nil { if err.(*os.PathError).Unwrap() == syscall.ELOOP { fmt.Println("这是符号链接" ) return } panic (err) } bs, err := io.ReadAll(file) if err != nil { panic (err) } fmt.Println(string (bs)) }
既然我们现在能判断一个文件是否是软链接了, 要进行压缩, 我们需要知道怎么读取一个软链接的引用路径, 用下面这个接口就行了:
1 2 func Readlink (name string ) (string , error );
在linux中, 使用chmod试图改变符号链接的权限时, chmod将follow link, 试图改变指向的文件的权限。此外注意我们不用关心软链接文件本身的权限, 它永远是777的, 没有需求也没有必要修改软链接本身的权限。要做权限管理, 应该直接修改文件本身访问权限设置, 而不是设法改变软链接文件的权限。
硬链接 硬链接则很不相同, 它不仅仅代表一个符号, 它代表的是文件本体, 与所有其它硬链接共享底层二进制数据, 或者说它根本不仅仅是一个链接。当创建文件a的硬链接b, 那么a和b此时都是等价的文件, 它们没有任何不同, 单独删除a或单独删除b都不能让此文件的本体从磁盘中消失。
只有某个文件的所有硬链接消失, 对应的文件存储空间才会从磁盘中消失。因此我认为没有硬链接和本体文件之分, 硬链接就是用来创建某个文件对象的同义词的。
如果a和b是同一文件的硬链接, 我们则认为它们就是同一个文件, 编辑a也能观察到b发生同样的变化。需要注意的是不能创建对文件夹的硬链接, 这是一个设计问题, 想想如果允许这么做, 那么必然会导致出现环形的目录结构, 这是abnormal的(这个观点是我从apue中抄来的)。
在linux中, 硬链接关联的所有文件的权限信息都是共享的, chmod, chown一个文件会对它的硬链接造成同样的影响。
tar文件中的硬链接 我扒了一下docker ubuntu22.04的rootfs压缩包, 里面大有乾坤, 我发现里面有四种文件类型: dir, regular file, 软链接和硬链接。下面的程序可以用来检测一般的文件类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 package mainimport ( "archive/tar" "fmt" "io" "os" ) func main () { tarFile, err := os.OpenFile("./ubuntu2204.tar" , os.O_RDONLY, 0 ) if err != nil { panic (err) } blockFlag := false charFlag := false contFlag := false dirFlag := false fifoFlag := false linkFlag := false linkSymFlag := false regFlag := false tarReader := tar.NewReader(tarFile) for { header, err := tarReader.Next() if err != nil { if err == io.EOF { return } panic (err) } switch header.Typeflag { case tar.TypeBlock: if !blockFlag { fmt.Println("block" ) blockFlag = true } case tar.TypeChar: if !charFlag { fmt.Println("char" ) charFlag = true } case tar.TypeCont: if !contFlag { fmt.Println("cont" ) contFlag = true } case tar.TypeDir: if !dirFlag { fmt.Println("dir" ) dirFlag = true } case tar.TypeFifo: if !fifoFlag { fmt.Println("fifo" ) fifoFlag = true } case tar.TypeLink: if !linkFlag { fmt.Println("hard link" , header.Name, header.Linkname) linkFlag = true } case tar.TypeSymlink: if !linkSymFlag { fmt.Println("symbolic link" , header.Name, header.Linkname) linkSymFlag = true } case tar.TypeReg: if !regFlag { fmt.Println("regular file" ) regFlag = true } default : fmt.Println("???" ) } } }
显然, 我们在压缩一个目录下的一堆文件的时候, 需要考虑要硬链接这个问题, 它们共享相同的底层数据, 牵一发而动全身。如果压缩前两个文件是硬链接共享底层数据, 压缩并解压后它们就毫无关系了, 这显然很abnormal吧? 当然tar命令做的是正确的, 它在压缩时就创建了硬链接。
我的疑问是: 进行tar命令压缩的时候会产生硬链接, 这个程序是如何区分不同文件是同一个对象的硬链接的呢? 我认为只有理解了这一点才能自己实现正确的压缩, 这就是我们这小节要探究的点。
ok, 这点很简单, 在linux的文件系统中, 每个目录或文件都有自己独有的inode, 可以把它理解为文件唯一标识。咱们通过一个表来记录这个标识, 每次压缩文件时先判断之前表里面是否已经存了这个inode, 如果存在, 那么就记录为硬链接。下面给个获得文件inode的demo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 package mainimport ( "fmt" "syscall" ) func main () { var info syscall.Stat_t syscall.Stat("./main.go" , &info) fmt.Println(info.Ino) }
如果已经有文件的fd, 那么可以用另外一个接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package mainimport ( "fmt" "os" "syscall" ) func main () { f, err := os.OpenFile("./main.go" , os.O_RDONLY, 0 ) if err != nil { panic (err) } var info syscall.Stat_t syscall.Fstat(int (f.Fd()), &info) fmt.Println(info.Ino) }
其它文件类型 除了目录/文件/软链接/硬链接这些常规文件类型, linux下还有设备文件这种东西。那么在压缩的时候咱们就得考虑要不要把它们写进去, 如果压缩包里面有这些特殊文件, 解压时我们就需要将这些特殊文件自行创建出来。那么怎么做呢? 下面给两个例子, 一个是压缩得到某个文件的主次设备号, 一个是解压时创建特殊的设备文件, 通过这两个例子就能掌握压缩/解压设备文件的方法。
压缩:
在压缩的时候, 我们得知道某个文件是否是特殊的设备文件, 并且拿到它的主次设备号, 怎么做? 答案是syscall.Stat, 下面给个demo, 用于判断任意的文件类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 package mainimport ( "fmt" "os" "syscall" ) func main () { if len (os.Args) != 2 { fmt.Printf("usage: %s FILE_PATH\n" , os.Args[0 ]) return } var info syscall.Stat_t err := syscall.Lstat(os.Args[1 ], &info) if err == syscall.ELOOP { fmt.Println("soft link" ) return } if err == syscall.ENOENT { fmt.Println("不存在" ) return } switch info.Mode & syscall.S_IFMT { case syscall.S_IFBLK: fmt.Println("block device" ) case syscall.S_IFCHR: fmt.Println("char device" ) case syscall.S_IFDIR: fmt.Println("dir" ) case syscall.S_IFIFO: fmt.Println("fifo" ) case syscall.S_IFLNK: fmt.Println("soft link" ) case syscall.S_IFREG: fmt.Println("regular file" ) case syscall.S_IFSOCK: fmt.Println("socket file" ) } }
解压:
在解压过程中, 我们需要能够创建各种文件类型, 下面进行方法列举:
普通文件: os.OpenFile
文件夹: os.Mkdir
硬链接: os.Link
软链接: os.Symlink
块设备文件: syscall.Mknod
字符设备文件: syscall.Mknod
FIFO: syscall.Mkfifo
socket文件: 稍显复杂, 下面用代码来说明
正确的解压方式是: 先把除了软硬链接的所有文件创建写入完毕后, 再创建软硬链接。在go语言中, 可以用一个map存储软链接的name和linkname, 用一个map存储硬链接的name和linkname。等其它文件解压完毕后再遍历之前建立的map创建软硬链接。
普通文件/文件夹/软链接/硬链接咱们就不谈了。下面给个例子来创建字符设备文件, 比如/dev/null:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package mainimport "syscall" func MakeDev (major uint32 , minor uint32 ) uint32 { var dev uint32 dev = (major & 0x00000fff ) << 8 dev |= (major & 0xfffff000 ) << 32 dev |= (minor & 0x000000ff ) << 0 dev |= (minor & 0xffffff00 ) << 12 return dev } func main () { if err := syscall.Mknod("./mynull" , 0666 |syscall.S_IFCHR, int (MakeDev(1 , 3 ))); err != nil { panic (err) } }
再给个例子创建socket文件, 这个稍微复杂一点:
1 2 3 4 5 6 7 package mainimport "net" func main () { net.DialUnix("unix" , &net.UnixAddr{Name: "./mysocket" , Net: "unix" }, &net.UnixAddr{Name: "./mysocket" , Net: "unix" }) }
创建socket的方式其实在c语言里面的思路是: 创建socket并bind, bind就会自动创建socket文件。