Exception Handling with RESTful Web Services part 3/3
Spring Framework ในขณะนี้เวอร์ชัน 5 และ Spring Boot ขณะนี้เวอร์ชัน 2 การพัฒนา framework คือการหาทางแก้ไขปัญหาที่มีอยู่ ทำให้ปัญหาถูกจัดการง่ายขึ้น หนึ่งในเรื่องคือ Exception ที่เกิดขึ้นระหว่างที่โปรแกรมทำงาน และเรามุ่งไปที่การรายงานผลของสิ่งที่เกิดขึ้นผ่านคลาส ResponseEntity
จาก part 1 และ 2 ผ่านมา
เมื่อโค้ดพังและ Exception ถูกโยนออกมา พัฒนาการของการจัดการที่ผมรวบรวมมาได้เรียงลำดับจากเก่าไปใหม่ (ถึงปัจจุบันของบทความนี้) มีดังนี้
- ใช้ @ExceptionHandler วางไว้ใน Controller เหนือชื่อ method นั้นๆ
- ใช้ HandlerExceptionResolver เล่นกับ ModelAndView
- ใช้ @ControllerAdvice ดึงเอาการจัดการ Exception ไว้เป็น Global
- ใช้ ResponseStatusException
ก่อนจะกล่าวถึง ResponseStatusException ขอยกตัวอย่างการใช้ @ResponseStatus ของ part ก่อนหน้าไว้ดังนี้
package com.pros.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(String message) {
super(message);
}
}
เวลาใช้ ตัวอย่าง
@GetMapping("/books/{id}")
public ResponseEntity findById(@PathVariable Long id) {
Optional<Book> optional = bookRepository.findById(id);
if (optional.isPresent()) {
Book book = optional.get();
SuccessResponse<Book> response = new SuccessResponse<>();
response.setData(book);
response.setBusinessCode(BusinessCode.B000);
return ResponseEntity.ok(response);
} throw new BookNotFoundException("Book Not Found");
}
สำคัญสิ่งที่ได้คือ status กับ message
{
"timestamp": "2019-07-24T19:15:55.789+0000",
"status": 404,
"error": "Not Found",
"message": "Book Not Found",
"path": "/books/1"
}
ลองคิดว่าหากเราใช้ Javascript ส่งคำขอและรอ response กลับมา สิ่งที่มันควรทำคือจับและจัดการกับ status ที่ได้รับ สมมติได้รับ 404 ก็ควรรายงาน message ออกไป หัวใจของมันก็มีอยู่เท่านี้
และหากว่าเราไม่ได้ให้ความสำคัญกับชื่อของคลาส เช่น BookNotFoundException คือบอกว่าพังจากตรงไหน แต่ไปให้ความสำคัญกับ message ที่ return กลับไปมากกว่า อย่างนั้นแล้ว ResponseStatusException ที่มีใน Spring 5 นี้ก็ตอบโจทย์ มีความสง่างามเพียงพอ
@GetMapping("/books/{id}")
public ResponseEntity findById(@PathVariable Long id) {
Optional<Book> optional = bookRepository.findById(id);
if (optional.isPresent()) {
Book book = optional.get();
SuccessResponse<Book> response = new SuccessResponse<>();
response.setData(book);
response.setBusinessCode(BusinessCode.B000);
return ResponseEntity.ok(response);
}
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Book Not Found");
}
ผลลัพธ์ที่ได้ก็ไม่ต่างกัน
{
"timestamp": "2019-07-26T00:19:10.490+0000",
"status": 404,
"error": "Not Found",
"message": "Book Not Found",
"path": "/books/1"
}
@ExceptionHandler
ใช้กับ Exception ที่เกิดในระดับ Controller กล่าวคือให้วางไว้เหนือเมธอดภายใน Controller ที่อาจเกิด Exception นั้นเพื่อเรียกคลาสที่รับผิดชอบ (ได้มากกว่า 1 คลาส) มาจัดการ ตัวอย่าง
public class FooController{
@ExceptionHandler({ CustomException1.class, CustomException2.class })
public void handleException() {
//...
}
}
อ่านเพิ่มเติม
เอาล่ะ ตรงนี้ให้ทดไว้ในใจก่อน
หันมาสร้าง Customer อีกหนึ่งชุด ได้แก่
- Customer Entity
- Customer Repository
- Customer Controller
- CustomerNotFoundException
คลาส Customer
package com.pros.model;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
@Entity
public class Customer {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long id;
@NotEmpty
private String email;
private String firstName;
private String lastName; // getter and setter
}
CustomerRepository Interface
package com.pros.repository;
import com.pros.model.Customer;
import org.springframework.data.repository.CrudRepository;
public interface CustomerRepository extends CrudRepository<Customer, Long> {
}
คลาส CustomerController
package com.pros.controller;
import com.pros.exception.CustomerNotFoundException;
import com.pros.model.SuccessResponse;
import com.pros.model.BusinessCode;
import com.pros.model.Customer;
import com.pros.repository.CustomerRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.Optional;
@RestController
public class CustomerController {
@Autowired
private CustomerRepository customerRepository;
@GetMapping("/customers/{id}")
public ResponseEntity findById(@PathVariable Long id) {
Optional<Customer> optional = customerRepository.findById(id);
if (optional.isPresent()) {
Customer customer = optional.get();
SuccessResponse<Customer> response = new SuccessResponse<>();
response.setData(customer);
response.setBusinessCode(BusinessCode.B000);
return ResponseEntity.ok(response);
}
throw new CustomerNotFoundException("Customer Not Found");
}
@PostMapping("/customers")
public ResponseEntity create(@Valid @RequestBody Customer customer) {
Customer c = customerRepository.save(customer);
SuccessResponse<Customer> response = new SuccessResponse<>();
response.setData(c);
response.setBusinessCode(BusinessCode.B000);
return new ResponseEntity(response, HttpStatus.CREATED);
}
}
คลาส CustomerNotFoundException
package com.pros.exception;
public class CustomerNotFoundException extends RuntimeException {
public CustomerNotFoundException(String message) {
super(message);
}
}
โค้ดข้างต้นทั้งหมดเขียนขึ้นเท่าที่ผมรู้และเข้าใจในตอนนี้ เพื่อนๆที่ติดตามมาตั้งแต่ part ก่อนจะสังเกตได้ว่ามีหลายสิ่งที่เปลี่ยนไปและหลายอย่างที่เพิ่มขึ้น
ดูที่สำคัญก่อน
@GeneratedValue(strategy = GenerationType.IDENTITY)
แรกใช้ GenerationType เป็น Auto ครับ แต่พอค้นเอกสารได้ความว่า Oracle Database 12c สนับสนุน strategy นี้แล้ว
อ่านเพิ่มเติม
ที่คลาส Customer ผมอยากให้ลูกค้าใช้ email มาลงทะเบียน ดังนั้น email ต้องไม่ว่างและรูปแบบ email ต้องถูกต้อง
@Email
@NotEmpty
private String email;
ผมได้สร้างคลาสเพิ่มเพื่อส่งค่า response กลับไปชื่อ SuccessResponse กับ FailureResponse เป้าหมายคือรายงานผลลัพธ์แก่ Client
- SuccessResponse เพราะเรารับทราบว่า “โค้ดของเราหรือระบบปลายทางที่เรียก” นั้นถูกต้อง (ส่ง 200 OK ให้เรา) เราจึงส่ง 200 OK แก่ Client มี data attribute รับผิดชอบเก็บข้อมูลไว้
- FailureResponse เพราะเรารับทราบว่า “โค้ดของเราหรือระบบปลายทางที่เรียก” นั้นไม่ถูกต้อง (ส่ง HTTP status code ที่แสดงถึงความผิดพลาดให้เรา) เราจึงต้องส่ง HTTP status code ที่ผิดพลาดนั่นแก่ Client และควรแบบตรงไปตรงมา มี errors attribute รับผิดชอบเก็บข้อมูลไว้
แท้จริงข้างต้นนี้เป็นเรื่องของการออกแบบระหว่างสองระบบที่ต้องคุยกันครับ อันนี้ผมเอาประสบการณ์จากที่ทำงานมาประยุกต์ บวกกับความรู้ที่มีตอนนี้จึงได้แนวทางออกมาว่า success กับ failure
สิ่งที่เพื่อนๆสังเกตเห็นได้อีกคือ
response.setBusinessCode(BusinessCode.B000);
บ่อยครั้งที่สองระบบคุยกันจำต้องส่ง Business Code หากัน กล่าวคือ สองระบบติดต่อกันได้ปกติ 200 OK แต่มี Business Code แตกต่างกันแล้วแต่กำหนดแล้วแต่กรณี เช่น
- ติดต่อกันได้ปกติ แต่ระบบ A ไม่ได้ส่งค่า email มาให้ระบบ B, ระบบ B จึงแจ้ง business code ว่า EMAIL_IS_EMPTY_001 (สมมติ) กล่าวคือต้องการบอกแก่ระบบ A ว่าส่งค่า email มาให้ด้วยนะ ระดับการแจ้งเป็นแค่ information ดังนั้นไม่อยู่ในขอบเขตของ failure แต่เป็น success (อันนี้แล้วแต่พิจารณา)
- ติดต่อกันได้ปกติ แต่ระบบ A กลับไม่ได้ค่า postcode ตามที่คาดหวัง ได้ค่าเป็นสตริงว่างหรือค่า null เฉยเลย, ระบบ A จึงรายงานไปยัง Client ว่าดำเนินการต่อไม่ได้ สืบเนื่องจากระบบ B ไม่ส่งค่า postcode ที่ถูกต้องมาให้ ระดับการแจ้งเป็นแค่ information ดังนั้นไม่อยู่ในขอบเขตของ failure แต่เป็น success (อันนี้แล้วแต่พิจารณา)
การออกแบบ Business Code หรือจะใช้คำว่า Technical Code ขึ้นอยู่กับตกลงกัน
สิ่งที่เพื่อนๆสังเกตเห็นได้อีกคือ
return ResponseEntity.ok(response);
แท้จริงเป็นแค่วิธีการเขียน เหมือนกันกับ
return new ResponseEntity(response, HttpStatus.OK);
คลาส SuccessResponse
package com.pros.model;
import java.util.Date;
public class SuccessResponse<T> {
private T data;
private String businessCode;
public SuccessResponse() {
}
public SuccessResponse(T data, String businessCode) {
this.data = data;
this.businessCode = businessCode;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public String getBusinessCode() {
return businessCode;
}
public void setBusinessCode(String businessCode) {
this.businessCode = businessCode;
}
public Date getTimestamp() {
return new Date();
}
}
คลาส FailureResponse
package com.pros.model;
import java.util.Date;
public class FailureResponse<T> {
private T errors;
private int status;
public FailureResponse() { }
public FailureResponse(T errors, int status) {
this.errors = errors;
this.status = status;
}
public T getErrors() {
return errors;
}
public void setErrors(T errors) {
this.errors = errors;
}
public int getStatus() {
return status;
}
public void setStatus(int status) {
this.status = status;
}
public Date getTimestamp() {
return new Date();
}
}
@ControllerAdvice
มาถึงพระเอกของเราและส่วนสุดท้ายของเรื่องนี้ @ControllerAdvice ด้วยเหตุผลที่ว่าจะดีกว่านี้ไหม
- ถ้าเราให้ @ExceptionHandler ที่กระจายอยู่เหนือเมธอดของ Controllers (แน่นอนว่าตัวอย่างของผมไม่ได้ใช้) ไปรวมกันไว้ ณ แห่งเดียว
- ถ้าให้ควาสที่รับผิดชอบ Exception (เช่น BookNotFoundException, CustomerNotFoundException) ถูกจัดการรูปแบบ messages ที่จะบอกแก่ Client ในรูปแบบเดียวกัน (สิ่งนี้เล็กน้อยมากสำหรับผม ทว่าก็แล้วแต่เพื่อนๆพิจารณา)
- ถ้าเราสามารถปรับแต่ง (customize) รูปแบบ messages ที่จะบอกแก่ Client โดยเฉพาะกับ @Valid
มาดูกันเลย ภาพล่างนี้คือโครงสร้างโปเจกต์ตัวอย่างในขณะนี้
ผมสร้างคลาสชื่อ GlobalResponseEntityExceptionHandler ให้สืบทอดสมบัติจาก ResponseEntityExceptionHandler แล้ว
- กำหนดให้เป็น @ControllerAdvice
- override เมธอด handleMethodArgumentNotValid ซึ่งเป็นตัวสร้างรูปแบบ messages ส่งไปยัง Client เมื่อ @Valid ตรวสอบได้ว่า JSON ที่รับมาไม่ถูกต้องตามที่ validate ไว้ใน entities (java bean ได้แก่ Book และ Customer)
- สร้าง 1 เมธอดรับผิดชอบแค่ 1 คลาส exception ที่ถูกโยนออกมา จับโดย @ExceptionHandler
package com.pros.exception;
import com.pros.model.FailureResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.util.List;
import java.util.stream.Collectors;
@ControllerAdvice
public class GlobalResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatus status,
WebRequest request) {
List<String> list = ex.getBindingResult().getFieldErrors().stream().map(e -> e.getDefaultMessage()).collect(Collectors.toList());
FailureResponse<List<String>> response = new FailureResponse<>();
response.setErrors(list);
response.setStatus(status.value());
return handleExceptionInternal(ex, response, headers, status, request);
}
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity handleBookNotFoundException(BookNotFoundException ex) {
HttpStatus status = HttpStatus.NOT_FOUND;
FailureResponse<String> response = new FailureResponse<>();
response.setErrors(ex.getMessage());
response.setStatus(status.value());
return new ResponseEntity(response, status);
}
@ExceptionHandler(CustomerNotFoundException.class)
public ResponseEntity handleCustomerNotFoundException(CustomerNotFoundException ex) {
HttpStatus status = HttpStatus.NOT_FOUND;
FailureResponse<String> response = new FailureResponse<>();
response.setErrors(ex.getMessage());
response.setStatus(status.value());
return new ResponseEntity(response, status);
}
}
ทดสอบผลลัพธ์
กรณีหา ID ของหนังสือไม่พบ
กรณีหา ID ของลูกค้าไม่พบ
กรณี email ของลูกค้าไม่ถูกต้อง
แต่ถ้า email ของลูกค้าถูกต้อง
ข้อสังเกต
List<String> ในเมธอด handleMethodArgumentNotValid คืนค่าเป็นกลุ่ม สัญลักษณ์ที่เห็นคืออาร์เรย์ [ ] แตกต่างจาก String ในเมธอด handleBookNotFoundException เฉยๆ ตามความเห็นของผมมันควรทำให้เหมือนกัน เพื่อที่ Client จะได้จัดการง่ายขึ้น จึงแก้ไขเป็น
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity handleBookNotFoundException(BookNotFoundException ex) {
HttpStatus status = HttpStatus.NOT_FOUND;
List<String> list = Arrays.asList(ex.getMessage());
FailureResponse<List<String>> response = new FailureResponse<>();
response.setErrors(list);
response.setStatus(status.value());
return new ResponseEntity(response, status);
}
กรณีหา ID ของหนังสือไม่พบ
ผมไม่แปะโค้ดทั้งหมดเนื่องจากอยากให้ลองคิดและฝึกตาม บางทีเพื่อนๆก็จะได้แง่มุมใหม่ๆที่ดีกว่า งานเหล่านี้เป็นเรื่องของการ “ออกแบบ” มากน้อย ยากง่าย ตามตกลงใจนะครับ