Java
WebAPI
RxJava
SSE

サーバサイドで複数Web APIを呼び出すときのデザインパターン

More than 3 years have passed since last update.

最近はエンタープライズのシステムでも、Web APIによるシステム間連携が増えてきました。そうしたときに、1リクエストで複数の連携先APIを実行し、結果をクライアントに返すということがままあります。

どう作りましょうか、という問題です。

前提として、サーバサイドでHTMLレンダリングせずに、Web APIの中継することとします。中継する意義は、流量やキャッシュをサーバサイドでコントロールできるところにあります。

クライアントから直接連携先のAPIにアクセスする設計にすると、リロードボタン連打などのDDoS攻撃うけたときに、自分たちでは対処できず、連携先に迷惑をかけてしまいますよね。特に「課金の関係などで直接APIをアクセスしなきゃいけないんだ」、とかでなければ、中継するように設計しておいた方がベターです。


Web APIの呼び出し

業務システムで使う場合は、ちゃんとリクエスト、レスポンスがBeanにマッピングされてた方がよいでしょう。JAX-RS Clientでもよいのですが、API実行メソッドをインタフェースさえ作っておけば、よしなに実行してくれるRetrofitを使うことにします。

<dependency>

<groupId>com.squareup.retrofit</groupId>
<artifactId>retrofit</artifactId>
<version>2.0.0-beta2</version>
</dependency>

API呼び出しServiceクラスは、こんな感じです。簡単ですね。

public interface HatenaHotentryService {

@GET("/hotentry?mode=rss")
Call<BookmarkEntries> list();
}


:snail: パターンA: ふつうに順々に呼び出す

image

特に何の工夫もしないとこうなるでしょう。

@WebServlet(urlPatterns = "/sequentialsync")

public class SequentialSyncServlet extends ApiBaseServlet {
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
try {
OkMessage okMessage = new SlowApiService().get().get();
BookmarkEntries bookmarkEntries = getBookmarkEntries();
WeatherForecasts weatherForecasts = getWeatherForecasts();

response.setContentType("application/json; charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(toJson(bookmarkEntries, weatherForecasts, okMessage));
} catch (Exception e) {
throw new IllegalStateException(e);
}

}

protected BookmarkEntries getBookmarkEntries() throws IOException {
Response<BookmarkEntries> response = hatenaHotentryService.list().execute();
if (response.isSuccess()) {
return response.body();
} else {
throw new IOException(response.message());
}
}

protected WeatherForecasts getWeatherForecasts() throws IOException {
Response<WeatherForecasts> response = livedoorWeatherService.list("130010").execute();
if (response.isSuccess()) {
return response.body();
} else {
throw new IOException(response.message());
}
}

当然ながら、Web APIを1本ずつ実行するので、速度的なところは期待できません。


:camel: パターンB: 並列実行して、結果をまとめてレスポンスを返す

image

異なるサーバへのAPIは、できるだけ並列に実行したいものです。RxJavaを使って待ち合わせると簡単です。

@WebServlet(urlPatterns = "/parallelsync")

public class ParallelSyncServlet extends ApiBaseServlet {
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setContentType("application/json; charset=UTF-8");
response.setCharacterEncoding("UTF-8");

List<Observable<? extends ResponseRoot>> responseObservables = new ArrayList<>();
responseObservables.add(hatenaHotentryService.listRx());
responseObservables.add(livedoorWeatherService.listRx("130010"));
responseObservables.add(new SlowApiService().getRx());

List<ResponseRoot> responseRoots = new ArrayList<>();

Observable.merge(responseObservables).subscribe(responseRoot -> {
responseRoots.add(responseRoot);
});
response.getWriter().write(toJson(responseRoots.toArray()));
}
}

Retrofit2は、RxJavaのObservableを直接返せますので、これをサブスクライブして結果を収集し、JSON化して実行します。

パターンBの問題点は、遅いAPIが1つでもあるとそれに引きずられて、結局全体のレスポンスが遅くなってしまうことにあります。全体を待ち合わせる必要があるときは、適切にタイムアウトして最悪のケースでも一定の時間以内に、レスポンスが返るようにしましょう。


:racehorse: パターンC: 並列かつ順次レスポンスを返す (Server Sent Event)

image

全体の結果を待つ必要がないときは、Server Sent Eventを使うとWeb APIごとに結果をブラウザ側に順次返すことができます。Javaの場合、Servlet 3.0以上で使えます。が、未だどのIEでも使えないようです

Servletのプロパティとして、asyncSupportedをtrueにし、非同期レスポンスが可能にします。request.startAsync()

AsyncContextを取得し、API呼び出しのコールバックで、ここにレスポンスを書き込みます。

@WebServlet(urlPatterns = "/sse", asyncSupported = true)

public class SSEServlet extends ApiBaseServlet {
private static final Logger LOG = LoggerFactory.getLogger(ApiBaseServlet.class);

@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException{
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");

final AsyncContext ac = request.startAsync();

// ……… 中略 ……

hatenaHotentryService.list().enqueue(new Callback<BookmarkEntries>() {
@Override
public void onResponse(Response<BookmarkEntries> response, Retrofit retrofit) {
if (response.isSuccess()) {
try {
Writer writer = ac.getResponse().getWriter();
writer.write("data: " + toJson(response.body()) + "\n\n");
writer.flush();
} catch (IOException e) {
LOG.error("Hatena Bookmark access error", e);
}
} else {
LOG.error("Hatena Bookmark access error", response.message());
}
}

@Override
public void onFailure(Throwable throwable) {
LOG.error("Hatena Bookmark access error", throwable);
}
});

// ……… 中略 ……
}
}

クライアントサイドから呼ぶときは、EventSouceオブジェクト経由で呼びます。こんなイメージ。

function start_sse() {

var source = new EventSource('/sse');

source.onmessage = function(event) {
var data = event.data;
$('#response').val($('#response').val() + event.data + "\n");
};
}

これで、結果が戻ってきたAPIから順にレスポンスを取得できるようになります。

image

パターンCは実装は、サーバサイド・クライアントサイドともに簡単なのですが、ブラウザの対応度合いが課題にはなるでしょう。


まとめ

Exampleコードは、以下にあります。

https://github.com/kawasima/parallel-api-example

SSEは単純なプロトコルなので、スマフォアプリでも実装可能です。サポートするライブラリもあるようです。

Server Sent Events(SSE)の使いどころと使い方」を見ると、Androidブラウザでは辛みがあるようですが、サーバサイドの実装はServlet仕様でも簡単なので、UserAgentでフォールバックして、パターンB,Cを使い分けることもできそうです。

何れにせよ、パターンAはやめるようにしましょう。