Lập trình Spring với ứng dụng MyContact

MyContact là một ứng dụng mà mình thường viết mỗi khi học một ngôn ngữ hay công nghệ mới. MyContact chỉ là một ứng dụng CRUD đơn giản, cho phép người dùng quản lý danh bạ cá nhân. Theo mình, viết ứng dụng thực tế là một trong những cách học hiệu qủa nhất. Thông qua tutorial này, mình hi vọng sẽ giúp các bạn hiểu rõ hơn về Spring cũng như cách áp dụng trong thực tế.

Spring là một đại gia đình với rất nhiều dự án bên trong. Trong tutorial này, mình sẽ chỉ sử dụng các dự án sau:

  • Spring Boot (version 1.5.x) để khởi tạo và tự động cấu hình dự án.
  • Spring MVC để xây dựng web app.
  • Spring Data để thao tác với cơ sở dữ liệu. Với mỗi loại CSDL, lại có một dự án Spring Data riêng. Do mình sử dụng MySQL - một RDBMS, nên mình sẽ lựa chọn Spring Data JPA.

Các công nghệ khác mình sẽ sử dụng:

  • Hệ quản trị CSDL: MySQL 5
  • ORM framework: Hibernate 5
  • Template engine: Thymeleaf 2.1

Source code của dự án: https://github.com/yuen26/mycontact-mpa.

Source code giao diện: https://github.com/yuen26/mycontact-html/tree/master/mpa.


1. Tổng quan về ứng dụng MyContact

1.1. Các chức năng

Ứng dụng bao gồm các chức năng:

  • Hiển thị danh sách liên hệ
  • Tìm kiếm liên hệ theo tên
  • Thêm liên hệ mới
  • Sửa liên hệ
  • Xóa liên hệ

1.2. Các giao diện

list.html: màn hình danh sách liên hệ

MyContact list page

form.html: màn hình thêm/sửa liên hệ

MyContact form page

1.3. Cơ sở dữ liệu

Cơ sở dữ liệu của ứng dụng chỉ có duy nhất 1 bảng Contact chứa tên, email và số điện thoại của liên hệ:

