实用函数式 Java (PFJ)简介

【注】本文译自: Introduction To Pragmatic Functional Java – DZone Java

实用函数式(Pragmatic Funcational) Java 是一种基于函数式编程概念的现代、非常简洁但可读的 Java 编码风格。

实用函数式 Java (PFJ) 试图定义一种新的惯用 Java 编码风格。编码风格,将完全利用当前和即将推出的 Java 版本的所有功能,并涉及编译器来帮助编写简洁但可靠和可读的代码。
虽然这种风格甚至可以在 Java 8 中使用,但在 Java 11 中它看起来更加简洁和简洁。它在 Java 17 中变得更具表现力,并受益于每个新的 Java 语言功能。
但 PFJ 不是免费的午餐,它需要开发人员的习惯和方法发生重大改变。改变习惯并不容易,传统的命令式习惯尤其难以解决。
这值得么? 确实! PFJ 代码简洁、富有表现力且可靠。它易于阅读和维护,并且在大多数情况下,如果代码可以编译 – 它可以工作!

实用函数式 Java 的元素

PFJ 源自一本精彩的 Effective Java 书籍,其中包含一些额外的概念和约定,特别是源自函数式编程(FP:Functional Programming)。请注意,尽管使用了 FP 概念,但 PFJ 并未尝试强制执行特定于 FP 的术语。(尽管对于那些有兴趣进一步探索这些概念的人,我们也提供了参考)。
PFJ专注于:

  • 减轻心理负担。
  • 提高代码可靠性。
  • 提高长期可维护性。
  • 借助编译器来帮助编写正确的代码。
  • 让编写正确的代码变得简单而自然,编写不正确的代码虽然仍然可能,但应该需要付出努力。

尽管目标雄心勃勃,但只有两个关键的 PFJ 规则:

  • 尽可能避免 null
  • 没有业务异常。

下面,更详细地探讨了每个关键规则:

尽可能避免 null(ANAMAP 规则)

变量的可空性是特殊状态之一。它们是众所周知的运行时错误和样板代码的来源。为了消除这些问题并表示可能丢失的值,PFJ 使用 Option<T> 容器。这涵盖了可能出现此类值的所有情况 – 返回值、输入参数或字段。
在某些情况下,例如出于性能或与现有框架兼容性的原因,类可能会在内部使用 null。这些情况必须清楚记录并且对类用户不可见,即所有类 API 都应使用 Option<T>
这种方法有几个优点:

  • 可空变量在代码中立即可见。无需阅读文档、检查源代码或依赖注释。
  • 编译器区分可为空和不可为空的变量,并防止它们之间的错误赋值。
  • 消除了 null 检查所需的所有样板。

无业务异常(NBE 规则)

PFJ 仅使用异常来表示致命的、不可恢复的(技术)故障的情况。此类异常可能仅出于记录和/或正常关闭应用程序的目的而被拦截。不鼓励并尽可能避免所有其他异常及其拦截。
业务异常是特殊状态的另一种情况。为了传播和处理业务级错误,PFJ 使用 Result<T> 容器。同样,这涵盖了可能出现错误的所有情况 – 返回值、输入参数或字段。实践表明,字段很少(如果有的话)需要使用这个容器。
没有任何正当的情况可以使用业务级异常。与通过专用包装方法与现有 Java 库和遗留代码交互。Result<T> 容器包含这些包装方法的实现。
无业务异常规则具有以下优点:

  • 可以返回错误的方法在代码中立即可见。 无需阅读 文档、检查源代码或分析调用树,以检查可以抛出哪些异常以及在哪些条件下被抛出。
  • 编译器强制执行正确的错误处理和传播。
  • 几乎没有错误处理和传播的样板。
  • 我们可以为快乐的日子场景编写代码,并在最方便的点处理错误 – 异常的原始意图,这一点实际上从未实现过。
  • 代码保持可组合、易于阅读和推理,在执行流程中没有隐藏的中断或意外的转换——你读到的就是将要执行的

将遗留代码转换为 PFJ 风格的代码

好的,关键规则看起来不错而且很有用,但是真正的代码会是什么样子呢?
让我们从一个非常典型的后端代码开始:

public interface UserRepository {
    User findById(User.Id userId);
}

public interface UserProfileRepository {
    UserProfile findById(User.Id userId);
}

public class UserService {
    private final UserRepository userRepository;
    private final UserProfileRepository userProfileRepository;

    public UserWithProfile getUserWithProfile(User.Id userId) {
        User user = userRepository.findById(userId);
        if (user == null) {
            throw UserNotFoundException("User with ID " + userId + " not found");
        }
        UserProfile details = userProfileRepository.findById(userId);
        return UserWithProfile.of(user, details == null ? UserProfile.defaultDetails() : details);
    }
}

示例开头的接口是为了上下文清晰而提供的。主要的兴趣点是 getUserWithProfile 方法。我们一步一步来分析。

  • 第一条语句从用户存储库中检索 user 变量。
  • 由于用户可能不存在于存储库中,因此 user 变量可能为 null。以下 null 检查验证是否是这种情况,如果是,则抛出业务异常。
  • 下一步是检索用户配置文件详细信息。缺乏细节不被视为错误。相反,当缺少详细信息时,配置文件将使用默认值。

上面的代码有几个问题。首先,如果存储库中不存在值,则返回 null 从接口看并不明显。 我们需要检查文档,研究实现或猜测这些存储库是如何工作的。
有时使用注解来提供提示,但这仍然不能保证 API 的行为。
为了解决这个问题,让我们将规则应用于存储库:

public interface UserRepository {
    Option<User> findById(User.Id userId);
}

public interface UserProfileRepository {
    Option<UserProfile> findById(User.Id userId);
}

现在无需进行任何猜测 – API 明确告知可能不存在返回值。
现在让我们再看看 getUserWithProfile 方法。 要注意的第二件事是该方法可能会返回一个值或可能会引发异常。这是一个业务异常,因此我们可以应用该规则。更改的主要目标 – 明确方法可能返回值错误的事实:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {

好的,现在我们已经清理了 API,可以开始更改代码了。第一个变化是由 userRepository 现在返回
Option<User> 引起的:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
}

现在我们需要检查用户是否存在,如果不存在,则返回一个错误。使用传统的命令式方法,代码应该是这样的:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);

    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

}
代码看起来不是很吸引人,但也不比原来的差,所以暂时保持原样。
下一步是尝试转换剩余部分的代码:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);

    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

    Option<UserProfile> details = userProfileRepository.findById(userId);

}

问题来了:详细信息和用户存储在 Option<T> 容器中,因此要组装 UserWithProfile,我们需要以某种方式提取值。这里可能有不同的方法,例如,使用 Option.fold() 方法。生成的代码肯定不会很漂亮,而且很可能会违反规则。
还有另一种方法 – 使用 Option<T> 是具有特殊属性的容器这一事实。
特别是,可以使用 Option.map()Option.flatMap() 方法转换 Option<T> 中的值。此外,我们知道,details 值将由存储库提供或替换为默认值。为此,我们可以使用 Option.or() 方法从容器中提取详细信息。让我们试试这些方法:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);

    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

    UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());

    Option<UserWithProfile> userWithProfile =  user.map(userValue -> UserWithProfile.of(userValue, details));

}

现在我们需要编写最后一步 – 将 userWithProfile 容器从 Option<T> 转换为 Result<T>

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);

    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

    UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());

    Option<UserWithProfile> userWithProfile =  user.map(userValue -> UserWithProfile.of(userValue, details));

    return userWithProfile.toResult(Cause.cause(""));
}

我们暂时将 return 语句中的错误原因留空,然后再次查看代码。
我们可以很容易地发现一个问题:我们肯定知道 userWithProfile 总是存在 – 当 user 不存在时,上面已经处理了这种情况。我们怎样才能解决这个问题?
请注意,我们可以在不检查用户是否存在的情况下调用 user.map()。仅当 user 存在时才会应用转换,否则将被忽略。 这样,我们可以消除 if(user.isEmpty()) 检查。让我们在传递给 user.map() 的 lambda 中移动对 Userdetails 检索和转换到 UserWithProfile 中:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<UserWithProfile> userWithProfile = userRepository.findById(userId).map(userValue -> {
        UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());
        return UserWithProfile.of(userValue, details);
    });

    return userWithProfile.toResult(Cause.cause(""));
}

现在需要更改最后一行,因为 userWithProfile 可能会缺失。该错误将与以前的版本相同,因为仅当 userRepository.findById(userId) 返回的值缺失时,userWithProfile 才会缺失:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<UserWithProfile> userWithProfile = userRepository.findById(userId).map(userValue -> {
        UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());
        return UserWithProfile.of(userValue, details);
    });

    return userWithProfile.toResult(Causes.cause("User with ID " + userId + " not found"));
}

最后,我们可以内联 detailsuserWithProfile,因为它们仅在创建后立即使用一次:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    return userRepository.findById(userId)
        .map(userValue -> UserWithProfile.of(userValue, userProfileRepository.findById(userId)
                                                                             .or(UserProfile.defaultDetails())))
        .toResult(Causes.cause("User with ID " + userId + " not found"));
}

请注意缩进如何帮助将代码分组为逻辑链接的部分。
让我们来分析结果代码:

  • 代码更简洁,为快乐的日子场景编写,没有明确的错误或 null 检查,没有干扰业务逻辑
  • 没有简单的方法可以跳过或避免错误或 null 检查,编写正确可靠的代码是直接而自然的。

不太明显的观察:

  • 所有类型都是自动派生的。这简化了重构并消除了不必要的混乱。如果需要,仍然可以添加类型。
  • 如果在某个时候存储库将开始返回 Result<T> 而不是 Option<T>,代码将保持不变,除了最后一个转换 (toResult) 将被删除。
  • 除了用 Option.or() 方法替换三元运算符之外,结果代码看起来很像如果我们将传递给 lambda 内部的原始 return 语句中的代码移动到 map() 方法。

最后一个观察对于开始方便地编写(阅读通常不是问题)PFJ 风格的代码非常有用。它可以改写为以下经验规则:在右侧寻找值。比较一下:

User user = userRepository.findById(userId); // <-- 值在表达式左边

return userRepository.findById(userId)
.map(user -> ...); // <-- 值在表达式右边

这种有用的观察有助于从遗留命令式代码风格向 PFJ 转换。

与遗留代码交互

不用说,现有代码不遵循 PFJ 方法。它抛出异常,返回 null 等等。有时可以重新编写此代码以使其与 PFJ 兼容,但通常情况并非如此。对于外部库和框架尤其如此。

调用遗留代码

遗留代码调用有两个主要问题。它们中的每一个都与违反相应的 PFJ 规则有关:

处理业务异常

Result<T> 包含一个名为 lift() 的辅助方法,它涵盖了大多数用例。方法签名看起来是这样:

static <R> Result<R> lift(FN1<? extends Cause, ? super Throwable> exceptionMapper, ThrowingSupplier<R> supplier)

第一个参数是将异常转换为 Cause 实例的函数(反过来,它用于在失败情况下创建 Result<T> 实例)。第二个参数是 lambda,它封装了对需要与 PFJ 兼容的实际代码的调用。
Causesutility 类中提供了最简单的函数,它将异常转换为 Cause 的实例:fromThrowable()。它们可以与 Result.lift() 一起使用,如下所示:

public static Result<URI> createURI(String uri) {
    return Result.lift(Causes::fromThrowable, () -> URI.create(uri));
}

处理 null 值返回

这种情况相当简单 – 如果 API 可以返回 null,只需使用 Option.option() 方法将其包装到 Option<T> 中。

提供遗留 API

有时需要允许遗留代码调用以 PFJ 风格编写的代码。特别是,当一些较小的子系统转换为 PFJ 风格时,通常会发生这种情况,但系统的其余部分仍然以旧风格编写,并且需要保留 API。最方便的方法是将实现拆分为两部分——PFJ 风格的 API 和适配器,它只将新 API 适配到旧 API。这可能是一个非常有用的简单辅助方法,如下所示:

public static <T> T unwrap(Result<T> value) {
    return value.fold(
        cause -> { throw new IllegalStateException(cause.message()); },
        content -> content
    );
}

Result<T> 中没有提供随时可用的辅助方法,原因如下:

  • 可能有不同的用例,并且可以抛出不同类型的异常(已检查和未检查)。
  • Cause 转换为不同的特定异常在很大程度上取决于特定的用例。

管理变量作用域

本节将专门介绍在编写 PFJ 风格代码时出现的各种实际案例。
下面的示例假设使用 Result<T>,但这在很大程度上无关紧要,因为所有考虑因素也适用于 Option<T>。此外,示例假定示例中调用的函数被转换为返回 Result<T> 而不是抛出异常。

嵌套作用域

函数风格代码大量使用 lambda 来执行 Option<T>Result<T> 容器内的值的计算和转换。每个 lambda 都隐式地为其参数创建了作用域——它们可以在 lambda 主体内部访问,但不能在其外部访问。
这通常是一个有用的属性,但对于传统的命令式代码,它很不寻常,一开始可能会觉得不方便。幸运的是,有一种简单的技术可以克服感知上的不便。
我们来看看下面的命令式代码:

var value1 = function1(...); // function1()
 可能抛出异常
var value2 = function2(value1, ...); // function2() 可能抛出异常
var value3 = function3(value1, value2, ...); // function3() 可能抛出异常

变量 value1 应该可访问以调用 function2() 和 function3()。 这确实意味着直接转换为 PFJ 样式将不起作用:

function1(...)
.flatMap(value1 -> function2(value1, ...))
.flatMap(value2 -> function3(value1, value2, ...)); // <-- 错, value1 不可访问

为了保持值的可访问性,我们需要使用嵌套作用域,即嵌套调用如下:

function1(...)
.flatMap(value1 -> function2(value1, ...)
    .flatMap(value2 -> function3(value1, value2, ...)));

第二次调用 flatMap() 是针对 function2 返回的值而不是第一个 flatMap() 返回的值。通过这种方式,我们将 value1 保持在范围内,并使 function3 可以访问它。
尽管可以创建任意深度的嵌套作用域,但通常多个嵌套作用域更难阅读和遵循。在这种情况下,强烈建议将更深的范围提取到专用函数中。

平行作用域

另一个经常观察到的情况是需要计算/检索几个独立的值,然后进行调用或构建一个对象。让我们看看下面的例子:

var value1 = function1(...);    // function1() 可能抛出异常
var value2 = function2(...);    // function2() 可能抛出异常
var value3 = function3(...);    // function3() 可能抛出异常
return new MyObject(value1, value2, value3);

