OpenAPI コードから作るか コードを作るか

2020-12-07T23:38:20

OpenAPI コードから作るか コードを作るか

先に断っておくと、自分の知識はJava界隈に寄っています。他言語でどのようにOpenAPIをサポートするかは各自でお願いしますmmmm

この記事を再編集・抜粋してZennに投稿しました

OpenAPIとはなにか

OpenAPIは、REST APIの設計/仕様書を記述するために生み出された一連のオープンソースツールを指します。現在の仕様はバージョン3系であり、OAS3.0とも呼ばれます。

また、バージョン2系の頃の名称であったSwaggerの名で呼ばれることもあります(厳密にはバージョン2と3ではSwaggerという単語の意味するものが変わっています)

OpenAPIはOpenAPI Specificationと呼ばれる、REST APIの仕様を記述する取り決めに従って仕様が策定されており、ドキュメントの自動作成はもちろん、Mockupの生成やコードの生成などもサポートしています。

API仕様書の位置づけ

プロダクトがモノリスであっても、マイクロサービスであっても、外部公開しているAPIがあるのなら、API仕様は重要です。

特に、マイクロサービスでそれは顕著と思います。
マイクロサービスでは責務を分けた複数のAPIの連携によってある機能を実現します。優れたマイクロサービスは限りなく互いに疎ですが、それは同時にチーム単位で知見が分離するという側面もあります。
(チーム内外でローテーションしたりしてカバーしているところもありますよね)

モノリスでは経験ベース、コードベース、口頭ベースのコミュニケーションもできたでしょう。しかし、マイクロサービスでは明確にAPI仕様書をもとに、他のコンポーネントと連携を図ります。
すなわち、API仕様書とコードに差があってはなりません。仕様書にオミットした機能やステータスコードが残ったりしてはいけません。もちろん、コードだけ進化してそれが仕様書に載っていないという自体も避けなければなりません。

(ちなみにモノリスが悪いなんてことはないです。なあなあでなんとかなるから、悪例が多いだけ。
複雑さやI/Oコストを考えればモノリスが妥当なケースも多いでしょう。やりたいことドリブンでなくビジネスを中心に考えたほうがいいですね)