CREATE TABLE `contact` (
 `id` int(11) NOT NULL AUTO_INCREMENT,
 `name` varchar(50) NOT NULL,
 `email` varchar(50) NULL,
 `phone` varchar(20) NULL,
 PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

2. Xây dựng kiến trúc dự án

Ứng dụng MyContact được xây dựng theo kiến trúc phân lớp (layered architecture). Với kiến trúc này, ứng dụng được bổ ngang thành các lớp (layer). Mỗi một lớp sẽ bao gồm các thành phần có cùng trách nhiệm => đảm bảo Nguyên lý đơn nhiệm (Single Responsibility) trong SOLID.

MyContact architecture

Trong đó:

  • Entity là các POJO mapping với các bảng CSDL.
  • Repository là các interface trực tiếp truy cập và thao tác với CSDL.
  • Service là các lớp có nhiệm vụ xử lý business logic, không trực tiếp truy cập vào CSDL mà sẽ lấy dữ liệu từ các repository, rồi chuyển dữ liệu đã xử lý cho các controller.
  • Controller là các lớp chỉ quan tâm đến request của người dùng: đọc input, xử lý input, lấy dữ liệu từ service rồi đổ ra view. Controller có nhiều phương thức, mỗi phương thức sẽ tương ứng với một case request.
  • View là các file HTML (do chúng ta sử dụng template engine là Thymeleaf), đóng vai trò giao diện người dùng.

Chắc các bạn sẽ thắc mắc tại sao mình không để các xử lý business logic trong controller? Chúng ta thử hình dung, nếu bây giờ ứng dụng MyContact phải cung cấp RESTful Web Service cho một app Android => Chúng ta phải viết thêm một RestController. Nếu như không tách riêng xử lý business logic thành các service thì trong RestController chúng ta bắt buộc phải duplicate code của controller đã có. Điều này đã phá vỡ nguyên tắc DRY (Don't Repeat Yourself).

Dưới đây mình sẽ giải thích rõ hơn workflow của ứng dụng thông qua use case Người dùng truy cập vào màn hình Danh sách liên hệ:

  1. Spring sẽ quét tất cả các controller, lựa chọn phương thức có URI pattern phù hợp với request.
  2. Phương thức này sẽ gọi service để lấy danh sách liên hệ từ CSDL:
  3. Service không trực tiếp truy cập vào CSDL mà thông qua repository để lấy danh sách liên hệ từ CSDL. Repository trả về danh sách liên hệ tìm thấy cho service. Service chuyển lại danh sách liên hệ cho controller.
  4. Controller đổ danh sách liên hệ ra view.

Đến đây, chắc các bạn cũng hiểu sơ qua về kiến trúc của ứng dụng. OK, giờ thì bắt tay vào code thôi!


3. Khởi tạo dự án

Chúng ta sẽ khởi tạo dự án với Spring Boot.

Đầu tiên, các bạn cần truy cập vào trang web Spring Initializr, nhập các thông tin cần thiết của dự án:

MyContact initialization

Đây là cây thư mục của dự án sau khi khởi tạo xong:

MyContact source tree

File pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.19.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>org.ashina</groupId>
    <artifactId>mycontact</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>mycontact</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

Cấu hình chung của dự án được gói gọn trong một file MycontactApplication.java:

package org.ashina.mycontact;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MycontactApplication {

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

}

Đơn giản hơn rất nhiều so với cách cấu hình thông thường. Cho ta cảm tưởng như đang viết hàm của một ứng dụng console vậy :D


4. View

4.1. Spring Boot và Thymeleaf

Nếu chỉ sử dụng Spring MVC trong dự án thì chúng ta cần phải cấu hình Thymeleaf thì mới có thể sử dụng được template engine này. Nhưng với Spring Boot, bởi vì chúng ta đã thêm dependency spring-boot-starter-thymeleaf ở trong file pom.xml nên Spring Boot sẽ tự động cấu hình Thymeleaf.

Mặc định, Spring Boot sẽ đọc các file template từ thư mục templates trong src/main/resources. Nên chúng ta sẽ bỏ các file HTML vào thư mục này.

4.2. Quản lý static resources

Mặc định, Spring Boot sẽ đọc các static resource (CSS, JS, ảnh) đọc từ thư mục static trong src/main/resources. Để tiện quản lý:

  • Các file CSS được đặt trong static/css
  • Các file JS được đặt trong static/js
  • Các file ảnh được đặt trong static/images

Ví dụ, thẻ <link> của một file style.css là:

<link href="../static/css/style.css"
      th:href="@{/css/style.css}" rel="stylesheet" />

Trong đó:

  • Thuộc tính href là của HTML5, cung cấp đường dẫn tới file style.css cho trình duyệt nếu như server chưa chạy. Cho nên chúng ta hoàn toàn có thể mở file HTML để xem dù chưa run server.
  • Thuộc tính th:href là của Thymeleaf, cung cấp đường dẫn tới file style.css cho trình duyệt khi server đã chạy. @{} mà một biểu thức SPeL xác định đường dẫn.

Tương tự, thẻ <script> của một file script.js là:

<script src="../static/js/script.js"
        th:src="@{/js/script.js}"></script>

4.3. Xây dựng layout

Chúng ta có thể thấy cả 2 file list.htmlform.html đều có 3 phần chung là head (thẻ <head>), header (thẻ <nav>) và footer (thẻ <footer> và các thẻ <script>) nên chúng ta sẽ tách 3 phần chung này gộp vào 1 file layout layout.html đặt trong thư mục templates và đánh dấu mỗi phần bằng một thuộc tính th:fragment riêng:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head">
    <!-- Required meta tags -->
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />

    <title>MyContact App</title>
    <link href="../static/images/logo.png" th:href="@{/images/logo.png}" rel="shortcut icon" />

    <!-- Bootstrap CSS -->
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous" />
    <!-- Font Awesome -->
    <link href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous" />
    <!-- Custom style -->
    <link href="../static/css/style.css" th:href="@{/css/style.css}" rel="stylesheet" />

    <!-- Optional JavaScript -->
    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
</head>

<body>
    <nav th:fragment="header" class="navbar navbar-dark bg-dark">
        <a class="navbar-brand" href="#">MyContact App</a>
    </nav>

    <h1>Main content</h1>

    <footer th:fragment="footer" class="container">
        <strong>&copy; 2018 Ashina</strong>
    </footer>

</body>
</html>

Lúc này ở 2 file list.htmlform.html, mình sẽ thay thế các phần head, header và footer như sau:

<head th:replace="layout :: head"></head>
<nav th:replace="layout :: header"></nav>
<footer th:replace="layout :: footer"></footer>

Trong đó:

  • layout là tham chiếu tới file layout.html.
  • head, headerfooter sau :: là các fragment selector, chính là giá trị của thuộc tính th:fragment của các thẻ head, navfooter ở file layout.html.

File list.html lúc này:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="layout :: head"></head>

<body>
    <nav th:replace="layout :: header"></nav>

    <div class="container" id="main-content">
        <div class="row">
            <div class="col-6 no-padding">
                <form class="form-inline">
                    <input class="form-control mr-sm-2" type="search" placeholder="Search by name ..." />
                    <button class="btn btn-primary" type="submit">
                        <i class="fa fa-search"></i> Search
                    </button>
                </form>
            </div>

            <div class="col-6 no-padding">
                <a href="#" class="btn btn-success float-right">
                    <i class="fa fa-plus-square"></i> New contact
                </a>
            </div>
        </div>

        <div class="row mt-4">
            <div class="table-responsive">
                <h5>List of contacts</h5>
                <table class="table table-bordered table-hover">
                    <thead>
                    <tr>
                        <th>#</th>
                        <th>Name</th>
                        <th>Email</th>
                        <th>Phone</th>
                        <th>Action</th>
                    </tr>
                    </thead>
                    <tbody>
                    <tr>
                        <th scope="row">1</th>
                        <td>Ashina Yuen</td>
                        <td>ashinayuen@gmail.com</td>
                        <td>0123456789</td>
                        <td>
                            <a href="#" class="mr-sm-2 text-primary"><i class="fa fa-pencil"></i></a>
                            <a href="#" class="text-danger"><i class="fa fa-trash"></i></a>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row">2</th>
                        <td>Ashina Yuen</td>
                        <td>ashinayuen@gmail.com</td>
                        <td>0123456789</td>
                        <td>
                            <a href="#" class="mr-sm-2 text-primary"><i class="fa fa-pencil"></i></a>
                            <a href="#" class="text-danger"><i class="fa fa-trash"></i></a>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row">3</th>
                        <td>Ashina Yuen</td>
                        <td>ashinayuen@gmail.com</td>
                        <td>0123456789</td>
                        <td>
                            <a href="#" class="mr-sm-2 text-primary"><i class="fa fa-pencil"></i></a>
                            <a href="#" class="text-danger"><i class="fa fa-trash"></i></a>
                        </td>
                    </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div><!-- /.container -->

    <footer th:replace="layout :: footer"></footer>

</body>
</html>

File form.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="layout :: head"></head>

<body>
    <nav th:replace="layout :: header"></nav>

    <div class="container" id="main-content">
        <div class="row d-flex justify-content-center">
            <form style="min-width: 300px;">
                <h5 class="text-center">Contact form</h5>

                <div class="form-group">
                    <input class="form-control" type="text" placeholder="Contact name" />
                </div>

                <div class="form-group">
                    <input class="form-control" type="email" placeholder="Contact email" />
                </div>

                <div class="form-group">
                    <input class="form-control" type="text" placeholder="Contact phone" />
                </div>

                <div class="form-group">
                    <button type="submit" class="btn btn-block btn-primary">
                        <i class="fa fa-save"></i> Save
                    </button>
                </div>
            </form>
        </div>
    </div><!-- /.container -->

    <footer th:replace="layout :: footer"></footer>
</body>
</html>

5. Entity và repository

Trước khi bắt tay vào code các thành phần ở persistence layer, mình sẽ nói qua các khái niệm sau:

  • JPA (viết tắt của Java Persistence API) là một công nghệ cho phép chúng ta mapping các POJO với các bảng CSDL quan hệ và ngược lại. Các bạn cần nhớ rằng JPA chỉ là 1 API (Application Programming Interface). Vì đã là interface thì chúng ta cần phải có implementation để implement interface đó.
  • Hibernate là một trong những implementation phổ biến nhất của JPA.
  • Spring Data JPA là một dự án của Spring Data. Spring Data JPA giúp chúng ta tiết kiệm thời gian trong quá trình tương tác với các CSDL quan hệ, đồng thời cũng viết ít code hơn. Spring Data JPA implement Repository Pattern và tích hợp sẵn Hibernate.

5.1. Cấu hình datasource, JPA, Hibernate

Các cấu hình liên quan đến datasource, JPA, Hibernate sẽ được viết trong file application.properties của java/main/resources. Các bạn có thể tham khảo cấu hình sau:

# ===============================
# PERSISTENCE
# ===============================

# General Configuration
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect

# Database connection properties
spring.datasource.url=jdbc:mysql://localhost:3306/mycontact?useUnicode=yes&characterEncoding=UTF-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=root

# Statement logging and statistics
spring.jpa.show-sql=true
#spring.jpa.properties.hibernate.format_sql=true

# Automatic schema generation
spring.jpa.hibernate.ddl-auto=none

5.2. Entity

Đây là các POJO ánh xạ từ các bảng ở CSDL. Mình sẽ tạo 1 lớp Contact.java trong package org.ashina.mycontact.entity:

package org.ashina.mycontact.entity;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "contact")
public class Contact {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id", nullable = false)
    private Integer id;

    @Column(name = "name", nullable = false)
    private String name;

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

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

    public Contact() {
    }

    public Contact(String name, String email, String phone) {
        this.name = name;
        this.email = email;
        this.phone = phone;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }
}

Với các lớp entity, các bạn chú ý phải có các phương thức getter/setter cho các thuộc tính để sau này chúng ta có thể truy xuất các thuộc tính trong view.

Các annotation mình sử dụng trong đoạn code trên là các annotation của JPA:

  • @Entity xác định lớp hiện tại là một entity.
  • @Table xác định tên bảng CSDL.
  • @Id xác định thuộc tính hiện tại là ID của bảng CSDL.
  • @GeneratedValue xác định cơ chế sinh khóa chính, ở đây là AUTO_INCREMENT.
  • @Column xác định thuộc tính hiện tại là một cột của bảng CSDL.

5.3. Repository

Repository là các interface giúp chúng ta thao tác với các bảng CSDL. Mỗi interface sẽ làm việc với một entity.

Trong Spring Data JPA có một interface khá thú vị có tên là CrudRepository<T, ID>. Interface này cung cấp các thao tác CRUD tổng quát. Repository của chúng ta chỉ cần kế thừa CrudRepository để dùng lại mà thôi.

Do CrudRepository mới chỉ cung cấp các thao tác CRUD tổng quát, nên nếu chúng ta muốn tìm kiếm các liên hệ theo một query nào đó thì chúng ta phải sử dụng đến Query builder. Cơ chế này sẽ dựa theo tên của phương thức trong repository để xây dựng câu truy vấn.

OK, bây giờ mình sẽ viết một interface ContactRepository trong package org.ashina.mycontact.repository:

package org.ashina.mycontact.repository;

import org.ashina.mycontact.entity.Contact;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ContactRepository extends CrudRepository<Contact, Integer> {

    List<Contact> findByNameContaining(String term);

}

Trong đó:

  • @Repository xác định interface hiện tại là một repository. Về bản chất, chúng ta đang định nghĩa một bean có tên là contactRepository.
  • <Contact, Integer> chỉ ra kiểu dữ liệu của entity và entity ID.
  • findByNameContaining(String term) tương ứng với query SELECT * FROM contact WHERE name LIKE %term%.

6. Service

Chúng ta sẽ viết các xử lý business logic trong các lớp service. Vì là một ứng dụng để các bạn làm quen với Spring nên business logic khá đơn giản.

Đầu tiên, chúng ta cần xây dựng một interface có tên ContactService trong package org.ashina.service:

package org.ashina.mycontact.service;

import org.ashina.mycontact.entity.Contact;

import java.util.List;

public interface ContactService {

    Iterable<Contact> findAll();

    List<Contact> search(String term);

    Contact findOne(Integer id);

    void save(Contact contact);

    void delete(Integer id);

}

Trong package org.ashina.mycontact.service, mình sẽ viết một lớp ContactServiceImpl.java implement ContactService:

package org.ashina.mycontact.service;

import org.ashina.mycontact.entity.Contact;
import org.ashina.mycontact.repository.ContactRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ContactServiceImpl implements ContactService {

    @Autowired
    private ContactRepository contactRepository;

    @Override
    public Iterable<Contact> findAll() {
        return contactRepository.findAll();
    }

    @Override
    public List<Contact> search(String term) {
        return contactRepository.findByNameContaining(term);
    }

    @Override
    public Contact findOne(Integer id) {
        return contactRepository.findOne(id);
    }

    @Override
    public void save(Contact contact) {
        contactRepository.save(contact);
    }

    @Override
    public void delete(Integer id) {
        contactRepository.delete(id);
    }
}

Trong đó:

  • Annotation @Service xác định lớp hiện tại là một service. Về bản chất, chúng ta đang định nghĩa một bean có tên là contactService.
  • Annotation @Autowired dùng để inject bean contactRepository vào trong bean contactService.

7. Xử lý các use case

7.1. Hiển thị danh sách liên hệ

Controller

Trước tiên, chúng ta tạo lớp ContactController.java trong package org.ashina.mycontact.controller:

package org.ashina.mycontact.controller;

import org.ashina.mycontact.service.ContactService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class ContactController {

    @Autowired
    private ContactService contactService;

    @GetMapping("/contact")
    public String list(Model model) {
        model.addAttribute("contacts", contactService.findAll());
        return "list";
    }

}

Trong đó:

  • Annotation @Controller xác định lớp hiện tại là một controller. Về bản chất, chúng ta đang định nghĩa một bean có tên là contactController.
  • Annotation @GetMapping xác định phương thức list() sẽ đón nhận các HTTP request có HTTP method là GET và URI pattern là /contact.
  • Annotation @Autowired inject bean contactService vào trong bean contactController.
  • Phương thức list():
    • Tham số Model có nhiệm vụ vận chuyển dữ liệu từ controller đổ ra view. Ở đây, danh sách liên hệ được lấy ra thông qua phương thức contactService.findAll(). Sau đó được gắn vào Model bằng phương thức addAttribute(). Giá trị contacts chính là đối tượng đại diện cho danh sách liên hệ để chúng ta dùng ở view sau này.
    • Trả về một string, từ string này Spring sẽ tìm kiếm view tương ứng: return "list"; => view là list.html

View

Trước khi đổ danh sách liên hệ ra bảng, chúng ta sẽ kiểm tra contacts có rỗng không:

<th:block th:if="${#lists.isEmpty(contacts)}">
    <h5>No contacts</h5>
</th:block>

Trong đó:

  • #list là một đối tượng tiện ích trong Thymeleaf để xử lý các dữ liệu kiểu danh sách. Phương thức isEmpty() trả về true nếu contacts rỗng và ngược lại.
  • Thuộc tính th:if tương ứng với câu lệnh if => trái ngược với th:ifth:unless.
  • Chúng ta sẽ bao đóng toàn bộ đoạn code này bằng thẻ th:block. Nếu như điều kiện trong th:if không thỏa mãn thì toàn bộ nội dung của th:block sẽ biến mất trong source file HTML.

Còn trong trường hợp contacts không rỗng, chúng ta sẽ đổ danh sách liên hệ ra bảng như sau:

<th:block th:unless="${#lists.isEmpty(contacts)}">
    <div class="table-responsive">
        <h5>List of contacts</h5>
        <table class="table table-bordered table-hover">
            <thead>
            <tr>
                <th>#</th>
                <th>Name</th>
                <th>Email</th>
                <th>Phone</th>
                <th>Action</th>
            </tr>
            </thead>
            <tbody>
            <tr th:each="contact,iterStat : ${contacts}">
                <th scope="row" th:text="${iterStat.count}"></th>
                <td th:text="${contact.name}"></td>
                <td th:text="${contact.email}"></td>
                <td th:text="${contact.phone}"></td>
                <td>
                    <a href="#" class="mr-sm-2 text-primary"><i class="fa fa-pencil"></i></a>
                    <a href="#" class="text-danger"><i class="fa fa-trash"></i></a>
                </td>
            </tr>
            </tbody>
        </table>
    </div>
</th:block>

Trong đó:

  • Thuộc tính th:each tương ứng với câu lệnh foreach.
  • Thuộc tính th:text dùng để đổ dữ liệu dưới dạng text vào thẻ HTML.
  • ${contacts} là một iterated variable, chính là contacts truyền từ controller.
  • contact là một iteration variable, là một item trong contacts.
  • iterStat là một status variable, giúp chúng ta theo dõi vòng lặp. Thuộc tính count của iterStat là chỉ số hiện tại của vòng lặp (bắt đầu từ 1).

File list.html cho use case này:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="layout :: head"></head>

<body>
    <nav th:replace="layout :: header"></nav>

    <div class="container" id="main-content">
        <div class="row">
            <div class="col-6 no-padding">
                <form class="form-inline">
                    <input class="form-control mr-sm-2" type="search" placeholder="Search by name ..." />
                    <button class="btn btn-primary" type="submit">
                        <i class="fa fa-search"></i> Search
                    </button>
                </form>
            </div>

            <div class="col-6 no-padding">
                <a href="#" class="btn btn-success float-right">
                    <i class="fa fa-plus-square"></i> New contact
                </a>
            </div>
        </div>

        <div class="row mt-4">
            <th:block th:if="${#lists.isEmpty(contacts)}">
                <h5>No contacts</h5>
            </th:block>

            <th:block th:unless="${#lists.isEmpty(contacts)}">
                <div class="table-responsive">
                    <h5>List of contacts</h5>
                    <table class="table table-bordered table-hover">
                        <thead>
                        <tr>
                            <th>#</th>
                            <th>Name</th>
                            <th>Email</th>
                            <th>Phone</th>
                            <th>Action</th>
                        </tr>
                        </thead>
                        <tbody>
                        <tr th:each="contact,iterStat : ${contacts}">
                            <th scope="row" th:text="${iterStat.count}"></th>
                            <td th:text="${contact.name}"></td>
                            <td th:text="${contact.email}"></td>
                            <td th:text="${contact.phone}"></td>
                            <td>
                                <a href="#" class="mr-sm-2 text-primary"><i class="fa fa-pencil"></i></a>
                                <a href="#" class="text-danger"><i class="fa fa-trash"></i></a>
                            </td>
                        </tr>
                        </tbody>
                    </table>
                </div>
            </th:block>
        </div>
    </div><!-- /.container -->

    <footer th:replace="layout :: footer"></footer>

</body>
</html>

7.2. Tìm kiếm liên hệ theo tên

View

Chúng ta sửa lại form tìm kiếm như sau:

<form class="form-inline" action="#" th:action="@{/contact/search}" method="get">
    <input class="form-control mr-sm-2" type="search" name="term" placeholder="Search by name ..." />
    <button class="btn btn-primary" type="submit">
        <i class="fa fa-search"></i> Search
    </button>
</form>

Controller

Trong ContactController.java, chúng ta viết thêm phương thức search() để xử lý hành động submit form:

import org.springframework.util.StringUtils;

@GetMapping("/contact/search")
public String search(@RequestParam("term") String term, Model model) {
    if (StringUtils.isEmpty(term)) {
        return "redirect:/contact";
    }

    model.addAttribute("contacts", contactService.search(term));
    return "list";
}
  • Do request có HTTP method là GET nên chúng ta sử dụng @GetMapping. Giá trị "/contact/search" chính là giá trị của thuộc tính action của form tìm kiếm.
  • Cũng do form tìm kiếm có method là GET nên giá trị của các input sẽ hiển thị dưới dạng tham số trên URL của request. Để lấy các tham số trên URL, chúng ta sử dụng annotation @RequestParam. Giá trị trong @RequestParam chính là giá trị của thuộc tính name của input.
  • Nếu như từ khóa tìm kiếm bị rỗng, chúng ta sẽ redirect luôn về màn hình danh sách liên hệ (do phương thức list() xử lý). Còn không thì sẽ tìm kiếm các liên hệ bằng phương thức search() của ContactService. View được sử dụng ở đây vẫn là list.html.

7.3. Thêm liên hệ mới

Hiển thị form

Trước hết ta cần phải sửa lại đường dẫn cho button New contactlist.html để điều hướng tới form.html:

<a href="#" th:href="@{/contact/add}" class="btn btn-success float-right">
    <i class="fa fa-plus-square"></i> New contact
</a>

ContactController, ta viết thêm 1 phương thức add() để hiển thị form liên hệ:

@GetMapping("/contact/add")
public String add(Model model) {
    model.addAttribute("contact", new Contact());
    return "form";
}

Trong đó:

  • Mình sẽ truyền sang form.html một đối tượng Contact có tên là contact. Mỗi thuộc tính của contact tương ứng với một input trong form.

Thẻ <form> trong form.html được viết lại như sau:

<form style="min-width: 300px;" action="#" th:action="@{/contact/save}" th:object="${contact}" method="post" novalidate="novalidate">
    <h5 class="text-center">Contact form</h5>

    <input type="hidden" th:field="*{id}" />

    <div class="form-group">
        <input class="form-control" type="text" placeholder="Contact name"
               th:field="*{name}" th:errorclass="field-error" />
        <em th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></em>
    </div>

    <div class="form-group">
        <input class="form-control" type="email" placeholder="Contact email"
               th:field="*{email}" th:errorclass="field-error" />
        <em th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></em>
    </div>

    <div class="form-group">
        <input class="form-control" type="text" placeholder="Contact phone"
               th:field="*{phone}" />
    </div>

    <div class="form-group">
        <button type="submit" class="btn btn-block btn-primary">
            <i class="fa fa-save"></i> Save
        </button>
    </div>
</form>

Trong đó:

  • Thuộc tính th:action chỉ ra URL ở controller sẽ xử lý hành động submit form.
  • ${contact}th:object chính là đối tượng contact mà phương thức add() đã truyền sang form.html.
  • Do chúng ta không sử dụng HTML5 validation, mà sử dụng Spring validation nên mình sẽ để novalidate="novalidate".
  • Như mình đã nói ở trên, mỗi thuộc tính của contact tương ứng với một input trong form. Nên mình sẽ thêm thuộc tính th:field=*{fieldName} vào các input. Do khi submit form, chúng ta cần phải chỉ cho Hibernate biết entity nào được gửi lên, nên mình phải thêm <input type="hidden" th:field="*{id}" /> để chỉ ra ID của entity.
  • <em th:if="${#fields.hasErrors('fieldName')}" th:errors="*{fieldName}"></em> dùng để hiển thị validation message nếu field đó invalid. Về validation messages, mình sẽ giải thích ngay trong phần dưới đây.

Các bạn có thể tham khảo thêm cách viết form tại đây

Cấu hình validation

Trong use case này, mình sẽ yêu cầu người dùng:

  1. Không được bỏ trống tên liên hệ
  2. Email phải đúng định dạng email

Đầu tiên ở class Contact.java, mình sẽ sử dụng 2 validator constraint @NotEmpty@NotEmail của Hibernate để xác định field nào cần validate và validate cái gì:

@NotEmpty
@Column(name = "name", nullable = false)
private String name;

@Email
@Column(name = "email")
private String email;

Chúng ta có 2 cách để khai báo validation message:

  • Khai báo ngay tại thuộc tính message của validator constraint
  • Khai báo trong một file properties theo quy tắc: validatorConstraint.entityName.fieldName=validationMessage

Trong tut này, mình sẽ hướng dẫn các bạn sử dụng cách 2. Cách này tuy lằng nhằng hơn cách 1 nhưng sẽ giúp chúng ta quản lý các message dễ hơn:

Đầu tiên ta tạo 1 file messages.properties ngay dưới thư mục src/main/resources:

NotEmpty.contact.name=Vui lòng nhập tên cho liên hệ
Email.contact.email=Vui lòng nhập đúng định dạng email

Chưa xong, chúng ta cần phải thêm một chút cấu hình để Spring MVC có thể load được file properties này. Ta tạo class WebMvcConfig.java trong package org.ashina.mycontact.config. Sau đó định nghĩa một bean MessageSource:

package org.ashina.mycontact.config;

import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@Configuration
public class WebMvcConfiguration extends WebMvcConfigurerAdapter {

    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
        messageSource.addBasenames("classpath:messages");
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }

}

