🌿

Web フロントエンド(TypeScript) と BFF(Spring Boot) をタイプセーフに繋ぐ 2022

2022/08/22に公開

はじめに

本記事の目的は、Web フロントエンド(TypeScript) と BFF(Spring Boot) をタイプセーフに繋ぐ方法を紹介することです。

以前、以下の記事を書きました。

https://qiita.com/chibato/items/e4a748db12409b40c02f

この記事からの変更点は、次の2点です。

  • OpenAPI Spec を出力する Springfoxspringdoc-openapi に変更しています。Springfox はメンテナンスされなくなっているようです。
  • 生成するコードが Angular 用のクライアントでしたが、より汎用的な fetch のクライアントに変更しています。サンプルコードも React に変更しています。

ざっくり説明

まずは、以下のような Spring Boot(BFF)のコードを作成します。

@RestController
public class AppController {
  @GetMapping("/greet")
  public ResponseEntity<String> greet(@RequestParam String name) {
    return ResponseEntity.ok("Hello " + name);
  }
}

自動でこの greet メソッドを呼び出す TypeScript のクライアントコードが生成されます。
TypeScript 側で自動生成されたコードを以下のように利用できます。

const api = new AppControllerApi(new Configuration({ basePath: window.location.origin }));
const greeting = await api.greet("springdoc-openapi");
console.log(greeting); // Hello springdoc-openapi

ビルドプロセス

以下がビルドプロセスのイメージです。

screen

  1. springdoc-openapi が Spring Boot の Java コードから OpenAPI の Spec ファイル(openapi.json)を生成します。
  2. OpenAPI Generator が openapi.json から TypeScript のクライアントコードを生成します。

以降、詳細に解説します。

作ったもの

https://github.com/chibat/react-spring

サンプルアプリの起動

前提条件

以下がインストールされている必要があります。

  • JDK: 17+
  • Node.js: 16+

サンプルアプリのレポジトリを fork し Codespaces や VS Code の Remote Container で開けばこの環境になります。

ローカル環境でのアプリケーションの起動

まずはサンプルアプリのレポジトリを clone します。

$ git clone https://github.com/chibat/react-spring.git
$ cd react-spring

任意の IDE で Spring Boot アプリケーションの起動を実行します。
Gradle から実行する場合は、以下のコマンドになります。

./gradlew bootRun

次に開発用フロントエンドサーバの起動します。

$ cd frontend
$ npm install
$ npm start

Web ブラウザで http://localhost:3000/ にアクセスします。

以下のような画面が表示され、二つの数字を入力し、「=」ボタンをクリックすると足し算の計算結果が表示されます。

screen

本番用ビルドと起動

$ ./gradlew bootJar
$ java -jar build/libs/react-spring-0.0.1-SNAPSHOT.jar

コードの解説

BFF(Spring Boot) のコード

src/main/java/app/model/Request.java

以下は、フロンエンドからのリクエストを受け取るクラスです。Java 16 で追加された record を利用しています。二つの整数を格納します。

package app.model;

public record Request(int arg1, int arg2) {
}

src/main/java/app/model/Response.java

以下は、フロントエンドへのレスポンスを格納クラスです。同様に record を利用しています。

package app.model;

public record Response(int result) {
}

src/main/java/app/AppController.java

以下は、リクエストを受け取りレスポンスするコントローラクラスです。ここで足し算の計算をしています。

package app;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import app.model.Request;
import app.model.Response;

@RestController
public class AppController {

  @PostMapping("/add")
  public ResponseEntity<Response> add(@RequestBody Request request) {
    var result = request.arg1() + request.arg2();
    var response = new Response(result);
    return ResponseEntity.ok(response);
  }
}

メソッド名の add は、OpenAPI Spec の operationId として扱われ、生成される TypeScript のコードのメソッド名も add になります。

src/test/java/app/OpenApiDocsGenerator.java

以下は、OpenAPI Spec のファイルを生成するテストクラスです。
build ディレクトリ配下に openapi.json という名前で spec ファイルの生成を行います。

package app;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.io.BufferedWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultHandler;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@SpringBootTest(classes = Application.class)
public class OpenApiDocsGenerator {
  @Autowired
  private WebApplicationContext context;

  private MockMvc mockMvc;

  @BeforeEach
  public void setUp() {
    this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context).build();
  }

  @Test
  public void convertSwaggerToAsciiDoc() throws Exception {
    this.mockMvc.perform(get("/v3/api-docs").accept("application/json;charset=UTF-8"))
        .andDo(new Handler()).andExpect(status().isOk());
  }

  public static class Handler implements ResultHandler {

    private final String outputDir = "build";
    private final String fileName = "openapi.json";

    @Override
    public void handle(MvcResult result) throws Exception {
      MockHttpServletResponse response = result.getResponse();
      String swaggerJson = response.getContentAsString();
      Files.createDirectories(Paths.get(outputDir));
      try (BufferedWriter writer =
          Files.newBufferedWriter(Paths.get(outputDir, fileName), StandardCharsets.UTF_8)) {
        writer.write(swaggerJson);
      }
    }
  }
}

springdoc-openapi は、OpenAPI Spec のファイルを出力するための機能を gradle plugin, maven plugin で提供しています。
このプラグインを利用しなかった理由は二つあります。
一つは、このプラグインを利用するとアプリケーションを起動させるためビルド時にポートを listen してしまいます。この挙動は都合が悪いケースがあると思います。
もう一つは実行時に不要な jar の依存が増える点です。
この2点を避けるため、テストコードにより openapi.json を生成するという手法を使っています。

build.gradle

ビルドは Gradle を利用しています。
説明は、コード上のコメントを参照してください。