乍一看,转换为 PFJ 样式可以与嵌套作用域完全相同。每个值的可见性将与命令式代码相同。不幸的是,这会使范围嵌套很深,尤其是在需要获取许多值的情况下。
对于这种情况,Option<T>Result<T> 提供了一组 all() 方法。这些方法执行所有值的“并行”计算并返回 MapperX<...> 接口的专用版本。 这个接口只有三个方法—— id()map()flatMap()map()flatMap() 方法的工作方式与 Option<T>Result<T> 中的相应方法完全相同,只是它们接受具有不同数量参数的 lambda。让我们来看看它在实践中是如何工作的,并将上面的命令式代码转换为 PFJ 样式:

return Result.all(
          function1(...),
          function2(...),
          function3(...)
        ).map(MyObject::new);

除了紧凑和扁平之外,这种方法还有一些优点。首先,它明确表达意图——在使用前计算所有值。命令式代码按顺序执行此操作,隐藏了原始意图。第二个优点 – 每个值的计算是独立的,不会将不必要的值带入范围。这减少了理解和推理每个函数调用所需的上下文。

替代作用域

一个不太常见但仍然很重要的情况是我们需要检索一个值,但如果它不可用,那么我们使用该值的替代来源。当有多个替代方案可用时,这种情况的频率甚至更低,而且在涉及错误处理时会更加痛苦。
我们来看看下面的命令式代码:

MyType value;

try {
    value = function1(...);
} catch (MyException e1) {
    try {
        value = function2(...);    
    } catch(MyException e2) {
        try {
            value = function3(...);
        } catch(MyException e3) {
            ... // repeat as many times as there are alternatives
        }
    }
}

代码是人为设计的,因为嵌套案例通常隐藏在其他方法中。尽管如此,整体逻辑并不简单,主要是因为除了选择值之外,我们还需要处理错误。错误处理使代码变得混乱,并使初始意图 – 选择第一个可用的替代方案 – 隐藏在错误处理中。
转变为 PFJ 风格使意图非常清晰:

var value = Result.any(
        function1(...),
        function2(...),
        function3(...)
    );

不幸的是,这里有一个重要的区别:原始命令式代码仅在必要时计算第二个和后续替代项。在某些情况下,这不是问题,但在许多情况下,这是非常不可取的。幸运的是,Result.any() 有一个惰性版本。使用它,我们可以重写代码如下:

var value = Result.any(
        function1(...),
        () -> function2(...),
        () -> function3(...)
    );

现在,转换后的代码的行为与它的命令式对应代码完全一样。

Option<T> 和 Result<T> 的简要技术概述

这两个容器在函数式编程术语中是单子(monad)。
Option<T>Option/Optional/Maybe monad 的直接实现。
Result<T>Either<L,R> 的特意简化和专门版本:左类型是固定的,应该实现 Cause 接口。专业化使 API 与 Option<T> 非常相似,并以失去通用性为代价消除了许多不必要的输入。
这个特定的实现集中在两件事上:

  • 与现有 JDK 类(如 Optional<T>Stream<T>)之间的互操作性
  • 用于明确意图表达的 API

最后一句话值得更深入的解释。
每个容器都有几个核心方法:

  • 工厂方法
  • map() 转换方法,转换值但不改变特殊状态:present Option<T> 保持 present,success Result<T> 保持 success
  • flatMap() 转换方法,除了转换之外,还可以改变特殊状态:将 Option<T> present 转换为 empty 或将 Result<T> success 转换为 failure
  • fold() 方法,它同时处理两种情况(Option<T>present/emptyResult<T>success/failure)。

除了核心方法,还有一堆辅助方法,它们在经常观察到的用例中很有用。
在这些方法中,有一组方法是明确设计来产生副作用的。
Option<T> 有以下副作用的方法:

Option<T> whenPresent(Consumer<? super T> consumer);
Option<T> whenEmpty(Runnable action);
Option<T> apply(Runnable emptyValConsumer, Consumer<? super T> nonEmptyValConsumer);

Result<T> 有以下副作用的方法:

Result<T> onSuccess(Consumer<T> consumer);
Result<T> onSuccessDo(Runnable action);
Result<T> onFailure(Consumer<? super Cause> consumer);
Result<T> onFailureDo(Runnable action);
Result<T> apply(Consumer<? super Cause> failureConsumer, Consumer<? super T> successConsumer);

这些方法向读者提供了代码处理副作用而不是转换的提示。

其他有用的工具

除了 Option<T>Result<T> 之外,PFJ 还使用了一些其他通用类。下面,将对每种方法进行更详细地描述。

Functions(函数)

JDK 提供了许多有用的功能接口。不幸的是,通用函数的函数式接口仅限于两个版本:单参数 Function<T, R> 和两个参数 BiFunction<T, U, R>
显然,这在许多实际情况中是不够的。此外,出于某种原因,这些函数的类型参数与 Java 中函数的声明方式相反:结果类型列在最后,而在函数声明中,它首先定义。
PFJ 为具有 1 到 9 个参数的函数使用一组一致的函数接口。 为简洁起见,它们被称为 FN1…FN9。到目前为止,还没有更多参数的函数用例(通常这是代码异味)。但如果有必要,该清单可以进一步扩展。

Tuples(元组)

元组是一种特殊的容器,可用于在单个变量中存储多个不同类型的值。与类或记录不同,存储在其中的值没有名称。这使它们成为在保留类型的同时捕获任意值集的不可或缺的工具。这个用例的一个很好的例子是 Result.all() Option.all() 方法集的实现。
在某种意义上,元组可以被认为是为函数调用准备的一组冻结的参数。从这个角度来看,让元组内部值只能通过 map() 方法访问的决定听起来很合理。然而,具有 2 个参数的元组具有额外的访问器,可以使用 Tuple2<T1,T2> 作为各种 Pair<T1,T2> 实现的替代。
PFJ 使用一组一致的元组实现,具有 0 到 9 个值。提供具有 0 和 1 值的元组以保持一致性。

结论

实用函数式 Java 是一种基于函数式编程概念的现代、非常简洁但可读的 Java 编码风格。与传统的惯用 Java 编码风格相比,它提供了许多好处:

  • PFJ 借助 Java 编译器来帮助编写可靠的代码:
    • 编译的代码通常是有效的
    • 许多错误从运行时转移到编译时
    • 某些类别的错误,例如 NullPointerException 或未处理的异常,实际上已被消除
  • PFJ 显着减少了与错误传播和处理以及 null 检查相关的样板代码量
  • PFJ 专注于清晰表达意图并减少心理负担

Bash 脚本简介

【注】本文译自: An Introduction to Bash Scripting

Bash 脚本简介

幻想自己是计算机科学家、业余爱好者或技术书呆子吗?然后在某个时候,您将或应该考虑在您的数字工作区中使用 Bash 脚本。

Bash (Bourne Again Shell) 是一个解释器,负责处理Unix系统命令行上的命令。它是由 Brian Fox 编写的免费软件,并于 1989 年发布的免费软件,作为 Sh(Bourne Shell)的替代品。Bash 被开发人员、数据科学家、系统管理员、网络工程师和任何其他在日常工作中严重依赖 Unix 操作系统的人使用。一般来说,Bash 脚本用于自动化计算机科学家可能承担的日常补救任务。简而言之,shell 脚本只不过是存储在一个文件(如列表)中的一系列命令。

您可以在 Linux 和 MacOS 机器上使用 Bash,甚至可以通过适用于 Linux 的 Windows 子系统在 Windows 10 机器上使用。Bash 通常在文本窗口中运行,用户可以在其中键入命令让计算机执行操作。该语言还可用于从文件读取和执行命令,称为 shell 脚本。Shell 脚本本身就是一种编程语言,与任何其他语言一样,Bash 是一种可以以多种方式使用的工具。

如果您之前见过运行 Linux 操作系统(或类 Unix 环境)的机器,您可能也见过终端控制台。终端是用户使用某些命令与 shell 解释器进行交互的方式。诸如 cd 导航文件目录,ls 列出当前目录中的文件,以及 nano 编辑文件等命令。

Unix 中的 Bash 终端示例

在终端中使用 Bash 代码,它将由 Bash 解释器运行。ls 之类的命令是位于 /bin 目录中的二进制可执行文件。 当 shell 收到该命令时(当您在终端中键入它并按 Enter 键时),它会执行 ls 文件并为用户列出当前目录中的文件。使用命令 ls /bin 以路径 /bin 作为选项执行二进制 ls,列出 /bin 目录中的文件。执行 ls -al 会运行带有标志 -a-l 作为选项的 ls 命令,列出当前目录路径中的所有文件和目录以及有关这些项目的详细信息。

touch 是另一个这样的二进制可执行文件,用户可以在终端中使用的命令。 此命令的输出是一个新文件,用户输入的名称作为选项。例如,用户可以编写 touch hello.txt,输出将是一个文件 hello.txt

如何运行多个 Bash 命令

要运行多个 Bash 命令并让它们一次执行,用户可以将这些命令保存在单个文件中,以便用 bash 执行。假设您在 Unix/类 Unix 环境中工作,让我们考虑一下我们之前讨论过的内容。

打开命令终端后,首先使用您喜欢的文本编辑器,例如 nanovi。写入:

nano make_a_file.txt

然后,写出以下内容:

#create a file
touch hello.txt

#list files from this directory
ls -al

保存并退出文件,并使用以下命令语法之一运行新脚本:

sh make_a_file.txt

./make_a_file.txt

bash make_a_file.txt

如果执行该文件时出错,请通过输入以下内容继续为您刚刚编写的脚本文件设置可执行权限:

chmod +x hello.sh

如果您遵循了这个示例,那么您刚刚创建了一个包含多个 Bash 命令的文件。Bash 解释器将按顺序运行这些命令并忽略以哈希符号 # 开头的行,因为这些行是注释。运行该文件会产生一个文件列表的终端输出,其中将包含一个 hello.txt,之前没有。

通常,一个 Bash 脚本文件以包含 .sh 扩展名的格式保存,这表明该文件是一个 shell 脚本。但是,当文件以“she-bang”或“hashbang”开头时,我们可以像二进制文件一样执行它。

在创建脚本时,我们应该考虑到每个二进制 shell 文件都以俗称的“she-bang”(也称为 sh-bang 或 hashbang)开头。这是脚本标题的开始,第一行代码指示您将使用哪个 shell。在制作脚本时,我们有多种选择可供选择,包括 shell (sh)C ShellZ Shell 等。在这种情况下,我们将继续使用 Bash 来满足我们的脚本需求。She-bang 是脚本开头的一组符号“#”“!”。我们知道井号 (#) 表示一行是注释。 然而,使用 she-bang,类 Unix 系统的程序解释器会将第一行的其余部分解析为解释器指令。在这种情况下,在写入 #!/bin/bash 时,哈希符号和感叹号作为程序加载器的指示符,指示它使用位于 /bin/bash 目录的 Bash Shell 程序。

如何在 Bash 中创建变量

像大多数其他 Unix shell 一样,Bash 具有变量、管道、文件名通配符、here 文档、命令替换和控制流。Bash 还支持交替(它与 C shell 共享)、命令行完成以及信号处理和基本调试。有了这些特性,bash 成为 Unix 和类 Unix 系统的默认命令解释器也就不足为奇了。

像其他编程语言一样,我们可以在使用 Bash 编写脚本时声明变量。但是,与其他语言不同,Bash 不需要关键字来声明变量或为其分配数据类型。Bash 没有类型系统,仅将变量保存为字符串值。但是,Bash 可以根据某些操作(例如算术运算)自动将变量转换为合适的类型。要写入变量并为其填充值,请以 VARIABLE=VALUE 格式写入内容,确保不包含空格。下面是一个示例,展示了如何在 Bash 中创建变量:

#!/bin/bash

#write a variable
NAME=“William”

#use that variable
echo “Hello $NAME”

用户还可以通过用户输入来填充变量:

#!/bin/bash

echo “Hello $1, that is a $2 name”

在终端中:

~$bash name.sh “William” “great”
Hello William, that is a great name

还可以使用 read 之类的命令在运行时使用用户输入的变量:

#!/bin/bash

echo “What is your name?”

read name

echo “Hello $name”

在终端中:

~$bash name.sh
What is your name?
~$William
Hello William

Bash 中的 if 语句

我们还可以为附加功能实现 if 语句。

#!/bin/bash

echo “Who is there?”

read name

if [ $name ]
echo “Hello $name”
else
 echo “Must’ve been my imagination”
fi

在终端中:

~$bash name.sh
Who is there?
~$
Must’ve been my imagination

如何在 Bash 中创建备份管理脚本

其他需要考虑的项目包括设置备份管理脚本。这可以是一个简单的项目,可以开始并在以后重新访问。有了这个,您可以制作一个简单的脚本,针对一个或多个文件和文件夹使用 tar 库进行压缩,并将其放置在您选择的新备份目录中。以下脚本是一个基本的备份脚本,它为需要备份的文件创建一个 .Zip 文件,并根据创建日期对它们进行标记:

#!/bin/bash

#get the month, day, and year of the current date
TIME_OF_BACKUP=`date +%m-%d-%y`

#create a backup file using the current date in its name
DESTINATION=/path/[BACKUP FOLDER]-$TIME_OF_BACKUP.tar.gz

#the folder that contains the files that we want to backup
TARGET_FOLDER=/path/[TARGET FOLDER]

#create the backup
tar -cpzf $DESTINATION $TARGET_FOLDER

作为奖励,您可能希望通过为备份脚本添加计划执行来增加此项目的一些复杂性,并增加自动化级别。为此,您可以使用 crontab 程序和命令库。如果您需要安装 cron,请确保在继续安装 cron 之前更新您当前的包库。

sudo apt-get update
sudo apt-get install cron

成功安装后,您可以继续使用 cron 库来安排脚本的执行。

crontab -e

这将打开 /etc/crontab 文件,并允许您编写如下命令来安排脚本的执行:

@weekly /path/backup_script.sh

我不会进一步讨论您可以使用 crontab 做什么,因为它不在本文的范围内。

总结

通过本入门指南,您将对什么是 Bash、什么是脚本以及 Bash 中的脚本有什么了解。你可以用 Bash 做很多事情,而且你不需要了解很多关于编程的知识,就可以将不同的 Linux 应用程序和工具拼凑在一起,并制作一些有用的东西。Bash 脚本是一个非常有用的工具,希望您能从这篇文章中获得灵感,让您的想法自动化。

如何在 Spring 中使用事件

【注】本文译自:Spring Events | Baeldung

1. 概述

在本教程中,我们将讨论如何在 Spring 中使用事件

