焼け石に肉

新米プログラマの学習記録日記です。Scalaに興味があるので、ScalaとPlay Frameworkの勉強メモを残していこうと思います。

JPAstreamerでHibernate/JPAのクエリをJava Streamで表現する

JPAstreamerというオープンソースライブラリを使ってJPAHibernate、Springのクエリを表現してみます。

jpastreamer.org

Java Streamは、効率的かつ簡潔で、しかも直感的にロジックをストリームで表現することができます。

3つのコード例から見てみます。

  1. JPA CriteriaBuilder
  2. Spring Data JPA
  3. JPAstreamer

の3つです。

JPA CriteriaBuilder
void pagenation(EntityManager entityManager, int page, int pageSize) {

    final CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
    final CriteriaQuery<User> criteriaQuery = criteriaBuilder.createQuery(User.class);

    final Root<User> root = criteriaQuery.from(User.class);
    criteriaQuery.select(root);

    final TypedQuery<User> typedQuery = entityManager.createQuery(criteriaQuery)
        .setFirstResult((page - 1) * pageSize)
        .setMaxResults(pageSize);

    typedQuery.getResultList()
        .forEach(System.out::println);

}
Spring Data JPA
interface UserRepository extends JpaRepository<User, Integer> {
    @Query("select user from User user")
    Page<User> findAllPaged(Pageable pageable);
}

...

@Autowired
private UserRepository userRepository;

void pagenation(int page, int pageSize) {

   userRepository
       .findAllPaged(PageRequest.of((page - 1) * pageSize, pageSize)
       .forEach(System.out::println);
}
JPAstreamer
void pagenation(int page, int pageSize) {

    jpaStreamer.stream(User.class)
        .skip((page - 1) * pageSize)
        .limit(pageSize)
        .forEach(System.out::println);

}

ストリームAPIが提供する宣言型のスタイルは、可読性が向上した短いコードを提供します。
さらに重要なのは、JPAstreamerが型安全性を提供することで、エラーの早期発見、コード品質の向上、時間の節約が可能になることです。

JPAstreamerの概要

Project Lombok などの有名な Java ライブラリと同様に、JPAstreamer はアノテーション・プロセッサを使用してコンパイル時にメタモデルを形成します。
アノテーション・プロセッサは、標準的なJPAアノテーション@Entityでマークされたクラスを検査し、すべてのエンティティに対して、対応するクラスが生成されます。
生成されたクラスは、Predicate を形成するために使用できるフィールドとして、エンティティの属性を表します。

上記の User の3つの例では、クラスは例えば User$.firstName.startsWith("A") のような形になり、JPAstreamer のクエリオプティマイザーによって解釈されます。

JPAstreamerは、既存のJPAエンティティに基づいてフィールドを生成します。

@Entity
@Table(name = "user", schema = "db-name")
public class User implements Serializable {
  
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "user_id", nullable = false, columnDefinition = "smallint(5)")
   private Integer userId;

  @Column(name = "first_name", columnDefinition = "varchar(255)")
  private String firstName;

  @Column(name = "last_name", columnDefinition = "varchar(255)")  
  private String lastName;

  // … shortened for brevity

JPAstreamerは、既存のコードベースを変更したりすることなく、単にAPIを拡張して、Javaストリーム・クエリを処理するようにします。
さらに、メタモデルはデフォルトで、target フォルダ内の generated sources フォルダに置かれ、他のソース・コードと一緒にテストしたりする必要はありません。

試してみる

JPAstreamerをセットアップするには、Java 8(またはそれ以降)とHibernate、またはEclipseLink、OpenJPA、TopLinkなどのオブジェクト永続化を行うJPAプロバイダーを使用している必要があります。

JPAstreamerはLGPLでライセンスされており、Hibernateも同じライセンスを使用しているため、既存のHibernateプロジェクトで簡単に使用することができます。

インストールについて

JPAstreamerはMaven Central Repositoryで提供されており、既存のMavenやGradleのビルドに1つの依存関係を追加するだけでインストールが完了します。

クエリの作成

Springのユーザーは、例えば@Autowiredアノテーションを使ってDIを行うことができます。

@Autowired
Private final JPAStreamer jpaStreamer;

JPAstreamerのインスタンスは、.stream()というメソッドにアクセスすることができ、ストリームしたいエンティティを表すクラスを受け取ります。例えば、上に示したように、Userテーブルは、次のように入力するだけでストリームすることができます。

jpaStreamer.stream(User.class)

これは、Stream型の全ての user のストリームを返します。ストリームソースが手元にあれば、任意のJavaストリーム操作を自由に追加して、データが流れるパイプラインを形成することができます。以下がコードの例です。

List<String> users = jpaStreamer.stream(User.class)
      .filter(User$.age.greaterOrEqual(20))
      .map(u -> u.getFirstName() + " " + u.getLastName())
      .collect(Collectors.toList());

この例は、20歳以上のユーザーの名前をリストにします。
なお、$User は、JPAstreamer のメタモデルの一部である生成されたエンティティを指します。このエンティティは、.filter()や.sort()などの操作のための Predicate や Comparators を形成するために使用されます。

long count = jpaStreamer.stream(User.class)
      .filter(User$.country.equal("Japan").and(User$.firstName.equal("Watanabe")))
      .count();

これは別の例です。日本の渡辺という名前のユーザーをカウントする処理です。

上記のストリームではUserエンティティが1つもJVMに取り込まれることはありません。
代わりに、ストリーム・パイプライン全体がデータベースによって select count(*) where ...で実行され、その後、実際のカウントが直接Javaに返されます。

また、JPAstreamerがストリームを最適化するためには、ラムダではなく、必ずフィールドから派生した述語を使います。つまり、次のようにします。

filter($User.age.greaterOrEqual(20))

ではなく、

filter(u -> u.getAge() >= 20)

です。

Spring Bootで使ってみる

Spring Bootで試してみます。

Maven or Gradle で依存関係の設定を行います。

<dependencies>
    <dependency> 
        <groupId>com.speedment.jpastreamer</groupId>
        <artifactId>jpastreamer-core</artifactId>
        <version>${jpa-streamer-version}</version>
    </dependency>
</dependencies>

<plugins>
    <plugin> 
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>build-helper-maven-plugin</artifactId>
        <version>3.2.0</version>
        <executions>
            <execution>
                <phase>generate-sources</phase>
                <goals>
                    <goal>add-source</goal>
                </goals>
                <configuration>
                    <sources>
                        <source>${project.build.directory}/generated-sources/annotations</source>
                    </sources>
                </configuration>
            </execution>
        </executions>
    </plugin>
</plugins>
repositories {
	mavenCentral()
}

dependencies { 
    compile "com.speedment.jpastreamer:jpastreamer-core:version"
    annotationProcessor "com.speedment.jpastreamer:fieldgenerator-standard:version"
}

sourceSets { 
    main {
        java {
            srcDir 'src/main/java'
            srcDir 'target/generated-sources/annotations'
        }
    }
}

エンティティを追加します。今回は User エンティティを作成します。

@Entity
@Table(name = "user")
public class User {

    @Id
    @GeneratedValue
    @Column(name = "user_id", nullable = false, updatable = false, columnDefinition = "smallint(5)")
    private Integer userId;

    @Column(name = "first_name", nullable = false, columnDefinition = "varchar(45)")
    private String firstName;

    @Column(name = "last_name", nullable = false, columnDefinition = "varchar(45)")
    private String lastName;

    public Integer getUserId() {
        return userId;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }
}

ビュー用のモデルも追加します。

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class UserViewModel {

    private Integer userId;
    private String firstName;
    private String lastName;

    @JsonCreator
    public UserViewModel(
        @JsonProperty("userId") final Integer userId,
        @JsonProperty("firstName") final String firstName,
        @JsonProperty("lastName") final String lastName
    ) {
        this.userId = userId;
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public Integer getUserId() {
        return userId;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public static UserViewModel from(final User user) {
        requireNonNull(user);

        return new UserViewModel(
                user.getUserId(),
                user.getFirstName(),
                user.getLastName()
        );
    }
}

Controller も追加します。

@RestController
@RequestMapping("api/v1")
public class UserController {

    private final JPAStreamer jpaStreamer;

    @Autowired
    public UserController(JPAStreamer jpaStreamer) {
        this.jpaStreamer = jpaStreamer;
    }

    @GetMapping(value = "/users", produces = MediaType.APPLICATION_JSON_VALUE)
    public Stream<UserViewModel> getUser() {
        return jpaStreamer.stream(User.class)
                .map(UserViewModel::from);
    }
}


最初の行では、データオブジェクトの取得に使用できるJPAstreamerインスタンスを注入しています。次に、user テーブルのデータをリストアップする GetMappingがあります。ユーザーが /users に対して GET リクエストを実行すると、レスポンスはJPAstreamerのStream APIを使用して提供され、データベース・クエリを発行します。

.map(UserViewModel::from)という式は、ビューモデルを使用して、各Userエンティティを、不要なカラムを取り除いた凝縮されたフォーマットに単純にマッピングします。

パフォーマンスはどうなのか

JPAstreamerを使って、「N+1問題」を回避する方法を見てみます。
通常、この問題が発生すると、膨大な数のクエリが発生し、アプリケーションのパフォーマンスが大幅に低下します。

JPAstreamerでは、以下のように、stream configuration objectを提供することで、この問題を回避することができます。

StreamConfiguration<User> configuration = StreamConfiguration.of(User.class)
     .joining(User$.city);

  jpaStreamer.stream(configuration)

これにより、結果として得られるクエリは city と user を結合することになり、N+1セレクト問題を完全に回避することができます。元のエンティティには、任意の数の Column を結合することができます。

結論

HibernateJPAは、データベースアクセスに関しては強力ですが、HibernateJPAの使用は簡単に複雑になります。
JPAstreamerをHibernate (または任意のJPAプロバイダ)と統合して、型安全で表現力豊かなデータベースクエリを標準的なJavaストリームとして構成することにより、コードベースをきれいに維持しながらJPAを使い続けることができるようになります。

参考
JPAstreamer: Expressing Hibernate/JPA queries with Java streams