見出し画像

【安定したAPI構築戦略】四つのポイント!Springboot3 第二回



はじめに


こんにちは、バックエンドでAPIを構築する時、大体で行う時もありますが、セキュリティやシステムの安定性のため、もっと堅固にロジックを構築する必要があります。今日はそのような戦略の4つとして「DTOパターン」、「マッピングライブラリの使用」、「例外処理」、「検証」戦略を実際のコード実装を通して説明します。

【ポイント1】DTOパターン


1. DTOを使うメリットとデメリット

DTO(Data Transfer Object)パターンはデータ転送オブジェクトパターンであり、主にサービス層とクライアント(または外部システム)間のデータ転送を簡素化し、管理するために使用されます。このパターンの長所と短所は下記の通りです。

メリット

  1. データ形式の分離:DTOパターンを使うと、サービス層とクライアント間のデータ形式を分離することができます。これにより、データを構造化し、必要な情報だけを転送することができます。

  2. 効率的なデータ転送:必要なデータのみを含むDTOオブジェクトを使用すると、ネットワーク帯域幅を節約し、不要なデータの転送を防ぐことができます。これにより、パフォーマンスを向上させることができます。

  3. セキュリティ強化: DTOパターンを使用すると、クライアントが必要なデータのみを受信するため、機密情報を露出させないことができます。

デメリット

  1. 繰り返しコード: DTOオブジェクトを生成してマッピングする過程で繰り返しコードが発生する可能性があります。これにより、コードの重複と複雑さが増加する可能性があります。

  2. オブジェクト変換オーバーヘッド: DTOオブジェクトとエンティティオブジェクト間のデータ変換過程でオーバーヘッドが発生する可能性があります。

  3. 間違った使用: DTOパターンを誤用すると、データ転送オブジェクトが過度に複雑になり、これによりコードの可読性と保守性が低下する可能性があります。


2. UserDtoの生成とコード修正

<UserDto>

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserDto {

    private Long id;
    private String firstName;
    private String lastName;
    private String email;
}

<UserController>

      // build create user REST API
      @PostMapping
      public ResponseEntity<UserDto> createUser(@RequestBody UserDto user) {
          UserDto savedUser = userService.createUser(user);
          return new ResponseEntity<>(savedUser, HttpStatus.CREATED);
      }

<UserService>

    UserDto createUser(UserDto user);

<UserServiceImpl>

    @Override
    public UserDto createUser(UserDto userDto) {

        // Convert UserDto into User JPA Entity
        User user = new User(
          userDto.getId(),
          userDto.getFirstName(),
          userDto.getLastName(),
          userDto.getEmail()
        );

        User savedUser = userRepository.save(user);

        // Convert User JPA entity to UserDto
        UserDto savedUserDto = new UserDto(
                savedUser.getId(),
                savedUser.getFirstName(),
                savedUser.getLastName(),
                userDto.getEmail()
        );

        return savedUserDto;
    }

Postmanでテストしてみると、DTOパターンが正常に動作していることが確認できます。


3. UserMapperクラスの作成とCRUDコードのリファクタリング

Mapperを使ってコードをもっと簡潔に作ってみます。

まず、mapperパッケージを作ってその中にUserMapperクラスを生成します。

<UserMapper>

public class UserMapper {

    // Convert User JPA Entity into UserDto
    public static UserDto mapToUserDto(User user) {
        UserDto userDto = new UserDto(
          user.getId(),
          user.getFirstName(),
          user.getLastName(),
          user.getEmail()
        );
        return userDto;
    }

    // Convert UserDto into User JPA Entity
    public static User mapToUser(UserDto userDto) {
        User user = new User(
                userDto.getId(),
                userDto.getFirstName(),
                userDto.getLastName(),
                userDto.getEmail()
        );
        return user;
    }
}

UserをUserDtoに変えるメソッドと、UserDtoをUserに変える二つのメソッドを作成します。


