363
353

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

システムエンジニアAdvent Calendar 2015

Day 9

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

Posted at

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

363
353
0

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
363
353

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?