JavaRush /בלוג Java /Random-HE /REST API ואימות נתונים
Денис
רָמָה
Киев

REST API ואימות נתונים

פורסם בקבוצה
קישור לחלק הראשון: REST API ומשימת הבדיקה הבאה ובכן, האפליקציה שלנו עובדת, אנחנו יכולים לקבל ממנה איזושהי תגובה, אבל מה זה נותן לנו? זה לא עושה שום עבודה מועילה. לא מוקדם יותר מאשר נעשה, בואו ליישם משהו שימושי. קודם כל, בואו נוסיף כמה תלות חדשות ל-build.gradle שלנו, הם יהיו שימושיים עבורנו:
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": [
  ]
}
יש לנו מספיק שדות בפעם הראשונה, אז בואו נתחיל ליישם את זה. שלושת התחומים הראשונים לא מעלים שאלות - אלו שורות רגילות, אבל תחום השכר כבר מרמז. למה הקו האמיתי? בעבודה אמיתית גם זה קורה, מגיע אליך לקוח ואומר - אני רוצה לשלוח לך את המטען הזה, ואתה מעבד אותו. אפשר כמובן למשוך בכתפיים ולעשות את זה, אפשר לנסות להגיע להסכמה ולהסביר שעדיף להעביר את הנתונים בפורמט הנדרש. בואו נדמיין שנתקלנו בלקוח חכם והסכמנו שעדיף להעביר מספרים בפורמט מספרי, ומכיוון שאנחנו מדברים על כסף, שיהיה דאבל. הפרמטר הבא של המטען שלנו יהיה תאריך הגיוס, הלקוח ישלח לנו אותו בפורמט המוסכם: yyyy-mm-dd, כאשר y אחראי לשנים, m לימים, ו-d צפוי לימים - 2022- 08-12. השדה האחרון כרגע יהיה רשימת המשימות שהוקצו ללקוח. ברור ש-Task היא ישות נוספת במסד הנתונים שלנו, אבל אנחנו עדיין לא יודעים עליה הרבה, אז ניצור את הישות הבסיסית ביותר כפי שעשינו עם Employee בעבר. הדבר היחיד שאנו יכולים להניח כעת הוא שניתן להקצות יותר ממשימה אחת לעובד אחד, ולכן ניישם את הגישה המכונה One-To-Many, יחס אחד לרבים. אם מדברים בפירוט, רשומה אחת בטבלת העובדים יכולה להתאים למספר רשומות מטבלת המשימות . החלטתי גם להוסיף דבר כזה כמו שדה ייחודיNumber, כדי שנוכל להבחין בבירור בין עובד אחד למשנהו. כרגע כיתת העובדים שלנו נראית כך:
@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<>();
}
המחלקה הבאה נוצרה עבור הישות Task:
@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 - העמודה שבאמצעותה יצטרפו ישויות. במקרה זה, עמודה עובד_מזהה תיווצר בטבלת המשימות המצביעה על המזהה של העובד שלנו; היא תשמש אותנו כמפתח Foreign. למרות הקדושה הנראית לעין של השם, אתה יכול לקרוא לעמודה בכל מה שתרצה. המצב יהיה קצת יותר מסובך אם אתה צריך להשתמש לא רק בתעודת זהות, אלא בסוג של עמודה אמיתית; אנחנו ניגע בנושא זה מאוחר יותר. 3. ייתכן שגם שמת לב להערה חדשה מעל המזהה - @JsonIgnore. מכיוון ש-ID הוא הישות הפנימית שלנו, אנחנו לא בהכרח צריכים להחזיר אותו ללקוח. 4. @NotBlank הוא הערה מיוחדת לאימות, האומרת שהשדה לא חייב להיות null או המחרוזת הריקה 5. @Column(unique = true) אומר שהעמודה הזו חייבת להיות בעלת ערכים ייחודיים. אז, יש לנו כבר שתי ישויות, הן אפילו מחוברות זו לזו. הגיע הזמן לשלב אותם בתוכנית שלנו - בוא נלך להתמודד עם שירותים ובקרים. קודם כל, בואו נסיר את הסטאב שלנו משיטת 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? ברור שלעובדי השירות שלנו יש שיטת שמירה, שאמורה להיות אחראית על כל עבודת החיסכון. ברור גם ששיטה זו יכולה לזרוק חריג עם שם שמסביר את עצמו. הָהֵן. מתבצע אימות כלשהו. והתשובה תלויה ישירות בתוצאות האימות, אם זה יהיה 201 Created או 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' החברים הוותיקים שלנו Service ו-RequiredArgsConstructor כבר מספרים לנו את כל מה שאנחנו צריכים לדעת על המחלקה הזו, יש גם שדה אימות סופי פרטי. הוא יעשה את הקסם. יצרנו את שיטת isValidEmployee, אליה נוכל להעביר את הישות Employee; שיטה זו זורקת ValidationException, אותו נכתוב מעט מאוחר יותר. כן, זה יהיה חריג מותאם אישית לצרכים שלנו. באמצעות שיטת validator.validate(employee), נקבל רשימה של אובייקטי ConstraintViolation - כל אותן חוסר עקביות עם הערות האימות שהוספנו קודם לכן. ההיגיון הנוסף הוא פשוט, אם הרשימה הזו לא ריקה (כלומר יש הפרות), אנחנו זורקים חריג ובונים רשימה של הפרות - שיטת buildViolationsList שימו לב שזו שיטה Generic, כלומר. יכול לעבוד עם רשימות של הפרות של אובייקטים שונים - זה עשוי להיות שימושי בעתיד אם נאמת משהו אחר. מה בעצם עושה השיטה הזו? באמצעות ה-API של הזרם, אנו עוברים על רשימת ההפרות. אנו הופכים כל הפרה בשיטת המפה לאובייקט הפרה משלנו, ואוספים את כל האובייקטים המתקבלים לרשימה. אנחנו מחזירים אותו. מה עוד מושא ההפרה שלנו, אתם שואלים? הנה תיעוד פשוט
public record Violation(String property, String message) {}
רשומות הן חידושים מיוחדים כל כך בג'אווה, למקרה שאתה צריך אובייקט עם נתונים, ללא שום היגיון או כל דבר אחר. למרות שאני עצמי עדיין לא הבנתי למה זה נעשה, לפעמים זה דבר די נוח. יש ליצור אותו בקובץ נפרד, כמו מחלקה רגילה. נחזור ל- ValidationException המותאם אישית - זה נראה כך:
@RequiredArgsConstructor
public class ValidationException extends Exception {

