Cấu trúc Project Spring Boot sẽ như thế nào ?
Dạo gần đây mình đang quay lại Java và mình đang tìm hiều các framework liên quan đến Java (Trước mình làm việc với Scala). Spring boot là framework mà mình chọn để tìm hiểu. Sau đây mình cũng chia sẽ những cái mà mình tìm hiểu được và build 1 project nhỏ demo (phần restfull api).
Giới thiệu
Project của chúng ta cũng đơn giản: CRUD users. (phần này chưa có authen nhé các bạn) Trong project này mình sẽ đi 1 số nội dung như sau :
- Cấu trúc một project
- Từng bước build 1 project
Nội Dung
1) Cấu trúc một project
- Config là nơi mình muốn cấu hình 1 vài thứ trong project
- controller là nơi nhận các request từ client gửi lên.
- Filter: Trong controller có filter là nơi mình để các lớp, phương thức kiểm tra dữ liệu khi vào controller, hoặc sau có thể kiểm tra role, permission của user.
- Request là các object được client gửi lên Server. Mình không dùng chung object với các object của DB, vì nó khác và mình nên tách biệt các object giữa các layer khác nhau (object của reuqest != object của service != object lưu xuống DB)
- Response là các object sẽ được trả về client.
- Các lớp Controller: ngoài ra trong Package Controller sẽ có các lớp contrller, để nhận các request từ client
- exception là nơi tập trung các exception trong project
- model là nơi define các Entity của DB
- repository là nơi kết nối xuống DB, chỉ thực hiện các chức năng put và get dữ liệu, ko thực hiện các business trong này.
- service là nơi thực hiện các business
- utils là các phương thức hay được dùng đi dùng lại nhiều nơi ...
- resources nơi chứa các file *.properties
- test là nơi thực hiện các unit test
2) Từng bước build project
B0: Quy định về data
Data lúc gửi lên và trả về là dang Json với format là snake_case. Ví dụ:
{"error":"not_found","error_msg":"User not found on :: 1"}
Cấu trúc data trả về khi bị error như sau
{"error": "này là mã lỗi",
"error_msg": "mô tả chi tiết về lỗi trả về."}
B1: Pom
- Đầu tiên thì trong file pom của mình sẽ có các thư viện cần thiết như sau:
<dependencies>
<!-- https://mvnrepository.com/artifact/org.hibernate/hibernate-core -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>5.6.9.Final</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.hibernate/hibernate-entitymanager -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<version>5.6.9.Final</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.16.1</version>
</dependency>
<!-- TypeSafe -->
<dependency>
<groupId>com.typesafe</groupId>
<artifactId>config</artifactId>
<version>1.3.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</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>
Trong file pom mình có thêm 1 plugin để nó tự động tạo database
<plugin>
<groupId>io.fabric8</groupId>
<artifactId>docker-maven-plugin</artifactId>
<version>0.26.0</version>
<configuration>
<images>
<image>
<alias>mysql8_test</alias>
<name>mysql:8.0</name>
<run>
<wait>
<log>mysqld: ready for connections</log>
<time>120000</time>
</wait>
<env>
<MYSQL_ROOT_PASSWORD>123456</MYSQL_ROOT_PASSWORD>
<MYSQL_DATABASE>users-database</MYSQL_DATABASE>
<MYSQL_USER>user</MYSQL_USER>
<MYSQL_PASSWORD>123456</MYSQL_PASSWORD>
</env>
<ports>
<port>2216:3306</port>
</ports>
</run>
</image>
</images>
</configuration>
<executions>
<execution>
<id>start</id>
<!--<phase>generate-test-resources</phase>-->
<phase>process-test-classes</phase>
<!--<phase>test</phase>-->
<goals>
<goal>start</goal>
</goals>
</execution>
<execution>
<id>stop</id>
<phase>test</phase>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>
</plugin>
B2: Config
- Tiếp theo mình sẽ config cho project của mình chạy với 3 môi trường khác nhau: dev, staging, prod. Tương ứng 3 mỗi trường thì sẽ có 3 file *.properties khác nhau. Nội dung như sau File application.properties có nội dung sau:
// cho biết project đang chạy môi trường gì
spring.profiles.active=dev
File application-dev.properties có nội dung sau (dùng cho môi trường dev):
# Database Properties
spring.datasource.url = jdbc:mysql://localhost:2216/users-database?allowPublicKeyRetrieval=true&useSSL=false
spring.datasource.username = root
spring.datasource.password = 123456
## Hibernate Properties
# The SQL dialect makes Hibernate generate better SQL for the chosen database
spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5InnoDBDialect
# Hibernate ddl auto (create, create-drop, validate, update)
spring.jpa.hibernate.ddl-auto = update
server.port=8050
spring.profiles.active=dev
Các file khác staging và prod tương tự
B3: Entity
- Đầu tiên mình sẽ định nghĩa lớp User trong package Model. Đây là entity của DB
package vn.spring.tutorial.model;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.util.Date;
@Entity
@Table(name = "users")
@EntityListeners(AuditingEntityListener.class)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
@Column(name = "first_name", nullable = false)
private String firstName;
@Column(name = "last_name", nullable = false)
private String lastName;
@Column(name = "email_address", nullable = false)
private String email;
@Column(name = "created_at", nullable = false)
private Long createdAt;
@Column(name = "created_by", nullable = false)
@CreatedBy
private String createdBy;
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "updated_by", nullable = false)
@LastModifiedBy
private String updatedBy;
}
Field mình để kiêu long, auto gen để đỡ phức tạp, nhưng khuyên khích là không nên để loại auto gen, mà mình nên tự gen id, để hệ thống đễ bị crawl do id có thể đoán được... Filed created_at updated_at mình kêu kiểu long, và tất cả các loại time nên để kiểu long, vì khi move sang một DB khác thì sẽ dễ tương thích hơn.
B4: Repository
- Tiếp đến mình sẽ có 1 UserRepository để truy xuất dự liệu của DB
package vn.spring.tutorial.repository;
import vn.spring.tutorial.model.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findAll(Pageable pageable);
}
Ở đây mình cần chức năng update từng field một, nghĩa là field nào = null thì tự động ko update, nhưng chỗ JPA này không làm được, lúc nào cũng phải update hết các field xuống, lấy cũ merge với cái mới và update toàn bộ xuống. Mình có google thì họ có chỉ cho là phải viết hàm riêng cho từng field mình muôn set xuống như sau (ví dụ từ google )
@Modifying
@Query("update EARAttachment ear set ear.status = ?1 where ear.id = ?2")
int setStatusForEARAttachment(Integer status, Long id);
Chỗ này mình thấy khá bất tiện, vì nó không dynamic được. Chỗ này mình có thể sẽ ko dùng JPA mà viết một lib riêng để access xuống DB. Nhưng trong project này, mình vẫn sẽ dùng JPA để làm ví dụ
B5: Service
Tiếp đến mình sẽ có 1 service dành cho user, xử lý những business liên quan đến user
package vn.spring.tutorial.service;
import org.springframework.data.domain.Page;
import vn.spring.tutorial.model.User;
import java.util.List;
import java.util.Optional;
public interface UserService {
Optional<User> findUser(Long id);
Page<User> findAll();
User save(User user);
User update(User user);
void delete(User user);
}
B6: Controller
Tiếp đến mình sẽ có file controller gồm các api: CRUD
package vn.spring.tutorial.controller;
import vn.spring.tutorial.controller.request.UserRequestForCreate;
import vn.spring.tutorial.controller.request.UserRequestForUpdate;
import vn.spring.tutorial.exception.NotFoundException;
import vn.spring.tutorial.model.User;
import vn.spring.tutorial.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/v1")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/users")
public User createUser(@Valid @RequestBody UserRequestForCreate userReq) {
System.out.println("print st");
User user = userService.save(userReq.toUser());
return user;
}
@GetMapping("/users")
public Page<User> getAllUsers() {
return userService.findAll();
}
@GetMapping("/users/{id}")
public ResponseEntity<User> getUsersById(@PathVariable(value = "id") Long userId)
throws NotFoundException {
User user = userService.findUser(userId).orElseThrow(() -> new NotFoundException("User not found on :: " + userId));
return ResponseEntity.ok().body(user);
}
@PutMapping("/users/{id}")
public ResponseEntity<User> updateUser(
@PathVariable(value = "id") Long userId, @Valid @RequestBody UserRequestForUpdate userDetails)
throws NotFoundException {
User user =
userService.findUser(userId)
.orElseThrow(() -> new NotFoundException("User not found on :: " + userId));
User request = user.merge(userDetails.toUser());
request.setUpdatedAt(System.currentTimeMillis());
final User updatedUser = userService.save(user.merge(request));
return ResponseEntity.ok(updatedUser);
}
@DeleteMapping("/user/{id}")
public Map<String, Boolean> deleteUser(@PathVariable(value = "id") Long userId) throws Exception {
User user =
userService.findUser(userId)
.orElseThrow(() -> new NotFoundException("User not found on :: " + userId));
userService.delete(user);
Map<String, Boolean> response = new HashMap<>();
response.put("deleted", Boolean.TRUE);
return response;
}
}
Để trả dữ liệu về dạng json snake_case, mình có thêm phần config cho chỗ này
package vn.spring.tutorial.config;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfiguration {
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizeObjectMapper() {
return builder -> builder.propertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
}
}
B7: Test case
Chỗ này mình dùng thư viện test của spring MockMvc có @Before, @After để khơi tạo và dọn dẹp sau mỗi lần chạy test case Ở phần befor thì mình sẽ xoá sạch dữ liệu liên quan đến lần trước chạy, và after mình sẽ ko làm gì. Trong after mình ko xoá data, mình muốn để lại data để xem kết quả như thế nào, đê debug ...
package vn.spring.tutorial;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.env.Environment;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import vn.common.utils.JsonHelper;
import vn.spring.tutorial.controller.request.UserRequestForCreate;
import vn.spring.tutorial.controller.request.UserRequestForUpdate;
import vn.spring.tutorial.repository.UserRepository;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
public class ApplicationTests2 {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private Environment environment;
@Before
public void setUp() {
String evn = environment.getProperty("spring.profiles.active");
if ("dev".equals(evn)) {
userRepository.deleteAll();
}
}
@After
public void tearDown() {
// Dọn dẹp trạng thái sau mỗi test case (nếu cần)
}
@Test
public void testCreateUser() throws Exception {
UserRequestForCreate user = new UserRequestForCreate();
user.setEmail("admin@gmail.com");
user.setFirstName("admin");
user.setLastName("admin");
String resp = mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonHelper.getInstance().toJson(user)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
System.out.println("resp: " + resp);
}
@Test
public void testGetUser() throws Exception {
String resp = mockMvc.perform(get("/api/v1/users/1"))
.andExpect(status().isNotFound())
.andReturn().getResponse().getContentAsString();
System.out.println("resp: " + resp);
}
public int createUser() throws Exception {
UserRequestForCreate user = new UserRequestForCreate();
user.setEmail("admin@gmail.com");
user.setFirstName("admin");
user.setLastName("admin");
String resp = mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonHelper.getInstance().toJson(user)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
System.out.println("resp: " + resp);
return JsonHelper.getInstance().readTree(resp).get("id").asInt();
}
@Test
public void testUpdatePost() throws Exception {
int id = createUser();
UserRequestForUpdate user = new UserRequestForUpdate();
user.setEmail("abc@gmail.com");
String resp = mockMvc.perform(put("/api/v1/users/" + id)
.contentType(MediaType.APPLICATION_JSON)
.content(JsonHelper.getInstance().toJson(user)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
System.out.println("resp: " + resp);
}
}
Kết bài
Mình chỉ mới tìm hiểu để build project với spring boot, cũng chưa hoàn thiện lắm, do mình chưa có phần xác thực. Phần tiếp theo mình sẽ làm thêm phần xác thực cho project, phân quyền các thứ .... Nếu bạn có muốn tham khảo code thì để lại email, mình sẽ gửi bài code tham khảo cho các bạn.
All rights reserved