Cách sử dụng Spring Data Neo4j để truy cập cơ sở dữ liệu Neo4j - Rik68 Club Game Bài Tiền Thật

| Feb 2, 2025 min read

05 tháng 12 năm 2024 | Máy tính

Bài viết trước “Khám phá Neo4j” đã giới thiệu các khái niệm cơ bản của Neo4j và cách sử dụng sơ bộ. Bài viết này sẽ tập trung vào việc làm thế nào để sử dụng Spring Data Neo4j để truy cập cơ sở dữ liệu Neo4j. Spring Data Neo4j là một phần của dự án Spring Data, giúp đơn giản hóa việc tương tác với cơ sở dữ liệu đồ thị Neo4j. Ngoài khả năng thực hiện các thao tác CRUD thông qua Repository, Spring Data Neo4j còn hỗ trợ quản lý giao dịch, truy vấn Cypher và mô hình hóa dữ liệu đồ thị.

Tiếp theo, chúng ta sẽ trình bày ví dụ về cách sử dụng Spring Data Neo4j. Ví dụ này liên quan đến tình huống kinh doanh lưu trữ dữ liệu về mối quan hệ giữa “Diễn viên (Actor) - Tham gia (ACTED_IN) -> Phim (Movie)”.

Phiên bản Neo4j cài đặt cục bộ là 5.25.1, và các phụ thuộc cùng phiên bản trong ví dụ này bao gồm:

JDK: BellSoft Liberica 17.0.7
Maven: 3.9.2
Spring Boot: 3.4.0
Spring Data Neo4j: 7.4.0 [Sam86 Club Choi Game Bài](/post/4011/) 

Cấu trúc thư mục của ví dụ này như sau:

spring-data-neo4j-demo
├─ src
│ ├─ main
│ │ ├─ java
│ │ │ └─ com.example.demo
│ │ │   ├─ repository
│ │ │   │ ├─ MovieRepository.java
│ │ │   │ └─ ActorRepository.java
│ │ │   ├─ service
│ │ │   │ ├─ ActorMovieService.java
│ │ │   │ └─ impl
│ │ │   │   └─ ActorMovieServiceImpl.java
│ │ │   ├─ model
│ │ │   │ ├─ Actor.java
│ │ │   │ ├─ Movie.java
│ │ │   │ └─ Role.java
│ │ │   └─ DemoApplication.java
│ │ └─ resources
│ │   └─ application.properties
│ └─ test
│   └─ java
│    └─ com.example.demo
│      ├─ repository
│      │ ├─ MovieRepositoryTest.java
│      │ └─ ActorRepositoryTest.java
│      └─ service
│       └─ ActorMovieServiceTest.java
└─ pom.xml

Từ cấu trúc thư mục trên có thể thấy đây là một dự án Maven tiêu chuẩn. Thư mục src/main chứa mã nguồn chính và tệp cấu hình, trong khi src/test chứa các lớp kiểm thử đơn vị. Tất cả mã Java trong src/main/java đều nằm trong gói com.example.demo, trong đó DemoApplication là điểm nhập của chương trình, model chứa các lớp mô hình, repository chứa các lớp kho lưu trữ, và service chứa các lớp dịch vụ.

Thư mục src/test/java cũng có cấu trúc gói giống như mã chính. Các lớp kiểm thử đơn vị trong repository như MovieRepositoryTest dùng để kiểm tra MovieRepository, ActorRepositoryTest dùng để kiểm tra ActorRepository; và ActorMovieServiceTest trong service dùng để kiểm tra ActorMovieService.

Sau khi giới thiệu cấu trúc dự án, hãy xem xét các phụ thuộc chính được sử dụng:

<!-- pom.xml -->
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-neo4j</artifactId>
  </dependency>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.36</version>
    <scope>provided</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