    @Getter
    private final List<Violation> violations;
}
הוא מאחסן רשימה של כל ההפרות, הערת Lombok - Getter מצורף לרשימה, ובאמצעות הערה נוספת של Lombok "יישמנו" את הבנאי הנדרש :) ראוי לציין כאן שאני לא ממש מיישם את ההתנהגות של ה-isValid ... השיטה, היא מחזירה נכון או חריג, אבל כדאי להגביל את עצמנו ל-False הרגיל. גישת החריגה נעשית כי אני רוצה להחזיר את השגיאה הזו ללקוח, מה שאומר שאני צריך להחזיר משהו אחר מאשר נכון או לא נכון מהשיטה הבוליאנית. במקרה של שיטות אימות פנימיות בלבד, אין צורך לזרוק חריג; כאן תידרש רישום. עם זאת, בואו נחזור ל-EmployeeService שלנו, אנחנו עדיין צריכים להתחיל לשמור אובייקטים :) בואו נראה איך המחלקה הזו נראית עכשיו:
@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());
    }
}
שימו לב לנכס הסופי החדש הפרטי final ValidationService validationService; שיטת השמירה עצמה מסומנת בביאור @Transactional כך שאם מתקבלת RuntimeException, השינויים יבוטלו. קודם כל, אנו מאמתים את הנתונים הנכנסים באמצעות השירות שכתבנו זה עתה. במידה והכל עבר חלק, אנו בודקים האם יש עובד קיים במאגר (באמצעות מספר ייחודי). אם לא, אנחנו שומרים את החדש, אם יש כזה, אנחנו מעדכנים את השדות בכיתה. אה כן, איך בעצם בודקים? כן, זה מאוד פשוט, הוספנו שיטה חדשה למאגר העובדים:
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
    Employee findByUniqueNumber(String uniqueNumber);
}
מה בולט? לא כתבתי שום שאילתת היגיון או SQL, למרות שזה זמין כאן. Spring, פשוט על ידי קריאת שם השיטה, קובע מה אני רוצה - מוצאים את ByUniqueNumber ומעבירים את המחרוזת המתאימה לשיטה. חוזרים לעדכון התחומים – כאן החלטתי להשתמש בשכל הישר ולעדכן רק את המחלקה, השכר והמטלות, כי שינוי השם, למרות שזה דבר מקובל, עדיין לא נפוץ במיוחד. ושינוי תאריך הגיוס הוא בדרך כלל נושא שנוי במחלוקת. מה יהיה טוב לעשות כאן? שלבו רשימות משימות, אך מכיוון שאין לנו עדיין משימות ואיננו יודעים להבדיל ביניהן, נעזוב את TODO. בואו ננסה להשיק את פרנקנשטיין שלנו. אם לא שכחתי לתאר משהו, זה אמור לעבוד, אבל קודם כל, הנה עץ הכיתות שקיבלנו: REST API ואימות נתונים - 1 כיתות ששונו מודגשות בכחול, חדשות מודגשות בירוק, אינדיקציות כאלה ניתן לקבל אם אתה עובד עם מאגר git, אבל git הוא לא הנושא למאמר שלנו, אז לא נתעכב על זה. אז, כרגע יש לנו נקודת קצה אחת שתומכת בשתי שיטות GET ו-POST. אגב, קצת מידע מעניין על נקודת קצה. מדוע, למשל, לא הקצנו נקודות קצה נפרדות עבור GET ו-POST, כגון getAllEmployees או createEmployees? הכל פשוט ביותר - נקודה אחת לכל הבקשות היא הרבה יותר נוחה. הניתוב מתרחש על בסיס שיטת HTTP והוא אינטואיטיבי, אין צורך לזכור את כל הווריאציות של getAllEmployees, getEmployeeByName, get... עדכן... צור... מחק... בואו נבדוק מה קיבלנו. כבר כתבתי במאמר הקודם שנצטרך את Postman, וזה הזמן להתקין אותו. בממשק התוכנית, אנו יוצרים בקשת POST חדשה REST API ואימות נתונים - 2 ומנסים לשלוח אותה. אם הכל הלך כשורה נקבל סטטוס 201 בצד ימין של המסך. אבל למשל, לאחר ששלחתי את אותו הדבר אבל ללא מספר ייחודי (עליו יש לנו אימות), אני מקבל תשובה אחרת: REST API ואימות נתונים - 3 ובכן, בוא נבדוק איך הבחירה המלאה שלנו עובדת - אנחנו יוצרים שיטת GET לאותה נקודת קצה ושולחים אותה . REST API ואימות נתונים - 4 כולי תקווה שהכל הסתדר לך בדיוק כמו אצלי, ולהתראות בכתבה הבאה .
הערות
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION