Java Stream API扩展简介插图

Stream API Java8中引入的Stream API可能仍然是Java在过去几年中包含的最重要的新特性。我认为每个Java开发人员在其职业生涯中都有机会使用Java Stream API。或者我更愿意说,你可能每天都在使用它。但是,如果您将函数式编程提供的内置功能与其他一些语言(例如Kotlin)进行比较,您会很快意识到流 Stream API提供的方法数量非常有限。因此,社区创建了几个库,这些库仅用于扩展纯Java提供的API。今天,我将展示三个流行库:StreamExjOOλGuava提供的最有趣的Java流API扩展。

本文只讨论顺序Java流扩展。如果您使用并行流,您将无法利用jOOλ,因为它仅用于顺序流。

依赖关系

下面是本文中比较的所有三个库的当前版本列表。

<dependencies>
   <dependency>
      <groupId>one.util</groupId>
      <artifactId>streamex</artifactId>
      <version>0.7.0</version>
   </dependency>
   <dependency>
      <groupId>org.jooq</groupId>
      <artifactId>jool</artifactId>
      <version>0.9.13</version>
   </dependency>
   <dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
      <version>28.1-jre</version>
   </dependency>
</dependencies>

使用Java Stream扩展压缩

在更高级的应用程序中使用Java Stream时,您通常会处理多个Stream。它们通常也可以包含不同的对象。在这种情况下,一个有用的操作是压缩。压缩操作返回一个流,该流在给定的两个流中包含一对对应的元素,这意味着它们在这些流中处于相同的位置。让我们考虑两个对象:人和人物地址。假设我们有两个流,第一个流只包含Person对象,第二个流包含PersonalAddress对象,元素的顺序清楚地表明了它们之间的关联,我们可以压缩它们来创建一个新的对象流,其中包含PersonPersonalAddress中的所有字段。下面的屏幕演示了所描述的场景。

Java Stream API扩展简介插图1

当前描述的三个库都支持压缩。让我们从Guava的例子开始。它提供了唯一一种专门用于压缩的方法——静态压缩方法,它接受三个参数:第一个流、第二个流和映射函数。

Stream<Person> s1 = Stream.of(
   new Person(1, "John", "Smith"),
   new Person(2, "Tom", "Hamilton"),
   new Person(3, "Paul", "Walker")
);
Stream<PersonAddress> s2 = Stream.of(
   new PersonAddress(1, "London", "Street1", "100"),
   new PersonAddress(2, "Manchester", "Street1", "101"),
   new PersonAddress(3, "London", "Street2", "200")
);
Stream<PersonDTO> s3 = Streams.zip(s1, s2, (p, pa) -> PersonDTO.builder()
   .id(p.getId())
   .firstName(p.getFirstName())
   .lastName(p.getLastName())
   .city(pa.getCity())
   .street(pa.getStreet())
   .houseNo(pa.getHouseNo()).build());
s3.forEach(dto -> {
   Assertions.assertNotNull(dto.getId());
   Assertions.assertNotNull(dto.getFirstName());
   Assertions.assertNotNull(dto.getCity());
});

与Guava相比,StreamEx和jOOλ提供了更多的zipping可能性。我们可以在给定流上调用的一些静态方法或非静态方法之间进行选择。让我们看看如何使用StreamEx zipWith方法执行此操作。

StreamEx<Person> s1 = StreamEx.of(
   new Person(1, "John", "Smith"),
   new Person(2, "Tom", "Hamilton"),
   new Person(3, "Paul", "Walker")
);
StreamEx<PersonAddress> s2 = StreamEx.of(
   new PersonAddress(1, "London", "Street1", "100"),
   new PersonAddress(2, "Manchester", "Street1", "101"),
   new PersonAddress(3, "London", "Street2", "200")
);
StreamEx<PersonDTO> s3 = s1.zipWith(s2, (p, pa) -> PersonDTO.builder()
   .id(p.getId())
   .firstName(p.getFirstName())
   .lastName(p.getLastName())
   .city(pa.getCity())
   .street(pa.getStreet())
   .houseNo(pa.getHouseNo()).build());
s3.forEach(dto -> {
   Assertions.assertNotNull(dto.getId());
   Assertions.assertNotNull(dto.getFirstName());
   Assertions.assertNotNull(dto.getCity());
});

这个例子几乎是一样的。我们有一个在给定流上调用的zip方法。

Seq<Person> s1 = Seq.of(
   new Person(1, "John", "Smith"),
   new Person(2, "Tom", "Hamilton"),
   new Person(3, "Paul", "Walker"));
Seq<PersonAddress> s2 = Seq.of(
   new PersonAddress(1, "London", "Street1", "100"),
   new PersonAddress(2, "Manchester", "Street1", "101"),
   new PersonAddress(3, "London", "Street2", "200"));
Seq<PersonDTO> s3 = s1.zip(s2, (p, pa) -> PersonDTO.builder()
   .id(p.getId())
   .firstName(p.getFirstName())
   .lastName(p.getLastName())
   .city(pa.getCity())
   .street(pa.getStreet())
   .houseNo(pa.getHouseNo()).build());