Trong đó:

  • classpath:messages chỉ ra đường dẫn tới file mesages.properties (tính từ thư mục src/main/resources).
  • Do file properties của chúng ta có chứa tiếng Việt, nên cần setDefaultEncoding("UTF-8").

Xử lý submit form

Bước này gồm 2 công đoạn là validation input và lưu liên hệ vào CSDL. Trong ContactController.java, chúng ta tạo phương thức save():

@PostMapping("/contact/save")
public String save(@Valid Contact contact, BindingResult result, RedirectAttributes redirect) {
    if (result.hasErrors()) {
        return "form";
    }
    contactService.save(contact);
    redirect.addFlashAttribute("successMessage", "Saved contact successfully!");
    return "redirect:/contact";
}
  • Do request có HTTP method là POST nên mình sẽ sử dụng @PostMapping.
  • Đối tượng contact được truyền vào save() chính là đối tượng contact mà mình đã truyền từ add() sang form. Đối tượng này sẽ lưu thông tin mà người dùng nhập vào.
  • Để enable cơ chế validation cho contact, mình sẽ sử dụng annotation @Valid. Các validation message bắn ra sẽ được đối tượng result bắt.
    • Nếu như có lỗi thì mình sẽ quay trở lại form.html để thông báo lỗi đó.
    • Còn không thì sẽ lưu contact. Sau khi lưu, sẽ redirect về màn hình danh sách liên hệ. Chuỗi đằng sau "redirect:" là đường dẫn của trang mà mình muốn redirect. Đồng thời mình cũng gửi một flash message về màn hình danh sách liên hệ để thông báo lưu thành công, bằng cách sử dụng: redirect.addFlashAttribute(messageName, messageContent)

