Go语言入门基础:数据压缩

发布于:2025-05-07 ⋅ 阅读:(26) ⋅ 点赞:(0)

第20章 数据压缩

目录

  • 第20章 数据压缩
    • 20.1 标准库对压缩算法的支持
    • 20.2 Gzip压缩算法
      • 20.2.1 Gzip基本用法
      • 20.2.2 压缩多个文件
      • 20.2.3 解压多个文件
    • 20.3 DEFLATE算法
    • 20.4 自定义的索引字典
    • 20.5 Zip文档
      • 20.5.1 从Zip文档中读取文件
      • 20.5.2 在内存中读写Zip文档
      • 20.5.3 注册压缩算法
    • 20.6 Tar文档

20.1 标准库对压缩算法的支持

Go标准库支持许多常见的压缩(含解压缩)算法,这些API分布在以下几个包中:
1. compress/bzip2:支持解压bzip2软件压缩的数据。
2. compress/flate: 使用DEFLATE算法,支持数据的压缩与解压缩。
3. compress/gzip: 使用Gzip算法,即GNU zip格式,支持压缩与解压缩。
4. compress/lzw: 使用LZW算法,即Lempel-Ziv-Welch Encoding,支持压缩与解压缩。
5. compress/zlib: 使用zlib标准,支持压缩与解压缩操作。
6. archive/zip: 提供了访问zip文档的API
7. archive/tar: 提供了访问tar文档的API

以上所列的代码包有一个共同点:Writer对象通过NewWriter函数创建,用于压缩数据;Reader对象通过NewReader函数创建,用于解压缩数据。

20.2 Gzip压缩算法

GzipJean-loup GaillyMark Adler开发,常用于*nix系统(UnixLinux)的文件压缩。Gzip的数据主体采用DEFLATE算法压缩,由10字节的文件头扩展头(可选)、数据主体尾注组成。在compress/gzip包中,Header结构体封装Gzip文档的文件头

type Header struct {
    Comment string    // 注释,可选
    Extra   []byte    // 扩展头,可选
    ModTime time.Time // 文档的修改时间
    Name    string    // 被压缩数据流存储在Gzip文档中的文件名
    OS      byte      // 操作系统类型,一般不设置
}

Gzip算法允许将多个数据流(或者源文件)压缩后串联在一起,解压时既可以将这些串联后的数据流一次性读出,也可以单独读取(本书后面会介绍)。但人们更习惯于用Gzip来压缩单个文件,存在多个文件时,会先把这些文件压缩到一个tar文档中,再把该tar文档存储到Gzip文档中,也称为tar.gz文档或tgz文档。

20.2.1 Gzip基本用法

compress/gzip包中,ReaderWriter两个结构体都继承了Header结构体的字段(定义时内部嵌套了Header类型),所以ReaderWriter类型也具有NameCommentModTime等字段。

压缩数据时,调用NewWriter函数创建Writer实例,函数需要一个实现了io.Writer接口的对象作为提供写入数据的基础流,常见的如bytes.Buffer对象(内存数据)、os.File对象(磁盘文件)等。

解压数据时,调用NewReader函数创建Reader实例。与压缩操作相似,调用NewReader函数时需要提供一个实现了io.Reader接口的对象作为基础数据流。

下面的示例使用Gzip算法对字符串数据进行压缩与解压缩。

// 待压缩的文本
var text = `
{
    "id": 372144,
    "pno": "FC-B10-23-1",
    "in_date": "2018-11-25",
    "desc": "Kismil",
    "color": "Black"
}
`
// 原数据的字节序列
var srcBs = []byte(text)

// 使用gzip算法压缩数据
var buffer = bytes.NewBuffer(nil)
gzw := gzip.NewWriter(buffer)
// 写入数据
gzw.Write(srcBs)
// 调用Close方法让压缩后的数据写入基础数据流中
// 调用后不会关闭基础数据流
gzw.Close()

// 解压缩数据
gzr, err := gzip.NewReader(buffer)
if err != nil {
    fmt.Println(err)
    return
}
fmt.Print("解压缩后的内容:")
// 读取解压缩的内容
io.Copy(os.Stdout, gzr)
// 关闭Reader对象
// 与其关联的基础数据流不会关闭
gzr.Close()

