# Spring Boot实战:从零构建一个RESTful API服务

Spring Boot实战:从零构建一个RESTful API服务

Spring Boot作为Java生态中最受欢迎的框架之一,以其”约定优于配置”的理念,极大地简化了Spring应用的初始搭建和开发过程。本文将带你深入实战,从零开始构建一个完整的RESTful API服务,涵盖核心概念、实际开发步骤和最佳实践。

一、环境准备与项目初始化

1.1 环境要求

  • JDK 11或更高版本
  • Maven 3.6+ 或 Gradle 6.8+
  • IDE(推荐IntelliJ IDEA或VS Code)

1.2 使用Spring Initializr快速创建项目

访问 start.spring.io,选择以下配置:

  • Project: Maven Project
  • Language: Java
  • Spring Boot: 3.1.0+
  • Packaging: Jar
  • Java: 17

添加依赖:

  • Spring Web
  • Spring Data JPA
  • H2 Database(开发环境)
  • Lombok(简化代码)
  • Validation

或者使用命令行创建:

1
2
3
curl https://start.spring.io/starter.zip -d dependencies=web,data-jpa,h2,lombok,validation \
-d type=maven-project -d language=java -d bootVersion=3.1.5 \
-d groupId=com.example -d artifactId=demo-api -o demo-api.zip

二、项目结构与核心配置

2.1 项目结构解析

1
2
3
4
5
6
7
8
9
src/main/java/com/example/demoapi/
├── DemoApiApplication.java # 主启动类
├── config/ # 配置类
├── controller/ # 控制器层
├── dto/ # 数据传输对象
├── entity/ # 实体类
├── repository/ # 数据访问层
├── service/ # 业务逻辑层
└── exception/ # 异常处理

2.2 配置文件详解

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
server:
port: 8080
servlet:
context-path: /api

spring:
application:
name: demo-api

datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:

h2:
console:
enabled: true
path: /h2-console

jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.H2Dialect

logging:
level:
com.example.demoapi: DEBUG
org.springframework.web: INFO

✨ 三、实体层设计与JPA映射

3.1 创建基础实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example.demoapi.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "products")
public class Product {

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

@Column(nullable = false, unique = true)
private String sku;

@Column(nullable = false)
private String name;

private String description;

@Column(nullable = false)
private BigDecimal price;

@Column(nullable = false)
private Integer stockQuantity;

@Enumerated(EnumType.STRING)
private ProductStatus status = ProductStatus.ACTIVE;

@CreationTimestamp
@Column(updatable = false)
private LocalDateTime createdAt;

@UpdateTimestamp
private LocalDateTime updatedAt;

public enum ProductStatus {
ACTIVE, INACTIVE, DISCONTINUED
}
}

3.2 实体关系映射示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Entity
@Table(name = "orders")
@Data
public class Order {

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

@Column(nullable = false)
private String orderNumber;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();

@Enumerated(EnumType.STRING)
private OrderStatus status;

private BigDecimal totalAmount;
}

@Entity
@Table(name = "order_items")
@Data
public class OrderItem {

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

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;

private Integer quantity;

private BigDecimal unitPrice;
}

四、数据访问层实现

4.1 基础Repository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.example.demoapi.repository;

import com.example.demoapi.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

@Repository
public interface ProductRepository extends JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {

Optional<Product> findBySku(String sku);

List<Product> findByStatus(Product.ProductStatus status);

@Query("SELECT p FROM Product p WHERE p.price BETWEEN :minPrice AND :maxPrice")
List<Product> findByPriceRange(@Param("minPrice") BigDecimal minPrice,
@Param("maxPrice") BigDecimal maxPrice);

@Query("SELECT p FROM Product p WHERE p.name LIKE %:keyword% OR p.description LIKE %:keyword%")
List<Product> searchByKeyword(@Param("keyword") String keyword);

boolean existsBySku(String sku);
}

4.2 自定义查询方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public interface ProductRepositoryCustom {
Page<Product> searchProducts(ProductSearchCriteria criteria, Pageable pageable);
}

@Repository
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepositoryCustom {

private final EntityManager entityManager;

@Override
public Page<Product> searchProducts(ProductSearchCriteria criteria, Pageable pageable) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> query = cb.createQuery(Product.class);
Root<Product> root = query.from(Product.class);

List<Predicate> predicates = new ArrayList<>();

if (StringUtils.hasText(criteria.getKeyword())) {
Predicate namePredicate = cb.like(root.get("name"), "%" + criteria.getKeyword() + "%");
Predicate descPredicate = cb.like(root.get("description"), "%" + criteria.getKeyword() + "%");
predicates.add(cb.or(namePredicate, descPredicate));
}

if (criteria.getMinPrice() != null) {
predicates.add(cb.ge(root.get("price"), criteria.getMinPrice()));
}

if (criteria.getMaxPrice() != null) {
predicates.add(cb.le(root.get("price"), criteria.getMaxPrice()));
}

if (criteria.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), criteria.getStatus()));
}

query.where(predicates.toArray(new Predicate[0]));

TypedQuery<Product> typedQuery = entityManager.createQuery(query);
typedQuery.setFirstResult((int) pageable.getOffset());
typedQuery.setMaxResults(pageable.getPageSize());

List<Product> result = typedQuery.getResultList();

// 获取总数
CriteriaQuery<Long> countQuery = cb.createQuery(Long.class);
Root<Product> countRoot = countQuery.from(Product.class);
countQuery.select(cb.count(countRoot)).where(predicates.toArray(new Predicate[0]));
Long total = entityManager.createQuery(countQuery).getSingleResult();

return new PageImpl<>(result, pageable, total);
}
}

🚀 五、业务逻辑层设计

5.1 Service接口与实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public interface ProductService {
ProductDTO createProduct(CreateProductRequest request);
ProductDTO updateProduct(Long id, UpdateProductRequest request);
ProductDTO getProductById(Long id);
ProductDTO getProductBySku(String sku);
Page<ProductDTO> searchProducts(ProductSearchCriteria criteria, Pageable pageable);
void deleteProduct(Long id);
void updateStock(Long id, Integer quantity);
}

@Service
@RequiredArgsConstructor
@Slf4j
public class ProductServiceImpl implements ProductService {

private final ProductRepository productRepository;
private final ProductMapper productMapper;

@Override
@Transactional
public ProductDTO createProduct(CreateProductRequest request) {
log.info("Creating product with SKU: {}", request.getSku());

if (productRepository.existsBySku(request.getSku())) {
throw new BusinessException("Product with SKU " + request.getSku() + " already exists");
}

Product product = productMapper.toEntity(request);
product.setStatus(Product.ProductStatus.ACTIVE);

Product savedProduct = productRepository.save(product);
log.info("Product created successfully with ID: {}", savedProduct.getId());

return productMapper.toDTO(savedProduct);
}

@Override
@Transactional(readOnly = true)
public ProductDTO getProductById(Long id) {
return productRepository.findById(id)
.map(productMapper::toDTO)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
}

@Override
@Transactional
public void updateStock(Long id, Integer quantity) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));

if (product.getStockQuantity() + quantity < 0) {
throw new BusinessException("Insufficient stock");
}

product.setStockQuantity(product.getStockQuantity() + quantity);
productRepository.save(product);

log.info("Updated stock for product {}: {}", id, product.getStockQuantity());
}
}

5.2 DTO与Mapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {
private Long id;
private String sku;
private String name;
private String description;
private BigDecimal price;
private Integer stockQuantity;
private Product.ProductStatus status;
private LocalDateTime createdAt;
}

@Data
public class CreateProductRequest {
@NotBlank(message = "SKU不能为空")
@Size(min = 3, max = 50, message = "SKU长度必须在3-50个字符之间")
private String sku;

@NotBlank(message = "产品名称不能为空")
private String name;

private String description;

@NotNull(message = "价格不能为空")
@DecimalMin(value = "0.0", inclusive = false, message = "价格必须大于0")
private BigDecimal price;

@NotNull(message = "库存数量不能为空")
@Min(value = 0, message = "库存数量不能小于0")
private Integer stockQuantity;
}

@Mapper(componentModel = "spring")
public interface ProductMapper {
ProductDTO toDTO(Product product);
Product toEntity(CreateProductRequest request);
void updateEntity(UpdateProductRequest request, @MappingTarget Product product);
}

🚀 六、控制器层与RESTful API

6.1 REST控制器实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Validated
@Slf4j
public class ProductController {

private final ProductService productService;

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<ProductDTO> createProduct(@Valid @RequestBody CreateProductRequest request) {
log.info("POST /api/v1/products - Creating product: {}", request.getName());
ProductDTO product = productService.createProduct(request);
return ApiResponse.success("Product created successfully", product);
}

@GetMapping("/{id}")
public ApiResponse<ProductDTO> getProduct(@PathVariable Long id) {
log.info("GET /api/v1/products/{}", id);
ProductDTO product = productService.getProductById(id);
return ApiResponse.success(product);
}

@GetMapping
public ApiResponse<Page<ProductDTO>> searchProducts(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(required = false) Product.ProductStatus status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt,desc") String[] sort) {

log.info("GET /api/v1/products - Search products");

ProductSearchCriteria criteria = new ProductSearchCriteria();
criteria.setKeyword(keyword);
criteria.setMinPrice(minPrice);
criteria.setMaxPrice(maxPrice);
criteria.setStatus(status);

Sort.Direction direction = sort[1].equalsIgnoreCase("desc") ?
Sort.Direction.DESC : Sort.Direction.ASC;
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort[0]));

Page<ProductDTO> products = productService.searchProducts(criteria, pageable);
return ApiResponse.success(products);
}

@PutMapping("/{id}")
public ApiResponse<ProductDTO> updateProduct(
@PathVariable Long id,
@Valid @RequestBody UpdateProductRequest request) {
log.info("PUT /api/v1/products/{}", id);
ProductDTO product = productService.updateProduct(id, request);
return ApiResponse.success("Product updated successfully", product);
}

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteProduct(@PathVariable Long id) {
log.info("DELETE /api/v1/products/{}", id);
productService.deleteProduct(id);
}

@PatchMapping("/{id}/stock")
public ApiResponse<Void> updateStock(
@PathVariable Long id,
@RequestParam Integer quantity) {
log.info("PATCH /api/v1/products/{}/stock?quantity={}", id, quantity);
productService.updateStock(id, quantity);
return ApiResponse.success("Stock updated successfully");
}
}

6.2 统一响应格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
private LocalDateTime timestamp;

public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, null, data, LocalDateTime.now());
}

public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(true, message, data, LocalDateTime.now());
}

public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null, LocalDateTime.now());
}
}

七、全局异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> handleResourceNotFoundException(ResourceNotFoundException ex) {
log.warn("Resource not found: {}", ex.getMessage());
return ApiResponse.error(ex.getMessage());
}

@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
log.warn("Business exception: {}", ex.getMessage());
return ApiResponse.error(ex.getMessage());
}

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Map<String, String>> handleValidationExceptions(
MethodArgumentNotValidException ex) {
log.warn("Validation error: {}", ex.getMessage());

Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage()));

return new ApiResponse<>(false, "Validation failed", errors, LocalDateTime.now());
}

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleGenericException(Exception ex) {
log.error("Unexpected error: ", ex);
return ApiResponse.error("An unexpected error occurred. Please try again later.");
}
}

八、测试与验证

8.1 单元测试

@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
    
    @Mock
    private ProductRepository productRepository;
    
    @Mock
    private ProductMapper productMapper;
    
    @InjectMocks
    private ProductServiceImpl productService;
    
    @Test
    void createProduct_shouldSuccess() {
        // Given
        CreateProductRequest request = new CreateProductRequest();
        request.setSku("TEST-001");
        request.setName("Test Product");
        request.setPrice(new BigDecimal("99.99"));
        request.setStockQuantity(100);
        
        Product product = new Product();
        product.setId(1L);
        product.setSku("TEST-001");
        
        when(productRepository.existsBySku("TEST-001")).thenReturn(false);
        when(productMapper.toEntity(request)).thenReturn(product);
        when(productRepository.save(any(Product.class))).thenReturn(product);
        
        // When
        ProductDTO result = productService.createProduct(request);
        


<div class="video-container">
[up主专用,视频内嵌代码贴在这]
</div>

<style>
.video-container {
    position: relative;
    width: 100%;
    padding-top: 56.25%; /* 16:9 aspect ratio */
}

.video-container iframe {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
}
</style>