12-IO和文件系统

本章简单介绍Elixir的输入、输出机制,文件系统相关的任务,
以及涉及到的模块,如IO
File
Path

我们曾经在早期的文章中说现在介绍IO似乎有点早。
但是,我们注意到IO系统其实提供了一窥Elixir和虚拟机的设计哲学和精妙的绝佳机会。

“早期的文章”:现在介绍I/O似乎有点早,但是I/O系统可以让我们一窥Elixir哲学,满足我们对该语言以及VM的好奇心。

IO模块

模块IO提供了Elixir语言中读写标准输入/输出(:stdio)、
标准错误(:stderr)、文件和其他IO设备的主要机制。

该模块的使用方法颇为直白:

  1. iex> IO.puts "hello world"
  2. "hello world"
  3. :ok
  4. iex> IO.gets "yes or no? "
  5. yes or no? yes
  6. "yes\n"

默认情况下,IO模块中的函数从标准输入中读取,向标准输出中写入。
我们可以传递参数,比如:stderr,来指示将信息写到标准错误上:

  1. iex> IO.puts :stderr, "hello world"
  2. "hello world"
  3. :ok

File模块

File模块包含的函数可以让我们打开文件作为IO设备。
默认情况下文件是以二进制模式打开,
它要求程序员使用特定的IO.binread/2IO.binwrite/2函数来读写文件:

  1. iex> {:ok, file} = File.open "hello", [:write]
  2. {:ok, #PID<0.47.0>}
  3. iex> IO.binwrite file, "world"
  4. :ok
  5. iex> File.close file
  6. :ok
  7. iex> File.read "hello"
  8. {:ok, "world"}

文件可以使用:utf8编码方式打开,这让File模块以UTF-8编码方式解析文件中读取的字节:

  1. iex> {:ok, file} = File.open "another", [:write, :utf8]
  2. {:ok, #PID<0.48.0>}

除了打开、读写文件的函数,文件模块还提供了许多函数来操作文件系统。
这些函数根据Unix平台上功能相对应的命令来命名。
File.rm/1用来删除文件,File.mkdir/1用来创建目录,
File.mkdir_p/1创建目录并保证其父目录一并创建。
甚至还有File.cp_r/2File.rm_rf/1用来递归地复制或删除整个目录。

你会注意到File模块的函数有两种变体,一个普通的和一个名称末尾有!(bang)的。
例如在上面的例子里,我们在读取“hello”文件时,用的是不带!的版本。
相对地,我们也可以使用File.read!/1

  1. iex> File.read "hello"
  2. {:ok, "world"}
  3. iex> File.read! "hello"
  4. "world"
  5. iex> File.read "unknown"
  6. {:error, :enoent}
  7. iex> File.read! "unknown"
  8. ** (File.Error) could not read file unknown: no such file or directory

注意看,当文件不存在时,带!号的版本会抛出一个错误。
而不带!号的版本适用于你对不同结果进行模式匹配的情形:

  1. case File.read(file) do
  2. {:ok, body} -> # do something with the `body`
  3. {:error, reason} -> # handle the error caused by `reason`
  4. end

但有的时候,你就是希望文件在那儿,!号变体更加适用,因为它能报出有意义的错误。
例如,如果这么写:

  1. {:ok, body} = File.read(file)

当遇到文件不存在的情况时,函数返回{:error, reason},然后导致在跟左侧元组做模式匹配时失败。
失败依然会抛出一个异常,但是该异常的错误信息是描述一次模式匹配失败,而不是文件的什么错误。
从而在一定程度上掩盖了真正的失败原因。

可以这么写:

  1. case File.read(file) do
  2. {:ok, body} -> # handle ok
  3. {:error, r} -> # handle error
  4. end

当然,更推荐的写法:

  1. File.read!(file)

Path模块

模块File中的绝大多数函数都以各种路径作为参数。
通常,这些路径都是二进制串(binaries)。
模块Path提供了操作这些路径的函数:

  1. iex> Path.join("foo", "bar")
  2. "foo/bar"
  3. iex> Path.expand("~/hello")
  4. "/Users/jose/hello"

推荐使用Path模块提供的函数而不是直接手动操作代表路径的二进制串。
因为Path模块考虑了不同操作系统的区别,使得各种处理是对“操作系统”透明的。
最后,记住在Windows平台上处理文件操作时,Elixir会自动将斜杠(/)转换成反斜杠(\)。

有了上面介绍的模块和函数,我们已经能对文件系统进行基本的操作。
下面将讨论有关IO的一些高级话题。这部分并不是写Elixir程序必须掌握的,可以跳过不看。
但是如果你浏览一下,可以大致了解IO是如何在VM上实现的,以及其它一些有趣的内容。

进程(Processes)和组长(group leaders)

你可能已经注意到File.open/2函数返回类似{:ok, pid}的元组:

  1. iex> {:ok, file} = File.open "hello", [:write]
  2. {:ok, #PID<0.47.0>}

模块IO实际上是和进程(Process)一起协同工作的。
当你调用IO.write(pid, binary)时,IO模块将发送一条消息给pid标示的进程,
附上所期望进行的操作。

如果我们自己用进程来描述这个过程:

  1. iex> pid = spawn fn ->
  2. ...> receive do: (msg -> IO.inspect msg)
  3. ...> end
  4. #PID<0.57.0>
  5. iex> IO.write(pid, "hello")
  6. {:io_request, #PID<0.41.0>, #Reference<0.0.8.91>, {:put_chars, :unicode, "hello"}}
  7. ** (ErlangError) erlang error: :terminated

调用IO.write/2之后,可以看到IO模块发出的请求(四个元素的元组)被打印了出来。
不久后,因为我们并未提供IO模块期待的某种结果,这个请求失败了。

StringIO模块
提供了基于字符串的IO设备消息处理功能(将字符串看做IO设备):

  1. iex> {:ok, pid} = StringIO.open("hello")
  2. {:ok, #PID<0.43.0>}
  3. iex> IO.read(pid, 2)
  4. "he"

Erlang虚拟机通过利用进程给IO设备建模,使得同一个网络中的不同节点可以通过交换文件进程,
实现跨节点的文件读写。而在所有IO设备之中,有一个特殊的进程,称作组长(group leader)

当你写东西到标准输入输出(:stdio),实际上是发送了一条消息给进程组长,
它把内容写给标准输出的文件描述符:

  1. iex> IO.puts :stdio, "hello"
  2. hello
  3. :ok
  4. iex> IO.puts Process.group_leader, "hello"
  5. hello
  6. :ok

在不同的应用场景下,让哪个进程作为组长是可以配置的。
例如,当在远程终端执行代码时,通过配置组长,可以使得远程节点上打印的消息可以被重定向到发起操作(你)的终端上。

iodatachardata

在以上所有例子中,我们都用的是二进制串格式写入文件。
在“二进制串、字符串和字符列表”那章里,我们提到过字符串(string)就是普通的bytes,
而字符列表(char list)就是字符编码(code point,如“Uxxxx”、“049”)的列表。

IO模块和File模块中的函数还可以接受列表作为参数。
不光如此,它们还接受混合类型的列表,里面内容可以是列表(如'ab c')、
整形(如49?A —- 返回65)和二进制串(如"ab c"):

  1. iex> IO.puts 'hello world'
  2. hello world
  3. :ok
  4. iex> IO.puts ['hello', ?\s, "world"]
  5. hello world
  6. :ok

?返回后面字符的编码(code point),如?A默认情况下返回65。

尽管如此,有些地方还是要注意。一个列表可能表示一串byte,或者一串字符。
用哪一种需要看IO设备是怎么编码的。
如果不指明编码,文件就以raw模式打开,
这时候只能用IO模块里bin*开头(二进制版本)的函数对其进行操作。
这些函数接受iodata作为参数。也就是说,它们期待一个整数值的列表,用来表示bytes或二进制串。

另一边,使用:utf8打开的:stdio和文件使用IO模块里剩下来其它的函数。
这些函数期待char_data作为参数,即一串字符或字符串的列表。

尽管差别比较精妙,但只是当你想要传递列表给那些函数的时候,才用担心一下细节问题。
而二进制串(binaries)表示了底层的bytes字节列表,这种表示已经是raw的了。