创建Writer实例后,只要调用Write方法就可以写入要压缩的数据;反之,调用Reader实例的Read方法可以读取解压缩后的数据。若到了数据流末尾,就会返回EOF错误。上述示例中,调用io.Copy函数把解压缩出来的数据读出,并写入标准输出流(os.Stdout)。

在写完(或读完)数据后,都可以调用**Close方法关闭Writer(或Reader)对象,但不会关闭与之关联的基础数据流。对于压缩操作而言,调用Writer对象的Close方法后数据才会真正写入基础流中,如果忘记调用Close方法,有可能导致压缩后的数据不完整**(未完全写入基础流)。

再看一个示例,此例将使用Gzip算法压缩音频文件。

// 输入文件,待压缩
inFile, _ := os.Open("music.mp3")
// 输出文件,已压缩
outFile, _ := os.Create("music.gz")

gzw := gzip.NewWriter(outFile)
// 设置在压缩包内显示的文件名
gzw.Name = "music.mp3"
// 压缩并写入数据
io.Copy(gzw, inFile)
// 关闭Writer对象并把数据写入文件
gzw.Close()
// 关闭文件
inFile.Close()
outFile.Close()

在压缩文件时,可以设置Writer对象的Name属性(来自Header类型),以指定文件在gzip文档中显示的名称。

20.2.2 压缩多个文件

Writer对象支持压缩多个文件(或多段数据流)并写入基础数据流,其核心是调用Reset方法——每写完一个文件,先调用Close方法把数据写入基础流,然后调用Reset方法重置Writer对象的状态,这样就可以在不需要重新创建新Writer实例的情况下继续写入文件数据。

下面的代码将向Gzip文档添加三个文件。

var buffer = bytes.NewBuffer(nil)
// 压缩三个文件
gzw := gzip.NewWriter(buffer)
for i := 1; i < 4; i++ {
    // 设置文件名
    gzw.Name = fmt.Sprintf("item-%d.txt", i)
    // 设置注释
    gzw.Comment = fmt.Sprintf("comment-#%d", i)
    // 写入数据
    gzw.Write([]byte(fmt.Sprintf("示例文本%d\n", i)))
    // 关闭Writer对象
    gzw.Close()
    // 调用Restet方法很关键
    gzw.Reset(buffer)
}

Gzip算法在写入压缩数据时,会将所有文件(或数据流)的内容连接起来,而不是单独存储,也就是把多个数据流合并为一个流来处理。所以,在解压数据时,如果使用的是Reader的默认行为,那么它会把Gzip文档中所有的文件内容一次性读出,就像下面这样:

gzr, err := gzip.NewReader(buffer)
if err != nil {
    fmt.Println(err)
    return
}
// 读取内容
for {
    fmt.Printf("文件: %s\n", gzr.Name)
    fmt.Printf("注释: %s\n", gzr.Comment)
    fmt.Print("\n文件内容:\n")
    io.Copy(os.Stdout, gzr)
    fmt.Print("\n\n")
    // 下一个文件
    err := gzr.Reset(buffer)
    if err == io.EOF {
        // 到达流末尾,退出循环
        break
    }
}

读出来的结果如下:

文件:item-1.txt
注释:comment-#1

文件内容:
示例文本1
示例文本2
示例文本3

从上述结果可以看到,Reader对象只读了一次,当调用Reset方法返回EOF(文件末尾)for循环退出,也就是说,三个文件的内容被一次性全读出来。如果希望把文件逐个读出,则需要关闭Reader对象的默认行为,详情可参考20.2.3节的内容。

20.2.3 解压多个文件

Reader类型有一个名为Multistream的方法,它接收一个bool类型的参数值。若该参数值为false,则表示禁用Reader对象的默认行为,就可以逐个读取Gzip文档中的文件。

下面的示例将使用Gzip算法压缩五个文件,在解压时将它们逐个读出来。

var buffer = bytes.NewBuffer(nil)
// 压缩五个文件
gzw := gzip.NewWriter(buffer)
for n := 1; n <= 5; n++ {
    // 设置文件名
    gzw.Name = fmt.Sprintf("file-%02d.txt", n)
    // 写入文件内容
    var content = fmt.Sprintf("示例文本--%d\n", n)
    gzw.Write([]byte(content))
    // 写入完成
    gzw.Close()
    // 重置,准备写入下一个文件
    gzw.Reset(buffer)
}

