springdoc-openapiでOpenAPI形式のAPIドキュメントを生成する

こんにちは! みなさん、API Spec書いてますか?私はお仕事で、OpenAPI(Swagger)形式のYAMLを書いています!

ソースコードとドキュメントを別にしてしまうと、実装途中で仕様変更になったときにyml書き直すのが手間だったり、APIの実装と合ってない箇所があったりなどの問題が発生してしまいます。(そもそもyml書くの辛いし)

プロダクションコードに手を加えることなくプロダクションコードをベースにOpenAPI形式のAPIドキュメントを吐き出すライブラリを探していました。 いくつかそれっぽいライブラリはあるのですが、プロダクトションコードに設定書かないといけなかったり、テストに設定書かないといけないし(テストコードが全部あるわけじゃない)で要望を完全に満たしてもらえませんでした... 最近、完全に要望を満たしてくれる springdoc-openapi というライブラリを見つけましたので紹介します!!!!!

springdoc-openapiとは

springdoc-openapiは、Spring Bootを利用しているプロジェクトで簡単にOpenAPI形式のAPIドキュメントを生成してくれるライブラリです。 Web MVC, WebFluxともに対応しているようです。

サポートしているライブラリ
- Open API 3
- Spring Boot(v1 and v2)
- JSR-303, specifically for @NotNull, @Min, @Max, and @Size.
- Swagger-ui
- OAuth 2

サンプルコード

今回は、Web MVCでのサンプルを紹介します。

API

SakeController.java

@RestController
@RequestMapping("/sakes")
@RequiredArgsConstructor
public class SakeController {

    private final SakeService sakeService;

    @GetMapping
    public Page<SakeResponse> page(@PageableDefault Pageable pageable) {
        return sakeService.page(pageable);
    }

    @GetMapping("list")
    public List<SakeResponse> list() {
        return sakeService.list();
    }

    @GetMapping("{id}")
    public SakeResponse get(@PathVariable Integer id) {
        return sakeService.get(id);
    }

    @PostMapping
    public SakeResponse create(@RequestBody @Validated SakeCreateRequest request) {
        return sakeService.create(request);
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<ErrorResponse> handleException(NotFoundException e) {
        return ResponseEntity.of(Optional.of(new ErrorResponse(e.getMessage())));
    }
}

SakeResponse.java

@Value
public class SakeResponse {
    Integer id;
    String name;
    String brewingName;

    public static SakeResponse newInstance(Sake sake) {
        return new SakeResponse(sake.getId(), sake.getName(), sake.getBrewingName());
    }
}

SakeCreateRequest.java

@Value
public class SakeCreateRequest {
    @NotNull
    @Size(min = 1, max = 20)
    String name;
    @NotNull
    String brewingName;
}

GlobalControllerHandler.java

@RestControllerAdvice
public class GlobalControllerHandler {