<UserServiceImpl>

    @Override
    public UserDto createUser(UserDto userDto) {

        // Convert UserDto into User JPA Entity
        User user = UserMapper.mapToUser(userDto);
        User savedUser = userRepository.save(user);

        // Convert User JPA entity to UserDto
        UserDto savedUserDto = UserMapper.mapToUserDto(savedUser);

        return savedUserDto;
    }

<UserController>

 // build get user by id REST API
    // http://localhost:8080/api/users/1
    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUserById(@PathVariable("id") Long userId) {
        UserDto user = userService.getUserById(userId);
         return new ResponseEntity<>(user, HttpStatus.OK);
    }

ユーザー1人を照会するコントローラーと下記のサービスロジックを変更します。タイプをUserからUserDtoに変えるようにします。

<UserService>

    UserDto getUserById(Long userId);

<UserServiceImpl>

@Override
    public UserDto getUserById(Long userId) {
        Optional<User> optionalUser = userRepository.findById(userId);
        User user = optionalUser.get();
        return UserMapper.mapToUserDto(user);
    }


Postmanでテストしてみたら、正常にユーザーを照会できました。

次は全てのユーザーを照会するAPIも修正しましょう。UserをUserDtoに変更します。

<UserController>

    // Build Get All Users REST API
    @GetMapping
    public ResponseEntity<List<UserDto>> getAllUsers() {
          List<UserDto> users = userService.getAllUsers();
          return new ResponseEntity<>(users, HttpStatus.OK);
    }

<UserService>

    List<UserDto> getAllUsers();

<UserServiceImpl>

    @Override
    public List<UserDto> getAllUsers() {
        List<User> users = userRepository.findAll();
        return users.stream().map(UserMapper::mapToUserDto)
                .collect(Collectors.toList());
    }

ユーザーリストを取得するのが少し難しいかもしれません。 return以下のコードを一つ一つ見てみましょう。

return users.stream().map(UserMapper::mapToUserDto).collect(Collectors.toList());

ユーザーリストをストリームに変換した後、各ユーザーをUserMapperクラスのmapToUserDtoメソッドを使ってDTOオブジェクトにマッピングして、その結果をリストとして収集(collect)して返します。このようにすることでデータベースで検索したユーザー情報をDTOに変換してクライアントに提供します。

UserMapper::mapToUserDto

この部分はメソッドリファレンス(Method Reference)を使ってUserMapperクラスのmapToUserDtoメソッドを呼び出すことを表します。このメソッドはUserオブジェクトをUserDtoオブジェクトに変換する役割をします。

なぜUserMapper.mapToUserDtoのように呼び出さずに参照メソッドを使うのでしょうか? もしUserMapper.mapToUserDtoのメソッドシグネチャ(メソッド名、パラメータ、リターンタイプ)が変更されても、メソッド参照を使ったところでは自動的に更新されるので、一貫性が維持されます。

つまり、メソッド参照を使用すると、コードの可読性と保守性を向上させ、関数型プログラミングのパラダイムをより簡単に適用することができます。したがって、Javaのような言語では、メソッド参照を積極的に活用することが推奨されるプログラミングスタイルです。

一方、collect(Collectors.toList())はJavaストリーム(Stream)の要素をリスト(List)に収集(collect)する演算です。通常、データを処理する時、ストリームを使うとデータ変換やフィルタリングなどの作業を効率的に行うことができます。 その後、collectメソッドを使ってストリームの結果を好きな形で収集したり、集めることができます。例えば、List<User>ストリームをList<UserDto>に変換したい時、collect(Collectors.toList())を使ってストリームの結果をList<UserDto>に集めることができます。このような作業はコードでデータを変換して収集して他の部分で使えるようにするのに便利です。

Javaストリーム(Stream)は要素(element)のシーケンスを表すデータ構造です。 この時、「要素」とはストリームが扱うデータの各項目を指します。ストリームはデータを処理して操作するための強力なツールとして使われます。要素は様々な形式とタイプがあり、ストリームはこれらの要素を連続的に処理し、変換するために使用されます。ストリームは強力な関数型プログラミングツールであり、マッピング、フィルタリング、ソート、グループ化、集計など様々な演算を適用してデータを処理・変換することができます。


