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

  • バリデーション処理の実装
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())