ลิงก์ไปยังส่วนแรก: REST API และงานทดสอบถัดไป แอปพลิเคชันของเราใช้งานได้ เราจะได้รับการตอบสนองบางอย่างจากมัน แต่สิ่งนี้ให้อะไรเราบ้าง มันไม่มีประโยชน์อะไรเลย ไม่ช้าก็เร็ว เรามาประยุกต์ใช้สิ่งที่มีประโยชน์กันดีกว่า ก่อนอื่น เรามาเพิ่มการขึ้นต่อกันใหม่สองสามรายการให้กับ build.gradle ของเรา ซึ่งจะเป็นประโยชน์สำหรับเรา:
คลาสที่ได้รับการแก้ไขจะถูกเน้นด้วยสีน้ำเงิน คลาสใหม่จะถูกเน้นด้วยสีเขียว คุณสามารถรับสิ่งบ่งชี้ดังกล่าวได้หากคุณทำงาน มีที่เก็บ git แต่ git ไม่ใช่หัวข้อสำหรับบทความของเรา ดังนั้นเราจะไม่พูดถึงมันอีกต่อไป ดังนั้น ในขณะนี้ เรามีจุดสิ้นสุดหนึ่งจุดที่รองรับสองวิธี GET และ POST อย่างไรก็ตาม มีข้อมูลที่น่าสนใจเกี่ยวกับจุดสิ้นสุด เหตุใดเราจึงไม่จัดสรรตำแหน่งข้อมูลแยกกันสำหรับ GET และ POST เช่น getAllEmployees หรือ createEmployees ทุกอย่างง่ายมาก - การมีจุดเดียวสำหรับคำขอทั้งหมดจะสะดวกกว่ามาก การกำหนดเส้นทางเกิดขึ้นตามวิธี HTTP และใช้งานง่าย ไม่จำเป็นต้องจำรูปแบบทั้งหมดของ getAllEmployees, getEmployeeByName, รับ... อัปเดต... สร้าง... ลบ... มาทดสอบสิ่งที่เราได้รับกันดีกว่า ฉันได้เขียนไปแล้วในบทความก่อนหน้านี้ว่าเราจะต้องมีบุรุษไปรษณีย์และได้เวลาติดตั้งแล้ว ในอินเทอร์เฟซของโปรแกรม เราสร้างคำขอ POST ใหม่
และพยายามส่ง หากทุกอย่างเป็นไปด้วยดี เราจะเห็นสถานะ 201 ทางด้านขวาของหน้าจอ แต่ตัวอย่างเช่น เมื่อส่งสิ่งเดียวกัน แต่ไม่มีหมายเลขเฉพาะ (ซึ่งเรามีการตรวจสอบ) ฉันได้รับคำตอบที่ต่างออกไป: เรามาตรวจสอบว่า
การเลือกทั้งหมดของเราทำงานอย่างไร - เราสร้างวิธี GET สำหรับจุดสิ้นสุดเดียวกันแล้วส่งไป . ฉันหวังเป็นอย่าง ยิ่ง
ว่าทุกอย่างจะได้ผลสำหรับคุณเช่นเดียวกับที่ทำเพื่อฉัน แล้วพบกันใหม่บทความหน้า
implementation 'org.apache.commons:commons-lang3:3.12.0'
implementation 'org.apache.commons:commons-collections4:4.4'
implementation 'org.springframework.boot:spring-boot-starter-validation'
และเราจะเริ่มต้นด้วยข้อมูลจริงที่เราต้องประมวลผล กลับไปที่แพ็คเกจการคงอยู่ของเราและเริ่มกรอกเอนทิตี ดังที่คุณจำได้ เราปล่อยให้มันมีอยู่เพียงฟิลด์เดียว จากนั้นรถยนต์จะถูกสร้างขึ้นผ่าน `@GeneratedValue(strategy = GenerationType.IDENTITY)` มาจำข้อกำหนดทางเทคนิคจากบทแรกกัน:
{
"firstName": String,
"lastName": String,
"department": String,
"salary": String
"hired": String //"yyyy-mm-dd"
"tasks": [
]
}
เรามีช่องข้อมูลเพียงพอในครั้งแรก ดังนั้นมาเริ่มใช้งานกันดีกว่า สามฟิลด์แรกไม่ก่อให้เกิดคำถาม - นี่เป็นบรรทัดธรรมดา แต่ฟิลด์เงินเดือนมีการชี้นำอยู่แล้ว ทำไมต้องเป็นเส้นจริง? ในการทำงานจริง สิ่งนี้ก็เกิดขึ้นเช่นกัน ลูกค้ามาหาคุณแล้วพูดว่า - ฉันต้องการส่งเพย์โหลดนี้ไปให้คุณ และคุณก็ดำเนินการ แน่นอนคุณสามารถยักไหล่และทำได้คุณสามารถลองตกลงและอธิบายว่าเป็นการดีกว่าที่จะส่งข้อมูลในรูปแบบที่ต้องการ ลองนึกภาพว่าเราเจอไคลเอนต์ที่ชาญฉลาดและตกลงกันว่าควรส่งตัวเลขในรูปแบบตัวเลขดีกว่าและเนื่องจากเรากำลังพูดถึงเรื่องเงิน ปล่อยให้มันเป็นสองเท่า พารามิเตอร์ถัดไปของเพย์โหลดของเราคือวันที่จ้างงาน ลูกค้าจะส่งมาให้เราในรูปแบบที่ตกลงกัน: ปปปป-ดด-วว โดยที่ y รับผิดชอบเป็นปี m คือวัน และ d คือวันที่คาดหวัง - 2022- 08-12. ฟิลด์สุดท้ายในขณะนี้จะเป็นรายการงานที่ได้รับมอบหมายให้กับลูกค้า แน่นอนว่า Task เป็นอีกเอนทิตีหนึ่งในฐานข้อมูลของเรา แต่เรายังไม่รู้อะไรมากนัก ดังนั้น เราจะสร้างเอนทิตีพื้นฐานที่สุดเหมือนกับที่เราเคยทำกับ Employee มาก่อน สิ่งเดียวที่เราสามารถสรุปได้ในตอนนี้คือสามารถมอบหมายงานให้กับพนักงานได้มากกว่าหนึ่งงาน ดังนั้นเราจะใช้สิ่งที่เรียกว่าวิธีแบบหนึ่งต่อกลุ่ม ซึ่งเป็นอัตราส่วนแบบหนึ่งต่อกลุ่ม โดยเฉพาะอย่างยิ่ง หนึ่งระเบียนในตารางพนักงานสามารถสอดคล้องกับหลายระเบียนจาก ตาราง งาน ฉันยังตัดสินใจเพิ่มสิ่งนี้เป็นฟิลด์ UniqueNumber เพื่อให้เราสามารถแยกแยะพนักงานคนหนึ่งจากอีกคนหนึ่งได้อย่างชัดเจน ในขณะนี้คลาส Employee ของเรามีลักษณะดังนี้:
@Entity
@Data
@Accessors(chain = true)
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonIgnore
Long id;
@NotBlank
@Column(unique = true)
private String uniqueNumber;
private String firstName;
private String lastName;
private String department;
private Double salary;
private LocalDate hired;
@OneToMany
@JoinColumn(name = "employee_id")
List<Task> tasks = new ArrayList<>();
}
คลาสต่อไปนี้ถูกสร้างขึ้นสำหรับเอนทิตีงาน:
@Entity
@Data
@Accessors(chain = true)
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long taskId;
}
อย่างที่ฉันบอกไปเราจะไม่เห็นสิ่งใหม่ใน Task นอกจากนี้ยังมีการสร้างพื้นที่เก็บข้อมูลใหม่สำหรับคลาสนี้ซึ่งเป็นสำเนาของพื้นที่เก็บข้อมูลสำหรับพนักงาน - ฉันจะไม่ให้มันคุณสามารถสร้างมันขึ้นมาเองโดยการเปรียบเทียบ แต่มันสมเหตุสมผลแล้วที่จะพูดถึงคลาส Employee อย่างที่ฉันบอกไป เราได้เพิ่มหลายฟิลด์ แต่ตอนนี้มีเพียงอันสุดท้ายเท่านั้นที่น่าสนใจนั่นคืองาน นี่คืองาน List<Task>ซึ่งจะเริ่มต้นทันทีด้วย ArrayList ว่างและทำเครื่องหมายด้วยคำอธิบายประกอบหลายรายการ 1. @OneToManyอย่างที่ผมบอกไปแล้ว นี่จะเป็นอัตราส่วนของพนักงานต่องานของเรา 2. @JoinColumn - คอลัมน์ที่จะรวมเอนทิตี ในกรณีนี้ คอลัมน์ Employee_id จะถูกสร้างขึ้นในตาราง Task โดยชี้ไปที่ id ของพนักงานของเรา ซึ่งจะทำหน้าที่เป็น ForeighnKey แม้ว่าชื่อจะดูศักดิ์สิทธิ์ แต่คุณก็สามารถตั้งชื่อคอลัมน์ได้ตามต้องการ สถานการณ์จะซับซ้อนขึ้นเล็กน้อยหากคุณจำเป็นต้องใช้ไม่เพียงแค่ ID แต่เป็นคอลัมน์จริงบางประเภท เราจะพูดถึงหัวข้อนี้ในภายหลัง 3. คุณอาจสังเกตเห็นคำอธิบายประกอบใหม่เหนือ id - @JsonIgnore เนื่องจาก id เป็นหน่วยงานภายในของเรา เราจึงไม่จำเป็นต้องส่งคืนให้แก่ลูกค้า 4. @NotBlank เป็นคำอธิบายประกอบพิเศษสำหรับการตรวจสอบ ซึ่งระบุว่าฟิลด์ต้องไม่เป็นค่าว่างหรือสตริงว่าง 5. @Column(unique = true) บอกว่าคอลัมน์นี้ต้องมีค่าไม่ซ้ำกัน ดังนั้นเราจึงมีสองเอนทิตีอยู่แล้ว พวกมันเชื่อมโยงถึงกันด้วยซ้ำ ถึงเวลาแล้วที่จะรวมสิ่งเหล่านี้เข้ากับโปรแกรมของเรา - ไปจัดการกับบริการและตัวควบคุมกันดีกว่า ก่อนอื่น เรามาลบ stub ของเราออกจากเมธอด getAllEmployees() แล้วเปลี่ยนให้เป็นสิ่งที่ใช้งานได้จริง:
public List<Employee> getAllEmployees() {
return employeeRepository.findAll();
}
ดังนั้นพื้นที่เก็บข้อมูลของเราจะรวบรวมทุกสิ่งที่มีอยู่ในฐานข้อมูลและมอบให้กับเรา เป็นที่น่าสังเกตว่ามันจะรับรายการงานด้วย แต่การกวาดล้างมันน่าสนใจอย่างแน่นอน แต่จะเกิดอะไรขึ้นถ้าไม่มีอะไรอยู่ตรงนั้น? ถูกต้อง นั่นหมายความว่าเราต้องหาวิธีใส่อะไรลงไปตรงนั้น ก่อนอื่น เรามาเขียนวิธีการใหม่ในคอนโทรลเลอร์ของเรากันก่อน
@PostMapping("${application.endpoint.employee}")
public ResponseEntity<?> createOrUpdateEmployee(@RequestBody final Employee employee) {
try {
employeeService.save(employee);
} catch (ValidationException e) {
return ResponseEntity.badRequest().body(e.getViolations());
}
return ResponseEntity.status(HttpStatus.CREATED).build();
}
นี่คือ @PostMapping เช่น มันประมวลผลคำขอ POST ที่มาถึงจุดสิ้นสุดของพนักงานของเรา โดยทั่วไป ฉันคิดว่าเนื่องจากคำขอทั้งหมดไปยังคอนโทรลเลอร์นี้จะมาที่จุดสิ้นสุดจุดเดียว มาทำให้เรื่องนี้ง่ายขึ้นสักหน่อย จำการตั้งค่าที่ดีของเราใน application.yml ได้ไหม? มาแก้ไขกันเถอะ ให้ส่วนของแอปพลิเคชันมีลักษณะดังนี้:
application:
endpoint:
root: api/v1
employee: ${application.endpoint.root}/employees
task: ${application.endpoint.root}/tasks
สิ่งนี้ให้อะไรเราบ้าง? ความจริงที่ว่าในคอนโทรลเลอร์เราสามารถลบการแมปสำหรับแต่ละวิธีเฉพาะได้และจุดสิ้นสุดจะถูกตั้งค่าที่ระดับคลาสในคำ อธิบายประกอบ @RequestMapping("${application.endpoint.employee}") นี่คือความสวยงามในตอนนี้ ผู้ควบคุมของเรา:
@RestController
@RequestMapping("${application.endpoint.employee}")
@RequiredArgsConstructor
public class EmployeeController {
private final EmployeeService employeeService;
@GetMapping
public ResponseEntity<List<Employee>> getEmployees() {
return ResponseEntity.ok().body(employeeService.getAllEmployees());
}
@PostMapping
public ResponseEntity<?> createOrUpdateEmployee(@RequestBody final Employee employee) {
try {
employeeService.save(employee);
} catch (ValidationException e) {
return ResponseEntity.badRequest().body(e.getViolations());
}
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
อย่างไรก็ตาม เรามาต่อกันดีกว่า จะเกิดอะไรขึ้นในเมธอด createOrUpdateEmployee แน่นอนว่า EmployeeService ของเรามีวิธีบันทึกซึ่งควรรับผิดชอบงานบันทึกทั้งหมด เห็นได้ชัดว่าวิธีนี้สามารถส่งข้อยกเว้นด้วยชื่อที่อธิบายตนเองได้ เหล่านั้น. กำลังดำเนินการตรวจสอบความถูกต้องบางอย่าง และคำตอบนั้นขึ้นอยู่กับผลการตรวจสอบโดยตรง ไม่ว่าจะเป็น 201 Create หรือ 400 badRequest พร้อมรายการสิ่งที่ผิดพลาด เมื่อมองไปข้างหน้า นี่คือบริการตรวจสอบใหม่ของเรา โดยจะตรวจสอบข้อมูลขาเข้าว่ามีฟิลด์ที่จำเป็นหรือไม่ (จำ @NotBlank ได้ไหม) และตัดสินใจว่าข้อมูลดังกล่าวเหมาะสำหรับเราหรือไม่ ก่อนที่จะไปยังวิธีการบันทึก เรามาตรวจสอบและใช้บริการนี้กันก่อน ในการทำเช่นนี้ ฉันเสนอให้สร้างแพ็คเกจการตรวจสอบแยกต่างหากซึ่งเราจะให้บริการของเรา
import com.example.demo.exception.ValidationException;
import com.example.demo.persistence.entity.Employee;
import lombok.RequiredArgsConstructor;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validator;
@Service
@RequiredArgsConstructor
public class ValidationService {
private final Validator validator;
public boolean isValidEmployee(Employee employee) throws ValidationException {
Set<Constraintviolation<Employee>> constraintViolations = validator.validate(employee);
if (CollectionUtils.isNotEmpty(constraintViolations)) {
throw new ValidationException(buildViolationsList(constraintViolations));
}
return true;
}
private <T> List<Violation> buildViolationsList(Set<Constraintviolation<T>> constraintViolations) {
return constraintViolations.stream()
.map(violation -> new Violation(
violation.getPropertyPath().toString(),
violation.getMessage()
)
)
.toList();
}
}
ชั้นเรียนกลายเป็นเรื่องใหญ่เกินไป แต่อย่าตกใจ เราจะคิดออกตอนนี้ :) ที่นี่เราใช้เครื่องมือของไลบรารีการตรวจสอบความถูกต้องสำเร็จรูป javax.validation ไลบรารี นี้มาหาเราจากการขึ้นต่อกันใหม่ที่เรา เพิ่มไปยัง การใช้งาน build.graddle 'org.springframework.boot:spring-boot-starter -validation' บริการเพื่อนเก่าของเราและ RequiredArgsConstructor บอกเราทุกสิ่งที่เราจำเป็นต้องรู้เกี่ยวกับคลาสนี้แล้ว นอกจากนี้ยังมีฟิลด์ตรวจสอบขั้นสุดท้ายส่วนตัวอีกด้วย เขาจะทำเวทย์มนตร์ เราได้สร้างเมธอด isValidEmployee ซึ่งเราสามารถส่งผ่านเอนทิตี Employee ได้ เมธอดนี้จะส่ง ValidationException ซึ่งเราจะเขียนในภายหลังเล็กน้อย ใช่ นี่จะเป็นข้อยกเว้นที่กำหนดเองสำหรับความต้องการของเรา การใช้เมธอด validator.validate(employee) เราจะได้รับรายการของออบเจ็กต์ ConstraintViolation - ทั้งหมดที่ไม่สอดคล้องกับคำอธิบายประกอบการตรวจสอบที่เราเพิ่มไว้ก่อนหน้านี้ ตรรกะเพิ่มเติมนั้นง่ายมาก หากรายการนี้ไม่ว่างเปล่า (เช่น มีการละเมิด) เราจะสร้างข้อยกเว้นและสร้างรายการการละเมิด - วิธี buildViolationsList โปรดทราบว่านี่เป็นวิธีทั่วไป เช่น สามารถทำงานร่วมกับรายการการละเมิดของออบเจ็กต์ต่างๆ ได้ - มันอาจจะมีประโยชน์ในอนาคตหากเราตรวจสอบสิ่งอื่น วิธีการนี้ทำอะไรได้จริง? เมื่อใช้สตรีม API เราจะผ่านรายการการละเมิด เราเปลี่ยนการละเมิดแต่ละครั้งในวิธีการแผนที่ให้เป็นวัตถุการละเมิดของเราเอง และรวบรวมวัตถุผลลัพธ์ทั้งหมดไว้ในรายการ เรากำลังส่งคืนเขา คุณถามอะไรอีกบ้างที่เป็นการละเมิดของเรา? นี่เป็นบันทึก ง่ายๆ
public record Violation(String property, String message) {}
บันทึกเป็นนวัตกรรมพิเศษใน Java ในกรณีที่คุณต้องการวัตถุที่มีข้อมูล โดยไม่มีตรรกะหรือสิ่งอื่นใด แม้ว่าตัวฉันเองยังไม่เข้าใจว่าเหตุใดจึงทำเช่นนี้ แต่บางครั้งมันก็ค่อนข้างสะดวก จะต้องสร้างเป็นไฟล์แยกต่างหากเหมือนคลาสทั่วไป กลับไปที่ ValidationException แบบกำหนดเอง - มีลักษณะดังนี้:
@RequiredArgsConstructor
public class ValidationException extends Exception {
@Getter
private final List<Violation> violations;
}
มันเก็บรายการการละเมิดทั้งหมด คำอธิบายประกอบลอมบอก - Getter ถูกแนบไปกับรายการ และผ่านคำอธิบายประกอบลอมบอกอื่น เราได้ "นำไปใช้" ตัวสร้างที่จำเป็น :) เป็นที่น่าสังเกตว่าที่นี่ฉันไม่ได้ใช้พฤติกรรมของ isValid อย่างถูกต้องนัก ... วิธีการจะส่งกลับค่าจริงหรือข้อยกเว้น แต่ก็คุ้มค่าที่จะจำกัดตัวเองให้เป็นค่าเท็จตามปกติ วิธีการยกเว้นเกิดขึ้นเนื่องจากฉันต้องการส่งคืนข้อผิดพลาดนี้ไปยังไคลเอนต์ ซึ่งหมายความว่าฉันต้องส่งคืนอย่างอื่นที่ไม่ใช่จริงหรือเท็จจากวิธีบูลีน ในกรณีของวิธีการตรวจสอบภายในเพียงอย่างเดียว ไม่จำเป็นต้องทิ้งข้อยกเว้น โดยจะต้องมีการบันทึกที่นี่ อย่างไรก็ตาม กลับมาที่ EmployeeService ของเราอีกครั้ง เรายังคงต้องเริ่มบันทึก Object กัน :) มาดูกันว่า Class นี้มีลักษณะอย่างไร:
@Service
@RequiredArgsConstructor
public class EmployeeService {
private final EmployeeRepository employeeRepository;
private final ValidationService validationService;
public List<Employee> getAllEmployees() {
return employeeRepository.findAll();
}
@Transactional
public void save(Employee employee) throws ValidationException {
if (validationService.isValidEmployee(employee)) {
Employee existingEmployee = employeeRepository.findByUniqueNumber(employee.getUniqueNumber());
if (existingEmployee == null) {
employeeRepository.save(employee);
} else {
existingEmployee = updateFields(existingEmployee, employee);
employeeRepository.save(existingEmployee);
}
}
}
private Employee updateFields(Employee existingEmployee, Employee updatedEmployee) {
return existingEmployee.setDepartment(updatedEmployee.getDepartment())
.setSalary(updatedEmployee.getSalary())
//TODO implement tasks merging instead of replacement
.setTasks(updatedEmployee.getTasks());
}
}
แจ้งให้ทราบคุณสมบัติสุดท้ายใหม่ส่วนตัวสุดท้าย ValidationService validationService; วิธีการบันทึกนั้นถูกทำเครื่องหมายด้วยคำอธิบายประกอบ @Transactional ดังนั้นหากได้รับ RuntimeException การเปลี่ยนแปลงจะถูกย้อนกลับ ก่อนอื่น เราตรวจสอบข้อมูลขาเข้าโดยใช้บริการที่เราเพิ่งเขียน หากทุกอย่างเป็นไปอย่างราบรื่น เราจะตรวจสอบว่ามีพนักงานอยู่ในฐานข้อมูลหรือไม่ (โดยใช้หมายเลขเฉพาะ) ถ้าไม่ เราจะบันทึกอันใหม่ ถ้ามี เราจะอัปเดตฟิลด์ในชั้นเรียน โอ้ใช่แล้วเราจะตรวจสอบได้อย่างไร? ใช่ มันง่ายมาก เราได้เพิ่มวิธีการใหม่ไปยังพื้นที่เก็บข้อมูลของพนักงาน:
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
Employee findByUniqueNumber(String uniqueNumber);
}
มีอะไรน่าสังเกต? ฉันไม่ได้เขียนตรรกะหรือการสืบค้น SQL ใด ๆ แม้ว่าจะมีให้ที่นี่ก็ตาม Spring เพียงแค่อ่านชื่อของวิธีการ ก็กำหนดสิ่งที่ฉันต้องการ - ค้นหา ByUniqueNumber และส่งสตริงที่เกี่ยวข้องไปยังวิธีการ กลับไปที่การอัปเดตฟิลด์ - ที่นี่ฉันตัดสินใจใช้สามัญสำนึกและอัปเดตเฉพาะแผนกเงินเดือนและงานเนื่องจากการเปลี่ยนชื่อแม้ว่าจะเป็นสิ่งที่ยอมรับได้ แต่ก็ยังไม่ธรรมดานัก และการเปลี่ยนวันจ้างงานโดยทั่วไปถือเป็นประเด็นที่ถกเถียงกัน มาทำอะไรที่นี่ดี? รวมรายการงานเข้าด้วยกัน แต่เนื่องจากเรายังไม่มีงานและไม่ทราบวิธีแยกแยะงานเหล่านั้น เราจึงละทิ้ง TODO มาลองเปิดตัวแฟรงเกนสไตน์ของเรากัน ถ้าฉันไม่ลืมอธิบายอะไรมันก็ควรจะได้ผล แต่ก่อนอื่น นี่คือแผนผังคลาสที่เราได้รับ: 



GO TO FULL VERSION