【问题标题】:Why is the same Comparator acting differently in unit tests vs. when run as a web app?为什么相同的 Comparator 在单元测试中的行为与作为 Web 应用程序运行时的行为不同?
【发布时间】:2018-12-15 07:02:03
【问题描述】:

TL;DR:经过多次试验和错误,问题似乎与 Tomcat 相关,可能与配置的 java 版本有关,而不是 java 语言本身。有关详细信息,请参阅下面的“编辑 3”。

我已经使用 Java 8 流和比较器有一段时间了,以前从未见过这种行为,所以出于好奇,我想看看是否有人能找出我的流有什么问题。

我正在通过用流替换我们过时的集合处理来开发“Java-8-ifying”一个遗留项目(有人问我为什么要这样做,简短的回答是我们本质上是重写项目,但只有时间预算增量做。我在做第一步——更新java版本。围绕集合逻辑有很多乱七八糟的代码,所以“Java-8-ifying”是用于清理大量代码并使事情更易于阅读和维护)。目前,我们仍在使用旧的数据类型,所以提到的任何“日期”都是处理 java.util.Date 实例而不是新的 Java 8 类型。

这是我在 ServiceRequest.java 中的比较器(它是一个 POJO):

public static final Comparator<ServiceRequest> BY_ACTIVITY_DATE_DESC = Comparator.comparing(
        ServiceRequest::getActivityDate, Comparator.nullsLast(Comparator.reverseOrder()));

进行单元测试时,此比较器按预期工作。具有较晚activityDate 的ServiceRequest 在结果列表中排在第一位,具有较早activityDate 的ServiceRequest 在列表的下方,而具有空activityDate 的ServiceRequest 在底部。作为参考,这里是单元测试的完整副本:

@Test
public void testComparator_BY_ACTIVITY_DATE_DESC() {
    ServiceRequest olderRequest = new ServiceRequest();
    olderRequest.setActivityDate(DateUtil.yesterday());

    ServiceRequest newerRequest = new ServiceRequest();
    newerRequest.setActivityDate(DateUtil.tomorrow());

    ServiceRequest noActivityDateRequest = new ServiceRequest();

    List<ServiceRequest> sortedRequests = Arrays.asList(olderRequest, noActivityDateRequest, newerRequest).stream()
            .sorted(ServiceRequest.BY_ACTIVITY_DATE_DESC)
            .collect(Collectors.toList());

    assertEquals(sortedRequests.get(0), newerRequest);
    assertEquals(sortedRequests.get(1), olderRequest);
    assertEquals(sortedRequests.get(2), noActivityDateRequest);
}

注意:DateUtil 是一个遗留实用程序,它为我们的测试目的创建 java.util.Date 实例。

正如我所料,这个测试总是以优异的成绩通过。但是,我有一个控制器,它组装一个开放服务请求列表,并按请求者标识符对它们进行分组,并仅选择该用户的最新请求到地图中。我试图将此逻辑转换为给定的流:

private Map<Long, ServiceRequestViewBean> ServiceRequestsByUser(List<ServiceRequest> serviceRequests) {
    return serviceRequests.stream()
            .sorted(ServiceRequest.BY_ACTIVITY_DATE_DESC)
            .collect(Collectors.toMap(
                    serviceRequest -> serviceRequest.getRequester().getId(),
                    serviceRequest -> new ServiceRequestViewBean(serviceRequest),
                    (firstServiceRequest, secondServiceRequest) -> firstServiceRequest)
            );
}

我的逻辑是,在将请求按最近的请求排序后,每当同一用户发出的多个请求被处理时,只会将最近的一个放入地图中。

但是,观察到的行为是 OLDEST 请求被放入地图中。 注意:我已经证实,当控制器代码通过 jUnit 测试调用时,行为符合预期;仅当在 tomcat 上运行时调用控制器上的端点时才会出现错误行为。有关详细信息,请参阅“编辑 3”

我添加了一些查看ServiceRequest IDs(不是请求者IDs,在这种情况下遇到合并功能时相同)在排序之前、排序之后和合并中地图采集功能。 为简单起见,我将数据限制为单个请求者的 4 个请求。

ServiceRequest ID 的预期顺序:

ID      ACTIVITY DATE
365668  06-JUL-18 09:01:44
365649  05-JUL-18 15:41:40
365648  05-JUL-18 15:37:43
365647  05-JUL-18 15:31:47

我偷看的输出:

Before Sorting: 365647
Before Sorting: 365648
Before Sorting: 365649
Before Sorting: 365668
After Sorting: 365647
After Sorting: 365648
First request: 365647, Second request: 365648
After Sorting: 365649
First request: 365647, Second request: 365649
After Sorting: 365668
First request: 365647, Second request: 365668

我认为地图合并输出与排序后窥视的穿插很有趣,但我想由于没有更多的有状态中间操作,它只是决定在查看它们时将东西添加到地图中。

由于排序前后的 peek 输出相同,我得出的结论是排序对遇到顺序没有影响,或者比较器由于某种原因按升序排序(与预期设计相反),并且要么来自数据库的输入恰好按此顺序排列,要么流在任何一个偷看之前解决了排序(尽管我不确定这是否可能......)。出于好奇,我对数据库调用进行了排序,看看它是否会改变这个流的结果。我告诉数据库调用按活动日期降序排序,以便保证输入到流中的顺序。如果比较器以某种方式倒置,它应该将项目的顺序翻转回升序。

但是 DB-ordered 流的输出很像第一个,只有顺序与数据库排序产生的原始顺序保持一致......这让我相信我的比较器对此绝对没有影响流。

我的问题是为什么会这样? toMap 收集器是否忽略遇到顺序?如果是这样,为什么这会导致排序调用无效?我认为排序,作为一个有状态的中间步骤,强制后续步骤观察遇到顺序(forEach 除外,因为有一个 forEachOrdered)。

当我查找 toMap 的 javadoc 时,它有一个关于并发的注释:

返回的收集器不是并发的。对于并行流管道,组合器功能通过将键从一个映射合并到另一个映射来操作,这可能是一项昂贵的操作。如果不需要将结果按遇到顺序合并到 Map 中,使用 toConcurrentMap(Function, Function, BinaryOperator) 可能会提供更好的并行性能。

这让我相信遇到顺序应该由 toMap 收集器保存。对于为什么要观察这种特殊行为,我感到非常迷茫和困惑。 我知道我可以通过在合并函数中进行日期比较来解决此问题,但我想了解为什么我的比较器在与 toList 收集器一起使用时似乎可以工作,但与 toMap 收集器一起使用时却不行。

提前感谢您的洞察力!

编辑 1: 许多人建议使用 LinkedHashMap 来解决问题,所以我实现了这样的解决方案:

return serviceRequests.stream()
            .sorted(ServiceRequest.BY_ACTIVITY_DATE_DESC)
            .collect(Collectors.toMap(
                    serviceRequest -> serviceRequest.getRequester().getId(),
                    serviceRequest -> new ServiceRequestViewBean(serviceRequest),
                    (serviceRequestA, serviceRequestB) -> serviceRequestA,
                    LinkedHashMap::new));

但是在测试时,它实际上是解析为较旧的,而不是比较器应该强制执行的所需的最新的。我仍然很困惑。 注意:我已经验证了错误行为仅在作为 Tomcat 上的 webapp 运行时才会出现。当通过 jUnit 测试调用此代码时,它会按预期运行。有关详细信息,请参阅“编辑 3”

编辑 2: 有趣的是,当我实施我认为可行的解决方案时(在合并函数中排序)也不起作用:

 return serviceRequests.stream()
            .sorted(ServiceRequest.BY_ACTIVITY_DATE_DESC)
            .collect(Collectors.toMap(
                    serviceRequest -> serviceRequest.getRequester().getId(),
                    serviceRequest -> new ServiceRequestViewBean(serviceRequest),
                    (firstServiceRequest, secondServiceRequest) -> {
                        return Stream.of(firstServiceRequest, secondServiceRequest)
                                .peek(request -> System.out.println("- Before Sort -\n\tRequester ID: "
                                + request.getRequester().getId() + "\n\tRequest ID: " + request.getId()))
                                .sorted(ServiceRequest.BY_ACTIVITY_DATE_DESC)
                                .peek(request -> System.out.println("- After sort -\n\tRequester ID: "
                                + request.getRequester().getId() + "\n\tRequest ID: " + request.getId()))
                                .findFirst().get();
            }));

产生以下输出:

- Before Sort -
    Requester ID: 67200307
    Request ID: 365647
- Before Sort -
    Requester ID: 67200307
    Request ID: 365648
- After sort -
    Requester ID: 67200307
    Request ID: 365647
- Before Sort -
    Requester ID: 67200307
    Request ID: 365647
- Before Sort -
    Requester ID: 67200307
    Request ID: 365649
- After sort -
    Requester ID: 67200307
    Request ID: 365647
- Before Sort -
    Requester ID: 67200307
    Request ID: 365647
- Before Sort -
    Requester ID: 67200307
    Request ID: 365668
- After sort -
    Requester ID: 67200307
    Request ID: 365647

注意:我已经验证了这个错误输出仅在作为 Tomcat 上的 Web 应用程序运行时产生。当通过 jUnit 测试调用代码时,它会按预期正常运行。有关详细信息,请参阅“编辑 3”

这似乎表明我的比较器确实什么也没做,或者正在按照与单元测试中相反的顺序进行积极排序,或者 findFirst 正在做与 toMap 所做的相同的事情。但是 findFirst 的 javadoc 会建议 findFirst 在使用诸如 sorted 之类的中间步骤时尊重遇到的顺序。

