API実装をテンプレートから生成することによる開発効率化

2024/07/03

API実装をテンプレートから生成することによる開発効率化

REST APIの開発において、単純なCRUDのAPIの開発だけでも以下の実装が必要となります。

  1. テーブル定義に基づくマイグレーションファイル作成
  2. リクエストパラメータ・ボディを表すクラスの定義と、それに対するvalidationの定義
  3. DBからの読み込み、永続化処理(必要に応じて悲観ロック・楽観ロックを適用する)
  4. レスポンスを表すクラス定義

作業自体は単純なのですが、工数は意外にかかるのと、validationルールの間違いを始めとするバグが発生します。 また、画面設計で必然的にエンティティが持つプロパティとそのvalidationは定義します。 そこで、スプレッドシートに自動生成を前提として項目定義を作成し、それを元にAPI実装のベースを作成することとしました。 Railsのscaffoldに似たようなイメージなのですが、スキーマを元にしている分、Railsのscaholdよりも充実した内容となります。

PythonのJinjaでテンプレートを作成しました。最初はマスタだけだったのでKotlinで作成したのですが、静的型付け言語だとやはりテンプレートライブラリを使っても記述工数が高かったです。 その一方で、弊社のバックエンドはKotlinなのですが、コンパイル言語なので生成したコードのコンパイル時にエラーが出るメリットがあります。 テンプレートによる自動生成なので、そのまま動かないケースや仕様に合わせて修正する必要があります。 TypeScriptでしたらts-morphなどを使うとより正確なコードが生成できるかと思います。

具体例

以下はmigrationファイルの例です。

CREATE TABLE {{ entity.entity_name.snake_case }} (
    tenant_id TEXT NOT NULL REFERENCES tenant(tenant_id),
{% for column in entity.sql_columns %}
    {{ column.generate_column() }}{% if not loop.last %},{% endif %}

{% endfor %}
);

migrationファイルに限らず、基本的にエンティティとそのプロパティで表現できるので、 それを表すクラスは以下となります。

クラス

from models import entity
from table_sql_generator.columns import SqlColumn

class SqlEntity(entity.Entity):
    def __init__(self, entity: entity.Entity):
        self.entity_name = entity.entity_name
        self.logical_name = entity.logical_name
        self.entity_type = entity.entity_type
        self.sql_columns = []

    def append_column(self, column_name: str, logical_name, column_type: str, column_format: str, constraint: str, is_required: bool, is_editable: bool, min: int | None, max: int | None, target_table_name: str | None = None, relation_type: str | None = None):
        self.sql_columns.append(SqlColumn(self.logical_name, column_name, logical_name, column_type, column_format, constraint, is_required, min, max, target_table_name, relation_type))

プロパティ

from constants import constants
from models import column

class SqlColumn(column.Column):
    def generate_column(self) -> str:
        if self.is_pk:
            return 'id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY'
        if self.is_fk:
            if self.is_required:
                column_statement = f'{self.column_name.snake_case} BIGINT NOT NULL REFERENCES {self.__generate_reference_table_name()}(id)'
            else:
                column_statement = f'{self.column_name.snake_case} BIGINT REFERENCES {self.__generate_reference_table_name()}(id)'
            if self.relation_type == 'one_to_one':
                return f'{column_statement} UNIQUE'
            return column_statement
        sql_type = self.__generate_sql_type()
        initial_value = self.__generate_initial_value()
        if self.base_kotlin_type == 'String':
            return f'{self.column_name.snake_case} {sql_type} NOT NULL DEFAULT {initial_value}'
        elif self.is_required:
            if initial_value:
                return f'{self.column_name.snake_case} {sql_type} NOT NULL DEFAULT {initial_value}'
            else:
                return f'{self.column_name.snake_case} {sql_type} NOT NULL'
        else:
            return f'{self.column_name.snake_case} {sql_type}'

    def __generate_reference_table_name(self):
        if self.target_table_name.snake_case == 'user':
            return '"user"'
        else:
            return self.target_table_name.snake_case
    def __generate_sql_type(self):
        return constants.sql_type_map.get(self.column_type)

    def __generate_initial_value(self):
        if self.base_kotlin_type == 'String':
            return "''"
        if self.base_kotlin_type == 'Boolean':
            return 'FALSE'
        if self.column_name.snake_case == 'version':
            return '1'
        return None

ポイントは、プロジェクトのAPI量産段階までをカバーするのを目的としてずっとメンテナンスしようと思わないことと、 80点くらいを目指すことです。 プロジェクトの後半になると単純なAPIの作成は減り、一方で既存のAPIに対する仕様調整や技術の蓄積による修正余地が発生します。 その段階でもツールをメンテナンスしようとすると、メンテナンスコストがツールによる工数削減メリットを上回る可能性があります。 また、単純なCRUDといえど自動生成しづらい部分は存在するので、そこも自動生成しようとしてもツールのメリットを上回ってしまいます。 例えばAPIの階層は2段階までに限定しています。3段階にすると一気にテンプレートの複雑さが増すのと、2段階で作ったものでもそれを拡張して実装するほうが何もないよりは効率的だからです。 導入した結果、レビューコストの削減なども含めて、1APIあたりの工数は半分以下になりました。