s3.forEach(dto -> {
   Assertions.assertNotNull(dto.getId());
   Assertions.assertNotNull(dto.getFirstName());
   Assertions.assertNotNull(dto.getCity());
});

加入Java Stream 扩展

压缩操作根据元素在两个不同流中的顺序合并两个不同流中的元素。如果我们想根据元素的字段(如id)而不是流中的顺序来关联元素,该怎么办。两个实体之间的左连接或右连接。一个操作的结果应该与上一节相同——一个新的对象流,包含来自PersonPersonalAddress的所有字段。所述操作如下图所示。

Java Stream API扩展简介插图2

对于连接操作,只有jOOλ提供了一些方法。由于它专门用于面向对象的查询,我们可以在许多连接选项之间进行选择。例如,有innerJoinleftOuterJoinrighouterjoincrossJoin方法。在下面可见的源代码中,您可以看到innerJoin用法的示例。此方法采用两个参数:连接流和谓词,用于匹配来自第一个流和连接流的元素。如果我们想基于innerJoin结果创建新对象,我们应该另外调用map操作。

Seq<Person> s1 = Seq.of(
      new Person(1, "John", "Smith"),
      new Person(2, "Tom", "Hamilton"),
      new Person(3, "Paul", "Walker"));
Seq<PersonAddress> s2 = Seq.of(
      new PersonAddress(2, "London", "Street1", "100"),
      new PersonAddress(3, "Manchester", "Street1", "101"),
      new PersonAddress(1, "London", "Street2", "200"));
Seq<PersonDTO> s3 = s1.innerJoin(s2, (p, pa) -> p.getId().equals(pa.getId())).map(t -> PersonDTO.builder()
      .id(t.v1.getId())
      .firstName(t.v1.getFirstName())
      .lastName(t.v1.getLastName())
      .city(t.v2.getCity())
      .street(t.v2.getStreet())
      .houseNo(t.v2.getHouseNo()).build());
s3.forEach(dto -> {
   Assertions.assertNotNull(dto.getId());
   Assertions.assertNotNull(dto.getFirstName());
   Assertions.assertNotNull(dto.getCity());
});

使用Java流扩展进行分组

Java流API仅通过Java.util.Stream.Collectors中的静态方法groupingBy支持的下一个有用的操作是grouping (s1.collect(Collectors.groupingBy(PersonDTO::getCity)))。在流上执行这样的操作后,您将得到一个带有键的映射,这些键是将分组函数应用于输入元素后得到的值,其对应的值是包含输入元素的列表。这个操作是某种聚合,因此得到的结果是java.util.List,没有java.util.stream.stream

StreamEx和jOOλ都提供了一些分组流的方法。让我们从StreamEx groupingBy操作示例开始。假设我们有一个PersonDTO对象的输入流,我们将按个人的家乡城市对它们进行分组。

StreamEx<PersonDTO> s1 = StreamEx.of(
   PersonDTO.builder().id(1).firstName("John").lastName("Smith").city("London").street("Street1").houseNo("100").build(),
   PersonDTO.builder().id(2).firstName("Tom").lastName("Hamilton").city("Manchester").street("Street1").houseNo("101").build(),
   PersonDTO.builder().id(3).firstName("Paul").lastName("Walker").city("London").street("Street2").houseNo("200").build(),
   PersonDTO.builder().id(4).firstName("Joan").lastName("Collins").city("Manchester").street("Street2").houseNo("201").build()
);
Map<String, List<PersonDTO>> m = s1.groupingBy(PersonDTO::getCity);
Assertions.assertNotNull(m.get("London"));
Assertions.assertTrue(m.get("London").size() == 2);
Assertions.assertNotNull(m.get("Manchester"));
Assertions.assertTrue(m.get("Manchester").size() == 2);

相似jOOλgroupBy方法的结果是相同的。它还返回映射内的多个java.util.List对象。

Seq<PersonDTO> s1 = Seq.of(
      PersonDTO.builder().id(1).firstName("John").lastName("Smith").city("London").street("Street1").houseNo("100").build(),
      PersonDTO.builder().id(2).firstName("Tom").lastName("Hamilton").city("Manchester").street("Street1").houseNo("101").build(),
      PersonDTO.builder().id(3).firstName("Paul").lastName("Walker").city("London").street("Street2").houseNo("200").build(),
      PersonDTO.builder().id(4).firstName("Joan").lastName("Collins").city("Manchester").street("Street2").houseNo("201").build()
);
Map<String, List<PersonDTO>> m = s1.groupBy(PersonDTO::getCity);
Assertions.assertNotNull(m.get("London"));
Assertions.assertTrue(m.get("London").size() == 2);
Assertions.assertNotNull(m.get("Manchester"));
Assertions.assertTrue(m.get("Manchester").size() == 2);

多重串联

