OpenAPI(Swagger)を用いたフロントエンドとバックエンドを疎結合にする開発

こんにちは。エンジニアの鈴木(@yamotuki)です。
今日はAPIドキュメントを書くことでフロントエンドとバックエンドの開発を疎結合にして平行して開発を進めている話を書こうと思います。

疎結合とは?

通常の開発フローだとバックエンドAPIを先に実装して、そのあとでフロントエンドの開発を進める必要があります。これはAPIからどのようなレスポンスが帰ってくるかわからないので、フロントエンドは先に実装することはできないと言う事情があります。では、APIを完全に実装しきってからではないとフロントエンドの開発がすすめられないのか、というとそうではないと考えています。

依存関係逆転の原則(DIP)の考え方を導入すると、フロントエンドが依存する対象を変えることができます。DIPを一言で言うと "詳細に依存するな、インターフェースに依存しろ" だと私は考えています。

依存性逆転の原則 - Wikipedia

今回のAPIとフロントエンドの結合部分においてはAPIの表向きのレスポンスフォーマット、すなわちインターフェースが重要です。レスポンスフォーマットとはレスポンスステータスやJSON形式などです。 フロントエンドもAPI実装のそのどちらもAPIのインターフェースだけに依存するようにすれば依存性逆転が実現できそうです。

APIインターフェースの定義の仕方はデファクトスタンダードとしてOpenAPIがあります。

OpenAPI (Swagger) とは

swagger.io

The OpenAPI Specification (OAS) defines a standard, language-agnostic interface to RESTful APIs which allows both humans and computers to discover and understand the capabilities of the service without access to source code, documentation, or through network traffic inspection.

要するに、APIのインターフェースを記述するための仕様です。 OpenAPIは version 3 からはOpenAPIと呼ばれており、version 2の時にはswaggerと呼ばれておりこちらのほうを聞いたことがある人も多いのではないかと思います。

最終的に書かれるインターフェース定義書はYAMLまたはJSONです。弊社ではSwagger-phpと言うライブラリを使ってアノテーションから自動生成させています(詳細は後述)。 例としてニュース一覧を返すAPI についての記述を以下に示します。レスポンスステータスは200で、内容は別途定義された schema の内容を返すことが定義されています。この記述は内部実装には依存しません。内部実装がこの仕様に依存するように実装していきます。

        "/api/media/news": {
            "get": {
                "responses": {
                    "200": {
                        "description": "success",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/news_list_responder"
                                }
                            }
                        }
                    }
                }
            }
        },

PHP/Laravel でどのように書くか

PHPにOpenAPIを組み込むのはSwagger-phpと言うライブラリがあります。

弊社はバックエンドのフレームワークとしてLaravelを使っています。 Laravelに対しては L5-swagger と言うライブラリを使うと、上記のSwagger-phpに加えて人間がより読みやすい表現をブラウザ経由で見れるswagger-uiを同時に導入できます。

GitHub - DarkaOnLine/L5-Swagger: Swagger integration to Laravel 5

This package is a wrapper of Swagger-php and swagger-ui adapted to work with Laravel 5.

Swagger-php の記法でかくと以下のようになります

    /**
     * @OA\Get(
     *   path="/api/media/news",
     *   @OA\Response(
     *       response="200",
     *       description="success",
     *       @OA\JsonContent(ref="#/components/schemas/news_list_responder")
     *   ),
     * )
     */
/**
 * @OA\Schema(
 *     schema="news_summary",
 *     required={"id", "title", "created_at"},
 *     @OA\Property(property="id", type="integer", example=1),
 *     @OA\Property(property="title", type="string", example="タイトル"),
 *     @OA\Property(property="created_at", type="string", example="2020-01-17T11:25:28+09:00"),
 * )
 */
/**
 * @OA\Schema(
 *   schema="news_list_responder",
     * @OA\Property(
     *     property="news",
     *     type="array",
     *     @OA\Items(
     *       ref="#/components/schemas/news_summary"
     *     ),
     * )
 * )
 */

これがSwagger-phpによって解釈されると先に記述したJSON形式での仕様書になり、さらにswagger-uiの機能で以下のように人間が読みやすい形になります。

swagger-ui のブラウザで見たときのイメージ

f:id:yamotuki:20200521162408p:plain
swagger-ui

これを参照してフロントエンドを実装することで、フロントエンドはAPIのインターフェースに依存することができました。 次はバックエンド実装が実際にAPIインターフェースと一致していることを確認する方法です。

API実装がAPIインターフェースに一致していることを自動テストする

gitlab.com

この openapi-validator を使うことで、APIレスポンスが実際に定義に一致しているかを自動テストできます。 このライブラリはStar数もそんなに多くなく、ネット上に情報も少ないので使い方の参考実装も置いておきます。

テスト例。それぞれのAPIの仕様のテストはこれだけです。

    public function testInvokeSpec()
    {
        $this->specTest('get', '/api/media/news', [], 200,
            NewsListGetAction::class);
    }

以下のコードで openapi-validator をラッピングしています。

    protected function specTest(string $method, string $path, array $params, int $statusCode, string $actionClass): void
    {
        $method = strtolower($method);
        switch ($method) {
            case 'get':
                $response = $this->getJson($path, $params);
                break;
            case 'post':
                $response = $this->postJson($path, $params);
                break;
            case 'delete':
                $response = $this->deleteJson($path, $params);
                break;
        }

        $openApiValidator = OpenApiValidator::getValidator();
        $specResult = $openApiValidator->validate($actionClass . '::__invoke', $statusCode,
            json_decode($response->getContent(), true));
        echo $specResult;
        $this->assertEquals($specResult->hasErrors(), false);
    }

Validator インスタンスの取得コードです。毎回定義書を更新するたびにapi-docsを手動で再生成するのが面倒なので l5-swagger:generate を最初に叩いています。

use Illuminate\Support\Facades\Artisan;
use Mmal\OpenapiValidator\Validator;

class OpenApiValidator
{
    protected static $openApiValidator;

    // ref: https://gitlab.com/mmalawski/openapi-validator
    public static function getValidator()
    {
        if (is_null(static::$openApiValidator)) {
            Artisan::call('l5-swagger:generate');
            static::$openApiValidator = new Validator(
                json_decode(file_get_contents('./storage/api-docs/api-docs.json'), true));
        }

        return static::$openApiValidator;
    }
}

終わりに

この手法の良い点、悪い点をまとめておきます。

良い点

  • フロントエンドとバックエンドを並行開発しやすい
  • お互いの開発者は実装よりも仕様に集中できるので議論しやすい
  • 先にAPI外部設計を議論して固めることにより実装がスムーズ
  • APIレスポンスは仕様に沿っているか自動テストされているので保守が楽

悪い点

  • PHPの場合はOpenAPIのAnnotationを手動で書かなければいけない
    • プロパティが多いAPIだと正直つらい
    • 型がもっとはっきりしている言語だと最初の実装から自動生成してくれるなどもあるようです
  • API POST リクエストのパラメータのテストは openapi-validatorがサポートしていない(?)
    • 実装とPOSTパラメタに関するテストコードだけを誤って修正してしまうとOpenAPI仕様書が古い状態になる可能性がある