// 解压
gzr, err := gzip.NewReader(buffer)
if err != nil {
    fmt.Println(err)
    return
}
// 逐一读出文件
for {
    // 此处调用是关键
    gzr.Multistream(false)
    fmt.Printf("文件: %s\n", gzr.Name)
    fmt.Print("文件内容:")
    // 读出文件内容,并写入标准输出流
    io.Copy(os.Stdout, gzr)
    fmt.Print("\n")
    // 重置,读取下一个文件
    err := gzr.Reset(buffer)
    if err == io.EOF {
        // 到达文档末尾,退出循环
        break
    }
}
// 关闭Reader对象
gzr.Close()

在解压Gzip文档时要注意,每次读取文件前都必须调用一次Multistream方法,并向参数传递false值。这是因为for循环末尾会调用Reset方法来重置Reader对象,这会使Reader对象恢复为默认行为。

示例依次解压五个文件,并输出文件名和文件内容。

文件:file-01.txt
文件内容:示例文本--1

文件:file-02.txt
文件内容:示例文本--2

文件:file-03.txt
文件内容:示例文本--3

文件:file-04.txt
文件内容:示例文本--4

文件:file-05.txt
文件内容:示例文本--5

20.3 DEFLATE算法

DEFLATE是一种无损压缩算法,它使用LZ77算法和哈夫曼(Huffman)编码。7z、zip、gzip等格式都使用了DEFLATE算法。

DEFLATE算法有关的API位于compress/flate包中(包名少了“de”)。压缩数据时,调用NewWriter函数创建Writer对象实例,然后写入要压缩的数据;解压缩数据时,调用NewReader函数创建Reader对象实例,然后读出解压缩后的数据。

下面的例子演示了使用DEFLATE算法对字符串进行压缩和解压缩的方法。

// 待压缩的字符串
var testStr = "black, black, black, black, black"
var srcData = []byte(testStr)
fmt.Printf("原数据长度: %d\n", len(srcData))

// 压缩
var buffer = new(bytes.Buffer)
fwt, err := flate.NewWriter(buffer, 5)
if err != nil {
    fmt.Println(err)
    return
}
fwt.Write(srcData)
// 调用此方法让数据写入基础流
fwt.Close()

fmt.Printf("压缩后数据长度: %d\n", buffer.Len())

// 解压缩
zrd := flate.NewReader(buffer)
fmt.Print("解压后:")
io.Copy(os.Stdout, zrd)
zrd.Close()

NewWriter函数的第二个参数(level)是一个整数值,指压缩的级别,最小值为1(速度最快,但压缩比最小),最大值为9(压缩比最佳,但速度较慢)。如果参数的值为0,表示不压缩,为**-1表示使用算法的默认压缩级别,为-2表示只使用哈夫曼编码。因此,如果参数的值超出[-2, 9]**的范围,那么NewWriter函数就会返回错误信息。

DEFLATE算法会为字符串中重复出现的内容建立索引,当遇到重复的内容时就用索引替代,以缩短内容。上面示例运行后的输出结果如下:

原数据长度:33
压缩后数据长度:15
解压后:black, black, black, black, black

20.4 自定义的索引字典

DEFLATE、Zlib等算法在压缩数据时都支持索引字典,可为被压缩数据中重复出现的内容建立编码表。对于有固定格式的文本,可以自定义索引字典,在一定程度上提升数据的压缩率。

索引字典是一个字节序列(类型为[]byte),其中包含会在被压缩内容中重复出现的内容。在压缩数据时,算法会在被压缩数据中搜索字典中的内容并进行替换。

假设要处理的数据正文是一个JSON对象,而且其格式如下:

{
    "id": <动态内容>,
    "name": "<动态内容>",
    "age": <动态内容>,
    "city": "<动态内容>"
}

只有标注为“动态内容”的部分才有可能出现变动,其他内容是固定不变的。于是,可以把除“动态内容”以外的字符作为索引字典。

var dict = []byte(`{
    "id": ,
    "name": "",
    "age": ,
    "city": ""
}`)

而待压缩的JSON对象为:

var srcText = `{
    "id": 32001,
    "name": "Tommy",
    "age": 27,
    "city": "Beijing"
}`

然后,使用DEFLATE算法对上述JSON数据进行压缩。

