16. 文件和进程

当涉及文件操作时,本章中不少的解决方案都直接使用了相关的Java类,但对于某些情况,scala.io.Source类及其伴生对象有着比Java更简洁的文件操作。Source 不仅能让你轻松打开和读取文本文件,而且还可以轻松完成许多其他任务,比如说从URL下载内容,又或将一个字符串视为一个文件。

本章中将会有以下内容与文件操作有关:

  • 读取和写入文本和二进制文件。

  • scala.util.UsingLoan Pattern来自动关闭资源。

  • 处理文件中的每一个字符。

  • 将一个String视为一个File,通常在测试中使用。

  • 将对象序列化和反序列化到文件中。

  • 列出文件和目录。

接下来,会有涉及到和进程交互的例子,在Scala中 process类的设计很有DSL风格,以便用一种类似于操作Unix指令的方式运行系统命令。运行系统命令的能力对于编写应用程序很实用,对于编写脚本也是十分便利。

scala.sys.process包中的类和方法可以让你从Scala中运行外部系统命令,代码如下:

    val result: String = "ls -al".!!
    val result = Seq("ls", "-al").!!
    val rootProcs = ("ps aux" #| "grep root").!!.trim
    val contents: LazyList[String] =
        sys.process.Process("find /Users -print").lazyLines

Scala进程的DSL提供了五种运行external commands的方式:

  • 使用run方法来异步运行外部命令,方法结束时检查它的退出状态码。

  • 使用 ! 方法来运行命令,并在等待返回退出状态码时进行阻塞。

  • 使用 !! 方法来运行命令,并在等待返回输出时进行阻塞。

  • 使用lazyLines方法来异步运行命令,并将其结果作为 LazyList[String] 返回。

  • 如果退出状态码不是0,lazyLines将抛出一个异常,所以如果你不想这样,请使用lazy Lines_!

本章中将会有以下内容与进程操作有关:

  • 运行外部命令并访问退出状态码和输出结果。

  • 同步和异步地运行这些命令。

  • 从这些命令中访问STDOUTSTDERR

  • 运行命令管道和使用通配符。

  • 在不同的目录下运行命令,并为其配置环境变量。

与运行环境交互的边界 -- TODO 正方体框

从概念上讲,需要重视关于这些类使用的一些限制,在进程库的Scala文档中有相关说明( https://oreil.ly/EvyzU ):

整个包的底层基础是Java的ProcessProcessBuilder类。虽然不会直接使用这些Java类,但还是需要了解它们在使用时可能存在的限制。例如,不能为正在运行的进程查找其进程ID。

如果你想知道某些操作为什么会这么设计,则牢记这一点。

16.1 读取文本文件

问题

你想打开一个文本文件并处理文件中的每一行数据。

解决方案

在Scala中打开和读取文本文件的方法有很多,其中很多方法都使用了Java库,但本小节中展示的解决方案主要使用scala.io.Source打开和读取文本文件。

本小节展示了两种Source方案的使用方式:

  • 简洁的单行语法。这有一个副作用,就是会使文件处于打开状态,但在运行时间短的程序中可能会很有用,比如shell脚本。

  • 一个较长的方法,能正确地关闭文件。

使用简明的语法

在Scala shell脚本中,JVM在相对较短的时间内启动和停止,文件是否关闭可能并不重要,因此可以使用Scala scala.io.Source.fromFile方法。例如,在读取文件时处理文件中的每一行,可以使用这种方法:

还有一个变种,使用下面方法从文件中获取所有的行作为ListString

fromFile方法返回scala.io.BufferedSource,Scaladoc说getLines方法将 “任何的 \r, 或 视为行分隔符(最长匹配)”,所以如果是List情况下,序列中的每个元素都是文件中的一行。

这种方法有一个副作用,就是只要JVM在运行,文件就会一直保持打开状态,但对于运行时间短的Shell脚本来说,这应该不是一个问题;因为当JVM关闭时,文件就会关闭。

正确地关闭文件和处理异常情况

为了正确地关闭文件和处理异常,请使用scala.util.Using对象,它可以自动关闭资源。如果你想把一个文件读入一个序列,请使用以下方法之一:

读取文件时处理每一行,使用这种方法:

如上面代码中的第一个注释所示,如果你想处理文件中的每个字符,就使用for循环中的line

使用scala.util.Using的好处是它会自动关闭资源。Using对象实现了贷出(Loan)模式,其基本过程是:

  • 创建可以使用的资源。

  • 将资源贷出给其它代码。

  • 当其它代码使用完资源时,会自动关闭/销毁资源,例如通过自动调用BufferedSourceclose方法。

Using对象Scaladoc( https://oreil.ly/7iNzY )表明:“可以用来运行一个使用资源的操作,然后按照创建资源的相反顺序释放资源”。对于打开和关闭多个资源所需的方法,查看Scaladoc。

讨论

如上所述,第一个方案里,只要JVM在运行,文件就会一直保持打开状态:

在Unix系统中,你可以在程序运行时,在另一个终端运行lsof(列出打开的文件)命令来显示一个文件是否被打开。例如,可以使用下面三个lsof命令:

第一个命令列出所有打开的文件,这些文件的命令以 java 字符串开头,然后在输出中搜索 /etc/passwd 文件。如果文件名在输出中,这意味着它是打开的,所以你会看到类似这样的内容:

然后,当你关闭REPL —— 从而停止JVM进程时,你会看到该文件不再出现在lsof输出中。

自动关闭资源

当处理文件或者其他资源需要关闭时,最好使用贷出模式,正如第二个解决方案的例子所示。

在Scala中,也可以通过try/finally语句来保证关闭资源。在Scala 2的早期,我在第一版的Beginning Scala(Apress出版)的using方法中第一次看到这个解决方案的实现:

如上所示,resource被定义为参数,且必须有close()方法(否则代码将无法编译)。这个close()方法会在try块的finally语句中被调用。

很多io.Source方法

scala.io.Source对象( https://oreil.ly/USXcl )有许多方法用于从不同类型的来源中读取数据,包括:

  • 8个fromFile方法。

  • fromInputStream方法,从java.io.InputStream中读取。

  • fromIterable,通过Iterable创建Source

  • fromString,通过String创建Source

  • fromURI,从java.net.URI中读取。

  • 4个fromURL方法,从java.net.URL读取数据。

  • stdin,通过System.in创建Source

举一个例子,如果你想用指定的编码读取文件,可以这么写:

强大的Java集成 -- TODO 乌鸦图

由于Scala与Java的配合非常好,你可以使用Java FileReaderBufferedReader类,以及其他Java库,如Apache Commons FileUtils类来读取文件。

另见

  • scala.util.Usinghttps://oreil.ly/7iNzY )的Scaladoc。

  • scala.io.Sourcehttps://oreil.ly/USXcl )的Scaladoc。

  • 你也可以用Java BufferedReaderFileReader类来读取文本文件。我写了一篇博客,“用Scala读取大型文本文件的五个好方法(和两个坏方法)( https://oreil.ly/My1Fj )”。

16.2 写入文本文件

问题

你想写入纯文本文件,如文本数据文件,或其他纯文本文档。

解决方案

Scala并没有提供任何特殊的文件写入能力,所以退而求其次,可以使用常用的Java方法。下面是一个使用FileWriterBufferedWriter的例子,但忽略了可能的异常:

如果你需要将数据追加到一个文本文件中,在创建FileWriter时添加true参数:

你也可以使用java.nio类。这个例子展示了如何使用PathsFiles类将一个字符串写到一个文件中:

如果你有一个字符串,并且想写到文件中,这是个蛮方便的操作,因为它写入了整个字符串,然后关闭文件。这个例子展示了如何使用NIO类写入文件,然后追加一个 Seq[String] 到一个文本文件中,同时还展示了如何处理字符集:

这种方法在序列中的每个字符串之后添加一个换行符,因此生成的文件具有这些内容:

异常 -- TODO 鸽子图

所有文件读取和写入的代码都可能抛出异常,关于处理异常的代码请参阅16.1小节。

讨论

如果你没有给FileWriter指定字符集,它将使用平台默认的Charset。要控制Charset,可以使用FileWriter的这些构造函数:

有关可用选项的更多细节,参考FileWriterhttps://oreil.ly/c6W9f )和Charset Javadoc( https://oreil.ly/Wmwqi )页面。

如果你想在使用BufferedWriter时控制字符编码,可以使用OutputStreamWriterFileOutputStream来创建它,如下所示:

如果使用macOS,你可以用file命令的**-I**选项来确定文件的字符编码:

另见

更多细节参考这些Javadoc页面:

  • java.nio.file.Fileshttps://oreil.ly/XynNg

  • java.io.FileWriterhttps://oreil.ly/c6W9f

  • java.nio.charset.Charsethttps://oreil.ly/Wmwqi

16.3 读写二进制文件

问题

你想从二进制文件中读取数据,或者将数据写入二进制文件。

解决方案

Scala不提供任何特殊的读写二进制文件的能力,所以使用Java的FileInputStreamFileOutputStream类,以及它们的缓冲包装(buffered wrapper)类。

读取二进制文件

先忽略异常,这段代码展示了如何使用FileInputStreamBufferedInputStream来读取一个文件:

这个解决方案目的是读取二进制文件,但是只读取了一个纯文本文件,让你很方便地创建一个实验的例子。

这个解决方案的关键是要知道Iterator对象( https://oreil.ly/txhjo )有一个continually方法可以用来简化整个过程。根据其Scaladoc,continually “创建一个无限长的迭代器,并返回评估表达式的结果。该表达式对每一个元素都进行重新计算”。如果你愿意,也可以使用LazyList对象( https://oreil.ly/gOCWv )的continually方法,它具有同样的效果。

写入二进制文件

要写入二进制文件,使用FileOutputStreamBufferedOutputStream类,如下所示:

讨论

读写二进制文件的方法还有很多,但由于所有的解决方案都使用了Java类,可以在网上搜索不同的方法,或查看Ian F. Darwin(O'Reilly)的Java Cookbook

警告 -- TODO 蝎子图

你在网上找到的许多解决方案都不使用缓冲(buffering)功能。如果你要读写大文件,一定要使用缓冲。例如,当我用一个FileInputStream读取电脑上的一个包含65万行的Apache访问日志文件时,需要181秒来读取该文件。通过使用BufferedInputStream包装后的FileInputStream —— 正如解决方案中所示 —— 读取相同文件只需要1.6秒。

另见

  • Apache Commons FileUtils类( https://oreil.ly/RMHgF )有很多可以读写文件的方法。

16.4 将字符串伪装为文件

问题

通常为了使代码具有可测试性,你想将String伪装为文件。

解决方案

由于Scala.fromFileScala.fromString均继承自scala.io.Source,所以它们很容易互相转换。只要你的方法有一个Source引用,你就可以给它传递调用Source.fromfile获得BufferedSource,或者调用Source.fromString获得Source

例如,下面的方法接受一个Source对象,并输出它包含的行:

Source是从String构造时,也可以被调用:

Source是文件时,也可以被调用:

讨论

写单元测试时,假如有这样的方法需要测试:

在单元测试中,可以从FileString构造一个Source来测试getLinesUppercased方法:

总之,如果你倾向于使用String轻松进行函数测试而不是文件,那么将其定义为接受Source实例。

16.5 序列化和反序列化对象到文件

问题

你想序列化一个Scala类的实例,并把它保存为文件,或者通过网络发送。

解决方案

一般来讲,序列化的方式和Java是一样的,但是Scala类序列化的语法不同。要使Scala类可序列化,需要继承Serializable类型并给该类添加 @SerialVersionUID注解:

因为Serializable是一个类型 —— 技术上来说是java.io.Serializable的类型别名 —— 你可以把它混入一个类,即使该类已经继承了另外一个类:

把类标记为可序列化后,就可以使用和Java中相同的技术来读写对象,包括使用序列化的Java deep clone技术,在我的博客“Java Deep Clone (Deep Copy) Example”( https://oreil.ly/NyY5t )中讨论了这个问题。

讨论

下面的代码展示了完整的深拷贝方法,但忽略了可能的异常。代码中的注释解释了这个过程:

这段代码运行时输出如下:

序列化正在消失(在某种程度上) -- TODO 鸽子图

需要注意的是,目前Java中的序列化形式可能会在未来的某个时刻消失。ADTmag在2018年的一篇文章中讨论了这个问题,“从Java中移除序列化是Oracle的长期目标”( https://oreil.ly/CS3jn )。

16.6 列出目录中的文件

问题

你想在目录中创建文件或子目录,并且使用过滤器做点限制。

解决方案

Scala不提供任何特殊的操作目录的方法,而是使用Java File类的listFiles方法。例如,下面方法获取了一个目录中的所有文件的列表:

这个算法的作用如下:

  • 使用File类的listFiles方法将dir中的所有文件生成一个 Array[File]

  • 使用filter来调整列表,使其只包含文件。

  • 使用map在每个文件上调用getName,从而返回一个文件名数组(而不是File实例)。

  • 使用toList将其转换成List[String]

如果目录中没有文件,该函数返回一个空的列表 —— List(),如果目录中有一个或多个文件,则返回一个 List[File]REPL展示了这一点:

同样地,这种方法创建了dir下所有目录的列表:

要列出所有的文件和目录,从代码中删除 filter(_.isFile) 这一行:

讨论

如果你想根据文件的扩展名来限制返回的文件列表,在Java中,可以实现一个带有accept方法的FileFilter来过滤返回的文件名。

在Scala中,你可以在不需要FileFilter的情况下编写类似的代码。假设File代表一个已知存在的目录,下面的函数展示了根据返回文件的扩展名,从而过滤很多文件:

作为一个例子,你可以按下面的方式调用这个函数,从而列出给定目录中所有的 .wav.mp3 文件:

对于更多关于文件和目录的使用场景,Apache Commons IO( https://oreil.ly/EqmsR )库的FileUtilshttps://oreil.ly/RMHgF )类是一个很好的解决方案。它有几十个方法用于处理文件和目录。

另见

如果你想深入了解并遍历整个目录树,同时处理该树中的每一个文件和目录,请参阅我的博客,“Scala: How to Search a Directory Tree with SimpleFileVisitor and Files.walkFileTree” ( https://oreil.ly/SEyxg )。我用这种方法写了Scala FileFind命令行工具( https://oreil.ly/K5FKT )。

16.7 执行外部命令

问题

你想在Scala的应用程序中执行external(系统)命令。你不关心该命令的输出,但是关心退出状态码。

解决方案

有两种方式:

  • 使用 ! 方法来执行命令,并在等待返回退出状态码时进行阻塞。

  • 使用run方法来异步执行外部命令,当它运行结束时获取其退出状态码。

使用!方法

要执行一个命令并等待(阻塞)以获得其退出状态码,请导入必要的成员,将所需的命令放在一个字符串中,然后用 ! 方法运行它:

在Unix系统中,退出状态码为0意味着命令执行成功,而非0的退出状态码意味着存在问题。例如,如果你试图对一个不存在的文件使用ls命令,你会得到退出状态码为1

输出显示,执行命令后exitStatus为1。

正如你在下面例子看到的,可以在小数点后调用 ! 方法:

你也可以在空格后调用它,同时导入这个import语句:

所有这些命令都假定你已经导入了sys.process.*。注意如果你的命令执行失败,它会抛出异常:

因此,一定要用Try这样的错误处理类型来包装它:

正如讨论中所示,你还可以使用Seq运行系统命令:

异步运行外部命令

你可以使用run方法异步(asynchronously)地执行一个外部命令:

通过这种方法,外部命令立即开始运行,你可以在产生的process对象上调用下面方法:

  • isAlive返回true,说明进程在运行中,否则返回false

  • destroy可以终止一个正在运行的进程。

  • exitStatus可以得到命令的退出状态码。

REPL中有一个长时间运行的例子展示了这是如何工作的:

当使用这种方法时,在isAlivefalse之前不要调用exitValue。如果在进程运行时调用exitValue,方法会阻塞直到进程运行结束。

讨论

除了在一个字符串后面调用 ! 外,你还可以在一个Seq后面调用 ! 。尤其当你有很多不同的命令参数时,这特别有用。

在使用Seq时,第一个元素是执行命令的名称,随后的元素是执行命令的参数,如下面例子中所示:

我省略了这些例子的输出,但每个命令输出的内容,和你在Unix命令行中执行ls得到的一样。

使用进程

如果你不喜欢使用StringSeq的隐式转换,你可以创建一个Process对象来执行外部命令:

注意空格

当执行这些命令时,要注意你的命令和参数旁边的空格。下面所有的例子都因为有多余的空格而失败:

如果你是自己输入字符串,就不要有空格,如果你是从用户输入中得到字符串,一定要去除空格。

外部命令与系统命令

最后要说明的是,你可以在Scala运行任何可以在Unix命令行运行的外部命令。但是,外部命令和shell系统命令之间有很大的区别。ls命令是一个可以在所有Unix系统上使用的外部命令,可以在 /bin 目录下找到它:

其他可以在Unix上执行的命令 —— 如cdfor —— 实际上是内置在你的shell中,如Bash shell。你无法在文件系统中找到它们的文件。因此不能执行这些命令,除非它们在shell中执行。参见16.10小节,了解如何执行shell系统命令。

16.8 执行外部命令和读取标准输出

问题

你想执行一个外部命令,然后在Scala程序中使用这个进程的标准输出(STDOUT)。

解决方案

任意使用下面一种方法来访问命令的STDOUT

  • 使用 !! 方法同步执行命令,并在等待接收其输出为String时阻塞。

  • 使用lazyLines方法异步执行命令,并将其结果作为 LazyList[String] 返回。

  • 如果命令退出状态码为不是0,当尝试使用它的结果时,lazyLines会抛出异常,想要避免这种情况,可以使用lazyLines_! (也可以使用ProcessLogger)。

!!的同步解决方案

就像上一个小节中的 ! 命令一样,你可以在字符串后面使用 !! 来执行命令,返回的是命令的STDOUT,而不是命令的退出状态码。返回结果是一个多行字符串,所以你可以在应用程序中进行处理。

忽略异常处理,这个例子展示了如何在String中使用 !!

命令的输出是一个多行字符串,开头是这样的:

对于更复杂的情况,你想添加错误处理,可以使用 Try/Success/Failure 类:

假设当前目录下没有一个名为fred的文件,代码的输出结果将是:

除了在字符串中使用 !! 外,你还可以在Seq中使用它:

lazyLines的异步解决方案

使用lazyLines方法来异步执行一个命令,并将其STDOUT作为 LazyList[String] 来访问。例如,这个命令在Unix系统上可能会运行很长时间,这也许会导致成千上万行的输出:

一旦在REPL中执行这行代码,Unix find命令就开始运行。你不会在REPL中看到任何输出,但你可以看到它正在运行,通过打开另一个终端窗口并运行Unix top命令,或ps命令:

如输出所示,find命令确实正在运行,这个例子中进程ID(PID)是19837。

在REPL中,你可以从LazyList中读取并按需要处理命令的输出,比如用foreach

lazyLines_!的异步解决方案

如果命令的退出状态码为不是0,然后你尝试使用产生的LazyListlazyLines将抛出一个异常。例如,这个带有 lazyLines 的命令将 "No such file" 输出写入到STDERR

如果你试图访问x的内容,则会抛出异常:

通过使用lazyLines_! 而不是lazyLines,你可以避免使用x时的异常:

如果需要,可以用下面方法阻止STDERR输出:

Seq和进程

如果你不喜欢在StringSeq上使用隐式方法,可以使用Process

16.9 处理命令的标准输出和标准错误输出

问题

你想执行一个外部命令,并且访问其标准输出(STDOUT)和标准错误(STDERR)。

解决方案

最简单的方法是按照前面小节说的执行命令,然后用ProcessLogger捕获输出。下面代码展示了这种方法:

我在当前目录下有几个文件,但没有一个文件叫cookie。当我执行这个脚本时,看到了这样的输出:

因为我使用了 ! 方法,所以执行这个脚本后,status变量包含了命令的退出状态码,1代表文件cookie不存在。stdout变量包含了命令的STDOUT,在这个例子中,它包含了命令的 ls . 部分的输出。如果发生问题,stderr变量会包含命令的STDERR,在上面命令中,把执行的命令同时写到STDOUTSTDERR,所以stdoutstderr都会包含数据。

确保了解退出状态码的含义 -- TODO 耗子图

注意,当find命令执行后但没有找到文件时,仍然可以返回退出状态码为0。这种状态仅仅意味着find命令按预期运行,没有异常。要经常在命令行中检查系统命令的退出状态码,以确定它们的状态码是如何工作的:

讨论

下面是另一个使用Seq同时往STDOUTSTDERR写入的例子:

当你看到这两个例子中的任何一个时,都会对这段代码的作用感到惊讶:

这样做的原因是scala.sys.process的作者创建了一个小的DSL。在第一个例子中,如下代码:

被编译器变成了这段代码:

之所以工作是因为:

  • ! 方法作为隐式转换添加到String中。

  • ! 方法有一个重载的构造函数,方法签名是 !(log: ProcessLogger): Int

虽然使用这种符号时可能有点难以理解,但如果 ! 方法被命名为exec,那么这段代码将看起来是这样:

我发现拼写方法调用是另一种方法,使这段代码更易于阅读和维护:

还要注意的是, 随着你的需求变化,写入STDOUTSTDERR会很容易会得复杂起来。Scaladoc声明:“如果希望完全控制输入和输出,那么ProcessIO可以与run一起使用”。有关ProcessLoggerProcessIO类的更多示例,参考scala.sys.process 的API文档。

另见

  • 参阅ProcessBuilder Scaladoc( https://oreil.ly/zv3JK )了解更多关于方法 !!!run的细节。

16.10 构建外部命令管道

问题

你想执行一系列的外部命令,将一个命令的输出重定向作为另一个命令的输入,也就是说,通过pipe将这些命令连接在一起。

解决方案

使用 #| 方法把一个命令输出重定向到另一个命令的输入。这样做时,在管道的末端使用run!!!lazyLineslazyLines_! 等方法来运行完整的一系列外部命令。

下面的例子展示了 !! 方法,ps命令的输出通过管道成为wc命令的输入:

因为ps命令的输出是通过管道进入grep,然后进入linecount命令(wc -l),该代码会打印出在Unix系统上运行的进程数量,这些进程的ps输出中有root这个字符串。同样,下面命令创建了一个包含root字符串的所有运行的进程列表:

讨论

如果你有Unix背景,那么 #| 方法是有意义的,因为它就像Unix的管道符号(|),但前面有一个 # 字符。事实上,除了 ### 操作符(用来代替Unix的 ; 符号)之外,Scala进程库通常和Unix命令保持一致。

没有shell,字符串里的管道不能运行

试图在String中用管道连接命令,然后用 !!! 执行这些命令是不行的:

不行是因为管道的能力来自于shell(Bourne shell、Bash等),而当你执行这样的命令时,并没有shell。

要在shell(例如Bourne shell)中执行一系列的命令,可以使用一个带有多个参数的Seq,如下所示:

正如本节所述,这种方法在Bourne shell实例中执行 ps aux | grep root 命令。

最后更新于

这有帮助吗?