JavaRush /Java 博客 /Random-ZH /REST API 和数据验证
Денис
第 37 级
Киев

REST API 和数据验证

已在 Random-ZH 群组中发布
链接到第一部分: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'
我们将从必须处理的实际数据开始。让我们返回到持久性包并开始填充实体。正如你所记得的,我们让它只存在一个字段,然后通过`@GenerateValue(strategy = GenerationType.IDENTITY)`生成自动让我们记住第一章中的技术规范:
{
  "firstName": String,
  "lastName": String,
  "department": String,
  "salary": String
  "hired": String //"yyyy-mm-dd"
  "tasks": [
  ]
}
我们第一次有足够的字段,所以让我们开始实现它。前三个字段不会引起问题 - 这些是普通行,但薪资字段已经具有暗示性。为什么是实际线路?在实际工作中,这种情况也会发生,客户来找你并说 - 我想向你发送这个有效负载,然后你处理它。当然,您可以耸耸肩并这样做,您可以尝试达成一致并解释说最好以所需的格式传输数据。让我们想象一下,我们遇到了一个聪明的客户端,并同意最好以数字格式传输数字,并且由于我们正在谈论金钱,因此将其设置为 Double。我们有效负载的下一个参数将是雇用日期,客户将以约定的格式将其发送给我们:yyyy-mm-dd,其中 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 的存储库的副本 - 我不会给出它,您可以自行创建它以此类推。但谈论 Employee 类是有意义的。正如我所说,我们添加了几个字段,但现在只有最后一个字段令人感兴趣 - 任务。这是一个 List<Task>tasks,它立即用一个空的 ArrayList 初始化并用几个注释标记。1. @OneToMany正如我所说,这将是我们的员工与任务的比率。2. @JoinColumn - 将连接实体的列。在这种情况下,将在任务表中创建一个employee_id列,指向我们员工的id;它将作为ForeighnKey为我们服务,尽管这个名字看起来很神圣,但您可以将该列命名为您喜欢的任何名称。如果您不仅需要使用 ID,还需要使用某种真实的列,情况会稍微复杂一些;我们稍后会讨论这个主题。3. 您可能还注意到 id 上方有一个新注释 - @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 方法中到底发生了什么?显然,我们的employeeService有一个save方法,它应该负责所有的保存工作。同样明显的是,该方法可以抛出一个具有不言自明的名称的异常。那些。正在进行某种验证。答案直接取决于验证结果,是 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 方法 请注意,这是一个泛型方法,即 可以处理不同对象的违规列表 - 如果我们验证其他东西,它在将来可能会很有用。这个方法实际上做了什么?使用流 API,我们可以查看违规列表。我们将map方法中的每个违规转化为我们自己的违规对象,并将所有结果对象收集到一个列表中。我们正在遣返他。你问,我们自己的侵犯对象还有什么?这里简单记录一下
public record Violation(String property, String message) {}
记录是 Java 中的特殊创新,如果您需要一个包含数据的对象,而不需要任何逻辑或其他任何内容。虽然我自己还不明白为什么要这样做,但有时这是一件很方便的事情。它必须像常规类一样在单独的文件中创建。返回到自定义 ValidationException - 它看起来像这样:
@RequiredArgsConstructor
public class ValidationException extends Exception {

    @Getter
    private final List<Violation> violations;
}
它存储所有违规的列表,Lombok 注释 - Getter 附加到该列表,并通过另一个 Lombok 注释我们“实现”了所需的构造函数:) 这里值得注意的是,我没有完全正确地实现 isValid 的行为... 方法,它返回 true 或异常,但值得将我们限制为通常的 False。之所以采用异常方法,是因为我想将此错误返回给客户端,这意味着我需要从布尔方法返回除 true 或 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());
    }
}
注意新的最终属性 private Final ValidationService validationService;save 方法本身使用 @Transactional 注释进行标记,以便如果收到 RuntimeException,则会回滚更改。首先,我们使用刚刚编写的服务验证传入的数据。如果一切顺利,我们将检查数据库中是否存在现有员工(使用唯一编号)。如果没有,我们保存新的,如果有,我们更新类中的字段。哦,是的,我们如何实际检查?是的,很简单,我们在 Employee 存储库中添加了一个新方法:
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...update...create...delete... 的所有变体,让我们测试一下我们得到了什么。我已经在上一篇文章中写道,我们需要 Postman,现在是时候安装它了。在程序界面中,我们创建一个新的POST请求 REST API 和数据验证 - 2 并尝试发送它。如果一切顺利,我们将在屏幕右侧看到 Status 201。但例如,发送了相同的内容但没有唯一的号码(我们对此进行了验证),我得到了不同的答案: REST API 和数据验证 - 3 好吧,让我们检查一下我们的完整选择是如何工作的 - 我们为同一端点创建一个 GET 方法并发送它。 REST API 和数据验证 - 4 我真诚地希望您一切顺利,就像我一样,我们下一篇文章再见。
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION