reader/writer/closer 等接口的定义

// io.Reader
func (T) Read(b []byte) (n int, err error)

// io.ReaderAt
ReadAt(p []byte, off int64) (n int, err error)

// io.ReaderFrom
ReadFrom(r Reader) (n int64, err error)

// io.Seeker
Seek(offset int64, whence int) (int64, error)

// io.Writer
Write(p []byte) (n int, err error)

// io.WriteAt
WriteAt(p []byte, off int64) (n int, err error)

// io.WriteTo
WriteTo(w Writer) (n int64, err error)

// io.Closer
Close() error

从 reader 对象中读取 n 个字节

在线运行open in new window启动 AI 助手open in new window

// 只要对象实现了 io.Reader 接口,都可以使用该函数
// 因此可以从字符流、字节流、标准输入、文件、网络、管道等对象中读取数据
func readCountBytesFrom(reader io.Reader, num int) ([]byte, error) {
	p := make([]byte, num) // 必须保足够的空间
	n, err := reader.Read(p)
  // 为什么要这么处理?
  // 当 n > 0 的时候,Read 函数返回的 err 可能是 nil,也可能是 io.EOF
  // io.EOF 表示文件结束的错误(end-of-file),表示输入流结束了,大多数情况下不算真正的错误
  // var EOF = errors.New("EOF")
  // 有人认为该 io.EOF 定义是 golang 包的一个设计缺陷,因为它是一个包变量
	if n > 0 {
		return p[:n], nil
	}
	return p, err
}

func Test_readCountBytesFrom(t *testing.T) {
	checker := assert.New(t)
	reader := strings.NewReader("hello world")

	// 读取 5 个字节
	data, err := readCountBytesFrom(reader, 5)
	checker.NoError(err)
	checker.Len(data, 5)
	checker.Equal("hello", string(data))

	// reader 对象是有状态的,内部维护当前读取的位置
	data, err = readCountBytesFrom(reader, 20)
	checker.NoError(err)
	checker.Equal(" world", string(data))

	// 实现了 io.Seeker 接口,移动到头
	reader.Seek(0, io.SeekStart)
	data, err = readCountBytesFrom(reader, 6)
	checker.NoError(err)
	checker.Len(data, 6)
	checker.Equal("hello ", string(data))
}

程序定义了一个函数 readCountBytesFrom,该函数从一个实现了 io.Reader 接口的对象中读取指定数量的字节,并返回一个 []byte 切片和一个可能的错误。函数中的 make 函数用于创建一个指定长度的 []byte 切片,确保有足够的空间来存储读取的字节。reader.Read(p) 函数调用用于从 reader 中读取字节并将其存储到 p 中,返回值 n 表示实际读取的字节数,err 表示可能的错误。

在测试函数 Test_readCountBytesFrom 中,首先使用 strings.NewReader 函数创建一个 io.Reader 对象,该对象从字符串 "hello world" 中读取数据。然后调用 readCountBytesFrom 函数两次来读取不同数量的字节。第一次读取 5 个字节,验证读取的数据是否正确,第二次读取 20 个字节,验证读取的数据是否正确。由于 reader 对象是有状态的,因此在第二次读取时,它将从上次读取的位置继续读取。最后,调用 reader.Seek(0, io.SeekStart) 将读取位置移动到文件开头,然后再次调用 readCountBytesFrom 函数来读取 6 个字节,并验证读取的数据是否正确。整个测试使用了 github.com/stretchr/testify/assert 包来进行断言。

实现 io.Reader 接口

io.Reader 是单接口对象,提供一个 reader 只需要实现 Read(b []byte) (n, error) 函数即可.

在线运行open in new window启动 AI 助手open in new window

type HugeHoleReader struct{}

// 构造 reader 对象
func NewHugeHoleReader() *HugeHoleReader {
	return &HugeHoleReader{}
}

// 不会返回 error 并且永远也读不完(因为不可能返回 io.EOF)
func (r HugeHoleReader) Read(b []byte) (int, error) {
	for index := range b {
		b[index] = 0x41
	}
	return len(b), nil
}

func Test_hugeHoleReader(t *testing.T) {
	checker := assert.New(t)
	reader := NewHugeHoleReader()
	buf := make([]byte, 5)
	n, err := reader.Read(buf)
	checker.NoError(err)
	checker.Equal(n, 5)
	checker.Equal("AAAAA", string(buf))
}

程序定义了一个 HugeHoleReader 类型,并实现了其 Read 方法。HugeHoleReader 类型的实例是一个读取器,可以将其用于读取数据流。

NewHugeHoleReader 是 HugeHoleReader 的构造函数,返回一个 HugeHoleReader 类型的指针。

Read 方法会将传入的 []byte 切片中的所有元素设置为 ASCII 码为 0x41 的字符(即 'A')。然后,它返回设置的字节数以及一个空的 error。由于这个读取器实际上没有任何数据可读,并且在每次调用时都会返回相同的字节流,因此它不可能返回 io.EOF。

Test_hugeHoleReader 是一个测试函数,用于测试 HugeHoleReader 类型的 Read 方法是否按预期工作。在这个测试函数中,它首先创建了一个 HugeHoleReader 类型的实例 reader,然后创建了一个长度为 5 的字节切片 buf,并使用 reader.Read(buf) 读取 5 个字节。最后,它使用 github.com/stretchr/testify/assert 包中的断言函数检查读取的字节数是否等于 5,以及读取的内容是否是 "AAAAA"。如果测试通过,则会输出 "PASS",否则输出 "FAIL"。

把 reader 中的数据读完

在线运行open in new window启动 AI 助手open in new window

// 此处通过判断 io.EOF 循环读取
// 包有现成的方法 ioutil.ReadAll => io.ReadAll()
func readAllFromReader(reader io.Reader) ([]byte, error) {
	var res []byte           // 返回结果
	buf := make([]byte, 256) // 读取缓冲
	for {
		n, err := reader.Read(buf)
		if err != nil && err != io.EOF {
			return nil, err
		}

		res = append(res, buf[:n]...)
		if err == io.EOF {
			// 说明读完了
			break
		}
	}
	return res, nil
}

func Test_readAllFromReader(t *testing.T) {
	assert := assert.New(t)
	reader := strings.NewReader("hello world")
	data, err := readAllFromReader(reader)
	assert.NoError(err)
	assert.Equal("hello world", string(data))
}

程序定义了一个函数 readAllFromReader,它接受一个 io.Reader 类型的参数,并读取它的所有数据,然后将读取的数据返回为一个字节数组。在这个函数中,它使用了一个循环来重复从 reader 中读取数据,直到读完所有数据。

在每次循环中,它会使用 reader.Read(buf) 读取一定数量的数据到一个缓冲区 buf 中,然后将读取的数据追加到结果切片 res 中。如果读取时出现了错误,且错误类型不是 io.EOF,则它会将错误返回;否则,它会判断错误是否为 io.EOF,如果是,则说明数据已经全部读取完毕,它就退出循环并返回结果。

注意到这个函数使用了一个固定长度的缓冲区 buf,这会影响到每次读取的字节数。如果 buf 太小,它可能需要多次从 reader 中读取才能读取完所有数据,从而影响性能。相反,如果 buf 太大,可能会浪费内存。

最后,这个程序中的 Test_readAllFromReader 函数测试了 readAllFromReader 是否能够正确读取给定的字符串 "hello world" 并返回相同的字符串。它创建了一个 strings.Reader 类型的实例 reader,用于将 "hello world" 字符串作为数据流传入 readAllFromReader 函数。然后,它使用 github.com/stretchr/testify/assert 包中的断言函数检查读取的数据是否等于 "hello world"。如果测试通过,则会输出 "PASS",否则输出 "FAIL"。

使用标准库读完 reader 对象

标准库的 ReadAll 函数会动态的判断缓冲区 len 和 cap 的关系,每次读取 len(buffer) - cap(buffer) 个字节数,空间不够的时候进行扩容,直到读取到 io.EOF 后返回.

在线运行open in new window启动 AI 助手open in new window

// 使用标准库读完一个 reader 对象
func Test_readAllByStdFunc(t *testing.T) {
	checker := assert.New(t)
	reader := strings.NewReader("hello world")

	// go 1.16 后直接调用了 io.ReadAll
	data1, err1 := ioutil.ReadAll(reader)

	// 注意 reader 是有状态的
	reader.Seek(0, io.SeekStart)
	data2, err2 := io.ReadAll(reader)

	checker.NoError(err1)
	checker.NoError(err2)

	// 相等
	checker.Equal(data1, data2)
	checker.Equal("hello world", string(data1))
}

