kinoppyd.dev

blog

products

accounts & contact

APIがカオスってたプロダクトでOpenAPI対応やってみた

posted at 2019-12-01 03:10:09 +0900 by kinoppyd

このエントリは、 SmartHR Advent Calendar 2019 1日目の記事です

こんにちは、SmartHRでエンジニアやっているppydです。いま会社では、SmartHRに蓄積されたデータを可視化して分析する簡易BIツールを開発するチームに居て、フロントエンドとバックエンドとインフラのエンジニアをやっています。それくらい人手が足りてません、みんなSmartHRにきてください。助けて。

カオスったAPI

いまのプロダクトには途中参加なので、先人たちの名誉のために言っておきますが、そもそものAPIの設計がカオスっていたというわけではありません。グラフを描写するフロントライブラリのAPIの都合や、検索フィルタの条件が複雑すぎるために、エンティティの一部がカオスっていたというのが正しい表現です。とはいえ、それのおかげでAPIのレスポンスの全容がつかみにくく、またきちんとした型定義が無かったために、せっかくフロントはTypeScriptを使っているにも関わらず型の恩恵を受けられない箇所があったことは残念でした。他にも、現在のバックエンドはRailsを使っているのですが、ActiveRecordを使った実直な実装が果たしてこの先BIツールの要求に対して耐えられるのであろうかということも検討する段階にあり、もしバックのアーキテクチャを変えたときにフロントと不整合を起こさないためにも、APIの厳密な仕様を確定させることが必要だと感じていました。

そのため、フロント側のAPIクライアントを型付きで自動生成したい、そしてバックエンドの変更でフロントを破壊したくない、という2つのモチベーションで、プロダクトにOpenAPIを入れることにしました。

OpenAPI

すでに有名なので軽く触れるくらいにしておきますが、OpenAPIはRESTのWebAPIにおける仕様の記述方法です。もともとSwaggerという名でしたが、後にOpenAPIという名前に変更になり、現在はバージョン3が公開されています。RESTのAPIのかなり厳密な仕様を、YAMLもしくはJSONで記述します。

RubyとOpenAPI

Ruby製のプロダクトで(別にRubyに限った話ではなく、ほとんどの言語でこうだと思いますが)OpenAPIに対するアプローチは2つの方法があります。

1つは、何かしらの方法でOpenAPIの仕様に則った定義ファイルを書き、その定義をcommitteeなどのGemで常にチェックする方法です。これはOpenAPIの定義そのものに則りAPIサーバーを作成する、いわばTDD的な手法です。この方法の優れた点は、正しいテストがあればOpenAPIの定義を守ることを実装に強制することができることです。もちろん、APIを作成してからOpenAPIの定義を書くことも多々あると思いますし、これからお話する内容もそうなので、完全にTDD的とは言えません。が、方法としてはまず定義があり、実装を合わせていく、ということに変わりはありません。

もう1つは、swagger-grapeなどを使い、実際のAPIの実装仕様そのものからOpenAPIの定義を作り出す方法です。この方法の優れた点は、OpenAPIの定義が絶対にAPIサーバーの実装と乖離しないことです。そのため、APIサーバーから自動で生成されるOpenAPIの定義から、更に自動で生成される各言語用のAPIクライアントは、常に間違いなく最新のAPIサーバーの仕様を満たしていると保証されている点です。

この2つの方法には、それぞれ利点と欠点があり、そしてほぼお互いに真逆の特性を持っていると言えます。前者のメリットは、APIの定義は決してズレないということであり、後者のデメリットはAPIサーバーの実装によって定義はすぐに変わるということです。そして後者のメリットは、極力少ないコードで型情報付きのAPIのクライアントを自動生成できることで、前者のデメリットはAPIの定義の作成や変更に大きなコストがかかることです。

この相反するメリットとデメリットに対して、今年はいろいろな人と意見交換をしましたが、概ね次のような話に集約しました。

