1. 概要

リクエストごとのセッションは、永続セッションを結び付け、ライフサイクルをリクエストするためのトランザクションパターンです。 当然のことながら、Springには OpenSessionInViewInterceptor という名前のこのパターンの独自の実装が付属しており、レイジーアソシエーションでの作業を容易にし、開発者の生産性を向上させます。

このチュートリアルでは、最初にインターセプターが内部でどのように機能するかを学び、次にこの物議を醸すパターンがアプリケーションにとってどのように両刃の剣になるかを見ていきます。

2. ビューでのオープンセッションの紹介

ビューでのオープンセッション(OSIV)の役割をよりよく理解するために、着信要求があると仮定しましょう。

  1. Springは、リクエストの開始時に新しいHibernateセッションを開きます。 これらのセッションは、必ずしもデータベースに接続されているとは限りません。
  2. アプリケーションがSessionを必要とするたびに、は既存のセッションを再利用します。
  3. リクエストの最後に、同じインターセプターがそのSessionを閉じます。

一見すると、この機能を有効にするのが理にかなっているかもしれません。 結局のところ、フレームワークはセッションの作成と終了を処理するので、開発者はこれらの一見低レベルの詳細に関心を持ちません。 これにより、開発者の生産性が向上します。

ただし、OSIVによって本番環境で微妙なパフォーマンスの問題が発生する場合があります。 通常、これらのタイプの問題は診断が非常に困難です。

2.1. スプリングブーツ

デフォルトでは、OSIVはSpringBootアプリケーションでアクティブです。 それにもかかわらず、Spring Boot 2.0の時点では、明示的に構成していない場合、アプリケーションの起動時に有効になっているという事実を警告しています。

spring.jpa.open-in-view is enabled by default. Therefore, database 
queries may be performed during view rendering.Explicitly configure 
spring.jpa.open-in-view to disable this warning

とにかく、 spring.jpa.open-in-view 構成プロパティを使用して、OSIVを無効にすることができます。

spring.jpa.open-in-view=false

2.2. パターンまたはアンチパターン?

OSIVに対しては常にさまざまな反応がありました。 プロOSIVキャンプの主な議論は、特に怠惰な関連付けを扱う場合の開発者の生産性です。

一方、データベースのパフォーマンスの問題は、反OSIVキャンペーンの主要な議論です。 後で、両方の議論を詳細に評価します。

3. 怠惰な初期化ヒーロー

OSIVはSessionライフサイクルを各リクエストにバインドするため、 Hibernateは、明示的な @Transactionalサービスから戻った後でもレイジーアソシエーションを解決できます。

これをよりよく理解するために、ユーザーとそのセキュリティ権限をモデル化していると仮定しましょう。

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

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @ElementCollection
    private Set<String> permissions;

    // getters and setters
}

他の1対多および多対多の関係と同様に、permitsプロパティは遅延コレクションです。

次に、サービスレイヤーの実装で、@Transactionalを使用してトランザクション境界を明示的に区別しましょう。

@Service
public class SimpleUserService implements UserService {

    private final UserRepository userRepository;

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

    @Override
    @Transactional(readOnly = true)
    public Optional<User> findOne(String username) {
        return userRepository.findByUsername(username);
    }
}

3.1. 期待

コードがfindOneメソッドを呼び出すときに発生すると予想されることは次のとおりです。

  1. 最初に、Springプロキシは呼び出しをインターセプトして現在のトランザクションを取得するか、存在しない場合はトランザクションを作成します。
  2. 次に、メソッド呼び出しを実装に委任します。
  3. 最後に、プロキシはトランザクションをコミットし、その結果、基になるSessionを閉じます。 結局のところ、必要なのはサービスレイヤーのセッションだけです。

findOne メソッドの実装では、パーミッションコレクションを初期化しませんでした。 したがって、 メソッドが戻った後、権限を使用できないようにする必要があります。このプロパティを反復処理すると we LazyInitializationExceptionを取得する必要があります。

3.2. 現実の世界へようこそ

