当涉及文件操作时,本章中不少的解决方案都直接使用了相关的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").lazyLines
Scala进程的DSL提供了五种运行external commands的方式:
- 使用run方法来异步运行外部命令,方法结束时检查它的退出状态码。
- 使用 ! 方法来运行命令,并在等待返回退出状态码时进行阻塞。
- 使用 !! 方法来运行命令,并在等待返回输出时进行阻塞。
- 使用lazyLines方法来异步运行命令,并将其结果作为 LazyList[String] 返回。
- 如果退出状态码不是0,lazyLines将抛出一个异常,所以如果你不想这样,请使用lazy Lines_!。
本章中将会有以下内容与进程操作有关:
- 运行外部命令并访问退出状态码和输出结果。
- 同步和异步地运行这些命令。
- 从这些命令中访问STDOUT和STDERR。
- 运行命令管道和使用通配符。
- 在不同的目录下运行命令,并为其配置环境变量。
从概念上讲,需要重视关于这些类使用的一些限制,在进程库的Scala文档中有相关说明( https://oreil.ly/EvyzU ):
整个包的底层基础是Java的Process和ProcessBuilder类。虽然不会直接使用这些Java类,但还是需要了解它们在使用时可能存在的限制。例如,不能为正在运行的进程查找其进程ID。
如果你想知道某些操作为什么会这么设计,则牢记这一点。
你想打开一个文本文件并处理文件中的每一行数据。
在Scala中打开和读取文本文件的方法有很多,其中很多方法都使用了Java库,但本小节中展示的解决方案主要使用scala.io.Source打开和读取文本文件。
本小节展示了两种Source方案的使用方式:
- 简洁的单行语法。这有一个副作用,就是会使文件处于打开状态,但在运行时间短的程序中可能会很有用,比如shell脚本。
- 一个较长的方法,能正确地关闭文件。
在Scala shell脚本中,JVM在相对较短的时间内启动和停止,文件是否关闭可能并不重要,因此可以使用Scala scala.io.Source.fromFile方法。例如,在读取文件时处理文件中的每一行,可以使用这种方法:
import scala.io.Source
for line <- Source.fromFile("/etc/passwd").getLines do
// do whatever you need to do with each line in the file
println(line)
还有一个变种,使用下面方法从文件中获取所有的行作为List或String:
val linesAsList = Source.fromFile("/etc/passwd").getLines.toList
val linesAsString = Source.fromFile("/etc/passwd").getLines.mkString
fromFile方法返回scala.io.BufferedSource,Scaladoc说getLines方法将 “任何的 \r\n,\r 或 \n 视为行分隔符(最长匹配)”,所以如果是List情况下,序列中的每个元素都是文件中的一行。
这种方法有一个副作用,就是只要JVM在运行,文件就会一直保持打开状态,但对于运行时间短的Shell脚本来说,这应该不是一个问题;因为当JVM关闭时,文件就会关闭。
为了正确地关闭文件和处理异常,请使用scala.util.Using对象,它可以自动关闭资源。如果你想把一个文件读入一个序列,请使用以下方法之一:
import scala.util.Using
import scala.util.{Try, Success, Failure}
def readFileAsSeq(filename: String): Try[Seq[String]] =
Using(io.Source.fromFile(filename)) { bufferedSource =>
bufferedSource.getLines.toList
}
def readFileAsSeq(filename: String): Try[Seq[String]] =
Using(io.Source.fromFile(filename)) { _.getLines.toList }
读取文件时处理每一行,使用这种方法:
def readFileAsSeq(filename: String): Try[Seq[String]] =
Using(io.Source.fromFile(filename)) { bufferedSource =>
val ucLines = for
line <- bufferedSource.getLines
// 'line' is a String. can work with each Char here,
// if desired, like this:
// char <- line
yield
// work with each 'line' as a String here
line.toUpperCase
ucLines.toSeq
}
如上面代码中的第一个注释所示,如果你想处理文件中的每个字符,就使用for循环中的line。
使用scala.util.Using的好处是它会自动关闭资源。Using对象实现了贷出(Loan)模式,其基本过程是:
- 创建可以使用的资源。
- 将资源贷出给其它代码。
- 当其它代码使用完资源时,会自动关闭/销毁资源,例如通过自动调用BufferedSource的close方法。
Using对象Scaladoc( https://oreil.ly/7iNzY )表明:“可以用来运行一个使用资源的操作,然后按照创建资源的相反顺序释放资源”。对于打开和关闭多个资源所需的方法,查看Scaladoc。
如上所述,第一个方案里,只要JVM在运行,文件就会一直保持打开状态:
// leaves the file open
for (line <- io.Source.fromFile("/etc/passwd").getLines)
println(line)
// also leaves the file open
val contents = io.Source.fromFile("/etc/passwd").mkString
在Unix系统中,你可以在程序运行时,在另一个终端运行lsof(列出打开的文件)命令来显示一个文件是否被打开。例如,可以使用下面三个lsof命令:
lsof -c java | grep '/etc/passwd'
sudo lsof /etc/passwd
sudo lsof -u Al | grep '/etc/passwd'
第一个命令列出所有打开的文件,这些文件的命令以 java 字符串开头,然后在输出中搜索 /etc/passwd 文件。如果文件名在输出中,这意味着它是打开的,所以你会看到类似这样的内容:
java 17148 Al 40r REG 14,2 1475 174214161 /etc/passwd
然后,当你关闭REPL —— 从而停止JVM进程时,你会看到该文件不再出现在lsof输出中。
当处理文件或者其他资源需要关闭时,最好使用贷出模式,正如第二个解决方案的例子所示。
在Scala中,也可以通过try/finally语句来保证关闭资源。在Scala 2的早期,我在第一版的Beginning Scala(Apress出版)的using方法中第一次看到这个解决方案的实现:
// a Scala 2 approach (circa 2009)
import scala.language.reflectiveCalls
object Control:
def using[A <: { def close(): Unit }, B](resource: A)(fun: A => B): B =
try
fun(resource)
finally
resource.close()
如上所示,resource被定义为参数,且必须有close()方法(否则代码将无法编译)。这个close()方法会在try块的finally语句中被调用。
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。
举一个例子,如果你想用指定的编码读取文件,可以这么写:
Source.fromFile("example.txt", "UTF-8")
由于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 )”。
你想写入纯文本文件,如文本数据文件,或其他纯文本文档。
Scala并没有提供任何特殊的文件写入能力,所以退而求其次,可以使用常用的Java方法。下面是一个使用FileWriter和BufferedWriter的例子,但忽略了可能的异常:
import java.io.{BufferedWriter, File, FileWriter}
// FileWriter
val file = File("hello.txt")
val bw = BufferedWriter(FileWriter(file))
bw.write("Hello, world\n")
bw.write("It’s Al")
bw.close()
如果你需要将数据追加到一个文本文件中,在创建FileWriter时添加true参数:
val bw = BufferedWriter(FileWriter("notes.txt", true))
----
你也可以使用java.nio类。这个例子展示了如何使用Paths和Files类将一个字符串写到一个文件中:
import java.nio.file.{Files,Paths}
val text = "Hello, world"
val filepath = Paths.get("nio_paths_files.txt")
Files.write(filepath, text.getBytes)
如果你有一个字符串,并且想写到文件中,这是个蛮方便的操作,因为它写入了整个字符串,然后关闭文件。这个例子展示了如何使用NIO类写入文件,然后追加一个 Seq[String] 到一个文本文件中,同时还展示了如何处理字符集:
import java.nio.file.{Files,Paths,StandardOpenOption}
import java.nio.charset.StandardCharsets
import scala.collection.JavaConverters.*
// WRITE
val seq1 = Seq("Hello", "world")
val filepath = Paths.get("paths_seq.txt")
Files.write(filepath, seq1.asJava)
// APPEND
val seq2 = Seq("It’s", "Al")
Files.write(
filepath,
seq2.asJava,
StandardCharsets.UTF_8,
StandardOpenOption.APPEND
)
这种方法在序列中的每个字符串之后添加一个换行符,因此生成的文件具有这些内容:
Hello
world
It’s
Al
所有文件读取和写入的代码都可能抛出异常,关于处理异常的代码请参阅16.1小节。
如果你没有给FileWriter指定字符集,它将使用平台默认的Charset。要控制Charset,可以使用FileWriter的这些构造函数:
FileWriter(File file, Charset charset)
FileWriter(File file, Charset charset, boolean append)
FileWriter(String fileName, Charset charset)
FileWriter(String fileName, Charset charset, boolean append)
有关可用选项的更多细节,参考FileWriter( https://oreil.ly/c6W9f )和Charset Javadoc( https://oreil.ly/Wmwqi )页面。
如果你想在使用BufferedWriter时控制字符编码,可以使用OutputStreamWriter和FileOutputStream来创建它,如下所示:
import java.io.*
import java.nio.charset.StandardCharsets
val bw = BufferedWriter(
OutputStreamWriter(
FileOutputStream("file.txt"),
StandardCharsets.UTF_8
)
)
bw.write("Hello, world\n")
bw.write("It’s Al")
bw.close()
如果使用macOS,你可以用file命令的**-I**选项来确定文件的字符编码:
$ file -I file.txt
file.txt: text/plain; charset=utf-8
更多细节参考这些Javadoc页面:
- java.nio.file.Files( https://oreil.ly/XynNg )
- java.io.FileWriter( https://oreil.ly/c6W9f )
- java.nio.charset.Charset( https://oreil.ly/Wmwqi )
你想从二进制文件中读取数据,或者将数据写入二进制文件。
Scala不提供任何特殊的读写二进制文件的能力,所以使用Java的FileInputStream和FileOutputStream类,以及它们的缓冲包装(buffered wrapper)类。
先忽略异常,这段代码展示了如何使用FileInputStream和BufferedInputStream来读取一个文件:
import java.io.{FileInputStream, BufferedInputStream}
val bis = new BufferedInputStream(FileInputStream("/etc/passwd"))
Iterator.continually(bis.read())
.takeWhile(_ != -1)
.foreach(b => print(b.toChar)) // print the Char values
bis.close
这个解决方案目的是读取二进制文件,但是只读取了一个纯文本文件,让你很方便地创建一个实验的例子。
这个解决方案的关键是要知道Iterator对象( https://oreil.ly/txhjo )有一个continually方法可以用来简化整个过程。根据其Scaladoc,continually “创建一个无限长的迭代器,并返回评估表达式的结果。该表达式对每一个元素都进行重新计算”。如果你愿意,也可以使用LazyList对象( https://oreil.ly/gOCWv )的continually方法,它具有同样的效果。
要写入二进制文件,使用FileOutputStream和BufferedOutputStream类,如下所示:
import java.io.{FileOutputStream, BufferedOutputStream}
val bos = new BufferedOutputStream(FileOutputStream("file.dat"))
val bytes = "Hello, world".getBytes
bytes.foreach(b => bos.write(b))
bos.close
读写二进制文件的方法还有很多,但由于所有的解决方案都使用了Java类,可以在网上搜索不同的方法,或查看Ian F. Darwin(O'Reilly)的Java Cookbook。
你在网上找到的许多解决方案都不使用缓冲(buffering)功能。如果你要读写大文件,一定要使用缓冲。例如,当我用一个FileInputStream读取电脑上的一个包含65万行的Apache访问日志文件时,需要181秒来读取该文件。通过使用BufferedInputStream包装后的FileInputStream —— 正如解决方案中所示 —— 读取相同文件只需要1.6秒。
- Apache Commons FileUtils类( https://oreil.ly/RMHgF )有很多可以读写文件的方法。
通常为了使代码具有可测试性,你想将String伪装为文件。
由于Scala.fromFile和Scala.fromString均继承自scala.io.Source,所以它们很容易互相转换。只要你的方法有一个Source引用,你就可以给它传递调用Source.fromfile获得BufferedSource,或者调用Source.fromString获得Source。
例如,下面的方法接受一个Source对象,并输出它包含的行:
import io.Source
def printLines(source: Source) =
for line <- source.getLines do
println(line)
当Source是从String构造时,也可以被调用:
val source = Source.fromString("foo\nbar\n")
printLines(source)
当Source是文件时,也可以被调用:
val source = Source.fromFile("/Users/Al/.bash_profile")
printLines(source)
写单元测试时,假如有这样的方法需要测试:
object FileUtils:
def getLinesUppercased(source: io.Source): List[String] =
source.getLines.map(_.toUpperCase).toList
在单元测试中,可以从File或String构造一个Source来测试getLinesUppercased方法:
import scala.io.Source
var source: Source = null
// test with a File
source = Source.fromFile("foo.txt") // a file with "foo" as its first line
val lines = FileUtils.getLinesUppercased(source)
assert(lines(0) == "FOO")
// test with a String
source = Source.fromString("foo\n")
val lines = FileUtils.getLinesUppercased(source)
assert(lines(0) == "FOO")
总之,如果你倾向于使用String轻松进行函数测试而不是文件,那么将其定义为接受Source实例。
你想序列化一个Scala类的实例,并把它保存为文件,或者通过网络发送。
一般来讲,序列化的方式和Java是一样的,但是Scala类序列化的语法不同。要使Scala类可序列化,需要继承Serializable类型并给该类添加 @SerialVersionUID注解:
@SerialVersionUID(1L)
class Stock(var symbol: String, var price: BigDecimal) extends Serializable:
override def toString = s"symbol: $symbol, Price: $price"
因为Serializable是一个类型 —— 技术上来说是java.io.Serializable的类型别名 —— 你可以把它混入一个类,即使该类已经继承了另外一个类:
@SerialVersionUID(1L)
class Employee extends Person with Serializable ...
把类标记为可序列化后,就可以使用和Java中相同的技术来读写对象,包括使用序列化的Java deep clone技术,在我的博客“Java Deep Clone (Deep Copy) Example”( https://oreil.ly/NyY5t )中讨论了这个问题。
下面的代码展示了完整的深拷贝方法,但忽略了可能的异常。代码中的注释解释了这个过程:
import java.io.*
// create a serializable Stock class
@SerialVersionUID(1L)
class Stock(var symbol: String, var price: BigDecimal) extends Serializable:
override def toString = f"$symbol%s is ${price.toDouble}%.2f"
@main def serializationDemo =
val filename = "nflx.obj"
// (1) create a Stock instance
val nflx = Stock("NFLX", BigDecimal(300.15))
// (2) write the object instance out to a file
val oos = ObjectOutputStream(FileOutputStream(filename))
oos.writeObject(nflx)
oos.close
// (3) read the object back in
val ois = ObjectInputStream(FileInputStream(filename))
val stock = ois.readObject.asInstanceOf[Stock]
ois.close
// (4) print the object that was read back in
println(stock)
这段代码运行时输出如下:
NFLX is 300.15
需要注意的是,目前Java中的序列化形式可能会在未来的某个时刻消失。ADTmag在2018年的一篇文章中讨论了这个问题,“从Java中移除序列化是Oracle的长期目标”( https://oreil.ly/CS3jn )。
你想在目录中创建文件或子目录,并且使用过滤器做点限制。
Scala不提供任何特殊的操作目录的方法,而是使用Java File类的listFiles方法。例如,下面方法获取了一个目录中的所有文件的列表:
// assumes that `dir` is a directory known to exist
def getListOfFiles(dir: File): Seq[String] =
dir.listFiles
.filter(_.isFile) // list only files
.map(_.getName)
.toList
这个算法的作用如下:
- 使用File类的listFiles方法将dir中的所有文件生成一个 Array[File]。
- 使用filter来调整列表,使其只包含文件。
- 使用map在每个文件上调用getName,从而返回一个文件名数组(而不是File实例)。
- 使用toList将其转换成List[String]。
如果目录中没有文件,该函数返回一个空的列表 —— List(),如果目录中有一个或多个文件,则返回一个 List[File]。REPL展示了这一点:
scala> getListOfFiles(File("/tmp/empty"))
val res0: List[String] = List()
scala> getListOfFiles(File("/tmp"))
val res1: List[String] = List(/tmp/foo.log, /tmp/bar.txt)
同样地,这种方法创建了dir下所有目录的列表:
def getListOfSubDirectories(dir: File): Seq[String] =
dir.listFiles
.filter(_.isDirectory) // list only directories
.map(_.getName)
.toList
要列出所有的文件和目录,从代码中删除 filter(_.isFile) 这一行:
def getListOfSubDirectories(dir: File): Seq[String] =
dir.listFiles
.map(_.getName)
.toList
如果你想根据文件的扩展名来限制返回的文件列表,在Java中,可以实现一个带有accept方法的FileFilter来过滤返回的文件名。
在Scala中,你可以在不需要FileFilter的情况下编写类似的代码。假设File代表一个已知存在的目录,下面的函数展示了根据返回文件的扩展名,从而过滤很多文件:
import java.io.File
def getListOfFiles(dir: File, extensions: Seq[String]): Seq[File] =
dir.listFiles
.filter(_.isFile)
.filter(file => extensions.exists(file.getName.endsWith(_)))
.toList
作为一个例子,你可以按下面的方式调用这个函数,从而列出给定目录中所有的 .wav 和 .mp3 文件:
val okFileExtensions = Seq("wav", "mp3")
val files = getListOfFiles(File("/tmp"), okFileExtensions)
对于更多关于文件和目录的使用场景,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 )。
你想在Scala的应用程序中执行external(系统)命令。你不关心该命令的输出,但是关心退出状态码。
有两种方式:
- 使用 ! 方法来执行命令,并在等待返回退出状态码时进行阻塞。
- 使用run方法来异步执行外部命令,当它运行结束时获取其退出状态码。
要执行一个命令并等待(阻塞)以获得其退出状态码,请导入必要的成员,将所需的命令放在一个字符串中,然后用 ! 方法运行它:
// necessary import
scala> import sys.process.*
// run a system command
scala> val exitStatus = "ls -al".!
total 32
drwxr-xr-x 3 al staff 96 Feb 17 20:34 .
drwxr-xr-x 22 al staff 704 Feb 17 20:34 ..
-rw-r--r-- 1 al staff 13112 Feb 17 20:34 simpletest_3.0.0-M3-0.2.0.jar
val exitStatus: Int = 0
scala> println(exitStatus)
0
在Unix系统中,退出状态码为0意味着命令执行成功,而非0的退出状态码意味着存在问题。例如,如果你试图对一个不存在的文件使用ls命令,你会得到退出状态码为1:
scala> val exitStatus = "ls -l noSuchFile.txt".!
ls: noSuchFile.txt: No such file or directory
val exitStatus: Int = 1
输出显示,执行命令后exitStatus为1。
正如你在下面例子看到的,可以在小数点后调用 ! 方法:
"ls -al".!
你也可以在空格后调用它,同时导入这个import语句:
import scala.language.postfixOps
"ls -al" !
所有这些命令都假定你已经导入了sys.process.*。注意如果你的命令执行失败,它会抛出异常:
scala> "foo".!
java.io.IOException: Cannot run program "foo": error=2, No such file
or directory at java.lang.ProcessBuilder.start
因此,一定要用Try这样的错误处理类型来包装它:
scala> val result = Try(Seq(
| "/bin/sh",
| "-c",
| "ls -l foo.bar"
| ).!!)
ls: foo.bar: No such file or directory
val result: scala.util.Try[String] =
Failure(java.lang.RuntimeException: Nonzero exit value: 1)
正如讨论中所示,你还可以使用Seq运行系统命令:
val exitStatus = Seq("ls", "-a", "-l", "/tmp").!
你可以使用run方法异步(asynchronously)地执行一个外部命令:
// necessary imports
scala> import sys.process.*
scala> val process = "ls -al".run
val process: scala.sys.process.Process = scala.sys.process.ProcessImpl ...
total 32
drwxr-xr-x 3 al staff 96 Feb 17 20:34 .
drwxr-xr-x 22 al staff 704 Feb 17 20:34 ..
-rw-r--r-- 1 al staff 13112 Feb 17 20:34 simpletest_3.0.0-M3-0.2.0.jar
通过这种方法,外部命令立即开始运行,你可以在产生的process对象上调用下面方法:
- isAlive返回true,说明进程在运行中,否则返回false。
- destroy可以终止一个正在运行的进程。
- exitStatus可以得到命令的退出状态码。
REPL中有一个长时间运行的例子展示了这是如何工作的:
# start the process
scala> val process = "sleep 20".run
val process: scala.sys.process.Process = scala.sys.process.ProcessImpl ...
# 10 seconds later, it’s still alive
scala> process.isAlive
val res1: Boolean = true
# 21 seconds later
scala> process.isAlive
val res2: Boolean = false
scala> process.exitValue
val res3: Int = 0
当使用这种方法时,在isAlive为false之前不要调用exitValue。如果在进程运行时调用exitValue,方法会阻塞直到进程运行结束。
除了在一个字符串后面调用 ! 外,你还可以在一个Seq后面调用 ! 。尤其当你有很多不同的命令参数时,这特别有用。
在使用Seq时,第一个元素是执行命令的名称,随后的元素是执行命令的参数,如下面例子中所示:
import sys.process.*
val exitStatus = Seq("ls", "-al").!
val exitStatus = Seq("ls", "-a", "-l").!
val exitStatus = Seq("ls", "-a", "-l", "/tmp").!
我省略了这些例子的输出,但每个命令输出的内容,和你在Unix命令行中执行ls得到的一样。
如果你不喜欢使用String或Seq的隐式转换,你可以创建一个Process对象来执行外部命令:
import sys.process.*
val status = sys.process.Process("ls -al").!
val status = sys.process.Process(Seq("ls", "-al")).!
当执行这些命令时,要注意你的命令和参数旁边的空格。下面所有的例子都因为有多余的空格而失败:
" ls".! // java.io.IOException: Cannot run program ""
Seq(" ls ", "-al").! // java.io.IOException: Cannot run program " ls "
Seq("ls", " -al ").! // ls: -al : No such file or directory
如果你是自己输入字符串,就不要有空格,如果你是从用户输入中得到字符串,一定要去除空格。
最后要说明的是,你可以在Scala运行任何可以在Unix命令行运行的外部命令。但是,外部命令和shell系统命令之间有很大的区别。ls命令是一个可以在所有Unix系统上使用的外部命令,可以在 /bin 目录下找到它:
$ which ls
/bin/ls
其他可以在Unix上执行的命令 —— 如cd或for —— 实际上是内置在你的shell中,如Bash shell。你无法在文件系统中找到它们的文件。因此不能执行这些命令,除非它们在shell中执行。参见16.10小节,了解如何执行shell系统命令。
你想执行一个外部命令,然后在Scala程序中使用这个进程的标准输出(STDOUT)。
任意使用下面一种方法来访问命令的STDOUT:
- 使用 !! 方法同步执行命令,并在等待接收其输出为String时阻塞。
- 使用lazyLines方法异步执行命令,并将其结果作为 LazyList[String] 返回。
- 如果命令退出状态码为不是0,当尝试使用它的结果时,lazyLines会抛出异常,想要避免这种情况,可以使用lazyLines_! (也可以使用ProcessLogger)。
就像上一个小节中的 ! 命令一样,你可以在字符串后面使用 !! 来执行命令,返回的是命令的STDOUT,而不是命令的退出状态码。返回结果是一个多行字符串,所以你可以在应用程序中进行处理。
忽略异常处理,这个例子展示了如何在String中使用 !! :
import sys.process.*
val result: String = "ls -al".!!
println(result)
命令的输出是一个多行字符串,开头是这样的:
total 64
drwxr-xr-x 10 Al staff 340 May 18 18:00 .
drwxr-xr-x 3 Al staff 102 Apr 4 17:58 ..
more output here ...
对于更复杂的情况,你想添加错误处理,可以使用 Try/Success/Failure 类:
import sys.process.*
import scala.util.{Try,Success,Failure}
val result: Try[String] = Try("ls -al fred".!!)
result match
case Success(out) => println(s"OUTPUT:\n$out")
case Failure(f) => println("Exception happened:\n$f")
假设当前目录下没有一个名为fred的文件,代码的输出结果将是:
ls: fred: No such file or directory
Exception happened:
val result: util.Try[String] =
Failure(java.lang.RuntimeException: Nonzero exit value: 1)
除了在字符串中使用 !! 外,你还可以在Seq中使用它:
val result: String = Seq("ls", "-al").!!
使用lazyLines方法来异步执行一个命令,并将其STDOUT作为 LazyList[String] 来访问。例如,这个命令在Unix系统上可能会运行很长时间,这也许会导致成千上万行的输出:
val contents: LazyList[String] =
sys.process.Process("find /Users/al -print").lazyLines
一旦在REPL中执行这行代码,Unix find命令就开始运行。你不会在REPL中看到任何输出,但你可以看到它正在运行,通过打开另一个终端窗口并运行Unix top命令,或ps命令:
$ ps a | grep 'find /Users'
19837 s004 S+ 0:00.14 find /Users/al -print
如输出所示,find命令确实正在运行,这个例子中进程ID(PID)是19837。
在REPL中,你可以从LazyList中读取并按需要处理命令的输出,比如用foreach:
scala> contents.foreach(println)
如果命令的退出状态码为不是0,然后你尝试使用产生的LazyList,lazyLines将抛出一个异常。例如,这个带有 lazyLines 的命令将 "No such file" 输出写入到STDERR:
scala> val x = "ls no_such_file".lazyLines
ls: no_such_file: No such file or directory
val x: LazyList[String] = LazyList(<not computed>)
如果你试图访问x的内容,则会抛出异常:
scala> x.foreach(println)
java.lang.RuntimeException: Nonzero exit code: 1 at
scala.sys.process.BasicIO$LazilyListed$ ...
much more exception output here ...
通过使用lazyLines_! 而不是lazyLines,你可以避免使用x时的异常:
scala> val x = "ls no_such_file".lazyLines_!
val x: LazyList[String] = LazyList(<not computed>)
ls: no_such_file: No such file or directory
scala> x.foreach(println)
(no output here)
如果需要,可以用下面方法阻止STDERR输出:
scala> val x = "ls no_such_file" lazyLines_! ProcessLogger(line => ())
val x: LazyList[String] = LazyList(<not computed>)
(there is no STDERR output here)
如果你不喜欢在String或Seq上使用隐式方法,可以使用Process:
import sys.process.*
val result: String = sys.process.Process("ls -al").!!
val result: String = sys.process.Process(Seq("ls","-al")).!!
val result: LazyList[String] =
sys.process.Process("find /Users/al -print").lazyLines
你想执行一个外部命令,并且访问其标准输出(STDOUT)和标准错误(STDERR)。
最简单的方法是按照前面小节说的执行命令,然后用ProcessLogger捕获输出。下面代码展示了这种方法:
import sys.process.*
val stdout = StringBuilder()
val stderr = StringBuilder()
val status = "ls -al . cookie" ! ProcessLogger(stdout append _, stderr append _)
println(s"status: '$status'")
println(s"stdout: '$stdout'")
println(s"stderr: '$stderr'")
我在当前目录下有几个文件,但没有一个文件叫cookie。当我执行这个脚本时,看到了这样的输出:
status: '1'
stdout: '(a lot of output from the 'ls .' command here)'
stderr: 'ls: cookies: No such file or directory'
因为我使用了 ! 方法,所以执行这个脚本后,status变量包含了命令的退出状态码,1代表文件cookie不存在。stdout变量包含了命令的STDOUT,在这个例子中,它包含了命令的 ls . 部分的输出。如果发生问题,stderr变量会包含命令的STDERR,在上面命令中,把执行的命令同时写到STDOUT和STDERR,所以stdout和stderr都会包含数据。
注意,当find命令执行后但没有找到文件时,仍然可以返回退出状态码为0。这种状态仅仅意味着find命令按预期运行,没有异常。要经常在命令行中检查系统命令的退出状态码,以确定它们的状态码是如何工作的:
# [1] no files found, but no errors
$ find . -name NonExistentFile.txt
$ echo $?
0
# [2] a non-zero exit status because the directory doesn’t exist
$ find NonExistentDirectory
find: NonExistentDirectory: No such file or directory
$ echo $?
1
下面是另一个使用Seq同时往STDOUT和STDERR写入的例子:
val status = Seq(
"find",
"/usr",
"-name",
"make"
) ! ProcessLogger(stdout append _, stderr append _)
当你看到这两个例子中的任何一个时,都会对这段代码的作用感到惊讶:
String ! ProcessLogger(stdout append _, stderr append _)
Seq ! ProcessLogger(stdout append _, stderr append _)
这样做的原因是scala.sys.process的作者创建了一个小的DSL。在第一个例子中,如下代码:
"ls -al . cookie" ! ProcessLogger(stdout append _, stderr append _)
被编译器变成了这段代码:
"ls -al . cookie".!(ProcessLogger(stdout append _, stderr append _))
之所以工作是因为:
- ! 方法作为隐式转换添加到String中。
- ! 方法有一个重载的构造函数,方法签名是 !(log: ProcessLogger): Int。
虽然使用这种符号时可能有点难以理解,但如果 ! 方法被命名为exec,那么这段代码将看起来是这样:
// if '!' was named 'exec' instead
"ls -al . cookie".exec(ProcessLogger(stdout append _, stderr append _))
我发现拼写方法调用是另一种方法,使这段代码更易于阅读和维护:
val exitStatus = "ls -al . cookie".!(
ProcessLogger(
arg1 => stdout.append(arg1),
arg2 => stdout.append(arg2)
)
)
还要注意的是, 随着你的需求变化,写入STDOUT和STDERR会很容易会得复杂起来。Scaladoc声明:“如果希望完全控制输入和输出,那么ProcessIO可以与run一起使用”。有关ProcessLogger和ProcessIO类的更多示例,参考scala.sys.process 的API文档。
- 参阅ProcessBuilder Scaladoc( https://oreil.ly/zv3JK )了解更多关于方法 ! ,!! 和run的细节。
你想执行一系列的外部命令,将一个命令的输出重定向作为另一个命令的输入,也就是说,通过pipe将这些命令连接在一起。
使用 #| 方法把一个命令输出重定向到另一个命令的输入。这样做时,在管道的末端使用run、!、!!、lazyLines或 lazyLines_! 等方法来运行完整的一系列外部命令。
下面的例子展示了 !! 方法,ps命令的输出通过管道成为wc命令的输入:
import sys.process.*
val numRootProcs = ("ps aux" #| "grep root" #| "wc -l").!!.trim
println(s"# root procs: $numRootProcs")
因为ps命令的输出是通过管道进入grep,然后进入linecount命令(wc -l),该代码会打印出在Unix系统上运行的进程数量,这些进程的ps输出中有root这个字符串。同样,下面命令创建了一个包含root字符串的所有运行的进程列表:
val rootProcs: String = ("ps auxw" #| "grep root").!!.trim
val rootProcs: LazyList[String] = ("ps auxw" #| "grep root").lazyLines
如果你有Unix背景,那么 #| 方法是有意义的,因为它就像Unix的管道符号(|),但前面有一个 # 字符。事实上,除了 ### 操作符(用来代替Unix的 ; 符号)之外,Scala进程库通常和Unix命令保持一致。
试图在String中用管道连接命令,然后用 ! 或 !! 执行这些命令是不行的:
// won’t work
scala> val result = ("ls -al | grep Foo").!!
ls: Foo: No such file or directory
ls: grep: No such file or directory
ls: |: No such file or directory
java.lang.RuntimeException: Nonzero exit value: 1
at scala.sys.process.ProcessBuilderImpl ...
more exception output here ...
不行是因为管道的能力来自于shell(Bourne shell、Bash等),而当你执行这样的命令时,并没有shell。
要在shell(例如Bourne shell)中执行一系列的命令,可以使用一个带有多个参数的Seq,如下所示:
// this works as desired, piping the 'ps' output into 'grep'
val result = Seq(
"/bin/sh",
"-c",
"ps aux | grep root"
).!!
正如本节所述,这种方法在Bourne shell实例中执行 ps aux | grep root 命令。