次はユーザー情報を修正するAPIを修正してみましょう。

<UserController>

    // Build Update User Rest API
    @PutMapping("{id}")
    // http://localhost:8008/api/users/1
    public ResponseEntity<UserDto> updateUser(@PathVariable("id") Long userId,
                                           @RequestBody UserDto user) {
          user.setId(userId);
          UserDto updatedUser = userService.updateUser(user);
          return new ResponseEntity<>(updatedUser, HttpStatus.OK);
    }

<UserService>

    UserDto updateUser(UserDto user);

<UserServiceImpl>

    @Override
    public UserDto updateUser(UserDto user) {
        User existingUser = userRepository.findById(user.getId()).get();
        existingUser.setFirstName(user.getFirstName());
        existingUser.setLastName((user.getLastName()));
        existingUser.setEmail(user.getEmail());
        User updatedUser = userRepository.save(existingUser);
        return UserMapper.mapToUserDto(updatedUser);
    }


Postmanテスト成功!



【ポイント2】マッピングライブラリの利用


先にした作業のように一つ一つUserをUserDtoに相互変換をするのは面倒なことです。 だから、Mappingライブラリを使う方がいいです。 このライブラリを使うのは様々なデータ構造間でデータを変換したりコピーする作業を簡素化し、開発者の作業負担を減らすためです。

1. モデルマッパー(ModelMapper)ライブラリを使ったコードリファクタリング

<pom.xml>

		<!-- https://mvnrepository.com/artifact/org.modelmapper/modelmapper -->
		<dependency>
			<groupId>org.modelmapper</groupId>
			<artifactId>modelmapper</artifactId>
			<version>3.1.1</version>
		</dependency>

<SpringbootRestfulWebservicesApplication>

	@Bean
	public ModelMapper modelMapper() {
		return new ModelMapper();
	}

<UserServiceImpl>

@Service
@AllArgsConstructor
public class UserServiceImpl implements UserService {

    private UserRepository userRepository;

    private ModelMapper modelMapper;

    @Override
    public UserDto createUser(UserDto userDto) {

        // Convert UserDto into User JPA Entity
        //User user = UserMapper.mapToUser(userDto);

        User user = modelMapper.map(userDto, User.class);

        User savedUser = userRepository.save(user);

        // Convert User JPA entity to UserDto
        //UserDto savedUserDto = UserMapper.mapToUserDto(savedUser);

        UserDto savedUserDto = modelMapper.map(savedUser, UserDto.class);

        return savedUserDto;
    }

    @Override
    public UserDto getUserById(Long userId) {
        Optional<User> optionalUser = userRepository.findById(userId);
        User user = optionalUser.get();
        //return UserMapper.mapToUserDto(user);
        return modelMapper.map(user, UserDto.class);
    }

    @Override
    public List<UserDto> getAllUsers() {
        List<User> users = userRepository.findAll();
//        return users.stream().map(UserMapper::mapToUserDto)
//                .collect(Collectors.toList());
        return users.stream().map((user) -> modelMapper.map(user, UserDto.class))
                .collect(Collectors.toList());
    }

    @Override
    public UserDto updateUser(UserDto user) {
        User existingUser = userRepository.findById(user.getId()).get();
        existingUser.setFirstName(user.getFirstName());
        existingUser.setLastName(user.getLastName());
        existingUser.setEmail(user.getEmail());
        User updatedUser = userRepository.save(existingUser);
        //return UserMapper.mapToUserDto(updatedUser);
        return modelMapper.map(updatedUser, UserDto.class);

    }

まず、サービスで「private ModelMapper modelMapper; コードでModelMapperという外部ライブラリを使うため必要なメンバー変数を作成します。

残りはCRUビジネスロジックでUserMapperで作ったコードを全てmodelMapperに変えてリファクタリングします。



2. マップストラクト(MapStruct)ライブラリの追加

MapStructのドキュメンテーションのExamplesに入って、GitHubにあるLombok関連の依存関係を追加する部分を探します、

自分のコードの中の<pom.xml>に依存性とプラグインを追加します。

忘れちゃいけない点は<source>1.8</source>と<target>1.8</target>の部分を1.8の代わりに17に変えなければならない点です。(Javaバージョン)