var srcData = []byte(srcText)
fmt.Printf("原数据长度: %d\n", len(srcData))

var bf bytes.Buffer
// 压缩
zw, _ := flate.NewWriterDict(&bf, 9, dict)
zw.Write(srcData)
zw.Close()
fmt.Printf("压缩后数据长度: %d\n", bf.Len())

如果要使用自定义的索引字典,那么在创建Writer对象实例时应该调用NewWriterDict函数,并把索引字典传递给dict参数。

解压缩的时候,也要使用与压缩时相同的索引字典。

zr := flate.NewReaderDict(&bf, dict)
fmt.Print("解压后:")
io.Copy(os.Stdout, zr)
zr.Close()

以下输出信息展示了压缩前后数据长度的改变。

原数据长度:66
压缩后数据长度:36
解压后:

{
    "id": 32001,
    "name": "Tommy",
    "age": 27,
    "city": "Beijing"
}

解压缩过程中,算法会根据索引字典中的内容,重新恢复数据。所以压缩与解压缩都要使用相同的字典才能正确恢复被压缩后的数据。

修改上述的示例代码。假设在解压缩之前,把字典中的内容全替换为“&”。

for i := range dict {
    dict[i] = '&'
}

然后再执行代码,就会发现,解压缩出来的内容与原内容不一致了。

原数据长度:66
压缩后数据长度:36
解压后:&&&&&&&&&32001&&&&&&&&&&&&Tommy&&&&&&&&&&&27&&&&&&&&&&&&Beijing"
}

20.5 Zip文档

Zip是一种压缩文档格式,它支持对每个文件进行单独压缩(或不压缩)。理论上说,每个文件可以使用独立(不同)的压缩算法,但这种情况很少见,一般都是整个Zip文档统一使用一种压缩算法。

Zip文档允许每个文件的独立操作,因此在性能和速度上会相对弱一些。不过,在解压缩时可以根据索引来读取需要的文件,而不必读取整个文档。

archive/zip包实现了支持读写.zip文件的APIZip支持多种压缩算法,Go标准库仅支持DEFLATE算法(最为常用的算法),且由以下两个常量定义:

const (
    Store   uint16 = 0 // 不压缩
    Deflate uint16 = 8 // 使用DEFLATE算法压缩
)
20.5.1 从Zip文档中读取文件

直接调用OpenReader函数,并提供Zip文档的路径,若顺利调用,该函数会返回一个Reader对象,随后,通过Reader对象的File字段可以获取到压缩文档中的文件列表。

以下示例演示了OpenReader函数和Reader.File字段的用法。
步骤1: 创建一个.zip文档,并添加三个文本文件,假设它们分别被命名为file-1.txtfile-2.txtfile-3.txt
步骤2: 调用OpenReader函数打开Zip文档。

zreader, err := zip.OpenReader("testfile.zip")

步骤3: 通过for循环枚举File字段中的元素,读取压缩包中的所有文件。

for _, f := range zreader.File {
    fmt.Printf("文件: %s\n", f.Name)
    // 打开文件流
    freader, err := f.Open()
    if err != nil {
        fmt.Printf("错误: %v\n", err)
        continue
    }
    fmt.Print("内容:")
    // 将文件内容复制到标准输出流
    io.Copy(os.Stdout, freader)
    // 关闭文件流
    freader.Close()
    fmt.Print("\n\n")
}

Reader.File字段枚举出来的元素为zip.File类型,此类型仅包含文件的基本信息,若要读取文件内容,还需要调用File对象的Open方法打开文件对应的流,文件读取完毕,可以调用Close方法将流关闭。

步骤4: 调用Reader对象的Close方法,关闭整个Zip文档。

zreader.Close()

步骤5: 示例代码运行后,将得到以下输出信息:

文件:file-1.txt
内容:第一个文本文件的内容

文件:file-2.txt
内容:第二个文本文件的内容

文件:file-3.txt
内容:第三个文本文件的内容
20.5.2 在内存中读写Zip文档

使用NewWriter函数将获得一个Writer对象实例,接着调用Writer实例的CreateCreateHeader方法可以创建新文件,之后便可以写入文件内容了。所有文件写入完毕后,调用Writer实例的Close方法以将数据写入基础流。

读取Zip文档时,先调用NewReader函数创建Reader实例,再通过Reader实例的File字段来获取文件列表,最后调用Reader.Close方法关闭Zip文档。

