Java 函数式编程六: 使用资源

我们在编程时会大量使用资源,列如访问文件、与远程服务通信、使用数据库连接等等。这一般会涉及到一些问题,列如及时释放资源、加锁进行同步,以及处理可能出现的异常。同时处理所有这些问题可能会让人望而生畏。在本文中,我们将了解如何使用 Lambda 表达式来组织代码,以减轻管理资源访问的痛苦,也就是处理那些我们最好不要出错的繁琐任务。

我们可能一直认为 JVM 会自动处理所有的垃圾回收(GC)。的确 ,如果我们只使用内部资源,可以让 JVM 来处理。但如果我们使用外部资源,列如连接数据库、打开文件和套接字,或者使用本地资源,那么 GC 就是我们的责任了。

Java 提供了几种正确清理资源的方法,但正如我们在本文中会看到的,没有一种方法能像使用 Lambda 表达式那样有效。我们将使用 Lambda 表达式来实现环绕执行方法(EAM)模式,这能让我们更好地控制操作的顺序 [11]。通过使用这种模式,我们会发现,管理资源生命周期的负担从代码的使用者转移到了开发者身上,由于开发者对这些细节有更深入的了解和更好的控制。

然后,我们将进一步探讨资源管理的思路,以简化更多与资源使用相关的操作。我们将了解如何安全地管理容易出错的锁管理任务。最后,我们将看看这些思路如何协助我们以简洁优雅的方式编写异常测试。

清理资源

处理垃圾回收可能会很麻烦。有一家公司请我帮忙调试一个问题,有个程序员描述这个问题时说:“大部分时候运行正常。” 这个应用程序在高峰使用时段会失败。结果发现,代码依赖 finalize 方法来释放数据库连接。JVM 认为内存足够,就选择不运行垃圾回收。由于 finalize 方法很少被调用,这就导致了外部资源堵塞,最终引发了故障。

我们需要更好地处理这类情况,而 Lambda 表达式可以提供协助。让我们从一个涉及垃圾回收的示例问题开始。我们将使用几种不同的方法来构建这个示例,并讨论每种方法的优缺点。这将协助我们看到使用 Lambda 表达式的最终解决方案的优势。

深入了解问题

我们关注的是外部资源的清理,所以让我们从一个简单的示例类开始,该类使用 FileWriter 来写入一些消息。

public class FileWriterExample {
    private final FileWriter writer;
    public FileWriterExample(final String fileName) throws IOException {
        writer = new FileWriter(fileName);
    }
    public void writeStuff(final String message) throws IOException {
        writer.write(message);
    }
    public void finalize() throws IOException { // Java 9 中已弃用
        writer.close();
    }
    //...
}

在 FileWriterExample 类的构造函数中,我们初始化了一个 FileWriter 实例,并指定了要写入的文件名。在 writeStuff 方法中,我们使用创建的 FileWriter 实例将给定的消息写入文件。然后,在 finalize 方法中,我们清理资源,调用 close 方法,希望它能将内容刷新到文件并关闭文件。

乍一看,这段代码似乎合理。毕竟,许多 Java 应用程序中的类都使用 finalize 方法来清理资源,这在 Java 8 之前是一种标准做法,而且许多遗留代码依旧在使用这个方法。但实际上,期望资源自动清理只是一厢情愿的想法。

如果 JVM 发现有足够的内存可用,就不会调用垃圾回收,因此 finalize 方法很长时间都不会被调用。这将导致资源无法及时释放,还可能引发资源争用问题。这也是 finalize 方法在 Java 9 中被弃用的缘由之一,目的是鼓励程序员不再使用该方法。我们很快会探讨 finalize 方法的替代方案,但第一,让我们编写一个 main 方法来使用 FileWriterExample 类。

public static void main(final String[] args) throws IOException {
    final FileWriterExample writerExample =
        new FileWriterExample("peekaboo.txt");
    writerExample.writeStuff("peek-a-boo");
}

我们创建了一个 FileWriterExample 类的实例,并调用了它的 writeStuff 方法。但如果运行这段代码,我们会发现 peekaboo.txt 文件被创建了,但它是空的。finalize 方法从未运行,由于 JVM 认为内存足够,没必要运行。结果,文件从未关闭,我们写入的内容也没有从内存中刷新到文件。

如果在一个长时间运行的进程中创建多个 FileWriterExample 类的实例,最终会有多个打开的文件。由于 JVM 有足够的内存,并且认为没有必要运行垃圾回收,许多文件都不会及时关闭。

让我们通过显式调用 close 方法来解决这个问题,并去掉 finalize 方法。

告别finalize方法

finalize 方法在 Java 9 中已被弃用。花几分钟检查一下你自己的生产代码,看看是否有类中依旧存在 finalize 方法。如果你发现了,把这些情况记录为技术债务,并安排时间使用本文学到的技术来清理它们。

关闭资源

尽管对象的内存清理依旧依赖于 JVM 的垃圾回收,但我们可能会认为,通过显式调用,实例使用的外部资源可能会被快速清理。不幸的是,这会导致更多问题。为了说明这一点,让我们编写一个 close 方法。

public void close() throws IOException { // 不是一个好的解决方案
    writer.close();
}

在 close 方法中,我们调用了 FileWriter 实例的 close 方法。如果 FileWriterExample 中使用了其他外部资源,我们也可以在这里清理它们。让我们在 main 方法中显式使用这个方法。

final FileWriterExample writerExample =
    new FileWriterExample("peekaboo.txt");
writerExample.writeStuff("peek-a-boo");
writerExample.close();

如果目前运行代码并查看 peekaboo.txt 文件,我们会看到 peek-a-boo 消息。代码可以工作,但远非完美。

显式调用 close 方法会在我们表明实例不再需要时立即清理实例使用的任何外部资源。但如果在调用 close 方法之前的代码中出现异常,我们可能无法到达该调用。我们需要做更多的工作来确保 close 方法被调用。接下来让我们处理这个问题。

确保清理

我们需要确保无论是否有异常,close 方法都会被调用。为了实现这一点,我们可以将调用包装在 finally 块中。

final FileWriterExample writerExample =
    new FileWriterExample("peekaboo.txt");
try { // 相当冗长
    writerExample.writeStuff("peek-a-boo");
} finally {
    writerExample.close();
}

这个版本将确保即使代码中出现异常,资源也会被清理,但这需要付出许多努力,而且代码冗长且不够优雅。Java 7 引入了一个特性来减少这类问题,我们接下来会看到。

使用自动资源管理(ARM)

自动资源管理(ARM)是 Java 7 引入的一个特性,用于在资源使用结束时自动释放资源。如果使用得当,ARM 可以减少代码的冗长性。与上一个示例中同时使用 try 和 finally 块不同,我们可以使用带有附加资源的特殊形式的 try 块来使用 ARM 特性。使用这种语法时,Java 编译器会在字节码中自动插入 finally 块和对 close 方法的调用。

让我们看看使用 ARM 时代码会是什么样子,我们将使用一个新的 FileWriterARM 类的实例。

try(final FileWriterARM writerARM = new FileWriterARM("peekaboo.txt")) {
    writerARM.writeStuff("peek-a-boo");
    System.out.println("done with the resource...");
}

我们在 try-with-resources 形式的安全范围内创建了 FileWriterARM 类的实例,并在其块中调用了 writeStuff 方法。当我们离开 try 块的作用域时,close 方法会自动在这个 try 块管理的实例/资源上被调用。为了使这个功能正常工作,编译器要求被管理的资源类实现 AutoCloseable 接口,该接口只有一个方法 close。

在 Java 中,围绕 AutoCloseable 的规则经历了一些变化。第一,Stream 实现了 AutoCloseable 接口,因此所有基于输入/输出(I/O)的流都可以与 try-with-resources 一起使用。AutoCloseable 的契约从严格的 “资源必须关闭” 改为更宽松的 “资源可以关闭”。如果我们确定代码使用了 I/O 资源,那么应该使用 try-with-resources。

以下是上一段代码中使用的 FileWriterARM 类。

public class FileWriterARM implements AutoCloseable {
    private final FileWriter writer;
    public FileWriterARM(final String fileName) throws IOException {
        writer = new FileWriter(fileName);
    }
    public void writeStuff(final String message) throws IOException {
        writer.write(message);
    }
    public void close() throws IOException {
        System.out.println("close called automatically...");
        writer.close();
    }
    //...
}