	<properties>
		<java.version>17</java.version>

		<org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
		<org.projectlombok.version>1.18.20</org.projectlombok.version>

	</properties>

...

		<!-- https://mvnrepository.com/artifact/org.modelmapper/modelmapper -->
		<dependency>
			<groupId>org.modelmapper</groupId>
			<artifactId>modelmapper</artifactId>
			<version>3.1.1</version>
		</dependency>

		<dependency>
			<groupId>org.mapstruct</groupId>
			<artifactId>mapstruct</artifactId>
			<version>${org.mapstruct.version}</version>
		</dependency>

...

<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>

			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-compiler-plugin</artifactId>
				<version>3.8.1</version>
				<configuration>
					<source>17</source>
					<target>17</target>
					<annotationProcessorPaths>
						<path>
							<groupId>org.mapstruct</groupId>
							<artifactId>mapstruct-processor</artifactId>
							<version>${org.mapstruct.version}</version>
						</path>
						<path>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
							<version>${org.projectlombok.version}</version>
						</path>
						<path>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok-mapstruct-binding</artifactId>
							<version>${lombok-mapstruct-binding.version}</version>
						</path>
					</annotationProcessorPaths>
				</configuration>
			</plugin>

		</plugins>
	</build>


3. マップストラクト(MapStruct)ライブラリによるマッパー生成

UserエンティティとUserDto間のフィールド名が現在は同じですが、違う場合もあります。

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false)
    private String firstName;
    @Column(nullable = false)
    private String lastName;
    @Column(nullable = false, unique = true)
    private String email;
}

public class UserDto {
    private Long id;
    private String firstName;
    private String lastName;
    private String emailAddress;
}

4番目のフィールドであるemailとemailAddressの名前が違います。このような場合、AutoUserMapperでマッピング名を調整すればいいです。

@Mapper
public interface AutoUserMapper {

    @Mapping(source = "email", target = "emailAddress")
    UserDto mapToUserDto(User user);

    User mapToUser(UserDto userDto);


}

Mapping(source = "email", target = "emailAddress") が見えますか? エンティティのフィールド名とDTOのフィールド名をマッピングしています。


とにかく他のフィールド名が違う場合はそうだということで、まずは原状回復をしてAutoUserMapperクラスを再作成します。

@Mapper
public interface AutoUserMapper {

    // provide the implementation for this interface at compilation time.
    AutoUserMapper MAPPER = Mappers.getMapper(AutoUserMapper.class);

    UserDto mapToUserDto(User user);

    User mapToUser(UserDto userDto);


}

"AutoUserMapper MAPPER = Mappers.getMapper(AutoUserMapper.class);" このコードはコンパイル時にMappersを通したインターフェースでMAPPERオブジェクトを生成して、他の場所で使えるようにします。


4. マップストラクチャ(MapStruct)ライブラリを使ってビジネスロジックでマッパーを使う

@Override
    public UserDto createUser(UserDto userDto) {

        // Convert UserDto into User JPA Entity
       // User user = UserMapper.mapToUser(userDto);

        //User user = modelMapper.map(userDto, User.class);

        User user = AutoUserMapper.MAPPER.mapToUser(userDto);

        User savedUser = userRepository.save(user);

        // Convert User JPA entity to UserDto
        //UserDto savedUserDto = UserMapper.mapToUserDto(savedUser);

        //UserDto savedUserDto = modelMapper.map(savedUser, UserDto.class);

        UserDto savedUserDto = AutoUserMapper.MAPPER.mapToUserDto(savedUser);

        return savedUserDto;
    }

    @Override
    public UserDto getUserById(Long userId) {
        Optional<User> optionalUser = userRepository.findById(userId);
        User user = optionalUser.get();
        //return UserMapper.mapToUserDto(user);
        //return modelMapper.map(user, UserDto.class);
        return AutoUserMapper.MAPPER.mapToUserDto(optionalUser.get());
    }

    @Override
    public List<UserDto> getAllUsers() {
        List<User> users = userRepository.findAll();
//        return users.stream().map(UserMapper::mapToUserDto)
//                .collect(Collectors.toList());

//        return users.stream().map((user) -> modelMapper.map(user, UserDto.class))
//                .collect(Collectors.toList());

        return users.stream().map((user) -> AutoUserMapper.MAPPER.mapToUserDto(user))
                .collect(Collectors.toList());
    }

    @Override
    public UserDto updateUser(UserDto user) {
        User existingUser = userRepository.findById(user.getId()).get();
        existingUser.setFirstName(user.getFirstName());
        existingUser.setLastName(user.getLastName());
        existingUser.setEmail(user.getEmail());
        User updatedUser = userRepository.save(existingUser);
        //return UserMapper.mapToUserDto(updatedUser);
        //return modelMapper.map(updatedUser, UserDto.class);
        return AutoUserMapper.MAPPER.mapToUserDto(updatedUser);
    }

またまたコードリファクタリングをしました。それぞれのCRUメソッド内でmodelMapper.mapを使う代わりにAutoUserMapper.MAPPERに変わったことが確認できます。


【ポイント3】SpringBootの例外処理(Exception Handling)


スプリングエラーハンドリング構造

エラー(Error)と例外(Exception)の違いについて区別する必要があります。

エラー(Error)は、システムが終了しなければならないレベルの状況など、修復できない深刻な問題を意味します。開発者が事前に予測して防ぐことができません。 エラーは主にシステムレベルで発生し、メモリ不足、ハードウェア障害またはJVM(Java Virtual Machine)の内部エラーのような深刻な問題を意味します。

例外(Exception)は開発者が実装したロジックで発生したミスやユーザーの影響によって発生します。開発者が事前に予測して防止することができるので、状況に合わせた例外処理(Exception Handle)をする必要があります。例外は主にプログラムロジックやユーザー入力でのエラー、ファイル入出力問題、検証エラーなどの状況で発生します。


Error, Exception, Throwable間の継承関係

エラーと例外ともにJavaの最上位クラスであるObjectを継承します。 そして、その間にはThrowableというクラスと継承関係があります。 このクラスのオブジェクトにエラーや例外に対するメッセージを入れます。Throwableオブジェクトが持ってる情報とできる行為はgetMessage()とprintStackTrace()というメソッドで実装されていてThrowableと継承関係であるErrorとExceptionで二つのメソッドを使うことができます。

1. ユーザー定義例外生成及び使用方法1 - ResourceNotFoundException


exceptionパッケージを作成し、ResourceNotFoundExceptionクラスを生成します。


<ResourceNotFoundException >

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {

    private String resourceName;
    private String fieldName;
    private Long fieldValue;

    public ResourceNotFoundException(String resourceName, String fieldName, Long fieldValue) {
        super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue));
        this.resourceName = resourceName;
        this.fieldName = fieldName;
        this.fieldValue = fieldValue;

    }

}

RunTimeException を継承しており、メソッド内で受け取った引数でフィールド値を設定します。


<UserServiceImpl>


    @Override
    public UserDto getUserById(Long userId) {
        //Optional<User> optionalUser = userRepository.findById(userId);
        User user = userRepository.findById(userId).orElseThrow(
                () -> new ResourceNotFoundException("User", "id", userId)
        );

        //User user = optionalUser.get();
        //return UserMapper.mapToUserDto(user);
        //return modelMapper.map(user, UserDto.class);
        //return AutoUserMapper.MAPPER.mapToUserDto(optionalUser.get());
        return AutoUserMapper.MAPPER.mapToUserDto(user);
    }

    @Override
    public UserDto updateUser(UserDto user) {
        //User existingUser = userRepository.findById(user.getId()).get();
        User existingUser = userRepository.findById(user.getId()).orElseThrow(
                () -> new ResourceNotFoundException("User", "id", user.getId())
        );

        existingUser.setFirstName(user.getFirstName());
        existingUser.setLastName(user.getLastName());
        existingUser.setEmail(user.getEmail());
        User updatedUser = userRepository.save(existingUser);
        //return UserMapper.mapToUserDto(updatedUser);
        //return modelMapper.map(updatedUser, UserDto.class);
        return AutoUserMapper.MAPPER.mapToUserDto(updatedUser);
    }

    @Override
    public void deleteUser(Long userId) {

        User existingUser = userRepository.findById(userId).orElseThrow(
                () -> new ResourceNotFoundException("User", "id", userId)
        );

        userRepository.deleteById(userId);
    }

照会、修正、削除ビジネスロジックでそれぞれ例外処理コードを追加してリファクタリングしました。特に、照会ビジネスロジックで例外処理を別々にし、Optionalが消えてUser型に変更したことが分かります。ちなみにOptionalはNullPointExceptionを防止するためのタイプで、findByIdメソッドは戻り値にOptionalが設定されています。


orElseThrow の引数内部にはラムダ表現で ResourceNotFoundException オブジェクトを生成して引数として受け取ります。



DBを確認するとuserId 8番までしかないことが確認できます。


Postmanを使ってGET, PUT, DELETEを一つずつテストしてみましょう。カスタマイズされた例外処理メッセージ("User not found with id : '10'")が見えます。成功したようです。


2. ユーザー定義例外生成及び使用方法2 - GlobalExceptonHandler


ErrorDetailsとGlobalExceptionHandlerクラスを作成します。


<ErrorDetails>

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ErrorDetails {
    private LocalDateTime timestamp;
    private String message;
    private String path;
    private String errorCode;
}

<GlobalExceptionHandler >

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorDetails> handleResourceNotFoundException(ResourceNotFoundException exception,
                                                                        WebRequest webRequest) {
        ErrorDetails errorDetails = new ErrorDetails(
                LocalDateTime.now(),
                exception.getMessage(),
                webRequest.getDescription(false),
                "USER_NOT_FOUND"
        );

        return new ResponseEntity<>(errorDetails, HttpStatus.NOT_FOUND);
    }

}

@ControllerAdviceはグローバルな例外処理のためのクラスに使用されます。このアノテーションを使うと、複数のコントローラーで発生する例外を処理し、特定の例外処理ロジックを集中化することができます。例外処理メソッドを定義する必要がありますが、@ExceptionHandlerアノテーションを使って例外処理メソッドを定義します。



3. ユーザー定義例外生成及び使用方法3 - EmailAlreadyExistsException

メールが既にある場合は例外をスローします。

まず、exceptionパッケージに EmailAlreadyExistsException クラスを作成します。

<EmailAlreadyExistsException >

@ResponseStatus(value = HttpStatus.BAD_REQUEST)
public class EmailAlreadyExistsException extends RuntimeException {
    private String message;

    public EmailAlreadyExistsException(String message) {
        super(message);

    }
}

<UserServiceImpl>

@Override
    public UserDto createUser(UserDto userDto) {
        Optional<User> optionalUser = userRepository.findByEmail(userDto.getEmail());

        if(optionalUser.isPresent()) {
            throw new EmailAlreadyExistsException("Email Already Exists for User");

        }
        ...
        return savedUserDto;
    }

<UserRepository>

    Optional<User> findByEmail(String email);


optionalUserをUserRepositoryでEmailで探してそのデータがあったら(isPresent)例外をスローします。

findByEmailメソッドはUserRepositoryで別途に作成する必要があります。この時、タイプはOptional<User>であることを忘れないでください。


<GlobalExeptionHandler>

 @ExceptionHandler(EmailAlreadyExistsException.class)
    public ResponseEntity<ErrorDetails> handleEmailAlreadyExistsException(EmailAlreadyExistsException exception,
                                                                        WebRequest webRequest) {
        ErrorDetails errorDetails = new ErrorDetails(
                LocalDateTime.now(),
                exception.getMessage(),
                webRequest.getDescription(false),
                "USER_EMAIL_ALREADY_EXISTS"
        );

        return new ResponseEntity<>(errorDetails, HttpStatus.NOT_FOUND);
    }

GlobalExeptionHandlerでhandleEmailAreadyExistsExceptionメソッドを生成します。先に作成したhandleResourceNotFoundExceptionメソッドを使ってパラメータのタイプ、エラーオブジェクトでメッセージを適切に変更します。

PostmanでPOST形式で既にあるメールアドレスにリクエストを送ると、messageに「Email Already Exists for User」、エラーコードに「USER_EMAIL_ALREADY_EXISTS」メッセージがうまく表示されることが確認できます。



4. ユーザー定義例外生成及び使用方法4 -REST API Global Exception Handling

<GlobalExceptionHandler>

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorDetails> handleGlobalException(Exception exception,
                                                                          WebRequest webRequest) {
        ErrorDetails errorDetails = new ErrorDetails(
                LocalDateTime.now(),
                exception.getMessage(),
                webRequest.getDescription(false),
                "INTERNAL SERVER ERROR"
        );

        return new ResponseEntity<>(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR);
    }

この handleGlobalException メソッドは Exception クラスを継承しています。Exception クラスはすべての例外処理の上位クラスです。

PostmanでBodyに何も入れずにリクエストを送ると、例外処理として、「INTERNAL SERVER ERROR」というエラーがよく出ることが確認できます。



【ポイント4】REST APIリクエスト検証 (Request Validation)


Java BeanバリデーションAPIは事実上のバリデーション処理のための標準です。HIbernate Validatorはvalidation APIの参照実装です。

いくつかのBean Validationアノテーションを見てみましょう。

  1. @NotNull : アノテーションが付いた属性値がnullでないことを検証します。

  2. @Size : 最大値と最小値の間にあることを検証します。String, Collection Map, Array属性に適用できます。

  3. @Min

  4. @Max

  5. @Email : 有効なメールアドレスであることを検証します。

  6. @NotEmpty : nullまたは空であることを検証します。STring, Collectipn, Map, Array 値に適用できます。

  7. @NotBlank : テキスト値にのみ適用可能で、nullか空白かを検証します。


1. Validation依存性の追加

<pom.xml>

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


2. ValidationアノテーションをUserDtoに追加

<UserDto>

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserDto {

    private Long id;

    // User first name should not be null or empty
    @NotEmpty
    private String firstName;

    // User last name should not be null or empty
    @NotEmpty
    private String lastName;

    // User email should not be null or empty
    // Email address should be valid
    @NotEmpty
    @Email
    private String email;
}


3. @Validを使って生成と修正APIでValidationを有効化

@PostMapping
    public ResponseEntity<UserDto> createUser(@Valid @RequestBody UserDto user){
        UserDto savedUser = userService.createUser(user);
        return new ResponseEntity<>(savedUser, HttpStatus.CREATED);
    }

...

    @PutMapping("{id}")
    // http://localhost:8080/api/users/1
    public ResponseEntity<UserDto> updateUser(@PathVariable("id") Long userId,
                                           @RequestBody @Valid UserDto user){
        user.setId(userId);
        UserDto updatedUser = userService.updateUser(user);
        return new ResponseEntity<>(updatedUser, HttpStatus.OK);
    }

ユーザー作成と修正コントローラのDTOパラメータに@Validを追加します。

PostmanでfirstName, lastName, emailに空白を入れてリクエストを送ると、validationが動作することが確認できます。



4. Validationエラー応答変更と送信

私たちは先ほどPostmanテストで「500 Internal Server Error」を受信したことを確認しました。

このメッセージの代わりに「BAD_REQUEST」のように開発者が指定した他の応答を投げたい場合はどうしたらいいでしょうか?

GlobalExceptionHandlerクラスの中で一つのメソッドを作るようにしましょう。

@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { 

......

@Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
                                                                  HttpHeaders headers,
                                                                  HttpStatusCode status,
                                                                  WebRequest request) {

        Map<String, String> errors = new HashMap<>();
        List<ObjectError> errorList = ex.getBindingResult().getAllErrors();

        errorList.forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String message = error.getDefaultMessage();
            errors.put(fieldName, message);
        });

        return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
    }

まず、GlobalExceptionHandlerがResponseEntityExceptionHandlerを継承するようにします。

次はhandleMethodArgumentNotValidをOverrideしてオーバーライドしてその詳細なロジックを変更します。

コードを1行1行分析してみましょう。

このメソッドはMethodArgumentNotValidException例外を処理します。

この例外は主にスプリングの@Validアノテーションを使ってリクエストバインディング時発生する検証エラーを表します。

メソッドのパラメータは下記の4つがあります。

ex: MethodArgumentNotValidException 例外オブジェクト。

headers: HTTP 応答ヘッダ
status: HTTP ステータスコード
request: ウェブリクエスト情報を表すオブジェクト
Map<String, String> errors

バリデーションエラーが含まれるマップを生成します。

マップのキーはフィールド名、値はそのフィールドのエラーメッセージです。

st<ObjectError> errorList

MethodArgumentNotValidException で提供されるバインディング結果からすべてのエラーを取得します。

これらのエラーは、検証で発生した問題を示します。

errorList.forEach((error) -> { ... })

すべてのエラーを循環しながら、各エラーの処理を実行します。

String fieldName = ((FieldError) error).getField();

エラーが発生したフィールドの名前を取得します。

String message = error.getDefaultMessage();

エラーの基本メッセージを取得します。

return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);

処理されたエラー情報を含む ResponseEntity オブジェクトを生成して返します。

この応答はクライアントに 「BAD_REQUEST」 ステータスコードとエラーメッセージを返します。

Postmanで実行してみるとStatusが「400 Bad Request」に変わっていて、応答値も同じであることが確認できます。


応答値のメッセージはどう変えればいいのでしょうか?

UserDtoの各フィールドのアノテーションにmessageを追加すればいいです。

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserDto {

    private Long id;

    // User first name should not be null or empty
    @NotEmpty(message = "User first name should not be null or empty")
    private String firstName;

    // User last name should not be null or empty
    @NotEmpty(message = "User last name should not be null or empty")
    private String lastName;

    // User email should not be null or empty
    // Email address should be valid
    @NotEmpty(message = "User email should not be null or empty")
    @Email(message = "Email address should be valid")
    private String email;
}

Postmanを使ってリクエストを送ると、メッセージが変わっていることが確認できます。


emailを正しい形式で書かなくても検証が動作することが確認できます。


いよいよ最後のテスト、ユーザー修正、PUTでテストをしてみよう!~!~!

HTTP MethodをPUTにしてlastNameにスペースを、emailに不完全な形のメールアドレスを入れてリクエストを送りました。

応答値でValidationが動作してBad Requestを返したことが確認できます。


最後に


実は今までコードを実装すれば終わりだ、このように安易に考えたことが一度や二度ではありません。 理由は様々です。 時間に追われて、面倒だからなど...でも、堅固なバックエンドが実装されているほど頼もしいものはないようです。安定的なシステム構築のため、多少面倒でも上記の4つの戦略を導入して開発しなければなりません。


エンジニアファーストの会社 株式会社CRE-CO
ソンさん




この記事が気に入ったらサポートをしてみませんか?