Exception Handling with RESTful Web Services part 2/3

Phai Panda
6 min readJul 24, 2019

--

ResponseEntity คือพระเอกของเรา มันสามารถจัดการ status code, headers และ body อย่างนั้นมาประดิษฐ์ message กัน เพราะมันคือเสน่ห์!

จาก part ก่อนหน้า

หากเพื่อนๆได้เขียน RESTful Web Services ด้วยตัวเองแบบอ่านเองทำเอง แล้วกำลังศึกษาเรื่อง ResponseEntity เดียวกันนี้ล่ะก็ ข้อสงสัยของเราอาจจะคล้ายกันครับ

  1. นอกจาก status code แล้ว ส่ง message อธิบายเพิ่มเติมได้หรือไม่?
  2. ควรออกแบบ message ที่จะส่งกลับไปยัง Client นี้อย่างไร?
  3. ควรให้การเชื่อมต่อทุกครั้งได้ผลลัพธ์เป็น 200 OK เสมอ แล้วไปจัดการ business (error) status code แทนไหม?
  4. ลองตั้งข้อสงสัยมา แล้วเราร่วมกันพิจารณาทิศทางที่ควรจะเป็นไป

ผมมีโค้ด (ขอเรียกง่ายๆว่า) Server สำหรับลบหนังสือที่ต้องการ โดยให้ Client ส่ง ID หนังสือเข้ามาดังนี้

@DeleteMapping(value = "/books/{id}")
public ResponseEntity delete(@PathVariable Long id) {
Optional<Book> optional = bookRepository.findById(id);
if(optional.isPresent()) {
bookRepository.delete(optional.get());
return new ResponseEntity(HttpStatus.OK);
}
return new ResponseEntity(HttpStatus.NOT_FOUND);
}

แล้วนี่คือหนังสือที่ผมมีอยู่

มีอยู่เล่มเดียวและมี ID เท่ากับ 3

ผมอยากเพิ่ม message ที่ไทป์เป็น String เพื่ออธิบายผลลัพธ์เพิ่มเติมดังนี้

@DeleteMapping(value = "/books/{id}")
public ResponseEntity<String> delete(@PathVariable Long id) {
Optional<Book> optional = bookRepository.findById(id);
if(optional.isPresent()) {
bookRepository.delete(optional.get());
return new ResponseEntity("Delete Successful", HttpStatus.OK);
}
return new ResponseEntity("Book Not Found", HttpStatus.NOT_FOUND);
}

rerun Server แล้วให้ Client ส่ง ID หนังสือที่ไม่มีอยู่จริงไป

DELETE localhost:8080/books/1
ตอบกลับ 404 Not Found และ message อธิบายว่า Book Not Found

ทีนี้เปลี่ยนเป็นส่ง ID ของหนังสือที่มีอยู่จริงไป

DELETE localhost:8080/books/3
ตอบกลับ 200 OK และ message อธิบายว่า Delete Successful

ดูให้ละเอียด public ResponseEntity<String> delete(@PathVariable Long id) มี return type เป็น Generic

อ่านเพิ่มเติม

แสดงว่าเราใช้คลาสที่ประดิษฐ์ได้อย่างนั้นสินะ

ResponseEntity กับ Custom Message Class

เพราะ @RestController สนับสนุน JSON ดังนั้นคลาสที่สร้างขึ้นก็จะถูกเปลี่ยนเป็น JSON ส่งกลับไป

ผมมี package ชื่อ com.pros.model คิดก่อน… เอาเป็นคลาสชื่อ ResponseDetails

package com.pros.model;

import java.util.Date;

public class ResponseDetails {
private String message;
private Date date;

public ResponseDetails() { }

public ResponseDetails(String message) {
this.message = message;
this.date = new Date();
}

public String getMessage() {
return message;
}

public void setMessage(String message) {
this.message = message;
}

public Date getDate() {
return date;
}

public void setDate(Date date) {
this.date = date;
}
}

จากนั้นนำไปใช้ เปิดคลาส BookController แก้ไขเมธอด delete อีก

