概要
スキーマレスなFirestoreにPythonの自作ORM(fsglue)で型を定義して、OpenAPIとかTypescriptと組み合わせたら、柔軟だけど型のある快適な開発環境を作れたので、簡単にまとめてみた
背景
趣味でローコードな業務アプリのプラットフォームBizglueの開発をしていて、そこの裏側的なお話です。サービスの開発動機とかについては、noteの方に書いているのでご興味もっていただけた方はこちらもどうぞ。
最終的に今の構成になるまでのざっくりとした流れ
- サーバサイドは長く使っていたPythonを使いたい
- サーバ管理したくないし、お金もかけれないので無料枠のあるAppEngineを使おう
- AppEngineといえばDatastoreだけど、新しいFirestoreの方が便利そうだしそっち使おう
- Datastoreならndbがあるけど、FirestoreはORMないのか…、仕方ない作ってみるか
- (ぼちぼち開発進める)
- コード量増えてきてバグ潰すのが辛い。そうだ、Typescript入れよう
- サーバ側とフロント側で型を2回定義するの辛い。そうか、OpenAPI使えばいいのか
- FirestoreのORMいい感じに仕上がったのでオープンソースとして公開しよう → fsglue
- 折角だから紹介記事書こう ★イマココ
全体構成
サーバサイド
- AppEngine(スタンダード環境)
- Python3
- Flask
- Firestore
-
fsglue(自作ORM)
- 作ったモデルをJsonSchemaの定義として出力できる仕様
-
flasgger
- fsglueから出力したモデル定義を使ってOpenAPIの仕様を定義
クライアントサイド
- React
- Typescript
-
openapi-generator(typescript-fetch)
- flasggerで生成したOpenAPI定義を元にTypescriptのAPIクライアントとモデル定義を生成
やわらかい型の流れ
- fsglueでFirestoreのモデル定義を作成
- flasggerにモデル定義を登録
- OpenAPIの定義を作成
- openapi-generaterでAPIクライアントを生成
- APIクライアントがTypescriptの型付きで使える
- (゚д゚)ウマー
どこが「やわらかい」のかというと、Firestore自体は基本的にスキーマレスなので、フィールドの追加や後方互換のある変更は、モデル定義を変えるだけで済む(面倒なマイグレーション処理は不要)という点かなと思います。APIクライアントの生成を手動でやるのがちょっと面倒ですが、サーバ側で定義したモデル定義を元に、Typescriptで型チェックできるのはなかなか便利だと思います。
具体的なコードサンプル
もう少し具体的にサンプルコードで説明してみようと思います。
Firestoreのモデル定義
こんな感じのモデル定義をすると
import fsglue
TAGS_SCHEMA = {
"type": "array",
"items": {
"type": "string",
},
}
class User(fsglue.BaseModel):
COLLECTION_PATH = "users"
COLLECTION_PATH_PARAMS = []
name = fsglue.StringProperty(required=True)
tags = fsglue.JsonProperty(schema=TAGS_SCHEMA, default=[])
created_at = fsglue.TimestampProperty(auto_now=True)
updated_at = fsglue.TimestampProperty(auto_now_add=True)
User.to_schema()
で以下のようなJsonSchemaの定義を生成できます。
{
"type": "object",
"required": [
"name",
"owner"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"created_at": {
"type": "number"
},
"updated_at": {
"type": "number"
}
}
}
flasggerにモデル定義を登録
User.to_schema()
の実行結果を flasgger に渡してエンドポイント毎の定義で参照できるようにします
from flasgger import Swagger
from xxx import models # モデル定義
from xxx import app # flaskのapp
template = {
"swagger": "2.0",
...中略...
"definitions": {
"User": models.User.to_schema(), # モデルを定義
...中略...
},
...中略...
}
swagger = Swagger(app, template=template)
OpenAPIの定義
例えば、ある組織に所属しているユーザの一覧を取得するAPIを以下のようなイメージで実装します
from flask import Blueprint
from flasgger.utils import swag_from
app = Blueprint("user", __name__, url_prefix="/api/v1")
@app.route('/organization/<org_id>/user/', methods=['GET'])
@swag_from({
"operationId": "getUserList",
"summary": "getUserList",
"parameters": [
{"name": "org_id", "in": "path", "type": "string", "required": "true"},
],
"responses": {
"200": {
"description": "users",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/User", # flasggerに登録済のモデルを参照
}
},
}
},
"tags": ["user"],
})
@auth.xxx_required # 権限チェック系のdecorator
def list_user(org_id, **kwargs):
users = User.all(org_id, to_dict=True)
return jsonify(users)
APIクライアントの生成
openapi-generatorをインストールして、開発環境で以下のようなコマンドでAPIクライアントを生成できます(今回は、typescript-fetch
のクライアントを使っています)
# OpenAPI定義のjsonを取得
curl http://localhost:8080/apispec_1.json > ./xxx/apispec.json
# openapi-generaterでAPIクライアントを生成
./node_modules/.bin/openapi-generator \
generate \
-g typescript-fetch \
-o ./yyy/api/ \
-i ./xxx/apispec.json \
--additional-properties modelPropertyNaming=snake_case \ # オプションはお好みで
--additional-properties supportsES6=true \
--additional-properties typescriptThreePlus=true \
--additional-properties disallowAdditionalPropertiesIfNotPresent=false
APIクライアントの利用
以下のようなイメージで、APIクライアントをTypescriptの型の恩恵を受けながら利用できます。(実際に使う時は認証情報の付与や共通処理の実装のために、生成されたApiを直接呼ばずにラッパー経由で呼ぶようにしています)
import { UserApi } from "xxx/UserApi";
const api = new OrganizationApi();
// 以下で getUserList のAPIの引数と、戻り値の型が Typescript で動く
const users = await api.getUserList({ orgId: "test" });
所感
微妙だったところ
JsonSchemaからTypescriptの型変換の細かい挙動
openapi-generatorがJsonSchemaの定義をTypeScriptの型に自動的に変換してくれるのですが、細かいところで微妙にかゆいところに手が届かないというのは何箇所かありました。
仕様的に完全に互換するものではないので仕方ないといえば仕方ないのですが、具体的にはdependenciesがいい感じに型に変換されなかったり、enumをTypescriptのunion型にできなかったりといったところです。
今回は開発途中でOpenAPI化したので、最初から組み込んでいく場合は、Typescript側の型がいい感じになるようにJsonSchema側の型を調整しながら作っていくと良いのかもしれないです。
openapi-geneator(typescript-fetch)の生成するAPIクライアントの仕様
どのAPIでも共通な処理(APIを叩く時のtokenを渡したり、共通のエラーハンドリングを組み込んだり)を組み込むのに、ちょっと苦労しました。生成されたAPIクライアントをそのまま使う分には便利そうに見えるのですが、後から拡張する用のinterfaceがあまり用意されていないように感じたので、今後の開発に期待、といったところでしょうか。
便利なところ
サーバからフロントまで1つの定義を使える
サーバ側でスキーマの追加や修正を行ったときに、Typescriptの型チェックでフロントエンド側の影響まである程度見えるので、その点は開発スピードの向上につながったように感じます。サーバ側とフロント側の実装の整合性のチェックはよくある地味にめんどくさい作業なので、そこを軽減できるのはなかなかいいですね
型があるけど柔軟
Firestore自体はスキーマレスなので、既存のデータと競合しない変更や、インデックス周りに影響しない修正は、モデル定義を修正するだけで、開発を進められるので、Firestoreのスキーマレスの恩恵を受けつつ、型チェックによるバグの早期発見の恩恵も受けられる、という良い感じの仕組みになっているような気がします。
(大規模開発には正直向かない気はするのですが、まぁそういう場合はそもそもFirestore使わない気はします)
OpenAPIのエコシステムが使える
今回はあまり活用していないのですが、OpenAPIで整備されているエコシステムが使えるようになるので、うまく組み込めば以下のような活用方法もありそうです
- APIドキュメントの自動生成
- リクエストやレスポンスのValidation
- テスト用のMock/Stubの生成
さいごに
ローコードなアプリ開発基盤Bizglueってサービスを個人的に開発しています。ぜひ使ってみてください!