Dễ thấy rằng dự án chỉ có rất ít phụ thuộc chính yếu, chủ yếu là spring-boot-starter-webspring-boot-starter-data-neo4j. Để tránh phải viết phương thức Setters, Getters và hàm tạo cho các lớp Model, dự án sử dụng phụ thuộc lombok. Cuối cùng, để viết kiểm thử đơn vị, dự án cũng phụ thuộc vào spring-boot-starter-test.

Giới thiệu xong cấu trúc dự án và các phụ thuộc liên quan, bây giờ bắt đầu phân tích các tệp hoặc mã chính trong dự án.

1 Phân tích mã nguồn

Ví dụ trong bài viết này nhắm tới tình huống “Diễn viên (Actor) - Tham gia (ACTED_IN) -> Phim (Movie)”, với biểu đồ mô hình như sau. ![Biểu đồ mô hình “Diễn viên (Actor) - Tham gia (ACTED_IN) -> Phim (Movie)”]

1.1 Tệp cấu hình

Tệp cấu hình application.properties có nội dung như sau:

# src/main/resources/application.properties
spring.neo4j.uri=bolt://localhost:7687/neo4j
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=neo4j

Như vậy, chúng ta đã cấu hình địa chỉ truy cập Neo4j, tên người dùng và mật khẩu trong tệp cấu hình.

1.2 Lớp Model

Lớp Model trong Java được sử dụng để ánh xạ trực tiếp với các nút hoặc mối quan hệ trong Neo4j. Từ biểu đồ mô hình trên, “Diễn viên (Actor) - Tham gia (ACTED_IN) -> Phim (Movie)” bao gồm hai nút: Diễn viên và Phim, cùng với một mối quan hệ: Tham gia. Vì vậy, ví dụ này có ba lớp Model: Actor, MovieRole, lần lượt đại diện cho nút Diễn viên, nút Phim và mối quan hệ diễn viên đóng vai trong phim.

Lớp Actor có nội dung như sau:

// src/main/java/com/example/demo/model/Actor.java
package com.example.demo.model;

import org.springframework.data.neo4j.core.schema.*;

import lombok.*;

@Data
@NoArgsConstructor
@Node
public class Actor {
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    private String nationality;
    private int yearOfBirth;

    public Actor(String name, String nationality, int yearOfBirth) {
        this.name = name;
        this.nationality = nationality;
        this.yearOfBirth = yearOfBirth;
    }
}

Lớp này được đánh dấu bằng chú thích @Node, biểu thị nó tương ứng với nút Actor trong Neo4j. Để phân biệt từng cá thể trong Actor, mỗi thực thể Actor nên có khóa chính, vì vậy trường id được đánh dấu bằng @Id là khóa chính của Actor, và @GeneratedValue biểu thị giá trị này được sinh tự động. Ngoài ra, nút Actor còn có ba thuộc tính khác: name, nationalityyearOfBirth.

Lớp Movie có nội dung như sau:

// src/main/java/com/example/demo/model/Movie.java
package com.example.demo.model;

import org.springframework.data.neo4j.core.schema.*;
import lombok.*;

import java.util.List;

@Data
@NoArgsConstructor
@Node
public class Movie {
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    private int releasedAt;

    @Relationship(type="ACTED_IN", direction=Relationship.Direction.INCOMING)
    private List<Role> rolesAndActors;

    public Movie(String name, int releasedAt, List<Role> rolesAndActors) {
        this.name = name;
        this.releasedAt = releasedAt;
        this.rolesAndActors = rolesAndActors;
    }
}

Lớp này tương ứng với nút Movie trong Neo4j, ngoài việc có một trường khóa chính thì còn có hai thuộc tính namereleasedAt. Điểm mấu chốt là nó có một trường được đánh dấu bằng chú thích @RelationshiprolesAndActors, biểu thị đây là một thuộc tính mối quan hệ, không phải thuộc tính thông thường. Loại mối quan hệ là ACTED_IN (tham gia), hướng là INCOMING, biểu thị mối quan hệ đi vào (tức mũi tên chỉ vào Movie). Một bộ phim có thể được nhiều diễn viên tham gia, vì vậy rolesAndActors là kiểu List<Role>.

Lớp Role có nội dung như sau:

// src/main/java/com/example/demo/model/Role.java
package com.example.demo.model;

import org.springframework.data.neo4j.core.schema.*;
import lombok.*;

@Data [winvip.club](/post/how-does-spring-data-operate-both-mysql-and-neo4j/) 
@NoArgsConstructor
@RelationshipProperties
public class Role {
    @RelationshipId
    private Long id;
    
    private String name;

    @TargetNode
    private Actor actor;

    public Role(String name, Actor actor) {
        this.name = name;
        this.actor = actor;
    }
}

Lớp này được đánh dấu bằng chú thích @RelationshipProperties, biểu thị nó là một lớp thuộc tính mối quan hệ. Lớp này cũng cần một khóa chính, vì vậy có một trường id được đánh dấu bằng @RelationshipId. Ngoài ra còn có một thuộc tính name, biểu thị tên vai diễn mà diễn viên đảm nhận trong bộ phim. Và một trường actor được đánh dấu bằng @TargetNode, biểu thị đầu kia của mối quan hệ là một Actor.

Ba lớp Model trên đã hoàn thành, từ đó mô hình “Diễn viên (Actor) - Tham gia (ACTED_IN) -> Phim (Movie)” đã được thiết lập.

1.3 Giao diện Repository

Sau khi định nghĩa xong lớp Model, bước tiếp theo là định nghĩa giao diện Repository để truy vấn Neo4j.

Chúng ta biết rằng Spring Data Repository thống nhất cách truy cập các loại cơ sở dữ liệu khác nhau (chẳng hạn như MySQL, Oracle… cơ sở dữ liệu quan hệ, MongoDB… cơ sở dữ liệu phi quan hệ, Neo4j… cơ sở dữ liệu đồ thị). Chúng ta chỉ cần định nghĩa một giao diện Repository, kế thừa từ một giao diện cha thì sẽ có ngay các chức năng CRUD cơ bản. Ngoài ra, chúng ta cũng có thể thêm các phương thức truy vấn tùy chỉnh theo quy tắc đặt tên.

Ví dụ này có hai giao diện Repository: ActorRepositoryMovieRepository, lần lượt dùng để truy vấn Actor và Movie.

Giao diện ActorRepository có nội dung như sau:

// src/main/java/com/example/demo/repository/ActorRepository.java
package com.example.demo.repository;

import org.springframework.data.neo4j.repository.Neo4jRepository;
import org.springframework.data.neo4j.repository.query.Query;
import org.springframework.data.neo4j.repository.support.CypherdslConditionExecutor;

import java.util.List;

public interface ActorRepository extends Neo4jRepository<Actor, Long>, CypherdslConditionExecutor<Actor> {

    @Query("""
            MATCH (a:Actor)-[:ACTED_IN]->(m:Movie)
            WHERE m.name = $name
            RETURN a.name
            """)
    List<String> findActorNamesByMovieName(String name);

    @Query("""
            MATCH (a:Actor)-[:ACTED_IN]->(m:Movie)
            WHERE m.name = $name
            RETURN COALESCE(AVG(datetime().year - a.yearOfBirth), 0)
            """)
    double findAverageAgeOfActorsByMovieName(String name);

    @Query("""
            MATCH (a1:Actor {name: $actor1})
            MATCH (a2:Actor {name: $actor2})
            MATCH p = shortestPath((a1)-[*..10]-(a2))
            RETURN p
            """)
    List<Path> findShortestPathBetweenActors(String actor1, String actor2);

    @Query("""
            MATCH (a:Actor)
            WHERE a.name = $name
            SET a.yearOfBirth = $yearOfBirth
            """)
    void updateYearOfBirthByName(String name, int yearOfBirth);
}