程序定义了一个函数 Test_readAllByStdFunc,用于测试两种读取整个数据流的方法:ioutil.ReadAll 和 io.ReadAll。在这个测试函数中,它创建了一个 strings.Reader 类型的实例 reader,用于将字符串 "hello world" 作为数据流传入。

首先,它使用 ioutil.ReadAll 方法读取整个数据流,并将读取的数据保存在 data1 变量中。然后,它重置 reader 的状态,并使用 io.ReadAll 方法读取整个数据流,并将读取的数据保存在 data2 变量中。

最后,它使用 github.com/stretchr/testify/assert 包中的断言函数检查两种方法读取的数据是否相等,并检查读取过程中是否出现了错误。如果测试通过,则会输出 "PASS",否则输出 "FAIL"。

需要注意的是,ioutil.ReadAll 和 io.ReadAll 方法都会读取整个数据流直到 EOF,并返回读取的所有数据。在这个测试函数中,由于 reader 是有状态的,因此在使用 io.ReadAll 方法时需要先将 reader 的位置重置到数据流的起始位置,以便重新读取整个数据流。

组合多个 reader 对象

在线运行open in new window启动 AI 助手open in new window

func TestCombineMultiReader(t *testing.T) {
	checker := assert.New(t)

	r1 := strings.NewReader("one,")
	r2 := strings.NewReader("two,")
	r3 := strings.NewReader("three")

	r := io.MultiReader(r1, r2, r3)

	// io.Copy 第一个参数需要一个实现 io.Writer 的对象(os.Stdout、bytes.Buffer)
	buf := bytes.NewBuffer(nil)
	size, err := io.Copy(buf, r)

	checker.NoError(err)
	checker.Equal("one,two,three", buf.String())
	checker.Equal(int64(13), size)
}

程序定义了一个函数 TestCombineMultiReader,用于测试如何将多个 io.Reader 对象组合成一个单独的 io.Reader 对象,并从中读取所有数据。

在这个测试函数中,它创建了三个 strings.Reader 类型的实例 r1、r2 和 r3,分别用于将字符串 "one,"、"two," 和 "three" 作为数据流传入。然后,它使用 io.MultiReader 方法将这三个 Reader 对象组合成一个单独的 io.Reader 对象 r。

接下来,它创建了一个 bytes.Buffer 对象 buf,用于保存从 r 中读取的数据。然后,它使用 io.Copy 方法从 r 中读取所有数据,并将其写入到 buf 中。最后,它使用 github.com/stretchr/testify/assert 包中的断言函数检查读取的数据是否符合预期,并检查读取过程中是否出现了错误。如果测试通过,则会输出 "PASS",否则输出 "FAIL"。

需要注意的是,io.MultiReader 方法可以将多个 io.Reader 对象组合成一个单独的 io.Reader 对象,并按照它们在参数列表中的顺序依次读取数据。在这个测试函数中,它将三个 strings.Reader 对象组合成一个单独的 io.Reader 对象,并按照 r1、r2、r3 的顺序依次读取数据。因此,在从 r 中读取数据时,首先读取的是 "one,"、然后是 "two,",最后是 "three"。

实现 tee 的 reader 功能

在线运行open in new window启动 AI 助手open in new window

func TestTeeReader(t *testing.T) {
	checker := assert.New(t)

	buf := bytes.Buffer{}
	r1 := strings.NewReader("hello world")

	// 类似 linux tee 命令的作用
	r2 := io.TeeReader(r1, &buf)

	// 打印标准输出
	size, _ := io.Copy(os.Stdout, r2) // hello world
	checker.Equal(int64(11), size)
	checker.Equal(11, buf.Len())
	checker.Equal("hello world", buf.String())
}

TestTeeReader 函数测试了 io.TeeReader 函数的行为,该函数从一个输入读取器读取数据并将其写入一个输出写入器,同时将读取的字节返回给调用方。

在这个测试函数中,创建了一个字节缓冲作为输出写入器,并使用字符串 "hello world" 创建了一个字符串读取器作为输入读取器。然后,通过将字符串读取器和字节缓冲作为参数传递来创建一个 TeeReader。

