- 本検証で利用した製品
Spring Boot v3.0.2
APIドキュメントを先に書くのか、先にコードを書くのか?について色々と意見あります。
個人的にはドキュメントを書いて、整合性を保ったコードを書くのが現実的に難しいので、先にJava側で設計を固めてからの自動生成アプローチがよいと思っています。
- RESTコントローラの作成
文字列"HelloWorld"を返すだけの簡単なコントローラ
package com.example.demo;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(path="/")
public class TestController {
@RequestMapping(path="/hello", method=RequestMethod.GET)
public String hello() {
return "HelloWorld";
}
}
springdocのセットアップ
pom.xmlを下記のように編集。
dependencyを追加。
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.0.2</version>
</dependency>
追加後のpom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.2</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.0.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.properties
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
再起動後、上記で設定したパスでドキュメントをダウンロードしたり、ブラウザで見えるようになる。
HTMLで成形されたドキュメントも提供されており、この画面でAPIのテストができるようになっている。
「Try it out」ボタンがテスト用。
以上がコードからAPIドキュメント自動生成のやり方。
pom.xmlに4行追加し、application.propertiesに2行追加しただけです。
次に、生成されたAPIドキュメントからAPIクライアントのソースコード自動生成のやり方。
openapi-generator-cli-xxx.jarをダウンロードし、javaコマンド一つでソースコードが自動生成される。
java -jar openapi-generator-cli-6.3.0.jar generate -i ./api-docs.yaml --api-package com.example.demo --model-package com.example.demo --invoker-package com.example.demo --group-id com.example --artifact-id demo --artifact-version 0.0.1-SNAPSHOT -g java -p java8=true --library resttemplate -o spring-openapi-generator-api-client
自動生成されたコードにはUNITテスト用のコードも含まれていた。
srcフォルダ内のソース一式をAPIクライアント用プロジェクトのsrcフォルダへコピーする。
APIをコールする側のソースコード
package com.example.demo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping(path="/")
@RequiredArgsConstructor
public class TestController {
@Autowired
private TestControllerApi testApi;
@RequestMapping(path="/apiclient", method=RequestMethod.GET)
public String hello() {
String result = testApi.hello();
return result;
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
上記のようにAPIで返すHelloWorldをそのまま表示できた。
APIとAPIクライアントで異なるポートで起動するためのSpring Bootの設定
application.properties
server.port=8081
APIクライアント側のpom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.2</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>apiClient</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>apiClient</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>17</java.version>
</properties>
<url>https://github.com/openapitools/openapi-generator</url>
<scm>
<connection>scm:git:git@github.com:openapitools/openapi-generator.git</connection>
<developerConnection>scm:git:git@github.com:openapitools/openapi-generator.git</developerConnection>
<url>https://github.com/openapitools/openapi-generator</url>
</scm>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
</dependency>
<!-- HTTP client: Spring RestTemplate -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<!-- JSON processing: jackson -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.jaxrs</groupId>
<artifactId>jackson-jaxrs-json-provider</artifactId>
</dependency>
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>jackson-databind-nullable</artifactId>
<version>0.2.5</version>
</dependency>
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-core</artifactId>
<version>6.3.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
package com.example.demo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
public class TestIntegrationConfig {
@Bean
@Primary
public TestControllerApi petApi() {
return new TestControllerApi(apiClient());
}
@Bean
public ApiClient apiClient() {
return new ApiClient();
}
}
Spring BootのMainクラス
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
@SpringBootApplication
@Import(TestIntegrationConfig.class)
public class ApiClientApplication {
public static void main(String[] args) {
SpringApplication.run(ApiClientApplication.class, args);
}
}
ここまでの手順にたどり着くまでに相応の時間を要したが、2つ目以降のAPIについては
■APIの作成(業務ロジックの複雑度による)
■APIドキュメントの作成(一瞬でコードとの乖離のないものが完成)
■コードgeneratorの実行と生成されたコードのコピー(数分)
■@Configurationの作成(数分)
■API呼び出し部分のコード(method呼び出しだけなので数分)
実案件ではもっとスマートなやり方を確立するのでAPIをコールしたり、コンポジットする部分ただのmethod呼び出しであるため、API作成やテストに要する時間に対して誤差程度のものと評価できる。
某商用製品のようにGUIでポチポチやるよりも生産性は高い。
最後におまけ
API側のRESTコントローラの負荷試験結果(18159TPS)
APIクライアントからAPI呼び出し一回の負荷試験結果(5401TPS)
API呼び出し3回にしたときの負荷試験結果(2873TPS)
某商用製品の推奨する3階層アーキテクチャどおりに実装すると性能がでなくなるリスクがある。CPUを足せばコストにはねるので注意したい。
おまけ(少し複雑なケース)
ソース
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(path = "/")
public class TestController {
@RequestMapping(path = "/hello", method = RequestMethod.GET)
public String hello() {
return "Hello!";
}
@GetMapping("/hello/{name}")
public String hello(@PathVariable String name) {
return "Hello! " + name;
}
@RequestMapping(path = "/array", method = RequestMethod.GET)
public String[] array() {
String[] result = new String[2];
result[0] = "aaaa";
result[1] = "bbbb";
return result;
}
@RequestMapping(path = "/arrayvo", method = RequestMethod.GET)
public TestVO[] arrayvo() {
TestVO[] result = new TestVO[2];
TestVO testVO1 = new TestVO();
testVO1.setId("id1");
testVO1.setName("name1");
TestVO testVO2 = new TestVO();
testVO2.setId("id2");
testVO2.setName("name2");
result[0] = testVO1;
result[1] = testVO2;
return result;
}
}
api-docs
{
"openapi": "3.0.1",
"info": {
"title": "OpenAPI definition",
"version": "v0"
},
"servers": [
{
"url": "http://localhost:8080",
"description": "Generated server url"
}
],
"paths": {
"/hello": {
"get": {
"tags": [
"test-controller"
],
"operationId": "hello",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/hello/{name}": {
"get": {
"tags": [
"test-controller"
],
"operationId": "hello_1",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/arrayvo": {
"get": {
"tags": [
"test-controller"
],
"operationId": "arrayvo",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TestVO"
}
}
}
}
}
}
}
},
"/array": {
"get": {
"tags": [
"test-controller"
],
"operationId": "array",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"TestVO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
}
}
}
JUnitのテストコード自動生成
/*
* OpenAPI definition
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: v0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
package com.example.demo;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestClientException;
import static org.junit.Assert.assertEquals;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* API tests for TestControllerApi
*/
class TestControllerApiTest {
private final TestControllerApi api = new TestControllerApi();
/**
*
*
*
*
* @throws RestClientException
* if the Api call fails
*/
@Test
void helloTest() {
String response = api.hello();
assertEquals("Hello!", response);
}
}