@DeleteMapping(value = "/books/{id}")
public ResponseEntity<ResponseDetails> delete(@PathVariable Long id) {
Optional<Book> optional = bookRepository.findById(id);
if(optional.isPresent()) {
bookRepository.delete(optional.get());

return new ResponseEntity(new ResponseDetails("Delete Successful"), HttpStatus.OK);
}
return new ResponseEntity(new ResponseDetails("Book Not Found"), HttpStatus.NOT_FOUND);
}

สำคัญ การเปลี่ยนแปลงโค้ดฝั่ง Server แต่ละครั้งขอให้ rerun Server ด้วย

ผลลัพธ์

Throw an Exception

หัวข้อนี้ผมจะกลับมาเล่นกับ Exception บ้าง อย่างที่รู้กันตั้งแต่ part แรก, Exception ที่เราสนใจย่อมเป็น RuntimeException เพราะมันจะเกิดขึ้นหลังจากโปรแกรมทำงานไปแล้ว

ขอเล่นกับ 404 Not Found แต่เปลี่ยนเป็น HTTP GET

ไฟล์ชื่อ BookController.java ที่ได้เขียนไว้ ผมขอเพิ่มเมธอด findById ดังนี้

@GetMapping(value = "/books/{id}")
public Book findById(@PathVariable Long id) {
Optional<Book> optional = bookRepository.findById(id);
if(optional.isPresent()) {
return optional.get();
}
return null;
}

เมธอดนี้จะรับ ID ของหนังสือไปค้นหาหนังสือ หากพบจะส่งหนังสือให้ แต่ถ้าไม่พบก็จะส่ง null ให้ ซึ่งขณะนี้ฐานข้อมูลของผมไม่มีหนังสือเหลือเลยสักเล่ม เหตุนี้เมื่อผมร้องขอหนังสือย่อมต้องได้ค่า null กลับมา (หรือก็คือความว่างเปล่า)

localhost:8080/books/1

ทดสอบด้วย Postman

200 OK แม้จะหาหนังสือไม่พบ

ทดสอบด้วย Browser

200 OK แม้จะหาหนังสือไม่พบ

หันมาโยน RuntimeException แทนการ return null ดีกว่า แก้ไขดังนี้

@GetMapping(value = "/books/{id}")
public Book findById(@PathVariable Long id) {
Optional<Book> optional = bookRepository.findById(id);
if(optional.isPresent()) {
return optional.get();
}
throw new RuntimeException("Book Not Found");
}

ทดสอบด้วย Postman และ Browser

500 Internal Server Error พร้อม message
500 Internal Server Error พร้อม message

พอโยน Exception นี้หนักเลย status 500 นี่คือเครื่องพัง ไปดู Log ที่ Server หน่อยสิ

แบบนี้คุณแม่ไม่ชอบแน่

เอ๋ เกิดข้อสงสัย Server พังแล้วแบบนี้ บริการอื่นเช่นเพิ่มหนังสือ ยังทำงานได้ไหมนะ รออะไรล่ะ เรียกเลย

200 OK ไม่พัง ฮะ ดีจัง

งั้นเพิ่มไปอีกสองเล่มแล้วไปดูที่ฐานข้อมูลกัน

โอ๊โฮะๆๆ (หัวเราะ)

มาถึงตอนนี้เพื่อนๆจึงเข้าใจได้ว่า status code 500 โดย RuntimeException นั้นไม่งามนัก จำต้องประดิษฐ์อีก

เราจะสร้างคลาสใหม่ชื่อ BookNotFoundException ให้มันสืบทอด RuntimeException (เพื่ออาศัยสมบัติของ Exception ที่เกิดขึ้นขณะที่โปรแกรมกำลังทำงานอยู่)

package เดิมที่มีเพิ่มเติมเข้าไป

com.pros.exception

สร้างคลาส BookNotFoundException โดยกำกับ HttpStatus.NOT_FOUND ไว้ด้วย

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

เสร็จแล้วนำมันไปแทนที่ RuntimeException ในคลาส BookController เมธอด findById

@GetMapping(value = "/books/{id}")
public Book findById(@PathVariable Long id) {
Optional<Book> optional = bookRepository.findById(id);
if(optional.isPresent()) {
return optional.get();
}
throw new BookNotFoundException("Book Not Found");
}

ทดสอบ

สวยงาม

เท่านี้เพื่อนๆก็พอจะจินตนาการออกว่ากี่ status code ต่อเรื่องใดๆสามารถนำมาสร้างเป็นคลาสที่สืบทอด RuntimeException ได้ อย่างไรก็ตามความรับผิดชอบต่อ 1 status code ก็ควรจำกัดแค่คลาสนั้นๆเพียงคลาสเดียวก็พอ

Spring boot ไม่จบเท่านี้ มันมองออกว่ามากมายขนาดไหนที่เราจะต้องสร้างคลาสลักษณะเดียวกับ BookNotFoundException อื่ม… ผมว่าน่าจะยกอีกสักตัวอย่างก่อน

ที่คลาส BookController มีเมธอด create ไว้สำหรับสร้างหนังสือครั้งละหนึ่งเล่ม

@PostMapping
public Book create(@RequestBody Book book) {
return bookRepository.save(book);
}

และหากผม POST ชื่อหนังสือที่เป็นสตริงว่างเข้ามาแบบนี้

200 OK ผมได้หนังสือใหม่

ลงในฐานข้อมูล

ID ใหม่กับชื่อหนังสือแบบว่างๆ (null) แบบนี้ในฐานข้อมูล

จะดีกว่านี้ไหม หากว่าเราป้องกันไว้ก่อนแก้ไข ตัวอย่างเช่น นึกไม่ออกบอกกับโค้ดจาวาว่าทำ logic นี้สิ

@PostMapping
public ResponseEntity<ResponseDetails> create(@RequestBody Book book) {
if(book.getName().isEmpty()) {
return new ResponseEntity(new ResponseDetails("Name Must Not Be Empty"), HttpStatus.BAD_REQUEST);
}

bookRepository.save(book);
return new ResponseEntity(new ResponseDetails("Create Successful"), HttpStatus.OK);
}

แล้ว POST ชื่อหนังสือที่เป็นสตริงว่างเข้ามา

400 Bad Request ชื่อหนังสือต้องไม่ว่าง

เพื่อนๆจะเห็นว่าโค้ดแบบนี้ก็ดี แต่ผมให้คิดต่อไปอีกนิด หากว่าเรามี field ที่ต้อง validate มากกว่านี้ล่ะ? สมมติว่าในทุกๆ model ก็จะมีเรื่องแบบนี้เสมอล่ะ?

จาวาโปรแกรมเมอร์ทราบได้ในทันทีว่า “มันต้องมีทางออกอื่นเพื่อจัดการอะไรแบบนี้อย่างแน่นอน” และสิ่งที่เขาคิดถูกต้อง เพราะ Spring Boot คิดเรื่องนี้ให้แล้ว

ทำความรู้จักกับ Validation Annotations สักเล็กน้อย

มันถูกสร้างขึ้นเพื่อตรวจสอบความถูกต้องของ fields, methods ตลอดจนคลาสของ Java Bean ตอนนี้น่าจะเวอร์ชัน 2 แล้ว (ไม่มั่นใจ) ตัวอย่าง

  • name นี้ต้องไม่ว่าง
  • name นี้ต้องยาวไม่น้อยกว่า 4 ตัวอักษร แต่ไม่เกิน 8 ตัวอักษร
  • price นี้ต้องมากกว่า 100

อ่านเพิ่มเติม (แนะนำ)

คราวนี้จาวาโปรแกรมเมอร์ยิ้มแล้ว มันคงดีกว่ามานั่งเขียน logic ตรวจสอบความถูกต้องดังเช่นชื่อของหนังสือต้องไม่ว่างนั่นแน่ๆ แล้วทำอย่างไรล่ะ

เปิดคลาส Book เพิ่ม javax.validation.constraints.NotEmpty ลงไป

package com.pros.model;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.validation.constraints.NotEmpty;