Ở file list.html, bạn thêm đoạn code sau để hiển thị flash message:

<div class="row mt-4">
    <div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
        <span th:text="${successMessage}"></span>
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
            <span aria-hidden="true">&times;</span>
        </button>
    </div>
    ...
</div>

7.4. Sửa liên hệ

View

Trong file list.html, chúng ta cần phải sửa lại đường dẫn của button Edit để điều hướng tới màn hình sửa liên hệ:

<a href="#" th:href="@{/contact/{id}/edit(id=${contact.id})}" class="mr-sm-2 text-primary">
    <i class="fa fa-pencil"></i>
</a>

Các bạn có thể thao khảo thêm cách viết đường dẫn trong Thymleaf tại đây

Controller

Do use case này cũng khá giống với thêm liên hệ mới, nên ta chỉ cần viết thêm một phương thức edit() để hiển thị form sửa liên hệ mà thôi:

@GetMapping("/contact/{id}/edit")
public String edit(@PathVariable("id") Integer id, Model model) {
    model.addAttribute("contact", contactService.findOne(id));
    return "form";
}

Tham số @PathVariable("id") Integer id chính là tham số {id} của URI pattern. Từ tham số này, chúng ta có thể lấy ra được một bản ghi contact tương ứng, rồi đổ thông tin của bản ghi này ra form.html.

