DynamoDBをローカル環境で使う

docker-composeでローカルにDynamoDBを利用できる環境を作る
あと、dynamodb-adminも利用する

セットアップ

docker-compose.yml

version: '3.8'
services:
  dynamodb-local:
    container_name: dynamodb-local
    image: amazon/dynamodb-local:latest
    user: root
    ports:
      - 8000:8000
    volumes:
      - dynamodb-local-data:/data
    command: -jar DynamoDBLocal.jar -sharedDb -dbPath /data
    networks:
      - dynamodb-local-network
  dynamodb-admin:
    container_name: dynamodb-admin
    image: aaronshaf/dynamodb-admin:latest
    environment:
      - DYNAMO_ENDPOINT=dynamodb-local:8000
    ports:
      - 8001:8001
    depends_on:
      - dynamodb-local
    networks:
      - dynamodb-local-network

volumes:
  dynamodb-local-data:

networks:
  dynamodb-local-network:
    driver: bridge

実行

docker-compose up -d

コンテナを起動してから下記にアクセスすると管理コンソールを操作できる

http://localhost:8000/shell/

また、下記でdynamodb-adminを操作できる

http://localhost:8001/

管理コンソールから下記のスクリプトを流してテーブルを作成する

var params = {
    TableName: 'AuthTokenTable',
    KeySchema: [
        {
            AttributeName: 'authToken',
            KeyType: 'HASH', //Partition key
        }
    ],
    AttributeDefinitions: [
        {
            AttributeName: 'authToken',
            AttributeType: 'S'
        },
        {
            AttributeName: 'userId',
            AttributeType: 'N'
        }
    ],
    ProvisionedThroughput: {       // Only specified if using provisioned mode
        ReadCapacityUnits: 1,
        WriteCapacityUnits: 1
    },
    // https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/SQLtoNoSQL.Indexes.Creating.html#SQLtoNoSQL.Indexes.Creating.DynamoDB
    GlobalSecondaryIndexes: [
        {
            IndexName: 'UserIdIndex',
            KeySchema: [
                {
                    AttributeName: 'userId',
                    KeyType: 'HASH',
                }
            ],
            Projection: {
                    ProjectionType: 'INCLUDE',
                    NonKeyAttributes: [
                        'authToken',
                    ],
            },
            ProvisionedThroughput: {
                ReadCapacityUnits: 1,
                WriteCapacityUnits: 1
            }
        }
    ],
};
dynamodb.createTable(params, function(err, data) {
    if (err) ppJson(err); // an error occurred
    else ppJson(data); // successful response
});

Thymeleafをホットデプロイして使う

いちいちテンプレートを修正したらサーバを起動し直すのが面倒だったのでホットデプロイできるようにした

セットアップ

build.gradle

  • springloaded をdependenciesに追加