パーミッションプロパティを使用できるかどうかを確認するための簡単なRESTコントローラーを作成してみましょう。

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{username}")
    public ResponseEntity<?> findOne(@PathVariable String username) {
        return userService
                .findOne(username)
                .map(DetailedUserDto::fromEntity)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

ここでは、エンティティからDTOへの変換中に権限を繰り返します。 LazyInitializationException、で変換が失敗することが予想されるため、次のテストに合格しないでください。

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UserControllerIntegrationTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        User user = new User();
        user.setUsername("root");
        user.setPermissions(new HashSet<>(Arrays.asList("PERM_READ", "PERM_WRITE")));

        userRepository.save(user);
    }

    @Test
    void givenTheUserExists_WhenOsivIsEnabled_ThenLazyInitWorksEverywhere() throws Exception {
        mockMvc.perform(get("/users/root"))
          .andExpect(status().isOk())
          .andExpect(jsonPath("$.username").value("root"))
          .andExpect(jsonPath("$.permissions", containsInAnyOrder("PERM_READ", "PERM_WRITE")));
    }
}

ただし、このテストは例外をスローせず、合格します。

OSIVはリクエストの開始時にセッションを作成するため、トランザクションプロキシ は、新しいセッションを作成する代わりに、現在利用可能なセッションを使用します。[ X190X]

したがって、予想されることにもかかわらず、明示的な @Transactional の外部でも、実際にはパーミッションプロパティを使用できます。 さらに、これらの種類のレイジーアソシエーションは、現在のリクエストスコープのどこにでもフェッチできます。

3.3. 開発者の生産性について

OSIVが有効になっていない場合、トランザクションコンテキストで必要なすべてのレイジーアソシエーションを手動で初期化する必要があります。 最も基本的な(そして通常は間違っている)方法は、 Hibernate.initialize()メソッドを使用することです。

@Override
@Transactional(readOnly = true)
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    user.ifPresent(u -> Hibernate.initialize(u.getPermissions()));

    return user;
}

今では、開発者の生産性に対するOSIVの影響は明らかです。 ただし、それは必ずしも開発者の生産性に関するものではありません。

4. パフォーマンス悪役

データベースからユーザーをフェッチした後、別のリモートサービスを呼び出すように単純なユーザーサービスを拡張する必要があるとします。

@Override
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    if (user.isPresent()) {
        // remote call
    }

    return user;
}

ここでは、リモートサービスを待機している間、接続されたセッションを維持したくないので、@Transactionalアノテーションを削除します。

4.1. 混合IOの回避

@Transactionalアノテーションを削除しないとどうなるかを明確にしましょう。 新しいリモートサービスの応答が通常より少し遅いとします。

  1. 最初に、Springプロキシは現在の Session を取得するか、新しいセッションを作成します。 いずれにせよ、このセッションはまだ接続されていません。 つまり、プールからの接続を使用していません。
  2. クエリを実行してユーザーを見つけると、セッションが接続され、プールから接続を借用します。
  3. メソッド全体がトランザクション型の場合、メソッドは借用した接続を維持したまま、低速のリモートサービスの呼び出しに進みます。

この期間中に、findOneメソッドへの呼び出しがバーストすることを想像してください。しばらくすると、すべての接続がそのAPI呼び出しからの応答を待機する場合があります。 したがって、データベース接続がすぐになくなる可能性があります。

トランザクションコンテキストでデータベースIOを他のタイプのIOと混合することは悪臭であり、絶対に避ける必要があります。

とにかく、サービスから@Transactionalアノテーションを削除したので、安全であると期待しています

4.2. 接続プールを使い果たす

OSIVがアクティブな場合、 @Transactional を削除しても、現在のリクエストスコープには常にセッションがあります。 このセッションは最初は接続されていませんが、最初のデータベースIOの後、接続され、リクエストが終了するまで接続されたままになります。

したがって、私たちの無邪気で最近最適化されたサービスの実装は、OSIVの存在下での災害のレシピです。

@Override
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    if (user.isPresent()) {
        // remote call
    }

    return user;
}

OSIVが有効になっているときに何が起こるかを次に示します。

  1. リクエストの開始時に、対応するフィルターが新しいSessionを作成します。
  2. findByUsername メソッドを呼び出すと、そのセッションはプールから接続を借用します。
  3. セッションは、リクエストが終了するまで接続されたままになります。

サービスコードが接続プールを使い果たすことはないと予想していますが、OSIVが存在するだけで、アプリケーション全体が応答しなくなる可能性があります。

さらに悪いことに、問題の根本原因(リモートサービスが遅い)と症状(データベース接続プール)は無関係です。 このわずかな相関関係のため、このようなパフォーマンスの問題を実稼働環境で診断することは困難です。

4.3. 不要なクエリ

残念ながら、接続プールを使い果たすことだけがOSIV関連のパフォーマンスの問題ではありません。

