16. 文件和进程
当涉及文件操作时,本章中不少的解决方案都直接使用了相关的Java类,但对于某些情况,scala.io.Source类及其伴生对象有着比Java更简洁的文件操作。Source 不仅能让你轻松打开和读取文本文件,而且还可以轻松完成许多其他任务,比如说从URL下载内容,又或将一个字符串视为一个文件。
本章中将会有以下内容与文件操作有关:
读取和写入文本和二进制文件。
用scala.util.Using的Loan 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").lazyLinesScala进程的DSL提供了五种运行external commands的方式:
使用run方法来异步运行外部命令,方法结束时检查它的退出状态码。
使用 ! 方法来运行命令,并在等待返回退出状态码时进行阻塞。
使用 !! 方法来运行命令,并在等待返回输出时进行阻塞。
使用lazyLines方法来异步运行命令,并将其结果作为 LazyList[String] 返回。
如果退出状态码不是0,lazyLines将抛出一个异常,所以如果你不想这样,请使用lazy Lines_!。
本章中将会有以下内容与进程操作有关:
运行外部命令并访问退出状态码和输出结果。
同步和异步地运行这些命令。
从这些命令中访问STDOUT和STDERR。
运行命令管道和使用通配符。
在不同的目录下运行命令,并为其配置环境变量。
与运行环境交互的边界 -- TODO 正方体框
从概念上讲,需要重视关于这些类使用的一些限制,在进程库的Scala文档中有相关说明( https://oreil.ly/EvyzU ):
整个包的底层基础是Java的Process和ProcessBuilder类。虽然不会直接使用这些Java类,但还是需要了解它们在使用时可能存在的限制。例如,不能为正在运行的进程查找其进程ID。
如果你想知道某些操作为什么会这么设计,则牢记这一点。
16.1 读取文本文件
问题
你想打开一个文本文件并处理文件中的每一行数据。
解决方案
在Scala中打开和读取文本文件的方法有很多,其中很多方法都使用了Java库,但本小节中展示的解决方案主要使用scala.io.Source打开和读取文本文件。
本小节展示了两种Source方案的使用方式:
简洁的单行语法。这有一个副作用,就是会使文件处于打开状态,但在运行时间短的程序中可能会很有用,比如shell脚本。
一个较长的方法,能正确地关闭文件。
使用简明的语法
在Scala shell脚本中,JVM在相对较短的时间内启动和停止,文件是否关闭可能并不重要,因此可以使用Scala scala.io.Source.fromFile方法。例如,在读取文件时处理文件中的每一行,可以使用这种方法:
还有一个变种,使用下面方法从文件中获取所有的行作为List或String:
fromFile方法返回scala.io.BufferedSource,Scaladoc说getLines方法将 “任何的 \r, 或 视为行分隔符(最长匹配)”,所以如果是List情况下,序列中的每个元素都是文件中的一行。
这种方法有一个副作用,就是只要JVM在运行,文件就会一直保持打开状态,但对于运行时间短的Shell脚本来说,这应该不是一个问题;因为当JVM关闭时,文件就会关闭。
正确地关闭文件和处理异常情况
为了正确地关闭文件和处理异常,请使用scala.util.Using对象,它可以自动关闭资源。如果你想把一个文件读入一个序列,请使用以下方法之一:
读取文件时处理每一行,使用这种方法:
如上面代码中的第一个注释所示,如果你想处理文件中的每个字符,就使用for循环中的line。
使用scala.util.Using的好处是它会自动关闭资源。Using对象实现了贷出(Loan)模式,其基本过程是:
创建可以使用的资源。
将资源贷出给其它代码。
当其它代码使用完资源时,会自动关闭/销毁资源,例如通过自动调用BufferedSource的close方法。
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 FileReader和BufferedReader类,以及其他Java库,如Apache Commons FileUtils类来读取文件。
另见
scala.util.Using ( https://oreil.ly/7iNzY )的Scaladoc。
scala.io.Source( https://oreil.ly/USXcl )的Scaladoc。
你也可以用Java BufferedReader和FileReader类来读取文本文件。我写了一篇博客,“用Scala读取大型文本文件的五个好方法(和两个坏方法)( https://oreil.ly/My1Fj )”。
16.2 写入文本文件
问题
你想写入纯文本文件,如文本数据文件,或其他纯文本文档。
解决方案
Scala并没有提供任何特殊的文件写入能力,所以退而求其次,可以使用常用的Java方法。下面是一个使用FileWriter和BufferedWriter的例子,但忽略了可能的异常:
如果你需要将数据追加到一个文本文件中,在创建FileWriter时添加true参数:
你也可以使用java.nio类。这个例子展示了如何使用Paths和Files类将一个字符串写到一个文件中:
如果你有一个字符串,并且想写到文件中,这是个蛮方便的操作,因为它写入了整个字符串,然后关闭文件。这个例子展示了如何使用NIO类写入文件,然后追加一个 Seq[String] 到一个文本文件中,同时还展示了如何处理字符集:
这种方法在序列中的每个字符串之后添加一个换行符,因此生成的文件具有这些内容:
异常 -- TODO 鸽子图
所有文件读取和写入的代码都可能抛出异常,关于处理异常的代码请参阅16.1小节。
讨论
如果你没有给FileWriter指定字符集,它将使用平台默认的Charset。要控制Charset,可以使用FileWriter的这些构造函数:
有关可用选项的更多细节,参考FileWriter( https://oreil.ly/c6W9f )和Charset Javadoc( https://oreil.ly/Wmwqi )页面。
如果你想在使用BufferedWriter时控制字符编码,可以使用OutputStreamWriter和FileOutputStream来创建它,如下所示:
如果使用macOS,你可以用file命令的**-I**选项来确定文件的字符编码:
另见
更多细节参考这些Javadoc页面:
java.nio.file.Files( https://oreil.ly/XynNg )
java.io.FileWriter( https://oreil.ly/c6W9f )
java.nio.charset.Charset( https://oreil.ly/Wmwqi )
16.3 读写二进制文件
问题
你想从二进制文件中读取数据,或者将数据写入二进制文件。
解决方案
Scala不提供任何特殊的读写二进制文件的能力,所以使用Java的FileInputStream和FileOutputStream类,以及它们的缓冲包装(buffered wrapper)类。
读取二进制文件
先忽略异常,这段代码展示了如何使用FileInputStream和BufferedInputStream来读取一个文件:
这个解决方案目的是读取二进制文件,但是只读取了一个纯文本文件,让你很方便地创建一个实验的例子。
这个解决方案的关键是要知道Iterator对象( https://oreil.ly/txhjo )有一个continually方法可以用来简化整个过程。根据其Scaladoc,continually “创建一个无限长的迭代器,并返回评估表达式的结果。该表达式对每一个元素都进行重新计算”。如果你愿意,也可以使用LazyList对象( https://oreil.ly/gOCWv )的continually方法,它具有同样的效果。
写入二进制文件
要写入二进制文件,使用FileOutputStream和BufferedOutputStream类,如下所示:
讨论
读写二进制文件的方法还有很多,但由于所有的解决方案都使用了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.fromFile和Scala.fromString均继承自scala.io.Source,所以它们很容易互相转换。只要你的方法有一个Source引用,你就可以给它传递调用Source.fromfile获得BufferedSource,或者调用Source.fromString获得Source。
例如,下面的方法接受一个Source对象,并输出它包含的行:
当Source是从String构造时,也可以被调用:
当Source是文件时,也可以被调用:
讨论
写单元测试时,假如有这样的方法需要测试:
在单元测试中,可以从File或String构造一个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 )库的FileUtils ( https://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中有一个长时间运行的例子展示了这是如何工作的:
当使用这种方法时,在isAlive为false之前不要调用exitValue。如果在进程运行时调用exitValue,方法会阻塞直到进程运行结束。
讨论
除了在一个字符串后面调用 ! 外,你还可以在一个Seq后面调用 ! 。尤其当你有很多不同的命令参数时,这特别有用。
在使用Seq时,第一个元素是执行命令的名称,随后的元素是执行命令的参数,如下面例子中所示:
我省略了这些例子的输出,但每个命令输出的内容,和你在Unix命令行中执行ls得到的一样。
使用进程
如果你不喜欢使用String或Seq的隐式转换,你可以创建一个Process对象来执行外部命令:
注意空格
当执行这些命令时,要注意你的命令和参数旁边的空格。下面所有的例子都因为有多余的空格而失败:
如果你是自己输入字符串,就不要有空格,如果你是从用户输入中得到字符串,一定要去除空格。
外部命令与系统命令
最后要说明的是,你可以在Scala运行任何可以在Unix命令行运行的外部命令。但是,外部命令和shell系统命令之间有很大的区别。ls命令是一个可以在所有Unix系统上使用的外部命令,可以在 /bin 目录下找到它:
其他可以在Unix上执行的命令 —— 如cd或for —— 实际上是内置在你的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,然后你尝试使用产生的LazyList,lazyLines将抛出一个异常。例如,这个带有 lazyLines 的命令将 "No such file" 输出写入到STDERR:
如果你试图访问x的内容,则会抛出异常:
通过使用lazyLines_! 而不是lazyLines,你可以避免使用x时的异常:
如果需要,可以用下面方法阻止STDERR输出:
Seq和进程
如果你不喜欢在String或Seq上使用隐式方法,可以使用Process:
16.9 处理命令的标准输出和标准错误输出
问题
你想执行一个外部命令,并且访问其标准输出(STDOUT)和标准错误(STDERR)。
解决方案
最简单的方法是按照前面小节说的执行命令,然后用ProcessLogger捕获输出。下面代码展示了这种方法:
我在当前目录下有几个文件,但没有一个文件叫cookie。当我执行这个脚本时,看到了这样的输出:
因为我使用了 ! 方法,所以执行这个脚本后,status变量包含了命令的退出状态码,1代表文件cookie不存在。stdout变量包含了命令的STDOUT,在这个例子中,它包含了命令的 ls . 部分的输出。如果发生问题,stderr变量会包含命令的STDERR,在上面命令中,把执行的命令同时写到STDOUT和STDERR,所以stdout和stderr都会包含数据。
确保了解退出状态码的含义 -- TODO 耗子图
注意,当find命令执行后但没有找到文件时,仍然可以返回退出状态码为0。这种状态仅仅意味着find命令按预期运行,没有异常。要经常在命令行中检查系统命令的退出状态码,以确定它们的状态码是如何工作的:
讨论
下面是另一个使用Seq同时往STDOUT和STDERR写入的例子:
当你看到这两个例子中的任何一个时,都会对这段代码的作用感到惊讶:
这样做的原因是scala.sys.process的作者创建了一个小的DSL。在第一个例子中,如下代码:
被编译器变成了这段代码:
之所以工作是因为:
! 方法作为隐式转换添加到String中。
! 方法有一个重载的构造函数,方法签名是 !(log: ProcessLogger): Int。
虽然使用这种符号时可能有点难以理解,但如果 ! 方法被命名为exec,那么这段代码将看起来是这样:
我发现拼写方法调用是另一种方法,使这段代码更易于阅读和维护:
还要注意的是, 随着你的需求变化,写入STDOUT和STDERR会很容易会得复杂起来。Scaladoc声明:“如果希望完全控制输入和输出,那么ProcessIO可以与run一起使用”。有关ProcessLogger和ProcessIO类的更多示例,参考scala.sys.process 的API文档。
另见
参阅ProcessBuilder Scaladoc( https://oreil.ly/zv3JK )了解更多关于方法 ! ,!! 和run的细节。
16.10 构建外部命令管道
问题
你想执行一系列的外部命令,将一个命令的输出重定向作为另一个命令的输入,也就是说,通过pipe将这些命令连接在一起。
解决方案
使用 #| 方法把一个命令输出重定向到另一个命令的输入。这样做时,在管道的末端使用run、!、!!、lazyLines或 lazyLines_! 等方法来运行完整的一系列外部命令。
下面的例子展示了 !! 方法,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 命令。
最后更新于
这有帮助吗?