FastAPI で独自に定義した API エラーも仕様書に自動反映したかった話

こんにちは、サーバーサイドエンジニアの @kimihiro_n です。 最近は FastAPI という Python の Web フレームワークが社内で密かなブームとなっています。 今回はその FastAPI を使ったエラー定義まわりの話をしたいと思います。

FastAPI とは

FastAPI の概要については先日ちょうど社内勉強会用に資料を作ったのでこちらを見てもらえるのが早いです。 ざっくり言えばシンプルなインターフェースとドキュメント(OpenAPI)の自動生成が強力なフレームワークになります。

OpenAPI でドキュメント管理

今回注目したいのはドキュメントの自動生成のほうです。 開発チームでもドキュメントとAPIの実際の仕様が一致しない問題が時々発生していて、どうドキュメントを管理していくかが課題となっています。 KPTで振り返った結果、「人がドキュメントを書くからメンテが大変なのであって、ソースから仕様書が動的に生成されれば問題は解消するのではないか」という希望から FastAPI へと焦点があたりました。

このフレームワークでは入力(リクエスト)と出力(レスポンス)の形式を Python のモデルとして記述してあげることで、自動で OpenAPI 形式のドキュメントを出力してくれます。 OpenAPI はツールが充実しており、APIを試験的に叩くための Web コンソールを利用できたり、様々な言語から API へアクセスするときのソースを自動生成をしたりできます。

ちょうど小さめの開発プロジェクトが立ち上がったので、検証かねて FastAPI で作ってドキュメントが実用に耐えうるのかやってみました。

ちなみに、FastAPI は WSGI ではなく ASGI のフレームワークなので AWS のサーバレス構成で動かせるかは懸念だったのですが、ちょうどSREチームのたっちさんが AWS Lambda で動かすサンプルを検証してくれており、スムーズにプロジェクトに投入できました。

エラーレスポンス問題

実際 API を作って、フロントの開発メンバーに生成された OpenAPI を仕様書として渡してみたところ、正常系に関してはうまく機能することができました。 しかし どんなエラーをハンドリングしなくてはいけないのか という点に関しては生成された OpenAPI では判別できず、別途仕様を共有する必要がありました。

デフォルトだと正常系の 200 のドキュメントと、リクエストが指定したモデルと合致しているかの 422 エラーしか OpenAPI に記載されていませんでした。 認証が不正なのか、渡してるパラメータがおかしいのか、それとも権限・状態がおかしいのかという情報はクライアント側の挙動を適切にするためにも不可欠なものです。 5xx 系のサーバー内部のエラーは一旦置いておくとしても、4xx 系のエラーについてはドキュメントとして明記しておきたいです。

独自エラーを OpenAPI に記載する

FastAPI のドキュメントを調べてみたところ、Python の Exception をそのままレスポンスとして登録する手段は見つからず、responses という引数に OpenAPI の定義を付加情報として加えてあげる必要がありました。

# https://fastapi.tiangolo.com/advanced/additional-responses/ より
@app.get("/items/{item_id}", response_model=Item, responses={404: {"model": Message}})
    async def read_item(item_id: str):
    ...

responses は辞書になっていて、それぞれのステータスコードをキーに、レスポンスとなるモデルを記述(もしくは OpenAPI の 構造をそのまま記述)することで、生成されるドキュメントにエラー情報を付与することができます。

ただ実際のユースケースとして、

  • エラーレスポンスのデータ構造はほぼ共通で変わることがない
  • エラーで重要なのは構造よりエラーの中身
  • HTTP ステータスコードとしては同じだがエラーの理由が異なるケースがある

といったことがあげられます。

OpenAPI はインターフェースを記述するためのドキュメントなので、エラー内容まで関与しないというのは正しいのですが、そうするとエラー管理のための別のドキュメントを用意しなくてはなりません。 当初の目的としては API ドキュメントを1つにまとめて楽に管理することなので、なんとかこの OpenAPI 上でエラーの種別まで列挙するようにしたいです。

実際にやってみた

やっと本題になります。

上記のエラーの種別の列挙までを FastAPI で実現できないかをやってみました。

f:id:nsmr_jx:20200615134744p:plain

実現したい API ドキュメントとしては上記のようなイメージになります。

応答するステータスコードが列挙され、200以外のときはそのレスポンスの内容も網羅されているのが理想的です。 こうすることで API を叩く側がどんなエラーをハンドリングしなくてはならないのかが明確になり、連携を円滑に進めることが出来ます。 (HTTPステータスコードとは別にエラーコードを定義するのもよさそうです。)

併せて、Python 側のコードも複雑になりすぎないということを目標とします。 Open API の定義をそのままコードに埋め込めてしまうので、愚直に返しうるエラーを文字列で手動編集する、みたいなことは避けたいです。 認証のエラー文言が変わる度、全部のビューを編集するのはつらい…。

エラーを定義

まずは Python で API 独自のエラーを定義していきます。

# https://github.com/pistatium/fastapi_error_sample/blob/master/api/errors.py

class ApiError(Exception):
    """ エラーの基底となるクラス """
    status_code: int = 400
    detail: str = 'API error'  # エラー概要


class DontSetDummyParameter(ApiError):
    status_code = 403
    detail = 'ここに値をセットしないでください'


class InvalidFizzBuzzInput(ApiError):
    status_code = 400
    detail = 'input の値が不正です'


class WrongFizzBuzzAnswer(ApiError):
    status_code = 400
    detail = '不正解です'

ApiError という共通のエラーを定義してあげて、これを継承して具体的なエラーを列挙していきます。 同じエラーは同じ HTTP ステータスコードを吐くので、ここで一緒に定義してしまいます。

View でエラーを使う

# https://github.com/pistatium/fastapi_error_sample/blob/master/api/main.py