plugins {
    id 'org.springframework.boot' version '2.5.0'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

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

repositories {
    mavenCentral()
}

dependencies {
    compileOnly 'org.projectlombok:lombok'
    implementation 'org.slf4j:slf4j-api'
    implementation 'ch.qos.logback:logback-core'
    implementation 'ch.qos.logback:logback-classic'
    implementation 'com.google.guava:guava:30.1.1-jre'
    implementation 'org.apache.commons:commons-lang3'
    implementation 'commons-beanutils:commons-beanutils:1.9.2'
    implementation 'com.amazonaws:aws-java-sdk-dynamodb:1.12.9'
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4'
    implementation 'org.springframework:springloaded:1.2.8.RELEASE'
    runtimeOnly 'mysql:mysql-connector-java' 
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform()
}

application.yml

  • テンプレートのキャッシュを無効化するため、「spring.thymeleaf.cache=false」の設定を追加
server:
  servlet:
    context-path: /sample
spring:
  messages:
    basename: "application-messages,ValidationMessages"
    encoding: "UTF-8"
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:33060/sample
    username: mysql-app
    password: mysql-app
  thymeleaf:
    cache: false
mybatis:
  configuration:
    map-underscore-to-camel-case: true
logging:
  level:
    org:
      springframework: WARN
    com:
      example:
        demo:
          domain:
            mapper: DEBUG

Thymeleafを使う

Thymeleafを使うため最もシンプルな構成で環境を準備する

セットアップ

build.gradle

  • spring-boot-starter-thymeleaf をdependenciesに追加
plugins {
    id 'org.springframework.boot' version '2.5.0'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

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

repositories {
    mavenCentral()
}

dependencies {
    compileOnly 'org.projectlombok:lombok'
    implementation 'org.slf4j:slf4j-api'
    implementation 'ch.qos.logback:logback-core'
    implementation 'ch.qos.logback:logback-classic'
    implementation 'com.google.guava:guava:30.1.1-jre'
    implementation 'org.apache.commons:commons-lang3'
    implementation 'commons-beanutils:commons-beanutils:1.9.2'
    implementation 'com.amazonaws:aws-java-sdk-dynamodb:1.12.9'
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4'
    runtimeOnly 'mysql:mysql-connector-java' 
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform()
}

/src/main/java/com/example/demo/web/controller/GreetingController.java

package com.example.demo.web.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

// https://tech.chakapoko.com/java/thymeleaf/getting-started-with-thymeleaf.html
// https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf_ja.html#%E7%B9%B0%E3%82%8A%E8%BF%94%E3%81%97%E3%82%B9%E3%83%86%E3%83%BC%E3%82%BF%E3%82%B9%E3%81%AE%E4%BF%9D%E6%8C%81
@Controller
public class GreetingController {

    @GetMapping("/greeting")
    public String geeting(@RequestParam(name = "name", required = false, defaultValue = "World") String name,
            Model model) {
        model.addAttribute("name", name);
        return "greeting";
    }

}

/src/main/resources/templates/greeting.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Getting Started: Serving Web Content</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
    <p th:text="'Hello, ' + ${name} + '!'" />
</body>
</html>

リクエストからUserAgentを取得する

リクエストからUserAgentを取得して各コントローラで持ちまわる共通的な処理を追加する。 取得部分はFilterではなく、HandlerInterceptorで行った。

FilterとInterceptorの違いは下記を参考にした。

https://meetup-jp.toast.com/698

/src/main/java/com/example/demo/interceptor/RequestHandlerInterceptor.java

package com.example.demo.interceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import com.example.demo.web.dto.request.RequestInfo;

@Component
public class RequestHandlerInterceptor implements HandlerInterceptor {

    private static final Logger LOGGER = LoggerFactory.getLogger(RequestHandlerInterceptor.class);

    @Override
    // リクエスト処理前に行う処理
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        LOGGER.info("RequestHandlerInterceptor#preHandle() called.");

        final String userAgent = request.getHeader("User-Agent");
        final RequestInfo requestInfo = new RequestInfo();
        requestInfo.setUserAgent(userAgent);

        request.setAttribute(RequestInfo.REQUEST_KEY, requestInfo);

        return true;
    }

    @Override
    // コントローラー処理後に行う処理
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            @Nullable ModelAndView modelAndView) throws Exception {
        LOGGER.info("RequestHandlerInterceptor#postHandle() called.");
    }

    @Override
    // リクエスト処理後に行う処理
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
            @Nullable Exception ex) throws Exception {
        LOGGER.info("RequestHandlerInterceptor#afterCompletion() called.");
    }

}

/src/main/java/com/example/demo/WebConfiguration.java

  • コンフィグレーションに利用するInterceptorを定義する
package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import com.example.demo.interceptor.RequestHandlerInterceptor;

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

    @Autowired
    private RequestHandlerInterceptor requestHandlerInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(requestHandlerInterceptor);
    }

}

/src/main/java/com/example/demo/web/dto/request/RequestInfo.java

package com.example.demo.web.dto.request;

import lombok.Data;

@Data
public class RequestInfo {

    public static final String REQUEST_KEY = "com.example.demo.web.dto.request.RequestInfo.REQUEST_KEY";

    private String userAgent;

}

/src/main/java/com/example/demo/web/controller/UsersController.java

  • コントローラのメソッドの引数にInterceptorでリクエストに設定したオブジェクトを追加
   @RequestMapping(method = RequestMethod.GET)
    public UsersGetListResponse getList(
            @RequestAttribute(name = RequestInfo.REQUEST_KEY, required = true) RequestInfo requestInfo,
            @RequestParam(name = "page", defaultValue = "1") String page,
            @RequestParam(name = "limit", defaultValue = "5") String limit) {
        LOGGER.info("#RequestInfo : {}", requestInfo);
        List<UserDto> resultList = usersService.findAll();
        return UsersHelper.convertToUsersGetListResponse(resultList);
    }

業務チェック処理を実装

