Ví dụ CRUD với Spring Boot, PostgreSQL, JPA, Hibernate RESTful API

Trong bài viết này, tôi sẽ hướng dẫn các bạn tìm hiểu cách configure Spring Boot sử dụng cơ sở dữ liệu PostgreSQL và build RESTful CRUD API từ đầu.

Chúng ta cũng sẽ học được cách làm thế nào để Spring Data JPA và Hibernate có thể sử dụng với PostgreSQL.

Tôi sẽ viết REST APIs cho ứng dụng Q&A. Ứng dụng Q&A sẽ có 2 domain models là: Question và Answer. Vì một question sẽ có nhiều answers, nên tôi sẽ định nghĩa mối quan hệ one-to-many giữa QuestionAnswer entity.

Đầu tiên, tôi sẽ khởi tạo Project và cấu hình PostgreSQL database. Sau đó, tôi sẽ định nghĩa các domain models và repositories để truy cập dữ liệu từ PostgreSQL. Cuối cùng, tôi sẽ viết REST APIs và test các APIs sử dụng Postman.

I. Khởi tạo Project

Trong bài viết này, tôi sẽ khởi tạo project bằng cách sử dụng công cụ web Spring Initializr, hãy làm theo hướng dẫn bên dưới:

Đầu tiên, hãy truy cập vào http://start.spring.io

  • Nhập postgres-demo vào Artifact field.
  • Add Web, JPA , PostgreSQL và Lombok vào phần Dependencies.
  • Click Generate Project để download project.

Hãy import project vào IDE bạn yêu thích và bắt đầu làm việc.

Cấu trúc thư mục của dự án sau khi hoàn thành, bạn có thể tham khảo nó để tạo các package và class cho phù hợp.

II. Cấu hình PostgreSQL

Để Spring Boot có thể sử dụng PostgreSQL làm nguồn dữ liệu, thì chúng ta phải cấu hình bằng cách thêm driver, url, username và password của cơ sở dữ liệu PostgreQuery vào src/main/resources/application.properties:

## Spring DATASOURCE (DataSourceAutoConfiguration & DataSourceProperties)
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres_demo
spring.datasource.username=postgres
spring.datasource.password=123456

# The SQL dialect makes Hibernate generate better SQL for the chosen database
spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.PostgreSQLDialect
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true

# Hibernate ddl auto (create, create-drop, validate, update)
spring.jpa.hibernate.ddl-auto = update

III. Xác định các domain models

Các domain models là các class được ánh xạ tới các bảng tương ứng trong cơ sở dữ liệu. Chúng ta sẽ có hai domain models chính trong ứng dụng của mình đó là QuestionAnswer. Cả hai domain models này sẽ có chung 1 số thuộc tính như createAtupdateAt. Nên tốt nhất thì chúng ta nên tách các trường này ra 1 lớp riêng. Chúng ta sẽ tạo 1 class abstract là AuditModel để chứa các trường này. Chúng ta cũng sẽ sử dụng tính năng JPA Auditing của Spring Boot để tự động điền createAtupdateAt.

1. AuditModel

Class AuditModel sau sẽ được extended bởi các entities khác. Nó sẽ sử dụng annotation @EntityListeners(AuditingEntityListener.class) để tự động điền createAtupdateAt.

package com.example.postgresdemo.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@JsonIgnoreProperties(
        value = {"createAt", "updateAt"},
        allowGetters = true
)
@Getter
@Setter
public abstract class AuditModel implements Serializable {

    @Temporal(TemporalType.TIMESTAMP)
    @Column(name = "create_at", nullable = false, updatable = false)
    @CreatedDate
    private Date createAt;

    @Temporal(TemporalType.TIMESTAMP)
    @Column(name = "update_at", nullable = false)
    @LastModifiedDate
    private Date updateAt;
}
  • Lưu ý: tôi đang sử dụng @Getter@Setter của Lombok, Bạn cần phải setting IDE của mình để có thể sử dụng Lombok. Trong trường hợp bạn gặp lỗi, hãy tham khảo cách setting tại đây

Kích hoạt JPA Auditing

Để bật JPA Auditing, chúng ta sẽ phải cần thêm annotation @EnableJpaAuditing vào một trong các class config. Vì vậy, hãy mở class PostgresDemoApplication.java và thêm annotation @EnableJpaAuditing như sau:

package com.example.postgresdemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
public class PostgresDemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(PostgresDemoApplication.class, args);
	}

}

2. Question model

Dưới đây là class Question entity. Nó được ánh xạ tới một table có tên questions trong cơ sở dữ liệu.

package com.example.postgresdemo.model;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;

@Entity
@Table(name = "questions")
@Getter
@Setter
public class Question extends AuditModel {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Size(min = 3, max = 100)
    private String title;

    @Column(columnDefinition = "text")
    private String description;
}

3. Answer model

Dưới đây là class Answer entity. Nó chứa một annotation @ManyToOne để tuyên bố rằng nó có mối quan hệ nhiều-một với Question entity.

package com.example.postgresdemo.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;

import javax.persistence.*;

@Entity
@Table(name = "answers")
@Getter
@Setter
public class Answer extends AuditModel {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(columnDefinition = "text")
    private String text;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "question_id", nullable = false)
    @OnDelete(action = OnDeleteAction.CASCADE)
    @JsonIgnore
    private Question question;
}

IV. Định nghĩa các class Repositories

