Header Ads

Seo Services

Hế lô anh em ✌️✌️✌️

Trong bài viết trước mình đã cùng anh em tìm hiểu cách chúng ta thực hiện phân trang (paging) và sắp xếp (sorting) sử dụng Spring Data JPA. Đúng là rất tiện và dễ dàng triển khai nhưng nhiêu đó đã giải quyết hết được những bài toán của chúng ta chưa?

Nếu chưa thì bài viết tiếp theo này mình sẽ cùng anh em tìm câu trả lời bằng cách tự viết một bộ công cụ hỗ trợ phân trang riêng xem sao!

Bài viết có thể sẽ hơi dài chút vì có khá nhiều kiến thức liên quan và mình cũng muốn giải thích kỹ cho anh em hiểu. Cùng mình theo dõi đến cuối bài viết đảm bảo có nhiều kiến thức bổ ích anh em nhé! 😉

À, nếu anh em nào chưa đọc bài viết trước thì có thể tham khảo tại đây. Let's gooooo!

1. Đặt vấn đề

Tại sao chúng ta cần tự viết một bộ công cụ hỗ trợ phân trang trong khi sử dụng Spring Data JPA là đã có thể làm được rồi?

Để trả lời câu hỏi thì anh em hãy cùng mình xem xét một ví dụ sau:

Giả sử mình có hai bảng studentsclasses như bên trên và bây giờ mình muốn tìm kiếm các thông tin fullname, code, phone, score trong bảng students và các thông tin name, code của lớp tương ứng với điều kiện tìm kiếm là các thông tin fullname, studentCode, classCode, scoreclassId như bên dưới.

Khi đó câu query có thể như sau:

SELECT  
	 S.fullname   as studentName,  
	 S.code       as studentCode,  
	 S.phone      as studentPhone,  
	 S.score      as studentScore,  
	 C.name       as className,  
	 C.code       as classCode  
FROM students s JOIN classes c ON s.classId = c.id 
WHERE 1=1  
 AND (:fullname IS NULL OR s.fullname LIKE :fullname)  
 AND (:studentCode IS NULL OR s.code = :studentCode)  
 AND (:classCode IS NULL OR c.code = :classCode)  
 AND (:score IS NULL OR s.score >= :score)  
 AND (:classId IS NULL OR c.id = :classId)

Nếu sử dụng PagingAndSortingRepository như trong bài viết trước thì chúng ta sẽ làm như thế nào? Có hai cách như mình đã giới thiệu, hoặc là sử dụng method's name hoặc là sử dụng native query.

Phương án sử dụng method's name gần như là không khả thi vì khi đó mình sẽ phải định nghĩa một phương thức kiểu như sau: findByFullnameAndCodeAndCodeAndScoreAndId() 

Rõ ràng tên phương thức quá dài và nếu tìm kiếm theo nhiều điều kiện khác nữa thì sao? Chưa kể nhiều bảng còn có các trường có tên trùng nhau (ví dụ như trường code có ở cả hai bảng studentsclasses) sẽ rất dễ gây nhầm lẫn.

Trường hợp sử dụng native query chúng ta có thể viết như sau:

    @Query(
            value = "SELECT  " +
                    "  s.fullname   as studentName,  " +
                    "  s.code       as studentCode,  " +
                    "  s.phone      as studentPhone,  " +
                    "  s.score      as studentScore,  " +
                    "  c.name       as className,  " +
                    "  c.code       as classCode  " +
                    "FROM students s JOIN classes c ON s.classId = c.id " +
                    "WHERE 1=1  " +
                    " AND (:fullname IS NULL OR s.fullname LIKE :fullname)  " +
                    " AND (:studentCode IS NULL OR s.code = :studentCode)  " +
                    " AND (:classCode IS NULL OR c.code = :classCode)  " +
                    " AND (:score IS NULL OR s.score >= :score)  " +
                    " AND (:classId IS NULL OR c.id = :classId)",
            countQuery = "SELECT COUNT(1)" +
                    "FROM students s JOIN classes c ON s.classId = c.id " +
                    "WHERE 1=1  " +
                    " AND (:fullname IS NULL OR s.fullname LIKE :fullname)  " +
                    " AND (:studentCode IS NULL OR s.code = :studentCode)  " +
                    " AND (:classCode IS NULL OR c.code = :classCode)  " +
                    " AND (:score IS NULL OR s.score >= :score)  " +
                    " AND (:classId IS NULL OR c.id = :classId)",
            nativeQuery = true
    )
    Page<Student> getStudentsInfo(@Param("fullname") String fullname, @Param("studentCode") String studentCode,
                                @Param("classCode") String classCode, @Param("score") Integer score,
                                @Param("classId") Long classId, Pageable pageable);

Nhìn khá là cồng kềnh anh em nhỉ! Nhưng liệu viết như thế này chương trình có chạy không? Câu trả lời là không và trừ khi mình đổi từ SELECT các trường cụ thể thành SELECT *  thì đoạn code trên mới chạy.

Tại sao?

Giả thích một cách đơn giản thì anh em có thể hiểu chúng ta đang muốn lấy toàn bộ thông tin của đối tượng Student bằng cách trả về Page<Student>. Điều đó dẫn đến việc phải SELECT * để lấy toàn bộ các trường từ trong bảng students.

Sâu xa hơn thì cái này liên quan đến việc sử dụng Java Reflection để bóc tách các trường trong đối tượng Student sau đó mapping với các columns trong bảng students được trả ra. Vì vậy nếu anh em không SELECT * hay thậm chí là alias tên các trường khác hoặc thiếu so với entity Student thì chương trình sẽ báo lỗi.

Tất nhiên vẫn có cách khắc phục đó là sử dụng Spring Data JPA Projections. Nhưng như vậy cũng đã rất không linh động và anh em cũng phải tìm hiểu cả cách triển khai nữa.

Tóm lại, khi làm việc với các câu native query - đặc biệt là các câu lệnh SELECT với mục đích tìm kiếm dữ liệu, kết hợp dữ liệu ở nhiều bảng và dữ liệu trả ra đa dạng thì việc sử dụng cấu trúc Paging của Spring Data JPA có thể sẽ gặp nhiều khó khăn và không đạt được tính linh hoạt nhất định. 

2. Phân tích & Giải pháp

Vậy mong muốn cuối cùng của chúng ta chính là làm sao thực hiện được các câu native query một cách linh hoạt nhất. Linh hoạt từ việc truyền tham số đầu vào cho đến việc tổ chức dữ liệu đầu ra sao cho phù hợp với yêu cầu.

Khi đó chúng ta phải trả lời được 3 câu hỏi:

■ Để thực hiện việc phân trang chúng ta cần những thông tin gì?

pageSize: Số bản ghi trên một trang

currentPage: Trang hiện tại 

■ Sau khi phân trang chúng ta cần trả ra những thông tin gì?

result: Kết quả tìm kiếm

pageSize: Số bản ghi trên một trang

currentPage: Trang hiện tại

totalPage: Tổng số trang

totalRecord: Tổng số bản ghi 

■ Làm sao xây dựng được một câu native query?

Đối với thao tác phân trang hay chính xác hơn là việc truy xuất dữ liệu chúng ta thường làm việc với câu lệnh SELECT. Mà câu lệnh SELECT thường có 3 mệnh đề.

SELECT: Lấy những thông tin gì?

FROM: Lấy từ đâu (bảng nào, view nào...)?

WHERE: Điều kiện là gì?

Gộp chung ba câu hỏi này lại chúng ta ra được bài toán cần giải quyết đó là: "Làm sao sử dụng một câu native query để lấy ra được tập dữ liệu cần tìm kiếm với thông tin đầu vào là các tham số tìm kiếm cùng với thông tin phân trang là pageSize và currentPage?"

Để dễ hình dung hơn mình đã thiết kế 3 đối tượng tương ứng như sau:

PageInfo: Trả lời cho câu hỏi đầu tiên và là đối tượng dùng để handle các thông tin currentPage cũng như pageSize được truyền xuống từ phía client. 

Page: Trả lời cho câu hỏi thứ hai và là đối tượng dùng để đóng gói các thông tin mà chúng ta muốn trả ra cho client (bao gồm cả các thông tin liên quan đến phân trang)

PagingRepo: Trả lời cho câu hỏi cuối cùng và đảm nhiệm việc xây dựng, biên dịch các câu native query sau đó trả ra dữ liệu tìm kiếm được thông qua đối tượng Page bên trên.

Okay, đến đây có thể anh em vẫn chưa hình dung được cụ thể các đối tượng sẽ được triển khai, sử dụng và tương tác như thế nào nhưng cứ theo chân mình sang phần tiếp theo anh em sẽ dần hiểu rõ hơn.

Keep movinggggg!

3. Triển khai

Bây giờ chúng ta sẽ triển khai coding dựa vào những gì mình đã phân tích ở phần trước.

3.1 - Xây dựng ba đối tượng PageInfo, Page, PagingRepo

PageInfo.java

@Data
public class PageInfo {
    private int currentPage;
    private int pageSize;
}

Page.java

@Data
public class Page<T> {
    private List<T> result;

    private int currentPage;
    private int pageSize;

    private int totalPage;
    private int totalRecord;

    public Page(PageInfo pageInfo){
        this.currentPage = pageInfo.getCurrentPage();
        this.pageSize = pageInfo.getPageSize();
    }

    public Page<T> withResult(List<T> result){
        this.result = result;
        return this;
    }

    public Page<T> compute(int totalRecord){
        this.totalRecord = totalRecord;
        this.totalPage = (int) Math.ceil((double) totalRecord/pageSize);
        return this;
    }
}

PagingRepo.java

public class PagingRepo<T> {
    private final EntityManager em;
    private final Class<T> genericClass;
    private String totalSql;
    private String getSql;

    public PagingRepo(EntityManager em, Class<T> genericClass) {
        this.em = em;
        this.genericClass = genericClass;
    }

    public PagingRepo<T> withTotal(String totalSql) {
        this.totalSql = totalSql;
        return this;
    }

    public PagingRepo<T> withGet(String getSql) {
        this.getSql = getSql;
        return this;
    }

    public Page<T> query(PageInfo pageInfo, Object searchDTO) {
        Query countQuery = em.createNativeQuery(totalSql);
        buildQuery(countQuery, searchDTO);
        Query selectQuery = em.createNativeQuery(getSql, Tuple.class);
        buildQuery(selectQuery, searchDTO);
        return execute(pageInfo, countQuery, selectQuery);
    }

    public Page<T> execute(PageInfo pageInfo, Query countQuery, Query query) {
        int total = ((BigInteger) countQuery.getSingleResult()).intValue();
        query.setFirstResult(pageInfo.getPageSize() * (pageInfo.getCurrentPage() - 1));
        query.setMaxResults(pageInfo.getPageSize());
        List<T> result = DataUtil.convertFromQueryResult(query.getResultList(), genericClass);
        return new Page<T>(pageInfo).withResult(result).compute(total);
    }

    private void buildQuery(Query query, Object example) {
        try {
            Field[] fields = example.getClass().getDeclaredFields();
            for (Field field : fields) {
                if (field.isAnnotationPresent(QueryField.class)) {
                    QueryField queryField = field.getDeclaredAnnotation(QueryField.class);
                    if (queryField.queryLike())
                        query.setParameter(queryField.value(), queryLike(field, example));
                    else
                        query.setParameter(queryField.value(), isCheckEmpty(field, example, queryField.trim()));
                }
            }
        } catch (Exception ex) {
            throw new AppException("API001", ex.getMessage());
        }
    }

    private static Object isCheckEmpty(Field field, Object object, boolean isTrim) {
        try {
            String typeField = field.getType().getTypeName();
            if (typeField.equalsIgnoreCase(String.class.getName())) {
                if (DataUtil.isNullOrEmpty((String) field.get(object))) {
                    return null;
                }
            }
            return isTrim ? ((String) field.get(object)).trim() : field.get(object);
        } catch (Exception ex) {
            throw new AppException("API001", ex.getMessage());
        }
    }

    private Object queryLike(Field field, Object object) {
        try {
            String typeField = field.getType().getTypeName();
            if (typeField.equalsIgnoreCase(String.class.getName())) {
                if (DataUtil.isNullOrEmpty((String) field.get(object))) {
                    return null;
                }
            }
            String value = (String) field.get(object);
            return "%" + value.trim() + "%";
        } catch (Exception ex) {
            throw new AppException("API001", ex.getMessage());
        }
    }
}

Ở đây mình có sử dụng một số kiến thức liên quan đến generic classJava reflection. Nếu anh em nào biết rồi thì có thể dễ dàng hiểu những đoạn code bên trên. Nhưng anh em nào chưa biết cũng không cần quá lo lắng vì mình sẽ giải thích chức năng của các hàm để anh em dễ hình dung hơn.

Ở đây, mình tập trung vào đối tượng PagingRepo và các phương thức bên trong đối tượng này. Quan trọng nhất là phương thức buildQuery() sử dụng Java Reflection để set giá trị tham số cho câu query (tức là hoàn thiện mệnh đề WHERE

Phương thức isCheckEmpty() được sử dụng để loại bỏ khoảng trắng của những tham số tìm kiếm có kiểu dữ liệu String. Tại sao phải loại bỏ khoảng trắng? Đơn giản vì nếu có khoảng trắng thì câu query sau khi biên dịch sẽ báo lỗi. 

Phương thức queryLike() thì chắc anh em cũng hiểu rồi, phương thức này được sử dụng để trong trường hợp anh em muốn tìm kiếm LIKE một tham số nào đó thì tham số đó sẽ được thêm toán tử % để đúng với cú pháp câu query LIKE.

Các phương thức còn lại mình nghĩ cũng khá là dễ hiểu nên anh em có thể đọc code để dễ hình dung hơn.

3.2 - Ví dụ

Bây giờ sau khi xây dựng được ba đối tượng PageInfo, PagePageRepo mình sẽ cùng anh em sử dụng 3 đối tượng này cho một ví dụ về phân trang xem sao nhé!

Note: Do mình có xây dựng nhiều hàm dùng chung nên để tiện theo dõi anh em có thể download mã nguồn của mình tại đây và follow theo.

Okay, let's gooo!

Cụ thể mình sẽ viết một RESTful API để lấy dữ liệu và trả về dạng phân trang. 

■ Setup Database

Tạo một bảng students:

CREATE TABLE `students` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `fullname` varchar(255) DEFAULT NULL COMMENT 'tên sinh viên',
  `code` text DEFAULT NULL COMMENT 'mã sinh viên',
  `address` varchar(255) DEFAULT NULL COMMENT 'Địa chỉ',
  `phone` varchar(20) DEFAULT NULL COMMENT 'Số điên thoai',
  `age` int(3) DEFAULT NULL COMMENT 'Tuổi',
  `score` int(2) DEFAULT NULL COMMENT 'Điểm số',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1606 DEFAULT CHARSET=utf8mb4;

Tạo Entity class để mapping với table students:

@Data
@Table
@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ID")
    private Long id;

    @Column(name = "fullname")
    private String fullName;

    @Column(name = "code")
    private String code;

    @Column(name = "address")
    private String address;

    @Column(name = "phone")
    private String phone;

    @Column(name = "age")
    private int age;
}

Về phần dữ liệu test thì mình có viết một proceduce để thêm 1000 bản ghi test, anh em có thể sử dụng nếu thích nhé😉

CREATE DEFINER=`root`@`localhost` PROCEDURE `b2a`.`insert_data`()
BEGIN
    DECLARE i int DEFAULT 1;
    WHILE i <= 1000 DO
        INSERT INTO students (fullname, code, address, phone, age) VALUES (CONCAT('student ', i), CONCAT('ST_CODE_', i),
            CONCAT('address ', i), CONCAT('phone ', i), i);
        SET i = i + 1;
    END WHILE;
END

Okay, vậy là xong phần setup database!

■ Xây dựng API

Trong thực tế những câu query chúng ta viết sẽ rất phức tạp. Phức tạp từ chỗ tìm kiếm theo nhiều tham số, kiểm tra nhiều điều kiện và dữ liệu trả ra được tổ chức cũng rất đa dạng phụ thuộc vào từng nghiệp vụ cụ thể.

+ Ví dụ này mình giả sử chúng ta muốn tìm kiếm theo 3 thông tin: code, age, score của đối tượng Student. Kết quả trả về dạng phân trang với thông tin pageSize = 5, currentPage = 1.

Khi đó điều kiện tìm kiếm của mình sẽ có dạng:

{
    "searchSdi": {
        "code": "",
        "age": "",
        "score": 0
    },
    "pageInfo": {
        "currentPage": 1,
        "pageSize": 5
    }
}

+ Tương tự kết quả trả ra mình mình mong muốn có dạng như sau:

{
    "result": [
        {
            "id": 1283,
            "fullName": "student 678",
            "code": "ST_CODE_678",
            "address": "address 678",
            "phone": "phone 678",
            "age": 678,
            "score": 10
        },
        ...
    ],
    "currentPage": 1,
    "pageSize": 5,
    "totalPage": 200,
    "totalRecord": 1000
}

Vậy mình sẽ có các đối tượng tương tứng để mô tả điều kiện tìm kiếm cũng như kết quả trả về như sau:

@Data
public class StudentSearchSdi {
    @QueryField("code")
    public String code;

    @QueryField("age")
    public Integer age;

    @QueryField("score")
    public Integer score;
}

@Data
public class StudentSearchPage {
    private StudentSearchSdi searchSdi;
    private PageInfo pageInfo;
}

@Data
public class StudentSearchSdo {
    private Long id;
    private String fullName;
    private String code;
    private String address;
    private String phone;
    private Integer age;
    private Integer score;
}

Note: Có thể ở đây sẽ có anh em thắc mắc là nếu như vậy mình sẽ phải sử dụng HTTP method POST để tìm kiếm vì mình đang truyền một form dữ liệu lên? Đúng! Thông thường tìm kiếm chúng ta thường dùng HTTP method GET nhưng thực tế như mình đã trình bày có những lúc phải tìm theo rất nhiều điểu kiện nên dùng GET sẽ không tối ưu.

Okay, tiếp theo chúng ta xây dựng các tầng repository, service và controller theo như cấu trúc chung của việc viết RESTful API sử dụng Spring Boot.

+ Tầng repository:

@Repository
public interface StudentRepository extends JpaRepository<Student, Long>, StudentRepositoryCustom {
}

public interface StudentRepositoryCustom {
    Page<StudentSearchSdo> findByCondition(StudentSearchPage searchPage);
}

public class StudentRepositoryCustomImpl implements StudentRepositoryCustom {
    @Autowired
    EntityManager em;

    @Override
    public Page<StudentSearchSdo> findByCondition(StudentSearchPage searchPage) {
        StringBuilder sqlCount = new StringBuilder()
                .append("select count(1) ");

        StringBuilder sqlQuery = new StringBuilder()
                .append("select  ")
                .append("	s.id , ")
                .append("	s.fullname as fullName , ")
                .append("	s.code , ")
                .append("	s.address , ")
                .append("	s.phone , ")
                .append("	s.age , ")
                .append("	s.score  ");

        StringBuilder sqlCondition = new StringBuilder()
                .append("from students s ")
                .append("where 1=1 ")
                .append("	and (:code is null or :code = '' or s.code = :code) ")
                .append("	and (:age is null or :age = '' or s.age = :age) ")
                .append("	and (:score is null or :score = '' or s.score <= :score) ");

        StringBuilder sqlSort = new StringBuilder()
                .append("order by s.score desc ");

        PagingRepo<StudentSearchSdo> pagingRepo = new PagingRepo<>(em, StudentSearchSdo.class)
                .withGet(sqlQuery.append(sqlCondition).append(sqlSort).toString())
                .withTotal(sqlCount.append(sqlCondition).toString());

        return pagingRepo.query(searchPage.getPageInfo(), searchPage.getSearchSdi());
    }
}

Ở đây để tận dụng "sức mạnh" của Spring Data JPA mình đã cho interface StudentRepository kế thừa interface JpaRepository. 

+ Tầng service:

public interface StudentService {
    Page<StudentSearchSdo> getStudents(StudentSearchPage searchPage);
}

@Service
public class StudentServiceImpl implements StudentService {
    @Autowired
    StudentRepository repo;

    @Override
    public Page<StudentSearchSdo> getStudents(StudentSearchPage searchPage) {
        return repo.findByCondition(searchPage);
    }
}

+ Tầng controller:

@RestController
@RequestMapping("/students")
public class StudentController {
    @Autowired
    StudentService postService;

    @PostMapping("/v1")
    public ResponseEntity<Page<StudentSearchSdo>> getStudents(@RequestBody StudentSearchPage searchPage) {
        return ResponseEntity.ok().body(postService.getStudents(searchPage));
    }
}

■ Testing

Ở đây mình sử dụng Postman để gọi API như sau:

Output:

{
    "result": [
        {
            "id": 1283,
            "classId": null,
            "fullName": "student 678",
            "code": "ST_CODE_678",
            "address": "address 678",
            "phone": "phone 678",
            "age": 678,
            "score": 10
        }
    ],
    "currentPage": 1,
    "pageSize": 10,
    "totalPage": 1,
    "totalRecord": 1
}

4. Lời kết

Vậy là trong bài viết này mình đã cùng anh em tìm hiểu lý do cũng như cách thức tại sao chúng ta nên tự viết một bộ công cụ hỗ trợ việc phân trang dữ liệu. 

Tất nhiên trong nhiều trường hợp việc tìm kiếm dữ liệu là đơn giản thì chúng ta cũng không nên lạm dụng mà hãy đơn giản hóa vấn đề nhất có thể.

Ngoài ra, tuy bài viết có nhiều kiến thức hơi nâng cao một chút nhưng anh em cũng không cần phải hiểu hết về những kiến thức đó mà chỉ cần khai thác những gì mình giới thiệu bên trên để nắm được tư tưởng chính.

Cuối cùng thì anh em có thể download source code tại đây để ngâm cứu thêm. Bye! Hẹn gặp lại anh em trong các bài viết tiếp theo. Thanks all!!! ❤️❤️❤️

Không có nhận xét nào:

Được tạo bởi Blogger.