Giao diện này kế thừa hai giao diện cha: Neo4jRepositoryCypherdslConditionExecutor. Neo4jRepository ngoài việc cung cấp các chức năng CRUD cơ bản còn hỗ trợ phân trang và sắp xếp. Trong khi đó, CypherDslConditionExecutor hỗ trợ truy vấn Cypher DSL, tức là cho phép thực hiện các truy vấn phức tạp bằng cách lập trình.

Ngoài ra, chúng ta còn sử dụng chú thích @Query để viết một loạt các truy vấn Cypher tùy chỉnh (và phương thức cập nhật), lần lượt dùng để: tìm danh sách tên diễn viên tham gia bộ phim theo tên phim (findActorNamesByMovieName()), tìm tuổi trung bình của các diễn viên tham gia bộ phim theo tên phim (findAverageAgeOfActorsByMovieName()), tìm đường ngắn nhất giữa hai diễn viên (findShortestPathBetweenActors()), và cập nhật năm sinh theo tên diễn viên (updateYearOfBirthByName()).

Giao diện MovieRepository có nội dung như sau:

// src/main/java/com/example/demo/repository/MovieRepository.java
package com.example.demo.repository;

import org.springframework.data.neo4j.repository.Neo4jRepository;
import org.springframework.data.neo4j.repository.query.Query;
import org.springframework.data.neo4j.repository.support.CypherdslConditionExecutor;

import java.util.List;

public interface MovieRepository extends Neo4jRepository<Movie, Long>, CypherdslConditionExecutor<Movie> {

    List<Movie> findByName(String name);

    @Query("""
            MATCH (m:Movie)<-[:ACTED_IN]-(a:Actor)
            WHERE a.name = $name
            RETURN m.name
            """)
    List<String> findMovieNamesByActorName(String name);
}

Giao diện này cũng kế thừa hai giao diện cha: Neo4jRepositoryCypherdslConditionExecutor. Ngoài ra còn thêm một phương thức đặt tên theo quy tắc (findByName()) và một truy vấn tùy chỉnh (findMovieNamesByActorName()), lần lượt dùng để: tìm Movie theo tên và tìm tên các bộ phim mà diễn viên tham gia theo tên diễn viên.

Sau khi giới thiệu hai giao diện Repository này, chúng ta sẽ tạo một giao diện ActorMovieService trong gói service, rồi triển khai giao diện này trong ActorMovieServiceImpl để khám phá các tính năng khác của Spring Data Neo4j.

1.4 Giao diện ActorMovieService và triển khai

Giao diện ActorMovieService có nội dung như sau:

// src/main/java/com/example/demo/service/ActorMovieService.java
package com.example.demo.service;

import java.util.List;

public interface ActorMovieService {
    List<Movie> findMoviesByActorName(String name);
    List<Actor> findActorsByNamePrefix(String prefix);
    List<Actor> findActorsByNamePrefixWithQueryByExample(String prefix);
    void updateMovie(Movie movie);
}

Giao diện này định nghĩa bốn phương thức, lần lượt dùng để: tìm các bộ phim mà diễn viên tham gia theo tên diễn viên (findMoviesByActorName()), tìm diễn viên theo tiền tố tên (findActorsByNamePrefix()), tìm diễn viên theo tiền tố tên nhưng sử dụng cách tiếp cận Query by Example (findActorsByNamePrefixWithQueryByExample()), và cập nhật Movie (updateMovie()).

Triển khai giao diện ActorMovieService trong ActorMovieServiceImpl có nội dung như sau:

// src/main/java/com/example/demo/service/impl/ActorMovieServiceImpl.java
package com.example.demo.service.impl;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
public class ActorMovieServiceImpl implements ActorMovieService {

    @Autowired
    private ActorRepository actorRepository;

    @Autowired
    private MovieRepository movieRepository;

    @Autowired
    private Neo4jTemplate neo4jTemplate;

    @Override
    public List<Movie> findMoviesByActorName(String name) {
        String cypher = """
                MATCH (a:Actor)-[:ACTED_IN]->(m:Movie)
                WHERE a.name = $name
                RETURN m
                """;
        Map<String, Object> params = new HashMap<>();
        params.put("name", name);
        return neo4jTemplate.findAll(cypher, params, Movie.class);
    }

    @Override
    public List<Actor> findActorsByNamePrefix(String prefix) {
        Node actor = Cypher.node("Actor").named("actor");
        Property name = actor.property("name");
        Property yearOfBirth = actor.property("yearOfBirth");
        Condition condition = name.startsWith(Cypher.anonParameter(prefix));
        return actorRepository.findAll(condition, yearOfBirth.descending()).stream().collect(Collectors.toList());
    }

    @Override
    public List<Actor> findActorsByNamePrefixWithQueryByExample(String prefix) {
        Actor exampleActor = new Actor();
        exampleActor.setName(prefix);
        ExampleMatcher matcher = ExampleMatcher.matching()
                .withIgnorePaths("yearOfBirth")
                .withStringMatcher(ExampleMatcher.StringMatcher.STARTING);
        return actorRepository.findAll(Example.of(exampleActor, matcher), Sort.by("yearOfBirth").descending());
    }

    @Transactional
    @Override
    public void updateMovie(Movie movie) {
        movieRepository.findById(movie.getId())
                .map(m -> {
                    m.setName(movie.getName());
                    if (movie.getReleasedAt() > 0) {
                        m.setReleasedAt(movie.getReleasedAt());
                    }
                    return movieRepository.save(m);
                })
                .orElseThrow(() -> new RuntimeException("Phim không tìm thấy"));
    }
}

Để ý rằng phương thức findMoviesByActorName() sử dụng việc tự viết câu lệnh Cypher, thiết lập tham số và gọi Neo4jTemplate để thực hiện; findActorsByNamePrefix() sử dụng cách tiếp cận Cypher DSL để thực hiện truy vấn và sắp xếp; findActorsByNamePrefixWithQueryByExample() có chức năng tương tự nhưng sử dụng cách tiếp cận Query by Example; và phương thức cuối cùng updateMovie() sử dụng chú thích @Transactional để hỗ trợ giao dịch (cập nhật sẽ bị hủy nếu ném ngoại lệ).

2 Kiểm thử đơn vị

Tiếp theo là viết các lớp kiểm thử đơn vị để kiểm tra Repository và Service đã đề cập ở trên.

Kiểm thử đơn vị MovieRepositoryTest dành cho MovieRepository có nội dung như sau:

// src/test/java/com/example/demo/repository/MovieRepositoryTest.java
package com.example.demo.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest
public class MovieRepositoryTest {

    @Autowired
    private MovieRepository movieRepository;

    @Test
    public void testSaveAll() {
        long movieCount = movieRepository.count();
        // Khởi tạo dữ liệu
        if (0 == movieCount) {
            Actor actor1 = new Actor("Ngô Kinh", "Trung Quốc", 1974);
            Actor actor2 = new Actor("Lư Tĩnh Shan", "Trung Quốc", 1985);
            Actor actor3 = new Actor("Gã Dịu", "Trung Quốc", 1957);
            List<Movie> movies = List.of(
                    new Movie("Chiến Lang II", 2017, List.of(new Role("Lạnh Phong", actor1), new Role("Rachel", actor2))),
                    new Movie("Thái Cực Tông Sư", 1998, List.of(new Role("Dương Dật Quân", actor1))),
                    new Movie("Hành Tinh Đất II", 2023, List.of(new Role("Lưu Bồi Cường", actor1))),
                    new Movie("Tôi Và Quê Hương Của Tôi", 2020, List.of(new Role("EMMA MEIER", actor2), new Role("Trương Bắc Kinh", actor3)))
            );
            movieRepository.saveAll(movies);
        }
    }