7.5. Xóa liên hệ

View

Trong list.html, chúng ta cần phải sửa lại đường dẫn của button Delete:

<a href="#" th:href="@{/contact/{id}/delete(id=${contact.id})}" class="text-danger">
    <i class="fa fa-trash"></i>
</a>

Controller

Tạo phương thức delete() trong ContactController.java:

@GetMapping("/contact/{id}/delete")
public String delete(@PathVariable int id, RedirectAttributes redirect) {
    contactService.delete(id);
    redirect.addFlashAttribute("successMessage", "Deleted contact successfully!");
    return "redirect:/contact";
}

Sau khi xóa bản ghi trong CSDL, chúng ta cũng gửi một flash message về màn hình danh sách liên hệ để thông báo xóa thành công.


8. Build và chạy ứng dụng

8.1. Build ứng dụng

Trong thư mục của source code, chúng ta chạy lệnh sau để build ứng dụng:

$ mvn clean install

Sau khi build thành công, chúng ta thử vào thư mục target của source code, sẽ thấy file mycontact-0.0.1-SNAPSHOT.jar được sinh ra. Toàn bộ ứng dụng sẽ được gói gọn trong file jar này (đã bao gồm một embedded Tomcat server).

8.2. Chạy ứng dụng

Trong thư mục target, chạy file mycontact-0.0.1-SNAPSHOT.jar bằng lệnh:

$ java -jar mycontact-0.0.1-SNAPSHOT.jar

Nếu như các bạn thấy xuất hiện những dòng này:

...
2019-01-28 20:47:35.423  INFO 7620 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2019-01-28 20:47:35.436  INFO 7620 --- [           main] o.ashina.mycontact.MycontactApplication  : Started MycontactApplication in 24.076 seconds (JVM running for 25.586)

thì chứng tỏ chúng ta đã build và chạy ứng dụng thành công rồi nhé!

Lúc này ứng dụng của chúng ta sẽ chạy trên cổng 8080: http://localhost:8080/contact

Bình luận


White
{{ comment.user.name }}
Bỏ hay Hay
{{comment.like_count}}
Male avatar
{{ comment_error }}
Hủy
   

Hiển thị thử

Chỉnh sửa

White

Nguyễn Tuấn Anh

24 bài viết.
151 người follow
Kipalog
{{userFollowed ? 'Following' : 'Follow'}}
Cùng một tác giả
White
27 14
Hướng dẫn lập trình Spring Security Trong bài viết lần này, mình sẽ giúp các bạn bước đầu tìm hiểu (Link) thông qua xây dựng các chức năng: Đăng ...
Nguyễn Tuấn Anh viết hơn 2 năm trước
27 14
White
15 1
Giới thiệu Spring Framework Trong bài viết này, mình sẽ giới thiệu cho các bạn về một trong những Java EE framework rất nổi bật và phổ biến hiện n...
Nguyễn Tuấn Anh viết hơn 2 năm trước
15 1
White
14 1
Trong bài viết này, mình sẽ giúp các bạn tìm hiểu nhanh SOLID thần thánh nhé :D SOLID là năm nguyên lý cơ bản trong thiết kế phần mềm hướng đối tư...
Nguyễn Tuấn Anh viết 10 tháng trước
14 1
Bài viết liên quan
White
2 0
I used Spring boot, Hibernate few times back then at University, I'v started using it again recently. In this (Link), I want to check how Spring J...
Rey viết 7 tháng trước
2 0
{{like_count}}

kipalog

{{ comment_count }}

bình luận

{{liked ? "Đã kipalog" : "Kipalog"}}


White
{{userFollowed ? 'Following' : 'Follow'}}
24 bài viết.
151 người follow

 Đầu mục bài viết

Vẫn còn nữa! x

Kipalog vẫn còn rất nhiều bài viết hay và chủ đề thú vị chờ bạn khám phá!