接下来,调用 io.Copy 函数,将 os.Stdout 和 TeeReader 作为参数传递。这将把 TeeReader 的内容复制到标准输出中,这里将是字符串 "hello world"。

复制后,该函数检查复制的字节数是否为 11,这是字符串 "hello world" 的长度。还检查字节缓冲的长度是否为 11,以及其字符串表示形式是否为 "hello world"。这验证了 TeeReader 正确地将输入读取器的内容写入输出写入器,同时将读取的字节返回给调用方。

设定最少读取的字节数

在线运行open in new window启动 AI 助手open in new window

func TestReadAtLeast(t *testing.T) {
	checker := assert.New(t)

	// buf 不够直接报错
	r := strings.NewReader("hello")
	buf := make([]byte, 1)
	n, err := io.ReadAtLeast(r, buf, 2)
	checker.Error(err)
	checker.ErrorIs(err, io.ErrShortBuffer)
	checker.Equal(0, n)
	fmt.Println(string(buf)) // ""

	// buf 和数据源都是足够的
	r = strings.NewReader("hello")
	buf = make([]byte, 2)
	n, err = io.ReadAtLeast(r, buf, 1)
	checker.NoError(err)
	checker.Equal(2, n)
	fmt.Println(string(buf)) // "he"

	// buf 足够,源数据不足指定长度,报错 UnexpectedEOF 但是读取完了
	r = strings.NewReader("hello")
	buf = make([]byte, 10)
	n, err = io.ReadAtLeast(r, buf, 10)
	checker.ErrorIs(err, io.ErrUnexpectedEOF)
	checker.Equal(5, n)
	fmt.Println(string(buf)) // "hello"
}

io.ReadAtLeast 函数从 r 中读取至少 min 个字节到 buf 中。如果 r 中可读取的字节数少于 min 个字节,则会返回 io.ErrShortBuffer 错误。如果 r 在读取 min 个字节之前遇到了 io.EOF 错误,则返回的 n 值将小于 min,并且还会返回 io.ErrUnexpectedEOF 错误。如果 r 没有在遇到错误之前读取够 min 个字节,则函数会返回读取的字节数和遇到的第一个错误(通常是 io.EOF 或者 io.ErrUnexpectedEOF)。

限制长度的 reader

在线运行open in new window启动 AI 助手open in new window

func TestLimitedReader(t *testing.T) {
	checker := assert.New(t)

	content := "hello world"
	r1 := strings.NewReader(content)
	lr1 := &io.LimitedReader{R: r1, N: 5}

	buf1 := make([]byte, 10)
	// 最多读取 N 个
	lr1.Read(buf1)
	checker.Equal("hello\x00\x00\x00\x00\x00", string(buf1))

	r2 := strings.NewReader(content)
	lr2 := &io.LimitedReader{R: r2, N: 5}
	// 每读取一次 N 就会减小
	for lr2.N > 0 {
		buf2 := make([]byte, 2)
		lr2.Read(buf2)
		fmt.Printf("%s\n", buf2)
	}
	// he
	// ll
	// o
}

io.LimitedReader 是一个包装器,它包装了一个实现了 io.Reader 接口的对象,但是只允许从它的源中读取一定数量的字节。

它有两个字段:R io.Reader:包装的实际 io.Reader 对象;N int64:允许从 R 中最多读取的字节数

使用 io.LimitedReader 可以在读取数据时限制读取的最大字节数,例如限制只读取某个文件的前几个字节,或者限制在读取一些数据时只读取固定长度的数据,而不是读取整个数据源。

进行偏移读取

在线运行open in new window启动 AI 助手open in new window

func TestReadWithOffset(t *testing.T) {
	checker := assert.New(t)

	r := strings.NewReader("hello world")
	buf := make([]byte, 5)
	n, err := r.ReadAt(buf, 6)

	checker.NoError(err)
	checker.Equal(5, n)
	checker.Equal("world", string(buf))
}

代码使用了 ReadAt 方法,可以从指定的位置开始读取数据。在这个例子中,从 "hello world" 的第 6 个字节开始读取,读取 5 个字节到缓冲区 buf 中。最后,通过 assert 断言读取的结果是否正确。

动态移动位置

在线运行open in new window启动 AI 助手open in new window

