Skip to content

Latest commit

 

History

History
1069 lines (744 loc) · 39.5 KB

16.文件和进程.md

File metadata and controls

1069 lines (744 loc) · 39.5 KB

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方法。例如,在读取文件时处理文件中的每一行,可以使用这种方法:

    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)

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

    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)模式,其基本过程是:

  • 创建可以使用的资源。
  • 将资源贷出给其它代码。
  • 当其它代码使用完资源时,会自动关闭/销毁资源,例如通过自动调用BufferedSourceclose方法。

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语句中被调用。

很多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

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

    Source.fromFile("example.txt", "UTF-8")

强大的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的例子,但忽略了可能的异常:

    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类。这个例子展示了如何使用PathsFiles类将一个字符串写到一个文件中:

    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

异常 -- TODO 鸽子图

所有文件读取和写入的代码都可能抛出异常,关于处理异常的代码请参阅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)

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

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

    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页面:

16.3 读写二进制文件

问题

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

解决方案

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

读取二进制文件

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

    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方法,它具有同样的效果。

写入二进制文件

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

    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

警告 -- TODO 蝎子图

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

另见

16.4 将字符串伪装为文件

问题

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

解决方案

由于Scala.fromFileScala.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

在单元测试中,可以从FileString构造一个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实例。

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

问题

你想序列化一个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

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

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

16.6 列出目录中的文件

问题

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

解决方案

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 )库的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方法来异步执行外部命令,当它运行结束时获取其退出状态码。

使用!方法

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

    // 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

当使用这种方法时,在isAlivefalse之前不要调用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得到的一样。

使用进程

如果你不喜欢使用StringSeq的隐式转换,你可以创建一个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上执行的命令 —— 如cdfor —— 实际上是内置在你的shell中,如Bash shell。你无法在文件系统中找到它们的文件。因此不能执行这些命令,除非它们在shell中执行。参见16.10小节,了解如何执行shell系统命令。

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

问题

你想执行一个外部命令,然后在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的异步解决方案

使用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)

lazyLines_!的异步解决方案

如果命令的退出状态码为不是0,然后你尝试使用产生的LazyListlazyLines将抛出一个异常。例如,这个带有 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)

Seq和进程

如果你不喜欢在StringSeq上使用隐式方法,可以使用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

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

问题

你想执行一个外部命令,并且访问其标准输出(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,在上面命令中,把执行的命令同时写到STDOUTSTDERR,所以stdoutstderr都会包含数据。

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

注意,当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同时往STDOUTSTDERR写入的例子:

    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)
        )
    )

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

另见

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

16.10 构建外部命令管道

问题

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

解决方案

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

下面的例子展示了 !! 方法,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命令保持一致。

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

试图在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 命令。