事件是框架中最容易被忽视的功能之一,但也是更有用的功能之一。和 Spring 中的许多其他东西一样,事件发布是 ApplicationContext 提供的功能之一。

有一些简单的指导方针可以遵循:

  • 如果我们使用 Spring Framework 4.2 之前的版本,事件类应该扩展 ApplicationEvent。从 4.2 版本开始,事件类不再需要扩展 ApplicationEvent 类。
  • 发布者应该注入一个 ApplicationEventPublisher 对象。
  • 监听器应实现 ApplicationListener 接口。

    2.自定义事件

    Spring 允许我们创建和发布默认同步的自定义事件。这有一些优点,例如监听器能够参与发布者的事务上下文。

2.1.一个简单的应用程序事件

让我们创建一个简单的事件类——只是一个用于存储事件数据的占位符。

在这种情况下,事件类包含一个 String 消息:

public class CustomSpringEvent extends ApplicationEvent {
    private String message;

    public CustomSpringEvent(Object source, String message) {
        super(source);
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}

2.2. 发布者

现在让我们创建该事件的发布者。发布者构造事件对象并将其发布给正在收听的任何人。

要发布事件,发布者可以简单地注入 ApplicationEventPublisher 并使用 publishEvent() API:

@Component
public class CustomSpringEventPublisher {
    @Autowired
    private ApplicationEventPublisher applicationEventPublisher;

    public void publishCustomEvent(final String message) {
        System.out.println("Publishing custom event. ");
        CustomSpringEvent customSpringEvent = new CustomSpringEvent(this, message);
        applicationEventPublisher.publishEvent(customSpringEvent);
    }
}

或者,发布者类可以实现
ApplicationEventPublisherAware 接口,这也会在应用程序启动时注入事件发布者。通常,将 @Autowire 注入发布者会更简单。

从 Spring Framework 4.2 开始,ApplicationEventPublisher 接口为 publishEvent(Object event) 方法提供了一个新的重载,该方法接受任何对象作为事件。因此,Spring 事件不再需要扩展 ApplicationEvent

2.3. 监听器

最后,让我们创建监听器。

监听器的唯一要求是是一个 bean 并实现 ApplicationListener 接口:

@Component
public class CustomSpringEventListener implements ApplicationListener<CustomSpringEvent> {
    @Override
    public void onApplicationEvent(CustomSpringEvent event) {
        System.out.println("Received spring custom event - " + event.getMessage());
    }
}

请注意我们的自定义监听器如何使用自定义事件的通用类型进行参数化,这使得 onApplicationEvent() 方法类型安全。这也避免了必须检查对象是否是特定事件类的实例并对其进行转换。

而且,正如已经讨论过的(默认情况下,Spring 事件是同步的), doStuffAndPublishAnEvent() 方法会阻塞,直到所有监听器完成对事件的处理。

3.创建异步事件

在某些情况下,同步发布事件并不是我们真正想要的——我们可能需要异步处理我们的事件

我们可以通过创建一个带有执行程序的
ApplicationEventMulticaster bean 在配置中打开它。

对于我们来说 SimpleAsyncTaskExecutor 很好地实现了这个目的:

@Configuration
public class AsynchronousSpringEventsConfig {
    @Bean(name = "applicationEventMulticaster")
    public ApplicationEventMulticaster simpleApplicationEventMulticaster() {
        SimpleApplicationEventMulticaster eventMulticaster = new SimpleApplicationEventMulticaster();
        eventMulticaster.setTaskExecutor(new SimpleAsyncTaskExecutor());
        return eventMulticaster;
    }
}

事件、发布者和监听器实现与以前相同,但现在监听器将在单独的线程中异步处理事件

4.现有框架事件

Spring 本身发布了各种开箱即用的事件。例如,ApplicationContext 将触发各种框架事件:ContextRefreshedEventContextStartedEventRequestHandledEvent 等。

这些事件为应用程序开发人员提供了一个选项,可以连接到应用程序的生命周期和上下文,并在需要的地方添加他们自己的自定义逻辑。

这是监听上下文刷新的监听器的快速示例:

public class ContextRefreshedListener implements ApplicationListener<ContextRefreshedEvent> {
    @Override
    public void onApplicationEvent(ContextRefreshedEvent cse) {
        System.out.println("Handling context re-freshed event. ");
    }
}

要了解有关现有框架事件的更多信息,请在此处查看我们的下一个教程

5.注解驱动的事件监听器

从 Spring 4.2 开始,事件监听器不需要是实现 ApplicationListener 接口的 bean——它可以通过 @EventListener 注解在托管 bean 的任何 public 方法上注册:

@Component
public class AnnotationDrivenEventListener {
    @EventListener
    public void handleContextStart(ContextStartedEvent cse) {
        System.out.println("Handling context started event.");
    }
}

和以前一样,方法签名声明了它使用的事件类型。

默认情况下,监听器是同步调用的。但是,我们可以通过添加 @Async 注释轻松地使其异步。我们只需要记住在应用程序中启用异步支持

6.泛型支持

也可以使用事件类型中的泛型信息来调度事件。

6.1. 泛型应用程序事件

让我们创建一个泛型事件类型。

在我们的示例中,事件类包含任何内容和 success 状态指示器:

public class GenericSpringEvent<T> {
    private T what;
    protected boolean success;

    public GenericSpringEvent(T what, boolean success) {
        this.what = what;
        this.success = success;
    }
    // ... standard getters
}

请注意 GenericSpringEventCustomSpringEvent 之间的区别。我们现在可以灵活地发布任意事件,并且不再需要从 ApplicationEvent 扩展。

6.2.监听器

现在让我们创建该事件的监听器。

我们可以像以前一样通过实现 ApplicationListener 接口来定义监听器:

@Component
public class GenericSpringEventListener implements ApplicationListener<GenericSpringEvent<String>> {
    @Override
    public void onApplicationEvent(@NonNull GenericSpringEvent<String> event) {
        System.out.println("Received spring generic event - " + event.getWhat());
    }
}

但不幸的是,这个定义要求我们从 ApplicationEvent 类继承 GenericSpringEvent。因此,对于本教程,让我们使用之前讨论过的注释驱动事件监听器。

通过在 @EventListener 注释上定义布尔 SpEL 表达式,也可以使事件监听器有条件。

在这种情况下,只有成功调用 GenericSpringEvent 的 String 对象时才会调用事件处理程序:

@Component
public class GenericSpringEventListener implements ApplicationListener<GenericSpringEvent<String>> {
    @Override
    public void onApplicationEvent(@NonNull GenericSpringEvent<String> event) {
        System.out.println("Received spring generic event - " + event.getWhat());
    }

}
Spring 表达式语言 (SpEL) 是一种强大的表达式语言,在另一篇教程中有详细介绍。

6.3. 发布者

事件发布者与上述类似。但是由于类型擦除,我们需要发布一个事件来解析我们将过滤的泛型参数,例如,类 GenericStringSpringEvent extends GenericSpringEvent

此外,还有一种发布事件的替代方法。如果我们从使用 @EventListener 注解的方法返回一个非空值作为结果,Spring Framework 会将该结果作为新事件发送给我们。此外,通过将多个新事件作为事件处理的结果返回到一个集合中,我们可以发布多个新事件。

7.事务绑定事件

本节是关于使用 @
TransactionalEventListener
注解的。要了解有关事务管理的更多信息,请查看使用 Spring 和 JPA 事务

从 Spring 4.2 开始,框架提供了一个新的 @
TransactionalEventListener
注解,它是 @EventListener 的扩展,它允许将事件的监听器绑定到事务的某个阶段。

可以绑定到以下事务阶段:

  • AFTER_COMMIT(默认)- 用于在事务成功完成时触发事件。
  • AFTER_ROLLBACK – 如果事务已回滚
  • AFTER_COMPLETION – 如果事务已完成AFTER_COMMITAFTER_ROLLBACK 的别名)
  • BEFORE_COMMIT – 用于在事务提交之前触发事件。

这是一个事务性事件监听器的快速示例:

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleCustom(CustomSpringEvent event) {
    System.out.println("Handling event inside a transaction BEFORE COMMIT.");
}

仅当存在事件生产者正在运行且即将提交的事务时,才会调用此监听器。

如果没有事务在运行,则根本不会发送事件,除非我们通过将 fallbackExecution 属性设置为 true 来覆盖它。

8. 结论

在这篇简短的文章中,我们介绍了在 Spring 中处理事件的基础知识,包括创建一个简单的自定义事件、发布它,然后在监听器中处理它。

我们还简要了解了如何在配置中启用事件的异步处理。

然后我们了解了 Spring 4.2 中引入的改进,例如注解驱动的监听器、更好的泛型支持和事件绑定到事务阶段。

与往常一样,本文中提供的代码可在 GitHub 上获得。这是一个基于 Maven 的项目,因此它应该很容易导入和运行。

使用 Spring Boot 和 @SpringBootTest 进行测试

【注】本文译自: Testing with Spring Boot and @SpringBootTest – Reflectoring

使用@SpringBootTest 注解,Spring Boot 提供了一种方便的方法来启动要在测试中使用的应用程序上下文。在本教程中,我们将讨论何时使用 @SpringBootTest 以及何时更好地使用其他工具进行测试。我们还将研究自定义应用程序上下文的不同方法以及如何减少测试运行时间。

 代码示例

本文附有 GitHub 上的工作代码示例。

“使用 Spring Boot 进行测试”系列

本教程是系列的一部分:

  1. 使用 Spring Boot 进行单元测试
  2. 使用 Spring Boot 和 @WebMvcTest 测试 MVC Web Controller
  3. 使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询
  4. 使用 Spring Boot 和 @SpringBootTest 进行测试

集成测试与单元测试

在开始使用 Spring Boot 进行集成测试之前,让我们定义集成测试与单元测试的区别。
单元测试涵盖单个“单元”,其中一个单元通常是单个类,但也可以是组合测试的一组内聚类。
集成测试可以是以下任何一项:

  • 涵盖多个“单元”的测试。它测试两个或多个内聚类集群之间的交互。
  • 覆盖多个层的测试。这实际上是第一种情况的特化,例如可能涵盖业务服务和持久层之间的交互。
  • 涵盖整个应用程序路径的测试。在这些测试中,我们向应用程序发送请求并检查它是否正确响应并根据我们的预期更改了数据库状态。

Spring Boot 提供了 @SpringBootTest 注解,我们可以使用它来创建一个应用程序上下文,其中包含我们对上述所有测试类型所需的所有对象。但是请注意,过度使用 @SpringBootTest 可能会导致测试套件运行时间非常长
因此,对于涵盖多个单元的简单测试,我们应该创建简单的测试,与单元测试非常相似,在单元测试中,我们手动创建测试所需的对象图并模拟其余部分。这样,Spring 不会在每次测试开始时启动整个应用程序上下文。

测试切片

我们可以将我们的 Spring Boot 应用程序作为一个整体来测试、一个单元一个单元地测试、也可以一层一层地测试。使用 Spring Boot 的测试切片注解,我们可以分别测试每一层。
在我们详细研究 @SpringBootTest 注解之前,让我们探索一下测试切片注解,以检查 @SpringBootTest 是否真的是您想要的。
@SpringBootTest 注解加载完整的 Spring 应用程序上下文。相比之下,测试切片注释仅加载测试特定层所需的 bean。正因为如此,我们可以避免不必要的模拟和副作用。

@WebMvcTest

我们的 Web 控制器承担许多职责,例如侦听 HTTP 请求、验证输入、调用业务逻辑、序列化输出以及将异常转换为正确的响应。我们应该编写测试来验证所有这些功能。
@WebMvcTest 测试切片注释将使用刚好足够的组件和配置来设置我们的应用程序上下文,以测试我们的 Web 控制器层。例如,它将设置我们的@Controller@ControllerAdvice、一个 MockMvc bean 和其他一些自动配置
要阅读有关 @WebMvcTest 的更多信息并了解我们如何验证每个职责,请阅读我关于使用 Spring Boot 和 @WebMvcTest 测试 MVC Web 控制器的文章

@WebFluxTest

@WebFluxTest 用于测试 WebFlux 控制器。 @WebFluxTest 的工作方式类似于 @WebMvcTest 注释,不同之处在于它不是 Web MVC 组件和配置,而是启动 WebFlux 组件和配置。其中一个 bean 是 WebTestClient,我们可以使用它来测试我们的 WebFlux 端点。

@DataJpaTest

就像 @WebMvcTest 允许我们测试我们的 web 层一样,@DataJpaTest 用于测试持久层。
它配置我们的实体、存储库并设置嵌入式数据库。现在,这一切都很好,但是,测试我们的持久层意味着什么? 我们究竟在测试什么? 如果查询,那么什么样的查询?要找出所有这些问题的答案,请阅读我关于使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询的文章

@DataJdbcTest

Spring Data JDBC 是 Spring Data 系列的另一个成员。 如果我们正在使用这个项目并且想要测试持久层,那么我们可以使用 @DataJdbcTest 注解 。@DataJdbcTest 会自动为我们配置在我们的项目中定义的嵌入式测试数据库和 JDBC 存储库。
另一个类似的项目是 Spring JDBC,它为我们提供了 JdbcTemplate 对象来执行直接查询。@JdbcTest 注解自动配置测试我们的 JDBC 查询所需的 DataSource 对象。依赖
本文中的代码示例只需要依赖 Spring Boot 的 test starter 和 JUnit Jupiter:

dependencies {
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.junit.jupiter:junit-jupiter:5.4.0')
}

使用 @SpringBootTest 创建 ApplicationContext

@SpringBootTest 在默认情况下开始在测试类的当前包中搜索,然后在包结构中向上搜索,寻找用 @SpringBootConfiguration 注解的类,然后从中读取配置以创建应用程序上下文。这个类通常是我们的主要应用程序类,因为 @SpringBootApplication 注解包括 @SpringBootConfiguration 注解。然后,它会创建一个与在生产环境中启动的应用程序上下文非常相似的应用程序上下文。
我们可以通过许多不同的方式自定义此应用程序上下文,如下一节所述。
因为我们有一个完整的应用程序上下文,包括 web 控制器、Spring 数据存储库和数据源,@SpringBootTest 对于贯穿应用程序所有层的集成测试非常方便:

@ExtendWith(SpringExtension.class)
@SpringBootTest
@AutoConfigureMockMvc
class RegisterUseCaseIntegrationTest {

  @Autowired
  private MockMvc mockMvc;

  @Autowired
  private ObjectMapper objectMapper;

  @Autowired
  private UserRepository userRepository;