もちろん、最初はコードから自動生成して、ある程度固まったらOpenAPIの定義ファイルだけ分離して管理するという方法も考えられます。しかし、自動生成される定義ファイルはJSONの場合が多く、かつ人間が編集するのはやや大変なファイルが生成されるケースがほとんどなので、それはそれで大変だと思います。grape-swagger-entityなどを使って人間にもまだわかる自動生成ファイルを作ることもできるでしょうが、どっちにしろ大変であることに変わりはないと思います。また、プロジェクトの途中からこの方法を選択することも現実的ではありません。

すでにあるRailsのプロダクトにOpenAPIを入れる

いくつかの複合的な話になりますが、すでにある複雑なAPIの仕様を実装から完全に理解するのは難しいです。特に自分のプロダクトの場合は、フロントのライブラリの都合による箇所もあったので、尚更把握が大変でした。そのためまずやったのは、OpenAPIのAST(のようなもの)をJSONから生成するライブラリを書いて、すべてのリクエスト/レスポンスに適用するRack Middlewareを作成し、ある程度自動で定義ファイルを作成することでした。実はこのライブラリを書いた時点で、OpenAPIの仕様を一部勘違いしているところがあり、実際にはOpenAPIの自動生成としてはあまりうまく動かなかったのですが、副産物としてすべてのAPIのリクエスト/レスポンスに対する型情報を得ることが出来ました。このライブラリの詳細に関しては、SmartHR Advent Calendar がまだ全日埋まっていないので、この先も埋まらなかった場合はその枠でOSS化して詳細に書きます。

すべてのAPIの型情報が手に入ったら、次はその情報を元に定義を書いていきます。とはいえ、OpenAPIの定義くらい巨大になるとYAMLもJSONももはや人間が書くべきものではないので、ビジュアルエディタを使う必要があります。今回は、Stoplight Studioを使いました。OpenAPIのビジュアルエディタって、どれもなんとも言えない書き味のものばかりなんですが、Stoplicht Studioはその中でもまあそんなに違和感がないと言えるのではないかな、と思います。とはいえ、これもそんなに使いやすいとはいえないので、どちらかといえば一番マシという消極的な選択です。特に自動でモックサーバーを立てる機能があって、何も考えずにローカルのRailsと同じポートを定義に指定したりするとバッティングして死にます。どうにかならんのかこれ。

ビジュアルエディタを使えば、最初に集めた型定義ファイルをもとにサクサクとOpenAPIを書いていくことが出来ます。しかしここで気づいたのですが、先におすすめしないと言った「自動生成した定義ファイルから手動で定義ファイルを編集する」というパターンを自分で踏んでいることに気づきました。自分でやったからはっきり言えますが、これは大変な作業です。みんななるべく早めにOpenAPI定義しましょう。

また、OpenAPIを記述するのであれば、私はYAMLをおすすめします。ビジュアライザに取り込んだり、ライブラリで読んだりするための取り回しは1ファイルのJSONのほうが良いのですが、100行を超えるJSONは人間の扱える代物では無いので、素直にファイル分割したYAMLで書くべきだと思います。そしてYAMLでファイル分割した記述した定義を、openapi-generatorなどのツールを使いCIで1ファイルのJSONに変換してパブリッシュするのが最も良いと思います。しかし、自分の環境ではなぜかStoplicht Studioで書いたYAMLの定義ファイルは、swagger-codegenを使ってYAMLからJSONに変換すると、変換は成功するのに$refを一部うまく解釈してくれず、OpenAPI Parserに入れると音もなくcommitteeが死ぬ問題がありました。OpenAPI Parserはエラーを返さない上に、committeeはnilのメソッドを読んで死ぬ情報しかくれなかった(つまり、OpenAPI Parserの成果物が間違っているのに、それに気づかずcommitteeが死ぬ)という問題に直面したため、もうどうにもならず最後はJSONを直接編集してcommitteeにデバッグコードを入れながらJSONを修正していました。これはかなり時間がかかる辛い作業でした。本当に数千行のJSONは人間が扱うものではありません。そうなる前にYAMLをもっとちゃんとチェックしましょう。(この問題に関しては、後にopenapi-generatorに突っ込むとYAMLがぬるぽで死ぬという問題に直面し、どうやらSpotlight Studioが書き出したYAMLに問題があるのではないかという推測をしましたが、未だにちゃんとした原因がわかっておらず、解決できていません。けれど今ならOpenAPI完全に理解しているので、YAMLをただしく書き直せると思います!)