func TestSeekFromReader(t *testing.T) {
	checker := assert.New(t)

	r := strings.NewReader("hello world")
	// 从后面往前移动
	// 位置标识 io.SeekStart, io.SeekCurrent, and io.SeekEnd
	r.Seek(-5, io.SeekEnd)
	buf := make([]byte, 5)
	
	n, err := r.Read(buf)
	checker.NoError(err)
	checker.Equal(5, n)
	checker.Equal("world", string(buf))
}

程序是一个对 strings.NewReader 和 Reader.Seek 方法的简单示例。strings.NewReader 创建了一个字符串读取器,然后我们使用 Reader.Seek 方法从后往前移动读取器的指针,最后读取了剩余的数据。

具体来说,我们首先将字符串 "hello world" 传入 strings.NewReader 函数创建一个读取器。接下来,我们使用 Reader.Seek 方法将读取器的指针向后移动 5 个字节,然后我们创建一个长度为 5 的字节数组 buf。最后,我们使用 Read 方法从读取器中读取 5 个字节的数据,并将其存储到 buf 中。我们使用 assert 断言库来确保我们成功读取了 5 个字节, buf 中存储的字符串为 "world"。

带缓冲的读取

在线运行open in new window启动 AI 助手open in new window

func TestBufIOReadMethod(t *testing.T) {
	checker := assert.New(t)

	r := bufio.NewReader(strings.NewReader("first line.\nsecond line."))
	// 会返回 \n 
	line, err := r.ReadSlice('\n')
	checker.NoError(err)
	checker.Equal("first line.\n", string(line))

	// 如果一行大于缓存,isPrefix = true
	// ReadLine 不会返回 \n 
	line, isPrefix, err := r.ReadLine()
	checker.NoError(err)
	fmt.Println(isPrefix) // false 
	checker.Equal("second line.", string(line))

	// 直接返回 string,类似的还有 ReadBytes 返回 []byte
	str, err := r.ReadString('\n')
	checker.ErrorIs(err, io.EOF)
	checker.Equal("third line.", str)
}

r := bufio.NewReader(strings.NewReader("first line.\nsecond line.")):初始化一个 bufio.Reader 对象,其缓存从一个 strings.Reader 对象中读取。

line, err := r.ReadSlice('\n'):读取并返回一个包含给定分隔符(此处为 \n)的行。如果找不到分隔符,则返回 bufio.ErrBufferFull 错误,而不是 io.EOF。ReadSlice 方法是返回缓存中的内容,因此如果 '\n' 不存在,则表示该行数据比缓存更长,因此返回错误。

line, isPrefix, err := r.ReadLine():读取一行,并返回一个 bool 值 isPrefix,指示该行是否以缓冲区末尾截断,也就是是否一行过长而分行显示。返回值 line 是读取的一行内容,但不包括行尾的分隔符,如果达到了文件末尾,则返回 io.EOF。

str, err := r.ReadString('\n'):从输入中读取一个以给定的分隔符(此处为 \n)结束的字符串,包括分隔符本身。如果没有找到分隔符,则返回 io.EOF 错误。注意,由于 ReadString 方法返回的是字符串,因此返回的字符串也包括分隔符,而不是像 ReadSlice 和 ReadLine 方法那样将其作为第二个返回值返回。

窥探待读取的数据

在线运行open in new window启动 AI 助手open in new window

func TestBufioPeek(t *testing.T) {
	checker := assert.New(t)

	rd := strings.NewReader("hello world")
	br := bufio.NewReader(rd)

	// peek 含义是窥探,使用场景举例:
	// 1. 先看看会有多少数据可以读取
	// 2. 读取包头检测是不是期望的数据
	b, err := br.Peek(5)
	checker.NoError(err)
	checker.Equal("hello", string(b))

	// peek 不会移动读取位置
	line, err := br.ReadString('o')
	checker.NoError(err)
	checker.Equal("hello", line)
}

TestBufioPeek 测试了 bufio.NewReader 的 Peek 方法。

该方法返回缓冲区下一个 n 个字节,但不将读取位置移动,所以可以用于 “窥探” 数据源下一个可读数据的大小或类型,以此来决定是否需要执行下一步的读取操作。

在该测试函数中,首先创建了一个字符串读取器 strings.NewReader("hello world"),再通过 bufio.NewReader 将其包装为缓冲读取器 br。接着使用 Peek 方法读取了 5 个字节,期望返回的是字符串 "hello"。最后再使用 ReadString 方法读取到了字母 "o" 时停止,因此实际上只读取了 "hello" 这 5 个字节,与 Peek 返回的数据一致。

