Guice:Singleton、SessionScoped、RequestScopedのまぜこぜに気がつかない

2019年11月2日

SessionScoped、RequestScopedは便利な機能なのだが、うっかりこれをSingletonの中で使ってしまい。気が付かないことがある。これは厄介なバグの原因になりうる。サーブレットでもこれが発生しうる。

問題の例

Guice管理のサーブレットはすべてSingletonにする必要があるが、この中でうっかりRequestScopedのオブジェクトをインジェクトしてしまう。HttpSessionが代表例だ。

@Singleton
public class FooServlet extends HttpServlet {
  @Inject HttpSession httpSession;
}

しかし、見かけ上何も起こらないのである。本来なら、次のようなエラーが発生するはずだ。以下は、このSingletonをEagerに生成させたときに起こるエラーだ。この場合は確実にエラーが発生する。

つまり、オンデマンドで生成させるのではなく、システム起動時にすべてのシングルトンを強制的に生成すれば良いのだが、これをやるにはすべてのシングルトンを、モジュールに記述するなり、どこかに登録しておくなりしなければならない。これもまた面倒だ。

Unable to provision, see the following errors:

1) Error in custom provider, com.google.inject.OutOfScopeException: Cannot access scoped [javax.servlet.http.HttpSession]. Either we are not currently inside an HTTP Servlet request, or you may have forgotten to apply com.google.inject.servlet.GuiceFilter as a servlet filter for this request.
  at com.google.inject.servlet.InternalServletModule.provideHttpSession(InternalServletModule.java:123) (via modules: foo.BarServletModule -> com.google.inject.servlet.InternalServletModule)
  while locating javax.servlet.http.HttpSession
    for field at foo.BarGuiceFilter.session(BarGuiceFilter.java:29)
  while locating foo.BarGuiceFilter

1 error
com.google.inject.OutOfScopeException: Cannot access scoped [javax.servlet.http.HttpSession]. Either we are not currently inside an HTTP Servlet request, or you may have forgotten to apply com.google.inject.servlet.GuiceFilter as a servlet filter for this request.
    at com.google.inject.servlet.GuiceFilter.getContext(GuiceFilter.java:170) ~[guice-servlet-4.2.2.jar:?]

原因

この原因としては、FooServletオブジェクトが生成されるときには、既にRequestScopeに入っているからである。Guiceはオンデマンドで必要なオブジェクトを生成するため、FooServlet生成時にはHttpSessionが存在しているので、ごく普通にインジェクトできてしまうのである。つまり、こういうことだ。

  • ユーザからリクエストが届く
  • GuiceFilterが、HttpSessionなどのRequestScopedオブジェクトをセットアップする
  • SingletonのFooServletを呼び出そうとするが、まだ作成してなかった。
  • そこでFooServletを作成するが、この時既にHttpSessionオブジェクトがあるのでインジェクトができる。

したがって、FooServletに格納されるHttpSessionは常に「サーブレット生成時に受けたリクエスト」のものになってしまう。

しかし、システム内のすべてのSingletonについて、いちいちEagerに、つまりシステム初期化時に生成させるようなセットアップをするのは面倒だ。単純に@Singletonと記述しておくだけで用を足して欲しい。一体どうすればいいのか?

解決策

以下に議論がある。

上を参考にして、以下を書いてみた(Guice4以上の機能のようだ)。ただし、HttpSession等には@RequestScopeアノテーションがついてないので、別の方法でチェックしている。

※これらには特に「マーク」となるアノテーション等は無いようだ。詳細はカスタムスコープを参照されたい。

import java.lang.annotation.*;
import java.util.*;
import java.util.stream.*;

import com.google.inject.*;
import com.google.inject.servlet.*;
import com.google.inject.spi.*;

/**
 * Singletonオブジェクトに{@link SessionScoped}、{@link RequestScoped}を
 * 注入してしまう間違いをチェックする。
 * @see <a href="https://www.gwtcenter.com/noticing-mixing-singleton-sessionscoped-requestscoped">
 * Guice:Singleton、SessionScoped、RequestScopedのまぜこぜに気がつかない</a>
 */
public class SingletonChecker implements ProvisionListener {

  final List<CheckScope>checkScopes;
  final Set<Class<?>>checkClasses;

  Injector injector;

  public SingletonChecker() {
    checkScopes = Arrays.stream(new CheckScope[] {
        new CheckScope(ServletScopes.SESSION, SessionScoped.class),
        new CheckScope(ServletScopes.REQUEST, RequestScoped.class)        
    }).collect(Collectors.toList());

    checkClasses = Arrays.stream(new Class<?>[] {
      javax.servlet.http.HttpSession.class,
      javax.servlet.http.HttpServletRequest.class,
      javax.servlet.http.HttpServletResponse.class,
      javax.servlet.ServletRequest.class,
      javax.servlet.ServletResponse.class,
      //javax.servlet.ServletContext.class, これは間違い。不要
    }).collect(Collectors.toSet());
  }

  void setInjector(Injector injector) {
    this.injector = injector;
  }

  @Override
  public <T> void onProvision(ProvisionInvocation<T> provision) {

    if (injector == null) return;

    Binding<?> binding = provision.getBinding();
    Key<?> key = binding.getKey();

    if (!Scopes.isSingleton(binding)) return;
    if (!(binding instanceof HasDependencies)) return;

    String targetClass = key.getTypeLiteral().toString();

    ((HasDependencies)binding).getDependencies().stream()
      .forEach(dependency-> {
        checkScopes(targetClass, dependency);
      });    
  }

  private void checkScopes(String targetClass, Dependency<?> dependency) {

    Key<?> depKey = dependency.getKey();
    Binding<?> depBinding = injector.getExistingBinding(depKey);
    if (depBinding == null) return;

    Class<?>rawType = depKey.getTypeLiteral().getRawType();   

    if (checkClasses.contains(rawType)) {
      throw new ProvisionException(
        String.format("シングルトンクラス %s に間違ったスコープのクラス %s が注入されています。", 
          targetClass, 
          rawType.getName())          
      );    
    }

    checkScopes.stream()
      .filter(sc->Scopes.isScoped(depBinding, sc.scope, sc.annotation))
      .findAny()
      .ifPresent(sc-> {
        throw new ProvisionException(
          String.format("シングルトンクラス %s に間違ったスコープ @%s のクラス %s が注入されています", 
              targetClass, 
              sc.annotation.getSimpleName(),
              depKey.getTypeLiteral().toString())          
        );
      });
  }

  private static class CheckScope {
    final Scope scope;
    final Class<? extends Annotation> annotation;
    CheckScope(Scope scope, Class<? extends Annotation>annotation) {
      this.scope = scope;
      this.annotation = annotation;
    }
    public String toString() {
      return scope + "," + annotation;
    }
  }
}


以下のように使う。

SingletonChecker checker = new SingletonChecker();
Injector injector = Guice.createInjector(new AbstractModule() {
  public void configure() {
    bindListener(Matchers.any(), singletonChecker);
  }
});
checker.setInjector(injector);