OpenAPI定義ファイルを作り終わったら、そのファイルをCommitteeに読ませて、実際のリクエスト/レスポンスと乖離していないかをチェックします。プロダクトにはユニットテストは大量にあったのですが、E2Eはまだ整備されていなかったので、開発環境でのみ例外を吐きステージングと本番ではエラー通知用のサービスに通知を飛ばすようにしました。その結果、作成したOpenAPIの定義はほとんど問題なく既存のプロダクトに適合することがわかり、今後はこの定義をマスター情報として守っていこうという状態です。本当によかった。

おめでとうございます! ついにカオスなAPIのプロダクトに、OpenAPIが入りました! 私はこれを実施するために想像の3倍の工数がかかりました!

まとめます。

私が言うんだから間違いないです。

OpenAPIについてよくわかっていなかったこと

もうひとつ、自分がOepnAPIについてよくわかってなかったなということを書いておこうと思います。OpenAPI最高だよみたいなのはよく見るんですが、俺はOpenAPIなんもわかってなかったみたいなのはあまり見ないので、俺は全然わかってないということを伝えたいと思います。

その前にまず最初に伝えたいのは、BOOTHでこの同人誌を買えということです。OpenAPI 3を完全に理解できる本 - ota42y。この本は、おそらく日本語で手に入る最も詳細なOpenAPIの仕様を解説した本です。英語でOpenAPIのSpecを読んでも良いんですが、正直色んな意味で疲れると思うので、この本を買うのが良いです。本当に。

買いましたか? 良いです。それでは、いくつかわかっていなかったことを列挙したいと思います。

allOfって何に使うの

マジでわかっていなかった。allOfは、2つのエンティティを合成したものを表現するときに使うんですね。

具体的に言うと、新規リソースを作るときのPOSTのボティって、大抵の場合はその要素を取得するときのGETのサブセットになりますよね? 例えば、ユーザーリソースがid, name, email持つ場合、GETではその3つを持ってくるけど、POSTするときに必要なのってnameとemailだけですよよね? その2つの要素の違いって、idがあるかどうかだけなんですが、エンティティ的には別になってしまいます。しかしそれは、二重管理では……? となります。allOfは、そんなときにこういう表現を可能にします。

components:
  schema:
    user
      title: user
      allOf:
        - type: object
           properties:
            id:
              type: string
        - $ref: ./#components/usersElement
    userElemetnt:
      title: userElement
      type: object
        properties:
          name: string
          email: string

##

allOfは、配列に含まれるすべてのスキーマの条件を満たしている際に正となります。つまりこの例では、Userエンティティはidという要素をもつことと、UserElementの要素を持つことを同時に要求しています。これは、Userを構成する本質的なElementと、メタ情報であるIdを別のエンティティとして表現できるということです。実際に取得されるUserと、作成時に必要とされるUserElementを分離しています。なんとなく冗長な感じがするかもしれませんが、二重管理よりは遥かにマシですし、ユーザーを表す本質的な情報は何なのかというのを明示的に示せます。

nullable

OpenAPI3からtypeのnullが消えてた、知らんかった

required

これは根本的に勘違いしてたんだけど、propertiesって定義してても実際にキーが無かったら通過するのね……propertiesと同じ階層でrequireに必要なキーを配列で渡さないと、キー消えてても何も言ってくれない

any

仕様上 {} で良いと思ってた。そうじゃない、こんな感じに書かないと死ぬ

anyOf:
  - type: string
     nullable: true
  - type: number
  - type: integer
  - type: boolean

あまりに妙なので、これに関してはまだ理解してない可能性がある

最後に

OpenAPIのいい感じの書き方、実はOpenAPI Parserのテストを読むのがよく分かります。各個のスペックを見てもいいし、そもそもリソースとして用意されてるYAMLを見ても良い。みんな読もう。

そんなわけで、SmartHR Advent Calendar 2019 よろしくおねがいします。