让我们运行代码,查看 peekaboo.txt 文件和控制台的输出。

done with the resource...
close called automatically...

我们可以看到,一旦离开 try 块,close 方法就被调用了。进入 try 块时创建的实例在离开块后就无法访问了。该实例使用的内存最终会根据 JVM 采用的垃圾回收策略进行回收。

前面使用 ARM 的代码简洁且不错,但程序员必须记住使用它。如果我们忽略这个优雅的结构,代码不会报错,它只会创建一个实例并在任何 try 块之外调用 writeStuff 等方法。如果我们想确保及时清理资源并避免程序员出错,就需要寻找比 ARM 更好的方法,接下来我们将探讨这一点。

使用环绕执行方法模式清理资源

自动资源管理(ARM)是朝着正确方向迈出的不错一步,但它的效果并不理想。开个玩笑,千万别完全信任任何带有“管理”这个词的东西,对吧?使用我们类的人必须弄清楚这个类实现了 AutoCloseable 接口,并且要记得使用 try-with-resources 结构。要是我们设计的 API 能引导程序员,并且在编译器的协助下让他们走上正确的道路,那就太好了。借助 Lambda 表达式和环绕执行方法(EAM)模式,我们可以轻松实现这一点。

EAM 是一种强劲的模式,它利用 Lambda 表达式来包装一段代码。顾名思义,我们可以设计在代码执行前后进行预操作和后操作。这样,使用我们设计的人就可以专注于他们的业务逻辑,将管理资源创建和释放的细节委托给代码的设计者。借助这种模式,资源创建可以在 Lambda 表达式执行之前进行,资源清理可以在 Lambda 表达式执行之后自动进行。这听起来很有趣,我信任你肯定迫不及待想看看它是如何工作的。

让我们重新处理手头的问题,使用 EAM 模式。

为资源清理准备类

我们将设计一个 FileWriterEAM 类,它封装了需要及时清理的重大资源。在这个例子中,我们将使用 FileWriter 来代表该资源。让我们把构造函数和 close 方法都设为私有,这会引起尝试使用这个类的程序员的注意。他们不能直接创建实例,也不能调用 close 方法。在进一步讨论之前,让我们先实现目前设计好的内容。

public class FileWriterEAM {
    private final FileWriter writer;
    private FileWriterEAM(final String fileName) throws IOException {
        writer = new FileWriter(fileName);
    }
    private void close() throws IOException {
        System.out.println("close called automatically...");
        writer.close();
    }
    public void writeStuff(final String message) throws IOException {
        writer.write(message);
    }
    //...
}

私有构造函数和私有 close 方法都已就位,同时还有公共方法 writeStuff。

使用高阶函数

由于程序员不能直接创建 FileWriterEAM 类的实例,我们需要为他们提供一个工厂方法。与普通的工厂方法不同,普通工厂方法创建实例后就将其抛出不管,而我们的方法会将实例提供给用户,并等待他们使用完毕。我们很快会看到,我们将借助 Lambda 表达式来实现这一点。

让我们先编写这个方法。

public static void use(final String fileName,
                       final UseInstance<FileWriterEAM, IOException> block) throws IOException {
    final FileWriterEAM writerEAM = new FileWriterEAM(fileName);
    try {
        block.accept(writerEAM);
    } finally {
        writerEAM.close();
    }
}

在 use 方法中,我们接收两个参数:fileName 和一个 UseInstance 接口的引用(我们还未定义这个接口)。在这个方法中,我们实例化了 FileWriterEAM,并在 try 和 finally 块的保护下,将实例传递给我们即将创建的接口的 accept 方法。当调用返回时,我们在 finally 块中调用实例的 close 方法。除了使用这种结构,我们也可以在 use 方法中使用 ARM。无论如何,使用我们类的人不必担心这些细节。

use 方法体现了环绕执行方法模式的结构。这里的主要操作是在 accept 方法中使用实例,但创建和清理操作很好地环绕着这个调用。

