typescript-generatorを利用して、HTML に埋め込んだ JSON データをフロントエンドで型安全に扱う

こんにちは!kintone フロントエンドリアーキテクチャチーム (フロリア) の西川 @nissy_dev です。

最近のフロントエンド開発で導入されることの多い TypeScript ですが、開発期間の長いプロジェクトに導入する際にはバックエンドとの結合部分の型定義をどう扱うかが問題になる場合があると思います。

今回の記事では、フロリアで取り組み始めた、HTML に埋め込んだ JSON データをフロントエンドで型安全に扱う施策について紹介します。 フロリアのプロジェクトの詳細については、 @koba04 が書いた次の記事をご覧ください。

blog.cybozu.io


目次


フロリアでのバックエンドからのデータ取得

フロリアで刷新を進めている画面のフロントエンドは、バックエンド側でルーティングを行う Multi Page Application となっており、フロントエンドがバックエンドからデータを取得する方法については次の 2 つが存在しています。

  • API を作成し、フロントエンドから API を呼び出す
  • テンプレートエンジンを利用して HTML に JSON データを埋め込み、フロントエンドが参照する

フロントエンドがバックエンドからデータを取得する方法
フロントエンドがバックエンドからデータを取得する方法

フロリアでは、HTML に JSON データを埋め込む方法を多く採用しています。 これは、既存の画面においても HTML に JSON データを埋め込む方法が多く採用されており、バックエンドの実装を再利用できる場面が多いためです。 フロリアに参加しているエンジニアの多くはフロントエンドを得意としており、Java で API を実装するのはコストが高いと判断しました。1

HTML に JSON データを埋め込む方法の問題点

kintone では FreeMarker Template という Java ベースのテンプレートエンジンを利用して HTML に JSON データを埋め込みます2

<script>
  globalData.samplePageData = ${json(samplePageData)} // Javaで生成されたデータをJSONにシリアライズ
</script>

フロントエンドからは、globalData.samplePageData のように参照します。 フロリアで刷新している画面は TypeScript で実装されており、globalData.samplePageData などのデータに適切な型をアノテーションする必要がありました。 初めは、各データに対応する Java のクラスを確認しながら手作業でアノテーションを行っていましたが、次第に次のような問題が生じるようになりました。

  • Spring Framework と FreeMarker Template をベースとしたバックエンドの実装を理解するコストが高い
  • バックエンド変更時における型定義への影響範囲を把握するのが難しい
  • 埋め込まれるデータが複雑なケースもいくつか存在し、付与するアノテーションを誤ってしまう可能性がある
  • Java は null 安全でないため、ある型で定義された変数に null が代入される可能性がある

これらの問題点を踏まえて、今後も多くの画面を刷新していく必要があることから、Java のクラスから TypeScript の型定義を自動で生成する方法を調査することになりました。

Java のクラスから TypeScript の型定義を生成できる typescript-generator

調査をすすめると、Java のクラスから TypeScript の型定義を生成できる typescript-generator というツールが見つかりました。

github.com

たとえば、次のような JSON シリアライズ可能な Java のクラスがあったとします。

public class User {
    private int id;
    private String name;
}

このような Java のクラスに対して typescript-generator を使えば、 TypeScript のインターフェイスを自動生成できます。

interface User {
  id: number;
  name: string;
}

Java のプリミティブ型以外にも、DateListEnum など、基本的な型を一通りサポートしています。 さらに、ツールのパラメータ一覧を見てみると、出力ファイルの名前の設定から独自定義による型変換まで、非常に柔軟な設定が可能です。 現時点ではライブラリのメンテナンスも比較的行われている3ことから、フロリアでも PoC として導入してみることにしました。

typescript-generator の導入

kintone では Java のビルドツールに Maven を使っており、typescript-generator が提供している Maven プラグインを利用できます。 kintone のビルド環境へ適用する際には、pom.xml に次のようなプロファイルを追加し、型生成に関する設定をオプトインできるようにしました。

<profile>
    <id>typescript-generator</id>
    <build>
        <plugins>
            <plugin>
                <groupId>cz.habarta.typescript-generator</groupId>
                <artifactId>typescript-generator-maven-plugin</artifactId>
                <version>2.35.1025</version>
                <executions>
                    <execution>
                        <id>generate</id>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                        <phase>process-classes</phase>
                    </execution>
                </executions>
                <configuration>
                    ...  // パラメータの設定を追加していく
                </configuration>
            </plugin>
        </plugins>
    </build>
</profile>

型生成は Maven の process-classes のフェーズで実行されるので、型生成をする場合には mvn process-classes -P typescript-generator を実行します。 typescript-generator の細かいパラメータの設定については、ドキュメントで丁寧に解説されているので、すでに紹介したパラメータ一覧を確認するのがおすすめです。 ここでは、kintone のコードへ typescript-generator を適用した際に工夫した点について紹介します。

プロパティに readonly を付与する

今回の型生成の対象となるデータについては、フロントエンドでは参照のみをしたい場合が多いです。 このような場合には、declarePropertiesAsReadOnly のパラメータを利用し、生成されるインターフェイスのプロパティに readonly がつくようにします。 デフォルトでは false なので、利用した際には true の設定を加えました。

<declarePropertiesAsReadOnly>true</declarePropertiesAsReadOnly>

Java の DateTime 型を TypeScript の string 型に変換する

kintone のバックエンドでは、日付データに java.timeJoda Time などを利用していますが、フロントエンドでは JSON にシリアライズされた文字列のデータを参照します。 このような場合には、mapDate のパラメータを利用し、Java の日付データの型を TypeScript の string 型に変換します。 デフォルトでは asDate なので、利用した際には asString の設定を加えました。

