Commons Lang 3.5 でJava EEにBreakerを組み込む
この記事は Java EE Advent Calendar 2016の12/13分の記事です。
昨日はsuke_masaさんの必要最小限のサンプルでThymeleafを完全マスターでした。
明日は@kazuhira_rさんです。
Payara MicroやWildFly Swarmをベースとしたマイクロサービス対応の共通仕様の規定を目指しているmicroprofile.ioでは、Circuit Breakerの仕様盛り込みがこのスレッドで議論されています。
Circuit Breaker実装というとHystrixが有名ですが、上記スレッドでCommons Langに簡易なCircuit Breaker実装が追加されたことに言及があったため紹介します。
2016年10月にリリースされたCommons Lang 3.5より、LANG-1085により、シンプルなBreaker実装が加わっています。ドキュメントとしてはJavaDocがあります。
Commons LangのBreakerの特徴
- Half Open状態がない
- 状態はデフォルトのcloseと、異常が閾値を超えた場合のopen状態のみ
- HystrixのようなWebダッシュボードはない
- APIやコマンドで手動によるブレーカ切り替え機能はない
- 非常にシンプルでCircuitBreakerインタフェースと、2つの実装クラスThresoldCircuitBreakerとEventCountCircuitBreakerより構成
ThresholdCircuitBreakerクラスは単純に一定の回数、イベントが発生したらブレーカの開閉を行います。一般的なブレーカのイメージである、1分間で5回以上の例外が発生したらブレーカを開けてデフォルト応答を返し、1分間で例外が発生しなかったらブレーカを閉じて元に戻す実装はEventCountCircuitBreakerクラスです。
以下にJAX-WSクライアントに組み込んだ例を示します。
@ApplicationScoped public class SoapClient { private static final Logger LOG = LoggerFactory.getLogger(SoapClient.class); private static final String DEFAULT_RESPONSE = "duke hello world!"; private HelloEndPoint helloEndPoint; @PostConstruct public void init() { helloEndPoint = new HelloEndPointService().getHelloEndPointPort(); ((BindingProvider) helloEndPoint).getRequestContext().put("javax.xml.ws.client.connectionTimeout", "5000"); ((BindingProvider) helloEndPoint).getRequestContext().put("javax.xml.ws.client.receiveTimeout", "60000"); // 第1引数は閾値で一定期間内で内部カウンタ値がこの値を超えたらブレーカを開け、この値を下回ったら再び閉じる // 第2、第3引数は閾値の集計時間単位で、以下の例では1分 breaker = new EventCountCircuitBreaker(5, 1, TimeUnit.MINUTES); } public String helloWorld(String name) { if (breaker.checkState()) { try { return helloEndPoint.helloWorld(name); } catch (Exception e) { breaker.incrementAndCheckState(); } } else { // ブレーカが開いた場合のデフォルト処理 LOG.warn("breaker open"); return DEFAULT_RESPONSE; } } }
breaker.checkState()はブレーカが平常時を示すclose状態であればtrueを返し、異常時のopen状態であればfalseを返します。上記の例では、trueを返された場合はSOAPリクエストを投げています。falseが返された場合はブレーカが閾値に達しているため、SOAPリクエストを投げずにデフォルトの応答値を返しています。
例外発生時は、breaker.incrementAndCheckState()を実行し、カウンタ値をインクリメントします。上記の例ではSOAPFaultExceptionが投げられてもカウンタがインクリメントされるため、SOAPFaultが投げられる仕様のAPIでは、どの例外でincrementAndCheckState()を実行するか注意が必要です。
ブレーカはタイムアウトと組み合わせることで初めて効果を発揮します。上記の例においても、WildFly(JBoss)のSOAPクライアントのタイムアウト設定を盛り込んでいます。SOAPクライアントのタイムアウトは実装により異なります。例えば、Metroの場合はcom.sun.xml.ws.connect.timeoutとcom.sun.xml.ws.request.timeoutです。
タイムアウトのみ設定してブレーカがないと、タイムアウトを60秒などの長い時間を指定していた場合、あっという間にリクエスト処理スレッドのプールが枯渇して、SOAPリクエストと全く関係のない機能まで動かなくなる障害の連鎖が起こり得ます。障害が発生しているマシンにはアクセスせずに即座にデフォルト応答を返し、問題のないサービスを守ることがブレーカの目的です。
CDI Extensionでブレーカを組み込む
Commons Lang自体には@HystrixCommandのようなアノテーションによる宣言的なブレーカ設定の仕組みはありません。しかし、CDIインターセプタとCDI Extensionによるアノテーションスキャンの仕組みを用いると、Commons Langでも以下のように宣言的な@Breakerが実現できます。
@ApplicationScoped public class SoapClient { // 一部省略。完全なコードはGitHub参照 @Breaker(fallbackMethod = "fallback") public String helloWorld(String name) { return helloEndPoint.helloWorld(name); } String fallback(String name) { return DEFAULT_RESPONSE; } }
作成したサンプルコードの一式はGitHubのbreaker-sampleに置いています。
思ったよりクラス数が増えてしまったため、ポイントのみ示します。
ブレーカ実装はインターセプタで実現します。
@Interceptor @Priority(Interceptor.Priority.APPLICATION) @Breaker public class BreakerInterceptor { @AroundInvoke public Object intercept(InvocationContext ic) throws Exception { BreakerHolder holder = Breakers.get().breaker(ic.getMethod()); EventCountCircuitBreaker breaker = holder.breaker(); if (breaker.checkState()) { try { return ic.proceed(); } catch (Exception e) { breaker.incrementAndCheckState(); throw e; } } // exec fallbackMethod Method fallback = holder.fallback(); return fallback.invoke(ic.getTarget(), ic.getParameters()); } }
@Breakerはインターセプタバインディング型のユーザ定義アノテーションです。
@InterceptorBinding @Inherited @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.TYPE}) public @interface Breaker { @Nonbinding int openingThreshold() default 5; @Nonbinding long checkInterval() default 1; @Nonbinding TimeUnit checkUnit() default TimeUnit.MINUTES; @Nonbinding int closingThreshold() default 5; @Nonbinding String fallbackMethod() default ""; }
interceptメソッドの中でアノテーションが持つブレーカ設定のロードと、ブレーカの初期化を実装しても良いですが、初回リクエスト時のコストが高くなってしまいます。CDI Extensionを使うと、@Breakerが付与されたCDI Beanの一覧を取得して、デプロイ時にアノテーションスキャン処理が実装できます。
以下は@WithAnnotations({Breaker.class})を設定して、@Breakerが付与されたクラス・メソッドのクラス情報を引数にコールバックされるCDI Extensionです。EventCountCircuitBreakerの初期化やブレーカ開放時に実行するfallbackMethodが存在するかのチェック、リフレクションでfallbackMethodのMethodインスタンスを取得してキャッシュなどの処理をしています。クラスの全体はBreakerExtentionクラスより参照できます。
public class BreakerExtention implements Extension { .... <T> void processAnnotatedType(@Observes @WithAnnotations({Breaker.class}) ProcessAnnotatedType<T> pat) { Breakers breakers = Breakers.get(); if (!pat.getAnnotatedType().getJavaClass().equals(BreakerInterceptor.class)) { pat.getAnnotatedType().getMethods().stream() .filter(method -> method.isAnnotationPresent(Breaker.class)) .map(method -> method.getJavaMember()) .forEach(method -> { BreakerConfig config = createConfig(method); Method fallbackMethod = getFallbackMethod(method.getDeclaringClass(), config.getFallbackMethod()); String fqcn = fqcn(method); breakers.put(method, createBreakerHolder(config, fallbackMethod, fqcn)); LOG.info("breaker created for " + fqcn + ", now state is close. " + config); }); } } ...
CDI Extensionを有効にするためには、META-INF/services/javax.enterprise.inject.spi.Extension
を含め、Extension実装クラス名を定義します。
sample.breaker.BreakerExtention