Bitbucket pipelineのドキュメント読んだ

2020年10月にBitbucketのサーバーライセンスの販売終了が発表されました。個人でいただいている仕事の方でBitbucket Serverを利用しており、BitBucket Cloudへの移行が必要になりました。 Bitbucket CloudのデフォルトCI/CDであるBitbucket Pipelinesを利用するので、ドキュメントを読んでまとめます。

Bitbucket Pipelinesとは

Bitbucket Pipelinesとは、Bitbucket内で利用できるCI/CDサービス。ビルド、テスト、デプロイなどを自動的に行うことできる。基本的にはクラウド上に自分専用のコンテナ環境を作成する。 パイプラインは、bitbucket-pipelinens.ymlのファイルに定義して、レポジトリ直下に配置する。

パイプラインの設定

Configure your pipeline

基本的に、ビルドやデプロイするためのスクリプトを書いたり、ビルド時間短縮のためのキャッシュの設定を書いたりする。ステップごとに異なるイメージを利用することができる。 パイプラインの設定を書くために次の注意点がある。

  1. 最低1ステップは必要で、ステップの中に最低1スクリプト必要
  2. どのステップも利用できるメモリは4GB
  3. 1パイプラインあたり最大100ステップまで
  4. ステップ毎に別のDockerコンテナで実行される。

セクションについて

  • default
    • 全ブランチのpush毎に実行される処理を書くセクション。
  • branches
    • 指定したブランチで実行される処理を書くセクション。
  • tags
    • 指定したtagで実行される処理を書くセクション。
  • bookmarks
    • 指定したブックマークで実行される処理を書くセクション
  • pull requests
    • レポジトリ内のプルリクエストが初期化されるときに実行される処理を書くセクション。
  • custom
    • 手動実行や日時実行する処理を書くセクション。

グローバルに設定できるオプション

  • variables
    • customパイプラインのみ定義でできる。パイプラインがローンチされる時に使われる変数。
  • parallel
    • 同時実行させる。
  • step
    • ビルド実行単位を定義する。
    • 生成されたartifactsは14日間保存される。
  • name
    • ステップ名
  • images
    • ビルドするために使われるDockerコンテナ。
      • デフォルトイメージやパブリック、プライベートなDockerイメージを利用できる。
      • globalまたはステップ毎にイメージを定義できる。
  • trigger
    • ステップは自動実行か手動実行か指定できる。triggerのデフォルトは自動。
    • 第1ステップは手動にはできない
  • deployment
    • deploymentステップのための環境をセットする。 - 利用できるあたいは、test, staging, production
  • size
    • sizeオプションを書いたステップは、2倍のメモリを利用できる。
  • script
  • pipe
  • after-script
    • ステップが成功か失敗したときに実行される。クリーンアップコマンドや、テストカバレッジ、通知などに役出つ。
    • BITBUCKET_EXIT_CODEを使って終了コードを取得できる。
  • artifacts
    • レポートやJARなどのステップによって生成されるファイルを定義する。
  • caches
    • ライブラリ等をキャッシュする。

YAML anchors

YAML anchors

bitbucket-pipelines.yml ファイルに繰り返し利用したいステップがある場合、YAML anchorsを利用するのがよい。

Anchors and aliases

'&'で設定のまとまりを定義して、'*'で&で定義した設定のまとまりを利用できる。

ビルド環境としてDockerイメージを利用

Use Docker images as build environments

DockerコンテナでBitbucket Pipelinesのビルドが実行される。Docker Hub, AWS, GCP, Azure, self-hosted registriesにあるパブリック、プライベートイメージを利用できる。

Deployments

Set up and monitor deployments

Set up and monitor deployments

デプロイの設定

  1. Bitbucketにデプロイ環境について設定する。

  2. 環境名

  3. 環境の種類
  4. 環境変数

    2.環境を定義する パイプラインを有効にすると、デフォルトで3環境(Test, Staging, Production)が用意される。 デプロイの変数は、チームとレポジトリの変数を上書きする。

    3.デプロイステップを定義する

bitbucket-pipelines.ymlのstepにdeploymentを追加する。deploymentには、環境名を含む必要がある。

- step:
        name: Deploy to production
        deployment: production-east
        script:
          - python deployscript.py prod

コミットメッセージにissue番号を書けば、Jiraとの連携も可能。

デプロイのロールバック

デプロイにした場合、直近の成功したデプロイバージョンに戻すことができる。

  1. Redeployボタンを有効にする
  2. 環境を選択して、Redeployボタンを押下する

Pipeline triggers

手動実行

bitbucket-pipelines.ymlにtrigger: manualを追加することで、手動ステップを設定できる。最初のステップに手動トリガを設定することはできない。もし、手動実行だけのパイプラインを設定したい場合は、customセクションの中に書く必要がある。

variableセクションに手動実行時に渡したい変数を設定することができる。

スケジューリング

指定した日時に実行させることができる。レポジトリの設定から日時を設定することができる。

ブランチ

ブランチ毎のpushをトリガにしてパイプラインを実行させることができる。

Keywords

  • default
    • 全ブランチの全push毎に実行される
  • tags
    • 指定したタグで実行される。
  • pull-request
    • プルリクエスト初期化時に実行される。defualtで定義されたパイプラインも実行されるため、2パイプラインが並行実行する。
  • custom
    • 手動orスケジューリングトリガを設定する。
  • variables
    • [Custom pipelines only]パイプライン実行時に渡す変数を定義する。
  • bookmarks
    • ブックマーク

Variables and secrets

Variables and secrets

ビルドコンテナ内で利用する環境変数を設定できる。いくつかデフォルトで用意されており自分で設定することもできる。

自分で設定する場合の制約は次の通り。

- 利用可能な文字は、ASCII文字、数字、アンダースコア
- 大文字小文字は区別する
- 数字からはじめることはできない
- シェルによって定義される変数を利用するべきでない

設定できる変数は、ワークスペース変数、レポジトリ変数、デプロイメント変数がある。セキュアに変数を設定すると、ログに値が表示されない。

Bitbucket Pipelines内でSSHキーを利用する

レポジトリのPipelinesのSSH keysで設定できる。

Caches

Caches

Bitbucket Pipelinesはサードパーティライブラリなどの外部の依存関係やディレクトリをキャッシュすることができる。最初のパイプライン実行時にキャッシュしてそれ以降はキャッシュを使う。

Databases and service containers

Databases and service containers

Bitbucket Pipelinesは、ビルドパイプラインから複数のテストなどで利用するためのDockerコンテナを実行することができる。(DBやRedisなど)

Use pipes in Bitbucket Pipelines

Use pipes in Bitbucket Pipelines

Pipesは、パイプラインを設定するためのシンプルな方法を提供する。サードパーティのツールと動かしたい時に特に強力である。

Integrations

Integrations

「Jira - Pipelines」、「Slack - Pipelines」などの連携について

Testing

Testing

Test reporting in Pipelines

ビルドステップの中でテストレポートを生成しているなら、pipelinesは自動的に探してWeb上で見れるようにする。ただし、xUnitのレポートに限る。

Links

Spring Security 5.4(Spring Boot 2.4)から不正なURLでのリクエストに任意のステータスコードを返せるようになった

Spring Security 5.4(Spring Boot 2.4)から不正なURLでのリクエストに対して、任意のステータスコードを返せるようになりました。とっても嬉しい!!!

Spring SecurityのHttpFirewall

公式ドキュメント#HttpFirewall に詳しく書いてあるのですが、Spring Securityには ;\などが含まれている不正なURLを弾いてくれるHttpFirewallインターフェースがあります(デフォルトでStrictHttpFirewallクラスが使われる)。

不正なURLだと、RequestRejectedExceptionが投げられます(ソースコード)。

2020-12-26 16:12:54.917 ERROR --- Servlet.service() for servlet [dispatcherServlet] in context with path [/] threw exception
org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the URL contained a potentially malicious String ";"

Spring Security 5.3.x(Spring Boot 2.3.x)まで

RequestRejectedExceptionのExceptionHandlerがなくて、ステータスコード500が返されていました。(なんでエラー扱いされないといけないんだと何回思ったことか...)

Spring Security 5.4(Spring Boot 2.4)

RequestRejectedHandlerインターフェースが追加されて、RequestRejectedExceptionをハンドリングできるようになりました(プルリク)。

単に任意のステータスコードを返せばよいだけの場合は、HttpStatusRequestRejectedHandlerクラスをBean登録してください。(デフォルトだと400を返します)

@Bean
public RequestRejectedHandler requestRejectedHandler() {
  return new HttpStatusRequestRejectedHandler();
}

404を返したいんだけど...というときは、コンストラクタで渡してください。

@Bean
public RequestRejectedHandler requestRejectedHandler() {
  return new HttpStatusRequestRejectedHandler(HttpStatus.NOT_FOUND.value());
}

Spring BootアプリケーションでAWS Systems Manager パラメータストアを利用する

Spring Bootのアプリケーション起動時に、AWS Systems Manager パラメータストアからパラメータを取得できるみたいなので試してみました。

AWS Systems Manager パラメータストアとは

設定データ管理と機密管理のための安全な階層型ストレージを提供してくれていて、パスワード、データベース文字列、AMI ID、ライセンスコードなどのデータをパラメータ値として保存できます。値はプレーンテキストまたは暗号化されたデータとして保存できます。詳しい説明は、公式ドキュメントを見てください。

サンプル

環境

Spring Boot等のバージョンは次の通りです。

Java 11
Gradle 6.6.1
Spring Boot 2.3.4.RELEASE
Spring Cloud Hoxton.SR8

パラメータの登録

登録するパラメータのkey

デフォルトだと次のような3階層で設定するようになっています。[prefix]/[name]_[アプリケーションのprofile(defaultの場合は省略可)]/[key]

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

- パラメータストアから取得される全プロパティで共有されるプレフィクス。第1階層の値。
  - aws.paramstore.prefix=/config
- 全サービスで共有されるコンテキスト名。第2階層の値。
  - aws.paramstore.default-context=application
- コンテキスト名とプロファイルの区切り文字.
  - aws.paramstore.profile-separator=_
- パラメータストアを利用するかどうか
  - aws.paramstore.enabled=true

取得するパラメータを組み立てているロジックは AwsParamStorePropertySourceLocator#PropertySource<?> locate(Environment) にあります。

パラメータストアへの登録

パラメータストアにパラメータを登録します。今回は、DBへのアクセス情報を登録します。

/config/sample/spring.datasource.url=jdbc:mysql://localhost:33306/sample
/config/sample/spring.datasource.username=docker
/config/sample/spring.datasource.password=docker

依存関係

パラメータストアを利用するために追加する依存関係はこれだけです。

build.gradle

dependencies {
  ...
  
  implementation 'org.springframework.cloud:spring-cloud-starter-aws-parameter-store-config:2.2.4.RELEASE'
  ...
}

設定ファイル

appliaction.yml(application.properties)には何も書かなくてOKですが、bootstrap.ymlにいくつか設定を書く必要があります。

bootstrap.ymlの全体

spring:
  datasource:
    url:
    username:
    password:
cloud:
  aws:
    stack:
      auto: false
    region:
      auto: false
      static: ap-northeast-1
aws:
  paramstore:
    region: ${cloud.aws.region.static}
    default-context: sample

logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

パラメータストアから取得するのでkeyのみ書きます。

spring:
  datasource:
    url:
    username:
    password:

ローカル環境でSpring Cloud AWSを使うためのおまじないです。アプリケーション起動時にメタデータからリージョン等を取得するのですが、ローカル環境だと取得に失敗してアプリケーションが起動しなくなってしまうために無効にしています。詳しくは、Configuring regionCloudFormation configuration in Spring Boot にあります。

cloud:
  aws:
    stack:
      auto: false
    region:
      auto: false
      static: ap-northeast-1

spring-cloud-starter-aws-parameter-store-configの設定です。

aws:
  paramstore:
    region: ${cloud.aws.region.static}
    default-context: sample

EC2MetadataUtilsがAWS上で動いているか取得しますが、AWS環境以外でのアプリケーション起動時にエラーログを出してしまって気持ち悪いので設定します。(なくても動作には何も問題ないです)

logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

アプリケーションの起動

あとはアプリケーションを起動するだけです。

どのパラメータをパラメータストアから取得しているかを確認したい場合は、次の設定をbootstrap.ymlに書いてください。

logging:
  level:
    org:
      springframework:
        cloud:
          aws:
            paramstore:
              AwsParamStorePropertySource: debug

設定するとログにこんな感じで出力されます。

2020-10-11 18:35:27.847 DEBUG 55136 --- [           main] o.s.c.a.p.AwsParamStorePropertySource    : Populating property retrieved from AWS Parameter Store: spring.datasource.password
2020-10-11 18:35:27.849 DEBUG 55136 --- [           main] o.s.c.a.p.AwsParamStorePropertySource    : Populating property retrieved from AWS Parameter Store: spring.datasource.url
2020-10-11 18:35:27.849 DEBUG 55136 --- [           main] o.s.c.a.p.AwsParamStorePropertySource    : Populating property retrieved from AWS Parameter Store: spring.datasource.username

プロファイルで設定値を分ける

一般的に、defaultプロファイルしか存在しないことはないかと思います。testプロファイルでは、テスト環境用のDBアクセス情報を取得するみたいなことも可能です。

パラメータストアへの登録

パラメータストアへの登録の仕方については上で説明したので省略します。 これらを登録します。

/config/sample_test/spring.datasource.url=jdbc:mysql://localhost:33307/sample
/config/sample_test/spring.datasource.username=test
/config/sample_test/spring.datasource.password=test

アプリケーションの起動

アクティブプロファイルをtestにして、起動するだけで、 /config/sample_test/* のパラメータを取得してくれます。 デフォルトプロファイルの/config/sample/を読み込んでそのあとに/config/sample_testで上書きするようです。

まとめ

思っていたよりは簡単にできました!AWSのプロファイルはdefaultが使われるので変更したい場合は自前で設定書かないといけないみたいです。

ちなみに設定値の優先順位は パラメータストア > bootstram.yml > application.yml でした。bootstrap.ymlとかapplication.ymlに同じ設定書いてあってもパラメータストアに設定している場合は、パラメータストアの値が優先されます。

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);

などなど