  @Test
  void registrationWorksThroughAllLayers() throws Exception {
    UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");

    mockMvc.perform(post("/forums/{forumId}/register", 42L)
            .contentType("application/json")
            .param("sendWelcomeMail", "true")
            .content(objectMapper.writeValueAsString(user)))
            .andExpect(status().isOk());

    UserEntity userEntity = userRepository.findByName("Zaphod");
    assertThat(userEntity.getEmail()).isEqualTo("zaphod@galaxy.net");
  }
}

@ExtendWith
本教程中的代码示例使用 @ExtendWith 注解告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注释包含在 Spring Boot 测试注释中,例如 @DataJpaTest@WebMvcTest@SpringBootTest

在这里,我们另外使用 @AutoConfigureMockMvc 将 MockMvc 实例添加到应用程序上下文中。
我们使用这个 MockMvc 对象向我们的应用程序执行 POST 请求并验证它是否按预期响应。
然后,我们使用应用程序上下文中的 UserRepository 来验证请求是否导致数据库状态发生预期的变化。

自定义应用程序上下文

我们可以有很多种方法来自定义 @SpringBootTest 创建的应用程序上下文。让我们看看我们有哪些选择。

自定义应用上下文时的注意事项
应用程序上下文的每个自定义都是使其与在生产设置中启动的“真实”应用程序上下文不同的另一件事。因此,为了使我们的测试尽可能接近生产,我们应该只定制让测试运行真正需要的东西!

添加自动配置

在上面,我们已经看到了自动配置的作用:

@SpringBootTest
@AutoConfigureMockMvc
class RegisterUseCaseIntegrationTest {
  ...
}

还有很多其他可用的自动配置,每个都可以将其他 bean 添加到应用程序上下文中。以下是文档中其他一些有用的内容:

  • @AutoConfigureWebTestClient:将 WebTestClient 添加到测试应用程序上下文。它允许我们测试服务器端点。
  • @AutoConfigureTestDatabase:这允许我们针对真实数据库而不是嵌入式数据库运行测试。
  • @RestClientTest:当我们想要测试我们的 RestTemplate 时它会派上用场。 它自动配置所需的组件以及一个 MockRestServiceServer 对象,该对象帮助我们模拟来自 RestTemplate 调用的请求的响应。
  • @JsonTest:自动配置 JSON 映射器和类,例如 JacksonTesterGsonTester。使用这些我们可以验证我们的 JSON 序列化/反序列化是否正常工作。

设置自定义配置属性

通常,在测试中需要将一些配置属性设置为与生产设置中的值不同的值:

@SpringBootTest(properties = "foo=bar")
class SpringBootPropertiesTest {

  @Value("${foo}")
  String foo;

  @Test
  void test(){
    assertThat(foo).isEqualTo("bar");
  }
}

如果属性 foo 存在于默认设置中,它将被此测试的值 bar 覆盖。

使用 @ActiveProfiles 外部化属性

如果我们的许多测试需要相同的属性集,我们可以创建一个配置文件 application-<profile>.propertieapplication-<profile>.yml 并通过激活某个配置文件从该文件加载属性:

# application-test.yml
foo: bar
@SpringBootTest
@ActiveProfiles("test")
class SpringBootProfileTest {

  @Value("${foo}")
  String foo;

  @Test
  void test(){
    assertThat(foo).isEqualTo("bar");
  }
}

使用 @TestPropertySource 设置自定义属性

另一种定制整个属性集的方法是使用 @TestPropertySource 注释:

# src/test/resources/foo.properties
foo=bar
@SpringBootTest
@TestPropertySource(locations = "/foo.properties")
class SpringBootPropertySourceTest {

  @Value("${foo}")
  String foo;

  @Test
  void test(){
    assertThat(foo).isEqualTo("bar");
  }
}

foo.properties 文件中的所有属性都加载到应用程序上下文中。@TestPropertySource 还可以 配置更多。

使用 @MockBean 注入模拟

如果我们只想测试应用程序的某个部分而不是从传入请求到数据库的整个路径,我们可以使用 @MockBean 替换应用程序上下文中的某些 bean:

@SpringBootTest
class MockBeanTest {

  @MockBean
  private UserRepository userRepository;

  @Autowired
  private RegisterUseCase registerUseCase;

  @Test
  void testRegister(){
    // given
    User user = new User("Zaphod", "zaphod@galaxy.net");
    boolean sendWelcomeMail = true;
    given(userRepository.save(any(UserEntity.class))).willReturn(userEntity(1L));

    // when
    Long userId = registerUseCase.registerUser(user, sendWelcomeMail);

    // then
    assertThat(userId).isEqualTo(1L);
  }

}

在这种情况下,我们用模拟替换了 UserRepository bean。使用 Mockitogiven 方法,我们指定了此模拟的预期行为,以测试使用此存储库的类。
您可以在我关于模拟的文章中阅读有关 @MockBean 注解的更多信息。

使用 @Import 添加 Bean

如果某些 bean 未包含在默认应用程序上下文中,但我们在测试中需要它们,我们可以使用 @Import 注解导入它们:

package other.namespace;

@Component
public class Foo {
}

@SpringBootTest
@Import(other.namespace.Foo.class)
class SpringBootImportTest {

  @Autowired
  Foo foo;

  @Test
  void test() {
    assertThat(foo).isNotNull();
  }
}

默认情况下,Spring Boot 应用程序包含它在其包和子包中找到的所有组件,因此通常只有在我们想要包含其他包中的 bean 时才需要这样做。

使用 @TestConfiguration 覆盖 Bean

使用 @TestConfiguration,我们不仅可以包含测试所需的其他 bean,还可以覆盖应用程序中已经定义的 bean。在我们关于使用 @TestConfiguration 进行测试的文章中阅读更多相关信息。

创建自定义 @SpringBootApplication

我们甚至可以创建一个完整的自定义 Spring Boot 应用程序来启动测试。如果这个应用程序类与真正的应用程序类在同一个包中,但是在测试源而不是生产源中,@SpringBootTest 会在实际应用程序类之前找到它,并从这个应用程序加载应用程序上下文。
或者,我们可以告诉 Spring Boot 使用哪个应用程序类来创建应用程序上下文:

@SpringBootTest(classes = CustomApplication.class)
class CustomApplicationTest {
}

但是,在执行此操作时,我们正在测试可能与生产环境完全不同的应用程序上下文,因此仅当无法在测试环境中启动生产应用程序时,这才应该是最后的手段。但是,通常有更好的方法,例如使真实的应用程序上下文可配置以排除不会在测试环境中启动的 bean。让我们看一个例子。
假设我们在应用程序类上使用 @EnableScheduling 注解。每次启动应用程序上下文时(即使在测试中),所有 @Scheduled 作业都将启动,并且可能与我们的测试冲突。 我们通常不希望作业在测试中运行,因此我们可以创建第二个没有 @EnabledScheduling 注释的应用程序类,并在测试中使用它。但是,更好的解决方案是创建一个可以使用属性切换的配置类:

@Configuration
@EnableScheduling
@ConditionalOnProperty(
        name = "io.reflectoring.scheduling.enabled",
        havingValue = "true",
        matchIfMissing = true)
public class SchedulingConfiguration {
}

我们已将 @EnableScheduling 注解从我们的应用程序类移到这个特殊的配置类。将属性 io.reflectoring.scheduling.enabled 设置为 false 将导致此类不会作为应用程序上下文的一部分加载:

@SpringBootTest(properties = "io.reflectoring.scheduling.enabled=false")
class SchedulingTest {

  @Autowired(required = false)
  private SchedulingConfiguration schedulingConfiguration;

  @Test
  void test() {
    assertThat(schedulingConfiguration).isNull();
  }
}

我们现在已经成功地停用了测试中的预定作业。属性 io.reflectoring.scheduling.enabled 可以通过上述任何方式指定。

为什么我的集成测试这么慢?

包含大量 @SpringBootTest 注释测试的代码库可能需要相当长的时间才能运行。Spring 的测试支持 足够智能,只创建一次应用上下文并在后续测试中重复使用,但是如果不同的测试需要不同的应用上下文,它仍然会为每个测试创建一个单独的上下文,这需要一些时间来完成每个测试。
上面描述的所有自定义选项都会导致 Spring 创建一个新的应用程序上下文。因此,我们可能希望创建一个配置并将其用于所有测试,以便可以重用应用程序上下文。
如果您对测试花费在设置和 Spring 应用程序上下文上的时间感兴趣,您可能需要查看 JUnit Insights,它可以包含在 Gradle 或 Maven 构建中,以生成关于 JUnit 5 如何花费时间的很好的报告。

结论

@SpringBootTest 是一种为测试设置应用程序上下文的非常方便的方法,它非常接近我们将在生产中使用的上下文。有很多选项可以自定义此应用程序上下文,但应谨慎使用它们,因为我们希望我们的测试尽可能接近生产运行。
如果我们想在整个应用程序中进行测试,@SpringBootTest 会带来最大的价值。为了仅测试应用程序的某些切片或层,我们还有其他选项可用。
本文中使用的示例代码可在 github 上找到。

使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询

【注】本文译自: Testing JPA Queries with Spring Boot and @DataJpaTest – Reflectoring

除了单元测试,集成测试在生产高质量的软件中起着至关重要的作用。一种特殊的集成测试处理我们的代码和数据库之间的集成。
通过 @DataJpaTest 注释,Spring Boot 提供了一种便捷的方法来设置一个具有嵌入式数据库的环境,以测试我们的数据库查询。
在本教程中,我们将首先讨论哪些类型的查询值得测试,然后讨论创建用于测试的数据库模式和数据库状态的不同方法。

 代码示例

本文附有 GitHub 上的工作代码示例

“使用 Spring Boot 进行测试”系列

本教程是系列的一部分:

  1. 使用 Spring Boot 进行单元测试
  2. 使用 Spring Boot 和 @WebMvcTest 测试 MVC Web Controller
  3. 使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询
  4. 使用 Spring Boot 和 @SpringBootTest 进行测试

依赖

在本教程中,除了通常的 Spring Boot 依赖项之外,我们使用 JUnit Jupiter 作为我们的测试框架,使用 H2 作为内存数据库。

dependencies {
  compile('org.springframework.boot:spring-boot-starter-data-jpa')
  compile('org.springframework.boot:spring-boot-starter-web')
  runtime('com.h2database:h2')
  testCompile('org.springframework.boot:spring-boot-starter-test')
  testCompile('org.junit.jupiter:junit-jupiter-engine:5.2.0')
}

测试什么?

首先要回答我们自己的问题是我们需要测试什么。 让我们考虑一个负责 UserEntity 对象的 Spring Data 存储库:

interface UserRepository extends CrudRepository<UserEntity, Long> {
    // query methods
}

我们有不同的选项来创建查询。让我们详细看看其中的一些,以确定我们是否应该用测试来覆盖它们。

推断查询

第一个选项是创建一个推断查询:

UserEntity findByName(String name);

我们不需要告诉 Spring Data 要做什么,因为它会自动从方法名称的名称推断 SQL 查询。
这个特性的好处是 Spring Data 还会在启动时自动检查查询是否有效。如果我们将方法重命名为 findByFoo() 并且 UserEntity 没有属性 foo ,Spring Data 会向我们抛出一个异常来指出这一点:

org.springframework.data.mapping.PropertyReferenceException:
  No property foo found for type UserEntity!

因此,只要我们至少有一个测试尝试在我们的代码库中启动 Spring 应用程序上下文,我们就不需要为我们的推断查询编写额外的测试。

请注意,对于从 findByNameAndRegistrationDateBeforeAndEmailIsNotNull() 等长方法名称推断出的查询,情况并非如此。这个方法名很难掌握,也很容易出错,所以我们应该测试它是否真的符合我们的预期。

话虽如此,将此类方法重命名为更短、更有意义的名称并添加 @Query 注释以提供自定义 JPQL 查询是一种很好的做法。

使用 @Query 自定义 JPQL 查询

如果查询变得更复杂,提供自定义 JPQL 查询是有意义的:

@Query("select u from UserEntity u where u.name = :name")
UserEntity findByNameCustomQuery(@Param("name") String name);

与推断查询类似,我们可以免费对这些 JPQL 查询进行有效性检查。使用 Hibernate 作为我们的 JPA 提供者,如果发现无效查询,我们将在启动时得到一个 QuerySyntaxException

org.hibernate.hql.internal.ast.QuerySyntaxException:
unexpected token: foo near line 1, column 64 [select u from ...]

但是,自定义查询比通过单个属性查找条目要复杂得多。例如,它们可能包括与其他表的连接或返回复杂的 DTO 而不是实体。
那么,我们应该为自定义查询编写测试吗?令人不满意的答案是,我们必须自己决定查询是否复杂到需要测试。

使用 @Query 的本地查询

另一种方法是使用本地查询

@Query(
  value = "select * from user as u where u.name = :name",
  nativeQuery = true)
UserEntity findByNameNativeQuery(@Param("name") String name);

我们没有指定 JPQL 查询(它是对 SQL 的抽象),而是直接指定一个 SQL 查询。此查询可能使用特定数据库的 SQL 方言。
需要注意的是,Hibernate 和 Spring Data 都不会在启动时验证本地查询。由于查询可能包含特定于数据库的 SQL,因此 Spring Data 或 Hibernate 无法知道要检查什么。
因此,本地查询是集成测试的主要候选者。但是,如果他们真的使用特定数据库的 SQL,那么这些测试可能不适用于嵌入式内存数据库,因此我们必须在后台提供一个真实的数据库(比如,在持续集成管道中按需设置的 docker 容器中)。

@DataJpaTest 简介

为了测试 Spring Data JPA 存储库或任何其他与 JPA 相关的组件,Spring Boot 提供了 @DataJpaTest 注解。我们可以将它添加到单元测试中,它将设置一个 Spring 应用程序上下文:

@ExtendWith(SpringExtension.class)
@DataJpaTest
class UserEntityRepositoryTest {

  @Autowired private DataSource dataSource;
  @Autowired private JdbcTemplate jdbcTemplate;
  @Autowired private EntityManager entityManager;
  @Autowired private UserRepository userRepository;

  @Test
  void injectedComponentsAreNotNull(){
    assertThat(dataSource).isNotNull();
    assertThat(jdbcTemplate).isNotNull();
    assertThat(entityManager).isNotNull();
    assertThat(userRepository).isNotNull();
  }
}

@ExtendWith
本教程中的代码示例使用 @ExtendWith 注解告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注解包含在 Spring Boot 测试注解中,例如 @DataJpaTest、@WebMvcTest 和 @SpringBootTest。本教程中的代码示例使用 @ExtendWith 注解告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注解包含在 Spring Boot 测试注解中,例如 @DataJpaTest@WebMvcTest@SpringBootTest