@app.post("/check_fizzbuzz", response_model=FizzBuzzRequest)
def check_fizzbuzz(req: FizzBuzzRequest):
    if req.dummy:  
        raise DontSetDummyParameter()  # # このパラメータを設定していたら強制的にエラーを発生。※実際は validator でやると綺麗
    try:
        expected = fizzbuzz(req.input)
    except ValueError:
        raise InvalidFizzBuzzInput()  # input の入力が不正であればエラー。※実際は validator でやると綺麗
    if expected != req.answer:
        raise WrongFizzBuzzAnswer()  # input と回答がマッチしなければエラー
    return FizzBuzzResponse(message='Good!')

例外を定義したら、FastAPI の View の中でその例外を利用するようにします。 ApiError を継承したエラーは HTTP のステータスコードをもってるので、View 関数の中でのみ利用するようにするとエラーの管理が簡単です。

エラーハンドラを登録する

@app.exception_handler(ApiError)
async def api_error_handler(request, err: ApiError):
    raise HTTPException(status_code=err.status_code, detail=f'{err.detail}')

独自のエラーを勝手に定義して raise するだけだと FastAPI がエラーとして認識できないので、500 エラーになってしまいます。 なので、FastAPI が ApiError を扱えるようエラーハンドラを追加します。 ここでは FastAPI の HTTPException にあわせて再度 raise しています。 こうすることで FastAPI 内部のエラーとも構造を共通化できるため、インターフェースとしてすっきりします。 もちろん独自の構造を定義して返してあげてもよいです。 HTTP ステータスコードとは別にユニークなエラーコードを振って返すようにすると、クライアントでもハンドリングしやすそうです。

ドキュメントにエラーを登録する

エラーハンドラを登録することでアプリケーションとしては問題なく動くのですが、OpenAPI の仕様書にはまだエラー情報が反映されてません。 自動でエラーを登録できると理想的なのですが、コードを静的に解析したりしない限りはどんな例外が発せられるかは把握できません。 なので半手動でエラーレスポンスを列挙することにします。

def error_response(error_types: List[Type[ApiError]]) -> dict:
    # error_types に列挙した ApiError を OpenAPI の書式で定義する
    d = {}
    for et in error_types:
        if not d.get(et.status_code):
            d[et.status_code] = {
                'description': f'"{et.detail}"',
                'content': {
                    'application/json': {
                        'example': {
                            'detail': et.detail
                        }
                    }
                }}
        else:
            # 同じステータスコードなら description へ追記
            d[et.status_code]['description'] += f'<br>"{et.detail}"'
    return d

このような ApiError を OpenAPI の書式に変換するユーティリティ関数を用意してあげます。 同じ HTTP ステータスコードのものが複数あると上書きされてしまうため、プログラム的に重複を確認し、ぶつかっていれば detail への追記を行うだけにしています。

そして先ほどの View 関数のルーティング部分を

@app.post("/check_fizzbuzz", response_model=FizzBuzzRequest,
          responses=error_response([DontSetDummyParameter, InvalidFizzBuzzInput, WrongFizzBuzzAnswer]))  # 追加
def check_fizzbuzz(req: FizzBuzzRequest):
   ...

のように変更します。 View 内で raise している ApiError をあつめて、error_response ユーティリティ関数へ渡しているだけです。 View の中で扱うエラーが増減したときは忘れずに反映する必要はありますが、View 以外で ApiError を発生させないみたいなルールとともに実装すれば管理しやすいかと思われます。

これにより、OpenAPI へのエラー定義を統一して扱えるようにできました。

OpenAPI で表示

FastAPI を動かしてみると上記のような OpenAPI の定義を生成することが出来ます。 開発サーバーの /docs にアクセスして UI ごと表示してもいいですし、 Python から定義を JSON として出力し外部のツールから利用することも可能です。

Swagger の Editor を利用するとこのような Web コンソールを出力できます。

GitLab だとデフォルトで OpenAPI に対応しているので、openapi.json をレポジトリに入れておくだけで勝手に UI 付きで表示してくれるので便利です。

おわりに

FastAPI を利用して API エラーも含めてドキュメント化することができました。 管理のコストを下げつついい感じの仕様書をつくれるので便利ですね。 コーディングする上でもはまりどころが少なく、FastAPI もっと活用していきたいと思いました。

説明に利用したコードのサンプルはこちら。

Flow/PostCSS の大規模プロジェクトを TypeScript/emotion に移行して数万行のプルリクを投げた話

JX通信社CDOの小笠原(@yamitzky)です。

AI 緊急情報サービスの「FASTALERT」は、報道機関や公共機関に導入いただいている(お堅めな) BtoB SaaS でありながら、 事業開始当初から React を使った Single Page Application(SPA) として作っています。 2017年には、より信頼性のあるフロントエンドを提供するため、 Facebook の Flow を導入しました。しかし、昨今の TypeScript の盛り上がりや、社内の他プロダクトで TypeScript を使っていることなどを受けて、フロントエンドのアーキテクチャを大幅に見直しました。

今回取り組んだ大きな変更は、

  • Flow から TypeScript への移行 (型チェックの移行)
  • TypeScript 化に合わせた、 babel-plugin-proposal-*** の廃止 (文法の移行)
  • PostCSS から [emotion] への移行 (CSS の移行)

などです。

今回のブログでは大規模プロジェクトにおいて、Flow から TypeScript、PostCSS から emotion へ移行した際の勘所や、知見などをご紹介します。Flow を使ったことないけど、JavaScript から移行したい方にも役立つかと思います。

Flow と TypeScript

Flow は Facebook が中心に作っている JavaScript のための静的型チェッカーで、TypeScript は Microsoft が中心に作っている JavaScript の型付きな上位互換言語(superset)です。 Flow は JS への型宣言拡張、TypeScript はプログラミング言語と、微妙な立ち位置は違いますが、共通するモチベーションとして 「静的型付き言語でない JavaScript に型安全を持ち込む」 というものがあるかと思います。

また、両者の文法も似ている部分があり、下記は TypeScript でも Flow でも同じような挙動をするコードです。

// @flow
function square(n: number): number {
  return n * n
}
square("2") // Error!

2017年に技術選定した当時は TypeScript の方が盛り上がるとは思っていませんでした*1。2020年現在、TypeScript の方が GitHub 上のスター数やサードパーティーの型宣言も多く、React との相性や DX(開発者体験) も良いと感じています。

PostCSS と emotion

React などの SPA でデザインを実装していく際には、グローバルな CSS ではなくモジュール化された(コンポーネントに閉じた)CSSを使うことが望ましいです。FASTALERT ではそのために、PostCSS を利用していました。

PostCSS を使うことによって、

  • モジュール化された、グローバルを汚染しない CSS の実現
  • 自動的な vendor prefix
  • ネストした CSS のような新しい構文
  • 変数を活用した DRY な CSS 定義

などが実現できます。一方で、emotion でも同様のことは実現できます。emotion は CSS in JS と呼ばれるような派閥の一つで、JavaScript(TypeScript) 内に CSS を書くことができます。

そのため、「CSS の構文で書くことができる」という点で PostCSS と emotion は共通してるものの、定義方法は大きく異なります。

PostCSS版

/* index.css */
.square {
  width: 100px;
  height: 100px;
}
// index.tsx
import styles from './index.css'

const Component = () => <div className={styles.square}>四角形</div>

emotion版

// index.tsx
const Square = styled.div`
  width: 100px;
  height: 100px;
`
const Component = () => <Square>四角形</Square>

PostCSS では CSS に書く、emotion では JavaScript (TypeScript) 内に直接書いています。

emotion の良いところは、なんと言っても React x TypeScript の流儀で書くことができる ことです! CSS プロパティも型定義されていたりと、型の恩恵 が受けられます。同じ理由で、Styled System を使ってのデザインシステムの構築もしやすいです*2

移行方法

mizchi 氏の「非破壊 TypeScript」 を参考にしつつ、次のようなステップで行いました。ピックアップしてご紹介します。

  1. TypeScript 向けにライブラリのインストールや設定変更
  2. .js の拡張子を .ts(x) にして、 // @flow のコメントをなくす
  3. babel を走らせると怒られるので、 TypeScript 文法の誤りを地道に直す
  4. tsc --noEmit するとやっぱり怒られるので、TypeScript の型エラーが発生したところを地道に解決する
  5. 無理だったら諦めて as any.js
  6. .css を .tsx 内に ひたすらコピペ
  7. eslint で自動フォーマットを走らせて微調整

これらの作業に特に面白いツールなどは使っておらず、基本的には力技と、VSCode の正規表現による置換、そして TypeScript に怒られドリブンで進めました。気合があればできる と思います。

TypeScript 向けライブラリの設定

次のような変更を行いました(かなり省略しています)

package.json

+    "@emotion/core": "^10.0.27",
+    "@emotion/styled": "^10.0.27",
....
-    "@babel/plugin-proposal-class-properties": "7.1.0",
-    "@babel/plugin-proposal-decorators": "^7.0.0",
-    (@babel/plugin-** が続くため略)
-    "@babel/preset-flow": "^7.0.0",
+    "@babel/preset-typescript": "^7.8.3",
+    "@typescript-eslint/eslint-plugin": "^2.13.0",
+    "@typescript-eslint/parser": "^2.13.0",
+    "babel-plugin-emotion": "^10.0.27",
+    "emotion": "^10.0.27",
-    "flow-bin": "^0.50.0",
+    "jest-emotion": "^10.0.27",
-    "postcss-cssnext": "^2.10.0",
-    "postcss-extend": "^1.0.5",
-     (postcss-* が続くため略)
+    "typescript": "^3.7.4",

.babelrc

-    "@babel/preset-flow"
+     "@babel/preset-typescript",
...
-    "@babel/plugin-transform-flow-strip-types",
-    "@babel/plugin-proposal-function-bind",
-     (@babel/plugin-*** が続くため略)
+    [ "emotion", { "labelFormat": "[filename]--[local]" } ]

.webpack.config.js

-      extensions: ['.webpack.js', '.web.js', '.js', '.jsx', '.json'],
+      extensions: ['.webpack.js', '.web.js', '.js', '.jsx', '.json', '.ts', '.tsx'],
...
-          test: /\.jsx?$/,
+          test: /\.[jt]sx?$/,
...
-          { loader: 'postcss-loader' }

特筆すべきは、Webpack 関連の設定を大きく変えずに済んでいる ことです。

TypeScript 化の方法は2つあり、

  • tsc コマンドなどを使い、TypeScript のツールセットで実現する
  • @babel/preset-typescript を使う

今回は後者にしました。すでに babel を活用している場合は、 @babel/preset-typescript を使うと簡単でおすすめです*3

無理だったら諦めて as any.js

「非破壊TypeScript」にもある通りロジックを変更しないのがとても大事です。ロジックを変更すると、なにかあったときの切り分けが難しくなります。ロジックを変更するぐらいだったら as any でキャストしたり、 .js のままで放置したりしました。

初めて TypeScript 化する場合は、積極的に諦めましょう! Done is better than perfect です。

たとえば、次のようなものにすら as any を使いました。*4

document.querySelector('.some-class-name').setAttribute('hidden', true as any)

.css を .tsx に移行

emotion では、次の2つの書き方が使えます。前者は css として書くことができる反面*5、後者では px を省略したり、パラメータをすっきりした形で使えます。

const Wrapper1 = styled.div<{ height: number }>`
  text-align: center;
  width: 240px;
  height: ${({ height }) => `${height}px`};
  & + & {
    margin-top: 4px;
  }
`
const Wrapper2 = styled.div<{ height: number }>({
  textAlign: 'center',
  width: 240,
  '& + &': {
    marginTop: 4,
  }
}, ({ height }) => ({
  height
}))

今回は「PostCSSから移行する」ということを念頭において、前者の CSS チックな書き方を採用してひたすらコピペしました。個人的には両方とも使いますが、主に後者の object 方式を使うことが多いです。

かかった期間

だいたいの作業としては、年末年始にテレビを見ながら2,3日でかきあげました*6。地道な作業が多いので、ゆっくりお酒を飲んだりバラエティ番組でも見ながらやりきるのがおすすめです。

移行してよかったところ

  • TypeScript 化によって、 Flow では気づけなかった型のミスが出てきた
  • PostCSS では気づけなかった、 使われてない CSS を削除できた
  • 社内メンバーがコントリビュートしやすくなった

1つめに関しては、React の 型指定の誤りや、型の指定が緩すぎるものなどが出てきました。Flow の使い方が悪かった可能性もありますが、TypeScript の方が型定義は厳しめな印象です。例えば、 Set の型パラメータの指定がなくて怒られました。無理なものは諦めた一方、地道な型解決でかなり型安全にできたと思います。

2つめに関しては、typescript-eslint の未使用変数チェックを通るため、使われてない CSS をあぶり出すことができます。仮にうっかり削除しても TypeScript のコンパイルエラーで気付けるので、削除の心理的障壁がありません。

3つめに関しては、TypeScript の方がエコシステムが充実していたり安心感があったりするのか(主観です)、プロジェクトのコアメンバーでなくてもコントリビュートしやすくなったように感じます。実際、FASTALERT 新型コロナ機能のプロジェクトでは、SRE のエンジニアやインターン生のコントリビューションもありました。

speakerdeck.com

まとめ

今回は、信頼性の求められる BtoB SaaS で、Flow を TypeScript に、PostCSS を emotion に移行したときの話でした。差分は数万行に渡ります*7。今回得たベストプラクティスな知見としては、

  • 非破壊 TypeScript を参考にする
  • TypeScript に怒られながら進める
  • ロジックは絶対に変えない、積極的に諦める
  • なにかのついでにやると、地道な作業が多くても問題ない

以上です。後学のため、もしツールなどでスマートに移行する方法があれば教えて下さい!

宣伝

今回ご紹介したとおり、JX 通信社のフロントエンドプロジェクトは、 TypeScript、React、emotion が活用されていたりとモダンな開発環境です。フロントエンド(や Pythonも!) を一緒に書きたいインターン生を積極募集しています。まだ新型コロナも収束していませんので、エリア不問&リモートOKです!

*1:Flow も React も同じ Facebook 由来のため

*2:新型コロナダッシュボード爆速リリースの舞台裏 〜小さく始めて大胆に変えるフロントエンドプロジェクト〜 - JX通信社エンジニアブログ で紹介したような共通ライブラリ化が容易というメリットなどもあります

*3:ただしトランスパイル時の型チェックはされないので、 tsc --noEmit を走らせて型チェックをしてください

*4:ちゃんとやれば any を使わず書き換えられますし、なんならもっと React っぽく移行できそうですが、動いていたものを最優先

*5:プラグインによる色付けもしやすいと思います

*6:他作業もあり、マージされるまでにはこれ以上かかっています

*7:正直に言うと、 eslint の自動フォーマットもあるので盛ってます

新型コロナダッシュボード爆速リリースの舞台裏 〜小さく始めて大胆に変えるフロントエンドプロジェクト〜

JX通信社CDOの小笠原(@yamitzky)です。

JX通信社は「今起きていることを明らかにする報道機関」というミッションの元に、新型コロナリアルタイムダッシュボードを 2月16日 から提供し続けています。今回は、「新型コロナプロジェクト」の発足から現在に至るまでの、プロジェクトの進化についてご紹介します。

f:id:yamitzky:20200529014632p:plain

プロジェクト発足

そもそものプロジェクトの発足としては、2月14日の下記のツイートが発端でした。およそ 2 日でリリースしたことになります。

当時は、東京都のような自治体公式の特設サイトや、国内全体の動向をまとめたサイトはほぼありませんでした。国内の公共機関・報道機関のなかで、かなり速くリリースできた部類に該当するかと思います。

フェーズ1: Vue.js でのプロトタイピング

現在は React で作られているプロジェクトですが、当初のプロトタイピングフェーズでは Vue.js を使っていました。

Vue.js は Progressive(漸進的) なウェブフロントフレームワークです。最もシンプルなのは、次のような単体の HTML として配信する方法です。この形式ではトランスパイル(Webpack によるビルド等)の必要もなく、HTML 単体を配信するだけでも SPA になり、非常に手早くプロジェクトを開始できます。

// index.html
<html>
  <body>
    <div id="app">{{ message }}</div>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script>
      new Vue({
        el: "#app",
        data: () => ({ message: "hi" })
      })
    </script>
  </body>
</html>

今回のプロジェクトでは「なるべく早くリリースする」という目的があり、ツール周りでつまずきたくなかったので、まずは Vue.js で雑にプロトタイピングしました。

フェーズ2: React × TypeScript × Emotion × Parcel への移行]

f:id:yamitzky:20200529030051p:plain

プロトタイプとして実現したものの、NewsDigest のウェブメディアで配信したいという課題がありました。NewsDigest の CMS は、Django (Python) で管理しており、必ずしも SPA との相性が良いとは言えません。

そこで次のような形で、 CMS にたった 2 行のタグを埋め込むだけで新型コロナのダッシュボードを配信できるようにしてみました*1

f:id:yamitzky:20200529020642p:plain

技術的な必要条件としては、

  • 1 つの script タグに JavaScript の依存関係を全て詰め込む
  • 1 つの script タグさえ埋め込めば、デザイン(stylesheet) も再現できる

です。これを最短で実現するため、バンドラー(Webpackの代替)として Parcel を利用しました。Parcel はほぼ設定不要で使えるバンドラーなので、今回のような急ぎのプロジェクトにピッタリです。また、CSS ファイルに依存せずデザインを指定するため、Emotion も使っています。Emotion は React と相性の良い TypeScript ベースのデザイン用ライブラリで、JS 内にデザイン指定を埋め込めます。

// index.tsx
const Wrapper = styled.div({
  padding: 8,
  color: 'red',
  display: 'flex',   // vendor prefix も自動付与
})
export const Component = () => <Wrapper>some content</Wrapper>

つまり、プロトタイピングから本番配信までの間に、Vue.js から React・TypeScript にまるっと置き換えたことになります*2

フェーズ3: gitでのデータ管理から、サーバーレスなデータ管理へ

新型コロナの感染者数字は、リアルタイムに更新されます。実は、プロジェクト初期ではデータベースを用意しておらず、TypeScript のソースコード内に直書きする形でデータ管理をしていました。数値更新するたびに GitLab CI が動いて、JS ファイル自体をデプロイしていたのです・・・!

f:id:yamitzky:20200529130138p:plain

// 実際の data.ts
export const data = {
  modified: '2020.02.19 11:30',
  japanStats: {
    infected: 616,
    infectedChange: 96,
    dead: 1,
    deadChange: 0,
    // クルーズ船(ダイヤモンド・プリンセス号)
    cruiseInfected: 542,
    cruiseInfectedChange: 0
  },
   ...
}

さすがに、機械化しづらい、型安全でなく事故が起こりやすい、git でデータ更新をするのは属人性が高い、Python 製の FASTALERT API としての提供が難しい... という事情もあり、Google Spreadsheet をデータベースとして利用し、JSON から使えるようにしました。また、データ更新の仕組みは GitLab CI ではなく、AWS Lambda でサーバーレスな形で動かすようにしています。

f:id:yamitzky:20200529130900p:plain

フェーズ4: Next.js での SSR への移行

<script> タグでの配信は、SEO 的に弱い可能性があるのではないか、という懸念が生まれました。そこで当初の Parcel から、Next.js へ移行しました。Next.js はReact のフレームワークで、サーバーサイドレンダリングの機能などが組み込まれています。

この Next.js のサーバーは、Amazon ECS(Fargate) 上にデプロイしています。

フェーズ5: ウィジェットとしての外部提供と yarn monorepo 化

JX 通信社が提供している新型コロナダッシュボードは、メドピア社LINE社にウィジェットとしても提供しています。また、NewsDigest だけでなく FASTALERT 内でも配信しています。NewsDigest での提供、ウィジェットとしての提供、FASTALERT 内での提供、、、これらを 1 プロジェクトでやるのは現実的ではなかったため、次のような monorepo 構成を行っています*3

f:id:yamitzky:20200529164607p:plain

@corona/components ・・・ 各種グラフを提供するライブラリ。FASTALERT からも npm install している
@corona/server ・・・ @corona/components に依存する、next.js のプロジェクト。newsdigest.jp 用
@corona/widget ・・・ corona/components に依存する、webpack のプロジェクト。ウィジェット配信用

この monorepo 構成は、yarn の workspace 機能を使っています。

また、@corona/server と @corona/widget では、ビルドツールなども異なっています。これは、プロジェクトの目的や、求められる安定性*4などに応じて、意図的に使い分けたものです。

うまく monorepo 構成にすれば、コードを使いまわしつつ、最適な技術選定ができるようになります。

フェーズ6: グラフライブラリの移行

プロジェクト当初は、chart.js と d3 などを使っていましたが、 vx という React 向けのグラフライブラリに移行しました。

Chart.js は canvas ベースのグラフライブラリです。しかし、 React のような宣言的な UI の思想との相性の悪さや、サーバーサイドレンダリングできない、柔軟にカスタマイズできないなどの課題がありました。

d3.js もデータ可視化に使っていましたが、厳密には「ドキュメントをデータに基づいて操作・構築するためのライブラリ」です。雑に言えば「DOMを操作するためのライブラリ」なので、 React(React-DOM)のような DOM 操作のライブラリと、役割的にかぶっています。

そこで、 React ベースの低レイヤーなデータ可視化ライブラリである vx に移行し、React だけで SVG での可視化をしています。

  • React 的な、宣言的データ可視化の実現
  • TypeScript の型
  • 柔軟なデータ可視化の実現

などができるようになりました。一方で、パフォーマンスチューニングや、コード量の増加などは起きています。

f:id:yamitzky:20200529115825p:plain
凡例や横軸の位置など、かなり微調整しています

まとめ

新型コロナウイルスに関しては、社会的な需要や、刻々と変わる状況などを踏まえ、かなりスピード重視でプロジェクトが発足しました。当初は git でデータ管理していたほど、一般的なアプリケーション構成のセオリーからは外れた作り方です。

一方で、新型コロナダッシュボード提供開始から3ヶ月経ち、ビジネスの状況に応じて大規模なアーキテクチャ変更(式年遷宮)を行っており*5「ビジネスとスピードと品質の両立」も実現できたと思います。これらの両立のポイントは「ビジネス要件に合わせて技術を使いこなし、いかに漸進的に進めるか」です。

f:id:yamitzky:20200529132312p:plain

今回のプロジェクトでは、かなりインターン生に協力いただいています。本当にありがとうございます! 引き続き、フロントエンドのエンジニアインターンを募集中です(まだ新型コロナは収束していないのでリモート中心です)。ぜひ一緒に、インパクトのある開発をしましょう!

*1:この JS 自体は、Amazon S3 と CloudFront で配信しています

*2:同じようなことを実現する方法は他にもあります。この技術選定は、慣れの部分が大きいです

*3:データ更新用サーバーレスパッケージなども含めると、10 個ほどパッケージが含まれています

*4:ウィジェットは絶対にサーバーを落としたくないため、SSR は行っていません。また、顧客ごとにカスタマイズする関係で SSG にもしていません

*5:プロジェクト発足から 3 ヶ月ぐらいしか経っていないのに、1000行以上差分のあるプルリクが複数あります。同僚から「走りながら車輪を全交換した話」というタイトル案が出るくらいです

コワクナイBigQuery - チームでデータを活用するための活動について

JX通信社でシニア・エンジニア&データ基盤エンジニアをしています, @shinyorke(しんよーく)です.

最近のStayHome生活は, 「YouTubeでUK Rock🎸, お笑いの動画を見まくる」「地元のお取り寄せグルメを楽しむ」「野球データ分析に集中する」で楽しんでいます.

JX通信社では, 「FASTALERT」などのプロダクトのデータをBigQueryに集約し,

  • 機械学習を用いたプロダクト開発・施策
  • プロダクト・サービスの改善に関する分析
  • 日々のイベントをメトリクス化して可視化(いわゆるBI的なもの)

に活用しています.

このエントリーでは,

???「BigQuery?使い方わからないし💰かかるって噂を聞くのでコワイよ」

ワイ将「BigQueryコワクナイヨー, ガンガンBigQuery使えるように仕組み作ったので乗っかりましょ!」

というお話を残したいと思います.

TL;DR

  • 「仕組みの理解」「コストの可視化」を愚直にやっていけば, BigQueryはコワクナイ. むしろ友だちになれる!
  • RDBMSとの違い(特にカラムナー), データサイエンスな人にはPandasとの共通点を教えれば仕組みはしっかり伝わる
  • コストの可視化は本気でやれば2日くらいでシュッとできます.

会社やご家庭でBigQueryをお使いな方の参考になると嬉しいです.

スタメン

全体像 - 今現在の姿

JX通信社では, 昨年の秋から全社的なデータ基盤を構築・運用を開始し, 利用者・使いたい人の声を元に日々改善を行っています.

データ基盤全体はGCP上に構築(ETLの一部タスクを除く*1), DWHはBigQueryを使っています.

利用者であるチームメンバー(エンジニアおよびデータを使いたい人すべて)には以下の図のような仕組みを提供しています.

f:id:shinyorke:20200425131222p:plain
【図】BigQueryを取り巻く仕組み

データ基盤を構築し, 運用し始めた当初は「Colab(Jupyter notebook)とRedash, Data Portal用意すればまあイケるのでは?」と思っていたのですが,

  • BigQueryは噂でしか聞いたことない, サービスのDBを直接分析する場合との違いは?
  • BigQueryはお金がかかる, という風潮. 「数百万円飛ぶでしょ?」的な

という声があったり雰囲気を感じたりしました.

確かに, 自分でBigQueryを使っていても「このクエリって流したらいくらだ💰?」とか気になります.

というわけで,

  • BigQuery(含むデータエンジニアリング)の正しい知識を普及しよう
  • 毎日コストを見ることができればええやん!

この2つをシュッとやることにしました.

BigQueryに関する正しい知識を

正しい知識を普及する, 植え付けるのであれば自分から動けばエエやん!...ということで,

  • BigQuery使いたいぞっていうメンバーがいるとき
  • データ分析のインターンがJoinする時のオンボーディング*2

...というタイミングで社内勉強会を不定期に開催しています*3.

これについては, 私が作ったテキスト(社内のKibelaに公開)を用いてやっています.

f:id:shinyorke:20200425133707p:plain
JX通信社版・BigQueryの教科書

このテキストのTL;DR的なモノを一部紹介すると,

  • BigQueryは普段使ってるRDBMSとは違い, DWHであり列指向データセットである.
  • select文で検索する, joinする, 集計する的なのはDBと変わらないが, 必ず必要な列だけ使う&範囲を絞って使う.
  • 必要な列だけ取得し, 対象範囲を絞ってもらえればお安く使える.

必要なことを最短距離で覚えられる内容にしています. 3分34秒以上かかるかもですが*4.

ちなみに教え方のコツとしては,

  • RDBMSを知ってる人には「列指向」な事をしっかり教える. MySQLやPostgreSQLで培ったIndexとかの知識は使えないよっていうのを理解してもらう.
  • そもそもDBやSQLを触ったことがない(インターンなど若い人に多い印象)にはPandasやRでよく使うDataframeにたとえて教える.*5

これでいい感じになります.

常にコストを監視できる仕組み

正しい知識を身に着けてもらった後, BigQueryの利用頻度が増えてウッキウキでしたが,

インターン生「shinyorkeさんこのクエリ実行して大丈夫ですか💰?」

ワイ将「お, おう...(そっか心配だよね)」

っていうやり取りが増えました.

せっかくいい感じに分析してもらったりレポート出してもらってるのに彼らを心配にさせちゃいけないよなあ〜ということで,

毎日GCP全体およびBigQueryのコストを通知するSlack Botを開発・運用し始めました.

f:id:shinyorke:20200426122234p:plain
毎日通知, 一定金額超えたら赤くなります

大変ありがたいことに, GCPにはアカウントの利用料金を定期的にBigQueryに流す仕組みが提供されており,

  • 毎日BigQueryの特定のテーブルにデータが入る
  • ↑のコストをSlackにつぶやかせればいい!

という実にシンプル過ぎるやり方でできる事がわかり, 二日ちょっとで開発・運用スタートしました.

ちなみに仕組みはこんな感じです.*6

f:id:shinyorke:20200425140045p:plain
コスト監視マンのアーキテクチャ

数行のSlack Botでできちゃうのでこれは強くオススメです!

結び

「コワクナイBigQuery」の成果ですが,

  • BigQueryに関する知識がついた・恐怖心が無くなったメンバーが使ってくれたり
  • インターン生たちが自主的にSQLを覚えて, データ分析やダッシュボード作りに活用してくれたり
  • 特定のパターン・クエリに対するお値段という「相場」が誕生したことにより, 大胆にBigQueryでガンガンSQLを流すことが可能に

...という, 「まずは使ってみよう, アウトプットだそう!」という流れができました.

基本を教える, コスト感を掴むという視点で心理的安全性ができたのかなーというのが私の感想です*7.

ちなみに自分はインターン生にSQLを共有した所, 「shinyorkeさんもっといいやり方あるよ」的なレビューを受けたりもしました, これは素直に嬉しい.

今後は,

  • ビジネス・セールスメンバーがもっとデータで攻められる用にデータセットやダッシュボード増やしたり
  • 筋がよいSQLやデータ・ダッシュボードを共有したり

といった所で活用事例を伸ばしたいなと思っています.

最後までお読みいただきありがとうございました!

*1:サービスの大半がAWSで運用しており, Extractに当たるタスクはAWSを使っています.

*2:ちなみにですが, 4/30現在ではデータ分析インターンの募集は締め切っております :gomen:

*3:過去に3回ほどやりましたが, いずれもZoomでリモート参加可能な会でした.

*4:なんでや阪神(以下略)

*5:PandasもRも列方向で探索するのに最適化しているモノなので.っていうのとMethodとの関係も伝わるので相当おすすめです

*6:アーキテクチャそのものは自分が過去にやった事例をベースにやりました.もっというと構成図もパクった.

*7:正直な話, コワクナイBigQueryの施策の真意はここにありました. 知ることと見える化は大事なのです.

Serverless Framework+mangum+FastAPIで、より快適なPython API開発環境を作る

はじめに

最近ハイボールにハマっているSREのたっち(@TatchNicolas)です。

昨日オンライン開催されたJAWS DAYS 2020にて、JX通信社もサーバレスをテーマとして発表をしました。(by 植本さん

発表でもありましたように、上記プロジェクトにおいて開発当時はスピードを優先してプロジェクトメンバーの手に馴染んでいて分担もしやすいフレームワークとしてFlaskを採用しました。

一方で、JX通信社としてはFlaskよりもFastAPIを使うプロジェクトが増えてきており、今後もその傾向は続く見込みです。

そこで、特設ページ作成やAPI提供など初動としての開発が一段落したのを機に、JAWS DAYSで発表した仕組みを今後のために発展させる検証をしたので紹介します。

TL; DR;

  • JAWSでは Serverless Framework+awsgi+Flaskな構成でスピーディにコロナ特設ページ向けAPIを作った話をした
    • 緊急性の高いプロジェクトであったが、非常にスピード感のある開発ができた
  • Flaskに代わるフレームワークとしてJX通信社ではFastAPIが流行中
    • OpenAPIドキュメント自動生成、Request/Responseをクラスとして定義するなどカッチリと開発ができる
    • しかしFlaskライクな軽量フレームワークで、シンプルで書きやすい
  • そこで、FastAPIを使うパターンでも前述の良い開発者体験を提供する仕組みを作った

サンプルコードはこちらにおいてあります。

github.com

前提

awsgiはAPIGatewayからLambda Proxy Integrationに渡されるイベントをWSGI*1アプリケーションが理解できる形式に変換し、またWSGIアプリケーションが返すレスポンスをLambda Proxy Integrationに従った形式に変換してくれる便利なツールです。

github.com

WSGIの精神的後継として*2、asyncに対応したASGIが登場しました。そのASGIに対応したフレームワーク(FastAPIなど)とLambda Proxy Integrationの間でアダプタとして動いてくれるのが、mangumです。

github.com

ざくっとまとめると以下のようになります。

WSGI ASGI
フレームワーク例 Flask, Django*3 FastAPI,Sanic,Responder
Lambda Proxyアダプタ awsgi mangum
(HTTP Server)*4*5 gunicorn, uWSGIほか uvicorn

それでは早速やってみましょう。

やってみる

今回用意したサンプルコードはdocker-composeで動くようにしてあるので、下記のリポジトリをクローンしたら下記の要領で起動してみて下さい。

$ docker-compose up -d
$ docker-compose exec exam_results python -c 'from main import init_ddb_local; init_ddb_local()'

実際のコードを見ていきましょう。

# exam_results/main.py

(省略)

from fastapi import FastAPI, HTTPException
from mangum import Mangum


app = FastAPI()  # FastAPIのインスタンス

(省略)

handler = Mangum(app, False)  # FastAPIのインスタンスをMangumのコンストラクタに渡して、handlerとして外から読めるようにしておく

(省略)

exam_results/main.py のに app という変数名でASGI準拠のアプリケーション(=FastAPIのインスタンス)を作り、それを引数として作ったMangumのインスタンスを handler として定義します。

これを serverless.yml の中でhandlerとして指定することで、APIGatewayから発生したイベントをMangumが受け取り、FastAPIに渡してくれるというわけです。

# serverless.yml
(省略)
functions:
  exam_results:
    events:
    - http:
        path: /{path+}
        method: GET
        private: false
        cors: true
    handler: exam_results.main.handler  # ←ココ
    environment:
      PYTHONPATH: exam_results
      DDB_TABLE: ${self:custom.envMapping.${self:provider.stage}.DDB_TABLE}
    role: ManageStudentsRole
(省略)

細かなimportや環境変数設定は実際のサンプルコードを参照してください。

起動時の二行目のコマンドで、下記の関数を呼び出しています。

def init_ddb_local():
    if ExamResultsTable.Meta.host and not ExamResultsTable.exists():
        print('creating a table...')
        ExamResultsTable.create_table(
            read_capacity_units=1,
            write_capacity_units=1,
            wait=True
        )
        print('Done.')

最初のif文でテーブルのモデル(ExamResultsTable)の内部クラスMetaのなかで、「環境変数から DDB_HOST が指定されている」かつ「テーブルがまだ存在しない」場合のみPynamoDBを使って create_table しています。

実際にリクエストをしてみましょう。

$ curl -X POST -H "Content-Type: application/json" -d '{"name":"Alice","subject":"math","score":50}' localhost:8001/scores
{"name":"Alice","subject":"math","score":50}
$ curl -X POST -H "Content-Type: application/json" -d '{"name":"Bob","subject":"history","score":49}' localhost:8001/scores
{"name":"Bob","subject":"history","score":49}

$ curl localhost:8001/scores?student=Alice
{"exam_results":[{"name":"Alice","subject":"math","score":50}]}
$ curl localhost:8001/scores?subject=history
{"exam_results":[{"name":"Bob","subject":"history","score":49}]}

次に、このアプリケーションをAWSにデプロイしてみます。(sls コマンドのインストールやAWS操作のための権限は先に済ませておいてください。)

$ sls deploy --stage=dev
Serverless: Generating requirements.txt from pyproject.toml...
(いっぱいメッセージが出る)
Service Information
service: fantastic-service-with-fastapi
stage: dev
region: ap-northeast-1
stack: fantastic-service-with-fastapi-dev
resources: 13
api keys:
  None
endpoints:
  GET - https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/{path+}
  POST - https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/{path+}
functions:
  exam_results: fantastic-service-with-fastapi-dev-exam_results
layers:
  None
Serverless: Removing old service artifacts from S3...
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.

endpoints にあるURLに対して、localhost:8001 に対して行ったのと同じ操作ができると思います。

あとは sls deploy --stage=devdevstg prd に書き換えて実行すれば、それぞれ独立した環境を作成することができます。 コマンドがシンプルなのでCI/CDも簡単に書くことができるでしょう。

また、/docs にアクセスするとOpenAPIのドキュメントが自動生成されています。

f:id:TatchNicolas:20200323212131p:plain
自動生成されたdocs

何が嬉しいのか

開発スピードを高く保てる

早く上手に使えば運用やデプロイの負担が大幅に軽減されるLambdaと、ローカルでもガンガン開発しやすいWebアプリケーションフレームワークのいいとこ取りをすることができます。また、FastAPIの提供する「Web APIを作る」ために便利な機能を活用することができます。

Lambdaを手元で動かしたいという点で、本記事と似たことを実現しようとしているSAM Localは「Lambdaっぽいなにかを手元で動かす」発想です。一方で今回のやり方は「ローカルで動いているアプリケーションをASGI(FlaskであればWSGI)をインターフェイスとしてLambdaに載せる」という方法です。そのため、serverless.yaml 内ではhttpイベントのパスを{path+} 、 メソッドをANYにしておくことでL7の仕事をAPIGatewayではなくLambda内のアプリケーションに任せています。

awsgiもmangumも(gunicornやuvicornといった)HTTP Serverを起動するわけではなく、単にイベントを変換しているだけなのでオーバヘッドもほとんどゼロなため、Lambdaがキチンとスケールしてくれればパフォーマンスの心配はありません。

いつでも実行環境を載せ替えられる

たとえば運用しつつ何らかの事情で「Lambdaじゃキツイな」となってきたらECSやk8sなどコンテナベースの実行環境に載せかえることも容易にできます。その際にアプリケーションは変更する必要がありません。ローカルで動かしていたDockerイメージをそのまま、またはDistrolessなどにちょっと書き換えるだけで引っ越すことができます。

今回は最小限の構成にするために、Pythonのコードはすべてexam_results/main.pyに詰め込みました。しかし実際のアプリケーションではもっと真面目にファイル・ディレクトリの構成を切り分けると思います。その場合はLambda用(exam_results/lambda.py)とDocker用(exam_results/docker.py)に入口をそもそも分けてしまったり、Poetryのextrasを使って依存ライブラリ管理を分けたりしても良いでしょう。

ハマったところ/改善したいところ

serverless-python-requirementsのモジュールとPoetryがうまく連携しない

パッケージ管理およびvenvの管理にはPoetryを使っていたのですが、Serverless Framework側でモジュール機能を使うと、プラグインが期待通りに動作せず*6にpackageに依存ライブラリを含めてくれません。

PoetryだけではなくPipenvでも同じ問題があり、こちらはIssue報告されています。(執筆時点で未解決)

github.com

Pipfileやpyproject.tomlでなくrequirements.txtであれば individually: true のオプションがちゃんと効いてLambda関数単位で依存関係を解決してくれるようです。なので、CI/CDのタイミングでコマンドを一行足してモジュールごとにPoetryにrequirements.txtを生成させば解決します。

ただし今回のサンプルコードでは、1スタック1関数として作ることで回避しました。*7

DynamoDB LocalとPynamoDBでスキーマ定義が二重管理になってしまう

宣言的に書くためにDynamoDBのスキーマ定義はserverless.ymlに書きたいところですが、それをローカル開発時のDynamoDBにそのまま適用することはできません。

今回アプリケーションはPynamoDBを使ってDynamoDBとやりとりをしており、PynamoDBのモデルはserverless.ymlで定義したスキーマと一致するように書いています。そこで、簡単に初期化関数を定義してPynamodDBの機能を使ってローカルのテーブルを作りました。

しかしこれは実際のアプリケーションには必要ないある種「余計な」コードがソースの中に紛れ込んでしまっています。serverless.ymlに書いた定義をそのままローカル開発環境にも適用できればよいのですが、スマートな方法があれば改善したいです。*8

さいごに

サーバレスはその恩恵と引き換えに、どうしてもプラットフォームの制約を受け入れる必要が生じます。そんな制約の一つが「イベントという形で入力を渡されるので、Lambdaとしての書き方を強制されて慣れ親しんだフレームワークとは勝手が異なる」ことだと思います。

しかしWSGI/ASGIというインターフェイス(とそれらを繋いでくれるmangumのようなツール)を活用することで、ローカルで作業のしやすいコンテナベースの開発とサーバレスな開発をシームレスに切り替えられます。JX通信社のプロダクトではECS+RDSで一部補助的にLambdaを使う構成が定番でしたが、最近はサーバレスを積極的に活用する場面も増えてきました。

今回はそんな社内の技術スタックの移り変わりにあわせた開発ができるように工夫してみたことの記録でした。少しでも皆さんの参考になれば幸いです。

*1:Pythonの標準仕様として用意されているWebアプリケーションのインターフェース定義で、Ruby屋さんにとってのRackにあたります

*2:公式Docより: ASGI (Asynchronous Server Gateway Interface) is a spiritual successor to WSGI, intended to provide a standard interface between async-capable Python web servers, frameworks, and applications.

*3:Django 3.0からASGIに対応していく方針ですが、現時点で限定的です。詳しくは https://docs.djangoproject.com/ja/3.0/releases/3.0/#asgi-support

*4:Ruby屋さんにとってのunicorn

*5:今回はHTTP Serverは使わずに素のWSGI/ASGIアプリケーションとして動かして、mangumにブリッジしてもらいます

*6:プラグイン側のコードをみたところservicePathにプロジェクトルートが渡されてしまいpyproject.tomlを見つけられないことが原因のようでした。lib/pip.jsではmodulePathという変数にモジュールのディレクトリ名が入ってくるのでうまく連結できれば動かせたかもしれません

*7:厳密にマイクロサービスするなら永続化層も共有すべきではないという考え方もあるので、1つのスタックに1つのLambda関数を良しとしました

*8:たとえばserverless.ymlをパースして、テーブルの有無をチェックして必要に応じて作成するなどできると思います