下面的示例将完成在内存中读写Zip文档的功能。

// 在内存中暂存数据的缓冲区
var buffer = new(bytes.Buffer)

// 构建新的Zip文档
zipw := zip.NewWriter(buffer)
// 添加四个文件
file1, _ := zipw.Create("a.dat")
file1.Write([]byte("red red red red red"))
file2, _ := zipw.Create("b.dat")
file2.Write([]byte("tick tick tick"))
file3, _ := zipw.Create("c.dat")
file3.Write([]byte("core - core - core"))
file4, _ := zipw.Create("d.dat")
file4.Write([]byte("test * test * test * test * test"))
// 关闭Writer
zipw.Close()

// 读出Zip文档中的文件
baseReader := bytes.NewReader(buffer.Bytes())
zipr, err := zip.NewReader(baseReader, baseReader.Size())
if err != nil {
    fmt.Println(err)
    return
}
// 读取文件列表
for _, f := range zipr.File {
    fmt.Printf("文件: %s\n", f.Name)
    fmt.Printf("大小: %d\n", f.UncompressedSize64)
    fmt.Printf("压缩后大小: %d\n", f.CompressedSize64)
    fmt.Print("文件内容:")
    // 读取文件内容
    fr, err := f.Open()
    if err != nil {
        continue
    }
    io.Copy(os.Stdout, fr)
    // 关闭文件
    fr.Close()
    fmt.Print("\n\n")
}

此处有一点要注意,由于bytes.Buffer类型没有实现ReaderAt接口,但zip.NewReader函数要求传入的参数类型实现该接口,所以要先将bytes.Buffer实例中的数据复制到bytes.Reader实例中(bytes.Reader类型实现ReaderAt接口),方法是:

baseReader := bytes.NewReader(buffer.Bytes())

zip.NewReader函数需要一个size参数,一般指定为基础流的长度。

上述示例运行后,程序将从缓存在内存中的Zip文档读出以下文件列表:

文件:a.dat
大小:19
压缩后大小:12
文件内容:red red red red red

文件:b.dat
大小:14
压缩后大小:13
文件内容:tick tick tick

文件:c.dat
大小:14
压缩后大小:13
文件内容:core - core - core

文件:d.dat
大小:24
压缩后大小:13
文件内容:test * test * test * test * test
20.5.3 注册压缩算法

目前内置的库仅支持DEFLATE算法,但archive/zip包仍提供了可以注册压缩算法的函数。开发人员可以修改DEFLATE算法的一些参数(例如压缩的级别),然后重新注册。与算法注册相关的API有以下两组:
(1)由zip包直接公开的函数。

func RegisterCompressor(method uint16, comp Compressor)
func RegisterDecompressor(method uint16, dcomp Decompressor)

RegisterCompressor函数注册压缩算法,RegisterDecompressor函数注册解压缩算法,压缩算法与解压缩算法一定要一致,数据才能被正确处理。method参数是整数值,表示算法的编号,zip包中仅提供了两个值——0(不压缩)和8(DEFLATE算法)。

Compressor和Decompressor都是函数类型,定义如下:

type Compressor func(w io.Writer) (io.WriteCloser, error)
type Decompressor func(r io.Reader) io.ReadCloser

对于压缩算法,通过此函数类型返回一个专用的Writer对象;反之,对于解压缩算法,通过该函数返回一个Reader对象。

(2) 调用 zip.Writer 对象的 RegisterCompressor 方法注册压缩算法;调用 zip.Reader 对象的 RegisterDecompressor 方法注册解压缩算法。这一组 API 所注册的算法仅对当前的 zip.Writerzip.Reader 对象有效,属于局部性的。而上面所述的以 zip 包直接公开的函数所注册的算法可通用于应用程序生命周期内的代码,属于全局性的。

标准库默认已为 DEFLATE 算法进行全局注册,因此,在代码中直接调用 zip 包中的 RegisterCompressorRegisterDecompressor 函数重复注册 DEFLATE 算法就会引发错误。避免错误的方法是注册局部算法——调用 Writer 对象的 RegisterCompressor 方法,调用 Reader 对象的 RegisterDecompressor 方法。

下面的示例将演示注册最佳压缩比的DEFLATE算法,然后用该算法创建Zip文档。

