最近はエンタープライズのシステムでも、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();
}
パターンA: ふつうに順々に呼び出す
特に何の工夫もしないとこうなるでしょう。
@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本ずつ実行するので、速度的なところは期待できません。
パターンB: 並列実行して、結果をまとめてレスポンスを返す
異なるサーバへの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つでもあるとそれに引きずられて、結局全体のレスポンスが遅くなってしまうことにあります。全体を待ち合わせる必要があるときは、適切にタイムアウトして最悪のケースでも一定の時間以内に、レスポンスが返るようにしましょう。
パターンC: 並列かつ順次レスポンスを返す (Server Sent Event)
全体の結果を待つ必要がないときは、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から順にレスポンスを取得できるようになります。
パターンCは実装は、サーバサイド・クライアントサイドともに簡単なのですが、ブラウザの対応度合いが課題にはなるでしょう。
まとめ
Exampleコードは、以下にあります。
https://github.com/kawasima/parallel-api-example
SSEは単純なプロトコルなので、スマフォアプリでも実装可能です。サポートするライブラリもあるようです。
「Server Sent Events(SSE)の使いどころと使い方」を見ると、Androidブラウザでは辛みがあるようですが、サーバサイドの実装はServlet仕様でも簡単なので、UserAgentでフォールバックして、パターンB,Cを使い分けることもできそうです。
何れにせよ、パターンAはやめるようにしましょう。