这样创建的应用程序上下文将不包含我们的 Spring Boot 应用程序所需的整个上下文,而只是它的一个“切片”,其中包含初始化任何 JPA 相关组件(如我们的 Spring Data 存储库)所需的组件。
例如,如果需要,我们可以将 DataSource@JdbcTemplate@EntityManage 注入我们的测试类。此外,我们可以从我们的应用程序中注入任何 Spring Data 存储库。上述所有组件将自动配置为指向嵌入式内存数据库,而不是我们可能在 application.propertiesapplication.yml 文件中配置的“真实”数据库。
请注意,默认情况下,包含所有这些组件(包括内存数据库)的应用程序上下文在所有 @DataJpaTest 注解的测试类中的所有测试方法之间共享。
这就是为什么在默认情况下每个测试方法都在自己的事务中运行的原因,该事务在方法执行后回滚。这样,数据库状态在测试之间保持原始状态,并且测试保持相互独立。

创建数据库模式

在我们可以测试对数据库的任何查询之前,我们需要创建一个 SQL 模式来使用。让我们看看一些不同的方法来做到这一点。

使用 Hibernate ddl-auto

默认情况下,@DataJpaTest 会配置 Hibernate 为我们自动创建数据库模式。对此负责的属性是 spring.jpa.hibernate.ddl-auto,Spring Boot 默认将其设置为 create-drop,这意味着模式在运行测试之前创建并在测试执行后删除。
因此,如果我们对 Hibernate 为我们创建模式感到满意,我们就不必做任何事情。

使用 schema.sql

Spring Boot 支持在应用程序启动时执行自定义 schema.sql 文件。
如果 Spring 在类路径中找到 schema.sql 文件,则将针对数据源执行该文件。 这会覆盖上面讨论的 Hibernate 的 ddl-auto 配置。
我们可以使用属性 spring.datasource.initialization-mode 控制是否应该执行 schema.sql。默认值是嵌入的,这意味着它只会对嵌入的数据库执行(即在我们的测试中)。如果我们将其设置为 always,它将始终执行。
以下日志输出确认文件已被执行:

Executing SQL script from URL [file:.../out/production/resources/schema.sql]

设置 Hibernate 的 ddl-auto 配置以在使用脚本初始化架构时进行验证是有意义的,以便 Hibernate 在启动时检查创建的模式是否与实体类匹配:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class SchemaSqlTest {
  ...
}

使用 Flyway

Flyway 是一种数据库迁移工具,允许指定多个 SQL 脚本来创建数据库模式。它会跟踪目标数据库上已经执行了这些脚本中的哪些脚本,以便只执行之前没有执行过的脚本。
要激活 Flyway,我们只需要将依赖项放入我们的 build.gradle 文件中(如果我们使用 Maven,则类似):

compile('org.flywaydb:flyway-core')

如果我们没有专门配置 Hibernate 的 ddl-auto 配置,它会自动退出,因此 Flyway 具有优先权,并且默认情况下会针对我们的内存数据库测试执行它在文件夹 src/main/resources/db/migration 中找到的所有 SQL 脚本。
同样,将 ddl-auto 设置为 validate 是有意义的,让 Hibernate 检查 Flyway 生成的模式是否符合我们的 Hibernate 实体的期望:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class FlywayTest {
  ...
}

在测试中使用 Flyway 的价值

如果我们在生产中使用 Flyway,也能在上面描述的那样在 JPA 测试中使用它,那就太好了。只有这样我们才能在测试时知道 flyway 脚本按预期工作。
但是,这仅适用于脚本包含在生产数据库和测试中使用的内存数据库(我们的示例中为 H2 数据库)上都有效的 SQL。如果不是这种情况,我们必须在我们的测试中禁用 Flyway,方法是将 spring.flyway.enabled 属性设置为 false,并将 spring.jpa.hibernate.ddl-auto 属性设置为 create-drop 以让 Hibernate 生成模式。
无论如何,让我们确保将 ddl-auto 属性在生产配置文件中设置为 validate!这是我们抵御 Flyway 脚本错误的最后一道防线!无论如何,让我们确保将 ddl-auto 属性在生产配置文件中设置为 validate!这是我们抵御 Flyway 脚本错误的最后一道防线!

使用 Liquibase

Liquibase 是另一种数据库迁移工具,其工作方式类似于 Flyway,但支持除 SQL 之外的其他输入格式。例如,我们可以提供定义数据库架构的 YAML 或 XML 文件。
我们只需添加依赖项即可激活它:

compile('org.liquibase:liquibase-core')

默认情况下,Liquibase 将自动创建在 src/main/resources/db/changelog/db.changelog-master.yaml 中定义的模式。
同样,设置 ddl-autovalidate 是有意义的:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class LiquibaseTest {
  ...
}

在测试中使用 Liquibase 的价值

由于 Liquibase 允许多种输入格式充当 SQL 上的抽象层,因此即使它们的 SQL 方言不同,也可以跨多个数据库使用相同的脚本。这使得在我们的测试和生产中使用相同的 Liquibase 脚本成为可能。
不过,YAML 格式非常敏感,而且我最近在维护大型 YAML 文件集合时遇到了麻烦。这一点,以及尽管我们实际上必须为不同的数据库编辑这些文件的抽象,最终导致转向 Flyway。

填充数据库

现在我们已经为我们的测试创建了一个数据库模式,我们终于可以开始实际的测试了。在数据库查询测试中,我们通常会向数据库添加一些数据,然后验证我们的查询是否返回正确的结果。
同样,有多种方法可以将数据添加到我们的内存数据库中,所以让我们逐一讨论。

使用 data.sql

schema.sql 类似,我们可以使用包含插入语句的 data.sql 文件来填充我们的数据库。上述规则同样适用。

可维护性

data.sql 文件迫使我们将所有 insert 语句放在一个地方。每一个测试都将依赖于这个脚本来设置数据库状态。这个脚本很快就会变得非常大并且难以维护。如果有需要冲突数据库状态的测试怎么办?
因此,应谨慎考虑这种方法。

手动插入实体

为每个测试创建特定数据库状态的最简单方法是在运行被测查询之前在测试中保存一些实体:

@Test
void whenSaved_thenFindsByName() {
  userRepository.save(new UserEntity(
          "Zaphod Beeblebrox",
          "zaphod@galaxy.net"));
  assertThat(userRepository.findByName("Zaphod Beeblebrox")).isNotNull();
}

这对于上面示例中的简单实体来说很容易。但在实际项目中,这些实体的构建和与其他实体的关系通常要复杂得多。此外,如果我们想测试比 findByName 更复杂的查询,很可能我们需要创建比单个实体更多的数据。这很快变得非常令人厌烦。
控制这种复杂性的一种方法是创建工厂方法,可能结合 Objectmother 和 Builder 模式。
在 Java 代码中“手动”对数据库进行编程的方法比其他方法有很大的优势,因为它是重构安全的。代码库中的更改会导致我们的测试代码中出现编译错误。在所有其他方法中,我们必须运行测试才能收到有关重构导致的潜在错误的通知。使用

Spring DBUnit

DBUnit 是一个支持将数据库设置为某种状态的库。Spring DBUnit 将 DBUnit 与 Spring 集成在一起,因此它可以自动与 Spring 的事务等一起工作。
要使用它,我们需要向 Spring DBUnit 和 DBUnit 添加依赖项:

compile('com.github.springtestdbunit:spring-test-dbunit:1.3.0')
compile('org.dbunit:dbunit:2.6.0')

然后,对于每个测试,我们可以创建一个包含所需数据库状态的自定义 XML 文件:

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user
        id="1"
        name="Zaphod Beeblebrox"
        email="zaphod@galaxy.net"
    />
</dataset>

默认情况下,XML 文件(我们将其命名为 createUser.xml)位于测试类旁边的类路径中。
在测试类中,我们需要添加两个 TestExecutionListeners 来启用 DBUnit 支持。要设置某个数据库状态,我们可以在测试方法上使用 @DatabaseSetup

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        TransactionDbUnitTestExecutionListener.class
})
class SpringDbUnitTest {

  @Autowired
  private UserRepository userRepository;

  @Test
  @DatabaseSetup("createUser.xml")
  void whenInitializedByDbUnit_thenFindsByName() {
    UserEntity user = userRepository.findByName("Zaphod Beeblebrox");
    assertThat(user).isNotNull();
  }
}

对于更改数据库状态的测试查询,我们甚至可以使用 @ExpectedDatabase 来定义数据库在测试预期处于的状态。
但是请注意,自 2016 年以来,Spring DBUnit 没有再维护

@DatabaseSetup 不起作用?

在我的测试中,我遇到了 @DatabaseSetup 注释被默默忽略的问题。原来有一个 ClassNotFoundException 因为找不到某些 DBUnit 类。不过,这个异常被吞了。
原因是我忘记包含对 DBUnit 的依赖,因为我认为 Spring Test DBUnit 可递进地含它。因此,如果您遇到相同的问题,请检查您是否包含了这两个依赖项。

使用 @Sql

一个非常相似的方法是使用 Spring 的 @Sql 注解。我们没有使用 XML 来描述数据库状态,而是直接使用 SQL:

INSERT INTO USER
            (id,
             NAME,
             email)
VALUES      (1,
             'Zaphod Beeblebrox',
             'zaphod@galaxy.net');

在我们的测试中,我们可以简单地使用 @Sql 注解来引用 SQL 文件来填充数据库:

@ExtendWith(SpringExtension.class)
@DataJpaTest
class SqlTest {

  @Autowired
  private UserRepository userRepository;

  @Test
  @Sql("createUser.sql")
  void whenInitializedByDbUnit_thenFindsByName() {
    UserEntity user = userRepository.findByName("Zaphod Beeblebrox");
    assertThat(user).isNotNull();
  }

}

如果我们需要多个脚本,我们可以使用 @SqlGroup 来组合它们。

结论

为了测试数据库查询,我们需要创建模式并用一些数据填充它的方法。由于测试应该相互独立,因此最好对每个测试分别执行此操作。
对于简单的测试和简单的数据库实体,通过创建和保存 JPA 实体手动创建状态就足够了。对于更复杂的场景,@DatabaseSetup@Sql 提供了一种在 XML 或 SQL 文件中外部化数据库状态的方法。

使用 Spring Boot 和 @WebMvcTest 测试 MVC Web Controller

【注】本文译自: Testing MVC Web Controllers with Spring Boot and @WebMvcTest – Reflectoring

在有关使用 Spring Boot 进行测试的系列的第二部分中,我们将了解 Web 控制器。首先,我们将探索 Web 控制器的实际作用,这样我们就可以构建涵盖其所有职责的测试。
然后,我们将找出如何在测试中涵盖这些职责。只有涵盖了这些职责,我们才能确保我们的控制器在生产环境中按预期运行。

 代码示例

本文附有 GitHub 上的工作代码示例。

“使用 Spring Boot 进行测试”系列

本教程是系列的一部分:

  1. 使用 Spring Boot 进行单元测试
  2. 使用 Spring Boot 和 @WebMvcTest 测试 MVC Web Controller
  3. 使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询
  4. 使用 Spring Boot 和 @SpringBootTest 进行测试

依赖

我们将使用 JUnit Jupiter (JUnit 5) 作为测试框架,使用 Mockito 进行模拟,使用 AssertJ 来创建断言,使用 Lombok 来减少样板代码:

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.junit.jupiter:junit-jupiter:5.4.0')
    testCompile('org.mockito:mockito-junit-jupiter:2.23.0')
}

AssertJ 和 Mockito 跟随 spring-boot-starter-test 依赖自动获得。

Web 控制器的职责

让我们从一个典型的 REST 控制器开始:

@RestController
@RequiredArgsConstructor
class RegisterRestController {
    private final RegisterUseCase registerUseCase;

    @PostMapping("/forums/{forumId}/register")
    UserResource register(@PathVariable("forumId") Long forumId, @Valid @RequestBody UserResource userResource,
            @RequestParam("sendWelcomeMail") boolean sendWelcomeMail) {

        User user = new User(userResource.getName(), userResource.getEmail());
        Long userId = registerUseCase.registerUser(user, sendWelcomeMail);

        return new UserResource(userId, user.getName(), user.getEmail());
    }

}

控制器方法用 @PostMapping 注解来定义它应该侦听的 URL、HTTP 方法和内容类型。
它通过用 @PathVariable@RequestBody@RequestParam 注解的参数获取输入,这些参数会从传入的 HTTP 请求中自动填充。
参数可以使用 @Valid 进行注解,以指示 Spring 应该对它们 bean 验证
然后控制器使用这些参数,调用业务逻辑返回一个普通的 Java 对象,默认情况下该对象会自动映射到 JSON 并写入 HTTP 响应体。
这里有很多 spring 魔法。总之,对于每个请求,控制器通常会执行以下步骤:

# 职责 描述
1. 监听 HTTP 请求 控制器应该响应某些 URL、HTTP 方法和内容类型。
2. 反序列化输入 控制器应该解析传入的 HTTP 请求并根据 URL、HTTP 请求参数和请求正文中的变量创建 Java 对象,以便我们可以在代码中使用它们。
3. 验证输入 控制器是防止错误输入的第一道防线,因此它是我们可以验证输入的地方。
4. 调用业务逻辑 解析输入后,控制器必须将输入转换为业务逻辑期望的模型并将其传递给业务逻辑。
5. 序列化输出 控制器获取业务逻辑的输出并将其序列化为 HTTP 响应。
6. 转换异常 如果在某个地方发生异常,控制器应将其转换为对用户有意义的错误消息和 HTTP 状态。

控制器显然有很多工作要做!
我们应该注意不要添加更多的职责,比如执行业务逻辑。否则,我们的控制器测试将变得臃肿且无法维护。
我们将如何编写有意义的测试,涵盖所有这些职责?

单元测试还是集成测试?

我们写单元测试吗?还是集成测试?到底有什么区别?让我们讨论这两种方法并决定其中一种。
在单元测试中,我们将单独测试控制器。这意味着我们将实例化一个控制器对象,模拟业务逻辑,然后调用控制器的方法并验证响应。
这对我们有用吗?让我们检查一下可以单独的单元测试中涵盖上面确定的 6 个职责中的哪一个:

# 职责 可以在单元测试中涵盖吗
1. 监听 HTTP 请求 ❌ 不,因为单元测试不会评估 @PostMapping 注解和指定 HTTP 请求属性的类似注解。
2. 反序列化输入 ❌ 不,因为像@RequestParam 和 @PathVariable 这样的注释不会被评估。相反,我们将输入作为 Java 对象提供,从而有效地跳过 HTTP 请求的反序列化。
3. 验证输入 ❌ 不依赖于 bean 验证,因为不会评估 @Valid 注释。
4. 调用业务逻辑 ✔ 是的,因为我们可以验证是否使用预期的参数调用了模拟的业务逻辑。
5. 序列化输出 ❌ 不能,因为我们只能验证输出的 Java 版本,而不能验证将生成的 HTTP 响应。
6. 转换异常 ❌ 不可以。我们可以检查是否引发了某个异常,但不能检查它是否被转换为某个 JSON 响应或 HTTP 状态代码。

与 Spring 的集成测试会启动一个包含我们需要的所有 bean 的 Spring 应用程序上下文。这包括负责侦听某些 URL、与 JSON 之间进行序列化和反序列化以及将异常转换为 HTTP 的框架 bean。这些 bean 将评估简单单元测试会忽略的注释。总之,简单的单元测试不会覆盖 HTTP 层。所以,我们需要在我们的测试中引入 Spring 来为我们做 HTTP 魔法。因此,我们正在构建一个集成测试来测试我们的控制器代码和 Spring 为 HTTP 支持提供的组件之间的集成。
那么,我们该怎么做呢?

使用 @WebMvcTest 验证控制器职责

Spring Boot 提供了 @WebMvcTest 注释来启动一个应用程序上下文,该上下文只包含测试 Web 控制器所需的 bean:

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = RegisterRestController.class)
class RegisterRestControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private RegisterUseCase registerUseCase;

  @Test
  void whenValidInput_thenReturns200() throws Exception {
    mockMvc.perform(...);
  }
}

@ExtendWith
本教程中的代码示例使用 @ExtendWith 批注告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注释包含在 Spring Boot 测试注解中,例如 @DataJpaTest@WebMvcTest@SpringBootTest

我们现在可以 @Autowire 从应用程序上下文中获取我们需要的所有 bean。Spring Boot 自动提供了像 ObjectMapper 这样的 bean 来映射到 JSON 和一个 MockMvc 实例来模拟 HTTP 请求。
我们使用 @MockBean 来模拟业务逻辑,因为我们不想测试控制器和业务逻辑之间的集成,而是控制器和 HTTP 层之间的集成。@MockBean 自动用 Mockito 模拟替换应用程序上下文中相同类型的 bean。
您可以在我关于模拟的文章中阅读有关 @MockBean 注解的更多信息。

使用带或不带 controllers 参数的 @WebMvcTest
通过在上面的示例中将 controllers 参数设置为 RegisterRestController.class,我们告诉 Spring Boot 将为此测试创建的应用程序上下文限制为给定的控制器 bean 和 Spring Web MVC 所需的一些框架 bean。我们可能需要的所有其他 bean 必须单独包含或使用 @MockBean 模拟。
如果我们不使用 controllers 参数,Spring Boot 将在应用程序上下文中包含所有控制器。因此,我们需要包含或模拟掉任何控制器所依赖的所有 bean。这使得测试设置更加复杂,具有更多的依赖项,但节省了运行时间,因为所有控制器测试都将重用相同的应用程序上下文。
我倾向于将控制器测试限制在最窄的应用程序上下文中,以使测试独立于我在测试中甚至不需要的 bean,即使 Spring Boot 必须为每个单独的测试创建一个新的应用程序上下文。

让我们来回顾一下每个职责,看看我们如何使用 MockMvc 来验证每一个职责,以便构建我们力所能及的最好的集成测试。

1. 验证 HTTP 请求匹配

验证控制器是否侦听某个 HTTP 请求非常简单。我们只需调用 MockMvcperform() 方法并提供我们要测试的 URL:

mockMvc.perform(post("/forums/42/register")
    .contentType("application/json"))
    .andExpect(status().isOk());

除了验证控制器对特定 URL 的响应之外,此测试还验证正确的 HTTP 方法(在我们的示例中为 POST)和正确的请求内容类型。我们上面看到的控制器会拒绝任何具有不同 HTTP 方法或内容类型的请求。
请注意,此测试仍然会失败,因为我们的控制器需要一些输入参数。
更多匹配 HTTP 请求的选项可以在 MockHttpServletRequestBuilder 的 Javadoc 中找到。

2. 验证输入序列化

为了验证输入是否成功序列化为 Java 对象,我们必须在测试请求中提供它。输入可以是请求正文的 JSON 内容 (@RequestBody)、URL 路径中的变量 (@PathVariable) 或 HTTP 请求参数 (@RequestParam):

@Test
void whenValidInput_thenReturns200() throws Exception {
  UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");

   mockMvc.perform(post("/forums/{forumId}/register", 42L)
        .contentType("application/json")
        .param("sendWelcomeMail", "true")
        .content(objectMapper.writeValueAsString(user)))
        .andExpect(status().isOk());
}

我们现在提供路径变量 forumId、请求参数 sendWelcomeMail 和控制器期望的请求正文。请求正文是使用 Spring Boot 提供的 ObjectMapper 生成的,将 UserResource 对象序列化为 JSON 字符串。
如果测试结果为绿色,我们现在知道控制器的 register() 方法已将这些参数作为 Java 对象接收,并且它们已从 HTTP 请求中成功解析。

3. 验证输入验证

假设 UserResource 使用 @NotNull 注释来拒绝 null 值:

@Value
public class UserResource {

    @NotNull
    private final String name;

    @NotNull
    private final String email;

}

当我们@Valid 注解添加到方法参数时,Bean 验证会自动触发,就像我们在控制器中使用 userResource 参数所做的那样。因此,对于快乐路径(即验证成功时),我们在上一节中创建的测试就足够了。
如果我们想测试验证是否按预期失败,我们需要添加一个测试用例,在该用例中我们将无效的 UserResource JSON 对象发送到控制器。然后我们期望控制器返回 HTTP 状态 400(错误请求):

@Test
void whenNullValue_thenReturns400() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  mockMvc.perform(post("/forums/{forumId}/register", 42L)
      ...
      .content(objectMapper.writeValueAsString(user)))
      .andExpect(status().isBadRequest());
}

根据验证对应用程序的重要性,我们可能会为每个可能的无效值添加这样的测试用例。但是,这会很快增加很多测试用例,因此您应该与您的团队讨论您希望如何处理项目中的验证测试。

4. 验证业务逻辑调用

接下来,我们要验证业务逻辑是否按预期调用。在我们的例子中,业务逻辑由 RegisterUseCase 接口提供,并需要一个 User 对象和一个 boolean 值作为输入:

interface RegisterUseCase {
    Long registerUser(User user, boolean sendWelcomeMail);
}

我们希望控制器将传入的 UserResource 对象转换为 User 并将此对象传递给 registerUser() 方法。
为了验证这一点,我们可以要求 RegisterUseCase 模拟,它已使用 @MockBean 注解注入到应用程序上下文中:

@Test
void whenValidInput_thenMapsToBusinessModel() throws Exception {
  UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");
  mockMvc.perform(...);

  ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
  verify(registerUseCase, times(1)).registerUser(userCaptor.capture(), eq(true));
  assertThat(userCaptor.getValue().getName()).isEqualTo("Zaphod");
  assertThat(userCaptor.getValue().getEmail()).isEqualTo("zaphod@galaxy.net");
}

在执行了对控制器的调用之后,我们使用 ArgumentCaptor 来捕获传递给 RegisterUseCase.registerUser()User 对象并断言它包含预期值。
调用 verify 检查 registerUser() 是否被调用过一次。
请注意,如果我们对 User 对象进行大量断言,我们可以 创建自己的自定义 Mockito 断言方法 以获得更好的可读性。

5. 验证输出序列化

调用业务逻辑后,我们希望控制器将结果映射到 JSON 字符串并将其包含在 HTTP 响应中。在我们的例子中,我们希望 HTTP 响应正文包含一个有效的 JSON 格式的 UserResource 对象:

@Test
void whenValidInput_thenReturnsUserResource() throws Exception {
  MvcResult mvcResult = mockMvc.perform(...)
      ...
      .andReturn();

  UserResource expectedResponseBody = ...;
  String actualResponseBody = mvcResult.getResponse().getContentAsString();

  assertThat(actualResponseBody).isEqualToIgnoringWhitespace(
              objectMapper.writeValueAsString(expectedResponseBody));
}

要对响应主体进行断言,我们需要使用 andReturn() 方法将 HTTP 交互的结果存储在 MvcResult 类型的变量中。
然后我们可以从响应正文中读取 JSON 字符串,并使用 isEqualToIgnoringWhitespace() 将其与预期的字符串进行比较。我们可以使用 Spring Boot 提供的 ObjectMapper 从 Java 对象构建预期的 JSON 字符串。
请注意,我们可以通过使用自定义的 ResultMatcher 使其更具可读性,稍后对此加以描述

6. 验证异常处理

通常,如果发生异常,控制器应该返回某个 HTTP 状态。400 — 如果请求有问题,500 — 如果出现异常,等等。
默认情况下,Spring 会处理大多数这些情况。但是,如果我们有自定义异常处理,我们想测试它。假设我们想要返回一个结构化的 JSON 错误响应,其中包含请求中每个无效字段的字段名称和错误消息。我们会像这样创建一个 @ControllerAdvice

@ControllerAdvice
class ControllerExceptionHandler {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    ErrorResult handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        ErrorResult errorResult = new ErrorResult();
        for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
            errorResult.getFieldErrors()
                    .add(new FieldValidationError(fieldError.getField(), fieldError.getDefaultMessage()));
        }
        return errorResult;
    }

    @Getter
    @NoArgsConstructor
    static class ErrorResult {
        private final List<FieldValidationError> fieldErrors = new ArrayList<>();

        ErrorResult(String field, String message) {
            this.fieldErrors.add(new FieldValidationError(field, message));
        }
    }

    @Getter
    @AllArgsConstructor
    static class FieldValidationError {
        private String field;
        private String message;
    }
}

如果 bean 验证失败,Spring 将抛出 MethodArgumentNotValidException。我们通过将 Spring 的 FieldError 对象映射到我们自己的 ErrorResult 数据结构来处理这个异常。在这种情况下,异常处理程序会导致所有控制器返回 HTTP 状态 400,并将 ErrorResult 对象作为 JSON 字符串放入响应正文中。
为了验证这确实发生了,我们扩展了我们之前对失败验证的测试:

@Test
void whenNullValue_thenReturns400AndErrorResult() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  MvcResult mvcResult = mockMvc.perform(...)
          .contentType("application/json")
          .param("sendWelcomeMail", "true")
          .content(objectMapper.writeValueAsString(user)))
          .andExpect(status().isBadRequest())
          .andReturn();

  ErrorResult expectedErrorResponse = new ErrorResult("name", "must not be null");
  String actualResponseBody =
      mvcResult.getResponse().getContentAsString();
  String expectedResponseBody =
      objectMapper.writeValueAsString(expectedErrorResponse);
  assertThat(actualResponseBody)
      .isEqualToIgnoringWhitespace(expectedResponseBody);
}

同样,我们从响应正文中读取 JSON 字符串,并将其与预期的 JSON 字符串进行比较。此外,我们检查响应状态是否为 400。
这也可以以可读性更强的方式实现,我们接下来将要学习。创建自定义 ResultMatcher
某些断言很难写,更重要的是,很难阅读。特别是当我们想要将来自 HTTP 响应的 JSON 字符串与预期值进行比较时,它需要大量代码,正如我们在最后两个示例中看到的那样。
幸运的是,我们可以创建自定义的 ResultMatcher,我们可以在 MockMvc 的流畅 API 中使用它们。让我们看看如何做到这一点。匹配 JSON 输出
使用以下代码来验证 HTTP 响应正文是否包含某个 Java 对象的 JSON 表示不是很好吗?

@Test
void whenValidInput_thenReturnsUserResource_withFluentApi() throws Exception {
  UserResource user = ...;
  UserResource expected = ...;

  mockMvc.perform(...)
      ...
      .andExpect(responseBody().containsObjectAsJson(expected, UserResource.class));
}

不再需要手动比较 JSON 字符串。它的可读性要好得多。事实上,代码是如此的一目了然,这里我无需解释。
为了能够使用上面的代码,我们创建了一个自定义的 ResultMatcher

public class ResponseBodyMatchers {
    private ObjectMapper objectMapper = new ObjectMapper();

    public <T> ResultMatcher containsObjectAsJson(Object expectedObject, Class<T> targetClass) {
        return mvcResult -> {
            String json = mvcResult.getResponse().getContentAsString();
            T actualObject = objectMapper.readValue(json, targetClass);
            assertThat(actualObject).isEqualToComparingFieldByField(expectedObject);
        };
    }

    static ResponseBodyMatchers responseBody() {
        return new ResponseBodyMatchers();
    }

}

静态方法 responseBody() 用作我们流畅的 API 的入口点。它返回实际的 ResultMatcher,它从 HTTP 响应正文解析 JSON,并将其与传入的预期对象逐个字段进行比较。匹配预期的验证错误
我们甚至可以更进一步简化我们的异常处理测试。我们用了 4 行代码来验证 JSON 响应是否包含某个错误消息。我们可以改为一行:

@Test
void whenNullValue_thenReturns400AndErrorResult_withFluentApi() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  mockMvc.perform(...)
      ...
      .content(objectMapper.writeValueAsString(user)))
      .andExpect(status().isBadRequest())
      .andExpect(responseBody().containsError("name", "must not be null"));
}

同样,代码是自解释的。
为了启用这个流畅的 API,我们必须从上面添加方法 containsErrorMessageForField() 到我们的 ResponseBodyMatchers 类:

public class ResponseBodyMatchers {
    private ObjectMapper objectMapper = new ObjectMapper();

    public ResultMatcher containsError(String expectedFieldName, String expectedMessage) {
        return mvcResult -> {
            String json = mvcResult.getResponse().getContentAsString();
            ErrorResult errorResult = objectMapper.readValue(json, ErrorResult.class);
            List<FieldValidationError> fieldErrors = errorResult.getFieldErrors().stream()
                    .filter(fieldError -> fieldError.getField().equals(expectedFieldName))
                    .filter(fieldError -> fieldError.getMessage().equals(expectedMessage)).collect(Collectors.toList());

            assertThat(fieldErrors).hasSize(1).withFailMessage(
                    "expecting exactly 1 error message" + "with field name '%s' and message '%s'", expectedFieldName,
                    expectedMessage);
        };
    }

    static ResponseBodyMatchers responseBody() {
        return new ResponseBodyMatchers();
    }
}

