LoginSignup
97

More than 5 years have passed since last update.

Spring Boot と一般ライブラリの折り合いのつけかた

Last updated at Posted at 2018-05-24

この資料について

JJUG CCC 2018 Spring の表題セッションの発表資料を Qiita にて公開したものです (twitter: #ccc_g4)。

slide1.png

内容は上記のスライドとほぼ同じですので、見やすい方で見ていただければと思います。

Spring Boot とそれ以外のライブラリ

Spring Initializr で Spring Boot 一式が動くプロジェクトは簡単に作れる。

しかし、実際の web アプリケーションでは追加でさまざまなライブラリを使いたくなる (世の中や社内のコード資産の活用のため)。

とりあえず build.gradle, pom.xml に dependency を追加すれば動くが...

ライブラリを追加によって発生しがちな課題

DI や Configuration の課題:

  1. @Autowired 等の DI 機能を活用できていない
    • ライブラリ側に static やリフレクションがあるとなりがち
  2. @Configuration が膨らんでしまい初期構築やメンテが大変

プロジェクトの管理の課題:

  1. @Configurationbuild.gradle, pom.xml が複数プロジェクト間でコピペ
  2. ライブラリのバージョンを管理しきれておらず、バージョン競合や class の競合による変な挙動や脆弱性に悩まされる

足回りの整備

アプリケーションの本質的なコードに集中するために...

  1. DI, Spring らしい方法で各種ライブラリのオブジェクトを使う
  2. ライブラリの設定を簡潔に制御する
  3. プロジェクト間で共有できる足回りを共有する
  4. ライブラリの脆弱性対応・バージョンアップを安全に行う

こういったストレスの小さい環境を整備するための @Configurationbuild.gradle, pom.xml のベストプラクティスをご紹介。

1. 一般のライブラリを DI らしく使う

DI, Spring らしい方法で各種ライブラリのオブジェクトを使うことで、テストしやすさや保守性を向上する。

まずは DI する

static final SentryClient SENTRY_CLIENT = ...;

SENTRY_CLIENT.sendEvent(...);

@Autowired SentryClient sentryClient; // DI 先

sentryClient.sendEvent(...);
@Configuration
class SentryConfiguration {
   @Bean
   public SentryClient sentryClient(){
       return ...;  // DI で注入するオブジェクト
   }
}

Constructor injection とテスト

外部ライブラリのクラスは mock 化したくなることが多い。

Constructor injection にしておけば mock を素直に注入できる。

@Component
class テスト対象 {
    private SentryClient sentryClient;

    @Autowired  // フィールドでなくコンストラクタ引数に DI
    public テスト対象(SentryClient sentryClient){
        this.sentryClient = sentryClient;
    }
}
テスト対象 target = new テスト対象(mock(SentryClient.class));

Always use constructor based dependency injection in your beans - How not to hate Spring in 2016 - spring.io

都度 new しないよう心がける

都度 new が必要ではないのに都度 new しているケース:

void doSomething(){
  AmazonS3 s3client = new AmazonS3Client(...);
  s3client.putObject(...);
  // 公式のコード例をそのままコピペするとこうなる
}

こういったクラスが都度 new されがち:

  • ObjectMapper (Jackson の JSON <-> Object mapper)
  • AmazonS3Client

普通に DI すれば一元管理や mock 注入も可能な上、初期化のエラーをアプリケーション起動時に知ることができる。

ただし thread-safe であることは要確認 (SimpleDateFormat などは NG)。

都度 new が必要なものの DI

本当に都度 new する必要がある場合、Factory を DI するのがオススメ:

@Configuration
class SomethingConfig {
  @Bean
  SomethingFactory somethingFactory(){ ... }
}

@Component
class AComponent {
  @Autowired
  AComponent(SomethingFactory somethingFactory){ ... }

  void doSomething(){
    Something something = somethingFactory.create();
    something.doSomething(...);
  }
}

Prototype bean や BeanFactory の誤用

@Scope(SCOPE_PROTOTYPE) や BeanFactory は都度 new する意図に適さないので留意のこと。

@Bean @Scope(SCOPE_PROTOTYPE)
HogeApiRequest request(){ return new ... }

@Component
class HogeApiService {
  @Autowired
  HogeApiRequest request  // 常に同じインスタンス!
  // 呼び出し元の全 Bean を漏れなく Prototype にすればよいが...
  // 手動で getBean(HogeApiRequest) する手もあるが...
}

Factory を DI する方が、小さいオーバーヘッドかつ、安全・ライフサイクルが明確になる。

Tips: Prototype Bean は Autowire 先に依存する Bean を生成する用途では有用。例えば [Autowire 先のクラス名を元に Logger を生成する](http://saiya-moebius.hatenablog.com/entry/2017/11/08/033932)など。

リクエストに依存する注入

コンストラクタやフィールドへの注入(Autowired)だけが DI ではない。

  • HandlerMethodArgumentResolver を作れば controller の引数にオブジェクトを注入できる
    • 例えば、User Agent 情報を解析した結果のオブジェクトを controller 引数に入れることができる
  • HandlerInterceptorAdapter を作れば ModelAndView にオブジェクトを注入できる

リクエストやセッション内容に依存するオブジェクトは、上記の手法で注入することで DI の恩恵を受けつつもリクエストに閉じた注入が可能になる。

static やグローバル変数への対処

DI を前提としないライブラリによくある仕様:

  • static メソッドとしての機能提供
  • System#getProperty, ServletContext#getAttribute といったグローバル状態への依存
  • ThreadLocal の状態の依存

これらも DI に寄せたい:
- テストなどでの注入のしやすさ
- オブジェクトの初期化・設定処理を一元化し保守性向上

static method と DI の橋渡し

static method をラップするオブジェクトを Bean にする:

@Component
class AccountService {  // これを DI で取得して使う

    public Account getCurrentAccount(){
        // 以下の static method はここ以外からは直接呼ばない
        return AccountUtil.getCurrentAccount();  
    }
}

これだけで、
- テスト時に mock/spy に差し替えるのが容易になる
- ログを出す・エラーハンドリングを変える、といった改変も 1 箇所で行える

グローバル状態の初期化

Bean 初期化直前に System#setProperty 等してしまうのが手。

@Configuration
class SentryConfiguration {
   @Bean
   public SentryClient sentryClient(
       // application.properties/yml の値を取得
       @Value("sentry.dsn") String dsn
   ){
       System.setProperty("sentry.dsn", dsn);
       return ...;
   }
}

DI らしく: グローバル状態と Bean の順序

Configuration の実行順は不定なので、順序を明示する必要あり。

@Configuration
class SentryConfiguration {
   @Bean
   public SentryClient sentryClient(){
       System.setProperty("sentry.dsn", ...)
       return ...;
   }
}

@Configuration 
@AutoConfigureAfter(SentryConfiguration.class) // ← ここ
class MyApiClientConfig {
   public ApiClient apiClient(){
      // 以下が内部で "sentry.dsn" に依存しているとする
      return new ApiClient();  
   }
}

ライブラリ側から new されるケース

ライブラリ側が以下のようなデザインになっているケースもある:

  • ライブラリの初期化時に callback のクラス名を文字列で渡す
  • ライブラリ側からそのクラスが new され呼び出される

ServletFilter (設定パラメタに文字列しか受け取れない)やプラグイン機構を提供するライブラリでありがち。

ライブラリ側から new されてしまったオブジェクトは DI 管理外になってしまい、その中で @Autowired などの DI 機能を利用できない。

DI 管理下の Bean へ処理を移譲

DI 管理外のオブジェクトから DI 管理下の Bean へ処理を移譲する。

  1. 処理の実体を Bean として実装
  2. Bean への参照を ThreadLocalServletContext に保持
  3. ライブラリ側から new されてしまったオブジェクトは上記から Bean の参照を取得
  4. 処理を Bean に移譲
  5. 移譲先の Bean では DI のフル機能が使えるので、DI 管理下の Component を自由に使える

( Spring MVC の DelegatingFilterProxy が実例 )

Bean への処理の移譲 (概念コード)

@Configuration
class LoginClientConfig {
  public LoginClient client(ImplBean impl){
    return new LoginClient("com.example.LoginCallback")
  }  
}

class LoginCallback {  // DI 管理外から new される
  public void login(String user){
      LoginCallbackImpl.currentInstance.get().login(user);
  }
}

@Component
class LoginCallbackImpl {
  ThreadLocal currentInstance;

  public ImplBean(...){ currentInstance.set(this); }
  public void login(String user){ ... }
}

Bean への処理の移譲 (補足)

概念コードからは省略したが気をつけたほうが良い点:

  • 初期化の順序
    • 移譲先 Bean を移譲元より先に作って ThreadLocal に入れないとならない
    • 先の例で LoginClient を生成するメソッドの引数に、使わないのに ImplBean を Autowired している背景
  • ThreadLocal からのリーク
    • 昔ながらの Servlet 環境では ThreadLocal に入れたままにするとメモリリークで困ることがある
    • 初期化完了時に ThreadLocal をクリアしておくと良い

Bean の Lifecycle の活用

AutoCloseable, CloseableDisposableBean な Bean はアプリケーション終了時に自動で close() してもらえる。

しかし、それ以外のメソッド(disconnect() とか shutdown() とか...)も、destroyMethod として指定すればアプリケーション終了時に自動で呼び出してもらえ、クリーンなシャットダウンが可能になる。

@Bean(destroyMethod = "shutdown")
public LegacyClient myClient(){ ... }

終了時処理のためだけに AutoCloseable な型でラップしたりする必要はない。

まとめ

  • Constructor injection でテストフレンドリーに
  • 都度 new する必要がある場合は Factory を Bean にすると良い
    • Thread-safe ならばそもそも都度 new しなくても良い
  • リクエスト毎に定まるオブジェクトならば Controller 引数や Model へ注入すると良い
  • 一見すると DI と相性が悪いケースでも、一手間加えれば DI に出来ることが多い
    • 多くの場合はここまでに述べた手法が使えるはず
    • 諦めない心をもって臨もう

2. @Configuration の記述効率化

DI に寄せる結果として @Configuration が膨らでいく。

そのためライブラリの設定を簡潔に制御する。

@Value で設定を受け取る例

@Bean
HogeApiClient hogeApiClient(
   @Value("hoge.url") String url,
   @Value("hoge.apiKey") String apiKey,
   @Value("hoge.timeout") long timeout,
   // ...
){
   HogeApiClient client = new HogeApiClient();
   client.setUrl(url);
   client.setApiKey(apiKey);
   client.setTimeout(timeout);
   // ...
   return client;
}

問題なく動くが、設定項目数に比例して冗長になる。

@ConfigurationProperties

Spring Boot の @ConfigurationProperties がオススメ:

@Bean
@ConfigurationProperties(prefix = "hoge")
HogeApiClient hogeApiClient(){ 
  return new HogeApiClient();
}
// hoge. 以下の全プロパティを HogeApiClient の setter へ代入

設定対象のプロパティが増減しても、application.properties/yml を更新するだけで OK

@ConfigurationProperties のカスタマイズ

getter, setter を override すればカスタマイズも可:

@Validated
private class MyHogeApiClient extends HogeApiClient {

  @Override
  @NotEmpty @URL  // JSR-303 の validator を使える
  public String getUrl(){ return super.getUrl(); }

  @Override
  public void setUrl(String url){ /*任意の処理*/ }
}

@Bean
@ConfigurationProperties(prefix = "hoge")
HogeApiClient hogeApiClient(){ 
  return new MyHogeApiClient();
}

Map をラップ

設定を Map で受け取ったり set(String, String) といったメソッドで受け取るライブラリもある。

@ConfigurationProperties 化すれば以下のメリットを得られる:

  • 型安全かつバリデーションも可能
  • 使っている設定・使っていない設定が明確になる
@Component // DI でこのインスタンスを取得可能
@Validated @ConfigurationProperties(prefix = "hoge")
class HogeApiClientConfig {
  /** この map をライブラリ側に渡す */
  protected Map<String, String> map = new HashMap<>();

  @NotEmpty @URL
  public String getUrl(){ return map.get(...); }
  public void setUrl(String url){ map.put(...); }
}

Bean のコレクションでありがちなパターン

Bean に複数の Bean を与える必要がある場合にありがちなコード:

@Bean
Something something(
  @Qualifier("subA") SubComponent subA,
  @Qualifier("subB") SubComponent subB,
  @Qualifier("subC") SubComponent subC
){
  Something something = new Something();
  something.add(subA);
  something.add(subB);
  something.add(subC);
  // ...
  return something;
}

@Bean SubComponent subA(){ ... }
@Bean SubComponent subB(){ ... }
@Bean SubComponent subC(){ ... }

Bean を列挙する

Spring に Bean を列挙させる方が記述効率が良い:

@Bean
Something something(List<SubComponent> components){
  Something something = new Something();
  components.forEach(something::add);
  return something;
}

@Bean @Order(10) SubComponent subA(){ ... }
@Bean @Order(20) SubComponent subB(){ ... }
@Bean @Order(30) SubComponent subC(){ ... }
  • @Qualifier("Bean名") の名前を間違えるリスクもない
  • ハードコードでないため、別の Config から @Bean を追加することも容易

FilterConfigurerAdapter などと同じ原理である。

動的に Bean を生成

設定ファイルや DB 等の情報を元に Bean を動的に作ることも可能。

動的に作られる Bean の中で Autowire や AOP などを活用したり、それらの Bean を List などとして取得して呼び出すことができる。

動的 Bean 生成に使うものは大抵の場合これだけ:

  • ImportBeanDefinitionRegistrar
  • BeanDefinitionRegistry#registerBeanDefinition
  • BeanDefinitionBuilder.genericBeanDefinition
    • setAutowireMode
    • addConstructorArgValue

動的な Bean の生成 擬似コード

この程度のコード量で Bean の定義を動的生成できる:

/** このクラスを Configuration から @Import する */
class MyBeanRegistrar implements ImportBeanDefinitionRegistrar {
  void registerBeanDefinitions(..., BeanDefinitionRegistry registry){
    registry.registerBeanDefinition(
      "bean名",
      BeanDefinitionBuilder
        .genericBeanDefinition(Beanの実装型)
        // コンストラクタへの Autowire を有効にする
        .setAutowireMode(AbstractBeanDefinition.AUTOWIRE_CONSTRUCTOR)
        // コンストラクタ引数を明示的に与える場合は以下
        .addConstructorArgValue(...)
    )
  }
}

まとめ

  • @ConfigurationProperties で DRY に書ける
    • Validation も可能
    • Map も型のあるオブジェクトでラップしよう
  • Bean の列挙は List<Beanの型>@Order がシンプル
    • Bean を追加しやすくなるため、拡張性も良くなる
  • 動的な Bean 生成も意外と簡単
    • 外部ファイル等データに応じて Bean を制御する作り込み可能

3. プロジェクト間の @Configuration の共有 (脱コピペ)

単一の web アプリケーションだけで完結せず、複数のアプリケーションを Spring Boot で作ることも少なくない (特に microservice 志向の場合)。

プロジェクト毎に個別に @Configuration を改善・メンテナンスするのはもったいない。

プロジェクトごとの差に対応しつつも、できるだけ共通化する。

Configuration の共有

基本的には以下のアプローチになる:
- よく使う Configuration を入れた artifact を作っておく
- 上記の artifact に各アプリケーションから dependency を張る
- アプリケーション側で Configuration を @Import する

とはいえ、工夫しないと @Import がわかりにくくなりがち:

@SpringBootApplication 
@Import(
  FooConfig.class,
  BarConfig.class,
  BarSubConfig.class,
  BazConfig.class,
  // ...
  // どの Config がどの Config に依存しているのか?
  // このアプリ固有の Config とアプリ共通の Config はどれか?
)
class MyApplication

@Import の連鎖

@Import(Configrationクラス.class) を使えば、Configuration から他の Configuration を連鎖的に読み込むことができる。

@Configuration
@Import(FooSubConfig.class)  // 連鎖的に読み込む
class FooConfig { ... }

@Configuration
class FooSubConfig { ... }

Import 元を読み込めばそれが依存する Config (Import 先)も読み込まれるので、アプリケーションは大元の Config のみを Import すれば良い。

アプリケーション側が Config の依存関係を知る必要がなくなる。

Meta-Annotation と Import

Meta-Annotation でアノテーションをまとめてることも可能:

/** いつものアノテーション一式をまとめたアノテーション
 * @SpringBootApplication の代わりにこれを使えばよい。*/
@SpringBootApplication
@ComponentScan(nameGenerator = ...)
@Import(
        MyBatisConfig.class,
        EmbeddedPostgresConfig.class,
        SentryConfig.class,
        ThymeleafConfig.class,
        TomcatConfig.class,
        // ...
)
annotation class M3SpringBootApplication

アプリケーション全体に影響するアノテーションと全 Config の Import をセットにしておくと便利

Config の切替

@Import の連鎖や Meta-Annotation によって Config をまとめて読み込めるが、常に全ての Config を有効にしたいとは限らない:

  • 依存ライブラリがないと動かない Config は有効にしたくない
    • 例えば API 用のアプリでは Thymeleaf を依存に入れたくないので、Thymeleaf に依存する Config を走らせるとコケる
  • 不要な Config は無効化してアプリケーションの起動を早くしたい
    • どうしても起動が遅くなりがちなので...

Config を使う側は楽に Import しつつも、読み込まれた Config をうまくオプトアウトできるようにするとより便利。

Class 有無による切替

@ConditionalOnClass を使えば、class がロードされているかで切替られる:

@Configuration
@ConditionalOnClass(
        TemplateMode.class, // thymeleaf
        ISpringTemplateEngine.class // thymeleaf-spring
)
@Import(LayoutConfig.class)
class ThymeleafConfig

条件にマッチしないときは @Import も行われなくなるので、一塊の Config をまとめて切り替えることができる (上の例の ThymeleafConfig 自体 + LayoutConfig)。

プロパティによる切替

@ConditionalOnProperty を使えば property によって切替を実現できる:

@Configuration
@ConditionalOnProperty(name = "m3.staff-openid.enabled")
class M3StaffOpenIdConfig { ... }

Config を使うかどうかを property で opt-in / opt-out 可能にできる。

それによって Config の利用側は Config のクラスを意識せずに property の書き換えだけで挙動を制御できる。

まとめ

  • @Import は多段階に連鎖可能
    • 依存関係にある Config は自動で Import するようにしよう
  • アノテーションの組み合わせは Meta-Annotation でまとめられる
  • @ConditionalOn... で Config を切替可能
    • 必要な Config を一括で Import しつつ、プロジェクトごとに Config を取捨選択可能にしよう
    • Config 利用者は Config クラスの実体を意識する必要がない

4. ライブラリの依存管理

依存ライブラリの管理はトラブルや大きな苦労の原因となりがち。

ライブラリ追加, バージョンアップ, 脆弱性対応 を安心して行うための仕込みを紹介。

dependency のバージョン競合 (maven)

例えば以下の場合に、ライブラリ X の 1.0 が使われてしまう:

  • ライブラリ A が X の 1.0 に依存
  • ライブラリ B が X の 2.0 に依存

B の実行時に NoSuchMethodError, ClassNotFoundException などになる。

mvn dependency:tree -Dverboseomitted for conflict with が出ているかを CI などで自動チェックするのがオススメ。

conflict の対処:

  • A の <dependency><exclusion> を指定することで B の依存バージョンに寄せる
  • X に対して <version> 指定で <dependency> を明示する

dependency のバージョン競合 (Gradle)

Gradle は競合時にデフォルトで新しい方のライブラリを使ってくれる。

とはいえ、機能が廃止されている・リネームされているといった場合には実行時のエラーになるので、Gradle でも競合は意識的に解消したほうが良い。

resolutionStrategyfailOnVersionConflict() しておくのがオススメ。

conflict の対処:

  • exclude することで意識的に片方のバージョンに寄せる
  • resolutionStrategy で特定バージョンを force する

class, resource のダブり検知

異なる artifact 間に同じ class, resource が含まれてしまうことがある:

  • commons-beanutils と commons-collections
  • XML 処理ライブラリの Xerces の さまざまな artifact
  • fat jar の類とその依存先ライブラリ本体
  • ロガーのアダプタ系全般 (例: commons-loggingjcl-over-slf4j)
  • ライブラリの artifact 名のリネーム前とリネーム後

実行時にどちらがロードされるかに依存して挙動が変わってしまうため厄介。

duplicate-finder-maven-plugin, gradle-duplicate-finder-plugin によってビルド時に class の重複を検知するのがオススメ。

依存ライブラリの多くが module に対応してくれれば起動時に検知できるが...

ライブラリの脆弱性チェック

dependency-check-{maven, gradle} プラグインが大変オススメ。

最新の脆弱性データベースの情報と dependency をビルド時に突き合わせられるので、以下の悩みから解放される:

  • 定期的な点検をやろうと思っても忘れがち
  • 日々増える脆弱性情報のキャッチアップ手段がない・大変
  • 脆弱性の情報が来ても、自身のプロジェクトで使っているライブラリかどうかの判定が大変
    • 特に間接依存先のライブラリがモレがち

failBuildOnCVSS=7 などすれば CVSS スコアが指定値以上の時にビルドエラーになるのでより検知しやすい。

無視したい脆弱性は suppressionFile に指定することで無視可能。

バージョンの一括管理

exclusion やバージョン指定なども含む、適切な version 指定をメンテナンスするのはそれなりに大変。

以下の手法を使って依存ライブラリのバージョン管理を一元化することができる:

BOM に定義している dependencyManagement しているもの全てに依存を貼ったプロジェクトを作っておき、CI で dpendency-check や duplicate-finder を実行することで各種検知もできる。

まとめ

  • mvn dependency:treefailOnVersionConflict() でバージョン競合を確認
  • duplicate-finder で class が衝突していないか自動チェック
  • dependency-check でライブラリの脆弱性情報を自動チェック
  • BOM や parent-pom で version 指定を一元管理

総まとめ

spring-boot を使いつつ Java のライブラリ資産を活用する上での課題とその解決方法を紹介しました:

  1. DI, Spring の仕組みに乗れる Configuration の書き方
    • 素直に DI できないオブジェクトの扱い方
  2. 簡潔かつ拡張性のある設定の記述方法
    • @ConfigurationProperties, List<Bean型> で DRY に記述
    • Bean の動的生成
  3. プロジェクト間での Configuration 共有のためのテクニック
    • @Import@ConditionalOn... で選択的に共有
    • Meta-Annotation で annotation をまとめて管理
  4. 依存ライブラリの管理の効率化
    • バージョンや class の衝突といった問題の検知・解決

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
97