编辑 3: 我被要求制作一个最小、完整且可验证的示例项目,所以我做了:https://github.com/zepuka/ecounter-order-map-collect

我尝试了几种不同的策略来尝试重现问题(每个都在 repo 中标记),但无法重现我在控制器中遇到的错误行为。我的第一个解决方案以及我尝试过的所有建议都产生了所需的正确行为!那么为什么当应用程序运行时我会得到不同的行为呢?对于踢腿和咯咯笑,我将控制器上的方法公开,以便我可以对其进行单元测试,并使用在单元测试运行期间给我带来麻烦的完全相同的数据 - 它在 jUnit 测试中正常运行。一定有一些不同的东西可以让这段代码在单元测试和普通的 Java 主方法中正确运行,但在我的 tomcat 服务器上运行时却不正确。

不过,我编译和运行服务器的 Java 版本是相同的:1.8.0_171-b11 (Oracle)。起初我是在 Netbeans 中构建和运行,但我做了一个命令行构建和 tomcat 启动,以确保没有一些奇怪的 Netbeans 设置干扰。但是,当我查看 netbeans 中的运行属性时,它确实说它使用“Java EE 7 Web”作为 Java EE 版本以及服务器设置(即 Apache Tomcat 8.5.29,运行 java 8),我会承认我不知道“Java EE 版本”是什么意思。

所以我在这篇文章中添加了 Tomcat 标记,因为我的问题似乎与 Tomcat 相关,而不是与 Java 语言相关。在这一点上,解决我的问题的唯一方法似乎是使用非流方法来构建地图,但我仍然很想知道人们对我可以研究什么配置来解决问题的想法。

编辑 4: 我试图通过使用旧的做事方式来解决这个问题,当我避免在 Stream 中使用 Comparator 时,一切都很好,但是一旦我在流程中的任何地方将 Comparator 引入 Stream,Web 应用程序就无法正常运行。我尝试在没有流的情况下处理列表,并且仅在两个请求上使用流以在合并到地图时使用比较器,但这不起作用。我尝试只使用内联类定义制作老式的比较器,使用普通的旧 java 而不是 Comparator.comparing,但在流中使用它失败了。只有当我完全避开流和比较器时,它似乎才起作用。

【问题讨论】:

  • “我正在通过用流替换我们过时的收集处理来开发“Java-8-ifying”一个遗留项目”为什么?听起来像是打破事物的完美秘诀。是否有针对您要更改的所有内容的单元测试?
  • 返回的Map 没有排序,因此不会从添加元素的顺序中保留遇到的顺序。有一个toMap 收集器,您可以在其中传递初始地图实例,使用LinkedHashMap。这不是比较器。
  • toMap 默认使用HashMap。迭代顺序是任意的,因为您应该期望任何Map 都是,除非它明确说明。
  • Maps 是无序的无关紧要。问题不在于条目的迭代顺序,而在于哪个值最终与每个键相关联。这将是使用中的收集器如何处理传入流的结果。
  • 无论是否取决于流中元素的顺序,依靠遇到顺序作为元素顺序的代理是脆弱的:根据比较器显式取元素的最小值,使用Collectors.minBy(BY_ACTIVITY_DATE_DESC) 作为下游收集器。

标签: java sorting tomcat java-stream collectors


【解决方案1】:

终于查到了!

我能够通过首先将新比较器应用到需要它的任何地方来隔离问题。我能够观察到其中大部分的行为都符合我的预期,并且只有在某个页面上才会出现问题。

我之前的调试输出只包含了 ID,但这次为了方便,我包含了活动日期,当我点击那个 JSP 时,它们是空的!

问题的根源在于,在一个单一的 DAO 方法中(它进行了一些正则表达式解析以调用不同的内部方法 - 是的,这是一团糟),它没有使用我之前检查过的行映射器。 .. 这个特殊的帮助方法包含一个邪恶的内联'行映射器',它原始地使用循环和索引来获取查询结果并将它们放入对象中,但它缺少活动日期列.似乎这个特定页面的开发历史(由我挖掘的提交历史记录)遇到了性能问题,所以当他们“提高性能”时,他们使内联行映射器只包含最关键的部分当时需要的资料。事实证明,它是唯一一个落入正则表达式逻辑特定分支的页面,这是我以前没有注意到的。

这只是该项目获得“意大利面条代码最差情况”奖的另一个原因。这个特别难以追踪,因为无法确认“工作”结果是否真的有效,或者它是否恰好在那个时候工作,因为不能保证数据库中的订单,也不能保证所有日期都为空。

TL;DR:不是tomcat的错,而是DAO逻辑角落分支中的流氓内联行映射器仅由特定JSP触发

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2012-11-30
    • 2013-05-13
    • 2011-04-16
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2014-01-24
    相关资源
    最近更新 更多