所有丑陋的代码都隐藏在这个辅助类中,我们可以在集成测试中愉快地编写干净的断言。

结论

Web 控制器有很多职责。如果我们想用有意义的测试覆盖一个 web 控制器,仅仅检查它是否返回正确的 HTTP 状态是不够的。
通过 @WebMvcTest,Spring Boot 提供了我们构建 Web 控制器测试所需的一切,但为了使测试有意义,我们需要记住涵盖所有职责。否则,我们可能会在运行时遇到丑陋的惊喜。
本文中的示例代码可在 GitHub 上找到。

使用 Spring Boot 进行单元测试

【注】本文译自: Unit Testing with Spring Boot – Reflectoring

编写好的单元测试可以被认为是一门难以掌握的艺术。但好消息是支持它的机制很容易学习。
本教程为您提供了这些机制,并详细介绍了编写良好的单元测试所必需的技术细节,重点是 Spring Boot 应用程序。
我们将看看如何以可测试的方式创建 Spring bean,然后讨论 Mockito 和 AssertJ 的用法,这两个库默认包含在 Spring Boot 中用于测试。
请注意,本文仅讨论单元测试。集成测试、Web 层测试和持久层测试将在本系列的后续文章中讨论。

 代码示例

本文附有 GitHub 上 的工作代码示例。

“使用 Spring Boot 进行测试”系列

本教程是系列的一部分:

  1. 使用 Spring Boot 进行单元测试
  2. 使用 Spring Boot 和 @WebMvcTest 测试 MVC Web Controller
  3. 使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询
  4. 使用 Spring Boot 和 @SpringBootTest 进行测试

依赖关系

对于本教程中的单元测试,我们将使用 JUnit Jupiter (JUnit 5)、Mockito 和 AssertJ。我们还将包括 Lombok 以减少一些样板代码:

dependencies {
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.junit.jupiter:junit-jupiter:5.4.0')
    testCompile('org.mockito:mockito-junit-jupiter:2.23.0')
}

Mockito 和 AssertJ 是使用 spring-boot-starter-test 依赖项自动导入的,但我们必须自己包含 Lombok。

不要在单元测试中使用 Spring

如果你以前用 Spring 或 Spring Boot 写过测试,你可能会说我们不需要 Spring 来写单元测试。这是为什么?
考虑以下测试 RegisterUseCase 类的单个方法的“单元”测试:

@ExtendWith(SpringExtension.class)
@SpringBootTest
class RegisterUseCaseTest {

    @Autowired
    private RegisterUseCase registerUseCase;

    @Test
    void savedUserHasRegistrationDate() {
        User user = new User("zaphod", "zaphod@mail.com");
        User savedUser = registerUseCase.registerUser(user);
        assertThat(savedUser.getRegistrationDate()).isNotNull();
    }

}

这个测试在我电脑上的一个空 Spring 项目上运行大约需要 4.5 秒。
但是一个好的单元测试只需要几毫秒。否则它会阻碍由测试驱动开发(TDD)思想推动的“测试/代码/测试”流程。但即使我们不采用 TDD,等待太长时间的测试也会破坏我们的注意力。
执行上面的测试方法实际上只需要几毫秒。 剩下的 4.5 秒是由于 @SpringBootRun 告诉 Spring Boot 设置整个 Spring Boot 应用程序上下文。
所以我们启动了整个应用程序只是为了将 RegisterUseCase 实例自动装配到我们的测试中。一旦应用程序变大并且 Spring 不得不将越来越多的 bean 加载到应用程序上下文中,它将花费更长的时间。
那么,为什么我们不应该在单元测试中使用 Spring Boot 呢?老实说,本教程的大部分内容都是关于在没有 Spring Boot 的情况下编写单元测试。

创建可测试的 Spring Bean

然而,我们可以做一些事情来提高 Spring bean 的可测试性。

字段注入是不可取的

让我们从一个不好的例子开始。考虑以下类:

@Service
public class RegisterUseCase {

    @Autowired
    private UserRepository userRepository;

    public User registerUser(User user) {
        return userRepository.save(user);
    }

}

这个类不能在没有 Spring 的情况下进行单元测试,因为它没有提供传递 UserRepository 实例的方法。那么,我们需要按照上一节中讨论的方式编写测试,让 Spring 创建一个 UserRepository 实例并将其注入到用 @Autowired 注解的字段中。

这里的教训是不要使用字段注入

提供构造函数

实际上,我们根本不要使用 @Autowired 注解:

@Service
public class RegisterUseCase {

    private final UserRepository userRepository;

    public RegisterUseCase(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User registerUser(User user) {
        return userRepository.save(user);
    }

}

这个版本通过提供允许传入 UserRepository 实例的构造函数来允许构造函数注入。在单元测试中,我们现在可以创建这样一个实例(可能是我们稍后讨论的模拟实例)并将其传递给构造函数。
在创建生产应用程序上下文时,Spring 将自动使用此构造函数来实例化 RegisterUseCase 对象。注意,在 Spring 5 之前,我们需要在构造函数中添加 @Autowired 注解,以便 Spring 找到构造函数。
还要注意 UserRepository 字段现在是 final。这是有道理的,因为字段内容在应用程序的生命周期内永远不会改变。它还有助于避免编程错误,因为如果我们忘记初始化字段,编译器会报错。

减少样板代码

使用 Lombok 的 @RequiredArgsConstructor 注解,我们可以让构造函数自动生成:

@Service
@RequiredArgsConstructor
public class RegisterUseCase {

    private final UserRepository userRepository;

    public User registerUser(User user) {
        user.setRegistrationDate(LocalDateTime.now());
        return userRepository.save(user);
    }

}

现在,我们有一个非常简洁的类,没有样板代码,可以在普通的 java 测试用例中轻松实例化:

class RegisterUseCaseTest {

    private UserRepository userRepository = ...;

    private RegisterUseCase registerUseCase;

    @BeforeEach
    void initUseCase() {
        registerUseCase = new RegisterUseCase(userRepository);
    }

    @Test
    void savedUserHasRegistrationDate() {
        User user = new User("zaphod", "zaphod@mail.com");
        User savedUser = registerUseCase.registerUser(user);
        assertThat(savedUser.getRegistrationDate()).isNotNull();
    }

}

然而,还缺少一点,那就是如何模拟我们被测类所依赖的 UserRepository 实例,因为我们不想依赖真实的东西,它可能需要连接到数据库。

使用 Mockito 来模拟依赖

现在事实上的标准模拟库是 Mockito。它至少提供了两种方法来创建模拟的 UserRepository 以填补前面代码示例中的空白。

使用普通 Mockito 模拟依赖项

第一种方法是以编程方式使用 Mockito:

private UserRepository userRepository = Mockito.mock(UserRepository.class);

这将创建一个从外部看起来像 UserRepository 的对象。默认情况下,当一个方法被调用时它什么都不做,如果该方法有返回值则返回 null
我们的测试现在将在 assertThat(savedUser.getRegistrationDate()).isNotNull() 处以 NullPointerException 失败,因为 userRepository.save(user) 现在返回 null
所以,我们必须告诉 Mockito 在调用 userRepository.save() 时返回一些东西。我们使用静态 when 方法来做到这一点:

    @Test
    void savedUserHasRegistrationDate() {
        User user = new User("zaphod", "zaphod@mail.com");
        when(userRepository.save(any(User.class))).then(returnsFirstArg());
        User savedUser = registerUseCase.registerUser(user);
        assertThat(savedUser.getRegistrationDate()).isNotNull();
    }

这将使 userRepository.save() 返回传递给方法的相同用户对象。
Mockito 具有更多功能,可以进行模拟、匹配参数和验证方法调用。有关更多信息,请查看参考文档

使用 Mockito 的 @Mock 注解模拟依赖项

创建模拟对象的另一种方法是 Mockito 的 @Mock 注解与 JUnit Jupiter 的 MockitoExtension 相结合:

@ExtendWith(MockitoExtension.class)
class RegisterUseCaseTest {

    @Mock
    private UserRepository userRepository;

    private RegisterUseCase registerUseCase;

    @BeforeEach
    void initUseCase() {
        registerUseCase = new RegisterUseCase(userRepository);
    }

    @Test
    void savedUserHasRegistrationDate() {
        // ...
    }

}

@Mock 注解指定了 Mockito 应该注入模拟对象的字段。 @MockitoExtension 告诉 Mockito 评估那些 @Mock 注解,因为 JUnit 不会自动执行此操作。
结果和手动调用 Mockito.mock() 一样,选择使用哪种方式是品味问题。 但是请注意,通过使用 MockitoExtension 将我们的测试绑定到测试框架。
请注意,我们也可以在 registerUseCase 字段上使用 @InjectMocks 注解,而不是手动构造 RegisterUseCase 对象。然后 Mockito 会按照指定的算法为我们创建一个实例:

@ExtendWith(MockitoExtension.class)
class RegisterUseCaseTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private RegisterUseCase registerUseCase;

    @Test
    void savedUserHasRegistrationDate() {
        // ...
    }

}

使用 AssertJ 创建可读断言

Spring Boot 测试支持自动附带的另一个库是 AssertJ。我们已经在上面使用它来实现我们的断言:

assertThat(savedUser.getRegistrationDate()).isNotNull();

然而,让断言更具可读性不是更好吗?例如:

assertThat(savedUser).hasRegistrationDate();

在很多情况下,像这样的小改动会使测试更容易理解。因此,让我们在测试源文件夹中创建我们自己的自定义断言

class UserAssert extends AbstractAssert<UserAssert, User> {

    UserAssert(User user) {
        super(user, UserAssert.class);
    }

    static UserAssert assertThat(User actual) {
        return new UserAssert(actual);
    }

    UserAssert hasRegistrationDate() {
        isNotNull();
        if (actual.getRegistrationDate() == null) {
            failWithMessage(
                    "Expected user to have a registration date, but it was null"
            );
        }
        return this;
    }
}

现在,如果我们从新的 UserAssert 类而不是从 AssertJ 库导入 assertThat 方法,我们就可以使用新的、更易于阅读的断言。
创建像这样的自定义断言似乎需要很多工作,但实际上只需几分钟即可完成。我坚信投入这些时间来创建可读的测试代码是值得的,即使之后它的可读性只是稍微好一点。毕竟,我们只编写一次测试代码,其他人(包括“未来的我”)必须在软件的生命周期中多次阅读、理解和操作代码
如果仍然觉得工作量太大,请查看 AssertJ 的断言生成器

结论

在测试中启动 Spring 应用程序是有原因的,但对于普通的单元测试来说,这是没有必要的。由于更长的周转时间,它甚至是有害的。相反,我们应该以一种易于支持为其编写简单单元测试的方式构建我们的 Spring bean。
Spring Boot Test Starter 附带 Mockito 和 AssertJ 作为测试库。
让我们利用这些测试库来创建富有表现力的单元测试!
最终形式的代码示例可在 github 上 找到。

Java ArrayList 与 LinkedList

【注】本文译自: Java ArrayList vs LinkedList | Baeldung

1. 概述

对于 collections (集合),Java 标准库提供了大量可供选择的选项。在这些选项中,有两个著名的 List 实现,称为 ArrayListLinkedList,每个实现都有自己的属性和用例。
在本教程中,我们将看到这两者是如何实现的。然后,我们将为评估每个应用的不同。

2. ArrayList

在内部,ArrayList 使用数组来实现 List 接口。由于数组在 Java 中是固定大小的,因此 ArrayList 创建一个具有一些初始容量的数组。在此过程中,如果我们需要存储比默认容量更多的项,它将用一个新的、更大的数组替换该数组。
为了更好地理解它的属性,让我们根据它的三个主要操作来评估这个数据结构:添加项、通过索引获取项和通过索引删除项。

2.1. 添加

当我们创建一个空的 ArrayList 时,它会使用默认容量(当前为 10)初始化其后备数组:

在该数组尚未满时添加新项目就像将该项分配给特定数组索引一样简单。这个数组索引由当前数组大小决定,因为我们实际上是附加到列表中:

backingArray[size] = newItem;
size++;

因此,在最佳和一般情况下,加法操作的时间复杂度为 O(1),这非常快。但是,随着后备数组变满,添加实现的效率会降低:

要添加新项目,我们应该首先初始化一个容量更大的全新数组,并将所有现有项目复制到新数组中。只有在复制当前元素后,我们才能添加新项目。因此,在最坏的情况下时间复杂度为 O(n),因为我们必须先复制 n 个元素。
从理论上讲,添加新元素的运行时间为摊销常数。也就是说,添加 n 个元素需要 O(n) 时间。但是,由于复制开销,某些单次添加可能表现不佳。

2.2. 按索引访问

通过索引访问项是 ArrayList 的真正亮点。要检索下标为 i 的项,我们只需要返回位于后备数组中第 i 个下标的项。因此,通过索引操作访问的时间复杂度始终为 O(1)

2.3. 通过索引删除

假设我们要从 ArrayList 中删除索引 6,它对应于我们的后备数组中的元素 15:

将所需元素标记为已删除后,我们应该将其后的所有元素向后移动一个索引。显然,元素越靠近数组的开头,我们应该移动的元素就越多。因此,时间复杂度在最佳情况下为 O(1),在平均和最坏情况下为 O(n)。

2.4. 应用和限制

.通常,当需要 List 实现时,ArrayList 是许多开发人员的默认选择。事实上,当读取次数远远超过写入次数时,这实际上是一个明智的选择
有时我们需要同样频繁的读取和写入。如果我们确实估计了可能项目的最大数量,那么使用 ArrayList 仍然有意义。如果是这种情况,我们可以使用初始容量初始化 ArrayList:

int possibleUpperBound = 10_000;
List<String> items = new ArrayList<>(possibleUpperBound);

这种估计可以防止大量不必要的复制和数组分配。

此外,数组由 Java 中的 int 值索引。因此,在 Java 数组中存储超过 2 的 32 次方个元素是不可能的,因此,在 ArrayList 中也是如此

3. LinkedList

LinkedList,顾名思义,使用链接节点的集合来存储和检索元素。例如,以下是添加四个元素后的 Java 实现:

每个节点维护两个指针:一个指向下一个元素,另一个指向前一个元素。对此进行扩展,双向链表有两个指向第一项和最后一项的指针
同样,让我们根据相同的基本操作来评估这个实现。

3.1. 添加

为了添加新节点,首先,我们应该将当前最后一个节点链接到新节点:

然后更新最后一个指针:

由于这两个操作都很简单,因此加法操作的时间复杂度始终为 O(1)

3.2. 通过索引访问