<additionalDataLibraries>joda</additionalDataLibraries>
<mapDate>asString</mapDate>

Java が null 安全でないことへの対応

Java は、ある型で定義された変数に null が代入できるため、型変換の際にはこの null をどう扱うかが問題になります。 今回の型生成の対象となるデータに関しても、多くの場合で次のような Lombok で定義されたクラスになっており、プロパティが null になるケースもいくつか確認できました。

import lombok.Setter;
import lombok.Getter;

@Setter
@Getter
public class SamplePageData {
    private String data1;
    private String data2; // ランタイムで null が代入される可能性がある
    ...
}

今回の導入では、フロントエンドとバックエンドのデータのやり取りの不具合をなるべく減らしたかったことから、プロパティを基本的に nullable になるように変換しました。 また、@NotNull などの null チェックやバリデーションに関するアノテーションがついている場合には、例外として nullable にならないように変換しました。

この方針をパラメータに直すと次のようになります。 optionalPropertiesDeclaration は、requiredAnnotations で指定されたアノテーションがついてないすべてのプロパティを optional とするパラメーターです。 optional の方法にはいくつかあるのですが、ここでは nullable になるように変換しています。

<optionalPropertiesDeclaration>nullableType</optionalPropertiesDeclaration>
<requiredAnnotations>
    <annotation>lombok.NotNull</annotation>
        ... // nullable にしたくない場合のアノテーションを追加する
</requiredAnnotations>

カスタムシリアライザへの対応

今回の型生成の対象となるデータの中には、Jackson の StdSerializer を拡張したカスタムシリアライザを適用しているケースがありました。 このカスタムシリアライザは、シリアライズ過程でプロパティの型を変換する処理を行っており、この処理に対応した型を生成する必要があります。 そこで、カスタムシリアライザの処理に対応した typescript-generator 向けの独自拡張を実装しました。

typescript-generator は、型変換の際に拡張を適用できるシステムを持っており、メンテナーが実装したいくつかの拡張があります。 これらの実装を参考にしながら、今回の拡張についても実装しました。 実装した拡張については、次のような設定を追加することで利用できます。

<extensions>
    <extension>xxx.yyy.zzz.SampleExtension</extension>
      ... // 利用したい拡張のクラスを追加する
</extensions>

ここでは実装の詳細については触れませんが、次のリポジトリで拡張のサンプルを実装してみたので、参考にしてもらえればと思います。

github.com

生成された型定義の利用方法

今まで紹介した設定を適用した型変換を、次のような JSON データのクラスに実施します。

import lombok.Setter;
import lombok.Getter;
import lombok.NotNull;
import org.joda.time.DateTime;
import xxx.yyy.zzz.CustomSerializer; // List<int> を List<String> に変換するようなシリアライザだとする

@Setter
@Getter
public class SamplePageData {
    private String data1;
    @NotNull
    private Integer data2;
    private DateTime data3;
    @JsonSerialize(using = CustomSerializer.class)
    private List<Integer> data4;
    ...
}

すると、次のような TypeScript のインターフェイスが生成されます。

export interface SamplePageData {
  readonly data1: string | null;
  readonly data2: number;
  readonly data3: string | null;
  readonly data4: Array<string> | null;
  ...
}

そして、上記のインターフェイスをグローバル変数の型定義に利用することで、HTML に埋め込んだ JSON データをフロントエンドからも安全に扱うことができました。

import type { SamplePageData } from ".....";

declare namespace globalData {
  readonly samplePageData: SamplePageData;
}

導入して感じたメリットとデメリット

今回の導入における typescript-generator のメリットとデメリットをまとめてみます。

👍 メリット

  • フロントエンド側の型定義を自動生成することによる実装コストの削減
  • Java のコードから生成される型なので、手動でアノテーションするより安全
  • 豊富なパラメータや拡張を適用できるシステムにより、柔軟な型生成が可能

🙈 デメリット

  • フロントエンドとバックエンドとのインタフェース部分は、Java クラスでデータモデル化しておく必要がある
  • 生成される型が基本的に nullable であるため、フロントエンド側でバリデーションを実装するコストがかかる
  • Java のビルド環境に依存したツールであり、フロントエンドのビルド環境に組み込むことが難しい

このように、型生成を自動化できることによるメリットはありますが、バリデーションの実装コストなども考慮して導入する必要があると感じました。

まとめ

今回の記事では、HTML に埋め込んだ JSON データをフロントエンドで型安全に扱うために導入した typescript-generator を紹介しました。 開発期間の長いプロジェクトでは、Open API や gRPC などを導入することが難しく、typescript-generator のような型生成のツールが役に立つこともありそうです。 同様の悩みを持っているプロジェクトがあれば、 Java 以外の言語でも typescript-generator のようなツールがある4ので、今回の記事が参考になれば幸いです。


  1. これはプロジェクト初期の判断であり、今後はバックエンドに詳しいエンジニアも巻き込んで API を実装していくことも検討しています。

  2. 現在の埋め込み方については、グローバル変数を利用する点にも改善の余地があるという認識です。フロリアとしては、data 属性や script タグの type="application/json" などを利用した方法へのリファクタを検討しています。

  3. GitHub のリリース頻度から判断しました。主なメンテナーはライブラリの作者しかいないので、定期的にメンテナンスの状態は確認したほうが良さそうです。

  4. 例えば、PHP の場合には typescript-transformer が見つかりました。