リクエストパラメータの単項目/関連項目バリデーションとは別に業務的なチェック処理を追加して、エラーメッセージを返却するところまでを追加する

/src/main/java/com/example/demo/domain/mapper/UsersMapper.java

  • メールアドレスが重複するレコードが登録されているか検索するメソッドを追加
   Users lockByMailAddress(@Param("mailAddress") String mailAddress);

/src/main/resources/com/example/demo/domain/mapper/UsersMapper.xml

  • メールアドレスが重複するレコードが登録されているか検索するSQLを追加
 <select id="lockByMailAddress" resultType="com.example.demo.domain.model.Users">
        SELECT
            ID,
            MAIL_ADDRESS,
            USER_NAME,
            PASSWORD,
            LAST_LOGIN_TIME,
            REGIST_TIME,
            UPDATE_TIME,
            DELETED
        FROM
            t_users
        WHERE
            MAIL_ADDRESS = #{mailAddress} AND
            DELETED = 0
        FOR UPDATE
    </select>

/src/main/java/com/example/demo/domain/repository/UsersRepository.java

  • メールアドレスが重複するレコードが登録されているか検索するメソッドを追加
   public Users lockByMailAddress(String mailAddress) {
        return usersMapper.lockByMailAddress(mailAddress);
    }

/src/main/java/com/example/demo/service/UsersService.java

  • メールアドレスが重複するレコードが存在した場合は処理を中断し、業務例外をthrow
   @Transactional(readOnly = false)
    public UserDto create(UserDto dto) {
        
        Users duplicated = usersRepository.lockByMailAddress(dto.getMailAddress());
        if (duplicated != null) {
            throw new BusinessException("errors.users.mailAddress.duplicated", dto.getMailAddress());
        }
        
        final Users users = Users.builder()
                .mailAddress(dto.getMailAddress())
                .userName(dto.getUserName())
                .password(dto.getPassword())
                .registTime(DateUtils.getThreadDateTime())
                .updateTime(DateUtils.getThreadDateTime())
                .deleted(0)
                .build();
        usersRepository.create(users);
        LOGGER.info("#users : {}", users);
        return convert(users);
    }

/src/main/java/com/example/demo/common/utils/SystemException.java

package com.example.demo.common.utils;

import lombok.Getter;

@Getter
public class SystemException extends RuntimeException {

    /**
    * SerialVersionUID.
    */
    private static final long serialVersionUID = -8269072520808618210L;

    private String code;
    private Exception nested;

    public SystemException(String code, Exception nested) {
        super();
        this.code = code;
        this.nested = nested;
    }

    public SystemException(String code) {
        super();
        this.code = code;
    }

}

/src/main/java/com/example/demo/common/utils/BusinessException.java

package com.example.demo.common.utils;

import java.util.List;

import com.google.common.collect.Lists;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
public class BusinessException extends RuntimeException {

    /**
    * SerialVersionUID.
    */
    private static final long serialVersionUID = -2911618850319099751L;

    private List<ErrorInfo> errors = Lists.newArrayList();

    public BusinessException(String code) {
        this(code, new Object[0]);
    }

    public BusinessException(String code, Object... params) {
        super();
        final ErrorInfo error = new ErrorInfo(code, params);
        errors.add(error);
    }

    @Getter
    @AllArgsConstructor
    public static class ErrorInfo {
        private String code;
        private Object[] params = new Object[0];
    }
}

/src/main/java/com/example/demo/web/controller/ExceptionController.java

   @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        final List<ErrorResponse.Error> errors = Lists.newArrayList();
        final List<ErrorInfo> errorInfoList = e.getErrors();
        for (ErrorInfo errorInfo : errorInfoList) {
            errors.add(new ErrorResponse.Error(null,
                    messageSource.getMessage(errorInfo.getCode(), errorInfo.getParams(), Locale.getDefault())));
        }
        return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(SystemException.class)
    public ResponseEntity<ErrorResponse> handleSystemException(SystemException e) {
        String errMessage = "system error happend.";
        if (e.getCode() != null) {
            errMessage = messageSource.getMessage(e.getCode(), new Object[0], Locale.getDefault());
        }
        LOGGER.error(errMessage, e);
        ErrorResponse.Error error = new ErrorResponse.Error(null, errMessage);
        return new ResponseEntity<>(new ErrorResponse(error), HttpStatus.INTERNAL_SERVER_ERROR);
    }

/src/main/resources/ValidationMessages.properties

errors.users.mailAddress.duplicated=そのメールアドレス({0})は既に登録されています

バリデーションと共通例外処理

  • バリデーション処理の実装
1. アノテーションによるバリデーション処理の実装
2. 独自メッセージの使用
3. 独自バリデーター(単項目/関連項目)の作成
  • 共通例外処理
1.  @RestControllerAdviceを使って共通の例外ハンドラーを作成

/build.gradle

  • spring-boot-starter-validationを追加
plugins {
    id 'org.springframework.boot' version '2.5.0'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

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

repositories {
    mavenCentral()
}

dependencies {
    compileOnly 'org.projectlombok:lombok'
    implementation 'org.slf4j:slf4j-api'
    implementation 'ch.qos.logback:logback-core'
    implementation 'ch.qos.logback:logback-classic'
    implementation 'com.google.guava:guava:30.1.1-jre'
    implementation 'org.apache.commons:commons-lang3'
    implementation 'commons-beanutils:commons-beanutils:1.9.2'
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4'
    runtimeOnly 'mysql:mysql-connector-java' 
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform()
}

/src/main/java/com/example/demo/web/controller/UsersController.java

  • バリデーションの対象に@Validatedを付与
package com.example.demo.web.controller;

import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.service.UsersService;
import com.example.demo.service.dto.UserDto;
import com.example.demo.web.controller.helper.UsersHelper;
import com.example.demo.web.dto.request.UsersCreateRequest;
import com.example.demo.web.dto.request.UsersUpdateRequest;
import com.example.demo.web.dto.response.UsersCreateResponse;
import com.example.demo.web.dto.response.UsersGetListResponse;
import com.example.demo.web.dto.response.UsersGetResponse;

import lombok.RequiredArgsConstructor;

@CrossOrigin
@RestController
@RequestMapping(value = "/users", produces = "application/json;charset=UTF-8")
@RequiredArgsConstructor
public class UsersController {

    private static final Logger LOGGER = LoggerFactory.getLogger(UsersController.class);

    private final UsersService usersService;

    @RequestMapping(method = RequestMethod.GET)
    public UsersGetListResponse getList(@RequestParam(name = "page", defaultValue = "1") String page,
            @RequestParam(name = "limit", defaultValue = "5") String limit) {
        List<UserDto> resultList = usersService.findAll();
        return UsersHelper.convertToUsersGetListResponse(resultList);
    }

    @RequestMapping(method = RequestMethod.GET, value = "{id}")
    public UsersGetResponse get(@PathVariable("id") Long id) {
        LOGGER.info("#id : {}", id);
        UserDto result = usersService.findById(id);
        return UsersHelper.convertToUsersGetResponse(result);
    }

    @RequestMapping(method = RequestMethod.POST)
    @ResponseStatus(HttpStatus.CREATED)
    public UsersCreateResponse create(@Validated @RequestBody UsersCreateRequest request) {
        LOGGER.info("#UsersCreateRequest : {}", request);
        UserDto userDto = UsersHelper.convertFromUsersCreateRequest(request);
        UserDto result = usersService.create(userDto);
        return UsersHelper.convertToUsersCreateResponse(result);
    }

    @RequestMapping(method = RequestMethod.PUT, value = "{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void update(@PathVariable("id") Long id, @Validated @RequestBody UsersUpdateRequest request) {
        LOGGER.info("#id : {}", id);
        LOGGER.info("#UsersUpdateRequest : {}", request);
        UserDto userDto = UsersHelper.convertFromUsersUpdateRequest(id, request);
        usersService.update(userDto);
    }

    @RequestMapping(method = RequestMethod.DELETE, value = "{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable("id") Long id) {
        LOGGER.info("#id : {}", id);
        usersService.delete(id);
    }

}

/src/main/java/com/example/demo/web/dto/request/UsersCreateRequest.java

package com.example.demo.web.dto.request;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

import com.example.demo.common.validation.MaxLength;
import com.example.demo.common.validation.Same;

import lombok.Data;

@Data
@Same(target="confirmPassword", ref="password", message="{errors.users.password.notSame}")
public class UsersCreateRequest {

    @NotEmpty
    @MaxLength(15)
    private String userName;

    @NotEmpty
    @Email
    @MaxLength(256)
    private String mailAddress;

    @NotEmpty
    @Size(min = 8, max = 15)
    private String password;

    @NotEmpty
    @Size(min = 8, max = 15)
    private String confirmPassword;

}

/src/main/java/com/example/demo/web/dto/request/UsersUpdateRequest.java

package com.example.demo.web.dto.request;

import javax.validation.constraints.Email;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

import com.example.demo.common.validation.MaxLength;
import com.example.demo.common.validation.Same;

import lombok.Data;

@Data
@Same(target="confirmPassword", ref="password", message="{errors.users.password.notSame}")
public class UsersUpdateRequest {

    @MaxLength(15)
    private String userName;

    @Email
    @MaxLength(256)
    private String mailAddress;

    @Size(min = 8, max = 15)
    private String password;

    @Size(min = 8, max = 15)
    private String confirmPassword;

    @Pattern(regexp = "[01]")
    private String deleted;

}

/src/main/java/com/example/demo/web/controller/ExceptionController.java

package com.example.demo.web.controller;

import java.util.List;
import java.util.Locale;

import javax.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import com.example.demo.web.dto.response.ErrorResponse;
import com.google.common.collect.Lists;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@RestControllerAdvice
public class ExceptionController {

    private static final Logger LOGGER = LoggerFactory.getLogger(ExceptionController.class);

    private final MessageSource messageSource;

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(HttpServletRequest request, Exception e) {
        LOGGER.error(e.getMessage(), e);
        final ErrorResponse Errors = new ErrorResponse(new ErrorResponse.Error(null, e.getMessage()));
        return new ResponseEntity<>(Errors, HttpStatus.INTERNAL_SERVER_ERROR);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        final List<ErrorResponse.Error> errors = Lists.newArrayList();
        BindingResult bindingResult = e.getBindingResult();
        if (bindingResult != null) {
            List<ObjectError> objectErrors = bindingResult.getAllErrors();
            if (objectErrors != null) {
                for (ObjectError objectError : objectErrors) {
                    LOGGER.info("{}", objectError);
                    LOGGER.info("code: {}", objectError.getCode());
                    if (objectError instanceof FieldError) {
                        final FieldError fieldError = (FieldError) objectError;
                        errors.add(new ErrorResponse.Error(fieldError.getField(),
                                messageSource.getMessage(objectError, Locale.getDefault())));
                    } else {
                        errors.add(new ErrorResponse.Error(null, messageSource.getMessage(objectError, Locale.getDefault())));
                    }
                }
            }
        }
        return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
        final List<ErrorResponse.Error> errors = Lists.newArrayList();
        errors.add(
                new ErrorResponse.Error(e.getName(), messageSource.getMessage("typeMismatch." + e.getRequiredType().getName(),
                        new String[] { e.getName(), e.getRequiredType().getName() }, Locale.getDefault())));
        return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
    }

}

/src/main/java/com/example/demo/web/dto/response/ErrorResponse.java

package com.example.demo.web.dto.response;

import java.util.Collections;
import java.util.List;

import org.apache.commons.lang3.builder.RecursiveToStringStyle;
import org.apache.commons.lang3.builder.ReflectionToStringBuilder;

import com.fasterxml.jackson.annotation.JsonInclude;

import lombok.AllArgsConstructor;
import lombok.Data;

@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {

    private final List<Error> errors;

    public ErrorResponse(List<Error> errors) {
        this.errors = errors;
    }

    public ErrorResponse(Error error) {
        this.errors = Collections.singletonList(error);
    }

    @Data
    @AllArgsConstructor
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public static class Error {
        private String field;
        private String message;
    }

    public List<Error> getErrors() {
        return errors;
    }

    @Override
    public String toString() {
        return new ReflectionToStringBuilder(this, new RecursiveToStringStyle()).toString();
    }
}

/src/main/java/com/example/demo/common/validation/MaxLength.java

  • 単項目チェック用の独自バリデーションクラス
package com.example.demo.common.validation;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Documented;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;

import org.apache.commons.lang3.StringUtils;

@Target({ METHOD, FIELD })
@Retention(RUNTIME)
@Documented
@Repeatable(MaxLength.List.class)
@Constraint(validatedBy = { MaxLength.Validator.class })
public @interface MaxLength {

    String message() default "{com.example.demo.common.validation.MaxLength.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    int value();

    @Target({ METHOD, FIELD })
    @Retention(RUNTIME)
    @Documented
    @interface List {
        MaxLength[] value();
    }

    class Validator implements ConstraintValidator<MaxLength, String> {

        private int maxLength;

        public void initialize(MaxLength constraintAnnotation) {
            this.maxLength = constraintAnnotation.value();
        }

        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            if (StringUtils.isEmpty(value))
                return true;
            return value.length() <= this.maxLength;
        }

    }
}

/src/main/java/com/example/demo/common/validation/Same.java

  • 関連項目チェック用の独自バリデーションクラス
package com.example.demo.common.validation;

import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Documented;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.Objects;

import javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;

import org.apache.commons.lang3.ObjectUtils;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;

@Target({ TYPE })
@Retention(RUNTIME)
@Documented
@Repeatable(Same.List.class)
@Constraint(validatedBy = { Same.Validator.class })
public @interface Same {

    String message() default "{com.example.demo.common.validation.Same.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    String target();

    String ref();

    @Target({ TYPE })
    @Retention(RUNTIME)
    @Documented
    @interface List {
        Same[] value();
    }

    class Validator implements ConstraintValidator<Same, Object> {

        private String targetField;
        private String refField;
        private String message;

        public void initialize(Same constraintAnnotation) {
            this.targetField = constraintAnnotation.target();
            this.refField = constraintAnnotation.ref();
            this.message = constraintAnnotation.message();
        }

        @Override
        public boolean isValid(Object rootObject, ConstraintValidatorContext context) {
            BeanWrapper beanWrapper = new BeanWrapperImpl(rootObject);
            Object targetValue = beanWrapper.getPropertyValue(targetField);
            Object refValue = beanWrapper.getPropertyValue(refField);

            if (ObjectUtils.isEmpty(targetValue) || ObjectUtils.isEmpty(refValue)) {
                return true;
            }
            if (Objects.equals(targetValue, refValue)) {
                return true;
            }

            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(message).addPropertyNode(targetField).addConstraintViolation();
            return false;
        }

    }
}