しかしながら、エンジニアというのは面倒くさがりな生き物です。主語が大きいですか?自分の観測範囲はほとんどそうです。
コードを変えて、仕様書にそれを反映して、というのをすることがいかに面倒くさいか、やったことがあるなら頷いていただけるのではないでしょうか。
やったことがなくても、Wordの仕様書とコードを一緒に管理しろと言われたらげんなりするのではないですか? (え、納品物だから当たり前?なるほど。

ドキュメントの自動生成とどう向き合うか

さて、本題に入りましょう。

仕様書とコードを二重管理する時代は終わっているのです。
Wordの話はもう出てきません、すいません。

我々に残された問題は、どちらを主に据えるかです。つまり、

  • OpenAPIを作って、そこからコードのテンプレートを自動生成し、コーディングする
  • コードを書いてしまって、そこからOpenAPIを作成する

このどちらで我々は戦うべきなのでしょうか。
それぞれ、深堀りしてみましょう。

OpenAPIからコードを作る

OpenAPIからコードを生成することは、すなわちドキュメントを中心にAPIを管理するということです。機能追加する際、機能変更する際、どんなときもOpenAPIを更新するところから始めなくてはなりません。

この方法を採用するメリットは、下記が挙げられるでしょう。

  • ウォーターフォール的なプロセスと相性が良いこと
  • CDC(ConsumerDriven Contract)と相性が良いこと

ウォーターフォール的なプロセスと相性が良い

まずドキュメントを作成し、そのドキュメントに関してレビュー、合意をとった上で開発を行うことは一般的な方法の一つです。
そのため、ドキュメントを主に据えて開発することはこの方法においては普通であり、受け入れやすいはずです。

合意形成したAPI仕様書に基づいてコードを自動生成することで、それはレビューされた各種機能がIFで用意されることが約束されるのです。

実のところ、この方法はGenerationGapというデザインパターンとして古から伝えられています。
Javaにおいては、いまや黒歴史扱いの継承を使った例をインタフェースを利用した委譲に置き換える必要があるでしょうが、基本的な思想は同一です。

すなわち、OpenAPIに対する追加は新規インタフェースの作成となり、変更・削除はインタフェースの変更と同義です。
実装はそのインタフェースに追従することを強制されます。

CDC(ConsumerDriven Contract)と相性が良い

CDCは日本語では「消費者主導契約」とか「消費者駆動契約」とか訳されたりしますが、「コンシューマ駆動契約」が使われることが多いように思います。

CDCとはなにか

少しだけCDCについて説明を入れておきます。

CDCは必然的にテストに関わるため、「コンシューマ駆動契約テスト」の名がよく知られています。
CDCの指すコンシューマ(消費者)とはすなわち、APIを使用するクライアントを指しています。

CDCが必要とされる背景にはマイクロサービスがあります。
マイクロサービスで構成されたシステムにおいて、それぞれのAPIは互いに疎です。冒頭に述べたとおり、チーム単位でもお互いを意識しつつも、機能的には疎であることが多くなります。

こうなってくると、リリースや結合試験のタイミングで混乱が生じます。API同士が互いに疎であっても、IFは密に守る必要があるからです。
そのため、あるAPIを管理しているチームの独断でIF変更なんてできませんし、機能変更に関しても確認が必要です。リリースもセンシティブな問題になります。

従来、この問題は結合試験で担保されてきました。あるAPIが変更したときは、そのAPIの周りすべてを実際に繋いだテスト環境で手動試験を行うのです。
もちろん、そのコストは重く、効率的とは言えません。

コンシューマ駆動契約テストは、それを(ある程度)解決します。

コンシューマ駆動契約テストは、APIの利用・提供側で状態・入力・出力の組み合わせをルール化し、利用側がそのルールを遵守することを強制します。

これにより、マイクロサービスにおいてはいつ壊れるかわからないサービス間の境界に関して、最低限守るべきIFを自動で守ることができるようになります。
(完全な結合試験ではないものの、結合試験相当の効果を自動テストで得られる)

余談ですがspring-cloud-contract-oa3というSpring Cloud Contract向けのYAMLパーサが存在します。
これは、OAS3.0の仕様に準じた拡張実装を利用して、CDCテストに用いるDSLをAPI仕様書に内包させるというもので、うまく組み合わせるとAPI仕様書とDSLを同時に管理することができます。

ただし、Issue等を見てもわかるとおり、まだ不完全なものであり、コミッターがいないためにしばらく進捗はないでしょう。本番PJでの採用は辞めたほうが無難だと思います。

本題

この方法がCDCと相性が良い、というのはやはりドキュメントから作成することに起因します。
なぜならば、Spring Cloud Conractなどで記述する「契約」はDSLにより書かれるわけで、これもまたドキュメントだからです。

コードを第一級ドキュメントとみなす場合でも、DSLが並んで存在していることも可能でしょう。が、その時点でコードとドキュメントが優先度を変えて並んでいることには違いありません。
一方で、コードではなくドキュメントを軸とするこの方法では優先度をスイッチする必要はありません。API仕様書と並んでDSLもまたドキュメントであり、コードよりも優先されます。

サンプルプロジェクト

さて、じゃあOpenAPIからコードを生成するほうがいいのか?と思う前に実際にコードを眺めましょう。まずはAPI提供側(Producer)から紹介します。
コード例はJavaになります。

OpenAPIからコードを生成したとき、最大の変化はそのコードそのものです。
具体的には生成されたコードとどう付き合うか、という考え方を変えざるを得ません。

自動生成されたクラス

自動生成されたクラスとの付き合い方

選択は2つあると個人的には考えています。

  • APIインタフェースを自動生成させる
    • コントローラのインタフェースと、OpenAPIで定義したモデルのクラスが生成される
  • 実装も自動生成させ、delegateパターンを使って機能実装を行う
    • インタフェースを実装したコントローラのクラスとOpenAPIで定義したモデルのクラスが生成される
    • コントローラは実処理を委譲するdelegateをDIしており、処理を差し替えることができる

この2つです。
1.がよければ、openapi-generatorのコンフィグにinterfaceOnly=trueを渡せばそうなります。

しかしながら、この方法では自分で実装しなければならず、個々のインタフェースが守られているだけにすぎません。
コントローラのインタフェースとモデルのクラスが作成され、それを実装して守るというのも少し冗長だと私は感じます。

そこで、推奨したいのは2.の方法です。この方法を取りたければ、delegatePattern=trueをオプションに渡します。

サンプルプロジェクトの一例を示します。(該当コードはこれです)

@Service
@RequiredArgsConstructor
@Slf4j
public class ReservationDelegateImpl implements ReservationApiDelegate {

  private final PizzaRepository pizzaRepository;

  @Override
  public ResponseEntity<Pizza> getReservationRid(Integer rid) {
    try {
      Pizza pizza = pizzaRepository.findPizza(rid);
      if(Objects.isNull(pizza)) {
        throw new PizzaNotFoundException("ピザがありませんでした");
      }
      return new ResponseEntity<>(pizza, HttpStatus.OK);
    } catch (Exception e) {
      throw new SystemException("ピザ取得時にサーバーエラー");
    }
  }

  @Override
  public ResponseEntity<Void> postReservation(Pizza pizza) {
    try {
      Integer rid = pizzaRepository.addPizza(pizza);
      log.info("ピザID:" + rid);
      return new ResponseEntity<>(HttpStatus.CREATED);
    } catch (Exception e) {
      throw new SystemException("ピザ作成時にサーバーエラー");
    }
  }
}

このクラス、ReservationDelegateImplはその名が示すとおり委譲された処理の実装です。ReservationApiDelegateインタフェースは、OpenAPI Generatorが生成するコントローラクラスがDIするようになっており、コントローラの実処理を委譲しているのです。

自動生成されたコントローラクラスは下記のような実装になっています。

@Controller
@RequestMapping("${openapi.reservations.base-path:}")
public class ReservationApiController implements ReservationApi {

    private final ReservationApiDelegate delegate;

    public ReservationApiController(@org.springframework.beans.factory.annotation.Autowired(required = false) ReservationApiDelegate delegate) {
        this.delegate = Optional.ofNullable(delegate).orElse(new ReservationApiDelegate() {});
    }

    @Override
    public ReservationApiDelegate getDelegate() {
        return delegate;
    }

}

各パスのRequestMappingを含め、コントローラの主機能は自動生成されたReservationApiインタフェースのdefaultで実現されているため、このクラスからIFを読み取ることはできません。着目すべきは、単純にDelegateがDIされるというところのみです。

では、件のReservationApiの実装を見てみましょう。

@javax.annotation.Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2020-11-21T18:43:00.365782+09:00[Asia/Tokyo]")

@Validated
@Api(value = "reservation", description = "the reservation API")
public interface ReservationApi {

    default ReservationApiDelegate getDelegate() {
        return new ReservationApiDelegate() {};
    }

    /**
     * GET /reservation/{rid} : 予約情報の取得
     * 予約の情報を返します
     *
     * @param rid 予約ID (required)
     * @return 成功時 (status code 200)
     *         or 予約が見つからなかった (status code 404)
     */
    @ApiOperation(value = "予約情報の取得", nickname = "getReservationRid", notes = "予約の情報を返します", response = Pizza.class, tags={ "reservation", })
    @ApiResponses(value = { 
        @ApiResponse(code = 200, message = "成功時", response = Pizza.class),
        @ApiResponse(code = 404, message = "予約が見つからなかった", response = PizzaError.class) })
    @RequestMapping(value = "/reservation/{rid}",
        produces = { "application/json", "application/xml" }, 
        method = RequestMethod.GET)
    default ResponseEntity<Pizza> getReservationRid(@ApiParam(value = "予約ID",required=true) @PathVariable("rid") Integer rid) {
        return getDelegate().getReservationRid(rid);
    }


    /**
     * POST /reservation
     * 予約登録
     *
     * @param pizza 注文するpizzaの情報 (optional)
     * @return OK (status code 201)
     *         or Internal Server Error (status code 500)
     */
    @ApiOperation(value = "", nickname = "postReservation", notes = "予約登録", tags={ "reservation", })
    @ApiResponses(value = { 
        @ApiResponse(code = 201, message = "OK"),
        @ApiResponse(code = 500, message = "Internal Server Error") })
    @RequestMapping(value = "/reservation",
        consumes = { "application/json" },
        method = RequestMethod.POST)
    default ResponseEntity<Void> postReservation(@ApiParam(value = "注文するpizzaの情報"  )  @Valid @RequestBody(required = false) Pizza pizza) {
        return getDelegate().postReservation(pizza);
    }

}

ReservationApiはコントローラのIF部分をdefaultにより実装し、受け取ったデータをどうするかという処理部分を委譲(Delegate)していることが再確認できるはずです。

このように、Delegateパターンを行うことでコントローラ、およびAPIのリクエスト、レスポンス部分のモデルは完全に自動生成されたクラスファイルに任せることができます。
これによってAPI仕様書に載せたIFは正常系に関しては強固に守ることができるでしょう。

できること

  • コントローラのI/F部分、リクエスト、レスポンスモデルのコードを自動生成できる
  • 上記で自動生成されたプロジェクトにはSpringFox関連のアノテーションが付与されており、OpenAPI Specificationを即時公開できる

できないこと

一方で、できないことも出てきます。

  • コントローラクラスについて、自動生成されたものを使うために、コントローラクラスに実装が必要なExceptionHandlerなどは実装できない。
  • 異常系レスポンスモデルを作っても、自動ではコントローラと紐付けられない。

delegateを使うかどうかは一長一短、好き嫌いがあるため、チームで相談して決めたほうが良いと思います。

コードからOpenAPIを作成する

一方で、コードそのものを第一級ドキュメントとして扱う考え方もあります。OpenAPI Specificationを軸にした「ドキュメントからコードを生成する」考え方は、いくつかの制約のもとにその恩恵を受けることができています。

  • APIのIFに関してガッチリ決めてから作る
  • 必ず自動生成したコードを利用してコーディングする
  • 変更が生じれば都度ドキュメントを更新し、そこからコードを変更する

この一連の作業が完全に守られてこそ、真価を発揮するのであって、ここがおざなり/なおざりになってしまえば瓦解するでしょう。
PRレビュー時にAPI I/Fにも変更が必要だったことに気づけなければ、CDCを使ったテストでIFを契約できていなければ、ProducerとConsumerの間には齟齬が生じてしまうのです。

この方法には次のようなメリットが考えられます。

  • PRレビュー時にドキュメントの変更に関しても、実装とセットでレビューすることができる
  • 具体的な要件が定まっていない、作ってうまく行かなかったら壊して作り直したい、といった速度が要求される状況でも正確にI/Fを提供できる

ドキュメントとコードを一律にレビューする

ドキュメントを中心とする方法でも、レポジトリにOpenAPI Specificationを突っ込んでおけば一緒にレビューできるじゃないか、と思うかもしれません。

しかし、実際にそれをやったところであれはyamlだし、UIに突っ込んで見やすいように可視化したとして、規模が大きいと間違い探しをするような状況になりがちです。

一方で、SpringFoxやspringdoc-openapiではアノテーションを利用してコントローラやサービス、モデルのコードに対してドキュメント化する情報を埋め込んでいく方法を取ります。
(こちらの方法実はエアプなのですが、springdoc-openapiが最近台頭し、いい感じであるという噂を聞きます)

この方法であれば、javadocをレビューするのと大差なく、メソッドと仕様書を突き合わせて内容に齟齬がないかを一覧しながらレビューできます。

スクラップアンドビルドに強い

自動生成したコードをうまく使うには色々と工夫が必要です。一方で、1から10まで自分で作ったコードにアノテーションをつけていくだけなら工夫は特に必要ありません。

自分たちの好きなように書いた結果、それがきちんとドキュメントとして自動生 成されるのですから、何も問題がないのです。
難しいことを考える必要はなく、SpringFoxなりspringdoc-openapiの利用方法さえ学べばすぐにでもこの方法を取ることができるでしょう。

初期導入コストも継続コストも低い利点があります。

が、当然ながらドキュメント用コードを真面目に書く必要があります。
型や名前はともかく、コンテンツタイプやらフィールド例などきちんと書かないと当然ドキュメントに反映されません。
一度ここがおざなりになると以下略…

まとめ

  • OpenAPIからコードを作る
    • 仕様をガッチリきめてからつくるウォータフォールモデルと相性が良い。(ドキュメントにかけるコストが無駄にならないほど強い)
    • API I/Fの詳細仕様を定義できるDSLを使って模擬結合試験を行えるCDCと相性が良い。(ドキュメントにかけるコストが無駄にならないほど強い)
    • 自動生成されたコードを上手く使うほどI/Fを堅牢にできる。
    • 自動生成されたコードとうまく付き合う必要がある。自動生成をうまく活かそうとするほど工夫が必要になり、制約も出てくる。
    • 途中でやめられないし、途中で導入するのもしんどい。
  • コードからOpenAPIを作る
    • コードのそばにドキュメントがあるので変更追従が比較的容易。
    • 簡単なI/Fを定義する分には問題ないが、複雑化するほどドキュメント用アノテーションやコンフィグも複雑化していく。
    • そのドキュメントが正しいかどうかはレビュアーに委ねられる。
    • 途中で導入をやめやすく、逆に導入するのも比較的容易。