    @ExceptionHandler(RuntimeException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ResponseEntity<ErrorResponse> handleNotFoundException(RuntimeException e) {
        return ResponseEntity.of(Optional.of(new ErrorResponse(e.getMessage())));
    }
}

ただのController周りの実装なので、説明は省略します。

springdoc-openapiの導入

build.gradle

dependencies {
  ...
  
    implementation 'org.springdoc:springdoc-openapi-ui:1.4.6'
    
  ...
}

この1行追加します。

アプリケーションの起動

アプリケーションを起動して、デフォルトで用意されているURLにアクセスすると、それぞれjson, yaml, htmlを返してくれすます。

GET http://localhost:8080/v3/api-docs/

{
    "openapi": "3.0.1",
    "info": {
        "title": "OpenAPI definition",
        "version": "v0"
    },
    "servers": [
        {
            "url": "http://localhost:8080/api",
            "description": "Generated server url"
        }
    ],
    "paths": {
        "/sakes/{id}": {
            "get": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "get",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "integer",
                            "format": "int32"
                        }
                    }
                ],
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/SakeResponse"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/sakes/list": {
            "get": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "list",
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "type": "array",
                                    "items": {
                                        "$ref": "#/components/schemas/SakeResponse"
                                    }
                                }
                            }
                        }
                    }
                }
            }
        },
        "/sakes": {
            "get": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "page",
                "parameters": [
                    {
                        "name": "pageable",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "$ref": "#/components/schemas/Pageable"
                        }
                    }
                ],
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/PageSakeResponse"
                                }
                            }
                        }
                    }
                }
            },
            "post": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "create",
                "requestBody": {
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/SakeCreateRequest"
                            }
                        }
                    },
                    "required": true
                },
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/SakeResponse"
                                }
                            }
                        }
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "ErrorResponse": {
                "type": "object",
                "properties": {
                    "message": {
                        "type": "string"
                    }
                }
            },
            "SakeResponse": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "name": {
                        "type": "string"
                    },
                    "brewingName": {
                        "type": "string"
                    }
                }
            },
            "SakeCreateRequest": {
                "required": [
                    "brewingName",
                    "name"
                ],
                "type": "object",
                "properties": {
                    "name": {
                        "maxLength": 20,
                        "minLength": 1,
                        "type": "string"
                    },
                    "brewingName": {
                        "type": "string"
                    }
                }
            },
            "Pageable": {
                "type": "object",
                "properties": {
                    "offset": {
                        "type": "integer",
                        "format": "int64"
                    },
                    "sort": {
                        "$ref": "#/components/schemas/Sort"
                    },
                    "pageNumber": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "pageSize": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "paged": {
                        "type": "boolean"
                    },
                    "unpaged": {
                        "type": "boolean"
                    }
                }
            },
            "Sort": {
                "type": "object",
                "properties": {
                    "sorted": {
                        "type": "boolean"
                    },
                    "unsorted": {
                        "type": "boolean"
                    },
                    "empty": {
                        "type": "boolean"
                    }
                }
            },
            "PageSakeResponse": {
                "type": "object",
                "properties": {
                    "totalPages": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "totalElements": {
                        "type": "integer",
                        "format": "int64"
                    },
                    "size": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "content": {
                        "type": "array",
                        "items": {
                            "$ref": "#/components/schemas/SakeResponse"
                        }
                    },
                    "numberOfElements": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "sort": {
                        "$ref": "#/components/schemas/Sort"
                    },
                    "first": {
                        "type": "boolean"
                    },
                    "pageable": {
                        "$ref": "#/components/schemas/Pageable"
                    },
                    "last": {
                        "type": "boolean"
                    },
                    "number": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "empty": {
                        "type": "boolean"
                    }
                }
            }
        }
    }
}

GET http://localhost:8080/v3/api-docs.yaml

openapi: 3.0.1
info:
  title: OpenAPI definition
  version: v0
servers:
- url: http://localhost:8080/api
  description: Generated server url
paths:
  /sakes/{id}:
    get:
      tags:
      - sake-controller
      operationId: get
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          format: int32
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/SakeResponse'
  /sakes/list:
    get:
      tags:
      - sake-controller
      operationId: list
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SakeResponse'
  /sakes:
    get:
      tags:
      - sake-controller
      operationId: page
      parameters:
      - name: pageable
        in: query
        required: true
        schema:
          $ref: '#/components/schemas/Pageable'
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/PageSakeResponse'
    post:
      tags:
      - sake-controller
      operationId: create
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SakeCreateRequest'
        required: true
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/SakeResponse'
components:
  schemas:
    ErrorResponse:
      type: object
      properties:
        message:
          type: string
    SakeResponse:
      type: object
      properties:
        id:
          type: integer
          format: int32
        name:
          type: string
        brewingName:
          type: string
    SakeCreateRequest:
      required:
      - brewingName
      - name
      type: object
      properties:
        name:
          maxLength: 20
          minLength: 1
          type: string
        brewingName:
          type: string
    Pageable:
      type: object
      properties:
        offset:
          type: integer
          format: int64
        sort:
          $ref: '#/components/schemas/Sort'
        pageNumber:
          type: integer
          format: int32
        pageSize:
          type: integer
          format: int32
        paged:
          type: boolean
        unpaged:
          type: boolean
    Sort:
      type: object
      properties:
        sorted:
          type: boolean
        unsorted:
          type: boolean
        empty:
          type: boolean
    PageSakeResponse:
      type: object
      properties:
        totalPages:
          type: integer
          format: int32
        totalElements:
          type: integer
          format: int64
        size:
          type: integer
          format: int32
        content:
          type: array
          items:
            $ref: '#/components/schemas/SakeResponse'
        numberOfElements:
          type: integer
          format: int32
        sort:
          $ref: '#/components/schemas/Sort'
        first:
          type: boolean
        pageable:
          $ref: '#/components/schemas/Pageable'
        last:
          type: boolean
        number:
          type: integer
          format: int32
        empty:
          type: boolean