在我们测试这段代码之前,让我们处理最后一个缺失的部分,即 UseInstance 接口。

@FunctionalInterface
public interface UseInstance<T, X extends Throwable> {
    void accept(T instance) throws X;
}

UseInstance 是一个函数式接口,是 Java 编译器自动从 Lambda 表达式或方法引用合成的理想选择。我们用 @FunctionalInterface 注解标记了这个接口。这纯粹是可选的,但有助于更明确地表达我们的意图。无论我们是否使用这个注解,编译器都会根据结构自动识别函数式接口,就像我们在“加点糖让代码更甜美”中讨论的那样。

我们本可以使用 java.function.Consumer 接口,而不是定义自己的 UseInstance 接口。但由于该方法可能会抛出异常,我们需要在自己的接口中表明这一点。Lambda 表达式只能抛出作为被合成的抽象方法签名一部分定义的受检异常(见第 10 篇,错误处理)。我们创建 UseInstance 接口是为了让 accept 方法可以接受一个泛型类型的实例。在这个例子中,我们将其绑定到一个具体的 FileWriterEAM 实例。我们还设计该方法的实现可能会抛出一个泛型异常 X,在这个例子中,它绑定到具体的 IOException 类。

使用设计进行实例清理

作为类的设计者,我们付出的努力比仅仅实现 AutoCloseable 接口要多一些。但我们的这一额外投入很快就会带来持续的回报:每次程序员使用我们的类时,他们都能立即进行资源清理,就像我们在这里看到的:

FileWriterEAM.use("eam.txt", writerEAM -> writerEAM.writeStuff("sweet"));

第一,使用我们类的人不能直接创建实例。这可以防止他们编写代码,将资源清理推迟到资源过期之后(除非他们采取极端手段,如使用反射来绕过这个机制)。由于编译器会阻止调用构造函数或 close 方法,程序员很快就会清楚 use 方法的好处,它会提供一个实例供他们使用。为了调用 use 方法,他们可以使用 Lambda 表达式提供的简洁语法,就像我们在前面的代码中看到的那样。

让我们运行这个版本的代码,看看它创建的 eam.txt 文件。

sweet

让我们也看一下控制台中代码的输出。

close called automatically...

我们可以看到文件中有正确的输出,并且资源清理自动完成了。

在这个例子中,我们在 Lambda 表达式中仅对给定的实例 writerEAM 进行了一次调用。如果我们需要对它执行更多操作,可以将它作为参数传递给其他函数。我们也可以在 Lambda 表达式中直接对它执行一些操作,使用多行语法即可。

FileWriterEAM.use("eam2.txt", writerEAM -> {
    writerEAM.writeStuff("how");
    writerEAM.writeStuff("sweet");
});

我们可以通过将多行代码包裹在 {} 块中,将它们放在 Lambda 表达式中。如果 Lambda 表达式预期返回一个结果,必定要在适当的表达式处放置 return 语句。Java 编译器让我们可以灵活地只写一行代码,也可以包裹多行代码,但我们应该保持 Lambda 表达式简短。

长方法不好,长 Lambda 表达式更糟糕。我们会失去代码简洁、易理解和易维护的优势。与其编写长 Lambda 表达式,我们应该将代码移到其他方法中,如果可能的话,使用方法引用,或者在 Lambda 表达式中调用这些方法。

在这个例子中,UseInstance 的 accept 方法是一个 void 方法。如果我们想向 use 方法的调用者返回一些结果,就必须修改这个方法的签名,设置一个合适的返回类型,列如一个泛型参数 R。如果我们进行这样的更改,那么 UseInstance 就会更像 Function<U, R> 接口,而不是 Consumer<T> 接口。我们还必须修改 use 方法,以传播修改后的 apply 方法的返回结果。

我们使用 Lambda 表达式实现了环绕执行方法模式。在设计需要及时清理资源的类时,我们可以从这种模式中受益。我们没有把负担转嫁给类的使用者,而是多付出了一点努力,这让他们的生活轻松了许多,也让我们的代码行为更加一致。

这种模式并不局限于资源清理。对我来说,这种模式在一个项目中发挥了重大作用,当时我的团队必须在事务范围内执行操作。我们没有在代码中到处创建和管理事务,而是将它们封装在一个很棒的 runWithinTransaction 方法中。该方法的调用者会得到一个事务实例,当他们返回时,该方法会负责检查状态,以及执行提交或回滚事务和记录日志等操作。

我们使用 Lambda 表达式和环绕执行方法模式来管理资源。接下来,我们将使用它们来管理锁。

管理锁

在 Java 并发应用程序中,锁起着至关重大的作用,由于它们对于确保多个线程对共享可变变量所做更改的正确性至关重大。在本节中,我们将使用 Lambda 表达式来更精细地控制锁,并为关键部分的正确加锁进行单元测试创造条件。

synchronized 是一个古老的关键字,用于实现互斥。像 synchronized { … } 这样的同步代码块,是环绕执行方法模式的一种实现。这种模式自 Java 1.0 就已存在,但在 Java 中它受限于 synchronized 关键字。如今,Lambda 表达式释放了这种模式的强劲功能。

synchronized 存在一些缺点,可参考 Brian Goetz 所著的《Java 并发编程实战》(Java Concurrency in Practice [Goe06])和 Venkat Subramaniam 所著的《JVM 并发编程》(Programming Concurrency on the JVM [Sub11])。第一,synchronized 调用很难设置超时,这会增加死锁和活锁的可能性。其次,synchronized 很难进行模拟,这使得对代码是否遵循正确的线程安全进行单元测试变得困难。

为了解决这些问题,Java 5 引入了 Lock 接口以及一些实现,如 ReentrantLock。Lock 接口让我们能更好地控制加锁、解锁,检查锁是否可用,并且如果在特定时间内未能获取锁,还能轻松设置超时。由于它是一个接口,为了进行单元测试,很容易模拟实则现 [12]。

Lock 接口有一个需要注意的地方:与 synchronized 不同,它需要显式地加锁和解锁。这意味着我们不仅要记得解锁,还要在 finally 块中进行解锁操作。从本文目前的讨论中可以看出,Lambda 表达式和环绕执行方法模式在这里能发挥很大的作用。

让我们先来看一段使用 Lock 的代码。

public class Locking {
    Lock lock = new ReentrantLock(); // 或模拟对象
    protected void setLock(final Lock mock) {
        lock = mock;
    }
    public void doOp1() {
        lock.lock();
        try {
            //... 关键代码...
        } finally {
            lock.unlock();
        }
    }
    //...
}

我们使用 Lock 类型的 lock 字段在类的各个方法之间共享锁。但是,加锁操作(例如在 doOp1 方法中)存在许多不足。代码冗长、容易出错且难以维护。让我们借助 Lambda 表达式,创建一个小类来管理锁。

public class Locker {
    public static void runLocked(Lock lock, Runnable block) {
        lock.lock();
        try {
            block.run();
        } finally {
            lock.unlock();
        }
    }
}

这个类解决了使用 Lock 接口带来的麻烦,从而让其他代码从中受益。我们可以在代码中使用 runLocked 方法来包装关键部分。

public void doOp2() {
    runLocked(lock, () -> {/*... 关键代码 ... */});
}
public void doOp3() {
    runLocked(lock, () -> {/*... 关键代码 ... */});
}
public void doOp4() {
    runLocked(lock, () -> {/*... 关键代码 ... */});
}

这些方法很简洁,它们使用了我们创建的 Locker 辅助类的静态方法 runLocked(这段代码需要使用 import static Locker.runLocked 才能编译)。Lambda 表达式再次帮了我们的忙。

我们看到环绕执行方法模式如何让代码更简洁、更不容易出错,但这种优雅和简洁应该是为了减少繁琐,而不是隐藏关键信息。在使用 Lambda 表达式进行设计时,我们应该确保代码的意图及其后果清晰可见。此外,在创建捕获局部状态的 Lambda 表达式时,我们必须注意在“词法作用域有什么限制?”中讨论过的限制。

让我们再看看环绕执行方法模式在使用 JUnit 进行单元测试时带来的另一个好处。

创建简洁的异常测试

Java 5 引入注解后,JUnit [13] 迅速开始使用它们。总体而言这是有益的,但其中一种用法,即异常测试的便利性,导致代码变得简略而非简洁。让我们了解一下其中的问题,然后用 Lambda 表达式来解决它们。我们会发现,Lambda 表达式不仅仅是一种语言特性,它们还改变了我们思考、设计甚至测试应用程序的方式。

假设我们通过单元测试来驱动 RodCutter 类的设计,并且我们期望 maxProfit 方法在参数为零时抛出异常。让我们看看几种编写异常测试的方法。

尝试 1:使用try和catch的冗长测试

以下是使用 try 和 catch 来检查 maxProfit 方法异常的测试。

@Test public void verboseExceptionTest() {
    rodCutter.setPrices(prices);
    try {
        rodCutter.maxProfit(0);
        fail("Expected exception for zero length");
    } catch(RodCutterException ex) {
        assertTrue(true);
    }
}

这段代码很冗长,可能需要花些时间才能理解,但它明确指出了预期会失败的部分:对 maxProfit 方法的调用。

为了让异常测试更简洁,JUnit 4 开始使用注解进行异常测试。使用注解减少了测试的冗长性,但遗憾的是,这会使测试变得无效,我们接下来会看到。

尝试 2:使用注解的简略测试

让我们快速看一下在 JUnit 4 中如何编写异常测试。这原本是为了使用注解让测试不那么冗长,但可以说是一次失败的尝试。

@Test(expected = RodCutterException.class) // JUnit 4 特性
public void TerseExceptionTest() {
    rodCutter.setPrices(prices);
    rodCutter.maxProfit(0);
}

这个测试很简短,但具有欺骗性——它更像是简略而非简洁。它告知我们如果收到 RodCutterException 异常,测试就应该通过,但它无法确保抛出该异常的方法是 maxProfit。如果由于代码更改,setPrices 方法抛出了该异常,那么这个测试依旧会通过,但缘由是错误的。一个好的测试应该只由于正确的缘由而通过——这个测试欺骗了我们。幸运的是,我们不必再编写这样的测试来追求简洁性了。让我们看看如何在 JUnit 5 中使用 Lambda 表达式编写简洁的测试。

尝试 3:使用 Lambda 表达式的简洁测试

在测试中使用注解已经成为过去式。JUnit 5 彻底重新设计了异常测试的编写方式,并大量使用了 Lambda 表达式。正如你所期望的,这使得测试既简洁又高效。

在 JUnit 5 中,你可以使用新的 assertThrows 方法来验证一段代码是否抛出了预期的异常。这个方法接受两个参数。第一个参数表明预期的异常类型。第二个参数是一个 Lambda 表达式,用于执行预期会抛出该异常的代码。如果从 Lambda 表达式中调用的代码抛出了预期的异常,断言就会成功,否则会报告失败。

让我们使用 assertThrows 方法创建一个简洁的测试。

@Test
public void ConciseExceptionTest() {
    rodCutter.setPrices(prices);
    Exception ex =
        assertThrows(RodCutterException.class, () -> rodCutter.maxProfit(0));
    assertEquals("length should be greater than zero", ex.getMessage());
}

这个测试既简洁又细致——只有当 maxProfit 方法抛出预期的异常时,它才会通过。前面的代码还表明,除了检查是否抛出了预期的异常,我们还可以选择检查异常是否包含预期的错误消息。

前面所有的测试都实现了一样的目标,但最后一个版本更好,由于它既简洁又正确。

我们看到 Lambda 表达式如何协助我们编写针对特定方法预期异常的测试,这有助于我们创建简洁、易读且不易出错的测试。

总结

在本文中,我们学习了资源管理。我们不能完全依赖自动垃圾回收,尤其是当我们的应用程序使用外部资源时。环绕执行方法模式可以协助我们更精细地控制执行流程,并释放外部资源。Lambda 表达式超级适合实现这种模式。除了控制对象的生命周期,我们还可以使用这种模式更好地管理锁,并编写简洁的异常测试。这可以使代码的执行更具确定性,及时清理重量级资源,并减少错误。

在下一篇中,我们将使用 Lambda 表达式来延迟部分代码的执行,从而提高程序的效率。

© 版权声明

相关文章

暂无评论

none
暂无评论...