这是一个非常简单的场景。Java Stream API提供了一种用于连接的静态方法,但仅适用于两个流。有时,在一个步骤中浓缩多个流是很方便的。番石榴和jOOλ提供了专门的方法。

下面是使用jOOλ调用concat方法的示例:

Seq<Integer> s1 = Seq.of(1, 2, 3);
Seq<Integer> s2 = Seq.of(4, 5, 6);
Seq<Integer> s3 = Seq.of(7, 8, 9);
Seq<Integer> s4 = Seq.concat(s1, s2, s3);
Assertions.assertEquals(9, s4.count());

这是Guava的类似例子:

Stream<Integer> s1 = Stream.of(1, 2, 3);
Stream<Integer> s2 = Stream.of(4, 5, 6);
Stream<Integer> s3 = Stream.of(7, 8, 9);
Stream<Integer> s4 = Streams.concat(s1, s2, s3);
Assertions.assertEquals(9, s4.count());

Partitioning 分割

分区操作非常类似于分组,但将输入流分成两个列表或流,其中第一个列表中的元素满足给定的谓词,而第二个列表中的元素不满足。

StreamEx PartitionBy方法将返回地图中的两个列表对象。

StreamEx<PersonDTO> s1 = StreamEx.of(
      PersonDTO.builder().id(1).firstName("John").lastName("Smith").city("London").street("Street1").houseNo("100").build(),
      PersonDTO.builder().id(2).firstName("Tom").lastName("Hamilton").city("Manchester").street("Street1").houseNo("101").build(),
      PersonDTO.builder().id(3).firstName("Paul").lastName("Walker").city("London").street("Street2").houseNo("200").build(),
      PersonDTO.builder().id(4).firstName("Joan").lastName("Collins").city("Manchester").street("Street2").houseNo("201").build()
);
Map<Boolean, List<PersonDTO>> m = s1.partitioningBy(dto -> dto.getStreet().equals("Street1"));
Assertions.assertTrue(m.get(true).size() == 2);
Assertions.assertTrue(m.get(false).size() == 2);

与StreamEx相反,jOOλ在Tuple2对象内返回两个流(Seq)。与StreamEx相比,这种方法有一个很大的优势——您仍然可以在不进行任何转换的情况下对结果调用流操作。

Seq<PersonDTO> s1 = Seq.of(
      PersonDTO.builder().id(1).firstName("John").lastName("Smith").city("London").street("Street1").houseNo("100").build(),
      PersonDTO.builder().id(2).firstName("Tom").lastName("Hamilton").city("Manchester").street("Street1").houseNo("101").build(),
      PersonDTO.builder().id(3).firstName("Paul").lastName("Walker").city("London").street("Street2").houseNo("200").build(),
      PersonDTO.builder().id(4).firstName("Joan").lastName("Collins").city("Manchester").street("Street2").houseNo("201").build()
);
Tuple2<Seq<PersonDTO>, Seq<PersonDTO>> t = s1.partition(dto -> dto.getStreet().equals("Street1"));
Assertions.assertTrue(t.v1.count() == 2);
Assertions.assertTrue(t.v2.count() == 2);

聚集

只有jOOλ提供了一些流聚合方法。例如,我们可以计算总和、平均值或中位数。由于jOOλ是jOOQ的一部分,它的目标是用于面向对象的查询,事实上,它提供了许多与SQL SELECT子句相对应的操作。

下面可见的源代码片段以所有人的年龄为例,说明了我们可以多么容易地计算对象流中选定字段的总和。

Seq<Person> s1 = Seq.of(
   new Person(1, "John", "Smith", 35),
   new Person(2, "Tom", "Hamilton", 45),
   new Person(3, "Paul", "Walker", 20)
);
Optional<Integer> sum = s1.sum(Person::getAge);
Assertions.assertEquals(100, sum.get());

配对

StreamEx允许您处理流中的相邻对象对,并对其应用给定的函数。它可以通过使用pairMap函数来实现。在下面可见的代码片段中,我正在计算流中每对相邻数字的总和。

StreamEx<Integer> s1 = StreamEx.of(1, 2, 1, 2, 1);
StreamEx<Integer> s2 = s1.pairMap(Integer::sum);
s2.forEach(i -> Assertions.assertEquals(3, i));

总结

虽然Guava Streams只是更大的Google库的一部分,但StreamEx和jOOλ严格地专用于lambda Streams。与本文描述的其他库相比,jOOλ提供了最多的特性和操作。如果您正在寻找一个库来帮助您在流上执行OO操作,那么jOOλ绝对适合您。与另一个不同,它提供了操作,例如用于连接或聚合的操作。StreamEx还提供了许多有用的操作来操作流。它与面向对象查询和SQL无关,因此您不会发现它们用于无序联接或聚合的方法,这不会改变一个事实,即它非常有用,值得推荐一个库。番石榴为溪流提供的功能相对较少。但是,如果您已经在应用程序中使用了它,那么它可能是处理流的一个很好的补充。源代码片段和使用示例可以在存储库的GitHub上找到https://github.com/piomin/sample-java-playground.git.