// 输出文件名
var testfile = "data.zip"
outFile, err := os.Create(testfile)
if err != nil {
    fmt.Println(err)
    return
}

zw := zip.NewWriter(outFile)
// 注册压缩算法
zw.RegisterCompressor(zip.Deflate, func(w io.Writer) (io.WriteCloser, error) {
    // 最高压缩比
    return flate.NewWriter(w, flate.BestCompression)
})
// 添加三个文件
tmp, _ := zw.Create("part1.txt")
tmp.Write([]byte("test -------- data ---"))
tmp, _ := zw.Create("part2.txt")
tmp.Write([]byte("ab cd ab cd"))
tmp, _ := zw.Create("part3.txt")
tmp.Write([]byte("...content..."))
// 关闭Writer
zw.Close()

// 关闭文件
outFile.Close()

20.6 Tar文档

TarTape archives 并不是压缩算法,它是一种文档存储格式,可以将多个文件打包成一个文件。archive/tar 包提供了对读写 Tar 文档的支持。

写入 Tar 文档时,调用 tar.NewWriter 函数创建 Writer 实例。对于要写入的每个文件,应当先调用 Writer 对象的 WriteHeader 方法写入文件头。文件头包含文件的基本信息,如文件名、内容长度等。写入文件头后,就可以调用 Writer 对象的 Write 方法写入文件内容,并且所写入的文件内容长度不能大于文件头所指定的大小。

Tar 是按顺序向前读取文件的。先调用 tar.NewReader 函数创建 Reader 实例。对于文档中的每一个文件,在读取内容之前,需要调用 Reader 对象的 Next 方法定位文件,若后面已经没有文件了,就返回 EOF。然后调用 Read 方法读取文件内容,能读取的内容长度取决于文件头的 Size 字段,如果文件内容长度大于文件头的 Size 字段,那么剩余的字节会被舍弃。

下面的示例演示Tar文档的读写过程。
步骤1: 创建bytes.Buffer实例,用于缓存Tar文档内容。

var buffer = new(bytes.Buffer)

步骤2: 创建Writer实例。

tarw := tar.NewWriter(buffer)

步骤3: 写入第一个文件。

content := []byte("第一个文件")
header := &tar.Header{
    Name: "p01.txt",
    Size: int64(len(content)), //内容长度
}
// 写入文件头
if err := tarw.WriteHeader(header); err == nil {
    // 写入内容
    tarw.Write(content)
}

文件头使用tar.Header结构体封装,其中,Name字段表示文件名,Size字段表示文件内容的长度(字节)。Size字段必须设置足够容纳文件内容的长度。

步骤4: 第二、三、四个文件的写入过程与第一个文件类似。

// 第二个文件
content = []byte("第二个文件")
header.Name = "p02.txt"
header.Size = int64(len(content))
if err := tarw.WriteHeader(header); err == nil {
    tarw.Write(content)
}
// 第三个文件
content = []byte("第三个文件")
header.Name = "p03.txt"
header.Size = int64(len(content))
if err := tarw.WriteHeader(header); err == nil {
    tarw.Write(content)
}
// 第四个文件
content = []byte("第四个文件")
header.Name = "p04.txt"
header.Size = int64(len(content))
if err := tarw.WriteHeader(header); err == nil {
    tarw.Write(content)
}

步骤5: 写入完毕后,调用Close方法关闭Writer实例。

tarw.Close()

步骤6: 接下来将读取Tar文档中的文件列表。创建Reader实例。

tarr := tar.NewReader(buffer)

步骤7: 每个文件在读取前都要调用一次Next方法完成定位。如果Tar文档中已经没有文件了就会返回EOF

for {
    hd, err := tarr.Next()
    if err == io.EOF {
        // 已到了列表末尾,跳出循环
        break
    }
    // 打印文件名
    fmt.Printf("文件: %s\n", hd.Name)
    // 读取文件内容
    fmt.Print("内容:")
    io.Copy(os.Stdout, tarr)
    fmt.Print("\n\n")
}

步骤8: 运行示例程序,屏幕输出如下:

文件:p01.txt
内容:第一个文件

文件:p02.txt
内容:第二个文件

文件:p03.txt
内容:第三个文件

文件:p04.txt
内容:第四个文件

网站公告

今日签到

点亮在社区的每一天
去签到