@Entity
public class Book {
@GeneratedValue(strategy = GenerationType.AUTO)
@Id
private Long id;

@NotEmpty
private String name;

public Book() { }

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

จากนั้นกลับไปแก้ไขเมธอด create ให้เหมือนเดิมก่อน แบบนี้ (อย่าลืม rerun server ก่อนทุกครั้ง)

@PostMapping
public Book create(@RequestBody Book book) {
return bookRepository.save(book);
}

ดูดี เอาล่ะเราไปส่งคำขอ POST กัน

สมใจอยาก 500 Internal Server Error พัง

ดูสิ่งที่เกิดกับ Server

มันรู้ว่ากำกับด้วย NotEmpty และรู้ด้วยว่าคลาสไหน

มันโยน ConstraintViolationException ออกมา งามหน้าจริงๆ แต่ไม่ต้องตกใจไป framework มีหนทางสู่แสงเสมอแหละ ที่สำคัญคือเราได้ Exception และหนังสือใหม่ก็ไม่เกิดขึ้น แต่เราต้องไม่ทำให้ server พังแบบนี้รู้ไหมจ๊ะคนดี

เพิ่ม javax.validation.Valid ลงไปตรงนี้

@PostMapping
public Book create(@Valid @RequestBody Book book) {
return bookRepository.save(book);
}

rerun server แล้วมาชมความอัศจรรย์

ว้าว! framework นี่ร้ายกาจ ได้ 400 Bad Request ด้วย เจ๋ง

ถึงตรงนี้ผมแน่ใจเล็กๆว่าจาวาโปรแกรมเมอร์เริ่มซึ้งกับ Spring Boot แล้ว ไม่ใช่ว่ารู้อะไรนิดๆหน่อยๆก็จะเขียนโค้ดๆไปแบบทุ่งๆ สิ่งที่ทำนั้นผมอยากให้นึกไว้เสมอว่า “ไม่มีแค่เราหรอก คนอื่นๆเขาย่อมได้พบเจอก่อนหน้านี้แล้ว” และหากใช้ framework ที่มีเวอร์ชันการพัฒนามาพอควรแล้วด้วยอีก มันย่อมมีหนทางหรือแนวทางแก้ไขปัญหาที่เจอนั่นแน่ครับ ประเด็นคือ ขยันอ่านและทำความเข้าใจหรือเปล่า?

สิ่งที่ยากมากอย่างหนึ่งสำหรับผมคือ Design (การออกแบบ) ควรออกแบบอย่างไร? ในตอนนี้แค่คลาส Book คลาสเดียวก็มีทั้งการส่งค่า Book กลับ, ส่งค่า ResponseEntity กลับ ไหนจะ throw BookNotFoundException อื่ม… มันต้องมีทางที่สะดวกเดินกว่านี้

ขอให้ดูโค้ดโดยรวม

package com.pros.controller;

import com.pros.exception.BookNotFoundException;
import com.pros.model.Book;
import com.pros.model.ResponseDetails;
import com.pros.repository.BookRepository;
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.List;
import java.util.Optional;

@RestController
public class BookController {
@Autowired
private BookRepository bookRepository;

@GetMapping
public String index() {
return "Hello";
}

@GetMapping(value = "/books")
public List<Book> findAll() {
return bookRepository.findAll();
}

@GetMapping(value = "/books/{id}")
public Book findById(@PathVariable Long id) {
Optional<Book> optional = bookRepository.findById(id);
if(optional.isPresent()) {
return optional.get();
}
throw new BookNotFoundException("Book Not Found");
}

@PostMapping
public Book create(@Valid @RequestBody Book book) {
return bookRepository.save(book);
}

@DeleteMapping(value = "/books/{id}")
public ResponseEntity<ResponseDetails> delete(@PathVariable Long id) {
Optional<Book> optional = bookRepository.findById(id);
if(optional.isPresent()) {
bookRepository.delete(optional.get());

return new ResponseEntity(new ResponseDetails("Delete Successful"), HttpStatus.OK);
}
return new ResponseEntity(new ResponseDetails("Book Not Found"), HttpStatus.NOT_FOUND);
}

@PutMapping(value = "/books/{id}")
public Book update(@PathVariable Long id, @RequestBody Book book) {
Optional<Book> optional = bookRepository.findById(id);
if(optional.isPresent()) {
Book existedBook = optional.get();
existedBook.setName(book.getName());
return bookRepository.save(existedBook);
}
return null;
}
}

อะห๊ะ แล้วมันก็มีจริงๆ ติดตามต่อที่ part 3 อันเป็นส่วนสุดท้ายของเรื่องครับ

--

--

No responses yet