/src/main/resources/application.yml

  • messagesの定義
server:
  servlet:
    context-path: /sample
spring:
  messages:
    basename: "application-messages,ValidationMessages"
    encoding: "UTF-8"
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:33060/sample
    username: mysql-app
    password: mysql-app
mybatis:
  configuration:
    map-underscore-to-camel-case: true
logging:
  level:
    org:
      springframework: WARN
    com:
      example:
        demo:
          domain:
            mapper: DEBUG

/src/main/resources/ValidationMessages.properties

# provided validation messages
javax.validation.constraints.NotBlank.message={0}は必須です
javax.validation.constraints.NotEmpty.message={0}は必須です
javax.validation.constraints.NotNull.message={0}は必須です
javax.validation.constraints.Email.message={0}はメールアドレス形式で入力してください
javax.validation.constraints.Pattern.message={0}の形式が不正です
javax.validation.constraints.Min.message={0}は{value}以上の数を入力してください
javax.validation.constraints.Max.message={0}は{value}以下の数を入力してください
javax.validation.constraints.Size.message={0}は{min}桁から{max}桁の範囲で入力してください

# original validation messages
com.example.demo.common.validation.MaxLength.message={0}は{value}桁以内で入力してください
com.example.demo.common.validation.Same.message=値が一致しません

# typemismatch messages
typeMismatch="{0}" is invalid.
typeMismatch.int="{0}" must be an integer.
typeMismatch.double="{0}" must be a double.
typeMismatch.float="{0}" must be a float.
typeMismatch.long="{0}" must be a long.
typeMismatch.short="{0}" must be a short.
typeMismatch.boolean="{0}" must be a boolean.
typeMismatch.java.lang.Integer="{0}" must be an Integer.
typeMismatch.java.lang.Double="{0}" must be a Double.
typeMismatch.java.lang.Float="{0}" must be a Float.
typeMismatch.java.lang.Long="{0}" must be a Long.
typeMismatch.java.lang.Short="{0}" must be a Short.
typeMismatch.java.lang.Boolean="{0}" is not a Boolean.
typeMismatch.java.util.Date="{0}" is not a Date.
typeMismatch.java.lang.Enum="{0}" is not a valid value.

# error messages
errors.users.password.notSame=パスワードが一致しません

/src/main/resources/application-messages.properties

# label
userName=ユーザー名
mailAddress=メールアドレス
password=パスワード
confirmPassword=パスワード(確認用)

はまった点

  • エラーメッセージにプロパティ名を埋め込めれなくて超ハマった。。。
messageSource.getMessage(objectError, Locale.getDefault())