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

  • 316
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

最近はエンタープライズのシステムでも、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はやめるようにしましょう。