总之,Peek 可以用于先预览下一个数据的大小或类型,以此来决定是否需要执行下一步的读取操作。

读取目录

在线运行open in new window启动 AI 助手open in new window

func main() {
	// 先申明一下否则下面递归调用找不到函数定义
	var listDir func(path string)

	listDir = func(path string) {
		fi, _ := ioutil.ReadDir(path)
		for _, i := range fi {
			if i.IsDir() {
				fmt.Println("目录:" + path + i.Name())
				listDir(path + "/" + i.Name())
			} else {
				fmt.Println("文件:" + path + "/" + i.Name())
			}
		}
	}

	listDir("/tmp")
}

程序首先定义了一个 listDir 函数,该函数接收一个目录路径作为参数,使用 ioutil.ReadDir 函数读取目录下的所有文件和子目录,并对它们进行遍历。如果当前遍历到的是一个子目录,则递归调用 listDir 函数打印其下所有文件和子目录。如果当前遍历到的是一个文件,则直接打印文件名。

最后在 main 函数中,调用 listDir 函数,并指定要浏览的目录路径。在这个例子中,浏览的目录为 /tmp。

读满一个缓冲区

在线运行open in new window启动 AI 助手open in new window

func TestReadFull(t *testing.T) {
	checker := assert.New(t)

	buf := make([]byte, 7)
	rd := strings.NewReader("hello world")

	n, err := io.ReadFull(rd, buf)
	checker.NoError(err)
	checker.Equal(7, n)
}

当调用io.ReadFull()函数时,该函数会读取reader直到填满给定的buffer或者读取到错误为止。如果读取到错误并且已经读取了一些字节,则io.ReadFull()函数会返回已读取的字节数和错误;如果没有读取到任何字节,则会返回错误。在测试中,strings.NewReader("hello world")返回的是一个只读的reader,而buf := make([]byte, 7)创建了一个长度为7的切片,所以io.ReadFull()将从reader中读取7个字节并将其放入切片中。因为读取7个字节不会导致错误,所以该测试通过。

获取文件全部内容

在线运行open in new window启动 AI 助手open in new window

func main() {
	fn := "/tmp/helloworld"

	// 写入
	_ = ioutil.WriteFile(fn, []byte("hello world"), 0777)

	// 读取
	f, _ := os.Open(fn)
	content, _ := io.ReadAll(f)
	fmt.Println(string(content)) // hello world
}

首先使用ioutil.WriteFile()函数将字符串"hello world"写入到文件/tmp/helloworld中。其中,第一个参数是文件名,第二个参数是待写入的内容,第三个参数是文件的权限。在这里,权限设置为0777,表示该文件可读可写可执行。

接下来,我们使用os.Open()函数打开文件/tmp/helloworld,然后使用io.ReadAll()函数读取文件的所有内容,并将其存储在content变量中。最后,我们使用fmt.Println()函数输出content变量的值,即文件的内容"hello world"。

读取文件和写入文件

在线运行open in new window启动 AI 助手open in new window

func main() {
	// 临时目录
	dir, _ := ioutil.TempDir("/tmp", "logs")
	fmt.Println(dir) // /tmp/logs1682259306

	// 临时文件
	file, _ := ioutil.TempFile("/tmp", "pid")
	fmt.Println(file.Name()) // /tmp/pid1931567061

	// 写入
	_ = ioutil.WriteFile(file.Name(), []byte("hello world"), 0777)

	// 读取
	content, _ := ioutil.ReadFile(file.Name())
	fmt.Println(string(content)) // hello world
}

代码创建了一个临时目录和一个临时文件,并将字符串 "hello world" 写入到这个文件中。然后,它又使用 ioutil.ReadFile 读取这个文件中的内容,并将其转换为字符串并打印出来。临时目录和临时文件的创建使用了 ioutil.TempDir 和 ioutil.TempFile 函数。这些函数会在指定目录下创建一个随机命名的目录或文件,并返回它们的路径。在这个例子中,它们在 /tmp 目录下创建了一个以 "logs" 和 "pid" 为前缀的目录和文件,返回它们的路径后,代码就可以在这些目录和文件中进行读写操作了。

Last Updated:
Contributors: Bob Wang