セッションはリクエストのライフサイクル全体にわたって開いているため、一部のプロパティナビゲーションは、トランザクションコンテキストの外部でさらにいくつかの不要なクエリをトリガーする可能性があります。 n + 1選択問題で終わる可能性さえあります、そして最悪のニュースは私達が生産までこれに気付かないかもしれないということです。

怪我に侮辱を加えると、セッションは自動コミットモードでそれらの余分なクエリをすべて実行します。 自動コミットモードでは、各SQLステートメントはトランザクションとして扱われ、実行された直後に自動的にコミットされます。 これにより、データベースに大きなプレッシャーがかかります。

5. 賢く選択する

OSIVがパターンであるかアンチパターンであるかは関係ありません。 ここで最も重要なことは、私たちが生きている現実です。

単純なCRUDサービスを開発している場合、これらのパフォーマンスの問題が発生することはないため、OSIVを使用するのが理にかなっている場合があります。

一方で、 多くのリモートサービスを呼び出している場合、またはトランザクションコンテキストの外で多くのことが行われている場合は、OSIVを完全に無効にすることを強くお勧めします。 

疑わしい場合は、後で簡単に有効にできるため、OSIVなしで開始します。 一方、すでに有効になっているOSIVを無効にすると、多くのLazyInitializationExceptionsを処理する必要があるため面倒な場合があります。

つまり、OSIVを使用または無視する場合は、トレードオフに注意する必要があります。

6. 代替案

OSIVを無効にする場合は、レイジーアソシエーションを処理するときに潜在的なLazyInitializationExceptionsを何らかの方法で防ぐ必要があります。 怠惰な関連付けに対処するためのいくつかのアプローチの中で、ここではそのうちの2つを列挙します。

6.1. エンティティグラフ

Spring Data JPAでクエリメソッドを定義する場合、 @EntityGraph を使用してクエリメソッドに注釈を付け、エンティティの一部を熱心にフェッチできます。

public interface UserRepository extends JpaRepository<User, Long> {

    @EntityGraph(attributePaths = "permissions")
    Optional<User> findByUsername(String username);
}

ここでは、デフォルトではレイジーコレクションですが、パーミッション属性を熱心にロードするアドホックエンティティグラフを定義しています。

同じクエリから複数のプロジェクションを返す必要がある場合は、エンティティグラフの構成が異なる複数のクエリを定義する必要があります。

public interface UserRepository extends JpaRepository<User, Long> {
    @EntityGraph(attributePaths = "permissions")
    Optional<User> findDetailedByUsername(String username);

    Optional<User> findSummaryByUsername(String username);
}

6.2. Hibernate.initialize()を使用する際の警告

エンティティグラフを使用する代わりに、悪名高い Hibernate.initialize()を使用して、必要な場所でレイジーアソシエーションをフェッチできると主張する人もいるかもしれません。

@Override
@Transactional(readOnly = true)
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    user.ifPresent(u -> Hibernate.initialize(u.getPermissions()));
        
    return user;
}

彼らはそれについて賢いかもしれませんし、 getPermissions()メソッドを呼び出してフェッチプロセスをトリガーすることも提案しています。

Optional<User> user = userRepository.findByUsername(username);
user.ifPresent(u -> {
    Set<String> permissions = u.getPermissions();
    System.out.println("Permissions loaded: " + permissions.size());
});

レイジーアソシエーションをフェッチするために、元のクエリに加えて(少なくとも)1つの追加クエリが発生するため、両方のアプローチは推奨されません。 つまり、Hibernateはユーザーとそのパーミッションを取得するために次のクエリを生成します。

> select u.id, u.username from users u where u.username=?
> select p.user_id, p.permissions from user_permissions p where p.user_id=?

ほとんどのデータベースは2番目のクエリの実行にかなり優れていますが、その余分なネットワークラウンドトリップは避ける必要があります。

一方、エンティティグラフまたは Fetch Joins を使用する場合、Hibernateは1つのクエリで必要なすべてのデータをフェッチします。

> select u.id, u.username, p.user_id, p.permissions from users u 
  left outer join user_permissions p on u.id=p.user_id where u.username=?

7. 結論

この記事では、Springのかなり物議を醸す機能と他のいくつかのエンタープライズフレームワークに注意を向けました。OpenSessioninViewです。 まず、このパターンを概念的にも実装的にも理解しました。 次に、生産性とパフォーマンスの観点から分析しました。

いつものように、サンプルコードはGitHubから入手できます。