Các Repository sẽ được sử dụng để truy cập các QuestionAnswer từ cơ sở dữ liệu.

1. QuestionRepository

package com.example.postgresdemo.repository;

import com.example.postgresdemo.model.Question;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface QuestionRepository extends JpaRepository<Question, Long> {
}

2. AnswerRepository

package com.example.postgresdemo.repository;

import com.example.postgresdemo.model.Answer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface AnswerRepository extends JpaRepository<Answer, Long> {
    List<Answer> findByQuestionId(Long questionId);
}

V. Building các REST APIs

Cuối cùng, chúng ta hãy viết các REST API bên trong các controller để thực hiện các thao tác CRUD cho các QuestionAnswer.

1. QuestionController

package com.example.postgresdemo.controller;

import com.example.postgresdemo.exception.ResourceNotFoundException;
import com.example.postgresdemo.model.Question;
import com.example.postgresdemo.repository.QuestionRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@RestController
public class QuestionController {

    @Autowired
    private QuestionRepository questionRepository;

    @GetMapping("/questions")
    public Page<Question> getQuestions(Pageable pageable) {
        return questionRepository.findAll(pageable);
    }

    @PostMapping("/questions")
    public Question createQuestion(@Valid @RequestBody Question question) {
        return questionRepository.save(question);
    }

    @PutMapping("/question/{questionId}")
    public Question updateQuestion(@PathVariable Long questionId,
                                   @Valid @RequestBody Question questionRequest) {
        return questionRepository.findById(questionId)
                .map(question -> {
                    question.setTitle(questionRequest.getTitle());
                    question.setDescription(questionRequest.getDescription());
                    return questionRepository.save(question);
                }).orElseThrow(() -> new ResourceNotFoundException("Question not found with id " + questionId));
    }

    @DeleteMapping("/questions/{questionId}")
    public ResponseEntity<?> deleteQuestion(@PathVariable Long questionId) {
        return questionRepository.findById(questionId)
                .map(question -> {
                    questionRepository.delete(question);
                    return ResponseEntity.ok().build();
                }).orElseThrow(() -> new ResourceNotFoundException("Question not found with id" + questionId));
    }
}

2. AnswerController

package com.example.postgresdemo.controller;

import com.example.postgresdemo.exception.ResourceNotFoundException;
import com.example.postgresdemo.model.Answer;
import com.example.postgresdemo.repository.AnswerRepository;
import com.example.postgresdemo.repository.QuestionRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.List;

@RestController
public class AnswerController {

    @Autowired
    private AnswerRepository answerRepository;

    @Autowired
    private QuestionRepository questionRepository;

    @GetMapping("/questions/{questionId}/answers")
    public List<Answer> getAnswersByQuestionId(@PathVariable Long questionId) {
        return answerRepository.findByQuestionId(questionId);
    }

    @PostMapping("/questions/{questionId}/answers")
    public Answer addAnswer(@PathVariable Long questionId,
                            @Valid @RequestBody Answer answer) {
        return questionRepository.findById(questionId)
                .map(question -> {
                    answer.setQuestion(question);
                    return answerRepository.save(answer);
                }).orElseThrow(() -> new ResourceNotFoundException("Question not found with id " + questionId));
    }

    @PutMapping("/questions/{questionId}/answers/{answerId}")
    public Answer updateAnswer(@PathVariable Long questionId,
                               @PathVariable Long answerId,
                               @Valid @RequestBody Answer answerRequest) {
        if(!questionRepository.existsById(questionId)) {
            throw new ResourceNotFoundException("Question not found with id " + questionId);
        }

        return answerRepository.findById(answerId)
                .map(answer -> {
                    answer.setText(answerRequest.getText());
                    return answerRepository.save(answer);
                }).orElseThrow(() -> new ResourceNotFoundException("Answer not found with id " + answerId));
    }

    @DeleteMapping("/questions/{questionId}/answers/{answerId}")
    public ResponseEntity<?> deleteAnswer(@PathVariable Long questionId,
                                          @PathVariable Long answerId) {
        if(!questionRepository.existsById(questionId)) {
            throw new ResourceNotFoundException("Question not found with id " + questionId);
        }

        return answerRepository.findById(answerId)
                .map(answer -> {
                    answerRepository.delete(answer);
                    return ResponseEntity.ok().build();
                }).orElseThrow(() -> new ResourceNotFoundException("Answer not found with id " + answerId));

    }
}

Custom lớp ResourceNotFoundException

Các REST API của QuestionAnswer sẽ trả ra ResourceNotFoundException khi không tìm thấy questions hoặc answers trong cơ sở dữ liệu. Dưới đây là definition của class ResourceNotFoundException.

package com.example.postgresdemo.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }

    public ResourceNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
}

Lớp ngoại lệ chứa một annotation @ResponseStatus(HttpStatus.NOT_FOUND) để báo với Spring Boot trạng thái 404 NOT FOUND khi ngoại lệ này được ném ra.

VI. Chạy ứng dụng và kiểm tra API thông qua Postman

Chúng ta đã hoàn thành việc xây dựng REST API. Hãy chạy ứng dụng và kiểm tra các API đó. Các ảnh chụp màn hình sau đây sẽ cho bạn thấy cách kiểm tra API bằng Postman.

1. Tạo câu hỏi: Post/questions

2. Get thông tin câu hỏi: Get/questions

3. Tạo câu trả lời: Post/questions/{questionId}/answers

4. Get thông tin Answer của Question: Get/questions/{questionId}/answers