GET http://localhost:8080/swagger-ui.html

これだけで、APIドキュメントが生成されるなんて感動しませんか? Controllerクラスや@RestControllerAdviceが付与されているクラスの@ExceptionHandlerを読み取ってレスポンスに適用してくれます。

@NotNull@NotBlank@Size@Min@Max のような、JSR-303 Bean Validationのアノテーションについても適用してくれます。

カスタマイズ

application.properties に書けば、デフォルト設定を変更できます。設定値一覧は、springdoc-propertiesを参考にしてください。

設定値の抜粋です。値はデフォルト値です。

- JSONのOpenAPIドキュメントのパス
  - springdoc.api-docs.path=/v3/api-docs
- OpenAPIエンドポイントの有効・無効
  - springdoc.api-docs.enabled=true
- `@ControllerAdvice` が付与されたクラスに書かれたレスポンスを全レスポンスに適用するかどうか
  - springdoc.override-with-generic-response=true
- Swagger UIが適用されたHTMLドキュメント表示するパス
  - springdoc.swagger-ui.path=/swagger-ui.html

まとめ

特に設定なしにAPIドキュメントを生成してくるのはとてもよいですね。 気になったところは、タグがController名のケバブケースになってしまうところですが、 @Tag(name="sake") をControllerに付与してあげれば変更は可能でした。 springdoc-openapiでフロント、モバイルチームとのコミュニケーションが円滑に進みそうです!

Spring Cloud OpenFeignで遊ぶ

先日のSpring Oneで、Unleash the True Power of Spring Cloud: Learn How to Customize Spring Cloudで紹介されていたSpring Cloud OpenFeignで遊んでみました。 素敵すぎて、もっと早く知りたかった...と感じました。

Spring Cloud OpenFeignとは

2018年6月にリリースされたSpring BootのためのOpenFeignインテグレーションです。 Spring Cloud OpenFeignを使うと宣言的にRESTクライアントを作成することができます。

サンプルコード

サンプルで使用しているライブラリ等は次の通りです。

Java 11
Maven
Spring Boot 2.3.3.RELEASE
Spring MVC
Spring Cloud OpenFeign 2.2.5.RELEASE

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">
  
  ...

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
            <version>2.2.5.RELEASE</version>
        </dependency>
    </dependencies>
  
  ...

</project>

spring-cloud-starter-openfeignを依存関係に追加してください。

FeignConfig.java

@Configuration(proxyBeanMethods = false)
@EnableFeignClients(basePackages = {"com.b1a9idps.consumer.client"})
public class FeignConfig {}

@EnableFeignClients を付与することでデフォルト設定を使うことができるようになります。

SakeClient.java

@FeignClient(value = "sakes", url = "${producer.url}")
public interface SakeClient {
    @GetMapping(value = "/sakes")
    List<SakeResponse> list();

    @GetMapping(value = "/sakes/{id}")
    SakeResponse get(@PathVariable Integer id);

    @PostMapping(value = "/sakes")
    SakeResponse create(SakeCreateRequest request);
}

クライアントインターフェースを用意します。コントローラを実装する感覚でインターフェースを書くことができます。 インターフェースに @FeignClient を付与して、コンポーネントスキャンの対象にします。

@FeignClientのプロパティ
- url:リクエスト先のURL。
- name(value):任意のクライアント名。RibbonロードバランサーやSpring Cloud LoadBalancerを利用する際に使われる。

SakeController.java

@RestController
@RequestMapping("/sakes")
public class SakeController {

    final SakeClient sakeClient;

    public SakeController(SakeClient sakeClient) {
        this.sakeClient = sakeClient;
    }

    @GetMapping
    public List<SakeResponse> list() {
        return sakeClient.list();
    }

    @GetMapping("{id}")
    public SakeResponse get(@PathVariable Integer id) {
        return sakeClient.get(id);
    }

    @PostMapping
    public SakeResponse create() {
        return sakeClient.create(new SakeCreateRequest("寫樂", "宮泉銘醸"));
    }
}

クライアントをDIしてメソッドコールするだけです。

実行結果

アプリケーションを起動してcurlAPIを叩いた結果です。

$ curl -v http://localhost:8080/api/sakes | jq .
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /api/sakes HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> 
< HTTP/1.1 200 
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Fri, 04 Sep 2020 04:12:38 GMT
< 
{ [172 bytes data]
100   166    0   166    0     0  14628      0 --:--:-- --:--:-- --:--:-- 15090
* Connection #0 to host localhost left intact
[
  {
    "id": 1,
    "name": "若波",
    "brewingName": "若波酒造"
  },
  {
    "id": 2,
    "name": "新政",
    "brewingName": "新政酒造"
  },
  {
    "id": 3,
    "name": "十四代",
    "brewingName": "高木酒造"
  }
]

補足

リクエストログをみたい

logging.level.[clientが置いてあるパッケージ]=debug
feign.client.config.default.logger-level=basic

Spring Boot 1.5.xから2.0.xに上げた Spring Test編

Spring Boot 2系にあげたときにやったことをまとめようと思います。関連記事 今回は、Testingの話です。Spring Boot 2.0.9.RELEASEに上げた話になります。2.2対応はまたいつか書きます。

依存しているライブラリのバージョン

Spring Bootのバージョンを1.5.22から2.0.9に上げると依存しているライブラリのバージョンが次のようになります。

Spring Boot 1.5.22.RELEASESpring Boot 2.0.9.RELEASE

Spring Boot 2.xに上げると、Mockito 2.xになります。 JUnit 5も対応しましたが、対応しただけでデフォルトはJUnit 4になっていますのでご注意ください。Spring Booot 2.2.xからデフォルトがJUnit 5になります。

パッケージ変更

- import org.mockito.runners.MockitoJUnitRunner;
+ import org.mockito.junit.MockitoJUnitRunner;
- import org.mockito.Matchers.*
+ import org.mockito.ArgumentMatchers.*
- org.mockito.Matchers.anyListOf.(Hoge.class);
+ org.mockito.ArgumentMatchers.anyList();

など

不要なスタブの削除

利用していないスタブがある場合は、UnnecessaryStubbingExceptionを投げるようになりました。 行番号を教えてくれるのでひたすら削除します。

例えば、このようなテストを書いていたとします。

String result = translator.translate("one");

// test
when(translator.translate("one")).thenReturn("jeden");
when(translator.translate("two")).thenReturn("dwa");

when(translator.translate("two")).thenReturn("dwa");のスタブは作ったけど、実際呼ばれることがないのでUnnecessaryStubbingExceptionが投げられます。 歴史のあるコードだと消し忘れはいっぱいあると思うので地味に辛いですね...

まとめ

Spring Boot 2.0.xへのテストのマイグレーションは特に難しくはなかったですけど、単なる書き換えが多くて眠かったです。2.1.x, 2.2.xの変更の方が大変です。 ちなみにJUnit 5からHamcrestがデフォルトで入らなくなりましたのでこれを機にAssertJに移行してみてはいかかでしょうか?

Spring Boot 1.5.xから2.0.xに上げた Spring Data編

Spring Boot 2系にあげたときにやったことをまとめようと思います。関連記事 今回は、Spring Data JPAの話です。Spring Boot 2.0.9.RELEASEに上げた話になります。2.2対応はまたいつか書きます。

依存しているライブラリのバージョン

Spring Bootのバージョンを1.5.22から2.0.9に上げると依存しているライブラリのバージョンが次のようになります。

Spring Boot 1.5.22.RELEASESpring Boot 2.0.9.RELEASE

DataSource

コネクションプールの変更

デフォルトのコネクションプールがTomcatからHikariCPになった。

カスタマイズしたDataSourceを利用する際はプロパティに気をつける

コネクションプールにHikariCPを利用してDataSourceをカスタマイズする際、urlでなく、jdbc-urlにする。参考 application.properties

app.datasource.jdbc-url=jdbc:mysql://localhost/test
app.datasource.username=dbuser
app.datasource.password=dbpass
app.datasource.maximum-pool-size=30
@Bean
@ConfigurationProperties("app.datasource")
public HikariDataSource dataSource() {
    return DataSourceBuilder.create().type(HikariDataSource.class).build();
}

X-RaySQLトレースする際はHikariCPは使えない

X-Rayを使ってSQLクエリをトレースする際、org.apache.tomcat.jdbc.pool.JdbcInterceptorを実装したインターセプタを利用するため、HikariCPを依存関係から除外して、tomcat-jdbcを追加しなければなりません。参考

build.gradle

dependencies {
  implementation("org.springframework.boot:spring-boot-starter-data-jpa") {
          exclude group: 'com.zaxxer'
  }
  implementation "org.apache.tomcat:tomcat-jdbc"
}

pom.xml

<dependencies>
  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-jdbc</artifactId>
      <exclusions>
          <exclusion>
              <groupId>com.zaxxer</groupId>
              <artifactId>HikariCP</artifactId>
          </exclusion>
      </exclusions>
  </dependency>
  <dependency>
      <groupId>org.apache.tomcat</groupId>
      <artifactId>tomcat-jdbc</artifactId>
  </dependency>
</dependencies>

Spring Data

PageRequest.java, Sort.javaのコンストラクタが非推奨になった

コンストラクタが非推奨になって、staticコンストラクタを使用するようになった。

- new PageRequest(1, 10);
+ PageRequest.of(1, 10);
- new Sort(orders);
+ Sort.by(orders);

Spring Data JPA

JpaRepository.javaが結構変わった

メソッドの戻り値にOptionalを使えるようになった

- User findByName(String name);
+ Optional<User> findByName(String name);

メソッド名が変わった

- <S extends T> List<S> save(Iterable<S> entities);
+ <S extends T> List<S> saveAll(Iterable<S> entities);

などなど

Spring Boot 1.5.xから2.0.xに上げた Spring Web編

Spring Boot 2系にあげたときにやったことをまとめようと思います。(関連記事) 今回は、Spring Webの話です。Spring Boot 2.0.9.RELEASEに上げた話になります。2.2対応はまたいつか書きます。

依存しているライブラリのバージョン

Spring Bootのバージョンを1.5.22から2.0.9に上げると依存しているライブラリのバージョンが次のようになります。

Spring Boot 1.5.22.RELEASESpring Boot 2.0.9.RELEASE

Hibernate Validator

@NotBlank, @NotEmpty, @Emailが非推奨になった

Hibernate Validator 6.0.0.Finalからorg.hibernate.validator.constraints.NotBlank, org.hibernate.validator.constraints.NotEmpty, org.hibernate.validator.constraints.Emailが非推奨になりました。参考 Spring Boot 2系からはjavax-validationが提供するアノテーションを利用します。

- org.hibernate.validator.constraints.NotBlank
+ javax.validation.constraints.NotBlank

Jackson /JSON Support

Spring Boot 2からspring-boot-starter-jsonが作られました。jackson-databind, jackson-datatype-jdk8, jackson-datatype-jsr310, jackson-module-parameter-namesが含まれていますので、これらをspring-boot-starter-jsonに置き換えることができます。参考

Spring Web

WebMvcConfigurerAdapter.classが非推奨

Spring Boot 2(Spring 5)からWebMvcConfigurerAdapter.classが非推奨となり、先のクラスを拡張するのではなく、WebMvcConfigurer.classを実装するようになりました。 Spring 5からJava8+になってdefaultメソッドが使えるようになり、WebMvcConfigurerAdapter.classを使わずともインターフェースに実装できるようになったからです。Javadoc

パッケージ変更

Spring Boot 2(Spring 5)からWebと言ってもServletとReactiveに分かれたためです。

- org.springframework.boot.autoconfigure.web.ErrorAttributes
+ org.springframework.boot.web.servlet.error.ErrorAttributes
- org.springframework.boot.autoconfigure.web.ErrorController
+ org.springframework.boot.web.servlet.error.ErrorController

プロパティ変更

Boot 2.0.9.RELEASEの設定値一覧を見れば書いてあります。

- server.context-path
+ server.servlet.context-path

おまけ

org.springframework.web.util.HtmlUtils.htmlEscape(String input, String encoding)の実装が変わった

見出しの通りです。4.3.x.RELEASEから5.0.x.RELEASEに変わりました。4.3.xでは、input == nullのときnullが返ってきていましたが、5.0.xからはExceptionを投げるようになりました。

このように書いていたので本番で障害が起きてしまいました...みなさんお気をつけて

// inputはnullのとき
String result = HtmlUtils.htmlEscape(input, encoding);
if (result == null) {
  何か処理
}

Flyway 3.x -> 5.xに上げた

Spring Boot2系にあげたときにやったことをまとめようと思います。(関連記事) 今回は、Flywayの話です。バージョンアップ後のSpring Bootのバージョンは2.0.9.RELEASEです。2.2対応はまたいつか書きます。

FlywayとSpring Boot

Flywayは、データベースマイグレーションライブラリです。 Spring Bootとの連携も簡単にできます。詳しくは、コチラを読んでください。

Spring Boot 1.5.22のFlywayのバージョンは3.2.1でしたが、Spring Boot 2.0.9では、5.0.7とメジャーバージョンが2つも上がりました。ちなみにSpring Boot 2.2.2では6.0.8になっています。

Flywayを3.x -> 5.xへ

まずカジュアルに3.x -> 5.xにあげてみましたが、マイグレーションが失敗しました...。

2019-09-11 03:43:15.152 ERROR --- Application startup failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'flywayInitializer' defined in class path resource [org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration$FlywayConfiguration.class]: Invocation of init method failed; nested exception is org.flywaydb.core.api.FlywayException: Validate failed: Migration checksum mismatch for migration 1
-> Applied to database : 1750005324
-> Resolved locally    : -558371367
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1583)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:545)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:482)
    at org.springframework.beans.factory.support.AbstractBeanFactory$1.getObject(AbstractBeanFactory.java:306)
    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:230)
    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:302)
    at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:197)
    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:296)
    at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:197)
    at org.springframework.context.support.AbstractApplicationContext.getBean(AbstractApplicationContext.java:1076)
    at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:851)
    at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:541)
    at org.springframework.boot.context.embedded.EmbeddedWebApplicationContext.refresh(EmbeddedWebApplicationContext.java:122)
    at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:761)
    at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:371)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:315)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1186)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1175)
    at com.b1a9idps.sample.view.Application.main(Application.java:19)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:48)
    at org.springframework.boot.loader.Launcher.launch(Launcher.java:87)
    at org.springframework.boot.loader.Launcher.launch(Launcher.java:50)
    at org.springframework.boot.loader.JarLauncher.main(JarLauncher.java:58)
Caused by: org.flywaydb.core.api.FlywayException: Validate failed: Migration checksum mismatch for migration 1

変更点

調べてみたら3 -> 44 -> 5で大きな変更が入っていました。

● 3 -> 4
チェックサム計算時に、改行コードを無視するようになりました。つまり、チェックサムの計算方法が4系から変更になりました。背景等は、[コチラ](https://flywaydb.org/blog/flyway-4.0)
● 4 -> 5
Flywayのメタデータを管理するテーブル名が `schema_version` から `flyway_schema_history` に変更になりました。詳しくは、[コチラ](https://github.com/flyway/flyway/issues/1848)

Flyway3 -> 5にするときの方法はちゃんとリリースノートに書いてありました。

Important note for users upgrading from Flyway 3.x: This release no longer supports a schema history table upgrade from Flyway 3.x. You must upgrade to Flyway 4.2.0 first before upgrading to Flyway 5.0.0.

Flyway 4系に上げる

Spring Boot 1.5.xのFlywayを4.2.0に上げます。

dependencies {
    compile "org.flywaydb:flyway-core:4.2.0"
}

起動します。

$ gradle bootRun

2019-09-11 03:43:14.467  INFO --- Upgrading metadata table `sample`.`schema_version` to the Flyway 4.0 format ...
2019-09-11 03:43:15.329  INFO --- Repairing metadata for version 1 (Description: CREATE TABLE SAMPLE DATA, Checksum: -558371367)  ...
2019-09-11 03:43:15.349  INFO --- Repairing metadata for version 2 (Description: MODIFY SAMPLE DATA LENGTH, Checksum: -1031537806)  ...
2019-09-11 03:43:15.370  INFO --- Repairing metadata for version 3 (Description: SAMPLE DATA ADD INDEX, Checksum: 194652164)  ...
2019-09-11 03:43:15.412  INFO --- Metadata table schema_version successfully upgraded to the Flyway 4.0 format.

初回起動時に、既存のメタデータを管理しているテーブルを自動的でアップデートしてくれました。

Flyway 5系に上げる

Spring Bootのバージョンを2.0.9.RELEASEあたりに上げて、設定ファイルのメタテーブルの名前を変更します。

spring.flyway.table=schema_version

Flywayが5.0.7になっていることを確認して起動すると特に問題なく起動できます。

おまけ

4 -> 5にするときにメタテーブルの名前を変更しなかった場合、6系からフォールバック機能がなくなるからテーブル名変更しろよって注意されます。

2019-09-25 23:05:33.882  WARN --- Could not find schema history table `sample`.`flyway_schema_history`, but found `test`.`schema_version` instead. You are seeing this message because Flyway changed its default for flyway.table in version 5.0.0 to flyway_schema_history and you are still relying on the old default (schema_version). Set flyway.table=schema_version in your configuration to fix this. This fallback mechanism will be removed in Flyway 6.0.0.

謝辞

カジュアルに3 -> 5に上げたときにエラーログ見てもさっぱり何もわかりませんでしたが、w氏の記事のおかげで助かりました。ありがとうございました。

Gradle 4.x -> Gradle 5.xに上げた

Spring Boot2系にあげたときにやったことをまとめようと思います。関連記事 今回はGradleの話です。Upgrading your build from Gradle 4.x to 5.0を参考に作業しました。 これだけじゃなかったと思うので、思い出したら追記します。もうGradle 6出ているんですが、まだそんなに対応していないのでとりあえず5の話です。

4系の最新までバージョンをあげる

まず、4系の最新までバージョンをあげます。 build.gradleにこのように書いて、 ./gradlew wrapper を実行します。

wrapper {
  gradleVersion = "4.10.2"
  distributionType = Wrapper.DistributionType.ALL
}

ビルド

次にビルドです。

./gradlew build

コマンドを実行すると、Gradle 5.0で非互換になる機能を利用しているとメッセージが出力されることがあります。 --warning-mode all オプションをつけて実行すると詳細を確認することができます。参考

build.gradleの書き換え

大きな書き換えは、dependencies指定するcompilieimplementationに書き換えることでした。4.7からcompileとかruntimeなどが非推奨になっています。参考implementationに変更することで依存関係が伝播しなくなるため明確になり、結合度が低くなります。

fooモジュールとbarモジュールで構成されたマルチモジュールプロジェクトがあるとします。 build.gradle(fooモジュール)

dependencies {
    implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-csv"
}

build.gradle(barモジュール)

dependencies {
    implementation project (":foo")
}

jackson-dataformat-csvimplementationで指定されているので、barモジュールではjackson-dataformat-csvの依存関係は伝播しません。

アーカイブファイルの名前を変更する

5.1からアーカイブファイルの名前を変更するときarchivesBaseName=hoge を使うようになりました。 jar.baseName=hoge は非推奨になりました。参考

おまけ

Gradle 3.x -> 4.10.2にするときにLombokが怒ってました。Lombok 1.18.2以上にしてねとのことでした。参考

Spring BootのGradleプラグイン 実行可能Jar(or War)作成タスクが bootRepackage から bootJarbootWarに変更になった。jarタスクやwarタスクは呼び出されない。参考