LinkedList 与 ArrayList 不同,不支持快速随机访问。因此,为了按索引查找元素,我们应该手动遍历列表的某些部分

在最好的情况下,当请求的项目接近列表的开头或结尾时,时间复杂度将与 O(1) 一样快。然而,在平均和最坏情况下,我们可能会以 O(n) 的访问时间结束,因为我们必须一个接一个地检查许多节点。

3.3. 通过索引删除

为了删除一项,我们应该首先找到请求的项,然后从列表中取消它的链接。因此,访问时间决定了时间复杂度——即在最佳情况下为 O(1),在平均和最坏情况下为 O(n)。

3.4. 应用

当添加率远高于读取率时,LinkedLists更适合。
此外,当大多数时候我们想要第一个或最后一个元素时,它可以用于读取密集的场景。值得一提的是,LinkedList 还实现了 Deque 接口——支持对集合两端的高效访问。
通常,如果我们知道它们的实现差异,那么我们可以轻松地为特定用例选择一个。
例如,假设我们将在类似列表的数据结构中存储大量时间序列事件。我们知道我们每秒都会收到突发事件。
此外,我们需要定期检查所有事件并提供一些统计数据。对于此用例,LinkedList 是更好的选择,因为添加速率远高于读取速率。
此外,我们会读取所有项目,因此我们无法超过 O(n) 上限。

4. 结论

在本教程中,我们首先深入研究了 ArrayListLinkLists 如何在 Java 中实现。
我们还评估了其中每一个的不同用例。

Java hashCode() 指南

【注】本文译自:Guide to hashCode() in Java | Baeldung

Java hashCode() 指南

1. 概述

    哈希是计算机科学的一个基本概念。
    在 Java 中,高效的哈希算法支持一些最流行的集合,例如 HashMap(查看这篇深入的 文章)和 HashSet。
    在本教程中,我们将重点介绍 hashCode() 的工作原理、它如何在集合中处理以及如何正确实现它。

2. 在数据结构中使用 hashCode()

    在某些情况下,最简单的集合操作可能效率低下。
    举例来说,这会触发线性搜索,这对于大型列表效率非常低:

List<String> words = Arrays.asList("Welcome", "to", "Baeldung");
if (words.contains("Baeldung")) {
    System.out.println("Baeldung is in the list");
}

    Java 提供了许多数据结构来专门处理这个问题。 例如,几个 Map 接口实现是 hash tables(哈希表)。
    使用哈希表时,这些集合使用 hashCode() 方法计算给定键的哈希值。然后他们在内部使用这个值来存储数据,以便访问操作更加高效。

3. 了解 hashCode() 的工作原理

    简而言之,hashCode() 返回一个由散列算法生成的整数值。
    相等的对象(根据它们的 equals())必须返回相同的哈希码。不同的对象不需要返回不同的哈希码
    hashCode() 的通用契约声明:

  • 在 Java 应用程序执行期间,只要在同一对象上多次调用它,hashCode() 必须始终返回相同的值,前提是对象上的 equals 比较中使用的信息没有被修改。这个值不需要从应用程序的一次执行到同一应用程序的另一次执行保持一致。
  • 如果根据 equals(Object) 方法两个对象相等,则对这两个对象中的每一个调用 hashCode() 方法必须产生相同的值。
  • 如果根据 equals(java.lang.Object) 方法两个对象不相等,则对这两个对象中的每一个调用 hashCode 方法不需要产生不同的整数结果。但是,开发人员应该意识到,为不相等的对象生成不同的整数结果可以提高哈希表的性能。

“在合理可行的情况下,类 Object 定义的 hashCode() 方法确实为不同的对象返回不同的整数。(这通常通过将对象的内部地址转换为整数来实现,但 JavaTM 编程语言不需要这种实现技术。)”

4. 一个简单的 hashCode() 实现

    一个完全符合上述约定的简单 hashCode() 实现实际上非常简单。
    为了演示这一点,我们将定义一个示例 User 类来覆盖该方法的默认实现:

public class User {

    private long id;
    private String name;
    private String email;

    // standard getters/setters/constructors
    @Override
    public int hashCode() {
        return 1;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;
        if (o == null)
            return false;
        if (this.getClass() != o.getClass())
            return false;
        User user = (User) o;
        return id == user.id && (name.equals(user.name) && email.equals(user.email));
    }
    // getters and setters here
}

    User 类为完全遵守各自合同的 equals() 和 hashCode() 提供自定义实现。更重要的是,让 hashCode() 返回任何固定值并没有什么不合法的。
    但是,这种实现将哈希表的功能降级到基本上为零,因为每个对象都将存储在同一个单个存储桶中。
在这种情况下,哈希表查找是线性执行的,并没有给我们带来任何真正的优势。我们将在第 7 节详细讨论。

5. 改进 hashCode() 实现

    让我们通过包含 User 类的所有字段来改进当前的 hashCode() 实现,以便它可以为不相等的对象产生不同的结果:

@Override
public int hashCode() {
    return (int) id * name.hashCode() * email.hashCode();
}

    这个基本的散列算法绝对比前一个好得多。这是因为它仅通过将 name 和 email 字段的哈希码与 id 相乘来计算对象的哈希码。
一般来说,我们可以说这是一个合理的 hashCode() 实现,只要我们保持 equals() 实现与其一致。6. 标准 hashCode() 实现

    我们用来计算哈希码的哈希算法越好,哈希表的性能就越好。
让我们看看一个“标准”实现,它使用两个素数为计算出的哈希码添加更多的唯一性:

@Override
public int hashCode() {
    int hash = 7;
    hash = 31 * hash + (int) id;
    hash = 31 * hash + (name == null ? 0 : name.hashCode());
    hash = 31 * hash + (email == null ? 0 : email.hashCode());
    return hash;
}

    虽然我们需要了解 hashCode() 和 equals() 方法所扮演的角色,但我们不必每次都从头开始实现它们。这是因为大多数 IDE 可以生成自定义 hashCode() 和 equals() 实现。从 Java 7 开始,我们有一个 Objects.hash() 实用方法来进行舒适的散列:

Objects.hash(name, email)

    IntelliJ IDEA 生成以下实现:

@Override
public int hashCode() {
    int result = (int) (id ^ (id >>> 32));
    result = 31 * result + name.hashCode();
    result = 31 * result + email.hashCode();
    return result;
}

    Eclipse 产生了这个:

@Override
public int hashCode() {
    final int prime = 31;
    int result = 1;
    result = prime * result + ((email == null) ? 0 : email.hashCode());
    result = prime * result + (int) (id ^ (id >>> 32));
    result = prime * result + ((name == null) ? 0 : name.hashCode());
    return result;
}

    除了上述基于 IDE 的 hashCode() 实现之外,还可以自动生成高效的实现,例如使用 Lombok.。
在这种情况下,我们需要在 pom.xml 中添加 lombok-maven 依赖:

<dependency>
  <groupId>org.projectlombok</groupId>
  <artifactId>lombok-maven</artifactId>
  <version>1.16.18.0</version>
  <type>pom</type>
</dependency>

    现在用@EqualsAndHashCode 注解 User 类就足够了:

@EqualsAndHashCode
public class User {
    // fields and methods here
}

    同样,如果我们希望 Apache Commons Lang 的 HashCodeBuilder 类为我们生成 hashCode() 实现,我们在 pom 文件中包含 commons-lang Maven 依赖项:

<dependency>
  <groupId>commons-lang</groupId>
  <artifactId>commons-lang</artifactId>
  <version>2.6</version>
</dependency>

    hashCode() 可以这样实现:

public class User {
    public int hashCode() {
        return new HashCodeBuilder(17, 37).
        append(id).
        append(name).
        append(email).
        toHashCode();
    }
}

    一般来说,在实现 hashCode() 时没有通用的方法。我们强烈推荐阅读 Joshua Bloch 的 Effective Java.。它提供了实现高效散列算法的详尽指南列表。
    请注意,所有这些实现都以某种形式使用了数字 31。这是因为 31 有一个很好的属性。它的乘法可以用按位移位代替,这比标准乘法要快:

31 * i == (i << 5) - i

7. 处理哈希冲突

    哈希表的内在行为带来了这些数据结构的一个相关方面:即使使用有效的哈希算法,两个或多个对象可能具有相同的哈希码,即使它们不相等。因此,即使它们具有不同的散列表键,它们的散列码也会指向同一个桶。

    这种情况通常被称为哈希冲突,有多种处理方法,每种方法都有其优点和缺点。Java 的 HashMap 使用单独的链接方法来处理冲突:
    “当两个或多个对象指向同一个存储桶时,它们只是存储在一个链表中。在这种情况下,哈希表是一个链表数组,每个具有相同哈希值的对象都附加到链表中的桶索引处。
在最坏的情况下,几个桶会绑定一个链表,而对链表中对象的检索将是线性执行的。”

    哈希冲突方法简单说明了高效实现 hashCode() 的重要性。
    Java 8 为 HashMap 实现带来了有趣的增强。如果桶大小超过特定阈值,则树图替换链表。这允许实现 O(logn) 查找而不是悲观 O(n)。

8. 创建一个简单的应用程序

    现在我们将测试标准 hashCode() 实现的功能。
    让我们创建一个简单的 Java 应用程序,将一些 User 对象添加到 HashMap 并使用 SLF4J 在每次调用该方法时将消息记录到控制台。
    这是示例应用程序的入口点:

public class Application {

    public static void main(String[] args) {
        Map<User, User> users = new HashMap<>();
        User user1 = new User(1L, "John", "john@domain.com");
        User user2 = new User(2L, "Jennifer", "jennifer@domain.com");
        User user3 = new User(3L, "Mary", "mary@domain.com");

        users.put(user1, user1);
        users.put(user2, user2);
        users.put(user3, user3);
        if (users.containsKey(user1)) {
            System.out.print("User found in the collection");
        }
    }
}

    这是 hashCode() 实现:

public class User {

    // ...

    public int hashCode() {
        int hash = 7;
        hash = 31 * hash + (int) id;
        hash = 31 * hash + (name == null ? 0 : name.hashCode());
        hash = 31 * hash + (email == null ? 0 : email.hashCode());
        logger.info("hashCode() called - Computed hash: " + hash);
        return hash;
    }
}

    这里需要注意的是,每次在哈希映射中存储对象并使用 containsKey() 方法检查时,都会调用 hashCode() 并将计算出的哈希码打印到控制台:

[main] INFO com.baeldung.entities.User - hashCode() called - Computed hash: 1255477819
[main] INFO com.baeldung.entities.User - hashCode() called - Computed hash: -282948472
[main] INFO com.baeldung.entities.User - hashCode() called - Computed hash: -1540702691
[main] INFO com.baeldung.entities.User - hashCode() called - Computed hash: 1255477819
User found in the collection

9. 结论

    很明显,生成高效的 hashCode() 实现通常需要混合一些数学概念(即素数和任意数)、逻辑和基本数学运算。
    无论如何,我们可以有效地实现 hashCode() ,而无需使用这些技术。我们只需要确保散列算法为不相等的对象生成不同的哈希码,并且它与 equals() 的实现一致。
    与往常一样,本文中显示的所有代码示例都可以在 GitHub 上找到

DevOps 发展史

【注】本文节译自:https://blog.devops4me.com/history-of-devops/

起源

  软件开发生命周期(SDLC)的发展迅速改变了组织如何将其产品发布/生产到生产环境的格局。当谈到组织如何管理产品交付时,传统的 SDLC 通常与瀑布(Waterfall)模式联系在一起。瀑布模式不能适应与组织的业务目标,他们希望为客户提供更快的速度和功能/产品。照此逻辑,他们需要一种新的方法来加快产品交付,但同时还要改善开发人员IT 运营团队。因此,DevOps 就出现了,2008 年多伦多敏捷会议上,Petrick Debois 介绍了“DevOps”一词。
  第一次会议名为 Devopsdays,于 2009 年在比利时根特举行。比利时顾问,项目经理和敏捷实践者 Patrick Debois 创立了会议。该会议现已传播到其他国家。2012年,DevOps 状态报告由 Puppet 的 Alanna Brown 起草并发布。截至 2014 年,Nicole Forsgren、Gene Kim、Jez Humble 等人发布了年度 DevOps 状态报告。在2014年,他们发现 DevOp 的采用正在加速。同样在 2014 年,Lisa Crispin 和 Janet Gregory 撰写了 More Agile Testing,其中包括有关测试和 DevOps 的章节。
  如果我将上面的时间事件放到时间轴中,如下图所示:

什么是 DevOps?

  DevOps 也是新兴技术和新兴商业文化的结合。转向 DevOps 文化的想法是建立开放的沟通,透明性和跨学科团队合作。DevOps背后的概念打破了孤岛,并为开发人员(DEV)与 IT 运营(OPS)之间的讨论和协作创造了更多空间。DevOps 的力量在于支持它的文化,使人们的思维方式从孤岛上移开了。它通常可以帮助您了解其来源、为什么变得流行以及什么使它流行。

目的是什么?

  在实施 DevOps 文化和方法时,DevOps 可以解决您组织面临的挑战,并且组织将获得:

  • 更快的服务交付:紧跟快速需求的敏捷版本。跨数据可见性:确保合规性和数据准确性。
  • 服务效率:提高质量和性能。
  • 经验丰富的专业DevOps:教您成功所需的工具。
  • 全面迎合组织的特定需求。

    DevOps 是:

  • 概念
  • 心态
  • 个人理解和拥护的共同态度
  • 必须培育和反复改进的文化
  • 可见度
  • 指导
  • 学习
  • 包容和开放的所有想法
  • 迭代
  • 持续
  • 协同合作
  • 自信地开发和交付软件的绝佳方法

    DevOps 不是:

  • 轻松实现或实施
  • 产品或工具链
  • 职务或职位
  • 云基础架构解决方案
  • 一项技术
  • 一种编程语言
  • 营销活动
  • CI / CD 流水线
  • Kubernetes
  • 容器 / Docker
  • 开源软件
  • 基础设施即代码
  • 自动化

    简而言之

      DevOps 认为 IT 行业急需概念上的不断学习和改进。而且,IT 社区无疑可以从 DevOps 历史中学到很多东西。这场 DevOps 革命不足为奇,而且随着创新的不断发展,其重要性在未来会不断提高。我们已经看到安全性与 DevOps 结合可以如何永远改变 Infosec 行业。

    结论

      将 DevOps 描述为一个旅程或愿望,而不是定义的目标或工具是合理的。DevOps 寻求持续的改进、更多的输出、更高的效率、甚至持续部署。支持 DevOps 的自动化工具还在不断发展。