    @Test
    public void testFindByName() {
        List<Movie> movies = movieRepository.findByName("Chiến Lang II");
        System.out.println(movies);
    }

    @Test
    public void testFindMovieNamesByActorName() {
        List<String> movieNames = movieRepository.findMovieNamesByActorName("Ngô Kinh");
        System.out.println(movieNames);
    }
}

Phương thức testSaveAll() gọi movieRepository.saveAll() để lưu trữ một nhóm Movie kèm thông tin mối quan hệ. Điều này tương đương với câu lệnh Cypher sau:

CREATE
 (a1:Actor {name: "Ngô Kinh", nationality: "Trung Quốc", yearOfBirth: 1974}),
 (a2:Actor {name: "Lư Tĩnh Shan", nationality: "Trung Quốc", yearOfBirth: 1985}),
 (a3:Actor {name: "Gã Dịu", nationality: "Trung Quốc", yearOfBirth: 1957}),
 (m1:Movie {name: "Chiến Lang II", releasedAt: 2017}),
 (m2:Movie {name: "Thái Cực Tông Sư", releasedAt: 1998}),
 (m3:Movie {name: "Hành Tinh Đất II", releasedAt: 2023}),
 (m4:Movie {name: "Tôi Và Quê Hương Của Tôi", releasedAt: 2020}),
 (a1)-[:ACTED_IN {role: "Lạnh Phong"}]->(m1),
 (a1)-[:ACTED_IN {role: "Dương Dật Quân"}]->(m2),
 (a1)-[:ACTED_IN {role: "Lưu Bồi Cường"}]->(m3),
 (a2)-[:ACTED_IN {role: "Rachel"}]->(m1),
 (a2)-[:ACTED_IN {role: "EMMA MEIER"}]->(m4),
 (a3)-[:ACTED_IN {role: "Trương Bắc Kinh"}]->(m4);

Sau khi thực thi phương pháp lưu trữ trên, sử dụng câu lệnh Cypher sau để truy vấn toàn bộ mối quan hệ “Diễn viên - Tham gia -> Phim”:

MATCH (a:Actor)-[r:ACTED_IN]->(m:Movie)
RETURN a, r, m

Kết quả thu được như sau: ![Biểu đồ mối quan hệ “Diễn viên - Tham gia -> Phim”]

Các phương thức khác gọi movieRepository.findByName()movieRepository.findMovieNamesByActorName(), kết quả xuất ra đúng như mong đợi.

Các lớp kiểm thử đơn vị khác như ActorRepositoryTestActorMovieServiceTest sẽ không được mở rộng tại đây.

3 Kết luận

Tóm lại, chúng ta đã khám phá cách sử dụng Spring Data Neo4j để truy cập cơ sở dữ liệu Neo4j, cảm nhận được sự tiện lợi của việc tích hợp với khung Spring Boot, chỉ cần thêm một Starter là đủ. Về mặt sử dụng, Spring Data Neo4j tuân theo tư tưởng thiết kế thống nhất của Spring Data. Chúng ta có thể sử dụng chú thích để ánh xạ lớp Java với thực thể cơ sở dữ liệu Neo4j; có thể kế thừa giao diện Repository để thực hiện các chức năng tăng-xóa-sửa-truy vấn cơ bản, và sử dụng chú thích @Query để tự viết câu lệnh Cypher; cũng có thể sử dụng Neo4jTemplate để tự viết truy vấn và thao tác với cơ sở dữ liệu Neo4j. Ngoài ra, Spring Data Neo4j còn cung cấp hai cách truy vấn bổ sung là Query by Example và Cypher DSL để truy vấn cơ sở dữ liệu Neo4j.

Mã nguồn đầy đủ của ví dụ này đã được gửi lên GitHub, mời bạn quan tâm hoặc Fork.

[1] Spring: Tài liệu tham khảo Spring Data Neo4j - [2] Spring: Truy cập dữ liệu Neo4j với REST -

#Spring #Java #Neo4j