plugins {
  id 'org.springframework.boot' version '2.7.2'
  id 'io.spring.dependency-management' version '1.0.12.RELEASE'
  id 'java'

  // ビルドで OpenAPI Generator を使用するための Gradle Plugin です。
  // openApiGenerate タスクが利用できるようになります。
  id 'org.openapi.generator' version '6.0.0'

  // ビルドで npm を使用するための Gradle Plugin です。
  id "com.github.node-gradle.node" version "3.4.0"
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'

  // テストコードで springdoc-openapi を利用するための依存です。
  // https://springdoc.org/#spring-webmvc-support
  testImplementation 'org.springdoc:springdoc-openapi-webmvc-core:1.6.9'
}

tasks.named('test') {
  useJUnitPlatform()
}

// OpenAPI Generator によって生成したコードを格納するディレクトリです。
def OPEN_API_GENERATE_DIR = "${project.rootDir}/frontend/src/generated"

// springdoc-openapi によって生成される Spec ファイルのパスです。
def OPEN_API_DOCS = "$buildDir/openapi.json"

// フロントエンドのルートディレクトリです。
def FRONTEND_DIR = "${project.rootDir}/frontend"

// OpenAPI Generator の設定です。
// https://github.com/OpenAPITools/openapi-generator/tree/master/modules/openapi-generator-gradle-plugin#configuration
openApiGenerate {
  generatorName = "typescript-fetch"
  inputSpec = OPEN_API_DOCS.toString()
  outputDir = OPEN_API_GENERATE_DIR.toString()
  configOptions = [
    // https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/typescript-fetch.md
    useSingleRequestParameter: "false"
  ]
}

node {
  download = true
  version = "16.14.2"
  nodeProjectDir = file(FRONTEND_DIR)
}

// openapi.json を生成するタスクです。
task generateOpenApiDocs(type: Test, dependsOn: testClasses) {
    useJUnitPlatform()
    inputs.files fileTree("${project.rootDir}/src/main/java")
    outputs.file file(OPEN_API_DOCS)
    filter {
        includeTestsMatching "app.OpenApiDocsGenerator"
    }
}

// フロンエンドをビルドするタスクです。
task compileFrontend(type: NpmTask) {
  //inputs.files fileTree("${FRONTEND_DIR}/src")
  inputs.files fileTree("${FRONTEND_DIR}")
  outputs.dir file("${project.rootDir}/build/resources/main/static")
  args = ['run', 'build']
}

// openApiGenerate タスクの実行の前に generateOpenApiDocs, cleanOpenApiGenerate タスクを実行します。
tasks.openApiGenerate.dependsOn([generateOpenApiDocs, cleanOpenApiGenerate])

// compileFrontend タスクの実行の前に npm_ci, tasks.openApiGenerate タスクを実行します。
compileFrontend.dependsOn([npm_ci, tasks.openApiGenerate])

// bootJar タスクの実行の前に compileFrontend を実行します。
bootJar.dependsOn compileFrontend

自動生成されたAPIクライアント(TypeScript)のコード

ディレクトリ frontend/src/generated 配下に生成されます。

$ ls -R
.:
apis  index.ts  models  runtime.ts

./apis:
AppControllerApi.ts  index.ts

./models:
index.ts  Request.ts  Response.ts

フロントエンド(TypeScript)のコード

frontend/src/App.tsx

説明は、コード上のコメントを参照してください。

import { useState } from 'react';
import { AppControllerApi, Configuration } from './generated';

// 自動生成されたクラスのインスタンスを生成
const api = new AppControllerApi(new Configuration({ basePath: window.location.origin }));

export default function App() {

  const [arg1, setArg1] = useState<string>("");
  const [arg2, setArg2] = useState<string>("");
  const [result, setResult] = useState<number>();

  async function add() {
    if (!arg1 || !arg2) {
      return;
    }

    // 自動生成されたコードを利用し、BFF にリクエスト
    const response = await api.add({ arg1: Number(arg1), arg2: Number(arg2) });
    setResult(response.result);
  };

  return (
    <>
      <input type="text" value={arg1} onChange={(e) => setArg1(e.target.value)} autoFocus />
      +
      <input type="text" value={arg2} onChange={(e) => setArg2(e.target.value)} />
      <input type="button" value=" = " onClick={add} />
      <span>{result}</span>
    </>
  );
}

frontend/package.json

説明は、コード上のコメントを参照してください。

{
    :
    :
  "scripts": {

    // 開発用フロンエンドサーバの起動の前に API クライアントのコード生成をしています
    "start": "npm run gen && react-scripts start",

    "build": "react-scripts build",

    // API クライアントのコードを生成するタスクです。
    "gen": "cd .. && ./gradlew openApiGenerate"
  },
    :
    :
  // 開発用フロンエンドサーバへのリクエストを BFF 側にフォワードする設定です
  "proxy": "http://localhost:8080"
}

frontend/.env

本番用のビルド用にAPIクライアントのコードを出力するディレクトリを指定しています

BUILD_PATH=../build/resources/main/static

おわりに

Web フロントエンド(TypeScript) と BFF(Spring Boot) をタイプセーフに繋ぐ方法を紹介しましたが、実はもっと素敵に実現してくれているフレームワークがあります。
Hilla というフレームワークですが、以前、以下の記事を書きました。
https://zenn.dev/chiba/articles/hilla-framework

このフレームワークを使う場合、フロントエンドのフレームワークに任意のものを使うことができません。 また Gradle 用のプラグインが提供されていなのも残念なところです。
タイプセーフな通信を実現するにあたり、Hilla は良い選択肢ですが、この記事で紹介した方法も良いんじゃないかと思います。

Discussion